ed97232598
Implements SPIKE-smart-coverage-2026-07-25 fixes B+A (additive; MinAgent unchanged). Fix B: storage.SmartReader.SMARTForBacking wired into the /disks union path (localapi Smart seam) so registry/USB drives get a real SMART read (watchdog Known stays enrich-free). Fix A: smartDeviceFor resolves dm/LVM to the whole disk via /sys/block/<dm>/slaves (recursive; skips >1-disk); the builtin local dir on the LVM root gets a SMART-only device from its containing filesystem (never touches backing/durable_id). SmartSummary.ModelName captured from smartctl. Fix C (-d sat) stays rejected. Tests + red-proofs (dm multi-disk skip, enrich smartHint, union routing); Known-path-never-SMARTs asserted.
155 lines
4.9 KiB
Go
155 lines
4.9 KiB
Go
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/<dm>/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/<name> 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
|
|
}
|