94b5caa7e5
Defense-in-depth for the deploy pipeline. Today a backend image shipped ahead of an un-applied migration; the Tree model selected columns the DB didn't have yet, so every trees query 500'd with an opaque UndefinedColumnError and the UI showed no trees. The root cause (deploys not running migrations) is fixed separately; this makes the *symptom* impossible to miss. - app/core/schema_version.py: compare the DB's stamped alembic head to the head(s) baked into the image's migration scripts. A DB with no alembic_version table (e.g. a create_all test DB) is treated as current, so this stays quiet outside real deployments. Uses to_regclass so a missing table never poisons the caller's transaction. - /health/ready: returns 503 with an explicit "drift: db=… expected=…" message when the schema is behind, instead of reporting ready and serving 500s. - Startup lifespan: logs CRITICAL on drift (advisory — never blocks startup). Liveness (/health) is untouched, so a drifted container isn't killed into a crash-loop — it's loudly degraded and self-heals once migrations apply. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
92 lines
3.3 KiB
Python
92 lines
3.3 KiB
Python
"""FastAPI application entrypoint.
|
|
|
|
Thin by design: wire settings, routers, and error handling, and expose the
|
|
OpenAPI contract. All domain logic lives in the service layer; the privacy
|
|
engine is the single enforcement point for reads.
|
|
"""
|
|
|
|
import logging
|
|
import sys
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from app.api.health import router as health_router
|
|
from app.api.v1 import api_router
|
|
from app.core.config import get_settings
|
|
from app.core.db import get_engine
|
|
from app.core.schema_version import schema_is_current
|
|
from app.services.exceptions import Conflict, Forbidden, NotFound
|
|
|
|
|
|
def _configure_logging() -> None:
|
|
"""Emit the app's own ``provenance.*`` logs at INFO to stdout (uvicorn only
|
|
configures its own loggers). The ConsoleMailer relies on this so self-hosters
|
|
can read verification/reset links from the logs."""
|
|
app_logger = logging.getLogger("provenance")
|
|
app_logger.setLevel(logging.INFO)
|
|
if not app_logger.handlers:
|
|
handler = logging.StreamHandler(sys.stdout)
|
|
handler.setFormatter(logging.Formatter("%(levelname)s [%(name)s] %(message)s"))
|
|
app_logger.addHandler(handler)
|
|
app_logger.propagate = False
|
|
|
|
|
|
async def _check_schema_drift() -> None:
|
|
"""On startup, shout if the DB schema is behind the code. The entrypoint
|
|
runs migrations when RUN_MIGRATIONS=1; this catches the case where that
|
|
didn't happen, so a half-applied deploy is obvious in the logs instead of a
|
|
silent storm of 500s. Never blocks startup — purely advisory."""
|
|
logger = logging.getLogger("provenance")
|
|
try:
|
|
async with get_engine().connect() as conn:
|
|
ok, db, expected = await schema_is_current(conn)
|
|
if not ok:
|
|
logger.critical(
|
|
"SCHEMA DRIFT: database is at %s but this build expects %s. "
|
|
"Run 'alembic upgrade head' — queries will fail until migrated.",
|
|
sorted(db) or ["none"],
|
|
sorted(expected),
|
|
)
|
|
except Exception as exc: # noqa: BLE001 — advisory only; never block startup
|
|
logger.warning("schema drift check skipped: %s", exc)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _lifespan(app: FastAPI):
|
|
await _check_schema_drift()
|
|
yield
|
|
|
|
|
|
def _register_error_handlers(app: FastAPI) -> None:
|
|
@app.exception_handler(NotFound)
|
|
async def _not_found(request: Request, exc: NotFound) -> JSONResponse:
|
|
return JSONResponse(status_code=404, content={"detail": str(exc) or "not found"})
|
|
|
|
@app.exception_handler(Forbidden)
|
|
async def _forbidden(request: Request, exc: Forbidden) -> JSONResponse:
|
|
return JSONResponse(status_code=403, content={"detail": str(exc) or "forbidden"})
|
|
|
|
@app.exception_handler(Conflict)
|
|
async def _conflict(request: Request, exc: Conflict) -> JSONResponse:
|
|
return JSONResponse(status_code=409, content={"detail": str(exc) or "conflict"})
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
_configure_logging()
|
|
settings = get_settings()
|
|
app = FastAPI(
|
|
title=settings.app_name,
|
|
version=settings.version,
|
|
description="Provenance API — family and land provenance.",
|
|
lifespan=_lifespan,
|
|
)
|
|
app.include_router(health_router)
|
|
app.include_router(api_router)
|
|
_register_error_handlers(app)
|
|
return app
|
|
|
|
|
|
app = create_app()
|