fix(disk-health): the alert that never sent — severity, a real Hiba level, and a memory that survives a restart
Three defects made the disk-health feature silent in exactly the case it
exists for. Evidence: felhom.eu documentation/audits/DIAG-smart-passed-trap-2026-08-14.md
1. SEVERITY (the one that changes whether anything arrives at all).
NotifyDiskHealthDegraded emitted severity "warn", which is NOT in the
hub's accepted set {info,warning,error,critical}. The hub coerced it to
"info" (hub/internal/api/handler.go) and severityNotifies dropped it
(hub/internal/notify/dispatcher.go), so every Figyelmeztetes-level disk
alert was filed as an informational notice and emailed to NOBODY, on the
customer and the operator leg alike. Now "warning". DiskAlertKind.Severity()
is exported so the contract is checkable from any package.
2. NO LEVEL ABOVE "worth an eye". smart_status.passed CANNOT fail on
unreadable sectors (attrs 187/197/198 all carry thresh 0 and a normalized
value floors at 1), so Hiba was unreachable for this whole fault class.
DiskVerdictFor now takes a DiskPrior and implements a 14-row top-down
ladder: sustained unreadable sectors, a count too large to be a blip (64),
unreadable+remapping together, overheating, NVMe critical flag or spent
endurance all reach Hiba. No fourth label — predicted failure is "Hiba".
3. IT SPOKE ONCE, AND FORGOT ON RESTART. The baseline was in-memory, so a box
that rebooted while a disk was failing never alerted again; and between 8
and 352 sectors nothing was emitted at all. State is now persisted
(disk-health-state.json, atomic tmp+rename), the decision compares against
the last ALERTED verdict (collapsing flaps to one alert while letting a
genuine escalation fire immediately), and a disk already at Hiba re-alerts
once it has BOTH doubled its count and waited out a 24h cooldown.
The card replays the same prior the check used (diskRecord.PriorSawUncorrectable)
so the chip and the email cannot disagree — the property the shared verdict
function exists to guarantee, now pinned rather than asserted.
Tests: 12 scenario groups A-L. Group L builds the Server through web.NewServer,
the same call main.go makes, over a real file.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
package agentapi
|
||||
|
||||
import "testing"
|
||||
|
||||
// The v0.215.0 severity ladder, verdict half. The event half (emission, damping, cooldown,
|
||||
// persistence) lives in internal/web — this file pins ONLY what the pure function decides.
|
||||
//
|
||||
// Every value used here is taken from the committed evidence:
|
||||
// felhom.eu/documentation/audits/fixtures/smart-ST3000VX010-failing-2026-08-14.json
|
||||
// (ST3000VX010-2E3166, S/N Z6A07P2G, /dev/sdg on DooPlex).
|
||||
|
||||
// realDrive is the failing drive AS CAPTURED on 2026-08-14: PASSED, 352 pending, 352 offline
|
||||
// uncorrectable, 0 reallocated, 40 °C. The whole point of the fixture is that Health is PASSED.
|
||||
func realDrive() *SmartSummary {
|
||||
return &SmartSummary{
|
||||
Health: SmartPassed,
|
||||
PendingSectors: ip(352),
|
||||
OfflineUncorrectable: ip(352),
|
||||
ReallocatedSectors: ip(0),
|
||||
TemperatureC: ip(40),
|
||||
}
|
||||
}
|
||||
|
||||
// Group A (verdict half) — Scenario A. The real drive on its SECOND observation reaches Hiba, and
|
||||
// the chip label is exactly "Hiba".
|
||||
//
|
||||
// Red-proof: delete truth-table row 6 (the `prior.SawUncorrectable` case) from DiskVerdictFor →
|
||||
// the drive still reaches Fail via row 8 (352 >= 64), so this test alone does NOT prove row 6.
|
||||
// TestLadder_SustainIsWhatFires below is the one that isolates it.
|
||||
func TestLadder_RealDrive_ReachesHiba(t *testing.T) {
|
||||
got := DiskVerdictFor(realDrive(), DiskPrior{SawUncorrectable: true})
|
||||
if got != DiskVerdictFail {
|
||||
t.Fatalf("real failing drive verdict = %d (%s), want Fail/Hiba", got, got.Label())
|
||||
}
|
||||
if got.Label() != "Hiba" {
|
||||
t.Errorf("label = %q, want %q", got.Label(), "Hiba")
|
||||
}
|
||||
// The trap this whole change exists for: the drive's own verdict says everything is fine.
|
||||
if realDrive().Health != SmartPassed {
|
||||
t.Fatal("fixture drift: the real drive's Health must be PASSED — that IS the defect")
|
||||
}
|
||||
}
|
||||
|
||||
// Groups B + C (verdict half) — Scenarios B and C. The SAME SmartSummary yields Figyelmeztetés on a
|
||||
// first sighting and Hiba once it is sustained. This is the pair that isolates row 6: the counters
|
||||
// are identical and only `prior` differs, so nothing else in the table can be producing the change.
|
||||
//
|
||||
// The values are the 11 August excursion (8 sectors), which cleared completely within an hour — a
|
||||
// count deliberately far below the 64 backstop so row 8 cannot mask row 6.
|
||||
//
|
||||
// Red-proof: remove the `prior.SawUncorrectable` clause from row 6 → the sustained case stays Warn.
|
||||
func TestLadder_SustainIsWhatFires(t *testing.T) {
|
||||
excursion := func() *SmartSummary {
|
||||
return &SmartSummary{Health: SmartPassed, PendingSectors: ip(8), OfflineUncorrectable: ip(8), ReallocatedSectors: ip(0)}
|
||||
}
|
||||
if got := DiskVerdictFor(excursion(), DiskPrior{}); got != DiskVerdictWarn {
|
||||
t.Errorf("first sighting of 8 sectors = %d (%s), want Warn/Figyelmeztetés — a single "+
|
||||
"excursion that clears by itself is normal and must NOT reach Hiba", got, got.Label())
|
||||
}
|
||||
if got := DiskVerdictFor(excursion(), DiskPrior{SawUncorrectable: true}); got != DiskVerdictFail {
|
||||
t.Errorf("SAME 8 sectors, now sustained = %d (%s), want Fail/Hiba", got, got.Label())
|
||||
}
|
||||
if got := DiskVerdictFor(excursion(), DiskPrior{}).Label(); got != "Figyelmeztetés" {
|
||||
t.Errorf("first-sighting label = %q, want Figyelmeztetés", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Row 8, the backstop — for a box that was powered off or restarted across the sustain window and so
|
||||
// has NO prior. 63 stays Warn, 64 reaches Hiba. The boundary is inclusive, which is what
|
||||
// `uncorrectableFailCount` claims and what the real drive did at 13 Aug 11:28 (exactly 64).
|
||||
//
|
||||
// Red-proof: change `>=` to `>` in row 8 → the "exactly 64" case reads Warn.
|
||||
func TestLadder_CountBackstopBoundary(t *testing.T) {
|
||||
cases := []struct {
|
||||
pending int
|
||||
want DiskVerdict
|
||||
}{
|
||||
{63, DiskVerdictWarn},
|
||||
{64, DiskVerdictFail},
|
||||
{352, DiskVerdictFail},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s := &SmartSummary{Health: SmartPassed, PendingSectors: ip(c.pending)}
|
||||
if got := DiskVerdictFor(s, DiskPrior{}); got != c.want {
|
||||
t.Errorf("%d pending sectors, no prior = %d (%s), want %d", c.pending, got, got.Label(), c.want)
|
||||
}
|
||||
}
|
||||
// Row 7 — unreadable AND remapping together is Hiba even at a low count with no prior.
|
||||
s := &SmartSummary{Health: SmartPassed, PendingSectors: ip(8), ReallocatedSectors: ip(1)}
|
||||
if got := DiskVerdictFor(s, DiskPrior{}); got != DiskVerdictFail {
|
||||
t.Errorf("row 7 (unreadable + reallocated) = %d, want Fail", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Group I — Scenario I, heat. 61 → Hiba, 56 → Figyelmeztetés, 54 → Rendben, with all counters clean.
|
||||
//
|
||||
// Red-proof: remove rows 3 and 13 → all three read Rendben.
|
||||
func TestLadder_Temperature(t *testing.T) {
|
||||
cases := []struct {
|
||||
temp int
|
||||
want DiskVerdict
|
||||
}{
|
||||
{54, DiskVerdictOK},
|
||||
{55, DiskVerdictWarn}, // inclusive boundary
|
||||
{56, DiskVerdictWarn},
|
||||
{59, DiskVerdictWarn},
|
||||
{60, DiskVerdictFail}, // inclusive boundary
|
||||
{61, DiskVerdictFail},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s := &SmartSummary{Health: SmartPassed, TemperatureC: ip(c.temp), PendingSectors: ip(0), ReallocatedSectors: ip(0)}
|
||||
if got := DiskVerdictFor(s, DiskPrior{}); got != c.want {
|
||||
t.Errorf("%d °C = %d (%s), want %d", c.temp, got, got.Label(), c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group J (verdict half) — Scenario J. No data never alarms, and a prior must not manufacture one:
|
||||
// a nil/UNKNOWN SmartSummary reads Nincs adat EVEN WITH SawUncorrectable set. Row 1 is first in the
|
||||
// table for exactly this reason.
|
||||
//
|
||||
// Red-proof: move row 1 below row 6 → the UNKNOWN-with-prior case reads Hiba, i.e. a disk whose
|
||||
// SMART briefly became unreadable would be reported as failing.
|
||||
func TestLadder_UnknownNeverAlarms(t *testing.T) {
|
||||
for _, s := range []*SmartSummary{nil, {Health: ""}, {Health: SmartUnknown}} {
|
||||
if got := DiskVerdictFor(s, DiskPrior{SawUncorrectable: true}); got != DiskVerdictUnknown {
|
||||
t.Errorf("no-data disk with a prior = %d (%s), want Unknown/Nincs adat", got, got.Label())
|
||||
}
|
||||
}
|
||||
if got := DiskVerdictFor(&SmartSummary{Health: SmartUnknown}, DiskPrior{}).Label(); got != "Nincs adat" {
|
||||
t.Errorf("label = %q, want Nincs adat", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The zero DiskPrior must be the SAFE default: a caller that forgets to load history can only
|
||||
// under-report (Figyelmeztetés), never over-report (Hiba) on a first sighting. This pins the
|
||||
// fail-safe direction the persisted-state loader relies on when its file is missing or corrupt.
|
||||
func TestLadder_ZeroPriorIsFailSafe(t *testing.T) {
|
||||
s := &SmartSummary{Health: SmartPassed, PendingSectors: ip(8)}
|
||||
if got := DiskVerdictFor(s, DiskPrior{}); got != DiskVerdictWarn {
|
||||
t.Fatalf("zero prior must degrade to Warn, not Fail; got %d (%s)", got, got.Label())
|
||||
}
|
||||
}
|
||||
|
||||
// UncorrectableSectors is max(pending, offline) — the number the alert copy quotes and the persisted
|
||||
// state remembers. A wrong answer here puts a wrong count in a customer's email.
|
||||
func TestUncorrectableSectors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *SmartSummary
|
||||
want int
|
||||
}{
|
||||
{"nil summary", nil, 0},
|
||||
{"neither reported (old agent)", &SmartSummary{Health: SmartPassed}, 0},
|
||||
{"both zero", &SmartSummary{PendingSectors: ip(0), OfflineUncorrectable: ip(0)}, 0},
|
||||
{"pending only", &SmartSummary{PendingSectors: ip(8)}, 8},
|
||||
{"offline only", &SmartSummary{OfflineUncorrectable: ip(24)}, 24},
|
||||
{"pending larger", &SmartSummary{PendingSectors: ip(40), OfflineUncorrectable: ip(24)}, 40},
|
||||
{"offline larger", &SmartSummary{PendingSectors: ip(24), OfflineUncorrectable: ip(40)}, 40},
|
||||
{"the real drive", realDrive(), 352},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := UncorrectableSectors(c.in); got != c.want {
|
||||
t.Errorf("%s: UncorrectableSectors = %d, want %d", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DegradedAttributes must NAME the counters behind a Hiba reached from counters (v0.215.0) — the
|
||||
// alert body is built from this and an empty list produces a message that says nothing is wrong.
|
||||
// It still returns nil for row 2 (drive-reported FAILING), which has no single triggering counter.
|
||||
//
|
||||
// Red-proof: restore the pre-v0.215.0 body (nil for anything at Fail) → the real-drive case returns
|
||||
// an empty list.
|
||||
func TestDegradedAttributes_NamesFailCounters(t *testing.T) {
|
||||
got := DegradedAttributes(realDrive())
|
||||
if len(got) == 0 {
|
||||
t.Fatal("a Hiba reached from counters must name its attributes, got none")
|
||||
}
|
||||
found := map[string]bool{}
|
||||
for _, a := range got {
|
||||
found[a] = true
|
||||
}
|
||||
for _, want := range []string{"függőben lévő szektorok", "javíthatatlan szektorok"} {
|
||||
if !found[want] {
|
||||
t.Errorf("missing attribute %q in %v", want, got)
|
||||
}
|
||||
}
|
||||
// Row 2 — the drive self-reports FAILING: no single triggering counter, so nil.
|
||||
if a := DegradedAttributes(&SmartSummary{Health: SmartFailing, PendingSectors: ip(5)}); a != nil {
|
||||
t.Errorf("FAILING (row 2) must return nil attributes, got %v", a)
|
||||
}
|
||||
// Nincs adat must never produce attribute names either.
|
||||
if a := DegradedAttributes(&SmartSummary{Health: SmartUnknown}); a != nil {
|
||||
t.Errorf("UNKNOWN must return nil attributes, got %v", a)
|
||||
}
|
||||
// Temperature is newly able to trigger on its own, so it must be nameable.
|
||||
hot := DegradedAttributes(&SmartSummary{Health: SmartPassed, TemperatureC: ip(61)})
|
||||
if len(hot) != 1 || hot[0] != "hőmérséklet" {
|
||||
t.Errorf("hot disk attributes = %v, want [hőmérséklet]", hot)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user