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
This commit is contained in:
admin committed 2026-11-14 17:34:13 +01:00
1 parent df0990f3aa
commit 95ed6314cc
4 files changed
+167 -9

No files matched your search

+17
View File
@@ -1,3 +1,20 @@
## Unreleased (2026-11-14 night) — built and NOT released: a clock step is not a new boot (felhom.eu R-1395)
**MinAgent: 0.158.1** (unchanged — the new field is optional; without it the controller behaves as before).
- **A step of the host's clock no longer makes the controller recreate every drive-backed app (R-1395).** Found on
the N100 on 2026-11-14: its time service came back, stepped the clock by +32.7 s, and radarr, sonarr and qbittorrent
were recreated. **The boot token is the AGENT's, not the controller's:** `guest_boot_id` in the agent's `/disks`
answer is `<host boot TIME>-<guest init start>`, and the kernel computes the boot time from the wall clock. The only
reader in this repository is `web.processGuestBootChange` (it compares the token with `settings.LastGuestBootID`).
It now decides through `guestBootVerdict`, which prefers the agent's new `guest_boot_key`
(`<kernel boot_id>-<guest init start>`; an agent after 0.174.0 — built there, not released): the key equal to the
record is the same boot whatever the old token says; the first report with a key moves the record to the key
WITHOUT a recreate when the old token says „same boot”; an agent that sends no key gets the old rule, byte for
byte. Tests `TestGuestBootVerdict_AClockStepIsNotANewBoot` (14 cases), `TestGuestBootVerdict_IsWhatTheBootPassReads`;
8 mutations seen red (felhom.eu `documentation/audits/night-build-2026-11-14/C/redproof-controller-boot-key.txt`).
**Not proven on a box.** With an older agent a clock step still recreates the apps once.
## v0.352.1 (2026-11-14) — Go 1.27.4: the security fix GO-2026-6859 (felhom.eu R-1391) — RELEASED (image `sha256:1a6e8945…`, from `8747744b`, CI run id 2637; rc.1 on scratch guest 9202); per-box floors 09:18 UTC, global floor 2026-11-14 09:21 UTC (MinAgent 0.158.1); golden 0.352.1 baked and vouched the same day
### 2026-11-14
+4
View File
@@ -392,6 +392,10 @@ type DisksResponse struct {
// GuestBootID changes on every guest boot (host or guest reboot) but is stable across a
// controller-only restart — the deterministic signal the controller recreates drive-backed apps on.
GuestBootID string `json:"guest_boot_id,omitempty"`
// GuestBootKey (R-1395; an agent after 0.174.0) is the same signal built from the kernel's boot_id instead of
// the boot TIME: a step of the host's clock moves the boot time (and so GuestBootID) and does not move this.
// "" from an older agent — the controller then reads GuestBootID as before. See web.guestBootVerdict.
GuestBootKey string `json:"guest_boot_key,omitempty"`
}
// FormatResult mirrors POST /disks/format (the success/refusal payload).
@@ -0,0 +1,95 @@
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)
}
}
+51 -9
View File
@@ -395,6 +395,36 @@ func (s *Server) driveGateLoop() {
}
}
// guestBootVerdict decides „is this a new boot of the guest?” from the persisted record and the agent's two tokens
// (R-1395). `oldTok` is guest_boot_id = "<host boot TIME>-<guest init start>"; the kernel computes the boot time from
// the wall clock, so a STEP of the host's clock moved it and the controller recreated every drive-backed app (the
// N100, 2026-11-14: +32.7 s, three apps down for seconds). `key` is guest_boot_key = "<kernel boot_id>-<guest init
// start>", which a clock step cannot move; "" from an agent that does not send it.
//
// - no key (older agent): the old rule — a non-empty token that differs from the record is a new boot.
// - key equals the record: same boot, whatever the old token says. THIS is the fix.
// - the record still holds the OLD token and it equals the old token now: same boot — and the record moves to the
// key (changed=false, persist=key), so the move itself recreates nothing.
// - anything else: a new boot; the record becomes the key.
//
// persist is the value to write: after the recreate pass when changed, at once when only the record moves.
// Pinned by TestGuestBootVerdict_*.
func guestBootVerdict(last, oldTok, key string) (changed bool, persist string) {
if key == "" {
if oldTok == "" || oldTok == last {
return false, ""
}
return true, oldTok
}
if key == last {
return false, ""
}
if last != "" && oldTok != "" && oldTok == last {
return false, key
}
return true, key
}
// processGuestBootChange converges a GUEST REBOOT DETERMINISTICALLY. On a guest reboot docker auto-starts
// the app containers (restart:unless-stopped) BEFORE the agent re-propagates the drive under the parent
// (~18s later) — so the create-time bind of an app's volume fails (`mkdir …/userdata: permission denied`
@@ -428,9 +458,21 @@ func (s *Server) processGuestBootChange() {
if derr != nil {
return
}
if resp.GuestBootID == "" || resp.GuestBootID == s.settings.GetLastGuestBootID() {
return // no boot-id, or unchanged (controller-only restart) → no recreate
last := s.settings.GetLastGuestBootID()
changed, persist := guestBootVerdict(last, resp.GuestBootID, resp.GuestBootKey)
if !changed {
if persist != "" && persist != last {
// R-1395: the first report with the clock-proof key, and the old token says „same boot” — take the key
// as the record from now on. No app is touched.
if serr := s.settings.SetLastGuestBootID(persist); serr != nil {
s.logger.Printf("[WARN] [gate] persist boot key: %v", serr)
} else {
s.logger.Printf("[INFO] [gate] boot record moved to the kernel's boot key (same boot; no app recreated)")
}
}
return // no token, or unchanged (controller-only restart) → no recreate
}
bootID := persist // the token of THIS boot, logged and persisted below
// Distinct stable drive paths that deployed drive-backed apps depend on (HDD_PATH is the drive root).
needed := map[string]bool{}
@@ -445,7 +487,7 @@ func (s *Server) processGuestBootChange() {
}
}
if len(needed) == 0 {
if serr := s.settings.SetLastGuestBootID(resp.GuestBootID); serr != nil {
if serr := s.settings.SetLastGuestBootID(bootID); serr != nil {
s.logger.Printf("[WARN] [gate] persist boot-id: %v", serr)
}
return
@@ -456,7 +498,7 @@ func (s *Server) processGuestBootChange() {
}
// READINESS GATE (the fix): wait for the REAL live in-guest bind, not the once-sampled agent view.
s.logger.Printf("[INFO] [gate] boot %s: waiting (≤%s) for live drive bind(s) %v before recreating drive-backed apps", resp.GuestBootID, bootBindWait, paths)
s.logger.Printf("[INFO] [gate] boot %s: waiting (≤%s) for live drive bind(s) %v before recreating drive-backed apps", bootID, bootBindWait, paths)
presentStable := pollLiveBinds(paths, driveBindLive, time.Sleep, time.Now, bootBindWait, bootBindPoll)
var bootStacks []bootStack
@@ -475,7 +517,7 @@ func (s *Server) processGuestBootChange() {
})
}
recreate := func(bs bootStack) {
s.logger.Printf("[INFO] [gate] boot %s: live bind confirmed — recreating drive-backed app %s (state=%s) onto %s", resp.GuestBootID, bs.name, bs.state, bs.hdd)
s.logger.Printf("[INFO] [gate] boot %s: live bind confirmed — recreating drive-backed app %s (state=%s) onto %s", bootID, bs.name, bs.state, bs.hdd)
// R-1251: a cut restore whose copy waited for THIS drive is poured back first — the recreate used to start
// the app on the cut restore's data, and a later start poured the old copy over what the household wrote.
if !s.resumePutBack(bs.name) || s.appHeld(bs.name) {
@@ -487,19 +529,19 @@ func (s *Server) processGuestBootChange() {
}
}
syncFB := func() {
s.logger.Printf("[INFO] [gate] boot %s: re-syncing FileBrowser mounts against the live binds", resp.GuestBootID)
s.logger.Printf("[INFO] [gate] boot %s: re-syncing FileBrowser mounts against the live binds", bootID)
go s.SyncFileBrowserMounts()
}
_, skipped, leftStopped := recreateDriveBackedApps(bootStacks, presentStable, recreate, syncFB)
if skipped > 0 {
s.logger.Printf("[WARN] [gate] boot %s: %d drive-backed app(s) had no live bind within %s — leaving to the drive gate", resp.GuestBootID, skipped, bootBindWait)
s.logger.Printf("[WARN] [gate] boot %s: %d drive-backed app(s) had no live bind within %s — leaving to the drive gate", bootID, skipped, bootBindWait)
}
if leftStopped > 0 {
// INFO, not WARN: this is the gate working as intended (R-55). Make the honoured path
// observable — a silent correct path is how an inert seam hides.
s.logger.Printf("[INFO] [gate] boot %s: %d drive-backed app(s) left stopped on purpose (recorded Stop, or a legacy app.yaml at zero containers)", resp.GuestBootID, leftStopped)
s.logger.Printf("[INFO] [gate] boot %s: %d drive-backed app(s) left stopped on purpose (recorded Stop, or a legacy app.yaml at zero containers)", bootID, leftStopped)
}
if serr := s.settings.SetLastGuestBootID(resp.GuestBootID); serr != nil {
if serr := s.settings.SetLastGuestBootID(bootID); serr != nil {
s.logger.Printf("[WARN] [gate] persist boot-id: %v", serr)
}
}