Files
felhom-controller/REPORT.md
T
admin 2fa1efc5e5
gates / gates (push) Successful in 9s
docs(REPORT): confirming cycle on v0.216.0, and persistence proven live
- 09:31:35Z on 0.216.0: '2 disk(s) evaluated, 0 alert(s)' — the count now
  matches the 2 persisted records, closing the disagreement that exposed R-335.
- The 0.215.0 -> 0.216.0 redeploy replaced the container and the state file
  came back with a changed_at written by the PREVIOUS version, so the new
  container loaded the pre-restart record instead of re-baselining. Scenario L
  observed on real hardware, not just through the production-path unit test.
- R-332 narrowed accordingly: what remains unproven is an already-ALERTED disk
  not re-alerting after a restart.
2026-08-14 11:33:11 +02:00

25 KiB
Raw Blame History

REPORT — controller v0.215.0 → v0.216.0: disk-health severity ladder, escalation, and the alert that never sent

Date: 2026-08-14 · Task class: Implementation · Repos touched: felhom-controller (code), felhom.eu (documentation only — no hub code, no manifest bump, no ArgoCD sync)


1. Confirmed baselines used (as read at the start of the run)

Repo main @ start Version → Shipped
felhom-controller 3e3ee94b7bbe6b66663c468e22aa86616365a45a v0.214.0 v0.215.0, then v0.216.0 (a defect found live in v0.215.0 — §14)
felhom.eu e0b56c976f8e4a7352309d78754fec448dd55f99 n/a (docs only) n/a

Both trees verified clean (git status --porcelain empty, HEAD == origin/main) before any build. The controller hash matched the spec's stated baseline exactly. MinAgent stays 0.129.0 — no agent change; every field read here has been on the wire since agent v0.94.0/v0.95.0.


2. Files created / modified

felhom-controller

  • controller/internal/agentapi/diskverdict.go — modified (14-row ladder, DiskPrior, UncorrectableSectors, TemperatureFailC)
  • controller/internal/agentapi/diskverdict_test.go — modified
  • controller/internal/agentapi/diskverdict_ladder_test.gocreated
  • controller/internal/notify/notifier.go — modified (severity, DiskAlert, DiskAlertKind, Severity(), 5 message shapes)
  • controller/internal/notify/disk_health_test.go — rewritten
  • controller/internal/web/disk_health_state.gocreated (persistence + decision)
  • controller/internal/web/disk_health.go — modified
  • controller/internal/web/disk_health_test.go — rewritten
  • controller/internal/web/server.go — modified (seam signature)
  • controller/cmd/controller/main.go — modified (6h → 1h)
  • (v0.216.0) controller/internal/web/disk_health.go + disk_health_test.go — the R-335 dedup fix and its test
  • CHANGELOG.md, CONTEXT.md, REUSE.md, controller/README.md, REPORT.md

felhom.eu (documentation only)

  • documentation/audits/DIAG-smart-passed-trap-2026-08-14.mdcreated
  • documentation/audits/fixtures/smart-ST3000VX010-failing-2026-08-14.jsoncreated (raw smartctl -a -j, verbatim)
  • documentation/audits/fixtures/smartd-history-sdg-2026-08-14.txtcreated (406 smartd journal lines)
  • documentation/architecture/00-capability-map.md, documentation/backlog/ROADMAP.md, documentation/backlog/OPEN-ITEMS.md — modified

3. Commits pushed to main

Repo Hash What
felhom.eu 848de81 Part 0 — fixtures + findings doc
felhom-controller bb50e12 Parts 13 — ladder, severity, persisted state + tests
felhom-controller c24f192 Group L strengthened to two post-restart checks
felhom-controller 34d83f5 Part 4 — cadence 6h → 1h (measured)
felhom-controller 8144a70 Part 5 — CHANGELOG / CONTEXT / README / REUSE
felhom.eu 767960b Part 5 — capability map, ROADMAP, register rows R-328…R-334
felhom-controller 90f2545 v0.216.0 — R-335, one physical disk evaluated once per run
felhom.eu fa4748d R-335 register row

4. Per-test results — all twelve groups

