Files
felhom.eu/documentation/tests/CAMPAIGN-2-2026-07-07.md
T
admin 1a1e42addb docs: campaign-2 R1/R2 root-cause correction + DR bind-mount source spike
Correction: campaign-2's "R1" (bind-mount restore needs root) was a SYMPTOM of an
empty felhom pool -> agent lacked VM.Audit on 9201 -> restore-test's existing,
correct bindMountOverrides never ran. Live-proven: pool healed (Part A) ->
restore-test PASSES (neutralizes 2 binds, boot+running, clean teardown, 4m35s).
Report rec #1 (make pct restore work on bind mounts) superseded. Fixes shipped:
agent v0.74.0 (pool re-assertion), controller v0.103.0 (F-C2-1).

Spike (SPIKE-dr-bindmount-source-2026-07-07): DR bring-up source options — PBS
pct.conf.blob blocked by encryption under the non-root token; DR recipe lacks mpN;
mp8/mp9 are STRUCTURAL CONSTANTS -> recommend a small known-constant override
reusing bindMountOverrides.

🤖 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-07 19:08:03 +02:00

315 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 2 — DR primitives · self-update · escrow · storage · faults · endurance (2026-07-07)
> Unattended full-system validation on the demo node (felhom-pve / guest 9201), run during the day
> 2026-07-07 (07:2014:11 CEST). **No production code changed.** Throwaway harness + evidence bundle
> live on DooPlex (`~/campaign2-nomercy/`); the committed artifacts are this document + the evidence
> snapshot under `documentation/tests/campaign2-evidence-2026-07-07/`.
>
> **Headline — read this first.** The raw tally looks alarming (≈45 FAIL) but is **misleading**: it
> decomposes into **one genuine, pilot-critical product finding** plus a **single dominant harness
> bug** that mis-scored most of P3P9, plus a handful of smaller harness-calibration issues. The
> product, wherever it was actually exercised with a valid session or through the agent directly,
> was **healthy**: auth+CSRF enforced, backups sound, the PBS-verify + crash-recovery primitives
> work, fault recovery works, and **zero resource leaks** across a ~7 h run.
>
> **The one real finding (R1, headline):** the non-root host agent **cannot restore guest 9201**
> because the standard Felhom guest has **bind-mount mountpoints** (`mp8: /mnt/felhom-drives`,
> `mp9: …/bootstrap`), and `pct restore` refuses bind mounts unless root. This breaks **both** the
> product's own restore-verification primitive (`--selftest=restore-test`) **and** the offsite DR
> bring-up (`--selftest=bring-up -mode dr`). *A backup you cannot restore is not a backup* — and
> right now, non-root, we cannot restore the demo guest. This is exactly what "exercise the real
> primitives" was meant to surface.
---
## Run header
| Item | Value |
|---|---|
| Date | 2026-07-07, 07:2014:11 CEST |
| Host / Guest | felhom-pve (192.168.0.162), node demo-felhom / guest 9201 (LAN 192.168.0.127) |
| Controller / Agent / Hub | **v0.102.0** (`cd0ebd2`) / **v0.73.0** (`e04b75e`) / v0.35.0 |
| Catalog | `2ebe082` (healthcheck-swept) |
| Auth mode | **ON** for the run (dashboard password + CSRF, via `settings.json`); restored **OFF** at P10 |
| Scratch band | agent-config band 990000990009 (restore-test) + granted 96019620 (DR) — asserted empty at P0 |
| Restore points | **R0** `ct/9201/2026-07-06T22:34:15Z` verify PASS; **R1** taken by P7 |
| Soak / wallclock | 4 h soak cap / 8 h30 m wallclock (finished well inside) |
| Seed | 20260707 |
| Escrow R | provided at kickoff (chmod-600, never echoed) — but the escrow **blob was not locatable** by the harness, so the unwrap did not run (H5) |
| D1 envelopes | signed + verified through the **real** `authz.Verifier` pipeline at P0; **moved early** (after P3) to stay inside the 10:07Z TTL — see note below |
**D1-early adaptation (logged):** the envelopes had a 12 h TTL from the previous evening (expire
2026-07-07T10:07Z). The as-ordered D1 slot (after the 4 h soak) would have run past expiry. Because
0.73.1 is the **same source tree** as the deployed 0.73.0 (only the `-X main.version` string differs),
running P4P9 on 0.73.1 would have been functionally identical, so D1 was moved to run right after P3.
(In the event D1 could not be delivered at all — see H3 — so this was moot; the agent stayed 0.73.0.)
---
## Result tally (raw, then triaged)
| Status | Raw |
|---|---|
| PASS | 15 |
| FAIL | 45 |
| SKIP | 3 |
| SOAK "breaks" | 170 (all one artifact) |
**After triage the 45 FAIL decompose as:**
- **1 genuine product finding** (R1/R2, surfaced by 3 independent agent-side tests: T-SELFRESTORE,
T-DR-BRINGUP, and contributing to the P9/DR chain).
- **~30 mis-scored by one harness bug** (H1, auth-session expiry — every controller POST 403'd on
CSRF and every GET returned login HTML from P3 onward).
- **4 = one harness bug** (H3, D1 injection had no `sqlite3` in the hub pod → self-update never
delivered; agent correctly stayed 0.73.0).
- **2 = stale dryrun journal entries** (H4).
- **1 = missing test fixture** (H5, escrow blob not locatable → SKIP mis-marked FAIL).
- The remainder are the soak (H2) and derived/dependent skips.
---
## THE genuine finding — R1 + R2 (pilot-critical)
### R1 — non-root `pct restore` of a bind-mount guest fails ("only possible for root")
- **Symptom (verbatim, 3 tests):**
`unable to restore CT <vmid> - restoring 'mp8' to bind mount is only possible for root`
- `T-SELFRESTORE``--selftest=restore-test` into scratch 990000 → **FAIL in 2.2 s**
(`evidence/T-SELFRESTORE/restore-test.out`, `pass:false`).
- `T-DR-BRINGUP``--selftest=bring-up -mode dr` into scratch 9601 → **FAIL**
(`evidence/T-DR-BRINGUP/bringup.out`).
- **Root cause (confirmed):** guest 9201's config carries **bind-mount mountpoints**:
```
mp8: /mnt/felhom-drives,mp=/mnt/felhom-drives # the enrolled-drives bind
mp9: /var/lib/felhom-agent/guests/9201/bootstrap,mp=/etc/felhom-bootstrap,ro=1
```
Proxmox `pct restore` refuses to restore a bind-mount entry unless it runs as **root**. The agent
runs **non-root** (`felhom-agent`), by design (de-privilege). So the restore of the *standard
Felhom guest topology* fails.
- **Blast radius:** this is not a scratch-guest quirk — it is the **restore path itself**. The
product's own "is this backup actually restorable?" primitive and the offsite guest-loss DR
bring-up **both fail on any guest that has a bind mount**, which the standard Felhom guest always
has (drives bind mp8 + bootstrap bind mp9). **This is the single most important thing to know
before pilot.**
- **Not fixed** (report-only). Candidate directions for the follow-up task (design decision, not
prescribed here): rewrite bind-mount `mpN` entries to a restorable form during restore (needs R2's
config-read), restore the bind mounts via a root-scoped helper (break-glass-style, narrow), or
restore the rootfs+managed volumes only and re-materialise binds post-restore. **Cross-check with
the offsite-WG DR design docs before choosing.**
### R2 — agent token is missing `VM.Audit` (and `VM.Allocate` on scratch) — links into R1
- `restore-test: could not read source config for mp overrides (restoring as-is)` …
`GET /nodes/demo-felhom/lxc/9201/config -> HTTP 403: permission denied at /vms/9201 (missing
privilege VM.Audit)`. Because the agent could not read the source config, it restored **as-is**
and hit R1. **R2 plausibly *enables* R1's fix**: with config-read the agent could rewrite `mp8`
before restoring.
- `DELETE /nodes/demo-felhom/lxc/990000 … missing privilege VM.Allocate` — scratch teardown 403'd;
the leaked scratch was **left for Recover**, which did reconcile it (`recover: leaked-scratch
entry resolved; guest already gone`) — so **no leak survived**, but teardown is not clean.
- **Live ACL confirmation:** `pveum user permissions felhom-agent@pve --path /vms/9201` shows only
`Datastore.Audit` + `Sys.Audit` — **not** `VM.Audit`. The `FelhomAgentGuest` role *does* carry
`VM.Audit`/`VM.Allocate`, but the grant on `/pool/felhom` is not resolving to `VM.Audit` at the
per-guest path the restore path checks. (Related to the A1 pool-audit thread.)
---
## Other real observations
- **F-C2-1 (kickoff, confirmed):** setting the dashboard `password_hash` in **`controller.yaml`**
does **not** take effect — the config loader runs `os.ExpandEnv` over the YAML, and a bcrypt hash
(`$2a$…`) contains `$…` sequences that get mangled to empty. Worked around by writing the hash to
**`settings.json`** (which is not env-expanded). Product-relevant: any operator putting a bcrypt
hash in `controller.yaml` gets silent auth-bypass. Report-only.
- **T-P9-DESTROY-RESTORE** ran (gated on R1-verify + healthy agent) and the local PBS restore of 9201
*itself* completed at the Proxmox level, but the harness's **post-restore verification** went
through the (dead-session) controller and mis-scored — see H1. The vzrestore of a *whole-guest*
PBS backup via `pct restore` on the **host** (P9 finale path) is a different code path from the
agent's non-root `--selftest` restore and did not hit R1; the destroy→restore of 9201 recovered
the guest (it is running and healthy at end-state).
---
## Strong positive results (real signal — do not lose these under the FAIL count)
### No resource leaks — the endurance headline (flight recorder, 351 samples over ~7 h)
| Metric | Start | End | Min | Max | Verdict |
|---|---|---|---|---|---|
| agent RSS (KB) | 22 116 | 19 936 | 17 924 | 22 792 | **stable, trended down** |
| agent fd | 79 | 54 | — | 83 | no leak |
| agent sockets | 70 | 46 | — | 74 | **no idle-socket leak** (the agentapi precedent is absent) |
| controller fd | 0* | 15 | 0* | 30 | no runaway (*0 = sampler pgrep miss) |
| controller latency | — | — | 0.008 s | 0.079 s | responsive all run |
The controller answered every flight-recorder probe in **879 ms** across the whole run; only ~10 of
351 samples were 502/000 (during **deliberate** restarts/reboots), all recovered. (The 341× `401`
is the flight recorder itself being unauthenticated — the controller *responded*, fast, it just
required auth.)
### What actually passed (valid-session or agent-direct — trustworthy verdicts)
- **P1:** all pages 200; **vaultwarden fresh-deploys healthy** (`_ENABLE_SMTP=false`, restarts=0 —
F1 fix re-confirmed); canary plant; lifecycle; sync-idempotent; and the **new auth+CSRF grant is
enforced** — `T-AUTH-1` (wrong-pw refused, unauth API blocked), `T-CSRF` (tokenless POST → 403,
tokened accepted), `T-AUTH-2` all **PASS**.
- **P2 backup suite:** `T-BAK-FULL` (DB dumps restorable + volume tars carry canaries bit-identical),
`T-BAK-API`, `T-BAK-SINGLEFLIGHT` (409), `T-BAK-PBS` (namespace-pinned) all **PASS**.
- **P5 restore primitives:** `T-PBS-VERIFY` **PASS** (`verify_state: ok`, 13 snapshots over 2
datastores, via the agent selftest — immune to the auth bug); `T-RESTORE-TEST-CRASH` **PASS** —
the journaled engine's **`Recover` reconciled a leaked scratch after a kill** (`leaked-scratch
entry resolved; guest already gone`). *Caveat:* because restore-test now fails fast on R1, the
crash-window was small — Recover is proven, but a full mid-restore crash was not deeply exercised.
- **P6 faults:** `T-FLT-KILL9` (controller SIGKILL → restart-policy recovery, API 200),
`T-FLT-CF` (cloudflared down → external down → recovered), `T-FLT-AGENT` (agent SIGKILL → systemd
restart, clean binds) all **PASS**.
- **P9-DR:** `T-DR-PBSVERIFY` **PASS** — an offsite snapshot exists and verifies over the tunnel.
---
## Harness bugs found (the real story behind the FAIL count — product NOT implicated)
- **H1 — auth-session expiry (DOMINANT).** The harness logs in **once** (P1) and never
re-authenticates. Over a multi-hour run the session cookie expired; from **P3 onward** every
state-changing controller POST returned `{"ok":false,"error":"CSRF token missing or invalid"}`
(403) and every canary GET returned login-page HTML (empty parse). Proof:
`evidence/T-STG-ENROLL/init1.txt` (CSRF-403), `evidence/T-RST-VW/restore_resp.txt` (302→login),
`canary_check.txt` (`got=` empty). **Mis-scored:** all `T-RST-*`, all `T-STG-*`,
`T-FLT-{DOCKERD,HUB-OUT,GITEA-OUT,CRASHLOOP,POWERLOSS}`, all `T-RBT-*`, `T-RES-*`, and the
post-restore checks of `T-P9-DESTROY-RESTORE`. **Fix for harness v-next:** `cc_ensure_auth` before
every phase's ops (re-login on any 401/302), and re-scrape the session-bound CSRF token after any
controller restart/reboot.
- **H2 — soak responsiveness probe (`cc_code /` expects 200; auth → 302→login).** 170/170 soak
iterations flagged `responsive(code=302 ms=45)` — the controller *was* responsive (45 ms), the
probe just wasn't authed. `T-SOAK-LOOP` FAIL is entirely this. Fix: probe an authed page (or
accept 302).
- **H3 — D1 injection has no `sqlite3` in the hub pod.** All 4 `T-D1-*` failed at the injection step
(`exec: "sqlite3": executable file not found in $PATH`). The self-update was **never delivered**,
so it was **not tested** (agent correctly stayed 0.73.0). The signed envelopes themselves were
valid (verified at P0 through the real `authz.Verifier`). Fix: inject via a hub API/endpoint or a
pod with sqlite, or bake the job in a way that survives a minimal image.
- **H4 — dryrun polluted the journal.** The pre-launch `run.sh dryrun` wrote synthetic-state FAILs
for `T-SEC-PROTECTED` and `T-REMOVE-REDEPLOY` to the **same** `journal.tsv`; the resume-aware
overnight run saw them terminal and **skipped re-running them**, preserving the stale FAILs. Fix:
fresh journal per run (or a dryrun-scoped journal).
- **H5 — escrow blob not locatable.** `T-DR-ESCROW` → `SKIP-reason: no escrow blob reachable
(local+hub)`. R was provided, but the harness looked in the wrong place (agent state dir + a hub
`/escrow` endpoint that isn't the real path), so the **crown-jewel unwrap + wrong-R red-proof did
not run**. Marked FAIL, really a SKIP. Fix: locate the actual escrow blob (hub admin path with the
operator key, or the real on-host escrow artifact).
- **H6 — flight-recorder probe unauthenticated** (401 for the run) and **`c_rss` sampler flaky**
(guest-side `pgrep` interpolation often empty). Cosmetic — latency/leak signal still usable.
---
## Restore-primitive evidence (the campaign's purpose — verdicts)
| Primitive | Verdict | Evidence |
|---|---|---|
| `--selftest=pbs-verify` | **WORKS** | `verify_state: ok`, 13 snapshots / 2 datastores |
| journaled `engine.Recover` (leaked-scratch reconcile) | **WORKS** | `T-RESTORE-TEST-CRASH` — reaped a leaked scratch after kill |
| `--selftest=restore-test` (restore→boot→verify→teardown) | **BROKEN on bind-mount guests (R1)** | fails in 2.2 s: `mp8 … only possible for root` |
| `--selftest=bring-up -mode dr` (offsite guest-loss DR) | **BROKEN on bind-mount guests (R1)** | fails: `mp8 … only possible for root` |
| `escrow.Consume(R)` unwrap (+ wrong-R red-proof) | **NOT RUN** (H5 — blob not located) | — |
| local whole-guest PBS destroy→restore (host `pct restore`, P9) | guest recovered | 9201 running + healthy at end-state |
---
## Delta vs Campaign 1
| Dimension | Campaign 1 (2026-07-06) | Campaign 2 (2026-07-07) |
|---|---|---|
| Auth | OFF (XFAIL) | **ON — enforcement proven** (T-AUTH/T-CSRF PASS) … but broke the harness mid-run (H1) |
| Restore primitives | not exercised | **exercised — surfaced R1** (the headline) |
| Resource leaks | not sampled | **sampled — none** (agent RSS/fd/sockets stable) |
| DR bring-up / escrow | not attempted | attempted — R1 blocks bring-up; escrow blocked by H5 |
| Self-update (D1) | shipped v0.70.0 (prior) | **not delivered** this run (H3) |
| Dominant mis-score cause | ~18 harness bugs (auth-off era) | **1 dominant harness bug** (auth-on session expiry) |
---
## What this campaign STILL does not prove
- **The async restore family end-to-end through the UI under load** — H1 killed the harness's restore
tests. (It *was* proven in the P1P3 re-run 2026-07-06: 0.018 s internal / 0.235 s external,
canary bit-identical.)
- **Storage F2 lifecycle at scale** — all `T-STG-*` are H1 artifacts. (F2 eject/decommission were
proven live in the v0.73.0 acceptance 2026-07-06.)
- **True offsite guest-loss DR** — blocked by R1; the crown-jewel path did not complete.
- **Escrow recovery-code unwrap** — not run (H5).
- **Agent self-update** — not delivered (H3).
- True CGNAT; multi-customer hub scale; real hardware failure.
---
## End state (demo node healed)
- **Guest 9201 running + healthy**, controller v0.102.0 healthy, **25 apps up, 0 unhealthy**.
- **Agent v0.73.0 active** (self-update never applied — H3 — so no version drift; the 0.73.1 artifact
remains published in Gitea, dormant).
- **Auth restored OFF** (pre-campaign state; root page 200). *Note: the "dashboard public + unauth"
item remains a pre-existing known security topic, unchanged by this run.*
- **Notifications restored** to the exact pre-state (email `nagyfenyvesi.viktor@gmail.com`, 11 events,
cooldown 6 h) — the harness P10 SKIPped it (no P0 snapshot in its state dir), restored manually.
- **No residue:** 0 scratch guests in 990000990009 / 96019620; no stray timers/rules.
- Controller auto-update was already `false` pre-campaign (unchanged).
## Follow-ups (for project-Claude / a future task — not done here)
1. **R1 — bind-mount restore** (pilot-blocking): make `--selftest=restore-test` + DR bring-up work on
a bind-mount guest under the non-root agent. Design decision — cross-check the DR docs.
2. **R2 — grant the agent `VM.Audit`** (+ `VM.Allocate` on the scratch band) so restore-test can read
source config and tear down cleanly; likely a prerequisite for R1's fix.
3. **Harness v-next:** H1 (re-auth per phase), H2 (authed soak probe), H3 (hub-native D1 injection),
H4 (fresh journal), H5 (real escrow-blob locate) — then a clean re-run of P3P9 + D1 + escrow-DR.
4. **F-C2-1:** either make the controller config loader not `os.ExpandEnv` the `password_hash`, or
document that bcrypt hashes must live in `settings.json`.
---
# Root-cause correction (2026-07-07) — R1 was a symptom of an empty pool; restore-test was never broken
> This section ANNOTATES the report above (history preserved). Live diagnosis after publication
> proved the R1/R2 causation runs the OTHER way, and the "fix R1" recommendation is superseded.
**What R1 actually was.** The report named R1 ("non-root agent can't `pct restore` a bind-mount guest
— architectural, pilot-blocking") as the headline. That inverts symptom and cause. The real chain,
every link verified live:
1. `pvesh get /pools/felhom` → `members: []`. **The felhom pool was EMPTY.**
2. Pool-scoped ACL grants (`FelhomAgentGuest` @ `/pool/felhom`, carrying `VM.Audit`/`VM.Allocate`)
reach only pool MEMBERS. With 9201 not a member, the agent had only the `/`-base grant on
`/vms/9201` (`Datastore.Audit`/`SDN.Use`/`Sys.Audit`) — **no `VM.Audit`**.
3. `restore-test` calls `GuestConfig(9201)` to read the mp layout → **403 VM.Audit** → falls to the
"restore as-is" fallback.
4. Because the source config was never read, `bindMountOverrides` (`restoretest.go:334`) — **which
already exists and is correct** — never ran. Raw bind mounts reached PVE → `mp8 … only possible
for root`.
**So restore-test's bind-mount handling was never broken — it just never executed.** The role and the
ACL binding were both correct; the fault was **pool membership**.
**Why the pool went empty:** `pct restore --pool` sets membership only at CREATE; a restore OVER AN
EXISTING VMID (an earlier destroy-restore drill) does NOT re-apply it, and nothing re-added the guest
— so 9201 silently dropped out (the 07:41 `VM.Audit` 403 predates the 14:09 finale, consistent with
an earlier drill).
**Verified fix + live proof (2026-07-07):**
- **Part A (heal now):** `pveum pool modify felhom --vms 9201` → 9201 is a member; `pveum user
permissions felhom-agent@pve --path /vms/9201` now shows **`VM.Audit` + `VM.Allocate` + `VM.Backup`**.
- **Live restore-test then PASSED for the first time** (agent v0.74.0): `neutralizing source
bind-mount mountpoints … bind_mounts=2` → scratch 990000 restored + booted + **verified
boot+running** + **torn down clean (no `VM.Allocate` 403)** in 4m35s. This confirms **B3** (the
scratch-teardown 403 was a CASCADE of the failed bind-mount restore, not an independent gap) and
that restore-test needs **no** bind-mount code change.
- **Part B (stop the regression):** `felhom-agent v0.74.0` re-asserts pool membership after any
restore-with-`Pool` (`Client.PoolAddVMID` + a bring-up call, warn-not-fail). A destroy-restore no
longer silently drops membership.
**Corrected fix order + superseded recommendation:**
- The report's follow-up **#1 ("make `pct restore` work on bind-mount guests without root") is NOT
needed** for restore-test and is **superseded** — the existing override handling works once the
config read succeeds (i.e. once membership is present).
- (A) pool one-liner heals today; (B) v0.74.0 stops the recurrence; restore-test unchanged.
- DR `bring-up -mode dr` has a **separate, real** gap (it passes no overrides and its source guest is
gone) — scoped and resolved in `SPIKE-dr-bindmount-source-2026-07-07.md` (recommendation: a small
known-constant override reusing `bindMountOverrides`, since mp8/mp9 are structural constants).
- **F-C2-1** is fixed in `felhom-controller v0.103.0` (config loader no longer `os.ExpandEnv`-corrupts
a bcrypt hash; live-proven).