"""Docker entrypoint: wait for DB, reconcile Alembic state, migrate, start API.""" from __future__ import annotations import subprocess import sys import time from pathlib import Path ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from alembic import command from alembic.config import Config from sqlalchemy import create_engine, inspect, text from app.core.install_secrets import ensure_install_secrets from app.core.config import settings INITIAL_REVISION = "20260711_0001" SCHEMA_TABLES = ( "content_pages", "email_verification_tokens", "password_reset_tokens", "refresh_tokens", "user_profiles", "users", ) def wait_for_database(max_attempts: int = 30, delay_seconds: float = 1.0): engine = create_engine(settings.database_url) last_error: Exception | None = None for _ in range(max_attempts): try: with engine.connect() as connection: connection.execute(text("SELECT 1")) return engine except Exception as exc: last_error = exc time.sleep(delay_seconds) detail = str(last_error or "unknown error") hint = "" if "password authentication failed" in detail or "does not exist" in detail: hint = ( "\n\nPostgres credentials in install.env do not match the existing database volume " "(common if docker compose ran before bootstrap_install.py).\n" "Local dev fix — deletes Docker DB data:\n" " docker compose --profile docker-web down -v\n" " docker compose --profile docker-web up -d --build\n" "See docs/secrets-recovery.md" ) raise RuntimeError(f"Database is unavailable: {detail}{hint}") from last_error def current_revision(engine) -> str | None: inspector = inspect(engine) if "alembic_version" not in inspector.get_table_names(): return None with engine.connect() as connection: return connection.execute(text("SELECT version_num FROM alembic_version")).scalar() def _reset_schema(engine) -> None: cascade = " CASCADE" if engine.dialect.name == "postgresql" else "" with engine.begin() as connection: for table in SCHEMA_TABLES: connection.execute(text(f'DROP TABLE IF EXISTS "{table}"{cascade}')) connection.execute(text(f'DROP TABLE IF EXISTS "alembic_version"{cascade}')) def run_migrations(engine) -> None: config = Config("alembic.ini") tables = set(inspect(engine).get_table_names()) revision = current_revision(engine) schema_tables = set(SCHEMA_TABLES) existing_schema = tables & schema_tables if existing_schema: if schema_tables.issubset(tables): if revision is None: command.stamp(config, INITIAL_REVISION) else: _reset_schema(engine) command.upgrade(config, "head") def main() -> None: ensure_install_secrets() engine = wait_for_database() run_migrations(engine) subprocess.run( [sys.executable, "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"], check=True, ) if __name__ == "__main__": main()