restore-test: neutralize source bind-mount mountpoints for scratch restore

A slice-10 enrolled guest's data drive is a host bind-mount mp0 that the privsep
token can't vzrestore ("bind mount is only possible for root") — so the restore-test
failed for every enrolled guest regardless of backup tier. The restore-test now reads
the source guest config (vmid from the archive volid) and passes RestoreLXC mp
overrides converting each bind-mount mpN to a throwaway 1G volume on the restore
storage (no root needed; boot-verify doesn't need the data). proxmox.RestoreLXC gains
MountOverrides. + unit tests (archiveVMID, bindMountOverrides).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 20:50:35 +02:00
parent 109dd853a3
commit 7fb7ef5c3d
4 changed files with 144 additions and 1 deletions
+10
View File
@@ -34,6 +34,13 @@ type RestoreLXCOptions struct {
Archive string // source archive volid, e.g. "local:backup/vzdump-lxc-9001-...tar.zst"
Storage string // target storage for the rootfs, e.g. "local-lvm"
Force bool // overwrite an existing VMID (destructive — caller must have authority)
// MountOverrides overrides specific mountpoints at restore time (mpN -> full value, e.g.
// "local-lvm:1,mp=/data,backup=0"). A restore param takes precedence over the archive's own
// mpN. The restore-test uses this to neutralize a SOURCE host bind-mount mountpoint (slice-10
// data drive) — vzrestore refuses to restore a bind mount under the privsep token ("restoring
// 'mpN' to bind mount is only possible for root"); replacing it with a throwaway volume needs
// no root and the boot-verify doesn't need the drive's data.
MountOverrides map[string]string
}
// RestoreLXC restores an LXC from a vzdump/PBS archive via POST /nodes/{node}/lxc
@@ -52,6 +59,9 @@ func (c *Client) RestoreLXC(ctx context.Context, opts RestoreLXCOptions) (string
if opts.Force {
v.Set("force", "1")
}
for k, val := range opts.MountOverrides {
v.Set(k, val) // e.g. mp0 -> "local-lvm:1,mp=/data,backup=0" (overrides the archive's mp0)
}
return c.dataString(ctx, http.MethodPost, "/nodes/"+c.node+"/lxc", v)
}
+74 -1
View File
@@ -3,6 +3,7 @@ package reconcile
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"time"
@@ -159,8 +160,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 {
mountOverrides = bindMountOverrides(srcCfg.MountPoints(), spec.RestoreStorage)
if len(mountOverrides) > 0 {
e.logger.Info("restore-test: neutralizing source bind-mount mountpoints for scratch restore",
"source_vmid", srcVMID, "scratch", vmid, "count", len(mountOverrides))
}
} else {
e.logger.Warn("restore-test: could not read source config for mp overrides (restoring as-is)",
"source_vmid", srcVMID, "err", cerr)
}
}
upid, err := e.api.RestoreLXC(ctx, proxmox.RestoreLXCOptions{
VMID: vmid, Archive: spec.Archive, Storage: spec.RestoreStorage,
VMID: vmid, Archive: spec.Archive, Storage: spec.RestoreStorage, MountOverrides: mountOverrides,
})
if err != nil {
res.Err = fmt.Errorf("reconcile: restore-test restore: %w", err)
@@ -226,6 +246,59 @@ func (e *Engine) runScratchTest(ctx context.Context, vmid int, spec RestoreTestS
res.Verified = "boot+running"
}
// archiveVMID extracts the source VMID from a backup archive volid. Handles PBS volids
// ("<store>:backup/ct/<vmid>/<ts>" and the /vm/<vmid>/ form) and vzdump file volids
// ("<store>:backup/vzdump-(lxc|qemu)-<vmid>-..."). 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] = fmt.Sprintf("%s:1,mp=%s,backup=0", restoreStorage, mp)
}
if len(out) == 0 {
return nil
}
return out
}
// 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, ",") {
if v, ok := strings.CutPrefix(kv, "mp="); ok {
return v
}
}
return ""
}
// teardownScratch destroys the scratch guest (benign, gated) and records the entry terminal.
// On any teardown failure it leaves the entry in-flight so Recover reaps the guest later.
func (e *Engine) teardownScratch(ctx context.Context, base JournalEntry) {
@@ -0,0 +1,48 @@
package reconcile
import "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")
}
}