nixos-rebuild-ng: init

This commit is contained in:
Thiago Kenji Okada 2024-11-04 22:23:33 +00:00
parent 3d8f220f71
commit 9e6ece3ee1
15 changed files with 1619 additions and 0 deletions

1
.github/labeler.yml vendored
View File

@ -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":

View File

@ -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

View File

@ -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

View File

@ -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";
};
}

View File

@ -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()

View File

@ -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<path>[^\#]*)\#?(?P<attr>[^\#\"]*)$"
)
@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)

View File

@ -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 `<nixpkgs/nixos>` 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", "<nixpkgs/nixos>", "--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 `<config>/bin/switch-to-configuration <action>`.
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)

View File

@ -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

View File

@ -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"]

View File

@ -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}"

View File

@ -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",
"<nixpkgs/nixos>",
"--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,
),
]
)

View File

@ -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()

View File

@ -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", "<nixpkgs/nixos>", "--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),
]
)

View File

@ -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",
]