refactor(firecracker): move gateway logic into the gateway service
Pull the gateway's host-side logic out of `infra_vm` and into `FirecrackerGateway`'s own methods, so the gateway is self-contained and `infra_vm` shrinks toward being just the pair coordinator (a step toward removing it). Now living in the gateway service: the gateway VM boot + `bb_orch` cmdline, the pre-minted JWT push, the mitmproxy CA fetch over SSH, the `SshGatewayTransport` exec/cp, and the lifecycle predicates (is_running/stop/address). `ensure_running` / `_adopt` construct the gateway and call `connect_to_orchestrator` (lazy import to break the cycle); the InfraEndpoint's gateway is now the `FirecrackerGateway` service. What deliberately stays shared in `infra_vm` (documented at the top of gateway.py): the plane-agnostic VM substrate the orchestrator VM also needs (`_boot_vm`, the stable SSH keypair, the secret-push retry, PID lifecycle), the pair coordinator (`ensure_running`: orchestrator-first health gate + singleton lock + adoption/version marker), and the single shared `bb_role`-branched init baked into the one published rootfs both VMs boot — the guest-side gateway startup can't live in a host method. These are the seam that moves to a neutral module when the Orchestrator service lands and `infra_vm` dissolves. CA fetch now raises GatewayError (was die/SystemExit) to match the ABC. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,26 @@
|
||||
"""Unit: the Firecracker gateway data plane as a microVM (PRD 0070)."""
|
||||
"""Unit: the Firecracker gateway data plane as a microVM (PRD 0070).
|
||||
|
||||
The gateway service owns its host-side logic directly: booting the gateway VM
|
||||
(via the shared `infra_vm._boot_vm` substrate), seeding the pre-minted token,
|
||||
fetching the CA over SSH, and the SSH exec/cp provisioning transport.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from subprocess import CompletedProcess
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend.firecracker import infra_vm
|
||||
from bot_bottle.backend.firecracker.gateway import (
|
||||
GATEWAY_NAME,
|
||||
FirecrackerGateway,
|
||||
SshGatewayTransport,
|
||||
)
|
||||
from bot_bottle.gateway import GatewayError
|
||||
from bot_bottle.gateway import GatewayError, GatewayProvisionError
|
||||
|
||||
_GW = "bot_bottle.backend.firecracker.gateway"
|
||||
|
||||
@@ -25,76 +34,108 @@ class TestFirecrackerGatewayConnect(unittest.TestCase):
|
||||
|
||||
def test_refuses_without_orchestrator_url(self) -> None:
|
||||
gw = FirecrackerGateway()
|
||||
with patch.object(infra_vm, "boot_gateway") as boot:
|
||||
with patch.object(infra_vm, "_boot_vm") as boot:
|
||||
with self.assertRaises(GatewayError):
|
||||
gw.connect_to_orchestrator("", _TOKEN)
|
||||
boot.assert_not_called()
|
||||
|
||||
def test_refuses_without_gateway_token(self) -> None:
|
||||
gw = FirecrackerGateway()
|
||||
with patch.object(infra_vm, "boot_gateway") as boot:
|
||||
with patch.object(infra_vm, "_boot_vm") as boot:
|
||||
with self.assertRaises(GatewayError):
|
||||
gw.connect_to_orchestrator(_ORCH_URL, "")
|
||||
boot.assert_not_called()
|
||||
|
||||
def test_refuses_a_url_without_a_host(self) -> None:
|
||||
gw = FirecrackerGateway()
|
||||
with patch.object(infra_vm, "boot_gateway") as boot:
|
||||
with patch.object(infra_vm, "_boot_vm") as boot:
|
||||
with self.assertRaises(GatewayError):
|
||||
gw.connect_to_orchestrator("http://:8099", _TOKEN)
|
||||
boot.assert_not_called()
|
||||
|
||||
def test_boots_the_gateway_vm_against_the_orchestrator_guest_ip(self) -> None:
|
||||
def test_boots_the_gateway_vm_and_seeds_the_token(self) -> None:
|
||||
gw = FirecrackerGateway()
|
||||
booted = infra_vm.InfraVm(guest_ip="10.243.255.3", private_key=Path("/k"))
|
||||
with patch.object(infra_vm, "boot_gateway", return_value=booted) as boot:
|
||||
with patch.object(infra_vm, "_boot_vm", return_value=booted) as boot, \
|
||||
patch.object(infra_vm, "_push_secret") as push:
|
||||
gw.connect_to_orchestrator(_ORCH_URL, _TOKEN)
|
||||
# The orchestrator guest IP is parsed off the URL; the token is threaded
|
||||
# through unchanged (the gateway never mints — #469).
|
||||
boot.assert_called_once_with("10.243.255.1", _TOKEN)
|
||||
# Booted on the gateway link with the gateway role; the orchestrator's
|
||||
# guest IP (parsed off the URL) rides the cmdline as bb_orch.
|
||||
kw = boot.call_args.kwargs
|
||||
self.assertEqual("gateway", kw["role"])
|
||||
self.assertIn("bb_orch=10.243.255.1", kw["extra_boot_args"])
|
||||
self.assertIsNone(kw.get("data_drive")) # data plane never opens the DB
|
||||
# The host-minted token (never the key — #469) is pushed to the guest.
|
||||
push.assert_called_once()
|
||||
self.assertEqual(_TOKEN, push.call_args.args[1])
|
||||
|
||||
|
||||
class TestFirecrackerGatewaySurface(unittest.TestCase):
|
||||
def test_is_running_reads_the_gateway_pidfile(self) -> None:
|
||||
gw = FirecrackerGateway()
|
||||
with patch.object(infra_vm, "gateway_running", return_value=True):
|
||||
with patch.object(infra_vm, "_pidfile_alive", return_value=True) as alive, \
|
||||
patch.object(infra_vm, "_gw_dir", return_value=Path("/gw")):
|
||||
self.assertTrue(gw.is_running())
|
||||
with patch.object(infra_vm, "gateway_running", return_value=False):
|
||||
self.assertFalse(gw.is_running())
|
||||
alive.assert_called_once_with(Path("/gw"))
|
||||
|
||||
def test_stop_stops_only_the_gateway_vm(self) -> None:
|
||||
def test_stop_kills_only_the_gateway_pidfile(self) -> None:
|
||||
gw = FirecrackerGateway()
|
||||
with patch.object(infra_vm, "stop_gateway") as stop:
|
||||
gw.stop()
|
||||
stop.assert_called_once_with()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
d = Path(td)
|
||||
(d / "vm.pid").write_text("123")
|
||||
with patch.object(infra_vm, "_gw_dir", return_value=d), \
|
||||
patch.object(infra_vm, "_kill_pidfile") as kill:
|
||||
gw.stop()
|
||||
kill.assert_called_once_with(d)
|
||||
self.assertFalse((d / "vm.pid").exists()) # pidfile cleared
|
||||
|
||||
def test_address_is_the_gateway_link_guest_ip(self) -> None:
|
||||
gw = FirecrackerGateway()
|
||||
with patch.object(infra_vm, "gateway_guest_ip", return_value="10.243.255.3"):
|
||||
self.assertEqual("10.243.255.3", gw.address())
|
||||
self.assertEqual(infra_vm.netpool.gw_slot().guest_ip, gw.address())
|
||||
|
||||
def test_ca_cert_pem_uses_the_live_handle_when_present(self) -> None:
|
||||
vm = MagicMock()
|
||||
vm.gateway_ca_pem.return_value = "PEM"
|
||||
def test_ca_cert_pem_reads_over_ssh_from_the_handle(self) -> None:
|
||||
vm = infra_vm.InfraVm(guest_ip="10.243.255.3", private_key=Path("/k"))
|
||||
gw = FirecrackerGateway(vm)
|
||||
with patch.object(infra_vm, "adopted_gateway_vm") as adopt:
|
||||
self.assertEqual("PEM", gw.ca_cert_pem(timeout=1))
|
||||
adopt.assert_not_called()
|
||||
vm.gateway_ca_pem.assert_called_once_with(timeout=1)
|
||||
pem = "-----BEGIN CERTIFICATE-----\nx\n-----END CERTIFICATE-----\n"
|
||||
with patch.object(infra_vm.subprocess, "run",
|
||||
return_value=CompletedProcess([], 0, stdout=pem)) as run:
|
||||
self.assertEqual(pem, gw.ca_cert_pem(timeout=5))
|
||||
# cat the CA path over SSH to the gateway VM's guest IP.
|
||||
argv = run.call_args.args[0]
|
||||
self.assertTrue(any("10.243.255.3" in a for a in argv))
|
||||
self.assertIn("cat /home/mitmproxy/.mitmproxy/mitmproxy-ca-cert.pem", argv)
|
||||
|
||||
def test_ca_cert_pem_adopts_the_running_gateway_when_no_handle(self) -> None:
|
||||
vm = MagicMock()
|
||||
vm.gateway_ca_pem.return_value = "PEM"
|
||||
def test_ca_cert_pem_raises_gateway_error_when_absent(self) -> None:
|
||||
gw = FirecrackerGateway()
|
||||
with patch.object(infra_vm, "adopted_gateway_vm", return_value=vm) as adopt:
|
||||
self.assertEqual("PEM", gw.ca_cert_pem())
|
||||
adopt.assert_called_once_with()
|
||||
with patch.object(infra_vm.subprocess, "run",
|
||||
return_value=CompletedProcess([], 1, stdout="", stderr="")):
|
||||
with self.assertRaises(GatewayError):
|
||||
gw.ca_cert_pem(timeout=0)
|
||||
|
||||
def test_provisioning_transport_targets_the_gateway_vm(self) -> None:
|
||||
def test_provisioning_transport_targets_the_gateway_link(self) -> None:
|
||||
gw = FirecrackerGateway()
|
||||
sentinel = object()
|
||||
with patch.object(infra_vm, "gateway_transport", return_value=sentinel):
|
||||
self.assertIs(sentinel, gw.provisioning_transport())
|
||||
transport = gw.provisioning_transport()
|
||||
assert isinstance(transport, SshGatewayTransport)
|
||||
self.assertEqual(infra_vm.netpool.gw_slot().guest_ip, transport._ip)
|
||||
|
||||
|
||||
class TestSshGatewayTransport(unittest.TestCase):
|
||||
def test_cp_into_preserves_source_mode(self) -> None:
|
||||
with tempfile.NamedTemporaryFile() as f:
|
||||
os.chmod(f.name, 0o700) # like the staged access-hook
|
||||
t = SshGatewayTransport(Path("/k"), "10.0.0.1")
|
||||
with patch(f"{_GW}.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) -> None:
|
||||
t = SshGatewayTransport(Path("/k"), "10.0.0.1")
|
||||
with patch(f"{_GW}.subprocess.run",
|
||||
return_value=CompletedProcess([], 1, stderr="nope")), \
|
||||
self.assertRaises(GatewayProvisionError):
|
||||
t.exec(["mkdir", "-p", "/git-gate"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user