Files
bot-bottle/tests/integration/test_pipelock_apply.py
T
didericis 539234f29e
test / unit (pull_request) Successful in 21s
test / integration (pull_request) Successful in 41s
refactor(sidecars): drop vestigial start/stop methods (PRD 0024 chunk 3)
Compose-up has owned per-container lifecycle since PRD 0018 ch3;
the .start() / .stop() methods on DockerPipelockProxy /
DockerEgress / DockerGitGate / DockerSupervise (and their
abstractmethod declarations in the four base ABCs) were already
documented as vestigial. With the bundle path in flight
(PRD 0024 ch2), they are truly dead — collapse to nothing.

Changes:
- Removed start/stop methods from the four DockerSidecar
  classes. Plan dataclasses, image/path constants,
  container-name helpers, and the .prepare() methods all stay
  (the renderer + apply path still need them).
- Removed the matching @abstractmethod declarations in the
  base ABCs so concrete subclasses don't have to stub them.
- launch.launch() and prepare.resolve_plan() no longer take
  proxy/git_gate/egress/supervise instance parameters. backend.py
  loses the four instance attributes it threaded through.
  prepare.resolve_plan() instantiates the four classes itself
  to call their .prepare() methods.
- Deleted four integration tests that only exercised the
  removed lifecycle: test_pipelock_sidecar_smoke,
  test_supervise_sidecar, test_git_gate_sidecar,
  test_git_gate_mirror.
- Dropped the .stop-idempotency case in test_orphan_cleanup;
  the network-cleanup cases stay (those test real production
  code).
- Marked test_pipelock_apply @skip pending chunk 4 — its
  bringup helper used .start; chunk 4 rewrites it with direct
  `docker run`.

Dockerfile deletion deferred to chunk 5 (when the bundle flag
default flips) — the legacy compose path still needs
Dockerfile.{egress,git-gate,supervise} until then.

Net: 708 lines removed, 80 added.

533 unit tests + 27 integration tests passing (5 skipped: the
chunk-4-pending case + existing GITEA_ACTIONS guards).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 01:01:10 -04:00

158 lines
5.8 KiB
Python

"""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.
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.
"""
from __future__ import annotations
import dataclasses
import os
import shutil
import subprocess
import tempfile
import time
import unittest
from pathlib import Path
from claude_bottle.backend.docker.network import (
network_create_egress,
network_create_internal,
network_remove,
)
from claude_bottle.backend.docker.pipelock import (
DockerPipelockProxy,
pipelock_container_name,
pipelock_tls_init,
)
from claude_bottle.backend.docker.pipelock_apply import (
PipelockApplyError,
apply_allowlist_change,
fetch_current_allowlist,
fetch_current_yaml,
)
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",
)
@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())}"
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:
DockerPipelockProxy().stop(self.sidecar_name)
for n in (self.internal_net, self.egress_net):
if n:
network_remove(n)
shutil.rmtree(self.work_dir, ignore_errors=True)
def _bring_up(self) -> None:
proxy = DockerPipelockProxy()
prep = proxy.prepare(fixture_minimal().bottles["dev"], self.slug, self.work_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,
)
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.
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()