Files
bot-bottle/bot_bottle/backend/firecracker/firecracker_vm.py
T
didericis ff8639e3c5
lint / lint (push) Successful in 2m9s
test / unit (pull_request) Successful in 1m5s
test / integration (pull_request) Successful in 23s
test / coverage (pull_request) Successful in 1m21s
feat(firecracker): persistent infra-VM singleton lifecycle (Stage B, 4/n)
The infra VM must outlive the short-lived `start` launcher and be reused
across launches. Add an idempotent singleton:

- `ensure_running()` adopts the infra VM when its control plane is already
  healthy (a prior launcher booted it), else clears any stale VM and boots
  a fresh one. Returns a handle usable for CA fetch / git-gate provisioning
  whether we booted it or adopted it.
- boot is `detached` (firecracker in its own session via start_new_session)
  so it survives the launcher exiting; its PID is recorded so a later
  process can `stop()` it. `_kill_pidfile` SIGTERM/SIGKILLs but only if the
  PID is still a firecracker process (guards a recycled PID).
- a STABLE SSH key (generated once under the infra cache dir, re-injected
  each boot via the cmdline) so any launcher can SSH in to fetch the CA /
  provision, not just the one that booted the VM. `InfraVm.vm` is None in
  the adopted case; teardown then goes through the PID file.

Verified on a KVM host: first ensure_running boots; a second adopts it
(same PID, no reboot) and can still reach /health and fetch the gateway CA
over SSH; stop() tears it down (control plane then unreachable).

Next: git-gate provisioning into the VM over SSH (today docker exec/cp),
then swap consolidated_launch.py onto the infra VM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-16 13:01:00 -04:00

181 lines
5.7 KiB
Python

"""Firecracker microVM process lifecycle.
A bottle VM is one `firecracker` process booted from a JSON config
(kernel + writable rootfs ext4 + one TAP network interface). We run it
`--no-api`: all configuration is in the config file, and teardown is a
process signal — the microVM dies with its VMM, which is exactly the
ephemeral-bottle semantics we want. No API socket, no runtime
reconfiguration.
The guest gets its IP from the kernel `ip=` cmdline (kernel-level
autoconfig, no in-guest iproute2) and its SSH pubkey from a `bb_pubkey=`
cmdline arg the init decodes.
"""
from __future__ import annotations
import base64
import json
import signal
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
from ...log import die, info
from . import util
# Serial console off the critical path but captured to a log for
# debugging boot failures. `reboot=k panic=1 pci=off` are the standard
# Firecracker guest args; `i8042.*` skip the (absent) PS/2 probe to
# shave boot time.
_BASE_BOOT_ARGS = (
"console=ttyS0 reboot=k panic=1 pci=off "
"i8042.noaux i8042.nomux i8042.nopnp i8042.dumbkbd "
"root=/dev/vda rw init=/bb-init"
)
@dataclass
class VmHandle:
"""A running microVM: its VMM process, guest IP, and console log."""
process: subprocess.Popen[bytes]
guest_ip: str
console_log: Path
def is_alive(self) -> bool:
return self.process.poll() is None
def terminate(self) -> None:
"""Stop the VMM (and thus the guest). SIGTERM, then SIGKILL."""
if self.process.poll() is not None:
return
self.process.send_signal(signal.SIGTERM)
try:
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait(timeout=5)
def _boot_args(guest_ip: str, host_ip: str, pubkey: str) -> str:
# ip=<client>::<gw>:<netmask>::<dev>:off — /31 point-to-point link,
# so netmask is 255.255.255.254 and the gateway is the host TAP IP.
ip_arg = f"ip={guest_ip}::{host_ip}:255.255.255.254::eth0:off"
pub_b64 = base64.b64encode(pubkey.encode()).decode()
return f"{_BASE_BOOT_ARGS} {ip_arg} bb_pubkey={pub_b64}"
def _config(
*,
rootfs: Path,
tap: str,
guest_ip: str,
host_ip: str,
pubkey: str,
vcpus: int,
mem_mib: int,
guest_mac: str,
) -> dict[str, object]:
return {
"boot-source": {
"kernel_image_path": str(util.kernel_path()),
"boot_args": _boot_args(guest_ip, host_ip, pubkey),
},
"drives": [
{
"drive_id": "rootfs",
"path_on_host": str(rootfs),
"is_root_device": True,
"is_read_only": False,
}
],
"network-interfaces": [
{
"iface_id": "eth0",
"host_dev_name": tap,
"guest_mac": guest_mac,
}
],
"machine-config": {
"vcpu_count": vcpus,
"mem_size_mib": mem_mib,
},
}
def boot(
*,
name: str,
rootfs: Path,
tap: str,
guest_ip: str,
host_ip: str,
pubkey: str,
run_dir: Path,
vcpus: int = 2,
mem_mib: int = 2048,
guest_mac: str = "06:00:AC:10:00:02",
detached: bool = False,
) -> VmHandle:
"""Write the config and launch the VMM. Returns once the process is
spawned; callers wait for SSH readiness separately.
`detached` starts the VMM in its own session (`start_new_session`) so it
survives the launcher exiting — used for the persistent per-host infra
VM, which must outlive the short-lived `start` process (agent VMs stay
attached and are torn down with the launcher)."""
run_dir.mkdir(parents=True, exist_ok=True)
config_path = run_dir / "config.json"
console_log = run_dir / "console.log"
config_path.write_text(json.dumps(
_config(
rootfs=rootfs, tap=tap, guest_ip=guest_ip, host_ip=host_ip,
pubkey=pubkey, vcpus=vcpus, mem_mib=mem_mib, guest_mac=guest_mac,
),
indent=2,
))
info(f"booting microVM {name} on {tap} (guest {guest_ip})")
log_fh = console_log.open("wb")
process = subprocess.Popen(
["firecracker", "--no-api", "--config-file", str(config_path)],
stdout=log_fh, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
start_new_session=detached,
)
return VmHandle(process=process, guest_ip=guest_ip, console_log=console_log)
def wait_for_ssh(
vm: VmHandle, private_key: Path, *, timeout: float = 30.0,
) -> None:
"""Poll SSH until the guest accepts a command or the deadline
passes. Dies (with the console tail) if the VMM exits early or the
guest never comes up — a boot failure must be loud, not a hang."""
deadline = time.monotonic() + timeout
probe = util.ssh_base_argv(private_key, vm.guest_ip) + ["true"]
while time.monotonic() < deadline:
if not vm.is_alive():
die(f"microVM exited during boot (rc={vm.process.returncode}).\n"
f"{_console_tail(vm.console_log)}")
result = subprocess.run(
probe, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False,
)
if result.returncode == 0:
return
time.sleep(0.5)
die(f"microVM {vm.guest_ip} did not accept SSH within {timeout:.0f}s.\n"
f"{_console_tail(vm.console_log)}")
def _console_tail(console_log: Path, lines: int = 25) -> str:
try:
text = console_log.read_text(errors="replace").splitlines()
except OSError:
return "(no console log)"
tail = "\n".join(text[-lines:])
return f"--- guest console (last {lines} lines) ---\n{tail}"