5b9ceaaaee
`apply_allowlist_change` used `docker restart <bundle>` to make
pipelock reload, which bounced ALL four daemons — including
supervise, whose MCP socket the agent's claude-code client had
open. That dropped the connection. A second apply works because
supervise has come back up by then.
Fix: per-daemon restart via SIGUSR1.
- New `_Supervisor.restart_daemon(name)` terminates one named
child and spawns a replacement in place. Other daemons keep
running.
- main() wires SIGUSR1 → `restart_daemon("pipelock")`. Pipelock
has no in-process reload, so this is its analog of egress's
SIGHUP-reload-addon path. Pipelock is the only daemon that
currently needs hot-config reload via restart; if others
acquire the need, add a new signal.
- `apply_allowlist_change` now `docker kill --signal USR1
<bundle>` instead of `docker restart`. Supervise / egress /
git-gate keep running across the apply.
Tests:
- New `_Supervisor.restart_daemon` cases: replaces in place
(different pid post-restart, sibling daemon unchanged),
unknown name is a no-op, restart-during-shutdown is a no-op.
- `test_pipelock_apply` rewritten to bring up the bundle image
with `CLAUDE_BOTTLE_SIDECAR_DAEMONS=pipelock` so the
supervisor is PID 1 and handles SIGUSR1. The previous
standalone-pipelock setup wouldn't survive SIGUSR1 (pipelock
default disposition is terminate). Test builds the bundle
image in setUpClass (cached layers make repeat runs fast).
531 tests passing locally (unit + integration).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
211 lines
8.3 KiB
Python
211 lines
8.3 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,
|
|
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_IMAGE,
|
|
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:
|
|
"""Brings up the bundle image with only the pipelock daemon
|
|
selected. The bundle's Python supervisor is PID 1, which is
|
|
what apply_allowlist_change targets via `docker kill
|
|
--signal USR1` — pipelock alone as PID 1 wouldn't survive
|
|
SIGUSR1 (default disposition = terminate). This shape is
|
|
what runs in production minus the other three daemons.
|
|
|
|
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)
|
|
|
|
# Ensure the bundle image is built. compose normally builds
|
|
# this lazily; we go through `docker run` here so we have to
|
|
# do it ourselves. Idempotent — cached layers make repeats
|
|
# fast.
|
|
repo_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
|
subprocess.run(
|
|
["docker", "build",
|
|
"-t", SIDECAR_BUNDLE_IMAGE,
|
|
"-f", "Dockerfile.sidecars", "."],
|
|
cwd=repo_root, check=True, capture_output=True,
|
|
)
|
|
|
|
self.sidecar_name = sidecar_bundle_container_name(self.slug)
|
|
subprocess.run(
|
|
["docker", "create",
|
|
"--name", self.sidecar_name,
|
|
"--network", self.internal_net,
|
|
"-e", "CLAUDE_BOTTLE_SIDECAR_DAEMONS=pipelock",
|
|
"-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",
|
|
SIDECAR_BUNDLE_IMAGE],
|
|
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()
|