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:
1 parent
9b472e8cf6
commit
82902edfc8
5 files changed
+457
-8
No files matched your search
+83
-6
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user