""" 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 (via ssh/agent) - tmux: run in a detached tmux session (long-running; operator can ``tmux attach`` to watch live output) - podman: run inside a rootless Podman container - firecracker: run inside a Firecracker microVM via SSH 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, Mapping, Optional from backend.executor.adapters import ( run_host, run_lxc, run_libvirt, run_podman, run_firecracker, ) def pick_runtime(action: Mapping[str, Any], node_role: Any) -> str: """Decide execution backend based on action + node role.""" runtime = action.get("runtime", "host") if runtime in ("lxc", "libvirt", "tmux", "podman", "firecracker"): return runtime return "host" def execute_action(action: Mapping[str, Any], workspace: Any, node: Mapping[str, Any]) -> int: """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``/``runtime``/``firecracker`` 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 == "podman": container = (node or {}).get("container") or "default" return run_podman(container, command, cwd=cwd) if runtime == "firecracker": from backend.config import EXTERNAL_CONFIG fc_config = (node or {}).get("firecracker", {}) host = (node or {}).get("host", "127.0.0.1") ssh_key = fc_config.get( "ssh_key", os.environ.get("FESTER_FC_SSH_KEY", EXTERNAL_CONFIG["firecracker_ssh_key"]), ) ssh_port = fc_config.get("ssh_port", 2222) return run_firecracker(host, ssh_key, ssh_port, command, cwd=cwd) 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 "" return 0