7005b22bcf
Chunk 3 of the host-control-server stack: grow the broker op vocabulary
(PRD gap 3), starting with `list_live`, and invert `reconcile` onto it.
- broker: split the closed op vocabulary into mutation (`launch`/`teardown`,
carry a bottle id + static flags) and query (`list_live`, carries nothing
but its op name) kinds. `verify_request` now enforces a **strict schema**
(open question 1, resolved yes): unknown claim keys are rejected, a mutation
must name its bottle, and a query that smuggles any id/flag is refused.
- broker verb: `LaunchBroker.list_live` / `SubmitBroker.list_live` return the
backend's live source IPs; a backend enumeration failure is converted to the
single `BrokerUnavailableError` "live set unknown" signal. `DockerBroker`
enumerates its labelled containers; `StubBroker` derives from launches (or a
test override).
- host controller: `POST /broker/live` verifies a signed `list_live` token and
returns `{source_ips}`; `BrokerClient.list_live` is its drop-in client.
- reconcile: `OrchestratorCore.reconcile()` drops the `live_source_ips`
parameter and pulls the live set from the broker itself — the tell that the
orchestrator couldn't see the backend goes away. **Fail-safe**: if the broker
can't return an authoritative set the sweep is skipped, never run against an
empty/partial set (which would reap healthy rows). The `/reconcile` HTTP
contract + `OrchestratorClient.reconcile` become a bare trigger.
The macOS launcher's Apple-container enumeration stays for now; it becomes the
host controller's `list_live` when launch itself moves behind the broker (the
pulled-forward chunk 5, next in the stack).
Tests + pyright clean; pylint 10.0 on broker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
109 lines
4.0 KiB
Python
109 lines
4.0 KiB
Python
"""Docker launch broker (PRD 0070) — the first *real* LaunchBroker.
|
|
|
|
On a verified launch request it starts a Docker container; on teardown it
|
|
removes it. This proves the orchestrator -> backend seam on the cheapest
|
|
backend (the gateway is already containers). Only the request's
|
|
static ids/flags reach `docker`, so nothing free-form crosses the boundary.
|
|
|
|
Slice 3 launches a single container from the request's `image_ref`, named
|
|
after the bottle id and labelled for cleanup. Wiring the full agent +
|
|
gateway (networks, mounts, the consolidated gateway) is a later
|
|
slice — this is the seam, not the finished launcher.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
|
|
from ..backend.docker.util import run_docker
|
|
from .broker import LaunchBroker, LaunchRequest
|
|
|
|
CONTAINER_PREFIX = "bot-bottle-orch-"
|
|
BOTTLE_ID_LABEL = "bot-bottle-bottle-id"
|
|
|
|
|
|
class DockerBrokerError(Exception):
|
|
"""A brokered docker launch/teardown failed (non-zero `docker` exit)."""
|
|
|
|
|
|
def container_name(bottle_id: str) -> str:
|
|
"""Deterministic container name for a bottle."""
|
|
return f"{CONTAINER_PREFIX}{bottle_id}"
|
|
|
|
|
|
def run_argv(req: LaunchRequest) -> list[str]:
|
|
"""`docker run` argv for a launch request — built only from its static
|
|
fields, so it can't be coerced into an arbitrary command."""
|
|
return [
|
|
"docker", "run", "--detach",
|
|
"--name", container_name(req.bottle_id),
|
|
"--label", f"{BOTTLE_ID_LABEL}={req.bottle_id}",
|
|
req.image_ref,
|
|
]
|
|
|
|
|
|
def rm_argv(req: LaunchRequest) -> list[str]:
|
|
"""`docker rm --force` argv to tear a bottle's container down."""
|
|
return ["docker", "rm", "--force", container_name(req.bottle_id)]
|
|
|
|
|
|
class DockerBroker(LaunchBroker):
|
|
"""A `LaunchBroker` that runs / removes a Docker container per bottle.
|
|
Provenance + schema verification are inherited from `LaunchBroker`."""
|
|
|
|
def _docker(self, argv: list[str]) -> subprocess.CompletedProcess[str]:
|
|
return run_docker(argv)
|
|
|
|
def _launch(self, req: LaunchRequest) -> None:
|
|
if not req.image_ref:
|
|
raise DockerBrokerError(f"launch request for {req.bottle_id} has no image_ref")
|
|
proc = self._docker(run_argv(req))
|
|
if proc.returncode != 0:
|
|
raise DockerBrokerError(
|
|
f"docker run failed for {req.bottle_id}: {proc.stderr.strip()}"
|
|
)
|
|
|
|
def _teardown(self, req: LaunchRequest) -> None:
|
|
proc = self._docker(rm_argv(req))
|
|
# Idempotent: an already-absent container is a successful teardown.
|
|
if proc.returncode != 0 and "No such container" not in proc.stderr:
|
|
raise DockerBrokerError(
|
|
f"docker rm failed for {req.bottle_id}: {proc.stderr.strip()}"
|
|
)
|
|
|
|
def _list_live(self) -> list[str]:
|
|
"""Every running bottle container's network IP, from `docker ps` filtered
|
|
to this broker's label. Raises `DockerBrokerError` if the listing or any
|
|
inspect fails, so reconcile treats the live set as unknown and skips the
|
|
sweep rather than reaping healthy rows against a partial enumeration."""
|
|
listing = self._docker([
|
|
"docker", "ps", "--filter", f"label={BOTTLE_ID_LABEL}",
|
|
"--format", "{{.ID}}",
|
|
])
|
|
if listing.returncode != 0:
|
|
raise DockerBrokerError(f"docker ps failed: {listing.stderr.strip()}")
|
|
ips: list[str] = []
|
|
for cid in (line for line in listing.stdout.splitlines() if line):
|
|
inspected = self._docker([
|
|
"docker", "inspect", "--format",
|
|
"{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}", cid,
|
|
])
|
|
if inspected.returncode != 0:
|
|
raise DockerBrokerError(
|
|
f"docker inspect {cid} failed; live set is not authoritative: "
|
|
f"{inspected.stderr.strip()}"
|
|
)
|
|
ips.extend(ip for ip in inspected.stdout.split() if ip)
|
|
return ips
|
|
|
|
|
|
__all__ = [
|
|
"DockerBroker",
|
|
"DockerBrokerError",
|
|
"container_name",
|
|
"run_argv",
|
|
"rm_argv",
|
|
"CONTAINER_PREFIX",
|
|
"BOTTLE_ID_LABEL",
|
|
]
|