Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6.5 KiB
REPORT — F1 (HIGH) + F2: never destroy a guest this transaction didn't create (v0.60.0)
Date: 2026-07-02 · Repo: felhom-agent · Class: Risky/supervised (destructive
rollback/destroy path) — strictly SAFER. Fixes the pool-effects campaign's F1/F2
(felhom.eu/documentation/audits/CAMPAIGN-pool-effects-2026-07-01.md); F3 is the companion doc in
felhom.eu (documentation/runbooks/provisioning.md, commit 6dae9d7).
1. Baseline → target
agent v0.59.0 (55ade9e) → v0.60.0 (commit b9356d6 on main). felhom.eu doc commit
6dae9d7 (no bump).
2. Root cause + what changed
The compensating destroy ran on ASSUMED provenance (Rollback:true/SameTxnCreated), never verified
against proof-of-launch. A RestoreLXC that failed synchronously (no UPID — e.g. PVE refusing a
pre-existing vmid the pool-blind ListLXC couldn't see) still armed DestroyLXC(vmid) — destroying a
guest the transaction never created. The pool ACL's 403 only saved the non-pool subset; an in-pool
pre-existing guest would have been destroyed, in-process AND by Recover. Now a RestoreLXC UPID is the
sole destroy authorization, in all three destroy paths (the pool ACL is defense-in-depth again):
- F1a
internal/reconcile/bringup.go—runBringUp's rollback defer gated onlaunched(set only after the restore POST is accepted); a no-launch failure closes the owning entry terminal-failed with NO destroy. Pre-restoreOpStartedappend kept (crash-safety).rollbackBringUpdoc updated: only ever called launch-proven. - F1b
internal/reconcile/restoretest.go—runScratchTest'steardownScratchdefer gated the same way. - F1c
internal/reconcile/recover.go— the no-UPID "POST never confirmed → abandon" check now runs BEFORE the Scratch/Rollback dispatch: a no-UPID guest-creating entry is abandoned (marked failed, NO destroy) instead of destroy-by-vmid-existence. Recover safe by DESIGN, not by the pool-blind "already gone" accident. - F2
restoretest.go—RunRestoreTestband-advance: an "already exists" refusal (newpveAlreadyExists, APIError-typed likepveConfigLock— a real restore failure is never misclassified) advances to the next free band vmid (pickScratchVMID+ exclude set, bounded by the band width); band exhausted →Skipped(the scheduler raises no record/alert), never FAIL. - Accepted residual (rule 4, by design): a crash in the one-statement window between obtaining the UPID and journaling it leaks a half-built guest Recover won't destroy — cleanable, preferable to destroying an innocent guest.
3. Green gate
go build ./... && go vet ./... && go test ./... — clean, 17 packages ok (build server go1.26.0 build
of the deploy binary also clean).
4. Tests (incl. the red-proofs)
- F1a red-proof
TestRunBringUp_NoLaunchNoDestroy(PVE "already exists" APIError + plain synchronous error): assertsDestroyLXCNOT called + entry terminal. Companion verified: with thelaunchedgate reverted it failsdestroys=[8000]— the exact destroy-innocent-guest bug. - F1b
TestRunRestoreTest_RestoreNoLaunchNoTeardown(replaces the old…RestoreFailureStillTearsDown, which asserted the BUGGY behavior): no teardown on a no-launch failure. Red-proof verified (destroys=[990000]pre-fix). - No-regression (Scenario B):
TestRunRestoreTest_LaunchedTaskFailureStillTearsDown+ a new "restore-task failure after launch" case inTestRunBringUp_CompensatingRollback— a LAUNCHED restore whose task fails still destroys the half-built guest. - F1c
TestRecover_BringUpNoUPIDAbandoned+TestRecover_ScratchNoUPIDAbandoned: a no-UPID Rollback/Scratch entry with a guest PRESENT at the vmid → abandoned via the fail-safe path, no destroy. Red-proof verified. Existing leaked-guest Recover tests updated to carry the UPID (the launch proof) their scenario implies. - F2
TestRunRestoreTest_AdvancesPastOccupiedBandVMID(squatter at 990000 → PASS at 990001, squatter never destroyed) +…BandFullOfSquattersSkips(Skipped, not FAIL, bounded — one try per band vmid) +TestPickScratchVMIDexclude-set coverage.
5. Live proofs (felhom-pve, v0.60.0 deployed)
- Probe A (headline):
--selftest=bring-up -vmid 9001(existing non-pool guest) → PVE refused ("CT 9001 already exists"); no gate decision, no DestroyLXC attempt of any kind (pre-fix run hadgate decision class=guest_destroy+ a 403'd DELETE); 9001 untouched; journal drained in-process (next run:recover: examined=0). - Probe D (F2): decoy parked at 990000 → restore-test logged "band VMID occupied … advancing", passed at 990001 (restored+booted+verified+torn-down in 28s), decoy survived, decoy removed.
- Regression: forced post-launch failure (
-cores 10000→ config PUT 400 after restore) → compensating rollback destroyed the half-built 9310 ("bring-up: rolled back"); a normal provision of 9310 then passed (boot+running 22s) and tore down cleanly. - STOP condition (any destroy attempted against 9001 or the decoy) did not trigger.
6. Deployed + published
- felhom-pve:
/usr/local/bin/felhom-agent= 0.60.0 (backupfelhom-agent.bak-0.59.0), unitactive, clean logs. Demo left healthy: poolfelhom= {9201}, guests 9001/9100/9201/9999 intact. - Gitea generic package
felhom-agent/0.60.0published (HTTP 201), download sha256 round-trip verified:45d19b8799cc764333cd95bb64e0515f2246cdb08c0fb36d9b23409051725831. - Operator follow-up: bump the hub Day-0 artifact manifest (password-gated UI) to agent 0.60.0 — until then, fresh Day-0 installs still fetch the manifest-pinned older version.
7. F3 doc
felhom.eu/documentation/runbooks/provisioning.md (commit 6dae9d7): pick the provision -vmid from
the host's FULL guest list (pct list + qm list), never the pool-only hub inventory; reserved bands
documented (9999, 990000–990009 fleet-wide). Campaign doc updated with the F1/F2/F3 resolution banner.
8. Observations (not acted on)
- The hub artifact-manifest bump remains operator-gated (hub v0.29.0 dropdowns make it one click).
- Campaign F4 (restore-test can't read a NON-pool source guest's config for bind-mount neutralization) and F5 (pool-blind eject dependent-scan) remain open as LOW, per the campaign doc.
- The bring-up duplicate-guard (
bringup.goListLXC scan) is still pool-blind — acceptable now that a collision is non-destructive and PVE refuses authoritatively; noted in the F3 runbook.