Skip to content

Commit

Permalink
nixos-rebuild-ng: implement build-image (#371142)
Browse files Browse the repository at this point in the history
  • Loading branch information
thiagokokada authored Jan 9, 2025
2 parents a077d7e + eccb855 commit e0d9824
Show file tree
Hide file tree
Showing 7 changed files with 298 additions and 21 deletions.
15 changes: 14 additions & 1 deletion pkgs/by-name/ni/nixos-rebuild-ng/nixos-rebuild.8.scd
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ _nixos-rebuild_ \[--verbose] [--max-jobs MAX_JOBS] [--cores CORES] [--log-format
\[--no-update-lock-file] [--no-write-lock-file] [--no-registries] [--commit-lock-file] [--update-input UPDATE_INPUT] [--override-input OVERRIDE_INPUT OVERRIDE_INPUT]++
\[--no-build-output] [--use-substitutes] [--help] [--file FILE] [--attr ATTR] [--flake [FLAKE]] [--no-flake] [--install-bootloader] [--profile-name PROFILE_NAME]++
\[--specialisation SPECIALISATION] [--rollback] [--upgrade] [--upgrade-all] [--json] [--ask-sudo-password] [--sudo] [--fast]++
\[--image-variant VARIANT]++
\[--build-host BUILD_HOST] [--target-host TARGET_HOST]++
\[{switch,boot,test,build,edit,repl,dry-build,dry-run,dry-activate,build-vm,build-vm-with-bootloader,list-generations}]
\[{switch,boot,test,build,edit,repl,dry-build,dry-run,dry-activate,build-image,build-vm,build-vm-with-bootloader,list-generations}]

# DESCRIPTION

Expand Down Expand Up @@ -106,6 +107,13 @@ It must be one of the following:
*repl*
Opens the configuration in *nix repl*.

*build-image*
Build a disk-image variant, pre-configured for the given
platform/provider. Select a variant with the *--image-variant* option
or run without any options to get a list of available variants.

$ nixos-rebuild build-image --image-variant proxmox

*build-vm*
Build a script that starts a NixOS virtual machine with the desired
configuration. It leaves a symlink _result_ in the current directory that
Expand Down Expand Up @@ -206,6 +214,11 @@ It must be one of the following:
Activates given specialisation; when not specified, switching and testing
will activate the base, unspecialised system.

*--image-variant* _variant_
Selects an image variant to build from the _config.system.build.images_
attribute of the given configuration. A list of variants is printed if
this option remains unset.

*--build-host* _host_
Instead of building the new configuration locally, use the specified host
to perform the build. The host needs to be accessible with ssh, and must
Expand Down
79 changes: 60 additions & 19 deletions pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from . import nix, tmpdir
from .constants import EXECUTABLE, WITH_NIX_2_18, WITH_REEXEC, WITH_SHELL_FILES
from .models import Action, BuildAttr, Flake, NRError, Profile
from .models import Action, BuildAttr, Flake, ImageVariants, NRError, Profile
from .process import Remote, cleanup_ssh
from .utils import Args, LogFormatter, tabulate

Expand Down Expand Up @@ -176,6 +176,11 @@ def get_parser() -> tuple[argparse.ArgumentParser, dict[str, argparse.ArgumentPa
"--target-host", help="Specifies host to activate the configuration"
)
main_parser.add_argument("--no-build-nix", action="store_true", help="Deprecated")
main_parser.add_argument(
"--image-variant",
help="Selects an image variant to build from the "
+ "config.system.build.images attribute of the given configuration",
)
main_parser.add_argument("action", choices=Action.values(), nargs="?")

return main_parser, sub_parsers
Expand Down Expand Up @@ -285,8 +290,7 @@ def reexec(
)
except CalledProcessError:
logger.warning(
"could not build a newer version of nixos-rebuild, "
+ "using current version"
"could not build a newer version of nixos-rebuild, using current version"
)

if drv:
Expand Down Expand Up @@ -372,6 +376,7 @@ def execute(argv: list[str]) -> None:
| Action.BUILD
| Action.DRY_BUILD
| Action.DRY_ACTIVATE
| Action.BUILD_IMAGE
| Action.BUILD_VM
| Action.BUILD_VM_WITH_BOOTLOADER
):
Expand All @@ -383,7 +388,29 @@ def execute(argv: list[str]) -> None:
flake_build_flags |= {"no_link": no_link, "dry_run": dry_run}
rollback = bool(args.rollback)

def validate_image_variant(variants: ImageVariants) -> None:
if args.image_variant not in variants:
raise NRError(
"please specify one of the following "
+ "supported image variants via --image-variant:\n"
+ "\n".join(f"- {v}" for v in variants.keys())
)

match action:
case Action.BUILD_IMAGE if flake:
variants = nix.get_build_image_variants_flake(
flake,
eval_flags=flake_common_flags,
)
validate_image_variant(variants)
attr = f"config.system.build.images.{args.image_variant}"
case Action.BUILD_IMAGE:
variants = nix.get_build_image_variants(
build_attr,
instantiate_flags=common_flags,
)
validate_image_variant(variants)
attr = f"config.system.build.images.{args.image_variant}"
case Action.BUILD_VM:
attr = "config.system.build.vm"
case Action.BUILD_VM_WITH_BOOTLOADER:
Expand Down Expand Up @@ -460,25 +487,37 @@ def execute(argv: list[str]) -> None:
sudo=args.sudo,
)

if action in (Action.SWITCH, Action.BOOT, Action.TEST, Action.DRY_ACTIVATE):
nix.switch_to_configuration(
path_to_config,
action,
target_host=target_host,
sudo=args.sudo,
specialisation=args.specialisation,
install_bootloader=args.install_bootloader,
)
elif action in (Action.BUILD_VM, Action.BUILD_VM_WITH_BOOTLOADER):
# If you get `not-found`, please open an issue
vm_path = next(path_to_config.glob("bin/run-*-vm"), "not-found")
print(
f"Done. The virtual machine can be started by running '{vm_path}'"
)
# Print only the result to stdout to make it easier to script
def print_result(msg: str, result: str | Path) -> None:
print(msg, end=" ", file=sys.stderr, flush=True)
print(result, flush=True)

match action:
case Action.SWITCH | Action.BOOT | Action.TEST | Action.DRY_ACTIVATE:
nix.switch_to_configuration(
path_to_config,
action,
target_host=target_host,
sudo=args.sudo,
specialisation=args.specialisation,
install_bootloader=args.install_bootloader,
)
case Action.BUILD_VM | Action.BUILD_VM_WITH_BOOTLOADER:
# If you get `not-found`, please open an issue
vm_path = next(path_to_config.glob("bin/run-*-vm"), "not-found")
print_result(
"Done. The virtual machine can be started by running", vm_path
)
case Action.BUILD_IMAGE:
disk_path = path_to_config / variants[args.image_variant]
print_result("Done. The disk image can be found in", disk_path)

case Action.EDIT:
nix.edit(flake, flake_build_flags)

case Action.DRY_RUN:
assert False, "DRY_RUN should be a DRY_BUILD alias"
raise AssertionError("DRY_RUN should be a DRY_BUILD alias")

case Action.LIST_GENERATIONS:
generations = nix.list_generations(profile)
if args.json:
Expand All @@ -494,11 +533,13 @@ def execute(argv: list[str]) -> None:
"current": "Current",
}
print(tabulate(generations, headers=headers))

