state-hub/api/services/schema_state.py
tegwick 97c8762a71
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 27s
feat(deploy): run migrations as part of the release, and report schema state
Central was serving two revisions behind the code it shipped: review_contracts
did not exist there although its migration was inside the running image. There
was no migration mechanism at all — bare uvicorn CMD, nothing chart-declared —
and nothing surfaced the mismatch. The API starts happily against a schema it
was not built for and only fails when a request touches a missing table.

Adds a chart-managed Helm pre-install/pre-upgrade hook running alembic upgrade
head, weighted to complete before the API rolls. A hook rather than an init
container: init containers run per pod, so more than one replica means
concurrent alembic upgrade with no locking. Failed jobs are deliberately
retained — a migration that fails and vanishes is how this drifted in the first
place.

/state/health now reports applied and expected revisions. "unknown" is
deliberately not "ok": an instance that cannot establish agreement must not
claim it, the same principle as instance_role defaulting to unknown.

Refs STATE-WP-0083-T07

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-26 00:00:09 +02:00

63 lines
2.2 KiB
Python

"""Report whether the database schema matches the code (STATE-WP-0083-T07).
Central ran two revisions behind the code it was serving, and `review_contracts`
did not exist there although its migration shipped inside the running image.
Nothing surfaced that: the API starts happily against a schema it was not built
for, and only fails when a request happens to touch the missing table.
A hub that cannot say which schema it is running has the same problem as a
projection that cannot name its source commit — it is asserting correctness it
cannot demonstrate.
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
_MIGRATIONS = Path(__file__).resolve().parents[2] / "migrations"
@lru_cache(maxsize=1)
def code_head_revision() -> str | None:
"""The head revision the shipped migration scripts define.
Read from the migration files rather than the database: this is what the
code expects, and it must be knowable without a working connection.
"""
try:
from alembic.config import Config
from alembic.script import ScriptDirectory
cfg = Config()
cfg.set_main_option("script_location", str(_MIGRATIONS))
heads = ScriptDirectory.from_config(cfg).get_heads()
return heads[0] if len(heads) == 1 else ",".join(sorted(heads)) or None
except Exception:
return None
async def db_revision(session: AsyncSession) -> str | None:
try:
result = await session.execute(text("select version_num from alembic_version"))
return result.scalar()
except Exception:
return None
async def schema_state(session: AsyncSession) -> dict[str, Any]:
applied = await db_revision(session)
expected = code_head_revision()
# "unknown" is deliberately not "ok": an instance that cannot determine its
# own schema state must not report agreement it has not established.
if applied is None or expected is None:
status = "unknown"
elif applied == expected:
status = "ok"
else:
status = "behind"
return {"status": status, "applied": applied, "expected": expected}