Flake-parts: writing custom flake modules

Published on

A primer on writing flake-parts reusable flake modules.

Background #

Flake-parts is a project that, on the surface, is in the same field as flake-utils, flake-tools and a couple of other abstractions on top of the standard flake syntax. Its killer feature, however, is in the flake modules which allow reusing one flake’s components in another flake.

Flake modules behave very similarly to NixOS or Home Manager modules, but they act on the flake itself, changing its outputs.

I have been on board the flake-parts train for quite a while and find it indispensable for both maintaining a couple of long-running projects and setting up quick ad-hoc flakes that might later evolve into a new project or get merged into a pre-existing project.

Example: typical use case for consuming modules #

The module I use in pretty much all of my projects is devshell. When combined with direnv, it shows a neat menu whenever I cd into a project directory:

❯ cd $<projectDir>
direnv: loading /tmp/tmp.JfrD4VJy4a/.envrc
direnv: using flake
direnv: nix-direnv: using cached dev shell
🔨 Welcome to devshell

[[general commands]]

  hello - print hello
  menu  - prints this menu

direnv: export +DEVSHELL_DIR +IN_NIX_SHELL +NIXPKGS_PATH +PRJ_DATA_DIR +PRJ_ROOT +name ~PATH ~XDG_DATA_DIRS

And this is achieved with a flake.nix file that looks like this:

flake.nix with simple devshell
{
  inputs = {
    flake-parts.url = "github:hercules-ci/flake-parts";
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    devshell.url = "github:numtide/devshell";
  };

  outputs =
    inputs@{ flake-parts, ... }:
    flake-parts.lib.mkFlake { inherit inputs; } {
      imports = [ inputs.devshell.flakeModule ];
      systems = [
        "x86_64-linux"
        "aarch64-linux"
        "aarch64-darwin"
        "x86_64-darwin"
      ];
      perSystem =
        _:
        {
          devshells.default = {
            commands = [
              {
                help = "print hello";
                name = "hello";
                command = "echo hello";
              }
            ];
          };
        };
    };
}

All aspects of the displayed menu and the underlying devshell can be configured through a corresponding flake module option. The options are also programmable, for example if your flake includes several packages, they can all be dynamically exposed in that menu:

❯ menu        

[[general commands]]

  hello       - print hello
  menu        - prints this menu

