refactor(gateway): Firecracker gateway under the Gateway service ABC
Add `FirecrackerGateway`, the Firecracker implementation of the shared `Gateway` service — completing the trio (docker, macOS, firecracker) behind one uniform contract. The gateway here is a whole microVM, so the adapter composes `infra_vm`'s VM primitives (boot, SSH secret push, netpool links) into the ABC surface: `connect_to_orchestrator(url, token)` parses the orchestrator guest IP off the URL and boots the gateway VM seeding the pre-minted token; `address()` is the gateway link's guest IP; `ca_cert_pem()` fetches over SSH (adapting the running VM when this process didn't boot it); `provisioning_transport()` is the SSH exec/cp transport. Trust-model alignment with the other backends: minting moves out of `_push_gateway_jwt` to the host composition point — `ensure_running` mints the role-scoped `gateway` JWT and threads it through `boot_gateway(orch_ip, token)`, so the push step only writes the token the gateway presents (#469). `infra_vm` keeps driving the orchestrator+gateway pair as a singleton and gains gateway-only helpers (`gateway_running`/`stop_gateway`/`gateway_guest_ip`/ `adopted_gateway_vm`); the consolidated launch reads the gateway's transport + CA off the service. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"""Unit: the Firecracker gateway data plane as a microVM (PRD 0070)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bot_bottle.backend.firecracker import infra_vm
|
||||
from bot_bottle.backend.firecracker.gateway import (
|
||||
GATEWAY_NAME,
|
||||
FirecrackerGateway,
|
||||
)
|
||||
from bot_bottle.gateway import GatewayError
|
||||
|
||||
_GW = "bot_bottle.backend.firecracker.gateway"
|
||||
|
||||
_ORCH_URL = "http://10.243.255.1:8099"
|
||||
_TOKEN = "gw-jwt-token"
|
||||
|
||||
|
||||
class TestFirecrackerGatewayConnect(unittest.TestCase):
|
||||
def test_default_name(self) -> None:
|
||||
self.assertEqual(GATEWAY_NAME, FirecrackerGateway().name)
|
||||
|
||||
def test_refuses_without_orchestrator_url(self) -> None:
|
||||
gw = FirecrackerGateway()
|
||||
with patch.object(infra_vm, "boot_gateway") 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 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 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:
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
self.assertTrue(gw.is_running())
|
||||
with patch.object(infra_vm, "gateway_running", return_value=False):
|
||||
self.assertFalse(gw.is_running())
|
||||
|
||||
def test_stop_stops_only_the_gateway_vm(self) -> None:
|
||||
gw = FirecrackerGateway()
|
||||
with patch.object(infra_vm, "stop_gateway") as stop:
|
||||
gw.stop()
|
||||
stop.assert_called_once_with()
|
||||
|
||||
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())
|
||||
|
||||
def test_ca_cert_pem_uses_the_live_handle_when_present(self) -> None:
|
||||
vm = MagicMock()
|
||||
vm.gateway_ca_pem.return_value = "PEM"
|
||||
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)
|
||||
|
||||
def test_ca_cert_pem_adopts_the_running_gateway_when_no_handle(self) -> None:
|
||||
vm = MagicMock()
|
||||
vm.gateway_ca_pem.return_value = "PEM"
|
||||
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()
|
||||
|
||||
def test_provisioning_transport_targets_the_gateway_vm(self) -> None:
|
||||
gw = FirecrackerGateway()
|
||||
sentinel = object()
|
||||
with patch.object(infra_vm, "gateway_transport", return_value=sentinel):
|
||||
self.assertIs(sentinel, gw.provisioning_transport())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -36,15 +36,13 @@ class TestPushSecrets(unittest.TestCase):
|
||||
self.assertIn(f"mv {infra_vm._GUEST_SIGNING_KEY_PATH}.tmp "
|
||||
f"{infra_vm._GUEST_SIGNING_KEY_PATH}", remote_cmd)
|
||||
|
||||
def test_gateway_jwt_is_minted_gateway_role_not_the_key(self):
|
||||
def test_gateway_jwt_is_the_pre_minted_token_not_the_key(self):
|
||||
# The host mints the `gateway` JWT and hands it in; `_push_gateway_jwt`
|
||||
# only writes it. It is the JWT (never the key itself) that lands in the
|
||||
# gateway VM.
|
||||
proc = MagicMock(returncode=0, stderr="")
|
||||
with patch.object(infra_vm, "host_orchestrator_token", return_value="host-key"), \
|
||||
patch.object(infra_vm, "mint", return_value="gw.jwt") as mint, \
|
||||
patch.object(infra_vm.subprocess, "run", return_value=proc) as run:
|
||||
infra_vm._push_gateway_jwt(self._infra())
|
||||
# Minted on the HOST from the signing key with the gateway role, and it
|
||||
# is the JWT (never the key itself) that lands in the gateway VM.
|
||||
mint.assert_called_once_with(infra_vm.ROLE_GATEWAY, "host-key")
|
||||
with patch.object(infra_vm.subprocess, "run", return_value=proc) as run:
|
||||
infra_vm._push_gateway_jwt(self._infra(), "gw.jwt")
|
||||
self.assertEqual("gw.jwt", run.call_args.kwargs["input"])
|
||||
remote_cmd = run.call_args.args[0][-1]
|
||||
self.assertIn(f"cat > {infra_vm._GUEST_GATEWAY_JWT_PATH}.tmp", remote_cmd)
|
||||
@@ -175,13 +173,14 @@ class TestBootRole(unittest.TestCase):
|
||||
patch.object(infra_vm, "_push_gateway_jwt") as push, \
|
||||
patch.object(infra_vm.firecracker_vm, "boot", return_value=vm) as boot, \
|
||||
patch.object(infra_vm, "_gw_dir", return_value=Path(td)):
|
||||
infra_vm.boot_gateway("10.243.255.1")
|
||||
infra_vm.boot_gateway("10.243.255.1", "gw.jwt")
|
||||
kw = boot.call_args.kwargs
|
||||
self.assertIn("bb_role=gateway", kw["extra_boot_args"])
|
||||
self.assertIn("bb_orch=10.243.255.1", kw["extra_boot_args"]) # reaches the CP
|
||||
self.assertEqual(infra_vm._GW_MEM_MIB, kw["mem_mib"])
|
||||
self.assertIsNone(kw["data_drive"]) # data plane never opens the DB
|
||||
push.assert_called_once() # gateway JWT seeded
|
||||
# The host-minted token is threaded through to the JWT push.
|
||||
self.assertEqual("gw.jwt", push.call_args.args[1])
|
||||
|
||||
|
||||
class TestSshGatewayTransport(unittest.TestCase):
|
||||
@@ -335,6 +334,8 @@ class TestEnsureRunningSingleton(unittest.TestCase):
|
||||
patch.object(infra_vm, "stop") as stop, \
|
||||
patch.object(infra_vm, "ensure_built"), \
|
||||
patch.object(infra_vm, "wait_for_health"), \
|
||||
patch.object(infra_vm, "host_orchestrator_token", return_value="k"), \
|
||||
patch.object(infra_vm, "mint", return_value="gw.jwt"), \
|
||||
patch.object(infra_vm, "boot_orchestrator") as boot_o, \
|
||||
patch.object(infra_vm, "boot_gateway") as boot_g:
|
||||
boot_o.return_value = infra_vm.InfraVm(
|
||||
@@ -345,8 +346,10 @@ class TestEnsureRunningSingleton(unittest.TestCase):
|
||||
stop.assert_called_once() # dislodge the outdated pair
|
||||
boot_o.assert_called_once()
|
||||
boot_g.assert_called_once()
|
||||
# The gateway is booted pointing at the orchestrator's guest IP.
|
||||
# The gateway is booted pointing at the orchestrator's guest IP,
|
||||
# carrying the host-minted `gateway` token.
|
||||
self.assertEqual("10.243.255.1", boot_g.call_args.args[0])
|
||||
self.assertEqual("gw.jwt", boot_g.call_args.args[1])
|
||||
# The fresh boot records the current version for the next launcher.
|
||||
self.assertEqual("v-current\n", (d / "booted-version").read_text())
|
||||
|
||||
@@ -365,6 +368,8 @@ class TestEnsureRunningSingleton(unittest.TestCase):
|
||||
patch.object(infra_vm, "stop") as stop, \
|
||||
patch.object(infra_vm, "ensure_built"), \
|
||||
patch.object(infra_vm, "wait_for_health"), \
|
||||
patch.object(infra_vm, "host_orchestrator_token", return_value="k"), \
|
||||
patch.object(infra_vm, "mint", return_value="gw.jwt"), \
|
||||
patch.object(infra_vm, "boot_orchestrator") as boot_o, \
|
||||
patch.object(infra_vm, "boot_gateway") as boot_g:
|
||||
boot_o.return_value = infra_vm.InfraVm(
|
||||
@@ -384,6 +389,8 @@ class TestEnsureRunningSingleton(unittest.TestCase):
|
||||
patch.object(infra_vm, "_health_ok", return_value=False), \
|
||||
patch.object(infra_vm, "stop") as stop, \
|
||||
patch.object(infra_vm, "ensure_built") as built, \
|
||||
patch.object(infra_vm, "host_orchestrator_token", return_value="k"), \
|
||||
patch.object(infra_vm, "mint", return_value="gw.jwt"), \
|
||||
patch.object(infra_vm, "boot_orchestrator") as boot_o, \
|
||||
patch.object(infra_vm, "boot_gateway") as boot_g, \
|
||||
patch.object(infra_vm, "wait_for_health") as wait:
|
||||
|
||||
Reference in New Issue
Block a user