Flag guests that no backup job covers #20

Merged
Duckboot merged 1 commits from feat/not-backed-up-9 into main 2026-11-02 23:28:07 +01:00
5 changed files with 97 additions and 6 deletions
Showing only changes of commit 520de16cc6 - Show all commits

No files matched your search

+3
View File
@@ -35,6 +35,9 @@ The shell key needs key-based root SSH to the PVE node (LXC only).
Switching context runs `cv4pve-cli config use`, which changes cv4pve-cli's global current Switching context runs `cv4pve-cli config use`, which changes cv4pve-cli's global current
context: other shells and scripts follow, and it stays after the app exits. context: other shells and scripts follow, and it stays after the app exits.
A `!` in the first column marks a guest that no backup job covers (Proxmox's
`not-backed-up` list); the frame's bottom border shows how many.
Empty fields in the edit dialog are left unchanged. If a CPU/memory change cannot be Empty fields in the edit dialog are left unchanged. If a CPU/memory change cannot be
hot-plugged, the app tells you it applies at the next reboot. hot-plugged, the app tells you it applies at the next reboot.
+27 -6
View File
@@ -659,6 +659,8 @@ class GuestApp(App):
# bumped on every context switch; replies started earlier come from the old cluster # bumped on every context switch; replies started earlier come from the old cluster
self.generation = 0 self.generation = 0
self.switching = False self.switching = False
self.context_name = ""
self.unprotected: set[int] = set() # VMIDs no backup job covers
CSS = """ CSS = """
#guests_section { #guests_section {
@@ -679,6 +681,7 @@ class GuestApp(App):
self.watch(self, "sub_title", self._show_context_frame) self.watch(self, "sub_title", self._show_context_frame)
table = self.query_one("#guests", DataTable) table = self.query_one("#guests", DataTable)
table.add_columns( table.add_columns(
"", # "!" = no backup job covers the guest
"VMID", "VMID",
"Name", "Name",
"Type", "Type",
@@ -692,9 +695,13 @@ class GuestApp(App):
self.action_reload() self.action_reload()
def _show_context_frame(self, sub_title: str) -> None: def _show_context_frame(self, sub_title: str) -> None:
self.query_one("#guests_section").border_subtitle = sub_title.removeprefix( self.context_name = sub_title.removeprefix("context: ")
"context: " self._update_frame()
)
def _update_frame(self) -> None:
n = len(self.unprotected & {g["vmid"] for g in self.guests.values()})
count = f" · {n} without backup job" if n else ""
self.query_one("#guests_section").border_subtitle = self.context_name + count
@work(thread=True) @work(thread=True)
def _show_context(self) -> None: def _show_context(self) -> None:
@@ -724,21 +731,26 @@ class GuestApp(App):
table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
] ]
def _fill(self, guests: list[dict], gen: int) -> None: def _fill(
self, guests: list[dict], gen: int, unprotected: set[int] | None = None
) -> None:
if gen != self.generation: # listed before a context switch: old cluster if gen != self.generation: # listed before a context switch: old cluster
return return
self.unprotected = unprotected or set()
table = self.query_one("#guests", DataTable) table = self.query_one("#guests", DataTable)
current = self._selected() current = self._selected()
table.clear() table.clear()
self.guests = {str(g["vmid"]): g for g in guests} self.guests = {str(g["vmid"]): g for g in guests}
for g in guests: for g in guests:
_add_plain(table, _add_plain(table,
"!" if g["vmid"] in self.unprotected else "",
g["vmid"], g["name"], g["type"], g["node"], g["status"], g["vmid"], g["name"], g["type"], g["node"], g["status"],
g.get("maxcpu", "-"), _scaled(g.get("maxmem"), 2**20), _scaled(g.get("maxdisk"), 2**30), g.get("maxcpu", "-"), _scaled(g.get("maxmem"), 2**20), _scaled(g.get("maxdisk"), 2**30),
key=str(g["vmid"]), key=str(g["vmid"]),
) # fmt: skip ) # fmt: skip
if current and str(current["vmid"]) in self.guests: if current and str(current["vmid"]) in self.guests:
table.move_cursor(row=table.get_row_index(str(current["vmid"]))) table.move_cursor(row=table.get_row_index(str(current["vmid"])))
self._update_frame()
def _reload(self, acted: dict | None = None) -> None: def _reload(self, acted: dict | None = None) -> None:
"""Runs in a worker thread. `acted` gets its live status: the listing lags.""" """Runs in a worker thread. `acted` gets its live status: the listing lags."""
@@ -751,7 +763,16 @@ class GuestApp(App):
except Exception as exc: # noqa: BLE001 - any failure must reach the user, not kill the app except Exception as exc: # noqa: BLE001 - any failure must reach the user, not kill the app
self.call_from_thread(self.notify, _message(exc), severity="error") self.call_from_thread(self.notify, _message(exc), severity="error")
return return
self.call_from_thread(self._fill, guests, gen) unprotected = None
try:
unprotected = cli.not_backed_up()
except Exception as exc: # noqa: BLE001 - the listing still works without it
self.call_from_thread(
self.notify,
f"backup coverage unknown: {_message(exc)}",
severity="warning",
)
self.call_from_thread(self._fill, guests, gen, unprotected)
def action_reload(self) -> None: def action_reload(self) -> None:
if self.switching: if self.switching:
+6
View File
@@ -379,3 +379,9 @@ def set_net(guest: dict, changes: dict[str, str | None]) -> None:
run( run(
"set", kind(guest), "config", guest["node"], str(guest["vmid"]), *opts, "--wait" "set", kind(guest), "config", guest["node"], str(guest["vmid"]), *opts, "--wait"
) )
def not_backed_up() -> set[int]:
"""VMIDs no backup job covers (Proxmox's own answer, incl. pool-based jobs)."""
items = json.loads(run("get", "cluster", "not-backed-up", "-o", "json"))
return {int(i["vmid"]) for i in items if i.get("vmid") is not None}
+43
View File
@@ -49,6 +49,7 @@ class Fake:
self.use_error = None self.use_error = None
self.use_hook = None self.use_hook = None
self.guests_by_context = {} self.guests_by_context = {}
self.unprotected, self.unprotected_error = set(), None
self.list_hook = None self.list_hook = None
# delivery A (guest info screen) # delivery A (guest info screen)
self.net_configs = {} # vmid -> extra config keys (netN, ipconfigN) self.net_configs = {} # vmid -> extra config keys (netN, ipconfigN)
@@ -103,6 +104,11 @@ class Fake:
self.status_calls.append(guest["vmid"]) self.status_calls.append(guest["vmid"])
return self.live_status.get(guest["vmid"], guest["status"]) return self.live_status.get(guest["vmid"], guest["status"])
def not_backed_up(self):
if self.unprotected_error:
raise self.unprotected_error
return set(self.unprotected)
def contexts(self): def contexts(self):
self.contexts_calls += 1 self.contexts_calls += 1
if self.contexts_error: if self.contexts_error:
@@ -195,6 +201,7 @@ def fake(monkeypatch):
"backup_now", "backup_now",
"bridges", "bridges",
"set_net", "set_net",
"not_backed_up",
): ):
monkeypatch.setattr(cli, name, getattr(f, name), raising=False) monkeypatch.setattr(cli, name, getattr(f, name), raising=False)
for name in ("list_guests", "guest_config", "disks", "action", "set_resources", for name in ("list_guests", "guest_config", "disks", "action", "set_resources",
@@ -2108,3 +2115,39 @@ async def test_info_resources_reload_after_edit(fake, monkeypatch):
await settle(app, pilot) await settle(app, pilot)
assert isinstance(app.screen, appmod.InfoScreen) assert isinstance(app.screen, appmod.InfoScreen)
assert resource_rows(app)["CPU"].startswith("8 cores") assert resource_rows(app)["CPU"].startswith("8 cores")
# --- #9: flag guests that no backup job covers --------------------------------------
def first_cell(app, vmid):
return str(app.query_one("#guests", DataTable).get_row(str(vmid))[0])
async def test_unprotected_guests_are_marked_and_counted(fake):
fake.unprotected = {100}
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
assert first_cell(app, 100) == "!"
assert first_cell(app, 108) == ""
subtitle = str(app.query_one("#guests_section").border_subtitle)
assert "mainframe" in subtitle and "1 without backup job" in subtitle
async def test_no_count_when_every_guest_is_covered(fake):
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
assert str(app.query_one("#guests_section").border_subtitle) == "mainframe"
assert first_cell(app, 100) == "" and first_cell(app, 108) == ""
async def test_backup_coverage_failure_still_lists_guests(fake):
fake.unprotected_error = cli.CliError("Error: boom")
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
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
+18
View File
@@ -985,3 +985,21 @@ def test_apply_ip_rejects_netmask_form_and_zero_prefix(cidr):
family = 6 if ":" in cidr else 4 family = 6 if ":" in cidr else 4
with pytest.raises(ValueError): with pytest.raises(ValueError):
cli.apply_ip({}, family, "static", cidr) cli.apply_ip({}, family, "static", cidr)
# --- #9: guests not covered by any backup job -----------------------------------------
def test_not_backed_up_returns_vmids(fake):
fake.result = (
json.dumps([{"vmid": 104, "name": "debian", "type": "lxc"}, {"vmid": "110"}]),
"",
0,
)
assert cli.not_backed_up() == {104, 110}
assert fake.argv == ["cv4pve-cli", "get", "cluster", "not-backed-up", "-o", "json"]
def test_not_backed_up_empty(fake):
fake.result = ("[]", "", 0)
assert cli.not_backed_up() == set()