PRD 0008: Git gate #11

Merged
didericis merged 13 commits from git-gate into main 2026-05-12 23:16:45 -04:00
2 changed files with 407 additions and 0 deletions
Showing only changes of commit f9d9e9cf33 - Show all commits
+16
View File
@@ -272,6 +272,22 @@ if ! GIT_SSH_COMMAND="$ssh_cmd" git -C "$repo_dir" fetch origin --prune >&2; the
echo "git-gate: upstream fetch failed for $repo_dir; refusing to serve stale data" >&2
exit 1
fi
# Sync the bare repo's HEAD to upstream's HEAD on the first fetch
# (when it still points at the `git init --bare` default of
# refs/heads/master and upstream uses something else, the cloned
# checkout would fail with "remote HEAD refers to nonexistent ref").
# Costs one extra ls-remote on first fetch only; subsequent fetches
# skip the branch. If upstream's default branch changes after the
# gate has cached it, restart the bottle to resync.
if ! git -C "$repo_dir" rev-parse --verify HEAD >/dev/null 2>&1; then
upstream_head=$(GIT_SSH_COMMAND="$ssh_cmd" git -C "$repo_dir" \
ls-remote --symref origin HEAD 2>/dev/null \
| awk '/^ref:/ {print $2; exit}')
if [ -n "$upstream_head" ]; then
git -C "$repo_dir" symbolic-ref HEAD "$upstream_head" || true
fi
fi
exit 0
"""
+391
View File
@@ -0,0 +1,391 @@
"""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()