Files
felhom-controller/REPORT.md
T
admin 45b52b6ed5
gates / gates (push) Successful in 12s
R-330: live validation on demo-hp — 3 scans inside the window, 0 events
v0.224.0 deployed to both demo boxes (both `0.224.0 … (healthy)`). Proven
through POST /api/backup/run, the endpoint the UI button invokes: 8 stacks
stopped and restarted over 87s, three dead-app scans ran INSIDE that window
(16:09:26 docmost, 16:09:56 paperless-ngx, 16:10:26 romm -- the same three apps
that alarmed the night before on 0.223.0), zero app_start_failed pushed.

The scan count is the positive control, not decoration: an absent alarm is
equally consistent with "suppressed correctly" and "the scanner stopped".

A first run is discarded IN THE REPORT rather than quietly dropped -- it fired
52s after a controller restart, inside deadAppBootGrace (90s), where the scan
returns early and could not have alarmed whatever the code did. demo-felhom is
deployed but NOT independently proven and says so: its single app cycles in ~1s,
too fast for any 30s scan to land inside.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LB8FmJaGd2cyjvy6dbEjpM
2026-08-30 18:12:26 +02:00

12 KiB

REPORT — R-330: the nightly backup alarmed about the apps it was holding down

Controller v0.224.0 · 2026-08-30 · implemented on DooPlex, diagnosed live on demo-hp

This report covers Option A of a two-part request. Option B (the hub Backup card that always reads Snapshots 0) is a separate, still-open defect and is described in §7.


1. What was reported

61 e-mails, arriving in two bursts every night from both demo boxes:

[Felhom] demo-hp: app_start_failed
Severity: warning
Time:     2026-08-30 02:30 CEST
Message:  Telepített alkalmazás nem fut: Docmost

2. What was actually happening

Nothing was broken. Both boxes were healthy at every check:

demo-felhom (N100) demo-hp (HP t740)
host uptime 20 d 8 d 23 h
agent 0.130.0, active 0.130.0, active
controller 0.223.0 0.223.0
containers 1/1 16/16, all healthy
SMART, all disks PASSED PASSED
journalctl -u felhom-agent -p warning, 3 days no entries no entries
hub health ok ok

The alarms are the box's own backup. Both bursts line up exactly with the nightly legs (backupwindow: DB dump at W, tier-2 at W+60m, off-box at W+105m; default W = 02:30 CEST):

leg fired (UTC) = CEST events pushed
db-dump 00:30 02:30 Docmost, Paperless-ngx, RomM
tier2-backup 01:30 03:30 —
offbox-backup 02:15 04:15 Docmost, Paperless-ngx

Evidence, from the guest's own controller log (all copied off the box before any change):

00:30:01 backup.go:868  [INFO] [backup] Stopping bookstack for safe volume dump
00:30:06 manager.go:1129 [INFO] [stacks] Stack bookstack stopped successfully (took 4.4s)
00:30:14 manager.go:1054 [INFO] [stacks] Stack bookstack started successfully (took 6.2s)
00:30:26 notifier.go:234 [INFO] Event pushed: app_start_failed (warning) — Telepített alkalmazás nem fut: Docmost

DumpAppVolumesSafe stops a stack (docker compose down), tars its volumes and starts it again — ~13 s per stack, measured — while the deadapp-check scheduler job runs every 30 s. The scan caught whichever stack was mid-cycle.

The positive observable that proves the boxes were fine (standing rule 3 — an absent alarm is not evidence): every dead-app scan across the other 23 hours logged 8 deployed app(s) evaluated, 0 currently down, and the same night's off-site run logged [offbox] backup OK: 8 app(s) backed up, 67 snapshot(s), 2m14s.

3. Root cause

A mechanism that exists, works, and was never consulted. quiesce/suppress.go solved exactly this problem in v0.179.0 (R-97b). But classifyRunStates read only quiesce.Loop.SuppressedStacks(), and the quiesce loop covers the whole-guest (vzdump/PBS) backup. The per-app legs stop stacks through Manager.DumpAppVolumesSafe, which registered with no suppressor at all.

Two mechanisms in this product stop a customer's app on purpose. Only one told the alarm.

