a621f4c5a0
TASK A — close out the reboot story (agent half). Root cause (pinned live on felhom-pve): an enrolled .mount unit left `disabled` by a prior detach never auto-mounts at boot, and kernel re-enumeration can move a drive's node (/dev/sdb->sdc). Fix re-asserts every enrolled mount by filesystem UUID at startup + on the periodic tick. - ResolveStorageDevice: resolve uuid:<fs-uuid> -> current /dev node via /dev/disk/by-uuid (never a cached node); errors if absent. - parseFelhomMountUnit: pure inverse of renderMountUnit (marker-gated). - (*SudoHostOps).ReassertEnrolledMounts: re-run EnsureMount (enable --now) for any enrolled unit not in /proc/mounts; idempotent, skips mounted/absent. - main.go: runs before ReassertGuestBinds at startup + on the 20s tick. - tests (Linux, seam=device resolution): letter-move tolerated (sdb->sdc) + red-proof companion, absent/scheme rejection, render->parse round-trip. TASK A2 verdict: enrolling a NEW drive does NOT need an LXC restart — the path lands on the live AttachDrive (shared parent, named live slots), not RebootGuest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
196 lines
7.4 KiB
Go
196 lines
7.4 KiB
Go
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")
|
|
}
|
|
}
|