diff --git a/README.md b/README.md index d002d7d..e549ac9 100644 --- a/README.md +++ b/README.md @@ -244,9 +244,10 @@ See [docs/execution-model.md](docs/execution-model.md). We keep this model small * **`backend="microvm"`** - the reserved hardware-VM boundary. The supervisor probes the host for a supported VMM (Firecracker, Cloud Hypervisor, QEMU) and an accessible `/dev/kvm`, and **fails closed** with a diagnostic naming what is - missing; when the host is capable it still refuses, because the guest launcher - and vsock transport are not yet implemented. It never degrades to a weaker - boundary. + missing. The VMM launcher (config materialization + process lifecycle) now + exists, but even on a capable host the backend still refuses, because the + in-guest agent and vsock cell transport are not yet implemented. It never + degrades to a weaker boundary. * **Broker** - sole path to privileged syscalls, sealed with AEAD (X25519 to ChaCha20-Poly1305) and strict per-direction replay counters. * **Fallback hardening** - even the process backend is defense-in-depth, not a diff --git a/docs/threat-model.md b/docs/threat-model.md index 5c51038..b80723e 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -44,8 +44,9 @@ high-assurance multitenancy, run one sandbox per process inside a VM or microVM. - **`backend="microvm"`** is the reserved hardware-VM boundary. The supervisor probes for a supported VMM (Firecracker, Cloud Hypervisor, QEMU) and an accessible `/dev/kvm` and **fails closed** with a diagnostic when they are - missing; even on a capable host it refuses, because the guest launcher and - vsock transport are not yet implemented. It never downgrades to a weaker + missing. The VMM launcher (config materialization + process lifecycle) now + exists, but even on a capable host it refuses, because the in-guest agent and + vsock cell transport are not yet implemented. It never downgrades to a weaker boundary. --- diff --git a/pyisolate/runtime/microvm.py b/pyisolate/runtime/microvm.py index f6cdb0b..d57a416 100644 --- a/pyisolate/runtime/microvm.py +++ b/pyisolate/runtime/microvm.py @@ -22,9 +22,12 @@ from __future__ import annotations +import json import math import os import shutil +import subprocess +import tempfile from dataclasses import dataclass, field from typing import Any, Optional @@ -210,3 +213,104 @@ def to_firecracker_json(self) -> dict[str, Any]: "uds_path": self.vsock_uds_path, }, } + + +@dataclass +class LaunchedMicroVM: + """A running VMM process and the on-disk artifacts backing it.""" + + process: "subprocess.Popen[bytes]" + workdir: str + config_path: str + api_socket: str + vsock_uds_path: str + + @property + def pid(self) -> int: + return self.process.pid + + def is_alive(self) -> bool: + return self.process.poll() is None + + +class MicroVMLauncher: + """Materialize a guest config and manage the VMM process lifecycle. + + This is the mechanical layer beneath a running microVM: it writes the + machine configuration to a per-VM working directory, builds the VMM command + line, spawns the process, and tears it down. It does **not** yet complete the + guest handshake -- the in-guest agent and the vsock cell transport are the + next increment -- so a launched VM has no cell channel and the supervisor + still refuses to hand back a usable sandbox. + + Only Firecracker's command line is implemented; a ready host running another + supported VMM is reported as unimplemented rather than mis-launched. + """ + + def __init__(self, support: MicroVMSupport) -> None: + self._support = support + + def build_command(self, config_path: str, api_socket: str) -> list[str]: + """Return the argv that boots *config_path* on the detected VMM.""" + if self._support.vmm_path is None: + raise MicroVMUnavailable("no VMM available to launch") + if self._support.vmm_kind != "firecracker": + raise MicroVMUnavailable( + f"launching {self._support.vmm_kind!r} is not implemented yet; " + "only Firecracker is wired up" + ) + return [ + self._support.vmm_path, + "--api-sock", + api_socket, + "--config-file", + config_path, + ] + + def _materialize_config(self, config: MicroVMConfig, workdir: str) -> str: + config_path = os.path.join(workdir, "vm-config.json") + with open(config_path, "w", encoding="utf-8") as handle: + json.dump(config.to_firecracker_json(), handle, indent=2, sort_keys=True) + return config_path + + def launch( + self, config: MicroVMConfig, *, workdir: Optional[str] = None + ) -> LaunchedMicroVM: + """Write the config and start the VMM, returning a handle to it. + + The caller owns the returned VM and must call :meth:`terminate` to stop + the process and remove the working directory. + """ + owns_workdir = workdir is None + workdir = workdir or tempfile.mkdtemp(prefix="pyisolate-vm-") + try: + config_path = self._materialize_config(config, workdir) + api_socket = os.path.join(workdir, "firecracker.socket") + command = self.build_command(config_path, api_socket) + process: "subprocess.Popen[bytes]" = subprocess.Popen( + command, close_fds=True + ) + except BaseException: + if owns_workdir: + shutil.rmtree(workdir, ignore_errors=True) + raise + return LaunchedMicroVM( + process=process, + workdir=workdir, + config_path=config_path, + api_socket=api_socket, + vsock_uds_path=config.vsock_uds_path, + ) + + @staticmethod + def terminate(vm: LaunchedMicroVM, *, timeout: float = 5.0) -> None: + """Stop the VMM process and remove its working directory.""" + process = vm.process + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + shutil.rmtree(vm.workdir, ignore_errors=True) diff --git a/pyisolate/supervisor.py b/pyisolate/supervisor.py index c60c65f..560de6f 100644 --- a/pyisolate/supervisor.py +++ b/pyisolate/supervisor.py @@ -498,9 +498,11 @@ def _spawn_microvm(self, name: str) -> Sandbox: support = _microvm.require_microvm_support() raise NotImplementedError( f"backend='microvm' prerequisites are present (VMM: " - f"{support.vmm_kind} at {support.vmm_path}, /dev/kvm accessible), but " - "the guest launcher and vsock transport are not yet implemented. " - "Use backend='process' for kernel-level confinement in the meantime." + f"{support.vmm_kind} at {support.vmm_path}, /dev/kvm accessible), and " + "the VMM launcher (MicroVMLauncher) can boot a guest, but the in-guest " + "agent and vsock cell transport are not yet implemented, so no cell " + "channel exists. Use backend='process' for kernel-level confinement " + "in the meantime." ) def _spawn_process( diff --git a/tests/test_microvm_launcher.py b/tests/test_microvm_launcher.py new file mode 100644 index 0000000..f358d95 --- /dev/null +++ b/tests/test_microvm_launcher.py @@ -0,0 +1,137 @@ +"""Tests for the microVM VMM launcher (config materialization + lifecycle). + +Booting a real guest needs KVM and a guest image, so these tests drive the +launcher against a *fake VMM* -- a tiny script that ignores its arguments and +sleeps -- to exercise config materialization, command construction, and the +process lifecycle without a hypervisor. +""" + +import json +import os +import stat +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +import pytest + +from pyisolate.runtime import microvm + + +def _fake_vmm(tmp_path: Path) -> str: + """A stand-in VMM executable that ignores args and stays alive.""" + script = tmp_path / "fake-vmm" + script.write_text("#!/bin/sh\nexec sleep 30\n", encoding="utf-8") + script.chmod(script.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return str(script) + + +def _config(tmp_path: Path) -> microvm.MicroVMConfig: + return microvm.MicroVMConfig( + kernel_image="/boot/vmlinux", + rootfs_image="/img/rootfs.ext4", + vsock_uds_path=str(tmp_path / "vm.sock"), + vcpus=2, + mem_size_mib=256, + ) + + +def _ready_support(vmm_path: str) -> microvm.MicroVMSupport: + return microvm.MicroVMSupport(vmm_kind="firecracker", vmm_path=vmm_path, kvm=True) + + +# -- command construction --------------------------------------------------- + + +def test_build_command_for_firecracker(): + support = _ready_support("/usr/bin/firecracker") + launcher = microvm.MicroVMLauncher(support) + cmd = launcher.build_command("/tmp/cfg.json", "/tmp/api.sock") + assert cmd == [ + "/usr/bin/firecracker", + "--api-sock", + "/tmp/api.sock", + "--config-file", + "/tmp/cfg.json", + ] + + +def test_build_command_rejects_unimplemented_vmm(): + support = microvm.MicroVMSupport( + vmm_kind="qemu", vmm_path="/usr/bin/qemu-system-x86_64", kvm=True + ) + launcher = microvm.MicroVMLauncher(support) + with pytest.raises(microvm.MicroVMUnavailable, match="not implemented"): + launcher.build_command("/tmp/cfg.json", "/tmp/api.sock") + + +def test_build_command_requires_a_vmm_path(): + support = microvm.MicroVMSupport(vmm_kind=None, vmm_path=None, kvm=True) + launcher = microvm.MicroVMLauncher(support) + with pytest.raises(microvm.MicroVMUnavailable): + launcher.build_command("/tmp/cfg.json", "/tmp/api.sock") + + +# -- launch / lifecycle ----------------------------------------------------- + + +def test_launch_writes_config_and_starts_process(tmp_path): + launcher = microvm.MicroVMLauncher(_ready_support(_fake_vmm(tmp_path))) + config = _config(tmp_path) + vm = launcher.launch(config) + try: + assert vm.is_alive() + assert vm.pid > 0 + # The config file is materialized in the VM's workdir and matches the + # Firecracker document the config renders. + with open(vm.config_path, encoding="utf-8") as handle: + written = json.load(handle) + assert written == config.to_firecracker_json() + assert os.path.dirname(vm.config_path) == vm.workdir + assert vm.api_socket.startswith(vm.workdir) + finally: + microvm.MicroVMLauncher.terminate(vm) + + +def test_terminate_stops_process_and_removes_workdir(tmp_path): + launcher = microvm.MicroVMLauncher(_ready_support(_fake_vmm(tmp_path))) + vm = launcher.launch(_config(tmp_path)) + workdir = vm.workdir + assert os.path.isdir(workdir) + microvm.MicroVMLauncher.terminate(vm) + assert not vm.is_alive() + assert not os.path.exists(workdir) + + +def test_terminate_is_safe_after_process_exit(tmp_path): + launcher = microvm.MicroVMLauncher(_ready_support(_fake_vmm(tmp_path))) + vm = launcher.launch(_config(tmp_path)) + vm.process.kill() + vm.process.wait() + # Already dead: terminate must not raise and must still clean up. + microvm.MicroVMLauncher.terminate(vm) + assert not os.path.exists(vm.workdir) + + +def test_launch_cleans_up_workdir_on_failure(tmp_path, monkeypatch): + # A non-Firecracker VMM fails in build_command *after* the workdir is made; + # the owned workdir must be removed rather than leaked. + support = microvm.MicroVMSupport( + vmm_kind="cloud-hypervisor", vmm_path="/usr/bin/cloud-hypervisor", kvm=True + ) + launcher = microvm.MicroVMLauncher(support) + created = [] + real_mkdtemp = microvm.tempfile.mkdtemp + + def _tracking_mkdtemp(*args, **kwargs): + path = real_mkdtemp(*args, **kwargs) + created.append(path) + return path + + monkeypatch.setattr(microvm.tempfile, "mkdtemp", _tracking_mkdtemp) + with pytest.raises(microvm.MicroVMUnavailable): + launcher.launch(_config(tmp_path)) + assert created, "launch should have created a workdir before failing" + assert not os.path.exists(created[0])