hub v0.179.1: a host boot ends "the host needs a restart" at once (demo-felhom 2026-11-08)
gates / gates (push) Successful in 2m3s

The host layer's reboot-needed run is cut at the host's last boot (crash guard
last_boot_at, one minute skew margin): the kernel step's reboot is reported on the
kernel layer, so the flag outlived it until the next night. Feeds the System page
and the 14-day alarm. Test TestRebootNeeded_ClearedByAHostBootAfterTheClaim, red on
two mutants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-11-08 07:21:50 +01:00
1 parent a0d8057634
commit ddbcabebb4
3 files changed
+135 -6

No files matched your search

+57
View File
@@ -328,3 +328,60 @@ func TestRebootNeeded_ClearedByAScannedPass(t *testing.T) {
t.Fatal("reboot alarm after the reboot")
}
}
// bootReport stores a host report whose crash guard says the host last booted at `boot` (the agent's
// system.facts.host.crash_guard.last_boot_at, UTC ISO).
func bootReport(t *testing.T, f *fix, host string, boot time.Time) {
t.Helper()
h, err := f.s.Store.GetHost(host)
if err != nil || h == nil {
t.Fatalf("no host %s", host)
}
body := `{"system":{"pve_version":"pve-manager/9.2.2/x","facts":{"host":{"crash_guard":{"last_boot_at":"` +
boot.UTC().Format("2006-01-02T15:04:05Z") + `"}}}}}`
if err := f.s.Store.SaveHostReport(host, h.CustomerID, []byte(body), store.HostReportDenorm{AgentVersion: "0.172.1"}); err != nil {
t.Fatal(err)
}
}
// 2026-11-08, demo-felhom: the night's host pass said reboot-needed at 05:31, the kernel step rebooted the box at
// 05:35, and the System page kept „the host needs a restart" until the next night's host pass — the reboot is
// reported on the KERNEL layer, never the host layer. A host boot AFTER the newest claim ends the need, on the page
// AND for the 14-day alarm. A boot BEFORE the claim (or within the margin) does not. Red-proof: drop the boot check
// in layerLine and the first assertion fails.
func TestRebootNeeded_ClearedByAHostBootAfterTheClaim(t *testing.T) {
f := newFix(t)
if _, err := f.s.Store.GetHost("hp"); err != nil {
t.Fatal(err)
}
f.ingest(t, "hp", Report{Layer: LayerHost, Trigger: "night", Mode: "apply", Outcome: "applied", Healthy: true,
Upgraded: []Package{pk("libc6", "2")}, RebootNeeded: true, RebootScanned: true})
claim := f.now
// A boot BEFORE the claim says nothing: the need stays.
bootReport(t, f, "hp", claim.Add(-time.Hour))
if l := f.s.layerLine("hp", LayerHost, 0); l.RebootNeededSince.IsZero() {
t.Fatal("a boot before the claim cleared the need")
}
// A boot within the margin after the claim (clock skew) says nothing either.
bootReport(t, f, "hp", claim.Add(30*time.Second))
if l := f.s.layerLine("hp", LayerHost, 0); l.RebootNeededSince.IsZero() {
t.Fatal("a boot inside the skew margin cleared the need")
}
// The kernel step's reboot, 4 minutes after the claim: the need is over.
f.now = f.now.Add(10 * time.Minute)
bootReport(t, f, "hp", claim.Add(4*time.Minute))
if l := f.s.layerLine("hp", LayerHost, 0); !l.RebootNeededSince.IsZero() {
t.Fatalf("a host boot after the claim did not clear the need: since %v", l.RebootNeededSince)
}
f.now = f.now.Add(20 * 24 * time.Hour)
f.s.Alarms()
if f.count(EventRebootNeeded) != 0 {
t.Fatal("reboot alarm after the host rebooted")
}
// A NEW claim after that boot (the next night installs more) starts the need again.
f.ingest(t, "hp", Report{Layer: LayerHost, Trigger: "night", Mode: "apply", Outcome: "applied", Healthy: true,
Upgraded: []Package{pk("libc6", "3")}, RebootNeeded: true, RebootScanned: true})
if l := f.s.layerLine("hp", LayerHost, 0); !l.RebootNeededSince.Equal(f.now) {
t.Fatalf("a claim after the boot: since = %v, want %v", l.RebootNeededSince, f.now)
}
}