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:
@@ -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 &
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user