From 90c69fb30b157cb4b7c9e445809f374b55c0fa48 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 13 Jul 2026 21:55:10 -0400 Subject: [PATCH] =?UTF-8?q?feat(orchestrator):=20slice=2013c(iv)=20?= =?UTF-8?q?=E2=80=94=20gateway=20joins=20a=20shared=20network?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consolidated gateway ran on the default bridge; the model needs it on a dedicated user-defined network that every agent bottle also joins, reaching the gateway's egress / git-http / supervise ports by its address (no host port publishing) — and the source IP the gateway attributes by is the bottle's address on this network. - GATEWAY_NETWORK = "bot-bottle-gateway"; DockerGateway gains a `network` param. - ensure_running now ensures the network exists (idempotent create, tolerating a concurrent 'already exists') then runs with `--network`. - Docker picks the subnet; the launcher reads it back (13c source-IP allocator) to hand each bottle a pinned address. 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 --- bot_bottle/orchestrator/gateway.py | 23 ++++++++++++++++++++- tests/unit/test_orchestrator_gateway.py | 27 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/bot_bottle/orchestrator/gateway.py b/bot_bottle/orchestrator/gateway.py index f6a7aae..e153721 100644 --- a/bot_bottle/orchestrator/gateway.py +++ b/bot_bottle/orchestrator/gateway.py @@ -25,6 +25,11 @@ from ..docker_cmd import run_docker GATEWAY_NAME = "bot-bottle-orch-gateway" GATEWAY_LABEL = "bot-bottle-orch-gateway=1" +# The single user-defined network the gateway and every agent bottle share. +# Agents attach here with a pinned IP and reach the gateway's egress / +# git-http / supervise ports by its address — no host port publishing, and +# the source IP the gateway attributes by is the address on this network. +GATEWAY_NETWORK = "bot-bottle-gateway" # The real sidecar-bundle image + its Dockerfile. Kept as a local constant # rather than imported from backend.docker.sidecar_bundle, which would drag @@ -77,11 +82,13 @@ class DockerGateway(Gateway): image_ref: str = GATEWAY_IMAGE, *, name: str = GATEWAY_NAME, + network: str = GATEWAY_NETWORK, build_context: Path | None = None, dockerfile: str | None = GATEWAY_DOCKERFILE, ) -> None: self.image_ref = image_ref self.name = name + self.network = network self._build_context = build_context or _REPO_ROOT self._dockerfile = dockerfile @@ -112,9 +119,22 @@ class DockerGateway(Gateway): ]) return self.name in proc.stdout.split() + def _ensure_network(self) -> None: + """Create the shared gateway network if it doesn't exist. Idempotent — + a concurrent create loses harmlessly (the loser sees 'already exists'). + Docker picks the subnet; the launcher reads it back to allocate IPs.""" + if run_docker(["docker", "network", "inspect", self.network]).returncode == 0: + return + proc = run_docker(["docker", "network", "create", self.network]) + if proc.returncode != 0 and "already exists" not in proc.stderr: + raise GatewayError( + f"gateway network {self.network} failed to create: {proc.stderr.strip()}" + ) + def ensure_running(self) -> None: if self.is_running(): return + self._ensure_network() # Clear any stale (stopped) container holding the fixed name, then # start fresh. `rm --force` on an absent name is a tolerated no-op. run_docker(["docker", "rm", "--force", self.name]) @@ -122,6 +142,7 @@ class DockerGateway(Gateway): "docker", "run", "--detach", "--name", self.name, "--label", GATEWAY_LABEL, + "--network", self.network, self.image_ref, ]) if proc.returncode != 0: @@ -135,5 +156,5 @@ class DockerGateway(Gateway): __all__ = [ "Gateway", "DockerGateway", "GatewayError", - "GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", + "GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", "GATEWAY_NETWORK", ] diff --git a/tests/unit/test_orchestrator_gateway.py b/tests/unit/test_orchestrator_gateway.py index ca9a8be..6a9d56a 100644 --- a/tests/unit/test_orchestrator_gateway.py +++ b/tests/unit/test_orchestrator_gateway.py @@ -51,6 +51,33 @@ class TestDockerGateway(unittest.TestCase): self.assertEqual(1, len(runs)) self.assertIn(self.sc.name, runs[0]) 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]) + + def test_ensure_running_creates_network_when_missing(self) -> None: + calls: list[list[str]] = [] + + def fake(argv: list[str]) -> Mock: + calls.append(argv) + if argv[:3] == ["docker", "network", "inspect"]: + return _proc(returncode=1, stderr="No such network") + return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc() + + with patch(_RUN_DOCKER, side_effect=fake): + self.sc.ensure_running() + creates = [c for c in calls if c[:3] == ["docker", "network", "create"]] + self.assertEqual([["docker", "network", "create", self.sc.network]], creates) + + def test_ensure_running_reuses_existing_network(self) -> None: + calls: list[list[str]] = [] + + def fake(argv: list[str]) -> Mock: + calls.append(argv) + return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc() + + with patch(_RUN_DOCKER, side_effect=fake): + self.sc.ensure_running() # network inspect returns 0 → exists + self.assertEqual([], [c for c in calls if c[:3] == ["docker", "network", "create"]]) def test_ensure_running_raises_on_docker_failure(self) -> None: def fake(argv: list[str]) -> Mock: