test(sidecars): integration sweep for the bundle path (PRD 0024 chunk 4) #58
@@ -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
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user