feat(backend): slice 13d — cut docker launch over to the consolidated gateway (e2e green)

Rewire DockerBottleBackend.launch to the consolidated model, replacing the
per-bottle sidecar bundle. VALIDATED END-TO-END on real docker:
test_sandbox_escape passes all 5 attacks (egress DLP + git-gate gitleaks)
through the shared gateway.

launch now:
  - mints git-gate dynamic keys (if any), then launch_consolidated() to
    register the bottle + provision its git-gate state into the gateway and
    get the agent's attach context (pinned source IP, gateway address);
  - installs the SHARED gateway CA (gateway.ca_cert_pem) into the agent;
  - renders the agent-only consolidated compose on the gateway network at the
    pinned IP, proxied through the gateway;
  - points the agent's git-gate insteadOf (http://<gw>:9420) and supervise
    MCP (http://<gw>:9100) at the gateway (DockerBottlePlan.agent_git_gate_url
    / agent_supervise_url);
  - teardown = compose down + teardown_consolidated (dereg + deprovision).
Dropped the per-bottle networks, per-bottle egress CA, and bundle service.

Fixes surfaced by the real e2e (couldn't be caught by unit mocks):
  - provision_git_gate installs the bottle-agnostic pre-receive/access hooks
    into the gateway (were cp'd per-bundle before);
  - source-IP allocation reads the *actual* container IPs on the network
    (gateway + orchestrator + agents), not just gateway + registry — the
    orchestrator container sits on the network and was colliding.

Unit tests for the old bundle launch rewritten against the new collaborators.

NOTE for reviewers: the gateway/bundle image (bot-bottle-sidecars) must be
rebuilt when its flat sources change — `ensure_built` only builds-if-missing,
so a stale image silently runs the OLD single-tenant daemons. A content-hash
/ --rebuild path is a follow-up.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
2026-07-13 23:28:36 -04:00
parent 0c2d0aca63
commit 716928315e
9 changed files with 188 additions and 216 deletions
+5
View File
@@ -111,6 +111,11 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
plumbing needed; the alias resolves inside the bridge.""" plumbing needed; the alias resolves inside the bridge."""
if plan.supervise_plan is None: if plan.supervise_plan is None:
return "" return ""
# Consolidated: the supervise daemon lives on the shared gateway, so
# the agent registers the gateway address (NO_PROXY bypasses egress),
# not the per-bottle `supervise` alias.
if plan.agent_supervise_url:
return plan.agent_supervise_url
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/" return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
def prepare_cleanup(self) -> DockerBottleCleanupPlan: def prepare_cleanup(self) -> DockerBottleCleanupPlan:
+19
View File
@@ -28,11 +28,30 @@ class DockerBottlePlan(BottlePlan):
# accidental log of the plan dataclass. # accidental log of the plan dataclass.
forwarded_env: dict[str, str] = field(repr=False) forwarded_env: dict[str, str] = field(repr=False)
use_runsc: bool use_runsc: bool
# Consolidated mode (PRD 0070): the agent reaches git-gate over HTTP at the
# shared gateway's address (`http://<gateway_ip>:9420`), set at launch.
# Empty → single-tenant defaults (the `git-gate` alias over git://).
agent_git_gate_url: str = ""
# Likewise the supervise MCP endpoint at the gateway (`http://<gw>:9100/`);
# empty → the single-tenant `supervise` alias.
agent_supervise_url: str = ""
@property @property
def container_name(self) -> str: def container_name(self) -> str:
return self.agent_provision.instance_name return self.agent_provision.instance_name
@property
def git_gate_insteadof_host(self) -> str:
if self.agent_git_gate_url.startswith("http://"):
return self.agent_git_gate_url.removeprefix("http://").rstrip("/")
return super().git_gate_insteadof_host
@property
def git_gate_insteadof_scheme(self) -> str:
if self.agent_git_gate_url.startswith("http://"):
return "http"
return super().git_gate_insteadof_scheme
@property @property
def image(self) -> str: def image(self) -> str:
return self.agent_provision.image return self.agent_provision.image
@@ -76,15 +76,21 @@ def _container_ip(name: str, network: str) -> str:
return ip return ip
def _taken_ips(client: OrchestratorClient, gateway_ip: str) -> list[str]: def _network_container_ips(network: str) -> list[str]:
"""Every address already in use on the gateway network: the gateway """Every address currently assigned on the gateway network the ground
container plus every live bottle the registry knows.""" truth for "in use": the gateway + orchestrator infrastructure containers
taken = [gateway_ip] and every live agent. Read from the network so a new bottle can't collide
for rec in client.list_bottles(): with anything actually attached (a registry-only view would miss the
src = rec.get("source_ip") orchestrator/gateway containers)."""
if isinstance(src, str) and src: proc = run_docker([
taken.append(src) "docker", "network", "inspect", "--format",
return taken "{{range .Containers}}{{.IPv4Address}} {{end}}", network,
])
ips: list[str] = []
for entry in proc.stdout.split():
# entries look like "172.20.0.2/16" — keep the address.
ips.append(entry.split("/", 1)[0])
return ips
def launch_consolidated( def launch_consolidated(
@@ -106,7 +112,7 @@ def launch_consolidated(
cidr = _network_cidr(network) cidr = _network_cidr(network)
gateway_ip = _container_ip(gateway_name, network) gateway_ip = _container_ip(gateway_name, network)
source_ip = next_free_ip(cidr, _taken_ips(client, gateway_ip)) source_ip = next_free_ip(cidr, _network_container_ips(network))
inputs = registration_inputs(egress_plan) inputs = registration_inputs(egress_plan)
reg = client.register_bottle( reg = client.register_bottle(
@@ -67,6 +67,12 @@ def provision_git_gate(gateway: str, bottle_id: str, plan: GitGatePlan) -> None:
_require_safe(bottle_id) _require_safe(bottle_id)
if not plan.upstreams: if not plan.upstreams:
return return
# The pre-receive + access hooks are bottle-agnostic and shared by every
# bottle's repos; install them into the gateway (idempotent — same content
# each time). The per-bottle model cp'd these into each bundle at start.
_exec(gateway, ["mkdir", "-p", "/etc/git-gate"])
_cp_into(gateway, str(plan.hook_script), "/etc/git-gate/pre-receive")
_cp_into(gateway, str(plan.access_hook_script), "/etc/git-gate/access-hook")
creds = _creds_dir(bottle_id) creds = _creds_dir(bottle_id)
_exec(gateway, ["mkdir", "-p", creds]) _exec(gateway, ["mkdir", "-p", creds])
for u in plan.upstreams: for u in plan.upstreams:
+47 -62
View File
@@ -36,13 +36,11 @@ from contextlib import ExitStack, contextmanager
from pathlib import Path from pathlib import Path
from typing import Callable, Generator from typing import Callable, Generator
from ...egress import egress_resolve_token_values
from ...git_gate import ( from ...git_gate import (
provision_git_gate_dynamic_keys, provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys, revoke_git_gate_provisioned_keys,
) )
from ...log import info, warn from ...log import info, warn
from . import network as network_mod
from . import util as docker_mod from . import util as docker_mod
from .bottle import DockerBottle from .bottle import DockerBottle
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
@@ -53,7 +51,6 @@ from ...bottle_state import (
read_committed_image, read_committed_image,
) )
from .compose import ( from .compose import (
bottle_plan_to_compose,
compose_down, compose_down,
compose_dump_logs, compose_dump_logs,
compose_file_path, compose_file_path,
@@ -62,7 +59,9 @@ from .compose import (
compose_up, compose_up,
write_compose_file, write_compose_file,
) )
from .egress import egress_tls_init from .consolidated_compose import consolidated_agent_compose
from .consolidated_launch import launch_consolidated, teardown_consolidated
from ...orchestrator.gateway import DockerGateway
# Where the repo root lives, for `docker build` context. Computed once. # Where the repo root lives, for `docker build` context. Computed once.
@@ -97,8 +96,7 @@ def launch(
try: try:
# Step 1: agent image. Use a committed snapshot when one exists # Step 1: agent image. Use a committed snapshot when one exists
# and is present in the local daemon; otherwise build from the # and is present in the local daemon; otherwise build from the
# Dockerfile. Sidecar images get built lazily by `docker compose # Dockerfile. (The gateway image is built by the orchestrator.)
# up` via the renderer's `build:` directives.
committed = read_committed_image(plan.slug) committed = read_committed_image(plan.slug)
if committed and docker_mod.image_exists(committed): if committed and docker_mod.image_exists(committed):
info(f"using committed image {committed!r}") info(f"using committed image {committed!r}")
@@ -112,82 +110,73 @@ def launch(
dockerfile=plan.dockerfile_path, dockerfile=plan.dockerfile_path,
) )
internal_network = network_mod.network_name_for_slug(plan.slug) # Step 2: mint the git-gate dynamic (gitea) deploy keys, if any, before
egress_network = network_mod.network_egress_name_for_slug(plan.slug) # provisioning the bottle's repos into the shared gateway.
egress_ca_host, egress_ca_cert_only = egress_tls_init(
egress_state_dir(plan.slug),
)
git_gate_plan = plan.git_gate_plan git_gate_plan = plan.git_gate_plan
if git_gate_plan.upstreams: if git_gate_plan.upstreams:
git_gate_plan = provision_git_gate_dynamic_keys( git_gate_plan = provision_git_gate_dynamic_keys(
plan.manifest.bottle, plan.manifest.bottle, git_gate_plan, git_gate_state_dir(plan.slug),
git_gate_plan,
git_gate_state_dir(plan.slug),
)
git_gate_plan = dataclasses.replace(
git_gate_plan,
internal_network=internal_network,
egress_network=egress_network,
) )
# Step 3: register on the orchestrator + provision this bottle's
# git-gate state into the shared gateway; get the agent's attach
# context (pinned source IP, gateway address, shared network).
ctx = launch_consolidated(plan.egress_plan, git_gate_plan, image_ref=plan.image)
stack.callback(
teardown_consolidated, ctx.bottle_id, orchestrator_url=ctx.orchestrator_url,
)
# Step 4: install the SHARED gateway CA into the agent (replaces the
# per-bottle CA) — read it out of the running gateway.
ca_dir = egress_state_dir(plan.slug) / "gateway-ca"
ca_dir.mkdir(parents=True, exist_ok=True)
ca_file = ca_dir / "gateway-ca.pem"
ca_file.write_text(DockerGateway(network=ctx.network).ca_cert_pem())
egress_plan = dataclasses.replace( egress_plan = dataclasses.replace(
plan.egress_plan, plan.egress_plan,
internal_network=internal_network, mitmproxy_ca_host_path=ca_file,
egress_network=egress_network, mitmproxy_ca_cert_only_host_path=ca_file,
mitmproxy_ca_host_path=egress_ca_host, )
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, # Point the agent's git-gate insteadOf rewrites at the shared gateway's
# HTTP git endpoint (9420) instead of the dead per-bottle `git-gate`
# alias. git-http + supervise on the gateway bypass the egress proxy
# (NO_PROXY includes the gateway address).
git_gate_url = (
f"http://{ctx.gateway_ip}:9420" if git_gate_plan.upstreams else ""
)
supervise_url = (
f"http://{ctx.gateway_ip}:9100/" if plan.supervise_plan is not None else ""
) )
supervise_plan = plan.supervise_plan
if supervise_plan is not None:
supervise_plan = dataclasses.replace(
supervise_plan,
internal_network=internal_network,
)
plan = dataclasses.replace( plan = dataclasses.replace(
plan, plan,
git_gate_plan=git_gate_plan, git_gate_plan=git_gate_plan,
egress_plan=egress_plan, egress_plan=egress_plan,
supervise_plan=supervise_plan, agent_git_gate_url=git_gate_url,
agent_supervise_url=supervise_url,
) )
# Step 6: render + write the compose file. metadata.json # Step 5: render + up the agent-only compose, pinned on the shared
# was written at prepare time and already carries # gateway network and proxied through the gateway's address.
# compose_project; nothing to update here.
state_dir = bottle_state_dir(plan.slug) state_dir = bottle_state_dir(plan.slug)
spec = bottle_plan_to_compose(plan) spec = consolidated_agent_compose(
plan, gateway_ip=ctx.gateway_ip, source_ip=ctx.source_ip, network=ctx.network,
)
compose_file = write_compose_file(spec, compose_file_path(state_dir)) compose_file = write_compose_file(spec, compose_file_path(state_dir))
project = compose_project_name(plan.slug) project = compose_project_name(plan.slug)
# Forwarded vars (OAuth token, host interpolations) flow through the
# Step 7: compose up. Token values + the OAuth placeholder # subprocess env as bare names so values never land in the file.
# flow through subprocess env; the compose file holds only compose_env: dict[str, str] = {**os.environ, **plan.forwarded_env}
# bare names for the secret-carrying entries.
effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env}
token_values = egress_resolve_token_values(
plan.egress_plan.token_env_map, effective_env,
)
compose_env: dict[str, str] = {
**os.environ,
**plan.forwarded_env,
**token_values,
}
info( info(
f"docker compose up -d (project {project}, " f"docker compose up -d (project {project}, agent on shared "
f"{len(spec['services'])} services)" f"gateway {ctx.gateway_ip}, ip {ctx.source_ip})"
) )
compose_up(project, compose_file, env=compose_env) compose_up(project, compose_file, env=compose_env)
# Register teardown in reverse order: log dump first, then
# `compose down`. Networks come down last via callbacks
# registered in step 2.
stack.callback(compose_down, project, compose_file) stack.callback(compose_down, project, compose_file)
stack.callback( stack.callback(
compose_dump_logs, project, compose_file, compose_log_path(state_dir), compose_dump_logs, project, compose_file, compose_log_path(state_dir),
) )
# Step 8: provision. Create the bottle first so provisioners # Step 6: provision (CA install now uses the gateway CA) + yield.
# can use bottle.exec / bottle.cp_in; set the prompt path
# returned by provision_prompt after the fact.
bottle = DockerBottle( bottle = DockerBottle(
plan.container_name, plan.container_name,
teardown, teardown,
@@ -200,10 +189,6 @@ def launch(
agent_workdir=plan.workspace_plan.workdir, agent_workdir=plan.workspace_plan.workdir,
) )
bottle.prompt_path = provision(plan, bottle) bottle.prompt_path = provision(plan, bottle)
# Step 9: yield. exec_agent continues to use `docker exec -it`
# — the agent runs `sleep infinity` per the renderer's
# service spec.
yield bottle yield bottle
finally: finally:
teardown() teardown()
+6 -5
View File
@@ -39,11 +39,12 @@ def _client(*, bottles: list[dict[str, object]] | None = None) -> Mock:
class TestLaunchConsolidated(unittest.TestCase): class TestLaunchConsolidated(unittest.TestCase):
def _run(self, client: Mock, provision: Mock | None = None): def _run(self, client: Mock, provision: Mock | None = None, *, on_network=("172.18.0.2",)):
service = MagicMock() service = MagicMock()
service.ensure_running.return_value = "http://orch:8080" service.ensure_running.return_value = "http://orch:8080"
with patch(f"{_MOD}._network_cidr", return_value="172.18.0.0/16"), \ 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}._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"{_MOD}.OrchestratorClient", return_value=client), \
patch(f"{_MOD}.provision_git_gate", provision or Mock()): patch(f"{_MOD}.provision_git_gate", provision or Mock()):
return launch_consolidated(_egress_plan(), _git_plan(), service=service) return launch_consolidated(_egress_plan(), _git_plan(), service=service)
@@ -64,10 +65,10 @@ class TestLaunchConsolidated(unittest.TestCase):
self.assertIn("api.example.com", kwargs.kwargs["policy"]) self.assertIn("api.example.com", kwargs.kwargs["policy"])
provision.assert_called_once() provision.assert_called_once()
def test_skips_gateway_and_live_bottle_addresses(self) -> None: def test_skips_all_addresses_on_the_network(self) -> None:
client = _client(bottles=[{"source_ip": "172.18.0.3"}]) # Gateway .2 + orchestrator .3 already attached -> agent gets .4.
ctx = self._run(client) ctx = self._run(_client(), on_network=("172.18.0.2", "172.18.0.3"))
self.assertEqual("172.18.0.4", ctx.source_ip) # .2 gw, .3 taken → .4 self.assertEqual("172.18.0.4", ctx.source_ip)
def test_provision_failure_rolls_back_registration(self) -> None: def test_provision_failure_rolls_back_registration(self) -> None:
client = _client() client = _client()
+64 -113
View File
@@ -1,4 +1,4 @@
"""Unit: Docker launch step uses committed image when available.""" """Unit: Docker launch step uses committed image when available (consolidated)."""
from __future__ import annotations from __future__ import annotations
@@ -6,6 +6,7 @@ import contextlib
import io import io
import tempfile import tempfile
import unittest import unittest
from collections.abc import Callable, Generator
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from unittest import mock from unittest import mock
@@ -14,9 +15,11 @@ from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.backend import BottleSpec from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker import launch as launch_mod from bot_bottle.backend.docker import launch as launch_mod
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.backend.docker.consolidated_launch import LaunchContext
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import ManifestIndex
from tests.unit import use_bottle_root
_SLUG = "dev-abc12" _SLUG = "dev-abc12"
@@ -28,163 +31,111 @@ _IDX = ManifestIndex.from_json_obj({
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
_CTX = LaunchContext(
bottle_id="b1", identity_token="t", source_ip="172.20.0.4",
network="bot-bottle-gateway", gateway_ip="172.20.0.2",
orchestrator_url="http://orch:8099",
)
def _plan(tmp: str) -> DockerBottlePlan: def _plan(tmp: str) -> DockerBottlePlan:
stage = Path(tmp) stage = Path(tmp)
spec = BottleSpec( spec = BottleSpec(
manifest=_IDX, manifest=_IDX, agent_name="demo", copy_cwd=False, user_cwd=tmp, identity=_SLUG,
agent_name="demo",
copy_cwd=False,
user_cwd=tmp,
identity=_SLUG,
) )
return DockerBottlePlan( return DockerBottlePlan(
spec=spec, spec=spec,
manifest=_IDX.load_for_agent("demo"), manifest=_IDX.load_for_agent("demo"),
stage_dir=stage, stage_dir=stage,
git_gate_plan=GitGatePlan( git_gate_plan=GitGatePlan(
slug=_SLUG, slug=_SLUG, entrypoint_script=stage / "e.sh", hook_script=stage / "h.sh",
entrypoint_script=stage / "entrypoint.sh", access_hook_script=stage / "a.sh", upstreams=(),
hook_script=stage / "hook.sh",
access_hook_script=stage / "access-hook.sh",
upstreams=(),
), ),
egress_plan=EgressPlan( egress_plan=EgressPlan(
slug=_SLUG, slug=_SLUG, routes_path=stage / "egress.yaml", routes=(), token_env_map={},
routes_path=stage / "egress.yaml",
routes=(),
token_env_map={},
), ),
supervise_plan=None, supervise_plan=None,
agent_provision=AgentProvisionPlan( agent_provision=AgentProvisionPlan(
template="claude", template="claude", command="claude", prompt_mode="append_file",
command="claude", image=_DEFAULT_IMAGE, dockerfile="", guest_home="/home/node",
prompt_mode="append_file", instance_name=f"bot-bottle-{_SLUG}", prompt_file=stage / "prompt.txt",
image=_DEFAULT_IMAGE,
dockerfile="",
guest_home="/home/node",
instance_name=f"bot-bottle-{_SLUG}",
prompt_file=stage / "prompt.txt",
guest_env={}, guest_env={},
), ),
slug=_SLUG, slug=_SLUG, forwarded_env={}, use_runsc=False,
forwarded_env={},
use_runsc=False,
) )
class TestLaunchCommittedImage(unittest.TestCase): class TestLaunchCommittedImage(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self._tmp = tempfile.mkdtemp(prefix="launch-committed-test.") self._tmp = tempfile.mkdtemp(prefix="launch-committed-test.")
self.addCleanup(lambda: __import__("shutil").rmtree(self._tmp, ignore_errors=True))
# The launch writes the gateway CA under the bottle root — sandbox it.
self.addCleanup(use_bottle_root(Path(self._tmp)))
def tearDown(self) -> None: @contextlib.contextmanager
import shutil def _patched(
shutil.rmtree(self._tmp, ignore_errors=True)
def _run_launch(
self, self,
plan: DockerBottlePlan,
*, *,
committed_tag: str | None = None, committed_tag: str | None,
image_present: bool = True, image_present: bool,
) -> list[str]: compose: Callable[..., dict[str, Any]],
"""Drive launch() through its full sequence with the committed-image ) -> Generator[list[str], None, None]:
behaviour controlled by the arguments. Returns the images that were
passed to `build_image` (empty list if it was never called)."""
built: list[str] = [] built: list[str] = []
gw = mock.Mock()
gw.ca_cert_pem.return_value = "-----BEGIN CERTIFICATE-----\nx\n-----END CERTIFICATE-----\n"
def fake_build(image: str, ctx: str, *, dockerfile: str = "") -> None: def _build(image: str, ctx: str, *, dockerfile: str = "") -> None:
del ctx, dockerfile del ctx, dockerfile
built.append(image) built.append(image)
with mock.patch.object( with mock.patch.object(launch_mod, "read_committed_image", return_value=committed_tag), \
launch_mod, "read_committed_image", return_value=committed_tag, mock.patch.object(launch_mod.docker_mod, "image_exists", return_value=image_present), \
), mock.patch.object( mock.patch.object(launch_mod.docker_mod, "build_image", side_effect=_build), \
launch_mod.docker_mod, "image_exists", return_value=image_present, mock.patch.object(launch_mod, "launch_consolidated", return_value=_CTX), \
), mock.patch.object( mock.patch.object(launch_mod, "teardown_consolidated"), \
launch_mod.docker_mod, "build_image", side_effect=fake_build, mock.patch.object(launch_mod, "DockerGateway", return_value=gw), \
), mock.patch.object( mock.patch.object(launch_mod, "consolidated_agent_compose", side_effect=compose), \
launch_mod, "egress_tls_init", mock.patch.object(launch_mod, "write_compose_file", return_value=Path("/tmp/c.yml")), \
return_value=(Path("/egress_ca"), Path("/egress_cert")), mock.patch.object(launch_mod, "compose_up"), \
), mock.patch.object( mock.patch.object(launch_mod, "compose_dump_logs"), \
launch_mod.network_mod, "network_name_for_slug", mock.patch.object(launch_mod, "compose_down"), \
return_value="bb-internal", contextlib.redirect_stderr(io.StringIO()):
), mock.patch.object( yield built
launch_mod.network_mod, "network_egress_name_for_slug",
return_value="bb-egress",
), mock.patch.object(
launch_mod, "bottle_plan_to_compose",
return_value={"services": {"agent": {}}},
), mock.patch.object(
launch_mod, "write_compose_file",
return_value=Path("/tmp/compose.yml"),
), mock.patch.object(launch_mod, "compose_up"), \
mock.patch.object(launch_mod, "compose_dump_logs"), \
mock.patch.object(launch_mod, "compose_down"), \
contextlib.redirect_stderr(io.StringIO()):
provision = mock.Mock(return_value=None)
with launch_mod.launch(plan, provision=provision):
pass
def _run_launch(
self, plan: DockerBottlePlan, *,
committed_tag: str | None = None, image_present: bool = True,
) -> list[str]:
def compose(_p: DockerBottlePlan, **_kw: Any) -> dict[str, Any]:
return {"services": {"agent": {}}}
with self._patched(
committed_tag=committed_tag, image_present=image_present, compose=compose,
) as built:
with launch_mod.launch(plan, provision=mock.Mock(return_value=None)):
pass
return built return built
def test_skips_build_when_committed_image_present(self) -> None: def test_skips_build_when_committed_image_present(self) -> None:
plan = _plan(self._tmp) built = self._run_launch(_plan(self._tmp), committed_tag=_COMMITTED_TAG, image_present=True)
built = self._run_launch(plan, committed_tag=_COMMITTED_TAG, image_present=True) self.assertEqual([], built) # committed image reused, no build
self.assertEqual([], built, "build_image should not be called when committed image exists")
def test_uses_committed_image_in_compose_spec(self) -> None: def test_uses_committed_image_in_compose_spec(self) -> None:
"""The compose spec renderer receives the committed image tag via captured: list[DockerBottlePlan] = []
plan.image — captured here by checking what bottle_plan_to_compose
was called with."""
plan = _plan(self._tmp)
captured_plans: list[DockerBottlePlan] = []
def fake_compose(p: DockerBottlePlan) -> dict[str, Any]: def compose(p: DockerBottlePlan, **_kw: Any) -> dict[str, Any]:
captured_plans.append(p) captured.append(p)
return {"services": {"agent": {}}} return {"services": {"agent": {}}}
with mock.patch.object( with self._patched(committed_tag=_COMMITTED_TAG, image_present=True, compose=compose):
launch_mod, "read_committed_image", return_value=_COMMITTED_TAG, with launch_mod.launch(_plan(self._tmp), provision=mock.Mock(return_value=None)):
), mock.patch.object(
launch_mod.docker_mod, "image_exists", return_value=True,
), mock.patch.object(
launch_mod.docker_mod, "build_image",
), mock.patch.object(
launch_mod, "egress_tls_init",
return_value=(Path("/egress_ca"), Path("/egress_cert")),
), mock.patch.object(
launch_mod.network_mod, "network_name_for_slug",
return_value="bb-internal",
), mock.patch.object(
launch_mod.network_mod, "network_egress_name_for_slug",
return_value="bb-egress",
), mock.patch.object(
launch_mod, "bottle_plan_to_compose", side_effect=fake_compose,
), mock.patch.object(
launch_mod, "write_compose_file",
return_value=Path("/tmp/compose.yml"),
), mock.patch.object(launch_mod, "compose_up"), \
mock.patch.object(launch_mod, "compose_dump_logs"), \
mock.patch.object(launch_mod, "compose_down"), \
contextlib.redirect_stderr(io.StringIO()):
provision = mock.Mock(return_value=None)
with launch_mod.launch(plan, provision=provision):
pass pass
self.assertEqual(_COMMITTED_TAG, captured[0].image)
self.assertEqual(1, len(captured_plans))
self.assertEqual(_COMMITTED_TAG, captured_plans[0].image)
def test_falls_back_to_build_when_no_committed_image(self) -> None: def test_falls_back_to_build_when_no_committed_image(self) -> None:
plan = _plan(self._tmp) self.assertEqual([_DEFAULT_IMAGE], self._run_launch(_plan(self._tmp), committed_tag=None))
built = self._run_launch(plan, committed_tag=None)
self.assertEqual([_DEFAULT_IMAGE], built)
def test_falls_back_to_build_when_committed_image_missing_from_daemon(self) -> None: def test_falls_back_to_build_when_committed_image_missing_from_daemon(self) -> None:
plan = _plan(self._tmp) built = self._run_launch(_plan(self._tmp), committed_tag=_COMMITTED_TAG, image_present=False)
built = self._run_launch(
plan, committed_tag=_COMMITTED_TAG, image_present=False,
)
self.assertEqual([_DEFAULT_IMAGE], built) self.assertEqual([_DEFAULT_IMAGE], built)
+17 -20
View File
@@ -19,9 +19,11 @@ from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.backend import BottleSpec from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker import launch as launch_mod from bot_bottle.backend.docker import launch as launch_mod
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.backend.docker.consolidated_launch import LaunchContext
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import ManifestIndex
from tests.unit import use_bottle_root
_INDEX = ManifestIndex.from_json_obj({ _INDEX = ManifestIndex.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": {}},
@@ -77,41 +79,36 @@ def _plan(tmp: str) -> DockerBottlePlan:
class TestTeardownWarning(unittest.TestCase): class TestTeardownWarning(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self._tmp = tempfile.mkdtemp(prefix="docker-launch-teardown-test.") self._tmp = tempfile.mkdtemp(prefix="docker-launch-teardown-test.")
self.addCleanup(lambda: __import__("shutil").rmtree(self._tmp, ignore_errors=True))
def tearDown(self) -> None: self.addCleanup(use_bottle_root(Path(self._tmp))) # sandbox the gateway CA write
import shutil
shutil.rmtree(self._tmp, ignore_errors=True)
def test_teardown_failure_emits_warning_with_container_and_operation(self): def test_teardown_failure_emits_warning_with_container_and_operation(self):
plan = _plan(self._tmp) plan = _plan(self._tmp)
buf = io.StringIO() buf = io.StringIO()
gw = mock.Mock()
gw.ca_cert_pem.return_value = "-----BEGIN CERTIFICATE-----\nx\n-----END CERTIFICATE-----\n"
ctx = LaunchContext(
bottle_id="b1", identity_token="t", source_ip="172.20.0.4",
network="bot-bottle-gateway", gateway_ip="172.20.0.2",
orchestrator_url="http://orch:8099",
)
with mock.patch.object(launch_mod.docker_mod, "build_image"), \ with mock.patch.object(launch_mod.docker_mod, "build_image"), \
mock.patch.object(launch_mod, "launch_consolidated", return_value=ctx), \
mock.patch.object(launch_mod, "teardown_consolidated"), \
mock.patch.object(launch_mod, "DockerGateway", return_value=gw), \
mock.patch.object( mock.patch.object(
launch_mod, "egress_tls_init", launch_mod, "consolidated_agent_compose",
return_value=(Path("/egress_ca"), Path("/egress_cert")),
), \
mock.patch.object(
launch_mod.network_mod, "network_name_for_slug",
return_value="bb-internal-test",
), \
mock.patch.object(
launch_mod.network_mod, "network_egress_name_for_slug",
return_value="bb-egress-test",
), \
mock.patch.object(
launch_mod, "bottle_plan_to_compose",
return_value={"services": {"agent": {}}}, return_value={"services": {"agent": {}}},
), \ ), \
mock.patch.object( mock.patch.object(
launch_mod, "write_compose_file", launch_mod, "write_compose_file", return_value=Path("/tmp/compose.yml"),
return_value=Path("/tmp/compose.yml"),
), \ ), \
mock.patch.object(launch_mod, "compose_up"), \ mock.patch.object(launch_mod, "compose_up"), \
mock.patch.object(launch_mod, "compose_dump_logs"), \ mock.patch.object(launch_mod, "compose_dump_logs"), \
mock.patch.object( mock.patch.object(
launch_mod, "compose_down", launch_mod, "compose_down",
side_effect=RuntimeError("network remove failed"), side_effect=RuntimeError("compose down failed"),
), \ ), \
contextlib.redirect_stderr(buf): contextlib.redirect_stderr(buf):
provision = mock.Mock(return_value=None) provision = mock.Mock(return_value=None)
+8 -6
View File
@@ -31,9 +31,9 @@ def _recorder(calls: list[list[str]]):
def _plan(*upstreams: GitGateUpstream) -> GitGatePlan: def _plan(*upstreams: GitGateUpstream) -> GitGatePlan:
return GitGatePlan( return GitGatePlan(
slug="demo", slug="demo",
entrypoint_script=Path(), entrypoint_script=Path("/stage/entrypoint.sh"),
hook_script=Path(), hook_script=Path("/stage/pre-receive"),
access_hook_script=Path(), access_hook_script=Path("/stage/access-hook"),
upstreams=tuple(upstreams), upstreams=tuple(upstreams),
) )
@@ -61,6 +61,8 @@ class TestProvisionGitGate(unittest.TestCase):
self.assertIn( self.assertIn(
["docker", "cp", "/host/kh", "gw:/git-gate/creds/bottle1/foo-known_hosts"], cps, ["docker", "cp", "/host/kh", "gw:/git-gate/creds/bottle1/foo-known_hosts"], cps,
) )
# The shared (bottle-agnostic) hooks are installed into the gateway.
self.assertIn(["docker", "cp", "/stage/pre-receive", "gw:/etc/git-gate/pre-receive"], cps)
# The init script runs in the gateway, namespaced under the bottle id. # 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"] exec_scripts = [c for c in calls if c[:3] == ["docker", "exec", "gw"] and c[3] == "sh"]
self.assertEqual(1, len(exec_scripts)) self.assertEqual(1, len(exec_scripts))
@@ -70,9 +72,9 @@ class TestProvisionGitGate(unittest.TestCase):
calls: list[list[str]] = [] calls: list[list[str]] = []
with patch(_RUN, side_effect=_recorder(calls)): with patch(_RUN, side_effect=_recorder(calls)):
provision_git_gate("gw", "b1", _plan(_up("foo"))) # no known_hosts provision_git_gate("gw", "b1", _plan(_up("foo"))) # no known_hosts
cps = [c for c in calls if c[:2] == ["docker", "cp"]] creds_cps = [c for c in calls if c[:2] == ["docker", "cp"] and "/git-gate/creds/" in c[3]]
self.assertEqual(1, len(cps)) # only the key, not known_hosts self.assertEqual(1, len(creds_cps)) # only the key, not known_hosts
self.assertTrue(cps[0][3].endswith("/foo-key")) self.assertTrue(creds_cps[0][3].endswith("/foo-key"))
def test_no_upstreams_is_noop(self) -> None: def test_no_upstreams_is_noop(self) -> None:
with patch(_RUN) as m: with patch(_RUN) as m: