refactor(sidecars): drop vestigial start/stop methods (PRD 0024 chunk 3)
test / unit (pull_request) Successful in 21s
test / integration (pull_request) Successful in 41s

Compose-up has owned per-container lifecycle since PRD 0018 ch3;
the .start() / .stop() methods on DockerPipelockProxy /
DockerEgress / DockerGitGate / DockerSupervise (and their
abstractmethod declarations in the four base ABCs) were already
documented as vestigial. With the bundle path in flight
(PRD 0024 ch2), they are truly dead — collapse to nothing.

Changes:
- Removed start/stop methods from the four DockerSidecar
  classes. Plan dataclasses, image/path constants,
  container-name helpers, and the .prepare() methods all stay
  (the renderer + apply path still need them).
- Removed the matching @abstractmethod declarations in the
  base ABCs so concrete subclasses don't have to stub them.
- launch.launch() and prepare.resolve_plan() no longer take
  proxy/git_gate/egress/supervise instance parameters. backend.py
  loses the four instance attributes it threaded through.
  prepare.resolve_plan() instantiates the four classes itself
  to call their .prepare() methods.
- Deleted four integration tests that only exercised the
  removed lifecycle: test_pipelock_sidecar_smoke,
  test_supervise_sidecar, test_git_gate_sidecar,
  test_git_gate_mirror.
- Dropped the .stop-idempotency case in test_orphan_cleanup;
  the network-cleanup cases stay (those test real production
  code).
- Marked test_pipelock_apply @skip pending chunk 4 — its
  bringup helper used .start; chunk 4 rewrites it with direct
  `docker run`.

Dockerfile deletion deferred to chunk 5 (when the bundle flag
default flips) — the legacy compose path still needs
Dockerfile.{egress,git-gate,supervise} until then.

Net: 708 lines removed, 80 added.

