Open a shell in a running LXC container (ssh + pct enter) #2
No files matched your search
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user