Organizing your Nix configuration without flakes

32 Likes

i like your thoughts on default.nix and providing schema - i had such thoughts myself for a while now

thanks for sharing your article

1 Like

As to why not use import "${nixpkgs}/nixos" , following the channels-utilizing convention of import <nixpkgs/nixos> ? Well, that was what I thought would look nicer as well, but that interface doesn’t allow for setting specialArgs, for some reason; the only arguments it accepts are configuration and modules . I might submit a pull request, but I can imagine there’s also just some reason for this choice that I don’t know.

By the way, there’s an open PR for allowing specialArgs the other way, courtesy of @bme:

And I’d personally use _module.args, where possible (which is settable via modules already).

5 Likes

Love this. Thanks for the write up. I’m playing with the thought of switching from flakes to npins as well for a couple of weeks now. And I also had the idea to using a similar Schema to flakes.

I don’t want to turn this thread into a debate about adding specialArgs to <nixpkgs/nixos> but if you have feelings about it I’m definitely interested in pushing my PR along, so comments in support welcome, or other alternative solutions which allow for npins sources to be used as modules that is as ā€œergonomicā€ as pushing them through special args very welcome.

nixos-rebuild -f . -A blah is what we can move to today, and is significantly better than the ā€œchannelā€ status-quo for non-flakes usage. Solving this ux wart (nice way to gather inputs flakes style without running into infrec problems) is an important stepping stone before trying to promote it in manuals / getting started materials.

6 Likes

I thought _module.args was a bad code smell, I guess. But honestly last time I wrote code using it was a while ago, so I might be misremembering things.

I really like the direction this is going and would love to have better conventions for default.nix. Another thing Flakes provide is pure evaluation. If I understand this correctly it just forbids certain things like network or environment variable access, so things like builtins.fetchurl without a hash do not work. Wouldn’t an alternative be to lint the code for ā€œimpureā€ behavior?

It’s the opposite: specialArgs is the smell, if the arg isn’t needed in imports etc.

2 Likes

Enable the pure-eval option in nix.

4 Likes

I’m using nix-darwin and I wrote my own script for rebuilding the system to support pure evaluation (which I ended up not using as I don’t wanna copy my configuration to store on each rebuild).

It might need some adjustment for nixos but still might be of some help in understanding pure evaluation without flakes:

I also prefer taking system and other overridables as arguments as it allows for super easy cross compilation by just passing an instance of nixpkgs with crossSystem set (as opposed to flake-like schema where I don’t know how you’re supposed to do cross compilation properly):

1 Like

This resonates extremely well with me, thank you for sharing your excellent write-up on this topic!

I’ve just been through a similar journey: as a long-term classic Nix user, I’ve recently worked with flakes extensively in a project, and what good things it brings to the table are (1) standardisation of the entry-point, and (2) pinning. As you’ve mentioned, there are substantial downsides being brought in as well (for me personally), and so I am wanting to stick to classic Nix.

I’ve actually started working on a flake-like configuration system in classic Nix that mimics (and extends, albeit in opinionated ways for my own personal use) the excellent flake-parts library.

I am pretty sure you could just use flake-parts for your entry point in a classic Nix project, as most (all?) code is actually flakes-agnostic.

1 Like

What about evaluation caching? Does classic Nix support evaluation caching?

Makes sense… that said, I played with using _module.args when I was coming up with this structure, and I ended up using it in specialArgs primarily because I really wanted to be able to do Flake style module importing (imports = [ self.nixosModules.my-cool-module ];). Propagating the value using _modules.args = { inherit sources self; } would be nice, but then it wouldn’t be able to be used in imports.

I should probably add a point about this in the post.

