4760a09263
PRD 0018 chunk 1. New module `claude_bottle/backend/docker/compose.py` exposing `bottle_plan_to_compose(plan) -> dict` — a pure function that translates a fully-resolved DockerBottlePlan into a Compose v2 spec. Not wired in yet. Tests cover the conditional-service matrix (git on/off × egress on/off × supervise on/off) plus per-service shape (images vs builds, network aliases, bind mounts, env vars, depends_on).
386 lines
13 KiB
Python
386 lines
13 KiB
Python
"""Compose-spec rendering for a Docker bottle (PRD 0018, chunk 1).
|
|
|
|
`bottle_plan_to_compose(plan)` returns a Compose v2 spec dict
|
|
describing the per-bottle container topology — one project per
|
|
bottle instance, services for the agent + every applicable sidecar,
|
|
two networks, no named volumes.
|
|
|
|
Pure function. No I/O, no subprocess. Expects every launch-time
|
|
field (network names, CA host paths, etc.) on the plan's inner
|
|
plans to be populated; chunks 2+3 own that ordering. Chunk 1 just
|
|
encodes the translation so it can be unit-tested in isolation.
|
|
|
|
Conditional services follow the plan content (matches the
|
|
SDK-call branching in `launch.py` today):
|
|
|
|
- pipelock + agent: always.
|
|
- git-gate: iff plan.git_gate_plan.upstreams.
|
|
- egress: iff plan.egress_plan.routes.
|
|
- supervise: iff plan.supervise_plan is not None.
|
|
|
|
Naming:
|
|
|
|
- Compose project: `claude-bottle-<slug>`.
|
|
- Service names (inside the file): `agent`, `pipelock`,
|
|
`egress`, `git-gate`, `supervise`.
|
|
- `container_name:` matches today's pattern
|
|
(`claude-bottle-<service>-<slug>`) so dashboard/cleanup discovery
|
|
via the prefix scan keeps working through the transition.
|
|
- Network aliases preserve the current dial-by-shortname pattern
|
|
for `egress` / `supervise`, and add the long container-name as
|
|
an internal-network alias for `pipelock` / `git-gate` so any
|
|
caller still referencing the long name resolves.
|
|
|
|
Sidecars that are built (egress, git-gate, supervise) get a
|
|
compose `build:` block pointing at the repo Dockerfile; the
|
|
`image:` tag is set explicitly so cached images on the daemon
|
|
aren't rebuilt on every up.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from ...egress import (
|
|
EGRESS_HOSTNAME,
|
|
EGRESS_ROUTES_IN_CONTAINER,
|
|
)
|
|
from ...git_gate import git_gate_aggregate_extra_hosts
|
|
from ...supervise import (
|
|
CURRENT_CONFIG_DIR_IN_AGENT,
|
|
QUEUE_DIR_IN_CONTAINER,
|
|
SUPERVISE_HOSTNAME,
|
|
SUPERVISE_PORT,
|
|
)
|
|
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,
|
|
)
|
|
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
|
from .supervise import (
|
|
SUPERVISE_DOCKERFILE,
|
|
SUPERVISE_IMAGE,
|
|
supervise_container_name,
|
|
)
|
|
|
|
|
|
# Repo root, used as the build context for sidecar Dockerfiles.
|
|
# Same derivation as the per-sidecar lifecycle modules.
|
|
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
|
|
|
|
def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]:
|
|
"""Render a Compose v2 spec dict from a fully-resolved
|
|
DockerBottlePlan.
|
|
|
|
The plan must have its inner plans (`proxy_plan`,
|
|
`git_gate_plan`, `egress_plan`, `supervise_plan`) populated
|
|
with launch-time fields — network names, CA host paths,
|
|
pipelock_proxy_url. The renderer doesn't validate; callers
|
|
feed it a fully-resolved plan or get an incomplete compose
|
|
spec back.
|
|
"""
|
|
project = f"claude-bottle-{plan.slug}"
|
|
services: dict[str, Any] = {}
|
|
|
|
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 {
|
|
"name": project,
|
|
"services": services,
|
|
"networks": _networks(plan),
|
|
}
|
|
|
|
|
|
def _networks(plan: DockerBottlePlan) -> dict[str, Any]:
|
|
"""Two compose-managed networks with explicit `name:` matching
|
|
the existing slug-suffixed convention. The internal one is
|
|
`--internal` (no default gateway); the egress one is a normal
|
|
user-defined bridge so the upstream-bound sidecars can resolve
|
|
+ reach the outside world."""
|
|
return {
|
|
"internal": {
|
|
"name": plan.proxy_plan.internal_network,
|
|
"internal": True,
|
|
},
|
|
"egress": {
|
|
"name": plan.proxy_plan.egress_network,
|
|
},
|
|
}
|
|
|
|
|
|
def _bind(host: str | Path, target: str, *, read_only: bool = True) -> dict[str, Any]:
|
|
"""One bind-mount entry in the long-form `volumes:` shape.
|
|
Long form is preferred over `host:target:ro` strings because
|
|
it's easier to inspect in tests and survives whitespace in
|
|
host paths."""
|
|
return {
|
|
"type": "bind",
|
|
"source": str(host),
|
|
"target": target,
|
|
"read_only": read_only,
|
|
}
|
|
|
|
|
|
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 _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 —
|
|
interactivity is per-exec. HTTP_PROXY/HTTPS_PROXY point at the
|
|
egress short-alias when an egress is declared, otherwise
|
|
straight at pipelock's container name. CA trust trio matches
|
|
the existing launch.py wiring."""
|
|
proxy_url = _agent_proxy_url(plan)
|
|
no_proxy = _agent_no_proxy(plan)
|
|
env: list[str] = [
|
|
f"HTTPS_PROXY={proxy_url}",
|
|
f"HTTP_PROXY={proxy_url}",
|
|
f"https_proxy={proxy_url}",
|
|
f"http_proxy={proxy_url}",
|
|
f"NO_PROXY={no_proxy}",
|
|
f"no_proxy={no_proxy}",
|
|
f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}",
|
|
f"SSL_CERT_FILE={AGENT_CA_BUNDLE}",
|
|
f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}",
|
|
]
|
|
# Forwarded vars (OAuth token, manifest host-interpolations):
|
|
# bare name → inherits from compose-up process env, value
|
|
# never lands on argv or in the compose file.
|
|
for name in sorted(plan.forwarded_env.keys()):
|
|
env.append(name)
|
|
|
|
service: dict[str, Any] = {
|
|
"image": plan.runtime_image,
|
|
"container_name": plan.container_name,
|
|
"command": ["sleep", "infinity"],
|
|
"networks": {"internal": None},
|
|
"environment": env,
|
|
}
|
|
if plan.use_runsc:
|
|
service["runtime"] = "runsc"
|
|
if plan.env_file and plan.env_file.exists() and plan.env_file.stat().st_size > 0:
|
|
service["env_file"] = [str(plan.env_file)]
|
|
|
|
volumes: list[dict[str, Any]] = []
|
|
if plan.supervise_plan is not None:
|
|
volumes.append(_bind(
|
|
plan.supervise_plan.current_config_dir,
|
|
CURRENT_CONFIG_DIR_IN_AGENT,
|
|
))
|
|
if volumes:
|
|
service["volumes"] = volumes
|
|
|
|
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
|
|
|
|
|
|
def _agent_proxy_url(plan: DockerBottlePlan) -> str:
|
|
"""Pick the agent's HTTP_PROXY. With egress declared, the agent
|
|
goes through egress (which in turn HTTPS_PROXYs to pipelock on
|
|
its outbound leg). Without egress, the agent talks straight to
|
|
pipelock."""
|
|
if plan.egress_plan.routes:
|
|
from .egress import EGRESS_PORT
|
|
return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}"
|
|
return f"http://{pipelock_container_name(plan.slug)}:{PIPELOCK_PORT}"
|
|
|
|
|
|
def _agent_no_proxy(plan: DockerBottlePlan) -> str:
|
|
"""NO_PROXY for the agent. Matches the launch.py rules:
|
|
loopback always, supervise hostname when the supervise sidecar
|
|
is up (the MCP long-poll pattern needs to bypass pipelock's
|
|
idle timeout)."""
|
|
hosts = ["localhost", "127.0.0.1"]
|
|
if plan.supervise_plan is not None:
|
|
hosts.append(SUPERVISE_HOSTNAME)
|
|
return ",".join(hosts)
|
|
|
|
|
|
__all__ = ["bottle_plan_to_compose"]
|