From ca00371aeb52908ae5d754cca87b37b912ff5bb8 Mon Sep 17 00:00:00 2001 From: ibizaman Date: Wed, 24 Dec 2025 08:32:23 +0100 Subject: [PATCH 01/13] contract: init underlying module --- lib/contracts/default.nix | 46 +++++++++++++++++++++++++++++++++++++++ lib/default.nix | 3 +++ 2 files changed, 49 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..62413651c69c7 --- /dev/null +++ b/lib/contracts/default.nix @@ -0,0 +1,46 @@ +{ lib }: +let + inherit (lib) optionalAttrs; + + mkContractFunctions = + { + mkConsumerOptions, + mkProviderOptions, + }: + { + mkConsumer = inputDefaults: { + input = mkConsumerOptions inputDefaults; + + output = mkProviderOptions { }; + }; + + mkProvider = + { + outputDefaults ? { }, + providerOptions ? { }, + }: + { + input = mkConsumerOptions { }; + } + // optionalAttrs (outputDefaults != { }) { + output = mkProviderOptions outputDefaults; + } + // optionalAttrs (providerOptions != { }) { + inherit providerOptions; + }; + }; + + importContract = + module: + let + importedModule = import module { inherit lib; }; + in + mkContractFunctions { + inherit (importedModule) mkConsumerOptions mkProviderOptions; + } + // { + inherit (importedModule) behaviorTest; + }; +in +{ +} diff --git a/lib/default.nix b/lib/default.nix index 044277fa24f54..7110df43f8eca 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 ab72ed6346c7b94dbac587208c6b5f803bece4ce Mon Sep 17 00:00:00 2001 From: ibizaman Date: Tue, 27 Jan 2026 11:19:38 +0100 Subject: [PATCH 02/13] contract for secrets: init --- lib/contracts/default.nix | 1 + lib/contracts/secrets.nix | 97 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 lib/contracts/secrets.nix diff --git a/lib/contracts/default.nix b/lib/contracts/default.nix index 62413651c69c7..8760cdce2e48a 100644 --- a/lib/contracts/default.nix +++ b/lib/contracts/default.nix @@ -43,4 +43,5 @@ let }; in { + secrets = importContract ./secrets.nix; } diff --git a/lib/contracts/secrets.nix b/lib/contracts/secrets.nix new file mode 100644 index 0000000000000..eebc77f95255b --- /dev/null +++ b/lib/contracts/secrets.nix @@ -0,0 +1,97 @@ +{ lib }: +let + inherit (lib) mkOption; + inherit (lib.types) listOf submodule str; +in +{ + mkConsumerOptions = + { + mode ? "0400", + owner ? "root", + group ? "root", + restartUnits ? [ ], + }: + mkOption { + description = '' + Consumer part of the contract for secrets. + ''; + + default = { }; + + type = submodule { + options = { + mode = mkOption { + description = '' + Mode the secret file must have. + ''; + type = str; + default = mode; + }; + + owner = mkOption { + description = '' + Linux user that must own the secret file. + ''; + type = str; + default = owner; + }; + + group = mkOption { + description = '' + Linux group that must own the secret file. + ''; + type = str; + default = group; + }; + + restartUnits = mkOption { + description = '' + Systemd units to restart after the secret is updated. + ''; + type = listOf str; + default = restartUnits; + }; + }; + }; + }; + + mkProviderOptions = + { + path ? null, + }: + mkOption { + description = '' + Providers part of the contract for secrets. + ''; + + default = { }; + + type = submodule { + options = { + path = mkOption ( + { + type = lib.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. + ''; + } + // ( + if (path != null) then + { + default = path; + } + else + { + example = "/run/secrets/secret"; + } + ) + ); + }; + }; + }; + + behaviorTest = import ./secrets/test.nix { inherit lib; }; +} From 45bcadf48203f122e04be3a2ae54da66d78b2d61 Mon Sep 17 00:00:00 2001 From: ibizaman Date: Tue, 27 Jan 2026 11:24:01 +0100 Subject: [PATCH 03/13] hardcoded-secret: new contract for secrets consumer --- nixos/modules/module-list.nix | 1 + nixos/modules/testing/hardcoded-secret.nix | 100 +++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 nixos/modules/testing/hardcoded-secret.nix diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index f0beafd773641..b81c5ed289568 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1941,6 +1941,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..6ce4acd099e87 --- /dev/null +++ b/nixos/modules/testing/hardcoded-secret.nix @@ -0,0 +1,100 @@ +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.testing.hardcoded-secret; + + inherit (lib) mapAttrs' mkOption nameValuePair; + inherit (lib.types) + attrsOf + nullOr + str + submodule + ; + inherit (pkgs) writeText; +in +{ + options.testing.hardcoded-secret = mkOption { + default = { }; + description = '' + Hardcoded secrets. These should only be used in tests. + ''; + example = lib.literalExpression '' + { + mySecret = { + input = { + user = "me"; + mode = "0400"; + restartUnits = [ "myservice.service" ]; + }; + settings.content = "My Secret"; + }; + } + ''; + type = attrsOf ( + submodule ( + { name, ... }: + { + options = lib.contracts.secrets.mkProvider { + providerOptions = mkOption { + description = '' + Settings specific to the hardcoded secrets module. + + Define either `content` or `source`. + ''; + + type = submodule { + options = { + content = mkOption { + type = nullOr 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. + ''; + default = null; + }; + + source = mkOption { + type = nullOr str; + description = '' + Source of the content of the secret as a path in the nix store. + ''; + default = null; + }; + }; + }; + }; + + outputDefaults = { + path = "/run/hardcodedsecrets/hardcodedsecret_${name}"; + }; + }; + } + ) + ); + }; + + config = { + system.activationScripts = mapAttrs' ( + n: cfg': + let + source = + if cfg'.providerOptions.source != null then + cfg'.providerOptions.source + else + writeText "hardcodedsecret_${n}_content" cfg'.providerOptions.content; + in + nameValuePair "hardcodedsecret_${n}" '' + mkdir -p "$(dirname "${cfg'.output.path}")" + touch "${cfg'.output.path}" + chmod ${cfg'.input.mode} "${cfg'.output.path}" + chown ${cfg'.input.owner}:${cfg'.input.group} "${cfg'.output.path}" + cp ${source} "${cfg'.output.path}" + '' + ) cfg; + }; +} From da39055b7b0c512729fda89b26b2b5ec3a9eb9ee Mon Sep 17 00:00:00 2001 From: ibizaman Date: Tue, 27 Jan 2026 11:25:52 +0100 Subject: [PATCH 04/13] contract for secrets: declare behavior test --- lib/contracts/secrets/test.nix | 86 ++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 lib/contracts/secrets/test.nix diff --git a/lib/contracts/secrets/test.nix b/lib/contracts/secrets/test.nix new file mode 100644 index 0000000000000..dc6d13d112247 --- /dev/null +++ b/lib/contracts/secrets/test.nix @@ -0,0 +1,86 @@ +{ + lib, +}: +let + inherit (lib) getAttrFromPath setAttrByPath; + inherit (lib) mkOption types; +in +{ + name, + providerRoot, + extraModules ? [ ], +}: +{ + name = "contracts_secrets_${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 e39ea8f668d0386441d1b3fd7e18cee2c91a5989 Mon Sep 17 00:00:00 2001 From: ibizaman Date: Tue, 27 Jan 2026 11:26:31 +0100 Subject: [PATCH 05/13] hardcoded-secret: define behavior test for contract for secrets --- nixos/tests/all-tests.nix | 1 + .../contracts/secrets/hardcoded-secret.nix | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 nixos/tests/contracts/secrets/hardcoded-secret.nix diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 58cddfabe0b7f..0386bc7e571b5 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -404,6 +404,7 @@ in containers-restart_networking = runTest ./containers-restart_networking.nix; containers-tmpfs = runTest ./containers-tmpfs.nix; containers-unified-hierarchy = runTest ./containers-unified-hierarchy.nix; + contracts-secrets-hardcoded-secret = runTest ./contracts/secrets/hardcoded-secret.nix; convos = runTest ./convos.nix; corerad = runTest ./corerad.nix; corteza = runTest ./corteza.nix; diff --git a/nixos/tests/contracts/secrets/hardcoded-secret.nix b/nixos/tests/contracts/secrets/hardcoded-secret.nix new file mode 100644 index 0000000000000..cb10c2b2b3840 --- /dev/null +++ b/nixos/tests/contracts/secrets/hardcoded-secret.nix @@ -0,0 +1,26 @@ +{ + lib, + config, + pkgs, + ... +}: +lib.contracts.secrets.behaviorTest { + name = "contracts-secrets-hardcoded-secret"; + providerRoot = [ + "testing" + "hardcoded-secret" + "mysecret" + ]; + extraModules = [ + ../../../modules/testing/hardcoded-secret.nix + ( + { config, ... }: + { + testing.hardcoded-secret.mysecret.providerOptions.content = config.test.content; + } + ) + ]; +} +// { + meta.maintainers = [ lib.maintainers.ibizaman ]; +} From 4a1814e8ce98bd02a737c51ba539b8fd213a0577 Mon Sep 17 00:00:00 2001 From: ibizaman Date: Tue, 27 Jan 2026 11:27:03 +0100 Subject: [PATCH 06/13] stash: use contract for secrets --- nixos/modules/services/web-apps/stash.nix | 44 +++++++-- nixos/tests/stash.nix | 108 ++++++++++++---------- 2 files changed, 95 insertions(+), 57 deletions(-) diff --git a/nixos/modules/services/web-apps/stash.nix b/nixos/modules/services/web-apps/stash.nix index 72d4d67cd6cf9..157a07d25bd3f 100644 --- a/nixos/modules/services/web-apps/stash.nix +++ b/nixos/modules/services/web-apps/stash.nix @@ -417,8 +417,16 @@ in ''; }; - passwordFile = mkOption { - type = types.nullOr types.path; + password = mkOption { + type = types.nullOr ( + lib.types.submodule { + options = lib.contracts.secrets.mkConsumer { + owner = cfg.user; + group = cfg.group; + mode = "0400"; + }; + } + ); default = null; example = "/path/to/password/file"; description = '' @@ -431,12 +439,28 @@ in ''; }; - jwtSecretKeyFile = mkOption { - type = types.path; + jwtSecretKey = mkOption { + type = types.nullOr ( + lib.types.submodule { + options = lib.contracts.secrets.mkConsumer { + owner = cfg.user; + group = cfg.group; + mode = "0400"; + }; + } + ); description = "Path to file containing a secret used to sign JWT tokens."; }; - sessionStoreKeyFile = mkOption { - type = types.path; + sessionStoreKey = mkOption { + type = types.nullOr ( + lib.types.submodule { + options = lib.contracts.secrets.mkConsumer { + owner = cfg.user; + group = cfg.group; + mode = "0400"; + }; + } + ); description = "Path to file containing a secret for session store."; }; @@ -467,7 +491,7 @@ in { assertion = !lib.xor (cfg.username != null || cfg.settings.username or null != null) ( - cfg.passwordFile != null || cfg.settings.password or null != null + cfg.password != null || cfg.settings.password or null != null ); message = "You must set either both username and password, or neither."; } @@ -514,9 +538,9 @@ 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=$(< ${cfg.password.output.path}) \ + jwtSecretKeyFile=$(< ${cfg.jwtSecretKey.output.path}) \ + sessionStoreKeyFile=$(< ${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..eabc16cab46ec 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"; + password.output = config.testing.hardcoded-secret."stash-password".output; - jwtSecretKeyFile = pkgs.writeText "jwt_secret_key" "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - sessionStoreKeyFile = pkgs.writeText "session_store_key" "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + jwtSecretKey.output = config.testing.hardcoded-secret."jwt_secret_key".output; + sessionStoreKey.output = config.testing.hardcoded-secret."session_store_key".output; - 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.password.input; + providerOptions.content = "MyPassword"; + }; + testing.hardcoded-secret."jwt_secret_key" = { + input = config.services.stash.jwtSecretKey.input; + providerOptions.content = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + }; + testing.hardcoded-secret."session_store_key" = { + input = config.services.stash.sessionStoreKey.input; + providerOptions.content = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; }; }; - }; testScript = '' machine.wait_for_unit("stash.service") From df410d57fa5da38d4b111031dc4d19cae49e4fa6 Mon Sep 17 00:00:00 2001 From: ibizaman Date: Tue, 27 Jan 2026 12:02:12 +0100 Subject: [PATCH 07/13] filebackup contract: init --- lib/contracts/default.nix | 1 + lib/contracts/filebackup.nix | 175 +++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 lib/contracts/filebackup.nix diff --git a/lib/contracts/default.nix b/lib/contracts/default.nix index 8760cdce2e48a..25f2ea8e5ec22 100644 --- a/lib/contracts/default.nix +++ b/lib/contracts/default.nix @@ -43,5 +43,6 @@ let }; in { + filebackup = importContract ./filebackup.nix; secrets = importContract ./secrets.nix; } diff --git a/lib/contracts/filebackup.nix b/lib/contracts/filebackup.nix new file mode 100644 index 0000000000000..4c93795289ada --- /dev/null +++ b/lib/contracts/filebackup.nix @@ -0,0 +1,175 @@ +{ lib, ... }: +let + inherit (lib) mkOption; + inherit (lib.types) + listOf + nonEmptyListOf + submodule + str + ; + + coerce = x: y: if x != null then x else y; +in +{ + description = '' + Contract for backing up files. + + The consumer dictates what [folders to backup](#opt-contracts.filebackup.input.sourceDirectories) + and the provider will execute the backup + and store a snapshot in a backup repository. + The exact details on how to configure acces to the repository + is specific for each provider. + + Every provider exposes a [backup systemd service](#opt-contracts.filebackup.output.backupService) + and a [script](#opt-contracts.filebackup.output.restoreScript) to list snaphosts + and restore from a given snapshot. + ''; + + mkConsumerOptions = + { + user ? "", + sourceDirectories ? [ "/var/lib/example" ], + sourceDirectoriesText ? null, + excludePatterns ? [ ], + beforeBackupHooks ? [ ], + afterBackupHooks ? [ ], + ... + }: + mkOption { + description = '' + Consumer part of the [filebackup contract](#opt-contracts.filebackup). + ''; + + default = { }; + + type = submodule { + options = { + user = mkOption { + description = '' + Unix user doing the backup. + ''; + type = str; + example = "vaultwarden"; + default = user; + }; + + sourceDirectories = mkOption ( + { + description = "Directories to backup."; + type = nonEmptyListOf str; + example = "/var/lib/vaultwarden"; + default = sourceDirectories; + } + # This pattern is clumsy but necessary to support + # referencing to the config attrset from withing the options' defaults. + // lib.optionalAttrs (sourceDirectoriesText != null) { + defaultText = sourceDirectoriesText; + } + ); + + excludePatterns = mkOption { + description = "File patterns to exclude."; + type = listOf str; + default = excludePatterns; + }; + + beforeBackupHooks = mkOption { + description = "Hooks to run before backup."; + type = listOf str; + default = beforeBackupHooks; + }; + + afterBackupHooks = mkOption { + description = "Hooks to run after backup."; + type = listOf str; + default = afterBackupHooks; + }; + }; + }; + }; + + mkProviderOptions = + { + restoreScript ? null, + restoreScriptText ? null, + backupService ? null, + backupServiceText ? null, + }: + let + fallbackRestoreName = "restoreScript"; + fallbackBackupServiceName = "backup.service"; + in + mkOption { + description = '' + Consumer part of the [filebackup contract](#opt-contracts.filebackup). + ''; + + default = { }; + + type = submodule { + options = { + restoreScript = mkOption ( + { + description = '' + Name of script that can restore the given sourceDirectories. + + To list snapshots, run: + + ```bash + $ ${coerce (coerce restoreScriptText restoreScript) fallbackRestoreName} snapshots + ``` + + To restore a given snapshot, run: + + ```bash + $ ${coerce (coerce restoreScriptText restoreScript) fallbackRestoreName} restore latest + ''; + type = str; + } + // ( + if (restoreScript != null) then + { + default = restoreScript; + } + else + { + example = fallbackRestoreName; + } + ) + # This pattern is clumsy but necessary to support + # referencing to the pkgs attrset from withing the options' defaults. + // lib.optionalAttrs (restoreScriptText != null) { + defaultText = restoreScriptText; + } + ); + + backupService = + mkOption { + description = '' + Name of service backing up the given sourceDirectories. + + Usually, this service will be run at regular intervals thanks to a systemd timer. + This is dependent on the actual contract provider used. + + It can also be ran manually with: + + ```bash + $ systemctl start ${coerce (coerce backupServiceText backupServiceText) fallbackBackupServiceName} + ``` + ''; + type = str; + } + // ( + if (backupService != null) then + { + default = backupService; + } + else + { + example = fallbackBackupServiceName; + } + ); + }; + }; + }; +} From be3b731d050c91c6134bd6b6d02cfa893be6175e Mon Sep 17 00:00:00 2001 From: ibizaman Date: Tue, 27 Jan 2026 14:28:23 +0100 Subject: [PATCH 08/13] restic: implement file backup contract provider --- nixos/modules/services/backup/restic.nix | 391 +++++++++++++---------- 1 file changed, 229 insertions(+), 162 deletions(-) diff --git a/nixos/modules/services/backup/restic.nix b/nixos/modules/services/backup/restic.nix index 85bb1cd0bf412..eb12a5e4c6092 100644 --- a/nixos/modules/services/backup/restic.nix +++ b/nixos/modules/services/backup/restic.nix @@ -10,6 +10,37 @@ let inherit (utils.systemdUtils.unitOptions) unitOption; in { + options.services.restic.fileBackups = lib.mkOption { + description = '' + Periodic backups to create with Restic. + + Each instance of the attrset is a provider for the [filebackup](#opt-contracts.filebackup) contract. + ''; + type = lib.types.attrsOf ( + lib.types.submodule ( + { name, config, ... }: + { + options = lib.contracts.filebackup.mkProvider { + outputDefaults = { + backupService = "restic-backups-${name}.service"; + restoreScript = "restic-backups-${name}-restore"; + }; + providerOptions = lib.mkOption { + # This should be more specific and use common submodule options + # with the options.services.restic.backups option. + description = '' + Passthrough options to the restic instance. + ''; + type = lib.types.anything; + default = { }; + }; + }; + } + ) + ); + default = { }; + }; + options.services.restic.backups = lib.mkOption { description = '' Periodic backups to create with Restic. @@ -255,7 +286,7 @@ in runCheck = lib.mkOption { type = lib.types.bool; default = builtins.length config.services.restic.backups.${name}.checkOpts > 0; - defaultText = lib.literalExpression ''builtins.length config.services.backups.${name}.checkOpts > 0''; + defaultText = lib.literalExpression "builtins.length config.services.backups.${name}.checkOpts > 0"; description = "Whether to run the `check` command with the provided `checkOpts` options."; example = true; }; @@ -363,171 +394,207 @@ in }; }; - config = { - assertions = lib.flatten ( - lib.mapAttrsToList (name: backup: [ - { - assertion = - ((backup.repository == null) != (backup.repositoryFile == null)) - || (backup.environmentFile != null); - message = "services.restic.backups.${name}: exactly one of repository, repositoryFile or environmentFile should be set"; - } - { - assertion = - let - fileBackup = (backup.paths != null && backup.paths != [ ]) || backup.dynamicFilesFrom != null; - commandBackup = backup.command != [ ]; - in - !(fileBackup && commandBackup); - message = "services.restic.backups.${name}: cannot do both a command backup and a file backup at the same time."; - } - { - assertion = (backup.passwordFile != null) || (backup.environmentFile != null); - message = "services.restic.backups.${name}: passwordFile or environmentFile must be set"; - } - ]) config.services.restic.backups - ); - systemd.services = lib.mapAttrs' ( - name: backup: - let - extraOptions = lib.concatMapStrings (arg: " -o ${arg}") backup.extraOptions; - inhibitCmd = lib.concatStringsSep " " [ - "${pkgs.systemd}/bin/systemd-inhibit" - "--mode='block'" - "--who='restic'" - "--what='sleep'" - "--why=${lib.escapeShellArg "Scheduled backup ${name}"} " - ]; - resticCmd = "${lib.optionalString backup.inhibitsSleep inhibitCmd}${lib.getExe backup.package}${extraOptions}"; - excludeFlags = lib.optional ( - backup.exclude != [ ] - ) "--exclude-file=${pkgs.writeText "exclude-patterns" (lib.concatStringsSep "\n" backup.exclude)}"; - filesFromTmpFile = "/run/restic-backups-${name}/includes"; - fileBackup = (backup.dynamicFilesFrom != null) || (backup.paths != null && backup.paths != [ ]); - commandBackup = backup.command != [ ]; - doBackup = fileBackup || commandBackup; - pruneCmd = lib.optionals (builtins.length backup.pruneOpts > 0) [ - (resticCmd + " unlock") - (resticCmd + " forget --prune " + (lib.concatStringsSep " " backup.pruneOpts)) - ]; - checkCmd = lib.optionals backup.runCheck [ - (resticCmd + " check " + (lib.concatStringsSep " " backup.checkOpts)) - ]; - # Helper functions for rclone remotes - rcloneRemoteName = builtins.elemAt (lib.splitString ":" backup.repository) 1; - rcloneAttrToOpt = v: "RCLONE_" + lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ] v); - rcloneAttrToConf = v: "RCLONE_CONFIG_" + lib.toUpper (rcloneRemoteName + "_" + v); - toRcloneVal = v: if lib.isBool v then lib.boolToString v else v; - in - lib.nameValuePair "restic-backups-${name}" ( - { - environment = { - # not %C, because that wouldn't work in the wrapper script - RESTIC_CACHE_DIR = "/var/cache/restic-backups-${name}"; - RESTIC_PASSWORD_FILE = backup.passwordFile; - RESTIC_REPOSITORY = backup.repository; - RESTIC_REPOSITORY_FILE = backup.repositoryFile; + config = lib.mkMerge [ + { + assertions = lib.flatten ( + lib.mapAttrsToList (name: backup: [ + { + assertion = + ((backup.repository == null) != (backup.repositoryFile == null)) + || (backup.environmentFile != null); + message = "services.restic.backups.${name}: exactly one of repository, repositoryFile or environmentFile should be set"; } - // lib.optionalAttrs (backup.rcloneOptions != null) ( - lib.mapAttrs' ( - name: value: lib.nameValuePair (rcloneAttrToOpt name) (toRcloneVal value) - ) backup.rcloneOptions - ) - // lib.optionalAttrs (backup.rcloneConfigFile != null) { - RCLONE_CONFIG = backup.rcloneConfigFile; + { + assertion = + let + fileBackup = (backup.paths != null && backup.paths != [ ]) || backup.dynamicFilesFrom != null; + commandBackup = backup.command != [ ]; + in + !(fileBackup && commandBackup); + message = "services.restic.backups.${name}: cannot do both a command backup and a file backup at the same time."; } - // lib.optionalAttrs (backup.rcloneConfig != null) ( - lib.mapAttrs' ( - name: value: lib.nameValuePair (rcloneAttrToConf name) (toRcloneVal value) - ) backup.rcloneConfig - ) - // lib.optionalAttrs (backup.progressFps != null) { - RESTIC_PROGRESS_FPS = toString backup.progressFps; - }; - path = [ config.programs.ssh.package ]; - restartIfChanged = false; - wants = [ "network-online.target" ]; - after = [ "network-online.target" ]; - serviceConfig = { - Type = "oneshot"; - ExecStart = - lib.optionals doBackup [ - "${resticCmd} backup ${ - lib.concatStringsSep " " ( - backup.extraBackupArgs - ++ lib.optionals fileBackup (excludeFlags ++ [ "--files-from=${filesFromTmpFile}" ]) - ++ lib.optionals commandBackup ([ "--stdin-from-command=true --" ] ++ backup.command) - ) - }" - ] - ++ pruneCmd - ++ checkCmd; - User = backup.user; - RuntimeDirectory = "restic-backups-${name}"; - CacheDirectory = "restic-backups-${name}"; - CacheDirectoryMode = "0700"; - PrivateTmp = true; + { + assertion = (backup.passwordFile != null) || (backup.environmentFile != null); + message = "services.restic.backups.${name}: passwordFile or environmentFile must be set"; } - // lib.optionalAttrs (backup.environmentFile != null) { - EnvironmentFile = backup.environmentFile; - }; - } - // lib.optionalAttrs (backup.initialize || doBackup || backup.backupPrepareCommand != null) { - preStart = '' - ${lib.optionalString (backup.backupPrepareCommand != null) '' - ${pkgs.writeScript "backupPrepareCommand" backup.backupPrepareCommand} - ''} - ${lib.optionalString backup.initialize '' - ${resticCmd} cat config > /dev/null || ${resticCmd} init - ''} - ${lib.optionalString (backup.paths != null && backup.paths != [ ]) '' - cat ${pkgs.writeText "staticPaths" (lib.concatLines backup.paths)} >> ${filesFromTmpFile} - ''} - ${lib.optionalString (backup.dynamicFilesFrom != null) '' - ${pkgs.writeScript "dynamicFilesFromScript" backup.dynamicFilesFrom} >> ${filesFromTmpFile} - ''} - ''; - } - // lib.optionalAttrs (doBackup || backup.backupCleanupCommand != null) { - postStop = '' - ${lib.optionalString (backup.backupCleanupCommand != null) '' - ${pkgs.writeScript "backupCleanupCommand" backup.backupCleanupCommand} - ''} - ${lib.optionalString fileBackup '' - rm ${filesFromTmpFile} - ''} - ''; + ]) config.services.restic.backups + ); + systemd.services = lib.mapAttrs' ( + name: backup: + let + extraOptions = lib.concatMapStrings (arg: " -o ${arg}") backup.extraOptions; + inhibitCmd = lib.concatStringsSep " " [ + "${pkgs.systemd}/bin/systemd-inhibit" + "--mode='block'" + "--who='restic'" + "--what='sleep'" + "--why=${lib.escapeShellArg "Scheduled backup ${name}"} " + ]; + resticCmd = "${lib.optionalString backup.inhibitsSleep inhibitCmd}${lib.getExe backup.package}${extraOptions}"; + excludeFlags = lib.optional ( + backup.exclude != [ ] + ) "--exclude-file=${pkgs.writeText "exclude-patterns" (lib.concatStringsSep "\n" backup.exclude)}"; + filesFromTmpFile = "/run/restic-backups-${name}/includes"; + fileBackup = (backup.dynamicFilesFrom != null) || (backup.paths != null && backup.paths != [ ]); + commandBackup = backup.command != [ ]; + doBackup = fileBackup || commandBackup; + pruneCmd = lib.optionals (builtins.length backup.pruneOpts > 0) [ + (resticCmd + " unlock") + (resticCmd + " forget --prune " + (lib.concatStringsSep " " backup.pruneOpts)) + ]; + checkCmd = lib.optionals backup.runCheck [ + (resticCmd + " check " + (lib.concatStringsSep " " backup.checkOpts)) + ]; + # Helper functions for rclone remotes + rcloneRemoteName = builtins.elemAt (lib.splitString ":" backup.repository) 1; + rcloneAttrToOpt = v: "RCLONE_" + lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ] v); + rcloneAttrToConf = v: "RCLONE_CONFIG_" + lib.toUpper (rcloneRemoteName + "_" + v); + toRcloneVal = v: if lib.isBool v then lib.boolToString v else v; + in + lib.nameValuePair "restic-backups-${name}" ( + { + environment = { + # not %C, because that wouldn't work in the wrapper script + RESTIC_CACHE_DIR = "/var/cache/restic-backups-${name}"; + RESTIC_PASSWORD_FILE = backup.passwordFile; + RESTIC_REPOSITORY = backup.repository; + RESTIC_REPOSITORY_FILE = backup.repositoryFile; + } + // lib.optionalAttrs (backup.rcloneOptions != null) ( + lib.mapAttrs' ( + name: value: lib.nameValuePair (rcloneAttrToOpt name) (toRcloneVal value) + ) backup.rcloneOptions + ) + // lib.optionalAttrs (backup.rcloneConfigFile != null) { + RCLONE_CONFIG = backup.rcloneConfigFile; + } + // lib.optionalAttrs (backup.rcloneConfig != null) ( + lib.mapAttrs' ( + name: value: lib.nameValuePair (rcloneAttrToConf name) (toRcloneVal value) + ) backup.rcloneConfig + ) + // lib.optionalAttrs (backup.progressFps != null) { + RESTIC_PROGRESS_FPS = toString backup.progressFps; + }; + path = [ config.programs.ssh.package ]; + restartIfChanged = false; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = + lib.optionals doBackup [ + "${resticCmd} backup ${ + lib.concatStringsSep " " ( + backup.extraBackupArgs + ++ lib.optionals fileBackup (excludeFlags ++ [ "--files-from=${filesFromTmpFile}" ]) + ++ lib.optionals commandBackup ([ "--stdin-from-command=true --" ] ++ backup.command) + ) + }" + ] + ++ pruneCmd + ++ checkCmd; + User = backup.user; + RuntimeDirectory = "restic-backups-${name}"; + CacheDirectory = "restic-backups-${name}"; + CacheDirectoryMode = "0700"; + PrivateTmp = true; + } + // lib.optionalAttrs (backup.environmentFile != null) { + EnvironmentFile = backup.environmentFile; + }; + } + // lib.optionalAttrs (backup.initialize || doBackup || backup.backupPrepareCommand != null) { + preStart = '' + ${lib.optionalString (backup.backupPrepareCommand != null) '' + ${pkgs.writeScript "backupPrepareCommand" backup.backupPrepareCommand} + ''} + ${lib.optionalString backup.initialize '' + ${resticCmd} cat config > /dev/null || ${resticCmd} init + ''} + ${lib.optionalString (backup.paths != null && backup.paths != [ ]) '' + cat ${pkgs.writeText "staticPaths" (lib.concatLines backup.paths)} >> ${filesFromTmpFile} + ''} + ${lib.optionalString (backup.dynamicFilesFrom != null) '' + ${pkgs.writeScript "dynamicFilesFromScript" backup.dynamicFilesFrom} >> ${filesFromTmpFile} + ''} + ''; + } + // lib.optionalAttrs (doBackup || backup.backupCleanupCommand != null) { + postStop = '' + ${lib.optionalString (backup.backupCleanupCommand != null) '' + ${pkgs.writeScript "backupCleanupCommand" backup.backupCleanupCommand} + ''} + ${lib.optionalString fileBackup '' + rm ${filesFromTmpFile} + ''} + ''; + } + ) + ) config.services.restic.backups; + systemd.timers = lib.mapAttrs' ( + name: backup: + lib.nameValuePair "restic-backups-${name}" { + wantedBy = [ "timers.target" ]; + inherit (backup) timerConfig; } - ) - ) config.services.restic.backups; - systemd.timers = lib.mapAttrs' ( - name: backup: - lib.nameValuePair "restic-backups-${name}" { - wantedBy = [ "timers.target" ]; - inherit (backup) timerConfig; - } - ) (lib.filterAttrs (_: backup: backup.timerConfig != null) config.services.restic.backups); + ) (lib.filterAttrs (_: backup: backup.timerConfig != null) config.services.restic.backups); - # generate wrapper scripts, as described in the createWrapper option - environment.systemPackages = lib.mapAttrsToList ( - name: backup: - let - extraOptions = lib.concatMapStrings (arg: " -o ${arg}") backup.extraOptions; - resticCmd = "${lib.getExe backup.package}${extraOptions}"; - in - pkgs.writeShellScriptBin "restic-${name}" '' - set -a # automatically export variables - ${lib.optionalString (backup.environmentFile != null) "source ${backup.environmentFile}"} - # set same environment variables as the systemd service - ${lib.pipe config.systemd.services."restic-backups-${name}".environment [ - (lib.filterAttrs (n: v: v != null && n != "PATH")) - (lib.mapAttrs (_: v: "${v}")) - lib.toShellVars - ]} - PATH=${config.systemd.services."restic-backups-${name}".environment.PATH}:$PATH + # generate wrapper scripts, as described in the createWrapper option + environment.systemPackages = lib.mapAttrsToList ( + name: backup: + let + extraOptions = lib.concatMapStrings (arg: " -o ${arg}") backup.extraOptions; + resticCmd = "${lib.getExe backup.package}${extraOptions}"; + in + pkgs.writeShellScriptBin "restic-${name}" '' + set -a # automatically export variables + ${lib.optionalString (backup.environmentFile != null) "source ${backup.environmentFile}"} + # set same environment variables as the systemd service + ${lib.pipe config.systemd.services."restic-backups-${name}".environment [ + (lib.filterAttrs (n: v: v != null && n != "PATH")) + (lib.mapAttrs (_: v: "${v}")) + lib.toShellVars + ]} + PATH=${config.systemd.services."restic-backups-${name}".environment.PATH}:$PATH - exec ${resticCmd} "$@" - '' - ) (lib.filterAttrs (_: v: v.createWrapper) config.services.restic.backups); - }; + exec ${resticCmd} "$@" + '' + ) (lib.filterAttrs (_: v: v.createWrapper) config.services.restic.backups); + } + { + services.restic.backups = + let + mkBackupConfig = + name: cfg: + { + user = cfg.input.user; + paths = cfg.input.sourceDirectories; + backupPrepareCommand = lib.concatStringsSep "\n" cfg.input.beforeBackupHooks; + backupCleanupCommand = lib.concatStringsSep "\n" cfg.input.afterBackupHooks; + exclude = cfg.input.excludePatterns; + } + // cfg.providerOptions; + in + lib.mapAttrs mkBackupConfig config.services.restic.fileBackups; + + environment.systemPackages = + let + mkRestoreScript = + name: cfg: + pkgs.writeShellApplication { + name = "restic-backups-${name}-restore"; + text = '' + if [ "$1" = "snapshots" ]; then + restic-${name} snapshots + elif [ "$1" = "restore" ]; then + shift + restic-${name} restore "$1" --target / + fi + ''; + }; + in + lib.mapAttrsToList mkRestoreScript config.services.restic.fileBackups; + } + ]; } From 328978e90654bd2201153492973a4858bd3127ca Mon Sep 17 00:00:00 2001 From: ibizaman Date: Tue, 27 Jan 2026 17:03:50 +0100 Subject: [PATCH 09/13] filebackup contract: declare behavior test --- lib/contracts/filebackup.nix | 2 + lib/contracts/filebackup/test.nix | 133 ++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 lib/contracts/filebackup/test.nix diff --git a/lib/contracts/filebackup.nix b/lib/contracts/filebackup.nix index 4c93795289ada..6ae3e495a73ec 100644 --- a/lib/contracts/filebackup.nix +++ b/lib/contracts/filebackup.nix @@ -172,4 +172,6 @@ in }; }; }; + + behaviorTest = import ./filebackup/test.nix { inherit lib; }; } diff --git a/lib/contracts/filebackup/test.nix b/lib/contracts/filebackup/test.nix new file mode 100644 index 0000000000000..c05687a79cf69 --- /dev/null +++ b/lib/contracts/filebackup/test.nix @@ -0,0 +1,133 @@ +{ + lib, +}: +let + inherit (lib) mkOption types; +in +{ + name, + providerRoot, + extraModules ? [ ], +}: +{ + name = "contracts_filebackup_${name}"; + + nodes.machine = + { config, ... }: + { + imports = extraModules; + + options.test = { + repository = mkOption { + type = types.str; + default = "/opt/repository"; + }; + username = mkOption { + type = types.str; + default = "me"; + }; + sourceDirectories = mkOption { + type = types.listOf types.str; + default = [ + "/opt/files/A" + "/opt/files/B" + ]; + }; + }; + + config = lib.mkMerge [ + (lib.setAttrByPath providerRoot { + input = { + inherit (config.test) sourceDirectories; + user = config.test.username; + }; + }) + (lib.mkIf (config.test.username != "root") { + users.users.${config.test.username} = { + isSystemUser = true; + group = config.test.username; + }; + users.groups.${config.test.username} = { }; + }) + ]; + }; + + extraPythonPackages = p: [ p.dictdiffer ]; + + testScript = + { nodes, ... }: + let + cfg = nodes.machine; + inherit (lib.getAttrFromPath providerRoot nodes.machine) output; + in + '' + from dictdiffer import diff # type: ignore + + username = "${cfg.test.username}" + sourceDirectories = [ ${lib.concatMapStringsSep ", " (x: ''"${x}"'') cfg.test.sourceDirectories} ] + + def list_files(dir): + files_and_content = {} + + files = machine.succeed(f"""find {dir} -type f""").split("\n")[:-1] + + for f in files: + content = machine.succeed(f"""cat {f}""").strip() + files_and_content[f] = content + + return files_and_content + + def assert_files(dir, files): + result = list(diff(list_files(dir), files)) + if len(result) > 0: + raise Exception("Unexpected files:", result) + + with subtest("Create initial content"): + for path in sourceDirectories: + machine.succeed(f""" + mkdir -p {path} + echo repo_fileA_1 > {path}/fileA + echo repo_fileB_1 > {path}/fileB + + chown {username}: -R {path} + chmod go-rwx -R {path} + """) + + for path in sourceDirectories: + assert_files(path, { + f'{path}/fileA': 'repo_fileA_1', + f'{path}/fileB': 'repo_fileB_1', + }) + + with subtest("First backup in repo"): + print(machine.succeed("systemctl cat ${output.backupService}")) + machine.succeed("systemctl start ${output.backupService}") + + with subtest("New content"): + for path in sourceDirectories: + machine.succeed(f""" + echo repo_fileA_2 > {path}/fileA + echo repo_fileB_2 > {path}/fileB + """) + + assert_files(path, { + f'{path}/fileA': 'repo_fileA_2', + f'{path}/fileB': 'repo_fileB_2', + }) + + with subtest("Delete content"): + for path in sourceDirectories: + machine.succeed(f"""rm -r {path}/*""") + + assert_files(path, {}) + + with subtest("Restore initial content from repo"): + machine.succeed("""${output.restoreScript} restore latest""") + + for path in sourceDirectories: + assert_files(path, { + f'{path}/fileA': 'repo_fileA_1', + f'{path}/fileB': 'repo_fileB_1', + }) + ''; +} From 4df3c48d5887f1a9ea212b5320c2fd57c47525f4 Mon Sep 17 00:00:00 2001 From: ibizaman Date: Tue, 27 Jan 2026 17:05:18 +0100 Subject: [PATCH 10/13] restic: define file backup contract behavior test --- nixos/tests/all-tests.nix | 1 + nixos/tests/contracts/filebackup/restic.nix | 37 +++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 nixos/tests/contracts/filebackup/restic.nix diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 0386bc7e571b5..c323fd5bd7fc8 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -404,6 +404,7 @@ in containers-restart_networking = runTest ./containers-restart_networking.nix; containers-tmpfs = runTest ./containers-tmpfs.nix; containers-unified-hierarchy = runTest ./containers-unified-hierarchy.nix; + contracts-filebackup-restic = runTest ./contracts/filebackup/restic.nix; contracts-secrets-hardcoded-secret = runTest ./contracts/secrets/hardcoded-secret.nix; convos = runTest ./convos.nix; corerad = runTest ./corerad.nix; diff --git a/nixos/tests/contracts/filebackup/restic.nix b/nixos/tests/contracts/filebackup/restic.nix new file mode 100644 index 0000000000000..202bd335e2d22 --- /dev/null +++ b/nixos/tests/contracts/filebackup/restic.nix @@ -0,0 +1,37 @@ +{ + lib, + config, + pkgs, + ... +}: +lib.contracts.filebackup.behaviorTest { + name = "contracts-filebackup-restic"; + providerRoot = [ + "services" + "restic" + "fileBackups" + "mybackup" + ]; + extraModules = [ + ../../../modules/services/backup/restic.nix + ( + { config, ... }: + { + systemd.tmpfiles.rules = [ + "d '${config.test.repository}' 0750 ${config.test.username} root - -" + ]; + + services.restic.fileBackups.mybackup = { + providerOptions = { + inherit (config.test) repository; + passwordFile = toString (pkgs.writeText "password" "password"); + initialize = true; + }; + }; + } + ) + ]; +} +// { + meta.maintainers = [ lib.maintainers.ibizaman ]; +} From 3e6a375d41e930c169bff3759449446ac4312085 Mon Sep 17 00:00:00 2001 From: ibizaman Date: Fri, 30 Jan 2026 20:51:50 +0100 Subject: [PATCH 11/13] contracts: add documentation --- lib/contracts/default.nix | 16 +++- .../modules/misc/documentation/contracts.nix | 82 +++++++++++++++++++ nixos/modules/module-list.nix | 1 + 3 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 nixos/modules/misc/documentation/contracts.nix diff --git a/lib/contracts/default.nix b/lib/contracts/default.nix index 25f2ea8e5ec22..9b6f51ebf70cc 100644 --- a/lib/contracts/default.nix +++ b/lib/contracts/default.nix @@ -11,7 +11,9 @@ let mkConsumer = inputDefaults: { input = mkConsumerOptions inputDefaults; - output = mkProviderOptions { }; + output = mkProviderOptions { } // { + visible = "shallow"; + }; }; mkProvider = @@ -20,7 +22,9 @@ let providerOptions ? { }, }: { - input = mkConsumerOptions { }; + input = mkConsumerOptions { } // { + visible = "shallow"; + }; } // optionalAttrs (outputDefaults != { }) { output = mkProviderOptions outputDefaults; @@ -28,6 +32,12 @@ let // optionalAttrs (providerOptions != { }) { inherit providerOptions; }; + + # Used for documentation + allOptions = { + input = mkConsumerOptions { }; + output = mkProviderOptions { }; + }; }; importContract = @@ -39,7 +49,7 @@ let inherit (importedModule) mkConsumerOptions mkProviderOptions; } // { - inherit (importedModule) behaviorTest; + inherit (importedModule) description behaviorTest; }; in { diff --git a/nixos/modules/misc/documentation/contracts.nix b/nixos/modules/misc/documentation/contracts.nix new file mode 100644 index 0000000000000..e3869b788ae35 --- /dev/null +++ b/nixos/modules/misc/documentation/contracts.nix @@ -0,0 +1,82 @@ +/** + Renders documentation for contracts. + For inclusion into documentation.nixos.extraModules. +*/ +{ lib, pkgs, ... }: +let + /** + Causes a contracts docs to be rendered. + This is an intermediate solution until we have "native" contracts docs in some nicer form. + */ + fakeSubmodule = + module: + lib.mkOption { + type = lib.types.submodule { + options = module.allOptions; + }; + inherit (module) description; + }; + + contractsModule = { + _file = "${__curPos.file}:${toString __curPos.line}"; + options = { + contracts = lib.mkOption { + description = '' + All [contracts](https://nixos.org/manual/nixos/unstable/#contracts) are found here. + + Create a consumer for a contract `` with: + + ```nix + { + options..