gate: the boot bind gate honours a customer's Stop (R-55, v0.157.0)
shouldRecreateOnBoot keyed on Deployed+drive-present alone. Deployed stays true across a Stop, so a drive-backed app the customer switched off was silently restarted on every guest reboot (proven live: immich). Requires len(Containers)>0 as well - R-52's existing-Exited vs absent distinction. A UI Stop is compose down and removes the containers; a guest that went down under a running app leaves them. Container STATE is still deliberately NOT a filter: that would miss a not-yet-restarted or stuck-Exited app, which is the bug the boot-id path exists to fix. Evidence sampled before any recreate - recreate's own StopStack erases it. Honoured Stops counted and logged separately from no-live-bind skips.
This commit is contained in:
@@ -21,34 +21,92 @@ func TestAgentWhere(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestShouldRecreateOnBoot pins the DETERMINISTIC boot-id recreate decision: recreate EVERY deployed
|
||||
// drive-backed present app, independent of its current container state.
|
||||
// TestShouldRecreateOnBoot pins the boot-id recreate decision on BOTH axes it must get right at once:
|
||||
// recreate every deployed drive-backed present app that still HAS containers, independent of what
|
||||
// STATE those containers are in.
|
||||
//
|
||||
// COMPANION GUARD: the pre-fix logic (the old `stackStartedRecently`, and even a `State!=stopped` filter)
|
||||
// MISSED an app that was Stopped/Exited at the one-shot instant — docker hadn't auto-restarted it yet
|
||||
// after the boot. The "exited" and "stopped" cases below are `true` here: a state-filtered impl returns
|
||||
// false for them and the app stays down (exactly what happened live: 5 apps exited after a host reboot).
|
||||
// COMPANION GUARD 1 (state must NOT filter): the pre-fix logic (the old `stackStartedRecently`, and
|
||||
// even a `State!=stopped` filter) MISSED an app that was Stopped/Exited at the one-shot instant —
|
||||
// docker hadn't auto-restarted it yet after the boot. The exited/stopped cases below are `true`: a
|
||||
// state-filtered impl returns false for them and the app stays down (exactly what happened live: 5
|
||||
// apps exited after a host reboot).
|
||||
//
|
||||
// COMPANION GUARD 2 (R-55: containers MUST filter): an impl that ignores `hasContainers` resurrects
|
||||
// an app the customer deliberately stopped, on every guest reboot. The zero-container cases below are
|
||||
// `false`; the pre-R-55 impl returns true for them (proven live: immich, stopped from the UI seconds
|
||||
// earlier, came back running).
|
||||
//
|
||||
// The two guards pull in opposite directions on purpose — that is the whole difficulty of this gate.
|
||||
// `state` and `hasContainers` are DIFFERENT questions: "is it up right now" vs "does docker still
|
||||
// have records of it", and only the second survives a reboot as a statement of intent.
|
||||
func TestShouldRecreateOnBoot(t *testing.T) {
|
||||
present := map[string]bool{"/mnt/felhom-drives/felhom-flash": true}
|
||||
const flash = "/mnt/felhom-drives/felhom-flash"
|
||||
present := map[string]bool{flash: true}
|
||||
cases := []struct {
|
||||
name string
|
||||
deployed bool
|
||||
hdd string
|
||||
want bool
|
||||
name string
|
||||
deployed bool
|
||||
hdd string
|
||||
hasContainers bool
|
||||
want bool
|
||||
}{
|
||||
{"deployed+present (recreate regardless of state)", true, "/mnt/felhom-drives/felhom-flash", true},
|
||||
{"drive absent (gate handles)", true, "/mnt/felhom-drives/felhom-usb", false},
|
||||
{"SSD path never", true, "/mnt/sys_drive/felhom-data", false},
|
||||
{"app.yaml not deployed", false, "/mnt/felhom-drives/felhom-flash", false},
|
||||
{"no HDD_PATH (SSD-resident)", true, "", false},
|
||||
// --- has containers: recreate regardless of what state they are in (guard 1) ---
|
||||
{"deployed+present+containers (running)", true, flash, true, true},
|
||||
{"deployed+present+containers (exited after boot)", true, flash, true, true},
|
||||
{"deployed+present+containers (stuck create-time failure)", true, flash, true, true},
|
||||
|
||||
// --- R-55: zero containers == `compose down` == the customer's Stop (guard 2) ---
|
||||
{"R-55 customer-stopped drive app (zero containers)", true, flash, false, false},
|
||||
{"R-55 gate-stopped app (zero containers; Return branch owns it)", true, flash, false, false},
|
||||
|
||||
// --- the pre-existing axes, unchanged ---
|
||||
{"drive absent (gate handles)", true, "/mnt/felhom-drives/felhom-usb", true, false},
|
||||
{"SSD path never", true, "/mnt/sys_drive/felhom-data", true, false},
|
||||
{"app.yaml not deployed", false, flash, true, false},
|
||||
{"no HDD_PATH (SSD-resident)", true, "", true, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := shouldRecreateOnBoot(c.deployed, c.hdd, present); got != c.want {
|
||||
if got := shouldRecreateOnBoot(c.deployed, c.hdd, present, c.hasContainers); got != c.want {
|
||||
t.Errorf("%s: shouldRecreateOnBoot = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecreateDriveBackedApps_HonoursCustomerStop is the R-55 regression at the driver level, and it
|
||||
// separates the two reasons an app is not recreated — a conflated counter would fire a WARN about a
|
||||
// missing drive bind for an app that is stopped exactly as the customer asked.
|
||||
//
|
||||
// COMPANION GUARD: drop `hasContainers` from the predicate and `immich` is recreated — the exact live
|
||||
// defect (REPORT §4b). Drop the counter split and `leftStopped` reads 0 while `skipped` reads 2.
|
||||
func TestRecreateDriveBackedApps_HonoursCustomerStop(t *testing.T) {
|
||||
const flash = "/mnt/felhom-drives/felhom-flash"
|
||||
present := map[string]bool{flash: true}
|
||||
stacks := []bootStack{
|
||||
// boot orphan: containers exist but are down → MUST be recreated.
|
||||
{name: "romm", deployed: true, hdd: flash, state: "exited", hasContainers: true},
|
||||
// customer pressed Stop → compose down → zero containers → MUST be left alone.
|
||||
{name: "immich", deployed: true, hdd: flash, state: "stopped", hasContainers: false},
|
||||
// drive-backed but the bind never went live → the drive gate's job, counted separately.
|
||||
{name: "stranded", deployed: true, hdd: "/mnt/felhom-drives/felhom-usb", hasContainers: true},
|
||||
}
|
||||
var recreated []string
|
||||
n, skipped, leftStopped := recreateDriveBackedApps(stacks, present,
|
||||
func(bs bootStack) { recreated = append(recreated, bs.name) }, func() {})
|
||||
|
||||
if len(recreated) != 1 || recreated[0] != "romm" {
|
||||
t.Fatalf("recreated=%v, want exactly [romm] — a customer-stopped app must never be restarted", recreated)
|
||||
}
|
||||
for _, name := range recreated {
|
||||
if name == "immich" {
|
||||
t.Fatal("immich was stopped from the UI and was recreated anyway — R-55 regression")
|
||||
}
|
||||
}
|
||||
if n != 1 || skipped != 1 || leftStopped != 1 {
|
||||
t.Fatalf("recreated=%d skipped=%d leftStopped=%d, want 1/1/1 "+
|
||||
"(skipped = no live bind; leftStopped = honoured customer Stop — these must not be conflated)",
|
||||
n, skipped, leftStopped)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultPromotionTarget pins M1 (never leave zero default).
|
||||
//
|
||||
// COMPANION GUARD: the pre-fix decommission blanked the default and promoted nothing — equivalent to this
|
||||
@@ -100,7 +158,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) {
|
||||
if !shouldRecreateOnBoot(true, flash, live, true) {
|
||||
t.Fatalf("with the live bind present, the drive-backed app MUST be recreated")
|
||||
}
|
||||
}
|
||||
@@ -112,7 +170,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) {
|
||||
if shouldRecreateOnBoot(true, flash, oldPresent, true) {
|
||||
t.Fatalf("companion: a single early sample reads the not-yet-live bind as absent and must MISS it")
|
||||
}
|
||||
}
|
||||
@@ -134,7 +192,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) {
|
||||
if shouldRecreateOnBoot(true, usb, live, true) {
|
||||
t.Fatalf("an absent drive's app must NOT be recreated here (the gate owns drive-absent)")
|
||||
}
|
||||
}
|
||||
@@ -146,18 +204,18 @@ func TestRecreateDriveBackedApps_SyncsFileBrowserAfterRecreate(t *testing.T) {
|
||||
flash := "/mnt/felhom-drives/felhom-flash"
|
||||
present := map[string]bool{flash: true}
|
||||
stacks := []bootStack{
|
||||
{name: "romm", deployed: true, hdd: flash}, // drive-backed, live → recreate
|
||||
{name: "actualbudget", deployed: true, hdd: "/mnt/sys_drive/felhom-data"}, // SSD → not recreated
|
||||
{name: "stranded", deployed: true, hdd: "/mnt/felhom-drives/felhom-usb"}, // drive-backed, bind NOT live → skipped
|
||||
{name: "romm", deployed: true, hdd: flash, hasContainers: true}, // drive-backed, live → recreate
|
||||
{name: "actualbudget", deployed: true, hdd: "/mnt/sys_drive/felhom-data", hasContainers: true}, // SSD → not recreated
|
||||
{name: "stranded", deployed: true, hdd: "/mnt/felhom-drives/felhom-usb", hasContainers: true}, // drive-backed, bind NOT live → skipped
|
||||
}
|
||||
var seq []string
|
||||
recreate := func(bs bootStack) { seq = append(seq, "recreate:"+bs.name) }
|
||||
syncFB := func() { seq = append(seq, "syncFB") }
|
||||
|
||||
recreated, skipped := recreateDriveBackedApps(stacks, present, recreate, syncFB)
|
||||
recreated, skipped, leftStopped := recreateDriveBackedApps(stacks, present, recreate, syncFB)
|
||||
|
||||
if recreated != 1 || skipped != 1 {
|
||||
t.Fatalf("recreated=%d skipped=%d, want 1/1", recreated, skipped)
|
||||
if recreated != 1 || skipped != 1 || leftStopped != 0 {
|
||||
t.Fatalf("recreated=%d skipped=%d leftStopped=%d, want 1/1/0", recreated, skipped, leftStopped)
|
||||
}
|
||||
// FileBrowser sync MUST be invoked, and AFTER every recreate.
|
||||
if len(seq) != 2 || seq[0] != "recreate:romm" || seq[len(seq)-1] != "syncFB" {
|
||||
|
||||
Reference in New Issue
Block a user