__init__
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Internal Firecracker configuration, API, and process adapters."""
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user