69db629e16
Rewire DockerBottleBackend.launch to the consolidated model, replacing the
per-bottle sidecar bundle. VALIDATED END-TO-END on real docker:
test_sandbox_escape passes all 5 attacks (egress DLP + git-gate gitleaks)
through the shared gateway.
launch now:
- mints git-gate dynamic keys (if any), then launch_consolidated() to
register the bottle + provision its git-gate state into the gateway and
get the agent's attach context (pinned source IP, gateway address);
- installs the SHARED gateway CA (gateway.ca_cert_pem) into the agent;
- renders the agent-only consolidated compose on the gateway network at the
pinned IP, proxied through the gateway;
- points the agent's git-gate insteadOf (http://<gw>:9420) and supervise
MCP (http://<gw>:9100) at the gateway (DockerBottlePlan.agent_git_gate_url
/ agent_supervise_url);
- teardown = compose down + teardown_consolidated (dereg + deprovision).
Dropped the per-bottle networks, per-bottle egress CA, and bundle service.
Fixes surfaced by the real e2e (couldn't be caught by unit mocks):
- provision_git_gate installs the bottle-agnostic pre-receive/access hooks
into the gateway (were cp'd per-bundle before);
- source-IP allocation reads the *actual* container IPs on the network
(gateway + orchestrator + agents), not just gateway + registry — the
orchestrator container sits on the network and was colliding.
Unit tests for the old bundle launch rewritten against the new collaborators.
NOTE for reviewers: the gateway/bundle image (bot-bottle-sidecars) must be
rebuilt when its flat sources change — `ensure_built` only builds-if-missing,
so a stale image silently runs the OLD single-tenant daemons. A content-hash
/ --rebuild path is a follow-up.
pyright 0 errors; pylint 9.83/10; unit suite green (1760 tests; the 13
test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise);
test_sandbox_escape green on real docker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
195 lines
7.6 KiB
Python
195 lines
7.6 KiB
Python
"""Launch step for the Docker bottle backend.
|
|
|
|
PRD 0018 chunk 3: each instance is one `docker compose` project.
|
|
|
|
The flow is:
|
|
|
|
1. Build the agent image from the provider Dockerfile (compose
|
|
builds the sidecar images via the `build:` directive on first up).
|
|
2. Mint the per-bottle egress CA (chunk 2 writes it under
|
|
state/<slug>/egress/).
|
|
3. Populate the inner plans with launch-time fields so the
|
|
renderer can read network names, CA paths.
|
|
6. Render the compose spec, write it to
|
|
state/<slug>/docker-compose.yml, write metadata.json.
|
|
7. `docker compose up -d` (token + OAuth values flow into the
|
|
compose subprocess env so `environment: [NAME]` bare-name
|
|
entries inherit without rendering values into the file).
|
|
8. Provision (CA install, prompt copy, skills, workspace, git,
|
|
supervise config) — unchanged, uses `docker exec` / `docker cp`.
|
|
9. Yield a DockerBottle handle. `exec_agent` runs claude via
|
|
`docker exec -it` exactly like the pre-compose world.
|
|
|
|
Teardown (ExitStack callbacks fire in reverse):
|
|
- Dump `docker compose logs --no-color --timestamps` to
|
|
state/<slug>/compose.log (best-effort).
|
|
- `docker compose down` removes the project's containers (not the
|
|
external networks).
|
|
- `network_remove` deletes the two networks we pre-created.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import os
|
|
from contextlib import ExitStack, contextmanager
|
|
from pathlib import Path
|
|
from typing import Callable, Generator
|
|
|
|
from ...git_gate import (
|
|
provision_git_gate_dynamic_keys,
|
|
revoke_git_gate_provisioned_keys,
|
|
)
|
|
from ...log import info, warn
|
|
from . import util as docker_mod
|
|
from .bottle import DockerBottle
|
|
from .bottle_plan import DockerBottlePlan
|
|
from ...bottle_state import (
|
|
bottle_state_dir,
|
|
egress_state_dir,
|
|
git_gate_state_dir,
|
|
read_committed_image,
|
|
)
|
|
from .compose import (
|
|
compose_down,
|
|
compose_dump_logs,
|
|
compose_file_path,
|
|
compose_log_path,
|
|
compose_project_name,
|
|
compose_up,
|
|
write_compose_file,
|
|
)
|
|
from .consolidated_compose import consolidated_agent_compose
|
|
from .consolidated_launch import launch_consolidated, teardown_consolidated
|
|
from ...orchestrator.gateway import DockerGateway
|
|
|
|
|
|
# Where the repo root lives, for `docker build` context. Computed once.
|
|
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
|
|
|
|
@contextmanager
|
|
def launch(
|
|
plan: DockerBottlePlan,
|
|
*,
|
|
provision: Callable[[DockerBottlePlan, "DockerBottle"], str | None],
|
|
) -> Generator[DockerBottle, None, None]:
|
|
"""Build, launch, and provision a Docker bottle via compose.
|
|
Teardown on exit."""
|
|
stack = ExitStack()
|
|
|
|
_bottle_for_revoke = plan.manifest.bottle
|
|
_git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
|
|
|
def teardown() -> None:
|
|
try:
|
|
stack.close()
|
|
except BaseException as exc: # noqa: W0718 — teardown must not fail
|
|
warn(
|
|
f"teardown failed for container {plan.container_name}"
|
|
f" (compose-down): {exc!r}"
|
|
)
|
|
revoke_git_gate_provisioned_keys(
|
|
_bottle_for_revoke, _git_gate_dir_for_revoke
|
|
)
|
|
|
|
try:
|
|
# Step 1: agent image. Use a committed snapshot when one exists
|
|
# and is present in the local daemon; otherwise build from the
|
|
# Dockerfile. (The gateway image is built by the orchestrator.)
|
|
committed = read_committed_image(plan.slug)
|
|
if committed and docker_mod.image_exists(committed):
|
|
info(f"using committed image {committed!r}")
|
|
plan = dataclasses.replace(
|
|
plan,
|
|
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
|
|
)
|
|
else:
|
|
docker_mod.build_image(
|
|
plan.image, _REPO_DIR,
|
|
dockerfile=plan.dockerfile_path,
|
|
)
|
|
|
|
# Step 2: mint the git-gate dynamic (gitea) deploy keys, if any, before
|
|
# provisioning the bottle's repos into the shared gateway.
|
|
git_gate_plan = plan.git_gate_plan
|
|
if git_gate_plan.upstreams:
|
|
git_gate_plan = provision_git_gate_dynamic_keys(
|
|
plan.manifest.bottle, git_gate_plan, git_gate_state_dir(plan.slug),
|
|
)
|
|
|
|
# Step 3: register on the orchestrator + provision this bottle's
|
|
# git-gate state into the shared gateway; get the agent's attach
|
|
# context (pinned source IP, gateway address, shared network).
|
|
ctx = launch_consolidated(plan.egress_plan, git_gate_plan, image_ref=plan.image)
|
|
stack.callback(
|
|
teardown_consolidated, ctx.bottle_id, orchestrator_url=ctx.orchestrator_url,
|
|
)
|
|
|
|
# Step 4: install the SHARED gateway CA into the agent (replaces the
|
|
# per-bottle CA) — read it out of the running gateway.
|
|
ca_dir = egress_state_dir(plan.slug) / "gateway-ca"
|
|
ca_dir.mkdir(parents=True, exist_ok=True)
|
|
ca_file = ca_dir / "gateway-ca.pem"
|
|
ca_file.write_text(DockerGateway(network=ctx.network).ca_cert_pem())
|
|
egress_plan = dataclasses.replace(
|
|
plan.egress_plan,
|
|
mitmproxy_ca_host_path=ca_file,
|
|
mitmproxy_ca_cert_only_host_path=ca_file,
|
|
)
|
|
# Point the agent's git-gate insteadOf rewrites at the shared gateway's
|
|
# HTTP git endpoint (9420) instead of the dead per-bottle `git-gate`
|
|
# alias. git-http + supervise on the gateway bypass the egress proxy
|
|
# (NO_PROXY includes the gateway address).
|
|
git_gate_url = (
|
|
f"http://{ctx.gateway_ip}:9420" if git_gate_plan.upstreams else ""
|
|
)
|
|
supervise_url = (
|
|
f"http://{ctx.gateway_ip}:9100/" if plan.supervise_plan is not None else ""
|
|
)
|
|
plan = dataclasses.replace(
|
|
plan,
|
|
git_gate_plan=git_gate_plan,
|
|
egress_plan=egress_plan,
|
|
agent_git_gate_url=git_gate_url,
|
|
agent_supervise_url=supervise_url,
|
|
)
|
|
|
|
# Step 5: render + up the agent-only compose, pinned on the shared
|
|
# gateway network and proxied through the gateway's address.
|
|
state_dir = bottle_state_dir(plan.slug)
|
|
spec = consolidated_agent_compose(
|
|
plan, gateway_ip=ctx.gateway_ip, source_ip=ctx.source_ip, network=ctx.network,
|
|
)
|
|
compose_file = write_compose_file(spec, compose_file_path(state_dir))
|
|
project = compose_project_name(plan.slug)
|
|
# Forwarded vars (OAuth token, host interpolations) flow through the
|
|
# subprocess env as bare names so values never land in the file.
|
|
compose_env: dict[str, str] = {**os.environ, **plan.forwarded_env}
|
|
info(
|
|
f"docker compose up -d (project {project}, agent on shared "
|
|
f"gateway {ctx.gateway_ip}, ip {ctx.source_ip})"
|
|
)
|
|
compose_up(project, compose_file, env=compose_env)
|
|
stack.callback(compose_down, project, compose_file)
|
|
stack.callback(
|
|
compose_dump_logs, project, compose_file, compose_log_path(state_dir),
|
|
)
|
|
|
|
# Step 6: provision (CA install now uses the gateway CA) + yield.
|
|
bottle = DockerBottle(
|
|
plan.container_name,
|
|
teardown,
|
|
None,
|
|
agent_command=plan.agent_command,
|
|
agent_prompt_mode=plan.agent_prompt_mode,
|
|
agent_provider_template=plan.agent_provider_template,
|
|
terminal_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name,
|
|
terminal_color=plan.spec.color,
|
|
agent_workdir=plan.workspace_plan.workdir,
|
|
)
|
|
bottle.prompt_path = provision(plan, bottle)
|
|
yield bottle
|
|
finally:
|
|
teardown()
|