Files
felhom-controller/REPORT.md
T
2026-08-02 23:57:07 +02:00

14 KiB
Raw Blame History

REPORT — v0.191.0 / .1 / .2: warn before the wall comes down (R-167 · R-158 · R-174)

Overwritten per the standing rule; the prior contents (v0.190.0 / R-157 A · R-170 · R-171, same day) have their durable record in CHANGELOG.md.

Session: 2026-08-02 · Repos: felhom-controller v0.190.0 → v0.191.2, felhom.eu hub v0.88.0 → v0.89.0 + docs. felhom-agent untouched (Part 3 read its golden-build script and changed nothing in it).


1. Baselines — one drifted

Repo §1 said Found on arrival Match?
felhom-controller 95eb5c2c1af4, v0.190.0 95eb5c2c1af4, v0.190.0 yes
felhom.eu d5774d318941, hub v0.87.0 8ef92a3f, hub v0.88.0 NO

hub v0.88.0 had already shipped (R-172, the WAL fix) between the task being written and this session. Target corrected to hub v0.89.0. Highest register ID in use was R-173, not R-171.

2. One decision taken with the operator before any code was written

Reconnaissance found that disk_warning / disk_critical were a complete customer pipeline with no producer — in the hub's allowedEventTypes, carrying Hungarian customerMessages, in settings.DefaultEnabledEvents, with a UI checkbox (event_disk_alerts) — and grep across all four repos found zero emitters. The only thing reacting to guest disk pressure was healthcheck.go:338, folding it into a generic health_degraded at 90% for registered storage paths only.

The task said to mint a new customer-facing type. Doing so would have put a near-duplicate beside an inert pair — and §5's own reason for rejecting backup_failed (it is customer-enabled and carries Hungarian copy) is the argument for reusing this one, because D-c routes the fill warning to the customer. Operator chose: wire the existing pair. So only ONE new hub type was minted (the operator one), and the sixth built-but-never-wired instance was closed rather than joined by a seventh.

3. Files changed and commits

6 modified, 4 added. controller/cmd/controller/main.go, internal/backup/{appstop_marker,backup,recovery_unit}.go, internal/notify/notifier.go, cmd/controller/appstop_wiring_test.go, CHANGELOG.md, REUSE.md · new: internal/fillwatch/{fillwatch.go,fillwatch_test.go}, internal/backup/{appstop_drivegate_test.go,recovery_unit_notify_test.go}

Commit on main Subject
cf48214 v0.191.0 — warn before the wall comes down (R-167, R-158, R-174)
5adae4d v0.191.1 — the fill check also runs at startup (R-167)
9a3c485 v0.191.2 — a quiet fill check now says so (R-167)

4. Tests

1157 → 1184 (+27). Full suite green in both repos (go build ./... && go vet ./... && go test ./...); all eight controller gates and all five felhom.eu gates OK. Hub: 574 → 579.

Red-proofs — each mutated, observed failing, restored

