fester/backend/integrations/firecracker.py

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,
}