test(sidecars): integration sweep for the bundle path (PRD 0024 chunk 4) #58
@@ -20,6 +20,17 @@
|
|||||||
|
|
||||||
set -e
|
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"
|
MODE="--mode regular@9099"
|
||||||
if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then
|
if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then
|
||||||
MODE="--mode upstream:$EGRESS_UPSTREAM_PROXY --listen-port 9099"
|
MODE="--mode upstream:$EGRESS_UPSTREAM_PROXY --listen-port 9099"
|
||||||
@@ -27,7 +38,7 @@ fi
|
|||||||
|
|
||||||
TRUST_FLAG=""
|
TRUST_FLAG=""
|
||||||
if [ -n "$EGRESS_UPSTREAM_CA" ] && [ -f "$EGRESS_UPSTREAM_CA" ]; then
|
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"
|
cat /etc/ssl/certs/ca-certificates.crt "$EGRESS_UPSTREAM_CA" > "$COMBINED"
|
||||||
TRUST_FLAG="--set ssl_verify_upstream_trusted_ca=$COMBINED"
|
TRUST_FLAG="--set ssl_verify_upstream_trusted_ca=$COMBINED"
|
||||||
fi
|
fi
|
||||||
@@ -46,4 +57,4 @@ if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then
|
|||||||
export NO_PROXY="localhost,127.0.0.1"
|
export NO_PROXY="localhost,127.0.0.1"
|
||||||
fi
|
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
|
# Order matters only for first-launch race-window reasons: egress
|
||||||
# starts first so pipelock's upstream connect succeeds during
|
# starts first so pipelock's upstream connect succeeds during
|
||||||
# pipelock's own startup. git-gate and supervise are independent.
|
# 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, ...] = (
|
_DAEMONS: tuple[_DaemonSpec, ...] = (
|
||||||
_DaemonSpec("egress", ("/bin/sh", "/app/egress-entrypoint.sh")),
|
_DaemonSpec("egress", ("/bin/sh", "/app/egress-entrypoint.sh")),
|
||||||
_DaemonSpec(
|
_DaemonSpec(
|
||||||
"pipelock",
|
"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("git-gate", ("/bin/sh", "/git-gate-entrypoint.sh")),
|
||||||
_DaemonSpec("supervise", ("python3", "/app/supervise_server.py")),
|
_DaemonSpec("supervise", ("python3", "/app/supervise_server.py")),
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
"""Integration: drive `apply_allowlist_change` against a real
|
"""Integration: drive `apply_allowlist_change` against a real
|
||||||
pipelock sidecar (PRD 0015).
|
pipelock sidecar (PRD 0015).
|
||||||
|
|
||||||
Brings up a real pipelock sidecar (via the production DockerPipelockProxy
|
Brings up a real pipelock container via direct `docker run` (the
|
||||||
bring-up), calls apply_allowlist_change to swap the api_allowlist,
|
old `.start()` helper went away in PRD 0024 chunk 3), calls
|
||||||
restarts pipelock, and verifies the running container now serves the
|
apply_allowlist_change to swap the api_allowlist, restarts
|
||||||
new yaml.
|
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
|
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
|
one-shot pipelock container — that doesn't work in DinD, so the
|
||||||
skips under GITEA_ACTIONS the same way the existing pipelock smoke
|
test skips under GITEA_ACTIONS.
|
||||||
test does.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -23,12 +26,17 @@ import time
|
|||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from claude_bottle.backend.docker.bottle_state import pipelock_state_dir
|
||||||
from claude_bottle.backend.docker.network import (
|
from claude_bottle.backend.docker.network import (
|
||||||
network_create_egress,
|
network_create_egress,
|
||||||
network_create_internal,
|
network_create_internal,
|
||||||
network_remove,
|
network_remove,
|
||||||
)
|
)
|
||||||
from claude_bottle.backend.docker.pipelock import (
|
from claude_bottle.backend.docker.pipelock import (
|
||||||
|
PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||||
|
PIPELOCK_CA_KEY_IN_CONTAINER,
|
||||||
|
PIPELOCK_IMAGE,
|
||||||
|
PIPELOCK_PORT,
|
||||||
DockerPipelockProxy,
|
DockerPipelockProxy,
|
||||||
pipelock_container_name,
|
pipelock_container_name,
|
||||||
pipelock_tls_init,
|
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 "
|
"skipped under act_runner: pipelock_tls_init uses a host bind mount "
|
||||||
"that doesn't share fs with the runner container",
|
"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):
|
class TestPipelockApply(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.slug = f"cb-test-pla-{os.getpid()}-{int(time.time())}"
|
self.slug = f"cb-test-pla-{os.getpid()}-{int(time.time())}"
|
||||||
@@ -65,31 +68,71 @@ class TestPipelockApply(unittest.TestCase):
|
|||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
if self.sidecar_name:
|
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):
|
for n in (self.internal_net, self.egress_net):
|
||||||
if n:
|
if n:
|
||||||
network_remove(n)
|
network_remove(n)
|
||||||
shutil.rmtree(self.work_dir, ignore_errors=True)
|
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:
|
def _bring_up(self) -> None:
|
||||||
proxy = DockerPipelockProxy()
|
"""Replicates the pre-chunk-3 bring-up sequence (create on
|
||||||
prep = proxy.prepare(fixture_minimal().bottles["dev"], self.slug, self.work_dir)
|
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.internal_net = network_create_internal(self.slug)
|
||||||
self.egress_net = network_create_egress(self.slug)
|
self.egress_net = network_create_egress(self.slug)
|
||||||
ca_cert_host, ca_key_host = pipelock_tls_init(self.work_dir)
|
ca_cert_host, ca_key_host = pipelock_tls_init(state_dir)
|
||||||
plan = dataclasses.replace(
|
|
||||||
prep,
|
self.sidecar_name = pipelock_container_name(self.slug)
|
||||||
internal_network=self.internal_net,
|
subprocess.run(
|
||||||
egress_network=self.egress_net,
|
["docker", "create",
|
||||||
ca_cert_host_path=ca_cert_host,
|
"--name", self.sidecar_name,
|
||||||
ca_key_host_path=ca_key_host,
|
"--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)
|
subprocess.run(
|
||||||
self.assertEqual(pipelock_container_name(self.slug), self.sidecar_name)
|
["docker", "network", "connect", self.egress_net, self.sidecar_name],
|
||||||
# Wait until docker exec succeeds — the container is up but
|
check=True, capture_output=True,
|
||||||
# pipelock may still be initializing. fetch_current_yaml is
|
)
|
||||||
# itself a docker exec, so retrying it doubles as a readiness
|
subprocess.run(
|
||||||
# probe.
|
["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
|
deadline = time.monotonic() + 15.0
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
try:
|
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