Restore a guest from a backup in the info screen
r on the info screen restores the backup under the cursor of the backup list. - New guest (default): the next free VMID from `get cluster nextid`, editable. The original is left untouched. An id already used by a listed guest is refused. - Overwrite: replaces the guest itself. The guest must be stopped; its live status is read at apply and again right before the call. The VMID must be typed to confirm, then an "Overwrite" confirm follows. --storage is always passed. It is preselected to the storage of the guest's rootfs or first disk, and the choices are the node's storages that can hold CT (rootdir) or VM (images) disks. Start after restore is off by default, because a copy keeps the original's MAC address and IP. Safety: - cli.restore refuses force (overwrite) for any VMID other than the guest's own (explicit raise). A hypothesis property checks that no other VMID ever reaches the CLI with force. - The volid and storage are validated before they become arguments. - A backup of the other guest type is refused. A reused VMID can carry a qemu backup for a CT or the other way round, and whether PVE rejects a mismatched archive before removing the old guest is not verified. - An earlier guest's backup (visible with a) is labelled with that guest's name in the dialog and the confirm. - r before the info screen has loaded says "still loading". - Restore gets a 1-hour CLI timeout. Not run live: a live test would create a guest that then has to be deleted, which the issue reserves for the user's approval. A read-only check opened the dialog for CT 104 (next id 105, local-lvm preselected) and closed it without any call. Closes #15 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #32.
This commit is contained in:
1 parent
262441916c
commit
0df928ab11
6 files changed
+862
-3
No files matched your search
@@ -19,6 +19,7 @@ from textual.widgets import (
|
||||
DataTable,
|
||||
Input,
|
||||
OptionList,
|
||||
RadioButton,
|
||||
Select,
|
||||
Static,
|
||||
)
|
||||
@@ -99,6 +100,10 @@ class Fake:
|
||||
None,
|
||||
)
|
||||
self.fstrim_paths = [{"path": "/", "trimmed": 2**30, "minimum": 0}]
|
||||
# issue #15 (restore from a backup)
|
||||
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
|
||||
|
||||
def list_guests(self):
|
||||
self.list_calls += 1
|
||||
@@ -284,6 +289,21 @@ class Fake:
|
||||
raise self.fstrim_error
|
||||
return [dict(p) for p in self.fstrim_paths]
|
||||
|
||||
def next_id(self):
|
||||
if self.nextid_error:
|
||||
raise self.nextid_error
|
||||
return self.nextid
|
||||
|
||||
def restore_storages(self, guest):
|
||||
return list(self.restore_store)
|
||||
|
||||
def restore(self, guest, volid, vmid, storage, force=False, start=False):
|
||||
self.restore_calls.append((guest["vmid"], volid, vmid, storage, force, start))
|
||||
if self.restore_hook:
|
||||
self.restore_hook()
|
||||
if self.restore_error:
|
||||
raise self.restore_error
|
||||
|
||||
def task_log(self, upid):
|
||||
self.log_calls.append(upid)
|
||||
if self.log_error:
|
||||
@@ -351,6 +371,9 @@ def fake(monkeypatch):
|
||||
"package_updates",
|
||||
"agent_ping",
|
||||
"fstrim",
|
||||
"next_id",
|
||||
"restore_storages",
|
||||
"restore",
|
||||
):
|
||||
monkeypatch.setattr(cli, name, getattr(f, name), raising=False)
|
||||
for name in ("list_guests", "guest_config", "disks", "action", "set_resources",
|
||||
@@ -4461,3 +4484,391 @@ async def test_fstrim_before_the_info_screen_loaded_says_still_loading(
|
||||
finally:
|
||||
release.set()
|
||||
await settle(app, pilot)
|
||||
|
||||
|
||||
# --- #15 restore a guest from a backup ----------------------------------------------------
|
||||
# spec: docs/plans/guest-tui-mvp.md ("Restore a guest from a backup (issue #15)" > app.py)
|
||||
|
||||
NEW_VOLID = BACKUPS[0]["volid"]
|
||||
OLD_VOLID = BACKUPS[1]["volid"]
|
||||
|
||||
|
||||
def vm_disk_on(fake, storage="zfs-pool"):
|
||||
# the guest's first disk lives on `storage` (config value "<storage>:<volume>,size=..")
|
||||
fake.net_configs[108] = {"scsi0": f"{storage}:vm-108-disk-0,size=10G"}
|
||||
|
||||
|
||||
async def open_restore(app, pilot, down=0, row=0):
|
||||
await open_info(app, pilot, down=down)
|
||||
app.screen.query_one("#backups", DataTable).move_cursor(row=row)
|
||||
await pilot.press("r")
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, appmod.RestoreScreen)
|
||||
|
||||
|
||||
def set_value(app, selector, value):
|
||||
app.screen.query_one(selector).value = value
|
||||
|
||||
|
||||
async def apply_new(app, pilot, newid=None, start=False):
|
||||
if newid is not None:
|
||||
set_value(app, "#newid", newid)
|
||||
if start:
|
||||
set_value(app, "#start", True)
|
||||
await press_button(app, pilot, "#apply")
|
||||
|
||||
|
||||
async def apply_overwrite(app, pilot, confirm="108", start=False):
|
||||
app.screen.query_one("#overwrite", RadioButton).value = True
|
||||
set_value(app, "#confirm", confirm)
|
||||
if start:
|
||||
set_value(app, "#start", True)
|
||||
await press_button(app, pilot, "#apply")
|
||||
|
||||
|
||||
def title_of(app):
|
||||
return str(app.screen.query_one("#dialog").border_title)
|
||||
|
||||
|
||||
async def test_r_on_backup_opens_restore_screen_with_defaults(fake):
|
||||
# spec: title names date and guest; #new default; #newid from next_id; #storage preselected
|
||||
vm_disk_on(fake, "zfs-pool")
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_restore(app, pilot)
|
||||
title = title_of(app)
|
||||
assert "Restore" in title and "docker" in title and "108" in title
|
||||
assert local_date(T_NEW)[:10] in title
|
||||
assert app.screen.query_one("#new", RadioButton).value is True
|
||||
assert app.screen.query_one("#overwrite", RadioButton).value is False
|
||||
assert app.screen.query_one("#newid", Input).value == "105"
|
||||
assert app.screen.query_one("#storage", Select).value == "zfs-pool"
|
||||
assert app.screen.query_one("#start", Checkbox).value is False
|
||||
assert fake.restore_calls == []
|
||||
|
||||
|
||||
async def test_restore_storage_falls_back_to_a_listed_one_when_disk_storage_unlisted(
|
||||
fake,
|
||||
):
|
||||
# spec: preselected "if listed"; otherwise the select must still hold a listed storage
|
||||
vm_disk_on(fake, "not-offered")
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_restore(app, pilot)
|
||||
assert app.screen.query_one("#storage", Select).value in fake.restore_store
|
||||
|
||||
|
||||
async def test_restore_ct_preselects_rootfs_storage(fake):
|
||||
# spec: "storage of the guest's rootfs/first disk"
|
||||
fake.net_configs[100] = {"rootfs": "zfs-pool:subvol-100-disk-0,size=8G"}
|
||||
fake.disk_map = {"rootfs": "8G"}
|
||||
fake.backup_list = [
|
||||
{**BACKUPS[0], "volid": "truenas-backup:backup/vzdump-lxc-100-new.tar.zst",
|
||||
"notes": "backrest (manual)"} # the CT's own backup (other notes = earlier guest, hidden)
|
||||
] # fmt: skip
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_restore(app, pilot, down=1)
|
||||
assert "backrest" in title_of(app)
|
||||
assert app.screen.query_one("#storage", Select).value == "zfs-pool"
|
||||
|
||||
|
||||
async def test_r_without_backups_notifies_info_and_opens_nothing(fake):
|
||||
# spec: no backup -> info notify
|
||||
fake.backup_list = []
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_info(app, pilot)
|
||||
await pilot.press("r")
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, appmod.InfoScreen)
|
||||
assert any(sev == "information" for _, sev in app.seen)
|
||||
assert fake.restore_calls == []
|
||||
|
||||
|
||||
async def test_new_apply_asks_confirm_titled_restore_then_restores_busy_and_reloads(
|
||||
fake,
|
||||
):
|
||||
# spec: apply new -> ConfirmScreen "Restore"; yes -> busy guard, restore(..., force=False, start=False)
|
||||
vm_disk_on(fake, "zfs-pool")
|
||||
app = make_app()
|
||||
busy_seen = []
|
||||
fake.restore_hook = lambda: busy_seen.append(set(app.busy))
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_restore(app, pilot)
|
||||
await apply_new(app, pilot)
|
||||
assert isinstance(app.screen, ConfirmScreen)
|
||||
assert app.screen.title_text == "Restore"
|
||||
text = text_of(app.screen)
|
||||
assert "105" in text and "zfs-pool" in text
|
||||
assert fake.restore_calls == []
|
||||
before = fake.list_calls
|
||||
await press_button(app, pilot, "#yes")
|
||||
assert fake.restore_calls == [(108, NEW_VOLID, 105, "zfs-pool", False, False)]
|
||||
assert busy_seen and busy_seen[0], (
|
||||
"source guest must be busy during the restore"
|
||||
)
|
||||
assert app.busy == set() and errors(app) == []
|
||||
assert fake.list_calls > before, "main list must reload"
|
||||
|
||||
|
||||
async def test_new_with_edited_id_and_start_checked_passes_them(fake):
|
||||
# spec: Input#newid editable; Checkbox#start -> start=True
|
||||
vm_disk_on(fake, "zfs-pool")
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_restore(app, pilot)
|
||||
await apply_new(app, pilot, newid="777", start=True)
|
||||
await press_button(app, pilot, "#yes")
|
||||
assert fake.restore_calls == [(108, NEW_VOLID, 777, "zfs-pool", False, True)]
|
||||
|
||||
|
||||
async def test_new_with_another_storage_selected_passes_it(fake):
|
||||
vm_disk_on(fake, "zfs-pool")
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_restore(app, pilot)
|
||||
set_value(app, "#storage", "local-lvm")
|
||||
await apply_new(app, pilot)
|
||||
await press_button(app, pilot, "#yes")
|
||||
assert fake.restore_calls[0][3] == "local-lvm"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["108", "100", "abc", "0", "-5", "", "10.5", "1e3"])
|
||||
async def test_new_with_unusable_id_errors_without_call(fake, bad):
|
||||
# spec: newid positive int not used by any guest in app.guests (108, 100 exist)
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_restore(app, pilot)
|
||||
await apply_new(app, pilot, newid=bad)
|
||||
assert errors(app)
|
||||
assert fake.restore_calls == []
|
||||
assert not isinstance(app.screen, ConfirmScreen)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("how", ["no", "escape"])
|
||||
async def test_new_confirm_declined_makes_no_call(fake, how):
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_restore(app, pilot)
|
||||
await apply_new(app, pilot)
|
||||
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("how", ["cancel", "escape"])
|
||||
async def test_restore_screen_cancel_or_escape_makes_no_call(fake, how):
|
||||
# spec: #cancel / escape = cancel
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_restore(app, pilot)
|
||||
if how == "cancel":
|
||||
await press_button(app, pilot, "#cancel")
|
||||
else:
|
||||
await pilot.press("escape")
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, appmod.InfoScreen)
|
||||
assert fake.restore_calls == [] and errors(app) == []
|
||||
|
||||
|
||||
async def test_overwrite_on_running_guest_errors_without_call(fake):
|
||||
# spec: overwrite needs a stopped guest (live cli.status at apply time); listing says stopped, live says running
|
||||
fake.guests = [dict(VM, status="stopped"), CT]
|
||||
fake.live_status = {108: "running"}
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_restore(app, pilot)
|
||||
await apply_overwrite(app, pilot)
|
||||
assert errors(app)
|
||||
assert fake.restore_calls == []
|
||||
assert not isinstance(app.screen, ConfirmScreen)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wrong", ["", "100", "docker", "1080", " 108x"])
|
||||
async def test_overwrite_with_wrong_typed_confirm_errors_without_call(fake, wrong):
|
||||
# spec: Input#confirm must equal the VMID
|
||||
fake.live_status = {108: "stopped"}
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_restore(app, pilot)
|
||||
await apply_overwrite(app, pilot, confirm=wrong)
|
||||
assert errors(app)
|
||||
assert fake.restore_calls == []
|
||||
assert not isinstance(app.screen, ConfirmScreen)
|
||||
|
||||
|
||||
async def test_overwrite_stopped_guest_right_confirm_asks_overwrite_then_restores_with_force(
|
||||
fake,
|
||||
):
|
||||
# spec: ConfirmScreen "Overwrite" (disks and config replaced, cannot be undone); yes -> force=True on the guest's own vmid
|
||||
vm_disk_on(fake, "zfs-pool")
|
||||
fake.live_status = {108: "stopped"}
|
||||
app = make_app()
|
||||
busy_seen = []
|
||||
fake.restore_hook = lambda: busy_seen.append(set(app.busy))
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_restore(app, pilot)
|
||||
await apply_overwrite(app, pilot, confirm="108", start=True)
|
||||
assert isinstance(app.screen, ConfirmScreen)
|
||||
assert app.screen.title_text == "Overwrite"
|
||||
text = text_of(app.screen).lower()
|
||||
assert "docker" in text and "108" in text and "undone" in text
|
||||
assert fake.restore_calls == []
|
||||
before = fake.list_calls
|
||||
await press_button(app, pilot, "#yes")
|
||||
assert fake.restore_calls == [(108, NEW_VOLID, 108, "zfs-pool", True, True)]
|
||||
assert busy_seen and busy_seen[0]
|
||||
assert app.busy == set() and errors(app) == []
|
||||
assert fake.list_calls > before
|
||||
|
||||
|
||||
async def test_overwrite_ignores_a_stale_newid_value(fake):
|
||||
# spec: #newid is "used for new" only; overwrite targets the guest's own vmid
|
||||
fake.live_status = {108: "stopped"}
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_restore(app, pilot)
|
||||
set_value(app, "#newid", "999")
|
||||
await apply_overwrite(app, pilot)
|
||||
await press_button(app, pilot, "#yes")
|
||||
assert [(c[2], c[4]) for c in fake.restore_calls] == [(108, True)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("how", ["no", "escape"])
|
||||
async def test_overwrite_confirm_declined_makes_no_call(fake, how):
|
||||
fake.live_status = {108: "stopped"}
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_restore(app, pilot)
|
||||
await apply_overwrite(app, pilot)
|
||||
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()
|
||||
|
||||
|
||||
async def test_restore_uses_the_backup_under_the_cursor(fake):
|
||||
# spec: the backup under the cursor of #backups (2nd row = the old one)
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_restore(app, pilot, row=1)
|
||||
assert local_date(T_OLD)[:10] in title_of(app)
|
||||
await apply_new(app, pilot)
|
||||
await press_button(app, pilot, "#yes")
|
||||
assert [c[1] for c in fake.restore_calls] == [OLD_VOLID]
|
||||
|
||||
|
||||
async def test_hidden_earlier_guest_backup_is_not_restorable_by_default(fake):
|
||||
# spec: hidden earlier-guest backups are not restorable unless shown with `a`
|
||||
fake.backup_list = [*BACKUPS, EARLIER]
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_restore(app, pilot, row=99) # cursor clamps to the last visible row
|
||||
await apply_new(app, pilot)
|
||||
await press_button(app, pilot, "#yes")
|
||||
assert [c[1] for c in fake.restore_calls] == [OLD_VOLID]
|
||||
|
||||
|
||||
async def test_earlier_guest_backup_is_restorable_after_a(fake):
|
||||
fake.backup_list = [*BACKUPS, EARLIER]
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_info(app, pilot)
|
||||
await pilot.press("a")
|
||||
await pilot.pause()
|
||||
app.screen.query_one("#backups", DataTable).move_cursor(row=2)
|
||||
await pilot.press("r")
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, appmod.RestoreScreen)
|
||||
await apply_new(app, pilot)
|
||||
await press_button(app, pilot, "#yes")
|
||||
assert [c[1] for c in fake.restore_calls] == [EARLIER["volid"]]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("overwrite", [False, True])
|
||||
async def test_restore_failure_notifies_error_and_releases_busy(fake, overwrite):
|
||||
# spec: all CliErrors -> error notify (markup-safe), busy released
|
||||
fake.restore_error = cli_error("Error: restore failed: [bold]no space[/bold]")
|
||||
fake.live_status = {108: "stopped"}
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_restore(app, pilot)
|
||||
if overwrite:
|
||||
await apply_overwrite(app, pilot)
|
||||
else:
|
||||
await apply_new(app, pilot)
|
||||
await press_button(app, pilot, "#yes")
|
||||
assert len(fake.restore_calls) == 1
|
||||
assert any("no space" in m for m in errors(app))
|
||||
assert app.busy == set() and app.is_running
|
||||
|
||||
|
||||
async def test_next_id_failure_is_notified_not_fatal(fake):
|
||||
# spec: all CliErrors -> error notify; the app keeps running
|
||||
fake.nextid_error = cli_error("Error: cluster not quorate")
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_info(app, pilot)
|
||||
await pilot.press("r")
|
||||
await settle(app, pilot)
|
||||
assert any("quorate" in m for m in errors(app))
|
||||
assert app.is_running and fake.restore_calls == []
|
||||
|
||||
|
||||
async def test_r_on_busy_guest_makes_no_restore_call(fake):
|
||||
# spec: busy guard on the source guest
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_restore(app, pilot)
|
||||
app.busy.add(108)
|
||||
await apply_new(app, pilot)
|
||||
if isinstance(app.screen, ConfirmScreen):
|
||||
await press_button(app, pilot, "#yes")
|
||||
assert fake.restore_calls == []
|
||||
assert any(sev == "warning" for _, sev in app.seen)
|
||||
app.busy.discard(108)
|
||||
|
||||
|
||||
# --- #15 review findings ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_restoring_an_earlier_guests_backup_says_so(fake):
|
||||
# shown only with "a": its notes name a deleted guest that had the same VMID
|
||||
fake.backup_list = [*BACKUPS, EARLIER]
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await open_info(app, pilot)
|
||||
await pilot.press("a")
|
||||
await pilot.pause()
|
||||
app.screen.query_one("#backups", DataTable).move_cursor(row=2) # the planka row
|
||||
await pilot.press("r")
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, appmod.RestoreScreen)
|
||||
assert "planka" in title_of(app) and "earlier guest" in title_of(app)
|
||||
|
||||
|
||||
async def test_restore_before_the_info_screen_loaded_says_still_loading(
|
||||
fake, monkeypatch
|
||||
):
|
||||
release = threading.Event()
|
||||
real = cli.guest_config
|
||||
monkeypatch.setattr(cli, "guest_config", lambda g: (release.wait(10), real(g))[1])
|
||||
app = make_app()
|
||||
async with app.run_test(size=SIZE) as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
try:
|
||||
await pilot.press("r")
|
||||
await pilot.pause()
|
||||
assert any("loading" in m.lower() for m, _ in app.seen)
|
||||
assert not isinstance(app.screen, appmod.RestoreScreen)
|
||||
finally:
|
||||
release.set()
|
||||
await settle(app, pilot)
|
||||
Reference in new issue
Block a user