nixos-rebuild-ng: detect systemd-run version

`systemd-run --output=cat` is only supported from version 261 and
onwards. NixOS 26.05 (current stable) ships with systemd 260, so it
means using `systemd-run --output=cat` for anyone upgrading from the
current stable for the next stable will result in switch failures.

For now let's add a version check for `systemd-run` and only use
`--output=cat` if the version is newer than 261. The code can be removed
after release-27.05.
This commit is contained in:
Thiago Kenji Okada
2026-10-01 17:34:57 +01:00
parent 09f7ac9f10
commit 4228ea13fd
3 changed files with 169 additions and 11 deletions

View File

@@ -1,6 +1,7 @@
import json
import logging
import os
import re
import sys
import textwrap
import uuid
@@ -47,9 +48,6 @@ SWITCH_TO_CONFIGURATION_CMD_PREFIX: Final = [
"NIXOS_NO_CHECK",
"--collect",
"--no-ask-password",
"--wait",
"--verbose",
"--output=cat",
"--quiet",
"--service-type=exec",
"--unit=nixos-rebuild-switch-to-configuration",
@@ -734,6 +732,10 @@ def switch_to_configuration(
cmd = []
elif os.environ.get("NIXOS_REBUILD_NO_SYSTEMD_RUN"):
cmd = []
elif _systemd_run_supports_output_cat(target_host):
cmd = [*cmd, "--wait", "--verbose", "--output=cat"]
else:
cmd = [*cmd, "--pipe"]
run_wrapper(
[*cmd, path_to_config / "bin/switch-to-configuration", str(action)],
@@ -753,6 +755,23 @@ def switch_to_configuration(
)
# TODO: remove this after release-27.05 and assume systemd 261+
def _systemd_run_supports_output_cat(target_host: Remote | None) -> bool:
"""Check whether the target's systemd-run supports --output=cat (systemd 261+)."""
try:
result = run_wrapper(
["systemd-run", "--version"],
remote=target_host,
capture_output=True,
)
except CalledProcessError:
logger.debug("systemd-run version detection failed, assuming <261")
return False
match = re.search(r"^systemd (\d+)(?:\D|$)", result.stdout, re.MULTILINE)
return match is not None and int(match.group(1)) >= 261
def upgrade_channels(
all_channels: bool = False,
elevate: Elevator = NO_ELEVATOR,

View File

@@ -227,6 +227,8 @@ def test_execute_nix_boot(mock_run: Mock, tmp_path: Path) -> None:
return CompletedProcess([], 0, "nixpkgs-rev")
elif args[0] == "nix-build":
return CompletedProcess([], 0, str(config_path))
elif "systemd-run" in args and "--version" in args:
return CompletedProcess([], 0, "systemd 261 (261.2)")
else:
return CompletedProcess([], 0)
@@ -234,7 +236,7 @@ def test_execute_nix_boot(mock_run: Mock, tmp_path: Path) -> None:
nr.execute(["nixos-rebuild", "boot", "--no-flake", "-vvv", "--no-reexec"])
assert mock_run.call_count == 8
assert mock_run.call_count == 9
mock_run.assert_has_calls(
[
call(
@@ -289,9 +291,18 @@ def test_execute_nix_boot(mock_run: Mock, tmp_path: Path) -> None:
check=False,
**DEFAULT_RUN_KWARGS,
),
call(
["systemd-run", "--version"],
check=True,
capture_output=True,
**DEFAULT_RUN_KWARGS,
),
call(
[
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
"--wait",
"--verbose",
"--output=cat",
config_path / "bin/switch-to-configuration",
"boot",
],
@@ -571,6 +582,8 @@ def test_execute_nix_switch_flake(mock_run: Mock, tmp_path: Path) -> None:
return CompletedProcess([], 0, str(config_path))
elif args[0] == "nix-instantiate":
return CompletedProcess([], 1)
elif "systemd-run" in args and "--version" in args:
return CompletedProcess([], 0, "systemd 258 (258.2)")
else:
return CompletedProcess([], 0)
@@ -593,7 +606,7 @@ def test_execute_nix_switch_flake(mock_run: Mock, tmp_path: Path) -> None:
]
)
assert mock_run.call_count == 5
assert mock_run.call_count == 6
mock_run.assert_has_calls(
[
call(
@@ -637,6 +650,12 @@ def test_execute_nix_switch_flake(mock_run: Mock, tmp_path: Path) -> None:
check=False,
**DEFAULT_RUN_KWARGS,
),
call(
["systemd-run", "--version"],
check=True,
capture_output=True,
**DEFAULT_RUN_KWARGS,
),
call(
[
"sudo",
@@ -644,6 +663,7 @@ def test_execute_nix_switch_flake(mock_run: Mock, tmp_path: Path) -> None:
"-i",
"NIXOS_INSTALL_BOOTLOADER=1",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
"--pipe",
config_path / "bin/switch-to-configuration",
"switch",
],
@@ -685,6 +705,8 @@ def test_execute_nix_switch_build_target_host_custom_profile(
return CompletedProcess([], 0, "/tmp/tmpdir")
elif args[0] == "ssh" and "readlink" in args:
return CompletedProcess([], 0, str(config_path))
elif "systemd-run" in args and "--version" in args:
return CompletedProcess([], 0, "systemd 258 (258.2)")
else:
return CompletedProcess([], 0)
@@ -712,7 +734,7 @@ def test_execute_nix_switch_build_target_host_custom_profile(
]
)
assert mock_run.call_count == 13
assert mock_run.call_count == 14
mock_run.assert_has_calls(
[
call(
@@ -901,6 +923,23 @@ def test_execute_nix_switch_build_target_host_custom_profile(
check=False,
**DEFAULT_RUN_KWARGS,
),
call(
[
"ssh",
*nr.process.SSH_DEFAULT_OPTS,
"user@target-host",
"--",
"/bin/sh",
"-c",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"systemd-run",
"--version",
],
check=True,
capture_output=True,
**DEFAULT_RUN_KWARGS,
),
call(
[
"ssh",
@@ -913,6 +952,7 @@ def test_execute_nix_switch_build_target_host_custom_profile(
"""'exec /usr/bin/env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
"sh",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
"--pipe",
str(config_path / "bin/switch-to-configuration"),
"switch",
],
@@ -944,6 +984,8 @@ def test_execute_nix_switch_flake_target_host(
return CompletedProcess([], 0, str(config_path))
elif args[0] == "nix-instantiate":
return CompletedProcess([], 1)
elif "systemd-run" in args and "--version" in args:
return CompletedProcess([], 0, "systemd 258 (258.2)")
else:
return CompletedProcess([], 0)
@@ -962,7 +1004,7 @@ def test_execute_nix_switch_flake_target_host(
]
)
assert mock_run.call_count == 6
assert mock_run.call_count == 7
mock_run.assert_has_calls(
[
call(
@@ -1027,6 +1069,23 @@ def test_execute_nix_switch_flake_target_host(
check=False,
**DEFAULT_RUN_KWARGS,
),
call(
[
"ssh",
*nr.process.SSH_DEFAULT_OPTS,
"user@localhost",
"--",
"/bin/sh",
"-c",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"systemd-run",
"--version",
],
check=True,
capture_output=True,
**DEFAULT_RUN_KWARGS,
),
call(
[
"ssh",
@@ -1039,6 +1098,7 @@ def test_execute_nix_switch_flake_target_host(
"""'exec /usr/bin/env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
"sh",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
"--pipe",
str(config_path / "bin/switch-to-configuration"),
"switch",
],
@@ -1072,6 +1132,8 @@ def test_execute_nix_switch_flake_build_host(
return CompletedProcess([], 0, str(config_path))
elif args[0] == "nix-instantiate":
return CompletedProcess([], 1)
elif "systemd-run" in args and "--version" in args:
return CompletedProcess([], 0, "systemd 258 (258.2)")
else:
return CompletedProcess([], 0)
@@ -1089,7 +1151,7 @@ def test_execute_nix_switch_flake_build_host(
]
)
assert mock_run.call_count == 8
assert mock_run.call_count == 9
mock_run.assert_has_calls(
[
call(
@@ -1164,9 +1226,16 @@ def test_execute_nix_switch_flake_build_host(
check=False,
**DEFAULT_RUN_KWARGS,
),
call(
["systemd-run", "--version"],
check=True,
capture_output=True,
**DEFAULT_RUN_KWARGS,
),
call(
[
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
"--pipe",
config_path / "bin/switch-to-configuration",
"switch",
],
@@ -1527,7 +1596,7 @@ def test_execute_switch_store_path(mock_run: Mock, tmp_path: Path) -> None:
)
# --store-path skips build and write_version_suffix, so only activation calls
assert mock_run.call_count == 4
assert mock_run.call_count == 5
mock_run.assert_has_calls(
[
call(
@@ -1552,9 +1621,16 @@ def test_execute_switch_store_path(mock_run: Mock, tmp_path: Path) -> None:
check=False,
**DEFAULT_RUN_KWARGS,
),
call(
["systemd-run", "--version"],
check=True,
capture_output=True,
**DEFAULT_RUN_KWARGS,
),
call(
[
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
"--pipe",
config_path / "bin/switch-to-configuration",
"switch",
],
@@ -1600,9 +1676,15 @@ def test_execute_switch_store_path_target_host(
)
# --store-path skips build and write_version_suffix, so only copy/activation calls
assert mock_run.call_count == 6
assert mock_run.call_count == 7
mock_run.assert_has_calls(
[
call(
["nix-instantiate", "--find-file", "nixos-system"],
check=False,
capture_output=True,
**DEFAULT_RUN_KWARGS,
),
call(
["nix-copy-closure", "--to", "user@remote-host", config_path],
check=True,
@@ -1662,6 +1744,23 @@ def test_execute_switch_store_path_target_host(
check=False,
**DEFAULT_RUN_KWARGS,
),
call(
[
"ssh",
*nr.process.SSH_DEFAULT_OPTS,
"user@remote-host",
"--",
"/bin/sh",
"-c",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"systemd-run",
"--version",
],
check=True,
capture_output=True,
**DEFAULT_RUN_KWARGS,
),
call(
[
"ssh",
@@ -1674,6 +1773,7 @@ def test_execute_switch_store_path_target_host(
"""'exec /usr/bin/env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
"sh",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
"--pipe",
str(config_path / "bin/switch-to-configuration"),
"switch",
],

View File

@@ -957,9 +957,16 @@ def test_switch_to_configuration_without_systemd_run_env_var(
)
@patch(
get_qualified_name(n._systemd_run_supports_output_cat, n),
autospec=True,
return_value=True,
)
@patch(get_qualified_name(n.run_wrapper, n), autospec=True)
def test_switch_to_configuration_with_systemd_run(
mock_run: Mock, monkeypatch: MonkeyPatch
mock_run: Mock,
mock_supports_output_cat: Mock,
monkeypatch: MonkeyPatch,
) -> None:
profile_path = Path("/path/to/profile")
config_path = Path("/path/to/config")
@@ -979,6 +986,9 @@ def test_switch_to_configuration_with_systemd_run(
mock_run.assert_called_with(
[
*n.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
"--wait",
"--verbose",
"--output=cat",
profile_path / "bin/switch-to-configuration",
"switch",
],
@@ -992,6 +1002,7 @@ def test_switch_to_configuration_with_systemd_run(
stdout=sys.stderr,
)
mock_supports_output_cat.return_value = False
target_host = m.Remote("user@localhost", [], "ssh")
with monkeypatch.context() as mp:
mp.setenv("LOCALE_ARCHIVE", "/path/to/locale")
@@ -1009,6 +1020,7 @@ def test_switch_to_configuration_with_systemd_run(
mock_run.assert_called_with(
[
*n.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
"--pipe",
config_path / "specialisation/special/bin/switch-to-configuration",
"test",
],
@@ -1023,6 +1035,33 @@ def test_switch_to_configuration_with_systemd_run(
)
@pytest.mark.parametrize(
("version_output", "expected"),
[
("systemd 260 (260.1)\n", False),
(
textwrap.dedent("""\
systemd 261 (261.2)
+PAM +AUDIT -SELINUX +APPARMOR +IMA +IPE +SMACK +SECCOMP +GCRYPT -GNUTLS +OPENSSL +ACL +BLKID +CURL +ELFUTILS +FIDO2 +IDN2 +KMOD +LIBCRYPTSETUP +LIBCRYPTSETUP_PLUGINS +LIBFDISK +PCRE2 +PWQUALITY +P11KIT +QRENCODE +TPM2 +BZIP2 +LZ4 +XZ +ZLIB +ZSTD +BPF_FRAMEWORK -BTF -XKBCOMMON +UTMP +LIBARCHIVE
"""),
True,
),
("systemd 270 (270.2)\n", True),
("unexpected output\n", False),
],
)
@patch(get_qualified_name(n.run_wrapper, n), autospec=True)
def test_systemd_run_supports_output_cat(
mock_run: Mock, version_output: str, expected: bool
) -> None:
mock_run.return_value = CompletedProcess([], 0, stdout=version_output)
assert n._systemd_run_supports_output_cat(None) is expected
mock_run.assert_called_once_with(
["systemd-run", "--version"], remote=None, capture_output=True
)
@patch("os.geteuid", autospec=True, return_value=1000)
@patch(get_qualified_name(n.run_wrapper, n), autospec=True)
def test_upgrade_channels(mock_run: Mock, mock_geteuid: Mock, tmpdir: Path) -> None: