diff --git a/CHANGELOG.md b/CHANGELOG.md index bf177e8..03b8fd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +## v0.94.0 — serialize per-disk SMART into the /disks payload (2026-07-24) + +Additive, backward-compatible; **MinAgent floor unchanged** (the controller feature-detects by payload +presence). No new smartctl load, no new endpoint, no sudoers change — the SMART is already computed on +the request path (`storage.Observe` → `enrich` runs `smartctl -a -j` for dir-backed targets); this +release simply copies the target's already-populated `Smart` into `localapi.DiskInfo`. + +- `DiskInfo` gains `Smart *hub.SmartSummary \`json:"smart,omitempty"\``. In `handleDisks` the summary is + copied **only when `t.Smart.Health != ""`** — a zero-value summary (SMART never read: no smartctl + device, USB bridge, or a failed read) stays omitted, so the controller sees *absent* and renders + "Nincs adat" rather than a misleading UNKNOWN. The union-in drives (driveTargets.Known path, no + enrichment) carry no SMART and are omitted by the same guard. +- The controller (v0.169.0) consumes this to render a "Lemezek állapota" card + a 6-hourly + degradation notification. Old controllers ignore the extra field. +- Test `TestDisks_SmartSerialized` (payload includes SATA counters + temperature for a fixture target; + absent-SMART target omits the field); red-proof: drop the copy → the serialized-Smart assertion fails. + ## v0.93.0 — a recovery code can no longer contain a hyphenated word (2026-07-21) > **SHIPPED 2026-07-22:** built + published (sha256 `a68b2ff73200622e…`), Day-0-manifest-vouched, diff --git a/CONTEXT.md b/CONTEXT.md index 109c521..4e7e64e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -5,6 +5,12 @@ ## Current +- **2026-07-24 — v0.94.0 (additive): SMART serialized into /disks.** `localapi.DiskInfo` gains + `Smart *hub.SmartSummary` (omitempty), copied from the target's already-computed Observe-time + enrichment when `Health != ""` — no new smartctl load, no endpoint, no sudoers/MinAgent change. The + controller v0.169.0 renders a "Lemezek állapota" card + 6h degradation alert from it; old controllers + ignore it. **NOTE: at the remote-site vacation window the agent is DOWN (localapi binds .162 → fails), + so live /disks-from-real-agent validation is deferred — the field is unit-proven; publish only.** - **2026-07-22 — v0.93.0 is the FLEET AGENT.** Built, published (sha `a68b2ff73200622e…`), Day-0-manifest-vouched (MinAgent also 0.93.0, operator-ruled) and deployed to BOTH boxes (`demo-felhom-8363b5` + `demo-hp-bb76ea`, the latter over G1 break-glass — still no key baked); diff --git a/internal/localapi/disks.go b/internal/localapi/disks.go index e2043d1..86231a8 100644 --- a/internal/localapi/disks.go +++ b/internal/localapi/disks.go @@ -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 diff --git a/internal/localapi/disks_smart_test.go b/internal/localapi/disks_smart_test.go new file mode 100644 index 0000000..cf56cea --- /dev/null +++ b/internal/localapi/disks_smart_test.go @@ -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) + } +}