Files
cv4pve-cli-tui/docs/plans/guest-tui-mvp.md
T
DuckbootandClaude Opus 5.5 f51aff3db5 Add Textual TUI for managing Proxmox guests via cv4pve-cli
List VMs and LXC containers from the current cv4pve-cli context and
start, shutdown, stop and reboot them, change cores and memory, and
grow disks. The app shells out to `cv4pve-cli -o json` and stores no
credentials.

- Every mutating call uses --wait: without it cv4pve-cli exits 0 even
  when the task fails.
- shutdown, stop and reboot need confirmation; disk growth is
  increment-only and asks again because it cannot be undone.
- After an action the acted-on guest's status is read from
  status/current, because /cluster/resources lags a few seconds.
- Notifies when a cores/memory change is pending until reboot.

Tests mock cv4pve-cli (85 tests). Live-tested edit, shutdown, start
and reboot on CT 104 (mainframe), restored afterwards; disk resize
was not run live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:12:05 +01:00

119 lines
6.1 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.