Add snapshots to the info screen: take, roll back, delete
A "Snapshots" section on the info screen lists the snapshots, newest first, without Proxmox's "current" pseudo entry. Keys: - p takes a snapshot. The name defaults to tui_YYYYMMDD_HHMM, the description is optional, and VMs can include RAM. - R rolls back to the snapshot under the cursor. The confirm names the snapshot and its date. A running guest is started again; its status is read right before the rollback, and if it cannot be read the rollback does not run. - D deletes the snapshot under the cursor, after a confirm. `delete ct|vm <node> <vmid> -y` without the snapshot subcommand destroys the whole guest. delete_snapshot therefore validates the name first (letter first, then [A-Za-z0-9_-], not "current"), writes "snapshot" into the argv as a literal, and checks the argv shape before running it. A property test covers rejected names. The info screen now scrolls, because four sections do not fit short terminals. A description starting with "-" is refused, since it is a positional argument and could be read as an option. Live test on CT 104: take, roll back (started again) and delete. The snapshot list, status, config and IPv4/IPv6 match the baseline. Closes #8 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #21.
This commit is contained in:
1 parent
520de16cc6
commit
ec35a4c996
6 files changed
+1035
-14
No files matched your search
@@ -1003,3 +1003,248 @@ def test_not_backed_up_returns_vmids(fake):
|
||||
def test_not_backed_up_empty(fake):
|
||||
fake.result = ("[]", "", 0)
|
||||
assert cli.not_backed_up() == set()
|
||||
|
||||
|
||||
# --- snapshots (issue #8); spec: docs/plans/guest-tui-mvp.md ("Snapshots in the info screen") ---
|
||||
# Sections below name the spec bullet they cover.
|
||||
|
||||
GOOD_NAMES = [
|
||||
"a",
|
||||
"snap1",
|
||||
"tui_20251102_1430",
|
||||
"A-b_c",
|
||||
"x" * 40,
|
||||
"snapshot",
|
||||
"currentx",
|
||||
"curren",
|
||||
]
|
||||
BAD_NAMES = [
|
||||
"",
|
||||
"current",
|
||||
"Current",
|
||||
"CURRENT",
|
||||
"1snap",
|
||||
"_snap",
|
||||
"-snap",
|
||||
"my snap",
|
||||
"a,b",
|
||||
";rm",
|
||||
"a;rm -rf",
|
||||
"x" * 41,
|
||||
"snäp",
|
||||
"snap\n",
|
||||
"snap ",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", GOOD_NAMES)
|
||||
def test_valid_snapname_accepts_pattern(
|
||||
name,
|
||||
): # valid_snapname: [A-Za-z][A-Za-z0-9_-]{0,39}
|
||||
assert cli.valid_snapname(name) == name
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", BAD_NAMES)
|
||||
def test_valid_snapname_rejects(
|
||||
name,
|
||||
): # valid_snapname: ValueError outside pattern / "current"
|
||||
with pytest.raises(ValueError):
|
||||
cli.valid_snapname(name)
|
||||
|
||||
|
||||
@given(st.text())
|
||||
def test_delete_snapshot_with_rejected_name_raises_and_never_runs(name):
|
||||
# delete_snapshot: name validated first; no subprocess call for an invalid name
|
||||
try:
|
||||
cli.valid_snapname(name)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
return # accepted names are covered by the argv tests
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
run = FakeRun(m)
|
||||
with pytest.raises(ValueError):
|
||||
cli.delete_snapshot(CT, name)
|
||||
with pytest.raises(ValueError):
|
||||
cli.delete_snapshot(VM, name)
|
||||
assert run.calls == []
|
||||
|
||||
|
||||
SNAPS_RAW = [
|
||||
{"name": "old", "description": "first", "snaptime": 1_000, "parent": None},
|
||||
{"name": "undated", "description": "", "snaptime": None, "parent": "old"},
|
||||
{
|
||||
"name": "current",
|
||||
"description": "You are here!",
|
||||
"snaptime": None,
|
||||
"parent": "new",
|
||||
},
|
||||
{"name": "new", "description": "second", "snaptime": 3_000, "parent": "old"},
|
||||
{"name": "mid", "description": "", "snaptime": 2_000, "parent": "old"},
|
||||
]
|
||||
|
||||
|
||||
def test_snapshots_drops_current_sorts_newest_first_null_last(fake): # snapshots()
|
||||
fake.result = (json.dumps(SNAPS_RAW), "", 0)
|
||||
assert [s["name"] for s in cli.snapshots(CT)] == ["new", "mid", "old", "undated"]
|
||||
|
||||
|
||||
def test_snapshots_drops_current_case_insensitively(
|
||||
fake,
|
||||
): # snapshots(): current is not a snapshot
|
||||
fake.result = (
|
||||
json.dumps([{"name": "Current", "snaptime": None}, SNAPS_RAW[0]]),
|
||||
"",
|
||||
0,
|
||||
)
|
||||
assert [s["name"] for s in cli.snapshots(CT)] == ["old"]
|
||||
|
||||
|
||||
def test_snapshots_only_current_gives_empty_list(
|
||||
fake,
|
||||
): # snapshots(): "No snapshots" case
|
||||
fake.result = (json.dumps([SNAPS_RAW[2]]), "", 0)
|
||||
assert cli.snapshots(CT) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"guest,kind",
|
||||
[(CT, "lxc"), (VM, "qemu")],
|
||||
)
|
||||
def test_snapshots_argv_uses_qemu_or_lxc(
|
||||
fake, guest, kind
|
||||
): # CLI facts: get guest snapshots
|
||||
fake.result = ("[]", "", 0)
|
||||
cli.snapshots(guest)
|
||||
assert fake.argv == [
|
||||
"cv4pve-cli", "get", "guest", "snapshots", "mainframe", kind, str(guest["vmid"]), "-o", "json",
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def test_snapshots_failure_raises_clierror(
|
||||
monkeypatch,
|
||||
): # snapshots(): failure surfaces as CliError
|
||||
FakeRun(monkeypatch, "Error: boom", returncode=1)
|
||||
with pytest.raises(cli.CliError):
|
||||
cli.snapshots(CT)
|
||||
|
||||
|
||||
def test_take_snapshot_ct_argv_has_no_vmstate_and_wait_last(fake): # take_snapshot
|
||||
cli.take_snapshot(CT, "snap1", "hello")
|
||||
assert fake.argv == [
|
||||
"cv4pve-cli", "create", "ct", "snapshot", "mainframe", "100", "snap1", "hello", "--wait",
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def test_take_snapshot_vm_without_vmstate(fake): # take_snapshot
|
||||
cli.take_snapshot(VM, "snap1", "hello")
|
||||
assert fake.argv == [
|
||||
"cv4pve-cli", "create", "vm", "snapshot", "mainframe", "108", "snap1", "hello", "--wait",
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def test_take_snapshot_vm_with_vmstate_before_wait(
|
||||
fake,
|
||||
): # take_snapshot: --vmstate 1 (VM only)
|
||||
cli.take_snapshot(VM, "snap1", "hello", vmstate=True)
|
||||
assert fake.argv == [
|
||||
"cv4pve-cli", "create", "vm", "snapshot", "mainframe", "108", "snap1", "hello",
|
||||
"--vmstate", "1", "--wait",
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def test_take_snapshot_ct_ignores_vmstate(fake): # take_snapshot: vmstate only for a VM
|
||||
cli.take_snapshot(CT, "snap1", "hello", vmstate=True)
|
||||
assert "--vmstate" not in fake.argv and fake.argv[-1] == "--wait"
|
||||
|
||||
|
||||
def test_take_snapshot_default_description(
|
||||
fake,
|
||||
): # take_snapshot: description or "cv4pve-tui"
|
||||
cli.take_snapshot(CT, "snap1")
|
||||
assert fake.argv[7] == "cv4pve-tui"
|
||||
|
||||
|
||||
def test_take_snapshot_empty_description_falls_back(
|
||||
fake,
|
||||
): # take_snapshot: `description or "cv4pve-tui"`
|
||||
cli.take_snapshot(VM, "snap1", "")
|
||||
assert fake.argv[7] == "cv4pve-tui"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["", "current", "1x", "a b", ";rm"])
|
||||
def test_take_snapshot_invalid_name_no_call(
|
||||
fake, name
|
||||
): # take_snapshot: name validated first
|
||||
with pytest.raises(ValueError):
|
||||
cli.take_snapshot(VM, name, "d")
|
||||
assert fake.calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("guest,kind", [(CT, "ct"), (VM, "vm")])
|
||||
def test_rollback_argv_without_start(fake, guest, kind): # rollback_snapshot
|
||||
cli.rollback_snapshot(guest, "snap1")
|
||||
assert fake.argv == [
|
||||
"cv4pve-cli", "do", "rollback", kind, "mainframe", str(guest["vmid"]), "snap1", "-y", "--wait",
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
@pytest.mark.parametrize("guest,kind", [(CT, "ct"), (VM, "vm")])
|
||||
def test_rollback_argv_with_start(
|
||||
fake, guest, kind
|
||||
): # rollback_snapshot: [--start 1] -y --wait
|
||||
cli.rollback_snapshot(guest, "snap1", start=True)
|
||||
assert fake.argv == [
|
||||
"cv4pve-cli", "do", "rollback", kind, "mainframe", str(guest["vmid"]), "snap1",
|
||||
"--start", "1", "-y", "--wait",
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["", "current", "Current", "9", "a b", "a,b"])
|
||||
def test_rollback_invalid_name_no_call(fake, name): # rollback_snapshot: name validated
|
||||
with pytest.raises(ValueError):
|
||||
cli.rollback_snapshot(CT, name, start=True)
|
||||
assert fake.calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("guest,kind", [(CT, "ct"), (VM, "vm")])
|
||||
def test_delete_snapshot_exact_argv(fake, guest, kind): # delete_snapshot
|
||||
cli.delete_snapshot(guest, "snap1")
|
||||
assert fake.argv == [
|
||||
"cv4pve-cli", "delete", kind, "snapshot", "mainframe", str(guest["vmid"]), "snap1", "-y", "--wait",
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
@given(
|
||||
name=st.from_regex(r"[A-Za-z][A-Za-z0-9_-]{0,39}", fullmatch=True).filter(
|
||||
lambda n: n.lower() != "current"
|
||||
),
|
||||
guest=st.sampled_from([CT, VM]),
|
||||
)
|
||||
def test_delete_never_produces_guest_destroying_form(name, guest):
|
||||
# DANGER: `delete <kind> <node> <vmid> -y` destroys the guest; "snapshot" must sit at index 3
|
||||
kind = {"lxc": "ct", "qemu": "vm"}[guest["type"]]
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
run = FakeRun(m)
|
||||
cli.delete_snapshot(guest, name)
|
||||
argv = run.argv
|
||||
assert argv[1:3] == ["delete", kind]
|
||||
assert argv[3] == "snapshot" # never the node directly after the kind
|
||||
assert (
|
||||
argv[4] == guest["node"] and argv[5] == str(guest["vmid"]) and argv[6] == name
|
||||
)
|
||||
assert argv[1:3] != ["delete", kind] or argv[3] != guest["node"]
|
||||
|
||||
|
||||
def test_delete_with_snapshot_like_name_still_has_snapshot_at_index_3(fake):
|
||||
# a valid name equal to the subcommand word must not shift the positions
|
||||
cli.delete_snapshot(CT, "snapshot")
|
||||
assert fake.argv[3:7] == ["snapshot", "mainframe", "100", "snapshot"]
|
||||
|
||||
|
||||
def test_take_snapshot_refuses_description_that_looks_like_an_option(fake):
|
||||
with pytest.raises(ValueError):
|
||||
cli.take_snapshot(
|
||||
{"vmid": 104, "node": "mainframe", "type": "lxc"}, "snap1", "-y"
|
||||
)
|
||||
assert fake.calls == []
|
||||
Reference in new issue
Block a user