Files
Nguyen Minh Phuc 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
docs: add USER_GUIDE.md, tighten comments, fix CLI needing a DSN
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.
2026-07-21 14:51:26 +00:00

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.