v0.60.0: proof-of-launch destroy gating (F1a/b/c) + restore-test band-advance (F2)

Campaign pool-effects F1 (HIGH): the bring-up compensating rollback and the
restore-test teardown destroyed the target vmid even when RestoreLXC failed
synchronously without creating anything — destroying a guest the transaction
never made (only the pool ACL 403 contained it). A RestoreLXC UPID is now the
sole destroy authorization in all three destroy paths (in-process bring-up
defer, in-process restore-test teardown, Recover). F2: the restore-test
advances past an 'already exists' band vmid (invisible squatter) instead of
failing + false-alerting; a fully-occupied band Skips.

Red-proof verified: with the gates reverted, the four new tests fail with the
innocent-guest destroy. go build/vet/test clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 10:18:51 +02:00
parent 55ade9e254
commit b9356d60ab
8 changed files with 400 additions and 67 deletions
+22 -13
View File
@@ -35,32 +35,41 @@ func (e *Engine) Recover(ctx context.Context) RecoverResult {
for _, entry := range e.journal.InFlight() {
res.Examined++
// PROOF-OF-LAUNCH gate, checked FIRST (campaign pool-effects F1c): an entry with no
// journaled UPID means the restore/POST was never confirmed → this transaction created
// NOTHING → NEVER destroy the vmid. For Scratch/Rollback (guest-creating) entries this
// is load-bearing: a pre-existing guest — possibly another customer's, invisible to the
// pool-blind ListLXC existence check — may sit at that vmid, and the old destroy-if-
// exists path would have destroyed it under a broad token. Abandon fail-safe instead.
// Accepted residual: a crash between obtaining and journaling the UPID may leak a
// half-built guest (cleanable) — preferable to destroying an innocent one.
if entry.UPID == "" {
e.append(terminal(entry, OpFailed))
res.RolledBack++
e.logger.Warn("recover: in-flight op had no task id; marked failed (fail-safe, no destroy)",
"op_id", entry.OpID, "vmid", entry.VMID, "kind", entry.Kind)
continue
}
// Scratch entries (slice-6 restore-test) are resolved by TEARDOWN, not by
// re-checking a sub-task UPID — a leaked scratch guest is the failure mode that
// matters. Handle them BEFORE the generic UPID path (else the restore sub-task's OK
// status would mark the entry succeeded while the guest still exists → leak).
// status would mark the entry succeeded while the guest still exists → leak). The
// UPID they carry is the launch proof authorizing the destroy-by-existence below.
if entry.Scratch {
e.recoverScratch(ctx, entry, &res)
continue
}
// Rollback entries (slice-7 bring-up) own a guest the agent was CREATING. An in-flight
// one means "VMID may be a half-built guest → destroy it" (compensating rollback) — same
// reason the Scratch path runs before the generic UPID path: the restore sub-task's OK
// status would otherwise mark the entry succeeded and leave a half-provisioned guest.
// launch-proven one means "VMID may be a half-built guest → destroy it" (compensating
// rollback) — same reason the Scratch path runs before the generic UPID path: the
// restore sub-task's OK status would otherwise mark the entry succeeded and leave a
// half-provisioned guest.
if entry.Rollback {
e.recoverBringUp(ctx, entry, &res)
continue
}
if entry.UPID == "" {
// POST never confirmed → abandon (fail-safe).
e.append(terminal(entry, OpFailed))
res.RolledBack++
e.logger.Warn("recover: in-flight op had no task id; marked failed (fail-safe)",
"op_id", entry.OpID, "vmid", entry.VMID, "kind", entry.Kind)
continue
}
st, err := e.api.TaskStatusOnce(ctx, entry.UPID)
if err != nil {
res.Unresolved++