Laser's cool website :)

Modularize Your NixOS Modules Pt. 1

Insert picture of Xzibit here.

Last year, some clever people came up with an interesting approach on organizing your Nix (and in most cases, NixOS) modules. I’m not going into the finer details here as there are excellent resources like this site which discusses the approach at length. In short, you get rid of stuff like file-based importing of modules. Module arguments become attributes stored as part of your config.

However, where most posts fall short is to show and explain how the concept can be meaningfully applied. There is the excellent infrastructure repository by the original inventor, however that one isn’t documented in such a depth that the inner workings immediately become clear with regards to how a NixOS configuration is actually built from such a setup. In this (series of) blog post(s), I’ll build not only one, but two repositories using this approach – one that holds a very simple package, a NixOS module that goes along with it plus a devshell that makes use of the program in that package, and another one that holds NixOS configurations that make use of that module.

I prefer to split machine configurations from modules and packages as these are two seperate aspects in my opinion. It makes reuse of modules and packages easier. If you’re only interested in managing your NixOS configurations and don’t have any modules or packages of your own, this part might not interest you too much.

Initializing the project

It’s good practice to keep your modules in a git repository and have it hosted somewhere so that you get version control and a backup, plus it’s always nice if flake inputs are available under a fixed URL. Create a new git repository using git init my-nixos-modules (or whatever directory you want to use instead of my-nixos-modules) and create a flake.nix containing the following:

 1{
 2  description = "My NixOS modules and packages";
 3  inputs = {
 4    flake-parts = {
 5      url = "github:hercules-ci/flake-parts";
 6      inputs.nixpkgs-lib.follows = "nixpkgs";
 7    };
 8    import-tree.url = "github:vic/import-tree";
 9    nixpkgs.url = "github:/nixOS/nixpkgs/nixos-unstable";
10  };
11  outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } (inputs.import-tree ./modules);
12}

We’ll need nixpkgs later for our package, but it’s not required at this point.

This is a variation of flake-parts getting started and is not very different from a “normal” flake. The flake’s output body is however generated by flake-partsmkFlake function, which in turn gets its contents from import-tree which just loads (almost) all .nix files under ./modules.

Surprisingly, that is all for our flake.nix. All other information is stored modularly in seperate .nix files.

The essentials

flake-parts needs to know which system architectures it should produce outputs for (see the aforementioned Getting Started link). Create a new file modules/systems.nix:

1{
2  systems = [
3    "x86_64-linux"
4    "aarch64-linux"
5  ];
6}

Please note that all file names and their locations don’t matter except for three criteria: They must be located under modules, end in .nix and they must not start with an underscore, as the latter are ignored. This is a convenient mechanism to disable module components. With these files in place, we basically have an equivalent to flake-utils or the forAllSystems approach you sometimes see in flakes. flake-parts handles this for us, but comes with a lot more powerful features that this approach leverages.

In case we want to debug our work in the repl, a useful tool is the debug functionality which allows inspecting attributes that are normally not exposed. Let’s enable it for the time being using modules/debug.nix:

1{ debug = true; }

For good measure, let’s update the flake.lock add our files to the git index, and commit:

1$ nix flake update
2$ git add modules flake.*
3$ git commit -m "Initial commit"

The current project structure should look like this:

├── flake.lock
├── flake.nix
└── modules
    ├── debug.nix
    └── systems.nix

Let’s inspect the repl to see the effect of what we created so far:

 1$ nix repl --expr "builtins.getFlake \"$PWD\""
 2Nix 2.31.3
 3Type :? for help.
 4Loading installable ''...
 5Added 20 variables.
 6_type, allSystems, apps, checks, currentSystem, debug, devShells, formatter, inputs, lastModified, lastModifiedDate, legacyPackages, narHash, nixosConfigurations, nixosModules, outPath, outputs, overlays, packages, sourceInfo
 7nix-repl> packages
 8{
 9  aarch64-linux = { ... };
10  x86_64-linux = «repeated»;
11}

So we see that flake-parts has created the respective systems under packages, even though they’re currently empty. But our barebones setup is working. Now, we create a trivial package, an accompanying development shell and finally, a NixOS module that we import later.

Adding our package

Since packages are different per system, they go into flake-partsperSystem attribute. For the sake of simplicity, we’ll just create a service that runs a script calling GNU hello.

1#! /usr/bin/env bash
2hello

You’ll notice that this script errors (after making it executable) if you don’t have hello installed, which you probably don’t. The nixpkgs library offers helpers to write scripts with the full store path, but we’ll be using writeShellApplication. This way, our shell script will stay generic and reusable on other distributions. Let’s create a modules/hello-package.nix:

 1{ self, ... }:
 2{
 3  perSystem =
 4    { pkgs, ... }:
 5    {
 6      packages.helloScript = pkgs.writeShellApplication {
 7        name = "hello-script";
 8        runtimeInputs = with pkgs; [ hello ];
 9        text = builtins.readFile "${self.outPath}/hello.sh";
10      };
11    };
12}

