diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/nixos_rebuild/__init__.py b/pkgs/by-name/ni/nixos-rebuild-ng/nixos_rebuild/__init__.py new file mode 100644 index 0000000000000..ba827586e32e6 --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/nixos_rebuild/__init__.py @@ -0,0 +1,152 @@ +import argparse +import sys +from pathlib import Path +from textwrap import dedent +from typing import assert_never + +from .models import Action, Flake +from .nix import ( + edit, + nix_build, + nix_flake_build, + nix_set_profile, + nix_switch_to_configuration, +) +from .process import run_exec +from .utils import info + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="nixos-rebuild", + description="Reconfigure a NixOS machine", + add_help=False, + ) + parser.add_argument("--help", action="store_true") + parser.add_argument("--file", "-f") + parser.add_argument("--attr", "-A") + parser.add_argument("--flake", nargs="?", const=True) + parser.add_argument("--no-flake", dest="flake", action="store_false") + parser.add_argument("--install-bootloader", action="store_true") + # TODO: add deprecated=True in Python >=3.13 + parser.add_argument("--install-grub", action="store_true") + parser.add_argument("--profile-name", default="system") + parser.add_argument("action", choices=Action.values(), nargs="?") + r = parser.parse_args(argv[1:]) + + # https://github.com/NixOS/nixpkgs/blob/master/pkgs/os-specific/linux/nixos-rebuild/nixos-rebuild.sh#L56 + if r.action == Action.DRY_RUN.value: + r.action = Action.DRY_BUILD.value + + if r.install_grub: + info(f"{argv[0]}: --install-grub deprecated, use --install-bootloader instead") + r.install_bootloader = True + + if r.flake and (r.file or r.attr): + sys.exit("error: '--flake' cannot be used with '--file' or '--attr'") + + return r + + +def run(argv: list[str]) -> None: + args = parse_args(argv) + if args.help or args.action is None: + run_exec(["man", "8", "nixos-rebuild"]) + + if args.profile_name == "system": + profile = Path("/nix/var/nix/profiles/system") + else: + profile = Path("/nix/var/nix/profiles/system-profiles") / args.profile_name + profile.parent.mkdir(mode=0o755, parents=True, exist_ok=True) + + flake = Flake.from_arg(args.flake) + + match action := Action(args.action): + case ( + Action.SWITCH + | Action.BOOT + | Action.TEST + | Action.BUILD + | Action.DRY_BUILD + | Action.DRY_ACTIVATE + ): + set_profile = action in (Action.SWITCH, Action.BOOT) + switch_to_configuration = action in ( + Action.SWITCH, + Action.BOOT, + Action.TEST, + Action.DRY_ACTIVATE, + ) + no_link = action in (Action.SWITCH, Action.BOOT) + keep_going = action in ( + Action.TEST, + Action.BUILD, + Action.DRY_BUILD, + Action.DRY_ACTIVATE, + ) + dry_run = action == Action.DRY_BUILD + info("building the system configuration...") + if flake: + path_to_config = nix_flake_build( + "config.system.build.toplevel", + flake, + no_link=no_link, + keep_going=keep_going, + dry_run=dry_run, + ) + else: + path_to_config = nix_build( + "system", + args.attr, + args.file, + no_out_link=no_link, + keep_going=keep_going, + dry_run=dry_run, + ) + if set_profile: + nix_set_profile(profile, path_to_config) + if switch_to_configuration: + nix_switch_to_configuration( + path_to_config, action, args.install_bootloader + ) + case Action.BUILD_VM | Action.BUILD_VM_WITH_BOOTLOADER: + info("building the system configuration...") + attr = "vm" if action == Action.BUILD_VM else "vmWithBootLoader" + if flake: + path_to_config = nix_flake_build( + f"config.system.build.{attr}", + flake, + ) + else: + path_to_config = nix_build( + attr, + args.attr, + args.file, + keep_going=True, + ) + print( + dedent(f""" + Done. The virtual machine can be started by running {next(path_to_config.glob("bin/run-*-vm"))} + """) + ) + case Action.EDIT: + if args.file or args.attr: + sys.exit("error: '--file' and '--attr' are not supported with 'edit'") + edit(flake) + case Action.DRY_RUN: + assert False, "DRY_RUN should be a DRY_BUILD alias" + case Action.REPL | Action.LIST_GENERATIONS: + raise NotImplementedError(action) + case _: + assert_never(action) + + +def main() -> None: + try: + run(sys.argv) + except KeyboardInterrupt: + sys.exit(130) + + +if __name__ == "__main__": + main() diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/nixos_rebuild/models.py b/pkgs/by-name/ni/nixos-rebuild-ng/nixos_rebuild/models.py new file mode 100644 index 0000000000000..af815ecd08e55 --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/nixos_rebuild/models.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import platform +import re +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Any + + +class Action(Enum): + SWITCH = "switch" + BOOT = "boot" + TEST = "test" + BUILD = "build" + EDIT = "edit" + REPL = "repl" + DRY_BUILD = "dry-build" + DRY_RUN = "dry-run" + DRY_ACTIVATE = "dry-activate" + BUILD_VM = "build-vm" + BUILD_VM_WITH_BOOTLOADER = "build-vm-with-bootloader" + LIST_GENERATIONS = "list-generations" + + def __str__(self) -> str: + return self.value + + @staticmethod + def values() -> list[str]: + return [a.value for a in Action] + + +@dataclass +class Flake: + path: Path + attr: str + + def __str__(self) -> str: + return f"{self.path}#{self.attr}" + + @staticmethod + def parse(flake_str: str) -> Flake: + m = re.match(r"^(?P[^\#]*)\#?(?P[^\#\"]*)$", flake_str) + assert m is not None, "match is None" + attr = m.group("attr") + if not attr: + hostname = platform.node() or "default" + attr = f"nixosConfigurations.{hostname}" + else: + attr = f"nixosConfigurations.{attr}" + return Flake(Path(m.group("path")), attr) + + @staticmethod + def from_arg(flake_arg: Any) -> Flake | None: + match flake_arg: + case str(s): + return Flake.parse(s) + case True: + return Flake.parse(".") + case False: + return None + case _: + # Use /etc/nixos/flake.nix if it exists. + default_path = Path("/etc/nixos/flake.nix") + if default_path.exists(): + # It can be a symlink to the actual flake. + if default_path.is_symlink(): + default_path = default_path.readlink() + return Flake.parse(str(default_path.parent)) + else: + return None diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/nixos_rebuild/nix.py b/pkgs/by-name/ni/nixos-rebuild-ng/nixos_rebuild/nix.py new file mode 100644 index 0000000000000..4f1ad47d679d6 --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/nixos_rebuild/nix.py @@ -0,0 +1,78 @@ +import os +import sys +from pathlib import Path +from typing import Final + +from .models import Action, Flake +from .process import run_capture, run_cmd, run_exec +from .utils import kwargs_to_flags + +FLAKE_FLAGS: Final = ["--extra-experimental-features", "nix-command flakes"] + + +def edit(flake: Flake | None) -> None: + if flake: + # TODO: lockFlags + run_exec(["nix", *FLAKE_FLAGS, "edit", "--", str(flake)]) + else: + nixos_config = Path( + os.environ.get("NIXOS_CONFIG") + or run_capture(["nix-instantiate", "--find-file", "nixos-config"]) + or "/etc/nixos/default.nix" + ) + if nixos_config.is_dir(): + nixos_config /= "default.nix" + + if nixos_config.exists(): + run_exec([os.environ.get("EDITOR", "nano"), str(nixos_config)]) + else: + sys.exit("warning: cannot find NixOS config file") + + +def nix_build( + attr: str, + pre_attr: str | None, + file: str | None, + **kwargs: bool | str, +) -> Path: + if pre_attr or file: + run_args = [ + "nix-build", + file or "default.nix", + "--attr", + f"{'.'.join([x for x in [pre_attr, attr] if x])}", + ] + else: + run_args = ["nix-build", "", "--attr", attr] + run_args = kwargs_to_flags(run_args, **kwargs) + return Path(run_capture(run_args).strip()) + + +def nix_flake_build(attr: str, flake: Flake, **kwargs: bool | str) -> Path: + run_args = [ + "nix", + *FLAKE_FLAGS, + "build", + "--print-out-paths", + f"{flake}.{attr}", + ] + run_args = kwargs_to_flags(run_args, **kwargs) + return Path(run_capture(run_args).strip()) + + +def nix_set_profile(profile: Path, path_to_config: Path) -> None: + run_cmd(["nix-env", "-p", str(profile), "--set", str(path_to_config)]) + + +def nix_switch_to_configuration( + path_to_config: Path, + action: Action, + install_bootloader: bool = False, +) -> None: + run_cmd( + [str(path_to_config / "bin/switch-to-configuration"), str(action)], + env={ + "NIXOS_INSTALL_BOOTLOADER": "1" if install_bootloader else "0", + "LOCALE_ARCHIVE": os.environ.get("LOCALE_ARCHIVE", ""), + }, + ) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/nixos_rebuild/process.py b/pkgs/by-name/ni/nixos-rebuild-ng/nixos_rebuild/process.py new file mode 100644 index 0000000000000..ed19a9e20097a --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/nixos_rebuild/process.py @@ -0,0 +1,30 @@ +import subprocess +import sys +from typing import Any + + +def run_cmd( + args: list[str], + check: bool = True, + **kwargs: Any, +) -> subprocess.CompletedProcess[str]: + r = subprocess.run(args, text=True, **kwargs) + + if check: + try: + r.check_returncode() + except subprocess.CalledProcessError as ex: + sys.exit(str(ex)) + + return r + + +def run_capture(args: list[str]) -> str: + r = run_cmd(args, stdout=subprocess.PIPE) + return r.stdout + + +def run_exec(args: list[str]) -> None: + # We will exit anyway, so ignore the check here + r = run_cmd(args, check=False) + sys.exit(r.returncode) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/nixos_rebuild/utils.py b/pkgs/by-name/ni/nixos-rebuild-ng/nixos_rebuild/utils.py new file mode 100644 index 0000000000000..facd7f3f4d9e7 --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/nixos_rebuild/utils.py @@ -0,0 +1,16 @@ +import sys +from functools import partial + +info = partial(print, file=sys.stderr) + + +def kwargs_to_flags(flags: list[str], **kwargs: bool | str) -> list[str]: + for k, v in kwargs.items(): + f = f"--{'-'.join(k.split('_'))}" + match v: + case True: + flags.append(f) + case str(): + flags.append(f) + flags.append(v) + return flags diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/package.nix b/pkgs/by-name/ni/nixos-rebuild-ng/package.nix new file mode 100644 index 0000000000000..3791aa47283cc --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/package.nix @@ -0,0 +1,60 @@ +{ + lib, + installShellFiles, + nix, + nixos-rebuild, + python3Packages, +}: +let + fallback = import ./../../../../nixos/modules/installer/tools/nix-fallback-paths.nix; + fs = lib.fileset; +in +python3Packages.buildPythonApplication { + pname = "nixos-rebuild-ng"; + version = "0.1"; + src = fs.toSource { + root = ./.; + fileset = fs.unions [ + ./nixos_rebuild + ./pyproject.toml + ./tests + ]; + }; + pyproject = true; + + nativeBuildInputs = [ + installShellFiles + python3Packages.setuptools + ]; + + postInstall = '' + installManPage ${nixos-rebuild}/share/man/man8/nixos-rebuild.8 + + installShellCompletion \ + --bash ${nixos-rebuild}/share/bash-completion/completions/_nixos-rebuild + ''; + + doCheck = true; + nativeCheckInputs = with python3Packages; [ + pytestCheckHook + mypy + ruff + black + ]; + postCheck = '' + echo -e "\x1b[32m## run mypy\x1b[0m" + mypy --strict nixos_rebuild + echo -e "\x1b[32m## run ruff\x1b[0m" + ruff check . + echo -e "\x1b[32m## run ruff format\x1b[0m" + ruff format --check . + ''; + + meta = { + description = "Rebuild your NixOS configuration and switch to it, on local hosts and remote"; + homepage = "https://github.com/NixOS/nixpkgs/tree/master/pkgs/os-specific/linux/nixos-rebuild"; + license = lib.licenses.mit; + maintainers = [ lib.maintainers.thiagokokada ]; + mainProgram = "nixos-rebuild"; + }; +} diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/pyproject.toml b/pkgs/by-name/ni/nixos-rebuild-ng/pyproject.toml new file mode 100644 index 0000000000000..812a633d1d5ee --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/pyproject.toml @@ -0,0 +1,10 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "nixos-rebuild-ng" +version = "0.0.0" + +[project.scripts] +nixos-rebuild = "nixos_rebuild:main" diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/tests/test_main.py b/pkgs/by-name/ni/nixos-rebuild-ng/tests/test_main.py new file mode 100644 index 0000000000000..a5f1b5bbcf590 --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/tests/test_main.py @@ -0,0 +1,39 @@ +import pytest + +import nixos_rebuild as nr + + +def test_parse_args(): + with pytest.raises(SystemExit) as e: + nr.parse_args(["nixos-rebuild", "unknown-action"]) + assert e.value.code == 2 + + with pytest.raises(SystemExit) as e: + nr.parse_args(["nixos-rebuild", "test", "--flake", "--file", "abc"]) + assert e.value.code == "error: '--flake' cannot be used with '--file' or '--attr'" + + r1 = nr.parse_args( + ["nixos-rebuild", "switch", "--install-grub", "--flake", "/etc/nixos"] + ) + assert r1.flake == "/etc/nixos" + assert r1.install_bootloader is True + assert r1.install_grub is True + assert r1.profile_name == "system" + assert r1.action == "switch" + + r2 = nr.parse_args( + [ + "nixos-rebuild", + "dry-run", + "--flake", + "--no-flake", + "-f", + "foo", + "--attr", + "bar", + ] + ) + assert r2.flake is False + assert r2.action == "dry-build" + assert r2.file == "foo" + assert r2.attr == "bar" diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/tests/test_models.py b/pkgs/by-name/ni/nixos-rebuild-ng/tests/test_models.py new file mode 100644 index 0000000000000..d5b012ab851f1 --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/tests/test_models.py @@ -0,0 +1,63 @@ +import platform +from pathlib import Path + +from nixos_rebuild import models as m + + +def test_flake_parse(monkeypatch): + monkeypatch.setattr(platform, "node", lambda: "hostname") + + assert m.Flake.parse("/path/to/flake#attr") == m.Flake( + Path("/path/to/flake"), "nixosConfigurations.attr" + ) + assert m.Flake.parse("/path/ to /flake") == m.Flake( + Path("/path/ to /flake"), "nixosConfigurations.hostname" + ) + assert m.Flake.parse("/path/to/flake") == m.Flake( + Path("/path/to/flake"), "nixosConfigurations.hostname" + ) + assert m.Flake.parse(".#attr") == m.Flake(Path("."), "nixosConfigurations.attr") + assert m.Flake.parse("#attr") == m.Flake(Path("."), "nixosConfigurations.attr") + assert m.Flake.parse(".") == m.Flake(Path("."), "nixosConfigurations.hostname") + assert m.Flake.parse("") == m.Flake(Path("."), "nixosConfigurations.hostname") + + +def test_flake_from_arg(monkeypatch): + # Flake string + assert m.Flake.from_arg("/path/to/flake#attr") == m.Flake( + Path("/path/to/flake"), "nixosConfigurations.attr" + ) + + # False + assert m.Flake.from_arg(False) is None + + # True + with monkeypatch.context() as mp: + mp.setattr(platform, "node", lambda: "hostname") + assert m.Flake.from_arg(True) == m.Flake( + Path("."), "nixosConfigurations.hostname" + ) + + # None when we do not have /etc/nixos/flake.nix + with monkeypatch.context() as mp: + mp.setattr(Path, "exists", lambda self: False) + assert m.Flake.from_arg(None) is None + + # None when we have a file in /etc/nixos/flake.nix + with monkeypatch.context() as mp: + mp.setattr(platform, "node", lambda: "hostname") + mp.setattr(Path, "exists", lambda self: True) + mp.setattr(Path, "is_symlink", lambda self: False) + assert m.Flake.from_arg(None) == m.Flake( + Path("/etc/nixos"), "nixosConfigurations.hostname" + ) + + # None when we have a file in /etc/nixos/flake.nix and it is a symlink + with monkeypatch.context() as mp: + mp.setattr(platform, "node", lambda: "hostname") + mp.setattr(Path, "exists", lambda self: True) + mp.setattr(Path, "is_symlink", lambda self: True) + mp.setattr(Path, "readlink", lambda self: Path("/path/to/flake.nix")) + assert m.Flake.from_arg(None) == m.Flake( + Path("/path/to"), "nixosConfigurations.hostname" + ) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/tests/test_nix.py b/pkgs/by-name/ni/nixos-rebuild-ng/tests/test_nix.py new file mode 100644 index 0000000000000..59451319855c8 --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/tests/test_nix.py @@ -0,0 +1,87 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import nixos_rebuild.nix as n +from nixos_rebuild import models as m + + +def test_edit(tmpdir, monkeypatch): + mock = MagicMock() + monkeypatch.setattr(n, "run_exec", mock) + + # Flake + flake = m.Flake.parse(".#attr") + n.edit(flake) + mock.assert_called_with( + [ + "nix", + "--extra-experimental-features", + "nix-command flakes", + "edit", + "--", + ".#nixosConfigurations.attr", + ] + ) + + # Legacy + # TODO: there are more cases to cover, but this should be sufficient to + # test the happy path + with monkeypatch.context() as mp: + default_nix = tmpdir.join("default.nix") + default_nix.write("{}") + + mp.setenv("NIXOS_CONFIG", str(tmpdir)) + mp.setenv("EDITOR", "editor") + + n.edit(None) + mock.assert_called_with(["editor", str(default_nix)]) + + +def test_nix_flake_build(monkeypatch): + mock = MagicMock(return_value=" \n/path/to/file\n ") + monkeypatch.setattr(n, "run_capture", mock) + flake = m.Flake.parse(".#attr") + + r = n.nix_flake_build("otherAttr", flake, no_link=True) + mock.assert_called_with( + [ + "nix", + "--extra-experimental-features", + "nix-command flakes", + "build", + "--print-out-paths", + ".#nixosConfigurations.attr.otherAttr", + "--no-link", + ] + ) + assert r == Path("/path/to/file") + + +def test_nix_build(monkeypatch): + mock = MagicMock(return_value=" \n/path/to/file\n ") + monkeypatch.setattr(n, "run_capture", mock) + + r = n.nix_build("attr", None, None) + assert r == Path("/path/to/file") + mock.assert_called_with(["nix-build", "", "--attr", "attr"]) + + n.nix_build("attr", "preAttr", "file") + mock.assert_called_with(["nix-build", "file", "--attr", "preAttr.attr"]) + + n.nix_build("attr", None, "file", no_out_link=True) + mock.assert_called_with(["nix-build", "file", "--attr", "attr", "--no-out-link"]) + + n.nix_build("attr", "preAttr", None, no_out_link=False, keep_going=True) + mock.assert_called_with( + ["nix-build", "default.nix", "--attr", "preAttr.attr", "--keep-going"] + ) + + +def test_nix_assert_profile(monkeypatch): + mock = MagicMock() + monkeypatch.setattr(n, "run_cmd", mock) + + n.nix_set_profile(Path("/path/to/profile"), Path("/path/to/config")) + mock.assert_called_with( + ["nix-env", "-p", "/path/to/profile", "--set", "/path/to/config"] + ) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/tests/test_process.py b/pkgs/by-name/ni/nixos-rebuild-ng/tests/test_process.py new file mode 100644 index 0000000000000..666b6d6562232 --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/tests/test_process.py @@ -0,0 +1,25 @@ +import pytest +from nixos_rebuild import process as p + + +def test_run_cmd(): + r = p.run_cmd(["echo", "hi"]) + assert r.returncode == 0 + + with pytest.raises(SystemExit) as e: + p.run_cmd(["sh", "-c", "exit 123"], check=True) + assert ( + e.value.code + == "Command '['sh', '-c', 'exit 123']' returned non-zero exit status 123." + ) + + +def test_run_capture(): + r = p.run_capture(["echo", "hi"]) + assert r == "hi\n" + + +def test_run_exec(): + with pytest.raises(SystemExit) as e: + p.run_exec(["echo", "hi"]) + assert e.value.code == 0 diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/tests/test_utils.py b/pkgs/by-name/ni/nixos-rebuild-ng/tests/test_utils.py new file mode 100644 index 0000000000000..aea439e76546f --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/tests/test_utils.py @@ -0,0 +1,6 @@ +from nixos_rebuild import utils as u + + +def test_kwargs_to_flags(): + r = u.kwargs_to_flags([], test_flag_1=True, test_flag_2=False, test_flag_3="value") + assert r == ["--test-flag-1", "--test-flag-3", "value"]