3c174bc6f2
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
217 lines
9.6 KiB
Go
217 lines
9.6 KiB
Go
package storage
|
|
|
|
import (
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
|
)
|
|
|
|
// Device ROLE classification — the protection tier the AGENT assigns a storage/device by its OWN
|
|
// inspection (the PVE storage view + the host mount/topology reads it already gathers), NEVER from
|
|
// the controller's or the hub's claim. This is the storage analog of classify.go's data-bearing
|
|
// verdict: "provenance is agent-internal, never populated from an external input, else a compromised
|
|
// hub could relabel a protected device to walk the gate."
|
|
//
|
|
// The tier decides who may authorize a destructive wipe of the device:
|
|
// - 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.
|
|
//
|
|
// 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
|
|
// user-data.
|
|
type DeviceRole string
|
|
|
|
const (
|
|
RoleSystem DeviceRole = "system"
|
|
RoleBackup DeviceRole = "backup"
|
|
RoleUserData DeviceRole = "user-data"
|
|
)
|
|
|
|
// reWholeDisk matches a whole raw disk path (no partition suffix): /dev/sda, /dev/vdb, /dev/nvme0n1.
|
|
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. 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). 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
|
|
}
|
|
mounts, err := host.Mounts()
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
set = map[string]bool{}
|
|
for _, m := range mounts {
|
|
if !systemMountPoints[cleanMountPath(m.MountPoint)] {
|
|
continue
|
|
}
|
|
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).
|
|
func wholeDiskOf(device string) (string, bool) {
|
|
if device == "" {
|
|
return "", false
|
|
}
|
|
dev := device
|
|
if resolved, err := filepath.EvalSymlinks(device); err == nil {
|
|
dev = resolved // canonicalize /dev/disk/by-uuid/… → /dev/sdXN
|
|
}
|
|
if m := reNVMePart.FindStringSubmatch(dev); m != nil {
|
|
return m[1], true // /dev/nvme0n1p2 → /dev/nvme0n1
|
|
}
|
|
if m := reSDPart.FindStringSubmatch(dev); m != nil {
|
|
return m[1], true // /dev/sdb1 → /dev/sdb
|
|
}
|
|
if reWholeDisk.MatchString(dev) {
|
|
return dev, true // already a whole disk
|
|
}
|
|
return "", false // /dev/mapper/*, network, unrecognized → undeterminable
|
|
}
|
|
|
|
// isSystemBacked reports whether device's whole-disk is an OS/system disk. It FAILS SAFE: any
|
|
// ambiguity (system set unknown, or the device's whole-disk unrecognizable) returns true (system).
|
|
func isSystemBacked(device string, sysDisks map[string]bool, sysKnown bool) bool {
|
|
if !sysKnown {
|
|
return true // can't determine the system disks → treat as system (most protected)
|
|
}
|
|
wd, ok := wholeDiskOf(device)
|
|
if !ok {
|
|
return true // unrecognizable device topology → most protected
|
|
}
|
|
return sysDisks[wd]
|
|
}
|
|
|
|
// RoleForStorage classifies a storage TARGET (from the agent's storage view) into its protection
|
|
// tier. typ is the reported storage type; backingDevice is its resolved block device ("" for
|
|
// network/lvm/dir-on-root). sysDisks/sysKnown come from SystemDisks (resolved once per request).
|
|
func RoleForStorage(typ, backingDevice string, sysDisks map[string]bool, sysKnown bool) DeviceRole {
|
|
switch typ {
|
|
case hub.StorageTypePBS:
|
|
return RoleBackup // the backup safety-net — protected
|
|
case hub.StorageTypeUSB, hub.StorageTypeLocalDir:
|
|
// A removable/extra dir storage is user-data ONLY when it has its OWN block device that is
|
|
// NOT part of the system disk. No device (a dir on the root fs) or a system-disk-backed dir
|
|
// → system. This is exactly "local-dir/usb on a non-root external device → user-data".
|
|
if backingDevice == "" {
|
|
return RoleSystem
|
|
}
|
|
if isSystemBacked(backingDevice, sysDisks, sysKnown) {
|
|
return RoleSystem
|
|
}
|
|
return RoleUserData
|
|
default:
|
|
// local (builtin, on the root fs), lvmthin (local-lvm), lvm (thick), nfs, cifs, and anything
|
|
// unrecognized → protected. Network shares are NOT customer-managed external drives in this
|
|
// model and are not within the controller's blast radius, so protecting them is safe.
|
|
return RoleSystem
|
|
}
|
|
}
|
|
|
|
// RoleForRawDevice classifies a RAW block device (the format endpoint's target, which may not yet be
|
|
// a registered PVE storage — e.g. a fresh external disk in the init flow). It distinguishes system
|
|
// from user-data by system-disk membership. A raw /dev path is never a PBS datastore (those are
|
|
// network/API), so the backup tier is not reachable here — the storage-list view (RoleForStorage)
|
|
// tiers PBS. Defaults to system on ambiguity.
|
|
func RoleForRawDevice(device string, sysDisks map[string]bool, sysKnown bool) DeviceRole {
|
|
if isSystemBacked(device, sysDisks, sysKnown) {
|
|
return RoleSystem
|
|
}
|
|
return RoleUserData
|
|
}
|
|
|
|
// SameWholeDisk reports whether two device paths live on the same physical whole disk
|
|
// (e.g. /dev/sdb and /dev/sdb1). Storage targets carry BOTH granularities in practice (a dir
|
|
// storage's BackingDevice is the mounted PARTITION, a raw enrolled drive mounts the WHOLE disk),
|
|
// so containment checks must compare at whole-disk level — mirroring isSystemBacked. False when
|
|
// either side's whole-disk is unrecognizable (device-mapper/network) — callers fail safe.
|
|
func SameWholeDisk(a, b string) bool {
|
|
wa, oka := wholeDiskOf(a)
|
|
wb, okb := wholeDiskOf(b)
|
|
return oka && okb && wa == wb
|
|
}
|