Fleet convergence — auto-update, release & auto-upgrade triage#
This is the runbook behind the AutoUpgradeCompareFailed and
AutoUpgradeStuckBehindRelease alerts — and, more generally, for any “why is
this host still on last month’s closure?” question. The pipeline’s failure mode
is quiet: the anti-downgrade guard fails closed, so a broken credential,
mirror, or release tip doesn’t page by itself — hosts just stop moving. That is
exactly what happened 2026-08-12..16 (expired compare PAT, plus an unbuildable
release), which is why every guard run now writes a verdict metric and why
those two alerts exist.
The three stages#
STAGE 1 ereshkigal: flake-auto-update.timer (1st & 15th, 03:00 + 30min jitter)
└─ nix run .#update
scoped input update (public inputs only; git+ssh inputs stay pinned)
→ full `om ci run` (the ONLY thing that builds host closures)
→ PR on automation/flake-update; green ⇒ squash auto-merge
│ merge to master
STAGE 2 GitHub Actions release.yml (push to master touching flake.lock)
PROVENANCE GATE: HEAD must have landed via a MERGED PR —
a direct push to master does NOT move the fleet pointer.
→ CalVer tag v<YYYY.MM.DD>[.N] + force-push HEAD:release
Escape hatch: `gh workflow run release.yml` (deliberate human action)
│
STAGE 3 each NixOS host: nixos-upgrade (default 04:30 nightly)
fetches git+http://voile:8418/tsunaminoai/nix-flake-final?ref=release
guard compares current rev vs release tip via the SAME mirror's
compare API — switches ONLY when provably behind (fail-closed)
- Stage 1 —
tsunaminoai.ci.autoUpdateon ereshkigal. The PR is opened before CI runs so a failure is never silent; a red build leaves the PR open with a root-cause comment and never merges. - Stage 2 —
releaseis the fleet’s convergence target, so only om-ci-gated commits may advance it. GitHub’s “Full Flake Check” builds neither host toplevels nor the VM checks — green GitHub CI proves nothing about whether a lock bump builds. The 2026-08-16 unbuildable release shipped precisely by pushingflake.lockstraight to master past a red om ci; the provenance gate makes that a no-op now. - Stage 3 —
tsunaminoai.autoUpgradewrapssystem.autoUpgrade, pinned to the voile Forgejo mirror (tsunaminoai.autoUpgrade.flakeRef) for both fetch and compare (tsunaminoai.autoUpgrade.mirrorApi, Forgejo’s unauthenticated compare API). No GitHub token anywhere in the guard — the old GitHub-compare + PAT design is how one expired token froze the whole fleet silently. Comparing against the same source it fetches from is self-consistent by construction.
The guard switches only on the one provable state — behind > 0 && ahead == 0.
Ahead, diverged, dirty/unknown current rev, unresolvable release ref, or an
unreachable compare API all skip (the previous fail-open behaviour reverted
a deliberate deploy on 2026-08-12, rolling ereshkigal’s gen 409 back to 408 and
taking the netns work with it).
Guard observability#
Every guard run — every exit path — writes to the node-exporter textfile collector:
auto_upgrade_guard{result=…} 1— one ofupgrade,ahead,identical,diverged,compare_failed,dirty,unresolvedauto_upgrade_commits_behind_release— commits the release tip has that the host does not
Two vmalert rules watch them (both severity=warning, see
telemetry):
AutoUpgradeCompareFailed (result compare_failed/unresolved, 48h)#
The guard cannot resolve the release ref or cannot compare against the mirror — the host has silently stopped converging (fail-closed). First three commands:
ssh <host> journalctl -u nixos-upgrade -e # the guard says exactly why it skipped
ssh <host> curl -fsS 'http://voile:8418/api/v1/repos/tsunaminoai/nix-flake-final/compare/<current_rev>...<release_rev>'
curl -fsS http://voile:8418/api/v1/version # is the Forgejo mirror itself up / mirroring?
Usual causes: voile (or its Forgejo container) down, the mirror sync stalled so
the host’s rev is unknown to it (404 → empty compare), or the host is running a
dirty working-tree rev from a deploy-rs push (expected dirty skip, not a
fault — it clears on the next release that supersedes the deploy).
AutoUpgradeStuckBehindRelease (behind > 0, 72h)#
The guard can compare and the host is provably behind, yet never converges — the nightly switch itself is failing. Most likely the release tip does not build on this host, or the run keeps getting skipped. First three commands:
ssh <host> journalctl -u nixos-upgrade -e # find the failing build/switch
ssh <host> systemctl list-timers nixos-upgrade # is the timer even firing?
nix log <failing-drv> # root-cause the build failure
om ci triage#
om ciis the only gate that builds host closures. Never bypass a red om ci by pushingflake.lockto master — and since the provenance gate, doing so no longer movesreleaseanyway.- “Cannot build X” lines are scheduler cascade, not causes. Of the
.drvpaths named on error lines, only those nix has a build log for actually ran and failed. Root-cause withnix log <drv>(works even for remote-builder failures) or rebuild locally with--builders "" --max-jobs 4. - The PR failure comment now does this extraction itself: it posts up to three root failures (drvs with build logs, last 30 lines each), skipping the cascade noise, plus the last 80 lines of CI output in a collapsible block. The 2026-08-15 comment predating this was a wall of cascade that led triage to disable a bystander VM check instead of the one real error behind it.
Manual interventions#
Re-run stage 1#
On ereshkigal: trigger-flake-auto-update (starts the systemd unit with its
proper environment). Running nix run .#update by hand as root breaks on
“No writable cache directories” and other env differences; from your own user
in a clean checkout it works, and nix run .#update -- --dry-run exercises the
whole pipeline without pushing.
Advance the release deliberately#
gh workflow run release.yml — the workflow_dispatch path skips the
merged-PR provenance check. This is a deliberate human action; make sure the
commit actually builds fleet-wide first.
Deploy a host now (push path)#
deploy is NOT on PATH; resolve the locked input, and always raise the confirm
timeout — the node default (60s) rolled back a large ereshkigal switch
mid-activation:
DRPATH=$(nix build --no-link --print-out-paths --impure \
--expr '(builtins.getFlake "'$PWD'").inputs.deploy-rs.packages.x86_64-linux.deploy-rs')
"$DRPATH/bin/deploy" .#<host> -s --confirm-timeout 120
- deploy-rs ships the working tree. To deploy committed state from a dirty
tree:
"$DRPATH/bin/deploy" "git+file://$PWD?rev=<sha>#<host>" -s --confirm-timeout 120. (A working-tree deploy leaves the host on a-dirtyrev, which the stage-3 guard then skips by design until a release supersedes it.) - Magic rollback is on: a failed confirm reverts automatically, no outage.
- mokou rejects SSH-to-self — when working on mokou use
nh os switch .#. - myon and the aarch64 Pis never build on-box (
remoteBuild = falsevialowMemoryHostsinmodules/flake/deploy/default.nix) — closures are built locally and pushed.
Gotchas#
- The libvirt-from-unstable overlay trap (broke the 2026-08 bump): never pull a library with same-version Python bindings (libvirt being the proven case) from unstable alone — the stable bindings’ generator hard-fails against the newer lib’s API XML. Bump both sides together or neither.
- The stage-1
github/automation-tokenPAT (fine-grained: Contents RW, Pull requests RW, Metadata R) is used ONLY by the scheduler on ereshkigal — the stage-3 guard needs no GitHub at all anymore. After any rotation, verify before deploying:curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $(sops --decrypt --extract '["github"]["automation-token"]' secrets.yaml)" https://api.github.com/repos/tsunaminoai/nix-flake-finalmust print200— the 2026-08 rotation shipped a dead token silently. - Piping build output (
| tee,| tail) eats exit codes without pipefail — gate on the command, not the pipe. nix run .#updaterefuses dirty trees and (live runs) non-master branches by design; privategit+sshinputs (corp/praxis) are left pinned because the automation host has no SSH creds for them.
Related#
- Deployment — the full deploy system this pipeline is the “eventually” half of (deploy-rs, deploy-* apps, the release workflow).
- Telemetry — where the guard metrics land and the full alert-rule catalogue.
- Alerts & Notifications — how the two AutoUpgrade warnings reach you (ntfy; warnings never hit Discord).
- Gitea — the voile Forgejo mirror that stage 3 both fetches from and compares against.