Restore a guest from a backup in the info screen
CI / pytest and ruff (pull_request) Successful in 7m25s
CI / pytest and ruff (push) Successful in 6m56s

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:
DuckbootandClaude Opus 5.5 committed 2026-11-03 01:32:05 +01:00
1 parent 262441916c
commit 0df928ab11
6 files changed
+862 -3

No files matched your search

+411
View File
@@ -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)