nixos-rebuild-ng: allow deploying closures (#482430)

This commit is contained in:
Paul Haerle
2026-01-22 13:58:18 +00:00
committed by GitHub
7 changed files with 334 additions and 3 deletions
+3
View File
@@ -1107,6 +1107,9 @@ in
nixos-rebuild-specialisations = runTestOn [ "x86_64-linux" ] {
imports = [ ./nixos-rebuild-specialisations.nix ];
};
nixos-rebuild-store-path = runTestOn [ "x86_64-linux" ] {
imports = [ ./nixos-rebuild-store-path.nix ];
};
nixos-rebuild-target-host = runTest {
imports = [ ./nixos-rebuild-target-host.nix ];
};
+101
View File
@@ -0,0 +1,101 @@
{ hostPkgs, ... }:
{
name = "nixos-rebuild-store-path";
# TODO: remove overlay from nixos/modules/profiles/installation-device.nix
# make it a _small package instead, then remove pkgsReadOnly = false;.
node.pkgsReadOnly = false;
nodes = {
machine =
{ lib, pkgs, ... }:
{
imports = [
../modules/profiles/installation-device.nix
../modules/profiles/base.nix
];
nix.settings = {
substituters = lib.mkForce [ ];
hashed-mirrors = null;
connect-timeout = 1;
};
system.includeBuildDependencies = true;
system.extraDependencies = [
# Not part of the initial build apparently?
pkgs.grub2
];
system.switch.enable = true;
virtualisation = {
cores = 2;
memorySize = 4096;
};
};
};
testScript =
let
configFile =
hostname:
hostPkgs.writeText "configuration.nix" # nix
''
{ lib, pkgs, ... }: {
imports = [
./hardware-configuration.nix
<nixpkgs/nixos/modules/testing/test-instrumentation.nix>
];
boot.loader.grub = {
enable = true;
device = "/dev/vda";
forceInstall = true;
};
documentation.enable = false;
networking.hostName = "${hostname}";
}
'';
in
# python
''
machine.start()
machine.succeed("udevadm settle")
machine.wait_for_unit("multi-user.target")
machine.succeed("nixos-generate-config")
with subtest("Build configuration without switching"):
machine.copy_from_host(
"${configFile "store-path-test"}",
"/etc/nixos/configuration.nix",
)
store_path = machine.succeed("nix-build '<nixpkgs/nixos>' -A system --no-out-link").strip()
machine.succeed(f"test -f {store_path}/nixos-version")
with subtest("Switch using --store-path"):
machine.succeed(f"nixos-rebuild switch --store-path {store_path}")
hostname = machine.succeed("cat /etc/hostname").strip()
assert hostname == "store-path-test", f"Expected hostname 'store-path-test', got '{hostname}'"
with subtest("Test using --store-path"):
machine.copy_from_host(
"${configFile "store-path-test-2"}",
"/etc/nixos/configuration.nix",
)
store_path_2 = machine.succeed("nix-build '<nixpkgs/nixos>' -A system --no-out-link").strip()
machine.succeed(f"nixos-rebuild test --store-path {store_path_2}")
hostname = machine.succeed("cat /etc/hostname").strip()
assert hostname == "store-path-test-2", f"Expected hostname 'store-path-test-2', got '{hostname}'"
with subtest("Ensure --store-path rejects invalid combinations"):
machine.fail(f"nixos-rebuild switch --store-path {store_path} --rollback")
machine.fail(f"nixos-rebuild switch --store-path {store_path} --flake .")
machine.fail(f"nixos-rebuild build --store-path {store_path}")
'';
}
@@ -21,7 +21,7 @@ nixos-rebuild - reconfigure a NixOS machine
_nixos-rebuild_ \[--verbose] [--quiet] [--max-jobs MAX_JOBS] [--cores CORES] [--log-format LOG_FORMAT] [--keep-going] [--keep-failed] [--fallback] [--repair] [--option OPTION OPTION] [--builders BUILDERS] [--include INCLUDE]++
\[--print-build-logs] [--show-trace] [--accept-flake-config] [--refresh] [--impure] [--offline] [--no-net] [--recreate-lock-file] [--no-update-lock-file] [--no-write-lock-file] [--no-registries] [--commit-lock-file]++
\[--update-input UPDATE_INPUT] [--override-input OVERRIDE_INPUT OVERRIDE_INPUT] [--no-build-output] [--use-substitutes] [--help] [--debug] [--file FILE] [--attr ATTR] [--flake [FLAKE]] [--no-flake] [--install-bootloader]++
\[--profile-name PROFILE_NAME] [--specialisation SPECIALISATION] [--rollback] [--upgrade] [--upgrade-all] [--json] [--ask-sudo-password] [--sudo] [--no-reexec]++
\[--profile-name PROFILE_NAME] [--specialisation SPECIALISATION] [--rollback] [--store-path STORE_PATH] [--upgrade] [--upgrade-all] [--json] [--ask-sudo-password] [--sudo] [--no-reexec]++
\[--build-host BUILD_HOST] [--target-host TARGET_HOST] [--no-build-nix] [--image-variant IMAGE_VARIANT]++
\[{switch,boot,test,build,edit,repl,dry-build,dry-run,dry-activate,build-image,build-vm,build-vm-with-bootloader,list-generations}]
@@ -182,6 +182,20 @@ It must be one of the following:
(The previous configuration is defined as the one before the “current”
generation of the Nix profile _/nix/var/nix/profiles/system_.)
*--store-path* _path_
Use a pre-built NixOS system store path at _path_ instead of evaluating
and building from the configuration. This skips the evaluation and build
phases entirely. The path must be a valid NixOS system closure
(containing _nixos-version_ and _bin/switch-to-configuration_).
This is useful for deploying closures that were built elsewhere, such as
in CI systems or on remote build machines.
Can only be used with *switch*, *boot*, *test*, and *dry-activate*
actions. Mutually exclusive with *--rollback*, *--flake*, *--file*, and
*--attr*. The *--build-host* option is ignored when *--store-path* is
specified.
*--builders* _builder-spec_
Allow ad-hoc remote builders for building the new system. This requires
the user executing *nixos-rebuild* (usually root) to be configured as a
@@ -98,6 +98,7 @@ python3Packages.buildPythonApplication rec {
# FIXME: this test is disabled since it times out in @ofborg
# nixos-rebuild-install-bootloader
nixos-rebuild-specialisations
nixos-rebuild-store-path
nixos-rebuild-target-host
;
repl = callPackage ./tests/repl.nix { };
@@ -136,6 +136,11 @@ def get_parser() -> tuple[argparse.ArgumentParser, dict[str, argparse.ArgumentPa
action="store_true",
help="Roll back to the previous configuration",
)
main_parser.add_argument(
"--store-path",
metavar="PATH",
help="Use a pre-built NixOS system store path instead of building",
)
main_parser.add_argument(
"--upgrade",
action="store_true",
@@ -269,6 +274,22 @@ def parse_args(
if args.flake and (args.file or args.attr):
parser.error("--flake cannot be used with --file or --attr")
if args.store_path:
if args.rollback:
parser.error("--store-path and --rollback are mutually exclusive")
if args.flake or args.file or args.attr:
parser.error("--store-path cannot be used with --flake, --file, or --attr")
if args.action not in (
Action.SWITCH.value,
Action.BOOT.value,
Action.TEST.value,
Action.DRY_ACTIVATE.value,
):
parser.error(f"--store-path cannot be used with '{args.action}'")
if args.flake is None:
# Disable flake auto-detection since we're using a pre-built store path
args.flake = False
return args, grouped_nix_args
@@ -297,7 +318,7 @@ def execute(argv: list[str]) -> None:
build_attr = BuildAttr.from_arg(args.attr, args.file)
flake = Flake.from_arg(args.flake, target_host)
if can_run and not flake:
if can_run and not flake and not args.store_path:
services.write_version_suffix(grouped_nix_args)
match action:
@@ -290,7 +290,14 @@ def build_and_activate_system(
grouped_nix_args=grouped_nix_args,
)
if args.rollback:
if args.store_path:
path_to_config = Path(args.store_path)
nix.copy_closure(
path_to_config,
to_host=target_host,
copy_flags=grouped_nix_args.copy_flags,
)
elif args.rollback:
path_to_config = _rollback_system(
action=action,
args=args,
@@ -34,6 +34,30 @@ def test_parse_args() -> None:
nr.parse_args(["nixos-rebuild", "edit", "--attr", "attr"])
assert e.value.code == 2
# --store-path validation tests
with pytest.raises(SystemExit) as e:
nr.parse_args(
["nixos-rebuild", "switch", "--store-path", "/nix/store/foo", "--rollback"]
)
assert e.value.code == 2
with pytest.raises(SystemExit) as e:
nr.parse_args(
["nixos-rebuild", "switch", "--store-path", "/nix/store/foo", "--flake"]
)
assert e.value.code == 2
with pytest.raises(SystemExit) as e:
nr.parse_args(["nixos-rebuild", "build", "--store-path", "/nix/store/foo"])
assert e.value.code == 2
# --store-path should disable flake auto-detection
r_store_path, _ = nr.parse_args(
["nixos-rebuild", "switch", "--store-path", "/nix/store/foo"]
)
assert r_store_path.flake is False
assert r_store_path.store_path == "/nix/store/foo"
r1, g1 = nr.parse_args(
[
"nixos-rebuild",
@@ -1214,3 +1238,163 @@ def test_execute_test_rollback(
),
]
)
@patch.dict(
os.environ,
{"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1"},
clear=True,
)
@patch("subprocess.run", autospec=True)
def test_execute_switch_store_path(mock_run: Mock, tmp_path: Path) -> None:
config_path = tmp_path / "test-system"
config_path.mkdir()
mock_run.return_value = CompletedProcess([], 0)
nr.execute(
[
"nixos-rebuild",
"switch",
"--store-path",
str(config_path),
"--no-reexec",
]
)
# --store-path skips build and write_version_suffix, so only activation calls
assert mock_run.call_count == 3
mock_run.assert_has_calls(
[
call(
[
"nix-env",
"-p",
Path("/nix/var/nix/profiles/system"),
"--set",
config_path,
],
check=True,
**DEFAULT_RUN_KWARGS,
),
call(
["test", "-d", "/run/systemd/system"],
check=False,
**DEFAULT_RUN_KWARGS,
),
call(
[
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
config_path / "bin/switch-to-configuration",
"switch",
],
check=True,
**(
DEFAULT_RUN_KWARGS
| {
"env": {
"NIXOS_INSTALL_BOOTLOADER": "0",
"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1",
}
}
),
),
]
)
@patch.dict(os.environ, {}, clear=True)
@patch("subprocess.run", autospec=True)
@patch(get_qualified_name(nr.services.cleanup_ssh), autospec=True)
def test_execute_switch_store_path_target_host(
mock_cleanup_ssh: Mock,
mock_run: Mock,
tmp_path: Path,
) -> None:
config_path = tmp_path / "test-system"
config_path.mkdir()
mock_run.return_value = CompletedProcess([], 0)
nr.execute(
[
"nixos-rebuild",
"switch",
"--store-path",
str(config_path),
"--target-host",
"user@remote-host",
"--sudo",
"--no-reexec",
]
)
# --store-path skips build and write_version_suffix, so only copy/activation calls
assert mock_run.call_count == 5
mock_run.assert_has_calls(
[
call(
["nix-copy-closure", "--to", "user@remote-host", config_path],
check=True,
**DEFAULT_RUN_KWARGS,
),
call(
[
"ssh",
*nr.process.SSH_DEFAULT_OPTS,
"user@remote-host",
"--",
"test",
"-f",
str(config_path / "nixos-version"),
],
check=False,
**DEFAULT_RUN_KWARGS,
),
call(
[
"ssh",
*nr.process.SSH_DEFAULT_OPTS,
"user@remote-host",
"--",
"sudo",
"nix-env",
"-p",
"/nix/var/nix/profiles/system",
"--set",
str(config_path),
],
check=True,
**DEFAULT_RUN_KWARGS,
),
call(
[
"ssh",
*nr.process.SSH_DEFAULT_OPTS,
"user@remote-host",
"--",
"test",
"-d",
"/run/systemd/system",
],
check=False,
**DEFAULT_RUN_KWARGS,
),
call(
[
"ssh",
*nr.process.SSH_DEFAULT_OPTS,
"user@remote-host",
"--",
"sudo",
"env",
"NIXOS_INSTALL_BOOTLOADER=0",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
str(config_path / "bin/switch-to-configuration"),
"switch",
],
check=True,
**DEFAULT_RUN_KWARGS,
),
]
)