206 lines
6.9 KiB
Python
206 lines
6.9 KiB
Python
"""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
|