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
@@ -419,3 +419,267 @@ def test_use_context_unknown_name_raises_clierror(monkeypatch):
|
||||
with pytest.raises(cli.CliError) as exc:
|
||||
cli.use_context("x")
|
||||
assert "Context 'x' not found" in str(exc.value)
|
||||
|
||||
|
||||
# --- delivery A: backups / jobs / net / interfaces ---------------------------
|
||||
# spec: docs/plans/guest-tui-mvp.md ("Guest info screen ... delivery A" > cli.py)
|
||||
|
||||
CT_NET = "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:36:DE:78,ip=dhcp,ip6=auto,type=veth"
|
||||
VM_NET = "virtio=02:24:A6:49:A8:25,bridge=vmbr0,tag=20,firewall=1"
|
||||
VMID108 = {"vmid": 108, "name": "docker", "node": "mainframe", "type": "qemu"}
|
||||
|
||||
|
||||
def storage(name, content="backup", enabled=1, active=1):
|
||||
return {"storage": name, "content": content, "enabled": enabled, "active": active,
|
||||
"type": "dir", "avail": 10**9} # fmt: skip
|
||||
|
||||
|
||||
def test_backup_storages_filters_content_enabled_active_keeps_order(fake):
|
||||
fake.result = (
|
||||
json.dumps([
|
||||
storage("zeta", "iso,backup,vztmpl"),
|
||||
storage("nobackup", "iso,snippets,import"),
|
||||
storage("off", enabled=0),
|
||||
storage("inactive", active=0),
|
||||
storage("alpha", "backup"),
|
||||
]),
|
||||
"",
|
||||
0,
|
||||
) # fmt: skip
|
||||
assert cli.backup_storages(VM) == ["zeta", "alpha"]
|
||||
assert fake.argv == [
|
||||
"cv4pve-cli",
|
||||
"get",
|
||||
"node",
|
||||
"storages",
|
||||
"mainframe",
|
||||
"-o",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
def test_backup_jobs_argv_and_returns_list(fake):
|
||||
jobs = [{"id": "backup-1", "enabled": 1, "storage": "truenas-backup", "all": 1}]
|
||||
fake.result = (json.dumps(jobs), "", 0)
|
||||
assert cli.backup_jobs() == jobs
|
||||
assert fake.argv == ["cv4pve-cli", "get", "cluster", "backup-jobs", "-o", "json"]
|
||||
|
||||
|
||||
def job(storage="truenas-backup", enabled=1, all=0, vmid=None, exclude=None, node=None):
|
||||
return {"id": "j", "enabled": enabled, "storage": storage, "all": all, "vmid": vmid,
|
||||
"exclude": exclude, "node": node} # fmt: skip
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"jobs,expected",
|
||||
[
|
||||
([job(all=1)], "truenas-backup"), # all=1
|
||||
([job(all=1, exclude="108")], None), # excluded
|
||||
([job(all=1, exclude="100,108,200")], None), # excluded in a list
|
||||
([job(all=1, exclude="1080")], "truenas-backup"), # not a substring match
|
||||
([job(vmid="100,108,200")], "truenas-backup"), # vmid comma string
|
||||
([job(vmid="1080,200")], None), # not a substring match
|
||||
([job(vmid="100,200")], None), # not listed
|
||||
([job(all=1, node="othernode")], None), # node mismatch
|
||||
([job(all=1, node="mainframe")], "truenas-backup"), # node match
|
||||
([job(all=1, enabled=0)], None), # disabled
|
||||
([job(vmid=None, all=0)], None), # covers nothing
|
||||
([], None), # no job
|
||||
(
|
||||
[job("first", enabled=0, all=1), job("second", all=1), job("third", all=1)],
|
||||
"second",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_job_storage(jobs, expected):
|
||||
assert cli.job_storage(jobs, VMID108) == expected
|
||||
|
||||
|
||||
def test_backups_merges_storages_newest_first_and_adds_storage(monkeypatch):
|
||||
per_storage = {
|
||||
"truenas-backup": [
|
||||
{
|
||||
"volid": "truenas-backup:backup/vzdump-qemu-108-a.vma.zst",
|
||||
"ctime": 100,
|
||||
"size": 1,
|
||||
},
|
||||
{
|
||||
"volid": "truenas-backup:backup/vzdump-qemu-108-b.vma.zst",
|
||||
"ctime": 300,
|
||||
"size": 3,
|
||||
},
|
||||
],
|
||||
"local": [
|
||||
{"volid": "local:backup/vzdump-qemu-108-c.vma.zst", "ctime": 200, "size": 2}
|
||||
],
|
||||
}
|
||||
calls = []
|
||||
|
||||
def run(argv, **kwargs):
|
||||
calls.append(list(argv))
|
||||
return subprocess.CompletedProcess(
|
||||
argv, 0, json.dumps(per_storage[argv[5]]), ""
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli.subprocess, "run", run)
|
||||
result = cli.backups(VMID108, ["truenas-backup", "local"])
|
||||
assert [(b["ctime"], b["storage"]) for b in result] == [
|
||||
(300, "truenas-backup"), (200, "local"), (100, "truenas-backup"),
|
||||
] # fmt: skip
|
||||
assert calls == [
|
||||
["cv4pve-cli", "get", "guest", "backups", "mainframe", s, "108", "-o", "json"]
|
||||
for s in ("truenas-backup", "local")
|
||||
]
|
||||
|
||||
|
||||
def test_backup_now_exact_argv_without_yes(fake):
|
||||
cli.backup_now(VMID108, "truenas-backup")
|
||||
assert fake.argv == [
|
||||
"cv4pve-cli", "do", "backup", "guest", "mainframe", "108", "truenas-backup",
|
||||
"--mode", "snapshot", "--compress", "zstd",
|
||||
"--notes-template", "{{guestname}} (manual)", "--wait",
|
||||
] # fmt: skip
|
||||
assert "-y" not in fake.argv and "--yes" not in fake.argv
|
||||
|
||||
|
||||
def test_backup_now_failure_raises_clierror(monkeypatch):
|
||||
FakeRun(monkeypatch, "Error: no space", returncode=5)
|
||||
with pytest.raises(cli.CliError):
|
||||
cli.backup_now(VMID108, "local")
|
||||
|
||||
|
||||
def test_parse_net_ct_sample_keeps_order():
|
||||
net = cli.parse_net(CT_NET)
|
||||
assert list(net.items()) == [
|
||||
("name", "eth0"), ("bridge", "vmbr0"), ("hwaddr", "BC:24:11:36:DE:78"),
|
||||
("ip", "dhcp"), ("ip6", "auto"), ("type", "veth"),
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def test_parse_net_vm_sample_first_item_is_model_mac():
|
||||
net = cli.parse_net(VM_NET)
|
||||
assert list(net.items()) == [
|
||||
("virtio", "02:24:A6:49:A8:25"), ("bridge", "vmbr0"), ("tag", "20"), ("firewall", "1"),
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def test_nets_only_netN_keys_sorted_numerically():
|
||||
config = {
|
||||
"cores": 4,
|
||||
"net10": VM_NET,
|
||||
"net2": CT_NET,
|
||||
"ipconfig0": "ip=dhcp",
|
||||
"net0": VM_NET,
|
||||
}
|
||||
result = cli.nets(config)
|
||||
assert list(result) == ["net0", "net2", "net10"]
|
||||
assert result["net2"] == cli.parse_net(CT_NET)
|
||||
|
||||
|
||||
def test_interfaces_ct_inet_inet6_and_excludes_lo(fake):
|
||||
fake.result = (
|
||||
json.dumps([
|
||||
{"name": "lo", "hwaddr": "00:00:00:00:00:00", "inet": "127.0.0.1/8", "inet6": "::1/128"},
|
||||
{"name": "eth0", "hwaddr": "BC:24:11:36:DE:78", "inet": "172.16.41.200/23",
|
||||
"inet6": "fe80::be24:11ff:fe36:de78/64", "ip-addresses": "[]"},
|
||||
{"name": "eth1", "hwaddr": "BC:24:11:36:DE:79", "inet": "10.0.0.5/24", "ip-addresses": "[]"},
|
||||
]),
|
||||
"",
|
||||
0,
|
||||
) # fmt: skip
|
||||
assert cli.interfaces(CT) == [
|
||||
{"name": "eth0", "mac": "BC:24:11:36:DE:78", "ipv4": ["172.16.41.200/23"],
|
||||
"ipv6": ["fe80::be24:11ff:fe36:de78/64"]},
|
||||
{"name": "eth1", "mac": "BC:24:11:36:DE:79", "ipv4": ["10.0.0.5/24"], "ipv6": []},
|
||||
] # fmt: skip
|
||||
assert fake.argv == [
|
||||
"cv4pve-cli",
|
||||
"get",
|
||||
"ct",
|
||||
"interfaces",
|
||||
"mainframe",
|
||||
"100",
|
||||
"-o",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
def test_interfaces_vm_agent_result_json_string_and_excludes_lo(fake):
|
||||
agent = [
|
||||
{"name": "lo", "hardware-address": "00:00:00:00:00:00",
|
||||
"ip-addresses": [{"ip-address": "127.0.0.1", "prefix": 8, "ip-address-type": "ipv4"}]},
|
||||
{"name": "ens18", "hardware-address": "02:24:A6:49:A8:25",
|
||||
"ip-addresses": [
|
||||
{"ip-address": "172.16.41.50", "prefix": 23, "ip-address-type": "ipv4"},
|
||||
{"ip-address": "fe80::24:a6ff:fe49:a825", "prefix": 64, "ip-address-type": "ipv6"},
|
||||
{"ip-address": "2001:db8::5", "prefix": 64, "ip-address-type": "ipv6"},
|
||||
]},
|
||||
] # fmt: skip
|
||||
fake.result = (json.dumps([{"key": "result", "value": json.dumps(agent)}]), "", 0)
|
||||
assert cli.interfaces(VMID108) == [
|
||||
{"name": "ens18", "mac": "02:24:A6:49:A8:25", "ipv4": ["172.16.41.50/23"],
|
||||
"ipv6": ["fe80::24:a6ff:fe49:a825/64", "2001:db8::5/64"]},
|
||||
] # fmt: skip
|
||||
assert fake.argv == [
|
||||
"cv4pve-cli",
|
||||
"get",
|
||||
"vm",
|
||||
"agent",
|
||||
"network",
|
||||
"mainframe",
|
||||
"108",
|
||||
"-o",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
def test_interfaces_vm_agent_failure_raises_clierror(monkeypatch):
|
||||
FakeRun(monkeypatch, "Error: QEMU guest agent is not running", returncode=5)
|
||||
with pytest.raises(cli.CliError):
|
||||
cli.interfaces(VMID108)
|
||||
|
||||
|
||||
def test_backup_jobs_parses_prune_backups_json_string(fake):
|
||||
# real cv4pve-cli output: prune-backups is a JSON *string*
|
||||
fake.result = (
|
||||
json.dumps(
|
||||
[
|
||||
{"id": "j", "prune-backups": '{"keep-last":"3"}'},
|
||||
{"id": "k", "prune-backups": None},
|
||||
]
|
||||
),
|
||||
"",
|
||||
0,
|
||||
)
|
||||
jobs = cli.backup_jobs()
|
||||
assert jobs[0]["prune-backups"] == {"keep-last": "3"}
|
||||
assert jobs[1]["prune-backups"] is None
|
||||
|
||||
|
||||
def test_interfaces_vm_tolerates_null_addresses_and_unknown_types(fake):
|
||||
result = [
|
||||
{"name": "eth0", "hardware-address": "aa", "ip-addresses": None},
|
||||
{"name": "eth1", "ip-addresses": [{"ip-address-type": "ipv5", "ip-address": "x", "prefix": 1},
|
||||
{"ip-address-type": "ipv4", "ip-address": "10.0.0.2", "prefix": 24}]},
|
||||
] # fmt: skip
|
||||
fake.result = (json.dumps([{"key": "result", "value": json.dumps(result)}]), "", 0)
|
||||
found = cli.interfaces({"vmid": 108, "node": "mainframe", "type": "qemu"})
|
||||
assert found == [
|
||||
{"name": "eth0", "mac": "aa", "ipv4": [], "ipv6": []},
|
||||
{"name": "eth1", "mac": None, "ipv4": ["10.0.0.2/24"], "ipv6": []},
|
||||
]
|
||||
|
||||
|
||||
def test_interfaces_ct_lists_all_addresses_incl_global_ipv6(fake):
|
||||
# real output: inet6 holds only one address (link-local); ip-addresses (JSON string) has all
|
||||
addrs = [
|
||||
{"prefix": "23", "ip-address": "172.16.41.200", "ip-address-type": "inet"},
|
||||
{"ip-address-type": "inet6", "ip-address": "2a02:fe1:e77e:6004:be24:11ff:fe36:de78", "prefix": "64"},
|
||||
{"prefix": "64", "ip-address": "fe80::be24:11ff:fe36:de78", "ip-address-type": "inet6"},
|
||||
] # fmt: skip
|
||||
eth0 = {"name": "eth0", "hwaddr": "bc:24:11:36:de:78", "inet": "172.16.41.200/23",
|
||||
"inet6": "fe80::be24:11ff:fe36:de78/64", "ip-addresses": json.dumps(addrs)} # fmt: skip
|
||||
fake.result = (json.dumps([eth0]), "", 0)
|
||||
found = cli.interfaces({"vmid": 104, "node": "mainframe", "type": "lxc"})
|
||||
assert found == [{"name": "eth0", "mac": "bc:24:11:36:de:78", "ipv4": ["172.16.41.200/23"],
|
||||
"ipv6": ["2a02:fe1:e77e:6004:be24:11ff:fe36:de78/64", "fe80::be24:11ff:fe36:de78/64"]}] # fmt: skip
|
||||
Reference in new issue
Block a user