ci(migrations): enforce graph + reversible cycle checks; fix FK downgrade naming
This commit is contained in:
9
Makefile
9
Makefile
@@ -105,8 +105,9 @@ backend-migrate: ## Apply backend DB migrations (uses backend/migrations)
|
|||||||
cd $(BACKEND_DIR) && uv run alembic upgrade head
|
cd $(BACKEND_DIR) && uv run alembic upgrade head
|
||||||
|
|
||||||
.PHONY: backend-migration-check
|
.PHONY: backend-migration-check
|
||||||
backend-migration-check: ## Validate Alembic migrations on clean Postgres (upgrade head + single-head sanity)
|
backend-migration-check: ## Validate migration graph + reversible path on clean Postgres
|
||||||
@set -euo pipefail; \
|
@set -euo pipefail; \
|
||||||
|
(cd $(BACKEND_DIR) && uv run python scripts/check_migration_graph.py); \
|
||||||
CONTAINER_NAME="mc-migration-check-$$RANDOM"; \
|
CONTAINER_NAME="mc-migration-check-$$RANDOM"; \
|
||||||
docker run -d --rm --name $$CONTAINER_NAME -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=migration_ci -p 55432:5432 postgres:16 >/dev/null; \
|
docker run -d --rm --name $$CONTAINER_NAME -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=migration_ci -p 55432:5432 postgres:16 >/dev/null; \
|
||||||
cleanup() { docker rm -f $$CONTAINER_NAME >/dev/null 2>&1 || true; }; \
|
cleanup() { docker rm -f $$CONTAINER_NAME >/dev/null 2>&1 || true; }; \
|
||||||
@@ -124,7 +125,11 @@ backend-migration-check: ## Validate Alembic migrations on clean Postgres (upgra
|
|||||||
AUTH_MODE=local \
|
AUTH_MODE=local \
|
||||||
LOCAL_AUTH_TOKEN=ci-local-token-ci-local-token-ci-local-token-ci-local-token \
|
LOCAL_AUTH_TOKEN=ci-local-token-ci-local-token-ci-local-token-ci-local-token \
|
||||||
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:55432/migration_ci \
|
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:55432/migration_ci \
|
||||||
uv run alembic heads | grep -q "(head)"
|
uv run alembic downgrade base && \
|
||||||
|
AUTH_MODE=local \
|
||||||
|
LOCAL_AUTH_TOKEN=ci-local-token-ci-local-token-ci-local-token-ci-local-token \
|
||||||
|
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:55432/migration_ci \
|
||||||
|
uv run alembic upgrade head
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: frontend-build ## Build artifacts
|
build: frontend-build ## Build artifacts
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def upgrade() -> None:
|
|||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.add_column('agents', sa.Column('gateway_id', sa.Uuid(), nullable=False))
|
op.add_column('agents', sa.Column('gateway_id', sa.Uuid(), nullable=False))
|
||||||
op.create_index(op.f('ix_agents_gateway_id'), 'agents', ['gateway_id'], unique=False)
|
op.create_index(op.f('ix_agents_gateway_id'), 'agents', ['gateway_id'], unique=False)
|
||||||
op.create_foreign_key(None, 'agents', 'gateways', ['gateway_id'], ['id'])
|
op.create_foreign_key('fk_agents_gateway_id_gateways', 'agents', 'gateways', ['gateway_id'], ['id'])
|
||||||
op.drop_column('gateways', 'main_session_key')
|
op.drop_column('gateways', 'main_session_key')
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ def upgrade() -> None:
|
|||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.add_column('gateways', sa.Column('main_session_key', sa.VARCHAR(), autoincrement=False, nullable=False))
|
op.add_column('gateways', sa.Column('main_session_key', sa.VARCHAR(), autoincrement=False, nullable=False))
|
||||||
op.drop_constraint(None, 'agents', type_='foreignkey')
|
op.drop_constraint('fk_agents_gateway_id_gateways', 'agents', type_='foreignkey')
|
||||||
op.drop_index(op.f('ix_agents_gateway_id'), table_name='agents')
|
op.drop_index(op.f('ix_agents_gateway_id'), table_name='agents')
|
||||||
op.drop_column('agents', 'gateway_id')
|
op.drop_column('agents', 'gateway_id')
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|||||||
77
backend/scripts/check_migration_graph.py
Normal file
77
backend/scripts/check_migration_graph.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""Migration graph integrity checks for CI.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
- alembic script graph can be loaded (detects broken/missing links)
|
||||||
|
- single head by default (unless ALLOW_MULTIPLE_HEADS=true)
|
||||||
|
- no orphan revisions (all revisions reachable from heads)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
|
|
||||||
|
|
||||||
|
def _truthy(value: str | None) -> bool:
|
||||||
|
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
alembic_ini = root / "alembic.ini"
|
||||||
|
cfg = Config(str(alembic_ini))
|
||||||
|
cfg.attributes["configure_logger"] = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
script = ScriptDirectory.from_config(cfg)
|
||||||
|
except Exception as exc: # pragma: no cover - CI path
|
||||||
|
print(f"ERROR: unable to load Alembic script directory: {exc}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
heads = list(script.get_heads())
|
||||||
|
except Exception as exc: # pragma: no cover - CI path
|
||||||
|
print(f"ERROR: unable to resolve Alembic heads: {exc}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
allow_multiple_heads = _truthy(os.getenv("ALLOW_MULTIPLE_HEADS"))
|
||||||
|
if not heads:
|
||||||
|
print("ERROR: no Alembic heads found")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if len(heads) > 1 and not allow_multiple_heads:
|
||||||
|
print("ERROR: multiple Alembic heads detected (set ALLOW_MULTIPLE_HEADS=true only for intentional merge windows)")
|
||||||
|
for h in heads:
|
||||||
|
print(f" - {h}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
reachable = {rev.revision for rev in script.walk_revisions(base="base", head="heads") if rev.revision}
|
||||||
|
except Exception as exc: # pragma: no cover - CI path
|
||||||
|
print(f"ERROR: failed while walking Alembic revision graph: {exc}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
all_revisions = {
|
||||||
|
rev.revision
|
||||||
|
for rev in script.revision_map._revision_map.values() # type: ignore[attr-defined]
|
||||||
|
if getattr(rev, "revision", None)
|
||||||
|
}
|
||||||
|
orphans = sorted(all_revisions - reachable)
|
||||||
|
if orphans:
|
||||||
|
print("ERROR: orphan Alembic revisions detected (not reachable from heads):")
|
||||||
|
for rev in orphans:
|
||||||
|
print(f" - {rev}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("OK: migration graph integrity passed")
|
||||||
|
print(f"Heads: {', '.join(heads)}")
|
||||||
|
print(f"Reachable revisions: {len(reachable)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user