GL-5: DR restore passes the FULL archive layout (live finding #2)

The live scratch DR exposed the second half of PVE's all-or-nothing
explicit-params restore: mountpoints NOT named in the params are silently
DROPPED - the DR guest came up without its mp0/mp1 data volumes (boot
passed; the customer's world did not ride along). drRestoreOverrides now
derives the COMPLETE param set from the archive's extracted config:
explicit rootfs, every storage-backed mpN passed through (size + in-guest
path + backup flag preserved so vzrestore extracts its content), the two
structural binds replaced by 4d-swapped throwaways; unknown bind mpN or
unparseable size refuses loudly. Snapshot sections never shadow the
current config.

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:10:54 +02:00
parent 3bf0110697
commit b3446213df
3 changed files with 154 additions and 33 deletions
+46 -11
View File
@@ -978,19 +978,54 @@ func TestRunBringUp_DRUnusedDeleteFailureWarns(t *testing.T) {
}
}
// GL-5: the archive-config rootfs parse — current-config line wins, snapshot sections never shadow
// it, absent/unparseable → 0 (which makes the DR bring-up refuse rather than guess a size).
func TestArchiveRootfsSizeGB(t *testing.T) {
full := "hostname: demo\nrootfs: local-lvm:vm-9201-disk-0,size=32G\nmp0: local-lvm:vm-9201-disk-1,mp=/var/lib/docker,size=200G\n"
if got := archiveRootfsSizeGB(full); got != 32 {
t.Errorf("full config: got %d, want 32", got)
// GL-5: drRestoreOverrides builds the COMPLETE explicit-params restore set from the archive's
// extracted config — the live validation proved PVE drops any mountpoint NOT named in the params,
// so mp0/mp1 pass-through is what keeps a DR guest's data volumes. Snapshot sections never shadow;
// unknown binds and unparseable sizes refuse.
func TestDRRestoreOverrides(t *testing.T) {
raw := `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
[snap1]
rootfs: local-lvm:vm-9201-disk-9,size=99G
`
ov, err := drRestoreOverrides(raw, "local-lvm")
if err != nil {
t.Fatalf("drRestoreOverrides: %v", err)
}
snap := "hostname: demo\n\n[before-upgrade]\nrootfs: local-lvm:vm-9201-disk-9,size=99G\n"
if got := archiveRootfsSizeGB(snap); got != 0 {
t.Errorf("a snapshot section's rootfs must NOT shadow an absent current one: got %d, want 0", got)
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 got := archiveRootfsSizeGB("hostname: demo\n"); got != 0 {
t.Errorf("no rootfs line: got %d, want 0", got)
if len(ov) != len(want) {
t.Fatalf("overrides = %+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)
}
}
// an UNKNOWN bind mpN = unknown topology → refuse (never restore a guest missing a mount)
if _, err := drRestoreOverrides("rootfs: l:d,size=8G\nmp3: /srv/other,mp=/data\n", "local-lvm"); err == nil ||
!strings.Contains(err.Error(), "unknown bind mountpoint") {
t.Errorf("unknown bind must refuse, got %v", err)
}
// no parseable rootfs → refuse
if _, err := drRestoreOverrides("hostname: x\n", "local-lvm"); err == nil ||
!strings.Contains(err.Error(), "rootfs size") {
t.Errorf("missing rootfs must refuse, got %v", err)
}
// a storage mpN without a size → refuse (cannot pass it through)
if _, err := drRestoreOverrides("rootfs: l:d,size=8G\nmp0: l:d1,mp=/x\n", "local-lvm"); err == nil ||
!strings.Contains(err.Error(), "no parseable size") {
t.Errorf("sizeless mpN must refuse, got %v", err)
}
}