quantum-framework 0.18.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantum/__init__.py +1 -0
- quantum/cli/__init__.py +2 -0
- quantum/cli/admin.py +131 -0
- quantum/cli/check.py +223 -0
- quantum/cli/commands/__init__.py +15 -0
- quantum/cli/commands/build.py +414 -0
- quantum/cli/commands/dev.py +185 -0
- quantum/cli/commands/docs.py +329 -0
- quantum/cli/commands/lint.py +523 -0
- quantum/cli/commands/migrate.py +548 -0
- quantum/cli/commands/new.py +622 -0
- quantum/cli/commands/serve.py +190 -0
- quantum/cli/commands/test.py +193 -0
- quantum/cli/deploy.py +810 -0
- quantum/cli/hot_reload.py +951 -0
- quantum/cli/jobs.py +356 -0
- quantum/cli/mq.py +582 -0
- quantum/cli/pkg.py +390 -0
- quantum/cli/runner.py +565 -0
- quantum/cli/server_process.py +159 -0
- quantum/cli/utils.py +334 -0
- quantum/compiler/__init__.py +30 -0
- quantum/compiler/base_generator.py +367 -0
- quantum/compiler/cli.py +295 -0
- quantum/compiler/expression_transformer.py +444 -0
- quantum/compiler/javascript/__init__.py +10 -0
- quantum/compiler/javascript/generator.py +659 -0
- quantum/compiler/optimizer.py +270 -0
- quantum/compiler/python/__init__.py +10 -0
- quantum/compiler/python/generator.py +883 -0
- quantum/compiler/python/runtime.py +863 -0
- quantum/compiler/transpiler.py +330 -0
- quantum/core/__init__.py +3 -0
- quantum/core/ast_nodes.py +2619 -0
- quantum/core/config_env.py +54 -0
- quantum/core/docs_links.py +8 -0
- quantum/core/expression_diagnostics.py +87 -0
- quantum/core/expression_stdlib.py +208 -0
- quantum/core/expressions.py +677 -0
- quantum/core/features/agents/src/__init__.py +30 -0
- quantum/core/features/agents/src/ast_node.py +540 -0
- quantum/core/features/conditionals/src/__init__.py +8 -0
- quantum/core/features/conditionals/src/ast_node.py +68 -0
- quantum/core/features/data_import/src/__init__.py +0 -0
- quantum/core/features/data_import/src/ast_node.py +292 -0
- quantum/core/features/data_import/src/runtime.py +533 -0
- quantum/core/features/dump/src/__init__.py +12 -0
- quantum/core/features/dump/src/ast_node.py +106 -0
- quantum/core/features/dump/src/parser.py +61 -0
- quantum/core/features/dump/src/runtime.py +246 -0
- quantum/core/features/functions/src/__init__.py +8 -0
- quantum/core/features/functions/src/ast_node.py +149 -0
- quantum/core/features/game_engine_2d/src/__init__.py +19 -0
- quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
- quantum/core/features/game_engine_2d/src/parser.py +983 -0
- quantum/core/features/invocation/src/__init__.py +0 -0
- quantum/core/features/invocation/src/ast_node.py +147 -0
- quantum/core/features/invocation/src/runtime.py +332 -0
- quantum/core/features/knowledge_base/src/__init__.py +6 -0
- quantum/core/features/knowledge_base/src/ast_node.py +113 -0
- quantum/core/features/knowledge_base/src/parser.py +82 -0
- quantum/core/features/logging/src/__init__.py +12 -0
- quantum/core/features/logging/src/ast_node.py +111 -0
- quantum/core/features/logging/src/parser.py +50 -0
- quantum/core/features/logging/src/runtime.py +190 -0
- quantum/core/features/loops/src/__init__.py +8 -0
- quantum/core/features/loops/src/ast_node.py +60 -0
- quantum/core/features/query/src/__init__.py +0 -0
- quantum/core/features/query/src/query_validators.py +20 -0
- quantum/core/features/state_management/src/__init__.py +11 -0
- quantum/core/features/state_management/src/ast_node.py +121 -0
- quantum/core/features/terminal_engine/src/__init__.py +21 -0
- quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
- quantum/core/features/terminal_engine/src/parser.py +361 -0
- quantum/core/features/testing_engine/src/__init__.py +41 -0
- quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
- quantum/core/features/testing_engine/src/parser.py +604 -0
- quantum/core/features/theming/src/__init__.py +48 -0
- quantum/core/features/theming/src/ast_node.py +137 -0
- quantum/core/features/theming/src/presets.py +405 -0
- quantum/core/features/ui_engine/src/__init__.py +20 -0
- quantum/core/features/ui_engine/src/ast_nodes.py +1913 -0
- quantum/core/features/ui_engine/src/parser.py +1192 -0
- quantum/core/features/websocket/src/__init__.py +24 -0
- quantum/core/features/websocket/src/ast_node.py +247 -0
- quantum/core/guards.py +53 -0
- quantum/core/html_compat.py +299 -0
- quantum/core/parser.py +1551 -0
- quantum/core/parser_registry.py +213 -0
- quantum/core/parsers/__init__.py +76 -0
- quantum/core/parsers/ai/__init__.py +12 -0
- quantum/core/parsers/ai/agent_parser.py +106 -0
- quantum/core/parsers/ai/knowledge_parser.py +86 -0
- quantum/core/parsers/ai/llm_parser.py +78 -0
- quantum/core/parsers/ai/team_parser.py +88 -0
- quantum/core/parsers/base.py +322 -0
- quantum/core/parsers/composition/__init__.py +10 -0
- quantum/core/parsers/composition/import_parser.py +50 -0
- quantum/core/parsers/composition/slot_parser.py +48 -0
- quantum/core/parsers/control_flow/__init__.py +11 -0
- quantum/core/parsers/control_flow/if_parser.py +71 -0
- quantum/core/parsers/control_flow/loop_parser.py +105 -0
- quantum/core/parsers/control_flow/set_parser.py +93 -0
- quantum/core/parsers/data/__init__.py +12 -0
- quantum/core/parsers/data/data_parser.py +229 -0
- quantum/core/parsers/data/invoke_parser.py +133 -0
- quantum/core/parsers/data/query_parser.py +213 -0
- quantum/core/parsers/data/transaction_parser.py +86 -0
- quantum/core/parsers/events/__init__.py +9 -0
- quantum/core/parsers/events/dispatch_event_parser.py +44 -0
- quantum/core/parsers/forms/__init__.py +11 -0
- quantum/core/parsers/forms/action_parser.py +79 -0
- quantum/core/parsers/forms/flash_parser.py +34 -0
- quantum/core/parsers/forms/redirect_parser.py +33 -0
- quantum/core/parsers/functions/__init__.py +11 -0
- quantum/core/parsers/functions/function_parser.py +98 -0
- quantum/core/parsers/functions/param_parser.py +73 -0
- quantum/core/parsers/functions/return_parser.py +31 -0
- quantum/core/parsers/html/__init__.py +10 -0
- quantum/core/parsers/html/component_call_parser.py +130 -0
- quantum/core/parsers/html/html_parser.py +115 -0
- quantum/core/parsers/jobs/__init__.py +11 -0
- quantum/core/parsers/jobs/job_parser.py +71 -0
- quantum/core/parsers/jobs/schedule_parser.py +62 -0
- quantum/core/parsers/jobs/thread_parser.py +57 -0
- quantum/core/parsers/messaging/__init__.py +17 -0
- quantum/core/parsers/messaging/message_ack_parser.py +30 -0
- quantum/core/parsers/messaging/message_nack_parser.py +30 -0
- quantum/core/parsers/messaging/message_parser.py +114 -0
- quantum/core/parsers/messaging/queue_parser.py +61 -0
- quantum/core/parsers/messaging/websocket_parser.py +121 -0
- quantum/core/parsers/routing/__init__.py +9 -0
- quantum/core/parsers/routing/route_parser.py +41 -0
- quantum/core/parsers/scripting/__init__.py +12 -0
- quantum/core/parsers/scripting/pyclass_parser.py +62 -0
- quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
- quantum/core/parsers/scripting/pyimport_parser.py +52 -0
- quantum/core/parsers/scripting/python_parser.py +49 -0
- quantum/core/parsers/services/__init__.py +12 -0
- quantum/core/parsers/services/dump_parser.py +52 -0
- quantum/core/parsers/services/file_parser.py +48 -0
- quantum/core/parsers/services/log_parser.py +46 -0
- quantum/core/parsers/services/mail_parser.py +65 -0
- quantum/core/tiers.py +164 -0
- quantum/core/xml_lines.py +81 -0
- quantum/packages/__init__.py +28 -0
- quantum/packages/manager.py +413 -0
- quantum/packages/manifest.py +351 -0
- quantum/packages/registry.py +399 -0
- quantum/packages/resolver.py +336 -0
- quantum/plugins/__init__.py +33 -0
- quantum/plugins/hooks.py +329 -0
- quantum/plugins/loader.py +479 -0
- quantum/plugins/manifest.py +336 -0
- quantum/plugins/registry.py +371 -0
- quantum/runtime/__init__.py +25 -0
- quantum/runtime/action_handler.py +532 -0
- quantum/runtime/adapters/__init__.py +88 -0
- quantum/runtime/adapters/memory_adapter.py +690 -0
- quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
- quantum/runtime/adapters/redis_adapter.py +582 -0
- quantum/runtime/adapters/sqlite_adapter.py +414 -0
- quantum/runtime/agent_service.py +1133 -0
- quantum/runtime/ast_cache.py +506 -0
- quantum/runtime/auth_service.py +267 -0
- quantum/runtime/component.py +1048 -0
- quantum/runtime/component_composer.py +133 -0
- quantum/runtime/component_resolver.py +174 -0
- quantum/runtime/conditions.py +54 -0
- quantum/runtime/database_service.py +595 -0
- quantum/runtime/db_schema.py +100 -0
- quantum/runtime/dev_panel.py +178 -0
- quantum/runtime/email_service.py +162 -0
- quantum/runtime/error_handler.py +295 -0
- quantum/runtime/execution_context.py +283 -0
- quantum/runtime/executor_registry.py +178 -0
- quantum/runtime/executors/__init__.py +71 -0
- quantum/runtime/executors/ai/__init__.py +12 -0
- quantum/runtime/executors/ai/agent_executor.py +217 -0
- quantum/runtime/executors/ai/knowledge_executor.py +114 -0
- quantum/runtime/executors/ai/llm_executor.py +153 -0
- quantum/runtime/executors/ai/team_executor.py +171 -0
- quantum/runtime/executors/base.py +264 -0
- quantum/runtime/executors/control_flow/__init__.py +12 -0
- quantum/runtime/executors/control_flow/if_executor.py +98 -0
- quantum/runtime/executors/control_flow/loop_executor.py +308 -0
- quantum/runtime/executors/control_flow/redirect_executor.py +43 -0
- quantum/runtime/executors/control_flow/set_executor.py +464 -0
- quantum/runtime/executors/data/__init__.py +12 -0
- quantum/runtime/executors/data/data_executor.py +156 -0
- quantum/runtime/executors/data/invoke_executor.py +228 -0
- quantum/runtime/executors/data/query_executor.py +290 -0
- quantum/runtime/executors/data/transaction_executor.py +91 -0
- quantum/runtime/executors/jobs/__init__.py +11 -0
- quantum/runtime/executors/jobs/job_executor.py +190 -0
- quantum/runtime/executors/jobs/schedule_executor.py +132 -0
- quantum/runtime/executors/jobs/thread_executor.py +127 -0
- quantum/runtime/executors/messaging/__init__.py +17 -0
- quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
- quantum/runtime/executors/messaging/message_executor.py +174 -0
- quantum/runtime/executors/messaging/queue_executor.py +103 -0
- quantum/runtime/executors/messaging/websocket_executor.py +197 -0
- quantum/runtime/executors/scripting/__init__.py +11 -0
- quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
- quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
- quantum/runtime/executors/scripting/python_executor.py +249 -0
- quantum/runtime/executors/services/__init__.py +12 -0
- quantum/runtime/executors/services/dump_executor.py +72 -0
- quantum/runtime/executors/services/file_executor.py +89 -0
- quantum/runtime/executors/services/log_executor.py +77 -0
- quantum/runtime/executors/services/mail_executor.py +81 -0
- quantum/runtime/expression_cache.py +498 -0
- quantum/runtime/file_upload_service.py +326 -0
- quantum/runtime/function_registry.py +118 -0
- quantum/runtime/game_builder.py +166 -0
- quantum/runtime/game_code_generator.py +2371 -0
- quantum/runtime/game_templates.py +2006 -0
- quantum/runtime/godot_code_generator.py +4681 -0
- quantum/runtime/godot_templates.py +1449 -0
- quantum/runtime/job_executor.py +1599 -0
- quantum/runtime/knowledge_service.py +546 -0
- quantum/runtime/llm_cache.py +100 -0
- quantum/runtime/llm_providers.py +704 -0
- quantum/runtime/llm_service.py +287 -0
- quantum/runtime/local_server.py +28 -0
- quantum/runtime/logging_setup.py +141 -0
- quantum/runtime/message_broker.py +364 -0
- quantum/runtime/message_queue_service.py +571 -0
- quantum/runtime/param_validation.py +218 -0
- quantum/runtime/pypy_compat.py +317 -0
- quantum/runtime/python_bridge.py +698 -0
- quantum/runtime/query_validators.py +304 -0
- quantum/runtime/renderer.py +734 -0
- quantum/runtime/service_container.py +470 -0
- quantum/runtime/table_params.py +163 -0
- quantum/runtime/terminal_builder.py +76 -0
- quantum/runtime/terminal_code_generator.py +607 -0
- quantum/runtime/terminal_templates.py +243 -0
- quantum/runtime/testing_builder.py +77 -0
- quantum/runtime/testing_code_generator.py +833 -0
- quantum/runtime/testing_templates.py +85 -0
- quantum/runtime/ui_builder.py +222 -0
- quantum/runtime/ui_console.py +405 -0
- quantum/runtime/ui_desktop.py +56 -0
- quantum/runtime/ui_form_rules.py +193 -0
- quantum/runtime/ui_html_adapter.py +2567 -0
- quantum/runtime/ui_html_templates.py +2106 -0
- quantum/runtime/ui_mobile_adapter.py +1832 -0
- quantum/runtime/ui_mobile_templates.py +1003 -0
- quantum/runtime/ui_page_renderer.py +392 -0
- quantum/runtime/ui_pager.py +73 -0
- quantum/runtime/ui_table_edit.py +139 -0
- quantum/runtime/ui_textual_adapter.py +1866 -0
- quantum/runtime/ui_textual_templates.py +45 -0
- quantum/runtime/ui_tokens.py +465 -0
- quantum/runtime/ui_validator.py +365 -0
- quantum/runtime/ui_view.py +221 -0
- quantum/runtime/validators.py +256 -0
- quantum/runtime/web_server.py +2131 -0
- quantum/runtime/websocket_adapter.py +501 -0
- quantum/runtime/websocket_service.py +585 -0
- quantum/runtime/websocket_transport.py +289 -0
- quantum/runtime/wsgi.py +101 -0
- quantum/services.py +109 -0
- quantum/utils/__init__.py +1 -0
- quantum_admin/__init__.py +4 -0
- quantum_admin/assets/quantum-admin-connectors.js +123 -0
- quantum_admin/assets/quantum-admin.css +2058 -0
- quantum_admin/assets/quantum-source-highlight.js +106 -0
- quantum_admin/backend/__init__.py +4 -0
- quantum_admin/backend/audit_service.py +346 -0
- quantum_admin/backend/auth_service.py +394 -0
- quantum_admin/backend/component_discovery.py +766 -0
- quantum_admin/backend/config_generator.py +586 -0
- quantum_admin/backend/connector_service.py +1016 -0
- quantum_admin/backend/crud.py +557 -0
- quantum_admin/backend/database.py +226 -0
- quantum_admin/backend/db_setup_service.py +193 -0
- quantum_admin/backend/deploy_service.py +698 -0
- quantum_admin/backend/docker_service.py +422 -0
- quantum_admin/backend/environment_service.py +418 -0
- quantum_admin/backend/git_service.py +370 -0
- quantum_admin/backend/health_service.py +390 -0
- quantum_admin/backend/job_service.py +347 -0
- quantum_admin/backend/log_service.py +331 -0
- quantum_admin/backend/main.py +14426 -0
- quantum_admin/backend/models.py +986 -0
- quantum_admin/backend/pipeline_service.py +812 -0
- quantum_admin/backend/resource_manager.py +1118 -0
- quantum_admin/backend/schemas.py +419 -0
- quantum_admin/backend/secret_manager.py +277 -0
- quantum_admin/backend/settings_service.py +488 -0
- quantum_admin/backend/template_service.py +710 -0
- quantum_admin/backend/test_execution_service.py +364 -0
- quantum_admin/backend/test_generator.py +782 -0
- quantum_admin/backend/webhook_service.py +454 -0
- quantum_admin/backend/websocket_manager.py +360 -0
- quantum_admin/components/admin/_layout/AdminShell.q +121 -0
- quantum_admin/components/admin/agents.q +107 -0
- quantum_admin/components/admin/app/[name].q +558 -0
- quantum_admin/components/admin/applications.q +160 -0
- quantum_admin/components/admin/component/[...path].q +205 -0
- quantum_admin/components/admin/components.q +59 -0
- quantum_admin/components/admin/connectors.q +283 -0
- quantum_admin/components/admin/dashboard.q +86 -0
- quantum_admin/components/admin/database.q +95 -0
- quantum_admin/components/admin/features.q +69 -0
- quantum_admin/components/admin/index.q +12 -0
- quantum_admin/components/admin/jobs.q +75 -0
- quantum_admin/components/admin/login.q +65 -0
- quantum_admin/components/admin/logout.q +11 -0
- quantum_admin/components/admin/settings.q +174 -0
- quantum_admin/components/admin/source.q +45 -0
- quantum_admin/components/admin/tests.q +73 -0
- quantum_admin/run.py +13 -0
- quantum_admin/services/__init__.py +16 -0
- quantum_admin/services/_base.py +84 -0
- quantum_admin/services/_component_test_generator.py +205 -0
- quantum_admin/services/_yaml_editor.py +82 -0
- quantum_admin/services/apps.py +330 -0
- quantum_admin/services/auth.py +61 -0
- quantum_admin/services/components.py +210 -0
- quantum_admin/services/connectors.py +164 -0
- quantum_admin/services/projects.py +189 -0
- quantum_admin/services/repository.py +223 -0
- quantum_admin/services/settings.py +150 -0
- quantum_admin/services/yaml_import.py +118 -0
- quantum_framework-0.18.0.dist-info/METADATA +281 -0
- quantum_framework-0.18.0.dist-info/RECORD +333 -0
- quantum_framework-0.18.0.dist-info/WHEEL +5 -0
- quantum_framework-0.18.0.dist-info/entry_points.txt +2 -0
- quantum_framework-0.18.0.dist-info/licenses/LICENSE +21 -0
- quantum_framework-0.18.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,1118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Resource Manager for Quantum Admin
|
|
3
|
+
Centralized management of ports, secrets, services, and configs
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import json
|
|
7
|
+
import socket
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Optional, List, Dict, Any
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from cryptography.fernet import Fernet
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
|
|
15
|
+
from sqlalchemy.orm import Session
|
|
16
|
+
from sqlalchemy.exc import IntegrityError
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from .models import PortAllocation, PortRange, Secret, ServiceRegistry, Project, Environment
|
|
22
|
+
from .database import SessionLocal
|
|
23
|
+
except ImportError:
|
|
24
|
+
from models import PortAllocation, PortRange, Secret, ServiceRegistry, Project, Environment
|
|
25
|
+
from database import SessionLocal
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ResourceExhaustedError(Exception):
|
|
29
|
+
"""Raised when no resources are available"""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PortManager:
|
|
34
|
+
"""Manages port allocation and tracking"""
|
|
35
|
+
|
|
36
|
+
DEFAULT_RANGES = {
|
|
37
|
+
"app": (8000, 8999),
|
|
38
|
+
"database": (5400, 5499),
|
|
39
|
+
"cache": (6370, 6399),
|
|
40
|
+
"web": (3000, 3099),
|
|
41
|
+
"queue": (5670, 5699),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def __init__(self, db: Session):
|
|
45
|
+
self.db = db
|
|
46
|
+
self._ensure_default_ranges()
|
|
47
|
+
|
|
48
|
+
def _ensure_default_ranges(self):
|
|
49
|
+
"""Create default port ranges if they don't exist"""
|
|
50
|
+
for name, (start, end) in self.DEFAULT_RANGES.items():
|
|
51
|
+
existing = self.db.query(PortRange).filter(PortRange.name == name).first()
|
|
52
|
+
if not existing:
|
|
53
|
+
range_obj = PortRange(
|
|
54
|
+
name=name,
|
|
55
|
+
start_port=start,
|
|
56
|
+
end_port=end,
|
|
57
|
+
host='*',
|
|
58
|
+
description=f"Default range for {name} services"
|
|
59
|
+
)
|
|
60
|
+
self.db.add(range_obj)
|
|
61
|
+
try:
|
|
62
|
+
self.db.commit()
|
|
63
|
+
except IntegrityError:
|
|
64
|
+
self.db.rollback()
|
|
65
|
+
|
|
66
|
+
def allocate(
|
|
67
|
+
self,
|
|
68
|
+
service_name: str,
|
|
69
|
+
port_type: str = "app",
|
|
70
|
+
host: str = "localhost",
|
|
71
|
+
project_id: int = None,
|
|
72
|
+
environment_id: int = None,
|
|
73
|
+
preferred_port: int = None
|
|
74
|
+
) -> Optional[PortAllocation]:
|
|
75
|
+
"""
|
|
76
|
+
Allocate an available port for a service.
|
|
77
|
+
|
|
78
|
+
Returns the PortAllocation object, or None if no ports available.
|
|
79
|
+
"""
|
|
80
|
+
# Check if already allocated
|
|
81
|
+
existing = self.db.query(PortAllocation).filter(
|
|
82
|
+
PortAllocation.service_name == service_name,
|
|
83
|
+
PortAllocation.host == host,
|
|
84
|
+
PortAllocation.status != 'released'
|
|
85
|
+
).first()
|
|
86
|
+
if existing:
|
|
87
|
+
logger.info(f"Port already allocated for {service_name}@{host}: {existing.port}")
|
|
88
|
+
return existing
|
|
89
|
+
|
|
90
|
+
# Try preferred port first
|
|
91
|
+
if preferred_port and self.is_available(preferred_port, host):
|
|
92
|
+
return self._do_allocate(
|
|
93
|
+
service_name, preferred_port, port_type, host,
|
|
94
|
+
project_id, environment_id
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Find next available in range
|
|
98
|
+
range_obj = self._get_range(port_type, host)
|
|
99
|
+
if not range_obj:
|
|
100
|
+
logger.error(f"No port range defined for type '{port_type}'")
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
for port in range(range_obj.start_port, range_obj.end_port + 1):
|
|
104
|
+
if self.is_available(port, host):
|
|
105
|
+
return self._do_allocate(
|
|
106
|
+
service_name, port, port_type, host,
|
|
107
|
+
project_id, environment_id
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
logger.error(f"No available ports in {port_type} range ({range_obj.start_port}-{range_obj.end_port}) for host {host}")
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
def _do_allocate(
|
|
114
|
+
self,
|
|
115
|
+
service_name: str,
|
|
116
|
+
port: int,
|
|
117
|
+
port_type: str,
|
|
118
|
+
host: str,
|
|
119
|
+
project_id: int = None,
|
|
120
|
+
environment_id: int = None
|
|
121
|
+
) -> PortAllocation:
|
|
122
|
+
"""Create the allocation record"""
|
|
123
|
+
allocation = PortAllocation(
|
|
124
|
+
service_name=service_name,
|
|
125
|
+
port=port,
|
|
126
|
+
port_type=port_type,
|
|
127
|
+
host=host,
|
|
128
|
+
project_id=project_id,
|
|
129
|
+
environment_id=environment_id,
|
|
130
|
+
status='allocated'
|
|
131
|
+
)
|
|
132
|
+
self.db.add(allocation)
|
|
133
|
+
try:
|
|
134
|
+
self.db.commit()
|
|
135
|
+
logger.info(f"Allocated port {port} for {service_name}@{host}")
|
|
136
|
+
return allocation
|
|
137
|
+
except IntegrityError:
|
|
138
|
+
self.db.rollback()
|
|
139
|
+
raise ResourceExhaustedError(f"Port {port} on {host} is already in use")
|
|
140
|
+
|
|
141
|
+
def release(self, port_or_service: int | str, host: str = "localhost") -> bool:
|
|
142
|
+
"""Release a port allocation by port number or service name"""
|
|
143
|
+
if isinstance(port_or_service, int):
|
|
144
|
+
# Release by port number
|
|
145
|
+
allocation = self.db.query(PortAllocation).filter(
|
|
146
|
+
PortAllocation.port == port_or_service,
|
|
147
|
+
PortAllocation.host == host,
|
|
148
|
+
PortAllocation.status != 'released'
|
|
149
|
+
).first()
|
|
150
|
+
else:
|
|
151
|
+
# Release by service name
|
|
152
|
+
allocation = self.db.query(PortAllocation).filter(
|
|
153
|
+
PortAllocation.service_name == port_or_service,
|
|
154
|
+
PortAllocation.host == host,
|
|
155
|
+
PortAllocation.status != 'released'
|
|
156
|
+
).first()
|
|
157
|
+
|
|
158
|
+
if not allocation:
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
allocation.status = 'released'
|
|
162
|
+
allocation.released_at = datetime.utcnow()
|
|
163
|
+
self.db.commit()
|
|
164
|
+
logger.info(f"Released port {allocation.port}@{host}")
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
def reserve(
|
|
168
|
+
self,
|
|
169
|
+
port: int,
|
|
170
|
+
host: str,
|
|
171
|
+
reason: str,
|
|
172
|
+
service_name: str = None
|
|
173
|
+
) -> bool:
|
|
174
|
+
"""Reserve a specific port (prevents auto-allocation)"""
|
|
175
|
+
# Check if already allocated
|
|
176
|
+
existing = self.db.query(PortAllocation).filter(
|
|
177
|
+
PortAllocation.port == port,
|
|
178
|
+
PortAllocation.host == host,
|
|
179
|
+
PortAllocation.status != 'released'
|
|
180
|
+
).first()
|
|
181
|
+
|
|
182
|
+
if existing:
|
|
183
|
+
if existing.is_reserved:
|
|
184
|
+
return True # Already reserved
|
|
185
|
+
return False # In use by another service
|
|
186
|
+
|
|
187
|
+
# Create reservation
|
|
188
|
+
allocation = PortAllocation(
|
|
189
|
+
service_name=service_name or f"reserved-{port}",
|
|
190
|
+
port=port,
|
|
191
|
+
port_type='reserved',
|
|
192
|
+
host=host,
|
|
193
|
+
status='reserved',
|
|
194
|
+
is_reserved=True,
|
|
195
|
+
reserved_reason=reason
|
|
196
|
+
)
|
|
197
|
+
self.db.add(allocation)
|
|
198
|
+
self.db.commit()
|
|
199
|
+
logger.info(f"Reserved port {port}@{host}: {reason}")
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
def get(self, service_name: str, host: str = "localhost") -> Optional[int]:
|
|
203
|
+
"""Get allocated port for a service"""
|
|
204
|
+
allocation = self.db.query(PortAllocation).filter(
|
|
205
|
+
PortAllocation.service_name == service_name,
|
|
206
|
+
PortAllocation.host == host,
|
|
207
|
+
PortAllocation.status != 'released'
|
|
208
|
+
).first()
|
|
209
|
+
return allocation.port if allocation else None
|
|
210
|
+
|
|
211
|
+
def is_available(self, port: int, host: str = "localhost") -> bool:
|
|
212
|
+
"""Check if port is available (not allocated and not in use)"""
|
|
213
|
+
# Check database
|
|
214
|
+
allocation = self.db.query(PortAllocation).filter(
|
|
215
|
+
PortAllocation.port == port,
|
|
216
|
+
PortAllocation.host == host,
|
|
217
|
+
PortAllocation.status != 'released'
|
|
218
|
+
).first()
|
|
219
|
+
|
|
220
|
+
if allocation:
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
# Check if port is actually in use on localhost
|
|
224
|
+
if host == 'localhost' or host == '127.0.0.1':
|
|
225
|
+
return self._check_port_free_local(port)
|
|
226
|
+
|
|
227
|
+
return True
|
|
228
|
+
|
|
229
|
+
def _check_port_free_local(self, port: int) -> bool:
|
|
230
|
+
"""Check if local port is free using socket"""
|
|
231
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
232
|
+
try:
|
|
233
|
+
s.settimeout(0.5)
|
|
234
|
+
s.bind(('127.0.0.1', port))
|
|
235
|
+
return True
|
|
236
|
+
except OSError:
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
def _get_range(self, port_type: str, host: str) -> Optional[PortRange]:
|
|
240
|
+
"""Get port range for a type"""
|
|
241
|
+
# First try host-specific range
|
|
242
|
+
range_obj = self.db.query(PortRange).filter(
|
|
243
|
+
PortRange.name == port_type,
|
|
244
|
+
PortRange.host == host
|
|
245
|
+
).first()
|
|
246
|
+
|
|
247
|
+
if range_obj:
|
|
248
|
+
return range_obj
|
|
249
|
+
|
|
250
|
+
# Fall back to wildcard range
|
|
251
|
+
return self.db.query(PortRange).filter(
|
|
252
|
+
PortRange.name == port_type,
|
|
253
|
+
PortRange.host == '*'
|
|
254
|
+
).first()
|
|
255
|
+
|
|
256
|
+
def get_ranges(self) -> List[PortRange]:
|
|
257
|
+
"""Get all port ranges"""
|
|
258
|
+
return self.db.query(PortRange).all()
|
|
259
|
+
|
|
260
|
+
def set_range(
|
|
261
|
+
self,
|
|
262
|
+
name: str,
|
|
263
|
+
start: int,
|
|
264
|
+
end: int,
|
|
265
|
+
host: str = "*",
|
|
266
|
+
description: str = None
|
|
267
|
+
) -> PortRange:
|
|
268
|
+
"""Set or update a port range"""
|
|
269
|
+
existing = self.db.query(PortRange).filter(
|
|
270
|
+
PortRange.name == name,
|
|
271
|
+
PortRange.host == host
|
|
272
|
+
).first()
|
|
273
|
+
|
|
274
|
+
if existing:
|
|
275
|
+
existing.start_port = start
|
|
276
|
+
existing.end_port = end
|
|
277
|
+
if description:
|
|
278
|
+
existing.description = description
|
|
279
|
+
else:
|
|
280
|
+
existing = PortRange(
|
|
281
|
+
name=name,
|
|
282
|
+
start_port=start,
|
|
283
|
+
end_port=end,
|
|
284
|
+
host=host,
|
|
285
|
+
description=description or f"Range for {name}"
|
|
286
|
+
)
|
|
287
|
+
self.db.add(existing)
|
|
288
|
+
|
|
289
|
+
self.db.commit()
|
|
290
|
+
return existing
|
|
291
|
+
|
|
292
|
+
def create_range(
|
|
293
|
+
self,
|
|
294
|
+
name: str,
|
|
295
|
+
start_port: int,
|
|
296
|
+
end_port: int,
|
|
297
|
+
host: str = "*",
|
|
298
|
+
description: str = None
|
|
299
|
+
) -> PortRange:
|
|
300
|
+
"""Create a new port range (alias for set_range)"""
|
|
301
|
+
return self.set_range(name, start_port, end_port, host, description)
|
|
302
|
+
|
|
303
|
+
def list_allocations(
|
|
304
|
+
self,
|
|
305
|
+
host: str = None,
|
|
306
|
+
project_id: int = None,
|
|
307
|
+
environment_id: int = None,
|
|
308
|
+
status: str = None,
|
|
309
|
+
include_released: bool = False
|
|
310
|
+
) -> List[PortAllocation]:
|
|
311
|
+
"""List all port allocations with optional filters"""
|
|
312
|
+
query = self.db.query(PortAllocation)
|
|
313
|
+
|
|
314
|
+
if host:
|
|
315
|
+
query = query.filter(PortAllocation.host == host)
|
|
316
|
+
if project_id:
|
|
317
|
+
query = query.filter(PortAllocation.project_id == project_id)
|
|
318
|
+
if environment_id:
|
|
319
|
+
query = query.filter(PortAllocation.environment_id == environment_id)
|
|
320
|
+
if status:
|
|
321
|
+
query = query.filter(PortAllocation.status == status)
|
|
322
|
+
elif not include_released:
|
|
323
|
+
query = query.filter(PortAllocation.status != 'released')
|
|
324
|
+
|
|
325
|
+
return query.order_by(PortAllocation.port).all()
|
|
326
|
+
|
|
327
|
+
def get_usage_stats(self, host: str = None) -> Dict[str, Dict]:
|
|
328
|
+
"""Get port usage statistics by range"""
|
|
329
|
+
ranges = self.get_ranges()
|
|
330
|
+
stats = {}
|
|
331
|
+
|
|
332
|
+
for r in ranges:
|
|
333
|
+
if host and r.host != '*' and r.host != host:
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
total = r.end_port - r.start_port + 1
|
|
337
|
+
used = self.db.query(PortAllocation).filter(
|
|
338
|
+
PortAllocation.port >= r.start_port,
|
|
339
|
+
PortAllocation.port <= r.end_port,
|
|
340
|
+
PortAllocation.status != 'released'
|
|
341
|
+
)
|
|
342
|
+
if host:
|
|
343
|
+
used = used.filter(PortAllocation.host == host)
|
|
344
|
+
|
|
345
|
+
used_count = used.count()
|
|
346
|
+
|
|
347
|
+
stats[r.name] = {
|
|
348
|
+
"range": f"{r.start_port}-{r.end_port}",
|
|
349
|
+
"total": total,
|
|
350
|
+
"used": used_count,
|
|
351
|
+
"available": total - used_count,
|
|
352
|
+
"percent_available": round((total - used_count) / total * 100, 1)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return stats
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class SecretManager:
|
|
359
|
+
"""Manages encrypted secrets"""
|
|
360
|
+
|
|
361
|
+
def __init__(self, db: Session):
|
|
362
|
+
self.db = db
|
|
363
|
+
self._fernet = self._init_encryption()
|
|
364
|
+
|
|
365
|
+
def _init_encryption(self) -> Fernet:
|
|
366
|
+
"""Initialize Fernet encryption with key from env or generate"""
|
|
367
|
+
key = os.environ.get('QUANTUM_ENCRYPTION_KEY')
|
|
368
|
+
if not key:
|
|
369
|
+
# Generate a stable key from a passphrase (NOT SECURE for production)
|
|
370
|
+
passphrase = os.environ.get('QUANTUM_SECRET_PASSPHRASE', 'quantum-admin-default-key')
|
|
371
|
+
key = base64.urlsafe_b64encode(
|
|
372
|
+
hashlib.sha256(passphrase.encode()).digest()
|
|
373
|
+
)
|
|
374
|
+
logger.warning("Using derived encryption key. Set QUANTUM_ENCRYPTION_KEY for production!")
|
|
375
|
+
else:
|
|
376
|
+
key = key.encode() if isinstance(key, str) else key
|
|
377
|
+
|
|
378
|
+
return Fernet(key)
|
|
379
|
+
|
|
380
|
+
def set(
|
|
381
|
+
self,
|
|
382
|
+
key: str,
|
|
383
|
+
value: str,
|
|
384
|
+
scope: str = "global",
|
|
385
|
+
project_id: int = None,
|
|
386
|
+
environment_id: int = None,
|
|
387
|
+
description: str = None
|
|
388
|
+
) -> Secret:
|
|
389
|
+
"""Store an encrypted secret"""
|
|
390
|
+
# Encrypt the value
|
|
391
|
+
encrypted = self._fernet.encrypt(value.encode()).decode()
|
|
392
|
+
|
|
393
|
+
# Check if exists
|
|
394
|
+
existing = self.db.query(Secret).filter(
|
|
395
|
+
Secret.key == key,
|
|
396
|
+
Secret.project_id == project_id,
|
|
397
|
+
Secret.environment_id == environment_id
|
|
398
|
+
).first()
|
|
399
|
+
|
|
400
|
+
if existing:
|
|
401
|
+
existing.encrypted_value = encrypted
|
|
402
|
+
existing.updated_at = datetime.utcnow()
|
|
403
|
+
if description:
|
|
404
|
+
existing.description = description
|
|
405
|
+
else:
|
|
406
|
+
existing = Secret(
|
|
407
|
+
key=key,
|
|
408
|
+
encrypted_value=encrypted,
|
|
409
|
+
scope=scope,
|
|
410
|
+
project_id=project_id,
|
|
411
|
+
environment_id=environment_id,
|
|
412
|
+
description=description
|
|
413
|
+
)
|
|
414
|
+
self.db.add(existing)
|
|
415
|
+
|
|
416
|
+
self.db.commit()
|
|
417
|
+
logger.info(f"Secret '{key}' stored (scope={scope})")
|
|
418
|
+
return existing
|
|
419
|
+
|
|
420
|
+
def get(
|
|
421
|
+
self,
|
|
422
|
+
key: str,
|
|
423
|
+
project_id: int = None,
|
|
424
|
+
environment_id: int = None
|
|
425
|
+
) -> Optional[str]:
|
|
426
|
+
"""Retrieve and decrypt a secret"""
|
|
427
|
+
# Try environment-specific first
|
|
428
|
+
if environment_id:
|
|
429
|
+
secret = self.db.query(Secret).filter(
|
|
430
|
+
Secret.key == key,
|
|
431
|
+
Secret.environment_id == environment_id
|
|
432
|
+
).first()
|
|
433
|
+
if secret:
|
|
434
|
+
return self._decrypt(secret.encrypted_value)
|
|
435
|
+
|
|
436
|
+
# Try project-specific
|
|
437
|
+
if project_id:
|
|
438
|
+
secret = self.db.query(Secret).filter(
|
|
439
|
+
Secret.key == key,
|
|
440
|
+
Secret.project_id == project_id,
|
|
441
|
+
Secret.environment_id == None
|
|
442
|
+
).first()
|
|
443
|
+
if secret:
|
|
444
|
+
return self._decrypt(secret.encrypted_value)
|
|
445
|
+
|
|
446
|
+
# Try global
|
|
447
|
+
secret = self.db.query(Secret).filter(
|
|
448
|
+
Secret.key == key,
|
|
449
|
+
Secret.project_id == None,
|
|
450
|
+
Secret.environment_id == None
|
|
451
|
+
).first()
|
|
452
|
+
if secret:
|
|
453
|
+
return self._decrypt(secret.encrypted_value)
|
|
454
|
+
|
|
455
|
+
return None
|
|
456
|
+
|
|
457
|
+
def _decrypt(self, encrypted_value: str) -> str:
|
|
458
|
+
"""Decrypt a value"""
|
|
459
|
+
return self._fernet.decrypt(encrypted_value.encode()).decode()
|
|
460
|
+
|
|
461
|
+
def list(
|
|
462
|
+
self,
|
|
463
|
+
project_id: int = None,
|
|
464
|
+
environment_id: int = None,
|
|
465
|
+
include_values: bool = False
|
|
466
|
+
) -> List[Dict]:
|
|
467
|
+
"""List secrets (optionally with decrypted values)"""
|
|
468
|
+
query = self.db.query(Secret)
|
|
469
|
+
|
|
470
|
+
if project_id is not None:
|
|
471
|
+
query = query.filter(
|
|
472
|
+
(Secret.project_id == project_id) | (Secret.project_id == None)
|
|
473
|
+
)
|
|
474
|
+
if environment_id is not None:
|
|
475
|
+
query = query.filter(
|
|
476
|
+
(Secret.environment_id == environment_id) | (Secret.environment_id == None)
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
secrets = query.order_by(Secret.scope, Secret.key).all()
|
|
480
|
+
|
|
481
|
+
result = []
|
|
482
|
+
for s in secrets:
|
|
483
|
+
data = s.to_dict(include_value=False)
|
|
484
|
+
if include_values:
|
|
485
|
+
data['value'] = self._decrypt(s.encrypted_value)
|
|
486
|
+
result.append(data)
|
|
487
|
+
|
|
488
|
+
return result
|
|
489
|
+
|
|
490
|
+
def list_secrets(
|
|
491
|
+
self,
|
|
492
|
+
project_id: int = None,
|
|
493
|
+
environment_id: int = None,
|
|
494
|
+
scope: str = None
|
|
495
|
+
) -> List[Secret]:
|
|
496
|
+
"""List secrets as Secret objects with optional filters"""
|
|
497
|
+
query = self.db.query(Secret)
|
|
498
|
+
|
|
499
|
+
if project_id is not None:
|
|
500
|
+
query = query.filter(
|
|
501
|
+
(Secret.project_id == project_id) | (Secret.project_id == None)
|
|
502
|
+
)
|
|
503
|
+
if environment_id is not None:
|
|
504
|
+
query = query.filter(
|
|
505
|
+
(Secret.environment_id == environment_id) | (Secret.environment_id == None)
|
|
506
|
+
)
|
|
507
|
+
if scope:
|
|
508
|
+
query = query.filter(Secret.scope == scope)
|
|
509
|
+
|
|
510
|
+
return query.order_by(Secret.scope, Secret.key).all()
|
|
511
|
+
|
|
512
|
+
def delete(self, secret_id_or_key, project_id: int = None, environment_id: int = None) -> bool:
|
|
513
|
+
"""Delete a secret by id or key"""
|
|
514
|
+
if isinstance(secret_id_or_key, int):
|
|
515
|
+
# Delete by id
|
|
516
|
+
secret = self.db.query(Secret).filter(Secret.id == secret_id_or_key).first()
|
|
517
|
+
else:
|
|
518
|
+
# Delete by key
|
|
519
|
+
query = self.db.query(Secret).filter(Secret.key == secret_id_or_key)
|
|
520
|
+
if project_id is not None:
|
|
521
|
+
query = query.filter(Secret.project_id == project_id)
|
|
522
|
+
if environment_id is not None:
|
|
523
|
+
query = query.filter(Secret.environment_id == environment_id)
|
|
524
|
+
secret = query.first()
|
|
525
|
+
|
|
526
|
+
if secret:
|
|
527
|
+
key = secret.key
|
|
528
|
+
self.db.delete(secret)
|
|
529
|
+
self.db.commit()
|
|
530
|
+
logger.info(f"Secret '{key}' deleted")
|
|
531
|
+
return True
|
|
532
|
+
return False
|
|
533
|
+
|
|
534
|
+
def rotate(
|
|
535
|
+
self,
|
|
536
|
+
secret_id_or_key,
|
|
537
|
+
new_value: str,
|
|
538
|
+
project_id: int = None,
|
|
539
|
+
environment_id: int = None
|
|
540
|
+
) -> bool:
|
|
541
|
+
"""Rotate a secret value by id or key"""
|
|
542
|
+
if isinstance(secret_id_or_key, int):
|
|
543
|
+
# Find by id
|
|
544
|
+
secret = self.db.query(Secret).filter(Secret.id == secret_id_or_key).first()
|
|
545
|
+
else:
|
|
546
|
+
# Find by key
|
|
547
|
+
secret = self.db.query(Secret).filter(
|
|
548
|
+
Secret.key == secret_id_or_key,
|
|
549
|
+
Secret.project_id == project_id,
|
|
550
|
+
Secret.environment_id == environment_id
|
|
551
|
+
).first()
|
|
552
|
+
|
|
553
|
+
if not secret:
|
|
554
|
+
return False
|
|
555
|
+
|
|
556
|
+
secret.encrypted_value = self._fernet.encrypt(new_value.encode()).decode()
|
|
557
|
+
secret.last_rotated = datetime.utcnow()
|
|
558
|
+
secret.updated_at = datetime.utcnow()
|
|
559
|
+
self.db.commit()
|
|
560
|
+
|
|
561
|
+
logger.info(f"Secret '{key}' rotated")
|
|
562
|
+
return True
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
class ServiceRegistryManager:
|
|
566
|
+
"""Manages service discovery registry"""
|
|
567
|
+
|
|
568
|
+
def __init__(self, db: Session):
|
|
569
|
+
self.db = db
|
|
570
|
+
|
|
571
|
+
def register(
|
|
572
|
+
self,
|
|
573
|
+
name: str,
|
|
574
|
+
host: str,
|
|
575
|
+
port: int,
|
|
576
|
+
service_type: str = "app",
|
|
577
|
+
protocol: str = "http",
|
|
578
|
+
health_endpoint: str = "/health",
|
|
579
|
+
health_interval: int = 30,
|
|
580
|
+
project_id: int = None,
|
|
581
|
+
environment_id: int = None,
|
|
582
|
+
metadata: Dict = None
|
|
583
|
+
) -> ServiceRegistry:
|
|
584
|
+
"""Register a service for discovery"""
|
|
585
|
+
existing = self.db.query(ServiceRegistry).filter(
|
|
586
|
+
ServiceRegistry.name == name
|
|
587
|
+
).first()
|
|
588
|
+
|
|
589
|
+
if existing:
|
|
590
|
+
existing.host = host
|
|
591
|
+
existing.port = port
|
|
592
|
+
existing.service_type = service_type
|
|
593
|
+
existing.protocol = protocol
|
|
594
|
+
existing.health_endpoint = health_endpoint
|
|
595
|
+
existing.health_interval = health_interval
|
|
596
|
+
existing.project_id = project_id
|
|
597
|
+
existing.environment_id = environment_id
|
|
598
|
+
existing.metadata_json = json.dumps(metadata) if metadata else None
|
|
599
|
+
existing.updated_at = datetime.utcnow()
|
|
600
|
+
else:
|
|
601
|
+
existing = ServiceRegistry(
|
|
602
|
+
name=name,
|
|
603
|
+
host=host,
|
|
604
|
+
port=port,
|
|
605
|
+
service_type=service_type,
|
|
606
|
+
protocol=protocol,
|
|
607
|
+
health_endpoint=health_endpoint,
|
|
608
|
+
health_interval=health_interval,
|
|
609
|
+
project_id=project_id,
|
|
610
|
+
environment_id=environment_id,
|
|
611
|
+
metadata_json=json.dumps(metadata) if metadata else None
|
|
612
|
+
)
|
|
613
|
+
self.db.add(existing)
|
|
614
|
+
|
|
615
|
+
self.db.commit()
|
|
616
|
+
logger.info(f"Service '{name}' registered at {protocol}://{host}:{port}")
|
|
617
|
+
return existing
|
|
618
|
+
|
|
619
|
+
def discover(
|
|
620
|
+
self,
|
|
621
|
+
service_type: str,
|
|
622
|
+
project_id: int = None,
|
|
623
|
+
environment_id: int = None,
|
|
624
|
+
healthy_only: bool = True
|
|
625
|
+
) -> List[ServiceRegistry]:
|
|
626
|
+
"""Discover services by type"""
|
|
627
|
+
query = self.db.query(ServiceRegistry).filter(
|
|
628
|
+
ServiceRegistry.service_type == service_type
|
|
629
|
+
)
|
|
630
|
+
if project_id:
|
|
631
|
+
query = query.filter(ServiceRegistry.project_id == project_id)
|
|
632
|
+
if environment_id:
|
|
633
|
+
query = query.filter(ServiceRegistry.environment_id == environment_id)
|
|
634
|
+
if healthy_only:
|
|
635
|
+
query = query.filter(ServiceRegistry.health_status == 'healthy')
|
|
636
|
+
return query.all()
|
|
637
|
+
|
|
638
|
+
def discover_by_name(self, name: str, healthy_only: bool = True) -> Optional[ServiceRegistry]:
|
|
639
|
+
"""Discover a service by name"""
|
|
640
|
+
query = self.db.query(ServiceRegistry).filter(ServiceRegistry.name == name)
|
|
641
|
+
if healthy_only:
|
|
642
|
+
query = query.filter(ServiceRegistry.health_status == 'healthy')
|
|
643
|
+
return query.first()
|
|
644
|
+
|
|
645
|
+
def discover_by_type(
|
|
646
|
+
self,
|
|
647
|
+
service_type: str,
|
|
648
|
+
project_id: int = None,
|
|
649
|
+
healthy_only: bool = True
|
|
650
|
+
) -> List[ServiceRegistry]:
|
|
651
|
+
"""Discover all services of a type"""
|
|
652
|
+
return self.discover(service_type, project_id, healthy_only=healthy_only)
|
|
653
|
+
|
|
654
|
+
def deregister(self, name: str) -> bool:
|
|
655
|
+
"""Remove a service from registry by name"""
|
|
656
|
+
service = self.db.query(ServiceRegistry).filter(
|
|
657
|
+
ServiceRegistry.name == name
|
|
658
|
+
).first()
|
|
659
|
+
if service:
|
|
660
|
+
self.db.delete(service)
|
|
661
|
+
self.db.commit()
|
|
662
|
+
logger.info(f"Service '{name}' deregistered")
|
|
663
|
+
return True
|
|
664
|
+
return False
|
|
665
|
+
|
|
666
|
+
def unregister(self, service_id: int) -> bool:
|
|
667
|
+
"""Remove a service from registry by id"""
|
|
668
|
+
service = self.db.query(ServiceRegistry).filter(
|
|
669
|
+
ServiceRegistry.id == service_id
|
|
670
|
+
).first()
|
|
671
|
+
if service:
|
|
672
|
+
name = service.name
|
|
673
|
+
self.db.delete(service)
|
|
674
|
+
self.db.commit()
|
|
675
|
+
logger.info(f"Service '{name}' unregistered")
|
|
676
|
+
return True
|
|
677
|
+
return False
|
|
678
|
+
|
|
679
|
+
def list_all(self) -> List[ServiceRegistry]:
|
|
680
|
+
"""List all registered services"""
|
|
681
|
+
return self.db.query(ServiceRegistry).order_by(
|
|
682
|
+
ServiceRegistry.service_type,
|
|
683
|
+
ServiceRegistry.name
|
|
684
|
+
).all()
|
|
685
|
+
|
|
686
|
+
def list_services(
|
|
687
|
+
self,
|
|
688
|
+
project_id: int = None,
|
|
689
|
+
environment_id: int = None,
|
|
690
|
+
service_type: str = None,
|
|
691
|
+
healthy_only: bool = False
|
|
692
|
+
) -> List[ServiceRegistry]:
|
|
693
|
+
"""List services with optional filters"""
|
|
694
|
+
query = self.db.query(ServiceRegistry)
|
|
695
|
+
|
|
696
|
+
if project_id:
|
|
697
|
+
query = query.filter(ServiceRegistry.project_id == project_id)
|
|
698
|
+
if environment_id:
|
|
699
|
+
query = query.filter(ServiceRegistry.environment_id == environment_id)
|
|
700
|
+
if service_type:
|
|
701
|
+
query = query.filter(ServiceRegistry.service_type == service_type)
|
|
702
|
+
if healthy_only:
|
|
703
|
+
query = query.filter(ServiceRegistry.health_status == 'healthy')
|
|
704
|
+
|
|
705
|
+
return query.order_by(ServiceRegistry.service_type, ServiceRegistry.name).all()
|
|
706
|
+
|
|
707
|
+
async def check_health(self, service_id: int) -> Dict:
|
|
708
|
+
"""Check health of a service by id and return result dict"""
|
|
709
|
+
import httpx
|
|
710
|
+
import time
|
|
711
|
+
|
|
712
|
+
service = self.db.query(ServiceRegistry).filter(
|
|
713
|
+
ServiceRegistry.id == service_id
|
|
714
|
+
).first()
|
|
715
|
+
|
|
716
|
+
if not service:
|
|
717
|
+
return {"status": "error", "message": "Service not found"}
|
|
718
|
+
|
|
719
|
+
if not service.health_endpoint:
|
|
720
|
+
return {"status": "unknown", "message": "No health endpoint configured"}
|
|
721
|
+
|
|
722
|
+
url = f"{service.protocol}://{service.host}:{service.port}{service.health_endpoint}"
|
|
723
|
+
|
|
724
|
+
try:
|
|
725
|
+
start_time = time.time()
|
|
726
|
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
727
|
+
response = await client.get(url)
|
|
728
|
+
response_time = int((time.time() - start_time) * 1000)
|
|
729
|
+
is_healthy = response.status_code == 200
|
|
730
|
+
|
|
731
|
+
service.health_status = 'healthy' if is_healthy else 'unhealthy'
|
|
732
|
+
service.last_health_check = datetime.utcnow()
|
|
733
|
+
service.consecutive_failures = 0 if is_healthy else service.consecutive_failures + 1
|
|
734
|
+
self.db.commit()
|
|
735
|
+
|
|
736
|
+
return {
|
|
737
|
+
"status": service.health_status,
|
|
738
|
+
"response_time": response_time,
|
|
739
|
+
"status_code": response.status_code,
|
|
740
|
+
"url": url
|
|
741
|
+
}
|
|
742
|
+
except Exception as e:
|
|
743
|
+
service.health_status = 'unhealthy'
|
|
744
|
+
service.last_health_check = datetime.utcnow()
|
|
745
|
+
service.consecutive_failures += 1
|
|
746
|
+
self.db.commit()
|
|
747
|
+
logger.warning(f"Health check failed for {service.name}: {e}")
|
|
748
|
+
return {
|
|
749
|
+
"status": "unhealthy",
|
|
750
|
+
"error": str(e),
|
|
751
|
+
"url": url
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async def _health_check_service(self, service: ServiceRegistry) -> bool:
|
|
755
|
+
"""Internal: Check health of a single service"""
|
|
756
|
+
import httpx
|
|
757
|
+
|
|
758
|
+
if not service.health_endpoint:
|
|
759
|
+
return True # No health check configured
|
|
760
|
+
|
|
761
|
+
url = f"{service.protocol}://{service.host}:{service.port}{service.health_endpoint}"
|
|
762
|
+
|
|
763
|
+
try:
|
|
764
|
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
765
|
+
response = await client.get(url)
|
|
766
|
+
is_healthy = response.status_code == 200
|
|
767
|
+
|
|
768
|
+
service.health_status = 'healthy' if is_healthy else 'unhealthy'
|
|
769
|
+
service.last_health_check = datetime.utcnow()
|
|
770
|
+
service.consecutive_failures = 0 if is_healthy else service.consecutive_failures + 1
|
|
771
|
+
self.db.commit()
|
|
772
|
+
|
|
773
|
+
return is_healthy
|
|
774
|
+
except Exception as e:
|
|
775
|
+
service.health_status = 'unhealthy'
|
|
776
|
+
service.last_health_check = datetime.utcnow()
|
|
777
|
+
service.consecutive_failures += 1
|
|
778
|
+
self.db.commit()
|
|
779
|
+
logger.warning(f"Health check failed for {service.name}: {e}")
|
|
780
|
+
return False
|
|
781
|
+
|
|
782
|
+
async def run_health_checks(
|
|
783
|
+
self,
|
|
784
|
+
project_id: int = None,
|
|
785
|
+
environment_id: int = None
|
|
786
|
+
) -> List[Dict]:
|
|
787
|
+
"""Run health checks on services with optional filters"""
|
|
788
|
+
services = self.list_services(
|
|
789
|
+
project_id=project_id,
|
|
790
|
+
environment_id=environment_id
|
|
791
|
+
)
|
|
792
|
+
results = []
|
|
793
|
+
|
|
794
|
+
for service in services:
|
|
795
|
+
is_healthy = await self._health_check_service(service)
|
|
796
|
+
results.append({
|
|
797
|
+
"name": service.name,
|
|
798
|
+
"healthy": is_healthy,
|
|
799
|
+
"status": service.health_status,
|
|
800
|
+
"url": f"{service.protocol}://{service.host}:{service.port}{service.health_endpoint}",
|
|
801
|
+
"last_check": service.last_health_check.isoformat() if service.last_health_check else None
|
|
802
|
+
})
|
|
803
|
+
|
|
804
|
+
return results
|
|
805
|
+
|
|
806
|
+
async def health_check_all(self) -> Dict[str, Dict]:
|
|
807
|
+
"""Run health checks on all registered services"""
|
|
808
|
+
services = self.list_all()
|
|
809
|
+
results = {}
|
|
810
|
+
|
|
811
|
+
for service in services:
|
|
812
|
+
is_healthy = await self._health_check_service(service)
|
|
813
|
+
results[service.name] = {
|
|
814
|
+
"healthy": is_healthy,
|
|
815
|
+
"status": service.health_status,
|
|
816
|
+
"url": f"{service.protocol}://{service.host}:{service.port}{service.health_endpoint}",
|
|
817
|
+
"last_check": service.last_health_check.isoformat() if service.last_health_check else None,
|
|
818
|
+
"failures": service.consecutive_failures
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return results
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
class ResourceDiscovery:
|
|
825
|
+
"""Auto-discovery of system resources"""
|
|
826
|
+
|
|
827
|
+
def __init__(self):
|
|
828
|
+
self._psutil_available = False
|
|
829
|
+
try:
|
|
830
|
+
import psutil
|
|
831
|
+
self._psutil_available = True
|
|
832
|
+
except ImportError:
|
|
833
|
+
logger.warning("psutil not installed - limited resource discovery")
|
|
834
|
+
|
|
835
|
+
def scan_ports_in_use(self, host: str = "localhost") -> List[Dict]:
|
|
836
|
+
"""Scan for ports currently in use on the system"""
|
|
837
|
+
if not self._psutil_available:
|
|
838
|
+
return self._scan_ports_socket(host)
|
|
839
|
+
|
|
840
|
+
import psutil
|
|
841
|
+
ports = []
|
|
842
|
+
|
|
843
|
+
for conn in psutil.net_connections(kind='inet'):
|
|
844
|
+
if conn.status == 'LISTEN' and conn.laddr:
|
|
845
|
+
port_info = {
|
|
846
|
+
"port": conn.laddr.port,
|
|
847
|
+
"host": conn.laddr.ip or "0.0.0.0",
|
|
848
|
+
"pid": conn.pid,
|
|
849
|
+
"status": conn.status
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
# Try to get process name
|
|
853
|
+
if conn.pid:
|
|
854
|
+
try:
|
|
855
|
+
proc = psutil.Process(conn.pid)
|
|
856
|
+
port_info["process_name"] = proc.name()
|
|
857
|
+
port_info["process_cmdline"] = " ".join(proc.cmdline()[:3])
|
|
858
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
859
|
+
pass
|
|
860
|
+
|
|
861
|
+
ports.append(port_info)
|
|
862
|
+
|
|
863
|
+
return sorted(ports, key=lambda x: x["port"])
|
|
864
|
+
|
|
865
|
+
def _scan_ports_socket(self, host: str) -> List[Dict]:
|
|
866
|
+
"""Without psutil there is no port list, and no scan either.
|
|
867
|
+
|
|
868
|
+
This used to connect_ex to ports 1-9999 one at a time with a 0.1s
|
|
869
|
+
timeout. On Linux a closed localhost port refuses at once, so nobody
|
|
870
|
+
noticed; on Windows each attempt waits out the timeout — measured at
|
|
871
|
+
~109 ms a port, about 18 MINUTES for a single /api/resources/overview
|
|
872
|
+
request. It hung the admin test suite on any machine without psutil,
|
|
873
|
+
and it was a port scan of the host besides. psutil is declared in
|
|
874
|
+
backend/requirements.txt; without it, the overview reports no ports.
|
|
875
|
+
"""
|
|
876
|
+
return []
|
|
877
|
+
|
|
878
|
+
def detect_quantum_processes(self) -> List[Dict]:
|
|
879
|
+
"""Detect running Quantum framework processes"""
|
|
880
|
+
if not self._psutil_available:
|
|
881
|
+
return []
|
|
882
|
+
|
|
883
|
+
import psutil
|
|
884
|
+
quantum_procs = []
|
|
885
|
+
|
|
886
|
+
for proc in psutil.process_iter(['pid', 'name', 'cmdline', 'create_time']):
|
|
887
|
+
try:
|
|
888
|
+
cmdline = proc.info.get('cmdline', [])
|
|
889
|
+
if cmdline and any('quantum' in str(arg).lower() for arg in cmdline):
|
|
890
|
+
quantum_procs.append({
|
|
891
|
+
"pid": proc.info['pid'],
|
|
892
|
+
"name": proc.info['name'],
|
|
893
|
+
"cmdline": " ".join(cmdline[:5]),
|
|
894
|
+
"started": datetime.fromtimestamp(proc.info['create_time']).isoformat()
|
|
895
|
+
})
|
|
896
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
897
|
+
continue
|
|
898
|
+
|
|
899
|
+
return quantum_procs
|
|
900
|
+
|
|
901
|
+
def get_docker_containers(self, docker_client=None) -> List[Dict]:
|
|
902
|
+
"""List Docker containers if available"""
|
|
903
|
+
containers = []
|
|
904
|
+
|
|
905
|
+
try:
|
|
906
|
+
import docker
|
|
907
|
+
client = docker_client or docker.from_env()
|
|
908
|
+
|
|
909
|
+
for container in client.containers.list(all=True):
|
|
910
|
+
ports = {}
|
|
911
|
+
if container.ports:
|
|
912
|
+
for internal, external in container.ports.items():
|
|
913
|
+
if external:
|
|
914
|
+
ports[internal] = external[0]['HostPort'] if external else None
|
|
915
|
+
|
|
916
|
+
containers.append({
|
|
917
|
+
"id": container.short_id,
|
|
918
|
+
"name": container.name,
|
|
919
|
+
"image": container.image.tags[0] if container.image.tags else "unknown",
|
|
920
|
+
"status": container.status,
|
|
921
|
+
"ports": ports,
|
|
922
|
+
"created": container.attrs.get('Created', '')[:19]
|
|
923
|
+
})
|
|
924
|
+
except ImportError:
|
|
925
|
+
logger.warning("Docker SDK not installed")
|
|
926
|
+
except Exception as e:
|
|
927
|
+
logger.warning(f"Docker not available: {e}")
|
|
928
|
+
|
|
929
|
+
return containers
|
|
930
|
+
|
|
931
|
+
def get_system_resources(self) -> Dict:
|
|
932
|
+
"""Get system resource usage"""
|
|
933
|
+
if not self._psutil_available:
|
|
934
|
+
return {"available": False}
|
|
935
|
+
|
|
936
|
+
import psutil
|
|
937
|
+
|
|
938
|
+
return {
|
|
939
|
+
"available": True,
|
|
940
|
+
"cpu_percent": psutil.cpu_percent(interval=0.1),
|
|
941
|
+
"cpu_count": psutil.cpu_count(),
|
|
942
|
+
"memory": {
|
|
943
|
+
"total": psutil.virtual_memory().total,
|
|
944
|
+
"available": psutil.virtual_memory().available,
|
|
945
|
+
"percent": psutil.virtual_memory().percent
|
|
946
|
+
},
|
|
947
|
+
"disk": {
|
|
948
|
+
"total": psutil.disk_usage('/').total,
|
|
949
|
+
"free": psutil.disk_usage('/').free,
|
|
950
|
+
"percent": psutil.disk_usage('/').percent
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
def get_overview(self, docker_client=None) -> Dict:
|
|
955
|
+
"""Get complete resource overview"""
|
|
956
|
+
return {
|
|
957
|
+
"ports_in_use": self.scan_ports_in_use(),
|
|
958
|
+
"quantum_processes": self.detect_quantum_processes(),
|
|
959
|
+
"docker_containers": self.get_docker_containers(docker_client),
|
|
960
|
+
"system": self.get_system_resources(),
|
|
961
|
+
"scanned_at": datetime.utcnow().isoformat()
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
class ResourceManager:
|
|
966
|
+
"""Centralized resource management service"""
|
|
967
|
+
|
|
968
|
+
def __init__(self, db: Session):
|
|
969
|
+
self.db = db
|
|
970
|
+
self.port_manager = PortManager(db)
|
|
971
|
+
self.secret_manager = SecretManager(db)
|
|
972
|
+
self.service_registry = ServiceRegistryManager(db)
|
|
973
|
+
self.discovery = ResourceDiscovery()
|
|
974
|
+
|
|
975
|
+
# Property aliases for API convenience
|
|
976
|
+
@property
|
|
977
|
+
def ports(self) -> PortManager:
|
|
978
|
+
return self.port_manager
|
|
979
|
+
|
|
980
|
+
@property
|
|
981
|
+
def secrets(self) -> SecretManager:
|
|
982
|
+
return self.secret_manager
|
|
983
|
+
|
|
984
|
+
@property
|
|
985
|
+
def services(self) -> 'ServiceRegistryManager':
|
|
986
|
+
return self.service_registry
|
|
987
|
+
|
|
988
|
+
# Port Management
|
|
989
|
+
def allocate_port(self, *args, **kwargs) -> int:
|
|
990
|
+
return self.port_manager.allocate(*args, **kwargs)
|
|
991
|
+
|
|
992
|
+
def release_port(self, *args, **kwargs) -> bool:
|
|
993
|
+
return self.port_manager.release(*args, **kwargs)
|
|
994
|
+
|
|
995
|
+
def reserve_port(self, *args, **kwargs) -> bool:
|
|
996
|
+
return self.port_manager.reserve(*args, **kwargs)
|
|
997
|
+
|
|
998
|
+
def get_port(self, *args, **kwargs) -> Optional[int]:
|
|
999
|
+
return self.port_manager.get(*args, **kwargs)
|
|
1000
|
+
|
|
1001
|
+
def list_allocations(self, *args, **kwargs) -> List[PortAllocation]:
|
|
1002
|
+
return self.port_manager.list_allocations(*args, **kwargs)
|
|
1003
|
+
|
|
1004
|
+
def check_port_available(self, port: int, host: str = "localhost") -> bool:
|
|
1005
|
+
return self.port_manager.is_available(port, host)
|
|
1006
|
+
|
|
1007
|
+
def get_port_ranges(self) -> List[PortRange]:
|
|
1008
|
+
return self.port_manager.get_ranges()
|
|
1009
|
+
|
|
1010
|
+
def get_port_usage(self, host: str = None) -> Dict:
|
|
1011
|
+
return self.port_manager.get_usage_stats(host)
|
|
1012
|
+
|
|
1013
|
+
# Secret Management
|
|
1014
|
+
def set_secret(self, *args, **kwargs) -> Secret:
|
|
1015
|
+
return self.secret_manager.set(*args, **kwargs)
|
|
1016
|
+
|
|
1017
|
+
def get_secret(self, *args, **kwargs) -> Optional[str]:
|
|
1018
|
+
return self.secret_manager.get(*args, **kwargs)
|
|
1019
|
+
|
|
1020
|
+
def list_secrets(self, *args, **kwargs) -> List[Dict]:
|
|
1021
|
+
return self.secret_manager.list(*args, **kwargs)
|
|
1022
|
+
|
|
1023
|
+
def delete_secret(self, *args, **kwargs) -> bool:
|
|
1024
|
+
return self.secret_manager.delete(*args, **kwargs)
|
|
1025
|
+
|
|
1026
|
+
def rotate_secret(self, *args, **kwargs) -> bool:
|
|
1027
|
+
return self.secret_manager.rotate(*args, **kwargs)
|
|
1028
|
+
|
|
1029
|
+
# Service Registry
|
|
1030
|
+
def register_service(self, *args, **kwargs) -> ServiceRegistry:
|
|
1031
|
+
return self.service_registry.register(*args, **kwargs)
|
|
1032
|
+
|
|
1033
|
+
def discover(self, *args, **kwargs) -> Optional[ServiceRegistry]:
|
|
1034
|
+
return self.service_registry.discover(*args, **kwargs)
|
|
1035
|
+
|
|
1036
|
+
def discover_by_type(self, *args, **kwargs) -> List[ServiceRegistry]:
|
|
1037
|
+
return self.service_registry.discover_by_type(*args, **kwargs)
|
|
1038
|
+
|
|
1039
|
+
def deregister_service(self, name: str) -> bool:
|
|
1040
|
+
return self.service_registry.deregister(name)
|
|
1041
|
+
|
|
1042
|
+
async def health_check_all(self) -> Dict[str, Dict]:
|
|
1043
|
+
return await self.service_registry.health_check_all()
|
|
1044
|
+
|
|
1045
|
+
# Resource Discovery
|
|
1046
|
+
def scan_resources(self, docker_client=None) -> Dict:
|
|
1047
|
+
"""Perform full resource scan"""
|
|
1048
|
+
return self.discovery.get_overview(docker_client)
|
|
1049
|
+
|
|
1050
|
+
def scan_ports(self, host: str = "localhost") -> List[Dict]:
|
|
1051
|
+
"""Scan ports in use"""
|
|
1052
|
+
return self.discovery.scan_ports_in_use(host)
|
|
1053
|
+
|
|
1054
|
+
def scan_processes(self) -> List[Dict]:
|
|
1055
|
+
"""Scan for Quantum processes"""
|
|
1056
|
+
return self.discovery.detect_quantum_processes()
|
|
1057
|
+
|
|
1058
|
+
def scan_containers(self, docker_client=None) -> List[Dict]:
|
|
1059
|
+
"""Scan Docker containers"""
|
|
1060
|
+
return self.discovery.get_docker_containers(docker_client)
|
|
1061
|
+
|
|
1062
|
+
def get_system_resources(self) -> Dict:
|
|
1063
|
+
"""Get system resource usage"""
|
|
1064
|
+
return self.discovery.get_system_resources()
|
|
1065
|
+
|
|
1066
|
+
def sync_discovered_ports(self, docker_client=None) -> Dict:
|
|
1067
|
+
"""
|
|
1068
|
+
Sync discovered ports with database allocations.
|
|
1069
|
+
This reconciles what's actually running with what's tracked.
|
|
1070
|
+
"""
|
|
1071
|
+
discovered = self.discovery.scan_ports_in_use()
|
|
1072
|
+
allocations = self.port_manager.list_allocations(include_released=False)
|
|
1073
|
+
|
|
1074
|
+
allocated_ports = {a.port: a for a in allocations}
|
|
1075
|
+
discovered_ports = {p['port']: p for p in discovered}
|
|
1076
|
+
|
|
1077
|
+
# Find orphaned allocations (tracked but not running)
|
|
1078
|
+
orphaned = []
|
|
1079
|
+
for port, alloc in allocated_ports.items():
|
|
1080
|
+
if port not in discovered_ports:
|
|
1081
|
+
orphaned.append({
|
|
1082
|
+
"port": port,
|
|
1083
|
+
"service": alloc.service_name,
|
|
1084
|
+
"status": "not_running"
|
|
1085
|
+
})
|
|
1086
|
+
|
|
1087
|
+
# Find untracked ports (running but not allocated)
|
|
1088
|
+
untracked = []
|
|
1089
|
+
for port, info in discovered_ports.items():
|
|
1090
|
+
if port not in allocated_ports and 1024 < port < 65535:
|
|
1091
|
+
untracked.append({
|
|
1092
|
+
"port": port,
|
|
1093
|
+
"process": info.get("process_name", "unknown"),
|
|
1094
|
+
"status": "untracked"
|
|
1095
|
+
})
|
|
1096
|
+
|
|
1097
|
+
return {
|
|
1098
|
+
"tracked": len(allocations),
|
|
1099
|
+
"discovered": len(discovered),
|
|
1100
|
+
"orphaned": orphaned,
|
|
1101
|
+
"untracked": untracked[:50], # Limit to 50
|
|
1102
|
+
"synced_at": datetime.utcnow().isoformat()
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
# Singleton instance
|
|
1107
|
+
_resource_manager = None
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def get_resource_manager(db: Session = None) -> ResourceManager:
|
|
1111
|
+
"""Get resource manager instance"""
|
|
1112
|
+
if db:
|
|
1113
|
+
return ResourceManager(db)
|
|
1114
|
+
|
|
1115
|
+
global _resource_manager
|
|
1116
|
+
if _resource_manager is None:
|
|
1117
|
+
_resource_manager = ResourceManager(SessionLocal())
|
|
1118
|
+
return _resource_manager
|