diff --git a/bot_bottle/backend/docker/consolidated_launch.py b/bot_bottle/backend/docker/consolidated_launch.py index 8b5c53eb..391f545d 100644 --- a/bot_bottle/backend/docker/consolidated_launch.py +++ b/bot_bottle/backend/docker/consolidated_launch.py @@ -61,20 +61,6 @@ def _network_cidr(network: str) -> str: return cidr -def _container_ip(name: str, network: str) -> str: - """A container's IPv4 address on `network`, or raise.""" - proc = run_docker([ - "docker", "inspect", "--format", - f'{{{{(index .NetworkSettings.Networks "{network}").IPAddress}}}}', name, - ]) - ip = proc.stdout.strip() - if proc.returncode != 0 or not ip: - raise ConsolidatedLaunchError( - f"container {name} has no address on {network}: {proc.stderr.strip()}" - ) - return ip - - def _network_container_ips(network: str) -> list[str]: """Every address currently assigned on the gateway network — the ground truth for "in use": the infra container and every live agent. Read from @@ -157,16 +143,17 @@ def launch_consolidated( service = service or DockerInfraService() url = service.ensure_running() # Agents attribute against the *gateway* container (data plane), not the - # orchestrator — the two planes are now separate containers. - gateway_name = service.gateway_name - _reprovision_running_bottles(url, network=network, infra_name=gateway_name) + # orchestrator — the two planes are now separate containers. Read its + # agent-facing address + provisioning transport off the Gateway service. + gateway = service.gateway() + _reprovision_running_bottles(url, network=network, infra_name=gateway.name) client = OrchestratorClient(url) cidr = _network_cidr(network) - gateway_ip = _container_ip(gateway_name, network) + gateway_ip = gateway.address() source_ip = next_free_ip(cidr, _network_container_ips(network)) - transport = DockerGatewayTransport(gateway_name) + transport = gateway.provisioning_transport() reg = provision_bottle( client, source_ip, egress_plan, git_gate_plan, transport, image_ref=image_ref, tokens=tokens, diff --git a/bot_bottle/backend/docker/gateway.py b/bot_bottle/backend/docker/gateway.py index f188ed77..a0de4674 100644 --- a/bot_bottle/backend/docker/gateway.py +++ b/bot_bottle/backend/docker/gateway.py @@ -4,17 +4,16 @@ import os import time from pathlib import Path -from ...orchestrator_auth import ROLE_GATEWAY, mint from .util import run_docker +from .gateway_provision import DockerGatewayTransport from ...paths import ( ORCHESTRATOR_AUTH_JWT_ENV, - host_orchestrator_token, host_gateway_ca_dir, ) from ...gateway import ( - Gateway, GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK, GATEWAY_DOCKERFILE, - REPO_ROOT, GATEWAY_LABEL, MITMPROXY_HOME, DEFAULT_CA_TIMEOUT_SECONDS, - CA_POLL_SECONDS, GATEWAY_CA_CERT, GatewayError + Gateway, GatewayTransport, GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK, + GATEWAY_DOCKERFILE, REPO_ROOT, GATEWAY_LABEL, MITMPROXY_HOME, + DEFAULT_CA_TIMEOUT_SECONDS, CA_POLL_SECONDS, GATEWAY_CA_CERT, GatewayError ) class DockerGateway(Gateway): @@ -32,7 +31,6 @@ class DockerGateway(Gateway): name: str = GATEWAY_NAME, network: str = GATEWAY_NETWORK, control_network: str = "", - orchestrator_url: str = "", build_context: Path | None = None, dockerfile: str | None = GATEWAY_DOCKERFILE, host_port_bindings: tuple[int, ...] = (), @@ -45,13 +43,13 @@ class DockerGateway(Gateway): # `orchestrator_url` by container name over docker DNS. Agents are never # on it, so they get no route to the control plane (PRD 0070). self._control_network = control_network - # The control-plane URL the gateway's data plane resolves per bottle - # against — reached by container name over docker DNS on the shared - # network (container↔container, no host firewall). Mandatory to *run* - # the gateway (see `ensure_running`); empty is tolerated only for the - # construct-then-read-CA path (`ca_cert_pem` on an already-running - # container), which never launches a container. - self._orchestrator_url = orchestrator_url + # The orchestrator binding — set by `connect_to_orchestrator` (the + # control-plane URL the data plane resolves against, and the pre-minted + # `gateway` token it presents; the gateway never mints, so it never + # holds the signing key — #469). Empty until connected; `ca_cert_pem` / + # `address` / `stop` work on an already-running gateway without it. + self._orchestrator_url = "" + self._gateway_token = "" self._build_context = build_context or REPO_ROOT self._dockerfile = dockerfile # Ports published on the host (0.0.0.0). Used by the Firecracker @@ -116,7 +114,13 @@ class DockerGateway(Gateway): f"gateway network {self.network} failed to create: {proc.stderr.strip()}" ) - def ensure_running(self) -> None: + def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None: + # Bind to this orchestrator (PRD 0070): stash the URL its daemons resolve + # policy against + the pre-minted `gateway` token they present, then bring + # the container up. The gateway never mints, so it holds no signing key — + # only this token (#469). + self._orchestrator_url = orchestrator_url + self._gateway_token = gateway_token # Fail closed on a missing policy source. The data-plane daemons are # resolver-only now (PRD 0070) — without an orchestrator URL egress # raises, git-http exits 1, and supervise exits 2 — so launching a @@ -127,6 +131,11 @@ class DockerGateway(Gateway): "gateway requires an orchestrator URL to run " "(resolver-only data plane; no single-tenant fallback)" ) + if not self._gateway_token: + raise GatewayError( + "gateway requires a pre-minted `gateway` token to run " + "(the orchestrator mints it; the gateway never holds the key)" + ) # Recreate when the running container's image is stale (a rebuild), # so source changes to the gateway's flat daemons take effect — not # just when the container is absent. @@ -165,7 +174,7 @@ class DockerGateway(Gateway): # operator routes (issue #469 review). Bare `--env NAME` keeps the value # off argv / `docker inspect`; only the gateway (not the agent) is given it. argv += ["--env", ORCHESTRATOR_AUTH_JWT_ENV] - run_env[ORCHESTRATOR_AUTH_JWT_ENV] = mint(ROLE_GATEWAY, host_orchestrator_token()) + run_env[ORCHESTRATOR_AUTH_JWT_ENV] = self._gateway_token argv.append(self.image_ref) proc = run_docker(argv, env=run_env) if proc.returncode != 0: @@ -216,4 +225,28 @@ class DockerGateway(Gateway): def stop(self) -> None: proc = run_docker(["docker", "rm", "--force", self.name]) if proc.returncode != 0 and "No such container" not in proc.stderr: - raise GatewayError(f"gateway failed to stop: {proc.stderr.strip()}") \ No newline at end of file + raise GatewayError(f"gateway failed to stop: {proc.stderr.strip()}") + + def address(self) -> str: + """The gateway's IPv4 on the agent-facing network (`self.network`) — the + proxy target agents dial for egress / git-http / supervise. This is + *not* the control network: agents share only this network with the + gateway, so its address here is also the source IP the gateway + attributes each request by.""" + proc = run_docker([ + "docker", "inspect", "--format", + f'{{{{(index .NetworkSettings.Networks "{self.network}").IPAddress}}}}', + self.name, + ]) + ip = proc.stdout.strip() + if proc.returncode != 0 or not ip: + raise GatewayError( + f"gateway {self.name} has no address on {self.network}: " + f"{proc.stderr.strip()}" + ) + return ip + + def provisioning_transport(self) -> GatewayTransport: + """The exec/cp transport git-gate provisioning stages per-bottle repos + + deploy keys through (over the docker socket).""" + return DockerGatewayTransport(self.name) \ No newline at end of file diff --git a/bot_bottle/backend/docker/gateway_provision.py b/bot_bottle/backend/docker/gateway_provision.py index 3eaadcbc..2b579130 100644 --- a/bot_bottle/backend/docker/gateway_provision.py +++ b/bot_bottle/backend/docker/gateway_provision.py @@ -15,10 +15,10 @@ bottle's push credentials out of another's repos on the shared gateway. from __future__ import annotations import re -from typing import Protocol from .util import run_docker from ...git_gate import GitGatePlan, git_gate_render_provision +from ...gateway import GatewayProvisionError, GatewayTransport # bottle ids index the gateway's per-bottle repo + creds dirs; they land in # exec/cp path arguments, so validate before any path is built (a traversal @@ -27,25 +27,6 @@ from ...git_gate import GitGatePlan, git_gate_render_provision _SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+") -class GatewayProvisionError(RuntimeError): - """A git-gate provisioning step against the running gateway failed.""" - - -class GatewayTransport(Protocol): - """How the launcher stages files + runs commands in the running gateway. - Backend-neutral so the same provisioning logic serves the docker gateway - (exec/cp over the docker socket) and the firecracker gateway VM (over - SSH).""" - - def exec(self, argv: list[str]) -> None: - """Run `argv` in the gateway, raising `GatewayProvisionError` on - failure.""" - - def cp_into(self, src: str, dest: str) -> None: - """Copy host file `src` to `dest` in the gateway, raising on - failure.""" - - class DockerGatewayTransport: """`GatewayTransport` for the docker gateway container (exec/cp).""" diff --git a/bot_bottle/backend/docker/infra.py b/bot_bottle/backend/docker/infra.py index 48da3356..097964fb 100644 --- a/bot_bottle/backend/docker/infra.py +++ b/bot_bottle/backend/docker/infra.py @@ -42,6 +42,7 @@ from ...gateway import ( GATEWAY_NETWORK, GatewayError, ) +from ...orchestrator_auth import ROLE_GATEWAY, mint from ...orchestrator.lifecycle import ( DEFAULT_PORT, DEFAULT_STARTUP_TIMEOUT_SECONDS, @@ -223,15 +224,17 @@ class DockerInfraService: f"orchestrator container failed to start: {proc.stderr.strip()}" ) - def _gateway(self) -> DockerGateway: + def gateway(self) -> DockerGateway: """The data-plane gateway, dual-homed on the agent network + the control - network, resolving policy against the orchestrator by name.""" + network, resolving policy against the orchestrator by name. Cheap to + reconstruct — the launch flow reads its `address()` / provisioning + transport off it, and `ensure_running` connects it to the control + plane.""" return DockerGateway( self.gateway_image, name=self._gateway_name, network=self.network, control_network=self.control_network, - orchestrator_url=ORCHESTRATOR_URL_IN_NETWORK, build_context=self._repo_root, dockerfile=None, # images are built by _build_images ) @@ -265,8 +268,14 @@ class DockerInfraService: ) # Bring up (or refresh) the gateway once the control plane it resolves - # against is healthy. `ensure_running` is itself idempotent. - self._gateway().ensure_running() + # against is healthy. The orchestrator (which holds the signing key) + # mints the role-scoped `gateway` JWT here and hands it to the gateway, + # which never sees the key (#469). `connect_to_orchestrator` is + # idempotent. + gateway_token = mint(ROLE_GATEWAY, host_orchestrator_token()) + self.gateway().connect_to_orchestrator( + ORCHESTRATOR_URL_IN_NETWORK, gateway_token, + ) return self.url def stop(self) -> None: diff --git a/bot_bottle/gateway/__init__.py b/bot_bottle/gateway/__init__.py index a1ad85ab..568ba7c5 100644 --- a/bot_bottle/gateway/__init__.py +++ b/bot_bottle/gateway/__init__.py @@ -6,14 +6,15 @@ because the attribution invariant (source IP + identity token, see `registry`) lets the gateway attribute each request to the right bottle — so per-bottle policy lives in one long-lived process keyed on who's calling. -`Gateway` is the backend-neutral lifecycle contract (mirrors `LaunchBroker`): -ensure the single instance is up, report it, tear it down. The docker -implementation (`DockerGateway`) lives in `backend/docker/gateway.py`; a -firecracker gateway VM slots in later. +`Gateway` is the backend-neutral service contract: bind the single instance to +its orchestrator and bring it up (`connect_to_orchestrator`), report its +agent-facing address + CA, vend the provisioning transport, tear it down. The +docker implementation (`DockerGateway`) lives in `backend/docker/gateway.py`; +the macOS + firecracker gateways slot in beside it. -The defining behaviour is **idempotent singleton**: `ensure_running` starts -the instance if absent and is a no-op if it's already up, so N bottle -launches never spawn N gateways. +The defining behaviour is **idempotent singleton**: `connect_to_orchestrator` +starts the instance if absent and is a no-op if it's already up on the same +binding, so N bottle launches never spawn N gateways. """ from __future__ import annotations @@ -21,6 +22,7 @@ from __future__ import annotations import abc import os from pathlib import Path +from typing import Protocol from ..paths import host_gateway_ca_dir @@ -87,21 +89,46 @@ class GatewayError(Exception): """The shared gateway failed to build/start/stop (non-zero `docker` exit).""" +class GatewayProvisionError(RuntimeError): + """A git-gate provisioning step against the running gateway failed.""" + + +class GatewayTransport(Protocol): + """How the launcher stages files + runs commands in the running gateway. + Backend-neutral so the same git-gate provisioning logic serves the docker + gateway container (exec/cp over the docker socket), the Apple gateway + container, and the firecracker gateway VM (over SSH).""" + + def exec(self, argv: list[str]) -> None: + """Run `argv` in the gateway, raising `GatewayProvisionError` on failure.""" + + def cp_into(self, src: str, dest: str) -> None: + """Copy host file `src` to `dest` in the gateway, raising on failure.""" + + class Gateway(abc.ABC): - """Lifecycle of the single per-host gateway. Backend-neutral.""" + """Provision + interact with the per-host gateway (data plane). + + One concrete impl per backend (`backend/*/gateway.py`); the host composes it + with the Orchestrator service. The gateway **never holds the signing key** — + it receives a pre-minted `gateway` token from the orchestrator and only + presents it. Backend-neutral.""" name: str def ensure_built(self) -> None: """Ensure the gateway's image / rootfs exists, building it if needed. - Default: nothing to build (e.g. a stub or a pre-pulled image).""" + Default: nothing to build (e.g. a stub or a pre-pulled image). Call + before `connect_to_orchestrator`.""" return @abc.abstractmethod - def ensure_running(self) -> None: - """Start the gateway if it isn't already up. Idempotent: a no-op - when it's already running (that's the whole point — one per host). - Assumes the image exists — call `ensure_built()` first.""" + def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None: + """Bind the gateway to this orchestrator and bring it up: store the URL + + the pre-minted `gateway` token as instance state, then (re)start the + gateway unit carrying the mitmproxy CA + that token, resolving policy + against `orchestrator_url`. Idempotent — a healthy, current gateway on + the same binding is left alone; a changed binding reconciles it.""" @abc.abstractmethod def is_running(self) -> bool: @@ -111,9 +138,25 @@ class Gateway(abc.ABC): def stop(self) -> None: """Remove the gateway. Idempotent — absent is success.""" + @abc.abstractmethod + def address(self) -> str: + """The agent-facing address agents dial for egress / git-http / supervise + (today's `gateway_ip`).""" + + @abc.abstractmethod + def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str: + """The mitmproxy CA (PEM) agents install to trust the gateway's TLS + interception. Polls — mitmproxy writes it a beat after start.""" + + @abc.abstractmethod + def provisioning_transport(self) -> "GatewayTransport": + """The cp/exec transport git-gate provisioning uses to place per-bottle + repos + deploy keys into the running gateway.""" + __all__ = [ - "Gateway", "GatewayError", "rotate_gateway_ca", + "Gateway", "GatewayError", "GatewayProvisionError", "GatewayTransport", + "rotate_gateway_ca", "GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", "GATEWAY_NETWORK", "GATEWAY_CA_CERT", "GATEWAY_CA_GLOB", ] diff --git a/tests/integration/test_orchestrator_docker_gateway.py b/tests/integration/test_orchestrator_docker_gateway.py index 7afef200..34333653 100644 --- a/tests/integration/test_orchestrator_docker_gateway.py +++ b/tests/integration/test_orchestrator_docker_gateway.py @@ -20,13 +20,15 @@ IMAGE = "busybox" class TestDockerGatewayIntegration(unittest.TestCase): def setUp(self) -> None: self.name = "bot-bottle-orch-gateway-itest-" + secrets.token_hex(4) - # Resolver-only data plane (PRD 0070) requires an orchestrator URL to - # run; busybox never dials it, so a placeholder is enough here. - self.sc = DockerGateway( - IMAGE, name=self.name, orchestrator_url="http://orchestrator:9000", - ) + self.sc = DockerGateway(IMAGE, name=self.name) self.addCleanup(self.sc.stop) + def _connect(self) -> None: + # Resolver-only data plane (PRD 0070) requires an orchestrator URL + a + # pre-minted `gateway` token to run; busybox never dials the orchestrator + # or presents the token, so placeholders are enough here. + self.sc.connect_to_orchestrator("http://orchestrator:9000", "placeholder-token") + def _count(self) -> int: proc = subprocess.run( ["docker", "ps", "-a", "--filter", f"name=^{self.name}$", @@ -36,10 +38,10 @@ class TestDockerGatewayIntegration(unittest.TestCase): return sum(1 for n in proc.stdout.split() if n == self.name) def test_ensure_is_idempotent_singleton(self) -> None: - self.sc.ensure_running() + self._connect() self.assertEqual(1, self._count()) - # A second ensure must not spawn a second container — one per host. - self.sc.ensure_running() + # A second connect must not spawn a second container — one per host. + self._connect() self.assertEqual(1, self._count()) self.sc.stop() self.assertEqual(0, self._count()) diff --git a/tests/unit/test_consolidated_launch.py b/tests/unit/test_consolidated_launch.py index f31eb906..50b8243c 100644 --- a/tests/unit/test_consolidated_launch.py +++ b/tests/unit/test_consolidated_launch.py @@ -46,8 +46,12 @@ class TestLaunchConsolidated(unittest.TestCase): ): service = MagicMock() service.ensure_running.return_value = "http://orch:8080" + # The launch flow reads the gateway's agent-facing address + provisioning + # transport off the Gateway service (service.gateway()). + gateway = service.gateway.return_value + gateway.name = "bot-bottle-gateway" + gateway.address.return_value = "172.18.0.2" with patch(f"{_MOD}._network_cidr", return_value="172.18.0.0/16"), \ - patch(f"{_MOD}._container_ip", return_value="172.18.0.2"), \ patch(f"{_MOD}._network_container_ips", return_value=list(on_network)), \ patch(f"{_MOD}.OrchestratorClient", return_value=client), \ patch(f"{_UTIL}.provision_git_gate", provision or Mock()): diff --git a/tests/unit/test_docker_infra.py b/tests/unit/test_docker_infra.py index 1adc0931..157d0bfb 100644 --- a/tests/unit/test_docker_infra.py +++ b/tests/unit/test_docker_infra.py @@ -2,7 +2,7 @@ `DockerInfraService` brings up two containers — the lean orchestrator on the `--internal` control network + host loopback, and the dual-homed gateway. These -tests isolate the orchestrator-container logic by mocking `_gateway` (the +tests isolate the orchestrator-container logic by mocking `gateway` (the gateway runs via `DockerGateway`, exercised in its own test).""" from __future__ import annotations @@ -52,7 +52,7 @@ class TestDockerInfraService(unittest.TestCase): # Isolate the orchestrator-container logic: the gateway is brought up by # DockerGateway (its own module/run_docker), tested separately. self.gw = MagicMock() - patcher = patch.object(self.svc, "_gateway", return_value=self.gw) + patcher = patch.object(self.svc, "gateway", return_value=self.gw) patcher.start() self.addCleanup(patcher.stop) @@ -86,7 +86,7 @@ class TestDockerInfraService(unittest.TestCase): self.assertEqual(self.svc.url, self.svc.ensure_running()) runs = [c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "run"]] self.assertEqual([], runs) - self.gw.ensure_running.assert_called_once_with() + self.gw.connect_to_orchestrator.assert_called_once() def test_recreates_orchestrator_when_source_changed(self) -> None: def fake(argv: list[str], **_kw: object) -> Mock: @@ -129,7 +129,7 @@ class TestDockerInfraService(unittest.TestCase): self.assertIn("--broker", argv) self.assertIn("stub", argv) # Gateway is brought up after the control plane is healthy. - self.gw.ensure_running.assert_called_once_with() + self.gw.connect_to_orchestrator.assert_called_once() def test_builds_gateway_and_orchestrator_images(self) -> None: def fake(argv: list[str], **_kw: object) -> Mock: @@ -154,7 +154,7 @@ class TestDockerInfraService(unittest.TestCase): return _proc() svc = DockerInfraService(port=20001) - with patch.object(svc, "_gateway", return_value=MagicMock()), \ + with patch.object(svc, "gateway", return_value=MagicMock()), \ patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \ patch(_RUN, side_effect=fake) as run, patch(_SLEEP): svc.ensure_running() diff --git a/tests/unit/test_orchestrator_gateway.py b/tests/unit/test_orchestrator_gateway.py index b23162e6..bf397982 100644 --- a/tests/unit/test_orchestrator_gateway.py +++ b/tests/unit/test_orchestrator_gateway.py @@ -28,6 +28,7 @@ def _proc(returncode: int = 0, stdout: str = "", stderr: str = "") -> Mock: _ORCH_URL = "http://orchestrator:9000" +_TOKEN = "gw-jwt-token" class TestDockerGateway(unittest.TestCase): @@ -39,18 +40,27 @@ class TestDockerGateway(unittest.TestCase): self.addCleanup(use_bottle_root(Path(self._tmp.name))) # Resolver-only data plane (PRD 0070): running the gateway requires an # orchestrator URL, so the fixture supplies one. - self.sc = DockerGateway("bot-bottle-gateway:latest", orchestrator_url=_ORCH_URL) + self.sc = DockerGateway("bot-bottle-gateway:latest") def test_default_name(self) -> None: self.assertEqual(GATEWAY_NAME, self.sc.name) - def test_ensure_running_refuses_without_orchestrator_url(self) -> None: + def test_connect_refuses_without_orchestrator_url(self) -> None: # No policy source → the data-plane daemons would only crash-loop, so # the launch must fail closed with a clear error rather than start one. sc = DockerGateway("bot-bottle-gateway:latest") with patch(_RUN_DOCKER) as m: with self.assertRaises(GatewayError): - sc.ensure_running() + sc.connect_to_orchestrator("", _TOKEN) + m.assert_not_called() + + def test_connect_refuses_without_gateway_token(self) -> None: + # The gateway never mints — it must be handed a pre-minted `gateway` + # token. Missing one is a misconfiguration, so fail closed before start. + sc = DockerGateway("bot-bottle-gateway:latest") + with patch(_RUN_DOCKER) as m: + with self.assertRaises(GatewayError): + sc.connect_to_orchestrator(_ORCH_URL, "") m.assert_not_called() def test_is_running_reads_docker_ps(self) -> None: @@ -73,7 +83,7 @@ class TestDockerGateway(unittest.TestCase): return _proc() with patch(_RUN_DOCKER, side_effect=fake): - self.sc.ensure_running() + self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN) self.assertEqual([], [c for c in calls if c[:2] == ["docker", "run"]]) self.assertEqual([], [c for c in calls if c[:2] == ["docker", "rm"]]) @@ -93,7 +103,7 @@ class TestDockerGateway(unittest.TestCase): return _proc() with patch(_RUN_DOCKER, side_effect=fake): - self.sc.ensure_running() + self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN) self.assertEqual(1, len([c for c in calls if c[:2] == ["docker", "run"]])) self.assertTrue(any(c[:2] == ["docker", "rm"] for c in calls)) @@ -105,7 +115,7 @@ class TestDockerGateway(unittest.TestCase): return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc() with patch(_RUN_DOCKER, side_effect=fake): - self.sc.ensure_running() + self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN) runs = [c for c in calls if c[:2] == ["docker", "run"]] self.assertEqual(1, len(runs)) @@ -132,6 +142,45 @@ class TestDockerGateway(unittest.TestCase): # Data plane resolves policy against the orchestrator control plane. self.assertIn(f"BOT_BOTTLE_ORCHESTRATOR_URL={_ORCH_URL}", runs[0]) + def test_connect_injects_the_pre_minted_gateway_token(self) -> None: + # The gateway presents the token the orchestrator handed it — it never + # mints (holds no signing key). The value rides the env (bare `--env + # NAME` on argv keeps it off `docker inspect`), not argv itself. + from bot_bottle.paths import ORCHESTRATOR_AUTH_JWT_ENV + + run_env: dict[str, str] = {} + + def fake(argv: list[str], **kw: object) -> Mock: + if argv[:2] == ["docker", "run"]: + env = kw.get("env") + if isinstance(env, dict): + run_env.update(env) + return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc() + + with patch(_RUN_DOCKER, side_effect=fake): + self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN) + self.assertEqual(_TOKEN, run_env[ORCHESTRATOR_AUTH_JWT_ENV]) + + def test_address_reads_container_ip_on_the_agent_network(self) -> None: + with patch(_RUN_DOCKER, return_value=_proc(stdout="172.18.0.2\n")) as m: + self.assertEqual("172.18.0.2", self.sc.address()) + argv = m.call_args.args[0] + self.assertEqual(["docker", "inspect"], argv[:2]) + self.assertIn(self.sc.name, argv) + self.assertTrue(any(self.sc.network in a for a in argv)) + + def test_address_raises_when_unassigned(self) -> None: + with patch(_RUN_DOCKER, return_value=_proc(stdout="")): + with self.assertRaises(GatewayError): + self.sc.address() + + def test_provisioning_transport_targets_the_gateway_container(self) -> None: + from bot_bottle.backend.docker.gateway_provision import DockerGatewayTransport + + transport = self.sc.provisioning_transport() + assert isinstance(transport, DockerGatewayTransport) + self.assertEqual(self.sc.name, transport.gateway) + def test_ensure_running_creates_network_when_missing(self) -> None: calls: list[list[str]] = [] @@ -142,7 +191,7 @@ class TestDockerGateway(unittest.TestCase): return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc() with patch(_RUN_DOCKER, side_effect=fake): - self.sc.ensure_running() + self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN) creates = [c for c in calls if c[:3] == ["docker", "network", "create"]] self.assertEqual([["docker", "network", "create", self.sc.network]], creates) @@ -173,7 +222,7 @@ class TestDockerGateway(unittest.TestCase): 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.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN) # 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: @@ -186,7 +235,7 @@ class TestDockerGateway(unittest.TestCase): with patch(_RUN_DOCKER, side_effect=fake): with self.assertRaises(GatewayError): - self.sc.ensure_running() + self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN) def test_stop_is_idempotent_on_missing(self) -> None: absent = _proc(returncode=1, stderr="Error: No such container: x")