How to correctly implement release-flexible NixOS Modules?

Referencing: Making NiXium release-flexible + PAC + Remanagement of tasks by Kreyren · Pull Request #124 · NiXium-org/NiXium · GitHub

Where i am trying to make a quick and painless way to change NixOS releases per each system, for that i’ve declared the invidual systems into a nixosModules and placed the nixosConfigurations into their own separate directory.

So this way i can have ready-to-use configurations like nixos-sinnenfreude-RELEASE and have them quickly applied through the deployment task that suffixes -install.

This works really well and allows me to test various contributions in practice on a physical hardware as needs be, but the issue is that none seems to know a sane way to implement the same treatment for the invidual modules.

To the issue at hand i tried to implement a module that handles the configuration of hardware acceleration for the sinnenfreude system where my initial idea was:

{
	"24.05" = {
		hardware.opengl = {
			enable = true;
			driSupport = true;
			driSupport32Bit = true;
		};
	};

	_ = {
		hardware.opengl = {
			enable = true;
			driSupport32Bit = true;
		};
	};
}."${config.system.nixos.release}"

Which is the projected ideal implementation as the case statement seems to make the implementation very easy to read and manage, but since it’s using config which it’s sourcing it causes infinite recursion… One method to manage this was to trick the nix interpreter by using mkIf as:

let
	inherit (lib) mkMerge mkIf;
in {
	config = mkMerge [
		(mkIf (config.system.nixos.release == "24.05") {
			hardware.opengl = {
				enable = true;
				driSupport = true;
				driSupport32Bit = true;
			};
		})

		(mkIf (config.system.nixos.release == "24.11") {
			hardware.graphics.enable = true;
			hardware.graphics.enable32Bit = true;
		})
	];
}

Which is harder to manage and it kinda causes more issues as on 24.05 it evaluates the 24.11 body of the if statement and fails because the option does not exists and vice-versa…

Question: Is there a sane and functional way to make release-flexible NixOS modules? I don’t want to manage this through making git branches as that makes the setup less functional – as separating branches inherently causes bitrotting as the older release implementation wouldn’t be getting updates due to added complexity to the maintainability where in this configuration i could just easily declare which options are to be always applied across all releases and global switches to handle the workflow.

Thanks for anything relevant! <3

You usually get pretty far with a simple or on the new option name. eg

isGraphical = config.services.xserver.enable || (config.services.displayManager.defaultSession or config.services.xserver.displayManager.defaultSession) != null;

I want to avoid declaring my own module options as it adds a huge amount of complexity that has to be constantly managed or how do you propose this should be integrated?

(note projected ideal implementation)

Couple of ideas:

  • declare separate directory structures - hence separate entrypoints - same as you mentioned earlier
  • set some specialArgs value to feed into stateVersion and your conditionals, which will sidestep the config infrec

That’s just a boolean which you put in a let in. isGraphical is not actually used for an option but the construct is similar. hasExtraConfig over at nix-user-module/audio.nix at master - nix-user-module - Gitea: with a cup of Mate is actually used to differentiate between options on 24.05 and unstable.

stateVersion is decoupled from the version of NixOS you use. You could have stateVersion 18.09 and use nixos 24.05.

I want to basically make one module e.g. hardware-acceleration that will be responsible of handling hardware acceleration across multiple releases… doing that through multiple directories is major complexity in comparison to just having a file with case statement.

:eyes:

awww i got so hopeful… :thinking: … feed that into system.nixos.release ?

so iiuc you are proposing to declare e.g. hardware.opengl.enable and do the configuration in a form of or conditionals? that seems like i would became unmaintainable very quickly considering scenario like hardware.opengl.enable changing on hardware.graphics.enable

True I forgot. I’m just used to bumping it.

After like 5h of work i figured out lot of opions, but this one seemed like the most note-worthy:

let
	inherit (lib) mkIf;
