fix(migrations): enforce schema ownership safety
This commit is contained in:
129
server/src/app/db/migration_preflight.py
Normal file
129
server/src/app/db/migration_preflight.py
Normal 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())
|
||||
22
server/src/app/db/schema_ownership.py
Normal file
22
server/src/app/db/schema_ownership.py
Normal 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)
|
||||
Reference in New Issue
Block a user