optionalAttrs in module -> infinite recursion with config

I have a little module that enables my remote builder, which I’ve used on a few NixOS machines to allow building on my relatively beefy server.

Today I decided to set up my M1 Mac to allow remote builds on this machine so I could hopefully have an easier time running x86_64-linux-based tests on various PRs. (I’m setting up to use nixpkgs#darwin.builder as well.) programs.ssh doesn’t exist on nix-darwin, so I need to adapt my module to only include this part of the configuration on Linux hosts.

Unfortunately I’m again running into the dreaded “infinite recursion with config” issues, of which there are many threads and SO posts.

I thought I was getting pretty good at this problem, but none of my usual tricks are working:

  • I initially tried inherit (pkgs.stdenv) isLinux, and when it didn’t work read several threads about pkgs depending on config, so obviously I can’t change use pkgs while defining config.
  • A common suggestion (at least for the pkgs.stdenv.isLinux issues in home-manager) is making isLinux ? false a passed in argument from the parent config, but I still get infinite recursion (or attribute missing if I change optionalAttrs to mkIf)
  • As a test, I tried removing the ${config.age.secrets... line, but still get infinite recursion
  • I really thought it would work if I just made it a configuration option, which is the version I’m including below, but I still get infinite recursion.
  • Also tried swapping out optionalAttrs for mkIf, which then gets me back to error: The option 'programs.ssh.extraConfig' does not exist. (even if I configure as false and set the default to false)

I’m sure there’s some fundamental concept I’m missing here. Why am I unable to configure this module based on the host OS?

{
  pkgs,
  config,
  lib,
  ...
}: let
  service_name = "use_builder";
  cfg = config.services.${service_name};
in
  with lib; {
    options.services.${service_name} = {
      enable = mkEnableOption service_name;
      isLinux = mkOption {
        type = types.bool;
        description = "Is this a Linux machine?";
        default = true;
      };
    };

    config = mkIf cfg.enable (mkMerge [
      {
        age.secrets.builder_id_ed25519.file = ../secrets/builder_id_ed25519.age;
        nix = {
          buildMachines = [
            {
              hostName = "builder";
              systems = ["aarch64-linux" "x86_64-linux"];
              maxJobs = 12;
              speedFactor = 2;
            }
          ];
          distributedBuilds = true;
        };
      }
      (optionalAttrs cfg.isLinux {
        programs.ssh.extraConfig = ''
          Host builder
            hostName myBuilderMachine.home.arpa
            User builder
            Port 2222
            IdentityFile = ${config.age.secrets.builder_id_ed25519.path}
        '';
      })
    ]);

Happy to include the full trace if helpful, this is the abbreviated version:

      at /nix/store/k90rk91pfcfl5cajsgi5pnvxkwfi77p1-source/lib/modules.nix:483:28:

          482|         builtins.addErrorContext (context name)
          483|           (args.${name} or config._module.args.${name})
             |                            ^
          484|       ) (lib.functionArgs f);

Thanks to this post I was able to find a workaround that doesn’t require figuring out the platform at all:

(optionalAttrs (options?programs.ssh.extraConfig) { ... }

While I’m glad to have it working, I would still like to figure out why I couldn’t get the cfg.isLinux approach to work, and if anyone has any pointers on the use of mkIf vs optionalAttrs (particularly inside mkMerge which seems to be common), I’m all ears.

You definitely want to use mkIf there, as otherwise you have that cfg depends on config which depends on the second module in your mkMerge which depends on cfg. mkIf relaxes that last link by making that second module at least evaluate to { programs = /*something*/; }, so that the module system can determine the value of config.services without needing to look further into that module. See the manual for another attempt at an explanation.

Past that, I’m not sure why your thing doesn’t work. It would help if you could share your entire config.

1 Like

Interesting – while it succeeds with optionalAttrs, it fails to build if I use (mkIf (options?programs.ssh.extraConfig) {

error: The option `programs.ssh.extraConfig' does not exist. Definition values:

Reading the link from the manual (thank you), I presume this is because mkIf pushes things down to programs.ssh.extraConfig = if ...

optionalAttrs cond as is defined as the following Nix expression:

As a result, the module system does not see the attrs when cond evaluates to false.

You got infinite recursion because in order to get the value of config, you would need to evaluate the optionalAttrs but that depends on the value of config. Nix is a lazy language so you might expect it to still work unless you do something like:

(optionalAttrs cfg.isLinux {
  services.use_builder.isLinux = false;
})

However, Nix is strict in names of attributes in attribute set, so even something like the following will trigger an infinite recursion:

let c = (if builtins.isAttrs c then { bar = 2; } else {}) // { foo = 5; }; in c.foo

On the other hand, lib.mkIf is a module system primitive and will preserve the content passed to it in the Nix value and will only resolve it in the module system evaluation:

nix-repl> :p lib.mkIf false { foo = true; }
{ _type = "if"; condition = false; content = { foo = true; }; }

nix-repl> (lib.modules.evalModules {
  modules = [
    { options.foo = lib.mkEnableOption "test"; }
    (lib.mkIf false { foo = true; })
  ];
}).config
{ foo = false; }

The benefit is that the module system is aware of the content argument of mkIf even when the condition is false but the consequence is that the options defined within need to be declared in the module system and can only have valid values set.

4 Likes

Thanks for a thorough explanation @jtojnar!

I think my confusion was because using mkIf cfg.enable in the outer scope was fine, but mkIf cfg.isLinux didn’t work. It now makes sense that the problem was that the attrset keys (specifically programs.ssh.extraConfig in this case) must exist and laziness doesn’t get around that issue when using mkIf. optionalAttrs is a regular function (not a module system primitive) and so it is even less lazy and results in infinite recursion.

In other words, the error: The option 'programs.ssh.extraConfig' does not exist. Definition values: when using mkIf was actually a good sign – one that its laziness was at least letting it get that far.