c53734d2bc
ci / lint (push) Successful in 33s
ci / types (push) Successful in 43s
ci / unit (push) Successful in 32s
ci / security (push) Successful in 57s
ci / dockerfile (push) Successful in 7s
ci / chart (push) Successful in 8s
ci / integration (push) Successful in 55s
ci / image (api) (push) Successful in 3m39s
ci / image (reconciler) (push) Successful in 2m53s
ci / image (worker) (push) Successful in 2m14s
ci / bump (push) Successful in 16s
The comment pass is prose-only: every distinct "why" is kept, the narration around it is not. Verified by AST-comparing each changed file against HEAD with docstrings stripped — only the two files below differ in executable code. Two real fixes fell out of the read-through: * The CLI documented itself as never touching the database, then called load_settings(), which requires SVCFORGE_PG_DSN. It refused to start without a Postgres URL it never opens. It now has its own two-field ClientSettings; the orphaned api_url/api_token are dropped from Settings, where nothing else read them. * repo/db.py had the DictRow alias comment and the ERROR_MAX_CHARS comment run together above the wrong symbol. USER_GUIDE.md is the caller-facing guide the README only gestured at: auth, catalog, every endpoint with curl, the lifecycle, the error table, rate limiting, the CLI, client generation, an end-to-end poll loop. It records two facts about the live deployment rather than documenting a flow nobody can run. SVCFORGE_JWKS_URL points at a realm with no IdP behind it, so the API logs "JWKS warm-up failed" at startup and every /v1 request is a 401. And `helm repo list` in the worker returns no repositories, so the three bitnamilegacy/ catalog entries cannot resolve at provision time; only the oci:// entries can. make lint clean, 76 unit + 111 integration tests pass.
225 lines
9.9 KiB
Python
225 lines
9.9 KiB
Python
"""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 <token>`. 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.
|