Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
104 changes: 104 additions & 0 deletions pyisolate/runtime/microvm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
8 changes: 5 additions & 3 deletions pyisolate/supervisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
137 changes: 137 additions & 0 deletions tests/test_microvm_launcher.py
Original file line number Diff line number Diff line change
@@ -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])
Loading