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
@@ -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 == []
|
||||
Reference in new issue
Block a user