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:
1 parent
df0990f3aa
commit
95ed6314cc
4 files changed
+167
-9
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user