Skip to content

Instantly share code, notes, and snippets.

@udf
Last active October 15, 2025 20:36
Show Gist options
  • Save udf/4d9301bdc02ab38439fd64fbda06ea43 to your computer and use it in GitHub Desktop.
Save udf/4d9301bdc02ab38439fd64fbda06ea43 to your computer and use it in GitHub Desktop.

Revisions

  1. udf revised this gist Nov 26, 2021. 1 changed file with 3 additions and 1 deletion.
    4 changes: 3 additions & 1 deletion write_up.md
    Original file line number Diff line number Diff line change
    @@ -225,4 +225,6 @@ in
    And it works! I don't really get exactly why explicitly pulling out the options that we want avoids the infinite recursion.

    # Conclusion
    Obviously for this trivial example, putting the merge at the `config.systemd` level makes more sense, but for [a more complex module](https://github.com/udf/nix-hanzo/blob/5b52e5c4fe42ccc36b6007e77e5546026ecb5357/modules/vpn-containers.nix#L49) it definitely helps with readability.
    Obviously for this trivial example, putting the merge at the `config.systemd` level makes more sense, but for [a more complex module](https://github.com/udf/nix-hanzo/blob/5b52e5c4fe42ccc36b6007e77e5546026ecb5357/modules/vpn-containers.nix#L49) it definitely helps with readability.

    Something else to note is that if we wanted to define service options then we would get a recursion error, the solution in that case is to move our module's options to another top level key that we're not going to use in the `config` section (for example, `options.custom.services.zfs-auto-scrub`).
  2. udf revised this gist Nov 26, 2021. 1 changed file with 3 additions and 1 deletion.
    4 changes: 3 additions & 1 deletion write_up.md
    Original file line number Diff line number Diff line change
    @@ -203,7 +203,9 @@ error: infinite recursion encountered

    What gives? if we take one key from the output of our function and assign it to the module, then it works fine:
    ```nix
    config.systemd = (mkMergeTopLevel (...).systemd);
    {
    config.systemd = (mkMergeTopLevel (...).systemd);
    }
    ```

    # Planet status: h4xed
  3. udf revised this gist Nov 26, 2021. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion write_up.md
    Original file line number Diff line number Diff line change
    @@ -163,7 +163,7 @@ lib.attrsets.foldAttrs
    So all we need to do is wrap `foldAttrs` in a `mapAttrs` so we can put each list through `mkMerge`:
    ```nix
    {
    mkMergeTopLevel = names: attrs: (
    mkMergeTopLevel = attrs: (
    mapAttrs (k: v: mkMerge v) (foldAttrs (n: a: [n] ++ a) [] attrs)
    );
    }
  4. udf revised this gist Nov 26, 2021. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion write_up.md
    Original file line number Diff line number Diff line change
    @@ -223,4 +223,4 @@ in
    And it works! I don't really get exactly why explicitly pulling out the options that we want avoids the infinite recursion.

    # Conclusion
    Obviously for this trivial example, putting the merge at the `config.systemd` level makes more sense, but for a more complex module
    Obviously for this trivial example, putting the merge at the `config.systemd` level makes more sense, but for [a more complex module](https://github.com/udf/nix-hanzo/blob/5b52e5c4fe42ccc36b6007e77e5546026ecb5357/modules/vpn-containers.nix#L49) it definitely helps with readability.
  5. udf created this gist Nov 26, 2021.
    226 changes: 226 additions & 0 deletions write_up.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,226 @@
    # The Setup
    I wanted to write a module that generates multiple systemd services and timers to scrub some zfs pools at certain intervals.
    The [default scrub config](https://github.com/NixOS/nixpkgs/blob/fc9ee3bc0b9e6d30f76f4b37084711ae03114e21/nixos/modules/tasks/filesystems/zfs.nix#L304) does not support individual scrub intervals for each pool.

    I want the config to look like this:

    ```nix
    {
    services.zfs-auto-scrub = {
    tank = "Sat *-*-* 00:00:00";
    backups = "Sat *-*-* 06:00:00";
    };
    }
    ```

    So let's define the basic structure of our module:
    ```nix
    { config, lib, pkgs, ... }:
    with lib;
    let
    cfg = config.services.zfs-auto-scrub;
    in
    {
    options.services.zfs-auto-scrub = mkOption {
    description = "Set of pools to scrub, to when to scrub them";
    type = types.attrsOf types.str;
    default = {};
    };
    confg = {}; # TODO: implement
    }
    ```

    Side note: I don't bother with an enable option for my own modules, because I comment out the import in my main config to disable a module, but feel free to add it if you're following along.

    So far pretty normal, let's use `mapAttrs'` to generate the unit and timer for each pool:
    ```nix
    {
    # ...
    config.systemd.services = (
    mapAttrs'
    (name: interval: nameValuePair "zfs-scrub-${name}" {
    description = "ZFS scrub for pool ${name}";
    after = [ "zfs-import.target" ];
    serviceConfig = {
    Type = "oneshot";
    };
    script = "${config.boot.zfs.package}/bin/zpool scrub ${name}";
    })
    cfg
    );
    config.systemd.timers = (
    mapAttrs'
    (name: interval: nameValuePair "zfs-scrub-${name}" {
    wantedBy = [ "timers.target" ];
    after = [ "multi-user.target" ];
    timerConfig = {
    OnCalendar = interval;
    Persistent = "yes";
    };
    })
    cfg
    );
    }
    ````
    Well, that's not so bad for this simple example, but I'm sure you can see how repetitive it gets to have to `mapAttrs'` for every key that you want to generate.
    # Merge all the keys!
    Enter [mkMerge](https://nixos.org/manual/nixos/stable/#sec-option-definitions-merging), it takes a list of options definitions and merges them.
    So we should be able to generate the units and timers individually and merge them into one at the top, right?
    ```nix
    {
    # ...
    config = mkMerge (mapAttrsToList (
    name: interval: {
    systemd.services."zfs-scrub-${name}" = {
    description = "ZFS scrub for pool ${name}";
    after = [ "zfs-import.target" ];
    serviceConfig = {
    Type = "oneshot";
    };
    script = "${config.boot.zfs.package}/bin/zpool scrub ${name}";
    };
    systemd.timers."zfs-scrub-${name}" = {
    wantedBy = [ "timers.target" ];
    after = [ "multi-user.target" ];
    timerConfig = {
    OnCalendar = interval;
    Persistent = "yes";
    };
    };
    }
    ) cfg);
    }
    ```

    Right?

    ```
    building Nix...
    error: infinite recursion encountered, at /nix/var/nix/profiles/per-user/root/channels/nixos/lib/modules.nix:131:21
    (use '--show-trace' to show detailed location information)
    ```

    Guess not.

    # jk... unless 😳

    There is [a quick workaround for this case](
    https://discourse.nixos.org/t/infinite-recursion-in-module-with-mkmerge/10989/6), since we're only generating `systemd.*` options we can put our merge at the `config.systemd` level:

    ```nix
    {
    # ...
    config.systemd = mkMerge (mapAttrsToList (
    name: interval: {
    services."zfs-scrub-${name}" = {
    # ...
    };
    timers."zfs-scrub-${name}" = {
    # ...
    };
    }
    ) cfg);
    }
    ```
    (repeated options omitted for brevity)

    While this works, it's really only fine for simple modules that generate options under one top level key. For example let's say we wanted to generate some users as well (doesn't fit with the example, but bare with me). If we add another option like `config.users = mkMerge ...`? Then we're back to square one.

    # Hack the planet
    What if we were to put the `mkMerge`'s one level lower? Essentially we would want to turn a list of options like:
    ```nix
    [ { a = 1; } { a = 2; b = 3; } ]
    ```
    into
    ```nix
    { a = mkMerge [ 1 2 ]; b = mkMerge [ 3 ]; }
    ```
    (imagine the integers as real options).

    It seems like a complicated problem, but there's a function in lib that solves the whole thing for us, [foldAttrs](https://nixos.org/manual/nixpkgs/stable/#function-library-lib.attrsets.foldAttrs).
    The example looks exactly like our problem!
    ```nix
    lib.attrsets.foldAttrs
    (n: a: [n] ++ a) []
    [
    { a = 2; b = 7; }
    { a = 3; }
    { b = 6; }
    ]
    => { a = [ 2 3 ]; b = [ 7 6 ]; }
    ```

    So all we need to do is wrap `foldAttrs` in a `mapAttrs` so we can put each list through `mkMerge`:
    ```nix
    {
    mkMergeTopLevel = names: attrs: (
    mapAttrs (k: v: mkMerge v) (foldAttrs (n: a: [n] ++ a) [] attrs)
    );
    }
    ```

    Let's slap that into our module:
    ```nix
    let
    # ...
    mkMergeTopLevel = attrs: (
    mapAttrs (k: v: mkMerge v) (foldAttrs (n: a: [n] ++ a) [] attrs)
    );
    in
    {
    # ...
    config = mkMergeTopLevel (mapAttrsToList (
    name: interval: {
    systemd.services."zfs-scrub-${name}" = {
    # ...
    };
    systemd.timers."zfs-scrub-${name}" = {
    # ...
    };
    }
    ) cfg);
    }
    ```
    And...
    ```
    building Nix...
    error: infinite recursion encountered
    ```
    (i cri)

    What gives? if we take one key from the output of our function and assign it to the module, then it works fine:
    ```nix
    config.systemd = (mkMergeTopLevel (...).systemd);
    ```

    # Planet status: h4xed
    So clearly what we're doing is legal, so lets explicitly pull out the option(s) that we want using `getAttrs`:
    ```nix
    let
    mkMergeTopLevel = names: attrs: getAttrs names (
    mapAttrs (k: v: mkMerge v) (foldAttrs (n: a: [n] ++ a) [] attrs)
    );
    in
    {
    # ...
    config = mkMergeTopLevel ["systemd"] (...);
    }
    ```

    And it works! I don't really get exactly why explicitly pulling out the options that we want avoids the infinite recursion.

    # Conclusion
    Obviously for this trivial example, putting the merge at the `config.systemd` level makes more sense, but for a more complex module