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:
2026-07-08 09:46:30 +02:00
parent 5a72a4b59c
commit b1697874ec
5 changed files with 331 additions and 122 deletions
+138
View File
@@ -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)
}
}