qemu: build the devbox VM as a self-contained repart image - #8
Merged
Conversation
gotha
marked this pull request as ready for review
August 30, 2026 21:38
run-qemu.sh wanted a raw nixos.img and a prebuilt OVMF, and pointed at .#packages.x86_64-linux.hala, which has not existed for a while. Replace it with `nix run .#devbox-qemu`, which builds the image and boots it. The image no longer comes from system.build.images.qemu. That, images.raw and the systemImage behind virtualisation.useBootLoader all route through nixos/lib/make-disk-image.nix, which copies the closure with cptofs (the lkl ext4 tool). cptofs spins on the devbox closure - 29.2 GiB, ~2500 paths - and burned 47 minutes of user CPU without a single syscall before being killed. It is not slow, it does not finish. systemd-repart writes the filesystem directly and gets there in minutes. virtualisation.useNixStoreImage was the other candidate and is not portable: nixpkgs documents its store image as built just-in-time at VM start and never cached, so it still needs the host store to boot. The repart image carries its own store on its own partition - no 9p mount, verified - so the .raw boots on any host with a UEFI-capable qemu. repart copies store paths but never populates the Nix database, which is what makes the nixpkgs example an "appliance" image. Left alone it broke home-manager: activation calls nix-store --realise on its own generation, that fails on a path that is physically present but unregistered, and activation aborts before writing ~/.config - so sway came up with its stock config, no wallpaper and no dotfiles. Register the closure on first boot the way the sd-card and ISO images do, which also gives a system profile and /etc/NIXOS so nixos-rebuild works inside the guest. The runner backs a writable qcow2 onto the image rather than copying 47G, and checks the overlay's backing file: an overlay from an older image cannot boot and, if it predates this setup, dies in OVMF as "BdsDxe: failed to load Boot0002 ... Not Found". DEVBOX_QEMU_DISPLAY and QEMU_OPTS make it runnable headless, which is the only way to see the boot at all. Also drop deploy-devbox-qemu: it targeted host devbox.qemu, which resolves nowhere. Entire-Checkpoint: 56c30a00955f
nix run .#devbox-qemu only ever existed for x86_64-linux and hardcoded -enable-kvm, so there was no way to boot a devbox VM from a Mac. Add an aarch64 image and darwin runners for it. distros/devbox-arm is devbox with what nixpkgs cannot build for aarch64-linux taken out. The user package list is filtered on meta.platforms rather than by name - a hardcoded list goes stale the moment someone adds a package to devbox - which drops spotify, slack and zoom-us. Steam has to be named explicitly: no package list mentions it, but its module switches on hardware.graphics.enable32Bit, which asserts on any non-x86_64 system. The two runners differ only in the qemu binary, the firmware and the machine flags, so both now come from one mkDevboxQemuApp template rather than each carrying a copy of the overlay logic. That also replaces install -Dm600, which is a GNU extension the install(1) in macOS base does not have. On Apple silicon -machine virt,accel=hvf -cpu host is real hardware virtualisation, so the ARM guest runs at native speed instead of emulating x86 under TCG - the whole reason for a second image. qemu ships the ARM edk2 blobs itself, both already padded to the 64M the virt machine's pflash wants, so there is no separate OVMF to chase on darwin. virt has no VGA, hence virtio-gpu-pci; there is no GPU passthrough either way, so sway lands on llvmpipe. devbox-qemu-x86 boots the x86_64 image under TCG. It is slow - fine over ssh, rough for sway - and exists because a Mac cannot build a Linux image at all, so the .raw has to be copied in from a Linux builder. Its imageFile is a plain path rather than the store path for that same reason: the store path would make the image a build input of the runner script, and nix run would try to build it and fail before ever reaching qemu. DEVBOX_QEMU_IMAGE overrides it. The backing path is made absolute because qcow2 resolves a relative one against the qcow2's own directory, not the working directory. The ARM image also drops crush and codex, and graphite-cli's shell completions. graphite-cli wraps its prebuilt binary in a buildFHSEnv and then runs gt under bubblewrap to generate those completions; bubblewrap needs a user namespace, which qemu-user cannot create, so the step fails whenever the image is built on an emulating x86_64 builder. crush and codex are compiles with nothing cached for aarch64-linux. The x86_64 image derivation is unchanged by all of this.
Two unrelated blockers, both only visible when the image is built through Determinate Nix's external Linux builder rather than on a Linux host. mpd: NixOS validates the generated mpd.conf by running mpd under expect(1), which needs a pty. That builder's /dev is a bare devtmpfs with no devpts mounted and builds run unprivileged, so expect dies with "The system has no more ptys" and takes the whole system closure down with it. The module has no knob to skip the check and a derivation cannot mount devpts for itself, so the only way past it is to not generate an mpd.conf. No loss either way: none of the devbox-qemu runners attach a sound device, so the guest has no audio hardware and mpd's pipewire output has nowhere to play. repart: systemd-repart writes the .raw into the build directory and, with Minimize = "guess", first stages a full copy of the closure under $TMPDIR just to measure the smallest partition that fits. Both land on the 3.9 GiB tmpfs that builder hands a build - half the VM's 8 GiB of RAM - against a 25.4 GiB closure, so it fails with Failed to copy '/nix/store/...' to '/build/.#repart...': No space left on device and even that is swallowed by the `| tee` in the upstream buildPhase, leaving a build that fails with three blank lines. Point both the scratch tree and the image at $out, which is on the real store. No-op on a Linux builder, where the build directory is disk-backed anyway.
The virt machine has no PS/2 controller. x86 q35 gets a keyboard and mouse for free from the emulated i8042, which is why the x86 runner never showed this, but virt gets nothing and the command line attached a GPU and no input devices at all. The greeter never saw a keystroke; the cursor being captured was qemu's host-side window grab, which the guest never sees. USB HID rather than virtio-input because the guest kernel carries usbcore, usbhid, hid-generic, xhci-hcd and xhci-pci in modules.builtin, while virtio_input ships only as a loadable module - built-in means the greeter has a keyboard with no module load in the path. usb-tablet reports absolute coordinates, so the pointer follows the host cursor instead of needing a grab.
The variable store remembers Boot#### entries by device path, so adding a PCI device shifts the enumeration and leaves entries that no longer resolve. The firmware then falls through to BdsDxe: loading Boot0002 "EFI Internal Shell" which reads as "the OS will not boot any more" even though the image is fine - adding the USB controller in the previous commit did exactly that. The runner only refreshed the varstore when it recreated the disk, and the image had not changed, so the stale one survived. Stamp the hashed command line next to the disk and discard the varstore whenever it no longer matches. The hash covers qemu's store path too, so a version bump counts as a change. The disk, and so the guest's state, is left alone: the ESP carries a fallback \EFI\BOOT\BOOT*.EFI, so a blank varstore boots on its own.
virtio-gpu-pci defaults to 1280x800, which is a cramped desktop. xres/yres set the EDID the guest reads, so they pick the mode sway actually comes up in rather than just widening the list on offer; DEVBOX_QEMU_XRES/YRES override. Verified over ssh into the guest: /sys/class/drm reports 1920x1080 first. full-grab installs a global event tap so system combos reach the guest rather than the host. Without it alt-1 is swallowed by aerospace, which binds it to 'workspace 1' in home-manager/aerospace/aerospace.toml, and sway inside the VM never sees the key. Left as part of the DEVBOX_QEMU_DISPLAY default so DEVBOX_QEMU_DISPLAY=cocoa turns the grab back off without editing anything. The grab is unverified: macOS only honours the tap once qemu holds Accessibility permission, which is granted per binary path and so needs re-granting whenever the qemu store path changes, and whether it wins against aerospace depends on which event tap sits earlier in the chain.
Setting xres/yres cost the dynamic resize. Without them cocoa's windowDidResize feeds dpy_set_ui_info into virtio_gpu_ui_info, which rewrites req_state and regenerates the EDID, so the guest follows the window and fills the screen on fullscreen. With them the guest stayed at a fixed 1080p, centred in a black frame - worse than the 1280x800 default it was meant to fix, since that at least grew to fit. Reverts the resolution half of 7af07cc; the full-grab half stays.
gotha
force-pushed
the
qemu-vm-repart-image
branch
from
August 31, 2026 17:48
1f08e0a to
b32bae7
Compare
The image ships no credentials, so a freshly booted VM can neither clone over ssh nor decrypt any sops secret, and neither failure says what is actually wrong. The zsh plugin clones fail on `Permission denied (publickey)` because home-manager/git rewrites the https URLs in .zshrc to git@github.com:, and sops-nix reports "0 successful groups required, got 0", which reads as a missing key. Write down both, and in particular the part that is easy to lose an hour to: home-manager creates ~/.gnupg at 755 to place its gpg-agent.conf symlink, and gpg will not use a keyring in a world-readable homedir - so after importing the key sops keeps failing with the identical error until the mode is fixed.
gotha
force-pushed
the
qemu-vm-repart-image
branch
from
August 31, 2026 18:09
b32bae7 to
80bb756
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces
run-qemu.shwithnix run .#devbox-qemu, which builds a portabledisk image and boots it.
run-qemu.shhad drifted: it wanted a rawnixos.imgand a prebuilt OVMF, andits error message pointed at
.#packages.x86_64-linux.hala, which no longerexists.
Why not the stock image path
system.build.images.qemu,images.rawand thesystemImagebehindvirtualisation.useBootLoaderall route throughnixos/lib/make-disk-image.nix,which copies the closure with
cptofs(the lkl ext4 tool). On the devboxclosure — 29.2 GiB, ~2500 paths —
cptofsstalls:rchar/wchar/syscr/syscwZero syscalls while burning 47 minutes of userspace CPU. It is not slow, it
does not finish.
make-disk-image.nixhardcodescptofswith no way to swapthe copy mechanism, so every stock route is blocked.
virtualisation.useNixStoreImageavoidscptofs(it uses erofs) but is notportable — nixpkgs documents its store image as built just-in-time at VM start
and "never lands in the store or binary cache", so the host store is still
needed at boot.
systemd-repartwrites the filesystem directly and completes in minutes.Portability
Verified from inside the booted guest:
The store is a real partition, there are no 9p mounts, and the qemu command
line has no
-virtfs. The.rawboots on any host with a UEFI-capable qemu.Nix database registration
repart.nixcopies store paths but never runsnix-store --load-db— this iswhat makes the nixpkgs example an "appliance" image that cannot be
nixos-rebuild-ed. Left alone it broke home-manager:fails on a path that is physically present but unregistered, activation aborts
before writing
~/.config, and sway comes up with its stock config — nowallpaper, no dotfiles.
os/linux/repart-image.nixships the closure'sregistrationfile and loads itin
boot.postBootCommands, assd-image.nixandiso-image.nixdo. That runsin stage-2 init before systemd, so it precedes
home-manager-<user>.service. Italso creates
/nix/var/nix/profiles/systemand/etc/NIXOS, sonixos-rebuildworks inside the guest.
After the fix, in the guest:
home-manager-gotha.servicenix-store --realisesystem-1-link~/.config/sway/config…-hm_swayconfigRunner details
an older image cannot boot, and one predating this setup has no ESP at all,
which surfaces in OVMF as
BdsDxe: failed to load Boot0002 ... Not Found.Note this discards VM state — unavoidable, since a qcow2 overlay references
blocks of its specific backing image.
DEVBOX_QEMU_DISPLAYandQEMU_OPTSallow headless runs with a serialconsole.
Also drops
deploy-devbox-qemu, which targeted hostdevbox.qemu— not in~/.ssh/configor anywhere in the repo.Running on Apple silicon
nix run .#devbox-qemuon a Mac boots an ARM build of the same devbox via-machine virt,accel=hvf -cpu host, so the guest runs at native speed ratherthan emulating x86 under TCG. The image comes from
distros/devbox-arm, whichfilters out everything nixpkgs has no aarch64-linux build for.
Getting that to build and boot on darwin turned up four separate blockers, each
its own commit.
mpd.conf needs a pty the builder does not have
NixOS validates the generated
mpd.confby running mpd underexpect(1).Determinate Nix's external Linux builder gives a build a
/devthat is a baredevtmpfs with no devpts mounted, and builds run unprivileged, so expect dies
with
The system has no more ptysand takes the whole system closure with it.There is no option to skip the check and a derivation cannot mount devpts for
itself, so the images disable mpd — no loss, since no runner attaches a sound
device and the guest has no audio hardware to play to.
repart needs more scratch than the builder's tmpfs
systemd-repartwrites the.rawinto the build directory, and withMinimize = "guess"it first stages a full copy of the closure under$TMPDIRjust to measure the smallest partition that fits. Both land on the 3.9 GiB
tmpfs that builder hands a build — half the VM's 8 GiB of RAM — against a
25.4 GiB closure:
The upstream
buildPhasepipes through| tee, which swallows that message, sothe build just fails with three blank lines.
repartImageOnOutinflake.nixpoints both the scratch tree and the image at
$out. Result:devbox_1.rawBOOTAA64.EFIpresentroot-arm64No-op on a Linux builder, where the build directory is disk-backed anyway.
virt has no PS/2, so the VM had no keyboard
x86
q35gets a keyboard and mouse free from the emulated i8042;virtgetsnothing, and the command line attached a GPU and no input devices at all. The
greeter never saw a keystroke — the captured cursor was qemu's host-side window
grab, which the guest never sees.
USB HID rather than virtio-input because the guest kernel carries
usbcore,usbhid,hid-generic,xhci-hcdandxhci-pciinmodules.builtin, whilevirtio_inputships only as a loadable module.usb-tabletreports absolutecoordinates, so the pointer follows the host cursor without a grab.
Adding that controller then broke boot
The EFI variable store records
Boot####entries by device path, so adding aPCI device shifts enumeration and the stored entry stops resolving. The firmware
falls through to
BdsDxe: loading Boot0002 "EFI Internal Shell", which reads asa dead image. Confirmed by booting the same image twice off throwaway copies,
varying only the varstore:
edk2-arm-vars.fdnixos login:Shell>The runner now stamps a hash of the qemu command line next to the disk and
discards the varstore when it no longer matches. The hash covers qemu's store
path, so a version bump counts too. The disk — and the guest's state — is left
alone: the ESP carries a fallback
\EFI\BOOT\BOOTAA64.EFI, so a blankvarstore boots unaided.
Host key capture
-display cocoa,full-grab=oninstalls a global event tap so combos likealt-1reach sway instead of being eaten by aerospace.DEVBOX_QEMU_DISPLAYoverrides the whole string, so
DEVBOX_QEMU_DISPLAY=cocoaturns it back off.Unverified: macOS only honours the tap once qemu holds Accessibility
permission, which is granted per binary path and so needs re-granting whenever
the qemu store path changes; and whether it beats aerospace depends on which
event tap sits earlier in the chain.
Known issue: virtio-gpu crashes the host qemu
Not fixed in this branch. qemu segfaults on the host while processing a GPU
command:
41 seconds after launch, on the
qemu_mainthread. It is a host-side qemu bug,not the image.
bochs-displayavoids the code path entirely and bindsbochs-drmcleanly with 1920x1080 available, but exposes norenderD128rendernode — and since sway already runs as
--unsupported-gpu, it would likely needWLR_RENDERER=pixmanto start at all. Untested, hence not swapped in.Setting
xres/yreson virtio-gpu was tried and reverted: it pinned the guestat a fixed 1080p centred in a black frame, losing the dynamic resize that makes
the guest follow the window on fullscreen.
Open points
bochs-displayis the candidatefix but needs sway verified on it first.
Minimize = "guess"walks the closure twice — once to measure, once for real— which is most of the 42-minute build. An explicit
SizeMinByteswouldroughly halve it at the cost of a hardcoded size.
Minimize = "guess"leaves slack.Minimize = "best"would size it near-exactly, more slowly still.
megapacks, rustc, openjdk and qemu itself. A slimmer VM variant would shrink
the image a lot.
bae-qemustill usesimages.qemuand will hit the samecptofsstall if itsclosure is comparable.
hosts/qemu1/qemu-no-audio.nixis imported directly bymkDevboxQemuImagerather than living in
os/linux/next tovirtio.nixandrepart-image.nix.hosts/qemu1/default.nixitself is not part of the image — repartmkForcesits grub and by-label root away — so the placement is worth a second look.
are lost whenever the overlay is recreated. The gpg key decrypts everything
under
secrets/, not just what the devbox reads.