feat(git-gate): add DockerGitGate sidecar lifecycle + image
Dockerfile.git-gate builds a small alpine image with git, openssh-client, and gitleaks; the directory layout the entrypoint and per-upstream cp's expect is pre-created in the image so docker cp can target paths beneath /etc/git-gate and /git-gate/creds at container-create time (cp doesn't create intermediate dirs). DockerGitGate.start mirrors DockerSSHGate's shape: build, create, cp the rendered entrypoint + hook + per-upstream identity files (plus a known_hosts file synthesized from KnownHostKey when set), attach the egress network, start. build_image gains an optional dockerfile= argument so the gate can build from its own Dockerfile in the shared context. PRD: docs/prds/0008-git-gate.md
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# Per-agent git-gate sidecar image (PRD 0008).
|
||||
#
|
||||
# Runs `git daemon --enable=receive-pack` so the agent in the bottle
|
||||
# can push to it over git://. A shared pre-receive hook runs gitleaks
|
||||
# against each incoming ref; on clean, it forwards the ref to the real
|
||||
# upstream using a credential the gate holds. The agent never sees the
|
||||
# upstream credential.
|
||||
#
|
||||
# The agent-facing leg sits on a Docker --internal network with no
|
||||
# default route, so the image is fully self-contained: no apk pulls at
|
||||
# boot, no remote registry lookups during the entrypoint.
|
||||
|
||||
FROM alpine:3.20
|
||||
|
||||
# git for the daemon + push-to-upstream;
|
||||
# openssh-client for the upstream SSH transport;
|
||||
# gitleaks is the actual scanner the pre-receive hook calls.
|
||||
RUN apk add --no-cache git openssh-client gitleaks
|
||||
|
||||
# Layout the gate uses at runtime:
|
||||
# /git-gate-entrypoint.sh — docker-cp'd at start time
|
||||
# /etc/git-gate/pre-receive — shared hook, docker-cp'd at start
|
||||
# /git-gate/creds/<name>-key — per-upstream identity, docker-cp'd
|
||||
# /git-gate/creds/<name>-known_hosts — per-upstream known_hosts, docker-cp'd
|
||||
# /git/<name>.git — bare repos, created by the entrypoint
|
||||
#
|
||||
# The intermediate directories must exist before `docker cp` runs (cp
|
||||
# does not create them); the bare-repo parent (/git) is also pre-created
|
||||
# defensively.
|
||||
RUN mkdir -p /etc/git-gate /git-gate/creds /git
|
||||
|
||||
ENTRYPOINT ["/bin/sh", "/git-gate-entrypoint.sh"]
|
||||
@@ -0,0 +1,206 @@
|
||||
"""DockerGitGate — the Docker-specific lifecycle for the per-agent
|
||||
git-gate sidecar (PRD 0008). Inherits the platform-agnostic prepare
|
||||
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_known_hosts_line
|
||||
from ...log import die, info, warn
|
||||
from ...util import expand_tilde
|
||||
from . import util as docker_mod
|
||||
|
||||
|
||||
GIT_GATE_IMAGE = os.environ.get(
|
||||
"CLAUDE_BOTTLE_GIT_GATE_IMAGE",
|
||||
"claude-bottle-git-gate:latest",
|
||||
)
|
||||
|
||||
GIT_GATE_DOCKERFILE = "Dockerfile.git-gate"
|
||||
|
||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER = "/git-gate-entrypoint.sh"
|
||||
GIT_GATE_HOOK_IN_CONTAINER = "/etc/git-gate/pre-receive"
|
||||
GIT_GATE_CREDS_DIR_IN_CONTAINER = "/git-gate/creds"
|
||||
|
||||
# git daemon's default listening port. Surfaced as a constant because
|
||||
# integration tests probe the gate on it.
|
||||
GIT_GATE_PORT = 9418
|
||||
|
||||
# Repo root, for `docker build` context. Resolved from this file's
|
||||
# location: claude_bottle/backend/docker/git_gate.py → repo root.
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
|
||||
|
||||
def git_gate_container_name(slug: str) -> str:
|
||||
return f"claude-bottle-git-gate-{slug}"
|
||||
|
||||
|
||||
def git_gate_host(slug: str) -> str:
|
||||
"""The hostname the agent's git client should connect to (same as
|
||||
the container name — Docker's embedded DNS resolves it on the
|
||||
`--internal` network)."""
|
||||
return git_gate_container_name(slug)
|
||||
|
||||
|
||||
def build_git_gate_image() -> None:
|
||||
"""Build the git-gate image from `Dockerfile.git-gate`. Called by
|
||||
`DockerGitGate.start`; exposed at module level so integration
|
||||
tests can build it without running the full launch pipeline."""
|
||||
docker_mod.build_image(GIT_GATE_IMAGE, _REPO_DIR, dockerfile=GIT_GATE_DOCKERFILE)
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
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,
|
||||
GIT_GATE_IMAGE,
|
||||
]
|
||||
if subprocess.run(
|
||||
create_args,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode != 0:
|
||||
die(f"failed to create git-gate sidecar {name}")
|
||||
|
||||
# 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"),
|
||||
]
|
||||
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()}"
|
||||
)
|
||||
|
||||
if subprocess.run(
|
||||
["docker", "network", "connect", plan.egress_network, name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).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}"
|
||||
)
|
||||
|
||||
if subprocess.run(
|
||||
["docker", "start", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).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}")
|
||||
|
||||
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}'"
|
||||
)
|
||||
@@ -100,12 +100,20 @@ def slugify(name: str) -> str:
|
||||
return slug
|
||||
|
||||
|
||||
def build_image(ref: str, context: str) -> None:
|
||||
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
"""Invokes `docker build` every call. Layer cache makes no-change
|
||||
rebuilds cheap; running every time means Dockerfile edits land
|
||||
without manual `docker rmi`."""
|
||||
without manual `docker rmi`.
|
||||
|
||||
`dockerfile` is an optional path (relative to `context`, or
|
||||
absolute) for callers that need to build from a non-default
|
||||
Dockerfile in the same context — e.g. `Dockerfile.git-gate`."""
|
||||
info(f"building image {ref} from {context} (layer cache keeps repeat builds fast)")
|
||||
subprocess.run(["docker", "build", "-t", ref, context], check=True)
|
||||
args = ["docker", "build", "-t", ref]
|
||||
if dockerfile:
|
||||
args.extend(["-f", dockerfile])
|
||||
args.append(context)
|
||||
subprocess.run(args, check=True)
|
||||
|
||||
|
||||
_TRUST_DIALOG_NODE_SCRIPT = (
|
||||
|
||||
Reference in New Issue
Block a user