From d4d84bde343a66b36f75e2ffc66f552ed76a8756 Mon Sep 17 00:00:00 2001 From: ibizaman Date: Fri, 27 Feb 2026 23:53:14 +0100 Subject: [PATCH 01/12] hardcoded-secret: init with contract for secrets --- 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 fde4180a04a61..90366d78006de 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1964,6 +1964,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 109a146767349..0b2268c8088f1 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -410,6 +410,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 6a4d4ef6424e25bbf0ca4ac9a57c4885285805fb Mon Sep 17 00:00:00 2001 From: ibizaman Date: Sat, 28 Feb 2026 21:29:37 +0100 Subject: [PATCH 02/12] stash: use contract for secrets --- nixos/modules/services/web-apps/stash.nix | 85 +++++++++++++++-- nixos/tests/stash.nix | 108 ++++++++++++---------- 2 files changed, 138 insertions(+), 55 deletions(-) diff --git a/nixos/modules/services/web-apps/stash.nix b/nixos/modules/services/web-apps/stash.nix index e4e1c65d09327..7726c2e71d04c 100644 --- a/nixos/modules/services/web-apps/stash.nix +++ b/nixos/modules/services/web-apps/stash.nix @@ -366,6 +366,69 @@ let done ''; }; + + secretOptionType = + let + contractSecretsType = types.submodule { + options = { + input = mkOption { + description = "Input of the contract for file secrets."; + default = { }; + type = types.submodule { + options = { + mode = mkOption { + description = '' + Mode the secret file must have. + ''; + type = types.str; + default = "0400"; + readOnly = true; + }; + + owner = mkOption { + description = '' + Linux user that must own the secret file. + ''; + type = types.str; + default = cfg.user; + readOnly = true; + }; + + group = mkOption { + description = '' + Linux group that must own the secret file. + ''; + type = types.str; + default = cfg.group; + readOnly = true; + }; + }; + }; + }; + + output = mkOption { + description = "Output of the contract for file secrets."; + type = types.submodule { + options = { + path = mkOption { + type = types.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. + ''; + }; + }; + }; + }; + }; + }; + in + types.oneOf [ + types.path + contractSecretsType + ]; in { meta = { @@ -418,7 +481,7 @@ in }; passwordFile = mkOption { - type = types.nullOr types.path; + type = types.nullOr secretOptionType; default = null; example = "/path/to/password/file"; description = '' @@ -431,12 +494,12 @@ in ''; }; - jwtSecretKeyFile = mkOption { - type = types.path; + jwtSecretKey = mkOption { + type = secretOptionType; description = "Path to file containing a secret used to sign JWT tokens."; }; - sessionStoreKeyFile = mkOption { - type = types.path; + sessionStoreKey = mkOption { + type = secretOptionType; description = "Path to file containing a secret for session store."; }; @@ -514,9 +577,15 @@ in install -d ${cfg.settings.generated} if [[ -z "${toString cfg.mutableSettings}" || ! -f ${cfg.dataDir}/config.yml ]]; then env \ - password=$(< ${cfg.passwordFile}) \ - jwtSecretKeyFile=$(< ${cfg.jwtSecretKeyFile}) \ - sessionStoreKeyFile=$(< ${cfg.sessionStoreKeyFile}) \ + password=$(< ${ + if lib.isPath cfg.passwordFile then cfg.passwordFile else cfg.passwordFile.output.path + }) \ + jwtSecretKeyFile=$(< ${ + if lib.isPath cfg.jwtSecretKey then cfg.jwtSecretKey else cfg.jwtSecretKey.output.path + }) \ + sessionStoreKeyFile=$(< ${ + if lib.isPath cfg.sessionStoreKey then cfg.sessionStoreKey else cfg.sessionStoreKey.output.path + }) \ ${lib.getExe pkgs.yq-go} ' .jwt_secret_key = strenv(jwtSecretKeyFile) | .session_store_key = strenv(sessionStoreKeyFile) | diff --git a/nixos/tests/stash.nix b/nixos/tests/stash.nix index 838a5e8a43c6d..119072363ccc7 100644 --- a/nixos/tests/stash.nix +++ b/nixos/tests/stash.nix @@ -9,61 +9,75 @@ import ./make-test-python.nix ( name = "stash"; meta.maintainers = pkgs.stash.meta.maintainers; - nodes.machine = { - services.stash = { - inherit dataDir; - enable = true; + nodes.machine = + { config, ... }: + { + services.stash = { + inherit dataDir; + enable = true; - username = "test"; - passwordFile = pkgs.writeText "stash-password" "MyPassword"; + username = "test"; + passwordFile.output = config.testing.hardcoded-secret."stash-password".output; - jwtSecretKeyFile = pkgs.writeText "jwt_secret_key" "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - sessionStoreKeyFile = pkgs.writeText "session_store_key" "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + jwtSecretKey.output.path = config.testing.hardcoded-secret."jwt_secret_key".output.path; + sessionStoreKey.output.path = config.testing.hardcoded-secret."session_store_key".output.path; - plugins = - let - src = pkgs.fetchFromGitHub { - owner = "stashapp"; - repo = "CommunityScripts"; - rev = "9b6fac4934c2fac2ef0859ea68ebee5111fc5be5"; - hash = "sha256-PO3J15vaA7SD4r/LyHlXjnpaeYAN9Q++O94bIWdz7OA="; - }; - in - [ - (pkgs.runCommand "stashNotes" { inherit src; } '' - mkdir -p $out/plugins - cp -r $src/plugins/stashNotes $out/plugins/stashNotes - '') - (pkgs.runCommand "Theme-Plex" { inherit src; } '' - mkdir -p $out/plugins - cp -r $src/themes/Theme-Plex $out/plugins/Theme-Plex - '') - ]; + plugins = + let + src = pkgs.fetchFromGitHub { + owner = "stashapp"; + repo = "CommunityScripts"; + rev = "9b6fac4934c2fac2ef0859ea68ebee5111fc5be5"; + hash = "sha256-PO3J15vaA7SD4r/LyHlXjnpaeYAN9Q++O94bIWdz7OA="; + }; + in + [ + (pkgs.runCommand "stashNotes" { inherit src; } '' + mkdir -p $out/plugins + cp -r $src/plugins/stashNotes $out/plugins/stashNotes + '') + (pkgs.runCommand "Theme-Plex" { inherit src; } '' + mkdir -p $out/plugins + cp -r $src/themes/Theme-Plex $out/plugins/Theme-Plex + '') + ]; - mutableScrapers = true; - scrapers = - let - src = pkgs.fetchFromGitHub { - owner = "stashapp"; - repo = "CommunityScrapers"; - rev = "2ece82d17ddb0952c16842b0775274bcda598d81"; - hash = "sha256-AEmnvM8Nikhue9LNF9dkbleYgabCvjKHtzFpMse4otM="; - }; - in - [ - (pkgs.runCommand "FTV" { inherit src; } '' - mkdir -p $out/scrapers/FTV - cp -r $src/scrapers/FTV.yml $out/scrapers/FTV - '') - ]; + mutableScrapers = true; + scrapers = + let + src = pkgs.fetchFromGitHub { + owner = "stashapp"; + repo = "CommunityScrapers"; + rev = "2ece82d17ddb0952c16842b0775274bcda598d81"; + hash = "sha256-AEmnvM8Nikhue9LNF9dkbleYgabCvjKHtzFpMse4otM="; + }; + in + [ + (pkgs.runCommand "FTV" { inherit src; } '' + mkdir -p $out/scrapers/FTV + cp -r $src/scrapers/FTV.yml $out/scrapers/FTV + '') + ]; - settings = { - inherit host port; + settings = { + inherit host port; - stash = [ { path = "/srv"; } ]; + stash = [ { path = "/srv"; } ]; + }; + }; + testing.hardcoded-secret."stash-password" = { + input = config.services.stash.passwordFile.input; + content = "MyPassword"; + }; + testing.hardcoded-secret."jwt_secret_key" = { + input = config.services.stash.jwtSecretKey.input; + content = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + }; + testing.hardcoded-secret."session_store_key" = { + input = config.services.stash.sessionStoreKey.input; + content = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; }; }; - }; testScript = '' machine.wait_for_unit("stash.service") From 058fee04d887ac346be7d81138aa168b39667f9b Mon Sep 17 00:00:00 2001 From: cinereal Date: Sat, 14 Mar 2026 15:08:39 -0700 Subject: [PATCH 03/12] lib.types: add types.option Signed-off-by: cinereal --- lib/tests/modules.sh | 3 +++ lib/tests/modules/option.nix | 15 +++++++++++++++ lib/types.nix | 7 +++++++ 3 files changed, 25 insertions(+) create mode 100644 lib/tests/modules/option.nix diff --git a/lib/tests/modules.sh b/lib/tests/modules.sh index aa9d3443fb6a4..6d662ca13b769 100755 --- a/lib/tests/modules.sh +++ b/lib/tests/modules.sh @@ -704,6 +704,9 @@ checkConfigError 'In module .*/options-type-error-configuration.nix: expected an # Check that that merging of option collisions doesn't depend on type being set checkConfigError 'The option .group..*would be a parent of the following options, but its type .. does not support nested options.\n\s*- option.s. with prefix .group.enable..*' config.group.enable ./merge-typeless-option.nix +# types.option +checkConfigOutput '^10$' config.anOption ./option.nix + # Test that types.optionType merges types correctly checkConfigOutput '^10$' config.theOption.int ./optionTypeMerging.nix checkConfigOutput '^"hello"$' config.theOption.str ./optionTypeMerging.nix diff --git a/lib/tests/modules/option.nix b/lib/tests/modules/option.nix new file mode 100644 index 0000000000000..bf48822d1d224 --- /dev/null +++ b/lib/tests/modules/option.nix @@ -0,0 +1,15 @@ +{ config, lib, ... }: +{ + options = { + theOption = lib.mkOption { + type = lib.types.option; + }; + anOption = config.theOption; + }; + config = { + theOption = lib.mkOption { + type = lib.types.int; + }; + anOption = 10; + }; +} diff --git a/lib/types.nix b/lib/types.nix index 4a859694a6e82..cef33debaba32 100644 --- a/lib/types.nix +++ b/lib/types.nix @@ -1180,6 +1180,13 @@ rec { }; }; + option = mkOptionType { + name = "option"; + description = "option"; + descriptionClass = "noun"; + check = isType "option"; + }; + # The type of a type! optionType = mkOptionType { name = "optionType"; From 8c596e208bf621504feaa678a14ebc0d99f485eb Mon Sep 17 00:00:00 2001 From: cinereal Date: Sat, 14 Mar 2026 15:54:36 -0700 Subject: [PATCH 04/12] lib.modules: add extendOption Signed-off-by: cinereal --- lib/modules.nix | 89 ++++++++++++++++++++++++++++++ lib/tests/modules.sh | 3 + lib/tests/modules/extendOption.nix | 16 ++++++ 3 files changed, 108 insertions(+) create mode 100644 lib/tests/modules/extendOption.nix diff --git a/lib/modules.nix b/lib/modules.nix index 6d430effcc895..b89308208b992 100644 --- a/lib/modules.nix +++ b/lib/modules.nix @@ -2077,6 +2077,94 @@ let config = lib.importTOML file; }; + /** + Extend a (sub-)module option with a set of overrides. + + modules.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 + { config, lib, ... }: + let + inherit (lib) mkOption modules types; + in + { + options.foo = modules.extendOption + { + bar = { + default = 10; + defaultText = "10"; + }; + } + (mkOption { + type = types.submodule { + options.bar = mkOption { + type = 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 = lib.optionalAttrs isSubmodule ( + lib.attrsets.mergeAttrsList (lib.lists.map (mod: mod.options or { }) type.getSubModules) + ); + subOverrides = lib.filterAttrs (k: _: subOptions ? ${k}) overrides; + directOverrides = lib.filterAttrs (k: _: !(subOptions ? ${k})) overrides; + in + lib.mkOption ( + # re-wrap existing option attributes + (builtins.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. + // lib.optionalAttrs isSubmodule { + 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 = lib.mapAttrs ( + k: _: + let + # option for the attribute in question: for now presume just one sub-module had it + attrOpt = (lib.head (lib.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; + } + ]; + } + ); + } + ); + + /** private = mapAttrs ( @@ -2226,6 +2314,7 @@ private # are just needed by types.nix, but are not meant to be consumed # externally. inherit + extendOption defaultOrderPriority defaultOverridePriority doRename diff --git a/lib/tests/modules.sh b/lib/tests/modules.sh index 6d662ca13b769..6c90451aee5d7 100755 --- a/lib/tests/modules.sh +++ b/lib/tests/modules.sh @@ -822,6 +822,9 @@ checkConfigOutput '^true$' config.viaConfig ./mkDefinition.nix checkConfigOutput '^true$' config.mkMerge ./mkDefinition.nix checkConfigOutput '^true$' config.mkForce ./mkDefinition.nix +# extend* +checkConfigError 'not of type' config.foo.bar ./extendOption.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/extendOption.nix b/lib/tests/modules/extendOption.nix new file mode 100644 index 0000000000000..a03fe728e2991 --- /dev/null +++ b/lib/tests/modules/extendOption.nix @@ -0,0 +1,16 @@ +{ lib, ... }: +{ + options.foo = + lib.modules.extendOption + { + bar.default = "baz"; + } + ( + lib.mkOption { + default = { }; + type = lib.types.submodule { + options.bar = lib.mkOption { type = lib.types.int; }; + }; + } + ); +} From d11ad3e0a9b5f4dd67c62660f8c871e7e8a7240b Mon Sep 17 00:00:00 2001 From: cinereal Date: Sat, 14 Mar 2026 15:55:19 -0700 Subject: [PATCH 05/12] lib.modules: add extendSubmodule Signed-off-by: cinereal --- lib/modules.nix | 47 +++++++++++++++++++++++++++ lib/tests/modules.sh | 1 + lib/tests/modules/extendSubmodule.nix | 16 +++++++++ 3 files changed, 64 insertions(+) create mode 100644 lib/tests/modules/extendSubmodule.nix diff --git a/lib/modules.nix b/lib/modules.nix index b89308208b992..98d9237bc1660 100644 --- a/lib/modules.nix +++ b/lib/modules.nix @@ -2165,6 +2165,52 @@ let ); /** + Extend a (sub-)module type with a set of overrides. + + modules.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 + { config, lib, ... }: + let + inherit (lib) mkOption modules types; + in + { + options.foo = mkOption { + default = { }; + type = modules.extendSubmodule + bar = { + default = 10; + defaultText = "10"; + }; + (types.submodule { + options.bar = mkOption { + type = types.int; + }; + }); + }; + } + ``` + */ + extendSubmodule = + overrides: mod: + (extendOption overrides ( + lib.mkOption { + type = mod; + } + )).type; + private = mapAttrs ( @@ -2315,6 +2361,7 @@ private # externally. inherit extendOption + extendSubmodule defaultOrderPriority defaultOverridePriority doRename diff --git a/lib/tests/modules.sh b/lib/tests/modules.sh index 6c90451aee5d7..52b093d9707d6 100755 --- a/lib/tests/modules.sh +++ b/lib/tests/modules.sh @@ -824,6 +824,7 @@ checkConfigOutput '^true$' config.mkForce ./mkDefinition.nix # extend* checkConfigError 'not of type' config.foo.bar ./extendOption.nix +checkConfigError 'not of type' config.foo.bar ./extendSubmodule.nix # specialArgs._class checkConfigOutput '"nixos"' config.nixos.config.foo ./specialArgs-class.nix diff --git a/lib/tests/modules/extendSubmodule.nix b/lib/tests/modules/extendSubmodule.nix new file mode 100644 index 0000000000000..50f99bf778292 --- /dev/null +++ b/lib/tests/modules/extendSubmodule.nix @@ -0,0 +1,16 @@ +{ lib, ... }: +{ + options.foo = lib.mkOption { + default = { }; + type = + lib.modules.extendSubmodule + { + bar.default = "baz"; + } + ( + lib.types.submodule { + options.bar = lib.mkOption { type = lib.types.int; }; + } + ); + }; +} From 5a2b91e4fe114803ec4e51e5d9573961f6911692 Mon Sep 17 00:00:00 2001 From: cinereal Date: Sat, 14 Mar 2026 15:55:30 -0700 Subject: [PATCH 06/12] lib.modules: add mkContract Signed-off-by: cinereal --- lib/modules.nix | 43 ++++++++++++++++++++++++++++++++ lib/tests/modules.sh | 2 +- lib/tests/modules/mkContract.nix | 14 +++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 lib/tests/modules/mkContract.nix diff --git a/lib/modules.nix b/lib/modules.nix index 98d9237bc1660..1278b4b691c0b 100644 --- a/lib/modules.nix +++ b/lib/modules.nix @@ -2211,6 +2211,48 @@ let } )).type; + /** + Construct a (sub-)module type from options and a set of overrides. + + modules.mkContract :: attrsOf option -> attrs -> optionType + + # Inputs + + `options` + + : 1\. An attrset of module options from which to construct the submodule type + + `overrides` + + : 2\. A (recursive) attrset of fields to add to the submodule type + + Example: + + ```nix + { config, lib, ... }: + let + inherit (lib) mkOption modules types; + in + { + options.foo = mkOption { + default = { }; + type = modules.mkContract + { + bar = mkOption { + type = types.int; + }; + } + bar = { + default = 10; + defaultText = "10"; + }; + }; + } + ``` + */ + mkContract = + options: overrides: extendSubmodule overrides (lib.types.submodule { inherit options; }); + private = mapAttrs ( @@ -2387,6 +2429,7 @@ private mkAssert mkBefore mkChangedOptionModule + mkContract mkDefault mkDefinition mkDerivedConfig diff --git a/lib/tests/modules.sh b/lib/tests/modules.sh index 52b093d9707d6..c203ca803173a 100755 --- a/lib/tests/modules.sh +++ b/lib/tests/modules.sh @@ -822,9 +822,9 @@ checkConfigOutput '^true$' config.viaConfig ./mkDefinition.nix checkConfigOutput '^true$' config.mkMerge ./mkDefinition.nix checkConfigOutput '^true$' config.mkForce ./mkDefinition.nix -# extend* checkConfigError 'not of type' config.foo.bar ./extendOption.nix checkConfigError 'not of type' config.foo.bar ./extendSubmodule.nix +checkConfigError 'not of type' config.foo.bar ./mkContract.nix # specialArgs._class checkConfigOutput '"nixos"' config.nixos.config.foo ./specialArgs-class.nix diff --git a/lib/tests/modules/mkContract.nix b/lib/tests/modules/mkContract.nix new file mode 100644 index 0000000000000..c8abe8779a6d6 --- /dev/null +++ b/lib/tests/modules/mkContract.nix @@ -0,0 +1,14 @@ +{ lib, ... }: +{ + options.foo = lib.mkOption { + default = { }; + type = + lib.modules.mkContract + { + bar = lib.mkOption { type = lib.types.int; }; + } + { + bar.default = "baz"; + }; + }; +} From ad23adbae9b107a98d8f2a002c17fca59c3b09b2 Mon Sep 17 00:00:00 2001 From: cinereal Date: Wed, 18 Mar 2026 14:42:01 -0700 Subject: [PATCH 07/12] lib.modules: add evalOption Signed-off-by: cinereal --- lib/modules.nix | 27 +++++++++++++++++++++++++ lib/tests/modules.sh | 1 + lib/tests/modules/evalOption.nix | 29 +++++++++++++++++++++++++++ lib/tests/modules/extendedOptions.nix | 14 +++++++++++++ 4 files changed, 71 insertions(+) create mode 100644 lib/tests/modules/evalOption.nix create mode 100644 lib/tests/modules/extendedOptions.nix diff --git a/lib/modules.nix b/lib/modules.nix index 1278b4b691c0b..40fe7597e109d 100644 --- a/lib/modules.nix +++ b/lib/modules.nix @@ -2077,6 +2077,32 @@ let config = lib.importTOML file; }; + /** + Evaluate a configuration in the context of a corresponding module system option. + + modules.evalOption :: option -> attrs -> attrs + + # Inputs + + `option` + + : 1\. Module system option in which to evaluate the configuration + + `conf` + + : 2\. Configuration to evaluate within the option + */ + evalOption = + option: conf: + (lib.evalModules { + modules = [ + { + options.opt = option; + config.opt = conf; + } + ]; + }).config.opt; + /** Extend a (sub-)module option with a set of overrides. @@ -2402,6 +2428,7 @@ private # are just needed by types.nix, but are not meant to be consumed # externally. inherit + evalOption extendOption extendSubmodule defaultOrderPriority diff --git a/lib/tests/modules.sh b/lib/tests/modules.sh index c203ca803173a..22c9a7a21dd48 100755 --- a/lib/tests/modules.sh +++ b/lib/tests/modules.sh @@ -822,6 +822,7 @@ 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.bar ./extendOption.nix checkConfigError 'not of type' config.foo.bar ./extendSubmodule.nix checkConfigError 'not of type' config.foo.bar ./mkContract.nix diff --git a/lib/tests/modules/evalOption.nix b/lib/tests/modules/evalOption.nix new file mode 100644 index 0000000000000..1c043cdcf3987 --- /dev/null +++ b/lib/tests/modules/evalOption.nix @@ -0,0 +1,29 @@ +{ lib, ... }: +let + inherit (lib) modules mkOption types; +in +{ + options.bar = mkOption { + default = + modules.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/extendedOptions.nix b/lib/tests/modules/extendedOptions.nix new file mode 100644 index 0000000000000..fcf5a9df58379 --- /dev/null +++ b/lib/tests/modules/extendedOptions.nix @@ -0,0 +1,14 @@ +{ lib, ... }: +{ + options.foo = lib.mkOption { + default = { }; + type = + lib.modules.extendedOptions + { + bar = lib.mkOption { type = lib.types.int; }; + } + { + bar.default = "baz"; + }; + }; +} From c7f2745526663d99b33052ba7b2d1d366e1e78bc Mon Sep 17 00:00:00 2001 From: cinereal Date: Sat, 14 Mar 2026 15:57:26 -0700 Subject: [PATCH 08/12] lib.contracts: add contracts module Signed-off-by: cinereal --- lib/contracts/default.nix | 56 +++++++++++++++++++++++++++++++++++++++ lib/default.nix | 3 +++ 2 files changed, 59 insertions(+) create mode 100644 lib/contracts/default.nix diff --git a/lib/contracts/default.nix b/lib/contracts/default.nix new file mode 100644 index 0000000000000..5854a098f50e3 --- /dev/null +++ b/lib/contracts/default.nix @@ -0,0 +1,56 @@ +{ lib, ... }: +let + inherit (lib) mkOption modules types; + inherit (types) attrsOf listOf option submodule str; + contractModule = mkOption { + type = submodule { + options = { + meta = mkOption { + description = '' + Useful information about the contract and its maintenance. + ''; + type = submodule { + options = { + description = mkOption { + description = '' + Description of the contract. + ''; + type = str; + }; + maintainers = mkOption { + description = '' + Maintainers of the contract. + ''; + type = listOf str; + }; + }; + }; + }; + input = mkOption { + description = '' + Input type of a contract. + ''; + type = attrsOf option; + apply = modules.mkContract; + }; + output = mkOption { + description = '' + Output type of a contract. + ''; + type = attrsOf option; + apply = modules.mkContract; + }; + behaviorTest = mkOption { + # The type should be more precise of course. + # There should actually be a NixOSTest type. + # And we can probably do something fancy with the `input` and `output` modules. + type = types.functionTo types.attrs; + }; + }; + }; + }; +in +# yields: attrsOf contractModule +lib.mapAttrs (_: path: modules.evalOption contractModule (import path { inherit lib; })) { + fileSecrets = ./file-secrets.nix; +} diff --git a/lib/default.nix b/lib/default.nix index 15949d1cdf367..5b942dbe1e873 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -72,6 +72,9 @@ let options = callLibs ./options.nix; types = callLibs ./types.nix; + # contracts + contracts = callLibs ./contracts; + # constants licenses = callLibs ./licenses.nix; sourceTypes = callLibs ./source-types.nix; From 970a7a3db2d85f265546fbd484467b976c4391b9 Mon Sep 17 00:00:00 2001 From: cinereal Date: Sat, 14 Mar 2026 15:57:45 -0700 Subject: [PATCH 09/12] lib.contracts: add contracts.fileSecrets Signed-off-by: cinereal --- lib/contracts/file-secrets.nix | 134 +++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 lib/contracts/file-secrets.nix diff --git a/lib/contracts/file-secrets.nix b/lib/contracts/file-secrets.nix new file mode 100644 index 0000000000000..7155f2ccfb071 --- /dev/null +++ b/lib/contracts/file-secrets.nix @@ -0,0 +1,134 @@ +{ + 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 + ]; + }; + + input = { + 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; + }; + }; + output = { + 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 { + 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 (lib.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 36c20d6ac30895553ad480ba8b2ecc5708a0f4c2 Mon Sep 17 00:00:00 2001 From: cinereal Date: Sat, 14 Mar 2026 15:58:15 -0700 Subject: [PATCH 10/12] contracts: use contracts.fileSecrets in contract test hardcoded-secret Signed-off-by: cinereal --- nixos/modules/testing/hardcoded-secret.nix | 53 +++-------- .../filesecrets/hardcoded-secret.nix | 17 ++-- nixos/tests/contracts/filesecrets/test.nix | 89 ------------------- 3 files changed, 18 insertions(+), 141 deletions(-) delete mode 100644 nixos/tests/contracts/filesecrets/test.nix diff --git a/nixos/modules/testing/hardcoded-secret.nix b/nixos/modules/testing/hardcoded-secret.nix index f553576868aa8..73453698fce54 100644 --- a/nixos/modules/testing/hardcoded-secret.nix +++ b/nixos/modules/testing/hardcoded-secret.nix @@ -7,13 +7,19 @@ let cfg = config.testing.hardcoded-secret; - inherit (lib) mapAttrs' mkOption nameValuePair; + inherit (lib) + contracts + mapAttrs' + mkOption + nameValuePair + ; inherit (lib.types) attrsOf str submodule ; inherit (pkgs) writeText; + inherit (contracts) fileSecrets; in { options.testing.hardcoded-secret = mkOption { @@ -45,51 +51,16 @@ in 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"; - }; - }; + type = fileSecrets.input { + owner.default = "root"; + group.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}"; - }; - }; + type = fileSecrets.output { + path.default = "/run/hardcodedsecrets/${name}"; }; }; diff --git a/nixos/tests/contracts/filesecrets/hardcoded-secret.nix b/nixos/tests/contracts/filesecrets/hardcoded-secret.nix index e90cadb767bdd..ec137074f1f85 100644 --- a/nixos/tests/contracts/filesecrets/hardcoded-secret.nix +++ b/nixos/tests/contracts/filesecrets/hardcoded-secret.nix @@ -1,14 +1,12 @@ -args@{ +{ lib, - config, - pkgs, ... }: -let - test = import ./test.nix args; -in -test { - name = "contracts-secrets-hardcoded-secret"; +{ + meta.maintainers = [ lib.maintainers.ibizaman ]; +} +// lib.contracts.fileSecrets.behaviorTest { + name = "hardcoded-secret"; providerRoot = [ "testing" "hardcoded-secret" @@ -24,6 +22,3 @@ test { ) ]; } -// { - meta.maintainers = [ lib.maintainers.ibizaman ]; -} diff --git a/nixos/tests/contracts/filesecrets/test.nix b/nixos/tests/contracts/filesecrets/test.nix deleted file mode 100644 index 00d2bdf251df8..0000000000000 --- a/nixos/tests/contracts/filesecrets/test.nix +++ /dev/null @@ -1,89 +0,0 @@ -{ - 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 1e35a8b9104cf67cabcf7273955836823b77242d Mon Sep 17 00:00:00 2001 From: cinereal Date: Sat, 14 Mar 2026 15:59:05 -0700 Subject: [PATCH 11/12] contracts: use contracts.fileSecrets in stash module Signed-off-by: cinereal --- nixos/modules/services/web-apps/stash.nix | 48 +++-------------------- 1 file changed, 6 insertions(+), 42 deletions(-) diff --git a/nixos/modules/services/web-apps/stash.nix b/nixos/modules/services/web-apps/stash.nix index 7726c2e71d04c..a3455740ec235 100644 --- a/nixos/modules/services/web-apps/stash.nix +++ b/nixos/modules/services/web-apps/stash.nix @@ -6,6 +6,7 @@ }: let inherit (lib) + contracts getExe literalExpression mkEnableOption @@ -16,6 +17,7 @@ let toUpper types ; + inherit (contracts) fileSecrets; cfg = config.services.stash; @@ -374,53 +376,15 @@ let input = mkOption { description = "Input of the contract for file secrets."; default = { }; - type = types.submodule { - options = { - mode = mkOption { - description = '' - Mode the secret file must have. - ''; - type = types.str; - default = "0400"; - readOnly = true; - }; - - owner = mkOption { - description = '' - Linux user that must own the secret file. - ''; - type = types.str; - default = cfg.user; - readOnly = true; - }; - - group = mkOption { - description = '' - Linux group that must own the secret file. - ''; - type = types.str; - default = cfg.group; - readOnly = true; - }; - }; + type = fileSecrets.input { + owner.default = cfg.user; + group.default = cfg.group; }; }; output = mkOption { description = "Output of the contract for file secrets."; - type = types.submodule { - options = { - path = mkOption { - type = types.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. - ''; - }; - }; - }; + type = fileSecrets.output { }; }; }; }; From cf1ebad3cad8a8f5021783061fe34a78486c1feb Mon Sep 17 00:00:00 2001 From: cinereal Date: Mon, 23 Mar 2026 11:46:04 +0100 Subject: [PATCH 12/12] lib.contracts: format Signed-off-by: cinereal --- lib/contracts/default.nix | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/contracts/default.nix b/lib/contracts/default.nix index 5854a098f50e3..a8ce0a4d5409a 100644 --- a/lib/contracts/default.nix +++ b/lib/contracts/default.nix @@ -1,7 +1,13 @@ { lib, ... }: let inherit (lib) mkOption modules types; - inherit (types) attrsOf listOf option submodule str; + inherit (types) + attrsOf + listOf + option + submodule + str + ; contractModule = mkOption { type = submodule { options = {