18d9b81add
tracker-policy-pr / check-pr (pull_request) Successful in 12s
test / integration-docker (pull_request) Successful in 19s
lint / lint (push) Successful in 53s
test / unit (pull_request) Failing after 1m46s
test / integration-firecracker (pull_request) Failing after 2m42s
test / coverage (pull_request) Has been skipped
test / publish-infra (pull_request) Has been skipped
Now that #469 got the DB off the data plane, the Firecracker infra runs as two microVMs instead of one — mirroring the docker/macos plane split: * orchestrator VM (ORCH_IFACE) — control plane + buildah image builds; sole DB opener; host-seeded signing key. No gateway daemons. * gateway VM (new GW_IFACE) — egress / git-http / supervise data plane; mitmproxy CA + a host-minted `gateway` JWT (never the key). Reaches the orchestrator only over the one nft forward rule its link allows. Both boot the SAME shared infra rootfs; a `bb_role=` kernel-cmdline arg selects which plane a VM's PID-1 init starts, so there is still one published artifact. The gateway learns the orchestrator's address via `bb_orch=` on the cmdline (no IP baked into the artifact). Isolation is nearly free: agents were already nft-dropped except the DNAT'd gateway ports, so re-pointing that single DNAT rule at the gateway VM (`dnat to gw_guest`) severs every agent's L3 route to the control plane. The only added nft is the second infra link's mirror block (masquerade egress + forward accept, which subsumes gateway->orchestrator) in the shared shell script and the NixOS module. netpool gains GW_IFACE + gw_slot() (the /31 above the orch link); firecracker_vm.boot gains extra_boot_args for the role cmdline; infra_vm ensure_running() boots + adopts the pair (orchestrator first, then the gateway that resolves policy against it) and returns an InfraEndpoint mirroring the docker/macos shape. Builds stay in the orchestrator (PRD 0070 v1); the gateway is the slim unit. Unit-tested (test_firecracker_infra_vm rewritten for two VMs; gw_slot helper test added); the KVM boot / L3-isolation checks are validated on a Firecracker host. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
167 lines
6.4 KiB
Python
167 lines
6.4 KiB
Python
"""Consolidated bottle launch sequence for the Firecracker backend
|
|
(PRD 0070, Stage B).
|
|
|
|
The orchestrator control plane and the shared gateway run in **two** persistent
|
|
per-host **infra VMs** (`infra_vm.py`), split per PRD 0070 — not Docker
|
|
containers. Agent VMs reach the gateway's egress / supervise / git-http ports
|
|
via a PREROUTING DNAT on their own host-side TAP IP that redirects to the
|
|
**gateway** VM (see `scripts/firecracker-netpool.sh`) — never the orchestrator,
|
|
so a breached agent has no L3 route to the control plane. The host CLI reaches
|
|
the control plane over HTTP at the orchestrator VM's guest IP.
|
|
|
|
Attribution is by the agent VM's guest IP, unspoofable by construction: the
|
|
/31 point-to-point TAP + the `bot_bottle_fc` nft table ensure only the
|
|
expected VM can source-IP that address.
|
|
|
|
Sequence:
|
|
1. ensure the infra VMs (orchestrator control plane + gateway data plane)
|
|
are up (a singleton pair — a prior launcher may already have booted them);
|
|
2. register the bottle on the orchestrator by its guest IP (attribution key)
|
|
→ bottle id + identity token;
|
|
3. provision its git-gate repos/creds into the gateway VM (over SSH);
|
|
4. fetch the shared gateway CA for the provisioner to install in the rootfs.
|
|
|
|
The TAP slot allocation, rootfs build, and VM boot are the caller's job.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from ...egress import EgressPlan
|
|
from ...git_gate import GitGatePlan
|
|
from ...log import info
|
|
from ...orchestrator.client import OrchestratorClient, OrchestratorClientError
|
|
from ...orchestrator.lifecycle import (
|
|
OrchestratorStartError, # re-exported so callers can catch it
|
|
)
|
|
from ...orchestrator.reprovision import reprovision_bottles
|
|
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
|
|
from ..consolidated_util import (
|
|
provision_bottle,
|
|
teardown_consolidated as _teardown_util,
|
|
)
|
|
from . import cleanup, infra_vm, util
|
|
|
|
_ENV_VAR_SECRET_PATH = "/run/bot-bottle/env-var-secret"
|
|
|
|
|
|
class ConsolidatedLaunchError(RuntimeError):
|
|
"""The consolidated register/provision sequence could not complete."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LaunchContext:
|
|
"""What the Firecracker launch needs from the consolidated sequence."""
|
|
|
|
bottle_id: str
|
|
identity_token: str
|
|
source_ip: str # the VM's guest IP — the attribution key
|
|
gateway_ca_pem: str # the shared gateway CA the provisioner installs
|
|
orchestrator_url: str
|
|
env_var_secret: str = "" # encryption key injected into the agent's env
|
|
|
|
|
|
def _guest_ip_from_config(config_path: Path) -> str:
|
|
"""Read the kernel's configured guest IP from a Firecracker config."""
|
|
try:
|
|
config = json.loads(config_path.read_text())
|
|
args = config["boot-source"]["boot_args"]
|
|
ip_arg = next(part for part in args.split() if part.startswith("ip="))
|
|
return ip_arg.removeprefix("ip=").split(":", 1)[0]
|
|
except (OSError, ValueError, KeyError, TypeError, StopIteration):
|
|
return ""
|
|
|
|
|
|
def persist_env_var_secret(private_key: Path, guest_ip: str, secret: str) -> None:
|
|
"""Mirror the exec-time key into guest tmpfs for restart recovery."""
|
|
proc = subprocess.run(
|
|
util.ssh_base_argv(private_key, guest_ip)
|
|
+ [f"umask 077; mkdir -p /run/bot-bottle; cat > {_ENV_VAR_SECRET_PATH}"],
|
|
input=secret, capture_output=True, text=True, check=False,
|
|
)
|
|
if proc.returncode != 0:
|
|
raise ConsolidatedLaunchError(
|
|
f"failed to persist {ENV_VAR_SECRET_NAME} in agent VM: "
|
|
f"{proc.stderr.strip() or '<no stderr>'}"
|
|
)
|
|
|
|
|
|
def _reprovision_running_bottles(client: OrchestratorClient) -> None:
|
|
"""Read keys from live agent VMs and restore the restarted gateway."""
|
|
try:
|
|
secrets_by_ip: dict[str, str] = {}
|
|
for run_dir in cleanup.live_run_dirs():
|
|
guest_ip = _guest_ip_from_config(run_dir / "config.json")
|
|
private_key = run_dir / "bottle_id_ed25519"
|
|
if not guest_ip or not private_key.is_file():
|
|
continue
|
|
proc = subprocess.run(
|
|
util.ssh_base_argv(private_key, guest_ip)
|
|
+ [f"cat {_ENV_VAR_SECRET_PATH}"],
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
if proc.returncode == 0 and proc.stdout.strip():
|
|
secrets_by_ip[guest_ip] = proc.stdout.strip()
|
|
count = reprovision_bottles(client, secrets_by_ip)
|
|
if count:
|
|
info(f"reprovisioned egress tokens for {count} Firecracker bottle(s)")
|
|
except (OSError, OrchestratorClientError) as exc:
|
|
info(f"egress token reprovision skipped: {exc}")
|
|
|
|
|
|
def launch_consolidated(
|
|
egress_plan: EgressPlan,
|
|
git_gate_plan: GitGatePlan,
|
|
*,
|
|
guest_ip: str,
|
|
image_ref: str = "",
|
|
tokens: dict[str, str] | None = None,
|
|
) -> LaunchContext:
|
|
"""Ensure the infra VM is up, register the bottle by its guest IP, and
|
|
provision its git-gate state into the gateway VM. Returns the context the
|
|
agent-VM launch needs. Raises on failure — the caller tears down."""
|
|
infra = infra_vm.ensure_running()
|
|
url = infra.orchestrator_url
|
|
client = OrchestratorClient(url)
|
|
_reprovision_running_bottles(client)
|
|
|
|
transport = infra_vm.gateway_transport()
|
|
reg = provision_bottle(
|
|
client, guest_ip, egress_plan, git_gate_plan, transport,
|
|
image_ref=image_ref, tokens=tokens,
|
|
)
|
|
# The shared gateway CA every agent on this host trusts for TLS
|
|
# interception — fetched from the infra VM over SSH.
|
|
return LaunchContext(
|
|
bottle_id=reg.bottle_id,
|
|
identity_token=reg.identity_token,
|
|
source_ip=guest_ip,
|
|
gateway_ca_pem=infra.gateway_ca_pem(),
|
|
orchestrator_url=url,
|
|
env_var_secret=reg.env_var_secret,
|
|
)
|
|
|
|
|
|
def teardown_consolidated(
|
|
bottle_id: str, *, orchestrator_url: str, timeout: float | None = None,
|
|
) -> None:
|
|
"""Deregister the bottle and remove its git-gate state from the gateway
|
|
VM. Both steps are idempotent so this is safe from a cleanup trap. Does
|
|
NOT stop the infra VMs — they're persistent per-host singletons shared by
|
|
every bottle."""
|
|
_teardown_util(bottle_id, infra_vm.gateway_transport(),
|
|
orchestrator_url=orchestrator_url, timeout=timeout)
|
|
|
|
|
|
__all__ = [
|
|
"LaunchContext",
|
|
"launch_consolidated",
|
|
"teardown_consolidated",
|
|
"ConsolidatedLaunchError",
|
|
"OrchestratorStartError",
|
|
]
|