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: