diff --git a/lib/modules.nix b/lib/modules.nix index 3f2bfd478b0d..0d761c632d02 100644 --- a/lib/modules.nix +++ b/lib/modules.nix @@ -46,6 +46,7 @@ let showFiles showOption unknownModule + literalExample ; in @@ -116,6 +117,98 @@ rec { turned off. ''; }; + + _module.assertions = mkOption { + description = '' + Assertions and warnings to trigger during module evaluation. The + attribute name will be displayed when it is triggered, allowing + users to disable/change these assertions again if necessary. See + the section on Warnings and Assertions in the manual for more + information. + ''; + example = literalExample '' + { + gpgSshAgent = { + enable = config.programs.gnupg.agent.enableSSHSupport && config.programs.ssh.startAgent; + message = "You can't use ssh-agent and GnuPG agent with SSH support enabled at the same time!"; + }; + + grafanaPassword = { + enable = config.services.grafana.database.password != ""; + message = "Grafana passwords will be stored as plaintext in the Nix store!"; + type = "warning"; + }; + } + ''; + default = {}; + internal = true; + type = types.attrsOf (types.submodule { + # TODO: Rename to assertion? Or allow also setting assertion? + options.enable = mkOption { + description = '' + Whether to enable this assertion. + + This is the inverse of asserting a condition: If a certain + condition should be true, then this + option should be set to false when that + case occurs + + ''; + type = types.bool; + }; + + options.type = mkOption { + description = '' + The type of the assertion. The default + "error" type will cause evaluation to fail, + while the "warning" type will only show a + warning. + ''; + type = types.enum [ "error" "warning" ]; + default = "error"; + example = "warning"; + }; + + options.message = mkOption { + description = '' + The assertion message to display if this assertion triggers. + To display option names in the message, add + options to the module function arguments + and use ''${options.path.to.option}. + ''; + type = types.str; + example = literalExample '' + Enabling both ''${options.services.foo.enable} and ''${options.services.bar.enable} is not possible. + ''; + }; + + options.triggerPath = mkOption { + description = '' + The config path which when evaluated should + trigger this assertion. By default this is + [], meaning evaluating + config at all will trigger the assertion. + On NixOS this default is changed to + [ "system" "build" "toplevel" such that + only a system evaluation triggers the assertions. + + Evaluating config from within the current + module evaluation doesn't cause a trigger. Only accessing it + from outside will do that. This means it's easy to miss + assertions if this option doesn't have an externally-accessed + value. + + ''; + # Mark as internal as it's easy to misuse it + internal = true; + type = types.uniq (types.listOf types.str); + # Default to [], causing assertions to be triggered when + # anything is evaluated. This is a safe and convenient default. + default = []; + example = [ "system" "build" "vm" ]; + }; + }); + }; }; config = { @@ -154,6 +247,64 @@ rec { # paths, meaning recursiveUpdate will never override any value else recursiveUpdate freeformConfig declaredConfig; + /* + Inject a list of assertions into a config value, corresponding to their + triggerPath (meaning when that path is accessed from the result of this + function, the assertion triggers). + */ + injectAssertions = assertions: config: let + # Partition into assertions that are triggered on this level and ones that aren't + parted = partition (a: length a.triggerPath == 0) assertions; + + # From the ones that are triggered, filter out ones that aren't enabled + # and group into warnings/errors + byType = groupBy (a: a.type) (filter (a: a.enable) parted.right); + + # Triggers semantically are just lib.id, but they print warning cause errors in addition + warningTrigger = value: lib.foldr (w: warn w.show) value (byType.warning or []); + errorTrigger = value: + if byType.error or [] == [] then value else + throw '' + Failed assertions: + ${concatMapStringsSep "\n" (a: "- ${a.show}") byType.error} + ''; + # Trigger for both warnings and errors + trigger = value: warningTrigger (errorTrigger value); + + # From the non-triggered assertions, split off the first element of triggerPath + # to get a mapping from nested attributes to a list of assertions for that attribute + nested = zipAttrs (map (a: { + ${head a.triggerPath} = a // { + triggerPath = tail a.triggerPath; + }; + }) parted.wrong); + + # Recursively inject assertions if config is an attribute set and we + # have assertions under its attributes + result = + if isAttrs config + then + mapAttrs (name: value: + if nested ? ${name} + then injectAssertions nested.${name} value + else value + ) config + else config; + in trigger result; + + # List of assertions for this module evaluation, where each assertion also + # has a `show` attribute for how to show it if triggered + assertions = mapAttrsToList (name: value: + let id = + if hasPrefix "_" name then "" + else "[${showOption prefix}${optionalString (prefix != []) "/"}${name}] "; + in value // { + show = "${id}${value.message}"; + } + ) config._module.assertions; + + finalConfig = injectAssertions assertions (removeAttrs config [ "_module" ]); + checkUnmatched = if config._module.check && config._module.freeformType == null && merged.unmatchedDefns != [] then let @@ -173,7 +324,7 @@ rec { result = builtins.seq checkUnmatched { inherit options; - config = removeAttrs config [ "_module" ]; + config = finalConfig; inherit (config) _module; }; in result;