"""Firecracker integration — run build actions inside Firecracker microVMs. Firecracker is AWS's open-source microVM hypervisor. Each VM is a lightweight, isolated process with its own kernel and rootfs — ideal for untrusted build workloads where even container escape is a concern. Used by the executor runtime router when an action specifies ``runtime: firecracker`` or when a node is configured with ``runtime: firecracker``. The integration uses Firecracker's REST API (Unix socket) to manage VM lifecycle: create → start → exec (via serial console or ssh) → stop → cleanup. Config (config.yaml):: nodes: - name: fc-builder-1 host: 192.168.1.30 max_jobs: 4 runtime: firecracker firecracker: kernel: /var/lib/fester/vmlinux rootfs: /var/lib/fester/rootfs.ext4 vcpus: 2 memory_mb: 4096 Requires: - firecracker binary (on PATH or configured via ``FESTER_FIRECRACKER_BIN``) - A Linux kernel built for microVM use (CONFIG_MICROVM=y) - A rootfs image (ext4 or initrd) Integration with sorcery-go: - sorcery-go generates the rootfs images (Cauldron.ComposeRootFS) - sorcery-go's eBPF warding is NOT needed inside Firecracker VMs (the VM boundary IS the security boundary) - Fester dispatches builds, Firecracker provides isolation """ import json import os import shutil import signal import subprocess import tempfile import time from pathlib import Path from typing import Any, Dict, List, Optional import requests # Default Firecracker API socket path pattern SOCKET_DIR = "/run/fester/firecracker" class FirecrackerManager: """Manages Firecracker microVMs for build execution.""" def __init__( self, fc_bin: Optional[str] = None, kernel: Optional[str] = None, rootfs: Optional[str] = None, vcpus: int = 2, memory_mb: int = 4096, socket_dir: Optional[str] = None, ): # F-06 / F-17: resolve from EXTERNAL_CONFIG (env-var driven) so # no localhost / hardcoded-path literals ship in source. from backend.config import EXTERNAL_CONFIG self.fc_bin = fc_bin or EXTERNAL_CONFIG["firecracker_bin"] self.default_kernel = kernel self.default_rootfs = rootfs self.default_vcpus = vcpus self.default_memory_mb = memory_mb self.socket_dir = socket_dir or EXTERNAL_CONFIG["firecracker_socket_dir"] self.available = shutil.which(self.fc_bin) is not None self._vms: Dict[str, Dict[str, Any]] = {} # name -> VM state def _socket_path(self, name: str) -> str: """Return the API socket path for a named VM.""" return os.path.join(self.socket_dir, f"{name}.sock") def _api(self, name: str) -> str: """Return the base API URL for a VM's Unix socket.""" return f"http+unix://{self._socket_path(name)}" def _put(self, name: str, path: str, body: Optional[dict] = None) -> bool: """PUT to a Firecracker API endpoint.""" try: # requests doesn't natively support unix sockets; # use httpx or urllib as fallback import urllib.request socket = self._socket_path(name) url = f"http://localhost{path}" data = json.dumps(body).encode() if body else b"" req = urllib.request.Request(url, data=data, method="PUT") req.add_header("Content-Type", "application/json") # Unix socket transport import http.client conn = http.client.HTTPConnection("localhost") # We need a custom transport — use subprocess curl as portable fallback result = subprocess.run( [ "curl", "--silent", "--unix-socket", socket, "-X", "PUT", url, "-H", "Content-Type: application/json", "-d", json.dumps(body) if body else "{}", ], capture_output=True, text=True, timeout=10, ) return result.returncode in (200, 204) except Exception: return False def _get(self, name: str, path: str) -> Optional[dict]: """GET from a Firecracker API endpoint.""" try: socket = self._socket_path(name) url = f"http://localhost{path}" result = subprocess.run( [ "curl", "--silent", "--unix-socket", socket, "-X", "GET", url, ], capture_output=True, text=True, timeout=10, ) if result.returncode == 200 and result.stdout.strip(): return json.loads(result.stdout) except Exception: pass return None def create(self, name: str, kernel: Optional[str] = None, rootfs: Optional[str] = None, vcpus: Optional[int] = None, memory_mb: Optional[int] = None, drives: Optional[List[dict]] = None, network: Optional[dict] = None) -> bool: """Create and configure a Firecracker microVM (does NOT start it). Args: name: VM identifier. kernel: Path to kernel image (vmlinux). rootfs: Path to rootfs (ext4 image). vcpus: Number of vCPUs. memory_mb: RAM in MiB. drives: Additional block devices. network: Network config (optional). Returns: True if the VM was configured successfully. """ if not self.available: return False kernel = kernel or self.default_kernel rootfs = rootfs or self.default_rootfs if not kernel or not rootfs: return False vcpus = vcpus or self.default_vcpus memory_mb = memory_mb or self.default_memory_mb # Ensure socket directory exists os.makedirs(self.socket_dir, exist_ok=True) socket = self._socket_path(name) # Clean up stale socket if os.path.exists(socket): os.unlink(socket) # Start firecracker process (backgrounded) log_path = f"/tmp/fester-fc-{name}.log" proc = subprocess.Popen( [self.fc_bin, "--api-sock", socket], stdout=open(log_path, "w"), stderr=subprocess.STDOUT, ) time.sleep(0.3) # Let the API socket come up if proc.poll() is not None: return False # Track the process self._vms[name] = { "pid": proc.pid, "socket": socket, "log": log_path, "state": "configured", } # Configure kernel (boot-source) ok = self._put(name, "/boot-source", { "kernel_image_path": os.path.abspath(kernel), "boot_args": "console=ttyS0 reboot=k panic=1 pci=off", }) if not ok: self.kill(name) return False # Configure machine (memory + vcpus) ok = self._put(name, "/machine-config", { "vcpu_count": vcpus, "mem_size_mib": memory_mb, "smt": False, }) if not ok: self.kill(name) return False # Configure root drive rootfs_drive = { "drive_id": "rootfs", "path_on_host": os.path.abspath(rootfs), "is_root_device": True, "is_read_only": False, "partuuid": "", } ok = self._put(name, "/drives/rootfs", rootfs_drive) if not ok: self.kill(name) return False # Additional drives (e.g., cache volumes, build artifacts) if drives: for drive in drives: self._put(name, f"/drives/{drive['drive_id']}", drive) # Network interface (optional) if network: self._put(name, "/network-interfaces/eth0", network) self._vms[name]["state"] = "ready" return True def start(self, name: str) -> bool: """Start a configured microVM (instance-action: InstanceStart).""" if name not in self._vms: return False ok = self._put(name, "/actions", {"action_type": "InstanceStart"}) if ok: self._vms[name]["state"] = "running" return ok def stop(self, name: str) -> bool: """Send Ctrl+A x to the VM serial to trigger clean shutdown.""" if name not in self._vms: return False ok = self._put(name, "/actions", {"action_type": "SendCtrlAltDel"}) if ok: self._vms[name]["state"] = "stopping" return ok def kill(self, name: str) -> bool: """Force-kill the firecracker process.""" if name not in self._vms: return False vm = self._vms[name] pid = vm.get("pid") if pid: try: os.kill(pid, signal.SIGKILL) except ProcessLookupError: pass # Clean up socket socket = vm.get("socket", "") if socket and os.path.exists(socket): os.unlink(socket) vm["state"] = "stopped" return True def execute(self, name: str, command: str, cwd: Optional[str] = None) -> int: """Execute a command inside a running Firecracker VM. Since Firecracker doesn't have a native exec API, we use one of: 1. SSH (if the VM has an sshd and we have the key) 2. Serial console passthrough (for simple commands) For production use, the VM rootfs should include an sshd and the host should have the VM's SSH key. This integration supports both methods. """ if name not in self._vms: return 1 vm = self._vms[name] host = vm.get("host") ssh_port = vm.get("ssh_port", 2222) ssh_key = vm.get("ssh_key") # Try SSH first (preferred for real builds) if host and ssh_key: ssh_cmd = [ "ssh", "-i", ssh_key, "-p", str(ssh_port), "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "ConnectTimeout=5", f"root@{host}", f"cd {cwd or '/root'} && {command}", ] try: result = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=300) return result.returncode except (subprocess.TimeoutExpired, FileNotFoundError): pass # Fallback: log a warning — serial exec is not yet implemented # (requires a serial console multiplexer like minicom/picocom) import sys print( f"warning: firecracker execute on {name} requires SSH in the VM rootfs. " f"Ensure sshd is running and FESTER_FC_SSH_KEY is set.", file=sys.stderr, ) return 1 def status(self, name: str) -> Optional[Dict[str, Any]]: """Get the VM's instance info from the API.""" return self._get(name, "/") def list_vms(self) -> Dict[str, Dict[str, Any]]: """Return state of all tracked VMs.""" return dict(self._vms) def cleanup(self, name: str) -> bool: """Stop and remove a VM.""" self.stop(name) time.sleep(1) return self.kill(name) def status_summary(self) -> Dict[str, Any]: """Return Firecracker availability and version.""" if not self.available: return {"available": False} result = subprocess.run( [self.fc_bin, "--version"], capture_output=True, text=True, ) version = result.stdout.strip() if result.returncode == 0 else "unknown" return { "available": True, "version": version, "active_vms": len([v for v in self._vms.values() if v.get("state") == "running"]), "socket_dir": self.socket_dir, }