From 48730305509a2602b7e178d2d05c59d1d4746b67 Mon Sep 17 00:00:00 2001 From: didericis Date: Thu, 16 Jul 2026 13:35:19 -0400 Subject: [PATCH] feat(firecracker): git-gate over SSH into the gateway VM (Stage B, 7/n) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- bot_bottle/backend/firecracker/infra_vm.py | 25 +++++++++++++++---- tests/unit/test_firecracker_infra_vm.py | 28 +++++++++++++++++++++- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/bot_bottle/backend/firecracker/infra_vm.py b/bot_bottle/backend/firecracker/infra_vm.py index 4cb5630..d7416e4 100644 --- a/bot_bottle/backend/firecracker/infra_vm.py +++ b/bot_bottle/backend/firecracker/infra_vm.py @@ -17,9 +17,11 @@ surface. from __future__ import annotations +import hashlib import os import shlex import signal +import stat import subprocess import time import urllib.error @@ -115,9 +117,13 @@ def ensure_built() -> None: def build_infra_rootfs_dir() -> Path: """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( - _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()}") 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( - 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, ) if proc.returncode != 0: @@ -363,8 +375,11 @@ cd /app BOT_BOTTLE_ROOT=/var/lib/bot-bottle python3 -m bot_bottle.orchestrator \\ --host 0.0.0.0 --port {CONTROL_PLANE_PORT} --broker stub & -# Gateway data plane (egress / git-gate / supervise), multi-tenant: each -# request resolves source-IP -> policy against the local control plane. +# Gateway data plane, multi-tenant: each request resolves source-IP -> +# 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} \\ python3 /app/gateway_init.py & diff --git a/tests/unit/test_firecracker_infra_vm.py b/tests/unit/test_firecracker_infra_vm.py index e080057..e858654 100644 --- a/tests/unit/test_firecracker_infra_vm.py +++ b/tests/unit/test_firecracker_infra_vm.py @@ -31,7 +31,8 @@ class TestBuildInfraRootfs(unittest.TestCase): infra_vm.build_infra_rootfs_dir() build.assert_called_once() self.assertEqual(infra_vm._INFRA_IMAGE, build.call_args.args[0]) - self.assertEqual("-infra", build.call_args.kwargs["variant"]) + # variant is "-infra-" 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, # and exports PATH so gateway_init's subprocess daemons find python3. init = build.call_args.kwargs["init_script"] @@ -40,6 +41,31 @@ class TestBuildInfraRootfs(unittest.TestCase): self.assertIn("export PATH=", init) # Persistent registry volume mounted at the DB dir before the CP starts. 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):