Add guest info screen with backups, backup now and network
Press i on a guest to see its backups and network. Backups from every backup-capable storage are listed newest first, with the backup jobs that cover the guest. The network part shows the netN settings (bridge, VLAN, MAC, IPv4/IPv6 setting, firewall) and the addresses in use. Press b in the info screen to back the guest up now: snapshot mode and zstd, like the scheduled job, to a storage you pick (the job's storage is preselected). It asks for confirmation, marks the guest busy and uses --wait. Fixed after review and a read-only live check: - prune-backups arrives as a JSON string; parsing it as a dict crashed the app. - CT addresses come from ip-addresses. inet6 holds only the link-local address, so the global SLAAC address was missing. - The info screen uses the guest's live status, and odd API data (null size, unknown address types) no longer kills the app. - The test fake now copies guest dicts; a shared constant was being mutated across tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
70bcb9e7b7
commit
32809b6743
6 files changed
+1057
-2
No files matched your search
+352
-2
@@ -6,11 +6,13 @@ All cli functions are monkeypatched: cv4pve-cli is never executed.
|
||||
import contextlib
|
||||
import subprocess
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, DataTable, Input, OptionList, Select, Static
|
||||
|
||||
from cv4pve_tui import app as appmod
|
||||
from cv4pve_tui import cli
|
||||
from cv4pve_tui.app import ConfirmScreen, ContextScreen, EditScreen, GuestApp
|
||||
|
||||
@@ -41,10 +43,22 @@ class Fake:
|
||||
self.use_hook = None
|
||||
self.guests_by_context = {}
|
||||
self.list_hook = None
|
||||
# delivery A (guest info screen)
|
||||
self.net_configs = {} # vmid -> extra config keys (netN, ipconfigN)
|
||||
self.storages = ["truenas-backup", "local"]
|
||||
self.jobs = [BACKUP_JOB]
|
||||
self.backup_list = list(BACKUPS) # newest first, as cli.backups returns them
|
||||
self.iface_list, self.iface_error = [], None
|
||||
self.iface_calls, self.backups_calls, self.backup_runs = [], [], []
|
||||
self.backup_error = None
|
||||
self.backup_hook = None
|
||||
|
||||
def list_guests(self):
|
||||
self.list_calls += 1
|
||||
snapshot = list(self.guests_by_context.get(self.current, self.guests))
|
||||
# copies: the app writes live status into these dicts; shared module constants must not change
|
||||
snapshot = [
|
||||
dict(g) for g in self.guests_by_context.get(self.current, self.guests)
|
||||
]
|
||||
if (
|
||||
self.list_hook
|
||||
): # runs after reading: simulates a slow reply from that cluster
|
||||
@@ -52,7 +66,7 @@ class Fake:
|
||||
return snapshot
|
||||
|
||||
def guest_config(self, guest):
|
||||
return {"cores": guest["maxcpu"]}
|
||||
return {"cores": guest["maxcpu"], **self.net_configs.get(guest["vmid"], {})}
|
||||
|
||||
def disks(self, config, kind):
|
||||
return dict(self.disk_map)
|
||||
@@ -97,10 +111,61 @@ class Fake:
|
||||
raise self.use_error
|
||||
self.current = name
|
||||
|
||||
def interfaces(self, guest):
|
||||
self.iface_calls.append(guest["vmid"])
|
||||
if self.iface_error:
|
||||
raise self.iface_error
|
||||
return list(self.iface_list)
|
||||
|
||||
def backup_storages(self, guest):
|
||||
return list(self.storages)
|
||||
|
||||
def backup_jobs(self):
|
||||
return list(self.jobs)
|
||||
|
||||
def backups(self, guest, storages):
|
||||
self.backups_calls.append((guest["vmid"], list(storages)))
|
||||
return list(self.backup_list)
|
||||
|
||||
def backup_now(self, guest, storage):
|
||||
self.backup_runs.append((guest["vmid"], storage))
|
||||
if self.backup_hook:
|
||||
self.backup_hook()
|
||||
if self.backup_error:
|
||||
raise self.backup_error
|
||||
|
||||
|
||||
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
|
||||
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"},
|
||||
{"volid": "local:backup/vzdump-qemu-108-old.vma.zst", "ctime": T_OLD,
|
||||
"size": int(1.5 * 2**30), "format": "vma.zst", "notes": "", "storage": "local"},
|
||||
] # fmt: skip
|
||||
CT_CONFIG = {
|
||||
"net0": "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:36:DE:78,ip=dhcp,ip6=auto,type=veth"
|
||||
}
|
||||
VM_CONFIG = {
|
||||
"net0": "virtio=02:24:A6:49:A8:25,bridge=vmbr0,tag=20,firewall=1",
|
||||
"ipconfig0": "ip=dhcp",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake(monkeypatch):
|
||||
f = Fake()
|
||||
# raising=False: the new names do not exist before the implementation (red run)
|
||||
for name in (
|
||||
"interfaces",
|
||||
"backup_storages",
|
||||
"backup_jobs",
|
||||
"backups",
|
||||
"backup_now",
|
||||
):
|
||||
monkeypatch.setattr(cli, name, getattr(f, name), raising=False)
|
||||
for name in ("list_guests", "guest_config", "disks", "action", "set_resources",
|
||||
"resize_disk", "pending_keys", "status", "contexts", "use_context"): # fmt: skip
|
||||
monkeypatch.setattr(cli, name, getattr(f, name))
|
||||
@@ -921,3 +986,288 @@ async def test_terminal_is_reset_inside_suspend_before_ssh(fake, monkeypatch):
|
||||
async with app.run_test() as pilot:
|
||||
await press_t(app, pilot)
|
||||
assert resets == [[("enter", None)]] # inside suspend, before ssh ran
|
||||
|
||||
|
||||
# --- delivery A: guest info screen / backup now ------------------------------
|
||||
# spec: docs/plans/guest-tui-mvp.md ("Guest info screen ... delivery A" > app.py)
|
||||
|
||||
|
||||
def local_date(epoch):
|
||||
return datetime.fromtimestamp(epoch).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def cells(table, row):
|
||||
return [str(c) for c in table.get_row_at(row)]
|
||||
|
||||
|
||||
def cli_error(text):
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
m.setattr(
|
||||
cli.subprocess,
|
||||
"run",
|
||||
lambda argv, **kw: subprocess.CompletedProcess(argv, 5, text, ""),
|
||||
)
|
||||
with pytest.raises(cli.CliError) as exc:
|
||||
cli.run("do", "x")
|
||||
return exc.value
|
||||
|
||||
|
||||
async def open_info(app, pilot, down=0):
|
||||
await settle(app, pilot)
|
||||
for _ in range(down):
|
||||
await pilot.press("down")
|
||||
await pilot.press("i")
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, appmod.InfoScreen)
|
||||
|
||||
|
||||
async def open_backup(app, pilot):
|
||||
await pilot.press("b")
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, appmod.BackupScreen)
|
||||
|
||||
|
||||
async def test_i_opens_info_screen_with_backups_newest_first(fake):
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot)
|
||||
table = app.screen.query_one("#backups", DataTable)
|
||||
assert table.row_count == 2
|
||||
new, old = cells(table, 0), cells(table, 1)
|
||||
assert local_date(T_NEW) in new and "truenas-backup" in new
|
||||
assert any("3.0" in c for c in new) and "docker (manual)" in new
|
||||
assert local_date(T_OLD) in old and "local" in old
|
||||
assert any("1.5" in c for c in old)
|
||||
assert fake.backups_calls == [(108, ["truenas-backup", "local"])]
|
||||
|
||||
|
||||
async def test_info_jobs_line_names_schedule_storage_and_keep_last(fake):
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot)
|
||||
text = str(app.screen.query_one("#jobs", Static).render())
|
||||
assert (
|
||||
"mon,wed,fri 04:00" in text
|
||||
and "truenas-backup" in text
|
||||
and "keep-last" in text
|
||||
)
|
||||
|
||||
|
||||
async def test_info_jobs_line_when_no_job_covers_guest(fake):
|
||||
fake.jobs = []
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot)
|
||||
text = str(app.screen.query_one("#jobs", Static).render())
|
||||
assert "No backup job covers this guest" in text
|
||||
|
||||
|
||||
async def test_info_nets_row_for_ct_sample(fake):
|
||||
fake.net_configs[100] = CT_CONFIG
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot, down=1) # CT 100 (stopped)
|
||||
table = app.screen.query_one("#nets", DataTable)
|
||||
assert table.row_count == 1
|
||||
assert cells(table, 0) == [
|
||||
"net0",
|
||||
"vmbr0",
|
||||
"-",
|
||||
"BC:24:11:36:DE:78",
|
||||
"dhcp",
|
||||
"auto",
|
||||
"no",
|
||||
]
|
||||
|
||||
|
||||
async def test_info_nets_row_for_vm_uses_ipconfig_tag_and_firewall(fake):
|
||||
fake.net_configs[108] = VM_CONFIG
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot)
|
||||
table = app.screen.query_one("#nets", DataTable)
|
||||
assert cells(table, 0) == [
|
||||
"net0",
|
||||
"vmbr0",
|
||||
"20",
|
||||
"02:24:A6:49:A8:25",
|
||||
"dhcp",
|
||||
"-",
|
||||
"yes",
|
||||
]
|
||||
|
||||
|
||||
async def test_info_addresses_show_runtime_ips_for_running_guest(fake):
|
||||
fake.iface_list = [{"name": "ens18", "mac": "02:24:A6:49:A8:25",
|
||||
"ipv4": ["172.16.41.50/23"], "ipv6": ["fe80::1/64"]}] # fmt: skip
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot)
|
||||
row = cells(app.screen.query_one("#addresses", DataTable), 0)
|
||||
assert row[0] == "ens18" and row[1] == "02:24:A6:49:A8:25"
|
||||
assert "172.16.41.50/23" in row[2] and "fe80::1/64" in row[3]
|
||||
assert fake.iface_calls == [108]
|
||||
|
||||
|
||||
async def test_info_does_not_query_interfaces_for_stopped_guest(fake):
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot, down=1) # CT 100 is stopped
|
||||
assert fake.iface_calls == []
|
||||
|
||||
|
||||
async def test_info_interfaces_failure_is_inline_unavailable_not_fatal(fake):
|
||||
fake.iface_error = cli_error("Error: QEMU guest agent is not running")
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot)
|
||||
text = text_of(app.screen).lower()
|
||||
assert "unavailable" in text and "guest agent is not running" in text
|
||||
assert errors(app) == [] and app.is_running
|
||||
assert (
|
||||
app.screen.query_one("#backups", DataTable).row_count == 2
|
||||
) # rest still loaded
|
||||
|
||||
|
||||
async def test_i_on_empty_table_does_nothing(fake):
|
||||
fake.guests = []
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await settle(app, pilot)
|
||||
await pilot.press("i")
|
||||
await settle(app, pilot)
|
||||
assert not isinstance(app.screen, appmod.InfoScreen)
|
||||
assert fake.backups_calls == []
|
||||
|
||||
|
||||
async def test_escape_closes_info_and_g_reloads_it(fake):
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot)
|
||||
before = len(fake.backups_calls)
|
||||
await pilot.press("g")
|
||||
await settle(app, pilot)
|
||||
assert len(fake.backups_calls) == before + 1
|
||||
await pilot.press("escape")
|
||||
await settle(app, pilot)
|
||||
assert not isinstance(app.screen, appmod.InfoScreen)
|
||||
|
||||
|
||||
async def test_b_opens_backup_screen_preselecting_job_storage(fake):
|
||||
fake.storages = ["local", "truenas-backup"] # job storage is not the first
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot)
|
||||
await open_backup(app, pilot)
|
||||
assert app.screen.query_one("#storage", Select).value == "truenas-backup"
|
||||
text = text_of(app.screen).lower()
|
||||
assert "snapshot" in text and "zstd" in text and "docker" in text
|
||||
assert fake.backup_runs == []
|
||||
|
||||
|
||||
async def test_backup_storage_falls_back_to_first_without_job(fake):
|
||||
fake.jobs = []
|
||||
fake.storages = ["local", "truenas-backup"]
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot)
|
||||
await open_backup(app, pilot)
|
||||
assert app.screen.query_one("#storage", Select).value == "local"
|
||||
|
||||
|
||||
async def test_backup_yes_runs_backup_now_once_busy_then_done_and_reloads(fake):
|
||||
app = make_app()
|
||||
busy_seen = []
|
||||
fake.backup_hook = lambda: busy_seen.append(set(app.busy))
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot)
|
||||
before = len(fake.backups_calls)
|
||||
await open_backup(app, pilot)
|
||||
await press_button(app, pilot, "#yes")
|
||||
assert fake.backup_runs == [(108, "truenas-backup")]
|
||||
assert busy_seen and busy_seen[0], (
|
||||
"guest must be marked busy while the backup runs"
|
||||
)
|
||||
assert app.busy == set()
|
||||
msgs = [m.lower() for m, _ in app.seen]
|
||||
assert any("backup started" in m for m in msgs)
|
||||
assert any("backup done" in m for m in msgs)
|
||||
assert errors(app) == []
|
||||
assert len(fake.backups_calls) > before # list reloaded
|
||||
assert isinstance(app.screen, appmod.InfoScreen)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("how", ["no", "escape"])
|
||||
async def test_backup_declined_makes_no_call(fake, how):
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot)
|
||||
await open_backup(app, pilot)
|
||||
if how == "no":
|
||||
await press_button(app, pilot, "#no")
|
||||
else:
|
||||
await pilot.press("escape")
|
||||
await settle(app, pilot)
|
||||
assert fake.backup_runs == []
|
||||
assert not isinstance(app.screen, appmod.BackupScreen)
|
||||
assert app.busy == set()
|
||||
|
||||
|
||||
async def test_backup_while_guest_busy_warns_and_makes_no_second_call(fake):
|
||||
started, release = threading.Event(), threading.Event()
|
||||
fake.backup_hook = lambda: (started.set(), release.wait(10))
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot)
|
||||
try:
|
||||
await open_backup(app, pilot)
|
||||
app.screen.query_one("#yes", Button).press()
|
||||
for _ in range(200):
|
||||
if started.is_set():
|
||||
break
|
||||
await pilot.pause(0.01)
|
||||
assert started.is_set()
|
||||
await pilot.press("b") # second attempt while the first still runs
|
||||
await pilot.pause()
|
||||
if isinstance(app.screen, appmod.BackupScreen): # refusal may come at "yes"
|
||||
app.screen.query_one("#yes", Button).press()
|
||||
await pilot.pause()
|
||||
assert any(sev == "warning" for _, sev in app.seen)
|
||||
assert fake.backup_runs == [(108, "truenas-backup")]
|
||||
finally:
|
||||
release.set()
|
||||
await settle(app, pilot)
|
||||
assert fake.backup_runs == [(108, "truenas-backup")]
|
||||
|
||||
|
||||
async def test_backup_now_failure_notifies_error_and_releases_busy(fake):
|
||||
fake.backup_error = cli_error("Error: storage 'truenas-backup' is not online")
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot)
|
||||
await open_backup(app, pilot)
|
||||
await press_button(app, pilot, "#yes")
|
||||
assert any("is not online" in m for m in errors(app))
|
||||
assert app.busy == set() and app.is_running
|
||||
assert not any("backup done" in m.lower() for m, _ in app.seen)
|
||||
|
||||
|
||||
# --- review findings: info screen robustness ---------------------------------
|
||||
|
||||
|
||||
async def test_info_backup_with_missing_size_shows_dash_and_app_survives(fake):
|
||||
fake.backup_list = [dict(BACKUPS[0], size=None)]
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot)
|
||||
assert "-" in cells(app.screen.query_one("#backups", DataTable), 0)
|
||||
assert app.is_running
|
||||
|
||||
|
||||
async def test_info_uses_live_status_not_table_status(fake):
|
||||
# the table status can be stale (listing lags); runtime IPs follow the live status
|
||||
fake.live_status = {100: "running"} # CT 100 is "stopped" in the listing
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot, down=1)
|
||||
assert fake.iface_calls == [100]
|
||||
Reference in new issue
Block a user