diff --git a/docs/PRODUCTION-RELEASE-CHECKLIST.md b/docs/PRODUCTION-RELEASE-CHECKLIST.md index c1b17b69b..ac866b727 100644 --- a/docs/PRODUCTION-RELEASE-CHECKLIST.md +++ b/docs/PRODUCTION-RELEASE-CHECKLIST.md @@ -51,12 +51,18 @@ ls -lh "$BACKUP_DIR" 发布前只读检查生产库状态: +```bash +docker exec xiaoxia-api-production python /app/scripts/alembic_preflight.py +``` + +也可手动检查: + ```bash docker exec xiaoxia-postgres psql -U xiaoxia -d xiaoxia_saas -Atc "select to_regclass('public.alembic_version');" docker exec xiaoxia-postgres psql -U xiaoxia -d xiaoxia_saas -Atc "select count(*) from pg_tables where schemaname='public' and tablename != 'alembic_version';" ``` -判断规则: +脚本 `recommended_action` 判断规则: - 已存在 `alembic_version`:执行 `alembic upgrade head`。 - 有业务表但没有 `alembic_version`:先执行 `alembic stamp head`,再执行 `alembic upgrade head`。 diff --git a/scripts/alembic_preflight.py b/scripts/alembic_preflight.py new file mode 100644 index 000000000..366ddc1ac --- /dev/null +++ b/scripts/alembic_preflight.py @@ -0,0 +1,84 @@ +"""Preflight Alembic state before a production release.""" + +from __future__ import annotations + +import argparse +import os +from dataclasses import dataclass + +from sqlalchemy import create_engine, text + + +@dataclass(frozen=True) +class AlembicPreflightResult: + has_alembic_version: bool + business_table_count: int + current_revision: str | None + head_revision: str + + @property + def action(self) -> str: + if self.has_alembic_version: + if self.current_revision == self.head_revision: + return "upgrade_head_noop_or_verify" + return "upgrade_head" + if self.business_table_count > 0: + return "stamp_head_then_upgrade_head" + return "upgrade_head_empty_database" + + +def get_head_revision() -> str: + from alembic.config import Config + from alembic.script import ScriptDirectory + + config = Config("alembic.ini") + script = ScriptDirectory.from_config(config) + head = script.get_current_head() + if not head: + raise RuntimeError("Alembic head revision not found") + return head + + +def inspect_database(database_url: str, head_revision: str) -> AlembicPreflightResult: + engine = create_engine(database_url) + with engine.begin() as connection: + has_alembic_version = ( + connection.execute( + text("SELECT to_regclass(:table_name)"), {"table_name": "public.alembic_version"} + ).scalar() + is not None + ) + business_table_count = connection.execute( + text("SELECT COUNT(*) FROM pg_tables " "WHERE schemaname = :schema_name AND tablename != :version_table"), + {"schema_name": "public", "version_table": "alembic_version"}, + ).scalar_one() + current_revision = None + if has_alembic_version: + current_revision = connection.execute(text("SELECT version_num FROM alembic_version LIMIT 1")).scalar() + + return AlembicPreflightResult( + has_alembic_version=has_alembic_version, + business_table_count=int(business_table_count), + current_revision=current_revision, + head_revision=head_revision, + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--database-url", default=os.getenv("DATABASE_URL"), help="Defaults to DATABASE_URL.") + args = parser.parse_args() + if not args.database_url: + raise SystemExit("DATABASE_URL is required") + + result = inspect_database(args.database_url, get_head_revision()) + print(f"has_alembic_version={str(result.has_alembic_version).lower()}") + print(f"business_table_count={result.business_table_count}") + print(f"current_revision={result.current_revision or ''}") + print(f"head_revision={result.head_revision}") + print(f"recommended_action={result.action}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/test_alembic_preflight.py b/tests/unit/test_alembic_preflight.py new file mode 100644 index 000000000..f7cec0004 --- /dev/null +++ b/tests/unit/test_alembic_preflight.py @@ -0,0 +1,45 @@ +from scripts.alembic_preflight import AlembicPreflightResult + + +def test_alembic_preflight_action_for_current_version(): + result = AlembicPreflightResult( + has_alembic_version=True, + business_table_count=12, + current_revision="002", + head_revision="002", + ) + + assert result.action == "upgrade_head_noop_or_verify" + + +def test_alembic_preflight_action_for_old_version(): + result = AlembicPreflightResult( + has_alembic_version=True, + business_table_count=12, + current_revision="001", + head_revision="002", + ) + + assert result.action == "upgrade_head" + + +def test_alembic_preflight_action_for_existing_tables_without_version(): + result = AlembicPreflightResult( + has_alembic_version=False, + business_table_count=12, + current_revision=None, + head_revision="002", + ) + + assert result.action == "stamp_head_then_upgrade_head" + + +def test_alembic_preflight_action_for_empty_database(): + result = AlembicPreflightResult( + has_alembic_version=False, + business_table_count=0, + current_revision=None, + head_revision="002", + ) + + assert result.action == "upgrade_head_empty_database"