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:
1 parent
6f009c6782
commit
f51aff3db5
10 files changed
+1612
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Textual TUI for cv4pve-cli."""
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user