C9-F1 + C9-F2: a restore that restored nothing, and a crash loop nobody saw (v0.183.0)

Both are the system reporting healthy while the customer is not, and both live in the same
status-derivation code. Neither is fixed by making the system quieter.

C9-F1 (HIGH) — Tier-2 writes recovery-unit/ on EVERY run and RestoreTier2Files has never read
it (tier2_restore.go:101-104 reads hdd/ + userdata/ only). Phase 0 enumerated all 53 catalog
templates against both demo boxes: 43 apps have NO readable subtree, so the button stopped the
app, restored 0 files, restarted it and said "Nincs hiányzó fájl — minden fájl megvan a helyén."
— at the moment the customer pressed it because files were missing, with 156 MB of BookStack's
data unread in the same copy. 9 apps have file legs but never their DB or volumes, so the same
sentence was also a clean bill of health over data never opened (immich: 1.3 GB Postgres unit).

Honesty half shipped: a pre-flight coverage check refuses UP FRONT without stopping the app and
NAMES the action that works; a run that proceeds claims only what it EXAMINED and discloses that
the database and volumes are not covered. Completeness is filed as C9-F1b — routing to the
Tier-1 unit restore puts a destructive operation behind a non-destructive button, so its confirm
copy has to carry that difference. C9-F4 filed: nothing reads the Tier-2 recovery-unit/ mirror,
so the second local copy that exists for drive loss is unreachable by any customer action.

C9-F2 (HIGH) — a crash loop was counted as working. StateRestarting is deliberately NOT added to
IsDownState (that alarms on every deploy fleet-wide, the over-correction F-A1 nearly cost us); a
sustained run becomes down after crashLoopAfter = 5m, set above the 120s deploy timeout, Mealie's
60s start_period and R-97b's 180s grace. The dashboard counter uses the same predicate, so it no
longer contradicts the alarm on the same screen. README's claim that faults "still surface as
restarting" was a wish with no test — corrected in place; it is the seventh such instance.

Six red-proofs observed, including the one that matters most: adding StateRestarting to
IsDownState fails the brief-restart test with "every deploy and update would page the operator".
go test ./... rc=0, 27 packages, run and read separately from this commit.
This commit is contained in:
2026-07-28 18:53:56 +02:00
parent d8b3279731
commit fd50a73e65
12 changed files with 805 additions and 38 deletions
+45 -5
View File
@@ -853,10 +853,31 @@ customer edit after the last copy wins) and **nothing is ever deleted** — this
`CrossDriveBackup.DestinationPath` (never a fresh target selection). Single-flight with
backup/restore; refusals (no copy / never ran / copy dir gone / either drive disconnected /
decommissioned) happen before the app is stopped, with customer-readable Hungarian reasons;
stop → copy → start → health-wait; zero files copied is a success ("Nincs hiányzó fájl…"). Out of
scope by design: overwrite/point-in-time restore (offbox + operator paths), per-file selection,
`recovery-unit/`. Apps that index their data dir (e.g. Nextcloud) may need a rescan (occ
files:scan) before restored files appear in their own UI.
stop → copy → start → health-wait. Out of scope by design: overwrite/point-in-time restore (offbox +
operator paths) and per-file selection. Apps that index their data dir (e.g. Nextcloud) may need a
rescan (occ files:scan) before restored files appear in their own UI.
> **COVERAGE — read this before assuming an app is protected by this button (C9-F1, v0.183.0).**
> This restore reads `hdd/` and `userdata/` **only**. It has never read `recovery-unit/`, which every
> Tier-2 run also writes and which holds the app's DB dumps and named-volume tarballs. Enumerated
> across all 53 catalog templates: **43 apps have no readable subtree at all** (their data is entirely
> in named volumes — BookStack, Docmost, Vaultwarden, Gitea, …), **9** have file legs but never their
> database or volumes, 1 is stateless. So the button is a guaranteed no-op for 81% of the catalog and
> only ever partial for the rest.
>
> Since v0.183.0 it is HONEST about that instead of silently reporting success:
> `Tier2RestoreCoverage` is consulted **before** anything starts, an app with no readable subtree is
> refused **without being stopped** and told which action does work („…Használd a Visszaállítás
> indítása gombot a Biztonsági mentés → Visszaállítás oldalon."), and a run that does proceed claims
> only what it **examined** („Minden vizsgált fájl megvan a helyén.") plus a disclosure that the
> database and internal volumes are not part of this restore.
>
> The action that DOES cover those apps is the keep-side recovery-unit restore
> (`POST /backup/restore``RestoreFromRecoveryUnit`), which replays volume tarballs and DB dumps.
> Routing customers there from the Tier-2 card is filed as **C9-F1b** — it puts a destructive
> operation behind a button reached via a non-destructive one, so the confirm copy must carry that
> difference. **C9-F4** is filed separately: nothing reads the Tier-2 copy's `recovery-unit/` mirror,
> so the second local copy that exists precisely for drive loss is unreachable by any customer action.
**Per-app Tier-2 config panel (v0.57.0)** — `GET/POST /stacks/{name}/backup`
(`internal/web/tier2_config_handler.go` + `templates/tier2_config.html`). The "2. mentés" row's
@@ -1762,7 +1783,26 @@ display, not alarms, and are unchanged). This rests on two invariants: **I1**
at `StateStopped` (compose down removes the containers); **I2** — the P2 restart-policy census
(53 templates / 78 services, all `unless-stopped`) means a crashing app never comes to rest at
`stopped`, so faults still surface as `exited`/`degraded`/`restarting`/`unhealthy`. If either
invariant changes, revisit the suppression. `IsDownState` itself is deliberately UNCHANGED (other
invariant changes, revisit the suppression.
> **C9-F2 (v0.183.0) — the `restarting` half of that sentence was a wish, not a fact.** `restarting`
> was named above as a state through which faults "still surface", but it was in no down set at all:
> `IsDownState` excludes it, so a crash-looping app raised no banner, no `app_start_failed`, no email
> and no hub event — and `unless-stopped` means Docker retries forever, so the silence was permanent.
> Campaign 9 watched docmost loop for nine minutes while the F-OBS heartbeat printed
> „180 scans since boot, 4 deployed app(s) evaluated, **0 currently down**".
>
> The fix does **not** add `StateRestarting` to `IsDownState` — that alarms on every deploy and update
> fleet-wide. A SUSTAINED restarting run becomes down after `stacks.crashLoopAfter` (**5 min**), chosen
> above the deploy flow's 120 s health timeout, Mealie's 60 s `start_period` and R-97b's 180 s quiesce
> grace, so the suppression windows compose into one bounded delay rather than leaving a gap. Carried
> by `Stack.RestartingSince` (stamped in `refreshStatusLocked`, cleared on any other state, not
> persisted) and read via `Stack.CrashLooping(now)` — used by BOTH the alarm and the dashboard
> "how many of my apps work" counter, which previously counted `restarting` as running and so
> contradicted the alarm on the same screen. Pinned by `crashloop_classify_test.go`; the test that a
> brief restart stays silent is the one that fails against the naive fix.
`IsDownState` itself is deliberately UNCHANGED (other
callers rely on stopped counting as down). An out-of-band `docker compose stop` leaves the containers
present → `StateExited` → still alerts, which is correct (out-of-band tampering is reportable).