Files
bot-bottle/bot_bottle/backend/firecracker/orchestrator.py
T
didericis-claude 45f3cefbc5 refactor(orchestrator): uniform control-plane auth provisioning per trust domain
Hoist control-plane auth provisioning out of the per-backend launchers into
one shared contract, parameterized per trust domain (#476). Every blocking
finding in PR #471 was the same integration-bug class: each launcher
re-derived, by hand, how to generate the signing key, scope it to the
orchestrator, mint the gateway JWT, and keep the host key canonical.

Introduces `trust_domain.py`:

  * `TrustDomain` — one credential boundary (host-canonical key file + role
    set + env vars). `mint`/`verify` are scoped to the domain's roles, so a
    future host-controller domain (#468) uses its own key/verifier/roles
    rather than a `host` role on the control plane's frozenset (which the
    orchestrator key could then forge).
  * `ControlPlaneProvisioning` — the single seam answering the four
    invariants: host-canonical key, split key-vs-token credential, CLI token
    valid across co-running backends, and fail-closed (no open mode) for any
    co-located topology.
  * `Topology` — the backend declares what it is; the default is co-located +
    fail-closed, so a backend need not redeclare it.

The `Orchestrator` ABC gets `control_plane_key()` (fail-closed) and routes
`mint_gateway_token()` through the contract; docker/macOS/firecracker
orchestrators, the server (verify), and the host CLI client (mint cli) all go
through the domain instead of reading the host key directly. `orchestrator_auth`
gains an optional `roles=` arg (default unchanged) so a domain scopes its own
role set; `paths.host_signing_key(filename)` generalizes host_orchestrator_token.

Adds unit coverage for the domain boundary + provisioning invariants and a PRD
capturing the durable rationale. No change to the auth primitive's HMAC, the
plane split, or the server's documented open-mode fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Closes #476
2026-07-26 01:32:53 +00:00

160 lines
7.1 KiB
Python

"""The Firecracker orchestrator (control plane) as a microVM (PRD 0070).
`FirecrackerOrchestrator` is the Firecracker implementation of the backend-neutral
`Orchestrator` service, and owns the control plane's host-side logic directly:
booting the orchestrator microVM on its NAT'd link with the persistent registry
volume (/dev/vdb — the sole opener of `bot-bottle.db`), seeding the host-canonical
signing key over SSH, and waiting for `/health`. The host CLI reaches it at its
guest IP, and so does the gateway (`bb_orch` cmdline), so `url()` == `gateway_url()`.
The plane-agnostic VM substrate the gateway VM also uses stays in `infra_vm` —
booting a VM from a per-plane rootfs (`boot_vm`), the stable SSH keypair, the
secret-push retry, the PID lifecycle, the adoption/version helpers, and the
per-plane guest inits (`role_init`). The pair coordinator (adopt-or-boot-both
under a singleton flock) is `FirecrackerInfraService` (`infra.py`).
"""
from __future__ import annotations
import subprocess
import time
from pathlib import Path
from ...log import die, info
from ...orchestrator.lifecycle import (
DEFAULT_STARTUP_TIMEOUT_SECONDS,
Orchestrator,
)
from . import firecracker_vm, infra_vm, netpool
from .infra_vm import ORCHESTRATOR_PORT
# The orchestrator microVM's name (its run dir). Fixed per host — one
# control-plane VM.
ORCHESTRATOR_NAME = "bot-bottle-orchestrator"
# Memory ceiling (fixed at boot, demand-paged). The orchestrator keeps the build
# headroom (buildah's 2-4 GB working set during in-VM agent builds) — PRD 0070
# "Memory: fixed ceilings".
_ORCH_MEM_MIB = 4096
_HEALTH_POLL_SECONDS = 0.5
# The registry "volume": a host-side ext4 file attached to the orchestrator VM as
# a second virtio-block device (guest /dev/vdb), mounted at the control plane's DB
# dir. It outlives the ephemeral rootfs, so the bottle registry survives an
# orchestrator-VM restart — the firecracker analogue of a docker volume. A plain
# ext4 file: `sudo mount -o loop <path>` on the host (VM stopped) to inspect
# bot-bottle.db. The gateway VM has no such volume — the data plane never opens
# the DB (#469).
_REGISTRY_SIZE = "512M"
def registry_volume_path() -> Path:
return infra_vm._infra_dir() / "registry.ext4"
class FirecrackerOrchestrator(Orchestrator):
"""The control plane as a Firecracker microVM on the orchestrator link.
The orchestrator rootfs is built/downloaded by `infra_vm.ensure_built` (the
ABC's `ensure_built` no-op here); `ensure_running` boots the VM, seeds the
signing key, and blocks until `/health` answers."""
name = ORCHESTRATOR_NAME
def __init__(self, vm: infra_vm.InfraVm | None = None) -> None:
# The live VM handle when this process booted it; None when adapting an
# already-running orchestrator (health/URL go through the fixed link).
self._vm = vm
def url(self) -> str:
"""The orchestrator VM's guest IP URL — where the host CLI reaches the
control plane."""
return f"http://{netpool.orch_slot().guest_ip}:{ORCHESTRATOR_PORT}"
def gateway_url(self) -> str:
"""Same guest IP the host uses — the gateway resolves the orchestrator at
`bb_orch=<guest_ip>` off its cmdline."""
return self.url()
def ssh_target(self) -> tuple[Path, str]:
"""(private key, guest IP) for SSHing into the orchestrator VM. In-VM
agent-image builds (buildah) run here — the build host drives them over
SSH with the stable infra key."""
return infra_vm._infra_dir() / "id_ed25519", netpool.orch_slot().guest_ip
def is_running(self) -> bool:
return infra_vm._pidfile_alive(infra_vm._orch_dir())
def stop(self) -> None:
"""Stop only the orchestrator VM (idempotent). A dead orchestrator fails
`infra_vm.adoptable`'s health check, so no version marker to clear."""
infra_vm._kill_pidfile(infra_vm._orch_dir())
infra_vm._pid_file(infra_vm._orch_dir()).unlink(missing_ok=True)
def ensure_running(
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
) -> None:
"""Boot the orchestrator VM on its link with the persistent registry
volume, seed the signing key over SSH, and block until `/health` answers.
Idempotent — a live, healthy control plane is left alone (the pair
coordinator otherwise `stop()`s it before calling, so this boots fresh)."""
if self.is_running() and self.is_healthy():
return
vm = infra_vm.boot_vm(
name=ORCHESTRATOR_NAME, slot=netpool.orch_slot(),
run_dir=infra_vm._orch_dir(), role="orchestrator", mem_mib=_ORCH_MEM_MIB,
data_drive=self._ensure_registry_volume(),
)
# Push the host-canonical signing key (the init waits for it before
# starting the control plane). It comes through the shared provisioning
# contract (#476) — the same host token file every backend uses, so a
# co-running docker/macOS control plane keeps working and the guest
# verifies tokens with the same key the CLI signs from; fail-closed, so
# the guest is never handed an empty key that would run it OPEN.
infra_vm.push_secret(
vm, self.control_plane_key(), infra_vm._GUEST_SIGNING_KEY_PATH,
"the control-plane signing key to the orchestrator VM "
"(its control plane will not start)",
)
self._vm = vm
self._wait_for_health(vm, timeout=startup_timeout)
def _wait_for_health(
self, vm: infra_vm.InfraVm, *, timeout: float,
) -> None:
"""Poll `/health` until it answers 200 or the deadline passes. Dies (with
the console tail) if the VMM exits early."""
url = f"{self.url()}/health"
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if vm.vm is not None and not vm.vm.is_alive():
die(f"orchestrator VM exited during boot (rc={vm.vm.process.returncode}).\n"
f"{firecracker_vm._console_tail(vm.vm.console_log)}")
if self.is_healthy():
info(f"orchestrator control plane healthy at {self.url()}")
return
time.sleep(_HEALTH_POLL_SECONDS)
tail = (firecracker_vm._console_tail(vm.vm.console_log)
if vm.vm is not None else "")
die(f"orchestrator control plane at {url} did not become healthy within "
f"{timeout:.0f}s.\n{tail}")
def _ensure_registry_volume(self) -> Path:
"""Create the empty ext4 registry volume on first use; reuse it after."""
vol = registry_volume_path()
if vol.exists():
return vol
info(f"creating infra registry volume {vol} ({_REGISTRY_SIZE})")
proc = subprocess.run(
["mke2fs", "-q", "-t", "ext4", "-F", str(vol), _REGISTRY_SIZE],
capture_output=True, text=True, check=False,
)
if proc.returncode != 0:
vol.unlink(missing_ok=True)
die(f"creating registry volume failed: {proc.stderr.strip()}")
return vol
__all__ = ["FirecrackerOrchestrator", "ORCHESTRATOR_NAME", "registry_volume_path"]