pyro-mcp/src/pyro_mcp/runtime.py

247 lines
8.9 KiB
Python

"""Embedded runtime resolver and diagnostics."""
from __future__ import annotations
import hashlib
import importlib.resources as resources
import json
import os
import stat
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from pyro_mcp.vm_network import TapNetworkManager
DEFAULT_PLATFORM = "linux-x86_64"
@dataclass(frozen=True)
class RuntimePaths:
"""Resolved paths for bundled runtime components."""
bundle_root: Path
manifest_path: Path
firecracker_bin: Path
jailer_bin: Path
guest_agent_path: Path | None
artifacts_dir: Path
notice_path: Path
manifest: dict[str, Any]
@dataclass(frozen=True)
class RuntimeCapabilities:
"""Feature flags inferred from the bundled runtime."""
supports_vm_boot: bool
supports_guest_exec: bool
supports_guest_network: bool
reason: str | None = None
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as fp:
for block in iter(lambda: fp.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def _ensure_executable(path: Path) -> None:
mode = path.stat().st_mode
if mode & stat.S_IXUSR:
return
path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
def _default_bundle_parent() -> Path:
return Path(str(resources.files("pyro_mcp.runtime_bundle")))
def resolve_runtime_paths(
*,
platform: str = DEFAULT_PLATFORM,
verify_checksums: bool = True,
) -> RuntimePaths:
"""Resolve and validate embedded runtime assets."""
bundle_parent = Path(os.environ.get("PYRO_RUNTIME_BUNDLE_DIR", _default_bundle_parent()))
bundle_root = bundle_parent / platform
manifest_path = bundle_root / "manifest.json"
notice_path = bundle_parent / "NOTICE"
if not manifest_path.exists():
raise RuntimeError(
f"bundled runtime manifest not found at {manifest_path}; reinstall package or "
"use a wheel that includes bundled runtime assets"
)
if not notice_path.exists():
raise RuntimeError(f"runtime NOTICE file missing at {notice_path}")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if not isinstance(manifest, dict):
raise RuntimeError("invalid runtime manifest format")
binaries = manifest.get("binaries")
if not isinstance(binaries, dict):
raise RuntimeError("runtime manifest is missing `binaries`")
firecracker_entry = binaries.get("firecracker")
jailer_entry = binaries.get("jailer")
if not isinstance(firecracker_entry, dict) or not isinstance(jailer_entry, dict):
raise RuntimeError("runtime manifest does not define firecracker/jailer binaries")
firecracker_bin = bundle_root / str(firecracker_entry.get("path", ""))
jailer_bin = bundle_root / str(jailer_entry.get("path", ""))
guest_agent_path: Path | None = None
guest = manifest.get("guest")
if isinstance(guest, dict):
agent_entry = guest.get("agent")
if isinstance(agent_entry, dict):
raw_agent_path = agent_entry.get("path")
if isinstance(raw_agent_path, str):
guest_agent_path = bundle_root / raw_agent_path
artifacts_dir = bundle_root / "profiles"
required_paths = [firecracker_bin, jailer_bin]
if guest_agent_path is not None:
required_paths.append(guest_agent_path)
for path in required_paths:
if not path.exists():
raise RuntimeError(f"runtime asset missing: {path}")
_ensure_executable(firecracker_bin)
_ensure_executable(jailer_bin)
if verify_checksums:
for entry in (firecracker_entry, jailer_entry):
raw_path = entry.get("path")
raw_hash = entry.get("sha256")
if not isinstance(raw_path, str) or not isinstance(raw_hash, str):
raise RuntimeError("runtime binary manifest entry is malformed")
full_path = bundle_root / raw_path
actual = _sha256(full_path)
if actual != raw_hash:
raise RuntimeError(
f"runtime checksum mismatch for {full_path}; expected {raw_hash}, got {actual}"
)
if isinstance(guest, dict):
agent_entry = guest.get("agent")
if isinstance(agent_entry, dict):
raw_path = agent_entry.get("path")
raw_hash = agent_entry.get("sha256")
if not isinstance(raw_path, str) or not isinstance(raw_hash, str):
raise RuntimeError("runtime guest agent manifest entry is malformed")
full_path = bundle_root / raw_path
actual = _sha256(full_path)
if actual != raw_hash:
raise RuntimeError(
f"runtime checksum mismatch for {full_path}; "
f"expected {raw_hash}, got {actual}"
)
return RuntimePaths(
bundle_root=bundle_root,
manifest_path=manifest_path,
firecracker_bin=firecracker_bin,
jailer_bin=jailer_bin,
guest_agent_path=guest_agent_path,
artifacts_dir=artifacts_dir,
notice_path=notice_path,
manifest=manifest,
)
def runtime_capabilities(paths: RuntimePaths) -> RuntimeCapabilities:
"""Infer what the current bundled runtime can actually do."""
binary_text = paths.firecracker_bin.read_text(encoding="utf-8", errors="ignore")
if "bundled firecracker shim" in binary_text:
return RuntimeCapabilities(
supports_vm_boot=False,
supports_guest_exec=False,
supports_guest_network=False,
reason="bundled runtime uses shim firecracker/jailer binaries",
)
capabilities = paths.manifest.get("capabilities")
if not isinstance(capabilities, dict):
return RuntimeCapabilities(
supports_vm_boot=False,
supports_guest_exec=False,
supports_guest_network=False,
reason="runtime manifest does not declare guest boot/exec/network capabilities",
)
supports_vm_boot = bool(capabilities.get("vm_boot"))
supports_guest_exec = bool(capabilities.get("guest_exec"))
supports_guest_network = bool(capabilities.get("guest_network"))
reason = None
if not supports_vm_boot:
reason = "runtime manifest does not advertise real VM boot support"
return RuntimeCapabilities(
supports_vm_boot=supports_vm_boot,
supports_guest_exec=supports_guest_exec,
supports_guest_network=supports_guest_network,
reason=reason,
)
def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]:
"""Build a runtime diagnostics report."""
report: dict[str, Any] = {
"platform": platform,
"runtime_ok": False,
"issues": [],
"kvm": {
"exists": Path("/dev/kvm").exists(),
"readable": os.access("/dev/kvm", os.R_OK),
"writable": os.access("/dev/kvm", os.W_OK),
},
"networking": {
"enabled_by_default": TapNetworkManager().enabled,
},
}
network = TapNetworkManager.diagnostics()
report["networking"].update(
{
"tun_available": network.tun_available,
"ip_binary": network.ip_binary,
"nft_binary": network.nft_binary,
"iptables_binary": network.iptables_binary,
"ip_forward_enabled": network.ip_forward_enabled,
}
)
try:
paths = resolve_runtime_paths(platform=platform, verify_checksums=True)
except Exception as exc: # noqa: BLE001
report["issues"] = [str(exc)]
return report
capabilities = runtime_capabilities(paths)
from pyro_mcp.vm_environments import EnvironmentStore
environment_store = EnvironmentStore(runtime_paths=paths)
report["runtime_ok"] = True
report["runtime"] = {
"bundle_root": str(paths.bundle_root),
"manifest_path": str(paths.manifest_path),
"firecracker_bin": str(paths.firecracker_bin),
"jailer_bin": str(paths.jailer_bin),
"guest_agent_path": str(paths.guest_agent_path) if paths.guest_agent_path else None,
"artifacts_dir": str(paths.artifacts_dir),
"artifacts_present": paths.artifacts_dir.exists(),
"notice_path": str(paths.notice_path),
"bundle_version": paths.manifest.get("bundle_version"),
"component_versions": paths.manifest.get("component_versions", {}),
"capabilities": {
"supports_vm_boot": capabilities.supports_vm_boot,
"supports_guest_exec": capabilities.supports_guest_exec,
"supports_guest_network": capabilities.supports_guest_network,
"reason": capabilities.reason,
},
"catalog_version": environment_store.catalog_version,
"cache_dir": str(environment_store.cache_dir),
"environments": environment_store.list_environments(),
}
if not report["kvm"]["exists"]:
report["issues"] = ["/dev/kvm is not available on this host"]
return report