Why can't I map over options and merge them in the config block?

I’m using terranix (I don’t think this is terranix specific) to create a module that spawns resources. I have an option that is something like:

{config, lib, ...}:
  options = {
      talos.clusters = lib.mkOption {
          default = {};
          type = lib.types.attrsOf (lib.types.submodule {
              options = {
                enable = lib.mkEnableOption "Enable a cluster";

                loadbalancer = lib.mkOption {
                  default = { enable = false; };
                  type = lib.types.submodule {
                      options.enable = lib.mkEnableOption "Enable the loadbalancer";
                  };
                };

                # ... and many, many more options
              };
          });
      };
  };

  config = { /* ... below */ };
}

So I have to iterate over each cluster and pull in lots of options into config blocks that end up looking like this:

  config = lib.mkMerge [
    { 
      resource.digitalocean_loadbalancer =
        let directives = lib.map (cluster: { ... }) (lib.attrValues config.talos.clusters)
        in collapseList directives # a utility function that turns a list of objects into a single object
    }
  ];

This works, but its unwieldy. For one I have to collapse a list myself into a single object, for another thing the clusters get deep, each cluster may have applications with plugins etc and so the mapping function has to deal with everything all at once. Furthermore other blocks often reference each other for dependency management, so I find myself iterating over the same lists over and over for slightly different purposes. I thought it would be really nice to just map over each cluster one at a time and then let nix merge all the blocks back together.

  config = lib.mkMerge (
    lib.map
      (cluster: { resource.digitalocean_loadbalancer = {}; })
      (lib.attrValues config.talos.clusters)
  );

All I have to do is supply a list of objects with the right shape, right? But attempting to iterate over a config option in any way, shape, or form this causes the following error:

      error: infinite recursion encountered
       at /nix/store/m68ikm8045fj7ys7qvgr608z9l70hh1k-source/lib/modules.nix:245:34:
          244|           (regularModules ++ [ internalModule ])
          245|           ({ inherit lib options config specialArgs; } // specialArgs);
             |                                  ^
          246|         in mergeModules prefix (reverseList collected);

I don’t understand why I can’t construct a list of objects for mkMerge to merge back together. It seems like there must be some magic going on in there that I don’t understand.

It’s infrecing because you have config (at the top-level) dependent on something within config. So you have to restructure it to not set the top-level directly.

Why am I allowed to map over it at all? The config already depends on itself in the first example, but doesn’t have an infinite loop.

Edit: I see why. It is because while this config is never going to have “talos.clusters” in its config, the module has to evaluate at least the first level of the config to search for “talos”, just in case it does. If the top layer of the config only contains keys like “resource” then it can evaluate that top level without looking any deeper. But if the base level is itself composed of an expression that could evaluate to having a key it is looking for, it is just infinite recursion.

I do have to restructure this somehow, though the method by which I do that feels a little out of my grasp at the moment.

So the problem is I can’t map over “talos” from the root to generate root level configs “resource”, “output” etc because that file itself may have a talos. If the resources were prefixed with a common prefix I absolutely could map over them. I could in fact map over individual statements like so

  config = {
    resource = lib.map (cluster: digitalocean_loadbalancer = {};) (lib.attrValues config.talos.clusters)
    output = lib.map (cluster: digitalocean_loadbalancer = {};) (lib.attrValues config.talos.clusters)
  }

This also works because again, I’m not on the outer level, but I wanted to do better. My solution is to have a set of options that “wraps” around terranix’s options allowing me to have that top level option and then have as many types of resource, output variable whatever underneath it. To do that I introduce a module that I import into my project called “tnx.nix”

{ config, lib, options, ... }:
{
  options = {
    tnx = lib.mkOption {
      default = {};
      type = lib.types.submodule {
        options = {
          # these are options provided by terranix at the root
          resource = options.resource;
          output = options.output;
        };
      };
    };
  };

  config = {
    # every time we assign our prefixed variants of these options, assign them to terranix's options
    resource = config.tnx.resource;
    output = config.tnx.output;
  };
}

All this does is allow me to specify my config like

  config = {
    tnx = lib.map(cluster: {
      resource.digitalocean_loadbalancer.${cluster.name} = { name = ...; };
      output."cluster-${name}".value = {
        ...
      };
    }) (lib.attrValues config.talos.clusters)
  }

There is no infinite loop on the config because it knows “talos” can’t be specified in this file, but I can completely dynamically create all resources and they will be merged with resources created in other modules, also dynamically in a way that always terminates. I hope this helps someone.