package storage import ( "context" "os" "path/filepath" "regexp" "strings" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" ) // sysBlockRoot is the sysfs block directory. A package var so tests can point it at a fixture tree. var sysBlockRoot = "/sys/block" // dmWholeDisk resolves a device-mapper / LVM device to its SINGLE backing whole disk, recursing // through stacked dm layers via /sys/block//slaves (Fix A, SPIKE-smart-coverage-2026-07-25). // Returns ok=false when the device is not dm, sysfs is missing, there are no slaves, or the slaves // span MORE THAN ONE physical disk — in that last case we deliberately skip rather than guess which // of two disks to SMART (e.g. a mirrored LV). func dmWholeDisk(device string) (string, bool) { name := dmName(device) if name == "" { return "", false } disks := map[string]bool{} if !collectSlaveDisks(name, disks, 0) { return "", false } if len(disks) != 1 { return "", false // no disk, or an ambiguous multi-disk dm — never guess } for d := range disks { return "/dev/" + d, true } return "", false } // dmName maps a dm device path to its sysfs name (dm-N). Handles /dev/dm-N (and a bare dm-N) // directly, and /dev/mapper/ by matching /sys/block/dm-*/dm/name. func dmName(device string) string { base := filepath.Base(device) if strings.HasPrefix(base, "dm-") { return base } if strings.HasPrefix(device, "/dev/mapper/") { entries, err := os.ReadDir(sysBlockRoot) if err != nil { return "" } for _, e := range entries { if !strings.HasPrefix(e.Name(), "dm-") { continue } b, err := os.ReadFile(filepath.Join(sysBlockRoot, e.Name(), "dm", "name")) if err == nil && strings.TrimSpace(string(b)) == base { return e.Name() } } } return "" } // collectSlaveDisks fills `disks` with the whole-disk names backing dm `name`, recursing through // nested dm. Returns false on missing sysfs, no slaves, or excessive nesting (loop guard). func collectSlaveDisks(name string, disks map[string]bool, depth int) bool { if depth > 8 { return false } entries, err := os.ReadDir(filepath.Join(sysBlockRoot, name, "slaves")) if err != nil { return false } if len(entries) == 0 { return false } for _, e := range entries { s := e.Name() if strings.HasPrefix(s, "dm-") { if !collectSlaveDisks(s, disks, depth+1) { return false } continue } disks[wholeDiskName(s)] = true } return true } // wholeDiskName strips a partition suffix to the whole-disk name (sda3→sda, nvme0n1p3→nvme0n1). func wholeDiskName(part string) string { if m := reNVMePartName.FindStringSubmatch(part); m != nil { return m[1] } if m := reSDPartName.FindStringSubmatch(part); m != nil { return m[1] } return part } var ( reNVMePartName = regexp.MustCompile(`^(nvme[0-9]+n[0-9]+)p[0-9]+$`) reSDPartName = regexp.MustCompile(`^((?:sd|hd|vd)[a-z]+)[0-9]+$`) ) // containingMountDevice returns the device of the mount whose mountpoint is the LONGEST prefix of // path — the filesystem that actually holds `path`. Used ONLY to pick a whole-disk device for a // SMART read of a dir-storage that lives inside a shared filesystem (the builtin `local` on the LVM // root); it never feeds durable_id / backing_device (which stay empty for such targets by design — // the removable-safety guard in build()). func containingMountDevice(mounts []Mount, path string) (string, bool) { clean := cleanMountPath(path) best, bestLen := "", -1 for _, m := range mounts { if m.Device == "" { continue } mp := cleanMountPath(m.MountPoint) if mp == clean || mp == "/" || strings.HasPrefix(clean, strings.TrimRight(mp, "/")+"/") { if len(mp) > bestLen { best, bestLen = m.Device, len(mp) } } } return best, best != "" } // SmartReader reads per-disk SMART for a backing device, resolving partition / dm / LVM down to the // whole disk. It exists so the localapi /disks UNION path (registry/USB drives that skip Observe's // enrich) gets the SAME SMART read the dir targets get, without duplicating smartDeviceFor (Fix B). // A zero-value summary (Health "") means "could not read" (nil ops, unresolvable device, or a read // error) — distinct from a read that returned UNKNOWN — so the caller can omit it exactly like the // dir path does. type SmartReader struct{ ops HostOps } // NewSmartReader wraps a HostOps for the localapi union path. func NewSmartReader(ops HostOps) *SmartReader { return &SmartReader{ops: ops} } // SMARTForBacking reads SMART for backingDevice (partition/dm/whole-disk). Never returns an error; // on any failure the summary's Health is "". func (r *SmartReader) SMARTForBacking(ctx context.Context, backingDevice string) hub.SmartSummary { if r == nil || r.ops == nil { return hub.SmartSummary{} } dev, ok := smartDeviceFor(backingDevice) if !ok { return hub.SmartSummary{} } sm, err := r.ops.SMART(ctx, dev) if err != nil { return hub.SmartSummary{} } return sm }