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
trueSo 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.