Skip to content

feat(core): roll back an update that never reaches a good boot - #60

Open
ajfriesen wants to merge 10 commits into
mainfrom
rollback
Open

ajfriesen wants to merge 10 commits into
mainfrom
rollback

Conversation

@ajfriesen

Copy link
Copy Markdown
Owner

Closes #20. Implements ADR 0009, now accepted.

Summary

An update that cannot come back up undoes itself. Three attempts, then the previous generation — and the refused release is written down so it is not offered again.

Boot counting and the gate that decides what a good boot is were per-board, and two of three boards simply never got a copy. Both now live in modules/core/ and reach every board through core/default.nix, which is what stops the next board being added without them.

 modules/
 ├── core/
+│   ├── good-boot.nix          # bootCounting (3 tries) + the gate, every board
 │   └── default.nix            # imports it, so a new board cannot miss it
 └── hardware/
-    └── generic-x86-disk.nix   # had the gate; rpi4/rpi5 had nothing
+    └── generic-x86-disk.nix   # loader config only
 daemon/
+├── rollback.go                # was this boot a rollback, and from which release
 └── update.go                  # skips refused releases until a newer one ships

The gate no longer requires greetd. A board whose GPU the kernel cannot drive has no kiosk session, so the old condition marked every boot bad and would have rolled the device back repeatedly over a fault no older generation fixes. What is left is the daemon answering /healthz — the device is reachable, so recovery is possible, and nothing more.

How a rollback is noticed

The bootloader decides this two stages before userland and leaves no file and no unit behind, so it is derived from three symlinks the system already maintains:

profile == current == booted     an ordinary boot
profile == current != booted     an update switched, has not rebooted yet
profile != current == booted     the loader abandoned the profile's generation

Reading current-system as well as booted-system is what separates the last line from the second: immediately after an update switches, the profile and the booted system also disagree, and reading only those two would report every pending update as a rollback.

The rejected release's version comes out of that generation's own closure rather than its NixOS label — a label is tags and nixpkgs version joined by hyphens, so dashboard-assistant-1.6.0-rc1-26.11… cannot be split back apart.

The refused versions are persisted in stateDir, which the symlinks cannot carry. Deriving them from the profile alone was wrong for the second rollback in a row:

roll back from 1.6.0  ->  profile names 1.6.0, skip it
install 1.7.0         ->  profile names 1.7.0, 1.6.0 forgotten
roll back from 1.7.0  ->  1.6.0 offered again, fails again, alternates forever

A release leaves the record when the device is running it — the way back for one refused by mistake, since the version picker still offers a rejected release.

Reported to Home Assistant

Three fields on the state snapshot, omitted entirely on a device that never rolled back:

 "update": {
   "installed_version": "1.4.0",
   "latest_version": "1.4.0",
+  "rolled_back": true,
+  "rejected_version": "1.5.0",
+  "rejected_generation": 2
 }

Reported separately from last_error because nothing failed to install: the switch was clean and was reported as done, and the generation only gave up hours later on the next boot. The integration ships from its own repo and does not surface these yet — needs a follow-up issue there.

Evidence

