Compare commits

...

2 Commits

Author SHA1 Message Date
didericis eab7d6ccbc feat(orchestrator): slice 13c(viii) — agent-only consolidated compose render
test / unit (pull_request) Successful in 1m2s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m18s
lint / lint (push) Successful in 2m8s
The per-bottle model rendered a compose project with the agent + a sidecar
bundle on two per-bottle networks. Consolidated has no sidecars — one shared
gateway serves everyone — so this renders JUST the agent, attached to the
external shared gateway network at the orchestrator-allocated pinned IP, and
pointed at the gateway's address for egress.

- consolidated_agent_compose(plan, *, gateway_ip, source_ip, network):
  agent service only; networks {<gateway>: {ipv4_address: source_ip}} on the
  external gateway network; HTTPS_PROXY -> http://<gateway_ip>:9099; NO_PROXY
  includes the gateway address so git-http + supervise (also on the gateway)
  bypass the proxy and are reached directly. No depends_on/sidecars.
- Pure (takes the launch-time LaunchContext values), so it's unit-testable
  without docker.

pyright 0 errors; pylint 9.83/10; unit suite green (the 13 test_sidecar_init
/bin/sleep errors are pre-existing NixOS-local noise).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 22:17:11 -04:00
didericis 389a2c10ce feat(orchestrator): slice 13c(vii) — stable shared gateway CA
lint / lint (push) Successful in 2m4s
test / unit (pull_request) Successful in 1m2s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m13s
The consolidated gateway TLS-intercepts every bottle, so all agents must
trust ONE CA. Rather than host-generate + mount a CA, let the gateway's own
mitmproxy generate its CA into a persistent named volume and extract the
cert for agents — no host openssl, keeps gateway.py lean.

- DockerGateway mounts GATEWAY_CA_VOLUME at /home/mitmproxy/.mitmproxy so the
  self-generated CA survives container recreation (it must not rotate, or
  already-launched agents would stop trusting the gateway).
- ca_cert_pem(): read the CA cert (PEM) out of the running gateway
  (docker exec cat mitmproxy-ca-cert.pem) — the agent installs this into its
  trust store to accept the gateway's bumped certs.

