Real Linux VMs as Go test fixtures — on macOS and Linux.
fleetbox boots stock Linux cloud images and hands them to your tests over SSH. On macOS
(Apple Silicon) it drives Apple's Virtualization.framework; on Linux, cloud-hypervisor.
The Go API is identical on both. Think testcontainers, except instead of a container you
get a whole machine: real kernel, real systemd, real /dev/kvm.
func TestAgainstRealLinux(t *testing.T) {
vm := fleetboxtest.Start(t, "debian-12")
out, err := vm.SSH(context.Background(), "uname -a")
if err != nil {
t.Fatal(err)
}
t.Log(out) // Linux ... aarch64 GNU/Linux
}One line gets you a booted Debian box, reachable over SSH with a keypair fleetbox
generates itself (it never touches your ~/.ssh). When the test returns, the VM is
destroyed.
Status: v0. It works, but the API will change and there are no compatibility promises yet. See Limitations.
Containers are wonderful right up until you need to test something a container can't
give you: a kernel module, a systemd unit, an nftables ruleset, kubeadm, a VPN,
anything that wants /dev/kvm. The usual fallback is a VM tool that arrives with a yaml
dialect, a background agent forwarding SSH ports, and patched images. That is a lot of
machinery, and all of it has to keep working.
fleetbox keeps the machinery small and refuses to grow it:
- Real VMs, not containers. It EFI-boots unmodified cloud images through their own bootloader; on M3+ you also get nested virtualization, so KVM works inside the guest.
- Every VM gets a routable IP. A vmnet SharedMode network on macOS 26+, a shared
bridge with static addresses on Linux. No port forwarding to set up, no tunnel daemon
to babysit: call
vm.IP()and connect. - Nothing of ours runs in the guest. No agent, no helper binary, no host/guest protocol. cloud-init configures the VM once at first boot; after that it's a plain distro you reach over SSH.
- Library-first. The Go package is the product; the CLI is a thin wrapper over the
same calls. Test fixtures clean themselves up through
t.Cleanup.
No yaml, no templates, no per-distro code paths. Flags and sane defaults.
- macOS on Apple Silicon. Clusters (VM↔VM networking) need macOS 26+; below that
you get a single VM at a time. Nested virtualization (
/dev/kvmin the guest) needs M3 or newer. Intel Macs are not supported. - Linux, amd64 or arm64. Needs
/dev/kvm(be in thekvmgroup) and root for the network — the shared bridge and per-VM taps. The CLI elevates itself: runfleetbox upand approve the one sudo password prompt. For the library andgo test, run under sudo (see Install). fleetbox checks both before booting anything and fails with a clear error, not a cryptic boot one.
Plus Go 1.24+. The module compiles on darwin/arm64 and linux/{amd64,arm64}; other
targets build but return a clear "unsupported platform" error at runtime.
go get github.com/pilat/fleetboxThat's the whole install: nothing to build, nothing to codesign. Your test binary stays
pure Go. On macOS, all the Virtualization.framework work lives in a small signed
fleetbox-helper that fleetbox downloads, checksum-pinned, into ~/.fleetbox/bin/ the
first time you boot a VM. Linux is the same story with different binaries: the
cloud-hypervisor VMM and its firmware are fetched and pinned on first use.
Prefer the CLI to the library? On macOS (Apple Silicon) it ships as a Homebrew cask:
brew tap pilat/fleetbox
brew install --cask fleetboxOn Linux (Homebrew has no casks there), install it straight from source:
go install github.com/pilat/fleetbox/cmd/fleetbox@latestEither way you get the pure-Go fleetbox binary; the helper and VMM still auto-download
on first boot, exactly as they do for the library.
On Linux the network work needs root, but you don't run sudo fleetbox yourself — the CLI
re-execs its own absolute path under sudo for up/down/rm, so you just run fleetbox up
and approve the password prompt (no need to put the binary on root's PATH). Read-only
commands — ls, ssh, cp, ssh-config — never ask for sudo. In a non-interactive shell
(CI, a pipe, no terminal) it doesn't hang: it prints the exact sudo … command to run and
exits.
For the library on Linux there's no auto-elevation (a test must never spawn a password prompt), so run the test binary under sudo. The invocation our own CI uses:
sudo -E env "HOME=$HOME" "PATH=$PATH" go test ./...HOME keeps fleetbox's state under your ~/.fleetbox (not /root), and PATH lets the
root process find the Go toolchain and the ip/iptables it shells out to.
The first boot also downloads the cloud image (a few hundred MB, cached in
~/.fleetbox/images/) and prints a progress line so it doesn't look like a hung test.
Hacking on fleetbox itself, or running on an air-gapped host? Build the helper locally and point fleetbox at it:
make helper # builds + ad-hoc-signs ./bin/fleetbox-helper
FLEETBOX_HELPER=$PWD/bin/fleetbox-helper go test ./...import (
"context"
"testing"
"github.com/pilat/fleetbox"
"github.com/pilat/fleetbox/fleetboxtest"
)
func TestNeedsARealKernel(t *testing.T) {
vm := fleetboxtest.Start(t, "debian-12",
fleetbox.WithCPUs(2),
fleetbox.WithMemoryGB(4),
)
// What no container can give you: systemd as PID 1, your own kernel,
// a working /dev/kvm.
out, err := vm.SSH(context.Background(),
"cat /proc/1/comm && uname -r && test -e /dev/kvm && echo ok")
if err != nil {
t.Fatalf("%v\n%s", err, out)
}
t.Log(out) // systemd / 6.1.0-… / ok
}fleetboxtest.Start registers t.Cleanup to destroy the VM, derives a collision-safe
name from the test name so parallel tests don't fight, and skips the test automatically
when the hardware can't run it. SkipIfShort opts a test out under go test -short.
StartN boots several VMs on one shared network — a real cluster whose members reach
each other by IP (see Limitations for where clustering is supported).
vm, err := fleetbox.Start(ctx, "builder",
fleetbox.WithImage("ubuntu-24.04"),
fleetbox.WithCPUs(4),
fleetbox.WithMemoryGB(8),
fleetbox.WithDiskGB(40),
)
if err != nil {
log.Fatal(err)
}
fmt.Println(vm.IP()) // net.IP — directly reachable, no forwarding
out, err := vm.SSH(ctx, "sudo apt-get install -y nginx") // user has passwordless sudo
if err != nil {
log.Fatalf("%v\n%s", err, out)
}
_ = vm.Stop(ctx) // graceful shutdown, disk preserved
// vm.Destroy(ctx) deletes it entirelyStart is idempotent: call it again with the same name and it boots the existing VM
instead of recreating it. State lives under ~/.fleetbox/clusters/<cluster>/<name>/ and
survives reboots; Destroy (or fleetbox rm) is the only thing that deletes a disk.
Full API on pkg.go.dev.
WithFixture packs a host directory into the guest as a read-only fixture — the way to
hand a VM your test data, config, or build output. It works identically on macOS
and Linux: at boot the directory is snapshotted into an ext4 image, attached read-only,
and mounted in the guest at the path you give:
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "input.json"), payload, 0o644)
vm := fleetboxtest.Start(t, "debian-12", fleetbox.WithFixture(dir, "/work"))
out, _ := vm.SSH(context.Background(), "cat /work/input.json") // reads the snapshotFrom the CLI it's a repeatable --fixture host:guest flag:
./bin/fleetbox up dev --fixture ./src:/work --fixture ./fixtures:/dataFixtures are read-only and world-readable: every file 0444, every directory 0555,
owned by root. Host permission and exec bits are not preserved. The set of fixtures is
frozen when the VM is first created (rm and recreate to change it), but the content is
re-snapshotted from the host directory on every boot, so a reboot picks up host-side
changes. To get data back out of the guest, use fleetbox cp or scp. The guest path
must be absolute, and host paths must not contain colons (the value splits on the last
colon).
The CLI wraps the same library for manual work:
make build # compiles ./bin/fleetbox (pure Go, nothing to sign)
./bin/fleetbox up web # create & boot a VM
./bin/fleetbox up node -n 3 # boot a cluster: node-1, node-2, node-3
./bin/fleetbox ssh node-2 # members are addressed by name
./bin/fleetbox ssh node-1 -- ping -c1 node-2 # ...and reach each other by IP
./bin/fleetbox cp ./mybinary web:/usr/local/bin/
./bin/fleetbox ls # NAME IP STATE CPUS MEM DISK AGE
./bin/fleetbox ssh-config >> ~/.ssh/config # then plain `ssh web` works
./bin/fleetbox down node-1 # stop one member; the rest keep running
./bin/fleetbox rm node # destroy the whole cluster (prefix match)
./bin/fleetbox version # print version, commit, build dateA cluster's VMs live in one holder process sharing one network (vmnet on macOS, a bridge
on Linux), which is what lets them reach each other by IP; ssh/down/rm still
address each member by name.
Pass a built-in alias or any direct URL to a raw / qcow2 cloud image:
| Alias | Image |
|---|---|
debian-11 |
Debian 11 generic cloud (amd64/arm64, per host) |
debian-12 (default) |
Debian 12 generic cloud |
debian-13 |
Debian 13 generic cloud |
ubuntu-22.04 |
Ubuntu 22.04 server cloud |
ubuntu-24.04 |
Ubuntu 24.04 server cloud |
ubuntu-26.04 |
Ubuntu 26.04 server cloud |
fleetboxtest.Start(t, "debian-12")
fleetboxtest.Start(t, "https://example.com/my-cloud-image.qcow2")Each alias is pinned to a dated upstream snapshot and verified by SHA256 on download, so
a given alias boots the same image on every machine and every run. A direct URL is taken
as-is (unverified) — the escape hatch for a custom or bleeding-edge image. Images are
downloaded and cached once in ~/.fleetbox/images/, with qcow2 converted to raw on the
way in. Adding a distro is adding a catalog entry; there are no per-distro code paths.
Start is a short pipeline: make sure the SSH keypair exists, download and cache the
image, write a cloud-init seed ISO, boot the VM, wait for SSH to answer. The
platform-specific step is the IP. On macOS the VM's address is read out of
/var/db/dhcpd_leases, looked up by hostname; on Linux fleetbox assigns a static
address on the bridge and hands it to the guest through cloud-init.
Where that pipeline runs is the part worth knowing. On Linux it runs in your process. On
macOS it runs inside the downloaded fleetbox-helper, the only binary that links
Virtualization.framework. The library spawns the helper bound to the test process, so
the helper and its VMs are reaped when the test exits, even on kill -9. The CLI does
the opposite: each up group (a single VM, or a cluster sharing one network) gets a
detached holder that outlives the command, which is what keeps VMs running between
invocations. On macOS that holder is the helper; on Linux it's the CLI re-exec'd. Either
way, SSH and cp dial the VM's IP directly — the helper protocol never proxies them.
For the full picture, read ARCHITECTURE.md; for why it's built this way, the decision log lives in docs/adr/.
- Fixtures are read-only. There is no live read-write share: edits inside the guest
don't flow back to the host, and host-side edits only show up after a reboot. The
exact semantics are under Handing a VM a directory; for
the guest→host direction, use
cp/ scp. - A CLI cluster is one process. Members share one holder so they can share one
network, so a holder crash takes the whole cluster down (a lone VM is unaffected). On
Linux a SIGKILL'd holder also leaves its bridge and taps behind; the next
upordownsweeps them. Members started by separateupcommands sit on separate networks and can't be merged afterwards — bring a cluster up together. - First run downloads, then caches. The cloud image (both platforms) and, on macOS,
the signed helper are fetched once into
~/.fleetboxand reused.FLEETBOX_HELPERswaps in a locally built helper (development, offline). In CI, cache~/.fleetbox/{bin,images}so cold runs don't re-download. - Platform matrix. Clusters need macOS Apple Silicon 26+ or Linux amd64/arm64;
macOS below 26 runs a single VM; Intel macOS is unsupported. On Linux, a stopped VM
brought back up needs its
/24to still be free — on a contended host the auto-picked subnet can shift and the rebooted VM won't be reachable; bring clusters up fresh. arm64 Linux boot via rust-hypervisor-firmware is not yet validated on hardware. - v0 API. Expect breaking changes until it stabilizes.
GitHub-hosted macOS runners can't nest virtualization, so the macOS workflow does lint,
build, and unit tests; VM-boot tests run locally via make test-vm. GitHub-hosted
x86-64 Linux runners, on the other hand, expose /dev/kvm, so the Linux backend's
VM-boot tests run in CI after a one-time udev tweak (plus a cache, so the image and VMM
aren't re-downloaded every run):
- run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666"' | sudo tee /etc/udev/rules.d/99-kvm.rules
sudo udevadm control --reload-rules && sudo udevadm trigger
- uses: actions/cache@v4
with:
path: |
~/.fleetbox/bin
~/.fleetbox/images
key: fleetbox-${{ runner.os }}arm64 hosted Linux runners do not have KVM ("not supported for this sku"); use an x86-64 runner for VM-boot CI. This is the "develop on a Mac, test in cheap x86-64 hosted Linux CI" story.
Roughly in priority order:
- Programmatic file copy — a library-side copy in/out for cases a fixture doesn't
fit (the CLI already has
cpover scp). - Preserve host permissions in fixtures (they currently arrive world-readable, uid 0).
Recently landed: read-only host→guest fixtures (WithFixture / --fixture, identical
on macOS and Linux), VM-to-VM networking over a real network (vmnet SharedMode), and CLI
clustering (fleetbox up node -n 3) — so a kubeadm cluster, an etcd quorum, or a Raft
group runs on real interconnected nodes, not mocks.
MIT.