It’d be nice if just using nixos-rebuild --option pure-eval true -f . -A nixosConfigurations.<host> did the trick, but then there’s the problem of needing to copy the project tree into the Nix store before attempting to evaluate anything from it; Flakes does this automatically. (turns out there’s an issue somewhat tangentially referring to this too: Practical pure eval for paths in non-Flake CLIs Ā· Issue #9329 Ā· NixOS/nix Ā· GitHub)

The eval-cache is just a Flakes thing as far as I know. To be honest, I’m not sure why, technically speaking, evaluation caching has to be limited to the Flakes model though. Probably something to do with the pure evaluation requirement?

No, but flakes also really only support it in very limited cases and I think you don’t hit those cases most of the time

5 Likes

I really like the writeup.

I feel like a missing tool is something like nix flake show, that could walk a default.nix and report any nonstandard attributes (kinda like a linter). It’d take a stance on what it thinks is the ā€œrightā€ way to lay things out, but people could iterate on tools like this to see what works well.

Unless there’s a technical reason that this needs to be part of the nix evaluation engine? (meaning less opportunity for rapid iteration, and less room for competing tools)

1 Like

(Sorry @WeetHet, I accidentally replied to your message, this response is to the OP itself)

I really love this idea! I know your approach here is to skip flakes entirely, but I’m wondering if something like this would maybe be a possible path forward for flakes themselves?

Looking at the way you layed out your default.nix, it seems very possible to specify a ā€œflakes 2.0ā€ schema and implement functionality in nix that enables (nearly) all the features flakes currently have with a more function-like syntax.

A ā€œflake 2.0ā€ could look something like this:

{
  self ? (import ./. {}),
  sources ? (import ./flake.lock),
  system,

  nixpkgs ? sources.nixpkgs { localSystem = system; },
  nix-darwin ? sources.nix-darwin { nixpkgs = nixpkgs; },
  home-manager ? sources.home-manager { nixpkgs = nixpkgs; },
  ...
}@inputs:
flake {
  flakeSchemaVersion = 2;

  nixosConfigurations.ilo = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      specialArgs = {
        inherit nixpkgs self sources;
      };
      modules = [ ./nixos/ilo/configuration.nix ];
  }

  nixosModules.my-cool-module = import ./modules/nixos/my-cool-module;

  packages = let
    callPackage = nixpkgs.lib.callPackage;
  in
  {
    package1 = callPackage ./packages/package1 {};
    package2 = callPackage ./packages/package2 {};
  };

  nixConfig.bash-prompt = "test> ";

}

flake would be a new builtin (like derivation) that would read flakeSchemaVersion and, based on the version number, enforce that the attrset matches the schema. If it doesn’t, evaluation is aborted with an error. If it does, flake would also add _type = "flake"; to the resulting attrset to allow special-casing flakes during evaluation, for example to allow the "${self}/folder/file"-pattern you outlined and to print them like Ā«flake /nix/store/15p0ac1xa3mrxngm4vvqppiy6qd8a86l-sourceĀ». It could also add inherit inputs; or other things, whatever is required to make this ergonomic both for the user and the evaluator.

The only basic requirement for a flake.nix file would be that it MUST contain a function that, if called with just the system argument in pure evaluation mode, produces a flake. All other requirements would depend on the flakeSchemaVersion, though it’s probably best to stay close to what we currently have. The evaluation itself would run just like for any nix file including lazy evaluation, so inputs aren’t downloaded when that isn’t needed.

import ./flake.lock would probably have to be handled specially because as I understand, it’s somewhat undesirable to make the lock file a nix expression itself, but this would be a completely separate and optional mechanism. If you wanted to use npins or niv, you absolutely could, nix would just provide a default implementation of version locking for a good out-of-the-box UX.

One thing that’s absent here is the per-system-attributes, so you don’t have packages.x86_64-linux.package1, just packages.package1. This ā€œbreaksā€ easy enumeration of platforms per package the way flakes do it right now, but I don’t think that’s an issue: The flakes schema could just specify that attributes of packages need to contain a meta.platforms attribute that is a list of strings, like all packages in nixpkgs already do. This makes cross-compilation in flakes much easier to achieve and allows you to try to compile a package even on other platforms without modifying the flake’s source code. Of course, this only works because there is now a system input, which has been thoroughly discussed in Make handling system parameter more ergonomic Ā· Issue #3843 Ā· NixOS/nix Ā· GitHub already. Another way to view this is that a flake is not the function, but the attrset produced by the function. In this model, the flake itself is pure.

Nothing needs to change about flake-urls and the concept of installables. They are a useful feature and the canonical address of a flake.

Flakes being plain old functions would also make them much easier to explain and understand because the entire mechanism is out in the open, they could be evaluated with nix eval, played around with on the repl, become easier to compose, and using --arg and --argstr could be trivially implemented to work for flakes as well, solving Allow plain Nix expressions as flake inputs Ā· Issue #5663 Ā· NixOS/nix Ā· GitHub, though I do understand that not everyone thinks this is a good idea.

Meanwhile, things like nix flake show, nix flake check, nix flake update and nix flake lock could continue to work as they already are, the change from flakes being an attrset to being a function would allow both kinds of flakes to co-exist for a while despite the attrset schema not being versioned, because the evaluator can detect the type of the expression easily, and the addition of a schema version addresses one of the biggest pain points of backwards- and forwards-compatibility. Separating the locking mechanism from the flake schema version and being able to iterate on them separately might also make it possible to address some of the abstraction issues that flakes currently have.

6 Likes

PR for specialArgs is now merged, so I guess in 25.11 this will get a bit neater.

1 Like