diff --git a/bot_bottle/backend/firecracker/firecracker_vm.py b/bot_bottle/backend/firecracker/firecracker_vm.py index 72a8e65..5c372b7 100644 --- a/bot_bottle/backend/firecracker/firecracker_vm.py +++ b/bot_bottle/backend/firecracker/firecracker_vm.py @@ -78,20 +78,32 @@ def _config( vcpus: int, mem_mib: int, guest_mac: str, + data_drive: Path | None = None, ) -> dict[str, object]: + drives: list[dict[str, object]] = [ + { + "drive_id": "rootfs", + "path_on_host": str(rootfs), + "is_root_device": True, + "is_read_only": False, + } + ] + # A second virtio-block device (guest /dev/vdb) — the infra VM's + # persistent registry "volume", a host-side ext4 file that outlives the + # ephemeral rootfs across VM restarts. + if data_drive is not None: + drives.append({ + "drive_id": "data", + "path_on_host": str(data_drive), + "is_root_device": False, + "is_read_only": False, + }) return { "boot-source": { "kernel_image_path": str(util.kernel_path()), "boot_args": _boot_args(guest_ip, host_ip, pubkey), }, - "drives": [ - { - "drive_id": "rootfs", - "path_on_host": str(rootfs), - "is_root_device": True, - "is_read_only": False, - } - ], + "drives": drives, "network-interfaces": [ { "iface_id": "eth0", @@ -119,6 +131,7 @@ def boot( mem_mib: int = 2048, guest_mac: str = "06:00:AC:10:00:02", detached: bool = False, + data_drive: Path | None = None, ) -> VmHandle: """Write the config and launch the VMM. Returns once the process is spawned; callers wait for SSH readiness separately. @@ -134,6 +147,7 @@ def boot( _config( rootfs=rootfs, tap=tap, guest_ip=guest_ip, host_ip=host_ip, pubkey=pubkey, vcpus=vcpus, mem_mib=mem_mib, guest_mac=guest_mac, + data_drive=data_drive, ), indent=2, )) diff --git a/bot_bottle/backend/firecracker/infra_vm.py b/bot_bottle/backend/firecracker/infra_vm.py index b6c0c50..4cb5630 100644 --- a/bot_bottle/backend/firecracker/infra_vm.py +++ b/bot_bottle/backend/firecracker/infra_vm.py @@ -165,6 +165,7 @@ def boot() -> InfraVm: name="bot-bottle-infra", rootfs=rootfs, tap=slot.iface, guest_ip=slot.guest_ip, host_ip=slot.host_ip, pubkey=pubkey, run_dir=run_dir, mem_mib=4096, detached=True, + data_drive=_ensure_registry_volume(), ) _pid_file().write_text(str(vm.process.pid)) return InfraVm(guest_ip=slot.guest_ip, private_key=private_key, vm=vm) @@ -180,6 +181,35 @@ def _pid_file() -> Path: return _infra_dir() / "vm.pid" +# The registry "volume": a host-side ext4 file attached to the infra VM as a +# second virtio-block device (guest /dev/vdb), mounted at the control plane's +# DB dir. It outlives the ephemeral rootfs, so the bottle registry survives an +# infra-VM restart — the firecracker analogue of a docker volume. It is a +# plain ext4 file: `sudo mount -o loop ` on the host (with the VM +# stopped) to inspect bot-bottle.db directly. +_REGISTRY_SIZE = "512M" + + +def registry_volume_path() -> Path: + return _infra_dir() / "registry.ext4" + + +def _ensure_registry_volume() -> Path: + """Create the empty ext4 registry volume on first use; reuse it after.""" + vol = registry_volume_path() + if vol.exists(): + return vol + info(f"creating infra registry volume {vol} ({_REGISTRY_SIZE})") + proc = subprocess.run( + ["mke2fs", "-q", "-t", "ext4", "-F", str(vol), _REGISTRY_SIZE], + capture_output=True, text=True, check=False, + ) + if proc.returncode != 0: + vol.unlink(missing_ok=True) + die(f"creating registry volume failed: {proc.stderr.strip()}") + return vol + + def _stable_keypair() -> tuple[Path, str]: """The infra VM's SSH keypair — generated once and reused, so any later launcher can SSH in (fetch CA / provision) even though a different process @@ -321,6 +351,11 @@ if [ -n "$KEY" ]; then fi chown -R 0:0 /root 2>/dev/null || true mkdir -p /etc/dropbear /run /var/lib/bot-bottle + +# Persistent registry volume (second virtio-block device, /dev/vdb) mounted +# at the control plane's DB dir, so bot-bottle.db survives infra-VM restarts. +mount -t ext4 /dev/vdb /var/lib/bot-bottle 2>/dev/null || true + /bb-dropbear -R -E -p 22 & # Control plane. Source is baked at /app; the package is stdlib-only. diff --git a/tests/unit/test_firecracker_backend.py b/tests/unit/test_firecracker_backend.py index 55b1937..32603d4 100644 --- a/tests/unit/test_firecracker_backend.py +++ b/tests/unit/test_firecracker_backend.py @@ -334,6 +334,18 @@ class TestBootArgs(unittest.TestCase): self.assertEqual("/run/rootfs.ext4", cfg["drives"][0]["path_on_host"]) self.assertFalse(cfg["drives"][0]["is_read_only"]) self.assertEqual("bbfc0", cfg["network-interfaces"][0]["host_dev_name"]) + self.assertEqual(1, len(cfg["drives"])) # no data drive by default + + def test_config_adds_data_drive(self): + cfg = cast(Any, firecracker_vm._config( + rootfs=Path("/run/rootfs.ext4"), tap="bbfc0", + guest_ip="100.64.0.1", host_ip="100.64.0.0", pubkey="k", + vcpus=2, mem_mib=2048, guest_mac="06:00:AC:10:00:02", + data_drive=Path("/run/registry.ext4"), + )) + self.assertEqual(2, len(cfg["drives"])) + self.assertFalse(cfg["drives"][1]["is_root_device"]) + self.assertEqual("/run/registry.ext4", cfg["drives"][1]["path_on_host"]) class TestBottleExecClose(unittest.TestCase): diff --git a/tests/unit/test_firecracker_infra_vm.py b/tests/unit/test_firecracker_infra_vm.py index 567f41f..e080057 100644 --- a/tests/unit/test_firecracker_infra_vm.py +++ b/tests/unit/test_firecracker_infra_vm.py @@ -38,6 +38,34 @@ class TestBuildInfraRootfs(unittest.TestCase): self.assertIn("bot_bottle.orchestrator", init) self.assertIn("gateway_init.py", init) self.assertIn("export PATH=", init) + # Persistent registry volume mounted at the DB dir before the CP starts. + self.assertIn("/dev/vdb", init) + + +class TestRegistryVolume(unittest.TestCase): + def test_reuses_existing_volume(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + vol = Path(td) / "registry.ext4" + vol.write_bytes(b"") # already present + with patch.object(infra_vm, "registry_volume_path", return_value=vol), \ + patch.object(infra_vm.subprocess, "run") as run: + out = infra_vm._ensure_registry_volume() + run.assert_not_called() # no mke2fs when it exists + self.assertEqual(vol, out) + + def test_creates_volume_when_missing(self): + import tempfile + from subprocess import CompletedProcess + with tempfile.TemporaryDirectory() as td: + vol = Path(td) / "registry.ext4" + with patch.object(infra_vm, "registry_volume_path", return_value=vol), \ + patch.object(infra_vm.subprocess, "run", + return_value=CompletedProcess([], 0)) as run: + infra_vm._ensure_registry_volume() + argv = run.call_args.args[0] + self.assertIn("mke2fs", argv) + self.assertIn(str(vol), argv) class TestEnsureBuilt(unittest.TestCase):