[PATCH v2 10/10] docs: add manual nixos-flake and qsu driving guide

Daniel Gomez <[email protected]> Fri, 12 Jun 2026 12:55:22 +0200
Newsgroups dev.linux.lists.kdevops
Message-ID <[email protected]>
From: Daniel Gomez <[email protected]>

Document the full bringup pipeline for both nixos-flake backends as
raw commands, so a user can reproduce, debug, or operate a guest
without the Ansible roles. The NIXOSFI path covers the controller
kernel build, per-guest imageless flake render, closure build and
bootspec read, qsu unit and env rendering, and the systemd-machined
start/stop lifecycle; the NIXOSFL path covers the libvirt qcow2 build
and virsh provisioning.

The guide explains that kdevops autogenerates each per-guest flake
from upstream's plain-Nix starter templates, which are not Jinja
ready, by rewriting the nixos-flake input to the vendored subtree and
injecting per-guest values, and that the Jinja mirrors must be
re-synced when the subtree updates. It also shows that qsu templates
render with the Ansible template module under kdevops and with
minijinja-cli when driven standalone, enumerating every unit and env
file qsu needs.

Generated-by: Claude AI
Signed-off-by: Daniel Gomez <[email protected]>
---
 MAINTAINERS                |   1 +
 docs/nixos-flake-manual.md | 327 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 328 insertions(+)

diff --git a/MAINTAINERS b/MAINTAINERS
index f2af8b43..12acaca7 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -150,6 +150,7 @@ S:	Maintained
 T:	git https://github.com/linux-kdevops/kdevops.git
 F:	kconfigs/Kconfig.nixos_flake
 F:	docs/nixos-flake-backends.md
+F:	docs/nixos-flake-manual.md
 F:	docs/nixosfl.md
 F:	playbooks/nixosfl.yml
 F:	playbooks/roles/nixosfl/
