Laser's cool website :)

Modularize Your NixOS Modules Pt. 2

In Part 1 of this series, we created a flake with an example package and accompanying module using the dendritic approach. In this part, we’ll apply this concept to our NixOS hosts and consume the flake we created in Part 1.

As a general principle, I’m not going to mention adding each individual file to git, but this is required for any tool apart from the repl to work. Remember to do this or you get errors from most nix tools.

Setting up the project structure

This is no different from Part 1. Initialize a git repository and add a flake.nix with the following content:

 1{
 2  description = "My dendritic NixOS hosts";
 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    example-module = {
11      url = "sourcehut:~incrediblelaser/dendritic-example-modules";
12      inputs.nixpkgs.follows = "nixpkgs";
13    };
14  };
15  outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } (inputs.import-tree ./modules);
16}

You might want to change the URL of the example-module input to a local path.

Again, our modules will go into modules/. Next, we’ll write composable modules that will be used by our hosts later.

Deciding on a hierarchical structure

The approach I chose (and from my understanding is also what the original author uses) is to basically create a base “aspect” onto which additional aspects are layered. This base aspect contains shared configuration for your hosts.

Another great resource that explains this concept can be found at Dr. Steve’s Dendritic Design repo.

One example we’ll use straight away is the import of the NixOS module we created in Part 1.

Create a new file modules/import-example-module.nix:

1{ inputs, ... }:
2{
3  flake.modules.nixos.base = {
4    imports = [ inputs.example-module.nixosModules.default ];
5  };
6}

Next, we ensure all our machines that use the base class come with an “admin” user that has sudo privileges. Let’s ignore that this is maybe not the best way to do this, and we’ll come back to this later, but at this point, it’s good enough for demonstration purposes. We’ll use modules/admin-user.nix for this.

1{
2  flake.modules.nixos.base = {
3    users.users.admin = {
4      isNormalUser = true;
5      extraGroups = [ "wheel" ];
6    };
7  };
8}

At this point, the base module contains both the example module import and our admin user. Time to make a host that uses these settings. We’ll start with a naive approach to make things easier here and transition to a more flexible approach later.

Our first host

In modules/example-host.nix, add we put the following:

 1{ config, inputs, ... }:
 2{
 3  flake.nixosConfigurations.example-host = inputs.nixpkgs.lib.nixosSystem {
 4    system = "x86_64-linux";
 5    modules = [
 6      config.flake.modules.nixos.base
 7      { networking.hostName = "example-host"; }
 8    ];
 9  };
10}

This is just an example to show the general layout of a NixOS host using the flake-parts provided mechanism to define them. Within the definition, we access the flake module “base” that we created earlier.

Let’s create a flake.lock and examine our host in the repl.

$ nix repl --expr "builtins.getFlake \"$PWD\""
…
nix-repl> nixosConfigurations.example-host.config.users.users
error: The option `flake.modules' is defined multiple times while it's expected to be unique.
…

Huh? So what’s happening? Well, turns out that flake-parts' default modules is different from the optional modules. The default ones, for example, can’t be merged, which we’re running into here. It wasn’t a problem in Part 1 because we only declared modules in a single place. Anyhow, let’s import the module proper in modules/import-flake-parts and try again:

1{ inputs, ... }:
2{
3  imports = [ inputs.flake-parts.flakeModules.modules ];
4}

We now get:

$ nix repl --expr "builtins.getFlake \"$PWD\""
…
nix-repl> nixosConfigurations.example-host.config.users.users
{
  admin = { ... };
…
}

nix-repl> nixosConfigurations.example-host.config.services.hello 
{
  enable = false;
  package = «error: The option `services.hello.package' was accessed but has no value defined. Try setting the option.»;
}

So we see that the contents from our nix files are now respected, though having an error for an undefined package isn’t nice. We’ll rectify this with our new gained knowledge about merged modules in a bit. But for now, let’s enable this service for our host by just adding it to our modules/example-host.nix:

 1{ config, inputs, ... }:
 2{
 3  flake.nixosConfigurations.example-host = inputs.nixpkgs.lib.nixosSystem {
 4    system = "x86_64-linux";
 5    modules = [
 6      config.flake.modules.nixos.base
 7      {
 8        networking.hostName = "example-host";
 9        services.hello.enable = true;
10      }
11    ];
12  };
13}

This might be a good time to tell you that you can just leave your repl open and reload the loaded flake using :r.

nix-repl> :r
nix-repl> nixosConfigurations.example-host.config.services.hello
{
  enable = true;
  package = «derivation /nix/store/xfsi3wf4gnh9i2c7kla3aq23dx1p2bgs-hello-script.drv»;
}

So now, with the service enabled, the package is actually set. This is because setting the package is also in our conditional block. Let’s try to run it from here:

$ nix run .#nixosConfigurations.example-host.config.services.hello.package
Hallo, Welt!

Throwback: Setting the hello script as service default

Going back to our modules repository from Part 1, we create a file called modules/set-hello-script-package-default.nix with the following content:

 1{ withSystem, ... }:
 2{
 3  flake.modules.nixos.hello =
 4    { pkgs, lib, ... }:
 5    {
 6      services.hello.package = lib.mkOptionDefault (
 7        withSystem pkgs.stdenv.hostPlatform.system ({ config, ... }: config.packages.helloScript)
 8      );
 9    };
10}

