linux-builder-vz: init modules and package

This commit is contained in:
Jacek Galowicz
2026-07-21 13:13:41 +02:00
parent 9287d5617a
commit e7c55acd40
4 changed files with 768 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
/*
The Linux remote-builder profile, on Apple's Virtualization.framework.
The vzvm counterpart to `./nix-builder-vm.nix`:
- Backend-neutral half lives in `./nix-builder.nix`.
- everything below is what differs from the QEMU backend.
Main differences:
- `networking.nameservers = [ "8.8.8.8" ]` is not needed.
vz uses the host's NAT, which provides working DNS.
- `virtualisation.graphics = false`: no display support in vz
- `virtualisation.useNixStoreImage`: vz only supports nix store image
*/
{
config,
lib,
...
}:
let
cfg = config.virtualisation.darwin-builder;
keysDirectory = "/var/keys";
keysMountUnit = "var-keys.mount";
in
{
imports = [
./nix-builder.nix
../virtualisation/vz-vm.nix
];
config = {
virtualisation.vz.forwardPorts = [
{
host.address = "127.0.0.1";
host.port = cfg.hostPort;
# Must track the port the guest serves SSH on, or the host forwards nowhere.
guest.port = config.virtualisation.vz.vsockSSH.port;
}
];
# The guest console belongs where a Mac user looks for service logs. Switch to `file`
# for a complete record: the unified log drops messages under bursts.
virtualisation.vz.console = lib.mkDefault "log";
virtualisation.vz.consoleLog = lib.mkDefault "./console.log";
# Our raw image must keep the `.qcow2` name nix-darwin's `ephemeral` wipes; vzvm checks
# the magic bytes and refuses a genuine leftover qcow2 rather than misreading it.
virtualisation.vz.diskImage = "./${config.networking.hostName}.qcow2";
# authorized keys come via virtiofs. fix uid and mode for sshd's ownership checks.
systemd.services.copy-builder-keys = {
description = "Stage builder SSH keys where sshd will accept them";
wantedBy = [ "multi-user.target" ];
requires = [ keysMountUnit ];
after = [ keysMountUnit ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
install -d -m 0755 -o root -g root /run/builder-keys
for key in ${keysDirectory}/*.pub; do
[ -e "$key" ] || continue
install -m 0444 -o root -g root "$key" /run/builder-keys/
done
'';
};
# The dependency belongs on the per-connection service, not on the socket.
systemd.services."vzvm-ssh@" = {
requires = [ "copy-builder-keys.service" ];
wants = [ "network-online.target" ];
after = [
"copy-builder-keys.service"
"network-online.target"
];
};
services.openssh.authorizedKeysFiles = lib.mkForce [
"/run/builder-keys/%u_ed25519.pub"
];
};
}

View File

@@ -0,0 +1,204 @@
# Backend-neutral pieces of running a NixOS guest in a VM.
#
# `qemu-vm.nix` still carries its own copies of these options: the two modules are
# never imported together because a configuration has exactly one VM backend, so
# the duplicated declarations cannot collide.
# Deduping `qemu-vm.nix` onto this module is a refactor left for later.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.virtualisation;
in
{
options = {
virtualisation.memorySize = lib.mkOption {
type = lib.types.ints.positive;
default = 1024;
description = ''
The memory size in megabytes of the virtual machine.
'';
};
virtualisation.cores = lib.mkOption {
type = lib.types.ints.positive;
default = 1;
description = ''
Specify the number of cores the guest is permitted to use.
The number can be higher than the available cores on the
host system.
'';
};
# `virtualisation.diskSize` comes from `disk-size-option.nix` in the default module list.
virtualisation.additionalPaths = lib.mkOption {
type = lib.types.listOf lib.types.path;
default = [ ];
description = ''
A list of paths whose closure should be made available to the VM.
The closure is copied into the VM's Nix store image and registered in
the guest's Nix database.
'';
};
virtualisation.writableStore = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
If enabled, the Nix store in the VM is made writable by layering an
overlay filesystem on top of the (read-only) store image.
'';
};
virtualisation.writableStoreUseTmpfs = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Use a tmpfs for the writable store instead of writing to a disk image.
Turning this off makes store writes survive a reboot, at the cost of
needing a disk to put them on.
'';
};
virtualisation.useHostCerts = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
If enabled, when `NIX_SSL_CERT_FILE` is set on the host,
pass the CA certificates from the host to the VM.
'';
};
virtualisation.sharedDirectories = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }:
{
options.source = lib.mkOption {
type = lib.types.str;
description = "The path of the directory to share, can be a shell variable";
};
options.target = lib.mkOption {
type = lib.types.path;
description = "The mount point of the directory inside the virtual machine";
};
options.tag = lib.mkOption {
type = lib.types.str;
default = name;
description = ''
The tag the guest mounts this share by. Defaults to the attribute
name. Backends impose their own length limits on tags.
'';
};
}
)
);
default = { };
example = {
my-share = {
source = "/path/to/be/shared";
target = "/mnt/shared";
};
};
description = ''
An attribute set of directories that will be shared with the virtual
machine. The attribute name is used as the mount tag.
'';
};
virtualisation.host.pkgs = lib.mkOption {
type = lib.types.pkgs;
default = pkgs;
defaultText = lib.literalExpression "pkgs";
example = lib.literalExpression ''
import pkgs.path { system = "aarch64-darwin"; }
'';
description = ''
Package set to use for the host-side tooling that launches the VM.
This is not the guest's package set: the host may well be a different
platform than the guest, which is the entire point of running a VM.
'';
};
};
config = {
# Passed on the kernel command line: a direct reference would make the closure self-referential.
systemd.services.register-nix-paths = lib.mkIf config.nix.enable {
# Runs early so the store DB is populated first; `--load-db` needs no daemon.
unitConfig.DefaultDependencies = false;
wantedBy = [ "sysinit.target" ];
before = [
"sysinit.target"
"shutdown.target"
"nix-daemon.socket"
"nix-daemon.service"
];
after = [ "local-fs.target" ];
conflicts = [ "shutdown.target" ];
restartIfChanged = false;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = lib.mkIf (config.nix.daemonUser != "root") config.nix.daemonUser;
Group = lib.mkIf (config.nix.daemonGroup != "root") config.nix.daemonGroup;
};
script = ''
if [[ "$(cat /proc/cmdline)" =~ regInfo=([^ ]*) ]]; then
${lib.getExe' config.nix.package.out "nix-store"} --load-db < "''${BASH_REMATCH[1]}"
fi
'';
};
virtualisation.additionalPaths = [ config.system.build.toplevel ];
# Read-only erofs store, overlaid when writable. Override per entry: `mkVMOverride` on
# the whole set would drop other modules' filesystems, including the Rosetta share.
fileSystems = {
"/nix/.ro-store" = lib.mkVMOverride {
device = "/dev/disk/by-label/nix-store";
fsType = "erofs";
neededForBoot = true;
options = [ "ro" ];
};
"/nix/store" = lib.mkVMOverride (
if cfg.writableStore then
{
overlay = {
lowerdir = [ "/nix/.ro-store" ];
upperdir = "/nix/.rw-store/upper";
workdir = "/nix/.rw-store/work";
};
}
else
{
device = "/nix/.ro-store";
fsType = "none";
options = [ "bind" ];
}
);
"/nix/.rw-store" = lib.mkIf (cfg.writableStore && cfg.writableStoreUseTmpfs) (
lib.mkVMOverride {
fsType = "tmpfs";
options = [ "mode=0755" ];
neededForBoot = true;
}
);
};
swapDevices = lib.mkVMOverride [ ];
boot.initrd.luks.devices = lib.mkVMOverride { };
# The host keeps time for us.
services.timesyncd.enable = false;
};
}

View File

@@ -0,0 +1,452 @@
# Runs a NixOS guest on Apple's Virtualization.framework with `vzvm`: Rosetta, and a
# lighter hypervisor than QEMU.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.virtualisation;
vzCfg = cfg.vz;
hostPkgs = cfg.host.pkgs;
regInfo = hostPkgs.closureInfo { rootPaths = cfg.additionalPaths; };
# Host-built erofs store image, named by closure hash so only a changed guest rebuilds it.
# A derivation is not an option: it would need the Linux builder this VM provides.
storeClosureInfo = hostPkgs.closureInfo {
rootPaths = [
config.system.build.toplevel
regInfo
];
};
storeImageName = "store-${lib.head (lib.splitString "-" (baseNameOf (toString storeClosureInfo)))}.img";
toplevel = config.system.build.toplevel;
kernelParams = [
# Virtualization.framework offers a virtio console
"console=hvc0"
"init=${toplevel}/init"
"regInfo=${regInfo}/registration"
]
++ config.boot.kernelParams;
forwards = map (forward: {
listen = "${forward.host.address}:${toString forward.host.port}";
vsockPort = forward.guest.port;
}) vzCfg.forwardPorts;
# The builder profile passes the keys directory as `"$KEYS"`; the runner resolves it.
shareFragment = lib.concatMapStrings (share: ''
shares=$(${lib.getExe hostPkgs.jq} -n --argjson shares "$shares" \
--arg tag ${lib.escapeShellArg share.tag} --arg path ${share.source} \
'$shares + [{tag: $tag, path: $path}]')
'') (lib.attrValues cfg.sharedDirectories);
staticConfig = {
cpuCount = cfg.cores;
memorySizeMiB = cfg.memorySize;
kernel = "${toplevel}/kernel";
initrd = "${toplevel}/initrd";
cmdline = toString kernelParams;
vsock.forwards = forwards;
rosetta = vzCfg.rosetta.enable;
inherit (vzCfg) nestedVirtualization;
# Only `file` carries a path; dispatch rather than fall through, so a new mode cannot
# silently be emitted as `file`.
console =
if vzCfg.console == "file" then
{
mode = "file";
path = vzCfg.consoleLog;
}
else
{ mode = vzCfg.console; };
}
// vzCfg.extraConfig;
staticConfigFile = hostPkgs.writeText "vzvm-config.json" (builtins.toJSON staticConfig);
in
{
imports = [ ./vm-base.nix ];
options = {
virtualisation.vz.package = lib.mkPackageOption hostPkgs "vzvm" { };
virtualisation.vz.rosetta = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Expose Rosetta to the guest, so that it can execute x86_64 binaries.
Rosetta must be installed on the host; the VM refuses to start
otherwise rather than quietly losing the ability to build for
x86_64-linux. Install it with:
```
softwareupdate --install-rosetta --agree-to-license
```
'';
};
};
virtualisation.vz.nestedVirtualization = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Boot the guest at EL2 so it gets a working `/dev/kvm`, which the Nix
daemon inside then advertises as the `kvm` system feature required
for running NixOS integration tests on the builder.
Needs macOS 15+ and an M3 or newer chip; the VM refuses to start
otherwise rather than quietly advertising a `kvm` it does not have.
'';
};
virtualisation.vz.forwardPorts = lib.mkOption {
type = lib.types.listOf (
lib.types.submodule {
options.host.address = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Host address to listen on.";
};
options.host.port = lib.mkOption {
type = lib.types.port;
description = "Host port to listen on.";
};
options.guest.port = lib.mkOption {
type = lib.types.port;
description = "Guest *vsock* port that connections are forwarded to.";
};
}
);
default = [ ];
example = lib.literalExpression ''
[ { host.port = 2222; guest.port = 22; } ]
'';
description = ''
Forward host TCP ports into guest over vsock.
Going via vsock relieves us from having to find a stable inbound address
in the NAT network setup.
'';
};
virtualisation.vz.console = lib.mkOption {
type = lib.types.enum [
"stdio"
"file"
"log"
];
default = "stdio";
description = ''
Where the guest console goes.
- `stdio`: standard output, which is interactive but goes nowhere under launchd.
- `file`: the path in {option}`virtualisation.vz.consoleLog`. The only complete
record: the unified log rate-limits chatty sources and drops under bursts.
- `log`: macOS unified logging, alongside vzvm's own diagnostics. Read it with
`log show --predicate 'subsystem == "systems.applicative.vzvm"'`, and narrow to
the guest with `AND category == "guest"`.
'';
};
virtualisation.vz.consoleLog = lib.mkOption {
type = lib.types.str;
default = "./console.log";
description = ''
Console log file, used when {option}`virtualisation.vz.console` is `file`.
Relative paths are resolved against working directory.
'';
};
virtualisation.vz.diskImage = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = "./${config.system.name}.img";
defaultText = lib.literalExpression ''"./''${config.system.name}.img"'';
description = ''
Path to the raw data disk backing the writable store, created on first
start. Set to `null` to run without one, which requires
{option}`virtualisation.writableStoreUseTmpfs`.
'';
};
virtualisation.vz.vsockSSH = {
enable = lib.mkOption {
type = lib.types.bool;
default = config.services.openssh.enable;
defaultText = lib.literalExpression "config.services.openssh.enable";
description = ''
Serve SSH on a vsock port in addition to any TCP listeners.
Inbound connections arrive over vsock rather than TCP because NAT
networking gives the guest no stable inbound address. Each connection
is handed to its own `sshd -i`, same way a socket-activated `sshd`
works over TCP.
'';
};
port = lib.mkOption {
type = lib.types.port;
default = 22;
description = "vsock port that SSH is served on.";
};
};
virtualisation.vz.extraConfig = lib.mkOption {
type = lib.types.attrs;
default = { };
description = ''
Additional attributes merged into the generated `vzvm` JSON configuration.
'';
};
};
config = lib.mkMerge [
{
assertions = [
{
assertion = hostPkgs.stdenv.hostPlatform.system == "aarch64-darwin";
message = ''
virtualisation.vz only runs on aarch64-darwin hosts, but
`virtualisation.host.pkgs` is ${hostPkgs.stdenv.hostPlatform.system}.
'';
}
{
assertion = pkgs.stdenv.hostPlatform.isAarch64;
message = ''
virtualisation.vz cannot emulate a foreign architecture: the guest
(${pkgs.stdenv.hostPlatform.system}) must be aarch64. x86_64 guest
binaries run through Rosetta instead, not through emulation.
'';
}
{
assertion = vzCfg.diskImage != null || cfg.writableStoreUseTmpfs;
message = ''
virtualisation.vz.diskImage is null, so there is nowhere to put the
writable store. Enable virtualisation.writableStoreUseTmpfs.
'';
}
];
boot.loader.grub.enable = false;
boot.initrd.availableKernelModules = [
"virtio_pci"
"virtio_blk"
"virtio_console"
"virtiofs"
"erofs"
"overlay"
];
# All inbound connections go via vsock
boot.kernelModules = [ "vmw_vsock_virtio_transport" ];
boot.initrd.systemd.enable = lib.mkDefault true;
system.requiredKernelConfig = with config.lib.kernelConfig; [
(isEnabled "VIRTIO_BLK")
(isEnabled "VIRTIO_PCI")
(isEnabled "VIRTIO_CONSOLE")
(isEnabled "VIRTIO_NET")
(isYes "BLK_DEV_INITRD")
(isEnabled "FUSE_FS")
(isEnabled "VIRTIO_FS")
(isEnabled "EROFS_FS")
(isEnabled "OVERLAY_FS")
(isEnabled "VSOCKETS")
(isEnabled "VIRTIO_VSOCKETS")
];
# Override per entry and not as a whole: see note in vm-base.nix.
fileSystems = {
"/" = lib.mkVMOverride {
device = "tmpfs";
fsType = "tmpfs";
neededForBoot = true;
options = [ "mode=0755" ];
};
}
// lib.optionalAttrs (!cfg.writableStoreUseTmpfs && vzCfg.diskImage != null) {
# Second disk, hence /dev/vdb: store image is always first.
"/nix/.rw-store" = lib.mkVMOverride {
device = "/dev/vdb";
fsType = "ext4";
autoFormat = true;
neededForBoot = true;
};
}
// lib.mapAttrs' (
_: share:
lib.nameValuePair share.target (
lib.mkVMOverride {
device = share.tag;
fsType = "virtiofs";
}
)
) cfg.sharedDirectories;
# DHCP and DNS come from host NAT
networking.useDHCP = lib.mkDefault true;
virtualisation.rosetta.enable = lib.mkIf vzCfg.rosetta.enable true;
# Must agree with the tag vzvm shares Rosetta under, which is fixed.
virtualisation.rosetta.mountTag = lib.mkIf vzCfg.rosetta.enable "rosetta";
system.build.vm =
hostPkgs.runCommand "nixos-vm"
{
preferLocalBuild = true;
meta.mainProgram = "run-${config.system.name}-vm";
}
''
mkdir -p $out/bin
ln -s ${config.system.build.toplevel} $out/system
ln -s ${hostPkgs.writeShellScript "run-${config.system.name}-vm" ''
set -eu
# nix-darwin runs this with a working directory it owns
imageDir="''${VZVM_STATE_DIR:-$PWD}"
mkdir -p "$imageDir"
if [ -z "''${TMPDIR:-}" ]; then
# GNU mktemp needs the XXXXXX placeholder; only reachable when TMPDIR is unset.
TMPDIR=$(${hostPkgs.coreutils}/bin/mktemp -d -t vzvm.XXXXXX)
fi
export TMPDIR
${lib.optionalString cfg.useHostCerts ''
mkdir -p "$TMPDIR/certs"
if [ -e "''${NIX_SSL_CERT_FILE:-}" ]; then
${hostPkgs.coreutils}/bin/install -m 0644 \
"$NIX_SSL_CERT_FILE" "$TMPDIR/certs/ca-certificates.crt"
else
echo "vzvm: NIX_SSL_CERT_FILE is unset or missing; guest gets no host CA certificates" >&2
: > "$TMPDIR/certs/ca-certificates.crt"
fi
''}
storeImage="$imageDir/${storeImageName}"
if [ ! -f "$storeImage" ]; then
echo "Building Nix store image, this can take a minute on first boot..." >&2
# Build into a temp file so an interrupted run leaves no truncated image behind.
storeImageTmp="$storeImage.tmp.$$"
# shellcheck disable=SC2064
trap "rm -f '$storeImageTmp'" EXIT
${hostPkgs.gnutar}/bin/tar --create \
--absolute-names \
--verbatim-files-from \
--transform 'flags=rSh;s|/nix/store/||' \
--transform 'flags=rSh;s|~nix~case~hack~[[:digit:]]\+||g' \
--files-from ${storeClosureInfo}/store-paths \
| ${hostPkgs.erofs-utils}/bin/mkfs.erofs \
--quiet \
--force-uid=0 \
--force-gid=0 \
-L nix-store \
-U eb176051-bd15-49b7-9e6b-462e0b467019 \
-T 0 \
--hard-dereference \
--tar=f \
"$storeImageTmp"
mv "$storeImageTmp" "$storeImage"
trap - EXIT
# Images from previous generations are dead weight and cheap to rebuild.
find "$imageDir" -maxdepth 1 -name 'store-*.img' ! -name ${lib.escapeShellArg storeImageName} -delete
fi
disks=$(${lib.getExe hostPkgs.jq} -n --arg store "$storeImage" \
'[{path: $store, readOnly: true}]')
shares='[]'
${shareFragment}
${lib.optionalString (vzCfg.diskImage != null) ''
dataDisk="${vzCfg.diskImage}"
case "$dataDisk" in
/*) ;;
*) dataDisk="$imageDir/''${dataDisk#./}" ;;
esac
if [ ! -f "$dataDisk" ]; then
echo "Creating ${toString cfg.diskSize}M data disk at $dataDisk..." >&2
${hostPkgs.coreutils}/bin/dd if=/dev/zero of="$dataDisk" bs=1M count=0 \
seek=${toString cfg.diskSize}
fi
disks=$(${lib.getExe hostPkgs.jq} -n --argjson disks "$disks" --arg data "$dataDisk" \
'$disks + [{path: $data, readOnly: false}]')
''}
config="$imageDir/vzvm.json"
${lib.getExe hostPkgs.jq} --argjson disks "$disks" --argjson shares "$shares" \
'.disks = $disks | .shares = $shares' ${staticConfigFile} > "$config"
exec ${lib.getExe vzCfg.package} "$config"
''} $out/bin/run-${config.system.name}-vm
'';
}
(lib.mkIf vzCfg.vsockSSH.enable {
# sshd has no vsock listener, so systemd owns the socket and hands it each connection.
systemd.sockets.vzvm-ssh = {
description = "SSH vsock socket";
wantedBy = [ "sockets.target" ];
socketConfig = {
ListenStream = "vsock::${toString vzCfg.vsockSSH.port}";
Accept = true;
# The default 64 suits interactive logins, not `ssh-ng://`; past the limit systemd
# stops accepting and connections hang without a banner or a log line anywhere.
MaxConnections = 512;
# stop the socket unit outright once exceeded
TriggerLimitIntervalSec = 0;
};
};
# vsock delivers no RST, so without keepalives a session whose peer vanished never dies.
services.openssh.settings = {
ClientAliveInterval = lib.mkDefault 60;
ClientAliveCountMax = lib.mkDefault 3;
};
systemd.services."vzvm-ssh@" = {
description = "SSH per-connection daemon (vsock)";
after = [ "sshd-keygen.service" ];
serviceConfig = {
ExecStart = "-${lib.getExe' config.services.openssh.package "sshd"} -i -f /etc/ssh/sshd_config";
StandardInput = "socket";
StandardOutput = "socket";
StandardError = "journal";
# Not `KillMode = "process"`: sshd's `nix-daemon` child would outlive the connection
# and leak instances against MaxConnections until the guest stops accepting SSH.
TimeoutStopSec = 10;
};
};
})
(lib.mkIf cfg.useHostCerts {
virtualisation.sharedDirectories.certs = {
source = "$TMPDIR/certs";
target = "/etc/ssl/certs";
};
security.pki.installCACerts = false;
})
];
}

View File

@@ -177,6 +177,31 @@ makeScopeWithSplicing' {
linux-builder-x86_64 = self.linux-builder.override {
modules = [ { nixpkgs.hostPlatform = "x86_64-linux"; } ];
};
# Like `linux-builder`, but runs the guest on Apple's Virtualization.framework
# via `vzvm`, translating x86_64-linux builds with Rosetta instead of emulating
# them. See doc/packages/darwin-builder.section.md
linux-builder-vz = lib.makeOverridable (
{ modules }:
let
nixos = import ../../nixos {
configuration = {
imports = [
../../nixos/modules/profiles/nix-builder-vz-vm.nix
]
++ modules;
virtualisation.host = { inherit pkgs; };
# aarch64-darwin is the only supported host, so the guest is fixed too.
nixpkgs.hostPlatform = lib.mkDefault "aarch64-linux";
};
system = null;
};
in
nixos.config.system.build.macos-builder-installer
) { modules = [ ]; };
}
);
}