fix(git-gate): use smart http for smolmachines pushes
test / unit (pull_request) Successful in 40s
test / integration (pull_request) Successful in 54s
test / unit (push) Successful in 37s
test / integration (push) Successful in 44s

This commit was merged in pull request #114.
This commit is contained in:
2026-05-29 23:21:50 -04:00
parent 630e65e9a4
commit 6ea19a8d53
12 changed files with 397 additions and 30 deletions
+3 -1
View File
@@ -31,6 +31,7 @@
# 9099 egress (mitmproxy, pipelock's upstream — not externally # 9099 egress (mitmproxy, pipelock's upstream — not externally
# addressed by the agent) # addressed by the agent)
# 9418 git-gate (git-daemon) # 9418 git-gate (git-daemon)
# 9420 git-gate smart HTTP (smolmachines agent-facing transport)
# 9100 supervise (MCP HTTP) # 9100 supervise (MCP HTTP)
# Stage 1: pipelock binary. The upstream pipelock image is a # 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.py /app/supervise.py
COPY bot_bottle/supervise_server.py /app/supervise_server.py COPY bot_bottle/supervise_server.py /app/supervise_server.py
COPY bot_bottle/sidecar_init.py /app/sidecar_init.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 COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
RUN chmod +x /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 # Documentation only — the compose renderer publishes whichever
# subset the bottle uses. # subset the bottle uses.
EXPOSE 8888 9099 9418 9100 EXPOSE 8888 9099 9418 9420 9100
# WORKDIR matches Dockerfile.supervise's prior layout so the # WORKDIR matches Dockerfile.supervise's prior layout so the
# in-app same-dir import in supervise_server.py stays deterministic. # in-app same-dir import in supervise_server.py stays deterministic.
@@ -68,7 +68,7 @@ class SmolmachinesBottlePlan(BottlePlan):
# empty when the agent has no prompt — claude-code reads it # empty when the agent has no prompt — claude-code reads it
# via --append-system-prompt-file only when non-empty. # via --append-system-prompt-file only when non-empty.
prompt_file: Path 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 # docker backend uses — same `.prepare()` calls produced
# them — but our launch step doesn't populate the # them — but our launch step doesn't populate the
# docker-specific network fields (internal_network, # docker-specific network fields (internal_network,
+12 -11
View File
@@ -45,7 +45,6 @@ from ..docker.git_gate import (
GIT_GATE_CREDS_DIR_IN_CONTAINER, GIT_GATE_CREDS_DIR_IN_CONTAINER,
GIT_GATE_ENTRYPOINT_IN_CONTAINER, GIT_GATE_ENTRYPOINT_IN_CONTAINER,
GIT_GATE_HOOK_IN_CONTAINER, GIT_GATE_HOOK_IN_CONTAINER,
GIT_GATE_PORT as _GIT_GATE_PORT,
) )
from ..docker.pipelock import ( from ..docker.pipelock import (
BUNDLE_LOCAL_PIPELOCK_URL, 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 # them up post-start. Pipelock's port is an env-overridable string
# in docker.pipelock; coerce to int here. # in docker.pipelock; coerce to int here.
_PIPELOCK_PORT = int(_PIPELOCK_PORT_STR) _PIPELOCK_PORT = int(_PIPELOCK_PORT_STR)
_GIT_HTTP_PORT = 9420
_SUPERVISE_PORT = SUPERVISE_PORT _SUPERVISE_PORT = SUPERVISE_PORT
@@ -172,7 +172,7 @@ def launch(
agent_git_gate_host = "" agent_git_gate_host = ""
if plan.git_gate_plan.upstreams: if plan.git_gate_plan.upstreams:
git_gate_host_port = _bundle.bundle_host_port( 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_git_gate_host = f"{loopback_ip}:{git_gate_host_port}"
agent_supervise_url = "" agent_supervise_url = ""
@@ -190,10 +190,11 @@ def launch(
# otherwise claude's HTTPS_PROXY catches direct calls to # otherwise claude's HTTPS_PROXY catches direct calls to
# the supervise URL (`http://<alias>:<port>/`) and proxies # the supervise URL (`http://<alias>:<port>/`) and proxies
# them through egress, which has no route for the alias # them through egress, which has no route for the alias
# and rejects with "Failed to connect". The git-gate URL # and rejects with "Failed to connect". The smolmachines
# uses git://, not affected by HTTP_PROXY, so the alias # git-gate URL uses smart HTTP, so it also has to bypass
# only has to be in NO_PROXY for the MCP / supervise # the agent's HTTP_PROXY and go straight to the host-
# path. Append rather than overwrite so prepare.py's # published git HTTP endpoint. Append rather than overwrite
# so prepare.py's
# `localhost,127.0.0.1` baseline stays in place. # `localhost,127.0.0.1` baseline stays in place.
existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1") existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1")
guest_env = { guest_env = {
@@ -203,7 +204,7 @@ def launch(
"NO_PROXY": f"{existing_no_proxy},{loopback_ip}", "NO_PROXY": f"{existing_no_proxy},{loopback_ip}",
} }
if agent_git_gate_host: 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: if agent_supervise_url:
guest_env["MCP_SUPERVISE_URL"] = agent_supervise_url guest_env["MCP_SUPERVISE_URL"] = agent_supervise_url
plan = dataclasses.replace( plan = dataclasses.replace(
@@ -305,10 +306,10 @@ def _bundle_launch_spec(
Daemons in the CSV: Daemons in the CSV:
- egress + pipelock are always present (pipelock is the - egress + pipelock are always present (pipelock is the
agent's first hop; egress is its upstream). 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. - 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 daemon-private values only (HTTPS_PROXY is scoped to the
egress process by egress_entrypoint.sh — see PRD 0024's bundle egress process by egress_entrypoint.sh — see PRD 0024's bundle
bind-address PR).""" bind-address PR)."""
@@ -353,7 +354,7 @@ def _bundle_launch_spec(
extra_hosts: list[str] = [] extra_hosts: list[str] = []
gp = plan.git_gate_plan gp = plan.git_gate_plan
if gp.upstreams: if gp.upstreams:
daemons.append("git-gate") daemons += ["git-gate", "git-http"]
volumes += [ volumes += [
(str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER, True), (str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER, True),
(str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER, True), (str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER, True),
@@ -395,7 +396,7 @@ def _bundle_launch_spec(
else: else:
ports_to_publish = [_PIPELOCK_PORT] ports_to_publish = [_PIPELOCK_PORT]
if gp.upstreams: if gp.upstreams:
ports_to_publish.append(_GIT_GATE_PORT) ports_to_publish.append(_GIT_HTTP_PORT)
if sp is not None: if sp is not None:
ports_to_publish.append(_SUPERVISE_PORT) ports_to_publish.append(_SUPERVISE_PORT)
@@ -18,7 +18,7 @@ Three concerns, all about git in the agent:
Differs from `backend.docker.provision.git` in one address detail: Differs from `backend.docker.provision.git` in one address detail:
the TSI-allowlisted guest can only reach the bundle's pinned IP the TSI-allowlisted guest can only reach the bundle's pinned IP
(no DNS resolver in the /32 allowlist), so the insteadOf URLs (no DNS resolver in the /32 allowlist), so the insteadOf URLs
are `git://<bundle_ip>:<port>/<name>.git` rather than the are `http://<bundle_ip>:<port>/<name>.git` rather than the
docker backend's `git://git-gate/<name>.git`. The render itself docker backend's `git://git-gate/<name>.git`. The render itself
is the shared `git_gate_render_gitconfig` on the platform-neutral is the shared `git_gate_render_gitconfig` on the platform-neutral
git_gate module.""" git_gate module."""
@@ -82,12 +82,14 @@ def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> Non
if not bottle.git: if not bottle.git:
return return
# `127.0.0.1:<host port>` form: the bundle's git-gate port # `<loopback alias>:<host port>` form: the bundle's git-gate
# is published on host loopback at launch time so the # HTTP port is published on host loopback at launch time so
# smolvm guest (which can only reach macOS networking via # the smolvm guest (which can only reach macOS networking via
# TSI, not the docker bridge IP) can dial it. launch.py # TSI, not the docker bridge IP) can dial it. launch.py
# populates `plan.agent_git_gate_host` after bundle bringup. # 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" guest_gitconfig = f"{_guest_home()}/.gitconfig"
# Stage the file under the plan's stage_dir so `machine cp` # Stage the file under the plan's stage_dir so `machine cp`
+4 -3
View File
@@ -146,13 +146,13 @@ def git_gate_aggregate_extra_hosts(
def git_gate_render_gitconfig( def git_gate_render_gitconfig(
entries: tuple[GitEntry, ...], gate_host: str entries: tuple[GitEntry, ...], gate_host: str, *, scheme: str = "git",
) -> str: ) -> str:
"""Render the agent's ~/.gitconfig content for git-gate """Render the agent's ~/.gitconfig content for git-gate
`insteadOf` rewrites. Pure host-side, no docker / smolvm; `insteadOf` rewrites. Pure host-side, no docker / smolvm;
exposed for tests + reuse across backends. 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 `<scheme>://` and the
repo path — backends differ here: repo path — backends differ here:
- docker: `git-gate` (the short network alias) - docker: `git-gate` (the short network alias)
- smolmachines: `<bundle_ip>:<port>` (no DNS in the - smolmachines: `<bundle_ip>:<port>` (no DNS in the
@@ -169,7 +169,7 @@ def git_gate_render_gitconfig(
"# fetch-from-upstream-before-every-upload-pack via access-hook).\n", "# fetch-from-upstream-before-every-upload-pack via access-hook).\n",
] ]
for entry in entries: 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") out.append(f"\tinsteadOf = {entry.Upstream}\n")
if entry.RemoteKey and entry.RemoteKey != entry.UpstreamHost: if entry.RemoteKey and entry.RemoteKey != entry.UpstreamHost:
port = ( 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.identityFile \"$keyfile\"",
" git -C \"$repo\" config git-gate.knownHosts \"$hostsfile\"", " git -C \"$repo\" config git-gate.knownHosts \"$hostsfile\"",
" git -C \"$repo\" config receive.denyCurrentBranch ignore", " 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\"", " install -m 755 /etc/git-gate/pre-receive \"$repo/hooks/pre-receive\"",
"}", "}",
"", "",
+149
View File
@@ -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())
+2 -1
View File
@@ -20,7 +20,7 @@ sick daemon."
Daemon subset is env-driven. The compose renderer narrows it via Daemon subset is env-driven. The compose renderer narrows it via
`BOT_BOTTLE_SIDECAR_DAEMONS=egress,pipelock` for bottles that `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 Stdlib-only by design adding supervisord/s6/runit for four
daemons is heavier than this script. daemons is heavier than this script.
@@ -98,6 +98,7 @@ _DAEMONS: tuple[_DaemonSpec, ...] = (
"--listen", "0.0.0.0:8888"), "--listen", "0.0.0.0:8888"),
), ),
_DaemonSpec("git-gate", ("/bin/sh", "/git-gate-entrypoint.sh")), _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")), _DaemonSpec("supervise", ("python3", "/app/supervise_server.py")),
) )
+3
View File
@@ -173,6 +173,9 @@ class TestEntrypointRender(unittest.TestCase):
self.assertIn("--timeout=15", script) self.assertIn("--timeout=15", script)
self.assertIn("--init-timeout=15", script) self.assertIn("--init-timeout=15", script)
self.assertIn("--base-path=/git", 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 # The access-hook is what makes fetch a mirror operation
# against the upstream (PRD 0008 v1.1). # against the upstream (PRD 0008 v1.1).
self.assertIn("--access-hook=/etc/git-gate/access-hook", script) self.assertIn("--access-hook=/etc/git-gate/access-hook", script)
+169
View File
@@ -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()
+9
View File
@@ -60,6 +60,15 @@ class TestGitGateGitconfigRender(unittest.TestCase):
'[url "git://192.168.20.2:9418/bot-bottle.git"]', out, '[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): def test_ip_upstream_also_rewrites_logical_remote_key(self):
m = Manifest.from_json_obj({ m = Manifest.from_json_obj({
"bottles": {"dev": {"git": {"remotes": { "bottles": {"dev": {"git": {"remotes": {
+3 -3
View File
@@ -50,15 +50,15 @@ class TestEnvForDaemon(unittest.TestCase):
env = _env_for_daemon("pipelock", self._BASE) env = _env_for_daemon("pipelock", self._BASE)
self.assertNotIn("EGRESS_TOKEN_0", env) self.assertNotIn("EGRESS_TOKEN_0", env)
self.assertNotIn("EGRESS_TOKEN_1", 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 # upstream proxy URL are all load-bearing for other
# daemons. # daemons.
self.assertEqual("/usr/bin", env["PATH"]) self.assertEqual("/usr/bin", env["PATH"])
self.assertEqual("http://127.0.0.1:8888", env["EGRESS_UPSTREAM_PROXY"]) self.assertEqual("http://127.0.0.1:8888", env["EGRESS_UPSTREAM_PROXY"])
self.assertEqual("9100", env["SUPERVISE_PORT"]) self.assertEqual("9100", env["SUPERVISE_PORT"])
def test_git_gate_and_supervise_also_lose_egress_tokens(self): def test_git_daemons_and_supervise_also_lose_egress_tokens(self):
for name in ("git-gate", "supervise"): for name in ("git-gate", "git-http", "supervise"):
env = _env_for_daemon(name, self._BASE) env = _env_for_daemon(name, self._BASE)
self.assertNotIn("EGRESS_TOKEN_0", env) self.assertNotIn("EGRESS_TOKEN_0", env)
self.assertNotIn("EGRESS_TOKEN_1", env) self.assertNotIn("EGRESS_TOKEN_1", env)
+35 -5
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import subprocess import subprocess
import tempfile import tempfile
import unittest import unittest
from dataclasses import replace
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@@ -23,9 +24,10 @@ from bot_bottle.backend.smolmachines.provision import (
skills as _skills, skills as _skills,
supervise as _supervise, supervise as _supervise,
) )
from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
from bot_bottle.backend.smolmachines.smolvm import SmolvmRunResult from bot_bottle.backend.smolmachines.smolvm import SmolvmRunResult
from bot_bottle.egress import EgressPlan, EgressRoute 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.manifest import GitEntry, Manifest
from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.supervise import SupervisePlan 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): def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self):
# Smolmachines's TSI-allowlisted guest dials git-gate via # Smolmachines's TSI-allowlisted guest dials git-gate via
# `127.0.0.1:<host port>` — the bundle's git-gate port is # smart HTTP at `127.0.0.1:<host port>` — the bundle's
# published on host loopback at launch time, and the plan # git HTTP port is published on host loopback at launch
# carries the discovered host port (here mocked to 9418). # time, and the plan carries the discovered host port.
plan = _plan( plan = _plan(
git=[GitEntry( git=[GitEntry(
Name="bot-bottle", Name="bot-bottle",
@@ -472,13 +474,41 @@ class TestProvisionGit(unittest.TestCase):
self.assertEqual(self.stage, staged_path.parent) self.assertEqual(self.stage, staged_path.parent)
content = staged_path.read_text() content = staged_path.read_text()
self.assertIn( 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( self.assertIn(
"\tinsteadOf = ssh://git@host/repo.git", content, "\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): class TestProvisionGitUser(unittest.TestCase):
"""`_provision_git_user` runs `git config --global` inside the """`_provision_git_user` runs `git config --global` inside the
guest as the node user with HOME forced via `smolvm -e` guest as the node user with HOME forced via `smolvm -e`