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

+189 -1
View File
@@ -105,6 +105,7 @@ class Fake:
self.nextid, self.nextid_error = 105, None
self.restore_store = ["local-lvm", "zfs-pool"]
self.restore_calls, self.restore_error, self.restore_hook = [], None, None
self.restore_sizes = [] # rootfs_gb of every restore call
def list_guests(self):
self.list_calls += 1
@@ -298,8 +299,11 @@ class Fake:
def restore_storages(self, guest):
return list(self.restore_store)
def restore(self, guest, volid, vmid, storage, force=False, start=False):
def restore(
self, guest, volid, vmid, storage, force=False, start=False, rootfs_gb=None
):
self.restore_calls.append((guest["vmid"], volid, vmid, storage, force, start))
self.restore_sizes.append(rootfs_gb)
if self.restore_hook:
self.restore_hook()
if self.restore_error:
@@ -4921,3 +4925,187 @@ async def test_keybar_follows_the_info_screen_bindings(fake):
await open_info(app, pilot)
_bar, text = keybar_text(app)
assert "Restore backup" in text
# --- shrink restore: smaller rootfs (docs/plans/lxc-shrink.md, A) ---------------------------
CT_VOLID_SHRINK = "truenas-backup:backup/vzdump-lxc-100-new.tar.zst"
CT_MPS = {
"rootfs": "zfs-pool:subvol-100-disk-0,size=8G",
"mp0": "local-lvm:vm-100-disk-1,mp=/data,size=4G",
"mp1": "/srv/share,mp=/share",
}
def ct_shrink_setup(fake, status="stopped", disk=0, mps=True):
fake.guests = [VM, dict(CT, status=status, disk=disk)]
fake.net_configs[100] = dict(CT_MPS) if mps else {"rootfs": CT_MPS["rootfs"]}
fake.disk_map = {"rootfs": "8G"}
fake.backup_list = [
{**BACKUPS[0], "volid": CT_VOLID_SHRINK, "notes": "backrest (manual)"}
]
async def open_ct_restore(app, pilot):
await open_restore(app, pilot, down=1)
async def apply_shrink(app, pilot, size):
set_value(app, "#rootfs", size)
await press_button(app, pilot, "#apply")
async def test_rootfs_field_exists_for_lxc_backup_and_is_empty(fake):
ct_shrink_setup(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
assert app.screen.query_one("#rootfs", Input).value == ""
async def test_rootfs_field_is_absent_for_vm_backup(fake):
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_restore(app, pilot)
assert not app.screen.query("#rootfs")
async def test_lxc_restore_with_empty_size_is_unchanged(fake):
ct_shrink_setup(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
await apply_new(app, pilot)
assert app.screen.title_text == "Restore"
await press_button(app, pilot, "#yes")
assert fake.restore_sizes == [None]
async def test_shrink_confirm_is_a_danger_screen_naming_mount_points(fake):
ct_shrink_setup(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
await apply_shrink(app, pilot, "4")
assert isinstance(app.screen, ConfirmScreen)
assert app.screen.title_text.startswith("DANGER")
assert app.screen.query_one("#dialog").has_class("danger")
text = text_of(app.screen)
assert text.startswith("DANGER")
for needle in ("mp0", "/data", "mp1", "/share", "may differ", "backup=0",
"lxc.mount.entry", "root@pam", "acl", "unknown", "4 GiB",
"105", "zfs-pool"): # fmt: skip
assert needle in text, needle
assert fake.restore_calls == []
async def test_shrink_confirm_without_mount_points_says_none(fake):
ct_shrink_setup(fake, mps=False)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
await apply_shrink(app, pilot, "4")
assert "mp0" not in text_of(app.screen)
assert "none" in text_of(app.screen).lower()
async def test_shrink_confirmed_restores_new_guest_with_rootfs_gb(fake):
ct_shrink_setup(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
await apply_shrink(app, pilot, " 4 ")
await press_button(app, pilot, "#yes")
assert fake.restore_calls == [
(100, CT_VOLID_SHRINK, 105, "zfs-pool", False, False)
]
assert fake.restore_sizes == [4]
assert app.busy == set() and errors(app) == []
@pytest.mark.parametrize("how", ["no", "escape"])
async def test_shrink_confirm_declined_makes_no_call(fake, how):
ct_shrink_setup(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
await apply_shrink(app, pilot, "4")
if how == "no":
await press_button(app, pilot, "#no")
else:
await pilot.press("escape")
await settle(app, pilot)
assert fake.restore_calls == [] and app.busy == set()
@pytest.mark.parametrize("bad", ["0", "-3", "abc", "1.5", "4G", "1e1"])
async def test_shrink_size_must_be_a_positive_integer(fake, bad):
ct_shrink_setup(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
await apply_shrink(app, pilot, bad)
assert errors(app)
assert fake.restore_calls == []
assert not isinstance(app.screen, ConfirmScreen)
async def test_shrink_with_overwrite_is_refused(fake):
ct_shrink_setup(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
set_value(app, "#rootfs", "4")
await apply_overwrite(app, pilot, confirm="100")
assert errors(app)
assert fake.restore_calls == []
assert not isinstance(app.screen, ConfirmScreen)
@pytest.mark.parametrize("size", ["1", "3"])
async def test_shrink_size_at_or_below_used_of_running_guest_is_refused(fake, size):
ct_shrink_setup(fake, status="running", disk=3 * 2**30)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
await apply_shrink(app, pilot, size)
assert errors(app)
assert fake.restore_calls == []
assert not isinstance(app.screen, ConfirmScreen)
async def test_shrink_size_above_used_of_running_guest_shows_used_space(fake):
ct_shrink_setup(fake, status="running", disk=3 * 2**30)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
await apply_shrink(app, pilot, "4")
assert isinstance(app.screen, ConfirmScreen)
text = text_of(app.screen)
assert "3.0 GiB" in text and "unknown" not in text
assert "not a fit check" in text
@pytest.mark.parametrize(("status", "disk"), [("running", 0), ("stopped", 3 * 2**30)])
async def test_shrink_is_not_refused_when_used_space_is_unknown_or_guest_stopped(
fake, status, disk
):
ct_shrink_setup(fake, status=status, disk=disk)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
await apply_shrink(app, pilot, "1")
assert isinstance(app.screen, ConfirmScreen)
assert errors(app) == []
async def test_shrink_status_failure_is_notified_without_confirm(fake):
ct_shrink_setup(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_ct_restore(app, pilot)
fake.current_error = cli_error("Error: boom")
await apply_shrink(app, pilot, "4")
assert errors(app)
assert fake.restore_calls == []
assert not isinstance(app.screen, ConfirmScreen)