GL-5b: restore-test full-fidelity verification (v0.76.0)
The restore-test had GL-5 finding #2's mirror image: its live-source-config bind-override path tripped PVE's drop-unlisted-mountpoints rule, so scratch guests boot-verified WITHOUT their storage mpN - weaker verification than claimed. Params now derive from the ARCHIVE's own embedded config via ExtractArchiveConfig + drRestoreOverrides (the object under test; full layout, content genuinely extracted - the added runtime IS the verification); unreadable/unknown-topology archives refuse up front. NEW mount-parity assert (2b, pre-start): restored mpN set vs the archive's - missing/mispathed/undersized/extra mpN fail the test naming the delta, so constraint (b) can never regress into a green light. MountParity + MountInventory ride the result + hub wire record (additive). Dead bindMountOverrides/archiveVMID path deleted with its tests (no reachable lookalike). DR bring-up untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -529,3 +530,140 @@ func indexOf(s, sub string) int {
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ── GL-5b: full-fidelity restore-test (archive-derived params + mount parity) ────────────────────
|
||||
|
||||
// gl5bArchiveCfg is a 9201-shaped archive config: rootfs + two storage mpN + the two structural binds.
|
||||
const gl5bArchiveCfg = `hostname: demo
|
||||
rootfs: local-lvm:vm-9201-disk-0,size=32G
|
||||
mp0: local-lvm:vm-9201-disk-1,mp=/var/lib/docker,backup=1,size=200G
|
||||
mp1: local-lvm:vm-9201-disk-2,mp=/mnt/sys_drive,backup=1,size=50G
|
||||
mp8: /mnt/felhom-drives,mp=/mnt/felhom-drives
|
||||
mp9: /var/lib/felhom-agent/guests/9201/bootstrap,mp=/etc/felhom-bootstrap,ro=1
|
||||
`
|
||||
|
||||
// gl5bRestoredCfg builds the scratch guest's restored config as PVE would report it after a
|
||||
// FULL-fidelity restore (storage mpN at archived path+size, binds as 1G throwaways) + a net0 so
|
||||
// the link-down step runs.
|
||||
func gl5bRestoredCfg() proxmox.GuestConfig {
|
||||
c := scratchCfg()
|
||||
c.Extra["mp0"] = json.RawMessage(`"local-lvm:vm-990000-disk-1,mp=/var/lib/docker,backup=1,size=200G"`)
|
||||
c.Extra["mp1"] = json.RawMessage(`"local-lvm:vm-990000-disk-2,mp=/mnt/sys_drive,backup=1,size=50G"`)
|
||||
c.Extra["mp8"] = json.RawMessage(`"local-lvm:vm-990000-disk-3,mp=/mnt/felhom-drives,backup=0,size=1G"`)
|
||||
c.Extra["mp9"] = json.RawMessage(`"local-lvm:vm-990000-disk-4,mp=/etc/felhom-bootstrap,backup=0,size=1G"`)
|
||||
return c
|
||||
}
|
||||
|
||||
// GL-5b Scenario A: the restore-test passes the FULL drRestoreOverrides param set (derived from
|
||||
// the ARCHIVE config, not any live guest) and the parity assert verifies the restored mpN set.
|
||||
func TestRunRestoreTest_FullFidelityParams(t *testing.T) {
|
||||
api := &fakeAPI{cfg: map[int]proxmox.GuestConfig{990000: gl5bRestoredCfg()}, extractCfg: gl5bArchiveCfg}
|
||||
e, _, q := newEngine(t, api, EmptyProvider{})
|
||||
defer q.Close()
|
||||
|
||||
res := e.RunRestoreTest(context.Background(), RestoreTestSpec{
|
||||
Archive: "local:backup/vzdump-lxc-9201-x.tar.zst", RestoreStorage: "local-lvm",
|
||||
ScratchMin: 990000, ScratchMax: 990009, SourceTier: "local",
|
||||
})
|
||||
if res.Err != nil || !res.Pass {
|
||||
t.Fatalf("expected pass, got %+v", res)
|
||||
}
|
||||
if len(api.extracts) != 1 || api.extracts[0] != "local:backup/vzdump-lxc-9201-x.tar.zst" {
|
||||
t.Fatalf("params must derive from the ARCHIVE's extracted config: %+v", api.extracts)
|
||||
}
|
||||
ov := api.restores[0].MountOverrides
|
||||
want := map[string]string{
|
||||
"rootfs": "local-lvm:32",
|
||||
"mp0": "local-lvm:200,mp=/var/lib/docker,backup=1",
|
||||
"mp1": "local-lvm:50,mp=/mnt/sys_drive,backup=1",
|
||||
"mp8": "local-lvm:1,mp=/mnt/felhom-drives,backup=0",
|
||||
"mp9": "local-lvm:1,mp=/etc/felhom-bootstrap,backup=0",
|
||||
}
|
||||
if len(ov) != len(want) {
|
||||
t.Fatalf("MountOverrides = %+v, want %+v", ov, want)
|
||||
}
|
||||
for k, v := range want {
|
||||
if ov[k] != v {
|
||||
t.Errorf("override[%s] = %q, want %q", k, ov[k], v)
|
||||
}
|
||||
}
|
||||
if res.MountParity != "ok" {
|
||||
t.Errorf("MountParity = %q, want ok", res.MountParity)
|
||||
}
|
||||
if len(res.MountInventory) != 4 {
|
||||
t.Errorf("MountInventory must carry the 4 verified mpN, got %v", res.MountInventory)
|
||||
}
|
||||
// scratch still torn down (full-fidelity changes verification, not lifecycle)
|
||||
if len(api.destroys) != 1 || api.destroys[0] != 990000 {
|
||||
t.Errorf("scratch must be torn down: %+v", api.destroys)
|
||||
}
|
||||
}
|
||||
|
||||
// GL-5b Scenario B (the non-hollow core): a restored guest MISSING a storage mpN — exactly PVE's
|
||||
// drop-unlisted-mountpoints shape — boots green but must FAIL on parity, naming the mpN.
|
||||
// COMPANION RED-PROOF: with the 2b parity assert removed, this run passes silently (mutation
|
||||
// run→fail→revert recorded in the REPORT).
|
||||
func TestRunRestoreTest_ParityCatchesDroppedMount(t *testing.T) {
|
||||
c := gl5bRestoredCfg()
|
||||
delete(c.Extra, "mp0") // constraint-(b): the docker-data volume silently dropped
|
||||
api := &fakeAPI{cfg: map[int]proxmox.GuestConfig{990000: c}, extractCfg: gl5bArchiveCfg}
|
||||
e, _, q := newEngine(t, api, EmptyProvider{})
|
||||
defer q.Close()
|
||||
|
||||
res := e.RunRestoreTest(context.Background(), RestoreTestSpec{
|
||||
Archive: "local:backup/vzdump-lxc-9201-x.tar.zst", RestoreStorage: "local-lvm",
|
||||
ScratchMin: 990000, ScratchMax: 990009, SourceTier: "local",
|
||||
})
|
||||
if res.Pass || res.Err == nil {
|
||||
t.Fatalf("a dropped mpN must FAIL the test, got %+v", res)
|
||||
}
|
||||
if !strings.Contains(res.Err.Error(), "mount parity FAILED") || !strings.Contains(res.Err.Error(), "mp0 MISSING") {
|
||||
t.Fatalf("the verdict must name the missing mpN, got: %v", res.Err)
|
||||
}
|
||||
if res.MountParity != "mismatch" {
|
||||
t.Errorf("MountParity = %q, want mismatch", res.MountParity)
|
||||
}
|
||||
// the guest never boots (parity fails pre-start) and the scratch is still torn down
|
||||
if len(api.starts) != 0 {
|
||||
t.Errorf("a parity-failed scratch must not be started: %+v", api.starts)
|
||||
}
|
||||
if len(api.destroys) != 1 {
|
||||
t.Errorf("teardown must still fire (launch-proven): %+v", api.destroys)
|
||||
}
|
||||
}
|
||||
|
||||
// GL-5b Scenario C: refusals propagate — an unreadable archive config or an unknown-topology
|
||||
// archive refuses UP FRONT (no restore, no scratch, nothing to tear down).
|
||||
func TestRunRestoreTest_RefusalsPropagate(t *testing.T) {
|
||||
// unreadable archive config
|
||||
apiErr := &fakeAPI{cfg: map[int]proxmox.GuestConfig{990000: scratchCfg()}}
|
||||
apiErr.extractErr = errors.New("proxmox: GET extractconfig -> HTTP 500: volume not found")
|
||||
e1, _, q1 := newEngine(t, apiErr, EmptyProvider{})
|
||||
defer q1.Close()
|
||||
res := e1.RunRestoreTest(context.Background(), RestoreTestSpec{
|
||||
Archive: "local:backup/gone.tar.zst", RestoreStorage: "local-lvm",
|
||||
ScratchMin: 990000, ScratchMax: 990009, SourceTier: "local",
|
||||
})
|
||||
if res.Err == nil || !strings.Contains(res.Err.Error(), "extract archive config") {
|
||||
t.Fatalf("unreadable archive config must refuse naming the step, got %+v", res)
|
||||
}
|
||||
if len(apiErr.restores) != 0 || len(apiErr.destroys) != 0 {
|
||||
t.Fatalf("the refusal must fire BEFORE any restore/teardown: %+v %+v", apiErr.restores, apiErr.destroys)
|
||||
}
|
||||
|
||||
// unknown bind topology
|
||||
apiBind := &fakeAPI{cfg: map[int]proxmox.GuestConfig{990000: scratchCfg()},
|
||||
extractCfg: "rootfs: l:d,size=8G\nmp3: /srv/other,mp=/data\n"}
|
||||
e2, _, q2 := newEngine(t, apiBind, EmptyProvider{})
|
||||
defer q2.Close()
|
||||
res = e2.RunRestoreTest(context.Background(), RestoreTestSpec{
|
||||
Archive: "local:backup/x.tar.zst", RestoreStorage: "local-lvm",
|
||||
ScratchMin: 990000, ScratchMax: 990009, SourceTier: "local",
|
||||
})
|
||||
if res.Err == nil || !strings.Contains(res.Err.Error(), "unknown bind mountpoint") {
|
||||
t.Fatalf("unknown topology must refuse via drRestoreOverrides' error, got %+v", res)
|
||||
}
|
||||
if len(apiBind.restores) != 0 {
|
||||
t.Fatalf("never restore a partial guest to verify it: %+v", apiBind.restores)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user