From 9e6ece3ee14c57a624501575ab6e8eeda0e56e06 Mon Sep 17 00:00:00 2001 From: Thiago Kenji Okada Date: Mon, 4 Nov 2024 22:23:33 +0000 Subject: [PATCH] nixos-rebuild-ng: init --- .github/labeler.yml | 1 + ci/OWNERS | 2 + pkgs/by-name/ni/nixos-rebuild-ng/README.md | 99 ++++++ pkgs/by-name/ni/nixos-rebuild-ng/package.nix | 80 +++++ .../src/nixos_rebuild/__init__.py | 222 ++++++++++++ .../src/nixos_rebuild/models.py | 121 +++++++ .../nixos-rebuild-ng/src/nixos_rebuild/nix.py | 282 +++++++++++++++ .../src/nixos_rebuild/utils.py | 28 ++ .../ni/nixos-rebuild-ng/src/pyproject.toml | 51 +++ .../ni/nixos-rebuild-ng/src/tests/__init__.py | 0 .../ni/nixos-rebuild-ng/src/tests/helpers.py | 13 + .../nixos-rebuild-ng/src/tests/test_main.py | 270 +++++++++++++++ .../nixos-rebuild-ng/src/tests/test_models.py | 100 ++++++ .../ni/nixos-rebuild-ng/src/tests/test_nix.py | 327 ++++++++++++++++++ .../nixos-rebuild-ng/src/tests/test_utils.py | 23 ++ 15 files changed, 1619 insertions(+) create mode 100644 pkgs/by-name/ni/nixos-rebuild-ng/README.md create mode 100644 pkgs/by-name/ni/nixos-rebuild-ng/package.nix create mode 100644 pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py create mode 100644 pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/models.py create mode 100644 pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py create mode 100644 pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/utils.py create mode 100644 pkgs/by-name/ni/nixos-rebuild-ng/src/pyproject.toml create mode 100644 pkgs/by-name/ni/nixos-rebuild-ng/src/tests/__init__.py create mode 100644 pkgs/by-name/ni/nixos-rebuild-ng/src/tests/helpers.py create mode 100644 pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py create mode 100644 pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_models.py create mode 100644 pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py create mode 100644 pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_utils.py diff --git a/.github/labeler.yml b/.github/labeler.yml index 9b4ae79ce188..d750cecdbe33 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -293,6 +293,7 @@ - any-glob-to-any-file: - nixos/**/* - pkgs/by-name/sw/switch-to-configuration-ng/**/* + - pkgs/by-name/ni/nixos-rebuild-ng/**/* - pkgs/os-specific/linux/nixos-rebuild/**/* "6.topic: nixos-container": diff --git a/ci/OWNERS b/ci/OWNERS index 4bb5e09c7f3a..979442de61be 100644 --- a/ci/OWNERS +++ b/ci/OWNERS @@ -143,6 +143,8 @@ nixos/modules/installer/tools/nix-fallback-paths.nix @NixOS/nix-team @raitobeza /nixos/tests/amazon-ssm-agent.nix @arianvp /nixos/modules/system/boot/grow-partition.nix @arianvp +# nixos-rebuild-ng +/pkgs/by-name/ni/nixos-rebuild-ng @thiagokokada # Updaters diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/README.md b/pkgs/by-name/ni/nixos-rebuild-ng/README.md new file mode 100644 index 000000000000..cc2419844ef8 --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/README.md @@ -0,0 +1,99 @@ +# nixos-rebuild-ng + +Work-in-Progress rewrite of +[`nixos-rebuild`](https://github.com/NixOS/nixpkgs/blob/master/pkgs/os-specific/linux/nixos-rebuild/nixos-rebuild.sh). + +## Why the rewrite? + +The current state of `nixos-rebuild` is dare: it is one of the most critical +piece of code we have in NixOS, but it has tons of issues: +- The code is written in Bash, and while this by itself is not necessary bad, + it means that it is difficult to do refactorings due to the lack of tooling + for the language +- The code itself is a hacky mess. Changing even one line of code can cause + issues that affects dozens of people +- Lack of proper testing (we do have some integration tests, but no unit tests + and coverage is probably pitiful) +- The code predates some of the improvements `nix` had over the years, e.g.: it + builds Flakes inside a temporary directory and read the resulting symlink + since the code seems to predate `--print-out-paths` flag + +Given all of those above, improvements in the `nixos-rebuild` are difficult to +do. A full rewrite is probably the easier way to improve the situation since +this can be done in a separate package that will not break anyone. So this is +an attempt of the rewrite. + +## Why Python? + +- It is the language of choice for many critical things inside `nixpkgs`, like + the `NixOSTest` and `systemd-boot-builder.py` activation scripts +- It is a language with great tooling, e.g.: `mypy` for type checking, `ruff` + for linting, `pytest` for unit testing +- It is a scripting language that fits well with the scope of this project +- Python's standard library is great and it means we will need a low number of + external dependencies for this project. For example, `nixos-rebuild` + currently depends in `jq` for JSON parsing, while Python has `json` in + standard library + +## Do's and Don'ts + +- Do: be as much of a drop-in replacement as possible +- Do: fix obvious bugs +- Do: improvements that are non-breaking +- Don't: change logic in breaking ways even if this would be an improvement + +## How to use + +```nix +{ pkgs, ... }: +{ + environment.systemPackages = [ pkgs.nixos-rebuild-ng ]; +} +``` + +And use `nixos-rebuild-ng` instead of `nixos-rebuild`. + +## Current caveats + +- For now we will install it in `nixos-rebuild-ng` path by default, to avoid + conflicting with the current `nixos-rebuild`. This means you can keep both in + your system at the same time, but it also means that a few things like bash + completion are broken right now (since it looks at `nixos-rebuild` binary) +- `_NIXOS_REBUILD_EXEC` is **not** implemented yet, so different from + `nixos-rebuild`, this will use the current version of `nixos-rebuild-ng` in + your `PATH` to build/set profile/switch, while `nixos-rebuild` builds the new + version (the one that will be switched) and re-exec to it instead. This means + that in case of bugs in `nixos-rebuild-ng`, the only way that you will get + them fixed is **after** you switch to a new version +- `nix` bootstrap is also **not** implemented yet, so this means that you will + eval with an old version of Nix instead of a newer one. This is unlikely to + cause issues, because the build will happen in the daemon anyway (that is + only changed after the switch), and unless you are using bleeding edge `nix` + features you will probably have zero problems here. You can basically think + that using `nixos-rebuild-ng` is similar to running `nixos-rebuild --fast` + right now +- Ignore any performance advantages of the rewrite right now, because of the 2 + caveats above +- `--target-host` and `--build-host` are not implemented yet and this is + probably the thing that will be most difficult to implement. Help here is + welcome +- Bugs in the profile manipulation can cause corruption of your profile that + may be difficult to fix, so right now I only recommend using + `nixos-rebuild-ng` if you are testing in a VM or in a filesystem with + snapshots like btrfs or ZFS. Those bugs are unlikely to be unfixable but the + errors can be difficult to understand. If you want to go anyway, + `nix-collect-garbage -d` and `nix store repair` are your friends + +## TODO + +- [ ] Remote host/builders (via SSH) +- [ ] Improve nix arguments handling (e.g.: `nixFlags` vs `copyFlags` in the + old `nixos-rebuild`) +- [ ] `_NIXOS_REBUILD_EXEC` +- [ ] Port `nixos-rebuild.passthru.tests` +- [ ] Change module system to allow easier opt-in, like + `system.switch.enableNg` for `switch-to-configuration-ng` +- [ ] Improve documentation +- [ ] `nixos-rebuild repl` (calling old `nixos-rebuild` for now) +- [ ] `nix` build/bootstrap +- [ ] Reduce build closure 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 000000000000..b97387c9cc5f --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/package.nix @@ -0,0 +1,80 @@ +{ + lib, + installShellFiles, + nix, + nixos-rebuild, + python3, + withNgSuffix ? true, +}: +python3.pkgs.buildPythonApplication { + pname = "nixos-rebuild-ng"; + version = "0.0.0"; + src = ./src; + pyproject = true; + + build-system = with python3.pkgs; [ + setuptools + ]; + + dependencies = with python3.pkgs; [ + tabulate + types-tabulate + ]; + + nativeBuildInputs = [ + installShellFiles + ]; + + propagatedBuildInputs = [ + # Make sure that we use the Nix package we depend on, not something + # else from the PATH for nix-{env,instantiate,build}. This is + # important, because NixOS defaults the architecture of the rebuilt + # system to the architecture of the nix-* binaries used. So if on an + # amd64 system the user has an i686 Nix package in her PATH, then we + # would silently downgrade the whole system to be i686 NixOS on the + # next reboot. + # The binary will be included in the wrapper for Python. + nix + ]; + + preBuild = '' + substituteInPlace nixos_rebuild/__init__.py \ + --subst-var-by nixos_rebuild ${lib.getExe nixos-rebuild} + ''; + + postInstall = + '' + installManPage ${nixos-rebuild}/share/man/man8/nixos-rebuild.8 + + installShellCompletion \ + --bash ${nixos-rebuild}/share/bash-completion/completions/_nixos-rebuild + '' + + lib.optionalString withNgSuffix '' + mv $out/bin/nixos-rebuild $out/bin/nixos-rebuild-ng + ''; + + nativeCheckInputs = with python3.pkgs; [ + pytestCheckHook + mypy + ruff + ]; + + pytestFlagsArray = [ "-vv" ]; + + postCheck = '' + echo -e "\x1b[32m## run mypy\x1b[0m" + mypy nixos_rebuild tests + echo -e "\x1b[32m## run ruff\x1b[0m" + ruff check nixos_rebuild tests + echo -e "\x1b[32m## run ruff format\x1b[0m" + ruff format --check nixos_rebuild tests + ''; + + meta = { + description = "Rebuild your NixOS configuration and switch to it, on local hosts and remote"; + homepage = "https://github.com/NixOS/nixpkgs/tree/master/pkgs/by-name/ni/nixos-rebuild-ng"; + license = lib.licenses.mit; + maintainers = [ lib.maintainers.thiagokokada ]; + mainProgram = if withNgSuffix then "nixos-rebuild-ng" else "nixos-rebuild"; + }; +} 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 new file mode 100644 index 000000000000..e3e198292c42 --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import argparse +import json +import os +import sys +from subprocess import run +from typing import assert_never + +from tabulate import tabulate + +from .models import Action, Flake, NRError, Profile +from .nix import ( + edit, + list_generations, + nixos_build, + nixos_build_flake, + rollback, + rollback_temporary_profile, + set_profile, + switch_to_configuration, + upgrade_channels, +) +from .utils import info + +VERBOSE = False + + +def parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]: + parser = argparse.ArgumentParser( + prog="nixos-rebuild", + description="Reconfigure a NixOS machine", + add_help=False, + allow_abbrev=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", "-p", default="system") + parser.add_argument("--specialisation", "-c") + parser.add_argument("--rollback", action="store_true") + parser.add_argument("--upgrade", action="store_true") + parser.add_argument("--upgrade-all", action="store_true") + parser.add_argument("--json", action="store_true") + parser.add_argument("action", choices=Action.values(), nargs="?") + + args, remainder = parser.parse_known_args(argv[1:]) + + global VERBOSE + # Manually parse verbose flag since this is a nix flag that also affect + # the script + VERBOSE = any(v == "--verbose" or v.startswith("-v") for v in remainder) + + # https://github.com/NixOS/nixpkgs/blob/master/pkgs/os-specific/linux/nixos-rebuild/nixos-rebuild.sh#L56 + if args.action == Action.DRY_RUN.value: + args.action = Action.DRY_BUILD.value + + if args.install_grub: + info( + f"{parser.prog}: warning: --install-grub deprecated, use --install-bootloader instead" + ) + args.install_bootloader = True + + if args.action == Action.EDIT.value and (args.file or args.attr): + parser.error("--file and --attr are not supported with 'edit'") + + if args.flake and (args.file or args.attr): + parser.error("--flake cannot be used with --file or --attr") + + if args.help or args.action is None: + r = run(["man", "8", "nixos-rebuild"], check=False) + parser.exit(r.returncode) + + return args, remainder + + +def execute(argv: list[str]) -> None: + args, nix_flags = parse_args(argv) + + profile = Profile.from_name(args.profile_name) + flake = Flake.from_arg(args.flake) + + if args.upgrade or args.upgrade_all: + upgrade_channels(bool(args.upgrade_all)) + + match action := Action(args.action): + case Action.SWITCH | Action.BOOT: + info("building the system configuration...") + if args.rollback: + path_to_config = rollback(profile) + elif flake: + path_to_config = nixos_build_flake( + "toplevel", + flake, + nix_flags, + no_link=True, + ) + set_profile(profile, path_to_config) + else: + path_to_config = nixos_build( + "system", + args.attr, + args.file, + nix_flags, + no_out_link=True, + ) + set_profile(profile, path_to_config) + switch_to_configuration( + path_to_config, + action, + specialisation=args.specialisation, + install_bootloader=args.install_bootloader, + ) + case Action.TEST | Action.BUILD | Action.DRY_BUILD | Action.DRY_ACTIVATE: + info("building the system configuration...") + dry_run = action == Action.DRY_BUILD + if args.rollback and action in (Action.TEST, Action.BUILD): + maybe_path_to_config = rollback_temporary_profile(profile) + if maybe_path_to_config: # kinda silly but this makes mypy happy + path_to_config = maybe_path_to_config + else: + raise NRError("could not find previous generation") + elif flake: + path_to_config = nixos_build_flake( + "toplevel", + flake, + nix_flags, + keep_going=True, + dry_run=dry_run, + ) + else: + path_to_config = nixos_build( + "system", + args.attr, + args.file, + nix_flags, + keep_going=True, + dry_run=dry_run, + ) + if action in (Action.TEST, Action.DRY_ACTIVATE): + switch_to_configuration( + path_to_config, + action, + specialisation=args.specialisation, + ) + 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 = nixos_build_flake( + attr, + flake, + nix_flags, + keep_going=True, + ) + else: + path_to_config = nixos_build( + attr, + args.attr, + args.file, + nix_flags, + keep_going=True, + ) + vm_path = next(path_to_config.glob("bin/run-*-vm"), "./result/bin/run-*-vm") + print(f"Done. The virtual machine can be started by running '{vm_path}'") + case Action.EDIT: + edit(flake, nix_flags) + case Action.DRY_RUN: + assert False, "DRY_RUN should be a DRY_BUILD alias" + case Action.LIST_GENERATIONS: + generations = list_generations(profile) + if args.json: + print(json.dumps(generations, indent=2)) + else: + headers = { + "generation": "Generation", + "date": "Build-date", + "nixosVersion": "NixOS version", + "kernelVersion": "Kernel", + "configurationRevision": "Configuration Revision", + "specialisations": "Specialisation", + "current": "Current", + } + # Not exactly the same format as legacy nixos-rebuild but close + # enough + table = tabulate( + generations, + headers=headers, + tablefmt="plain", + numalign="left", + stralign="left", + disable_numparse=True, + ) + print(table) + case Action.REPL: + # For now just redirect it to `nixos-rebuild` instead of + # duplicating the code + os.execv( + "@nixos_rebuild@", + argv, + ) + case _: + assert_never(action) + + +def main() -> None: + try: + execute(sys.argv) + except (Exception, KeyboardInterrupt) as ex: + if VERBOSE: + raise ex + else: + sys.exit(str(ex)) + + +if __name__ == "__main__": + main() diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/models.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/models.py new file mode 100644 index 000000000000..489c9ba3e796 --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/models.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import platform +import re +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Any, ClassVar, TypedDict, override + + +class NRError(Exception): + "nixos-rebuild general error." + + def __init__(self, message: str): + self.message = message + + @override + def __str__(self) -> str: + return f"error: {self.message}" + + +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" + + @override + def __str__(self) -> str: + return self.value + + @staticmethod + def values() -> list[str]: + return [a.value for a in Action] + + +@dataclass(frozen=True) +class Flake: + path: Path + attr: str + _re: ClassVar[re.Pattern[str]] = re.compile( + r"^(?P[^\#]*)\#?(?P[^\#\"]*)$" + ) + + @override + def __str__(self) -> str: + return f"{self.path}#{self.attr}" + + @classmethod + def parse(cls, flake_str: str, hostname: str | None = None) -> Flake: + m = cls._re.match(flake_str) + assert m is not None, f"got no matches for {flake_str}" + attr = m.group("attr") + if not attr: + attr = f"nixosConfigurations.{hostname or "default"}" + else: + attr = f"nixosConfigurations.{attr}" + return Flake(Path(m.group("path")), attr) + + @classmethod + def from_arg(cls, flake_arg: Any) -> Flake | None: + hostname = platform.node() + match flake_arg: + case str(s): + return cls.parse(s, hostname) + case True: + return cls.parse(".", hostname) + 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 cls.parse(str(default_path.parent), hostname) + else: + return None + + +@dataclass(frozen=True) +class Generation: + id: int + timestamp: str # we may want to have a proper timestamp type in future + current: bool + + +# camelCase since this will be used as output for `--json` flag +class GenerationJson(TypedDict): + generation: int + date: str + nixosVersion: str + kernelVersion: str + configurationRevision: str + specialisations: list[str] + current: bool + + +@dataclass(frozen=True) +class Profile: + name: str + path: Path + + @staticmethod + def from_name(name: str = "system") -> Profile: + match name: + case "system": + return Profile(name, Path("/nix/var/nix/profiles/system")) + case _: + path = Path("/nix/var/nix/profiles/system-profiles") / name + path.parent.mkdir(mode=0o755, parents=True, exist_ok=True) + return Profile(name, path) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py new file mode 100644 index 000000000000..1197fb5abbf6 --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +import os +from datetime import datetime +from pathlib import Path +from subprocess import PIPE, CalledProcessError, run +from typing import Final + +from .models import ( + Action, + Flake, + Generation, + GenerationJson, + NRError, + Profile, +) +from .utils import dict_to_flags + +FLAKE_FLAGS: Final = ["--extra-experimental-features", "nix-command flakes"] + + +def edit(flake: Flake | None, nix_flags: list[str] | None = None) -> None: + "Try to find and open NixOS configuration file in editor." + if flake: + run( + ["nix", *FLAKE_FLAGS, "edit", *(nix_flags or []), "--", str(flake)], + check=False, + ) + else: + if nix_flags: + raise NRError("'edit' does not support extra Nix flags") + nixos_config = Path( + os.getenv("NIXOS_CONFIG") + or run( + ["nix-instantiate", "--find-file", "nixos-config"], + text=True, + stdout=PIPE, + check=False, + ).stdout.strip() + or "/etc/nixos/default.nix" + ) + if nixos_config.is_dir(): + nixos_config /= "default.nix" + + if nixos_config.exists(): + run([os.getenv("EDITOR", "nano"), nixos_config], check=False) + else: + raise NRError("cannot find NixOS config file") + + +def _parse_generation_from_nix_store(path: Path, profile: Profile) -> Generation: + entry_id = path.name.split("-")[1] + current = path.name == profile.path.readlink().name + timestamp = datetime.fromtimestamp(path.stat().st_ctime).strftime( + "%Y-%m-%d %H:%M:%S" + ) + + return Generation( + id=int(entry_id), + timestamp=timestamp, + current=current, + ) + + +def _parse_generation_from_nix_env(line: str) -> Generation: + parts = line.split() + + entry_id = parts[0] + timestamp = f"{parts[1]} {parts[2]}" + current = "(current)" in parts + + return Generation( + id=int(entry_id), + timestamp=timestamp, + current=current, + ) + + +def get_generations(profile: Profile, lock_profile: bool = False) -> list[Generation]: + """Get all NixOS generations from profile. + + Includes generation ID (e.g.: 1, 2), timestamp (e.g.: when it was created) + and if this is the current active profile or not. + + If `lock_profile = True` this command will need root to run successfully. + """ + if not profile.path.exists(): + raise NRError(f"no profile '{profile.name}' found") + + result = [] + if lock_profile: + # Using `nix-env --list-generations` needs root to lock the profile + # TODO: do we actually need to lock profile for e.g.: rollback? + # https://github.com/NixOS/nix/issues/5144 + r = run( + ["nix-env", "-p", profile.path, "--list-generations"], + text=True, + stdout=True, + check=True, + ) + for line in r.stdout.splitlines(): + result.append(_parse_generation_from_nix_env(line)) + else: + for p in profile.path.parent.glob("system-*-link"): + result.append(_parse_generation_from_nix_store(p, profile)) + return sorted(result, key=lambda d: d.id) + + +def list_generations(profile: Profile) -> list[GenerationJson]: + """Get all NixOS generations from profile, including extra information. + + Includes OS information like the commit, kernel version, configuration + revision and specialisations. + + Will be formatted in a way that is expected by the output of + `nixos-rebuild list-generations --json`. + """ + generations = get_generations(profile) + result = [] + for generation in reversed(generations): + generation_path = ( + profile.path.parent / f"{profile.path.name}-{generation.id}-link" + ) + try: + nixos_version = (generation_path / "nixos-version").read_text().strip() + except IOError: + nixos_version = "Unknown" + try: + kernel_version = next( + (generation_path / "kernel-modules/lib/modules").iterdir() + ).name + except IOError: + kernel_version = "Unknown" + specialisations = [ + s.name for s in (generation_path / "specialisation").glob("*") if s.is_dir() + ] + try: + configuration_revision = run( + [generation_path / "sw/bin/nixos-version", "--configuration-revision"], + capture_output=True, + check=True, + text=True, + ).stdout.strip() + except (CalledProcessError, IOError): + configuration_revision = "Unknown" + + result.append( + GenerationJson( + generation=generation.id, + date=generation.timestamp, + nixosVersion=nixos_version, + kernelVersion=kernel_version, + configurationRevision=configuration_revision, + specialisations=specialisations, + current=generation.current, + ) + ) + + return result + + +def nixos_build( + attr: str, + pre_attr: str | None, + file: str | None, + nix_flags: list[str] | None = None, + **kwargs: bool | str, +) -> Path: + """Build NixOS attribute using classic Nix. + + It will by default build `` with `attr`, however it + optionally supports building from an external file and custom attributes + paths. + + Returns the built attribute as 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 += dict_to_flags(kwargs) + (nix_flags or []) + r = run(run_args, check=True, text=True, stdout=PIPE) + return Path(r.stdout.strip()) + + +def nixos_build_flake( + attr: str, + flake: Flake, + nix_flags: list[str] | None = None, + **kwargs: bool | str, +) -> Path: + """Build NixOS attribute using Flakes. + + Returns the built attribute as path. + """ + run_args = [ + "nix", + *FLAKE_FLAGS, + "build", + "--print-out-paths", + f"{flake}.config.system.build.{attr}", + ] + run_args += dict_to_flags(kwargs) + (nix_flags or []) + r = run(run_args, check=True, text=True, stdout=PIPE) + return Path(r.stdout.strip()) + + +def rollback(profile: Profile) -> Path: + "Rollback Nix profile, like one created by `nixos-rebuild switch`." + run(["nix-env", "--rollback", "-p", profile.path], check=True) + # Rollback config PATH is the own profile + return profile.path + + +def rollback_temporary_profile(profile: Profile) -> Path | None: + "Rollback a temporary Nix profile, like one created by `nixos-rebuild test`." + generations = get_generations(profile, lock_profile=True) + previous_gen_id = None + for generation in generations: + if not generation.current: + previous_gen_id = generation.id + + if previous_gen_id: + return profile.path.parent / f"{profile.name}-{previous_gen_id}-link" + else: + return None + + +def set_profile(profile: Profile, path_to_config: Path) -> None: + "Set a path as the current active Nix profile." + run(["nix-env", "-p", profile.path, "--set", path_to_config], check=True) + + +def switch_to_configuration( + path_to_config: Path, + action: Action, + install_bootloader: bool = False, + specialisation: str | None = None, +) -> None: + """Call `/bin/switch-to-configuration `. + + Expects a built path to run, like one generated with `nixos_build` or + `nixos_build_flake` functions. + """ + if specialisation: + if action not in (Action.SWITCH, Action.TEST): + raise NRError( + "'--specialisation' can only be used with 'switch' and 'test'" + ) + path_to_config = path_to_config / f"specialisation/{specialisation}" + + if not path_to_config.exists(): + raise NRError(f"specialisation not found: {specialisation}") + + run( + [path_to_config / "bin/switch-to-configuration", str(action)], + env={ + "NIXOS_INSTALL_BOOTLOADER": "1" if install_bootloader else "0", + "LOCALE_ARCHIVE": os.getenv("LOCALE_ARCHIVE", ""), + }, + check=True, + ) + + +def upgrade_channels(all: bool = False) -> None: + """Upgrade channels for classic Nix. + + It will either upgrade just the `nixos` channel (including any channel + that has a `.update-on-nixos-rebuild` file) or all. + """ + for channel_path in Path("/nix/var/nix/profiles/per-user/root/channels/").glob("*"): + if ( + all + or channel_path.name == "nixos" + or (channel_path / ".update-on-nixos-rebuild").exists() + ): + run(["nix-channel", "--update", channel_path.name], check=False) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/utils.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/utils.py new file mode 100644 index 000000000000..1cc210c3005a --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/utils.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import sys +from functools import partial +from typing import Any + +info = partial(print, file=sys.stderr) + + +def dict_to_flags(d: dict[str, Any]) -> list[str]: + flags = [] + for key, value in d.items(): + flag = f"--{'-'.join(key.split('_'))}" + match value: + case None | False: + pass + case True: + flags.append(flag) + case int(): + flags.append(f"-{key[0] * value}") + case str(): + flags.append(flag) + flags.append(value) + case list(): + flags.append(flag) + for v in value: + flags.append(v) + return flags diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/pyproject.toml b/pkgs/by-name/ni/nixos-rebuild-ng/src/pyproject.toml new file mode 100644 index 000000000000..457bcfe011b6 --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/pyproject.toml @@ -0,0 +1,51 @@ +[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" + +[tool.mypy] +# `--strict` config, but explicit options to avoid breaking build when mypy is +# updated +warn_unused_configs = true +disallow_any_generics = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_return_any = true +# no_implicit_reexport = true +strict_equality = true +extra_checks = true + +# extra options not included in `--strict` +enable_error_code = ["explicit-override", "mutable-override"] + +[[tool.mypy.overrides]] +module = "pytest.*" +ignore_missing_imports = true + +[tool.ruff.lint] +extend-select = [ + # ensure imports are sorted + "I", + # require 'from __future__ import annotations' + "FA102", + # require `check` argument for `subprocess.run` + "PLW1510", +] + +[tool.ruff.lint.per-file-ignores] +"tests/" = ["FA102"] + +[tool.pytest.ini_options] +addopts = ["--import-mode=importlib"] diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/__init__.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/helpers.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/helpers.py new file mode 100644 index 000000000000..0474c6671edd --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/helpers.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from types import ModuleType +from typing import Any, Callable + + +def get_qualified_name( + method: Callable[..., Any], + module: ModuleType | None = None, +) -> str: + module_name = getattr(module, "__name__", method.__module__) + method_name = getattr(method, "__qualname__", method.__name__) + return f"{module_name}.{method_name}" 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 new file mode 100644 index 000000000000..03dcf684e734 --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py @@ -0,0 +1,270 @@ +import textwrap +from pathlib import Path +from subprocess import PIPE, CompletedProcess +from typing import Any +from unittest.mock import call, patch + +import pytest + +import nixos_rebuild as nr + +from .helpers import get_qualified_name + + +@pytest.fixture(autouse=True) +def setup(monkeypatch: Any) -> None: + monkeypatch.setenv("LOCALE_ARCHIVE", "/locale") + + +def test_parse_args() -> None: + 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 == 2 + + with pytest.raises(SystemExit) as e: + nr.parse_args(["nixos-rebuild", "edit", "--attr", "attr"]) + assert e.value.code == 2 + + r1, remainder = nr.parse_args( + [ + "nixos-rebuild", + "switch", + "--install-grub", + "--flake", + "/etc/nixos", + "--extra", + "flag", + ] + ) + assert remainder == ["--extra", "flag"] + 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, remainder = nr.parse_args( + [ + "nixos-rebuild", + "dry-run", + "--flake", + "--no-flake", + "-f", + "foo", + "--attr", + "bar", + ] + ) + assert remainder == [] + assert r2.flake is False + assert r2.action == "dry-build" + assert r2.file == "foo" + assert r2.attr == "bar" + + +@patch(get_qualified_name(nr.nix.run, nr.nix), autospec=True) +def test_execute_nix_boot(mock_run: Any, tmp_path: Path) -> None: + config_path = tmp_path / "test" + config_path.touch() + mock_run.side_effect = [ + # nixos_build + CompletedProcess([], 0, str(config_path)), + # set_profile + CompletedProcess([], 0), + # switch_to_configuration + CompletedProcess([], 0), + ] + + nr.execute(["nixos-rebuild", "boot", "--no-flake", "-vvv"]) + + assert nr.VERBOSE is True + assert mock_run.call_count == 3 + mock_run.assert_has_calls( + [ + call( + [ + "nix-build", + "", + "--attr", + "system", + "--no-out-link", + "-vvv", + ], + check=True, + text=True, + stdout=PIPE, + ), + call( + [ + "nix-env", + "-p", + Path("/nix/var/nix/profiles/system"), + "--set", + config_path, + ], + check=True, + ), + call( + [config_path / "bin/switch-to-configuration", "boot"], + env={"NIXOS_INSTALL_BOOTLOADER": "0", "LOCALE_ARCHIVE": "/locale"}, + check=True, + ), + ] + ) + + +@patch(get_qualified_name(nr.nix.run, nr.nix), autospec=True) +def test_execute_nix_switch_flake(mock_run: Any, tmp_path: Path) -> None: + config_path = tmp_path / "test" + config_path.touch() + mock_run.side_effect = [ + # nixos_build_flake + CompletedProcess([], 0, str(config_path)), + # set_profile + CompletedProcess([], 0), + # switch_to_configuration + CompletedProcess([], 0), + ] + + nr.execute( + [ + "nixos-rebuild", + "switch", + "--flake", + "/path/to/config#hostname", + "--install-bootloader", + "--verbose", + ] + ) + + assert nr.VERBOSE is True + assert mock_run.call_count == 3 + mock_run.assert_has_calls( + [ + call( + [ + "nix", + "--extra-experimental-features", + "nix-command flakes", + "build", + "--print-out-paths", + "/path/to/config#nixosConfigurations.hostname.config.system.build.toplevel", + "--no-link", + "--verbose", + ], + check=True, + text=True, + stdout=PIPE, + ), + call( + [ + "nix-env", + "-p", + Path("/nix/var/nix/profiles/system"), + "--set", + config_path, + ], + check=True, + ), + call( + [config_path / "bin/switch-to-configuration", "switch"], + env={"NIXOS_INSTALL_BOOTLOADER": "1", "LOCALE_ARCHIVE": "/locale"}, + check=True, + ), + ] + ) + + +@patch(get_qualified_name(nr.nix.run, nr.nix), autospec=True) +def test_execute_switch_rollback(mock_run: Any) -> None: + nr.execute(["nixos-rebuild", "switch", "--rollback", "--install-bootloader"]) + + assert nr.VERBOSE is False + assert mock_run.call_count == 2 + mock_run.assert_has_calls( + [ + call( + [ + "nix-env", + "--rollback", + "-p", + Path("/nix/var/nix/profiles/system"), + ], + check=True, + ), + call( + [ + Path("/nix/var/nix/profiles/system/bin/switch-to-configuration"), + "switch", + ], + env={"NIXOS_INSTALL_BOOTLOADER": "1", "LOCALE_ARCHIVE": "/locale"}, + check=True, + ), + ] + ) + + +@patch(get_qualified_name(nr.nix.run, nr.nix), autospec=True) +@patch(get_qualified_name(nr.nix.Path.exists, nr.nix), autospec=True, return_value=True) +@patch(get_qualified_name(nr.nix.Path.mkdir, nr.nix), autospec=True) +def test_execute_test_rollback( + mock_path_mkdir: Any, + mock_path_exists: Any, + mock_run: Any, +) -> None: + mock_run.side_effect = [ + # rollback_temporary_profile + CompletedProcess( + [], + 0, + stdout=textwrap.dedent("""\ + 2082 2024-11-07 22:58:56 + 2083 2024-11-07 22:59:41 + 2084 2024-11-07 23:54:17 (current) + """), + ), + # switch_to_configuration + CompletedProcess([], 0), + ] + + nr.execute( + [ + "nixos-rebuild", + "test", + "--rollback", + "--profile-name", + "foo", + ] + ) + + assert nr.VERBOSE is False + assert mock_run.call_count == 2 + mock_run.assert_has_calls( + [ + call( + [ + "nix-env", + "-p", + Path("/nix/var/nix/profiles/system-profiles/foo"), + "--list-generations", + ], + text=True, + stdout=True, + check=True, + ), + call( + [ + Path( + "/nix/var/nix/profiles/system-profiles/foo-2083-link/bin/switch-to-configuration" + ), + "test", + ], + env={"NIXOS_INSTALL_BOOTLOADER": "0", "LOCALE_ARCHIVE": "/locale"}, + check=True, + ), + ] + ) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_models.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_models.py new file mode 100644 index 000000000000..cd3a19beb789 --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_models.py @@ -0,0 +1,100 @@ +import platform +from pathlib import Path +from typing import Any +from unittest.mock import patch + +from nixos_rebuild import models as m + +from .helpers import get_qualified_name + + +def test_flake_parse() -> None: + assert m.Flake.parse("/path/to/flake#attr") == m.Flake( + Path("/path/to/flake"), "nixosConfigurations.attr" + ) + assert m.Flake.parse("/path/ to /flake", "hostname") == m.Flake( + Path("/path/ to /flake"), "nixosConfigurations.hostname" + ) + assert m.Flake.parse("/path/to/flake", "hostname") == 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(".", None) == m.Flake(Path("."), "nixosConfigurations.default") + assert m.Flake.parse("", "") == m.Flake(Path("."), "nixosConfigurations.default") + + +@patch(get_qualified_name(platform.node), autospec=True) +def test_flake_from_arg(mock_node: Any) -> None: + mock_node.return_value = "hostname" + + # 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 + assert m.Flake.from_arg(True) == m.Flake(Path("."), "nixosConfigurations.hostname") + + # None when we do not have /etc/nixos/flake.nix + with patch( + get_qualified_name(m.Path.exists, m), + autospec=True, + return_value=False, + ): + assert m.Flake.from_arg(None) is None + + # None when we have a file in /etc/nixos/flake.nix + with ( + patch( + get_qualified_name(m.Path.exists, m), + autospec=True, + return_value=True, + ), + patch( + get_qualified_name(m.Path.is_symlink, m), + autospec=True, + return_value=False, + ), + ): + assert m.Flake.from_arg(None) == m.Flake( + Path("/etc/nixos"), "nixosConfigurations.hostname" + ) + + with ( + patch( + get_qualified_name(m.Path.exists, m), + autospec=True, + return_value=True, + ), + patch( + get_qualified_name(m.Path.is_symlink, m), + autospec=True, + return_value=True, + ), + patch( + get_qualified_name(m.Path.readlink, m), + autospec=True, + return_value=Path("/path/to/flake.nix"), + ), + ): + assert m.Flake.from_arg(None) == m.Flake( + Path("/path/to"), "nixosConfigurations.hostname" + ) + + +@patch(get_qualified_name(m.Path.mkdir, m), autospec=True) +def test_profile_from_name(mock_mkdir: Any) -> None: + assert m.Profile.from_name("system") == m.Profile( + "system", + Path("/nix/var/nix/profiles/system"), + ) + + assert m.Profile.from_name("something") == m.Profile( + "something", + Path("/nix/var/nix/profiles/system-profiles/something"), + ) + mock_mkdir.assert_called_once() diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py new file mode 100644 index 000000000000..8b145bfc8305 --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py @@ -0,0 +1,327 @@ +import textwrap +from pathlib import Path +from subprocess import PIPE, CompletedProcess +from typing import Any +from unittest.mock import ANY, call, patch + +import pytest + +import nixos_rebuild.nix as n +from nixos_rebuild import models as m + +from .helpers import get_qualified_name + + +@patch(get_qualified_name(n.run, n), autospec=True) +def test_edit(mock_run: Any, monkeypatch: Any, tmpdir: Any) -> None: + # Flake + flake = m.Flake.parse(".#attr") + n.edit(flake, ["--commit-lock-file"]) + mock_run.assert_called_with( + [ + "nix", + "--extra-experimental-features", + "nix-command flakes", + "edit", + "--commit-lock-file", + "--", + ".#nixosConfigurations.attr", + ], + check=False, + ) + + # Classic + 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_run.assert_called_with(["editor", default_nix], check=False) + + +def test_get_generations_from_nix_store(tmp_path: Path) -> None: + nixos_path = tmp_path / "nixos-system" + nixos_path.mkdir() + + (tmp_path / "system").symlink_to(tmp_path / "system-2-link") + # In the "wrong" order on purpose to make sure we are sorting the results + (tmp_path / "system-1-link").symlink_to(nixos_path) + (tmp_path / "system-3-link").symlink_to(nixos_path) + (tmp_path / "system-2-link").symlink_to(nixos_path) + + assert n.get_generations( + m.Profile("system", tmp_path / "system"), + lock_profile=False, + ) == [ + m.Generation(id=1, current=False, timestamp=ANY), + m.Generation(id=2, current=True, timestamp=ANY), + m.Generation(id=3, current=False, timestamp=ANY), + ] + + +@patch( + get_qualified_name(n.run, n), + autospec=True, + return_value=CompletedProcess( + [], + 0, + stdout=textwrap.dedent("""\ + 2082 2024-11-07 22:58:56 + 2083 2024-11-07 22:59:41 + 2084 2024-11-07 23:54:17 (current) + """), + ), +) +def test_get_generations_from_nix_env(mock_run: Any, tmp_path: Path) -> None: + path = tmp_path / "test" + path.touch() + + assert n.get_generations(m.Profile("system", path), lock_profile=True) == [ + m.Generation(id=2082, current=False, timestamp="2024-11-07 22:58:56"), + m.Generation(id=2083, current=False, timestamp="2024-11-07 22:59:41"), + m.Generation(id=2084, current=True, timestamp="2024-11-07 23:54:17"), + ] + + +@patch( + get_qualified_name(n.get_generations), + autospec=True, + return_value=[ + m.Generation( + id=1, + timestamp="2024-11-07 23:54:17", + current=False, + ), + m.Generation( + id=2, + timestamp="2024-11-07 23:54:17", + current=True, + ), + ], +) +def test_list_generations(mock_get_generations: Any, tmp_path: Path) -> None: + # Probably better to test this function in a real system, this test is + # mostly to make sure it doesn't break horribly + assert n.list_generations(m.Profile("system", tmp_path)) == [ + { + "configurationRevision": "Unknown", + "current": True, + "date": "2024-11-07 23:54:17", + "generation": 2, + "kernelVersion": "Unknown", + "nixosVersion": "Unknown", + "specialisations": [], + }, + { + "configurationRevision": "Unknown", + "current": False, + "date": "2024-11-07 23:54:17", + "generation": 1, + "kernelVersion": "Unknown", + "nixosVersion": "Unknown", + "specialisations": [], + }, + ] + + +@patch( + get_qualified_name(n.run, n), + autospec=True, + return_value=CompletedProcess([], 0, stdout=" \n/path/to/file\n "), +) +def test_nixos_build_flake(mock_run: Any) -> None: + flake = m.Flake.parse(".#hostname") + + assert n.nixos_build_flake( + "toplevel", + flake, + ["--nix-flag", "foo"], + no_link=True, + ) == Path("/path/to/file") + mock_run.assert_called_with( + [ + "nix", + "--extra-experimental-features", + "nix-command flakes", + "build", + "--print-out-paths", + ".#nixosConfigurations.hostname.config.system.build.toplevel", + "--no-link", + "--nix-flag", + "foo", + ], + check=True, + text=True, + stdout=PIPE, + ) + + +@patch( + get_qualified_name(n.run, n), + autospec=True, + return_value=CompletedProcess([], 0, stdout=" \n/path/to/file\n "), +) +def test_nixos_build(mock_run: Any, monkeypatch: Any) -> None: + assert n.nixos_build("attr", None, None, ["--nix-flag", "foo"]) == Path( + "/path/to/file" + ) + mock_run.assert_called_with( + ["nix-build", "", "--attr", "attr", "--nix-flag", "foo"], + check=True, + text=True, + stdout=PIPE, + ) + + n.nixos_build("attr", "preAttr", "file") + mock_run.assert_called_with( + ["nix-build", "file", "--attr", "preAttr.attr"], + check=True, + text=True, + stdout=PIPE, + ) + + n.nixos_build("attr", None, "file", no_out_link=True) + mock_run.assert_called_with( + ["nix-build", "file", "--attr", "attr", "--no-out-link"], + check=True, + text=True, + stdout=PIPE, + ) + + n.nixos_build("attr", "preAttr", None, no_out_link=False, keep_going=True) + mock_run.assert_called_with( + ["nix-build", "default.nix", "--attr", "preAttr.attr", "--keep-going"], + check=True, + text=True, + stdout=PIPE, + ) + + +@patch(get_qualified_name(n.run, n), autospec=True) +def test_rollback(mock_run: Any, tmp_path: Path) -> None: + path = tmp_path / "test" + path.touch() + + profile = m.Profile("system", path) + + assert n.rollback(profile) == profile.path + mock_run.assert_called_with(["nix-env", "--rollback", "-p", path], check=True) + + +def test_rollback_temporary_profile(tmp_path: Path) -> None: + path = tmp_path / "test" + path.touch() + profile = m.Profile("system", path) + + with patch(get_qualified_name(n.run, n), autospec=True) as mock_run: + mock_run.return_value = CompletedProcess( + [], + 0, + stdout=textwrap.dedent("""\ + 2082 2024-11-07 22:58:56 + 2083 2024-11-07 22:59:41 + 2084 2024-11-07 23:54:17 (current) + """), + ) + assert ( + n.rollback_temporary_profile(m.Profile("system", path)) + == path.parent / "system-2083-link" + ) + assert ( + n.rollback_temporary_profile(m.Profile("foo", path)) + == path.parent / "foo-2083-link" + ) + + with patch(get_qualified_name(n.run, n), autospec=True) as mock_run: + mock_run.return_value = CompletedProcess([], 0, stdout="") + assert n.rollback_temporary_profile(profile) is None + + +@patch(get_qualified_name(n.run, n), autospec=True) +def test_set_profile(mock_run: Any) -> None: + profile_path = Path("/path/to/profile") + config_path = Path("/path/to/config") + n.set_profile(m.Profile("system", profile_path), config_path) + + mock_run.assert_called_with( + ["nix-env", "-p", profile_path, "--set", config_path], check=True + ) + + +@patch(get_qualified_name(n.run, n), autospec=True) +def test_switch_to_configuration(mock_run: Any, monkeypatch: Any) -> None: + profile_path = Path("/path/to/profile") + config_path = Path("/path/to/config") + + with monkeypatch.context() as mp: + mp.setenv("LOCALE_ARCHIVE", "") + + n.switch_to_configuration( + profile_path, + m.Action.SWITCH, + specialisation=None, + install_bootloader=False, + ) + mock_run.assert_called_with( + [profile_path / "bin/switch-to-configuration", "switch"], + env={"NIXOS_INSTALL_BOOTLOADER": "0", "LOCALE_ARCHIVE": ""}, + check=True, + ) + + with pytest.raises(m.NRError) as e: + n.switch_to_configuration( + config_path, + m.Action.BOOT, + specialisation="special", + ) + assert ( + str(e.value) + == "error: '--specialisation' can only be used with 'switch' and 'test'" + ) + + with monkeypatch.context() as mp: + mp.setenv("LOCALE_ARCHIVE", "/path/to/locale") + mp.setattr(Path, Path.exists.__name__, lambda self: True) + + n.switch_to_configuration( + Path("/path/to/config"), + m.Action.TEST, + install_bootloader=True, + specialisation="special", + ) + mock_run.assert_called_with( + [ + config_path / "specialisation/special/bin/switch-to-configuration", + "test", + ], + env={"NIXOS_INSTALL_BOOTLOADER": "1", "LOCALE_ARCHIVE": "/path/to/locale"}, + check=True, + ) + + +@patch( + get_qualified_name(n.Path.glob, n), + autospec=True, + return_value=[ + Path("/nix/var/nix/profiles/per-user/root/channels/nixos"), + Path("/nix/var/nix/profiles/per-user/root/channels/nixos-hardware"), + Path("/nix/var/nix/profiles/per-user/root/channels/home-manager"), + ], +) +def test_upgrade_channels(mock_glob: Any) -> None: + with patch(get_qualified_name(n.run, n), autospec=True) as mock_run: + n.upgrade_channels(False) + mock_run.assert_called_with(["nix-channel", "--update", "nixos"], check=False) + + with patch(get_qualified_name(n.run, n), autospec=True) as mock_run: + n.upgrade_channels(True) + mock_run.assert_has_calls( + [ + call(["nix-channel", "--update", "nixos"], check=False), + call(["nix-channel", "--update", "nixos-hardware"], check=False), + call(["nix-channel", "--update", "home-manager"], check=False), + ] + ) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_utils.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_utils.py new file mode 100644 index 000000000000..98c0b798742c --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_utils.py @@ -0,0 +1,23 @@ +from nixos_rebuild import utils as u + + +def test_dict_to_flags() -> None: + r = u.dict_to_flags( + { + "test_flag_1": True, + "test_flag_2": False, + "test_flag_3": "value", + "test_flag_4": ["v1", "v2"], + "test_flag_5": None, + "verbose": 5, + } + ) + assert r == [ + "--test-flag-1", + "--test-flag-3", + "value", + "--test-flag-4", + "v1", + "v2", + "-vvvvv", + ]