Commit b470b443 authored by Siddhartha's avatar Siddhartha
Browse files

nixos/systemd-boot: Add support for an XBOOTLDR partition

parent 04beb0ae
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -279,6 +279,10 @@ The pre-existing [services.ankisyncd](#opt-services.ankisyncd.enable) has been m

- Cinnamon has been updated to 6.0. Please beware that the [Wayland session](https://blog.linuxmint.com/?p=4591) is still experimental in this release.

- New `boot.loader.systemd-boot.xbootldrMountPoint` allows setting up a separate [XBOOTLDR partition](https://uapi-group.org/specifications/specs/boot_loader_specification/) to store boot files. Useful on systems with a small EFI System partition that cannot be easily repartitioned.

- `boot.loader.systemd-boot` will now verify that `efiSysMountPoint` (and `xbootldrMountPoint` if configured) are mounted partitions.

- `services.postgresql.extraPlugins` changed its type from just a list of packages to also a function that returns such a list.
  For example a config line like ``services.postgresql.extraPlugins = with pkgs.postgresql_11.pkgs; [ postgis ];`` is recommended to be changed to ``services.postgresql.extraPlugins = ps: with ps; [ postgis ];``;

+49 −20
Original line number Diff line number Diff line
@@ -17,6 +17,9 @@ from dataclasses import dataclass

# These values will be replaced with actual values during the package build
EFI_SYS_MOUNT_POINT = "@efiSysMountPoint@"
BOOT_MOUNT_POINT = "@bootMountPoint@"
LOADER_CONF = f"{EFI_SYS_MOUNT_POINT}/loader/loader.conf"  # Always stored on the ESP
NIXOS_DIR = "@nixosDir@"
TIMEOUT = "@timeout@"
EDITOR = "@editor@" == "1"
CONSOLE_MODE = "@consoleMode@"
@@ -28,6 +31,7 @@ CONFIGURATION_LIMIT = int("@configurationLimit@")
CAN_TOUCH_EFI_VARIABLES = "@canTouchEfiVariables@"
GRACEFUL = "@graceful@"
COPY_EXTRA_FILES = "@copyExtraFiles@"
CHECK_MOUNTPOINTS = "@checkMountpoints@"

@dataclass
class BootSpec:
@@ -87,7 +91,7 @@ def generation_conf_filename(profile: str | None, generation: int, specialisatio


def write_loader_conf(profile: str | None, generation: int, specialisation: str | None) -> None:
    with open(f"{EFI_SYS_MOUNT_POINT}/loader/loader.conf.tmp", 'w') as f:
    with open(f"{LOADER_CONF}.tmp", 'w') as f:
        if TIMEOUT != "":
            f.write(f"timeout {TIMEOUT}\n")
        f.write("default %s\n" % generation_conf_filename(profile, generation, specialisation))
@@ -96,7 +100,7 @@ def write_loader_conf(profile: str | None, generation: int, specialisation: str
        f.write(f"console-mode {CONSOLE_MODE}\n")
        f.flush()
        os.fsync(f.fileno())
    os.rename(f"{EFI_SYS_MOUNT_POINT}/loader/loader.conf.tmp", f"{EFI_SYS_MOUNT_POINT}/loader/loader.conf")
    os.rename(f"{LOADER_CONF}.tmp", LOADER_CONF)


def get_bootspec(profile: str | None, generation: int) -> BootSpec:
@@ -126,9 +130,9 @@ def copy_from_file(file: str, dry_run: bool = False) -> str:
    store_file_path = os.path.realpath(file)
    suffix = os.path.basename(store_file_path)
    store_dir = os.path.basename(os.path.dirname(store_file_path))
    efi_file_path = "/efi/nixos/%s-%s.efi" % (store_dir, suffix)
    efi_file_path = f"{NIXOS_DIR}/{store_dir}-{suffix}.efi"
    if not dry_run:
        copy_if_not_exists(store_file_path, f"{EFI_SYS_MOUNT_POINT}%s" % (efi_file_path))
        copy_if_not_exists(store_file_path, f"{BOOT_MOUNT_POINT}{efi_file_path}")
    return efi_file_path

def write_entry(profile: str | None, generation: int, specialisation: str | None,
@@ -145,7 +149,7 @@ def write_entry(profile: str | None, generation: int, specialisation: str | None

    try:
        if bootspec.initrdSecrets is not None:
            subprocess.check_call([bootspec.initrdSecrets, f"{EFI_SYS_MOUNT_POINT}%s" % (initrd)])
            subprocess.check_call([bootspec.initrdSecrets, f"{BOOT_MOUNT_POINT}%s" % (initrd)])
    except subprocess.CalledProcessError:
        if current:
            print("failed to create initrd secrets!", file=sys.stderr)
@@ -155,7 +159,7 @@ def write_entry(profile: str | None, generation: int, specialisation: str | None
                  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 = f"{EFI_SYS_MOUNT_POINT}/loader/entries/%s" % (
    entry_file = f"{BOOT_MOUNT_POINT}/loader/entries/%s" % (
        generation_conf_filename(profile, generation, specialisation))
    tmp_path = "%s.tmp" % (entry_file)
    kernel_params = "init=%s " % bootspec.init
@@ -202,14 +206,14 @@ def get_generations(profile: str | None = None) -> list[SystemIdentifier]:


def remove_old_entries(gens: list[SystemIdentifier]) -> None:
    rex_profile = re.compile(r"^" + re.escape(EFI_SYS_MOUNT_POINT) + "/loader/entries/nixos-(.*)-generation-.*\.conf$")
    rex_generation = re.compile(r"^" + re.escape(EFI_SYS_MOUNT_POINT) + "/loader/entries/nixos.*-generation-([0-9]+)(-specialisation-.*)?\.conf$")
    rex_profile = re.compile(r"^" + re.escape(BOOT_MOUNT_POINT) + "/loader/entries/nixos-(.*)-generation-.*\.conf$")
    rex_generation = re.compile(r"^" + re.escape(BOOT_MOUNT_POINT) + "/loader/entries/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))
        known_paths.append(copy_from_file(bootspec.initrd, True))
    for path in glob.iglob(f"{EFI_SYS_MOUNT_POINT}/loader/entries/nixos*-generation-[1-9]*.conf"):
    for path in glob.iglob(f"{BOOT_MOUNT_POINT}/loader/entries/nixos*-generation-[1-9]*.conf"):
        if rex_profile.match(path):
            prof = rex_profile.sub(r"\1", path)
        else:
@@ -220,11 +224,18 @@ 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(f"{EFI_SYS_MOUNT_POINT}/efi/nixos/*"):
    for path in glob.iglob(f"{BOOT_MOUNT_POINT}/{NIXOS_DIR}/*"):
        if not path in known_paths and not os.path.isdir(path):
            os.unlink(path)


def cleanup_esp() -> None:
    for path in glob.iglob(f"{EFI_SYS_MOUNT_POINT}/loader/entries/nixos*"):
        os.unlink(path)
    if os.path.isdir(f"{EFI_SYS_MOUNT_POINT}/{NIXOS_DIR}"):
        shutil.rmtree(f"{EFI_SYS_MOUNT_POINT}/{NIXOS_DIR}")


def get_profiles() -> list[str]:
    if os.path.isdir("/nix/var/nix/profiles/system-profiles/"):
        return [x
@@ -255,6 +266,9 @@ def install_bootloader(args: argparse.Namespace) -> None:
    # flags to pass to bootctl install/update
    bootctl_flags = []

    if BOOT_MOUNT_POINT != EFI_SYS_MOUNT_POINT:
        bootctl_flags.append(f"--boot-path={BOOT_MOUNT_POINT}")

    if CAN_TOUCH_EFI_VARIABLES != "1":
        bootctl_flags.append("--no-variables")

@@ -263,8 +277,8 @@ def install_bootloader(args: argparse.Namespace) -> None:

    if os.getenv("NIXOS_INSTALL_BOOTLOADER") == "1":
        # bootctl uses fopen() with modes "wxe" and fails if the file exists.
        if os.path.exists(f"{EFI_SYS_MOUNT_POINT}/loader/loader.conf"):
            os.unlink(f"{EFI_SYS_MOUNT_POINT}/loader/loader.conf")
        if os.path.exists(LOADER_CONF):
            os.unlink(LOADER_CONF)

        subprocess.check_call([f"{SYSTEMD}/bin/bootctl", f"--esp-path={EFI_SYS_MOUNT_POINT}"] + bootctl_flags + ["install"])
    else:
@@ -291,13 +305,15 @@ def install_bootloader(args: argparse.Namespace) -> None:
            print("updating systemd-boot from %s to %s" % (installed_version, available_version))
            subprocess.check_call([f"{SYSTEMD}/bin/bootctl", f"--esp-path={EFI_SYS_MOUNT_POINT}"] + bootctl_flags + ["update"])

    os.makedirs(f"{EFI_SYS_MOUNT_POINT}/efi/nixos", exist_ok=True)
    os.makedirs(f"{EFI_SYS_MOUNT_POINT}/loader/entries", exist_ok=True)
    os.makedirs(f"{BOOT_MOUNT_POINT}/{NIXOS_DIR}", exist_ok=True)
    os.makedirs(f"{BOOT_MOUNT_POINT}/loader/entries", exist_ok=True)

    gens = get_generations()
    for profile in get_profiles():
        gens += get_generations(profile)

    remove_old_entries(gens)

    for gen in gens:
        try:
            bootspec = get_bootspec(gen.profile, gen.generation)
@@ -315,9 +331,15 @@ def install_bootloader(args: argparse.Namespace) -> None:
            else:
                raise e

    for root, _, files in os.walk(f"{EFI_SYS_MOUNT_POINT}/efi/nixos/.extra-files", topdown=False):
        relative_root = root.removeprefix(f"{EFI_SYS_MOUNT_POINT}/efi/nixos/.extra-files").removeprefix("/")
        actual_root = os.path.join(f"{EFI_SYS_MOUNT_POINT}", relative_root)
    if BOOT_MOUNT_POINT != EFI_SYS_MOUNT_POINT:
        # Cleanup any entries in ESP if xbootldrMountPoint is set.
        # If the user later unsets xbootldrMountPoint, entries in XBOOTLDR will not be cleaned up
        # automatically, as we don't have information about the mount point anymore.
        cleanup_esp()

    for root, _, files in os.walk(f"{BOOT_MOUNT_POINT}/{NIXOS_DIR}/.extra-files", topdown=False):
        relative_root = root.removeprefix(f"{BOOT_MOUNT_POINT}/{NIXOS_DIR}/.extra-files").removeprefix("/")
        actual_root = os.path.join(f"{BOOT_MOUNT_POINT}", relative_root)

        for file in files:
            actual_file = os.path.join(actual_root, file)
@@ -330,7 +352,7 @@ def install_bootloader(args: argparse.Namespace) -> None:
            os.rmdir(actual_root)
        os.rmdir(root)

    os.makedirs(f"{EFI_SYS_MOUNT_POINT}/efi/nixos/.extra-files", exist_ok=True)
    os.makedirs(f"{BOOT_MOUNT_POINT}/{NIXOS_DIR}/.extra-files", exist_ok=True)

    subprocess.check_call(COPY_EXTRA_FILES)

@@ -340,6 +362,8 @@ def main() -> None:
    parser.add_argument('default_config', metavar='DEFAULT-CONFIG', help=f"The default {DISTRO_NAME} config to boot")
    args = parser.parse_args()

    subprocess.check_call(CHECK_MOUNTPOINTS)

    try:
        install_bootloader(args)
    finally:
@@ -347,7 +371,12 @@ 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(f"{EFI_SYS_MOUNT_POINT}", os.O_RDONLY))
        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)

        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)

+50 −9
Original line number Diff line number Diff line
@@ -7,7 +7,7 @@ let

  efi = config.boot.loader.efi;

  systemdBootBuilder = pkgs.substituteAll {
  systemdBootBuilder = pkgs.substituteAll rec {
    src = ./systemd-boot-builder.py;

    isExecutable = true;
@@ -28,23 +28,40 @@ let

    inherit (efi) efiSysMountPoint canTouchEfiVariables;

    bootMountPoint = if cfg.xbootldrMountPoint != null
      then cfg.xbootldrMountPoint
      else efi.efiSysMountPoint;

    nixosDir = "/EFI/nixos";

    inherit (config.system.nixos) distroName;

    memtest86 = optionalString cfg.memtest86.enable pkgs.memtest86plus;

    netbootxyz = optionalString cfg.netbootxyz.enable pkgs.netbootxyz-efi;

    checkMountpoints = pkgs.writeShellScript "check-mountpoints" ''
      fail() {
        echo "$1 = '$2' is not a mounted partition. Is the path configured correctly?" >&2
        exit 1
      }
      ${pkgs.util-linuxMinimal}/bin/findmnt ${efiSysMountPoint} > /dev/null || fail efiSysMountPoint ${efiSysMountPoint}
      ${lib.optionalString
        (cfg.xbootldrMountPoint != null)
        "${pkgs.util-linuxMinimal}/bin/findmnt ${cfg.xbootldrMountPoint} > /dev/null || fail xbootldrMountPoint ${cfg.xbootldrMountPoint}"}
    '';

    copyExtraFiles = pkgs.writeShellScript "copy-extra-files" ''
      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}" "${bootMountPoint}/"${escapeShellArg n}
        ${pkgs.coreutils}/bin/install -D $empty_file "${bootMountPoint}/${nixosDir}/.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}" "${bootMountPoint}/loader/entries/"${escapeShellArg n}
        ${pkgs.coreutils}/bin/install -D $empty_file "${bootMountPoint}/${nixosDir}/.extra-files/loader/entries/"${escapeShellArg n}
      '') cfg.extraEntries)}
    '';
  };
@@ -99,6 +116,18 @@ in {
      '';
    };

    xbootldrMountPoint = mkOption {
      default = null;
      type = types.nullOr types.str;
      description = lib.mdDoc ''
        Where the XBOOTLDR partition is mounted.

        If set, this partition will be used as $BOOT to store boot loader entries and extra files
        instead of the EFI partition. As per the bootloader specification, it is recommended that
        the EFI and XBOOTLDR partitions be mounted at `/efi` and `/boot`, respectively.
      '';
    };

    configurationLimit = mkOption {
      default = null;
      example = 120;
@@ -108,7 +137,7 @@ in {
        Useful to prevent boot partition running out of disk space.

        `null` means no limit i.e. all generations
        that were not garbage collected yet.
        that have not been garbage collected yet.
      '';
    };

@@ -200,7 +229,7 @@ in {
      '';
      description = lib.mdDoc ''
        Any additional entries you want added to the `systemd-boot` menu.
        These entries will be copied to {file}`/boot/loader/entries`.
        These entries will be copied to {file}`$BOOT/loader/entries`.
        Each attribute name denotes the destination file name,
        and the corresponding attribute value is the contents of the entry.

@@ -217,9 +246,9 @@ in {
        { "efi/memtest86/memtest.efi" = "''${pkgs.memtest86plus}/memtest.efi"; }
      '';
      description = lib.mdDoc ''
        A set of files to be copied to {file}`/boot`.
        A set of files to be copied to {file}`$BOOT`.
        Each attribute name denotes the destination file name in
        {file}`/boot`, while the corresponding
        {file}`$BOOT`, while the corresponding
        attribute value specifies the source file.
      '';
    };
@@ -243,6 +272,18 @@ in {

  config = mkIf cfg.enable {
    assertions = [
      {
        assertion = (hasPrefix "/" efi.efiSysMountPoint);
        message = "The ESP mount point '${efi.efiSysMountPoint}' must be an absolute path";
      }
      {
        assertion = cfg.xbootldrMountPoint == null || (hasPrefix "/" cfg.xbootldrMountPoint);
        message = "The XBOOTLDR mount point '${cfg.xbootldrMountPoint}' must be an absolute path";
      }
      {
        assertion = cfg.xbootldrMountPoint != efi.efiSysMountPoint;
        message = "The XBOOTLDR mount point '${cfg.xbootldrMountPoint}' cannot be the same as the ESP mount point '${efi.efiSysMountPoint}'";
      }
      {
        assertion = (config.boot.kernelPackages.kernel.features or { efiBootStub = true; }) ? efiBootStub;
        message = "This kernel does not support the EFI boot stub";
+115 −0
Original line number Diff line number Diff line
@@ -14,6 +14,72 @@ let
    boot.loader.efi.canTouchEfiVariables = true;
    environment.systemPackages = [ pkgs.efibootmgr ];
  };

  commonXbootldr = { config, lib, pkgs, ... }:
    let
      diskImage = import ../lib/make-disk-image.nix {
        inherit config lib pkgs;
        label = "nixos";
        format = "qcow2";
        partitionTableType = "efixbootldr";
        touchEFIVars = true;
        installBootLoader = true;
      };
    in
    {
      imports = [ common ];
      virtualisation.useBootLoader = lib.mkForce false; # Only way to tell qemu-vm not to create the default system image
      virtualisation.directBoot.enable = false; # But don't direct boot either because we're testing systemd-boot

      system.build.diskImage = diskImage; # Use custom disk image with an XBOOTLDR partition
      virtualisation.efi.variables = "${diskImage}/efi-vars.fd";

      virtualisation.useDefaultFilesystems = false; # Needs custom setup for `diskImage`
      virtualisation.bootPartition = null;
      virtualisation.fileSystems = {
        "/" = {
          device = "/dev/vda3";
          fsType = "ext4";
        };
        "/boot" = {
          device = "/dev/vda2";
          fsType = "vfat";
          noCheck = true;
        };
        "/efi" = {
          device = "/dev/vda1";
          fsType = "vfat";
          noCheck = true;
        };
      };

      boot.loader.systemd-boot.enable = true;
      boot.loader.efi.efiSysMountPoint = "/efi";
      boot.loader.systemd-boot.xbootldrMountPoint = "/boot";
    };

  customDiskImage = 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.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
  '';
in
{
  basic = makeTest {
@@ -65,6 +131,32 @@ in
    '';
  };

  basicXbootldr = makeTest {
    name = "systemd-boot-xbootldr";
    meta.maintainers = with pkgs.lib.maintainers; [ sdht0 ];

    nodes.machine = commonXbootldr;

    testScript = { nodes, ... }: ''
      ${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")

      # 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"')
    '';
  };

  # Check that specialisations create corresponding boot entries.
  specialisation = makeTest {
    name = "systemd-boot-specialisation";
@@ -184,6 +276,29 @@ in
    '';
  };

  entryFilenameXbootldr = makeTest {
    name = "systemd-boot-entry-filename-xbootldr";
    meta.maintainers = with pkgs.lib.maintainers; [ sdht0 ];

    nodes.machine = { pkgs, lib, ... }: {
      imports = [ commonXbootldr ];
      boot.loader.systemd-boot.memtest86.enable = true;
      boot.loader.systemd-boot.memtest86.entryFilename = "apple.conf";
    };

    testScript = { nodes, ... }: ''
      ${customDiskImage nodes}

      machine.start()
      machine.wait_for_unit("multi-user.target")

      machine.succeed("test -e /efi/EFI/systemd/systemd-bootx64.efi")
      machine.fail("test -e /boot/loader/entries/memtest86.conf")
      machine.succeed("test -e /boot/loader/entries/apple.conf")
      machine.succeed("test -e /boot/EFI/memtest86/memtest.efi")
    '';
  };

  extraEntries = makeTest {
    name = "systemd-boot-extra-entries";
    meta.maintainers = with pkgs.lib.maintainers; [ Enzime julienmalka ];