refactor(sidecars): bundle is the only shape (PRD 0024 chunk 5)
The CLAUDE_BOTTLE_SIDECAR_BUNDLE feature flag is gone. Every
bottle ships with the agent + bundle pair — no opt-in, no legacy
four-sidecar fallback.
Changes:
- Renderer (compose.py): bottle_plan_to_compose unconditionally
emits {agent, sidecars}. Deleted _pipelock_service,
_git_gate_service, _egress_service, _supervise_service helpers.
_agent_service.depends_on collapses to ["sidecars"].
- sidecar_bundle.py: deleted sidecar_bundle_enabled (the flag
parser). SIDECAR_BUNDLE_IMAGE + container-name helper stay.
- pipelock_apply.py: docker cp + docker restart now target
sidecar_bundle_container_name(slug). Bundle restart bounces
all four daemons together (per-daemon reload is the eventual
feature, not v1).
- Per-sidecar modules trimmed:
- egress.py: dropped EGRESS_IMAGE, EGRESS_DOCKERFILE,
build_egress_image, egress_url. Kept EGRESS_PORT, CA paths,
egress_container_name (still used by the renderer's network
aliases).
- git_gate.py: dropped GIT_GATE_IMAGE, GIT_GATE_DOCKERFILE,
build_git_gate_image. Kept git_gate_host + GIT_GATE_PORT.
- supervise.py: dropped SUPERVISE_IMAGE, SUPERVISE_DOCKERFILE,
build_supervise_image, supervise_url.
- Deleted Dockerfile.{egress,git-gate,supervise}. The bundle's
Dockerfile.sidecars is the only sidecar image now.
- test_compose.py: deleted TestPipelockAlwaysPresent,
TestConditionalGitGate, TestConditionalEgress,
TestConditionalSupervise, TestFullMatrix (legacy-shape only),
TestSidecarBundleFlag (flag is gone). TestSidecarBundleShape
drops its patch.dict wrapper. TestAgentAlwaysPresent's
depends_on cases collapse to one.
- test_pipelock_apply.py: bringup container name uses
sidecar_bundle_container_name(slug) to match the production
target.
- README.md Architecture section rewritten to describe the
agent + bundle pair.
Net: -626 lines.
Test status: 498 unit + 27 integration + 1 skipped (chunk-4
pending — superseded by this chunk's rewrite). Locally verified
end-to-end bottle launch produces exactly 2 containers
(claude-bottle-<slug> + claude-bottle-sidecars-<slug>).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -61,24 +61,19 @@ from ...util import expand_tilde
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
from .egress import (
|
||||
EGRESS_CA_IN_CONTAINER,
|
||||
EGRESS_DOCKERFILE,
|
||||
EGRESS_IMAGE,
|
||||
EGRESS_PIPELOCK_CA_IN_CONTAINER,
|
||||
egress_container_name,
|
||||
)
|
||||
from .git_gate import (
|
||||
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
|
||||
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
||||
GIT_GATE_DOCKERFILE,
|
||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
||||
GIT_GATE_HOOK_IN_CONTAINER,
|
||||
GIT_GATE_IMAGE,
|
||||
git_gate_container_name,
|
||||
)
|
||||
from .pipelock import (
|
||||
PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||
PIPELOCK_CA_KEY_IN_CONTAINER,
|
||||
PIPELOCK_IMAGE,
|
||||
PIPELOCK_PORT,
|
||||
pipelock_container_name,
|
||||
)
|
||||
@@ -87,17 +82,11 @@ from .sidecar_bundle import (
|
||||
SIDECAR_BUNDLE_DOCKERFILE,
|
||||
SIDECAR_BUNDLE_IMAGE,
|
||||
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.
|
||||
# Same derivation as the per-sidecar lifecycle modules.
|
||||
# Repo root, used as the build context for the bundle Dockerfile.
|
||||
_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.
|
||||
"""
|
||||
project = f"claude-bottle-{plan.slug}"
|
||||
services: dict[str, Any] = {}
|
||||
|
||||
if sidecar_bundle_enabled():
|
||||
# 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)
|
||||
|
||||
services: dict[str, Any] = {
|
||||
"sidecars": _sidecar_bundle_service(plan),
|
||||
"agent": _agent_service(plan),
|
||||
}
|
||||
return {
|
||||
"name": project,
|
||||
"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]:
|
||||
"""The single `sidecars` service that replaces the four
|
||||
per-sidecar containers (PRD 0024). One container per bottle,
|
||||
bundle image, all four daemons under a Python init supervisor.
|
||||
"""The `sidecars` service: one container per bottle, bundle
|
||||
image, all four daemons under a Python init supervisor.
|
||||
|
||||
Mechanics:
|
||||
|
||||
- Daemon subset narrows via `CLAUDE_BOTTLE_SIDECAR_DAEMONS`
|
||||
env. pipelock is always present; egress / git-gate /
|
||||
supervise are conditional on the plan, identical to the
|
||||
legacy branching.
|
||||
- Volumes are the UNION of what the four prior services
|
||||
bind-mounted, preserving the same in-container paths so
|
||||
every daemon finds its config / hooks / CA where it
|
||||
expects.
|
||||
supervise are conditional on the plan.
|
||||
- Volumes are the union of the four daemons' bind-mounts,
|
||||
preserving the same in-container paths so each daemon
|
||||
finds its config / hooks / CA where it expects.
|
||||
- Environment is the union of *daemon-private* env vars
|
||||
(EGRESS_UPSTREAM_PROXY, SUPERVISE_BOTTLE_SLUG, etc).
|
||||
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
|
||||
hostname (pipelock, egress, git-gate, supervise plus
|
||||
their `claude-bottle-<service>-<slug>` long forms) so
|
||||
any existing inter-service reference (notably the
|
||||
agent's HTTPS_PROXY and depends_on lookups) resolves to
|
||||
the bundle.
|
||||
the agent's HTTPS_PROXY URL and any other inter-service
|
||||
reference resolves to the bundle.
|
||||
"""
|
||||
daemons: list[str] = ["egress", "pipelock"]
|
||||
if plan.git_gate_plan.upstreams:
|
||||
@@ -327,126 +267,6 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
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]:
|
||||
"""Agent container. Runs `sleep infinity`; claude is `docker
|
||||
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:
|
||||
service["volumes"] = volumes
|
||||
|
||||
if sidecar_bundle_enabled():
|
||||
# Bundle shape: a single dependency. The init supervisor
|
||||
# owns intra-bundle daemon ordering, so the agent only
|
||||
# waits for the bundle container itself.
|
||||
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
|
||||
# The init supervisor inside the bundle owns intra-bundle
|
||||
# daemon ordering, so the agent only waits for the bundle
|
||||
# container itself.
|
||||
service["depends_on"] = ["sidecars"]
|
||||
|
||||
return service
|
||||
|
||||
|
||||
@@ -15,20 +15,11 @@ from pathlib import Path
|
||||
|
||||
from ...egress import Egress
|
||||
from ...log import die
|
||||
from . import util as docker_mod
|
||||
|
||||
|
||||
|
||||
|
||||
EGRESS_IMAGE = os.environ.get(
|
||||
"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>`.
|
||||
# Listening port the egress daemon binds inside the bundle. The
|
||||
# agent's HTTP_PROXY env var resolves to `http://egress:<port>`,
|
||||
# and the bundle's network aliases route `egress` to itself.
|
||||
EGRESS_PORT = int(os.environ.get("CLAUDE_BOTTLE_EGRESS_PORT", "9099"))
|
||||
|
||||
# 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"
|
||||
)
|
||||
|
||||
# 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:
|
||||
"""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}"
|
||||
|
||||
|
||||
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]:
|
||||
"""Mint the per-bottle egress MITM CA via host `openssl req`.
|
||||
|
||||
|
||||
@@ -1,56 +1,39 @@
|
||||
"""DockerGitGate — the Docker-specific lifecycle for the per-agent
|
||||
git-gate sidecar (PRD 0008). Inherits the platform-agnostic prepare
|
||||
step (upstream lift + entrypoint/hook render) from `GitGate`."""
|
||||
"""DockerGitGate — Docker-flavored git-gate config (PRD 0008).
|
||||
Inherits the platform-agnostic prepare step (upstream lift +
|
||||
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
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
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_HOOK_IN_CONTAINER = "/etc/git-gate/pre-receive"
|
||||
GIT_GATE_ACCESS_HOOK_IN_CONTAINER = "/etc/git-gate/access-hook"
|
||||
GIT_GATE_CREDS_DIR_IN_CONTAINER = "/git-gate/creds"
|
||||
|
||||
# git daemon's default listening port. Surfaced as a constant because
|
||||
# integration tests probe the gate on it.
|
||||
# git daemon's default listening port.
|
||||
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:
|
||||
"""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}"
|
||||
|
||||
|
||||
def git_gate_host(slug: str) -> str:
|
||||
"""The hostname the agent's git client should connect to (same as
|
||||
the container name — Docker's embedded DNS resolves it on the
|
||||
`--internal` network)."""
|
||||
"""The hostname the agent's git client connects to. Resolves via
|
||||
the bundle's network alias to the bundle container, where the
|
||||
git-gate daemon listens on GIT_GATE_PORT."""
|
||||
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):
|
||||
"""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."""
|
||||
The git-gate daemon's container lifecycle is owned by the
|
||||
sidecar bundle (PRD 0024)."""
|
||||
|
||||
@@ -25,7 +25,7 @@ from pathlib import Path
|
||||
from ...pipelock import pipelock_render_yaml
|
||||
from ...yaml_subset import YamlSubsetError, parse_yaml_subset
|
||||
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:
|
||||
@@ -73,15 +73,15 @@ def render_allowlist_content(hosts: list[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
|
||||
image is distroless and has no shell utilities. `docker cp` is a
|
||||
daemon-API tarball copy — works on stopped containers too, and
|
||||
doesn't need anything in the container's PATH.
|
||||
Uses `docker cp` because pipelock inside the bundle is the
|
||||
distroless pipelock binary with no shell, and `docker cp` is a
|
||||
daemon-API tarball copy that works regardless of what's
|
||||
available inside the container.
|
||||
|
||||
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")
|
||||
os.close(fd)
|
||||
try:
|
||||
@@ -125,19 +125,27 @@ def fetch_current_allowlist(slug: str) -> str:
|
||||
def apply_allowlist_change(
|
||||
slug: str, new_allowlist_content: 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).
|
||||
2. Fetch + parse current pipelock.yaml.
|
||||
3. Replace api_allowlist with the proposed hosts; re-render.
|
||||
4. docker cp the new yaml into the sidecar.
|
||||
5. docker restart so pipelock reloads.
|
||||
4. Write the new yaml to the bind-mount source.
|
||||
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
|
||||
strings (operator-facing format). Raises PipelockApplyError on
|
||||
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)
|
||||
container = pipelock_container_name(slug)
|
||||
container = sidecar_bundle_container_name(slug)
|
||||
current_yaml = fetch_current_yaml(slug)
|
||||
try:
|
||||
cfg = parse_yaml_subset(current_yaml)
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
"""Sidecar bundle constants + helpers for the Docker backend
|
||||
(PRD 0024 chunk 2).
|
||||
(PRD 0024).
|
||||
|
||||
The bundle image (built by Dockerfile.sidecars, see PRD 0024
|
||||
chunk 1) collapses pipelock + egress + git-gate + supervise into
|
||||
one container per bottle. Whether the renderer emits the bundle
|
||||
shape (one `sidecars` service) or the legacy four-sidecar shape
|
||||
is controlled by `CLAUDE_BOTTLE_SIDECAR_BUNDLE`; chunk 2 ships
|
||||
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`.
|
||||
"""
|
||||
The bundle image (built by Dockerfile.sidecars, PRD 0024 chunk 1)
|
||||
runs pipelock + egress + git-gate + supervise as one container
|
||||
per bottle under a small Python init supervisor. As of chunk 5
|
||||
the bundle is the only shape — the legacy four-sidecar topology
|
||||
and its `CLAUDE_BOTTLE_SIDECAR_BUNDLE` feature flag are gone."""
|
||||
|
||||
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
|
||||
discovery-by-prefix logic keeps working."""
|
||||
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
|
||||
supervise sidecar (PRD 0013). Inherits the platform-agnostic prepare
|
||||
step (queue dir + current-config staging) from `Supervise`."""
|
||||
"""DockerSupervise — Docker-flavored supervise config (PRD 0013).
|
||||
Inherits the platform-agnostic prepare step (queue dir +
|
||||
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
|
||||
|
||||
import os
|
||||
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)
|
||||
from ...supervise import Supervise
|
||||
|
||||
|
||||
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}"
|
||||
|
||||
|
||||
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):
|
||||
"""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."""
|
||||
The supervise daemon's container lifecycle is owned by the
|
||||
sidecar bundle (PRD 0024)."""
|
||||
|
||||
Reference in New Issue
Block a user