I love this. I recently switched from flakes to npins and this helps a lot.
Hi Luc, your website is not loading for me.
I think the settings where this makes sense is CI - a use-case where this can be combined with any dependency manager you prefer.
On the original article, one question I tend to find missing from these discussions is when someone should want to reproduce published software in the first place.
If, for one, one is willing to test out newer nixpkgs versions while an upstream library had not maintained this as well, I believe it is entirely reasonable not to settle for the stale-ish upstream.
Hey, Iād like to make sure I understand this issue. Even though I prefer using flakes, I appreciate reading and understanding the good-faith arguments from people who arenāt just here to rant!
My understanding is that flakes prevent unpinned inputs in dependencies by evaluating Nix expressions in pure evaluation mode, where fetchTarball fails if a content hash is not specified. We can evaluate non-flake expressions in pure mode by either using the --pure-eval flag, or by enabling the pure-eval Nix setting. But youāre saying thereās something more, or something different required to get a generalized mechanism for reproducible evaluation?
(I read your other post about the definition of āreproducibleā. I get the frustration with imprecise use of words. But the evaluation part is reproducible, right? Derivations from pure-eval expressions are bit-by-bit identical, even if store paths arenāt necessarily?)
What might that generalized mechanism look like? Is it something different from pure mode? Or is it some systematic assurance that dependencies will work in pure mode?
Iāve seen a suggestion for pure-eval by default. If pure mode becomes the default assumption then itās more likely dependencies will work in pure mode, and less likely theyāll stop working randomly in some future update. There would be backward-compatibility pain, but people could use the --no-pure-eval flag where needed.
Or what about some way for an expression to signal that it requires pure mode? Like a special syntax? Or an alternate entrypoint expression convention, like pure-default.nix instead of default.nix? Or some less awful name, like mod.nix?
Flakes were able to avoid the backward compatibility problem by using the alternate entrypoint strategy via flake.nix, sort of creating an alternate ecosystem that is known to be pure. But of course using the flake entrypoint requires adopting all of the flake features. I donāt see how thatās a misdesign, but I do see how it doesnāt do what you want.
Thank you for the good faith questions!
(Not replying in post order.)
Basically right, and even without pure-eval, it can be a Reproducible āBuildā, of the .drv itself that is.
From my previous reply:
Though Nix build environments can be [reproducible]. The Nix Expression Language is how you traditionally compute those environments. The Nix Expression Language and its evaluation can use āreproducible buildsā concepts to produce a repeatable build environment i.e. producing the same
.drv. And just as with Reproducible Builds, this can be achieved when the same inputs are either specified or accounted for.
I believe that this is what youāre asking about, right? The environment descriptions can be reproducible. The outputs used coming from those environments cannot be guaranteed to be by Nix. For example a dependency that would put the current date in the binary would not be a āReproducibly Builtā input. So the environment description for it can be reproduced faithfully, the environmentās inputs are as faithfully reproducible as the built outputs can be.
Though thatās kinda out of scope for Nix, and it is not a problem. Itās only an important distinction to make and be transparent about: highlighting where the reproducibility/repeatability boundaries exist.
I donāt recall exactly which other issues there were, and if they are still problematic, but the main one is that there is no way to āproperlyā handle what Iām calling the source self-reference. This is because access to paths is basically forbidden for any practical purpose.
Since I donāt recall all the problems, letās work through it together. Itāll take a short while, sorry.
Alright, letās make a Nix expression.
.../project $ cat > default.nix <<EOF
> {
hello = "world!";
}
EOF
.../project $ nix-instantiate --eval
{ hello = "world!"; }
.../project $ nix-instantiate --option pure-eval true --eval
error: access to absolute path '.../project/default.nix' is forbidden in pure eval mode (use '--impure' to override)
Welp, thatās kind of understandable, after all, pure evaluation forbids access to those paths. Though you might already see the scale of the problems weāre facing: pure evaluation is not exposed in a way where it is useful for external consumers of Nix expressions.
Alright, letās do it like Flakes, letās add the thing to the store.
.../project $ nix-store --add ./.
error: store path 'f4vzb2bfxdgpgws799z7gb2j9v6vbbfs-.' has invalid name '.'
.../project $ drat
drat: command not found
.../project $ nix-store --add $PWD
/nix/store/7in452y7l1g42g2b7yy5k21wdj7r0wvf-project
PAUSE right here⦠This is not appropriate, an impurity slipped in. Which one? the last component of the $PWD! If you look closely at how Flakes do it, and how other parts of the broader Nix ecosystem does it, generally the store path name will be source. This produces a semantically different input.
Alright, letās continue with making it a source path.
.../project $ nix-instantiate --eval --strict --expr '(builtins.path { name = "source"; path = ./.; })' | tr -d '"'
/nix/store/6slp4giv8m2glawm2nmlb8v6qj3s6p6i-source
.../project $ ls -l /nix/store/6slp4giv8m2glawm2nmlb8v6qj3s6p6i-source
ls: cannot access '/nix/store/6slp4giv8m2glawm2nmlb8v6qj3s6p6i-source': No such file or directory
.../project $ nix-instantiate --read-write-mode --eval --strict --expr '(builtins.path { name = "source"; path = ./.; })' | tr -d '"'
/nix/store/6slp4giv8m2glawm2nmlb8v6qj3s6p6i-source
.../project $ ls -l /nix/store/6slp4giv8m2glawm2nmlb8v6qj3s6p6i-source
total 4
-r--r--r-- 3 root root 24 Dec 31 1969 default.nix
Unwieldy alreadyā¦
.../project $ nix-instantiate --eval
{ hello = "world!"; }
.../project $ nix-instantiate --eval $(nix-instantiate --read-write-mode --eval --strict --expr '(builtins.path { name = "source"; path = ./.; })' | tr -d '"')
{ hello = "world!"; }
By now, I guess you can see that this is inconvenient⦠but look carefully: weāve still not evaluated that with pure-eval.
.../project $ nix-instantiate --option pure-eval true --eval $(nix-instantiate --read-write-mode --eval --strict --expr '(builtins.path { name = "source"; path = ./.; })' | tr -d '"')
error: access to absolute path '/nix/store/6slp4giv8m2glawm2nmlb8v6qj3s6p6i-source' is forbidden in pure eval mode (use '--impure' to override)
Welp!
Letās pivot to a git repository.
.../project $ git init --initial-branch=purely-projecting
Initialized empty Git repository in .../project/.git/
.../project $ git add default.nix
.../project $ nix-instantiate --eval --expr 'import (builtins.fetchGit ./.)'
fetching git input 'git+file:///.../project'
warning: Git tree '/.../project' is dirty
{ hello = "world!"; }
.../project $ nix-instantiate --option pure-eval true --eval --expr 'import (builtins.fetchGit ./.)'
⦠while calling the 'fetchGit' builtin
at «string»:1:9:
1| import (builtins.fetchGit ./.)
| ^
error: in pure evaluation mode, 'fetchTree' requires a locked input
.../project $ nix-instantiate --option pure-eval true --eval --expr 'import (builtins.fetchGit { url = ./.; rev = "?"; })'
⦠while calling the 'fetchGit' builtin
at «string»:1:9:
1| import (builtins.fetchGit { url = ./.; rev = "?"; })
| ^
error: hash '?' has wrong length for hash type 'sha1'
.../project $ git log
fatal: your current branch 'purely-projecting' does not have any commits yet
With fetchGit, just as with fetchTree, there is no way to use a dirty input with the exposed functionalities of pure-eval. This is a bit awkward to work with, isnāt it?
.../project $ git commit -m'initial'
[purely-projecting (root-commit) cbf580a] initial
1 file changed, 3 insertions(+)
create mode 100644 default.nix
.../project $ nix-instantiate --option pure-eval true --eval --expr '{ rev }: import (builtins.fetchGit { url = ./.; inherit rev; })' --argstr rev "$(git rev-parse HEAD)"
fetching git input 'git+file:///.../project'
{ hello = "world!"; }
We did it! Maybe! Kind of!
See how hard it is to ājustā evaluate code that is already on your system? Code that you might be working on currently! I might be missing an even more obscure trick, but if Iām not, then thereās no way to imitate the āpurenessā of Flakes with your own tooling around existing Nix primitivesā¦
NOTE: This is incomplete, as it would also prevent ātriviallyā getting an archive of a revision of the code to then evaluate after unpacking, as the archive would be lacking a
.gitrepo.
All of those hoops we had to go through makes it so you would have to implement your own tooling that wraps pure evaluation to get a sensible-enough workflow.
And this also highlights the other big issue with the current pure evaluation primitives: there is no way to ensure your collaborators also use pure evaluation while working on things. They would also have to use the tooling to work around the lack of primitives, or else they may ājustā evaluate impurely and cause a bit of back-and-forth when contributing.
As illustrated previously, itās more than a backward-compatibility pain, but a complete paradigm shift to use pure-eval as it exists today. To do as suggested, it would require that pure-eval for standard Nix expressions and CLI be denatured enough that it would lose some or most of its benefits. It would also not help projects opting-in into proper pure-eval (read more of this reply and youāll see what I mean, I thinkā¦)
I donāt know what such generalized mechanism should look like. Though what it could look like is something not unlike builtins.tryEval, i.e. taking an arbitrary value and whatever is involved in computing the value gets evaluated within the confines of pure evaluation.
# pure.nix
# Backward compatibility boilerplate
(builtins.pureEvaluationWith or (
{ paths ? {} }: code:
code paths
))
{
# Paths that pure evaluation would "allow" for you.
paths = {
self = ./.;
};
}
# Function taking the paths as an input.
# They could be transformed, lazy-tree'd, whatever.
({ self }:
# Trivial example
import (self + "/default.nix")
)
.../project $ nix-instantiate --eval ./pure.nix
{ hello = "world!"; }
This is not necessarily good, but this is one quick way I came up with, to show that this could be exposed in a backward-compatible way. This would allow Nix expression producers (e.g. authors, like you and me) to opt-in into pure evaluation.
This could also enable basically the same guarantees that ācannot be opted-outā in evaluation determinism.
It could even allow more granular opting-out, for example āsmugglingā-in values, like re-exposing builtins.currentSystem in a managed way. That is, in a way where any ātop of a pure evaluationā needs to then pass the value downward.
# `builtins.currentSystem` is allowed as long as this
# is not also imported in a `pureEvaluationWith` context.
# Consumers of the expression would need to provide `system` themselves.
{ system ? builtins.currentSystem }:
# Backward compatibility shim
(builtins.pureEvaluationWith or (
{ paths ? {}, args ? {} }: code:
code (paths // args)
))
{
# "Impure" arguments can be smuggled only through here.
args = {
inherit system;
};
}
# The pure evaluation "context"
({ system }:
# builtins.currentSystem is now forbidden here.
# So we use the smuggled-in value instead:
"This is a ${system} system, I know this!"
)
.../project $ nix-instantiate --eval --expr 'import ./system.nix { }'
"This is a x86_64-linux system, I know this!"
.../project $ nix-instantiate --option pure-eval true --eval --expr "$(cat system.nix)" --argstr system foobar
"This is a foobar system, I know this!"
What matters here is that the expression could still be used within a pure evaluation with import (builtins.fetchTarball { ... }) { system = "..."; }, while still doing the āright thingā with the currentSystem input by default when itās at the top of the evaluation.
About this whole idea, I donāt intend to imply that end-users would all use the builtins.pureEvaluationWith construct. Instead, I would expect that their preferred tooling would make use of it, or that the ābiggerā projects that they consume would use it when needed.
This wouldnāt prevent āpurelyā using an āimpureā dependency in your project! As long as you pin the input, you can fetch that project that still isnāt using builtins.pureEvaluationWith, and as long as it isnāt using the few forbidden patterns, it would transitively be āpureā (for all that matters). Just like how you can import a non-Flake Nix project in your Flake!
Note that Iām not a Nix (the tooling and language) developer. I donāt know what subtleties I could be missing with this freshly thought-up scenario. What matters is that it needs to enable any expression to opt-in, so that needs to happen somehow within the Nix expressions, and canāt be an option or a CLI flag.
What Iām pretty sure about is that it probably shouldnāt be a form of pragma or magic comment. Those are scary to consider, as they change the semantics of what the Nix evaluation logic does. With this simple boilerplate to smuggle paths and arguments, the contract is simple, and more importantly: can be shimmed by author-end-users!
The misdesign is, first of all an opinion, but itās not Flakes, but how Flakes were brought into the codebase.
Having another entrypoint for this new declarative interface for inputs is good. And this could have been implemented using generic primitives, and those primitives would have elevated the whole ecosystem all at once, rather than blocked on Flakes.
Hopefully these illustrated examples help understand where I stand about the pure evaluation problems. Sorry(?) for monopolizing your time!
@samueldr Ok yes, that does highlight the difficulties. Thanks for explaining!
Put flake.nix in a subdirectory and refer to it via path: e.g. nix develop path:./nix -c $SHELL. In this case you still get all the tooling and you only copy to the store content in ./nix.
That only works if the only thing you use nix for is devshells. Lazy trees also actually solve the problem in exactly that scenario, so if that feature ever lands you wonāt even need that hack.
It is a useful pattern, though, you can also use it to hide dev dependencies from dependant flakes.
Donāt misunderstand, I personally think the flakes concept is salvageable, and I actually do believe it to be useful. I just think it needs to be redesigned from scratch to be ergonomically usable, rather than yet another pile of footguns, and none of that has happened in the 6 years the feature has been around for.
āWhat they solveā - nada
In the first link @roberth describes (as I understand it) the problem with premature stabilization. I agree: declaring Flakes officially stable right now risks permanently locking in undesirable behaviors that are actually just bugs.
How does that mean Flakes ādonāt solve anythingā?
Isnāt the goal to fix those bugs and get closer to a stable version?
Admittedly, Iād not expect one to arrive at the same or similar conclusions from any one of these links, but from a non-trivial combination⦠My short answer is: flakes should have been tackling DI, and they explicitly donāt. Wonāt. There are diverging opinions on whether bugs are fixable too.
On this note, I was happy to read about the motivations about the āunflakeā project announced here the other day, although I didnāt get around to tinkering with yet Unflake: flake dependencies for non-flake projects (and a way to stop writing .follows)