This basically does the same thing as setting the default attribute in a “normal” module. The options use the mkOptionDefault priority for their default entries. If you want to see that change reflected in your machines repository, you need to update that flake input.

Dendritification of the NixOS configurations

First off, that’s totally a word.

Anyhow, the best approach I’ve seen that applies the dendritic approach to NixOS configurations is by mightyiam in his infra repository. I’ve basically stolen copied this file for myself and modified it a bit so that it doesn’t need experimental language features (pipes). Let’s see what it does:

1options.configurations.nixos = lib.mkOption {
2  type = lib.types.lazyAttrsOf (
3    lib.types.submodule {
4      options.module = lib.mkOption {
5        type = lib.types.deferredModule;
6      };
7    }
8  );
9};

This defines a flake-wide option of lazy attributes that have a module attribute which in turn is an option of deferredModule. Honestly, I hadn’t heard about these types before, but from my research back then, these are often used to evaluate NixOS configurations. As a result, we can write stuff like the following into our flake:

1{
2  configurations.nixos.my-dendritic-host.module = { pkgs, ... }: {
3    environment.systemPackages = [ pkgs.hello ];
4  };
5}

What follows is the function to apply these scattered modules onto our NixOS configurations attribute:

 1config.flake = {
 2  nixosConfigurations = lib.flip lib.mapAttrs config.configurations.nixos (
 3    name: { module }: lib.nixosSystem { modules = [ module ]; }
 4  );
 5
 6  checks =
 7    config.flake.nixosConfigurations
 8    |> lib.mapAttrsToList (
 9      name: nixos: {
10        ${nixos.config.nixpkgs.hostPlatform.system} = {
11          "configurations/nixos/${name}" = nixos.config.system.build.toplevel;
12        };
13      }
14    )
15    |> lib.mkMerge;
16};

Personally, I have adopted this code as modules/generators/nixos.nix and it looks like the following (some stuff excluded to focus on the essentials):

 1{
 2  lib,
 3  config,
 4  ...
 5}:
 6{
 7  options.configurations.nixos = lib.mkOption {
 8    type = lib.types.lazyAttrsOf (
 9      lib.types.submodule {
10        options.module = lib.mkOption {
11          type = lib.types.deferredModule;
12        };
13      }
14    );
15  };
16
17  config.flake = {
18    nixosConfigurations = lib.flip lib.mapAttrs config.configurations.nixos (
19      name:
20      { module }:
21          lib.nixosSystem {
22          modules = [
23            module
24            {
25              config = {
26                networking.hostName = lib.mkDefault name;
27              };
28            }
29          ];
30        }
31    );
32
33    checks =
34      lib.mkMerge (
35        lib.mapAttrsToList (name: nixos: {
36          ${nixos.config.nixpkgs.hostPlatform.system} = {
37            "configurations/nixos/${name}" = nixos.config.system.build.toplevel;
38          };
39        }) config.flake.nixosConfigurations
40      );
41  };
42}

Basically the same without pipes, but with one more goodie: The hosts get a hostname by default which is the name of their attribute. To see this in effect, we create a new file at modules/hosts/dendritic-example/system.nix:

1{
2  configurations.nixos.dendritic-host.example = {
3    nixpkgs.hostPlatform = "x86_64-linux";
4  };
5}

Also, we have decided that our hello script service is very important and as such, we’re going to create an aspect for this that our new machine will import after. In modules/hello.nix:

1{
2  flake.modules.nixos.hello = {
3    services.hello.enable = true;
4  };
5}

Then, in dendritic fashion, we create a new file at modules/hosts/dendritic-example/imports.nix:

1{ config, ... }:
2{
3  configurations.nixos.dendritic-example.module = {
4    imports = with config.flake.modules.nixos; [
5      base
6      hello
7    ];
8  };
9}

Let’s examine our new host in the repl!

nix-repl> nixosConfigurations.dendritic-example.config.networking.hostName
"dendritic-example"

nix-repl> nixosConfigurations.dendritic-example.config.services.hello.enable
true

So this concludes the very basics of this approach. In this part, we created a very simple host that was defined in a very dendritic fashion. There is still a lot of room for improvement: Currently, most files live right under modules, and only the later ones are somewhat organized. But one advantage of the dendritic approach is that files can be moved freely, so feel free to get inspired by the other good resources, like the previously linked infrastructure or the very thorough Dendritic Design repositories.

The former has some very nice other tricks up its sleeve like defining flake-wide metadata, which I also adopted with some changes. I also have an import function for machines defined via a JSON file, a method for automatically generating hosts that differ in small details (a number of hosts which hostnames only differ in their name and with only a different IPv4 subnet) as well as a method to define different nixpkgs per host so that my machines can choose between unstable and unstable-small, as I use the former for desktops and the latter for servers. These aren’t perfect solutions and in the future, I’ll look into flake-file as well, but if there’s any interest, I’ll do some write-up on how I implemented my solutions.

As before, you can find the git of the files I created for this tutorial in my sourcehut repository.

#nixos #nix #linux