Add LXC shell via ssh and pct enter

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>
This commit is contained in:
DuckbootandClaude Opus 5.5 committed 2026-11-02 20:35:22 +01:00
1 parent 62b79ab29e
commit 40feb125b4
4 files changed
+272 -1

No files matched your search

+37 -1
View File
@@ -1,7 +1,9 @@
"""Textual app for managing Proxmox guests through cv4pve-cli."""
import subprocess
from textual import work
from textual.app import App, ComposeResult
from textual.app import App, ComposeResult, SuspendNotSupported
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import (
@@ -132,6 +134,7 @@ class GuestApp(App):
("x", "act('stop')", "Stop"),
("r", "act('reboot')", "Reboot"),
("e", "edit", "Edit"),
("t", "shell", "Shell"),
("g", "reload", "Refresh"),
("c", "context", "Context"),
("q", "quit", "Quit"),
@@ -336,6 +339,39 @@ class GuestApp(App):
return
self._open_edit(guest)
def action_shell(self) -> None:
guest = self._selected()
if guest is None:
return
who = f"{guest['name']} ({guest['vmid']})"
if guest["type"] != "lxc":
self.notify(f"{who} is not a container", severity="warning")
return
if guest["status"] != "running":
self.notify(f"{who} is not running", severity="warning")
return
if guest["vmid"] in self.busy:
self.notify(f"{who} is busy", severity="warning")
return
cmd = ["ssh", "-t", f"root@{guest['node']}", "pct", "enter", str(guest["vmid"])]
error = None
try:
with self.suspend():
try: # inside: Textual's suspend() skips the resume if its body raises
code = subprocess.run(cmd, check=False).returncode
except OSError as exc:
error = _message(exc)
except SuspendNotSupported as exc:
error = _message(exc)
else:
if (
error is None and code == 255
): # other codes are the shell's own last status
error = "ssh failed (exit 255)"
if error:
self.notify(f"Shell in {who} failed: {error}", severity="error")
self.action_reload()
@work(thread=True)
def _open_edit(self, guest: dict) -> None:
try: