refactor(orchestrator): split conflated sidecar image into orchestrator + gateway images
lint / lint (push) Successful in 2m19s
test / unit (pull_request) Successful in 1m14s
test / integration (pull_request) Successful in 32s
test / coverage (pull_request) Successful in 1m28s

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-<slug>`) 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
2026-07-14 16:24:16 -04:00
parent 910267b8a8
commit 96b84eb84d
17 changed files with 165 additions and 48 deletions
+19 -16
View File
@@ -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-<slug>`. 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}"
+2 -2
View File
@@ -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]
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+6 -6
View File
@@ -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.)"""
+43 -4
View File
@@ -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",
]
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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