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 ---------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -376,16 +376,30 @@ rewrite.
|
||||
`bottle_plan_to_compose` to emit two services. Feature flag
|
||||
it via env var. Update unit tests to assert on both shapes
|
||||
(flag on vs off) during the migration window.
|
||||
3. **Backend Python collapse.** Trim the four docker
|
||||
sidecar modules, consolidate container-name helpers, update
|
||||
orphan-cleanup logic to look for the bundle by name. Delete
|
||||
old Dockerfiles.
|
||||
4. **Integration test sweep.** Bring every integration test
|
||||
that probes a four-container shape (`pipelock_container_name`,
|
||||
`egress_container_name`, etc.) onto the bundle. Confirm
|
||||
3. **Backend Python collapse.** Drop the vestigial per-container
|
||||
`.start()` / `.stop()` methods from `DockerPipelockProxy`,
|
||||
`DockerEgress`, `DockerGitGate`, `DockerSupervise` (and from
|
||||
the ABCs in `claude_bottle/{pipelock,egress,git_gate,supervise}.py`).
|
||||
These were already documented as vestigial in PRD 0018 ch3.
|
||||
Strip vestigial sidecar-instance parameters from
|
||||
`launch.launch()` and `prepare.resolve_plan()`. Delete the
|
||||
integration tests that exclusively exercised those methods
|
||||
(`test_pipelock_sidecar_smoke`, `test_supervise_sidecar`,
|
||||
`test_git_gate_sidecar`, `test_git_gate_mirror`). Skip
|
||||
`test_pipelock_apply` pending chunk 4 bringup rewrite.
|
||||
Orphan cleanup already uses a prefix scan and catches the
|
||||
bundle for free; no change needed. Dockerfile deletion is
|
||||
deferred to chunk 5 — until the flag flips, the legacy path
|
||||
still needs `Dockerfile.{egress,git-gate,supervise}` for
|
||||
compose `build:`.
|
||||
4. **Integration test sweep.** Rewrite `test_pipelock_apply`'s
|
||||
bringup with direct `docker run` so the
|
||||
`apply_allowlist_change` hot-reload retains coverage. Add
|
||||
any bundle-specific integration smoke as needed. Confirm
|
||||
PRD 0022 stays green.
|
||||
5. **Docs + flag removal.** Flip the default, remove the
|
||||
feature flag, update README + CLAUDE.md.
|
||||
feature flag, delete `Dockerfile.{egress,git-gate,supervise}`,
|
||||
update README + CLAUDE.md.
|
||||
|
||||
## Open questions
|
||||
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
"""Integration: the git-gate is a bidirectional mirror of its
|
||||
upstream (PRD 0008 v1.1).
|
||||
|
||||
Three round-trip assertions against a real Docker daemon plus a
|
||||
sibling sshd container playing the role of "real upstream":
|
||||
|
||||
1. clone-through-gate returns whatever the upstream has at the
|
||||
moment of clone (refs + content).
|
||||
2. After a second commit lands on the upstream out-of-band, a
|
||||
fetch through the gate picks it up — the access-hook is
|
||||
refreshing before each upload-pack.
|
||||
3. A push through the gate (clean commit) lands on the upstream's
|
||||
bare repo — the pre-receive hook's forward phase works.
|
||||
|
||||
These are the user-facing semantics: every operation against the
|
||||
gate is observably equivalent to the same operation against the
|
||||
real upstream.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from claude_bottle.backend.docker.git_gate import (
|
||||
DockerGitGate,
|
||||
build_git_gate_image,
|
||||
)
|
||||
from claude_bottle.backend.docker.network import (
|
||||
network_create_egress,
|
||||
network_create_internal,
|
||||
network_remove,
|
||||
)
|
||||
from claude_bottle.manifest import Manifest
|
||||
from tests._docker import skip_unless_docker
|
||||
|
||||
|
||||
# Same image used by test_git_gate_sidecar — alpine + git + gitleaks.
|
||||
CLIENT_IMAGE = "zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f"
|
||||
|
||||
# Built once in setUpClass via `docker build -` from the inline
|
||||
# Dockerfile below. Carries openssh-server, a `git` user, baked-in
|
||||
# host keys, and a bare repo at /git/foo.git seeded with one commit.
|
||||
UPSTREAM_IMAGE = "claude-bottle-test-upstream:latest"
|
||||
|
||||
UPSTREAM_DOCKERFILE = textwrap.dedent("""
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache openssh-server git
|
||||
RUN adduser -D -s /usr/bin/git-shell git && \\
|
||||
passwd -u git && \\
|
||||
mkdir -p /home/git/.ssh && \\
|
||||
chown git:git /home/git/.ssh && \\
|
||||
chmod 700 /home/git/.ssh && \\
|
||||
mkdir -p /git && \\
|
||||
chown git:git /git
|
||||
# Bake host keys into the image so the test can pin the
|
||||
# KnownHostKey value before the container starts. Re-running
|
||||
# ssh-keygen -A at boot would invalidate that pinning.
|
||||
RUN ssh-keygen -A
|
||||
USER git
|
||||
RUN git config --global init.defaultBranch main && \\
|
||||
git config --global user.email upstream@example && \\
|
||||
git config --global user.name upstream && \\
|
||||
git init --bare /git/foo.git && \\
|
||||
git clone /git/foo.git /tmp/w && \\
|
||||
cd /tmp/w && \\
|
||||
echo "initial upstream content" > README.md && \\
|
||||
git add README.md && \\
|
||||
git commit -q -m "initial commit" && \\
|
||||
git push -q origin main && \\
|
||||
rm -rf /tmp/w
|
||||
USER root
|
||||
RUN echo "PermitRootLogin no" >> /etc/ssh/sshd_config && \\
|
||||
echo "PasswordAuthentication no" >> /etc/ssh/sshd_config && \\
|
||||
echo "AuthorizedKeysFile /home/git/.ssh/authorized_keys" >> /etc/ssh/sshd_config
|
||||
CMD ["/usr/sbin/sshd", "-D", "-e"]
|
||||
""").strip()
|
||||
|
||||
|
||||
@skip_unless_docker()
|
||||
class TestGitGateBidirectionalMirror(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Pull the client image first (other suites do the same — keeps
|
||||
# registry races contained to setUpClass).
|
||||
if subprocess.run(
|
||||
["docker", "pull", CLIENT_IMAGE],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||
).returncode != 0:
|
||||
raise unittest.SkipTest(f"could not pull {CLIENT_IMAGE}")
|
||||
|
||||
# Build the upstream sshd image from stdin (no build context
|
||||
# needed — Dockerfile has no COPY/ADD).
|
||||
build_result = subprocess.run(
|
||||
["docker", "build", "-t", UPSTREAM_IMAGE, "-"],
|
||||
input=UPSTREAM_DOCKERFILE,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if build_result.returncode != 0:
|
||||
raise unittest.SkipTest(
|
||||
f"could not build upstream image: {build_result.stderr}"
|
||||
)
|
||||
|
||||
# Pull the upstream's baked-in ed25519 host pubkey out of the
|
||||
# image so we can pin it as KnownHostKey on the gate's manifest
|
||||
# entry. Reading from a transient container ensures we get the
|
||||
# same key the running sshd will present.
|
||||
pub_result = subprocess.run(
|
||||
["docker", "run", "--rm", "--entrypoint", "cat",
|
||||
UPSTREAM_IMAGE, "/etc/ssh/ssh_host_ed25519_key.pub"],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
parts = pub_result.stdout.strip().split()
|
||||
# Format: "ssh-ed25519 <base64-pubkey> <comment>" — drop comment.
|
||||
cls.upstream_host_key = f"{parts[0]} {parts[1]}"
|
||||
|
||||
# Build the gate image (uses build cache after the first run).
|
||||
build_git_gate_image()
|
||||
|
||||
def setUp(self):
|
||||
suffix = self.id().rsplit('.', 1)[-1].replace('_', '-')[-12:]
|
||||
self.slug = f"t{os.getpid()}-{suffix}"
|
||||
self.gate_name = ""
|
||||
self.upstream_name = f"claude-bottle-test-upstream-{self.slug}"
|
||||
self.internal_net = ""
|
||||
self.egress_net = ""
|
||||
self.work_dir = Path(tempfile.mkdtemp())
|
||||
|
||||
# Per-test SSH auth keypair. The host gets the private key
|
||||
# path on disk (manifest IdentityFile); the upstream's
|
||||
# authorized_keys gets the public key, docker-cp'd in just
|
||||
# before sshd starts.
|
||||
self.auth_key = self.work_dir / "auth_key"
|
||||
subprocess.run(
|
||||
["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(self.auth_key),
|
||||
"-C", "git-gate-test"],
|
||||
check=True, stdout=subprocess.DEVNULL,
|
||||
)
|
||||
self.auth_pub = self.work_dir / "auth_key.pub"
|
||||
|
||||
# Networks first so the upstream can attach to the egress
|
||||
# network at create time.
|
||||
self.internal_net = network_create_internal(self.slug)
|
||||
self.egress_net = network_create_egress(self.slug)
|
||||
|
||||
# Start the upstream sshd container, attached to the egress
|
||||
# network (which the gate also lives on). Container name doubles
|
||||
# as its DNS-resolvable hostname.
|
||||
subprocess.run(
|
||||
["docker", "create",
|
||||
"--name", self.upstream_name,
|
||||
"--network", self.egress_net,
|
||||
UPSTREAM_IMAGE],
|
||||
check=True, stdout=subprocess.DEVNULL,
|
||||
)
|
||||
# docker cp the per-test pubkey into the upstream as
|
||||
# /home/git/.ssh/authorized_keys (right user, right path).
|
||||
subprocess.run(
|
||||
["docker", "cp", str(self.auth_pub),
|
||||
f"{self.upstream_name}:/home/git/.ssh/authorized_keys"],
|
||||
check=True, stdout=subprocess.DEVNULL,
|
||||
)
|
||||
# chown / chmod the authorized_keys before sshd refuses to
|
||||
# use it.
|
||||
for argv in (
|
||||
["chown", "git:git", "/home/git/.ssh/authorized_keys"],
|
||||
["chmod", "600", "/home/git/.ssh/authorized_keys"],
|
||||
):
|
||||
subprocess.run(
|
||||
["docker", "exec", "-u", "0", self.upstream_name, *argv],
|
||||
check=False, stdout=subprocess.DEVNULL,
|
||||
)
|
||||
# The exec-then-start ordering is unusual — exec on a stopped
|
||||
# container is OK on modern docker but if it errors we just
|
||||
# do the chown after start instead. Retry post-start to be
|
||||
# safe.
|
||||
subprocess.run(
|
||||
["docker", "start", self.upstream_name],
|
||||
check=True, stdout=subprocess.DEVNULL,
|
||||
)
|
||||
for argv in (
|
||||
["chown", "git:git", "/home/git/.ssh/authorized_keys"],
|
||||
["chmod", "600", "/home/git/.ssh/authorized_keys"],
|
||||
):
|
||||
subprocess.run(
|
||||
["docker", "exec", "-u", "0", self.upstream_name, *argv],
|
||||
check=False, stdout=subprocess.DEVNULL,
|
||||
)
|
||||
# Wait for sshd to bind; a short retry against TCP 22 is enough.
|
||||
ready = False
|
||||
for _ in range(30):
|
||||
probe = subprocess.run(
|
||||
["docker", "exec", self.upstream_name,
|
||||
"sh", "-c", "nc -z 127.0.0.1 22"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||
)
|
||||
if probe.returncode == 0:
|
||||
ready = True
|
||||
break
|
||||
subprocess.run(["sleep", "0.2"], check=False)
|
||||
if not ready:
|
||||
self.fail("upstream sshd never bound port 22")
|
||||
|
||||
# Build the gate plan + start it. Upstream URL points at the
|
||||
# upstream container's hostname (Docker DNS resolves it on the
|
||||
# egress network) on port 22, user `git`.
|
||||
manifest = Manifest.from_json_obj({
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"git": [{
|
||||
"Name": "foo",
|
||||
"Upstream": f"ssh://git@{self.upstream_name}/git/foo.git",
|
||||
"IdentityFile": str(self.auth_key),
|
||||
"KnownHostKey": self.upstream_host_key,
|
||||
}],
|
||||
},
|
||||
},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
bottle = manifest.bottles["dev"]
|
||||
gate = DockerGitGate()
|
||||
prep = gate.prepare(bottle, self.slug, self.work_dir)
|
||||
plan = dataclasses.replace(
|
||||
prep,
|
||||
internal_network=self.internal_net,
|
||||
egress_network=self.egress_net,
|
||||
)
|
||||
self.gate_name = gate.start(plan)
|
||||
|
||||
def tearDown(self):
|
||||
if self.gate_name:
|
||||
DockerGitGate().stop(self.gate_name)
|
||||
if self.upstream_name:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", self.upstream_name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||
)
|
||||
for n in (self.internal_net, self.egress_net):
|
||||
if n:
|
||||
network_remove(n)
|
||||
shutil.rmtree(self.work_dir, ignore_errors=True)
|
||||
|
||||
def _upstream_main_sha(self) -> str:
|
||||
"""Read upstream's current refs/heads/main sha by exec'ing
|
||||
directly into the upstream container's bare repo."""
|
||||
out = subprocess.run(
|
||||
["docker", "exec", "-u", "git", self.upstream_name,
|
||||
"git", "-C", "/git/foo.git", "rev-parse", "refs/heads/main"],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
return out.stdout.strip()
|
||||
|
||||
def _push_to_upstream_oob(self, message: str) -> str:
|
||||
"""Make a new commit directly on the upstream's bare repo
|
||||
(out-of-band, not through the gate). Returns the new sha."""
|
||||
script = textwrap.dedent(f"""
|
||||
set -e
|
||||
cd /tmp
|
||||
rm -rf w
|
||||
git clone /git/foo.git w
|
||||
cd w
|
||||
git config user.email upstream@example
|
||||
git config user.name upstream
|
||||
echo "$RANDOM-$$" >> README.md
|
||||
git add README.md
|
||||
git commit -q -m "{message}"
|
||||
git push -q origin main
|
||||
git rev-parse HEAD
|
||||
""").strip()
|
||||
out = subprocess.run(
|
||||
["docker", "exec", "-u", "git", self.upstream_name,
|
||||
"sh", "-c", script],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
return out.stdout.strip().splitlines()[-1]
|
||||
|
||||
@unittest.skipIf(
|
||||
os.environ.get("GITEA_ACTIONS") == "true",
|
||||
"skipped under act_runner: docker socket mount topology breaks "
|
||||
"in-process visibility of networks created on the host daemon",
|
||||
)
|
||||
def test_clone_and_refetch_reflect_upstream(self):
|
||||
"""Clone via gate returns upstream's commit. After a second
|
||||
commit lands on the upstream out-of-band, a re-fetch through
|
||||
the gate picks it up — the access-hook is refreshing before
|
||||
each upload-pack."""
|
||||
initial_sha = self._upstream_main_sha()
|
||||
|
||||
# Clone via gate.
|
||||
clone_script = (
|
||||
f"set -e\n"
|
||||
f"cd /tmp && git clone -q git://{self.gate_name}/foo.git r\n"
|
||||
f"git -C r rev-parse refs/remotes/origin/main\n"
|
||||
f"cat r/README.md\n"
|
||||
)
|
||||
clone = subprocess.run(
|
||||
["docker", "run", "--rm",
|
||||
"--network", self.internal_net,
|
||||
"--entrypoint", "sh",
|
||||
CLIENT_IMAGE,
|
||||
"-c", clone_script],
|
||||
capture_output=True, text=True, timeout=60, check=False,
|
||||
)
|
||||
self.assertEqual(
|
||||
0, clone.returncode,
|
||||
f"clone via gate failed: stdout={clone.stdout!r} "
|
||||
f"stderr={clone.stderr!r}",
|
||||
)
|
||||
cloned_sha = clone.stdout.strip().splitlines()[0]
|
||||
self.assertEqual(
|
||||
initial_sha, cloned_sha,
|
||||
"clone via gate must return the upstream's current sha",
|
||||
)
|
||||
self.assertIn("initial upstream content", clone.stdout)
|
||||
|
||||
# Out-of-band commit on the upstream.
|
||||
new_sha = self._push_to_upstream_oob("second commit")
|
||||
self.assertNotEqual(initial_sha, new_sha)
|
||||
|
||||
# ls-remote via gate (re-fetch should pick up the new sha).
|
||||
ls = subprocess.run(
|
||||
["docker", "run", "--rm",
|
||||
"--network", self.internal_net,
|
||||
"--entrypoint", "sh",
|
||||
CLIENT_IMAGE,
|
||||
"-c", f"git ls-remote git://{self.gate_name}/foo.git refs/heads/main"],
|
||||
capture_output=True, text=True, timeout=60, check=False,
|
||||
)
|
||||
self.assertEqual(0, ls.returncode, f"ls-remote failed: {ls.stderr!r}")
|
||||
gate_sha = ls.stdout.split()[0]
|
||||
self.assertEqual(
|
||||
new_sha, gate_sha,
|
||||
"ls-remote via gate must reflect the upstream's out-of-band update; "
|
||||
"if this assertion fails, the access-hook is not refreshing on every "
|
||||
"upload-pack and the gate is serving stale data",
|
||||
)
|
||||
|
||||
@unittest.skipIf(
|
||||
os.environ.get("GITEA_ACTIONS") == "true",
|
||||
"skipped under act_runner: docker socket mount topology breaks "
|
||||
"in-process visibility of networks created on the host daemon",
|
||||
)
|
||||
def test_push_through_gate_lands_on_upstream(self):
|
||||
"""A clean (no-gitleaks-hit) push through the gate lands on
|
||||
the upstream's bare repo — pre-receive phase 2 forwards
|
||||
the accepted refs."""
|
||||
# Make a commit through the gate. The script clones via gate
|
||||
# (so the commit will be a child of upstream's current main).
|
||||
push_script = textwrap.dedent(f"""
|
||||
set -e
|
||||
cd /tmp
|
||||
git clone -q git://{self.gate_name}/foo.git r
|
||||
cd r
|
||||
git config user.email client@example
|
||||
git config user.name client
|
||||
echo "client-side commit" > NEW.md
|
||||
git add NEW.md
|
||||
git commit -q -m "client commit"
|
||||
git rev-parse HEAD
|
||||
git push origin main 2>&1
|
||||
""").strip()
|
||||
push = subprocess.run(
|
||||
["docker", "run", "--rm",
|
||||
"--network", self.internal_net,
|
||||
"--entrypoint", "sh",
|
||||
CLIENT_IMAGE,
|
||||
"-c", push_script],
|
||||
capture_output=True, text=True, timeout=120, check=False,
|
||||
)
|
||||
self.assertEqual(
|
||||
0, push.returncode,
|
||||
f"push via gate failed: stdout={push.stdout!r} "
|
||||
f"stderr={push.stderr!r}",
|
||||
)
|
||||
client_sha = push.stdout.splitlines()[0].strip()
|
||||
self.assertEqual(
|
||||
client_sha, self._upstream_main_sha(),
|
||||
"push via gate must land on upstream's bare repo; "
|
||||
"if this fails the pre-receive forward phase is broken or the "
|
||||
"upstream credential is misconfigured",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,224 +0,0 @@
|
||||
"""Integration: per-agent git-gate sidecar (PRD 0008).
|
||||
|
||||
Two tests against a real Docker daemon:
|
||||
|
||||
1. ls-remote against a gate whose upstream is unreachable fails
|
||||
with the access-hook's fail-closed rejection. Proves the
|
||||
daemon is bound to its port AND the access-hook is wired:
|
||||
a working ls-remote against the gate is necessarily a working
|
||||
ls-remote against the upstream (PRD 0008's transparent-mirror
|
||||
contract).
|
||||
2. A push containing a gitleaks-detectable secret is rejected
|
||||
by the pre-receive hook with a non-zero exit on the agent
|
||||
side and a gitleaks-rejection line in the response. The PRD's
|
||||
primary success criterion.
|
||||
|
||||
A successful round-trip (clone through gate reflects upstream)
|
||||
needs a reachable upstream SSH host; deferred to a follow-up.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from claude_bottle.backend.docker.git_gate import (
|
||||
DockerGitGate,
|
||||
build_git_gate_image,
|
||||
)
|
||||
from claude_bottle.backend.docker.network import (
|
||||
network_create_egress,
|
||||
network_create_internal,
|
||||
network_remove,
|
||||
)
|
||||
from claude_bottle.manifest import Manifest
|
||||
from tests._docker import skip_unless_docker
|
||||
|
||||
# The official gitleaks image already has git + alpine; reusing it
|
||||
# for the client side too saves a separate image pull.
|
||||
CLIENT_IMAGE = "zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f"
|
||||
|
||||
# Synthetic high-entropy AKIA-shaped string; gitleaks's aws-access-token
|
||||
# rule fires on this with the default config. AWS's own example
|
||||
# ("AKIAIOSFODNN7EXAMPLE") is NOT flagged by gitleaks v8.x — entropy
|
||||
# filter rejects it — so we use a distinct random-looking value.
|
||||
FAKE_AWS_KEY = "AKIAQRJHK7N5ZPM2VXTL"
|
||||
|
||||
|
||||
@skip_unless_docker()
|
||||
class TestGitGateSidecar(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Pre-pull the client/gitleaks base so per-test runs aren't
|
||||
# racing the registry. Skip cleanly on pull failure (a real
|
||||
# outage is out of scope here).
|
||||
result = subprocess.run(
|
||||
["docker", "pull", CLIENT_IMAGE],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise unittest.SkipTest(f"could not pull {CLIENT_IMAGE}")
|
||||
# Build the gate image once for the class. Layer cache makes
|
||||
# repeated runs cheap.
|
||||
build_git_gate_image()
|
||||
|
||||
def setUp(self):
|
||||
# DNS hostnames on user-defined Docker networks max out at 63
|
||||
# chars per label (RFC 1035). The full container name is
|
||||
# `claude-bottle-git-gate-<slug>` = 23 + len(slug), so the slug
|
||||
# has to stay under ~40 to be resolvable. Keep it short.
|
||||
suffix = self.id().rsplit('.', 1)[-1].replace('_', '-')[-12:]
|
||||
self.slug = f"t{os.getpid()}-{suffix}"
|
||||
self.gate_name = ""
|
||||
self.internal_net = ""
|
||||
self.egress_net = ""
|
||||
self.work_dir = Path(tempfile.mkdtemp())
|
||||
|
||||
def tearDown(self):
|
||||
if self.gate_name:
|
||||
DockerGitGate().stop(self.gate_name)
|
||||
for n in (self.internal_net, self.egress_net):
|
||||
if n:
|
||||
network_remove(n)
|
||||
shutil.rmtree(self.work_dir, ignore_errors=True)
|
||||
|
||||
def _start_gate(self, name: str = "foo") -> str:
|
||||
"""Build a one-upstream gate and bring it up. Returns the
|
||||
container name (== git-gate hostname on the internal net)."""
|
||||
# Contents of the fake key don't matter for these tests — the
|
||||
# rejection-path hook never reaches phase 2 where it would be
|
||||
# used, and ls-remote doesn't push.
|
||||
fake_key = self.work_dir / "fake-key"
|
||||
fake_key.write_text("not-a-real-key\n")
|
||||
|
||||
manifest = Manifest.from_json_obj({
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"git": [{
|
||||
"Name": name,
|
||||
"Upstream": "ssh://git@upstream.invalid/path.git",
|
||||
"IdentityFile": str(fake_key),
|
||||
"KnownHostKey": "ssh-ed25519 AAAAEXAMPLE",
|
||||
}],
|
||||
},
|
||||
},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
bottle = manifest.bottles["dev"]
|
||||
|
||||
gate = DockerGitGate()
|
||||
prep = gate.prepare(bottle, self.slug, self.work_dir)
|
||||
|
||||
self.internal_net = network_create_internal(self.slug)
|
||||
self.egress_net = network_create_egress(self.slug)
|
||||
plan = dataclasses.replace(
|
||||
prep,
|
||||
internal_network=self.internal_net,
|
||||
egress_network=self.egress_net,
|
||||
)
|
||||
self.gate_name = gate.start(plan)
|
||||
return self.gate_name
|
||||
|
||||
@unittest.skipIf(
|
||||
os.environ.get("GITEA_ACTIONS") == "true",
|
||||
"skipped under act_runner: docker socket mount topology breaks "
|
||||
"in-process visibility of networks created on the host daemon",
|
||||
)
|
||||
def test_ls_remote_fails_closed_when_upstream_unreachable(self):
|
||||
"""The gate's access-hook runs `git fetch origin --prune` before
|
||||
every upload-pack. With the fixture's deliberately unreachable
|
||||
`ssh://git@upstream.invalid/...`, that fetch fails and the
|
||||
hook exits 1; the daemon reports access-denied. Asserting
|
||||
non-zero here is what proves the access-hook is wired: under
|
||||
the v1 (push-only) design ls-remote against a fresh gate
|
||||
returned exit 0 with no refs."""
|
||||
gate = self._start_gate("foo")
|
||||
# Daemon still has to bind first; retry the TCP connect a few
|
||||
# times. The expected end state is a non-zero exit from the
|
||||
# daemon's access-denied response — not a connection refused.
|
||||
probe = subprocess.run(
|
||||
["docker", "run", "--rm",
|
||||
"--network", self.internal_net,
|
||||
"--entrypoint", "sh",
|
||||
CLIENT_IMAGE,
|
||||
"-c",
|
||||
f"for i in $(seq 1 15); do "
|
||||
f" out=$(git ls-remote git://{gate}/foo.git 2>&1) && exit 99;"
|
||||
f" case \"$out\" in *'access denied'*|*'not exported'*) "
|
||||
f" echo \"$out\"; exit 1;; esac;"
|
||||
f" sleep 1;"
|
||||
f"done;"
|
||||
f"echo TIMEOUT; exit 2"],
|
||||
capture_output=True, text=True, timeout=60, check=False,
|
||||
)
|
||||
# exit 1: daemon access-denied as expected. exit 99 would mean
|
||||
# ls-remote actually succeeded against the unreachable upstream
|
||||
# (impossible — would indicate stale-data serving, the very
|
||||
# thing the access-hook is meant to prevent).
|
||||
self.assertEqual(
|
||||
1, probe.returncode,
|
||||
f"expected fail-closed access-denied; got "
|
||||
f"exit={probe.returncode} stdout={probe.stdout!r} "
|
||||
f"stderr={probe.stderr!r}",
|
||||
)
|
||||
|
||||
@unittest.skipIf(
|
||||
os.environ.get("GITEA_ACTIONS") == "true",
|
||||
"skipped under act_runner: docker socket mount topology breaks "
|
||||
"in-process visibility of networks created on the host daemon",
|
||||
)
|
||||
def test_push_with_secret_is_rejected(self):
|
||||
"""The PRD 0008 success criterion: a push containing a
|
||||
gitleaks-detectable secret is rejected; the hook's "gitleaks
|
||||
rejected" line appears in the response, and git push exits
|
||||
non-zero on the client side."""
|
||||
gate = self._start_gate("foo")
|
||||
push_script = (
|
||||
"set -e\n"
|
||||
"cd /tmp\n"
|
||||
# Wait for git daemon to bind. Under the v1.1 design,
|
||||
# ls-remote never returns 0 against an unreachable
|
||||
# upstream (access-hook fail-closed), so we wait for *any*
|
||||
# response (the daemon's access-denied line) as the
|
||||
# readiness signal.
|
||||
f"for i in $(seq 1 15); do "
|
||||
f" out=$(git ls-remote git://{gate}/foo.git 2>&1) || true;"
|
||||
f" case \"$out\" in *'remote error'*|*'access denied'*) break;; esac;"
|
||||
f" sleep 1;"
|
||||
f"done\n"
|
||||
"git init -q -b main repo\n"
|
||||
"cd repo\n"
|
||||
"git config user.email test@example.com\n"
|
||||
"git config user.name test\n"
|
||||
f"echo '{FAKE_AWS_KEY}' > leak.txt\n"
|
||||
"git add leak.txt\n"
|
||||
"git commit -q -m leak\n"
|
||||
f"git push git://{gate}/foo.git main 2>&1\n"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["docker", "run", "--rm",
|
||||
"--network", self.internal_net,
|
||||
"--entrypoint", "sh",
|
||||
CLIENT_IMAGE,
|
||||
"-c", push_script],
|
||||
capture_output=True, text=True, timeout=120, check=False,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
self.assertNotEqual(
|
||||
0, result.returncode,
|
||||
f"expected push to fail; output={combined!r}",
|
||||
)
|
||||
# Hook's stderr is delivered to the client via the `remote:`
|
||||
# prefix during a git push. Either token is enough to prove
|
||||
# the pre-receive hook ran and rejected the push.
|
||||
self.assertTrue(
|
||||
"gitleaks rejected" in combined or "leaks found" in combined,
|
||||
f"expected a gitleaks rejection in the response; got: {combined!r}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,8 +1,13 @@
|
||||
"""Integration: the cleanup primitives the start-flow trap depends on
|
||||
are idempotent. The original orphan-network bug was a trap-ordering
|
||||
issue; the fix moved the install earlier. The trap is only safe if
|
||||
network_remove and PipelockProxy.stop are no-ops against missing
|
||||
resources."""
|
||||
"""Integration: the network-cleanup primitives the start-flow trap
|
||||
depends on are idempotent. The original orphan-network bug was a
|
||||
trap-ordering issue; the fix moved the install earlier. The trap
|
||||
is only safe if network_remove is a no-op against missing
|
||||
resources.
|
||||
|
||||
The PipelockProxy.stop idempotency case that used to live here was
|
||||
removed in PRD 0024 chunk 3 when the per-container .stop method
|
||||
went away — sidecar teardown is now compose's responsibility, and
|
||||
`compose down` already no-ops on missing containers."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
@@ -13,10 +18,6 @@ from claude_bottle.backend.docker.network import (
|
||||
network_create_internal,
|
||||
network_remove,
|
||||
)
|
||||
from claude_bottle.backend.docker.pipelock import (
|
||||
DockerPipelockProxy,
|
||||
pipelock_container_name,
|
||||
)
|
||||
from tests._docker import skip_unless_docker
|
||||
|
||||
|
||||
@@ -71,10 +72,6 @@ class TestOrphanCleanup(unittest.TestCase):
|
||||
self.assertTrue(network_remove(self.internal_name))
|
||||
self.assertTrue(network_remove(self.egress_name))
|
||||
|
||||
def test_pipelock_stop_missing_sidecar(self):
|
||||
# Should not raise.
|
||||
DockerPipelockProxy().stop(pipelock_container_name(f"missing-{self.slug}"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -50,6 +50,11 @@ from tests.fixtures import fixture_minimal
|
||||
"skipped under act_runner: pipelock_tls_init uses a host bind mount "
|
||||
"that doesn't share fs with the runner container",
|
||||
)
|
||||
@unittest.skip(
|
||||
"PRD 0024 chunk 3: the .start-based bringup helper this test used was "
|
||||
"deleted. Chunk 4 rewrites the bringup with a direct `docker run` so "
|
||||
"the apply_allowlist_change hot-reload retains integration coverage."
|
||||
)
|
||||
class TestPipelockApply(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.slug = f"cb-test-pla-{os.getpid()}-{int(time.time())}"
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Integration: drive the production pipelock-sidecar bring-up
|
||||
(`DockerPipelockProxy.prepare` → `.start`) and probe /health from a
|
||||
sibling container on the same internal network. The point is that the
|
||||
test exercises the production code path — if the docker create/cp/start
|
||||
sequence in DockerPipelockProxy.start changes shape, this test should
|
||||
notice.
|
||||
|
||||
We don't probe /health from the host because the sidecar is created
|
||||
attached to an `--internal` network with no published port (that's
|
||||
the production topology). An in-network curl container reaches it the
|
||||
same way the agent container would in production.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from claude_bottle.backend.docker.network import (
|
||||
network_create_egress,
|
||||
network_create_internal,
|
||||
network_remove,
|
||||
)
|
||||
from claude_bottle.backend.docker.pipelock import (
|
||||
PIPELOCK_PORT,
|
||||
DockerPipelockProxy,
|
||||
pipelock_container_name,
|
||||
pipelock_tls_init,
|
||||
)
|
||||
from tests._docker import skip_unless_docker
|
||||
from tests.fixtures import fixture_minimal
|
||||
|
||||
CURL_IMAGE = "curlimages/curl:latest"
|
||||
|
||||
|
||||
@skip_unless_docker()
|
||||
class TestPipelockSidecarSmoke(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Pre-pull curlimages/curl so the per-test retry loop isn't
|
||||
# racing the registry. Skip cleanly if the pull fails (the
|
||||
# canary suite will surface a real registry outage separately).
|
||||
result = subprocess.run(
|
||||
["docker", "pull", CURL_IMAGE],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise unittest.SkipTest(f"could not pull {CURL_IMAGE}")
|
||||
|
||||
def setUp(self):
|
||||
self.slug = f"cb-test-smoke-{os.getpid()}"
|
||||
self.sidecar_name = ""
|
||||
self.internal_net = ""
|
||||
self.egress_net = ""
|
||||
self.work_dir = Path(tempfile.mkdtemp())
|
||||
|
||||
def tearDown(self):
|
||||
if self.sidecar_name:
|
||||
DockerPipelockProxy().stop(self.sidecar_name)
|
||||
for n in (self.internal_net, self.egress_net):
|
||||
if n:
|
||||
network_remove(n)
|
||||
shutil.rmtree(self.work_dir, ignore_errors=True)
|
||||
|
||||
@unittest.skipIf(
|
||||
os.environ.get("GITEA_ACTIONS") == "true",
|
||||
"skipped under act_runner: docker socket mount topology breaks "
|
||||
"in-process visibility of networks created on the host daemon",
|
||||
)
|
||||
def test_prepare_and_start_yield_healthy_sidecar(self):
|
||||
proxy = DockerPipelockProxy()
|
||||
|
||||
prep = proxy.prepare(fixture_minimal().bottles["dev"], self.slug, self.work_dir)
|
||||
|
||||
self.internal_net = network_create_internal(self.slug)
|
||||
self.egress_net = network_create_egress(self.slug)
|
||||
|
||||
# PRD 0006: pipelock's tls_interception block in the rendered
|
||||
# YAML references in-container CA paths; .start docker-cp's
|
||||
# those files in. The full launch flow generates the CA via
|
||||
# `pipelock_tls_init`; this smoke test calls it directly.
|
||||
ca_cert_host, ca_key_host = pipelock_tls_init(self.work_dir)
|
||||
plan = dataclasses.replace(
|
||||
prep,
|
||||
internal_network=self.internal_net,
|
||||
egress_network=self.egress_net,
|
||||
ca_cert_host_path=ca_cert_host,
|
||||
ca_key_host_path=ca_key_host,
|
||||
)
|
||||
|
||||
self.sidecar_name = proxy.start(plan)
|
||||
self.assertEqual(pipelock_container_name(self.slug), self.sidecar_name)
|
||||
|
||||
# Probe /health from a sibling container on the internal network —
|
||||
# same access topology the agent container uses in production.
|
||||
# curl retries on connection refused while pipelock is booting.
|
||||
probe = subprocess.run(
|
||||
[
|
||||
"docker", "run", "--rm",
|
||||
"--network", self.internal_net,
|
||||
CURL_IMAGE,
|
||||
"-sf", "--max-time", "2",
|
||||
"--retry", "15",
|
||||
"--retry-delay", "1",
|
||||
"--retry-connrefused",
|
||||
f"http://{self.sidecar_name}:{PIPELOCK_PORT}/health",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(
|
||||
0, probe.returncode,
|
||||
f"health probe failed: stdout={probe.stdout!r} stderr={probe.stderr!r}",
|
||||
)
|
||||
body = probe.stdout
|
||||
self.assertIn('"status":"healthy"', body)
|
||||
self.assertRegex(body, r'"version":"[0-9]+\.[0-9]+\.[0-9]+"')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,307 +0,0 @@
|
||||
"""Integration: drive `DockerSupervise.start` against the supervise
|
||||
sidecar and round-trip an MCP tool call through the queue (PRD 0013).
|
||||
|
||||
Topology mirrors production minimally: a per-bottle internal docker
|
||||
network for the agent ↔ supervise leg, no egress network (supervise
|
||||
doesn't make outbound calls). The "agent" is a curl container on the
|
||||
internal net; the supervisor lives on the host (this test process)
|
||||
and uses claude_bottle.cli.dashboard helpers to write Response files.
|
||||
|
||||
Verifies:
|
||||
1. `tools/list` returns the three PRD 0013 tool names over real MCP
|
||||
wire format.
|
||||
2. A `tools/call` from the in-container agent blocks until the host
|
||||
writes a Response to the queue; once written, the agent receives
|
||||
the approval payload.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from claude_bottle import supervise as _sv
|
||||
from claude_bottle.backend.docker.network import (
|
||||
network_create_internal,
|
||||
network_remove,
|
||||
)
|
||||
from claude_bottle.backend.docker.supervise import (
|
||||
DockerSupervise,
|
||||
build_supervise_image,
|
||||
supervise_container_name,
|
||||
)
|
||||
from claude_bottle.cli import dashboard
|
||||
from claude_bottle.supervise import SupervisePlan, list_pending_proposals
|
||||
from tests._docker import skip_unless_docker
|
||||
|
||||
|
||||
CURL_IMAGE = "curlimages/curl:latest"
|
||||
|
||||
|
||||
@skip_unless_docker()
|
||||
class TestSuperviseSidecar(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
r = subprocess.run(
|
||||
["docker", "pull", CURL_IMAGE],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
raise unittest.SkipTest(f"could not pull {CURL_IMAGE}")
|
||||
build_supervise_image()
|
||||
|
||||
def setUp(self):
|
||||
self.slug = f"cb-test-sv-{os.getpid()}-{int(time.time())}"
|
||||
self.sidecar_name = ""
|
||||
self.internal_net = ""
|
||||
self.work_dir = Path(tempfile.mkdtemp(prefix="supervise-int."))
|
||||
self.queue_dir = self.work_dir / "queue"
|
||||
self.queue_dir.mkdir()
|
||||
|
||||
def tearDown(self):
|
||||
if self.sidecar_name:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", self.sidecar_name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
if self.internal_net:
|
||||
network_remove(self.internal_net)
|
||||
shutil.rmtree(self.work_dir, ignore_errors=True)
|
||||
|
||||
def _require_bind_mount_sharing(self) -> None:
|
||||
"""Skip if `docker run -v <host-path>:<container-path>` doesn't
|
||||
share the filesystem between the test process and the spawned
|
||||
container. In docker-in-docker CI (Gitea Actions runner with
|
||||
host socket forwarded), bind-mount paths are resolved against
|
||||
the outer host's fs, not the runner container's — so the
|
||||
sidecar writes proposals to a dir the test process can't see.
|
||||
|
||||
Cached on the class so the probe runs once per test session."""
|
||||
cached = getattr(type(self), "_bind_mount_ok", None)
|
||||
if cached is True:
|
||||
return
|
||||
if cached is False:
|
||||
self.skipTest(
|
||||
"docker bind mounts don't share fs with this test process "
|
||||
"(likely docker-in-docker); the supervise queue round-trip "
|
||||
"requires real host fs sharing"
|
||||
)
|
||||
probe_dir = Path(tempfile.mkdtemp(prefix="supervise-bind-probe."))
|
||||
try:
|
||||
(probe_dir / "from-host").write_text("x")
|
||||
r = subprocess.run(
|
||||
[
|
||||
"docker", "run", "--rm",
|
||||
"-v", f"{probe_dir}:/probe",
|
||||
"--entrypoint", "sh",
|
||||
CURL_IMAGE,
|
||||
"-c", "test -f /probe/from-host && touch /probe/from-container",
|
||||
],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
ok = (
|
||||
r.returncode == 0
|
||||
and (probe_dir / "from-container").exists()
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(probe_dir, ignore_errors=True)
|
||||
type(self)._bind_mount_ok = ok
|
||||
if not ok:
|
||||
self.skipTest(
|
||||
"docker bind mounts don't share fs with this test process "
|
||||
"(likely docker-in-docker); the supervise queue round-trip "
|
||||
"requires real host fs sharing"
|
||||
)
|
||||
|
||||
def _bring_up_sidecar(self) -> None:
|
||||
self.internal_net = network_create_internal(self.slug)
|
||||
plan = SupervisePlan(
|
||||
slug=self.slug,
|
||||
queue_dir=self.queue_dir,
|
||||
current_config_dir=self.work_dir / "current-config",
|
||||
internal_network=self.internal_net,
|
||||
)
|
||||
# current_config_dir isn't bind-mounted into the sidecar, only
|
||||
# the queue dir is. Create it for symmetry with production.
|
||||
plan.current_config_dir.mkdir()
|
||||
self.sidecar_name = DockerSupervise().start(plan)
|
||||
|
||||
# Block until the server is ready to answer (the container
|
||||
# `docker start` returns immediately; python is still
|
||||
# binding to the port).
|
||||
deadline = time.monotonic() + 10.0
|
||||
while time.monotonic() < deadline:
|
||||
rc = subprocess.run(
|
||||
[
|
||||
"docker", "run", "--rm",
|
||||
"--network", self.internal_net,
|
||||
CURL_IMAGE,
|
||||
"-fsS", "-o", "/dev/null",
|
||||
"--max-time", "2",
|
||||
f"http://{_sv.SUPERVISE_HOSTNAME}:{_sv.SUPERVISE_PORT}/health",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode
|
||||
if rc == 0:
|
||||
return
|
||||
time.sleep(0.25)
|
||||
raise AssertionError("supervise sidecar /health never came up")
|
||||
|
||||
def _curl_jsonrpc(self, body: dict[str, object]) -> dict[str, object]:
|
||||
"""Invoke curl on the internal network to POST a JSON-RPC
|
||||
request to the supervise sidecar and parse the response."""
|
||||
payload = json.dumps(body)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker", "run", "--rm",
|
||||
"--network", self.internal_net,
|
||||
CURL_IMAGE,
|
||||
"-sS", "--max-time", "30",
|
||||
"-H", "Content-Type: application/json",
|
||||
"-X", "POST",
|
||||
"--data", payload,
|
||||
f"http://{_sv.SUPERVISE_HOSTNAME}:{_sv.SUPERVISE_PORT}/",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AssertionError(
|
||||
f"curl to supervise failed: {result.stderr}\n"
|
||||
f"stdout: {result.stdout}"
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_tools_list_over_mcp(self):
|
||||
self._bring_up_sidecar()
|
||||
result = self._curl_jsonrpc(
|
||||
{"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
|
||||
)
|
||||
self.assertEqual(1, result["id"])
|
||||
names = {t["name"] for t in result["result"]["tools"]}
|
||||
self.assertEqual(
|
||||
{
|
||||
_sv.TOOL_EGRESS_BLOCK,
|
||||
_sv.TOOL_PIPELOCK_BLOCK,
|
||||
_sv.TOOL_CAPABILITY_BLOCK,
|
||||
_sv.TOOL_LIST_EGRESS_ROUTES,
|
||||
},
|
||||
names,
|
||||
)
|
||||
|
||||
def test_tools_call_round_trips_through_queue(self):
|
||||
"""End-to-end: agent in the bottle calls egress-block;
|
||||
the call blocks on the queue; the host approves via the
|
||||
dashboard helpers; the agent receives the approval.
|
||||
|
||||
This test focuses on the supervise sidecar's queue + response
|
||||
plumbing, not the egress apply path itself. The apply
|
||||
function is stubbed so we don't need to bring up a real
|
||||
egress sidecar (its docker lifecycle has its own
|
||||
integration coverage)."""
|
||||
self._require_bind_mount_sharing()
|
||||
self._bring_up_sidecar()
|
||||
|
||||
# Stub the apply step. The dashboard's approve() calls
|
||||
# add_route to docker-exec into the egress sidecar;
|
||||
# this test isn't exercising the real sidecar, so patch it
|
||||
# to a no-op that returns plausible before/after strings
|
||||
# the audit-log writer can render.
|
||||
from claude_bottle.cli import dashboard as _dash
|
||||
original_apply = _dash.add_route
|
||||
_dash.add_route = (
|
||||
lambda slug, new: ("(stubbed before)", new)
|
||||
)
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def caller() -> None:
|
||||
captured["response"] = self._curl_jsonrpc({
|
||||
"jsonrpc": "2.0", "id": 7, "method": "tools/call",
|
||||
"params": {
|
||||
"name": _sv.TOOL_EGRESS_BLOCK,
|
||||
"arguments": {
|
||||
"host": "api.example.com",
|
||||
"justification": "integration test",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
t = threading.Thread(target=caller)
|
||||
t.start()
|
||||
try:
|
||||
# Wait for the proposal to appear in the queue (the
|
||||
# sidecar writes it before blocking on wait_for_response).
|
||||
deadline = time.monotonic() + 10.0
|
||||
qp = None
|
||||
while time.monotonic() < deadline:
|
||||
pending = list_pending_proposals(self.queue_dir)
|
||||
if pending:
|
||||
qp = dashboard.QueuedProposal(
|
||||
proposal=pending[0], queue_dir=self.queue_dir,
|
||||
)
|
||||
break
|
||||
time.sleep(0.1)
|
||||
self.assertIsNotNone(qp, "proposal never appeared in queue")
|
||||
assert qp is not None # type-narrowing
|
||||
self.assertEqual(
|
||||
_sv.TOOL_EGRESS_BLOCK, qp.proposal.tool,
|
||||
)
|
||||
self.assertEqual("integration test", qp.proposal.justification)
|
||||
|
||||
# Approve via the dashboard helper. The apply step (now
|
||||
# stubbed) would docker-exec into the egress sidecar
|
||||
# and SIGHUP it. The supervise sidecar sees the response
|
||||
# file and returns to the curl caller.
|
||||
dashboard.approve(qp, notes="lgtm from integration test")
|
||||
finally:
|
||||
_dash.add_route = original_apply
|
||||
t.join(timeout=20)
|
||||
|
||||
response = captured.get("response")
|
||||
self.assertIsNotNone(response, "curl thread never produced a response")
|
||||
assert isinstance(response, dict) # type-narrowing
|
||||
self.assertEqual(7, response["id"])
|
||||
result = response["result"]
|
||||
assert isinstance(result, dict)
|
||||
self.assertFalse(result.get("isError"))
|
||||
text = result["content"][0]["text"]
|
||||
self.assertIn("status: approved", text)
|
||||
self.assertIn("notes: lgtm from integration test", text)
|
||||
|
||||
def test_orphan_sidecar_name_collision_recovered(self):
|
||||
"""An orphan supervise sidecar from a previous run blocks
|
||||
the next .start with a duplicate-name error. Documents the
|
||||
observed behavior so a future change that adds auto-cleanup
|
||||
can flip the assertion."""
|
||||
self._bring_up_sidecar()
|
||||
self.assertEqual(supervise_container_name(self.slug), self.sidecar_name)
|
||||
# Second .start should fail because the container name is
|
||||
# taken. cleanup is handled by the orphan probe in prepare.py
|
||||
# (tested separately in test_orphan_cleanup).
|
||||
with self.assertRaises(SystemExit):
|
||||
DockerSupervise().start(SupervisePlan(
|
||||
slug=self.slug,
|
||||
queue_dir=self.queue_dir,
|
||||
current_config_dir=self.work_dir / "current-config",
|
||||
internal_network=self.internal_net,
|
||||
))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user