From 9e5d00c6fa00650916a1ddc2eb571bcf481deecc Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 13 Jul 2026 21:15:21 -0400 Subject: [PATCH] =?UTF-8?q?feat(git-gate+orchestrator):=20slice=2013c(i)?= =?UTF-8?q?=20=E2=80=94=20per-bottle=20git-gate=20provisioning=20render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consolidated gateway serves every bottle's repos under /git// (slice 10), so each bottle's bare repos + per-repo credential config must be provisioned into that namespace. This adds the renderer for that; placing the creds + running it inside the gateway is the launch-wiring step. - Extract the credential-wiring `init_repo` shell into a shared `_git_gate_init_repo_fn(repo_root, creds_dir)` — one source for the security-sensitive logic. The single-tenant daemon entrypoint is byte-unchanged (still /git + /git-gate/creds; existing tests green). - git_gate_render_provision(bottle_id, upstreams): posix-sh that inits ONE bottle's bare repos under /git// reading creds from /git-gate/creds//. Init-only (no `git daemon` — the shared gateway already serves). Isolating each bottle's repo root + creds dir by id is what keeps one bottle's push credentials out of another's repos. - bottle_id is embedded unquoted in the script, so it's restricted to a shell/path-safe alphabet (registry ids are token_hex; defense in depth). pyright 0 errors; pylint 9.83/10; unit suite green (1724 tests; the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- bot_bottle/git_gate.py | 2 + bot_bottle/git_gate_render.py | 78 ++++++++++++++------ tests/unit/test_git_gate_provision_render.py | 67 +++++++++++++++++ 3 files changed, 126 insertions(+), 21 deletions(-) create mode 100644 tests/unit/test_git_gate_provision_render.py diff --git a/bot_bottle/git_gate.py b/bot_bottle/git_gate.py index 0c63248..10ed752 100644 --- a/bot_bottle/git_gate.py +++ b/bot_bottle/git_gate.py @@ -46,6 +46,7 @@ from .git_gate_render import ( git_gate_known_hosts_line, git_gate_render_access_hook, git_gate_render_entrypoint, + git_gate_render_provision, git_gate_render_gitconfig, git_gate_render_hook, git_gate_upstreams_for_bottle, @@ -155,6 +156,7 @@ __all__ = [ "git_gate_render_gitconfig", "git_gate_known_hosts_line", "git_gate_render_entrypoint", + "git_gate_render_provision", "git_gate_render_hook", "git_gate_render_access_hook", "provision_git_gate_dynamic_keys", diff --git a/bot_bottle/git_gate_render.py b/bot_bottle/git_gate_render.py index ec2cbc8..5b2277c 100644 --- a/bot_bottle/git_gate_render.py +++ b/bot_bottle/git_gate_render.py @@ -9,6 +9,7 @@ own; `git_gate` re-exports these names for API stability.""" from __future__ import annotations +import re import shlex from dataclasses import dataclass from pathlib import Path @@ -125,22 +126,18 @@ def git_gate_known_hosts_line(host: str, port: str, key: str) -> str: return f"{target} {key}\n" -def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str: - """Posix-sh entrypoint. One `init_repo` call per upstream, then - `exec git daemon`. The function reads - `/git-gate/creds/-{key,known_hosts}` (bind-mounted into - the bundle by the renderer) and wires them into each bare repo's - config; the access-hook + pre-receive hook pick those paths up - at fetch / push time.""" - lines = [ - "#!/bin/sh", - "set -eu", - "", +def _git_gate_init_repo_fn(repo_root: str, creds_dir: str) -> list[str]: + """The `init_repo` shell function, parameterized by the bare-repo root + and the per-bottle creds dir. Single source of the credential-wiring + logic, shared by the single-tenant daemon entrypoint (`/git`, + `/git-gate/creds`) and the consolidated per-bottle provisioning + (`/git/`, `/git-gate/creds/`).""" + return [ "init_repo() {", " name=$1", " upstream_url=$2", - " keyfile=/git-gate/creds/${name}-key", - " hostsfile=/git-gate/creds/${name}-known_hosts", + f" keyfile={creds_dir}/${{name}}-key", + f" hostsfile={creds_dir}/${{name}}-known_hosts", "", # `|| true`: PRD 0018 chunk 3+ bind-mounts these RO from the # host, so chmod-syscalls fail with EROFS. The files already @@ -153,14 +150,14 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str: " chmod 600 \"$hostsfile\" 2>/dev/null || true", " fi", "", - " repo=/git/${name}.git", + f" repo={repo_root}/${{name}}.git", " if [ ! -d \"$repo\" ]; then", " git init --bare \"$repo\" >/dev/null", - # --mirror=fetch sets remote.origin.fetch = +refs/*:refs/* so", - # a later `git fetch origin` mirrors the upstream's full ref", - # graph (heads, tags, notes) into the bare repo at canonical", - # paths. It does NOT set remote.origin.mirror=true, so an", - # explicit `git push origin :` still pushes one ref.", + # --mirror=fetch sets remote.origin.fetch = +refs/*:refs/* so a later + # `git fetch origin` mirrors the upstream's full ref graph (heads, + # tags, notes) into the bare repo at canonical paths. It does NOT set + # remote.origin.mirror=true, so an explicit `git push origin + # :` still pushes one ref. " git -C \"$repo\" remote add --mirror=fetch origin \"$upstream_url\"", " fi", " git -C \"$repo\" config git-gate.identityFile \"$keyfile\"", @@ -170,9 +167,19 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str: " git -C \"$repo\" config http.receivepack true", " install -m 755 /etc/git-gate/pre-receive \"$repo/hooks/pre-receive\"", "}", - "", - "mkdir -p /git", ] + + +def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str: + """Posix-sh entrypoint. One `init_repo` call per upstream, then + `exec git daemon`. The function reads + `/git-gate/creds/-{key,known_hosts}` (bind-mounted into + the bundle by the renderer) and wires them into each bare repo's + config; the access-hook + pre-receive hook pick those paths up + at fetch / push time.""" + lines = ["#!/bin/sh", "set -eu", ""] + lines += _git_gate_init_repo_fn("/git", "/git-gate/creds") + lines += ["", "mkdir -p /git"] for u in upstreams: lines.append(f"init_repo {shlex.quote(u.name)} {shlex.quote(u.upstream_url)}") lines.extend([ @@ -190,6 +197,35 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str: return "\n".join(lines) + "\n" +# A bottle id namespaces the consolidated gateway's repo + creds dirs; it is +# embedded unquoted in the provisioning script, so restrict it to a shell- and +# path-safe alphabet (registry ids are token_hex — this is defense in depth). +_SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+") + + +def git_gate_render_provision( + bottle_id: str, upstreams: tuple[GitGateUpstream, ...], +) -> str: + """Posix-sh script that provisions ONE bottle's bare repos into the + consolidated gateway (PRD 0070), under `/git//` with creds + read from `/git-gate/creds//`. Init-only — no `git daemon`, + since the shared gateway already serves every bottle; run inside the + running gateway when the bottle is registered. + + Isolating each bottle's repo root and creds dir by id is what keeps one + bottle's push credentials out of another's repos on the shared gateway.""" + if not _SAFE_BOTTLE_ID.fullmatch(bottle_id): + raise ValueError(f"git-gate: unsafe bottle id {bottle_id!r}") + repo_root = f"/git/{bottle_id}" + creds_dir = f"/git-gate/creds/{bottle_id}" + lines = ["#!/bin/sh", "set -eu", ""] + lines += _git_gate_init_repo_fn(repo_root, creds_dir) + lines += ["", f"mkdir -p {shlex.quote(repo_root)}"] + for u in upstreams: + lines.append(f"init_repo {shlex.quote(u.name)} {shlex.quote(u.upstream_url)}") + return "\n".join(lines) + "\n" + + def git_gate_render_hook() -> str: """The shared pre-receive hook: gitleaks-scan all incoming refs, then forward each accepted ref to the real upstream (`origin`) diff --git a/tests/unit/test_git_gate_provision_render.py b/tests/unit/test_git_gate_provision_render.py new file mode 100644 index 0000000..3fc1a07 --- /dev/null +++ b/tests/unit/test_git_gate_provision_render.py @@ -0,0 +1,67 @@ +"""Unit: consolidated per-bottle git-gate provisioning render (PRD 0070).""" + +from __future__ import annotations + +import unittest + +from bot_bottle.git_gate_render import ( + GitGateUpstream, + git_gate_render_entrypoint, + git_gate_render_provision, +) + + +def _ups(*names: str) -> tuple[GitGateUpstream, ...]: + return tuple( + GitGateUpstream( + name=n, + upstream_url=f"ssh://git@github.com/x/{n}.git", + upstream_host="github.com", + upstream_port="22", + identity_file="", + known_host_key="", + ) + for n in names + ) + + +class TestProvisionRender(unittest.TestCase): + def test_namespaces_repos_and_creds_by_bottle_id(self) -> None: + script = git_gate_render_provision("bottleab12", _ups("foo")) + self.assertIn("repo=/git/bottleab12/${name}.git", script) + self.assertIn("keyfile=/git-gate/creds/bottleab12/${name}-key", script) + self.assertIn("mkdir -p /git/bottleab12", script) + + def test_one_init_repo_call_per_upstream(self) -> None: + script = git_gate_render_provision("b1", _ups("foo", "bar")) + calls = [l for l in script.splitlines() if l.startswith("init_repo ")] + self.assertEqual(2, len(calls)) + + def test_provision_does_not_start_the_daemon(self) -> None: + # The shared gateway already serves; provisioning is init-only. + self.assertNotIn("git daemon", git_gate_render_provision("b1", _ups("foo"))) + + def test_installs_pre_receive_hook(self) -> None: + script = git_gate_render_provision("b1", _ups("foo")) + self.assertIn("install -m 755 /etc/git-gate/pre-receive", script) + + def test_rejects_unsafe_bottle_id(self) -> None: + for bad in ("../etc", "a/b", "a b", "a;rm", ""): + with self.assertRaises(ValueError): + git_gate_render_provision(bad, _ups("foo")) + + +class TestEntrypointUnchanged(unittest.TestCase): + """The shared `_git_gate_init_repo_fn` refactor must not alter the + single-tenant daemon entrypoint's output.""" + + def test_entrypoint_still_single_tenant_flat(self) -> None: + script = git_gate_render_entrypoint(_ups("foo")) + self.assertIn("repo=/git/${name}.git", script) # flat, not namespaced + self.assertIn("keyfile=/git-gate/creds/${name}-key", script) + self.assertIn("--base-path=/git", script) + self.assertIn("exec git daemon", script) + + +if __name__ == "__main__": + unittest.main()