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>
This commit is contained in:
DuckbootandClaude Opus 5.5 committed 2026-11-02 20:12:05 +01:00
1 parent 6f009c6782
commit f51aff3db5
10 files changed
+1612

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Textual TUI for cv4pve-cli."""
+278
View File
@@ -0,0 +1,278 @@
"""Textual app for managing Proxmox guests through cv4pve-cli."""
from textual import work
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, DataTable, Footer, Header, Input, Select, Static
from cv4pve_tui import cli
def _message(exc: Exception) -> str:
return (
exc.summary if isinstance(exc, cli.CliError) else f"{type(exc).__name__}: {exc}"
)
class ConfirmScreen(ModalScreen[bool]):
BINDINGS = [("escape", "dismiss(False)", "No")] # noqa: RUF012 - Textual idiom
def __init__(self, message: str) -> None:
super().__init__()
self.message = message
def compose(self) -> ComposeResult:
with Vertical():
yield Static(self.message)
with Horizontal():
yield Button("Yes", id="yes", variant="error")
yield Button("No", id="no")
def on_button_pressed(self, event: Button.Pressed) -> None:
self.dismiss(event.button.id == "yes")
class EditScreen(ModalScreen):
"""Dismisses with a dict of validated values, or None when cancelled."""
BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom
def __init__(self, guest: dict, config: dict, disks: dict[str, str]) -> None:
super().__init__()
self.guest, self.config, self.disks = guest, config, disks
def compose(self) -> ComposeResult:
with Vertical():
yield Static(
f"Edit {self.guest['name']} ({self.guest['vmid']}); empty = unchanged"
)
yield Input(
placeholder=f"cores (now {self.config.get('cores', '?')})", id="cores"
)
yield Input(
placeholder=f"memory MiB (now {self.config.get('memory', '?')})",
id="memory",
)
yield Select(
[(f"{k} ({v})", k) for k, v in self.disks.items()],
prompt="disk",
id="disk",
)
yield Input(placeholder="grow by GiB", id="grow")
with Horizontal():
yield Button("Apply", id="apply", variant="primary")
yield Button("Cancel", id="cancel")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "cancel":
self.dismiss(None)
return
try:
values = {name: self._number(name) for name in ("cores", "memory", "grow")}
except ValueError as exc:
self.app.notify(str(exc), severity="error")
return
disk = self.query_one("#disk", Select).value
if values["grow"] is not None and disk is Select.NULL:
self.app.notify("Choose a disk to grow", severity="error")
return
values["disk"] = None if disk is Select.NULL else disk
self.dismiss(values)
def _number(self, name: str) -> int | None:
text = self.query_one(f"#{name}", Input).value.strip()
if not text:
return None
if not text.isdecimal() or int(text) <= 0:
raise ValueError(f"{name} must be a positive integer")
return int(text)
class GuestApp(App):
BINDINGS = [ # noqa: RUF012 - Textual idiom
("s", "act('start')", "Start"),
("S", "act('shutdown')", "Shutdown"),
("x", "act('stop')", "Stop"),
("r", "act('reboot')", "Reboot"),
("e", "edit", "Edit"),
("g", "reload", "Refresh"),
("q", "quit", "Quit"),
]
def __init__(self) -> None:
super().__init__()
self.guests: dict[str, dict] = {}
self.busy: set[int] = set()
def compose(self) -> ComposeResult:
yield Header()
yield DataTable(id="guests", cursor_type="row")
yield Footer()
def on_mount(self) -> None:
table = self.query_one("#guests", DataTable)
table.add_columns(
"VMID",
"Name",
"Type",
"Node",
"Status",
"Cores",
"Memory (MiB)",
"Disk (GiB)",
)
self.action_reload()
def _selected(self) -> dict | None:
table = self.query_one("#guests", DataTable)
if not table.row_count:
return None
return self.guests[
table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
]
def _fill(self, guests: list[dict]) -> None:
table = self.query_one("#guests", DataTable)
current = self._selected()
table.clear()
self.guests = {str(g["vmid"]): g for g in guests}
for g in guests:
table.add_row(
g["vmid"], g["name"], g["type"], g["node"], g["status"],
g.get("maxcpu", "-"), _scaled(g.get("maxmem"), 2**20), _scaled(g.get("maxdisk"), 2**30),
key=str(g["vmid"]),
) # fmt: skip
if current and str(current["vmid"]) in self.guests:
table.move_cursor(row=table.get_row_index(str(current["vmid"])))
def _reload(self, acted: dict | None = None) -> None:
"""Runs in a worker thread. `acted` gets its live status: the listing lags."""
try:
guests = cli.list_guests()
for g in guests:
if acted and g["vmid"] == acted["vmid"]:
g["status"] = cli.status(g)
except Exception as exc: # noqa: BLE001 - any failure must reach the user, not kill the app
self.call_from_thread(self.notify, _message(exc), severity="error")
return
self.call_from_thread(self._fill, guests)
@work(thread=True)
def action_reload(self) -> None:
self._reload()
def _start_job(self, guest: dict, label: str, job) -> None:
if guest["vmid"] in self.busy:
self.notify(
f"{guest['name']} ({guest['vmid']}) is busy", severity="warning"
)
return
self.busy.add(guest["vmid"])
self.notify(f"{label} {guest['name']} ({guest['vmid']}) ...")
self._run_job(guest, label, job)
@work(thread=True)
def _run_job(self, guest: dict, label: str, job) -> None:
try:
job()
except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app
self.call_from_thread(
self.notify, f"{label} failed: {_message(exc)}", severity="error"
)
else:
self.call_from_thread(
self.notify, f"{label} done: {guest['name']} ({guest['vmid']})"
)
finally:
self.busy.discard(guest["vmid"])
self._reload(acted=guest)
def action_act(self, name: str) -> None:
guest = self._selected()
if guest is None:
return
run = lambda: cli.action(name, guest)
if name == "start":
self._start_job(guest, name, run)
return
who = f"{guest['name']} ({guest['vmid']})"
text = f"{name.capitalize()} {who}?"
if name == "stop":
text = f"Hard power-off {who}? The guest is killed without shutting down."
self.push_screen(
ConfirmScreen(text),
lambda ok: self._start_job(guest, name, run) if ok else None,
)
def action_edit(self) -> None:
guest = self._selected()
if guest is None:
return
if guest["vmid"] in self.busy:
self.notify(
f"{guest['name']} ({guest['vmid']}) is busy", severity="warning"
)
return
self._open_edit(guest)
@work(thread=True)
def _open_edit(self, guest: dict) -> None:
try:
config = cli.guest_config(guest)
disks = cli.disks(config, cli.kind(guest))
except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app
self.call_from_thread(self.notify, _message(exc), severity="error")
return
self.call_from_thread(
self.push_screen,
EditScreen(guest, config, disks),
lambda v: self._edit_done(guest, v),
)
def _edit_done(self, guest: dict, values: dict | None) -> None:
if not values or not any(values[k] for k in ("cores", "memory", "grow")):
return
if values["grow"]:
who = f"{guest['name']} ({guest['vmid']})"
self.push_screen(
ConfirmScreen(
f"Grow {values['disk']} of {who} by {values['grow']} GiB? This cannot be undone."
),
lambda ok: (
self._apply(guest, values) if ok else None
), # "No" cancels the whole edit
)
else:
self._apply(guest, values)
def _apply(self, guest: dict, values: dict) -> None:
cores, memory, grow = values["cores"], values["memory"], values["grow"]
def job() -> None:
if cores or memory:
cli.set_resources(guest, cores=cores, memory_mb=memory)
if grow:
cli.resize_disk(guest, values["disk"], grow)
if cores or memory:
try:
pending = cli.pending_keys(guest)
except Exception as exc: # noqa: BLE001 - only a notice; the change itself succeeded
self.call_from_thread(
self.notify,
f"pending check failed: {_message(exc)}",
severity="warning",
)
return
if {"cores", "memory"} & set(pending):
self.call_from_thread(self.notify, "Change applies at next reboot")
self._start_job(guest, "edit", job)
def _scaled(value, unit: int):
return "-" if value is None else round(value / unit)
def main() -> None:
GuestApp().run()
+140
View File
@@ -0,0 +1,140 @@
"""Thin subprocess wrapper around cv4pve-cli."""
import json
import re
import subprocess
class CliError(Exception):
"""cv4pve-cli failed; str() is its combined, stripped output."""
@property
def summary(self) -> str:
"""One line for the UI: first non-UPID line, plus its indented detail line."""
lines = str(self).splitlines()
for i, line in enumerate(lines):
text = line.strip()
if not text or text.startswith("UPID:"):
continue
text = text.removeprefix("Error:").strip()
if text.endswith(":") and i + 1 < len(lines) and lines[i + 1][:1].isspace():
text += " " + lines[i + 1].strip()
return text
return str(self)
def run(*args: str) -> str:
try:
proc = subprocess.run(
["cv4pve-cli", *args],
check=False,
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
timeout=330,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise CliError(str(exc)) from exc
if proc.returncode != 0:
raise CliError(
"\n".join(s.strip() for s in (proc.stdout, proc.stderr) if s.strip())
)
return proc.stdout
def kind(guest: dict) -> str:
return "vm" if guest["type"] == "qemu" else "ct"
def _api_type(guest: dict) -> str:
return "qemu" if guest["type"] == "qemu" else "lxc"
def list_guests() -> list[dict]:
guests = json.loads(run("get", "guests", "-o", "json"))
for g in guests:
g["name"] = (
g.get("name") or f"VM {g['vmid']}"
) # unnamed guests have no/null name
return sorted(guests, key=lambda g: g["vmid"])
def guest_config(guest: dict) -> dict:
out = run("show", kind(guest), guest["node"], str(guest["vmid"]), "-o", "json")
return {item["key"]: item["value"] for item in json.loads(out)}
_VM_DISK = re.compile(r"(scsi|virtio|sata|ide)\d+")
_CT_DISK = re.compile(r"rootfs|mp\d+")
def disks(config: dict, kind: str) -> dict[str, str]:
pattern = _VM_DISK if kind == "vm" else _CT_DISK
result = {}
for key, value in config.items():
if not pattern.fullmatch(key):
continue
value = str(value)
size = re.search(r"size=(\w+)", value)
if size and "media=cdrom" not in value and "cloudinit" not in value:
result[key] = size.group(1)
return result
ACTIONS = ("start", "shutdown", "stop", "reboot")
def action(name: str, guest: dict) -> None:
if name not in ACTIONS:
raise ValueError(f"unknown action: {name}")
run("do", name, kind(guest), guest["node"], str(guest["vmid"]), "-y", "--wait")
def _positive(value, what: str) -> int:
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
raise ValueError(f"{what} must be a positive integer")
return value
def set_resources(
guest: dict, cores: int | None = None, memory_mb: int | None = None
) -> None:
opts = []
if cores is not None:
opts += ["--cores", str(_positive(cores, "cores"))]
if memory_mb is not None:
opts += ["--memory", str(_positive(memory_mb, "memory"))]
if not opts:
raise ValueError("nothing to set")
run(
"set", kind(guest), "config", guest["node"], str(guest["vmid"]), *opts, "--wait"
)
def resize_disk(guest: dict, disk: str, grow_gb: int) -> None:
_positive(grow_gb, "grow")
run(
"do",
"resize",
kind(guest),
"disk",
guest["node"],
str(guest["vmid"]),
disk,
f"+{grow_gb}G",
"--wait",
)
def pending_keys(guest: dict) -> list[str]:
path = f"/nodes/{guest['node']}/{_api_type(guest)}/{guest['vmid']}/pending"
items = json.loads(run("api", "get", path, "-o", "json"))
return [i["key"] for i in items if i.get("pending") is not None]
def status(guest: dict) -> str:
"""Live status; get guests (/cluster/resources) lags a few seconds behind actions."""
path = f"/nodes/{guest['node']}/{_api_type(guest)}/{guest['vmid']}/status/current"
return {
i["key"]: i["value"] for i in json.loads(run("api", "get", path, "-o", "json"))
}["status"]