From a1180adec151960232c1bf7e36ff113d0acb54ea Mon Sep 17 00:00:00 2001 From: didericis Date: Wed, 27 May 2026 00:43:08 -0400 Subject: [PATCH] feat(compose): emit bundle shape behind feature flag (PRD 0024 chunk 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docker backend's compose renderer now emits a single `sidecars` service in place of the four per-sidecar services when CLAUDE_BOTTLE_SIDECAR_BUNDLE is truthy. Default (unset/0/ false) keeps the legacy five-service shape so existing operators don't have to migrate atomically; chunks 4-5 flip the default and delete the flag. New module claude_bottle/backend/docker/sidecar_bundle.py owns the bundle image constant (CLAUDE_BOTTLE_SIDECAR_IMAGE env var override + claude-bottle-sidecars:latest default), the Dockerfile reference, the container-name helper, and the flag-parser. The bundle service: - joins both internal + egress networks with aliases for every legacy shortname + per-slug long form so the agent's HTTPS_PROXY URL (which dials `egress` or `claude-bottle-pipelock-`) keeps resolving with no agent-side change - carries CLAUDE_BOTTLE_SIDECAR_DAEMONS= for the init supervisor to narrow which daemons to start - carries the union of the four prior services' daemon-private env vars (EGRESS_UPSTREAM_PROXY, SUPERVISE_*, token env names) - does NOT carry HTTPS_PROXY/HTTP_PROXY/NO_PROXY — those would route git-gate's git fetches through pipelock by mistake - union'd bind-mounts at the same in-container paths as before HTTPS_PROXY scoping moved into egress_entrypoint.sh so only mitmdump's subprocess sees it. In the legacy four-sidecar shape the env vars also lived in the egress service's compose env; the shell script's export is additionally defensive. Tests: - All 44 existing TestCompose cases pass unchanged (flag off → legacy shape). - 20 new TestSidecarBundleShape cases assert on the bundle's services / aliases / env / volumes / depends_on under the flag. - 8 new TestSidecarBundleFlag cases lock down the env-var parser (unset / 0 / false / no / off → disabled; everything else → enabled). Co-Authored-By: Claude Opus 4.7 --- claude_bottle/backend/docker/compose.py | 178 +++++++++++++-- .../backend/docker/sidecar_bundle.py | 52 +++++ claude_bottle/egress_entrypoint.sh | 14 ++ tests/unit/test_compose.py | 208 ++++++++++++++++++ 4 files changed, 437 insertions(+), 15 deletions(-) create mode 100644 claude_bottle/backend/docker/sidecar_bundle.py diff --git a/claude_bottle/backend/docker/compose.py b/claude_bottle/backend/docker/compose.py index 227c030..021ad58 100644 --- a/claude_bottle/backend/docker/compose.py +++ b/claude_bottle/backend/docker/compose.py @@ -83,6 +83,12 @@ from .pipelock import ( pipelock_container_name, ) from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH +from .sidecar_bundle import ( + SIDECAR_BUNDLE_DOCKERFILE, + SIDECAR_BUNDLE_IMAGE, + sidecar_bundle_container_name, + sidecar_bundle_enabled, +) from .supervise import ( SUPERVISE_DOCKERFILE, SUPERVISE_IMAGE, @@ -109,16 +115,24 @@ def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]: project = f"claude-bottle-{plan.slug}" services: dict[str, Any] = {} - services["pipelock"] = _pipelock_service(plan) + if sidecar_bundle_enabled(): + # PRD 0024 bundle shape: one `sidecars` service running all + # four daemons under the bundle image's init supervisor. + services["sidecars"] = _sidecar_bundle_service(plan) + else: + # Legacy four-sidecar shape. Kept side-by-side behind the + # flag through chunks 2-4 so existing operators don't have + # to migrate atomically. + services["pipelock"] = _pipelock_service(plan) - if plan.git_gate_plan.upstreams: - services["git-gate"] = _git_gate_service(plan) + if plan.git_gate_plan.upstreams: + services["git-gate"] = _git_gate_service(plan) - if plan.egress_plan.routes: - services["egress"] = _egress_service(plan) + if plan.egress_plan.routes: + services["egress"] = _egress_service(plan) - if plan.supervise_plan is not None: - services["supervise"] = _supervise_service(plan) + if plan.supervise_plan is not None: + services["supervise"] = _supervise_service(plan) services["agent"] = _agent_service(plan) @@ -185,6 +199,134 @@ def _pipelock_service(plan: DockerBottlePlan) -> dict[str, Any]: } +def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]: + """The single `sidecars` service that replaces the four + per-sidecar containers (PRD 0024). One container per bottle, + bundle image, all four daemons under a Python init supervisor. + + Mechanics: + + - Daemon subset narrows via `CLAUDE_BOTTLE_SIDECAR_DAEMONS` + env. pipelock is always present; egress / git-gate / + supervise are conditional on the plan, identical to the + legacy branching. + - Volumes are the UNION of what the four prior services + bind-mounted, preserving the same in-container paths so + every daemon finds its config / hooks / CA where it + expects. + - Environment is the union of *daemon-private* env vars + (EGRESS_UPSTREAM_PROXY, SUPERVISE_BOTTLE_SLUG, etc). + HTTPS_PROXY is NOT propagated here — see the comment in + egress_entrypoint.sh; setting it at the container level + would route git-gate's git fetches through pipelock, + which is wrong. + - Network aliases register every legacy short/long + hostname (pipelock, egress, git-gate, supervise plus + their `claude-bottle--` long forms) so + any existing inter-service reference (notably the + agent's HTTPS_PROXY and depends_on lookups) resolves to + the bundle. + """ + daemons: list[str] = ["egress", "pipelock"] + if plan.git_gate_plan.upstreams: + daemons.append("git-gate") + if plan.supervise_plan is not None: + daemons.append("supervise") + + env: list[str] = [f"CLAUDE_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"] + volumes: list[dict[str, Any]] = [] + + # --- pipelock ---------------------------------------------------- + pp = plan.proxy_plan + volumes += [ + _bind(pp.yaml_path, "/etc/pipelock.yaml"), + _bind(pp.ca_cert_host_path, PIPELOCK_CA_CERT_IN_CONTAINER), + _bind(pp.ca_key_host_path, PIPELOCK_CA_KEY_IN_CONTAINER), + ] + + # --- egress (always part of the bundle; the EGRESS_UPSTREAM_* + # env vars + ca bind-mounts are needed iff routes exist; when + # the bottle has no routes the egress daemon falls back to its + # `regular@9099` mode and is unused) ----------------------------- + ep = plan.egress_plan + if ep.routes: + env.append(f"EGRESS_UPSTREAM_PROXY={ep.pipelock_proxy_url}") + env.append(f"EGRESS_UPSTREAM_CA={EGRESS_PIPELOCK_CA_IN_CONTAINER}") + volumes += [ + _bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER), + _bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER), + _bind(ep.pipelock_ca_host_path, EGRESS_PIPELOCK_CA_IN_CONTAINER), + ] + for token_env in sorted(ep.token_env_map.keys()): + env.append(token_env) + + # --- git-gate ---------------------------------------------------- + extra_hosts: list[str] = [] + 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", + )) + extra_map = git_gate_aggregate_extra_hosts(gp.upstreams) + extra_hosts = [f"{host}:{ip}" for host, ip in sorted(extra_map.items())] + + # --- supervise --------------------------------------------------- + sp = plan.supervise_plan + if sp is not None: + env += [ + f"SUPERVISE_BOTTLE_SLUG={plan.slug}", + f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}", + f"SUPERVISE_PORT={SUPERVISE_PORT}", + ] + volumes.append({ + "type": "bind", + "source": str(sp.queue_dir), + "target": QUEUE_DIR_IN_CONTAINER, + "read_only": False, + }) + + # Internal-network aliases: every shortname + long-form legacy + # name routes to the bundle so the agent's HTTPS_PROXY URL + # (which references either `pipelock` or `egress`) keeps + # resolving without an agent-side change. + internal_aliases = [ + pipelock_container_name(plan.slug), + EGRESS_HOSTNAME, + egress_container_name(plan.slug), + ] + if gp.upstreams: + internal_aliases.append(git_gate_container_name(plan.slug)) + if sp is not None: + internal_aliases.append(SUPERVISE_HOSTNAME) + internal_aliases.append(supervise_container_name(plan.slug)) + + 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, + } + if extra_hosts: + service["extra_hosts"] = extra_hosts + return service + + def _git_gate_service(plan: DockerBottlePlan) -> dict[str, Any]: """Git-gate sidecar. Built from Dockerfile.git-gate. Entrypoint + pre-receive hook + access-hook bind-mount from the stage @@ -352,14 +494,20 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]: if volumes: service["volumes"] = volumes - depends_on = ["pipelock"] - if plan.git_gate_plan.upstreams: - depends_on.append("git-gate") - if plan.egress_plan.routes: - depends_on.append("egress") - if plan.supervise_plan is not None: - depends_on.append("supervise") - service["depends_on"] = depends_on + if sidecar_bundle_enabled(): + # Bundle shape: a single dependency. The init supervisor + # owns intra-bundle daemon ordering, so the agent only + # waits for the bundle container itself. + service["depends_on"] = ["sidecars"] + else: + depends_on = ["pipelock"] + if plan.git_gate_plan.upstreams: + depends_on.append("git-gate") + if plan.egress_plan.routes: + depends_on.append("egress") + if plan.supervise_plan is not None: + depends_on.append("supervise") + service["depends_on"] = depends_on return service diff --git a/claude_bottle/backend/docker/sidecar_bundle.py b/claude_bottle/backend/docker/sidecar_bundle.py new file mode 100644 index 0000000..b8dafe9 --- /dev/null +++ b/claude_bottle/backend/docker/sidecar_bundle.py @@ -0,0 +1,52 @@ +"""Sidecar bundle constants + helpers for the Docker backend +(PRD 0024 chunk 2). + +The bundle image (built by Dockerfile.sidecars, see PRD 0024 +chunk 1) collapses pipelock + egress + git-gate + supervise into +one container per bottle. Whether the renderer emits the bundle +shape (one `sidecars` service) or the legacy four-sidecar shape +is controlled by `CLAUDE_BOTTLE_SIDECAR_BUNDLE`; chunk 2 ships +both shapes side by side behind the flag so existing operators +keep working unchanged while the bundle path soaks. + +This module is intentionally tiny — just the constants + the +flag + the container-name helper. The compose-renderer branch +that consumes it lives in `compose.py`. +""" + +from __future__ import annotations + +import os + + +# 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, matching +# the existing `CLAUDE_BOTTLE_PIPELOCK_IMAGE` shape. +SIDECAR_BUNDLE_IMAGE = os.environ.get( + "CLAUDE_BOTTLE_SIDECAR_IMAGE", + "claude-bottle-sidecars:latest", +) + +SIDECAR_BUNDLE_DOCKERFILE = "Dockerfile.sidecars" + + +def sidecar_bundle_container_name(slug: str) -> str: + """`claude-bottle-sidecars-`. Same prefix scheme as the + per-sidecar containers it replaces, so the dashboard's + discovery-by-prefix logic keeps working.""" + return f"claude-bottle-sidecars-{slug}" + + +def sidecar_bundle_enabled(env: dict[str, str] | None = None) -> bool: + """Feature-flag check. The flag is opt-in for chunk 2: + unset / "" / "0" / "false" → legacy four-sidecar shape; + anything else → bundle shape. Chunks 4-5 flip the default and + then delete the flag. + + `env` defaults to `os.environ` at call time so tests can + monkey-patch the environment without re-importing the module.""" + if env is None: + env = dict(os.environ) + raw = env.get("CLAUDE_BOTTLE_SIDECAR_BUNDLE", "").strip().lower() + return raw not in ("", "0", "false", "no", "off") diff --git a/claude_bottle/egress_entrypoint.sh b/claude_bottle/egress_entrypoint.sh index 6a4b674..c697d6c 100644 --- a/claude_bottle/egress_entrypoint.sh +++ b/claude_bottle/egress_entrypoint.sh @@ -32,4 +32,18 @@ if [ -n "$EGRESS_UPSTREAM_CA" ] && [ -f "$EGRESS_UPSTREAM_CA" ]; then TRUST_FLAG="--set ssl_verify_upstream_trusted_ca=$COMBINED" fi +# Scope the proxy env to this process tree only. In the bundle +# image (PRD 0024) the four daemons share one container — setting +# HTTPS_PROXY at the container level would route git-gate's git +# pushes through pipelock, which is wrong (pipelock doesn't proxy +# SSH and would block public git repos). Setting them here means +# only mitmdump's subprocess inherits them. In the legacy +# four-sidecar setup these env vars are also set in compose; here +# they're additionally defensive. +if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then + export HTTPS_PROXY="$EGRESS_UPSTREAM_PROXY" + export HTTP_PROXY="$EGRESS_UPSTREAM_PROXY" + export NO_PROXY="localhost,127.0.0.1" +fi + exec mitmdump $MODE $TRUST_FLAG -s /app/egress_addon.py diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index 2a6050b..b2949fa 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -455,6 +455,214 @@ class TestFullMatrix(unittest.TestCase): self.assertEqual(expected, set(s.keys())) +class TestSidecarBundleShape(unittest.TestCase): + """PRD 0024 chunk 2: with CLAUDE_BOTTLE_SIDECAR_BUNDLE=1 set, + the renderer emits a `sidecars` service in place of the four + per-sidecar services. The legacy four-sidecar tests above + cover the flag-off shape; these lock down the flag-on shape.""" + + def _render(self, **plan_kwargs): + from unittest.mock import patch + with patch.dict("os.environ", {"CLAUDE_BOTTLE_SIDECAR_BUNDLE": "1"}): + return bottle_plan_to_compose(_plan(**plan_kwargs)) + + 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("claude-bottle-sidecars:latest", sc["image"]) + self.assertEqual("Dockerfile.sidecars", sc["build"]["dockerfile"]) + + def test_bundle_container_name_uses_sidecars_prefix(self): + sc = self._render()["services"]["sidecars"] + self.assertEqual(f"claude-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_cover_pipelock_and_egress_shortnames(self): + # The agent's HTTPS_PROXY url references either `egress` or + # `pipelock` (long form). Both must resolve to the bundle. + sc = self._render()["services"]["sidecars"] + aliases = set(sc["networks"]["internal"]["aliases"]) + self.assertIn("egress", aliases) + self.assertIn(f"claude-bottle-pipelock-{SLUG}", aliases) + self.assertIn(f"claude-bottle-egress-{SLUG}", 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(f"claude-bottle-git-gate-{SLUG}", 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(f"claude-bottle-git-gate-{SLUG}", aliases) + self.assertIn("supervise", aliases) + self.assertIn(f"claude-bottle-supervise-{SLUG}", aliases) + + def test_daemons_csv_lists_only_active(self): + # Egress + pipelock are always in the daemon set even when + # the bottle has no routes (egress falls back to regular@9099 + # and is just unused; cheaper than special-casing). + sc = self._render()["services"]["sidecars"] + daemons = { + line.split("=", 1)[1] + for line in sc["environment"] + if line.startswith("CLAUDE_BOTTLE_SIDECAR_DAEMONS=") + } + self.assertEqual({"egress,pipelock"}, 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("CLAUDE_BOTTLE_SIDECAR_DAEMONS="): + csv = line.split("=", 1)[1] + break + else: + self.fail("CLAUDE_BOTTLE_SIDECAR_DAEMONS not in env") + self.assertEqual( + ["egress", "pipelock", "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 pipelock. 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_env_present_when_routes_declared(self): + sc = self._render(with_egress=True)["services"]["sidecars"] + env_strings = sc["environment"] + self.assertTrue(any( + e.startswith("EGRESS_UPSTREAM_PROXY=") for e in env_strings)) + self.assertTrue(any( + e.startswith("EGRESS_UPSTREAM_CA=") for e in env_strings)) + # Token env name is forwarded as a bare entry. + self.assertIn("EGRESS_TOKEN_0", env_strings) + + def test_egress_env_omitted_when_no_routes(self): + sc = self._render()["services"]["sidecars"] + env_strings = sc["environment"] + for e in env_strings: + self.assertFalse(e.startswith("EGRESS_UPSTREAM_PROXY=")) + self.assertFalse(e.startswith("EGRESS_UPSTREAM_CA=")) + + 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.assertTrue(any(e.startswith("SUPERVISE_QUEUE_DIR=") for e in env_strings)) + self.assertTrue(any(e.startswith("SUPERVISE_PORT=") for e in env_strings)) + + def test_volumes_union_minimal_includes_pipelock(self): + sc = self._render()["services"]["sidecars"] + targets = {v["target"] for v in sc["volumes"]} + self.assertIn("/etc/pipelock.yaml", 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"]} + # Pipelock + egress + git-gate + supervise paths all + # present. + self.assertIn("/etc/pipelock.yaml", targets) + self.assertIn("/etc/egress/routes.yaml", targets) + self.assertIn("/git-gate-entrypoint.sh", targets) + # supervise queue dir target = QUEUE_DIR_IN_CONTAINER + self.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise") + for t in targets)) + + def test_extra_hosts_emitted_for_git_upstreams(self): + sc = self._render(with_git=True)["services"]["sidecars"] + self.assertIn("example.com:10.0.0.1", sc.get("extra_hosts", [])) + + def test_extra_hosts_omitted_when_no_git(self): + sc = self._render()["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 TestSidecarBundleFlag(unittest.TestCase): + """The flag's parser: covers the cases sidecar_bundle_enabled + has to interpret correctly so an operator-supplied value lands + in the expected shape.""" + + def _enabled(self, value: str | None) -> bool: + from claude_bottle.backend.docker.sidecar_bundle import ( + sidecar_bundle_enabled, + ) + env: dict[str, str] = {} + if value is not None: + env["CLAUDE_BOTTLE_SIDECAR_BUNDLE"] = value + return sidecar_bundle_enabled(env) + + def test_unset_disabled(self): + self.assertFalse(self._enabled(None)) + + def test_empty_disabled(self): + self.assertFalse(self._enabled("")) + + def test_zero_disabled(self): + self.assertFalse(self._enabled("0")) + + def test_false_disabled(self): + self.assertFalse(self._enabled("false")) + self.assertFalse(self._enabled("FALSE")) + + def test_no_disabled(self): + self.assertFalse(self._enabled("no")) + self.assertFalse(self._enabled("off")) + + def test_one_enabled(self): + self.assertTrue(self._enabled("1")) + + def test_true_enabled(self): + self.assertTrue(self._enabled("true")) + + def test_arbitrary_truthy_enabled(self): + # The flag treats anything other than the known falsy + # values as truthy — operator typos default to "enabled" + # which is the safer interpretation for an opt-in flag. + self.assertTrue(self._enabled("yes")) + + class TestProjectNaming(unittest.TestCase): """The slug ↔ compose-project mapping is the contract dashboard, cleanup, and launch all rely on. Lock it down."""