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:
2026-07-13 13:15:03 +02:00
parent c20814e6c2
commit 3c174bc6f2
11 changed files with 450 additions and 63 deletions
+76 -9
View File
@@ -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).