367a503a0f
F-REBOOT — a guest rebooted mid-backup never came back (fault 11: 9m47s of total appliance outage, no lock, nothing retrying). The existing stale-lock recovery is correct but missed it two ways: its predicate needs a stale vzdump lock and that guest was unlocked, and it runs only at agent startup. New periodic guest-power watchdog acts on 'should be running, is not, is not locked'. onboot is the should-be-running signal, not invented here: stalelock.go already uses it for this same decision, it is 0 on scratch/golden, and pve-guests uses it at host boot. Guards: onboot:0 never touched (Scenario B), a locked guest is left to the stale-lock path, a guest with a vzdump in flight is left stopped, unprovable ownership acts on nothing, unconfirmable backup state fails safe. Bounded retry 3x at 1/2/4m then ERROR (Scenario C) — a healthy start takes ~25s. F-LEAK — a failed restore-test could not destroy its scratch (403 VM.Allocate). It is pool membership, not privsep: VM.Allocate is granted at /pool/felhom only, and a failed restore never completes the --pool association. Fix needs NO new grant — Pool.Allocate is already held, so the teardown adopts the stranded scratch into the pool and retries the destroy. Guarded by scratchAdoptAllowed: scratch provenance AND the numeric band, both required (Scenario E). Six red-proofs across both fixes, all observed failing.
72 lines
3.2 KiB
Go
72 lines
3.2 KiB
Go
package reconcile
|
|
|
|
import "testing"
|
|
|
|
// F-LEAK (Campaign 8): a restore-test whose RESTORE FAILED could not destroy its own scratch guest —
|
|
// `DELETE /nodes/x/lxc/990000` → 403 "missing privilege VM.Allocate". The cause is pool membership,
|
|
// not privsep: VM.Allocate is granted at /pool/felhom only, and a failed restore never completes the
|
|
// `--pool felhom` association, so the guest's own path resolves to / where the token holds nothing.
|
|
//
|
|
// The fix adopts the stranded scratch into the pool (Pool.Allocate — already granted) and retries the
|
|
// destroy. scratchAdoptAllowed is the guard that keeps that from becoming "co-opt any guest into the
|
|
// pool and delete it", and this file is its mirror test.
|
|
//
|
|
// Scenario D (the scratch is destroyed) is the live replay; Scenario E — the agent still cannot reach
|
|
// a non-scratch guest this way — is HERE, because it must hold as a pure property and not depend on
|
|
// what PVE happens to refuse.
|
|
|
|
// Scenario E — the adoption path REFUSES anything outside the scratch band.
|
|
//
|
|
// RED-PROOF: replace the band check with `return true, ""` → every case below reports allowed and the
|
|
// test fails with "adoption ALLOWED for vmid 9201 — that would let the agent co-opt a customer guest
|
|
// into the pool and destroy it".
|
|
func TestScratchAdoptAllowed_RefusesNonScratch(t *testing.T) {
|
|
const min, max = 990000, 990009
|
|
for _, tc := range []struct {
|
|
name string
|
|
vmid int
|
|
scratch bool
|
|
}{
|
|
{"a live customer guest", 9201, true}, // scratch-flagged but OUTSIDE the band
|
|
{"the golden image", 9100, true}, // ditto
|
|
{"just below the band", min - 1, true}, // off-by-one
|
|
{"just above the band", max + 1, true}, // off-by-one
|
|
{"in-band but NOT scratch provenance", min, false},
|
|
{"neither", 100, false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ok, why := scratchAdoptAllowed(tc.vmid, min, max, tc.scratch)
|
|
if ok {
|
|
t.Errorf("adoption ALLOWED for vmid %d — that would let the agent co-opt a non-scratch guest into the pool and destroy it", tc.vmid)
|
|
}
|
|
if why == "" {
|
|
t.Error("refusal carried no reason — a silent refusal is unreviewable")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The in-band, scratch-provenanced case IS allowed — otherwise the fix does nothing and the leak stays.
|
|
//
|
|
// RED-PROOF: make scratchAdoptAllowed always return false → this fails with "adoption refused for a
|
|
// genuine in-band scratch guest", i.e. F-LEAK is not fixed at all.
|
|
func TestScratchAdoptAllowed_AllowsGenuineScratch(t *testing.T) {
|
|
const min, max = 990000, 990009
|
|
for _, vmid := range []int{min, min + 5, max} {
|
|
ok, why := scratchAdoptAllowed(vmid, min, max, true)
|
|
if !ok {
|
|
t.Errorf("adoption refused for a genuine in-band scratch guest %d: %s", vmid, why)
|
|
}
|
|
}
|
|
}
|
|
|
|
// An unconfigured band must refuse everything rather than defaulting to something permissive — a
|
|
// zero-valued band is a wiring bug, and the safe reading of a wiring bug is "do nothing".
|
|
func TestScratchAdoptAllowed_UnconfiguredBandRefuses(t *testing.T) {
|
|
for _, tc := range []struct{ min, max int }{{0, 0}, {0, 990009}, {990009, 990000}, {-1, 5}} {
|
|
if ok, _ := scratchAdoptAllowed(990000, tc.min, tc.max, true); ok {
|
|
t.Errorf("adoption allowed with an unconfigured band [%d,%d]", tc.min, tc.max)
|
|
}
|
|
}
|
|
}
|