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
@@ -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)
|
||||
@@ -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