Repository navigation
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 throughcore/default.nix, which is what stops the next board 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— 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:
Reading
current-systemas well asbooted-systemis 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: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_errorbecause 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 loaderbootctlhad to install with--no-variablesat the removable fallback path. Unrelated flags in principle, untested upstream (nixpkgs' ownbootCountingtest runs withcanTouchEfiVariables = true).x86, in a VM —
nix build .#checks.x86_64-linux.good-boot, five boots, 116 s:Raspberry Pi 5, on the bench —
just hw-test:The image's ESP ships
nixos-1302d171…+3.confand the booted board has no counter on any entry. Onlysystemd-bless-bootremoves one, and it runs only when the loader set that variable — so boot counting works through U-Boot's UEFI,--no-variablesand all.The daemon,
go test -race ./...:Neither half can pass vacuously: the new
rollback-is-armedflake check asserts the image writes a counter on all three boards, and the bench asserts one was removed. Verified red both ways —enable = falseandtries = 5each fail, naming every board.All 7 flake checks, 177 bench unit tests,
gofmt/go vet/nixfmt/ruffgreen.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:
/healthzonly: not greetd, not Home Assistant reachability, not the screen.switch-to-configurationleaves the same three symlinks as a rollback, becausenixos-rebuildsets 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 isumask=0077root-only.last_errordistinguishes them in practice. Documented inrollback.go.good-bootis 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), thetest_journey.pyerrors (device_ipis session-scoped and first evaluated seconds after that reboot, so it seesend0 DOWN), andtest_nix_builds_outside_the_tmpfs(30 s console timeout onnix config show). On an uncounted boot — which the post-reboot boot is — nothing here executes:boot-complete.targethas no[Install]and is pulled in only bysystemd-bless-boot's generator, which needs the counter blessing just removed.🤖 Generated with Claude Code