Group Scenario Test Result
A real drive, 2nd observation TestDiskCheck_RealDrive_HibaAndOneCriticalEvent + TestLadder_RealDrive_ReachesHiba PASS
B the transient that cleared TestDiskCheck_FirstSightingIsWarnOnly PASS
C sustained → Hiba TestDiskLadder_SustainDrivesTheEscalation + TestLadder_SustainIsWhatFires PASS
D recovered, silent TestDiskCheck_RecoveryIsSilentAndClearsState PASS
E flap damping TestDiskCheck_FlapDamping PASS
F escalation beats damping TestDiskCheck_EscalationBeatsDamping PASS
G still getting worse TestDiskCheck_RealertWhenStillWorsening PASS
H below both bars TestDiskCheck_NoRealertBelowBothBars PASS
I heat TestLadder_Temperature + TestDiskCheck_TemperatureShape PASS
J no data never alarms TestDiskCheck_UnknownNeverAlarmsNorErasesPrior + TestLadder_UnknownNeverAlarms PASS
K severity routes TestNotifyDiskHealthDegraded_SeverityRoutes PASS
L state survives restart (seam) TestDiskCheck_StateSurvivesRestart_ProductionPath PASS

Supporting: TestLadder_CountBackstopBoundary, TestLadder_ZeroPriorIsFailSafe, TestUncorrectableSectors, TestDegradedAttributes_NamesFailCounters, TestNotifyDiskHealthDegraded_{WarnShape,FailShapes,CopyDiscipline}, TestDiskAlertDecision_Table, TestDiskState_CorruptFileFallsBackToNoPrior, TestDiskCheck_DisappearedDiskIsForgotten, TestDiskCheck_UnreachableAgentIsInert — all PASS.


5. Red-proof outcomes — all twelve, individually

Each mutation was applied by script, asserted present in the source before the run (the harness aborts with MUTATION-NOT-APPLIED if the target text is absent), the named test run, and the file reverted with git checkout --. The tree was confirmed clean after the sweep.

# Mutation applied Target test Outcome
A remove truth-table row 6 (the sustain rule) TestDiskCheck_RealDrive_HibaAndOneCriticalEvent RED-PROOF PASSED — FINDING, see below
B make row 9 return Fail TestDiskCheck_FirstSightingIsWarnOnly failed as required
C pass a zero DiskPrior in RunDiskHealthCheck TestDiskLadder_SustainDrivesTheEscalation failed as required
D let Rendben fall through the silence guard TestDiskCheck_RecoveryIsSilentAndClearsState failed as required
E compare against last observed verdict, not last alerted TestDiskCheck_FlapDamping failed as required
F let damping cover escalations TestDiskCheck_EscalationBeatsDamping failed as required
G remove the re-alert branch TestDiskCheck_RealertWhenStillWorsening failed as required
H make cooldown/doubling an OR instead of an AND TestDiskCheck_NoRealertBelowBothBars failed as required
I remove truth-table rows 3 and 13 TestLadder_Temperature, TestDiskCheck_TemperatureShape failed as required
J let UNKNOWN delete the prior record TestDiskCheck_UnknownNeverAlarmsNorErasesPrior failed as required
K restore severity := "warn" TestNotifyDiskHealthDegraded_SeverityRoutes failed as required
L skip loading the persisted state TestDiskCheck_StateSurvivesRestart_ProductionPath failed as required

A thirteenth red-proof, added after the deploy (R-335)

# Mutation applied Target test Outcome
M delete the if seen[key] { continue } dedup guard TestDiskCheck_SameDiskTwiceIsEvaluatedOnce failed as required

Observed failure: first sighting of an aliased disk must be silent, got 1: [{Label:felhom-backup … Kind:2 Sectors:8}] — i.e. Kind:2 is DiskAlertFailSectors, a Hiba on a first sighting of 8 sectors. Reverted.

FINDING — red-proof A passed, and it is the spec's mutation that is at fault, not the code

The task specified group A's red-proof as "remove truth-table row 6 → verdict is Warn". That mutation cannot fail a test built on the real drive's values: the real drive carries 352 unreadable sectors, so with row 6 deleted it still reaches Hiba via row 8 (count ≥ 64). The test correctly stayed green, so the mutation proves nothing about row 6.