This is the "seam built but never wired" class, fifth instance — and the first where the unwired half was a consumer rather than a producer. That distinction is why the tests below include an AST wiring check: a test that injected the suppressor directly would have passed against the shipped bug.

4. The fix

internal/backup/appstop_suppress.go (new) puts the suppression on AppStopGuard, which already brackets every deliberate stop in the product (Begin before the stop, End after a successful restart) at all three call sites — volume dump, off-site reconstitute, .fab export — and which main.go hands as ONE object to both the backup manager and the exporter. The fact the alarm needs already lived there with exactly one writer; a fourth registry beside it would have been drift.

scanDeployedAppRunStates now passes unionSuppressed(q.SuppressedStacks(), g.SuppressedStacks()). All three per-app stop paths are fixed by the one change, not only the nightly leg that was reported — a .fab export and an off-site restore stop an app the same way and would alarm the same way.

It must never latch — the harder half

Permanent suppression trades a loud false alarm for a silent real one (F-CRIT-1, R-88 Scenario D). End() runs only on a restart that succeeded, so an open-ended hold is a genuine hazard here in a way it is not for the quiesce loop, which always releases. Three independent guards:

  1. ReleaseFailed — a restart attempted and broken drops the entry immediately; the app alarms on the very next scan, with no delay at all. Wired at every failure path (volume dump, restartStack in the off-site reconstitution, the exporter's restart defer — the exporter's seam interface grew the method rather than the exporter keeping separate bookkeeping).
  2. Begin replaces the set — one marker file is one operation, so a set stranded by an operation that died mid-window cannot survive into a later one.
  3. appStopMaxHold = 6 h — a backstop for a hold nothing released, logged at WARN when it fires.

The post-restart grace is 180 s, the same constant and derivation as quiesce.quiesceAlarmGrace. Two windows over one alarm that disagreed on how long a restart takes would be a bug waiting to be found on whichever path used the shorter one.

The suppression is deliberately not persisted: after a crash the guard holds nothing, Recover() either brings the apps back or leaves them genuinely down, and a down app must alarm. ReleaseFailed drops the suppression and keeps the durable crash marker — the two are independent, and a test pins that.

5. Tests, and the red-proofs

internal/backup/appstop_suppress_test.go drives the real DumpAppVolumesSafe (not Begin directly) and asserts the suppression set the dead-app scanner actually reads — the consequence, not a log line.

test pins
TestVolumeDump_SuppressesTheAlarmForTheAppItIsHolding the fix, through the production path
TestVolumeDump_SuppressionExpiresSoARealOutageStillAlarms the window is a bounded delay, never a lost alarm
TestVolumeDump_FailedRestartAlarmsImmediately anti-latch #1, and that the crash marker survives
TestSuppression_CannotOutliveTheBackstop anti-latch #3
TestBeginReplacesThePreviousOperationsSet anti-latch #2
TestSuppression_FailedBeginSuppressesNothing a refused stop suppresses nothing
TestHeldAppIsNotReportedDown the consequence: held app silent, genuinely exited app still alarms
TestScanDeployedAppRunStatesIsGivenTheAppStopGuard the wiring, by AST walk

Three companion red-proofs were run, and each printed the pre-fix value:

  1. Delete g.markStopped(stackNames) from Begin → suppressed at stop = map[], want bookstack — the exact shape that produced the e-mails. (TestVolumeDump_SuppressionExpires… and TestSuppression_CannotOutlive… also went red.)
  2. Delete m.appStop.ReleaseFailed(stackName) from the volume dump → suppressed = map[bookstack:true] after a restart that FAILED.
  3. Pass nil instead of appStopGuard in main.go → the AST wiring test failed.

Each was restored immediately and git diff verified clean afterwards. Red-proof 3 is the load-bearing one: the component was never the broken part, so a suite that only injected it would have been green against the shipped defect.

Green gate: go build ./... && go vet ./... && go test ./... in felhom-controller/controller/ — clean, no failures.

6. Live validation — v0.224.0 deployed and PROVEN on demo-hp

Built, pushed and deployed to both boxes; both report 0.224.0 … (healthy).

