Files
felhom-agent/internal/storage/hostread.go
T
admin 3c174bc6f2 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
2026-07-13 13:15:03 +02:00

256 lines
8.1 KiB
Go

package storage
import (
"bufio"
"os"
"path/filepath"
"strings"
)
// HostReader is the non-privileged host-read seam the observer and watchdog need. All of
// it is read-only and root-free: the active mount table, fs-UUID resolution via the
// /dev/disk/by-uuid symlinks, block-device presence, and the rotational/removable sysfs
// flags. Production is *ProcHostReader; tests inject a fake.
//
// Anything that needs root (smartctl, lvs, blkid) is NOT here — it lands on Phase B's
// privileged HostOps surface. Keep this seam root-free.
type HostReader interface {
// Mounts parses the active mount table (/proc/mounts).
Mounts() ([]Mount, error)
// ResolveUUID returns the filesystem UUID of a block device, derived from the
// /dev/disk/by-uuid symlinks. ok=false when the device has no by-uuid entry.
ResolveUUID(device string) (uuid string, ok bool)
// DeviceExists reports whether a block-device node is present (the fast USB-drop
// signal for the watchdog).
DeviceExists(device string) bool
// Rotational reads the backing disk's rotational flag (true=HDD/slow, false=SSD/fast).
// ok=false when it cannot be determined (network fs, missing sysfs, device-mapper).
Rotational(device string) (rotational bool, ok bool)
// 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.
type Mount struct {
Device string // e.g. "/dev/sdb1", "server:/export", "//server/share"
MountPoint string
FSType string
}
// ProcHostReader is the production HostReader: it reads the host's /proc, /dev, and /sys.
// The paths are fields so tests CAN point it at fixtures, though most tests use a fully
// fake HostReader instead.
type ProcHostReader struct {
ProcMounts string // default "/proc/mounts"
ByUUIDDir string // default "/dev/disk/by-uuid"
SysClass string // default "/sys/class/block"
}
// NewProcHostReader builds a ProcHostReader with the standard host paths.
func NewProcHostReader() *ProcHostReader {
return &ProcHostReader{
ProcMounts: "/proc/mounts",
ByUUIDDir: "/dev/disk/by-uuid",
SysClass: "/sys/class/block",
}
}
// Mounts parses /proc/mounts. The format is space-separated, octal-escaped fields:
// device mountpoint fstype options dump pass. We unescape the first three.
func (r *ProcHostReader) Mounts() ([]Mount, error) {
f, err := os.Open(r.procMounts())
if err != nil {
return nil, err
}
defer f.Close()
var out []Mount
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 3 {
continue
}
out = append(out, Mount{
Device: unescapeMount(fields[0]),
MountPoint: unescapeMount(fields[1]),
FSType: fields[2],
})
}
return out, sc.Err()
}
// ResolveUUID reverse-maps a device path to its fs-UUID by reading the /dev/disk/by-uuid
// symlinks and matching the canonical target of each against the device.
func (r *ProcHostReader) ResolveUUID(device string) (string, bool) {
if device == "" {
return "", false
}
want := canonPath(device)
entries, err := os.ReadDir(r.byUUIDDir())
if err != nil {
return "", false
}
for _, e := range entries {
link := filepath.Join(r.byUUIDDir(), e.Name())
target, err := os.Readlink(link)
if err != nil {
continue
}
if !filepath.IsAbs(target) {
target = filepath.Join(r.byUUIDDir(), target)
}
if canonPath(target) == want {
return e.Name(), true
}
}
return "", false
}
// DeviceExists stats the device node (after resolving symlinks like /dev/disk/by-uuid/X).
func (r *ProcHostReader) DeviceExists(device string) bool {
if device == "" {
return false
}
_, err := os.Stat(device)
return err == nil
}
// Rotational reads /sys/block/<parent-disk>/queue/rotational for the device's backing
// disk. "1" => rotational (HDD/slow), "0" => SSD/fast.
func (r *ProcHostReader) Rotational(device string) (bool, bool) {
disk, ok := r.parentDisk(device)
if !ok {
return false, false
}
b, err := os.ReadFile(filepath.Join(r.sysBlockDir(), disk, "queue", "rotational"))
if err != nil {
return false, false
}
switch strings.TrimSpace(string(b)) {
case "1":
return true, true
case "0":
return false, true
}
return false, false
}
// Removable reads /sys/block/<parent-disk>/removable. "1" => removable (USB/hot-plug).
func (r *ProcHostReader) Removable(device string) (bool, bool) {
disk, ok := r.parentDisk(device)
if !ok {
return false, false
}
b, err := os.ReadFile(filepath.Join(r.sysBlockDir(), disk, "removable"))
if err != nil {
return false, false
}
switch strings.TrimSpace(string(b)) {
case "1":
return true, true
case "0":
return false, true
}
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.
func (r *ProcHostReader) parentDisk(device string) (string, bool) {
name := filepath.Base(strings.TrimSpace(device))
if name == "" || name == "." || name == "/" {
return "", false
}
real, err := filepath.EvalSymlinks(filepath.Join(r.sysClass(), name))
if err != nil {
return "", false
}
// If <name> is a partition, /sys/class/block/<name>/partition exists and its parent
// directory is the disk. Otherwise <name> IS the disk.
if _, err := os.Stat(filepath.Join(real, "partition")); err == nil {
return filepath.Base(filepath.Dir(real)), true
}
return filepath.Base(real), true
}
// sysBlockDir derives /sys/block from the configured /sys/class/block.
func (r *ProcHostReader) sysBlockDir() string {
return filepath.Join(filepath.Dir(filepath.Dir(r.sysClass())), "block")
}
func (r *ProcHostReader) procMounts() string {
if r.ProcMounts != "" {
return r.ProcMounts
}
return "/proc/mounts"
}
func (r *ProcHostReader) byUUIDDir() string {
if r.ByUUIDDir != "" {
return r.ByUUIDDir
}
return "/dev/disk/by-uuid"
}
func (r *ProcHostReader) sysClass() string {
if r.SysClass != "" {
return r.SysClass
}
return "/sys/class/block"
}
// canonPath resolves symlinks for a best-effort canonical comparison, falling back to the
// cleaned path when the target can't be resolved (e.g. the device just disappeared).
func canonPath(p string) string {
if real, err := filepath.EvalSymlinks(p); err == nil {
return real
}
return filepath.Clean(p)
}
// unescapeMount decodes the octal \040-style escapes /proc/mounts uses for spaces, tabs,
// newlines and backslashes in the device/mountpoint fields.
func unescapeMount(s string) string {
if !strings.Contains(s, `\`) {
return s
}
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+3 < len(s) && isOctal(s[i+1]) && isOctal(s[i+2]) && isOctal(s[i+3]) {
v := (int(s[i+1]-'0') << 6) | (int(s[i+2]-'0') << 3) | int(s[i+3]-'0')
b.WriteByte(byte(v))
i += 3
continue
}
b.WriteByte(s[i])
}
return b.String()
}
func isOctal(c byte) bool { return c >= '0' && c <= '7' }