diff --git a/lib/contracts/default.nix b/lib/contracts/default.nix new file mode 100644 index 0000000000000..a765ab0c9f0de --- /dev/null +++ b/lib/contracts/default.nix @@ -0,0 +1,62 @@ +{ lib }: +let + inherit (lib) optionalAttrs; + + mkContractFunctions = + { + mkConsumerOptions, + mkProviderOptions, + }: + { + mkConsumer = inputDefaults: { + options = { + input = mkConsumerOptions inputDefaults; + + output = mkProviderOptions { } // { + visible = "shallow"; + }; + }; + }; + + mkProvider = + { + outputDefaults ? { }, + providerOptions ? { }, + }: + { + options = { + input = mkConsumerOptions { } // { + visible = "shallow"; + }; + } + // optionalAttrs (outputDefaults != { }) { + output = mkProviderOptions outputDefaults; + } + // optionalAttrs (providerOptions != { }) { + inherit providerOptions; + }; + }; + + # Used for documentation + allOptions = { + input = mkConsumerOptions { }; + output = mkProviderOptions { }; + }; + }; + + importContract = + module: + let + importedModule = import module { inherit lib; }; + in + mkContractFunctions { + inherit (importedModule) mkConsumerOptions mkProviderOptions; + } + // { + inherit (importedModule) description behaviorTest; + }; +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..6ae3e495a73ec --- /dev/null +++ b/lib/contracts/filebackup.nix @@ -0,0 +1,177 @@ +{ 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; + } + ); + }; + }; + }; + + 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', + }) + ''; +} 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; }; +} 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}'") + ''; +} 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; diff --git a/nixos/modules/misc/documentation/contracts.nix b/nixos/modules/misc/documentation/contracts.nix new file mode 100644 index 0000000000000..35e16093d748f --- /dev/null +++ b/nixos/modules/misc/documentation/contracts.nix @@ -0,0 +1,82 @@ +/** + Renders documentation for contracts. + For inclusion into documentation.nixos.extraModules. +*/ +{ lib, ... }: +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..