Import but don't import your NixOS modules

Published 2024-07-29 on Farid Zakaria's Blog

This is a follow-up post to my prior one NixOS Option Inspection. Many thanks to @roberth who followed up on my issue and helped explain it. 🙏

If you are using NixOS, you’ve likely encountered the module system. It’s NixOS’s super-power and what makes it incredibly easy to share, reuse and configure systems based on Nix

{
  imports = [ ./hello.nix ];
  
  services.hello = {
    enable = true;
    greeter = "Bob";
  };
}

In a prior post, I wrote about how it can be challenging to work backwards ⏪ from a NixOS option’s value to where it was defined.

Turns out, the answer in the post was relatively simple and Nixpkgs has a gesture for discovering the answer via definitionsWithLocations.

Turns out, in order to get definitionsWithLocations to play nice, you have to avoid a surprisingly common footgun 🥵.

Let’s use an incredibly simple example to demonstrate the bug.

First let’s make our module greet.nix; it’s a trivial module that sets a single option. We also have a config.nix which you could think of as our configuration.nix in a typical NixOS installation that defines the option.

# greet.nix
{...}: {
    greet.name = "hi";
}

# config.nix
let 
modules = {
  greet = import ./greet.nix;
};
system = {lib, ...}: {
  imports = [
    modules.greet
  ];

  options = {
    greet.name = lib.mkOption {
        type = lib.types.str;
    };
  };
};
in (import <nixpkgs/lib>).evalModules {
  modules = [ system  ];
}

Next, let’s import this module, à la imports keyword attribute in a NixOS module.

❗I have chosen to create a container attrset modules to mimic what we might accomplish in a Nix Flakes.

Everything looks OK, and evaluates correctly.

❯ nix-instantiate --eval config.nix -A config.greet.name
"hi"

If we try definitionsWithLocations however we don’t get what we expected. 🤮

❯ nix repl 
Nix 2.23.2
nix-repl> :l ./config.nix
Added 7 variables.

nix-repl> :p options.greet.name.definitionsWithLocations
[
  {
    file = "<unknown-file>";
    value = "hi";
  }
]

Did you spot the problem ? 🕵️‍♂️🧐🤔

It was the import ./greet.nix 🤯

We imported the Nix expression into our modules container and as a result lost any traceability back to the originating file.

Luckily, the fix is simple! Don’t import within an imports 👌

# This is better ✅
modules = {
  greet = ./greet.nix;
};

# This is also good ✅
imports = [
    ./greet.nix
];

With the applied fix, the results are what we want, and the world makes sense again. 😌

❯ nix repl
Nix 2.23.2
Type :? for help.
nix-repl> :l ./config.nix                                
Added 7 variables.

nix-repl> :p options.greet.name.definitionsWithLocations
[
  {
    file = "greet.nix";
    value = "hi";
  }
]

How common is this footgun ?

Turns out incredibly common (anecdotally) since there is nothing from preventing one from doing it; and it still “works”. The Nix Flake’s format makes this setup increasingly more common as well.

For a demonstration of how easy the fix is, I contributed a patch to agenix in #277 that fixes the issue.

diff --git a/flake.nix b/flake.nix
index 587138e..3a68940 100644
--- a/flake.nix
+++ b/flake.nix
@@ -23,13 +23,13 @@
   }: let
     eachSystem = nixpkgs.lib.genAttrs (import systems);
   in {
-    nixosModules.age = import ./modules/age.nix;
+    nixosModules.age = ./modules/age.nix;
     nixosModules.default = self.nixosModules.age;
 
-    darwinModules.age = import ./modules/age.nix;
+    darwinModules.age = ./modules/age.nix;
     darwinModules.default = self.darwinModules.age;
 
-    homeManagerModules.age = import ./modules/age-home.nix;
+    homeManagerModules.age = ./modules/age-home.nix;
     homeManagerModules.default = self.homeManagerModules.age;
 
     overlays.default = import ./overlay.nix;

Don’t import within an imports