ffffe66c95
Addresses review on #519 (@didericis 6143 + the fail-hard direction over the codex skip). Base owns the flow (6143 / ADR 0006). `attach_bottled_agents_to_gateway()` is now a concrete method on `BottleBackend` that delegates to `gateway_attach.reconcile_running_bottles`; backends override only three primitives — `_gateway_attach_resources()`, `_running_bottles()`, `_attach_bottle_to_gateway()`. The shared control flow + error policy live in one place so a backend can't drift onto a bespoke loop or a silent skip. Keeps the reconcile flow + `GatewayAttachResources` out of `backend/base.py` (the size guardrail) in a dedicated `backend/gateway_attach.py`. New ADR 0006 records the "shared behaviour in the base backend, subclasses override primitives" theme. Fail hard, never skip (the maintainer's direction over the codex review's skip). Any attach failure now aborts bring-up instead of being logged and skipped: a bottle that silently can't reach the fresh gateway (its egress just starts failing TLS) is worse than a loud failure. Every bottle is attempted and the failures are raised together (aggregate `InfraLaunchError`) so one bring-up surfaces the full blast radius. Resource gathering (the CA fetch) also fails hard. PRD 0081 goal + design updated to match (reversed from the earlier "tolerate per-bottle failures" draft). Bumps the base.py guardrail cap 580->600 for the new contract surface (the delegator + 3 abstract primitives); the flow itself lives in gateway_attach.py. Refs #516, #519. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
245 lines
9.2 KiB
Python
245 lines
9.2 KiB
Python
"""Consolidated bottle launch sequence for the docker backend (PRD 0070).
|
|
|
|
Composes the orchestrator primitives into the register/teardown sequence:
|
|
|
|
1. ensure the per-host pair (orchestrator + gateway containers) is up;
|
|
2. allocate the bottle a pinned source IP on the gateway network;
|
|
3. register it and provision its git-gate repos/creds into the gateway.
|
|
|
|
Returns a `LaunchContext` with everything the agent container needs to
|
|
attach. The agent `docker run` itself is the backend's job; this owns the
|
|
orchestrator-facing wiring so that sequence stays testable in isolation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
from dataclasses import dataclass
|
|
|
|
from ... import log
|
|
from .util import run_docker
|
|
from ...egress import EgressPlan
|
|
from ...git_gate import GitGatePlan
|
|
from ...orchestrator.client import OrchestratorClient
|
|
from ...gateway import GATEWAY_NETWORK
|
|
from .infra import INFRA_NAME, DockerInfraService
|
|
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
|
|
from ...orchestrator.reprovision import reprovision_bottles
|
|
from ..base import InfraLaunchError
|
|
from ..provision_bottle import deprovision_bottle, provision_bottle
|
|
from ..util import AGENT_CA_PATH
|
|
from .gateway_transport import DockerGatewayTransport
|
|
from .gateway_net import next_free_ip
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LaunchContext:
|
|
"""What the agent container needs to join the shared gateway."""
|
|
|
|
bottle_id: str
|
|
identity_token: str
|
|
source_ip: str # the agent's pinned address (attribution key)
|
|
network: str # the shared gateway network to attach to
|
|
gateway_ip: str # the gateway's address — the agent's proxy target
|
|
orchestrator_url: str
|
|
env_var_secret: str = "" # encryption key injected into the agent's env
|
|
|
|
|
|
def _network_cidr(network: str) -> str:
|
|
"""The gateway network's IPv4 subnet, or raise."""
|
|
proc = run_docker([
|
|
"docker", "network", "inspect",
|
|
"--format", "{{range .IPAM.Config}}{{.Subnet}}{{end}}", network,
|
|
])
|
|
cidr = proc.stdout.strip()
|
|
if proc.returncode != 0 or not cidr:
|
|
raise InfraLaunchError(
|
|
f"gateway network {network} has no subnet: {proc.stderr.strip()}"
|
|
)
|
|
return cidr
|
|
|
|
|
|
def _network_container_ips(network: str) -> list[str]:
|
|
"""Every address currently assigned on the gateway network — the ground
|
|
truth for "in use": the gateway container and every live agent. Read from
|
|
the network so a new bottle can't collide with anything actually attached."""
|
|
proc = run_docker([
|
|
"docker", "network", "inspect", "--format",
|
|
"{{range .Containers}}{{.IPv4Address}} {{end}}", network,
|
|
])
|
|
if proc.returncode != 0:
|
|
detail = proc.stderr.strip() or f"exit {proc.returncode}"
|
|
raise ConsolidatedLaunchError(
|
|
f"could not inspect addresses on gateway network {network}: {detail}"
|
|
)
|
|
ips: list[str] = []
|
|
for entry in proc.stdout.split():
|
|
ips.append(entry.split("/", 1)[0])
|
|
return ips
|
|
|
|
|
|
def running_agent_containers(
|
|
network: str = GATEWAY_NETWORK, gateway_name: str = INFRA_NAME,
|
|
) -> list[str]:
|
|
"""Every running agent container on the gateway `network` (the gateway
|
|
itself excluded) — the bottles the bring-up reconcile attaches to the fresh
|
|
gateway (PRD 0081). Raises `OSError` if `docker` can't be run (fail hard: a
|
|
reconcile that can't list its bottles must not look like "no bottles")."""
|
|
proc = run_docker([
|
|
"docker", "network", "inspect", "--format",
|
|
"{{range .Containers}}{{.Name}}\n{{end}}", network,
|
|
])
|
|
return [
|
|
name for name in (line.strip() for line in proc.stdout.splitlines())
|
|
if name and name != gateway_name
|
|
]
|
|
|
|
|
|
def push_ca_to_container(container: str, ca_pem: str) -> None:
|
|
"""(Re)attach one running agent container to the current gateway: replace
|
|
its trusted gateway CA with `ca_pem` and rebuild its trust store via
|
|
`docker cp` + `docker exec` (PRD 0081). Unconditional install — there is one
|
|
gateway, so no fingerprint match is needed.
|
|
|
|
Fail hard: raises `InfraLaunchError` (naming the container) on any failure —
|
|
the base reconcile aggregates and surfaces it rather than leaving the agent
|
|
silently unable to reach the gateway."""
|
|
mkdir = run_docker(
|
|
["docker", "exec", container, "mkdir", "-p",
|
|
"/usr/local/share/ca-certificates"]
|
|
)
|
|
if mkdir.returncode != 0:
|
|
raise InfraLaunchError(
|
|
f"CA push to {container} failed (mkdir): {mkdir.stderr.strip()}"
|
|
)
|
|
with tempfile.NamedTemporaryFile("w", suffix=".crt") as tmp:
|
|
tmp.write(ca_pem)
|
|
tmp.flush()
|
|
cp = run_docker(["docker", "cp", tmp.name, f"{container}:{AGENT_CA_PATH}"])
|
|
if cp.returncode != 0:
|
|
raise InfraLaunchError(
|
|
f"CA push to {container} failed (cp): {cp.stderr.strip()}"
|
|
)
|
|
ex = run_docker(
|
|
["docker", "exec", container, "sh", "-c",
|
|
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates"]
|
|
)
|
|
if ex.returncode != 0:
|
|
raise InfraLaunchError(
|
|
f"CA push to {container} failed (update-ca-certificates): "
|
|
f"{ex.stderr.strip()}"
|
|
)
|
|
|
|
|
|
def _reprovision_running_bottles(
|
|
orchestrator_url: str,
|
|
network: str = GATEWAY_NETWORK,
|
|
infra_name: str = INFRA_NAME,
|
|
) -> None:
|
|
"""Re-inject egress tokens for any registered bottles that lost their
|
|
in-memory tokens (e.g., after an orchestrator restart).
|
|
|
|
For each registered bottle whose source IP maps to a live container on the
|
|
gateway network, reads ENV_VAR_SECRET via ``docker exec … printenv`` and
|
|
calls ``POST /bottles/<id>/reprovision_gateway``. Idempotent — a no-op
|
|
when the orchestrator already has all tokens loaded. Best-effort: a single
|
|
container exec failure never blocks a new bottle launch."""
|
|
client = OrchestratorClient(orchestrator_url)
|
|
# Build {source_ip: container_name} from live containers on the gateway
|
|
# network, excluding the gateway container itself.
|
|
try:
|
|
proc = run_docker([
|
|
"docker", "network", "inspect",
|
|
"--format", "{{range .Containers}}{{.Name}} {{.IPv4Address}}\n{{end}}",
|
|
network,
|
|
])
|
|
except OSError as exc:
|
|
log.info(f"egress token reprovision skipped: {exc}")
|
|
return
|
|
ip_to_container: dict[str, str] = {}
|
|
for line in proc.stdout.splitlines():
|
|
parts = line.strip().split()
|
|
if len(parts) >= 2 and parts[0] != infra_name:
|
|
ip = parts[1].split("/", 1)[0]
|
|
if ip:
|
|
ip_to_container[ip] = parts[0]
|
|
|
|
secrets_by_ip: dict[str, str] = {}
|
|
for source_ip, container_name in ip_to_container.items():
|
|
proc = run_docker(
|
|
["docker", "exec", container_name, "printenv", ENV_VAR_SECRET_NAME]
|
|
)
|
|
if proc.returncode == 0 and proc.stdout.strip():
|
|
secrets_by_ip[source_ip] = proc.stdout.strip()
|
|
|
|
reprovisioned = reprovision_bottles(client, secrets_by_ip)
|
|
if reprovisioned:
|
|
log.info(
|
|
"reprovisioned egress tokens",
|
|
context={"count": reprovisioned},
|
|
)
|
|
|
|
|
|
def launch_consolidated(
|
|
egress_plan: EgressPlan,
|
|
git_gate_plan: GitGatePlan,
|
|
*,
|
|
image_ref: str = "",
|
|
tokens: dict[str, str] | None = None,
|
|
service: DockerInfraService | None = None,
|
|
infra_name: str = INFRA_NAME,
|
|
network: str = GATEWAY_NETWORK,
|
|
) -> LaunchContext:
|
|
"""Ensure the orchestrator + gateway pair is up, allocate + register the bottle, and
|
|
provision its git-gate state. Returns the agent's attach context.
|
|
|
|
Also reprovisiones egress tokens for any already-running bottles that lost
|
|
their in-memory credentials (e.g. after an orchestrator restart), so
|
|
they regain egress access before the new bottle is registered."""
|
|
service = service or DockerInfraService()
|
|
url = service.ensure_running()
|
|
# Agents attribute against the *gateway* container (data plane), not the
|
|
# orchestrator — the two planes are now separate containers. Read its
|
|
# agent-facing address + provisioning transport off the Gateway service.
|
|
gateway = service.gateway()
|
|
_reprovision_running_bottles(url, network=network, infra_name=gateway.name)
|
|
client = OrchestratorClient(url)
|
|
|
|
cidr = _network_cidr(network)
|
|
gateway_ip = gateway.address()
|
|
source_ip = next_free_ip(cidr, _network_container_ips(network))
|
|
|
|
transport = gateway.provisioning_transport()
|
|
reg = provision_bottle(
|
|
client, source_ip, egress_plan, git_gate_plan, transport,
|
|
image_ref=image_ref, tokens=tokens,
|
|
)
|
|
return LaunchContext(
|
|
bottle_id=reg.bottle_id,
|
|
identity_token=reg.identity_token,
|
|
source_ip=source_ip,
|
|
network=network,
|
|
gateway_ip=gateway_ip,
|
|
orchestrator_url=url,
|
|
env_var_secret=reg.env_var_secret,
|
|
)
|
|
|
|
|
|
def deprovision_consolidated(
|
|
bottle_id: str, *, orchestrator_url: str, infra_name: str = INFRA_NAME,
|
|
timeout: float | None = None,
|
|
) -> None:
|
|
"""Deregister the bottle and remove its git-gate state. Idempotent."""
|
|
deprovision_bottle(bottle_id, DockerGatewayTransport(infra_name),
|
|
orchestrator_url=orchestrator_url, timeout=timeout)
|
|
|
|
|
|
__all__ = [
|
|
"LaunchContext",
|
|
"launch_consolidated",
|
|
"running_agent_containers",
|
|
"push_ca_to_container",
|
|
"deprovision_consolidated",
|
|
"InfraLaunchError",
|
|
]
|