This was anticipated while writing the tests and is documented in the test's own comment rather than discovered afterwards. Row 6 is genuinely pinned, by two tests that hold the counters at 8 (far below the 64 backstop) and vary only the prior:

  • TestLadder_SustainIsWhatFires (agentapi) — same SmartSummary, DiskPrior{} → Warn, DiskPrior{SawUncorrectable:true} → Fail.
  • TestDiskLadder_SustainDrivesTheEscalation (web) — the event-level twin.

Both were run under the row-6-deleted mutation and both failed, as recorded: SAME 8 sectors, now sustained = 2 (Figyelmeztetés), want Fail/Hiba and severity = "warning", want critical / chip = "Figyelmeztetés", want Hiba. So the invariant is covered; only the spec's chosen mutation was invalid.

A second finding, from building red-proof L

The first version of the Group L seam test ran one check after the restart and passed under the mutation — because a controller that has forgotten its state is also silent on its first check. The test was strengthened to run two checks (commit c24f192), after which the mutation fails. This is the exact shape §10 warns about, caught by running the red-proof rather than assuming it.


6. Test count and suite state

  • Before: 1391 test functions (at 3e3ee94) · After: 1414 (+23)
  • go build ./... && go vet ./... && go test ./...all green, no failures, no skips introduced.
  • python3 controller/scripts/controller_gates.pyall 11 gates OK.

7. Cadence measurement (Part 4)

Measured on demo-hp (Tier 0, disposable), through fetchDisks' real path — the agent local API GET /disks, not the 60 s card cache. Ten consecutive calls, all HTTP 200:

0.840558  0.817000  0.815783  0.831992  0.809066
0.832899  0.824788  0.804886  0.812539  0.833547   (seconds)
min median max disk count
0.804886 s 0.820894 s 0.840558 s 3 physical rows across 2 devices (SanDisk X600 M.2 SATA SSD; Toshiba KXG50PNV1T02 NVMe, counted twice as c11-scratch + felhom-backup)

Branch taken: median < 5 s → 6*time.Hour1*time.Hour. The median is ~6× under the bar. The detection argument is the real one: the observed benign excursion lasted about one hour, so a 6-hourly sampler can land either side of it and then catch the terminal run half a day late.

No spin-up signature appeared in the timings (uniform ~0.82 s; demo-hp is all-flash), so the measurement did not suggest the spun-down-drive concern. That question is recorded as an Observation below and deliberately not acted on.


8. Live validation

Deployed to demo-hp guest 9201 via the bootstrap path (docker pull/etc/felhom-controller-imagesystemctl restart felhom-controller-bootstrap.service).

gitea.dooplex.hu/admin/felhom-controller:0.215.0 Up 19 seconds (healthy)   # 06:23Z
gitea.dooplex.hu/admin/felhom-controller:0.216.0 Up 6 seconds (healthy)    # after the R-335 fix

Leg 1 — no over-correction (the load-bearing check)

