Files
felhom.eu/documentation/audits/CAMPAIGN-nomercy-2026-07-06.md
T
admin 8e26cdc3a5 docs(audit): closure appendix — F2 closed, F4-UX fixed, healthcheck class fixed
Pilot-blockers bundle shipped + live-validated: agent v0.73.0 (F2 mount-role
fallback), controller v0.102.0 (async restore family, F4 re-adjudicated as a UX
bug not a restore bug), catalog healthcheck sweep. All three accepted live on the
demo node.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-06 20:43:52 +02:00

298 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CAMPAIGN — "No-mercy" unattended full-system test (2026-07-06)
> Unattended pilot-readiness stress + regression campaign on the demo node
> (felhom-pve / guest 9201), run overnight while the operator slept. **No production code
> changed.** Throwaway harness + evidence bundle live on DooPlex
> (`~/campaign-nomercy-2026-07-05/`), never committed. This document is the one sanctioned
> artifact.
>
> **Headline:** the platform's resilience and safety came out **strong** — every fault-injection
> and crash-recovery scenario passed, the destroy-and-restore DR path is proven sound, the soak ran
> 2.5 h with zero invariant breaks, and every safety gate held. The raw FAIL tally (25) is
> **inflated and misleading**: ~10 FAILs are collateral of a *single* real app finding
> (vaultwarden crash-loop), ~8 are the product **correctly refusing** an operation that the harness
> then mis-scored, and the rest are harness-calibration bugs (incl. the one that made the P9 finale
> read FAIL even though the restore itself works). The genuine product findings are a short list,
> all MEDIUM-or-below, plus one HIGH-interest app-config bug.
## Run header
| Item | Value |
|---|---|
| Date run | 2026-07-06, ~07:4012:13 CEST (P0 supervised 07:40; P1P10 detached 08:0912:13) |
| Host | felhom-pve (192.168.0.162), node demo-felhom |
| Guest | 9201 (demo-felhom), LAN 192.168.0.127 |
| Controller / Agent / Hub | **0.100.0 / 0.72.0 / 0.35.0** (== baselines; git heads af7ea0b / 26664d6 / 61f4898) |
| Auth mode | DISABLED by deployed config (no dashboard password → CSRF exempt); T-AUTH → XFAIL |
| Runner | on DooPlex (survives the host reboots/hard-reset); detached `setsid nohup`; journal-resumable; each test in an isolated subshell |
| RNG seed (soak) | 20260706 |
| Restore points | R0 `ct/9201/2026-07-06T05:40:07Z` (verify ok), R0.5 (P2), **R1 `ct/9201/2026-07-06T06:47:23Z` (verify ok)** |
## End state (operator's first-coffee summary)
- **Guest 9201 was destroyed and restored** during the finale. The automated restore hit a harness
bug (below); **CC recovered it manually from R1** — 9201 is **running**, controller **healthy**,
all three USB drives **re-bound**, and the full app stack is back. State is as of **R1**
(2026-07-06 06:47 UTC); anything written to guest volumes during P7/P8 is expected-lost (by DR
design — restore returns to snapshot state).
- **Only unhealthy container: `vaultwarden`** (crash-looping — see Finding F1). Everything else is
healthy: nextcloud, navidrome, gitea, filebrowser, cloudflared, paperless, mealie, calcom,
rallly, sparkyfitness, uptime-kuma, actualbudget, felhom-controller.
- **Notifications restored** to the original 11 events (email nagyfenyvesi.viktor@gmail.com,
cooldown 6 h) and verified. They were muted for the run and P10 could not un-mute (controller was
down mid-finale); CC restored them manually.
- **No chaos residue:** 0 leftover auto-revert timers, no disk fillers, no stray iptables DROP
rules, USB drives re-authorized (device letters re-enumerated sdb↔sdd after the unplug tests —
cosmetic; drives mount by stable name).
- **Password status:** unchanged (auth remained disabled per the operator's kickoff choice).
## Result tally (raw, then triaged)
| Status | Raw count |
|---|---|
| PASS | 24 |
| FAIL | 25 |
| XFAIL | 2 |
| SKIP | 4 |
**After triage the 25 FAILs decompose as:** ~10 *collateral* of Finding F1 (a single crash-looping
app failing health-embedded assertions), ~8 *harness-calibration* mis-scores (the product behaved
correctly), 34 *genuine findings*, and the P9/P10 pair caused by one harness bug (DR restore is
sound). Details below; the matrix marks each FAIL's class.
---
## Genuine findings (product — evidence packages, hypotheses labelled)
### F1 — `vaultwarden` crash-loops: app-email shim sets `SMTP_FROM` without `SMTP_HOST` — **HIGH interest**
- **Symptom:** vaultwarden container in `Restarting` throughout. Container log (verbatim):
*"Error loading config: Both SMTP_HOST and SMTP_FROM need to be set for email support without USE_SENDMAIL"*.
- **Repro:** deploy vaultwarden with `app_email.enabled=true` in settings (the demo's state). The
mailrelay/app-email integration injects `SMTP_FROM=…@felhom.eu` into the compose env but not
`SMTP_HOST` → vaultwarden refuses to start and crash-loops.
- **Blast radius (why the FAIL count looks bad):** vaultwarden is the highest-stakes app and is
embedded in many assertions, so its crash *collateral-failed*: T-DEPLOY-SET, T-CANARY-PLANT,
T-LIFECYCLE, T-REMOVE-REDEPLOY, T-BAK-FULL, T-RST-VW/TIER2 (skipped — no vw canary), T-FLT-DOCKERD,
and all four T-RBT-* reboot tests (their `assert_stack` waits on vaultwarden reaching health).
- **Hypothesis (labelled):** the app-email SMTP shim (`internal/mailrelay`, catalog vaultwarden
template) should inject **both** `SMTP_HOST` (the relay endpoint) and `SMTP_FROM`, or vaultwarden's
template should gate SMTP on both being present. Compare with other SMTP apps — likely affects any
app whose image hard-requires `SMTP_HOST` when `SMTP_FROM` is set. Cross-check against
`SPIKE-smtp-app-relay-2026-06-28.md`.
- **Not fixed** (report-only). This is the single most valuable finding of the run.
### F2 — `decommission (anyway)` on a user-data drive: agent returns HTTP 403 — **MEDIUM, verify**
- **Symptom:** `POST /api/storage/decommission {mode:"anyway"}` → controller 502 wrapping
`agentapi: POST /disks/decommission: HTTP 403`.
- **Question to resolve:** is the agent's 403 **correct gating** (decommission is operator-signed /
role-protected even for user-data) or a **real gap** (the customer-facing "decommission anyway"
flow can't complete)? The storage-authz redesign makes system/backup operator-only, but this was a
**user-data** drive, so a 403 here may be an over-broad gate. Evidence:
`evidence/T-STG-DECOM-ANYWAY/decom_anyway.txt`.
- **Note:** `T-STG-DECOM-MIGRATE` (409) is *not* this — it was a correct backup-in-progress refusal
(see harness note H3).
### F3 — Gitea-outage: catalog sync does not recover cleanly after connectivity is restored — **MEDIUM, verify**
- **Symptom:** with the Gitea IP blocked from the guest, `POST /api/sync` failed (expected/graceful,
controller stayed up — good); **after** unblocking, the follow-up sync did not return 2xx within
the test window. Evidence: `evidence/T-FLT-GITEA-OUT/sync_after.txt`.
- **Hypothesis (labelled):** either a sync backoff/cooldown longer than the test's post-unblock
wait, or a cached failure state not cleared on recovery. Contrast with **T-FLT-HUB-OUT which
PASSED** (hub report resumed after unblock). Worth confirming whether catalog sync self-heals on a
longer horizon.
### F4 — DB restore returned HTTP 000 — **LOW, likely transient/collateral**
- `T-RST-DB` (paperless DB restore) recorded HTTP 000 (connection failed) at that instant — most
likely a transient controller unavailability during the restore suite rather than a defect.
Re-run in the targeted P1P3 re-run (below) to confirm.
---
## Strong positive results (real signal — do not lose these under the FAIL count)
### Resilience / fault recovery — **6/6 PASS**, with measured recovery times
| Scenario | Result | Time-to-recover |
|---|---|---|
| Controller `kill -9` (real SIGKILL of the process, mid-backup) | PASS — restart policy recovered, API 200 | ~ (container-up) |
| Guest hard `pct stop` mid-backup (power-loss) | PASS — journal resume, canaries intact | 44 s |
| Agent `kill -9` on host | PASS — systemd restart, clean binds | 6 s |
| App crash-loop (DB dependency knocked over) | PASS — detected + alert + recovered on repair | 45 s |
| Cloudflared down → external down → recover | PASS — external tunnel returned | (self-heal) |
| Hub outage (iptables block, auto-revert) | PASS — controller kept serving, report resumed | — |
| dockerd restart reconverge | 320 s | — |
### Host reboot storms — **recovery WORKED** (the T-RBT FAILs are collateral of F1)
The `T-RBT-*` tests are marked FAIL only because `assert_stack` waits on vaultwarden (F1) reaching
health. The **host + controller + apps actually recovered every time**, proven by the recorded
times: **clean reboot 126 s, reboot mid-backup 69 s, reboot mid-migration 58 s, and a true
`sysrq-b` HARD power-loss 103 s.** Filesystems came back clean; the demo node survived a hard reset
and self-restored in under two minutes.
### Safety gates — **all held**
- `T-STG-SEC-FORMAT` PASS: type-to-confirm enforced, non-candidate refused, and the **`/dev/sda`
root-disk fence correctly refused** (the `FENCE`/FAIL journal line is that self-test *tripping the
fence on purpose* — it is proof the fence works, not a failure).
- `T-SEC-PROTECTED` PASS: traefik/cloudflared/felhom-controller/filebrowser refuse stop/remove and
stay Up.
- `T-BAK-SINGLEFLIGHT` PASS: second backup correctly 409'd.
- `T-RST-NEG` PASS: bad restore ids/stacks refused cleanly with **zero writes** under target paths.
- Backups never touched any namespace/group but `ct/9201` (`T-BAK-PBS` PASS).
### Stability & DR
- **Soak: 76 ops over ~2.5 h, 0 invariant breaks** (seed 20260706) — controller stayed < 5 s
responsive throughout, no unexpected 5xx, no state-model divergence, no RSS leak signal.
- **CPU soak: 20 min stress-ng, worst page latency 172 ms** (< 5 s target).
- **DR destroy-and-restore is proven sound.** The PBS whole-guest restore mechanism, transparent
decryption, 3-volume layout, drive re-bind, and full app recovery all work — demonstrated by the
successful manual recovery from R1. The finale's automated FAIL was purely the harness bug H1.
---
## Harness bugs found (fix before any re-run — some already fixed in the workspace copy)
| # | Bug | Effect | Status |
|---|---|---|---|
| H1 | P9 built the `pct restore` volid from the **epoch** backup-time, not the ISO-8601 form → `unable to parse PBS volume name`. Destroy succeeded, restore couldn't parse, node left down. | Caused T-P9-DESTROY-RESTORE FAIL and, downstream, T-P10-NOTIF-RESTORE FAIL (controller down during P10). **DR mechanism itself is fine.** | **FIXED** (epoch→ISO in the snapshot pointer) |
| H2 | `assert_stack` / dockerd / reboot assertions hard-depend on a single app (vaultwarden) reaching health. | Turned F1 into ~10 collateral FAILs. | Fix: assert controller+agent+binds+tunnel; treat per-app health as informational. |
| H3 | Storage lifecycle tests don't serialize around backups. | MIGRATE-ALL/APP + DECOM-MIGRATE got **correct** 409 "backup in progress" but were scored FAIL. | Fix: wait for `/api/backup/status` idle before storage ops. |
| H4 | Enrollment test didn't handle the already-enrolled/mounted precondition. | T-STG-ENROLL scored FAIL on a **correct** 409 needs-confirmation. | Fix: pre-clean or expect the confirmation branch. |
| H5 | Deploy-values builder sent `DOMAIN=<random secret>` (DOMAIN is auto-filled by the controller). | Latent — did **not** cause the vw crash (F1 did), but would misconfigure DOMAIN. | Fix: never send DOMAIN. |
| H6 | `T-PAGE-ALL` treated `/debug` as must-be-200. | FAIL on `/debug`→404, which is **by-design** when debug mode is off (12/12 real pages 200). | Reclassify XFAIL. |
| H7 | `T-RES-MEMGUARD` expected the 384 MB mem-request guard to fire, but the guest had ample free RAM (avail 10.2 GB) so it correctly didn't. | FAIL on absence of a refusal that wasn't warranted. | Fix: deploy enough to actually cross the reserve, or assert the accounting only. |
| H8 | Journal notes with embedded newlines split a TSV row (one stray blank line). | Cosmetic. | Fix: strip newlines in `journal_set` notes. |
**Recommended follow-up:** with H1H5 fixed and F1 worked around (set `SMTP_HOST` or disable
app-email for the restore centrepiece), **re-run P1P3 targeted** to get clean, real backup/restore
(F3-volume-tar / C2 Tier-2) coverage on a healthy app set — this is the one piece of pilot-critical
coverage the vaultwarden crash denied us tonight.
---
## Test matrix (id | class | result | note)
| Test | Verdict | Note |
|---|---|---|
| T-P0-INVENTORY / VERSIONS / R0 / NOTIF / SMOKE | **PASS** | preflight clean; R0 verified ok |
| T-PAGE-ALL | FAIL → **harness (H6)** | only `/debug`→404 (by-design); 12/12 real pages 200 |
| T-DEPLOY-SET | FAIL → **collateral F1** | 6/7 apps healthy; vaultwarden crash-loop |
| T-CANARY-PLANT / T-LIFECYCLE / T-REMOVE-REDEPLOY | FAIL → **collateral F1** | vaultwarden-dependent |
| T-SYNC-IDEMPOTENT | **PASS** | zero writes on re-sync |
| T-SEC-PROTECTED | **PASS** | protected stacks refuse + stay Up |
| T-AUTH-1 / T-AUTH-2 | **XFAIL** | auth disabled by deployed config (known) |
| T-BAK-API / T-BAK-SINGLEFLIGHT / T-BAK-PBS | **PASS** | API truthful; single-flight 409; group-scoped |
| T-BAK-FULL | FAIL → **collateral F1** | vaultwarden volume tar absent (app never ran) |
| T-BAK-OFFBOX | SKIP | needs provisioned SFTP key (unattended) |
| T-RST-NEG | **PASS** | bad restores refused, zero writes |
| T-RST-DB | FAIL → **F4 (LOW)** | HTTP 000 (likely transient) |
| T-RST-VW / T-RST-TIER2 | SKIP → **collateral F1** | no vaultwarden canary (app crashed) |
| T-STG-SEC-FORMAT | **PASS** | type-to-confirm + `/dev/sda` fence + mkfs-never-ran |
| T-STG-RECOMMISSION | **PASS** | drive recommissioned, apps recoverable |
| T-STG-ENROLL | FAIL → **harness (H4)** | correct 409 needs-confirmation (device mounted) |
| T-STG-MIGRATE-ALL / -APP / -NEG, T-STG-DECOM-MIGRATE | FAIL → **harness (H3)** | correct 409 "backup in progress" |
| T-STG-DECOM-ANYWAY | FAIL → **F2 (MED)** | agent `POST /disks/decommission` 403 — verify |
| T-STG-UNPLUG / T-STG-FULL | FAIL → **mixed** | partial; re-run after H3 |
| T-STG-NET | SKIP | needs provisioned NFS/SMB export |
| T-FLT-KILL9 / POWERLOSS-GUEST / AGENT / CRASHLOOP / CF / HUB-OUT | **PASS** | full fault recovery (times above) |
| T-FLT-DOCKERD | FAIL → **collateral F1** | reconverge saw vw=restarting |
| T-FLT-GITEA-OUT | FAIL → **F3 (MED)** | sync didn't recover in-window post-unblock |
| T-RBT-CLEAN / MIDBACKUP / MIDMIGRATION / HARD | FAIL → **collateral F1 / H2** | host recovered 58126 s (hard reset 103 s) |
| T-RES-CPU | **PASS** | 20 min soak, latency 172 ms |
| T-RES-MEMGUARD | FAIL → **harness (H7)** | ample RAM; guard correctly didn't fire |
| T-SOAK-LOOP | **PASS** | 76 ops, 0 invariant breaks |
| T-P9-DESTROY-RESTORE | FAIL → **harness (H1)** | volid format; **DR proven via manual recovery** |
| T-P10-NOTIF-RESTORE | FAIL → **consequence of H1** | controller down during P10; **restored manually** |
| T-P10-CLEANUP / ENDSTATE / REPORT | **PASS** | no residue; end state captured |
## What this campaign does NOT prove
- **Offsite escrow restore with the operator-held recovery code R** — cannot be unattended.
- **True-CGNAT WG tunnel behaviour** — no CGNAT vantage from the LAN runner.
- **Clean backup/restore of the highest-stakes app** — denied tonight by F1; recover via the
targeted P1P3 re-run.
- **Non-technical-user UX** — no human-in-the-loop signal.
- Any assertion reading a best-effort endpoint (alert/monitoring HTML scrape, hub-report timestamp)
is corroborating, not authoritative.
## Not changed
No production code, versions, or configuration were changed. The single change is this document.
The demo node was destroyed and restored (authorized) and left **running and healthy** at R1 state,
with notifications restored. Evidence bundle retained on DooPlex, never committed; no secrets in
this report.
---
# Findings triage addendum (2026-07-06)
Follow-up implementation task on F1F3. F1/F3 fixed + shipped; F2 diagnosed to a verdict (agent
untouched — a fix is a separate follow-up). Shipped: catalog `d86e256`, controller **v0.101.0**
(`fc28033` sync deadline, `c997d79` agent-refusal surfacing).
## F1 — CORRECTED root cause (the original hypothesis above was wrong)
The report's F1 hypothesis — *"the shim sets `SMTP_FROM` without `SMTP_HOST`"* — is **incorrect**:
with app-email off (the default) the shim injects **nothing**. Validated against live source +
the pinned image. Real cause: the template **always** defines `SMTP_HOST=${SMTP_HOST:-}` and
`SMTP_FROM=${SMTP_FROM:-}`; vaultwarden treats a **defined-but-empty** env var as *set*, and with
its `_enable_smtp` defaulting **true** its validation (`smtp_host.is_some() == smtp_from.is_empty()`
`true == true`) errors out and the process exits. Every fresh email-off deploy shipped a dead
password manager.
**Empirical proof** on `vaultwarden/server:1.33.2-alpine` (evidence:
DooPlex `evidence/PROBE-F1/`): P1 `SMTP_HOST=`/`SMTP_FROM=` (defined-empty) → **exit 12**, exact
campaign error; P2 `+_ENABLE_SMTP=false`**boots**; P3 `_ENABLE_SMTP=true`+host+from → **boots**.
**Fix** (catalog): gate the SMTP group with `_ENABLE_SMTP` — compose default `false` (validation
skipped, clean boot), flipped `"true"` by the app-email injection via `smtp_mapping.extra`.
**Live-validated via the real UI pipeline** (v0.101.0 on 9201): fresh email-off deploy → healthy,
`_ENABLE_SMTP=false`, restarts=0; app-email ON → healthy with `SMTP_HOST=felhom-controller` +
`SMTP_FROM=vaultwarden@felhom.eu`; OFF → healthy. The other 5 smtp-mapped templates are
campaign-boot-proven tolerant (no edits).
## F2 — VERDICT: **real finding** (agent role-resolution inconsistency; fail-safe direction)
Campaign-time agent journal (persistent journald survived the reboots), the authoritative evidence:
```
Jul 06 08:36:03 felhom-agent WARN "local-api: protected — decommission refused by role"
vmid=9201 where=/mnt/teszt_enroll role=system
```
`where` + `role` give the verdict directly (no reproduction needed — the drive was never mutated).
**Mechanism** (`felhom-agent internal/localapi/disks.go:1020` `roleForMountPath`): it loops the
agent's `storage.Observe()` **storage targets** for one whose `MountPath == where`, and **fail-safe
returns `RoleSystem` when none matches** (`:1031`). `/mnt/teszt_enroll` is a real user-data USB
drive but is **not a PVE storage** (`pvesm status` lists only felhom-usb / felhom-flash — both
`content backup` — plus the PBS stores; no `teszt_enroll`). So no target matches → `system`
decommission refused.
**Why it's a real finding, not a harness artifact:** the agent's own `/disks` inventory
**already classifies teszt_enroll as `role: user-data`** (durable_id `uuid:f2236136…`), yet the
decommission/eject gate independently resolves it to `system`. Two role-resolution paths disagree:
the disk-inventory path knows it's user-data; the gate (keyed on PVE storage targets only) doesn't
see a bind-mounted user-data drive that isn't its own PVE `dir` storage. Any such drive hits this,
so a legitimate customer "decommission anyway" can be blocked.
**Severity: MEDIUM, fail-safe direction.** The default is *over-refusal* (refuse rather than wrongly
unmount) — it never permits a wrong decommission, only blocks a right one. Not a security hole.
**Suggested fix (deferred — agent follow-up task, NOT done here):** have `roleForMountPath` fall
back to the durable-id / DiskInfo role (or `RoleForRawDevice`) when no storage target matches the
mount, instead of blanket-defaulting to `system` — so an enrolled user-data drive is decommissionable
regardless of whether it is a standalone PVE storage. Pair with a red-proof on a bind-mount-only
user-data drive.
## F3 — FIXED (controller v0.101.0)
Real half confirmed: `internal/sync/sync.go runGitInDir` ran `cmd.Run()` with no context, so a hung
remote parked the sync goroutine, held `syncing=true`, and refused every later sync until a
controller restart. Fixed with a per-command `exec.CommandContext` deadline (`gitCmdTimeout = 120s`);
the deadline error names the timeout + masked args. Debounce unchanged. Tests + red-proof shipped;
live sync smoke `POST /api/sync` → 2xx post-deploy.
## Harness note (unchanged — for the record)
The P9 finale FAIL in the main report was a harness volid-format bug (epoch vs ISO-8601), fixed in
the DooPlex workspace copy; the DR restore mechanism itself is sound (9201 recovered from R1). The
other harness mis-scores (H2H8) stand as written.
---
**F2 UPDATE (2026-07-06): CLOSED**`felhom-agent` v0.73.0 mount-role fallback; live-proven eject +
decommission on `/mnt/teszt_enroll` (both were 403). See `RERUN-p1p3-2026-07-06.md` closure appendix.