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
96 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|