Files
felhom-controller/controller/internal/web/guest_boot_verdict_test.go
T
admin 95ed6314cc controller (unreleased): a step of the host's clock is not a new boot (R-1395)
The boot pass decides through guestBootVerdict and prefers the agent's new guest_boot_key (kernel boot_id), which a
clock step cannot move. An agent that sends no key gets the old rule. The move to the key recreates nothing.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-11-14 17:34:13 +01:00

96 lines
4.5 KiB
Go

package web
import (
"go/ast"
"go/parser"
"go/token"
"strings"
"testing"
)
// R-1395: on 2026-11-14 the N100's time service came back and stepped the host's clock by +32.7 s. The agent's boot
// token carries the host's boot TIME, which the kernel computes from the wall clock — so the token moved and the
// controller recreated radarr, sonarr and qbittorrent. The consequence asserted: with the clock-proof key, a moved
// boot time is NOT a new boot; a real reboot still is; and the move from the old record to the key recreates nothing.
// COMPANION RED-PROOFS (observed, felhom.eu audits/night-build-2026-11-14/C/redproof-controller-boot-key.txt).
func TestGuestBootVerdict_AClockStepIsNotANewBoot(t *testing.T) {
const (
key = "5b1f6c1e-8f0a-4c56-9d1c-0a8c3b7d2e11-4242"
keyNext = "c0ffee00-1111-2222-3333-444455556666-3907" // after a host reboot
keyGuest = "5b1f6c1e-8f0a-4c56-9d1c-0a8c3b7d2e11-991234"
old = "1794112518-4242" // btime before the step
oldStep = "1794112550-4242" // the same boot, btime moved by the +32 s step
)
for _, c := range []struct {
name string
last, oldTok, key_ string
changed bool
persist string
}{
{"THE CASE: the clock stepped, the key did not move", key, oldStep, key, false, ""},
{"same boot, nothing moved", key, old, key, false, ""},
{"a host reboot: the key changes", key, "1794200000-3907", keyNext, true, keyNext},
{"a guest reboot: the key's second half changes", key, "1794112518-991234", keyGuest, true, keyGuest},
{"the first report with a key, same boot by the old token: the record moves, no recreate", old, old, key, false, key},
{"the first report with a key, and the old token differs: a new boot", old, "1794200000-3907", keyNext, true, keyNext},
{"a box that never recorded a boot: the first pass runs, as before", "", old, key, true, key},
{"a key and no old token at all, record differs: a new boot", old, "", key, true, key},
// an older agent sends no key: the rule of before, byte for byte
{"older agent, same boot", old, old, "", false, ""},
{"older agent, new boot", old, "1794200000-3907", "", true, "1794200000-3907"},
{"older agent, guest not running (no token)", old, "", "", false, ""},
{"older agent, never recorded", "", old, "", true, old},
{"older agent, the clock stepped: still a (false) new boot — only the key cures it", old, oldStep, "", true, oldStep},
{"an agent downgrade: the record is a key, the agent sends only the old token", key, old, "", true, old},
} {
changed, persist := guestBootVerdict(c.last, c.oldTok, c.key_)
if changed != c.changed || persist != c.persist {
t.Errorf("%s: guestBootVerdict(%q, %q, %q) = (%v, %q), want (%v, %q)", c.name, c.last, c.oldTok, c.key_, changed, persist, c.changed, c.persist)
}
}
}
// The verdict is a pure function; a pure function nobody calls is the inert-seam defect. processGuestBootChange must
// decide through it, with the agent's key, and must not compare the old token by itself any more.
func TestGuestBootVerdict_IsWhatTheBootPassReads(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "intermediary.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var calls, rawCompares, persists int
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Name.Name != "processGuestBootChange" {
continue
}
ast.Inspect(fn, func(n ast.Node) bool {
switch node := n.(type) {
case *ast.CallExpr:
if id, ok := node.Fun.(*ast.Ident); ok && id.Name == "guestBootVerdict" && len(node.Args) == 3 {
if a, ok := node.Args[2].(*ast.SelectorExpr); ok && a.Sel.Name == "GuestBootKey" {
calls++
}
}
if sel, ok := node.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "SetLastGuestBootID" && len(node.Args) == 1 {
if a, ok := node.Args[0].(*ast.SelectorExpr); ok && strings.HasPrefix(a.Sel.Name, "GuestBoot") {
rawCompares++ // persisting the agent's raw token, not the verdict's
} else {
persists++
}
}
case *ast.BinaryExpr:
for _, side := range []ast.Expr{node.X, node.Y} {
if a, ok := side.(*ast.SelectorExpr); ok && a.Sel.Name == "GuestBootID" && (node.Op == token.EQL || node.Op == token.NEQ) {
rawCompares++
}
}
}
return true
})
}
if calls != 1 || rawCompares != 0 || persists != 3 {
t.Errorf("processGuestBootChange: guestBootVerdict(…, resp.GuestBootKey) calls=%d (want 1), raw uses of the old token=%d (want 0), persists of the verdict's token=%d (want 3)", calls, rawCompares, persists)
}
}