v0.190.0 — the boot settle window, both gates on intent, and R-171
gates / gates (push) Successful in 8s

R-171 (a regression v0.189.0 introduced, CONFIRMED on hardware before any fix
was written). Replacing isBootOrphan's container-count term with recorded intent
made a drive-gate-stopped app read as a boot orphan: the gate stops apps with
`compose down` (zero containers) and never touches desired_state, because it is
not the customer. Observed on 9201 with the drive held unmounted — the sweep
found and started it, burned both attempts, and handed it to the dead-app alarm.
The write hazard did not materialise (the unbound mountpoint is host-root-owned
and the guest is unprivileged) but that protection is accidental and untested.
New consumer-side seam bootrecon.StartGate, fail-safe (cannot determine ⇒ do not
start), wired in main.go. The rule is not new: the API's startGatedByMissingDrive
already refuses this; the sweep bypassed it.

R-157 mechanism A. The sweep looked once at T+5s, deriving candidates from a
fleet docker was still restoring — three of six hard resets. Now a settle-then-
sweep window: sample every 5s, settled after 3 identical samples, sweep ONCE at
the end; ends on settled or a 50s budget, and the log says which. The budget is
50s because settle+budget+one retry must stay under the 90s dead-app grace — a
test rejected 60s at 95s. A window that overruns emits a LATE RECOVERY warn
rather than the grace being widened to hide it.

Widening the window made two more holders reachable, so the one gate covers all
three: an absent drive, a quiesce, and an in-flight app-data operation — reusing
quiesce.SuppressedStacks() and a new read-only AppStopGuard.HeldStacks().

R-170. shouldRecreateOnBoot now reads desired_state with the identical three-way
table; absent keeps the old hasContainers behaviour exactly. Its comment argued
for the container count and was rewritten. presentStable is untouched. The two
gates' agreement is pinned from both sides against one fixture table.

27/27 packages green; 6 red-proofs observed FAIL then restored.
This commit is contained in:
2026-08-02 19:56:20 +02:00
parent 3446609420
commit 582135f861
13 changed files with 1272 additions and 43 deletions
+6 -4
View File
@@ -6,6 +6,8 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
func TestAgentWhere(t *testing.T) {
@@ -65,7 +67,7 @@ func TestShouldRecreateOnBoot(t *testing.T) {
{"no HDD_PATH (SSD-resident)", true, "", true, false},
}
for _, c := range cases {
if got := shouldRecreateOnBoot(c.deployed, c.hdd, present, c.hasContainers); got != c.want {
if got := shouldRecreateOnBoot(c.deployed, c.hdd, present, c.hasContainers, stacks.DesiredStateUnknown); got != c.want {
t.Errorf("%s: shouldRecreateOnBoot = %v, want %v", c.name, got, c.want)
}
}
@@ -158,7 +160,7 @@ func TestPollLiveBinds_WaitsForLateBind(t *testing.T) {
if nowT < goLive { // proves it WAITED through the rebind window (a single sample would return at t=0)
t.Fatalf("poll returned at t=%s before the bind went live at %s — it did not wait (regression)", nowT, goLive)
}
if !shouldRecreateOnBoot(true, flash, live, true) {
if !shouldRecreateOnBoot(true, flash, live, true, stacks.DesiredStateUnknown) {
t.Fatalf("with the live bind present, the drive-backed app MUST be recreated")
}
}
@@ -170,7 +172,7 @@ func TestSingleEarlySample_MissesLateBind_Companion(t *testing.T) {
flash := "/mnt/felhom-drives/felhom-flash"
bindLiveAtBoot := func(string) bool { return false } // not yet live at the boot instant
oldPresent := map[string]bool{flash: bindLiveAtBoot(flash)}
if shouldRecreateOnBoot(true, flash, oldPresent, true) {
if shouldRecreateOnBoot(true, flash, oldPresent, true, stacks.DesiredStateUnknown) {
t.Fatalf("companion: a single early sample reads the not-yet-live bind as absent and must MISS it")
}
}
@@ -192,7 +194,7 @@ func TestPollLiveBinds_TimeoutLeavesAbsent(t *testing.T) {
if nowT < bootBindWait {
t.Fatalf("poll must run to the deadline for an absent drive, t=%s", nowT)
}
if shouldRecreateOnBoot(true, usb, live, true) {
if shouldRecreateOnBoot(true, usb, live, true, stacks.DesiredStateUnknown) {
t.Fatalf("an absent drive's app must NOT be recreated here (the gate owns drive-absent)")
}
}