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
+81 -94
View File
@@ -1,29 +1,28 @@
"""The control loop.
Every other service in svcforge is edge-triggered: a tenant POSTs, a row appears, a worker
claims it. Edge-triggered systems are correct exactly as long as nothing is ever missed —
and things are missed. A worker is SIGKILLed holding a lease. An operator runs
`helm uninstall` by hand. A pod dies between the CAS and the enqueue. Nobody sends an event
for any of that, because the thing that would have sent it is the thing that died.
Every other service here is edge-triggered: a tenant POSTs, a row appears, a worker claims
it. That is correct only as long as nothing is missed, and things are missed — a worker
SIGKILLed holding a lease, an operator running `helm uninstall` by hand, a pod dying
between the CAS and the enqueue. Nothing sends an event for any of it, because the thing
that would have sent it is the thing that died.
So: level-triggered. Every 60 seconds, compare the world to the database and enqueue what
is missing. The four checks below do not know or care what went wrong, or whether anything
did; they are the same code on the happy path and after an outage. That property is the
entire reason this service exists, and it is why each check is written as a *query for
work*, never as a reaction to an event.
is missing. The four checks below do not know what went wrong, or whether anything did;
they are the same code on the happy path and after an outage. That is why each is written
as a query for work rather than a reaction to an event.
Three rules hold the design together:
* **Singleton.** `replicas: 1`, `strategy: Recreate` in the chart. Two reconcilers
double-enqueue drift and race on TTL. There is no leader election here on purpose: the
correct lease for that lives in Postgres next to the data, not in a Redis lock, and
until there is a second replica to elect between, an election is a subsystem that can
only fail. One pod, and the `SvcforgeReconcilerStale` alert is what notices it is gone.
* **Each check is independent.** One failing check must not skip the other three. A helm
binary that cannot reach the API server must not stop TTLs from expiring.
* **Enqueue, never act.** The reconciler diagnoses; workers treat. It writes task rows and
instance states, and never calls `helm install`. The one exception is reading — the drift
check lists the live releases, because seeing reality is the job.
* **Singleton.** `replicas: 1`, `strategy: Recreate`. Two reconcilers double-enqueue drift
and race on TTL. No leader election on purpose — the right lease for that lives in
Postgres next to the data, and until there is a second replica to elect between, an
election is a subsystem that can only fail. The `SvcforgeReconcilerStale` alert notices
when the one pod is gone.
* **Each check is independent.** A helm binary that cannot reach the API server must not
stop TTLs from expiring.
* **Enqueue, never act.** The reconciler diagnoses and workers treat: it writes task rows
and instance states and never calls `helm install`. Reading is the exception, since
seeing reality is the job.
"""
from __future__ import annotations
@@ -63,9 +62,9 @@ log = get_logger("svcforge.reconciler")
class ReconcilerDeps:
"""Everything a check is allowed to touch. Built once in main(), passed down.
Same shape as `WorkerDeps` for the same reason: the checks take `deps` instead of
reaching for globals, so the integration tests below run every check against a real
Postgres and a `FakeProvisioner` without a cluster anywhere in sight.
Same shape as `WorkerDeps` and for the same reason: checks take `deps` instead of
reaching for globals, so the integration tests run every check against a real Postgres
and a `FakeProvisioner` with no cluster in sight.
"""
pool: DictPool
@@ -90,21 +89,17 @@ class ReconcilerDeps:
async def check_drift(deps: ReconcilerDeps) -> None:
"""The live helm releases versus what the database believes.
This is the only check that looks outside Postgres, and the only one that can catch the
failure nothing else can: someone ran `helm uninstall` by hand, or a node was drained
and the release never came back. The DB still says `ready` and still hands the tenant an
endpoint that resolves to nothing.
The only check that looks outside Postgres, and the only one that catches someone
running `helm uninstall` by hand or a drained node whose release never came back — where
the DB still says `ready` and still hands the tenant an endpoint resolving to nothing.
Two directions, two very different answers:
Two directions, two different answers:
* **Release gone, DB says `ready`** -> re-enqueue provision. Safe, because provisioning
is `helm upgrade --install` against a deterministic release name: converging on
desired state, not a blind re-install.
* **Release exists, DB knows nothing** -> log at error with release and namespace, and
stop. **Never delete in v1.** The reconciler's view of "the DB knows nothing" is one
query against one database; the release might belong to another team, another tool,
or a migration half-finished. Deleting on that evidence is how an automated system
takes down production faster than any human could. A human reads the log and decides.
* **Release gone, DB says `ready`** -> re-enqueue provision. Safe because provisioning
is `helm upgrade --install` against a deterministic release name.
* **Release exists, DB knows nothing** -> log at error and stop. **Never delete in v1.**
"The DB knows nothing" is one query against one database, and the release might belong
to another team, another tool, or a half-finished migration. A human decides.
"""
with tracer().start_as_current_span("helm.list"):
releases = await deps.provisioner.list_releases()
@@ -132,31 +127,30 @@ async def check_drift(deps: ReconcilerDeps) -> None:
{"instance_id": str(inst.id), "team": inst.team},
)
except Exception:
# The task is already committed; the notification is a courtesy. A webhook
# timing out must not abandon the rest of the sweep — the instances after this
# one in the loop have the same problem and nobody else is coming to find them.
# The task is committed; the notification is a courtesy. A webhook timing out
# must not abandon the rest of the sweep — the instances after this one have the
# same problem and nobody else is coming to find them.
log.exception("notify.failed", instance_id=str(inst.id))
known = await deps.reconcile.known_releases()
for name, namespace in sorted(live - known):
# error, not warning: this is a resource nobody is billing for and nobody owns.
# It will sit here every 60s until a human deletes it or adopts it. That is the
# intended pressure.
# error, not warning: a resource nobody owns and nobody is billing for. It repeats
# every 60s until a human deletes or adopts it, which is the intended pressure.
log.error("drift.orphan_release", release=name, namespace=namespace, action="none (v1 never deletes)")
async def check_lease_expiry(deps: ReconcilerDeps) -> None:
"""Tasks whose worker died -> back to `queued`.
A lease. No lock survives a power cut: a worker SIGKILLed mid-provision
leaves `state='running'` with `locked_by` set and nobody running it, and no amount of
cleanup code in the worker helps, because the worker is the part that died. `locked_at`
plus a timeout is the only thing that recovers the row, which is why `locked_at` exists.
A lease, not a lock: no lock survives a power cut. A worker SIGKILLed mid-provision
leaves `state='running'` with `locked_by` set and nobody running it, and cleanup code in
the worker cannot help because the worker is what died. `locked_at` plus a timeout is
the only thing that recovers the row.
The 5-minute default must exceed the longest a healthy task can hold a lease, or the
reconciler hands a still-running provision to a second worker. Handlers are idempotent,
so that is survivable, though it still costs a duplicated helm run — which is why
`lease_seconds` sits above helm's `--timeout`.
The 5-minute default must exceed the longest a healthy task can hold a lease, or a
still-running provision is handed to a second worker. Handlers are idempotent so that is
survivable, but it costs a duplicated helm run — hence `lease_seconds` > helm's
`--timeout`.
"""
freed = await deps.tasks.reset_expired_leases(deps.settings.lease_seconds)
if freed:
@@ -166,14 +160,13 @@ async def check_lease_expiry(deps: ReconcilerDeps) -> None:
async def check_ttl(deps: ReconcilerDeps) -> None:
"""Expired instances -> `deleting`, plus a deprovision task.
The line item that stops a demo cluster from becoming a permanent cloud bill. Also the
sweep the API's DELETE route depends on: it CASes to `deleting` and enqueues in two
statements, and a crash in between lands here on the next tick.
What stops a demo cluster becoming a permanent cloud bill, and the sweep the API's
DELETE route depends on: DELETE CASes and enqueues in two statements, and a crash
between them lands here on the next tick.
Idempotent by construction — the work list excludes anything that already has a queued
or running deprovision, and the CAS and the insert share one transaction. Without that
guard, a deprovision that takes longer than 60 seconds gets a second task on the next
tick, and a third on the tick after.
Idempotent by construction — the work list excludes anything with a queued or running
deprovision, and the CAS and insert share one transaction. Without that, a deprovision
taking longer than 60 seconds collects a new task every tick.
"""
for inst in await deps.reconcile.due_for_deprovision():
task_id = await deps.reconcile.enqueue_deprovision(inst.id)
@@ -192,20 +185,18 @@ async def check_ttl(deps: ReconcilerDeps) -> None:
async def check_version_drift(deps: ReconcilerDeps) -> None:
"""The day-2 rollout: the work-list query, one service type at a time.
Everything that makes this safe is somewhere else, which is the point:
Everything that makes it safe is somewhere else, which is the point:
* `list_upgradable` limits to `max_in_flight` and returns nothing while
`rollout_state='halted'`, so a bad chart stops after one tenant.
* `schedule_upgrade_at` turns the tenant's maintenance window into a `run_after`; the
queue does the waiting, in `where run_after <= now()`. There is no scheduler here and
there must not be one a task parked in Postgres until 03:00 Sunday survives a
reconciler restart, and an in-memory timer does not.
* `security: true` in the catalog bypasses the window. A CVE with a public exploit does
not wait until Sunday.
* `schedule_upgrade_at` turns the maintenance window into a `run_after` and the queue
does the waiting, in `where run_after <= now()`. No scheduler here, and there must not
be one: a task parked in Postgres until 03:00 Sunday survives a restart, a timer does
not.
* `security: true` in the catalog bypasses the window.
A bad window spec is this instance's problem, not the fleet's: log it and move to the
next one. Failing the whole check would let one tenant's typo freeze everyone's
security rollout.
A bad window spec is one instance's problem: log it and move on. Failing the check would
let one tenant's typo freeze everyone's security rollout.
"""
now = deps.clock.now()
@@ -260,23 +251,20 @@ CHECKS: dict[str, Callable[[ReconcilerDeps], Awaitable[None]]] = {
async def tick(deps: ReconcilerDeps) -> None:
"""One pass: all four checks, then the gauges, then the heartbeat.
Checks first, gauges second: `svcforge_queue_depth` is read straight after the checks
that add to the queue, so the value scraped is the value the tick left behind rather
than one from before its own work.
Checks first, gauges second, so `svcforge_queue_depth` reports what this tick left
behind rather than what preceded its own work.
The heartbeat is set unconditionally, and that is deliberate. It answers "is the loop
running", not "is everything fine" — the checks have their own alerts. Gating it on
success would make `SvcforgeReconcilerStale` fire for a helm blip and mean two things
at once, and an alert that means two things gets muted.
The heartbeat is set unconditionally. It answers "is the loop running", not "is
everything fine" — the checks have their own alerts. Gating it on success would make
`SvcforgeReconcilerStale` fire for a helm blip and mean two things at once, and an alert
that means two things gets muted.
The whole tick runs inside one span, which is a considered exception to "manual spans go
around helm calls only". That rule exists so the API does not hand-roll spans that
`opentelemetry-instrument` already creates for it. Nothing auto-instruments the
reconciler: without a span here it emits no traces at all, and — because
`inject_traceparent` serialises the *active* context — every task it enqueues would be
written with a null `traceparent` and be unjoinable to the tick that decided to create
it. One span per tick is what makes "why was this instance re-provisioned at 03:00?" a
question the traces can answer.
The whole tick runs in one span, a considered exception to "manual spans wrap helm calls
only". That rule keeps the API from hand-rolling spans `opentelemetry-instrument`
already makes; nothing auto-instruments the reconciler, so without this it emits no
traces at all and — since `inject_traceparent` serialises the *active* context — every
task it enqueues would carry a null `traceparent` and be unjoinable to the tick that
created it.
"""
with tracer().start_as_current_span("reconciler.tick"):
await _run_checks(deps)
@@ -291,12 +279,11 @@ async def _run_checks(deps: ReconcilerDeps) -> None:
try:
await check(deps)
except Exception: # the tick is the error boundary
# The swallow is the design. These four checks share nothing but a database
# handle, and the value of a level-triggered loop is that it keeps running: an
# unreachable cluster must not stop TTLs from expiring, and one tenant's broken
# The swallow is the design. The four checks share nothing but a database
# handle, and a level-triggered loop is only worth having if it keeps running:
# an unreachable cluster must not stop TTLs expiring, and one tenant's broken
# window spec must not stop drift detection. This means "this check achieved
# nothing for 60 seconds", which the log says out loud. It never means "the
# reconciler stops".
# nothing for 60 seconds", never "the reconciler stops".
log.exception("check.failed", check=name)
try:
@@ -311,10 +298,10 @@ async def _run_checks(deps: ReconcilerDeps) -> None:
async def run_reconciler(deps: ReconcilerDeps, stop: asyncio.Event) -> None:
"""Tick, sleep, repeat, until told to stop.
Tick first, then sleep: a pod that has just been restarted should reconcile now, not in
sixty seconds. Fixed interval rather than a fixed period a tick that overruns simply
delays the next one, instead of stacking a second tick on top of the first, which for a
singleton would be exactly the concurrent reconciler `replicas: 1` exists to prevent.
Tick first, then sleep: a just-restarted pod should reconcile now, not in sixty seconds.
Fixed interval rather than fixed period, so a tick that overruns delays the next one
instead of stacking a second on top — which for a singleton is exactly the concurrent
reconciler `replicas: 1` exists to prevent.
"""
while not stop.is_set():
await tick(deps)
@@ -353,13 +340,13 @@ async def _amain(once: bool, own_team: str, max_in_flight: int) -> None:
try:
if once:
# One pass and exit: the acceptance path, and how you drive a reconcile by hand
# from a shell. No metrics server — nothing would ever scrape it.
# One pass and exit: the acceptance path, and how to drive a reconcile by hand.
# No metrics server — nothing would ever scrape it.
await tick(deps)
return
# settings.metrics_port, like the worker. SVCFORGE_METRICS_PORT still overrides it,
# through pydantic rather than a second CLI option, so the port has one definition.
# settings.metrics_port, like the worker. SVCFORGE_METRICS_PORT overrides it through
# pydantic rather than a second CLI option, so the port has one definition.
start_metrics_server(settings.metrics_port)
stop = asyncio.Event()