service.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. """Secure cross-store lifecycle orchestration for external data sources."""
  2. from dataclasses import replace
  3. from app.core.common.identifiers import new_governance_uid
  4. from app.core.data_source.definitions import DataSourceFilters
  5. from app.core.data_source.errors import (
  6. DataSourceConfigurationInvalid,
  7. DataSourceError,
  8. DataSourceNotFound,
  9. )
  10. from app.core.data_source.models import (
  11. DataSourceCredential,
  12. DataSourceDefinition,
  13. )
  14. TYPE_ALIASES = {
  15. "postgres": "postgresql",
  16. "postgresql": "postgresql",
  17. "mysql": "mysql",
  18. }
  19. POOL_INVALIDATION_REASONS = {
  20. "admin_reset",
  21. "configuration_changed",
  22. "credential_rotated",
  23. }
  24. class DataSourceService:
  25. def __init__(
  26. self,
  27. *,
  28. definitions,
  29. credentials,
  30. platform_session,
  31. adapter_resolver,
  32. connection_manager,
  33. settings_resolver,
  34. outbox_enqueuer,
  35. ):
  36. self.definitions = definitions
  37. self.credentials = credentials
  38. self.platform_session = platform_session
  39. self.adapter_resolver = adapter_resolver
  40. self.connection_manager = connection_manager
  41. self.settings_resolver = settings_resolver
  42. self.outbox_enqueuer = outbox_enqueuer
  43. @staticmethod
  44. def _database_type(payload):
  45. requested = str(payload.get("type") or "").strip().lower()
  46. database_type = TYPE_ALIASES.get(requested)
  47. if database_type is None:
  48. raise DataSourceConfigurationInvalid(
  49. "only PostgreSQL and MySQL data sources are supported"
  50. )
  51. return database_type
  52. @staticmethod
  53. def _required_text(payload, name):
  54. value = str(payload.get(name) or "").strip()
  55. if not value:
  56. raise DataSourceConfigurationInvalid(f"{name} is required")
  57. return value
  58. @classmethod
  59. def _definition(
  60. cls,
  61. payload,
  62. *,
  63. uid,
  64. credential_version,
  65. existing=None,
  66. ):
  67. database_type = cls._database_type(payload)
  68. try:
  69. port = int(payload.get("port"))
  70. except (TypeError, ValueError) as exc:
  71. raise DataSourceConfigurationInvalid("port is invalid") from exc
  72. if port < 1 or port > 65535:
  73. raise DataSourceConfigurationInvalid("port is invalid")
  74. tls_options = payload.get("tls_options") or {}
  75. if not isinstance(tls_options, dict):
  76. raise DataSourceConfigurationInvalid("tls_options is invalid")
  77. return DataSourceDefinition(
  78. uid=uid,
  79. name_en=cls._required_text(payload, "name_en"),
  80. name_zh=payload.get("name_zh"),
  81. database_type=database_type,
  82. host=cls._required_text(payload, "host"),
  83. port=port,
  84. database=cls._required_text(payload, "database"),
  85. schema=payload.get("schema"),
  86. credential_ref=uid,
  87. credential_version=credential_version,
  88. pool_size=payload.get("pool_size"),
  89. max_overflow=payload.get("max_overflow"),
  90. tls_options=tls_options,
  91. status=bool(payload.get("status", True)),
  92. description=payload.get("desc"),
  93. extra_properties=(
  94. dict(existing.extra_properties) if existing else {}
  95. ),
  96. )
  97. def _effective_credential(self, payload, existing):
  98. username = payload.get("username")
  99. password = payload.get("password")
  100. current = None
  101. if existing is not None and (not username or not password):
  102. current = self.credentials.get_active(
  103. self.platform_session(),
  104. existing.uid,
  105. existing.credential_version,
  106. )
  107. username = str(username or (current.username if current else "")).strip()
  108. password = str(password or (current.password if current else ""))
  109. if not username or not password:
  110. raise DataSourceConfigurationInvalid(
  111. "username and password are required"
  112. )
  113. options = payload.get("credential_options") or (
  114. dict(current.options) if current else {}
  115. )
  116. if not isinstance(options, dict):
  117. raise DataSourceConfigurationInvalid(
  118. "credential options are invalid"
  119. )
  120. return DataSourceCredential(username, password, options)
  121. def save(self, payload, *, actor_uid):
  122. if not isinstance(payload, dict):
  123. raise DataSourceConfigurationInvalid()
  124. requested_uid = str(payload.get("uid") or "").strip() or None
  125. existing = (
  126. self.definitions.get(requested_uid)
  127. if requested_uid is not None
  128. else None
  129. )
  130. if requested_uid is not None and existing is None:
  131. raise DataSourceNotFound()
  132. uid = requested_uid or new_governance_uid()
  133. credential = self._effective_credential(payload, existing)
  134. candidate = self._definition(
  135. payload,
  136. uid=uid,
  137. credential_version=(
  138. existing.credential_version if existing else 1
  139. ),
  140. existing=existing,
  141. )
  142. adapter = self.adapter_resolver(candidate.database_type)
  143. adapter.validate_options(candidate.tls_options)
  144. settings = self.settings_resolver(candidate.pool_overrides())
  145. adapter.test_connection(
  146. candidate,
  147. credential,
  148. query_timeout=settings["query_timeout"],
  149. )
  150. session = self.platform_session()
  151. previous_version = (
  152. existing.credential_version if existing is not None else None
  153. )
  154. try:
  155. sealed = self.credentials.create_version(
  156. session,
  157. data_source_uid=uid,
  158. credential=credential,
  159. actor_uid=actor_uid,
  160. )
  161. self.outbox_enqueuer(
  162. session,
  163. aggregate_type="datasource",
  164. aggregate_id=uid,
  165. event_type="datasource.credential_version_created",
  166. payload={
  167. "uid": uid,
  168. "database_type": candidate.database_type,
  169. "credential_version": sealed.credential_version,
  170. },
  171. )
  172. session.commit()
  173. except Exception:
  174. session.rollback()
  175. raise
  176. candidate = replace(
  177. candidate,
  178. credential_version=sealed.credential_version,
  179. )
  180. try:
  181. saved = self.definitions.save(candidate)
  182. except Exception as graph_error:
  183. try:
  184. self.credentials.compensate_failed_activation(
  185. session,
  186. data_source_uid=uid,
  187. failed_version=sealed.credential_version,
  188. restore_version=previous_version,
  189. actor_uid=actor_uid,
  190. )
  191. session.commit()
  192. except Exception as compensation_error:
  193. session.rollback()
  194. raise DataSourceError(
  195. "data source save requires reconciliation"
  196. ) from compensation_error
  197. raise DataSourceError(
  198. "data source definition could not be saved"
  199. ) from graph_error
  200. if existing is not None:
  201. self.connection_manager.invalidate(
  202. uid,
  203. "configuration_changed",
  204. )
  205. return saved, existing is None
  206. def list(self, payload):
  207. payload = payload if isinstance(payload, dict) else {}
  208. def optional_text(name):
  209. value = payload.get(name)
  210. if value is None:
  211. return None
  212. normalized = str(value).strip()
  213. return normalized or None
  214. filters = DataSourceFilters(
  215. uid=optional_text("uid"),
  216. name_en=optional_text("name_en"),
  217. name_zh=optional_text("name_zh"),
  218. database_type=optional_text("type"),
  219. status=payload.get("status"),
  220. )
  221. return self.definitions.list(filters)
  222. def test_connection(self, payload):
  223. if not isinstance(payload, dict):
  224. raise DataSourceConfigurationInvalid()
  225. uid = str(payload.get("uid") or "").strip() or None
  226. existing = self.definitions.get(uid) if uid else None
  227. if uid and existing is None:
  228. raise DataSourceNotFound()
  229. effective_uid = uid or new_governance_uid()
  230. credential = self._effective_credential(payload, existing)
  231. candidate = self._definition(
  232. payload,
  233. uid=effective_uid,
  234. credential_version=(
  235. existing.credential_version if existing else 1
  236. ),
  237. existing=existing,
  238. )
  239. adapter = self.adapter_resolver(candidate.database_type)
  240. settings = self.settings_resolver(candidate.pool_overrides())
  241. adapter.test_connection(
  242. candidate,
  243. credential,
  244. query_timeout=settings["query_timeout"],
  245. )
  246. return {"connected": True, "message": "连接测试成功"}
  247. def delete(self, uid, *, actor_uid):
  248. uid = str(uid or "").strip()
  249. existing = self.definitions.get(uid)
  250. if existing is None:
  251. raise DataSourceNotFound()
  252. self.connection_manager.invalidate(uid, "datasource_deleted")
  253. if not self.definitions.delete(uid):
  254. raise DataSourceNotFound()
  255. session = self.platform_session()
  256. try:
  257. revoked = self.credentials.revoke_all(
  258. session,
  259. data_source_uid=uid,
  260. actor_uid=actor_uid,
  261. )
  262. self.outbox_enqueuer(
  263. session,
  264. aggregate_type="datasource",
  265. aggregate_id=uid,
  266. event_type="datasource.deleted",
  267. payload={"uid": uid},
  268. )
  269. session.commit()
  270. except Exception as exc:
  271. session.rollback()
  272. raise DataSourceError(
  273. "data source deletion requires reconciliation"
  274. ) from exc
  275. return {"uid": uid, "revoked_credential_versions": revoked}
  276. def pool_statuses(self):
  277. return self.connection_manager.snapshot()
  278. def pool_status(self, uid):
  279. uid = str(uid or "").strip()
  280. for status in self.pool_statuses():
  281. if status.data_source_uid == uid:
  282. return status
  283. raise DataSourceNotFound()
  284. def invalidate_pool(self, uid, *, reason, actor_uid):
  285. uid = str(uid or "").strip()
  286. if reason not in POOL_INVALIDATION_REASONS:
  287. raise DataSourceConfigurationInvalid(
  288. "pool invalidation reason is invalid"
  289. )
  290. if self.definitions.get(uid) is None:
  291. raise DataSourceNotFound()
  292. self.connection_manager.invalidate(uid, reason)
  293. session = self.platform_session()
  294. try:
  295. self.credentials.record_pool_event(
  296. session,
  297. data_source_uid=uid,
  298. actor_uid=actor_uid,
  299. event_type="pool_invalidated",
  300. safe_detail=f"pool invalidated: {reason}",
  301. )
  302. session.commit()
  303. except Exception:
  304. session.rollback()
  305. raise
  306. return {"uid": uid, "invalidated": True, "reason": reason}
  307. @staticmethod
  308. def serialize(definition):
  309. return {
  310. "uid": definition.uid,
  311. "name_en": definition.name_en,
  312. "name_zh": definition.name_zh,
  313. "type": definition.database_type,
  314. "host": definition.host,
  315. "port": definition.port,
  316. "database": definition.database,
  317. "schema": definition.schema,
  318. "pool_size": definition.pool_size,
  319. "max_overflow": definition.max_overflow,
  320. "status": definition.status,
  321. "desc": definition.description,
  322. "credential_configured": bool(
  323. definition.credential_version
  324. ),
  325. }
  326. @staticmethod
  327. def serialize_pool_status(status):
  328. return {
  329. "data_source_uid": status.data_source_uid,
  330. "credential_version": status.credential_version,
  331. "pool_state": status.pool_state,
  332. "pool_size": status.pool_size,
  333. "checked_out": status.checked_out,
  334. "checked_out_peak": status.checked_out_peak,
  335. "checked_in": status.checked_in,
  336. "overflow": status.overflow,
  337. "leases": status.leases,
  338. "checkout_wait_ms": status.checkout_wait_ms,
  339. "last_used_at": status.last_used_at,
  340. "connection_created_total": status.connection_created_total,
  341. "connection_failed_total": status.connection_failed_total,
  342. "pool_timeout_total": status.pool_timeout_total,
  343. "invalidated_total": status.invalidated_total,
  344. "query_total": status.query_total,
  345. "last_query_duration_ms": status.last_query_duration_ms,
  346. "consecutive_failures": status.consecutive_failures,
  347. "circuit_open_until": status.circuit_open_until,
  348. }
  349. def build_data_source_service(manager):
  350. from app.core.events.outbox import enqueue_outbox
  351. return DataSourceService(
  352. definitions=manager.definitions,
  353. credentials=manager.credentials,
  354. platform_session=manager.platform_session,
  355. adapter_resolver=manager.adapter_resolver,
  356. connection_manager=manager,
  357. settings_resolver=manager.settings_resolver,
  358. outbox_enqueuer=enqueue_outbox,
  359. )