test(sidecars): integration sweep for the bundle path (PRD 0024 chunk 4)
Three deliverables:
1. Rewrite test_pipelock_apply bringup with a direct `docker run`.
Replaces the .start-based bringup deleted in chunk 3. Stages
the yaml + CAs to the real pipelock_state_dir so the bind-
mount target matches what apply_allowlist_change writes to —
the legacy .start path did this implicitly because it lived
inside the production flow; the new bringup needs to be
explicit about the path. All 4 cases pass.
2. New tests/integration/test_sidecar_bundle_compose.py: end-
to-end smoke with CLAUDE_BOTTLE_SIDECAR_BUNDLE=1. Brings up
a real bottle via the compose path and verifies the agent
can reach pipelock + supervise through the bundle's legacy
aliases (no agent-side config changes between flag positions).
Skipped under act_runner — multi-stage build + bind mounts.
3. Two bundle-path bugs surfaced and fixed while running PRD
0022 with the flag on:
- egress_entrypoint.sh: add `--set confdir=/home/mitmproxy/
.mitmproxy` so mitmdump finds the bind-mounted CA. The
legacy Dockerfile.egress runs as user mitmproxy (~mitmproxy
resolves correctly); the bundle runs as root and otherwise
would look in /root/.mitmproxy/ and mint a NEW CA the agent
doesn't trust. Symptom: PRD 0022 attack-3 curl failed with
"unable to get local issuer certificate".
- sidecar_init.py: add `--listen 0.0.0.0:8888` to pipelock's
argv. Without it pipelock defaults to 127.0.0.1, so the
in-bundle egress's upstream connect to the
`claude-bottle-pipelock-<slug>` alias arrives over the
docker network and gets refused. The legacy renderer
passed this flag verbatim; the bundle dropped it. Symptom:
egress returned HTTP 502 with "Connect call failed
('172.x.x.x', 8888)".
PRD 0022's 5-attack sandbox-escape suite now passes with the
bundle flag on AND off.
Test status:
- Unit: 533 passing.
- Integration: 9 passing locally with flag off, 5 passing with
flag on. Bundle compose smoke + PRD 0022 sandbox-escape both
green under CLAUDE_BOTTLE_SIDECAR_BUNDLE=1.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user