case Action.REPL:
if flake:
nix.repl_flake("toplevel", flake, flake_build_flags)
else:
nix.repl("system", build_attr, build_flags)

case _:
assert_never(action)

Expand Down
3 changes: 3 additions & 0 deletions pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

from .process import Remote, run_wrapper

type ImageVariants = dict[str, str]


class NRError(Exception):
"nixos-rebuild general error."
Expand All @@ -30,6 +32,7 @@ class Action(Enum):
DRY_BUILD = "dry-build"
DRY_RUN = "dry-run"
DRY_ACTIVATE = "dry-activate"
BUILD_IMAGE = "build-image"
BUILD_VM = "build-vm"
BUILD_VM_WITH_BOOTLOADER = "build-vm-with-bootloader"
LIST_GENERATIONS = "list-generations"
Expand Down
54 changes: 54 additions & 0 deletions pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import json
import logging
import os
import textwrap
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from importlib.resources import files
Expand All @@ -17,6 +19,7 @@
Flake,
Generation,
GenerationJson,
ImageVariants,
NRError,
Profile,
Remote,
Expand Down Expand Up @@ -263,6 +266,57 @@ def find_file(file: str, nix_flags: Args | None = None) -> Path | None:
return Path(r.stdout.strip())


def get_build_image_variants(
build_attr: BuildAttr,
instantiate_flags: Args | None = None,
) -> ImageVariants:
path = (
f'"{build_attr.path.resolve()}"'
if isinstance(build_attr.path, Path)
else build_attr.path
)
r = run_wrapper(
[
"nix-instantiate",
"--eval",
"--strict",
"--json",
"--expr",
textwrap.dedent(f"""
let
value = import {path};
set = if builtins.isFunction value then value {{}} else value;
in
builtins.mapAttrs (n: v: v.passthru.filePath) set.{build_attr.to_attr("config.system.build.images")}
"""),
*dict_to_flags(instantiate_flags),
],
stdout=PIPE,
)
j: ImageVariants = json.loads(r.stdout.strip())
return j


