feat(firecracker): persistent registry volume for the infra VM (Stage B, 6/n)
The infra VM's rootfs is ephemeral (rebuilt each boot), so the bottle registry DB needs durable storage across restarts. Give the infra VM a firecracker analogue of a docker volume: a host-side ext4 file attached as a second virtio-block device (guest /dev/vdb), mounted at the control plane's DB dir (/var/lib/bot-bottle, where host_db_path lives at db/bot-bottle.db). - firecracker_vm.boot/_config take an optional `data_drive` (a non-root, RW second drive). - infra_vm creates the volume on first use (`mke2fs` an empty ext4 at <fc-cache>/infra/registry.ext4) and mounts /dev/vdb in the PID-1 init before the control plane starts. It's a plain ext4 file, so `sudo mount -o loop <path>` (VM stopped) inspects bot-bottle.db directly. Verified on a KVM host: register a bottle, restart the infra VM (fresh rootfs, same volume) — /dev/vdb re-mounts and the registry `db/` dir + a marker file survive the restart. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
@@ -78,20 +78,32 @@ def _config(
|
|||||||
vcpus: int,
|
vcpus: int,
|
||||||
mem_mib: int,
|
mem_mib: int,
|
||||||
guest_mac: str,
|
guest_mac: str,
|
||||||
|
data_drive: Path | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
return {
|
drives: list[dict[str, object]] = [
|
||||||
"boot-source": {
|
|
||||||
"kernel_image_path": str(util.kernel_path()),
|
|
||||||
"boot_args": _boot_args(guest_ip, host_ip, pubkey),
|
|
||||||
},
|
|
||||||
"drives": [
|
|
||||||
{
|
{
|
||||||
"drive_id": "rootfs",
|
"drive_id": "rootfs",
|
||||||
"path_on_host": str(rootfs),
|
"path_on_host": str(rootfs),
|
||||||
"is_root_device": True,
|
"is_root_device": True,
|
||||||
"is_read_only": False,
|
"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": drives,
|
||||||
"network-interfaces": [
|
"network-interfaces": [
|
||||||
{
|
{
|
||||||
"iface_id": "eth0",
|
"iface_id": "eth0",
|
||||||
@@ -119,6 +131,7 @@ def boot(
|
|||||||
mem_mib: int = 2048,
|
mem_mib: int = 2048,
|
||||||
guest_mac: str = "06:00:AC:10:00:02",
|
guest_mac: str = "06:00:AC:10:00:02",
|
||||||
detached: bool = False,
|
detached: bool = False,
|
||||||
|
data_drive: Path | None = None,
|
||||||
) -> VmHandle:
|
) -> VmHandle:
|
||||||
"""Write the config and launch the VMM. Returns once the process is
|
"""Write the config and launch the VMM. Returns once the process is
|
||||||
spawned; callers wait for SSH readiness separately.
|
spawned; callers wait for SSH readiness separately.
|
||||||
@@ -134,6 +147,7 @@ def boot(
|
|||||||
_config(
|
_config(
|
||||||
rootfs=rootfs, tap=tap, guest_ip=guest_ip, host_ip=host_ip,
|
rootfs=rootfs, tap=tap, guest_ip=guest_ip, host_ip=host_ip,
|
||||||
pubkey=pubkey, vcpus=vcpus, mem_mib=mem_mib, guest_mac=guest_mac,
|
pubkey=pubkey, vcpus=vcpus, mem_mib=mem_mib, guest_mac=guest_mac,
|
||||||
|
data_drive=data_drive,
|
||||||
),
|
),
|
||||||
indent=2,
|
indent=2,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ def boot() -> InfraVm:
|
|||||||
name="bot-bottle-infra", rootfs=rootfs, tap=slot.iface,
|
name="bot-bottle-infra", rootfs=rootfs, tap=slot.iface,
|
||||||
guest_ip=slot.guest_ip, host_ip=slot.host_ip, pubkey=pubkey,
|
guest_ip=slot.guest_ip, host_ip=slot.host_ip, pubkey=pubkey,
|
||||||
run_dir=run_dir, mem_mib=4096, detached=True,
|
run_dir=run_dir, mem_mib=4096, detached=True,
|
||||||
|
data_drive=_ensure_registry_volume(),
|
||||||
)
|
)
|
||||||
_pid_file().write_text(str(vm.process.pid))
|
_pid_file().write_text(str(vm.process.pid))
|
||||||
return InfraVm(guest_ip=slot.guest_ip, private_key=private_key, vm=vm)
|
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"
|
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 <path>` 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]:
|
def _stable_keypair() -> tuple[Path, str]:
|
||||||
"""The infra VM's SSH keypair — generated once and reused, so any later
|
"""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
|
launcher can SSH in (fetch CA / provision) even though a different process
|
||||||
@@ -321,6 +351,11 @@ if [ -n "$KEY" ]; then
|
|||||||
fi
|
fi
|
||||||
chown -R 0:0 /root 2>/dev/null || true
|
chown -R 0:0 /root 2>/dev/null || true
|
||||||
mkdir -p /etc/dropbear /run /var/lib/bot-bottle
|
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 &
|
/bb-dropbear -R -E -p 22 &
|
||||||
|
|
||||||
# Control plane. Source is baked at /app; the package is stdlib-only.
|
# Control plane. Source is baked at /app; the package is stdlib-only.
|
||||||
|
|||||||
@@ -334,6 +334,18 @@ class TestBootArgs(unittest.TestCase):
|
|||||||
self.assertEqual("/run/rootfs.ext4", cfg["drives"][0]["path_on_host"])
|
self.assertEqual("/run/rootfs.ext4", cfg["drives"][0]["path_on_host"])
|
||||||
self.assertFalse(cfg["drives"][0]["is_read_only"])
|
self.assertFalse(cfg["drives"][0]["is_read_only"])
|
||||||
self.assertEqual("bbfc0", cfg["network-interfaces"][0]["host_dev_name"])
|
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):
|
class TestBottleExecClose(unittest.TestCase):
|
||||||
|
|||||||
@@ -38,6 +38,34 @@ class TestBuildInfraRootfs(unittest.TestCase):
|
|||||||
self.assertIn("bot_bottle.orchestrator", init)
|
self.assertIn("bot_bottle.orchestrator", init)
|
||||||
self.assertIn("gateway_init.py", init)
|
self.assertIn("gateway_init.py", init)
|
||||||
self.assertIn("export PATH=", 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):
|
class TestEnsureBuilt(unittest.TestCase):
|
||||||
|
|||||||
Reference in New Issue
Block a user