service.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  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. filters = DataSourceFilters(
  209. uid=payload.get("uid"),
  210. name_en=payload.get("name_en"),
  211. name_zh=payload.get("name_zh"),
  212. database_type=payload.get("type"),
  213. status=payload.get("status"),
  214. )
  215. return self.definitions.list(filters)
  216. def test_connection(self, payload):
  217. if not isinstance(payload, dict):
  218. raise DataSourceConfigurationInvalid()
  219. uid = str(payload.get("uid") or "").strip() or None
  220. existing = self.definitions.get(uid) if uid else None
  221. if uid and existing is None:
  222. raise DataSourceNotFound()
  223. effective_uid = uid or new_governance_uid()
  224. credential = self._effective_credential(payload, existing)
  225. candidate = self._definition(
  226. payload,
  227. uid=effective_uid,
  228. credential_version=(
  229. existing.credential_version if existing else 1
  230. ),
  231. existing=existing,
  232. )
  233. adapter = self.adapter_resolver(candidate.database_type)
  234. settings = self.settings_resolver(candidate.pool_overrides())
  235. adapter.test_connection(
  236. candidate,
  237. credential,
  238. query_timeout=settings["query_timeout"],
  239. )
  240. return {"connected": True, "message": "连接测试成功"}
  241. def delete(self, uid, *, actor_uid):
  242. uid = str(uid or "").strip()
  243. existing = self.definitions.get(uid)
  244. if existing is None:
  245. raise DataSourceNotFound()
  246. self.connection_manager.invalidate(uid, "datasource_deleted")
  247. if not self.definitions.delete(uid):
  248. raise DataSourceNotFound()
  249. session = self.platform_session()
  250. try:
  251. revoked = self.credentials.revoke_all(
  252. session,
  253. data_source_uid=uid,
  254. actor_uid=actor_uid,
  255. )
  256. self.outbox_enqueuer(
  257. session,
  258. aggregate_type="datasource",
  259. aggregate_id=uid,
  260. event_type="datasource.deleted",
  261. payload={"uid": uid},
  262. )
  263. session.commit()
  264. except Exception as exc:
  265. session.rollback()
  266. raise DataSourceError(
  267. "data source deletion requires reconciliation"
  268. ) from exc
  269. return {"uid": uid, "revoked_credential_versions": revoked}
  270. def pool_statuses(self):
  271. return self.connection_manager.snapshot()
  272. def pool_status(self, uid):
  273. uid = str(uid or "").strip()
  274. for status in self.pool_statuses():
  275. if status.data_source_uid == uid:
  276. return status
  277. raise DataSourceNotFound()
  278. def invalidate_pool(self, uid, *, reason, actor_uid):
  279. uid = str(uid or "").strip()
  280. if reason not in POOL_INVALIDATION_REASONS:
  281. raise DataSourceConfigurationInvalid(
  282. "pool invalidation reason is invalid"
  283. )
  284. if self.definitions.get(uid) is None:
  285. raise DataSourceNotFound()
  286. self.connection_manager.invalidate(uid, reason)
  287. session = self.platform_session()
  288. try:
  289. self.credentials.record_pool_event(
  290. session,
  291. data_source_uid=uid,
  292. actor_uid=actor_uid,
  293. event_type="pool_invalidated",
  294. safe_detail=f"pool invalidated: {reason}",
  295. )
  296. session.commit()
  297. except Exception:
  298. session.rollback()
  299. raise
  300. return {"uid": uid, "invalidated": True, "reason": reason}
  301. @staticmethod
  302. def serialize(definition):
  303. return {
  304. "uid": definition.uid,
  305. "name_en": definition.name_en,
  306. "name_zh": definition.name_zh,
  307. "type": definition.database_type,
  308. "host": definition.host,
  309. "port": definition.port,
  310. "database": definition.database,
  311. "schema": definition.schema,
  312. "pool_size": definition.pool_size,
  313. "max_overflow": definition.max_overflow,
  314. "status": definition.status,
  315. "desc": definition.description,
  316. "credential_configured": bool(
  317. definition.credential_version
  318. ),
  319. }
  320. @staticmethod
  321. def serialize_pool_status(status):
  322. return {
  323. "data_source_uid": status.data_source_uid,
  324. "credential_version": status.credential_version,
  325. "pool_state": status.pool_state,
  326. "pool_size": status.pool_size,
  327. "checked_out": status.checked_out,
  328. "checked_out_peak": status.checked_out_peak,
  329. "checked_in": status.checked_in,
  330. "overflow": status.overflow,
  331. "leases": status.leases,
  332. "checkout_wait_ms": status.checkout_wait_ms,
  333. "last_used_at": status.last_used_at,
  334. "connection_created_total": status.connection_created_total,
  335. "connection_failed_total": status.connection_failed_total,
  336. "pool_timeout_total": status.pool_timeout_total,
  337. "invalidated_total": status.invalidated_total,
  338. "query_total": status.query_total,
  339. "last_query_duration_ms": status.last_query_duration_ms,
  340. "consecutive_failures": status.consecutive_failures,
  341. "circuit_open_until": status.circuit_open_until,
  342. }
  343. def build_data_source_service(manager):
  344. from app.core.events.outbox import enqueue_outbox
  345. return DataSourceService(
  346. definitions=manager.definitions,
  347. credentials=manager.credentials,
  348. platform_session=manager.platform_session,
  349. adapter_resolver=manager.adapter_resolver,
  350. connection_manager=manager,
  351. settings_resolver=manager.settings_resolver,
  352. outbox_enqueuer=enqueue_outbox,
  353. )