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
+189
-1
@@ -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)
|
||||
Reference in new issue
Block a user