feat(compose): bundle shape behind feature flag (PRD 0024 chunk 2) #56

Merged
didericis merged 1 commits from prd-0024-chunk-2-renderer-collapse into main 2026-05-27 00:46:51 -04:00
4 changed files with 437 additions and 15 deletions
+163 -15
View File
@@ -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-<service>-<slug>` 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
@@ -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-<slug>`. 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")
+14
View File
@@ -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
+208
View File
@@ -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."""