pyright 0 errors; pylint 9.83/10; unit suite green (the 13 test_sidecar_init
/bin/sleep errors are pre-existing NixOS-local noise).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 22:12:18 -04:00
4 changed files with 170 additions and 0 deletions
@@ -0,0 +1,78 @@
"""Agent-only compose for the consolidated docker backend (PRD 0070).
The per-bottle model rendered a compose project with the agent *and* a
sidecar bundle on two per-bottle networks. In the consolidated model the
sidecars are gone — one shared gateway serves every bottle — so this renders
just the agent, attached to the **external shared gateway network** with the
pinned source IP the orchestrator allocated, and pointed at the gateway's
address for egress (and, around the proxy, for git-http / supervise).
Pure: it takes the launch-time `LaunchContext` values (gateway address,
source IP, network) and the prepared plan, and returns a compose dict — no
docker, so it's testable in isolation.
"""
from __future__ import annotations
from typing import Any
from ...egress import egress_agent_env_entries
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
from .bottle_plan import DockerBottlePlan
from .egress import EGRESS_PORT
def consolidated_agent_compose(
plan: DockerBottlePlan,
*,
gateway_ip: str,
source_ip: str,
network: str,
) -> dict[str, Any]:
"""A compose spec with only the agent service, on the external gateway
network at `source_ip`, proxying egress through `gateway_ip`."""
proxy_url = f"http://{gateway_ip}:{EGRESS_PORT}"
# git-http + supervise live on the gateway too and must NOT go through the
# egress proxy — the agent reaches them directly by the gateway address.
no_proxy = f"localhost,127.0.0.1,{gateway_ip}"
env: list[str] = [
f"HTTPS_PROXY={proxy_url}",
f"HTTP_PROXY={proxy_url}",
f"https_proxy={proxy_url}",
f"http_proxy={proxy_url}",
f"NO_PROXY={no_proxy}",
f"no_proxy={no_proxy}",
f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}",
f"SSL_CERT_FILE={AGENT_CA_BUNDLE}",
f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}",
]
for name, value in sorted(plan.agent_provision.guest_env.items()):
env.append(f"{name}={value}")
# Forwarded vars: bare name → inherits from the compose-up process env so
# the secret value never lands on argv or in the compose file.
for name in sorted(plan.forwarded_env.keys()):
env.append(name)
env.extend(egress_agent_env_entries(plan.egress_plan))
service: dict[str, Any] = {
"image": plan.image,
"container_name": plan.container_name,
"command": ["sleep", "infinity"],
# Pinned address on the shared gateway network — the orchestrator
# registered this IP, and the gateway attributes the bottle by it.
"networks": {network: {"ipv4_address": source_ip}},
"environment": env,
}
if plan.use_runsc:
service["runtime"] = "runsc"
return {
"name": f"bot-bottle-{plan.slug}",
"services": {"agent": service},
# The gateway network is created + owned by the orchestrator; compose
# attaches to it (external) and must not create or destroy it.
"networks": {network: {"external": True}},
}
__all__ = ["consolidated_agent_compose"]
+24
View File
@@ -31,6 +31,14 @@ GATEWAY_LABEL = "bot-bottle-orch-gateway=1"
# the source IP the gateway attributes by is the address on this network.
GATEWAY_NETWORK = "bot-bottle-gateway"
# mitmproxy's CA dir in the bundle. A persistent named volume here keeps the
# gateway's self-generated CA STABLE across container recreation — every agent
# installs this one CA to trust the shared gateway's TLS interception, so it
# must not rotate when the gateway restarts.
MITMPROXY_HOME = "/home/mitmproxy/.mitmproxy"
GATEWAY_CA_VOLUME = "bot-bottle-gateway-mitmproxy"
GATEWAY_CA_CERT = f"{MITMPROXY_HOME}/mitmproxy-ca-cert.pem"
# The real sidecar-bundle image + its Dockerfile. Kept as a local constant
# rather than imported from backend.docker.sidecar_bundle, which would drag
# the whole backend layer into the lean orchestrator (see #359); unify when
@@ -143,11 +151,26 @@ class DockerGateway(Gateway):
"--name", self.name,
"--label", GATEWAY_LABEL,
"--network", self.network,
# Persist the self-generated CA so it survives restarts (agents
# trust it) — see GATEWAY_CA_VOLUME.
"--volume", f"{GATEWAY_CA_VOLUME}:{MITMPROXY_HOME}",
self.image_ref,
])
if proc.returncode != 0:
raise GatewayError(f"gateway failed to start: {proc.stderr.strip()}")
def ca_cert_pem(self) -> str:
"""The gateway's CA certificate (PEM) that agents install to trust its
TLS interception. Read from the running container; raises if the
gateway hasn't generated it yet (it's created on first mitmproxy
start)."""
proc = run_docker(["docker", "exec", self.name, "cat", GATEWAY_CA_CERT])
if proc.returncode != 0 or not proc.stdout.strip():
raise GatewayError(
f"gateway CA cert not available: {proc.stderr.strip() or 'empty'}"
)
return proc.stdout
def stop(self) -> None:
proc = run_docker(["docker", "rm", "--force", self.name])
if proc.returncode != 0 and "No such container" not in proc.stderr:
@@ -157,4 +180,5 @@ class DockerGateway(Gateway):
__all__ = [
"Gateway", "DockerGateway", "GatewayError",
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", "GATEWAY_NETWORK",
"GATEWAY_CA_VOLUME", "GATEWAY_CA_CERT",
]
+51
View File
@@ -0,0 +1,51 @@
"""Unit: agent-only consolidated compose render (PRD 0070)."""
from __future__ import annotations
import unittest
from bot_bottle.backend.docker.consolidated_compose import consolidated_agent_compose
from tests.unit.test_compose import _plan
_GW = "172.18.0.2"
_IP = "172.18.0.5"
_NET = "bot-bottle-gateway"
class TestConsolidatedAgentCompose(unittest.TestCase):
def _spec(self, *, runsc: bool = False):
plan = _plan(with_egress=True, supervise=True, with_git=True)
if runsc:
plan = type(plan)(**{**vars(plan), "use_runsc": True}) # type: ignore[arg-type]
return consolidated_agent_compose(plan, gateway_ip=_GW, source_ip=_IP, network=_NET)
def test_only_agent_service_no_sidecars(self) -> None:
# The whole point of consolidation: no per-bottle sidecar bundle.
self.assertEqual(["agent"], list(self._spec()["services"]))
def test_agent_pinned_on_external_gateway_network(self) -> None:
spec = self._spec()
self.assertEqual({"external": True}, spec["networks"][_NET])
agent_net = spec["services"]["agent"]["networks"][_NET]
self.assertEqual(_IP, agent_net["ipv4_address"])
def test_proxy_and_ca_point_at_gateway(self) -> None:
env = self._spec()["services"]["agent"]["environment"]
self.assertIn(f"HTTPS_PROXY=http://{_GW}:9099", env)
# git-http + supervise on the gateway must bypass the egress proxy.
self.assertTrue(any(e.startswith("NO_PROXY=") and _GW in e for e in env))
def test_no_sidecar_dependency(self) -> None:
self.assertNotIn("depends_on", self._spec()["services"]["agent"])
def test_runsc_runtime_when_enabled(self) -> None:
self.assertEqual("runsc", self._spec(runsc=True)["services"]["agent"]["runtime"])
def test_forwarded_env_stays_bare_names(self) -> None:
env = self._spec()["services"]["agent"]["environment"]
# forwarded secrets are bare names (value inherited from process env).
self.assertIn("CLAUDE_CODE_OAUTH_TOKEN", env)
if __name__ == "__main__":
unittest.main()
+17
View File
@@ -6,11 +6,15 @@ import unittest
from unittest.mock import Mock, patch
from bot_bottle.orchestrator.gateway import (
GATEWAY_CA_CERT,
GATEWAY_NAME,
DockerGateway,
GatewayError,
)
_CA_PEM = "-----BEGIN CERTIFICATE-----\nMII...\n-----END CERTIFICATE-----\n"
_RUN_DOCKER = "bot_bottle.orchestrator.gateway.run_docker"
@@ -53,6 +57,8 @@ class TestDockerGateway(unittest.TestCase):
self.assertIn("bot-bottle-sidecars:latest", runs[0])
# Runs on the shared gateway network so agents can reach it by IP.
self.assertEqual(self.sc.network, runs[0][runs[0].index("--network") + 1])
# Persists its CA on a named volume so agents keep trusting it.
self.assertTrue(any("mitmproxy" in a for a in runs[0]))
def test_ensure_running_creates_network_when_missing(self) -> None:
calls: list[list[str]] = []
@@ -68,6 +74,17 @@ class TestDockerGateway(unittest.TestCase):
creates = [c for c in calls if c[:3] == ["docker", "network", "create"]]
self.assertEqual([["docker", "network", "create", self.sc.network]], creates)
def test_ca_cert_pem_reads_from_container(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(stdout=_CA_PEM)) as m:
self.assertEqual(_CA_PEM, self.sc.ca_cert_pem())
argv = m.call_args.args[0]
self.assertEqual(["docker", "exec", self.sc.name, "cat", GATEWAY_CA_CERT], argv)
def test_ca_cert_pem_raises_when_absent(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(returncode=1, stderr="No such file")):
with self.assertRaises(GatewayError):
self.sc.ca_cert_pem()
def test_ensure_running_reuses_existing_network(self) -> None:
calls: list[list[str]] = []