summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--checks.nix275
-rw-r--r--flake.nix46
-rw-r--r--options.nix29
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;
 }