From eab7d6ccbc15306d24f65685d37fda10bf826143 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 13 Jul 2026 22:17:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(orchestrator):=20slice=2013c(viii)=20?= =?UTF-8?q?=E2=80=94=20agent-only=20consolidated=20compose=20render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 {: {ipv4_address: source_ip}} on the external gateway network; HTTPS_PROXY -> http://: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 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- .../backend/docker/consolidated_compose.py | 78 +++++++++++++++++++ tests/unit/test_consolidated_compose.py | 51 ++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 bot_bottle/backend/docker/consolidated_compose.py create mode 100644 tests/unit/test_consolidated_compose.py 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()