package web import ( "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) func TestAgentWhere(t *testing.T) { cases := map[string]string{ "/mnt/felhom-drives/felhom-usb": "/mnt/felhom-usb", // stable → raw "/mnt/felhom-usb": "/mnt/felhom-usb", // legacy raw → raw (idempotent) "/mnt/felhom-drives/x": "/mnt/x", } for in, want := range cases { if got := agentWhere(in); got != want { t.Errorf("agentWhere(%q) = %q, want %q", in, got, want) } } } // 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 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) { const flash = "/mnt/felhom-drives/felhom-flash" present := map[string]bool{flash: true} cases := []struct { name string deployed bool hdd string hasContainers bool want bool }{ // --- 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, 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 // always returning ("", false). The "promote another" + "block when only drive" cases below fail that. func TestDefaultPromotionTarget(t *testing.T) { flash := "/mnt/felhom-drives/felhom-flash" usb := "/mnt/felhom-drives/felhom-usb" // decommissioning a NON-default → no action. paths := []settings.StoragePath{{Path: flash, IsDefault: true, Schedulable: true}, {Path: usb, Schedulable: true}} if tgt, blk := defaultPromotionTarget(paths, usb, ""); tgt != "" || blk { t.Fatalf("non-default decommission: got (%q,%v), want (\"\",false)", tgt, blk) } // decommissioning the DEFAULT with another usable → promote it. if tgt, blk := defaultPromotionTarget(paths, flash, ""); tgt != usb || blk { t.Fatalf("default decommission: got (%q,%v), want (%q,false)", tgt, blk, usb) } // prefer the migrate target when valid. if tgt, _ := defaultPromotionTarget(paths, flash, usb); tgt != usb { t.Fatalf("should prefer migrate target %q, got %q", usb, tgt) } // the ONLY usable drive (other is decommissioned) → BLOCK. only := []settings.StoragePath{{Path: flash, IsDefault: true, Schedulable: true}, {Path: usb, Decommissioned: true}} if tgt, blk := defaultPromotionTarget(only, flash, ""); tgt != "" || !blk { t.Fatalf("only-drive decommission: got (%q,%v), want (\"\",true)", tgt, blk) } } // TestPollLiveBinds_WaitsForLateBind is the boot-race fix's core: the readiness gate must WAIT for the // drive bind to actually go live (the agent re-binds it ~18s post-boot) and only THEN report it present, // so shouldRecreateOnBoot fires and the create-time-failed app is recreated. // // COMPANION (must fail pre-fix): the old readiness was a SINGLE early sample of the agent's // BoundUnderParent — taken before the ~18s rebind, so it read the bind ABSENT, recreated nothing, and // burned the boot-id one-shot. TestSingleEarlySample_MissesLateBind_Companion pins that broken outcome; // and if pollLiveBinds is reverted to a no-wait single check, this test fails its "did it wait" assertion. func TestPollLiveBinds_WaitsForLateBind(t *testing.T) { flash := "/mnt/felhom-drives/felhom-flash" var nowT time.Duration // fake monotonic clock since boot now := func() time.Time { return time.Unix(0, 0).Add(nowT) } sleep := func(d time.Duration) { nowT += d } const goLive = 18 * time.Second // the real agent rebind lag bindLive := func(string) bool { return nowT >= goLive } live := pollLiveBinds([]string{flash}, bindLive, sleep, now, bootBindWait, bootBindPoll) if !live[flash] { t.Fatalf("poll must detect the bind once live; got %v", live) } 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) { t.Fatalf("with the live bind present, the drive-backed app MUST be recreated") } } // TestSingleEarlySample_MissesLateBind_Companion is the explicit pre-fix companion: a single sample of // the bind at boot (t=0), before the ~18s rebind, reads it ABSENT → the app is NOT recreated and stays // Exited. This is exactly what stranded paperless-webserver et al. live. 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) { t.Fatalf("companion: a single early sample reads the not-yet-live bind as absent and must MISS it") } } // TestPollLiveBinds_TimeoutLeavesAbsent: a drive that never comes live within the window stays absent, // so its apps are NOT recreated here (left to the normal drive gate) — no spurious recreate/loop. func TestPollLiveBinds_TimeoutLeavesAbsent(t *testing.T) { usb := "/mnt/felhom-drives/felhom-usb" var nowT time.Duration now := func() time.Time { return time.Unix(0, 0).Add(nowT) } sleep := func(d time.Duration) { nowT += d } bindLive := func(string) bool { return false } // never live live := pollLiveBinds([]string{usb}, bindLive, sleep, now, bootBindWait, bootBindPoll) if live[usb] { t.Fatalf("a never-live bind must remain absent after the bounded wait") } if nowT < bootBindWait { t.Fatalf("poll must run to the deadline for an absent drive, t=%s", nowT) } if shouldRecreateOnBoot(true, usb, live, true) { t.Fatalf("an absent drive's app must NOT be recreated here (the gate owns drive-absent)") } } // TestRecreateDriveBackedApps_SyncsFileBrowserAfterRecreate (Task B): FileBrowser must be re-synced // AFTER the drive-backed apps are recreated (so it syncs against live binds). COMPANION: the pre-fix // boot-recreate path never synced FileBrowser — red-proofed by dropping the syncFB() call. 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, 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, leftStopped := recreateDriveBackedApps(stacks, present, recreate, syncFB) 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" { t.Fatalf("FileBrowser sync must run once, AFTER the recreate; seq=%v", seq) } } // TestRecreateDriveBackedApps_SyncsEvenWithNoRecreate: FileBrowser is re-synced to reflect the live // binds even when no app needed recreating (so its mounts never go stale). func TestRecreateDriveBackedApps_SyncsEvenWithNoRecreate(t *testing.T) { stacks := []bootStack{{name: "x", deployed: true, hdd: "/mnt/sys_drive/felhom-data"}} // SSD only synced := false recreateDriveBackedApps(stacks, map[string]bool{}, func(bootStack) { t.Fatal("should not recreate") }, func() { synced = true }) if !synced { t.Fatal("FileBrowser sync must run even when nothing was recreated") } } func TestStablePathForName(t *testing.T) { if got := stablePathForName("felhom-usb"); got != "/mnt/felhom-drives/felhom-usb" { t.Errorf("stablePathForName = %q", got) } } // TestPlanDriveGates pins the gate's pure decision across the four meaningful states. // // COMPANION GUARD: a trivial impl that gates every absent path regardless of the disconnected flag would // re-stop an already-disconnected drive (and never return it); one that ignores presence would never // gate. Both fail here. func TestPlanDriveGates(t *testing.T) { paths := []settings.StoragePath{ {Path: "/mnt/felhom-drives/usb"}, // present + connected → no action {Path: "/mnt/felhom-drives/flash"}, // ABSENT + connected → STOP {Path: "/mnt/felhom-drives/back", Disconnected: true}, // present + disconnected → RETURN {Path: "/mnt/felhom-drives/gone", Disconnected: true}, // ABSENT + disconnected → no action (steady) {Path: "/mnt/felhom-drives/dead", Decommissioned: true}, // decommissioned → never touched {Path: "/mnt/sys_drive/felhom-data"}, // INTERNAL SSD (absent from agent) → never gated } disks := []agentapi.DiskInfo{ // present = BoundUnderParent (the usable-in-guest signal), not merely State==attached. {MountPath: "/mnt/usb", GuestPath: "/mnt/felhom-drives/usb", State: "attached", BoundUnderParent: true}, {MountPath: "/mnt/back", GuestPath: "/mnt/felhom-drives/back", State: "attached", BoundUnderParent: true}, // flash reports State=attached but NOT bound under parent yet → still treated ABSENT (the // reboot-ordering case: raw drive mounted, agent hasn't bound it under the parent yet). {MountPath: "/mnt/flash", GuestPath: "/mnt/felhom-drives/flash", State: "attached", BoundUnderParent: false}, // gone + dead report NO present disk } actions := map[string]gateAction{} for _, a := range planDriveGates(paths, disks) { actions[a.Path] = a } if len(actions) != 2 { t.Fatalf("expected exactly 2 actions (stop flash, return back), got %d: %+v", len(actions), actions) } if a, ok := actions["/mnt/felhom-drives/flash"]; !ok || !a.Stop || a.Return { t.Errorf("flash should STOP (absent+connected): %+v", a) } if a, ok := actions["/mnt/felhom-drives/back"]; !ok || !a.Return || a.Stop || a.Raw != "/mnt/back" { t.Errorf("back should RETURN with raw /mnt/back (present+disconnected): %+v", a) } if _, gated := actions["/mnt/felhom-drives/usb"]; gated { t.Errorf("usb (present+connected) must not be gated") } if _, acted := actions["/mnt/felhom-drives/gone"]; acted { t.Errorf("gone (absent+already-disconnected) is steady — no action") } if _, acted := actions["/mnt/felhom-drives/dead"]; acted { t.Errorf("decommissioned drive must never be gated") } if _, acted := actions["/mnt/sys_drive/felhom-data"]; acted { t.Errorf("internal SSD/system path must NEVER be gated (only /mnt/felhom-drives/ externals)") } }