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:
DuckbootandClaude Opus 5.5 committed 2026-11-02 23:26:49 +01:00
1 parent 520de16cc6
commit ec35a4c996
6 files changed
+1035 -14

No files matched your search

+245
View File
@@ -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 == []