Compare commits

..

4 Commits

Author SHA1 Message Date
didericis 389a2c10ce feat(orchestrator): slice 13c(vii) — stable shared gateway CA
lint / lint (push) Successful in 2m4s
test / unit (pull_request) Successful in 1m2s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m13s
The consolidated gateway TLS-intercepts every bottle, so all agents must
trust ONE CA. Rather than host-generate + mount a CA, let the gateway's own
mitmproxy generate its CA into a persistent named volume and extract the
cert for agents — no host openssl, keeps gateway.py lean.

- DockerGateway mounts GATEWAY_CA_VOLUME at /home/mitmproxy/.mitmproxy so the
  self-generated CA survives container recreation (it must not rotate, or
  already-launched agents would stop trusting the gateway).
- ca_cert_pem(): read the CA cert (PEM) out of the running gateway
  (docker exec cat mitmproxy-ca-cert.pem) — the agent installs this into its
  trust store to accept the gateway's bumped certs.

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 22:12:18 -04:00
didericis 9896986810 feat(orchestrator): slice 13c(vi) — consolidated launch sequence (composition)
test / unit (pull_request) Successful in 1m3s
test / integration (pull_request) Successful in 21s
test / coverage (pull_request) Successful in 1m12s
lint / lint (push) Successful in 2m5s
Composes the orchestrator primitives into the register/teardown sequence
that replaces the per-bottle bundle:

  launch_consolidated(egress_plan, git_gate_plan): ensure orchestrator +
    gateway up -> read the gateway network's subnet + the gateway's own
    address -> allocate the bottle a pinned source IP (skip the gateway +
    live bottles) -> register (egress policy blob + slug metadata) ->
    provision its git-gate repos/creds into the gateway. Returns a
    LaunchContext (bottle_id, identity_token, source_ip, network, gateway_ip,
    orchestrator_url) — everything the agent container needs to attach.
    Rolls the registration back if provisioning fails, so no orphan.

  teardown_consolidated(bottle_id): deregister + deprovision (idempotent).

The agent `docker run` itself stays the backend's job (provider
provisioning); this owns the orchestrator-facing wiring so the sequence is
unit-testable — collaborators mocked, next_free_ip + registration_inputs run
for real (IP allocation + policy blob asserted end to end).

