Files
bot-bottle/claude_bottle/backend/docker/pipelock.py
T
didericis 0eb482daf0
test / unit (pull_request) Successful in 19s
test / integration (pull_request) Successful in 26s
fix(docker): surface sidecar docker errors + probe for name orphans
Two failure-clarity paper cuts from the cred-proxy debugging:

1. Every docker create / start / network-connect call on the three
   sidecars (pipelock, git-gate, cred-proxy) was piping stderr to
   DEVNULL. A stuck orphan from a previous run produced "failed to
   create pipelock sidecar claude-bottle-pipelock-demo" with no
   pointer at the real cause ("Conflict. The container name ... is
   already in use ..."). Switch each call to capture_output=True and
   include the stripped stderr in the die() message.

2. The agent container had a container_exists() probe in resolve_plan
   that fails fast with a hint, but the sidecars (whose names are
   deterministic from the slug) didn't. So an orphan caused launch()
   to bail deep inside docker create. Add a probe in resolve_plan for
   each sidecar this launch will actually try to create: pipelock
   always; git-gate when bottle.git is non-empty; cred-proxy when
   bottle.cred_proxy.routes is non-empty. Die with a "./cli.py
   cleanup" pointer.

Smoke-tested with an orphaned pipelock-<slug> container — the new
probe fires with the expected hint before any sidecar build/start
work begins.
2026-05-24 12:33:54 -04:00

188 lines
7.2 KiB
Python

"""DockerPipelockProxy — the Docker-specific implementation of the
sidecar's start/stop lifecycle. Inherits the platform-agnostic
YAML-config generation from PipelockProxy."""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
from ...log import die, info, warn
from ...pipelock import PipelockProxy, PipelockProxyPlan
# Pipelock image, pinned by digest. The digest is the multi-arch image
# index for ghcr.io/luckypipewrench/pipelock:2.3.0.
PIPELOCK_IMAGE = os.environ.get(
"CLAUDE_BOTTLE_PIPELOCK_IMAGE",
"ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9",
)
# 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`.
PIPELOCK_CA_CERT_IN_CONTAINER = "/etc/pipelock-ca.pem"
PIPELOCK_CA_KEY_IN_CONTAINER = "/etc/pipelock-ca-key.pem"
def pipelock_container_name(slug: str) -> str:
return f"claude-bottle-pipelock-{slug}"
def pipelock_proxy_url(slug: str) -> str:
return f"http://{pipelock_container_name(slug)}:{PIPELOCK_PORT}"
def pipelock_tls_init(stage_dir: Path) -> tuple[Path, Path]:
"""Generate a fresh per-bottle CA via a one-shot pipelock container.
Runs `pipelock tls init` against a host-mounted scratch dir, leaving
`ca.pem` (public cert, mode 600) and `ca-key.pem` (private key, mode
600) under `<stage_dir>/pipelock-ca/`. Returns the two host paths.
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."""
work = stage_dir / "pipelock-ca"
work.mkdir(exist_ok=True)
result = subprocess.run(
["docker", "run", "--rm",
"-v", f"{work}:/h",
"-e", "PIPELOCK_HOME=/h",
PIPELOCK_IMAGE, "tls", "init"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
die(f"pipelock tls init failed: {result.stderr.strip()}")
cert = work / "ca.pem"
key = work / "ca-key.pem"
if not cert.is_file() or not key.is_file():
die(f"pipelock tls init did not produce ca files in {work}")
return (cert, key)
class DockerPipelockProxy(PipelockProxy):
"""Brings the pipelock sidecar up and down via Docker."""
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:<port>`.
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}'"
)