582135f861
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.
294 lines
15 KiB
Go
294 lines
15 KiB
Go
package web
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"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) {
|
|
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, stacks.DesiredStateUnknown); 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, stacks.DesiredStateUnknown) {
|
|
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, stacks.DesiredStateUnknown) {
|
|
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, stacks.DesiredStateUnknown) {
|
|
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)")
|
|
}
|
|
}
|