nixos-rebuild-ng: improve developer experience (#356468)

authored by Thiago Kenji Okada and committed by GitHub 1f815066 cbccc5f1

+238 -36
+39 -1
pkgs/by-name/ni/nixos-rebuild-ng/README.md
··· 53 54 And use `nixos-rebuild-ng` instead of `nixos-rebuild`. 55 56 ## Current caveats 57 58 - For now we will install it in `nixos-rebuild-ng` path by default, to avoid ··· 96 - [ ] Improve documentation 97 - [ ] `nixos-rebuild repl` (calling old `nixos-rebuild` for now) 98 - [ ] `nix` build/bootstrap 99 - - [ ] Reduce build closure
··· 53 54 And use `nixos-rebuild-ng` instead of `nixos-rebuild`. 55 56 + ## Development 57 + 58 + Run: 59 + 60 + ```console 61 + nix-build -A nixos-rebuild-ng.tests.ci 62 + ``` 63 + 64 + The command above will run the unit tests and linters, and also check if the 65 + code is formatted. However, sometimes is more convenient to run just a few 66 + tests to debug, in this case you can run: 67 + 68 + ```console 69 + nix-shell -A nixos-rebuild-ng.devShell 70 + ``` 71 + 72 + The command above should automatically put you inside `src` directory, and you 73 + can run: 74 + 75 + ```console 76 + # run program 77 + python -m nixos_rebuild 78 + # run tests 79 + python -m pytest 80 + # check types 81 + mypy . 82 + # fix lint issues 83 + ruff check --fix . 84 + # format code 85 + ruff format . 86 + ``` 87 + 88 ## Current caveats 89 90 - For now we will install it in `nixos-rebuild-ng` path by default, to avoid ··· 128 - [ ] Improve documentation 129 - [ ] `nixos-rebuild repl` (calling old `nixos-rebuild` for now) 130 - [ ] `nix` build/bootstrap 131 + - [ ] Generate tab completion via [`shtab`](https://docs.iterative.ai/shtab/) 132 + - [x] Reduce build closure 133 + 134 + ## TODON'T 135 + 136 + - Reimplement `systemd-run` logic (will be moved to the new 137 + [`apply`](https://github.com/NixOS/nixpkgs/pull/344407) script)
+43 -15
pkgs/by-name/ni/nixos-rebuild-ng/package.nix
··· 1 { 2 lib, 3 installShellFiles, 4 nix, 5 nixos-rebuild, 6 python3, 7 withNgSuffix ? true, 8 }: 9 - python3.pkgs.buildPythonApplication { 10 pname = "nixos-rebuild-ng"; 11 version = "0.0.0"; 12 src = ./src; 13 pyproject = true; 14 15 - build-system = with python3.pkgs; [ 16 setuptools 17 ]; 18 19 - dependencies = with python3.pkgs; [ 20 tabulate 21 - types-tabulate 22 ]; 23 24 nativeBuildInputs = [ ··· 53 mv $out/bin/nixos-rebuild $out/bin/nixos-rebuild-ng 54 ''; 55 56 - nativeCheckInputs = with python3.pkgs; [ 57 pytestCheckHook 58 - mypy 59 - ruff 60 ]; 61 62 pytestFlagsArray = [ "-vv" ]; 63 64 - postCheck = '' 65 - echo -e "\x1b[32m## run mypy\x1b[0m" 66 - mypy nixos_rebuild tests 67 - echo -e "\x1b[32m## run ruff\x1b[0m" 68 - ruff check nixos_rebuild tests 69 - echo -e "\x1b[32m## run ruff format\x1b[0m" 70 - ruff format --check nixos_rebuild tests 71 - ''; 72 73 meta = { 74 description = "Rebuild your NixOS configuration and switch to it, on local hosts and remote";
··· 1 { 2 lib, 3 installShellFiles, 4 + mkShell, 5 nix, 6 nixos-rebuild, 7 python3, 8 + python3Packages, 9 + runCommand, 10 withNgSuffix ? true, 11 }: 12 + python3Packages.buildPythonApplication rec { 13 pname = "nixos-rebuild-ng"; 14 version = "0.0.0"; 15 src = ./src; 16 pyproject = true; 17 18 + build-system = with python3Packages; [ 19 setuptools 20 ]; 21 22 + dependencies = with python3Packages; [ 23 tabulate 24 ]; 25 26 nativeBuildInputs = [ ··· 55 mv $out/bin/nixos-rebuild $out/bin/nixos-rebuild-ng 56 ''; 57 58 + nativeCheckInputs = with python3Packages; [ 59 pytestCheckHook 60 ]; 61 62 pytestFlagsArray = [ "-vv" ]; 63 64 + passthru = 65 + let 66 + python-with-pkgs = python3.withPackages ( 67 + ps: with ps; [ 68 + mypy 69 + pytest 70 + ruff 71 + types-tabulate 72 + # dependencies 73 + tabulate 74 + ] 75 + ); 76 + in 77 + { 78 + devShell = mkShell { 79 + packages = [ python-with-pkgs ]; 80 + shellHook = '' 81 + cd pkgs/by-name/ni/nixos-rebuild-ng/src || true 82 + ''; 83 + }; 84 + 85 + # NOTE: this is a passthru test rather than a build-time test because we 86 + # want to keep the build closures small 87 + tests.ci = runCommand "${pname}-ci" { nativeBuildInputs = [ python-with-pkgs ]; } '' 88 + export RUFF_CACHE_DIR="$(mktemp -d)" 89 + 90 + echo -e "\x1b[32m## run mypy\x1b[0m" 91 + mypy ${src} 92 + echo -e "\x1b[32m## run ruff\x1b[0m" 93 + ruff check ${src} 94 + echo -e "\x1b[32m## run ruff format\x1b[0m" 95 + ruff format --check ${src} 96 + 97 + touch $out 98 + ''; 99 + }; 100 101 meta = { 102 description = "Rebuild your NixOS configuration and switch to it, on local hosts and remote";
+18 -7
pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py
··· 7 from subprocess import run 8 from typing import assert_never 9 10 - from tabulate import tabulate 11 - 12 from .models import Action, Flake, NRError, Profile 13 from .nix import ( 14 edit, 15 list_generations, 16 nixos_build, 17 nixos_build_flake, ··· 88 if args.upgrade or args.upgrade_all: 89 upgrade_channels(bool(args.upgrade_all)) 90 91 - match action := Action(args.action): 92 case Action.SWITCH | Action.BOOT: 93 info("building the system configuration...") 94 if args.rollback: ··· 177 if args.json: 178 print(json.dumps(generations, indent=2)) 179 else: 180 headers = { 181 "generation": "Generation", 182 "date": "Build-date", ··· 216 raise ex 217 else: 218 sys.exit(str(ex)) 219 - 220 - 221 - if __name__ == "__main__": 222 - main()
··· 7 from subprocess import run 8 from typing import assert_never 9 10 from .models import Action, Flake, NRError, Profile 11 from .nix import ( 12 edit, 13 + find_file, 14 + get_nixpkgs_rev, 15 list_generations, 16 nixos_build, 17 nixos_build_flake, ··· 88 if args.upgrade or args.upgrade_all: 89 upgrade_channels(bool(args.upgrade_all)) 90 91 + action = Action(args.action) 92 + # Only run shell scripts from the Nixpkgs tree if the action is 93 + # "switch", "boot", or "test". With other actions (such as "build"), 94 + # the user may reasonably expect that no code from the Nixpkgs tree is 95 + # executed, so it's safe to run nixos-rebuild against a potentially 96 + # untrusted tree. 97 + can_run = action in (Action.SWITCH, Action.BOOT, Action.TEST) 98 + if can_run and not flake: 99 + nixpkgs_path = find_file("nixpkgs", nix_flags) 100 + rev = get_nixpkgs_rev(nixpkgs_path) 101 + if nixpkgs_path and rev: 102 + (nixpkgs_path / ".version-suffix").write_text(rev) 103 + 104 + match action: 105 case Action.SWITCH | Action.BOOT: 106 info("building the system configuration...") 107 if args.rollback: ··· 190 if args.json: 191 print(json.dumps(generations, indent=2)) 192 else: 193 + from tabulate import tabulate 194 + 195 headers = { 196 "generation": "Generation", 197 "date": "Build-date", ··· 231 raise ex 232 else: 233 sys.exit(str(ex))
+4
pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__main__.py
···
··· 1 + from . import main 2 + 3 + if __name__ == "__main__": 4 + main()
+3 -8
pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/models.py
··· 46 class Flake: 47 path: Path 48 attr: str 49 - _re: ClassVar[re.Pattern[str]] = re.compile( 50 - r"^(?P<path>[^\#]*)\#?(?P<attr>[^\#\"]*)$" 51 - ) 52 53 @override 54 def __str__(self) -> str: ··· 59 m = cls._re.match(flake_str) 60 assert m is not None, f"got no matches for {flake_str}" 61 attr = m.group("attr") 62 - if not attr: 63 - attr = f"nixosConfigurations.{hostname or "default"}" 64 - else: 65 - attr = f"nixosConfigurations.{attr}" 66 - return Flake(Path(m.group("path")), attr) 67 68 @classmethod 69 def from_arg(cls, flake_arg: Any) -> Flake | None:
··· 46 class Flake: 47 path: Path 48 attr: str 49 + _re: ClassVar = re.compile(r"^(?P<path>[^\#]*)\#?(?P<attr>[^\#\"]*)$") 50 51 @override 52 def __str__(self) -> str: ··· 57 m = cls._re.match(flake_str) 58 assert m is not None, f"got no matches for {flake_str}" 59 attr = m.group("attr") 60 + nixos_attr = f"nixosConfigurations.{attr or hostname or "default"}" 61 + return Flake(Path(m.group("path")), nixos_attr) 62 63 @classmethod 64 def from_arg(cls, flake_arg: Any) -> Flake | None:
+45 -1
pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py
··· 1 from __future__ import annotations 2 3 import os 4 from datetime import datetime 5 from pathlib import Path 6 from subprocess import PIPE, CalledProcessError, run ··· 14 NRError, 15 Profile, 16 ) 17 - from .utils import dict_to_flags 18 19 FLAKE_FLAGS: Final = ["--extra-experimental-features", "nix-command flakes"] 20 ··· 46 run([os.getenv("EDITOR", "nano"), nixos_config], check=False) 47 else: 48 raise NRError("cannot find NixOS config file") 49 50 51 def _parse_generation_from_nix_store(path: Path, profile: Profile) -> Generation:
··· 1 from __future__ import annotations 2 3 import os 4 + import shutil 5 from datetime import datetime 6 from pathlib import Path 7 from subprocess import PIPE, CalledProcessError, run ··· 15 NRError, 16 Profile, 17 ) 18 + from .utils import dict_to_flags, info 19 20 FLAKE_FLAGS: Final = ["--extra-experimental-features", "nix-command flakes"] 21 ··· 47 run([os.getenv("EDITOR", "nano"), nixos_config], check=False) 48 else: 49 raise NRError("cannot find NixOS config file") 50 + 51 + 52 + def find_file(file: str, nix_flags: list[str] | None = None) -> Path | None: 53 + "Find classic Nixpkgs location." 54 + r = run( 55 + ["nix-instantiate", "--find-file", file, *(nix_flags or [])], 56 + stdout=PIPE, 57 + check=False, 58 + text=True, 59 + ) 60 + if r.returncode: 61 + return None 62 + return Path(r.stdout.strip()) 63 + 64 + 65 + def get_nixpkgs_rev(nixpkgs_path: Path | None) -> str | None: 66 + """Get Nixpkgs path as a Git revision. 67 + 68 + Can be used to generate `.version-suffix` file.""" 69 + if not nixpkgs_path: 70 + return None 71 + 72 + # Git is not included in the closure for nixos-rebuild so we need to check 73 + if not shutil.which("git"): 74 + info(f"warning: Git not found; cannot figure out revision of '{nixpkgs_path}'") 75 + return None 76 + 77 + # Get current revision 78 + r = run( 79 + ["git", "-C", nixpkgs_path, "rev-parse", "--short", "HEAD"], 80 + check=False, 81 + stdout=PIPE, 82 + text=True, 83 + ) 84 + rev = r.stdout.strip() 85 + 86 + if rev: 87 + # Check if repo is dirty 88 + if run(["git", "-C", nixpkgs_path, "diff", "--quiet"], check=False).returncode: 89 + rev += "M" 90 + return f".git.{rev}" 91 + else: 92 + return None 93 94 95 def _parse_generation_from_nix_store(path: Path, profile: Profile) -> Generation:
+31 -4
pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py
··· 67 68 69 @patch(get_qualified_name(nr.nix.run, nr.nix), autospec=True) 70 - def test_execute_nix_boot(mock_run: Any, tmp_path: Path) -> None: 71 config_path = tmp_path / "test" 72 config_path.touch() 73 mock_run.side_effect = [ 74 # nixos_build 75 CompletedProcess([], 0, str(config_path)), 76 # set_profile ··· 82 nr.execute(["nixos-rebuild", "boot", "--no-flake", "-vvv"]) 83 84 assert nr.VERBOSE is True 85 - assert mock_run.call_count == 3 86 mock_run.assert_has_calls( 87 [ 88 call( 89 [ 90 "nix-build", ··· 180 181 182 @patch(get_qualified_name(nr.nix.run, nr.nix), autospec=True) 183 - def test_execute_switch_rollback(mock_run: Any) -> None: 184 nr.execute(["nixos-rebuild", "switch", "--rollback", "--install-bootloader"]) 185 186 assert nr.VERBOSE is False 187 - assert mock_run.call_count == 2 188 mock_run.assert_has_calls( 189 [ 190 call(
··· 67 68 69 @patch(get_qualified_name(nr.nix.run, nr.nix), autospec=True) 70 + @patch(get_qualified_name(nr.nix.shutil.which), autospec=True, return_value="/bin/git") 71 + def test_execute_nix_boot(mock_which: Any, mock_run: Any, tmp_path: Path) -> None: 72 + nixpkgs_path = tmp_path / "nixpkgs" 73 + nixpkgs_path.mkdir() 74 config_path = tmp_path / "test" 75 config_path.touch() 76 mock_run.side_effect = [ 77 + # update_nixpkgs_rev 78 + CompletedProcess([], 0, str(nixpkgs_path)), 79 + CompletedProcess([], 0, "nixpkgs-rev"), 80 + CompletedProcess([], 0), 81 # nixos_build 82 CompletedProcess([], 0, str(config_path)), 83 # set_profile ··· 89 nr.execute(["nixos-rebuild", "boot", "--no-flake", "-vvv"]) 90 91 assert nr.VERBOSE is True 92 + assert mock_run.call_count == 6 93 mock_run.assert_has_calls( 94 [ 95 + call( 96 + ["nix-instantiate", "--find-file", "nixpkgs", "-vvv"], 97 + stdout=PIPE, 98 + check=False, 99 + text=True, 100 + ), 101 + call( 102 + ["git", "-C", nixpkgs_path, "rev-parse", "--short", "HEAD"], 103 + check=False, 104 + stdout=PIPE, 105 + text=True, 106 + ), 107 + call( 108 + ["git", "-C", nixpkgs_path, "diff", "--quiet"], 109 + check=False, 110 + ), 111 call( 112 [ 113 "nix-build", ··· 203 204 205 @patch(get_qualified_name(nr.nix.run, nr.nix), autospec=True) 206 + def test_execute_switch_rollback(mock_run: Any, tmp_path: Path) -> None: 207 + nixpkgs_path = tmp_path / "nixpkgs" 208 + nixpkgs_path.touch() 209 + 210 nr.execute(["nixos-rebuild", "switch", "--rollback", "--install-bootloader"]) 211 212 assert nr.VERBOSE is False 213 + assert mock_run.call_count == 3 214 + # ignoring update_nixpkgs_rev calls 215 mock_run.assert_has_calls( 216 [ 217 call(
+55
pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py
··· 42 mock_run.assert_called_with(["editor", default_nix], check=False) 43 44 45 def test_get_generations_from_nix_store(tmp_path: Path) -> None: 46 nixos_path = tmp_path / "nixos-system" 47 nixos_path.mkdir()
··· 42 mock_run.assert_called_with(["editor", default_nix], check=False) 43 44 45 + @patch(get_qualified_name(n.shutil.which), autospec=True, return_value="/bin/git") 46 + def test_get_nixpkgs_rev(mock_which: Any) -> None: 47 + assert n.get_nixpkgs_rev(None) is None 48 + 49 + path = Path("/path/to/nix") 50 + 51 + with patch( 52 + get_qualified_name(n.run, n), 53 + autospec=True, 54 + side_effect=[CompletedProcess([], 0, "")], 55 + ) as mock_run: 56 + assert n.get_nixpkgs_rev(path) is None 57 + mock_run.assert_called_with( 58 + ["git", "-C", path, "rev-parse", "--short", "HEAD"], 59 + check=False, 60 + stdout=PIPE, 61 + text=True, 62 + ) 63 + 64 + expected_calls = [ 65 + call( 66 + ["git", "-C", path, "rev-parse", "--short", "HEAD"], 67 + check=False, 68 + stdout=PIPE, 69 + text=True, 70 + ), 71 + call( 72 + ["git", "-C", path, "diff", "--quiet"], 73 + check=False, 74 + ), 75 + ] 76 + 77 + with patch( 78 + get_qualified_name(n.run, n), 79 + autospec=True, 80 + side_effect=[ 81 + CompletedProcess([], 0, "0f7c82403fd6"), 82 + CompletedProcess([], returncode=0), 83 + ], 84 + ) as mock_run: 85 + assert n.get_nixpkgs_rev(path) == ".git.0f7c82403fd6" 86 + mock_run.assert_has_calls(expected_calls) 87 + 88 + with patch( 89 + get_qualified_name(n.run, n), 90 + autospec=True, 91 + side_effect=[ 92 + CompletedProcess([], 0, "0f7c82403fd6"), 93 + CompletedProcess([], returncode=1), 94 + ], 95 + ) as mock_run: 96 + assert n.get_nixpkgs_rev(path) == ".git.0f7c82403fd6M" 97 + mock_run.assert_has_calls(expected_calls) 98 + 99 + 100 def test_get_generations_from_nix_store(tmp_path: Path) -> None: 101 nixos_path = tmp_path / "nixos-system" 102 nixos_path.mkdir()