refactor(sidecars): drop vestigial start/stop methods (PRD 0024 chunk 3)
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:
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}'"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -326,19 +326,6 @@ class Egress(ABC):
|
||||
token_env_map=egress_token_env_map(routes),
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def start(self, plan: EgressPlan) -> str:
|
||||
"""Bring up the egress sidecar according to `plan`.
|
||||
Returns the target string identifying the running instance —
|
||||
the same value to pass to `.stop`. Backend-specific."""
|
||||
|
||||
@abstractmethod
|
||||
def stop(self, target: str) -> None:
|
||||
"""Tear down the egress sidecar identified by `target`
|
||||
(the value `.start` returned). Idempotent: a missing target
|
||||
is success. Backend-specific."""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_ALLOWLIST",
|
||||
"EGRESS_HOSTNAME",
|
||||
|
||||
@@ -371,14 +371,3 @@ class GitGate(ABC):
|
||||
upstreams=upstreams,
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def start(self, plan: GitGatePlan) -> str:
|
||||
"""Bring up the gate sidecar according to `plan`. Returns the
|
||||
target string identifying the running instance — the same
|
||||
value to pass to `.stop`. Backend-specific."""
|
||||
|
||||
@abstractmethod
|
||||
def stop(self, target: str) -> None:
|
||||
"""Tear down the gate sidecar identified by `target` (the
|
||||
value `.start` returned). Idempotent: a missing target is
|
||||
success. Backend-specific."""
|
||||
|
||||
@@ -344,14 +344,3 @@ class PipelockProxy(ABC):
|
||||
yaml_path.chmod(0o600)
|
||||
return PipelockProxyPlan(yaml_path=yaml_path, slug=slug)
|
||||
|
||||
@abstractmethod
|
||||
def start(self, plan: PipelockProxyPlan) -> str:
|
||||
"""Bring up the pipelock sidecar according to `plan`. Returns
|
||||
the proxy_target string identifying the running instance — the
|
||||
same value to pass to `.stop`. Backend-specific."""
|
||||
|
||||
@abstractmethod
|
||||
def stop(self, proxy_target: str) -> None:
|
||||
"""Tear down the pipelock sidecar identified by `proxy_target`
|
||||
(the value `.start` returned). Idempotent: a missing target is
|
||||
success. Backend-specific."""
|
||||
|
||||
@@ -494,18 +494,6 @@ class Supervise(ABC):
|
||||
current_config_dir=current_config_dir,
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def start(self, plan: SupervisePlan) -> str:
|
||||
"""Bring up the supervise sidecar according to `plan`. Returns
|
||||
the target string identifying the running instance — the same
|
||||
value to pass to `.stop`. Backend-specific."""
|
||||
|
||||
@abstractmethod
|
||||
def stop(self, target: str) -> None:
|
||||
"""Tear down the supervise sidecar identified by `target`.
|
||||
Idempotent: a missing target is success."""
|
||||
|
||||
|
||||
# --- Helpers ---------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user