# What was mutated Result
A1 the errors.Is(err, ErrStartRefused) branch removed from Recover 4 tests FAIL — refusals collapse into Failed and alarm
A2 SetStarter(gatedAppStopStarter{…})SetStarter(stackMgr) (the v0.189.0 wiring) TestMainWiresGatedAppStopStarter FAILS
C the m.unitNotify(...) call removed from the capture loop 2 tests FAIL — the seam-never-wired shape
E both edge guards removed 3 tests FAIL — warns twice; the dead zone breaks
G recovery_unit_capture_failed removed from operatorOnlyEvents 2 tests FAIL, one reading "a customer was emailed the OPERATOR-ONLY …"
H clear thresholds edited into equality with warn TestThresholdsKeepTheirHysteresisGap FAILS
J fillWatcher.SetNotify( commented out (string still in the file) TestMainWiresTheFillWatcher FAILS — proves AST, not substring

One red-proof was a FALSE GREEN on the first attempt, and it is reported rather than quietly redone. Red-proof E's first mutation removed only if next == prev { continue }; a second guard (next <= prev) still caught the repeat, so every test passed and the "proof" proved nothing. Redone removing both guards. This is exactly the trap CLAUDE.md records — a mutation or a -run filter that yields a green and reads like a completed proof.

One pre-existing test was TIGHTENED, not loosened. TestMainReportsTheInterruptedOperation asserted the guard was if appStopRecovery != nil. R-174 makes that insufficient — Recover now returns non-nil for a refusal-only recovery — so the test now requires Alarming() in the condition.

5. Thresholds and cadence, with their justification

value why
warn used ≥ 85% OR free < 5 GiB a percentage alone lies at both ends of this fleet's real size range: 85% of a 20 G mp1 leaves 3 G — less than one DB-backed app's unit (~2× its data, §7.5) — while 85% of a 4 TB drive leaves 600 G
critical used ≥ 95% OR free < 2 GiB below 2 GiB a volume tar of almost any real app fails: "the next backup will not complete", not "it is getting tight"
clear used ≤ 75% AND free ≥ 7 GiB both must hold; the gap to warn is the hysteresis dead zone, so a filesystem on the line does not flap. Pinned with a real margin, not merely an inequality
cadence daily 03:30 + once 90 s after startup a fill is slow-moving, so a shorter interval buys no earlier warning; 03:30 precedes the nightly app-data legs, so a customer about to lose a backup hears about it with a night's margin. The startup run exists because neither Daily nor Every fires on registration — §7

The live proof vindicated the two-term design: the critical crossing fired on the free-byte term (1.7 GB) while the disk was only 91% used. A percentage-only rule would have missed it entirely.

6. The exact Hungarian customer copy, for review as copy

disk_warning (severity warning) — as rendered live on 9201:

A(z) „Rendszer- és mentési terület” tároló 85% foglalt — 4,7 GB szabad hely maradt. Kérjük, szabadíts fel helyet, mielőtt megtelik: törölj felesleges fájlokat, vagy csatlakoztass új meghajtót. Ha megtelik, a biztonsági mentések meghiúsulnak.

disk_critical (severity critical) — as rendered live on 9201:

A(z) „Rendszer- és mentési terület” tároló kritikusan megtelt: 1,7 GB szabad hely maradt (91% foglalt). A biztonsági mentések és az alkalmazások írásai bármikor meghiúsulhatnak. Kérjük, mielőbb szabadíts fel helyet: törölj felesleges fájlokat, vagy csatlakoztass új meghajtót.

Decimal comma throughout (4,7 GB); the storage is named by its label, never its path; both end in an action. No emoji (asserted by a test; emoji_gate passes).

7. Two defects found by the validation itself, both fixed and shipped

  1. v0.191.1 — the check was reachable only on its daily schedule. Neither sched.Daily nor sched.Every fires on registration; both wait for their first tick. A box that BOOTS already over the line would have stayed silent for up to 24 h — the R-100 shape, and the same gap the hub's own checkers avoid by leaving already-breached keys unseeded at init. Now also runs once 90 s after startup; safe because the check is edge-triggered against persisted state, so an already-warned filesystem stays silent.
  2. v0.191.2 — a quiet run was unreadable as evidence. After the customer had been warned, a restart produced zero fillwatch lines — equally consistent with "ran and chose silence" and "never ran". For an edge-triggered check the quiet run is the healthy steady state, so that ambiguity is permanent, not rare. Check now logs a per-run summary (checked N filesystem(s), M unreadable/skipped, K notification(s); bands: …), counting unreadable separately so a silently unreadable drive cannot read as "all fine".

8. Live evidence — three flows, guest 9201 on felhom-pve

Method: endpoint/log-level on real hardware, exercising the production paths — browser automation is not available on DooPlex. The hub's notification_log was read from a WAL-aware copy of /data/hub.db (all three files — R-172's lesson); the copies were deleted afterwards.

Flow 1 — the guard refuses, keeps the marker, does not alarm. Both hdd_1 mounts held unmounted against the agent's ~60 s heal, per the documented method:

[WARN] [appstop] refusing to restart "calibre-web" after an interrupted operation:
       drive /mnt/felhom-drives/hdd_1 is not a live mountpoint
[WARN] [appstop] crash recovery: NOT restarting calibre-web — start refused by a deliberate
       holder: … the marker is KEPT and the holder owns the restart
[WARN] [appstop] crash recovery: 1 app(s) were deliberately NOT restarted (drive absent) —
       KEEPING the marker; this is the gate working, not a fault: [calibre-web]
[WARN] [appstop] …HELD… — not alarming: … restarted=[] held_by_drive=[calibre-web]

Marker retained byte-identical; docker ps -a --filter name=calibre-web0; no backup_failed event. Scenario B, on a later boot with the drive live: restarted calibre-web after the interrupted an app-data backup (volume dump) and the marker was cleared.

Flow 2 — the operator event arrives; the customer is refused. Two real capture failures:

