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:
@@ -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++
|
||||
|
||||
Reference in New Issue
Block a user