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.
This commit is contained in:
r-vdp
2026-07-01 12:05:01 +02:00
parent c5871d2171
commit bb258c2dbe
2 changed files with 58 additions and 50 deletions
@@ -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)
+4 -2
View File
@@ -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"
'';
}
);