The question that kept this switched off: boot counting needs systemd-boot to set LoaderBootCountPath, a volatile EFI variable, at runtime — from a loader bootctl had to install with --no-variables at the removable fallback path. Unrelated flags in principle, untested upstream (nixpkgs' own bootCounting test runs with canTouchEfiVariables = true).

x86, in a VM — nix build .#checks.x86_64-linux.good-boot, five boots, 116 s:

Before:  no such check
After:   LoaderBootCountPath present
         ha-boot-health[525]: daemon healthy; blessing this boot
         systemd-bless-boot[739]: Marked boot as 'good'. (Boot attempt counter is at 1.)
         gate fails x3 -> entry +0-3 marked bad -> boots the previous generation
         profile != current == booted   (the state daemon/rollback.go reads)

Raspberry Pi 5, on the bench — just hw-test:

Before:  no such assertions
After:   test_the_loader_counted_and_blessed_this_generation PASSED
         test_the_good_boot_gate_never_failed               PASSED

The image's ESP ships nixos-1302d171…+3.conf and the booted board has no counter on any entry. Only systemd-bless-boot removes one, and it runs only when the loader set that variable — so boot counting works through U-Boot's UEFI, --no-variables and all.

The daemon, go test -race ./...:

TestRollbackNamesTheRejectedRelease              names 1.5.0 from the profile's generation
TestNoRollbackOnTheLookAlikeStates               4 states that must not read as rollbacks
TestRejectedReleaseIsNotOffered                  Latest stays at installed, picker marks it
TestANewerReleaseClearsTheSkip                   1.6.0 offered, 1.5.0 stays behind it
TestRejectionsOutliveTheNextUpdate               the two-bad-releases loop, closed
TestInstallingARejectedReleaseClearsIt           the way back
TestSnapshotReportsARollback                     the wire names the integration reads

Neither half can pass vacuously: the new rollback-is-armed flake check asserts the image writes a counter on all three boards, and the bench asserts one was removed. Verified red both ways — enable = false and tries = 5 each fail, naming every board.

All 7 flake checks, 177 bench unit tests, gofmt/go vet/nixfmt/ruff green.

Merge Danger

Door: two-way.

Revert is a revert. Nothing migrates, nothing is destroyed, and the one piece of persisted state (stateDir/rejected-releases) is a plain file that a reverted build simply stops reading. A device already on an older release keeps booting it.

Blast Radius: every board's boot path.

This arms a mechanism that can choose a different generation to boot, on every board, which is the sharpest thing in the repo to get wrong. Worth knowing before merging:

  • The expensive direction of wrong is a gate that is too strict — it would spend a healthy release's three attempts and roll back a working update. Hence /healthz only: not greetd, not Home Assistant reachability, not the screen.
  • A freshly flashed device has nothing to fall back to. If it never reaches a good boot it keeps trying that one, and the fix is to reflash. Automatic rollback protects updates, not flashing.
  • A switch that dies inside switch-to-configuration leaves the same three symlinks as a rollback, because nixos-rebuild sets the profile before it activates. Both mean "told to run a release, not running it", so both get the same treatment; what would tell them apart is the spent counter on the ESP, which is umask=0077 root-only. last_error distinguishes them in practice. Documented in rollback.go.
  • good-boot is the slowest CI job — a real systemd-boot booted five times, the only check needing nested reboots.

Three failures in the bench run are not from this branch and are left alone: test_journal_archive_survives_a_reboot (flash-wear.nix, reproducible), the test_journey.py errors (device_ip is session-scoped and first evaluated seconds after that reboot, so it sees end0 DOWN), and test_nix_builds_outside_the_tmpfs (30 s console timeout on nix config show). On an uncounted boot — which the post-reboot boot is — nothing here executes: boot-complete.target has no [Install] and is pulled in only by systemd-bless-boot's generator, which needs the counter blessing just removed.

🤖 Generated with Claude Code

ajfriesen and others added 10 commits October 20, 2026 00:42
Boot counting was off on every board and the gate that decides whether a
boot was good existed on x86 only, so two of three boards had nothing
assessing their updates at all. Both now live in modules/core/good-boot.nix
and reach every board through core/default.nix, which is what stops the
next board from being added without them.

The gate no longer requires greetd. A board whose GPU the kernel cannot
drive has no kiosk session, so the old condition marked every boot bad and
would have rolled the device back repeatedly over a fault no older
generation fixes. What is left is the daemon answering /healthz, which is
what a good boot means here: the device is reachable, so recovery is
possible (CONTEXT.md, docs/adr/0009).

Refs #20

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A boot-counting rollback is decided by the bootloader, two stages before
userland, and leaves no file and no unit behind. So it is derived from
three symlinks the running system already maintains: the system profile
still points at the generation that was switched to, while the booted and
the running system are both the one the loader fell back to. Reading
current-system as well as booted-system is what separates a rollback from
an update that has switched but not rebooted, which leaves the profile and
the booted system disagreeing in exactly the same way.

The rejected release's version comes out of that generation's own closure
rather than its NixOS label, because a label is tags and nixpkgs version
joined by hyphens and a prerelease cannot be split back out of one.

Nothing is persisted: the profile keeps pointing at the rejected
generation until the next update switches, which is exactly as long as the
rejection is meant to last.

Refs #20

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Whether a loader installed with --no-variables at the removable fallback
path can still count boots is why boot counting stayed switched off: the
two flags are unrelated in principle, the combination is untested upstream
(nixpkgs' own bootCounting test runs with canTouchEfiVariables = true),
and getting it wrong is silent — no counter, no blessing, no failed unit,
and a device with no safety net under its updates that looks like it has
one.

tests/good-boot.nix now boots a real systemd-boot with the x86 target's
loader settings and answers it: LoaderBootCountPath is set, the boot is
blessed, and a generation whose gate fails three times is abandoned for
the previous one. It ends on the exact symlink disagreement
daemon/rollback.go reads, so the Go side is known to parse a state real
hardware produces. 116 s, five boots.

The Pi half cannot be asked of a VM — there the UEFI environment is
U-Boot's — so labgrid/test_good_boot.py asks a board whether automatic
rollback is armed.

Refs #20

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four pages said rollback was manual and that switching boot counting on
was tracked separately. They now describe what the device does: three
attempts to come back up on the network, then the previous generation, the
refused release skipped until a newer one ships, and the rollback reported
to Home Assistant. Recovery is reframed as what rollback cannot see — a
device that boots fine but misbehaves.

Refs #20

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deriving the rejection from the system profile alone was wrong for the
second rollback in a row. Roll back from 1.6.0, install 1.7.0, roll back
from that too: the loader skips the bad 1.6.0 entry and lands on 1.5.0,
the profile now names 1.7.0, and 1.6.0 is forgotten — so it is offered
again, fails again, and the device alternates between two releases it has
already refused. Exactly the loop ADR 0009 exists to prevent.

The refused versions are therefore written to stateDir, which outlives the
switch that retires the symlink evidence. A release leaves the record when
the device is running it, which is the way back for one refused by
mistake: the version picker still offers a rejected release, so a user who
freed space on a full ESP can install it, and succeeding clears it.

Also documents what the symlinks cannot distinguish: nixos-rebuild sets
the profile before it activates, so a switch that dies inside
switch-to-configuration leaves the same three links in the same
arrangement as a rollback. Both mean the device was told to run a release
and is not running it, so both warrant the same action; what would tell
them apart is the spent boot counter on the ESP, which is root-only.

Refs #20

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ha-boot-health` read as a Home Assistant unit checking boot "health", and
both halves were wrong: Home Assistant reachability is deliberately not
part of the decision (the unit's own header argues why), and "boot health"
is the spelling CONTEXT.md tells us to avoid for a **good boot**. Every
other unit this project ships is `dashboard-assistant-*`.

Moving it out of the x86 target was the moment to fix it, so it is
`dashboard-assistant-good-boot` now, matching the module, the ADR and both
tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CONTEXT.md lists "failed update" and "bad update" under _Avoid_ for a
**rejected release**, and "healthy" under _Avoid_ for a **good boot**;
three sentences used them anyway.

features.md also claimed the `update` entity carries the refused release.
The device reports it on its state snapshot, but the integration ships
from its own repository and does not surface it yet, so the pages now say
the device reports the rollback rather than that Home Assistant shows it.

The rollback sections also describe what the device actually remembers now
— every refused release, not only the most recent — and that installing
one from the picker takes it off the list.

Refs #20

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With bootCounting off, boot entries are simply written without a counter.
Nothing fails, nothing is logged, and a device looks exactly like one
being assessed right up to the update that needed the safety net and
found none — the same class of invisible regression kiosk-flash-flags
exists for.

It is also the other half of a bench assertion that would otherwise go
quiet. labgrid/test_first_boot.py proves a counter was *removed* from the
ESP, which is only evidence of blessing if one was written in the first
place; this is what guarantees that. Neither can pass vacuously while
both are in place.

Reads all three boards, because this is per-board bootloader config
reached through modules/core/default.nix: an override in one hardware
module would leave the other two armed and say nothing.

Refs #20

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bench run reported three failures and one pass in test_good_boot.py,
and the pass was the real defect: test_no_entry_is_left_counting passes
precisely when boot counting is dead, which is the vacuous-pass trap that
file's own header warned about.

Boot counting applies to a *freshly installed* generation. systemd-boot
writes the entry with a counter, sets LoaderBootCountPath, and
systemd-bless-boot then removes the counter from the filename. Every
later boot of that generation is uncounted: no counter, no variable, and
— because the bless generator is what pulls in boot-complete.target —
the good-boot gate correctly never runs. test_good_boot.py sorted after
test_first_boot.py, whose last test reboots the board, so all three
assertions landed on an uncounted second boot and measured nothing.

So they move into test_first_boot.py's boot-chain section, above that
reboot, and are rewritten to read the entry filenames rather than this
boot: a counter still present is a failure however the run is ordered, an
empty directory is a failure, and a blessed entry is proof wherever it is
found. The gate is checked for never having *failed* rather than for
being active, since inactive is the right answer on an uncounted boot.

This is also how the Pi answer was reached: the image ships its entry
with +3 and the board has none left, and only systemd-bless-boot removes
a counter — so systemd-boot does set LoaderBootCountPath under U-Boot's
UEFI despite --no-variables. rpi-disk-image.nix now records it.

Refs #20

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`systemctl cat` pages. On the serial console stdout is always a tty, so
`less` opened and sat waiting for a keypress that never came — the shell
never returned to a prompt and every command after it timed out. One line
in the good-boot gate assertion produced 22 failures and 15 errors in a
bench run, none of them about the device.

The gate is now asked the way test_growpart_succeeded asks: `systemctl
show -p … --value` through the value() helper, which does not page.
LoadState guards the unit existing, because Result=success is also what a
deleted unit reports; Result guards the outcome, and a oneshot that never
ran on an uncounted boot reports success, which is the honest answer.

README gains the rule next to the prompt-escapes section, since it is the
same failure shape — a live board that reads as a dead one — with the
pager's status line in the pexpect buffer as the tell.

What the run did establish, before it wedged: on a freshly flashed Pi 5
the ESP entry is blessed. The image ships it carrying +3, so the counter
was removed, and only systemd-bless-boot does that — which runs only when
systemd-boot set LoaderBootCountPath. Boot counting works through
U-Boot's UEFI, observed rather than inferred.

Refs #20

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

This branch has not been deployed

No deployments
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.

Automatic rollback of updates that fail to reach a good boot

1 participant