refactor(backend): InfraService ABC + FirecrackerInfraService

Add a backend-neutral `InfraService` ABC (backend/infra_service.py) so all three
backends present the same composer contract: `orchestrator()` / `gateway()`
accessors, `ensure_running(*, startup_timeout) -> str` (the host control-plane
URL), `stop()`, plus concrete `url()` / `is_healthy()` that delegate to the
orchestrator. `DockerInfraService` and `MacosInfraService` now subclass it.

Give firecracker the missing class: `FirecrackerInfraService`
(backend/firecracker/infra.py) wraps the `infra_vm` substrate as the pair
coordinator (adopt-or-boot-both under the singleton flock). `infra_vm` keeps only
the plane-agnostic substrate; its `ensure_running` / `_adopt` / `InfraEndpoint`
move to the class, and the coordinator helpers it now calls cross-module go
public (`singleton_lock` / `expected_version` / `adoptable` /
`record_booted_version`).

Unify the return type on the way: `ensure_running` returns the URL string
everywhere (was `str` for docker, two different `InfraEndpoint`s for
macOS/firecracker), and those `InfraEndpoint`s are deleted — the services are the
source of truth (callers read `gateway().address()` / `orchestrator().url()`).
docker's `url` property becomes the inherited `url()`. backend.py /
consolidated_launch / image_builder + tests updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 16:10:21 -04:00
parent cb2d778a8f
commit afb92ca155
19 changed files with 337 additions and 309 deletions
@@ -130,7 +130,7 @@ class TestDockerOrchestratorAuthIntegration(unittest.TestCase):
# than hand-rolling urllib here; _request (not one of the named
# wrapper methods) is what exposes raw status codes for arbitrary
# paths/tokens, which is exactly what these auth-boundary tests need.
client = OrchestratorClient(self.svc.url, auth_token=token)
client = OrchestratorClient(self.svc.url(), auth_token=token)
return client._request(method, path) # pylint: disable=protected-access
def test_health_is_open_without_a_token(self) -> None:
+7 -8
View File
@@ -442,8 +442,7 @@ class TestIsBackendReady(unittest.TestCase):
class TestEnsureOrchestrator(unittest.TestCase):
"""The backend-agnostic orchestrator bring-up entry point. Docker starts
the orchestrator + gateway containers; firecracker boots the infra VM;
macos-container starts the infra container."""
each backend's InfraService brings up its orchestrator + gateway pair."""
def test_docker_delegates_to_infra_service(self):
b = get_bottle_backend("docker")
@@ -457,23 +456,23 @@ class TestEnsureOrchestrator(unittest.TestCase):
self.assertEqual(url, "http://127.0.0.1:8099")
service_cls.return_value.ensure_running.assert_called_once_with()
def test_firecracker_delegates_to_infra_vm(self):
def test_firecracker_delegates_to_infra_service(self):
b = get_bottle_backend("firecracker")
with patch(
"bot_bottle.backend.firecracker.infra_vm.ensure_running"
) as ensure_running:
ensure_running.return_value.orchestrator_url = (
"bot_bottle.backend.firecracker.infra.FirecrackerInfraService"
) as service_cls:
service_cls.return_value.ensure_running.return_value = (
"http://10.243.255.1:8099"
)
url = b.ensure_orchestrator()
self.assertEqual(url, "http://10.243.255.1:8099")
def test_macos_delegates_to_infra_container(self):
def test_macos_delegates_to_infra_service(self):
b = get_bottle_backend("macos-container")
with patch(
"bot_bottle.backend.macos_container.infra.MacosInfraService"
) as service_cls:
service_cls.return_value.ensure_running.return_value.orchestrator_url = (
service_cls.return_value.ensure_running.return_value = (
"http://192.168.128.2:8099"
)
url = b.ensure_orchestrator()
+1 -1
View File
@@ -37,7 +37,7 @@ class TestDockerInfraService(unittest.TestCase):
self.assertEqual(GATEWAY_NAME, self.svc.gateway_name)
def test_url_delegates_to_the_orchestrator(self) -> None:
self.assertEqual("http://127.0.0.1:8099", self.svc.url)
self.assertEqual("http://127.0.0.1:8099", self.svc.url())
def test_is_healthy_delegates_to_the_orchestrator(self) -> None:
self.orch.is_healthy.return_value = True
+126
View File
@@ -0,0 +1,126 @@
"""Unit: FirecrackerInfraService composes the orchestrator + gateway microVM pair.
The two-VM singleton lifecycle (adopt-or-boot-both under a flock, the version
marker) that must hold without a live VM. The VM substrate + boot primitives are
tested in test_firecracker_infra_vm; the orchestrator/gateway services in their
own modules. These isolate the *composition*."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from bot_bottle.backend.firecracker import infra_vm
from bot_bottle.backend.firecracker.gateway import FirecrackerGateway
from bot_bottle.backend.firecracker.infra import FirecrackerInfraService
from bot_bottle.backend.firecracker.orchestrator import FirecrackerOrchestrator
# The service imports the classes into its own module namespace, so patch them
# there (not at their home modules).
_ORCH_CLS = "bot_bottle.backend.firecracker.infra.FirecrackerOrchestrator"
_GW_CLS = "bot_bottle.backend.firecracker.infra.FirecrackerGateway"
class TestAccessors(unittest.TestCase):
def test_orchestrator_and_gateway_services(self) -> None:
svc = FirecrackerInfraService()
self.assertIsInstance(svc.orchestrator(), FirecrackerOrchestrator)
self.assertIsInstance(svc.gateway(), FirecrackerGateway)
def test_stop_stops_both_vms(self) -> None:
with patch.object(infra_vm, "stop") as stop:
FirecrackerInfraService().stop()
stop.assert_called_once()
def test_url_and_is_healthy_delegate_to_the_orchestrator(self) -> None:
svc = FirecrackerInfraService()
ip = infra_vm.netpool.orch_slot().guest_ip
self.assertEqual(f"http://{ip}:{infra_vm.ORCHESTRATOR_PORT}", svc.url())
class TestEnsureRunning(unittest.TestCase):
def test_adopts_when_healthy_alive_and_version_matches(self):
# Healthy control plane + live gateway + existing key + matching version
# marker -> adopt (no stop/build/boot); returns the orchestrator URL.
with tempfile.TemporaryDirectory() as td:
d = Path(td)
(d / "id_ed25519").write_text("k")
(d / "booted-version").write_text("v-current\n")
with patch.object(infra_vm, "_infra_dir", return_value=d), \
patch.object(infra_vm, "expected_version", return_value="v-current"), \
patch.object(infra_vm, "_health_ok", return_value=True), \
patch.object(infra_vm, "_pidfile_alive", return_value=True), \
patch.object(infra_vm, "stop") as stop, \
patch.object(infra_vm, "ensure_built") as built:
url = FirecrackerInfraService().ensure_running()
stop.assert_not_called()
built.assert_not_called()
ip = infra_vm.netpool.orch_slot().guest_ip
self.assertEqual(f"http://{ip}:{infra_vm.ORCHESTRATOR_PORT}", url)
def test_reboots_both_when_version_stale(self):
# Healthy control plane but the running pair booted an OLDER image
# (marker mismatch) -> reboot rather than adopt stale code.
with tempfile.TemporaryDirectory() as td:
d = Path(td)
(d / "id_ed25519").write_text("k")
(d / "booted-version").write_text("v-old\n")
with patch.object(infra_vm, "_infra_dir", return_value=d), \
patch.object(infra_vm, "expected_version", return_value="v-current"), \
patch.object(infra_vm, "_health_ok", return_value=True), \
patch.object(infra_vm, "_pidfile_alive", return_value=True), \
patch.object(infra_vm, "stop") as stop, \
patch.object(infra_vm, "ensure_built"), \
patch(_ORCH_CLS) as orch_cls, patch(_GW_CLS) as gw_cls:
orch_cls.return_value.gateway_url.return_value = "http://10.243.255.1:8099"
orch_cls.return_value.mint_gateway_token.return_value = "gw.jwt"
FirecrackerInfraService().ensure_running()
stop.assert_called_once() # dislodge the outdated pair
orch_cls.return_value.ensure_running.assert_called_once()
# The gateway service is bound to the orchestrator's gateway URL,
# carrying the orchestrator-minted `gateway` token.
gw_cls.return_value.connect_to_orchestrator.assert_called_once_with(
"http://10.243.255.1:8099", "gw.jwt")
# The fresh boot records the current version for the next launcher.
self.assertEqual("v-current\n", (d / "booted-version").read_text())
def test_reboots_when_gateway_dead(self):
# Orchestrator healthy + marker current, but the gateway VM is gone ->
# the pair is half-down, so reboot both rather than adopt partial state.
with tempfile.TemporaryDirectory() as td:
d = Path(td)
(d / "id_ed25519").write_text("k")
(d / "booted-version").write_text("v-current\n")
with patch.object(infra_vm, "_infra_dir", return_value=d), \
patch.object(infra_vm, "expected_version", return_value="v-current"), \
patch.object(infra_vm, "_health_ok", return_value=True), \
patch.object(infra_vm, "_pidfile_alive", return_value=False), \
patch.object(infra_vm, "stop") as stop, \
patch.object(infra_vm, "ensure_built"), \
patch(_ORCH_CLS) as orch_cls, patch(_GW_CLS) as gw_cls:
FirecrackerInfraService().ensure_running()
stop.assert_called_once()
orch_cls.return_value.ensure_running.assert_called_once()
gw_cls.return_value.connect_to_orchestrator.assert_called_once()
def test_boots_both_when_no_running_pair(self):
with tempfile.TemporaryDirectory() as td:
with patch.object(infra_vm, "_infra_dir", return_value=Path(td)), \
patch.object(infra_vm, "expected_version", return_value="v-current"), \
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(_ORCH_CLS) as orch_cls, patch(_GW_CLS) as gw_cls:
FirecrackerInfraService().ensure_running()
stop.assert_called_once() # clear stale VMs first
built.assert_called_once()
# Orchestrator is brought up BEFORE the gateway is connected (its daemons
# reach the control plane at startup).
orch_cls.return_value.ensure_running.assert_called_once()
gw_cls.return_value.connect_to_orchestrator.assert_called_once()
if __name__ == "__main__":
unittest.main()
+8 -132
View File
@@ -1,8 +1,8 @@
"""Unit tests for the Firecracker infra VMs (orchestrator + gateway boot).
"""Unit tests for the Firecracker infra-VM substrate (boot primitives + inits).
The KVM boot / HTTP reachability is integration-tested on a KVM host; here we
cover the URL shape, the shared-rootfs role wiring, the secret-push decisions,
and the two-VM singleton lifecycle that must hold without a VM.
cover the per-plane role inits, rootfs build, the secret-push retry, and the
pidfile/adoption helpers. The pair coordinator is tested in test_firecracker_infra.
"""
from __future__ import annotations
@@ -43,31 +43,6 @@ class TestPushSecret(unittest.TestCase):
die.assert_called_once()
class TestInfraEndpoint(unittest.TestCase):
"""The endpoint mirrors docker/macOS: it exposes the orchestrator service's
URL and delegates the CA fetch to the gateway service."""
def _endpoint(self) -> infra_vm.InfraEndpoint:
from bot_bottle.backend.firecracker.gateway import FirecrackerGateway
from bot_bottle.backend.firecracker.orchestrator import FirecrackerOrchestrator
return infra_vm.InfraEndpoint(
orchestrator=FirecrackerOrchestrator(),
gateway=FirecrackerGateway(
infra_vm.InfraVm(guest_ip="10.243.255.3", private_key=Path("/k"))),
)
def test_orchestrator_url_is_the_orchestrator_service(self):
ep = self._endpoint()
ip = infra_vm.netpool.orch_slot().guest_ip
self.assertEqual(f"http://{ip}:{infra_vm.ORCHESTRATOR_PORT}", ep.orchestrator_url)
def test_gateway_ca_pem_delegates_to_the_gateway_service(self):
ep = self._endpoint()
with patch.object(ep.gateway, "ca_cert_pem", return_value="PEM") as ca:
self.assertEqual("PEM", ep.gateway_ca_pem(timeout=1))
ca.assert_called_once_with(timeout=1)
class TestRoleInits(unittest.TestCase):
def test_orchestrator_init_starts_only_the_control_plane(self):
init = infra_vm.role_init("orchestrator")
@@ -143,105 +118,6 @@ class TestEnsureBuilt(unittest.TestCase):
tags.index(infra_vm._ORCHESTRATOR_FC_IMAGE))
# The orchestrator + gateway services are imported lazily inside ensure_running
# / _adopt (to break the infra_vm <-> service import cycle), so patch them at
# their home modules.
_ORCH_CLS = "bot_bottle.backend.firecracker.orchestrator.FirecrackerOrchestrator"
_GW_CLS = "bot_bottle.backend.firecracker.gateway.FirecrackerGateway"
class TestEnsureRunningSingleton(unittest.TestCase):
def test_adopts_when_healthy_alive_and_version_matches(self):
# Healthy control plane + live gateway + existing key + matching version
# marker -> adopt (no boot).
from bot_bottle.backend.firecracker.gateway import FirecrackerGateway
from bot_bottle.backend.firecracker.orchestrator import FirecrackerOrchestrator
import tempfile
with tempfile.TemporaryDirectory() as td:
d = Path(td)
(d / "id_ed25519").write_text("k")
(d / "booted-version").write_text("v-current\n")
with patch.object(infra_vm, "_infra_dir", return_value=d), \
patch.object(infra_vm, "_expected_version", return_value="v-current"), \
patch.object(infra_vm, "_health_ok", return_value=True), \
patch.object(infra_vm, "_pidfile_alive", return_value=True):
ep = infra_vm.ensure_running()
# Adopted service handles addressing the two distinct infra links.
self.assertIsInstance(ep.orchestrator, FirecrackerOrchestrator)
self.assertEqual(
infra_vm.netpool.orch_slot().guest_ip,
ep.orchestrator_url.split("://")[1].split(":")[0])
self.assertIsInstance(ep.gateway, FirecrackerGateway)
self.assertEqual(infra_vm.netpool.gw_slot().guest_ip, ep.gateway.address())
def test_reboots_both_when_version_stale(self):
# Healthy control plane but the running pair booted an OLDER image
# (marker mismatch) -> reboot rather than adopt stale code.
import tempfile
with tempfile.TemporaryDirectory() as td:
d = Path(td)
(d / "id_ed25519").write_text("k")
(d / "booted-version").write_text("v-old\n")
with patch.object(infra_vm, "_infra_dir", return_value=d), \
patch.object(infra_vm, "_expected_version", return_value="v-current"), \
patch.object(infra_vm, "_health_ok", return_value=True), \
patch.object(infra_vm, "_pidfile_alive", return_value=True), \
patch.object(infra_vm, "stop") as stop, \
patch.object(infra_vm, "ensure_built"), \
patch(_ORCH_CLS) as orch_cls, \
patch(_GW_CLS) as gw_cls:
orch_cls.return_value.gateway_url.return_value = "http://10.243.255.1:8099"
orch_cls.return_value.mint_gateway_token.return_value = "gw.jwt"
infra_vm.ensure_running()
stop.assert_called_once() # dislodge the outdated pair
orch_cls.return_value.ensure_running.assert_called_once()
# The gateway service is bound to the orchestrator's gateway URL,
# carrying the orchestrator-minted `gateway` token.
gw_cls.return_value.connect_to_orchestrator.assert_called_once_with(
"http://10.243.255.1:8099", "gw.jwt")
# The fresh boot records the current version for the next launcher.
self.assertEqual("v-current\n", (d / "booted-version").read_text())
def test_reboots_when_gateway_dead(self):
# Orchestrator healthy + marker current, but the gateway VM is gone ->
# the pair is half-down, so reboot both rather than adopt partial state.
import tempfile
with tempfile.TemporaryDirectory() as td:
d = Path(td)
(d / "id_ed25519").write_text("k")
(d / "booted-version").write_text("v-current\n")
with patch.object(infra_vm, "_infra_dir", return_value=d), \
patch.object(infra_vm, "_expected_version", return_value="v-current"), \
patch.object(infra_vm, "_health_ok", return_value=True), \
patch.object(infra_vm, "_pidfile_alive", return_value=False), \
patch.object(infra_vm, "stop") as stop, \
patch.object(infra_vm, "ensure_built"), \
patch(_ORCH_CLS) as orch_cls, \
patch(_GW_CLS) as gw_cls:
infra_vm.ensure_running()
stop.assert_called_once()
orch_cls.return_value.ensure_running.assert_called_once()
gw_cls.return_value.connect_to_orchestrator.assert_called_once()
def test_boots_both_when_unhealthy(self):
import tempfile
with tempfile.TemporaryDirectory() as td:
with patch.object(infra_vm, "_infra_dir", return_value=Path(td)), \
patch.object(infra_vm, "_expected_version", return_value="v-current"), \
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(_ORCH_CLS) as orch_cls, \
patch(_GW_CLS) as gw_cls:
infra_vm.ensure_running()
stop.assert_called_once() # clear stale VMs first
built.assert_called_once()
# Orchestrator is brought up BEFORE the gateway is connected (its daemons
# reach the control plane at startup).
orch_cls.return_value.ensure_running.assert_called_once()
gw_cls.return_value.connect_to_orchestrator.assert_called_once()
class TestStop(unittest.TestCase):
def test_stops_both_vms_and_drops_marker(self):
import tempfile
@@ -306,21 +182,21 @@ class TestAdoptable(unittest.TestCase):
with patch.object(infra_vm, "_infra_dir", return_value=d), \
patch.object(infra_vm, "_health_ok", return_value=True), \
patch.object(infra_vm, "_pidfile_alive", return_value=True):
self.assertTrue(infra_vm._adoptable(d / "id_ed25519", "u", "v1"))
self.assertTrue(infra_vm.adoptable(d / "id_ed25519", "u", "v1"))
def test_false_when_key_missing(self):
import tempfile
with tempfile.TemporaryDirectory() as td:
d = self._dir(td, key=False, version="v1")
with patch.object(infra_vm, "_infra_dir", return_value=d):
self.assertFalse(infra_vm._adoptable(d / "id_ed25519", "u", "v1"))
self.assertFalse(infra_vm.adoptable(d / "id_ed25519", "u", "v1"))
def test_false_when_no_version_marker(self):
import tempfile
with tempfile.TemporaryDirectory() as td:
d = self._dir(td) # key present, no booted-version
with patch.object(infra_vm, "_infra_dir", return_value=d):
self.assertFalse(infra_vm._adoptable(d / "id_ed25519", "u", "v1"))
self.assertFalse(infra_vm.adoptable(d / "id_ed25519", "u", "v1"))
def test_false_when_version_mismatch(self):
import tempfile
@@ -329,7 +205,7 @@ class TestAdoptable(unittest.TestCase):
with patch.object(infra_vm, "_infra_dir", return_value=d), \
patch.object(infra_vm, "_health_ok", return_value=True), \
patch.object(infra_vm, "_pidfile_alive", return_value=True):
self.assertFalse(infra_vm._adoptable(d / "id_ed25519", "u", "v1"))
self.assertFalse(infra_vm.adoptable(d / "id_ed25519", "u", "v1"))
def test_false_when_gateway_dead(self):
import tempfile
@@ -338,7 +214,7 @@ class TestAdoptable(unittest.TestCase):
with patch.object(infra_vm, "_infra_dir", return_value=d), \
patch.object(infra_vm, "_health_ok", return_value=True), \
patch.object(infra_vm, "_pidfile_alive", return_value=False):
self.assertFalse(infra_vm._adoptable(d / "id_ed25519", "u", "v1"))
self.assertFalse(infra_vm.adoptable(d / "id_ed25519", "u", "v1"))
class TestKillInfraFirecrackers(unittest.TestCase):
+5 -6
View File
@@ -55,14 +55,13 @@ class TestEnsureGateway(unittest.TestCase):
return ensure_gateway()
def _service(self) -> MagicMock:
from bot_bottle.backend.macos_container.infra import InfraEndpoint
service = MagicMock()
# Two containers now: the orchestrator on the control network, the
# gateway on the agent network — distinct addresses.
service.ensure_running.return_value = InfraEndpoint(
orchestrator_url="http://192.168.128.2:8099",
gateway_ip="192.168.128.3",
)
# gateway on the agent network — distinct addresses. `ensure_running`
# returns the control-plane URL; the gateway address comes off the
# gateway service.
service.ensure_running.return_value = "http://192.168.128.2:8099"
service.gateway.return_value.address.return_value = "192.168.128.3"
service.network = "bot-bottle-mac-gateway"
service.ca_cert_pem.return_value = "PEM"
return service
+5 -9
View File
@@ -30,21 +30,17 @@ class TestInfraEnsureRunning(unittest.TestCase):
self.addCleanup(p.stop)
def test_composes_networks_builds_and_brings_up_orchestrator_first(self) -> None:
with patch(f"{_INFRA}.ensure_networks") as nets, \
patch(f"{_INFRA}.container_mod") as mod:
mod.try_container_ipv4_on_network.return_value = "192.168.128.3"
endpoint = self.svc.ensure_running()
with patch(f"{_INFRA}.ensure_networks") as nets:
url = self.svc.ensure_running()
nets.assert_called_once()
self.orch.ensure_built.assert_called_once()
self.gw.ensure_built.assert_called_once()
self.orch.ensure_running.assert_called_once()
self.assertEqual("http://192.168.128.2:8099", endpoint.orchestrator_url)
self.assertEqual("192.168.128.3", endpoint.gateway_ip)
# Returns the host control-plane URL (the InfraService contract).
self.assertEqual("http://192.168.128.2:8099", url)
def test_connects_gateway_with_orchestrator_url_and_minted_token(self) -> None:
with patch(f"{_INFRA}.ensure_networks"), \
patch(f"{_INFRA}.container_mod") as mod:
mod.try_container_ipv4_on_network.return_value = "192.168.128.3"
with patch(f"{_INFRA}.ensure_networks"):
self.svc.ensure_running()
self.gw.connect_to_orchestrator.assert_called_once_with(
"http://192.168.128.2:8099", "gw.jwt")