fix(migrations): enforce schema ownership safety

This commit is contained in:
caoxiaozhu
2026-07-14 09:23:34 +08:00
parent 1347366b95
commit 11275e4ba6
18 changed files with 755 additions and 57 deletions

View File

@@ -0,0 +1,129 @@
from __future__ import annotations
import sys
from dataclasses import dataclass
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.engine import Connection, Engine
from sqlalchemy.exc import SQLAlchemyError
from app.core.config import get_settings
from app.db.schema_ownership import MIGRATION_OWNED_TABLES
# 迁移脚本新增或删除自有表时,必须同步更新该映射并补充对应测试。
MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
"20260713_0001": frozenset(
{
"expense_cases",
"expense_case_links",
"business_events",
}
),
"20260713_0002": frozenset(
{
"expense_cases",
"expense_case_links",
"business_events",
"auth_sessions",
}
),
}
if MIGRATION_OWNED_TABLES_BY_REVISION["20260713_0002"] != MIGRATION_OWNED_TABLES:
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
class MigrationPreflightError(RuntimeError):
"""Raised when the database schema cannot be safely advanced by Alembic."""
@dataclass(frozen=True)
class MigrationPreflightState:
revision: str | None
owned_tables: frozenset[str]
def _format_tables(table_names: frozenset[str]) -> str:
return ", ".join(sorted(table_names)) or "none"
def _validate_connection(connection: Connection) -> MigrationPreflightState:
table_names = frozenset(inspect(connection).get_table_names())
owned_tables = table_names & MIGRATION_OWNED_TABLES
if "alembic_version" not in table_names:
if owned_tables:
raise MigrationPreflightError(
"unversioned database contains migration-owned tables "
f"({_format_tables(owned_tables)}); refusing to guess, stamp, or repair"
)
return MigrationPreflightState(revision=None, owned_tables=owned_tables)
revisions = tuple(
str(revision)
for revision in connection.execute(
text("SELECT version_num FROM alembic_version")
).scalars()
)
if not revisions:
if owned_tables:
raise MigrationPreflightError(
"alembic_version has no recorded revision but migration-owned tables exist "
f"({_format_tables(owned_tables)}); refusing to guess, stamp, or repair"
)
return MigrationPreflightState(revision=None, owned_tables=owned_tables)
if len(revisions) > 1:
raise MigrationPreflightError(
"multiple Alembic revisions are recorded "
f"({', '.join(sorted(revisions))}); branch state is unsupported"
)
revision = revisions[0]
expected_tables = MIGRATION_OWNED_TABLES_BY_REVISION.get(revision)
if expected_tables is None:
raise MigrationPreflightError(
f"unknown Alembic revision {revision!r}; refusing to run migrations"
)
if owned_tables != expected_tables:
missing_tables = expected_tables - owned_tables
unexpected_tables = owned_tables - expected_tables
raise MigrationPreflightError(
f"migration-owned table set does not match revision {revision}: "
f"missing={_format_tables(missing_tables)}; "
f"unexpected={_format_tables(unexpected_tables)}"
)
return MigrationPreflightState(revision=revision, owned_tables=owned_tables)
def validate_migration_state(bind: Engine | Connection) -> MigrationPreflightState:
"""Inspect the database without changing its schema or Alembic revision state."""
if isinstance(bind, Engine):
with bind.connect() as connection:
return _validate_connection(connection)
return _validate_connection(bind)
def main() -> int:
settings = get_settings()
engine = create_engine(settings.resolved_database_url, pool_pre_ping=True)
try:
state = validate_migration_state(engine)
except (MigrationPreflightError, SQLAlchemyError) as exc:
print(f"Database migration preflight failed: {exc}", file=sys.stderr)
return 1
finally:
engine.dispose()
revision = state.revision or "unversioned/base"
print(
"Database migration preflight passed: "
f"revision={revision}; owned_tables={_format_tables(state.owned_tables)}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
from sqlalchemy.engine import Connection, Engine
from app.db.base import Base
MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
{
"auth_sessions",
"expense_cases",
"expense_case_links",
"business_events",
}
)
def create_legacy_schema(bind: Engine | Connection) -> None:
"""创建仍由旧 bootstrap 管理的表,不越过 Alembic 的表所有权边界。"""
legacy_tables = [
table for table in Base.metadata.sorted_tables if table.name not in MIGRATION_OWNED_TABLES
]
Base.metadata.create_all(bind=bind, tables=legacy_tables)