From 6ea19a8d53a262fd1d43f75966c3028a6774e3e9 Mon Sep 17 00:00:00 2001 From: didericis-codex Date: Fri, 29 May 2026 23:21:50 -0400 Subject: [PATCH] fix(git-gate): use smart http for smolmachines pushes --- Dockerfile.sidecars | 4 +- .../backend/smolmachines/bottle_plan.py | 2 +- bot_bottle/backend/smolmachines/launch.py | 23 +-- .../backend/smolmachines/provision/git.py | 12 +- bot_bottle/git_gate.py | 7 +- bot_bottle/git_http_backend.py | 149 +++++++++++++++ bot_bottle/sidecar_init.py | 3 +- tests/unit/test_git_gate.py | 3 + tests/unit/test_git_http_backend.py | 169 ++++++++++++++++++ tests/unit/test_provision_git.py | 9 + tests/unit/test_sidecar_init.py | 6 +- tests/unit/test_smolmachines_provision.py | 40 ++++- 12 files changed, 397 insertions(+), 30 deletions(-) create mode 100644 bot_bottle/git_http_backend.py create mode 100644 tests/unit/test_git_http_backend.py diff --git a/Dockerfile.sidecars b/Dockerfile.sidecars index ed19379..3483974 100644 --- a/Dockerfile.sidecars +++ b/Dockerfile.sidecars @@ -31,6 +31,7 @@ # 9099 egress (mitmproxy, pipelock's upstream — not externally # addressed by the agent) # 9418 git-gate (git-daemon) +# 9420 git-gate smart HTTP (smolmachines agent-facing transport) # 9100 supervise (MCP HTTP) # Stage 1: pipelock binary. The upstream pipelock image is a @@ -81,6 +82,7 @@ COPY bot_bottle/yaml_subset.py /app/yaml_subset.py COPY bot_bottle/supervise.py /app/supervise.py COPY bot_bottle/supervise_server.py /app/supervise_server.py COPY bot_bottle/sidecar_init.py /app/sidecar_init.py +COPY bot_bottle/git_http_backend.py /app/git_http_backend.py COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh RUN chmod +x /app/egress-entrypoint.sh @@ -97,7 +99,7 @@ RUN mkdir -p \ # Documentation only — the compose renderer publishes whichever # subset the bottle uses. -EXPOSE 8888 9099 9418 9100 +EXPOSE 8888 9099 9418 9420 9100 # WORKDIR matches Dockerfile.supervise's prior layout so the # in-app same-dir import in supervise_server.py stays deterministic. diff --git a/bot_bottle/backend/smolmachines/bottle_plan.py b/bot_bottle/backend/smolmachines/bottle_plan.py index e90714a..681d24d 100644 --- a/bot_bottle/backend/smolmachines/bottle_plan.py +++ b/bot_bottle/backend/smolmachines/bottle_plan.py @@ -68,7 +68,7 @@ class SmolmachinesBottlePlan(BottlePlan): # empty when the agent has no prompt — claude-code reads it # via --append-system-prompt-file only when non-empty. prompt_file: Path - # Inner Plans for the four bundle daemons. The same shape the + # Inner Plans for the sidecar bundle daemons. The same shape the # docker backend uses — same `.prepare()` calls produced # them — but our launch step doesn't populate the # docker-specific network fields (internal_network, diff --git a/bot_bottle/backend/smolmachines/launch.py b/bot_bottle/backend/smolmachines/launch.py index d133e7c..68863b5 100644 --- a/bot_bottle/backend/smolmachines/launch.py +++ b/bot_bottle/backend/smolmachines/launch.py @@ -45,7 +45,6 @@ from ..docker.git_gate import ( GIT_GATE_CREDS_DIR_IN_CONTAINER, GIT_GATE_ENTRYPOINT_IN_CONTAINER, GIT_GATE_HOOK_IN_CONTAINER, - GIT_GATE_PORT as _GIT_GATE_PORT, ) from ..docker.pipelock import ( BUNDLE_LOCAL_PIPELOCK_URL, @@ -77,6 +76,7 @@ _SMOLMACHINE_CACHE_DIR = Path.home() / ".cache" / "bot-bottle" / "smolmachines" # them up post-start. Pipelock's port is an env-overridable string # in docker.pipelock; coerce to int here. _PIPELOCK_PORT = int(_PIPELOCK_PORT_STR) +_GIT_HTTP_PORT = 9420 _SUPERVISE_PORT = SUPERVISE_PORT @@ -172,7 +172,7 @@ def launch( agent_git_gate_host = "" if plan.git_gate_plan.upstreams: git_gate_host_port = _bundle.bundle_host_port( - plan.slug, _GIT_GATE_PORT, host_ip=loopback_ip, + plan.slug, _GIT_HTTP_PORT, host_ip=loopback_ip, ) agent_git_gate_host = f"{loopback_ip}:{git_gate_host_port}" agent_supervise_url = "" @@ -190,10 +190,11 @@ def launch( # otherwise claude's HTTPS_PROXY catches direct calls to # the supervise URL (`http://:/`) and proxies # them through egress, which has no route for the alias - # and rejects with "Failed to connect". The git-gate URL - # uses git://, not affected by HTTP_PROXY, so the alias - # only has to be in NO_PROXY for the MCP / supervise - # path. Append rather than overwrite so prepare.py's + # and rejects with "Failed to connect". The smolmachines + # git-gate URL uses smart HTTP, so it also has to bypass + # the agent's HTTP_PROXY and go straight to the host- + # published git HTTP endpoint. Append rather than overwrite + # so prepare.py's # `localhost,127.0.0.1` baseline stays in place. existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1") guest_env = { @@ -203,7 +204,7 @@ def launch( "NO_PROXY": f"{existing_no_proxy},{loopback_ip}", } if agent_git_gate_host: - guest_env["GIT_GATE_URL"] = f"git://{agent_git_gate_host}" + guest_env["GIT_GATE_URL"] = f"http://{agent_git_gate_host}" if agent_supervise_url: guest_env["MCP_SUPERVISE_URL"] = agent_supervise_url plan = dataclasses.replace( @@ -305,10 +306,10 @@ def _bundle_launch_spec( Daemons in the CSV: - egress + pipelock are always present (pipelock is the agent's first hop; egress is its upstream). - - git-gate is conditional on plan.git_gate_plan.upstreams. + - git-gate + git-http are conditional on plan.git_gate_plan.upstreams. - supervise is conditional on plan.supervise_plan. - Env + volumes are the union of the four daemons' needs, with + Env + volumes are the union of the sidecar daemons' needs, with daemon-private values only (HTTPS_PROXY is scoped to the egress process by egress_entrypoint.sh — see PRD 0024's bundle bind-address PR).""" @@ -353,7 +354,7 @@ def _bundle_launch_spec( extra_hosts: list[str] = [] gp = plan.git_gate_plan if gp.upstreams: - daemons.append("git-gate") + daemons += ["git-gate", "git-http"] volumes += [ (str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER, True), (str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER, True), @@ -395,7 +396,7 @@ def _bundle_launch_spec( else: ports_to_publish = [_PIPELOCK_PORT] if gp.upstreams: - ports_to_publish.append(_GIT_GATE_PORT) + ports_to_publish.append(_GIT_HTTP_PORT) if sp is not None: ports_to_publish.append(_SUPERVISE_PORT) diff --git a/bot_bottle/backend/smolmachines/provision/git.py b/bot_bottle/backend/smolmachines/provision/git.py index 7968d23..017798d 100644 --- a/bot_bottle/backend/smolmachines/provision/git.py +++ b/bot_bottle/backend/smolmachines/provision/git.py @@ -18,7 +18,7 @@ Three concerns, all about git in the agent: Differs from `backend.docker.provision.git` in one address detail: the TSI-allowlisted guest can only reach the bundle's pinned IP (no DNS resolver in the /32 allowlist), so the insteadOf URLs -are `git://:/.git` rather than the +are `http://:/.git` rather than the docker backend's `git://git-gate/.git`. The render itself is the shared `git_gate_render_gitconfig` on the platform-neutral git_gate module.""" @@ -82,12 +82,14 @@ def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> Non if not bottle.git: return - # `127.0.0.1:` form: the bundle's git-gate port - # is published on host loopback at launch time so the - # smolvm guest (which can only reach macOS networking via + # `:` form: the bundle's git-gate + # HTTP port is published on host loopback at launch time so + # the smolvm guest (which can only reach macOS networking via # TSI, not the docker bridge IP) can dial it. launch.py # populates `plan.agent_git_gate_host` after bundle bringup. - content = git_gate_render_gitconfig(bottle.git, plan.agent_git_gate_host) + content = git_gate_render_gitconfig( + bottle.git, plan.agent_git_gate_host, scheme="http", + ) guest_gitconfig = f"{_guest_home()}/.gitconfig" # Stage the file under the plan's stage_dir so `machine cp` diff --git a/bot_bottle/git_gate.py b/bot_bottle/git_gate.py index 8b945eb..c7734b2 100644 --- a/bot_bottle/git_gate.py +++ b/bot_bottle/git_gate.py @@ -146,13 +146,13 @@ def git_gate_aggregate_extra_hosts( def git_gate_render_gitconfig( - entries: tuple[GitEntry, ...], gate_host: str + entries: tuple[GitEntry, ...], gate_host: str, *, scheme: str = "git", ) -> str: """Render the agent's ~/.gitconfig content for git-gate `insteadOf` rewrites. Pure host-side, no docker / smolvm; exposed for tests + reuse across backends. - `gate_host` is the part of the URL between `git://` and the + `gate_host` is the part of the URL between `://` and the repo path — backends differ here: - docker: `git-gate` (the short network alias) - smolmachines: `:` (no DNS in the @@ -169,7 +169,7 @@ def git_gate_render_gitconfig( "# fetch-from-upstream-before-every-upload-pack via access-hook).\n", ] for entry in entries: - out.append(f'[url "git://{gate_host}/{entry.Name}.git"]\n') + out.append(f'[url "{scheme}://{gate_host}/{entry.Name}.git"]\n') out.append(f"\tinsteadOf = {entry.Upstream}\n") if entry.RemoteKey and entry.RemoteKey != entry.UpstreamHost: port = ( @@ -237,6 +237,7 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str: " git -C \"$repo\" config git-gate.identityFile \"$keyfile\"", " git -C \"$repo\" config git-gate.knownHosts \"$hostsfile\"", " git -C \"$repo\" config receive.denyCurrentBranch ignore", + " git -C \"$repo\" config http.receivepack true", " install -m 755 /etc/git-gate/pre-receive \"$repo/hooks/pre-receive\"", "}", "", diff --git a/bot_bottle/git_http_backend.py b/bot_bottle/git_http_backend.py new file mode 100644 index 0000000..0d5ee73 --- /dev/null +++ b/bot_bottle/git_http_backend.py @@ -0,0 +1,149 @@ +"""Tiny smart-HTTP wrapper for git-gate repos. + +Used by the smolmachines backend where `git://` push traffic over the +host-published Docker port can hang before receive-pack reaches hooks. +The wrapper serves the same `/git/*.git` bare repos through +`git http-backend`, so pre-receive and upstream forwarding remain the +git-gate enforcement point. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urlsplit + + +DEFAULT_PORT = 9420 + + +class GitHttpHandler(BaseHTTPRequestHandler): + server_version = "bot-bottle-git-http/1" + + def do_GET(self) -> None: + self._run_backend() + + def do_POST(self) -> None: + self._run_backend() + + def _run_backend(self) -> None: + parsed = urlsplit(self.path) + if self._is_upload_pack(parsed.path, parsed.query): + repo_dir = self._repo_dir(parsed.path) + if repo_dir is None: + self.send_error(404) + return + hook_path = os.environ.get( + "GIT_GATE_ACCESS_HOOK", "/etc/git-gate/access-hook", + ) + hook = subprocess.run( + [hook_path, "upload-pack", + str(repo_dir), self.client_address[0], self.client_address[0]], + capture_output=True, + check=False, + ) + if hook.returncode != 0: + self.send_response(403) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.end_headers() + self.wfile.write(hook.stderr or hook.stdout) + return + env = os.environ.copy() + env.update({ + "GIT_PROJECT_ROOT": os.environ.get("GIT_PROJECT_ROOT", "/git"), + "GIT_HTTP_EXPORT_ALL": "1", + "REQUEST_METHOD": self.command, + "PATH_INFO": parsed.path, + "QUERY_STRING": parsed.query, + "CONTENT_TYPE": self.headers.get("content-type", ""), + "CONTENT_LENGTH": self.headers.get("content-length", "0"), + "REMOTE_ADDR": self.client_address[0], + "REMOTE_PORT": str(self.client_address[1]), + "REMOTE_USER": "", + "SERVER_NAME": self.server.server_name, + "SERVER_PORT": str(self.server.server_port), + "SERVER_PROTOCOL": self.request_version, + }) + for header, variable in ( + ("accept", "HTTP_ACCEPT"), + ("content-encoding", "HTTP_CONTENT_ENCODING"), + ("git-protocol", "HTTP_GIT_PROTOCOL"), + ("user-agent", "HTTP_USER_AGENT"), + ): + value = self.headers.get(header) + if value: + env[variable] = value + length = int(self.headers.get("content-length", "0") or "0") + body = self.rfile.read(length) if length else b"" + proc = subprocess.run( + ["git", "http-backend"], + input=body, + env=env, + capture_output=True, + check=False, + ) + self._write_cgi_response(proc.stdout) + + def _repo_dir(self, path: str) -> Path | None: + root = Path(os.environ.get("GIT_PROJECT_ROOT", "/git")).resolve() + relative = path.lstrip("/").split(".git", 1)[0] + ".git" + candidate = (root / relative).resolve() + if root not in (candidate, *candidate.parents): + return None + if not candidate.is_dir(): + return None + return candidate + + @staticmethod + def _is_upload_pack(path: str, query: str) -> bool: + if path.endswith("/git-upload-pack"): + return True + if path.endswith("/info/refs"): + return any( + pair == "service=git-upload-pack" + for pair in query.split("&") + ) + return False + + def _write_cgi_response(self, raw: bytes) -> None: + head, sep, body = raw.partition(b"\r\n\r\n") + line_sep = b"\r\n" + if not sep: + head, sep, body = raw.partition(b"\n\n") + line_sep = b"\n" + status = 200 + headers: list[tuple[str, str]] = [] + for line in head.split(line_sep): + if not line: + continue + key, _, value = line.decode("latin1").partition(":") + value = value.strip() + if key.lower() == "status": + status = int(value.split()[0]) + else: + headers.append((key, value)) + self.send_response(status) + for key, value in headers: + self.send_header(key, value) + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt: str, *args: object) -> None: + sys.stdout.write(fmt % args + "\n") + sys.stdout.flush() + + +def main() -> int: + port = int(os.environ.get("GIT_HTTP_PORT", str(DEFAULT_PORT))) + server = ThreadingHTTPServer(("0.0.0.0", port), GitHttpHandler) + sys.stdout.write(f"git-http listening on 0.0.0.0:{port}\n") + sys.stdout.flush() + server.serve_forever() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bot_bottle/sidecar_init.py b/bot_bottle/sidecar_init.py index a9d9457..1681c66 100644 --- a/bot_bottle/sidecar_init.py +++ b/bot_bottle/sidecar_init.py @@ -20,7 +20,7 @@ sick daemon." Daemon subset is env-driven. The compose renderer narrows it via `BOT_BOTTLE_SIDECAR_DAEMONS=egress,pipelock` for bottles that -don't use git-gate or supervise. Default: all four. +don't use git-gate or supervise. Default: all daemons. Stdlib-only by design — adding supervisord/s6/runit for four daemons is heavier than this script. @@ -98,6 +98,7 @@ _DAEMONS: tuple[_DaemonSpec, ...] = ( "--listen", "0.0.0.0:8888"), ), _DaemonSpec("git-gate", ("/bin/sh", "/git-gate-entrypoint.sh")), + _DaemonSpec("git-http", ("python3", "/app/git_http_backend.py")), _DaemonSpec("supervise", ("python3", "/app/supervise_server.py")), ) diff --git a/tests/unit/test_git_gate.py b/tests/unit/test_git_gate.py index 3fe5856..eafbad4 100644 --- a/tests/unit/test_git_gate.py +++ b/tests/unit/test_git_gate.py @@ -173,6 +173,9 @@ class TestEntrypointRender(unittest.TestCase): self.assertIn("--timeout=15", script) self.assertIn("--init-timeout=15", script) self.assertIn("--base-path=/git", script) + # Smart HTTP receive-pack uses the same bare repos and hooks + # as git-daemon, so repos must opt in to HTTP pushes too. + self.assertIn("http.receivepack true", 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) diff --git a/tests/unit/test_git_http_backend.py b/tests/unit/test_git_http_backend.py new file mode 100644 index 0000000..d2abae8 --- /dev/null +++ b/tests/unit/test_git_http_backend.py @@ -0,0 +1,169 @@ +"""Unit: smart-HTTP git-gate wrapper.""" + +import os +import subprocess +import tempfile +import threading +import unittest +import urllib.request +from pathlib import Path +from unittest import mock + +from bot_bottle.git_http_backend import GitHttpHandler + + +class TestGitHttpBackend(unittest.TestCase): + def test_real_git_push_reaches_bare_repo(self): + from http.server import ThreadingHTTPServer + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + bare = root / "repo.git" + subprocess.run(["git", "init", "--bare", str(bare)], + check=True, capture_output=True, text=True) + subprocess.run( + ["git", "-C", str(bare), "config", "http.receivepack", "true"], + check=True, + ) + + old_root = os.environ.get("GIT_PROJECT_ROOT") + os.environ["GIT_PROJECT_ROOT"] = str(root) + self.addCleanup(self._restore_env, old_root) + old_hook = os.environ.get("GIT_GATE_ACCESS_HOOK") + hook = root / "access-hook" + hook.write_text("#!/bin/sh\nexit 0\n") + hook.chmod(0o700) + os.environ["GIT_GATE_ACCESS_HOOK"] = str(hook) + self.addCleanup(self._restore_hook, old_hook) + + server = ThreadingHTTPServer(("127.0.0.1", 0), GitHttpHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + self.addCleanup(server.shutdown) + self.addCleanup(server.server_close) + + work = root / "work" + work.mkdir() + subprocess.run(["git", "init"], cwd=work, check=True, + capture_output=True, text=True) + subprocess.run(["git", "config", "user.name", "test"], + cwd=work, check=True) + subprocess.run(["git", "config", "user.email", "test@example.invalid"], + cwd=work, check=True) + (work / "README.md").write_text("test\n") + subprocess.run(["git", "add", "README.md"], cwd=work, check=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=work, + check=True, capture_output=True, text=True) + + url = f"http://127.0.0.1:{server.server_port}/repo.git" + subprocess.run( + ["git", "push", url, "HEAD:refs/heads/main"], + cwd=work, + check=True, + capture_output=True, + text=True, + timeout=5, + ) + + pushed = subprocess.check_output( + ["git", "-C", str(bare), "rev-parse", "refs/heads/main"], + text=True, + ).strip() + head = subprocess.check_output( + ["git", "-C", str(work), "rev-parse", "HEAD"], + text=True, + ).strip() + self.assertEqual(head, pushed) + subprocess.run( + ["git", "-C", str(bare), "symbolic-ref", "HEAD", "refs/heads/main"], + check=True, + ) + + clone = root / "clone" + subprocess.run( + ["git", "clone", url, str(clone)], + check=True, + capture_output=True, + text=True, + timeout=5, + ) + cloned = subprocess.check_output( + ["git", "-C", str(clone), "rev-parse", "HEAD"], + text=True, + ).strip() + self.assertEqual(head, cloned) + + def test_post_forwards_git_cgi_headers(self): + from http.server import ThreadingHTTPServer + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "repo.git").mkdir() + + old_root = os.environ.get("GIT_PROJECT_ROOT") + os.environ["GIT_PROJECT_ROOT"] = str(root) + self.addCleanup(self._restore_env, old_root) + + server = ThreadingHTTPServer(("127.0.0.1", 0), GitHttpHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + self.addCleanup(server.shutdown) + self.addCleanup(server.server_close) + + backend_response = ( + b"Status: 200 OK\r\n" + b"Content-Type: application/x-git-upload-pack-result\r\n" + b"\r\n" + b"0000" + ) + calls = [ + subprocess.CompletedProcess(["hook"], 0, b"", b""), + subprocess.CompletedProcess(["git"], 0, backend_response, b""), + ] + with mock.patch( + "bot_bottle.git_http_backend.subprocess.run", + side_effect=calls, + ) as run: + request = urllib.request.Request( + f"http://127.0.0.1:{server.server_port}" + "/repo.git/git-upload-pack", + data=b"compressed", + headers={ + "Accept": "application/x-git-upload-pack-result", + "Content-Encoding": "gzip", + "Content-Type": "application/x-git-upload-pack-request", + "Git-Protocol": "version=2", + "User-Agent": "git/test", + }, + method="POST", + ) + with urllib.request.urlopen(request, timeout=5) as response: + self.assertEqual(200, response.status) + self.assertEqual(b"0000", response.read()) + + env = run.call_args_list[1].kwargs["env"] + self.assertEqual("gzip", env["HTTP_CONTENT_ENCODING"]) + self.assertEqual("version=2", env["HTTP_GIT_PROTOCOL"]) + self.assertEqual( + "application/x-git-upload-pack-result", + env["HTTP_ACCEPT"], + ) + self.assertEqual("git/test", env["HTTP_USER_AGENT"]) + + @staticmethod + def _restore_env(value: str | None) -> None: + if value is None: + os.environ.pop("GIT_PROJECT_ROOT", None) + else: + os.environ["GIT_PROJECT_ROOT"] = value + + @staticmethod + def _restore_hook(value: str | None) -> None: + if value is None: + os.environ.pop("GIT_GATE_ACCESS_HOOK", None) + else: + os.environ["GIT_GATE_ACCESS_HOOK"] = value + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_provision_git.py b/tests/unit/test_provision_git.py index 0a891aa..7fd1c97 100644 --- a/tests/unit/test_provision_git.py +++ b/tests/unit/test_provision_git.py @@ -60,6 +60,15 @@ class TestGitGateGitconfigRender(unittest.TestCase): '[url "git://192.168.20.2:9418/bot-bottle.git"]', out, ) + def test_scheme_can_be_http_for_smolmachines(self): + bottle = fixture_with_git().bottles["dev"] + out = git_gate_render_gitconfig( + bottle.git, "127.0.0.16:57001", scheme="http", + ) + self.assertIn( + '[url "http://127.0.0.16:57001/bot-bottle.git"]', out, + ) + def test_ip_upstream_also_rewrites_logical_remote_key(self): m = Manifest.from_json_obj({ "bottles": {"dev": {"git": {"remotes": { diff --git a/tests/unit/test_sidecar_init.py b/tests/unit/test_sidecar_init.py index 0cfddf4..569de07 100644 --- a/tests/unit/test_sidecar_init.py +++ b/tests/unit/test_sidecar_init.py @@ -50,15 +50,15 @@ class TestEnvForDaemon(unittest.TestCase): env = _env_for_daemon("pipelock", self._BASE) self.assertNotIn("EGRESS_TOKEN_0", env) self.assertNotIn("EGRESS_TOKEN_1", env) - # Non-token bundle env stays — supervise / git-gate / the + # Non-token bundle env stays — supervise / git-gate / git-http / the # upstream proxy URL are all load-bearing for other # daemons. self.assertEqual("/usr/bin", env["PATH"]) self.assertEqual("http://127.0.0.1:8888", env["EGRESS_UPSTREAM_PROXY"]) self.assertEqual("9100", env["SUPERVISE_PORT"]) - def test_git_gate_and_supervise_also_lose_egress_tokens(self): - for name in ("git-gate", "supervise"): + def test_git_daemons_and_supervise_also_lose_egress_tokens(self): + for name in ("git-gate", "git-http", "supervise"): env = _env_for_daemon(name, self._BASE) self.assertNotIn("EGRESS_TOKEN_0", env) self.assertNotIn("EGRESS_TOKEN_1", env) diff --git a/tests/unit/test_smolmachines_provision.py b/tests/unit/test_smolmachines_provision.py index bc75086..754939d 100644 --- a/tests/unit/test_smolmachines_provision.py +++ b/tests/unit/test_smolmachines_provision.py @@ -9,6 +9,7 @@ from __future__ import annotations import subprocess import tempfile import unittest +from dataclasses import replace from pathlib import Path from unittest.mock import patch @@ -23,9 +24,10 @@ from bot_bottle.backend.smolmachines.provision import ( skills as _skills, supervise as _supervise, ) +from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec from bot_bottle.backend.smolmachines.smolvm import SmolvmRunResult from bot_bottle.egress import EgressPlan, EgressRoute -from bot_bottle.git_gate import GitGatePlan +from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.manifest import GitEntry, Manifest from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.supervise import SupervisePlan @@ -447,9 +449,9 @@ class TestProvisionGit(unittest.TestCase): def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self): # Smolmachines's TSI-allowlisted guest dials git-gate via - # `127.0.0.1:` — the bundle's git-gate port is - # published on host loopback at launch time, and the plan - # carries the discovered host port (here mocked to 9418). + # smart HTTP at `127.0.0.1:` — the bundle's + # git HTTP port is published on host loopback at launch + # time, and the plan carries the discovered host port. plan = _plan( git=[GitEntry( Name="bot-bottle", @@ -472,13 +474,41 @@ class TestProvisionGit(unittest.TestCase): self.assertEqual(self.stage, staged_path.parent) content = staged_path.read_text() self.assertIn( - '[url "git://127.0.0.1:9418/bot-bottle.git"]', content, + '[url "http://127.0.0.1:9418/bot-bottle.git"]', content, ) self.assertIn( "\tinsteadOf = ssh://git@host/repo.git", content, ) +class TestBundleLaunchSpec(unittest.TestCase): + def test_git_gate_uses_http_daemon_for_smolmachines(self): + plan = _plan() + plan = replace( + plan, + git_gate_plan=replace( + plan.git_gate_plan, + upstreams=(GitGateUpstream( + name="bot-bottle", + upstream_url="ssh://git@host/repo.git", + upstream_host="host", + upstream_port="22", + identity_file="/tmp/key", + known_host_key="", + ),), + ), + ) + + spec = _bundle_launch_spec(plan, "net", "127.0.0.16") + + self.assertEqual( + "egress,pipelock,git-gate,git-http", + spec.daemons_csv, + ) + self.assertIn(9420, spec.ports_to_publish) + self.assertNotIn(9418, spec.ports_to_publish) + + class TestProvisionGitUser(unittest.TestCase): """`_provision_git_user` runs `git config --global` inside the guest as the node user with HOME forced via `smolvm -e`