From bb258c2dbedde8601dd9981c069b06fe7ae34c78 Mon Sep 17 00:00:00 2001 From: r-vdp Date: Wed, 1 Jul 2026 12:05:01 +0200 Subject: [PATCH] nixos/systemd-boot: update via Varlink instead of parsing bootctl status systemd v261 lists the new systemd-boot-fallback binary in `bootctl status`, so our regex picked up the older fallback version and ran `bootctl update` on an already current ESP, which then exits non-zero. Switch from parsing the `bootctl --status` output to having bootctl compare versions itself by calling io.systemd.BootControl.Install over Varlink. bootctl is spawned as a stdio Varlink server so that we always use the bootctl from the target closure. An already current binary comes back as an io.systemd.System error carrying ESTALE, which we treat as success. --- .../systemd-boot/systemd-boot-builder.py | 102 +++++++++--------- nixos/tests/systemd-boot.nix | 6 +- 2 files changed, 58 insertions(+), 50 deletions(-) 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 534efa8877e2..3e917a7cf48f 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 @@ -243,6 +243,38 @@ def run( return subprocess.run(cmd, check=True, text=True, stdout=stdout, stderr=sys.stderr) +def bootctl_varlink_call(method: str, parameters: dict[str, Any]) -> dict[str, Any]: + # Spawn bootctl as a Varlink server on stdio, mirroring what varlinkctl + # does when given an executable path. We cannot talk to a running + # systemd-bootctl.socket because that would use bootctl from the booted + # system rather than the closure we are switching to, does not let us set + # SYSTEMD_ESP_PATH/SYSTEMD_XBOOTLDR_PATH, and is unavailable inside + # nixos-enter anyway. + env = os.environ | { + "SYSTEMD_VARLINK_LISTEN": "-", + "SYSTEMD_ESP_PATH": str(EFI_SYS_MOUNT_POINT), + } + if BOOT_MOUNT_POINT != EFI_SYS_MOUNT_POINT: + env["SYSTEMD_XBOOTLDR_PATH"] = str(BOOT_MOUNT_POINT) + + proc = subprocess.Popen( + [f"{SYSTEMD}/bin/bootctl"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=sys.stderr, + env=env, + ) + assert proc.stdin is not None and proc.stdout is not None + request = json.dumps({"method": method, "parameters": parameters}).encode() + out, _ = proc.communicate(request + b"\0") + reply, _, _ = out.partition(b"\0") + if not reply: + raise RuntimeError( + f"bootctl exited with status {proc.returncode} without a Varlink reply" + ) + return json.loads(reply) + + def generation_dir(profile: str | None, generation: int) -> Path: if profile: return Path( @@ -479,55 +511,29 @@ def install_bootloader(args: argparse.Namespace) -> None: + ["install"] ) else: - # Update bootloader to latest if needed - available_out = run( - [f"{SYSTEMD}/bin/bootctl", "--version"], stdout=subprocess.PIPE - ).stdout.split()[2] - installed_out = run( - [f"{SYSTEMD}/bin/bootctl", f"--esp-path={EFI_SYS_MOUNT_POINT}", "status"], - stdout=subprocess.PIPE, - ).stdout + # Let bootctl compare versions itself. Over Varlink, an already + # current binary comes back as an io.systemd.System error carrying + # ESTALE, which we can tell apart from real failures. + params: dict[str, Any] = {"operation": "update"} + if not CAN_TOUCH_EFI_VARIABLES: + params["touchVariables"] = False + if GRACEFUL: + params["graceful"] = True + reply = bootctl_varlink_call("io.systemd.BootControl.Install", params) - # See status_binaries() in systemd bootctl.c for code which generates this - # Matches - # Available Boot Loaders on ESP: - # ESP: /boot (/dev/disk/by-partuuid/9b39b4c4-c48b-4ebf-bfea-a56b2395b7e0) - # File: └─/EFI/systemd/systemd-bootx64.efi (systemd-boot 255.2) - # But also: - # Available Boot Loaders on ESP: - # 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, - ) - - 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`" - ) - - if available_match is None: - raise Exception("could not determine systemd-boot version") - - installed_version = installed_match.group(1) - 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, - ) - run( - [f"{SYSTEMD}/bin/bootctl", f"--esp-path={EFI_SYS_MOUNT_POINT}"] - + bootctl_flags - + ["update"] - ) + error = reply.get("error") + if error is not None: + error_params = reply.get("parameters") or {} + if ( + error == "io.systemd.System" + and error_params.get("errno") == errno.ESTALE + ): + # Same or newer boot loader version already in place. + pass + else: + raise RuntimeError( + f"bootctl update failed: {error} {json.dumps(error_params)}" + ) (BOOT_MOUNT_POINT / NIXOS_DIR).mkdir(parents=True, exist_ok=True) (BOOT_MOUNT_POINT / "loader/entries").mkdir(parents=True, exist_ok=True) diff --git a/nixos/tests/systemd-boot.nix b/nixos/tests/systemd-boot.nix index e001f11ce3a3..f36d3870f98e 100644 --- a/nixos/tests/systemd-boot.nix +++ b/nixos/tests/systemd-boot.nix @@ -478,7 +478,6 @@ in return machine.succeed("/run/current-system/bin/switch-to-configuration boot 2>&1") output = switch() - assert "updating systemd-boot from ${oldVersion} 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" @@ -489,9 +488,12 @@ in "mv /boot/EFI/BOOT/bootx64.efi.new /boot/EFI/BOOT/bootx64.efi", ) output = switch() - assert "updating systemd-boot from ${oldVersion} 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 switching with an up-to-date bootloader is a no-op"): + output = machine.succeed("/run/current-system/bin/switch-to-configuration boot 2>&1") + assert "same boot loader version in place already" in output, "Expected bootctl to skip already-current binary" ''; } );