butterpot and vmrunner: system and test-infrastructure co-design
Since early 2024, I have been working on butterpot, a Btrfs-based backup system for my own use. Several alternatives already exist, but none provide the exact feature set I have in mind, so I started sketching an architecture.
When it came to implementation, a harder question emerged: how do you safely test software that might alter the filesystems on your development machine?
My answer was disposable virtual machines. That requirement led to vmrunner, a test-infrastructure project shaped by the needs of butterpot.
From system requirements to test infrastructure
Creating a VM is straightforward. Making VM-backed tests repeatable, fast, frictionless, and rootless across macOS and Linux hosts—with Linux guests—is the harder problem.
Those four goals shaped vmrunner.
Repeatable
The simplest way to guarantee a known state is to never reuse an environment. Each test starts with a disposable guest, and the guest is deleted afterward.
This avoids cleanup logic inside the guest: discard the environment, then repeat from the same starting point.
Fast
Disposable environments are practical only when creating and starting them is cheap. Work that does not depend on an individual test—such as preparing the guest image and building a compatible sysroot—should happen once and be cached.
The per-test path should be limited to creating fresh writable state, launching the guest, and running the test binary. The current implementation does not fully achieve that yet, but it establishes the target.
Frictionless
Rust makes ordinary tests pleasantly direct with #[test]. Conceptually, a VM-backed test should feel similar:
The injected Spawner provides the default environment and can launch multiple machines within a single test case.
Rootless
On Linux, starting a VM does not inherently require root, but parts of the setup need elevated capabilities. vmrunner uses unshare to create a user namespace and become scoped "pretend-root" instead of running the test as host-wide root.
On macOS, the binary launching the VM must be signed. For a unit test, this means signing the test binary and re-executing it before launching VMs through the sandbox API.
What runs inside the VM?
The guest runs the compiled test target. vmrunner invokes a command equivalent to:
./target/<mode>/<bin> <args>
The macro and Spawner keep orchestration in the Rust test, while filesystem-changing work runs inside the disposable Linux guest.
Current state and performance
vmrunner is functional and usable, but still slow compared with an ordinary unit-test workflow.
Most of the first-run cost comes from generating a sufficient sysroot for the test binary. In particular, the linker needs compatible glibc symbols for the guest operating system. The process is:
- Prepare the guest by installing the required libraries.
- Use
libguestfsto extract the relevant files from the root filesystem in theqcow2image. - Use that sysroot to compile the test binary for compatibility with the guest.
- Launch the VMs and run the test.
The first three steps are the most time-consuming. All three can be cached, but CI must absorb a few minutes of preparation when that cache is cold, such as after changes to the VM image.
Host requirements
The current implementation relies on:
unshareand access to/dev/kvmon Linuxlibkrunlibguestfs- guest images in
qcow2format
This list is likely to change as the implementation evolves. The goal is to reduce host requirements without making compilation impractically expensive.
Co-design in practice
butterpot created the need for isolated, reproducible filesystem tests, and those needs shaped vmrunner's API and lifecycle. In return, vmrunner's startup and preparation costs influence how butterpot's test suite should be divided.
Logic that does not need a complete guest can remain in ordinary Rust tests. Tests that exercise real filesystem behavior can use vmrunner and accept the additional isolation cost.
tl;dr
Use vmrunner when tests need reproducible filesystem state and isolation from the host, and when CI can absorb the additional latency.
I recommend feature-gating these tests and running them nightly or when changes touch the relevant filesystem-facing modules.