feat(firecracker): git-gate over SSH into the gateway VM (Stage B, 7/n)

git-gate now works end-to-end for git-upstream bottles on the infra VM.
Three fixes surfaced by driving a real clone through git-http:

- `SshGatewayTransport.cp_into` preserves the source file mode (docker cp
  does). The access-hook is staged 0700 and git-http execs it directly; a
  plain `cat >` landed it 0644 -> EACCES. Keys stay 0600.
- the infra init runs `BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise`:
  the VM backend reaches git over git-http (9420), so the git:// daemon
  (git-gate, whose /git-gate-entrypoint.sh the consolidated model doesn't
  stage) is left out instead of crash-looping.
- `build_infra_rootfs_dir` folds the init's content-hash into the rootfs
  cache key, so an init change actually rebuilds the rootfs (the base image
  digest alone wouldn't catch it).

Verified on a KVM host: launch_consolidated provisions a git-upstream
bottle's repo + creds into the gateway VM over SSH; an agent VM clones via
git-http (source-IP attributed) and the access-hook resolves the
provisioned key + known_hosts and attempts the upstream SSH fetch —
failing closed only because the test used a dummy key + fake upstream
("refusing to serve stale data"). With real creds the fetch serves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
2026-07-16 13:35:19 -04:00
parent b93b14f5c2
commit 4873030550
2 changed files with 47 additions and 6 deletions
+20 -5
View File
@@ -17,9 +17,11 @@ surface.
from __future__ import annotations from __future__ import annotations
import hashlib
import os import os
import shlex import shlex
import signal import signal
import stat
import subprocess import subprocess
import time import time
import urllib.error import urllib.error
@@ -115,9 +117,13 @@ def ensure_built() -> None:
def build_infra_rootfs_dir() -> Path: def build_infra_rootfs_dir() -> Path:
"""The infra VM's base rootfs: the infra image prepared with the """The infra VM's base rootfs: the infra image prepared with the
control-plane + gateway init as PID 1.""" control-plane + gateway init as PID 1. The init's content is folded into
the cache key so an init change rebuilds the rootfs (the base image digest
alone wouldn't catch it)."""
init = _infra_init()
tag = hashlib.sha256(init.encode()).hexdigest()[:8]
return util.build_base_rootfs_dir( return util.build_base_rootfs_dir(
_INFRA_IMAGE, variant="-infra", init_script=_infra_init(), _INFRA_IMAGE, variant=f"-infra-{tag}", init_script=init,
) )
@@ -278,8 +284,14 @@ class SshGatewayTransport:
f"infra gateway exec {argv!r} failed: {proc.stderr.strip()}") f"infra gateway exec {argv!r} failed: {proc.stderr.strip()}")
def cp_into(self, src: str, dest: str) -> None: def cp_into(self, src: str, dest: str) -> None:
# Preserve the source mode (docker cp does): the access-hook is staged
# 0700 and git-http execs it directly — a plain `cat >` would land it
# 0644 and the exec fails with EACCES; keys stay 0600.
mode = stat.S_IMODE(os.stat(src).st_mode)
q = shlex.quote(dest)
proc = subprocess.run( proc = subprocess.run(
util.ssh_base_argv(self._key, self._ip) + [f"cat > {shlex.quote(dest)}"], util.ssh_base_argv(self._key, self._ip)
+ [f"cat > {q} && chmod {mode:o} {q}"],
input=Path(src).read_bytes(), capture_output=True, timeout=30, check=False, input=Path(src).read_bytes(), capture_output=True, timeout=30, check=False,
) )
if proc.returncode != 0: if proc.returncode != 0:
@@ -363,8 +375,11 @@ cd /app
BOT_BOTTLE_ROOT=/var/lib/bot-bottle python3 -m bot_bottle.orchestrator \\ BOT_BOTTLE_ROOT=/var/lib/bot-bottle python3 -m bot_bottle.orchestrator \\
--host 0.0.0.0 --port {CONTROL_PLANE_PORT} --broker stub & --host 0.0.0.0 --port {CONTROL_PLANE_PORT} --broker stub &
# Gateway data plane (egress / git-gate / supervise), multi-tenant: each # Gateway data plane, multi-tenant: each request resolves source-IP ->
# request resolves source-IP -> policy against the local control plane. # policy against the local control plane. The VM backend reaches git over
# git-http (9420), so the git:// daemon (git-gate, needs a per-bottle
# entrypoint the consolidated model doesn't use) is left out.
BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise \\
BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{CONTROL_PLANE_PORT} \\ BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{CONTROL_PLANE_PORT} \\
python3 /app/gateway_init.py & python3 /app/gateway_init.py &
+27 -1
View File
@@ -31,7 +31,8 @@ class TestBuildInfraRootfs(unittest.TestCase):
infra_vm.build_infra_rootfs_dir() infra_vm.build_infra_rootfs_dir()
build.assert_called_once() build.assert_called_once()
self.assertEqual(infra_vm._INFRA_IMAGE, build.call_args.args[0]) self.assertEqual(infra_vm._INFRA_IMAGE, build.call_args.args[0])
self.assertEqual("-infra", build.call_args.kwargs["variant"]) # variant is "-infra-<init-hash>" so an init change rebuilds the rootfs.
self.assertTrue(build.call_args.kwargs["variant"].startswith("-infra-"))
# The init runs BOTH the control plane and the gateway data plane, # The init runs BOTH the control plane and the gateway data plane,
# and exports PATH so gateway_init's subprocess daemons find python3. # and exports PATH so gateway_init's subprocess daemons find python3.
init = build.call_args.kwargs["init_script"] init = build.call_args.kwargs["init_script"]
@@ -40,6 +41,31 @@ class TestBuildInfraRootfs(unittest.TestCase):
self.assertIn("export PATH=", init) self.assertIn("export PATH=", init)
# Persistent registry volume mounted at the DB dir before the CP starts. # Persistent registry volume mounted at the DB dir before the CP starts.
self.assertIn("/dev/vdb", init) self.assertIn("/dev/vdb", init)
# VM backend uses git-http (9420); the git:// daemon is left out.
self.assertIn("BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise", init)
class TestSshGatewayTransport(unittest.TestCase):
def test_cp_into_preserves_source_mode(self):
import os
import tempfile
from subprocess import CompletedProcess
with tempfile.NamedTemporaryFile() as f:
os.chmod(f.name, 0o700) # like the staged access-hook
t = infra_vm.SshGatewayTransport(Path("/k"), "10.0.0.1")
with patch.object(infra_vm.subprocess, "run",
return_value=CompletedProcess([], 0)) as run:
t.cp_into(f.name, "/etc/git-gate/access-hook")
remote_cmd = run.call_args.args[0][-1]
self.assertIn("chmod 700", remote_cmd) # exec bit preserved over SSH
def test_exec_raises_on_failure(self):
from subprocess import CompletedProcess
t = infra_vm.SshGatewayTransport(Path("/k"), "10.0.0.1")
with patch.object(infra_vm.subprocess, "run",
return_value=CompletedProcess([], 1, stderr="nope")), \
self.assertRaises(infra_vm.GatewayProvisionError):
t.exec(["mkdir", "-p", "/git-gate"])
class TestRegistryVolume(unittest.TestCase): class TestRegistryVolume(unittest.TestCase):