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

+146
View File
@@ -1621,3 +1621,149 @@ def test_fstrim_gets_a_longer_timeout_than_other_calls(fake):
fake.result = (json.dumps([{"key": "result", "value": "{}"}]), "", 0)
cli.fstrim(VM19)
assert fake.calls[0][1]["timeout"] >= 1800 # a big disk can take longer than 330 s
# --- restore a guest from a backup (issue #15) ------------------------------
# spec: docs/plans/guest-tui-mvp.md ("Restore a guest from a backup (issue #15)" > cli.py)
CT_VOLID = "truenas-backup:backup/vzdump-lxc-104-2026_11_02-04_11_47.tar.zst"
VM_VOLID = "local:backup/vzdump-qemu-108-2026_11_02-04_11_47.vma.zst"
def test_next_id_parses_json_number_and_runs_get_cluster_nextid(fake):
# spec: next_id() -> int; `get cluster nextid -o json` -> a JSON number
fake.result = ("105\n", "", 0)
assert cli.next_id() == 105
assert fake.argv == ["cv4pve-cli", "get", "cluster", "nextid", "-o", "json"]
@pytest.mark.parametrize("volid", [CT_VOLID, VM_VOLID])
def test_valid_volid_accepts_real_volids_and_returns_them(volid):
# spec: valid_volid(volid) -> str
assert cli.valid_volid(volid) == volid
@pytest.mark.parametrize(
"volid",
[
"-y",
"",
"local:iso/x.iso",
"a b",
"x:backup/vzdump-lxc-104-",
"--force:backup/vzdump-lxc-104-x", # option-looking storage part
"local:backup/vzdump-lxc-104-x y", # whitespace in the file part
"local:backup/vzdump-vm-104-x.tar", # kind is lxc|qemu only
],
)
def test_valid_volid_rejects_everything_else(volid):
with pytest.raises(ValueError):
cli.valid_volid(volid)
def test_restore_storages_ct_wants_rootdir_vm_wants_images(fake):
# spec: enabled+active storages whose content has rootdir (CT) or images (VM), API order
fake.result = (
json.dumps([
storage("zeta", "iso,rootdir,images"),
storage("only-backup", "backup"),
storage("vm-only", "images"),
storage("ct-only", "rootdir,vztmpl"),
storage("off", "rootdir,images", enabled=0),
storage("inactive", "rootdir,images", active=0),
storage("alpha", "images,rootdir"),
]),
"",
0,
) # fmt: skip
assert cli.restore_storages(CT) == ["zeta", "ct-only", "alpha"]
assert cli.restore_storages(VM) == ["zeta", "vm-only", "alpha"]
assert fake.calls[0][0] == [
"cv4pve-cli", "get", "node", "storages", "mainframe", "-o", "json",
] # fmt: skip
def test_restore_new_vm_exact_argv_without_force_or_start(fake):
# spec: do restore <kind> <node> <vmid> <volid> --storage <storage> -y --wait
cli.restore(VM, VM_VOLID, 109, "local-lvm")
assert fake.argv == [
"cv4pve-cli", "do", "restore", "vm", "mainframe", "109", VM_VOLID,
"--storage", "local-lvm", "-y", "--wait",
] # fmt: skip
def test_restore_new_ct_with_start_exact_argv(fake):
cli.restore(CT, CT_VOLID, 105, "zfs", start=True)
assert fake.argv == [
"cv4pve-cli", "do", "restore", "ct", "mainframe", "105", CT_VOLID,
"--storage", "zfs", "--start", "1", "-y", "--wait",
] # fmt: skip
def test_restore_overwrite_vm_exact_argv_with_force(fake):
cli.restore(VM, VM_VOLID, 108, "local-lvm", force=True)
assert fake.argv == [
"cv4pve-cli", "do", "restore", "vm", "mainframe", "108", VM_VOLID,
"--storage", "local-lvm", "--force", "1", "-y", "--wait",
] # fmt: skip
def test_restore_overwrite_ct_with_start_exact_argv(fake):
cli.restore(CT, CT_VOLID, 100, "zfs", force=True, start=True)
assert fake.argv == [
"cv4pve-cli", "do", "restore", "ct", "mainframe", "100", CT_VOLID,
"--storage", "zfs", "--force", "1", "--start", "1", "-y", "--wait",
] # fmt: skip
def test_restore_force_on_another_vmid_raises_and_never_runs(fake):
# spec: force=True only when vmid == guest["vmid"] - never overwrite some other guest
with pytest.raises(ValueError):
cli.restore(VM, VM_VOLID, 100, "local-lvm", force=True)
assert fake.calls == []
@pytest.mark.parametrize("vmid", [0, -1, -108])
@pytest.mark.parametrize("force", [False, True])
def test_restore_non_positive_vmid_raises_and_never_runs(fake, vmid, force):
# spec: vmid a positive int
with pytest.raises(ValueError):
cli.restore(VM, VM_VOLID, vmid, "local-lvm", force=force)
assert fake.calls == []
@pytest.mark.parametrize("volid", ["-y", "", "local:iso/x.iso", "a b"])
def test_restore_rejected_volid_raises_and_never_runs(fake, volid):
# spec: volid validated (it is a positional argument)
with pytest.raises(ValueError):
cli.restore(VM, volid, 109, "local-lvm")
assert fake.calls == []
def test_restore_failure_raises_clierror(monkeypatch):
FakeRun(monkeypatch, "Error: storage is full", returncode=5)
with pytest.raises(cli.CliError):
cli.restore(VM, VM_VOLID, 109, "local-lvm")
@given(vmid=st.integers(min_value=-10, max_value=10**9).filter(lambda n: n != 108))
def test_restore_force_never_runs_for_a_vmid_other_than_the_guests(vmid):
# DANGER property: --force 1 on any other guest would destroy it
with pytest.MonkeyPatch.context() as m:
run = FakeRun(m)
with pytest.raises(ValueError):
cli.restore(VM, VM_VOLID, vmid, "local-lvm", force=True)
assert run.calls == []
# --- #15 review findings ----------------------------------------------------------------
@pytest.mark.parametrize("force", [False, True])
def test_restore_refuses_a_backup_of_the_other_guest_type(fake, force):
ct = {"vmid": 100, "node": "mainframe", "type": "lxc"}
with pytest.raises(ValueError):
cli.restore(
ct, "local:backup/vzdump-qemu-100-x.vma.zst", 100, "local-lvm", force=force
)
assert fake.calls == []