v0.95.0: SMART coverage — union-path drives + LVM/dm root + device model
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.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||
)
|
||||
|
||||
// fixtureSysfs builds a /sys/block-shaped tree and points sysBlockRoot at it. `slaves` maps a dm
|
||||
// name to its slave entries; `dmNames` maps a dm name to its /dm/name content (for /dev/mapper/*).
|
||||
func fixtureSysfs(t *testing.T, slaves map[string][]string, dmNames map[string]string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for dm, sl := range slaves {
|
||||
for _, s := range sl {
|
||||
if err := os.MkdirAll(filepath.Join(root, dm, "slaves", s), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for dm, name := range dmNames {
|
||||
dir := filepath.Join(root, dm, "dm")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "name"), []byte(name+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
old := sysBlockRoot
|
||||
sysBlockRoot = root
|
||||
t.Cleanup(func() { sysBlockRoot = old })
|
||||
}
|
||||
|
||||
// Fix A dm/LVM resolution. Red-proof: remove the `len(disks) != 1` all-same-disk guard in
|
||||
// dmWholeDisk → the "mirror over two disks" case resolves to one of them instead of skipping.
|
||||
func TestDMWholeDisk(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
slaves map[string][]string
|
||||
dmNames map[string]string
|
||||
in string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"single SATA slave", map[string][]string{"dm-1": {"sda3"}}, nil, "/dev/dm-1", "/dev/sda", true},
|
||||
{"single NVMe slave", map[string][]string{"dm-0": {"nvme0n1p3"}}, nil, "/dev/dm-0", "/dev/nvme0n1", true},
|
||||
{"stacked dm → one disk", map[string][]string{"dm-2": {"dm-1"}, "dm-1": {"sda3"}}, nil, "/dev/dm-2", "/dev/sda", true},
|
||||
{"mapper name → dm-1", map[string][]string{"dm-1": {"sda3"}}, map[string]string{"dm-1": "pve-root"}, "/dev/mapper/pve-root", "/dev/sda", true},
|
||||
{"mirror over two disks → skip", map[string][]string{"dm-1": {"sda3", "sdb3"}}, nil, "/dev/dm-1", "", false},
|
||||
{"no slaves → skip", map[string][]string{"dm-1": {}}, nil, "/dev/dm-1", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
fixtureSysfs(t, c.slaves, c.dmNames)
|
||||
got, ok := dmWholeDisk(c.in)
|
||||
if ok != c.ok || got != c.want {
|
||||
t.Errorf("%s: dmWholeDisk(%q) = (%q,%v), want (%q,%v)", c.name, c.in, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// smartDeviceFor routes dm/mapper devices through the resolver, and whole-disk/partition through the
|
||||
// regex path unchanged.
|
||||
func TestSmartDeviceFor_DMBranch(t *testing.T) {
|
||||
fixtureSysfs(t, map[string][]string{"dm-1": {"sda3"}}, map[string]string{"dm-1": "pve-root"})
|
||||
if dev, ok := smartDeviceFor("/dev/mapper/pve-root"); !ok || dev != "/dev/sda" {
|
||||
t.Errorf("smartDeviceFor(/dev/mapper/pve-root) = (%q,%v), want (/dev/sda,true)", dev, ok)
|
||||
}
|
||||
// missing sysfs → skip, not a guess
|
||||
fixtureSysfs(t, map[string][]string{}, nil)
|
||||
if _, ok := smartDeviceFor("/dev/dm-9"); ok {
|
||||
t.Error("smartDeviceFor should skip an unresolvable dm device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainingMountDevice(t *testing.T) {
|
||||
mounts := []Mount{
|
||||
{Device: "/dev/mapper/pve-root", MountPoint: "/"},
|
||||
{Device: "/dev/sda2", MountPoint: "/boot/efi"},
|
||||
{Device: "/dev/sdb1", MountPoint: "/mnt/usb"},
|
||||
}
|
||||
// A dir inside root resolves to root's device (longest prefix wins over "/").
|
||||
if dev, ok := containingMountDevice(mounts, "/var/lib/vz"); !ok || dev != "/dev/mapper/pve-root" {
|
||||
t.Errorf("containing(/var/lib/vz) = (%q,%v), want /dev/mapper/pve-root", dev, ok)
|
||||
}
|
||||
// A path under a more-specific mount picks that mount, not root.
|
||||
if dev, ok := containingMountDevice(mounts, "/mnt/usb/data"); !ok || dev != "/dev/sdb1" {
|
||||
t.Errorf("containing(/mnt/usb/data) = (%q,%v), want /dev/sdb1", dev, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Model capture (v0.95.0) — smartctl's model_name flows into SmartSummary; absent → nil.
|
||||
func TestParseSMART_ModelName(t *testing.T) {
|
||||
withModel := parseSMART([]byte(`{"model_name":"TOSHIBA MQ04ABF100","smart_status":{"passed":true}}`))
|
||||
if withModel.ModelName == nil || *withModel.ModelName != "TOSHIBA MQ04ABF100" {
|
||||
t.Errorf("ModelName = %v, want TOSHIBA MQ04ABF100", withModel.ModelName)
|
||||
}
|
||||
if withModel.Health != hub.SmartPassed {
|
||||
t.Errorf("health = %q, want PASSED", withModel.Health)
|
||||
}
|
||||
noModel := parseSMART([]byte(`{"smart_status":{"passed":true}}`))
|
||||
if noModel.ModelName != nil {
|
||||
t.Errorf("absent model_name should be nil, got %v", noModel.ModelName)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user