ホーム
building

stoat

Running a throwaway VM on Linux means libvirt, a daemon, and XML, or it means a QEMU command line you re-derive from your shell history every time. stoat is one binary instead.

stoat — ~/.stoat
.stoat stoat ls
NAME MODE STATE CPUS RAM SSH
alpine-live live running 2 2048 2200
ubuntu-dev cloud running 4 4096 2201
arch-scratch disk stopped 2 4096 2202
.stoat stoat ssh alpine-live
Warning: Permanently added '[127.0.0.1]:2200' (ED25519) to known hosts.
Welcome to Alpine!
localhost:~#

I wanted a scratch VM I could break and throw away, and every option asked more of me than the job was worth. libvirt drags in a daemon and a permissions model and XML. Vagrant wants Ruby and a box registry. Raw QEMU works but I kept re-deriving the same wall of flags from shell history every single time.

So stoat is one static binary instead. No daemon, no config language to learn. It drives QEMU and then gets out of the way.

Alpine is what made it click. It boots in about a second, which is exactly what a throwaway VM should do. The catch is that a fresh Alpine drops you at a login prompt with no network and no sshd until you run setup-alpine by hand.

I skip that step. stoat builds an apkovl overlay at every boot and hands it to the guest as a fake FAT disk over -virtfs/vvfat. The overlay carries stoat's own ed25519 key, so root@127.0.0.1 answers the moment sshd comes up. No password, no key copying. The host key survives rebuilds, so your SSH client never complains about a changed fingerprint.

The overlay gets rebuilt from scratch on every start, which makes live VMs genuinely disposable. Nothing you do inside one survives a stop, and that is the point.

stoat — ~/.stoat
.stoat stoat ls
NAME MODE STATE SSH
alpine-live live running 2200
ubuntu-dev cloud running 2201
arch-box disk stopped 2202
old-vm broken toml: line 1: expected '='

A VM is one of three kinds, and the kind decides how it gets provisioned. A live VM is the Alpine trick above: diskless, rebuilt every start, gone when you stop it. A cloud VM takes an Ubuntu, Debian, Fedora, or Arch image and lays a copy-on-write overlay over one shared base, so ten Ubuntu VMs share a single download and a few megabytes of delta each. It also drops a cloud-init seed that runs once, on first boot. A disk VM is a plain persistent qcow2 for everything else.

The disk kind is the one that trips people. It starts empty. Until you install the guest yourself and add stoat's key, there is nothing on the far end of an SSH connection. Rather than let you sit through a connect timeout, stoat checks first and tells you. Once the OS is in you press i, it flips installed in the vm.toml, and the VM boots straight off the disk instead of the installer.

Recipes for XFCE, Docker, dev tools, or Tailscale ride on top of whichever path applies. They get pushed over ssh for live and disk VMs, and merged into the cloud-init seed for cloud ones. Provisioning a cloud VM twice is a deliberate no-op, because the seed already ran and rebuilding it would throw away real guest state.

stoat — provision
.stoat stoat provision ubuntu-dev --recipe docker
==> pushing recipe: docker
+ apt-get update
+ install docker-ce docker-compose-plugin
+ systemctl enable --now docker
docker active (running)
done in 41s

stoat launches QEMU with a -pidfile and a unix-socket monitor for a graceful shutdown, then lets go. There is no supervising process. Quit stoat and the VM you started keeps running, because nothing about it depends on stoat being alive.

State is just files. Each VM is a directory under ~/.stoat holding one hand-editable vm.toml, and stoat re-reads it on every operation. No database, no cache to invalidate. Botch an edit and the VM shows up as a broken row instead of vanishing. Even then its recorded port stays reserved, grabbed by a best-effort regex over the unparseable file, so the next VM can't land on top of it.

Day to day I live in the TUI. It lists every VM with its mode and state, and the keys do the obvious things: starts or stops the highlighted VM, s drops into ssh, p provisions, opens details, / filters the list, n builds a new one.

stoat
stoat · 4 vms · ↑↓ to move
alpine-live live running 2c 2048M
ubuntu-dev cloud running 4c 4096M
arch-box disk stopped 2c 4096M
old-vm broken toml: line 1
↵ start/stop · → details · s ssh · p provision · / filter · n new · q quit

The CLI covers the same ground for scripts. ls, up, down, ssh, provision, rm, recipe, logs, and doctor each do one thing, and the exit codes carry weight: 0 for success, 1 for a runtime failure, 2 for a usage mistake. That is enough to drop stoat into a Makefile or a CI job without scraping its output.

stoat — doctor
.stoat stoat doctor
/dev/kvm accessible
qemu-system-x86_64 8.2.1
4 vms tracked · 1 broken
exit 0

The third door is an MCP server, so an agent can drive VMs the way I do. It is a small Python process that shells out to the stoat binary and reads its --json output. It never links the Go package or touches ~/.stoat directly, and the JSON contract is versioned, so a mismatched pair refuses to start instead of failing three calls deep.

It is pre-1.0 and single-user. It assumes it owns its ~/.stoat, and it gives you nothing past what QEMU and KVM already sandbox. The Alpine live path is the most exercised; the cloud-init backends and disk installs are newer, and the vm.toml layout may still move before 1.0. It is AGPL-3.0, so nobody folds it into a closed product.