Phase 4: operator documentation and rolling-upgrade-with-drain¶
Context¶
This is phase 4 (final) of PLAN-health-checks.md.
Phases 1–3 built the machinery; this phase makes it usable and
verifiable by operators: the load-balancer probe configuration
and example configs, the rolling-upgrade-with-drain procedure,
and the end-to-end CI test that proves a node drains out of the
pool on SIGTERM before it stops serving (the assertion phase 1f
deferred).
The shape of the deliverables is set by two facts found while planning:
- The doc homes already exist.
docs/operator_guide/load_balancing.mdalready explains why you put an LB in front of sf-api and that SF ships none; it just lacks the health-probe config.docs/operator_guide/upgrades.mdalready describes online upgrades; it lacks the drain-aware rolling procedure (and still carries stale etcd-era prose). We extend both rather than add pages, somkdocs.yml.tmplnav needs no change. - The cluster_ci Python harness cannot restart a daemon —
BaseTestCasetalks to the cluster only throughsystem_client(the API), with no SSH/systemctl seam. So the end-to-end drain test is not a pytest. It is a node-level shell script intools/, invoked fromfunctional-tests.ymlvia the existingtools/run_remote ${primary} "sudo bash tools/<script>.sh"pattern (the same mechanism astools/ci_log_checks.sh/tools/ci_event_checks.sh). This matches CLAUDE.md's "no large scripts inline in CI — put them intools/" rule. (The drain handler logic already has unit coverage intest_gunicorn_drain.py; this phase adds the live, on-a-node proof.)
Key references¶
docs/operator_guide/load_balancing.md— existing page (port 13000, plain HTTP, operator-provided LB, TLS terminated at the LB). Extend with the health-probe section + examples.docs/operator_guide/upgrades.md— existing page; extend with the drain-aware rolling procedure. Note: its opening prose still describes reading/writing objects "from etcd" — etcd is gone (byo-mariadb); flag/fix the stale references touched by the new section, but a full rewrite of the page is out of scope.- The behaviour to document (all already implemented):
/livez(always 200ok),/readyz(200ready/ 503not ready),/healthz(alias of/readyz), unauthenticated on 13000. The LB probes/readyz(routing = readiness).- SIGTERM →
/readyz503 first → serveAPI_DRAIN_GRACE(default 25s) → gunicorn graceful shutdown (--graceful-timeout 30) → exit, withTimeoutStopSec=70sas the systemd cap. - sf-database exposes
grpc.health.v1(probe withgrpc-health-probe), already documented indocs/operator_guide/database.md. - The routing principle: the LB routes to sf-api only; other daemons are internal (gRPC / mesh / WATCHDOG).
- The two PKI domains (edge cert the LB terminates vs mesh mTLS) — health rides the existing LB→sf-api leg (phase-0 OQ9).
.github/workflows/functional-tests.yml— thetools/run_remote ${primary} "..."steps near the end (log checks ~:544, the failure-grep at ~:553, event checks ~:590). The new drain step plugs in here.tools/ci_log_checks.sh,tools/ci_event_checks.sh,tools/run_remote— the precedent for a node-level CI check.
Inherited decisions¶
- The LB probes
/readyz; 200 = in rotation, 503 = drain. The drain window (API_DRAIN_GRACE=25s) assumes a ~10s LB probe interval — the docs must tell operators to set their probe interval and unhealthy-threshold so the LB notices the 503 within the grace, and (phase-0 D6) a generous start-period so a slow first boot is not read as failure. - nginx FOSS has only passive health checks (active
health_checkis NGINX Plus); the docs must say so and give the passive (max_fails/fail_timeout) pattern, not pretend FOSS can actively poll/readyz.
Step-level guidance¶
Sequential where dependent; isolation none; one commit each.
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 4a — LB health-probe docs + example configs | medium | opus | none | Extend docs/operator_guide/load_balancing.md with a "Health checks" section: the three endpoints and their codes; that the LB should health-check /readyz (200 = route, 503 = drain) and that /livez is for an orchestrator/systemctl, not the LB; the unauthenticated-but-firewall-to-the-LB-subnet note; and that probes are cheap (cached, no DB hit). Then example configs for all three the master plan requires: HAProxy (backend with option httpchk GET /readyz + http-check expect status 200, an inter/fall/rise tuned so the 503 is seen within API_DRAIN_GRACE); nginx (FOSS) — be correct: FOSS has only passive checks, so show upstream with max_fails/fail_timeout and proxy_next_upstream, and explicitly state active /readyz polling needs NGINX Plus (or an external prober); one cloud LB — AWS ALB target group (health-check path /readyz, success matcher 200, interval/threshold guidance), noting TLS is terminated at the LB (edge cert) per the two-PKI model. Keep examples minimal and copy-pasteable. Verify the HAProxy/nginx directive names are real. Commit subject: docs: load-balancer health-check configuration for sf-api. |
| 4b — rolling-upgrade-with-drain procedure | medium | sonnet | none | Extend docs/operator_guide/upgrades.md with a "Rolling upgrade with drain" section: the per-node loop — (1) systemctl stop sf-api (SIGTERM) → /readyz flips to 503 → the LB drains the node on its next probe → in-flight requests finish within the grace; (2) upgrade the node's venv; (3) systemctl start sf-api → /readyz returns 200 → back in rotation — repeated node by node for zero-downtime. Cover the ordering with schema migration: run sf-ctl ensure-mariadb-schema (operator-driven, byo-mariadb) before rolling the daemons, since sf-database refuses to start on a stale schema. Note that rolling the elected sf-cluster triggers the watchdog/lease failover to a standby (phase 3), and that non-sf-api daemons are not LB-probed so they just stop/start. While here, fix the stale "from etcd / written back to etcd" prose in the section you touch (MariaDB, not etcd). Commit subject: docs: rolling-upgrade-with-drain procedure. |
| 4c — end-to-end drain CI check | high | opus | none | Add tools/ci_drain_check.sh (a node-level bash script, run as root on a cluster node) that proves the drain live: (1) assert curl -s -o /dev/null -w '%{http_code}' http://localhost:13000/readyz is 200 and /livez is 200; (2) start a tight background poller of /readyz; (3) systemctl stop sf-api & (SIGTERM → drain); (4) assert that within the API_DRAIN_GRACE window /readyz returns 503 while /livez still returns 200 and the gunicorn process is still up (the drain window — readiness flipped before shutdown); (5) wait for the stop to complete; (6) systemctl start sf-api and poll until /readyz is 200 again, so the node is left healthy. Make it robust: bounded timeouts, clear pass/fail echo + non-zero exit on failure, and always restart sf-api on exit (trap) so a failure does not leave the node down. Invoke it from functional-tests.yml via tools/run_remote ${primary} "sudo bash tools/ci_drain_check.sh" at a safe point — before the log/event-failure-grep steps would otherwise flag the expected sf-api stop, and confirm the clean SIGTERM stop (exit 143, SuccessExitStatus) does not match the failure greps at functional-tests.yml:~553 (Main process exited, stop-sigterm.* timed out); if it would, place the drain step after, or add a scoped allowance. Read tools/ci_log_checks.sh and an existing run_remote step for the exact invocation idiom. Commit subject: ci: end-to-end sf-api drain check on a live node. |
| 4d — mark plan complete + final sweep | low | sonnet | none | Flip phase 4 to Complete in the master plan execution table and docs/plans/index.md, and add the master plan's "all phases complete" note. Re-read the master plan's Success criteria and confirm each is met (or note any residual), and ensure README.md / ARCHITECTURE.md / AGENTS.md mention the health surface (most was added in phases 1–3 — fill any gap, e.g. a one-line README pointer). Commit subject: plans: mark health-checks complete. |
Step ordering and dependencies¶
- 4a and 4b are doc-only and independent (different pages); either order.
- 4c is independent of the docs but is the riskiest (it manipulates a live CI node) — land it after 4a/4b so the branch already carries the operator-facing description of what it verifies.
- 4d is last; it confirms the whole plan's success criteria.
Success criteria¶
docs/operator_guide/load_balancing.mddocuments probing/readyzand carries correct, minimal HAProxy, nginx-FOSS, and one-cloud-LB examples (with the nginx-FOSS passive-only caveat stated).docs/operator_guide/upgrades.mddocuments the rolling-upgrade-with-drain procedure including the ensure-mariadb-schema ordering, and no longer claims objects live in etcd in the touched section.tools/ci_drain_check.shexists, is invoked fromfunctional-tests.yml, proves/readyz→503-before-exit on a live node, and always restarts sf-api so the cluster is left healthy; it does not trip the CI log/event failure greps.- The master plan and
index.mdshow all phases Complete, and the master plan's Success criteria are each met or have a recorded residual. pre-commit run --all-filespasses;actionlintaccepts the workflow change.
Back brief¶
Before executing, back-brief the operator: confirm the
three LB examples to ship (HAProxy, nginx-FOSS, AWS ALB — or a
different cloud LB if preferred), the decision to extend the
existing pages rather than add new ones, and the approach to the
CI drain check (node-level tools/ script via run_remote,
self-healing restart) given the harness cannot restart daemons
from pytest. Flag that the drain step manipulates a live CI
node and must be sequenced so it neither breaks concurrent
checks nor trips the failure greps.
Review checklist for the management session¶
Standard checklist from the master plan, plus:
- The LB examples use real directive names and the
nginx-FOSS active-vs-passive caveat is stated (no
pretending FOSS can poll
/readyz). - The upgrade doc has the schema-migration-before-daemons ordering and drops the stale etcd prose it touches.
-
ci_drain_check.shis bounded, self-healing (restarts sf-api on any exit path), and asserts 503-while-still-up (not merely 503 after exit). - The drain CI step does not cause
functional-tests.yml's log/event failure greps to fire on the expected clean stop/start. - All phases show Complete and the master plan's Success criteria are revisited.