feat(firecracker): run the orchestrator control plane as a VM (Stage B, 1/n)
lint / lint (push) Successful in 2m18s
test / unit (pull_request) Successful in 1m23s
test / integration (pull_request) Successful in 29s
test / coverage (pull_request) Successful in 1m17s

First slice of Stage B: the orchestrator control plane runs as a
persistent Firecracker infra VM instead of a Docker container. The host
CLI reaches it over HTTP at the orchestrator link's guest IP; agent VMs
will reach its gateway ports (added next) over VM-to-VM routing.

- Dockerfile.orchestrator bakes the stdlib-only control-plane source
  (COPY bot_bottle) so the image is self-contained and runs from a built
  image with no runtime bind-mount — a guest VM can't bind-mount host
  source. (Build-from-source stays the default; a pull-from-registry mode
  lands later. The docker backend's dev bind-mount still overlays this.)
- util.build_base_rootfs_dir / inject_guest_boot take a `variant` +
  `init_script`, so the same orchestrator image is prepared two ways
  without a cache collision: the builder VM keeps the SSH-only agent init;
  the infra VM gets a control-plane PID-1 init.
- new firecracker/infra_vm.py: boot the infra VM on the orchestrator link,
  run `python -m bot_bottle.orchestrator` as PID 1, and poll /health.

Verified on a KVM host: infra VM boots, control plane answers
`GET /health -> 200 {"status":"ok"}` from the host over the TAP link.
Next: fold gateway_init (egress/git-gate/supervise) into the same VM,
then agent->gateway routing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
2026-07-16 12:34:51 -04:00
parent 73ee081c65
commit ebaa999c21
4 changed files with 231 additions and 8 deletions
+17 -8
View File
@@ -159,15 +159,22 @@ def docker_image_id(ref: str) -> str:
return result.stdout.strip().replace("sha256:", "")[:16]
def build_base_rootfs_dir(image_ref: str) -> Path:
"""Export the agent image's filesystem and inject the guest init +
static dropbear. Cached by image digest — the per-bottle bits
def build_base_rootfs_dir(
image_ref: str, *, variant: str = "", init_script: str | None = None,
) -> Path:
"""Export the image's filesystem and inject the guest init + static
dropbear. Cached by image digest — the per-bottle bits
(authorized_keys, IP) are passed at boot via the kernel cmdline, so
this tree carries nothing bottle-specific and is safely shared.
`variant` suffixes the cache key so the same image can be prepared
with a different `init_script` (e.g. the infra VM boots the same
orchestrator image as the builder but runs the control plane as
PID 1, not the SSH-only agent init) without a cache collision.
Returns the prepared directory (read as the `mke2fs -d` source)."""
digest = docker_image_id(image_ref)
base = cache_dir() / "rootfs" / digest
base = cache_dir() / "rootfs" / f"{digest}{variant}"
ready = base / ".bb-ready"
if ready.is_file():
return base
@@ -200,17 +207,19 @@ def build_base_rootfs_dir(image_ref: str) -> Path:
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
inject_guest_boot(base)
inject_guest_boot(base, init_script=init_script)
ready.write_text("ok\n")
return base
def inject_guest_boot(rootfs: Path) -> None:
"""Drop the static dropbear and the PID-1 init into the rootfs."""
def inject_guest_boot(rootfs: Path, init_script: str | None = None) -> None:
"""Drop the static dropbear and the PID-1 init into the rootfs.
`init_script` defaults to the SSH-only agent init; the infra VM
passes its own (control plane + gateway) init."""
shutil.copy2(dropbear_path(), rootfs / "bb-dropbear")
os.chmod(rootfs / "bb-dropbear", 0o755)
init = rootfs / "bb-init"
init.write_text(_GUEST_INIT)
init.write_text(init_script or _GUEST_INIT)
os.chmod(init, 0o755)