test(sidecars): integration sweep for the bundle path (PRD 0024 chunk 4) #58

Merged
didericis merged 1 commits from prd-0024-chunk-4-integration-tests into main 2026-05-27 01:18:51 -04:00
4 changed files with 207 additions and 32 deletions
+13 -2
View File
@@ -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
+8 -1
View File
@@ -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")),
+72 -29
View File
@@ -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:
@@ -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()