"""One-shot, operator-only Cloud PostgreSQL release migration entrypoint.""" from __future__ import annotations import asyncio import os import re from collections.abc import Mapping import sqlalchemy from ..cloud.bootstrap import SUPPORTED_PGVECTOR_DIMENSIONS from ..core import app as core_app from ..core.stages.load_config import LoadConfigStage from ..core.stages.setup_logger import SetupLoggerStage from .mgr import PersistenceManager, PersistenceMode from .postgresql_url import normalize_asyncpg_url DEFAULT_OPERATOR_DSN_ENV = 'LANGBOT_CLOUD_MIGRATION_DSN' _ENV_NAME = re.compile(r'^[A-Z_][A-Z0-9_]*$') class CloudReleaseMigrationConfigurationError(RuntimeError): """Raised before any database operation when migration input is unsafe.""" def _url_endpoint(url: sqlalchemy.engine.URL, *, label: str) -> tuple[str, int]: """Return a comparison-safe PostgreSQL endpoint without leaking its DSN.""" try: host = (url.host or '').strip().casefold() port = url.port or 5432 except (TypeError, ValueError): raise CloudReleaseMigrationConfigurationError(f'{label} PostgreSQL host or port is invalid') from None if not host or isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535: raise CloudReleaseMigrationConfigurationError(f'{label} PostgreSQL host or port is invalid') return host, port def _operator_database_url( instance_config: dict, *, environ: Mapping[str, str], ) -> sqlalchemy.engine.URL: database_config = instance_config.get('database') if not isinstance(database_config, dict) or database_config.get('use') != 'postgresql': raise CloudReleaseMigrationConfigurationError( 'Cloud release migration requires explicit database.use=postgresql; SQLite fallback is forbidden' ) runtime_config = database_config.get('postgresql') if not isinstance(runtime_config, dict): raise CloudReleaseMigrationConfigurationError('Cloud runtime PostgreSQL configuration is missing') migration_config = database_config.get('cloud_migration', {}) if not isinstance(migration_config, dict): raise CloudReleaseMigrationConfigurationError('database.cloud_migration must be a mapping') dsn_env = migration_config.get('operator_dsn_env', DEFAULT_OPERATOR_DSN_ENV) if not isinstance(dsn_env, str) or not _ENV_NAME.fullmatch(dsn_env): raise CloudReleaseMigrationConfigurationError( 'database.cloud_migration.operator_dsn_env must name an uppercase environment variable' ) raw_dsn = environ.get(dsn_env, '').strip() if not raw_dsn: raise CloudReleaseMigrationConfigurationError( f'Cloud release migration requires the operator DSN in environment variable {dsn_env}' ) try: operator_url = sqlalchemy.engine.make_url(raw_dsn) except Exception: # Never echo a malformed DSN because it may contain an unescaped secret. raise CloudReleaseMigrationConfigurationError('Cloud release migration operator DSN is invalid') from None try: operator_url = normalize_asyncpg_url(operator_url) except ValueError: raise CloudReleaseMigrationConfigurationError( 'Cloud release migration operator DSN must use valid PostgreSQL asyncpg options' ) from None operator_user = (operator_url.username or '').strip() operator_database = (operator_url.database or '').strip() operator_host, operator_port = _url_endpoint(operator_url, label='Cloud release migration operator') runtime_url_value = runtime_config.get('url') if runtime_url_value: if not isinstance(runtime_url_value, str): raise CloudReleaseMigrationConfigurationError('Cloud runtime PostgreSQL URL must be a string') try: runtime_url = sqlalchemy.engine.make_url(runtime_url_value) except Exception: raise CloudReleaseMigrationConfigurationError('Cloud runtime PostgreSQL URL is invalid') from None if runtime_url.drivername not in {'postgresql', 'postgresql+asyncpg'}: raise CloudReleaseMigrationConfigurationError('Cloud runtime database URL must use PostgreSQL') runtime_user = (runtime_url.username or '').strip() runtime_database = (runtime_url.database or '').strip() runtime_host, runtime_port = _url_endpoint(runtime_url, label='Cloud runtime') else: runtime_user = str(runtime_config.get('user', 'postgres') or '').strip() runtime_database = str(runtime_config.get('database', 'postgres') or '').strip() runtime_host = str(runtime_config.get('host', '') or '').strip().casefold() runtime_port = runtime_config.get('port', 5432) if not operator_user or not operator_database: raise CloudReleaseMigrationConfigurationError( 'Cloud release migration operator DSN must include a user, host, and database' ) if ( not runtime_user or not runtime_database or not runtime_host or isinstance(runtime_port, bool) or not isinstance(runtime_port, int) or not 1 <= runtime_port <= 65535 ): raise CloudReleaseMigrationConfigurationError( 'Cloud runtime PostgreSQL user, host, port, and database are required' ) if operator_user == runtime_user: raise CloudReleaseMigrationConfigurationError( 'Cloud release migration requires a distinct operator role from the runtime PostgreSQL role' ) if operator_database != runtime_database: raise CloudReleaseMigrationConfigurationError( 'Cloud release migration operator DSN must target the configured runtime database' ) if operator_host != runtime_host or operator_port != runtime_port: # The first Cloud release intentionally requires the migrator and # runtime to use the same PostgreSQL endpoint. Supporting a direct # operator endpoint plus a runtime pooler requires a database-backed # immutable cluster identity check; accepting aliases here would turn a # same-named database on another cluster into a silent migration target. raise CloudReleaseMigrationConfigurationError( 'Cloud release migration operator DSN must target the configured runtime PostgreSQL endpoint' ) vdb_config = instance_config.get('vdb') if not isinstance(vdb_config, dict) or vdb_config.get('use') != 'pgvector': raise CloudReleaseMigrationConfigurationError('Cloud release migration requires vdb.use=pgvector') pgvector_config = vdb_config.get('pgvector') if not isinstance(pgvector_config, dict) or pgvector_config.get('use_business_database') is not True: raise CloudReleaseMigrationConfigurationError( 'Cloud release migration requires vdb.pgvector.use_business_database=true' ) dimensions = pgvector_config.get('allowed_dimensions') if ( not isinstance(dimensions, list) or not dimensions or any(isinstance(item, bool) or not isinstance(item, int) for item in dimensions) or not set(dimensions).issubset(SUPPORTED_PGVECTOR_DIMENSIONS) ): raise CloudReleaseMigrationConfigurationError( 'Cloud release migration pgvector dimensions are outside the release-created index set' ) return operator_url async def run_cloud_release_migration( ap: core_app.Application, *, environ: Mapping[str, str] | None = None, ) -> None: """Run and validate one release migration with an isolated operator DSN.""" operator_url = _operator_database_url( ap.instance_config.data, environ=os.environ if environ is None else environ, ) manager = PersistenceManager( ap, mode=PersistenceMode.RELEASE_MIGRATION, database_url=operator_url, ) ap.persistence_mgr = manager try: await manager.initialize() ap.logger.info('Cloud PostgreSQL release migration reached and validated the exact release head.') finally: await manager.shutdown() async def run_cloud_release_migration_from_config(loop: asyncio.AbstractEventLoop) -> None: """Load only process configuration/logging, then run the one-shot job.""" ap = core_app.Application() ap.event_loop = loop await LoadConfigStage().run(ap) await SetupLoggerStage().run(ap) await run_cloud_release_migration(ap)