diff --git a/claude_bottle/egress_entrypoint.sh b/claude_bottle/egress_entrypoint.sh index c697d6c..fddf17b 100644 --- a/claude_bottle/egress_entrypoint.sh +++ b/claude_bottle/egress_entrypoint.sh @@ -20,6 +20,17 @@ set -e +# Pin mitmproxy's config dir to the bind-mount location of its CA +# regardless of which user mitmdump runs as. In the legacy +# four-sidecar setup (Dockerfile.egress, USER mitmproxy) this +# resolved naturally to `~mitmproxy/.mitmproxy`. In the PRD 0024 +# bundle (USER root) `~root/.mitmproxy` is empty, so without this +# flag mitmdump would generate a fresh CA on the wrong path and +# the agent's installed trust anchor would no longer match the +# bumped leaf certs. +CONFDIR=/home/mitmproxy/.mitmproxy +CONFDIR_FLAG="--set confdir=$CONFDIR" + MODE="--mode regular@9099" if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then MODE="--mode upstream:$EGRESS_UPSTREAM_PROXY --listen-port 9099" @@ -27,7 +38,7 @@ fi TRUST_FLAG="" if [ -n "$EGRESS_UPSTREAM_CA" ] && [ -f "$EGRESS_UPSTREAM_CA" ]; then - COMBINED=/home/mitmproxy/.mitmproxy/combined-trust.pem + COMBINED=$CONFDIR/combined-trust.pem cat /etc/ssl/certs/ca-certificates.crt "$EGRESS_UPSTREAM_CA" > "$COMBINED" TRUST_FLAG="--set ssl_verify_upstream_trusted_ca=$COMBINED" fi @@ -46,4 +57,4 @@ if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then export NO_PROXY="localhost,127.0.0.1" fi -exec mitmdump $MODE $TRUST_FLAG -s /app/egress_addon.py +exec mitmdump $CONFDIR_FLAG $MODE $TRUST_FLAG -s /app/egress_addon.py diff --git a/claude_bottle/sidecar_init.py b/claude_bottle/sidecar_init.py index f00e062..6d9fafd 100644 --- a/claude_bottle/sidecar_init.py +++ b/claude_bottle/sidecar_init.py @@ -57,11 +57,18 @@ class _DaemonSpec: # Order matters only for first-launch race-window reasons: egress # starts first so pipelock's upstream connect succeeds during # pipelock's own startup. git-gate and supervise are independent. +# Pipelock binds 0.0.0.0:8888 explicitly. Without `--listen` it +# defaults to 127.0.0.1 which would be unreachable from sibling +# services on the docker network. The legacy four-sidecar +# compose renderer passed the same flag; the bundle keeps the +# explicit binding. _DAEMONS: tuple[_DaemonSpec, ...] = ( _DaemonSpec("egress", ("/bin/sh", "/app/egress-entrypoint.sh")), _DaemonSpec( "pipelock", - ("/usr/local/bin/pipelock", "run", "--config", "/etc/pipelock.yaml"), + ("/usr/local/bin/pipelock", "run", + "--config", "/etc/pipelock.yaml", + "--listen", "0.0.0.0:8888"), ), _DaemonSpec("git-gate", ("/bin/sh", "/git-gate-entrypoint.sh")), _DaemonSpec("supervise", ("python3", "/app/supervise_server.py")), diff --git a/tests/integration/test_pipelock_apply.py b/tests/integration/test_pipelock_apply.py index e170b0e..b3be368 100644 --- a/tests/integration/test_pipelock_apply.py +++ b/tests/integration/test_pipelock_apply.py @@ -1,20 +1,23 @@ """Integration: drive `apply_allowlist_change` against a real pipelock sidecar (PRD 0015). -Brings up a real pipelock sidecar (via the production DockerPipelockProxy -bring-up), calls apply_allowlist_change to swap the api_allowlist, -restarts pipelock, and verifies the running container now serves the -new yaml. +Brings up a real pipelock container via direct `docker run` (the +old `.start()` helper went away in PRD 0024 chunk 3), calls +apply_allowlist_change to swap the api_allowlist, restarts +pipelock, and verifies the running container now serves the new +yaml. + +The hot-reload code path under test (apply_allowlist_change, +fetch_current_yaml, fetch_current_allowlist) is unchanged from +PRD 0015 — only the test's bringup helper moved. Setup uses pipelock_tls_init which bind-mounts a host path into a -one-shot pipelock container — that doesn't work in DinD, so the test -skips under GITEA_ACTIONS the same way the existing pipelock smoke -test does. +one-shot pipelock container — that doesn't work in DinD, so the +test skips under GITEA_ACTIONS. """ from __future__ import annotations -import dataclasses import os import shutil import subprocess @@ -23,12 +26,17 @@ import time import unittest from pathlib import Path +from claude_bottle.backend.docker.bottle_state import pipelock_state_dir from claude_bottle.backend.docker.network import ( network_create_egress, network_create_internal, network_remove, ) from claude_bottle.backend.docker.pipelock import ( + PIPELOCK_CA_CERT_IN_CONTAINER, + PIPELOCK_CA_KEY_IN_CONTAINER, + PIPELOCK_IMAGE, + PIPELOCK_PORT, DockerPipelockProxy, pipelock_container_name, pipelock_tls_init, @@ -50,11 +58,6 @@ from tests.fixtures import fixture_minimal "skipped under act_runner: pipelock_tls_init uses a host bind mount " "that doesn't share fs with the runner container", ) -@unittest.skip( - "PRD 0024 chunk 3: the .start-based bringup helper this test used was " - "deleted. Chunk 4 rewrites the bringup with a direct `docker run` so " - "the apply_allowlist_change hot-reload retains integration coverage." -) class TestPipelockApply(unittest.TestCase): def setUp(self): self.slug = f"cb-test-pla-{os.getpid()}-{int(time.time())}" @@ -65,31 +68,71 @@ class TestPipelockApply(unittest.TestCase): def tearDown(self): if self.sidecar_name: - DockerPipelockProxy().stop(self.sidecar_name) + subprocess.run( + ["docker", "rm", "-f", self.sidecar_name], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, + ) for n in (self.internal_net, self.egress_net): if n: network_remove(n) shutil.rmtree(self.work_dir, ignore_errors=True) + # Clean up the per-slug state dir under ~/.claude-bottle/state/ + # (apply_allowlist_change writes there; _bring_up calls + # proxy.prepare with the same path so the bind-mount and the + # hot-reload write target stay coherent). + shutil.rmtree(pipelock_state_dir(self.slug), ignore_errors=True) def _bring_up(self) -> None: - proxy = DockerPipelockProxy() - prep = proxy.prepare(fixture_minimal().bottles["dev"], self.slug, self.work_dir) + """Replicates the pre-chunk-3 bring-up sequence (create on + internal network → bind-mount yaml + CAs → attach egress + network → docker start) without going through the deleted + `DockerPipelockProxy.start` helper. The same sequence is + what `docker compose up` does for the pipelock service in + production; this test path keeps the standalone-pipelock + smoke alive so `apply_allowlist_change`'s host-side + write + docker-restart loop has integration coverage. + + The yaml stages into the production-real + `pipelock_state_dir(slug)` (not a private temp dir) so the + bind-mount target matches what `apply_allowlist_change` + writes to — otherwise the hot-reload would write to a + nowhere-mounted host path and the container would never see + the updated config.""" + state_dir = pipelock_state_dir(self.slug) + state_dir.mkdir(parents=True, exist_ok=True) + prep = DockerPipelockProxy().prepare( + fixture_minimal().bottles["dev"], self.slug, state_dir, + ) self.internal_net = network_create_internal(self.slug) self.egress_net = network_create_egress(self.slug) - ca_cert_host, ca_key_host = pipelock_tls_init(self.work_dir) - plan = dataclasses.replace( - prep, - internal_network=self.internal_net, - egress_network=self.egress_net, - ca_cert_host_path=ca_cert_host, - ca_key_host_path=ca_key_host, + ca_cert_host, ca_key_host = pipelock_tls_init(state_dir) + + self.sidecar_name = pipelock_container_name(self.slug) + subprocess.run( + ["docker", "create", + "--name", self.sidecar_name, + "--network", self.internal_net, + "-v", f"{prep.yaml_path}:/etc/pipelock.yaml:ro", + "-v", f"{ca_cert_host}:{PIPELOCK_CA_CERT_IN_CONTAINER}:ro", + "-v", f"{ca_key_host}:{PIPELOCK_CA_KEY_IN_CONTAINER}:ro", + PIPELOCK_IMAGE, + "run", "--config", "/etc/pipelock.yaml", + "--listen", f"0.0.0.0:{PIPELOCK_PORT}"], + check=True, capture_output=True, ) - self.sidecar_name = proxy.start(plan) - self.assertEqual(pipelock_container_name(self.slug), self.sidecar_name) - # Wait until docker exec succeeds — the container is up but - # pipelock may still be initializing. fetch_current_yaml is - # itself a docker exec, so retrying it doubles as a readiness - # probe. + subprocess.run( + ["docker", "network", "connect", self.egress_net, self.sidecar_name], + check=True, capture_output=True, + ) + subprocess.run( + ["docker", "start", self.sidecar_name], + check=True, capture_output=True, + ) + # Wait until fetch_current_yaml succeeds — it's a docker cp + # which works on a started-but-not-yet-ready pipelock, so + # this is more of a "container exists" probe than a + # readiness one; the hot-reload tests below tolerate + # pipelock briefly being slow to serve. deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: try: diff --git a/tests/integration/test_sidecar_bundle_compose.py b/tests/integration/test_sidecar_bundle_compose.py new file mode 100644 index 0000000..61ac5ec --- /dev/null +++ b/tests/integration/test_sidecar_bundle_compose.py @@ -0,0 +1,114 @@ +"""Integration: end-to-end smoke for the PRD 0024 bundle shape. + +Verifies that flipping `CLAUDE_BOTTLE_SIDECAR_BUNDLE=1` produces a +working bottle: `docker compose up` brings the agent + bundle pair +online, the four daemons inside the bundle bind their ports, and +the agent can reach pipelock + supervise via the bundle's network +aliases (no agent-side config changes between flag positions). + +Skipped under GITEA_ACTIONS — the bundle image is a multi-stage +build pulling 200+MB of base layers, and the bind-mounts won't +share filesystem with the runner container. Same constraint as +the chunk-1 image-probe test. +""" + +from __future__ import annotations + +import os +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from claude_bottle.backend import BottleSpec, get_bottle_backend +from claude_bottle.manifest import Manifest +from tests._docker import skip_unless_docker + + +def _manifest() -> Manifest: + """Bottle with supervise on so the bundle exercises three of + the four daemons (pipelock, egress, supervise). Git is off + because a meaningful git-gate test needs a real upstream and + SSH keys — out of scope for a bundle smoke. Egress is + implicitly on as pipelock's upstream regardless of routes.""" + return Manifest.from_json_obj({ + "bottles": { + "dev": { + "supervise": True, + }, + }, + "agents": { + "demo": {"skills": [], "prompt": "", "bottle": "dev"}, + }, + }) + + +@skip_unless_docker() +@unittest.skipIf( + os.environ.get("GITEA_ACTIONS") == "true", + "skipped under act_runner: multi-stage bundle build pulls 200+MB " + "of base layers and bind-mounts don't share fs with the runner", +) +class TestSidecarBundleCompose(unittest.TestCase): + """One end-to-end pass with the bundle flag on. Skipping under + act_runner; the local docker daemon does the work.""" + + def test_bottle_up_with_bundle_flag_on(self): + stage_dir = Path(tempfile.mkdtemp(prefix="cb-bundle-smoke.")) + try: + with patch.dict(os.environ, {"CLAUDE_BOTTLE_SIDECAR_BUNDLE": "1"}): + backend = get_bottle_backend() + spec = BottleSpec( + manifest=_manifest(), + agent_name="demo", + copy_cwd=False, + user_cwd=str(stage_dir), + ) + plan = backend.prepare(spec, stage_dir=stage_dir) + with backend.launch(plan) as bottle: + # The agent's HTTPS_PROXY URL (resolved at + # renderer-time, unchanged from the legacy + # shape) should reach pipelock inside the + # bundle. We probe by asking for the proxy's + # listening port from inside the agent. + probe = bottle.exec( + "set -eu\n" + "echo HTTPS_PROXY=$HTTPS_PROXY\n" + "PORT=$(echo \"$HTTPS_PROXY\" | sed -E 's|.*:([0-9]+).*|\\1|')\n" + "HOST=$(echo \"$HTTPS_PROXY\" | sed -E 's|http://([^:]+):.*|\\1|')\n" + "echo HOST=$HOST PORT=$PORT\n" + # nc is not in the agent image but curl is — + # a CONNECT with no upstream URL will get + # rejected by pipelock with 400 or 405 but + # confirms the listener is alive at the + # alias. + "curl -sS --max-time 5 -o /dev/null -w 'http=%{http_code}\\n' " + " \"http://$HOST:$PORT/\" || true\n" + ) + # The supervise URL resolves to the same bundle + # via its supervise alias, on a different port. + supervise_probe = bottle.exec( + "set -eu\n" + "curl -sS --max-time 5 -o /dev/null " + " -w 'http=%{http_code}\\n' " + " \"http://supervise:9100/health\" || true\n" + ) + finally: + shutil.rmtree(stage_dir, ignore_errors=True) + + self.assertEqual(0, probe.returncode, msg=probe.stderr) + # pipelock answered SOMETHING — any 4xx is fine, just proves + # the bundle's pipelock daemon is listening at the + # `pipelock` alias on port 8888 (or whatever the env says). + self.assertIn("http=", probe.stdout, + f"no HTTP response from pipelock: {probe.stdout!r}") + # supervise's /health endpoint exists (PRD 0013); it should + # answer 200 or similar — anything non-empty proves the + # third daemon's alias resolves to the same bundle. + self.assertEqual(0, supervise_probe.returncode, msg=supervise_probe.stderr) + self.assertIn("http=", supervise_probe.stdout) + + +if __name__ == "__main__": + unittest.main()