fix(git-gate): use smart http for smolmachines pushes
This commit was merged in pull request #114.
This commit is contained in:
+3
-1
@@ -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,
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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\"",
|
||||||
"}",
|
"}",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
Reference in New Issue
Block a user