Files
felhom.eu/documentation/tests/CAMPAIGN-2-2026-07-07.md
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

20 KiB
Raw Permalink Blame History

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.
  • 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.Auditnot 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 enforcedT-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-ESCROWSKIP-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/felhommembers: []. 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).