"""The app factory and its lifespan. `create_app(settings)` is a factory rather than a module-level `app = FastAPI()` because a test needs an app pointed at a throwaway Postgres, and an import-time app reads the real environment at import time — before any fixture can say otherwise. """ from __future__ import annotations import asyncio from collections.abc import AsyncIterator from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from jwt import PyJWKClient from starlette.exceptions import HTTPException from services.api.models import ErrorBody from services.api.routes import health, instances from svcforge_core import obs from svcforge_core.adapters.redis import RateLimiter, make_redis from svcforge_core.domain.catalog import load_catalog from svcforge_core.repo.db import make_pool from svcforge_core.settings import Settings, load_settings log = obs.get_logger("svcforge.api") @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: """Open the pool, yield, close the pool. A lifespan context, not the deprecated startup/shutdown decorators: those cannot express "this resource lives exactly as long as the app" and leave no place to put teardown next to setup. Closing matters — an unclosed pool leaves connections open server-side after SIGTERM, and on a pooled Postgres with a small budget a few rolling deploys exhaust it. (The old decorator's name is spelled nowhere here on purpose: CI greps for the literal string, so a comment quoting it fails the gate as loudly as a call would.) """ settings: Settings = app.state.settings app.state.catalog = load_catalog(settings.catalog_path) # Redis is optional by construction: `make_redis` returns None when no DSN is set and # every consumer treats None as "skip", so a deployment without Redis loses rate # limiting and keeps everything else. Built once here, not per request. redis = make_redis(settings) app.state.redis = redis app.state.rate_limiter = ( RateLimiter(redis, limit=settings.rate_limit_per_minute, window_s=60) if redis is not None else None ) pool = make_pool(settings.runtime_dsn, settings.pool_min_size, settings.pool_max_size) # wait=True fails NOW, loudly, if the DSN is wrong — instead of at the first request, # as a PoolTimeout, in front of a user. await pool.open(wait=True) app.state.pool = pool # The pool is open from here, so everything below is inside the try: an exception in # JWKS setup must still close it, or a crash-looping pod leaks a connection per restart # until the database refuses new ones. try: if settings.jwks_url and not settings.auth_disabled: client = PyJWKClient(settings.jwks_url, cache_keys=True, lifespan=300) app.state.jwks_client = client # Warm the cache off the loop so the first authenticated request does not pay a # blocking urlopen. Best-effort: a slow IdP must not stop the pod from starting, # and a miss later costs one to_thread hop. try: await asyncio.to_thread(client.get_signing_keys) except Exception: # deliberate catch-all: startup must not hinge on the IdP being up log.warning("JWKS warm-up failed; keys will be fetched on first use", exc_info=True) else: app.state.jwks_client = None yield finally: await pool.close() if redis is not None: await redis.aclose() # --------------------------------------------------------------------------- API docs # What the generated schema cannot express. Kept next to create_app because /docs is what # someone integrating reads, and they do not have this repo. USER_GUIDE.md is the longer form. API_DESCRIPTION = """ Provision managed service instances into Kubernetes. The catalog offers Elasticsearch, Redis and Postgres, plus two deliberately tiny entries — `podinfo` and `nginx` — for exercising the platform where there is no room for the real thing. ## Authentication Every `/v1` route needs a bearer JWT: `Authorization: Bearer `. The token is verified against the configured JWKS (RS256), and its `team` claim decides which instances you can see. **Authorisation is a WHERE clause** — asking for another team's instance returns `404`, not `403`, so the API never confirms that an id you cannot access exists. ## Writes are asynchronous `POST` and `DELETE` return **202 Accepted**, not 201/204. They enqueue work and return immediately; nothing is provisioned yet when you get the response. Poll `GET /v1/instances/{id}` and watch `state`. ## Instance lifecycle requested -> provisioning -> ready | v deleting -> deleted `failed` is reachable from `requested` and `provisioning` when a provision exhausts its retries. A `ready` instance whose release vanished is re-provisioned automatically by the reconciler, so `ready` is the only state that carries a usable `endpoint`. ## Errors Every non-2xx body is the same shape — `{"code": ..., "message": ...}` — including the 404s and 405s raised by the framework itself. `code` is stable and meant for machines; `message` is for humans. """ OPENAPI_TAGS = [ { "name": "instances", "description": "Create, inspect and delete service instances. All writes are 202 + poll.", }, { "name": "ops", "description": ( "Liveness, readiness and Prometheus metrics. Unauthenticated, and not part of " "the tenant API surface." ), }, ] async def _http_exception_handler(request: Request, exc: Exception) -> JSONResponse: """Render HTTPException bodies as ErrorBody, so every error has one shape. Handlers raise `detail={"code": ..., "message": ...}`, which FastAPI's default would nest under `{"detail": {...}}`. Plain-string details (a framework 405, say) are wrapped so clients never branch on the body's type. Registered on starlette's HTTPException, not fastapi's. The FastAPI class is a subclass and Starlette matches handlers by walking `type(exc).__mro__`, so a handler keyed on the subclass never fires for a framework-raised 404 or 405. Keying on the parent catches both, and the branch below renders each into ErrorBody. """ assert isinstance(exc, HTTPException) # noqa: S101 - registered only for HTTPException # Widened to object deliberately: Starlette types `detail` as str, but FastAPI passes # through whatever a handler raised, and ours raise dicts. Narrowing off the declared # type would let mypy call the dict branch unreachable and delete it. detail: object = exc.detail if isinstance(detail, dict) and "code" in detail and "message" in detail: body = ErrorBody(code=str(detail["code"]), message=str(detail["message"])) else: body = ErrorBody(code=f"http_{exc.status_code}", message=str(detail)) return JSONResponse(status_code=exc.status_code, content=body.model_dump(), headers=exc.headers) async def _validation_exception_handler(request: Request, exc: Exception) -> JSONResponse: """Render request-validation failures as ErrorBody too. A forbidden extra field, a bad type or an out-of-range ttl_days raises RequestValidationError, which the handler above never sees. Without this, FastAPI's default `{"detail": [...]}` is a second 422 shape alongside the handlers' ErrorBody. """ assert isinstance(exc, RequestValidationError) # noqa: S101 - registered only for this return JSONResponse( status_code=422, content=ErrorBody(code="validation_error", message=str(exc.errors())).model_dump(), ) def create_app(settings: Settings | None = None) -> FastAPI: """App factory: lifespan, routers, exception handler, /metrics.""" settings = settings or load_settings() # FIRST, before any router is built and any logger is bound. Without it the API is the # one service of three that never configures structlog, and its lines go out through # logging.lastResort as bare text on stderr — no service, no trace_id, no JSON envelope. # `settings.log_json` was silently inert here for the same reason. obs.setup("svcforge-api", settings) # Refuse the dev escape hatches when SVCFORGE_ENVIRONMENT says this is not a laptop. # Unconditional and early: a check that runs only from a branch someone remembered to # write is a check that does not run. settings.check_production() # The description is the API's documentation, rendered as markdown at /docs. It is the # only place a caller without this repo learns the two things the schema cannot say: # every write is asynchronous, and the lifecycle is a state machine they have to poll. app = FastAPI( title="svcforge", version="0.1.0", summary="X-as-a-Service control plane", description=API_DESCRIPTION, openapi_tags=OPENAPI_TAGS, lifespan=lifespan, ) app.state.settings = settings # /metrics is a normal route on health.router, not an app.mount — see health.metrics # for why the mount does not actually serve a bare /metrics. app.include_router(health.router) app.include_router(instances.router) app.add_exception_handler(HTTPException, _http_exception_handler) app.add_exception_handler(RequestValidationError, _validation_exception_handler) return app def app() -> FastAPI: """Entry point for `uvicorn services.api.main:app --factory`.""" return create_app() # No `if __name__ == "__main__"` here on purpose. `services/api/__main__.py` is the single # entrypoint and the image's ENTRYPOINT uses it. A second one in this module drifted from # it — different log_level, different access_log — so the same app started two ways.