refactor(gateway): introduce the Gateway service ABC (docker impl)

Replace the docker backend's ad-hoc gateway wiring with the shared `Gateway`
service ABC (in the gateway package), the first of the two per-host service
classes that supersede the per-backend infra glue.

The contract is backend-neutral: `connect_to_orchestrator(url, gateway_token)`
binds the gateway to its control plane and brings it up, `address()` reports
the agent-facing proxy target, `ca_cert_pem()` vends the mitmproxy CA, and
`provisioning_transport()` hands back the exec/cp seam git-gate provisioning
stages per-bottle repos + deploy keys through. `GatewayProvisionError` +
`GatewayTransport` move to the ABC so every backend shares them.

Key trust-model change: the gateway no longer mints its own token. It never
holds the signing key (#469), so the orchestrator mints the role-scoped
`gateway` JWT and hands it in via `connect_to_orchestrator`; the gateway only
injects it. `DockerGateway.ensure_running` becomes `connect_to_orchestrator`
(stashing the URL + token as instance state), and the docker launch flow reads
`address()` / `provisioning_transport()` off the service rather than
re-deriving the container IP + transport itself.

macOS + firecracker gateways move under the ABC in following commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 12:58:00 -04:00
parent 18d9b81add
commit 05df21f210
9 changed files with 205 additions and 97 deletions
@@ -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,
+49 -16
View File
@@ -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()}")
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)
+1 -20
View File
@@ -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)."""
+14 -5
View File
@@ -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:
+57 -14
View File
@@ -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",
]
@@ -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())
+5 -1
View File
@@ -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()):
+5 -5
View File
@@ -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()
+58 -9
View File
@@ -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")