in {
	"24.05" = {
		hardware.opengl = {
			enable = true;
			driSupport = true;
			driSupport32Bit = true;
		};
	};

	_ = {
		# hardware.graphics.enable = true;

		hardware.graphics.enable = (if builtins.hasAttr "hardwareGraphicsEnable" options.hardware.graphics.enable
			then { hardwareGraphicsEnable = true; }
			else {});

		# hardware.graphics.enable32Bit = true;
	};

It adds a lot of unwanted complexity, but maybe doing a wrapper that could do similar thing assuming that i just need to set the option so that nix doesn’t fail on it not being defined? Kinda running out of ideas tbh

A couple of patterns I have used in the past:

  • hardware.${if versionAtLeast release "24.11" then "graphics" else "opengl"} = ...
  • hardware = if ... then { graphics = ...; } else { opengl = ...; }
  • Stub away new options using mkSinkUndeclaredOptions
  • Use mkMerge + optionalAttrs rather than mkIf as it doesn’t check the options when the condition is false.

Don’t do that. You might loose data because of that. Programs like nextcloud or postgres might just upgrade and data might still be laying in the wrong directory.

1 Like

I don’t think that works for this usecase as other modules will get more complex than just using a single option e.g.

{ lib, nixosRelease, ... }:

# Sound management of SINNENFREUDE

let
	inherit (lib) mkIf;
in {
	"24.05" = {
		sound.enable = true;

		hardware.pulseaudio.enable = false;

		services.pipewire = {
			enable = true;
			alsa.enable = true;
			alsa.support32Bit = true;
			pulse.enable = true;
		};
	};

	"24.11" = {
		services.pipewire = {
			enable = true;
			alsa.enable = true;
			alsa.support32Bit = true;
			pulse.enable = true;
		};
	};

	_ = {
		# Should always be applied for all releases
		security.rtkit.enable = true;
	};
}."${nixosRelease}"

That seem like it would add a lot of maintenance overhead, but potentially worst case option atm assuming that it would be declared as flake/machines/work.nix at 13c3bd8573543493df392bc21630960029c921d2 · ivi-vink/flake · GitHub

elaborate?

It always depends on what the issue at hand is. It’s often the case that some options simply get renamed and this pattern is a simple solution to those cases.

Also note that, in this example, you could also simply guard the other options behind the version check.

i.e.

{
  config = mkMerge [
    (lib.mkIf (!is2411) { # or lib.optionalAttrs if the options don't exist in the negative case
      sound.enable = true;

      hardware.pulseaudio.enable = false;
    })

    {
      services.pipewire = {
        enable = true;
        alsa.enable = true;
        alsa.support32Bit = true;
        pulse.enable = true;
      };
    }
  ];
}

Well, any of this requires maintenance. You just have to use the lowest maintenance option available.

In the case of options that don’t exist yet where you just simply don’t care about setting it, mkSinkUndeclaredOptions can be the simplest option.

For instance, programs.steam.extraCompatPackages was added in 24.05. In order to make my config compatible, I simply sunk the option for systems using <24.05 where I didn’t care about it being set.

The reason why guards behind mkIf fail to eval when the options used don’t exist is that this is intentional: mkIf checks all options used, even if the condition is false in order to catch nasty bugs that depend on some condition.

If you know that you will intentionally use an option that would not pass the check on an older/newer version of NixOS and therefore guard it behind a version check that is never true when the option does not exist, NixOS does not know about this fact and will throw an error regardless. You can side-step this checking by making the attrs disappear entirely when the condition is false.

This will fail:

{
  services.foo = mkIf (versionOlder ...) {
    oldOption = true;
  };
}

This works:

{
  services.foo = optionalAttrs (versionOlder ...) {
    oldOption = true;
  };
}

Ehh… so from informations from @Atemu i read through the nix docs and this is what i figured out:

{
	"24.05" = {
		hardware.opengl = {
			enable = true;
			driSupport = true;
			driSupport32Bit = true;
		};
	};

	"24.11pre-git" = {
		hardware.graphics.enable = true;
		hardware.graphics.enable32Bit = true;
	};

	"24.11" = {
		hardware.graphics.enable = true;
		hardware.graphics.enable32Bit = true;
	};

	_ = {
		hardware.opengl = {
			enable = true;
			driSupport32Bit = true;
		};
	};
}."${lib.trivial.release}"

Which seems to be the projected ideal solution that manages the mentioned issues as using lib.trivial.release avoids the use of config that causes infinite recursion and gets me the desired string for comparison and doesn’t seem to evaluate the contents of the case bodies that do not match. :dark_sunglasses:

Thanks everyone for help! <3

1 Like