Method: POST /api/backup/run — the exact endpoint the "Mentés indítása" button invokes — driven headlessly from inside guest 9201 (no browser on DooPlex; the residual is client-side rendering only). No state was hand-set: the real RunDBDumps ran and really stopped and started all eight stacks.

The first run is discarded and the reason is recorded rather than quietly dropped. The controller had restarted at 16:00:25 UTC and the trigger landed at 16:01:17 — inside deadAppBootGrace (90 s), during which the scan returns early. Half that window could not have alarmed whatever the code did, so it proves nothing. A second run was taken at 16:08:56, 8.5 minutes past the grace.

Run 2 — the numbers (demo-hp, UTC):

stacks stopped and restarted 8 (bookstack, calibre-web, docmost, kimai, opengist, paperless-ngx, privatebin, romm)
window 16:08:59 → 16:10:26 (87 s)
dead-app scans that ran INSIDE the window 3 — 16:09:26, 16:09:56, 16:10:26
Event pushed: app_start_failed 0 (0 across the whole uptime)

The positive control is the point (standing rule 3 — an absent alarm is equally consistent with "suppressed correctly" and "the scanner stopped"). The scanner was demonstrably alive and evaluating throughout, and each of the three scans landed on an app that was actually down or mid-restart:

scan app in its stop/start window at that moment alarmed on 0.223.0 last night?
16:09:26 docmost (down 16:09:16 → 16:09:31) yes
16:09:56 paperless-ngx (down 16:09:48 → 16:10:08) yes
16:10:26 romm (restarting, back at 16:10:26) yes

That is a true A/B on the same box, the same job and the same three apps that produced last night's e-mails: 0.223.0 → 3 events, 0.224.0 → 0 events, with the scanner proven running in both.

demo-felhom is deployed but NOT independently proven, and this is stated rather than implied. Its single app (opengist) cycles in ~1 s (16:11:07 → 16:11:08), so no 30 s scan could land inside the window — the run produced zero events, but zero events was the expected result either way. Its scanner is confirmed alive ([deadapp] check alive: 20 scans since boot, 1 deployed app(s) evaluated, 0 currently down). The code path is identical to the one proven on demo-hp; the narrower race is also why that box sent 2 mails a night rather than 5.

The real acceptance test is tonight's unattended 02:30 and 04:15 CEST runs. Zero app_start_failed mails from either box tomorrow morning closes this; any mail is a regression.

7. Not done, and why

  • The restore-hold path (offbox_reconstitute.go, an app deliberately held down after a failed replay) calls End(), so it gets the 180 s grace and then alarms. That is today's behaviour plus 180 s and is deliberate: the app really is down, the customer should learn that, and the hold has its own operator notification (restoreHoldNotify) besides.
  • HeldStacks() was left alone. It reads the marker from disk for the boot reconciler and covers "held right now" but not the post-restart grace — which is precisely the window R-97b proved is needed. The new in-memory set is a superset for alarm purposes; the durable one stays the recovery record.

8. Found while diagnosing — a second, still-open defect (Option B)

The hub's customer Backup card is inert for every customer. It reads Snapshots 0 · Repo Size 0 MB · Integrity Unknown while the same box's log says [offbox] backup OK: 8 app(s) backed up, 67 snapshot(s).

hub/internal/web/templates/customer_unified.html:284 renders snapshot_count / repo_size_mb / integrity_ok from the report. Those fields are declared in controller/internal/report/types.go:105-108 and assigned nowhere — a repo-wide grep finds only the declaration. The controller tracks the real numbers in internal/backup/offbox.go (SnapshotCount, line 1043) and serves them on its own API (internal/web/offbox_handlers.go:295); they are simply never copied into the hub report.

Until it is fixed, that card must not be read as evidence of a missing backup. It is a two-repo change (controller report builder + hub) and is Option B of this request.

9. Also observed (not changed)

  • ssh demo-hp no longer works — the tailnet peer 100.76.96.79 has been offline 8 days (tailscale status: offline, last seen 8d ago). The box is reachable on the home LAN as ssh hp → 192.168.0.104, which is what every command in this report used.
  • drill-r50-0a4f9a still appears in the hub host list as DOWN — a leftover record from the R-50 drill whose rig was torn down; not a live box.