nixos-installer/lib/flakes.nix
Niklas Gollenstede dd8fc696f4 improve installation scripting:
further improve scripting robustness, add fs.disks.devices.partitionDuringInstallation, ad fs.zfs.datasets.*.recursiveProps, add deploy-system-to-hetzner-vps install script
2023-05-02 02:13:24 +02:00

304 lines
24 KiB
Nix

dirname: inputs@{ self, nixpkgs, ...}: let
inherit (nixpkgs) lib;
inherit (import "${dirname}/vars.nix" dirname inputs) namesToAttrs mapMerge mapMergeUnique mergeAttrsUnique flipNames;
inherit (import "${dirname}/imports.nix" dirname inputs) getNixFiles importWrapped getOverlaysFromInputs getModulesFromInputs;
inherit (import "${dirname}/scripts.nix" dirname inputs) substituteImplicit extractBashFunction;
setup-scripts = (import "${dirname}/setup-scripts" "${dirname}/setup-scripts" inputs);
prefix = inputs.config.prefix;
inherit (import "${dirname}/misc.nix" dirname inputs) trace;
in rec {
# Simplified implementation of »flake-utils.lib.eachSystem«.
forEachSystem = systems: getSystemOutputs: flipNames (namesToAttrs getSystemOutputs systems);
# Sooner or later this should be implemented in nix itself, for now require »inputs.nixpkgs« and a system that can run »x86_64-linux« (native or through qemu).
patchFlakeInputs = inputs: patches: outputs: let
inherit ((import inputs.nixpkgs { overlays = [ ]; config = { }; system = "x86_64-linux"; }).pkgs) applyPatches fetchpatch;
in outputs (builtins.mapAttrs (name: input: if name != "self" && patches?${name} && patches.${name} != [ ] then (let
patched = applyPatches {
name = "${name}-patched"; src = "${input.sourceInfo or input}";
patches = map (patch: if patch ? url then fetchpatch patch else patch) patches.${name};
};
sourceInfo = (builtins.removeAttrs (input.sourceInfo or input) [ "narHash"]) // patched; # (keeps (short)rev, which is not really correct)
dir = if input?sourceInfo.outPath && lib.hasPrefix input.outPath input.sourceInfo.outPath then lib.removePrefix input.sourceInfo.outPath input.outPath else ""; # this should work starting with nix version 2.14 (before, they are the same path)
in (
# sourceInfo = { lastModified; lastModifiedDate; narHash; outPath; rev?; shortRev?; }
# A non-flake has only the attrs of »sourceInfo«.
# A flake has »{ inputs; outputs; sourceInfo; } // outputs // sourceInfo«, where »inputs« is what's passed to the outputs function without »self«, and »outputs« is the result of calling the outputs function. Don't know the merge priority.
# Since nix v2.14, the direct »outPath« has the relative location of the »dir« containing the »flake.nix« as suffix (if not "").
if (!input?sourceInfo) then sourceInfo else (let
outputs = (import "${patched.outPath}${dir}/flake.nix").outputs ({ self = sourceInfo // outputs; } // input.inputs);
in outputs // sourceInfo // { outPath = "${patched.outPath}${dir}"; inherit (input) inputs; inherit outputs; inherit sourceInfo; })
)) else input) inputs);
# Generates implicit flake outputs by importing conventional paths in the local repo. E.g.:
# outputs = inputs@{ self, nixpkgs, wiplib, ... }: wiplib.lib.wip.importRepo inputs ./. (repo@{ overlays, lib, ... }: let ... in [ repo ... ])
importRepo = inputs: flakePath': outputs: let
flakePath = builtins.path { path = flakePath'; name = "source"; }; # Referring to the current flake directory as »./.« is quite intuitive (and »inputs.self.outPath« causes infinite recursion), but without this it adds another hash to the path (because it copies it). For flakes with »dir != ""«, this includes only the ».« directory, making references to »./..« invalid, but ensuring that »./flake.nix« exists (there), and the below default paths are relative to that (and not whatever nix thought is the root of the repo).
# TODO: should _not_ do the above if it is not a direct store path
in let result = (outputs (
(let it = importWrapped inputs "${flakePath}/lib"; in if it.exists then {
lib = it.result;
} else { }) // (let it = importWrapped inputs "${flakePath}/overlays"; in if it.exists then {
overlays = { default = final: prev: builtins.foldl' (prev: overlay: prev // (overlay final prev)) prev (builtins.attrValues it.result); } // it.result;
} else { }) // (let it = importWrapped inputs "${flakePath}/modules"; in if it.exists then {
nixosModules = { default = { imports = builtins.attrValues it.result; }; } // it.result;
} else { })
)); in if (builtins.isList result) then mergeOutputs result else result;
# Combines »patchFlakeInputs« and »importRepo« in a single call. E.g.:
# outputs = inputs: let patches = {
# nixpkgs = [
# # remote: { url = "https://github.com/NixOS/nixpkgs/pull/###.diff"; sha256 = inputs.nixpkgs.lib.fakeSha256; }
# # local: ./overlays/patches/nixpkgs-###.patch # (use long native path to having the path change if any of the other files in ./. change)
# ]; # ...
# }; in inputs.wiplib.lib.wip.patchFlakeInputsAndImportRepo inputs patches ./. (inputs@{ self, nixpkgs, ... }: repo@{ nixosModules, overlays, lib, ... }: let ... in [ repo ... ])
patchFlakeInputsAndImportRepo = inputs: patches: flakePath: outputs: (
patchFlakeInputs inputs patches (inputs: importRepo inputs flakePath (outputs (inputs // {
self = inputs.self // { outPath = builtins.path { path = flakePath; name = "source"; }; }; # If the »flake.nix is in a sub dir of a repo, "${inputs.self}" would otherwise refer to the parent. (?)
})))
);
# Merges a list of flake output attribute sets.
mergeOutputs = outputList: lib.zipAttrsWith (type: values: (
if ((lib.length values) == 1) then (lib.head values)
else if (lib.all lib.isAttrs values) then (lib.zipAttrsWith (system: values: mergeAttrsUnique values) values)
else throw "Outputs.${type} has multiple values which are not all attribute sets, can't merge."
)) outputList;
# Given a path to a host config file, returns some properties defined in its first inline module (to be used where accessing them via »nodes.${name}.config...« isn't possible).
getSystemPreface = inputs: entryPath: args: let
imported = (importWrapped inputs entryPath).required ({ config = null; pkgs = null; lib = null; name = null; nodes = null; extraModules = null; } // args);
module = builtins.elemAt imported.imports 0; props = module.${prefix}.preface;
in if (
imported?imports && (builtins.isList imported.imports) && (imported.imports != [ ]) && module?${prefix}.preface && props?hardware
) then (props) else throw "File ${entryPath} must fulfill the structure: dirname: inputs: { ... }: { imports = [ { ${prefix}.preface = { hardware = str; ... } } ]; }";
# Builds the System Configuration for a single host. Since each host depends on the context of all other host (in the same "network"), this is essentially only callable through »mkNixosConfigurations«.
# See »mkSystemsFlake« for documentation of the arguments.
mkNixosConfiguration = args@{ name,
entryPath ? null, config ? null, preface ? null,
inputs ? { }, overlays ? (getOverlaysFromInputs inputs), modules ? (getModulesFromInputs inputs),
peers ? { }, nixosSystem ? inputs.nixpkgs.lib.nixosSystem, localSystem ? null,
renameOutputs ? (name: null), # If not supplied (explicitly or as »false« by »mkSystemsFlake«), assume the system is not exported.
... }: let
preface = args.preface or (getSystemPreface inputs entryPath (specialArgs // { inherit name; }));
targetSystem = "${preface.hardware}-linux"; buildSystem = if localSystem != null then localSystem else targetSystem;
specialArgs = (args.specialArgs or { }) // {
nodes = peers; # NixOPS
};
outputName = if renameOutputs != false then renameOutputs name else name;
in let system = { inherit preface; } // (nixosSystem {
system = buildSystem; # (this actually does nothing more than setting »config.nixpkgs.system« and can be null here)
modules = [ { imports = [ # Anything specific to only this evaluation of the module tree should go here.
(args.config or (importWrapped inputs entryPath).module)
{ _module.args.name = lib.mkOverride 99 name; } # (specialisations can somehow end up with the name »configuration«, which is very incorrect)
{ networking.hostName = name; }
]; _file = "${dirname}/flakes.nix#modules"; } ];
extraModules = modules ++ [ { imports = [ ({ # These are passed as »extraModules« module argument and can thus conveniently be reused when defining containers and such (Therefore define as much stuff as possible here).
# TODO: or should these be set as »baseModules«? Does that implicitly pass these into any derived configs?
}) ({ config, ... }: {
nixpkgs = { inherit overlays; }
// (if buildSystem != targetSystem then { localSystem.system = buildSystem; crossSystem.system = targetSystem; } else { system = targetSystem; });
_module.args = builtins.removeAttrs specialArgs [ "name" ]; # (pass the args here, so that they also apply to any other evaluation using »extraModules«)
${prefix}.base.includeInputs = lib.mkDefault (if config.boot.isContainer then inputs // {
self = null; # avoid changing (and thus restarting) the containers on every trivial change
} else inputs);
system.nixos.revision = lib.mkIf (inputs?nixpkgs.rev) inputs.nixpkgs.rev; # (evaluating the default value fails under some circumstances)
}) ({
options.${prefix}.preface.hardware = lib.mkOption { description = "The name of the system's CPU instruction set (the first part of what is often referred to as »system«)."; type = lib.types.str; readOnly = true; };
}) ({
options.${prefix}.preface.instances = lib.mkOption { description = "List of host names to instantiate this host config for, instead of just for the file name."; type = lib.types.listOf lib.types.str; readOnly = true; };
config.${prefix} = if preface?instances then { } else { preface.instances = [ name ]; };
}) ({
options.${prefix}.setup.scripts = lib.mkOption {
description = ''
Attrset of bash scripts defining functions that do installation and maintenance operations.
The functions should expect the bash options `pipefail` and `nounset` (`-u`) to be set.
See »./setup-scripts/README.md« for more information.
'';
type = lib.types.attrsOf (lib.types.nullOr (lib.types.submodule ({ name, config, ... }: { options = {
name = lib.mkOption { description = "Symbolic name of the script."; type = lib.types.str; default = name; readOnly = true; };
path = lib.mkOption { description = "Path of file for ».text« to be loaded from."; type = lib.types.nullOr lib.types.path; default = null; };
text = lib.mkOption { description = "Script text to process."; type = lib.types.str; default = builtins.readFile config.path; };
order = lib.mkOption { description = "Parsing order of the scripts. Higher orders will be parsed later, and can thus overwrite earlier definitions."; type = lib.types.int; default = 1000; };
}; })));
apply = lib.filterAttrs (k: v: v != null);
}; config.${prefix}.setup.scripts = lib.mapAttrs (name: path: lib.mkOptionDefault { inherit path; }) (setup-scripts);
}) ({ config, options, pkgs, ... }: {
options.${prefix}.setup.appliedScripts = lib.mkOption {
type = lib.types.functionTo lib.types.str; readOnly = true;
default = context: substituteImplicit { inherit pkgs; scripts = lib.sort (a: b: a.order < b.order) (lib.attrValues config.${prefix}.setup.scripts); context = { inherit config options pkgs inputs outputName; } // context; }; # inherit (builtins) trace;
};
}) ]; _file = "${dirname}/flakes.nix#extraModules"; } ];
specialArgs = { inherit outputName; };
# (This is already set during module import, while »_module.args« only becomes available during module evaluation (before that, using it causes infinite recursion). Since it can't be ensured that this is set in every circumstance where »extraModules« are being used, it should generally not be used to set custom arguments. The »outputName« is only applicable in the current evaluation anyway, though.)
}); in system;
# Given either a list (or attr set) of »files« (paths to ».nix« or ».nix.md« files for dirs with »default.nix« files in them) or a »dir« path (and optionally a list of base names to »exclude« from it), this builds the NixOS configuration for each host (per file) in the context of all configs provided.
# If »files« is an attr set, exactly one host with the attribute's name as hostname is built for each attribute. Otherwise the default is to build for one host per configuration file, named as the file name without extension or the sub-directory name. Setting »${prefix}.preface.instances« can override this to build the same configuration for those multiple names instead (the specific »name« is passed as additional »specialArgs« to the modules and can thus be used to adjust the config per instance).
# All other arguments are as specified by »mkSystemsFlake« and are passed to »mkNixosConfiguration«.
mkNixosConfigurations = args: let # { files, dir, exclude, ... }
files = args.files or (builtins.removeAttrs (getNixFiles args.dir) (args.exclude or [ ]));
files' = if builtins.isAttrs files then files else (builtins.listToAttrs (map (entryPath: let
stripped = builtins.match ''^(.*)[.]nix[.]md$'' (builtins.baseNameOf entryPath);
name = builtins.unsafeDiscardStringContext (if stripped != null then (builtins.elemAt stripped 0) else (builtins.baseNameOf entryPath));
in { inherit name; value = entryPath; }) files));
configs = mapMergeUnique (name: entryPath: (let
preface = (getSystemPreface inputs entryPath { });
in (mapMergeUnique (name: {
"${name}" = mkNixosConfiguration ((
builtins.removeAttrs args [ "files" "dir" "exclude" ]
) // {
inherit name entryPath; peers = configs;
});
}) (if !(args?files && builtins.isAttrs files) && preface?instances then preface.instances else [ name ])))) (files');
withId = lib.filterAttrs (name: node: node.preface?id) configs;
ids = mapMerge (name: node: { "${toString node.preface.id}" = name; }) withId;
duplicate = builtins.removeAttrs withId (builtins.attrValues ids);
in if duplicate != { } then (
throw "»${prefix}.preface.id«s are not unique! The following hosts share their IDs with some other host: ${builtins.concatStringsSep ", " (builtins.attrNames duplicate)}"
) else configs;
# Builds a system of NixOS hosts and exports them, plus »apps« and »devShells« to manage them, as flake outputs.
# All arguments are optional, as long as the default can be derived from the other arguments as passed.
mkSystemsFlake = args@{
# An attrset of imported Nix flakes, for example the argument(s) passed to the flake »outputs« function. All other arguments are optional (and have reasonable defaults) if this is provided and contains »self« and the standard »nixpkgs«. This is also the second argument passed to the individual host's top level config files.
inputs ? { },
# Arguments »{ files, dir, exclude, }« to »mkNixosConfigurations«, see there for details. May also be a list of those attrsets, in which case those multiple sets of hosts will be built separately by »mkNixosConfigurations«, allowing for separate sets of »peers« passed to »mkNixosConfiguration«. Each call will receive all other arguments, and the resulting sets of hosts will be merged.
systems ? ({ dir = "${inputs.self}/hosts"; exclude = [ ]; }),
# List of overlays to set as »config.nixpkgs.overlays«. Defaults to ».overlays.default« of all »overlayInputs«/»inputs« (incl. »inputs.self«).
overlays ? (getOverlaysFromInputs overlayInputs),
# (Subset of) »inputs« that »overlays« will be used from. Example: »{ inherit (inputs) self flakeA flakeB; }«.
overlayInputs ? inputs,
# List of Modules to import for all hosts, in addition to the default ones in »nixpkgs«. The host-individual module should selectively enable these. Defaults to ».nixosModules.default« of all »moduleInputs«/»inputs« (including »inputs.self«).
modules ? (getModulesFromInputs moduleInputs),
# (Subset of) »inputs« that »modules« will be used from. Example: »{ inherit (inputs) self flakeA flakeB; }«.
moduleInputs ? inputs,
# Additional arguments passed to each module evaluated for the host config (if that module is defined as a function).
specialArgs ? { },
# The »nixosSystem« function defined in »<nixpkgs>/flake.nix«, or equivalent.
nixosSystem ? inputs.nixpkgs.lib.nixosSystem,
# If provided, then cross compilation is enabled for all hosts whose target architecture is different from this. Since cross compilation currently fails for (some stuff in) NixOS, better don't set »localSystem«. Without it, building for other platforms works fine (just slowly) if »boot.binfmt.emulatedSystems« on the building system is configured for the respective target(s).
localSystem ? null,
## If provided, then change the name of each output attribute by passing it through this function. Allows exporting of multiple variants of a repo's hosts from a single flake:
renameOutputs ? false,
... }: let
otherArgs = args // { inherit inputs overlays modules specialArgs nixosSystem localSystem renameOutputs; };
nixosConfigurations = if builtins.isList systems then mergeAttrsUnique (map (systems: mkNixosConfigurations (otherArgs // systems)) systems) else mkNixosConfigurations (otherArgs // systems);
in let outputs = {
inherit nixosConfigurations;
} // (forEachSystem [ "aarch64-linux" "x86_64-linux" ] (localSystem: let
pkgs = (import inputs.nixpkgs { inherit overlays; system = localSystem; });
tools = lib.unique (map (p: p.outPath) (lib.filter lib.isDerivation pkgs.stdenv.allowedRequisites));
in rec {
apps = lib.mapAttrs (name: system: rec { type = "app"; derivation = writeSystemScripts { inherit name system pkgs; }; program = "${derivation}"; }) nixosConfigurations;
# dummy that just pulls in all system builds
packages.all-systems = pkgs.runCommandLocal "all-systems" { } ''
${''
mkdir -p $out/systems
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: system: "ln -sT ${system.config.system.build.toplevel} $out/systems/${if renameOutputs == false then name else renameOutputs name}") nixosConfigurations)}
''}
${''
mkdir -p $out/scripts
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: system: "ln -sT ${outputs.apps.${localSystem}.${name}.program} $out/scripts/${if renameOutputs == false then name else renameOutputs name}") nixosConfigurations)}
''}
${lib.optionalString (inputs != { }) ''
mkdir -p $out/inputs
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: { outPath, ... }: "ln -sT ${outPath} $out/inputs/${name}") inputs)}
''}
'';
checks.all-systems = packages.all-systems;
})); in if renameOutputs == false then outputs else {
nixosConfigurations = mapMerge (k: v: { ${renameOutputs k} = v; }) outputs.nixosConfigurations;
} // (forEachSystem [ "aarch64-linux" "x86_64-linux" ] (localSystem: {
apps = mapMerge (k: v: { ${renameOutputs k} = v; }) outputs.apps.${localSystem};
packages.${renameOutputs "all-systems"} = outputs.packages.${localSystem}.all-systems;
checks.${renameOutputs "all-systems"} = outputs.checks.${localSystem}.all-systems;
}));
# Do per-host setup and maintenance things:
# SYNOPSIS: nix run REPO#HOST [-- [sudo] [bash | [--command SCRIPT | FUNC] ...[ARG|OPTION]]]
# Where »REPO« is the path to a flake repo using »mkSystemsFlake« for it's »apps« output, and »HOST« is the name of a host it defines.
# If the first argument (after »--«) is »sudo«, then the program will re-execute itself as root using sudo (minus that »sudo« argument).
# If the (then) first argument is »bash«, or if there are no (more) arguments or options, it will execute an interactive shell with the variables and functions sourced.
# If an option »--command« is supplied, then the first positional argument is `eval`ed as bash instructions, otherwise the first argument is called as a function (or program).
# Either way, the remaining arguments and options have been parsed by »generic-arg-parse« and are available in »argv« and »args«.
# Examples:
# Install the host named »$target« to the image file »/tmp/system-$target.img«:
# $ nix run .#$target -- install-system /tmp/system-$target.img
# Run an interactive bash session with the setup functions in the context of the current host:
# $ nix run /etc/nixos/#$(hostname)
# Run a root session in the context of a different host (useful if Nix is not installed for root on the current host):
# $ nix run /etc/nixos/#other-host -- sudo
writeSystemScripts = {
name ? system._module.args.name,
system, pkgs,
}: let
tools = lib.unique (map (p: p.outPath) (lib.filter lib.isDerivation pkgs.stdenv.allowedRequisites));
in pkgs.writeShellScript "scripts-${name}" ''
# if first arg is »sudo«, re-execute this script with sudo (as root)
if [[ ''${1:-} == sudo ]] ; then shift ; exec sudo --preserve-env=SSH_AUTH_SOCK -- "$0" "$@" ; fi
# if the (now) first arg is »bash« or there are no args, re-execute this script as bash »--init-file«, starting an interactive bash in the context of the script
if [[ ''${1:-} == bash ]] || [[ $# == 0 && $0 == *-scripts-${name} ]] ; then
shift ; exec ${pkgs.bashInteractive}/bin/bash --init-file <(cat << "EOS"${"\n"+''
# prefix the script to also include the default init files
! [[ -e /etc/profile ]] || . /etc/profile
for file in ~/.bash_profile ~/.bash_login ~/.profile ; do
if [[ -r $file ]] ; then . $file ; break ; fi
done ; unset $file
# add active »hostName« to shell prompt
PS1=''${PS1/\\$/\\[\\e[93m\\](${name})\\[\\e[97m\\]\\$}
''}EOS
cat $0) -i -s ':' "$@"
fi
# provide installer tools (native to localSystem, not targetSystem)
hostPath=$PATH ; PATH=${lib.makeBinPath tools}
${extractBashFunction (builtins.readFile setup-scripts.utils) "generic-arg-parse"}
set -o pipefail -o nounset # (do not rely on errexit)
generic-arg-parse "$@" || exit
if [[ ''${args[debug]:-} ]] ; then # for the aliases to work, they have to be set before the functions are parsed
shopt -s expand_aliases # enable aliases in non-interactive bash
for control in return exit ; do alias $control='{
status=$? ; if ! (( status )) ; then '$control' 0 ; fi # control flow return
if ! ${pkgs.bashInteractive}/bin/bash --init-file ${system.config.environment.etc.bashrc.source} ; then '$control' $status ; fi # »|| '$control'« as an error-catch
}' ; done
fi
${system.config.${prefix}.setup.appliedScripts { native = pkgs; }}
# either call »argv[0]« with the remaining parameters as arguments, or if »$1« is »-c« eval »$2«.
if [[ ''${args[debug]:-} || ''${args[trace]:-} ]] ; then set -x ; fi
if [[ ''${args[command]:-} ]] ; then
command=''${argv[0]:?'With --command, the first positional argument must specify the commands to run.'} || exit
argv=( "''${argv[@]:1}" ) ; set -- "''${argv[@]}" ; eval "$command" || exit
else
entry=''${argv[0]:?} || exit
argv=( "''${argv[@]:1}" ) ; "$entry" "''${argv[@]}" || exit
fi
'';
}