agent v0.87.0: SystemDisks device-mapper walk — legacy-boot hosts get a working drive wizard (IA finding 2, MEDIUM)
Operator ruling 2026-07-13: walk the root's backing device through /sys/block/<dev>/slaves recursively down to physical disks (dm AND md; topology, never VG names); those + any mounted-ESP holder are system; the all-system fail-safe returns to being the WALK-FAILURE error case only. SAFETY DIRECTION: a root-backing disk classified candidate is made impossible — per-branch conservatism (any unresolvable slave fails the WHOLE walk -> ok=false -> the unchanged all-system path). - physicalDisksOf/walkSlaves in role.go (symlink canon -> wholeDiskOf fast path -> recursive slaves walk; cycle/depth guard; non-/dev sources unwalkable) - HostReader.BlockSlaves(name) — the ONE new seam method; ProcHostReader reads /sys/block/<name>/slaves; all four test fakes mirror it - role_walk_test.go: signature table (root-backing disk ALWAYS system across legacy-LVM / md-raid / EFI+raw / EFI+LVM / nested dm-on-md — NEVER weaken) + dead-wizard-lives + dangling-slave fail-safe (real sysKnown=false path) + cycle + empty-slaves; red-proofs A/B/D run->fail->revert (recorded in REPORT) - §3 spike transcripts (drill legacy: dm-1->sda3->sda; felhom-pve: ESP+walk agree on sda -> byte-identical regression); caller audit: none relied on all-system as a feature - format/mkfs paths, data-bearing guards, wizard UI untouched
This commit is contained in:
@@ -90,11 +90,12 @@ func (f fakeGuestList) ListLXC(context.Context) ([]proxmox.Guest, error) { retur
|
||||
// a different whole-disk (e.g. /dev/sdb1) classifies as user-data without touching the real host.
|
||||
type fakeHostReader struct{ mounts []storage.Mount }
|
||||
|
||||
func (f fakeHostReader) Mounts() ([]storage.Mount, error) { return f.mounts, nil }
|
||||
func (f fakeHostReader) ResolveUUID(string) (string, bool) { return "", false }
|
||||
func (f fakeHostReader) DeviceExists(string) bool { return true }
|
||||
func (f fakeHostReader) Rotational(string) (bool, bool) { return false, false }
|
||||
func (f fakeHostReader) Removable(string) (bool, bool) { return false, false }
|
||||
func (f fakeHostReader) Mounts() ([]storage.Mount, error) { return f.mounts, nil }
|
||||
func (f fakeHostReader) ResolveUUID(string) (string, bool) { return "", false }
|
||||
func (f fakeHostReader) DeviceExists(string) bool { return true }
|
||||
func (f fakeHostReader) Rotational(string) (bool, bool) { return false, false }
|
||||
func (f fakeHostReader) Removable(string) (bool, bool) { return false, false }
|
||||
func (f fakeHostReader) BlockSlaves(string) ([]string, bool) { return nil, false }
|
||||
|
||||
// sysOnSDA is the default system-disk fixture (root on /dev/sda) used by the disk-server test helpers.
|
||||
func sysOnSDA() fakeHostReader {
|
||||
|
||||
@@ -18,9 +18,10 @@ func (u uuidHostReader) ResolveUUID(dev string) (string, bool) {
|
||||
v, ok := u.uuids[dev]
|
||||
return v, ok
|
||||
}
|
||||
func (u uuidHostReader) DeviceExists(string) bool { return true }
|
||||
func (u uuidHostReader) Rotational(string) (bool, bool) { return false, false }
|
||||
func (u uuidHostReader) Removable(string) (bool, bool) { return false, false }
|
||||
func (u uuidHostReader) DeviceExists(string) bool { return true }
|
||||
func (u uuidHostReader) Rotational(string) (bool, bool) { return false, false }
|
||||
func (u uuidHostReader) Removable(string) (bool, bool) { return false, false }
|
||||
func (u uuidHostReader) BlockSlaves(string) ([]string, bool) { return nil, false }
|
||||
|
||||
// Impl-2b: a RAW enrolled drive is not a PVE storage (absent from Observe), so its durable-id must be
|
||||
// resolved from the mount table's device fs-UUID. RED-PROOF: with the Observe-only resolution (before
|
||||
|
||||
@@ -33,6 +33,7 @@ func (f *f2HostReader) ResolveUUID(dev string) (string, bool) { u, ok := f.uuids
|
||||
func (f *f2HostReader) DeviceExists(string) bool { return true }
|
||||
func (f *f2HostReader) Rotational(string) (bool, bool) { return false, false }
|
||||
func (f *f2HostReader) Removable(string) (bool, bool) { return false, false }
|
||||
func (f *f2HostReader) BlockSlaves(string) ([]string, bool) { return nil, false }
|
||||
func (f *f2HostReader) mountsCalls() int { f.mu.Lock(); defer f.mu.Unlock(); return f.calls }
|
||||
|
||||
// errStorage is a StorageView whose Observe always fails — for the Observe-error fail-safe (edge C3).
|
||||
@@ -67,8 +68,8 @@ func TestRoleForMountPath_F2_BindMountedUserData_Ejectable(t *testing.T) {
|
||||
}}
|
||||
host := &f2HostReader{
|
||||
mounts: []storage.Mount{
|
||||
{Device: "/dev/sda1", MountPoint: "/"}, // system disk
|
||||
{Device: "/dev/sdb", MountPoint: "/mnt/teszt_enroll"}, // the raw enrolled user-data drive
|
||||
{Device: "/dev/sda1", MountPoint: "/"}, // system disk
|
||||
{Device: "/dev/sdb", MountPoint: "/mnt/teszt_enroll"}, // the raw enrolled user-data drive
|
||||
},
|
||||
uuids: map[string]string{"/dev/sdb": "f2236136-ced7"},
|
||||
}
|
||||
|
||||
@@ -29,6 +29,11 @@ type HostReader interface {
|
||||
// Removable reads the backing disk's removable flag (true => a USB/hot-plug device).
|
||||
// ok=false when it cannot be determined.
|
||||
Removable(device string) (removable bool, ok bool)
|
||||
// BlockSlaves lists the component (slave) device names beneath /sys/block/<name>/slaves —
|
||||
// non-empty for VIRTUAL block devices (device-mapper dm-*, md-raid md*), empty for a
|
||||
// physical disk (the dir exists but has no entries). hasDir=false when <name> has no
|
||||
// /sys/block entry at all (partitions, unknown names). Root-free (sysfs is world-readable).
|
||||
BlockSlaves(name string) (slaves []string, hasDir bool)
|
||||
}
|
||||
|
||||
// Mount is one active-mount-table entry.
|
||||
@@ -156,6 +161,25 @@ func (r *ProcHostReader) Removable(device string) (bool, bool) {
|
||||
return false, false
|
||||
}
|
||||
|
||||
// BlockSlaves lists /sys/block/<name>/slaves. Every whole device in /sys/block carries the
|
||||
// slaves/ directory (empty on physical disks); partitions have no /sys/block entry at all —
|
||||
// they resolve via the partition regexes in role.go, never through here.
|
||||
func (r *ProcHostReader) BlockSlaves(name string) ([]string, bool) {
|
||||
name = filepath.Base(strings.TrimSpace(name))
|
||||
if name == "" || name == "." || name == "/" {
|
||||
return nil, false
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(r.sysBlockDir(), name, "slaves"))
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// parentDisk maps a device path (possibly a partition like /dev/sdb1 or /dev/nvme0n1p2)
|
||||
// to its parent disk's sysfs name (sdb / nvme0n1). It uses /sys/class/block/<name>, whose
|
||||
// real path ends in .../<disk>/<partition> for a partition and .../<disk> for a whole disk.
|
||||
|
||||
@@ -36,10 +36,11 @@ func (f *fakeStorageAPI) NodeStorage(context.Context) ([]proxmox.Storage, error)
|
||||
type fakeHostReader struct {
|
||||
mounts []Mount
|
||||
mountsErr error
|
||||
uuids map[string]string // device -> uuid
|
||||
exists map[string]bool // device -> present
|
||||
rotational map[string]bool // device -> rotational (presence => known)
|
||||
removable map[string]bool // device -> removable (presence => known)
|
||||
uuids map[string]string // device -> uuid
|
||||
exists map[string]bool // device -> present
|
||||
rotational map[string]bool // device -> rotational (presence => known)
|
||||
removable map[string]bool // device -> removable (presence => known)
|
||||
slaves map[string][]string // block NAME -> sysfs slaves (presence => /sys/block dir exists)
|
||||
}
|
||||
|
||||
func (h *fakeHostReader) Mounts() ([]Mount, error) { return h.mounts, h.mountsErr }
|
||||
@@ -56,6 +57,10 @@ func (h *fakeHostReader) Removable(device string) (bool, bool) {
|
||||
v, ok := h.removable[device]
|
||||
return v, ok
|
||||
}
|
||||
func (h *fakeHostReader) BlockSlaves(name string) ([]string, bool) {
|
||||
s, ok := h.slaves[name]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// byName indexes observed targets for assertions.
|
||||
func byName(targets []hub.StorageTarget) map[string]hub.StorageTarget {
|
||||
|
||||
@@ -3,6 +3,7 @@ package storage
|
||||
import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||
)
|
||||
@@ -17,7 +18,7 @@ import (
|
||||
// - system : the appliance's OS/boot/EFI/guest-rootfs storage. Operator-signature ONLY.
|
||||
// - backup : the backup safety-net (PBS). Operator-signature ONLY (wiping it destroys the net).
|
||||
// - user-data : a customer external data drive — already within the controller's blast radius
|
||||
// (it bind-mounts /mnt), so a customer informed-confirmation may authorize a wipe.
|
||||
// (it bind-mounts /mnt), so a customer informed-confirmation may authorize a wipe.
|
||||
//
|
||||
// On ANY ambiguity the agent defaults to the MOST-PROTECTED role (system) — consistent with the
|
||||
// destructive-on-ambiguity invariant: an unrecognized device is treated as protected, never silently
|
||||
@@ -34,15 +35,21 @@ const (
|
||||
var reWholeDisk = regexp.MustCompile(`^/dev/(?:sd|hd|vd)[a-z]+$|^/dev/nvme[0-9]+n[0-9]+$`)
|
||||
|
||||
// systemMountPoints are the host mountpoints whose backing whole-disk is, by definition, the OS /
|
||||
// system disk. /boot and /boot/efi are the load-bearing ones: on a typical Proxmox/Debian install
|
||||
// the ESP is a raw partition directly on the OS disk, so it pins the OS whole-disk even when / is on
|
||||
// LVM/device-mapper (which we cannot trace back to a raw disk without privileged LVM introspection).
|
||||
// system disk. A mounted ESP (/boot/efi) pins the OS disk directly; an LVM/device-mapper root is
|
||||
// traced to its physical parents by the root-free sysfs slaves/ walk (v0.87.0 — before that, a
|
||||
// legacy-boot host with no mounted ESP resolved NOTHING and the all-system fail-safe killed the
|
||||
// drive wizard permanently, IA finding 2).
|
||||
var systemMountPoints = map[string]bool{"/": true, "/boot": true, "/boot/efi": true}
|
||||
|
||||
// SystemDisks resolves the set of whole-disk device paths that host the OS (the disks backing /,
|
||||
// /boot and /boot/efi). ok=false when NONE could be resolved (no system mountpoint mapped to a raw
|
||||
// disk) — callers then treat every candidate as system (most protected). Root-free: it parses the
|
||||
// mount table + world-readable /dev symlinks only (the root-CLI fence is untouched).
|
||||
// /boot and /boot/efi). Virtual backing devices (device-mapper/LVM, md-raid — the legacy-boot
|
||||
// common case where / sits on /dev/mapper/pve-root and no ESP is mounted) are walked recursively
|
||||
// through the sysfs slaves/ chain down to their physical parent disks (operator ruling
|
||||
// 2026-07-13: walk topology, never VG names). ok=false ONLY when the topology could not be fully
|
||||
// grounded — no system mountpoint found, or ANY system mount whose backing device the walk could
|
||||
// not resolve to physical disks — and callers then treat every candidate as system (most
|
||||
// protected). That all-system fail-safe is back to being the ERROR case, not the legacy-boot
|
||||
// common case. Root-free: mount table + /dev symlinks + world-readable sysfs only.
|
||||
func SystemDisks(host HostReader) (set map[string]bool, ok bool) {
|
||||
if host == nil {
|
||||
return nil, false
|
||||
@@ -56,13 +63,73 @@ func SystemDisks(host HostReader) (set map[string]bool, ok bool) {
|
||||
if !systemMountPoints[cleanMountPath(m.MountPoint)] {
|
||||
continue
|
||||
}
|
||||
if wd, wok := wholeDiskOf(m.Device); wok {
|
||||
set[wd] = true
|
||||
disks, dok := physicalDisksOf(host, m.Device)
|
||||
if !dok {
|
||||
// A system mount we cannot ground in physical disks — the WHOLE resolution is
|
||||
// undeterminable. Never return a partial set as ok: a root-backing disk missing
|
||||
// from the set is exactly the catastrophic direction (a system disk offered as
|
||||
// a wizard candidate).
|
||||
return nil, false
|
||||
}
|
||||
for _, d := range disks {
|
||||
set[d] = true
|
||||
}
|
||||
}
|
||||
return set, len(set) > 0
|
||||
}
|
||||
|
||||
// physicalDisksOf resolves a mounted device to the PHYSICAL whole disks backing it. Plain
|
||||
// disks/partitions resolve directly (wholeDiskOf); a virtual device (dm-*, md*) is walked via
|
||||
// its sysfs slaves. ok=false when the device cannot be grounded (network/dataset sources,
|
||||
// unknown names, or any unresolvable slave branch).
|
||||
func physicalDisksOf(host HostReader, device string) ([]string, bool) {
|
||||
if device == "" {
|
||||
return nil, false
|
||||
}
|
||||
dev := device
|
||||
if resolved, err := filepath.EvalSymlinks(device); err == nil {
|
||||
dev = resolved // canonicalize /dev/mapper/pve-root → /dev/dm-1, by-uuid links, …
|
||||
}
|
||||
if wd, wok := wholeDiskOf(dev); wok {
|
||||
return []string{wd}, true // already a raw disk or a recognizable partition
|
||||
}
|
||||
if !strings.HasPrefix(dev, "/dev/") {
|
||||
return nil, false // ZFS dataset, NFS, overlay, … — not a block topology we can walk
|
||||
}
|
||||
return walkSlaves(host, filepath.Base(dev), map[string]bool{})
|
||||
}
|
||||
|
||||
// walkSlaves recursively resolves a VIRTUAL block device name (dm-*, md*) to physical whole
|
||||
// disks via /sys/block/<name>/slaves. Per-branch conservatism (operator ruling): ANY slave that
|
||||
// cannot be resolved — a dangling entry, an unrecognizable name, a virtual device with no
|
||||
// listable slaves — fails the WHOLE walk. The candidate/protected verdict must never rest on a
|
||||
// partially-understood topology. visited doubles as the cycle/degenerate-depth guard.
|
||||
func walkSlaves(host HostReader, name string, visited map[string]bool) ([]string, bool) {
|
||||
if name == "" || visited[name] || len(visited) > 32 {
|
||||
return nil, false
|
||||
}
|
||||
visited[name] = true
|
||||
slaves, hasDir := host.BlockSlaves(name)
|
||||
if !hasDir || len(slaves) == 0 {
|
||||
// No /sys/block entry (not a whole device) or nothing beneath a supposed virtual
|
||||
// device — either way this branch cannot be grounded.
|
||||
return nil, false
|
||||
}
|
||||
var out []string
|
||||
for _, s := range slaves {
|
||||
if wd, wok := wholeDiskOf("/dev/" + s); wok {
|
||||
out = append(out, wd) // a physical disk or a partition of one (sda3 → /dev/sda)
|
||||
continue
|
||||
}
|
||||
sub, sok := walkSlaves(host, s, visited)
|
||||
if !sok {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, sub...)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// wholeDiskOf maps a device path (a partition, a whole disk, or a /dev/disk/by-* symlink) to its
|
||||
// whole-disk /dev path. ok=false when the result is not a recognizable raw disk (device-mapper / LVM
|
||||
// / network) — the caller then treats the topology as undeterminable (→ most-protected).
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
// v0.87.0 — SystemDisks device-mapper/md walk (IA finding 2, operator ruling 2026-07-13).
|
||||
// The direction that must be IMPOSSIBLE is a root-backing disk classified as a candidate;
|
||||
// the direction being fixed is the legacy-boot all-system over-protection (dead wizard).
|
||||
//
|
||||
// Fixtures mirror the §3 live transcripts: on the drill host / → /dev/mapper/pve-root →
|
||||
// dm-1 → slaves sda3 → parent sda; physical disks carry an EMPTY slaves dir (hasDir=true).
|
||||
// COMPANION red-proofs (run → fail → revert, recorded in REPORT):
|
||||
// B: walk returns only the dm node (pre-fix shape) → TestSystemDisks_WalkTopologies legacy
|
||||
// fixture fails its system-set assertion.
|
||||
// A: pre-fix resolver (no walk) → TestSystemDisks_LegacyBoot_WizardLives fails
|
||||
// (reproduces the dead-wizard live shape).
|
||||
// D: per-branch conservatism removed (skip unresolvable slaves) → the dangling fixture
|
||||
// fails on "scratch classified candidate while walk incomplete".
|
||||
|
||||
// walkTopology is one fixture: the host's mounts + sysfs slaves tree, with the disks that
|
||||
// MUST be system and a scratch disk that must stay outside the set.
|
||||
type walkTopology struct {
|
||||
name string
|
||||
mounts []Mount
|
||||
slaves map[string][]string
|
||||
wantSystem []string // every entry MUST be in the resolved set (signature assertion B)
|
||||
scratch string // must NOT be in the set (wizard-eligible)
|
||||
}
|
||||
|
||||
func walkTopologies() []walkTopology {
|
||||
return []walkTopology{
|
||||
{
|
||||
// The drill host's exact shape (§3 transcript): legacy boot, LVM root, no ESP mount.
|
||||
name: "legacy LVM root (dm -> partition -> disk)",
|
||||
mounts: []Mount{{Device: "/dev/mapper/pve-root", MountPoint: "/", FSType: "ext4"}},
|
||||
slaves: map[string][]string{
|
||||
"pve-root": {"sda3"},
|
||||
"sda": {}, // physical disk: slaves dir exists, empty (live-probed)
|
||||
},
|
||||
wantSystem: []string{"/dev/sda"},
|
||||
scratch: "/dev/sdd",
|
||||
},
|
||||
{
|
||||
// md-raid root: BOTH member disks are system.
|
||||
name: "md-raid root (md -> 2 disks)",
|
||||
mounts: []Mount{{Device: "/dev/md0", MountPoint: "/", FSType: "ext4"}},
|
||||
slaves: map[string][]string{
|
||||
"md0": {"sda1", "sdb1"},
|
||||
},
|
||||
wantSystem: []string{"/dev/sda", "/dev/sdb"},
|
||||
scratch: "/dev/sdd",
|
||||
},
|
||||
{
|
||||
// Plain EFI + raw partitions (the pre-walk demo shape) — no virtual layer at all.
|
||||
name: "EFI + raw partitions",
|
||||
mounts: []Mount{
|
||||
{Device: "/dev/sda2", MountPoint: "/", FSType: "ext4"},
|
||||
{Device: "/dev/sda1", MountPoint: "/boot/efi", FSType: "vfat"},
|
||||
},
|
||||
slaves: map[string][]string{},
|
||||
wantSystem: []string{"/dev/sda"},
|
||||
scratch: "/dev/sdd",
|
||||
},
|
||||
{
|
||||
// felhom-pve's exact shape (§3 baseline): EFI mount AND LVM root — the walk and the
|
||||
// ESP must agree on the same disk (scenario C: output identical pre/post).
|
||||
name: "EFI + LVM root (felhom-pve)",
|
||||
mounts: []Mount{
|
||||
{Device: "/dev/mapper/pve-root", MountPoint: "/", FSType: "ext4"},
|
||||
{Device: "/dev/sda2", MountPoint: "/boot/efi", FSType: "vfat"},
|
||||
},
|
||||
slaves: map[string][]string{
|
||||
"pve-root": {"sda3"},
|
||||
},
|
||||
wantSystem: []string{"/dev/sda"},
|
||||
scratch: "/dev/sdd",
|
||||
},
|
||||
{
|
||||
// Nested virtual layers: dm on md on partitions of two disks.
|
||||
name: "nested dm-on-md",
|
||||
mounts: []Mount{{Device: "/dev/mapper/pve-root", MountPoint: "/", FSType: "ext4"}},
|
||||
slaves: map[string][]string{
|
||||
"pve-root": {"md0"},
|
||||
"md0": {"sda2", "sdb2"},
|
||||
},
|
||||
wantSystem: []string{"/dev/sda", "/dev/sdb"},
|
||||
scratch: "/dev/sdd",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemDisks_WalkTopologies is the SIGNATURE test (scenario B): in every topology the
|
||||
// walk resolves, the root-backing physical disk(s) are ALWAYS in the system set. This
|
||||
// assertion may never be weakened.
|
||||
func TestSystemDisks_WalkTopologies(t *testing.T) {
|
||||
for _, tc := range walkTopologies() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
host := &fakeHostReader{mounts: tc.mounts, slaves: tc.slaves}
|
||||
set, ok := SystemDisks(host)
|
||||
if !ok {
|
||||
t.Fatalf("SystemDisks must resolve this topology, got ok=false (set=%v)", set)
|
||||
}
|
||||
for _, d := range tc.wantSystem {
|
||||
if !set[d] {
|
||||
t.Errorf("SIGNATURE VIOLATION: root-backing disk %s missing from system set %v", d, set)
|
||||
}
|
||||
}
|
||||
if set[tc.scratch] {
|
||||
t.Errorf("scratch disk %s wrongly in the system set %v", tc.scratch, set)
|
||||
}
|
||||
// And the classification consequences: the system disk is never user-data, the
|
||||
// scratch disk is never system.
|
||||
if RoleForRawDevice(tc.wantSystem[0], set, ok) != RoleSystem {
|
||||
t.Errorf("root-backing disk %s must classify system", tc.wantSystem[0])
|
||||
}
|
||||
if RoleForRawDevice(tc.scratch, set, ok) != RoleUserData {
|
||||
t.Errorf("scratch disk %s must classify user-data (wizard-eligible)", tc.scratch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemDisks_LegacyBoot_WizardLives (scenario A): the drill-box shape resolves — the
|
||||
// scratch disk is wizard-eligible instead of the pre-fix all-system dead end.
|
||||
// RED-PROOF companion: with the pre-fix resolver (wholeDiskOf only, no walk) this fails on
|
||||
// ok=false — the exact dead-wizard live shape from the IA report.
|
||||
func TestSystemDisks_LegacyBoot_WizardLives(t *testing.T) {
|
||||
host := &fakeHostReader{
|
||||
mounts: []Mount{{Device: "/dev/mapper/pve-root", MountPoint: "/", FSType: "ext4"}},
|
||||
slaves: map[string][]string{"pve-root": {"sda3"}},
|
||||
}
|
||||
set, ok := SystemDisks(host)
|
||||
if !ok {
|
||||
t.Fatal("legacy-boot topology must resolve (the all-system fail-safe is the ERROR case, not the legacy-boot case)")
|
||||
}
|
||||
if !set["/dev/sda"] {
|
||||
t.Fatalf("root parent disk missing: %v", set)
|
||||
}
|
||||
if isSystemBacked("/dev/sdd", set, ok) {
|
||||
t.Fatal("hot-added scratch disk still classified system — the wizard stays dead")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemDisks_DanglingSlave_FailSafe (scenario D): an unresolvable slave fails the WHOLE
|
||||
// walk and callers see the all-system behavior through the SAME code path as today
|
||||
// (sysKnown=false → isSystemBacked true for everything).
|
||||
// RED-PROOF companion: treating an unresolved slave as skippable makes ok=true here → the
|
||||
// scratch disk classifies candidate while the walk is incomplete → this test fails.
|
||||
func TestSystemDisks_DanglingSlave_FailSafe(t *testing.T) {
|
||||
host := &fakeHostReader{
|
||||
mounts: []Mount{{Device: "/dev/mapper/pve-root", MountPoint: "/", FSType: "ext4"}},
|
||||
slaves: map[string][]string{
|
||||
// One branch resolves (sda3 → sda), one is DANGLING (dm-9 has no /sys/block entry).
|
||||
// The resolvable branch is load-bearing for the red-proof: a "skip the unresolved
|
||||
// slave" mutation would yield a plausible non-empty set — exactly the partial
|
||||
// topology the conservatism rule forbids.
|
||||
"pve-root": {"sda3", "dm-9"},
|
||||
},
|
||||
}
|
||||
set, ok := SystemDisks(host)
|
||||
if ok {
|
||||
t.Fatalf("a dangling slave must fail the whole walk, got ok=true set=%v", set)
|
||||
}
|
||||
// The fail-safe path itself (not a lookalike): sysKnown=false forces system for EVERY
|
||||
// device, exactly as the pre-walk legacy behavior did.
|
||||
if !isSystemBacked("/dev/sdd", set, ok) {
|
||||
t.Fatal("fail-safe violated: scratch disk classified candidate while the walk is incomplete")
|
||||
}
|
||||
if RoleForRawDevice("/dev/sdd", set, ok) != RoleSystem {
|
||||
t.Fatal("fail-safe violated at role level")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemDisks_CycleGuard: a slaves cycle (corrupt sysfs / hostile fixture) terminates and
|
||||
// fails safe instead of recursing forever.
|
||||
func TestSystemDisks_CycleGuard(t *testing.T) {
|
||||
host := &fakeHostReader{
|
||||
mounts: []Mount{{Device: "/dev/mapper/pve-root", MountPoint: "/", FSType: "ext4"}},
|
||||
slaves: map[string][]string{
|
||||
"pve-root": {"dm-1"},
|
||||
"dm-1": {"pve-root"},
|
||||
},
|
||||
}
|
||||
if _, ok := SystemDisks(host); ok {
|
||||
t.Fatal("a slaves cycle must fail safe (ok=false)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemDisks_VirtualWithEmptySlaves: a virtual root whose slaves dir is EMPTY (nothing to
|
||||
// ground on) fails safe — hasDir alone is not resolution.
|
||||
func TestSystemDisks_VirtualWithEmptySlaves(t *testing.T) {
|
||||
host := &fakeHostReader{
|
||||
mounts: []Mount{{Device: "/dev/mapper/pve-root", MountPoint: "/", FSType: "ext4"}},
|
||||
slaves: map[string][]string{"pve-root": {}},
|
||||
}
|
||||
if _, ok := SystemDisks(host); ok {
|
||||
t.Fatal("virtual device with no slaves must fail safe")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user