136 lines
4.7 KiB
Python
Executable File
136 lines
4.7 KiB
Python
Executable File
"""
|
|
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 |