Files
cv4pve-cli-tui/docs/plans/guest-tui-mvp.md
T
2026-11-02 20:43:13 +01:00

12 KiB

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

  • Project skeleton (uv, pyproject.toml, entry point cv4pve-tui) - uv run python -c "import cv4pve_tui" succeeds.
  • Tests for cli.py with subprocess.run mocked - fail before implementation.
  • cli.py - tests pass.
  • Tests for the app with Textual run_test and cli functions mocked.
  • app.py - tests pass.
  • Read-only smoke test against the real cluster (listing, edit dialog disk options, pending check).
  • 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

  • Tests for contexts/use_context and the UI contract above - red first.
  • Implementation - full suite green, ruff clean.
  • 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

  • Tests for the UI contract above - red first.
  • Implementation - full suite green, ruff clean. README key table gets t.
  • 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).