From 978a08c88fbd7448cb0385f0bc5732ad4c00dea7 Mon Sep 17 00:00:00 2001 From: zimbatm Date: Mon, 5 Jan 2026 18:45:15 +0100 Subject: [PATCH] nixos-rebuild-ng: allow deploying closures Tools that want to deploy NixOS all re-implement the same core logic available in `nixos-rebuild-ng` (bin/switch-to-configuration + nix-env -p) because they want to be able to deploy specific system closures. By adding a `--store-path` flag, tools can now directly invoke nixos-rebuild-ng instead. Bringing the interface to a higher level allows us to incorporate ideas such as health checks and auto-rollbacks that are harder to achieve on the lower layer. --- nixos/tests/all-tests.nix | 3 + nixos/tests/nixos-rebuild-store-path.nix | 101 ++++++++++ .../ni/nixos-rebuild-ng/nixos-rebuild.8.scd | 16 +- pkgs/by-name/ni/nixos-rebuild-ng/package.nix | 1 + .../src/nixos_rebuild/__init__.py | 23 ++- .../src/nixos_rebuild/services.py | 9 +- .../nixos-rebuild-ng/src/tests/test_main.py | 184 ++++++++++++++++++ 7 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 nixos/tests/nixos-rebuild-store-path.nix diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 6cd4ac9a4ecb..39542f1e683a 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -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 ]; }; diff --git a/nixos/tests/nixos-rebuild-store-path.nix b/nixos/tests/nixos-rebuild-store-path.nix new file mode 100644 index 000000000000..c451ff865744 --- /dev/null +++ b/nixos/tests/nixos-rebuild-store-path.nix @@ -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 + + ]; + + 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 '' -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 '' -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}") + ''; +} diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/nixos-rebuild.8.scd b/pkgs/by-name/ni/nixos-rebuild-ng/nixos-rebuild.8.scd index 3e04426ba66a..bc7f09e036d1 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/nixos-rebuild.8.scd +++ b/pkgs/by-name/ni/nixos-rebuild-ng/nixos-rebuild.8.scd @@ -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 diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/package.nix b/pkgs/by-name/ni/nixos-rebuild-ng/package.nix index b62492b58b66..e08e5cca9c65 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/package.nix +++ b/pkgs/by-name/ni/nixos-rebuild-ng/package.nix @@ -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 { }; diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py index e4fa5ef75a43..bedfdba11536 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py @@ -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: diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py index f1902bd30fb8..5d3a74ea821e 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py @@ -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, diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py index cb7c87ad74f3..cd8716c7d024 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py @@ -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, + ), + ] + )