205 lines
7.3 KiB
Python
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("'", "'\"'\"'") + "'"
|