Show pending node package updates (read-only)
CI / pytest and ruff (pull_request) Successful in 4m47s
CI / pytest and ruff (push) Successful in 4m22s

U opens "Package updates": the pending apt updates of every online
node, with node, package, current and new version, origin and
priority. A node that fails is shown as "<node>: unavailable", and the
other nodes are still listed. A hint says the list is as of the
node's last apt update; this call does not refresh it.

The main frame's bottom border shows "· N package updates" after the
backup count, with a singular for 1. The count is fetched on mount,
after a context switch and whenever the updates screen loads, not on
every guest reload, since it costs one CLI call per node. It uses the
same generation guard as the guest list, so a count from the old
cluster is dropped after a switch.

Running apt update or upgrade stays out of scope; it is a write.

Read-only live check: "mainframe · 23 package updates", and 23 rows
in the screen.

Closes #13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #26.
This commit is contained in:
DuckbootandClaude Opus 5.5 committed 2026-11-03 00:14:07 +01:00
1 parent 1ed5e5069b
commit 364f8e2137
6 files changed
+493

No files matched your search

+1
View File
@@ -29,6 +29,7 @@ uv run cv4pve-tui
| `Enter` then `p` / `R` / `D` | in info: take a snapshot (name, description, VM: include RAM) / roll back to the snapshot under the cursor (confirms; starts the guest again if it is running) / delete the snapshot under the cursor (confirms) | | `Enter` then `p` / `R` / `D` | in info: take a snapshot (name, description, VM: include RAM) / roll back to the snapshot under the cursor (confirms; starts the guest again if it is running) / delete the snapshot under the cursor (confirms) |
| `t` | shell in a running LXC container (`ssh -t root@<node> pct enter <vmid>`) | | `t` | shell in a running LXC container (`ssh -t root@<node> pct enter <vmid>`) |
| `l` | tasks: recent cluster tasks (start, duration, node, type, VMID, user, status; `!` marks failures); `Enter` shows the log of the task under the cursor, `g` reloads. In info: `l` = only this guest's tasks | | `l` | tasks: recent cluster tasks (start, duration, node, type, VMID, user, status; `!` marks failures); `Enter` shows the log of the task under the cursor, `g` reloads. In info: `l` = only this guest's tasks |
| `U` | package updates: pending updates per online node (node, package, current, new, origin, priority), read-only; `g` reloads. The list is as of the node's last `apt update`. The frame's bottom border shows the count |
| `g` | refresh | | `g` | refresh |
| `c` | switch cv4pve-cli context (cluster) | | `c` | switch cv4pve-cli context (cluster) |
| `q` | quit | | `q` | quit |
+38
View File
@@ -591,3 +591,41 @@ CLI facts (read-only probes):
a read-only plain-text log widget `#tasklog` (no markup parsing), loaded in a worker; a read-only plain-text log widget `#tasklog` (no markup parsing), loaded in a worker;
failure -> error notify. `escape` closes. failure -> error notify. `escape` closes.
- Main-screen footer shows `l Tasks`. - Main-screen footer shows `l Tasks`.
---
# Node package updates (issue #13)
## Decision (user-approved)
`U` on the main screen shows pending package updates per node; the frame's bottom
border shows the count. Read-only: running `apt update`/`upgrade` stays out of scope.
CLI facts (read-only probes):
- `get nodes -o json` -> list of dicts with `node`, `status` (`online`/`offline`), ...
- `get node packages <node> -o json` -> list of dicts with `Package`, `OldVersion`,
`Version`, `Title`, `Priority`, `Origin`, ... (23 on mainframe). The list is as fresh
as the node's last `apt update` (Proxmox runs it daily); the call does not refresh it.
### `cli.py`
- `nodes() -> list[str]` - names of online nodes, sorted.
- `package_updates(node) -> list[dict]` - `get node packages <node> -o json`, sorted by
`Package`.
### `app.py` (UI contract)
- `UpdatesScreen(ModalScreen)` opened by `U`: frame `#updates_section` titled "Package
updates", `DataTable#updates`: Node, Package, Current (`OldVersion`), New
(`Version`), Origin, Priority; `Static#updates_note`: "No pending updates" when
empty, plus one line per node that failed ("<node>: unavailable: <summary>") and a
fixed hint that the list is as of the node's last apt update. Loaded in a thread
worker; `g` reloads; `escape` closes. A failing node does not hide the others.
- The main frame's bottom border adds "· N package updates" when N > 0, after the
backup count. The count is fetched in a worker on mount, after a context switch, and
every time the UpdatesScreen loads - not on every guest reload (one CLI call per node).
It follows the same generation guard as the guest list (a count from the old cluster
is dropped after a switch) and is cleared when a switch starts. Failures leave the
count unknown (nothing shown), no error notify on mount.
- Main footer shows `U Updates`.
+107
View File
@@ -536,6 +536,81 @@ class TasksScreen(ModalScreen):
self.app.push_screen(TaskLogScreen(self.rows[event.cursor_row])) self.app.push_screen(TaskLogScreen(self.rows[event.cursor_row]))
def _collect_updates() -> tuple[list[dict], list[str]]:
"""Runs in a worker thread: (rows over all online nodes, one note per failed node)."""
rows, notes = [], []
for node in cli.nodes():
try:
rows += [{**p, "node": node} for p in cli.package_updates(node)]
except Exception as exc: # noqa: BLE001 - a failing node must not hide the others
notes.append(f"{node}: unavailable: {_message(exc)}")
return rows, notes
class UpdatesScreen(ModalScreen):
"""Pending package updates of every online node (read-only)."""
BINDINGS = [ # noqa: RUF012 - Textual idiom
("escape", "dismiss", "Close"),
("g", "reload", "Refresh"),
]
DEFAULT_CSS = """
UpdatesScreen { align: center middle; }
UpdatesScreen #updates_section {
width: 100%; height: 100%; border: round $accent; border-title-style: bold;
padding: 0 1; background: $surface;
}
UpdatesScreen DataTable { height: 1fr; }
"""
def compose(self) -> ComposeResult:
with Vertical(id="updates_section") as section:
section.border_title = "Package updates"
yield DataTable(id="updates", cursor_type="row")
yield Static("", id="updates_note", markup=False)
yield Footer()
def on_mount(self) -> None:
self.query_one("#updates", DataTable).add_columns(
"Node", "Package", "Current", "New", "Origin", "Priority"
)
self.action_reload()
def action_reload(self) -> None:
self._load_worker()
@work(thread=True)
def _load_worker(self) -> None:
gen = self.app.generation
try:
rows, notes = _collect_updates()
except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app
self.app.call_from_thread(self.app.notify, _message(exc), severity="error")
return
self.app.call_from_thread(self._fill, rows, notes)
self.app.call_from_thread(
self.app._set_count, None if notes else len(rows), gen
)
def _fill(self, rows: list[dict], notes: list[str]) -> None:
table = self.query_one("#updates", DataTable)
table.clear()
for p in rows:
_add_plain(
table,
p["node"],
p.get("Package", ""),
p.get("OldVersion", ""),
p.get("Version", ""),
p.get("Origin", ""),
p.get("Priority", ""),
)
lines = [] if rows else ["No pending updates"]
lines += notes
lines.append("Lists are as of each node's last apt update; it is not run here.")
self.query_one("#updates_note", Static).update("\n".join(lines))
class InfoScreen(ModalScreen): class InfoScreen(ModalScreen):
"""Backups, backup job and network of one guest (read-only), plus backup now.""" """Backups, backup job and network of one guest (read-only), plus backup now."""
@@ -932,6 +1007,7 @@ class GuestApp(App):
("g", "reload", "Refresh"), ("g", "reload", "Refresh"),
("c", "context", "Context"), ("c", "context", "Context"),
("l", "tasks", "Tasks"), ("l", "tasks", "Tasks"),
("U", "updates", "Updates"),
("q", "quit", "Quit"), ("q", "quit", "Quit"),
] ]
@@ -945,6 +1021,7 @@ class GuestApp(App):
self.switching = False self.switching = False
self.context_name = "" self.context_name = ""
self.unprotected: set[int] = set() # VMIDs no backup job covers self.unprotected: set[int] = set() # VMIDs no backup job covers
self.package_count: int | None = None # pending package updates; None = unknown
CSS = """ CSS = """
#guests_section { #guests_section {
@@ -979,10 +1056,34 @@ class GuestApp(App):
) )
self._show_context() self._show_context()
self.action_reload() self.action_reload()
self._count_worker()
def action_tasks(self) -> None: def action_tasks(self) -> None:
self.push_screen(TasksScreen()) self.push_screen(TasksScreen())
def action_updates(self) -> None:
self.push_screen(UpdatesScreen())
@work(thread=True)
def _count_worker(self) -> None:
self._refresh_count()
def _refresh_count(self) -> None:
"""Runs in a worker thread: any failure leaves the count unknown, silently."""
gen = self.generation
try:
rows, notes = _collect_updates()
count = None if notes else len(rows)
except Exception: # noqa: BLE001 - the count is optional; no notify on failure
count = None
self.call_from_thread(self._set_count, count, gen)
def _set_count(self, count: int | None, gen: int) -> None:
if gen != self.generation: # fetched before a context switch: old cluster
return
self.package_count = count
self._update_frame()
def _show_context_frame(self, sub_title: str) -> None: def _show_context_frame(self, sub_title: str) -> None:
self.context_name = sub_title.removeprefix("context: ") self.context_name = sub_title.removeprefix("context: ")
self._update_frame() self._update_frame()
@@ -990,6 +1091,9 @@ class GuestApp(App):
def _update_frame(self) -> None: def _update_frame(self) -> None:
n = len(self.unprotected & {g["vmid"] for g in self.guests.values()}) n = len(self.unprotected & {g["vmid"] for g in self.guests.values()})
count = f" · {n} without backup job" if n else "" count = f" · {n} without backup job" if n else ""
if self.package_count:
n = self.package_count
count += f" · {n} package update{'s' if n != 1 else ''}"
self.query_one("#guests_section").border_subtitle = self.context_name + count self.query_one("#guests_section").border_subtitle = self.context_name + count
@work(thread=True) @work(thread=True)
@@ -1112,6 +1216,8 @@ class GuestApp(App):
).clear() # old cluster's guests: not actionable ).clear() # old cluster's guests: not actionable
self.guests = {} self.guests = {}
self.undo.clear() # VMIDs repeat across clusters: never undo onto another cluster self.undo.clear() # VMIDs repeat across clusters: never undo onto another cluster
self.package_count = None
self._update_frame()
self.generation += 1 self.generation += 1
self.switching = True self.switching = True
self._switch_context(name) self._switch_context(name)
@@ -1127,6 +1233,7 @@ class GuestApp(App):
else: else:
self.call_from_thread(setattr, self, "sub_title", f"context: {name}") self.call_from_thread(setattr, self, "sub_title", f"context: {name}")
self._reload() self._reload()
self._refresh_count()
finally: finally:
self.call_from_thread(setattr, self, "switching", False) self.call_from_thread(setattr, self, "switching", False)
+12
View File
@@ -456,6 +456,18 @@ def tasks() -> list[dict]:
return sorted(items, key=lambda t: t.get("starttime") or 0, reverse=True) return sorted(items, key=lambda t: t.get("starttime") or 0, reverse=True)
def nodes() -> list[str]:
"""Names of the online nodes, sorted."""
items = json.loads(run("get", "nodes", "-o", "json"))
return sorted(n["node"] for n in items if n.get("status") == "online")
def package_updates(node: str) -> list[dict]:
"""Pending package updates of a node (as of its last `apt update`), by package."""
items = json.loads(run("get", "node", "packages", node, "-o", "json"))
return sorted(items, key=lambda p: p.get("Package", ""))
def task_log(upid: str) -> str: def task_log(upid: str) -> str:
if not isinstance(upid, str) or not upid.startswith("UPID:"): if not isinstance(upid, str) or not upid.startswith("UPID:"):
raise ValueError("not a task id (UPID)") raise ValueError("not a task id (UPID)")
+300
View File
@@ -80,6 +80,12 @@ class Fake:
] # newest first, as cli.tasks returns them ] # newest first, as cli.tasks returns them
self.tasks_error, self.tasks_calls = None, 0 self.tasks_error, self.tasks_calls = None, 0
self.log_text, self.log_error, self.log_calls = "TASK OK\n", None, [] self.log_text, self.log_error, self.log_calls = "TASK OK\n", None, []
# issue #13 (node package updates): node -> rows; one online node, nothing pending
self.updates = {"mainframe": []}
self.updates_by_context = {} # context name -> {node: rows}, overrides self.updates
self.nodes_error, self.updates_errors = None, {} # node -> exception
self.nodes_calls, self.updates_calls = 0, []
self.updates_hook = None
def list_guests(self): def list_guests(self):
self.list_calls += 1 self.list_calls += 1
@@ -225,6 +231,26 @@ class Fake:
raise self.tasks_error raise self.tasks_error
return [dict(t) for t in self.task_list] return [dict(t) for t in self.task_list]
def _cluster(self):
return self.updates_by_context.get(self.current, self.updates)
def nodes(self):
self.nodes_calls += 1
if self.nodes_error:
raise self.nodes_error
return sorted(self._cluster())
def package_updates(self, node):
self.updates_calls.append(node)
rows = [dict(r) for r in self._cluster()[node]]
if (
self.updates_hook
): # runs after reading: simulates a slow reply from that cluster
self.updates_hook()
if node in self.updates_errors:
raise self.updates_errors[node]
return rows
def task_log(self, upid): def task_log(self, upid):
self.log_calls.append(upid) self.log_calls.append(upid)
if self.log_error: if self.log_error:
@@ -287,6 +313,8 @@ def fake(monkeypatch):
"delete_snapshot", "delete_snapshot",
"tasks", "tasks",
"task_log", "task_log",
"nodes",
"package_updates",
): ):
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",
@@ -3171,3 +3199,275 @@ async def test_finished_task_with_empty_status_is_not_shown_as_running(fake):
row = [str(c) for c in table.get_row_at(0)] row = [str(c) for c in table.get_row_at(0)]
assert "running" not in row assert "running" not in row
assert row[0] == "!" # finished without OK: flagged assert row[0] == "!" # finished without OK: flagged
# --- #13: node package updates ------------------------------------------------------------
# spec: docs/plans/guest-tui-mvp.md ("Node package updates (issue #13)")
def pkg(name, old="1.0", new="2.0", origin="Debian", priority="optional"):
return {"Package": name, "OldVersion": old, "Version": new, "Title": f"{name} title",
"Priority": priority, "Origin": origin} # fmt: skip
def many(n, prefix="pkg"):
return [pkg(f"{prefix}{i:02d}") for i in range(n)]
def frame_subtitle(app):
return str(app.query_one("#guests_section").border_subtitle)
async def open_updates(app, pilot):
await pilot.press("U")
await settle(app, pilot)
assert isinstance(app.screen, appmod.UpdatesScreen)
return app.screen.query_one("#updates", DataTable)
def note_of(app):
return str(app.screen.query_one("#updates_note", Static).render())
def two_nodes(fake):
fake.updates = {
"mainframe": [pkg("curl", "7.1", "7.2", "Debian", "optional"),
pkg("pve-manager", "8.1", "8.2", "Proxmox", "important")],
"edge": [pkg("bash", "5.1", "5.2", "Debian", "required")],
} # fmt: skip
async def test_u_lists_rows_across_nodes_with_all_columns(
fake,
): # UpdatesScreen rows + columns
two_nodes(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
table = await open_updates(app, pilot)
assert [str(c.label) for c in table.columns.values()] == [
"Node", "Package", "Current", "New", "Origin", "Priority"] # fmt: skip
assert table.row_count == 3
assert {tuple(cells(table, i)) for i in range(3)} == {
("mainframe", "curl", "7.1", "7.2", "Debian", "optional"),
("mainframe", "pve-manager", "8.1", "8.2", "Proxmox", "important"),
("edge", "bash", "5.1", "5.2", "Debian", "required"),
}
assert (
app.screen.query_one("#updates_section").border_title == "Package updates"
)
async def test_updates_screen_shows_no_pending_updates_note_when_empty(
fake,
): # "No pending updates"
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
table = await open_updates(app, pilot)
assert table.row_count == 0
assert "No pending updates" in note_of(app)
async def test_updates_screen_has_apt_update_freshness_hint(
fake,
): # fixed hint: as of last apt update
two_nodes(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
await open_updates(app, pilot)
assert "apt update" in note_of(app).lower()
async def test_failing_node_is_unavailable_and_other_nodes_still_listed(
fake,
): # failing node != hidden others
two_nodes(fake)
err = cli.CliError("Error: node edge timed out")
fake.updates_errors = {"edge": err}
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
table = await open_updates(app, pilot)
assert table.row_count == 2
assert {cells(table, i)[0] for i in range(2)} == {"mainframe"}
note = note_of(app)
assert "edge: unavailable" in note and err.summary in note
assert "mainframe: unavailable" not in note
async def test_g_in_updates_screen_reloads(fake): # `g` reloads
two_nodes(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
table = await open_updates(app, pilot)
before = len(fake.updates_calls)
fake.updates["edge"].append(pkg("zsh"))
await pilot.press("g")
await settle(app, pilot)
assert len(fake.updates_calls) > before
assert table.row_count == 4
async def test_escape_closes_updates_screen(fake): # `escape` closes
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
await open_updates(app, pilot)
await pilot.press("escape")
await settle(app, pilot)
assert not isinstance(app.screen, appmod.UpdatesScreen)
assert app.is_running
async def test_frame_subtitle_shows_package_update_count_after_mount(
fake,
): # "· N package updates"
fake.updates = {"mainframe": many(23)}
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
assert "· 23 package updates" in frame_subtitle(app)
assert "mainframe" in frame_subtitle(app)
async def test_frame_subtitle_count_sums_nodes(fake): # count over all online nodes
two_nodes(fake)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
assert "· 3 package updates" in frame_subtitle(app)
async def test_no_package_count_when_nothing_pending(fake): # N > 0 only
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
assert frame_subtitle(app) == "mainframe"
async def test_package_count_follows_backup_count_in_subtitle(
fake,
): # "after the backup count"
fake.unprotected = {100}
fake.updates = {"mainframe": many(23)}
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
sub = frame_subtitle(app)
assert "1 without backup job" in sub and "· 23 package updates" in sub
assert sub.index("without backup job") < sub.index("package updates")
async def test_plain_guest_reload_does_not_refetch_package_count(
fake,
): # not on every guest reload
fake.updates = {"mainframe": many(23)}
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
nodes_before, pkg_before, list_before = (
fake.nodes_calls,
len(fake.updates_calls),
fake.list_calls,
)
assert nodes_before >= 1 and pkg_before >= 1
await pilot.press("g")
await settle(app, pilot)
assert fake.list_calls > list_before
assert (fake.nodes_calls, len(fake.updates_calls)) == (nodes_before, pkg_before)
assert "· 23 package updates" in frame_subtitle(app)
async def test_opening_updates_screen_refreshes_the_frame_count(
fake,
): # count fetched on every screen load
fake.updates = {"mainframe": many(23)}
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
fake.updates["mainframe"] = many(5)
await open_updates(app, pilot)
assert "· 5 package updates" in frame_subtitle(app)
assert "23 package updates" not in frame_subtitle(app)
async def test_context_switch_clears_old_count_then_shows_new_clusters_count(
fake,
): # cleared at switch start
fake.updates = {"mainframe": many(23)}
fake.updates_by_context = {"mainframe-edge": {"edge": many(4)}}
fake.guests_by_context = {"mainframe-edge": [EDGE]}
started, release = threading.Event(), threading.Event()
fake.use_hook = lambda: (started.set(), release.wait(10))
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
assert "23 package updates" in frame_subtitle(app)
try:
await switch_to_edge(app, pilot)
await until(pilot, started.is_set)
await pilot.pause()
assert "package updates" not in frame_subtitle(app)
finally:
release.set()
await settle(app, pilot)
sub = frame_subtitle(app)
assert "· 4 package updates" in sub and "23" not in sub
async def test_count_fetched_before_a_switch_but_arriving_after_it_is_dropped(
fake,
): # generation guard
fake.updates = {"mainframe": many(23)}
fake.updates_by_context = {"mainframe-edge": {"edge": many(4)}}
fake.guests_by_context = {"mainframe-edge": [EDGE]}
hook, started, release = blocking_once()
fake.updates_hook = hook # the mount-time count reads mainframe, then stalls
app = make_app()
async with app.run_test(size=SIZE) as pilot:
try:
await until(pilot, started.is_set)
await switch_to_edge(app, pilot)
await until(pilot, lambda: rows(app) == ["300"])
finally:
release.set()
await settle(app, pilot)
sub = frame_subtitle(app)
assert "23" not in sub
assert "· 4 package updates" in sub
@pytest.mark.parametrize("failure", ["nodes", "node"])
async def test_count_failure_on_mount_shows_nothing_and_does_not_notify(
fake, failure
): # unknown count
fake.updates = {"mainframe": many(23)}
if failure == "nodes":
fake.nodes_error = cli.CliError("Error: no route")
else:
fake.updates_errors = {"mainframe": cli.CliError("Error: no route")}
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
assert "package updates" not in frame_subtitle(app)
assert errors(app) == []
assert app.query_one("#guests", DataTable).row_count == 2
async def test_footer_on_main_screen_shows_u_updates(fake): # footer `U Updates`
app = make_app()
async with app.run_test() as pilot:
await settle(app, pilot)
shown = [ab.binding for ab in app.active_bindings.values() if ab.binding.show]
assert any(b.key == "U" and b.description == "Updates" for b in shown)
async def test_frame_subtitle_singular_for_one_package_update(fake):
fake.updates = {"mainframe": many(1)}
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await settle(app, pilot)
assert frame_subtitle(app).endswith("· 1 package update")
+35
View File
@@ -1340,3 +1340,38 @@ def test_task_log_rejects_non_upid_without_calling_subprocess(
with pytest.raises(ValueError): with pytest.raises(ValueError):
cli.task_log(bad) cli.task_log(bad)
assert fake.calls == [] assert fake.calls == []
# --- #13: nodes / package_updates (spec: "Node package updates (issue #13)") ---------------
def test_nodes_argv_only_online_sorted(fake):
fake.result = (json.dumps([{"node": "zeta", "status": "online"},
{"node": "down", "status": "offline"},
{"node": "alpha", "status": "online"}]), "", 0) # fmt: skip
assert cli.nodes() == ["alpha", "zeta"]
assert fake.argv == ["cv4pve-cli", "get", "nodes", "-o", "json"]
def test_package_updates_argv_and_sorted_by_package(fake):
fake.result = (json.dumps([{"Package": "zsh", "Version": "2"},
{"Package": "bash", "Version": "1"},
{"Package": "curl", "Version": "3"}]), "", 0) # fmt: skip
got = cli.package_updates("mainframe")
assert fake.argv == [
"cv4pve-cli",
"get",
"node",
"packages",
"mainframe",
"-o",
"json",
]
assert [p["Package"] for p in got] == ["bash", "curl", "zsh"]
assert got[0] == {"Package": "bash", "Version": "1"}
def test_package_updates_failure_raises_clierror(monkeypatch):
FakeRun(monkeypatch, "Error: node unreachable\n", returncode=1)
with pytest.raises(cli.CliError):
cli.package_updates("mainframe")