docs: add USER_GUIDE.md, tighten comments, fix CLI needing a DSN
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.
This commit is contained in:
Nguyen Minh Phuc
2026-07-21 11:18:57 +00:00
parent 7918dc2b37
commit c53734d2bc
22 changed files with 936 additions and 872 deletions
+43 -52
View File
@@ -1,7 +1,7 @@
"""The app factory and its lifespan.
`create_app(settings)` is a factory, not a module-level `app = FastAPI()`, for one reason:
a test needs an app pointed at a throwaway Postgres, and an import-time app reads the real
`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.
"""
@@ -32,23 +32,21 @@ log = obs.get_logger("svcforge.api")
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Open the pool, yield, close the pool.
A lifespan context, not the deprecated startup/shutdown event decorators: those cannot
express "this resource lives for exactly as long as the app", and give you no place to
put the teardown next to the setup. Closing the pool matters — an unclosed pool means
connections linger server-side after SIGTERM, and on a pooled Postgres with a small
connection budget a few rolling deploys exhaust it.
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 in this package on purpose: CI greps for
the literal string, and a comment quoting it fails the gate just as loudly as a call.)
(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 here rather than per request because a
# connection pool per request is a connection pool per request.
# 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 = (
@@ -61,16 +59,16 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await pool.open(wait=True)
app.state.pool = pool
# The pool is open from here on, 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.
# 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 identity provider must not stop the
# pod from starting — a cache miss later just costs one to_thread hop.
# 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
@@ -87,9 +85,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# --------------------------------------------------------------------------- API docs
# Everything a caller needs that the generated schema cannot express on its own. Kept next
# to create_app rather than in a README because /docs is what someone integrating actually
# reads, and a README in this repo is not something they have.
# 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
@@ -144,21 +141,19 @@ OPENAPI_TAGS = [
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": ...}`; FastAPI's default would nest
that under `{"detail": {...}}`. Plain-string details (raised by FastAPI itself, e.g.
a 405) are wrapped so clients never have to branch on the body's type.
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. fastapi.HTTPException 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 — which are
starlette.HTTPException instances. Keying on the parent catches both: app handlers
raise the FastAPI subclass with a dict detail, the framework raises the parent with a
str detail, and the branch below renders each into ErrorBody.
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 — our handlers raise dicts. Narrowing off the
# declared type would make mypy call the dict branch unreachable and delete it.
# 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"]))
@@ -170,10 +165,9 @@ async def _http_exception_handler(request: Request, exc: Exception) -> JSONRespo
async def _validation_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Render request-validation failures as ErrorBody too.
A body that fails validation (a forbidden extra field, a bad type, an out-of-range
ttl_days) raises RequestValidationError, which the HTTPException handler above never
sees. Without this it returns FastAPI's default `{"detail": [...]}` a second 422 shape
alongside the ErrorBody 422s the handlers raise. This gives every 422 one shape.
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(
@@ -186,22 +180,20 @@ 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 before any logger is bound. Without this the
# API is the one service of three that never configures structlog: its lines go out
# through logging.lastResort as bare text on stderr with no service, no trace_id and
# no JSON envelope — a parse failure in the collector, and unattributable in Loki.
# 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.
# Called unconditionally and early: a check that only runs from a branch someone
# remembered to write is a check that does not run.
# 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. FastAPI renders it as markdown at /docs,
# and it is the only place a caller who does not have this repo can learn the two things
# that are not obvious from the schema: every write is asynchronous, and the instance
# lifecycle is a state machine they have to poll.
# 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",
@@ -227,7 +219,6 @@ def app() -> FastAPI:
return create_app()
# There is deliberately no `if __name__ == "__main__"` here. `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 `python -m services.api`
# and `python services/api/main.py` started the same app two different ways.
# 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.