From 96b84eb84d36c36e0ae2a63278701cb0decbe6be Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 14 Jul 2026 16:24:16 -0400 Subject: [PATCH 1/5] refactor(orchestrator): split conflated sidecar image into orchestrator + gateway images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One image — `bot-bottle-sidecars:latest`, built from `Dockerfile.sidecars` — served two unrelated roles: the egress/git-gate/supervise *data plane* and the orchestrator *control plane* (which ran the same image with the entrypoint overridden to `python3 -m bot_bottle.orchestrator`). The control plane is stdlib-only, so it needed none of the mitmproxy/git/gitleaks payload it was riding on — while being the most secret-dense process on the host (PRD 0070's "secret concentration"). Split into two purpose-built images: - `Dockerfile.gateway` -> `bot-bottle-gateway:latest` — the data plane (renamed from Dockerfile.sidecars; identical contents). - `Dockerfile.orchestrator` -> `bot-bottle-orchestrator:latest` — a lean `python:3.12-slim` runtime; the bind-mounted `bot_bottle` package supplies the code (so the #381 source-hash recreate semantics are unchanged). `OrchestratorService` now takes distinct `image` (control plane, default `ORCHESTRATOR_IMAGE`) and `gateway_image` (data plane, default `GATEWAY_IMAGE`) instead of feeding one `self.image` to both, and builds the lean image (build-if-missing) before starting the container. The per-bottle bundle constants in `backend/docker/sidecar_bundle.py` now alias the gateway constants so a bundle and the shared gateway can never drift onto different images. The `bot-bottle-sidecars` *image* name and `Dockerfile.sidecars` are gone; the per-bottle *container* name prefix (`bot-bottle-sidecars-`) is intentionally left for a separate change. Verified end-to-end: both images build; the lean image runs the control plane; `ensure_running` brings up the orchestrator on `bot-bottle-orchestrator:latest` and the gateway on `bot-bottle-gateway:latest` (distinct images) and reports healthy. Closes #384. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- Dockerfile.sidecars => Dockerfile.gateway | 10 +++- Dockerfile.orchestrator | 27 +++++++++++ bot_bottle/backend/docker/sidecar_bundle.py | 35 +++++++------- bot_bottle/egress_addon_core.py | 4 +- bot_bottle/egress_dlp_config.py | 2 +- bot_bottle/git_http_backend.py | 4 +- bot_bottle/orchestrator/gateway.py | 12 ++--- bot_bottle/orchestrator/lifecycle.py | 47 +++++++++++++++++-- bot_bottle/sidecar_init.py | 2 +- bot_bottle/supervise_server.py | 2 +- tests/README.md | 2 +- .../test_orchestrator_docker_gateway_build.py | 2 +- .../integration/test_sidecar_bundle_image.py | 6 +-- tests/unit/test_compose.py | 4 +- tests/unit/test_macos_container_util.py | 8 ++-- tests/unit/test_orchestrator_gateway.py | 6 +-- tests/unit/test_orchestrator_lifecycle.py | 40 ++++++++++++++++ 17 files changed, 165 insertions(+), 48 deletions(-) rename Dockerfile.sidecars => Dockerfile.gateway (89%) create mode 100644 Dockerfile.orchestrator diff --git a/Dockerfile.sidecars b/Dockerfile.gateway similarity index 89% rename from Dockerfile.sidecars rename to Dockerfile.gateway index 2d43a97..4660b0b 100644 --- a/Dockerfile.sidecars +++ b/Dockerfile.gateway @@ -1,4 +1,12 @@ -# Per-bottle sidecar bundle image (PRD 0024). +# Gateway data-plane image (PRD 0024 bundle shape; PRD 0070 gateway). +# +# The egress / git-gate / supervise *data plane* — one image, shared by +# the consolidated per-host gateway (PRD 0070) and, on backends that +# still run a per-bottle bundle, each bottle's sidecar. It is NOT the +# orchestrator control plane: that is the separate, lean +# `bot-bottle-orchestrator` image (Dockerfile.orchestrator, #384), which +# ships only python + the stdlib-only `bot_bottle` package and none of +# this image's mitmproxy / git / gitleaks payload. # # Collapses the prior per-sidecar images (egress, git-gate, # supervise) into one. A small stdlib-Python init supervisor at diff --git a/Dockerfile.orchestrator b/Dockerfile.orchestrator new file mode 100644 index 0000000..daa8129 --- /dev/null +++ b/Dockerfile.orchestrator @@ -0,0 +1,27 @@ +# Orchestrator control-plane image (PRD 0070, #384). +# +# The per-host orchestrator runs `python3 -m bot_bottle.orchestrator`. +# The `bot_bottle` package is **stdlib-only** by design, so the control +# plane needs nothing but a Python runtime — none of the gateway's +# mitmproxy / git / gitleaks payload (that is the separate +# `bot-bottle-gateway` image, Dockerfile.gateway). Splitting them keeps +# the secret-dense control plane (it concentrates every bottle's egress +# tokens — see PRD 0070's "secret concentration") on a minimal +# dependency surface. +# +# The repo is bind-mounted read-only into the container at run time (see +# `orchestrator/lifecycle.py`), so the source is NOT copied in here: the +# image is just the runtime. `ensure_running` recreates the container +# only when the bind-mounted source hash changes (#381), which is why +# the code stays a mount rather than a baked layer. + +FROM python:3.12-slim + +# No third-party deps to install — stdlib only. Kept as an explicit, +# self-documenting stage so a future confinement step (baking the +# package, dropping the bind mount) has an obvious home. +WORKDIR /app + +# Documentation only; lifecycle.py overrides the entrypoint to +# `python3 -m bot_bottle.orchestrator` with the runtime flags. +ENTRYPOINT ["python3", "-m", "bot_bottle.orchestrator"] diff --git a/bot_bottle/backend/docker/sidecar_bundle.py b/bot_bottle/backend/docker/sidecar_bundle.py index af3d39e..105d044 100644 --- a/bot_bottle/backend/docker/sidecar_bundle.py +++ b/bot_bottle/backend/docker/sidecar_bundle.py @@ -1,30 +1,33 @@ """Sidecar bundle constants + helpers for the Docker backend (PRD 0024). -The bundle image (built by Dockerfile.sidecars, PRD 0024 chunk 1) -runs egress + git-gate + supervise as one container per bottle -under a small Python init supervisor. As of chunk 5 the bundle -is the only shape — the legacy four-sidecar topology and its -`BOT_BOTTLE_SIDECAR_BUNDLE` feature flag are gone.""" +A per-bottle sidecar bundle runs egress + git-gate + supervise as one +container under a small Python init supervisor. As of PRD 0024 chunk 5 +the bundle is the only shape — the legacy four-sidecar topology and its +`BOT_BOTTLE_SIDECAR_BUNDLE` feature flag are gone. + +The bundle's image is the **same data plane** as the consolidated +per-host gateway (PRD 0070), so it is one image with one name and one +Dockerfile: `bot-bottle-gateway` / `Dockerfile.gateway` (#384). These +constants alias the gateway's so a per-bottle bundle and the shared +gateway can never drift onto different images.""" from __future__ import annotations -import os +from ...orchestrator.gateway import GATEWAY_DOCKERFILE, GATEWAY_IMAGE -# Bundle image. Defaults to a built-locally tag (built from the -# repo's Dockerfile.sidecars via compose `build:`). Operators -# pinning to a published digest can override via env. -SIDECAR_BUNDLE_IMAGE = os.environ.get( - "BOT_BOTTLE_SIDECAR_IMAGE", - "bot-bottle-sidecars:latest", -) - -SIDECAR_BUNDLE_DOCKERFILE = "Dockerfile.sidecars" +# The per-bottle bundle image == the gateway data-plane image (aliased so +# there is one source of truth; the `BOT_BOTTLE_GATEWAY_IMAGE` env override +# on the gateway constant applies here too). +SIDECAR_BUNDLE_IMAGE = GATEWAY_IMAGE +SIDECAR_BUNDLE_DOCKERFILE = GATEWAY_DOCKERFILE def sidecar_bundle_container_name(slug: str) -> str: """`bot-bottle-sidecars-`. Same prefix scheme as the per-sidecar containers it replaces, so the dashboard's - discovery-by-prefix logic keeps working.""" + discovery-by-prefix logic keeps working. (The per-bottle *container* + name keeps the historical `sidecars-` prefix; only the *image* was + renamed to `bot-bottle-gateway` in #384.)""" return f"bot-bottle-sidecars-{slug}" diff --git a/bot_bottle/egress_addon_core.py b/bot_bottle/egress_addon_core.py index 24f632c..c4fa9a1 100644 --- a/bot_bottle/egress_addon_core.py +++ b/bot_bottle/egress_addon_core.py @@ -8,7 +8,7 @@ container. Imports: stdlib + `yaml_subset` (which is itself stdlib-only and ships flat into the sidecar bundle image alongside this file — -see `Dockerfile.sidecars`).""" +see `Dockerfile.gateway`).""" from __future__ import annotations @@ -22,7 +22,7 @@ except ImportError: # pragma: no cover - host-side path from .yaml_subset import YamlSubsetError, parse_yaml_subset # DLP detector-config parsing lives in a sibling module (also flat-bundled -# into the gateway — see Dockerfile.sidecars). Re-exported below so existing +# into the gateway — see Dockerfile.gateway). Re-exported below so existing # `from egress_addon_core import ON_MATCH_*` callers keep working. try: from egress_dlp_config import ( # type: ignore[import-not-found] diff --git a/bot_bottle/egress_dlp_config.py b/bot_bottle/egress_dlp_config.py index 29892ee..faee229 100644 --- a/bot_bottle/egress_dlp_config.py +++ b/bot_bottle/egress_dlp_config.py @@ -7,7 +7,7 @@ kept apart from the request-time scan/decision flow in `egress_addon_core` so each half reads top-to-bottom without scrolling past the other. Stdlib-only; ships flat into the sidecar bundle image alongside -`egress_addon_core.py` — see `Dockerfile.sidecars`.""" +`egress_addon_core.py` — see `Dockerfile.gateway`.""" from __future__ import annotations diff --git a/bot_bottle/git_http_backend.py b/bot_bottle/git_http_backend.py index 25275ac..7564513 100644 --- a/bot_bottle/git_http_backend.py +++ b/bot_bottle/git_http_backend.py @@ -28,7 +28,7 @@ from pathlib import Path from urllib.parse import urlsplit # policy_resolver ships flat alongside this file in the sidecar bundle -# image (see Dockerfile.sidecars); the bot_bottle.* fallback is the +# image (see Dockerfile.gateway); the bot_bottle.* fallback is the # host-side / test path. Mirrors egress_addon's import shape. try: from policy_resolver import ( # type: ignore[import-not-found] @@ -104,7 +104,7 @@ def resolve_sandbox_root( # Mirrors git_gate_render.GIT_GATE_TIMEOUT_SECS. Duplicated rather than # imported: this module ships as a flat top-level sibling in the sidecar -# bundle image (see Dockerfile.sidecars), not as part of the bot_bottle +# bundle image (see Dockerfile.gateway), not as part of the bot_bottle # package, so `bot_bottle.git_gate` and its dependency chain aren't # available at runtime. GIT_GATE_TIMEOUT_SECS = 15 diff --git a/bot_bottle/orchestrator/gateway.py b/bot_bottle/orchestrator/gateway.py index 17dccd1..7c11466 100644 --- a/bot_bottle/orchestrator/gateway.py +++ b/bot_bottle/orchestrator/gateway.py @@ -45,12 +45,12 @@ 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 +# The gateway data-plane image + its Dockerfile. Kept as a local constant # rather than imported from backend.docker.sidecar_bundle, which would drag # the whole backend layer into the lean orchestrator (see #359); unify when -# that lands. Env override matches the backend's BOT_BOTTLE_SIDECAR_IMAGE. -GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_SIDECAR_IMAGE", "bot-bottle-sidecars:latest") -GATEWAY_DOCKERFILE = "Dockerfile.sidecars" +# that lands. Env override matches the backend's BOT_BOTTLE_GATEWAY_IMAGE. +GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest") +GATEWAY_DOCKERFILE = "Dockerfile.gateway" _REPO_ROOT = Path(__file__).resolve().parents[2] @@ -86,8 +86,8 @@ class Gateway(abc.ABC): class DockerGateway(Gateway): """The consolidated gateway as a single, fixed-name Docker container. - `image_ref` defaults to the real sidecar-bundle image; `ensure_built` - builds it from `Dockerfile.sidecars` when it's missing. (Note: slice 5 + `image_ref` defaults to the gateway data-plane image; `ensure_built` + builds it from `Dockerfile.gateway` when it's missing. (Note: slice 5 builds + launches the bundle container; wiring its per-bottle, source-IP-keyed config is a later slice — see PRD 0070.)""" diff --git a/bot_bottle/orchestrator/lifecycle.py b/bot_bottle/orchestrator/lifecycle.py index f8b70fa..ad80bd3 100644 --- a/bot_bottle/orchestrator/lifecycle.py +++ b/bot_bottle/orchestrator/lifecycle.py @@ -17,6 +17,7 @@ names + the published port). from __future__ import annotations import hashlib +import os import time import urllib.error import urllib.request @@ -25,11 +26,19 @@ from pathlib import Path from .. import log from ..docker_cmd import run_docker from ..paths import bot_bottle_root -from .gateway import GATEWAY_IMAGE, GATEWAY_NETWORK, DockerGateway +from .gateway import GATEWAY_IMAGE, GATEWAY_NETWORK, DockerGateway, GatewayError DEFAULT_PORT = 8099 ORCHESTRATOR_NAME = "bot-bottle-orchestrator" ORCHESTRATOR_LABEL = "bot-bottle-orchestrator=1" +# The control-plane's own runtime image — lean (python + the stdlib-only +# `bot_bottle` package, bind-mounted at run time), distinct from the heavy +# gateway data-plane image it used to borrow (#384). Env override for +# operators pinning a published build. +ORCHESTRATOR_IMAGE = os.environ.get( + "BOT_BOTTLE_ORCHESTRATOR_IMAGE", "bot-bottle-orchestrator:latest" +) +ORCHESTRATOR_DOCKERFILE = "Dockerfile.orchestrator" # Baked onto the container as a label so `ensure_running` can tell whether the # running process is executing the *current* bind-mounted source — see # `_source_hash`. @@ -37,7 +46,7 @@ ORCHESTRATOR_SOURCE_HASH_LABEL = "bot-bottle-orchestrator-source-hash" # The repo root is bind-mounted into the control-plane container so # `python -m bot_bottle.orchestrator` resolves the package (the orchestrator -# is stdlib-only, so the bundle image's python is enough). +# is stdlib-only, so the lean orchestrator image's python is enough). _REPO_ROOT = Path(__file__).resolve().parents[2] _APP_DIR = "/app" _ROOT_IN_CONTAINER = "/bot-bottle-root" @@ -76,13 +85,19 @@ class OrchestratorService: *, port: int = DEFAULT_PORT, network: str = GATEWAY_NETWORK, - image: str = GATEWAY_IMAGE, + image: str = ORCHESTRATOR_IMAGE, + gateway_image: str = GATEWAY_IMAGE, repo_root: Path = _REPO_ROOT, host_root: Path | None = None, ) -> None: self.port = port self.network = network + # Two distinct images (#384): `image` is the lean control-plane + # runtime this container runs; `_gateway_image` is the heavy egress / + # git-gate / supervise data plane the gateway container runs. They + # were one conflated image before the split. self.image = image + self._gateway_image = gateway_image self._repo_root = repo_root self._host_root = host_root or bot_bottle_root() @@ -141,7 +156,29 @@ class OrchestratorService: ) def _gateway(self) -> DockerGateway: - return DockerGateway(self.image, network=self.network, orchestrator_url=self.internal_url) + return DockerGateway( + self._gateway_image, network=self.network, orchestrator_url=self.internal_url + ) + + def _ensure_orchestrator_image(self) -> None: + """Build the lean control-plane image from `Dockerfile.orchestrator` + when it's missing (#384). Cheap — a `FROM python:*-slim` base with no + deps to install, so the layer cache makes rebuilds a no-op. Unlike the + gateway image this is build-if-missing, not build-every-time: the + control plane bind-mounts its source, so a code change is caught by the + source-hash recreate (below), not by an image rebuild.""" + if run_docker(["docker", "image", "inspect", self.image]).returncode == 0: + return + argv = ["docker", "build", "-t", self.image, + "-f", str(self._repo_root / ORCHESTRATOR_DOCKERFILE), + str(self._repo_root)] + if os.environ.get("BOT_BOTTLE_NO_CACHE"): + argv.insert(2, "--no-cache") + proc = run_docker(argv) + if proc.returncode != 0: + raise GatewayError( + f"orchestrator image build failed: {proc.stderr.strip()}" + ) def _orchestrator_source_current(self, current_hash: str) -> bool: """True iff the running orchestrator container was created from the @@ -181,6 +218,7 @@ class OrchestratorService: if self.is_healthy() and self._orchestrator_source_current(current_hash): return self.url + self._ensure_orchestrator_image() log.info("starting orchestrator container", context={"name": ORCHESTRATOR_NAME}) self._run_orchestrator_container(current_hash) @@ -204,6 +242,7 @@ __all__ = [ "OrchestratorService", "OrchestratorStartError", "ORCHESTRATOR_NAME", + "ORCHESTRATOR_IMAGE", "DEFAULT_PORT", "DEFAULT_STARTUP_TIMEOUT_SECONDS", ] diff --git a/bot_bottle/sidecar_init.py b/bot_bottle/sidecar_init.py index 835c193..ac4478b 100644 --- a/bot_bottle/sidecar_init.py +++ b/bot_bottle/sidecar_init.py @@ -1,6 +1,6 @@ """Per-bottle sidecar supervisor (PRD 0024 chunk 1). -PID 1 inside the `bot-bottle-sidecars` bundle image. Spawns +PID 1 inside the `bot-bottle-gateway` data-plane image. Spawns the configured daemons (egress, git-gate, supervise), forwards SIGTERM/SIGINT to each child, and propagates per-daemon stdout+stderr to the container log with a `[name] ` prefix. diff --git a/bot_bottle/supervise_server.py b/bot_bottle/supervise_server.py index e6c570d..474b01a 100644 --- a/bot_bottle/supervise_server.py +++ b/bot_bottle/supervise_server.py @@ -50,7 +50,7 @@ from dataclasses import dataclass, replace try: # Same-directory imports inside the bundle container; these files are - # COPYed flat under /app by Dockerfile.sidecars. + # COPYed flat under /app by Dockerfile.gateway. from egress_addon_core import LOG_OFF, load_config from policy_resolver import PolicyResolveError, PolicyResolver import supervise as _sv diff --git a/tests/README.md b/tests/README.md index c3275d2..b80135c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -48,7 +48,7 @@ Discovery is invoked with `-t .` (top-level dir = repo root) so the the bottle's runtime, and creates zero Docker resources. - `test_orphan_cleanup.py` — `network_remove` is idempotent against missing resources, so the EXIT trap can call it unconditionally. -- `test_sidecar_bundle_image.py` — builds Dockerfile.sidecars and +- `test_sidecar_bundle_image.py` — builds Dockerfile.gateway and probes that gitleaks / mitmdump / supervise are all reachable inside the bundle. - `test_sidecar_bundle_compose.py` — end-to-end compose-up of an diff --git a/tests/integration/test_orchestrator_docker_gateway_build.py b/tests/integration/test_orchestrator_docker_gateway_build.py index 66f7254..aa7c328 100644 --- a/tests/integration/test_orchestrator_docker_gateway_build.py +++ b/tests/integration/test_orchestrator_docker_gateway_build.py @@ -21,7 +21,7 @@ IMAGE = "busybox" class TestDockerGatewayImageExists(unittest.TestCase): def test_image_exists_true_for_present_false_for_absent(self) -> None: # Ensure the tiny image is present (build_if_missing is disabled here - # so this never triggers a Dockerfile.sidecars build). + # so this never triggers a Dockerfile.gateway build). subprocess.run( ["docker", "pull", IMAGE], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, diff --git a/tests/integration/test_sidecar_bundle_image.py b/tests/integration/test_sidecar_bundle_image.py index 771f438..42212dd 100644 --- a/tests/integration/test_sidecar_bundle_image.py +++ b/tests/integration/test_sidecar_bundle_image.py @@ -6,7 +6,7 @@ config (routes.yaml, etc) — that lands in chunk 2 when the renderer wires the bundle into compose. What we verify here is the chunk-1 contract: - - Dockerfile.sidecars builds (multi-stage works, base layers + - Dockerfile.gateway builds (multi-stage works, base layers pull, COPYs resolve). - gitleaks, mitmdump are at the documented paths and answer `--version`. @@ -28,8 +28,8 @@ import unittest from tests._docker import skip_unless_docker -_IMAGE = "bot-bottle-sidecars-test:chunk1" -_DOCKERFILE = "Dockerfile.sidecars" +_IMAGE = "bot-bottle-gateway-test:chunk1" +_DOCKERFILE = "Dockerfile.gateway" @skip_unless_docker() diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index fa83cb3..8e44aa2 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -297,8 +297,8 @@ class TestSidecarBundleShape(unittest.TestCase): def test_bundle_uses_bundle_image_and_dockerfile(self): sc = self._render()["services"]["sidecars"] - self.assertEqual("bot-bottle-sidecars:latest", sc["image"]) - self.assertEqual("Dockerfile.sidecars", sc["build"]["dockerfile"]) + self.assertEqual("bot-bottle-gateway:latest", sc["image"]) + self.assertEqual("Dockerfile.gateway", sc["build"]["dockerfile"]) def test_bundle_container_name_uses_sidecars_prefix(self): sc = self._render()["services"]["sidecars"] diff --git a/tests/unit/test_macos_container_util.py b/tests/unit/test_macos_container_util.py index 2e0f3bd..213526f 100644 --- a/tests/unit/test_macos_container_util.py +++ b/tests/unit/test_macos_container_util.py @@ -88,14 +88,14 @@ resolver #2 "BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9", }): util.build_image( - "bot-bottle-sidecars:latest", + "bot-bottle-gateway:latest", "/repo", - dockerfile="Dockerfile.sidecars", + dockerfile="Dockerfile.gateway", ) self.assertEqual( [ - "container", "build", "-t", "bot-bottle-sidecars:latest", - "--dns", "9.9.9.9", "-f", "/repo/Dockerfile.sidecars", "/repo", + "container", "build", "-t", "bot-bottle-gateway:latest", + "--dns", "9.9.9.9", "-f", "/repo/Dockerfile.gateway", "/repo", ], run.call_args_list[-1].args[0], ) diff --git a/tests/unit/test_orchestrator_gateway.py b/tests/unit/test_orchestrator_gateway.py index 2836528..09a53f4 100644 --- a/tests/unit/test_orchestrator_gateway.py +++ b/tests/unit/test_orchestrator_gateway.py @@ -24,7 +24,7 @@ def _proc(returncode: int = 0, stdout: str = "", stderr: str = "") -> Mock: class TestDockerGateway(unittest.TestCase): def setUp(self) -> None: - self.sc = DockerGateway("bot-bottle-sidecars:latest") + self.sc = DockerGateway("bot-bottle-gateway:latest") def test_default_name(self) -> None: self.assertEqual(GATEWAY_NAME, self.sc.name) @@ -86,7 +86,7 @@ class TestDockerGateway(unittest.TestCase): runs = [c for c in calls if c[:2] == ["docker", "run"]] self.assertEqual(1, len(runs)) self.assertIn(self.sc.name, runs[0]) - self.assertIn("bot-bottle-sidecars:latest", runs[0]) + self.assertIn("bot-bottle-gateway: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. @@ -183,7 +183,7 @@ class TestDockerGatewayBuild(unittest.TestCase): builds = [c for c in calls if c[:2] == ["docker", "build"]] self.assertEqual(1, len(builds)) self.assertIn(self.sc.image_ref, builds[0]) - self.assertTrue(any(a.endswith("Dockerfile.sidecars") for a in builds[0])) + self.assertTrue(any(a.endswith("Dockerfile.gateway") for a in builds[0])) self.assertNotIn("--no-cache", builds[0]) def test_ensure_built_no_cache_env_forces_full_rebuild(self) -> None: diff --git a/tests/unit/test_orchestrator_lifecycle.py b/tests/unit/test_orchestrator_lifecycle.py index 30a7f82..abc27c5 100644 --- a/tests/unit/test_orchestrator_lifecycle.py +++ b/tests/unit/test_orchestrator_lifecycle.py @@ -9,6 +9,7 @@ from pathlib import Path from unittest.mock import MagicMock, Mock, patch from bot_bottle.orchestrator.lifecycle import ( + ORCHESTRATOR_IMAGE, ORCHESTRATOR_NAME, ORCHESTRATOR_SOURCE_HASH_LABEL, OrchestratorService, @@ -121,6 +122,45 @@ class TestOrchestratorService(unittest.TestCase): self.assertIn("bot_bottle.orchestrator", argv) self.assertEqual("127.0.0.1:8099:8099", argv[argv.index("--publish") + 1]) + def test_ensure_running_builds_lean_orchestrator_image_when_missing(self) -> None: + # The control plane runs its own lean image (#384), distinct from the + # gateway data plane — built from Dockerfile.orchestrator when absent. + calls: list[list[str]] = [] + + def fake(argv: list[str]) -> Mock: + calls.append(argv) + if argv[:2] == ["docker", "ps"]: + return _proc(stdout="") # orchestrator not running + if argv[:3] == ["docker", "image", "inspect"]: + return _proc(returncode=1) # image absent -> build + return _proc() + + with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \ + patch(_GATEWAY), patch(_RUN, side_effect=fake), patch(_SLEEP): + self.svc.ensure_running() + builds = [c for c in calls if c[:2] == ["docker", "build"]] + self.assertEqual(1, len(builds)) + self.assertIn(ORCHESTRATOR_IMAGE, builds[0]) + self.assertTrue(any(a.endswith("Dockerfile.orchestrator") for a in builds[0])) + # It is NOT the gateway image/dockerfile — the split is the point. + self.assertFalse(any("Dockerfile.gateway" in a for a in builds[0])) + + def test_ensure_running_skips_orchestrator_image_build_when_present(self) -> None: + calls: list[list[str]] = [] + + def fake(argv: list[str]) -> Mock: + calls.append(argv) + if argv[:2] == ["docker", "ps"]: + return _proc(stdout="") + if argv[:3] == ["docker", "image", "inspect"]: + return _proc(returncode=0) # image present -> no build + return _proc() + + with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \ + patch(_GATEWAY), patch(_RUN, side_effect=fake), patch(_SLEEP): + self.svc.ensure_running() + self.assertEqual([], [c for c in calls if c[:2] == ["docker", "build"]]) + def test_ensure_running_raises_on_timeout(self) -> None: with patch(_URLOPEN, side_effect=urllib.error.URLError("down")), \ patch(_GATEWAY), patch(_RUN, return_value=Mock(returncode=0, stderr="")), \ -- 2.52.0 From c10d1cb6e01d5302803277c673e534734ea17c96 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 14 Jul 2026 16:46:46 -0400 Subject: [PATCH 2/5] =?UTF-8?q?refactor(de-sidecar):=20rename=20sidecar=5F?= =?UTF-8?q?init=E2=86=92gateway=5Finit,=20drop=20docker's=20dead=20per-bot?= =?UTF-8?q?tle=20compose=20renderer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of the de-sidecar cleanup (#385 discussion): the per-bottle companion container is the old architecture. - Rename `bot_bottle/sidecar_init.py` → `gateway_init.py` (it's the gateway image's PID-1 supervisor); env var `BOT_BOTTLE_SIDECAR_DAEMONS` → `BOT_BOTTLE_GATEWAY_DAEMONS`; log prefix `sidecar-init:` → `gateway-init:`. Update Dockerfile.gateway COPY/ENTRYPOINT and the test. - Remove the dead per-bottle compose renderer from `backend/docker/compose.py` (`bottle_plan_to_compose`, `_sidecar_bundle_service`, `_agent_service`, and the network/bind/proxy helpers). Docker's live path uses `consolidated_agent_compose`; only the compose *lifecycle* helpers (up/down/ls/write) remain. Trim `test_compose.py` to the surviving helpers. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- Dockerfile.gateway | 15 +- bot_bottle/backend/docker/compose.py | 249 +---------- .../{sidecar_init.py => gateway_init.py} | 26 +- tests/unit/test_compose.py | 419 +----------------- ...t_sidecar_init.py => test_gateway_init.py} | 30 +- 5 files changed, 45 insertions(+), 694 deletions(-) rename bot_bottle/{sidecar_init.py => gateway_init.py} (94%) rename tests/unit/{test_sidecar_init.py => test_gateway_init.py} (95%) diff --git a/Dockerfile.gateway b/Dockerfile.gateway index 4660b0b..e5456ea 100644 --- a/Dockerfile.gateway +++ b/Dockerfile.gateway @@ -1,16 +1,15 @@ # Gateway data-plane image (PRD 0024 bundle shape; PRD 0070 gateway). # -# The egress / git-gate / supervise *data plane* — one image, shared by -# the consolidated per-host gateway (PRD 0070) and, on backends that -# still run a per-bottle bundle, each bottle's sidecar. It is NOT the +# The egress / git-gate / supervise *data plane* — one image, run by +# the consolidated per-host gateway (PRD 0070). It is NOT the # orchestrator control plane: that is the separate, lean # `bot-bottle-orchestrator` image (Dockerfile.orchestrator, #384), which # ships only python + the stdlib-only `bot_bottle` package and none of # this image's mitmproxy / git / gitleaks payload. # -# Collapses the prior per-sidecar images (egress, git-gate, +# Collapses the prior per-daemon images (egress, git-gate, # supervise) into one. A small stdlib-Python init supervisor at -# /app/sidecar_init.py spawns all daemons, forwards SIGTERM, and +# /app/gateway_init.py spawns all daemons, forwards SIGTERM, and # propagates per-daemon stdout/stderr to the container log with a # `[name]` prefix. See PRD 0024 for the rationale. # @@ -20,7 +19,7 @@ # /app/egress_addon.py + siblings mitmproxy addon (egress) # /app/egress-entrypoint.sh mitmdump launcher # /app/supervise_server.py + .py supervise MCP server -# /app/sidecar_init.py PID 1 supervisor +# /app/gateway_init.py PID 1 supervisor # /etc/egress/routes.yaml bind-mounted at run time # /etc/git-gate/pre-receive docker-cp'd at start time # /git-gate-entrypoint.sh docker-cp'd at start time @@ -84,7 +83,7 @@ COPY bot_bottle/audit_store.py /app/audit_store.py COPY bot_bottle/store_manager.py /app/store_manager.py COPY bot_bottle/supervise.py /app/supervise.py COPY bot_bottle/supervise_server.py /app/supervise_server.py -COPY bot_bottle/sidecar_init.py /app/sidecar_init.py +COPY bot_bottle/gateway_init.py /app/gateway_init.py COPY bot_bottle/git_http_backend.py /app/git_http_backend.py COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh RUN chmod +x /app/egress-entrypoint.sh @@ -110,4 +109,4 @@ WORKDIR /app # PID 1 is the supervisor. It owns signal handling and exit-code # propagation; no `exec` chain in the entrypoint itself. -ENTRYPOINT ["python3", "/app/sidecar_init.py"] +ENTRYPOINT ["python3", "/app/gateway_init.py"] diff --git a/bot_bottle/backend/docker/compose.py b/bot_bottle/backend/docker/compose.py index 9929610..7cfe940 100644 --- a/bot_bottle/backend/docker/compose.py +++ b/bot_bottle/backend/docker/compose.py @@ -1,20 +1,10 @@ -"""Compose-spec rendering for a Docker bottle (PRD 0018, chunk 1). +"""Docker compose lifecycle helpers (PRD 0018). -`bottle_plan_to_compose(plan)` returns a Compose v2 spec dict -describing the per-bottle container topology — one project per -bottle instance, services for the agent + every applicable sidecar, -two networks, no named volumes. - -Pure function. No I/O, no subprocess. Expects every launch-time -field (network names, CA host paths, etc.) on the plan's inner -plans to be populated; chunks 2+3 own that ordering. - -Conditional services follow the plan content: - - - agent + sidecars bundle: always. - - git-gate: iff plan.git_gate_plan.upstreams. - - egress: iff plan.egress_plan.routes. - - supervise: iff plan.supervise_plan is not None. +Serialize a compose spec to disk, drive `docker compose up/down`, +dump the merged log on teardown, and enumerate `bot-bottle-*` +projects. The spec itself is built by `consolidated_compose.py` +(the consolidated per-host gateway topology); this module owns the +I/O side that persists and runs it. """ from __future__ import annotations @@ -25,233 +15,7 @@ import sys from pathlib import Path from typing import Any -from ...egress import ( - EGRESS_HOSTNAME, - EGRESS_ROUTES_IN_CONTAINER, - egress_agent_env_entries, - egress_sidecar_env_entries, -) -from ...git_gate import GIT_GATE_HOSTNAME from ...log import die, warn -from ...supervise import ( - DB_PATH_IN_CONTAINER, - SUPERVISE_HOSTNAME, - SUPERVISE_PORT, -) -from ...util import expand_tilde -from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH -from .bottle_plan import DockerBottlePlan -from .egress import ( - EGRESS_CA_IN_CONTAINER, - EGRESS_PORT, -) -from .git_gate import ( - GIT_GATE_ACCESS_HOOK_IN_CONTAINER, - GIT_GATE_CREDS_DIR_IN_CONTAINER, - GIT_GATE_ENTRYPOINT_IN_CONTAINER, - GIT_GATE_HOOK_IN_CONTAINER, -) -from . import network as network_mod -from .sidecar_bundle import ( - SIDECAR_BUNDLE_DOCKERFILE, - SIDECAR_BUNDLE_IMAGE, - sidecar_bundle_container_name, -) - - -# Repo root, used as the build context for the bundle Dockerfile. -_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) - - -def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]: - """Render a Compose v2 spec dict from a fully-resolved - DockerBottlePlan. - - The plan must have its inner plans (`git_gate_plan`, - `egress_plan`, `supervise_plan`) populated with launch-time - fields — network names, CA host paths. The renderer doesn't - validate; callers feed it a fully-resolved plan or get an - incomplete compose spec back. - """ - project = f"bot-bottle-{plan.slug}" - services: dict[str, Any] = { - "sidecars": _sidecar_bundle_service(plan), - "agent": _agent_service(plan), - } - return { - "name": project, - "services": services, - "networks": _networks(plan), - } - - -def _networks(plan: DockerBottlePlan) -> dict[str, Any]: - """Compose-managed networks with explicit `name:` matching the - existing slug-suffixed convention. Compose creates them on `up` - and destroys them on `down`. The internal one is `--internal` - (no default gateway); the egress one is a normal user-defined - bridge.""" - return { - "internal": { - "name": network_mod.network_name_for_slug(plan.slug), - "internal": True, - }, - "egress": { - "name": network_mod.network_egress_name_for_slug(plan.slug), - }, - } - - -def _bind(host: str | Path, target: str, *, read_only: bool = True) -> dict[str, Any]: - """One bind-mount entry in the long-form `volumes:` shape. - Long form is preferred over `host:target:ro` strings because - it's easier to inspect in tests and survives whitespace in - host paths.""" - return { - "type": "bind", - "source": str(host), - "target": target, - "read_only": read_only, - } - - -def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]: - """The `sidecars` service: one container per bottle, bundle - image, all daemons under a Python init supervisor. - - Daemon subset narrows via `BOT_BOTTLE_SIDECAR_DAEMONS` env. - egress is always present; git-gate / supervise are conditional. - """ - daemons: list[str] = ["egress"] - if plan.git_gate_plan.upstreams: - daemons.append("git-gate") - if plan.supervise_plan is not None: - daemons.append("supervise") - - env: list[str] = [f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"] - volumes: list[dict[str, Any]] = [] - - # --- egress ------------------------------------------------------- - ep = plan.egress_plan - volumes.append(_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER)) - if ep.routes: - volumes.append(_bind(ep.routes_path.parent, str(Path(EGRESS_ROUTES_IN_CONTAINER).parent))) - env.extend(egress_sidecar_env_entries(ep)) - - # --- git-gate ----------------------------------------------------- - gp = plan.git_gate_plan - if gp.upstreams: - volumes += [ - _bind(gp.entrypoint_script, GIT_GATE_ENTRYPOINT_IN_CONTAINER), - _bind(gp.hook_script, GIT_GATE_HOOK_IN_CONTAINER), - _bind(gp.access_hook_script, GIT_GATE_ACCESS_HOOK_IN_CONTAINER), - ] - for u in gp.upstreams: - keypath = expand_tilde(u.identity_file) - volumes.append(_bind( - keypath, - f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key", - )) - if u.known_hosts_file: - volumes.append(_bind( - u.known_hosts_file, - f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts", - )) - - # --- supervise ---------------------------------------------------- - sp = plan.supervise_plan - if sp is not None: - env += [ - f"SUPERVISE_BOTTLE_SLUG={plan.slug}", - f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", - f"SUPERVISE_PORT={SUPERVISE_PORT}", - ] - volumes.append({ - "type": "bind", - "source": str(sp.db_path), - "target": DB_PATH_IN_CONTAINER, - "read_only": False, - }) - internal_aliases = [EGRESS_HOSTNAME] - if gp.upstreams: - internal_aliases.append(GIT_GATE_HOSTNAME) - if sp is not None: - internal_aliases.append(SUPERVISE_HOSTNAME) - - service: dict[str, Any] = { - "image": SIDECAR_BUNDLE_IMAGE, - "build": { - "context": _REPO_DIR, - "dockerfile": SIDECAR_BUNDLE_DOCKERFILE, - }, - "container_name": sidecar_bundle_container_name(plan.slug), - "networks": { - "internal": {"aliases": internal_aliases}, - "egress": None, - }, - "environment": env, - "volumes": volumes, - } - return service - - -def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]: - """Agent container. Runs `sleep infinity`; claude is `docker - exec -it`'d into it later. HTTP_PROXY/HTTPS_PROXY point at the - egress sidecar.""" - proxy_url = _agent_proxy_url(plan) - no_proxy = _agent_no_proxy(plan) - env: list[str] = [ - f"HTTPS_PROXY={proxy_url}", - f"HTTP_PROXY={proxy_url}", - f"https_proxy={proxy_url}", - f"http_proxy={proxy_url}", - f"NO_PROXY={no_proxy}", - f"no_proxy={no_proxy}", - f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}", - f"SSL_CERT_FILE={AGENT_CA_BUNDLE}", - f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}", - ] - for name, value in sorted(plan.agent_provision.guest_env.items()): - env.append(f"{name}={value}") - # Forwarded vars (OAuth token, manifest host-interpolations): - # bare name → inherits from compose-up process env, value - # never lands on argv or in the compose file. - for name in sorted(plan.forwarded_env.keys()): - env.append(name) - env.extend(egress_agent_env_entries(plan.egress_plan)) - - service: dict[str, Any] = { - "image": plan.image, - "container_name": plan.container_name, - "command": ["sleep", "infinity"], - "networks": {"internal": None}, - "environment": env, - } - if plan.use_runsc: - service["runtime"] = "runsc" - - # The init supervisor inside the bundle owns intra-bundle - # daemon ordering, so the agent only waits for the bundle - # container itself. - service["depends_on"] = ["sidecars"] - - return service - - -def _agent_proxy_url(plan: DockerBottlePlan) -> str: - """Agent's HTTP_PROXY — always points at egress.""" - return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}" - - -def _agent_no_proxy(plan: DockerBottlePlan) -> str: - """NO_PROXY for the agent: loopback always; supervise hostname - when the supervise sidecar is up (MCP long-poll must bypass - the egress proxy).""" - hosts = ["localhost", "127.0.0.1"] - if plan.supervise_plan is not None: - hosts.append(SUPERVISE_HOSTNAME) - return ",".join(hosts) # --- Lifecycle helpers (PRD 0018 chunk 3) ---------------------------------- @@ -442,7 +206,6 @@ __all__ = [ "COMPOSE_FILE_NAME", "COMPOSE_LOG_NAME", "COMPOSE_PROJECT_PREFIX", - "bottle_plan_to_compose", "compose_down", "compose_dump_logs", "compose_file_path", diff --git a/bot_bottle/sidecar_init.py b/bot_bottle/gateway_init.py similarity index 94% rename from bot_bottle/sidecar_init.py rename to bot_bottle/gateway_init.py index ac4478b..e91a7b8 100644 --- a/bot_bottle/sidecar_init.py +++ b/bot_bottle/gateway_init.py @@ -1,4 +1,4 @@ -"""Per-bottle sidecar supervisor (PRD 0024 chunk 1). +"""Gateway data-plane supervisor (PRD 0070; PRD 0024 bundle shape). PID 1 inside the `bot-bottle-gateway` data-plane image. Spawns the configured daemons (egress, git-gate, supervise), @@ -7,20 +7,20 @@ stdout+stderr to the container log with a `[name] ` prefix. Failure policy (interim): when a child dies unexpectedly, the supervisor logs the death and leaves the surviving children -running. The bundle stays up; whatever the dead daemon served +running. The gateway stays up; whatever the dead daemon served will start failing, surfacing in the agent's own error path. -The supervisor itself exits only when (a) the operator/compose -sends SIGTERM/SIGINT, or (b) every child has died. +The supervisor itself exits only when (a) the operator sends +SIGTERM/SIGINT, or (b) every child has died. Failure policy (eventual): on unexpected death, the supervisor restarts the daemon and emits a notification to the supervise -sidecar so the operator sees the event. That lands in a later -PR; the interim policy is "don't take the bundle down for one +daemon so the operator sees the event. That lands in a later +PR; the interim policy is "don't take the gateway down for one sick daemon." -Daemon subset is env-driven. The compose renderer narrows it via -`BOT_BOTTLE_SIDECAR_DAEMONS=egress` for bottles that -don't use git-gate or supervise. Default: all daemons. +Daemon subset is env-driven via `BOT_BOTTLE_GATEWAY_DAEMONS=egress` +for callers that don't use git-gate or supervise. Default: all +daemons. Stdlib-only by design — adding supervisord/s6/runit for four daemons is heavier than this script. @@ -103,8 +103,8 @@ def _selected_daemons( env: dict[str, str], all_daemons: Sequence[_DaemonSpec] | None = None, ) -> tuple[_DaemonSpec, ...]: - """Filter the daemon set by the BOT_BOTTLE_SIDECAR_DAEMONS env - var. Unknown names in the list are ignored — the renderer is the + """Filter the daemon set by the BOT_BOTTLE_GATEWAY_DAEMONS env + var. Unknown names in the list are ignored — the caller is the source of truth for which daemons are wired. `all_daemons` defaults to `_DAEMONS` resolved at call time (not @@ -112,7 +112,7 @@ def _selected_daemons( `_DAEMONS` and have the new value take effect.""" if all_daemons is None: all_daemons = _DAEMONS - raw = env.get("BOT_BOTTLE_SIDECAR_DAEMONS", "").strip() + raw = env.get("BOT_BOTTLE_GATEWAY_DAEMONS", "").strip() if not raw: return tuple(all_daemons) wanted = {n.strip() for n in raw.split(",") if n.strip()} @@ -120,7 +120,7 @@ def _selected_daemons( def _log(msg: str) -> None: - sys.stdout.write(f"sidecar-init: {msg}\n") + sys.stdout.write(f"gateway-init: {msg}\n") sys.stdout.flush() diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index 8e44aa2..95f5301 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -1,434 +1,23 @@ -"""Unit: compose-spec renderer (PRD 0018 chunk 1). +"""Unit: docker compose lifecycle helpers (PRD 0018). -Pure-function tests for `bottle_plan_to_compose`. Fixtures build a -fully-resolved DockerBottlePlan in memory; the renderer just -translates it to the compose dict. Conditional-service matrix is -covered via parameterized cases (git on/off × egress on/off × -supervise on/off). +The compose *spec* is built by `consolidated_compose.py`; these +tests cover the I/O-side helpers in `compose.py` — the slug ↔ +project mapping and `docker compose ls` enumeration. """ from __future__ import annotations import subprocess import unittest -from pathlib import Path -from typing import Any from unittest import mock -from bot_bottle.agent_provider import AgentProvisionPlan -from bot_bottle.backend import BottleSpec -from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.backend.docker.compose import ( COMPOSE_PROJECT_PREFIX, - bottle_plan_to_compose, compose_project_name, list_active_slugs, list_compose_projects, slug_from_compose_project, ) -from bot_bottle.egress import ( - EgressPlan, - EgressRoute, -) -from bot_bottle.git_gate import GitGatePlan, GitGateUpstream -from bot_bottle.manifest import ManifestIndex -from bot_bottle.supervise import SupervisePlan - - -SLUG = "demo-abc12" -STAGE = Path("/tmp/cb-stage") -STATE = Path("/tmp/cb-state") - - -def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> ManifestIndex: - """Minimal manifest with the toggles the chunk-1 matrix needs. - The renderer only reads from the plan, not the manifest, so this - is just here to back BottleSpec.""" - bottle: dict[str, object] = {} - if supervise: - bottle["supervise"] = True - if with_git: - bottle["git-gate"] = {"repos": { - "upstream": { - "url": "ssh://git@example.com:22/x/y.git", - "key": {"provider": "static", "path": "/etc/hostname"}, - }, - }} - if with_egress: - bottle["egress"] = { - "routes": [{ - "host": "api.example", - "auth": {"scheme": "Bearer", "token_ref": "TOK"}, - }], - } - return ManifestIndex.from_json_obj({ - "bottles": {"dev": bottle}, - "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, - }) - - - -def _git_gate_plan(upstreams: tuple[GitGateUpstream, ...] = ()) -> GitGatePlan: - return GitGatePlan( - slug=SLUG, - entrypoint_script=STATE / "git-gate" / "entrypoint.sh", - hook_script=STATE / "git-gate" / "pre-receive", - access_hook_script=STATE / "git-gate" / "access-hook", - upstreams=upstreams, - internal_network=f"bot-bottle-net-{SLUG}", - egress_network=f"bot-bottle-egress-{SLUG}", - ) - - -def _egress_plan( - routes: tuple[EgressRoute, ...] = (), - *, - canary: bool = False, -) -> EgressPlan: - token_env_map = { - r.token_env: r.token_ref - for r in routes - if r.token_env - } - return EgressPlan( - slug=SLUG, - routes_path=STATE / "egress" / "routes.yaml", - routes=routes, - token_env_map=token_env_map, - internal_network=f"bot-bottle-net-{SLUG}", - egress_network=f"bot-bottle-egress-{SLUG}", - mitmproxy_ca_host_path=STATE / "egress-ca" / "mitmproxy-ca.pem", - mitmproxy_ca_cert_only_host_path=STATE / "egress-ca" / "ca.pem", - canary="fake-canary-value" if canary else "", - canary_env="CANON_ALPHA_SECRET" if canary else "", - ) - - -def _supervise_plan() -> SupervisePlan: - return SupervisePlan( - slug=SLUG, - db_path=STATE / "bot-bottle.db", - internal_network=f"bot-bottle-net-{SLUG}", - ) - - -def _plan( - *, - with_git: bool = False, - with_egress: bool = False, - supervise: bool = False, - canary: bool = False, -) -> DockerBottlePlan: - """Build a fully-resolved DockerBottlePlan. Toggles cover the - matrix the renderer's conditional-service logic branches on.""" - upstreams: tuple[GitGateUpstream, ...] = () - if with_git: - upstreams = (GitGateUpstream( - name="upstream", - upstream_url="ssh://git@example.com:22/x/y.git", - upstream_host="example.com", - upstream_port="22", - identity_file="/etc/hostname", - known_host_key="", - known_hosts_file=STATE / "git-gate" / "upstream-known_hosts", - ),) - routes: tuple[EgressRoute, ...] = () - if with_egress: - routes = (EgressRoute( - host="api.example", - auth_scheme="Bearer", - token_env="EGRESS_TOKEN_0", - token_ref="TOK", - roles=(), - ),) - - index = _manifest(supervise=supervise, with_git=with_git, with_egress=with_egress) - spec = BottleSpec( - manifest=index, - agent_name="demo", - copy_cwd=False, - user_cwd="/tmp/x", - ) - return DockerBottlePlan( - spec=spec, - manifest=index.load_for_agent("demo"), - stage_dir=STAGE, - slug=SLUG, - forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"}, - git_gate_plan=_git_gate_plan(upstreams), - egress_plan=_egress_plan(routes, canary=canary), - supervise_plan=_supervise_plan() if supervise else None, - use_runsc=False, - agent_provision=AgentProvisionPlan( - template="claude", - command="claude", - prompt_mode="append_file", - image="bot-bottle-claude:latest", - dockerfile="", - guest_home="/home/node", - instance_name=f"bot-bottle-{SLUG}", - prompt_file=STAGE / "prompt", - guest_env={}, - ), - ) - - -class TestProjectAndNetworks(unittest.TestCase): - def test_project_name(self): - spec = bottle_plan_to_compose(_plan()) - self.assertEqual(f"bot-bottle-{SLUG}", spec["name"]) - - def test_internal_network_is_internal(self): - spec = bottle_plan_to_compose(_plan()) - net = spec["networks"]["internal"] - self.assertEqual(f"bot-bottle-net-{SLUG}", net["name"]) - self.assertTrue(net["internal"]) - - def test_egress_network_is_external_bridge(self): - spec = bottle_plan_to_compose(_plan()) - net = spec["networks"]["egress"] - self.assertEqual(f"bot-bottle-egress-{SLUG}", net["name"]) - # No `internal:` key on the egress network — defaults to a - # normal user-defined bridge. - self.assertNotIn("internal", net) - - -class TestAgentAlwaysPresent(unittest.TestCase): - def test_agent_in_services(self): - s = bottle_plan_to_compose(_plan())["services"] - self.assertIn("agent", s) - - def test_agent_command(self): - s = bottle_plan_to_compose(_plan())["services"]["agent"] - self.assertEqual(["sleep", "infinity"], s["command"]) - - def test_agent_image_uses_runtime_image(self): - plan = _plan() - s = bottle_plan_to_compose(plan)["services"]["agent"] - self.assertEqual(plan.image, s["image"]) - - def test_agent_only_on_internal_network(self): - s = bottle_plan_to_compose(_plan())["services"]["agent"] - self.assertEqual({"internal"}, set(s["networks"].keys())) - - def test_agent_proxy_always_via_egress(self): - for with_egress in (False, True): - with self.subTest(with_egress=with_egress): - s = bottle_plan_to_compose( - _plan(with_egress=with_egress) - )["services"]["agent"] - proxy_lines = [e for e in s["environment"] if e.startswith("HTTPS_PROXY=")] - self.assertEqual(1, len(proxy_lines)) - self.assertEqual("HTTPS_PROXY=http://egress:9099", proxy_lines[0]) - - def test_agent_proxy_via_egress_when_egress_present(self): - s = bottle_plan_to_compose(_plan(with_egress=True))["services"]["agent"] - proxy = [e for e in s["environment"] if e.startswith("HTTPS_PROXY=")][0] - self.assertEqual("HTTPS_PROXY=http://egress:9099", proxy) - - def test_agent_no_proxy_adds_supervise_when_enabled(self): - s = bottle_plan_to_compose( - _plan(supervise=True) - )["services"]["agent"] - no_proxy = [e for e in s["environment"] if e.startswith("NO_PROXY=")][0] - self.assertIn("supervise", no_proxy) - - def test_agent_forwarded_env_uses_bare_names(self): - # Bare NAME → compose inherits value from the up-process env, - # so secret token values stay out of the file. - s = bottle_plan_to_compose(_plan())["services"]["agent"] - self.assertIn("CLAUDE_CODE_OAUTH_TOKEN", s["environment"]) - - def test_agent_provider_env_uses_literal_values(self): - plan = _plan() - provision = AgentProvisionPlan( - template="codex", - command="codex", - prompt_mode="read_prompt_file", - image="bot-bottle-codex:latest", - dockerfile="", - guest_home="/home/node", - instance_name=f"bot-bottle-{SLUG}", - prompt_file=STAGE / "prompt", - guest_env={"CODEX_HOME": "/home/node/.codex"}, - ) - plan = type(plan)(**{**vars(plan), "agent_provision": provision}) # type: ignore - s = bottle_plan_to_compose(plan)["services"]["agent"] - self.assertIn("CODEX_HOME=/home/node/.codex", s["environment"]) - - def test_agent_runsc_runtime(self): - plan = _plan() - plan = type(plan)(**{**vars(plan), "use_runsc": True}) # type: ignore - s = bottle_plan_to_compose(plan)["services"]["agent"] - self.assertEqual("runsc", s["runtime"]) - - def test_agent_depends_only_on_sidecars(self): - # Bundle shape: the init supervisor owns intra-bundle daemon - # ordering, so the agent waits on the bundle container alone. - for kwargs in [{}, {"with_git": True, "with_egress": True, "supervise": True}]: - with self.subTest(**kwargs): - s = bottle_plan_to_compose(_plan(**kwargs))["services"]["agent"] - self.assertEqual(["sidecars"], s["depends_on"]) - - def test_agent_has_no_current_config_mount_with_supervise(self): - with_sv = bottle_plan_to_compose(_plan(supervise=True))["services"]["agent"] - self.assertNotIn("volumes", with_sv) - without_sv = bottle_plan_to_compose(_plan(supervise=False))["services"]["agent"] - self.assertNotIn("volumes", without_sv) - - -class TestSidecarBundleShape(unittest.TestCase): - """The compose renderer emits exactly one `sidecars` service in - place of the daemons it owns (egress + git-gate + supervise). - PRD 0024 chunk 5 dropped the legacy four-sidecar shape entirely, - so the bundle is the only thing exercised here.""" - - def _render(self, **plan_kwargs: object) -> Any: # type: ignore - return bottle_plan_to_compose(_plan(**plan_kwargs)) # type: ignore - - def test_emits_two_services_minimal(self): - spec = self._render() - self.assertEqual({"sidecars", "agent"}, set(spec["services"].keys())) - - def test_emits_two_services_full_matrix(self): - spec = self._render(with_git=True, with_egress=True, supervise=True) - # Still two services — the bundle absorbs git-gate/egress/supervise. - self.assertEqual({"sidecars", "agent"}, set(spec["services"].keys())) - - def test_bundle_uses_bundle_image_and_dockerfile(self): - sc = self._render()["services"]["sidecars"] - self.assertEqual("bot-bottle-gateway:latest", sc["image"]) - self.assertEqual("Dockerfile.gateway", sc["build"]["dockerfile"]) - - def test_bundle_container_name_uses_sidecars_prefix(self): - sc = self._render()["services"]["sidecars"] - self.assertEqual(f"bot-bottle-sidecars-{SLUG}", sc["container_name"]) - - def test_bundle_joins_both_networks(self): - sc = self._render()["services"]["sidecars"] - self.assertEqual({"internal", "egress"}, set(sc["networks"].keys())) - - def test_internal_aliases_include_egress_shortname(self): - sc = self._render()["services"]["sidecars"] - aliases = set(sc["networks"]["internal"]["aliases"]) - self.assertIn("egress", aliases) - - def test_internal_aliases_omit_inactive_sidecars(self): - # With no git-gate / supervise, those names are NOT aliased - # — keeps the alias list honest about what's actually - # listening inside the bundle. - sc = self._render()["services"]["sidecars"] - aliases = set(sc["networks"]["internal"]["aliases"]) - self.assertNotIn("git-gate", aliases) - self.assertNotIn("supervise", aliases) - - def test_internal_aliases_include_active_sidecars(self): - sc = self._render(with_git=True, supervise=True)["services"]["sidecars"] - aliases = set(sc["networks"]["internal"]["aliases"]) - self.assertIn("git-gate", aliases) - self.assertIn("supervise", aliases) - - def test_daemons_csv_lists_only_active(self): - sc = self._render()["services"]["sidecars"] - daemons = { - line.split("=", 1)[1] - for line in sc["environment"] - if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS=") - } - self.assertEqual({"egress"}, daemons) - - def test_daemons_csv_expands_with_optional_sidecars(self): - sc = self._render(with_git=True, supervise=True)["services"]["sidecars"] - for line in sc["environment"]: - if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS="): - csv = line.split("=", 1)[1] - break - else: - self.fail("BOT_BOTTLE_SIDECAR_DAEMONS not in env") - self.assertEqual( - ["egress", "git-gate", "supervise"], - csv.split(","), - ) - - def test_bundle_env_does_not_set_https_proxy(self): - # HTTPS_PROXY at the container level would route git-gate's - # git fetches through the proxy. Scoping it to mitmdump is - # the job of egress_entrypoint.sh; the bundle env must not - # leak it. - sc = self._render(with_egress=True)["services"]["sidecars"] - for line in sc["environment"]: - self.assertFalse( - line.startswith("HTTPS_PROXY=") - or line.startswith("HTTP_PROXY=") - or line.startswith("NO_PROXY="), - f"bundle env must not set {line!r}", - ) - - def test_egress_token_env_present_when_routes_declared(self): - sc = self._render(with_egress=True)["services"]["sidecars"] - env_strings = sc["environment"] - self.assertIn("EGRESS_TOKEN_0", env_strings) - - def test_egress_token_env_omitted_when_no_routes(self): - sc = self._render()["services"]["sidecars"] - env_strings = sc["environment"] - self.assertNotIn("EGRESS_TOKEN_0", env_strings) - - def test_canary_env_registered_as_sensitive_in_sidecar(self): - sc = self._render(canary=True)["services"]["sidecars"] - env_strings = sc["environment"] - self.assertIn("CANON_ALPHA_SECRET=fake-canary-value", env_strings) - self.assertIn( - "BOT_BOTTLE_SENSITIVE_PREFIXES=CANON_ALPHA_SECRET", - env_strings, - ) - - def test_canary_env_visible_to_agent(self): - agent = self._render(canary=True)["services"]["agent"] - env_strings = agent["environment"] - self.assertIn("CANON_ALPHA_SECRET=fake-canary-value", env_strings) - - def test_supervise_env_present_when_active(self): - sc = self._render(supervise=True)["services"]["sidecars"] - env_strings = sc["environment"] - self.assertIn(f"SUPERVISE_BOTTLE_SLUG={SLUG}", env_strings) - self.assertIn("SUPERVISE_DB_PATH=/run/supervise/bot-bottle.db", env_strings) - self.assertTrue(any(e.startswith("SUPERVISE_PORT=") for e in env_strings)) - - def test_volumes_always_includes_egress_ca(self): - sc = self._render()["services"]["sidecars"] - targets = {v["target"] for v in sc["volumes"]} - self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets) - - def test_volumes_union_full_matrix(self): - sc = self._render(with_git=True, with_egress=True, supervise=True)[ - "services"]["sidecars"] - targets = {v["target"] for v in sc["volumes"]} - self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets) - self.assertIn("/etc/egress", targets) - self.assertIn("/git-gate-entrypoint.sh", targets) - self.assertIn("/git-gate/creds/upstream-known_hosts", targets) - self.assertIn("/run/supervise/bot-bottle.db", targets) - - def test_extra_hosts_omitted_for_git_upstreams(self): - sc = self._render(with_git=True)["services"]["sidecars"] - self.assertNotIn("extra_hosts", sc) - - def test_agent_depends_on_bundle_only(self): - sc = self._render(with_git=True, with_egress=True, supervise=True)[ - "services"]["agent"] - self.assertEqual(["sidecars"], sc["depends_on"]) - - def test_agent_proxy_url_resolves_via_bundle_alias(self): - # With egress active, the agent's HTTPS_PROXY points at - # `egress` shortname; bundle aliases `egress` to itself so - # the URL keeps working without an agent-side change. - spec = self._render(with_egress=True) - sc = spec["services"]["agent"] - proxy = next(e for e in sc["environment"] if e.startswith("HTTPS_PROXY=")) - self.assertIn("egress", proxy) - self.assertIn("egress", - spec["services"]["sidecars"]["networks"]["internal"]["aliases"]) class TestProjectNaming(unittest.TestCase): diff --git a/tests/unit/test_sidecar_init.py b/tests/unit/test_gateway_init.py similarity index 95% rename from tests/unit/test_sidecar_init.py rename to tests/unit/test_gateway_init.py index b47f913..98607b6 100644 --- a/tests/unit/test_sidecar_init.py +++ b/tests/unit/test_gateway_init.py @@ -1,6 +1,6 @@ -"""Unit: sidecar bundle init supervisor (PRD 0024 chunk 1). +"""Unit: gateway data-plane init supervisor (PRD 0070; PRD 0024 bundle shape). -Tests both the helper functions in `bot_bottle.sidecar_init` +Tests both the helper functions in `bot_bottle.gateway_init` and the supervisor's end-to-end signal / exit-code behavior. The end-to-end tests use real subprocesses (`/bin/sleep`, `/bin/sh -c '...'`) — short-lived, no docker required — so they @@ -18,7 +18,7 @@ import warnings from pathlib import Path from unittest.mock import patch -from bot_bottle.sidecar_init import ( +from bot_bottle.gateway_init import ( _DaemonSpec, _Supervisor, _argv_for_daemon, @@ -80,18 +80,18 @@ class TestSelectedDaemons(unittest.TestCase): ["egress", "git-gate", "supervise"]) def test_empty_returns_all(self): - got = _selected_daemons({"BOT_BOTTLE_SIDECAR_DAEMONS": ""}, + got = _selected_daemons({"BOT_BOTTLE_GATEWAY_DAEMONS": ""}, all_daemons=self._DAEMONS) self.assertEqual(3, len(got)) def test_whitespace_only_returns_all(self): - got = _selected_daemons({"BOT_BOTTLE_SIDECAR_DAEMONS": " "}, + got = _selected_daemons({"BOT_BOTTLE_GATEWAY_DAEMONS": " "}, all_daemons=self._DAEMONS) self.assertEqual(3, len(got)) def test_explicit_subset(self): got = _selected_daemons( - {"BOT_BOTTLE_SIDECAR_DAEMONS": "egress,git-gate"}, + {"BOT_BOTTLE_GATEWAY_DAEMONS": "egress,git-gate"}, all_daemons=self._DAEMONS, ) self.assertEqual([d.name for d in got], ["egress", "git-gate"]) @@ -100,7 +100,7 @@ class TestSelectedDaemons(unittest.TestCase): # Order in the env var doesn't matter; the result follows # the canonical _DAEMONS order so egress starts first. got = _selected_daemons( - {"BOT_BOTTLE_SIDECAR_DAEMONS": "supervise,git-gate,egress"}, + {"BOT_BOTTLE_GATEWAY_DAEMONS": "supervise,git-gate,egress"}, all_daemons=self._DAEMONS, ) self.assertEqual([d.name for d in got], @@ -108,14 +108,14 @@ class TestSelectedDaemons(unittest.TestCase): def test_unknown_names_ignored(self): got = _selected_daemons( - {"BOT_BOTTLE_SIDECAR_DAEMONS": "egress,bogus"}, + {"BOT_BOTTLE_GATEWAY_DAEMONS": "egress,bogus"}, all_daemons=self._DAEMONS, ) self.assertEqual([d.name for d in got], ["egress"]) def test_whitespace_in_names_stripped(self): got = _selected_daemons( - {"BOT_BOTTLE_SIDECAR_DAEMONS": " egress , git-gate "}, + {"BOT_BOTTLE_GATEWAY_DAEMONS": " egress , git-gate "}, all_daemons=self._DAEMONS, ) self.assertEqual([d.name for d in got], ["egress", "git-gate"]) @@ -441,7 +441,7 @@ class TestSupervisor(unittest.TestCase): time.sleep(0.3) # let `trap` register sup.request_shutdown(reason="test") - with patch("bot_bottle.sidecar_init._GRACE_SECONDS", 0.3): + with patch("bot_bottle.gateway_init._GRACE_SECONDS", 0.3): rc = self._drive(sup, max_wait_s=4.0) # Process was SIGKILL'd → returncode -9 on POSIX. @@ -464,7 +464,7 @@ class TestSupervisor(unittest.TestCase): class TestMainEndToEnd(unittest.TestCase): - """Run sidecar_init.py as a real subprocess to cover the + """Run gateway_init.py as a real subprocess to cover the signal-handler installation path. Skipped on platforms without /bin/sleep + /bin/sh.""" @@ -477,20 +477,20 @@ class TestMainEndToEnd(unittest.TestCase): def _run(self, daemons_csv: str, send_signal: int | None, wait_before_signal: float = 0.4, overall_timeout: float = 6.0) -> tuple[int, str]: - """Spawn sidecar_init.main() in a child process with the + """Spawn gateway_init.main() in a child process with the DAEMONS list patched to harmless `sleep 30` commands. Returns (returncode, captured stdout).""" helper = ( "import os, runpy, sys\n" - "from bot_bottle import sidecar_init as si\n" + "from bot_bottle import gateway_init as si\n" "si._DAEMONS = (\n" " si._DaemonSpec('alpha', ('/bin/sleep','30')),\n" " si._DaemonSpec('beta', ('/bin/sleep','30')),\n" ")\n" "sys.exit(si.main([]))\n" ) - env = {**os.environ, "BOT_BOTTLE_SIDECAR_DAEMONS": daemons_csv} + env = {**os.environ, "BOT_BOTTLE_GATEWAY_DAEMONS": daemons_csv} proc = subprocess.Popen( [sys.executable, "-c", helper], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, @@ -504,7 +504,7 @@ class TestMainEndToEnd(unittest.TestCase): except subprocess.TimeoutExpired: proc.kill() out_b, _ = proc.communicate() - self.fail("sidecar_init main() did not exit before timeout") + self.fail("gateway_init main() did not exit before timeout") return proc.returncode, out_b.decode("utf-8", errors="replace") def test_sigterm_clean_shutdown(self): -- 2.52.0 From 77948ef56ceb5c48e63f0964ab7cb2e87831fb3b Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 14 Jul 2026 17:02:08 -0400 Subject: [PATCH 3/5] refactor(de-sidecar): remove the per-bottle companion-container architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-agent companion container (the egress/git-gate/supervise data plane run once per bottle) is the pre-consolidation architecture. Remove it and disable the backends that still depend on it, per the #385 thread. - Delete `backend/docker/sidecar_bundle.py`; docker's live path uses the consolidated shared gateway, not a per-bottle bundle. - Disable the firecracker and macos-container backends: their `launch()` fails closed (they launched a per-bottle companion; firecracker's consolidated relaunch is #354, macos follows). Their `enumerate` return empty and `cleanup` drop the companion-container discovery (firecracker keeps VMM/run-dir cleanup). - Fail-close both backends' `egress_apply` reload (it signalled the per-bottle container); consolidated egress policy resolves per-request against the orchestrator, so gateway-side apply is a follow-up. - Rename `egress_sidecar_env_entries` → `egress_gateway_env_entries`, `SIDECAR_PORTS` → `GATEWAY_PORTS`. - Move the shared DockerBottlePlan fixture to `tests/unit/_docker_bottle_plan.py`; delete tests for the removed launch paths; update cleanup/egress-apply tests. Docker consolidated launch verified end-to-end (multitenant isolation integration test passes). macos/firecracker are intentionally disabled. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- bot_bottle/backend/docker/egress_apply.py | 57 +-- bot_bottle/backend/docker/sidecar_bundle.py | 33 -- .../firecracker/bottle_cleanup_plan.py | 9 +- bot_bottle/backend/firecracker/cleanup.py | 25 +- bot_bottle/backend/firecracker/enumerate.py | 37 +- bot_bottle/backend/firecracker/launch.py | 405 +-------------- bot_bottle/backend/firecracker/netpool.py | 2 +- bot_bottle/backend/macos_container/cleanup.py | 3 +- .../backend/macos_container/egress_apply.py | 32 +- .../backend/macos_container/enumerate.py | 40 +- bot_bottle/backend/macos_container/launch.py | 461 +----------------- bot_bottle/egress.py | 6 +- tests/integration/test_firecracker_launch.py | 142 ------ .../test_macos_container_launch.py | 239 --------- .../test_sidecar_bundle_compose.py | 106 ---- tests/unit/_docker_bottle_plan.py | 156 ++++++ tests/unit/test_consolidated_compose.py | 2 +- tests/unit/test_egress.py | 6 +- tests/unit/test_egress_apply.py | 30 +- tests/unit/test_firecracker_cleanup.py | 24 +- tests/unit/test_macos_container_cleanup.py | 25 +- tests/unit/test_macos_container_launch.py | 369 -------------- 22 files changed, 259 insertions(+), 1950 deletions(-) delete mode 100644 bot_bottle/backend/docker/sidecar_bundle.py delete mode 100644 tests/integration/test_firecracker_launch.py delete mode 100644 tests/integration/test_macos_container_launch.py delete mode 100644 tests/integration/test_sidecar_bundle_compose.py create mode 100644 tests/unit/_docker_bottle_plan.py delete mode 100644 tests/unit/test_macos_container_launch.py diff --git a/bot_bottle/backend/docker/egress_apply.py b/bot_bottle/backend/docker/egress_apply.py index 9f05e47..d65df11 100644 --- a/bot_bottle/backend/docker/egress_apply.py +++ b/bot_bottle/backend/docker/egress_apply.py @@ -1,60 +1,29 @@ -"""Host-side helper for egress sidecar inspection and live updates. +"""Host-side egress route-apply for the docker backend. -The approve path uses this module to validate a proposed routes file, -write it to the bottle's live egress state dir, and signal the sidecar -bundle so the mitmproxy addon reloads it. +The per-bottle companion container this used to signal (`docker kill +--signal HUP `) was removed in the de-sidecar cleanup (#385). +In the consolidated model the shared gateway resolves egress policy +per-request against the orchestrator rather than reloading a per-bottle +routes file, so the live per-bottle reload is not supported here and +fails closed until the gateway-side apply lands. """ from __future__ import annotations -import os -import subprocess - -from ...egress import EGRESS_ROUTES_IN_CONTAINER -from ...log import warn from ..egress_apply import EgressApplicator, EgressApplyError -from .sidecar_bundle import sidecar_bundle_container_name - - -def fetch_current_routes(slug: str) -> str: - container = sidecar_bundle_container_name(slug) - r = subprocess.run( - ["docker", "exec", container, "cat", EGRESS_ROUTES_IN_CONTAINER], - capture_output=True, text=True, check=False, - ) - if r.returncode != 0: - raise EgressApplyError( - f"could not read routes.yaml from {container}: " - f"{(r.stderr or '').strip() or 'container not running?'}" - ) - return r.stdout class DockerEgressApplicator(EgressApplicator): def _signal_bundle_reload(self, slug: str) -> None: - container = sidecar_bundle_container_name(slug) - result = subprocess.run( - ["docker", "kill", "--signal", "HUP", container], - capture_output=True, text=True, check=False, env=os.environ, + del slug + raise EgressApplyError( + "live egress route-apply was removed with the per-bottle " + "companion container (#385); route changes will flow through " + "the consolidated gateway in a follow-up." ) - if result.returncode != 0: - last_error = (result.stderr or "").strip() or (result.stdout or "").strip() - warn( - f"egress: routes updated on disk for {slug}, but bundle reload failed: " - f"{last_error or 'docker kill failed'}" - ) - raise EgressApplyError( - f"could not reload egress bundle {container}: " - f"{last_error or 'docker kill failed'}" - ) applicator = DockerEgressApplicator() -__all__ = [ - "DockerEgressApplicator", - "EgressApplyError", - "applicator", - "fetch_current_routes", -] +__all__ = ["DockerEgressApplicator", "EgressApplyError", "applicator"] diff --git a/bot_bottle/backend/docker/sidecar_bundle.py b/bot_bottle/backend/docker/sidecar_bundle.py deleted file mode 100644 index 105d044..0000000 --- a/bot_bottle/backend/docker/sidecar_bundle.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Sidecar bundle constants + helpers for the Docker backend -(PRD 0024). - -A per-bottle sidecar bundle runs egress + git-gate + supervise as one -container under a small Python init supervisor. As of PRD 0024 chunk 5 -the bundle is the only shape — the legacy four-sidecar topology and its -`BOT_BOTTLE_SIDECAR_BUNDLE` feature flag are gone. - -The bundle's image is the **same data plane** as the consolidated -per-host gateway (PRD 0070), so it is one image with one name and one -Dockerfile: `bot-bottle-gateway` / `Dockerfile.gateway` (#384). These -constants alias the gateway's so a per-bottle bundle and the shared -gateway can never drift onto different images.""" - -from __future__ import annotations - -from ...orchestrator.gateway import GATEWAY_DOCKERFILE, GATEWAY_IMAGE - - -# The per-bottle bundle image == the gateway data-plane image (aliased so -# there is one source of truth; the `BOT_BOTTLE_GATEWAY_IMAGE` env override -# on the gateway constant applies here too). -SIDECAR_BUNDLE_IMAGE = GATEWAY_IMAGE -SIDECAR_BUNDLE_DOCKERFILE = GATEWAY_DOCKERFILE - - -def sidecar_bundle_container_name(slug: str) -> str: - """`bot-bottle-sidecars-`. Same prefix scheme as the - per-sidecar containers it replaces, so the dashboard's - discovery-by-prefix logic keeps working. (The per-bottle *container* - name keeps the historical `sidecars-` prefix; only the *image* was - renamed to `bot-bottle-gateway` in #384.)""" - return f"bot-bottle-sidecars-{slug}" diff --git a/bot_bottle/backend/firecracker/bottle_cleanup_plan.py b/bot_bottle/backend/firecracker/bottle_cleanup_plan.py index 3fd8eec..a052239 100644 --- a/bot_bottle/backend/firecracker/bottle_cleanup_plan.py +++ b/bot_bottle/backend/firecracker/bottle_cleanup_plan.py @@ -10,10 +10,9 @@ from .. import BottleCleanupPlan @dataclass(frozen=True) class FirecrackerBottleCleanupPlan(BottleCleanupPlan): - # PIDs of orphaned firecracker VMM processes and the sidecar - # containers left behind by previous bottles. + # PIDs of orphaned firecracker VMM processes and the per-bottle run + # dirs left behind by previous bottles. vm_pids: tuple[int, ...] = () - containers: tuple[str, ...] = () run_dirs: tuple[str, ...] = () def print(self) -> None: @@ -22,11 +21,9 @@ class FirecrackerBottleCleanupPlan(BottleCleanupPlan): return for pid in self.vm_pids: info(f"firecracker VM process: pid {pid}") - for name in self.containers: - info(f"firecracker sidecar container: {name}") for path in self.run_dirs: info(f"firecracker run dir: {path}") @property def empty(self) -> bool: - return not (self.vm_pids or self.containers or self.run_dirs) + return not (self.vm_pids or self.run_dirs) diff --git a/bot_bottle/backend/firecracker/cleanup.py b/bot_bottle/backend/firecracker/cleanup.py index 938bca9..af41e29 100644 --- a/bot_bottle/backend/firecracker/cleanup.py +++ b/bot_bottle/backend/firecracker/cleanup.py @@ -1,9 +1,8 @@ """Cleanup for the Firecracker backend. Orphans are: firecracker VMM processes whose config lives under our run -dir, the `bot-bottle-sidecars-*` containers, and the per-bottle run -dirs. TAP slots free themselves (the flock drops when the launcher -exits), so there is nothing to reclaim there. +dir, and the per-bottle run dirs. TAP slots free themselves (the flock +drops when the launcher exits), so there is nothing to reclaim there. """ from __future__ import annotations @@ -18,8 +17,6 @@ from ...log import info from . import util from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan -_SIDECAR_PREFIX = "bot-bottle-sidecars-" - def _run_root() -> Path: return util.cache_dir() / "run" @@ -46,17 +43,6 @@ def _orphan_vm_pids() -> list[int]: return pids -def _sidecar_containers() -> list[str]: - result = subprocess.run( - ["docker", "ps", "-a", "--format", "{{.Names}}", - "--filter", f"name={_SIDECAR_PREFIX}"], - capture_output=True, text=True, check=False, - ) - if result.returncode != 0: - return [] - return sorted(n.strip() for n in result.stdout.splitlines() if n.strip()) - - def _run_dirs() -> list[str]: run_root = _run_root() if not run_root.is_dir(): @@ -67,7 +53,6 @@ def _run_dirs() -> list[str]: def prepare_cleanup() -> FirecrackerBottleCleanupPlan: return FirecrackerBottleCleanupPlan( vm_pids=tuple(_orphan_vm_pids()), - containers=tuple(_sidecar_containers()), run_dirs=tuple(_run_dirs()), ) @@ -79,12 +64,6 @@ def cleanup(plan: FirecrackerBottleCleanupPlan) -> None: os.kill(pid, signal.SIGTERM) except ProcessLookupError: pass - for name in plan.containers: - info(f"docker rm -f {name}") - subprocess.run( - ["docker", "rm", "-f", name], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, - ) for path in plan.run_dirs: info(f"rm -rf {path}") shutil.rmtree(path, ignore_errors=True) diff --git a/bot_bottle/backend/firecracker/enumerate.py b/bot_bottle/backend/firecracker/enumerate.py index 4c9e90d..d8eecf1 100644 --- a/bot_bottle/backend/firecracker/enumerate.py +++ b/bot_bottle/backend/firecracker/enumerate.py @@ -1,43 +1,14 @@ """Active-agent enumeration for the Firecracker backend. -The agent runs in a VM (no container to list), so a live bottle is -identified by its running sidecar container `bot-bottle-sidecars-` -— the same discovery-by-prefix the other backends use. +The backend is disabled during the de-sidecar cleanup (#385) — it can't +launch bottles, so there are none to enumerate. Real enumeration returns +with the backend's consolidated relaunch (#354). """ from __future__ import annotations -import subprocess - -from ...bottle_state import read_metadata from .. import ActiveAgent -_SIDECAR_PREFIX = "bot-bottle-sidecars-" - def enumerate_active() -> list[ActiveAgent]: - result = subprocess.run( - ["docker", "ps", "--format", "{{.Names}}", - "--filter", f"name={_SIDECAR_PREFIX}"], - capture_output=True, text=True, check=False, - ) - if result.returncode != 0: - return [] - out: list[ActiveAgent] = [] - for name in sorted(n.strip() for n in result.stdout.splitlines() if n.strip()): - slug = name[len(_SIDECAR_PREFIX):] - metadata = read_metadata(slug) - if metadata is None or metadata.backend != "firecracker": - # Skip sidecars owned by another backend (docker shares the - # container-name prefix). - continue - out.append(ActiveAgent( - backend_name="firecracker", - slug=slug, - agent_name=metadata.agent_name, - started_at=metadata.started_at, - services=(), - label=metadata.label, - color=metadata.color, - )) - return out + return [] diff --git a/bot_bottle/backend/firecracker/launch.py b/bot_bottle/backend/firecracker/launch.py index a0b0486..a5e6347 100644 --- a/bot_bottle/backend/firecracker/launch.py +++ b/bot_bottle/backend/firecracker/launch.py @@ -1,404 +1,39 @@ -"""Launch flow for the Firecracker backend. +"""Launch flow for the Firecracker backend — temporarily disabled (#385). -Per bottle: - 1. mint the egress CA, build the agent image (docker), export it to a - cached ext4 rootfs; - 2. claim a free TAP pool slot (rootless flock); - 3. bring up the Docker sidecar bundle, publishing egress / git-gate / - supervise on the slot's host-side TAP IP at fixed ports; - 4. boot the microVM on that TAP; wait for SSH; - 5. provision (CA, prompt, skills, workspace, git, supervise) over SSH. +The firecracker backend launched a per-bottle companion container (the +egress / git-gate / supervise data plane) alongside each microVM. That +per-bottle-companion architecture was removed in the de-sidecar cleanup; +firecracker's replacement — the consolidated per-host gateway — lands in +its own cutover (#354). -Isolation is enforced by the operator-provisioned nft table (checked -fail-closed in preflight): a VM reaches only its sidecar (DNAT'd from -the host TAP IP) and nothing else. The agent's HTTPS_PROXY therefore -points at `http://:9099`, its only route to the world. +Until that lands, launching a firecracker bottle fails closed rather than +silently running the removed path. `prepare` / `status` / cleanup still +work, so `backend status --backend=firecracker` and orphan cleanup are +unaffected. """ from __future__ import annotations -import dataclasses -import os -import subprocess -from contextlib import ExitStack, contextmanager -from pathlib import Path +from contextlib import contextmanager from typing import Callable, Generator -from ...bottle_state import ( - egress_state_dir, - git_gate_state_dir, - read_committed_image, -) -from ...egress import ( - EGRESS_ROUTES_IN_CONTAINER, - egress_agent_env_entries, - egress_resolve_token_values, - egress_sidecar_env_entries, -) -from ...git_gate import ( - provision_git_gate_dynamic_keys, - revoke_git_gate_provisioned_keys, -) -from ...log import die, info, warn -from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT -from ...util import expand_tilde -from ..docker.egress import ( - EGRESS_CA_IN_CONTAINER, - EGRESS_PORT, - egress_tls_init, -) -from ..docker.git_gate import ( - GIT_GATE_ACCESS_HOOK_IN_CONTAINER, - GIT_GATE_CREDS_DIR_IN_CONTAINER, - GIT_GATE_ENTRYPOINT_IN_CONTAINER, - GIT_GATE_HOOK_IN_CONTAINER, -) -from ..docker.sidecar_bundle import ( - SIDECAR_BUNDLE_DOCKERFILE, - SIDECAR_BUNDLE_IMAGE, -) -from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH -from . import firecracker_vm, isolation_probe, netpool, util +from ...log import die from .bottle import FirecrackerBottle from .bottle_plan import FirecrackerBottlePlan -_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) -_GIT_HTTP_PORT = 9420 -_GIT_GATE_READY_FILE = "/run/git-gate/ready" - - -def sidecar_container_name(slug: str) -> str: - return f"bot-bottle-sidecars-{slug}" - - @contextmanager def launch( plan: FirecrackerBottlePlan, *, provision: Callable[[FirecrackerBottlePlan, "FirecrackerBottle"], str | None], ) -> Generator[FirecrackerBottle, None, None]: - stack = ExitStack() - bottle_for_revoke = plan.manifest.bottle - git_gate_dir_for_revoke = git_gate_state_dir(plan.slug) - - def teardown() -> None: - teardown_exc: BaseException | None = None - try: - stack.close() - except BaseException as exc: # noqa: W0718 - teardown must continue - teardown_exc = exc - warn(f"firecracker teardown failed: {exc!r}") - revoke_git_gate_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke) - if teardown_exc is not None: - raise teardown_exc - - try: - plan = _mint_certs(plan) - plan = _build_agent_image(plan) - - # Claim a TAP slot; the flock is held until teardown closes it. - slot, lock = netpool.allocate(plan.slug) - stack.callback(lock.close) - info(f"firecracker slot {slot.iface}: host={slot.host_ip} " - f"guest={slot.guest_ip}") - - plan = _provision_git_gate_keys(plan) - - sidecar_name = sidecar_container_name(plan.slug) - _force_remove_container(sidecar_name) - _start_sidecar_bundle(plan, sidecar_name, slot.host_ip) - stack.callback(_force_remove_container, sidecar_name) - _stage_git_gate(plan, sidecar_name) - - plan = _stamp_agent_urls(plan, slot.host_ip) - - # Build the per-bottle rootfs + SSH key, then boot. - base_dir = util.build_base_rootfs_dir(plan.image) - run_dir = util.cache_dir() / "run" / plan.slug - run_dir.mkdir(parents=True, exist_ok=True) - rootfs = run_dir / "rootfs.ext4" - util.build_rootfs_ext4(base_dir, rootfs) - private_key, pubkey = util.generate_keypair(run_dir) - - vm = firecracker_vm.boot( - name=plan.container_name, - rootfs=rootfs, - tap=slot.iface, - guest_ip=slot.guest_ip, - host_ip=slot.host_ip, - pubkey=pubkey, - run_dir=run_dir, - ) - stack.callback(vm.terminate) - firecracker_vm.wait_for_ssh(vm, private_key) - - # Authoritative fail-closed egress-boundary check, before the - # agent runs: prove the VM cannot reach the host directly. - isolation_probe.verify_isolation(private_key, slot.guest_ip) - - bottle = FirecrackerBottle( - plan.container_name, - private_key=private_key, - guest_ip=slot.guest_ip, - guest_env=_agent_guest_env(plan, slot.host_ip), - agent_command=plan.agent_command, - agent_prompt_mode=plan.agent_prompt_mode, - agent_provider_template=plan.agent_provider_template, - terminal_title=( - f"{plan.spec.label} ({plan.spec.agent_name})" - if plan.spec.label else plan.spec.agent_name - ), - terminal_color=plan.spec.color, - agent_workdir=plan.workspace_plan.workdir, - ) - bottle.prompt_path = provision(plan, bottle) - - yield bottle - finally: - teardown() - - -def _mint_certs(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan: - egress_ca_host, egress_ca_cert_only = egress_tls_init(egress_state_dir(plan.slug)) - egress_plan = dataclasses.replace( - plan.egress_plan, - mitmproxy_ca_host_path=egress_ca_host, - mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, + """Fail closed: the firecracker backend is disabled while its + consolidated (gateway-backed) launch is built in #354.""" + del plan, provision + die( + "the firecracker backend is temporarily disabled during the " + "companion-container removal (#385); its consolidated relaunch " + "lands in #354. Use --backend=docker for now." ) - return dataclasses.replace(plan, egress_plan=egress_plan) - - -def _build_agent_image(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan: - _docker_build(SIDECAR_BUNDLE_IMAGE, _REPO_DIR, dockerfile=SIDECAR_BUNDLE_DOCKERFILE) - committed = read_committed_image(plan.slug) - if committed and _image_exists(committed): - info(f"using committed image {committed!r}") - return dataclasses.replace( - plan, - agent_provision=dataclasses.replace(plan.agent_provision, image=committed), - ) - _docker_build(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path) - return plan - - -def _provision_git_gate_keys(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan: - if not plan.git_gate_plan.upstreams: - return plan - git_gate_plan = provision_git_gate_dynamic_keys( - plan.manifest.bottle, plan.git_gate_plan, git_gate_state_dir(plan.slug), - ) - return dataclasses.replace(plan, git_gate_plan=git_gate_plan) - - -def _stamp_agent_urls( - plan: FirecrackerBottlePlan, host_ip: str, -) -> FirecrackerBottlePlan: - proxy_url = f"http://{host_ip}:{EGRESS_PORT}" - supervise_url = ( - f"http://{host_ip}:{SUPERVISE_PORT}/" if plan.supervise_plan is not None else "" - ) - git_gate_url = ( - f"http://{host_ip}:{_GIT_HTTP_PORT}" if plan.git_gate_plan.upstreams else "" - ) - return dataclasses.replace( - plan, - agent_proxy_url=proxy_url, - agent_git_gate_url=git_gate_url, - agent_supervise_url=supervise_url, - ) - - -# --- sidecar bundle (Docker) ---------------------------------------- - -def _start_sidecar_bundle( - plan: FirecrackerBottlePlan, sidecar_name: str, host_ip: str, -) -> None: - argv = ["docker", "run", "--name", sidecar_name, "--detach", "--rm", - "-e", f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(_sidecar_daemons(plan))}"] - for entry in _sidecar_env_entries(plan): - argv += ["-e", entry] - for host_path, container_path, read_only in _sidecar_mounts(plan): - argv += ["-v", f"{host_path}:{container_path}{':ro' if read_only else ''}"] - # Publish on the slot's host TAP IP at fixed ports — each bottle - # has a distinct host_ip, so fixed ports never collide, and the VM - # reaches them at a stable, well-known address (its only route out). - for port in _sidecar_ports(plan): - argv += ["-p", f"{host_ip}:{port}:{port}"] - argv.append(SIDECAR_BUNDLE_IMAGE) - - effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env} - token_values = egress_resolve_token_values( - plan.egress_plan.token_env_map, effective_env, - ) - env = {**os.environ, **token_values} - info(f"docker run sidecar bundle {sidecar_name} (published on {host_ip})") - result = subprocess.run(argv, capture_output=True, text=True, env=env, check=False) - if result.returncode != 0: - die(f"docker run for sidecar bundle {sidecar_name} failed: " - f"{(result.stderr or '').strip() or ''}") - - -def _sidecar_daemons(plan: FirecrackerBottlePlan) -> tuple[str, ...]: - daemons = ["egress"] - if plan.git_gate_plan.upstreams: - daemons += ["git-gate", "git-http"] - if plan.supervise_plan is not None: - daemons.append("supervise") - return tuple(daemons) - - -def _sidecar_ports(plan: FirecrackerBottlePlan) -> tuple[int, ...]: - ports = [EGRESS_PORT] - if plan.git_gate_plan.upstreams: - ports.append(_GIT_HTTP_PORT) - if plan.supervise_plan is not None: - ports.append(SUPERVISE_PORT) - return tuple(ports) - - -def _sidecar_env_entries(plan: FirecrackerBottlePlan) -> tuple[str, ...]: - env: list[str] = list(egress_sidecar_env_entries(plan.egress_plan)) - if plan.git_gate_plan.upstreams: - env.append(f"BOT_BOTTLE_GIT_GATE_READY_FILE={_GIT_GATE_READY_FILE}") - if plan.supervise_plan is not None: - env += [ - f"SUPERVISE_BOTTLE_SLUG={plan.slug}", - f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", - f"SUPERVISE_PORT={SUPERVISE_PORT}", - ] - return tuple(env) - - -def _sidecar_mounts( - plan: FirecrackerBottlePlan, -) -> tuple[tuple[str, str, bool], ...]: - mounts: list[tuple[str, str, bool]] = [] - ep = plan.egress_plan - mounts.append((str(ep.mitmproxy_ca_host_path.parent), - str(Path(EGRESS_CA_IN_CONTAINER).parent), False)) - if ep.routes: - mounts.append((str(ep.routes_path.parent), - str(Path(EGRESS_ROUTES_IN_CONTAINER).parent), True)) - sp = plan.supervise_plan - if sp is not None: - mounts.append((str(sp.db_path.parent), - str(Path(DB_PATH_IN_CONTAINER).parent), False)) - return tuple(mounts) - - -def _stage_git_gate(plan: FirecrackerBottlePlan, sidecar_name: str) -> None: - gp = plan.git_gate_plan - if not gp.upstreams: - return - _docker_exec(sidecar_name, [ - "mkdir", "-p", - str(Path(GIT_GATE_HOOK_IN_CONTAINER).parent), - GIT_GATE_CREDS_DIR_IN_CONTAINER, "/git", - str(Path(_GIT_GATE_READY_FILE).parent), - ]) - for host_path, container_path in _git_gate_files(plan): - _docker_cp(host_path, f"{sidecar_name}:{container_path}") - _docker_exec(sidecar_name, [ - "sh", "-c", - f"chmod 755 {GIT_GATE_ENTRYPOINT_IN_CONTAINER} " - f"{GIT_GATE_HOOK_IN_CONTAINER} {GIT_GATE_ACCESS_HOOK_IN_CONTAINER} && " - f"chmod 600 {GIT_GATE_CREDS_DIR_IN_CONTAINER}/* && " - f"touch {_GIT_GATE_READY_FILE}", - ]) - - -def _git_gate_files(plan: FirecrackerBottlePlan) -> tuple[tuple[str, str], ...]: - gp = plan.git_gate_plan - files: list[tuple[str, str]] = [ - (str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER), - (str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER), - (str(gp.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER), - ] - for upstream in gp.upstreams: - files.append((expand_tilde(upstream.identity_file), - f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-key")) - if upstream.known_hosts_file: - files.append((str(upstream.known_hosts_file), - f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-known_hosts")) - return tuple(files) - - -# --- agent guest env ------------------------------------------------- - -def _agent_guest_env(plan: FirecrackerBottlePlan, host_ip: str) -> dict[str, str]: - """Env injected into every agent/exec call over SSH. The VM has no - baked process env (it just runs init), so the proxy/CA/git/supervise - wiring is applied per-invocation.""" - proxy_url = f"http://{host_ip}:{EGRESS_PORT}" - no_proxy = f"localhost,127.0.0.1,{host_ip}" - env: dict[str, str] = { - "HTTPS_PROXY": proxy_url, "HTTP_PROXY": proxy_url, - "https_proxy": proxy_url, "http_proxy": proxy_url, - "NO_PROXY": no_proxy, "no_proxy": no_proxy, - "NODE_EXTRA_CA_CERTS": AGENT_CA_PATH, - "SSL_CERT_FILE": AGENT_CA_BUNDLE, - "REQUESTS_CA_BUNDLE": AGENT_CA_BUNDLE, - } - if plan.agent_git_gate_url: - env["GIT_GATE_URL"] = plan.agent_git_gate_url - if plan.agent_supervise_url: - env["MCP_SUPERVISE_URL"] = plan.agent_supervise_url - for entry in egress_agent_env_entries(plan.egress_plan): - key, _, value = entry.partition("=") - env[key] = value - env.update(plan.agent_provision.guest_env) - # Forwarded (bare-name) env: resolve host values now, since the VM - # can't inherit them from a `docker run --env NAME`. - for name in plan.forwarded_env: - value = os.environ.get(name) - if value is not None: - env[name] = value - return env - - -# --- docker helpers -------------------------------------------------- - -def _docker_build(ref: str, context: str, *, dockerfile: str = "") -> None: - info(f"docker build {ref}") - args = ["docker", "build", "-t", ref] - if dockerfile: - if not os.path.isabs(dockerfile): - dockerfile = os.path.join(context, dockerfile) - args += ["-f", dockerfile] - args.append(context) - result = subprocess.run(args, check=False) - if result.returncode != 0: - die(f"docker build for {ref!r} failed") - - -def _image_exists(ref: str) -> bool: - return subprocess.run( - ["docker", "image", "inspect", ref], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, - ).returncode == 0 - - -def _force_remove_container(name: str) -> None: - subprocess.run( - ["docker", "rm", "-f", name], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, - ) - - -def _docker_exec(name: str, argv: list[str]) -> None: - result = subprocess.run( - ["docker", "exec", name, *argv], capture_output=True, text=True, check=False, - ) - if result.returncode != 0: - die(f"docker exec in {name} failed: " - f"{(result.stderr or '').strip() or ''}") - - -def _docker_cp(host_path: str, dest: str) -> None: - result = subprocess.run( - ["docker", "cp", host_path, dest], capture_output=True, text=True, check=False, - ) - if result.returncode != 0: - die(f"docker cp {host_path} -> {dest} failed: " - f"{(result.stderr or '').strip() or ''}") + yield # unreachable — `die` raises; keeps this a generator/contextmanager diff --git a/bot_bottle/backend/firecracker/netpool.py b/bot_bottle/backend/firecracker/netpool.py index 09bb596..9de0b09 100644 --- a/bot_bottle/backend/firecracker/netpool.py +++ b/bot_bottle/backend/firecracker/netpool.py @@ -91,7 +91,7 @@ def ip_base() -> str: # Sidecar ports the VM reaches at its host-side TAP IP. Kept in sync # with the backend constants (egress 9099, supervise 9100, git-http # 9420); rendered into the setup output for operator visibility. -SIDECAR_PORTS = (9099, 9100, 9420) +GATEWAY_PORTS = (9099, 9100, 9420) @dataclass(frozen=True) diff --git a/bot_bottle/backend/macos_container/cleanup.py b/bot_bottle/backend/macos_container/cleanup.py index 2dae5f8..bd7d5ee 100644 --- a/bot_bottle/backend/macos_container/cleanup.py +++ b/bot_bottle/backend/macos_container/cleanup.py @@ -9,7 +9,6 @@ from . import util as container_mod from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan _PREFIX = "bot-bottle-" -_BUNDLE_PREFIX = "bot-bottle-sidecars-" def _list_prefixed_containers() -> list[str]: @@ -24,7 +23,7 @@ def _list_prefixed_containers() -> list[str]: return [] return sorted( name for name in (line.strip() for line in result.stdout.splitlines()) - if name.startswith(_PREFIX) or name.startswith(_BUNDLE_PREFIX) + if name.startswith(_PREFIX) ) diff --git a/bot_bottle/backend/macos_container/egress_apply.py b/bot_bottle/backend/macos_container/egress_apply.py index a9c7df6..c9b6eda 100644 --- a/bot_bottle/backend/macos_container/egress_apply.py +++ b/bot_bottle/backend/macos_container/egress_apply.py @@ -1,36 +1,24 @@ -"""Host-side egress apply for the macos-container backend. +"""Host-side egress route-apply for the macos-container backend. -Uses `container kill --signal HUP` (Apple Container framework) instead -of `docker kill` to signal the sidecar bundle. +The per-bottle companion container this used to signal (`container kill +--signal HUP `) was removed in the de-sidecar cleanup (#385), +along with the disabled macOS launch path. Fails closed until the macOS +backend grows the consolidated gateway. """ from __future__ import annotations -import os -import subprocess - -from ...log import warn from ..egress_apply import EgressApplicator, EgressApplyError -from .launch import sidecar_container_name class MacOSContainerEgressApplicator(EgressApplicator): def _signal_bundle_reload(self, slug: str) -> None: - container = sidecar_container_name(slug) - result = subprocess.run( - ["container", "kill", "--signal", "HUP", container], - capture_output=True, text=True, check=False, env=os.environ, + del slug + raise EgressApplyError( + "live egress route-apply was removed with the per-bottle " + "companion container (#385); the macos-container backend is " + "disabled until it uses the consolidated gateway." ) - if result.returncode != 0: - last_error = (result.stderr or "").strip() or (result.stdout or "").strip() - warn( - f"egress: routes updated on disk for {slug}, but bundle reload failed: " - f"{last_error or 'container kill failed'}" - ) - raise EgressApplyError( - f"could not reload egress bundle {container}: " - f"{last_error or 'container kill failed'}" - ) applicator = MacOSContainerEgressApplicator() diff --git a/bot_bottle/backend/macos_container/enumerate.py b/bot_bottle/backend/macos_container/enumerate.py index b7d261d..27f8b8d 100644 --- a/bot_bottle/backend/macos_container/enumerate.py +++ b/bot_bottle/backend/macos_container/enumerate.py @@ -1,40 +1,14 @@ -"""Active-agent enumeration for the macOS Apple Container backend.""" +"""Active-agent enumeration for the macOS Apple Container backend. + +The backend is disabled during the de-sidecar cleanup (#385) — it can't +launch bottles, so there are none to enumerate. Enumeration returns when +the backend grows the consolidated gateway. +""" from __future__ import annotations -import subprocess - -from ...bottle_state import read_metadata from .. import ActiveAgent -_PREFIX = "bot-bottle-" -_SIDECAR_PREFIX = "bot-bottle-sidecars-" - def enumerate_active() -> list[ActiveAgent]: - result = subprocess.run( - ["container", "list", "--quiet"], - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - return [] - out: list[ActiveAgent] = [] - for name in sorted(line.strip() for line in result.stdout.splitlines()): - if not name.startswith(_PREFIX): - continue - if name.startswith(_SIDECAR_PREFIX): - continue - slug = name[len(_PREFIX):] - metadata = read_metadata(slug) - out.append(ActiveAgent( - backend_name="macos-container", - slug=slug, - agent_name=metadata.agent_name if metadata else "?", - started_at=metadata.started_at if metadata else "", - services=(), - label=metadata.label if metadata else "", - color=metadata.color if metadata else "", - )) - return out + return [] diff --git a/bot_bottle/backend/macos_container/launch.py b/bot_bottle/backend/macos_container/launch.py index 6e9034f..d6141f3 100644 --- a/bot_bottle/backend/macos_container/launch.py +++ b/bot_bottle/backend/macos_container/launch.py @@ -1,458 +1,39 @@ -"""Launch flow for the macOS Apple Container backend. +"""Launch flow for the macOS Apple Container backend — disabled (#385). -This backend keeps the explicit proxy-env enforcement model for v1: -the agent container is attached only to a host-only Apple Container -network, while the sidecar bundle is attached to a NAT network first -and the host-only network second. The sidecar's host-only IP is -discovered from `container inspect` and stamped into the agent's -HTTP_PROXY / HTTPS_PROXY env vars. +This backend launched a per-bottle companion container (the egress / +git-gate / supervise data plane) alongside the agent container, with the +agent's proxy env pointed at the companion's host-only IP. That +per-bottle-companion architecture was removed in the de-sidecar cleanup; +the macOS backend will be re-enabled once it grows the consolidated +per-host gateway the docker backend already uses. + +Until then, launching a macOS bottle fails closed. `prepare` / `status` +/ cleanup still work. """ from __future__ import annotations -import dataclasses -import os -import subprocess -from contextlib import ExitStack, contextmanager -from pathlib import Path +from contextlib import contextmanager from typing import Callable, Generator -from ...bottle_state import ( - egress_state_dir, - git_gate_state_dir, - read_committed_image, -) -from ...egress import ( - EGRESS_ROUTES_IN_CONTAINER, - egress_agent_env_entries, - egress_resolve_token_values, - egress_sidecar_env_entries, -) -from ...git_gate import ( - provision_git_gate_dynamic_keys, - revoke_git_gate_provisioned_keys, -) -from ...log import die, info, warn -from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT -from ...util import expand_tilde -from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT -from ..docker.git_gate import ( - GIT_GATE_ACCESS_HOOK_IN_CONTAINER, - GIT_GATE_CREDS_DIR_IN_CONTAINER, - GIT_GATE_ENTRYPOINT_IN_CONTAINER, - GIT_GATE_HOOK_IN_CONTAINER, -) -from ..docker.sidecar_bundle import ( - SIDECAR_BUNDLE_DOCKERFILE, - SIDECAR_BUNDLE_IMAGE, -) -from ..docker.egress import egress_tls_init -from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH -from . import util as container_mod +from ...log import die from .bottle import MacosContainerBottle from .bottle_plan import MacosContainerBottlePlan -_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) -_AGENT_SLEEP_SECONDS = "2147483647" -_GIT_HTTP_PORT = 9420 -_GIT_GATE_READY_FILE = "/run/git-gate/ready" - - -def internal_network_name(slug: str) -> str: - return f"bot-bottle-net-{slug}" - - -def egress_network_name(slug: str) -> str: - return f"bot-bottle-egress-{slug}" - - -def sidecar_container_name(slug: str) -> str: - return f"bot-bottle-sidecars-{slug}" - - @contextmanager def launch( plan: MacosContainerBottlePlan, *, provision: Callable[[MacosContainerBottlePlan, "MacosContainerBottle"], str | None], ) -> Generator[MacosContainerBottle, None, None]: - """Build, run, provision, and yield an Apple Container bottle.""" - stack = ExitStack() - bottle_for_revoke = plan.manifest.bottle - git_gate_dir_for_revoke = git_gate_state_dir(plan.slug) - - def teardown() -> None: - teardown_exc: BaseException | None = None - try: - stack.close() - except BaseException as exc: # noqa: W0718 - teardown must continue - teardown_exc = exc - warn(f"macos-container teardown failed: {exc!r}") - revoke_git_gate_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke) - if teardown_exc is not None: - raise teardown_exc - - try: - plan = _mint_certs(plan) - plan = _build_images(plan) - - internal_network = internal_network_name(plan.slug) - egress_network = egress_network_name(plan.slug) - _create_networks(internal_network, egress_network, stack) - - plan = _provision_git_gate_keys(plan) - - sidecar_name = sidecar_container_name(plan.slug) - container_mod.force_remove_container(sidecar_name) - _start_sidecar_bundle(plan, sidecar_name, internal_network, egress_network) - stack.callback(container_mod.force_remove_container, sidecar_name) - _stage_git_gate(plan, sidecar_name) - - sidecar_ip = container_mod.container_ipv4_on_network( - sidecar_name, internal_network, - ) - plan = _stamp_agent_urls(plan, sidecar_ip) - - container_mod.force_remove_container(plan.container_name) - _start_agent(plan, internal_network, sidecar_ip) - stack.callback(container_mod.force_remove_container, plan.container_name) - - bottle = MacosContainerBottle( - plan.container_name, - teardown, - None, - agent_command=plan.agent_command, - agent_prompt_mode=plan.agent_prompt_mode, - agent_provider_template=plan.agent_provider_template, - terminal_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name, - terminal_color=plan.spec.color, - agent_workdir=plan.workspace_plan.workdir, - ) - bottle.prompt_path = provision(plan, bottle) - - yield bottle - finally: - teardown() - - -def _mint_certs(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan: - egress_ca_host, egress_ca_cert_only = egress_tls_init( - egress_state_dir(plan.slug), + """Fail closed: the macOS backend is disabled until it grows the + consolidated per-host gateway (the companion-container path it used + was removed in #385).""" + del plan, provision + die( + "the macos-container backend is temporarily disabled during the " + "companion-container removal (#385); it will return once it uses " + "the consolidated gateway. Use --backend=docker for now." ) - egress_plan = dataclasses.replace( - plan.egress_plan, - mitmproxy_ca_host_path=egress_ca_host, - mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, - ) - return dataclasses.replace(plan, egress_plan=egress_plan) - - -def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan: - container_mod.build_image( - SIDECAR_BUNDLE_IMAGE, - _REPO_DIR, - dockerfile=SIDECAR_BUNDLE_DOCKERFILE, - ) - committed = read_committed_image(plan.slug) - if committed and container_mod.image_exists(committed): - info(f"using committed image {committed!r}") - return dataclasses.replace( - plan, - agent_provision=dataclasses.replace( - plan.agent_provision, - image=committed, - ), - ) - container_mod.build_image( - plan.image, - _REPO_DIR, - dockerfile=plan.dockerfile_path, - ) - return plan - - -def _create_networks( - internal_network: str, - egress_network: str, - stack: ExitStack, -) -> None: - container_mod.create_network(internal_network, internal=True) - stack.callback(container_mod.remove_network, internal_network) - container_mod.create_network(egress_network) - stack.callback(container_mod.remove_network, egress_network) - - -def _start_sidecar_bundle( - plan: MacosContainerBottlePlan, - sidecar_name: str, - internal_network: str, - egress_network: str, -) -> None: - argv = _sidecar_run_argv(plan, sidecar_name, internal_network, egress_network) - effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env} - token_values = egress_resolve_token_values( - plan.egress_plan.token_env_map, effective_env, - ) - env = {**os.environ, **token_values} - info(f"container run sidecar bundle {sidecar_name}") - result = subprocess.run( - argv, capture_output=True, text=True, env=env, check=False, - ) - if result.returncode != 0: - die( - f"container run for sidecar bundle {sidecar_name} failed: " - f"{(result.stderr or '').strip() or ''}" - ) - - -def _start_agent( - plan: MacosContainerBottlePlan, - internal_network: str, - sidecar_ip: str, -) -> None: - argv = _agent_run_argv(plan, internal_network, sidecar_ip) - env = { - **os.environ, - **plan.forwarded_env, - } - info(f"container run agent {plan.container_name}") - result = subprocess.run( - argv, capture_output=True, text=True, env=env, check=False, - ) - if result.returncode != 0: - die( - f"container run for agent {plan.container_name} failed: " - f"{(result.stderr or '').strip() or ''}" - ) - - -def _stamp_agent_urls( - plan: MacosContainerBottlePlan, - sidecar_ip: str, -) -> MacosContainerBottlePlan: - proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}" - supervise_url = "" - if plan.supervise_plan is not None: - supervise_url = f"http://{sidecar_ip}:{SUPERVISE_PORT}/" - git_gate_url = "" - if plan.git_gate_plan.upstreams: - git_gate_url = f"http://{sidecar_ip}:{_GIT_HTTP_PORT}" - return dataclasses.replace( - plan, - agent_proxy_url=proxy_url, - agent_git_gate_url=git_gate_url, - agent_supervise_url=supervise_url, - ) - - -def _provision_git_gate_keys( - plan: MacosContainerBottlePlan, -) -> MacosContainerBottlePlan: - if not plan.git_gate_plan.upstreams: - return plan - git_gate_plan = provision_git_gate_dynamic_keys( - plan.manifest.bottle, - plan.git_gate_plan, - git_gate_state_dir(plan.slug), - ) - return dataclasses.replace(plan, git_gate_plan=git_gate_plan) - - -def _stage_git_gate(plan: MacosContainerBottlePlan, sidecar_name: str) -> None: - gp = plan.git_gate_plan - if not gp.upstreams: - return - - container_mod.exec_container( - sidecar_name, - [ - "mkdir", - "-p", - str(Path(GIT_GATE_HOOK_IN_CONTAINER).parent), - GIT_GATE_CREDS_DIR_IN_CONTAINER, - "/git", - str(Path(_GIT_GATE_READY_FILE).parent), - ], - ) - - for host_path, container_path in _git_gate_files(plan): - container_mod.copy_into_container( - sidecar_name, host_path, container_path, - ) - - container_mod.exec_container( - sidecar_name, - [ - "sh", - "-c", - "chmod 755 " - f"{GIT_GATE_ENTRYPOINT_IN_CONTAINER} " - f"{GIT_GATE_HOOK_IN_CONTAINER} " - f"{GIT_GATE_ACCESS_HOOK_IN_CONTAINER} && " - f"chmod 600 {GIT_GATE_CREDS_DIR_IN_CONTAINER}/* && " - f"touch {_GIT_GATE_READY_FILE}", - ], - ) - - -def _git_gate_files( - plan: MacosContainerBottlePlan, -) -> tuple[tuple[str, str], ...]: - gp = plan.git_gate_plan - files: list[tuple[str, str]] = [ - (str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER), - (str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER), - (str(gp.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER), - ] - for upstream in gp.upstreams: - files.append(( - expand_tilde(upstream.identity_file), - f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-key", - )) - if upstream.known_hosts_file: - files.append(( - str(upstream.known_hosts_file), - f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-known_hosts", - )) - return tuple(files) - - -def _sidecar_run_argv( - plan: MacosContainerBottlePlan, - sidecar_name: str, - internal_network: str, - egress_network: str, -) -> list[str]: - argv = [ - "container", "run", - "--name", sidecar_name, - "--detach", - "--rm", - "--network", egress_network, - "--network", internal_network, - "--dns", _sidecar_dns(), - "--env", f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(_sidecar_daemons(plan))}", - ] - for entry in _sidecar_env_entries(plan): - argv += ["--env", entry] - for host_path, container_path, read_only in _sidecar_mounts(plan): - argv += ["--mount", _mount_spec(host_path, container_path, read_only)] - argv.append(SIDECAR_BUNDLE_IMAGE) - return argv - - -def _agent_run_argv( - plan: MacosContainerBottlePlan, - internal_network: str, - sidecar_ip: str, -) -> list[str]: - argv = [ - "container", "run", - "--name", plan.container_name, - "--detach", - "--network", internal_network, - ] - for entry in _agent_env_entries(plan, sidecar_ip): - argv += ["--env", entry] - argv += [plan.image, "sleep", _AGENT_SLEEP_SECONDS] - return argv - - -def _sidecar_dns() -> str: - return container_mod.dns_server() - - -def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]: - daemons = ["egress"] - if plan.git_gate_plan.upstreams: - daemons += ["git-gate", "git-http"] - if plan.supervise_plan is not None: - daemons.append("supervise") - return tuple(daemons) - - -def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]: - env: list[str] = list(egress_sidecar_env_entries(plan.egress_plan)) - if plan.git_gate_plan.upstreams: - env.append(f"BOT_BOTTLE_GIT_GATE_READY_FILE={_GIT_GATE_READY_FILE}") - if plan.supervise_plan is not None: - env += [ - f"SUPERVISE_BOTTLE_SLUG={plan.slug}", - f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", - f"SUPERVISE_PORT={SUPERVISE_PORT}", - ] - return tuple(env) - - -def _sidecar_mounts( - plan: MacosContainerBottlePlan, -) -> tuple[tuple[str, str, bool], ...]: - mounts: list[tuple[str, str, bool]] = [] - - ep = plan.egress_plan - mounts.append(( - str(ep.mitmproxy_ca_host_path.parent), - str(Path(EGRESS_CA_IN_CONTAINER).parent), - False, - )) - if ep.routes: - mounts.append(( - str(ep.routes_path.parent), - str(Path(EGRESS_ROUTES_IN_CONTAINER).parent), - True, - )) - - sp = plan.supervise_plan - if sp is not None: - # `container run --mount type=bind` only accepts directory - # sources (a file source fails with "is not a directory") — - # mount db_path's dedicated parent dir instead of the file - # itself, same as the CA/routes mounts above. - mounts.append(( - str(sp.db_path.parent), - str(Path(DB_PATH_IN_CONTAINER).parent), - False, - )) - - return tuple(mounts) - -def _mount_spec(host_path: str, container_path: str, read_only: bool) -> str: - spec = f"type=bind,source={host_path},target={container_path}" - if read_only: - spec += ",readonly" - return spec - - -def _agent_env_entries( - plan: MacosContainerBottlePlan, - sidecar_ip: str, -) -> tuple[str, ...]: - proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}" - no_proxy = _agent_no_proxy(plan, sidecar_ip) - env = [ - f"HTTPS_PROXY={proxy_url}", - f"HTTP_PROXY={proxy_url}", - f"https_proxy={proxy_url}", - f"http_proxy={proxy_url}", - f"NO_PROXY={no_proxy}", - f"no_proxy={no_proxy}", - f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}", - f"SSL_CERT_FILE={AGENT_CA_BUNDLE}", - f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}", - ] - if plan.agent_git_gate_url: - env.append(f"GIT_GATE_URL={plan.agent_git_gate_url}") - if plan.agent_supervise_url: - env.append(f"MCP_SUPERVISE_URL={plan.agent_supervise_url}") - for name, value in sorted(plan.agent_provision.guest_env.items()): - env.append(f"{name}={value}") - for name in sorted(plan.forwarded_env.keys()): - env.append(name) - env.extend(egress_agent_env_entries(plan.egress_plan)) - return tuple(env) - - -def _agent_no_proxy(plan: MacosContainerBottlePlan, sidecar_ip: str) -> str: - hosts = ["localhost", "127.0.0.1", sidecar_ip] - return ",".join(hosts) + yield # unreachable — `die` raises; keeps this a generator/contextmanager diff --git a/bot_bottle/egress.py b/bot_bottle/egress.py index 98ea7bf..d3e4436 100644 --- a/bot_bottle/egress.py +++ b/bot_bottle/egress.py @@ -63,8 +63,8 @@ def _random_canary_env() -> str: return f"{first}_{second}_SECRET" -def egress_sidecar_env_entries(plan: "EgressPlan") -> tuple[str, ...]: - """Return sidecar env entries needed by egress across all backends.""" +def egress_gateway_env_entries(plan: "EgressPlan") -> tuple[str, ...]: + """Return gateway env entries needed by egress across all backends.""" env: list[str] = [] if plan.routes: env.extend(sorted(plan.token_env_map.keys())) @@ -412,6 +412,6 @@ __all__ = [ "egress_resolve_token_values", "egress_routes_for_bottle", "egress_agent_env_entries", - "egress_sidecar_env_entries", + "egress_gateway_env_entries", "egress_token_env_map", ] diff --git a/tests/integration/test_firecracker_launch.py b/tests/integration/test_firecracker_launch.py deleted file mode 100644 index 654e524..0000000 --- a/tests/integration/test_firecracker_launch.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Integration: Firecracker microVM launch. - -End-to-end against a real Firecracker microVM: prepare + launch a bottle -on the firecracker backend and verify the agent execs after provisioning -and that the egress proxy env is wired to the sidecar. - -Gated on the `backend status` result for firecracker (0 == the privileged -TAP pool + nft isolation table are provisioned). Skips cleanly with setup -instructions otherwise, so the suite runs on hosts without the pool. -""" - -from __future__ import annotations - -import contextlib -import io -import os -import shutil -import tempfile -import unittest -from pathlib import Path - -from bot_bottle.backend import BottleSpec, get_bottle_backend -from bot_bottle.backend.firecracker import FirecrackerBottleBackend -from bot_bottle.manifest import ManifestIndex - - -def _firecracker_status_ok() -> bool: - """Gate on `./cli.py backend status --backend=firecracker`: a 0 exit - means the pool + nft table are ready. Output is captured so the - decorator stays quiet during collection; any error → not ready.""" - buf = io.StringIO() - try: - with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf): - return FirecrackerBottleBackend.status() == 0 - except Exception: - return False - - -_SKIP_MSG = ( - "firecracker backend not ready — provision the network pool with " - "`./cli.py backend setup --backend=firecracker`, then confirm with " - "`./cli.py backend status --backend=firecracker`" -) - - -def _minimal_agent_dockerfile(path: Path) -> None: - path.write_text( - "\n".join(( - "FROM node:22-slim", - "RUN apt-get update \\", - " && apt-get install -y --no-install-recommends \\", - " ca-certificates curl git \\", - " && rm -rf /var/lib/apt/lists/*", - "USER node", - "WORKDIR /home/node", - "CMD [\"sleep\", \"infinity\"]", - "", - )), - encoding="utf-8", - ) - - -def _minimal_manifest(dockerfile: Path) -> ManifestIndex: - return ManifestIndex.from_json_obj({ - "bottles": { - "dev": { - "agent_provider": { - "template": "pi", - "dockerfile": str(dockerfile), - "settings": { - "provider": "example", - "base_url": "https://example.com/v1", - "models": ["smoke"], - }, - }, - "egress": {"routes": [{"host": "example.com"}]}, - }, - }, - "agents": { - "demo": {"skills": [], "prompt": "smoke", "bottle": "dev"}, - }, - }) - - -@unittest.skipIf( - os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: cannot host Firecracker microVMs", -) -@unittest.skipUnless(_firecracker_status_ok(), _SKIP_MSG) -class TestFirecrackerLaunch(unittest.TestCase): - """Launch once, reuse the bottle across probes.""" - - @classmethod - def setUpClass(cls) -> None: - cls.stage = Path(tempfile.mkdtemp(prefix="cb-firecracker-launch.")) - cls._launch = None - cls.bottle = None - dockerfile = cls.stage / "Dockerfile.agent-smoke" - _minimal_agent_dockerfile(dockerfile) - os.environ["BOT_BOTTLE_BACKEND"] = "firecracker" - try: - backend = get_bottle_backend() - spec = BottleSpec( - manifest=_minimal_manifest(dockerfile), - agent_name="demo", - copy_cwd=False, - user_cwd=str(cls.stage), - ) - cls.plan = backend.prepare(spec, stage_dir=cls.stage) - cls._launch = backend.launch(cls.plan) - cls.bottle = cls._launch.__enter__() - except BaseException: - if cls._launch is not None: - cls._launch.__exit__(None, None, None) - shutil.rmtree(cls.stage, ignore_errors=True) - os.environ.pop("BOT_BOTTLE_BACKEND", None) - raise - - @classmethod - def tearDownClass(cls) -> None: - try: - if cls._launch is not None: - cls._launch.__exit__(None, None, None) - finally: - shutil.rmtree(cls.stage, ignore_errors=True) - os.environ.pop("BOT_BOTTLE_BACKEND", None) - - def test_smoke_exec_echo(self) -> None: - r = self.bottle.exec("echo hello-from-firecracker") # type: ignore[union-attr] - self.assertEqual(0, r.returncode, msg=r.stderr) - self.assertIn("hello-from-firecracker", r.stdout) - - def test_proxy_env_points_at_sidecar(self) -> None: - r = self.bottle.exec( # type: ignore[union-attr] - "printf '%s\\n' \"$HTTPS_PROXY\" \"$HTTP_PROXY\"" - ) - self.assertEqual(0, r.returncode, msg=r.stderr) - self.assertIn("http", r.stdout.lower()) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/integration/test_macos_container_launch.py b/tests/integration/test_macos_container_launch.py deleted file mode 100644 index 71678d5..0000000 --- a/tests/integration/test_macos_container_launch.py +++ /dev/null @@ -1,239 +0,0 @@ -"""Integration: macOS Container launch topology. - -End-to-end against Apple's real `container` runtime. The smoke launches -a bottle with the experimental macOS Container backend and verifies the -properties that make the explicit-proxy launch acceptable: - - - the agent can exec commands after provisioning; - - HTTP(S)_PROXY points at the sidecar's internal-network IP; - - allowlisted HTTPS reaches the egress sidecar; - - direct egress with proxy env removed fails from the internal-only - agent network; - - non-allowlisted proxy traffic is blocked. - -Skipped under Gitea Actions and on hosts without Apple's `container`. -""" - -from __future__ import annotations - -import os -import platform -import shutil -import subprocess -import tempfile -import unittest -from pathlib import Path - -from bot_bottle.backend import BottleSpec, get_bottle_backend -from bot_bottle.backend.macos_container.util import ( - dns_server as _container_dns_server, - is_available as _container_available, -) -from bot_bottle.manifest import ManifestIndex - - -_AGENT_PROMPT = "You are a launch smoke-test agent. Be brief." - - -def _minimal_agent_dockerfile(path: Path) -> None: - path.write_text( - "\n".join(( - "FROM node:22-slim", - "RUN apt-get update \\", - " && apt-get install -y --no-install-recommends \\", - " ca-certificates curl git \\", - " && rm -rf /var/lib/apt/lists/*", - "USER node", - "WORKDIR /home/node", - "CMD [\"sleep\", \"infinity\"]", - "", - )), - encoding="utf-8", - ) - - -def _minimal_manifest(dockerfile: Path) -> ManifestIndex: - return ManifestIndex.from_json_obj({ - "bottles": { - "dev": { - "agent_provider": { - "template": "pi", - "dockerfile": str(dockerfile), - "settings": { - "provider": "example", - "base_url": "https://example.com/v1", - "models": ["smoke"], - }, - }, - "egress": { - "routes": [ - {"host": "example.com"}, - ], - }, - }, - }, - "agents": { - "demo": { - "skills": [], - "prompt": _AGENT_PROMPT, - "bottle": "dev", - }, - }, - }) - - -def _buildkit_dns_available() -> bool: - if platform.system() != "Darwin" or not _container_available(): - return False - stage = Path(tempfile.mkdtemp(prefix="cb-container-buildkit-dns.")) - image = "bot-bottle-buildkit-dns-check:latest" - try: - dockerfile = stage / "Dockerfile" - dockerfile.write_text( - "FROM debian:bookworm-slim\n" - "RUN getent hosts deb.debian.org\n", - encoding="utf-8", - ) - result = subprocess.run( - [ - "container", "build", - "--dns", _container_dns_server(), - "-t", image, - "-f", str(dockerfile), - str(stage), - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - return result.returncode == 0 - finally: - subprocess.run( - ["container", "image", "delete", image], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - shutil.rmtree(stage, ignore_errors=True) - - -@unittest.skipIf( - os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: cannot host Apple Container VMs", -) -@unittest.skipUnless( - platform.system() == "Darwin", - "Apple Container is macOS-only", -) -@unittest.skipUnless( - _container_available(), - "Apple Container not on PATH; install from " - "https://github.com/apple/container/releases", -) -@unittest.skipUnless( - _buildkit_dns_available(), - "Apple Container BuildKit cannot resolve deb.debian.org on this host", -) -class TestMacosContainerLaunch(unittest.TestCase): - """Launch once and reuse the bottle across probes.""" - - @classmethod - def setUpClass(cls) -> None: - cls.stage = Path(tempfile.mkdtemp(prefix="cb-macos-container-launch.")) - cls._launch = None - cls.bottle = None - dockerfile = cls.stage / "Dockerfile.agent-smoke" - _minimal_agent_dockerfile(dockerfile) - os.environ["BOT_BOTTLE_BACKEND"] = "macos-container" - try: - backend = get_bottle_backend() - spec = BottleSpec( - manifest=_minimal_manifest(dockerfile), - agent_name="demo", - copy_cwd=False, - user_cwd=str(cls.stage), - ) - cls.plan = backend.prepare(spec, stage_dir=cls.stage) - cls._launch = backend.launch(cls.plan) - cls.bottle = cls._launch.__enter__() - except BaseException: - if cls._launch is not None: - cls._launch.__exit__(None, None, None) - shutil.rmtree(cls.stage, ignore_errors=True) - os.environ.pop("BOT_BOTTLE_BACKEND", None) - raise - - @classmethod - def tearDownClass(cls) -> None: - try: - if cls._launch is not None: - cls._launch.__exit__(None, None, None) - finally: - shutil.rmtree(cls.stage, ignore_errors=True) - os.environ.pop("BOT_BOTTLE_BACKEND", None) - - def test_smoke_exec_echo(self): - r = self.bottle.exec( # type: ignore[union-attr] - "echo hello-from-macos-container" - ) - self.assertEqual(0, r.returncode, msg=r.stderr) - self.assertIn("hello-from-macos-container", r.stdout) - - def test_proxy_env_points_at_sidecar_internal_ip(self): - r = self.bottle.exec( # type: ignore[union-attr] - "printf '%s\n' \"$HTTPS_PROXY\" \"$HTTP_PROXY\" " - "\"$NO_PROXY\" \"$NODE_EXTRA_CA_CERTS\"" - ) - self.assertEqual(0, r.returncode, msg=r.stderr) - values = [line.strip() for line in r.stdout.splitlines()] - self.assertEqual(4, len(values), values) - self.assertEqual(values[0], values[1], values) - self.assertRegex(values[0], r"^http://[0-9.]+:9099$") - self.assertNotIn("127.0.0.1", values[0]) - sidecar_host = values[0].removeprefix("http://").removesuffix(":9099") - self.assertIn(sidecar_host, values[2]) - self.assertEqual( - "/usr/local/share/ca-certificates/bot-bottle-mitm-ca.crt", - values[3], - ) - - def test_allowlisted_https_reaches_egress_proxy(self): - r = self.bottle.exec( # type: ignore[union-attr] - "curl -fsS --max-time 20 https://example.com >/dev/null && echo OK" - ) - self.assertEqual(0, r.returncode, msg=r.stderr + r.stdout) - self.assertIn("OK", r.stdout) - - def test_direct_egress_bypass_without_proxy_fails(self): - r = self.bottle.exec( # type: ignore[union-attr] - "env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy " - "curl -s --show-error --max-time 5 https://example.com 2>&1 || true" - ) - self.assertTrue( - "refused" in r.stdout.lower() - or "timed out" in r.stdout.lower() - or "unreachable" in r.stdout.lower() - or "failed" in r.stdout.lower() - or "could not resolve" in r.stdout.lower() - or "connection reset" in r.stdout.lower(), - f"expected direct egress to fail; got: {r.stdout!r}", - ) - - def test_non_allowlisted_host_fails_through_proxy(self): - r = self.bottle.exec( # type: ignore[union-attr] - "curl -s --show-error --max-time 10 https://iana.org 2>&1 || true" - ) - self.assertTrue( - "403" in r.stdout - or "502" in r.stdout - or "blocked" in r.stdout.lower() - or "not allowed" in r.stdout.lower() - or "not in the bottle's egress.routes allowlist" in r.stdout.lower() - or "forbidden" in r.stdout.lower() - or "failed" in r.stdout.lower(), - f"expected non-allowlisted proxy request to fail; got: {r.stdout!r}", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/integration/test_sidecar_bundle_compose.py b/tests/integration/test_sidecar_bundle_compose.py deleted file mode 100644 index b57600e..0000000 --- a/tests/integration/test_sidecar_bundle_compose.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Integration: end-to-end smoke for the PRD 0024 bundle shape. - -Verifies that flipping `BOT_BOTTLE_SIDECAR_BUNDLE=1` produces a -working bottle: `docker compose up` brings the agent + bundle pair -online, the daemons inside the bundle bind their ports, and the -agent can reach egress + supervise via the bundle's network -aliases (no agent-side config changes between flag positions). - -Skipped under GITEA_ACTIONS — the bundle image is a multi-stage -build pulling 200+MB of base layers, and the bind-mounts won't -share filesystem with the runner container. Same constraint as -the chunk-1 image-probe test. -""" - -from __future__ import annotations - -import os -import shutil -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - -from bot_bottle.backend import BottleSpec, get_bottle_backend -from bot_bottle.manifest import ManifestIndex -from tests._docker import skip_unless_docker - - -def _manifest() -> ManifestIndex: - """Bottle with supervise on so the bundle exercises egress + - supervise. Git is off because a meaningful git-gate test needs - a real upstream and SSH keys — out of scope for a bundle smoke.""" - return ManifestIndex.from_json_obj({ - "bottles": { - "dev": { - "supervise": True, - }, - }, - "agents": { - "demo": {"skills": [], "prompt": "", "bottle": "dev"}, - }, - }) - - -@skip_unless_docker() -@unittest.skipIf( - os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: multi-stage bundle build pulls 200+MB " - "of base layers and bind-mounts don't share fs with the runner", -) -class TestSidecarBundleCompose(unittest.TestCase): - """One end-to-end pass with the bundle flag on. Skipping under - act_runner; the local docker daemon does the work.""" - - def test_bottle_up_with_bundle_flag_on(self): - stage_dir = Path(tempfile.mkdtemp(prefix="cb-bundle-smoke.")) - try: - with patch.dict(os.environ, {"BOT_BOTTLE_SIDECAR_BUNDLE": "1"}): - backend = get_bottle_backend("docker") - spec = BottleSpec( - manifest=_manifest(), - agent_name="demo", - copy_cwd=False, - user_cwd=str(stage_dir), - ) - plan = backend.prepare(spec, stage_dir=stage_dir) - with backend.launch(plan) as bottle: - # The agent's HTTPS_PROXY URL (resolved at - # renderer-time) should reach egress inside - # the bundle. A bare CONNECT with no upstream - # URL gets rejected with 400 or 405 but proves - # the listener is alive at the alias. - probe = bottle.exec( - "set -eu\n" - "echo HTTPS_PROXY=$HTTPS_PROXY\n" - "PORT=$(echo \"$HTTPS_PROXY\" | sed -E 's|.*:([0-9]+).*|\\1|')\n" - "HOST=$(echo \"$HTTPS_PROXY\" | sed -E 's|http://([^:]+):.*|\\1|')\n" - "echo HOST=$HOST PORT=$PORT\n" - "curl -sS --max-time 5 -o /dev/null -w 'http=%{http_code}\\n' " - " \"http://$HOST:$PORT/\" || true\n" - ) - # The supervise URL resolves to the same bundle - # via its supervise alias, on a different port. - supervise_probe = bottle.exec( - "set -eu\n" - "curl -sS --max-time 5 -o /dev/null " - " -w 'http=%{http_code}\\n' " - " \"http://supervise:9100/health\" || true\n" - ) - finally: - shutil.rmtree(stage_dir, ignore_errors=True) - - self.assertEqual(0, probe.returncode, msg=probe.stderr) - # egress answered SOMETHING — any 4xx is fine, just proves - # the egress daemon is listening at the proxy address. - self.assertIn("http=", probe.stdout, - f"no HTTP response from egress: {probe.stdout!r}") - # supervise's /health endpoint exists (PRD 0013); it should - # answer 200 or similar — anything non-empty proves the - # third daemon's alias resolves to the same bundle. - self.assertEqual(0, supervise_probe.returncode, msg=supervise_probe.stderr) - self.assertIn("http=", supervise_probe.stdout) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/_docker_bottle_plan.py b/tests/unit/_docker_bottle_plan.py new file mode 100644 index 0000000..e2126af --- /dev/null +++ b/tests/unit/_docker_bottle_plan.py @@ -0,0 +1,156 @@ +"""Shared in-memory `DockerBottlePlan` fixture for docker-backend tests. + +A fully-resolved plan with toggles for the conditional-service matrix +(git-gate / egress / supervise / canary). Consumed by the consolidated +compose tests; kept here (rather than in a test module) so it survives +independent of any one test file. +""" + +from __future__ import annotations + +from pathlib import Path + +from bot_bottle.agent_provider import AgentProvisionPlan +from bot_bottle.backend import BottleSpec +from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan +from bot_bottle.egress import EgressPlan, EgressRoute +from bot_bottle.git_gate import GitGatePlan, GitGateUpstream +from bot_bottle.manifest import ManifestIndex +from bot_bottle.supervise import SupervisePlan + + +SLUG = "demo-abc12" +STAGE = Path("/tmp/cb-stage") +STATE = Path("/tmp/cb-state") + + +def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> ManifestIndex: + """Minimal manifest with the toggles the matrix needs. The renderer + only reads from the plan, not the manifest, so this is just here to + back BottleSpec.""" + bottle: dict[str, object] = {} + if supervise: + bottle["supervise"] = True + if with_git: + bottle["git-gate"] = {"repos": { + "upstream": { + "url": "ssh://git@example.com:22/x/y.git", + "key": {"provider": "static", "path": "/etc/hostname"}, + }, + }} + if with_egress: + bottle["egress"] = { + "routes": [{ + "host": "api.example", + "auth": {"scheme": "Bearer", "token_ref": "TOK"}, + }], + } + return ManifestIndex.from_json_obj({ + "bottles": {"dev": bottle}, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + }) + + +def _git_gate_plan(upstreams: tuple[GitGateUpstream, ...] = ()) -> GitGatePlan: + return GitGatePlan( + slug=SLUG, + entrypoint_script=STATE / "git-gate" / "entrypoint.sh", + hook_script=STATE / "git-gate" / "pre-receive", + access_hook_script=STATE / "git-gate" / "access-hook", + upstreams=upstreams, + internal_network=f"bot-bottle-net-{SLUG}", + egress_network=f"bot-bottle-egress-{SLUG}", + ) + + +def _egress_plan( + routes: tuple[EgressRoute, ...] = (), + *, + canary: bool = False, +) -> EgressPlan: + token_env_map = { + r.token_env: r.token_ref + for r in routes + if r.token_env + } + return EgressPlan( + slug=SLUG, + routes_path=STATE / "egress" / "routes.yaml", + routes=routes, + token_env_map=token_env_map, + internal_network=f"bot-bottle-net-{SLUG}", + egress_network=f"bot-bottle-egress-{SLUG}", + mitmproxy_ca_host_path=STATE / "egress-ca" / "mitmproxy-ca.pem", + mitmproxy_ca_cert_only_host_path=STATE / "egress-ca" / "ca.pem", + canary="fake-canary-value" if canary else "", + canary_env="CANON_ALPHA_SECRET" if canary else "", + ) + + +def _supervise_plan() -> SupervisePlan: + return SupervisePlan( + slug=SLUG, + db_path=STATE / "bot-bottle.db", + internal_network=f"bot-bottle-net-{SLUG}", + ) + + +def _plan( + *, + with_git: bool = False, + with_egress: bool = False, + supervise: bool = False, + canary: bool = False, +) -> DockerBottlePlan: + """Build a fully-resolved DockerBottlePlan. Toggles cover the + matrix the renderer's conditional-service logic branches on.""" + upstreams: tuple[GitGateUpstream, ...] = () + if with_git: + upstreams = (GitGateUpstream( + name="upstream", + upstream_url="ssh://git@example.com:22/x/y.git", + upstream_host="example.com", + upstream_port="22", + identity_file="/etc/hostname", + known_host_key="", + known_hosts_file=STATE / "git-gate" / "upstream-known_hosts", + ),) + routes: tuple[EgressRoute, ...] = () + if with_egress: + routes = (EgressRoute( + host="api.example", + auth_scheme="Bearer", + token_env="EGRESS_TOKEN_0", + token_ref="TOK", + roles=(), + ),) + + index = _manifest(supervise=supervise, with_git=with_git, with_egress=with_egress) + spec = BottleSpec( + manifest=index, + agent_name="demo", + copy_cwd=False, + user_cwd="/tmp/x", + ) + return DockerBottlePlan( + spec=spec, + manifest=index.load_for_agent("demo"), + stage_dir=STAGE, + slug=SLUG, + forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"}, + git_gate_plan=_git_gate_plan(upstreams), + egress_plan=_egress_plan(routes, canary=canary), + supervise_plan=_supervise_plan() if supervise else None, + use_runsc=False, + agent_provision=AgentProvisionPlan( + template="claude", + command="claude", + prompt_mode="append_file", + image="bot-bottle-claude:latest", + dockerfile="", + guest_home="/home/node", + instance_name=f"bot-bottle-{SLUG}", + prompt_file=STAGE / "prompt", + guest_env={}, + ), + ) diff --git a/tests/unit/test_consolidated_compose.py b/tests/unit/test_consolidated_compose.py index f463d33..47ea9a4 100644 --- a/tests/unit/test_consolidated_compose.py +++ b/tests/unit/test_consolidated_compose.py @@ -5,7 +5,7 @@ from __future__ import annotations import unittest from bot_bottle.backend.docker.consolidated_compose import consolidated_agent_compose -from tests.unit.test_compose import _plan +from tests.unit._docker_bottle_plan import _plan _GW = "172.18.0.2" _IP = "172.18.0.5" diff --git a/tests/unit/test_egress.py b/tests/unit/test_egress.py index 4b41afb..ee44f2a 100644 --- a/tests/unit/test_egress.py +++ b/tests/unit/test_egress.py @@ -16,7 +16,7 @@ from bot_bottle.egress import ( egress_render_routes, egress_resolve_token_values, egress_routes_for_bottle, - egress_sidecar_env_entries, + egress_gateway_env_entries, egress_token_env_map, ) from bot_bottle.errors import MissingEnvVarError @@ -603,7 +603,7 @@ class TestEgressEnvEntries(unittest.TestCase): "CANON_ALPHA_SECRET=fake-canary-value", "BOT_BOTTLE_SENSITIVE_PREFIXES=CANON_ALPHA_SECRET", ), - egress_sidecar_env_entries(plan), + egress_gateway_env_entries(plan), ) def test_agent_entries_include_only_canary_bait(self): @@ -630,7 +630,7 @@ class TestEgressEnvEntries(unittest.TestCase): canary="fake-canary-value", ) - self.assertEqual((), egress_sidecar_env_entries(plan)) + self.assertEqual((), egress_gateway_env_entries(plan)) self.assertEqual((), egress_agent_env_entries(plan)) diff --git a/tests/unit/test_egress_apply.py b/tests/unit/test_egress_apply.py index 12d3b1a..203b606 100644 --- a/tests/unit/test_egress_apply.py +++ b/tests/unit/test_egress_apply.py @@ -70,32 +70,16 @@ class TestApplyRoutesChange(unittest.TestCase): self.addCleanup(self._tmp.cleanup) self.addCleanup(use_bottle_root(Path(self._tmp.name) / ".bot-bottle")) - def test_writes_live_routes_and_signals_reload(self): - calls: list[list[str]] = [] - - def fake_run(argv: list[str], **kwargs: object) -> SimpleNamespace: - calls.append(list(argv)) - return SimpleNamespace(returncode=0, stdout="", stderr="") - - with patch( - "bot_bottle.backend.docker.egress_apply.subprocess.run", - side_effect=fake_run, - ): - before, after = applicator.apply_routes_change( + def test_apply_routes_change_fails_closed_after_companion_removal(self): + # The per-bottle companion container that live route-apply used to + # signal was removed in the de-sidecar cleanup (#385); apply now + # fails closed until the gateway-side apply lands. + with self.assertRaises(EgressApplyError) as cm: + applicator.apply_routes_change( "dev", "routes:\n - host: google.com\n", ) - - self.assertEqual("", before) - self.assertEqual("routes:\n - host: google.com\n", after) - self.assertEqual( - "routes:\n - host: google.com\n", - (Path(self._tmp.name) / ".bot-bottle/state/dev/egress/routes.yaml").read_text(encoding="utf-8"), - ) - self.assertEqual( - ["docker", "kill", "--signal", "HUP", "bot-bottle-sidecars-dev"], - calls[0], - ) + self.assertIn("consolidated gateway", str(cm.exception)) if __name__ == "__main__": diff --git a/tests/unit/test_firecracker_cleanup.py b/tests/unit/test_firecracker_cleanup.py index d5e6018..27b0097 100644 --- a/tests/unit/test_firecracker_cleanup.py +++ b/tests/unit/test_firecracker_cleanup.py @@ -38,18 +38,6 @@ class TestOrphanEnumeration(unittest.TestCase): with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(returncode=1)): self.assertEqual([], fc_cleanup._orphan_vm_pids()) - def test_sidecar_containers_sorted(self): - with patch.object(fc_cleanup.subprocess, "run", - return_value=_proc("bot-bottle-sidecars-b\nbot-bottle-sidecars-a\n")): - self.assertEqual( - ["bot-bottle-sidecars-a", "bot-bottle-sidecars-b"], - fc_cleanup._sidecar_containers(), - ) - - def test_sidecar_containers_empty_on_failure(self): - with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(returncode=1)): - self.assertEqual([], fc_cleanup._sidecar_containers()) - def test_run_dirs_empty_when_absent(self): with patch.object(fc_cleanup.util, "cache_dir") as cache: cache.return_value.__truediv__.return_value.is_dir.return_value = False @@ -57,28 +45,23 @@ class TestOrphanEnumeration(unittest.TestCase): def test_prepare_cleanup_assembles_plan(self): with patch.object(fc_cleanup, "_orphan_vm_pids", return_value=[7]), \ - patch.object(fc_cleanup, "_sidecar_containers", return_value=["c1"]), \ patch.object(fc_cleanup, "_run_dirs", return_value=["/run/x"]): plan = fc_cleanup.prepare_cleanup() self.assertEqual((7,), plan.vm_pids) - self.assertEqual(("c1",), plan.containers) self.assertEqual(("/run/x",), plan.run_dirs) class TestCleanupRemoval(unittest.TestCase): - def test_cleanup_kills_removes_and_rmtrees(self): + def test_cleanup_kills_and_rmtrees(self): plan = FirecrackerBottleCleanupPlan( - vm_pids=(101,), containers=("bot-bottle-sidecars-x",), + vm_pids=(101,), run_dirs=("/run/dev-x",), ) with patch.object(fc_cleanup.os, "kill") as kill, \ - patch.object(fc_cleanup.subprocess, "run") as run, \ patch.object(fc_cleanup.shutil, "rmtree") as rmtree, \ patch.object(fc_cleanup, "info"): fc_cleanup.cleanup(plan) kill.assert_called_once() - run.assert_called_once() - self.assertIn("bot-bottle-sidecars-x", run.call_args.args[0]) rmtree.assert_called_once_with("/run/dev-x", ignore_errors=True) def test_cleanup_tolerates_dead_pid(self): @@ -101,13 +84,12 @@ class TestCleanupPlan(unittest.TestCase): def test_print_lists_resources(self): plan = FirecrackerBottleCleanupPlan( - vm_pids=(5,), containers=("c",), run_dirs=("/r",), + vm_pids=(5,), run_dirs=("/r",), ) with patch("bot_bottle.backend.firecracker.bottle_cleanup_plan.info") as info: plan.print() joined = " ".join(c.args[0] for c in info.call_args_list) self.assertIn("pid 5", joined) - self.assertIn("container: c", joined) self.assertIn("run dir: /r", joined) diff --git a/tests/unit/test_macos_container_cleanup.py b/tests/unit/test_macos_container_cleanup.py index fc2d980..8a736a0 100644 --- a/tests/unit/test_macos_container_cleanup.py +++ b/tests/unit/test_macos_container_cleanup.py @@ -43,27 +43,10 @@ class TestMacosContainerCleanup(unittest.TestCase): class TestMacosContainerEnumerate(unittest.TestCase): - def test_enumerate_active_reads_metadata(self): - completed = enum_mod.subprocess.CompletedProcess( - args=[], - returncode=0, - stdout="bot-bottle-a\nbot-bottle-sidecars-a\nother\n", - stderr="", - ) - - class _Metadata: - agent_name = "impl" - started_at = "2026-06-10T00:00:00Z" - label = "Implement" - color = "blue" - - with patch.object(enum_mod.subprocess, "run", return_value=completed), \ - patch.object(enum_mod, "read_metadata", return_value=_Metadata()): - agents = enum_mod.enumerate_active() - self.assertEqual(1, len(agents)) - self.assertEqual("macos-container", agents[0].backend_name) - self.assertEqual("a", agents[0].slug) - self.assertEqual("impl", agents[0].agent_name) + def test_enumerate_active_is_empty_while_disabled(self): + # The macOS backend is disabled during the de-sidecar cleanup + # (#385); it launches nothing, so there is nothing to enumerate. + self.assertEqual([], enum_mod.enumerate_active()) if __name__ == "__main__": diff --git a/tests/unit/test_macos_container_launch.py b/tests/unit/test_macos_container_launch.py deleted file mode 100644 index 7dd9de7..0000000 --- a/tests/unit/test_macos_container_launch.py +++ /dev/null @@ -1,369 +0,0 @@ -"""Unit: Apple Container launch argv construction.""" - -from __future__ import annotations - -import unittest -import tempfile -from pathlib import Path -from types import SimpleNamespace -from typing import cast -from unittest.mock import patch - -from bot_bottle.agent_provider import AgentProvisionPlan -from bot_bottle.backend import BottleSpec -from bot_bottle.backend.macos_container import launch -from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan -from bot_bottle.egress import EgressPlan -from bot_bottle.git_gate import GitGatePlan -from bot_bottle.manifest import ManifestIndex - -_MANIFEST = ManifestIndex.from_json_obj({ - "bottles": {"dev": {}}, - "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, -}).load_for_agent("demo") - - -def _plan( - *, - stage_dir: Path, - git: bool = False, - supervise: bool = False, - agent_git_gate_url: str = "", - agent_supervise_url: str = "", - canary: bool = False, -) -> MacosContainerBottlePlan: - routes_path = stage_dir / "routes.yaml" - routes_path.write_text("routes: []\n", encoding="utf-8") - ca_dir = stage_dir / "egress-ca" - ca_dir.mkdir(exist_ok=True) - ca_path = ca_dir / "mitmproxy-ca.pem" - ca_path.write_text("ca\n", encoding="utf-8") - egress_plan = SimpleNamespace( - mitmproxy_ca_host_path=ca_path, - routes_path=routes_path, - routes=("route",), - token_env_map={"EGRESS_TOKEN_0": "HOST_TOKEN"}, - canary="fake-canary-value" if canary else "", - canary_env="CANON_ALPHA_SECRET" if canary else "", - ) - if git: - key_path = stage_dir / "origin-key" - key_path.write_text("key\n", encoding="utf-8") - known_hosts_path = stage_dir / "origin-known-hosts" - known_hosts_path.write_text("example.com ssh-ed25519 AAAA\n", encoding="utf-8") - entrypoint = stage_dir / "git_gate_entrypoint.sh" - entrypoint.write_text("#!/bin/sh\n", encoding="utf-8") - hook = stage_dir / "git_gate_pre_receive.sh" - hook.write_text("#!/bin/sh\n", encoding="utf-8") - access_hook = stage_dir / "git_gate_access_hook.sh" - access_hook.write_text("#!/bin/sh\n", encoding="utf-8") - upstream = SimpleNamespace( - name="origin", - identity_file=str(key_path), - known_hosts_file=known_hosts_path, - ) - git_gate_plan = SimpleNamespace( - upstreams=(upstream,), - entrypoint_script=entrypoint, - hook_script=hook, - access_hook_script=access_hook, - ) - else: - git_gate_plan = SimpleNamespace(upstreams=()) - supervise_plan = ( - SimpleNamespace( - db_path=Path("/state/bot-bottle.db"), - ) - if supervise else None - ) - agent_provision = SimpleNamespace( - guest_env={"LITERAL": "value"}, - provisioned_env={"CODEX_HOME": "/run/codex-home"}, - ) - return cast(MacosContainerBottlePlan, SimpleNamespace( - spec=SimpleNamespace(), - manifest=_MANIFEST, - stage_dir=stage_dir, - slug="dev-abc", - container_name="bot-bottle-dev-abc", - image="bot-bottle-agent:latest", - forwarded_env={"OAUTH_TOKEN": "host-value"}, - egress_plan=egress_plan, - git_gate_plan=git_gate_plan, - supervise_plan=supervise_plan, - agent_provision=agent_provision, - agent_git_gate_url=agent_git_gate_url, - agent_supervise_url=agent_supervise_url, - )) - - -class TestMacosContainerLaunchArgv(unittest.TestCase): - def setUp(self): - self._tmp = tempfile.TemporaryDirectory() - self.stage_dir = Path(self._tmp.name) - - def tearDown(self): - self._tmp.cleanup() - - def test_sidecar_argv_uses_egress_network_first_and_explicit_dns(self): - plan = _plan(stage_dir=self.stage_dir, supervise=True) - with patch.object(launch.os, "environ", { - "BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9", - }): - argv = launch._sidecar_run_argv( - plan, - "bot-bottle-sidecars-dev-abc", - "bot-bottle-net-dev-abc", - "bot-bottle-egress-dev-abc", - ) - self.assertEqual( - [ - "--network", "bot-bottle-egress-dev-abc", - "--network", "bot-bottle-net-dev-abc", - ], - argv[argv.index("--network"):argv.index("--dns")], - ) - self.assertIn("--dns", argv) - self.assertEqual("9.9.9.9", argv[argv.index("--dns") + 1]) - self.assertIn( - "BOT_BOTTLE_SIDECAR_DAEMONS=egress,supervise", - argv, - ) - self.assertIn("EGRESS_TOKEN_0", argv) - self.assertIn( - f"type=bind,source={self.stage_dir / 'egress-ca'},target=/home/mitmproxy/.mitmproxy", - argv, - ) - self.assertIn( - f"type=bind,source={self.stage_dir},target=/etc/egress,readonly", - argv, - ) - self.assertIn( - "type=bind,source=/state,target=/run/supervise", - argv, - ) - - def test_sidecar_argv_registers_canary_env_as_sensitive(self): - plan = _plan(stage_dir=self.stage_dir, canary=True) - argv = launch._sidecar_run_argv( - plan, - "bot-bottle-sidecars-dev-abc", - "bot-bottle-net-dev-abc", - "bot-bottle-egress-dev-abc", - ) - self.assertIn("CANON_ALPHA_SECRET=fake-canary-value", argv) - self.assertIn("BOT_BOTTLE_SENSITIVE_PREFIXES=CANON_ALPHA_SECRET", argv) - - def test_agent_argv_receives_canary_env(self): - plan = _plan(stage_dir=self.stage_dir, canary=True) - argv = launch._agent_run_argv( - plan, - "bot-bottle-net-dev-abc", - "192.0.2.10", - ) - self.assertIn("CANON_ALPHA_SECRET=fake-canary-value", argv) - - def test_agent_env_points_proxy_at_sidecar_ip(self): - plan = _plan( - stage_dir=self.stage_dir, - agent_git_gate_url="http://192.168.128.2:9420", - agent_supervise_url="http://192.168.128.2:9100/", - ) - env = launch._agent_env_entries(plan, "192.168.128.2") - self.assertIn("HTTPS_PROXY=http://192.168.128.2:9099", env) - self.assertIn("HTTP_PROXY=http://192.168.128.2:9099", env) - self.assertIn("https_proxy=http://192.168.128.2:9099", env) - self.assertIn("http_proxy=http://192.168.128.2:9099", env) - self.assertIn("NO_PROXY=localhost,127.0.0.1,192.168.128.2", env) - self.assertIn("NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/bot-bottle-mitm-ca.crt", env) - self.assertIn("SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt", env) - self.assertIn("REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt", env) - self.assertIn("GIT_GATE_URL=http://192.168.128.2:9420", env) - self.assertIn("MCP_SUPERVISE_URL=http://192.168.128.2:9100/", env) - self.assertIn("LITERAL=value", env) - self.assertIn("OAUTH_TOKEN", env) - self.assertNotIn("CODEX_HOME", env) - - def test_agent_run_uses_internal_network_only(self): - plan = _plan(stage_dir=self.stage_dir) - argv = launch._agent_run_argv( - plan, "bot-bottle-net-dev-abc", "192.168.128.2", - ) - self.assertIn("--network", argv) - self.assertEqual("bot-bottle-net-dev-abc", argv[argv.index("--network") + 1]) - self.assertNotIn("bot-bottle-egress-dev-abc", argv) - self.assertEqual(["bot-bottle-agent:latest", "sleep", "2147483647"], argv[-3:]) - - def test_git_gate_daemons_are_ready_gated(self): - plan = _plan(stage_dir=self.stage_dir, git=True) - self.assertEqual( - ("egress", "git-gate", "git-http"), - launch._sidecar_daemons(plan), - ) - self.assertIn( - "BOT_BOTTLE_GIT_GATE_READY_FILE=/run/git-gate/ready", - launch._sidecar_env_entries(plan), - ) - - def test_stamp_agent_urls_includes_git_http_when_git_gate_exists(self): - plan = _plan(stage_dir=self.stage_dir, git=True, supervise=True) - with patch.object(launch.dataclasses, "replace") as replace: - launch._stamp_agent_urls(plan, "192.168.128.2") - replace.assert_called_once_with( - plan, - agent_proxy_url="http://192.168.128.2:9099", - agent_git_gate_url="http://192.168.128.2:9420", - agent_supervise_url="http://192.168.128.2:9100/", - ) - - def test_macos_plan_uses_http_git_gate_rewrites(self): - base = _plan( - stage_dir=self.stage_dir, - git=True, - agent_git_gate_url="http://192.168.128.2:9420", - ) - plan = MacosContainerBottlePlan( - spec=base.spec, - manifest=base.manifest, - stage_dir=base.stage_dir, - git_gate_plan=base.git_gate_plan, - egress_plan=base.egress_plan, - supervise_plan=base.supervise_plan, - agent_provision=base.agent_provision, - slug=base.slug, - forwarded_env=base.forwarded_env, - agent_git_gate_url=base.agent_git_gate_url, - ) - self.assertEqual( - "192.168.128.2:9420", - plan.git_gate_insteadof_host, - ) - self.assertEqual("http", plan.git_gate_insteadof_scheme) - - def test_stage_git_gate_copies_files_and_releases_ready_marker(self): - plan = _plan(stage_dir=self.stage_dir, git=True) - with ( - patch.object(launch.container_mod, "exec_container") as exec_container, - patch.object(launch.container_mod, "copy_into_container") as copy_in, - ): - launch._stage_git_gate(plan, "sidecar") - - exec_container.assert_any_call( - "sidecar", - [ - "mkdir", - "-p", - "/etc/git-gate", - "/git-gate/creds", - "/git", - "/run/git-gate", - ], - ) - copied = [call.args for call in copy_in.call_args_list] - self.assertIn( - ( - "sidecar", - str(self.stage_dir / "git_gate_entrypoint.sh"), - "/git-gate-entrypoint.sh", - ), - copied, - ) - self.assertIn( - ( - "sidecar", - str(self.stage_dir / "origin-key"), - "/git-gate/creds/origin-key", - ), - copied, - ) - self.assertIn( - ( - "sidecar", - str(self.stage_dir / "origin-known-hosts"), - "/git-gate/creds/origin-known_hosts", - ), - copied, - ) - self.assertIn( - "touch /run/git-gate/ready", - exec_container.call_args_list[-1].args[1][-1], - ) - - -def _build_plan(stage_dir: Path) -> MacosContainerBottlePlan: - return MacosContainerBottlePlan( - spec=cast(BottleSpec, SimpleNamespace()), - manifest=_MANIFEST, - stage_dir=stage_dir, - git_gate_plan=cast(GitGatePlan, SimpleNamespace(upstreams=())), - egress_plan=cast(EgressPlan, SimpleNamespace(canary="")), - supervise_plan=None, - agent_provision=AgentProvisionPlan( - template="claude", - command="claude", - prompt_mode="append_file", - image="bot-bottle-agent:latest", - dockerfile="/repo/Dockerfile", - guest_home="/home/node", - instance_name="bot-bottle-dev-abc", - prompt_file=stage_dir / "prompt.txt", - guest_env={}, - ), - slug="dev-abc", - forwarded_env={}, - ) - - -class TestMacosContainerLaunchCommittedImage(unittest.TestCase): - def setUp(self): - self._tmp = tempfile.TemporaryDirectory() - self.stage_dir = Path(self._tmp.name) - - def tearDown(self): - self._tmp.cleanup() - - def test_build_images_uses_committed_image_when_present(self): - plan = _build_plan(self.stage_dir) - calls = [] - - def fake_build(image: str, context: str, *, dockerfile: str = "") -> None: - calls.append((image, context, dockerfile)) - - with patch.object( - launch, "read_committed_image", - return_value="bot-bottle-committed-dev-abc:latest", - ), patch.object( - launch.container_mod, "image_exists", return_value=True, - ), patch.object( - launch.container_mod, "build_image", side_effect=fake_build, - ), patch.object(launch, "info"): - updated = launch._build_images(plan) - - self.assertEqual("bot-bottle-committed-dev-abc:latest", updated.image) - self.assertEqual(1, len(calls)) - self.assertEqual(launch.SIDECAR_BUNDLE_IMAGE, calls[0][0]) - - def test_build_images_builds_agent_when_committed_image_missing(self): - plan = _build_plan(self.stage_dir) - calls = [] - - def fake_build(image: str, context: str, *, dockerfile: str = "") -> None: - calls.append((image, context, dockerfile)) - - with patch.object( - launch, "read_committed_image", - return_value="bot-bottle-committed-dev-abc:latest", - ), patch.object( - launch.container_mod, "image_exists", return_value=False, - ), patch.object( - launch.container_mod, "build_image", side_effect=fake_build, - ): - updated = launch._build_images(plan) - - self.assertEqual("bot-bottle-agent:latest", updated.image) - self.assertEqual(2, len(calls)) - self.assertEqual("bot-bottle-agent:latest", calls[1][0]) - - -if __name__ == "__main__": - unittest.main() -- 2.52.0 From 09393b354ba165707559e8faeb1aaa13f63d1e35 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 14 Jul 2026 17:07:56 -0400 Subject: [PATCH 4/5] refactor(de-sidecar): purge the "sidecar" name from live code, tests, and current docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the de-sidecar cleanup: no live code, test, current doc, script, or nix file mentions or is named "sidecar" any more. Only the dated PRD/research docs keep the term as historical record (agreed on the #385 thread). - Rename `sidecar_init.py`→`gateway_init.py` was done earlier; this pass sweeps the remaining descriptive uses: the egress / git-gate / supervise components are the gateway's *daemons*, the shared container is the *gateway*, the old per-bottle container was the *companion container*. - Rename `tests/integration/test_sidecar_bundle_image.py`→`test_gateway_image.py` and its class; update `docs/ci.md` + `tests/README.md` for the renamed/ removed integration tests. - `SIDECAR_PORTS` shell var in `scripts/firecracker-netpool.sh`→`GATEWAY_PORTS`. Full unit suite green (bar the pre-existing `/bin/sleep`-missing env errors in test_gateway_init); docker integration — gateway singleton, broker, real two-bottle multitenant isolation, and the gateway-image build — all pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- AGENTS.md | 6 ++-- README.md | 18 ++++++------ bot_bottle/agent_provider.py | 4 +-- bot_bottle/backend/__init__.py | 10 +++---- bot_bottle/backend/docker/backend.py | 2 +- .../backend/docker/consolidated_compose.py | 4 +-- .../backend/docker/consolidated_launch.py | 2 +- bot_bottle/backend/docker/egress.py | 2 +- bot_bottle/backend/docker/egress_apply.py | 2 +- bot_bottle/backend/docker/git_gate.py | 2 +- bot_bottle/backend/docker/launch.py | 2 +- bot_bottle/backend/docker/network.py | 2 +- bot_bottle/backend/docker/setup.py | 6 ++-- bot_bottle/backend/firecracker/bottle.py | 2 +- bot_bottle/backend/firecracker/bottle_plan.py | 4 +-- bot_bottle/backend/firecracker/enumerate.py | 2 +- bot_bottle/backend/firecracker/launch.py | 2 +- bot_bottle/backend/firecracker/netpool.py | 4 +-- .../backend/macos_container/__init__.py | 2 +- .../backend/macos_container/egress_apply.py | 2 +- .../backend/macos_container/enumerate.py | 2 +- bot_bottle/backend/macos_container/launch.py | 2 +- bot_bottle/backend/resolve_common.py | 2 +- bot_bottle/bottle_state.py | 18 ++++++------ bot_bottle/cli/cleanup.py | 4 +-- bot_bottle/contrib/claude/agent_provider.py | 4 +-- bot_bottle/contrib/codex/agent_provider.py | 4 +-- bot_bottle/dlp_detectors.py | 2 +- bot_bottle/egress.py | 6 ++-- bot_bottle/egress_addon.py | 2 +- bot_bottle/egress_addon_core.py | 2 +- bot_bottle/egress_dlp_config.py | 2 +- bot_bottle/egress_entrypoint.sh | 6 ++-- bot_bottle/git_gate.py | 8 +++--- bot_bottle/git_gate_render.py | 6 ++-- bot_bottle/git_http_backend.py | 6 ++-- bot_bottle/manifest_agent.py | 4 +-- bot_bottle/manifest_bottle.py | 8 +++--- bot_bottle/orchestrator/__main__.py | 2 +- bot_bottle/orchestrator/docker_broker.py | 4 +-- bot_bottle/orchestrator/gateway.py | 4 +-- bot_bottle/orchestrator/registration.py | 2 +- bot_bottle/paths.py | 4 +-- bot_bottle/policy_resolver.py | 2 +- bot_bottle/queue_store.py | 2 +- bot_bottle/supervise.py | 28 +++++++++---------- bot_bottle/supervise_server.py | 2 +- bot_bottle/yaml_subset.py | 2 +- docs/ci.md | 3 +- docs/demo.tape | 4 +-- nix/firecracker-netpool.nix | 4 +-- scripts/firecracker-netpool.sh | 14 +++++----- tests/README.md | 10 ++----- ..._bundle_image.py => test_gateway_image.py} | 10 +++---- .../test_orchestrator_docker_gateway_build.py | 2 +- tests/integration/test_orphan_cleanup.py | 2 +- tests/integration/test_sandbox_escape.py | 10 +++---- tests/unit/__init__.py | 2 +- tests/unit/test_consolidated_compose.py | 6 ++-- tests/unit/test_egress.py | 2 +- tests/unit/test_egress_addon_core.py | 6 ++-- tests/unit/test_egress_addon_log_redaction.py | 4 +-- tests/unit/test_egress_addon_request_flow.py | 10 +++---- tests/unit/test_egress_apply.py | 2 +- tests/unit/test_git_gate.py | 4 +-- tests/unit/test_git_http_backend.py | 2 +- tests/unit/test_macos_container_cleanup.py | 6 ++-- tests/unit/test_macos_container_util.py | 2 +- tests/unit/test_orchestrator_registration.py | 2 +- tests/unit/test_provision_git.py | 2 +- tests/unit/test_supervise_server.py | 2 +- 71 files changed, 163 insertions(+), 168 deletions(-) rename tests/integration/{test_sidecar_bundle_image.py => test_gateway_image.py} (92%) diff --git a/AGENTS.md b/AGENTS.md index 6920191..a427128 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,10 +8,10 @@ broad permissions inside a sandbox, so a misbehaving agent cannot reach the host. A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates the runtime lifecycle and the copying of skills and env vars into it. The default backend on compatible macOS hosts is macos-container: -agents and sidecar bundles run through Apple's `container` CLI without +agents and gateways run through Apple's `container` CLI without requiring Docker. On KVM-capable Linux hosts the default is firecracker: agents run in a Firecracker microVM reached over SSH on a point-to-point -TAP, while the sidecar bundle still uses Docker. The legacy Docker +TAP, while the gateway still uses Docker. The legacy Docker backend remains available with `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`. @@ -59,7 +59,7 @@ backend remains available with `BOT_BOTTLE_BACKEND=docker` or in a PRD, research note, or decision record. - Low dependencies by default. The project is Python, stdlib-first (no runtime pip dependencies in the package itself; the only language - runtime is the Python 3.13 used by the CLI + sidecars). Ask before + runtime is the Python 3.13 used by the CLI + companion containers). Ask before adding new tools, runtimes, or package managers. - Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/): `[(scope)][!]: `, where `` is one of `feat`, `fix`, diff --git a/README.md b/README.md index edf2098..abc995f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ - **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist; per-route path/method/header `matches` filtering; outbound DLP scanning for known tokens and secrets, inbound DLP scanning for prompt-injection attempts; DoH and arbitrary hosts blocked by default. - **Per-route token-match policy** — each egress route picks what happens when the outbound DLP catches a token via `dlp.outbound_on_match`: `supervise` (default) holds the request and surfaces it in `./cli.py supervise` for approval (an approved value is remembered for the life of the proxy); `redact` scrubs the value and forwards; `block` is a hard `403`. Cuts false-positive friction without weakening default-deny. -- **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only. +- **Tokens the agent never sees** — host secrets live in a gateway; the agent dials `http://gateway:9099/` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only. - **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential. - **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load. - **Trust boundary at `$HOME`** — bottles (credentials, egress, remotes) live only under `~/.bot-bottle/bottles/`. Repos may ship agents but not bottles, so a cloned repo can't redirect an env var to an attacker host. @@ -24,17 +24,17 @@ - **Parallel, isolated bottles** — each bottle runs in its own backend-owned isolation boundary; bottles don't share state or talk to each other. - **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding. - **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required. -- **Apple Container backend (macOS default when available)** — runs the agent and sidecar bundle with Apple's `container` CLI, using a host-only agent network plus a separate sidecar egress network. -- **Firecracker backend (Linux default when available)** — runs the agent in a KVM Firecracker microVM reached over SSH on a point-to-point TAP, with the sidecar bundle in Docker. A dedicated, fail-closed `nftables` table isolates the guest, closing the raw DNS/IP exfiltration gap that exists in the legacy Docker backend. Requires KVM (`/dev/kvm`) and a one-time privileged network-pool setup. +- **Apple Container backend (macOS default when available)** — runs the agent and gateway with Apple's `container` CLI, using a host-only agent network plus a separate gateway egress network. +- **Firecracker backend (Linux default when available)** — runs the agent in a KVM Firecracker microVM reached over SSH on a point-to-point TAP, with the gateway in Docker. A dedicated, fail-closed `nftables` table isolates the guest, closing the raw DNS/IP exfiltration gap that exists in the legacy Docker backend. Requires KVM (`/dev/kvm`) and a one-time privileged network-pool setup. - **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container or KVM via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`. ## Architecture -On the default macOS Apple Container backend, a bottle is an agent container on a host-only internal network plus a sidecar bundle attached to both that internal network and a NAT egress network. The agent gets HTTP(S)_PROXY and CA bundle env vars pointing at the sidecar's internal-network IP, so HTTP/HTTPS traffic flows through the sidecar instead of direct egress. `bottle.git` / git-gate is intentionally deferred on this backend until a safe Apple Container key-delivery path exists. +On the default macOS Apple Container backend, a bottle is an agent container on a host-only internal network plus a gateway attached to both that internal network and a NAT egress network. The agent gets HTTP(S)_PROXY and CA bundle env vars pointing at the gateway's internal-network IP, so HTTP/HTTPS traffic flows through the gateway instead of direct egress. `bottle.git` / git-gate is intentionally deferred on this backend until a safe Apple Container key-delivery path exists. -On the Firecracker backend, a bottle is an agent microVM plus a Docker sidecar bundle for egress, git-gate, and supervise. The VM reaches the sidecars over a per-bottle point-to-point TAP link; a dedicated fail-closed `nftables` table (`inet bot_bottle_fc`) confines the guest to that link, so nothing leaves the box except through the sidecars. The TAP pool and nft table are provisioned once (root); per-launch needs no privilege. +On the Firecracker backend, a bottle is an agent microVM plus a Docker gateway for egress, git-gate, and supervise. The VM reaches the gateway over a per-bottle point-to-point TAP link; a dedicated fail-closed `nftables` table (`inet bot_bottle_fc`) confines the guest to that link, so nothing leaves the box except through the gateway. The TAP pool and nft table are provisioned once (root); per-launch needs no privilege. -On the legacy Docker backend, the same logical bottle is two containers per agent: an `agent` container and a `sidecars` container. They share a per-agent Docker `--internal` network; the agent has no default route off-box. +On the legacy Docker backend, the same logical bottle is two containers per agent: an `agent` container and a `companion containers` container. They share a per-agent Docker `--internal` network; the agent has no default route off-box. The Docker topology looks like this: @@ -67,11 +67,11 @@ The Docker topology looks like this: └─────────────────────────────────────────────────────────────────────┘ ``` -When the agent exits, `cli.py` tears down every sidecar and both networks; nothing about a bottle persists between runs. +When the agent exits, `cli.py` tears down every gateway and both networks; nothing about a bottle persists between runs. ## Quickstart -On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The Firecracker backend (Linux) requires Docker on the host for the sidecar bundle plus the `firecracker` binary and KVM. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`. +On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The Firecracker backend (Linux) requires Docker on the host for the gateway plus the `firecracker` binary and KVM. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`. Use `BOT_BOTTLE_BACKEND=docker ./cli.py start ` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend. @@ -81,7 +81,7 @@ On Linux, a KVM-capable host defaults to the Firecracker backend. It needs: - **`/dev/kvm`** present and accessible. Load `kvm-intel` or `kvm-amd` (and enable virtualization in BIOS/firmware). The invoking user must be in the `kvm` group: `sudo usermod -aG kvm "$USER"` then re-login. bot-bottle preflights this and reports exactly what's missing. - **`firecracker`** on `PATH`: grab a release from . Start flows print this pointer when the binary is missing. -- **Docker** for the sidecar bundle and image build. +- **Docker** for the gateway and image build. - **A one-time privileged network setup** — the per-bottle TAP pool plus the fail-closed `nftables` isolation table. Run `./cli.py backend setup --backend=firecracker` for the host-appropriate config (a NixOS module, a `sudo` script elsewhere); `./cli.py backend status --backend=firecracker` reports what's present, including whether the pool range collides with an existing route. The pool defaults to `10.243.0.0/16` (an obscure RFC-1918 block that dodges docker/libvirt/LAN and, deliberately, Tailscale's `100.64.0.0/10` CGNAT range); override with `BOT_BOTTLE_FC_IP_BASE` if it clashes on your host. ```sh diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index 398f239..4f26726 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -204,9 +204,9 @@ class AgentProvider(ABC): bottle: "Bottle", supervise_url: str, ) -> None: - """Register the per-bottle supervise sidecar as an MCP server + """Register the per-bottle supervise daemon as an MCP server in the provider's in-guest config. Called by the backend after - the supervise sidecar is reachable. No-op when + the supervise daemon is reachable. No-op when `plan.supervise_plan is None`.""" @abstractmethod diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index b00b3c6..1efff36 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -199,7 +199,7 @@ class ActiveAgent: bottle is the container, the agent is what runs in it.) Fields are deliberately backend-neutral. `services` is the set - of sidecar daemons currently up for this bottle (`egress`, + of gateway daemons currently up for this bottle (`egress`, `git-gate`, `supervise`); the dashboard uses it to gate edit verbs. `backend_name` is the matching key in `_BACKENDS` (`docker` / `firecracker` / `macos-container`) — used by the active- @@ -251,7 +251,7 @@ class Bottle(ABC): `user` (default `node`, matching the agent image's USER directive) and return the captured stdout/stderr/returncode. The bottle's environment (including HTTPS_PROXY pointing at - the egress sidecar) is inherited by the child. Non-zero + the egress daemon) is inherited by the child. Non-zero exit does not raise — callers inspect `returncode` themselves. @@ -454,7 +454,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): declarative provision-plan apply, supervise MCP registration) live on the `AgentProvider` plugin. The backend only owns the steps that are about backend infrastructure (CA, workspace, - git) and surfaces the supervise sidecar URL its launch step + git) and surfaces the supervise daemon URL its launch step knows about via `supervise_mcp_url`. PRD 0017: cred-proxy's agent-side dotfile rewrites (~/.npmrc, @@ -502,7 +502,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): def supervise_mcp_url(self, plan: PlanT) -> str: """Return the agent-side URL of the per-bottle supervise - sidecar, or "" when this bottle has no sidecar. The provider + gateway, or "" when this bottle has no gateway. The provider plugin's `provision_supervise_mcp` uses it to register the MCP entry inside the guest. @@ -524,7 +524,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): def enumerate_active(self) -> Sequence[ActiveAgent]: """Return every currently-running agent on this backend. Empty when none. Backend-specific: docker queries `docker - compose ls`; firecracker cross-references its running sidecar + compose ls`; firecracker cross-references its running gateway containers against per-bottle metadata.""" @classmethod diff --git a/bot_bottle/backend/docker/backend.py b/bot_bottle/backend/docker/backend.py index d9da165..abff088 100644 --- a/bot_bottle/backend/docker/backend.py +++ b/bot_bottle/backend/docker/backend.py @@ -106,7 +106,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup yield bottle def supervise_mcp_url(self, plan: DockerBottlePlan) -> str: - """Docker bottles reach the supervise sidecar via the + """Docker bottles reach the supervise daemon via the compose-network alias `supervise:9100`. No per-bottle URL plumbing needed; the alias resolves inside the bridge.""" if plan.supervise_plan is None: diff --git a/bot_bottle/backend/docker/consolidated_compose.py b/bot_bottle/backend/docker/consolidated_compose.py index 72ab584..596c351 100644 --- a/bot_bottle/backend/docker/consolidated_compose.py +++ b/bot_bottle/backend/docker/consolidated_compose.py @@ -1,8 +1,8 @@ """Agent-only compose for the consolidated docker backend (PRD 0070). The per-bottle model rendered a compose project with the agent *and* a -sidecar bundle on two per-bottle networks. In the consolidated model the -sidecars are gone — one shared gateway serves every bottle — so this renders +gateway on two per-bottle networks. In the consolidated model the +per-bottle companion containers are gone — one shared gateway serves every bottle — so this renders just the agent, attached to the **external shared gateway network** with the pinned source IP the orchestrator allocated, and pointed at the gateway's address for egress (and, around the proxy, for git-http / supervise). diff --git a/bot_bottle/backend/docker/consolidated_launch.py b/bot_bottle/backend/docker/consolidated_launch.py index ab70dcc..4ea31c8 100644 --- a/bot_bottle/backend/docker/consolidated_launch.py +++ b/bot_bottle/backend/docker/consolidated_launch.py @@ -1,7 +1,7 @@ """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: +replaces the per-bottle gateway: 1. ensure the orchestrator control plane + shared gateway are up; 2. allocate the bottle a pinned source IP on the gateway network (the diff --git a/bot_bottle/backend/docker/egress.py b/bot_bottle/backend/docker/egress.py index 080e2c6..891fdf2 100644 --- a/bot_bottle/backend/docker/egress.py +++ b/bot_bottle/backend/docker/egress.py @@ -4,7 +4,7 @@ prepare-time routes-yaml rendering itself lives on the platform-neutral `Egress` ABC — backends instantiate it directly. The per-container `.start()` / `.stop()` lifecycle was removed in -PRD 0024 chunk 3; the sidecar bundle (PRD 0024) runs egress +PRD 0024 chunk 3; the gateway (PRD 0024) runs egress under its python init supervisor.""" from __future__ import annotations diff --git a/bot_bottle/backend/docker/egress_apply.py b/bot_bottle/backend/docker/egress_apply.py index d65df11..5d8b938 100644 --- a/bot_bottle/backend/docker/egress_apply.py +++ b/bot_bottle/backend/docker/egress_apply.py @@ -1,7 +1,7 @@ """Host-side egress route-apply for the docker backend. The per-bottle companion container this used to signal (`docker kill ---signal HUP `) was removed in the de-sidecar cleanup (#385). +--signal HUP `) was removed in the companion-container removal (#385). In the consolidated model the shared gateway resolves egress policy per-request against the orchestrator rather than reloading a per-bottle routes file, so the live per-bottle reload is not supported here and diff --git a/bot_bottle/backend/docker/git_gate.py b/bot_bottle/backend/docker/git_gate.py index 19e397b..b338f55 100644 --- a/bot_bottle/backend/docker/git_gate.py +++ b/bot_bottle/backend/docker/git_gate.py @@ -2,7 +2,7 @@ bind-mounts target + the listening port. The prepare-time entrypoint / hook render lives on the platform-neutral `GitGate` ABC — backends instantiate it directly. The git-gate daemon's container lifecycle -is owned by the sidecar bundle (PRD 0024).""" +is owned by the gateway (PRD 0024).""" from __future__ import annotations diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index c1a75c5..def5036 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -5,7 +5,7 @@ PRD 0018 chunk 3: each instance is one `docker compose` project. The flow is: 1. Build the agent image from the provider Dockerfile (compose - builds the sidecar images via the `build:` directive on first up). + builds the gateway image on first up). 2. Mint the per-bottle egress CA (chunk 2 writes it under state//egress/). 3. Populate the inner plans with launch-time fields so the diff --git a/bot_bottle/backend/docker/network.py b/bot_bottle/backend/docker/network.py index 6ec13f9..66247a1 100644 --- a/bot_bottle/backend/docker/network.py +++ b/bot_bottle/backend/docker/network.py @@ -76,7 +76,7 @@ def network_create_internal(slug: str) -> str: def network_create_egress(slug: str) -> str: """Create a per-agent user-defined bridge (NOT the legacy `bridge`) - so the egress sidecar has working DNS for upstream hostnames.""" + so the egress daemon has working DNS for upstream hostnames.""" return _network_create_with_prefix(network_egress_name_for_slug(slug), internal=False) diff --git a/bot_bottle/backend/docker/setup.py b/bot_bottle/backend/docker/setup.py index c666159..577d16d 100644 --- a/bot_bottle/backend/docker/setup.py +++ b/bot_bottle/backend/docker/setup.py @@ -1,7 +1,7 @@ """Host setup + status for the Docker backend. Unlike Firecracker, the Docker backend needs no privileged one-time -host provisioning (no TAP pool / nft table) — networks and the sidecar +host provisioning (no TAP pool / nft table) — networks and the gateway bundle are created per-launch. So `setup()` is mostly an install/daemon pointer, and `status()` reports whether docker is usable. @@ -45,7 +45,7 @@ def setup() -> int: return 1 sys.stderr.write( "Docker backend: no privileged host setup required — networks and " - "the sidecar bundle are created per-launch.\n" + "the gateway are created per-launch.\n" ) if not _daemon_reachable(): sys.stderr.write( @@ -64,7 +64,7 @@ def setup() -> int: def teardown() -> int: sys.stderr.write( "Docker backend: nothing to undo — it provisions no privileged host " - "state (networks and the sidecar bundle are per-launch and are " + "state (networks and the gateway are per-launch and are " "removed by `./cli.py cleanup`). Docker itself is left installed.\n" ) return 0 diff --git a/bot_bottle/backend/firecracker/bottle.py b/bot_bottle/backend/firecracker/bottle.py index eef9132..b8bee3f 100644 --- a/bot_bottle/backend/firecracker/bottle.py +++ b/bot_bottle/backend/firecracker/bottle.py @@ -7,7 +7,7 @@ session. `ssh -t` forwards the host terminal's SIGWINCH to the remote PTY natively, so no separate resize bridge is needed. Commands run as the image's `node` user via `runuser`, with HOME/USER/ -PATH and the bottle env (HTTPS_PROXY at the sidecar, CA paths, …) set +PATH and the bottle env (HTTPS_PROXY at the gateway, CA paths, …) set per-invocation through `env` (the VM itself just runs the init; it has no baked-in process env like a `docker run` container would). """ diff --git a/bot_bottle/backend/firecracker/bottle_plan.py b/bot_bottle/backend/firecracker/bottle_plan.py index 91decae..9631957 100644 --- a/bot_bottle/backend/firecracker/bottle_plan.py +++ b/bot_bottle/backend/firecracker/bottle_plan.py @@ -13,7 +13,7 @@ from .. import BottlePlan class FirecrackerBottlePlan(BottlePlan): slug: str forwarded_env: dict[str, str] = field(repr=False) - # Stamped by launch once the sidecar is up and its ports are + # Stamped by launch once the gateway is up and its ports are # published on the host-side TAP IP (empty at prepare time). agent_proxy_url: str = "" agent_git_gate_url: str = "" @@ -21,7 +21,7 @@ class FirecrackerBottlePlan(BottlePlan): @property def container_name(self) -> str: - """Instance name, reused for the sidecar container + VM run dir. + """Instance name, reused for the gateway container + VM run dir. Matches the `bot-bottle-` convention the other backends use so cleanup/enumerate discovery-by-prefix keeps working.""" return self.agent_provision.instance_name diff --git a/bot_bottle/backend/firecracker/enumerate.py b/bot_bottle/backend/firecracker/enumerate.py index d8eecf1..7d96a9a 100644 --- a/bot_bottle/backend/firecracker/enumerate.py +++ b/bot_bottle/backend/firecracker/enumerate.py @@ -1,6 +1,6 @@ """Active-agent enumeration for the Firecracker backend. -The backend is disabled during the de-sidecar cleanup (#385) — it can't +The backend is disabled during the companion-container removal (#385) — it can't launch bottles, so there are none to enumerate. Real enumeration returns with the backend's consolidated relaunch (#354). """ diff --git a/bot_bottle/backend/firecracker/launch.py b/bot_bottle/backend/firecracker/launch.py index a5e6347..438a5eb 100644 --- a/bot_bottle/backend/firecracker/launch.py +++ b/bot_bottle/backend/firecracker/launch.py @@ -2,7 +2,7 @@ The firecracker backend launched a per-bottle companion container (the egress / git-gate / supervise data plane) alongside each microVM. That -per-bottle-companion architecture was removed in the de-sidecar cleanup; +per-bottle-companion architecture was removed in the companion-container removal; firecracker's replacement — the consolidated per-host gateway — lands in its own cutover (#354). diff --git a/bot_bottle/backend/firecracker/netpool.py b/bot_bottle/backend/firecracker/netpool.py index 9de0b09..faad11f 100644 --- a/bot_bottle/backend/firecracker/netpool.py +++ b/bot_bottle/backend/firecracker/netpool.py @@ -18,7 +18,7 @@ Topology (per slot i): * a /31 host<->guest link: host = base + 2i (the gateway the VM routes through), guest = base + 2i + 1 (the VM's address). * isolation via ``table inet bot_bottle_fc``: a VM reaches only its - own sidecar (DNAT'd from the host TAP IP) and nothing else. + own gateway (DNAT'd from the host TAP IP) and nothing else. The default IP block is ``10.243.0.0/16`` — an intentionally obscure corner of RFC-1918 private space. RFC-1918 is the range *designated* @@ -88,7 +88,7 @@ def ip_base() -> str: return _cfg("BOT_BOTTLE_FC_IP_BASE") -# Sidecar ports the VM reaches at its host-side TAP IP. Kept in sync +# Gateway ports the VM reaches at its host-side TAP IP. Kept in sync # with the backend constants (egress 9099, supervise 9100, git-http # 9420); rendered into the setup output for operator visibility. GATEWAY_PORTS = (9099, 9100, 9420) diff --git a/bot_bottle/backend/macos_container/__init__.py b/bot_bottle/backend/macos_container/__init__.py index 8a7e222..6d56f95 100644 --- a/bot_bottle/backend/macos_container/__init__.py +++ b/bot_bottle/backend/macos_container/__init__.py @@ -2,7 +2,7 @@ Selectable via `BOT_BOTTLE_BACKEND=macos-container`. This package owns the Apple `container` CLI integration; launch remains gated until the -sidecar network enforcement shape is implemented. +gateway network enforcement shape is implemented. """ from .backend import MacosContainerBottleBackend diff --git a/bot_bottle/backend/macos_container/egress_apply.py b/bot_bottle/backend/macos_container/egress_apply.py index c9b6eda..c7e2cbb 100644 --- a/bot_bottle/backend/macos_container/egress_apply.py +++ b/bot_bottle/backend/macos_container/egress_apply.py @@ -1,7 +1,7 @@ """Host-side egress route-apply for the macos-container backend. The per-bottle companion container this used to signal (`container kill ---signal HUP `) was removed in the de-sidecar cleanup (#385), +--signal HUP `) was removed in the companion-container removal (#385), along with the disabled macOS launch path. Fails closed until the macOS backend grows the consolidated gateway. """ diff --git a/bot_bottle/backend/macos_container/enumerate.py b/bot_bottle/backend/macos_container/enumerate.py index 27f8b8d..7523ed5 100644 --- a/bot_bottle/backend/macos_container/enumerate.py +++ b/bot_bottle/backend/macos_container/enumerate.py @@ -1,6 +1,6 @@ """Active-agent enumeration for the macOS Apple Container backend. -The backend is disabled during the de-sidecar cleanup (#385) — it can't +The backend is disabled during the companion-container removal (#385) — it can't launch bottles, so there are none to enumerate. Enumeration returns when the backend grows the consolidated gateway. """ diff --git a/bot_bottle/backend/macos_container/launch.py b/bot_bottle/backend/macos_container/launch.py index d6141f3..31a9d93 100644 --- a/bot_bottle/backend/macos_container/launch.py +++ b/bot_bottle/backend/macos_container/launch.py @@ -3,7 +3,7 @@ This backend launched a per-bottle companion container (the egress / git-gate / supervise data plane) alongside the agent container, with the agent's proxy env pointed at the companion's host-only IP. That -per-bottle-companion architecture was removed in the de-sidecar cleanup; +per-bottle-companion architecture was removed in the companion-container removal; the macOS backend will be re-enabled once it grows the consolidated per-host gateway the docker backend already uses. diff --git a/bot_bottle/backend/resolve_common.py b/bot_bottle/backend/resolve_common.py index 7270d37..9f55a61 100644 --- a/bot_bottle/backend/resolve_common.py +++ b/bot_bottle/backend/resolve_common.py @@ -94,7 +94,7 @@ def prepare_egress( def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None: - """Prepare the supervise sidecar state dir. Returns None when + """Prepare the supervise daemon state dir. Returns None when bottle.supervise is falsy.""" if not bottle.supervise: return None diff --git a/bot_bottle/bottle_state.py b/bot_bottle/bottle_state.py index 1ac5bb0..f611fd1 100644 --- a/bot_bottle/bottle_state.py +++ b/bot_bottle/bottle_state.py @@ -44,16 +44,16 @@ _STATE_SUBDIR = "state" _PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile" _COMMITTED_IMAGE_NAME = "committed-image" _TRANSCRIPT_SUBDIR = "transcript" -# Per-sidecar scratch subdirs. PRD 0018 chunk 2: bind-mount sources +# Per-daemon scratch subdirs. PRD 0018 chunk 2: bind-mount sources # live here so chunk 3's `docker compose up` can find them at stable -# paths. Each sidecar's `prepare()` writes config + CAs into its own +# paths. Each daemon's `prepare()` writes config + CAs into its own # subdir; the launch step is unchanged today (still `docker cp`). _EGRESS_SUBDIR = "egress" _GIT_GATE_SUBDIR = "git-gate" _SUPERVISE_SUBDIR = "supervise" _AGENT_SUBDIR = "agent" _METADATA_NAME = "metadata.json" -# Live-config dir bind-mounted into the supervise sidecar (read-only). +# Live-config dir bind-mounted into the supervise daemon (read-only). # Host's apply paths keep these files fresh so supervise's # `list-egress-routes` MCP tool returns the current state — # not a snapshot from launch time. @@ -222,7 +222,7 @@ def per_bottle_image_tag(identity: str) -> str: def live_config_dir(identity: str) -> Path: """Per-bottle live-config dir. Bind-mounted read-only into the - supervise sidecar; the host's apply paths refresh the files on + supervise daemon; the host's apply paths refresh the files on every operator approval so the agent's `list-*` MCP tools always return current state.""" return bottle_state_dir(identity) / _LIVE_CONFIG_SUBDIR @@ -260,9 +260,9 @@ def transcript_snapshot_dir(identity: str) -> Path: return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR -# --- Per-sidecar scratch subdirs (PRD 0018 chunk 2) ------------------------ +# --- Per-daemon scratch subdirs (PRD 0018 chunk 2) ------------------------ # -# Each sidecar gets its own subdir under the bottle's state dir for +# Each daemon gets its own subdir under the bottle's state dir for # bind-mount sources (config, CAs, hooks, etc.). Prepare-time writes # land here; the state dir's normal cleanup (`cleanup_state`) reaps # them along with everything else when the bottle session ends and @@ -270,20 +270,20 @@ def transcript_snapshot_dir(identity: str) -> Path: def egress_state_dir(identity: str) -> Path: - """State subdir for the egress sidecar: routes.yaml + the + """State subdir for the egress daemon: routes.yaml + the per-bottle mitmproxy CA. Bind-mount source from chunk 3 onward.""" return bottle_state_dir(identity) / _EGRESS_SUBDIR def git_gate_state_dir(identity: str) -> Path: - """State subdir for the git-gate sidecar: entrypoint + hooks + + """State subdir for the git-gate daemon: entrypoint + hooks + per-upstream known_hosts. Bind-mount source from chunk 3 onward.""" return bottle_state_dir(identity) / _GIT_GATE_SUBDIR def supervise_state_dir(identity: str) -> Path: - """State subdir reserved for supervise sidecar bind-mount sources. + """State subdir reserved for supervise daemon bind-mount sources. Runtime queue/audit rows live in the host-level bot-bottle SQLite database, so they survive state-dir cleanup.""" return bottle_state_dir(identity) / _SUPERVISE_SUBDIR diff --git a/bot_bottle/cli/cleanup.py b/bot_bottle/cli/cleanup.py index 94d263c..bbed373 100644 --- a/bot_bottle/cli/cleanup.py +++ b/bot_bottle/cli/cleanup.py @@ -2,8 +2,8 @@ Walks every registered backend (docker, firecracker, macos-container) so a single `./cli.py cleanup` reaps every backend's leftovers — a -firecracker bottle's sidecars won't survive a docker-only cleanup pass -(issue addressed alongside #77). +firecracker bottle's VM processes and run dirs won't survive a +docker-only cleanup pass (issue addressed alongside #77). Each backend's `prepare_cleanup` enumerates its own resources; docker's `_list_orphan_state_dirs` consults diff --git a/bot_bottle/contrib/claude/agent_provider.py b/bot_bottle/contrib/claude/agent_provider.py index 204357b..c6c6943 100644 --- a/bot_bottle/contrib/claude/agent_provider.py +++ b/bot_bottle/contrib/claude/agent_provider.py @@ -4,7 +4,7 @@ The Claude-specific behavior previously inlined under `agent_provider.agent_provision_plan` (claude.json trust marker, api.anthropic.com egress route, OAuth-token placeholder), plus the `claude mcp add` invocation that registers the supervise -sidecar in claude-code's user config (PRD 0013).""" +gateway in claude-code's user config (PRD 0013).""" from __future__ import annotations @@ -293,7 +293,7 @@ class ClaudeAgentProvider(AgentProvider): supervise_url: str, ) -> None: """Run `claude mcp add` inside the agent guest to register the - supervise sidecar in claude-code's user config (~/.claude.json). + supervise daemon in claude-code's user config (~/.claude.json). Failure is logged but not fatal — the bottle still works without the entry; the operator can register it manually.""" diff --git a/bot_bottle/contrib/codex/agent_provider.py b/bot_bottle/contrib/codex/agent_provider.py index 0a681a5..eb09977 100644 --- a/bot_bottle/contrib/codex/agent_provider.py +++ b/bot_bottle/contrib/codex/agent_provider.py @@ -4,7 +4,7 @@ The Codex-specific behavior previously inlined under `agent_provider.agent_provision_plan` (config.toml trust marker, chatgpt.com / api.openai.com egress routes, optional host-credential forwarding with dummy-auth.json + verify), plus the `codex mcp add` -invocation that registers the supervise sidecar in Codex's +invocation that registers the supervise daemon in Codex's ~/.codex/config.toml (PRD 0050).""" from __future__ import annotations @@ -266,7 +266,7 @@ class CodexAgentProvider(AgentProvider): supervise_url: str, ) -> None: """Run `codex mcp add` inside the agent guest to register the - supervise sidecar in Codex's user config (~/.codex/config.toml). + supervise daemon in Codex's user config (~/.codex/config.toml). Mirrors the Claude provider's `claude mcp add` flow — failure is logged but not fatal.""" diff --git a/bot_bottle/dlp_detectors.py b/bot_bottle/dlp_detectors.py index 13b93aa..346a147 100644 --- a/bot_bottle/dlp_detectors.py +++ b/bot_bottle/dlp_detectors.py @@ -3,7 +3,7 @@ Pure Python, no mitmproxy dependency. Each detector is a module-level function returning `ScanResult | None`. -Ships flat into the sidecar bundle image alongside +Ships flat into the gateway image alongside `egress_addon_core.py` — both this file and the package source use the same try/except import shim pattern. """ diff --git a/bot_bottle/egress.py b/bot_bottle/egress.py index d3e4436..f700dc9 100644 --- a/bot_bottle/egress.py +++ b/bot_bottle/egress.py @@ -2,7 +2,7 @@ This module defines the abstract proxy (`Egress`), its plan dataclass (`EgressPlan`), and the resolved per-route shape -(`EgressRoute`). The sidecar's start/stop lifecycle is backend- +(`EgressRoute`). The gateway's start/stop lifecycle is backend- specific and lives on concrete subclasses (see `bot_bottle/backend/docker/egress.py`). """ @@ -87,7 +87,7 @@ class EgressRoute(Route): Inherits `host`, `matches`, `auth_scheme`, and `token_env` from `egress_addon_core.Route` — those are the fields that cross the - YAML wire into the sidecar. The fields below are host-only and + YAML wire into the gateway. The fields below are host-only and are never serialised to the addon. `token_ref` is the host env var the CLI reads at launch and forwards @@ -386,7 +386,7 @@ class Egress(ABC): routes_path.write_text(egress_render_routes(routes, log=log)) routes_path.chmod(0o600) # Generate a per-session fake secret under a plausible random env name. - # The sidecar marks that exact env name as sensitive for known-secret + # The gateway marks that exact env name as sensitive for known-secret # scanning; the agent receives the same name/value as exfil bait. canary = secrets.token_urlsafe(32) return EgressPlan( diff --git a/bot_bottle/egress_addon.py b/bot_bottle/egress_addon.py index 4516d0c..174f740 100644 --- a/bot_bottle/egress_addon.py +++ b/bot_bottle/egress_addon.py @@ -240,7 +240,7 @@ class EgressAddon: tokens, resolved by source IP in one round-trip (fail-closed to deny-all + empty slug if unattributed); `env` is the process env overlaid with the bottle's tokens, so upstream-auth injection (and DLP) use *this* - bottle's credentials — exactly what the per-bottle sidecar's env did. + bottle's credentials — exactly what the per-bottle gateway daemon's env did. The identity token, if the agent injected one, is read then stripped so it never leaks upstream.""" if self._resolver is None: diff --git a/bot_bottle/egress_addon_core.py b/bot_bottle/egress_addon_core.py index c4fa9a1..a1ee7f2 100644 --- a/bot_bottle/egress_addon_core.py +++ b/bot_bottle/egress_addon_core.py @@ -7,7 +7,7 @@ exercise the parse + decision functions without depending on the container. Imports: stdlib + `yaml_subset` (which is itself stdlib-only and -ships flat into the sidecar bundle image alongside this file — +ships flat into the gateway image alongside this file — see `Dockerfile.gateway`).""" from __future__ import annotations diff --git a/bot_bottle/egress_dlp_config.py b/bot_bottle/egress_dlp_config.py index faee229..f304a72 100644 --- a/bot_bottle/egress_dlp_config.py +++ b/bot_bottle/egress_dlp_config.py @@ -6,7 +6,7 @@ and what the proxy does when an outbound detector matches a token kept apart from the request-time scan/decision flow in `egress_addon_core` so each half reads top-to-bottom without scrolling past the other. -Stdlib-only; ships flat into the sidecar bundle image alongside +Stdlib-only; ships flat into the gateway image alongside `egress_addon_core.py` — see `Dockerfile.gateway`.""" from __future__ import annotations diff --git a/bot_bottle/egress_entrypoint.sh b/bot_bottle/egress_entrypoint.sh index 4187b31..c8e68aa 100644 --- a/bot_bottle/egress_entrypoint.sh +++ b/bot_bottle/egress_entrypoint.sh @@ -1,8 +1,8 @@ #!/bin/sh -# Egress daemon entrypoint inside the sidecar bundle (PRD 0024). +# Egress daemon entrypoint inside the gateway (PRD 0024). # # Extracted verbatim from Dockerfile.egress's prior inline `sh -c` -# ENTRYPOINT so the supervisor in bot_bottle/sidecar_init.py can +# ENTRYPOINT so the supervisor in bot_bottle/gateway_init.py can # call it as a normal child. Behavior is unchanged: # # * Upstream proxy: when EGRESS_UPSTREAM_PROXY is set, switch @@ -22,7 +22,7 @@ set -e # Pin mitmproxy's config dir to the bind-mount location of its CA # regardless of which user mitmdump runs as. In the legacy -# four-sidecar setup (Dockerfile.egress, USER mitmproxy) this +# four-daemon setup (Dockerfile.egress, USER mitmproxy) this # resolved naturally to `~mitmproxy/.mitmproxy`. In the PRD 0024 # bundle (USER root) `~root/.mitmproxy` is empty, so without this # flag mitmdump would generate a fresh CA on the wrong path and diff --git a/bot_bottle/git_gate.py b/bot_bottle/git_gate.py index 10ed752..59305d2 100644 --- a/bot_bottle/git_gate.py +++ b/bot_bottle/git_gate.py @@ -1,6 +1,6 @@ """Per-agent git-gate (PRD 0008). -A third per-agent sidecar that fronts the bottle's declared git +A third per-agent daemon that fronts the bottle's declared git upstreams as a transparent mirror. Each `bottle.git` entry maps to a bare repo on the gate; `git daemon` serves the bare repos over `git:///.git`. Two hooks make the mirror bidirectional: @@ -15,7 +15,7 @@ a bare repo on the gate; `git daemon` serves the bare repos over The agent never sees the upstream credential under either path. -Why a separate sidecar (not folded into egress or ssh-gate): the +Why a separate daemon (not folded into egress or ssh-gate): the gate is the only one of the three that holds upstream push credentials. Mixing it with egress would put push creds in the same blast radius as internet-facing TLS interception; mixing it @@ -23,7 +23,7 @@ with ssh-gate would force ssh-gate above L4 and into git-protocol land. See `docs/prds/0008-git-gate.md`. This module defines the abstract gate (`GitGate`) and its plan -dataclass (`GitGatePlan`). The sidecar's start/stop lifecycle is +dataclass (`GitGatePlan`). The gateway's start/stop lifecycle is backend-specific and lives on concrete subclasses (see `bot_bottle/backend/docker/git_gate.py`).""" @@ -86,7 +86,7 @@ class GitGatePlan: class GitGate(ABC): """The per-agent git-gate. Encapsulates the host-side prepare - (upstream lift + entrypoint/hook render); the sidecar's + (upstream lift + entrypoint/hook render); the gateway's start/stop lifecycle is backend-specific and lives on concrete subclasses.""" diff --git a/bot_bottle/git_gate_render.py b/bot_bottle/git_gate_render.py index 5b2277c..fb23bb2 100644 --- a/bot_bottle/git_gate_render.py +++ b/bot_bottle/git_gate_render.py @@ -1,7 +1,7 @@ """Pure host-side rendering for the per-agent git-gate (PRD 0008). Builds the agent's `.gitconfig` insteadOf rewrites, the known_hosts -line, and the entrypoint / pre-receive / access-hook scripts the sidecar +line, and the entrypoint / pre-receive / access-hook scripts the gateway runs. No docker or forge calls — exposed for tests and reuse across backends. Split out of `git_gate.py` so the control surface (`GitGate`) and the deploy-key lifecycle (`git_gate_provision`) each read on their @@ -16,7 +16,7 @@ from pathlib import Path from .manifest import ManifestBottle, ManifestGitEntry -# Short network alias for git-gate inside the sidecar bundle. The +# Short network alias for git-gate inside the gateway. The # agent's `.gitconfig` insteadOf rewrites resolve through this name. GIT_GATE_HOSTNAME = "git-gate" # Shared timeout (seconds) for all git-gate subprocess and CGI calls: @@ -38,7 +38,7 @@ class GitGateUpstream: KnownHostKey string from the manifest; the gate's start step materialises it into a known_hosts file if non-empty. - the gate credential paths inside the running sidecar.""" + the gate credential paths inside the running gateway.""" name: str upstream_url: str diff --git a/bot_bottle/git_http_backend.py b/bot_bottle/git_http_backend.py index 7564513..483ed84 100644 --- a/bot_bottle/git_http_backend.py +++ b/bot_bottle/git_http_backend.py @@ -2,7 +2,7 @@ Used where `git://` push traffic over a host-published Docker port can hang before receive-pack reaches hooks (e.g. the firecracker backend, -where the guest reaches the sidecar over the point-to-point TAP). The +where the guest reaches the gateway over the point-to-point TAP). The wrapper serves the same `/git/*.git` bare repos through `git http-backend`, so pre-receive and upstream forwarding remain the git-gate enforcement point. @@ -27,7 +27,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import urlsplit -# policy_resolver ships flat alongside this file in the sidecar bundle +# policy_resolver ships flat alongside this file in the gateway # image (see Dockerfile.gateway); the bot_bottle.* fallback is the # host-side / test path. Mirrors egress_addon's import shape. try: @@ -103,7 +103,7 @@ def resolve_sandbox_root( return namespace # Mirrors git_gate_render.GIT_GATE_TIMEOUT_SECS. Duplicated rather than -# imported: this module ships as a flat top-level sibling in the sidecar +# imported: this module ships as a flat top-level sibling in the gateway # bundle image (see Dockerfile.gateway), not as part of the bot_bottle # package, so `bot_bottle.git_gate` and its dependency chain aren't # available at runtime. diff --git a/bot_bottle/manifest_agent.py b/bot_bottle/manifest_agent.py index c0fb1cf..5766ef6 100644 --- a/bot_bottle/manifest_agent.py +++ b/bot_bottle/manifest_agent.py @@ -17,7 +17,7 @@ class ManifestAgentProvider: `template` selects a built-in launch/runtime contract. `dockerfile` optionally points at a custom agent-image Dockerfile while leaving - bot-bottle's sidecar infrastructure intact. + bot-bottle's gateway infrastructure intact. `auth_token` names the host env var that holds the provider's OAuth token (Claude only). The provisioner injects a provider-owned egress @@ -26,7 +26,7 @@ class ManifestAgentProvider: so the Claude Code CLI starts. `forward_host_credentials` forwards the host Codex auth token into - the egress sidecar (Codex only). + the egress daemon (Codex only). """ template: str = "claude" diff --git a/bot_bottle/manifest_bottle.py b/bot_bottle/manifest_bottle.py index 3b0f068..8d47c6b 100644 --- a/bot_bottle/manifest_bottle.py +++ b/bot_bottle/manifest_bottle.py @@ -39,10 +39,10 @@ class ManifestBottle: # identity without any git-gate.repos upstreams, and vice versa. git_user: ManifestGitUser = field(default_factory=ManifestGitUser) egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig) - # Per-bottle stuck-recovery sidecar (PRD 0013). When true (the + # Per-bottle stuck-recovery daemon (PRD 0013). When true (the # default, issue #249), the launch step brings up a supervise - # sidecar that exposes egress MCP tools to the agent. Set - # `supervise: false` to skip the sidecar. + # daemon that exposes egress MCP tools to the agent. Set + # `supervise: false` to skip the gateway. supervise: bool = True @classmethod @@ -61,7 +61,7 @@ class ManifestBottle: raise ManifestError( f"bottle '{name}' has an 'ssh' field, which has been removed " f"(PRD 0009). Declare upstreams under 'git-gate.repos' with " - f"url + identity + host_key; the git-gate sidecar (PRD 0008) " + f"url + identity + host_key; the git-gate daemon (PRD 0008) " f"holds the credential and gitleaks-scans pushes." ) diff --git a/bot_bottle/orchestrator/__main__.py b/bot_bottle/orchestrator/__main__.py index 9a8d15d..26c6f40 100644 --- a/bot_bottle/orchestrator/__main__.py +++ b/bot_bottle/orchestrator/__main__.py @@ -39,7 +39,7 @@ def main(argv: list[str] | None = None) -> int: ) parser.add_argument( "--gateway", action="store_true", - help="run one consolidated per-host sidecar bundle (build-if-missing)", + help="run one consolidated per-host gateway (build-if-missing)", ) args = parser.parse_args(argv) diff --git a/bot_bottle/orchestrator/docker_broker.py b/bot_bottle/orchestrator/docker_broker.py index 9caf3a6..e5af91c 100644 --- a/bot_bottle/orchestrator/docker_broker.py +++ b/bot_bottle/orchestrator/docker_broker.py @@ -2,12 +2,12 @@ On a verified launch request it starts a Docker container; on teardown it removes it. This proves the orchestrator -> backend seam on the cheapest -backend (the sidecar bundle is already containers). Only the request's +backend (the gateway is already containers). Only the request's static ids/flags reach `docker`, so nothing free-form crosses the boundary. Slice 3 launches a single container from the request's `image_ref`, named after the bottle id and labelled for cleanup. Wiring the full agent + -sidecar bundle (networks, mounts, the consolidated sidecar) is a later +gateway (networks, mounts, the consolidated gateway) is a later slice — this is the seam, not the finished launcher. """ diff --git a/bot_bottle/orchestrator/gateway.py b/bot_bottle/orchestrator/gateway.py index 7c11466..f474cd9 100644 --- a/bot_bottle/orchestrator/gateway.py +++ b/bot_bottle/orchestrator/gateway.py @@ -1,7 +1,7 @@ """The consolidated per-host gateway (PRD 0070). The core consolidation win: **one** persistent gateway per host, shared by -every bottle, instead of a sidecar bundle per bottle. It's safe to share +every bottle, instead of a gateway per bottle. It's safe to share 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. @@ -46,7 +46,7 @@ GATEWAY_CA_VOLUME = "bot-bottle-gateway-mitmproxy" GATEWAY_CA_CERT = f"{MITMPROXY_HOME}/mitmproxy-ca-cert.pem" # The gateway data-plane image + its Dockerfile. Kept as a local constant -# rather than imported from backend.docker.sidecar_bundle, which would drag +# rather than imported from the backend layer, which would drag # the whole backend layer into the lean orchestrator (see #359); unify when # that lands. Env override matches the backend's BOT_BOTTLE_GATEWAY_IMAGE. GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest") diff --git a/bot_bottle/orchestrator/registration.py b/bot_bottle/orchestrator/registration.py index 6773a52..fd88610 100644 --- a/bot_bottle/orchestrator/registration.py +++ b/bot_bottle/orchestrator/registration.py @@ -5,7 +5,7 @@ registry: turns a prepared bottle's egress plan into the backend-neutral inputs `Orchestrator.launch_bottle` takes — the egress **policy** blob and launch **metadata**. -The policy blob is the exact routes YAML the per-bottle egress sidecar used +The policy blob is the exact routes YAML the per-bottle egress daemon used to read from a file; in the consolidated model the multi-tenant gateway's `PolicyResolver` fetches it from the registry per request (keyed by source IP) instead. Same render, so consolidated and single-tenant egress apply diff --git a/bot_bottle/paths.py b/bot_bottle/paths.py index 8ac64ad..15bf2e3 100644 --- a/bot_bottle/paths.py +++ b/bot_bottle/paths.py @@ -10,7 +10,7 @@ suite points it at a throwaway dir instead of monkey-patching the function override covers them all), and operators can relocate the root if needed. This module has no bot-bottle imports, so it is safe to import from any -layer (and to COPY flat into the sidecar bundle). +layer (and to COPY flat into the gateway). """ from __future__ import annotations @@ -35,7 +35,7 @@ def host_db_path() -> Path: Kept in its own `db/` subdirectory (not directly under the root) so a backend that can only bind-mount *directories* can share this one file - with a sidecar without exposing the root's other contents (git-gate + with a gateway without exposing the root's other contents (git-gate keys, per-bottle state, ...).""" return bot_bottle_root() / "db" / HOST_DB_FILENAME diff --git a/bot_bottle/policy_resolver.py b/bot_bottle/policy_resolver.py index 7449ee5..d9c62de 100644 --- a/bot_bottle/policy_resolver.py +++ b/bot_bottle/policy_resolver.py @@ -23,7 +23,7 @@ closed too rather than silently serving stale or empty policy. The resolved value is the policy blob the orchestrator stores verbatim; the consumer parses it (e.g. the egress addon's `load_config`). This module is stdlib-only and free of bot-bottle imports so it can be COPYed flat into -the sidecar bundle. +the gateway. """ from __future__ import annotations diff --git a/bot_bottle/queue_store.py b/bot_bottle/queue_store.py index cd6b188..51072c0 100644 --- a/bot_bottle/queue_store.py +++ b/bot_bottle/queue_store.py @@ -26,7 +26,7 @@ class QueueStore(DbStore): if db_path is not None: resolved = db_path else: - # In the sidecar container SUPERVISE_DB_PATH points at the + # In the gateway container SUPERVISE_DB_PATH points at the # bind-mounted host DB. On the host this env var is never set, # so we always fall through to host_db_path(). env_path = os.environ.get("SUPERVISE_DB_PATH", "").strip() diff --git a/bot_bottle/supervise.py b/bot_bottle/supervise.py index b086ad4..df07ede 100644 --- a/bot_bottle/supervise.py +++ b/bot_bottle/supervise.py @@ -1,25 +1,25 @@ """Per-bottle supervise plane (PRD 0013). -The supervise plane is the per-bottle MCP sidecar plus its host-side -queue/audit support. The sidecar (bot_bottle.supervise_server) +The supervise plane is the per-bottle MCP daemon plus its host-side +queue/audit support. The daemon (bot_bottle.supervise_server) sits on the bottle's internal network and exposes MCP tools the agent calls when it needs an operator-reviewed egress change: * egress-block / allow — agent proposes a new routes.yaml Each tool call: the agent passes the full proposed file plus a -justification text. The sidecar validates the proposal syntactically, +justification text. The gateway validates the proposal syntactically, writes it to the host SQLite queue table, and holds the tool-call connection open. The operator's supervise TUI (bot_bottle.cli.supervise) sees the proposal, accepts -approve / modify / reject, and writes a response row. The sidecar sees +approve / modify / reject, and writes a response row. The gateway sees the response and returns `{status, notes}` to the agent. This module defines the host-side library: dataclasses for the queue record shapes, queue read/write helpers, the audit log writer, and the -diff renderer. The in-container sidecar lives in +diff renderer. The in-gateway daemon lives in bot_bottle/supervise_server.py; the supervise daemon's container -lifecycle is owned by the sidecar bundle (PRD 0024). +lifecycle is owned by the gateway (PRD 0024). For 0013 the supervisor's approval handlers are deliberately no-ops: on approval the audit log is written and the response file is @@ -75,18 +75,18 @@ except ImportError: try: from .paths import bot_bottle_root -except ImportError: # flat imports inside the sidecar bundle +except ImportError: # flat imports inside the gateway from paths import bot_bottle_root # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module SUPERVISE_HOSTNAME = "supervise" SUPERVISE_PORT = 9100 -# The supervise sidecar uses these to query egress's +# The supervise daemon uses these to query egress's # introspection endpoint for the `list-egress-routes` MCP # tool. The hostname + port match egress's docker network # listen port (see backend.docker.egress.EGRESS_PORT). The supervise -# daemon runs inside the sidecar bundle alongside egress, so loopback +# daemon runs inside the gateway alongside egress, so loopback # is the stable address across docker, firecracker, and Apple # Container backends. EGRESS_FORWARD_PROXY = "http://127.0.0.1:9099" @@ -117,7 +117,7 @@ try: from .audit_store import AuditStore from .store_manager import StoreManager except ImportError: - # Sidecar bundle: files are flat-copied under /app, not a package. + # Gateway: files are flat-copied under /app, not a package. from queue_store import QueueStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from audit_store import AuditStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from store_manager import StoreManager # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module @@ -222,14 +222,14 @@ def sha256_hex(content: str) -> str: return hashlib.sha256(content.encode("utf-8")).hexdigest() -# --- Sidecar plan + abstract lifecycle ------------------------------------- +# --- Gateway plan + abstract lifecycle ------------------------------------- @dataclass(frozen=True) class SupervisePlan: """Output of Supervise.prepare; consumed by .start. - `db_path` is the host database bind-mounted into the sidecar at + `db_path` is the host database bind-mounted into the gateway at /run/supervise/bot-bottle.db. `internal_network` is empty at prepare time; the backend's launch step fills it via dataclasses.replace before calling .start.""" @@ -240,8 +240,8 @@ class SupervisePlan: class Supervise(ABC): - """Per-bottle supervise sidecar. Encapsulates host-side database - staging; the sidecar's start/stop lifecycle is backend-specific.""" + """Per-bottle supervise daemon. Encapsulates host-side database + staging; the gateway's start/stop lifecycle is backend-specific.""" def prepare( self, diff --git a/bot_bottle/supervise_server.py b/bot_bottle/supervise_server.py index 474b01a..e52bf74 100644 --- a/bot_bottle/supervise_server.py +++ b/bot_bottle/supervise_server.py @@ -1,4 +1,4 @@ -"""Supervise sidecar HTTP server (PRD 0013). +"""Supervise daemon HTTP server (PRD 0013). Per-bottle MCP server exposing tools the agent calls to propose egress config changes when stuck. The tools are `egress-allow`, diff --git a/bot_bottle/yaml_subset.py b/bot_bottle/yaml_subset.py index 6432a3e..d959391 100644 --- a/bot_bottle/yaml_subset.py +++ b/bot_bottle/yaml_subset.py @@ -71,7 +71,7 @@ class YamlSubsetError(ValueError): that want fatal-exit semantics (manifest loader, egress-apply, etc.) catch this at their own boundary and forward to `die`; callers running outside the bot-bottle CLI process (the - egress sidecar's addon) handle it as a normal exception.""" + egress daemon's addon) handle it as a normal exception.""" diff --git a/docs/ci.md b/docs/ci.md index 195b6ab..51d7e8d 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -22,8 +22,7 @@ mounted in. That topology breaks two assumptions those tests make: `http://127.0.0.1:` from inside the job time out. The affected tests (`test_orphan_cleanup.test_create_and_remove`, -`test_sidecar_bundle_image.TestSidecarBundleImage`, -`test_sidecar_bundle_compose.TestSidecarBundleCompose`) still run +`test_gateway_image.TestGatewayImage`) still run locally where the test process and Docker daemon share a host. Making them work in CI is a follow-up: either re-write them to discover container IPs via `docker inspect`, or reconfigure the diff --git a/docs/demo.tape b/docs/demo.tape index 5a9e2ef..e1fe0e6 100644 --- a/docs/demo.tape +++ b/docs/demo.tape @@ -38,7 +38,7 @@ Type "y" Enter # Wait for the bottle to launch: networks created, pipelock + git-gate -# sidecars started, agent container started, claude boots. +# companion containers started, agent container started, claude boots. Sleep 22s # Probe 1 — warm-up. A reply at all proves api.anthropic.com is @@ -72,7 +72,7 @@ Type "init /tmp/r, commit AKIAQRJHK7N5ZPM2VXTL to leak.txt, push to ssh://git@up Enter Sleep 30s -# Leave claude. The launcher tears down the container, sidecars, and +# Leave claude. The launcher tears down the container, companion containers, and # networks on session end. Ctrl+D Sleep 4s diff --git a/nix/firecracker-netpool.nix b/nix/firecracker-netpool.nix index 5f25f27..7def4fa 100644 --- a/nix/firecracker-netpool.nix +++ b/nix/firecracker-netpool.nix @@ -2,7 +2,7 @@ # # The one-time privileged setup the Firecracker backend needs: a pool of # user- (or group-) owned point-to-point TAP devices plus a fail-closed -# nftables table that confines every microVM to its own sidecar. +# nftables table that confines every microVM to its own gateway. # # NON-INVASIVE BY DESIGN. It does NOT flip `networking.nftables.enable` # (which would switch your whole host firewall backend) or @@ -150,7 +150,7 @@ in } ]; - # VM->sidecar traffic is DNAT'd and forwarded, so forwarding must be on. + # VM->gateway traffic is DNAT'd and forwarded, so forwarding must be on. boot.kernel.sysctl."net.ipv4.ip_forward" = 1; # One oneshot brings up the whole pool (TAPs + independent nft table) diff --git a/scripts/firecracker-netpool.sh b/scripts/firecracker-netpool.sh index cf080c9..9f9d444 100755 --- a/scripts/firecracker-netpool.sh +++ b/scripts/firecracker-netpool.sh @@ -4,7 +4,7 @@ # Creates a pool of point-to-point TAP devices (owned by the invoking # user so the backend can open them without root at launch) and a # dedicated nftables table that isolates every VM: a bottle VM can -# reach only its own sidecar (published on the host-side TAP IP) and +# reach only its own gateway (published on the host-side TAP IP) and # nothing else on the host or network. # # Why a pool + one-time setup: creating a TAP and assigning it an IP @@ -72,9 +72,9 @@ for _v in POOL_SIZE IP_BASE PREFIX TABLE; do [ -n "${!_v}" ] || { echo "error: $_v unresolved (set BOT_BOTTLE_FC_* or fix $_DEFAULTS)" >&2; exit 1; } done -# Sidecar ports (must match the backend). egress=9099, supervise=9100, +# Gateway ports (must match the backend). egress=9099, supervise=9100, # git-http=9420. Reached by the VM at its host-side TAP IP. -SIDECAR_PORTS="9099,9100,9420" +GATEWAY_PORTS="9099,9100,9420" # --- IP math --------------------------------------------------------- # Slot i occupies the /31 {base+2i, base+2i+1}: host = base+2i (the @@ -107,7 +107,7 @@ cmd_up() { fi echo "firecracker net pool: $POOL_SIZE slots, base $IP_BASE, $own_desc" - # VM->sidecar traffic is DNAT'd to the sidecar container and + # VM->gateway traffic is DNAT'd to the gateway container and # forwarded, so forwarding must be enabled (Docker also sets this). sysctl -qw net.ipv4.ip_forward=1 @@ -135,11 +135,11 @@ _install_nft() { # tool's traffic is affected. Priority -10 runs before Docker's # filter hooks (priority 0); a drop here is terminal for the packet. # - # forward: VM egress is DNAT'd to the sidecar (established via + # forward: VM egress is DNAT'd to the gateway (established via # `ct status dnat`); return traffic via `ct state established`. # Anything else from a VM is dropped -> no route to the internet - # or the rest of the host except through the sidecar proxy. - # input: a VM never needs host-local delivery (its sidecar is + # or the rest of the host except through the gateway proxy. + # input: a VM never needs host-local delivery (its gateway is # reached via DNAT->forward), so drop all direct input from VMs # -> host services bound on 0.0.0.0 are unreachable from the VM. nft -f - < None: - # Docker is always required (the agent + sidecars run under it, - # and VM backends still use it for the sidecar bundle); the + # Docker is always required (the agent + companion containers run under it, + # and VM backends still use it for the gateway); the # class-level @skip_unless_docker already covers that. Pin # Docker when BOT_BOTTLE_BACKEND is unset to preserve the # Docker-backed CI path. @@ -121,7 +121,7 @@ class TestSandboxEscape(unittest.TestCase): "egress": { "routes": [{"host": "api.anthropic.com"}], }, - # git-gate sidecar so attack 5 can push. Upstream + # git-gate daemon so attack 5 can push. Upstream # is intentionally unreachable — the pre-receive # gitleaks hook must reject BEFORE git-gate # attempts the upstream push. A preset `host_key` @@ -275,7 +275,7 @@ class TestSandboxEscape(unittest.TestCase): def _assert_sandbox_block(self, label: str, r: object) -> None: # type: ignore """A real sandbox block produces an HTTP 403 with a - recognizable sandbox sidecar marker in the body. ANY + recognizable sandbox gateway marker in the body. ANY other outcome (200 from upstream, 401/404 from upstream, non-marker 5xx) means the request escaped — the secret reached the network.""" diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index be349dc..9f804e4 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -5,7 +5,7 @@ no test ever reads or writes the real ``~/.bot-bottle`` (state, queue, and audit dirs all derive from ``paths.bot_bottle_root()`` → ``Path.home()``). Without this, a test that takes a ``flock`` on the real audit log can **block indefinitely** when a live bottle's supervise -sidecar holds that lock — observed as a hung ``coverage run`` at 0% CPU — +gateway holds that lock — observed as a hung ``coverage run`` at 0% CPU — and unisolated tests otherwise pollute the developer's home dir. Individual tests that need their own ``HOME`` still override diff --git a/tests/unit/test_consolidated_compose.py b/tests/unit/test_consolidated_compose.py index 47ea9a4..33495e1 100644 --- a/tests/unit/test_consolidated_compose.py +++ b/tests/unit/test_consolidated_compose.py @@ -19,8 +19,8 @@ class TestConsolidatedAgentCompose(unittest.TestCase): plan = type(plan)(**{**vars(plan), "use_runsc": True}) # type: ignore[arg-type] return consolidated_agent_compose(plan, gateway_ip=_GW, source_ip=_IP, network=_NET) - def test_only_agent_service_no_sidecars(self) -> None: - # The whole point of consolidation: no per-bottle sidecar bundle. + def test_only_agent_service_no_companion_container(self) -> None: + # The whole point of consolidation: no per-bottle gateway. self.assertEqual(["agent"], list(self._spec()["services"])) def test_agent_pinned_on_external_gateway_network(self) -> None: @@ -35,7 +35,7 @@ class TestConsolidatedAgentCompose(unittest.TestCase): # git-http + supervise on the gateway must bypass the egress proxy. self.assertTrue(any(e.startswith("NO_PROXY=") and _GW in e for e in env)) - def test_no_sidecar_dependency(self) -> None: + def test_no_companion_container_dependency(self) -> None: self.assertNotIn("depends_on", self._spec()["services"]["agent"]) def test_runsc_runtime_when_enabled(self) -> None: diff --git a/tests/unit/test_egress.py b/tests/unit/test_egress.py index ee44f2a..2db81b7 100644 --- a/tests/unit/test_egress.py +++ b/tests/unit/test_egress.py @@ -586,7 +586,7 @@ class TestCanaryGeneration(unittest.TestCase): class TestEgressEnvEntries(unittest.TestCase): - def test_sidecar_entries_include_route_tokens_and_canary_scan_prefix(self): + def test_gateway_entries_include_route_tokens_and_canary_scan_prefix(self): plan = EgressPlan( slug="s", routes_path=Path("/tmp/r.yaml"), diff --git a/tests/unit/test_egress_addon_core.py b/tests/unit/test_egress_addon_core.py index 8fcbf7b..958418d 100644 --- a/tests/unit/test_egress_addon_core.py +++ b/tests/unit/test_egress_addon_core.py @@ -951,7 +951,7 @@ class TestScanOutbound(unittest.TestCase): body='{"jsonrpc":"2.0","method":"initialize"}', ) self.assertIsNone(scan_outbound(route, text, { - "EGRESS_TOKEN_0": "sidecar-owned-secret", + "EGRESS_TOKEN_0": "gateway-owned-secret", })) def test_token_in_body_blocked(self): @@ -1302,7 +1302,7 @@ class TestScanOutboundEnhanced(unittest.TestCase): self.assertEqual("warn", result.severity) def test_bot_bottle_sensitive_prefixes_env_var(self): - # When the sidecar env contains BOT_BOTTLE_SENSITIVE_PREFIXES, + # When the gateway env contains BOT_BOTTLE_SENSITIVE_PREFIXES, # scan_outbound should scan those additional prefixes. secret = "extra-sensitive-value-abc" env = { @@ -1324,7 +1324,7 @@ class TestScanOutboundEnhanced(unittest.TestCase): self.assertIsNotNone(result) def test_canary_detected_via_random_secret_env_name(self): - # The fake secret uses a randomized env name that the sidecar marks + # The fake secret uses a randomized env name that the gateway marks # as sensitive through BOT_BOTTLE_SENSITIVE_PREFIXES. canary = "canaryvalue12345abcdef" env = { diff --git a/tests/unit/test_egress_addon_log_redaction.py b/tests/unit/test_egress_addon_log_redaction.py index 6a969c5..5a648e2 100644 --- a/tests/unit/test_egress_addon_log_redaction.py +++ b/tests/unit/test_egress_addon_log_redaction.py @@ -1,6 +1,6 @@ """Unit: LOG_FULL credential redaction in _log_request / _log_response (issue #257). -egress_addon.py is sidecar-only code that depends on mitmproxy, which is +egress_addon.py is gateway-only code that depends on mitmproxy, which is not installed on the host. This file pre-populates sys.modules with the minimum mocks needed so EgressAddon can be imported and tested without the real mitmproxy package.""" @@ -17,7 +17,7 @@ from unittest.mock import patch # --------------------------------------------------------------------------- -# Sidecar-import shims — must run before importing egress_addon +# Gateway-import shims — must run before importing egress_addon # --------------------------------------------------------------------------- def _ensure_shims() -> None: diff --git a/tests/unit/test_egress_addon_request_flow.py b/tests/unit/test_egress_addon_request_flow.py index aa46628..71256a0 100644 --- a/tests/unit/test_egress_addon_request_flow.py +++ b/tests/unit/test_egress_addon_request_flow.py @@ -1,6 +1,6 @@ """Unit: EgressAddon request/response decision flow (issue #286). -`egress_addon.py` is the sidecar-only mitmproxy adapter that wires the +`egress_addon.py` is the gateway-only mitmproxy adapter that wires the host-importable decision logic in `egress_addon_core` into mitmproxy's request/response hooks. The core logic is exercised directly by `test_egress_addon_core.py`; the redaction logging by @@ -13,7 +13,7 @@ from coverage. mitmproxy is not installed on the host, so we pre-populate `sys.modules` with the minimum stubs needed to import the adapter (a `mitmproxy.http` module exposing a `Response` with `.make`, plus the flat -`egress_addon_core` name the sidecar uses).""" +`egress_addon_core` name the gateway uses).""" from __future__ import annotations @@ -158,7 +158,7 @@ class _WebSocketData: # --------------------------------------------------------------------------- -# Sidecar-import shims — must run before importing egress_addon +# Gateway-import shims — must run before importing egress_addon # --------------------------------------------------------------------------- @@ -303,9 +303,9 @@ class TestAuthInjection(unittest.TestCase): route = Route(host="api.example.com", auth_scheme="Bearer", token_env="EGRESS_TOKEN_0") addon = _addon(Config(routes=(route,))) flow = _Flow(_Request(host="api.example.com", headers={"authorization": "Bearer agent-faked"})) - with patch.dict("os.environ", {"EGRESS_TOKEN_0": "real-sidecar-token"}): + with patch.dict("os.environ", {"EGRESS_TOKEN_0": "real-gateway-token"}): _run_request(addon, flow) - self.assertEqual("Bearer real-sidecar-token", flow.request.headers.get("authorization")) + self.assertEqual("Bearer real-gateway-token", flow.request.headers.get("authorization")) self.assertIsNone(flow.response) def test_auth_route_with_unset_env_blocks(self) -> None: diff --git a/tests/unit/test_egress_apply.py b/tests/unit/test_egress_apply.py index 203b606..68325b1 100644 --- a/tests/unit/test_egress_apply.py +++ b/tests/unit/test_egress_apply.py @@ -72,7 +72,7 @@ class TestApplyRoutesChange(unittest.TestCase): def test_apply_routes_change_fails_closed_after_companion_removal(self): # The per-bottle companion container that live route-apply used to - # signal was removed in the de-sidecar cleanup (#385); apply now + # signal was removed in the companion-container removal (#385); apply now # fails closed until the gateway-side apply lands. with self.assertRaises(EgressApplyError) as cm: applicator.apply_routes_change( diff --git a/tests/unit/test_git_gate.py b/tests/unit/test_git_gate.py index b4f6254..2f0acf4 100644 --- a/tests/unit/test_git_gate.py +++ b/tests/unit/test_git_gate.py @@ -218,9 +218,9 @@ class TestHookRender(unittest.TestCase): self.assertIn("supervisor approved # gitleaks:allow", hook) self.assertIn("supervisor rejected # gitleaks:allow", hook) - def test_inline_gitleaks_allow_python_imports_work_in_sidecar_layout(self): + def test_inline_gitleaks_allow_python_imports_work_in_gateway_layout(self): hook = git_gate_render_hook() - # The sidecar image copies supervise.py flat under /app, while + # The gateway image copies supervise.py flat under /app, while # host-side tests import it through the bot_bottle package. # Hooks execute from the bare repo directory, so the embedded # Python must include /app and support both import layouts. diff --git a/tests/unit/test_git_http_backend.py b/tests/unit/test_git_http_backend.py index 95672d8..cda0d32 100644 --- a/tests/unit/test_git_http_backend.py +++ b/tests/unit/test_git_http_backend.py @@ -220,7 +220,7 @@ class TestGitHttpBackend(unittest.TestCase): def test_subprocess_calls_include_timeout(self): """Both subprocess.run calls (access-hook and git http-backend) must - pass timeout= so a hung upstream cannot wedge the sidecar.""" + pass timeout= so a hung upstream cannot wedge the gateway.""" from http.server import ThreadingHTTPServer with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/unit/test_macos_container_cleanup.py b/tests/unit/test_macos_container_cleanup.py index 8a736a0..df70988 100644 --- a/tests/unit/test_macos_container_cleanup.py +++ b/tests/unit/test_macos_container_cleanup.py @@ -16,12 +16,12 @@ class TestMacosContainerCleanup(unittest.TestCase): completed = cleanup.subprocess.CompletedProcess( args=[], returncode=0, - stdout="bot-bottle-a\nbot-bottle-sidecars-a\nother\n", + stdout="bot-bottle-a\nbot-bottle-b\nother\n", stderr="", ) with patch.object(cleanup.subprocess, "run", return_value=completed): self.assertEqual( - ["bot-bottle-a", "bot-bottle-sidecars-a"], + ["bot-bottle-a", "bot-bottle-b"], cleanup._list_prefixed_containers(), ) @@ -44,7 +44,7 @@ class TestMacosContainerCleanup(unittest.TestCase): class TestMacosContainerEnumerate(unittest.TestCase): def test_enumerate_active_is_empty_while_disabled(self): - # The macOS backend is disabled during the de-sidecar cleanup + # The macOS backend is disabled during the companion-container removal cleanup # (#385); it launches nothing, so there is nothing to enumerate. self.assertEqual([], enum_mod.enumerate_active()) diff --git a/tests/unit/test_macos_container_util.py b/tests/unit/test_macos_container_util.py index 213526f..54e604c 100644 --- a/tests/unit/test_macos_container_util.py +++ b/tests/unit/test_macos_container_util.py @@ -267,7 +267,7 @@ resolver #2 self.assertEqual( "192.168.128.2", util.container_ipv4_on_network( - "bot-bottle-sidecars-demo", + "bot-bottle-demo", "bot-bottle-net-demo", ), ) diff --git a/tests/unit/test_orchestrator_registration.py b/tests/unit/test_orchestrator_registration.py index 7e14684..846da4e 100644 --- a/tests/unit/test_orchestrator_registration.py +++ b/tests/unit/test_orchestrator_registration.py @@ -28,7 +28,7 @@ def _plan(routes: tuple[EgressRoute, ...], *, slug: str = "demo", log: int = 0) class TestEgressPolicy(unittest.TestCase): def test_policy_round_trips_through_load_config(self) -> None: # The policy the gateway serves must parse back to the same allow-list - # the per-bottle sidecar applied — moving onto the shared gateway must + # the per-bottle gateway applied — moving onto the shared gateway must # not change a bottle's egress. routes = (EgressRoute(host="api.example.com"), EgressRoute(host="pypi.org")) cfg = load_config(egress_policy(_plan(routes))) diff --git a/tests/unit/test_provision_git.py b/tests/unit/test_provision_git.py index 441a24e..2bf21ca 100644 --- a/tests/unit/test_provision_git.py +++ b/tests/unit/test_provision_git.py @@ -26,7 +26,7 @@ class TestGitGateGitconfigRender(unittest.TestCase): bottle = fixture_with_git().bottles["dev"] out = git_gate_render_gitconfig(bottle.git, GIT_GATE_HOSTNAME) # Both entries map to a [url ...] block keyed on the gate's - # short network alias (`git-gate`) inside the sidecar bundle. + # short network alias (`git-gate`) inside the gateway. self.assertIn( '[url "git://git-gate/bot-bottle.git"]', out, diff --git a/tests/unit/test_supervise_server.py b/tests/unit/test_supervise_server.py index df81d3a..d73ffac 100644 --- a/tests/unit/test_supervise_server.py +++ b/tests/unit/test_supervise_server.py @@ -1,4 +1,4 @@ -"""Unit: supervise sidecar MCP server (PRD 0013).""" +"""Unit: supervise daemon MCP server (PRD 0013).""" import http.client import json -- 2.52.0 From 56d879f0b3fbbe34fbd99ddc3cef5535f546b7d9 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 14 Jul 2026 17:12:12 -0400 Subject: [PATCH 5/5] =?UTF-8?q?fix(tests):=20pyright=20strict=20=E2=80=94?= =?UTF-8?q?=20export=20=5Fplan=20fixture,=20drop=20unused=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `tests/unit/_docker_bottle_plan.py`: add `__all__ = ["_plan"]` so the underscore-prefixed fixture isn't flagged reportUnusedFunction. - `tests/unit/test_egress_apply.py`: drop `SimpleNamespace` / `patch` imports left unused after the reload test became fail-closed. `pyright .` → 0 errors. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- tests/unit/_docker_bottle_plan.py | 4 ++++ tests/unit/test_egress_apply.py | 2 -- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit/_docker_bottle_plan.py b/tests/unit/_docker_bottle_plan.py index e2126af..6250f30 100644 --- a/tests/unit/_docker_bottle_plan.py +++ b/tests/unit/_docker_bottle_plan.py @@ -23,6 +23,10 @@ SLUG = "demo-abc12" STAGE = Path("/tmp/cb-stage") STATE = Path("/tmp/cb-state") +# Exported to consumers (e.g. test_consolidated_compose); named with a +# leading underscore for the historical fixture convention. +__all__ = ["_plan"] + def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> ManifestIndex: """Minimal manifest with the toggles the matrix needs. The renderer diff --git a/tests/unit/test_egress_apply.py b/tests/unit/test_egress_apply.py index 68325b1..66ca42c 100644 --- a/tests/unit/test_egress_apply.py +++ b/tests/unit/test_egress_apply.py @@ -5,8 +5,6 @@ integration test).""" import tempfile import unittest from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch from tests.unit import use_bottle_root from bot_bottle.backend.egress_apply import EgressApplyError -- 2.52.0