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 2804aecbddb5..b2873966619c 100644 --- a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py +++ b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py @@ -11,7 +11,8 @@ import sys import tempfile import warnings import json -from typing import NamedTuple, Any, Sequence +import glob +from typing import NamedTuple, Any, Sequence, Type from dataclasses import dataclass from pathlib import Path @@ -36,6 +37,8 @@ GRACEFUL = "@graceful@" COPY_EXTRA_FILES = "@copyExtraFiles@" CHECK_MOUNTPOINTS = "@checkMountpoints@" STORE_DIR = "@storeDir@" +BOOT_COUNTING_TRIES = "@bootCountingTries@" +BOOT_COUNTING = "@bootCounting@" == "True" @dataclass @@ -53,6 +56,135 @@ class BootSpec: initrdSecrets: str | None = None # noqa: N815 +@dataclass +class Entry: + profile: str | None + generation_number: int + specialisation: str | None + + @classmethod + def from_path(cls: Type["Entry"], path: Path) -> "Entry": + filename = path.name + # Matching nixos-$profile-generation-*.conf + rex_profile = re.compile(r"^nixos-(.*)-generation-.*\.conf$") + # Matching nixos*-generation-$number*.conf + rex_generation = re.compile(r"^nixos.*-generation-([0-9]+).*\.conf$") + # Matching nixos*-generation-$number-specialisation-$specialisation_name*.conf + rex_specialisation = re.compile( + r"^nixos.*-generation-([0-9]+)-specialisation-([a-zA-Z0-9]+).*\.conf$" + ) + profile = ( + rex_profile.sub(r"\1", filename) if rex_profile.match(filename) else None + ) + specialisation = ( + rex_specialisation.sub(r"\2", filename) + if rex_specialisation.match(filename) + else None + ) + try: + generation_number = int(rex_generation.sub(r"\1", filename)) + except ValueError: + raise + return cls(profile, generation_number, specialisation) + + +@dataclass +class DiskEntry: + entry: Entry + default: bool + counters: str | None + title: str | None + description: str | None + kernel: Path + initrd: Path + devicetree: Path | None + kernel_params: str | None + machine_id: str | None + sort_key: str + + @classmethod + def from_path(cls: Type["DiskEntry"], path: Path) -> "DiskEntry": + entry = Entry.from_path(path) + data = path.read_text().splitlines() + if "" in data: + data.remove("") + entry_map = dict(lines.split(" ", 1) for lines in data) + assert "linux" in entry_map + assert "initrd" in entry_map + filename = path.name + # Matching nixos*-generation-*$counters.conf + rex_counters = re.compile(r"^nixos.*-generation-.*(\+\d(-\d)?)\.conf$") + counters = ( + rex_counters.sub(r"\1", filename) if rex_counters.match(filename) else None + ) + + maybe_devicetree_path = entry_map.get("devicetree") + disk_entry = cls( + entry=entry, + default=(entry_map.get("sort-key") == "default"), + counters=counters, + title=entry_map.get("title"), + description=entry_map.get("version"), + kernel=Path(entry_map["linux"]), + initrd=Path(entry_map["initrd"]), + devicetree=Path(maybe_devicetree_path) if maybe_devicetree_path else None, + machine_id=entry_map.get("machine-id"), + sort_key=entry_map.get("sort_key", "nixos"), + kernel_params=entry_map.get("options"), + ) + return disk_entry + + def write(self, sorted_first: str) -> None: + # TODO + # Compute a sort-key sorted before sorted_first + # This will compute something like: nixos -> nixor-default to make sure we come before other nixos entries, + # while allowing users users can pre-pend their own entries before. + default_sort_key = ( + sorted_first[:-1] + chr(ord(sorted_first[-1]) - 1) + "-default" + ) + tmp_path = self.path.with_suffix(".tmp") + with tmp_path.open("w") as f: + # We use "sort-key" to sort the default generation first. + # The "default" string is sorted before "non-default" (alphabetically) + boot_entry = [ + f"title {self.title}" if self.title is not None else None, + f"version {self.description}" if self.description is not None else None, + f"linux {self.kernel}", + f"initrd {self.initrd}", + f"options {self.kernel_params}" + if self.kernel_params is not None + else None, + f"machine-id {self.machine_id}" + if self.machine_id is not None + else None, + f"devicetree /{self.devicetree}" + if self.devicetree is not None + else None, + f"sort-key {default_sort_key if self.default else self.sort_key}", + ] + + f.write("\n".join(filter(None, boot_entry))) + f.flush() + os.fsync(f.fileno()) + tmp_path.rename(self.path) + + @property + def path(self) -> Path: + pieces = [ + "nixos", + self.entry.profile or None, + "generation", + str(self.entry.generation_number), + f"specialisation-{self.entry.specialisation}" + if self.entry.specialisation + else None, + ] + prefix = "-".join(p for p in pieces if p) + return Path( + f"{BOOT_MOUNT_POINT}/loader/entries/{prefix}{self.counters if self.counters else ''}.conf" + ) + + libc = ctypes.CDLL("libc.so.6") FILE = None | int @@ -72,7 +204,9 @@ class SystemIdentifier(NamedTuple): def copy_if_not_exists(source: Path, dest: Path) -> None: if not dest.exists(): - tmpfd, tmppath = tempfile.mkstemp(dir=dest.parent, prefix=dest.name, suffix='.tmp.') + tmpfd, tmppath = tempfile.mkstemp( + dir=dest.parent, prefix=dest.name, suffix=".tmp." + ) shutil.copyfile(source, tmppath) os.fsync(tmpfd) shutil.move(tmppath, dest) @@ -97,38 +231,14 @@ def system_dir( return d -BOOT_ENTRY = """title {title} -sort-key {sort_key} -version Generation {generation} {description} -linux {kernel} -initrd {initrd} -options {kernel_params} -""" - - -def generation_conf_filename( - profile: str | None, generation: int, specialisation: str | None -) -> str: - pieces = [ - "nixos", - profile or None, - "generation", - str(generation), - f"specialisation-{specialisation}" if specialisation else None, - ] - return "-".join(p for p in pieces if p) + ".conf" - - -def write_loader_conf( - profile: str | None, generation: int, specialisation: str | None -) -> None: +def write_loader_conf(profile: str | None) -> None: tmp = LOADER_CONF.with_suffix(".tmp") with tmp.open("x") as f: f.write(f"timeout {TIMEOUT}\n") - f.write( - "default %s\n" - % generation_conf_filename(profile, generation, specialisation) - ) + if profile: + f.write("default nixos-%s-generation-*\n" % profile) + else: + f.write("default nixos-generation-*\n") if not EDITOR: f.write("editor 0\n") if REBOOT_FOR_BITLOCKER: @@ -139,6 +249,23 @@ def write_loader_conf( os.rename(tmp, LOADER_CONF) +def scan_entries() -> list[DiskEntry]: + """ + Scan all entries in $ESP/loader/entries/* + Does not support Type 2 entries as we do not support them for now. + Returns a generator of Entry. + """ + entries = [] + for path in Path(f"{EFI_SYS_MOUNT_POINT}/loader/entries/").glob( + "nixos*-generation-[1-9]*.conf" + ): + try: + entries.append(DiskEntry.from_path(path)) + except ValueError: + continue + return entries + + def get_bootspec(profile: str | None, generation: int) -> BootSpec: system_directory = system_dir(profile, generation, None) boot_json_path = (system_directory / "boot.json").resolve() @@ -210,6 +337,8 @@ def write_entry( specialisation: str | None, machine_id: str | None, bootspec: BootSpec, + entries: list[DiskEntry], + sorted_first: str, current: bool, ) -> None: if specialisation: @@ -244,37 +373,35 @@ def write_entry( "or renamed a file in `boot.initrd.secrets`", file=sys.stderr, ) - entry_file = ( - BOOT_MOUNT_POINT - / "loader/entries" - / generation_conf_filename(profile, generation, specialisation) - ) - tmp_path = entry_file.with_suffix(".tmp") kernel_params = "init=%s " % bootspec.init kernel_params = kernel_params + " ".join(bootspec.kernelParams) build_time = int(system_dir(profile, generation, specialisation).stat().st_ctime) build_date = datetime.datetime.fromtimestamp(build_time).strftime("%F") - with tmp_path.open("w") as f: - f.write( - BOOT_ENTRY.format( - title=title, - sort_key=bootspec.sortKey, - generation=generation, - kernel=f"/{kernel}", - initrd=f"/{initrd}", - kernel_params=kernel_params, - description=f"{bootspec.label}, built on {build_date}", - ) - ) - if machine_id is not None: - f.write("machine-id %s\n" % machine_id) - if devicetree is not None: - f.write(f"devicetree /{devicetree}\n") - f.flush() - os.fsync(f.fileno()) - tmp_path.rename(entry_file) + counters = f"+{BOOT_COUNTING_TRIES}" if BOOT_COUNTING else "" + entry = Entry(profile, generation, specialisation) + # We check if the entry we are writing is already on disk + # and we update its "default entry" status + for entry_on_disk in entries: + if entry == entry_on_disk.entry: + entry_on_disk.default = current + entry_on_disk.write(sorted_first) + return + + DiskEntry( + entry=entry, + title=title, + kernel=kernel, + initrd=initrd, + devicetree=devicetree, + counters=counters, + kernel_params=kernel_params, + machine_id=machine_id, + description=f"Generation {generation} {bootspec.label}, built on {build_date}", + sort_key=bootspec.sortKey, + default=current, + ).write(sorted_first) def get_generations(profile: str | None = None) -> list[SystemIdentifier]: @@ -301,11 +428,9 @@ def get_generations(profile: str | None = None) -> list[SystemIdentifier]: return configurations[-configurationLimit:] -def remove_old_entries(gens: list[SystemIdentifier]) -> None: - rex_profile = re.compile(r"^nixos-(.*)-generation-.*\.conf$") - rex_generation = re.compile( - r"^nixos.*-generation-([0-9]+)(-specialisation-.*)?\.conf$" - ) +def remove_old_entries( + gens: list[SystemIdentifier], disk_entries: list[DiskEntry] +) -> None: known_paths = [] for gen in gens: bootspec = get_bootspec(gen.profile, gen.generation) @@ -313,20 +438,16 @@ def remove_old_entries(gens: list[SystemIdentifier]) -> None: known_paths.append(copy_from_file(bootspec.initrd, True).name) if bootspec.devicetree is not None: known_paths.append(copy_from_file(bootspec.devicetree, True).name) - for path in (BOOT_MOUNT_POINT / "loader/entries").glob( - "nixos*-generation-[1-9]*.conf", case_sensitive=False - ): - if rex_profile.match(path.name): - prof = rex_profile.sub(r"\1", path.name) - else: - prof = None - try: - gen_number = int(rex_generation.sub(r"\1", path.name)) - except ValueError: - continue - if (prof, gen_number, None) not in gens: - path.unlink() - for path in (BOOT_MOUNT_POINT / NIXOS_DIR).iterdir(): + + for disk_entry in disk_entries: + if ( + disk_entry.entry.profile, + disk_entry.entry.generation_number, + None, + ) not in gens: + os.unlink(disk_entry.path) + for path_name in glob.iglob(f"{EFI_SYS_MOUNT_POINT}/efi/nixos/*"): + path = Path(path_name) if path.name not in known_paths and not path.is_dir(): path.unlink() @@ -444,13 +565,37 @@ def install_bootloader(args: argparse.Namespace) -> None: for profile in get_profiles(): gens += get_generations(profile) - remove_old_entries(gens) + entries = scan_entries() + remove_old_entries(gens, entries) + # Compute the sort-key that will be sorted first. + sorted_first = "" + for gen in gens: + try: + bootspec = get_bootspec(gen.profile, gen.generation) + if bootspec.sortKey < sorted_first or sorted_first == "": + sorted_first = bootspec.sortKey + except OSError as e: + # See https://github.com/NixOS/nixpkgs/issues/114552 + if e.errno == errno.EINVAL: + profile = ( + f"profile '{gen.profile}'" if gen.profile else "default profile" + ) + print( + "ignoring {} in the list of boot entries because of the following error:\n{}".format( + profile, e + ), + file=sys.stderr, + ) + else: + raise e for gen in gens: try: bootspec = get_bootspec(gen.profile, gen.generation) is_default = Path(bootspec.init).parent == Path(args.default_config) - write_entry(*gen, machine_id, bootspec, current=is_default) + write_entry( + *gen, machine_id, bootspec, entries, sorted_first, current=is_default + ) for specialisation in bootspec.specialisations.keys(): write_entry( gen.profile, @@ -458,10 +603,16 @@ def install_bootloader(args: argparse.Namespace) -> None: specialisation, machine_id, bootspec, - current=is_default, + entries, + sorted_first, + current=( + is_default + and bootspec.specialisations[specialisation].sortKey + == bootspec.sortKey + ), ) if is_default: - write_loader_conf(*gen) + write_loader_conf(gen.profile) except OSError as e: # See https://github.com/NixOS/nixpkgs/issues/114552 if e.errno == errno.EINVAL: 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 28ba374272f3..f7f7daf8a331 100644 --- a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot.nix +++ b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot.nix @@ -97,6 +97,9 @@ let '') cfg.extraEntries )} ''; + + bootCountingTries = cfg.bootCounting.tries; + bootCounting = if cfg.bootCounting.enable then "True" else "False"; }; }; @@ -417,6 +420,15 @@ in ''; }; + bootCounting = { + enable = mkEnableOption "automatic boot assessment"; + tries = mkOption { + default = 3; + type = types.int; + description = "number of tries each entry should start with"; + }; + }; + rebootForBitlocker = mkOption { default = false; diff --git a/nixos/tests/systemd-boot.nix b/nixos/tests/systemd-boot.nix index 53f8db7a808c..3b205b20873d 100644 --- a/nixos/tests/systemd-boot.nix +++ b/nixos/tests/systemd-boot.nix @@ -14,6 +14,8 @@ let boot.loader.efi.canTouchEfiVariables = true; environment.systemPackages = [ pkgs.efibootmgr ]; system.switch.enable = true; + # Needed for machine-id to be persisted between reboots + environment.etc."machine-id".text = "00000000000000000000000000000000"; }; commonXbootldr = @@ -90,8 +92,111 @@ let # Set NIX_DISK_IMAGE so that the qemu script finds the right disk image. os.environ['NIX_DISK_IMAGE'] = tmp_disk_image.name ''; + # Check that we are booting the default entry and not the generation with largest version number + defaultEntry = + { + lib, + pkgs, + withBootCounting ? false, + ... + }: + runTest { + name = "systemd-boot-default-entry" + lib.optionalString withBootCounting "-with-boot-counting"; + meta.maintainers = with pkgs.lib.maintainers; [ julienmalka ]; + + nodes = { + machine = + { nodes, ... }: + { + imports = [ common ]; + system.extraDependencies = [ nodes.other_machine.system.build.toplevel ]; + boot.loader.systemd-boot.bootCounting.enable = withBootCounting; + }; + + other_machine = { + imports = [ common ]; + boot.loader.systemd-boot.bootCounting.enable = withBootCounting; + environment.systemPackages = [ pkgs.hello ]; + }; + }; + testScript = + { nodes, ... }: + let + orig = nodes.machine.system.build.toplevel; + other = nodes.other_machine.system.build.toplevel; + in + '' + orig = "${orig}" + other = "${other}" + + def check_current_system(system_path): + machine.succeed(f'test $(readlink -f /run/current-system) = "{system_path}"') + + check_current_system(orig) + + # Switch to other configuration + machine.succeed("nix-env -p /nix/var/nix/profiles/system --set ${other}") + machine.succeed(f"{other}/bin/switch-to-configuration boot") + # Rollback, default entry is now generation 1 + machine.succeed("nix-env -p /nix/var/nix/profiles/system --rollback") + machine.succeed(f"{orig}/bin/switch-to-configuration boot") + machine.shutdown() + machine.start() + machine.wait_for_unit("multi-user.target") + # Check that we booted generation 1 (default) + # even though generation 2 comes first in alphabetical order + check_current_system(orig) + ''; + }; + garbage-collect-entry = + { + withBootCounting ? false, + ... + }: + runTest ( + { lib, ... }: + { + name = + "systemd-boot-garbage-collect-entry" + lib.optionalString withBootCounting "-with-boot-counting"; + meta.maintainers = with lib.maintainers; [ julienmalka ]; + + nodes = { + inherit common; + machine = + { nodes, ... }: + { + imports = [ common ]; + + boot.loader.systemd-boot.bootCounting.enable = withBootCounting; + + # These are configs for different nodes, but we'll use them here in `machine` + system.extraDependencies = [ + nodes.common.system.build.toplevel + ]; + }; + }; + + testScript = + { nodes, ... }: + let + baseSystem = nodes.common.system.build.toplevel; + in + '' + machine.succeed("nix-env -p /nix/var/nix/profiles/system --set ${baseSystem}") + machine.succeed("nix-env -p /nix/var/nix/profiles/system --delete-generations 1") + # At this point generation 1 has already been marked as good so we reintroduce counters artificially + ${lib.optionalString withBootCounting '' + machine.succeed("mv /boot/loader/entries/nixos-generation-1.conf /boot/loader/entries/nixos-generation-1+3.conf") + ''} + machine.succeed("${baseSystem}/bin/switch-to-configuration boot") + machine.fail("test -e /boot/loader/entries/nixos-generation-1*") + machine.succeed("test -e /boot/loader/entries/nixos-generation-2.conf") + ''; + } + ); in { + inherit defaultEntry; basic = runTest ( { lib, ... }: { @@ -108,7 +213,10 @@ in machine.wait_for_unit("multi-user.target") machine.succeed("test -e /boot/loader/entries/nixos-generation-1.conf") - machine.succeed("grep 'sort-key nixos' /boot/loader/entries/nixos-generation-1.conf") + out = machine.succeed("cat /boot/loader/entries/nixos-generation-1.conf") + print(out) + # our sort-key will uses r to sort before nixos + machine.succeed("grep 'sort-key nixor-default' /boot/loader/entries/nixos-generation-1.conf") # Ensure we actually booted using systemd-boot # Magic number is the vendor UUID used by systemd-boot. @@ -224,33 +332,31 @@ in }; }; - testScript = - { nodes, ... }: - '' - machine.start() - machine.wait_for_unit("multi-user.target") + testScript = '' + machine.start() + machine.wait_for_unit("multi-user.target") - machine.succeed( - "test -e /boot/loader/entries/nixos-generation-1-specialisation-something.conf" - ) - machine.succeed( - "grep -q 'title NixOS (something)' /boot/loader/entries/nixos-generation-1-specialisation-something.conf" - ) - machine.succeed( - "grep 'sort-key something' /boot/loader/entries/nixos-generation-1-specialisation-something.conf" - ) - '' - + pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isAarch64 '' - machine.succeed( - r"grep 'devicetree /EFI/nixos/[a-z0-9]\{32\}.*dummy' /boot/loader/entries/nixos-generation-1-specialisation-something.conf" - ) - ''; + machine.succeed( + "test -e /boot/loader/entries/nixos-generation-1-specialisation-something.conf" + ) + machine.succeed( + "grep -q 'title NixOS (something)' /boot/loader/entries/nixos-generation-1-specialisation-something.conf" + ) + machine.succeed( + "grep 'sort-key something' /boot/loader/entries/nixos-generation-1-specialisation-something.conf" + ) + '' + + pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isAarch64 '' + machine.succeed( + r"grep 'devicetree /EFI/nixos/[a-z0-9]\{32\}.*dummy' /boot/loader/entries/nixos-generation-1-specialisation-something.conf" + ) + ''; } ); # Boot without having created an EFI entry--instead using default "/EFI/BOOT/BOOTX64.EFI" fallback = runTest ( - { pkgs, lib, ... }: + { lib, ... }: { name = "systemd-boot-fallback"; meta.maintainers = with lib.maintainers; [ @@ -259,7 +365,7 @@ in ]; nodes.machine = - { pkgs, lib, ... }: + { lib, ... }: { imports = [ common ]; boot.loader.efi.canTouchEfiVariables = lib.mkForce false; @@ -333,12 +439,10 @@ in name = "systemd-boot-memtest86"; meta.maintainers = with lib.maintainers; [ julienmalka ]; - nodes.machine = - { pkgs, lib, ... }: - { - imports = [ common ]; - boot.loader.systemd-boot.memtest86.enable = true; - }; + nodes.machine = { + imports = [ common ]; + boot.loader.systemd-boot.memtest86.enable = true; + }; testScript = '' machine.succeed("test -e /boot/loader/entries/memtest86.conf") @@ -353,12 +457,10 @@ in name = "systemd-boot-netbootxyz"; meta.maintainers = with lib.maintainers; [ julienmalka ]; - nodes.machine = - { pkgs, lib, ... }: - { - imports = [ common ]; - boot.loader.systemd-boot.netbootxyz.enable = true; - }; + nodes.machine = { + imports = [ common ]; + boot.loader.systemd-boot.netbootxyz.enable = true; + }; testScript = '' machine.succeed("test -e /boot/loader/entries/netbootxyz.conf") @@ -443,13 +545,11 @@ in name = "systemd-boot-memtest-sortkey"; meta.maintainers = with lib.maintainers; [ julienmalka ]; - nodes.machine = - { pkgs, lib, ... }: - { - imports = [ common ]; - boot.loader.systemd-boot.memtest86.enable = true; - boot.loader.systemd-boot.memtest86.sortKey = "apple"; - }; + nodes.machine = { + imports = [ common ]; + boot.loader.systemd-boot.memtest86.enable = true; + boot.loader.systemd-boot.memtest86.sortKey = "apple"; + }; testScript = '' machine.succeed("test -e /boot/loader/entries/memtest86.conf") @@ -466,12 +566,10 @@ in name = "systemd-boot-entry-filename-xbootldr"; meta.maintainers = with lib.maintainers; [ sdht0 ]; - nodes.machine = - { pkgs, lib, ... }: - { - imports = [ commonXbootldr ]; - boot.loader.systemd-boot.memtest86.enable = true; - }; + nodes.machine = { + imports = [ commonXbootldr ]; + boot.loader.systemd-boot.memtest86.enable = true; + }; testScript = { nodes, ... }: @@ -494,16 +592,14 @@ in name = "systemd-boot-extra-entries"; meta.maintainers = with lib.maintainers; [ julienmalka ]; - nodes.machine = - { pkgs, lib, ... }: - { - imports = [ common ]; - boot.loader.systemd-boot.extraEntries = { - "banana.conf" = '' - title banana - ''; - }; + nodes.machine = { + imports = [ common ]; + boot.loader.systemd-boot.extraEntries = { + "banana.conf" = '' + title banana + ''; }; + }; testScript = '' machine.succeed("test -e /boot/loader/entries/banana.conf") @@ -519,7 +615,7 @@ in meta.maintainers = with lib.maintainers; [ julienmalka ]; nodes.machine = - { pkgs, lib, ... }: + { pkgs, ... }: { imports = [ common ]; boot.loader.systemd-boot.extraFiles = { @@ -558,12 +654,10 @@ in ]; }; - with_netbootxyz = - { pkgs, ... }: - { - imports = [ common ]; - boot.loader.systemd-boot.netbootxyz.enable = true; - }; + with_netbootxyz = { + imports = [ common ]; + boot.loader.systemd-boot.netbootxyz.enable = true; + }; }; testScript = @@ -602,41 +696,6 @@ in } ); - garbage-collect-entry = runTest ( - { lib, ... }: - { - name = "systemd-boot-garbage-collect-entry"; - meta.maintainers = with lib.maintainers; [ julienmalka ]; - - nodes = { - inherit common; - machine = - { pkgs, nodes, ... }: - { - imports = [ common ]; - - # These are configs for different nodes, but we'll use them here in `machine` - system.extraDependencies = [ - nodes.common.system.build.toplevel - ]; - }; - }; - - testScript = - { nodes, ... }: - let - baseSystem = nodes.common.system.build.toplevel; - in - '' - machine.succeed("nix-env -p /nix/var/nix/profiles/system --set ${baseSystem}") - machine.succeed("nix-env -p /nix/var/nix/profiles/system --delete-generations 1") - machine.succeed("${baseSystem}/bin/switch-to-configuration boot") - machine.fail("test -e /boot/loader/entries/nixos-generation-1.conf") - machine.succeed("test -e /boot/loader/entries/nixos-generation-2.conf") - ''; - } - ); - no-bootspec = runTest ( { lib, ... }: { @@ -654,4 +713,99 @@ in ''; } ); + + bootCounting = + let + baseConfig = { + imports = [ common ]; + boot.loader.systemd-boot.bootCounting.enable = true; + boot.loader.systemd-boot.bootCounting.trials = 2; + }; + in + { pkgs, ... }: + runTest { + name = "systemd-boot-counting"; + meta.maintainers = with pkgs.lib.maintainers; [ julienmalka ]; + + nodes = { + machine = + { nodes, ... }: + { + imports = [ baseConfig ]; + system.extraDependencies = [ nodes.bad_machine.system.build.toplevel ]; + }; + + bad_machine = { + imports = [ baseConfig ]; + + systemd.services."failing" = { + script = "exit 1"; + requiredBy = [ "boot-complete.target" ]; + before = [ "boot-complete.target" ]; + serviceConfig.Type = "oneshot"; + }; + }; + }; + testScript = + { nodes, ... }: + let + orig = nodes.machine.system.build.toplevel; + bad = nodes.bad_machine.system.build.toplevel; + in + '' + orig = "${orig}" + bad = "${bad}" + + def check_current_system(system_path): + machine.succeed(f'test $(readlink -f /run/current-system) = "{system_path}"') + + # Ensure we booted using an entry with counters enabled + machine.succeed( + "test -e /sys/firmware/efi/efivars/LoaderBootCountPath-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f" + ) + + # systemd-bless-boot should have already removed the "+2" suffix from the boot entry + machine.wait_for_unit("systemd-bless-boot.service") + machine.succeed("test -e /boot/loader/entries/nixos-generation-1.conf") + check_current_system(orig) + + # Switch to bad configuration + machine.succeed("nix-env -p /nix/var/nix/profiles/system --set ${bad}") + machine.succeed(f"{bad}/bin/switch-to-configuration boot") + + # Ensure new bootloader entry has initialized counter + machine.succeed("test -e /boot/loader/entries/nixos-generation-1.conf") + machine.succeed("test -e /boot/loader/entries/nixos-generation-2+2.conf") + machine.shutdown() + + machine.start() + machine.wait_for_unit("multi-user.target") + check_current_system(bad) + machine.succeed("test -e /boot/loader/entries/nixos-generation-1.conf") + machine.succeed("test -e /boot/loader/entries/nixos-generation-2+1-1.conf") + machine.shutdown() + + machine.start() + machine.wait_for_unit("multi-user.target") + check_current_system(bad) + machine.succeed("test -e /boot/loader/entries/nixos-generation-1.conf") + machine.succeed("test -e /boot/loader/entries/nixos-generation-2+0-2.conf") + machine.shutdown() + + # Should boot back into original configuration + machine.start() + check_current_system(orig) + machine.wait_for_unit("multi-user.target") + machine.succeed("test -e /boot/loader/entries/nixos-generation-1.conf") + machine.succeed("test -e /boot/loader/entries/nixos-generation-2+0-2.conf") + machine.shutdown() + ''; + }; + defaultEntryWithBootCounting = + { lib, pkgs }: + defaultEntry { + inherit lib pkgs; + withBootCounting = true; + }; + garbageCollectEntryWithBootCounting = garbage-collect-entry { withBootCounting = true; }; }