Merge pull request 'refactor(sidecars): bundle is the only shape (PRD 0024 chunk 5)' (#59) from prd-0024-chunk-5-flag-removal into main
This commit was merged in pull request #59.
This commit is contained in:
@@ -1,69 +0,0 @@
|
|||||||
# Per-bottle egress sidecar image (PRD 0017).
|
|
||||||
#
|
|
||||||
# Replaces cred-proxy (PRD 0010). Sits on the agent's HTTP_PROXY /
|
|
||||||
# HTTPS_PROXY path (wiring lands in chunk 2) and owns three jobs:
|
|
||||||
# 1. MITM HTTPS using the per-bottle CA (chunk 2 moves the CA
|
|
||||||
# generation from pipelock).
|
|
||||||
# 2. Enforce manifest-declared path_allowlist per route.
|
|
||||||
# 3. Inject Authorization headers for routes that declare an auth
|
|
||||||
# block.
|
|
||||||
#
|
|
||||||
# Chunk 1 of PRD 0017 ships this image and the addon. Wiring it
|
|
||||||
# into the bottle launch (and the per-bottle CA + the pipelock
|
|
||||||
# upstream proxy) is chunk 2.
|
|
||||||
|
|
||||||
# mitmproxy base image. mitmdump + addon API are already there; we
|
|
||||||
# only need to drop our addon in. TODO: pin by digest.
|
|
||||||
FROM mitmproxy/mitmproxy:11.1.3
|
|
||||||
|
|
||||||
USER root
|
|
||||||
|
|
||||||
# The addon ships as three files. `_core.py` is pure-logic,
|
|
||||||
# importable both inside the container and from the host's tests;
|
|
||||||
# `_addon.py` is the mitmproxy hook wrapper; `yaml_subset.py` is
|
|
||||||
# the stdlib-only YAML parser the addon uses to read routes.yaml.
|
|
||||||
# All three land flat in /app/ so mitmdump's loader resolves them
|
|
||||||
# as top-level sibling modules (absolute imports).
|
|
||||||
COPY claude_bottle/egress_addon_core.py /app/egress_addon_core.py
|
|
||||||
COPY claude_bottle/egress_addon.py /app/egress_addon.py
|
|
||||||
COPY claude_bottle/yaml_subset.py /app/yaml_subset.py
|
|
||||||
|
|
||||||
# Pre-create the runtime directories the backend's start step will
|
|
||||||
# `docker cp` into. docker cp does not create intermediate dirs, so
|
|
||||||
# the mkdir must be baked into the image.
|
|
||||||
# /etc/egress routes.yaml lands here
|
|
||||||
# ~/.mitmproxy mitmproxy CA (cert+key concat) + the
|
|
||||||
# pipelock CA (cert only, for upstream
|
|
||||||
# trust on the HTTPS_PROXY=pipelock leg)
|
|
||||||
# Ownership lets the unprivileged mitmproxy user read the files.
|
|
||||||
RUN mkdir -p /etc/egress /home/mitmproxy/.mitmproxy \
|
|
||||||
&& chown -R mitmproxy:mitmproxy /etc/egress /home/mitmproxy/.mitmproxy /app
|
|
||||||
|
|
||||||
USER mitmproxy
|
|
||||||
|
|
||||||
# Listening port. Agents dial egress on this port via their
|
|
||||||
# HTTP_PROXY env. Surfaced as EXPOSE for documentation; not required
|
|
||||||
# for the internal network to route to it.
|
|
||||||
EXPOSE 9099
|
|
||||||
|
|
||||||
# Entrypoint:
|
|
||||||
# - Upstream proxy: when EGRESS_UPSTREAM_PROXY is set,
|
|
||||||
# use mitmproxy's `--mode upstream:URL` to forward all
|
|
||||||
# post-MITM traffic through pipelock. (mitmproxy does NOT
|
|
||||||
# honor HTTPS_PROXY env vars on its outbound side — it's a
|
|
||||||
# proxy server, not a client.) Standalone runs without
|
|
||||||
# EGRESS_UPSTREAM_PROXY fall back to `regular@9099`
|
|
||||||
# direct-to-upstream — useful for unit tests of the image.
|
|
||||||
# - Upstream trust: when EGRESS_UPSTREAM_CA is set, build
|
|
||||||
# a combined trust bundle (system roots + pipelock CA) and
|
|
||||||
# point mitmproxy at it via
|
|
||||||
# `--set ssl_verify_upstream_trusted_ca`. This option REPLACES
|
|
||||||
# mitmproxy's default trust store with the file we point it
|
|
||||||
# at — passing just pipelock's CA would break pipelock-
|
|
||||||
# passthrough hosts (api.anthropic.com etc.) where mitmproxy
|
|
||||||
# sees real upstream certs signed by public CAs. The combined
|
|
||||||
# bundle covers both pipelock-MITM'd and pipelock-passthrough
|
|
||||||
# hosts.
|
|
||||||
# - -s /app/egress_addon.py → loads our addon, reads
|
|
||||||
# /etc/egress/routes.yaml.
|
|
||||||
ENTRYPOINT ["sh", "-c", "MODE=\"--mode regular@9099\"; if [ -n \"$EGRESS_UPSTREAM_PROXY\" ]; then MODE=\"--mode upstream:$EGRESS_UPSTREAM_PROXY --listen-port 9099\"; fi; TRUST_FLAG=\"\"; if [ -n \"$EGRESS_UPSTREAM_CA\" ] && [ -f \"$EGRESS_UPSTREAM_CA\" ]; then COMBINED=/home/mitmproxy/.mitmproxy/combined-trust.pem; cat /etc/ssl/certs/ca-certificates.crt \"$EGRESS_UPSTREAM_CA\" > \"$COMBINED\"; TRUST_FLAG=\"--set ssl_verify_upstream_trusted_ca=$COMBINED\"; fi; exec mitmdump $MODE $TRUST_FLAG -s /app/egress_addon.py"]
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# Per-agent git-gate sidecar image (PRD 0008).
|
|
||||||
#
|
|
||||||
# Runs `git daemon --enable=receive-pack` so the agent in the bottle
|
|
||||||
# can push to it over git://. A shared pre-receive hook runs gitleaks
|
|
||||||
# against each incoming ref; on clean, it forwards the ref to the real
|
|
||||||
# upstream using a credential the gate holds. The agent never sees the
|
|
||||||
# upstream credential.
|
|
||||||
#
|
|
||||||
# The agent-facing leg sits on a Docker --internal network with no
|
|
||||||
# default route, so the image is fully self-contained: no apk pulls at
|
|
||||||
# boot, no remote registry lookups during the entrypoint.
|
|
||||||
|
|
||||||
# Base on the upstream gitleaks image (alpine + gitleaks v8.x);
|
|
||||||
# alpine doesn't package gitleaks so this avoids a separate
|
|
||||||
# install path. Pinned by digest for reproducibility.
|
|
||||||
FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f
|
|
||||||
|
|
||||||
# openssh-client supplies the upstream SSH transport the pre-receive
|
|
||||||
# hook uses to forward accepted refs. git-daemon is the listener the
|
|
||||||
# agent pushes to (alpine ships `git-daemon` as a sub-package, not
|
|
||||||
# part of `git`). The `git` core binary is already in the base image.
|
|
||||||
RUN apk add --no-cache openssh-client git-daemon
|
|
||||||
|
|
||||||
# Layout the gate uses at runtime:
|
|
||||||
# /git-gate-entrypoint.sh — docker-cp'd at start time
|
|
||||||
# /etc/git-gate/pre-receive — shared hook, docker-cp'd at start
|
|
||||||
# /git-gate/creds/<name>-key — per-upstream identity, docker-cp'd
|
|
||||||
# /git-gate/creds/<name>-known_hosts — per-upstream known_hosts, docker-cp'd
|
|
||||||
# /git/<name>.git — bare repos, created by the entrypoint
|
|
||||||
#
|
|
||||||
# The intermediate directories must exist before `docker cp` runs (cp
|
|
||||||
# does not create them); the bare-repo parent (/git) is also pre-created
|
|
||||||
# defensively.
|
|
||||||
RUN mkdir -p /etc/git-gate /git-gate/creds /git
|
|
||||||
|
|
||||||
# Base image's ENTRYPOINT is the gitleaks binary; override explicitly.
|
|
||||||
ENTRYPOINT ["/bin/sh", "/git-gate-entrypoint.sh"]
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# Per-bottle supervise sidecar image (PRD 0013).
|
|
||||||
#
|
|
||||||
# Exposes three MCP tools (cred-proxy-block, pipelock-block,
|
|
||||||
# capability-block) the agent calls to propose config changes when
|
|
||||||
# stuck. Each tool call writes a Proposal to a host-mounted queue
|
|
||||||
# dir and blocks waiting for the operator's Response.
|
|
||||||
#
|
|
||||||
# Stdlib-only Python. The bottle slug arrives via
|
|
||||||
# SUPERVISE_BOTTLE_SLUG; the host's ~/.claude-bottle/queue/<slug>/
|
|
||||||
# is bind-mounted at /run/supervise/queue.
|
|
||||||
|
|
||||||
# python:3.13-alpine, pinned by digest (same image cred-proxy uses,
|
|
||||||
# so docker pulls / caches once for both sidecars).
|
|
||||||
FROM python@sha256:420cd0bf0f3998275875e02ecd5808168cf0843cbb4d3c536432f729247b2acc
|
|
||||||
|
|
||||||
# Both files ship as single files into /app; supervise_server.py
|
|
||||||
# imports supervise via same-directory resolution.
|
|
||||||
COPY claude_bottle/supervise.py /app/supervise.py
|
|
||||||
COPY claude_bottle/supervise_server.py /app/supervise_server.py
|
|
||||||
|
|
||||||
# Pre-create the queue mount point so docker's bind-mount has a
|
|
||||||
# parent dir. Matches Dockerfile.cred-proxy's pattern.
|
|
||||||
RUN mkdir -p /run/supervise/queue
|
|
||||||
|
|
||||||
EXPOSE 9100
|
|
||||||
|
|
||||||
# WORKDIR makes the in-app same-dir import deterministic regardless
|
|
||||||
# of how the container is launched.
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# PID 1 is python for clean signal handling and exit codes.
|
|
||||||
ENTRYPOINT ["python3", "/app/supervise_server.py"]
|
|
||||||
@@ -69,16 +69,22 @@ pieces of v1.
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
A bottle is the agent container plus up to three per-protocol egress
|
A bottle is two containers per agent: an `agent` container, and a
|
||||||
sidecars on a per-agent Docker `--internal` network. The agent has no
|
`sidecars` container that bundles pipelock + egress + git-gate +
|
||||||
default route off-box. All HTTP and HTTPS egress — from the agent
|
supervise behind a Python init supervisor (PRD 0024). They share a
|
||||||
*and* from cred-proxy when it dials an upstream — funnels through
|
per-agent Docker `--internal` network; the agent has no default
|
||||||
pipelock, where the egress allowlist, TLS interception, and
|
route off-box. All HTTP and HTTPS egress funnels through pipelock,
|
||||||
request-body DLP scanner enforce the manifest before any byte leaves
|
where the egress allowlist, TLS interception, and request-body DLP
|
||||||
the host. The only egress that doesn't traverse pipelock is git-gate's
|
scanner enforce the manifest before any byte leaves the host. The
|
||||||
SSH push/fetch to `bottle.git` upstreams — pipelock can't proxy SSH,
|
only egress that doesn't traverse pipelock is git-gate's SSH
|
||||||
so git-gate is its own L4-style egress path with gitleaks doing the
|
push/fetch to `bottle.git` upstreams — pipelock can't proxy SSH,
|
||||||
pre-receive scan.
|
so git-gate is its own L4-style egress path with gitleaks doing
|
||||||
|
the pre-receive scan.
|
||||||
|
|
||||||
|
The agent dials the bundle by the legacy short names (`pipelock`,
|
||||||
|
`egress`, `git-gate`, `supervise`); the renderer registers those as
|
||||||
|
docker-network aliases on the bundle so existing HTTPS_PROXY URLs
|
||||||
|
and MCP endpoints resolve without an agent-side change.
|
||||||
|
|
||||||
```
|
```
|
||||||
host ( ./cli.py )
|
host ( ./cli.py )
|
||||||
|
|||||||
@@ -61,24 +61,19 @@ from ...util import expand_tilde
|
|||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
from .egress import (
|
from .egress import (
|
||||||
EGRESS_CA_IN_CONTAINER,
|
EGRESS_CA_IN_CONTAINER,
|
||||||
EGRESS_DOCKERFILE,
|
|
||||||
EGRESS_IMAGE,
|
|
||||||
EGRESS_PIPELOCK_CA_IN_CONTAINER,
|
EGRESS_PIPELOCK_CA_IN_CONTAINER,
|
||||||
egress_container_name,
|
egress_container_name,
|
||||||
)
|
)
|
||||||
from .git_gate import (
|
from .git_gate import (
|
||||||
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
|
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
|
||||||
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
||||||
GIT_GATE_DOCKERFILE,
|
|
||||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
||||||
GIT_GATE_HOOK_IN_CONTAINER,
|
GIT_GATE_HOOK_IN_CONTAINER,
|
||||||
GIT_GATE_IMAGE,
|
|
||||||
git_gate_container_name,
|
git_gate_container_name,
|
||||||
)
|
)
|
||||||
from .pipelock import (
|
from .pipelock import (
|
||||||
PIPELOCK_CA_CERT_IN_CONTAINER,
|
PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||||
PIPELOCK_CA_KEY_IN_CONTAINER,
|
PIPELOCK_CA_KEY_IN_CONTAINER,
|
||||||
PIPELOCK_IMAGE,
|
|
||||||
PIPELOCK_PORT,
|
PIPELOCK_PORT,
|
||||||
pipelock_container_name,
|
pipelock_container_name,
|
||||||
)
|
)
|
||||||
@@ -87,17 +82,11 @@ from .sidecar_bundle import (
|
|||||||
SIDECAR_BUNDLE_DOCKERFILE,
|
SIDECAR_BUNDLE_DOCKERFILE,
|
||||||
SIDECAR_BUNDLE_IMAGE,
|
SIDECAR_BUNDLE_IMAGE,
|
||||||
sidecar_bundle_container_name,
|
sidecar_bundle_container_name,
|
||||||
sidecar_bundle_enabled,
|
|
||||||
)
|
|
||||||
from .supervise import (
|
|
||||||
SUPERVISE_DOCKERFILE,
|
|
||||||
SUPERVISE_IMAGE,
|
|
||||||
supervise_container_name,
|
|
||||||
)
|
)
|
||||||
|
from .supervise import supervise_container_name
|
||||||
|
|
||||||
|
|
||||||
# Repo root, used as the build context for sidecar Dockerfiles.
|
# Repo root, used as the build context for the bundle Dockerfile.
|
||||||
# Same derivation as the per-sidecar lifecycle modules.
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||||
|
|
||||||
|
|
||||||
@@ -113,29 +102,10 @@ def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
spec back.
|
spec back.
|
||||||
"""
|
"""
|
||||||
project = f"claude-bottle-{plan.slug}"
|
project = f"claude-bottle-{plan.slug}"
|
||||||
services: dict[str, Any] = {}
|
services: dict[str, Any] = {
|
||||||
|
"sidecars": _sidecar_bundle_service(plan),
|
||||||
if sidecar_bundle_enabled():
|
"agent": _agent_service(plan),
|
||||||
# PRD 0024 bundle shape: one `sidecars` service running all
|
}
|
||||||
# four daemons under the bundle image's init supervisor.
|
|
||||||
services["sidecars"] = _sidecar_bundle_service(plan)
|
|
||||||
else:
|
|
||||||
# Legacy four-sidecar shape. Kept side-by-side behind the
|
|
||||||
# flag through chunks 2-4 so existing operators don't have
|
|
||||||
# to migrate atomically.
|
|
||||||
services["pipelock"] = _pipelock_service(plan)
|
|
||||||
|
|
||||||
if plan.git_gate_plan.upstreams:
|
|
||||||
services["git-gate"] = _git_gate_service(plan)
|
|
||||||
|
|
||||||
if plan.egress_plan.routes:
|
|
||||||
services["egress"] = _egress_service(plan)
|
|
||||||
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
services["supervise"] = _supervise_service(plan)
|
|
||||||
|
|
||||||
services["agent"] = _agent_service(plan)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"name": project,
|
"name": project,
|
||||||
"services": services,
|
"services": services,
|
||||||
@@ -173,47 +143,18 @@ def _bind(host: str | Path, target: str, *, read_only: bool = True) -> dict[str,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _pipelock_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|
||||||
"""Pipelock sidecar. Pinned-digest image (no build). The
|
|
||||||
rendered YAML config + CA cert + key bind-mount in from the
|
|
||||||
paths the prepare step laid down on plan.proxy_plan."""
|
|
||||||
pp = plan.proxy_plan
|
|
||||||
name = pipelock_container_name(plan.slug)
|
|
||||||
return {
|
|
||||||
"image": PIPELOCK_IMAGE,
|
|
||||||
"container_name": name,
|
|
||||||
"command": [
|
|
||||||
"run",
|
|
||||||
"--config", "/etc/pipelock.yaml",
|
|
||||||
"--listen", f"0.0.0.0:{PIPELOCK_PORT}",
|
|
||||||
],
|
|
||||||
"networks": {
|
|
||||||
"internal": {"aliases": [name]},
|
|
||||||
"egress": None,
|
|
||||||
},
|
|
||||||
"volumes": [
|
|
||||||
_bind(pp.yaml_path, "/etc/pipelock.yaml"),
|
|
||||||
_bind(pp.ca_cert_host_path, PIPELOCK_CA_CERT_IN_CONTAINER),
|
|
||||||
_bind(pp.ca_key_host_path, PIPELOCK_CA_KEY_IN_CONTAINER),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||||
"""The single `sidecars` service that replaces the four
|
"""The `sidecars` service: one container per bottle, bundle
|
||||||
per-sidecar containers (PRD 0024). One container per bottle,
|
image, all four daemons under a Python init supervisor.
|
||||||
bundle image, all four daemons under a Python init supervisor.
|
|
||||||
|
|
||||||
Mechanics:
|
Mechanics:
|
||||||
|
|
||||||
- Daemon subset narrows via `CLAUDE_BOTTLE_SIDECAR_DAEMONS`
|
- Daemon subset narrows via `CLAUDE_BOTTLE_SIDECAR_DAEMONS`
|
||||||
env. pipelock is always present; egress / git-gate /
|
env. pipelock is always present; egress / git-gate /
|
||||||
supervise are conditional on the plan, identical to the
|
supervise are conditional on the plan.
|
||||||
legacy branching.
|
- Volumes are the union of the four daemons' bind-mounts,
|
||||||
- Volumes are the UNION of what the four prior services
|
preserving the same in-container paths so each daemon
|
||||||
bind-mounted, preserving the same in-container paths so
|
finds its config / hooks / CA where it expects.
|
||||||
every daemon finds its config / hooks / CA where it
|
|
||||||
expects.
|
|
||||||
- Environment is the union of *daemon-private* env vars
|
- Environment is the union of *daemon-private* env vars
|
||||||
(EGRESS_UPSTREAM_PROXY, SUPERVISE_BOTTLE_SLUG, etc).
|
(EGRESS_UPSTREAM_PROXY, SUPERVISE_BOTTLE_SLUG, etc).
|
||||||
HTTPS_PROXY is NOT propagated here — see the comment in
|
HTTPS_PROXY is NOT propagated here — see the comment in
|
||||||
@@ -223,9 +164,8 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
- Network aliases register every legacy short/long
|
- Network aliases register every legacy short/long
|
||||||
hostname (pipelock, egress, git-gate, supervise plus
|
hostname (pipelock, egress, git-gate, supervise plus
|
||||||
their `claude-bottle-<service>-<slug>` long forms) so
|
their `claude-bottle-<service>-<slug>` long forms) so
|
||||||
any existing inter-service reference (notably the
|
the agent's HTTPS_PROXY URL and any other inter-service
|
||||||
agent's HTTPS_PROXY and depends_on lookups) resolves to
|
reference resolves to the bundle.
|
||||||
the bundle.
|
|
||||||
"""
|
"""
|
||||||
daemons: list[str] = ["egress", "pipelock"]
|
daemons: list[str] = ["egress", "pipelock"]
|
||||||
if plan.git_gate_plan.upstreams:
|
if plan.git_gate_plan.upstreams:
|
||||||
@@ -327,126 +267,6 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
return service
|
return service
|
||||||
|
|
||||||
|
|
||||||
def _git_gate_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|
||||||
"""Git-gate sidecar. Built from Dockerfile.git-gate. Entrypoint
|
|
||||||
+ pre-receive hook + access-hook bind-mount from the stage
|
|
||||||
paths the prepare step wrote. Per-upstream identity files
|
|
||||||
bind-mount from the user's ssh-key location after `~`
|
|
||||||
expansion. Per-upstream known_hosts files come in via chunk 2 —
|
|
||||||
the GitGatePlan doesn't carry those host paths yet (they're
|
|
||||||
currently materialized at start time by DockerGitGate.start).
|
|
||||||
"""
|
|
||||||
gp = plan.git_gate_plan
|
|
||||||
name = git_gate_container_name(plan.slug)
|
|
||||||
|
|
||||||
volumes: list[dict[str, Any]] = [
|
|
||||||
_bind(gp.entrypoint_script, GIT_GATE_ENTRYPOINT_IN_CONTAINER),
|
|
||||||
_bind(gp.hook_script, GIT_GATE_HOOK_IN_CONTAINER),
|
|
||||||
_bind(gp.access_hook_script, GIT_GATE_ACCESS_HOOK_IN_CONTAINER),
|
|
||||||
]
|
|
||||||
for u in gp.upstreams:
|
|
||||||
keypath = expand_tilde(u.identity_file)
|
|
||||||
volumes.append(_bind(
|
|
||||||
keypath,
|
|
||||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
|
|
||||||
))
|
|
||||||
|
|
||||||
service: dict[str, Any] = {
|
|
||||||
"image": GIT_GATE_IMAGE,
|
|
||||||
"build": {
|
|
||||||
"context": _REPO_DIR,
|
|
||||||
"dockerfile": GIT_GATE_DOCKERFILE,
|
|
||||||
},
|
|
||||||
"container_name": name,
|
|
||||||
"networks": {
|
|
||||||
"internal": {"aliases": [name]},
|
|
||||||
"egress": None,
|
|
||||||
},
|
|
||||||
"volumes": volumes,
|
|
||||||
}
|
|
||||||
extra_hosts = git_gate_aggregate_extra_hosts(gp.upstreams)
|
|
||||||
if extra_hosts:
|
|
||||||
service["extra_hosts"] = [
|
|
||||||
f"{host}:{ip}" for host, ip in sorted(extra_hosts.items())
|
|
||||||
]
|
|
||||||
return service
|
|
||||||
|
|
||||||
|
|
||||||
def _egress_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|
||||||
"""Egress sidecar. Built from Dockerfile.egress. Routes
|
|
||||||
through pipelock on its upstream leg via `EGRESS_UPSTREAM_PROXY` +
|
|
||||||
`EGRESS_UPSTREAM_CA`. One env-list entry per upstream-token slot
|
|
||||||
(bare NAME inherits from the compose-up process env, so secret
|
|
||||||
values stay off argv and out of the compose file). routes.yaml +
|
|
||||||
mitmproxy CA + pipelock CA bind-mount from the stage paths."""
|
|
||||||
ep = plan.egress_plan
|
|
||||||
name = egress_container_name(plan.slug)
|
|
||||||
|
|
||||||
env: list[str] = [
|
|
||||||
f"EGRESS_UPSTREAM_PROXY={ep.pipelock_proxy_url}",
|
|
||||||
f"HTTPS_PROXY={ep.pipelock_proxy_url}",
|
|
||||||
f"HTTP_PROXY={ep.pipelock_proxy_url}",
|
|
||||||
"NO_PROXY=localhost,127.0.0.1",
|
|
||||||
f"EGRESS_UPSTREAM_CA={EGRESS_PIPELOCK_CA_IN_CONTAINER}",
|
|
||||||
]
|
|
||||||
for token_env in sorted(ep.token_env_map.keys()):
|
|
||||||
env.append(token_env)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"image": EGRESS_IMAGE,
|
|
||||||
"build": {
|
|
||||||
"context": _REPO_DIR,
|
|
||||||
"dockerfile": EGRESS_DOCKERFILE,
|
|
||||||
},
|
|
||||||
"container_name": name,
|
|
||||||
"networks": {
|
|
||||||
"internal": {"aliases": [EGRESS_HOSTNAME]},
|
|
||||||
"egress": None,
|
|
||||||
},
|
|
||||||
"environment": env,
|
|
||||||
"volumes": [
|
|
||||||
_bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER),
|
|
||||||
_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER),
|
|
||||||
_bind(ep.pipelock_ca_host_path, EGRESS_PIPELOCK_CA_IN_CONTAINER),
|
|
||||||
],
|
|
||||||
"depends_on": ["pipelock"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _supervise_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|
||||||
"""Supervise sidecar. Internal network only — no upstream calls.
|
|
||||||
Queue dir bind-mounts read-write so the sidecar can append audit
|
|
||||||
events and the host-side capability handlers can drop new
|
|
||||||
proposals into it."""
|
|
||||||
sp = plan.supervise_plan
|
|
||||||
assert sp is not None
|
|
||||||
name = supervise_container_name(plan.slug)
|
|
||||||
return {
|
|
||||||
"image": SUPERVISE_IMAGE,
|
|
||||||
"build": {
|
|
||||||
"context": _REPO_DIR,
|
|
||||||
"dockerfile": SUPERVISE_DOCKERFILE,
|
|
||||||
},
|
|
||||||
"container_name": name,
|
|
||||||
"networks": {
|
|
||||||
"internal": {"aliases": [SUPERVISE_HOSTNAME]},
|
|
||||||
},
|
|
||||||
"environment": [
|
|
||||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
|
||||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
|
||||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
|
||||||
],
|
|
||||||
"volumes": [
|
|
||||||
{
|
|
||||||
"type": "bind",
|
|
||||||
"source": str(sp.queue_dir),
|
|
||||||
"target": QUEUE_DIR_IN_CONTAINER,
|
|
||||||
"read_only": False,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||||
"""Agent container. Runs `sleep infinity`; claude is `docker
|
"""Agent container. Runs `sleep infinity`; claude is `docker
|
||||||
exec -it`'d into it later. No TTY at the container level —
|
exec -it`'d into it later. No TTY at the container level —
|
||||||
@@ -494,20 +314,10 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
if volumes:
|
if volumes:
|
||||||
service["volumes"] = volumes
|
service["volumes"] = volumes
|
||||||
|
|
||||||
if sidecar_bundle_enabled():
|
# The init supervisor inside the bundle owns intra-bundle
|
||||||
# Bundle shape: a single dependency. The init supervisor
|
# daemon ordering, so the agent only waits for the bundle
|
||||||
# owns intra-bundle daemon ordering, so the agent only
|
# container itself.
|
||||||
# waits for the bundle container itself.
|
service["depends_on"] = ["sidecars"]
|
||||||
service["depends_on"] = ["sidecars"]
|
|
||||||
else:
|
|
||||||
depends_on = ["pipelock"]
|
|
||||||
if plan.git_gate_plan.upstreams:
|
|
||||||
depends_on.append("git-gate")
|
|
||||||
if plan.egress_plan.routes:
|
|
||||||
depends_on.append("egress")
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
depends_on.append("supervise")
|
|
||||||
service["depends_on"] = depends_on
|
|
||||||
|
|
||||||
return service
|
return service
|
||||||
|
|
||||||
|
|||||||
@@ -15,20 +15,11 @@ from pathlib import Path
|
|||||||
|
|
||||||
from ...egress import Egress
|
from ...egress import Egress
|
||||||
from ...log import die
|
from ...log import die
|
||||||
from . import util as docker_mod
|
|
||||||
|
|
||||||
|
|
||||||
|
# Listening port the egress daemon binds inside the bundle. The
|
||||||
|
# agent's HTTP_PROXY env var resolves to `http://egress:<port>`,
|
||||||
EGRESS_IMAGE = os.environ.get(
|
# and the bundle's network aliases route `egress` to itself.
|
||||||
"CLAUDE_BOTTLE_EGRESS_IMAGE",
|
|
||||||
"claude-bottle-egress:latest",
|
|
||||||
)
|
|
||||||
|
|
||||||
EGRESS_DOCKERFILE = "Dockerfile.egress"
|
|
||||||
|
|
||||||
# Listening port inside the sidecar. The agent's HTTP_PROXY env var
|
|
||||||
# resolves to `http://egress:<port>`.
|
|
||||||
EGRESS_PORT = int(os.environ.get("CLAUDE_BOTTLE_EGRESS_PORT", "9099"))
|
EGRESS_PORT = int(os.environ.get("CLAUDE_BOTTLE_EGRESS_PORT", "9099"))
|
||||||
|
|
||||||
# In-container path for mitmproxy's CA. The format is a single PEM
|
# In-container path for mitmproxy's CA. The format is a single PEM
|
||||||
@@ -41,33 +32,15 @@ EGRESS_PIPELOCK_CA_IN_CONTAINER = (
|
|||||||
"/home/mitmproxy/.mitmproxy/pipelock-ca.pem"
|
"/home/mitmproxy/.mitmproxy/pipelock-ca.pem"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Repo root, for `docker build` context. Resolved from this file's
|
|
||||||
# location: claude_bottle/backend/docker/egress.py → repo root.
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
||||||
|
|
||||||
|
|
||||||
def egress_container_name(slug: str) -> str:
|
def egress_container_name(slug: str) -> str:
|
||||||
|
"""The legacy per-sidecar container name. Kept as a function so
|
||||||
|
the renderer can register it as a docker-network alias on the
|
||||||
|
bundle — any code still referring to `claude-bottle-egress-<slug>`
|
||||||
|
resolves to the bundle's IP."""
|
||||||
return f"claude-bottle-egress-{slug}"
|
return f"claude-bottle-egress-{slug}"
|
||||||
|
|
||||||
|
|
||||||
def egress_url() -> str:
|
|
||||||
"""Base URL the agent will dial via HTTP_PROXY (chunk 2). Stable
|
|
||||||
across bottles because the sidecar attaches `--network-alias
|
|
||||||
egress` on the internal network; the container name (which
|
|
||||||
carries the slug) is not referenced by agent-side config."""
|
|
||||||
return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}"
|
|
||||||
|
|
||||||
|
|
||||||
def build_egress_image() -> None:
|
|
||||||
"""Build the egress image from `Dockerfile.egress`.
|
|
||||||
Called by `DockerEgress.start`; exposed at module level so
|
|
||||||
integration tests can build it without running the full launch
|
|
||||||
pipeline."""
|
|
||||||
docker_mod.build_image(
|
|
||||||
EGRESS_IMAGE, _REPO_DIR, dockerfile=EGRESS_DOCKERFILE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
||||||
"""Mint the per-bottle egress MITM CA via host `openssl req`.
|
"""Mint the per-bottle egress MITM CA via host `openssl req`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +1,39 @@
|
|||||||
"""DockerGitGate — the Docker-specific lifecycle for the per-agent
|
"""DockerGitGate — Docker-flavored git-gate config (PRD 0008).
|
||||||
git-gate sidecar (PRD 0008). Inherits the platform-agnostic prepare
|
Inherits the platform-agnostic prepare step (upstream lift +
|
||||||
step (upstream lift + entrypoint/hook render) from `GitGate`."""
|
entrypoint/hook render) from `GitGate`. The git-gate daemon runs
|
||||||
|
inside the sidecar bundle (PRD 0024); this module just holds the
|
||||||
|
in-container paths the renderer's bind-mounts target."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ...git_gate import GitGate
|
from ...git_gate import GitGate
|
||||||
from . import util as docker_mod
|
|
||||||
|
|
||||||
|
|
||||||
GIT_GATE_IMAGE = os.environ.get(
|
|
||||||
"CLAUDE_BOTTLE_GIT_GATE_IMAGE",
|
|
||||||
"claude-bottle-git-gate:latest",
|
|
||||||
)
|
|
||||||
|
|
||||||
GIT_GATE_DOCKERFILE = "Dockerfile.git-gate"
|
|
||||||
|
|
||||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER = "/git-gate-entrypoint.sh"
|
GIT_GATE_ENTRYPOINT_IN_CONTAINER = "/git-gate-entrypoint.sh"
|
||||||
GIT_GATE_HOOK_IN_CONTAINER = "/etc/git-gate/pre-receive"
|
GIT_GATE_HOOK_IN_CONTAINER = "/etc/git-gate/pre-receive"
|
||||||
GIT_GATE_ACCESS_HOOK_IN_CONTAINER = "/etc/git-gate/access-hook"
|
GIT_GATE_ACCESS_HOOK_IN_CONTAINER = "/etc/git-gate/access-hook"
|
||||||
GIT_GATE_CREDS_DIR_IN_CONTAINER = "/git-gate/creds"
|
GIT_GATE_CREDS_DIR_IN_CONTAINER = "/git-gate/creds"
|
||||||
|
|
||||||
# git daemon's default listening port. Surfaced as a constant because
|
# git daemon's default listening port.
|
||||||
# integration tests probe the gate on it.
|
|
||||||
GIT_GATE_PORT = 9418
|
GIT_GATE_PORT = 9418
|
||||||
|
|
||||||
# Repo root, for `docker build` context. Resolved from this file's
|
|
||||||
# location: claude_bottle/backend/docker/git_gate.py → repo root.
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
||||||
|
|
||||||
|
|
||||||
def git_gate_container_name(slug: str) -> str:
|
def git_gate_container_name(slug: str) -> str:
|
||||||
|
"""The legacy per-sidecar container name. Kept as a function so
|
||||||
|
the renderer can register it as a docker-network alias on the
|
||||||
|
bundle — any code still dialing `claude-bottle-git-gate-<slug>`
|
||||||
|
resolves to the bundle's IP."""
|
||||||
return f"claude-bottle-git-gate-{slug}"
|
return f"claude-bottle-git-gate-{slug}"
|
||||||
|
|
||||||
|
|
||||||
def git_gate_host(slug: str) -> str:
|
def git_gate_host(slug: str) -> str:
|
||||||
"""The hostname the agent's git client should connect to (same as
|
"""The hostname the agent's git client connects to. Resolves via
|
||||||
the container name — Docker's embedded DNS resolves it on the
|
the bundle's network alias to the bundle container, where the
|
||||||
`--internal` network)."""
|
git-gate daemon listens on GIT_GATE_PORT."""
|
||||||
return git_gate_container_name(slug)
|
return git_gate_container_name(slug)
|
||||||
|
|
||||||
|
|
||||||
def build_git_gate_image() -> None:
|
|
||||||
"""Build the git-gate image from `Dockerfile.git-gate`. Called by
|
|
||||||
`DockerGitGate.start`; exposed at module level so integration
|
|
||||||
tests can build it without running the full launch pipeline."""
|
|
||||||
docker_mod.build_image(GIT_GATE_IMAGE, _REPO_DIR, dockerfile=GIT_GATE_DOCKERFILE)
|
|
||||||
|
|
||||||
|
|
||||||
class DockerGitGate(GitGate):
|
class DockerGitGate(GitGate):
|
||||||
"""Docker-flavored GitGate: inherits `.prepare()` from the base.
|
"""Docker-flavored GitGate: inherits `.prepare()` from the base.
|
||||||
Container lifecycle is owned by compose; per-container
|
The git-gate daemon's container lifecycle is owned by the
|
||||||
`.start()` / `.stop()` were removed in PRD 0024 chunk 3."""
|
sidecar bundle (PRD 0024)."""
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from pathlib import Path
|
|||||||
from ...pipelock import pipelock_render_yaml
|
from ...pipelock import pipelock_render_yaml
|
||||||
from ...yaml_subset import YamlSubsetError, parse_yaml_subset
|
from ...yaml_subset import YamlSubsetError, parse_yaml_subset
|
||||||
from .bottle_state import pipelock_state_dir
|
from .bottle_state import pipelock_state_dir
|
||||||
from .pipelock import pipelock_container_name
|
from .sidecar_bundle import sidecar_bundle_container_name
|
||||||
|
|
||||||
|
|
||||||
def _pipelock_yaml_host_path(slug: str) -> Path:
|
def _pipelock_yaml_host_path(slug: str) -> Path:
|
||||||
@@ -73,15 +73,15 @@ def render_allowlist_content(hosts: list[str]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def fetch_current_yaml(slug: str) -> str:
|
def fetch_current_yaml(slug: str) -> str:
|
||||||
"""Read the live /etc/pipelock.yaml from the pipelock sidecar.
|
"""Read the live /etc/pipelock.yaml from the sidecar bundle.
|
||||||
|
|
||||||
Uses `docker cp` (not `docker exec cat`) because the pipelock
|
Uses `docker cp` because pipelock inside the bundle is the
|
||||||
image is distroless and has no shell utilities. `docker cp` is a
|
distroless pipelock binary with no shell, and `docker cp` is a
|
||||||
daemon-API tarball copy — works on stopped containers too, and
|
daemon-API tarball copy that works regardless of what's
|
||||||
doesn't need anything in the container's PATH.
|
available inside the container.
|
||||||
|
|
||||||
Raises PipelockApplyError if the read fails."""
|
Raises PipelockApplyError if the read fails."""
|
||||||
container = pipelock_container_name(slug)
|
container = sidecar_bundle_container_name(slug)
|
||||||
fd, tmp_path = tempfile.mkstemp(prefix="cb-pipelock-fetch.", suffix=".yaml")
|
fd, tmp_path = tempfile.mkstemp(prefix="cb-pipelock-fetch.", suffix=".yaml")
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
try:
|
try:
|
||||||
@@ -125,19 +125,27 @@ def fetch_current_allowlist(slug: str) -> str:
|
|||||||
def apply_allowlist_change(
|
def apply_allowlist_change(
|
||||||
slug: str, new_allowlist_content: str,
|
slug: str, new_allowlist_content: str,
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""Apply `new_allowlist_content` to the pipelock sidecar:
|
"""Apply `new_allowlist_content` to the sidecar bundle:
|
||||||
1. Parse the proposed hosts (one per line).
|
1. Parse the proposed hosts (one per line).
|
||||||
2. Fetch + parse current pipelock.yaml.
|
2. Fetch + parse current pipelock.yaml.
|
||||||
3. Replace api_allowlist with the proposed hosts; re-render.
|
3. Replace api_allowlist with the proposed hosts; re-render.
|
||||||
4. docker cp the new yaml into the sidecar.
|
4. Write the new yaml to the bind-mount source.
|
||||||
5. docker restart so pipelock reloads.
|
5. `docker restart` the bundle so pipelock reloads.
|
||||||
|
|
||||||
|
The restart bounces ALL four daemons inside the bundle, not
|
||||||
|
just pipelock — pipelock has no in-process reload and the
|
||||||
|
bundle init re-spawns the four daemons on container restart.
|
||||||
|
Per-daemon reload would need a supervisor IPC channel (PRD
|
||||||
|
0024 open question 1's "eventually" path); the bundle-wide
|
||||||
|
restart is the v1 trade-off.
|
||||||
|
|
||||||
Returns (before, after) where both are one-per-line allowlist
|
Returns (before, after) where both are one-per-line allowlist
|
||||||
strings (operator-facing format). Raises PipelockApplyError on
|
strings (operator-facing format). Raises PipelockApplyError on
|
||||||
any failure; the sidecar's existing config stays in place until
|
any failure; the sidecar's existing config stays in place until
|
||||||
docker cp succeeds, and the restart is what makes it live."""
|
the host write succeeds, and the restart is what makes it
|
||||||
|
live."""
|
||||||
new_hosts = parse_allowlist_content(new_allowlist_content)
|
new_hosts = parse_allowlist_content(new_allowlist_content)
|
||||||
container = pipelock_container_name(slug)
|
container = sidecar_bundle_container_name(slug)
|
||||||
current_yaml = fetch_current_yaml(slug)
|
current_yaml = fetch_current_yaml(slug)
|
||||||
try:
|
try:
|
||||||
cfg = parse_yaml_subset(current_yaml)
|
cfg = parse_yaml_subset(current_yaml)
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
"""Sidecar bundle constants + helpers for the Docker backend
|
"""Sidecar bundle constants + helpers for the Docker backend
|
||||||
(PRD 0024 chunk 2).
|
(PRD 0024).
|
||||||
|
|
||||||
The bundle image (built by Dockerfile.sidecars, see PRD 0024
|
The bundle image (built by Dockerfile.sidecars, PRD 0024 chunk 1)
|
||||||
chunk 1) collapses pipelock + egress + git-gate + supervise into
|
runs pipelock + egress + git-gate + supervise as one container
|
||||||
one container per bottle. Whether the renderer emits the bundle
|
per bottle under a small Python init supervisor. As of chunk 5
|
||||||
shape (one `sidecars` service) or the legacy four-sidecar shape
|
the bundle is the only shape — the legacy four-sidecar topology
|
||||||
is controlled by `CLAUDE_BOTTLE_SIDECAR_BUNDLE`; chunk 2 ships
|
and its `CLAUDE_BOTTLE_SIDECAR_BUNDLE` feature flag are gone."""
|
||||||
both shapes side by side behind the flag so existing operators
|
|
||||||
keep working unchanged while the bundle path soaks.
|
|
||||||
|
|
||||||
This module is intentionally tiny — just the constants + the
|
|
||||||
flag + the container-name helper. The compose-renderer branch
|
|
||||||
that consumes it lives in `compose.py`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -36,17 +29,3 @@ def sidecar_bundle_container_name(slug: str) -> str:
|
|||||||
per-sidecar containers it replaces, so the dashboard's
|
per-sidecar containers it replaces, so the dashboard's
|
||||||
discovery-by-prefix logic keeps working."""
|
discovery-by-prefix logic keeps working."""
|
||||||
return f"claude-bottle-sidecars-{slug}"
|
return f"claude-bottle-sidecars-{slug}"
|
||||||
|
|
||||||
|
|
||||||
def sidecar_bundle_enabled(env: dict[str, str] | None = None) -> bool:
|
|
||||||
"""Feature-flag check. The flag is opt-in for chunk 2:
|
|
||||||
unset / "" / "0" / "false" → legacy four-sidecar shape;
|
|
||||||
anything else → bundle shape. Chunks 4-5 flip the default and
|
|
||||||
then delete the flag.
|
|
||||||
|
|
||||||
`env` defaults to `os.environ` at call time so tests can
|
|
||||||
monkey-patch the environment without re-importing the module."""
|
|
||||||
if env is None:
|
|
||||||
env = dict(os.environ)
|
|
||||||
raw = env.get("CLAUDE_BOTTLE_SIDECAR_BUNDLE", "").strip().lower()
|
|
||||||
return raw not in ("", "0", "false", "no", "off")
|
|
||||||
|
|||||||
@@ -1,49 +1,23 @@
|
|||||||
"""DockerSupervise — the Docker-specific lifecycle for the per-bottle
|
"""DockerSupervise — Docker-flavored supervise config (PRD 0013).
|
||||||
supervise sidecar (PRD 0013). Inherits the platform-agnostic prepare
|
Inherits the platform-agnostic prepare step (queue dir +
|
||||||
step (queue dir + current-config staging) from `Supervise`."""
|
current-config staging) from `Supervise`. The supervise daemon
|
||||||
|
runs inside the sidecar bundle (PRD 0024); this module just holds
|
||||||
|
the container-name helper the renderer's network alias targets."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
from ...supervise import Supervise
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ...supervise import (
|
|
||||||
SUPERVISE_HOSTNAME,
|
|
||||||
SUPERVISE_PORT,
|
|
||||||
Supervise,
|
|
||||||
)
|
|
||||||
from . import util as docker_mod
|
|
||||||
|
|
||||||
|
|
||||||
SUPERVISE_IMAGE = os.environ.get(
|
|
||||||
"CLAUDE_BOTTLE_SUPERVISE_IMAGE",
|
|
||||||
"claude-bottle-supervise:latest",
|
|
||||||
)
|
|
||||||
|
|
||||||
SUPERVISE_DOCKERFILE = "Dockerfile.supervise"
|
|
||||||
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
||||||
|
|
||||||
|
|
||||||
def supervise_container_name(slug: str) -> str:
|
def supervise_container_name(slug: str) -> str:
|
||||||
|
"""The legacy per-sidecar container name. Kept as a function so
|
||||||
|
the renderer can register it as a docker-network alias on the
|
||||||
|
bundle — any code still referring to
|
||||||
|
`claude-bottle-supervise-<slug>` resolves to the bundle's IP."""
|
||||||
return f"claude-bottle-supervise-{slug}"
|
return f"claude-bottle-supervise-{slug}"
|
||||||
|
|
||||||
|
|
||||||
def supervise_url() -> str:
|
|
||||||
"""Base URL the agent's MCP client dials. Stable across bottles
|
|
||||||
because the sidecar attaches `--network-alias supervise` on the
|
|
||||||
internal network."""
|
|
||||||
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}"
|
|
||||||
|
|
||||||
|
|
||||||
def build_supervise_image() -> None:
|
|
||||||
"""Build the supervise image from `Dockerfile.supervise`. Called
|
|
||||||
by `DockerSupervise.start`; exposed at module level so tests can
|
|
||||||
build it without running the full launch pipeline."""
|
|
||||||
docker_mod.build_image(SUPERVISE_IMAGE, _REPO_DIR, dockerfile=SUPERVISE_DOCKERFILE)
|
|
||||||
|
|
||||||
|
|
||||||
class DockerSupervise(Supervise):
|
class DockerSupervise(Supervise):
|
||||||
"""Docker-flavored Supervise: inherits `.prepare()` from the base.
|
"""Docker-flavored Supervise: inherits `.prepare()` from the base.
|
||||||
Container lifecycle is owned by compose; per-container
|
The supervise daemon's container lifecycle is owned by the
|
||||||
`.start()` / `.stop()` were removed in PRD 0024 chunk 3."""
|
sidecar bundle (PRD 0024)."""
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ from claude_bottle.backend.docker.pipelock import (
|
|||||||
PIPELOCK_IMAGE,
|
PIPELOCK_IMAGE,
|
||||||
PIPELOCK_PORT,
|
PIPELOCK_PORT,
|
||||||
DockerPipelockProxy,
|
DockerPipelockProxy,
|
||||||
pipelock_container_name,
|
|
||||||
pipelock_tls_init,
|
pipelock_tls_init,
|
||||||
)
|
)
|
||||||
from claude_bottle.backend.docker.pipelock_apply import (
|
from claude_bottle.backend.docker.pipelock_apply import (
|
||||||
@@ -47,6 +46,9 @@ from claude_bottle.backend.docker.pipelock_apply import (
|
|||||||
fetch_current_allowlist,
|
fetch_current_allowlist,
|
||||||
fetch_current_yaml,
|
fetch_current_yaml,
|
||||||
)
|
)
|
||||||
|
from claude_bottle.backend.docker.sidecar_bundle import (
|
||||||
|
sidecar_bundle_container_name,
|
||||||
|
)
|
||||||
from claude_bottle.yaml_subset import parse_yaml_subset
|
from claude_bottle.yaml_subset import parse_yaml_subset
|
||||||
from tests._docker import skip_unless_docker
|
from tests._docker import skip_unless_docker
|
||||||
from tests.fixtures import fixture_minimal
|
from tests.fixtures import fixture_minimal
|
||||||
@@ -107,7 +109,14 @@ class TestPipelockApply(unittest.TestCase):
|
|||||||
self.egress_net = network_create_egress(self.slug)
|
self.egress_net = network_create_egress(self.slug)
|
||||||
ca_cert_host, ca_key_host = pipelock_tls_init(state_dir)
|
ca_cert_host, ca_key_host = pipelock_tls_init(state_dir)
|
||||||
|
|
||||||
self.sidecar_name = pipelock_container_name(self.slug)
|
# apply_allowlist_change targets sidecar_bundle_container_name
|
||||||
|
# (chunk 5 flipped the bundle to the only shape). Bringing the
|
||||||
|
# standalone pipelock up under that name keeps this test
|
||||||
|
# exercising the real production code path; the bundle's
|
||||||
|
# other three daemons aren't running here, but the
|
||||||
|
# apply/fetch code only touches /etc/pipelock.yaml + the
|
||||||
|
# pipelock binary, so the lighter setup is fine.
|
||||||
|
self.sidecar_name = sidecar_bundle_container_name(self.slug)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "create",
|
["docker", "create",
|
||||||
"--name", self.sidecar_name,
|
"--name", self.sidecar_name,
|
||||||
|
|||||||
+12
-242
@@ -196,55 +196,6 @@ class TestProjectAndNetworks(unittest.TestCase):
|
|||||||
self.assertNotIn("internal", net)
|
self.assertNotIn("internal", net)
|
||||||
|
|
||||||
|
|
||||||
class TestPipelockAlwaysPresent(unittest.TestCase):
|
|
||||||
"""Pipelock is unconditional — every bottle has the SSRF guard +
|
|
||||||
body scanner sitting on its upstream leg."""
|
|
||||||
|
|
||||||
def test_minimal_plan_has_pipelock(self):
|
|
||||||
spec = bottle_plan_to_compose(_plan())
|
|
||||||
self.assertIn("pipelock", spec["services"])
|
|
||||||
|
|
||||||
def test_pipelock_pinned_image_no_build(self):
|
|
||||||
s = bottle_plan_to_compose(_plan())["services"]["pipelock"]
|
|
||||||
self.assertTrue(s["image"].startswith("ghcr.io/luckypipewrench/pipelock"))
|
|
||||||
self.assertNotIn("build", s)
|
|
||||||
|
|
||||||
def test_pipelock_container_name(self):
|
|
||||||
s = bottle_plan_to_compose(_plan())["services"]["pipelock"]
|
|
||||||
self.assertEqual(f"claude-bottle-pipelock-{SLUG}", s["container_name"])
|
|
||||||
|
|
||||||
def test_pipelock_on_both_networks(self):
|
|
||||||
s = bottle_plan_to_compose(_plan())["services"]["pipelock"]
|
|
||||||
self.assertIn("internal", s["networks"])
|
|
||||||
self.assertIn("egress", s["networks"])
|
|
||||||
|
|
||||||
def test_pipelock_long_name_alias_on_internal(self):
|
|
||||||
# Backward compat: anything still dialing pipelock by
|
|
||||||
# `claude-bottle-pipelock-<slug>` resolves on the internal
|
|
||||||
# network.
|
|
||||||
s = bottle_plan_to_compose(_plan())["services"]["pipelock"]
|
|
||||||
aliases = s["networks"]["internal"]["aliases"]
|
|
||||||
self.assertIn(f"claude-bottle-pipelock-{SLUG}", aliases)
|
|
||||||
|
|
||||||
def test_pipelock_bind_mounts(self):
|
|
||||||
s = bottle_plan_to_compose(_plan())["services"]["pipelock"]
|
|
||||||
targets = {v["target"] for v in s["volumes"]}
|
|
||||||
self.assertEqual(
|
|
||||||
{"/etc/pipelock.yaml", "/etc/pipelock-ca.pem", "/etc/pipelock-ca-key.pem"},
|
|
||||||
targets,
|
|
||||||
)
|
|
||||||
for v in s["volumes"]:
|
|
||||||
self.assertEqual("bind", v["type"])
|
|
||||||
self.assertTrue(v["read_only"])
|
|
||||||
|
|
||||||
def test_pipelock_command(self):
|
|
||||||
s = bottle_plan_to_compose(_plan())["services"]["pipelock"]
|
|
||||||
self.assertEqual(
|
|
||||||
["run", "--config", "/etc/pipelock.yaml", "--listen", "0.0.0.0:8888"],
|
|
||||||
s["command"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAgentAlwaysPresent(unittest.TestCase):
|
class TestAgentAlwaysPresent(unittest.TestCase):
|
||||||
def test_agent_in_services(self):
|
def test_agent_in_services(self):
|
||||||
s = bottle_plan_to_compose(_plan())["services"]
|
s = bottle_plan_to_compose(_plan())["services"]
|
||||||
@@ -298,18 +249,13 @@ class TestAgentAlwaysPresent(unittest.TestCase):
|
|||||||
s = bottle_plan_to_compose(plan)["services"]["agent"]
|
s = bottle_plan_to_compose(plan)["services"]["agent"]
|
||||||
self.assertEqual("runsc", s["runtime"])
|
self.assertEqual("runsc", s["runtime"])
|
||||||
|
|
||||||
def test_agent_depends_on_pipelock(self):
|
def test_agent_depends_only_on_sidecars(self):
|
||||||
s = bottle_plan_to_compose(_plan())["services"]["agent"]
|
# Bundle shape: the init supervisor owns intra-bundle daemon
|
||||||
self.assertIn("pipelock", s["depends_on"])
|
# ordering, so the agent waits on the bundle container alone.
|
||||||
|
for kwargs in [{}, {"with_git": True, "with_egress": True, "supervise": True}]:
|
||||||
def test_agent_depends_on_every_present_sidecar(self):
|
with self.subTest(**kwargs):
|
||||||
s = bottle_plan_to_compose(
|
s = bottle_plan_to_compose(_plan(**kwargs))["services"]["agent"]
|
||||||
_plan(with_git=True, with_egress=True, supervise=True)
|
self.assertEqual(["sidecars"], s["depends_on"])
|
||||||
)["services"]["agent"]
|
|
||||||
self.assertEqual(
|
|
||||||
{"pipelock", "git-gate", "egress", "supervise"},
|
|
||||||
set(s["depends_on"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_agent_current_config_mount_only_with_supervise(self):
|
def test_agent_current_config_mount_only_with_supervise(self):
|
||||||
with_sv = bottle_plan_to_compose(_plan(supervise=True))["services"]["agent"]
|
with_sv = bottle_plan_to_compose(_plan(supervise=True))["services"]["agent"]
|
||||||
@@ -325,146 +271,14 @@ class TestAgentAlwaysPresent(unittest.TestCase):
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
class TestConditionalGitGate(unittest.TestCase):
|
|
||||||
def test_absent_when_no_upstreams(self):
|
|
||||||
s = bottle_plan_to_compose(_plan(with_git=False))["services"]
|
|
||||||
self.assertNotIn("git-gate", s)
|
|
||||||
|
|
||||||
def test_present_when_upstreams(self):
|
|
||||||
s = bottle_plan_to_compose(_plan(with_git=True))["services"]
|
|
||||||
self.assertIn("git-gate", s)
|
|
||||||
|
|
||||||
def test_git_gate_built_from_dockerfile(self):
|
|
||||||
s = bottle_plan_to_compose(_plan(with_git=True))["services"]["git-gate"]
|
|
||||||
self.assertEqual("Dockerfile.git-gate", s["build"]["dockerfile"])
|
|
||||||
self.assertEqual("claude-bottle-git-gate:latest", s["image"])
|
|
||||||
|
|
||||||
def test_git_gate_extra_hosts(self):
|
|
||||||
s = bottle_plan_to_compose(_plan(with_git=True))["services"]["git-gate"]
|
|
||||||
self.assertIn("example.com:10.0.0.1", s["extra_hosts"])
|
|
||||||
|
|
||||||
def test_git_gate_identity_file_bind_mount(self):
|
|
||||||
s = bottle_plan_to_compose(_plan(with_git=True))["services"]["git-gate"]
|
|
||||||
# Per-upstream identity file is mounted at /git-gate/creds/<name>-key.
|
|
||||||
self.assertTrue(any(
|
|
||||||
v["target"] == "/git-gate/creds/upstream-key"
|
|
||||||
for v in s["volumes"]
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
class TestConditionalEgress(unittest.TestCase):
|
|
||||||
def test_absent_when_no_routes(self):
|
|
||||||
s = bottle_plan_to_compose(_plan(with_egress=False))["services"]
|
|
||||||
self.assertNotIn("egress", s)
|
|
||||||
|
|
||||||
def test_present_when_routes(self):
|
|
||||||
s = bottle_plan_to_compose(_plan(with_egress=True))["services"]
|
|
||||||
self.assertIn("egress", s)
|
|
||||||
|
|
||||||
def test_egress_alias_on_internal(self):
|
|
||||||
s = bottle_plan_to_compose(_plan(with_egress=True))["services"]["egress"]
|
|
||||||
self.assertIn("egress", s["networks"]["internal"]["aliases"])
|
|
||||||
|
|
||||||
def test_egress_upstream_envs(self):
|
|
||||||
s = bottle_plan_to_compose(_plan(with_egress=True))["services"]["egress"]
|
|
||||||
env = s["environment"]
|
|
||||||
self.assertIn(
|
|
||||||
f"EGRESS_UPSTREAM_PROXY=http://claude-bottle-pipelock-{SLUG}:8888",
|
|
||||||
env,
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
"EGRESS_UPSTREAM_CA=/home/mitmproxy/.mitmproxy/pipelock-ca.pem",
|
|
||||||
env,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_egress_token_slot_bare_name(self):
|
|
||||||
# Bare NAME entry in environment list → value inherits from
|
|
||||||
# compose process env, never lands in the rendered file.
|
|
||||||
s = bottle_plan_to_compose(_plan(with_egress=True))["services"]["egress"]
|
|
||||||
self.assertIn("EGRESS_TOKEN_0", s["environment"])
|
|
||||||
|
|
||||||
def test_egress_depends_on_pipelock(self):
|
|
||||||
s = bottle_plan_to_compose(_plan(with_egress=True))["services"]["egress"]
|
|
||||||
self.assertIn("pipelock", s["depends_on"])
|
|
||||||
|
|
||||||
def test_egress_bind_mounts(self):
|
|
||||||
s = bottle_plan_to_compose(_plan(with_egress=True))["services"]["egress"]
|
|
||||||
targets = {v["target"] for v in s["volumes"]}
|
|
||||||
self.assertEqual(
|
|
||||||
{
|
|
||||||
"/etc/egress/routes.yaml",
|
|
||||||
"/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem",
|
|
||||||
"/home/mitmproxy/.mitmproxy/pipelock-ca.pem",
|
|
||||||
},
|
|
||||||
targets,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestConditionalSupervise(unittest.TestCase):
|
|
||||||
def test_absent_when_off(self):
|
|
||||||
s = bottle_plan_to_compose(_plan(supervise=False))["services"]
|
|
||||||
self.assertNotIn("supervise", s)
|
|
||||||
|
|
||||||
def test_present_when_on(self):
|
|
||||||
s = bottle_plan_to_compose(_plan(supervise=True))["services"]
|
|
||||||
self.assertIn("supervise", s)
|
|
||||||
|
|
||||||
def test_supervise_internal_only(self):
|
|
||||||
s = bottle_plan_to_compose(_plan(supervise=True))["services"]["supervise"]
|
|
||||||
self.assertEqual({"internal"}, set(s["networks"].keys()))
|
|
||||||
|
|
||||||
def test_supervise_alias_on_internal(self):
|
|
||||||
s = bottle_plan_to_compose(_plan(supervise=True))["services"]["supervise"]
|
|
||||||
self.assertIn("supervise", s["networks"]["internal"]["aliases"])
|
|
||||||
|
|
||||||
def test_supervise_queue_dir_mounted_rw(self):
|
|
||||||
s = bottle_plan_to_compose(_plan(supervise=True))["services"]["supervise"]
|
|
||||||
queue_mount = [v for v in s["volumes"] if v["target"] == "/run/supervise/queue"]
|
|
||||||
self.assertEqual(1, len(queue_mount))
|
|
||||||
self.assertFalse(queue_mount[0]["read_only"])
|
|
||||||
|
|
||||||
def test_supervise_env_vars(self):
|
|
||||||
s = bottle_plan_to_compose(_plan(supervise=True))["services"]["supervise"]
|
|
||||||
self.assertIn(f"SUPERVISE_BOTTLE_SLUG={SLUG}", s["environment"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestFullMatrix(unittest.TestCase):
|
|
||||||
"""The eight combinations of git/egress/supervise toggles. Just
|
|
||||||
asserts which services appear — content correctness is covered
|
|
||||||
per-service above."""
|
|
||||||
|
|
||||||
def test_matrix(self):
|
|
||||||
cases: list[tuple[bool, bool, bool, set[str]]] = []
|
|
||||||
for g in (False, True):
|
|
||||||
for e in (False, True):
|
|
||||||
for sv in (False, True):
|
|
||||||
expected = {"pipelock", "agent"}
|
|
||||||
if g:
|
|
||||||
expected.add("git-gate")
|
|
||||||
if e:
|
|
||||||
expected.add("egress")
|
|
||||||
if sv:
|
|
||||||
expected.add("supervise")
|
|
||||||
cases.append((g, e, sv, expected))
|
|
||||||
|
|
||||||
for g, e, sv, expected in cases:
|
|
||||||
with self.subTest(git=g, egress=e, supervise=sv):
|
|
||||||
s = bottle_plan_to_compose(
|
|
||||||
_plan(with_git=g, with_egress=e, supervise=sv)
|
|
||||||
)["services"]
|
|
||||||
self.assertEqual(expected, set(s.keys()))
|
|
||||||
|
|
||||||
|
|
||||||
class TestSidecarBundleShape(unittest.TestCase):
|
class TestSidecarBundleShape(unittest.TestCase):
|
||||||
"""PRD 0024 chunk 2: with CLAUDE_BOTTLE_SIDECAR_BUNDLE=1 set,
|
"""The compose renderer emits exactly one `sidecars` service in
|
||||||
the renderer emits a `sidecars` service in place of the four
|
place of the four daemons it owns (pipelock + egress + git-gate
|
||||||
per-sidecar services. The legacy four-sidecar tests above
|
+ supervise). PRD 0024 chunk 5 dropped the legacy four-sidecar
|
||||||
cover the flag-off shape; these lock down the flag-on shape."""
|
shape entirely, so the bundle is the only thing exercised here."""
|
||||||
|
|
||||||
def _render(self, **plan_kwargs):
|
def _render(self, **plan_kwargs):
|
||||||
from unittest.mock import patch
|
return bottle_plan_to_compose(_plan(**plan_kwargs))
|
||||||
with patch.dict("os.environ", {"CLAUDE_BOTTLE_SIDECAR_BUNDLE": "1"}):
|
|
||||||
return bottle_plan_to_compose(_plan(**plan_kwargs))
|
|
||||||
|
|
||||||
def test_emits_two_services_minimal(self):
|
def test_emits_two_services_minimal(self):
|
||||||
spec = self._render()
|
spec = self._render()
|
||||||
@@ -619,50 +433,6 @@ class TestSidecarBundleShape(unittest.TestCase):
|
|||||||
spec["services"]["sidecars"]["networks"]["internal"]["aliases"])
|
spec["services"]["sidecars"]["networks"]["internal"]["aliases"])
|
||||||
|
|
||||||
|
|
||||||
class TestSidecarBundleFlag(unittest.TestCase):
|
|
||||||
"""The flag's parser: covers the cases sidecar_bundle_enabled
|
|
||||||
has to interpret correctly so an operator-supplied value lands
|
|
||||||
in the expected shape."""
|
|
||||||
|
|
||||||
def _enabled(self, value: str | None) -> bool:
|
|
||||||
from claude_bottle.backend.docker.sidecar_bundle import (
|
|
||||||
sidecar_bundle_enabled,
|
|
||||||
)
|
|
||||||
env: dict[str, str] = {}
|
|
||||||
if value is not None:
|
|
||||||
env["CLAUDE_BOTTLE_SIDECAR_BUNDLE"] = value
|
|
||||||
return sidecar_bundle_enabled(env)
|
|
||||||
|
|
||||||
def test_unset_disabled(self):
|
|
||||||
self.assertFalse(self._enabled(None))
|
|
||||||
|
|
||||||
def test_empty_disabled(self):
|
|
||||||
self.assertFalse(self._enabled(""))
|
|
||||||
|
|
||||||
def test_zero_disabled(self):
|
|
||||||
self.assertFalse(self._enabled("0"))
|
|
||||||
|
|
||||||
def test_false_disabled(self):
|
|
||||||
self.assertFalse(self._enabled("false"))
|
|
||||||
self.assertFalse(self._enabled("FALSE"))
|
|
||||||
|
|
||||||
def test_no_disabled(self):
|
|
||||||
self.assertFalse(self._enabled("no"))
|
|
||||||
self.assertFalse(self._enabled("off"))
|
|
||||||
|
|
||||||
def test_one_enabled(self):
|
|
||||||
self.assertTrue(self._enabled("1"))
|
|
||||||
|
|
||||||
def test_true_enabled(self):
|
|
||||||
self.assertTrue(self._enabled("true"))
|
|
||||||
|
|
||||||
def test_arbitrary_truthy_enabled(self):
|
|
||||||
# The flag treats anything other than the known falsy
|
|
||||||
# values as truthy — operator typos default to "enabled"
|
|
||||||
# which is the safer interpretation for an opt-in flag.
|
|
||||||
self.assertTrue(self._enabled("yes"))
|
|
||||||
|
|
||||||
|
|
||||||
class TestProjectNaming(unittest.TestCase):
|
class TestProjectNaming(unittest.TestCase):
|
||||||
"""The slug ↔ compose-project mapping is the contract dashboard,
|
"""The slug ↔ compose-project mapping is the contract dashboard,
|
||||||
cleanup, and launch all rely on. Lock it down."""
|
cleanup, and launch all rely on. Lock it down."""
|
||||||
|
|||||||
Reference in New Issue
Block a user