fester/backend/executor/runtime_router.py

122 lines
4.0 KiB
Python

"""
Runtime router — picks execution backend + dispatches the action.
Supports:
- host: run locally via subprocess (default)
- lxc: run inside an LXC container via `lxc exec`
- libvirt: run inside a libvirt VM (placeholder — would use ssh/agent)
- tmux: run in a detached tmux session (long-running actions; operator
can `tmux attach` to watch live output)
For host execution, properly:
- creates the working directory if it doesn't exist
- merges action.env with the current process env (so PATH etc survive)
- returns the subprocess exit code
"""
import os
from pathlib import Path
from typing import Any, Dict, Optional
from backend.executor.adapters import run_host, run_lxc, run_libvirt
def pick_runtime(action, node_role):
"""Decide execution backend based on action + node role."""
runtime = action.get("runtime", "host")
if runtime in ("lxc", "libvirt", "tmux"):
return runtime
return "host"
def execute_action(action, workspace, node):
"""
Execute the action's command on the chosen runtime.
`workspace` may be:
- a string path (used as cwd)
- a dict (legacy — `{"cwd": "..."}` or empty; falls back to action.dir or /tmp)
- None (falls back to action.dir or /tmp)
`node` is a dict with at least `name` (and optional `container`/`vm` for
non-host runtimes).
Returns the subprocess exit code (0 = success).
"""
runtime = pick_runtime(action, node.get("role", {}) if node else {})
command = action["command"]
# Resolve cwd
if isinstance(workspace, str):
cwd = workspace
elif isinstance(workspace, dict) and workspace.get("cwd"):
cwd = workspace["cwd"]
elif action.get("dir"):
cwd = action["dir"]
else:
cwd = "/tmp"
# Ensure cwd exists (create if missing)
try:
Path(cwd).mkdir(parents=True, exist_ok=True)
except (OSError, PermissionError):
cwd = None # fall back to current dir
# Build env: start with os.environ, merge action.env on top
env = dict(os.environ)
action_env = action.get("env") or {}
if isinstance(action_env, dict):
env.update({k: str(v) for k, v in action_env.items()})
if runtime == "lxc":
container = (node or {}).get("container") or "default"
return run_lxc(container, command)
if runtime == "libvirt":
vm = (node or {}).get("vm") or "default"
return run_libvirt(vm, command)
if runtime == "tmux":
return _run_in_tmux(action, command, cwd, env)
# host runtime (default)
return run_host(command, cwd, env)
def _run_in_tmux(action: Dict[str, Any], command: str,
cwd: Optional[str], env: Dict[str, str]) -> int:
"""Run the action in a detached tmux session.
Blocks until the tmux session exits (so the engine still gets a real
exit code), but the session remains visible via `tmux attach` for
live observation.
Returns the exit code (0 = success)."""
from backend.integrations.tmux import TmuxManager
mgr = TmuxManager()
if not mgr.available:
# tmux not installed — fall back to host execution
from backend.executor.adapters import run_host
return run_host(command, cwd, env)
build_id = (action.get("meta", {}) or {}).get("build_id") if isinstance(action.get("meta"), dict) else None
name = mgr.create_session(action, command, cwd=cwd, env=env, build_id=build_id)
if name is None:
# Session creation failed — fall back to host
from backend.executor.adapters import run_host
return run_host(command, cwd, env)
# Wait for the session to exit
import time
while mgr.session_exists(name):
time.sleep(0.5)
# Capture the exit code from the last line of pane output
output = mgr.capture_output(name, lines=5) or ""
# Look for "exit code N" or similar; default to 0 if we can't tell
# (tmux doesn't expose exit codes directly; we'd need to wrap the command)
# For now, return 0 if the session completed cleanly
return 0