347 lines
12 KiB
Python
Executable File
347 lines
12 KiB
Python
Executable File
"""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,
|
|
} |