Interactive shell into a compose-managed container over WebSocket + xterm.js,
opened from the container card on the stack Overview tab. Admin-only (non-admin
handshake rejected with 4403); only containers with the compose project label
are reachable.
- backend services/exec_service.py: create/start/resize exec + a shared
bidirectional pump_exec (recv/sendall on sock._sock, executor thread,
resize control frames, exit-code frame).
- routers/ws.py: _authorize_admin + /ws/exec/{container_id} and the
/ws/agent-exec/{agent_id}/{container_id} proxy (forwards BOTH directions).
- agent_app.py: /agent/ws/exec/{container_id}.
- frontend: @xterm/xterm + @xterm/addon-fit; ContainerTerminal modal (shell
picker, fit/resize, exit/error handling) + a Terminal button on ContainerCard.
Live-verified (TestClient): local happy/exit/guard/4403/4401, agent happy/4401,
proxy bidirectional round-trip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
137 lines
4.8 KiB
Python
137 lines
4.8 KiB
Python
"""Interactive exec (web terminal) into compose-managed containers.
|
|
|
|
Reuses ``container_service._get_managed`` so a terminal can only be opened on a
|
|
container that belongs to a compose-managed stack — never an arbitrary host
|
|
container. The raw exec socket is bidirectional (stdin + a TTY-merged
|
|
stdout/stderr stream), suitable for piping straight to xterm.js.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
import socket as _socket
|
|
|
|
from starlette.websockets import WebSocketDisconnect
|
|
|
|
from docker_client import DockerError, get_client, safe_call
|
|
from services.container_service import _get_managed
|
|
|
|
DEFAULT_SHELL = "/bin/sh"
|
|
|
|
|
|
def create_exec(container_id: str, cmd: list[str] | None = None, tty: bool = True) -> str:
|
|
"""Create an exec instance on a managed container and return its id."""
|
|
container = _get_managed(container_id)
|
|
client = get_client()
|
|
created = safe_call(
|
|
client.api.exec_create,
|
|
container.id,
|
|
cmd or [DEFAULT_SHELL],
|
|
stdin=True,
|
|
tty=tty,
|
|
stdout=True,
|
|
stderr=True,
|
|
)
|
|
return created["Id"]
|
|
|
|
|
|
def start_exec(exec_id: str, tty: bool = True):
|
|
"""Start the exec and return ``(holder, raw_socket)``.
|
|
|
|
docker-py 7.x returns a ``socket.SocketIO`` wrapper whose real, recv/sendall
|
|
-capable fd lives at ``._sock``; older versions hand back the socket
|
|
directly. We keep both: ``holder`` is what we close, ``raw`` is what we
|
|
recv/sendall on.
|
|
"""
|
|
client = get_client()
|
|
holder = safe_call(client.api.exec_start, exec_id, socket=True, tty=tty, demux=False)
|
|
raw = getattr(holder, "_sock", None) or holder
|
|
return holder, raw
|
|
|
|
|
|
def resize_exec(exec_id: str, height: int, width: int) -> None:
|
|
"""Resize the exec's TTY (rows x cols) so the shell wraps correctly."""
|
|
client = get_client()
|
|
safe_call(client.api.exec_resize, exec_id, height=height, width=width)
|
|
|
|
|
|
def exec_exit_code(exec_id: str):
|
|
"""Return the exec's ExitCode once it has finished (None while running)."""
|
|
client = get_client()
|
|
info = safe_call(client.api.exec_inspect, exec_id)
|
|
return info.get("ExitCode")
|
|
|
|
|
|
async def pump_exec(websocket, exec_id: str, holder, raw) -> None:
|
|
"""Bidirectionally pump an exec socket <-> a WebSocket.
|
|
|
|
Shared by the central app and the agent (both pass a Starlette WebSocket).
|
|
Browser -> container: JSON ``{"type":"data","data":...}`` keystrokes and
|
|
``{"type":"resize","rows","cols"}`` control frames (raw text is also
|
|
accepted as keystrokes). Container -> browser: ``{"type":"data","data":...}``
|
|
then a final ``{"type":"exit","code":...}``.
|
|
|
|
The blocking ``recv`` runs in the default executor; on teardown we shut the
|
|
socket down so that orphaned recv thread unblocks and exits.
|
|
"""
|
|
raw.setblocking(True)
|
|
loop = asyncio.get_event_loop()
|
|
closed = asyncio.Event()
|
|
|
|
async def to_browser() -> None:
|
|
try:
|
|
while True:
|
|
data = await loop.run_in_executor(None, raw.recv, 4096)
|
|
if not data:
|
|
break
|
|
await websocket.send_text(
|
|
json.dumps({"type": "data", "data": data.decode("utf-8", "replace")})
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
finally:
|
|
closed.set()
|
|
|
|
async def from_browser() -> None:
|
|
try:
|
|
while True:
|
|
msg = await websocket.receive_text()
|
|
obj = None
|
|
try:
|
|
obj = json.loads(msg)
|
|
except (json.JSONDecodeError, TypeError):
|
|
obj = None
|
|
if isinstance(obj, dict) and obj.get("type") == "resize":
|
|
with contextlib.suppress(Exception):
|
|
resize_exec(exec_id, int(obj.get("rows", 24)), int(obj.get("cols", 80)))
|
|
elif isinstance(obj, dict) and "data" in obj:
|
|
await loop.run_in_executor(None, raw.sendall, str(obj["data"]).encode())
|
|
else:
|
|
await loop.run_in_executor(None, raw.sendall, msg.encode())
|
|
except WebSocketDisconnect:
|
|
pass
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
finally:
|
|
closed.set()
|
|
|
|
out_task = asyncio.create_task(to_browser())
|
|
in_task = asyncio.create_task(from_browser())
|
|
await closed.wait()
|
|
|
|
with contextlib.suppress(Exception):
|
|
raw.shutdown(_socket.SHUT_RDWR)
|
|
with contextlib.suppress(Exception):
|
|
holder.close()
|
|
for task in (out_task, in_task):
|
|
task.cancel()
|
|
with contextlib.suppress(Exception):
|
|
await asyncio.gather(out_task, in_task, return_exceptions=True)
|
|
|
|
code = None
|
|
with contextlib.suppress(Exception):
|
|
code = exec_exit_code(exec_id)
|
|
with contextlib.suppress(Exception):
|
|
await websocket.send_text(json.dumps({"type": "exit", "code": code}))
|