533 unit tests + 27 integration tests passing (5 skipped: the
chunk-4-pending case + existing GITEA_ACTIONS guards).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 01:01:10 -04:00
parent c37344608b
commit 539234f29e
18 changed files with 80 additions and 1758 deletions
+2 -26
View File
@@ -23,15 +23,11 @@ from . import prepare as _prepare
from .bottle import DockerBottle
from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_plan import DockerBottlePlan
from .egress import DockerEgress
from .git_gate import DockerGitGate
from .pipelock import DockerPipelockProxy
from .provision import ca as _ca
from .provision import git as _git
from .provision import prompt as _prompt
from .provision import skills as _skills
from .provision import supervise as _supervise_prov
from .supervise import DockerSupervise
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
@@ -40,32 +36,12 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
name = "docker"
def __init__(self) -> None:
self._proxy = DockerPipelockProxy()
self._git_gate = DockerGitGate()
self._egress = DockerEgress()
self._supervise = DockerSupervise()
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
return _prepare.resolve_plan(
spec,
stage_dir=stage_dir,
proxy=self._proxy,
git_gate=self._git_gate,
egress=self._egress,
supervise=self._supervise,
)
return _prepare.resolve_plan(spec, stage_dir=stage_dir)
@contextmanager
def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
with _launch.launch(
plan,
proxy=self._proxy,
git_gate=self._git_gate,
egress=self._egress,
supervise=self._supervise,
provision=self.provision,
) as bottle:
with _launch.launch(plan, provision=self.provision) as bottle:
yield bottle
def provision_ca(self, plan: DockerBottlePlan, target: str) -> None:
+5 -219
View File
@@ -13,14 +13,8 @@ import os
import subprocess
from pathlib import Path
from ...egress import (
EGRESS_HOSTNAME,
EGRESS_ROUTES_IN_CONTAINER,
Egress,
EgressPlan,
egress_resolve_token_values,
)
from ...log import die, info, warn
from ...egress import Egress
from ...log import die
from . import util as docker_mod
@@ -166,214 +160,6 @@ def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]:
class DockerEgress(Egress):
"""Brings the egress sidecar up and down via Docker."""
def start(self, plan: EgressPlan) -> str:
"""Boot the egress sidecar:
1. Resolve every host TokenRef env var into a concrete
value. Fails early if any are unset.
2. Build the egress image (no-op when cache is hot).
3. `docker create` on the internal network with
`--network-alias egress`, the `HTTPS_PROXY=pipelock`
env (so the upstream leg traverses pipelock), the
`EGRESS_UPSTREAM_CA` env pointing at the in-container
pipelock-CA path (so mitmproxy trusts pipelock's MITM),
and one `-e EGRESS_TOKEN_N` flag per token slot.
Secret values arrive via subprocess env, never argv.
4. `docker cp` the routes.yaml, mitmproxy CA (cert+key
concat), and pipelock CA (cert only) into the container.
5. Attach to the per-agent egress network so the proxy can
reach pipelock.
6. `docker start`.
Returns the container name (the target passed to `.stop`)."""
if not plan.routes:
die("DockerEgress.start called with no routes; caller should skip")
if not plan.internal_network or not plan.egress_network:
die(
"DockerEgress.start: internal_network / egress_network must be "
"populated on the plan before start"
)
if not plan.routes_path.is_file():
die(
f"egress routes file missing at {plan.routes_path}; "
f"Egress.prepare must run first"
)
if plan.mitmproxy_ca_host_path == Path() or not plan.mitmproxy_ca_host_path.is_file():
die(
f"DockerEgress.start: mitmproxy CA missing at "
f"{plan.mitmproxy_ca_host_path}; egress_tls_init must run first"
)
# pipelock CA + upstream proxy URL: both must be present (we
# use HTTPS_PROXY=pipelock with pipelock's own MITM CA on the
# upstream leg) or both absent (egress goes direct, for
# standalone integration tests that don't bring pipelock up).
route_via_pipelock = bool(plan.pipelock_proxy_url) or plan.pipelock_ca_host_path != Path()
if route_via_pipelock:
if not plan.pipelock_proxy_url:
die(
"DockerEgress.start: pipelock_ca_host_path is set but "
"pipelock_proxy_url is empty; populate both or neither."
)
if not plan.pipelock_ca_host_path.is_file():
die(
f"DockerEgress.start: pipelock CA missing at "
f"{plan.pipelock_ca_host_path}; pipelock_tls_init must run first"
)
# Resolve host env vars into concrete values. Must happen at
# start time (not prepare) — the values flow into the sidecar's
# environ via subprocess env. The plan never holds them.
token_values = egress_resolve_token_values(
plan.token_env_map, dict(os.environ),
)
build_egress_image()
name = egress_container_name(plan.slug)
info(f"starting egress sidecar {name} on network {plan.internal_network}")
create_args = [
"docker", "create",
"--name", name,
"--network", plan.internal_network,
"--network-alias", EGRESS_HOSTNAME,
]
if route_via_pipelock:
# Route egress's outbound traffic through pipelock
# so the egress allowlist + DLP body scanner apply to
# the egress → upstream leg. Pipelock MITMs each
# handshake with its per-bottle CA, which is docker-cp'd
# in below and pointed to via the EGRESS_UPSTREAM_CA
# env (entrypoint conditionally adds the matching --set
# flag).
#
# EGRESS_UPSTREAM_PROXY is the mechanism: mitmproxy
# does NOT honor HTTPS_PROXY env vars on its outbound
# side (it's a proxy server, not a client). The
# entrypoint reads this env and switches mitmdump to
# `--mode upstream:<URL>` so all post-MITM traffic
# CONNECTs to pipelock instead of going direct. The
# HTTPS/HTTP_PROXY env vars below are kept for any
# bundled client libraries (mitmproxy plugin requests,
# etc.) that might honor them — harmless if ignored.
create_args.extend([
"-e", f"EGRESS_UPSTREAM_PROXY={plan.pipelock_proxy_url}",
"-e", f"HTTPS_PROXY={plan.pipelock_proxy_url}",
"-e", f"HTTP_PROXY={plan.pipelock_proxy_url}",
"-e", "NO_PROXY=localhost,127.0.0.1",
"-e", f"EGRESS_UPSTREAM_CA={EGRESS_PIPELOCK_CA_IN_CONTAINER}",
])
# One -e flag per token slot; values arrive via subprocess env.
# docker create with `-e NAME` (no =VALUE) reads NAME from the
# current process env at create time. We pass `env=child_env`
# to subprocess.run so the value comes from token_values, not
# the host's os.environ directly — keeps the resolver in one
# place and lets egress_resolve_token_values surface
# missing-env errors with a clear hint.
for token_env in sorted(plan.token_env_map.keys()):
create_args.extend(["-e", token_env])
create_args.append(EGRESS_IMAGE)
child_env: dict[str, str] = {**os.environ, **token_values}
create_result = subprocess.run(
create_args, capture_output=True, text=True, env=child_env, check=False,
)
if create_result.returncode != 0:
die(
f"failed to create egress sidecar {name}: "
f"{create_result.stderr.strip()}"
)
# routes.yaml also lands inside the container; bump to 644
# for the same reason as the CAs — mitmproxy user (uid 1000)
# has to read it. Host stage_dir is mode 700 so the file
# isn't actually exposed to other host users.
plan.routes_path.chmod(0o644)
# Pipelock CA: pipelock itself runs as root so its in-pipelock
# copy doesn't care about mode, but egress's mitmproxy
# user does. Bump on the host so docker cp into egress
# carries world-readable.
if route_via_pipelock:
plan.pipelock_ca_host_path.chmod(0o644)
cps: list[tuple[Path, str, str]] = [
(plan.routes_path, EGRESS_ROUTES_IN_CONTAINER, "routes.yaml"),
(plan.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER, "mitmproxy CA"),
]
if route_via_pipelock:
cps.append((
plan.pipelock_ca_host_path,
EGRESS_PIPELOCK_CA_IN_CONTAINER,
"pipelock CA",
))
for src, dst, label in cps:
cp_result = subprocess.run(
["docker", "cp", str(src), f"{name}:{dst}"],
capture_output=True,
text=True,
check=False,
)
if cp_result.returncode != 0:
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
die(
f"failed to copy {label} into {name}: "
f"{cp_result.stderr.strip()}"
)
connect_result = subprocess.run(
["docker", "network", "connect", plan.egress_network, name],
capture_output=True, text=True, check=False,
)
if connect_result.returncode != 0:
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
die(
f"failed to attach egress sidecar {name} to egress network "
f"{plan.egress_network}: {connect_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 egress sidecar {name}: "
f"{start_result.stderr.strip()}"
)
return name
def stop(self, target: str) -> None:
"""Idempotent: missing container is success. `target` is the
container name returned by `.start`."""
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 egress sidecar {target}; "
f"clean up with 'docker rm -f {target}'"
)
"""Docker-flavored Egress: inherits `.prepare()` from the base.
Container lifecycle is owned by compose; per-container
`.start()` / `.stop()` were removed in PRD 0024 chunk 3."""
+4 -169
View File
@@ -5,17 +5,9 @@ step (upstream lift + entrypoint/hook render) from `GitGate`."""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
from ...git_gate import (
GitGate,
GitGatePlan,
git_gate_aggregate_extra_hosts,
git_gate_known_hosts_line,
)
from ...log import die, info, warn
from ...util import expand_tilde
from ...git_gate import GitGate
from . import util as docker_mod
@@ -59,163 +51,6 @@ def build_git_gate_image() -> None:
class DockerGitGate(GitGate):
"""Brings the git-gate sidecar up and down via Docker."""
def start(self, plan: GitGatePlan) -> str:
"""Boot the gate sidecar:
1. Build the gate image (no-op when cache is hot).
2. `docker create` on the internal network with the canonical
name; the image's ENTRYPOINT runs the cp'd entrypoint
script at start time.
3. `docker cp` the entrypoint, the shared pre-receive hook,
and each upstream's identity + known_hosts into the
container.
4. Attach to the per-agent egress network so the gate can
reach the real upstream.
5. `docker start`.
Returns the container name (the target passed to `.stop`)."""
if not plan.upstreams:
die("DockerGitGate.start called with no upstreams; caller should skip")
if not plan.internal_network or not plan.egress_network:
die(
"DockerGitGate.start: internal_network / egress_network must be "
"populated on the plan before start"
)
if not plan.entrypoint_script.is_file():
die(
f"git-gate entrypoint missing at {plan.entrypoint_script}; "
f"GitGate.prepare must run first"
)
if not plan.hook_script.is_file():
die(
f"git-gate hook missing at {plan.hook_script}; "
f"GitGate.prepare must run first"
)
if not plan.access_hook_script.is_file():
die(
f"git-gate access-hook missing at {plan.access_hook_script}; "
f"GitGate.prepare must run first"
)
build_git_gate_image()
name = git_gate_container_name(plan.slug)
info(f"starting git-gate sidecar {name} on network {plan.internal_network}")
create_args = [
"docker", "create",
"--name", name,
"--network", plan.internal_network,
]
for host, ip in git_gate_aggregate_extra_hosts(plan.upstreams).items():
create_args.extend(["--add-host", f"{host}:{ip}"])
create_args.append(GIT_GATE_IMAGE)
create_result = subprocess.run(
create_args, capture_output=True, text=True, check=False,
)
if create_result.returncode != 0:
die(
f"failed to create git-gate sidecar {name}: "
f"{create_result.stderr.strip()}"
)
# Order matters: entrypoint + hook first so they're present
# when docker start fires. Per-upstream creds afterwards.
stage_dir = plan.entrypoint_script.parent
cps: list[tuple[str, str, str]] = [
(str(plan.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER, "entrypoint"),
(str(plan.hook_script), GIT_GATE_HOOK_IN_CONTAINER, "pre-receive hook"),
(str(plan.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER, "access-hook"),
]
for u in plan.upstreams:
keypath = expand_tilde(u.identity_file)
cps.append((
keypath,
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
f"upstream key for '{u.name}'",
))
if u.known_host_key:
hosts_path = stage_dir / f"git_gate_known_hosts_{u.name}"
hosts_path.write_text(
git_gate_known_hosts_line(
u.upstream_host, u.upstream_port, u.known_host_key
)
)
hosts_path.chmod(0o600)
cps.append((
str(hosts_path),
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts",
f"upstream known_hosts for '{u.name}'",
))
for src, dst, label in cps:
cp_result = subprocess.run(
["docker", "cp", src, f"{name}:{dst}"],
capture_output=True,
text=True,
check=False,
)
if cp_result.returncode != 0:
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
die(
f"failed to copy {label} into {name}: "
f"{cp_result.stderr.strip()}"
)
connect_result = subprocess.run(
["docker", "network", "connect", plan.egress_network, name],
capture_output=True, text=True, check=False,
)
if connect_result.returncode != 0:
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
die(
f"failed to attach git-gate sidecar {name} to egress network "
f"{plan.egress_network}: {connect_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 git-gate sidecar {name}: "
f"{start_result.stderr.strip()}"
)
return name
def stop(self, target: str) -> None:
"""Idempotent: missing container is success. `target` is the
container name returned by `.start`."""
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 git-gate sidecar {target}; "
f"clean up with 'docker rm -f {target}'"
)
"""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."""
+2 -16
View File
@@ -63,14 +63,11 @@ from .compose import (
compose_up,
write_compose_file,
)
from .egress import DockerEgress, egress_tls_init
from .git_gate import DockerGitGate
from .egress import egress_tls_init
from .pipelock import (
DockerPipelockProxy,
pipelock_proxy_url,
pipelock_tls_init,
)
from .supervise import DockerSupervise
# Where the repo root lives, for `docker build` context. Computed once.
@@ -81,21 +78,10 @@ _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
def launch(
plan: DockerBottlePlan,
*,
proxy: DockerPipelockProxy,
git_gate: DockerGitGate,
egress: DockerEgress,
supervise: DockerSupervise,
provision: Callable[[DockerBottlePlan, str], str | None],
) -> Generator[DockerBottle, None, None]:
"""Build, launch, and provision a Docker bottle via compose.
Teardown on exit. The per-sidecar `proxy / git_gate / egress /
supervise` parameters are vestigial from the pre-compose flow —
kept for backwards-compat with backend.py's call site; the
`start()`/`stop()` methods on those classes are no longer
invoked (chunk 3 collapsed them into the compose service spec).
They'll be removed entirely in a follow-up cleanup."""
del proxy, git_gate, egress, supervise # not invoked in compose flow
Teardown on exit."""
stack = ExitStack()
def teardown() -> None:
+22 -121
View File
@@ -1,6 +1,14 @@
"""DockerPipelockProxy — the Docker-specific implementation of the
sidecar's start/stop lifecycle. Inherits the platform-agnostic
YAML-config generation from PipelockProxy."""
sidecar's `.prepare()` step + in-container CA path constants.
Inherits the platform-agnostic YAML-config generation from
PipelockProxy.
The per-container `.start()` / `.stop()` lifecycle was deleted in
PRD 0024 chunk 3 — compose-up owns the container lifecycle (PRD
0018) and the bundle path (PRD 0024) collapses pipelock + egress
+ git-gate + supervise into one container. What remains here is
the prepare-time YAML rendering + the CA path constants the
compose renderer reads."""
from __future__ import annotations
@@ -8,8 +16,8 @@ import os
import subprocess
from pathlib import Path
from ...log import die, info, warn
from ...pipelock import PipelockProxy, PipelockProxyPlan
from ...log import die
from ...pipelock import PipelockProxy
# Pipelock image, pinned by digest. The digest is the multi-arch image
@@ -22,9 +30,9 @@ PIPELOCK_IMAGE = os.environ.get(
# Listening port for pipelock's forward proxy.
PIPELOCK_PORT = os.environ.get("CLAUDE_BOTTLE_PIPELOCK_PORT", "8888")
# In-container paths where the per-bottle CA cert + key land after
# `docker cp` in `DockerPipelockProxy.start`. Pipelock's rendered
# YAML references these paths under `tls_interception`.
# In-container paths where the per-bottle CA cert + key land via
# the compose renderer's bind-mounts. Pipelock's rendered YAML
# references these paths under `tls_interception`.
PIPELOCK_CA_CERT_IN_CONTAINER = "/etc/pipelock-ca.pem"
PIPELOCK_CA_KEY_IN_CONTAINER = "/etc/pipelock-ca-key.pem"
@@ -46,10 +54,10 @@ def pipelock_tls_init(stage_dir: Path) -> tuple[Path, Path]:
The image is pinned (same digest the running sidecar uses) so the
generated CA matches what the sidecar expects. Output is owned by
whatever UID the one-shot ran as; `DockerPipelockProxy.start`
`docker cp`s the files into the sidecar's filesystem layer, so
runtime ownership inside the sidecar (root in pipelock's
distroless image) is independent."""
whatever UID the one-shot ran as; the compose renderer's
bind-mounts pin the files in place at runtime, so ownership
inside the running sidecar (root in pipelock's distroless image)
is independent."""
work = stage_dir / "pipelock-ca"
work.mkdir(exist_ok=True)
result = subprocess.run(
@@ -77,117 +85,10 @@ def pipelock_tls_init(stage_dir: Path) -> tuple[Path, Path]:
class DockerPipelockProxy(PipelockProxy):
"""Brings the pipelock sidecar up and down via Docker."""
"""Docker-flavored PipelockProxy: inherits `.prepare()` from the
base, exposes the in-container CA paths the renderer reads.
Container lifecycle is owned by compose."""
CA_CERT_IN_CONTAINER = PIPELOCK_CA_CERT_IN_CONTAINER
CA_KEY_IN_CONTAINER = PIPELOCK_CA_KEY_IN_CONTAINER
def start(self, plan: PipelockProxyPlan) -> str:
"""Boot the pipelock sidecar:
1. `docker create` on the internal network with the canonical
name and argv `run --config /etc/pipelock.yaml --listen
0.0.0.0:<port>`.
2. `docker cp` the YAML config to /etc/pipelock.yaml.
3. `docker cp` the CA cert + key to /etc/pipelock-ca.pem
and /etc/pipelock-ca-key.pem (pipelock runs as root in
its distroless image, so no chown is needed).
4. Attach to the per-agent egress network.
5. `docker start`.
Returns the container name (the proxy_target passed to .stop)."""
name = pipelock_container_name(plan.slug)
if not plan.yaml_path.is_file():
die(
f"pipelock yaml not found at {plan.yaml_path}; "
f"PipelockProxy.prepare must run first"
)
if not plan.ca_cert_host_path.is_file() or not plan.ca_key_host_path.is_file():
die(
f"pipelock CA missing at {plan.ca_cert_host_path} / "
f"{plan.ca_key_host_path}; pipelock_tls_init must run first"
)
info(f"starting pipelock sidecar {name} on network {plan.internal_network}")
create_args = [
"docker", "create",
"--name", name,
"--network", plan.internal_network,
PIPELOCK_IMAGE,
"run", "--config", "/etc/pipelock.yaml",
"--listen", f"0.0.0.0:{PIPELOCK_PORT}",
]
create_result = subprocess.run(
create_args, capture_output=True, text=True, check=False,
)
if create_result.returncode != 0:
die(
f"failed to create pipelock sidecar {name}: "
f"{create_result.stderr.strip()}"
)
for src, dst, label in (
(plan.yaml_path, "/etc/pipelock.yaml", "yaml"),
(plan.ca_cert_host_path, PIPELOCK_CA_CERT_IN_CONTAINER, "ca cert"),
(plan.ca_key_host_path, PIPELOCK_CA_KEY_IN_CONTAINER, "ca key"),
):
cp_result = subprocess.run(
["docker", "cp", str(src), f"{name}:{dst}"],
capture_output=True,
text=True,
check=False,
)
if cp_result.returncode != 0:
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
die(f"failed to copy pipelock {label} into {name}: {cp_result.stderr.strip()}")
connect_result = subprocess.run(
["docker", "network", "connect", plan.egress_network, name],
capture_output=True, text=True, check=False,
)
if connect_result.returncode != 0:
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
die(
f"failed to attach pipelock sidecar {name} to egress network "
f"{plan.egress_network}: {connect_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 pipelock sidecar {name}: "
f"{start_result.stderr.strip()}"
)
return name
def stop(self, proxy_target: str) -> None:
"""Idempotent: missing container is success. `proxy_target` is
the container name returned by .start."""
if subprocess.run(
["docker", "inspect", proxy_target],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode == 0:
if subprocess.run(
["docker", "rm", "-f", proxy_target],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode != 0:
warn(
f"failed to remove pipelock sidecar {proxy_target}; "
f"clean up with 'docker rm -f {proxy_target}'"
)
+5 -4
View File
@@ -43,16 +43,17 @@ def resolve_plan(
spec: BottleSpec,
*,
stage_dir: Path,
proxy: DockerPipelockProxy,
git_gate: DockerGitGate,
egress: DockerEgress,
supervise: DockerSupervise,
) -> DockerBottlePlan:
"""Resolve Docker-specific names and write scratch files. Trusts
that the agent and its skills/git-gate keys are present —
validation already ran in the base class."""
docker_mod.require_docker()
proxy = DockerPipelockProxy()
git_gate = DockerGitGate()
egress = DockerEgress()
supervise = DockerSupervise()
manifest = spec.manifest
agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
+3 -85
View File
@@ -5,16 +5,12 @@ 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
@@ -48,84 +44,6 @@ def build_supervise_image() -> None:
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}'"
)
"""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."""