From d0532b28503a88e19436bb8479ad521b0292b130 Mon Sep 17 00:00:00 2001 From: Quentin Cazier Date: Fri, 11 Sep 2026 18:57:54 +0200 Subject: [PATCH 1/4] feat(installer): ask for the target disk when the configured one is missing When the disk set in securix.self.mainDisk does not exist on the machine, autoinstall-terminal now lists the unused disks, asks which one to install on and points the configured path at it with a symlink. The partitioning scripts generated by disko keep working unchanged, since they address the partitions by label and only use the disk path for partitioning. Floppy, loop and optical devices are left out, as well as disks holding a mounted filesystem such as the installer medium. Closes #131 Signed-off-by: Quentin Cazier --- lib/default.nix | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/lib/default.nix b/lib/default.nix index f9ddd35e..4d63dd9a 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -261,7 +261,8 @@ rec { This is the Securix live offline installer image edition ${edition}. This installer will install your system in ${mainDisk}, if that's not what you want, - contact the system administrators. + contact the system administrators. If that disk is missing, the installer will ask + which disk to use instead. Run: `autoinstall-terminal` to start the automatic installation process. ''; @@ -366,11 +367,35 @@ rec { box_message "All preflight checks passed." - log_info "${mainDisk} will be re-initialized and formatted, please confirm this is the right target." + main_disk="${mainDisk}" + if [ ! -b "$main_disk" ]; then + log_warn "$main_disk does not exist on this machine." + candidates=() + while read -r name size type model; do + [ "$type" = "disk" ] || continue + # Skip the disks in use, such as the installer medium. + if lsblk -no MOUNTPOINTS "/dev/$name" | grep -q .; then + continue + fi + candidates+=("/dev/$name $size $model") + done < <(lsblk -dn -o NAME,SIZE,TYPE,MODEL -e 2,7,11) + if [ "''${#candidates[@]}" -eq 0 ]; then + log_error "No disk available for the installation." + exit 1 + fi + choice=$(${pkgs.gum}/bin/gum choose --header "Installation disk" "''${candidates[@]}") || { log_warn "Operation cancelled."; exit 0; } + main_disk="''${choice%% *}" + # The partitioning scripts only know the configured path, so point it at the chosen disk. + mkdir -p "$(dirname "${mainDisk}")" + ln -s "$main_disk" "${mainDisk}" + log_info "${mainDisk} now points to $main_disk." + fi + + log_info "$main_disk will be re-initialized and formatted, please confirm this is the right target." ${pkgs.gum}/bin/gum confirm "Proceed with reformatting?" || { log_warn "Operation cancelled."; exit 0; } - wipefs -fa "${mainDisk}" ; sudo dd if=/dev/zero of="${mainDisk}" bs=4M count=1024; - log_info "${mainDisk} re-initialized and formatted." + wipefs -fa "$main_disk" ; sudo dd if=/dev/zero of="$main_disk" bs=4M count=1024; + log_info "$main_disk re-initialized and formatted." ${pkgs.systemd}/bin/udevadm settle ${diskProcedureScript} @@ -383,7 +408,7 @@ rec { exit 1 fi ${optionalString createSecureBootKeys createSecureBootKeysScript} - box_message "Burning the image on ${mainDisk}..." + box_message "Burning the image on $main_disk..." ${installProcedureScript config} ${optionalString enrollSecureBootKeys secureBootEnrollmentScript} ${optionalString (preprovisionOptions.tpm2HostKeys or false) tpm2ProvisionScript} From 27f94cca00b11872c4bc64f1b718c62abe10d338 Mon Sep 17 00:00:00 2001 From: Quentin Cazier Date: Fri, 11 Sep 2026 18:57:54 +0200 Subject: [PATCH 2/4] tests: cover the installer disk prompt Boots an installer configured for a disk that does not exist in the VM, answers the prompt and checks that the installation lands on the chosen disk. A second run must not ask again. Signed-off-by: Quentin Cazier --- tests/autoinstall-missing-disk.nix | 132 +++++++++++++++++++++++++++++ tests/default.nix | 1 + 2 files changed, 133 insertions(+) create mode 100644 tests/autoinstall-missing-disk.nix diff --git a/tests/autoinstall-missing-disk.nix b/tests/autoinstall-missing-disk.nix new file mode 100644 index 00000000..c2664a38 --- /dev/null +++ b/tests/autoinstall-missing-disk.nix @@ -0,0 +1,132 @@ +# SPDX-FileCopyrightText: 2026 Quentin Cazier +# +# SPDX-License-Identifier: MIT +# +# Regression test for https://github.com/cloud-gouv/securix/issues/131 +# +# Boots a Securix installer whose configured disk does not exist, picks the +# disk offered by `autoinstall-terminal` and checks that the installation +# lands on it. A second run must not ask again. + +{ pkgs, libSecurix }: + +let + lib = pkgs.lib; + + targetSystem = pkgs.nixos ( + { lib, ... }: { + imports = [ "${pkgs.disko.src}/module.nix" ]; + + options.securix.self.mainDisk = lib.mkOption { type = lib.types.str; }; + + config = { + securix.self.mainDisk = "/dev/nvme0n1"; + + disko.devices.disk.main = { + device = "/dev/nvme0n1"; + type = "disk"; + content = { + type = "gpt"; + partitions.root = { + size = "100%"; + content = { + type = "luks"; + name = "securix-root"; + passwordFile = "/tmp/disk-passphrase"; + content = { + type = "filesystem"; + format = "ext4"; + mountpoint = "/"; + }; + }; + }; + }; + }; + + users.users.root.initialPassword = "test"; + fileSystems."/".device = "/dev/mapper/securix-root"; + fileSystems."/".fsType = "ext4"; + boot.loader.grub.enable = false; + }; + } + ); + + installer = libSecurix.buildInstallerSystem { + inherit targetSystem; + installScript = "echo 'install skipped for test'"; + preprovisionOptions = { + secureBoot = "disabled"; + skipPreflightCheck = true; + tpm2HostKeys = false; + ageHostKeys = false; + }; + }; + + autoinstallPkg = + lib.findFirst (p: p.name or "" == "autoinstall-terminal") + (throw "autoinstall-terminal not found in installer packages") + installer.config.environment.systemPackages; + +in +pkgs.testers.nixosTest { + name = "autoinstall-terminal-missing-disk"; + + nodes.machine = _: { + virtualisation.emptyDiskImages = [ 4096 ]; + environment.systemPackages = [ + autoinstallPkg + pkgs.expect + ]; + }; + + testScript = '' + import textwrap + + first_run = textwrap.dedent("""\ + expect <<'EXPECT_EOF' + set timeout 300 + spawn autoinstall-terminal + expect "Installation disk" + sleep 1 + send "\r" + expect "Proceed with reformatting?" + send "\r" + expect { + "Installation is complete" { exit 0 } + timeout { puts "TIMEOUT"; exit 1 } + eof { puts "UNEXPECTED EOF"; exit 1 } + } + EXPECT_EOF + """) + + second_run = textwrap.dedent("""\ + expect <<'EXPECT_EOF' + set timeout 300 + spawn autoinstall-terminal + expect { + "Installation disk" { puts "ASKED AGAIN"; exit 1 } + "Proceed with reformatting?" { send "\r" } + timeout { puts "TIMEOUT"; exit 1 } + eof { puts "UNEXPECTED EOF"; exit 1 } + } + expect { + "Installation is complete" { exit 0 } + timeout { puts "TIMEOUT"; exit 1 } + eof { puts "UNEXPECTED EOF"; exit 1 } + } + EXPECT_EOF + """) + + machine.start() + machine.wait_for_unit("multi-user.target") + + machine.succeed("echo -n 'testpassphrase' > /tmp/disk-passphrase") + machine.fail("test -e /dev/nvme0n1") + + machine.succeed(first_run) + machine.succeed("test \"$(readlink /dev/nvme0n1)\" = /dev/vdb") + machine.succeed("lsblk -no PARTLABEL /dev/vdb | grep -q disk-main-root") + + machine.succeed(second_run) + ''; +} diff --git a/tests/default.nix b/tests/default.nix index 3f6f74b8..4b60ab3f 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -7,6 +7,7 @@ minimal = import ./minimal.nix { inherit pkgs libSecurix; }; anssi-minimal = import ./anssi-minimal.nix { inherit pkgs libSecurix; }; idempotent-autoinstall = import ./idempotent-autoinstall.nix { inherit pkgs libSecurix; }; + autoinstall-missing-disk = import ./autoinstall-missing-disk.nix { inherit pkgs libSecurix; }; portail = import ./portail.nix { inherit pkgs libSecurix; }; tools = import ./tools.nix { inherit pkgs libSecurix; }; } From 057ae1e0a7f7fd42e4ecf004ce8506ff8790973f Mon Sep 17 00:00:00 2001 From: Quentin Cazier Date: Tue, 22 Sep 2026 17:29:39 +0200 Subject: [PATCH 3/4] tests: install and boot the system in the missing disk test The test now runs a real installation with the securix_v1 layout on the disk picked at the prompt, then boots a second VM from that disk alone and checks that root is the LUKS volume found by its partition label. This shows that the installed system does not depend on the configured disk path. The expect scripts move to let bindings, and the lines copied from the idempotent installer test are dropped since the target is now a real bootable system. Signed-off-by: Quentin Cazier --- tests/autoinstall-missing-disk.nix | 178 ++++++++++++++++------------- 1 file changed, 101 insertions(+), 77 deletions(-) diff --git a/tests/autoinstall-missing-disk.nix b/tests/autoinstall-missing-disk.nix index c2664a38..25033c7f 100644 --- a/tests/autoinstall-missing-disk.nix +++ b/tests/autoinstall-missing-disk.nix @@ -4,59 +4,57 @@ # # Regression test for https://github.com/cloud-gouv/securix/issues/131 # -# Boots a Securix installer whose configured disk does not exist, picks the -# disk offered by `autoinstall-terminal` and checks that the installation -# lands on it. A second run must not ask again. +# Installs a system configured for a disk that does not exist on the disk +# picked at the installer prompt, runs the installer a second time, then +# boots a second VM from that disk alone. { pkgs, libSecurix }: let lib = pkgs.lib; + qemu-common = import "${pkgs.path}/nixos/lib/qemu-common.nix" { + inherit lib; + inherit (pkgs) stdenv; + }; + + # Unlocks LUKS at install time and from the initrd at boot. + diskKey = pkgs.writeText "disk-key" "testpassphrase"; + targetSystem = pkgs.nixos ( - { lib, ... }: { - imports = [ "${pkgs.disko.src}/module.nix" ]; + { lib, modulesPath, ... }: { + imports = [ + "${pkgs.disko.src}/module.nix" + ../modules/filesystems + (modulesPath + "/testing/test-instrumentation.nix") + (modulesPath + "/profiles/qemu-guest.nix") + ]; options.securix.self.mainDisk = lib.mkOption { type = lib.types.str; }; config = { securix.self.mainDisk = "/dev/nvme0n1"; + securix.filesystems.layout = "securix_v1"; + + disko.devices.disk."/dev/nvme0n1".content.partitions.luks.content.passwordFile = "${diskKey}"; + boot.initrd.secrets."/etc/secrets/disk.key" = diskKey; + boot.initrd.luks.devices.croot.keyFile = "/etc/secrets/disk.key"; - disko.devices.disk.main = { - device = "/dev/nvme0n1"; - type = "disk"; - content = { - type = "gpt"; - partitions.root = { - size = "100%"; - content = { - type = "luks"; - name = "securix-root"; - passwordFile = "/tmp/disk-passphrase"; - content = { - type = "filesystem"; - format = "ext4"; - mountpoint = "/"; - }; - }; - }; - }; - }; - - users.users.root.initialPassword = "test"; - fileSystems."/".device = "/dev/mapper/securix-root"; - fileSystems."/".fsType = "ext4"; - boot.loader.grub.enable = false; + # Securix uses lanzaboote, systemd-boot is enough to boot the disk in a VM. + # The installer VM is not booted with UEFI, hence graceful. + boot.loader.systemd-boot.enable = true; + boot.loader.systemd-boot.graceful = true; + + documentation.enable = false; + system.stateVersion = "26.05"; }; } ); installer = libSecurix.buildInstallerSystem { inherit targetSystem; - installScript = "echo 'install skipped for test'"; preprovisionOptions = { secureBoot = "disabled"; - skipPreflightCheck = true; tpm2HostKeys = false; ageHostKeys = false; }; @@ -67,12 +65,58 @@ let (throw "autoinstall-terminal not found in installer packages") installer.config.environment.systemPackages; + # Runs autoinstall-terminal with the given answers and waits for it to finish. + mkInstallRun = + name: answers: + pkgs.writeText "${name}.exp" '' + set timeout 600 + spawn autoinstall-terminal + ${answers} + expect { + "Installation is complete" {} + timeout { puts "TIMEOUT"; exit 1 } + eof { puts "UNEXPECTED EOF"; exit 1 } + } + expect eof + lassign [wait] pid spawnid os_error value + exit $value + ''; + + firstRun = mkInstallRun "first-run" '' + expect "Installation disk" + sleep 1 + send "\r" + expect "Proceed with reformatting?" + send "\r" + ''; + + secondRun = mkInstallRun "second-run" '' + expect { + "Installation disk" { puts "ASKED AGAIN"; exit 1 } + "Proceed with reformatting?" { send "\r" } + timeout { puts "TIMEOUT"; exit 1 } + } + ''; + + # Boots the installed disk alone, with UEFI firmware. + bootInstalledDisk = lib.concatStringsSep " " [ + (qemu-common.qemuBinary pkgs.qemu_test) + "-m 1024" + "-device virtio-blk-pci,drive=installed" + "-drive if=pflash,format=raw,unit=0,readonly=on,file=${pkgs.OVMF.firmware}" + "-drive if=pflash,format=raw,unit=1,readonly=on,file=${pkgs.OVMF.variables}" + ]; in pkgs.testers.nixosTest { name = "autoinstall-terminal-missing-disk"; nodes.machine = _: { - virtualisation.emptyDiskImages = [ 4096 ]; + virtualisation.emptyDiskImages = [ 12288 ]; + virtualisation.memorySize = 2048; + boot.supportedFilesystems = [ + "btrfs" + "vfat" + ]; environment.systemPackages = [ autoinstallPkg pkgs.expect @@ -80,53 +124,33 @@ pkgs.testers.nixosTest { }; testScript = '' - import textwrap - - first_run = textwrap.dedent("""\ - expect <<'EXPECT_EOF' - set timeout 300 - spawn autoinstall-terminal - expect "Installation disk" - sleep 1 - send "\r" - expect "Proceed with reformatting?" - send "\r" - expect { - "Installation is complete" { exit 0 } - timeout { puts "TIMEOUT"; exit 1 } - eof { puts "UNEXPECTED EOF"; exit 1 } - } - EXPECT_EOF - """) - - second_run = textwrap.dedent("""\ - expect <<'EXPECT_EOF' - set timeout 300 - spawn autoinstall-terminal - expect { - "Installation disk" { puts "ASKED AGAIN"; exit 1 } - "Proceed with reformatting?" { send "\r" } - timeout { puts "TIMEOUT"; exit 1 } - eof { puts "UNEXPECTED EOF"; exit 1 } - } - expect { - "Installation is complete" { exit 0 } - timeout { puts "TIMEOUT"; exit 1 } - eof { puts "UNEXPECTED EOF"; exit 1 } - } - EXPECT_EOF - """) - machine.start() machine.wait_for_unit("multi-user.target") - - machine.succeed("echo -n 'testpassphrase' > /tmp/disk-passphrase") machine.fail("test -e /dev/nvme0n1") - machine.succeed(first_run) - machine.succeed("test \"$(readlink /dev/nvme0n1)\" = /dev/vdb") - machine.succeed("lsblk -no PARTLABEL /dev/vdb | grep -q disk-main-root") - - machine.succeed(second_run) + with subtest("the installer asks for a disk and installs on it"): + machine.succeed("expect ${firstRun}") + machine.succeed("test \"$(readlink /dev/nvme0n1)\" = /dev/vdb") + machine.succeed("lsblk -no PARTLABEL /dev/vdb | grep -q disk-_dev_nvme0n1-luks") + + with subtest("a second run does not ask again"): + machine.succeed("expect ${secondRun}") + + machine.succeed("sync") + machine.shutdown() + + disk = f"{machine.state_dir}/empty0.qcow2" + booted = create_machine( + start_command=f"${bootInstalledDisk} -drive file={disk},id=installed,if=none,werror=report", + name="booted", + ) + driver.machines_qemu.append(booted) + booted.start() + + with subtest("the installed system boots from the chosen disk"): + booted.wait_for_unit("multi-user.target") + booted.fail("test -e /dev/nvme0n1") + booted.succeed("findmnt -n -o SOURCE / | grep -q '^/dev/mapper/croot'") + booted.succeed("cryptsetup status croot | grep -q 'device:.*/dev/vda3'") ''; } From 642b57e78d827927d5e9afa95d6e28c03ffc07d7 Mon Sep 17 00:00:00 2001 From: Quentin Cazier Date: Wed, 23 Sep 2026 18:09:59 +0200 Subject: [PATCH 4/4] tests: skip the FIDO2 preflight check in the missing disk test Since #267 the preflight check runs unless skipPreflightChecks is set, and there is no security key in the VM. Signed-off-by: Quentin Cazier --- tests/autoinstall-missing-disk.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/autoinstall-missing-disk.nix b/tests/autoinstall-missing-disk.nix index 25033c7f..f9c77c40 100644 --- a/tests/autoinstall-missing-disk.nix +++ b/tests/autoinstall-missing-disk.nix @@ -55,6 +55,7 @@ let inherit targetSystem; preprovisionOptions = { secureBoot = "disabled"; + skipPreflightChecks = true; tpm2HostKeys = false; ageHostKeys = false; };