Files
cv4pve-cli-tui/docs/plans/guest-tui-mvp.md
T
DuckbootandClaude Opus 5.5 32809b6743 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>
2026-11-02 21:06:22 +01:00

344 lines
18 KiB
Markdown

# Guest TUI MVP
## Problem
Manage Proxmox guests (VMs and LXC containers) from the terminal without remembering
`cv4pve-cli` syntax: list them, start/shutdown/stop/reboot, and change CPU cores,
memory and disk size.
## Approach
Thin subprocess wrapper around `cv4pve-cli` (tested against 2.4.0) plus a Textual app.
The app never handles credentials: it uses whatever context `cv4pve-cli config use`
selected.
Observed CLI behaviour the design relies on:
- `get guests -o json` -> list of dicts with `vmid`, `type` (`qemu`|`lxc`), `node`,
`name`, `status`, `maxcpu`, `maxmem` (bytes), `maxdisk` (bytes), ...
- `show vm|ct <node> <vmid> -o json` -> list of `{"key": ..., "value": ...}`.
- `api get /nodes/<node>/<qemu|lxc>/<vmid>/pending -o json` -> list of
`{"key", "value", "pending", "delete"}`; `pending` non-null means not yet applied.
- Async actions print a UPID and exit 0 **even when the task fails**. With `--wait`
the CLI waits for the task and exits non-zero (5) with the error text on failure.
Every mutating call therefore uses `--wait`.
- `do stop` refuses to run without `-y` (verified); `-y` is accepted by all four
lifecycle actions, so it is always passed.
- Errors: non-zero exit, message on stdout/stderr. Sync failures are multi-line
(`Error: ...` / `Call: ...` / `Alias: ...`). Success is judged by exit code only,
never by parsing UPID text.
- Each call costs ~0.5 s (.NET startup), so calls run in worker threads.
- `get guests` (`/cluster/resources`) lags a few seconds behind actions; after an
action the app reads the acted-on guest's live status from
`api get /nodes/<node>/<qemu|lxc>/<vmid>/status/current` (key/value list).
### Module `cv4pve_tui/cli.py`
- `CliError(Exception)` - `str()` is the CLI's combined, stripped output;
`.summary` is one line for the UI: the first non-empty line that is not a UPID,
`Error:`-prefix stripped, plus the following indented detail line if the first
line ends with `:`.
- `run(*args) -> str` - `subprocess.run(["cv4pve-cli", *args], capture_output=True,
text=True, stdin=DEVNULL, timeout=330)`; non-zero exit -> `CliError`.
- `kind(guest) -> "vm" | "ct"` - from `type` (`qemu` -> `vm`, `lxc` -> `ct`).
- `list_guests() -> list[dict]` - sorted by `vmid`.
- `guest_config(guest) -> dict` - key/value list flattened to a dict.
- `disks(config, kind) -> dict[str, str]` - resizable disks -> size string.
VM: `scsiN|virtioN|sataN|ideN` with a `size=`, excluding `media=cdrom` and
cloud-init. CT: `rootfs`, `mpN`.
- `ACTIONS = ("start", "shutdown", "stop", "reboot")`;
`action(name, guest)` -> `do <name> <kind> <node> <vmid> -y --wait`;
unknown name -> `ValueError`.
- `set_resources(guest, cores=None, memory_mb=None)` ->
`set <kind> config <node> <vmid> [--cores N] [--memory M] --wait`; values must be
positive ints; nothing to set -> `ValueError`.
- `resize_disk(guest, disk, grow_gb)` -> `do resize <kind> disk <node> <vmid> <disk>
+<grow_gb>G --wait`; `grow_gb` must be a positive int (growth only - Proxmox cannot
shrink, and an increment cannot shrink by construction).
- `pending_keys(guest) -> list[str]` - keys with a non-null `pending`.
### Module `cv4pve_tui/app.py`
- `DataTable` of guests: VMID, name, type, node, status, cores, memory (MiB),
disk (GiB). Cursor row = selected guest.
- Keys: `s` start, `S` shutdown, `x` stop (hard), `r` reboot, `e` edit,
`g` refresh, `q` quit.
- `shutdown`, `stop`, `reboot` open a confirm modal naming the guest; `stop` says it
is a hard power-off. `start` needs no confirmation.
- `e` opens an edit modal: cores, memory MiB, disk select + "grow by GiB".
Empty field = unchanged. Disk growth gets its own confirm (irreversible).
- After `set_resources`, if `pending_keys` contains `cores` or `memory`, notify that
the change applies at next reboot.
- All CLI calls in thread workers; results/errors via `notify` (errors show
`CliError.summary`); table refreshes after each action.
- A "working" notice is shown when an action starts (`--wait` can block minutes),
and a second action on a guest that is already busy is refused.
- Action keys on an empty table do nothing (no crash).
### UI contract (shared by tests and implementation)
- `app.py` does `from cv4pve_tui import cli` and always calls `cli.<function>(...)`,
so tests monkeypatch `cv4pve_tui.cli.<function>`.
- `class GuestApp(App)`; `def main()` runs it.
- Guest table: `DataTable` with id `guests`, one row per guest, row key = str(vmid),
in `list_guests()` order. Loaded on mount.
- `ConfirmScreen(ModalScreen[bool])`: buttons `#yes` / `#no`; its text names the guest
(name and vmid); for `stop` it contains the words "hard power-off".
- `EditScreen(ModalScreen)`: `Input#cores`, `Input#memory`, `Select#disk`,
`Input#grow`, buttons `#apply` / `#cancel`. Inputs are pre-filled with nothing
(empty = unchanged); placeholders show current values. Disk options come from
`cli.disks(cli.guest_config(guest), cli.kind(guest))`.
On apply: if cores/memory given -> `cli.set_resources(guest, cores=..., memory_mb=...)`
(only the given ones, as ints); if grow given -> `ConfirmScreen` first, then
`cli.resize_disk(guest, disk, grow_gb)`. Non-integer or <= 0 input -> `notify`
with severity "error" and no CLI call.
- Thread workers (`@work(thread=True)` or `run_worker(..., thread=True)`); tests wait
with `await app.workers.wait_for_complete()` then `await pilot.pause()`.
## Out of scope
Create/delete, snapshots, migration, console, backup, in-app context switching,
sockets/vCPU/hotplug settings, live auto-refresh.
## Steps
- [x] Project skeleton (`uv`, `pyproject.toml`, entry point `cv4pve-tui`) -
`uv run python -c "import cv4pve_tui"` succeeds.
- [x] Tests for `cli.py` with `subprocess.run` mocked - fail before implementation.
- [x] `cli.py` - tests pass.
- [x] Tests for the app with Textual `run_test` and `cli` functions mocked.
- [x] `app.py` - tests pass.
- [x] Read-only smoke test against the real cluster (listing, edit dialog disk options,
pending check).
- [x] Live edit (cores/memory), shutdown, start, reboot on CT 104 (mainframe), restored
to baseline afterwards. Disk resize not run live (irreversible).
## Open questions
None blocking.
---
# Context switching
## Problem
Several cv4pve-cli contexts (clusters) exist; switch between them inside the TUI
instead of quitting and running `cv4pve-cli config use`.
## Decision
Option A: switching runs `cv4pve-cli config use <name>`, which changes the **global**
current context (all shells and scripts follow) and stays after the app exits. cv4pve-cli
2.4.0 has no per-call context flag or env var (verified), so there is no app-local option.
Observed CLI behaviour:
- `config list` has no `-o json`. Text output, one line per context; current one
prefixed with `* `, others with two spaces:
`* mainframe mainframe.iot.home.arpa:8006 (api-token)`
` mainframe-edge mainframe-edge.home.arpa:8006 (api-token)`
- `config use <name>` -> exit 0 on success; unknown name -> exit 3,
`Error: Context '<name>' not found.`, current context unchanged.
### `cli.py`
- `contexts() -> list[dict]` - `run("config", "list")`, each line parsed to
`{"name", "host", "auth", "current": bool}` (regex
`^(\*)?\s*(\S+)\s+(\S+)\s+\((.*)\)\s*$`); non-matching lines are ignored.
- `use_context(name)` - `run("config", "use", name)`.
### `app.py` (UI contract)
- The active context is always visible: `app.sub_title == f"context: {name}"`
(shown in the Header). Set on mount from `cli.contexts()` and after every switch.
If `contexts()` fails: error notify, sub_title `context: ?`.
- Key `c` opens `ContextScreen(ModalScreen[str | None])` with `OptionList#contexts`,
one option per context, option id = context name, current one marked with `*`.
Selecting an option dismisses with its name; `escape` dismisses with `None`.
- `c` while any guest is busy -> warning notify, no screen (VMIDs collide across
clusters, so the busy guard would point at the wrong cluster).
- Selecting the current context or dismissing -> no CLI call.
- A reload started before a switch must not refill the table (generation counter);
`g` and `c` are refused while a switch runs; the post-action reload finishes before
the guest stops being busy.
- Selecting another context: table cleared immediately (old cluster's guests must not
stay actionable), then in a worker `cli.use_context(name)`, sub_title updated, guests
reloaded. On `CliError`: error notify with summary, sub_title re-read from
`cli.contexts()`, guests reloaded.
## Out of scope
Adding/editing/deleting contexts, `config verify`, restoring the original context on exit.
## Steps
- [x] Tests for `contexts`/`use_context` and the UI contract above - red first.
- [x] Implementation - full suite green, ruff clean.
- [x] Live: open the list, switch to `mainframe-edge` and back to `mainframe`
(only `config use` and read-only listing; no guest actions); final context
is `mainframe`.
- [ ] Push `feat/guest-tui-mvp`, merge to `main` (user-approved).
---
# LXC shell
## Problem
Open an interactive shell in a running LXC container from the TUI. cv4pve-cli 2.4.0 has
no console/shell/exec for LXC (verified: no command; the Proxmox API has no LXC exec),
so the app goes around it: SSH to the PVE node as root and run `pct enter <vmid>`.
## Decision
Option 1 from the discussion: `ssh -t root@<node> pct enter <vmid>`. The node name is
the SSH host (resolves via DNS here: `mainframe`, `mainframe-edge`); the user's key is in
root's `authorized_keys` (verified: `ssh -o BatchMode=yes root@mainframe pct status 104`
-> `status: running`). This bypasses cv4pve-cli and its API token.
## `app.py` (UI contract)
- Key `t` ("Shell") on the selected guest.
- Only for `type == "lxc"` and `status == "running"`; otherwise a warning notify that
says why (not a container / not running) and nothing runs. Empty table: nothing.
- Busy guest (action running) -> warning notify, nothing runs.
- Runs on the main thread inside `with self.suspend():` -
`subprocess.run(["ssh", "-t", f"root@{node}", "pct", "enter", str(vmid)])`
(no capture, no timeout: the user's terminal is handed over; `import subprocess` in
app.py so tests patch `cv4pve_tui.app.subprocess.run`).
- Exit 255 (ssh itself failed) -> error notify naming the guest and the code. Other
exit codes are the container shell's last status (`exit` after a failed command
gives 1) and are not errors.
- Inside `suspend()`, right before ssh, the terminal gets a full reset (`ESC c`, what
`reset` sends) so the container shell starts on a clean screen without leftover TUI
terminal modes (user request). Done locally, not in the container: no dependency on
`reset` existing there, and `pct enter` stays unchanged.
- `OSError` from `subprocess.run` is caught *inside* the `suspend()` block: Textual
8.2.8's `suspend()` has no try/finally, so an exception escaping it leaves the app
suspended.
- The guest table reloads after the shell returns (the user may have stopped or
rebooted the container from inside).
- `textual.app.SuspendNotSupported` (headless / unsupported driver) and `OSError`
(no `ssh` binary) -> error notify, app keeps running.
- Tests: `App.suspend` raises `SuspendNotSupported` under `run_test`, so tests replace
`app.suspend` with a no-op context manager (e.g. `contextlib.nullcontext`) unless
testing that error path.
## Out of scope
VM consoles (serial/VNC), configurable SSH user/host/options, termproxy/websocket.
## Steps
- [x] Tests for the UI contract above - red first.
- [x] Implementation - full suite green, ruff clean. README key table gets `t`.
- [x] Interactive test by the user: `t` on CT 104, `exit`, back in the table; screen
clean after the reset.
- [ ] Push `feat/lxc-shell`, merge to `main` (user-approved).
---
# Guest info screen: backups, backup now, network (read-only) - delivery A
## Problem
Per guest, see its latest backups and network setup, and start a backup now. Network
*editing* is delivery B (separate, live-tested), not part of this.
## Decisions (user-approved)
- Backup now uses the scheduled job's settings: `--mode snapshot --compress zstd`.
Storage is chosen from the node's backup-capable storages, preselected to the job's
storage (`truenas-backup` here), `local` as alternative.
- Manual backups get `--notes-template "{{guestname}} (manual)"` so they are
recognisable. Note for the user: `truenas-backup` has storage prune `keep-all=1`, but
the job has `keep-last 3` per guest; a manual backup there likely counts towards it at
the job's next run (not verified).
Observed CLI behaviour (cv4pve-cli 2.4.0, all read-only probes):
- `get node storages <node> -o json` -> list of dicts: `storage`, `content`
(comma list, e.g. `iso,snippets,import,backup,vztmpl`), `enabled`, `active`, `type`,
`avail` (bytes).
- `get cluster backup-jobs -o json` -> list of dicts: `id`, `enabled`, `storage`,
`schedule` (e.g. `mon,wed,fri 04:00`), `mode`, `compress`, `all` (1 = all guests),
`vmid` (comma list or null), `exclude` (comma list or null), `node` (null = all),
`next-run` (epoch), `prune-backups` (e.g. `{"keep-last":"3"}`).
- `get guest backups <node> <storage> <vmid> -o json` -> list of dicts: `volid`
(`truenas-backup:backup/vzdump-lxc-104-2026_11_02-04_11_47.tar.zst`), `ctime` (epoch),
`size` (bytes), `format`, `notes`, `protected`, `subtype` (`lxc`|`qemu`), `vmid`.
- `do backup guest <node> <vmid> <storage> [--key value]` - no `--yes` option.
vzdump params: `mode` snapshot|suspend|stop, `compress` 0|1|gzip|lzo|zstd,
`notes-template`.
- CT net config: `netN` = `name=eth0,bridge=vmbr0,hwaddr=BC:24:11:36:DE:78,ip=dhcp,ip6=auto,type=veth`
(optional `gw`, `gw6`, `tag`, `firewall`, `rate`, `mtu`, ...).
- VM net config: `netN` = `virtio=02:24:A6:49:A8:25,bridge=vmbr0[,tag=..][,firewall=1]`
(first item is `<model>=<MAC>`); IP settings live in `ipconfigN` (cloud-init), e.g.
`ip=dhcp`.
- Runtime IPs, CT (running): `get ct interfaces <node> <vmid> -o json` -> list of
dicts: `name`, `hwaddr`, `inet` (e.g. `172.16.41.200/23`), `inet6` (may be absent/
null), `ip-addresses` (JSON string).
- Runtime IPs, VM (running, guest agent): `get vm agent network <node> <vmid> -o json`
-> `[{"key": "result", "value": "<JSON string>"}]`; the JSON is a list of
`{"name", "hardware-address", "ip-addresses": [{"ip-address", "prefix",
"ip-address-type": "ipv4"|"ipv6"}]}`. Fails when the agent is not running.
### `cli.py`
- `backup_storages(guest) -> list[str]` - storages on the guest's node whose `content`
contains `backup` and that are enabled and active, in API order.
- `backup_jobs() -> list[dict]`.
- `job_storage(jobs, guest) -> str | None` - storage of the first enabled job covering
the guest: (`all` truthy and vmid not in `exclude`) or vmid in `vmid`; and `node` null
or equal to the guest's node.
- `backup_jobs()` parses `prune-backups` when the CLI returns it as a JSON string
(real output: `'{"keep-last":"3"}'`).
- `backups(guest, storages) -> list[dict]` - all backups of the guest on those
storages, newest first (`ctime` desc). Each dict gets `storage` (from volid prefix).
- `backup_now(guest, storage)` -> `do backup guest <node> <vmid> <storage> --mode
snapshot --compress zstd --notes-template "{{guestname}} (manual)" --wait`.
- `parse_net(value: str) -> dict` - `netN` string to an ordered dict of key -> value
(insertion order kept, so B can re-serialise unchanged keys). VM: the first
`<model>=<MAC>` item is kept as is (key = model).
- `nets(config) -> dict[str, dict]` - `netN` keys (sorted by N) -> `parse_net(value)`.
- `interfaces(guest) -> list[dict]` - runtime interfaces as
`{"name", "mac", "ipv4": [cidr...], "ipv6": [cidr...]}`, loopback (`lo`) excluded.
CT via `get ct interfaces` (addresses from its `ip-addresses` JSON string - `inet6`
alone holds only one, e.g. link-local, and misses the global SLAAC address), VM via
`get vm agent network`. Only called when the guest's live status is running.
### `app.py` (UI contract)
- Key `i` on a guest opens `InfoScreen(guest)` (a `Screen` or `ModalScreen`). Empty
table: nothing.
- InfoScreen loads in a thread worker: config -> `cli.nets`, `cli.interfaces` (only if
status is `running`; a failure there is shown inline as "unavailable: <summary>", not
as a fatal error), `cli.backup_storages`, `cli.backup_jobs`, `cli.backups`.
- Shows:
- `DataTable#backups`: Date (local time, `YYYY-MM-DD HH:MM`), Storage, Size (GiB, one
decimal), Format, Notes. Newest first. A `Static#jobs` line naming the job(s) that
cover the guest (schedule, storage, `keep-last` if present) or "No backup job
covers this guest".
- `DataTable#nets`: Iface (net0...), Bridge, VLAN (`tag` or `-`), MAC, IPv4 (CT `ip`,
VM `ipconfigN` `ip`, or `-`), IPv6 (CT `ip6`, VM `ipconfigN` `ip6`, or `-`),
Firewall (`yes`/`no`).
- `DataTable#addresses`: Interface, MAC, IPv4, IPv6 (runtime), or the unavailable
line.
- Key `b` in InfoScreen: `BackupScreen` modal with `Select#storage` (options =
backup storages, preselected `job_storage` or the first), text saying mode snapshot,
zstd, and the guest; buttons `#yes` / `#no`; escape = no. On yes: guest marked busy
in `app.busy` (refuse with a warning if already busy), "backup started" notify, worker
runs `cli.backup_now`, then "backup done"/error notify, busy released, backup list
reloaded if the InfoScreen is still open.
- `escape` closes InfoScreen; `g` in InfoScreen reloads it.
- Every CLI failure -> error notify with `_message(exc)`, app keeps running.
## Steps
- [x] Tests (cli + UI contract) - red first.
- [x] Implementation - suite green, ruff clean, README keys `i` / `b`.
- [x] Read-only live check: open InfoScreen for CT 104 and VM 108, data shown.
- [ ] User tests "Backup now" on CT 104.
- [ ] Push `feat/guest-info`, merge to `main` (user-approved after the test).