42f69dadda
H2 decommission UI button (migrate / anyway); H3 one-click re-enroll of a
decommissioned drive; M1 default reassignment (auto-promote + block-if-none);
M3 migrate re-asserts 2775 setgid on userdata dirs; deterministic guest-reboot
recreate via agent boot_id (replaces the timed sample). Fixes the {path}/{where}
H1 JS bug. Non-hollow tests + companions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
137 lines
6.6 KiB
Go
137 lines
6.6 KiB
Go
package web
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"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 deterministic boot-id recreate decision.
|
|
//
|
|
// COMPANION GUARD: the OLD timed-sample (`stackStartedRecently || State∈{exited,restarting,unhealthy}`)
|
|
// MISSED a healthy-but-stale Running app sampled minutes after boot — the first case below would be
|
|
// `false` under it. The boot-id path recreates it (it came back on this boot, drive present). It still
|
|
// respects a cleanly user-Stopped app and never touches absent-drive / SSD / not-deployed apps.
|
|
func TestShouldRecreateOnBoot(t *testing.T) {
|
|
present := map[string]bool{"/mnt/felhom-drives/felhom-flash": true}
|
|
cases := []struct {
|
|
name string
|
|
deployed bool
|
|
hdd string
|
|
state stacks.ContainerState
|
|
want bool
|
|
}{
|
|
{"healthy-but-stale (old sample MISSED this)", true, "/mnt/felhom-drives/felhom-flash", stacks.StateRunning, true},
|
|
{"exited", true, "/mnt/felhom-drives/felhom-flash", stacks.StateExited, true},
|
|
{"unhealthy", true, "/mnt/felhom-drives/felhom-flash", stacks.StateUnhealthy, true},
|
|
{"user-stopped (respected)", true, "/mnt/felhom-drives/felhom-flash", stacks.StateStopped, false},
|
|
{"not-deployed", true, "/mnt/felhom-drives/felhom-flash", stacks.StateNotDeployed, false},
|
|
{"drive absent (gate handles)", true, "/mnt/felhom-drives/felhom-usb", stacks.StateRunning, false},
|
|
{"SSD path never", true, "/mnt/sys_drive/felhom-data", stacks.StateRunning, false},
|
|
{"app.yaml not deployed", false, "/mnt/felhom-drives/felhom-flash", stacks.StateRunning, false},
|
|
}
|
|
for _, c := range cases {
|
|
if got := shouldRecreateOnBoot(c.deployed, c.hdd, c.state, present); got != c.want {
|
|
t.Errorf("%s: shouldRecreateOnBoot = %v, want %v", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
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)")
|
|
}
|
|
}
|