Files
nixos-router/tests/image.nix
T
nilsandClaude Opus 5.5 daa70fe7ac Add a read-only appliance image with a dm-verity protected store
nixosModules.image builds a GPT disk image with systemd-repart: ESP with a
UKI carrying the store's verity root hash, an erofs /nix/store on a
dm-verity partition, a tmpfs root, an immutable /etc overlay and an ext4
state partition on /var. No Nix on the device; it is replaced as a whole.

The store is made with 4 KiB erofs blocks: systemd-repart passes the 512
byte sector size to mkfs.erofs, and libblkid cannot verify the checksum of
such a superblock, so udev never reported the file system and the initrd
waited forever for /dev/mapper/usr.

examples/image (nix build .#image) is a hardware test build with a debug
console password; checks.image boots the image under UEFI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:54:24 +01:00

105 lines
3.6 KiB
Nix

# The appliance image: boot the raw disk image under UEFI and check what makes
# it an appliance (read-only verity store, volatile root, persistent /var) while
# the router itself runs.
{ self, pkgs }:
let
common = import ./common.nix;
in
pkgs.testers.runNixOSTest {
name = "router-image";
nodes.machine =
{ lib, ... }:
{
imports = [
self.nixosModules.router
self.nixosModules.image
common.tools
];
# The VM boots the image's own disk, not the usual test plumbing.
virtualisation.fileSystems = lib.mkVMOverride { };
virtualisation.directBoot.enable = false;
virtualisation.mountHostNixStore = false;
virtualisation.useEFIBoot = true;
virtualisation.vlans = [
1
2
];
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
networking.interfaces.eth2.ipv4.addresses = lib.mkForce [ ];
system.image.version = "1";
router = {
enable = true;
wan.interface = "eth1";
lans.main = {
interface = "eth2";
internet = true;
ipv4.address = "192.168.10.1";
dhcp.pool = {
start = "192.168.10.100";
end = "192.168.10.199";
};
};
};
};
testScript =
{ nodes, ... }:
''
import os
import subprocess
import tempfile
disk = tempfile.NamedTemporaryFile()
subprocess.run([
"${nodes.machine.virtualisation.qemu.package}/bin/qemu-img", "create",
"-f", "qcow2",
"-b", "${nodes.machine.system.build.image}/${nodes.machine.image.filePath}",
"-F", "raw", disk.name,
], check=True)
os.environ["NIX_DISK_IMAGE"] = disk.name
machine.wait_for_unit("multi-user.target")
with subtest("the disk has the appliance layout"):
parts = machine.succeed("lsblk -n -o PARTLABEL /dev/vda")
for label in ("esp", "store-verity", "store", "state"):
assert label in parts, parts
with subtest("root is volatile and /nix/store is a dm-verity protected read-only partition"):
machine.succeed("findmnt --kernel --type tmpfs /")
assert "ACTIVE" in machine.succeed("dmsetup info --target verity usr")
backing = machine.succeed("df --output=source /nix/store | tail -n1").strip()
assert backing == "/dev/mapper/usr", backing
machine.fail("touch /nix/store/should-not-work")
machine.fail("touch /etc/should-not-work")
with subtest("the image identifies itself"):
machine.succeed("grep -qE '^IMAGE_VERSION=.?1.?$' /etc/os-release")
machine.succeed("grep -qE '^IMAGE_ID=.?nixos-router.?$' /etc/os-release")
with subtest("the router comes up from the image"):
machine.wait_for_unit("nftables.service")
machine.wait_for_unit("unbound.service")
machine.wait_for_unit("kea-dhcp4-server.service")
machine.wait_until_succeeds("ip -4 addr show dev eth2 | grep -q 192.168.10.1")
with subtest("there is no Nix on the device"):
machine.fail("command -v nix-env")
with subtest("/var is on the state partition and survives a reboot, the root does not"):
src = machine.succeed("findmnt -n -o SOURCE /var").strip()
assert "vda" in src, src
machine.succeed("echo kept > /var/lib/marker; echo lost > /root/marker; sync")
machine.shutdown()
machine.start()
machine.wait_for_unit("multi-user.target")
assert machine.succeed("cat /var/lib/marker").strip() == "kept"
machine.fail("test -e /root/marker")
machine.wait_for_unit("nftables.service")
'';
}