From c10d1cb6e01d5302803277c673e534734ea17c96 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 14 Jul 2026 16:46:46 -0400 Subject: [PATCH] =?UTF-8?q?refactor(de-sidecar):=20rename=20sidecar=5Finit?= =?UTF-8?q?=E2=86=92gateway=5Finit,=20drop=20docker's=20dead=20per-bottle?= =?UTF-8?q?=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):