diff --git a/nixos/doc/manual/release-notes/rl-2611.section.md b/nixos/doc/manual/release-notes/rl-2611.section.md index bb2058d6b4ac..98548f040816 100644 --- a/nixos/doc/manual/release-notes/rl-2611.section.md +++ b/nixos/doc/manual/release-notes/rl-2611.section.md @@ -26,4 +26,6 @@ +- `boot.loader.systemd-boot` gained support for [Automatic Boot Assessment](https://systemd.io/AUTOMATIC_BOOT_ASSESSMENT/) via the new [`boot.loader.systemd-boot.bootCounting`](#opt-boot.loader.systemd-boot.bootCounting.enable) options, allowing automatic detection of and recovery from bad NixOS generations. As part of this change, boot loader entries on the ESP/XBOOTLDR partition are now named `nixos-.conf` instead of `nixos-generation-.conf`; existing entries are migrated automatically on the next `nixos-rebuild boot`/`switch`. + - The `newuidmap` and `newgidmap` security wrappers are now installed with `cap_setuid`/`cap_setgid` file capabilities instead of the setuid-root bit, matching shadow's `--with-fcaps` install mode and other major distributions. Rootless containers (podman, docker-rootless, unprivileged user namespaces) are unaffected. The only behavioural change is that mapping host uid 0 via `/etc/subuid` (which NixOS never configures by default) additionally requires `cap_setfcap`; users who explicitly grant uid 0 in a subuid range can restore the previous behaviour with `security.wrappers.newuidmap.capabilities = lib.mkForce "cap_setuid,cap_setfcap+ep";`. 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 90241b92cd5f..801b598233ae 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 @@ -3,6 +3,8 @@ import argparse import ctypes import datetime import errno +import functools +import hashlib import os import re import shutil @@ -11,7 +13,7 @@ import sys import tempfile import warnings import json -from typing import NamedTuple, Any, Sequence +from typing import NamedTuple, Any, Protocol, Sequence from dataclasses import dataclass from pathlib import Path @@ -19,9 +21,11 @@ from pathlib import Path EFI_SYS_MOUNT_POINT = Path("@efiSysMountPoint@") BOOT_MOUNT_POINT = Path("@bootMountPoint@") LOADER_CONF = EFI_SYS_MOUNT_POINT / "loader/loader.conf" # Always stored on the ESP -NIXOS_DIR = Path("@nixosDir@".strip("/")) # Path relative to the XBOOTLDR or ESP mount point +NIXOS_DIR = Path( + "@nixosDir@".strip("/") +) # Path relative to the XBOOTLDR or ESP mount point TIMEOUT = "@timeout@" -EDITOR = "@editor@" == "1" # noqa: PLR0133 +EDITOR = "@editor@" == "1" # noqa: PLR0133 CONSOLE_MODE = "@consoleMode@" BOOTSPEC_TOOLS = "@bootspecTools@" DISTRO_NAME = "@distroName@" @@ -29,13 +33,16 @@ NIX = "@nix@" SYSTEMD = "@systemd@" CONFIGURATION_LIMIT = int("@configurationLimit@") REBOOT_FOR_BITLOCKER = bool("@rebootForBitlocker@") -CAN_TOUCH_EFI_VARIABLES = "@canTouchEfiVariables@" -GRACEFUL = "@graceful@" +CAN_TOUCH_EFI_VARIABLES = "@canTouchEfiVariables@" == "1" +GRACEFUL = "@graceful@" == "1" COPY_EXTRA_FILES = "@copyExtraFiles@" CHECK_MOUNTPOINTS = "@checkMountpoints@" STORE_DIR = "@storeDir@" +BOOT_COUNTING_TRIES = "@bootCountingTries@" +BOOT_COUNTING = "@bootCounting@" == "True" -@dataclass + +@dataclass(frozen=True) class BootSpec: init: Path initrd: Path @@ -50,12 +57,98 @@ class BootSpec: initrdSecrets: str | None = None # noqa: N815 -libc = ctypes.CDLL("libc.so.6") +class WriteBootFile(Protocol): + def write_boot_file(self, path: Path, *, critical: bool) -> None: ... -FILE = None | int -def run(cmd: Sequence[str | Path], stdout: FILE = None) -> subprocess.CompletedProcess[str]: - return subprocess.run(cmd, check=True, text=True, stdout=stdout) +@dataclass +class CopyWriter: + source: Path + + def write_boot_file(self, path: Path, *, critical: bool) -> None: + if path.exists(): + return + with tempfile.NamedTemporaryFile( + mode="wb", + dir=path.parent, + delete=False, + prefix=path.name, + suffix=".tmp", + ) as tmp: + with open(self.source, mode="rb") as source_file: + shutil.copyfileobj(source_file, tmp) + tmp.flush() + os.fsync(tmp.fileno()) + tmp.close() + os.rename(tmp.name, path) + + +@dataclass +class InitrdWithSecretsWriter: + source: Path + initrd_secrets: Path + generation: int + + def write_boot_file(self, path: Path, *, critical: bool) -> None: + # Secrets can change between rebuilds, so always rebuild from the + # pristine initrd into a temp file and rename into place. + with tempfile.NamedTemporaryFile( + mode="wb", + dir=path.parent, + delete=False, + prefix=path.name, + suffix=".tmp", + ) as tmp: + try: + with open(self.source, mode="rb") as source_file: + shutil.copyfileobj(source_file, tmp) + tmp.flush() + run([self.initrd_secrets, tmp.name]) + os.fsync(tmp.fileno()) + except subprocess.CalledProcessError: + os.unlink(tmp.name) + if critical: + print("failed to create initrd secrets!", file=sys.stderr) + sys.exit(1) + # Keep the entry bootable by leaving at least a pristine + # initrd in place. CopyWriter is a no-op if one already + # exists. + CopyWriter(source=self.source).write_boot_file(path, critical=False) + print( + "warning: failed to update initrd secrets for an older " + f"generation ({self.generation}). The previous secrets " + "in this initrd will continue to be used. To silence " + "this warning, restore the secret files to their " + "original locations or delete this generation.", + file=sys.stderr, + ) + return + except BaseException: + os.unlink(tmp.name) + raise + os.rename(tmp.name, path) + + +@dataclass +class ContentsWriter: + contents: bytes + + def write_boot_file(self, path: Path, *, critical: bool) -> None: + if path.exists(): + return + with tempfile.NamedTemporaryFile( + mode="wb", + dir=path.parent, + delete=False, + prefix=path.name, + suffix=".tmp", + ) as tmp: + tmp.write(self.contents) + tmp.flush() + os.fsync(tmp.fileno()) + tmp.close() + os.rename(tmp.name, path) + class SystemIdentifier(NamedTuple): profile: str | None @@ -63,51 +156,131 @@ class SystemIdentifier(NamedTuple): specialisation: str | None -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.') - shutil.copyfile(source, tmppath) - os.fsync(tmpfd) - shutil.move(tmppath, dest) +@dataclass +class BootFile: + path: Path + writer: WriteBootFile + + @staticmethod + def from_source(source: Path) -> "BootFile": + return BootFile( + path=boot_path(source), + writer=CopyWriter(source=source), + ) + + @staticmethod + def from_initrd( + generation: int, + source: Path, + initrd_secrets: Path | None, + ) -> "BootFile": + if initrd_secrets is None: + return BootFile.from_source(source) + else: + # We're trying to calculate a canonical path unique to + # this initrd and secret-appender. The boot_path is the + # canonical path for files that don't need modifications, + # so it serves as a perfect proxy for the unique + # information to combine for a combined unique path. The + # original paths themselves would have also been fine, but + # boot_path is more semantically representative, since + # it's the actual path whose uniqueness we're trying to + # ensure for other things. + combined = "\n".join( + [str(boot_path(source)), str(boot_path(initrd_secrets))] + ) + combined_hash = hashlib.sha256(combined.encode("utf-8")).hexdigest() + return BootFile( + path=NIXOS_DIR / f"{combined_hash}-initrd.efi", + writer=InitrdWithSecretsWriter( + source=source, + initrd_secrets=initrd_secrets, + generation=generation, + ), + ) + + @staticmethod + def from_entry(contents: bytes) -> tuple["BootFile", str]: + contents_hash = hashlib.sha256(contents).hexdigest() + path_prefix = f"nixos-{contents_hash}" + pat = re.compile(rf"{re.escape(path_prefix)}(\+[0-9]+(-[0-9]+)?)?\.conf") + path = None + for e in os.scandir(path=BOOT_MOUNT_POINT / "loader" / "entries"): + if pat.fullmatch(e.name) is None: + continue + # Ignore files whose content does not match the hash in their + # name so GC removes them and a fresh entry is written. + if hashlib.sha256(Path(e.path).read_bytes()).hexdigest() != contents_hash: + continue + path = Path("loader/entries") / e.name + break + if path is None: + counters = f"+{BOOT_COUNTING_TRIES}" if BOOT_COUNTING else "" + path = Path(f"loader/entries/{path_prefix}{counters}.conf") + return ( + BootFile( + path=path, + writer=ContentsWriter(contents=contents), + ), + f"{path_prefix}.conf", + ) + + +# This gets its own type alias to document that the order is very +# important. The order ensures that entry files are written after +# their respective kernel / initrd / etc. +type BootFileList = list[BootFile] + + +libc = ctypes.CDLL("libc.so.6") + +FILE = None | int + + +def run( + cmd: Sequence[str | Path], stdout: FILE = None +) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, check=True, text=True, stdout=stdout, stderr=sys.stderr) def generation_dir(profile: str | None, generation: int) -> Path: if profile: - return Path(f"/nix/var/nix/profiles/system-profiles/{profile}-{generation}-link") + return Path( + f"/nix/var/nix/profiles/system-profiles/{profile}-{generation}-link" + ) else: return Path(f"/nix/var/nix/profiles/system-{generation}-link") -def system_dir(profile: str | None, generation: int, specialisation: str | None) -> Path: + +def system_dir( + profile: str | None, generation: int, specialisation: str | None +) -> Path: d = generation_dir(profile, generation) if specialisation: return d / "specialisation" / specialisation else: 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(default_entry_id: str | None) -> None: tmp = LOADER_CONF.with_suffix(".tmp") - with tmp.open('x') as f: + with tmp.open("x") as f: f.write(f"timeout {TIMEOUT}\n") - f.write("default %s\n" % generation_conf_filename(profile, generation, specialisation)) + if default_entry_id is None: + # No generation matched the requested default config; fall back to + # the newest entry as determined by Boot Loader Spec sorting. + f.write("default nixos-*\n") + elif BOOT_COUNTING: + # `preferred` (systemd-boot >= 260) honours boot assessment, so a + # generation that exhausted its boot counter is skipped and we fall + # through to `default`. systemd-boot sorts entries with + # tries_left == 0 to the end of the list and resolves the `default` + # glob against that order, so `nixos-*` yields the newest entry that + # is not bad, or a bad one only if every nixos entry is bad. + f.write(f"preferred {default_entry_id}\n") + f.write("default nixos-*\n") + else: + f.write(f"default {default_entry_id}\n") if not EDITOR: f.write("editor 0\n") if REBOOT_FOR_BITLOCKER: @@ -127,7 +300,9 @@ def get_bootspec(profile: str | None, generation: int) -> BootSpec: try: bootspec_json = json.load(f) except ValueError as e: - print(f"error: Malformed Json: {e}, in {boot_json_path}", file=sys.stderr) + print( + f"error: Malformed Json: {e}, in {boot_json_path}", file=sys.stderr + ) sys.exit(1) else: boot_json_str = run( @@ -143,17 +318,18 @@ def get_bootspec(profile: str | None, generation: int) -> BootSpec: bootspec_json = json.loads(boot_json_str) return bootspec_from_json(bootspec_json) + def bootspec_from_json(bootspec_json: dict[str, Any]) -> BootSpec: - specialisations = bootspec_json['org.nixos.specialisation.v1'] + specialisations = bootspec_json["org.nixos.specialisation.v1"] specialisations = {k: bootspec_from_json(v) for k, v in specialisations.items()} - systemdBootExtension = bootspec_json.get('org.nixos.systemd-boot', {}) - sortKey = systemdBootExtension.get('sortKey', 'nixos') - devicetree = systemdBootExtension.get('devicetree') + systemdBootExtension = bootspec_json.get("org.nixos.systemd-boot", {}) + sortKey = systemdBootExtension.get("sortKey", "nixos") + devicetree = systemdBootExtension.get("devicetree") if devicetree: devicetree = Path(devicetree) - main_json = bootspec_json['org.nixos.bootspec.v1'] + main_json = bootspec_json["org.nixos.bootspec.v1"] for attr in ("kernel", "initrd", "toplevel"): if attr in main_json: main_json[attr] = Path(main_json[attr]) @@ -165,67 +341,58 @@ def bootspec_from_json(bootspec_json: dict[str, Any]) -> BootSpec: ) -def copy_from_file(file: Path, dry_run: bool = False) -> Path: - """ - Copy a file to the boot filesystem (XBOOTLDR if in use, otherwise ESP), basing the destination filename on the store path that's being copied from. Return the destination path, relative to the boot filesystem mountpoint. - """ +@functools.lru_cache(maxsize=None) +def boot_path(file: Path) -> Path: store_file_path = file.resolve() suffix = store_file_path.name store_subdir = store_file_path.relative_to(STORE_DIR).parts[0] - efi_file_path = NIXOS_DIR / (f"{suffix}.efi" if suffix == store_subdir else f"{store_subdir}-{suffix}.efi") - if not dry_run: - copy_if_not_exists(store_file_path, BOOT_MOUNT_POINT / efi_file_path) - return efi_file_path + return NIXOS_DIR / ( + f"{suffix}.efi" if suffix == store_subdir else f"{store_subdir}-{suffix}.efi" + ) -def write_entry(profile: str | None, generation: int, specialisation: str | None, - machine_id: str | None, bootspec: BootSpec, current: bool) -> None: +def boot_file( + profile: str | None, + generation: int, + specialisation: str | None, + machine_id: str | None, + bootspec: BootSpec, +) -> tuple[BootFileList, str]: if specialisation: bootspec = bootspec.specialisations[specialisation] - kernel = copy_from_file(bootspec.kernel) - initrd = copy_from_file(bootspec.initrd) - devicetree = copy_from_file(bootspec.devicetree) if bootspec.devicetree is not None else None + kernel = BootFile.from_source(bootspec.kernel) + initrd = BootFile.from_initrd( + generation, + bootspec.initrd, + Path(bootspec.initrdSecrets) if bootspec.initrdSecrets is not None else None, + ) + devicetree = None + if bootspec.devicetree is not None: + devicetree = BootFile.from_source(bootspec.devicetree) + + kernel_params = " ".join([f"init={bootspec.init}"] + bootspec.kernelParams) + build_time = int(system_dir(profile, generation, specialisation).stat().st_ctime) + build_date = datetime.datetime.fromtimestamp(build_time).strftime("%F") title = "{name}{profile}{specialisation}".format( name=DISTRO_NAME, profile=" [" + profile + "]" if profile else "", - specialisation=" (%s)" % specialisation if specialisation else "") - - try: - if bootspec.initrdSecrets is not None: - run([bootspec.initrdSecrets, BOOT_MOUNT_POINT / initrd]) - except subprocess.CalledProcessError: - if current: - print("failed to create initrd secrets!", file=sys.stderr) - sys.exit(1) - else: - print("warning: failed to create initrd secrets " - 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 = 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) + specialisation=" (%s)" % specialisation if specialisation else "", + ) + description = f"Generation {generation} {bootspec.label}, built on {build_date}" + boot_entry = [ + f"title {title}", + f"version {description}", + f"linux /{str(kernel.path)}", + f"initrd /{str(initrd.path)}", + f"options {kernel_params}", + f"machine-id {machine_id}" if machine_id is not None else None, + f"devicetree /{str(devicetree.path)}" if devicetree is not None else None, + f"sort-key {bootspec.sortKey}", + ] + contents = "\n".join(filter(None, boot_entry)) + entry, bootctl_id = BootFile.from_entry(contents.encode("utf-8")) + return (list(filter(None, [kernel, initrd, devicetree, entry])), bootctl_id) def get_generations(profile: str | None = None) -> list[SystemIdentifier]: @@ -245,43 +412,15 @@ def get_generations(profile: str | None = None) -> list[SystemIdentifier]: configurationLimit = CONFIGURATION_LIMIT configurations = [ SystemIdentifier( - profile=profile, - generation=int(line.split()[0]), - specialisation=None + profile=profile, generation=int(line.split()[0]), specialisation=None ) for line in gen_lines ] 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$") - known_paths = [] - for gen in gens: - bootspec = get_bootspec(gen.profile, gen.generation) - known_paths.append(copy_from_file(bootspec.kernel, True).name) - 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(): - if path.name not in known_paths and not path.is_dir(): - path.unlink() - - def cleanup_esp() -> None: - for path in (EFI_SYS_MOUNT_POINT / "loader/entries").glob("nixos*"): + for path in (EFI_SYS_MOUNT_POINT / "loader" / "entries").glob("nixos*"): path.unlink() nixos_dir = EFI_SYS_MOUNT_POINT / NIXOS_DIR if nixos_dir.is_dir(): @@ -291,12 +430,13 @@ def cleanup_esp() -> None: def get_profiles() -> list[str]: system_profiles = Path("/nix/var/nix/profiles/system-profiles/") if system_profiles.is_dir(): - return [x.name - for x in system_profiles.iterdir() - if not x.name.endswith("-link")] + return [ + x.name for x in system_profiles.iterdir() if not x.name.endswith("-link") + ] else: return [] + def install_bootloader(args: argparse.Namespace) -> None: try: with open("/etc/machine-id") as machine_file: @@ -307,7 +447,10 @@ def install_bootloader(args: argparse.Namespace) -> None: machine_id = None if os.getenv("NIXOS_INSTALL_GRUB") == "1": - warnings.warn("NIXOS_INSTALL_GRUB env var deprecated, use NIXOS_INSTALL_BOOTLOADER", DeprecationWarning) + warnings.warn( + "NIXOS_INSTALL_GRUB env var deprecated, use NIXOS_INSTALL_BOOTLOADER", + DeprecationWarning, + ) os.environ["NIXOS_INSTALL_BOOTLOADER"] = "1" # flags to pass to bootctl install/update @@ -316,10 +459,10 @@ def install_bootloader(args: argparse.Namespace) -> None: if BOOT_MOUNT_POINT != EFI_SYS_MOUNT_POINT: bootctl_flags.append(f"--boot-path={BOOT_MOUNT_POINT}") - if CAN_TOUCH_EFI_VARIABLES != "1": + if not CAN_TOUCH_EFI_VARIABLES: bootctl_flags.append("--no-variables") - if GRACEFUL == "1": + if GRACEFUL: bootctl_flags.append("--graceful") if os.getenv("NIXOS_INSTALL_BOOTLOADER") == "1": @@ -351,13 +494,18 @@ def install_bootloader(args: argparse.Namespace) -> None: # ESP: /boot (/dev/disk/by-partuuid/9b39b4c4-c48b-4ebf-bfea-a56b2395b7e0) # File: ├─/EFI/systemd/HashTool.efi # └─/EFI/systemd/systemd-bootx64.efi (systemd-boot 255.2) - installed_match = re.search(r"^\W+.*/EFI/(?:BOOT|systemd)/.*\.efi \(systemd-boot ([\d.]+[^)]*)\)$", - installed_out, re.IGNORECASE | re.MULTILINE) + installed_match = re.search( + r"^\W+.*/EFI/(?:BOOT|systemd)/.*\.efi \(systemd-boot ([\d.]+[^)]*)\)$", + installed_out, + re.IGNORECASE | re.MULTILINE, + ) available_match = re.search(r"^\((.*)\)$", available_out) if installed_match is None: - raise Exception("Could not find any previously installed systemd-boot. If you are switching to systemd-boot from a different bootloader, you need to run `nixos-rebuild switch --install-bootloader`") + raise Exception( + "Could not find any previously installed systemd-boot. If you are switching to systemd-boot from a different bootloader, you need to run `nixos-rebuild switch --install-bootloader`" + ) if available_match is None: raise Exception("could not determine systemd-boot version") @@ -366,7 +514,11 @@ def install_bootloader(args: argparse.Namespace) -> None: available_version = available_match.group(1) if installed_version < available_version: - print("updating systemd-boot from %s to %s" % (installed_version, available_version), file=sys.stderr) + print( + "updating systemd-boot from %s to %s" + % (installed_version, available_version), + file=sys.stderr, + ) run( [f"{SYSTEMD}/bin/bootctl", f"--esp-path={EFI_SYS_MOUNT_POINT}"] + bootctl_flags @@ -380,24 +532,45 @@ def install_bootloader(args: argparse.Namespace) -> None: for profile in get_profiles(): gens += get_generations(profile) - remove_old_entries(gens) + boot_files: BootFileList = [] + critical_paths: set[Path] = set() + + default_config = Path(args.default_config) + default_entry_id: str | None = None 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) - for specialisation in bootspec.specialisations.keys(): - write_entry(gen.profile, gen.generation, specialisation, machine_id, bootspec, current=is_default) + bootspec = get_bootspec(gen.profile, gen.generation) + is_default = Path(bootspec.init).parent == default_config + new_boot_files, new_bootctl_id = boot_file(*gen, machine_id, bootspec) + boot_files.extend(new_boot_files) + if is_default: + default_entry_id = new_bootctl_id + critical_paths.update(bf.path for bf in new_boot_files) + for specialisation_name, specialisation in bootspec.specialisations.items(): + is_default = Path(specialisation.init).parent == default_config + new_boot_files, new_bootctl_id = boot_file( + gen.profile, + gen.generation, + specialisation_name, + machine_id, + bootspec, + ) + boot_files.extend(new_boot_files) if is_default: - write_loader_conf(*gen) - 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 + default_entry_id = new_bootctl_id + critical_paths.update(bf.path for bf in new_boot_files) + + # Garbage-collect stale kernels/initrds/entries before re-populating extra + # files, so that user-supplied extraEntries (which may also live under + # loader/entries and start with `nixos-`) are not removed again. + garbage_collect(boot_files) + + write_boot_files(boot_files, critical_paths) + + write_loader_conf(default_entry_id) + + remove_extra_files() + run([COPY_EXTRA_FILES]) if BOOT_MOUNT_POINT != EFI_SYS_MOUNT_POINT: # Cleanup any entries in ESP if xbootldrMountPoint is set. @@ -405,6 +578,8 @@ def install_bootloader(args: argparse.Namespace) -> None: # automatically, as we don't have information about the mount point anymore. cleanup_esp() + +def remove_extra_files() -> None: extra_files_dir = BOOT_MOUNT_POINT / NIXOS_DIR / ".extra-files" for root, _, files in extra_files_dir.walk(top_down=False): relative_root = root.relative_to(extra_files_dir) @@ -421,12 +596,45 @@ def install_bootloader(args: argparse.Namespace) -> None: extra_files_dir.mkdir(parents=True, exist_ok=True) - run([COPY_EXTRA_FILES]) + +def garbage_collect(gc_roots: BootFileList) -> None: + keep = {BOOT_MOUNT_POINT / gc_root.path for gc_root in gc_roots} + + def delete_path(e: os.DirEntry) -> None: + if e.is_file(follow_symlinks=True) and Path(e.path) not in keep: + os.remove(e.path) + + for e in os.scandir(BOOT_MOUNT_POINT / NIXOS_DIR): + delete_path(e) + + for e in os.scandir(BOOT_MOUNT_POINT / "loader" / "entries"): + match = re.fullmatch(r"nixos-.+\.conf", e.name) + if match: + delete_path(e) + + +def write_boot_files(boot_files: BootFileList, critical_paths: set[Path]) -> None: + # Deduplicate by destination path so shared files are written once. + seen: set[Path] = set() + for boot_file in boot_files: + if boot_file.path in seen: + continue + seen.add(boot_file.path) + boot_file.writer.write_boot_file( + BOOT_MOUNT_POINT / boot_file.path, + critical=boot_file.path in critical_paths, + ) def main() -> None: - parser = argparse.ArgumentParser(description=f"Update {DISTRO_NAME}-related systemd-boot files") - parser.add_argument('default_config', metavar='DEFAULT-CONFIG', help=f"The default {DISTRO_NAME} config to boot") + parser = argparse.ArgumentParser( + description=f"Update {DISTRO_NAME}-related systemd-boot files" + ) + parser.add_argument( + "default_config", + metavar="DEFAULT-CONFIG", + help=f"The default {DISTRO_NAME} config to boot", + ) args = parser.parse_args() run([CHECK_MOUNTPOINTS]) @@ -440,13 +648,18 @@ def main() -> None: # event sync the efi filesystem after each update. rc = libc.syncfs(os.open(f"{BOOT_MOUNT_POINT}", os.O_RDONLY)) if rc != 0: - print(f"could not sync {BOOT_MOUNT_POINT}: {os.strerror(rc)}", file=sys.stderr) + print( + f"could not sync {BOOT_MOUNT_POINT}: {os.strerror(rc)}", file=sys.stderr + ) if BOOT_MOUNT_POINT != EFI_SYS_MOUNT_POINT: rc = libc.syncfs(os.open(EFI_SYS_MOUNT_POINT, os.O_RDONLY)) if rc != 0: - print(f"could not sync {EFI_SYS_MOUNT_POINT}: {os.strerror(rc)}", file=sys.stderr) + print( + f"could not sync {EFI_SYS_MOUNT_POINT}: {os.strerror(rc)}", + file=sys.stderr, + ) -if __name__ == '__main__': +if __name__ == "__main__": 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 28ba374272f3..4c3c28b11e90 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,26 @@ in ''; }; + bootCounting = { + enable = mkEnableOption '' + [Automatic Boot Assessment](https://systemd.io/AUTOMATIC_BOOT_ASSESSMENT/). + + New boot entries are written with a boot counter in the file name. On + each boot, systemd-boot decrements the counter; once the booted system + reaches `boot-complete.target`, `systemd-bless-boot.service` removes the + counter and marks the entry as good. An entry whose counter reaches zero + is considered bad and will be skipped in favour of an older generation + ''; + tries = mkOption { + default = 3; + type = types.ints.positive; + description = '' + Number of boot attempts a freshly written entry is given before it is + considered bad. + ''; + }; + }; + rebootForBitlocker = mkOption { default = false; diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index a4c63fcbc368..663f65595661 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1593,7 +1593,10 @@ in systemd = runTest ./systemd.nix; systemd-analyze = runTest ./systemd-analyze.nix; systemd-binfmt = handleTestOn [ "x86_64-linux" ] ./systemd-binfmt.nix { }; - systemd-boot = import ./systemd-boot.nix { inherit runTest runTestOn; }; + systemd-boot = import ./systemd-boot.nix { + inherit runTest runTestOn; + inherit (pkgs) lib; + }; systemd-bpf = runTest ./systemd-bpf.nix; systemd-capsules = runTest ./systemd-capsules.nix; systemd-confinement = handleTest ./systemd-confinement { }; diff --git a/nixos/tests/systemd-boot.nix b/nixos/tests/systemd-boot.nix index 53f8db7a808c..eb2384a11f03 100644 --- a/nixos/tests/systemd-boot.nix +++ b/nixos/tests/systemd-boot.nix @@ -1,10 +1,37 @@ { runTest, runTestOn, + lib, ... }: let + testScriptPreamble = + # python + '' + def check_current_system(system_path): + machine.succeed(f'test $(readlink -f /run/current-system) = "{system_path}"') + + def check_generation(generation: int, tries_left=0, tries_failed=0, specialisation=None) -> list[str]: + if specialisation: + title = f"NixOS ({specialisation})" + else: + title = "NixOS" + + conf_files = machine.succeed( + f"grep --files-with-matches 'version Generation {generation} NixOS' /boot/loader/entries/nixos-*.conf | xargs grep --line-regexp --fixed-strings --files-with-matches 'title {title}'" + ).split("\n") + + suffix = "" + if tries_left: + suffix += f"+{tries_left}" + if tries_failed: + suffix += f"-{tries_failed}" + + assert conf_files[0].endswith(f"{suffix}.conf"), f"Expected {conf_files[0]} to end with {suffix}.conf" + return conf_files + ''; + common = { pkgs, ... }: { @@ -14,6 +41,10 @@ let boot.loader.efi.canTouchEfiVariables = true; environment.systemPackages = [ pkgs.efibootmgr ]; system.switch.enable = true; + # Needed for machine-id to be persisted between reboots. + # Must be a valid (non-zero) ID, otherwise sd_id128_get_machine() + # returns -ENOMEDIUM and dbus-broker refuses to start. + environment.etc."machine-id".text = "1234567890abcdef1234567890abcdef\n"; }; commonXbootldr = @@ -68,30 +99,150 @@ let boot.loader.systemd-boot.xbootldrMountPoint = "/boot"; }; - customDiskImage = nodes: '' - import os - import subprocess - import tempfile + customDiskImage = + nodes: + # python + '' + import os + import subprocess + import tempfile - tmp_disk_image = tempfile.NamedTemporaryFile() + tmp_disk_image = tempfile.NamedTemporaryFile() - subprocess.run([ - "${nodes.machine.virtualisation.qemu.package}/bin/qemu-img", - "create", - "-f", - "qcow2", - "-b", - "${nodes.machine.system.build.diskImage}/nixos.qcow2", - "-F", - "qcow2", - tmp_disk_image.name, - ]) + subprocess.run([ + "${nodes.machine.virtualisation.qemu.package}/bin/qemu-img", + "create", + "-f", + "qcow2", + "-b", + "${nodes.machine.system.build.diskImage}/nixos.qcow2", + "-F", + "qcow2", + 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 - ''; + # 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 = + { + withBootCounting ? false, + ... + }: + runTest { + name = "systemd-boot-default-entry" + lib.optionalString withBootCounting "-with-boot-counting"; + meta.maintainers = with 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 = + { pkgs, ... }: + { + 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 + # python + '' + ${testScriptPreamble} + + orig = "${orig}" + other = "${other}" + + 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; + boot.loader.systemd-boot.memtest86.enable = true; + + # 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 + # python + '' + ${testScriptPreamble} + + 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") + + conf_file = check_generation(1)[0] + new_conf_file = conf_file.replace(".conf", "+1-3.conf") + + # At this point generation 1 has already been marked as good so we reintroduce counters artificially + ${lib.optionalString withBootCounting '' + machine.succeed(f"mv {conf_file} {new_conf_file}") + ''} + machine.succeed("${baseSystem}/bin/switch-to-configuration boot") + machine.fail( + "grep --files-with-matches 'version Generation 1 NixOS' /boot/loader/entries/nixos-*.conf" + ) + check_generation(2) + ''; + } + ); in { + defaultEntry = defaultEntry { }; + garbage-collect-entry = garbage-collect-entry { }; + basic = runTest ( { lib, ... }: { @@ -103,22 +254,26 @@ in nodes.machine = common; - testScript = '' - machine.start() - machine.wait_for_unit("multi-user.target") + testScript = # python + '' + ${testScriptPreamble} - machine.succeed("test -e /boot/loader/entries/nixos-generation-1.conf") - machine.succeed("grep 'sort-key nixos' /boot/loader/entries/nixos-generation-1.conf") + machine.start() + machine.wait_for_unit("multi-user.target") - # Ensure we actually booted using systemd-boot - # Magic number is the vendor UUID used by systemd-boot. - machine.succeed( - "test -e /sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f" - ) + conf_file = check_generation(1)[0] - # "bootctl install" should have created an EFI entry - machine.succeed('efibootmgr | grep "Linux Boot Manager"') - ''; + machine.succeed(f"grep 'sort-key nixos' {conf_file}") + + # Ensure we actually booted using systemd-boot + # Magic number is the vendor UUID used by systemd-boot. + machine.succeed( + "test -e /sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f" + ) + + # "bootctl install" should have created an EFI entry + machine.succeed('efibootmgr | grep "Linux Boot Manager"') + ''; } ); @@ -141,6 +296,7 @@ in let efiArch = pkgs.stdenv.hostPlatform.efiArch; in + #python '' machine.start(allow_reboot=True) machine.wait_for_unit("multi-user.target") @@ -169,14 +325,17 @@ in testScript = { nodes, ... }: + #python '' + ${testScriptPreamble} + ${customDiskImage nodes} machine.start() machine.wait_for_unit("multi-user.target") machine.succeed("test -e /efi/EFI/systemd/systemd-bootx64.efi") - machine.succeed("test -e /boot/loader/entries/nixos-generation-1.conf") + check_generation(1) # Ensure we actually booted using systemd-boot # Magic number is the vendor UUID used by systemd-boot. @@ -225,32 +384,33 @@ in }; testScript = - { nodes, ... }: + # python '' + ${testScriptPreamble} + machine.start() machine.wait_for_unit("multi-user.target") + conf_files = check_generation(1, specialisation="something") 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" + f"grep --fixed-strings --line-regexp 'sort-key something' {" ".join(conf_files)}" ) + + ${lib.optionalString pkgs.stdenv.hostPlatform.isAarch64 + #python + '' + machine.succeed( + fr"grep 'devicetree /EFI/nixos/[a-z0-9]\{32\}.*dummy' {" ".join(conf_files)}" + ) + '' + } ''; } ); # 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,27 +419,31 @@ in ]; nodes.machine = - { pkgs, lib, ... }: + { lib, ... }: { imports = [ common ]; boot.loader.efi.canTouchEfiVariables = lib.mkForce false; }; - testScript = '' - machine.start() - machine.wait_for_unit("multi-user.target") + testScript = + # python + '' + ${testScriptPreamble} - machine.succeed("test -e /boot/loader/entries/nixos-generation-1.conf") + machine.start() + machine.wait_for_unit("multi-user.target") - # Ensure we actually booted using systemd-boot - # Magic number is the vendor UUID used by systemd-boot. - machine.succeed( - "test -e /sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f" - ) + check_generation(1) - # "bootctl install" should _not_ have created an EFI entry - machine.fail('efibootmgr | grep "Linux Boot Manager"') - ''; + # Ensure we actually booted using systemd-boot + # Magic number is the vendor UUID used by systemd-boot. + machine.succeed( + "test -e /sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f" + ) + + # "bootctl install" should _not_ have created an EFI entry + machine.fail('efibootmgr | grep "Linux Boot Manager"') + ''; } ); @@ -295,35 +459,37 @@ in nodes.machine = common; - testScript = '' - machine.succeed("mount -o remount,rw /boot") + testScript = + # python + '' + machine.succeed("mount -o remount,rw /boot") - def switch(): - # Replace version inside sd-boot with something older. See magic[] string in systemd src/boot/efi/boot.c - machine.succeed( - """ - find /boot -iname '*boot*.efi' -print0 | \ - xargs -0 -I '{}' sed -i 's/#### LoaderInfo: systemd-boot .* ####/#### LoaderInfo: systemd-boot 000.0-1-notnixos ####/' '{}' - """ - ) - return machine.succeed("/run/current-system/bin/switch-to-configuration boot 2>&1") + def switch(): + # Replace version inside sd-boot with something older. See magic[] string in systemd src/boot/efi/boot.c + machine.succeed( + """ + find /boot -iname '*boot*.efi' -print0 | \ + xargs -0 -I '{}' sed -i 's/#### LoaderInfo: systemd-boot .* ####/#### LoaderInfo: systemd-boot 000.0-1-notnixos ####/' '{}' + """ + ) + return machine.succeed("/run/current-system/bin/switch-to-configuration boot 2>&1") - output = switch() - assert "updating systemd-boot from 000.0-1-notnixos to " in output, "Couldn't find systemd-boot update message" - assert 'to "/boot/EFI/systemd/systemd-bootx64.efi"' in output, "systemd-boot not copied to to /boot/EFI/systemd/systemd-bootx64.efi" - assert 'to "/boot/EFI/BOOT/BOOTX64.EFI"' in output, "systemd-boot not copied to to /boot/EFI/BOOT/BOOTX64.EFI" + output = switch() + assert "updating systemd-boot from 000.0-1-notnixos to " in output, "Couldn't find systemd-boot update message" + assert 'to "/boot/EFI/systemd/systemd-bootx64.efi"' in output, "systemd-boot not copied to to /boot/EFI/systemd/systemd-bootx64.efi" + assert 'to "/boot/EFI/BOOT/BOOTX64.EFI"' in output, "systemd-boot not copied to to /boot/EFI/BOOT/BOOTX64.EFI" - with subtest("Test that updating works with lowercase bootx64.efi"): - machine.succeed( - # Move to tmp file name first, otherwise mv complains the new location is the same - "mv /boot/EFI/BOOT/BOOTX64.EFI /boot/EFI/BOOT/bootx64.efi.new", - "mv /boot/EFI/BOOT/bootx64.efi.new /boot/EFI/BOOT/bootx64.efi", - ) - output = switch() - assert "updating systemd-boot from 000.0-1-notnixos to " in output, "Couldn't find systemd-boot update message" - assert 'to "/boot/EFI/systemd/systemd-bootx64.efi"' in output, "systemd-boot not copied to to /boot/EFI/systemd/systemd-bootx64.efi" - assert 'to "/boot/EFI/BOOT/BOOTX64.EFI"' in output, "systemd-boot not copied to to /boot/EFI/BOOT/BOOTX64.EFI" - ''; + with subtest("Test that updating works with lowercase bootx64.efi"): + machine.succeed( + # Move to tmp file name first, otherwise mv complains the new location is the same + "mv /boot/EFI/BOOT/BOOTX64.EFI /boot/EFI/BOOT/bootx64.efi.new", + "mv /boot/EFI/BOOT/bootx64.efi.new /boot/EFI/BOOT/bootx64.efi", + ) + output = switch() + assert "updating systemd-boot from 000.0-1-notnixos to " in output, "Couldn't find systemd-boot update message" + assert 'to "/boot/EFI/systemd/systemd-bootx64.efi"' in output, "systemd-boot not copied to to /boot/EFI/systemd/systemd-bootx64.efi" + assert 'to "/boot/EFI/BOOT/BOOTX64.EFI"' in output, "systemd-boot not copied to to /boot/EFI/BOOT/BOOTX64.EFI" + ''; } ); @@ -333,17 +499,17 @@ 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") - machine.succeed("test -e /boot/efi/memtest86/memtest.efi") - ''; + testScript = + # python + '' + machine.succeed("test -e /boot/loader/entries/memtest86.conf") + machine.succeed("test -e /boot/efi/memtest86/memtest.efi") + ''; } ); @@ -353,17 +519,17 @@ 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") - machine.succeed("test -e /boot/efi/netbootxyz/netboot.xyz.efi") - ''; + testScript = + # python + '' + machine.succeed("test -e /boot/loader/entries/netbootxyz.conf") + machine.succeed("test -e /boot/efi/netbootxyz/netboot.xyz.efi") + ''; } ); @@ -380,10 +546,12 @@ in boot.loader.systemd-boot.edk2-uefi-shell.enable = true; }; - testScript = '' - machine.succeed("test -e /boot/loader/entries/edk2-uefi-shell.conf") - machine.succeed("test -e /boot/efi/edk2-uefi-shell/shell.efi") - ''; + testScript = + # python + '' + machine.succeed("test -e /boot/loader/entries/edk2-uefi-shell.conf") + machine.succeed("test -e /boot/efi/edk2-uefi-shell/shell.efi") + ''; } ); @@ -411,29 +579,31 @@ in }; }; - testScript = '' - machine.succeed("test -e /boot/efi/edk2-uefi-shell/shell.efi") + testScript = + # python + '' + machine.succeed("test -e /boot/efi/edk2-uefi-shell/shell.efi") - machine.succeed("test -e /boot/loader/entries/windows_7.conf") - machine.succeed("test -e /boot/loader/entries/windows_Ten.conf") - machine.succeed("test -e /boot/loader/entries/windows_11.conf") + machine.succeed("test -e /boot/loader/entries/windows_7.conf") + machine.succeed("test -e /boot/loader/entries/windows_Ten.conf") + machine.succeed("test -e /boot/loader/entries/windows_11.conf") - machine.succeed("grep 'efi /efi/edk2-uefi-shell/shell.efi' /boot/loader/entries/windows_7.conf") - machine.succeed("grep 'efi /efi/edk2-uefi-shell/shell.efi' /boot/loader/entries/windows_Ten.conf") - machine.succeed("grep 'efi /efi/edk2-uefi-shell/shell.efi' /boot/loader/entries/windows_11.conf") + machine.succeed("grep 'efi /efi/edk2-uefi-shell/shell.efi' /boot/loader/entries/windows_7.conf") + machine.succeed("grep 'efi /efi/edk2-uefi-shell/shell.efi' /boot/loader/entries/windows_Ten.conf") + machine.succeed("grep 'efi /efi/edk2-uefi-shell/shell.efi' /boot/loader/entries/windows_11.conf") - machine.succeed("grep 'HD0c1:EFI\\\\Microsoft\\\\Boot\\\\Bootmgfw.efi' /boot/loader/entries/windows_7.conf") - machine.succeed("grep 'FS0:EFI\\\\Microsoft\\\\Boot\\\\Bootmgfw.efi' /boot/loader/entries/windows_Ten.conf") - machine.succeed("grep 'HD0d4:EFI\\\\Microsoft\\\\Boot\\\\Bootmgfw.efi' /boot/loader/entries/windows_11.conf") + machine.succeed("grep 'HD0c1:EFI\\\\Microsoft\\\\Boot\\\\Bootmgfw.efi' /boot/loader/entries/windows_7.conf") + machine.succeed("grep 'FS0:EFI\\\\Microsoft\\\\Boot\\\\Bootmgfw.efi' /boot/loader/entries/windows_Ten.conf") + machine.succeed("grep 'HD0d4:EFI\\\\Microsoft\\\\Boot\\\\Bootmgfw.efi' /boot/loader/entries/windows_11.conf") - machine.succeed("grep 'sort-key before_all_others' /boot/loader/entries/windows_7.conf") - machine.succeed("grep 'sort-key o_windows_Ten' /boot/loader/entries/windows_Ten.conf") - machine.succeed("grep 'sort-key zzz' /boot/loader/entries/windows_11.conf") + machine.succeed("grep 'sort-key before_all_others' /boot/loader/entries/windows_7.conf") + machine.succeed("grep 'sort-key o_windows_Ten' /boot/loader/entries/windows_Ten.conf") + machine.succeed("grep 'sort-key zzz' /boot/loader/entries/windows_11.conf") - machine.succeed("grep 'title Windows 7' /boot/loader/entries/windows_7.conf") - machine.succeed("grep 'title Windows Ten' /boot/loader/entries/windows_Ten.conf") - machine.succeed('grep "title Title with-_-punctuation ...?!" /boot/loader/entries/windows_11.conf') - ''; + machine.succeed("grep 'title Windows 7' /boot/loader/entries/windows_7.conf") + machine.succeed("grep 'title Windows Ten' /boot/loader/entries/windows_Ten.conf") + machine.succeed('grep "title Title with-_-punctuation ...?!" /boot/loader/entries/windows_11.conf') + ''; } ); @@ -443,19 +613,19 @@ 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") - machine.succeed("test -e /boot/efi/memtest86/memtest.efi") - machine.succeed("grep 'sort-key apple' /boot/loader/entries/memtest86.conf") - ''; + testScript = + # python + '' + machine.succeed("test -e /boot/loader/entries/memtest86.conf") + machine.succeed("test -e /boot/efi/memtest86/memtest.efi") + machine.succeed("grep 'sort-key apple' /boot/loader/entries/memtest86.conf") + ''; } ); @@ -466,15 +636,14 @@ 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, ... }: + # python '' ${customDiskImage nodes} @@ -494,21 +663,21 @@ 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") - machine.succeed("test -e /boot/efi/nixos/.extra-files/loader/entries/banana.conf") - ''; + testScript = + # python + '' + machine.succeed("test -e /boot/loader/entries/banana.conf") + machine.succeed("test -e /boot/efi/nixos/.extra-files/loader/entries/banana.conf") + ''; } ); @@ -519,7 +688,7 @@ in meta.maintainers = with lib.maintainers; [ julienmalka ]; nodes.machine = - { pkgs, lib, ... }: + { pkgs, ... }: { imports = [ common ]; boot.loader.systemd-boot.extraFiles = { @@ -527,10 +696,12 @@ in }; }; - testScript = '' - machine.succeed("test -e /boot/efi/fruits/tomato.efi") - machine.succeed("test -e /boot/efi/nixos/.extra-files/efi/fruits/tomato.efi") - ''; + testScript = + # python + '' + machine.succeed("test -e /boot/efi/fruits/tomato.efi") + machine.succeed("test -e /boot/efi/nixos/.extra-files/efi/fruits/tomato.efi") + ''; } ); @@ -558,12 +729,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 = @@ -573,6 +742,7 @@ in baseSystem = nodes.common.system.build.toplevel; finalSystem = nodes.with_netbootxyz.system.build.toplevel; in + # python '' machine.succeed("test -e /boot/efi/fruits/tomato.efi") machine.succeed("test -e /boot/efi/nixos/.extra-files/efi/fruits/tomato.efi") @@ -602,41 +772,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, ... }: { @@ -648,10 +783,182 @@ in boot.bootspec.enable = false; }; - testScript = '' - machine.start() - machine.wait_for_unit("multi-user.target") - ''; + testScript = + # python + '' + machine.start() + machine.wait_for_unit("multi-user.target") + ''; } ); + + bootCounting = + let + baseConfig = { + imports = [ common ]; + + boot.loader.systemd-boot.bootCounting = { + enable = true; + tries = 2; + }; + }; + in + runTest { + name = "systemd-boot-counting"; + meta.maintainers = with lib.maintainers; [ julienmalka ]; + + nodes = { + machine = + { nodes, ... }: + { + imports = [ baseConfig ]; + system.extraDependencies = [ + nodes.bad_machine.system.build.toplevel + nodes.unused_machine.system.build.toplevel + ]; + }; + + unused_machine = + { pkgs, ... }: + { + imports = [ baseConfig ]; + # Distinguish this closure from `machine` without pulling in extra deps. + environment.systemPackages = [ pkgs.hello ]; + }; + + 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; + unused = nodes.unused_machine.system.build.toplevel; + in + # python + '' + ${testScriptPreamble} + + orig = "${orig}" + bad = "${bad}" + unused = "${unused}" + + machine.start(allow_reboot=True) + + # 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") + conf_file = check_generation(1) + check_current_system(orig) + + print(machine.succeed("cat /boot/loader/entries/*.conf")) + + # Register the bad configuration as generation 2 and another good + # configuration as generation 3, then make generation 2 the default. + # This verifies that `preferred` in loader.conf selects gen 2 even + # though gen 3 sorts higher, and that once gen 2 is marked bad we + # fall back to the newest non-bad entry (gen 3). + machine.succeed("nix-env -p /nix/var/nix/profiles/system --set ${bad}") + machine.succeed("nix-env -p /nix/var/nix/profiles/system --set ${unused}") + machine.succeed(f"{bad}/bin/switch-to-configuration boot") + + # Ensure new bootloader entries have initialized counters + check_generation(1) + check_generation(2, 2) + check_generation(3, 2) + + machine.reboot() + + machine.wait_for_unit("multi-user.target") + check_current_system(bad) + check_generation(1) + check_generation(2, 1, 1) + check_generation(3, 2) + + machine.reboot() + + machine.wait_for_unit("multi-user.target") + check_current_system(bad) + check_generation(1) + check_generation(2, 0, 2) + check_generation(3, 2) + + machine.reboot() + + machine.wait_for_unit("multi-user.target") + # Gen 2 has exhausted its tries; `preferred` skips it and `default + # nixos-*` resolves to the newest non-bad entry, which is gen 3. + check_current_system(unused) + machine.wait_for_unit("systemd-bless-boot.service") + check_generation(2, 0, 2) + check_generation(3) + ''; + }; + + bootCountingSpecialisation = + let + baseConfig = { + imports = [ common ]; + boot.loader.systemd-boot.bootCounting = { + enable = true; + tries = 2; + }; + }; + + specialisationName = "+something+-+that+-+breaks-parsing+-+"; + in + runTest { + name = "systemd-boot-counting-specialisation"; + meta.maintainers = with lib.maintainers; [ julienmalka ]; + + nodes = { + machine = + { nodes, lib, ... }: + { + imports = [ baseConfig ]; + specialisation.${specialisationName}.configuration = { + boot.loader.systemd-boot.sortKey = "something"; + }; + }; + }; + testScript = + { nodes, ... }: + let + orig = nodes.machine.system.build.toplevel; + in + # python + '' + ${testScriptPreamble} + + orig = "${orig}" + + # Ensure we booted using an entry with counters enabled + machine.succeed( + "test -e /sys/firmware/efi/efivars/LoaderBootCountPath-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f" + ) + + check_generation(1) + check_current_system(orig) + + # Ensure the bootloader entry for the specialisation has initialized the boot counter + check_generation(1, 2, specialisation="${specialisationName}") + ''; + }; + + defaultEntryWithBootCounting = defaultEntry { withBootCounting = true; }; + + garbageCollectEntryWithBootCounting = garbage-collect-entry { withBootCounting = true; }; }