4b2dbcdefd
Phase 3 of PRD 0013. Wires the supervise sidecar into bottle launch: - Manifest: bottle.supervise (bool, default False). Opt-in for v1 so existing bottles are unchanged. - supervise.py: adds SupervisePlan + abstract Supervise(ABC) with a prepare template that stages the per-bottle queue dir on the host and the current-config dir under stage_dir (routes.json + allowlist + Dockerfile). Stdlib-only so it still runs as the in-container shared helper. - backend/docker/supervise.py: DockerSupervise concrete start/stop. No egress network (the sidecar doesn't make outbound calls); just the bottle's internal network with network-alias "supervise" and a bind-mount of the host queue dir at /run/supervise/queue. - Prepare wires supervise.prepare into the DockerBottlePlan, derives routes_content from cred_proxy_plan, allowlist_content from pipelock_effective_allowlist, and dockerfile_content from the repo's Dockerfile. supervise sidecar added to the orphan probe. - Launch starts the supervise sidecar after pipelock + cred-proxy but before the agent (so DNS resolution for `supervise` is up on the agent's first tool call). - Agent container gets a read-only bind-mount of the current-config dir at /etc/claude-bottle/current-config when supervise is enabled. - bottle_plan print + to_dict surface the supervise state. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
132 lines
4.4 KiB
Python
132 lines
4.4 KiB
Python
"""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`."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from ...log import die, info, warn
|
|
from ...supervise import (
|
|
QUEUE_DIR_IN_CONTAINER,
|
|
SUPERVISE_HOSTNAME,
|
|
SUPERVISE_PORT,
|
|
Supervise,
|
|
SupervisePlan,
|
|
)
|
|
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:
|
|
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):
|
|
"""Brings the supervise sidecar up and down via Docker."""
|
|
|
|
def start(self, plan: SupervisePlan) -> str:
|
|
"""Boot the supervise sidecar:
|
|
1. Build the supervise image (no-op when cache is hot).
|
|
2. `docker create` on the internal network with
|
|
`--network-alias supervise` and SUPERVISE_BOTTLE_SLUG in
|
|
the environ.
|
|
3. Bind-mount the host queue dir at /run/supervise/queue.
|
|
4. `docker start`.
|
|
No egress network — the supervise sidecar does not make
|
|
outbound calls. Returns the container name."""
|
|
if not plan.internal_network:
|
|
die("DockerSupervise.start: plan.internal_network must be set before start")
|
|
if not plan.queue_dir.is_dir():
|
|
die(
|
|
f"DockerSupervise.start: queue dir missing at {plan.queue_dir}; "
|
|
f"Supervise.prepare must run first"
|
|
)
|
|
|
|
build_supervise_image()
|
|
|
|
name = supervise_container_name(plan.slug)
|
|
info(f"starting supervise sidecar {name} on network {plan.internal_network}")
|
|
|
|
create_args = [
|
|
"docker", "create",
|
|
"--name", name,
|
|
"--network", plan.internal_network,
|
|
"--network-alias", SUPERVISE_HOSTNAME,
|
|
"-e", f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
|
"-e", f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
|
"-e", f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
|
"-v", f"{plan.queue_dir}:{QUEUE_DIR_IN_CONTAINER}",
|
|
SUPERVISE_IMAGE,
|
|
]
|
|
|
|
create_result = subprocess.run(
|
|
create_args, capture_output=True, text=True, check=False,
|
|
)
|
|
if create_result.returncode != 0:
|
|
die(
|
|
f"failed to create supervise sidecar {name}: "
|
|
f"{create_result.stderr.strip()}"
|
|
)
|
|
|
|
start_result = subprocess.run(
|
|
["docker", "start", name], capture_output=True, text=True, check=False,
|
|
)
|
|
if start_result.returncode != 0:
|
|
subprocess.run(
|
|
["docker", "rm", "-f", name],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
die(
|
|
f"failed to start supervise sidecar {name}: "
|
|
f"{start_result.stderr.strip()}"
|
|
)
|
|
|
|
return name
|
|
|
|
def stop(self, target: str) -> None:
|
|
"""Idempotent: missing container is success."""
|
|
if subprocess.run(
|
|
["docker", "inspect", target],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
).returncode == 0:
|
|
if subprocess.run(
|
|
["docker", "rm", "-f", target],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
).returncode != 0:
|
|
warn(
|
|
f"failed to remove supervise sidecar {target}; "
|
|
f"clean up with 'docker rm -f {target}'"
|
|
)
|