Method: endpoint-level — authenticated GET /dashboard on the real controller (https://felhom.enkisfelhom.hu/dashboard, HTTP 200, 44 036 bytes), i.e. the exact endpoint the UI invokes; only rendering is skipped. No browser is available on DooPlex.

Card contents, parsed from the response body:

Disk Chip Class Temp
KXG50PNV1T02 NVMe TOSHIBA 1024GB Rendben state-text-run 53 °C
KXG50PNV1T02 NVMe TOSHIBA 1024GB Rendben state-text-run 53 °C
SanDisk X600 M.2 2280 SATA 128GB Rendben state-text-run 44 °C

Figyelmeztetés = 0, Hiba = 0, Nincs adat = 0, state-text-warn = 0, state-text-crit = 0. No healthy disk was over-corrected.

Positive observable, at deploy: [INFO] [scheduler] Registered periodic job: disk-health-check (every 1h0m0s) — the new cadence is in force, not merely compiled.

Positive observable, per cycle — two full hourly cycles observed after the deploy, from the container log:

2026/08/14 06:23:13 [INFO] [scheduler] Registered periodic job: disk-health-check (every 1h0m0s)
2026/08/14 07:23:13 [INFO] [scheduler] Running job: disk-health-check
2026/08/14 07:23:14 [INFO] [web] disk-health check complete: 3 disk(s) evaluated, 0 alert(s)
2026/08/14 07:23:14 [INFO] [scheduler] Job disk-health-check completed (took 849ms)
2026/08/14 08:23:13 [INFO] [scheduler] Running job: disk-health-check
2026/08/14 08:23:14 [INFO] [web] disk-health check complete: 3 disk(s) evaluated, 0 alert(s)
2026/08/14 08:23:14 [INFO] [scheduler] Job disk-health-check completed (took 843ms)

grep -c disk_health_degraded over the whole container log: 0. Both cycles ran (849 ms / 843 ms, matching the §7 measurement), evaluated every disk, and emitted nothing. Zero alerts from a check that demonstrably ran — not silence.

Persisted state written by the first cycle (/opt/docker/felhom-controller/data/disk-health-state.json, 428 bytes, on the felhom-controller-data docker volume, so it survives container recreation):

{"version": 1, "disks": {
  "path:/var/lib/vz":                          {"verdict": 1, "saw_uncorrectable": false, ...},
  "uuid:91d2dc2d-2d28-4929-9bdd-3e11fa2f41ae": {"verdict": 1, "saw_uncorrectable": false, ...}}}

verdict: 1 is DiskVerdictOK for both, saw_uncorrectable: false, never alerted.

Reading those two artefacts against each other is what exposed R-335 — see §14.

Leg 2 — the severity fix arrives (the point of the task)

Two synthetic disk_health_degraded events pushed for customer demo-hp through the real hub event endpoint (POST https://hub.felhom.eu/api/v1/event), from the guest's own controller using its own hub credentials — the genuine controller→hub path, not a hand-crafted operator call. Both returned HTTP 200 {"ok":true}. The hub DB was read with its -wal and -shm copied alongside hub.db (a hub.db-only read is stale).

As STORED by the hub (events):

id severity pushed severity STORED
2964 warning warning
2965 warn info ← coerced

notification_log rows for those two events:

id event_type severity channel status error
689 disk_health_degraded warning operator sent (none)
(the "warn" push) NO ROW EXISTS

That pair is the proof. The identical event, differing only in one word of the severity string, is the difference between delivered to the operator and stored as an informational notice and delivered to nobody. This is the first time this leg has been observed end to end.

Only the operator leg fired because demo-hp has no customer_notifications row at all (no customer email, no enabled_events), so no customer row was possible for either push — verified directly, not assumed. One real email was sent to the operator, as the task anticipated.


9. NOT yet live-validated — stated explicitly

The Fail-from-counters path has never fired on real hardware. Everything in §4/§5 exercises it against the committed fixture's values in unit tests only. The live legs above prove the negative (no false alert on three healthy disks) and the severity wire (end to end, through the hub) — they do not prove a live disk reaching Hiba. The fixture tests must not be read as a live proof.

Tracked as R-332 (WATCHING). Closing condition: a live disk reaching Hiba from counters, or a deliberate injection through the real pipeline (agent /disks → controller check → hub event) — not a hand-set verdict.

One item originally listed here has since been proven live and is no longer part of this gap: the persisted state surviving a controller restart. The v0.215.0 → v0.216.0 redeploy destroyed and rebuilt the container, and the new one read back a changed_at written by the previous version rather than re-baselining — see §14. What remains unproven is the stronger half: an already-alerted disk not re-alerting after a restart, which needs a disk that has actually alerted. The drive that produced the fixture lives in DooPlex, which is Tier 2 and never a drill target; the demo boxes are all-flash and healthy.


10. Teardown

This run provisioned nothing — no VM, no guest, no hub customer record, no storage. Nothing was formatted, mounted, unmounted, repaired or written on any monitored disk; the only write is the controller's own disk-health-state.json inside its data volume.

Disposition of what the run did create:

  • Two synthetic hub events (events id 2964, 2965) and one notification_log row (id 689) on the live hub. Left in place deliberately. Both messages are self-labelling ("R-328 severity probe (…) - synthetic, no real disk fault"), and deleting rows from the production hub DB is a riskier act than leaving two clearly-marked probe rows. Named here so they are not mistaken later for a real disk fault on demo-hp.
  • One real operator email resulting from row 689.
  • A local copy of hub.db/-wal/-shm in the session scratchpad only (not committed, not exported).

11. Register rows

Row State Owner
R-328 — the severity drop: "warn" coerced to info, emailed to nobody CLOSED (controller v0.215.0), proven live side by side CC
R-329app_start_failed carries the identical defect READY — not fixed here; needs a decision on whether it should notify at all Viktor
R-330 — Phase 2: collect SMART attrs 187/199/188 + persist samples READY — a declared wire change, hub models it in the same session under G-1 CC
R-331 — Phase 3: growth-rate detection; revisit the static 64 READY, blocked on R-330 CC
R-332 — the Fail path has never fired on real hardware WATCHING CC
R-333 — NVMe temperature bands; agent smartctl has no -n standby READY (S each) Viktor decides (a); CC does (b)
R-334 — released with no golden carrying it (gate waiver) READY — now applies to v0.216.0 CC bakes; Viktor vouches
R-335 — one physical disk walked twice per run, sustaining against itself CLOSED (controller v0.216.0) CC

smartd-on-DooPlex-alerts-nobody is recorded in DIAG-smart-passed-trap-2026-08-14.md §8 as the same shape one layer out.


12. Observations — noticed, NOT acted on

  1. app_start_failed has the identical severity defect (notifier.go ~L546, "warn"). Left untouched per scope. It needs a prior decision — should a stopped app email the customer at all? — because flipping the string alone converts a silent event into a mail flood on a crash-looping box. R-329.

  2. The 55/60 °C bands are spinning-disk bands being applied to NVMe, and this is close to biting. Adopted unchanged from the operator's Prometheus config by explicit decision — but demo-hp's healthy Toshiba NVMe idles at 53 °C, i.e. 2 °C below Figyelmeztetés and 7 °C below Hiba, and NVMe routinely passes 60 °C under sustained write with no fault. As shipped, a healthy customer NVMe under load can be reported as Hiba — the single worst outcome this feature can produce, and the one leg 1 exists to guard. Not changed here because the threshold is a stated, settled operator decision; flagged rather than overridden. R-333(a) — recommend splitting the bands by device class, or dropping them for NVMe and relying on critical_warning.

  3. The agent runs bare smartctl -a -j with no -n standby (felhom-agent/internal/storage/hostops.go:368), so every poll wakes a spun-down drive, and 6h → 1h multiplies that by six. Recorded, not acted on, per the task's instruction. demo-hp is all-flash so the measurement could not reveal it. Mitigating datum from the fixture: the failing drive logged only 3375 load cycles in 60505 power-on hours (~one per 18 h), so this duty cycle barely spins down at all. R-333(b).

  4. source ~/.config/credentials prints two recovery codes to the terminal. The file contains hyphenated keys (R_DEMO-FELHOM, R_DEMO-HP) that bash cannot assign, so sourcing it emits command not found errors containing the secret values. Anything that sources that file leaks them into logs, scrollback and transcripts. Not a code defect and out of scope; worth quoting values from it by other means, or renaming the keys.

  5. golden_currency_gate.py has no waiver parser. Its own failure text says "record a waiver in OPEN-ITEMS.md — never a bypass", but nothing reads such a waiver, so the only way past it is the bypass it warns against. See §13.


13. Deviations, stated plainly

  • git push --no-verify was used once, on the felhom.eu docs push (767960b), and only there. Cause: golden_currency_gate.py correctly convicts the fact that controller v0.215.0 is released and no golden carries it (newest bake 0.214.0), so a newly installed machine would receive 0.214.0 — without the severity fix. A golden bake was out of the task's scope, and its second half (vouching in the hub's day-0 artifact manifest) is operator-password-gated, so CC cannot complete it; a baked-but-unvouched golden is worse than none. Recorded as R-334 with the bake+vouch owners named. CI re-runs the same entry point and will mail the operator. The running fleet is unaffected.
  • One pre-existing test changed meaning by design: TestDiskVerdictFor's critical_warning>0 → warn case is now → fail (truth-table row 4 — NVMe's own critical flag is a device declaration, not a drifting counter). TestDiskHealthCheck_DegradationOnce and its siblings were rewritten into the scenario groups because they encoded the pre-v0.215.0 single-alert behaviour the task deliberately replaces (Scenario C).

