62f6f8db34
The CLAUDE_BOTTLE_SIDECAR_BUNDLE feature flag is gone. Every
bottle ships with the agent + bundle pair — no opt-in, no legacy
four-sidecar fallback.
Changes:
- Renderer (compose.py): bottle_plan_to_compose unconditionally
emits {agent, sidecars}. Deleted _pipelock_service,
_git_gate_service, _egress_service, _supervise_service helpers.
_agent_service.depends_on collapses to ["sidecars"].
- sidecar_bundle.py: deleted sidecar_bundle_enabled (the flag
parser). SIDECAR_BUNDLE_IMAGE + container-name helper stay.
- pipelock_apply.py: docker cp + docker restart now target
sidecar_bundle_container_name(slug). Bundle restart bounces
all four daemons together (per-daemon reload is the eventual
feature, not v1).
- Per-sidecar modules trimmed:
- egress.py: dropped EGRESS_IMAGE, EGRESS_DOCKERFILE,
build_egress_image, egress_url. Kept EGRESS_PORT, CA paths,
egress_container_name (still used by the renderer's network
aliases).
- git_gate.py: dropped GIT_GATE_IMAGE, GIT_GATE_DOCKERFILE,
build_git_gate_image. Kept git_gate_host + GIT_GATE_PORT.
- supervise.py: dropped SUPERVISE_IMAGE, SUPERVISE_DOCKERFILE,
build_supervise_image, supervise_url.
- Deleted Dockerfile.{egress,git-gate,supervise}. The bundle's
Dockerfile.sidecars is the only sidecar image now.
- test_compose.py: deleted TestPipelockAlwaysPresent,
TestConditionalGitGate, TestConditionalEgress,
TestConditionalSupervise, TestFullMatrix (legacy-shape only),
TestSidecarBundleFlag (flag is gone). TestSidecarBundleShape
drops its patch.dict wrapper. TestAgentAlwaysPresent's
depends_on cases collapse to one.
- test_pipelock_apply.py: bringup container name uses
sidecar_bundle_container_name(slug) to match the production
target.
- README.md Architecture section rewritten to describe the
agent + bundle pair.
Net: -626 lines.
Test status: 498 unit + 27 integration + 1 skipped (chunk-4
pending — superseded by this chunk's rewrite). Locally verified
end-to-end bottle launch produces exactly 2 containers
(claude-bottle-<slug> + claude-bottle-sidecars-<slug>).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
210 lines
8.4 KiB
Python
210 lines
8.4 KiB
Python
"""Integration: drive `apply_allowlist_change` against a real
|
|
pipelock sidecar (PRD 0015).
|
|
|
|
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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
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_tls_init,
|
|
)
|
|
from claude_bottle.backend.docker.pipelock_apply import (
|
|
PipelockApplyError,
|
|
apply_allowlist_change,
|
|
fetch_current_allowlist,
|
|
fetch_current_yaml,
|
|
)
|
|
from claude_bottle.backend.docker.sidecar_bundle import (
|
|
sidecar_bundle_container_name,
|
|
)
|
|
from claude_bottle.yaml_subset import parse_yaml_subset
|
|
from tests._docker import skip_unless_docker
|
|
from tests.fixtures import fixture_minimal
|
|
|
|
|
|
@skip_unless_docker()
|
|
@unittest.skipIf(
|
|
os.environ.get("GITEA_ACTIONS") == "true",
|
|
"skipped under act_runner: pipelock_tls_init uses a host bind mount "
|
|
"that doesn't share fs with the runner container",
|
|
)
|
|
class TestPipelockApply(unittest.TestCase):
|
|
def setUp(self):
|
|
self.slug = f"cb-test-pla-{os.getpid()}-{int(time.time())}"
|
|
self.sidecar_name = ""
|
|
self.internal_net = ""
|
|
self.egress_net = ""
|
|
self.work_dir = Path(tempfile.mkdtemp(prefix="pipelock-apply."))
|
|
|
|
def tearDown(self):
|
|
if 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:
|
|
"""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(state_dir)
|
|
|
|
# apply_allowlist_change targets sidecar_bundle_container_name
|
|
# (chunk 5 flipped the bundle to the only shape). Bringing the
|
|
# standalone pipelock up under that name keeps this test
|
|
# exercising the real production code path; the bundle's
|
|
# other three daemons aren't running here, but the
|
|
# apply/fetch code only touches /etc/pipelock.yaml + the
|
|
# pipelock binary, so the lighter setup is fine.
|
|
self.sidecar_name = sidecar_bundle_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,
|
|
)
|
|
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:
|
|
fetch_current_yaml(self.slug)
|
|
return
|
|
except PipelockApplyError:
|
|
pass
|
|
time.sleep(0.25)
|
|
raise AssertionError("pipelock sidecar never became reachable")
|
|
|
|
def _wait_for_yaml(self, contains: str, *, deadline_s: float = 15.0) -> str:
|
|
"""Poll docker exec until /etc/pipelock.yaml contains `contains`,
|
|
returning the yaml. Used to bridge the docker-restart window."""
|
|
deadline = time.monotonic() + deadline_s
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
yaml = fetch_current_yaml(self.slug)
|
|
if contains in yaml:
|
|
return yaml
|
|
except PipelockApplyError:
|
|
pass
|
|
time.sleep(0.25)
|
|
self.fail(f"never saw {contains!r} in /etc/pipelock.yaml")
|
|
|
|
def test_apply_swaps_api_allowlist(self):
|
|
self._bring_up()
|
|
|
|
initial_yaml = fetch_current_yaml(self.slug)
|
|
# fixture_minimal yields the baked-in DEFAULT_ALLOWLIST in
|
|
# pipelock.py; api.anthropic.com is in there.
|
|
self.assertIn("api.anthropic.com", initial_yaml)
|
|
|
|
new_content = "api.anthropic.com\nnew-host.example\n"
|
|
before, after = apply_allowlist_change(self.slug, new_content)
|
|
self.assertIn("api.anthropic.com", before)
|
|
self.assertNotIn("new-host.example", before)
|
|
self.assertIn("new-host.example", after)
|
|
|
|
updated = self._wait_for_yaml("new-host.example")
|
|
cfg = parse_yaml_subset(updated)
|
|
self.assertIn("new-host.example", cfg["api_allowlist"]) # type: ignore[operator]
|
|
self.assertIn("api.anthropic.com", cfg["api_allowlist"]) # type: ignore[operator]
|
|
# tls_interception block (set up by the production prepare
|
|
# via pipelock_build_config) is preserved across the swap.
|
|
self.assertIn("tls_interception", cfg)
|
|
|
|
def test_apply_with_invalid_host_raises(self):
|
|
self._bring_up()
|
|
with self.assertRaises(PipelockApplyError):
|
|
apply_allowlist_change(self.slug, "host with space.example\n")
|
|
|
|
def test_fetch_current_allowlist_renders_one_per_line(self):
|
|
self._bring_up()
|
|
listing = fetch_current_allowlist(self.slug)
|
|
self.assertTrue(listing.endswith("\n"))
|
|
self.assertIn("api.anthropic.com\n", listing)
|
|
|
|
def test_apply_against_missing_sidecar_raises(self):
|
|
# Don't bring up — the slug points at nothing.
|
|
with self.assertRaises(PipelockApplyError):
|
|
apply_allowlist_change(self.slug, "x.example\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|