Skip to content

Batch 4C pt6: the validated paired-release install + live-cutover runbook - #197

Merged
mousebrains merged 9 commits into
mainfrom
b4c-runbook
Jun 15, 2026
Merged

Batch 4C pt6: the validated paired-release install + live-cutover runbook#197
mousebrains merged 9 commits into
mainfrom
b4c-runbook

Conversation

@mousebrains

@mousebrains mousebrains commented Jun 14, 2026

Copy link
Copy Markdown
Owner

Batch 4C — increment 6: the validated paired-release install and live-cutover runbook

Started as "rewrite the install runbook §5 to use the renderers"; it grew into the single end-to-end runbook for both a virgin install and the no-downtime migration of the running WKCC host, every section rehearsed on a VM (and now the stage-only path on the live host itself).

What changed

The rendered install (the original scope):

  • §5 is rendered, not hand-crafted. render-units --out-dir /etc/systemd/system emits the six engine cutover drop-ins (audit-gauges is an engine consumer now, refactor(audit): promote audit_gauges to levels audit-gauges (4C prep) #191); render-serving emits the nginx root + FPM open_basedir, applied with sed-from-the-rendered-value so the values are deterministic from host.yaml rather than hand-typed. The deployer's gate (step 7) re-verifies all of it.
  • Docroot is the Cleanup: FK + reach.name fixes, smarter USGS tz, slow markers, scripts retire #3 shared cache /var/cache/kayak/docroot throughout (layout table, OUTPUT_DIR, nginx root, open_basedir) — the release no longer carries a docroot, and open_basedir drops the /opt/kayak/releases entry.
  • New host.yaml install (step 2) flips the generated-cache paths off the read-only release; new /var/cache/kayak/{docroot,map-layers,gauge-metadata} dirs + the docroot recursive+default www-data ACL in §6. §5d sets the serving knobs the gate requires (KAYAK_NGINX_DOCROOT_CONF / KAYAK_FPM_POOL, fail-closed); KAYAK_HOST_UNITS removed (the gate sources its set from render-units --list-units, Batch 4C pt4: gate sources engine set from render-units --list-units (D-CONSUMER) #196).

The live-cutover material (added after rehearsing it):

  • "Migrating a RUNNING host" section — the no-downtime ordering (Phase 0 pre-stage alongside → Phase 1 apply config to FILES only, defer the reload → Phase 2 activate/migrate the live DB → Phase 3 graceful reload flip). reload not restart for FPM (restart drops workers → transient 404).
  • Failed-cutover rollback + recovery — the deployer restores the DB backup + config and the old public_html keeps serving; recover by retry.
  • "Where the old single-tree layout maps to" table~/kayak, ~/.venv, ~/kayak_data, ~/public_html, ~/DB, ~/var, ~/logs, ~/.config/kayak, ~/backups → their new homes, with the four dispositions (into the immutable release / into the cache / unchanged-by-design / kept-in-place for the un-re-pointed shell consumers).
  • Post-cutover cleanup + per-release disk hygiene — what's safe to remove after the soak (with an explicit DO-NOT-REMOVE list) and what accumulates per release (releases auto-prune to KAYAK_KEEP_RELEASES; .staging auto-cleans on success, manual sweep only after a failed deploy).
  • Live stage-only learnings — the zero-setup pre-flight smoke check (inline ENGINE_REPO/DATASET_REPO), the release-id-differs-without-host.yaml caveat, and the staged-dir cleanup.

Validation status

Path Where Result
Virgin install, steps 1–8 clean arm64 Debian 13 VM (×2) serving paired-release host, gate passing; 2nd run clean first-try off the updated runbook
Running-host migration, Phase 0–3 live-equivalent VM clone (×2, full live DB) zero serving disruption; the cutover also fixed the live audit-gauges stale unit
Failed-cutover rollback VM clone, forced activation failure DB + config restored, old public_html never dropped, retry recovers
Stage-only dry run the live amd64 host, 2026-06-14 clean pass — HTTPS fetch + ref verify, amd64 wheel from the locks, validate-dataset OK, digest computed; no mutation

The renderers are unit-tested; nginx -t / systemd-analyze verify of the rendered output happen on the VM, not in CI.

CI

Doc-only. CI shellchecks deploy/*.sh but not .md, and no test references the runbook (deploy/ is outside the ruff/mypy scope) — verified referenced files exist.

Remaining (not in this PR)

  • The live WKCC cutover execution (yours to drive; snapshot first, rollback is the net).
  • A short post-cutover truing-up follow-up to capture what the real flip teaches (real TLS/certbot + vhost server_names, real Turnstile keys, prod disk math, live drain timing) — the deferred genericization knobs land there.

🤖 Generated with Claude Code

mousebrains and others added 8 commits June 14, 2026 15:23
…derers

Increment 6 of Batch 4C. The install/cutover runbook (deploy/INSTALL-paired-
release.md) predated the renderers, #3, and #196; its step 5 was hand-crafted sed.
Brought current with everything that landed:

- §5 is now RENDERED, not hand-crafted: `render-units --out-dir /etc/systemd/system`
  emits the 6 engine cutover drop-ins (audit-gauges is an engine consumer now,
  #191); `render-serving` emits the nginx `root` + FPM `open_basedir`, applied with
  sed-from-the-rendered-value so the values are deterministic from host.yaml.
- Docroot is the #3 shared cache `/var/cache/kayak/docroot` everywhere (layout,
  OUTPUT_DIR, nginx root, open_basedir) — the release no longer carries a docroot.
  The open_basedir drops the /opt/kayak/releases entry (PHP execs the
  self-contained docroot, never the release).
- New step-2 host.yaml install (the cutover flips the generated-cache paths off the
  read-only release); new /var/cache/kayak/{docroot,map-layers,gauge-metadata} dirs
  + the docroot recursive+default www-data ACL in §6 (else freshly built pages 403).
- §5d sets the serving-path knobs the gate now requires (KAYAK_NGINX_DOCROOT_CONF /
  KAYAK_FPM_POOL, fail-closed); KAYAK_HOST_UNITS removed (gate sources its set from
  `render-units --list-units`, #196). ENGINE_BRANCH=main (#190 merged).
- The "Still [4C-TODO] for the renderers" section is resolved; what remains is the
  increment-7 VM re-validation of the rendered flow + the live cutover, and the
  deferred genericization (vhost server_names / certbot-root knobs).

The base install (steps 1–4, 6–8) is VM-validated; step 5's rendered flow is the
spec for the increment-7 cutover rehearsal (renderers unit-tested; `nginx -t` /
`systemd-analyze verify` of the output happen on the VM).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drove the full runbook on a virgin Debian 13 VM (levels-mac) end-to-end — virgin
net-install → serving paired-release host, homepage + DB-backed description.php
both 200, the activation gate passing on render-units --list-units / nginx / FPM.
Findings folded in:

- §5a: create the self-signed /etc/nginx/ssl/dummy.{crt,key} the default-server
  block (deploy/nginx-default-server, 444 on bare-IP) requires — `nginx -t` fails
  on the missing file otherwise. This was the one hard break; it's undocumented in
  SETUP.md's legacy path too (noted).
- Header: status updated to VALIDATED end-to-end (incl. the rendered step 5), and
  clarified that the live WKCC *migration* (vs. this virgin install) is increment 7.
- §2 / §5d: UNCOMMENT the deploy.env example lines (ENGINE_BRANCH, SERVING_CUTOVER,
  the serving knobs) via sed instead of appending, so there's no commented+active
  duplicate.
- §5c: note that `systemd-analyze verify` reporting the ExecStart "not executable"
  pre-activation is expected (same as the render-units warning) — `current` doesn't
  exist until step 7; the merged unit structure is valid.
- "Still open": the virgin VM validation is done; what remains is the live
  migration (keep old serving up, reuse the live DB, retire scripts/deploy.sh).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… runbook

Rehearsed the live cutover (running host → paired-release) end-to-end on a
live-equivalent VM clone (full live DB — 4.46M obs, 77 migrations) with ZERO
serving disruption, and captured it as a "Migrating a RUNNING host" section.

The validated no-downtime ordering:
- Phase 0: pre-stage the paired-release ALONGSIDE the running site (dirs, host.yaml,
  deploy.env, stage-only) — no mutation, public_html keeps serving.
- Phase 1: apply the cutover config to the FILES only (render-serving → nginx root
  + FPM open_basedir via sed; render-units → drop-ins) and stop the timers, but DO
  NOT reload nginx/FPM — the running services hold the old in-memory config, so
  public_html keeps serving. Skip install-config.sh (pool/runtime-config already
  exist; a reload now would serve the empty new docroot).
- Phase 2: activate — the gate passes on the FILES, `migrate` reports "No pending"
  against the live DB (no init-db — reuse it), the build writes the new docroot,
  symlink switch. Still serving public_html.
- Phase 3: graceful `nginx reload` + `fpm restart` → flip to the new docroot.

Confirmed: serving stayed 200 throughout (public_html → new docroot at the flip);
the live DB migrated clean; and the cutover FIXED the live audit-gauges unit (the
#191 promotion lands via render-units, which scripts/deploy.sh never did). "Still
open" trimmed to: execute the real cutover + the deferred genericization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…no-outage)

Second migration rehearsal on a fresh live-equivalent clone (verbatim from the
runbook) passed end-to-end — release staged alongside the running site, config
applied to files with no reload (public_html kept serving 200), activation gate
passed + `migrate` "No pending" on the live DB, build → new docroot, then the flip.

One real finding: `systemctl restart php8.4-fpm` in the flip drops all workers and
has a brief not-ready window — I caught a transient `description.php` 404 curling
right after it (the file/ACL/open_basedir were all fine; a re-curl was 200). Switch
Phase 3 to `systemctl reload php8.4-fpm` + `php-fpm8.4 -t`: verified on the VM that
reload is graceful (200 immediately after, no window) AND re-reads the pool config
(dropping the docroot from open_basedir + reload → 404; restoring → 200). So a live
cutover flips with zero PHP outage. (The first rehearsal used restart and got lucky
on timing.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ran a failed-first-cutover rollback rehearsal on a live-equivalent clone: forced
the activation to fail at the health check (bad HEALTH_URL) AFTER the DB mutation
+ symlink switch. Confirmed the safety net:
- the deployer restored the 625 MB DB backup (obs back to 4,465,536) + the runtime
  config, removed `current`, left maintenance on + consumers stopped (no-prior-
  release path);
- the OLD public_html kept serving 200 throughout — a failed cutover does NOT take
  the site down, because Phase 1-2 never reload nginx/FPM;
- recovery by fixing the cause + retrying (`rm -f /opt/kayak/maintenance`; re-run
  kayak-deploy.sh) re-activated cleanly and the Phase-3 flip served the new docroot,
  DB intact.

Added the recovery note to the migration section (retry vs full abort), and noted
that the config files point at the new docroot post-failure so a stray nginx reload
would switch to it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Where the old single-tree layout maps to" table to the running-host
migration section, answering "where did ~/kayak, ~/.venv, ~/kayak_data,
~/public_html, ~/DB, ~/var, ~/logs, ~/.config/kayak, ~/backups go?"

Distinguishes the four dispositions, each verified against the units/config
on the live host:
- moved into the immutable release: the engine (~/kayak src + ~/.venv) →
  /opt/kayak/current/venv, the dataset (~/kayak_data) → current/dataset;
- moved to the regenerable cache: ~/public_html → /var/cache/kayak/docroot (#3),
  plus the two in-tree caches ~/kayak/{var/osmb,Gauge-metadata-cache};
- unchanged by design: ~/DB, ~/var/status.html, ~/logs, ~/backups (mutable
  state + host-level units, all keyed off HostConfig fields / open_basedir);
- kept-but-in-place: the ~/kayak checkout + ~/.venv stay because the cutover
  does NOT re-point the shell-script consumers (kayak-recap/heartbeat/
  config-drift/healthcheck/cert-expiry/backup-*, which ExecStart from
  ~/kayak/{scripts,systemd}/ and recap.sh still calls ~/.venv/bin/python3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two additions to the running-host migration section, both grounded in the
deployer's actual behavior:

Post-cutover cleanup (one-time, after a few days' soak) — what's safe to remove
once the paired release is proven: the old ~/public_html docroot (superseded by
/var/cache/kayak/docroot) and the two in-tree caches that relocated
(~/kayak/var/osmb, ~/kayak/Gauge-metadata-cache → /var/cache/kayak/{map-layers,
gauge-metadata}). Plus an explicit DO-NOT-REMOVE list: ~/kayak + ~/.venv stay
(the un-re-pointed shell consumers — recap/heartbeat/config-drift/healthcheck/
cert-expiry/backup-* — still ExecStart from them, and recap.sh runs
~/.venv/bin/python3), and the mutable/unchanged-by-design dirs stay. Corrects
the prior line that called ~/.venv "unused".

Ongoing disk hygiene (what accumulates per release) — a table of growth +
bound: /opt/kayak/releases/ auto-prunes to KAYAK_KEEP_RELEASES (default 5,
never current/previous); /opt/kayak/.staging auto-cleans on success but is
deliberately kept (CLEAN_SCRATCH=0) on a failed/rolled-back deploy to preserve
the ~650 MB pre-activate DB backup — the one recurring manual sweep, after the
restore is verified. Net: steady state is self-bounding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…at, cleanup)

Three additions validated by the 2026-06-14 live amd64 stage-only dry run:

- Pre-flight smoke check: stage-only needs only ENGINE_REPO/DATASET_REPO, so
  passing them inline proves prod's amd64 wheel build + dataset validation +
  digest with zero config files and zero mutation. Documented in the running-host
  migration section as the first de-risking step before any Phase-0 setup.
- Release-id caveat (step 3): a no-host.yaml dry run computes a DIFFERENT release
  id than the activation (the id folds in the host-config fingerprint +
  normalized runtime-config). Calls it out so the differing id isn't read as
  instability.
- Staged-dir cleanup (step 3): stage-only RETAINS releases/<id>/ for inspection;
  note to rm it (only .staging auto-cleans on exit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mousebrains mousebrains changed the title Batch 4C pt5: rewrite paired-release runbook §5 to use the renderers Batch 4C pt6: the validated paired-release install + live-cutover runbook Jun 15, 2026
@mousebrains

Copy link
Copy Markdown
Owner Author

Adversarial review — PR #197 (Batch 4C pt6: the paired-release install + live-cutover runbook)

Doc-only, but this is the runbook that will drive the live cutover of the WKCC host, so I reviewed it as an operational procedure — cross-checking every sed/path/unit claim against this host's actual installed config and the deployer/renderer behavior from #193#196. CI is still running (doc-only, so just shellcheck of deploy/*.sh, which this PR doesn't touch). The procedure is sound and impressively well-rehearsed; the no-downtime ordering is correct. Three things to fix before the real flip, plus a minor.

Verified correct against the live host

1. (Medium) Internal contradiction: "3 engine units" vs the actual 6

The ~/.venv row of the mapping table (line ~331) says:

"The cutover re-points the 3 engine units (kayak-status, kayak-fetch-osmb, kayak-audit-gauges) at the release venv."

That's wrong — it's 6, and the same doc says so elsewhere (the cleanup DO-NOT-REMOVE list: pipeline, decimate, editor-retention, status, fetch-osmb, audit-gauges). I confirmed on this host that all six currently ExecStart=/home/pat/.venv/bin/levels … and render-units --list-units emits all six — so all six re-point, not three. In a runbook that will be executed live, a wrong/short engine-unit list invites "did decimate/editor-retention/pipeline get missed?" mid-cutover. Fix the count and the list in that row.

2. (Medium) The Phase 1 → Phase 2 window roots nginx at an empty docroot — guard it against a stray reload

Phase 1 edits the nginx file to root /var/cache/kayak/docroot; (created empty in Phase 0) and deliberately does not reload; the docroot isn't populated until the Phase 2 activation build. That's the right no-downtime design — but for the multi-minute window between Phase 1 and the end of the Phase 2 build, the on-disk nginx config points at an empty docroot, so any nginx reload in that window flips live serving to 404s. The runbook only mentions "a stray reload would switch to it" in the failure-recovery note, not as a proactive happy-path guard. On this host the vector is real: certbot.timer is enabled and active (a renewal that reloads nginx would land mid-window), plus any manual/config-management reload. (logrotate is not a risk — invoke-rc.d nginx rotate is a log reopen/USR1, not a config reload.) Recommend one of:

  • Mask the reloaders for the cutover: systemctl stop certbot.timer (+ anything that reloads nginx/FPM) before Phase 1, restore after Phase 3; or
  • Pre-build the docroot in Phase 0 (… OUTPUT_DIR=/var/cache/kayak/docroot $R/venv/bin/levels build from the staged release) so it's never empty — a stray reload then serves a valid, if slightly stale, site instead of 404s. The Phase 2 build overwrites it anyway.

3. (Medium-low) The "pre-flight already disabled the legacy auto-deploy" is referenced but never a step

The cleanup section (line ~443) asserts "the pre-flight already disabled any cron/timer that git-pulls main or runs scripts/deploy.sh" — but no Phase 0 / pre-flight step in the runbook actually does that disabling. This host has a known nightly snapshot that git pulls main (the #100 half-deploy incident). If it survives the cutover, it keeps updating the ~/kayak editable checkout — harmless for the release-pinned engine units, but it (or a stray scripts/deploy.sh) could rebuild the now-shared docroot from the editable tree and fight the paired release. Make the disable an explicit Phase 0 step (or, if there's genuinely no such job on this host, drop the dangling "already disabled" reference so it doesn't read as a satisfied precondition).

4. (Low) "migrate reports 'No pending'" may not hold on the live DB

The live-migration note states migrate reports "No pending … when the host already runs the deployed engine." Given the same nightly-pull-without-migrate gap (#100), the live DB can lag the merged engine, in which case the cutover's migrate will apply the pending migrations (correct and desirable — the deployer backs up first) rather than report "No pending." Soften the wording so an operator doesn't read an applied-migration as anomalous.

Verdict

Approve in spirit — the runbook is thorough, VM- and stage-only-validated, and its serving/ACL/reload mechanics check out against the live config. #1 (wrong unit count) and #2 (empty-docroot reload window, with certbot.timer active) are the two I'd want fixed before driving the real WKCC cutover; #3/#4 are accuracy hardening on the same operation.

— Claude Opus-4.8(1M)/xhigh

@mousebrains

Copy link
Copy Markdown
Owner Author

Correction to finding #3 (the "legacy auto-deploy" point).

I checked this against the live host and could not locate any auto-pull/auto-deploy mechanism: no root crontab (sudo crontab -lno crontab for root), no pat crontab, no systemd timer matching snapshot/pull/deploy, and no repo unit/script that git pulls main or runs scripts/deploy.sh on a schedule (scripts/deploy.sh/release.sh are manual). My phrasing "this host has a known nightly snapshot that git-pulls main" came from prior (#100) context and was not verified against the current host — apologies for stating it as fact.

So finding #3 reduces to a doc-consistency point only: the cleanup section asserts "the pre-flight already disabled any cron/timer that git-pulls main or runs scripts/deploy.sh," but there is no such pre-flight step in the runbook and no such job appears to exist here. Action: either add the disable as an explicit Phase 0 step if a root-owned scheduler I can't see does exist, or drop the dangling "already disabled" reference so it doesn't read as a satisfied precondition.

Findings #1 (3-vs-6 engine units), #2 (empty-docroot reload window; certbot.timer active), and #4 (migrate "No pending" wording) are unaffected.

— Claude Opus-4.8(1M)/xhigh

All four cross-checked against the live host's installed config:

1. (Medium) Mapping table `~/.venv` row said the cutover re-points "3 engine
   units" — it's 6. Verified all of pipeline/decimate/editor-retention/status/
   fetch-osmb/audit-gauges currently ExecStart=~/.venv/bin/levels (= render-units
   --list-units). Fixed the count + list; noted the basis.

2. (Medium) Empty-docroot reload window: Phase 1 roots nginx at the
   Phase-0-empty docroot and defers the reload, so any nginx reload before the
   Phase-2 build 404s live serving — and certbot.timer is active on this host.
   Added an explicit guard (stop certbot.timer in Phase 1, restart in Phase 3)
   + documented the pre-build-the-docroot alternative with its schema caveat.

3. (consistency) The cleanup section referenced a "pre-flight already disabled"
   step that didn't exist (and the reviewer verified no auto-deploy scheduler
   exists here). Added a real read-only Pre-flight checklist (no-auto-deploy
   check, df headroom, the Phase-1 sed targets, ~/kayak clean+on-main) and
   reworded the cleanup reference to point at it.

4. (Low) Softened the live-migration "migrate reports No pending" wording: if
   the DB lags the deployed engine, migrate APPLIES the pending migrations
   (deployer backs up first) — both outcomes are normal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mousebrains

Copy link
Copy Markdown
Owner Author

Thanks — all four addressed in c8771ed, each re-checked against this host.

#1 (3-vs-6 engine units) — fixed. Confirmed all six (pipeline, decimate, editor-retention, status, fetch-osmb, audit-gauges) currently ExecStart=~/.venv/bin/levels = render-units --list-units. The ~/.venv mapping row now says 6 with the full list, consistent with the DO-NOT-REMOVE list.

#2 (empty-docroot reload window) — guarded. Added an explicit sudo systemctl stop certbot.timer at the top of Phase 1 (the named active reloader) with start restored in Phase 3, and documented the stronger pre-build-the-docroot-in-Phase-0 alternative (never-empty) with its one caveat: the staged engine builds against the live DB, fine when the DB already runs that engine's schema, can mismatch if migrations are pending — so pause-the-reloader is the simpler default.

#3 (dangling "already disabled" ref) — both halves done. Your verification (no root/pat crontab, no timer, scripts/deploy.sh manual) is now baked in: added a real Pre-flight (read-only checks) section — no-auto-deploy check (crontab -l ×2 + list-timers), df headroom, the two Phase-1 sed targets, ~/kayak clean+on-main — and reworded the cleanup #3 comment to reference that step ("inert once pre-flight confirmed … and disabled any it found") instead of asserting a phantom disable.

#4 (migrate "No pending" wording) — softened. Now: reports "No pending" if the DB matches the deployed engine, or applies the pending migrations (deployer backs up first) if it lags — both normal.

Also for the record from the live host since this review: Phase 0 is now placed and verified on levels.wkcc.orgdeploy.env (HTTPS repos) + host.yaml (the 3 cache flips) in place, real pre-cutover release-id f3e884d09c3b, render-serving/render-units confirmed rooting /var/cache/kayak/docroot. No serving disruption; activation still refused (SERVING_CUTOVER unset).

@mousebrains
mousebrains merged commit 723cb00 into main Jun 15, 2026
9 checks passed
@mousebrains
mousebrains deleted the b4c-runbook branch June 15, 2026 00:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant