Local qemu VMs + tap/bridge networking for recipe testing
On Fri, 25 Sep 2026, by @lucasdicioccio, 1470 words, 5 code snippets, 0 links, 0images.
Generated from specs/qemu-test-vms.md — the repository is the canonical source, and may be ahead of this page.
Local qemu VMs + tap/bridge networking for recipe testing
Status: phases 1 to 5 of the phased plan are implemented:
Salmon.Builtin.Nodes.LinuxBridge (bridge/tap), Debootstrap.ensureVm9pBoot
(9p root, -kernel/-initrd), Salmon.Builtin.Nodes.Qemu (a VM as a
systemdService), and the Layer 3 tier in Test.Harness (withVm/withVmAt,
Test.QemuSmokeSpec), whose first real recipe test is
Test.PostgresReplicationSpec. The first “Future work” item shipped too:
Test.PostgresSwitchoverSpec and Test.PgPairDemoSpec run several VMs on
one bridge. Not done: phase 6 (a raw disk image) and snapshot/clone support.
How it was built, and what departed from this design, is in
specs/qemu-test-vms-progress.md. Kept as the design record.
Problem
salmon-ops-recipes/test/Test/Harness.hs already tiers tests by IO cost/
blast-radius, and its Layer 2 (podmanTrack/withContainer) dogfoods
Podman.pullImage/Podman.runContainer to get a real, disposable sandbox
for recipes that need a real service (Postgres, in Test.PostgresInitSpec).
That works well for anything that fits in a container, but several things
this project increasingly needs to test do not:
- systemd units as PID 1 actually manages them (
Systemd.hsis used all oversalmon-ops/SreBox, but a container’s systemd, if present at all, behaves differently from a real boot), - real network interfaces/routing (
WireGuard.hs,Routes.hs,Netfilter.hs), where a podman container’s network namespace doesn’t exercise the same code paths as a genuine host interface, - multi-machine topologies where “machine” needs to mean something closer
to a real boot (kernel, init, network stack) — exactly what the [[pg-ha
control plane spec]] (
specs/pg-ha-control-plane.md) needs to test the diagonal replication pair, bouncer failover, etc. against something more realistic than two podman containers.
There’s an unfinished start at this: Debian.Debootstrap.rootTree builds a
debootstrapped root filesystem at a path (idempotent via skipIfFileExists
on etc/issue) but nothing turns that into something bootable, and there’s
no qemu or bridge/tap networking builtin at all — Netfilter.hs literally
has -- TODO: Ip, Ip6, Arp, Bridge, NetDev and stops there.
Goal
A Layer 3 addition to the same test-harness tiering: real qemu VMs, on a local bridge, reachable over SSH, disposable the same way Layer 2’s podman containers are — dogfooding new salmon builtins (bridge/tap setup, qemu VM lifecycle) as the sandbox provisioner, same philosophy as Layer 2.
Design goals / non-goals
Goals:
- New builtins follow every existing convention:
op/Track'-shaped, idempotentup(see CLAUDE.md’s “Conventions for node authors”),downimplemented (this project’sdownTreemachinery assumes it, and disposable-sandbox teardown is the whole point here). - Reuse
Systemd.hsfor the VM process lifecycle instead of inventing a new “manage a long-running process” mechanism — a qemu VM is just another systemd unit from the host’s point of view (see §2). - Reuse
Ssh.hsfor reaching into a running VM, exactly like Layer 2 usespodman exec. - Finish
Debootstrapenough to produce something qemu can boot, without inventing a whole image-building subsystem.
Non-goals (v1):
- Production VM hosting (this is a test sandbox mechanism, not a new “salmon runs your workload in a VM” feature — no live migration, no resize, no snapshots).
- libvirt/virsh — plain
qemu-system-x86_64+ a monitor socket is enough for scripted start/stop; libvirt’s XML/daemon layer adds nothing v1 needs. - Multi-host bridging (VXLAN, etc.) — a single-host Linux bridge is enough for “several VMs on one test machine talk to each other and to podman containers if needed.”
Proposed builtins
1. Salmon.Builtin.Nodes.LinuxBridge (new)
The bridge + tap primitives Netfilter.hs’s TODO never got to:
data Bridge = Bridge { bridge_name :: Text }
data Tap = Tap { tap_name :: Text, tap_bridge :: Bridge, tap_owner :: Maybe User.User }
bridge :: Reporter Report -> Track' (Binary "ip") -> Bridge -> Op
tap :: Reporter Report -> Track' (Binary "ip") -> Tap -> Opup: ip link add name <br> type bridge && ip link set <br> up;
ip tuntap add dev <tap> mode tap [user <owner>] && ip link set <tap> master <br> && ip link set <tap> up. Neither ip link add nor ip tuntap add is
idempotent (both fail with “File exists” on retry) — same shape as
Netfilter.rule’s problem, so use the same fix already established as this
project’s convention: prelim checks ip link show <name> and reports
Skippable if it’s already there, rather than trying to force the ip
invocation itself to be idempotent. down: ip link delete <name>.
2. Salmon.Builtin.Nodes.Qemu (new)
A VM as a systemd unit, mirroring Nginx.setup/PgBouncer.setup’s exact
shape (render a start command, hand it to Systemd.systemdService):
data VmConfig
= VmConfig
{ vm_name :: Text
, vm_memory_mb :: Int
, vm_smp :: Int
, vm_disk :: FilePath -- see §3, the boot image
, vm_tap :: LinuxBridge.Tap -- depends on §1
, vm_mac :: Text -- stable MAC so the host can predict/reserve a DHCP lease if needed
, vm_monitor_socket :: FilePath
, vm_extra_args :: [Text]
}
setup :: Reporter Systemd.Report -> Track' (Binary "systemctl") -> Track' (Binary "qemu-system-x86_64") -> VmConfig -> Opup/down are exactly “start/stop the systemd unit” (free, via
Systemd.hs) — no new process-management code. Command line:
qemu-system-x86_64 -name <vm_name> -m <memory_mb> -smp <smp>
-drive file=<disk>,if=virtio,format=raw
-netdev tap,id=net0,ifname=<tap>,script=no,downscript=no
-device virtio-net-pci,netdev=net0,mac=<mac>
-monitor unix:<monitor_socket>,server,nowait
-nographic -serial mon:stdio
-enable-kvm -- if /dev/kvm exists; fall back to TCG otherwise (slow but portable, worth keeping as a fallback for CI boxes without nested virt)
Graceful shutdown on down ideally goes through the monitor socket
(system_powerdown) rather than systemctl stop sending SIGTERM straight
to qemu — worth a small Qemu.shutdown helper that writes to the monitor
socket and polls for the process to exit before falling back to a hard
stop, so the guest gets a real ACPI shutdown instead of losing an in-flight
up/write. Exact mechanism (raw socket write vs qemu-system-x86_64’s own
-monitor command tooling, if any exists on the host) is an implementation
detail to work out against a real qemu version, not a design blocker.
3. Finishing Debootstrap: from chroot dir to bootable disk
Two options, both worth having eventually but starting with the first:
a. 9p virtfs passthrough (recommended v1 default) — skip image-building
entirely; boot the existing RootTree directory straight off the host
filesystem via qemu’s virtfs:
-fsdev local,id=root,path=<rootTree.path>,security_model=mapped
-device virtio-9p-pci,fsdev=root,mount_tag=/dev/root
-kernel <rootTree.path>/boot/vmlinuz-* -initrd <rootTree.path>/boot/initrd.img-*
-append "root=/dev/root rootfstype=9p rootflags=trans=virtio rw console=ttyS0"
No mkfs/loop-mount step, no separate image artifact to keep in sync with
the chroot, fast to rebuild (debootstrap again just overwrites the dir,
same idempotency the node already has). Tradeoff: 9p root is nonstandard
enough that a few recipes’ assumptions (real block device semantics,
fsync behavior a Postgres data directory cares about) might not transfer
1:1 to production behavior — acceptable for “does the recipe’s up/down/
check logic run correctly,” not for filesystem-performance testing.
b. Raw disk image (future work, if 9p’s divergence bites) — extend
Debootstrap with a variant that targets a loop-mounted .raw/.img file
instead of a plain directory (losetup, mkfs.ext4, mount, run
debootstrap against the mountpoint, install a bootloader or keep using
direct -kernel/-initrd boot to skip GRUB entirely), producing a real
block-device-backed VM. More moving parts (loop device idempotency/cleanup
needs its own care — a stale loop device from a crashed previous run is
exactly the kind of thing down needs to handle), so deferred until 9p
proves insufficient.
Either way, RootTree.includes needs to grow to cover what a bootable VM
needs that a plain chroot doesn’t: a kernel package (linux-image-<arch>),
openssh-server (so §4’s SSH-based test harness can reach in), and enough
of an init to reach multi-user (Debian’s default systemd-sysv — already
implied by debootstrap unless --variant=minbase was used, worth
confirming RootTree isn’t passing that).
4. Test harness: Layer 3
-- Test.Harness additions, mirroring podmanTrack/withContainer/podmanExec_
qemuTrack :: Track' (Binary "qemu-system-x86_64")
withVm :: VmConfig -> (Ssh.Remote -> IO a) -> IO a -- boots, polls SSH readiness, runs action, tears down
vmExec_ :: Ssh.Remote -> Text -> IO ()withVm runs Qemu.setup’s Op through runUp/runDown exactly like
withContainer does for podman — real IO, no mocking, dogfooding the new
builtins as their own test infrastructure. “Boot readiness” is poll-SSH-
until-it-answers (a VM takes real seconds to boot, unlike a podman
container being “up”), with a timeout that fails loudly rather than hanging
a test suite — same “skip loudly, don’t hang” spirit as requireExecutable.
requireExecutable "qemu-system-x86_64" (already-generic) gates the whole
tier the same way Layer 2 gates on podman, so a machine without qemu
skips these tests instead of failing.
Open questions
- KVM availability in CI/dev containers: nested virtualization may not
be available everywhere this test suite runs.
-enable-kvmneeds a/dev/kvm-exists fallback to TCG (§2 already notes this) — worth confirming up front whether TCG boot times are tolerable for a test suite before committing to “VMs boot fast enough to be a normal test tier” as an assumption. - Bridge lifecycle scope: one shared bridge reused across test runs
(persistent, created once, VMs’ taps attach/detach per test), or a fresh
bridge per test run (fully disposable, more
sudo ip-shaped setup/teardown noise per test)? Leaning towards one persistent bridge (named distinctly, e.g.salmontest0) with per-test taps, mirroring how Layer 2 doesn’t recreate podman’s network each test either. - Privilege:
ip link add/tuntap addand (for KVM)/dev/kvmaccess typically need root or specific capabilities/group membership (CAP_NET_ADMIN, thekvmgroup). Does the test harness assume the invoking user already has these (documented prerequisite, same aspodmanneeding to be installed/usable), or does it need a sudo-wrapped path? Recommend the former (documented prerequisite) to match how Layer 2 already assumes a working, usablepodmanrather than trying to grant privileges itself. - Where do prebuilt kernel/initrd come from:
debootstrapinstalls/boot/vmlinuz-*/initrd.img-*only if a kernel package is inincludes(§3) — confirm the target suite’s kernel package name (linux-image-amd64on Debian stable) and thatupdate-initramfsruns automatically as part of package postinst inside the chroot (it should, via the chroot’s own dpkg triggers) rather than needing an explicit step.
Phased plan
LinuxBridge(§1): bridge + tap nodes, prelim-based idempotency,down. Test by hand (ip link show) before anything qemu-shaped depends on it.- Extend
Debootstrap’sincludes/confirm kernel+ssh presence (§3a); by hand, boot the resulting chroot directly with a one-off qemu command line (no salmonOpyet) to validate the 9p+-kernelapproach works at all before wrapping it in a node. Qemunode (§2) wrapping the now-validated command line as aSystemd.systemdService, against the §1 bridge.Test.HarnessLayer 3 (§4):withVm, SSH-readiness polling, one smoke test that boots a VM and runs a trivial command over SSH.- Pick one existing recipe that Layer 2 can’t exercise well (a
Systemd- orWireGuard-dependent one) and add its first Layer 3 test, proving the tier end to end. - Raw disk image variant (§3b), only if 9p’s divergence from a real block device turns out to matter for something concrete.
Future work
- Multi-VM topologies on the same bridge for testing the [[pg-ha control plane]]’s diagonal replication pair against two real VMs instead of two podman containers, once Layer 3 itself is proven out.
- Snapshot/clone support (qemu
-snapshotor backing-file qcow2 images) to make repeated test runs cheaper once the raw-image variant (§3b) exists.