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:
@@ -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)
|
## 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,
|
> **SHIPPED 2026-07-22:** built + published (sha256 `a68b2ff73200622e…`), Day-0-manifest-vouched,
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
|
|
||||||
## Current
|
## 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…`),
|
- **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
|
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);
|
(`demo-felhom-8363b5` + `demo-hp-bb76ea`, the latter over G1 break-glass — still no key baked);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||||
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||||
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
"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.
|
// 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).
|
// The controller's drive-absent gate + auto-restart key on this (and State).
|
||||||
BoundUnderParent bool `json:"bound_under_parent"`
|
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).
|
// 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
|
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)
|
out = append(out, di)
|
||||||
}
|
}
|
||||||
// Impl-2a: union in registry+units drives that Observe() does NOT surface (a drive with no PVE
|
// Impl-2a: union in registry+units drives that Observe() does NOT surface (a drive with no PVE
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user