Event pushed: recovery_unit_capture_failed (error) — Recovery unit capture FAILED for "calibre-web"
  … /mnt/felhom-drives/hdd_1: 24.5/93.9 GB used (26%), 64.6 GB free. Error: … permission denied

Hub notification_log — the positive observable:

customer | recovery_unit_capture_failed | skipped | operator_only
operator | recovery_unit_capture_failed | sent    |

Flow 3 — the customer is warned, once, in Hungarian. /mnt/sys_drive filled with fallocate:

run state fired log
1 90% / 4.7 GB disk_warning ok → warning … notifying the customer
2 unchanged nothing (edge trigger held)
3 91% / 1.7 GB disk_critical warning → critical …
4 file removed nothing critical → ok … cleared silently, re-armed; state emptied

Hub: customer | disk_warning | sent and customer | disk_critical | sent, Hungarian rendered. Exactly two events across four runs. Run 2's silence is meaningful only because run 3 proves the startup check executes every boot — needing that inference is what motivated v0.191.2.

9. Deployed versions

$ ssh felhom-pve "pct exec 9201 -- docker ps --filter name=felhom-controller …"
gitea.dooplex.hu/admin/felhom-controller:0.191.2 | Up (healthy)

Hub: ArgoCD app felhom Synced / Healthy after a deliberate hard-refresh + sync (never kubectl set image); deploy/hub rolled out; image gitea.dooplex.hu/admin/felhom-hub:0.89.0; startup log clean.

10. Teardown

R167-FILLTEST.bin deleted (/mnt/sys_drive back to 2.0 G used / 45 G free); the app-stop marker cleared by a successful recovery; /root/hold-unmount.sh + /tmp/hold.log removed from felhom-pve; the secret-bearing local hub.db* copies deleted. All 15 containers on 9201 healthy.

One self-inflicted incident, disclosed in full. Restoring the drive by hand I ran mount --bind /mnt/hdd_1 /mnt/felhom-drives/hdd_1, omitting the felhom-data segment the agent uses (felhom-agent/internal/localapi/intermediary.go:15mount --bind /mnt/<name>/felhom-data /mnt/felhom-drives/<name>). The guest then saw the drive root instead of the namespace root, which an unprivileged guest cannot write to — producing real permission denied capture failures. They served as flow 2's evidence, but they were caused by me, not found by me, and the report says so. Separately, a manual docker compose up -d from a stack dir started containers without the controller-injected env (there is no .env on disk — the controller injects at exec time), which recreated immich-server with a blank DB password and left it in a 28P01 auth-failure restart loop. Both were repaired through production paths: the bind corrected, then the stacks brought back by the controller's own StartStack and boot reconciler ([bootrecon] … 1 app(s) recovered in 1 attempt(s): [immich]). Lesson, learned twice in one session: on a box the agent manages, restore state by letting the agent and controller do it.

11. Register rows

Opened: R-174 (closed same session), R-175, R-176, R-177 — each ID established free by grep -ro "R-17n\b" documentation/ *.md → 0 hits. Closed: R-158 (by R-167 — no second row was filed for the same wire), R-167, R-174. Updated and still open: R-165 (gains the spike's M1-M5 and the operator question).

12. CI and --no-verify

--no-verify was NOT used. Every push ran .githooks/pre-push (gates --fast) and it passed.

CI checked by PULL, matching head_sha to each commit — CI emails only on failure, so a green that was never looked at is an assumption. All three commits green:

Commit Task id Run # Conclusion
cf48214 (v0.191.0) 31 11 success
5adae4d (v0.191.1) 34 12 success
9a3c485 (v0.191.2) 35 13 success

The felhom.eu side (hub v0.89.0, the manifest bump and the docs) is in that repo's REPORT.md §6 — also all green.

13. Observations — noticed, documented, NOT acted on

  • R-177 — no operator-triggerable "run this scheduler job now" path. It cost a controller restart per observation here, and costs the same on a support call.
  • R-17507-backup-architecture.md §7.5 states one box's size bound as if it were the fleet's.
  • R-176 — two R-165 prerequisites are unmeasured (a pre-merge-archive restore-test; the in-place migration rehearsal).
  • The drive gate's return branch failed to restart both apps ([WARN] [gate] restart calibre-web: … exit code 1) at a moment when a manual start succeeded seconds later. Not filed as a row: it happened during my incorrect bind, so the likeliest cause is mine and the evidence is contaminated. Worth a look if it recurs on a clean box.