diff --git a/docs/nixos-flake-manual.md b/docs/nixos-flake-manual.md
new file mode 100644
index 00000000..b770369b
--- /dev/null
+++ b/docs/nixos-flake-manual.md
@@ -0,0 +1,327 @@
+# Driving nixos-flake and qsu by hand
+
+This is the manual counterpart to the `make` targets. It walks the full
+pipeline command-by-command so you can reproduce, debug, or operate a
+guest without the Ansible roles. Everything here is what the roles run on
+your behalf; nothing is hidden.
+
+Two backends share the nixos-flake library:
+
+- **NIXOSFL** (`nixosfl`) — a NixOS qcow2 built from a flake closure and
+  run under **libvirtd**. The libvirt peer of `guestfs`.
+- **NIXOSFI** (`nixosfi`) — the same declarative NixOS run **imageless**
+  on **systemd-machined** via the **qsu** runtime (`qemu-system@<vm>`
+  user services). No disk image; rootfs is the closure over virtiofs.
+
+Conventions used below: one guest named `nixosfi-dev`, index `0`, so its
+SSH port is `QSU_SSH_PORT_BASE + 0 = 10022` and its vsock CID is
+`QSU_VSOCK_CID_BASE + 0 = 100`. `$CFG` is your `NIXOSFI_CONFIG_DIR` (or
+`NIXOSFL_CONFIG_DIR`); `$TOP` is the kdevops tree.
+
+Nix lives off-`PATH` for non-login shells; prefix it once:
+
+```sh
+export PATH=/nix/var/nix/profiles/default/bin:$PATH
+```
+
+Prerequisites: a flake-enabled Nix (`experimental-features = nix-command
+flakes`); for NIXOSFI also `qemu-system`, `qemu-utils`, `socat`,
+`virtiofsd`, `systemd-container` (`varlinkctl` lives in systemd), and
+`minijinja-cli` (`cargo install minijinja-cli`), plus membership in the
+`kvm` and `systemd-journal` groups. See
+`scripts/qemu-system-units/docs/requirements.md` for the per-distro list.
+
+---
+
+## NIXOSFI: imageless + qsu, end to end
+
+### 1. Build the kernel on the controller
+
+NIXOSFI boots a controller-built kernel directly (`-kernel`), no guest
+copy. `BOOTLINUX_DIRECT_BOOT` builds the tree out-of-tree into a destdir
+on the controller. `$SRC` is the kernel checkout, `$BUILD` the
+out-of-tree build dir (`O=`), `$DEST` the destdir staged for QEMU:
+
+```sh
+# Tree: clone it, or point $SRC at an existing checkout/worktree and skip this
+git clone --branch <ref> <target_linux_git> $SRC
+
+# Configure: stage the resolved .config, then resolve it against the tree
+mkdir --parents $BUILD $DEST/boot
+cp <resolved-.config> $BUILD/.config
+make --directory=$SRC O=$BUILD olddefconfig
+make --directory=$SRC O=$BUILD syncconfig
+
+# The release string the rest of the pipeline keys off, e.g. 7.0.0-rc1+
+make --silent --directory=$SRC O=$BUILD kernelrelease   # -> $REL
+
+# Build with all cores
+make --directory=$SRC O=$BUILD --jobs="$(nproc)"
+
+# Install the image by hand. Avoid `make install`: it execs the distro
+# /sbin/installkernel hook (initramfs + bootloader against the
+# controller's own /boot, often needs root). `make image_name` prints
+# the built image path relative to $SRC, e.g. arch/x86/boot/bzImage.
+cp "$BUILD/$(make --silent --directory=$SRC O=$BUILD image_name)" $DEST/boot/vmlinuz-$REL
+
+# Modules into the destdir; virtiofs serves $DEST/lib/modules to the guest
+make --directory=$SRC O=$BUILD INSTALL_MOD_PATH=$DEST modules_install
+# kernel image:  $DEST/boot/vmlinuz-$REL
+# modules:       $DEST/lib/modules/$REL/
+```
+
+The above is what the `make linux-direct-boot` target open-codes
+(`roles/bootlinux/tasks/build/direct-boot.yml`).
+
+### 2. Render the per-guest flake
+
+One flake directory per guest pins the vendored nixos-flake subtree and
+selects the **imageless** backend (`packages.<system>.toplevel`):
+
+```sh
+mkdir --parents $CFG/nixosfi-dev
+# $CFG/nixosfi-dev/flake.nix   inputs.nixos-flake.url = path:$TOP/scripts/nixos-flake
+#                              modules = [ nixos-flake.nixosModules.backends.imageless ... ]
+# $CFG/nixosfi-dev/default.nix  guest config + SSH authorized key
+```
+
+**What kdevops autogenerates and why.** nixos-flake ships *starter*
+flakes —
+[`scripts/nixos-flake/templates/imageless/{flake,default}.nix`](../scripts/nixos-flake/templates/imageless/flake.nix) —
+meant to be copied with `nix flake init --template`. They are plain Nix,
+not Jinja, and carry a placeholder input
+`nixos-flake.url = "path:/path/to/nixos-flake"`. kdevops cannot use them
+as-is: it has to rewrite that URL to the vendored subtree
+(`path:$TOP/scripts/nixos-flake`) and inject per-guest values (the SSH
+authorized key, the enabled `profiles.*`/`testSuites.*` modules, and one
+`<pkg>-src` input per source override). So kdevops keeps Jinja mirrors of
+the starters at `roles/nixosfi/templates/{flake,default}.nix.j2`, and the
+Ansible `template` module renders one concrete `flake.nix` + `default.nix`
+per guest. **When the subtree is updated, re-sync the `.j2` mirrors
+against the upstream starters above** — they intentionally track them.
+
+Pin the input so an edited subtree is actually picked up (a stale
+`flake.lock` silently reuses the old closure):
+
+```sh
+nix flake update --flake path:$CFG/nixosfi-dev nixos-flake
+```
+
+### 3. Build the closure and read the bootspec
+
+```sh
+nix build path:$CFG/nixosfi-dev#toplevel --out-link $CFG/nixosfi-dev/result
+cat $CFG/nixosfi-dev/result/boot.json        # org.nixos.bootspec.v1 -> .init, .initrd
+```
+
+`init` and `initrd` from `boot.json` are what the guest boots. The
+imageless module builds no bootloader and sets no `boot.kernelParams`, so
+QEMU must supply the command line explicitly: it needs both `root=tmpfs`
+(systemd-fstab-generator turns this into the tmpfs `/sysroot` it
+switch-roots into) and `init=<path>` from the bootspec. Omitting
+`root=tmpfs` leaves the guest with no root to switch to.
+
+### 4. Render the qsu unit + env files
+
+qsu is a set of Jinja templates under
+`$TOP/scripts/qemu-system-units/templates/` — there is no daemon and no
+wrapper. kdevops renders them with the Ansible `template` module (roles
+`qsu/tasks/render-units.yml` and `render-per-vm.yml`). **Outside Ansible,
+qsu's own tooling is `minijinja-cli --trim-blocks`** with a per-VM YAML
+vars file; install it once with `cargo install minijinja-cli`. kdevops
+does *not* use minijinja-cli — it feeds the same `.j2` files the Kconfig
+values resolved into `extra_vars`. Both produce byte-identical output.
+
+There are eight rendered artefacts. Four are host-wide (render once); four
+are per-guest (render once per VM). `nvme.env.j2` is a macro library
+imported by `vm.env.j2`, not a standalone file, so keep it alongside when
+rendering.
+
+Host-wide, deploy once:
+
+```sh
+install --directory ~/.config/systemd/user ~/.config/systemd/qemu-system
+cd $TOP/scripts/qemu-system-units
+minijinja-cli --trim-blocks --output ~/.config/systemd/user/[email protected] \
+  templates/[email protected] vars/nixosfi-dev.yaml
+minijinja-cli --trim-blocks --output ~/.config/systemd/user/[email protected] \
+  templates/[email protected] vars/nixosfi-dev.yaml
+minijinja-cli --trim-blocks --output ~/.config/systemd/user/[email protected] \
+  templates/[email protected] vars/nixosfi-dev.yaml
+cp files/qmp-powerdown ~/.config/systemd/qemu-system/      # static, not rendered
+```
+
+Per-guest, one set per VM. `vm.env` is where every knob lands — the QEMU
+argv, the controller kernel/init/initrd, NVMe drives, virtiofs shares, the
+SSH hostfwd and the vsock CID:
+
+```sh
+# 1. QEMU environment file
+minijinja-cli --trim-blocks --output ~/.config/systemd/qemu-system/nixosfi-dev.env \
+  templates/vm.env.j2 vars/nixosfi-dev.yaml
+# 2. Per-instance service drop-in (vsock CID, Requires= the virtiofsd sockets)
+install --directory ~/.config/systemd/user/[email protected]
+minijinja-cli --trim-blocks \
+  --output ~/.config/systemd/user/[email protected]/override.conf \
+  templates/qemu-system-override.conf.j2 vars/nixosfi-dev.yaml
+# 3. Per-(vm,share) virtiofsd env — one per non-home share (store, modules, ...)
+install --directory ~/.config/systemd/virtiofsd
+minijinja-cli --trim-blocks --define share_tag=store \
+  --output ~/.config/systemd/virtiofsd/nixosfi-dev-store.env \
+  templates/virtiofsd.env.j2 vars/nixosfi-dev.yaml       # VIRTIOFSD_SHARED_DIR=/nix/store
+minijinja-cli --trim-blocks --define share_tag=modules \
+  --output ~/.config/systemd/virtiofsd/nixosfi-dev-modules.env \
+  templates/virtiofsd.env.j2 vars/nixosfi-dev.yaml       # VIRTIOFSD_SHARED_DIR=<destdir>/lib/modules
+# 4. Per-(vm,share) virtiofsd stop-ordering drop-in — one per share
+install --directory ~/.config/systemd/user/[email protected]
+minijinja-cli --trim-blocks \
+  --output ~/.config/systemd/user/[email protected]/override.conf \
+  templates/virtiofsd-override.conf.j2 vars/nixosfi-dev.yaml
+# (repeat the drop-in for -modules and any further shares)
+```
+
+The rendered `vm.env` reads, abbreviated:
+
+```sh
+QEMU_BINARY=qemu-system-x86_64
+QEMU_ARGS="-machine type=q35 -accel kvm -cpu host -m 4096 -smp 4 \
+  -nic user,model=virtio-net-pci-non-transitional,hostfwd=tcp:127.0.0.1:10022-:22 \
+  -device vhost-vsock-pci-non-transitional,guest-cid=100 \
+  -fsdev ... mount_tag=store ...        # /nix/store over virtiofs
+  -device nvme,serial=kdevops0 ...      # one per QSU_NVME drive
+  -nographic -serial mon:stdio"
+KERNEL_ARGS=-kernel <destdir>/boot/vmlinuz-<rel> \
+  -append "root=tmpfs console=ttyS0,115200 console=hvc0 init=<init-from-bootspec>" \
+  -initrd <initrd-from-bootspec>
+```
+
+Finally create the NVMe backing files the `vm.env` drives reference
+(relative to the service `WorkingDirectory`, `%S/qemu-system/<vm>`):
+
+```sh
+install --directory ~/.local/state/qemu-system/nixosfi-dev
+qemu-img create --format qcow2 ~/.local/state/qemu-system/nixosfi-dev/nvme0.qcow2 20G  # x QSU_NVME_DRIVE_COUNT
+```
+
+### 5. Start the guest
+
+`virtiofsd@*.socket` units are socket-activated; QEMU's first connect
+spawns the matching `virtiofsd@*.service`. The `[email protected]`
+`ExecStartPost` registers the guest with machined over Varlink, so it
+shows up in `machinectl`:
+
+```sh
+systemctl --user daemon-reload
+systemctl --user restart [email protected] [email protected]
+systemctl --user start [email protected]
+machinectl --user list                           # nixosfi-dev, class=vm
+ssh -p 10022 [email protected]                      # or: ssh nixosfi-dev (if ~/.ssh/config updated)
+```
+
+### 6. Operate it (qsu cheat sheet)
+
+```sh
+systemctl --user status  [email protected]
+journalctl [email protected] --follow   # serial console in the journal
+machinectl --user status nixosfi-dev
+systemctl --user stop    [email protected]    # graceful: QMP system_powerdown via ExecStop
+systemctl --user restart [email protected]
+```
+
+The qsu services run in user scope and register with the **user**
+machine manager (needs systemd v259+), so `machinectl` needs `--user`
+(like `systemctl --user`) to list or inspect them; without it
+`machinectl` talks to the system manager and shows nothing.
+`machinectl --user terminate <vm>` is the non-graceful kill. Likewise
+`--user-unit=` (not `--user -u`): user-service stdout lands in the
+**system** journal via `[email protected]`.
+
+### 7. Fast kernel iteration
+
+After rebuilding the kernel, re-pin, rebuild the closure against it,
+re-render the env, and restart — the live-run results symlink follows the
+new release automatically:
+
+```sh
+make linux-direct-boot
+nix flake update --flake path:$CFG/nixosfi-dev nixos-flake
+nix build path:$CFG/nixosfi-dev#toplevel --out-link $CFG/nixosfi-dev/result
+# re-render nixosfi-dev.env with the new kernel/init/initrd, then:
+systemctl --user restart [email protected]
+```
+
+The `make` wrapper for all of step 7 is `make nixosfi-rebuild-boot`.
+
+### 8. Tear down
+
+```sh
+systemctl --user stop [email protected]
+rm --recursive --force ~/.local/state/qemu-system/nixosfi-dev   # NVMe qcow2 + ephemeral config
+rm --force ~/.config/systemd/qemu-system/nixosfi-dev.env
+```
+
+---
+
+## NIXOSFL: imageless's libvirt peer
+
+Same flake library, **libvirt** backend (`backends.libvirt`), output is a
+qcow2 (`packages.<system>.image`) run under libvirtd. No kernel build, no
+qsu — the guest is self-contained.
+
+```sh
+# 1. Render the per-guest flake (libvirt variant) into $CFG/nixosfl-dev/
+#    from roles/nixosfl/templates/{flake,default}.nix.j2
+
+# 2. Build the disk image
+nix build path:$CFG/nixosfl-dev#image --out-link $CFG/nixosfl-dev/result
+
+# 3. Stage it into the libvirt storage pool
+cp $CFG/nixosfl-dev/result/nixos.qcow2 $NIXOSFL_STORAGE_DIR/nixosfl-dev.qcow2
+
+# 4. Define and start the domain (libvirt XML from vm-libvirt.xml.j2)
+export LIBVIRT_DEFAULT_URI=qemu:///system
+virsh define $NIXOSFL_STORAGE_DIR/nixosfl-dev.xml
+virsh start  nixosfl-dev
+
+# 5. Connect
+virsh domifaddr nixosfl-dev      # find the DHCP address
+ssh nixosfl-dev                  # once ~/.ssh/config is updated
+virsh console nixosfl-dev        # serial console
+```
+
+`make nixosfl-bringup` / `nixosfl-console` / `nixosfl-destroy` wrap these.
+
+---
+
+## Source overrides (both backends)
+
+Rebuild a tracked package (fio, xfstests, xfsprogs, libbpf, libbpf-tools,
+damo, nfstest, pynfs, xnvme, cpupower) from your own tree. Set
+`NIXOS_FLAKE_OVERRIDE_<PKG>=y` plus `_SRC` (local path or git URL) and
+optional `_REF`; kdevops adds a `<pkg>-src` flake input that a
+`default.nix` overlay `overrideAttrs` consumes. After changing a
+`_SRC`/`_REF`, refresh the pinned input and rebuild:
+
+```sh
+nix flake update --flake path:$CFG/<vm> <pkg>-src
+nix build path:$CFG/<vm>#toplevel --out-link $CFG/<vm>/result   # or #image for nixosfl
+```
+
+---
+
+## Where the knobs live
+
+`Kconfig.qsu` (`QSU_*`) — runtime sizing NIXOSFI reuses: `QSU_RAM` (4096),
+`QSU_CPUS` (4), `QSU_MACHINE_TYPE` (q35), `QSU_SSH_PORT_BASE` (10022),
+`QSU_VSOCK_CID_BASE` (100), `QSU_NVME_DRIVE_COUNT` (4) /
+`_SIZE_GB` (20) plus the NVMe block-geometry and atomic-write fields.
+
+`Kconfig.nixos_flake` (`NIXOS_FLAKE_*`) — shared library: profiles,
+mounts, testSuites, source overrides; baked into the closure.
+
+`NIXOSFI_CONFIG_DIR` / `NIXOSFL_CONFIG_DIR` — where per-guest flakes
+render. `NIXOSFL_STORAGE_DIR` — the libvirt qcow2 pool.
+
+For the upstream tools themselves, see `man qemu-system`,
+`man systemd.service`, `man machinectl`, and the vendored projects under
+`scripts/qemu-system-units/` and `scripts/nixos-flake/`.

-- 
2.54.0