# 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 -o json` -> list of `{"key": ..., "value": ...}`. - `api get /nodes////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////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 -y --wait`; unknown name -> `ValueError`. - `set_resources(guest, cores=None, memory_mb=None)` -> `set config [--cores N] [--memory M] --wait`; values must be positive ints; nothing to set -> `ValueError`. - `resize_disk(guest, disk, grow_gb)` -> `do resize disk +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.(...)`, so tests monkeypatch `cv4pve_tui.cli.`. - `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 `, 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 ` -> exit 0 on success; unknown name -> exit 3, `Error: Context '' 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 `. ## Decision Option 1 from the discussion: `ssh -t root@ pct enter `. 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).