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,1016 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Connector Service for Quantum Admin
|
|
3
|
+
Manages infrastructure connectors (databases, queues, cache, storage, AI)
|
|
4
|
+
with YAML persistence and Docker auto-provisioning
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import yaml
|
|
8
|
+
import uuid
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
from dataclasses import dataclass, field, asdict
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from enum import Enum
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Base paths
|
|
19
|
+
# QUANTUM_ADMIN_SETTINGS_DIR moves these files, as QUANTUM_ADMIN_DATABASE_URL
|
|
20
|
+
# moves the database: the test suite points both at a temporary directory
|
|
21
|
+
# before importing the backend, so no test run writes the owner's settings.
|
|
22
|
+
SETTINGS_DIR = Path(os.environ.get("QUANTUM_ADMIN_SETTINGS_DIR") or Path(__file__).parent.parent / "settings")
|
|
23
|
+
CONNECTORS_FILE = SETTINGS_DIR / "connectors.yaml"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConnectorFileError(RuntimeError):
|
|
27
|
+
"""connectors.yaml could not be loaded; nothing may be written over it."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConnectorType(str, Enum):
|
|
31
|
+
DATABASE = "database"
|
|
32
|
+
MESSAGE_QUEUE = "mq"
|
|
33
|
+
CACHE = "cache"
|
|
34
|
+
STORAGE = "storage"
|
|
35
|
+
AI = "ai"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConnectorStatus(str, Enum):
|
|
39
|
+
UNKNOWN = "unknown"
|
|
40
|
+
CONNECTED = "connected"
|
|
41
|
+
DISCONNECTED = "disconnected"
|
|
42
|
+
ERROR = "error"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Provider configurations
|
|
46
|
+
PROVIDER_CONFIGS = {
|
|
47
|
+
# Databases
|
|
48
|
+
"postgres": {
|
|
49
|
+
"type": ConnectorType.DATABASE,
|
|
50
|
+
"default_port": 5432,
|
|
51
|
+
"docker_image": "postgres:16-alpine",
|
|
52
|
+
"env_vars": ["POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_DB"],
|
|
53
|
+
"test_command": "SELECT 1"
|
|
54
|
+
},
|
|
55
|
+
"mysql": {
|
|
56
|
+
"type": ConnectorType.DATABASE,
|
|
57
|
+
"default_port": 3306,
|
|
58
|
+
"docker_image": "mysql:8.0",
|
|
59
|
+
"env_vars": ["MYSQL_ROOT_PASSWORD", "MYSQL_DATABASE", "MYSQL_USER", "MYSQL_PASSWORD"],
|
|
60
|
+
"test_command": "SELECT 1"
|
|
61
|
+
},
|
|
62
|
+
"mariadb": {
|
|
63
|
+
"type": ConnectorType.DATABASE,
|
|
64
|
+
"default_port": 3306,
|
|
65
|
+
"docker_image": "mariadb:11.2",
|
|
66
|
+
"env_vars": ["MARIADB_ROOT_PASSWORD", "MARIADB_DATABASE", "MARIADB_USER", "MARIADB_PASSWORD"],
|
|
67
|
+
"test_command": "SELECT 1"
|
|
68
|
+
},
|
|
69
|
+
"mongodb": {
|
|
70
|
+
"type": ConnectorType.DATABASE,
|
|
71
|
+
"default_port": 27017,
|
|
72
|
+
"docker_image": "mongo:7.0",
|
|
73
|
+
"env_vars": ["MONGO_INITDB_ROOT_USERNAME", "MONGO_INITDB_ROOT_PASSWORD"],
|
|
74
|
+
"test_command": "db.runCommand({ping: 1})"
|
|
75
|
+
},
|
|
76
|
+
"sqlite": {
|
|
77
|
+
"type": ConnectorType.DATABASE,
|
|
78
|
+
"default_port": 0,
|
|
79
|
+
"docker_image": None,
|
|
80
|
+
"env_vars": [],
|
|
81
|
+
"test_command": "SELECT 1"
|
|
82
|
+
},
|
|
83
|
+
# Message Queues
|
|
84
|
+
"rabbitmq": {
|
|
85
|
+
"type": ConnectorType.MESSAGE_QUEUE,
|
|
86
|
+
"default_port": 5672,
|
|
87
|
+
"docker_image": "rabbitmq:3-management",
|
|
88
|
+
"env_vars": ["RABBITMQ_DEFAULT_USER", "RABBITMQ_DEFAULT_PASS"],
|
|
89
|
+
"management_port": 15672
|
|
90
|
+
},
|
|
91
|
+
"redis-queue": {
|
|
92
|
+
"type": ConnectorType.MESSAGE_QUEUE,
|
|
93
|
+
"default_port": 6379,
|
|
94
|
+
"docker_image": "redis:7-alpine",
|
|
95
|
+
"env_vars": []
|
|
96
|
+
},
|
|
97
|
+
"kafka": {
|
|
98
|
+
"type": ConnectorType.MESSAGE_QUEUE,
|
|
99
|
+
"default_port": 9092,
|
|
100
|
+
"docker_image": "confluentinc/cp-kafka:7.5.0",
|
|
101
|
+
"env_vars": ["KAFKA_BROKER_ID", "KAFKA_ZOOKEEPER_CONNECT"]
|
|
102
|
+
},
|
|
103
|
+
# Cache
|
|
104
|
+
"redis": {
|
|
105
|
+
"type": ConnectorType.CACHE,
|
|
106
|
+
"default_port": 6379,
|
|
107
|
+
"docker_image": "redis:7-alpine",
|
|
108
|
+
"env_vars": []
|
|
109
|
+
},
|
|
110
|
+
"memcached": {
|
|
111
|
+
"type": ConnectorType.CACHE,
|
|
112
|
+
"default_port": 11211,
|
|
113
|
+
"docker_image": "memcached:1.6-alpine",
|
|
114
|
+
"env_vars": []
|
|
115
|
+
},
|
|
116
|
+
# Storage
|
|
117
|
+
"s3": {
|
|
118
|
+
"type": ConnectorType.STORAGE,
|
|
119
|
+
"default_port": 443,
|
|
120
|
+
"docker_image": None,
|
|
121
|
+
"env_vars": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]
|
|
122
|
+
},
|
|
123
|
+
"minio": {
|
|
124
|
+
"type": ConnectorType.STORAGE,
|
|
125
|
+
"default_port": 9000,
|
|
126
|
+
"docker_image": "minio/minio",
|
|
127
|
+
"env_vars": ["MINIO_ROOT_USER", "MINIO_ROOT_PASSWORD"],
|
|
128
|
+
"console_port": 9001
|
|
129
|
+
},
|
|
130
|
+
"local": {
|
|
131
|
+
"type": ConnectorType.STORAGE,
|
|
132
|
+
"default_port": 0,
|
|
133
|
+
"docker_image": None,
|
|
134
|
+
"env_vars": []
|
|
135
|
+
},
|
|
136
|
+
# AI Providers
|
|
137
|
+
"ollama": {
|
|
138
|
+
"type": ConnectorType.AI,
|
|
139
|
+
"default_port": 11434,
|
|
140
|
+
"docker_image": "ollama/ollama",
|
|
141
|
+
"env_vars": [],
|
|
142
|
+
"default_endpoint": "http://localhost:11434"
|
|
143
|
+
},
|
|
144
|
+
"lmstudio": {
|
|
145
|
+
"type": ConnectorType.AI,
|
|
146
|
+
"default_port": 1234,
|
|
147
|
+
"docker_image": None,
|
|
148
|
+
"env_vars": [],
|
|
149
|
+
"default_endpoint": "http://localhost:1234/v1"
|
|
150
|
+
},
|
|
151
|
+
"anthropic": {
|
|
152
|
+
"type": ConnectorType.AI,
|
|
153
|
+
"default_port": 443,
|
|
154
|
+
"docker_image": None,
|
|
155
|
+
"env_vars": ["ANTHROPIC_API_KEY"],
|
|
156
|
+
"default_endpoint": "https://api.anthropic.com"
|
|
157
|
+
},
|
|
158
|
+
"openai": {
|
|
159
|
+
"type": ConnectorType.AI,
|
|
160
|
+
"default_port": 443,
|
|
161
|
+
"docker_image": None,
|
|
162
|
+
"env_vars": ["OPENAI_API_KEY"],
|
|
163
|
+
"default_endpoint": "https://api.openai.com/v1"
|
|
164
|
+
},
|
|
165
|
+
"openrouter": {
|
|
166
|
+
"type": ConnectorType.AI,
|
|
167
|
+
"default_port": 443,
|
|
168
|
+
"docker_image": None,
|
|
169
|
+
"env_vars": ["OPENROUTER_API_KEY"],
|
|
170
|
+
"default_endpoint": "https://openrouter.ai/api/v1"
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class Connector:
|
|
177
|
+
"""Represents an infrastructure connector"""
|
|
178
|
+
id: str
|
|
179
|
+
name: str
|
|
180
|
+
type: str # database, mq, cache, storage, ai
|
|
181
|
+
provider: str # postgres, mysql, rabbitmq, redis, s3, ollama, etc.
|
|
182
|
+
host: str = "localhost"
|
|
183
|
+
port: int = 0
|
|
184
|
+
username: str = ""
|
|
185
|
+
password: str = "" # Stored encrypted
|
|
186
|
+
database: str = "" # or bucket, vhost, etc.
|
|
187
|
+
options: Dict[str, Any] = field(default_factory=dict)
|
|
188
|
+
is_default: bool = False
|
|
189
|
+
docker_auto: bool = False
|
|
190
|
+
docker_image: str = ""
|
|
191
|
+
docker_container_id: str = ""
|
|
192
|
+
status: str = "unknown"
|
|
193
|
+
last_tested: Optional[str] = None
|
|
194
|
+
created_at: str = ""
|
|
195
|
+
updated_at: str = ""
|
|
196
|
+
# Scope: application_id = None means public (available to all apps)
|
|
197
|
+
# application_id = <id> means exclusive to that application
|
|
198
|
+
application_id: Optional[int] = None
|
|
199
|
+
scope: str = "public" # "public" or "application"
|
|
200
|
+
|
|
201
|
+
def __post_init__(self):
|
|
202
|
+
if not self.id:
|
|
203
|
+
self.id = str(uuid.uuid4())[:8]
|
|
204
|
+
if not self.created_at:
|
|
205
|
+
self.created_at = datetime.utcnow().isoformat()
|
|
206
|
+
if not self.updated_at:
|
|
207
|
+
self.updated_at = self.created_at
|
|
208
|
+
if not self.port and self.provider in PROVIDER_CONFIGS:
|
|
209
|
+
self.port = PROVIDER_CONFIGS[self.provider]["default_port"]
|
|
210
|
+
if not self.docker_image and self.provider in PROVIDER_CONFIGS:
|
|
211
|
+
self.docker_image = PROVIDER_CONFIGS[self.provider].get("docker_image", "")
|
|
212
|
+
|
|
213
|
+
def to_dict(self) -> Dict:
|
|
214
|
+
"""Convert to dictionary, excluding sensitive data"""
|
|
215
|
+
data = asdict(self)
|
|
216
|
+
# Mask password for safe output
|
|
217
|
+
if data.get("password"):
|
|
218
|
+
data["password_masked"] = "***" + data["password"][-4:] if len(data["password"]) > 4 else "****"
|
|
219
|
+
return data
|
|
220
|
+
|
|
221
|
+
def to_safe_dict(self) -> Dict:
|
|
222
|
+
"""Convert to dictionary without password"""
|
|
223
|
+
data = self.to_dict()
|
|
224
|
+
data.pop("password", None)
|
|
225
|
+
return data
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class ConnectorService:
|
|
229
|
+
"""Service for managing infrastructure connectors"""
|
|
230
|
+
|
|
231
|
+
def __init__(self):
|
|
232
|
+
self._ensure_directories()
|
|
233
|
+
self._connectors_cache: Optional[List[Connector]] = None
|
|
234
|
+
self._cache_time: Optional[datetime] = None
|
|
235
|
+
self._cache_ttl = 5 # seconds
|
|
236
|
+
self._secret_manager = None
|
|
237
|
+
|
|
238
|
+
def _ensure_directories(self):
|
|
239
|
+
"""Ensure settings directories exist"""
|
|
240
|
+
SETTINGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
|
|
242
|
+
def _get_secret_manager(self):
|
|
243
|
+
"""Lazy load secret manager"""
|
|
244
|
+
if self._secret_manager is None:
|
|
245
|
+
try:
|
|
246
|
+
from .secret_manager import secret_manager
|
|
247
|
+
except ImportError:
|
|
248
|
+
from secret_manager import secret_manager
|
|
249
|
+
self._secret_manager = secret_manager
|
|
250
|
+
return self._secret_manager
|
|
251
|
+
|
|
252
|
+
def _encrypt_password(self, password: str) -> str:
|
|
253
|
+
"""Encrypt password for storage"""
|
|
254
|
+
if not password:
|
|
255
|
+
return ""
|
|
256
|
+
try:
|
|
257
|
+
return self._get_secret_manager().encrypt(password)
|
|
258
|
+
except Exception as e:
|
|
259
|
+
# Fail closed. This returned the plain password, which was then
|
|
260
|
+
# written to connectors.yaml as if it were encrypted.
|
|
261
|
+
logger.error(f"Failed to encrypt password: {e}")
|
|
262
|
+
raise ValueError(f"could not encrypt the connector password: {e}") from e
|
|
263
|
+
|
|
264
|
+
def _decrypt_password(self, encrypted: str) -> str:
|
|
265
|
+
"""Decrypt password from storage"""
|
|
266
|
+
if not encrypted:
|
|
267
|
+
return ""
|
|
268
|
+
try:
|
|
269
|
+
return self._get_secret_manager().decrypt(encrypted)
|
|
270
|
+
except Exception as e:
|
|
271
|
+
logger.error(f"Failed to decrypt password: {e}")
|
|
272
|
+
return ""
|
|
273
|
+
|
|
274
|
+
# =========================================================================
|
|
275
|
+
# CRUD Operations
|
|
276
|
+
# =========================================================================
|
|
277
|
+
|
|
278
|
+
def list_connectors(
|
|
279
|
+
self,
|
|
280
|
+
connector_type: Optional[str] = None,
|
|
281
|
+
application_id: Optional[int] = None,
|
|
282
|
+
include_public: bool = True
|
|
283
|
+
) -> List[Connector]:
|
|
284
|
+
"""
|
|
285
|
+
List connectors, optionally filtered by type and/or application
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
connector_type: Filter by type (database, mq, cache, storage, ai)
|
|
289
|
+
application_id: Filter by application ID (returns app's connectors + public ones)
|
|
290
|
+
include_public: If True, include public connectors when filtering by app
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
List of connectors
|
|
294
|
+
"""
|
|
295
|
+
connectors = self._load_connectors()
|
|
296
|
+
|
|
297
|
+
if connector_type:
|
|
298
|
+
connectors = [c for c in connectors if c.type == connector_type]
|
|
299
|
+
|
|
300
|
+
if application_id is not None:
|
|
301
|
+
if include_public:
|
|
302
|
+
# Return app-specific + public connectors
|
|
303
|
+
connectors = [
|
|
304
|
+
c for c in connectors
|
|
305
|
+
if c.application_id == application_id or c.scope == "public"
|
|
306
|
+
]
|
|
307
|
+
else:
|
|
308
|
+
# Return only app-specific connectors
|
|
309
|
+
connectors = [c for c in connectors if c.application_id == application_id]
|
|
310
|
+
|
|
311
|
+
return connectors
|
|
312
|
+
|
|
313
|
+
def list_public_connectors(self, connector_type: Optional[str] = None) -> List[Connector]:
|
|
314
|
+
"""List only public connectors"""
|
|
315
|
+
connectors = self._load_connectors()
|
|
316
|
+
connectors = [c for c in connectors if c.scope == "public"]
|
|
317
|
+
|
|
318
|
+
if connector_type:
|
|
319
|
+
connectors = [c for c in connectors if c.type == connector_type]
|
|
320
|
+
|
|
321
|
+
return connectors
|
|
322
|
+
|
|
323
|
+
def get_connector(self, connector_id: str) -> Optional[Connector]:
|
|
324
|
+
"""Get a connector by ID"""
|
|
325
|
+
connectors = self._load_connectors()
|
|
326
|
+
for conn in connectors:
|
|
327
|
+
if conn.id == connector_id:
|
|
328
|
+
return conn
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
def create_connector(self, data: Dict[str, Any]) -> Connector:
|
|
332
|
+
"""
|
|
333
|
+
Create a new connector
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
data: Connector data
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
Created connector
|
|
340
|
+
"""
|
|
341
|
+
connectors = self._load_connectors()
|
|
342
|
+
|
|
343
|
+
# Determine scope
|
|
344
|
+
scope = data.get("scope", "public")
|
|
345
|
+
application_id = data.get("application_id")
|
|
346
|
+
if application_id:
|
|
347
|
+
scope = "application"
|
|
348
|
+
|
|
349
|
+
# Create connector
|
|
350
|
+
connector = Connector(
|
|
351
|
+
id=str(uuid.uuid4())[:8],
|
|
352
|
+
name=data.get("name", "Unnamed"),
|
|
353
|
+
type=data.get("type", "database"),
|
|
354
|
+
provider=data.get("provider", "postgres"),
|
|
355
|
+
host=data.get("host", "localhost"),
|
|
356
|
+
port=int(data.get("port", 0)),
|
|
357
|
+
username=data.get("username", ""),
|
|
358
|
+
password=self._encrypt_password(data.get("password", "")),
|
|
359
|
+
database=data.get("database", ""),
|
|
360
|
+
options=data.get("options", {}),
|
|
361
|
+
is_default=data.get("is_default", False),
|
|
362
|
+
docker_auto=data.get("docker_auto", False),
|
|
363
|
+
docker_image=data.get("docker_image", ""),
|
|
364
|
+
status="unknown",
|
|
365
|
+
scope=scope,
|
|
366
|
+
application_id=int(application_id) if application_id else None
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# If this is set as default, unset others of same type
|
|
370
|
+
if connector.is_default:
|
|
371
|
+
for c in connectors:
|
|
372
|
+
if c.type == connector.type:
|
|
373
|
+
c.is_default = False
|
|
374
|
+
|
|
375
|
+
connectors.append(connector)
|
|
376
|
+
self._save_connectors(connectors)
|
|
377
|
+
|
|
378
|
+
logger.info(f"Created connector: {connector.name} ({connector.id})")
|
|
379
|
+
return connector
|
|
380
|
+
|
|
381
|
+
def update_connector(self, connector_id: str, data: Dict[str, Any]) -> Optional[Connector]:
|
|
382
|
+
"""
|
|
383
|
+
Update an existing connector
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
connector_id: Connector ID
|
|
387
|
+
data: Updated data
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
Updated connector or None if not found
|
|
391
|
+
"""
|
|
392
|
+
connectors = self._load_connectors()
|
|
393
|
+
|
|
394
|
+
for i, conn in enumerate(connectors):
|
|
395
|
+
if conn.id == connector_id:
|
|
396
|
+
# Update fields
|
|
397
|
+
conn.name = data.get("name", conn.name)
|
|
398
|
+
conn.type = data.get("type", conn.type)
|
|
399
|
+
conn.provider = data.get("provider", conn.provider)
|
|
400
|
+
conn.host = data.get("host", conn.host)
|
|
401
|
+
conn.port = int(data.get("port", conn.port))
|
|
402
|
+
conn.username = data.get("username", conn.username)
|
|
403
|
+
conn.database = data.get("database", conn.database)
|
|
404
|
+
conn.options = data.get("options", conn.options)
|
|
405
|
+
conn.docker_auto = data.get("docker_auto", conn.docker_auto)
|
|
406
|
+
conn.docker_image = data.get("docker_image", conn.docker_image)
|
|
407
|
+
conn.updated_at = datetime.utcnow().isoformat()
|
|
408
|
+
|
|
409
|
+
# Update scope and application_id
|
|
410
|
+
if "scope" in data:
|
|
411
|
+
conn.scope = data["scope"]
|
|
412
|
+
if "application_id" in data:
|
|
413
|
+
app_id = data["application_id"]
|
|
414
|
+
conn.application_id = int(app_id) if app_id else None
|
|
415
|
+
if app_id:
|
|
416
|
+
conn.scope = "application"
|
|
417
|
+
|
|
418
|
+
# Only update password if provided
|
|
419
|
+
if data.get("password"):
|
|
420
|
+
conn.password = self._encrypt_password(data["password"])
|
|
421
|
+
|
|
422
|
+
# Handle default flag
|
|
423
|
+
if data.get("is_default"):
|
|
424
|
+
for c in connectors:
|
|
425
|
+
if c.type == conn.type:
|
|
426
|
+
c.is_default = (c.id == connector_id)
|
|
427
|
+
|
|
428
|
+
connectors[i] = conn
|
|
429
|
+
self._save_connectors(connectors)
|
|
430
|
+
|
|
431
|
+
logger.info(f"Updated connector: {conn.name} ({conn.id})")
|
|
432
|
+
return conn
|
|
433
|
+
|
|
434
|
+
return None
|
|
435
|
+
|
|
436
|
+
def delete_connector(self, connector_id: str) -> bool:
|
|
437
|
+
"""
|
|
438
|
+
Delete a connector
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
connector_id: Connector ID
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
True if deleted, False if not found
|
|
445
|
+
"""
|
|
446
|
+
connectors = self._load_connectors()
|
|
447
|
+
original_count = len(connectors)
|
|
448
|
+
|
|
449
|
+
connectors = [c for c in connectors if c.id != connector_id]
|
|
450
|
+
|
|
451
|
+
if len(connectors) < original_count:
|
|
452
|
+
self._save_connectors(connectors)
|
|
453
|
+
logger.info(f"Deleted connector: {connector_id}")
|
|
454
|
+
return True
|
|
455
|
+
|
|
456
|
+
return False
|
|
457
|
+
|
|
458
|
+
def set_default(self, connector_id: str) -> bool:
|
|
459
|
+
"""Set a connector as default for its type"""
|
|
460
|
+
connectors = self._load_connectors()
|
|
461
|
+
target_conn = None
|
|
462
|
+
|
|
463
|
+
for conn in connectors:
|
|
464
|
+
if conn.id == connector_id:
|
|
465
|
+
target_conn = conn
|
|
466
|
+
break
|
|
467
|
+
|
|
468
|
+
if not target_conn:
|
|
469
|
+
return False
|
|
470
|
+
|
|
471
|
+
# Unset other defaults of same type
|
|
472
|
+
for conn in connectors:
|
|
473
|
+
if conn.type == target_conn.type:
|
|
474
|
+
conn.is_default = (conn.id == connector_id)
|
|
475
|
+
|
|
476
|
+
self._save_connectors(connectors)
|
|
477
|
+
return True
|
|
478
|
+
|
|
479
|
+
def get_default(self, connector_type: str) -> Optional[Connector]:
|
|
480
|
+
"""Get the default connector for a type"""
|
|
481
|
+
connectors = self.list_connectors(connector_type)
|
|
482
|
+
for conn in connectors:
|
|
483
|
+
if conn.is_default:
|
|
484
|
+
return conn
|
|
485
|
+
return connectors[0] if connectors else None
|
|
486
|
+
|
|
487
|
+
# =========================================================================
|
|
488
|
+
# Connection Testing
|
|
489
|
+
# =========================================================================
|
|
490
|
+
|
|
491
|
+
async def test_connection(self, connector_id: str) -> Dict[str, Any]:
|
|
492
|
+
"""
|
|
493
|
+
Test a connector's connection
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
connector_id: Connector ID
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
Test result with success status and details
|
|
500
|
+
"""
|
|
501
|
+
connector = self.get_connector(connector_id)
|
|
502
|
+
if not connector:
|
|
503
|
+
return {"success": False, "error": "Connector not found"}
|
|
504
|
+
|
|
505
|
+
return await self._test_connector(connector)
|
|
506
|
+
|
|
507
|
+
async def test_connection_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
508
|
+
"""Test connection with provided data (for new connectors before save)"""
|
|
509
|
+
connector = Connector(
|
|
510
|
+
id="test",
|
|
511
|
+
name=data.get("name", "Test"),
|
|
512
|
+
type=data.get("type", "database"),
|
|
513
|
+
provider=data.get("provider", "postgres"),
|
|
514
|
+
host=data.get("host", "localhost"),
|
|
515
|
+
port=int(data.get("port", 0)),
|
|
516
|
+
username=data.get("username", ""),
|
|
517
|
+
password=data.get("password", ""),
|
|
518
|
+
database=data.get("database", ""),
|
|
519
|
+
options=data.get("options", {})
|
|
520
|
+
)
|
|
521
|
+
return await self._test_connector(connector)
|
|
522
|
+
|
|
523
|
+
async def _test_connector(self, connector: Connector) -> Dict[str, Any]:
|
|
524
|
+
"""Internal test implementation"""
|
|
525
|
+
import httpx
|
|
526
|
+
|
|
527
|
+
try:
|
|
528
|
+
if connector.type == ConnectorType.DATABASE:
|
|
529
|
+
return await self._test_database(connector)
|
|
530
|
+
elif connector.type == ConnectorType.MESSAGE_QUEUE:
|
|
531
|
+
return await self._test_message_queue(connector)
|
|
532
|
+
elif connector.type == ConnectorType.CACHE:
|
|
533
|
+
return await self._test_cache(connector)
|
|
534
|
+
elif connector.type == ConnectorType.STORAGE:
|
|
535
|
+
return await self._test_storage(connector)
|
|
536
|
+
elif connector.type == ConnectorType.AI:
|
|
537
|
+
return await self._test_ai(connector)
|
|
538
|
+
else:
|
|
539
|
+
return {"success": False, "error": f"Unknown connector type: {connector.type}"}
|
|
540
|
+
except Exception as e:
|
|
541
|
+
return {"success": False, "error": str(e)}
|
|
542
|
+
|
|
543
|
+
async def _test_database(self, connector: Connector) -> Dict[str, Any]:
|
|
544
|
+
"""Test database connection"""
|
|
545
|
+
password = self._decrypt_password(connector.password) if connector.password else ""
|
|
546
|
+
|
|
547
|
+
if connector.provider == "postgres":
|
|
548
|
+
try:
|
|
549
|
+
import asyncpg
|
|
550
|
+
conn = await asyncpg.connect(
|
|
551
|
+
host=connector.host,
|
|
552
|
+
port=connector.port,
|
|
553
|
+
user=connector.username,
|
|
554
|
+
password=password,
|
|
555
|
+
database=connector.database or "postgres",
|
|
556
|
+
timeout=5
|
|
557
|
+
)
|
|
558
|
+
version = await conn.fetchval("SELECT version()")
|
|
559
|
+
await conn.close()
|
|
560
|
+
self._update_status(connector.id, ConnectorStatus.CONNECTED)
|
|
561
|
+
return {"success": True, "version": version}
|
|
562
|
+
except ImportError:
|
|
563
|
+
# Fall back to sync driver
|
|
564
|
+
pass
|
|
565
|
+
except Exception as e:
|
|
566
|
+
self._update_status(connector.id, ConnectorStatus.ERROR)
|
|
567
|
+
return {"success": False, "error": str(e)}
|
|
568
|
+
|
|
569
|
+
elif connector.provider in ["mysql", "mariadb"]:
|
|
570
|
+
try:
|
|
571
|
+
import aiomysql
|
|
572
|
+
conn = await aiomysql.connect(
|
|
573
|
+
host=connector.host,
|
|
574
|
+
port=connector.port,
|
|
575
|
+
user=connector.username,
|
|
576
|
+
password=password,
|
|
577
|
+
db=connector.database or "mysql",
|
|
578
|
+
connect_timeout=5
|
|
579
|
+
)
|
|
580
|
+
async with conn.cursor() as cur:
|
|
581
|
+
await cur.execute("SELECT VERSION()")
|
|
582
|
+
version = await cur.fetchone()
|
|
583
|
+
conn.close()
|
|
584
|
+
self._update_status(connector.id, ConnectorStatus.CONNECTED)
|
|
585
|
+
return {"success": True, "version": version[0] if version else "unknown"}
|
|
586
|
+
except ImportError:
|
|
587
|
+
pass
|
|
588
|
+
except Exception as e:
|
|
589
|
+
self._update_status(connector.id, ConnectorStatus.ERROR)
|
|
590
|
+
return {"success": False, "error": str(e)}
|
|
591
|
+
|
|
592
|
+
elif connector.provider == "sqlite":
|
|
593
|
+
import sqlite3
|
|
594
|
+
try:
|
|
595
|
+
db_path = connector.database or ":memory:"
|
|
596
|
+
conn = sqlite3.connect(db_path, timeout=5)
|
|
597
|
+
cursor = conn.cursor()
|
|
598
|
+
cursor.execute("SELECT sqlite_version()")
|
|
599
|
+
version = cursor.fetchone()
|
|
600
|
+
conn.close()
|
|
601
|
+
self._update_status(connector.id, ConnectorStatus.CONNECTED)
|
|
602
|
+
return {"success": True, "version": f"SQLite {version[0]}" if version else "unknown"}
|
|
603
|
+
except Exception as e:
|
|
604
|
+
self._update_status(connector.id, ConnectorStatus.ERROR)
|
|
605
|
+
return {"success": False, "error": str(e)}
|
|
606
|
+
|
|
607
|
+
# Generic TCP test for other databases
|
|
608
|
+
return await self._test_tcp(connector)
|
|
609
|
+
|
|
610
|
+
async def _test_message_queue(self, connector: Connector) -> Dict[str, Any]:
|
|
611
|
+
"""Test message queue connection"""
|
|
612
|
+
if connector.provider == "rabbitmq":
|
|
613
|
+
# Test management API
|
|
614
|
+
import httpx
|
|
615
|
+
try:
|
|
616
|
+
mgmt_port = PROVIDER_CONFIGS["rabbitmq"].get("management_port", 15672)
|
|
617
|
+
password = self._decrypt_password(connector.password) if connector.password else ""
|
|
618
|
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
619
|
+
resp = await client.get(
|
|
620
|
+
f"http://{connector.host}:{mgmt_port}/api/overview",
|
|
621
|
+
auth=(connector.username or "guest", password or "guest")
|
|
622
|
+
)
|
|
623
|
+
if resp.status_code == 200:
|
|
624
|
+
data = resp.json()
|
|
625
|
+
self._update_status(connector.id, ConnectorStatus.CONNECTED)
|
|
626
|
+
return {
|
|
627
|
+
"success": True,
|
|
628
|
+
"version": data.get("rabbitmq_version"),
|
|
629
|
+
"node": data.get("node")
|
|
630
|
+
}
|
|
631
|
+
except Exception as e:
|
|
632
|
+
self._update_status(connector.id, ConnectorStatus.ERROR)
|
|
633
|
+
return {"success": False, "error": str(e)}
|
|
634
|
+
|
|
635
|
+
return await self._test_tcp(connector)
|
|
636
|
+
|
|
637
|
+
async def _test_cache(self, connector: Connector) -> Dict[str, Any]:
|
|
638
|
+
"""Test cache connection"""
|
|
639
|
+
if connector.provider == "redis":
|
|
640
|
+
try:
|
|
641
|
+
import redis.asyncio as aioredis
|
|
642
|
+
password = self._decrypt_password(connector.password) if connector.password else None
|
|
643
|
+
r = aioredis.Redis(
|
|
644
|
+
host=connector.host,
|
|
645
|
+
port=connector.port,
|
|
646
|
+
password=password,
|
|
647
|
+
socket_timeout=5
|
|
648
|
+
)
|
|
649
|
+
info = await r.info("server")
|
|
650
|
+
await r.close()
|
|
651
|
+
self._update_status(connector.id, ConnectorStatus.CONNECTED)
|
|
652
|
+
return {
|
|
653
|
+
"success": True,
|
|
654
|
+
"version": info.get("redis_version"),
|
|
655
|
+
"mode": info.get("redis_mode")
|
|
656
|
+
}
|
|
657
|
+
except ImportError:
|
|
658
|
+
pass
|
|
659
|
+
except Exception as e:
|
|
660
|
+
self._update_status(connector.id, ConnectorStatus.ERROR)
|
|
661
|
+
return {"success": False, "error": str(e)}
|
|
662
|
+
|
|
663
|
+
return await self._test_tcp(connector)
|
|
664
|
+
|
|
665
|
+
async def _test_storage(self, connector: Connector) -> Dict[str, Any]:
|
|
666
|
+
"""Test storage connection"""
|
|
667
|
+
if connector.provider in ["s3", "minio"]:
|
|
668
|
+
try:
|
|
669
|
+
import httpx
|
|
670
|
+
endpoint = f"http://{connector.host}:{connector.port}"
|
|
671
|
+
if connector.provider == "s3":
|
|
672
|
+
endpoint = connector.options.get("endpoint", "https://s3.amazonaws.com")
|
|
673
|
+
|
|
674
|
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
675
|
+
# Simple health check
|
|
676
|
+
resp = await client.get(f"{endpoint}/minio/health/live" if connector.provider == "minio" else endpoint)
|
|
677
|
+
if resp.status_code in [200, 403]: # 403 = needs auth but server is up
|
|
678
|
+
self._update_status(connector.id, ConnectorStatus.CONNECTED)
|
|
679
|
+
return {"success": True, "endpoint": endpoint}
|
|
680
|
+
except Exception as e:
|
|
681
|
+
self._update_status(connector.id, ConnectorStatus.ERROR)
|
|
682
|
+
return {"success": False, "error": str(e)}
|
|
683
|
+
|
|
684
|
+
elif connector.provider == "local":
|
|
685
|
+
path = connector.database or connector.options.get("path", "/tmp")
|
|
686
|
+
if os.path.isdir(path):
|
|
687
|
+
self._update_status(connector.id, ConnectorStatus.CONNECTED)
|
|
688
|
+
return {"success": True, "path": path}
|
|
689
|
+
else:
|
|
690
|
+
self._update_status(connector.id, ConnectorStatus.ERROR)
|
|
691
|
+
return {"success": False, "error": f"Directory not found: {path}"}
|
|
692
|
+
|
|
693
|
+
return {"success": False, "error": "Unknown storage provider"}
|
|
694
|
+
|
|
695
|
+
async def _test_ai(self, connector: Connector) -> Dict[str, Any]:
|
|
696
|
+
"""Test AI provider connection"""
|
|
697
|
+
import httpx
|
|
698
|
+
|
|
699
|
+
endpoint = connector.options.get("endpoint") or f"http://{connector.host}:{connector.port}"
|
|
700
|
+
password = self._decrypt_password(connector.password) if connector.password else ""
|
|
701
|
+
|
|
702
|
+
try:
|
|
703
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
704
|
+
if connector.provider == "ollama":
|
|
705
|
+
resp = await client.get(f"{endpoint}/api/tags")
|
|
706
|
+
if resp.status_code == 200:
|
|
707
|
+
data = resp.json()
|
|
708
|
+
models = [m["name"] for m in data.get("models", [])]
|
|
709
|
+
self._update_status(connector.id, ConnectorStatus.CONNECTED)
|
|
710
|
+
return {"success": True, "models": models[:10]}
|
|
711
|
+
|
|
712
|
+
elif connector.provider == "lmstudio":
|
|
713
|
+
resp = await client.get(f"{endpoint}/models")
|
|
714
|
+
if resp.status_code == 200:
|
|
715
|
+
data = resp.json()
|
|
716
|
+
models = [m["id"] for m in data.get("data", [])]
|
|
717
|
+
self._update_status(connector.id, ConnectorStatus.CONNECTED)
|
|
718
|
+
return {"success": True, "models": models[:10]}
|
|
719
|
+
|
|
720
|
+
elif connector.provider == "anthropic":
|
|
721
|
+
# This used to mark the connector CONNECTED and return a
|
|
722
|
+
# fixed model list without sending any request — a missing
|
|
723
|
+
# or revoked key "tested" green. It now asks the API.
|
|
724
|
+
if not password:
|
|
725
|
+
self._update_status(connector.id, ConnectorStatus.ERROR)
|
|
726
|
+
return {"success": False, "error": "No API key configured"}
|
|
727
|
+
base = connector.options.get("endpoint") or "https://api.anthropic.com/v1"
|
|
728
|
+
resp = await client.get(f"{base.rstrip('/')}/models",
|
|
729
|
+
headers={"x-api-key": password, "anthropic-version": "2023-06-01"})
|
|
730
|
+
if resp.status_code == 200:
|
|
731
|
+
models = [m.get("id") for m in resp.json().get("data", [])]
|
|
732
|
+
self._update_status(connector.id, ConnectorStatus.CONNECTED)
|
|
733
|
+
return {"success": True, "models": models[:10]}
|
|
734
|
+
self._update_status(connector.id, ConnectorStatus.ERROR)
|
|
735
|
+
return {"success": False, "error": f"HTTP {resp.status_code} from {base}"}
|
|
736
|
+
|
|
737
|
+
elif connector.provider in ["openai", "openrouter"]:
|
|
738
|
+
headers = {"Authorization": f"Bearer {password}"} if password else {}
|
|
739
|
+
resp = await client.get(f"{endpoint}/models", headers=headers)
|
|
740
|
+
if resp.status_code == 200:
|
|
741
|
+
data = resp.json()
|
|
742
|
+
models = [m["id"] for m in data.get("data", [])]
|
|
743
|
+
self._update_status(connector.id, ConnectorStatus.CONNECTED)
|
|
744
|
+
return {"success": True, "models": models[:10]}
|
|
745
|
+
|
|
746
|
+
except httpx.ConnectError:
|
|
747
|
+
self._update_status(connector.id, ConnectorStatus.DISCONNECTED)
|
|
748
|
+
return {"success": False, "error": "Connection refused - is the server running?"}
|
|
749
|
+
except httpx.TimeoutException:
|
|
750
|
+
self._update_status(connector.id, ConnectorStatus.ERROR)
|
|
751
|
+
return {"success": False, "error": "Connection timed out"}
|
|
752
|
+
except Exception as e:
|
|
753
|
+
self._update_status(connector.id, ConnectorStatus.ERROR)
|
|
754
|
+
return {"success": False, "error": str(e)}
|
|
755
|
+
|
|
756
|
+
return {"success": False, "error": f"Unknown AI provider: {connector.provider}"}
|
|
757
|
+
|
|
758
|
+
async def _test_tcp(self, connector: Connector) -> Dict[str, Any]:
|
|
759
|
+
"""Generic TCP connection test"""
|
|
760
|
+
import asyncio
|
|
761
|
+
|
|
762
|
+
try:
|
|
763
|
+
reader, writer = await asyncio.wait_for(
|
|
764
|
+
asyncio.open_connection(connector.host, connector.port),
|
|
765
|
+
timeout=5
|
|
766
|
+
)
|
|
767
|
+
writer.close()
|
|
768
|
+
await writer.wait_closed()
|
|
769
|
+
self._update_status(connector.id, ConnectorStatus.CONNECTED)
|
|
770
|
+
return {"success": True, "message": f"TCP connection to {connector.host}:{connector.port} successful"}
|
|
771
|
+
except asyncio.TimeoutError:
|
|
772
|
+
self._update_status(connector.id, ConnectorStatus.ERROR)
|
|
773
|
+
return {"success": False, "error": "Connection timed out"}
|
|
774
|
+
except Exception as e:
|
|
775
|
+
self._update_status(connector.id, ConnectorStatus.ERROR)
|
|
776
|
+
return {"success": False, "error": str(e)}
|
|
777
|
+
|
|
778
|
+
def _update_status(self, connector_id: str, status: ConnectorStatus):
|
|
779
|
+
"""Update connector status"""
|
|
780
|
+
connectors = self._load_connectors()
|
|
781
|
+
for conn in connectors:
|
|
782
|
+
if conn.id == connector_id:
|
|
783
|
+
conn.status = status.value
|
|
784
|
+
conn.last_tested = datetime.utcnow().isoformat()
|
|
785
|
+
break
|
|
786
|
+
self._save_connectors(connectors)
|
|
787
|
+
|
|
788
|
+
# =========================================================================
|
|
789
|
+
# Docker Auto-Provisioning
|
|
790
|
+
# =========================================================================
|
|
791
|
+
|
|
792
|
+
def start_docker_container(self, connector_id: str) -> Dict[str, Any]:
|
|
793
|
+
"""Start Docker container for a connector"""
|
|
794
|
+
connector = self.get_connector(connector_id)
|
|
795
|
+
if not connector:
|
|
796
|
+
return {"success": False, "error": "Connector not found"}
|
|
797
|
+
|
|
798
|
+
if not connector.docker_auto:
|
|
799
|
+
return {"success": False, "error": "Docker auto-provision not enabled for this connector"}
|
|
800
|
+
|
|
801
|
+
if not connector.docker_image:
|
|
802
|
+
return {"success": False, "error": "No Docker image configured"}
|
|
803
|
+
|
|
804
|
+
try:
|
|
805
|
+
from .docker_service import DockerService
|
|
806
|
+
except ImportError:
|
|
807
|
+
from docker_service import DockerService
|
|
808
|
+
|
|
809
|
+
try:
|
|
810
|
+
docker = DockerService()
|
|
811
|
+
container_name = f"quantum-{connector.provider}-{connector.id}"
|
|
812
|
+
|
|
813
|
+
# Build environment variables
|
|
814
|
+
env_vars = self._build_docker_env(connector)
|
|
815
|
+
|
|
816
|
+
# Create and start container
|
|
817
|
+
container_id = docker.create_container(
|
|
818
|
+
name=container_name,
|
|
819
|
+
image=connector.docker_image,
|
|
820
|
+
port=connector.port,
|
|
821
|
+
env_vars=env_vars,
|
|
822
|
+
auto_start=True
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
if container_id:
|
|
826
|
+
# Update connector with container ID
|
|
827
|
+
self.update_connector(connector_id, {
|
|
828
|
+
"docker_container_id": container_id,
|
|
829
|
+
"status": "connected"
|
|
830
|
+
})
|
|
831
|
+
return {"success": True, "container_id": container_id}
|
|
832
|
+
else:
|
|
833
|
+
return {"success": False, "error": "Failed to create container"}
|
|
834
|
+
|
|
835
|
+
except Exception as e:
|
|
836
|
+
return {"success": False, "error": str(e)}
|
|
837
|
+
|
|
838
|
+
def stop_docker_container(self, connector_id: str) -> Dict[str, Any]:
|
|
839
|
+
"""Stop Docker container for a connector"""
|
|
840
|
+
connector = self.get_connector(connector_id)
|
|
841
|
+
if not connector:
|
|
842
|
+
return {"success": False, "error": "Connector not found"}
|
|
843
|
+
|
|
844
|
+
if not connector.docker_container_id:
|
|
845
|
+
return {"success": False, "error": "No Docker container associated"}
|
|
846
|
+
|
|
847
|
+
try:
|
|
848
|
+
from .docker_service import DockerService
|
|
849
|
+
except ImportError:
|
|
850
|
+
from docker_service import DockerService
|
|
851
|
+
|
|
852
|
+
try:
|
|
853
|
+
docker = DockerService()
|
|
854
|
+
success = docker.stop_container(connector.docker_container_id)
|
|
855
|
+
|
|
856
|
+
if success:
|
|
857
|
+
self.update_connector(connector_id, {"status": "disconnected"})
|
|
858
|
+
return {"success": True}
|
|
859
|
+
else:
|
|
860
|
+
return {"success": False, "error": "Failed to stop container"}
|
|
861
|
+
|
|
862
|
+
except Exception as e:
|
|
863
|
+
return {"success": False, "error": str(e)}
|
|
864
|
+
|
|
865
|
+
def _build_docker_env(self, connector: Connector) -> Dict[str, str]:
|
|
866
|
+
"""Build environment variables for Docker container"""
|
|
867
|
+
password = self._decrypt_password(connector.password) if connector.password else ""
|
|
868
|
+
env = {}
|
|
869
|
+
|
|
870
|
+
if connector.provider == "postgres":
|
|
871
|
+
env = {
|
|
872
|
+
"POSTGRES_USER": connector.username or "postgres",
|
|
873
|
+
"POSTGRES_PASSWORD": password or "postgres",
|
|
874
|
+
"POSTGRES_DB": connector.database or "postgres"
|
|
875
|
+
}
|
|
876
|
+
elif connector.provider in ["mysql", "mariadb"]:
|
|
877
|
+
prefix = "MYSQL" if connector.provider == "mysql" else "MARIADB"
|
|
878
|
+
env = {
|
|
879
|
+
f"{prefix}_ROOT_PASSWORD": password or "root",
|
|
880
|
+
f"{prefix}_DATABASE": connector.database or "quantum",
|
|
881
|
+
f"{prefix}_USER": connector.username or "quantum",
|
|
882
|
+
f"{prefix}_PASSWORD": password or "quantum"
|
|
883
|
+
}
|
|
884
|
+
elif connector.provider == "mongodb":
|
|
885
|
+
env = {
|
|
886
|
+
"MONGO_INITDB_ROOT_USERNAME": connector.username or "admin",
|
|
887
|
+
"MONGO_INITDB_ROOT_PASSWORD": password or "admin"
|
|
888
|
+
}
|
|
889
|
+
elif connector.provider == "rabbitmq":
|
|
890
|
+
env = {
|
|
891
|
+
"RABBITMQ_DEFAULT_USER": connector.username or "guest",
|
|
892
|
+
"RABBITMQ_DEFAULT_PASS": password or "guest"
|
|
893
|
+
}
|
|
894
|
+
elif connector.provider == "minio":
|
|
895
|
+
env = {
|
|
896
|
+
"MINIO_ROOT_USER": connector.username or "minioadmin",
|
|
897
|
+
"MINIO_ROOT_PASSWORD": password or "minioadmin"
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
return env
|
|
901
|
+
|
|
902
|
+
# =========================================================================
|
|
903
|
+
# Persistence
|
|
904
|
+
# =========================================================================
|
|
905
|
+
|
|
906
|
+
def _load_connectors(self) -> List[Connector]:
|
|
907
|
+
"""Load connectors from YAML file"""
|
|
908
|
+
# Check cache
|
|
909
|
+
if self._connectors_cache and self._cache_time:
|
|
910
|
+
age = (datetime.now() - self._cache_time).total_seconds()
|
|
911
|
+
if age < self._cache_ttl:
|
|
912
|
+
return self._connectors_cache
|
|
913
|
+
|
|
914
|
+
if not CONNECTORS_FILE.exists():
|
|
915
|
+
return []
|
|
916
|
+
|
|
917
|
+
try:
|
|
918
|
+
with open(CONNECTORS_FILE, 'r', encoding='utf-8') as f:
|
|
919
|
+
data = yaml.safe_load(f) or []
|
|
920
|
+
|
|
921
|
+
# Fail closed on anything that does not load. An entry that failed
|
|
922
|
+
# used to be skipped with a log line — and the next save rewrote
|
|
923
|
+
# the file without it, deleting the connector. Same for an
|
|
924
|
+
# unreadable file: it returned [] and the next save emptied it.
|
|
925
|
+
connectors = []
|
|
926
|
+
for posicao, item in enumerate(data):
|
|
927
|
+
try:
|
|
928
|
+
connectors.append(Connector(**item))
|
|
929
|
+
except Exception as e:
|
|
930
|
+
nome = item.get("name") if isinstance(item, dict) else item
|
|
931
|
+
raise ConnectorFileError(
|
|
932
|
+
f"{CONNECTORS_FILE}: entry {posicao} ({nome!r}) could not be loaded: {e}. "
|
|
933
|
+
f"Fix the file; nothing was changed.") from e
|
|
934
|
+
|
|
935
|
+
# Update cache
|
|
936
|
+
self._connectors_cache = connectors
|
|
937
|
+
self._cache_time = datetime.now()
|
|
938
|
+
|
|
939
|
+
return connectors
|
|
940
|
+
|
|
941
|
+
except ConnectorFileError:
|
|
942
|
+
raise
|
|
943
|
+
except Exception as e:
|
|
944
|
+
logger.error(f"Error loading connectors: {e}")
|
|
945
|
+
raise ConnectorFileError(f"{CONNECTORS_FILE} could not be read: {e}. Nothing was changed.") from e
|
|
946
|
+
|
|
947
|
+
def _save_connectors(self, connectors: List[Connector]):
|
|
948
|
+
"""Save connectors to YAML file"""
|
|
949
|
+
try:
|
|
950
|
+
data = [asdict(c) for c in connectors]
|
|
951
|
+
|
|
952
|
+
# Atomic: a crash mid-write must not leave half a file.
|
|
953
|
+
temporario = CONNECTORS_FILE.with_suffix(".yaml.tmp")
|
|
954
|
+
with open(temporario, 'w', encoding='utf-8') as f:
|
|
955
|
+
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
|
|
956
|
+
os.replace(temporario, CONNECTORS_FILE)
|
|
957
|
+
|
|
958
|
+
# Invalidate cache
|
|
959
|
+
self._connectors_cache = None
|
|
960
|
+
self._cache_time = None
|
|
961
|
+
|
|
962
|
+
logger.info(f"Saved {len(connectors)} connectors")
|
|
963
|
+
|
|
964
|
+
except Exception as e:
|
|
965
|
+
logger.error(f"Error saving connectors: {e}")
|
|
966
|
+
raise
|
|
967
|
+
|
|
968
|
+
# =========================================================================
|
|
969
|
+
# Migration from AI connections
|
|
970
|
+
# =========================================================================
|
|
971
|
+
|
|
972
|
+
def migrate_ai_connections(self, ai_connections: List[Dict]) -> int:
|
|
973
|
+
"""
|
|
974
|
+
Migrate existing AI connections to connector format
|
|
975
|
+
|
|
976
|
+
Args:
|
|
977
|
+
ai_connections: List of AI connection dicts from old format
|
|
978
|
+
|
|
979
|
+
Returns:
|
|
980
|
+
Number of migrated connections
|
|
981
|
+
"""
|
|
982
|
+
count = 0
|
|
983
|
+
for old_conn in ai_connections:
|
|
984
|
+
try:
|
|
985
|
+
self.create_connector({
|
|
986
|
+
"name": old_conn.get("name", "Migrated AI"),
|
|
987
|
+
"type": "ai",
|
|
988
|
+
"provider": old_conn.get("provider", "ollama"),
|
|
989
|
+
"host": "localhost",
|
|
990
|
+
"port": PROVIDER_CONFIGS.get(old_conn.get("provider", "ollama"), {}).get("default_port", 11434),
|
|
991
|
+
"password": old_conn.get("api_key", ""),
|
|
992
|
+
"options": {
|
|
993
|
+
"endpoint": old_conn.get("endpoint", ""),
|
|
994
|
+
"default_model": old_conn.get("default_model", ""),
|
|
995
|
+
"max_tokens": old_conn.get("max_tokens", 4096),
|
|
996
|
+
"temperature": old_conn.get("temperature", 0.7)
|
|
997
|
+
},
|
|
998
|
+
"is_default": old_conn.get("is_default", False)
|
|
999
|
+
})
|
|
1000
|
+
count += 1
|
|
1001
|
+
except Exception as e:
|
|
1002
|
+
logger.error(f"Failed to migrate AI connection: {e}")
|
|
1003
|
+
|
|
1004
|
+
return count
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
# Singleton instance
|
|
1008
|
+
_connector_service: Optional[ConnectorService] = None
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def get_connector_service() -> ConnectorService:
|
|
1012
|
+
"""Get singleton instance of ConnectorService"""
|
|
1013
|
+
global _connector_service
|
|
1014
|
+
if _connector_service is None:
|
|
1015
|
+
_connector_service = ConnectorService()
|
|
1016
|
+
return _connector_service
|