diff --git a/nixos/modules/profiles/nix-builder-vz-vm.nix b/nixos/modules/profiles/nix-builder-vz-vm.nix new file mode 100644 index 000000000000..5d8ddf1d3d12 --- /dev/null +++ b/nixos/modules/profiles/nix-builder-vz-vm.nix @@ -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" + ]; + }; +} diff --git a/nixos/modules/virtualisation/vm-base.nix b/nixos/modules/virtualisation/vm-base.nix new file mode 100644 index 000000000000..4eb949e32acb --- /dev/null +++ b/nixos/modules/virtualisation/vm-base.nix @@ -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; + }; +} diff --git a/nixos/modules/virtualisation/vz-vm.nix b/nixos/modules/virtualisation/vz-vm.nix new file mode 100644 index 000000000000..a2791649a58c --- /dev/null +++ b/nixos/modules/virtualisation/vz-vm.nix @@ -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; + }) + ]; +} diff --git a/pkgs/top-level/darwin-packages.nix b/pkgs/top-level/darwin-packages.nix index 33e09e254c43..6e0934a19bb3 100644 --- a/pkgs/top-level/darwin-packages.nix +++ b/pkgs/top-level/darwin-packages.nix @@ -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 = [ ]; }; } ); }