From 1789de41806f4d22954d3405abc0aa8fd4c978ba Mon Sep 17 00:00:00 2001 From: Jason Yundt Date: Fri, 29 May 2026 07:01:15 -0400 Subject: [PATCH 1/3] Init disko.unattendedInstall.* NixOS module options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At the moment, docs/disko-install.md shows some example code for using disko in order to do an unattended NixOS installation. Before this change, anyone who wanted to use disko in order to do an unattended NixOS installation had to copy that code and adapt it to their needs. It would be nice if there was a more convenient way to do unattended installations that did not involve copying all that code. This change allows users to do that. Now, when users import disko’s NixOS module, they will get access to a few new options that make it easier to do unattended installs. Additionally, the underlying implementation for these options is improved compared to the example code that’s in docs/disko-install.md. If you use the example code that’s in docs/disko-install.md, then the flake that contains your deployee NixOS configuration will get evaluated multiple times: 1. It will get evaluated when you build your unattended install medium. 2. It will get evaluated when disko-install runs “nix flake metadata”. 3. It will get evaluated when disko-install builds the artifacts that are needed in order to wipe the disk(s), format the disk(s), mount the disk(s) and run nixos-install. When using the new disko.unattendedInstall.* options, only one evaluation will happen. Finally, the example code in docs/disko-install.md only works if you are using flakes for your deployee configuration. The new disko.unattendedInstall.* options work even if you aren’t using flakes at all. This change does not update the documentation in docs/disko-install.md. A future commit will update that file so that it talks about the disko.unattendedInstall.* options. --- flake.nix | 7 + lib/unattended-install.nix | 402 ++++++++++++++++++ module.nix | 5 +- tests/unattended-install-module/common.nix | 8 + tests/unattended-install-module/default.nix | 20 + tests/unattended-install-module/deployee.nix | 65 +++ .../unattended-installer.nix | 22 + 7 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 lib/unattended-install.nix create mode 100644 tests/unattended-install-module/common.nix create mode 100644 tests/unattended-install-module/default.nix create mode 100644 tests/unattended-install-module/deployee.nix create mode 100644 tests/unattended-install-module/unattended-installer.nix diff --git a/flake.nix b/flake.nix index c2f36151e..a2cc767fe 100644 --- a/flake.nix +++ b/flake.nix @@ -76,6 +76,8 @@ diskoVersion = version; }; + unattended-install-module = pkgs.callPackage ./tests/unattended-install-module { }; + checkJqSyntax = pkgs.runCommand "check-jq-syntax" { nativeBuildInputs = [ pkgs.jq ]; } '' echo '{ "blockdevices" : [] }' | jq -r -f ${./disk-deactivate/disk-deactivate.jq} --arg disk_to_clear foo echo '{ "blockdevices" : [] }' | jq -r -f ${./disk-deactivate/zfs-swap-deactivate.jq} @@ -94,6 +96,11 @@ in # FIXME: aarch64-linux seems to hang on boot lib.optionalAttrs pkgs.stdenv.hostPlatform.isx86_64 (nixosTests // { inherit disko-install; }) + # x86_32 is skipped because we need to create virtual machines that + # have more than 2 GiB of RAM. + // lib.optionalAttrs (!pkgs.stdenv.hostPlatform.isx86_32) { + inherit unattended-install-module; + } // pkgs.lib.optionalAttrs (!pkgs.stdenv.buildPlatform.isRiscV64 && !pkgs.stdenv.hostPlatform.isx86_32) { diff --git a/lib/unattended-install.nix b/lib/unattended-install.nix new file mode 100644 index 000000000..7b74a24c7 --- /dev/null +++ b/lib/unattended-install.nix @@ -0,0 +1,402 @@ +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.disko.unattendedInstall; + installScriptName = "disko-unattended-nixos-installer"; + installScriptPrettyName = config.systemd.services.unattendedInstall.description; + motdPath = "/var/lib/${installScriptName}/motd"; + runInstallerCommand = "systemctl start unattendedInstall.service"; + viewLogsCommand = "journalctl --boot --unit=unattendedInstall.service --unit=unattendedInstallAtBoot.service"; +in +{ + options.disko.unattendedInstall = { + enable = lib.mkOption { + type = lib.types.bool; + description = '' + Enable configuration options for doing automatic unattended + installations of NixOS. + + When using any of the {option}`disko.unattendedInstall.*` options, you + must have two NixOS configurations: + + 1. The deployee configuration. The depolyee configuration gets used as + the first generation for the freshly installed NixOS system. See + {option}`disko.unattendedInstall.deployeeConfiguration`. + + 2. The unattended installer configuration. The unattended installer + configuration gets activated when you boot into an unattended + installer. + + For the deployee configuration, this option should be set to `false`. + For the unattended installer configuration, this option should be set + to `true`. + + When this option is set to `true`, two systemd services will be added + to your system: `unattendedInstall.service` and + `unattendedInstallAtBoot.service`. You can manually start + `unattendedInstall.service` in order to perform an unattended + installation (this is useful for debugging). You can automatically + start `unattendedInstallAtBoot.service` at boot in order to make the + process completely unattended (see + {option}`disko.unattendedInstall.startAtBoot` for details). Both + `unattendedInstall.service` and `unattendedInstallAtBoot.service` do + pretty much the same thing. + + Technically, you can manually start + `unattendedInstallAtBoot.service`, but it’s not recommended. + `unattendedInstallAtBoot.service` will output all of its logs to the + console. When the system is booting, this is a good thing because it + allows you to see what is going on. After the system has finished + booting, this is a bad thing because the stream of text makes it + difficult to use TTYs. + ''; + default = false; + example = true; + }; + deployeeConfiguration = lib.mkOption { + type = lib.types.raw; + description = '' + The NixOS configuration that will be used as the first generation of + the new NixOS installation that will be created by the unattended + installation process. + ''; + example = lib.literalMD '' + If you aren’t using any experimental features for your NixOS + configuration, then you would set this option to something like this: + + ```nix + import "''${modulesPath}/.." { + configuration = ./configuration.nix; + } + ``` + + If you’re using [the flakes experimental feature](https://nix.dev/manual/nix/2.34/development/experimental-features.html#xp-feature-flakes) + for your NixOS configuration, then you would set this option to + something like this: + + ```nix + self.nixosConfigurations.my-computer + ``` + ''; + }; + startAtBoot = lib.mkOption { + type = lib.types.enum [ + "off" + "on" + "separate-boot-menu-item" + ]; + description = '' + Whether or not to automatically start performing an unattended + installation at boot. + + - When this option is set to `"off"`, the unattended installer will not + start at boot. In order to perform an unattended installation, the + user will have to manually start `unattendedInstall.service` after + logging in. Setting this option to `"off"` is useful for debugging. + + - When this option is set to `"on"`, the unattended installer will + automatically start at boot. This allows for a completely unattended + installation. + + - When this option is set to `"separate-boot-menu-item"`, a new + `unattendedInstall` boot menu item will be created. If the user + selects the `unattendedInstall` boot menu item, then the unattended + installer will start automatically at boot. If the user selects the + default boot menu item, then the unattended installer will not start + automatically at boot. + + `"separate-boot-menu-item"` is useful for preventing infinite boot + loops. When this option is set to `"on"`, there’s a chance that a + computer will boot into the unattended installer, finish the + installation successfully, automatically reboot, boot back into the + unattended installer, and continue looping through that process until + someone notices and manually stops it. Setting this option to + `"separate-boot-menu-item"` prevents that problem from happening. If + the computer happens to reboot into the unattended installer after + the installation has finished successfully, it will select the + default boot option which won’t automatically run the unattended + installer. + ''; + default = "separate-boot-menu-item"; + example = "off"; + }; + successfulBootInstallNextStep = lib.mkOption { + type = lib.types.enum [ + "continue-booting" + "poweroff" + "reboot" + ]; + description = '' + If the unattended installer is started at boot and it finishes + successfully, then what happens next? This option controls what happens + next. + + - When this option is set to `"continue-booting"`, the systemd + `default.target` unit will be started. This is mainly useful for + debugging. + + - When this option is set to `"poweroff"`, the system will be shut + down. + + - When this option is set to `"reboot"`, the system will restart. + ''; + default = "poweroff"; + example = "reboot"; + }; + motdHints = lib.mkOption { + type = lib.types.bool; + description = '' + When this option is set to `true`, the MOTD that gets displayed when + users log in will be used to give users hints about unattended + installations. For example, if the unattended installer was not started + at boot, then the MOTD will tell users how to start it. Or, if the + unattended installer was started at boot and finished unsuccessfully, + then the MOTD will tell users that the installer failed and explain how + to check the installer’s logs. + ''; + default = true; + example = false; + }; + extraNixOSInstallArgs = lib.mkOption { + type = lib.types.listOf lib.types.str; + description = '' + The unattended installer will run {command}`nixos-install` as a part of + the unattended installation process. This option allows you to pass + additional command-line arguments to {command}`nixos-install`. + ''; + default = [ ]; + example = [ "--no-channel-copy" ]; + }; + }; + config = lib.mkIf cfg.enable { + users.motdFile = lib.mkIf cfg.motdHints motdPath; + systemd = { + tmpfiles.settings."10-initial-MOTD"."${motdPath}"."f".argument = '' + You have successfully booted into a NixOS configuration that can be + used to perform unattended installs of NixOS. The unattended installer + has not been started yet (at least, not since the last boot). + + You can start the unattended installer by running this command as root: + + # ${runInstallerCommand} + + You can check the unattended installer’s logs by running this command + as root: + + # ${viewLogsCommand} + + NOTE: If you recently did an unattended installation, then your + computer may have successfully finished the unattended installation, + rebooted and then booted back into the installation medium. + ''; + services = + let + installScript = pkgs.writeShellApplication { + name = installScriptName; + runtimeInputs = [ + # This first one is needed or else we will get this error… + # + # > nix-env: command not found + # + # …when we try to run nixos-install. + cfg.deployeeConfiguration.config.nix.package + cfg.deployeeConfiguration.config.system.build.destroyFormatMount + cfg.deployeeConfiguration.config.system.build.nixos-install + ]; + text = '' + set -euo pipefail + + ${lib.toShellVars { + inherit (cfg) extraNixOSInstallArgs; + inherit motdPath; + systemDerivation = cfg.deployeeConfiguration.config.system.build.toplevel; + inProgressMOTD = '' + The ${installScriptPrettyName} is currently running. You can + check on its progress by running this command as root: + + # ${viewLogsCommand} + ''; + failureMOTD = '' + The ${installScriptPrettyName} has failed. You can check its + logs by running this command as root: + + # ${viewLogsCommand} + + You can try to run the ${installScriptPrettyName} again by + running this command as root: + + # ${runInstallerCommand} + ''; + successMOTD = '' + The ${installScriptPrettyName} has finished installing NixOS + successfully. You can view the logs for the installer by + running this command as root: + + # ${viewLogsCommand} + + At this point, you can shutdown the system by running this + command as root: + + # systemctl poweroff + + After you have shut down the system, remove the installation + medium. Turn the system back on in order to boot into your + new installation of NixOS! + ''; + }} + # We could use “set -x” in order to achieve a similar effect, but + # disko-destroy-format-mount already uses “set -x”. I’m using my + # own custom output format in order to make things less + # confusing. + function show_and_run_command { + printf '# ' + printf '%q ' "$@" + printf '\n' + "$@" + } + + echo 'Starting unattended NixOS installation…' + printf '%s' "$inProgressMOTD" > "$motdPath" + wasSuccessful=false + trap " + if [ \"\$wasSuccessful\" = true ] + then + printf '%s' \"\$successMOTD\" > \"\$motdPath\" + else + printf '%s' \"\$failureMOTD\" > \"\$motdPath\" + fi + " EXIT + + show_and_run_command disko-destroy-format-mount --yes-wipe-all-disks + # When using nixos-install, you typically give nixos-install a + # Nix expression that represents a NixOS configuration (e.g., a + # path to a system.nix file or a flake URL). nixos-install will + # then evaluate and build that Nix expression. + # + # In this situation, we are using --system in order to give it a + # system derivation instead of a Nix expression. This means that + # we won’t have to evaluate or build anything. Doing this has a + # few advantages: + # + # 1. Skiping the evaluation and building steps saves time. + # + # 2. If we did not skip evaluating and building the deployee + # configuration here, then there’s a good chance that we would + # end up evaluating and building it twice. The first time, the + # deployee configuration would be evaluated and built on the + # machine that is being used to create the unattended + # installation medium. That first time would technically be + # optional, but many users would do it in order to make sure + # that their deployee configuration is not broken. The second + # time, the deployee configuration would be evaluated and + # built during the unattended installation. In this situation, + # doing the same thing twice is not helpful. It’s a waste of + # resources. Additionally, if the unattended install medium + # gets used more than once, then the evaluating and building + # steps would happen more than twice which would waste even + # more resources. + # + # 3. It’s very difficult to make sure that any Nix expression + # that a user might use for their deployee configuration will + # evaluate successfully. For example, what if the Nix + # expression uses buitins.fetchGit with an “ssh://” URL? It’s + # very possible that that “ssh://” URL will work fine when + # evaluation is done on the user’s main machine but will fail + # when done on an unattended install medium because the + # install medium’s SSH key is not trusted. We can sidestep + # problems like that one by avoiding evaluation. + # + # 4. If we gave nixos-install a Nix expression, then we would + # have to switch between using --file and --flake depdending + # on whether or not the deployee configuration was specified + # in a flake. Using --system means that we don’t have to have + # to worry about whether the deployee configuration was + # specified in a flake or not. + # + # 5. By using --system here, we end up evaluating and building + # the deployee configuration on the machine that builds the + # unattended install medium. That machine is likely to have + # more paths in its Nix store than the unattended install + # medium itself. If we were to build on the unattended install + # medium, then it’s likely that we would have to download and + # build more paths because those paths were not already in the + # unattended install medium’s Nix store. + show_and_run_command nixos-install \ + --system "$systemDerivation" \ + --no-root-password \ + "''${extraNixOSInstallArgs[@]}" + wasSuccessful=true + echo 'The unattended NixOS installation finished successfully!' + ''; + }; + unattendedInstallDependencies = [ + # This service assumes that the MOTD file already exists before it + # runs. systemd-tmpfiles-setup.service is the thing that makes sure + # that the MOTD file exists. + "systemd-tmpfiles-setup.service" + ]; + baseUnattendedInstallService = { + wants = unattendedInstallDependencies; + after = unattendedInstallDependencies; + serviceConfig.ExecStart = lib.getExe installScript; + }; + commonConfig = { + # This service creates the initial MOTD file. If the MOTD file is + # actually going to get used, then it needs to exist before getty + # is started. + systemd-tmpfiles-setup.before = lib.mkIf cfg.motdHints [ "getty-pre.target" ]; + + unattendedInstall = baseUnattendedInstallService; + unattendedInstallAtBoot = baseUnattendedInstallService; + }; + configThatDiffersForRegularVsAtBoot = { + unattendedInstall = { + # This part tries to prevent people from accidentally running two + # installers at the same time. + conflicts = [ "unattendedInstallAtBoot.service" ]; + before = [ "unattendedInstallAtBoot.service" ]; + + description = "Disko Unattended NixOS Installer"; + }; + unattendedInstallAtBoot = { + conflicts = [ "unattendedInstall.service" ]; + after = [ "unattendedInstall.service" ]; + + description = installScriptPrettyName + " (running during boot)"; + unitConfig = { + OnSuccess = lib.mkIf (cfg.successfulBootInstallNextStep == "continue-booting") "default.target"; + OnFailure = "default.target"; + SuccessAction = lib.mkIf ( + cfg.successfulBootInstallNextStep != "continue-booting" + ) cfg.successfulBootInstallNextStep; + }; + # This next part make it so that the user can see that the + # unattended installer is making progress while it’s running. I’m + # only enabling this for the version of the service that runs at + # boot so that the user’s terminal doesn’t get overwritten with a + # flood of messages if they run the unattended installer manually + # from a tty. + serviceConfig = { + StandardOutput = "journal+console"; + StandardError = "journal+console"; + }; + }; + }; + in + lib.mkMerge [ + commonConfig + configThatDiffersForRegularVsAtBoot + ]; + }; + boot.kernelParams = lib.mkIf (cfg.startAtBoot == "on") [ + "systemd.unit=unattendedInstallAtBoot.service" + ]; + specialisation = lib.mkIf (cfg.startAtBoot == "separate-boot-menu-item") { + unattendedInstall.configuration.disko.unattendedInstall.startAtBoot = lib.mkForce "on"; + }; + }; +} diff --git a/module.nix b/module.nix index 1094a5c2b..48244bd5c 100644 --- a/module.nix +++ b/module.nix @@ -18,7 +18,10 @@ let }; in { - imports = [ ./lib/make-disk-image.nix ]; + imports = [ + ./lib/make-disk-image.nix + ./lib/unattended-install.nix + ]; options.disko = { imageBuilder = { diff --git a/tests/unattended-install-module/common.nix b/tests/unattended-install-module/common.nix new file mode 100644 index 000000000..56c3e8d9d --- /dev/null +++ b/tests/unattended-install-module/common.nix @@ -0,0 +1,8 @@ +{ + imports = [ ../../module.nix ]; + + boot.loader = { + systemd-boot.enable = true; + efi.canTouchEfiVariables = true; + }; +} diff --git a/tests/unattended-install-module/default.nix b/tests/unattended-install-module/default.nix new file mode 100644 index 000000000..a7dd6ee79 --- /dev/null +++ b/tests/unattended-install-module/default.nix @@ -0,0 +1,20 @@ +{ + pkgs ? import { }, +}: +pkgs.testers.runNixOSTest { + name = "disko-unattended-install-module-test"; + nodes.main = ./unattended-installer.nix; + + testScript = '' + main.start(allow_reboot=True) + assert main.succeed("hostname").rstrip() == "unattended-installer" + # This verifies that unattendedInstallAtBoot.service did indeed start at + # boot and that it finished successfully. + assert main.fail("systemctl is-failed unattendedInstallAtBoot.service").rstrip() == "inactive" + # This makes it so that we will boot into the new installation of NixOS + # instead of the original one when we reboot. + main.succeed("rm --recursive --force /boot/EFI") + main.reboot() + assert main.succeed("hostname").rstrip() == "deployee" + ''; +} diff --git a/tests/unattended-install-module/deployee.nix b/tests/unattended-install-module/deployee.nix new file mode 100644 index 000000000..f812d9f8c --- /dev/null +++ b/tests/unattended-install-module/deployee.nix @@ -0,0 +1,65 @@ +{ + config, + modulesPath, + pkgs, + ... +}: +{ + imports = [ + ./common.nix + "${modulesPath}/profiles/qemu-guest.nix" + # When writing NixOS tests, you normally declare NixOS configurations by + # doing something like this: + # + # { pkgs }: + # pkgs.testers.runNixOSTest { + # name = "example-test"; + # nodes.exampleMachine = { + # networking.hostName = "example-machine"; + # }; + # testScript = '' + # exampleMachine.succeed("true") + # ''; + # } + # + # In that example, test-instrumentation.nix automatically gets imported for + # exampleMachine because exampleMachine is a part of the nodes attribute + # set. + # + # This deployee configuration is not going to be a part of the nodes + # attribute set, so we have to import test-instrumentation.nix manually. + "${modulesPath}/testing/test-instrumentation.nix" + ]; + + system.stateVersion = config.system.nixos.release; + + disko.devices.disk.main = { + device = "/dev/vdb"; + type = "disk"; + content = { + type = "gpt"; + partitions = { + efiSystemPartition = { + type = "EF00"; + size = "512M"; + content = { + type = "filesystem"; + format = "vfat"; + mountpoint = "/boot"; + mountOptions = [ "umask=0077" ]; + }; + }; + root = { + size = "100%"; + content = { + type = "filesystem"; + format = "ext4"; + mountpoint = "/"; + }; + }; + }; + }; + }; + + networking.hostName = "deployee"; +} diff --git a/tests/unattended-install-module/unattended-installer.nix b/tests/unattended-install-module/unattended-installer.nix new file mode 100644 index 000000000..42e3d55fa --- /dev/null +++ b/tests/unattended-install-module/unattended-installer.nix @@ -0,0 +1,22 @@ +{ modulesPath, pkgs, ... }: +{ + imports = [ ./common.nix ]; + + virtualisation = { + emptyDiskImages = [ 4096 ]; + useBootLoader = true; + useEFIBoot = true; + }; + + disko.unattendedInstall = { + enable = true; + deployeeConfiguration = import "${modulesPath}/.." { + configuration = ./deployee.nix; + inherit (pkgs.stdenv.hostPlatform) system; + }; + startAtBoot = "on"; + successfulBootInstallNextStep = "continue-booting"; + }; + + networking.hostName = "unattended-installer"; +} From 1c6db00525c0ef7ec1b2f8a3cdfdccc0e752ec91 Mon Sep 17 00:00:00 2001 From: Jason Yundt Date: Wed, 3 Jun 2026 13:01:44 -0400 Subject: [PATCH 2/3] Add disko-unattended-install-iso image variant Before this change, if you wanted to do an unattended installation of NixOS, you would have to manually create two different configurations: the deployee configuration and the unattended installer configuration. This change makes it so that you only need to create the deployee configuration. After you have created the deployee configuration, you can create an unattended installation ISO for that deployee configuration by running this command: nixos-rebuild build-image --image-variant disko-unattended-install-iso If you need to customize the unattended installation ISO for whatever reason, then you can set the image.modules.disko-unattended-install-iso option in the deployee configuration. If you need an even greater level of control, then you can still manually create the unattended installer configuration and manually figure out how to get that configuration onto a bootable installation medium. --- flake.nix | 11 +++ lib/unattended-install-iso.nix | 110 ++++++++++++++++++++++ lib/unattended-install.nix | 1 + tests/unattended-install-iso/default.nix | 72 ++++++++++++++ tests/unattended-install-iso/deployee.nix | 92 ++++++++++++++++++ 5 files changed, 286 insertions(+) create mode 100644 lib/unattended-install-iso.nix create mode 100644 tests/unattended-install-iso/default.nix create mode 100644 tests/unattended-install-iso/deployee.nix diff --git a/flake.nix b/flake.nix index a2cc767fe..321ab4da9 100644 --- a/flake.nix +++ b/flake.nix @@ -77,6 +77,7 @@ }; unattended-install-module = pkgs.callPackage ./tests/unattended-install-module { }; + unattended-install-iso = pkgs.callPackage ./tests/unattended-install-iso { }; checkJqSyntax = pkgs.runCommand "check-jq-syntax" { nativeBuildInputs = [ pkgs.jq ]; } '' echo '{ "blockdevices" : [] }' | jq -r -f ${./disk-deactivate/disk-deactivate.jq} --arg disk_to_clear foo @@ -101,6 +102,16 @@ // lib.optionalAttrs (!pkgs.stdenv.hostPlatform.isx86_32) { inherit unattended-install-module; } + # FIXME: aarch64-linux fails to find /dev/disk/by-label/nixos-26.11-aarch64 + // + lib.optionalAttrs + ( + !pkgs.stdenv.hostPlatform.isx86_32 + && !(pkgs.stdenv.hostPlatform.isAarch64 && pkgs.stdenv.hostPlatform.isLinux) + ) + { + inherit unattended-install-iso; + } // pkgs.lib.optionalAttrs (!pkgs.stdenv.buildPlatform.isRiscV64 && !pkgs.stdenv.hostPlatform.isx86_32) { diff --git a/lib/unattended-install-iso.nix b/lib/unattended-install-iso.nix new file mode 100644 index 000000000..a4820fc36 --- /dev/null +++ b/lib/unattended-install-iso.nix @@ -0,0 +1,110 @@ +{ + config, + modulesPath, + pkgs, + ... +}@outerModuleArgs: +let + # When you run “nixos-rebuild build-image”, nixos-rebuild will give you a + # list of supported image variants. This module adds a new supported image + # variant to that list. In order to add a new supported image variant to that + # list, we need to set the image.modules. option. Our + # new image variant is named disko-unattended-install-iso, so we need to set + # the image.modules.disko-unattended-install-iso option. + # + # Unfortunately, when you set image.modules.disko-unattended-install-iso, it + # will merge that module with the rest of your NixOS configuration. So if you + # enable KDE Plasma in your main configuration, then it will enable KDE + # Plasma on the unattended install ISO. This is not what we want. + # + # In order to work around that problem, we create a “realUnattendedInstallerConfiguration”. + # The realUnattendedInstallerConfiguration does NOT automatically inherit + # things from the main NixOS configuration. The realUnattendedInstallerConfiguration + # is based solely off of things specified in image.modules.disko-unattended-install-iso. + # Later on, we forcibly set image.modules.disko-unattended-install-iso.system.build.image + # so that users get an ISO that is built from the realUnattendedInstallerConfiguration. + realUnattendedInstallerConfiguration = import "${modulesPath}/.." { + configuration = { + imports = [ config.image.modules.disko-unattended-install-iso ]; + + overrideSystemBuildImage = false; + }; + inherit (pkgs.stdenv.hostPlatform) system; + }; +in +{ + image.modules.disko-unattended-install-iso = + { + config, + lib, + modulesPath, + ... + }: + { + imports = [ + ../module.nix + "${modulesPath}/installer/cd-dvd/installation-cd-base.nix" + ]; + + options.overrideSystemBuildImage = lib.mkOption { + type = lib.types.bool; + default = true; + internal = true; + }; + + config = + let + installScriptPrettyName = config.systemd.services.unattendedInstall.description; + in + { + system.build.image = lib.mkIf config.overrideSystemBuildImage ( + lib.mkForce realUnattendedInstallerConfiguration.config.system.build.image + ); + + disko.unattendedInstall = { + enable = true; + # Originally, I tried doing this for this next part: + # + # deployeeConfiguration.config = outerModuleArgs.config; + # + # Doing that caused an evaluation error and a whole bunch of + # evaluation warnings. As a workaround, we set + # deployeeConfiguration.config to an attribute set that contains + # only the attributes that are actually needed by the + # ./unattended-install.nix. + deployeeConfiguration.config = { + nix.package = outerModuleArgs.config.nix.package; + system.build = { + inherit (outerModuleArgs.config.system.build) destroyFormatMount nixos-install toplevel; + }; + }; + }; + specialisation = + lib.mkIf (config.disko.unattendedInstall.startAtBoot == "separate-boot-menu-item") + { + unattendedInstall.configuration.isoImage.configurationName = lib.mkDefault installScriptPrettyName; + }; + + # For the most part, it doesn’t make sense to inherit things from the + # outer NixOS configuration, so we avoid doing so. That being said, + # there are a few things that would be good to inherit. We manually + # inherit them below. + nix.package = lib.mkDefault outerModuleArgs.config.nix.package; + networking.hostName = + let + installScriptPrettyNameNoSpaces = lib.replaceString " " "-" installScriptPrettyName; + outerHostName = outerModuleArgs.config.networking.hostName; + desiredHostName = "${installScriptPrettyNameNoSpaces}-For-${outerHostName}"; + # See the description for networking.hostName. + maximumHostNameLength = 63; + in + lib.mkDefault (lib.substring 0 maximumHostNameLength desiredHostName); + # This next one prevents the following situation: a user uses + # bcachefs (for example) in disko.devices.disk.. Their + # NixOS configuration works perfectly fine, but the disko unattended + # install ISO does not because NixOS installation ISOs don’t support + # bcachefs by default. + boot.supportedFilesystems = lib.mkDefault outerModuleArgs.config.boot.supportedFilesystems; + }; + }; +} diff --git a/lib/unattended-install.nix b/lib/unattended-install.nix index 7b74a24c7..00e2b661c 100644 --- a/lib/unattended-install.nix +++ b/lib/unattended-install.nix @@ -13,6 +13,7 @@ let viewLogsCommand = "journalctl --boot --unit=unattendedInstall.service --unit=unattendedInstallAtBoot.service"; in { + imports = [ ./unattended-install-iso.nix ]; options.disko.unattendedInstall = { enable = lib.mkOption { type = lib.types.bool; diff --git a/tests/unattended-install-iso/default.nix b/tests/unattended-install-iso/default.nix new file mode 100644 index 000000000..286053b14 --- /dev/null +++ b/tests/unattended-install-iso/default.nix @@ -0,0 +1,72 @@ +{ + pkgs ? import { }, +}: +let + # This is a base64-encoded random number. It was generated by running this + # command: + # + # python -c ' + # import base64 + # import random + # print(base64.b64encode(random.randbytes(32)).decode(encoding="utf_8")) + # ' + successfulInstallSignal = "q6veJrLaLyBqlcKERGYScr/ygC2buxA9ZpJgnkwjVlA="; +in +pkgs.testers.runNixOSTest { + name = "disko-unattended-install-iso-test"; + nodes.main = + { modulesPath, pkgs, ... }: + { + virtualisation = { + libvirtd.enable = true; + diskSize = 4 * 1024; + memorySize = 3 * 1024; + }; + environment = { + # This is needed for the virt-install and virsh commands. + systemPackages = [ pkgs.virt-manager ]; + + etc."unattended-install.iso".source = + let + deployeeConfiguration = import "${modulesPath}/.." { + configuration = { + imports = [ ./deployee.nix ]; + inherit successfulInstallSignal; + }; + inherit (pkgs.stdenv.hostPlatform) system; + }; + isoPackage = deployeeConfiguration.config.system.build.images.disko-unattended-install-iso; + in + "${isoPackage}/iso/${isoPackage.isoName}"; + }; + }; + + testScript = '' + successful_install_signal = "${successfulInstallSignal}" + deployee_vm_log = main.succeed( + "virt-install" + " --osinfo nixos-unstable" + " --name deployee" + " --memory 2048" + " --boot uefi" + " --disk size=3" + # This next line disables networking. If we didn’t explicitly disable + # networking here, then we would need to run another command that starts + # the default network. We don’t actually need networking for anything, so + # it’s better to just disable it. + " --network none" + " --cdrom /etc/unattended-install.iso" + " --autoconsole text" + # This next part makes it so that any text that virt-install prints to + # stdout will get printed to both stdout and stderr at the same time. + # Text that goes to stdout will get captured and put in the + # deployee_vm_log variable. Text that goes to stderr will not be captured + # and will be printed as a part of the test’s build logs. We want the + # text to be printed as a part of the test’s build logs so that people + # can see that the test is indeed making progress while it’s running. + " | tee >(cat 1>&2)" + ) + main.succeed('test "$(virsh domstate deployee)" = "shut off"') + assert successful_install_signal in deployee_vm_log + ''; +} diff --git a/tests/unattended-install-iso/deployee.nix b/tests/unattended-install-iso/deployee.nix new file mode 100644 index 000000000..e9c0c2158 --- /dev/null +++ b/tests/unattended-install-iso/deployee.nix @@ -0,0 +1,92 @@ +{ + config, + lib, + modulesPath, + pkgs, + ... +}: +let + extraKernelParams = [ "console=ttyS0" ]; +in +{ + imports = [ + ../../module.nix + "${modulesPath}/profiles/qemu-guest.nix" + ]; + + options.successfulInstallSignal = lib.mkOption { + type = lib.types.str; + description = '' + The test will check to see if this string gets output to the deployee’s + serial console. If it does, then that indicates that the installation was + successful. + ''; + }; + + config = { + boot = { + loader = { + systemd-boot.enable = true; + efi.canTouchEfiVariables = true; + }; + kernelParams = extraKernelParams ++ [ "systemd.unit=displaySuccessfulInstallSignal.service" ]; + }; + + system.stateVersion = config.system.nixos.release; + + disko.devices.disk.main = { + device = "/dev/vda"; + type = "disk"; + content = { + type = "gpt"; + partitions = { + efiSystemPartition = { + type = "EF00"; + size = "512M"; + content = { + type = "filesystem"; + format = "vfat"; + mountpoint = "/boot"; + mountOptions = [ "umask=0077" ]; + }; + }; + root = { + size = "100%"; + content = { + type = "filesystem"; + format = "ext4"; + mountpoint = "/"; + }; + }; + }; + }; + }; + + networking.hostName = "deployee"; + + systemd.services.displaySuccessfulInstallSignal = { + script = '' + printf 'Successful install signal: %s\n' ${lib.escapeShellArg config.successfulInstallSignal} + ''; + unitConfig.SuccessAction = "poweroff"; + serviceConfig = { + StandardOutput = "journal+console"; + StandardError = "journal+console"; + }; + }; + + image.modules.disko-unattended-install-iso = { + boot.kernelParams = extraKernelParams; + disko.unattendedInstall = { + startAtBoot = "on"; + # This next part prevents nixos-install from wasting time by trying + # (and failing) to connect to cache.nixos.org. + extraNixOSInstallArgs = [ + "--option" + "substituters" + "" + ]; + }; + }; + }; +} From 08343e00ba0066241039a9cbfea4e13d5abe7e0a Mon Sep 17 00:00:00 2001 From: Jason Yundt Date: Tue, 9 Jun 2026 14:23:50 -0400 Subject: [PATCH 3/3] =?UTF-8?q?Add=20guide=20for=20using=20disko=E2=80=99s?= =?UTF-8?q?=20new=20unattended=20install=20module=20and=20image=20variant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change also removes the documentation for the old way of doing unattended NixOS installs. Closes #606. --- docs/INDEX.md | 1 + docs/disko-install.md | 113 ++----------------------------- docs/unattended-install.md | 135 +++++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 107 deletions(-) create mode 100644 docs/unattended-install.md diff --git a/docs/INDEX.md b/docs/INDEX.md index 2c1b02703..aae239090 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -12,6 +12,7 @@ - [How to Guide](./HowTo.md) - [Disko-Install](./disko-install.md) - [Disko-Images](./disko-images.md) +- [Performing Unattended NixOS Installations](./unattended-install.md) - [Support Matrix](./supportmatrix.md) - [Reference](./reference.md) - [Upgrade Guide](./upgrade-guide.md) diff --git a/docs/disko-install.md b/docs/disko-install.md index c19536a0f..dab0fde2b 100644 --- a/docs/disko-install.md +++ b/docs/disko-install.md @@ -135,110 +135,9 @@ from other devices if needed. ### Using disko-install in an offline installer -If you want to use **disko-install** from a custom installer without internet, -you need to make sure that in addition to the toplevel of your NixOS closure -that you plan to install, it also needs to contain **diskoScript** and all the -flake inputs for evaluation. - -#### Example configuration to install - -Add this to your flake.nix output: - -```nix -{ - nixosConfigurations.your-machine = inputs.nixpkgs.lib.nixosSystem { - system = "x86_64-linux"; - # to pass this flake into your configuration (see the example below) - specialArgs = {inherit self;}; - modules = [ - { - # TODO: add your NixOS configuration here, don't forget your hardware-configuration.nix as well! - boot.loader.systemd-boot.enable = true; - imports = [ self.inputs.disko.nixosModules.disko ]; - disko.devices = { - disk = { - vdb = { - device = "/dev/disk/by-id/some-disk-id"; - type = "disk"; - content = { - type = "gpt"; - partitions = { - ESP = { - type = "EF00"; - size = "500M"; - content = { - type = "filesystem"; - format = "vfat"; - mountpoint = "/boot"; - mountOptions = [ "umask=0077" ]; - }; - }; - root = { - size = "100%"; - content = { - type = "filesystem"; - format = "ext4"; - mountpoint = "/"; - }; - }; - }; - }; - }; - }; - }; - } - ]; - }; -} -``` - -#### Example for a NixOS installer - -```nix -# `self` here is referring to the flake `self`, you may need to pass it using `specialArgs` or define your NixOS installer configuration -# in the flake.nix itself to get direct access to the `self` flake variable. -{ lib, pkgs, self, ... }: -let - flakeOutPaths = - let - collector = - parent: - map ( - child: - [ child.outPath ] ++ (if child ? inputs && child.inputs != { } then (collector child) else [ ]) - ) (lib.attrValues parent.inputs); - in - lib.unique (lib.flatten (collector self)); - - dependencies = [ - self.nixosConfigurations.your-machine.config.system.build.toplevel - self.nixosConfigurations.your-machine.config.system.build.diskoScript - self.nixosConfigurations.your-machine.config.system.build.diskoScript.drvPath - self.nixosConfigurations.your-machine.pkgs.stdenv.drvPath - - # https://github.com/NixOS/nixpkgs/blob/f2fd33a198a58c4f3d53213f01432e4d88474956/nixos/modules/system/activation/top-level.nix#L342 - self.nixosConfigurations.your-machine.pkgs.perlPackages.ConfigIniFiles - self.nixosConfigurations.your-machine.pkgs.perlPackages.FileSlurp - - (self.nixosConfigurations.your-machine.pkgs.closureInfo { rootPaths = [ ]; }).drvPath - ] ++ flakeOutPaths; - - closureInfo = pkgs.closureInfo { rootPaths = dependencies; }; -in -# Now add `closureInfo` to your NixOS installer -{ - environment.etc."install-closure".source = "${closureInfo}/store-paths"; - - environment.systemPackages = [ - (pkgs.writeShellScriptBin "install-nixos-unattended" '' - set -eux - # Replace "/dev/disk/by-id/some-disk-id" with your actual disk ID - exec ${pkgs.disko}/bin/disko-install --flake "${self}#your-machine" --disk vdb "/dev/disk/by-id/some-disk-id" - '') - ]; -} -``` - -Also see the -[NixOS test of disko-install](https://github.com/nix-community/disko/blob/master/tests/disko-install/default.nix) -that also runs without internet. +Previous versions of this documentation contained some example code for how to +use **disko-install** to generate a custom installer that can be used to +perform offline installations of NixOS. Performing offline installations by +creating a custom installer that runs **disko-install** is no longer +recommended. If you would like to use **disko** to do an offline installation +of NixOS, then please take a look at [this guide](unattended-install.md). diff --git a/docs/unattended-install.md b/docs/unattended-install.md new file mode 100644 index 000000000..5195daada --- /dev/null +++ b/docs/unattended-install.md @@ -0,0 +1,135 @@ +# Performing Unattended NixOS Installations + +You can use **disko**’s NixOS module in order to perform unattended +installations of NixOS. When performing unattended NixOS installations, you +generally end up having two different NixOS configurations: + +1. The deployee configuration. This is the configuration that you are trying to + install onto a system. You must create this NixOS configuration yourself. + +2. The unattended installer configuration. This is the configuration that will + be used for the installation medium that your will boot into in order to + start the unattended installation. You can create the unattended installer + configuration yourself, or you can let **disko**’s NixOS module + automatically create it for you. + +Regardless of which method you choose, unattended installs can be done offline. +The unattended installer should finish successfully regardless of whether or +not the computer that your are installing NixOS on is able to connect to the +Internet. + +## Method 1: `nixos-rebuild build-image --image-variant disko-unattended-install-iso` + +This first method is designed to make doing unattended installations as easy as +possible. This first method is recommended in most scenarios. In order to +perform an unattended NixOS installation using this method, follow these +instructions: + +1. If you have not already, create a deployee configuration. Make sure that the + deployee configuration imports **disko**’s NixOS module and uses **disko** + for all of its disks, partitions and filesystems. + + If you aren’t sure how to create such a NixOS configuration, then please + take some time to use **disko** in order to perform a manual installation + of NixOS. After you perform a manual installation of NixOS using **disko**, + you can reuse the NixOS configuration for that installation as the deployee + configuration for future unattended NixOS installations. For more + information about how to perform a manual installation of NixOS using + **disko**, please take a look at [the Quickstart Guide](./quickstart.md). + +2. _(Optional)_ In the deployee configuration, customize the value of the + `image.modules.disko-unattended-install-iso` option. Customizing that option + will allow you to tweak the unattended installer configuration that will be + automatically generated for you in a later step. Take a look at [`/lib/unattended-install.nix`][unattended-install.nix] for + documentation about some of the options that you may want to tweak here. + +3. Build an unattended install ISO image by doing one of the following: + + - If you did not use flakes when creating your deployee configuration, then + run this command: + + ```bash + nixos-rebuild build-image --include nixos-config= --image-variant disko-unattended-install-iso + ``` + + - If your deployee configuration is available as a flake output, then run + this command: + + ```bash + nixos-rebuild build-image --flake # --image-variant disko-unattended-install-iso + ``` + + Once either of those previous commands finishes, there will be a newly + generate ISO image located inside the `./result/iso` directory. + +4. Write the ISO image that’s located in `./result/iso` onto a medium (for + example: a USB drive). This will turn the medium into an unattended + installation medium. + + If you aren’t sure how to do this, then please take a look at [the NixOS + Manual’s “Booting from a USB flash drive” + section](https://nixos.org/manual/nixos/stable/#sec-booting-from-usb). + +5. On the machine that you want to install NixOS on, start booting into the + unattended installation medium. + +6. Once the unattended installation medium starts booting, you will see a menu + that contains multiple different options. Choose the option that contains + the text “Disko Unattended NixOS Installer”. + + (That option may not be present in the boot menu if you made certain + customizations during step 2 of this procedure). + +7. Wait for the installation to finish successfully. If the installation + finishes successfully, then the computer that you are installing NixOS on + will shut itself down automatically. + + (The computer may not automatically shut down after the installation + finishes successfully if you made certain customizations during step 2 of + this procedure). + +8. Remove the unattended installation medium from the computer. + +9. Turn the computer on. At this point, your new installation of NixOS should + start booting! + +## Method 2: Manually Creating the Unattended Installer Configuration + +This second method is designed for advanced users. It’s not as straightforward +as the first method, but it allows for greater customization of the unattended +installation medium (for example, you could create an unattended installation +medium that doesn’t use any ISO 9660 filesystems at all). + +The instructions for this method aren’t as specific as the instructions for the +previous method. You’ll need to put in some extra work in order to figure out +exactly how you want to do everything. This method is only recommended for +advanced users. + +In order to perform an unattended NixOS installation using this method, follow +these instructions: + +1. If you have not already, create a deployee configuration. In your deployee + configuration, make sure that the `disko.unattendedInstall.enable` option is + set to `false`. + +2. Create an unattended installer configuration. In your unattended installer + configuration, make sure that the `disko.unattendedInstall.enable` option is + set to `true`. Also, make sure that you set the + `disko.unattendedInstall.deployeeConfiguration` option. For more information + about how to set `disko.unattendedInstall.deployeeConfiguration`, see + [`/lib/unattended-install.nix`][unattended-install.nix]. + +3. Somehow, install the unattended installer configuration onto a medium in + order to turn that medium into an unattended installation medium. You could + do this using [**disko-install**](./disko-install.md). You could also do + this by first running `nixos-rebuild build-image` and then writing the + resulting image to the medium. + +4. Boot into the newly created unattended installation medium. + +5. Make sure that the `unattendedInstallAtBoot.service` systemd service was + started during the boot process, or manually start the + `unattendedInstall.service` systemd service. + +[unattended-install.nix]: ../lib/unattended-install.nix