diff --git a/internal/reconcile/restoretest.go b/internal/reconcile/restoretest.go index a4d9203..4de2cb6 100644 --- a/internal/reconcile/restoretest.go +++ b/internal/reconcile/restoretest.go @@ -3,6 +3,7 @@ package reconcile import ( "context" "fmt" + "math" "regexp" "strconv" "strings" @@ -169,10 +170,21 @@ func (e *Engine) runScratchTest(ctx context.Context, vmid int, spec RestoreTestS 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)) + 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)", @@ -299,6 +311,47 @@ func mountPathOf(opts string) string { return "" } +// rootfsSizeGB parses the GB size from a volume spec's "size=" field (e.g. +// "local-lvm:vm-9201-disk-0,size=8G"), rounding UP to whole GB — a restore needs the target volume +// >= the archive's. Returns 0 when no size field is present (caller then skips the override). +func rootfsSizeGB(spec string) int { + for _, kv := range strings.Split(spec, ",") { + if v, ok := strings.CutPrefix(kv, "size="); ok { + return sizeToGB(v) + } + } + return 0 +} + +// sizeToGB converts a PVE size string ("8G", "512M", "1T", "8192K") to whole GB, rounding up (min 1 +// when positive). Returns 0 on a malformed value. +func sizeToGB(s string) int { + if s == "" { + return 0 + } + mult := 1.0 // default GB if no recognized unit suffix + num := s + switch s[len(s)-1] { + case 'T', 't': + mult, num = 1024, s[:len(s)-1] + case 'G', 'g': + mult, num = 1, s[:len(s)-1] + case 'M', 'm': + mult, num = 1.0/1024, s[:len(s)-1] + case 'K', 'k': + mult, num = 1.0/(1024*1024), s[:len(s)-1] + } + f, err := strconv.ParseFloat(num, 64) + if err != nil || f <= 0 { + return 0 + } + gb := int(math.Ceil(f * mult)) + if gb < 1 { + gb = 1 + } + return gb +} + // 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) { diff --git a/internal/reconcile/restoretest_mounts_test.go b/internal/reconcile/restoretest_mounts_test.go index 8859b53..b48d8d1 100644 --- a/internal/reconcile/restoretest_mounts_test.go +++ b/internal/reconcile/restoretest_mounts_test.go @@ -46,3 +46,19 @@ func TestBindMountOverrides(t *testing.T) { t.Error("expected nil when there are no bind mounts") } } + +func TestRootfsSizeGB(t *testing.T) { + cases := map[string]int{ + "local-lvm:vm-9201-disk-0,size=8G": 8, + "local-lvm:base,size=512M": 1, // rounds up + "local-lvm:base,size=1T": 1024, + "local-lvm:base,size=0": 0, + "local-lvm:base": 0, // no size field + "local-lvm:base,size=8": 8, // unit-less → GB + } + for spec, want := range cases { + if got := rootfsSizeGB(spec); got != want { + t.Errorf("rootfsSizeGB(%q) = %d, want %d", spec, got, want) + } + } +}