I prefer to not use relative paths for reading non-nix files, but rather use the flake’s root via self.outPath because otherwise, moving hello-package.nix would necessitate moving hello.sh as well. We could of course just write the text into a nix file directly, but that would defeat the purpose of a portable script file. You can argue that this isn’t purely dendritic, but let’s keep it how it currently is.

Checking out our repl again, we see that the packages have been created:

packages.x86_64-linux.helloScript
«derivation /nix/store/xfsi3wf4gnh9i2c7kla3aq23dx1p2bgs-hello-script.drv»

Let’s add our new files to git so that they’re picked up by the other nix commands and try to run it:

1$ git add hello.sh modules/hello-package.nix
2$ nix run .#helloScript
3warning: Git tree '…' is dirty
4Hallo, Welt!

Hooray! We have created a trivial package! Let’s create the accompanying development shell. I promise, this is where it gets interesting.

Adding a development shell

Development shells are also system-specific, so they too go into the perSystem attribute. Usually, they contain something being worked on and some useful tooling. In our case, we want a development shell with the program itself and its runtime dependencies so that we can use them in isolation. Let’s use modules/hello-devshell.nix:

 1{
 2  perSystem =
 3    { pkgs, self', ... }:
 4    {
 5      devShells.hello = pkgs.mkShellNoCC {
 6        packages = [
 7          self'.packages.helloScript
 8        ];
 9      };
10    };
11}

self' (self prime) is a feature of flake-parts which enables us to access the flake’s own attributes with our system preselected. Don’t ask me how they did this, it might just be magic at this point. We want this because we want the package that corresponds to the devshell’s system.

Add the module to the git index and try open the devshell to run our script:

1$ git add modules/hello-devshell.nix
2nix develop .#hello
3warning: Git tree '…' is dirty
4$  hello-script 
5Hallo, Welt!

Very cool! But now the tricky and actually interesting part that I promised begins. As we see, the original hello actually isn’t part of the development shell:

1$ nix develop .#hello
2$ hello
3bash: hello: Kommando nicht gefunden

(Command not found in German, I’m too lazy to run with a different locale)

There isn’t actually a simple attribute in our package that returns its runtime inputs. We need to store those somewhere and then retrieve them, and for that, we’ll use options.

If you’ve written a NixOS module with options before, this might seem familiar (and I recommend doing this before, as explaining everything about this would make this post even longer…). Runtime inputs are system-dependent since they’re a list of packages, so we create an appropriate option under perSystem.

Please note that I’m not claiming that this is the definitive way to do this, there might be better approaches that are more modular, but this is probably the simplest one when using a dendritic style. If not, I’d love to see some better example.

Creating an option to store shared attributes

We create a new file modules/runtime-inputs-option.nix with the following content:

 1{ lib, ... }:
 2{
 3  perSystem.options.runtimeInputs =
 4    let
 5      inherit (lib) mkOption;
 6      inherit (lib.types) attrsOf listOf package;
 7    in
 8    mkOption {
 9      type = attrsOf (listOf package);
10      default = { };
11    };
12}

and in tandem modify our modules/hello-package.nix like so:

 1{ self, ... }:
 2{
 3  perSystem =
 4    { config, pkgs, ... }:
 5    {
 6      runtimeInputs.helloScript = with pkgs; [ hello ];
 7      packages.helloScript = pkgs.writeShellApplication {
 8        name = "hello-script";
 9        runtimeInputs = config.runtimeInputs.helloScript;
10        text = builtins.readFile "${self.outPath}/hello.sh";
11      };
12    };
13}

After adding the new nix file to git, we can still run our script via nix run:

1$ nix run .#helloScript
2Hallo, Welt!

So what did we do? Instead of just adding our runtime inputs to the package directly, we saved them to perSystem’s config.runtimeInputs.helloScript. This works because we previously created an option for a list of packages. You can use any name under perSystem.runtimeInputs because of type attrsOf. The individual entries must be a list of packages, like writeShellApplication’s runtimeInputs.

Let’s amend our development shell to include our newly defined runtime inputs and switch from self' to perSystem’s config, as these are equivalent in this case.

 1{
 2  perSystem =
 3    {
 4      config,
 5      pkgs,
 6      ...
 7    }:
 8    {
 9      devShells.hello = pkgs.mkShellNoCC {
10        packages =
11          with config;
12          [
13            packages.helloScript
14          ]
15          ++ runtimeInputs.helloScript;
16      };
17    };
18}

We enter the development shell again and test that we now have access to the runtime inputs:

1$ nix develop .#hello
2$ hello
3Hallo, Welt!

Hooray again! This list of packages is now available for each attribute under perSystem with the correct architecture for each package preselected.

Note that according to the official documentation, while this works, this isn’t optimal for a reason I currently don’t care about. However, I might rectify this later.

I’ll update this post later with an exported NixOS module that can be imported by a NixOS configuration. In the meantime, you can find this example repository at https://git.sr.ht/~incrediblelaser/dendritic-example-modules.

Writing a NixOS module using this approach

NixOS modules using flake-parts look a bit different from “normal” NixOS modules as they’re basically “wrapped” in the dendritic approach; this makes use of the functionality described here. This makes them available across your whole flake, similar to how we made the runtime inputs available across perSystem. This allows us to load flake modules by name and hence make everything dendritic.

Another and possibly bigger difference is that packages used within modules can’t simply be used like you might know from normal modules (e.g. cfg.package = pkg.whatever won’t work). This is described here; also we can’t use importApply since that is non-dendritic. But there is at least one other way, which the official docs call the “Factor it out” approach.

A skeleton flake-parts module for us to use looks like this:

1{
2  flake.modules.nixos.hello = {
3    # NixOS module goes here
4  };
5}

Let’s fill it with what you’d usually expect from a module. In, for example, modules/hello-service.nix:

 1{
 2  config,
 3  lib,
 4  pkgs,
 5  ...
 6}:
 7let
 8  cfg = config.services.hello;
 9  inherit (lib)
10    mkDefault
11    mkEnableOption
12    mkOption
13    mkIf
14    ;
15  inherit (lib.types) package;
16in
17{
18  options.services.hello = {
19    enable = mkEnableOption "Hello script system service";
20    package = mkOption {
21      type = package;
22      description = "The hello script package to use";
23    };
24  };
25  config = mkIf cfg.enable {
26    systemd.services.hello = {
27      wantedBy = [ "multi-user.target" ];
28      description = "Very important hello service";
29      serviceConfig = {
30        Type = "oneshot";
31        execStart = "${cfg.package}/bin/hello-script";
32        DynamicUser = true;
33        RemainAfterExit = true;
34      };
35    };
36  };
37}

Note that this service isn’t properly hardened and serves only for demonstration purposes. Don’t blame me if your house catches fire after enabling this.

So far, this looks very familiar, if you’ve written a NixOS module before. But it’s missing the default package setting for our service. Instead of putting it into the options part, we put it as “Option Default” priority into the config part and apply what the official documentation describes:

 1{ withSystem, ... }:
 2{
 3  flake.modules.nixos.hello =
 4    {
 5      config,
 6      lib,
 7      pkgs,
 8      ...
 9    }:
10    let
11      cfg = config.services.hello;
12      inherit (lib)
13        mkOptionDefault
14        mkEnableOption
15        mkOption
16        mkIf
17        ;
18      inherit (lib.types) package;
19    in
20    {
21      options.services.hello = {
22        enable = mkEnableOption "Hello script system service";
23        package = mkOption {
24          type = package;
25          description = "The hello script package to use";
26        };
27      };
28      config = mkIf cfg.enable {
29        services.hello.package = mkOptionDefault (
30          withSystem pkgs.stdenv.hostPlatform.system ({ config, ... }: config.packages.helloScript)
31        );
32        systemd.services.hello = {
33          wantedBy = [ "multi-user.target" ];
34          description = "Very important hello service";
35          serviceConfig = {
36            Type = "oneshot";
37            execStart = "${cfg.package}/bin/hello-script";
38            DynamicUser = true;
39            RemainAfterExit = true;
40          };
41        };
42      };
43    };
44}

The only difference to before are lines 1 to 3 (as well as the corresponding closing bracket in line 44), which now makes withSystem available in our module, and lines 29 to 31, which set our package with mkOptionDefault priority (this corresponds to mkOverride 1500; the higher the number, the lower the priority).

So now we have a nice and hopefully working module, but it’s not yet exported to be consumed by a NixOS configuration. This will be done in – surprise – another flake module.

In modules/nixos-module-export.nix, we put the following:

1{ config, ... }:
2{
3  flake.nixosModules.default.imports = with config.flake.modules.nixos; [
4    hello
5  ];
6}

This makes use of flake-parts’ mechanism to automatically create the nixosModules output for us, as described here. It’s also a teaser for the next part: instead of doing something like imports = [ ./hello-service.nix ] (which wouldn’t be dendritic), we effectively do imports = [ config.flake.modules.nixos.hello ]. This works regardless of location of the file this module is declared in. There will be intensive usage of that functionality when we actually define system configurations.

This is the part where you could notice that config has a different scope depending on the context. In a NixOS module, it refers to the NixOS system configuration. In perSystem, it refers to values stored in that scope, like our runtimeInputs. When nested, inner ones shadow the outer ones. While this hasn’t happened here, there are cases where you want access to both the inner and the outer config (there’ll be a constructed example in a follow up if demand is there). In those cases, you can bind one or more attributes to a variable. You’ve probably seen something this before:

1outer@{ config, ...}:
2{
3  whatEver = { config, ... }: {
4    attributeOne = config.someSystemValue;
5    attributeTwo = outer.config.someGlobalValue;
6  };
7}

Whether this all worked out, we’ll see when we import this nixosModule into a system that we’ll define using this dendritic approach in the next part.

#nixos #nix #linux