| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381 |
- """Secure cross-store lifecycle orchestration for external data sources."""
- from dataclasses import replace
- from app.core.common.identifiers import new_governance_uid
- from app.core.data_source.definitions import DataSourceFilters
- from app.core.data_source.errors import (
- DataSourceConfigurationInvalid,
- DataSourceError,
- DataSourceNotFound,
- )
- from app.core.data_source.models import (
- DataSourceCredential,
- DataSourceDefinition,
- )
- TYPE_ALIASES = {
- "postgres": "postgresql",
- "postgresql": "postgresql",
- "mysql": "mysql",
- }
- POOL_INVALIDATION_REASONS = {
- "admin_reset",
- "configuration_changed",
- "credential_rotated",
- }
- class DataSourceService:
- def __init__(
- self,
- *,
- definitions,
- credentials,
- platform_session,
- adapter_resolver,
- connection_manager,
- settings_resolver,
- outbox_enqueuer,
- ):
- self.definitions = definitions
- self.credentials = credentials
- self.platform_session = platform_session
- self.adapter_resolver = adapter_resolver
- self.connection_manager = connection_manager
- self.settings_resolver = settings_resolver
- self.outbox_enqueuer = outbox_enqueuer
- @staticmethod
- def _database_type(payload):
- requested = str(payload.get("type") or "").strip().lower()
- database_type = TYPE_ALIASES.get(requested)
- if database_type is None:
- raise DataSourceConfigurationInvalid(
- "only PostgreSQL and MySQL data sources are supported"
- )
- return database_type
- @staticmethod
- def _required_text(payload, name):
- value = str(payload.get(name) or "").strip()
- if not value:
- raise DataSourceConfigurationInvalid(f"{name} is required")
- return value
- @classmethod
- def _definition(
- cls,
- payload,
- *,
- uid,
- credential_version,
- existing=None,
- ):
- database_type = cls._database_type(payload)
- try:
- port = int(payload.get("port"))
- except (TypeError, ValueError) as exc:
- raise DataSourceConfigurationInvalid("port is invalid") from exc
- if port < 1 or port > 65535:
- raise DataSourceConfigurationInvalid("port is invalid")
- tls_options = payload.get("tls_options") or {}
- if not isinstance(tls_options, dict):
- raise DataSourceConfigurationInvalid("tls_options is invalid")
- return DataSourceDefinition(
- uid=uid,
- name_en=cls._required_text(payload, "name_en"),
- name_zh=payload.get("name_zh"),
- database_type=database_type,
- host=cls._required_text(payload, "host"),
- port=port,
- database=cls._required_text(payload, "database"),
- schema=payload.get("schema"),
- credential_ref=uid,
- credential_version=credential_version,
- pool_size=payload.get("pool_size"),
- max_overflow=payload.get("max_overflow"),
- tls_options=tls_options,
- status=bool(payload.get("status", True)),
- description=payload.get("desc"),
- extra_properties=(
- dict(existing.extra_properties) if existing else {}
- ),
- )
- def _effective_credential(self, payload, existing):
- username = payload.get("username")
- password = payload.get("password")
- current = None
- if existing is not None and (not username or not password):
- current = self.credentials.get_active(
- self.platform_session(),
- existing.uid,
- existing.credential_version,
- )
- username = str(username or (current.username if current else "")).strip()
- password = str(password or (current.password if current else ""))
- if not username or not password:
- raise DataSourceConfigurationInvalid(
- "username and password are required"
- )
- options = payload.get("credential_options") or (
- dict(current.options) if current else {}
- )
- if not isinstance(options, dict):
- raise DataSourceConfigurationInvalid(
- "credential options are invalid"
- )
- return DataSourceCredential(username, password, options)
- def save(self, payload, *, actor_uid):
- if not isinstance(payload, dict):
- raise DataSourceConfigurationInvalid()
- requested_uid = str(payload.get("uid") or "").strip() or None
- existing = (
- self.definitions.get(requested_uid)
- if requested_uid is not None
- else None
- )
- if requested_uid is not None and existing is None:
- raise DataSourceNotFound()
- uid = requested_uid or new_governance_uid()
- credential = self._effective_credential(payload, existing)
- candidate = self._definition(
- payload,
- uid=uid,
- credential_version=(
- existing.credential_version if existing else 1
- ),
- existing=existing,
- )
- adapter = self.adapter_resolver(candidate.database_type)
- adapter.validate_options(candidate.tls_options)
- settings = self.settings_resolver(candidate.pool_overrides())
- adapter.test_connection(
- candidate,
- credential,
- query_timeout=settings["query_timeout"],
- )
- session = self.platform_session()
- previous_version = (
- existing.credential_version if existing is not None else None
- )
- try:
- sealed = self.credentials.create_version(
- session,
- data_source_uid=uid,
- credential=credential,
- actor_uid=actor_uid,
- )
- self.outbox_enqueuer(
- session,
- aggregate_type="datasource",
- aggregate_id=uid,
- event_type="datasource.credential_version_created",
- payload={
- "uid": uid,
- "database_type": candidate.database_type,
- "credential_version": sealed.credential_version,
- },
- )
- session.commit()
- except Exception:
- session.rollback()
- raise
- candidate = replace(
- candidate,
- credential_version=sealed.credential_version,
- )
- try:
- saved = self.definitions.save(candidate)
- except Exception as graph_error:
- try:
- self.credentials.compensate_failed_activation(
- session,
- data_source_uid=uid,
- failed_version=sealed.credential_version,
- restore_version=previous_version,
- actor_uid=actor_uid,
- )
- session.commit()
- except Exception as compensation_error:
- session.rollback()
- raise DataSourceError(
- "data source save requires reconciliation"
- ) from compensation_error
- raise DataSourceError(
- "data source definition could not be saved"
- ) from graph_error
- if existing is not None:
- self.connection_manager.invalidate(
- uid,
- "configuration_changed",
- )
- return saved, existing is None
- def list(self, payload):
- payload = payload if isinstance(payload, dict) else {}
- filters = DataSourceFilters(
- uid=payload.get("uid"),
- name_en=payload.get("name_en"),
- name_zh=payload.get("name_zh"),
- database_type=payload.get("type"),
- status=payload.get("status"),
- )
- return self.definitions.list(filters)
- def test_connection(self, payload):
- if not isinstance(payload, dict):
- raise DataSourceConfigurationInvalid()
- uid = str(payload.get("uid") or "").strip() or None
- existing = self.definitions.get(uid) if uid else None
- if uid and existing is None:
- raise DataSourceNotFound()
- effective_uid = uid or new_governance_uid()
- credential = self._effective_credential(payload, existing)
- candidate = self._definition(
- payload,
- uid=effective_uid,
- credential_version=(
- existing.credential_version if existing else 1
- ),
- existing=existing,
- )
- adapter = self.adapter_resolver(candidate.database_type)
- settings = self.settings_resolver(candidate.pool_overrides())
- adapter.test_connection(
- candidate,
- credential,
- query_timeout=settings["query_timeout"],
- )
- return {"connected": True, "message": "连接测试成功"}
- def delete(self, uid, *, actor_uid):
- uid = str(uid or "").strip()
- existing = self.definitions.get(uid)
- if existing is None:
- raise DataSourceNotFound()
- self.connection_manager.invalidate(uid, "datasource_deleted")
- if not self.definitions.delete(uid):
- raise DataSourceNotFound()
- session = self.platform_session()
- try:
- revoked = self.credentials.revoke_all(
- session,
- data_source_uid=uid,
- actor_uid=actor_uid,
- )
- self.outbox_enqueuer(
- session,
- aggregate_type="datasource",
- aggregate_id=uid,
- event_type="datasource.deleted",
- payload={"uid": uid},
- )
- session.commit()
- except Exception as exc:
- session.rollback()
- raise DataSourceError(
- "data source deletion requires reconciliation"
- ) from exc
- return {"uid": uid, "revoked_credential_versions": revoked}
- def pool_statuses(self):
- return self.connection_manager.snapshot()
- def pool_status(self, uid):
- uid = str(uid or "").strip()
- for status in self.pool_statuses():
- if status.data_source_uid == uid:
- return status
- raise DataSourceNotFound()
- def invalidate_pool(self, uid, *, reason, actor_uid):
- uid = str(uid or "").strip()
- if reason not in POOL_INVALIDATION_REASONS:
- raise DataSourceConfigurationInvalid(
- "pool invalidation reason is invalid"
- )
- if self.definitions.get(uid) is None:
- raise DataSourceNotFound()
- self.connection_manager.invalidate(uid, reason)
- session = self.platform_session()
- try:
- self.credentials.record_pool_event(
- session,
- data_source_uid=uid,
- actor_uid=actor_uid,
- event_type="pool_invalidated",
- safe_detail=f"pool invalidated: {reason}",
- )
- session.commit()
- except Exception:
- session.rollback()
- raise
- return {"uid": uid, "invalidated": True, "reason": reason}
- @staticmethod
- def serialize(definition):
- return {
- "uid": definition.uid,
- "name_en": definition.name_en,
- "name_zh": definition.name_zh,
- "type": definition.database_type,
- "host": definition.host,
- "port": definition.port,
- "database": definition.database,
- "schema": definition.schema,
- "pool_size": definition.pool_size,
- "max_overflow": definition.max_overflow,
- "status": definition.status,
- "desc": definition.description,
- "credential_configured": bool(
- definition.credential_version
- ),
- }
- @staticmethod
- def serialize_pool_status(status):
- return {
- "data_source_uid": status.data_source_uid,
- "credential_version": status.credential_version,
- "pool_state": status.pool_state,
- "pool_size": status.pool_size,
- "checked_out": status.checked_out,
- "checked_out_peak": status.checked_out_peak,
- "checked_in": status.checked_in,
- "overflow": status.overflow,
- "leases": status.leases,
- "checkout_wait_ms": status.checkout_wait_ms,
- "last_used_at": status.last_used_at,
- "connection_created_total": status.connection_created_total,
- "connection_failed_total": status.connection_failed_total,
- "pool_timeout_total": status.pool_timeout_total,
- "invalidated_total": status.invalidated_total,
- "query_total": status.query_total,
- "last_query_duration_ms": status.last_query_duration_ms,
- "consecutive_failures": status.consecutive_failures,
- "circuit_open_until": status.circuit_open_until,
- }
- def build_data_source_service(manager):
- from app.core.events.outbox import enqueue_outbox
- return DataSourceService(
- definitions=manager.definitions,
- credentials=manager.credentials,
- platform_session=manager.platform_session,
- adapter_resolver=manager.adapter_resolver,
- connection_manager=manager,
- settings_resolver=manager.settings_resolver,
- outbox_enqueuer=enqueue_outbox,
- )
|