diff --git a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py index a040518a5a575..85a349faf3bd5 100755 --- a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py +++ b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py @@ -61,14 +61,14 @@ def generation_conf_filename(profile: Optional[str], generation: int, specialisa def write_loader_conf(profile: Optional[str], generation: int, specialisation: Optional[str]) -> None: - with open("@efiSysMountPoint@/loader/loader.conf.tmp", 'w') as f: + with open("@mountPoint@/loader/loader.conf.tmp", 'w') as f: if "@timeout@" != "": f.write("timeout @timeout@\n") f.write("default %s\n" % generation_conf_filename(profile, generation, specialisation)) if not @editor@: f.write("editor 0\n"); f.write("console-mode @consoleMode@\n"); - os.rename("@efiSysMountPoint@/loader/loader.conf.tmp", "@efiSysMountPoint@/loader/loader.conf") + os.rename("@mountPoint@/loader/loader.conf.tmp", "@mountPoint@/loader/loader.conf") def profile_path(profile: Optional[str], generation: int, specialisation: Optional[str], name: str) -> str: @@ -81,7 +81,7 @@ def copy_from_profile(profile: Optional[str], generation: int, specialisation: O store_dir = os.path.basename(os.path.dirname(store_file_path)) efi_file_path = "/efi/nixos/%s-%s.efi" % (store_dir, suffix) if not dry_run: - copy_if_not_exists(store_file_path, "@efiSysMountPoint@%s" % (efi_file_path)) + copy_if_not_exists(store_file_path, "@mountPoint@%s" % (efi_file_path)) return efi_file_path @@ -117,7 +117,7 @@ def write_entry(profile: Optional[str], generation: int, specialisation: Optiona try: append_initrd_secrets = profile_path(profile, generation, specialisation, "append-initrd-secrets") - subprocess.check_call([append_initrd_secrets, "@efiSysMountPoint@%s" % (initrd)]) + subprocess.check_call([append_initrd_secrets, "@mountPoint@%s" % (initrd)]) except FileNotFoundError: pass except subprocess.CalledProcessError: @@ -129,7 +129,7 @@ def write_entry(profile: Optional[str], generation: int, specialisation: Optiona f'for "{title} - Configuration {generation}", an older generation', file=sys.stderr) print("note: this is normal after having removed " "or renamed a file in `boot.initrd.secrets`", file=sys.stderr) - entry_file = "@efiSysMountPoint@/loader/entries/%s" % ( + entry_file = "@mountPoint@/loader/entries/%s" % ( generation_conf_filename(profile, generation, specialisation)) tmp_path = "%s.tmp" % (entry_file) kernel_params = "init=%s " % profile_path(profile, generation, specialisation, "init") @@ -188,13 +188,13 @@ def get_specialisations(profile: Optional[str], generation: int, _: Optional[str def remove_old_entries(gens: List[SystemIdentifier]) -> None: - rex_profile = re.compile("^@efiSysMountPoint@/loader/entries/nixos-(.*)-generation-.*\.conf$") - rex_generation = re.compile("^@efiSysMountPoint@/loader/entries/nixos.*-generation-([0-9]+)(-specialisation-.*)?\.conf$") + rex_profile = re.compile("^@mountPoint@/loader/entries/nixos-(.*)-generation-.*\.conf$") + rex_generation = re.compile("^@mountPoint@/loader/entries/nixos.*-generation-([0-9]+)(-specialisation-.*)?\.conf$") known_paths = [] for gen in gens: known_paths.append(copy_from_profile(*gen, "kernel", True)) known_paths.append(copy_from_profile(*gen, "initrd", True)) - for path in glob.iglob("@efiSysMountPoint@/loader/entries/nixos*-generation-[1-9]*.conf"): + for path in glob.iglob("@mountPoint@/loader/entries/nixos*-generation-[1-9]*.conf"): if rex_profile.match(path): prof = rex_profile.sub(r"\1", path) else: @@ -205,7 +205,7 @@ def remove_old_entries(gens: List[SystemIdentifier]) -> None: continue if not (prof, gen_number, None) in gens: os.unlink(path) - for path in glob.iglob("@efiSysMountPoint@/efi/nixos/*"): + for path in glob.iglob("@mountPoint@/efi/nixos/*"): if not path in known_paths and not os.path.isdir(path): os.unlink(path) @@ -252,14 +252,14 @@ def main() -> None: if os.getenv("NIXOS_INSTALL_BOOTLOADER") == "1": # bootctl uses fopen() with modes "wxe" and fails if the file exists. - if os.path.exists("@efiSysMountPoint@/loader/loader.conf"): - os.unlink("@efiSysMountPoint@/loader/loader.conf") + if os.path.exists("@mountPoint@/loader/loader.conf"): + os.unlink("@mountPoint@/loader/loader.conf") - subprocess.check_call(["@systemd@/bin/bootctl", "--esp-path=@efiSysMountPoint@"] + bootctl_flags + ["install"]) + subprocess.check_call(["@systemd@/bin/bootctl", "--esp-path=@mountPoint@"] + bootctl_flags + ["install"]) else: # Update bootloader to latest if needed available_out = subprocess.check_output(["@systemd@/bin/bootctl", "--version"], universal_newlines=True).split()[2] - installed_out = subprocess.check_output(["@systemd@/bin/bootctl", "--esp-path=@efiSysMountPoint@", "status"], universal_newlines=True) + installed_out = subprocess.check_output(["@systemd@/bin/bootctl", "--esp-path=@mountPoint@", "status"], universal_newlines=True) # See status_binaries() in systemd bootctl.c for code which generates this installed_match = re.search(r"^\W+File:.*/EFI/(?:BOOT|systemd)/.*\.efi \(systemd-boot ([\d.]+[^)]*)\)$", @@ -284,10 +284,10 @@ def main() -> None: print("skipping systemd-boot update to %s because of known regression" % available_version) else: print("updating systemd-boot from %s to %s" % (installed_version, available_version)) - subprocess.check_call(["@systemd@/bin/bootctl", "--esp-path=@efiSysMountPoint@"] + bootctl_flags + ["update"]) + subprocess.check_call(["@systemd@/bin/bootctl", "--esp-path=@mountPoint@"] + bootctl_flags + ["update"]) - mkdir_p("@efiSysMountPoint@/efi/nixos") - mkdir_p("@efiSysMountPoint@/loader/entries") + mkdir_p("@mountPoint@/efi/nixos") + mkdir_p("@mountPoint@/loader/entries") gens = get_generations() for profile in get_profiles(): @@ -309,9 +309,9 @@ def main() -> None: else: raise e - for root, _, files in os.walk('@efiSysMountPoint@/efi/nixos/.extra-files', topdown=False): - relative_root = root.removeprefix("@efiSysMountPoint@/efi/nixos/.extra-files").removeprefix("/") - actual_root = os.path.join("@efiSysMountPoint@", relative_root) + for root, _, files in os.walk('@mountPoint@/efi/nixos/.extra-files', topdown=False): + relative_root = root.removeprefix("@mountPoint@/efi/nixos/.extra-files").removeprefix("/") + actual_root = os.path.join("@mountPoint@", relative_root) for file in files: actual_file = os.path.join(actual_root, file) @@ -324,7 +324,7 @@ def main() -> None: os.rmdir(actual_root) os.rmdir(root) - mkdir_p("@efiSysMountPoint@/efi/nixos/.extra-files") + mkdir_p("@mountPoint@/efi/nixos/.extra-files") subprocess.check_call("@copyExtraFiles@") @@ -332,9 +332,9 @@ def main() -> None: # it can leave the system in an unbootable state, when a crash/outage # happens shortly after an update. To decrease the likelihood of this # event sync the efi filesystem after each update. - rc = libc.syncfs(os.open("@efiSysMountPoint@", os.O_RDONLY)) + rc = libc.syncfs(os.open("@mountPoint@", os.O_RDONLY)) if rc != 0: - print("could not sync @efiSysMountPoint@: {}".format(os.strerror(rc)), file=sys.stderr) + print("could not sync @mountPoint@: {}".format(os.strerror(rc)), file=sys.stderr) if __name__ == '__main__': diff --git a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot.nix b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot.nix index 8a3e89e5888bc..9245c656f0e5f 100644 --- a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot.nix +++ b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot.nix @@ -9,7 +9,7 @@ let python3 = pkgs.python3.withPackages (ps: [ ps.packaging ]); - systemdBootBuilder = pkgs.substituteAll { + systemdBootBuilder = mountPoint: pkgs.substituteAll { src = ./systemd-boot-builder.py; isExecutable = true; @@ -28,7 +28,9 @@ let inherit (cfg) consoleMode graceful; - inherit (efi) efiSysMountPoint canTouchEfiVariables; + inherit (efi) canTouchEfiVariables; + + inherit mountPoint; inherit (config.system.nixos) distroName; @@ -40,21 +42,21 @@ let empty_file=$(${pkgs.coreutils}/bin/mktemp) ${concatStrings (mapAttrsToList (n: v: '' - ${pkgs.coreutils}/bin/install -Dp "${v}" "${efi.efiSysMountPoint}/"${escapeShellArg n} - ${pkgs.coreutils}/bin/install -D $empty_file "${efi.efiSysMountPoint}/efi/nixos/.extra-files/"${escapeShellArg n} + ${pkgs.coreutils}/bin/install -Dp "${v}" "${mountPoint}/"${escapeShellArg n} + ${pkgs.coreutils}/bin/install -D $empty_file "${mountPoint}/efi/nixos/.extra-files/"${escapeShellArg n} '') cfg.extraFiles)} ${concatStrings (mapAttrsToList (n: v: '' - ${pkgs.coreutils}/bin/install -Dp "${pkgs.writeText n v}" "${efi.efiSysMountPoint}/loader/entries/"${escapeShellArg n} - ${pkgs.coreutils}/bin/install -D $empty_file "${efi.efiSysMountPoint}/efi/nixos/.extra-files/loader/entries/"${escapeShellArg n} + ${pkgs.coreutils}/bin/install -Dp "${pkgs.writeText n v}" "${mountPoint}/loader/entries/"${escapeShellArg n} + ${pkgs.coreutils}/bin/install -D $empty_file "${mountPoint}/efi/nixos/.extra-files/loader/entries/"${escapeShellArg n} '') cfg.extraEntries)} ''; }; - checkedSystemdBootBuilder = pkgs.runCommand "systemd-boot" { + checkedSystemdBootBuilder = mountPoint: pkgs.runCommand "systemd-boot" { nativeBuildInputs = [ pkgs.mypy python3 ]; } '' - install -m755 ${systemdBootBuilder} $out + install -m755 ${systemdBootBuilder mountPoint} $out mypy \ --no-implicit-optional \ --disallow-untyped-calls \ @@ -62,11 +64,16 @@ let $out ''; - finalSystemdBootBuilder = pkgs.writeScript "install-systemd-boot.sh" '' - #!${pkgs.runtimeShell} - ${checkedSystemdBootBuilder} "$@" - ${cfg.extraInstallCommands} - ''; + finalSystemdBootBuilder = let + installDirs = + if cfg.mirroredBoots != [] + then cfg.mirroredBoots + else [efi.efiSysMountPoint]; + in + pkgs.writeShellScript "install-systemd-boot.sh" + (lib.concatMapStrings (x: "${checkedSystemdBootBuilder x} \"$@\"\n") installDirs) + + cfg.extraInstallCommands; + in { imports = @@ -238,6 +245,17 @@ in { ''; }; + mirroredBoots = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = []; + example = '' + [ "/boot1" "/boot2" ] + ''; + description = lib.mdDoc '' + Mirror the boot configuration to multiple locations. + ''; + }; + }; config = mkIf cfg.enable { diff --git a/nixos/tests/systemd-boot.nix b/nixos/tests/systemd-boot.nix index 84a4da5aa6ec5..b805b1cccd4d4 100644 --- a/nixos/tests/systemd-boot.nix +++ b/nixos/tests/systemd-boot.nix @@ -199,6 +199,142 @@ in machine.succeed("test -e /boot/efi/nixos/.extra-files/efi/fruits/tomato.efi") ''; }; + mirroredBoots = makeTest { + name = "mirrored-boots"; + nodes.machine = { + config, + ... + }: { + imports = [ + ../modules/image/repart.nix + + ]; + virtualisation = { + useEFIBoot = true; + directBoot.enable = false; + useBootLoader = true; + useDefaultFilesystems = false; + fileSystems = { + "/" = { + device = "/dev/disk/by-partlabel/root"; + fsType = "ext4"; + }; + "/boot0" = { + device = "/dev/disk/by-partlabel/esp"; + fsType = "vfat"; + }; + "/boot1" = { + device = "/dev/disk/by-partlabel/esp"; + fsType = "vfat"; + }; + "/boot2" = { + device = "/dev/disk/by-partlabel/esp"; + fsType = "vfat"; + }; + }; + }; + image.repart = { + name = "mirrored-boots-image"; + partitions = { + "boot0" = { + repartConfig = { + Type = "esp"; + Format = "vfat"; + SizeMinBytes = + if config.nixpkgs.hostPlatform.isx86_64 + then "64M" + else "96M"; + Label = "esp"; + }; + }; + "boot1" = { + repartConfig = { + Type = "esp"; + Format = "vfat"; + SizeMinBytes = + if config.nixpkgs.hostPlatform.isx86_64 + then "64M" + else "96M"; + Label = "esp"; + }; + }; + "boot2" = { + repartConfig = { + Type = "esp"; + Format = "vfat"; + SizeMinBytes = + if config.nixpkgs.hostPlatform.isx86_64 + then "64M" + else "96M"; + Label = "esp"; + }; + }; + "root" = { + storePaths = [config.system.build.toplevel]; + repartConfig = { + Type = "root"; + Format = config.fileSystems."/".fsType; + Label = "root"; + Minimize = "guess"; + }; + }; + }; + }; + boot.loader = { + efi = { + efiSysMountPoint = "/boot0"; + canTouchEfiVariables = true; + }; + systemd-boot = { + enable = true; + mirroredBoots = [ + "/boot1" + "/boot2" + ]; + }; + }; + }; + + testScript = {nodes, ...}: + '' + import os + import subprocess + import tempfile + + tmp_disk_image = tempfile.NamedTemporaryFile() + + subprocess.run([ + "${nodes.machine.virtualisation.qemu.package}/bin/qemu-img", + "create", + "-f", + "qcow2", + "-b", + "${nodes.machine.system.build.image}/image.raw", + "-F", + "raw", + tmp_disk_image.name, + ]) + + # Set NIX_DISK_IMAGE so that the qemu script finds the right disk image. + os.environ['NIX_DISK_IMAGE'] = tmp_disk_image.name + machine.start() + machine.wait_for_unit("multi-user.target") + machine.fail("test -n \"$(find /boot0 -prune empty)\"") + machine.succeed("test -z \"$(find /boot1 -prune empty)\"") + machine.succeed("test -z \"$(find /boot2 -prune empty)\"") + machine.succeed("diff -r -q -x random-seed /boot1 /boot2") + ''; + /* + tests are + /boot0 should be empty + /boot1 and /boot2 shoud not be empty + + /boot1 should equal /boot2 (except random-seed) + + efibootmgr should show two UEFI OS entries + + */ + }; switch-test = makeTest { name = "systemd-boot-switch-test";