"""FastAPI application factory, bootstrap, and health endpoints.""" from __future__ import annotations import logging import uuid from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from app.config import get_settings from app.db import Base, get_engine from app.dynsec import dynsec_client from app.models import ManagementRole, ManagementUser from app.security import hash_password logger = logging.getLogger("mqtt-backend") logging.basicConfig(level=logging.INFO) def _bootstrap_admin() -> None: """Create the initial management admin if none exists (spec section 7.1).""" from sqlalchemy import create_engine, select from sqlalchemy.orm import Session settings = get_settings() url = settings.database_url if url.startswith("postgresql://"): url = url.replace("postgresql://", "postgresql+psycopg://", 1) engine = create_engine(url, future=True) with Session(engine) as session: has_admin = session.scalar( select(ManagementUser.id).where( ManagementUser.role == ManagementRole.SUPER_ADMIN ) ) if has_admin is None: if not settings.admin_initial_password: raise RuntimeError( "no administrator exists and ADMIN_INITIAL_PASSWORD is not set" ) session.add( ManagementUser( username=settings.admin_initial_username, password_hash=hash_password(settings.admin_initial_password), role=ManagementRole.SUPER_ADMIN, must_change_password=True, ) ) session.commit() logger.info("bootstrapped initial admin user %r", settings.admin_initial_username) else: logger.info("administrator already present; bootstrap skipped") @asynccontextmanager async def lifespan(app: FastAPI): settings = get_settings() # Create tables on startup for the development stack. Production uses # explicit Alembic migrations (spec section 63) — the tables are # identical because the models are the single source of truth. Base.metadata.create_all(get_engine()) _bootstrap_admin() try: dynsec_client.connect() logger.info("dynamic security channel connected") from app import metrics dynsec_client.set_event_sink(metrics.handle_sys_message) # Best effort: ensure the management identity can read $SYS — # the 2.1 auto-generated admin role set does not guarantee it # (see the compose healthcheck note). sys-observe is one of the # plugin's default roles (read-only $SYS/#); addClientRole is # idempotent broker-side. try: dynsec_client.add_client_role( settings.mosquitto_dynsec_username, "sys-observe", priority=1 ) logger.info("sys-observe role granted to the management identity") except Exception as exc: # noqa: BLE001 logger.warning("could not grant sys-observe: %s", exc) except Exception as exc: # noqa: BLE001 - startup should not crash the API logger.warning("dynsec channel not ready yet: %s", exc) yield dynsec_client.close() app = FastAPI( title="MQTT Management Platform API", version="0.1.0", lifespan=lifespan, ) # The frontend is served by nginx which proxies /api to this backend; CORS # only matters for local development where Vite runs on another port. app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173"] if not get_settings().is_production else [], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.middleware("http") async def correlation_id_middleware(request: Request, call_next): """Attach a correlation ID to every request (spec section 42).""" correlation_id = request.headers.get("X-Correlation-ID") or uuid.uuid4().hex request.state.correlation_id = correlation_id response = await call_next(request) response.headers["X-Correlation-ID"] = correlation_id return response @app.get("/health/live") def health_live(): return {"status": "ok"} @app.get("/health/ready") def health_ready(): """Readiness: database + dynsec management channel (spec section 46).""" checks: dict[str, str] = {} try: from sqlalchemy import text with get_engine().connect() as conn: conn.execute(text("SELECT 1")) checks["database"] = "ok" except Exception as exc: # noqa: BLE001 checks["database"] = f"error: {exc}" checks["dynsec_channel"] = "ok" if dynsec_client._connected.is_set() else "not connected" checks["control_service"] = "ok" # probed lazily on deploy ready = checks["database"] == "ok" return {"status": "ready" if ready else "degraded", "checks": checks} # Routers from app.routers import auth, broker, mqtt, pki, system, users # noqa: E402 app.include_router(auth.router) app.include_router(users.router) app.include_router(mqtt.router) app.include_router(broker.router) app.include_router(pki.router) app.include_router(system.router)