fester/backend/integrations/tmux.py

205 lines
7.3 KiB
Python

"""
Tmux integration — spawn long-running actions in detached tmux sessions.
Each action gets its own session `fester-<action_name>-<build_id>` so
operators can `tmux attach -t fester-build_kernel-abc123` to watch live
build output.
This is the "action runtime" alternative to running actions inline via
subprocess. Set `action["runtime"] = "tmux"` (or `build_env["runtime"]`)
to route through TmuxManager instead of run_host.
Requires: tmux on PATH.
"""
import re
import shutil
import subprocess
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
def _have_tmux() -> bool:
return shutil.which("tmux") is not None
def _safe_session_name(name: str) -> str:
"""tmux session names can't contain `.` or `:`."""
return re.sub(r"[^a-zA-Z0-9_-]", "_", name)[:200]
class TmuxManager:
"""Manage per-action tmux sessions."""
def __init__(self):
self._available = _have_tmux()
@property
def available(self) -> bool:
return self._available
def session_name(self, action: Dict[str, Any], build_id: Optional[str] = None) -> str:
"""Return the canonical session name for an action."""
name = action.get("name", "action")
if build_id:
return _safe_session_name(f"fester-{name}-{build_id}")
return _safe_session_name(f"fester-{name}")
def create_session(self, action: Dict[str, Any], cmd: str,
cwd: Optional[str] = None,
env: Optional[Dict[str, str]] = None,
build_id: Optional[str] = None) -> Optional[str]:
"""Create a detached tmux session running `cmd`.
Returns the session name on success, None on failure (or if
tmux isn't available)."""
if not self._available:
return None
name = self.session_name(action, build_id)
# If session already exists, kill it first (idempotent)
if self.session_exists(name):
self.kill_session(name)
# Build the command with cwd + env wrapping
# Use bash -lc so env vars expand correctly
env_prefix = ""
if env:
env_prefix = " ".join(f"{k}={shlex_quote(str(v))}" for k, v in env.items()) + " "
full_cmd = f"{env_prefix}{cmd}"
if cwd:
full_cmd = f"cd {shlex_quote(cwd)} && {full_cmd}"
# Run inside bash -lc so the command parses properly
argv = [
"tmux", "new-session", "-d",
"-s", name,
"-x", "200", "-y", "50", # window size
"bash", "-lc", full_cmd,
]
try:
result = subprocess.run(argv, capture_output=True, timeout=10)
if result.returncode == 0:
return name
# Session creation failed
return None
except (subprocess.TimeoutExpired, FileNotFoundError):
return None
def session_exists(self, name: str) -> bool:
if not self._available:
return False
try:
r = subprocess.run(
["tmux", "has-session", "-t", name],
capture_output=True, timeout=2,
)
return r.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError):
return False
def attach(self, name: str) -> bool:
"""Attach to a tmux session. Returns True on success.
Note: this blocks until the user detaches."""
if not self._available or not self.session_exists(name):
return False
try:
subprocess.run(["tmux", "attach", "-t", name])
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def kill_session(self, name: str) -> bool:
if not self._available:
return False
try:
subprocess.run(["tmux", "kill-session", "-t", name],
capture_output=True, timeout=2)
return True
except (subprocess.TimeoutExpired, FileNotFoundError):
return False
def capture_output(self, name: str, lines: int = 1000) -> Optional[str]:
"""Capture the last N lines of tmux pane output."""
if not self._available or not self.session_exists(name):
return None
try:
r = subprocess.run(
["tmux", "capture-pane", "-p", "-S", f"-{lines}", "-t", name],
capture_output=True, timeout=3,
)
if r.returncode == 0:
return r.stdout.decode("utf-8", errors="replace")
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return None
def session_state(self, name: str) -> Optional[Dict[str, Any]]:
"""Return state info for a session: exists, pane_pid, started_at, etc."""
if not self._available or not self.session_exists(name):
return None
try:
# Get the pane's pid
r = subprocess.run(
["tmux", "list-panes", "-t", name, "-F",
"#{pane_pid}:#{session_created}:#{pane_current_command}"],
capture_output=True, timeout=2,
)
if r.returncode != 0:
return {"name": name, "exists": True}
line = r.stdout.decode().strip().split("\n")[0]
parts = line.split(":")
return {
"name": name,
"exists": True,
"pane_pid": int(parts[0]) if parts and parts[0].isdigit() else None,
"created_at": int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None,
"current_command": parts[2] if len(parts) > 2 else None,
}
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError):
return {"name": name, "exists": True}
def list_fester_sessions(self) -> List[Dict[str, Any]]:
"""List all fester-* tmux sessions."""
if not self._available:
return []
try:
r = subprocess.run(
["tmux", "list-sessions", "-F", "#{session_name}:#{session_created}:#{session_attached}"],
capture_output=True, timeout=2,
)
if r.returncode != 0:
return []
sessions = []
for line in r.stdout.decode().strip().split("\n"):
if not line or not line.startswith("fester-"):
continue
parts = line.split(":")
name = parts[0]
created = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
attached = parts[2] == "1" if len(parts) > 2 else False
state = self.session_state(name) or {}
sessions.append({
"name": name,
"created_at": created,
"attached": attached,
"pane_pid": state.get("pane_pid"),
"current_command": state.get("current_command"),
})
return sessions
except (subprocess.TimeoutExpired, FileNotFoundError):
return []
def shlex_quote(s: str) -> str:
"""Minimal shlex.quote replacement (avoids importing shlex for one call)."""
if not s:
return "''"
if re.match(r"^[A-Za-z0-9_@%+=:,./-]+$", s):
return s
return "'" + s.replace("'", "'\"'\"'") + "'"