Press t on a running container to suspend the TUI and run `ssh -t root@<node> pct enter <vmid>`. cv4pve-cli has no shell or exec for LXC, so this goes around it and needs key-based root SSH to the PVE node. - VMs, stopped containers and busy guests get a warning instead. - Only exit code 255 (ssh failed) is reported as an error. Other codes are the last status of the container's shell. - OSError is caught inside suspend(). Textual's suspend() has no try/finally, so an exception escaping it would leave the app suspended. - The guest table reloads when the shell returns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 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 withvmid,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"};pendingnon-null means not yet applied.- Async actions print a UPID and exit 0 even when the task fails. With
--waitthe CLI waits for the task and exits non-zero (5) with the error text on failure. Every mutating call therefore uses--wait. do stoprefuses to run without-y(verified);-yis 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 fromapi 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;.summaryis 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"- fromtype(qemu->vm,lxc->ct).list_guests() -> list[dict]- sorted byvmid.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|ideNwith asize=, excludingmedia=cdromand 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_gbmust 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-nullpending.
Module cv4pve_tui/app.py
DataTableof guests: VMID, name, type, node, status, cores, memory (MiB), disk (GiB). Cursor row = selected guest.- Keys:
sstart,Sshutdown,xstop (hard),rreboot,eedit,grefresh,qquit. shutdown,stop,rebootopen a confirm modal naming the guest;stopsays it is a hard power-off.startneeds no confirmation.eopens an edit modal: cores, memory MiB, disk select + "grow by GiB". Empty field = unchanged. Disk growth gets its own confirm (irreversible).- After
set_resources, ifpending_keyscontainscoresormemory, notify that the change applies at next reboot. - All CLI calls in thread workers; results/errors via
notify(errors showCliError.summary); table refreshes after each action. - A "working" notice is shown when an action starts (
--waitcan 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.pydoesfrom cv4pve_tui import cliand always callscli.<function>(...), so tests monkeypatchcv4pve_tui.cli.<function>.class GuestApp(App);def main()runs it.- Guest table:
DataTablewith idguests, one row per guest, row key = str(vmid), inlist_guests()order. Loaded on mount. ConfirmScreen(ModalScreen[bool]): buttons#yes/#no; its text names the guest (name and vmid); forstopit 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 fromcli.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 ->ConfirmScreenfirst, thencli.resize_disk(guest, disk, grow_gb). Non-integer or <= 0 input ->notifywith severity "error" and no CLI call.- Thread workers (
@work(thread=True)orrun_worker(..., thread=True)); tests wait withawait app.workers.wait_for_complete()thenawait 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 pointcv4pve-tui) -uv run python -c "import cv4pve_tui"succeeds. - Tests for
cli.pywithsubprocess.runmocked - fail before implementation. cli.py- tests pass.- Tests for the app with Textual
run_testandclifunctions 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 listhas 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 fromcli.contexts()and after every switch. Ifcontexts()fails: error notify, sub_titlecontext: ?. - Key
copensContextScreen(ModalScreen[str | None])withOptionList#contexts, one option per context, option id = context name, current one marked with*. Selecting an option dismisses with its name;escapedismisses withNone. cwhile 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);
gandcare 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. OnCliError: error notify with summary, sub_title re-read fromcli.contexts(), guests reloaded.
Out of scope
Adding/editing/deleting contexts, config verify, restoring the original context on exit.
Steps
- Tests for
contexts/use_contextand the UI contract above - red first. - Implementation - full suite green, ruff clean.
- Live: open the list, switch to
mainframe-edgeand back tomainframe(onlyconfig useand read-only listing; no guest actions); final context ismainframe. - Push
feat/guest-tui-mvp, merge tomain(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"andstatus == "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 subprocessin app.py so tests patchcv4pve_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 (
exitafter a failed command gives 1) and are not errors. OSErrorfromsubprocess.runis caught inside thesuspend()block: Textual 8.2.8'ssuspend()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) andOSError(nosshbinary) -> error notify, app keeps running.- Tests:
App.suspendraisesSuspendNotSupportedunderrun_test, so tests replaceapp.suspendwith 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:
ton CT 104,exit, back in the table. - Push
feat/lxc-shell, merge tomain(user-approved).