commit 1d506471504157982b68ead51ddb6c46ddf28c42 Author: Julien Malka Date: Fri May 17 08:52:48 2024 +0200 init diff --git a/configuration.nix b/configuration.nix new file mode 100644 index 0000000..7e0a9cb --- /dev/null +++ b/configuration.nix @@ -0,0 +1,60 @@ +{ + lib, + pkgs, + inputs, + ... +}: + +{ + imports = [ + ./hardware-configuration.nix + ./qemu-vm.nix + ]; + + # Bootloader. + boot.loader.systemd-boot.enable = true; + networking.hostName = "s3ns-nixos-demo"; + users.users.demo = { + isNormalUser = true; + extraGroups = [ "wheel" ]; # Enable ‘sudo’ for the user. + initialPassword = "demo"; + }; + + nix.channel.enable = true; + + services.xserver = { + layout = "fr"; + xkbVariant = ""; + }; + # Set your time zone. + time.timeZone = "Europe/Paris"; + + environment.systemPackages = with pkgs; [ neovim ]; + services.openssh.enable = true; + + users.users.root.openssh.authorizedKeys.keys = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDMBW7rTtfZL9wtrpCVgariKdpN60/VeAzXkh9w3MwbO julien@enigma" + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC1p76c2+I5EG/QoWbUywrRghB4k7+/Mik9I6GryKb5Rf4q8SMf7rR1ec9gSfdfVcLyez26oLfU88zo+YBAtN7gUWs4psG3xfGwKX42LT3i4oPZWMI8O93NTRN3Mrdlz+B0Py9OQODt0rfEohTNWghJUhvbOWsL8EeKg2fuV24GBZFsjz8YlDtKhQwynY3J6SHCnq2y6OcfQu6dnsGWQmF4Ahr1X9WTDGc2/NZbxzxW4uV00QQ+9noMTuI40Ge2/83e++caHxY8qxEFgNuQd38OPn0IyzpGsnbxKxvI9Ne/XSPtmuO3yzc6tfC75cbsUFkKFXkY2NQT5jXNj/Stz/xD" + ]; + + virtualisation = { + cores = 8; + memorySize = 4096; + useBootLoader = true; + useEFIBoot = true; + forwardPorts = [ + { + from = "host"; + host.port = 5050; + guest.port = 22; + } + { + from = "host"; + host.port = 8888; + guest.port = 8888; + } + ]; + }; + + system.stateVersion = "23.11"; +} diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..bd711a6 --- /dev/null +++ b/default.nix @@ -0,0 +1,25 @@ +let + inputs = import ./npins; + nixpkgs = import inputs.nixpkgs { }; + lib = nixpkgs.lib; + + disko = import "${inputs.disko}/module.nix"; +in +rec { + + nixosConfigurations = builtins.mapAttrs ( + name: value: + import "${inputs.nixpkgs}/nixos/lib/eval-config.nix" { + lib = lib; + system = "x86_64-linux"; + specialArgs = { + inherit inputs; + }; + modules = [ + value + disko + ]; + extraModules = [ ]; + } + ) { s3ns = import ./configuration.nix; }; +} diff --git a/hardware-configuration.nix b/hardware-configuration.nix new file mode 100644 index 0000000..2ed4219 --- /dev/null +++ b/hardware-configuration.nix @@ -0,0 +1,21 @@ +{ lib, modulesPath, ... }: + +{ + imports = [ (modulesPath + "/profiles/qemu-guest.nix") ]; + + boot.initrd.availableKernelModules = [ + "ata_piix" + "uhci_hcd" + "virtio_pci" + "virtio_scsi" + "sd_mod" + "sr_mod" + ]; + boot.initrd.kernelModules = [ ]; + boot.kernelModules = [ ]; + boot.extraModulePackages = [ ]; + + networking.useDHCP = lib.mkDefault true; + + nixpkgs.hostPlatform = lib.mkDefault "aarch64-linux"; +} diff --git a/make-disk-image.nix b/make-disk-image.nix new file mode 100644 index 0000000..9bdbf4e --- /dev/null +++ b/make-disk-image.nix @@ -0,0 +1,658 @@ +/* Technical details + +`make-disk-image` has a bit of magic to minimize the amount of work to do in a virtual machine. + +It relies on the [LKL (Linux Kernel Library) project](https://github.com/lkl/linux) which provides Linux kernel as userspace library. + +The Nix-store only image only need to run LKL tools to produce an image and will never spawn a virtual machine, whereas full images will always require a virtual machine, but also use LKL. + +### Image preparation phase + +Image preparation phase will produce the initial image layout in a folder: + +- devise a root folder based on `$PWD` +- prepare the contents by copying and restoring ACLs in this root folder +- load in the Nix store database all additional paths computed by `pkgs.closureInfo` in a temporary Nix store +- run `nixos-install` in a temporary folder +- transfer from the temporary store the additional paths registered to the installed NixOS +- compute the size of the disk image based on the apparent size of the root folder +- partition the disk image using the corresponding script according to the partition table type +- format the partitions if needed +- use `cptofs` (LKL tool) to copy the root folder inside the disk image + +At this step, the disk image already contains the Nix store, it now only needs to be converted to the desired format to be used. + +### Image conversion phase + +Using `qemu-img`, the disk image is converted from a raw format to the desired format: qcow2(-compressed), vdi, vpc. + +### Image Partitioning + +#### `none` + +No partition table layout is written. The image is a bare filesystem image. + +#### `legacy` + +The image is partitioned using MBR. There is one primary ext4 partition starting at 1 MiB that fills the rest of the disk image. + +This partition layout is unsuitable for UEFI. + +#### `legacy+gpt` + +This partition table type uses GPT and: + +- create a "no filesystem" partition from 1MiB to 2MiB ; +- set `bios_grub` flag on this "no filesystem" partition, which marks it as a [GRUB BIOS partition](https://www.gnu.org/software/parted/manual/html_node/set.html) ; +- create a primary ext4 partition starting at 2MiB and extending to the full disk image ; +- perform optimal alignments checks on each partition + +This partition layout is unsuitable for UEFI boot, because it has no ESP (EFI System Partition) partition. It can work with CSM (Compatibility Support Module) which emulates legacy (BIOS) boot for UEFI. + +#### `efi` + +This partition table type uses GPT and: + +- creates an FAT32 ESP partition from 8MiB to specified `bootSize` parameter (256MiB by default), set it bootable ; +- creates an primary ext4 partition starting after the boot partition and extending to the full disk image + +#### `efixbootldr` + +This partition table type uses GPT and: + +- creates an FAT32 ESP partition from 8MiB to 100MiB, set it bootable ; +- creates an FAT32 BOOT partition from 100MiB to specified `bootSize` parameter (256MiB by default), set `bls_boot` flag ; +- creates an primary ext4 partition starting after the boot partition and extending to the full disk image + +#### `hybrid` + +This partition table type uses GPT and: + +- creates a "no filesystem" partition from 0 to 1MiB, set `bios_grub` flag on it ; +- creates an FAT32 ESP partition from 8MiB to specified `bootSize` parameter (256MiB by default), set it bootable ; +- creates a primary ext4 partition starting after the boot one and extending to the full disk image + +This partition could be booted by a BIOS able to understand GPT layouts and recognizing the MBR at the start. + +### How to run determinism analysis on results? + +Build your derivation with `--check` to rebuild it and verify it is the same. + +If it fails, you will be left with two folders with one having `.check`. + +You can use `diffoscope` to see the differences between the folders. + +However, `diffoscope` is currently not able to diff two QCOW2 filesystems, thus, it is advised to use raw format. + +Even if you use raw disks, `diffoscope` cannot diff the partition table and partitions recursively. + +To solve this, you can run `fdisk -l $image` and generate `dd if=$image of=$image-p$i.raw skip=$start count=$sectors` for each `(start, sectors)` listed in the `fdisk` output. Now, you will have each partition as a separate file and you can compare them in pairs. +*/ +{ pkgs +, lib + +, # The NixOS configuration to be installed onto the disk image. + config + +, # The size of the disk, in megabytes. + # if "auto" size is calculated based on the contents copied to it and + # additionalSpace is taken into account. + diskSize ? "auto" + +, # additional disk space to be added to the image if diskSize "auto" + # is used + additionalSpace ? "512M" + +, # size of the boot partition, is only used if partitionTableType is + # either "efi" or "hybrid" + # This will be undersized slightly, as this is actually the offset of + # the end of the partition. Generally it will be 1MiB smaller. + bootSize ? "256M" + +, # The files and directories to be placed in the target file system. + # This is a list of attribute sets {source, target, mode, user, group} where + # `source' is the file system object (regular file or directory) to be + # grafted in the file system at path `target', `mode' is a string containing + # the permissions that will be set (ex. "755"), `user' and `group' are the + # user and group name that will be set as owner of the files. + # `mode', `user', and `group' are optional. + # When setting one of `user' or `group', the other needs to be set too. + contents ? [] + +, # Type of partition table to use; described in the `Image Partitioning` section above. + partitionTableType ? "legacy" + +, # Whether to invoke `switch-to-configuration boot` during image creation + installBootLoader ? true + +, # Whether to output have EFIVARS available in $out/efi-vars.fd and use it during disk creation + touchEFIVars ? false + +, # OVMF firmware derivation + OVMF ? pkgs.OVMF.fd + +, # EFI firmware + efiFirmware ? OVMF.firmware + +, # EFI variables + efiVariables ? OVMF.variables + +, # The root file system type. + fsType ? "ext4" + +, # Filesystem label + label ? if onlyNixStore then "nix-store" else "nixos" + +, # The initial NixOS configuration file to be copied to + # /etc/nixos/configuration.nix. + configFile ? null + +, # Shell code executed after the VM has finished. + postVM ? "" + +, # Guest memory size + memSize ? 1024 + +, # Copy the contents of the Nix store to the root of the image and + # skip further setup. Incompatible with `contents`, + # `installBootLoader` and `configFile`. + onlyNixStore ? false + +, name ? "nixos-disk-image" + +, # Disk image format, one of qcow2, qcow2-compressed, vdi, vpc, raw. + format ? "raw" + + # Whether to fix: + # - GPT Disk Unique Identifier (diskGUID) + # - GPT Partition Unique Identifier: depends on the layout, root partition UUID can be controlled through `rootGPUID` option + # - GPT Partition Type Identifier: fixed according to the layout, e.g. ESP partition, etc. through `parted` invocation. + # - Filesystem Unique Identifier when fsType = ext4 for *root partition*. + # BIOS/MBR support is "best effort" at the moment. + # Boot partitions may not be deterministic. + # Also, to fix last time checked of the ext4 partition if fsType = ext4. +, deterministic ? true + + # GPT Partition Unique Identifier for root partition. +, rootGPUID ? "F222513B-DED1-49FA-B591-20CE86A2FE7F" + # When fsType = ext4, this is the root Filesystem Unique Identifier. + # TODO: support other filesystems someday. +, rootFSUID ? (if fsType == "ext4" then rootGPUID else null) + +, # Whether a nix channel based on the current source tree should be + # made available inside the image. Useful for interactive use of nix + # utils, but changes the hash of the image when the sources are + # updated. + copyChannel ? true + +, # Additional store paths to copy to the image's store. + additionalPaths ? [] +}: + +assert (lib.assertOneOf "partitionTableType" partitionTableType [ "legacy" "legacy+gpt" "efi" "efixbootldr" "hybrid" "none" ]); +assert (lib.assertMsg (fsType == "ext4" && deterministic -> rootFSUID != null) "In deterministic mode with a ext4 partition, rootFSUID must be non-null, by default, it is equal to rootGPUID."); + # We use -E offset=X below, which is only supported by e2fsprogs +assert (lib.assertMsg (partitionTableType != "none" -> fsType == "ext4") "to produce a partition table, we need to use -E offset flag which is support only for fsType = ext4"); +assert (lib.assertMsg (touchEFIVars -> partitionTableType == "hybrid" || partitionTableType == "efi" || partitionTableType == "efixbootldr" || partitionTableType == "legacy+gpt") "EFI variables can be used only with a partition table of type: hybrid, efi, efixbootldr, or legacy+gpt."); + # If only Nix store image, then: contents must be empty, configFile must be unset, and we should no install bootloader. +assert (lib.assertMsg (onlyNixStore -> contents == [] && configFile == null && !installBootLoader) "In a only Nix store image, the contents must be empty, no configuration must be provided and no bootloader should be installed."); +# Either both or none of {user,group} need to be set +assert (lib.assertMsg (lib.all + (attrs: ((attrs.user or null) == null) + == ((attrs.group or null) == null)) + contents) "Contents of the disk image should set none of {user, group} or both at the same time."); + +with lib; + +let format' = format; in let + + format = if format' == "qcow2-compressed" then "qcow2" else format'; + + compress = optionalString (format' == "qcow2-compressed") "-c"; + + filename = "nixos." + { + qcow2 = "qcow2"; + vdi = "vdi"; + vpc = "vhd"; + raw = "img"; + }.${format} or format; + + rootPartition = { # switch-case + legacy = "1"; + "legacy+gpt" = "2"; + efi = "2"; + efixbootldr = "3"; + hybrid = "3"; + }.${partitionTableType}; + + partitionDiskScript = { # switch-case + legacy = '' + parted --script $diskImage -- \ + mklabel msdos \ + mkpart primary ext4 1MiB -1 + ''; + "legacy+gpt" = '' + parted --script $diskImage -- \ + mklabel gpt \ + mkpart no-fs 1MB 2MB \ + set 1 bios_grub on \ + align-check optimal 1 \ + mkpart primary ext4 2MB -1 \ + align-check optimal 2 \ + print + ${optionalString deterministic '' + sgdisk \ + --disk-guid=97FD5997-D90B-4AA3-8D16-C1723AEA73C \ + --partition-guid=1:1C06F03B-704E-4657-B9CD-681A087A2FDC \ + --partition-guid=2:970C694F-AFD0-4B99-B750-CDB7A329AB6F \ + --partition-guid=3:${rootGPUID} \ + $diskImage + ''} + ''; + efi = '' + parted --script $diskImage -- \ + mklabel gpt \ + mkpart ESP fat32 8MiB ${bootSize} \ + set 1 boot on \ + mkpart primary ext4 ${bootSize} -1 + ${optionalString deterministic '' + sgdisk \ + --disk-guid=97FD5997-D90B-4AA3-8D16-C1723AEA73C \ + --partition-guid=1:1C06F03B-704E-4657-B9CD-681A087A2FDC \ + --partition-guid=2:${rootGPUID} \ + $diskImage + ''} + ''; + efixbootldr = '' + parted --script $diskImage -- \ + mklabel gpt \ + mkpart ESP fat32 8MiB 100MiB \ + set 1 boot on \ + mkpart BOOT fat32 100MiB ${bootSize} \ + set 2 bls_boot on \ + mkpart ROOT ext4 ${bootSize} -1 + ${optionalString deterministic '' + sgdisk \ + --disk-guid=97FD5997-D90B-4AA3-8D16-C1723AEA73C \ + --partition-guid=1:1C06F03B-704E-4657-B9CD-681A087A2FDC \ + --partition-guid=2:970C694F-AFD0-4B99-B750-CDB7A329AB6F \ + --partition-guid=3:${rootGPUID} \ + $diskImage + ''} + ''; + hybrid = '' + parted --script $diskImage -- \ + mklabel gpt \ + mkpart ESP fat32 8MiB ${bootSize} \ + set 1 boot on \ + mkpart no-fs 0 1024KiB \ + set 2 bios_grub on \ + mkpart primary ext4 ${bootSize} -1 + ${optionalString deterministic '' + sgdisk \ + --disk-guid=97FD5997-D90B-4AA3-8D16-C1723AEA73C \ + --partition-guid=1:1C06F03B-704E-4657-B9CD-681A087A2FDC \ + --partition-guid=2:970C694F-AFD0-4B99-B750-CDB7A329AB6F \ + --partition-guid=3:${rootGPUID} \ + $diskImage + ''} + ''; + none = ""; + }.${partitionTableType}; + + useEFIBoot = touchEFIVars; + + nixpkgs = cleanSource pkgs.path; + + # FIXME: merge with channel.nix / make-channel.nix. + channelSources = pkgs.runCommand "nixos-${config.system.nixos.version}" {} '' + mkdir -p $out + cp -prd ${nixpkgs.outPath} $out/nixos + chmod -R u+w $out/nixos + if [ ! -e $out/nixos/nixpkgs ]; then + ln -s . $out/nixos/nixpkgs + fi + rm -rf $out/nixos/.git + echo -n ${config.system.nixos.versionSuffix} > $out/nixos/.version-suffix + ''; + + binPath = with pkgs; makeBinPath ( + [ rsync + util-linux + parted + e2fsprogs + lkl + config.system.build.nixos-install + config.system.build.nixos-enter + nix + systemdMinimal + ] + ++ lib.optional deterministic gptfdisk + ++ stdenv.initialPath); + + # I'm preserving the line below because I'm going to search for it across nixpkgs to consolidate + # image building logic. The comment right below this now appears in 4 different places in nixpkgs :) + # !!! should use XML. + sources = map (x: x.source) contents; + targets = map (x: x.target) contents; + modes = map (x: x.mode or "''") contents; + users = map (x: x.user or "''") contents; + groups = map (x: x.group or "''") contents; + + basePaths = [ config.system.build.toplevel ] + ++ lib.optional copyChannel channelSources; + + additionalPaths' = subtractLists basePaths additionalPaths; + + closureInfo = pkgs.closureInfo { + rootPaths = basePaths ++ additionalPaths'; + }; + + blockSize = toString (4 * 1024); # ext4fs block size (not block device sector size) + + prepareImage = '' + export PATH=${binPath} + + # Yes, mkfs.ext4 takes different units in different contexts. Fun. + sectorsToKilobytes() { + echo $(( ( "$1" * 512 ) / 1024 )) + } + + sectorsToBytes() { + echo $(( "$1" * 512 )) + } + + # Given lines of numbers, adds them together + sum_lines() { + local acc=0 + while read -r number; do + acc=$((acc+number)) + done + echo "$acc" + } + + mebibyte=$(( 1024 * 1024 )) + + # Approximative percentage of reserved space in an ext4 fs over 512MiB. + # 0.05208587646484375 + # × 1000, integer part: 52 + compute_fudge() { + echo $(( $1 * 52 / 1000 )) + } + + mkdir $out + + root="$PWD/root" + mkdir -p $root + + # Copy arbitrary other files into the image + # Semi-shamelessly copied from make-etc.sh. I (@copumpkin) shall factor this stuff out as part of + # https://github.com/NixOS/nixpkgs/issues/23052. + set -f + sources_=(${concatStringsSep " " sources}) + targets_=(${concatStringsSep " " targets}) + modes_=(${concatStringsSep " " modes}) + set +f + + for ((i = 0; i < ''${#targets_[@]}; i++)); do + source="''${sources_[$i]}" + target="''${targets_[$i]}" + mode="''${modes_[$i]}" + + if [ -n "$mode" ]; then + rsync_chmod_flags="--chmod=$mode" + else + rsync_chmod_flags="" + fi + # Unfortunately cptofs only supports modes, not ownership, so we can't use + # rsync's --chown option. Instead, we change the ownerships in the + # VM script with chown. + rsync_flags="-a --no-o --no-g $rsync_chmod_flags" + if [[ "$source" =~ '*' ]]; then + # If the source name contains '*', perform globbing. + mkdir -p $root/$target + for fn in $source; do + rsync $rsync_flags "$fn" $root/$target/ + done + else + mkdir -p $root/$(dirname $target) + if [ -e $root/$target ]; then + echo "duplicate entry $target -> $source" + exit 1 + elif [ -d $source ]; then + # Append a slash to the end of source to get rsync to copy the + # directory _to_ the target instead of _inside_ the target. + # (See `man rsync`'s note on a trailing slash.) + rsync $rsync_flags $source/ $root/$target + else + rsync $rsync_flags $source $root/$target + fi + fi + done + + export HOME=$TMPDIR + + # Provide a Nix database so that nixos-install can copy closures. + export NIX_STATE_DIR=$TMPDIR/state + nix-store --load-db < ${closureInfo}/registration + + chmod 755 "$TMPDIR" + echo "running nixos-install..." + nixos-install --root $root --no-bootloader --no-root-passwd \ + --system ${config.system.build.toplevel} \ + ${if copyChannel then "--channel ${channelSources}" else "--no-channel-copy"} \ + --substituters "" + + ${optionalString (additionalPaths' != []) '' + nix --extra-experimental-features nix-command copy --to $root --no-check-sigs ${concatStringsSep " " additionalPaths'} + ''} + + diskImage=nixos.raw + + ${if diskSize == "auto" then '' + ${if partitionTableType == "efi" || partitionTableType == "efixbootldr" || partitionTableType == "hybrid" then '' + # Add the GPT at the end + gptSpace=$(( 512 * 34 * 1 )) + # Normally we'd need to account for alignment and things, if bootSize + # represented the actual size of the boot partition. But it instead + # represents the offset at which it ends. + # So we know bootSize is the reserved space in front of the partition. + reservedSpace=$(( gptSpace + $(numfmt --from=iec '${bootSize}') )) + '' else if partitionTableType == "legacy+gpt" then '' + # Add the GPT at the end + gptSpace=$(( 512 * 34 * 1 )) + # And include the bios_grub partition; the ext4 partition starts at 2MB exactly. + reservedSpace=$(( gptSpace + 2 * mebibyte )) + '' else if partitionTableType == "legacy" then '' + # Add the 1MiB aligned reserved space (includes MBR) + reservedSpace=$(( mebibyte )) + '' else '' + reservedSpace=0 + ''} + additionalSpace=$(( $(numfmt --from=iec '${additionalSpace}') + reservedSpace )) + + # Compute required space in filesystem blocks + diskUsage=$(find . ! -type d -print0 | du --files0-from=- --apparent-size --block-size "${blockSize}" | cut -f1 | sum_lines) + # Each inode takes space! + numInodes=$(find . | wc -l) + # Convert to bytes, inodes take two blocks each! + diskUsage=$(( (diskUsage + 2 * numInodes) * ${blockSize} )) + # Then increase the required space to account for the reserved blocks. + fudge=$(compute_fudge $diskUsage) + requiredFilesystemSpace=$(( diskUsage + fudge )) + + diskSize=$(( requiredFilesystemSpace + additionalSpace )) + + # Round up to the nearest mebibyte. + # This ensures whole 512 bytes sector sizes in the disk image + # and helps towards aligning partitions optimally. + if (( diskSize % mebibyte )); then + diskSize=$(( ( diskSize / mebibyte + 1) * mebibyte )) + fi + + truncate -s "$diskSize" $diskImage + + printf "Automatic disk size...\n" + printf " Closure space use: %d bytes\n" $diskUsage + printf " fudge: %d bytes\n" $fudge + printf " Filesystem size needed: %d bytes\n" $requiredFilesystemSpace + printf " Additional space: %d bytes\n" $additionalSpace + printf " Disk image size: %d bytes\n" $diskSize + '' else '' + truncate -s ${toString diskSize}M $diskImage + ''} + + ${partitionDiskScript} + + ${if partitionTableType != "none" then '' + # Get start & length of the root partition in sectors to $START and $SECTORS. + eval $(partx $diskImage -o START,SECTORS --nr ${rootPartition} --pairs) + + mkfs.${fsType} -b ${blockSize} -F -L ${label} $diskImage -E offset=$(sectorsToBytes $START) $(sectorsToKilobytes $SECTORS)K + '' else '' + mkfs.${fsType} -b ${blockSize} -F -L ${label} $diskImage + ''} + + echo "copying staging root to image..." + cptofs -p ${optionalString (partitionTableType != "none") "-P ${rootPartition}"} \ + -t ${fsType} \ + -i $diskImage \ + $root${optionalString onlyNixStore builtins.storeDir}/* / || + (echo >&2 "ERROR: cptofs failed. diskSize might be too small for closure."; exit 1) + ''; + + moveOrConvertImage = '' + ${if format == "raw" then '' + mv $diskImage $out/${filename} + '' else '' + ${pkgs.qemu-utils}/bin/qemu-img convert -f raw -O ${format} ${compress} $diskImage $out/${filename} + ''} + diskImage=$out/${filename} + ''; + + createEFIVars = '' + efiVars=$out/efi-vars.fd + cp ${efiVariables} $efiVars + chmod 0644 $efiVars + ''; + + createHydraBuildProducts = '' + mkdir -p $out/nix-support + echo "file ${format}-image $out/${filename}" >> $out/nix-support/hydra-build-products + ''; + + buildImage = pkgs.vmTools.runInLinuxVM ( + pkgs.runCommand name { + preVM = prepareImage + lib.optionalString touchEFIVars createEFIVars; + buildInputs = with pkgs; [ util-linux e2fsprogs dosfstools ]; + postVM = moveOrConvertImage + createHydraBuildProducts + postVM; + QEMU_OPTS = + concatStringsSep " " (lib.optional useEFIBoot "-drive if=pflash,format=raw,unit=0,readonly=on,file=${efiFirmware}" + ++ lib.optionals touchEFIVars [ + "-drive if=pflash,format=raw,unit=1,file=$efiVars" + ] ++ lib.optionals (OVMF.systemManagementModeRequired or false) [ + "-machine" "q35,smm=on" + "-global" "driver=cfi.pflash01,property=secure,value=on" + ] + ); + inherit memSize; + } '' + export PATH=${binPath}:$PATH + + rootDisk=${if partitionTableType != "none" then "/dev/vda${rootPartition}" else "/dev/vda"} + + # It is necessary to set root filesystem unique identifier in advance, otherwise + # bootloader might get the wrong one and fail to boot. + # At the end, we reset again because we want deterministic timestamps. + ${optionalString (fsType == "ext4" && deterministic) '' + tune2fs -T now ${optionalString deterministic "-U ${rootFSUID}"} -c 0 -i 0 $rootDisk + ''} + # make systemd-boot find ESP without udev + mkdir /dev/block + ln -s /dev/vda1 /dev/block/254:1 + + mountPoint=/mnt + mkdir $mountPoint + mount $rootDisk $mountPoint + + # Create the ESP and mount it. Unlike e2fsprogs, mkfs.vfat doesn't support an + # '-E offset=X' option, so we can't do this outside the VM. + ${optionalString (partitionTableType == "efi" || partitionTableType == "hybrid") '' + mkdir -p /mnt/boot + mkfs.vfat -n ESP /dev/vda1 + mount /dev/vda1 /mnt/boot + + ${optionalString touchEFIVars "mount -t efivarfs efivarfs /sys/firmware/efi/efivars"} + ''} + ${optionalString (partitionTableType == "efixbootldr") '' + mkdir -p /mnt/{boot,efi} + mkfs.vfat -n ESP /dev/vda1 + mkfs.vfat -n BOOT /dev/vda2 + mount /dev/vda1 /mnt/efi + mount /dev/vda2 /mnt/boot + + ${optionalString touchEFIVars "mount -t efivarfs efivarfs /sys/firmware/efi/efivars"} + ''} + + # Install a configuration.nix + mkdir -p /mnt/etc/nixos + ${optionalString (configFile != null) '' + cp ${configFile} /mnt/etc/nixos/configuration.nix + ''} + + ${lib.optionalString installBootLoader '' + # In this throwaway resource, we only have /dev/vda, but the actual VM may refer to another disk for bootloader, e.g. /dev/vdb + # Use this option to create a symlink from vda to any arbitrary device you want. + ${optionalString (config.boot.loader.grub.enable && config.boot.loader.grub.device != "/dev/vda") '' + mkdir -p $(dirname ${config.boot.loader.grub.device}) + ln -s /dev/vda ${config.boot.loader.grub.device} + ''} + + # Set up core system link, bootloader (sd-boot, GRUB, uboot, etc.), etc. + + # NOTE: systemd-boot-builder.py calls nix-env --list-generations which + # clobbers $HOME/.nix-defexpr/channels/nixos This would cause a folder + # /homeless-shelter to show up in the final image which in turn breaks + # nix builds in the target image if sandboxing is turned off (through + # __noChroot for example). + export HOME=$TMPDIR + NIXOS_INSTALL_BOOTLOADER=1 nixos-enter --root $mountPoint -- /nix/var/nix/profiles/system/bin/switch-to-configuration boot + + # The above scripts will generate a random machine-id and we don't want to bake a single ID into all our images + rm -f $mountPoint/etc/machine-id + ''} + + # Set the ownerships of the contents. The modes are set in preVM. + # No globbing on targets, so no need to set -f + targets_=(${concatStringsSep " " targets}) + users_=(${concatStringsSep " " users}) + groups_=(${concatStringsSep " " groups}) + for ((i = 0; i < ''${#targets_[@]}; i++)); do + target="''${targets_[$i]}" + user="''${users_[$i]}" + group="''${groups_[$i]}" + if [ -n "$user$group" ]; then + # We have to nixos-enter since we need to use the user and group of the VM + nixos-enter --root $mountPoint -- chown -R "$user:$group" "$target" + fi + done + + umount -R /mnt + + # Make sure resize2fs works. Note that resize2fs has stricter criteria for resizing than a normal + # mount, so the `-c 0` and `-i 0` don't affect it. Setting it to `now` doesn't produce deterministic + # output, of course, but we can fix that when/if we start making images deterministic. + # In deterministic mode, this is fixed to 1970-01-01 (UNIX timestamp 0). + # This two-step approach is necessary otherwise `tune2fs` will want a fresher filesystem to perform + # some changes. + ${optionalString (fsType == "ext4") '' + tune2fs -T now ${optionalString deterministic "-U ${rootFSUID}"} -c 0 -i 0 $rootDisk + ${optionalString deterministic "tune2fs -f -T 19700101 $rootDisk"} + ''} + '' + ); +in + if onlyNixStore then + pkgs.runCommand name {} + (prepareImage + moveOrConvertImage + createHydraBuildProducts + postVM) + else buildImage diff --git a/npins/default.nix b/npins/default.nix new file mode 100644 index 0000000..4a7c372 --- /dev/null +++ b/npins/default.nix @@ -0,0 +1,47 @@ +# Generated by npins. Do not modify; will be overwritten regularly +let + data = builtins.fromJSON (builtins.readFile ./sources.json); + version = data.version; + + mkSource = spec: + assert spec ? type; let + path = + if spec.type == "Git" then mkGitSource spec + else if spec.type == "GitRelease" then mkGitSource spec + else if spec.type == "PyPi" then mkPyPiSource spec + else if spec.type == "Channel" then mkChannelSource spec + else builtins.throw "Unknown source type ${spec.type}"; + in + spec // { outPath = path; }; + + mkGitSource = { repository, revision, url ? null, hash, ... }: + assert repository ? type; + # At the moment, either it is a plain git repository (which has an url), or it is a GitHub/GitLab repository + # In the latter case, there we will always be an url to the tarball + if url != null then + (builtins.fetchTarball { + inherit url; + sha256 = hash; # FIXME: check nix version & use SRI hashes + }) + else assert repository.type == "Git"; builtins.fetchGit { + url = repository.url; + rev = revision; + # hash = hash; + }; + + mkPyPiSource = { url, hash, ... }: + builtins.fetchurl { + inherit url; + sha256 = hash; + }; + + mkChannelSource = { url, hash, ... }: + builtins.fetchTarball { + inherit url; + sha256 = hash; + }; +in +if version == 3 then + builtins.mapAttrs (_: mkSource) data.pins +else + throw "Unsupported format version ${toString version} in sources.json. Try running `npins upgrade`" diff --git a/npins/sources.json b/npins/sources.json new file mode 100644 index 0000000..85d1c85 --- /dev/null +++ b/npins/sources.json @@ -0,0 +1,29 @@ +{ + "pins": { + "disko": { + "type": "Git", + "repository": { + "type": "GitHub", + "owner": "nix-community", + "repo": "disko" + }, + "branch": "master", + "revision": "5f6dbcce99d60dd77f96dfc66d06bbea149a40e1", + "url": "https://github.com/nix-community/disko/archive/5f6dbcce99d60dd77f96dfc66d06bbea149a40e1.tar.gz", + "hash": "0jg9m6hdxgbj2ivr846hd7df3wrm5c3saymh5ymdzvf6nf37fils" + }, + "nixpkgs": { + "type": "Git", + "repository": { + "type": "GitHub", + "owner": "nixos", + "repo": "nixpkgs" + }, + "branch": "nixos-unstable", + "revision": "33d1e753c82ffc557b4a585c77de43d4c922ebb5", + "url": "https://github.com/nixos/nixpkgs/archive/33d1e753c82ffc557b4a585c77de43d4c922ebb5.tar.gz", + "hash": "0s5rrp2spdqdc21h4faahq2h5giaznq8n757j20zl9ap8d7jk03i" + } + }, + "version": 3 +} \ No newline at end of file diff --git a/qemu-common.nix b/qemu-common.nix new file mode 100644 index 0000000..b946f62 --- /dev/null +++ b/qemu-common.nix @@ -0,0 +1,65 @@ +# QEMU-related utilities shared between various Nix expressions. +{ lib, pkgs }: + +let + zeroPad = n: + lib.optionalString (n < 16) "0" + + (if n > 255 + then throw "Can't have more than 255 nets or nodes!" + else lib.toHexString n); +in + +rec { + qemuNicMac = net: machine: "52:54:00:12:${zeroPad net}:${zeroPad machine}"; + + qemuNICFlags = nic: net: machine: + [ + "-device virtio-net-pci,netdev=vlan${toString nic},mac=${qemuNicMac net machine}" + ''-netdev vde,id=vlan${toString nic},sock="$QEMU_VDE_SOCKET_${toString net}"'' + ]; + + qemuSerialDevice = + if with pkgs.stdenv.hostPlatform; isx86 || isLoongArch64 || isMips64 || isRiscV then "ttyS0" + else if (with pkgs.stdenv.hostPlatform; isAarch || isPower) then "ttyAMA0" + else throw "Unknown QEMU serial device for system '${pkgs.stdenv.hostPlatform.system}'"; + + qemuBinary = qemuPkg: + let + hostStdenv = qemuPkg.stdenv; + hostSystem = hostStdenv.system; + guestSystem = pkgs.stdenv.hostPlatform.system; + + linuxHostGuestMatrix = { + x86_64-linux = "${qemuPkg}/bin/qemu-kvm -cpu max"; + armv7l-linux = "${qemuPkg}/bin/qemu-system-arm -machine virt,accel=kvm:tcg -cpu max"; + aarch64-linux = "${qemuPkg}/bin/qemu-system-aarch64 -machine virt,gic-version=max,accel=kvm:tcg -cpu max"; + powerpc64le-linux = "${qemuPkg}/bin/qemu-system-ppc64 -machine powernv"; + powerpc64-linux = "${qemuPkg}/bin/qemu-system-ppc64 -machine powernv"; + x86_64-darwin = "${qemuPkg}/bin/qemu-kvm -cpu max"; + }; + otherHostGuestMatrix = { + aarch64-darwin = { + aarch64-linux = "${qemuPkg}/bin/qemu-system-aarch64 -machine virt,gic-version=2,accel=hvf:tcg -cpu max"; + inherit (otherHostGuestMatrix.x86_64-darwin) x86_64-linux; + }; + x86_64-darwin = { + x86_64-linux = "${qemuPkg}/bin/qemu-system-x86_64 -machine type=q35,accel=hvf:tcg -cpu max"; + }; + }; + + throwUnsupportedHostSystem = + let + supportedSystems = [ "linux" ] ++ (lib.attrNames otherHostGuestMatrix); + in + throw "Unsupported host system ${hostSystem}, supported: ${lib.concatStringsSep ", " supportedSystems}"; + throwUnsupportedGuestSystem = guestMap: + throw "Unsupported guest system ${guestSystem} for host ${hostSystem}, supported: ${lib.concatStringsSep ", " (lib.attrNames guestMap)}"; + in + if hostStdenv.isLinux then + linuxHostGuestMatrix.${guestSystem} or "${qemuPkg}/bin/qemu-kvm" + else + let + guestMap = (otherHostGuestMatrix.${hostSystem} or throwUnsupportedHostSystem); + in + (guestMap.${guestSystem} or (throwUnsupportedGuestSystem guestMap)); +} diff --git a/qemu-guest.nix b/qemu-guest.nix new file mode 100644 index 0000000..8b3df97 --- /dev/null +++ b/qemu-guest.nix @@ -0,0 +1,17 @@ +# Common configuration for virtual machines running under QEMU (using +# virtio). + +{ config, lib, ... }: + +{ + boot.initrd.availableKernelModules = [ "virtio_net" "virtio_pci" "virtio_mmio" "virtio_blk" "virtio_scsi" "9p" "9pnet_virtio" ]; + boot.initrd.kernelModules = [ "virtio_balloon" "virtio_console" "virtio_rng" ]; + + boot.initrd.postDeviceCommands = lib.mkIf (!config.boot.initrd.systemd.enable) + '' + # Set the system time from the hardware clock to work around a + # bug in qemu-kvm > 1.5.2 (where the VM clock is initialised + # to the *boot time* of the host). + hwclock -s + ''; +} diff --git a/qemu-vm.nix b/qemu-vm.nix new file mode 100644 index 0000000..7ed1bd0 --- /dev/null +++ b/qemu-vm.nix @@ -0,0 +1,1411 @@ +# This module creates a virtual machine from the NixOS configuration. +# Building the `config.system.build.vm' attribute gives you a command +# that starts a KVM/QEMU VM running the NixOS configuration defined in +# `config'. By default, the Nix store is shared read-only with the +# host, which makes (re)building VMs very efficient. + +{ + config, + lib, + pkgs, + options, + ... +}: + +with lib; + +let + + qemu-common = import ./qemu-common.nix { inherit lib pkgs; }; + + cfg = config.virtualisation; + + opt = options.virtualisation; + + qemu = cfg.qemu.package; + + hostPkgs = cfg.host.pkgs; + + consoles = lib.concatMapStringsSep " " (c: "console=${c}") cfg.qemu.consoles; + + driveOpts = + { ... }: + { + + options = { + + file = mkOption { + type = types.str; + description = lib.mdDoc "The file image used for this drive."; + }; + + driveExtraOpts = mkOption { + type = types.attrsOf types.str; + default = { }; + description = lib.mdDoc "Extra options passed to drive flag."; + }; + + deviceExtraOpts = mkOption { + type = types.attrsOf types.str; + default = { }; + description = lib.mdDoc "Extra options passed to device flag."; + }; + + name = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc "A name for the drive. Must be unique in the drives list. Not passed to qemu."; + }; + }; + }; + + selectPartitionTableLayout = + { useEFIBoot, useDefaultFilesystems }: + if useDefaultFilesystems then if useEFIBoot then "efi" else "legacy" else "none"; + + driveCmdline = + idx: + { + file, + driveExtraOpts, + deviceExtraOpts, + ... + }: + let + drvId = "drive${toString idx}"; + mkKeyValue = generators.mkKeyValueDefault { } "="; + mkOpts = opts: concatStringsSep "," (mapAttrsToList mkKeyValue opts); + driveOpts = mkOpts ( + driveExtraOpts + // { + index = idx; + id = drvId; + "if" = "none"; + inherit file; + } + ); + deviceOpts = mkOpts (deviceExtraOpts // { drive = drvId; }); + device = + if cfg.qemu.diskInterface == "scsi" then + "-device lsi53c895a -device scsi-hd,${deviceOpts}" + else + "-device virtio-blk-pci,${deviceOpts}"; + in + "-drive ${driveOpts} ${device}"; + + drivesCmdLine = drives: concatStringsSep "\\\n " (imap1 driveCmdline drives); + + # Shell script to start the VM. + startVM = '' + #! ${hostPkgs.runtimeShell} + + export PATH=${makeBinPath [ hostPkgs.coreutils ]}''${PATH:+:}$PATH + + set -e + + # Create an empty ext4 filesystem image. A filesystem image does not + # contain a partition table but just a filesystem. + createEmptyFilesystemImage() { + local name=$1 + local size=$2 + local temp=$(mktemp) + ${qemu}/bin/qemu-img create -f raw "$temp" "$size" + ${hostPkgs.e2fsprogs}/bin/mkfs.ext4 -L ${rootFilesystemLabel} "$temp" + ${qemu}/bin/qemu-img convert -f raw -O qcow2 "$temp" "$name" + rm "$temp" + } + + NIX_DISK_IMAGE=$(readlink -f "''${NIX_DISK_IMAGE:-${toString config.virtualisation.diskImage}}") || test -z "$NIX_DISK_IMAGE" + + if test -n "$NIX_DISK_IMAGE" && ! test -e "$NIX_DISK_IMAGE"; then + echo "Disk image do not exist, creating the virtualisation disk image..." + + ${ + if (cfg.useBootLoader && cfg.useDefaultFilesystems) then + '' + # Create a writable qcow2 image using the systemImage as a backing + # image. + + # CoW prevent size to be attributed to an image. + # FIXME: raise this issue to upstream. + ${qemu}/bin/qemu-img create \ + -f qcow2 \ + -b ${systemImage}/nixos.qcow2 \ + -F qcow2 \ + "$NIX_DISK_IMAGE" + '' + else if cfg.useDefaultFilesystems then + '' + createEmptyFilesystemImage "$NIX_DISK_IMAGE" "${toString cfg.diskSize}M" + '' + else + '' + # Create an empty disk image without a filesystem. + ${qemu}/bin/qemu-img create -f qcow2 "$NIX_DISK_IMAGE" "${toString cfg.diskSize}M" + '' + } + echo "Virtualisation disk image created." + fi + + # Create a directory for storing temporary data of the running VM. + if [ -z "$TMPDIR" ] || [ -z "$USE_TMPDIR" ]; then + TMPDIR=$(mktemp -d nix-vm.XXXXXXXXXX --tmpdir) + fi + + ${lib.optionalString (cfg.useNixStoreImage) ( + if cfg.writableStore then + '' + # Create a writable copy/snapshot of the store image. + ${qemu}/bin/qemu-img create -f qcow2 -F qcow2 -b ${storeImage}/nixos.qcow2 "$TMPDIR"/store.img + '' + else + '' + ( + cd ${builtins.storeDir} + ${hostPkgs.erofs-utils}/bin/mkfs.erofs \ + --force-uid=0 \ + --force-gid=0 \ + -L ${nixStoreFilesystemLabel} \ + -U eb176051-bd15-49b7-9e6b-462e0b467019 \ + -T 0 \ + --exclude-regex="$( + <${ + hostPkgs.closureInfo { + rootPaths = [ + config.system.build.toplevel + regInfo + ]; + } + }/store-paths \ + sed -e 's^.*/^^g' \ + | cut -c -10 \ + | ${hostPkgs.python3}/bin/python ${./includes-to-excludes.py} )" \ + "$TMPDIR"/store.img \ + . \ + /dev/null + ) + '' + )} + + # Create a directory for exchanging data with the VM. + mkdir -p "$TMPDIR/xchg" + + ${lib.optionalString cfg.useHostCerts '' + mkdir -p "$TMPDIR/certs" + if [ -e "$NIX_SSL_CERT_FILE" ]; then + cp -L "$NIX_SSL_CERT_FILE" "$TMPDIR"/certs/ca-certificates.crt + else + echo \$NIX_SSL_CERT_FILE should point to a valid file if virtualisation.useHostCerts is enabled. + fi + ''} + + ${lib.optionalString cfg.useEFIBoot '' + # Expose EFI variables, it's useful even when we are not using a bootloader (!). + # We might be interested in having EFI variable storage present even if we aren't booting via UEFI, hence + # no guard against `useBootLoader`. Examples: + # - testing PXE boot or other EFI applications + # - directbooting LinuxBoot, which `kexec()s` into a UEFI environment that can boot e.g. Windows + NIX_EFI_VARS=$(readlink -f "''${NIX_EFI_VARS:-${config.system.name}-efi-vars.fd}") + # VM needs writable EFI vars + if ! test -e "$NIX_EFI_VARS"; then + ${ + if cfg.useBootLoader then + # We still need the EFI var from the make-disk-image derivation + # because our "switch-to-configuration" process might + # write into it and we want to keep this data. + ''cp ${systemImage}/efi-vars.fd "$NIX_EFI_VARS"'' + else + ''cp ${cfg.efi.variables} "$NIX_EFI_VARS"'' + } + chmod 0644 "$NIX_EFI_VARS" + fi + ''} + + ${lib.optionalString cfg.tpm.enable '' + NIX_SWTPM_DIR=$(readlink -f "''${NIX_SWTPM_DIR:-${config.system.name}-swtpm}") + mkdir -p "$NIX_SWTPM_DIR" + ${lib.getExe cfg.tpm.package} \ + socket \ + --tpmstate dir="$NIX_SWTPM_DIR" \ + --ctrl type=unixio,path="$NIX_SWTPM_DIR"/socket,terminate \ + --pid file="$NIX_SWTPM_DIR"/pid --daemon \ + --tpm2 \ + --log file="$NIX_SWTPM_DIR"/stdout,level=6 + + # Enable `fdflags` builtin in Bash + # We will need it to perform surgical modification of the file descriptor + # passed in the coprocess to remove `FD_CLOEXEC`, i.e. close the file descriptor + # on exec. + # If let alone, it will trigger the coprocess to read EOF when QEMU is `exec` + # at the end of this script. To work around that, we will just clear + # the `FD_CLOEXEC` bits as a first step. + enable -f ${hostPkgs.bash}/lib/bash/fdflags fdflags + # leave a dangling subprocess because the swtpm ctrl socket has + # "terminate" when the last connection disconnects, it stops swtpm. + # When qemu stops, or if the main shell process ends, the coproc will + # get signaled by virtue of the pipe between main and coproc ending. + # Which in turns triggers a socat connect-disconnect to swtpm which + # will stop it. + coproc waitingswtpm { + read || : + echo "" | ${lib.getExe hostPkgs.socat} STDIO UNIX-CONNECT:"$NIX_SWTPM_DIR"/socket + } + # Clear `FD_CLOEXEC` on the coprocess' file descriptor stdin. + fdflags -s-cloexec ''${waitingswtpm[1]} + ''} + + cd "$TMPDIR" + + ${lib.optionalString (cfg.emptyDiskImages != [ ]) "idx=0"} + ${flip concatMapStrings cfg.emptyDiskImages (size: '' + if ! test -e "empty$idx.qcow2"; then + ${qemu}/bin/qemu-img create -f qcow2 "empty$idx.qcow2" "${toString size}M" + fi + idx=$((idx + 1)) + '')} + + # Start QEMU. + exec ${qemu-common.qemuBinary qemu} \ + -name ${config.system.name} \ + -m ${toString config.virtualisation.memorySize} \ + -smp ${toString config.virtualisation.cores} \ + -device virtio-rng-pci \ + ${concatStringsSep " " config.virtualisation.qemu.networkingOptions} \ + ${ + concatStringsSep " \\\n " ( + mapAttrsToList ( + tag: share: "-virtfs local,path=${share.source},security_model=none,mount_tag=${tag}" + ) config.virtualisation.sharedDirectories + ) + } \ + ${drivesCmdLine config.virtualisation.qemu.drives} \ + ${concatStringsSep " \\\n " config.virtualisation.qemu.options} \ + $QEMU_OPTS \ + "$@" + ''; + + regInfo = hostPkgs.closureInfo { rootPaths = config.virtualisation.additionalPaths; }; + + # Use well-defined and persistent filesystem labels to identify block devices. + rootFilesystemLabel = "nixos"; + espFilesystemLabel = "ESP"; # Hard-coded by make-disk-image.nix + nixStoreFilesystemLabel = "nix-store"; + + # The root drive is a raw disk which does not necessarily contain a + # filesystem or partition table. It thus cannot be identified via the typical + # persistent naming schemes (e.g. /dev/disk/by-{label, uuid, partlabel, + # partuuid}. Instead, supply a well-defined and persistent serial attribute + # via QEMU. Inside the running system, the disk can then be identified via + # the /dev/disk/by-id scheme. + rootDriveSerialAttr = "root"; + + # System image is akin to a complete NixOS install with + # a boot partition and root partition. + systemImage = import ./make-disk-image.nix { + inherit pkgs config lib; + additionalPaths = [ regInfo ]; + format = "qcow2"; + onlyNixStore = false; + label = rootFilesystemLabel; + partitionTableType = selectPartitionTableLayout { inherit (cfg) useDefaultFilesystems useEFIBoot; }; + # Bootloader should be installed on the system image only if we are booting through bootloaders. + # Though, if a user is not using our default filesystems, it is possible to not have any ESP + # or a strange partition table that's incompatible with GRUB configuration. + # As a consequence, this may lead to disk image creation failures. + # To avoid this, we prefer to let the user find out about how to install the bootloader on its ESP/disk. + # Usually, this can be through building your own disk image. + # TODO: If a user is interested into a more fine grained heuristic for `installBootLoader` + # by examining the actual contents of `cfg.fileSystems`, please send a PR. + installBootLoader = cfg.useBootLoader && cfg.useDefaultFilesystems; + touchEFIVars = cfg.useEFIBoot; + diskSize = "auto"; + additionalSpace = "10G"; + copyChannel = false; + OVMF = cfg.efi.OVMF; + }; + + storeImage = import ./make-disk-image.nix { + name = "nix-store-image"; + inherit pkgs config lib; + additionalPaths = [ regInfo ]; + format = "qcow2"; + onlyNixStore = true; + label = nixStoreFilesystemLabel; + partitionTableType = "none"; + installBootLoader = false; + touchEFIVars = false; + diskSize = "auto"; + additionalSpace = "0M"; + copyChannel = false; + }; +in + +{ + imports = [ + ./qemu-guest.nix + (mkRenamedOptionModule + [ + "virtualisation" + "pathsInNixDB" + ] + [ + "virtualisation" + "additionalPaths" + ] + ) + (mkRemovedOptionModule + [ + "virtualisation" + "bootDevice" + ] + "This option was renamed to `virtualisation.rootDevice`, as it was incorrectly named and misleading. Take the time to review what you want to do and look at the new options like `virtualisation.{bootLoaderDevice, bootPartition}`, open an issue in case of issues." + ) + (mkRemovedOptionModule + [ + "virtualisation" + "efiVars" + ] + "This option was removed, it is possible to provide a template UEFI variable with `virtualisation.efi.variables` ; if this option is important to you, open an issue" + ) + (mkRemovedOptionModule + [ + "virtualisation" + "persistBootDevice" + ] + "Boot device is always persisted if you use a bootloader through the root disk image ; if this does not work for your usecase, please examine carefully what `virtualisation.{bootDevice, rootDevice, bootPartition}` options offer you and open an issue explaining your need.`" + ) + ]; + + options = { + + virtualisation.fileSystems = options.fileSystems; + + virtualisation.memorySize = mkOption { + type = types.ints.positive; + default = 1024; + description = lib.mdDoc '' + The memory size in megabytes of the virtual machine. + ''; + }; + + virtualisation.msize = mkOption { + type = types.ints.positive; + default = 16384; + description = lib.mdDoc '' + The msize (maximum packet size) option passed to 9p file systems, in + bytes. Increasing this should increase performance significantly, + at the cost of higher RAM usage. + ''; + }; + + virtualisation.diskSize = mkOption { + type = types.nullOr types.ints.positive; + default = 1024; + description = lib.mdDoc '' + The disk size in megabytes of the virtual machine. + ''; + }; + + virtualisation.diskImage = mkOption { + type = types.nullOr types.str; + default = "./${config.system.name}.qcow2"; + defaultText = literalExpression ''"./''${config.system.name}.qcow2"''; + description = lib.mdDoc '' + Path to the disk image containing the root filesystem. + The image will be created on startup if it does not + exist. + + If null, a tmpfs will be used as the root filesystem and + the VM's state will not be persistent. + ''; + }; + + virtualisation.bootLoaderDevice = mkOption { + type = types.path; + default = "/dev/disk/by-id/virtio-${rootDriveSerialAttr}"; + defaultText = literalExpression ''/dev/disk/by-id/virtio-${rootDriveSerialAttr}''; + example = "/dev/disk/by-id/virtio-boot-loader-device"; + description = lib.mdDoc '' + The path (inside th VM) to the device to boot from when legacy booting. + ''; + }; + + virtualisation.bootPartition = mkOption { + type = types.nullOr types.path; + default = if cfg.useEFIBoot then "/dev/disk/by-label/${espFilesystemLabel}" else null; + defaultText = literalExpression ''if cfg.useEFIBoot then "/dev/disk/by-label/${espFilesystemLabel}" else null''; + example = "/dev/disk/by-label/esp"; + description = lib.mdDoc '' + The path (inside the VM) to the device containing the EFI System Partition (ESP). + + If you are *not* booting from a UEFI firmware, this value is, by + default, `null`. The ESP is mounted under `/boot`. + ''; + }; + + virtualisation.rootDevice = mkOption { + type = types.nullOr types.path; + default = "/dev/disk/by-label/${rootFilesystemLabel}"; + defaultText = literalExpression ''/dev/disk/by-label/${rootFilesystemLabel}''; + example = "/dev/disk/by-label/nixos"; + description = lib.mdDoc '' + The path (inside the VM) to the device containing the root filesystem. + ''; + }; + + virtualisation.emptyDiskImages = mkOption { + type = types.listOf types.ints.positive; + default = [ ]; + description = lib.mdDoc '' + Additional disk images to provide to the VM. The value is + a list of size in megabytes of each disk. These disks are + writeable by the VM. + ''; + }; + + virtualisation.graphics = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Whether to run QEMU with a graphics window, or in nographic mode. + Serial console will be enabled on both settings, but this will + change the preferred console. + ''; + }; + + virtualisation.resolution = mkOption { + type = options.services.xserver.resolutions.type.nestedTypes.elemType; + default = { + x = 1024; + y = 768; + }; + description = lib.mdDoc '' + The resolution of the virtual machine display. + ''; + }; + + virtualisation.cores = mkOption { + type = types.ints.positive; + default = 1; + description = lib.mdDoc '' + 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.sharedDirectories = mkOption { + type = types.attrsOf ( + types.submodule { + options.source = mkOption { + type = types.str; + description = lib.mdDoc "The path of the directory to share, can be a shell variable"; + }; + options.target = mkOption { + type = types.path; + description = lib.mdDoc "The mount point of the directory inside the virtual machine"; + }; + } + ); + default = { }; + example = { + my-share = { + source = "/path/to/be/shared"; + target = "/mnt/shared"; + }; + }; + description = lib.mdDoc '' + An attributes set of directories that will be shared with the + virtual machine using VirtFS (9P filesystem over VirtIO). + The attribute name will be used as the 9P mount tag. + ''; + }; + + virtualisation.additionalPaths = mkOption { + type = types.listOf types.path; + default = [ ]; + description = lib.mdDoc '' + A list of paths whose closure should be made available to + the VM. + + When 9p is used, the closure is registered in the Nix + database in the VM. All other paths in the host Nix store + appear in the guest Nix store as well, but are considered + garbage (because they are not registered in the Nix + database of the guest). + + When {option}`virtualisation.useNixStoreImage` is + set, the closure is copied to the Nix store image. + ''; + }; + + virtualisation.forwardPorts = mkOption { + type = types.listOf ( + types.submodule { + options.from = mkOption { + type = types.enum [ + "host" + "guest" + ]; + default = "host"; + description = lib.mdDoc '' + Controls the direction in which the ports are mapped: + + - `"host"` means traffic from the host ports + is forwarded to the given guest port. + - `"guest"` means traffic from the guest ports + is forwarded to the given host port. + ''; + }; + options.proto = mkOption { + type = types.enum [ + "tcp" + "udp" + ]; + default = "tcp"; + description = lib.mdDoc "The protocol to forward."; + }; + options.host.address = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc "The IPv4 address of the host."; + }; + options.host.port = mkOption { + type = types.port; + description = lib.mdDoc "The host port to be mapped."; + }; + options.guest.address = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc "The IPv4 address on the guest VLAN."; + }; + options.guest.port = mkOption { + type = types.port; + description = lib.mdDoc "The guest port to be mapped."; + }; + } + ); + default = [ ]; + example = lib.literalExpression '' + [ # forward local port 2222 -> 22, to ssh into the VM + { from = "host"; host.port = 2222; guest.port = 22; } + + # forward local port 80 -> 10.0.2.10:80 in the VLAN + { from = "guest"; + guest.address = "10.0.2.10"; guest.port = 80; + host.address = "127.0.0.1"; host.port = 80; + } + ] + ''; + description = lib.mdDoc '' + When using the SLiRP user networking (default), this option allows to + forward ports to/from the host/guest. + + ::: {.warning} + If the NixOS firewall on the virtual machine is enabled, you also + have to open the guest ports to enable the traffic between host and + guest. + ::: + + ::: {.note} + Currently QEMU supports only IPv4 forwarding. + ::: + ''; + }; + + virtualisation.restrictNetwork = mkOption { + type = types.bool; + default = false; + example = true; + description = lib.mdDoc '' + If this option is enabled, the guest will be isolated, i.e. it will + not be able to contact the host and no guest IP packets will be + routed over the host to the outside. This option does not affect + any explicitly set forwarding rules. + ''; + }; + + virtualisation.vlans = mkOption { + type = types.listOf types.ints.unsigned; + default = if config.virtualisation.interfaces == { } then [ 1 ] else [ ]; + defaultText = lib.literalExpression ''if config.virtualisation.interfaces == {} then [ 1 ] else [ ]''; + example = [ + 1 + 2 + ]; + description = lib.mdDoc '' + Virtual networks to which the VM is connected. Each + number «N» in this list causes + the VM to have a virtual Ethernet interface attached to a + separate virtual network on which it will be assigned IP + address + `192.168.«N».«M»`, + where «M» is the index of this VM + in the list of VMs. + ''; + }; + + virtualisation.interfaces = mkOption { + default = { }; + example = { + enp1s0.vlan = 1; + }; + description = lib.mdDoc '' + Network interfaces to add to the VM. + ''; + type = + with types; + attrsOf (submodule { + options = { + vlan = mkOption { + type = types.ints.unsigned; + description = lib.mdDoc '' + VLAN to which the network interface is connected. + ''; + }; + + assignIP = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Automatically assign an IP address to the network interface using the same scheme as + virtualisation.vlans. + ''; + }; + }; + }); + }; + + virtualisation.writableStore = mkOption { + type = types.bool; + default = cfg.mountHostNixStore; + defaultText = literalExpression "cfg.mountHostNixStore"; + description = lib.mdDoc '' + If enabled, the Nix store in the VM is made writable by + layering an overlay filesystem on top of the host's Nix + store. + + By default, this is enabled if you mount a host Nix store. + ''; + }; + + virtualisation.writableStoreUseTmpfs = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Use a tmpfs for the writable store instead of writing to the VM's + own filesystem. + ''; + }; + + networking.primaryIPAddress = mkOption { + type = types.str; + default = ""; + internal = true; + description = lib.mdDoc "Primary IP address used in /etc/hosts."; + }; + + virtualisation.host.pkgs = mkOption { + type = options.nixpkgs.pkgs.type; + default = pkgs; + defaultText = literalExpression "pkgs"; + example = literalExpression '' + import pkgs.path { system = "x86_64-darwin"; } + ''; + description = lib.mdDoc '' + Package set to use for the host-specific packages of the VM runner. + Changing this to e.g. a Darwin package set allows running NixOS VMs on Darwin. + ''; + }; + + virtualisation.qemu = { + package = mkOption { + type = types.package; + default = + if hostPkgs.stdenv.hostPlatform.qemuArch == pkgs.stdenv.hostPlatform.qemuArch then + hostPkgs.qemu_kvm + else + hostPkgs.qemu; + defaultText = literalExpression "if hostPkgs.stdenv.hostPlatform.qemuArch == pkgs.stdenv.hostPlatform.qemuArch then config.virtualisation.host.pkgs.qemu_kvm else config.virtualisation.host.pkgs.qemu"; + example = literalExpression "pkgs.qemu_test"; + description = lib.mdDoc "QEMU package to use."; + }; + + options = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "-vga std" ]; + description = lib.mdDoc '' + Options passed to QEMU. + See [QEMU User Documentation](https://www.qemu.org/docs/master/system/qemu-manpage) for a complete list. + ''; + }; + + consoles = mkOption { + type = types.listOf types.str; + default = + let + consoles = [ + "${qemu-common.qemuSerialDevice},115200n8" + "tty0" + ]; + in + if cfg.graphics then consoles else reverseList consoles; + example = [ "console=tty1" ]; + description = lib.mdDoc '' + The output console devices to pass to the kernel command line via the + `console` parameter, the primary console is the last + item of this list. + + By default it enables both serial console and + `tty0`. The preferred console (last one) is based on + the value of {option}`virtualisation.graphics`. + ''; + }; + + networkingOptions = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ + "-net nic,netdev=user.0,model=virtio" + "-netdev user,id=user.0,\${QEMU_NET_OPTS:+,$QEMU_NET_OPTS}" + ]; + description = lib.mdDoc '' + Networking-related command-line options that should be passed to qemu. + The default is to use userspace networking (SLiRP). + See the [QEMU Wiki on Networking](https://wiki.qemu.org/Documentation/Networking) for details. + + If you override this option, be advised to keep + `''${QEMU_NET_OPTS:+,$QEMU_NET_OPTS}` (as seen in the example) + to keep the default runtime behaviour. + ''; + }; + + drives = mkOption { + type = types.listOf (types.submodule driveOpts); + description = lib.mdDoc "Drives passed to qemu."; + }; + + diskInterface = mkOption { + type = types.enum [ + "virtio" + "scsi" + "ide" + ]; + default = "virtio"; + example = "scsi"; + description = lib.mdDoc "The interface used for the virtual hard disks."; + }; + + guestAgent.enable = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Enable the Qemu guest agent. + ''; + }; + + virtioKeyboard = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Enable the virtio-keyboard device. + ''; + }; + }; + + virtualisation.useNixStoreImage = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Build and use a disk image for the Nix store, instead of + accessing the host's one through 9p. + + For applications which do a lot of reads from the store, + this can drastically improve performance, but at the cost of + disk space and image build time. + + As an alternative, you can use a bootloader which will provide you + with a full NixOS system image containing a Nix store and + avoid mounting the host nix store through + {option}`virtualisation.mountHostNixStore`. + ''; + }; + + virtualisation.mountHostNixStore = mkOption { + type = types.bool; + default = !cfg.useNixStoreImage && !cfg.useBootLoader; + defaultText = literalExpression "!cfg.useNixStoreImage && !cfg.useBootLoader"; + description = lib.mdDoc '' + Mount the host Nix store as a 9p mount. + ''; + }; + + virtualisation.directBoot = { + enable = mkOption { + type = types.bool; + default = !cfg.useBootLoader; + defaultText = "!cfg.useBootLoader"; + description = lib.mdDoc '' + If enabled, the virtual machine will boot directly into the kernel instead of through a bootloader. + Read more about this feature in the [QEMU documentation on Direct Linux Boot](https://qemu-project.gitlab.io/qemu/system/linuxboot.html) + + This is enabled by default. + If you want to test netboot, consider disabling this option. + Enable a bootloader with {option}`virtualisation.useBootLoader` if you need. + + Relevant parameters such as those set in `boot.initrd` and `boot.kernelParams` are also passed to QEMU. + Additional parameters can be supplied on invocation through the environment variable `$QEMU_KERNEL_PARAMS`. + They are added to the `-append` option, see [QEMU User Documentation](https://www.qemu.org/docs/master/system/qemu-manpage) for details + For example, to let QEMU use the parent terminal as the serial console, set `QEMU_KERNEL_PARAMS="console=ttyS0"`. + + This will not (re-)boot correctly into a system that has switched to a different configuration on disk. + ''; + }; + initrd = mkOption { + type = types.str; + default = "${config.system.build.initialRamdisk}/${config.system.boot.loader.initrdFile}"; + defaultText = "\${config.system.build.initialRamdisk}/\${config.system.boot.loader.initrdFile}"; + description = lib.mdDoc '' + In direct boot situations, you may want to influence the initrd to load + to use your own customized payload. + + This is useful if you want to test the netboot image without + testing the firmware or the loading part. + ''; + }; + }; + + virtualisation.useBootLoader = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Use a boot loader to boot the system. + This allows, among other things, testing the boot loader. + + If disabled, the kernel and initrd are directly booted, + forgoing any bootloader. + + Check the documentation on {option}`virtualisation.directBoot.enable` for details. + ''; + }; + + virtualisation.useEFIBoot = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + If enabled, the virtual machine will provide a EFI boot + manager. + useEFIBoot is ignored if useBootLoader == false. + ''; + }; + + virtualisation.efi = { + OVMF = mkOption { + type = types.package; + default = (pkgs.OVMF.override { secureBoot = cfg.useSecureBoot; }).fd; + defaultText = '' + (pkgs.OVMF.override { + secureBoot = cfg.useSecureBoot; + }).fd''; + description = lib.mdDoc "OVMF firmware package, defaults to OVMF configured with secure boot if needed."; + }; + + firmware = mkOption { + type = types.path; + default = cfg.efi.OVMF.firmware; + defaultText = literalExpression "cfg.efi.OVMF.firmware"; + description = lib.mdDoc '' + Firmware binary for EFI implementation, defaults to OVMF. + ''; + }; + + variables = mkOption { + type = types.path; + default = cfg.efi.OVMF.variables; + defaultText = literalExpression "cfg.efi.OVMF.variables"; + description = lib.mdDoc '' + Platform-specific flash binary for EFI variables, implementation-dependent to the EFI firmware. + Defaults to OVMF. + ''; + }; + }; + + virtualisation.tpm = { + enable = mkEnableOption "a TPM device in the virtual machine with a driver, using swtpm."; + + package = mkPackageOption cfg.host.pkgs "swtpm" { }; + + deviceModel = mkOption { + type = types.str; + default = ( + { + "i686-linux" = "tpm-tis"; + "x86_64-linux" = "tpm-tis"; + "ppc64-linux" = "tpm-spapr"; + "armv7-linux" = "tpm-tis-device"; + "aarch64-linux" = "tpm-tis-device"; + } + .${pkgs.hostPlatform.system} or (throw "Unsupported system for TPM2 emulation in QEMU") + ); + defaultText = '' + Based on the guest platform Linux system: + + - `tpm-tis` for (i686, x86_64) + - `tpm-spapr` for ppc64 + - `tpm-tis-device` for (armv7, aarch64) + ''; + example = "tpm-tis-device"; + description = lib.mdDoc "QEMU device model for the TPM, uses the appropriate default based on th guest platform system and the package passed."; + }; + }; + + virtualisation.useDefaultFilesystems = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + If enabled, the boot disk of the virtual machine will be + formatted and mounted with the default filesystems for + testing. Swap devices and LUKS will be disabled. + + If disabled, a root filesystem has to be specified and + formatted (for example in the initial ramdisk). + ''; + }; + + virtualisation.useSecureBoot = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Enable Secure Boot support in the EFI firmware. + ''; + }; + + virtualisation.bios = mkOption { + type = types.nullOr types.package; + default = null; + description = lib.mdDoc '' + An alternate BIOS (such as `qboot`) with which to start the VM. + Should contain a file named `bios.bin`. + If `null`, QEMU's builtin SeaBIOS will be used. + ''; + }; + + virtualisation.useHostCerts = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + If enabled, when `NIX_SSL_CERT_FILE` is set on the host, + pass the CA certificates from the host to the VM. + ''; + }; + }; + + config = { + + assertions = + lib.concatLists ( + lib.flip lib.imap cfg.forwardPorts ( + i: rule: [ + { + assertion = rule.from == "guest" -> rule.proto == "tcp"; + message = '' + Invalid virtualisation.forwardPorts..proto: + Guest forwarding supports only TCP connections. + ''; + } + { + assertion = rule.from == "guest" -> lib.hasPrefix "10.0.2." rule.guest.address; + message = '' + Invalid virtualisation.forwardPorts..guest.address: + The address must be in the default VLAN (10.0.2.0/24). + ''; + } + ] + ) + ) + ++ [ + { + assertion = pkgs.stdenv.hostPlatform.is32bit -> cfg.memorySize < 2047; + message = '' + virtualisation.memorySize is above 2047, but qemu is only able to allocate 2047MB RAM on 32bit max. + ''; + } + { + assertion = + cfg.directBoot.enable || cfg.directBoot.initrd == options.virtualisation.directBoot.initrd.default; + message = '' + You changed the default of `virtualisation.directBoot.initrd` but you are not + using QEMU direct boot. This initrd will not be used in your current + boot configuration. + + Either do not mutate `virtualisation.directBoot.initrd` or enable direct boot. + + If you have a more advanced usecase, please open an issue or a pull request. + ''; + } + ]; + + warnings = + optional + ( + cfg.writableStore + && cfg.useNixStoreImage + && opt.writableStore.highestPrio > lib.modules.defaultOverridePriority + ) + '' + You have enabled ${opt.useNixStoreImage} = true, + without setting ${opt.writableStore} = false. + + This causes a store image to be written to the store, which is + costly, especially for the binary cache, and because of the need + for more frequent garbage collection. + + If you really need this combination, you can set ${opt.writableStore} + explicitly to true, incur the cost and make this warning go away. + Otherwise, we recommend + + ${opt.writableStore} = false; + '' + ++ optional (cfg.directBoot.enable && cfg.useBootLoader) '' + You enabled direct boot and a bootloader, QEMU will not boot your bootloader, rendering + `useBootLoader` useless. You might want to disable one of those options. + ''; + + # In UEFI boot, we use a EFI-only partition table layout, thus GRUB will fail when trying to install + # legacy and UEFI. In order to avoid this, we have to put "nodev" to force UEFI-only installs. + # Otherwise, we set the proper bootloader device for this. + # FIXME: make a sense of this mess wrt to multiple ESP present in the system, probably use boot.efiSysMountpoint? + boot.loader.grub.device = mkVMOverride (if cfg.useEFIBoot then "nodev" else cfg.bootLoaderDevice); + boot.loader.grub.gfxmodeBios = with cfg.resolution; "${toString x}x${toString y}"; + + boot.initrd.kernelModules = optionals (cfg.useNixStoreImage && !cfg.writableStore) [ "erofs" ]; + + boot.loader.supportsInitrdSecrets = mkIf (!cfg.useBootLoader) (mkVMOverride false); + + boot.initrd.postMountCommands = lib.mkIf (!config.boot.initrd.systemd.enable) '' + # Mark this as a NixOS machine. + mkdir -p $targetRoot/etc + echo -n > $targetRoot/etc/NIXOS + + # Fix the permissions on /tmp. + chmod 1777 $targetRoot/tmp + + mkdir -p $targetRoot/boot + + ${optionalString cfg.writableStore '' + echo "mounting overlay filesystem on /nix/store..." + mkdir -p -m 0755 $targetRoot/nix/.rw-store/store $targetRoot/nix/.rw-store/work $targetRoot/nix/store + mount -t overlay overlay $targetRoot/nix/store \ + -o lowerdir=$targetRoot/nix/.ro-store,upperdir=$targetRoot/nix/.rw-store/store,workdir=$targetRoot/nix/.rw-store/work || fail + ''} + ''; + + systemd.tmpfiles.settings."10-qemu-vm" = lib.mkIf config.boot.initrd.systemd.enable { + "/etc/NIXOS".f = { + mode = "0644"; + user = "root"; + group = "root"; + }; + "${config.boot.loader.efi.efiSysMountPoint}".d = { + mode = "0644"; + user = "root"; + group = "root"; + }; + }; + + # After booting, register the closure of the paths in + # `virtualisation.additionalPaths' in the Nix database in the VM. This + # allows Nix operations to work in the VM. The path to the + # registration file is passed through the kernel command line to + # allow `system.build.toplevel' to be included. (If we had a direct + # reference to ${regInfo} here, then we would get a cyclic + # dependency.) + boot.postBootCommands = lib.mkIf config.nix.enable '' + if [[ "$(cat /proc/cmdline)" =~ regInfo=([^ ]*) ]]; then + ${config.nix.package.out}/bin/nix-store --load-db < ''${BASH_REMATCH[1]} + fi + ''; + + boot.initrd.availableKernelModules = + optional cfg.writableStore "overlay" + ++ optional (cfg.qemu.diskInterface == "scsi") "sym53c8xx" + ++ optional (cfg.tpm.enable) "tpm_tis"; + + virtualisation.additionalPaths = [ config.system.build.toplevel ]; + + virtualisation.sharedDirectories = { + nix-store = mkIf cfg.mountHostNixStore { + source = builtins.storeDir; + target = "/nix/store"; + }; + xchg = { + source = ''"$TMPDIR"/xchg''; + target = "/tmp/xchg"; + }; + shared = { + source = ''"''${SHARED_DIR:-$TMPDIR/xchg}"''; + target = "/tmp/shared"; + }; + certs = mkIf cfg.useHostCerts { + source = ''"$TMPDIR"/certs''; + target = "/etc/ssl/certs"; + }; + }; + + security.pki.installCACerts = mkIf cfg.useHostCerts false; + + virtualisation.qemu.networkingOptions = + let + forwardingOptions = flip concatMapStrings cfg.forwardPorts ( + { + proto, + from, + host, + guest, + }: + if from == "host" then + "hostfwd=${proto}:${host.address}:${toString host.port}-" + + "${guest.address}:${toString guest.port}," + else + "'guestfwd=${proto}:${guest.address}:${toString guest.port}-" + + "cmd:${pkgs.netcat}/bin/nc ${host.address} ${toString host.port}'," + ); + restrictNetworkOption = lib.optionalString cfg.restrictNetwork "restrict=on,"; + in + [ + "-net nic,netdev=user.0,model=virtio" + "-netdev user,id=user.0,${forwardingOptions}${restrictNetworkOption}\"$QEMU_NET_OPTS\"" + ]; + + virtualisation.qemu.options = mkMerge [ + (mkIf cfg.qemu.virtioKeyboard [ "-device virtio-keyboard" ]) + (mkIf pkgs.stdenv.hostPlatform.isx86 [ + "-usb" + "-device usb-tablet,bus=usb-bus.0" + ]) + (mkIf pkgs.stdenv.hostPlatform.isAarch [ + "-device virtio-gpu-pci" + "-device usb-ehci,id=usb0" + "-device usb-kbd" + "-device usb-tablet" + ]) + ( + let + alphaNumericChars = lowerChars ++ upperChars ++ (map toString (range 0 9)); + # Replace all non-alphanumeric characters with underscores + sanitizeShellIdent = + s: + concatMapStrings (c: if builtins.elem c alphaNumericChars then c else "_") (stringToCharacters s); + in + mkIf cfg.directBoot.enable [ + "-kernel \${NIXPKGS_QEMU_KERNEL_${sanitizeShellIdent config.system.name}:-${config.system.build.toplevel}/kernel}" + "-initrd ${cfg.directBoot.initrd}" + ''-append "$(cat ${config.system.build.toplevel}/kernel-params) init=${config.system.build.toplevel}/init regInfo=${regInfo}/registration ${consoles} $QEMU_KERNEL_PARAMS"'' + ] + ) + (mkIf cfg.useEFIBoot [ + "-drive if=pflash,format=raw,unit=0,readonly=on,file=${cfg.efi.firmware}" + "-drive if=pflash,format=raw,unit=1,readonly=off,file=$NIX_EFI_VARS" + ]) + (mkIf (cfg.bios != null) [ "-bios ${cfg.bios}/bios.bin" ]) + (mkIf (!cfg.graphics) [ "-nographic" ]) + (mkIf (cfg.tpm.enable) [ + "-chardev socket,id=chrtpm,path=\"$NIX_SWTPM_DIR\"/socket" + "-tpmdev emulator,id=tpm_dev_0,chardev=chrtpm" + "-device ${cfg.tpm.deviceModel},tpmdev=tpm_dev_0" + ]) + (mkIf (pkgs.stdenv.hostPlatform.isx86 && cfg.efi.OVMF.systemManagementModeRequired) [ + "-machine" + "q35,smm=on" + "-global" + "driver=cfi.pflash01,property=secure,value=on" + ]) + ]; + + virtualisation.qemu.drives = mkMerge [ + (mkIf (cfg.diskImage != null) [ + { + name = "root"; + file = ''"$NIX_DISK_IMAGE"''; + driveExtraOpts.cache = "writeback"; + driveExtraOpts.werror = "report"; + deviceExtraOpts.bootindex = "1"; + deviceExtraOpts.serial = rootDriveSerialAttr; + } + ]) + (mkIf cfg.useNixStoreImage [ + { + name = "nix-store"; + file = ''"$TMPDIR"/store.img''; + deviceExtraOpts.bootindex = "2"; + driveExtraOpts.format = if cfg.writableStore then "qcow2" else "raw"; + } + ]) + (imap0 (idx: _: { + file = "$(pwd)/empty${toString idx}.qcow2"; + driveExtraOpts.werror = "report"; + }) cfg.emptyDiskImages) + ]; + + # By default, use mkVMOverride to enable building test VMs (e.g. via + # `nixos-rebuild build-vm`) of a system configuration, where the regular + # value for the `fileSystems' attribute should be disregarded (since those + # filesystems don't necessarily exist in the VM). You can disable this + # override by setting `virtualisation.fileSystems = lib.mkForce { };`. + fileSystems = lib.mkIf (cfg.fileSystems != { }) (mkVMOverride cfg.fileSystems); + + virtualisation.fileSystems = + let + mkSharedDir = tag: share: { + name = if tag == "nix-store" && cfg.writableStore then "/nix/.ro-store" else share.target; + value.device = tag; + value.fsType = "9p"; + value.neededForBoot = true; + value.options = [ + "trans=virtio" + "version=9p2000.L" + "msize=${toString cfg.msize}" + ] ++ lib.optional (tag == "nix-store") "cache=loose"; + }; + in + lib.mkMerge [ + (lib.mapAttrs' mkSharedDir cfg.sharedDirectories) + { + "/" = lib.mkIf cfg.useDefaultFilesystems ( + if cfg.diskImage == null then + { + device = "tmpfs"; + fsType = "tmpfs"; + } + else + { + device = cfg.rootDevice; + fsType = "ext4"; + } + ); + "/tmp" = lib.mkIf config.boot.tmp.useTmpfs { + device = "tmpfs"; + fsType = "tmpfs"; + neededForBoot = true; + # Sync with systemd's tmp.mount; + options = [ + "mode=1777" + "strictatime" + "nosuid" + "nodev" + "size=${toString config.boot.tmp.tmpfsSize}" + ]; + }; + "/nix/${if cfg.writableStore then ".ro-store" else "store"}" = lib.mkIf cfg.useNixStoreImage { + device = "/dev/disk/by-label/${nixStoreFilesystemLabel}"; + neededForBoot = true; + options = [ "ro" ]; + }; + "/nix/.rw-store" = lib.mkIf (cfg.writableStore && cfg.writableStoreUseTmpfs) { + fsType = "tmpfs"; + options = [ "mode=0755" ]; + neededForBoot = true; + }; + "/boot" = lib.mkIf (cfg.useBootLoader && cfg.bootPartition != null) { + device = cfg.bootPartition; + fsType = "vfat"; + noCheck = true; # fsck fails on a r/o filesystem + }; + } + ]; + + boot.initrd.systemd = lib.mkIf (config.boot.initrd.systemd.enable && cfg.writableStore) { + mounts = [ + { + where = "/sysroot/nix/store"; + what = "overlay"; + type = "overlay"; + options = "lowerdir=/sysroot/nix/.ro-store,upperdir=/sysroot/nix/.rw-store/store,workdir=/sysroot/nix/.rw-store/work"; + wantedBy = [ "initrd-fs.target" ]; + before = [ "initrd-fs.target" ]; + requires = [ "rw-store.service" ]; + after = [ "rw-store.service" ]; + unitConfig.RequiresMountsFor = "/sysroot/nix/.ro-store"; + } + ]; + services.rw-store = { + before = [ "shutdown.target" ]; + conflicts = [ "shutdown.target" ]; + unitConfig = { + DefaultDependencies = false; + RequiresMountsFor = "/sysroot/nix/.rw-store"; + }; + serviceConfig = { + Type = "oneshot"; + ExecStart = "/bin/mkdir -p -m 0755 /sysroot/nix/.rw-store/store /sysroot/nix/.rw-store/work /sysroot/nix/store"; + }; + }; + }; + + swapDevices = (if cfg.useDefaultFilesystems then mkVMOverride else mkDefault) [ ]; + boot.initrd.luks.devices = (if cfg.useDefaultFilesystems then mkVMOverride else mkDefault) { }; + + # Don't run ntpd in the guest. It should get the correct time from KVM. + services.timesyncd.enable = false; + + services.qemuGuest.enable = cfg.qemu.guestAgent.enable; + + 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.writeScript "run-nixos-vm" startVM} $out/bin/run-${config.system.name}-vm + ''; + + # When building a regular system configuration, override whatever + # video driver the host uses. + services.xserver.videoDrivers = mkVMOverride [ "modesetting" ]; + services.xserver.defaultDepth = mkVMOverride 0; + services.xserver.resolutions = mkVMOverride [ cfg.resolution ]; + services.xserver.monitorSection = '' + # Set a higher refresh rate so that resolutions > 800x600 work. + HorizSync 30-140 + VertRefresh 50-160 + ''; + + # Wireless won't work in the VM. + networking.wireless.enable = mkVMOverride false; + services.connman.enable = mkVMOverride false; + + # Speed up booting by not waiting for ARP. + networking.dhcpcd.extraConfig = "noarp"; + + networking.usePredictableInterfaceNames = false; + + system.requiredKernelConfig = + with config.lib.kernelConfig; + [ + (isEnabled "VIRTIO_BLK") + (isEnabled "VIRTIO_PCI") + (isEnabled "VIRTIO_NET") + (isEnabled "EXT4_FS") + (isEnabled "NET_9P_VIRTIO") + (isEnabled "9P_FS") + (isYes "BLK_DEV") + (isYes "PCI") + (isYes "NETDEVICES") + (isYes "NET_CORE") + (isYes "INET") + (isYes "NETWORK_FILESYSTEMS") + ] + ++ optionals (!cfg.graphics) [ + (isYes "SERIAL_8250_CONSOLE") + (isYes "SERIAL_8250") + ] + ++ optionals (cfg.writableStore) [ (isEnabled "OVERLAY_FS") ]; + }; + + # uses types of services/x11/xserver.nix + meta.buildDocsInSandbox = false; +}