v0.94.0: serialize per-disk SMART into the /disks payload

Additive, backward-compatible (MinAgent floor unchanged). The SMART is already
computed on the request path (storage.Observe -> enrich); this copies the target's
Smart into localapi.DiskInfo (pointer, omitempty) only when Health != "", so an
unread/absent summary stays omitted and the controller renders "Nincs adat".
No new smartctl load, endpoint, or sudoers change.

Test TestDisks_SmartSerialized + red-proof (drop the copy -> fails).
This commit is contained in:
2026-07-24 21:11:13 +02:00
parent c230258542
commit 21fee69154
4 changed files with 101 additions and 0 deletions
+14
View File
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)
@@ -151,6 +152,12 @@ type DiskInfo struct {
// at GuestPath (a host mount-table check) — i.e. live + usable in the guest in the intermediary model.
// The controller's drive-absent gate + auto-restart key on this (and State).
BoundUnderParent bool `json:"bound_under_parent"`
// Smart is the already-computed per-disk SMART health summary (v0.94.0), serialized here so the
// controller can render a disk-health card + degradation alert WITHOUT any new smartctl load — the
// value is copied straight from the target's Observe-time enrichment. omitempty + a pointer so a
// device that exposes no SMART (USB bridge, unread) is ABSENT, not a misleading zero-value UNKNOWN;
// the controller feature-detects presence and renders "Nincs adat" when nil (never alarms).
Smart *hub.SmartSummary `json:"smart,omitempty"`
}
// handleDisks lists the host's drives + data-bearing flags (read-only/benign).
@@ -206,6 +213,13 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
di.WipeDurableID = wid
}
}
// v0.94.0: surface the already-computed SMART only when it was actually read (Health set).
// A zero-value summary (enrich skipped / no smartctl device) has Health "" → stays omitted, so
// the controller sees "absent" and renders "Nincs adat" rather than a false UNKNOWN.
if t.Smart.Health != "" {
sm := t.Smart
di.Smart = &sm
}
out = append(out, di)
}
// Impl-2a: union in registry+units drives that Observe() does NOT surface (a drive with no PVE
+64
View File
@@ -0,0 +1,64 @@
package localapi
import (
"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)
}
}