def get_build_image_variants_flake(
flake: Flake,
eval_flags: Args | None = None,
) -> ImageVariants:
r = run_wrapper(
[
"nix",
"eval",
"--json",
flake.to_attr("config.system.build.images"),
"--apply",
"builtins.mapAttrs (n: v: v.passthru.filePath)",
*dict_to_flags(eval_flags),
],
stdout=PIPE,
)
j: ImageVariants = json.loads(r.stdout.strip())
return j


def get_nixpkgs_rev(nixpkgs_path: Path | None) -> str | None:
"""Get Nixpkgs path as a Git revision.
Expand Down
2 changes: 1 addition & 1 deletion pkgs/by-name/ni/nixos-rebuild-ng/src/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,4 @@ extend-select = [

[tool.pytest.ini_options]
pythonpath = ["."]
addopts = ["--import-mode=importlib"]
addopts = "--import-mode=importlib"
69 changes: 69 additions & 0 deletions pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,75 @@ def run_side_effect(args: list[str], **kwargs: Any) -> CompletedProcess[str]:
)


@patch.dict(nr.process.os.environ, {}, clear=True)
@patch(get_qualified_name(nr.process.subprocess.run), autospec=True)
def test_execute_nix_build_image_flake(mock_run: Any, tmp_path: Path) -> None:
config_path = tmp_path / "test"
config_path.touch()

def run_side_effect(args: list[str], **kwargs: Any) -> CompletedProcess[str]:
if args[0] == "nix" and "eval" in args:
return CompletedProcess(
[],
0,
"""
{
"azure": "nixos-image-azure-25.05.20250102.6df2492-x86_64-linux.vhd",
"vmware": "nixos-image-vmware-25.05.20250102.6df2492-x86_64-linux.vmdk"
}
""",
)
elif args[0] == "nix":
return CompletedProcess([], 0, str(config_path))
else:
return CompletedProcess([], 0)

mock_run.side_effect = run_side_effect

nr.execute(
[
"nixos-rebuild",
"build-image",
"--image-variant",
"azure",
"--flake",
"/path/to/config#hostname",
]
)

assert mock_run.call_count == 2
mock_run.assert_has_calls(
[
call(
[
"nix",
"eval",
"--json",
"/path/to/config#nixosConfigurations.hostname.config.system.build.images",
"--apply",
"builtins.mapAttrs (n: v: v.passthru.filePath)",
],
check=True,
stdout=PIPE,
**DEFAULT_RUN_KWARGS,
),
call(
[
"nix",
"--extra-experimental-features",
"nix-command flakes",
"build",
"--print-out-paths",
"/path/to/config#nixosConfigurations.hostname.config.system.build.images.azure",
],
check=True,
stdout=PIPE,
**DEFAULT_RUN_KWARGS,
),
]
)


@patch.dict(nr.process.os.environ, {}, clear=True)
@patch(get_qualified_name(nr.process.subprocess.run), autospec=True)
def test_execute_nix_switch_flake(mock_run: Any, tmp_path: Path) -> None:
Expand Down
Loading

0 comments on commit e0d9824

Please sign in to comment.