diff options
| -rw-r--r-- | checks.nix | 275 | ||||
| -rw-r--r-- | flake.nix | 46 | ||||
| -rw-r--r-- | options.nix | 29 |
3 files changed, 300 insertions, 50 deletions
diff --git a/checks.nix b/checks.nix new file mode 100644 index 0000000..c20451a --- /dev/null +++ b/checks.nix @@ -0,0 +1,275 @@ +{ pkgs, self, system, ... }: + +let + # The copy of `lib` from `pkgs` is not quite the same as the `lib` that + # modules get. We're pretending this file is analogous to a package + # definition, so this is the only one we have available. It's declared + # explicitly here because doing this in `flake.nix` would have been harder + # to explain. + lib = pkgs.lib; + + # `mkNixEvalCheck` is a helper function that defines a check which + # evaluates some nix code, and diffs the evaluation result against an + # expected result. + # + # While you may occasionally call this directly, it is more likely that + # you want `mkNixModuleCheck`. This one is presented first for ease of + # understanding how they work. + # + # To do this, it sets up a nested instance of `nix` which runs within the + # sandbox and can't touch the outer system. This is slightly involved, so + # this helper is its own thing even though in practice we will also need + # module handling logic for everything we're interested in testing. + mkNixEvalCheck = name: input: expected: + # Since this is a convenience wrapper, the implementation is trivial. + mkNixEvalCheckWith { inherit name input expected; }; + + # `mkNixEvalCheckWith` is the underlying implmentation of + # `mkNixEvalCheck`. It has extra options, which are needed by + # `mkNixModuleCheck` and likely not by anything else. + mkNixEvalCheckWith = + { + # These parameters have the same meanings as in + # `mkNixEvalCheck`. + name, input, expected, + + # An attribute set of additional source files to link in so the + # builder can use them, keyed by their filenames. + extraSources ? { } + }: + + let + # Optionally, `input` can take a `sources` parameter to find + # other files it relies on. We apply that parameter here. + # + # Making this work correctly is a lot of code, especially + # compared to just using `builtins.readFile`. It's worth it, + # because it makes any stack traces produced during testing + # significantly more readable, by adding filenames to them. + appliedInput = if builtins.isFunction input + then input sourceFiles + else input; + + # An attribute set of names and file contents in the store for + # data that should be available to the build script via `src` + # and to `appliedInput` via `sources`. + # + # This family of three variables exists so that `appliedInput` + # doesn't need to have a cyclic dependency. + sourceContents = extraSources // { + "input.nix" = appliedInput; + "expected.json" = "${expected}\n"; + }; + + # An attribute set of names and derivations that should be + # available to the build script via `mkDerivation`'s `src` + # parameter. + # + # This family of three variables exists so that `appliedInput` + # doesn't need to have a cyclic dependency. + sourceDerivations = + lib.mapAttrs (name: value: pkgs.writeTextDir name value) + sourceContents; + + # An attribute set of names and complete file paths in the store + # that should be available to `appliedInput` via its `sources` + # parameter. + # + # This family of three variables exists so that `appliedInput` + # doesn't need to have a cyclic dependency. + sourceFiles = + lib.mapAttrs (name: value: "${value}/${name}") + sourceDerivations; + + in pkgs.stdenv.mkDerivation { + name = "secrets-nix-test-${name}"; + + # The easiest way to get the test's input into a format the + # build script can work with is to use it as the `src`. + # + # We construct a source directory with two files `input` and + # `expected`. We also respect any additional files from + # `extraSources`. + src = pkgs.symlinkJoin { + name = "secrets-nix-test-${name}-src"; + paths = lib.foldlAttrs + (result: name: value: result ++ [ value ]) + [ ] + sourceDerivations; + }; + + nativeBuildInputs = with pkgs; [ diffutils nix ]; + + # Recall that a nix flake check is a derivation; the check + # succeeds if and only if the derivation builds successfully. + # Sadly, `checkPhase` is skipped, so we do all the work in + # `buildPhase`. + buildPhase = '' + # Because we potentially build some derivations, we need a + # proper temporary directory for the store; we can't just use + # the dummy store. + mkdir nix-store + ${pkgs.nix}/bin/nix \ + --extra-experimental-features nix-command \ + --store /build/nix-store \ + --show-trace \ + eval --json --file $src/input.nix > $out + + # The exit code of diff is what we want for this. Yay! + if ! ${pkgs.diffutils}/bin/diff $src/expected.json $out; then + # It's good to keep the user-programmer in the loop... + echo + echo "This is a nix evaluation test case. The expected eval" + echo "output differed from the actual output. In an ideal" + echo "world, the above diff would help you understand why." + echo + false + fi + ''; + }; + + + # `mkNixModuleCheck` builds on the behavior of `mkNixEvalCheck`, + # attempting to call `evalModules` on a user-provided module, combining it + # with the smalltech options module and with an appropriate version of + # nixpkgs itself. + # + # This helper is intended to be useful for writing test cases that + # simulate configuring a complete system. It is the helper we are likely + # to use most frequently. + # + # It defers to `mkNixModuleCheckWith` for all the real work, so + # as to be able to provide some sensible defaults while still being + # configurable. + mkNixModuleCheck = + # This name will be appended to the derivation name. Set it to + # something distinctive; it appears in error messages. + name: + + # This should be a string (usually written as a multiline string + # literal) giving the exact text of the input module. + # + # Unfortunately it needs to be a string, not an arbitrary nix + # expression, because it needs to be serialized so the inner nix can + # get a copy of it. + input: + + # This should be a nix expression evaluating to an attribute set, + # corresponding to the expected output of the test case. + # + # It will be serialized to JSON before comparing it against the actual + # output. + expected: + + # Since this is a convenience wrapper, the implementation is trivial. + mkNixModuleCheckWith { inherit name input expected; }; + + + # `mkNixModuleCheckWith` is the underlying implementation of + # `mkNixModuleCheck`. The only situation in which you'd use it directly + # is if you need to set a custom `keyPath`. + # + # Please notice the somewhat subtle implementation; this is generating + # a text file containing nix code, which is interpreted by the nested + # nix. That's why we have to go to so much trouble to pass things around + # as strings. + # + # Fortunately for passing `nixpkgs` itself, we can reference + # the path to where it's already present in the filesystem as an input + # to this flake in the outer nix; otherwise the test's input file would + # be many megabytes in size. + # + # Less fortunately, for `options.nix` and the test input, we need to + # include the contents textually. This leads to some awkwardness and + # limitations, but it should be reliable. + mkNixModuleCheckWith = + { + # These parameters have the same meanings as in + # `mkNixModuleCheck`. + name, input, expected, + + # Since the return value of a module potentially contains + # functions and other things we can't serialize, we always extract + # a sub-value from within it, and compare against that. + # + # The default is likely to be all we need for most cases. + keyPath ? "config.secrets.export" + }: mkNixEvalCheckWith { + inherit name; + + extraSources = { + "options.nix" = builtins.readFile ./options.nix; + "test-input-module.nix" = input; + }; + + # We rely on the `sources` parameter; for details of how it works, + # see the binding of `appliedInput` in the definition of + # `mkNixEvalCheckWith`, above. + # + # It's important that the copy of `pkgs` available to the modules + # in the inner nix be the same one this file is using for its own + # needs, here in the outer nix. Otherwise, things like + # `${pkgs.bash}` in expected-output values won't work correctly, + # and since scripts are a critical part of the output, that's + # mandatory. + # + # The way it's passed through here should work, but if it breaks, + # well, hopefully this comment saved you some time. + input = sources: '' + let pkgs = import ${self.inputs.nixpkgs} { }; + in (pkgs.lib.evalModules { + modules = [ + { + imports = [ + ${sources."options.nix"} + ${sources."test-input-module.nix"} + ]; + } + ]; + specialArgs = { inherit pkgs; }; + }).${keyPath} + ''; + + expected = builtins.toJSON expected; + }; + +in { + # Verify that the nix evaluator test harness works. + trivial = mkNixEvalCheck "trivial" "1 + 2" "3"; + + # Verify that when no secrets are configured, we correctly export a set + # containing no secrets. + empty = mkNixModuleCheck "empty" '' + { + config = { }; + } + '' { }; + + # Verify that when a single specific secret is configured, we correctly + # export a set containing a description of it, and nothing else. + single = mkNixModuleCheck "single" '' + { + config = { + secrets.secrets.example = { + filename = "example.key"; + script = ''' + touch example.key + '''; + }; + }; + } + '' { + example = { + path = "/etc/nixos/secrets/example.key"; + script = '' + #!${pkgs.bash}/bin/bash + touch example.key + ''; + }; + }; + + ### ADD MORE NIX MODULE TEST CASES ABOVE THIS LINE! :) + + # Verify that the Rust command-line tool builds correctly. + rust = self.packages.${system}.default; +} diff --git a/flake.nix b/flake.nix index 8df1039..c101218 100644 --- a/flake.nix +++ b/flake.nix @@ -28,6 +28,9 @@ in { nixosModules.default = { ... }: { imports = [ + # Please note that checks.nix also directly references options.nix, + # so if you add anything else to these imports, also add it in + # checks.nix. ./options.nix ]; }; @@ -47,44 +50,11 @@ }; }); + # The checks are quite verbose, so they're in a separate file. checks = forAllSystems (system: - let pkgs = nixpkgsFor.${system}; - mkNixEvalCheck = name: input: expected: pkgs.stdenv.mkDerivation { - name = "smalltech-nix-test-${name}"; - - src = pkgs.symlinkJoin { - name = "smalltech-nix-test-${name}-src"; - paths = [ - (pkgs.writeTextDir "input" input) - (pkgs.writeTextDir "expected" "${expected}\n") - ]; - }; - - dontUnpack = true; - - nativeBuildInputs = with pkgs; [ diffutils nix ]; - - buildPhase = '' - mkdir nix-store - ${pkgs.nix}/bin/nix \ - --extra-experimental-features nix-command \ - --store dummy:// \ - eval --json --file $src/input > $out - - if ! ${pkgs.diffutils}/bin/diff $src/expected $out; then - echo - echo "This is a nix evaluation test case. The expected eval" - echo "output differed from the actual output. In an ideal" - echo "world, the above diff would help you understand why." - echo - false - fi - ''; - }; - in { - nix-trivial = mkNixEvalCheck "trivial" "1 + 2" "3"; - - rust = self.packages.${system}.default; - }); + let pkgs = nixpkgsFor.${system}; + in import ./checks.nix { + inherit pkgs self system; + }); }; } diff --git a/options.nix b/options.nix index 5fa70dc..8531404 100644 --- a/options.nix +++ b/options.nix @@ -1,4 +1,4 @@ -{ pkgs, lib }: +{ config, pkgs, lib, ... }: { options.secrets = { @@ -47,18 +47,19 @@ example = { mattermost = { path = "/etc/nixos/secrets/mattermost.key"; - script = "touch /etc/nixos/secrets/mattermost.key" + script = "touch /etc/nixos/secrets/mattermost.key"; }; neooffice = { path = "/etc/nixos/secrets/neooffice.key"; - script = "head -c 32 /dev/urandom > /etc/nixos/secrets/neooffice.key" + script = + "head -c 32 /dev/urandom > /etc/nixos/secrets/neooffice.key"; }; }; - type = lib.types.attrsOf lib.types.submodule = { + type = lib.types.attrsOf (lib.types.submodule { options = { - path = { + path = lib.mkOption { type = lib.types.pathWith { absolute = true; inStore = false; @@ -74,7 +75,7 @@ example = "/etc/nixos/secrets/neooffice.key"; }; - script = { + script = lib.mkOption { type = lib.types.lines; description = '' An internal value which is part of `secrets.export`, used by @@ -90,18 +91,22 @@ ''; }; }; - }; + }); }; }; - config.secrets.export = { config, pkgs, ... }: + config.secrets.export = let exportSecret = name: secret: { - path = "/etc/nixos/secrets/${secret.file}"; + path = "/etc/nixos/secrets/${secret.filename}"; + + # In defiance of the usual code style, we leave off the trailing + # newline here because that makes life easier when writing test + # cases (see `checks.nix`), which would otherwise have to add an + # extra one. script = '' #!${pkgs.bash}/bin/bash - ${secret.script} - ''; + ${secret.script}''; }; - in mapAttrs exportSecret config.secrets.secrets; + in builtins.mapAttrs exportSecret config.secrets.secrets; } |