Campaign 9: the Tier-2 restore paths are PROVEN; 3 defects filed, none fixed
Phase A is the headline and it passed on live hardware, through the real endpoints the UI
posts to: a customer who deletes files — or their entire app data directory — gets everything
back byte-identical, and the app works afterwards (paperless served the restored bytes over
its own API at the exact pre-deletion sha256). A1's two non-destruction promises both hold.
Three defects, recorded not fixed:
C9-F1 (HIGH) the Tier-2 restore button is offered for apps it can never restore, takes a
real outage, and reports "nothing was missing" — indistinguishable from a
genuine result, while 156 MB of that app's data sits unread in the same copy.
C9-F2 (HIGH) an app in a crash loop never alarms on any channel; StateRestarting is in no
down-set, so F-OBS's own heartbeat printed "0 currently down" for 9 minutes.
C9-F3 (MEDIUM) an interrupted offsite run leaves a lock the self-heal cannot reach; the tier
is dead until a human unlocks and the operator is told "unknown reason".
This answers Phase C item 8.
Two candidates were deliberately NOT filed: a recovery-unit poisoning the catalog sync healed
in ~3 min, and a snapshot_id that is documented as logging-only. Reporting either would have
been reporting an artifact.
Stopped at the end of Phase B (plus D10), then full recovery — both boxes healthy, real
cadences, offsite tier proven working again, no leaked scratch guests, peti untouched.
D11's approved staleAfter compression turned out not to be a knob; reported, not worked around.
This commit is contained in:
@@ -0,0 +1,825 @@
|
||||
# CAMPAIGN 9 — proof, not fixes: the restore paths and the interactions
|
||||
|
||||
**Adversarial, destructive, unattended. 2026-07-28 17:12 CEST → in progress.**
|
||||
|
||||
Nodes exercised: `demo-felhom` (+ guest 9201), `demo-hp` (+ guest 9201), `ep0`
|
||||
(`felhom-hetzner`). Controllers on **0.182.0** both boxes; agent 0.110.0; hub 0.80.0.
|
||||
|
||||
**`peti-felhom` was never touched** — not its data, not its namespace, not `u629488-sub2`. The ep0
|
||||
rollback copy `/srv/pbs-felhom` was never deleted.
|
||||
|
||||
Evidence: **`DooPlex:~/campaign9/evidence/`**, written continuously by 7 collectors (6 log streams +
|
||||
a 2-minute state snapshot) started **before** the first mutation and verified emitting live lines
|
||||
before Phase A began.
|
||||
|
||||
---
|
||||
|
||||
## Headline
|
||||
|
||||
**The two never-proven restore paths are now proven, on live hardware, through the real endpoints
|
||||
the UI posts to.** A customer who deletes files — or their entire app data directory — gets
|
||||
everything back, byte-identical, and the app works afterwards.
|
||||
|
||||
**One HIGH-severity defect fell out of proving it: F-1.** For apps whose data lives entirely in
|
||||
Docker named volumes, the „Fájlok visszaállítása" button is rendered, stops the app, restores
|
||||
nothing, and reports **„Nincs hiányzó fájl — minden fájl megvan a helyén."** — a message that
|
||||
positively asserts the customer's files are all present. It says this on every click, on any day,
|
||||
no matter how much data was lost, while 156 MB of that app's data sits unread in the same Tier-2
|
||||
copy the button claims to restore from.
|
||||
|
||||
---
|
||||
|
||||
## 1. Phase 0 — questions, answers, and skips
|
||||
|
||||
Asked in one batch, before touching anything. **All four answered; nothing was skipped for lack of
|
||||
an answer.**
|
||||
|
||||
| # | Question | Answer received |
|
||||
|---|---|---|
|
||||
| 1 | Which targets may I destroy data on? | **Throwaway apps AND existing demo apps on BOTH boxes.** |
|
||||
| 2 | May the hub's `staleAfter` be compressed for Phase D item 11? | **Yes — compress, then revert.** |
|
||||
| 3 | How may ep0 be taken down? | **Power-cycle allowed programmatically** (widened from Campaign 8's "no power-cycle"). |
|
||||
| 4 | `drill-r50` destroy/rebuild? Host reboot for Fault 12? | **drill-r50 free to destroy; host reboot OK on BOTH demo hosts.** |
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase A — the restore proofs (the headline)
|
||||
|
||||
### A1 — Tier-2 cross-drive restore of ordinary app data — **PROVEN**
|
||||
|
||||
**Target.** `paperless-ngx` on **demo-hp** guest 9201 — chosen because it is the only app in the
|
||||
fleet that combines real user content (16 documents), a complete Tier-2 copy with a real user-file
|
||||
leg, and an R-101 success anchor. Live root `/mnt/felhom-drives/nvme-1tb`; Tier-2 copy
|
||||
`/mnt/sys_drive/felhom-data/backups/secondary/paperless-ngx`.
|
||||
|
||||
**Method.** The real endpoint the UI posts to — `POST /backup/tier2/restore` with `stack_name` —
|
||||
driven **through traefik over https with the vhost Host header**, from inside the guest, with a real
|
||||
session cookie and a real CSRF token scraped from the rendered `/backups/apps` page. No server logic
|
||||
was skipped; only browser rendering.
|
||||
|
||||
**The rendered dialog** (`evidence/phaseA/A1-dialog-rendered.txt`) — this is the R-101 branch, live:
|
||||
|
||||
```
|
||||
Visszaállítja a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM
|
||||
törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: 2026-07-28 16:43.
|
||||
```
|
||||
|
||||
**Baseline.** All 43 files in the copy were byte-identical to live before anything was touched
|
||||
(`A1-copy-inv-BEFORE.txt` vs `A1-live-inv-BEFORE.txt`, `diff` empty).
|
||||
|
||||
**The mutation exercised all three of the dialog's promises in one run** — because the dialog makes
|
||||
three distinct claims and proving them separately would have been three outages:
|
||||
|
||||
| class | files | promise under test |
|
||||
|---|---|---|
|
||||
| DELETED | 6 (3 originals, 1 archive, 2 thumbnails, spanning all subdirs, incl. one complete document) | must come back byte-identical |
|
||||
| CREATED | 2 (one in an existing dir, one in a **new** dir) | „NEM törlődnek" — must survive |
|
||||
| MODIFIED | 1 thumbnail, appended marker | „NEM módosulnak" — must not be overwritten |
|
||||
|
||||
**Result — all three hold** (`A1-verify.txt`):
|
||||
|
||||
```
|
||||
=== PROMISE 1: deleted files came back BYTE-IDENTICAL ===
|
||||
originals/0000002.pdf: OK
|
||||
originals/0000005.pdf: OK
|
||||
originals/0000012.pdf: OK
|
||||
archive/0000012.pdf: OK
|
||||
thumbnails/0000012.webp: OK
|
||||
thumbnails/0000003.webp: OK
|
||||
|
||||
=== PROMISE 2: created files were NOT deleted ===
|
||||
PRESENT originals/c9-created-file.txt be279d439b852e78c6d10379c4f569360283d4ce77523f4848723beb5b78997e
|
||||
PRESENT c9-newdir/c9-keep.txt 4fa680f76eeac33369fefec88d1a216cea7d16255573e439302d40c63fa9d6a9
|
||||
|
||||
=== PROMISE 3: locally-modified file was NOT overwritten ===
|
||||
pre-edit fd9b92dd77df6bd9e6474d238ec86e029e7f562c820ed1172d71ad6cb9eccb42 <- must NOT match
|
||||
post-edit 15c61ab7ddd1bfcfecc7ac7f43d12357de123b42d3ab127e3a23a46af4bd4fd2 <- must match
|
||||
actual 15c61ab7ddd1bfcfecc7ac7f43d12357de123b42d3ab127e3a23a46af4bd4fd2
|
||||
marker still present: CAMPAIGN9-LOCAL-EDIT-MUST-NOT-BE-OVERWRITTEN
|
||||
live file count: 45 (43 baseline + 2 created — arithmetic closes)
|
||||
```
|
||||
|
||||
**Stop/start and health**, from the controller (`A1-controller-log.txt`):
|
||||
|
||||
```
|
||||
15:21:59 [WARN] [web] Tier-2 file restore requested (async): stack=paperless-ngx
|
||||
15:21:59 [INFO] [backup] Tier-2 file restore for paperless-ngx: …/secondary/paperless-ngx (v2) → /mnt/felhom-drives/nvme-1tb (additive-only)
|
||||
15:22:20 … Post-restore health check: paperless-ngx not yet running, waiting… (×5)
|
||||
15:22:45 [DEBUG] [backup] Post-restore health check: paperless-ngx is running
|
||||
15:22:45 [INFO] [backup] Tier-2 file restore completed for paperless-ngx: 6 file(s) restored (39s)
|
||||
```
|
||||
|
||||
Container uptime confirmed the real stop/start independently (`Up 5 seconds (health: starting)` →
|
||||
`Up 27 seconds (healthy)` polled at 10-second intervals).
|
||||
|
||||
**The restored data is usable BY THE APP, not merely present on disk** (`A1-usable.txt`) — two
|
||||
independent checks:
|
||||
|
||||
```
|
||||
=== CHECK 1: paperless resolves each restored doc and its content matches the DB checksum ===
|
||||
doc 2 invoice-114750664 original_exists=True checksum_match=True thumb_exists=True
|
||||
doc 5 Project Overtime 2023.11 original_exists=True checksum_match=True thumb_exists=True
|
||||
doc 12 Hotel original_exists=True checksum_match=True archive_exists=True archive_match=True thumb_exists=True
|
||||
|
||||
=== CHECK 2: served over paperless's own HTTP API, hashing what the app returns ===
|
||||
doc 2 http=200 served_sha256=87226ff2…13244f == recorded before deletion
|
||||
doc 5 http=200 served_sha256=1395403a…f2f2df == recorded before deletion
|
||||
doc 12 http=200 served_sha256=1dd4cd16…dd924b == recorded before deletion (archive; /download/ serves the archive when one exists)
|
||||
```
|
||||
|
||||
**A1 verdict: PROVEN.** Byte-identical recovery, both non-destruction promises kept, app healthy
|
||||
and serving the restored bytes over its own API.
|
||||
|
||||
### A2 — Tier-1 local recovery-unit restore — **DISTINCT, and exercised**
|
||||
|
||||
**It is a distinct path**, not subsumed: `POST /backup/restore` (`stack_name` + `snapshot_id`) →
|
||||
`RestoreFromRecoveryUnit` (`internal/web/handlers.go:1230`, `internal/backup/restore_unit.go:96`).
|
||||
It reads the app's on-drive recovery unit, recovers secrets from the guest's live `app.yaml`,
|
||||
restores named-volume data from the unit's tars, and redeploys. Tier-2 copies are **not** reachable
|
||||
through it.
|
||||
|
||||
Exercised on `calibre-web` (demo-felhom), `A2-restore-status.txt` / `A2-log.txt`:
|
||||
|
||||
```
|
||||
15:28:34 [INFO] [backup] Restoring calibre-web from recovery unit: images=1, secrets recovered=0/0, data_keys=0
|
||||
15:28:39 [INFO] [backup] Restoring Docker volume calibre-web_calibre_web_config for calibre-web
|
||||
15:28:39 [INFO] [backup] Restored 1 Docker volume(s) for calibre-web
|
||||
15:28:52 [INFO] [backup] Restore-from-unit completed: calibre-web (18.25s)
|
||||
```
|
||||
|
||||
App healthy 29 s later; volume fingerprint unchanged (`6b10ec90…`), consistent with restoring a tar
|
||||
that matched current content.
|
||||
|
||||
**Honest limit:** this run proves the *path executes and the app survives it*. It does **not** prove
|
||||
content recovery, because nothing was lost first. The content-recovery proof is A3.
|
||||
|
||||
**One thing checked and deliberately NOT reported as a finding.** `snapshot_id` is required by the
|
||||
handler and then never passed to `RestoreFromRecoveryUnit`, while the success flash names it —
|
||||
which looks like "you picked A and got B". It is neither: `restore_points.go:14-18` states there is
|
||||
exactly one keep-side restore point and that `snapshot_id` is logging-only, and the live API
|
||||
confirms exactly one point per app with `short_id: "helyi"` — the value the flash prints. Correct by
|
||||
design, pinned by its own comment. **ARTIFACT of reading, not a defect.**
|
||||
|
||||
### A3 — restore after REAL loss — **PROVEN**
|
||||
|
||||
The harder shape: not gap-fill, but deleting the app's data directory **entirely**, while the app
|
||||
ran, as an "I deleted my files" event actually looks.
|
||||
|
||||
```
|
||||
=== BEFORE: paperless serves doc 12 === source_exists True
|
||||
=== DESTROYING /mnt/felhom-drives/nvme-1tb/appdata/paperless ===
|
||||
exists after rm: NO
|
||||
=== AFTER === source_exists False
|
||||
doc12_download_http=404
|
||||
=== doc count in DB is untouched === 16
|
||||
```
|
||||
|
||||
The loss was verified with a **positive observable** — the document download going 200 → **404** —
|
||||
not merely by the directory being absent.
|
||||
|
||||
**Tier used: Tier-2**, via the same customer button. Result
|
||||
(`A3-restore-status.txt`, `A3-verify.txt`):
|
||||
|
||||
```
|
||||
message: "paperless-ngx: 43 fájl visszaállítva a másodlagos másolatból." (46 s)
|
||||
|
||||
=== 1. every restored file byte-identical to the Tier-2 copy? ===
|
||||
copy files: 43 / live files: 43
|
||||
IDENTICAL — every file in the copy is back, byte-for-byte, and nothing extra
|
||||
|
||||
=== 2. does PAPERLESS work? ===
|
||||
documents_ok 16 of 16
|
||||
problems []
|
||||
|
||||
=== 3. serve doc 12 over HTTP again (the observable that was 404 after the loss) ===
|
||||
doc 2 http=200 sha256=87226ff2…13244f
|
||||
doc 5 http=200 sha256=1395403a…f2f2df
|
||||
doc 12 http=200 sha256=1dd4cd16…dd924b
|
||||
```
|
||||
|
||||
**A3 verdict: PROVEN — the customer gets their app back, not "only some files".**
|
||||
|
||||
**The honest residue, stated plainly.** Three things did not come back, and none of them is a
|
||||
defect — each is the correct consequence of not being in the last successful copy:
|
||||
|
||||
- the two files created *after* the last Tier-2 run — never backed up, correctly gone;
|
||||
- the local edit to `thumbnails/0000001.webp` — the file returned in its **pristine** backed-up
|
||||
form (`fd9b92dd…`), i.e. the post-loss restore legitimately reverted an unbacked-up edit. This is
|
||||
the same file that A1 proved is *never overwritten* while it still exists; the two results are
|
||||
consistent and together delimit the guarantee exactly: **existing files are untouched; destroyed
|
||||
files return at their last-backup state.**
|
||||
- `appdata/paperless/export/` — absent from the copy, recreated empty by paperless on startup.
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings
|
||||
|
||||
### F-1 — the restore button reports success for apps it can never restore — **DEFECT, HIGH**
|
||||
|
||||
**Applies to:** every app whose data lives entirely in Docker named volumes and which therefore has
|
||||
no bind-mounted user-file leg. On demo-felhom that is **BookStack and Docmost** — 2 of the 4 apps
|
||||
with a Tier-2 copy, i.e. half the fleet's Tier-2-configured apps.
|
||||
|
||||
**Action.** Clicked „Fájlok visszaállítása" for `bookstack` on demo-felhom via the real endpoint.
|
||||
|
||||
**Observation** (`A1b-bookstack-post.txt`, `A1b-bookstack-log.txt`):
|
||||
|
||||
```
|
||||
15:26:05 [WARN] [web] Tier-2 file restore requested (async): stack=bookstack
|
||||
15:26:05 [INFO] [backup] Tier-2 file restore for bookstack: …/secondary/bookstack (v2) → /mnt/sys_drive/felhom-data (additive-only)
|
||||
15:26:28 [INFO] [backup] Tier-2 file restore completed for bookstack: 0 file(s) restored (19s)
|
||||
|
||||
restore-status: {"ok":true, … "message":"Nincs hiányzó fájl — minden fájl megvan a helyén."}
|
||||
|
||||
BEFORE: bookstack|Up 4 hours (healthy)
|
||||
AFTER: bookstack|Up 25 seconds (healthy) <- a real outage was taken
|
||||
```
|
||||
|
||||
And the copy the button claims to restore from is **not empty** — it holds the app's entire data:
|
||||
|
||||
```
|
||||
16K …/secondary/bookstack/recovery-unit/compose
|
||||
60K …/secondary/bookstack/recovery-unit/db-dumps
|
||||
156M …/secondary/bookstack/recovery-unit/volume-dumps <- BookStack's actual data
|
||||
4.0K …/secondary/bookstack/recovery-unit/manifest.json
|
||||
|
||||
ls …/secondary/bookstack/ -> recovery-unit (no hdd/, no userdata/)
|
||||
```
|
||||
|
||||
**Why it can never succeed.** `RestoreTier2Files` merges exactly two subtrees —
|
||||
`destBase/hdd` → `liveNsRoot` and `destBase/userdata` → `liveNsRoot/userdata`
|
||||
(`internal/backup/tier2_restore.go:101-104`) — and `os.Stat`-skips each when absent
|
||||
(`:114-116`). `recovery-unit/` is never read by this path. For an app with no bind legs,
|
||||
`tier2CaptureSet` correctly yields **zero legs** (`internal/backup/tier2_capture.go:42-91`), so
|
||||
neither subtree is ever written. The loop therefore restores 0 files **on every click, forever**,
|
||||
and `backupTier2RestoreHandler` maps `n == 0` to the reassuring message
|
||||
(`internal/web/handlers.go:1313-1316`).
|
||||
|
||||
**Expectation.** Either the button is not offered when the app's Tier-2 copy contains no restorable
|
||||
file leg, or the message distinguishes "nothing was missing" from "this copy holds no restorable
|
||||
files for this app". The customer must not be told their files are all in place by a code path that
|
||||
never looked at where their files live.
|
||||
|
||||
**Severity: HIGH.** This is the customer's own recovery action. The failure mode is the project's
|
||||
own documented trap class twice over: the message is a **presence-not-success** claim (0 files
|
||||
copied is read as "nothing was missing"), and it is **unfalsifiable** — identical output whether the
|
||||
app is pristine or has lost everything. It is also the exact shape of the six shipped-invariant
|
||||
comments in the workspace `CLAUDE.md`: `tier2_restore.go:14-24` scopes the helper to "class-C data —
|
||||
HDD bind-mount user files", which is true of the *helper* and silently false of the *button* the UI
|
||||
renders on top of it. No test pins the UI-level claim.
|
||||
|
||||
**DEFECT.** Not an artifact: no cadence was compressed and no state was manufactured — BookStack
|
||||
was in its normal, healthy, production configuration.
|
||||
|
||||
---
|
||||
|
||||
## 4. What came off the never-proven list
|
||||
|
||||
| item | status |
|
||||
|---|---|
|
||||
| Tier-2 cross-drive restore of **ordinary app data** (was: only R-7b share definitions, 2026-07-18) | **PROVEN** (A1) |
|
||||
| Tier-2 „existing files are not modified or deleted" promise | **PROVEN** (A1, both halves) |
|
||||
| Tier-2 restore after **total loss** of an app data directory | **PROVEN** (A3) |
|
||||
| Restored data **usable by the app** rather than present on disk | **PROVEN** (A1/A3, via paperless's own API) |
|
||||
| Tier-1 recovery-unit restore is a **distinct** path | **ESTABLISHED**; path exercised end-to-end (A2) |
|
||||
| Tier-1 **content recovery** after real loss | **NOT PROVEN** — A2 ran against an intact app; A3 used Tier-2 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase B — do the six recent fixes interact?
|
||||
|
||||
### Setup, and what is therefore an ARTIFACT
|
||||
|
||||
| knob | real | campaign | box |
|
||||
|---|---|---|---|
|
||||
| local (vzdump) cadence | `0` ⇒ 24 h | 900 s | both |
|
||||
| PBS tier cadence | 604800 s (7 d) | 900 s | both |
|
||||
| restore-test cadence | 302400 s (84 h) | 120 s (demo-hp) / 3600 s (demo-felhom) | both |
|
||||
| `backup_window_start` | 02:30 / unset | 14:00 ⇒ gate [16:00, 20:00) | both |
|
||||
| PBS reachability | up | `iptables -j REJECT` on `10.77.0.1:8007` | both |
|
||||
|
||||
**Everything about FREQUENCY below is an artifact of that compression.** What is not an artifact is
|
||||
the ORDER of events, which mechanism fired, and what the operator was told.
|
||||
|
||||
### B3 — both tiers due, one succeeds, one fails — **COHERENT**
|
||||
|
||||
This arose on the very first cycle after the gate opened (`B3-mixed-outcome-cycle1.txt`):
|
||||
|
||||
```
|
||||
15:42:58 [quiesce] backup due on 2 tier(s) — quiescing 1 stack(s): [paperless-ngx]
|
||||
15:43:05 [quiesce] tier local: backup job …006689159 started — polling
|
||||
15:44:25 [quiesce] tier local: backup job …006689159 done — next tier may start (app still quiesced)
|
||||
15:44:25 [quiesce] tier felhom-pbs: backup job …020207536 started — polling
|
||||
15:44:35 [quiesce] unquiescing (backup failed): restarting 1 stack(s)
|
||||
15:44:50 [quiesce] tier felhom-pbs: backup job …020207536 failed
|
||||
15:44:50 [quiesce] tier felhom-pbs has now failed 1 time(s) in a row — deferring its next quiesce
|
||||
by 15m0s (cap 4h0m0s) so the apps are not stopped again for a backup that cannot succeed
|
||||
15:44:50 Event pushed: whole_guest_backup_failed (error) — … (next attempt in 15m0s)
|
||||
```
|
||||
|
||||
- **R-82's one-quiesce rule holds under mixed outcomes**: two due tiers, apps stopped **once**, both
|
||||
tiers attempted inside the single window.
|
||||
- **The breaker is per-target as designed**: `local` succeeded and was untouched; only `felhom-pbs`
|
||||
armed. A broken offsite tier did not suppress a healthy local one.
|
||||
- **Exactly ONE event**, edge-triggered at the first failure, naming the tier, the reason and the
|
||||
retry interval. The hub received exactly one row (`id 1915`) — no duplicates.
|
||||
- App downtime was ~97 s and paperless came back healthy.
|
||||
|
||||
**Operator picture: coherent.** One event, one cause, one next step.
|
||||
|
||||
### F-CRIT-2's size filter, confirmed live against REAL damage — **not manufactured**
|
||||
|
||||
Unprompted, before any Phase B injection, the demo-hp agent logged:
|
||||
|
||||
```
|
||||
17:37:59 WARN backup: ignoring an INCOMPLETE archive when computing tier freshness — it is not a
|
||||
successful backup target=felhom-pbs vmid=9201 volid=felhom-pbs:backup/ct/9201/2026-07-28T05:31:14Z
|
||||
size_bytes=1 reason="size 1 B is below the 1048576 B plausibility floor"
|
||||
```
|
||||
|
||||
Independently confirmed on ep0's filesystem — the snapshot is a genuine aborted archive:
|
||||
|
||||
```
|
||||
/mnt/pbs-datastore/ns/demo-hp/ct/9201/2026-07-28T05:31:14Z/
|
||||
catalog.pcat1.tmp_didx 0 bytes
|
||||
root.pxar.tmp_didx 0 bytes <- no manifest, no index.json.blob
|
||||
pct.conf.blob 425 bytes
|
||||
```
|
||||
|
||||
This is F-CRIT-2 doing exactly its job on a real leftover phantom, in production, without being
|
||||
asked. **PROVEN, not manufactured.**
|
||||
|
||||
### Incidental proof: the local whole-guest archive restores and boots
|
||||
|
||||
The restore-test that ran at 17:47:32 selected the local vzdump written by the B3 cycle minutes
|
||||
earlier, restored it into scratch guest `990000`, booted it, and tore it down:
|
||||
|
||||
```
|
||||
17:49:01 restore-test: scratch guest torn down vmid=990000
|
||||
17:49:01 backup: scheduled restore-test passed archive=local:backup/vzdump-lxc-9201-2026_07_28-17_43_05.tar.zst duration_s=89.19
|
||||
```
|
||||
|
||||
`pct list` afterwards shows no `990000` guest — **F-LEAK's fenced destroy is holding.**
|
||||
|
||||
### B1 — contention, then the breaker — **COHERENT; the contention path does NOT swallow the transition**
|
||||
|
||||
Forced by tightening the restore-test cadence to 120 s against its ~89 s duration, so the R-85 gate
|
||||
is held most of the time. Both sides of the seam were captured in the same second:
|
||||
|
||||
Agent (`agent-demo-hp.log`):
|
||||
|
||||
```
|
||||
18:03:09 local-api: backup refused — a heavy operation is already in flight vmid=9201 requested_target=local busy=restore-test
|
||||
18:03:09 local-api: backup refused — a heavy operation is already in flight vmid=9201 requested_target=felhom-pbs busy=restore-test
|
||||
```
|
||||
|
||||
Controller (`ctrl-demo-hp.log`):
|
||||
|
||||
```
|
||||
16:02:58 [quiesce] backup due on 2 tier(s) — quiescing 1 stack(s): [paperless-ngx]
|
||||
16:03:09 [quiesce] tier local is BUSY — the agent refused the backup because a concurrent heavy
|
||||
operation holds it. This is contention, NOT a failure: the tier stays due and retries in 15m0s
|
||||
16:03:09 [quiesce] tier felhom-pbs is BUSY — … (contended for 0s)
|
||||
16:03:09 [quiesce] unquiescing (last tier is busy — deferring to a later cycle): restarting 1 stack(s)
|
||||
```
|
||||
|
||||
- **No breaker armed and NO event pushed for the contention** — F-A1's whole point, confirmed.
|
||||
- The app was **restarted immediately** rather than left down for the restore-test's duration —
|
||||
the second trap F-A1's comment names ("app thrash") is avoided.
|
||||
- Note `felhom-pbs` was contended in a cycle where it would certainly have *failed* (its path is
|
||||
blocked). The 409 precedes the attempt, so contention wins that cycle and the tier stays due —
|
||||
correct, and it does **not** reset the breaker: `noteTierContention` never touches breaker state,
|
||||
and the earlier armed failure count survives.
|
||||
|
||||
**Operator picture: coherent, and correctly silent.** Contention produced no operator noise at all,
|
||||
while the genuine failure minutes earlier produced exactly one event.
|
||||
|
||||
### The breaker's retry discipline, over four polls
|
||||
|
||||
```
|
||||
15:44:50 tier felhom-pbs has now failed 1 time(s) in a row — deferring … by 15m0s (cap 4h0m0s)
|
||||
15:47:59 [DEBUG] tier felhom-pbs is in backoff after 1 consecutive failure(s) — not quiescing until 2026-07-28T15:59:50Z
|
||||
15:52:58 [DEBUG] … not quiescing until 2026-07-28T15:59:50Z
|
||||
15:58:00 [DEBUG] … not quiescing until 2026-07-28T15:59:50Z
|
||||
```
|
||||
|
||||
Three skipped polls, **one** WARN when the breaker armed, **DEBUG** for each skip, **one** event.
|
||||
The apps were not stopped once during the backoff. This is the exact behaviour R-88 was built for,
|
||||
and the pre-R-88 bug (a stop/start cycle every 5 minutes forever) did not recur.
|
||||
|
||||
### Both boxes independently, same result
|
||||
|
||||
demo-felhom armed its own breaker at 16:00:00 with its own single event
|
||||
(`whole_guest_backup_failed`, hub id 1916), against demo-hp's id 1915. Per-box and per-target
|
||||
isolation both hold.
|
||||
|
||||
### B4 / B5 — and what they turned up
|
||||
|
||||
The demo-felhom rig (docmost made unstartable, PBS blocked, cadences compressed) produced a finding
|
||||
rather than a clean pass — see **F-2** below. R-100's success anchor was confirmed correct along the
|
||||
way: after the induced offsite failure on demo-hp, `last_run` advanced to the failing run while
|
||||
`last_success` **stayed** at the last genuine success:
|
||||
|
||||
```
|
||||
"last_run": "2026-07-28T16:01:56Z",
|
||||
"last_status": "error",
|
||||
"last_success": "2026-07-28T16:00:18Z",
|
||||
```
|
||||
|
||||
That is precisely R-100's contract — a failure no longer refreshes the staleness clock.
|
||||
|
||||
### Not reached
|
||||
|
||||
B2's *combined* form (a phantom created during a quiesce that ALSO fails to restart an app) and B4's
|
||||
full form (a stale anchor while the breaker is backed off, observed as one operator picture) were not
|
||||
reached. Both need a longer run than this session had.
|
||||
|
||||
---
|
||||
|
||||
## 6. Findings
|
||||
|
||||
### F-2 — an app in a crash loop never alarms, on any channel — **DEFECT, HIGH**
|
||||
|
||||
This is F-CRIT-1's exact shape in a state F-CRIT-1's fix does not cover. Campaign 8 closed
|
||||
"an app that fails to restart after a quiesce never alarms" for `StateStopped`. A **crash-looping**
|
||||
app is not `StateStopped`, and nothing sees it.
|
||||
|
||||
**Action.** On demo-felhom, `docmost/docmost:0.95.0` was retagged to an image that exits immediately,
|
||||
and the stack was started. (The first attempt — editing the compose image tag — was reverted by the
|
||||
catalog sync within 3 minutes, which is how O-1 below was found.)
|
||||
|
||||
**Observation.** docmost crash-looped continuously. Sampled every 10 s through the controller's own
|
||||
`/api/stacks` — i.e. exactly the value the alarm logic reads, not `docker ps`:
|
||||
|
||||
```
|
||||
16:05:53 docmost_state=restarting restartcount=14
|
||||
16:06:24 docmost_state=restarting restartcount=15
|
||||
16:07:04 docmost_state=restarting restartcount=15
|
||||
16:08:05 docmost_state=restarting restartcount=16
|
||||
16:08:25 docmost_state=restarting restartcount=16 … unbroken for the whole observation
|
||||
```
|
||||
|
||||
The restart policy makes this **permanent**, not transient:
|
||||
|
||||
```
|
||||
docker inspect docmost -> unless-stopped maxretry=0 restartcount=16
|
||||
```
|
||||
|
||||
And across that entire period:
|
||||
|
||||
```
|
||||
dashboard page mentions of "docmost": 0
|
||||
dashboard warning words (Hiba|Figyel|nem fut): 0
|
||||
app_start_failed events pushed: 0
|
||||
hub events for demo-felhom: only whole_guest_backup_failed (the tier, not the app)
|
||||
```
|
||||
|
||||
**The decisive evidence is the detector's own heartbeat.** F-OBS (v0.180.0) exists so that a stalled
|
||||
dead-app check cannot hide behind silence — it prints a POSITIVE observable every ~10 minutes. Here
|
||||
it proves the opposite of what it was built to reassure about: the detector is **alive, running, and
|
||||
blind**.
|
||||
|
||||
```
|
||||
15:59:17 [deadapp] check alive: 160 scans since boot, 4 deployed app(s) evaluated, 0 currently down
|
||||
16:09:17 [deadapp] check alive: 180 scans since boot, 4 deployed app(s) evaluated, 0 currently down
|
||||
^^^^^^^^^^^^^^^^^
|
||||
docmost at 16:09:17: status=restarting restartcount=18 policy=unless-stopped
|
||||
```
|
||||
|
||||
Twenty scans ran between those two lines. Every one of them looked at a permanently crash-looping
|
||||
app and counted **0 down**. This is not an absent signal being read as health — it is the health
|
||||
signal itself, positively asserting that nothing is wrong.
|
||||
|
||||
**The causal chain, pinned to the code.**
|
||||
|
||||
1. `aggregateState` (`internal/stacks/manager.go:753-756`) — *"Any restarting → stack is restarting"*.
|
||||
This test runs **before** the partial/degraded logic, so one crash-looping container plus two
|
||||
healthy ones yields `StateRestarting`, never `StateDegraded`.
|
||||
2. `IsDownState` (`internal/stacks/manager.go:54-56`) is
|
||||
`StateStopped || StateExited || StateDegraded` — **`StateRestarting` is absent**.
|
||||
3. `classifyRunStates` (`cmd/controller/main.go:1292-1294`) computes
|
||||
`down := stacks.IsDownState(st.State) && !userStopped && !quiesced[…]` → **false**.
|
||||
|
||||
So the app never enters `dead` (no dashboard banner) and never enters `states` as `Down` (no
|
||||
`app_start_failed`, hence no customer email and no hub event). There is **no crash-loop detector
|
||||
anywhere in the controller** — `RestartCount` is read in exactly one unrelated place
|
||||
(`internal/web/intermediary.go:105,343`), and `StateRestarting` appears in no alarm path.
|
||||
|
||||
**Why this is not covered by the existing suppression logic.** R-97b's grace window is irrelevant
|
||||
here: it expired at 16:01:56 and the app was still silent minutes later. The failure is not a
|
||||
suppression that fails to expire — it is a state that is never considered "down" in the first place.
|
||||
|
||||
**Expectation.** A container that Docker is restarting indefinitely is a dead app. Either
|
||||
`StateRestarting` belongs in the down set (with a debounce so a normal restart does not alarm), or
|
||||
the restart count must be watched. As written, `docker restart: unless-stopped` — the catalog's
|
||||
standard policy — converts every crash into permanent silence.
|
||||
|
||||
**Severity HIGH.** It is the same class the project has now shipped twice (F-CRIT-1, and the
|
||||
`classifyRunStates` I1 comment in the workspace `CLAUDE.md` table), it affects the most common
|
||||
real-world app failure mode, and it is *indefinitely* silent rather than delayed.
|
||||
|
||||
**DEFECT.** The trigger (a retagged image) was manufactured; **crash loops are not** — a bad image,
|
||||
a failed migration, a corrupt config or a repeatedly OOM-killed process all produce exactly this
|
||||
state, and no cadence was compressed to make it happen.
|
||||
|
||||
---
|
||||
|
||||
### O-1 — recovery-unit capture does not validate, but the catalog sync bounds the damage — **OBSERVATION, not a defect**
|
||||
|
||||
**This was very nearly filed as a MEDIUM defect. The live system corrected me, and the correction is
|
||||
the more useful result.**
|
||||
|
||||
What was observed first. The docmost compose was edited to an unpullable image tag; the controller's
|
||||
periodic refresh captured the recovery unit **7 minutes later** with no validation, so the live app
|
||||
and its only restore point disagreed — and the restore point was the broken one:
|
||||
|
||||
```
|
||||
15:50:07 [INFO] [backup] Recovery unit captured for docmost → …/backups/primary/docmost (images=3, …)
|
||||
live: docmost|docmost/docmost:0.95.0|Up 4 hours (healthy)
|
||||
manifest: ['docmost/docmost:0.95.0-campaign9-nonexistent', …]
|
||||
```
|
||||
|
||||
That looked serious, because `ListRestorePoints` returns **exactly one** keep-side restore point per
|
||||
app (`restore_points.go:14-18`, confirmed live) — an unvalidated refresh replaces the only one there
|
||||
is. `CaptureRecoveryUnit` (`recovery_unit.go:78-130`) does indeed gate its write on a config
|
||||
*checksum change* alone: no run-state check, no health check, no test that the pinned images resolve.
|
||||
|
||||
**What actually happened next.** Three minutes later the catalog git-sync reverted the drift on its
|
||||
own, and the recovery unit followed within the same pass:
|
||||
|
||||
```
|
||||
15:53:17 [INFO] [sync] Updated docmost/docker-compose.yml
|
||||
15:53:17 [INFO] [sync] Periodic sync: Sablonok frissítve — frissítve: docmost
|
||||
15:53:17 [INFO] [backup] Recovery unit captured for docmost → …/backups/primary/docmost (images=3, …)
|
||||
```
|
||||
|
||||
Verified afterwards on all three surfaces:
|
||||
|
||||
```
|
||||
live compose image: image: docmost/docmost:0.95.0
|
||||
Tier-1 recovery unit pins: ['docmost/docmost:0.95.0', 'postgres:16-alpine', 'redis:7-alpine']
|
||||
Tier-2 copy's unit pins: ['docmost/docmost:0.95.0', 'postgres:16-alpine', 'redis:7-alpine']
|
||||
```
|
||||
|
||||
**Conclusion.** Compose drift is self-healing and the restore point follows it, bounded by the sync
|
||||
interval — the poisoned window here was **~3 minutes**. The system does not leave you in the state I
|
||||
manufactured, so **reporting this as a defect would have been reporting an artifact.** The absence
|
||||
of validation in `CaptureRecoveryUnit` is real, but it is compensated, and the compensating control
|
||||
was proven live rather than assumed.
|
||||
|
||||
**The one residue worth an operator's attention** — narrow, and NOT demonstrated here: the sync makes
|
||||
the *catalog* the source of truth, so a bad image tag **in the catalog** would propagate to every
|
||||
box's only restore point unhealed, because sync would be restoring it rather than reverting it. That
|
||||
is a catalog-quality concern owned by the catalog gates, not a controller defect.
|
||||
|
||||
### F-3 — an interrupted offsite run leaves a lock the self-heal cannot reach, reported as "unknown" — **DEFECT, MEDIUM**
|
||||
|
||||
**This answers Phase C item 8** ("is the repo usable after a killed run, or does it need an
|
||||
unlock?"). It was found by accident — the campaign severed its own in-flight run — which makes it a
|
||||
genuine interruption rather than a constructed one.
|
||||
|
||||
**Sequence.** An offsite run started at 15:58:12 and took the repo's exclusive lock at 15:59:00. At
|
||||
15:59:33 its transport was severed. The run died holding the lock. Every later run then failed:
|
||||
|
||||
```
|
||||
16:15:48 [offbox] pre-push dump leg completed in 20.056s — snapshot pair is coherent
|
||||
16:15:50 [ERROR] [offbox] backup failed after 23s: offbox repo unreachable: exit status 1:
|
||||
unable to create lock in backend: repository is already locked exclusively by PID 14328 on demo-hp by root
|
||||
lock was created at 2026-07-28 15:59:00 (16m49s ago)
|
||||
the `unlock` command can be used to remove stale locks
|
||||
```
|
||||
|
||||
**The remedy exists in the code and was never reached.** `resticStep`
|
||||
(`internal/backup/offbox.go:634-648`) detects `repository is already locked`, escalates to
|
||||
`unlock --remove-all`, and retries once — its comment states plainly that this is required because
|
||||
"the recreated container has a new hostname, so restic can't verify the dead PID and won't treat the
|
||||
lock as stale for ~30 min (the overnight-campaign C2 finding)".
|
||||
|
||||
But the failure happens **before** any `resticStep` call. `ensureOffboxRepo`
|
||||
(`offbox.go:676-696`) probes the repo first; that probe hits the lock, `classifyResticProbe`
|
||||
(`offbox.go:77-93`) has no case for a lock error so it returns `"other"`, and the default branch
|
||||
returns `offbox repo unreachable: …` — a fail-fast with **no unlock escalation**. The self-heal sits
|
||||
behind a gate the failure never opens.
|
||||
|
||||
**And the operator is told the wrong thing.** `ClassifyOffsiteFailure` likewise has no lock case, so
|
||||
it falls through to `OffsiteFailUnknown`. Hub event 1919:
|
||||
|
||||
```
|
||||
"A távoli mentés ismeretlen okból nem sikerült (23s): offbox repo unreachable: … repository is
|
||||
already locked exclusively by PID 14328 … the `unlock` command can be used to remove stale locks"
|
||||
```
|
||||
|
||||
"The remote backup failed **for an unknown reason**" — for a condition that is precisely known,
|
||||
named in the attached detail, and self-healable by code already in the binary.
|
||||
|
||||
**Impact.** The offsite tier stays **dead until a human intervenes**. Verified: it took a manual
|
||||
`restic unlock --remove-all` to restore it (`evidence/phaseC/C8-unlock.txt`).
|
||||
|
||||
**Reachability is ordinary, not exotic.** Any interruption of an in-flight run leaves this lock — a
|
||||
controller container restart, an OOM, a network drop, or **a host reboot mid-backup, which is
|
||||
Phase C item 6's scenario**. The code's own comment describes exactly this ("the C2 crash lock").
|
||||
|
||||
**Severity MEDIUM, argued rather than asserted.** Against HIGH: an error event *is* raised
|
||||
immediately on the operator channel, its detail literally contains the remedy, and R-100's 48 h
|
||||
`offsite_stale` is a second backstop, so this is loud-but-mislabelled rather than silent — unlike
|
||||
F-1 and F-2. Against LOW: a backup tier is down until manual intervention, and the headline actively
|
||||
misdirects triage.
|
||||
|
||||
**DEFECT.** No cadence compression was involved in the mechanism; only the interruption was
|
||||
deliberate.
|
||||
|
||||
---
|
||||
|
||||
### O-2 — a hand-run `docker compose up -d` starts a Felhom app WITHOUT its secrets — **OPERATIONAL, worth a runbook line**
|
||||
|
||||
Encountered during recovery, by me. Restoring docmost with a plain
|
||||
`cd /opt/docker/stacks/docmost && docker compose up -d` produced a container that crash-looped on:
|
||||
|
||||
```
|
||||
{"minLength":"APP_SECRET must be longer than or equal to 32 characters","isNotEmpty":"APP_SECRET should not be empty"}
|
||||
```
|
||||
|
||||
Secrets are injected by the controller's `stackEnv` at start time (`StartStack`,
|
||||
`internal/stacks/manager.go:912-948`); they are **not** in a `.env` file beside the compose. A manual
|
||||
`up -d` therefore recreates the container with empty secrets — turning a healthy app into a broken
|
||||
one, and looking exactly like the fault you were trying to fix. Recovery must go through the
|
||||
controller (`POST /api/stacks/<name>/restart`), which is what finally fixed it.
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase C / D / E — reached, and not reached
|
||||
|
||||
| # | item | status |
|
||||
|---|---|---|
|
||||
| C6 | host reboot mid-backup | **NOT REACHED.** Approved by the operator; not attempted. Its most likely consequence was nonetheless observed by another route — see F-3, which is exactly the state a reboot mid-backup would leave. |
|
||||
| C7 | three-way concurrency (backup + restore-test + GC) | **PARTIALLY.** Backup-vs-restore-test contention was proven (B1); GC was not added as a third party. |
|
||||
| C8 | restic transport interrupted mid-run — is the repo usable after? | **ANSWERED: no, it needs an unlock.** See **F-3**. |
|
||||
| D9 | Scenario C — never-succeeded Tier-2 on a throwaway app | **NOT REACHED.** No throwaway app was created. |
|
||||
| D10 | F-DIAG's failure classes + no credential/repo-URL/raw-error passthrough | **PROVEN for the surfaces that leave the box** — see below. |
|
||||
| D11 | R-100's threshold — see `offsite_stale` actually fire | **NOT REACHED**, and the approved method turned out not to exist — see below. |
|
||||
| E12 | F-HUB `SQLITE_BUSY` reproduction | **NOT REACHED** (explicitly lowest priority). |
|
||||
|
||||
### D10 — the sanitiser, proven on the exact case that defeated its first version
|
||||
|
||||
Six distinct causes map to six distinct Hungarian messages (`offbox.go:201-208`). The transport case
|
||||
was induced live by blocking port 23 from inside the guest, and the resulting hub event's
|
||||
`details_json` was:
|
||||
|
||||
```
|
||||
"A távoli tárhely nem érhető el (hálózat vagy bejelentkezés) (51s): offbox repo unreachable:
|
||||
exit status 1: subprocess ssh: ssh: connect to host <repo> port 23: Network is unreachable
|
||||
Fatal: unable to open repository at <repo> unable to start the sftp session, error: EOF"
|
||||
```
|
||||
|
||||
- the correct distinct class, not a generic string;
|
||||
- the **bare hostname** — the precise shape the sanitiser's first version leaked on, per its own
|
||||
comment (`offbox.go:170-181`) — is replaced with `<repo>`;
|
||||
- the full `sftp:user@host:/path` repo URL is replaced with `<repo>`.
|
||||
|
||||
**Nothing raw leaves the box.** The hub *report* carries no error text at all: `OffboxReportStatus`
|
||||
(`offbox.go:1149-1174`) has no error field by construction.
|
||||
|
||||
**One precise caveat, not a finding.** The `last_error` persisted in the guest's `settings.json` is
|
||||
raw, and the customer's own `/backups/remote` page renders it verbatim under „Utolsó hiba:",
|
||||
including the bare hostname and the `sftp:` URL. That is local-only and discloses nothing to that
|
||||
viewer: the same host, user and repo path are displayed two rows above as *configuration* on the
|
||||
same page. Sanitisation is applied on the paths that leave the box, which is where the contract puts
|
||||
it.
|
||||
|
||||
### D11 — the approved compression does not exist as a knob
|
||||
|
||||
Phase 0 approved compressing the hub's `staleAfter` for this item. It is **not configurable**:
|
||||
`cmd/hub/main.go:552` calls `monitor.NewOffsiteChecker(dataStore, 0, …)` and `0` selects the
|
||||
compile-time `defaultOffsiteStaleAfter = 48 * time.Hour` (`internal/monitor/offsite.go:41,60-62`).
|
||||
Compressing it would have required editing, building and deploying hub code — which this campaign is
|
||||
forbidden to do ("no production code ships", "no inline fixes"). **Reported rather than worked
|
||||
around**, per the standing rule that a recommendation not followed gets a line saying why.
|
||||
|
||||
The alternative that needs no code change — ageing the controller's reported `last_success` anchor
|
||||
past 48 h and letting the hub judge it at its real threshold — was identified but not executed.
|
||||
That is the recommended method for a future run.
|
||||
|
||||
Along the way R-100's producer half was confirmed correct twice, at real settings: after each
|
||||
induced failure `last_run` advanced while `last_success` held.
|
||||
|
||||
```
|
||||
"last_run": "2026-07-28T16:15:50Z",
|
||||
"last_status": "error",
|
||||
"last_success": "2026-07-28T16:00:18Z",
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Phase F — recovery
|
||||
|
||||
Full log: `evidence/PHASE-F-recovery.log`. Every revert was verified with a positive observable.
|
||||
|
||||
| reverted | verification |
|
||||
|---|---|
|
||||
| agent cadences, both hosts | `cadences restored -> 0 302400 [604800]`; `systemctl is-active` = `active` |
|
||||
| backup windows, both boxes | set back to `02:30` through the same endpoint that changed them |
|
||||
| PBS path block, both hosts | `pvesm status` shows `felhom-pbs … active` on both |
|
||||
| offsite transport block (guest) | ssh reaches the storage box again (host key exchanged) |
|
||||
| docmost broken image | real `0.95.0` re-pulled; **restarted via the controller** after O-2 |
|
||||
| stale restic lock | `unlock --remove-all`; `list locks` empty; 8 snapshots readable |
|
||||
| campaign scratch files | `0` remaining `c9-*` on both guests; host `/tmp` cleared |
|
||||
| collectors | all 7 stopped; `0` remaining remote tails |
|
||||
|
||||
**The offsite tier was not merely unblocked but proven working again** — a full run through the real
|
||||
endpoint after the unlock:
|
||||
|
||||
```
|
||||
"last_run": "2026-07-28T16:20:57Z",
|
||||
"last_status": "ok",
|
||||
"last_success": "2026-07-28T16:20:57Z",
|
||||
"last_duration": "1m35s",
|
||||
"snapshot_count": 8
|
||||
```
|
||||
|
||||
**Final fleet state — both boxes fully healthy:**
|
||||
|
||||
```
|
||||
demo-felhom : docmost + docmost-postgres + docmost-redis, immich (×4), calibre-web,
|
||||
bookstack (×2), filebrowser, samba, cloudflared, traefik — ALL healthy
|
||||
demo-hp : paperless (×3), filebrowser, samba, cloudflared, traefik — ALL healthy
|
||||
agents : active on both hosts
|
||||
iptables : 0 campaign9 rules on either host and in either guest
|
||||
scratch VMs : none in the 990000 band on either host
|
||||
ep0 : /srv/pbs-felhom rollback copy intact (13G); datastore 14% used
|
||||
peti-felhom : never touched
|
||||
```
|
||||
|
||||
**One benign residue, stated for completeness.** The in-memory R-88 breaker on each box still holds
|
||||
a `felhom-pbs` consecutive-failure count from the induced failures. Its `until` timestamps are long
|
||||
past, so it blocks nothing; the only effect is that a *next* failure would back off 30 m instead of
|
||||
15 m. It clears on the next successful whole-guest backup, and by design it also clears on any
|
||||
controller restart (`breaker.go` — deliberately not persisted). Nothing was done about it because
|
||||
clearing it would have meant taking another app outage for no benefit.
|
||||
|
||||
---
|
||||
|
||||
## 9. Where this stopped, and why
|
||||
|
||||
**Stopped at the end of Phase B, with Phase D item 10 additionally completed**, then Phase F.
|
||||
|
||||
Phase A — the campaign's stated headline and its whole justification — is complete and proven. Phase
|
||||
B produced two of its five combinations cleanly, plus a HIGH finding. Phase C item 8 was answered by
|
||||
an accident that turned out to be a genuine defect. Phase D item 10 is proven; D11's approved method
|
||||
did not exist.
|
||||
|
||||
Phase C item 6 (host reboot mid-backup) was deliberately **not started**. It is a large new fault
|
||||
class against two boxes that are remote until ~08-02, and beginning it would have meant either
|
||||
rushing it or leaving the fleet in an unknown state. The campaign's own rule — *stop cleanly at a
|
||||
phase boundary; a broken test node is fine, an unreported one is not* — made the choice: finish the
|
||||
recovery properly rather than start a fault I could not supervise to completion. F-3 partly covers
|
||||
its most likely consequence anyway, since an interrupted run is exactly what a reboot mid-backup
|
||||
produces.
|
||||
|
||||
---
|
||||
|
||||
## 10. Findings, ranked
|
||||
|
||||
| # | finding | severity | class |
|
||||
|---|---|---|---|
|
||||
| **F-1** | the Tier-2 restore button reports success for apps it can never restore (BookStack, Docmost — half the fleet's Tier-2 apps); takes a real outage; message is indistinguishable from a genuine "nothing was missing" | **HIGH** | DEFECT |
|
||||
| **F-2** | an app in a crash loop never alarms on any channel — `StateRestarting` is in no down-set, and the dead-app heartbeat positively reports "0 currently down" while it loops | **HIGH** | DEFECT |
|
||||
| **F-3** | an interrupted offsite run leaves an exclusive lock the existing self-heal cannot reach; tier dead until manual `unlock`; reported to the operator as "unknown reason" | **MEDIUM** | DEFECT |
|
||||
| O-1 | recovery-unit capture validates nothing, but the catalog sync reverts drift within ~3 min and the unit follows | — | OBSERVATION (nearly mis-filed as a defect) |
|
||||
| O-2 | a hand-run `docker compose up -d` starts a Felhom app without its secrets | — | OPERATIONAL |
|
||||
|
||||
**Mechanisms confirmed working, on live hardware:** R-82's one-quiesce rule under mixed outcomes;
|
||||
R-88's breaker (per-target, edge-triggered, one event, no app thrash); F-A1's contention deferral
|
||||
(no breaker, no event, prompt restart); F-CRIT-2's size filter (against a real 1-byte phantom, not a
|
||||
manufactured one); R-100's success anchor (twice); F-DIAG's sanitiser (on the exact bare-hostname
|
||||
case that defeated its first version); F-OBS's positive observable (which is what made F-2
|
||||
provable); F-LEAK's fenced destroy (no leaked scratch guests across ~10 restore-tests).
|
||||
|
||||
---
|
||||
|
||||
## 11. What came off the never-proven list — and the honest residue
|
||||
|
||||
**Off the list:**
|
||||
|
||||
- Tier-2 cross-drive restore of **ordinary app data** — the customer's actual button (A1)
|
||||
- Tier-2's "existing files are NOT modified or deleted" promise, both halves (A1)
|
||||
- Tier-2 restore after **total loss** of an app's data directory (A3)
|
||||
- Restored data **usable by the app**, proven through the app's own API (A1/A3)
|
||||
- Tier-1 recovery-unit restore is a **distinct** path, and it executes end-to-end (A2)
|
||||
- The six recent fixes **do** compose: R-82 + R-88 + F-A1 + F-CRIT-2 + R-100 produced one coherent
|
||||
operator story in every combination reached
|
||||
- Whether an interrupted restic run leaves a usable repo (C8 — it does not)
|
||||
|
||||
**Still not proven — the residue:**
|
||||
|
||||
- **Tier-1 content recovery after real loss.** A2 ran against an intact app; A3 used Tier-2. Nobody
|
||||
has yet destroyed an app's data and recovered it *from the recovery unit*. This is now the single
|
||||
most valuable unproven item.
|
||||
- **Host reboot mid-backup** (C6) — untested, and F-REBOOT proved the guest-reboot sibling was real.
|
||||
- **Three-way concurrency** with GC as the third party (C7).
|
||||
- **Scenario C** — a never-succeeded Tier-2, live (D9).
|
||||
- **`offsite_stale` actually firing** (D11) — never observed. Needs the anchor-ageing method above,
|
||||
not the `staleAfter` compression that was approved but does not exist.
|
||||
- **F-HUB** `SQLITE_BUSY` characterisation (E12).
|
||||
- **Whether F-1 and F-2 have siblings**: both are "a surface reports health it did not establish".
|
||||
Nothing in this campaign searched systematically for others.
|
||||
|
||||
@@ -28,6 +28,9 @@ State: `BLOCKED` · `READY` · `WAITING-ON-OPERATOR` · `WATCHING`. Every row ha
|
||||
| **F-A1** | ~~A restore-test in flight made a healthy backup report as FAILED (HTTP 409 read as a tier failure): breaker armed + operator emailed, on both boxes~~ | **SHIPPED + PROVEN-LIVE** (controller v0.179.0, 2026-07-28) | — | 409 → contention: tier stays DUE, dropped before anything stops (15m), and BLOCKED alarm if contention outlives the agent's 120m ceiling (3h). Hub DB: 409 → **0** operator emails, real failure → **1** | — |
|
||||
| **R-100** | ~~A restic offsite tier that fails every night never goes stale on the hub — `isStale` counted from `LastRun`, which the controller writes unconditionally on failure~~ | **SHIPPED + PROVEN-LIVE** (controller v0.181.0 + hub v0.80.0, 2026-07-28) | — | Anchored on a new `last_success`. **Severity corrected during Phase 0: this was NOT a silence** — `backup_failed` does fire nightly and reaches the operator (live DB: 5 sends). The real defect is **defeated defence in depth**: the hub-side *pull* net was anchored on a field the failing controller keeps refreshing, so it could not compensate for a lost *push* (cf. F-HUB). Live on demo-hp: induced failure → `last_run` advanced 11:25:48Z, `last_success` **held** 11:24:20Z; demo-felhom healthy → anchor advanced. Legacy degrade logged once per customer, live | — |
|
||||
| **R-101** | ~~Tier-2 `LastRun` is written on failure and rendered to the customer as „Legutóbbi másolat" — including in the restore confirm dialog~~ | **SHIPPED + PROVEN-LIVE** (controller v0.182.0, 2026-07-28) | — | `CrossDriveBackup.LastSuccess` + `SuccessTracked`; the dialog names the last **successful** copy and discloses a failed newest attempt. Legacy rows migrate truthfully on first touch (an `ok` row adopts its time; an `error` row seeds nothing) — without the marker all 7 fleet rows would have flipped to „Még nincs sikeres másolat" on deploy. **Part 2:** the three `record*` sites rebuilt the whole struct; replaced by `tier2Update` (copy-and-overlay, safe by construction) — the naive fix would have had `recordTier2Failure` CLEAR the anchor. Live on demo-hp, rendered dialog read in both states | — |
|
||||
| **C9-F1** | Tier-2 „Fájlok visszaállítása" is offered for apps whose Tier-2 copy has **no restorable file leg** (BookStack, Docmost = 2 of the 4 Tier-2 apps on demo-felhom). Clicking it stops the app, restores 0 files, and reports **„Nincs hiányzó fájl — minden fájl megvan a helyén."** — indistinguishable from a genuine "nothing was missing", on every click, forever, while 156 MB of that app's data sits unread in `recovery-unit/` in the same copy | **READY (HIGH)** | — | Either don't render the button when the copy has no `hdd/`+`userdata/` leg, or make the message distinguish "nothing missing" from "nothing restorable here". `tier2_restore.go:101-104` reads only those two subtrees; `tier2_restore_test.go:219` pins 0-files-as-success and explicitly accepts the outage; `tier2_honesty_test.go` Scenario D asserts the button MUST render for exactly BookStack's shape | CC |
|
||||
| **C9-F2** | An app in a **Docker crash loop never alarms on any channel** — no dashboard banner, no `app_start_failed`, no customer email, no hub event. `aggregateState` returns `StateRestarting` (`manager.go:753-756`, checked before the degraded branch) and `IsDownState` (`manager.go:54-56`) excludes it, so `classifyRunStates` computes `down=false`. With `restart: unless-stopped` this is **permanent** silence. F-CRIT-1's fix covers `StateStopped` only | **READY (HIGH)** | — | Add `StateRestarting` to the down set with a debounce, or watch `RestartCount`. Proven live on demo-felhom: 9+ min crash loop, restartcount 18, while the F-OBS heartbeat printed **"180 scans since boot, 4 deployed app(s) evaluated, 0 currently down"** | CC |
|
||||
| **C9-F3** | An **interrupted offsite run leaves an exclusive restic lock the self-heal cannot reach**: `resticStep` (`offbox.go:634-648`) has `unlock --remove-all`, but `ensureOffboxRepo`'s probe fails first, `classifyResticProbe` (`offbox.go:77-93`) has no lock case → `"other"` → fail-fast. Tier dead until a human unlocks; `ClassifyOffsiteFailure` likewise has no lock case so the operator is told **„A távoli mentés ismeretlen okból nem sikerült"** for a precisely-known, self-healable condition | **READY (MEDIUM)** | — | Add a lock case to both classifiers and let the probe path escalate to `unlock --remove-all`. Answers Phase C item 8: the repo is NOT usable after a killed run. Cleared manually this run; tier proven working again (`ok`, 1m35s). Reachable by any interruption — container restart, OOM, **host reboot mid-backup** | CC |
|
||||
| **F-DIAG** | ~~Four distinct offsite failure causes collapse into two operator-visible strings~~ | **SHIPPED** (controller v0.182.0, 2026-07-28) | — | `ClassifyOffsiteFailure` → quota / orphaned / no_repo / no_units / transport / **unknown**, each with its own Hungarian message. Unclassifiable says so rather than being folded into a neighbour. **Secrets:** the old message was a raw `err.Error()` passthrough carrying `sftp:<user>@<host>:<path>`; redaction is now by the target's **actual** host/user/path (a first regex-only attempt leaked on a bare hostname and its own test caught it). Unit-proven; **not** yet exercised by a live offsite failure of each class | — |
|
||||
| **F-OPS** | ~~A manual `pct restore` inherits the source guest's bind mounts — during a real DR, on a different host, under pressure~~ | **DOCUMENTED** (2026-07-28) | — | `documentation/runbooks/RUNBOOK-manual-guest-restore.md`: which `mpN` are volumes vs host binds, the `mp9` source-VMID trap (it can bind **another guest's bootstrap credentials**), strip-and-re-add before first boot, and a positive pre-start verification. Docs only by design — the agent already neutralises binds on its own restore paths, and a second implementation would drift | — |
|
||||
| **F-REBOOT** | ~~A guest rebooted during its backup does not come back — shutdown completes, start never happens, no self-heal; 9m47s total appliance outage with every alarm silent~~ | **SHIPPED + PROVEN-LIVE** (agent v0.107.0, 2026-07-28) | — | 60 s guest-power watchdog; `onboot` is the deliberate-stop discriminator (already the stale-lock path's, and what `pve-guests` consults), retry bounded 3x/1m-2m-4m then escalates once. Live on demo-hp: **120 s unattended** vs the incident's 587 s with a human; Scenario B proven (an `onboot:0` guest left stopped) | — |
|
||||
|
||||
Reference in New Issue
Block a user