Finalize guest boot and exec runtime updates
This commit is contained in:
parent
23a2dfb330
commit
1b19bff7b6
7 changed files with 198 additions and 8 deletions
|
|
@ -7,4 +7,4 @@ iproute2
|
||||||
iputils-ping
|
iputils-ping
|
||||||
netbase
|
netbase
|
||||||
procps
|
procps
|
||||||
python3-minimal
|
python3
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,4 @@ iproute2
|
||||||
iputils-ping
|
iputils-ping
|
||||||
netbase
|
netbase
|
||||||
procps
|
procps
|
||||||
python3-minimal
|
python3
|
||||||
|
|
|
||||||
164
src/pyro_mcp/runtime_boot_check.py
Normal file
164
src/pyro_mcp/runtime_boot_check.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -11,9 +11,9 @@
|
||||||
},
|
},
|
||||||
"bundle_version": "0.1.0",
|
"bundle_version": "0.1.0",
|
||||||
"capabilities": {
|
"capabilities": {
|
||||||
"guest_exec": false,
|
"guest_exec": true,
|
||||||
"guest_network": false,
|
"guest_network": true,
|
||||||
"vm_boot": false
|
"vm_boot": true
|
||||||
},
|
},
|
||||||
"component_versions": {
|
"component_versions": {
|
||||||
"base_distro": "debian-bookworm-20250210",
|
"base_distro": "debian-bookworm-20250210",
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
},
|
},
|
||||||
"rootfs": {
|
"rootfs": {
|
||||||
"path": "profiles/debian-base/rootfs.ext4",
|
"path": "profiles/debian-base/rootfs.ext4",
|
||||||
"sha256": "46247e10fe9b223b15c4ccc672710c2f3013bf562ed9cf9b48af1f092d966494"
|
"sha256": "004c66edabb15969f684feb9d1c0e93df74cfba80270a408b2432a0fe1f30396"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"debian-build": {
|
"debian-build": {
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
},
|
},
|
||||||
"rootfs": {
|
"rootfs": {
|
||||||
"path": "profiles/debian-build/rootfs.ext4",
|
"path": "profiles/debian-build/rootfs.ext4",
|
||||||
"sha256": "a0e9ec968b0fc6826f94a678164abc8c9b661adf87984184bd08abd1da15d7b6"
|
"sha256": "6c1b541260beb3a79788fcfe6d960fc352161512f160c9ed1f1e7b547508fe13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"debian-git": {
|
"debian-git": {
|
||||||
|
|
@ -60,7 +60,7 @@
|
||||||
},
|
},
|
||||||
"rootfs": {
|
"rootfs": {
|
||||||
"path": "profiles/debian-git/rootfs.ext4",
|
"path": "profiles/debian-git/rootfs.ext4",
|
||||||
"sha256": "e28ba2e3fa9ed37bcc9fc04a9b4414f0b29d8c7378508e10be78049a38c25894"
|
"sha256": "7ad128be3f4a785c173c349a4b2f870a402f239856ce41372c2107d186dcb87e"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,11 @@ def build_launch_plan(instance: VmInstanceLike) -> FirecrackerLaunchPlan:
|
||||||
"console=ttyS0",
|
"console=ttyS0",
|
||||||
"reboot=k",
|
"reboot=k",
|
||||||
"panic=1",
|
"panic=1",
|
||||||
|
"acpi=off",
|
||||||
"pci=off",
|
"pci=off",
|
||||||
|
"root=/dev/vda",
|
||||||
|
"rootfstype=ext4",
|
||||||
|
"rw",
|
||||||
"init=/opt/pyro/bin/pyro-init",
|
"init=/opt/pyro/bin/pyro-init",
|
||||||
]
|
]
|
||||||
if instance.network is not None:
|
if instance.network is not None:
|
||||||
|
|
|
||||||
21
tests/test_runtime_boot_check.py
Normal file
21
tests/test_runtime_boot_check.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -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"))
|
rendered = json.loads(plan.config_path.read_text(encoding="utf-8"))
|
||||||
assert rendered["machine-config"]["vcpu_count"] == 2
|
assert rendered["machine-config"]["vcpu_count"] == 2
|
||||||
assert rendered["network-interfaces"][0]["host_dev_name"] == "pyroabcdef12"
|
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 "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.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"]
|
assert "pyro.gateway_ip=172.29.100.1" in rendered["boot-source"]["boot_args"]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue