package storage import ( "os" "path/filepath" "runtime" "testing" ) // fakeDevDisk builds a temp /dev/disk tree: a real "device" file + by-id/by-uuid symlinks to it. // Returns the disk root + the device path. (filepath.EvalSymlinks needs real targets, so the // "device" is a regular file standing in for a block device.) Skips on Windows where creating a // symlink needs a privilege — the durable-device code is Linux-only (the agent runs on the PVE // host), so these run on the build server / demo host. func fakeDevDisk(t *testing.T, links map[string]string, uuids map[string]string) (root, device string) { t.Helper() if runtime.GOOS == "windows" { t.Skip("durable-device symlink tests run on Linux (the agent's OS); symlink creation needs privilege on Windows") } base := t.TempDir() device = filepath.Join(base, "sdb") if err := os.WriteFile(device, []byte("x"), 0o600); err != nil { t.Fatal(err) } root = filepath.Join(base, "disk") mk := func(sub, name, target string) { dir := filepath.Join(root, sub) os.MkdirAll(dir, 0o755) if err := os.Symlink(target, filepath.Join(dir, name)); err != nil { t.Fatal(err) } } for name := range links { mk("by-id", name, device) } for uuid := range uuids { mk("by-uuid", uuid, device) } return root, device } func withDevDiskRoot(t *testing.T, root string) { t.Helper() old := devDiskRoot devDiskRoot = root t.Cleanup(func() { devDiskRoot = old }) } // TestResolveStorageDevice_ToleratesDeviceLetterMove (Task A): on a host reboot the kernel can move a // drive from /dev/sdb to /dev/sdc; ResolveStorageDevice re-resolves the enrolled durable-id by filesystem // UUID and returns the CURRENT node, so the reshuffle is a no-op. COMPANION: a node-based resolver (what // the broken remount effectively did — trust the enroll-time node) returns the STALE node → the WRONG // device once the letter moves. func TestResolveStorageDevice_ToleratesDeviceLetterMove(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("symlink-based device resolution runs on Linux (the agent's OS)") } base := t.TempDir() sdb := filepath.Join(base, "sdb") // the enroll-time node (now holds some OTHER drive after reboot) sdc := filepath.Join(base, "sdc") // where felhom-usb's UUID now lives after re-enumeration if err := os.WriteFile(sdb, []byte("x"), 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(sdc, []byte("x"), 0o600); err != nil { t.Fatal(err) } root := filepath.Join(base, "disk") if err := os.MkdirAll(filepath.Join(root, "by-uuid"), 0o755); err != nil { t.Fatal(err) } const uuid = "da9e7089-cf8e-4617-adcb-a377743fae00" if err := os.Symlink(sdc, filepath.Join(root, "by-uuid", uuid)); err != nil { // moved: UUID → sdc t.Fatal(err) } withDevDiskRoot(t, root) // FIX: resolve by UUID → the CURRENT node (sdc), tolerating the move. got, err := ResolveStorageDevice("uuid:" + uuid) if err != nil { t.Fatalf("ResolveStorageDevice: %v", err) } wantSdc, _ := filepath.EvalSymlinks(sdc) if got != wantSdc { t.Fatalf("resolved %q, want the CURRENT node %q (the UUID moved sdb→sdc)", got, wantSdc) } // COMPANION: a node-based remount that trusts the recorded enroll-time node (sdb) now targets the // WRONG device — sdb is no longer where this UUID lives. cachedNode, _ := filepath.EvalSymlinks(sdb) if cachedNode == got { t.Fatalf("companion: the cached enroll-time node must differ from the freshly-resolved device after a move (cached=%q resolved=%q)", cachedNode, got) } } // TestResolveStorageDevice_AbsentAndScheme: a vanished UUID errors (so the re-assert skips a gone drive), // and only the uuid: scheme is resolvable (never a bare /dev node — the anti-node-binding invariant). func TestResolveStorageDevice_AbsentAndScheme(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("linux") } withDevDiskRoot(t, filepath.Join(t.TempDir(), "empty")) if _, err := ResolveStorageDevice("uuid:gone-uuid"); err == nil { t.Fatal("an absent UUID must error so the re-assert skips a gone drive instead of fail-mounting") } for _, id := range []string{"/dev/sdb1", "store:felhom-usb", "byid:wwn-x", ""} { if _, err := ResolveStorageDevice(id); err == nil { t.Errorf("ResolveStorageDevice(%q) must reject a non-uuid: scheme", id) } } } // TestParseFelhomMountUnit round-trips renderMountUnit → parseFelhomMountUnit and rejects foreign units. func TestParseFelhomMountUnit(t *testing.T) { spec := MountSpec{Name: "felhom-usb", UUID: "da9e7089-cf8e-4617-adcb-a377743fae00", Where: "/mnt/felhom-usb", FSType: "ext4"} got, ok := parseFelhomMountUnit(renderMountUnit(spec)) if !ok { t.Fatal("our own rendered unit must parse") } if got.Name != spec.Name || got.UUID != spec.UUID || got.Where != spec.Where || got.FSType != spec.FSType { t.Fatalf("round-trip mismatch: got %+v want %+v", got, spec) } if _, ok := parseFelhomMountUnit("[Mount]\nWhat=/dev/sdb1\nWhere=/mnt/x\n"); ok { t.Fatal("a non-felhom unit (no marker, not by-uuid) must not parse") } } // DeviceDurableID prefers a wwn- by-id link over ata-/serial links and over a uuid. func TestDeviceDurableID_PrefersWWN(t *testing.T) { root, device := fakeDevDisk(t, map[string]string{"wwn-0x5000c500abcd": "", "ata-Samsung_SSD_850_S1": "", "scsi-35000c500abcd": ""}, map[string]string{"1111-2222": ""}) withDevDiskRoot(t, root) id, err := DeviceDurableID(device) if err != nil { t.Fatalf("DeviceDurableID: %v", err) } if id != "byid:wwn-0x5000c500abcd" { t.Errorf("durable id = %q, want the wwn link", id) } } // With no by-id link, it falls back to a filesystem UUID. func TestDeviceDurableID_FallsBackToUUID(t *testing.T) { root, device := fakeDevDisk(t, map[string]string{}, map[string]string{"abcd-ef01": ""}) withDevDiskRoot(t, root) id, err := DeviceDurableID(device) if err != nil { t.Fatalf("DeviceDurableID: %v", err) } if id != "byuuid:abcd-ef01" { t.Errorf("durable id = %q, want byuuid:abcd-ef01", id) } } // A device with no durable identity at all → error (cannot be wipe-bound). func TestDeviceDurableID_NoneErrors(t *testing.T) { root, device := fakeDevDisk(t, map[string]string{}, map[string]string{}) withDevDiskRoot(t, root) if _, err := DeviceDurableID(device); err == nil { t.Fatal("a device with no wwn/serial/uuid must error (no durable id)") } } // ResolveDurableDevice round-trips a by-id id back to the canonical device path. func TestResolveDurableDevice_RoundTrip(t *testing.T) { root, device := fakeDevDisk(t, map[string]string{"wwn-0xabc": ""}, map[string]string{}) withDevDiskRoot(t, root) got, err := ResolveDurableDevice("byid:wwn-0xabc") if err != nil { t.Fatalf("ResolveDurableDevice: %v", err) } want, _ := filepath.EvalSymlinks(device) if got != want { t.Errorf("resolved %q, want %q", got, want) } } // A path-only / unknown-scheme id is REFUSED (the anti-retarget invariant). func TestResolveDurableDevice_RefusesPathOnly(t *testing.T) { withDevDiskRoot(t, t.TempDir()) for _, bad := range []string{"/dev/sdb", "sdb", "uuid-no-scheme", "byid:../../etc/passwd", "byuuid:../x"} { if _, err := ResolveDurableDevice(bad); err == nil { t.Errorf("ResolveDurableDevice(%q) succeeded, want refusal", bad) } } } // A durable id whose link is gone → error (device removed/replaced). func TestResolveDurableDevice_MissingErrors(t *testing.T) { withDevDiskRoot(t, t.TempDir()) // empty: no by-id dir if _, err := ResolveDurableDevice("byid:wwn-0xgone"); err == nil { t.Fatal("an absent durable id must error") } }