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

+495
View File
@@ -12,6 +12,7 @@ import pytest
from textual.screen import ModalScreen
from textual.widgets import (
Button,
Checkbox,
DataTable,
Input,
OptionList,
@@ -63,6 +64,13 @@ class Fake:
# delivery B (network editing)
self.bridge_list = ["vmbr0"]
self.set_calls, self.set_error, self.set_hook = [], None, None
# issue #8 (snapshots)
self.snap_list = [dict(s) for s in SNAPSHOTS] # newest first, no "current"
self.snap_error = None
self.snap_calls = []
self.take_calls, self.rollback_calls, self.delete_calls = [], [], []
self.snap_error_op = None
self.snap_hook = None
def list_guests(self):
self.list_calls += 1
@@ -169,11 +177,37 @@ class Fake:
else:
config[key] = value
def snapshots(self, guest):
self.snap_calls.append(guest["vmid"])
if self.snap_error:
raise self.snap_error
return [dict(s) for s in self.snap_list]
def _snap_op(self, calls, entry):
calls.append(entry)
if self.snap_hook:
self.snap_hook()
if self.snap_error_op:
raise self.snap_error_op
def take_snapshot(self, guest, name, description="", vmstate=False):
self._snap_op(self.take_calls, (guest["vmid"], name, description, vmstate))
def rollback_snapshot(self, guest, name, start=False):
self._snap_op(self.rollback_calls, (guest["vmid"], name, start))
def delete_snapshot(self, guest, name):
self._snap_op(self.delete_calls, (guest["vmid"], name))
BACKUP_JOB = {"id": "backup-1", "enabled": 1, "storage": "truenas-backup", "all": 1,
"vmid": None, "exclude": None, "node": None, "schedule": "mon,wed,fri 04:00",
"mode": "snapshot", "compress": "zstd", "prune-backups": {"keep-last": "3"}} # fmt: skip
T_OLD, T_NEW = 1_762_000_000, 1_762_500_000 # epoch seconds
SNAPSHOTS = [ # as cli.snapshots returns them: newest first, without "current"
{"name": "newer", "description": "second one", "snaptime": T_NEW, "parent": "older", "vmstate": 1},
{"name": "older", "description": "first one", "snaptime": T_OLD, "parent": None, "vmstate": 0},
] # fmt: skip
BACKUPS = [
{"volid": "truenas-backup:backup/vzdump-qemu-108-new.vma.zst", "ctime": T_NEW,
"size": 3 * 2**30, "format": "vma.zst", "notes": "docker (manual)", "storage": "truenas-backup"},
@@ -202,6 +236,10 @@ def fake(monkeypatch):
"bridges",
"set_net",
"not_backed_up",
"snapshots",
"take_snapshot",
"rollback_snapshot",
"delete_snapshot",
):
monkeypatch.setattr(cli, name, getattr(f, name), raising=False)
for name in ("list_guests", "guest_config", "disks", "action", "set_resources",
@@ -2151,3 +2189,460 @@ async def test_backup_coverage_failure_still_lists_guests(fake):
assert app.query_one("#guests", DataTable).row_count == 2
assert any(sev == "warning" for _, sev in app.seen) # coverage unknown, said so
assert app.is_running
# --- snapshots in the info screen (issue #8) ---------------------------------
# spec: docs/plans/guest-tui-mvp.md ("Snapshots in the info screen (issue #8)" > app.py)
async def open_snapshot_dialog(app, pilot):
await pilot.press("p")
await settle(app, pilot)
assert isinstance(app.screen, appmod.SnapshotScreen)
async def wait_started(pilot, event):
for _ in range(200):
if event.is_set():
return
await pilot.pause(0.01)
raise AssertionError("worker never started")
async def test_info_snapshots_section_lists_newest_first_with_ram_for_vm(fake):
# InfoScreen: Vertical#snapshots_section "Snapshots", DataTable#snapshots Name/Date/Description/RAM
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot)
section = app.screen.query_one("#snapshots_section")
assert "Snapshots" in str(section.border_title)
table = app.screen.query_one("#snapshots", DataTable)
assert table.row_count == 2
newer, older = cells(table, 0), cells(table, 1)
assert "newer" in newer and local_date(T_NEW) in newer and "second one" in newer
assert "older" in older and local_date(T_OLD) in older and "first one" in older
assert "yes" in newer and "no" in older
assert fake.snap_calls == [108]
async def test_info_snapshots_ct_has_no_ram_column(fake):
# DataTable#snapshots: RAM column for VMs only
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot, down=1) # CT 100
table = app.screen.query_one("#snapshots", DataTable)
assert table.row_count == 2
assert len(table.columns) == 3
assert "yes" not in cells(table, 0)
async def test_info_snapshot_without_date_shows_dash(fake):
# Date: YYYY-MM-DD HH:MM or "-"
fake.snap_list = [
{
"name": "nodate",
"description": "d",
"snaptime": None,
"parent": None,
"vmstate": 0,
}
]
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot)
assert "-" in cells(app.screen.query_one("#snapshots", DataTable), 0)
assert app.is_running
async def test_info_no_snapshots_shows_note(fake):
# empty list -> #snap_note "No snapshots"
fake.snap_list = []
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot)
assert app.screen.query_one("#snapshots", DataTable).row_count == 0
assert "No snapshots" in str(
app.screen.query_one("#snap_note", Static).render()
)
async def test_info_snapshots_failure_is_inline_unavailable_and_rest_works(fake):
# a failure of cli.snapshots -> "unavailable: <summary>" in #snap_note; rest of the screen still works
fake.snap_error = cli_error("Error: snapshot listing exploded")
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot)
note = str(app.screen.query_one("#snap_note", Static).render())
assert "unavailable" in note and "exploded" in note
assert app.screen.query_one("#backups", DataTable).row_count == 2
assert isinstance(app.screen, appmod.InfoScreen) and app.is_running
async def test_p_opens_snapshot_dialog_with_prefilled_name_and_title(fake):
# p: SnapshotScreen, title "Take snapshot: <name> (<vmid>)", Input#snapname tui_YYYYMMDD_HHMM
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot)
before = datetime.now().strftime("tui_%Y%m%d_%H%M")
await open_snapshot_dialog(app, pilot)
after = datetime.now().strftime("tui_%Y%m%d_%H%M")
assert app.screen.query_one("#snapname", Input).value in {before, after}
title = str(app.screen.query_one("#dialog").border_title)
assert "Take snapshot" in title and "docker" in title and "108" in title
app.screen.query_one("#snapdesc", Input)
app.screen.query_one("#yes", Button)
app.screen.query_one("#no", Button)
assert fake.take_calls == []
async def test_snapshot_dialog_vmstate_checkbox_only_for_vm(fake):
# Checkbox#vmstate: VMs only; not shown for CTs
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot, down=1)
await open_snapshot_dialog(app, pilot)
assert len(app.screen.query("#vmstate")) == 0
await pilot.press("escape")
await settle(app, pilot)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot)
await open_snapshot_dialog(app, pilot)
assert len(app.screen.query("#vmstate")) == 1
@pytest.mark.parametrize("how", ["no", "escape"])
async def test_snapshot_dialog_declined_makes_no_call(fake, how):
# SnapshotScreen: buttons #yes/#no, escape = no
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot)
await open_snapshot_dialog(app, pilot)
if how == "no":
await press_button(app, pilot, "#no")
else:
await pilot.press("escape")
await settle(app, pilot)
assert fake.take_calls == []
assert isinstance(app.screen, appmod.InfoScreen)
assert app.busy == set()
@pytest.mark.parametrize("bad", ["", "my snap", "1abc", "current", "a;rm", "x" * 41])
async def test_snapshot_invalid_name_error_notify_dialog_stays_no_call(fake, bad):
# Invalid name -> error notify, dialog stays, no call
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot)
await open_snapshot_dialog(app, pilot)
app.screen.query_one("#snapname", Input).value = bad
await press_button(app, pilot, "#yes")
assert errors(app), "an error notification is expected"
assert isinstance(app.screen, appmod.SnapshotScreen)
assert fake.take_calls == [] and app.busy == set()
async def test_snapshot_yes_takes_vm_snapshot_once_busy_then_reloads(fake):
# Yes -> busy guard, worker cli.take_snapshot(guest, name, desc, vmstate), notify, info reloads
app = make_app()
busy_seen = []
fake.snap_hook = lambda: busy_seen.append(set(app.busy))
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot)
before = len(fake.snap_calls)
await open_snapshot_dialog(app, pilot)
app.screen.query_one("#snapname", Input).value = "before_upgrade"
app.screen.query_one("#snapdesc", Input).value = "pre upgrade"
app.screen.query_one("#vmstate", Checkbox).value = True
await press_button(app, pilot, "#yes")
assert fake.take_calls == [(108, "before_upgrade", "pre upgrade", True)]
assert busy_seen and busy_seen[0], (
"guest must be busy while the snapshot is taken"
)
assert app.busy == set()
assert errors(app) == [] and app.seen
assert len(fake.snap_calls) > before # info reloaded
assert isinstance(app.screen, appmod.InfoScreen)
async def test_snapshot_vmstate_unticked_passes_false(fake):
# Checkbox#vmstate off -> vmstate False
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot)
await open_snapshot_dialog(app, pilot)
app.screen.query_one("#snapname", Input).value = "plain"
app.screen.query_one("#snapdesc", Input).value = "d"
app.screen.query_one("#vmstate", Checkbox).value = False
await press_button(app, pilot, "#yes")
assert fake.take_calls == [(108, "plain", "d", False)]
async def test_snapshot_ct_never_passes_vmstate(fake):
# CT: no checkbox, so vmstate is False
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot, down=1)
await open_snapshot_dialog(app, pilot)
app.screen.query_one("#snapname", Input).value = "ctsnap"
app.screen.query_one("#snapdesc", Input).value = "d"
await press_button(app, pilot, "#yes")
assert fake.take_calls == [(100, "ctsnap", "d", False)]
async def test_snapshot_failure_notifies_error_and_releases_busy(fake):
# CliError -> error notify, busy released
fake.snap_error_op = cli_error("Error: snapshot is not possible: no space left")
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot)
await open_snapshot_dialog(app, pilot)
app.screen.query_one("#snapname", Input).value = "willfail"
await press_button(app, pilot, "#yes")
assert any("no space left" in m for m in errors(app))
assert app.busy == set() and app.is_running
# --- rollback ---------------------------------------------------------------
async def open_confirm_on_row(app, pilot, key, row=1):
await open_info(app, pilot)
app.screen.query_one("#snapshots", DataTable).move_cursor(row=row)
await pilot.pause()
await pilot.press(key)
await settle(app, pilot)
assert isinstance(app.screen, ConfirmScreen)
async def test_R_confirm_names_second_snapshot_and_rolls_back_running_vm_with_start(
fake,
):
# R: ConfirmScreen "Roll back" naming snapshot+date, data-loss warning, running -> started again, start=True
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_confirm_on_row(app, pilot, "R")
assert "Roll back" in str(app.screen.query_one("#dialog").border_title)
text = text_of(app.screen)
assert "older" in text and "newer" not in text
assert local_date(T_OLD)[:10] in text
assert "lost" in text.lower()
assert "started" in text.lower()
assert fake.rollback_calls == []
await press_button(app, pilot, "#yes")
assert fake.rollback_calls == [(108, "older", True)]
assert app.busy == set() and errors(app) == []
async def test_R_uses_live_status_for_start_flag(fake):
# "running (live status)": CT is "stopped" in the listing but running live -> start=True
fake.live_status = {100: "running"}
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot, down=1)
app.screen.query_one("#snapshots", DataTable).move_cursor(row=0)
await pilot.press("R")
await settle(app, pilot)
await press_button(app, pilot, "#yes")
assert fake.rollback_calls == [(100, "newer", True)]
@pytest.mark.parametrize("guest_down,vmid", [(0, 108), (1, 100)])
async def test_R_stopped_guest_rolls_back_without_start(fake, guest_down, vmid):
# stopped (live status; VM listing says running but live says stopped) -> start=False
fake.live_status = {108: "stopped"}
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot, down=guest_down)
app.screen.query_one("#snapshots", DataTable).move_cursor(row=1)
await pilot.press("R")
await settle(app, pilot)
await press_button(app, pilot, "#yes")
assert fake.rollback_calls == [(vmid, "older", False)]
@pytest.mark.parametrize("key,calls", [("R", "rollback_calls"), ("D", "delete_calls")])
@pytest.mark.parametrize("how", ["no", "escape"])
async def test_R_D_declined_make_no_call(fake, key, calls, how):
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_confirm_on_row(app, pilot, key)
if how == "no":
await press_button(app, pilot, "#no")
else:
await pilot.press("escape")
await settle(app, pilot)
assert getattr(fake, calls) == []
assert isinstance(app.screen, appmod.InfoScreen) and app.busy == set()
async def test_R_failure_notifies_error_and_releases_busy(fake):
fake.snap_error_op = cli_error("Error: rollback refused: snapshot is locked")
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_confirm_on_row(app, pilot, "R")
await press_button(app, pilot, "#yes")
assert any("locked" in m for m in errors(app))
assert app.busy == set() and app.is_running
async def test_R_reloads_info_after_rollback(fake):
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_confirm_on_row(app, pilot, "R")
before = len(fake.snap_calls)
await press_button(app, pilot, "#yes")
assert len(fake.snap_calls) > before
# --- delete -----------------------------------------------------------------
async def test_D_confirm_names_snapshot_then_deletes_it_busy_and_reloads(fake):
# D: ConfirmScreen "Delete snapshot" naming it; yes -> busy guard, cli.delete_snapshot, notify, reload
app = make_app()
busy_seen = []
fake.snap_hook = lambda: busy_seen.append(set(app.busy))
async with app.run_test(size=SIZE) as pilot:
await open_confirm_on_row(app, pilot, "D")
assert "Delete snapshot" in str(app.screen.query_one("#dialog").border_title)
text = text_of(app.screen)
assert "older" in text and "newer" not in text
assert fake.delete_calls == []
before = len(fake.snap_calls)
await press_button(app, pilot, "#yes")
assert fake.delete_calls == [(108, "older")]
assert busy_seen and busy_seen[0]
assert app.busy == set() and errors(app) == [] and app.seen
assert len(fake.snap_calls) > before
assert fake.rollback_calls == [] and fake.take_calls == []
async def test_D_targets_cursor_row_first_snapshot_for_ct(fake):
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot, down=1)
await pilot.press("D")
await settle(app, pilot)
assert "newer" in text_of(app.screen)
await press_button(app, pilot, "#yes")
assert fake.delete_calls == [(100, "newer")]
async def test_D_failure_notifies_error_and_releases_busy(fake):
fake.snap_error_op = cli_error("Error: snapshot 'older' does not exist")
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_confirm_on_row(app, pilot, "D")
await press_button(app, pilot, "#yes")
assert any("does not exist" in m for m in errors(app))
assert app.busy == set() and app.is_running
# --- no snapshots / busy ----------------------------------------------------
@pytest.mark.parametrize("key", ["R", "D"])
async def test_R_D_without_snapshots_give_info_notify_and_no_dialog(fake, key):
# R/D with no snapshot -> info notify, no dialog
fake.snap_list = []
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot)
await pilot.press(key)
await settle(app, pilot)
assert isinstance(app.screen, appmod.InfoScreen)
assert [sev for _, sev in app.seen if sev != "information"] == []
assert app.seen
assert fake.rollback_calls == [] and fake.delete_calls == []
@pytest.mark.parametrize(
"key,calls", [("R", "rollback_calls"), ("D", "delete_calls"), ("p", "take_calls")]
)
async def test_snapshot_keys_while_guest_busy_warn_and_make_no_second_call(
fake, key, calls
):
# busy guest -> warning, no call (busy held by a running take_snapshot)
started, release = threading.Event(), threading.Event()
fake.snap_hook = lambda: (started.set(), release.wait(10))
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot)
try:
await open_snapshot_dialog(app, pilot)
app.screen.query_one("#snapname", Input).value = "first"
app.screen.query_one("#yes", Button).press()
await wait_started(pilot, started)
assert (
fake.take_calls == [(108, "first", "", False)]
or len(fake.take_calls) == 1
)
app.screen.query_one("#snapshots", DataTable).move_cursor(row=0)
await pilot.press(key)
await pilot.pause()
if isinstance(app.screen, (appmod.SnapshotScreen, ConfirmScreen)):
app.screen.query_one(
"#yes", Button
).press() # refusal may come at "yes"
await pilot.pause()
assert any(sev == "warning" for _, sev in app.seen)
assert fake.rollback_calls == [] and fake.delete_calls == []
assert len(fake.take_calls) == 1
finally:
release.set()
await settle(app, pilot)
assert (
fake.rollback_calls == []
and fake.delete_calls == []
and len(fake.take_calls) == 1
)
# --- #8 review findings ---------------------------------------------------------------
async def test_rollback_start_flag_uses_status_at_rollback_time(fake):
# opened while running, stopped before "yes": do not start it again
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_confirm_on_row(app, pilot, "R")
fake.live_status = {108: "stopped"}
await press_button(app, pilot, "#yes")
assert fake.rollback_calls == [(108, "older", False)]
async def test_rollback_aborts_when_status_cannot_be_read(fake, monkeypatch):
# an unknown status must not silently leave a running guest stopped
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_confirm_on_row(app, pilot, "R")
def broken(guest):
raise cli.CliError("Error: status unavailable")
monkeypatch.setattr(cli, "status", broken)
await press_button(app, pilot, "#yes")
assert fake.rollback_calls == []
assert errors(app) and app.busy == set()
async def test_snapshot_description_starting_with_dash_is_refused(fake):
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot)
await pilot.press("p")
await settle(app, pilot)
app.screen.query_one("#snapdesc", Input).value = "-y"
await press_button(app, pilot, "#yes")
assert fake.take_calls == [] and errors(app)
assert isinstance(app.screen, appmod.SnapshotScreen)
async def test_info_screen_scrolls_on_short_terminals(fake):
from textual.containers import VerticalScroll
app = make_app()
async with app.run_test(size=(100, 24)) as pilot:
await open_info(app, pilot)
assert isinstance(app.screen.query_one("#info_body"), VerticalScroll)
+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 == []