From b1697874ec751c189ba2331b1c0ef4d7da66ebfe Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 8 Jul 2026 09:46:30 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6 --- internal/backup/runner.go | 2 + internal/hub/report.go | 5 + internal/reconcile/restoretest.go | 182 ++++++++++-------- internal/reconcile/restoretest_mounts_test.go | 126 +++++++----- internal/reconcile/restoretest_test.go | 138 +++++++++++++ 5 files changed, 331 insertions(+), 122 deletions(-) diff --git a/internal/backup/runner.go b/internal/backup/runner.go index 553debb..c69190a 100644 --- a/internal/backup/runner.go +++ b/internal/backup/runner.go @@ -308,6 +308,8 @@ func ToHubRestoreTest(res reconcile.RestoreTestResult, testedAt time.Time) hub.R DurationSeconds: res.Duration.Seconds(), Warnings: res.StartWarnings, WarningsRecognized: res.WarningsRecognized, + MountParity: res.MountParity, + MountInventory: res.MountInventory, } if res.Err != nil { rt.Error = res.Err.Error() diff --git a/internal/hub/report.go b/internal/hub/report.go index 22deeae..87c65c2 100644 --- a/internal/hub/report.go +++ b/internal/hub/report.go @@ -304,6 +304,11 @@ type RestoreTest struct { // (⇒ false) when absent — and false is the SAFE default: the hub then treats it as an // unrecognized warning (louder), so a missing flag can only over-notice, never hide. WarningsRecognized bool `json:"warnings_recognized,omitempty"` + // MountParity ("ok"|"mismatch"; omitted on pre-v0.76.0 agents) + MountInventory (the verified + // mpN set) carry the GL-5b full-fidelity proof: the restored scratch matched the ARCHIVE's + // mount layout, not just booted. Additive — a hub that predates them ignores the unknown keys. + MountParity string `json:"mount_parity,omitempty"` + MountInventory []string `json:"mount_inventory,omitempty"` } // PBSSnapshot is one PBS (offsite) snapshot's inventory + integrity state (doc 03 §8, slice diff --git a/internal/reconcile/restoretest.go b/internal/reconcile/restoretest.go index 5ca724d..a7af2a2 100644 --- a/internal/reconcile/restoretest.go +++ b/internal/reconcile/restoretest.go @@ -4,7 +4,7 @@ import ( "context" "fmt" "math" - "regexp" + "sort" "strconv" "strings" "time" @@ -63,6 +63,14 @@ type RestoreTestResult struct { // wrong/stale recognizer can at worst over-notice a benign warning, never false-fail and // never hide a real one. Empty StartWarnings ⇒ trivially recognized (N/A). WarningsRecognized bool + // MountParity is "ok" when the restored scratch's mpN set matches the archive's (GL-5b: the + // assert that keeps PVE's drop-unlisted-mountpoints rule from ever regressing into a green + // light); "mismatch" fails the test with Err naming the delta. "" on runs that never reached + // the assert (restore failed earlier). + MountParity string + // MountInventory lists the parity-verified mountpoints ("mpN= (G)" / throwaway + // stand-ins) — the record's proof of WHAT was verified, not just that something booted. + MountInventory []string } // benignWarningAnchor is a deliberately version-FREE substring of the systemd-nesting start @@ -198,36 +206,27 @@ func (e *Engine) runScratchTest(ctx context.Context, vmid int, spec RestoreTestS // 1. Restore into the fresh scratch VMID (benign create path). The UPID is for error // detection only — it does NOT make the Scratch entry terminal (teardown does). - // A source guest with a host BIND-mount mountpoint (slice-10 data drive) can't be - // vzrestore'd by the privsep token ("restoring 'mpN' to bind mount is only possible for - // root"). The restore-test only needs the guest to BOOT — the data drives are irrelevant - // (their host paths would also collide). So neutralize each source bind-mount mpN to a - // throwaway volume on the restore storage (needs no root). Best-effort: if the source - // config can't be read, restore as-is (a bind-mount guest then fails as before, in the verdict). - var mountOverrides map[string]string - if srcVMID, ok := archiveVMID(spec.Archive); ok { - if srcCfg, cerr := e.api.GuestConfig(ctx, srcVMID); cerr == nil { - if binds := bindMountOverrides(srcCfg.MountPoints(), spec.RestoreStorage); len(binds) > 0 { - // PVE refuses a restore that carries mountpoint params unless `rootfs` is also set - // ("mount points configured, but 'rootfs' not set"). Size the rootfs override from the - // SOURCE rootfs (restore needs target >= the archive's volume). Without a parseable - // size we can't safely override, so restore as-is (the bind-mount restore then fails - // in the verdict rather than risking a wrong rootfs size). - if sz := rootfsSizeGB(srcCfg.RootFS); sz > 0 { - binds["rootfs"] = fmt.Sprintf("%s:%d", spec.RestoreStorage, sz) - mountOverrides = binds - e.logger.Info("restore-test: neutralizing source bind-mount mountpoints for scratch restore", - "source_vmid", srcVMID, "scratch", vmid, "bind_mounts", len(binds)-1, "rootfs_gb", sz) - } else { - e.logger.Warn("restore-test: source has bind mounts but rootfs size unparseable — restoring as-is", - "source_vmid", srcVMID, "rootfs", srcCfg.RootFS) - } - } - } else { - e.logger.Warn("restore-test: could not read source config for mp overrides (restoring as-is)", - "source_vmid", srcVMID, "err", cerr) - } + // The restore params derive from the ARCHIVE's own embedded config — the object under + // test — exactly like DR bring-up (GL-5b closes GL-5 finding #2's mirror image): PVE's + // explicit-params restore REQUIRES an explicit rootfs AND silently DROPS unlisted + // mountpoints, so the pre-v0.76.0 live-source-config bind-override path boot-verified + // scratch guests WITHOUT their storage mpN — weaker verification than it claimed. The FULL + // layout now rides: rootfs explicit, every storage mpN passed through (its content is + // genuinely EXTRACTED — full fidelity; the added runtime IS the verification), the two + // structural binds → throwaway stand-ins. An unreadable archive config or an unknown + // topology REFUSES up front — never restore a partial guest to "verify" it. + rawCfg, err := e.api.ExtractArchiveConfig(ctx, spec.Archive) + if err != nil { + res.Err = fmt.Errorf("reconcile: restore-test extract archive config: %w", err) + return false } + mountOverrides, err := drRestoreOverrides(rawCfg, spec.RestoreStorage) + if err != nil { + res.Err = fmt.Errorf("reconcile: restore-test: %w", err) + return false + } + e.logger.Info("restore-test: full-fidelity restore params derived from the archive config", + "scratch", vmid, "params", len(mountOverrides)) upid, err := e.api.RestoreLXC(ctx, proxmox.RestoreLXCOptions{ // Pool=DefaultPool so the scratch guest is created INTO the felhom pool — else a pool-scoped // token 403s on the scratch guest's config/start/destroy (SPIKE residual #2). @@ -262,6 +261,21 @@ func (e *Engine) runScratchTest(ctx context.Context, vmid int, spec RestoreTestS res.Err = fmt.Errorf("reconcile: restore-test read scratch config: %w", err) return } + + // 2b. Mount-parity assert (GL-5b, the non-hollow core): the restored guest's mpN set must + // match the archive's — storage mpN as real volumes at their archived path+size, the two + // structural binds as their throwaway stand-ins. This makes GL-5's PVE rule (b) — + // unlisted mountpoints silently dropped — impossible to regress into a green light: a + // boot-only verify cannot see a missing data volume; this can. + inventory, delta := mountParity(archiveCurrentConfig(rawCfg), cfg.MountPoints()) + res.MountInventory = inventory + if len(delta) > 0 { + res.MountParity = "mismatch" + res.Err = fmt.Errorf("reconcile: restore-test mount parity FAILED (restored scratch does not match the archive): %s", strings.Join(delta, "; ")) + return + } + res.MountParity = "ok" + for key, val := range cfg.Nets() { if _, err := e.api.SetConfig(ctx, vmid, map[string]string{key: withLinkDown(val)}); err != nil { res.Err = fmt.Errorf("reconcile: restore-test net link-down %s: %w", key, err) @@ -307,58 +321,72 @@ func (e *Engine) runScratchTest(ctx context.Context, vmid int, spec RestoreTestS return false } -// archiveVMID extracts the source VMID from a backup archive volid. Handles PBS volids -// (":backup/ct//" and the /vm// form) and vzdump file volids -// (":backup/vzdump-(lxc|qemu)--..."). Returns false when no vmid is found. -func archiveVMID(volid string) (int, bool) { - for _, re := range []*regexp.Regexp{pbsArchiveRE, vzdumpArchiveRE} { - if m := re.FindStringSubmatch(volid); m != nil { - if n, err := strconv.Atoi(m[1]); err == nil { - return n, true - } - } - } - return 0, false -} - -var ( - pbsArchiveRE = regexp.MustCompile(`/(?:ct|vm)/(\d+)/`) - vzdumpArchiveRE = regexp.MustCompile(`vzdump-(?:lxc|qemu)-(\d+)-`) -) - -// bindMountOverrides maps each BIND-mount mpN (the volume part is an absolute host PATH, not a -// "storage:volume") to a throwaway 1G volume override on restoreStorage, preserving the in-guest -// mount path. Storage-backed mpN are left to restore normally (returns nil when there are none). -// This is what lets the restore-test boot-verify a slice-10 enrolled guest whose data drive is a -// host bind mount the privsep token can't otherwise restore. -func bindMountOverrides(mps map[string]string, restoreStorage string) map[string]string { - out := map[string]string{} - for key, val := range mps { - volPart, rest, _ := strings.Cut(val, ",") - if !strings.HasPrefix(volPart, "/") { - continue // "storage:volume" → a real volume, not a host bind mount - } - mp := mountPathOf(rest) - if mp == "" { - mp = volPart // fall back to the host path if no explicit in-guest mp= - } - out[key] = throwawayVolumeOverride(restoreStorage, mp) - } - if len(out) == 0 { - return nil - } - return out -} - // throwawayVolumeOverride is the ONE source of the restore-override value format: a throwaway 1G -// volume on restoreStorage at the given in-guest path, excluded from backup. Used by the -// restore-test's source-config-derived overrides (above) and by the DR bring-up's synthesized -// structural-bind overrides (GL-5, bringup.go — its mpN are platform constants, so it skips this -// file's is-a-bind filtering and calls the format directly). +// volume on restoreStorage at the given in-guest path, excluded from backup. Used by +// drRestoreOverrides (bringup.go) for the structural binds — which both the DR bring-up and the +// restore-test (GL-5b) derive their restore params from. (The old live-source-config +// bindMountOverrides/archiveVMID path was deleted in v0.76.0 when the restore-test switched to +// archive-derived params: it verified the wrong object AND tripped PVE's drop-unlisted-mountpoints +// rule; keeping the dead lookalike reachable is how the next bug happens.) func throwawayVolumeOverride(restoreStorage, guestPath string) string { return fmt.Sprintf("%s:1,mp=%s,backup=0", restoreStorage, guestPath) } +// mountParity compares the restored scratch guest's mpN set against the archive's current config +// (GL-5b). Per archive mpN: a storage-backed one must exist restored at the same in-guest path +// with at least the archived size (the pass-through recreates it at exactly that size); a +// structural host bind must exist as its throwaway stand-in at the same path. Restored mpN slots +// the archive doesn't carry are a delta too. Returns the verified inventory + the mismatch list +// (empty delta = parity). Deterministic order (sorted slots). +func mountParity(archiveCfg, restored map[string]string) (inventory, delta []string) { + isMP := func(k string) bool { + return len(k) > 2 && k[:2] == "mp" && k[2] >= '0' && k[2] <= '9' + } + slots := make([]string, 0, len(archiveCfg)) + for k := range archiveCfg { + if isMP(k) { + slots = append(slots, k) + } + } + sort.Strings(slots) + for _, k := range slots { + aval := archiveCfg[k] + avol, arest, _ := strings.Cut(aval, ",") + apath := mountPathOf(arest) + if apath == "" && strings.HasPrefix(avol, "/") { + apath = avol // a bind without an explicit mp= mounts at its host path + } + rval, ok := restored[k] + if !ok { + delta = append(delta, fmt.Sprintf("%s MISSING from the restored guest (archive: %s)", k, aval)) + continue + } + _, rrest, _ := strings.Cut(rval, ",") + if rpath := mountPathOf(rrest); rpath != apath { + delta = append(delta, fmt.Sprintf("%s path %q != archive %q", k, rpath, apath)) + continue + } + if strings.HasPrefix(avol, "/") { + // structural bind → its throwaway stand-in (a real volume at the same path) + inventory = append(inventory, fmt.Sprintf("%s=%s (throwaway for the archived bind)", k, apath)) + continue + } + asz, rsz := rootfsSizeGB(aval), rootfsSizeGB(rval) + if asz > 0 && rsz < asz { + delta = append(delta, fmt.Sprintf("%s size %dG < archive %dG", k, rsz, asz)) + continue + } + inventory = append(inventory, fmt.Sprintf("%s=%s (%dG)", k, apath, rsz)) + } + for k, v := range restored { + if isMP(k) && archiveCfg[k] == "" { + delta = append(delta, fmt.Sprintf("%s present on the restored guest but not in the archive: %s", k, v)) + } + } + sort.Strings(delta) + return inventory, delta +} + // mountPathOf returns the mp= field from an mpN value's trailing options ("" if absent). func mountPathOf(opts string) string { for _, kv := range strings.Split(opts, ",") { diff --git a/internal/reconcile/restoretest_mounts_test.go b/internal/reconcile/restoretest_mounts_test.go index b48d8d1..f6544c4 100644 --- a/internal/reconcile/restoretest_mounts_test.go +++ b/internal/reconcile/restoretest_mounts_test.go @@ -1,51 +1,13 @@ package reconcile -import "testing" +import ( + "strings" + "testing" +) -func TestArchiveVMID(t *testing.T) { - cases := map[string]struct { - volid string - want int - ok bool - }{ - "pbs ct": {"felhom-pbs:backup/ct/9201/2026-06-12T18:29:58Z", 9201, true}, - "pbs vm": {"felhom-pbs:backup/vm/142/2026-06-12T18:29:58Z", 142, true}, - "vzdump lxc": {"local:backup/vzdump-lxc-9201-2026_06_12-18_29_58.tar.zst", 9201, true}, - "vzdump qemu": {"local:backup/vzdump-qemu-100-2026_06_12.vma.zst", 100, true}, - "none": {"local:iso/whatever.iso", 0, false}, - } - for name, c := range cases { - t.Run(name, func(t *testing.T) { - got, ok := archiveVMID(c.volid) - if ok != c.ok || got != c.want { - t.Errorf("archiveVMID(%q) = (%d,%v), want (%d,%v)", c.volid, got, ok, c.want, c.ok) - } - }) - } -} - -func TestBindMountOverrides(t *testing.T) { - mps := map[string]string{ - "mp0": "/mnt/felhom-usb/felhom-data,mp=/mnt/felhom-usb", // bind mount → override - "mp1": "local-lvm:8,mp=/data,backup=0", // real volume → left alone - "mp2": "/srv/extra", // bind mount, no explicit mp= → use host path - } - out := bindMountOverrides(mps, "local-lvm") - if _, ok := out["mp1"]; ok { - t.Error("mp1 is a storage volume and must NOT be overridden") - } - if got, want := out["mp0"], "local-lvm:1,mp=/mnt/felhom-usb,backup=0"; got != want { - t.Errorf("mp0 override = %q, want %q", got, want) - } - if got, want := out["mp2"], "local-lvm:1,mp=/srv/extra,backup=0"; got != want { - t.Errorf("mp2 override = %q, want %q", got, want) - } - - // No bind mounts → nil (restore proceeds unchanged). - if bindMountOverrides(map[string]string{"mp0": "local-lvm:8,mp=/data"}, "local-lvm") != nil { - t.Error("expected nil when there are no bind mounts") - } -} +// (TestArchiveVMID + TestBindMountOverrides were deleted with their subjects in v0.76.0/GL-5b — +// the restore-test now derives its params from the ARCHIVE's embedded config via +// drRestoreOverrides; the live-source-config path was the pre-fix shape.) func TestRootfsSizeGB(t *testing.T) { cases := map[string]int{ @@ -62,3 +24,77 @@ func TestRootfsSizeGB(t *testing.T) { } } } + +// GL-5b: the mount-parity comparator — the assert that keeps PVE's drop-unlisted-mountpoints rule +// from regressing into a green light. Pure-function cases; the engine-level Scenario A/B live in +// restoretest_test.go / the fake-driven tests. +func TestMountParity(t *testing.T) { + archive := map[string]string{ + "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", + } + + // full parity: storage mpN at archived path+size, binds as throwaway stand-ins + restored := map[string]string{ + "mp0": "local-lvm:vm-990000-disk-1,mp=/var/lib/docker,backup=1,size=200G", + "mp1": "local-lvm:vm-990000-disk-2,mp=/mnt/sys_drive,backup=1,size=50G", + "mp8": "local-lvm:vm-990000-disk-3,mp=/mnt/felhom-drives,backup=0,size=1G", + "mp9": "local-lvm:vm-990000-disk-4,mp=/etc/felhom-bootstrap,backup=0,size=1G", + } + inv, delta := mountParity(archive, restored) + if len(delta) != 0 { + t.Fatalf("full parity expected, got delta: %v", delta) + } + if len(inv) != 4 { + t.Fatalf("inventory must carry all 4 verified mpN, got %v", inv) + } + + // the GL-5 constraint-(b) shape: a storage mpN silently dropped → NAMED delta (Scenario B core) + dropped := map[string]string{ + "mp1": restored["mp1"], "mp8": restored["mp8"], "mp9": restored["mp9"], + } + _, delta = mountParity(archive, dropped) + if len(delta) != 1 || !strings.Contains(delta[0], "mp0 MISSING") { + t.Fatalf("dropped mp0 must be a named delta, got %v", delta) + } + + // wrong path + wrongPath := map[string]string{ + "mp0": "local-lvm:vm-990000-disk-1,mp=/elsewhere,size=200G", + "mp1": restored["mp1"], "mp8": restored["mp8"], "mp9": restored["mp9"], + } + _, delta = mountParity(archive, wrongPath) + if len(delta) != 1 || !strings.Contains(delta[0], `mp0 path "/elsewhere"`) { + t.Fatalf("wrong path must be a named delta, got %v", delta) + } + + // undersized volume (a 1G stand-in where 200G of data should be = the dropped-content shape) + small := map[string]string{ + "mp0": "local-lvm:vm-990000-disk-1,mp=/var/lib/docker,size=1G", + "mp1": restored["mp1"], "mp8": restored["mp8"], "mp9": restored["mp9"], + } + _, delta = mountParity(archive, small) + if len(delta) != 1 || !strings.Contains(delta[0], "mp0 size 1G < archive 200G") { + t.Fatalf("undersized mpN must be a named delta, got %v", delta) + } + + // an extra restored mpN the archive never had is a delta too + extra := map[string]string{ + "mp0": restored["mp0"], "mp1": restored["mp1"], "mp8": restored["mp8"], "mp9": restored["mp9"], + "mp3": "local-lvm:vm-990000-disk-9,mp=/stray,size=1G", + } + _, delta = mountParity(archive, extra) + if len(delta) != 1 || !strings.Contains(delta[0], "mp3 present on the restored guest but not in the archive") { + t.Fatalf("extra mpN must be a named delta, got %v", delta) + } + + // no mpN anywhere = trivially parity (the golden-shaped archive) + inv, delta = mountParity(map[string]string{"rootfs": "l:d,size=8G"}, map[string]string{}) + if len(delta) != 0 || len(inv) != 0 { + t.Fatalf("mpN-less archive must be trivial parity, got inv=%v delta=%v", inv, delta) + } +} diff --git a/internal/reconcile/restoretest_test.go b/internal/reconcile/restoretest_test.go index 50a5bb2..1bde068 100644 --- a/internal/reconcile/restoretest_test.go +++ b/internal/reconcile/restoretest_test.go @@ -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) + } +}