From 6639af3fc115a838b0c344fef7bb8df9df8e50a8 Mon Sep 17 00:00:00 2001 From: ibizaman Date: Fri, 27 Feb 2026 23:53:14 +0100 Subject: [PATCH 01/19] hardcoded-secret: init with contract for secrets This initial change demonstrates the concept of a contract: an expectation as verified by a test (here: `fileSecrets` test), that may be fulfilled by a provider (in our example here: `hardcoded-secret`). This simple example demonstrates that at their core, contracts do not technically require any machinery: without the test, our `hardcoded-secret` 'contract provider' module would still just work: This just uses existing NixOS machinery of options, types (with those of the abstract expectation in this change still left implicit: the interface used by the test to verify expected behavior), and tests. What this achieves is also just things the module system does: given the options, one may put info in, which may generate configuration, as well as lead to calculated options yielding information one may take back out again. --- nixos/modules/module-list.nix | 1 + nixos/modules/testing/hardcoded-secret.nix | 127 ++++++++++++++++++ nixos/tests/all-tests.nix | 3 + nixos/tests/contracts/default.nix | 4 + .../filesecrets/hardcoded-secret.nix | 29 ++++ nixos/tests/contracts/filesecrets/test.nix | 89 ++++++++++++ 6 files changed, 253 insertions(+) create mode 100644 nixos/modules/testing/hardcoded-secret.nix create mode 100644 nixos/tests/contracts/default.nix create mode 100644 nixos/tests/contracts/filesecrets/hardcoded-secret.nix create mode 100644 nixos/tests/contracts/filesecrets/test.nix diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index a9dcefebf99f3..4802fe4cbf3a5 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -2013,6 +2013,7 @@ ./tasks/stratis.nix ./tasks/swraid.nix ./tasks/trackpoint.nix + ./testing/hardcoded-secret.nix ./testing/service-runner.nix ./virtualisation/amazon-options.nix ./virtualisation/appvm.nix diff --git a/nixos/modules/testing/hardcoded-secret.nix b/nixos/modules/testing/hardcoded-secret.nix new file mode 100644 index 0000000000000..f553576868aa8 --- /dev/null +++ b/nixos/modules/testing/hardcoded-secret.nix @@ -0,0 +1,127 @@ +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.testing.hardcoded-secret; + + inherit (lib) mapAttrs' mkOption nameValuePair; + inherit (lib.types) + attrsOf + str + submodule + ; + inherit (pkgs) writeText; +in +{ + options.testing.hardcoded-secret = mkOption { + default = { }; + description = '' + Hardcoded file secrets. These should only be used in tests. + + They aim to replace the usage of pkgs.writeText in NixOS VM tests + as those make the file world readable + while this module set runtime permissions on the file. + This makes the tests more accurate, ensuring the permissions + set by the contract consumer are correct. + ''; + example = lib.literalExpression '' + { + mySecret = { + input = { + user = "me"; + mode = "0400"; + }; + content = "My Secret"; + }; + } + ''; + type = attrsOf ( + submodule ( + { name, ... }: + { + options = { + input = mkOption { + description = "Input of the contract for file secrets."; + type = lib.types.submodule { + options = { + mode = mkOption { + description = '' + Mode the secret file must have. + ''; + type = str; + default = "0400"; + }; + + owner = mkOption { + description = '' + Linux user that must own the secret file. + ''; + type = str; + default = "root"; + }; + + group = mkOption { + description = '' + Linux group that must own the secret file. + ''; + type = str; + default = "root"; + }; + }; + }; + }; + + output = mkOption { + description = "Output of the contract for file secrets."; + default = { }; + type = lib.types.submodule { + options = { + path = mkOption { + type = str; + description = '' + Path to the file containing the secret generated out of band. + + This path will exist after deploying to a target host, + it is not available through the nix store. + ''; + default = "/run/hardcodedsecrets/${name}"; + }; + }; + }; + }; + + content = mkOption { + type = str; + description = '' + Content of the secret as a string. + + This will be stored in the nix store and should only be used for testing or maybe in dev. + ''; + }; + }; + } + ) + ); + }; + + config = { + system.activationScripts = mapAttrs' ( + n: cfg': + let + source = writeText "hardcodedsecret_${n}_content" cfg'.content; + + inherit (cfg') input output; + in + nameValuePair "hardcodedsecret_${n}" '' + mkdir -p "$(dirname "${output.path}")" + touch "${output.path}" + chmod ${input.mode} "${output.path}" + chown ${input.owner}:${input.group} "${output.path}" + cp ${source} "${output.path}" + '' + ) cfg; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 378db6403e129..b302a306e6ac2 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -416,6 +416,9 @@ in containers-restart_networking = runTest ./containers-restart_networking.nix; containers-tmpfs = runTest ./containers-tmpfs.nix; containers-unified-hierarchy = runTest ./containers-unified-hierarchy.nix; + contracts = import ./contracts { + inherit runTest; + }; convos = runTest ./convos.nix; coredns = runTest ./coredns.nix; corerad = runTest ./corerad.nix; diff --git a/nixos/tests/contracts/default.nix b/nixos/tests/contracts/default.nix new file mode 100644 index 0000000000000..548829012fc85 --- /dev/null +++ b/nixos/tests/contracts/default.nix @@ -0,0 +1,4 @@ +{ runTest }: +{ + filesecrets-hardcoded-secret = runTest ./filesecrets/hardcoded-secret.nix; +} diff --git a/nixos/tests/contracts/filesecrets/hardcoded-secret.nix b/nixos/tests/contracts/filesecrets/hardcoded-secret.nix new file mode 100644 index 0000000000000..e90cadb767bdd --- /dev/null +++ b/nixos/tests/contracts/filesecrets/hardcoded-secret.nix @@ -0,0 +1,29 @@ +args@{ + lib, + config, + pkgs, + ... +}: +let + test = import ./test.nix args; +in +test { + name = "contracts-secrets-hardcoded-secret"; + providerRoot = [ + "testing" + "hardcoded-secret" + "mysecret" + ]; + extraModules = [ + ../../../modules/testing/hardcoded-secret.nix + ( + { config, ... }: + { + testing.hardcoded-secret.mysecret.content = config.test.content; + } + ) + ]; +} +// { + meta.maintainers = [ lib.maintainers.ibizaman ]; +} diff --git a/nixos/tests/contracts/filesecrets/test.nix b/nixos/tests/contracts/filesecrets/test.nix new file mode 100644 index 0000000000000..00d2bdf251df8 --- /dev/null +++ b/nixos/tests/contracts/filesecrets/test.nix @@ -0,0 +1,89 @@ +{ + lib, + config, + pkgs, + ... +}: +let + inherit (lib) getAttrFromPath setAttrByPath; + inherit (lib) mkOption types; +in +{ + name, + providerRoot, + extraModules ? [ ], +}: +{ + name = "contracts_filesecrets_${name}"; + + nodes.machine = + { config, ... }: + { + imports = extraModules; + + options.test = { + owner = mkOption { + type = types.str; + default = "root"; + }; + + group = mkOption { + type = types.str; + default = "root"; + }; + + mode = mkOption { + type = types.str; + default = "0400"; + }; + + content = mkOption { + type = types.str; + default = "a super secret secret!"; + }; + }; + + config = lib.mkMerge [ + (setAttrByPath providerRoot { + input = { + inherit (config.test) owner group mode; + }; + }) + (lib.mkIf (config.test.owner != "root") { + users.users.${config.test.owner}.isNormalUser = true; + }) + (lib.mkIf (config.test.group != "root") { + users.groups.${config.test.group} = { }; + }) + ]; + }; + + testScript = + { nodes, ... }: + let + cfg = nodes.machine; + inherit (getAttrFromPath providerRoot nodes.machine) output; + in + '' + owner = machine.succeed("stat -c '%U' ${output.path}").strip() + print(f"Got owner {owner}") + if owner != "${cfg.test.owner}": + raise Exception(f"Owner should be '${cfg.test.owner}' but got '{owner}'") + + group = machine.succeed("stat -c '%G' ${output.path}").strip() + print(f"Got group {group}") + if group != "${cfg.test.group}": + raise Exception(f"Group should be '${cfg.test.group}' but got '{group}'") + + mode = str(int(machine.succeed("stat -c '%a' ${output.path}").strip())) + print(f"Got mode {mode}") + wantedMode = str(int("${cfg.test.mode}")) + if mode != wantedMode: + raise Exception(f"Mode should be '{wantedMode}' but got '{mode}'") + + content = machine.succeed("cat ${output.path}").strip() + print(f"Got content {content}") + if content != "${cfg.test.content}": + raise Exception(f"Content should be '${cfg.test.content}' but got '{content}'") + ''; +} From e1d3439aa2fd9204421116e14592da72ba2955a6 Mon Sep 17 00:00:00 2001 From: cinereal Date: Fri, 3 Apr 2026 17:13:10 +0200 Subject: [PATCH 02/19] lib.attrsets: add helpers for nested attribute sets Add `lib.types.nestedAttrsOf`, as a nested attribute set of a given type. Further add functions in `lib.attrsets` to process values of this type: - `isNestedAttrsLeaf` - `mapNestedAttrsWith` - `mapNestedAttrs'` - `concatMapNestedAttrsWith` - `concatMapNestedAttrs'` Note that recursion over this type may either require all or any of the leaf types to be present. While this type and its helpers are generic in function, these will later be used for abstracting overc variable-depth nested attrsets used in name-spacing for contract instances. In that particular use-case, we may only presume for part of the leaf type's keys to be present. Signed-off-by: cinereal --- lib/attrsets.nix | 219 ++++++++++++++++++ lib/default.nix | 5 + lib/modules.nix | 43 ++++ lib/tests/misc.nix | 210 +++++++++++++++++ lib/tests/modules.sh | 5 + .../declare-nested-attrs-misshapen.nix | 22 ++ .../modules/declare-nested-attrs-unsound.nix | 17 ++ lib/tests/modules/declare-nested-attrs.nix | 16 ++ lib/tests/modules/types.nix | 1 + lib/types.nix | 99 ++++++++ 10 files changed, 637 insertions(+) create mode 100644 lib/tests/modules/declare-nested-attrs-misshapen.nix create mode 100644 lib/tests/modules/declare-nested-attrs-unsound.nix create mode 100644 lib/tests/modules/declare-nested-attrs.nix diff --git a/lib/attrsets.nix b/lib/attrsets.nix index ed2fa9770410d..a362e5b00d9ac 100644 --- a/lib/attrsets.nix +++ b/lib/attrsets.nix @@ -2258,4 +2258,223 @@ rec { ) intersection; in (x // y) // mask; + + /** + Determine whether a value is a leaf node when traversing a `nestedAttrsOf elemType` structure. + + `isNestedAttrsLeaf :: ((String -> Bool) -> [String] -> Bool) -> OptionType -> Any -> Bool` + + For scalar element types (e.g. `int`, `str`), uses the type's `.check`. + For submodule types, given their native type check works only after merge, + instead check key presence, controlled by `predicate` (`lib.any` or `lib.all`) + depending on whether the element submodule should allow additional keys. + + # Inputs + + `predicate` + + : 1\. A predicate combinator: `lib.any` treats a value as a leaf if *any* key matches a declared option; `lib.all` requires *all* keys to match + + `elemType` + + : 2\. The element option type of the `nestedAttrsOf` structure + + `v` + + : 3\. The value to test + + # Example + + ```nix + let + elementType = types.submodule { options.result = mkOption { type = types.int; }; }; + in + isNestedAttrsLeaf lib.any elementType { result = 1; } + # => true + isNestedAttrsLeaf lib.any elementType { a.b = { result = 1; }; } + # => false (intermediate attrset, no key matches "result") + ``` + */ + isNestedAttrsLeaf = + predicate: elemType: v: + let + # Top-level option names of the element type, used to detect leaves. + # Mirrors the heuristic in `nestedAttrsOf.merge`. + elemOptAttrs = elemType.getSubOptions [ ]; + in + if elemOptAttrs == { } then + elemType.check v + else + lib.isAttrs v && predicate (k: elemOptAttrs ? ${k}) (lib.attrNames v); + + /** + Map a function over all leaves in a `nestedAttrsOf` structure. + + `mapNestedAttrsWith :: ((String -> Bool) -> [String] -> Bool) -> nestedAttrsOf a -> (a -> b) -> nestedAttrsOf a -> nestedAttrsOf b` + + Traverses the nested attribute set recursively, applying `fn` only at leaf + values as determined by `isNestedAttrsLeaf predicate`. Intermediate attrset + nodes are preserved as-is in the output structure. + + # Inputs + + `predicate` + + : 1\. Leaf-detection combinator: `lib.any` or `lib.all` + + `type` + + : 2\. A `nestedAttrsOf` option type whose element type identifies leaves + + `fn` + + : 3\. Function applied to each leaf value + + `value` + + : 4\. The nested attribute set (or leaf) to traverse + + # Example + + ```nix + mapNestedAttrsWith lib.any (types.nestedAttrsOf types.int) (x: x * 2) { a.b = 1; a.c = 3; d = 5; } + # => { a.b = 2; a.c = 6; d = 10; } + ``` + */ + mapNestedAttrsWith = + predicate: + let + recurse = + type: fn: value: + if isNestedAttrsLeaf predicate type.nestedTypes.elemType value then + fn value + else + mapAttrs (_: recurse type fn) value; + in + recurse; + + /** + Map a function over all leaves in a `nestedAttrsOf` structure, preserving the structure. + + `mapNestedAttrs' :: nestedAttrsOf a -> (a -> b) -> nestedAttrsOf a -> nestedAttrsOf b` + + Traverses the nested attribute set recursively, + applying `fn` only at leaf values. + Intermediate attrset nodes are preserved as-is. + For submodule element types allowing additional keys, + while presuming no key of the nestred attrset structure is contained in the element submodule type. + + # Inputs + + `type` + + : 1\. A `nestedAttrsOf` option type whose element type identifies leaves + + `fn` + + : 2\. Function applied to each leaf value; result replaces the leaf in the output + + `value` + + : 3\. The nested attribute set to traverse + + # Example + + ```nix + mapNestedAttrs' (types.nestedAttrsOf types.int) (x: x * 2) { a.b = 1; a.c = 3; d = 5; } + # => { a.b = 2; a.c = 6; d = 10; } + ``` + */ + mapNestedAttrs' = mapNestedAttrsWith lib.any; + + /** + Like `concatMapNestedAttrs'`, but with a configurable leaf-detection predicate. + + `concatMapNestedAttrsWith :: ((String -> Bool) -> [String] -> Bool) -> nestedAttrsOf elemType -> ([String] -> elemType -> AttrSet b) -> nestedAttrsOf elemType -> AttrSet b` + + The `predicate` argument controls how submodule-typed leaves are detected from + intermediate namespace attrsets: pass `lib.any` (used by `concatMapNestedAttrs'`) + to treat a value as a leaf if *any* of its keys is a declared option of the element + type, or `lib.all` to require *all* keys to match. + + # Inputs + + `predicate` + + : 1\. Leaf-detection combinator: `lib.any` or `lib.all` + + `type` + + : 2\. A `nestedAttrsOf` option type whose element type identifies leaves + + `fn` + + : 3\. Function called at each leaf: receives the path (list of strings) and leaf value, returns an attrset + + `attrs` + + : 4\. The nested attribute set to traverse + + # Example + + ```nix + concatMapNestedAttrsWith lib.all (nestedAttrsOf (submodule { options.result = mkOption { type = int; }; })) + (path: v: { ${concatStringsSep "." path} = v.result; }) + { a.b = { result = 1; }; a.c = { result = 2; }; } + # => { "a.b" = 1; "a.c" = 2; } + ``` + */ + concatMapNestedAttrsWith = + predicate: type: fn: + let + recurse = + path: attrs: + lib.concatMapAttrs ( + name: v: + if isNestedAttrsLeaf predicate type.nestedTypes.elemType v then + fn (path ++ [ name ]) v + else + recurse (path ++ [ name ]) v + ) attrs; + in + recurse [ ]; + + /** + Like `concatMapAttrs`, but recurses into nested attribute sets, calling `fn` + only at leaves as determined by the element type of the given `nestedAttrsOf` type. + + The function `fn` receives the full path (as a list of strings) to the leaf + and the leaf value, and must return an attribute set to be merged into the result. + + Leaf detection mirrors `nestedAttrsOf.merge`: for simple element types (e.g. `int`) + `elemType.check` is used directly; for submodule element types (whose `check = isAttrs` + cannot distinguish leaves from intermediate nodes) a value is treated as a leaf when + all of its keys are declared options of the element type. + + `concatMapNestedAttrs' :: nestedAttrsOf elemType -> ([String] -> elemType -> AttrSet b) -> nestedAttrsOf elemType -> AttrSet b` + + # Inputs + + `type` + + : 1\. A `nestedAttrsOf` option type whose element type identifies leaves + + `fn` + + : 2\. Function called at each leaf: receives the path (list of strings) and leaf value, returns an attrset + + `attrs` + + : 3\. The nested attribute set to traverse + + # Example + + ```nix + concatMapNestedAttrs' (nestedAttrsOf (submodule { options.result = mkOption { type = int; }; })) + (path: v: { ${concatStringsSep "." path} = v.result; }) + { a.b = { result = 1; }; a.c = { result = 2; }; } + # => { "a.b" = 1; "a.c" = 2; } + ``` + */ + concatMapNestedAttrs' = concatMapNestedAttrsWith lib.any; } diff --git a/lib/default.nix b/lib/default.nix index 751738a98965d..ff4c89321f4c5 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -256,6 +256,11 @@ let isAttrs intersectAttrs removeAttrs + isNestedAttrsLeaf + mapNestedAttrs' + mapNestedAttrsWith + concatMapNestedAttrsWith + concatMapNestedAttrs' ; inherit (self.lists) singleton diff --git a/lib/modules.nix b/lib/modules.nix index c5c935144ea64..5de5f6f5b135f 100644 --- a/lib/modules.nix +++ b/lib/modules.nix @@ -1435,6 +1435,48 @@ let inherit highestPrio; }; + /** + Run the module-system property pipeline (`mkMerge` / `mkIf` + discharging followed by `mkOverride` priority filtering) on a list + of definitions. + + Intended for option types that handle their own per-leaf priority + composition - e.g. tree-shaped types like `nestedAttrsOf` whose + `merge` walks into sub-paths that the top-level pipeline (run by + `mergeDefinitions`) doesn't reach. Returns the same shape as + `filterOverrides'`: a `values` list of definitions stripped of + their override wrappers, plus the surviving `highestPrio`. + + `lib.modules.applyLeafProperties :: [{ file; value }] -> { values; highestPrio }` + + # Inputs + + `defs` + + : 1\. List of `{ file, value }` definitions for one logical leaf. + + # Example + + ```nix + # Inside an option type's `merge`: + let + processed = lib.modules.applyLeafProperties subDefs; + in + elemType.merge loc processed.values + ``` + */ + applyLeafProperties = + defs: + filterOverrides' ( + concatMap ( + d: + map (v: { + inherit (d) file; + value = v; + }) (dischargeProperties d.value) + ) defs + ); + /** Sort a list of properties. The sort priority of a property is defaultOrderPriority by default, but can be overridden by wrapping the property @@ -2292,6 +2334,7 @@ private # are just needed by types.nix, but are not meant to be consumed # externally. inherit + applyLeafProperties defaultOrderPriority defaultOverridePriority doRename diff --git a/lib/tests/misc.nix b/lib/tests/misc.nix index 6c254cae232b1..e61b113ca1d8b 100644 --- a/lib/tests/misc.nix +++ b/lib/tests/misc.nix @@ -78,6 +78,11 @@ let makeIncludePath makeOverridable mapAttrs + isNestedAttrsLeaf + mapNestedAttrsWith + mapNestedAttrs' + concatMapNestedAttrs' + concatMapNestedAttrsWith mapAttrsToListRecursive mapAttrsToListRecursiveCond mapCartesianProduct @@ -2179,6 +2184,211 @@ runTests { }; }; + # mapNestedAttrsWith: flat input (no nesting) - fn applied directly to each value + testMapNestedAttrsFlat = { + expr = mapNestedAttrsWith lib.any (types.nestedAttrsOf types.int) (x: x * 2) { + a = 1; + b = 3; + }; + expected = { + a = 2; + b = 6; + }; + }; + + # mapNestedAttrsWith: nested input - fn applied only at leaves, attrset nodes preserved + testMapNestedAttrsNested = { + expr = mapNestedAttrsWith lib.any (types.nestedAttrsOf types.int) (x: x * 2) { + a.b = 1; + a.c = 3; + d = 5; + }; + expected = { + a.b = 2; + a.c = 6; + d = 10; + }; + }; + + # mapNestedAttrsWith: empty input + testMapNestedAttrsEmpty = { + expr = mapNestedAttrsWith lib.any (types.nestedAttrsOf types.int) (x: x + 1) { }; + expected = { }; + }; + + # mapNestedAttrs': flat input - equivalent to mapNestedAttrsWith lib.any + testMapNestedAttrsFlat' = { + expr = mapNestedAttrs' (types.nestedAttrsOf types.int) (x: x * 2) { + a = 1; + b = 3; + }; + expected = { + a = 2; + b = 6; + }; + }; + + # mapNestedAttrs': nested input - structure preserved, apply `fn` only at leaves + testMapNestedAttrsNested' = { + expr = mapNestedAttrs' (types.nestedAttrsOf types.int) (x: x * 2) { + a.b = 1; + a.c = 3; + d = 5; + }; + expected = { + a.b = 2; + a.c = 6; + d = 10; + }; + }; + + # mapNestedAttrs': multiple siblings at same level all transformed + testMapNestedAttrsSiblings' = { + expr = + let + elementType = types.submodule { options.v = lib.mkOption { type = types.int; }; }; + in + mapNestedAttrs' (types.nestedAttrsOf elementType) (x: x.v) { + ns.a.v = 1; + ns.b.v = 2; + ns.c.v = 3; + }; + expected = { + ns.a = 1; + ns.b = 2; + ns.c = 3; + }; + }; + + # concatMapNestedAttrs': flat input - fn receives single-element path and leaf value + testConcatMapNestedAttrsFlat = { + expr = + concatMapNestedAttrs' (types.nestedAttrsOf types.int) + (path: x: { ${lib.concatStringsSep "." path} = x * 2; }) + { + a = 1; + b = 3; + }; + expected = { + a = 2; + b = 6; + }; + }; + + # concatMapNestedAttrs': nested input - fn receives full path list and leaf value, result is flat + testConcatMapNestedAttrsNested = { + expr = + concatMapNestedAttrs' (types.nestedAttrsOf types.int) + (path: x: { ${lib.concatStringsSep "." path} = x; }) + { + a.b = 1; + a.c = 2; + d = 3; + }; + expected = { + "a.b" = 1; + "a.c" = 2; + d = 3; + }; + }; + + # concatMapNestedAttrs': empty input + testConcatMapNestedAttrsEmpty' = { + expr = concatMapNestedAttrs' (types.nestedAttrsOf types.int) (path: x: { + ${lib.head path} = x; + }) { }; + expected = { }; + }; + + # isNestedAttrsLeaf: scalar type - leaf detected by elemType.check + testIsNestedAttrsLeafScalar = { + expr = isNestedAttrsLeaf lib.any types.int 42; + expected = true; + }; + + # isNestedAttrsLeaf: scalar type - non-leaf (attrset where int expected) + testIsNestedAttrsLeafScalarNonLeaf = { + expr = isNestedAttrsLeaf lib.any types.int { a = 1; }; + expected = false; + }; + + # isNestedAttrsLeaf: submodule type with lib.any - leaf when any key matches declared option + testIsNestedAttrsLeafSubmoduleAny = { + expr = + let + elementType = types.submodule { options.foo = lib.mkOption { type = types.int; }; }; + in + isNestedAttrsLeaf lib.any elementType { + foo = 1; + extra = "x"; + }; + expected = true; + }; + + # isNestedAttrsLeaf: submodule type with lib.all - non-leaf when not all keys match declared options + testIsNestedAttrsLeafSubmoduleAllFalse = { + expr = + let + elementType = types.submodule { options.foo = lib.mkOption { type = types.int; }; }; + in + isNestedAttrsLeaf lib.all elementType { + foo = 1; + extra = "x"; + }; + expected = false; + }; + + # isNestedAttrsLeaf: submodule type - intermediate attrset not a leaf (no keys match) + testIsNestedAttrsLeafSubmoduleIntermediate = { + expr = + let + elementType = types.submodule { options.foo = lib.mkOption { type = types.int; }; }; + in + isNestedAttrsLeaf lib.any elementType { + a.foo = 1; + }; + expected = false; + }; + + # concatMapNestedAttrsWith: lib.all predicate - only detects leaves where all keys match declared options + testConcatMapNestedAttrsWithAll = { + expr = + let + elementType = types.submodule { options.foo = lib.mkOption { type = types.int; }; }; + in + concatMapNestedAttrsWith lib.all (types.nestedAttrsOf elementType) + (path: v: { ${lib.concatStringsSep "." path} = v.foo; }) + { + a = { + b.foo = 1; + c.foo = 2; + }; + }; + expected = { + "a.b" = 1; + "a.c" = 2; + }; + }; + + # concatMapNestedAttrsWith: lib.any predicate - detects leaves even with extra keys alongside declared options + testConcatMapNestedAttrsWithAny = { + expr = + let + elementType = types.submodule { options.foo = lib.mkOption { type = types.int; }; }; + in + concatMapNestedAttrsWith lib.any (types.nestedAttrsOf elementType) + (path: v: { ${lib.concatStringsSep "." path} = v.foo; }) + { + a = { + foo = 1; + extra = "x"; + }; + }; + expected = { + a = 1; + }; + }; + testFilterAttrs = { expr = filterAttrs (n: v: n != "a" && (v.hello or false) == true) { a.hello = true; diff --git a/lib/tests/modules.sh b/lib/tests/modules.sh index 94aeea299e040..011c766ec6076 100755 --- a/lib/tests/modules.sh +++ b/lib/tests/modules.sh @@ -438,6 +438,11 @@ checkConfigOutput '^"hello"$' config.packageInvalidIdentifier.pname ./declare-mk checkConfigOutput '^"pkgs\.\\"123\\"\.\\"with\\\\\\"quote\\"\.hello"$' options.packageInvalidIdentifier.defaultText.text ./declare-mkPackageOption.nix checkConfigOutput '^"pkgs\.\\"123\\"\.\\"with\\\\\\"quote\\"\.hello"$' options.packageInvalidIdentifierExample.example.text ./declare-mkPackageOption.nix +# Check nestedAttrsOf +checkConfigOutput '^3$' config.value.b.d.e ./declare-nested-attrs.nix +checkConfigError 'A definition for option .* is not of type .*' config.value.b.f ./declare-nested-attrs-unsound.nix +checkConfigError 'expected an attribute set with keys from \[request, result\]' config.value.consumer ./declare-nested-attrs-misshapen.nix + # submoduleWith ## specialArgs should work diff --git a/lib/tests/modules/declare-nested-attrs-misshapen.nix b/lib/tests/modules/declare-nested-attrs-misshapen.nix new file mode 100644 index 0000000000000..5f74092abc060 --- /dev/null +++ b/lib/tests/modules/declare-nested-attrs-misshapen.nix @@ -0,0 +1,22 @@ +{ lib, ... }: +{ + options.value = lib.mkOption { + type = lib.types.nestedAttrsOf ( + lib.types.submodule { + options = { + request = lib.mkOption { + type = lib.types.int; + default = 0; + }; + result = lib.mkOption { + type = lib.types.int; + default = 0; + }; + }; + } + ); + default = { }; + }; + # Misshapen: scalar where an attrset `{ request = ...; result = ...; }` was expected. + config.value.consumer = 1; +} diff --git a/lib/tests/modules/declare-nested-attrs-unsound.nix b/lib/tests/modules/declare-nested-attrs-unsound.nix new file mode 100644 index 0000000000000..4cc3ff23035eb --- /dev/null +++ b/lib/tests/modules/declare-nested-attrs-unsound.nix @@ -0,0 +1,17 @@ +{ lib, ... }: + +{ + options = { + value = lib.mkOption { + type = lib.types.nestedAttrsOf lib.types.int; + default = { + a = 1; + b = { + c = 2; + d.e = 3; + f = "4"; + }; + }; + }; + }; +} diff --git a/lib/tests/modules/declare-nested-attrs.nix b/lib/tests/modules/declare-nested-attrs.nix new file mode 100644 index 0000000000000..c10b357a2cee7 --- /dev/null +++ b/lib/tests/modules/declare-nested-attrs.nix @@ -0,0 +1,16 @@ +{ lib, ... }: + +{ + options = { + value = lib.mkOption { + type = lib.types.nestedAttrsOf lib.types.int; + default = { + a = 1; + b = { + c = 2; + d.e = 3; + }; + }; + }; + }; +} diff --git a/lib/tests/modules/types.nix b/lib/tests/modules/types.nix index 6b11defbae362..d1da9d457d0b7 100644 --- a/lib/tests/modules/types.nix +++ b/lib/tests/modules/types.nix @@ -540,6 +540,7 @@ in # json & toml assert json.description == "JSON value"; assert toml.description == "TOML value"; + assert (types.nestedAttrsOf types.int).description == "nested attribute set of signed integer"; # done "ok"; }; diff --git a/lib/types.nix b/lib/types.nix index f9ae3c82a1720..a4eb8f0f452d3 100644 --- a/lib/types.nix +++ b/lib/types.nix @@ -1795,6 +1795,105 @@ rec { nestedTypes.finalType = finalType; }; + # `nestedAttrsOf elemType`: an attribute set nested to arbitrary depth whose + # leaves are of `elemType`. Namespace nodes recurse; leaves delegate to + # `elemType.merge`, so the element type owns how duplicate definitions at one + # leaf combine. A leaf that must tolerate agreeing duplicates simply uses an + # element type whose `merge` does so (e.g. `mergeEqualOption` instead of `raw`'s + # `mergeOneOption`) -- the nested type stays generic and hard-codes no duplicate + # handling of its own. + nestedAttrsOf = + elemType: + let + self = lib.types.mkOptionType { + name = "nestedAttrsOf"; + description = "nested attribute set of ${elemType.description}"; + descriptionClass = "composite"; + nestedTypes.elemType = elemType; + check = + value: elemType.check value || (lib.isAttrs value && lib.all self.check (lib.attrValues value)); + + # Merge a list of definitions at a given location. + merge = + loc: defs: + let + # Collect all attribute names mentioned across every definition. + allNames = lib.unique (lib.concatMap (def: lib.attrNames def.value) defs); + + # Top-level option names of the element type, used to detect leaves. + elemOptAttrs = elemType.getSubOptions [ ]; + + mergeAttr = + name: + let + # Narrow to the definitions that actually set this attribute, + # then run the module-system property pipeline on them so + # per-leaf priority wrappers + # (`mkOverride`/`mkDefault`/`mkOptionDefault`/`mkMerge`/`mkIf`) + # compose like they do at the top of an option. Without this, + # a tree-level `mkOptionDefault someTree` from one module is + # wiped out entirely by a normal-priority leaf override from + # another, and callers have to compose with + # `lib.recursiveUpdate` themselves. + rawSubDefs = lib.concatMap ( + def: + lib.optional (def.value ? ${name}) { + inherit (def) file; + value = def.value.${name}; + } + ) defs; + subDefs = (lib.modules.applyLeafProperties rawSubDefs).values; + + # Decide whether to recurse or delegate to the element type. + # We consider a value a leaf if its keys match the element type's known options. + # Otherwise it is an intermediate namespace attrset and we recurse. + # This is a way to cope with submodule types, as their `check` function accepts any attrset. + # While it would be nice to check their elements' types as well, + # the elements' types would only be available after merge. + isLeafLike = d: lib.all (k: elemOptAttrs ? ${k}) (lib.attrNames d.value); + goDeeper = lib.all (d: lib.isAttrs d.value && !isLeafLike d) subDefs; + + # When the element type is a structured submodule (has known options) + # and a definition supplies a non-attrset, non-path value, throw a + # clear error instead of letting submodule.merge try to import it as + # a module path, which produces cryptic "doesn't represent an absolute + # path" or "No such file or directory" messages. + misshapen = + if elemOptAttrs == { } then + null + else + lib.findFirst (d: !lib.isAttrs d.value && !builtins.isPath d.value) null subDefs; + in + if misshapen != null then + throw '' + In `${lib.options.showOption (loc ++ [ name ])}` (from ${misshapen.file}): + expected an attribute set with keys from [${ + lib.concatStringsSep ", " (lib.filter (k: k != "_module") (lib.attrNames elemOptAttrs)) + }], + but got a ${builtins.typeOf misshapen.value}. + This usually means a level of nesting is missing — the body was written directly at the parent path instead of being wrapped under one of the expected keys. + '' + else if goDeeper then + self.merge (loc ++ [ name ]) subDefs + else + elemType.merge (loc ++ [ name ]) subDefs; + in + lib.genAttrs allNames mergeAttr; + + # Forward sub-option/module machinery to the element type so that + # tooling (e.g. `nixos-option`) can still introspect leaf values. + getSubOptions = prefix: elemType.getSubOptions (prefix ++ [ "" ]); + getSubModules = elemType.getSubModules; + substSubModules = m: nestedAttrsOf (elemType.substSubModules m); + + # Standard functor so the type integrates with lib.types combinators. + functor = (lib.types.defaultFunctor "nestedAttrsOf") // { + wrapped = elemType; + }; + }; + in + self; + /** Augment the given type with an additional type check function. From fe2762b3abad5c049a384f14cbd552b474ee63cd Mon Sep 17 00:00:00 2001 From: cinereal Date: Wed, 15 Apr 2026 15:07:56 +0200 Subject: [PATCH 03/19] lib: add module system extensions for contracts Adds module system helper functions intended to facilitate contracts: - `lib.evalOption`: evaluate config in the context of an option, which allows using the type checks (and calculated options) of the module system as a function, e.g. for use in `lib`. Further adds two functions intended to facilitate extending submodule options/types, so as to resolve contracts' challenge of deduplicating types that may differ largely in overriding `default` values. - `lib.extendOption`: extend a submodule option with overrides - `lib.extendSubmodule`: extend a submodule type with overrides Signed-off-by: cinereal --- lib/default.nix | 3 + lib/modules.nix | 150 ++++++++++++++++++++++++++ lib/tests/modules.sh | 4 + lib/tests/modules/evalOption.nix | 29 +++++ lib/tests/modules/extendOption.nix | 21 ++++ lib/tests/modules/extendSubmodule.nix | 21 ++++ 6 files changed, 228 insertions(+) create mode 100644 lib/tests/modules/evalOption.nix create mode 100644 lib/tests/modules/extendOption.nix create mode 100644 lib/tests/modules/extendSubmodule.nix diff --git a/lib/default.nix b/lib/default.nix index ff4c89321f4c5..302f28b91073f 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -478,6 +478,9 @@ let ; inherit (self.modules) evalModules + evalOption + extendOption + extendSubmodule setDefaultModuleLocation unifyModuleSyntax applyModuleArgsIfFunction diff --git a/lib/modules.nix b/lib/modules.nix index 5de5f6f5b135f..42e5e761057f8 100644 --- a/lib/modules.nix +++ b/lib/modules.nix @@ -2185,6 +2185,153 @@ let config = lib.importTOML file; }; + /** + Evaluate a configuration in the context of a corresponding module system option. + + lib.evalOption :: option -> attrs -> attrs + + # Inputs + + `option` + + : 1\. Module system option in which to evaluate the configuration + + `conf` + + : 2\. Configuration to evaluate within the option + + # Example + + ```nix + lib.evalOption + (lib.mkOption { + default = { }; + type = lib.types.submodule { + options.foo = lib.mkOption { type = lib.types.int; }; + }; + }) + { foo = 1; } + # => { foo = 1; } + ``` + */ + evalOption = + option: conf: + (evalModules { + modules = [ + { + options.opt = option; + config.opt = conf; + } + ]; + }).config.opt; + + /** + Extend a (sub-)module option with a set of overrides. + + lib.extendOption :: attrs -> option -> option + + # Inputs + + `overrides` + + : 1\. A (recursive) attrset of fields to add to the option + + `opt` + + : 2\. Option to extend with `overrides` + + # Example + + ```nix + lib.extendOption + { bar.default = 10; } + (lib.mkOption { + type = lib.types.submodule { + options.bar = lib.mkOption { type = lib.types.int; }; + }; + }) + ``` + */ + extendOption = + overrides: opt: + let + inherit (opt) type; + isSubmodule = lib.isOptionType type && type.name == "submodule"; + # (deduplicated) keys from submodules to iterate over + # we don't need the values, but attrset offers O(1) containment checks + subOptions = optionalAttrs isSubmodule ( + lib.attrsets.mergeAttrsList (map (mod: mod.options or { }) type.getSubModules) + ); + subOverrides = lib.filterAttrs (k: _: subOptions ? ${k}) overrides; + directOverrides = lib.filterAttrs (k: _: !(subOptions ? ${k})) overrides; + in + mkOption ( + # re-wrap existing option attributes + (removeAttrs opt [ "_type" ]) + # if we are annotating something that isn't a sub-module, + # just override relevant attributes on the option + // directOverrides + # to annotate a sub-module, presume we should just annotate its sub-options, + # which we iterate over to reconstruct with relevant annotations. + // optionalAttrs isSubmodule { + default = { }; + type = lib.types.submoduleWith ( + # meta we will not change + type.functor.payload + # modules are the parameter that will change as per our overrides + // { + modules = [ + { + options = mapAttrs ( + k: _: + let + # option for the attribute in question: for now presume just one sub-module had it + attrOpt = (head (filter (m: (m.options or { }) ? ${k}) type.getSubModules)).options.${k}; + # any overrides for the attribute in question + attrOverrides = subOverrides.${k} or { }; + in + # recurse until we have no more overrides to annotate the option with + if attrOverrides != { } then extendOption attrOverrides attrOpt else attrOpt + ) subOptions; + } + ]; + } + ); + } + ); + + /** + Extend a (sub-)module type with a set of overrides. + + lib.extendSubmodule :: attrs -> optionType -> optionType + + # Inputs + + `overrides` + + : 1\. A (recursive) attrset of fields to add to the option + + `mod` + + : 2\. A (sub-)module type to extend with `overrides` + + # Example + + ```nix + lib.extendSubmodule + { bar.default = 10; } + (lib.types.submodule { + options.bar = lib.mkOption { type = lib.types.int; }; + }) + ``` + */ + extendSubmodule = + overrides: mod: + (extendOption overrides (mkOption { + default = { }; + type = mod; + })).type; + private = mapAttrs ( @@ -2339,7 +2486,10 @@ private defaultOverridePriority doRename evalModules + evalOption evalOptionValue # for use by lib.types + extendOption + extendSubmodule filterOverrides filterOverrides' fixMergeModules diff --git a/lib/tests/modules.sh b/lib/tests/modules.sh index 011c766ec6076..a3763a3a63748 100755 --- a/lib/tests/modules.sh +++ b/lib/tests/modules.sh @@ -792,6 +792,10 @@ checkConfigOutput '^true$' config.viaConfig ./mkDefinition.nix checkConfigOutput '^true$' config.mkMerge ./mkDefinition.nix checkConfigOutput '^true$' config.mkForce ./mkDefinition.nix +checkConfigOutput '2' config.bar.baz ./evalOption.nix +checkConfigError 'not of type' config.foo.boo.bar ./extendOption.nix +checkConfigError 'not of type' config.foo.boo.bar ./extendSubmodule.nix + # specialArgs._class checkConfigOutput '"nixos"' config.nixos.config.foo ./specialArgs-class.nix checkConfigOutput '"bar"' config.conditionalImportAsNixos.config.foo ./specialArgs-class.nix diff --git a/lib/tests/modules/evalOption.nix b/lib/tests/modules/evalOption.nix new file mode 100644 index 0000000000000..d3c4ae0f60c5b --- /dev/null +++ b/lib/tests/modules/evalOption.nix @@ -0,0 +1,29 @@ +{ lib, ... }: +let + inherit (lib) mkOption types; +in +{ + options.bar = mkOption { + default = + lib.evalOption + (mkOption { + default = { }; + type = types.submodule ( + { config, ... }: + { + options = { + foo = mkOption { + type = types.int; + }; + baz = mkOption { + default = config.foo + 1; + }; + }; + } + ); + }) + { + foo = 1; + }; + }; +} diff --git a/lib/tests/modules/extendOption.nix b/lib/tests/modules/extendOption.nix new file mode 100644 index 0000000000000..b69db158067c4 --- /dev/null +++ b/lib/tests/modules/extendOption.nix @@ -0,0 +1,21 @@ +{ lib, ... }: +{ + options.foo = + lib.extendOption + { + boo.bar.default = "baz"; + } + ( + lib.mkOption { + default = { }; + type = lib.types.submodule { + options.boo = lib.mkOption { + default = { }; + type = lib.types.submodule { + options.bar = lib.mkOption { type = lib.types.int; }; + }; + }; + }; + } + ); +} diff --git a/lib/tests/modules/extendSubmodule.nix b/lib/tests/modules/extendSubmodule.nix new file mode 100644 index 0000000000000..cf2700881244a --- /dev/null +++ b/lib/tests/modules/extendSubmodule.nix @@ -0,0 +1,21 @@ +{ lib, ... }: +{ + options.foo = lib.mkOption { + default = { }; + type = + lib.extendSubmodule + { + boo.bar.default = "baz"; + } + ( + lib.types.submodule { + options.boo = lib.mkOption { + default = { }; + type = lib.types.submodule { + options.bar = lib.mkOption { type = lib.types.int; }; + }; + }; + } + ); + }; +} From e495f39d7aa98e3e0c99cff5b17eed55dd605a74 Mon Sep 17 00:00:00 2001 From: cinereal Date: Wed, 15 Apr 2026 15:08:18 +0200 Subject: [PATCH 04/19] lib/contracts: add contracts system Adds a central place for storing contracts, alongside a way to aggregate requests to be handled by preferred (default) providers for the contract. The contracts are presented as typed request/result interfaces between consumers and providers. Storing the contracts themselves in `lib` is technically optional, but any stored in there we can generate documentation for. One may nevertheless extend this nixpkgs-provided set of contracts with their own in `config.contracts`. Note that our contracts module is intended to be environment-agnostic, that is, should work not just in NixOS, but also in modular services and e.g. home-manager. That said, providers are expected to be specific to an environment: each environment exposes a different set of configuration options (while if we had no need to set configuration options, one might as well use plain functions over contracts). `lib/contracts` uses the following structure: - `default.nix`: entrypoint for `lib.contract` - `helpers.nix`: helper functions for use with contracts: so far `isInstance` - `module.nix`: contracts module with `contractDefinitions` and `contracts` - `templates/default.nix`: exposes contract templates in this directory as `lib.contracts` - `templates/file-secrets.nix`: example contract template `fileSecrets` Signed-off-by: cinereal --- lib/contracts/default.nix | 10 + lib/contracts/definitions/default.nix | 6 + lib/contracts/definitions/file-secrets.nix | 135 +++++++ lib/contracts/helpers.nix | 21 ++ lib/contracts/module.nix | 394 +++++++++++++++++++++ lib/default.nix | 4 + lib/tests/modules.sh | 3 + lib/tests/modules/contracts-basic.nix | 53 +++ 8 files changed, 626 insertions(+) create mode 100644 lib/contracts/default.nix create mode 100644 lib/contracts/definitions/default.nix create mode 100644 lib/contracts/definitions/file-secrets.nix create mode 100644 lib/contracts/helpers.nix create mode 100644 lib/contracts/module.nix create mode 100644 lib/tests/modules/contracts-basic.nix diff --git a/lib/contracts/default.nix b/lib/contracts/default.nix new file mode 100644 index 0000000000000..868f4c2165574 --- /dev/null +++ b/lib/contracts/default.nix @@ -0,0 +1,10 @@ +# set of utilities for using contracts. +# templates of individual contracts are stored in `./templates`. +{ lib, ... }: +let + callLibs = file: import file { inherit lib; }; +in +{ + module = ./module.nix; +} +// callLibs ./helpers.nix diff --git a/lib/contracts/definitions/default.nix b/lib/contracts/definitions/default.nix new file mode 100644 index 0000000000000..67d40dedcfa3e --- /dev/null +++ b/lib/contracts/definitions/default.nix @@ -0,0 +1,6 @@ +# collection of contract templates, defined in `lib` so we can still build the manual. +# declarations of individual contracts follow the type in `./definition-type.nix`. +{ lib, ... }: +lib.mapAttrs (_: path: import path { inherit lib; }) { + fileSecrets = ./file-secrets.nix; +} diff --git a/lib/contracts/definitions/file-secrets.nix b/lib/contracts/definitions/file-secrets.nix new file mode 100644 index 0000000000000..2d2c1844296fb --- /dev/null +++ b/lib/contracts/definitions/file-secrets.nix @@ -0,0 +1,135 @@ +{ + lib, + ... +}: +let + inherit (lib) mkOption types; + inherit (types) str; +in +{ + meta = { + description = '' + Contract for secrets handling where a consumer requests a secret + and a provider provides it at runtime at a given file path. + ''; + maintainers = with lib.maintainers; [ + ibizaman + kiara + ]; + }; + interface = { + request = { + mode = mkOption { + description = '' + Mode the secret file must have. + ''; + type = str; + default = "0400"; + }; + + owner = mkOption { + description = '' + Linux user that must own the secret file. + ''; + type = str; + }; + + group = mkOption { + description = '' + Linux group that must own the secret file. + ''; + type = str; + }; + }; + result = { + path = mkOption { + type = str; + description = '' + Path to the file containing the secret generated out of band. + + This path will exist after deploying to a target host, + it is not available through the nix store. + ''; + }; + }; + }; + behaviorTest = + { + name, + providerRoot, + extraModules ? [ ], + }: + { + name = "contracts_filesecrets_${name}"; + nodes.machine = + { config, ... }: + { + imports = extraModules; + + options.test = { + owner = mkOption { + type = str; + default = "root"; + }; + + group = mkOption { + type = str; + default = "root"; + }; + + mode = mkOption { + type = str; + default = "0400"; + }; + + content = mkOption { + type = str; + default = "a super secret secret!"; + }; + }; + + config = lib.mkMerge [ + (lib.setAttrByPath providerRoot { + request = { + inherit (config.test) owner group mode; + }; + }) + (lib.mkIf (config.test.owner != "root") { + users.users.${config.test.owner}.isNormalUser = true; + }) + (lib.mkIf (config.test.group != "root") { + users.groups.${config.test.group} = { }; + }) + ]; + }; + + testScript = + { nodes, ... }: + let + cfg = nodes.machine; + inherit (lib.getAttrFromPath providerRoot nodes.machine) result; + in + '' + owner = machine.succeed("stat -c '%U' ${result.path}").strip() + print(f"Got owner {owner}") + if owner != "${cfg.test.owner}": + raise Exception(f"Owner should be '${cfg.test.owner}' but got '{owner}'") + + group = machine.succeed("stat -c '%G' ${result.path}").strip() + print(f"Got group {group}") + if group != "${cfg.test.group}": + raise Exception(f"Group should be '${cfg.test.group}' but got '{group}'") + + mode = str(int(machine.succeed("stat -c '%a' ${result.path}").strip())) + print(f"Got mode {mode}") + wantedMode = str(int("${cfg.test.mode}")) + if mode != wantedMode: + raise Exception(f"Mode should be '{wantedMode}' but got '{mode}'") + + content = machine.succeed("cat ${result.path}").strip() + print(f"Got content {content}") + if content != "${cfg.test.content}": + raise Exception(f"Content should be '${cfg.test.content}' but got '{content}'") + ''; + }; +} diff --git a/lib/contracts/helpers.nix b/lib/contracts/helpers.nix new file mode 100644 index 0000000000000..67550e01332e2 --- /dev/null +++ b/lib/contracts/helpers.nix @@ -0,0 +1,21 @@ +{ lib, ... }: +{ + /** + Whether a value is a contract instance (an attrset with `request` and `result`). + + Useful when an option accepts both a contract type and a plain value + (e.g. `types.oneOf [ types.path contractType ]` or `types.nullOr contractType`) + and the consumer needs to branch on which was provided. + + lib.contract.isInstance :: a -> bool + + # Example + + ```nix + if lib.contract.isInstance cfg.passwordFile + then cfg.passwordFile.result.path + else cfg.passwordFile + ``` + */ + isInstance = v: lib.isAttrs v && v ? result; +} diff --git a/lib/contracts/module.nix b/lib/contracts/module.nix new file mode 100644 index 0000000000000..ba20000f06ca8 --- /dev/null +++ b/lib/contracts/module.nix @@ -0,0 +1,394 @@ +{ lib, config, ... }: +let + inherit (lib) mkOption types; + inherit (types) + attrsOf + nestedAttrsOf + raw + submodule + ; + # `or` fallbacks allow the docs build sandbox to evaluate this module: + # the sandbox passes a fake `config` via `specialArgs` that lacks these attributes. + contractDefinitions = config.contractDefinitions or lib.contracts; +in +{ + options = { + contractDefinitions = mkOption { + description = '' + Types of contracts. + For info on how to instantiate these, see `config.contracts`. + + To create a new contract type, add an instance of `config.contractDefinitions.""` + defining `meta` and `interface` options, or when adding to nixpkgs, + preferably adding one in `lib/contracts`. + + Nixpkgs-shipped contract types (`lib.contracts`) are available automatically + via a fallback in `contracts` option generation. Additional (user-defined) + contract types added here are merged alongside the lib-shipped ones. + + **Integrating into a new module system** (e.g. home-manager, nix-darwin): + + 1\. Import this module (`lib/contracts/module.nix`). + + 2\. Seed `config.contractDefinitions` with `lib.contracts` so that nixpkgs-shipped + contract definitions are available alongside any user-defined types. + + Both steps are combined in a thin wrapper module; see + `nixos/modules/contracts/default.nix` for the reference implementation. + ''; + # types are in `lib` as the docs build's sandbox has no `config`. + type = attrsOf raw; + }; + contracts = mkOption { + description = '' + Contract instances, keyed by contract type. + + This option is system-agnostic - it works identically in NixOS + and any module system that imports `lib/contracts/module.nix`. + + Consumers set `contracts..want`, providers set `contracts..providers`, + and results are read from `contracts..results`. + ''; + type = submodule { + options = lib.mapAttrs ( + contractName: contractType: + let + inherit (contractType) meta interface; + wantType = nestedAttrsOf (submodule { + options = { + request = mkOption { + description = '' + The request parameters. + Must match the `${contractName}` contract interface's request type. + ''; + type = submodule { + options = interface.request; + }; + }; + result = mkOption { + description = '' + Result returned to the request by the provider's side of the `${contractName}` contract. + Must match the `${contractName}` contract interface's result type. + ''; + type = submodule { + options = interface.result; + }; + }; + }; + }); + in + mkOption { + description = '' + ${meta.description} + + Providers for the contract may be implemented by defining an option as follows: + + ```nix + { lib, ... }: + let + inherit (lib.contracts.${contractName}) mkProviderType; + in + { + options = { + ${contractName} = lib.mkOption { + description = ${"'"}' + Instances of contract `${contractName}`, including contract request/result and provider-specific options. + + Option `config.contracts.${contractName}.instances` refers to providers' options like this one. + ${"'"}'; + example = lib.literalExpression ${"'"}' + { + ""."" = { + request = { + # options shared between any provider of the `${contractName}` contract + # "" = ...; + }; + # provider-specific options: + # "" = ...; + }; + } + ${"'"}'; + type = mkProviderType { + # overrides.request = { "".default = ...; }; + # overrides.result = { "".default = ...; }; + # providerOptions = { + # "" = lib.mkOption { + # type = lib.types.""; + # description = "A provider-specific option."; + # }; + # }; + fulfill = request: { + # "" = ...; + }; + # fulfill' = { request, name }: { ... }; # variant exposing instance name + }; + }; + }; + } + ``` + ''; + type = submodule (contract: { + options = { + want = mkOption { + description = '' + Requests declared by consumers of the `${contractName}` contract, consisting of + request inputs and (once fulfilled) the provider's returned results. + + `want` uses `nestedAttrsOf`, so consumers can organize entries at any depth: + + ```nix + contracts.${contractName}.want."" = { + secret = cfg.secret; # flat + db.primary = cfg.primaryDb; # grouped + caches.region-a.fast = cfg.fastCache; # deeply nested + }; + ``` + + Results mirror the same structure: + `config.contracts.${contractName}.results."".db.primary`, etc. + + **NixOS module consumer:** + + ```nix + { lib, config, ... }: + let + cfg = config.services.""; + inherit (lib.contracts) ${contractName}; + in + { + options.services.""."