From 1b19bff7b65edb7e8c2c6136abcca67b0500a040 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sat, 7 Mar 2026 17:09:21 -0300 Subject: [PATCH] Finalize guest boot and exec runtime updates --- .../linux-x86_64/packages/debian-base.txt | 2 +- .../linux-x86_64/packages/debian-git.txt | 2 +- src/pyro_mcp/runtime_boot_check.py | 164 ++++++++++++++++++ .../runtime_bundle/linux-x86_64/manifest.json | 12 +- src/pyro_mcp/vm_firecracker.py | 4 + tests/test_runtime_boot_check.py | 21 +++ tests/test_vm_firecracker.py | 1 + 7 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 src/pyro_mcp/runtime_boot_check.py create mode 100644 tests/test_runtime_boot_check.py diff --git a/runtime_sources/linux-x86_64/packages/debian-base.txt b/runtime_sources/linux-x86_64/packages/debian-base.txt index 3c826ec..9c7ce06 100644 --- a/runtime_sources/linux-x86_64/packages/debian-base.txt +++ b/runtime_sources/linux-x86_64/packages/debian-base.txt @@ -7,4 +7,4 @@ iproute2 iputils-ping netbase procps -python3-minimal +python3 diff --git a/runtime_sources/linux-x86_64/packages/debian-git.txt b/runtime_sources/linux-x86_64/packages/debian-git.txt index 263f329..41e1c90 100644 --- a/runtime_sources/linux-x86_64/packages/debian-git.txt +++ b/runtime_sources/linux-x86_64/packages/debian-git.txt @@ -8,4 +8,4 @@ iproute2 iputils-ping netbase procps -python3-minimal +python3 diff --git a/src/pyro_mcp/runtime_boot_check.py b/src/pyro_mcp/runtime_boot_check.py new file mode 100644 index 0000000..0cd91d3 --- /dev/null +++ b/src/pyro_mcp/runtime_boot_check.py @@ -0,0 +1,164 @@ +"""Direct Firecracker boot validation for a bundled runtime profile.""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import tempfile +import time +from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace + +from pyro_mcp.runtime import resolve_runtime_paths +from pyro_mcp.vm_firecracker import build_launch_plan +from pyro_mcp.vm_profiles import get_profile + + +@dataclass(frozen=True) +class BootCheckResult: + profile: str + workdir: Path + firecracker_started: bool + vm_alive_after_wait: bool + process_returncode: int | None + kernel_panic: bool + failure_reason: str | None + serial_log: str + firecracker_log: str + + +def _read_text(path: Path) -> str: # pragma: no cover - thin file helper + if not path.exists(): + return "" + return path.read_text(encoding="utf-8", errors="ignore") + + +def _classify_result(*, firecracker_log: str, serial_log: str, vm_alive: bool) -> str | None: + if vm_alive: + return None + if "Could not initialize logger" in serial_log: + return "firecracker logger initialization failed" + if "Kernel panic" in serial_log: + return "guest kernel panic during boot" + if "Successfully started microvm" not in firecracker_log: + return "firecracker did not fully start the microVM" + return "microVM exited before boot validation window elapsed" + + +def run_boot_check( + *, + profile: str = "debian-base", + vcpu_count: int = 1, + mem_mib: int = 1024, + wait_seconds: int = 8, + keep_workdir: bool = False, +) -> BootCheckResult: # pragma: no cover - integration helper + get_profile(profile) + if wait_seconds <= 0: + raise ValueError("wait_seconds must be positive") + + runtime_paths = resolve_runtime_paths() + profile_dir = runtime_paths.artifacts_dir / profile + + workdir = Path(tempfile.mkdtemp(prefix="pyro-boot-check-")) + try: + rootfs_copy = workdir / "rootfs.ext4" + shutil.copy2(profile_dir / "rootfs.ext4", rootfs_copy) + instance = SimpleNamespace( + vm_id="abcd00000001", + vcpu_count=vcpu_count, + mem_mib=mem_mib, + workdir=workdir, + metadata={ + "kernel_image": str(profile_dir / "vmlinux"), + "rootfs_image": str(rootfs_copy), + }, + network=None, + ) + launch_plan = build_launch_plan(instance) + serial_path = workdir / "serial.log" + firecracker_log_path = workdir / "firecracker.log" + firecracker_log_path.touch() + + with serial_path.open("w", encoding="utf-8") as serial_fp: + proc = subprocess.Popen( # noqa: S603 + [ + str(runtime_paths.firecracker_bin), + "--no-api", + "--config-file", + str(launch_plan.config_path), + "--log-path", + str(firecracker_log_path), + "--level", + "Info", + ], + stdout=serial_fp, + stderr=subprocess.STDOUT, + text=True, + ) + time.sleep(wait_seconds) + vm_alive = proc.poll() is None + process_returncode = proc.poll() + if vm_alive: + proc.kill() + proc.wait(timeout=10) + process_returncode = proc.returncode + + serial_log = _read_text(serial_path) + firecracker_log = _read_text(firecracker_log_path) + failure_reason = _classify_result( + firecracker_log=firecracker_log, + serial_log=serial_log, + vm_alive=vm_alive, + ) + return BootCheckResult( + profile=profile, + workdir=workdir, + firecracker_started="Successfully started microvm" in firecracker_log, + vm_alive_after_wait=vm_alive, + process_returncode=process_returncode, + kernel_panic="Kernel panic" in serial_log, + failure_reason=failure_reason, + serial_log=serial_log, + firecracker_log=firecracker_log, + ) + finally: + if not keep_workdir: + shutil.rmtree(workdir, ignore_errors=True) + + +def main() -> None: # pragma: no cover - CLI wiring + parser = argparse.ArgumentParser(description="Run a direct Firecracker boot check.") + parser.add_argument("--profile", default="debian-base") + parser.add_argument("--vcpu-count", type=int, default=1) + parser.add_argument("--mem-mib", type=int, default=1024) + parser.add_argument("--wait-seconds", type=int, default=8) + parser.add_argument("--keep-workdir", action="store_true") + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args() + + result = run_boot_check( + profile=args.profile, + vcpu_count=args.vcpu_count, + mem_mib=args.mem_mib, + wait_seconds=args.wait_seconds, + keep_workdir=args.keep_workdir, + ) + print(f"[boot] profile={result.profile}") + print(f"[boot] firecracker_started={result.firecracker_started}") + print(f"[boot] vm_alive_after_wait={result.vm_alive_after_wait}") + print(f"[boot] process_returncode={result.process_returncode}") + if result.failure_reason is None: + print("[boot] result=success") + return + print(f"[boot] result=failure reason={result.failure_reason}") + if args.keep_workdir: + print(f"[boot] workdir={result.workdir}") + if args.verbose: + print("[serial]") + print(result.serial_log.rstrip()) + print("[firecracker]") + print(result.firecracker_log.rstrip()) + raise SystemExit(1) diff --git a/src/pyro_mcp/runtime_bundle/linux-x86_64/manifest.json b/src/pyro_mcp/runtime_bundle/linux-x86_64/manifest.json index 1d25331..511b4c0 100644 --- a/src/pyro_mcp/runtime_bundle/linux-x86_64/manifest.json +++ b/src/pyro_mcp/runtime_bundle/linux-x86_64/manifest.json @@ -11,9 +11,9 @@ }, "bundle_version": "0.1.0", "capabilities": { - "guest_exec": false, - "guest_network": false, - "vm_boot": false + "guest_exec": true, + "guest_network": true, + "vm_boot": true }, "component_versions": { "base_distro": "debian-bookworm-20250210", @@ -38,7 +38,7 @@ }, "rootfs": { "path": "profiles/debian-base/rootfs.ext4", - "sha256": "46247e10fe9b223b15c4ccc672710c2f3013bf562ed9cf9b48af1f092d966494" + "sha256": "004c66edabb15969f684feb9d1c0e93df74cfba80270a408b2432a0fe1f30396" } }, "debian-build": { @@ -49,7 +49,7 @@ }, "rootfs": { "path": "profiles/debian-build/rootfs.ext4", - "sha256": "a0e9ec968b0fc6826f94a678164abc8c9b661adf87984184bd08abd1da15d7b6" + "sha256": "6c1b541260beb3a79788fcfe6d960fc352161512f160c9ed1f1e7b547508fe13" } }, "debian-git": { @@ -60,7 +60,7 @@ }, "rootfs": { "path": "profiles/debian-git/rootfs.ext4", - "sha256": "e28ba2e3fa9ed37bcc9fc04a9b4414f0b29d8c7378508e10be78049a38c25894" + "sha256": "7ad128be3f4a785c173c349a4b2f870a402f239856ce41372c2107d186dcb87e" } } } diff --git a/src/pyro_mcp/vm_firecracker.py b/src/pyro_mcp/vm_firecracker.py index 20c87fe..38d5756 100644 --- a/src/pyro_mcp/vm_firecracker.py +++ b/src/pyro_mcp/vm_firecracker.py @@ -50,7 +50,11 @@ def build_launch_plan(instance: VmInstanceLike) -> FirecrackerLaunchPlan: "console=ttyS0", "reboot=k", "panic=1", + "acpi=off", "pci=off", + "root=/dev/vda", + "rootfstype=ext4", + "rw", "init=/opt/pyro/bin/pyro-init", ] if instance.network is not None: diff --git a/tests/test_runtime_boot_check.py b/tests/test_runtime_boot_check.py new file mode 100644 index 0000000..0733e0a --- /dev/null +++ b/tests/test_runtime_boot_check.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from pyro_mcp.runtime_boot_check import _classify_result + + +def test_classify_result_reports_kernel_panic() -> None: + reason = _classify_result( + firecracker_log="Successfully started microvm", + serial_log="Kernel panic - not syncing: VFS: Unable to mount root fs", + vm_alive=False, + ) + assert reason == "guest kernel panic during boot" + + +def test_classify_result_reports_success_when_vm_stays_alive() -> None: + reason = _classify_result( + firecracker_log="Successfully started microvm", + serial_log="boot log", + vm_alive=True, + ) + assert reason is None diff --git a/tests/test_vm_firecracker.py b/tests/test_vm_firecracker.py index dc90906..191cc8f 100644 --- a/tests/test_vm_firecracker.py +++ b/tests/test_vm_firecracker.py @@ -47,6 +47,7 @@ def test_build_launch_plan_writes_expected_files(tmp_path: Path) -> None: rendered = json.loads(plan.config_path.read_text(encoding="utf-8")) assert rendered["machine-config"]["vcpu_count"] == 2 assert rendered["network-interfaces"][0]["host_dev_name"] == "pyroabcdef12" + assert "acpi=off" in rendered["boot-source"]["boot_args"] assert "init=/opt/pyro/bin/pyro-init" in rendered["boot-source"]["boot_args"] assert "pyro.guest_ip=172.29.100.2" in rendered["boot-source"]["boot_args"] assert "pyro.gateway_ip=172.29.100.1" in rendered["boot-source"]["boot_args"]