14. R-335 — a defect in v0.215.0, found live, fixed as v0.216.0

How it was found. Not by a test and not by review: by reading the release's own positive observable against the release's own persisted artefact. The hourly check logged "3 disk(s) evaluated"; disk-health-state.json held two records. Two artefacts that should have agreed did not.

Cause. demo-hp's c11-scratch and felhom-backup are the same physical NVMe (/dev/nvme0n1) and resolve to the same diskKey, so one disk was walked twice in a single run.

Why it mattered. RunDiskHealthCheck writes a disk's new record before the next entry reads it, so the second copy of an aliased disk consumed the first copy's write as its prior. The disk therefore sustained against itself and reached Hiba on a first sighting — defeating truth-table row 6, the single rule separating a one-hour benign excursion from a false critical alert — and would have emitted two identical events for one drive.

Severity in practice: latent, not active. Nothing fired on demo-hp because all three entries are healthy with zero counters. But any aliased disk developing one pending sector would have gone straight to Hiba, which is precisely the outcome §8 leg 1 exists to prevent. Aliasing is not exotic — it is the normal shape whenever a box has two PVE storage entries on one physical device.

Fix (v0.216.0, 90f2545). Each diskKey is evaluated once per run. Both entries stay marked seen, so neither is mistaken for a disappeared disk, and the card still renders both storage rows — the dedup is about state and alerts, not display. Pinned by TestDiskCheck_SameDiskTwiceIsEvaluatedOnce, red-proof run and reverted (§5).

