From 444924d95dbe097ab2314dfcec868152aa949e2d Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 13 Jul 2026 21:59:12 -0400 Subject: [PATCH] =?UTF-8?q?feat(git-gate+orchestrator):=20slice=2013c(v)?= =?UTF-8?q?=20=E2=80=94=20provision=20git-gate=20into=20the=20running=20ga?= =?UTF-8?q?teway?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Places a bottle's git-gate credentials + bare repos into the live shared gateway container when it registers — the execution side of the 13c(i) provisioning render. - provision_git_gate(gateway, bottle_id, plan): mkdir the per-bottle creds dir, docker cp each upstream's identity key (+ known_hosts when present) into /git-gate/creds//, then docker exec the namespaced, init-only script from git_gate_render_provision. No-op with no upstreams. - deprovision_git_gate(gateway, bottle_id): rm the bottle's /git/ + creds on teardown (idempotent). - bottle_id is validated (shell/path-safe alphabet) BEFORE any docker cp/rm, so a traversal id can never reach a path arg — the creds/repo dirs land in docker cp/rm arguments. Registry ids are token_hex; defense in depth. pyright 0 errors; pylint 9.83/10; unit suite green (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 --- .../backend/docker/gateway_provision.py | 94 +++++++++++++++ tests/unit/test_gateway_provision.py | 111 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 bot_bottle/backend/docker/gateway_provision.py create mode 100644 tests/unit/test_gateway_provision.py diff --git a/bot_bottle/backend/docker/gateway_provision.py b/bot_bottle/backend/docker/gateway_provision.py new file mode 100644 index 0000000..21d8aef --- /dev/null +++ b/bot_bottle/backend/docker/gateway_provision.py @@ -0,0 +1,94 @@ +"""Provision one bottle's git-gate state into the running shared gateway +(PRD 0070, docker slice). + +The consolidated gateway serves every bottle's repos under `/git//` +with per-repo credentials under `/git-gate/creds//`. When a bottle +is registered the launcher must place *its* deploy keys + known_hosts into +that per-bottle creds dir and init its bare repos there — so this copies the +credential files into the live gateway container and runs the (namespaced, +init-only) provisioning script produced by `git_gate_render_provision`. + +Isolating each bottle's creds dir + repo root by id is what keeps one +bottle's push credentials out of another's repos on the shared gateway. +""" + +from __future__ import annotations + +import re + +from ...docker_cmd import run_docker +from ...git_gate import GitGatePlan, git_gate_render_provision + +# bottle ids index the gateway's per-bottle repo + creds dirs; they land in +# `docker cp`/`rm` path arguments, so validate before any path is built (a +# traversal id like "../etc" must never reach the container). Registry ids are +# token_hex — this is defense in depth at the docker boundary. +_SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+") + + +class GatewayProvisionError(RuntimeError): + """A git-gate provisioning step against the running gateway failed.""" + + +def _require_safe(bottle_id: str) -> None: + if not _SAFE_BOTTLE_ID.fullmatch(bottle_id): + raise GatewayProvisionError(f"unsafe bottle id {bottle_id!r}") + + +def _creds_dir(bottle_id: str) -> str: + return f"/git-gate/creds/{bottle_id}" + + +def _exec(gateway: str, argv: list[str]) -> None: + """`docker exec` a command in the gateway, raising on non-zero exit.""" + proc = run_docker(["docker", "exec", gateway, *argv]) + if proc.returncode != 0: + raise GatewayProvisionError( + f"gateway exec {argv!r} failed: {proc.stderr.strip()}" + ) + + +def _cp_into(gateway: str, src: str, dest: str) -> None: + """`docker cp` a host file into the gateway, raising on non-zero exit.""" + proc = run_docker(["docker", "cp", src, f"{gateway}:{dest}"]) + if proc.returncode != 0: + raise GatewayProvisionError( + f"gateway cp {src} -> {dest} failed: {proc.stderr.strip()}" + ) + + +def provision_git_gate(gateway: str, bottle_id: str, plan: GitGatePlan) -> None: + """Place `bottle_id`'s git-gate credentials into the running `gateway` + container and init its bare repos under `/git//`. + + Copies each upstream's identity key (and known_hosts, when present) into + `/git-gate/creds//`, then runs the namespaced provisioning + script. No-op for a bottle with no git upstreams.""" + _require_safe(bottle_id) + if not plan.upstreams: + return + creds = _creds_dir(bottle_id) + _exec(gateway, ["mkdir", "-p", creds]) + for u in plan.upstreams: + if u.identity_file: + _cp_into(gateway, u.identity_file, f"{creds}/{u.name}-key") + known_hosts = str(u.known_hosts_file) + if known_hosts and known_hosts != ".": + _cp_into(gateway, known_hosts, f"{creds}/{u.name}-known_hosts") + # Init the bare repos + per-repo credential config for this namespace. + script = git_gate_render_provision(bottle_id, plan.upstreams) + _exec(gateway, ["sh", "-c", script]) + + +def deprovision_git_gate(gateway: str, bottle_id: str) -> None: + """Remove a bottle's repos + creds from the gateway on teardown. Idempotent + — an already-absent namespace is a clean no-op (best effort; a stray dir + can't leak, since attribution is by source IP and the bottle is gone).""" + _require_safe(bottle_id) + run_docker([ + "docker", "exec", gateway, "rm", "-rf", + f"/git/{bottle_id}", _creds_dir(bottle_id), + ]) + + +__all__ = ["provision_git_gate", "deprovision_git_gate", "GatewayProvisionError"] diff --git a/tests/unit/test_gateway_provision.py b/tests/unit/test_gateway_provision.py new file mode 100644 index 0000000..8755c14 --- /dev/null +++ b/tests/unit/test_gateway_provision.py @@ -0,0 +1,111 @@ +"""Unit: git-gate provisioning into the running shared gateway (PRD 0070).""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +from bot_bottle.backend.docker.gateway_provision import ( + GatewayProvisionError, + deprovision_git_gate, + provision_git_gate, +) +from bot_bottle.git_gate import GitGatePlan, GitGateUpstream + +_RUN = "bot_bottle.backend.docker.gateway_provision.run_docker" + + +def _proc(returncode: int = 0, stderr: str = "") -> Mock: + return Mock(returncode=returncode, stderr=stderr) + + +def _recorder(calls: list[list[str]]): + """A run_docker side_effect that records argv and returns success.""" + def fake(argv: list[str]) -> Mock: + calls.append(argv) + return _proc() + return fake + + +def _plan(*upstreams: GitGateUpstream) -> GitGatePlan: + return GitGatePlan( + slug="demo", + entrypoint_script=Path(), + hook_script=Path(), + access_hook_script=Path(), + upstreams=tuple(upstreams), + ) + + +def _up(name: str, *, key: str = "/host/keys/id", known_hosts: str = "") -> GitGateUpstream: + return GitGateUpstream( + name=name, + upstream_url=f"ssh://git@github.com/x/{name}.git", + upstream_host="github.com", + upstream_port="22", + identity_file=key, + known_host_key="", + known_hosts_file=Path(known_hosts) if known_hosts else Path(), + ) + + +class TestProvisionGitGate(unittest.TestCase): + def test_copies_creds_and_runs_namespaced_init(self) -> None: + calls: list[list[str]] = [] + with patch(_RUN, side_effect=_recorder(calls)): + provision_git_gate("gw", "bottle1", _plan(_up("foo", known_hosts="/host/kh"))) + + cps = [c for c in calls if c[:2] == ["docker", "cp"]] + self.assertIn(["docker", "cp", "/host/keys/id", "gw:/git-gate/creds/bottle1/foo-key"], cps) + self.assertIn( + ["docker", "cp", "/host/kh", "gw:/git-gate/creds/bottle1/foo-known_hosts"], cps, + ) + # The init script runs in the gateway, namespaced under the bottle id. + exec_scripts = [c for c in calls if c[:3] == ["docker", "exec", "gw"] and c[3] == "sh"] + self.assertEqual(1, len(exec_scripts)) + self.assertIn("repo=/git/bottle1/${name}.git", exec_scripts[0][-1]) + + def test_omits_known_hosts_copy_when_absent(self) -> None: + calls: list[list[str]] = [] + with patch(_RUN, side_effect=_recorder(calls)): + provision_git_gate("gw", "b1", _plan(_up("foo"))) # no known_hosts + cps = [c for c in calls if c[:2] == ["docker", "cp"]] + self.assertEqual(1, len(cps)) # only the key, not known_hosts + self.assertTrue(cps[0][3].endswith("/foo-key")) + + def test_no_upstreams_is_noop(self) -> None: + with patch(_RUN) as m: + provision_git_gate("gw", "b1", _plan()) + m.assert_not_called() + + def test_raises_on_docker_failure(self) -> None: + with patch(_RUN, return_value=_proc(returncode=1, stderr="boom")): + with self.assertRaises(GatewayProvisionError): + provision_git_gate("gw", "b1", _plan(_up("foo"))) + + def test_rejects_unsafe_bottle_id_before_any_docker(self) -> None: + with patch(_RUN) as m: + with self.assertRaises(GatewayProvisionError): + provision_git_gate("gw", "../etc", _plan(_up("foo"))) + m.assert_not_called() # rejected before a single docker call + + +class TestDeprovision(unittest.TestCase): + def test_removes_repo_and_creds(self) -> None: + with patch(_RUN, return_value=_proc()) as m: + deprovision_git_gate("gw", "b1") + argv = m.call_args.args[0] + self.assertEqual(["docker", "exec", "gw", "rm", "-rf"], argv[:5]) + self.assertIn("/git/b1", argv) + self.assertIn("/git-gate/creds/b1", argv) + + def test_rejects_unsafe_bottle_id(self) -> None: + with patch(_RUN) as m: + with self.assertRaises(GatewayProvisionError): + deprovision_git_gate("gw", "a/b") + m.assert_not_called() + + +if __name__ == "__main__": + unittest.main()