nixos-rebuild-ng: validate NixOS configuration path (#418243)

authored by Thiago Kenji Okada and committed by GitHub ae48ab38 662f1d70

+152 -76
+45 -18
pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py
··· 5 5 import sys 6 6 from pathlib import Path 7 7 from subprocess import CalledProcessError, run 8 + from textwrap import dedent 8 9 from typing import Final, assert_never 9 10 10 11 from . import nix, tmpdir 11 12 from .constants import EXECUTABLE, WITH_NIX_2_18, WITH_REEXEC, WITH_SHELL_FILES 12 - from .models import Action, BuildAttr, Flake, ImageVariants, NRError, Profile 13 + from .models import Action, BuildAttr, Flake, ImageVariants, NixOSRebuildError, Profile 13 14 from .process import Remote, cleanup_ssh 14 15 from .utils import Args, LogFormatter, tabulate 15 16 ··· 99 100 "--attr", 100 101 "-A", 101 102 help="Enable and build the NixOS system from nix file and use the " 102 - + "specified attribute path from file specified by the --file option", 103 + "specified attribute path from file specified by the --file option", 103 104 ) 104 105 main_parser.add_argument( 105 106 "--flake", ··· 117 118 "--install-bootloader", 118 119 action="store_true", 119 120 help="Causes the boot loader to be (re)installed on the device specified " 120 - + "by the relevant configuration options", 121 + "by the relevant configuration options", 121 122 ) 122 123 main_parser.add_argument( 123 124 "--install-grub", ··· 142 143 "--upgrade", 143 144 action="store_true", 144 145 help="Update the root user's channel named 'nixos' before rebuilding " 145 - + "the system and channels which have a file named '.update-on-nixos-rebuild'", 146 + "the system and channels which have a file named '.update-on-nixos-rebuild'", 146 147 ) 147 148 main_parser.add_argument( 148 149 "--upgrade-all", ··· 186 187 main_parser.add_argument( 187 188 "--image-variant", 188 189 help="Selects an image variant to build from the " 189 - + "config.system.build.images attribute of the given configuration", 190 + "config.system.build.images attribute of the given configuration", 190 191 ) 191 192 main_parser.add_argument("action", choices=Action.values(), nargs="?") 192 193 ··· 321 322 # - Exec format error (e.g.: another OS/CPU arch) 322 323 logger.warning( 323 324 "could not re-exec in a newer version of nixos-rebuild, " 324 - + "using current version", 325 + "using current version", 325 326 exc_info=logger.isEnabledFor(logging.DEBUG), 326 327 ) 327 328 # We already run clean-up, let's re-exec in the current version ··· 329 330 os.execve(current, argv, os.environ | {"_NIXOS_REBUILD_REEXEC": "1"}) 330 331 331 332 333 + def validate_image_variant(image_variant: str, variants: ImageVariants) -> None: 334 + if image_variant not in variants: 335 + raise NixOSRebuildError( 336 + "please specify one of the following supported image variants via " 337 + "--image-variant:\n" + "\n".join(f"- {v}" for v in variants) 338 + ) 339 + 340 + 341 + def validate_nixos_config(path_to_config: Path) -> None: 342 + if not (path_to_config / "nixos-version").exists() and not os.environ.get( 343 + "NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM" 344 + ): 345 + msg = dedent( 346 + # the lowercase for the first letter below is proposital 347 + f""" 348 + your NixOS configuration path seems to be missing essential files. 349 + To avoid corrupting your current NixOS installation, the activation will abort. 350 + 351 + This could be caused by Nix bug: https://github.com/NixOS/nix/issues/13367. 352 + This is the evaluated NixOS configuration path: {path_to_config}. 353 + Change the directory to somewhere else (e.g., `cd $HOME`) before trying again. 354 + 355 + If you think this is a mistake, you can set the environment variable 356 + NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM to 1 357 + and re-run the command to continue. 358 + Please open an issue if this is the case. 359 + """ 360 + ).strip() 361 + raise NixOSRebuildError(msg) 362 + 363 + 332 364 def execute(argv: list[str]) -> None: 333 365 args, args_groups = parse_args(argv) 334 366 ··· 393 425 no_link = action in (Action.SWITCH, Action.BOOT) 394 426 rollback = bool(args.rollback) 395 427 396 - def validate_image_variant(variants: ImageVariants) -> None: 397 - if args.image_variant not in variants: 398 - raise NRError( 399 - "please specify one of the following " 400 - + "supported image variants via --image-variant:\n" 401 - + "\n".join(f"- {v}" for v in variants) 402 - ) 403 - 404 428 match action: 405 429 case Action.BUILD_IMAGE if flake: 406 430 variants = nix.get_build_image_variants_flake( 407 431 flake, 408 432 eval_flags=flake_common_flags, 409 433 ) 410 - validate_image_variant(variants) 434 + validate_image_variant(args.image_variant, variants) 411 435 attr = f"config.system.build.images.{args.image_variant}" 412 436 case Action.BUILD_IMAGE: 413 437 variants = nix.get_build_image_variants( 414 438 build_attr, 415 439 instantiate_flags=common_flags, 416 440 ) 417 - validate_image_variant(variants) 441 + validate_image_variant(args.image_variant, variants) 418 442 attr = f"config.system.build.images.{args.image_variant}" 419 443 case Action.BUILD_VM: 420 444 attr = "config.system.build.vm" ··· 435 459 if maybe_path_to_config: # kinda silly but this makes mypy happy 436 460 path_to_config = maybe_path_to_config 437 461 else: 438 - raise NRError("could not find previous generation") 462 + raise NixOSRebuildError("could not find previous generation") 439 463 case (_, True, _, _): 440 - raise NRError(f"--rollback is incompatible with '{action}'") 464 + raise NixOSRebuildError( 465 + f"--rollback is incompatible with '{action}'" 466 + ) 441 467 case (_, False, Remote(_), Flake(_)): 442 468 path_to_config = nix.build_remote_flake( 443 469 attr, ··· 488 514 copy_flags=copy_flags, 489 515 ) 490 516 if action in (Action.SWITCH, Action.BOOT): 517 + validate_nixos_config(path_to_config) 491 518 nix.set_profile( 492 519 profile, 493 520 path_to_config,
+23 -24
pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/models.py
··· 4 4 from dataclasses import dataclass 5 5 from enum import Enum 6 6 from pathlib import Path 7 - from typing import Any, Callable, ClassVar, Self, TypedDict, override 7 + from typing import Any, ClassVar, Self, TypedDict, override 8 8 9 9 from .process import Remote, run_wrapper 10 10 11 11 type ImageVariants = list[str] 12 12 13 13 14 - class NRError(Exception): 14 + class NixOSRebuildError(Exception): 15 15 "nixos-rebuild general error." 16 16 17 17 def __init__(self, message: str) -> None: ··· 100 100 return None 101 101 102 102 103 + def get_hostname(target_host: Remote | None) -> str | None: 104 + if target_host: 105 + try: 106 + return run_wrapper( 107 + ["uname", "-n"], 108 + capture_output=True, 109 + remote=target_host, 110 + ).stdout.strip() 111 + except (AttributeError, subprocess.CalledProcessError): 112 + return None 113 + else: 114 + return platform.node() 115 + 116 + 103 117 @dataclass(frozen=True) 104 118 class Flake: 105 119 path: Path | str ··· 114 128 return f"{self.path}#{self.attr}" 115 129 116 130 @classmethod 117 - def parse( 118 - cls, 119 - flake_str: str, 120 - hostname_fn: Callable[[], str | None] = lambda: None, 121 - ) -> Self: 131 + def parse(cls, flake_str: str, target_host: Remote | None = None) -> Self: 122 132 m = cls._re.match(flake_str) 123 133 assert m is not None, f"got no matches for {flake_str}" 124 134 attr = m.group("attr") 125 - nixos_attr = f'nixosConfigurations."{attr or hostname_fn() or "default"}"' 135 + nixos_attr = ( 136 + f'nixosConfigurations."{attr or get_hostname(target_host) or "default"}"' 137 + ) 126 138 path_str = m.group("path") 127 139 if ":" in path_str: 128 140 return cls(path_str, nixos_attr) ··· 143 155 144 156 @classmethod 145 157 def from_arg(cls, flake_arg: Any, target_host: Remote | None) -> Self | None: 146 - def get_hostname() -> str | None: 147 - if target_host: 148 - try: 149 - return run_wrapper( 150 - ["uname", "-n"], 151 - stdout=subprocess.PIPE, 152 - remote=target_host, 153 - ).stdout.strip() 154 - except (AttributeError, subprocess.CalledProcessError): 155 - return None 156 - else: 157 - return platform.node() 158 - 159 158 match flake_arg: 160 159 case str(s): 161 - return cls.parse(s, get_hostname) 160 + return cls.parse(s, target_host) 162 161 case True: 163 - return cls.parse(".", get_hostname) 162 + return cls.parse(".", target_host) 164 163 case False: 165 164 return None 166 165 case _: ··· 169 168 if default_path.exists(): 170 169 # It can be a symlink to the actual flake. 171 170 default_path = default_path.resolve() 172 - return cls.parse(str(default_path.parent), get_hostname) 171 + return cls.parse(str(default_path.parent), target_host) 173 172 else: 174 173 return None 175 174
+8 -8
pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py
··· 20 20 Generation, 21 21 GenerationJson, 22 22 ImageVariants, 23 - NRError, 23 + NixOSRebuildError, 24 24 Profile, 25 25 Remote, 26 26 ) ··· 256 256 ) 257 257 else: 258 258 if flake_flags: 259 - raise NRError("'edit' does not support extra Nix flags") 259 + raise NixOSRebuildError("'edit' does not support extra Nix flags") 260 260 nixos_config = Path( 261 261 os.getenv("NIXOS_CONFIG") or find_file("nixos-config") or "/etc/nixos" 262 262 ) ··· 266 266 if nixos_config.exists(): 267 267 run_wrapper([os.getenv("EDITOR", "nano"), nixos_config], check=False) 268 268 else: 269 - raise NRError("cannot find NixOS config file") 269 + raise NixOSRebuildError("cannot find NixOS config file") 270 270 271 271 272 272 def find_file(file: str, nix_flags: Args | None = None) -> Path | None: ··· 424 424 and if this is the current active profile or not. 425 425 """ 426 426 if not profile.path.exists(): 427 - raise NRError(f"no profile '{profile.name}' found") 427 + raise NixOSRebuildError(f"no profile '{profile.name}' found") 428 428 429 429 def parse_path(path: Path, profile: Profile) -> Generation: 430 430 entry_id = path.name.split("-")[1] ··· 456 456 and if this is the current active profile or not. 457 457 """ 458 458 if not profile.path.exists(): 459 - raise NRError(f"no profile '{profile.name}' found") 459 + raise NixOSRebuildError(f"no profile '{profile.name}' found") 460 460 461 461 # Using `nix-env --list-generations` needs root to lock the profile 462 462 r = run_wrapper( ··· 635 635 """ 636 636 if specialisation: 637 637 if action not in (Action.SWITCH, Action.TEST): 638 - raise NRError( 638 + raise NixOSRebuildError( 639 639 "'--specialisation' can only be used with 'switch' and 'test'" 640 640 ) 641 641 path_to_config = path_to_config / f"specialisation/{specialisation}" 642 642 643 643 if not path_to_config.exists(): 644 - raise NRError(f"specialisation not found: {specialisation}") 644 + raise NixOSRebuildError(f"specialisation not found: {specialisation}") 645 645 646 646 r = run_wrapper( 647 647 ["test", "-d", "/run/systemd/system"], ··· 652 652 if r.returncode: 653 653 logger.debug( 654 654 "skipping systemd-run to switch configuration since systemd is " 655 - + "not working in target host" 655 + "not working in target host" 656 656 ) 657 657 cmd = [] 658 658
+3 -3
pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py
··· 55 55 if o in ["-t", "-tt", "RequestTTY=yes", "RequestTTY=force"]: 56 56 logger.warning( 57 57 f"detected option '{o}' in NIX_SSHOPTS. SSH's TTY may " 58 - + "cause issues, it is recommended to remove this option" 58 + "cause issues, it is recommended to remove this option" 59 59 ) 60 60 if not ask_sudo_password: 61 61 logger.warning( 62 62 "if you want to prompt for sudo password use " 63 - + "'--ask-sudo-password' option instead" 63 + "'--ask-sudo-password' option instead" 64 64 ) 65 65 66 66 ··· 161 161 if sudo and remote and remote.sudo_password is None: 162 162 logger.error( 163 163 "while running command with remote sudo, did you forget to use " 164 - + "--ask-sudo-password?" 164 + "--ask-sudo-password?" 165 165 ) 166 166 raise 167 167
+10 -9
pkgs/by-name/ni/nixos-rebuild-ng/src/pyproject.toml
··· 39 39 40 40 [tool.ruff.lint] 41 41 extend-select = [ 42 - # Enforce type annotations 42 + # enforce type annotations 43 43 "ANN", 44 44 # don't shadow built-in names 45 45 "A", 46 - # Better list/set/dict comprehensions 46 + # better list/set/dict comprehensions 47 47 "C4", 48 - # Check for debugger statements 48 + # check for debugger statements 49 49 "T10", 50 50 # ensure imports are sorted 51 51 "I", 52 - # Automatically upgrade syntax for newer versions 52 + # automatically upgrade syntax for newer versions 53 53 "UP", 54 54 # detect common sources of bugs 55 55 "B", 56 - # Ruff specific rules 56 + # ruff specific rules 57 57 "RUF", 58 58 # require `check` argument for `subprocess.run` 59 59 "PLW1510", 60 60 # check for needless exception names in raise statements 61 61 "TRY201", 62 - # Pythonic naming conventions 62 + # pythonic naming conventions 63 63 "N", 64 + # string concatenation rules 65 + "ISC001", 66 + "ISC002", 67 + "ISC003", 64 68 ] 65 69 ignore = [ 66 70 # allow Any type 67 71 "ANN401" 68 72 ] 69 - 70 - [tool.ruff.lint.per-file-ignores] 71 - "tests/" = ["FA102"] 72 73 73 74 [tool.pytest.ini_options] 74 75 pythonpath = ["."]
+43 -7
pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py
··· 213 213 ) 214 214 215 215 216 - @patch.dict(os.environ, {}, clear=True) 216 + @patch.dict( 217 + os.environ, 218 + {"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1"}, 219 + clear=True, 220 + ) 217 221 @patch("subprocess.run", autospec=True) 218 222 def test_execute_nix_boot(mock_run: Mock, tmp_path: Path) -> None: 219 223 nixpkgs_path = tmp_path / "nixpkgs" ··· 291 295 "boot", 292 296 ], 293 297 check=True, 294 - **(DEFAULT_RUN_KWARGS | {"env": {"NIXOS_INSTALL_BOOTLOADER": "0"}}), 298 + **( 299 + DEFAULT_RUN_KWARGS 300 + | { 301 + "env": { 302 + "NIXOS_INSTALL_BOOTLOADER": "0", 303 + "NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1", 304 + } 305 + } 306 + ), 295 307 ), 296 308 ] 297 309 ) ··· 421 433 ) 422 434 423 435 424 - @patch.dict(os.environ, {}, clear=True) 436 + @patch.dict( 437 + os.environ, 438 + {"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1"}, 439 + clear=True, 440 + ) 425 441 @patch("subprocess.run", autospec=True) 426 442 def test_execute_nix_switch_flake(mock_run: Mock, tmp_path: Path) -> None: 427 443 config_path = tmp_path / "test" ··· 498 514 "switch", 499 515 ], 500 516 check=True, 501 - **(DEFAULT_RUN_KWARGS | {"env": {"NIXOS_INSTALL_BOOTLOADER": "1"}}), 517 + **( 518 + DEFAULT_RUN_KWARGS 519 + | { 520 + "env": { 521 + "NIXOS_INSTALL_BOOTLOADER": "1", 522 + "NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1", 523 + } 524 + } 525 + ), 502 526 ), 503 527 ] 504 528 ) 505 529 506 530 507 - @patch.dict(os.environ, {}, clear=True) 531 + @patch.dict( 532 + os.environ, 533 + {"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1"}, 534 + clear=True, 535 + ) 508 536 @patch("subprocess.run", autospec=True) 509 537 @patch("uuid.uuid4", autospec=True) 510 538 @patch(get_qualified_name(nr.cleanup_ssh), autospec=True) ··· 714 742 ) 715 743 716 744 717 - @patch.dict(os.environ, {}, clear=True) 745 + @patch.dict( 746 + os.environ, 747 + {"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1"}, 748 + clear=True, 749 + ) 718 750 @patch("subprocess.run", autospec=True) 719 751 @patch(get_qualified_name(nr.cleanup_ssh), autospec=True) 720 752 def test_execute_nix_switch_flake_target_host( ··· 817 849 ) 818 850 819 851 820 - @patch.dict(os.environ, {}, clear=True) 852 + @patch.dict( 853 + os.environ, 854 + {"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1"}, 855 + clear=True, 856 + ) 821 857 @patch("subprocess.run", autospec=True) 822 858 @patch(get_qualified_name(nr.cleanup_ssh), autospec=True) 823 859 def test_execute_nix_switch_flake_build_host(
+19 -6
pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_models.py
··· 30 30 ) 31 31 32 32 33 - def test_flake_parse(tmpdir: Path, monkeypatch: MonkeyPatch) -> None: 33 + @patch("platform.node", autospec=True, return_value="hostname") 34 + def test_flake_parse(mock_node: Mock, tmpdir: Path, monkeypatch: MonkeyPatch) -> None: 34 35 assert m.Flake.parse("/path/to/flake#attr") == m.Flake( 35 36 Path("/path/to/flake"), 'nixosConfigurations."attr"' 36 37 ) 37 - assert m.Flake.parse("/path/ to /flake", lambda: "hostname") == m.Flake( 38 + assert m.Flake.parse("/path/ to /flake") == m.Flake( 38 39 Path("/path/ to /flake"), 'nixosConfigurations."hostname"' 39 40 ) 40 - assert m.Flake.parse("/path/to/flake", lambda: "hostname") == m.Flake( 41 - Path("/path/to/flake"), 'nixosConfigurations."hostname"' 42 - ) 41 + with patch( 42 + get_qualified_name(m.run_wrapper, m), 43 + autospec=True, 44 + return_value=subprocess.CompletedProcess([], 0, stdout="remote\n"), 45 + ): 46 + target_host = m.Remote("target@remote", [], None) 47 + assert m.Flake.parse("/path/to/flake", target_host) == m.Flake( 48 + Path("/path/to/flake"), 'nixosConfigurations."remote"' 49 + ) 43 50 # change directory to tmpdir 44 51 with monkeypatch.context() as patch_context: 45 52 patch_context.chdir(tmpdir) ··· 49 56 assert m.Flake.parse("#attr") == m.Flake( 50 57 Path("."), 'nixosConfigurations."attr"' 51 58 ) 52 - assert m.Flake.parse(".") == m.Flake(Path("."), 'nixosConfigurations."default"') 59 + assert m.Flake.parse(".") == m.Flake( 60 + Path("."), 'nixosConfigurations."hostname"' 61 + ) 53 62 assert m.Flake.parse("path:/to/flake#attr") == m.Flake( 54 63 "path:/to/flake", 'nixosConfigurations."attr"' 55 64 ) 65 + 66 + # from here on we should return "default" 67 + mock_node.return_value = None 68 + 56 69 assert m.Flake.parse("github:user/repo/branch") == m.Flake( 57 70 "github:user/repo/branch", 'nixosConfigurations."default"' 58 71 )
+1 -1
pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py
··· 714 714 remote=None, 715 715 ) 716 716 717 - with pytest.raises(m.NRError) as e: 717 + with pytest.raises(m.NixOSRebuildError) as e: 718 718 n.switch_to_configuration( 719 719 config_path, 720 720 m.Action.BOOT,