Deployed: gitea.dooplex.hu/admin/felhom-controller:0.216.0 Up 6 seconds (healthy).

Confirming cycle on v0.216.0 — CONFIRMED LIVE, 09:31:35Z:

live image: gitea.dooplex.hu/admin/felhom-controller:0.216.0 Up About an hour (healthy)
2026/08/14 09:31:35 [INFO] [web] disk-health check complete: 2 disk(s) evaluated, 0 alert(s)
grep -c disk_health_degraded: 0

2 disk(s) evaluated now matches the 2 persisted records. The count and the artefact agree, which is the disagreement that exposed R-335 in the first place. Still zero alerts, still both card rows.

The redeploy also proved persistence live — a gap §9 had listed as unproven

The 0.215.0 → 0.216.0 redeploy replaced the container, and the state file came back intact:

"path:/var/lib/vz":  {"verdict": 1, "changed_at": "2026-08-14T07:23:14.640216851Z", ...}
"uuid:91d2dc2d-…":   {"verdict": 1, "changed_at": "2026-08-14T07:23:14.640216851Z", ...}

That changed_at was written by v0.215.0's first cycle at 07:23Z, before the container was destroyed and rebuilt. The v0.216.0 container read it back and preserved it rather than stamping a fresh time — so the new container loaded the pre-restart record instead of silently re-baselining. That is Scenario L observed on real hardware, not just through the production-path unit test, and it is exactly the behaviour that was impossible before v0.215.0 (the baseline was in-memory).

It also incidentally confirms the unchanged-verdict path: changed_at is preserved across four checks and two controller versions because the verdict never changed, rather than being churned every cycle.

What this still does NOT prove: these disks are healthy and were never alerted, so the stronger half — an already-ALERTED disk not re-alerting after a restart — remains unit-tested only. R-332 stands.

Process note, recorded because it nearly cost the fix. The red-proof harness reverts with git checkout --, which restores to HEAD. Running a red-proof against an uncommitted fix therefore deletes the fix along with the mutation — which happened here and was caught only by re-grepping the source afterwards. Commit the fix before red-proofing it, or snapshot outside git.