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
# 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.
@@ -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,
+12 -11
View File
@@ -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://<alias>:<port>/`) 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)
@@ -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://<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
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:<host port>` 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
# `<loopback alias>:<host port>` 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`
+4 -3
View File
@@ -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 `<scheme>://` and the
repo path — backends differ here:
- docker: `git-gate` (the short network alias)
- 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",
]
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\"",
"}",
"",
+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
`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")),
)
+3
View File
@@ -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)
+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,
)
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": {
+3 -3
View File
@@ -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)
+35 -5
View File
@@ -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:<host port>` — 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:<host port>` — 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`