[this flake's packages]

  run-cmatrix - run flake's package run-cmatrix
  run-cowsay  - run flake's package run-cowsay
Sample flake.nix with dynamic devshell commands
{
  inputs = {
    flake-parts.url = "github:hercules-ci/flake-parts";
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    devshell.url = "github:numtide/devshell";
  };

  outputs =
    inputs@{ flake-parts, ... }:
    flake-parts.lib.mkFlake { inherit inputs; } {
      imports = [ inputs.devshell.flakeModule ];
      systems = [
        "x86_64-linux"
        "aarch64-linux"
        "aarch64-darwin"
        "x86_64-darwin"
      ];
      perSystem =
        { pkgs, self', ... }:
        let
          inherit (pkgs) lib;
        in
        {
          packages = rec {
            default = cowsay;
            inherit (pkgs) cowsay cmatrix;
          };
          devshells.default = {
            commands =
              lib.pipe self'.packages [ # Take own packages
                lib.attrsToList # Turn into a list of { name = "hello"; value = pkgs.hello; }
                (lib.filter (a: a.name != "default")) # Remove "default" package
                (map (a: rec {
                  name = "run-${a.name}";
                  help = "run flake's package ${name}";
                  package = a.value;
                  category = "this flake's packages";
                })) # Transform the list into the schema compatible with devshell commands
              ]
              ++ [ # Manually specified commands
                {
                  help = "print hello";
                  name = "hello";
                  command = "echo hello";
                }
              ]

            ;
          };
        };
    };
}

Example: writing configurable inputs bumper #

When I work on flake projects, I frequently need to bump inputs one by one. These actions are usually followed by commits with creative messages like “bumping inputs”, “[ci] bump inputs” or “ff”.

Let’s write a flake module which creates devshell commands for specific modules which need to be bumped often. This would basically be a wrapper around nix flake update --commit-lock-file with an optional --update-input that shows up in project’s $PATH for quick access.

For the purposes of this section, “provider” flake is the one containing bumper flake module and the “consumer” flake is the one that imports it.

Interface #

Starting with the interface in flake.nix. Unlike services. and programs. options in NixOS modules, there is no convention for option.enable for flake-modules. So the consumer would import this module like so:

{
  inputs.bumper-flake-module.url = "<url>";
  inputs.devshell.url = "github:numtide/devshell";
  input1 = "<ref_1>"; # This input needs to be bumped often
  input2 = "<ref_2>"; # This input needs to be bumped often

  outputs =
  # ...
  {
    imports = [
      inputs.devshell.flakeModule
      bumper-flake-module.flakeModule
    ];
  
    perSystem = _: {
      bumper = {
        changingInputs = [ "input1" "input2" ];
        bumpAllInputs = true; # Adds a "bump all inputs" command
      };
    }
  };
}

And then see the commands in a menu:

direnv: using flake
direnv: nix-direnv: using cached dev shell
🔨 Welcome to devshell
# ...
[flake management]

  flake-bump-all-inputs       - Bump all inputs
  flake-bump-input1           - Bump input input1
  flake-bump-input2           - Bump input input2

The module #

The flake modules operate the same way as NixOS modules – by computing a fixed point for the merge operation on attrsets. Similar to the standard modules, the modules are a function, but unlike the standard modules the default arguments are components of the consumer flake instead of the NixOS configuration.

For proper variable binding, it’s possible to use the importApply pattern to produce the flake module based on the values in the provider flake.

The full source code for the module and the script is here, but the most important parts are:

# Top-level parameters that are bound to the provider flake
# These are passed from `flake.nix` using importApply
{ flake-parts-lib, nixpkgs-lib, ... }:
# ...
# These values would bind to the consumer flake when this flake module is imported:
{ config, self, inputs, ... }:
# ...
# This is an example of using the consumer's flake values to generate the
# scripts
      help =
        # Double-check that the input actually exists
        # This is not strictly necessary as the wrapped nix flake does the same thing, but it's an illustration of referring to the consumer flake (self.inputs)
        assert assertMsg (builtins.elem inputName (
          builtins.attrNames self.inputs
        )) "Input '${inputName}' does not exist in current flake. Check bumper settings.";
        "Bump input ${inputName}";
# ...

This module gets turned into a flake module in flake.nix by importing bumperModule.nix and applying it to the arguments passed from provider flake (flake-parts-lib and nixpkgs-lib).

The bumper script source is here. It’s a thin wrapper around nix flake update, so it’s not terribly interesting.

Example usage #

This is what the consumer experience would look like when using this module:

direnv: using flake
direnv: nix-direnv: renewed cache
🔨 Welcome to devshell

[[general commands]]

  menu                  - prints this menu

[flake management]

  flake-bump-all-inputs - Bump all inputs
  flake-bump-bumper     - Bump input bumper

direnv: export +DEVSHELL_DIR +IN_NIX_SHELL +NIXPKGS_PATH +PRJ_DATA_DIR +PRJ_ROOT +name ~PATH ~XDG_DATA_DIRS
# <a commit is pushed out to bumper repo>
❯ flake-bump-bumper    
# ...
# And we have a commit with the custom message:
❯ git log -1 --pretty=format:%s%b
[ci]: bumping bumperFlake lock file updates:

• Updated input 'bumper':
    'github:VTimofeenko/writing-flake-modules/d78ffddd6d9523bddcc9f7700543c3b252830f3f?dir=example-1-configurable-inputs-bumper/provider' (2024-05-01)
  → 'github:VTimofeenko/writing-flake-modules/d870ba6194b17e10be72e65d21fc2793c3863212?dir=example-1-configurable-inputs-bumper/provider' (2024-05-01)

A sample flake is in the same repository, under “consumer”.

Example: composable homeManagerModules #

Let’s build a flake module which will allow homeManagerModules output to compose, like nixosModules do. A flake module like that becomes necessary when multiple flake modules declare homeManagerModules output and flake-parts becomes confused about merging those modules.

To demonstrate this (“bad” flake):

# flake.nix
{
  description = "Flake with two homeManagerModules outputs";

  inputs = {
    flake-parts.url = "github:hercules-ci/flake-parts";
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  };

  outputs =
    inputs@{ flake-parts, ... }:
    flake-parts.lib.mkFlake { inherit inputs; } (
      {
        flake-parts-lib,
        ...
      }:
      {
        imports = [
          ./hmModule1.nix
          ./hmModule2.nix
        ];
        systems = [
          "x86_64-linux"
          "aarch64-linux"
          "aarch64-darwin"
          "x86_64-darwin"
        ];
        perSystem =
          { pkgs, ... }:
          {
            packages.default = pkgs.hello;
          };
      }
    );
}

# hmModule1.nix
_:
{
  flake.homeManagerModules.foo-1 =
    { pkgs, ... }:
    {
      home.packages = [ pkgs.cowsay ];
    }
  ;
}

# hmModule2.nix
_:
{
  flake.homeManagerModules.foo-2 =
    { pkgs, ... }:
    {
      home.packages = [ pkgs.hello ];
    }
  ;
}

nix flake check would complain:

       error: The option `flake.homeManagerModules' is defined multiple times while it's expected to be unique.

       Definition values:
       - In `/nix/store/8bxvqk4349b3irgk45w8fy06wjb7sqc6-source/hmModule1.nix':
           {
             foo-1 = <function, args: {pkgs}>;
           }
       - In `/nix/store/8bxvqk4349b3irgk45w8fy06wjb7sqc6-source/hmModule2.nix':
           {
             foo-2 = <function, args: {pkgs}>;
           }
       Use `lib.mkForce value` or `lib.mkDefault value` to change the priority on any of these definitions.

This happens because homeManagerModules is just a random output with no specified way to merge it.

NixOS modules, on the other hand, don’t produce the same error (just replace flake.homeManagerModules with flake.nixosModules). This happens because flake-parts explicitly define how to merge the flake.nixosModules option here. Solution is to do a bit of s;nixosModules;homeManagerModules;g:

# Flake module that declares flake.homeManagerModules outputs and how to merge it
{
  lib,
  # , self
  flake-parts-lib,
  moduleLocation,
  ...
}:
let
  inherit (lib) mapAttrs mkOption types;
  inherit (flake-parts-lib) mkSubmoduleOptions;
in
{
  options = {
    flake = mkSubmoduleOptions {
      homeManagerModules = mkOption {
        type = types.lazyAttrsOf types.unspecified;
        default = { };
        apply = mapAttrs (
          k: v: {
            _file = "${toString moduleLocation}#homeManagerModules.${k}";
            imports = [ v ];
          }
        );
        description = ''
          Home Manager modules.

          You may use this for reusable pieces of configuration, service modules, etc.
        '';
      };
    };
  };
}

And import it in the flake (“good” flake):

        imports = [
+         ./mkHomeManagerOutputsMerge.nix
          ./hmModule1.nix
          ./hmModule2.nix
        ];

Now nix flake check stops complaining:

❯ nix flake check && echo "SUCCESS"
warning: unknown flake output 'homeManagerModules'
warning: The check omitted these incompatible systems: aarch64-darwin, aarch64-linux, x86_64-darwin
Use '--all-systems' to check all.
SUCCESS

Example: organizing code using flake modules #

TL;DR: example code.

In addition to the aforementioned output reusability/configuration across flakes I found flake-modules very useful for code organization within a monorepo-like project.

Say you have a project that has the following outputs:

  1. Exposes a package definition (packages.foo)
  2. Has a NixOS module for that package (nixosModules.foo)
  3. Has a home manager module for that package (homeManagerModules.foo)
  4. Has checks for different aspects of the package

With the standard approach, the code implementing all of those outputs could be scattered across different directories (package in packages/foo, modules in modules/nixos, etc.) and in different parts of the flake.nix file.

This might lead to related code changes being done in different directories complicating the diffs.

Flake modules allow putting all that code in a single directory (say, flake-modules/foo) and importing it once in the flake.nix file.

This section follows the development process of the sample flake module with trusty pkgs.hello being the stand-in for packages.foo. The main syntax inspiration is “Dogfood a Reusable Flake Module” from the official documentation.

flake.nix #

The easy part: setting up flake.nix and importing the future flake module:

cd $(mktemp -d)
❯ mkdir -p flake-modules/foo
❯ nix flake init -t github:hercules-ci/flake-parts
wrote: /tmp/tmp.9EKwLKT4Fi/flake.nix
# Stub flake module so flake show works
echo "_: {}" > flake-modules/foo/default.nix
$EDITOR flake.nix
Resulting flake.nix with the module import
{
  description = "Flake with a flake module";

  inputs = {
    flake-parts.url = "github:hercules-ci/flake-parts";
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  };

  outputs =
    inputs@{ flake-parts, ... }:
    flake-parts.lib.mkFlake { inherit inputs; } (
      { withSystem, flake-parts-lib, ... }:
      let
        inherit (flake-parts-lib) importApply;
        foo-flake-mod = importApply ./flake-modules/foo { inherit withSystem; };
      in
      {
        imports = [ foo-flake-mod ];
        systems = [
          "x86_64-linux"
          "aarch64-linux"
          "aarch64-darwin"
          "x86_64-darwin"
        ];
        # the rest of the flake.nix
      }
    );
}

Now nix flake show shows (for now, an empty) flake:

❯ nix flake show
path:/tmp/tmp.9EKwLKT4Fi?lastModified=1714330880&narHash=sha256-DSvBWp/ReLzfchwoOJpzm%2BONWgyZjVwnrf/m1GJfmvo%3D

Declaring a package #

To add a package, the flake-modules/foo/default.nix should now be changed to look like this:

# The importApply argument. Use this to reference things defined locally,
# as opposed to the flake where this is imported.
localFlake:

# Regular module arguments; self, inputs, etc all reference the final user flake,
# where this module was imported.
{ lib, config, self, inputs, ... }:
{
  perSystem = { system, ... }: {
    packages.foo = localFlake.withSystem system ({ config, pkgs, ... }:
      pkgs.callPackage ./pkgs/foo.nix { } 
    );
  };
}

With pkgs/foo.nix consisting of:

# A very realistic package that exposes pkgs.hello
# For a real package this would be a call to a mkDerivation-like function
{ hello }:
hello

After this, nix flake show displays:

❯ nix flake show                    
path:/tmp/tmp.9EKwLKT4Fi?lastModified=1714331937&narHash=sha256-JyIOTbNOufPZkVbx35sFU4e2iyKbPpCI0B3NgXBP6DM%3D
└───packages
    ├───aarch64-darwin
    │   └───foo omitted (use '--all-systems' to show)
    ├───aarch64-linux
    │   └───foo omitted (use '--all-systems' to show)
    ├───x86_64-darwin
    │   └───foo omitted (use '--all-systems' to show)
    └───x86_64-linux
        └───foo: package 'hello-2.12.1'

Note that no additional changes in flake.nix are needed, the changes propagate through the imports.

Writing a NixOS module #

Let’s write a simple module that creates a systemd service running our package.foo.

While writing this post, I discovered that Flake-parts has a special API that pulls the per-system attributes into the modules, called moduleWithSystem. It works very much like withSystem, so let’s add it to flake.nix inherit point:

# flake.nix
- { withSystem, flake-parts-lib, ... }:
+ { withSystem, moduleWithSystem, flake-parts-lib, ... }:

- foo-flake-mod = importApply ./flake-modules/foo { inherit withSystem };
+ foo-flake-mod = importApply ./flake-modules/foo { inherit withSystem moduleWithSystem; };

Then, let’s add the module to flake-modules/foo/default.nix:

{
  perSystem = { system, ... }: {
    packages.foo = localFlake.withSystem system ({ config, pkgs, ... }:
      pkgs.callPackage ./pkgs/foo.nix {}
    );
  };

+  flake.nixosModules.foo = localFlake.moduleWithSystem (
+    perSystem@{ config }: import ./nixosModules perSystem;
+  );
}

And the module code itself:

```nix
perSystem: # How the per-system part of flake is passed
{ lib, ... }: # Standard module arguments
{
  systemd.services.foo = {
    wantedBy = [ "multi-user.target" ];
    serviceConfig = {
      Type = "simple";
      ExecStart = lib.getExe perSystem.config.packages.foo;
    };
  };
}

The module now shows up in nix flake show:

+├───nixosModules
+│   └───foo: NixOS module
└───packages
    ├───aarch64-darwin
...

To demonstrate how it works, let’s add a check for it inside perSystem:

perSystem = 
    { system, ... }:
    {
    # ...
+     checks.foo-nixos-module = localFlake.withSystem system (
+       {
+         config,
+         pkgs,
+         ...
+       }:
+       pkgs.testers.runNixOSTest {
+         name = "foo-nixos-module";
+         nodes.machine1 =
+           _: # { config, pkgs, ... }:
+           {
+             imports = [ self.nixosModules.foo ];
+           };
+         testScript = ''
+           machine.wait_for_unit("foo")
+           _, output = machine.systemctl("status foo")
+
+           assert "Hello, world" in output
+         '';
+       }
+     );
  };

And nix flake check is happy:

❯ nix flake check && echo "SUCCESS"
warning: The check omitted these incompatible systems: aarch64-darwin, aarch64-linux, x86_64-darwin
Use '--all-systems' to check all.
SUCCESS

To be fair, moduleWithSystem is not the only way to pass the flake’s package to a NixOS module. Other alternatives include:

  1. Declaring an overlay (btw flake parts makes it easy) and asking that the developer of the consumer flake to include that overlay in their instance of pkgs.

    Then, just use pkgs in the module as usual.

  2. The module code in nixosModules/default.nix is not technically a module, it’s a function of perSystem argument that produces a module. The flake’s self reference can be passed same way and then the self.packages.${pkgs.system} can be extracted from it in the module code.

    This was my pattern of choice, but I will try switching to moduleWithSystem moving forward.

Writing a Home Manager module #

In a manner that is very similar to nixosModules.foo, we can write a Home Manager module.

One important thing about this output is that it’s not one of the standard flake-parts flake API. In practice this means that if we have two flake modules declaring homeManagerModules outputs, there will be a conflict when they are both imported. Fixing it would need another flake module that would help flake-parts merging the non-standard output. See corresponding section for an example.

Here’s the content of homeManagerModules/default.nix. The only adjustments needed are Home Manager-specific systemd.user.services options:

perSystem:
{ lib, ... }:
{
  systemd.user.services.foo = {
    Service = {
      Type = "simple";
      ExecStart = lib.getExe perSystem.config.packages.foo;
    };
    Install.WantedBy = [ "default.target" ];
  };
}

The check for it could look like this:

      checks.foo-hm-module = localFlake.withSystem system (
        { config, pkgs, ... }:
        pkgs.testers.runNixOSTest {
          name = "foo-hm-module";
          nodes.machine1 =
            _: # { config, pkgs, ... }:
            {
              imports = [ inputs.home-manager.nixosModules.home-manager ];
              users.users.alice = {
                isNormalUser = true;
                password = "hunter2";
              };

              home-manager.users.alice =
                _: # { config, ... }: # config is home-manager's config, not the OS one
                {
                  imports = [ self.homeManagerModules.foo ];
                  home.stateVersion = "23.11";
                };
            };
          testScript = ''
            # Login
            machine.wait_until_tty_matches("1", "login: ")
            machine.send_chars("alice\n")
            machine.wait_until_tty_matches("1", "Password: ")
            machine.send_chars("hunter2\n")
            machine.wait_until_tty_matches("1", "alice\@machine")

            # Wait for everything to start
            machine.wait_for_unit("home-manager-alice.service")
            _, output = machine.systemctl("status foo", user="alice")

            assert "Hello, world" in output
          '';
        }
      );

Which keeps nix flake check happy.

Scaling to multiple flake-modules #

Much like foo flake-module, a bar flake module could be written and merged into the resulting flake through flake-parts imports. Keep in mind that for non-standard outputs (like homeManagerModules), it’s necessary to tell flake-parts how to merge them.

As an example, in my dotfiles/homelab/etc. repository I have certain reusable parts of my configuration split into flake-modules.

Tags