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.
140 lines
4.8 KiB
Go
140 lines
4.8 KiB
Go
package localapi
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
|
)
|
|
|
|
func smartIP(v int) *int { return &v }
|
|
|
|
// TestDisks_SmartSerialized (v0.94.0) — the already-computed SMART summary is copied into the /disks
|
|
// payload for a target that has it (Health set), including the SATA counters + temperature; a target
|
|
// whose SMART was never read (zero-value summary, Health "") omits the field entirely.
|
|
//
|
|
// Red-proof: drop the `di.Smart = &sm` copy in handleDisks → the "data" disk's Smart is nil → this fails.
|
|
func TestDisks_SmartSerialized(t *testing.T) {
|
|
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
|
|
sv := fakeStorage{targets: []hub.StorageTarget{
|
|
{
|
|
Name: "data", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/data",
|
|
Smart: hub.SmartSummary{
|
|
Health: hub.SmartPassed,
|
|
TemperatureC: smartIP(34),
|
|
ReallocatedSectors: smartIP(3),
|
|
PendingSectors: smartIP(0),
|
|
},
|
|
},
|
|
// Zero-value SMART (never read) — Health "" → must be omitted from the payload.
|
|
{Name: "nosmart", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdc1", MountPath: "/mnt/nosmart"},
|
|
}}
|
|
h := newDiskServer(t, d, &fakeGate{}, sv, nil)
|
|
|
|
w := do(t, h, "GET", "/disks", "A", "")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GET /disks: %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
byName := map[string]DiskInfo{}
|
|
for _, di := range decodeDisks(t, w.Body.Bytes()) {
|
|
byName[di.Name] = di
|
|
}
|
|
|
|
ds := byName["data"].Smart
|
|
if ds == nil {
|
|
t.Fatal("data disk: smart summary was not serialized")
|
|
}
|
|
if ds.Health != hub.SmartPassed {
|
|
t.Errorf("data smart Health = %q, want PASSED", ds.Health)
|
|
}
|
|
if ds.ReallocatedSectors == nil || *ds.ReallocatedSectors != 3 {
|
|
t.Errorf("data ReallocatedSectors = %v, want 3", ds.ReallocatedSectors)
|
|
}
|
|
if ds.PendingSectors == nil || *ds.PendingSectors != 0 {
|
|
t.Errorf("data PendingSectors = %v, want 0 (a real zero, not null)", ds.PendingSectors)
|
|
}
|
|
if ds.TemperatureC == nil || *ds.TemperatureC != 34 {
|
|
t.Errorf("data TemperatureC = %v, want 34", ds.TemperatureC)
|
|
}
|
|
|
|
if byName["nosmart"].Smart != nil {
|
|
t.Errorf("nosmart disk: smart must be omitted when Health is empty, got %+v", byName["nosmart"].Smart)
|
|
}
|
|
}
|
|
|
|
// ---- Fix B (v0.95.0): the /disks union path reads SMART for registry/USB drives ----
|
|
|
|
type fakeKnownTargets struct{ drives []storage.KnownTarget }
|
|
|
|
func (f fakeKnownTargets) Known(context.Context) ([]storage.KnownTarget, error) { return f.drives, nil }
|
|
|
|
type fakeSmartReader struct {
|
|
byDev map[string]hub.SmartSummary
|
|
calls []string
|
|
}
|
|
|
|
func (f *fakeSmartReader) SMARTForBacking(_ context.Context, dev string) hub.SmartSummary {
|
|
f.calls = append(f.calls, dev)
|
|
if s, ok := f.byDev[dev]; ok {
|
|
return s
|
|
}
|
|
return hub.SmartSummary{}
|
|
}
|
|
|
|
func sp(s string) *string { return &s }
|
|
|
|
// A union-path (registry/USB) drive now gets a real SMART read + model, via the Smart seam — it used
|
|
// to ride the enrich-free union path and show "Nincs adat".
|
|
// Red-proof: delete the Fix-B block in handleDisks (the s.smart.SMARTForBacking call) → the union
|
|
// drive carries no smart and this fails.
|
|
func TestDisks_UnionPathReadsSMART(t *testing.T) {
|
|
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}}
|
|
sm := &fakeSmartReader{byDev: map[string]hub.SmartSummary{
|
|
"/dev/sdb1": {Health: hub.SmartPassed, ModelName: sp("TOSHIBA MQ04ABF100")},
|
|
}}
|
|
srv, err := NewServer(Options{
|
|
ListenAddr: "127.0.0.1:0",
|
|
Guests: &fakeGuests{},
|
|
Backups: &fakeBackups{},
|
|
Store: &fakeStore{},
|
|
Storage: fakeStorage{}, // no Observe targets → the union drive is not deduped away
|
|
DriveTargets: fakeKnownTargets{drives: []storage.KnownTarget{
|
|
{Name: "data-usb", Type: hub.StorageTypeUSB, MountPath: "/mnt/hdd_1", DurableID: "uuid:47a3", UUID: "47a3"},
|
|
}},
|
|
Smart: sm,
|
|
Tokens: staticTokens{"A": 8200},
|
|
Disks: d,
|
|
HostReader: sysOnSDA(),
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
srv.baseCtx = context.Background()
|
|
srv.resolveStorageDevice = func(string) (string, error) { return "/dev/sdb1", nil }
|
|
|
|
disks := decodeDisks(t, do(t, srv.Handler(), "GET", "/disks", "A", "").Body.Bytes())
|
|
var usb *DiskInfo
|
|
for i := range disks {
|
|
if disks[i].Name == "data-usb" {
|
|
usb = &disks[i]
|
|
}
|
|
}
|
|
if usb == nil {
|
|
t.Fatalf("union drive not in /disks: %+v", disks)
|
|
}
|
|
if usb.Smart == nil || usb.Smart.Health != hub.SmartPassed {
|
|
t.Fatalf("union drive SMART not read: %+v", usb.Smart)
|
|
}
|
|
if usb.Smart.ModelName == nil || *usb.Smart.ModelName != "TOSHIBA MQ04ABF100" {
|
|
t.Errorf("union drive model not carried: %v", usb.Smart)
|
|
}
|
|
if len(sm.calls) != 1 || sm.calls[0] != "/dev/sdb1" {
|
|
t.Errorf("SMART should be read once on /dev/sdb1, got %v", sm.calls)
|
|
}
|
|
}
|