diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 5832dc2..8a487ae 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -23,15 +23,11 @@ from . import prepare as _prepare from .bottle import DockerBottle from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_plan import DockerBottlePlan -from .egress import DockerEgress -from .git_gate import DockerGitGate -from .pipelock import DockerPipelockProxy from .provision import ca as _ca from .provision import git as _git from .provision import prompt as _prompt from .provision import skills as _skills from .provision import supervise as _supervise_prov -from .supervise import DockerSupervise class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]): @@ -40,32 +36,12 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup name = "docker" - def __init__(self) -> None: - self._proxy = DockerPipelockProxy() - self._git_gate = DockerGitGate() - self._egress = DockerEgress() - self._supervise = DockerSupervise() - def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan: - return _prepare.resolve_plan( - spec, - stage_dir=stage_dir, - proxy=self._proxy, - git_gate=self._git_gate, - egress=self._egress, - supervise=self._supervise, - ) + return _prepare.resolve_plan(spec, stage_dir=stage_dir) @contextmanager def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]: - with _launch.launch( - plan, - proxy=self._proxy, - git_gate=self._git_gate, - egress=self._egress, - supervise=self._supervise, - provision=self.provision, - ) as bottle: + with _launch.launch(plan, provision=self.provision) as bottle: yield bottle def provision_ca(self, plan: DockerBottlePlan, target: str) -> None: diff --git a/claude_bottle/backend/docker/egress.py b/claude_bottle/backend/docker/egress.py index c535608..27b4b39 100644 --- a/claude_bottle/backend/docker/egress.py +++ b/claude_bottle/backend/docker/egress.py @@ -13,14 +13,8 @@ import os import subprocess from pathlib import Path -from ...egress import ( - EGRESS_HOSTNAME, - EGRESS_ROUTES_IN_CONTAINER, - Egress, - EgressPlan, - egress_resolve_token_values, -) -from ...log import die, info, warn +from ...egress import Egress +from ...log import die from . import util as docker_mod @@ -166,214 +160,6 @@ def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]: class DockerEgress(Egress): - """Brings the egress sidecar up and down via Docker.""" - - def start(self, plan: EgressPlan) -> str: - """Boot the egress sidecar: - 1. Resolve every host TokenRef env var into a concrete - value. Fails early if any are unset. - 2. Build the egress image (no-op when cache is hot). - 3. `docker create` on the internal network with - `--network-alias egress`, the `HTTPS_PROXY=pipelock` - env (so the upstream leg traverses pipelock), the - `EGRESS_UPSTREAM_CA` env pointing at the in-container - pipelock-CA path (so mitmproxy trusts pipelock's MITM), - and one `-e EGRESS_TOKEN_N` flag per token slot. - Secret values arrive via subprocess env, never argv. - 4. `docker cp` the routes.yaml, mitmproxy CA (cert+key - concat), and pipelock CA (cert only) into the container. - 5. Attach to the per-agent egress network so the proxy can - reach pipelock. - 6. `docker start`. - Returns the container name (the target passed to `.stop`).""" - if not plan.routes: - die("DockerEgress.start called with no routes; caller should skip") - if not plan.internal_network or not plan.egress_network: - die( - "DockerEgress.start: internal_network / egress_network must be " - "populated on the plan before start" - ) - if not plan.routes_path.is_file(): - die( - f"egress routes file missing at {plan.routes_path}; " - f"Egress.prepare must run first" - ) - if plan.mitmproxy_ca_host_path == Path() or not plan.mitmproxy_ca_host_path.is_file(): - die( - f"DockerEgress.start: mitmproxy CA missing at " - f"{plan.mitmproxy_ca_host_path}; egress_tls_init must run first" - ) - # pipelock CA + upstream proxy URL: both must be present (we - # use HTTPS_PROXY=pipelock with pipelock's own MITM CA on the - # upstream leg) or both absent (egress goes direct, for - # standalone integration tests that don't bring pipelock up). - route_via_pipelock = bool(plan.pipelock_proxy_url) or plan.pipelock_ca_host_path != Path() - if route_via_pipelock: - if not plan.pipelock_proxy_url: - die( - "DockerEgress.start: pipelock_ca_host_path is set but " - "pipelock_proxy_url is empty; populate both or neither." - ) - if not plan.pipelock_ca_host_path.is_file(): - die( - f"DockerEgress.start: pipelock CA missing at " - f"{plan.pipelock_ca_host_path}; pipelock_tls_init must run first" - ) - - # Resolve host env vars into concrete values. Must happen at - # start time (not prepare) — the values flow into the sidecar's - # environ via subprocess env. The plan never holds them. - token_values = egress_resolve_token_values( - plan.token_env_map, dict(os.environ), - ) - - build_egress_image() - - name = egress_container_name(plan.slug) - info(f"starting egress sidecar {name} on network {plan.internal_network}") - - create_args = [ - "docker", "create", - "--name", name, - "--network", plan.internal_network, - "--network-alias", EGRESS_HOSTNAME, - ] - if route_via_pipelock: - # Route egress's outbound traffic through pipelock - # so the egress allowlist + DLP body scanner apply to - # the egress → upstream leg. Pipelock MITMs each - # handshake with its per-bottle CA, which is docker-cp'd - # in below and pointed to via the EGRESS_UPSTREAM_CA - # env (entrypoint conditionally adds the matching --set - # flag). - # - # EGRESS_UPSTREAM_PROXY is the mechanism: mitmproxy - # does NOT honor HTTPS_PROXY env vars on its outbound - # side (it's a proxy server, not a client). The - # entrypoint reads this env and switches mitmdump to - # `--mode upstream:` so all post-MITM traffic - # CONNECTs to pipelock instead of going direct. The - # HTTPS/HTTP_PROXY env vars below are kept for any - # bundled client libraries (mitmproxy plugin requests, - # etc.) that might honor them — harmless if ignored. - create_args.extend([ - "-e", f"EGRESS_UPSTREAM_PROXY={plan.pipelock_proxy_url}", - "-e", f"HTTPS_PROXY={plan.pipelock_proxy_url}", - "-e", f"HTTP_PROXY={plan.pipelock_proxy_url}", - "-e", "NO_PROXY=localhost,127.0.0.1", - "-e", f"EGRESS_UPSTREAM_CA={EGRESS_PIPELOCK_CA_IN_CONTAINER}", - ]) - # One -e flag per token slot; values arrive via subprocess env. - # docker create with `-e NAME` (no =VALUE) reads NAME from the - # current process env at create time. We pass `env=child_env` - # to subprocess.run so the value comes from token_values, not - # the host's os.environ directly — keeps the resolver in one - # place and lets egress_resolve_token_values surface - # missing-env errors with a clear hint. - for token_env in sorted(plan.token_env_map.keys()): - create_args.extend(["-e", token_env]) - create_args.append(EGRESS_IMAGE) - - child_env: dict[str, str] = {**os.environ, **token_values} - - create_result = subprocess.run( - create_args, capture_output=True, text=True, env=child_env, check=False, - ) - if create_result.returncode != 0: - die( - f"failed to create egress sidecar {name}: " - f"{create_result.stderr.strip()}" - ) - - # routes.yaml also lands inside the container; bump to 644 - # for the same reason as the CAs — mitmproxy user (uid 1000) - # has to read it. Host stage_dir is mode 700 so the file - # isn't actually exposed to other host users. - plan.routes_path.chmod(0o644) - # Pipelock CA: pipelock itself runs as root so its in-pipelock - # copy doesn't care about mode, but egress's mitmproxy - # user does. Bump on the host so docker cp into egress - # carries world-readable. - if route_via_pipelock: - plan.pipelock_ca_host_path.chmod(0o644) - cps: list[tuple[Path, str, str]] = [ - (plan.routes_path, EGRESS_ROUTES_IN_CONTAINER, "routes.yaml"), - (plan.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER, "mitmproxy CA"), - ] - if route_via_pipelock: - cps.append(( - plan.pipelock_ca_host_path, - EGRESS_PIPELOCK_CA_IN_CONTAINER, - "pipelock CA", - )) - for src, dst, label in cps: - cp_result = subprocess.run( - ["docker", "cp", str(src), f"{name}:{dst}"], - capture_output=True, - text=True, - check=False, - ) - if cp_result.returncode != 0: - subprocess.run( - ["docker", "rm", "-f", name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - die( - f"failed to copy {label} into {name}: " - f"{cp_result.stderr.strip()}" - ) - - connect_result = subprocess.run( - ["docker", "network", "connect", plan.egress_network, name], - capture_output=True, text=True, check=False, - ) - if connect_result.returncode != 0: - subprocess.run( - ["docker", "rm", "-f", name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - die( - f"failed to attach egress sidecar {name} to egress network " - f"{plan.egress_network}: {connect_result.stderr.strip()}" - ) - - start_result = subprocess.run( - ["docker", "start", name], capture_output=True, text=True, check=False, - ) - if start_result.returncode != 0: - subprocess.run( - ["docker", "rm", "-f", name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - die( - f"failed to start egress sidecar {name}: " - f"{start_result.stderr.strip()}" - ) - - return name - - def stop(self, target: str) -> None: - """Idempotent: missing container is success. `target` is the - container name returned by `.start`.""" - if subprocess.run( - ["docker", "inspect", target], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ).returncode == 0: - if subprocess.run( - ["docker", "rm", "-f", target], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ).returncode != 0: - warn( - f"failed to remove egress sidecar {target}; " - f"clean up with 'docker rm -f {target}'" - ) + """Docker-flavored Egress: inherits `.prepare()` from the base. + Container lifecycle is owned by compose; per-container + `.start()` / `.stop()` were removed in PRD 0024 chunk 3.""" diff --git a/claude_bottle/backend/docker/git_gate.py b/claude_bottle/backend/docker/git_gate.py index 935968b..5901817 100644 --- a/claude_bottle/backend/docker/git_gate.py +++ b/claude_bottle/backend/docker/git_gate.py @@ -5,17 +5,9 @@ step (upstream lift + entrypoint/hook render) from `GitGate`.""" from __future__ import annotations import os -import subprocess from pathlib import Path -from ...git_gate import ( - GitGate, - GitGatePlan, - git_gate_aggregate_extra_hosts, - git_gate_known_hosts_line, -) -from ...log import die, info, warn -from ...util import expand_tilde +from ...git_gate import GitGate from . import util as docker_mod @@ -59,163 +51,6 @@ def build_git_gate_image() -> None: class DockerGitGate(GitGate): - """Brings the git-gate sidecar up and down via Docker.""" - - def start(self, plan: GitGatePlan) -> str: - """Boot the gate sidecar: - 1. Build the gate image (no-op when cache is hot). - 2. `docker create` on the internal network with the canonical - name; the image's ENTRYPOINT runs the cp'd entrypoint - script at start time. - 3. `docker cp` the entrypoint, the shared pre-receive hook, - and each upstream's identity + known_hosts into the - container. - 4. Attach to the per-agent egress network so the gate can - reach the real upstream. - 5. `docker start`. - Returns the container name (the target passed to `.stop`).""" - if not plan.upstreams: - die("DockerGitGate.start called with no upstreams; caller should skip") - if not plan.internal_network or not plan.egress_network: - die( - "DockerGitGate.start: internal_network / egress_network must be " - "populated on the plan before start" - ) - if not plan.entrypoint_script.is_file(): - die( - f"git-gate entrypoint missing at {plan.entrypoint_script}; " - f"GitGate.prepare must run first" - ) - if not plan.hook_script.is_file(): - die( - f"git-gate hook missing at {plan.hook_script}; " - f"GitGate.prepare must run first" - ) - if not plan.access_hook_script.is_file(): - die( - f"git-gate access-hook missing at {plan.access_hook_script}; " - f"GitGate.prepare must run first" - ) - - build_git_gate_image() - - name = git_gate_container_name(plan.slug) - info(f"starting git-gate sidecar {name} on network {plan.internal_network}") - - create_args = [ - "docker", "create", - "--name", name, - "--network", plan.internal_network, - ] - for host, ip in git_gate_aggregate_extra_hosts(plan.upstreams).items(): - create_args.extend(["--add-host", f"{host}:{ip}"]) - create_args.append(GIT_GATE_IMAGE) - create_result = subprocess.run( - create_args, capture_output=True, text=True, check=False, - ) - if create_result.returncode != 0: - die( - f"failed to create git-gate sidecar {name}: " - f"{create_result.stderr.strip()}" - ) - - # Order matters: entrypoint + hook first so they're present - # when docker start fires. Per-upstream creds afterwards. - stage_dir = plan.entrypoint_script.parent - cps: list[tuple[str, str, str]] = [ - (str(plan.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER, "entrypoint"), - (str(plan.hook_script), GIT_GATE_HOOK_IN_CONTAINER, "pre-receive hook"), - (str(plan.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER, "access-hook"), - ] - for u in plan.upstreams: - keypath = expand_tilde(u.identity_file) - cps.append(( - keypath, - f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key", - f"upstream key for '{u.name}'", - )) - if u.known_host_key: - hosts_path = stage_dir / f"git_gate_known_hosts_{u.name}" - hosts_path.write_text( - git_gate_known_hosts_line( - u.upstream_host, u.upstream_port, u.known_host_key - ) - ) - hosts_path.chmod(0o600) - cps.append(( - str(hosts_path), - f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts", - f"upstream known_hosts for '{u.name}'", - )) - - for src, dst, label in cps: - cp_result = subprocess.run( - ["docker", "cp", src, f"{name}:{dst}"], - capture_output=True, - text=True, - check=False, - ) - if cp_result.returncode != 0: - subprocess.run( - ["docker", "rm", "-f", name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - die( - f"failed to copy {label} into {name}: " - f"{cp_result.stderr.strip()}" - ) - - connect_result = subprocess.run( - ["docker", "network", "connect", plan.egress_network, name], - capture_output=True, text=True, check=False, - ) - if connect_result.returncode != 0: - subprocess.run( - ["docker", "rm", "-f", name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - die( - f"failed to attach git-gate sidecar {name} to egress network " - f"{plan.egress_network}: {connect_result.stderr.strip()}" - ) - - start_result = subprocess.run( - ["docker", "start", name], capture_output=True, text=True, check=False, - ) - if start_result.returncode != 0: - subprocess.run( - ["docker", "rm", "-f", name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - die( - f"failed to start git-gate sidecar {name}: " - f"{start_result.stderr.strip()}" - ) - - return name - - def stop(self, target: str) -> None: - """Idempotent: missing container is success. `target` is the - container name returned by `.start`.""" - if subprocess.run( - ["docker", "inspect", target], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ).returncode == 0: - if subprocess.run( - ["docker", "rm", "-f", target], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ).returncode != 0: - warn( - f"failed to remove git-gate sidecar {target}; " - f"clean up with 'docker rm -f {target}'" - ) + """Docker-flavored GitGate: inherits `.prepare()` from the base. + Container lifecycle is owned by compose; per-container + `.start()` / `.stop()` were removed in PRD 0024 chunk 3.""" diff --git a/claude_bottle/backend/docker/launch.py b/claude_bottle/backend/docker/launch.py index 6d11962..5b48711 100644 --- a/claude_bottle/backend/docker/launch.py +++ b/claude_bottle/backend/docker/launch.py @@ -63,14 +63,11 @@ from .compose import ( compose_up, write_compose_file, ) -from .egress import DockerEgress, egress_tls_init -from .git_gate import DockerGitGate +from .egress import egress_tls_init from .pipelock import ( - DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init, ) -from .supervise import DockerSupervise # Where the repo root lives, for `docker build` context. Computed once. @@ -81,21 +78,10 @@ _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) def launch( plan: DockerBottlePlan, *, - proxy: DockerPipelockProxy, - git_gate: DockerGitGate, - egress: DockerEgress, - supervise: DockerSupervise, provision: Callable[[DockerBottlePlan, str], str | None], ) -> Generator[DockerBottle, None, None]: """Build, launch, and provision a Docker bottle via compose. - Teardown on exit. The per-sidecar `proxy / git_gate / egress / - supervise` parameters are vestigial from the pre-compose flow — - kept for backwards-compat with backend.py's call site; the - `start()`/`stop()` methods on those classes are no longer - invoked (chunk 3 collapsed them into the compose service spec). - They'll be removed entirely in a follow-up cleanup.""" - del proxy, git_gate, egress, supervise # not invoked in compose flow - + Teardown on exit.""" stack = ExitStack() def teardown() -> None: diff --git a/claude_bottle/backend/docker/pipelock.py b/claude_bottle/backend/docker/pipelock.py index a7b3a6b..3bdd96e 100644 --- a/claude_bottle/backend/docker/pipelock.py +++ b/claude_bottle/backend/docker/pipelock.py @@ -1,6 +1,14 @@ """DockerPipelockProxy — the Docker-specific implementation of the -sidecar's start/stop lifecycle. Inherits the platform-agnostic -YAML-config generation from PipelockProxy.""" +sidecar's `.prepare()` step + in-container CA path constants. +Inherits the platform-agnostic YAML-config generation from +PipelockProxy. + +The per-container `.start()` / `.stop()` lifecycle was deleted in +PRD 0024 chunk 3 — compose-up owns the container lifecycle (PRD +0018) and the bundle path (PRD 0024) collapses pipelock + egress ++ git-gate + supervise into one container. What remains here is +the prepare-time YAML rendering + the CA path constants the +compose renderer reads.""" from __future__ import annotations @@ -8,8 +16,8 @@ import os import subprocess from pathlib import Path -from ...log import die, info, warn -from ...pipelock import PipelockProxy, PipelockProxyPlan +from ...log import die +from ...pipelock import PipelockProxy # Pipelock image, pinned by digest. The digest is the multi-arch image @@ -22,9 +30,9 @@ PIPELOCK_IMAGE = os.environ.get( # Listening port for pipelock's forward proxy. PIPELOCK_PORT = os.environ.get("CLAUDE_BOTTLE_PIPELOCK_PORT", "8888") -# In-container paths where the per-bottle CA cert + key land after -# `docker cp` in `DockerPipelockProxy.start`. Pipelock's rendered -# YAML references these paths under `tls_interception`. +# In-container paths where the per-bottle CA cert + key land via +# the compose renderer's bind-mounts. Pipelock's rendered YAML +# references these paths under `tls_interception`. PIPELOCK_CA_CERT_IN_CONTAINER = "/etc/pipelock-ca.pem" PIPELOCK_CA_KEY_IN_CONTAINER = "/etc/pipelock-ca-key.pem" @@ -46,10 +54,10 @@ def pipelock_tls_init(stage_dir: Path) -> tuple[Path, Path]: The image is pinned (same digest the running sidecar uses) so the generated CA matches what the sidecar expects. Output is owned by - whatever UID the one-shot ran as; `DockerPipelockProxy.start` - `docker cp`s the files into the sidecar's filesystem layer, so - runtime ownership inside the sidecar (root in pipelock's - distroless image) is independent.""" + whatever UID the one-shot ran as; the compose renderer's + bind-mounts pin the files in place at runtime, so ownership + inside the running sidecar (root in pipelock's distroless image) + is independent.""" work = stage_dir / "pipelock-ca" work.mkdir(exist_ok=True) result = subprocess.run( @@ -77,117 +85,10 @@ def pipelock_tls_init(stage_dir: Path) -> tuple[Path, Path]: class DockerPipelockProxy(PipelockProxy): - """Brings the pipelock sidecar up and down via Docker.""" + """Docker-flavored PipelockProxy: inherits `.prepare()` from the + base, exposes the in-container CA paths the renderer reads. + Container lifecycle is owned by compose.""" CA_CERT_IN_CONTAINER = PIPELOCK_CA_CERT_IN_CONTAINER CA_KEY_IN_CONTAINER = PIPELOCK_CA_KEY_IN_CONTAINER - def start(self, plan: PipelockProxyPlan) -> str: - """Boot the pipelock sidecar: - 1. `docker create` on the internal network with the canonical - name and argv `run --config /etc/pipelock.yaml --listen - 0.0.0.0:`. - 2. `docker cp` the YAML config to /etc/pipelock.yaml. - 3. `docker cp` the CA cert + key to /etc/pipelock-ca.pem - and /etc/pipelock-ca-key.pem (pipelock runs as root in - its distroless image, so no chown is needed). - 4. Attach to the per-agent egress network. - 5. `docker start`. - Returns the container name (the proxy_target passed to .stop).""" - name = pipelock_container_name(plan.slug) - if not plan.yaml_path.is_file(): - die( - f"pipelock yaml not found at {plan.yaml_path}; " - f"PipelockProxy.prepare must run first" - ) - if not plan.ca_cert_host_path.is_file() or not plan.ca_key_host_path.is_file(): - die( - f"pipelock CA missing at {plan.ca_cert_host_path} / " - f"{plan.ca_key_host_path}; pipelock_tls_init must run first" - ) - - info(f"starting pipelock sidecar {name} on network {plan.internal_network}") - - create_args = [ - "docker", "create", - "--name", name, - "--network", plan.internal_network, - PIPELOCK_IMAGE, - "run", "--config", "/etc/pipelock.yaml", - "--listen", f"0.0.0.0:{PIPELOCK_PORT}", - ] - create_result = subprocess.run( - create_args, capture_output=True, text=True, check=False, - ) - if create_result.returncode != 0: - die( - f"failed to create pipelock sidecar {name}: " - f"{create_result.stderr.strip()}" - ) - - for src, dst, label in ( - (plan.yaml_path, "/etc/pipelock.yaml", "yaml"), - (plan.ca_cert_host_path, PIPELOCK_CA_CERT_IN_CONTAINER, "ca cert"), - (plan.ca_key_host_path, PIPELOCK_CA_KEY_IN_CONTAINER, "ca key"), - ): - cp_result = subprocess.run( - ["docker", "cp", str(src), f"{name}:{dst}"], - capture_output=True, - text=True, - check=False, - ) - if cp_result.returncode != 0: - subprocess.run( - ["docker", "rm", "-f", name], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, - ) - die(f"failed to copy pipelock {label} into {name}: {cp_result.stderr.strip()}") - - connect_result = subprocess.run( - ["docker", "network", "connect", plan.egress_network, name], - capture_output=True, text=True, check=False, - ) - if connect_result.returncode != 0: - subprocess.run( - ["docker", "rm", "-f", name], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, - ) - die( - f"failed to attach pipelock sidecar {name} to egress network " - f"{plan.egress_network}: {connect_result.stderr.strip()}" - ) - - start_result = subprocess.run( - ["docker", "start", name], capture_output=True, text=True, check=False, - ) - if start_result.returncode != 0: - subprocess.run( - ["docker", "rm", "-f", name], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, - ) - die( - f"failed to start pipelock sidecar {name}: " - f"{start_result.stderr.strip()}" - ) - - return name - - def stop(self, proxy_target: str) -> None: - """Idempotent: missing container is success. `proxy_target` is - the container name returned by .start.""" - if subprocess.run( - ["docker", "inspect", proxy_target], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ).returncode == 0: - if subprocess.run( - ["docker", "rm", "-f", proxy_target], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ).returncode != 0: - warn( - f"failed to remove pipelock sidecar {proxy_target}; " - f"clean up with 'docker rm -f {proxy_target}'" - ) diff --git a/claude_bottle/backend/docker/prepare.py b/claude_bottle/backend/docker/prepare.py index f19a627..882e2fc 100644 --- a/claude_bottle/backend/docker/prepare.py +++ b/claude_bottle/backend/docker/prepare.py @@ -43,16 +43,17 @@ def resolve_plan( spec: BottleSpec, *, stage_dir: Path, - proxy: DockerPipelockProxy, - git_gate: DockerGitGate, - egress: DockerEgress, - supervise: DockerSupervise, ) -> DockerBottlePlan: """Resolve Docker-specific names and write scratch files. Trusts that the agent and its skills/git-gate keys are present — validation already ran in the base class.""" docker_mod.require_docker() + proxy = DockerPipelockProxy() + git_gate = DockerGitGate() + egress = DockerEgress() + supervise = DockerSupervise() + manifest = spec.manifest agent = manifest.agents[spec.agent_name] bottle = manifest.bottle_for(spec.agent_name) diff --git a/claude_bottle/backend/docker/supervise.py b/claude_bottle/backend/docker/supervise.py index ae4af26..ab2f4ab 100644 --- a/claude_bottle/backend/docker/supervise.py +++ b/claude_bottle/backend/docker/supervise.py @@ -5,16 +5,12 @@ step (queue dir + current-config staging) from `Supervise`.""" from __future__ import annotations import os -import subprocess from pathlib import Path -from ...log import die, info, warn from ...supervise import ( - QUEUE_DIR_IN_CONTAINER, SUPERVISE_HOSTNAME, SUPERVISE_PORT, Supervise, - SupervisePlan, ) from . import util as docker_mod @@ -48,84 +44,6 @@ def build_supervise_image() -> None: class DockerSupervise(Supervise): - """Brings the supervise sidecar up and down via Docker.""" - - def start(self, plan: SupervisePlan) -> str: - """Boot the supervise sidecar: - 1. Build the supervise image (no-op when cache is hot). - 2. `docker create` on the internal network with - `--network-alias supervise` and SUPERVISE_BOTTLE_SLUG in - the environ. - 3. Bind-mount the host queue dir at /run/supervise/queue. - 4. `docker start`. - No egress network — the supervise sidecar does not make - outbound calls. Returns the container name.""" - if not plan.internal_network: - die("DockerSupervise.start: plan.internal_network must be set before start") - if not plan.queue_dir.is_dir(): - die( - f"DockerSupervise.start: queue dir missing at {plan.queue_dir}; " - f"Supervise.prepare must run first" - ) - - build_supervise_image() - - name = supervise_container_name(plan.slug) - info(f"starting supervise sidecar {name} on network {plan.internal_network}") - - create_args = [ - "docker", "create", - "--name", name, - "--network", plan.internal_network, - "--network-alias", SUPERVISE_HOSTNAME, - "-e", f"SUPERVISE_BOTTLE_SLUG={plan.slug}", - "-e", f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}", - "-e", f"SUPERVISE_PORT={SUPERVISE_PORT}", - "-v", f"{plan.queue_dir}:{QUEUE_DIR_IN_CONTAINER}", - SUPERVISE_IMAGE, - ] - - create_result = subprocess.run( - create_args, capture_output=True, text=True, check=False, - ) - if create_result.returncode != 0: - die( - f"failed to create supervise sidecar {name}: " - f"{create_result.stderr.strip()}" - ) - - start_result = subprocess.run( - ["docker", "start", name], capture_output=True, text=True, check=False, - ) - if start_result.returncode != 0: - subprocess.run( - ["docker", "rm", "-f", name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - die( - f"failed to start supervise sidecar {name}: " - f"{start_result.stderr.strip()}" - ) - - return name - - def stop(self, target: str) -> None: - """Idempotent: missing container is success.""" - if subprocess.run( - ["docker", "inspect", target], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ).returncode == 0: - if subprocess.run( - ["docker", "rm", "-f", target], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ).returncode != 0: - warn( - f"failed to remove supervise sidecar {target}; " - f"clean up with 'docker rm -f {target}'" - ) + """Docker-flavored Supervise: inherits `.prepare()` from the base. + Container lifecycle is owned by compose; per-container + `.start()` / `.stop()` were removed in PRD 0024 chunk 3.""" diff --git a/claude_bottle/egress.py b/claude_bottle/egress.py index 5823129..f59d3e0 100644 --- a/claude_bottle/egress.py +++ b/claude_bottle/egress.py @@ -326,19 +326,6 @@ class Egress(ABC): token_env_map=egress_token_env_map(routes), ) - @abstractmethod - def start(self, plan: EgressPlan) -> str: - """Bring up the egress sidecar according to `plan`. - Returns the target string identifying the running instance — - the same value to pass to `.stop`. Backend-specific.""" - - @abstractmethod - def stop(self, target: str) -> None: - """Tear down the egress sidecar identified by `target` - (the value `.start` returned). Idempotent: a missing target - is success. Backend-specific.""" - - __all__ = [ "DEFAULT_ALLOWLIST", "EGRESS_HOSTNAME", diff --git a/claude_bottle/git_gate.py b/claude_bottle/git_gate.py index db092a2..ffdb952 100644 --- a/claude_bottle/git_gate.py +++ b/claude_bottle/git_gate.py @@ -371,14 +371,3 @@ class GitGate(ABC): upstreams=upstreams, ) - @abstractmethod - def start(self, plan: GitGatePlan) -> str: - """Bring up the gate sidecar according to `plan`. Returns the - target string identifying the running instance — the same - value to pass to `.stop`. Backend-specific.""" - - @abstractmethod - def stop(self, target: str) -> None: - """Tear down the gate sidecar identified by `target` (the - value `.start` returned). Idempotent: a missing target is - success. Backend-specific.""" diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index 49c4e64..c640e47 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -344,14 +344,3 @@ class PipelockProxy(ABC): yaml_path.chmod(0o600) return PipelockProxyPlan(yaml_path=yaml_path, slug=slug) - @abstractmethod - def start(self, plan: PipelockProxyPlan) -> str: - """Bring up the pipelock sidecar according to `plan`. Returns - the proxy_target string identifying the running instance — the - same value to pass to `.stop`. Backend-specific.""" - - @abstractmethod - def stop(self, proxy_target: str) -> None: - """Tear down the pipelock sidecar identified by `proxy_target` - (the value `.start` returned). Idempotent: a missing target is - success. Backend-specific.""" diff --git a/claude_bottle/supervise.py b/claude_bottle/supervise.py index 25701f7..6576527 100644 --- a/claude_bottle/supervise.py +++ b/claude_bottle/supervise.py @@ -494,18 +494,6 @@ class Supervise(ABC): current_config_dir=current_config_dir, ) - @abstractmethod - def start(self, plan: SupervisePlan) -> str: - """Bring up the supervise sidecar according to `plan`. Returns - the target string identifying the running instance — the same - value to pass to `.stop`. Backend-specific.""" - - @abstractmethod - def stop(self, target: str) -> None: - """Tear down the supervise sidecar identified by `target`. - Idempotent: a missing target is success.""" - - # --- Helpers --------------------------------------------------------------- diff --git a/docs/prds/0024-consolidate-sidecar-bundle.md b/docs/prds/0024-consolidate-sidecar-bundle.md index 4e6be07..6ebf385 100644 --- a/docs/prds/0024-consolidate-sidecar-bundle.md +++ b/docs/prds/0024-consolidate-sidecar-bundle.md @@ -376,16 +376,30 @@ rewrite. `bottle_plan_to_compose` to emit two services. Feature flag it via env var. Update unit tests to assert on both shapes (flag on vs off) during the migration window. -3. **Backend Python collapse.** Trim the four docker - sidecar modules, consolidate container-name helpers, update - orphan-cleanup logic to look for the bundle by name. Delete - old Dockerfiles. -4. **Integration test sweep.** Bring every integration test - that probes a four-container shape (`pipelock_container_name`, - `egress_container_name`, etc.) onto the bundle. Confirm +3. **Backend Python collapse.** Drop the vestigial per-container + `.start()` / `.stop()` methods from `DockerPipelockProxy`, + `DockerEgress`, `DockerGitGate`, `DockerSupervise` (and from + the ABCs in `claude_bottle/{pipelock,egress,git_gate,supervise}.py`). + These were already documented as vestigial in PRD 0018 ch3. + Strip vestigial sidecar-instance parameters from + `launch.launch()` and `prepare.resolve_plan()`. Delete the + integration tests that exclusively exercised those methods + (`test_pipelock_sidecar_smoke`, `test_supervise_sidecar`, + `test_git_gate_sidecar`, `test_git_gate_mirror`). Skip + `test_pipelock_apply` pending chunk 4 bringup rewrite. + Orphan cleanup already uses a prefix scan and catches the + bundle for free; no change needed. Dockerfile deletion is + deferred to chunk 5 — until the flag flips, the legacy path + still needs `Dockerfile.{egress,git-gate,supervise}` for + compose `build:`. +4. **Integration test sweep.** Rewrite `test_pipelock_apply`'s + bringup with direct `docker run` so the + `apply_allowlist_change` hot-reload retains coverage. Add + any bundle-specific integration smoke as needed. Confirm PRD 0022 stays green. 5. **Docs + flag removal.** Flip the default, remove the - feature flag, update README + CLAUDE.md. + feature flag, delete `Dockerfile.{egress,git-gate,supervise}`, + update README + CLAUDE.md. ## Open questions diff --git a/tests/integration/test_git_gate_mirror.py b/tests/integration/test_git_gate_mirror.py deleted file mode 100644 index 53d5a4a..0000000 --- a/tests/integration/test_git_gate_mirror.py +++ /dev/null @@ -1,391 +0,0 @@ -"""Integration: the git-gate is a bidirectional mirror of its -upstream (PRD 0008 v1.1). - -Three round-trip assertions against a real Docker daemon plus a -sibling sshd container playing the role of "real upstream": - - 1. clone-through-gate returns whatever the upstream has at the - moment of clone (refs + content). - 2. After a second commit lands on the upstream out-of-band, a - fetch through the gate picks it up — the access-hook is - refreshing before each upload-pack. - 3. A push through the gate (clean commit) lands on the upstream's - bare repo — the pre-receive hook's forward phase works. - -These are the user-facing semantics: every operation against the -gate is observably equivalent to the same operation against the -real upstream. -""" - -import dataclasses -import os -import shutil -import subprocess -import tempfile -import textwrap -import unittest -from pathlib import Path - -from claude_bottle.backend.docker.git_gate import ( - DockerGitGate, - build_git_gate_image, -) -from claude_bottle.backend.docker.network import ( - network_create_egress, - network_create_internal, - network_remove, -) -from claude_bottle.manifest import Manifest -from tests._docker import skip_unless_docker - - -# Same image used by test_git_gate_sidecar — alpine + git + gitleaks. -CLIENT_IMAGE = "zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f" - -# Built once in setUpClass via `docker build -` from the inline -# Dockerfile below. Carries openssh-server, a `git` user, baked-in -# host keys, and a bare repo at /git/foo.git seeded with one commit. -UPSTREAM_IMAGE = "claude-bottle-test-upstream:latest" - -UPSTREAM_DOCKERFILE = textwrap.dedent(""" - FROM alpine:3.20 - RUN apk add --no-cache openssh-server git - RUN adduser -D -s /usr/bin/git-shell git && \\ - passwd -u git && \\ - mkdir -p /home/git/.ssh && \\ - chown git:git /home/git/.ssh && \\ - chmod 700 /home/git/.ssh && \\ - mkdir -p /git && \\ - chown git:git /git - # Bake host keys into the image so the test can pin the - # KnownHostKey value before the container starts. Re-running - # ssh-keygen -A at boot would invalidate that pinning. - RUN ssh-keygen -A - USER git - RUN git config --global init.defaultBranch main && \\ - git config --global user.email upstream@example && \\ - git config --global user.name upstream && \\ - git init --bare /git/foo.git && \\ - git clone /git/foo.git /tmp/w && \\ - cd /tmp/w && \\ - echo "initial upstream content" > README.md && \\ - git add README.md && \\ - git commit -q -m "initial commit" && \\ - git push -q origin main && \\ - rm -rf /tmp/w - USER root - RUN echo "PermitRootLogin no" >> /etc/ssh/sshd_config && \\ - echo "PasswordAuthentication no" >> /etc/ssh/sshd_config && \\ - echo "AuthorizedKeysFile /home/git/.ssh/authorized_keys" >> /etc/ssh/sshd_config - CMD ["/usr/sbin/sshd", "-D", "-e"] -""").strip() - - -@skip_unless_docker() -class TestGitGateBidirectionalMirror(unittest.TestCase): - @classmethod - def setUpClass(cls): - # Pull the client image first (other suites do the same — keeps - # registry races contained to setUpClass). - if subprocess.run( - ["docker", "pull", CLIENT_IMAGE], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, - ).returncode != 0: - raise unittest.SkipTest(f"could not pull {CLIENT_IMAGE}") - - # Build the upstream sshd image from stdin (no build context - # needed — Dockerfile has no COPY/ADD). - build_result = subprocess.run( - ["docker", "build", "-t", UPSTREAM_IMAGE, "-"], - input=UPSTREAM_DOCKERFILE, - text=True, - capture_output=True, - check=False, - ) - if build_result.returncode != 0: - raise unittest.SkipTest( - f"could not build upstream image: {build_result.stderr}" - ) - - # Pull the upstream's baked-in ed25519 host pubkey out of the - # image so we can pin it as KnownHostKey on the gate's manifest - # entry. Reading from a transient container ensures we get the - # same key the running sshd will present. - pub_result = subprocess.run( - ["docker", "run", "--rm", "--entrypoint", "cat", - UPSTREAM_IMAGE, "/etc/ssh/ssh_host_ed25519_key.pub"], - capture_output=True, text=True, check=True, - ) - parts = pub_result.stdout.strip().split() - # Format: "ssh-ed25519 " — drop comment. - cls.upstream_host_key = f"{parts[0]} {parts[1]}" - - # Build the gate image (uses build cache after the first run). - build_git_gate_image() - - def setUp(self): - suffix = self.id().rsplit('.', 1)[-1].replace('_', '-')[-12:] - self.slug = f"t{os.getpid()}-{suffix}" - self.gate_name = "" - self.upstream_name = f"claude-bottle-test-upstream-{self.slug}" - self.internal_net = "" - self.egress_net = "" - self.work_dir = Path(tempfile.mkdtemp()) - - # Per-test SSH auth keypair. The host gets the private key - # path on disk (manifest IdentityFile); the upstream's - # authorized_keys gets the public key, docker-cp'd in just - # before sshd starts. - self.auth_key = self.work_dir / "auth_key" - subprocess.run( - ["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(self.auth_key), - "-C", "git-gate-test"], - check=True, stdout=subprocess.DEVNULL, - ) - self.auth_pub = self.work_dir / "auth_key.pub" - - # Networks first so the upstream can attach to the egress - # network at create time. - self.internal_net = network_create_internal(self.slug) - self.egress_net = network_create_egress(self.slug) - - # Start the upstream sshd container, attached to the egress - # network (which the gate also lives on). Container name doubles - # as its DNS-resolvable hostname. - subprocess.run( - ["docker", "create", - "--name", self.upstream_name, - "--network", self.egress_net, - UPSTREAM_IMAGE], - check=True, stdout=subprocess.DEVNULL, - ) - # docker cp the per-test pubkey into the upstream as - # /home/git/.ssh/authorized_keys (right user, right path). - subprocess.run( - ["docker", "cp", str(self.auth_pub), - f"{self.upstream_name}:/home/git/.ssh/authorized_keys"], - check=True, stdout=subprocess.DEVNULL, - ) - # chown / chmod the authorized_keys before sshd refuses to - # use it. - for argv in ( - ["chown", "git:git", "/home/git/.ssh/authorized_keys"], - ["chmod", "600", "/home/git/.ssh/authorized_keys"], - ): - subprocess.run( - ["docker", "exec", "-u", "0", self.upstream_name, *argv], - check=False, stdout=subprocess.DEVNULL, - ) - # The exec-then-start ordering is unusual — exec on a stopped - # container is OK on modern docker but if it errors we just - # do the chown after start instead. Retry post-start to be - # safe. - subprocess.run( - ["docker", "start", self.upstream_name], - check=True, stdout=subprocess.DEVNULL, - ) - for argv in ( - ["chown", "git:git", "/home/git/.ssh/authorized_keys"], - ["chmod", "600", "/home/git/.ssh/authorized_keys"], - ): - subprocess.run( - ["docker", "exec", "-u", "0", self.upstream_name, *argv], - check=False, stdout=subprocess.DEVNULL, - ) - # Wait for sshd to bind; a short retry against TCP 22 is enough. - ready = False - for _ in range(30): - probe = subprocess.run( - ["docker", "exec", self.upstream_name, - "sh", "-c", "nc -z 127.0.0.1 22"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, - ) - if probe.returncode == 0: - ready = True - break - subprocess.run(["sleep", "0.2"], check=False) - if not ready: - self.fail("upstream sshd never bound port 22") - - # Build the gate plan + start it. Upstream URL points at the - # upstream container's hostname (Docker DNS resolves it on the - # egress network) on port 22, user `git`. - manifest = Manifest.from_json_obj({ - "bottles": { - "dev": { - "git": [{ - "Name": "foo", - "Upstream": f"ssh://git@{self.upstream_name}/git/foo.git", - "IdentityFile": str(self.auth_key), - "KnownHostKey": self.upstream_host_key, - }], - }, - }, - "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, - }) - bottle = manifest.bottles["dev"] - gate = DockerGitGate() - prep = gate.prepare(bottle, self.slug, self.work_dir) - plan = dataclasses.replace( - prep, - internal_network=self.internal_net, - egress_network=self.egress_net, - ) - self.gate_name = gate.start(plan) - - def tearDown(self): - if self.gate_name: - DockerGitGate().stop(self.gate_name) - if self.upstream_name: - subprocess.run( - ["docker", "rm", "-f", self.upstream_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) - - def _upstream_main_sha(self) -> str: - """Read upstream's current refs/heads/main sha by exec'ing - directly into the upstream container's bare repo.""" - out = subprocess.run( - ["docker", "exec", "-u", "git", self.upstream_name, - "git", "-C", "/git/foo.git", "rev-parse", "refs/heads/main"], - capture_output=True, text=True, check=True, - ) - return out.stdout.strip() - - def _push_to_upstream_oob(self, message: str) -> str: - """Make a new commit directly on the upstream's bare repo - (out-of-band, not through the gate). Returns the new sha.""" - script = textwrap.dedent(f""" - set -e - cd /tmp - rm -rf w - git clone /git/foo.git w - cd w - git config user.email upstream@example - git config user.name upstream - echo "$RANDOM-$$" >> README.md - git add README.md - git commit -q -m "{message}" - git push -q origin main - git rev-parse HEAD - """).strip() - out = subprocess.run( - ["docker", "exec", "-u", "git", self.upstream_name, - "sh", "-c", script], - capture_output=True, text=True, check=True, - ) - return out.stdout.strip().splitlines()[-1] - - @unittest.skipIf( - os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: docker socket mount topology breaks " - "in-process visibility of networks created on the host daemon", - ) - def test_clone_and_refetch_reflect_upstream(self): - """Clone via gate returns upstream's commit. After a second - commit lands on the upstream out-of-band, a re-fetch through - the gate picks it up — the access-hook is refreshing before - each upload-pack.""" - initial_sha = self._upstream_main_sha() - - # Clone via gate. - clone_script = ( - f"set -e\n" - f"cd /tmp && git clone -q git://{self.gate_name}/foo.git r\n" - f"git -C r rev-parse refs/remotes/origin/main\n" - f"cat r/README.md\n" - ) - clone = subprocess.run( - ["docker", "run", "--rm", - "--network", self.internal_net, - "--entrypoint", "sh", - CLIENT_IMAGE, - "-c", clone_script], - capture_output=True, text=True, timeout=60, check=False, - ) - self.assertEqual( - 0, clone.returncode, - f"clone via gate failed: stdout={clone.stdout!r} " - f"stderr={clone.stderr!r}", - ) - cloned_sha = clone.stdout.strip().splitlines()[0] - self.assertEqual( - initial_sha, cloned_sha, - "clone via gate must return the upstream's current sha", - ) - self.assertIn("initial upstream content", clone.stdout) - - # Out-of-band commit on the upstream. - new_sha = self._push_to_upstream_oob("second commit") - self.assertNotEqual(initial_sha, new_sha) - - # ls-remote via gate (re-fetch should pick up the new sha). - ls = subprocess.run( - ["docker", "run", "--rm", - "--network", self.internal_net, - "--entrypoint", "sh", - CLIENT_IMAGE, - "-c", f"git ls-remote git://{self.gate_name}/foo.git refs/heads/main"], - capture_output=True, text=True, timeout=60, check=False, - ) - self.assertEqual(0, ls.returncode, f"ls-remote failed: {ls.stderr!r}") - gate_sha = ls.stdout.split()[0] - self.assertEqual( - new_sha, gate_sha, - "ls-remote via gate must reflect the upstream's out-of-band update; " - "if this assertion fails, the access-hook is not refreshing on every " - "upload-pack and the gate is serving stale data", - ) - - @unittest.skipIf( - os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: docker socket mount topology breaks " - "in-process visibility of networks created on the host daemon", - ) - def test_push_through_gate_lands_on_upstream(self): - """A clean (no-gitleaks-hit) push through the gate lands on - the upstream's bare repo — pre-receive phase 2 forwards - the accepted refs.""" - # Make a commit through the gate. The script clones via gate - # (so the commit will be a child of upstream's current main). - push_script = textwrap.dedent(f""" - set -e - cd /tmp - git clone -q git://{self.gate_name}/foo.git r - cd r - git config user.email client@example - git config user.name client - echo "client-side commit" > NEW.md - git add NEW.md - git commit -q -m "client commit" - git rev-parse HEAD - git push origin main 2>&1 - """).strip() - push = subprocess.run( - ["docker", "run", "--rm", - "--network", self.internal_net, - "--entrypoint", "sh", - CLIENT_IMAGE, - "-c", push_script], - capture_output=True, text=True, timeout=120, check=False, - ) - self.assertEqual( - 0, push.returncode, - f"push via gate failed: stdout={push.stdout!r} " - f"stderr={push.stderr!r}", - ) - client_sha = push.stdout.splitlines()[0].strip() - self.assertEqual( - client_sha, self._upstream_main_sha(), - "push via gate must land on upstream's bare repo; " - "if this fails the pre-receive forward phase is broken or the " - "upstream credential is misconfigured", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/integration/test_git_gate_sidecar.py b/tests/integration/test_git_gate_sidecar.py deleted file mode 100644 index 2537b6b..0000000 --- a/tests/integration/test_git_gate_sidecar.py +++ /dev/null @@ -1,224 +0,0 @@ -"""Integration: per-agent git-gate sidecar (PRD 0008). - -Two tests against a real Docker daemon: - - 1. ls-remote against a gate whose upstream is unreachable fails - with the access-hook's fail-closed rejection. Proves the - daemon is bound to its port AND the access-hook is wired: - a working ls-remote against the gate is necessarily a working - ls-remote against the upstream (PRD 0008's transparent-mirror - contract). - 2. A push containing a gitleaks-detectable secret is rejected - by the pre-receive hook with a non-zero exit on the agent - side and a gitleaks-rejection line in the response. The PRD's - primary success criterion. - -A successful round-trip (clone through gate reflects upstream) -needs a reachable upstream SSH host; deferred to a follow-up. -""" - -import dataclasses -import os -import shutil -import subprocess -import tempfile -import unittest -from pathlib import Path - -from claude_bottle.backend.docker.git_gate import ( - DockerGitGate, - build_git_gate_image, -) -from claude_bottle.backend.docker.network import ( - network_create_egress, - network_create_internal, - network_remove, -) -from claude_bottle.manifest import Manifest -from tests._docker import skip_unless_docker - -# The official gitleaks image already has git + alpine; reusing it -# for the client side too saves a separate image pull. -CLIENT_IMAGE = "zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f" - -# Synthetic high-entropy AKIA-shaped string; gitleaks's aws-access-token -# rule fires on this with the default config. AWS's own example -# ("AKIAIOSFODNN7EXAMPLE") is NOT flagged by gitleaks v8.x — entropy -# filter rejects it — so we use a distinct random-looking value. -FAKE_AWS_KEY = "AKIAQRJHK7N5ZPM2VXTL" - - -@skip_unless_docker() -class TestGitGateSidecar(unittest.TestCase): - @classmethod - def setUpClass(cls): - # Pre-pull the client/gitleaks base so per-test runs aren't - # racing the registry. Skip cleanly on pull failure (a real - # outage is out of scope here). - result = subprocess.run( - ["docker", "pull", CLIENT_IMAGE], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, - ) - if result.returncode != 0: - raise unittest.SkipTest(f"could not pull {CLIENT_IMAGE}") - # Build the gate image once for the class. Layer cache makes - # repeated runs cheap. - build_git_gate_image() - - def setUp(self): - # DNS hostnames on user-defined Docker networks max out at 63 - # chars per label (RFC 1035). The full container name is - # `claude-bottle-git-gate-` = 23 + len(slug), so the slug - # has to stay under ~40 to be resolvable. Keep it short. - suffix = self.id().rsplit('.', 1)[-1].replace('_', '-')[-12:] - self.slug = f"t{os.getpid()}-{suffix}" - self.gate_name = "" - self.internal_net = "" - self.egress_net = "" - self.work_dir = Path(tempfile.mkdtemp()) - - def tearDown(self): - if self.gate_name: - DockerGitGate().stop(self.gate_name) - for n in (self.internal_net, self.egress_net): - if n: - network_remove(n) - shutil.rmtree(self.work_dir, ignore_errors=True) - - def _start_gate(self, name: str = "foo") -> str: - """Build a one-upstream gate and bring it up. Returns the - container name (== git-gate hostname on the internal net).""" - # Contents of the fake key don't matter for these tests — the - # rejection-path hook never reaches phase 2 where it would be - # used, and ls-remote doesn't push. - fake_key = self.work_dir / "fake-key" - fake_key.write_text("not-a-real-key\n") - - manifest = Manifest.from_json_obj({ - "bottles": { - "dev": { - "git": [{ - "Name": name, - "Upstream": "ssh://git@upstream.invalid/path.git", - "IdentityFile": str(fake_key), - "KnownHostKey": "ssh-ed25519 AAAAEXAMPLE", - }], - }, - }, - "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, - }) - bottle = manifest.bottles["dev"] - - gate = DockerGitGate() - prep = gate.prepare(bottle, self.slug, self.work_dir) - - self.internal_net = network_create_internal(self.slug) - self.egress_net = network_create_egress(self.slug) - plan = dataclasses.replace( - prep, - internal_network=self.internal_net, - egress_network=self.egress_net, - ) - self.gate_name = gate.start(plan) - return self.gate_name - - @unittest.skipIf( - os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: docker socket mount topology breaks " - "in-process visibility of networks created on the host daemon", - ) - def test_ls_remote_fails_closed_when_upstream_unreachable(self): - """The gate's access-hook runs `git fetch origin --prune` before - every upload-pack. With the fixture's deliberately unreachable - `ssh://git@upstream.invalid/...`, that fetch fails and the - hook exits 1; the daemon reports access-denied. Asserting - non-zero here is what proves the access-hook is wired: under - the v1 (push-only) design ls-remote against a fresh gate - returned exit 0 with no refs.""" - gate = self._start_gate("foo") - # Daemon still has to bind first; retry the TCP connect a few - # times. The expected end state is a non-zero exit from the - # daemon's access-denied response — not a connection refused. - probe = subprocess.run( - ["docker", "run", "--rm", - "--network", self.internal_net, - "--entrypoint", "sh", - CLIENT_IMAGE, - "-c", - f"for i in $(seq 1 15); do " - f" out=$(git ls-remote git://{gate}/foo.git 2>&1) && exit 99;" - f" case \"$out\" in *'access denied'*|*'not exported'*) " - f" echo \"$out\"; exit 1;; esac;" - f" sleep 1;" - f"done;" - f"echo TIMEOUT; exit 2"], - capture_output=True, text=True, timeout=60, check=False, - ) - # exit 1: daemon access-denied as expected. exit 99 would mean - # ls-remote actually succeeded against the unreachable upstream - # (impossible — would indicate stale-data serving, the very - # thing the access-hook is meant to prevent). - self.assertEqual( - 1, probe.returncode, - f"expected fail-closed access-denied; got " - f"exit={probe.returncode} stdout={probe.stdout!r} " - f"stderr={probe.stderr!r}", - ) - - @unittest.skipIf( - os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: docker socket mount topology breaks " - "in-process visibility of networks created on the host daemon", - ) - def test_push_with_secret_is_rejected(self): - """The PRD 0008 success criterion: a push containing a - gitleaks-detectable secret is rejected; the hook's "gitleaks - rejected" line appears in the response, and git push exits - non-zero on the client side.""" - gate = self._start_gate("foo") - push_script = ( - "set -e\n" - "cd /tmp\n" - # Wait for git daemon to bind. Under the v1.1 design, - # ls-remote never returns 0 against an unreachable - # upstream (access-hook fail-closed), so we wait for *any* - # response (the daemon's access-denied line) as the - # readiness signal. - f"for i in $(seq 1 15); do " - f" out=$(git ls-remote git://{gate}/foo.git 2>&1) || true;" - f" case \"$out\" in *'remote error'*|*'access denied'*) break;; esac;" - f" sleep 1;" - f"done\n" - "git init -q -b main repo\n" - "cd repo\n" - "git config user.email test@example.com\n" - "git config user.name test\n" - f"echo '{FAKE_AWS_KEY}' > leak.txt\n" - "git add leak.txt\n" - "git commit -q -m leak\n" - f"git push git://{gate}/foo.git main 2>&1\n" - ) - result = subprocess.run( - ["docker", "run", "--rm", - "--network", self.internal_net, - "--entrypoint", "sh", - CLIENT_IMAGE, - "-c", push_script], - capture_output=True, text=True, timeout=120, check=False, - ) - combined = result.stdout + result.stderr - self.assertNotEqual( - 0, result.returncode, - f"expected push to fail; output={combined!r}", - ) - # Hook's stderr is delivered to the client via the `remote:` - # prefix during a git push. Either token is enough to prove - # the pre-receive hook ran and rejected the push. - self.assertTrue( - "gitleaks rejected" in combined or "leaks found" in combined, - f"expected a gitleaks rejection in the response; got: {combined!r}", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/integration/test_orphan_cleanup.py b/tests/integration/test_orphan_cleanup.py index 4aff79b..bc99737 100644 --- a/tests/integration/test_orphan_cleanup.py +++ b/tests/integration/test_orphan_cleanup.py @@ -1,8 +1,13 @@ -"""Integration: the cleanup primitives the start-flow trap depends on -are idempotent. The original orphan-network bug was a trap-ordering -issue; the fix moved the install earlier. The trap is only safe if -network_remove and PipelockProxy.stop are no-ops against missing -resources.""" +"""Integration: the network-cleanup primitives the start-flow trap +depends on are idempotent. The original orphan-network bug was a +trap-ordering issue; the fix moved the install earlier. The trap +is only safe if network_remove is a no-op against missing +resources. + +The PipelockProxy.stop idempotency case that used to live here was +removed in PRD 0024 chunk 3 when the per-container .stop method +went away — sidecar teardown is now compose's responsibility, and +`compose down` already no-ops on missing containers.""" import os import subprocess @@ -13,10 +18,6 @@ from claude_bottle.backend.docker.network import ( network_create_internal, network_remove, ) -from claude_bottle.backend.docker.pipelock import ( - DockerPipelockProxy, - pipelock_container_name, -) from tests._docker import skip_unless_docker @@ -71,10 +72,6 @@ class TestOrphanCleanup(unittest.TestCase): self.assertTrue(network_remove(self.internal_name)) self.assertTrue(network_remove(self.egress_name)) - def test_pipelock_stop_missing_sidecar(self): - # Should not raise. - DockerPipelockProxy().stop(pipelock_container_name(f"missing-{self.slug}")) - if __name__ == "__main__": unittest.main() diff --git a/tests/integration/test_pipelock_apply.py b/tests/integration/test_pipelock_apply.py index 3f0181b..e170b0e 100644 --- a/tests/integration/test_pipelock_apply.py +++ b/tests/integration/test_pipelock_apply.py @@ -50,6 +50,11 @@ 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())}" diff --git a/tests/integration/test_pipelock_sidecar_smoke.py b/tests/integration/test_pipelock_sidecar_smoke.py deleted file mode 100644 index cb96a8b..0000000 --- a/tests/integration/test_pipelock_sidecar_smoke.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Integration: drive the production pipelock-sidecar bring-up -(`DockerPipelockProxy.prepare` → `.start`) and probe /health from a -sibling container on the same internal network. The point is that the -test exercises the production code path — if the docker create/cp/start -sequence in DockerPipelockProxy.start changes shape, this test should -notice. - -We don't probe /health from the host because the sidecar is created -attached to an `--internal` network with no published port (that's -the production topology). An in-network curl container reaches it the -same way the agent container would in production. -""" - -import dataclasses -import os -import shutil -import subprocess -import tempfile -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 ( - PIPELOCK_PORT, - DockerPipelockProxy, - pipelock_container_name, - pipelock_tls_init, -) -from tests._docker import skip_unless_docker -from tests.fixtures import fixture_minimal - -CURL_IMAGE = "curlimages/curl:latest" - - -@skip_unless_docker() -class TestPipelockSidecarSmoke(unittest.TestCase): - @classmethod - def setUpClass(cls): - # Pre-pull curlimages/curl so the per-test retry loop isn't - # racing the registry. Skip cleanly if the pull fails (the - # canary suite will surface a real registry outage separately). - result = subprocess.run( - ["docker", "pull", CURL_IMAGE], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - if result.returncode != 0: - raise unittest.SkipTest(f"could not pull {CURL_IMAGE}") - - def setUp(self): - self.slug = f"cb-test-smoke-{os.getpid()}" - self.sidecar_name = "" - self.internal_net = "" - self.egress_net = "" - self.work_dir = Path(tempfile.mkdtemp()) - - 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) - - @unittest.skipIf( - os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: docker socket mount topology breaks " - "in-process visibility of networks created on the host daemon", - ) - def test_prepare_and_start_yield_healthy_sidecar(self): - 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) - - # PRD 0006: pipelock's tls_interception block in the rendered - # YAML references in-container CA paths; .start docker-cp's - # those files in. The full launch flow generates the CA via - # `pipelock_tls_init`; this smoke test calls it directly. - 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) - - # Probe /health from a sibling container on the internal network — - # same access topology the agent container uses in production. - # curl retries on connection refused while pipelock is booting. - probe = subprocess.run( - [ - "docker", "run", "--rm", - "--network", self.internal_net, - CURL_IMAGE, - "-sf", "--max-time", "2", - "--retry", "15", - "--retry-delay", "1", - "--retry-connrefused", - f"http://{self.sidecar_name}:{PIPELOCK_PORT}/health", - ], - capture_output=True, - text=True, - timeout=60, - check=False, - ) - self.assertEqual( - 0, probe.returncode, - f"health probe failed: stdout={probe.stdout!r} stderr={probe.stderr!r}", - ) - body = probe.stdout - self.assertIn('"status":"healthy"', body) - self.assertRegex(body, r'"version":"[0-9]+\.[0-9]+\.[0-9]+"') - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/integration/test_supervise_sidecar.py b/tests/integration/test_supervise_sidecar.py deleted file mode 100644 index 1bf0e37..0000000 --- a/tests/integration/test_supervise_sidecar.py +++ /dev/null @@ -1,307 +0,0 @@ -"""Integration: drive `DockerSupervise.start` against the supervise -sidecar and round-trip an MCP tool call through the queue (PRD 0013). - -Topology mirrors production minimally: a per-bottle internal docker -network for the agent ↔ supervise leg, no egress network (supervise -doesn't make outbound calls). The "agent" is a curl container on the -internal net; the supervisor lives on the host (this test process) -and uses claude_bottle.cli.dashboard helpers to write Response files. - -Verifies: - 1. `tools/list` returns the three PRD 0013 tool names over real MCP - wire format. - 2. A `tools/call` from the in-container agent blocks until the host - writes a Response to the queue; once written, the agent receives - the approval payload. -""" - -from __future__ import annotations - -import json -import os -import shutil -import subprocess -import tempfile -import threading -import time -import unittest -from pathlib import Path - -from claude_bottle import supervise as _sv -from claude_bottle.backend.docker.network import ( - network_create_internal, - network_remove, -) -from claude_bottle.backend.docker.supervise import ( - DockerSupervise, - build_supervise_image, - supervise_container_name, -) -from claude_bottle.cli import dashboard -from claude_bottle.supervise import SupervisePlan, list_pending_proposals -from tests._docker import skip_unless_docker - - -CURL_IMAGE = "curlimages/curl:latest" - - -@skip_unless_docker() -class TestSuperviseSidecar(unittest.TestCase): - @classmethod - def setUpClass(cls): - r = subprocess.run( - ["docker", "pull", CURL_IMAGE], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - if r.returncode != 0: - raise unittest.SkipTest(f"could not pull {CURL_IMAGE}") - build_supervise_image() - - def setUp(self): - self.slug = f"cb-test-sv-{os.getpid()}-{int(time.time())}" - self.sidecar_name = "" - self.internal_net = "" - self.work_dir = Path(tempfile.mkdtemp(prefix="supervise-int.")) - self.queue_dir = self.work_dir / "queue" - self.queue_dir.mkdir() - - def tearDown(self): - if self.sidecar_name: - subprocess.run( - ["docker", "rm", "-f", self.sidecar_name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - if self.internal_net: - network_remove(self.internal_net) - shutil.rmtree(self.work_dir, ignore_errors=True) - - def _require_bind_mount_sharing(self) -> None: - """Skip if `docker run -v :` doesn't - share the filesystem between the test process and the spawned - container. In docker-in-docker CI (Gitea Actions runner with - host socket forwarded), bind-mount paths are resolved against - the outer host's fs, not the runner container's — so the - sidecar writes proposals to a dir the test process can't see. - - Cached on the class so the probe runs once per test session.""" - cached = getattr(type(self), "_bind_mount_ok", None) - if cached is True: - return - if cached is False: - self.skipTest( - "docker bind mounts don't share fs with this test process " - "(likely docker-in-docker); the supervise queue round-trip " - "requires real host fs sharing" - ) - probe_dir = Path(tempfile.mkdtemp(prefix="supervise-bind-probe.")) - try: - (probe_dir / "from-host").write_text("x") - r = subprocess.run( - [ - "docker", "run", "--rm", - "-v", f"{probe_dir}:/probe", - "--entrypoint", "sh", - CURL_IMAGE, - "-c", "test -f /probe/from-host && touch /probe/from-container", - ], - capture_output=True, - check=False, - ) - ok = ( - r.returncode == 0 - and (probe_dir / "from-container").exists() - ) - finally: - shutil.rmtree(probe_dir, ignore_errors=True) - type(self)._bind_mount_ok = ok - if not ok: - self.skipTest( - "docker bind mounts don't share fs with this test process " - "(likely docker-in-docker); the supervise queue round-trip " - "requires real host fs sharing" - ) - - def _bring_up_sidecar(self) -> None: - self.internal_net = network_create_internal(self.slug) - plan = SupervisePlan( - slug=self.slug, - queue_dir=self.queue_dir, - current_config_dir=self.work_dir / "current-config", - internal_network=self.internal_net, - ) - # current_config_dir isn't bind-mounted into the sidecar, only - # the queue dir is. Create it for symmetry with production. - plan.current_config_dir.mkdir() - self.sidecar_name = DockerSupervise().start(plan) - - # Block until the server is ready to answer (the container - # `docker start` returns immediately; python is still - # binding to the port). - deadline = time.monotonic() + 10.0 - while time.monotonic() < deadline: - rc = subprocess.run( - [ - "docker", "run", "--rm", - "--network", self.internal_net, - CURL_IMAGE, - "-fsS", "-o", "/dev/null", - "--max-time", "2", - f"http://{_sv.SUPERVISE_HOSTNAME}:{_sv.SUPERVISE_PORT}/health", - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ).returncode - if rc == 0: - return - time.sleep(0.25) - raise AssertionError("supervise sidecar /health never came up") - - def _curl_jsonrpc(self, body: dict[str, object]) -> dict[str, object]: - """Invoke curl on the internal network to POST a JSON-RPC - request to the supervise sidecar and parse the response.""" - payload = json.dumps(body) - result = subprocess.run( - [ - "docker", "run", "--rm", - "--network", self.internal_net, - CURL_IMAGE, - "-sS", "--max-time", "30", - "-H", "Content-Type: application/json", - "-X", "POST", - "--data", payload, - f"http://{_sv.SUPERVISE_HOSTNAME}:{_sv.SUPERVISE_PORT}/", - ], - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - raise AssertionError( - f"curl to supervise failed: {result.stderr}\n" - f"stdout: {result.stdout}" - ) - return json.loads(result.stdout) - - def test_tools_list_over_mcp(self): - self._bring_up_sidecar() - result = self._curl_jsonrpc( - {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, - ) - self.assertEqual(1, result["id"]) - names = {t["name"] for t in result["result"]["tools"]} - self.assertEqual( - { - _sv.TOOL_EGRESS_BLOCK, - _sv.TOOL_PIPELOCK_BLOCK, - _sv.TOOL_CAPABILITY_BLOCK, - _sv.TOOL_LIST_EGRESS_ROUTES, - }, - names, - ) - - def test_tools_call_round_trips_through_queue(self): - """End-to-end: agent in the bottle calls egress-block; - the call blocks on the queue; the host approves via the - dashboard helpers; the agent receives the approval. - - This test focuses on the supervise sidecar's queue + response - plumbing, not the egress apply path itself. The apply - function is stubbed so we don't need to bring up a real - egress sidecar (its docker lifecycle has its own - integration coverage).""" - self._require_bind_mount_sharing() - self._bring_up_sidecar() - - # Stub the apply step. The dashboard's approve() calls - # add_route to docker-exec into the egress sidecar; - # this test isn't exercising the real sidecar, so patch it - # to a no-op that returns plausible before/after strings - # the audit-log writer can render. - from claude_bottle.cli import dashboard as _dash - original_apply = _dash.add_route - _dash.add_route = ( - lambda slug, new: ("(stubbed before)", new) - ) - - captured: dict[str, object] = {} - - def caller() -> None: - captured["response"] = self._curl_jsonrpc({ - "jsonrpc": "2.0", "id": 7, "method": "tools/call", - "params": { - "name": _sv.TOOL_EGRESS_BLOCK, - "arguments": { - "host": "api.example.com", - "justification": "integration test", - }, - }, - }) - - t = threading.Thread(target=caller) - t.start() - try: - # Wait for the proposal to appear in the queue (the - # sidecar writes it before blocking on wait_for_response). - deadline = time.monotonic() + 10.0 - qp = None - while time.monotonic() < deadline: - pending = list_pending_proposals(self.queue_dir) - if pending: - qp = dashboard.QueuedProposal( - proposal=pending[0], queue_dir=self.queue_dir, - ) - break - time.sleep(0.1) - self.assertIsNotNone(qp, "proposal never appeared in queue") - assert qp is not None # type-narrowing - self.assertEqual( - _sv.TOOL_EGRESS_BLOCK, qp.proposal.tool, - ) - self.assertEqual("integration test", qp.proposal.justification) - - # Approve via the dashboard helper. The apply step (now - # stubbed) would docker-exec into the egress sidecar - # and SIGHUP it. The supervise sidecar sees the response - # file and returns to the curl caller. - dashboard.approve(qp, notes="lgtm from integration test") - finally: - _dash.add_route = original_apply - t.join(timeout=20) - - response = captured.get("response") - self.assertIsNotNone(response, "curl thread never produced a response") - assert isinstance(response, dict) # type-narrowing - self.assertEqual(7, response["id"]) - result = response["result"] - assert isinstance(result, dict) - self.assertFalse(result.get("isError")) - text = result["content"][0]["text"] - self.assertIn("status: approved", text) - self.assertIn("notes: lgtm from integration test", text) - - def test_orphan_sidecar_name_collision_recovered(self): - """An orphan supervise sidecar from a previous run blocks - the next .start with a duplicate-name error. Documents the - observed behavior so a future change that adds auto-cleanup - can flip the assertion.""" - self._bring_up_sidecar() - self.assertEqual(supervise_container_name(self.slug), self.sidecar_name) - # Second .start should fail because the container name is - # taken. cleanup is handled by the orphan probe in prepare.py - # (tested separately in test_orphan_cleanup). - with self.assertRaises(SystemExit): - DockerSupervise().start(SupervisePlan( - slug=self.slug, - queue_dir=self.queue_dir, - current_config_dir=self.work_dir / "current-config", - internal_network=self.internal_net, - )) - - -if __name__ == "__main__": - unittest.main()