refactor(de-sidecar): rename sidecar_init→gateway_init, drop docker's dead per-bottle compose renderer

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
2026-07-14 16:46:46 -04:00
parent 96b84eb84d
commit c10d1cb6e0
5 changed files with 45 additions and 694 deletions
+7 -8
View File
@@ -1,16 +1,15 @@
# Gateway data-plane image (PRD 0024 bundle shape; PRD 0070 gateway). # Gateway data-plane image (PRD 0024 bundle shape; PRD 0070 gateway).
# #
# The egress / git-gate / supervise *data plane* — one image, shared by # The egress / git-gate / supervise *data plane* — one image, run by
# the consolidated per-host gateway (PRD 0070) and, on backends that # the consolidated per-host gateway (PRD 0070). It is NOT the
# still run a per-bottle bundle, each bottle's sidecar. It is NOT the
# orchestrator control plane: that is the separate, lean # orchestrator control plane: that is the separate, lean
# `bot-bottle-orchestrator` image (Dockerfile.orchestrator, #384), which # `bot-bottle-orchestrator` image (Dockerfile.orchestrator, #384), which
# ships only python + the stdlib-only `bot_bottle` package and none of # ships only python + the stdlib-only `bot_bottle` package and none of
# this image's mitmproxy / git / gitleaks payload. # 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 # 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 # propagates per-daemon stdout/stderr to the container log with a
# `[name]` prefix. See PRD 0024 for the rationale. # `[name]` prefix. See PRD 0024 for the rationale.
# #
@@ -20,7 +19,7 @@
# /app/egress_addon.py + siblings mitmproxy addon (egress) # /app/egress_addon.py + siblings mitmproxy addon (egress)
# /app/egress-entrypoint.sh mitmdump launcher # /app/egress-entrypoint.sh mitmdump launcher
# /app/supervise_server.py + .py supervise MCP server # /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/egress/routes.yaml bind-mounted at run time
# /etc/git-gate/pre-receive docker-cp'd at start time # /etc/git-gate/pre-receive docker-cp'd at start time
# /git-gate-entrypoint.sh 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/store_manager.py /app/store_manager.py
COPY bot_bottle/supervise.py /app/supervise.py COPY bot_bottle/supervise.py /app/supervise.py
COPY bot_bottle/supervise_server.py /app/supervise_server.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/git_http_backend.py /app/git_http_backend.py
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
RUN chmod +x /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 # PID 1 is the supervisor. It owns signal handling and exit-code
# propagation; no `exec` chain in the entrypoint itself. # propagation; no `exec` chain in the entrypoint itself.
ENTRYPOINT ["python3", "/app/sidecar_init.py"] ENTRYPOINT ["python3", "/app/gateway_init.py"]
+6 -243
View File
@@ -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 Serialize a compose spec to disk, drive `docker compose up/down`,
describing the per-bottle container topology — one project per dump the merged log on teardown, and enumerate `bot-bottle-*`
bottle instance, services for the agent + every applicable sidecar, projects. The spec itself is built by `consolidated_compose.py`
two networks, no named volumes. (the consolidated per-host gateway topology); this module owns the
I/O side that persists and runs it.
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.
""" """
from __future__ import annotations from __future__ import annotations
@@ -25,233 +15,7 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Any 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 ...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) ---------------------------------- # --- Lifecycle helpers (PRD 0018 chunk 3) ----------------------------------
@@ -442,7 +206,6 @@ __all__ = [
"COMPOSE_FILE_NAME", "COMPOSE_FILE_NAME",
"COMPOSE_LOG_NAME", "COMPOSE_LOG_NAME",
"COMPOSE_PROJECT_PREFIX", "COMPOSE_PROJECT_PREFIX",
"bottle_plan_to_compose",
"compose_down", "compose_down",
"compose_dump_logs", "compose_dump_logs",
"compose_file_path", "compose_file_path",
@@ -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 PID 1 inside the `bot-bottle-gateway` data-plane image. Spawns
the configured daemons (egress, git-gate, supervise), 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 Failure policy (interim): when a child dies unexpectedly, the
supervisor logs the death and leaves the surviving children 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. will start failing, surfacing in the agent's own error path.
The supervisor itself exits only when (a) the operator/compose The supervisor itself exits only when (a) the operator sends
sends SIGTERM/SIGINT, or (b) every child has died. SIGTERM/SIGINT, or (b) every child has died.
Failure policy (eventual): on unexpected death, the supervisor Failure policy (eventual): on unexpected death, the supervisor
restarts the daemon and emits a notification to the supervise restarts the daemon and emits a notification to the supervise
sidecar so the operator sees the event. That lands in a later daemon so the operator sees the event. That lands in a later
PR; the interim policy is "don't take the bundle down for one PR; the interim policy is "don't take the gateway down for one
sick daemon." sick daemon."
Daemon subset is env-driven. The compose renderer narrows it via Daemon subset is env-driven via `BOT_BOTTLE_GATEWAY_DAEMONS=egress`
`BOT_BOTTLE_SIDECAR_DAEMONS=egress` for bottles that for callers that don't use git-gate or supervise. Default: all
don't use git-gate or supervise. Default: all daemons. daemons.
Stdlib-only by design adding supervisord/s6/runit for four Stdlib-only by design adding supervisord/s6/runit for four
daemons is heavier than this script. daemons is heavier than this script.
@@ -103,8 +103,8 @@ def _selected_daemons(
env: dict[str, str], env: dict[str, str],
all_daemons: Sequence[_DaemonSpec] | None = None, all_daemons: Sequence[_DaemonSpec] | None = None,
) -> tuple[_DaemonSpec, ...]: ) -> tuple[_DaemonSpec, ...]:
"""Filter the daemon set by the BOT_BOTTLE_SIDECAR_DAEMONS env """Filter the daemon set by the BOT_BOTTLE_GATEWAY_DAEMONS env
var. Unknown names in the list are ignored the renderer is the var. Unknown names in the list are ignored the caller is the
source of truth for which daemons are wired. source of truth for which daemons are wired.
`all_daemons` defaults to `_DAEMONS` resolved at call time (not `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.""" `_DAEMONS` and have the new value take effect."""
if all_daemons is None: if all_daemons is None:
all_daemons = _DAEMONS all_daemons = _DAEMONS
raw = env.get("BOT_BOTTLE_SIDECAR_DAEMONS", "").strip() raw = env.get("BOT_BOTTLE_GATEWAY_DAEMONS", "").strip()
if not raw: if not raw:
return tuple(all_daemons) return tuple(all_daemons)
wanted = {n.strip() for n in raw.split(",") if n.strip()} wanted = {n.strip() for n in raw.split(",") if n.strip()}
@@ -120,7 +120,7 @@ def _selected_daemons(
def _log(msg: str) -> None: def _log(msg: str) -> None:
sys.stdout.write(f"sidecar-init: {msg}\n") sys.stdout.write(f"gateway-init: {msg}\n")
sys.stdout.flush() sys.stdout.flush()
+4 -415
View File
@@ -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 The compose *spec* is built by `consolidated_compose.py`; these
fully-resolved DockerBottlePlan in memory; the renderer just tests cover the I/O-side helpers in `compose.py` — the slug ↔
translates it to the compose dict. Conditional-service matrix is project mapping and `docker compose ls` enumeration.
covered via parameterized cases (git on/off × egress on/off ×
supervise on/off).
""" """
from __future__ import annotations from __future__ import annotations
import subprocess import subprocess
import unittest import unittest
from pathlib import Path
from typing import Any
from unittest import mock 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 ( from bot_bottle.backend.docker.compose import (
COMPOSE_PROJECT_PREFIX, COMPOSE_PROJECT_PREFIX,
bottle_plan_to_compose,
compose_project_name, compose_project_name,
list_active_slugs, list_active_slugs,
list_compose_projects, list_compose_projects,
slug_from_compose_project, 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): class TestProjectNaming(unittest.TestCase):
@@ -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 and the supervisor's end-to-end signal / exit-code behavior. The
end-to-end tests use real subprocesses (`/bin/sleep`, end-to-end tests use real subprocesses (`/bin/sleep`,
`/bin/sh -c '...'`) short-lived, no docker required so they `/bin/sh -c '...'`) short-lived, no docker required so they
@@ -18,7 +18,7 @@ import warnings
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from bot_bottle.sidecar_init import ( from bot_bottle.gateway_init import (
_DaemonSpec, _DaemonSpec,
_Supervisor, _Supervisor,
_argv_for_daemon, _argv_for_daemon,
@@ -80,18 +80,18 @@ class TestSelectedDaemons(unittest.TestCase):
["egress", "git-gate", "supervise"]) ["egress", "git-gate", "supervise"])
def test_empty_returns_all(self): def test_empty_returns_all(self):
got = _selected_daemons({"BOT_BOTTLE_SIDECAR_DAEMONS": ""}, got = _selected_daemons({"BOT_BOTTLE_GATEWAY_DAEMONS": ""},
all_daemons=self._DAEMONS) all_daemons=self._DAEMONS)
self.assertEqual(3, len(got)) self.assertEqual(3, len(got))
def test_whitespace_only_returns_all(self): 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) all_daemons=self._DAEMONS)
self.assertEqual(3, len(got)) self.assertEqual(3, len(got))
def test_explicit_subset(self): def test_explicit_subset(self):
got = _selected_daemons( got = _selected_daemons(
{"BOT_BOTTLE_SIDECAR_DAEMONS": "egress,git-gate"}, {"BOT_BOTTLE_GATEWAY_DAEMONS": "egress,git-gate"},
all_daemons=self._DAEMONS, all_daemons=self._DAEMONS,
) )
self.assertEqual([d.name for d in got], ["egress", "git-gate"]) 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 # Order in the env var doesn't matter; the result follows
# the canonical _DAEMONS order so egress starts first. # the canonical _DAEMONS order so egress starts first.
got = _selected_daemons( got = _selected_daemons(
{"BOT_BOTTLE_SIDECAR_DAEMONS": "supervise,git-gate,egress"}, {"BOT_BOTTLE_GATEWAY_DAEMONS": "supervise,git-gate,egress"},
all_daemons=self._DAEMONS, all_daemons=self._DAEMONS,
) )
self.assertEqual([d.name for d in got], self.assertEqual([d.name for d in got],
@@ -108,14 +108,14 @@ class TestSelectedDaemons(unittest.TestCase):
def test_unknown_names_ignored(self): def test_unknown_names_ignored(self):
got = _selected_daemons( got = _selected_daemons(
{"BOT_BOTTLE_SIDECAR_DAEMONS": "egress,bogus"}, {"BOT_BOTTLE_GATEWAY_DAEMONS": "egress,bogus"},
all_daemons=self._DAEMONS, all_daemons=self._DAEMONS,
) )
self.assertEqual([d.name for d in got], ["egress"]) self.assertEqual([d.name for d in got], ["egress"])
def test_whitespace_in_names_stripped(self): def test_whitespace_in_names_stripped(self):
got = _selected_daemons( got = _selected_daemons(
{"BOT_BOTTLE_SIDECAR_DAEMONS": " egress , git-gate "}, {"BOT_BOTTLE_GATEWAY_DAEMONS": " egress , git-gate "},
all_daemons=self._DAEMONS, all_daemons=self._DAEMONS,
) )
self.assertEqual([d.name for d in got], ["egress", "git-gate"]) 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 time.sleep(0.3) # let `trap` register
sup.request_shutdown(reason="test") 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) rc = self._drive(sup, max_wait_s=4.0)
# Process was SIGKILL'd → returncode -9 on POSIX. # Process was SIGKILL'd → returncode -9 on POSIX.
@@ -464,7 +464,7 @@ class TestSupervisor(unittest.TestCase):
class TestMainEndToEnd(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 signal-handler installation path. Skipped on platforms
without /bin/sleep + /bin/sh.""" without /bin/sleep + /bin/sh."""
@@ -477,20 +477,20 @@ class TestMainEndToEnd(unittest.TestCase):
def _run(self, daemons_csv: str, send_signal: int | None, def _run(self, daemons_csv: str, send_signal: int | None,
wait_before_signal: float = 0.4, wait_before_signal: float = 0.4,
overall_timeout: float = 6.0) -> tuple[int, str]: 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. DAEMONS list patched to harmless `sleep 30` commands.
Returns (returncode, captured stdout).""" Returns (returncode, captured stdout)."""
helper = ( helper = (
"import os, runpy, sys\n" "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._DAEMONS = (\n"
" si._DaemonSpec('alpha', ('/bin/sleep','30')),\n" " si._DaemonSpec('alpha', ('/bin/sleep','30')),\n"
" si._DaemonSpec('beta', ('/bin/sleep','30')),\n" " si._DaemonSpec('beta', ('/bin/sleep','30')),\n"
")\n" ")\n"
"sys.exit(si.main([]))\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( proc = subprocess.Popen(
[sys.executable, "-c", helper], [sys.executable, "-c", helper],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
@@ -504,7 +504,7 @@ class TestMainEndToEnd(unittest.TestCase):
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
proc.kill() proc.kill()
out_b, _ = proc.communicate() 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") return proc.returncode, out_b.decode("utf-8", errors="replace")
def test_sigterm_clean_shutdown(self): def test_sigterm_clean_shutdown(self):