This commit is contained in:
its.kstyagi@gmail.com
2026-09-04 20:56:18 +00:00
commit 1022f24c34
43 changed files with 6160 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Internal Firecracker configuration, API, and process adapters."""
+82
View File
@@ -0,0 +1,82 @@
"""Typed Firecracker HTTP requests over a per-VM Unix-domain socket."""
from __future__ import annotations
import http.client
import json
import socket
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from ..errors import FirecrackerError
class _UnixHTTPConnection(http.client.HTTPConnection):
def __init__(self, socket_path: Path, timeout: float) -> None:
super().__init__("localhost", timeout=timeout)
self._socket_path = socket_path
def connect(self) -> None:
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.settimeout(self.timeout)
self.sock.connect(str(self._socket_path))
class FirecrackerClient:
"""Keep Firecracker endpoint details out of lifecycle orchestration."""
def __init__(self, socket_path: Path, timeout_s: float) -> None:
self._socket_path = socket_path
self._timeout_s = timeout_s
def configure_and_start(self, config: Mapping[str, Any]) -> None:
machine = config["machine-config"]
boot = config["boot-source"]
drives = config["drives"]
interfaces = config["network-interfaces"]
if not isinstance(machine, Mapping) or not isinstance(boot, Mapping):
raise FirecrackerError("invalid Firecracker configuration")
if not isinstance(drives, list) or not drives:
raise FirecrackerError("Firecracker configuration has no root drive")
if not isinstance(interfaces, list) or not interfaces:
raise FirecrackerError("Firecracker configuration has no network interface")
self._request(
"PUT",
"/machine-config",
{
"vcpu_count": machine["vcpu_count"],
"mem_size_mib": machine["mem_size_mib"],
"smt": False,
},
)
self._request("PUT", "/boot-source", boot)
self._request("PUT", "/drives/rootfs", drives[0])
self._request("PUT", "/network-interfaces/eth0", interfaces[0])
self._request("PUT", "/actions", {"action_type": "InstanceStart"})
def _request(self, method: str, path: str, body: object) -> None:
payload = json.dumps(body).encode("utf-8")
connection = _UnixHTTPConnection(self._socket_path, self._timeout_s)
try:
connection.request(
method,
path,
payload,
{
"Content-Type": "application/json",
"Accept": "application/json",
},
)
response = connection.getresponse()
response_body = response.read().decode("utf-8", errors="replace")
except (OSError, http.client.HTTPException) as error:
raise FirecrackerError(f"Firecracker API {method} {path}: {error}") from error
finally:
connection.close()
if response.status >= 300:
raise FirecrackerError(
f"Firecracker API {method} {path}: {response.status} {response_body}"
)
+94
View File
@@ -0,0 +1,94 @@
"""Build and persist Firecracker configuration for one VM."""
from __future__ import annotations
import json
import math
import os
import tempfile
from collections.abc import Mapping
from ipaddress import IPv4Address, IPv4Network
from pathlib import Path
from typing import Any
from ..config import Settings
from ..domain import VmRecord
def vcpu_count(cpu: float) -> int:
"""Firecracker accepts whole vCPUs; fractional capacity rounds up for now."""
return max(1, math.ceil(cpu))
def boot_ip_argument(
guest_ip: IPv4Address,
gateway: IPv4Address,
network: IPv4Network,
) -> str:
return f"ip={guest_ip}::{gateway}:{network.netmask}::eth0:off"
def build_config(
settings: Settings,
vm: VmRecord,
kernel: Path,
rootfs: Path,
) -> dict[str, Any]:
boot_args = " ".join(
(
"console=ttyS0",
"reboot=k",
"panic=1",
"pci=off",
boot_ip_argument(IPv4Address(vm.guest_ip), settings.gateway, settings.network),
)
)
return {
"boot-source": {
"kernel_image_path": str(kernel),
"boot_args": boot_args,
},
"drives": [
{
"drive_id": "rootfs",
"path_on_host": str(rootfs),
"is_root_device": True,
"is_read_only": False,
}
],
"machine-config": {
"vcpu_count": vcpu_count(vm.cpu),
"mem_size_mib": vm.ram_mib,
},
"network-interfaces": [
{
"iface_id": "eth0",
"guest_mac": vm.mac,
"host_dev_name": vm.tap,
}
],
}
def write_config(path: Path, config: Mapping[str, Any]) -> None:
"""Atomically publish a rendered Firecracker configuration file."""
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(
prefix=f".{path.name}.",
suffix=".tmp",
dir=path.parent,
)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as temporary_file:
json.dump(config, temporary_file, indent=2)
temporary_file.write("\n")
temporary_file.flush()
os.fsync(temporary_file.fileno())
os.replace(temporary_name, path)
finally:
try:
os.unlink(temporary_name)
except FileNotFoundError:
pass
+205
View File
@@ -0,0 +1,205 @@
"""Firecracker process lifecycle and PID identity handling."""
from __future__ import annotations
import os
import signal
import subprocess
import time
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
from ..config import Settings
from ..domain import VmRecord
from ..errors import FirecrackerError
class _PopenLike(Protocol):
pid: int
def poll(self) -> int | None: ...
@dataclass(frozen=True, slots=True)
class ProcessInfo:
pid: int
start_time: str
class FirecrackerProcessManager:
"""Start, observe, and terminate detached Firecracker processes."""
def __init__(
self,
settings: Settings,
*,
popen: Callable[..., _PopenLike] = subprocess.Popen,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
) -> None:
self._settings = settings
self._popen = popen
self._sleep = sleep
self._monotonic = monotonic
def start(self, vm: VmRecord) -> ProcessInfo:
socket_path = Path(vm.socket)
if socket_path.exists():
try:
socket_path.unlink()
except OSError as error:
raise FirecrackerError(
f"could not remove stale Firecracker socket {socket_path}: {error}"
) from error
log_path = Path(vm.log)
log_path.parent.mkdir(parents=True, exist_ok=True)
process: _PopenLike | None = None
try:
with log_path.open("ab", buffering=0) as log_file:
process = self._popen(
[str(self._settings.firecracker_binary), "--api-sock", str(socket_path)],
stdout=log_file,
stderr=log_file,
start_new_session=True,
)
self._wait_for_socket(process, socket_path, log_path)
start_time = self.process_start_time(process.pid)
if start_time is None:
raise FirecrackerError(
f"could not establish a safe process identity for Firecracker process {process.pid}"
)
return ProcessInfo(pid=process.pid, start_time=start_time)
except OSError as error:
if process is not None:
self._cleanup_started_process(process, socket_path)
raise FirecrackerError(f"could not start Firecracker: {error}") from error
except BaseException:
if process is not None:
self._cleanup_started_process(process, socket_path)
raise
def is_alive(self, vm: VmRecord) -> bool:
if vm.pid is None or vm.pid <= 0 or vm.process_start_time is None:
return False
try:
os.kill(vm.pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return self.process_start_time(vm.pid) == vm.process_start_time
def terminate(self, vm: VmRecord) -> None:
if vm.pid is None:
self._remove_socket(Path(vm.socket))
return
if vm.pid <= 0:
raise FirecrackerError(f"invalid persisted Firecracker PID: {vm.pid}")
if vm.process_start_time is None:
if not self._pid_exists(vm.pid):
self._remove_socket(Path(vm.socket))
return
raise FirecrackerError(
f"refusing to signal Firecracker PID {vm.pid} without a process identity token"
)
if not self.is_alive(vm):
self._remove_socket(Path(vm.socket))
return
try:
os.kill(vm.pid, signal.SIGTERM)
except ProcessLookupError:
self._remove_socket(Path(vm.socket))
return
except OSError as error:
raise FirecrackerError(f"could not stop Firecracker process {vm.pid}: {error}") from error
deadline = self._monotonic() + self._settings.terminate_timeout_s
while self.is_alive(vm) and self._monotonic() < deadline:
self._sleep(0.05)
if self.is_alive(vm):
self._terminate_process_id(vm.pid, force=True)
self._remove_socket(Path(vm.socket))
def process_start_time(self, pid: int) -> str | None:
"""Read Linux proc start time so a reused PID is not mistaken for a VM."""
try:
stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
except OSError:
return None
closing_parenthesis = stat.rfind(")")
if closing_parenthesis < 0:
return None
fields = stat[closing_parenthesis + 2 :].split()
try:
return fields[19]
except IndexError:
return None
def _wait_for_socket(
self,
process: _PopenLike,
socket_path: Path,
log_path: Path,
) -> None:
deadline = self._monotonic() + self._settings.api_socket_timeout_s
while self._monotonic() < deadline:
if socket_path.exists():
return
if process.poll() is not None:
raise FirecrackerError(f"Firecracker exited early; see {log_path}")
self._sleep(0.05)
raise FirecrackerError("timed out waiting for Firecracker API socket")
@staticmethod
def _remove_socket(socket_path: Path) -> None:
try:
socket_path.unlink()
except FileNotFoundError:
pass
except OSError:
# A stale socket is cleaned on the next launch; never hide a successful stop.
pass
@staticmethod
def _pid_exists(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def _cleanup_started_process(self, process: _PopenLike, socket_path: Path) -> None:
try:
self._terminate_process_id(process.pid)
except FirecrackerError:
pass
deadline = self._monotonic() + self._settings.terminate_timeout_s
while process.poll() is None and self._monotonic() < deadline:
self._sleep(0.05)
if process.poll() is None:
try:
self._terminate_process_id(process.pid, force=True)
except FirecrackerError:
pass
self._remove_socket(socket_path)
@staticmethod
def _terminate_process_id(pid: int, *, force: bool = False) -> None:
if pid <= 0:
raise FirecrackerError(f"invalid Firecracker PID: {pid}")
signal_to_send = signal.SIGKILL if force else signal.SIGTERM
try:
os.kill(pid, signal_to_send)
except ProcessLookupError:
pass
except OSError as error:
action = "force-stop" if force else "stop"
raise FirecrackerError(f"could not {action} Firecracker process {pid}: {error}") from error