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.
Files changed (333) hide show
  1. quantum/__init__.py +1 -0
  2. quantum/cli/__init__.py +2 -0
  3. quantum/cli/admin.py +131 -0
  4. quantum/cli/check.py +223 -0
  5. quantum/cli/commands/__init__.py +15 -0
  6. quantum/cli/commands/build.py +414 -0
  7. quantum/cli/commands/dev.py +185 -0
  8. quantum/cli/commands/docs.py +329 -0
  9. quantum/cli/commands/lint.py +523 -0
  10. quantum/cli/commands/migrate.py +548 -0
  11. quantum/cli/commands/new.py +622 -0
  12. quantum/cli/commands/serve.py +190 -0
  13. quantum/cli/commands/test.py +193 -0
  14. quantum/cli/deploy.py +810 -0
  15. quantum/cli/hot_reload.py +951 -0
  16. quantum/cli/jobs.py +356 -0
  17. quantum/cli/mq.py +582 -0
  18. quantum/cli/pkg.py +390 -0
  19. quantum/cli/runner.py +565 -0
  20. quantum/cli/server_process.py +159 -0
  21. quantum/cli/utils.py +334 -0
  22. quantum/compiler/__init__.py +30 -0
  23. quantum/compiler/base_generator.py +367 -0
  24. quantum/compiler/cli.py +295 -0
  25. quantum/compiler/expression_transformer.py +444 -0
  26. quantum/compiler/javascript/__init__.py +10 -0
  27. quantum/compiler/javascript/generator.py +659 -0
  28. quantum/compiler/optimizer.py +270 -0
  29. quantum/compiler/python/__init__.py +10 -0
  30. quantum/compiler/python/generator.py +883 -0
  31. quantum/compiler/python/runtime.py +863 -0
  32. quantum/compiler/transpiler.py +330 -0
  33. quantum/core/__init__.py +3 -0
  34. quantum/core/ast_nodes.py +2619 -0
  35. quantum/core/config_env.py +54 -0
  36. quantum/core/docs_links.py +8 -0
  37. quantum/core/expression_diagnostics.py +87 -0
  38. quantum/core/expression_stdlib.py +208 -0
  39. quantum/core/expressions.py +677 -0
  40. quantum/core/features/agents/src/__init__.py +30 -0
  41. quantum/core/features/agents/src/ast_node.py +540 -0
  42. quantum/core/features/conditionals/src/__init__.py +8 -0
  43. quantum/core/features/conditionals/src/ast_node.py +68 -0
  44. quantum/core/features/data_import/src/__init__.py +0 -0
  45. quantum/core/features/data_import/src/ast_node.py +292 -0
  46. quantum/core/features/data_import/src/runtime.py +533 -0
  47. quantum/core/features/dump/src/__init__.py +12 -0
  48. quantum/core/features/dump/src/ast_node.py +106 -0
  49. quantum/core/features/dump/src/parser.py +61 -0
  50. quantum/core/features/dump/src/runtime.py +246 -0
  51. quantum/core/features/functions/src/__init__.py +8 -0
  52. quantum/core/features/functions/src/ast_node.py +149 -0
  53. quantum/core/features/game_engine_2d/src/__init__.py +19 -0
  54. quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
  55. quantum/core/features/game_engine_2d/src/parser.py +983 -0
  56. quantum/core/features/invocation/src/__init__.py +0 -0
  57. quantum/core/features/invocation/src/ast_node.py +147 -0
  58. quantum/core/features/invocation/src/runtime.py +332 -0
  59. quantum/core/features/knowledge_base/src/__init__.py +6 -0
  60. quantum/core/features/knowledge_base/src/ast_node.py +113 -0
  61. quantum/core/features/knowledge_base/src/parser.py +82 -0
  62. quantum/core/features/logging/src/__init__.py +12 -0
  63. quantum/core/features/logging/src/ast_node.py +111 -0
  64. quantum/core/features/logging/src/parser.py +50 -0
  65. quantum/core/features/logging/src/runtime.py +190 -0
  66. quantum/core/features/loops/src/__init__.py +8 -0
  67. quantum/core/features/loops/src/ast_node.py +60 -0
  68. quantum/core/features/query/src/__init__.py +0 -0
  69. quantum/core/features/query/src/query_validators.py +20 -0
  70. quantum/core/features/state_management/src/__init__.py +11 -0
  71. quantum/core/features/state_management/src/ast_node.py +121 -0
  72. quantum/core/features/terminal_engine/src/__init__.py +21 -0
  73. quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
  74. quantum/core/features/terminal_engine/src/parser.py +361 -0
  75. quantum/core/features/testing_engine/src/__init__.py +41 -0
  76. quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
  77. quantum/core/features/testing_engine/src/parser.py +604 -0
  78. quantum/core/features/theming/src/__init__.py +48 -0
  79. quantum/core/features/theming/src/ast_node.py +137 -0
  80. quantum/core/features/theming/src/presets.py +405 -0
  81. quantum/core/features/ui_engine/src/__init__.py +20 -0
  82. quantum/core/features/ui_engine/src/ast_nodes.py +1913 -0
  83. quantum/core/features/ui_engine/src/parser.py +1192 -0
  84. quantum/core/features/websocket/src/__init__.py +24 -0
  85. quantum/core/features/websocket/src/ast_node.py +247 -0
  86. quantum/core/guards.py +53 -0
  87. quantum/core/html_compat.py +299 -0
  88. quantum/core/parser.py +1551 -0
  89. quantum/core/parser_registry.py +213 -0
  90. quantum/core/parsers/__init__.py +76 -0
  91. quantum/core/parsers/ai/__init__.py +12 -0
  92. quantum/core/parsers/ai/agent_parser.py +106 -0
  93. quantum/core/parsers/ai/knowledge_parser.py +86 -0
  94. quantum/core/parsers/ai/llm_parser.py +78 -0
  95. quantum/core/parsers/ai/team_parser.py +88 -0
  96. quantum/core/parsers/base.py +322 -0
  97. quantum/core/parsers/composition/__init__.py +10 -0
  98. quantum/core/parsers/composition/import_parser.py +50 -0
  99. quantum/core/parsers/composition/slot_parser.py +48 -0
  100. quantum/core/parsers/control_flow/__init__.py +11 -0
  101. quantum/core/parsers/control_flow/if_parser.py +71 -0
  102. quantum/core/parsers/control_flow/loop_parser.py +105 -0
  103. quantum/core/parsers/control_flow/set_parser.py +93 -0
  104. quantum/core/parsers/data/__init__.py +12 -0
  105. quantum/core/parsers/data/data_parser.py +229 -0
  106. quantum/core/parsers/data/invoke_parser.py +133 -0
  107. quantum/core/parsers/data/query_parser.py +213 -0
  108. quantum/core/parsers/data/transaction_parser.py +86 -0
  109. quantum/core/parsers/events/__init__.py +9 -0
  110. quantum/core/parsers/events/dispatch_event_parser.py +44 -0
  111. quantum/core/parsers/forms/__init__.py +11 -0
  112. quantum/core/parsers/forms/action_parser.py +79 -0
  113. quantum/core/parsers/forms/flash_parser.py +34 -0
  114. quantum/core/parsers/forms/redirect_parser.py +33 -0
  115. quantum/core/parsers/functions/__init__.py +11 -0
  116. quantum/core/parsers/functions/function_parser.py +98 -0
  117. quantum/core/parsers/functions/param_parser.py +73 -0
  118. quantum/core/parsers/functions/return_parser.py +31 -0
  119. quantum/core/parsers/html/__init__.py +10 -0
  120. quantum/core/parsers/html/component_call_parser.py +130 -0
  121. quantum/core/parsers/html/html_parser.py +115 -0
  122. quantum/core/parsers/jobs/__init__.py +11 -0
  123. quantum/core/parsers/jobs/job_parser.py +71 -0
  124. quantum/core/parsers/jobs/schedule_parser.py +62 -0
  125. quantum/core/parsers/jobs/thread_parser.py +57 -0
  126. quantum/core/parsers/messaging/__init__.py +17 -0
  127. quantum/core/parsers/messaging/message_ack_parser.py +30 -0
  128. quantum/core/parsers/messaging/message_nack_parser.py +30 -0
  129. quantum/core/parsers/messaging/message_parser.py +114 -0
  130. quantum/core/parsers/messaging/queue_parser.py +61 -0
  131. quantum/core/parsers/messaging/websocket_parser.py +121 -0
  132. quantum/core/parsers/routing/__init__.py +9 -0
  133. quantum/core/parsers/routing/route_parser.py +41 -0
  134. quantum/core/parsers/scripting/__init__.py +12 -0
  135. quantum/core/parsers/scripting/pyclass_parser.py +62 -0
  136. quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
  137. quantum/core/parsers/scripting/pyimport_parser.py +52 -0
  138. quantum/core/parsers/scripting/python_parser.py +49 -0
  139. quantum/core/parsers/services/__init__.py +12 -0
  140. quantum/core/parsers/services/dump_parser.py +52 -0
  141. quantum/core/parsers/services/file_parser.py +48 -0
  142. quantum/core/parsers/services/log_parser.py +46 -0
  143. quantum/core/parsers/services/mail_parser.py +65 -0
  144. quantum/core/tiers.py +164 -0
  145. quantum/core/xml_lines.py +81 -0
  146. quantum/packages/__init__.py +28 -0
  147. quantum/packages/manager.py +413 -0
  148. quantum/packages/manifest.py +351 -0
  149. quantum/packages/registry.py +399 -0
  150. quantum/packages/resolver.py +336 -0
  151. quantum/plugins/__init__.py +33 -0
  152. quantum/plugins/hooks.py +329 -0
  153. quantum/plugins/loader.py +479 -0
  154. quantum/plugins/manifest.py +336 -0
  155. quantum/plugins/registry.py +371 -0
  156. quantum/runtime/__init__.py +25 -0
  157. quantum/runtime/action_handler.py +532 -0
  158. quantum/runtime/adapters/__init__.py +88 -0
  159. quantum/runtime/adapters/memory_adapter.py +690 -0
  160. quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
  161. quantum/runtime/adapters/redis_adapter.py +582 -0
  162. quantum/runtime/adapters/sqlite_adapter.py +414 -0
  163. quantum/runtime/agent_service.py +1133 -0
  164. quantum/runtime/ast_cache.py +506 -0
  165. quantum/runtime/auth_service.py +267 -0
  166. quantum/runtime/component.py +1048 -0
  167. quantum/runtime/component_composer.py +133 -0
  168. quantum/runtime/component_resolver.py +174 -0
  169. quantum/runtime/conditions.py +54 -0
  170. quantum/runtime/database_service.py +595 -0
  171. quantum/runtime/db_schema.py +100 -0
  172. quantum/runtime/dev_panel.py +178 -0
  173. quantum/runtime/email_service.py +162 -0
  174. quantum/runtime/error_handler.py +295 -0
  175. quantum/runtime/execution_context.py +283 -0
  176. quantum/runtime/executor_registry.py +178 -0
  177. quantum/runtime/executors/__init__.py +71 -0
  178. quantum/runtime/executors/ai/__init__.py +12 -0
  179. quantum/runtime/executors/ai/agent_executor.py +217 -0
  180. quantum/runtime/executors/ai/knowledge_executor.py +114 -0
  181. quantum/runtime/executors/ai/llm_executor.py +153 -0
  182. quantum/runtime/executors/ai/team_executor.py +171 -0
  183. quantum/runtime/executors/base.py +264 -0
  184. quantum/runtime/executors/control_flow/__init__.py +12 -0
  185. quantum/runtime/executors/control_flow/if_executor.py +98 -0
  186. quantum/runtime/executors/control_flow/loop_executor.py +308 -0
  187. quantum/runtime/executors/control_flow/redirect_executor.py +43 -0
  188. quantum/runtime/executors/control_flow/set_executor.py +464 -0
  189. quantum/runtime/executors/data/__init__.py +12 -0
  190. quantum/runtime/executors/data/data_executor.py +156 -0
  191. quantum/runtime/executors/data/invoke_executor.py +228 -0
  192. quantum/runtime/executors/data/query_executor.py +290 -0
  193. quantum/runtime/executors/data/transaction_executor.py +91 -0
  194. quantum/runtime/executors/jobs/__init__.py +11 -0
  195. quantum/runtime/executors/jobs/job_executor.py +190 -0
  196. quantum/runtime/executors/jobs/schedule_executor.py +132 -0
  197. quantum/runtime/executors/jobs/thread_executor.py +127 -0
  198. quantum/runtime/executors/messaging/__init__.py +17 -0
  199. quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
  200. quantum/runtime/executors/messaging/message_executor.py +174 -0
  201. quantum/runtime/executors/messaging/queue_executor.py +103 -0
  202. quantum/runtime/executors/messaging/websocket_executor.py +197 -0
  203. quantum/runtime/executors/scripting/__init__.py +11 -0
  204. quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
  205. quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
  206. quantum/runtime/executors/scripting/python_executor.py +249 -0
  207. quantum/runtime/executors/services/__init__.py +12 -0
  208. quantum/runtime/executors/services/dump_executor.py +72 -0
  209. quantum/runtime/executors/services/file_executor.py +89 -0
  210. quantum/runtime/executors/services/log_executor.py +77 -0
  211. quantum/runtime/executors/services/mail_executor.py +81 -0
  212. quantum/runtime/expression_cache.py +498 -0
  213. quantum/runtime/file_upload_service.py +326 -0
  214. quantum/runtime/function_registry.py +118 -0
  215. quantum/runtime/game_builder.py +166 -0
  216. quantum/runtime/game_code_generator.py +2371 -0
  217. quantum/runtime/game_templates.py +2006 -0
  218. quantum/runtime/godot_code_generator.py +4681 -0
  219. quantum/runtime/godot_templates.py +1449 -0
  220. quantum/runtime/job_executor.py +1599 -0
  221. quantum/runtime/knowledge_service.py +546 -0
  222. quantum/runtime/llm_cache.py +100 -0
  223. quantum/runtime/llm_providers.py +704 -0
  224. quantum/runtime/llm_service.py +287 -0
  225. quantum/runtime/local_server.py +28 -0
  226. quantum/runtime/logging_setup.py +141 -0
  227. quantum/runtime/message_broker.py +364 -0
  228. quantum/runtime/message_queue_service.py +571 -0
  229. quantum/runtime/param_validation.py +218 -0
  230. quantum/runtime/pypy_compat.py +317 -0
  231. quantum/runtime/python_bridge.py +698 -0
  232. quantum/runtime/query_validators.py +304 -0
  233. quantum/runtime/renderer.py +734 -0
  234. quantum/runtime/service_container.py +470 -0
  235. quantum/runtime/table_params.py +163 -0
  236. quantum/runtime/terminal_builder.py +76 -0
  237. quantum/runtime/terminal_code_generator.py +607 -0
  238. quantum/runtime/terminal_templates.py +243 -0
  239. quantum/runtime/testing_builder.py +77 -0
  240. quantum/runtime/testing_code_generator.py +833 -0
  241. quantum/runtime/testing_templates.py +85 -0
  242. quantum/runtime/ui_builder.py +222 -0
  243. quantum/runtime/ui_console.py +405 -0
  244. quantum/runtime/ui_desktop.py +56 -0
  245. quantum/runtime/ui_form_rules.py +193 -0
  246. quantum/runtime/ui_html_adapter.py +2567 -0
  247. quantum/runtime/ui_html_templates.py +2106 -0
  248. quantum/runtime/ui_mobile_adapter.py +1832 -0
  249. quantum/runtime/ui_mobile_templates.py +1003 -0
  250. quantum/runtime/ui_page_renderer.py +392 -0
  251. quantum/runtime/ui_pager.py +73 -0
  252. quantum/runtime/ui_table_edit.py +139 -0
  253. quantum/runtime/ui_textual_adapter.py +1866 -0
  254. quantum/runtime/ui_textual_templates.py +45 -0
  255. quantum/runtime/ui_tokens.py +465 -0
  256. quantum/runtime/ui_validator.py +365 -0
  257. quantum/runtime/ui_view.py +221 -0
  258. quantum/runtime/validators.py +256 -0
  259. quantum/runtime/web_server.py +2131 -0
  260. quantum/runtime/websocket_adapter.py +501 -0
  261. quantum/runtime/websocket_service.py +585 -0
  262. quantum/runtime/websocket_transport.py +289 -0
  263. quantum/runtime/wsgi.py +101 -0
  264. quantum/services.py +109 -0
  265. quantum/utils/__init__.py +1 -0
  266. quantum_admin/__init__.py +4 -0
  267. quantum_admin/assets/quantum-admin-connectors.js +123 -0
  268. quantum_admin/assets/quantum-admin.css +2058 -0
  269. quantum_admin/assets/quantum-source-highlight.js +106 -0
  270. quantum_admin/backend/__init__.py +4 -0
  271. quantum_admin/backend/audit_service.py +346 -0
  272. quantum_admin/backend/auth_service.py +394 -0
  273. quantum_admin/backend/component_discovery.py +766 -0
  274. quantum_admin/backend/config_generator.py +586 -0
  275. quantum_admin/backend/connector_service.py +1016 -0
  276. quantum_admin/backend/crud.py +557 -0
  277. quantum_admin/backend/database.py +226 -0
  278. quantum_admin/backend/db_setup_service.py +193 -0
  279. quantum_admin/backend/deploy_service.py +698 -0
  280. quantum_admin/backend/docker_service.py +422 -0
  281. quantum_admin/backend/environment_service.py +418 -0
  282. quantum_admin/backend/git_service.py +370 -0
  283. quantum_admin/backend/health_service.py +390 -0
  284. quantum_admin/backend/job_service.py +347 -0
  285. quantum_admin/backend/log_service.py +331 -0
  286. quantum_admin/backend/main.py +14426 -0
  287. quantum_admin/backend/models.py +986 -0
  288. quantum_admin/backend/pipeline_service.py +812 -0
  289. quantum_admin/backend/resource_manager.py +1118 -0
  290. quantum_admin/backend/schemas.py +419 -0
  291. quantum_admin/backend/secret_manager.py +277 -0
  292. quantum_admin/backend/settings_service.py +488 -0
  293. quantum_admin/backend/template_service.py +710 -0
  294. quantum_admin/backend/test_execution_service.py +364 -0
  295. quantum_admin/backend/test_generator.py +782 -0
  296. quantum_admin/backend/webhook_service.py +454 -0
  297. quantum_admin/backend/websocket_manager.py +360 -0
  298. quantum_admin/components/admin/_layout/AdminShell.q +121 -0
  299. quantum_admin/components/admin/agents.q +107 -0
  300. quantum_admin/components/admin/app/[name].q +558 -0
  301. quantum_admin/components/admin/applications.q +160 -0
  302. quantum_admin/components/admin/component/[...path].q +205 -0
  303. quantum_admin/components/admin/components.q +59 -0
  304. quantum_admin/components/admin/connectors.q +283 -0
  305. quantum_admin/components/admin/dashboard.q +86 -0
  306. quantum_admin/components/admin/database.q +95 -0
  307. quantum_admin/components/admin/features.q +69 -0
  308. quantum_admin/components/admin/index.q +12 -0
  309. quantum_admin/components/admin/jobs.q +75 -0
  310. quantum_admin/components/admin/login.q +65 -0
  311. quantum_admin/components/admin/logout.q +11 -0
  312. quantum_admin/components/admin/settings.q +174 -0
  313. quantum_admin/components/admin/source.q +45 -0
  314. quantum_admin/components/admin/tests.q +73 -0
  315. quantum_admin/run.py +13 -0
  316. quantum_admin/services/__init__.py +16 -0
  317. quantum_admin/services/_base.py +84 -0
  318. quantum_admin/services/_component_test_generator.py +205 -0
  319. quantum_admin/services/_yaml_editor.py +82 -0
  320. quantum_admin/services/apps.py +330 -0
  321. quantum_admin/services/auth.py +61 -0
  322. quantum_admin/services/components.py +210 -0
  323. quantum_admin/services/connectors.py +164 -0
  324. quantum_admin/services/projects.py +189 -0
  325. quantum_admin/services/repository.py +223 -0
  326. quantum_admin/services/settings.py +150 -0
  327. quantum_admin/services/yaml_import.py +118 -0
  328. quantum_framework-0.18.0.dist-info/METADATA +281 -0
  329. quantum_framework-0.18.0.dist-info/RECORD +333 -0
  330. quantum_framework-0.18.0.dist-info/WHEEL +5 -0
  331. quantum_framework-0.18.0.dist-info/entry_points.txt +2 -0
  332. quantum_framework-0.18.0.dist-info/licenses/LICENSE +21 -0
  333. 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