diff --git a/bot_bottle/backend/docker/consolidated_compose.py b/bot_bottle/backend/docker/consolidated_compose.py new file mode 100644 index 0000000..72ab584 --- /dev/null +++ b/bot_bottle/backend/docker/consolidated_compose.py @@ -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"] diff --git a/tests/unit/test_consolidated_compose.py b/tests/unit/test_consolidated_compose.py new file mode 100644 index 0000000..f463d33 --- /dev/null +++ b/tests/unit/test_consolidated_compose.py @@ -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()