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

+3
View File
@@ -20,10 +20,13 @@ uv run cv4pve-tui
| `x` | stop - hard power-off (confirm) | | `x` | stop - hard power-off (confirm) |
| `r` | reboot (confirm) | | `r` | reboot (confirm) |
| `e` | edit cores / memory / grow a disk (disk growth asks again; it cannot be undone) | | `e` | edit cores / memory / grow a disk (disk growth asks again; it cannot be undone) |
| `t` | shell in a running LXC container (`ssh -t root@<node> pct enter <vmid>`) |
| `g` | refresh | | `g` | refresh |
| `c` | switch cv4pve-cli context (cluster) | | `c` | switch cv4pve-cli context (cluster) |
| `q` | quit | | `q` | quit |
The shell key needs key-based root SSH to the PVE node (LXC only).
Switching context runs `cv4pve-cli config use`, which changes cv4pve-cli's global current Switching context runs `cv4pve-cli config use`, which changes cv4pve-cli's global current
context: other shells and scripts follow, and it stays after the app exits. context: other shells and scripts follow, and it stays after the app exits.
+52
View File
@@ -179,3 +179,55 @@ Adding/editing/deleting contexts, `config verify`, restoring the original contex
(only `config use` and read-only listing; no guest actions); final context (only `config use` and read-only listing; no guest actions); final context
is `mainframe`. is `mainframe`.
- [ ] Push `feat/guest-tui-mvp`, merge to `main` (user-approved). - [ ] 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 <vmid>`.
## Decision
Option 1 from the discussion: `ssh -t root@<node> pct enter <vmid>`. 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.
- `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`.
- [ ] Interactive test by the user: `t` on CT 104, `exit`, back in the table.
- [ ] Push `feat/lxc-shell`, merge to `main` (user-approved).
+37 -1
View File
@@ -1,7 +1,9 @@
"""Textual app for managing Proxmox guests through cv4pve-cli.""" """Textual app for managing Proxmox guests through cv4pve-cli."""
import subprocess
from textual import work 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.containers import Horizontal, Vertical
from textual.screen import ModalScreen from textual.screen import ModalScreen
from textual.widgets import ( from textual.widgets import (
@@ -132,6 +134,7 @@ class GuestApp(App):
("x", "act('stop')", "Stop"), ("x", "act('stop')", "Stop"),
("r", "act('reboot')", "Reboot"), ("r", "act('reboot')", "Reboot"),
("e", "edit", "Edit"), ("e", "edit", "Edit"),
("t", "shell", "Shell"),
("g", "reload", "Refresh"), ("g", "reload", "Refresh"),
("c", "context", "Context"), ("c", "context", "Context"),
("q", "quit", "Quit"), ("q", "quit", "Quit"),
@@ -336,6 +339,39 @@ class GuestApp(App):
return return
self._open_edit(guest) 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) @work(thread=True)
def _open_edit(self, guest: dict) -> None: def _open_edit(self, guest: dict) -> None:
try: try:
+180
View File
@@ -3,6 +3,7 @@
All cli functions are monkeypatched: cv4pve-cli is never executed. All cli functions are monkeypatched: cv4pve-cli is never executed.
""" """
import contextlib
import subprocess import subprocess
import threading import threading
@@ -727,3 +728,182 @@ async def test_context_refused_while_post_action_reload_runs(fake):
finally: finally:
release.set() release.set()
await settle(app, pilot) await settle(app, pilot)
# --- LXC shell --------------------------------------------------------------
# spec: docs/plans/guest-tui-mvp.md ("LXC shell" > "app.py (UI contract)")
RUNNING_CT = {"vmid": 104, "name": "debian", "node": "mainframe", "type": "lxc", "status": "running",
"maxcpu": 1, "maxmem": 512 * 2**20, "maxdisk": 8 * 2**30} # fmt: skip
SSH_ARGV = ["ssh", "-t", "root@mainframe", "pct", "enter", "104"]
def patch_shell(monkeypatch, app, rc=0, error=None):
"""Replace subprocess.run and app.suspend with recorders; returns the event log."""
events = []
def run(argv, **kwargs):
events.append(("run", list(argv)))
if error:
raise error
return subprocess.CompletedProcess(argv, rc)
@contextlib.contextmanager
def suspend():
events.append(("enter", None))
yield
events.append(("exit", None))
monkeypatch.setattr("cv4pve_tui.app.subprocess.run", run)
app.suspend = suspend
return events
def ssh_runs(events):
return [argv for kind, argv in events if kind == "run"]
async def press_t(app, pilot):
await settle(app, pilot)
await pilot.press("t")
await settle(app, pilot)
async def test_t_on_running_lxc_runs_ssh_pct_enter_inside_suspend(fake, monkeypatch):
# spec: runs the ssh argv once, on the main thread, inside `with self.suspend()`
fake.guests = [RUNNING_CT]
app = make_app()
events = patch_shell(monkeypatch, app)
async with app.run_test() as pilot:
await press_t(app, pilot)
assert events == [("enter", None), ("run", SSH_ARGV), ("exit", None)]
assert not errors(app)
assert app.is_running
async def test_shell_exit_nonzero_notifies_error_with_guest_and_code(fake, monkeypatch):
# spec: non-zero exit -> error notify naming the guest and the exit code
fake.guests = [RUNNING_CT]
app = make_app()
events = patch_shell(monkeypatch, app, rc=255)
async with app.run_test() as pilot:
await press_t(app, pilot)
assert ssh_runs(events) == [SSH_ARGV]
assert any(("debian" in m or "104" in m) and "255" in m for m in errors(app))
assert app.is_running
@pytest.mark.parametrize("guest,why", [(VM, "not a container"), (CT, "not running")])
async def test_t_on_ineligible_guest_warns_and_runs_nothing(
fake, monkeypatch, guest, why
):
# spec: only type lxc and status running; otherwise warning (says why), nothing runs
fake.guests = [guest]
app = make_app()
events = patch_shell(monkeypatch, app)
async with app.run_test() as pilot:
await press_t(app, pilot)
assert events == []
assert any(sev == "warning" for _, sev in app.seen)
assert not errors(app)
async def test_t_on_empty_table_does_nothing(fake, monkeypatch):
# spec: empty table -> nothing
fake.guests = []
app = make_app()
events = patch_shell(monkeypatch, app)
async with app.run_test() as pilot:
await press_t(app, pilot)
assert events == []
assert app.seen == []
assert app.is_running
async def test_t_on_busy_guest_warns_and_runs_nothing(fake, monkeypatch):
# spec: busy guest (action running) -> warning notify, nothing runs
started, release = threading.Event(), threading.Event()
fake.guests = [RUNNING_CT]
fake.action_hook = lambda: (started.set(), release.wait(10))
app = make_app()
events = patch_shell(monkeypatch, app)
async with app.run_test() as pilot:
await settle(app, pilot)
try:
await pilot.press("s")
await until(pilot, started.is_set)
warnings_before = sum(sev == "warning" for _, sev in app.seen)
await pilot.press("t")
await pilot.pause()
assert events == []
assert sum(sev == "warning" for _, sev in app.seen) > warnings_before
finally:
release.set()
await settle(app, pilot)
assert events == []
async def test_suspend_not_supported_notifies_error_and_app_keeps_running(
fake, monkeypatch
):
# spec: SuspendNotSupported (headless driver, suspend left unpatched) -> error notify
fake.guests = [RUNNING_CT]
app = make_app()
calls = []
monkeypatch.setattr(
"cv4pve_tui.app.subprocess.run", lambda argv, **kw: calls.append(argv)
)
async with app.run_test() as pilot:
await press_t(app, pilot)
assert calls == []
assert errors(app)
assert app.is_running
async def test_missing_ssh_binary_notifies_error_and_app_keeps_running(
fake, monkeypatch
):
# spec: OSError (no ssh binary) -> error notify, app keeps running
fake.guests = [RUNNING_CT]
app = make_app()
events = patch_shell(monkeypatch, app, error=FileNotFoundError("ssh"))
async with app.run_test() as pilot:
await press_t(app, pilot)
assert ssh_runs(events) == [SSH_ARGV]
assert errors(app)
assert app.is_running
# --- review findings: LXC shell ----------------------------------------------
async def test_missing_ssh_binary_still_resumes_app(fake, monkeypatch):
# Textual's suspend() has no try/finally: an exception inside it skips the resume
fake.guests = [RUNNING_CT]
app = make_app()
events = patch_shell(monkeypatch, app, error=FileNotFoundError("ssh"))
async with app.run_test() as pilot:
await press_t(app, pilot)
assert events[-1] == ("exit", None)
async def test_shell_exit_code_from_inside_container_is_not_an_error(fake, monkeypatch):
# pct enter returns the shell's last status; only 255 means ssh itself failed
fake.guests = [RUNNING_CT]
app = make_app()
patch_shell(monkeypatch, app, rc=1)
async with app.run_test() as pilot:
await press_t(app, pilot)
assert errors(app) == []
async def test_table_reloads_after_shell(fake, monkeypatch):
fake.guests = [RUNNING_CT]
app = make_app()
patch_shell(monkeypatch, app)
async with app.run_test() as pilot:
await settle(app, pilot)
before = fake.list_calls
await press_t(app, pilot)
await settle(app, pilot)
assert fake.list_calls > before