fix(maf): harden phase 5 approval safety
This commit is contained in:
1 parent
f82363d81f
commit
a55992e31c
11 files changed
+489
-146
No files matched your search
@@ -58,27 +58,70 @@ def _create_lease_table_sql(conn_or_dialect: Any) -> str:
|
||||
"""
|
||||
|
||||
|
||||
def _add_missing_columns(conn: Any, *, inspector: Any, execute: Any) -> None:
|
||||
"""Complete a partially created table without rewriting existing data.
|
||||
def validate_lease_table_schema(inspector: Any) -> list[str]:
|
||||
"""Return every lease-table contract violation found by SQLAlchemy."""
|
||||
|
||||
The migration is additive. A missing primary key or action foreign-key
|
||||
column cannot be safely added to a populated table, so fail loudly rather
|
||||
than creating a lease table that the arbiter could misinterpret.
|
||||
"""
|
||||
if TABLE not in inspector.get_table_names():
|
||||
return [f"missing table: {TABLE}"]
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns(TABLE)}
|
||||
required = {"target_key", "action_id", "lease_expires_at", "created_at", "updated_at"}
|
||||
missing = required - columns
|
||||
if not missing:
|
||||
return
|
||||
if {"target_key", "action_id"} & missing:
|
||||
raise RuntimeError(
|
||||
f"{TABLE} is incomplete ({sorted(missing)}); recreate it with the deployment migration"
|
||||
required_columns = ("target_key", "action_id", "lease_expires_at", "created_at", "updated_at")
|
||||
columns = {column["name"]: column for column in inspector.get_columns(TABLE)}
|
||||
issues: list[str] = []
|
||||
for name in required_columns:
|
||||
column = columns.get(name)
|
||||
if column is None:
|
||||
issues.append(f"missing column: {name}")
|
||||
elif column.get("nullable") is not False:
|
||||
issues.append(f"nullable column: {name}")
|
||||
|
||||
primary_key = inspector.get_pk_constraint(TABLE).get("constrained_columns") or []
|
||||
if list(primary_key) != ["target_key"]:
|
||||
issues.append(f"primary key must be (target_key), found {list(primary_key)}")
|
||||
|
||||
foreign_key_ok = False
|
||||
for foreign_key in inspector.get_foreign_keys(TABLE):
|
||||
options = foreign_key.get("options") or {}
|
||||
if (
|
||||
list(foreign_key.get("constrained_columns") or []) == ["action_id"]
|
||||
and foreign_key.get("referred_table") == "maf_roundtable_actions"
|
||||
and list(foreign_key.get("referred_columns") or []) == ["id"]
|
||||
and str(options.get("ondelete") or "").upper() == "CASCADE"
|
||||
):
|
||||
foreign_key_ok = True
|
||||
break
|
||||
if not foreign_key_ok:
|
||||
issues.append(
|
||||
"missing foreign key: action_id -> maf_roundtable_actions.id ON DELETE CASCADE"
|
||||
)
|
||||
timestamp_type = _timestamp_sql(conn)
|
||||
for column in ("lease_expires_at", "created_at", "updated_at"):
|
||||
if column in missing:
|
||||
execute(f"ALTER TABLE {TABLE} ADD COLUMN {column} {timestamp_type}")
|
||||
|
||||
unique_action_id = any(
|
||||
list(constraint.get("column_names") or []) == ["action_id"]
|
||||
for constraint in inspector.get_unique_constraints(TABLE)
|
||||
)
|
||||
# Some dialects expose a named UNIQUE constraint only as a unique index.
|
||||
unique_action_id = unique_action_id or any(
|
||||
bool(index.get("unique")) and list(index.get("column_names") or []) == ["action_id"]
|
||||
for index in inspector.get_indexes(TABLE)
|
||||
)
|
||||
if not unique_action_id:
|
||||
issues.append("missing unique constraint: action_id")
|
||||
|
||||
indexes = {index.get("name"): list(index.get("column_names") or []) for index in inspector.get_indexes(TABLE)}
|
||||
for name, column in ((ACTION_INDEX, "action_id"), (EXPIRY_INDEX, "lease_expires_at")):
|
||||
if name not in indexes:
|
||||
issues.append(f"missing index: {name}")
|
||||
elif indexes[name] != [column]:
|
||||
issues.append(f"invalid index: {name} must cover ({column})")
|
||||
return issues
|
||||
|
||||
|
||||
def _table_has_rows(conn: Any, text: Any) -> bool:
|
||||
return conn.execute(text(f"SELECT 1 FROM {TABLE} LIMIT 1")).first() is not None
|
||||
|
||||
|
||||
def _create_indexes(execute: Any) -> None:
|
||||
execute(f"CREATE INDEX {ACTION_INDEX} ON {TABLE} (action_id)")
|
||||
execute(f"CREATE INDEX {EXPIRY_INDEX} ON {TABLE} (lease_expires_at)")
|
||||
|
||||
|
||||
async def _migrate(dry_run: bool) -> int:
|
||||
@@ -111,22 +154,53 @@ def _migrate_sync(conn: Any, *, dry_run: bool) -> None:
|
||||
if TABLE not in inspector.get_table_names():
|
||||
logger.info("Creating %s", TABLE)
|
||||
execute(_create_lease_table_sql(conn))
|
||||
_create_indexes(execute)
|
||||
if dry_run:
|
||||
execute(f"CREATE INDEX {ACTION_INDEX} ON {TABLE} (action_id)")
|
||||
execute(f"CREATE INDEX {EXPIRY_INDEX} ON {TABLE} (lease_expires_at)")
|
||||
return
|
||||
inspector = inspect(conn)
|
||||
else:
|
||||
logger.info("Table %s already exists", TABLE)
|
||||
_add_missing_columns(conn, inspector=inspector, execute=execute)
|
||||
if not dry_run:
|
||||
issues = validate_lease_table_schema(inspector)
|
||||
constraint_issues = [
|
||||
issue for issue in issues
|
||||
if not issue.startswith("missing index:")
|
||||
]
|
||||
if constraint_issues:
|
||||
has_rows = _table_has_rows(conn, text)
|
||||
detail = "; ".join(constraint_issues)
|
||||
if has_rows:
|
||||
raise RuntimeError(
|
||||
f"{TABLE} has data but its lease safety schema is incomplete: {detail}. "
|
||||
"Back up and manually rebuild the table after operational review."
|
||||
)
|
||||
logger.warning(
|
||||
"%s is empty but incomplete; rebuilding it with the current full schema: %s",
|
||||
TABLE,
|
||||
detail,
|
||||
)
|
||||
execute(f"DROP TABLE {TABLE}")
|
||||
execute(_create_lease_table_sql(conn))
|
||||
_create_indexes(execute)
|
||||
if dry_run:
|
||||
return
|
||||
inspector = inspect(conn)
|
||||
else:
|
||||
for index_name, column in (
|
||||
(ACTION_INDEX, "action_id"),
|
||||
(EXPIRY_INDEX, "lease_expires_at"),
|
||||
):
|
||||
if f"missing index: {index_name}" in issues:
|
||||
execute(f"CREATE INDEX {index_name} ON {TABLE} ({column})")
|
||||
if dry_run:
|
||||
return
|
||||
inspector = inspect(conn)
|
||||
|
||||
indexes = {index.get("name") for index in inspector.get_indexes(TABLE)}
|
||||
if ACTION_INDEX not in indexes:
|
||||
execute(f"CREATE INDEX {ACTION_INDEX} ON {TABLE} (action_id)")
|
||||
if EXPIRY_INDEX not in indexes:
|
||||
execute(f"CREATE INDEX {EXPIRY_INDEX} ON {TABLE} (lease_expires_at)")
|
||||
final_issues = validate_lease_table_schema(inspector)
|
||||
if final_issues:
|
||||
raise RuntimeError(
|
||||
f"{TABLE} migration did not produce the required lease schema: "
|
||||
+ "; ".join(final_issues)
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
Reference in new issue
Block a user