feat(git-gate): mirror fetch through access-hook (bidirectional)
The gate is now a transparent mirror, not push-only. Per-repo init now runs `git remote add --mirror=fetch origin <url>` so a later `git fetch origin` mirrors the upstream's full ref graph at canonical paths. The pre-receive hook forwards accepted refs via `git push origin` (renamed from upstream). New: an access-hook script wired via `git daemon --access-hook` runs `git fetch origin --prune` against the real upstream before every upload-pack request (clone, fetch, pull, ls-remote). On upstream error the hook exits non-zero — the agent's fetch fails rather than the gate serving stale data. The pre-existing smoke test (ls-remote against unreachable upstream returns refs) had to invert: under the bidirectional design any ls-remote success is necessarily a success against the upstream, so the unreachable-upstream case now correctly fails closed.
This commit is contained in:
@@ -2,16 +2,19 @@
|
||||
|
||||
Two tests against a real Docker daemon:
|
||||
|
||||
1. A freshly-started gate answers ls-remote requests on its
|
||||
internal-network address. Proves the daemon is up and the
|
||||
bare repos rendered by the entrypoint are exported.
|
||||
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. This is
|
||||
the PRD's success criterion.
|
||||
side and a gitleaks-rejection line in the response. The PRD's
|
||||
primary success criterion.
|
||||
|
||||
A successful clean-push roundtrip needs a real upstream SSH host;
|
||||
deferred to a follow-up integration test.
|
||||
A successful round-trip (clone through gate reflects upstream)
|
||||
needs a reachable upstream SSH host; deferred to a follow-up.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
@@ -124,14 +127,18 @@ class TestGitGateSidecar(unittest.TestCase):
|
||||
"skipped under act_runner: docker socket mount topology breaks "
|
||||
"in-process visibility of networks created on the host daemon",
|
||||
)
|
||||
def test_ls_remote_succeeds_against_fresh_gate(self):
|
||||
"""A freshly-started gate has an empty bare repo per upstream;
|
||||
`git ls-remote` returns no refs and exits 0. Probes the gate
|
||||
from a sibling container on the same internal network — same
|
||||
access topology the agent uses in production."""
|
||||
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")
|
||||
# git ls-remote retries weren't strictly needed in local runs,
|
||||
# but the daemon takes a beat to bind after docker start.
|
||||
# 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,
|
||||
@@ -139,15 +146,23 @@ class TestGitGateSidecar(unittest.TestCase):
|
||||
CLIENT_IMAGE,
|
||||
"-c",
|
||||
f"for i in $(seq 1 15); do "
|
||||
f" git ls-remote git://{gate}/foo.git >/tmp/out 2>&1 && exit 0;"
|
||||
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"cat /tmp/out; exit 1"],
|
||||
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(
|
||||
0, probe.returncode,
|
||||
f"ls-remote failed: stdout={probe.stdout!r} stderr={probe.stderr!r}",
|
||||
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(
|
||||
@@ -164,10 +179,14 @@ class TestGitGateSidecar(unittest.TestCase):
|
||||
push_script = (
|
||||
"set -e\n"
|
||||
"cd /tmp\n"
|
||||
# Wait for git daemon to bind. ls-remote retries until
|
||||
# connection works; we then assume the gate is ready.
|
||||
# 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" git ls-remote git://{gate}/foo.git >/dev/null 2>&1 && break;"
|
||||
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"
|
||||
|
||||
@@ -10,6 +10,7 @@ from claude_bottle.git_gate import (
|
||||
GitGatePlan,
|
||||
GitGateUpstream,
|
||||
git_gate_known_hosts_line,
|
||||
git_gate_render_access_hook,
|
||||
git_gate_render_entrypoint,
|
||||
git_gate_render_hook,
|
||||
git_gate_upstreams_for_bottle,
|
||||
@@ -87,6 +88,12 @@ class TestEntrypointRender(unittest.TestCase):
|
||||
self.assertIn("exec git daemon", script)
|
||||
self.assertIn("--enable=receive-pack", script)
|
||||
self.assertIn("--base-path=/git", script)
|
||||
# The access-hook is what makes fetch a mirror operation
|
||||
# against the upstream (PRD 0008 v1.1).
|
||||
self.assertIn("--access-hook=/etc/git-gate/access-hook", script)
|
||||
# Each repo's `origin` remote is wired to the upstream via
|
||||
# --mirror=fetch so `git fetch origin` mirrors all refs.
|
||||
self.assertIn("remote add --mirror=fetch origin", script)
|
||||
|
||||
def test_empty_upstreams_still_execs_daemon(self):
|
||||
# A no-upstream gate is a no-op for repos but the daemon still
|
||||
@@ -97,17 +104,36 @@ class TestEntrypointRender(unittest.TestCase):
|
||||
|
||||
|
||||
class TestHookRender(unittest.TestCase):
|
||||
def test_hook_has_two_phases(self):
|
||||
def test_pre_receive_hook_has_two_phases(self):
|
||||
hook = git_gate_render_hook()
|
||||
# Phase 1: gitleaks. Phase 2: forward.
|
||||
# Phase 1: gitleaks. Phase 2: forward to origin.
|
||||
self.assertIn("gitleaks git", hook)
|
||||
self.assertIn("git push upstream", hook)
|
||||
self.assertIn("git push origin", hook)
|
||||
# KnownHostKey absence is fail-closed.
|
||||
self.assertIn("refusing to push", hook)
|
||||
# Stdin is buffered to a tempfile so both phases can re-read.
|
||||
self.assertIn("refs_file=$(mktemp)", hook)
|
||||
|
||||
|
||||
class TestAccessHookRender(unittest.TestCase):
|
||||
def test_access_hook_refreshes_origin_on_upload_pack(self):
|
||||
hook = git_gate_render_access_hook()
|
||||
# Service-name guard: only upload-pack (fetch / clone / pull /
|
||||
# ls-remote) triggers the upstream refresh; receive-pack
|
||||
# bypasses this and the pre-receive hook gates it instead.
|
||||
self.assertIn('service=$1', hook)
|
||||
self.assertIn('"$service" != "upload-pack"', hook)
|
||||
# The fetch is what makes the gate a transparent mirror.
|
||||
self.assertIn("git -C \"$repo_dir\" fetch origin --prune", hook)
|
||||
|
||||
def test_access_hook_fail_closed_on_upstream_error(self):
|
||||
hook = git_gate_render_access_hook()
|
||||
# Upstream-fetch failure exits non-zero, which propagates to
|
||||
# the agent's fetch as a real error rather than stale data.
|
||||
self.assertIn("refusing to serve stale data", hook)
|
||||
self.assertIn("exit 1", hook)
|
||||
|
||||
|
||||
class TestPrepare(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.stage = Path(tempfile.mkdtemp())
|
||||
@@ -117,7 +143,7 @@ class TestPrepare(unittest.TestCase):
|
||||
|
||||
shutil.rmtree(self.stage, ignore_errors=True)
|
||||
|
||||
def test_prepare_writes_entrypoint_and_hook_mode_600(self):
|
||||
def test_prepare_writes_all_three_scripts(self):
|
||||
plan = _StubGate().prepare(
|
||||
fixture_with_git().bottles["dev"], "demo", self.stage
|
||||
)
|
||||
@@ -127,8 +153,17 @@ class TestPrepare(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
self.stage / "git_gate_pre_receive.sh", plan.hook_script
|
||||
)
|
||||
self.assertEqual(
|
||||
self.stage / "git_gate_access_hook.sh", plan.access_hook_script
|
||||
)
|
||||
# Entrypoint + pre-receive are mode 600 (loaded into the
|
||||
# gate by docker cp and then `install -m 755`'d into each
|
||||
# bare repo's hooks/ — source bit doesn't matter). The
|
||||
# access-hook is execed directly by git daemon, so it has to
|
||||
# carry the x bit through docker cp.
|
||||
self.assertEqual(0o600, os.stat(plan.entrypoint_script).st_mode & 0o777)
|
||||
self.assertEqual(0o600, os.stat(plan.hook_script).st_mode & 0o777)
|
||||
self.assertEqual(0o700, os.stat(plan.access_hook_script).st_mode & 0o777)
|
||||
|
||||
def test_prepare_plan_carries_upstreams_and_slug(self):
|
||||
plan = _StubGate().prepare(
|
||||
|
||||
Reference in New Issue
Block a user