Restore a container backup with a smaller root disk

Proxmox only grows disks. The restore dialog gets an optional "Root
disk GiB" field for container backups; with a size, the backup is
restored as a new guest with --rootfs <storage>:<N> (Proxmox's advanced
restore mode).

- A smaller disk is only allowed for a new VMID, checked in the dialog
  and again in cli.restore(): with force, Proxmox deletes the old disks
  before it unpacks, and a failed unpack then removes the guest.
- The size must be a positive integer (0 gives an unlimited subvol on
  ZFS/dir storage) and above the used root disk space of a running
  guest.
- The confirm screen is a separate DANGER screen with a red border: it
  says mount points are dropped and their backed-up files land in the
  root disk, raw lxc.* lines can come back under root@pam, root disk
  options are not carried over, and the used space is no fit check.
- Never --unique or ignore-unpack-errors.

Design: docs/plans/lxc-shrink.md (section A).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DuckbootandClaude Opus 5.5 committed 2026-11-04 19:58:02 +01:00
1 parent 9b472e8cf6
commit 82902edfc8
5 files changed
+457 -8

No files matched your search

+83 -6
View File
@@ -56,6 +56,7 @@ class DialogScreen(ModalScreen):
DialogScreen .row Label { width: 14; height: 3; content-align: left middle; }
DialogScreen .row Select, DialogScreen .row Input { width: 1fr; }
DialogScreen .row Select.mode { width: 16; }
DialogScreen #dialog.danger { border: heavy $error; }
DialogScreen .sub {
height: auto; border: round $accent; border-title-style: bold; padding: 0 1;
}
@@ -72,12 +73,15 @@ class DialogScreen(ModalScreen):
class ConfirmScreen(DialogScreen):
BINDINGS = [("escape", "dismiss(False)", "No")] # noqa: RUF012 - Textual idiom
def __init__(self, message: str, title: str = "Confirm") -> None:
def __init__(
self, message: str, title: str = "Confirm", danger: bool = False
) -> None:
super().__init__()
self.message, self.title_text = message, title
self.message, self.title_text, self.danger = message, title, danger
def compose(self) -> ComposeResult:
with self.box(self.title_text):
with self.box(self.title_text) as box:
box.set_class(self.danger, "danger")
yield Static(self.message, markup=False)
with Horizontal(classes="buttons"):
yield Button("Yes", id="yes", variant="error")
@@ -706,7 +710,7 @@ class UpdatesScreen(ModalScreen):
class RestoreScreen(DialogScreen):
"""Restore one backup; dismisses with {overwrite, vmid, storage, start}, or None."""
"""Restore one backup; dismisses with {overwrite, vmid, storage, start, rootfs_gb}, or None."""
BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom
@@ -735,6 +739,13 @@ class RestoreScreen(DialogScreen):
id="storage",
)
yield Checkbox("Start after restore", id="start")
if "vzdump-lxc-" in self.backup["volid"]:
with Horizontal(classes="row"):
yield Label("Root disk GiB")
yield Input(
placeholder="empty = as in the backup; smaller = DANGEROUS",
id="rootfs",
)
with Horizontal(classes="row"):
yield Label("Confirm")
yield Input(placeholder="type the VMID to overwrite", id="confirm")
@@ -752,6 +763,21 @@ class RestoreScreen(DialogScreen):
self.dismiss(None)
return
overwrite = self.query_one("#overwrite", RadioButton).value
rootfs_gb = None
for field in self.query("#rootfs"):
text = field.value.strip()
if text and (not text.isdecimal() or int(text) <= 0):
self.app.notify(
"The root disk size must be a positive whole number of GiB",
severity="error",
)
return
rootfs_gb = int(text) if text else None
if rootfs_gb and overwrite: # force=1 destroys the old disks before extracting
self.app.notify(
"A smaller root disk is only possible as a new guest", severity="error"
)
return
if overwrite:
vmid = self.guest["vmid"]
if self.query_one("#confirm", Input).value != str(vmid):
@@ -778,6 +804,7 @@ class RestoreScreen(DialogScreen):
"vmid": vmid,
"storage": self.query_one("#storage", Select).value,
"start": self.query_one("#start", Checkbox).value,
"rootfs_gb": rootfs_gb,
}
)
@@ -1198,8 +1225,9 @@ class InfoScreen(ModalScreen):
if values["overwrite"] and cli.status(guest) != "stopped":
raise cli.CliError(f"{who} must be stopped to be overwritten")
cli.restore(
guest, volid, vmid, values["storage"], values["overwrite"], start
)
guest, volid, vmid, values["storage"], values["overwrite"], start,
rootfs_gb=values["rootfs_gb"],
) # fmt: skip
def confirmed(ok: bool) -> None:
if ok:
@@ -1215,12 +1243,61 @@ class InfoScreen(ModalScreen):
),
confirmed,
)
elif values["rootfs_gb"]:
self._confirm_shrink(what, text, values, confirmed)
else:
self.app.push_screen(
ConfirmScreen(f"Restore {what} as new guest {vmid}? {text}", "Restore"),
confirmed,
)
@work(thread=True)
def _confirm_shrink(self, what: str, text: str, values: dict, confirmed) -> None:
size = values["rootfs_gb"]
try: # live: the used space only exists while the guest runs
now = cli.current(self.guest)
except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app
self.app.call_from_thread(self.app.notify, _message(exc), severity="error")
return
used = int(now.get("disk") or 0) if now["status"] == "running" else 0
if used and size * 2**30 <= used:
self.app.call_from_thread(
self.app.notify,
f"{size} GiB is not above the {used / 2**30:.1f} GiB the running guest "
"already uses on its root disk",
severity="error",
)
return
mounts = ", ".join(
f"{k} ({m[1]})"
for k, v in sorted(self.config.items())
if re.fullmatch(r"mp\d+", k) and (m := re.search(r"\bmp=([^,]+)", str(v)))
)
message = (
f"DANGER: restore {what} as new guest {values['vmid']} with a root disk of "
f"only {size} GiB.\n\n"
"- Every mount point (mpN) of the backup is dropped from the new guest. "
"Files of mount points with backup=1 land inside the root disk and count "
"against its size; mount points with backup=0 are not in the backup at all.\n"
f"- The guest has these mount points now: {mounts or 'none'}. The backup "
"may differ.\n"
"- Raw lxc.* lines, including lxc.mount.entry bind mounts, may come back "
"when cv4pve-cli runs as root@pam: never run both guests at once.\n"
"- Root disk options (acl, quota, mountoptions, replicate) are not carried "
"over.\n"
f"- Used on the root disk now: "
f"{f'{used / 2**30:.1f} GiB' if used else 'unknown'}. This is not a fit "
"check: the backup may be older, mount point data and ext4 overhead (5 % "
"reserve, inodes) come on top.\n"
"- If the disk is too small the restore task fails. The original is "
f"untouched: this is always a new guest.\n\n{text}"
)
self.app.call_from_thread(
self.app.push_screen,
ConfirmScreen(message, "DANGER: smaller root disk", danger=True),
confirmed,
)
@work(thread=True)
def _confirm_overwrite(self, screen: ConfirmScreen, confirmed) -> None:
try: # live status: the guest table's status may be stale
+12 -1
View File
@@ -215,8 +215,12 @@ def restore(
storage: str,
force: bool = False,
start: bool = False,
rootfs_gb: int | None = None,
) -> None:
"""Restore `volid` as `vmid`; `force` (overwrite) only for the guest's own VMID."""
"""Restore `volid` as `vmid`; `force` (overwrite) only for the guest's own VMID.
`rootfs_gb` (container, new guest only) restores onto a root disk of that size.
"""
_positive(vmid, "vmid")
if force and vmid != guest["vmid"]:
raise ValueError(
@@ -230,11 +234,18 @@ def restore(
)
if not re.fullmatch(_STORAGE, storage):
raise ValueError(f"invalid storage name: {storage!r}")
if rootfs_gb is not None:
# 0 would make PVE allocate an unlimited subvol on ZFS
_positive(rootfs_gb, "root disk size")
if force or backup_type != "lxc":
raise ValueError("a smaller root disk needs a container and a new guest")
opts = ["--storage", storage]
if force:
opts += ["--force", "1"]
if start:
opts += ["--start", "1"]
if rootfs_gb is not None:
opts += ["--rootfs", f"{storage}:{rootfs_gb}"]
run(
"do", "restore", kind(guest), guest["node"], str(vmid), volid, *opts,
"-y", "--wait", timeout=3600,