pyright 0 errors; pylint 9.83/10; unit suite green (1755 tests; the 13
test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 22:03:21 -04:00
didericis 20e83258bf feat(git-gate+orchestrator): slice 13c(v) — provision git-gate into the running gateway
lint / lint (push) Successful in 2m16s
test / unit (pull_request) Successful in 1m8s
test / integration (pull_request) Successful in 29s
test / coverage (pull_request) Successful in 1m21s
Places a bottle's git-gate credentials + bare repos into the live shared
gateway container when it registers — the execution side of the 13c(i)
provisioning render.

- provision_git_gate(gateway, bottle_id, plan): mkdir the per-bottle creds
  dir, docker cp each upstream's identity key (+ known_hosts when present)
  into /git-gate/creds/<bottle_id>/, then docker exec the namespaced,
  init-only script from git_gate_render_provision. No-op with no upstreams.
- deprovision_git_gate(gateway, bottle_id): rm the bottle's /git/<id> +
  creds on teardown (idempotent).
- bottle_id is validated (shell/path-safe alphabet) BEFORE any docker cp/rm,
  so a traversal id can never reach a path arg — the creds/repo dirs land in
  docker cp/rm arguments. Registry ids are token_hex; defense in depth.

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 21:59:12 -04:00
didericis c5ba33bf0a feat(orchestrator): slice 13c(iv) — gateway joins a shared network
test / unit (pull_request) Successful in 1m5s
test / integration (pull_request) Successful in 22s
test / coverage (pull_request) Successful in 1m15s
lint / lint (push) Successful in 2m18s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 21:55:10 -04:00
6 changed files with 531 additions and 1 deletions
@@ -0,0 +1,145 @@
"""Consolidated bottle launch sequence for the docker backend (PRD 0070).
Composes the orchestrator primitives into the register/teardown sequence that
replaces the per-bottle sidecar bundle:
1. ensure the orchestrator control plane + shared gateway are up;
2. allocate the bottle a pinned source IP on the gateway network (the
attribution key), skipping the gateway's own address + live bottles;
3. register it (egress policy blob + slug metadata) → bottle id + identity
token;
4. provision its git-gate repos/creds into the running gateway.
It returns a `LaunchContext` with everything the agent container needs to
attach — network, pinned IP, the gateway's address (its proxy target), the
orchestrator URL, and the identity token. The agent `docker run` itself is
the backend's job (it owns provider provisioning); this owns the
orchestrator-facing wiring so that sequence stays testable in isolation.
"""
from __future__ import annotations
from dataclasses import dataclass
from ...docker_cmd import run_docker
from ...egress import EgressPlan
from ...git_gate import GitGatePlan
from ...orchestrator.client import OrchestratorClient
from ...orchestrator.gateway import GATEWAY_NAME, GATEWAY_NETWORK
from ...orchestrator.lifecycle import OrchestratorProcess
from ...orchestrator.registration import registration_inputs
from .gateway_net import next_free_ip
from .gateway_provision import deprovision_git_gate, provision_git_gate
class ConsolidatedLaunchError(RuntimeError):
"""The consolidated register/provision sequence could not complete."""
@dataclass(frozen=True)
class LaunchContext:
"""What the agent container needs to join the shared gateway."""
bottle_id: str
identity_token: str
source_ip: str # the agent's pinned address (attribution key)
network: str # the shared gateway network to attach to
gateway_ip: str # the gateway's address — the agent's proxy target
orchestrator_url: str
def _network_cidr(network: str) -> str:
"""The gateway network's IPv4 subnet, or raise."""
proc = run_docker([
"docker", "network", "inspect",
"--format", "{{range .IPAM.Config}}{{.Subnet}}{{end}}", network,
])
cidr = proc.stdout.strip()
if proc.returncode != 0 or not cidr:
raise ConsolidatedLaunchError(
f"gateway network {network} has no subnet: {proc.stderr.strip()}"
)
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"gateway {name} has no address on {network}: {proc.stderr.strip()}"
)
return ip
def _taken_ips(client: OrchestratorClient, gateway_ip: str) -> list[str]:
"""Every address already in use on the gateway network: the gateway
container plus every live bottle the registry knows."""
taken = [gateway_ip]
for rec in client.list_bottles():
src = rec.get("source_ip")
if isinstance(src, str) and src:
taken.append(src)
return taken
def launch_consolidated(
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
*,
image_ref: str = "",
process: OrchestratorProcess | None = None,
gateway_name: str = GATEWAY_NAME,
network: str = GATEWAY_NETWORK,
) -> LaunchContext:
"""Ensure the orchestrator + gateway are up, allocate + register the
bottle, and provision its git-gate state. Returns the agent's attach
context. Raises `ConsolidatedLaunchError` (or the primitives' own errors)
if any step fails — the caller tears down on failure."""
process = process or OrchestratorProcess()
url = process.ensure_running()
client = OrchestratorClient(url)
cidr = _network_cidr(network)
gateway_ip = _container_ip(gateway_name, network)
source_ip = next_free_ip(cidr, _taken_ips(client, gateway_ip))
inputs = registration_inputs(egress_plan)
reg = client.register_bottle(
source_ip, image_ref=image_ref, policy=inputs.policy, metadata=inputs.metadata,
)
try:
provision_git_gate(gateway_name, reg.bottle_id, git_gate_plan)
except Exception:
# Roll the registration back so a provisioning failure leaves no orphan.
client.teardown_bottle(reg.bottle_id)
raise
return LaunchContext(
bottle_id=reg.bottle_id,
identity_token=reg.identity_token,
source_ip=source_ip,
network=network,
gateway_ip=gateway_ip,
orchestrator_url=url,
)
def teardown_consolidated(
bottle_id: str, *, orchestrator_url: str, gateway_name: str = GATEWAY_NAME,
) -> None:
"""Deregister the bottle and remove its git-gate state from the gateway.
Both steps are idempotent so this is safe from a cleanup trap."""
OrchestratorClient(orchestrator_url).teardown_bottle(bottle_id)
deprovision_git_gate(gateway_name, bottle_id)
__all__ = [
"LaunchContext",
"launch_consolidated",
"teardown_consolidated",
"ConsolidatedLaunchError",
]
@@ -0,0 +1,94 @@
"""Provision one bottle's git-gate state into the running shared gateway
(PRD 0070, docker slice).
The consolidated gateway serves every bottle's repos under `/git/<bottle_id>/`
with per-repo credentials under `/git-gate/creds/<bottle_id>/`. When a bottle
is registered the launcher must place *its* deploy keys + known_hosts into
that per-bottle creds dir and init its bare repos there — so this copies the
credential files into the live gateway container and runs the (namespaced,
init-only) provisioning script produced by `git_gate_render_provision`.
Isolating each bottle's creds dir + repo root by id is what keeps one
bottle's push credentials out of another's repos on the shared gateway.
"""
from __future__ import annotations
import re
from ...docker_cmd import run_docker
from ...git_gate import GitGatePlan, git_gate_render_provision
# bottle ids index the gateway's per-bottle repo + creds dirs; they land in
# `docker cp`/`rm` path arguments, so validate before any path is built (a
# traversal id like "../etc" must never reach the container). Registry ids are
# token_hex — this is defense in depth at the docker boundary.
_SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+")
class GatewayProvisionError(RuntimeError):
"""A git-gate provisioning step against the running gateway failed."""
def _require_safe(bottle_id: str) -> None:
if not _SAFE_BOTTLE_ID.fullmatch(bottle_id):
raise GatewayProvisionError(f"unsafe bottle id {bottle_id!r}")
def _creds_dir(bottle_id: str) -> str:
return f"/git-gate/creds/{bottle_id}"
def _exec(gateway: str, argv: list[str]) -> None:
"""`docker exec` a command in the gateway, raising on non-zero exit."""
proc = run_docker(["docker", "exec", gateway, *argv])
if proc.returncode != 0:
raise GatewayProvisionError(
f"gateway exec {argv!r} failed: {proc.stderr.strip()}"
)
def _cp_into(gateway: str, src: str, dest: str) -> None:
"""`docker cp` a host file into the gateway, raising on non-zero exit."""
proc = run_docker(["docker", "cp", src, f"{gateway}:{dest}"])
if proc.returncode != 0:
raise GatewayProvisionError(
f"gateway cp {src} -> {dest} failed: {proc.stderr.strip()}"
)
def provision_git_gate(gateway: str, bottle_id: str, plan: GitGatePlan) -> None:
"""Place `bottle_id`'s git-gate credentials into the running `gateway`
container and init its bare repos under `/git/<bottle_id>/`.
Copies each upstream's identity key (and known_hosts, when present) into
`/git-gate/creds/<bottle_id>/`, then runs the namespaced provisioning
script. No-op for a bottle with no git upstreams."""
_require_safe(bottle_id)
if not plan.upstreams:
return
creds = _creds_dir(bottle_id)
_exec(gateway, ["mkdir", "-p", creds])
for u in plan.upstreams:
if u.identity_file:
_cp_into(gateway, u.identity_file, f"{creds}/{u.name}-key")
known_hosts = str(u.known_hosts_file)
if known_hosts and known_hosts != ".":
_cp_into(gateway, known_hosts, f"{creds}/{u.name}-known_hosts")
# Init the bare repos + per-repo credential config for this namespace.
script = git_gate_render_provision(bottle_id, plan.upstreams)
_exec(gateway, ["sh", "-c", script])
def deprovision_git_gate(gateway: str, bottle_id: str) -> None:
"""Remove a bottle's repos + creds from the gateway on teardown. Idempotent
— an already-absent namespace is a clean no-op (best effort; a stray dir
can't leak, since attribution is by source IP and the bottle is gone)."""
_require_safe(bottle_id)
run_docker([
"docker", "exec", gateway, "rm", "-rf",
f"/git/{bottle_id}", _creds_dir(bottle_id),
])
__all__ = ["provision_git_gate", "deprovision_git_gate", "GatewayProvisionError"]
+46 -1
View File
@@ -25,6 +25,19 @@ 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"
# mitmproxy's CA dir in the bundle. A persistent named volume here keeps the
# gateway's self-generated CA STABLE across container recreation — every agent
# installs this one CA to trust the shared gateway's TLS interception, so it
# must not rotate when the gateway restarts.
MITMPROXY_HOME = "/home/mitmproxy/.mitmproxy"
GATEWAY_CA_VOLUME = "bot-bottle-gateway-mitmproxy"
GATEWAY_CA_CERT = f"{MITMPROXY_HOME}/mitmproxy-ca-cert.pem"
# 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 +90,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 +127,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,11 +150,27 @@ class DockerGateway(Gateway):
"docker", "run", "--detach",
"--name", self.name,
"--label", GATEWAY_LABEL,
"--network", self.network,
# Persist the self-generated CA so it survives restarts (agents
# trust it) — see GATEWAY_CA_VOLUME.
"--volume", f"{GATEWAY_CA_VOLUME}:{MITMPROXY_HOME}",
self.image_ref,
])
if proc.returncode != 0:
raise GatewayError(f"gateway failed to start: {proc.stderr.strip()}")
def ca_cert_pem(self) -> str:
"""The gateway's CA certificate (PEM) that agents install to trust its
TLS interception. Read from the running container; raises if the
gateway hasn't generated it yet (it's created on first mitmproxy
start)."""
proc = run_docker(["docker", "exec", self.name, "cat", GATEWAY_CA_CERT])
if proc.returncode != 0 or not proc.stdout.strip():
raise GatewayError(
f"gateway CA cert not available: {proc.stderr.strip() or 'empty'}"
)
return proc.stdout
def stop(self) -> None:
proc = run_docker(["docker", "rm", "--force", self.name])
if proc.returncode != 0 and "No such container" not in proc.stderr:
@@ -135,5 +179,6 @@ class DockerGateway(Gateway):
__all__ = [
"Gateway", "DockerGateway", "GatewayError",
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE",
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", "GATEWAY_NETWORK",
"GATEWAY_CA_VOLUME", "GATEWAY_CA_CERT",
]
+91
View File
@@ -0,0 +1,91 @@
"""Unit: consolidated launch sequence — compose the orchestrator primitives (PRD 0070)."""
from __future__ import annotations
import unittest
from pathlib import Path
from unittest.mock import MagicMock, Mock, patch
from bot_bottle.backend.docker.consolidated_launch import (
launch_consolidated,
teardown_consolidated,
)
from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.orchestrator.client import RegisteredBottle
_MOD = "bot_bottle.backend.docker.consolidated_launch"
def _egress_plan() -> EgressPlan:
return EgressPlan(
slug="demo", routes_path=Path("/x"), routes=(EgressRoute(host="api.example.com"),),
token_env_map={},
)
def _git_plan() -> GitGatePlan:
return GitGatePlan(
slug="demo", entrypoint_script=Path(), hook_script=Path(),
access_hook_script=Path(), upstreams=(),
)
def _client(*, bottles: list[dict[str, object]] | None = None) -> Mock:
c = Mock()
c.list_bottles.return_value = bottles or []
c.register_bottle.return_value = RegisteredBottle("b1", "tok")
return c
class TestLaunchConsolidated(unittest.TestCase):
def _run(self, client: Mock, provision: Mock | None = None):
process = MagicMock()
process.ensure_running.return_value = "http://orch:8080"
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}.OrchestratorClient", return_value=client), \
patch(f"{_MOD}.provision_git_gate", provision or Mock()):
return launch_consolidated(_egress_plan(), _git_plan(), process=process)
def test_allocates_ip_registers_and_provisions(self) -> None:
client = _client()
provision = Mock()
ctx = self._run(client, provision)
# .1 is the router, .2 is the gateway → first bottle gets .3.
self.assertEqual("172.18.0.3", ctx.source_ip)
self.assertEqual("172.18.0.2", ctx.gateway_ip)
self.assertEqual("b1", ctx.bottle_id)
self.assertEqual("tok", ctx.identity_token)
self.assertEqual("http://orch:8080", ctx.orchestrator_url)
# Registered with the source IP + the egress policy blob.
kwargs = client.register_bottle.call_args
self.assertEqual("172.18.0.3", kwargs.args[0])
self.assertIn("api.example.com", kwargs.kwargs["policy"])
provision.assert_called_once()
def test_skips_gateway_and_live_bottle_addresses(self) -> None:
client = _client(bottles=[{"source_ip": "172.18.0.3"}])
ctx = self._run(client)
self.assertEqual("172.18.0.4", ctx.source_ip) # .2 gw, .3 taken → .4
def test_provision_failure_rolls_back_registration(self) -> None:
client = _client()
provision = Mock(side_effect=RuntimeError("provision boom"))
with self.assertRaises(RuntimeError):
self._run(client, provision)
client.teardown_bottle.assert_called_once_with("b1") # no orphan left
class TestTeardownConsolidated(unittest.TestCase):
def test_deregisters_and_deprovisions(self) -> None:
client = Mock()
with patch(f"{_MOD}.OrchestratorClient", return_value=client), \
patch(f"{_MOD}.deprovision_git_gate") as deprov:
teardown_consolidated("b1", orchestrator_url="http://orch:8080")
client.teardown_bottle.assert_called_once_with("b1")
deprov.assert_called_once()
if __name__ == "__main__":
unittest.main()
+111
View File
@@ -0,0 +1,111 @@
"""Unit: git-gate provisioning into the running shared gateway (PRD 0070)."""
from __future__ import annotations
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from bot_bottle.backend.docker.gateway_provision import (
GatewayProvisionError,
deprovision_git_gate,
provision_git_gate,
)
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
_RUN = "bot_bottle.backend.docker.gateway_provision.run_docker"
def _proc(returncode: int = 0, stderr: str = "") -> Mock:
return Mock(returncode=returncode, stderr=stderr)
def _recorder(calls: list[list[str]]):
"""A run_docker side_effect that records argv and returns success."""
def fake(argv: list[str]) -> Mock:
calls.append(argv)
return _proc()
return fake
def _plan(*upstreams: GitGateUpstream) -> GitGatePlan:
return GitGatePlan(
slug="demo",
entrypoint_script=Path(),
hook_script=Path(),
access_hook_script=Path(),
upstreams=tuple(upstreams),
)
def _up(name: str, *, key: str = "/host/keys/id", known_hosts: str = "") -> GitGateUpstream:
return GitGateUpstream(
name=name,
upstream_url=f"ssh://git@github.com/x/{name}.git",
upstream_host="github.com",
upstream_port="22",
identity_file=key,
known_host_key="",
known_hosts_file=Path(known_hosts) if known_hosts else Path(),
)
class TestProvisionGitGate(unittest.TestCase):
def test_copies_creds_and_runs_namespaced_init(self) -> None:
calls: list[list[str]] = []
with patch(_RUN, side_effect=_recorder(calls)):
provision_git_gate("gw", "bottle1", _plan(_up("foo", known_hosts="/host/kh")))
cps = [c for c in calls if c[:2] == ["docker", "cp"]]
self.assertIn(["docker", "cp", "/host/keys/id", "gw:/git-gate/creds/bottle1/foo-key"], cps)
self.assertIn(
["docker", "cp", "/host/kh", "gw:/git-gate/creds/bottle1/foo-known_hosts"], cps,
)
# The init script runs in the gateway, namespaced under the bottle id.
exec_scripts = [c for c in calls if c[:3] == ["docker", "exec", "gw"] and c[3] == "sh"]
self.assertEqual(1, len(exec_scripts))
self.assertIn("repo=/git/bottle1/${name}.git", exec_scripts[0][-1])
def test_omits_known_hosts_copy_when_absent(self) -> None:
calls: list[list[str]] = []
with patch(_RUN, side_effect=_recorder(calls)):
provision_git_gate("gw", "b1", _plan(_up("foo"))) # no known_hosts
cps = [c for c in calls if c[:2] == ["docker", "cp"]]
self.assertEqual(1, len(cps)) # only the key, not known_hosts
self.assertTrue(cps[0][3].endswith("/foo-key"))
def test_no_upstreams_is_noop(self) -> None:
with patch(_RUN) as m:
provision_git_gate("gw", "b1", _plan())
m.assert_not_called()
def test_raises_on_docker_failure(self) -> None:
with patch(_RUN, return_value=_proc(returncode=1, stderr="boom")):
with self.assertRaises(GatewayProvisionError):
provision_git_gate("gw", "b1", _plan(_up("foo")))
def test_rejects_unsafe_bottle_id_before_any_docker(self) -> None:
with patch(_RUN) as m:
with self.assertRaises(GatewayProvisionError):
provision_git_gate("gw", "../etc", _plan(_up("foo")))
m.assert_not_called() # rejected before a single docker call
class TestDeprovision(unittest.TestCase):
def test_removes_repo_and_creds(self) -> None:
with patch(_RUN, return_value=_proc()) as m:
deprovision_git_gate("gw", "b1")
argv = m.call_args.args[0]
self.assertEqual(["docker", "exec", "gw", "rm", "-rf"], argv[:5])
self.assertIn("/git/b1", argv)
self.assertIn("/git-gate/creds/b1", argv)
def test_rejects_unsafe_bottle_id(self) -> None:
with patch(_RUN) as m:
with self.assertRaises(GatewayProvisionError):
deprovision_git_gate("gw", "a/b")
m.assert_not_called()
if __name__ == "__main__":
unittest.main()
+44
View File
@@ -6,11 +6,15 @@ import unittest
from unittest.mock import Mock, patch
from bot_bottle.orchestrator.gateway import (
GATEWAY_CA_CERT,
GATEWAY_NAME,
DockerGateway,
GatewayError,
)
_CA_PEM = "-----BEGIN CERTIFICATE-----\nMII...\n-----END CERTIFICATE-----\n"
_RUN_DOCKER = "bot_bottle.orchestrator.gateway.run_docker"
@@ -51,6 +55,46 @@ 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])
# Persists its CA on a named volume so agents keep trusting it.
self.assertTrue(any("mitmproxy" in a for a in runs[0]))
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_ca_cert_pem_reads_from_container(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(stdout=_CA_PEM)) as m:
self.assertEqual(_CA_PEM, self.sc.ca_cert_pem())
argv = m.call_args.args[0]
self.assertEqual(["docker", "exec", self.sc.name, "cat", GATEWAY_CA_CERT], argv)
def test_ca_cert_pem_raises_when_absent(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(returncode=1, stderr="No such file")):
with self.assertRaises(GatewayError):
self.sc.ca_cert_pem()
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: