Skip to content

host-agent applies OS updates: install to the other slot, switch in the window, revert on its own (#563) - #574

Merged
onel merged 18 commits into
devfrom
feat/563-host-agent-os-update
Oct 2, 2026
Merged

onel merged 18 commits into
devfrom
feat/563-host-agent-os-update

Conversation

@onel

@onel onel commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #563. A slice of #486.

What this does

host-agent now applies OS updates on the hosted profile (UPDATES.md # 1):

  1. The update-target answer gains an optional os list. host-agent picks the next release: it never skips a minor, goes at most one minor back, and never goes below the running control plane's floor.
  2. It downloads the bundle and checks its sha256 against the answer before RAUC sees it. So a leaked signer alone cannot install anything (DECISIONS.md 2026-10-02).
  3. rauc install writes the other slot ahead of the window. The boot order does not move (activate-installed=false).
  4. Inside the window, after stream B, it switches with rauc status mark-active other. It checks that the grubenv reads OK=1 TRY=0 for the new slot (the Hosted image in the A/B layout: 1 GiB squashfs slots, a state partition, GRUB on both firmwares (#561) #570 gap), then reboots.
  5. On the next boot, the slot is marked good once the brain answers. If not, the slot is marked bad and the box reboots to the old slot. If host-agent never starts, an image timer (moose-os-trial.timer, 15 min) reboots the box. Only the first boot after a switch is on trial.
  6. The version read reports os_version and os_slot. The update-target read reports stream A's decision. Admins get one notification per outcome.

The appliance build has no OS applier yet (#564) and reports stream A as unsupported.

The private-side change this needs (for the maintainer)

No production box moves its OS until the control plane sends the OS part. This PR does not touch the private side. The exact wire change for GET /api/updates/target:

  • New field. An optional top-level os: a JSON array, oldest first, of {"version": "X.Y.Z", "bundle_url": "<url>", "bundle_sha256": "<64 lowercase hex>"}.
  • Which entries. The newest patch of each minor, from the oldest minor still supported up to the box's OS target. The target is the last entry. Leave os out when there is no OS target.
  • bundle_url. https://github.com/onmoose/os/releases/download/vX.Y.Z/moose-vX.Y.Z-amd64.raucb. The box refuses any URL outside that prefix.
  • bundle_sha256. The hex digest in the release's moose-vX.Y.Z-amd64.raucb.sha256 asset, read once when the release is recorded. Never resolved per request, and never "latest".
  • Bad entries. A malformed os part makes the box refuse stream A only. Stream B is not affected.

Tested

  • make check and make check-web are green. There are new tests for the pick rules, the loop ordering, the transaction against a fake two-slot RAUC, the report, the brain pass-through, the notification and the outcome check.
  • CI only, every publish input false. There are two new boots under UEFI and legacy BIOS, both in every full run:
    • os-update: a wrong digest is refused, the install happens ahead of the window, the box switches, boots slot B and marks it good. The owner, session, password hash, SSH host key, machine-id, data and app are intact.
    • os-revert: host-agent is kept off slot B, so the image's safety net reboots the box, and it goes back to slot A on its own.
  • Last full run: 37058171441 (head 02ca9f4; later commits change only docs). It is green, with all 14 boot jobs under UEFI and legacy BIOS. All runs are listed in docs/progress/host-agent-os-update.md.

Numbers

  • Download per update: 438.5 MB (one bundle).
  • Disk while installing: 438.5 MB on the state partition, 1.1% of a 40 GB disk, deleted afterwards.
  • Time to apply in the guest: download and digest under 1 s, rauc install 3 to 6 s, switch to marked good 17 s, reboot included. The guest reads from a local file server, so a real download adds the network time.
  • CI, measured. The full list took 12.7 min wall and 33.0 runner minutes before (run 37017805364, Build, sign and publish a RAUC bundle per OS release #562). It takes 13.7 min wall and 47.0 runner minutes now (run 37058171441, the last head tested). The four new jobs take 3.0 to 5.2 min each, and the test bundle takes 26 s in the build job.

Found on the way

Notes

  • Approved by the maintainer: CLAUDE.md gains the log fields os, slot and digest, and nothing else.
  • No new dependencies.
  • Known gaps are in the progress entry: QEMU only, the trial checks the brain's /healthz only, no report back to the cloud, and the test bundle is not a release bundle.

onel added 5 commits October 2, 2026 16:36
…oot, revert (#563)

The update-target answer gains an OS list; host-agent installs the next
release into the other slot with RAUC after checking the bundle's sha256
against the answer, switches inside the window, and marks the new slot
good once the brain is healthy. A trial that fails reboots to the old
slot; an image timer reboots a slot whose host-agent never started.
Two new boots prove both paths under UEFI and BIOS.
@greptile-apps

greptile-apps Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 3/5

[Critical risk] Implements OS update mechanism with boot-time slot switching and auto-revert.

This PR does not yet appear safe to merge because a good slot can still be marked bad and a late trial can reject a healthy OS.

Findings

  1. P1 Healthy slot gets marked bad ▶
  2. P1 Slow boot rejects healthy OS ▶

Reviews (13) · Last reviewed commit: "fixup! docs: numbers and runs for #563"

Comment thread internal/hostagent/updatetarget/http.go Outdated
Comment thread internal/hostagent/updatetarget/os.go
Comment thread cmd/host-agent-real/main.go
Comment thread internal/hostagent/osupdate/osupdate.go
Comment thread internal/hostagent/osupdate/osupdate.go Outdated
Comment on lines +384 to +386
if err := os.Remove(TrialMarker(a.dir(), slot)); err != nil {
slog.Error("os update: could not remove the trial marker", "err", err, "slot", slot)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Healthy slot gets marked bad

If the trial marks a healthy slot good but cannot remove its trial marker, it only logs the failure. The image timer later sees that marker, marks the healthy slot bad, and reboots it. Keep a failed marker removal from making a good slot look like a failed trial.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b638594. The trial records the good outcome before it removes the marker. Removal is retried for about 13 min, under the 15 min timer. A later boot that finds the good outcome for that switch only removes the marker. The image timer cannot use the grubenv to tell good from on-trial, because GRUB under UEFI does not save TRY (#575). Tests: TestStaleMarkerOnAGoodSlot, TestTrialStaysUpWhenMarkGoodFails.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Covered by b638594 and 02ca9f4. The trial records the good outcome before it removes the marker. Removal is retried for about 13 min, under the 15 min timer, and the trial's deadline now ends 2 min before the timer. A later boot that finds the good outcome for that switch only removes the marker and does not start a new trial. Tests: TestStaleMarkerOnAGoodSlot, TestTrialStaysUpWhenMarkGoodFails, TestTrialDeadlineCountsFromBoot.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dismissing what is left. The remaining case needs the trial to fail to delete one small file on /state for about 13 min in a row, after it already wrote the good outcome there. A /state that can write but not delete for that long is a broken disk, and the box has bigger problems than an OS revert. Even then the result is a revert to the known-good OS and a retry the next night, not a broken box. The safety net cannot read GRUB's TRY under UEFI until #575 is fixed. #575 is where the timer gets a second signal.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood. Since the good outcome is persisted before marker cleanup, the remaining failure requires an unusually long-lived /state delete failure. Even then, the safety behavior returns to the known-good slot and retries on a later night; the missing UEFI TRY signal remains tracked under #575.

Comment thread cmd/brain/osupdate.go Outdated
Comment thread dev/cloud/run-cloud-tests.sh
onel added 5 commits October 2, 2026 17:39
Nothing installs or switches while the booted slot is on trial, and the
box switches its OS at most once a night. A bad os part, a list that
leaves a minor out, and a downgrade without a readable floor are
refused for stream A only. A failed reboot undoes the switch; the image
timer leaves a slot already marked good alone; rauc status is retried at
start; an unreadable record is never written over; the download is
capped. A local run builds the test bundle itself.
#563)

Debian's rauc-service marks every booted slot good at the end of boot,
so a slot whose host-agent never started was marked good and the box
could not fall back. The os-revert boot caught it.
Comment thread dev/cloud/cloud-assertions.sh
GRUB under UEFI does not save the booted slot's TRY flag (CI run
37045179497), so a grubenv check made the safety net never fire under
UEFI. host-agent now retries removing the marker after mark-good
instead. The rauc-mark-good mask is dropped: Debian ships no such unit.
Comment thread docs/progress/host-agent-os-update.md Outdated
Second review: a trial that cannot mark its slot reboots at most once,
the image timer reboots a slot at most once, a switch counts only once
activated, leftover downloads are removed, the trial timeout is capped
under the image timer, and a stale marker on a good slot is only
removed.
Comment thread dev/cloud/mkosi.extra/usr/lib/moose/os-trial-check
Comment thread internal/hostagent/osupdate/osupdate.go
Comment thread internal/hostagent/osupdate/osupdate.go Outdated
onel added 2 commits October 2, 2026 20:32
Third round: the trial ends 13 min after boot at the latest, 2 min
before the image timer; a new switch clears the slot's old notes; a
slot that gives up its trial leaves trial-failed-<slot>, so a power cut
between mark-active and the activated flag still records the revert.
Comment on lines +503 to +505
if timeout < minTrialWait {
timeout = minTrialWait
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Slow boot rejects healthy OS

If host-agent starts the trial after about 12 minutes 50 seconds of boot time, this limit gives the brain just ten seconds to answer. The brain may still be starting, so host-agent marks a healthy new slot bad and reboots to the old OS. A late start needs a way to avoid treating a slow brain as a failed update.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dismissed, by design. The 15 min image timer is the outer limit for any trial boot, and the trial must decide before it. A box whose host-agent only starts the trial about 13 min after boot is already far outside a normal boot (seconds to about a minute in the boot proofs). Treating it as a failed update and going back to the known-good OS is the safe choice. Extending the trial past the timer would let a slot that never gets healthy keep running. The next night retries the update.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood. Given the 15-minute image timer is the hard outer bound, clamping the host-agent trial to decide before it is intentional: a very late host-agent start leaves only the remaining safety window, and treating that as a failed trial avoids letting an unverified slot run past the timer. Dismissed.

@onel
onel merged commit 1387f71 into dev Oct 2, 2026
8 checks passed
@onel
onel deleted the feat/563-host-agent-os-update branch October 2, 2026 20:29
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.

host-agent applies OS updates: install to the other slot, reboot in the window, revert on its own

1 participant