Modularize Your NixOS Modules Pt. 1
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.
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-parts’ mkFlake 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 two 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.nixLet’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-parts’ perSystem attribute.
For the sake of simplicity, we’ll just create a service that runs a script calling GNU hello.
1#! /usr/bin/env bash
2helloYou’ll notice that this script errors (after making it executable)
if you don’t have hello installed, which you probably won’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 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.
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.