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
+81 -45
View File
@@ -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)
}
}