diff --git a/content/posts/pinning-nixos-with-npins.md b/content/posts/pinning-nixos-with-npins.md new file mode 100644 index 0000000..82f8537 --- /dev/null +++ b/content/posts/pinning-nixos-with-npins.md @@ -0,0 +1,368 @@ ++++ +date = "2024-05-20" +draft = false +path = "/blog/pinning-nixos-with-npins" +tags = ["nix"] +title = "Pinning NixOS with npins, or how to kill channels forever without flakes" ++++ + +> Start of Meetup: "hmm, Kane is using nixos channels, that's not good, it's going to gaslight you"
+> 6 hours later: Utterly bamboozled by channels
+> 6.5 hours later: I am no longer using channels + +\- [@riking@social.wxcafe.net](https://social.wxcafe.net/@riking/112465844452065776) + +Nix channels, which, just like Nix, is a name overloaded to mean several +things, are an excellent way to confuse and baffle yourself with a NixOS +configuration by making it depend on uncontrolled and confusing external +variables rather than being self-contained. You can see [an excellent +explanation of the overloaded meanings of "channels" at samueldr's +blog][samueldr-channels]. In this post I am using "channels" to refer to the +`nix-channel` command that many people to manage what `` points to, +and thus control system updates. + +[samueldr-channels]: https://samuel.dionne-riel.com/blog/2024/05/07/its-not-flakes-vs-channels.html + +It is a poorly guarded secret in NixOS that `nixos-rebuild` is simply a bad +shell script; you can [read the sources here][nixos-rebuild]. I would even go +so far as to argue that it's a bad shell script that is a primary contributor +to flakes gaining prominence, since its UX on flakes is so much better: flakes +don't have the `/etc/nixos` permissions problems *or* the pains around pinning +that exist in the default non-flakes `nixos-rebuild` experience. We rather owe +it to our users to produce a better build tool, though, because `nixos-rebuild` +is *awful*, and there are currently the beginnings of efforts in that direction +by people including samueldr; `colmena` is also an example of a better build +tool. + +Both the permissions issue and the pinning are extremely solvable problems +though, which is the subject of this post. [Flakes have their +flaws][samueldr-flakes] and, more to the point, plenty of people just don't +want to learn them yet, and nobody has yet met people where they are at with +respect to making this simplification *without* doing it with flakes. + +This is ok! Let's use something more understandable that does the pinning part +of flakes and not worry about the other parts. + +[samueldr-flakes]: https://samuel.dionne-riel.com/blog/2023/09/06/flakes-is-an-experiment-that-did-too-much-at-once.html + +This blog post teaches you how to move your NixOS configuration into a repo +wherever you want, and eliminate `nix-channel` altogether, instead pinning the +version of `` and NixOS in a file in your repo next to your config. + +[nixos-rebuild]: https://github.com/nixos/nixpkgs/blob/b5c90bbeb36af876501e1f4654713d1e75e6f972/pkgs/os-specific/linux/nixos-rebuild/nixos-rebuild.sh + +# Background: what NixOS builds actually do + +First, let's say how NixOS builds actually work, skipping over all the remote +build stuff that `nixos-rebuild` also does. + +For non-flakes, `` is evaluated; that is, [`nixos/default.nix`][nixos-defaultnix] in +``. This resolves the `NIX_PATH` entry `` as the first +user-provided NixOS module to evaluate, or alternatively +`/etc/nixos/configuration.nix` if that doesn't exist. For flake configurations, +substitute `yourflake#nixosConfigurations.NAME` in your head in place of +``. + +[nixos-defaultnix]: https://github.com/nixos/nixpkgs/blob/6510ec5acdd465a016e5671ffa99460ef70e6c25/nixos/default.nix + +The default `NIX_PATH` is the following: + +``` +nix-path = $HOME/.nix-defexpr/channels nixpkgs=/nix/var/nix/profiles/per-user/root/channels/nixpkgs /nix/var/nix/profiles/per-user/root/channels +``` + +That is to say, unless it's been changed, `` will reference root's +channels, managed with `nix-channel`. + +Next, the attribute `config.nix.package` of `` is evaluated then +built/downloaded (!!) unless it is a flake config (or `--no-build-nix` or +`--fast` is passed). Then the attribute `config.system.build.nixos-rebuild` is +likewise evaluated and the `nixos-rebuild` is re-executed into the one from the +future configuration instead of the one from the current configuration, unless +`--fast` is passed. + +Once your configuration has been evaluated once or twice pointlessly, it is +evaluated a third time, for the attribute `config.system.build.toplevel`, and +that is built to yield the new system generation. + +This derivation is what becomes `/run/current-system`: it contains a bunch of +symlinks to everything that forms that generation such as the kernel, initrd, +`etc` and `sw` (which is the NixOS equivalent of `/usr`). + +Finally, `the-build-result/bin/switch-to-configuration` is invoked with an +argument `switch`, `dry-activate`, or similar. + +--- + +From this information, one could pretty much write a NixOS build tool: it really is +just `nix build -f '' config.system.build.toplevel` (in old +syntax, `nix-build '' -A config.system.build.toplevel`), then +`result/bin/switch-to-configuration`. That's all it does. + +# Background: what is npins anyway? + +[`npins`][npins] is the spiritual successor to [niv], the venerable Nix pinning +tool many people used before switching to flakes. But what is a pinning tool +for Nix anyway? It's just a tool that finds the latest commit of something, +downloads it, then stores that commit ID and the hash of the code in it in a +machine-readable lock file that you can check in. When evaluating your Nix +expressions, they can use `builtins.fetchTarball` to obtain that exact same +code every time. + +That is to say, a pinning tool lets you avoid having to copy paste Git commit +IDs around, and ultimately does something like this in the end, which hands you +a path in the Nix store with the code at that version. + +```nix +builtins.fetchTarball { + # https://github.com/lix-project/lix/tree/main + url = "https://github.com/lix-project/lix/archive/992c63fc0b485e571714eabe28e956f10e865a89.tar.gz"; + sha256 = "sha256-L1tz9F8JJOrjT0U6tC41aynGcfME3wUubpp32upseJU="; + name = "source"; +}; +``` + +Let's demystefy how pinning tools work by writing a trivial one in a couple of +lines of code. + +First, let's find the latest commit of nixos-unstable with `git ls-remote`: + +``` +~ » git ls-remote https://github.com/nixos/nixpkgs nixos-unstable +4a6b83b05df1a8bd7d99095ec4b4d271f2956b64 refs/heads/nixos-unstable +~ » git ls-remote https://github.com/nixos/nixpkgs nixos-unstable | cut -f1 +4a6b83b05df1a8bd7d99095ec4b4d271f2956b64 +``` + +Then we can construct an archive URL for that commit ID, and fetch it into the +Nix store: + +``` +~ » nix-prefetch-url --name source --unpack https://github.com/nixos/nixpkgs/archive/4a6b83b05df1a8bd7d99095ec4b4d271f2956b64.tar.gz +0zmyrxyrq6l2qjiy4fshjvhza6gvjdq1fn82543wb2li21jmpnpq +``` + +And finally fetch it from a Nix expression: + +``` +~ » nix repl +Lix 2.90.0-lixpre20240517-0d2cc81 +Type :? for help. +nix-repl> nixpkgs = builtins.fetchTarball { url = "https://github.com/nixos/nixpkgs/archive/4a6b83b05df1a8bd7d99095ec4b4d271f2956b64.tar.gz"; name = "source"; sha256 = "0zmyrxyrq6l2qjiy4fshjvhza6gvjdq1fn82543wb2li21jmpnpq"; } +nix-repl> nixpkgs +"/nix/store/0aavdx9m5ms1cj5pb1dx0brbrbigy8ij-source" +``` + +This is essentially exactly what npins does, minus the part of saving the +commit ID and hash into `npins/sources.json`. + +We could write a simple shell script to do this, perhaps called +`./bad-npins.sh`: + +```bash +#!/usr/bin/env bash + +name=nixpkgs +repo=https://github.com/nixos/nixpkgs +branch=nixos-unstable + +tarballUrl="$repo/archive/$(git ls-remote "$repo" nixos-unstable | cut -f1)" +sha256=$(nix-prefetch-url --name source --unpack "$tarballUrl") + +# initialize sources.json if not present +[[ ! -f sources.json ]] && echo '{}' > sources.json + +# use sponge from moreutils to deal with jq not having the buffering to safely +# do in-place updates +< sources.json jq --arg sha256 "$sha256" --arg url "$tarballUrl" --arg name "$name" \ + '.[$name] = {sha256: $sha256, url: $url}' \ + | sponge sources.json +``` + +and then from Nix we can load the sources: + +```nix +let + srcs = builtins.fromJSON (builtins.readFile ./sources.json); + fetchOne = _name: { sha256, url, ... }: builtins.fetchTarball { + name = "source"; + inherit sha256 url; + }; +in +builtins.mapAttrs fetchOne srcs +``` + +Result: + +``` +~ » nix eval -f sources.nix +{ nixpkgs = "/nix/store/0aavdx9m5ms1cj5pb1dx0brbrbigy8ij-source"; } +``` + +We now have a bad pinning tool! I wouldn't recommend using this shell script, since +it doesn't do things like check if redownloading the tarball is necessary, but +it is certainly cute and it does work. + +`npins` is pretty much this at its core, but well-executed. + +[npins]: https://github.com/andir/npins +[niv]: https://github.com/nmattia/niv + +# Fixing the UX issues + +We know that: + +1. `` as seen by `nixos-rebuild` determines what version of nixpkgs + is used to build the configuration. +2. Where the configuration is is simply determined by `` +3. Both instances of duplicate configuration evaluation are gated on `--fast` + not being passed. + +So, we just have to invoke `nixos-rebuild` with the right options and +`NIX_PATH` such that we get a config from the current directory with a +`nixpkgs` version determined by `npins`. + +Let's set up npins, then write a simple shell script. + +``` +$ npins init --bare +$ npins add --name nixpkgs channel nixos-unstable +``` + +You can also use `nixos-23.11` (or future versions once they come out) in place +of `nixos-unstable` here, if you want to use a stable nixpkgs. + +Time for a simple shell script. Note that this shell script uses `nix eval`, +which we at *Lix* are very unlikely to ever break in the future, but it does +require `--extra-experimental-features nix-command` as an argument if you don't +have the experimental feature enabled, or +`nix.settings.experimental-features = "nix-command"` in a NixOS config. (The +experimental feature can be hacked around with +`nix-instantiate --json --eval npins/default.nix -A nixpkgs.outPath | jq -r .`, +which works around `nix-instantiate --eval` missing a `--raw` flag, but this is +kind of pointless since we are about to use flakes features in a second) + +```bash +#!/usr/bin/env bash + +cd $(dirname $0) + +# assume that if there are no args, you want to switch to the configuration +cmd=${1:-switch} +shift + +nixpkgs_pin=$(nix eval --raw -f npins/default.nix nixpkgs) +nix_path="nixpkgs=${nixpkgs_pin}:nixos-config=${PWD}/configuration.nix" + +# without --fast, nixos-rebuild will compile nix and use the compiled nix to +# evaluate the config, wasting several seconds +sudo env NIX_PATH="${nix_path}" nixos-rebuild "$cmd" --fast "$@" +``` + +# Killing channels + +Since building the config successfully, we can now kill channels to stop their +reign of terror, since we no longer need them to build the configuration at +all. Use `sudo nix-channel --list` and then `sudo nix-channel --remove +CHANNELNAME` on each one. While you're at it, you can also delete `/etc/nixos` +if you've moved your configuration to your home directory. + +Now we have a NixOS configuration built without using channels, but once we are +running that system, `` will still refer to a channel (or nothing, if +the channels are deleted), since we didn't do anything to `NIX_PATH` on the +running system. Also, the `nixpkgs` flake reference will point to the latest +`nixos-unstable` at the time of running a command like `nix run nixpkgs#hello`. +Let's fix both of these things. + +For context, *by default*, on NixOS 24.05 and later, due to [PR +254405](https://github.com/NixOS/nixpkgs/pull/254405), *flake*-based NixOS +configs get pinned `` and a pinned `nixpkgs` flake of the exact same +version as the running system, such that `nix-shell -p hello` and `nix run +nixpkgs#hello` give you the same `hello` every time: it will always be the same +one as if you put it in `systemPackages`. That setup works by setting +`NIX_PATH` to refer to the flake registry `/etc/nix/registry.json`, which then +is set to resolve `nixpkgs` to `/nix/store/xxx-source`, that is, the nixpkgs of +the current configuration. + +We can bring the same niceness to non-flake configurations, with the exact same +code behind it, even! + +Let's fix the `NIX_PATH`. Add this module worth of code into your config +somewhere, say, `pinning.nix`, then add it to `imports` of `configuration.nix`: + +```nix +{ config, pkgs, ... }: +let sources = import ./npins; +in { + # We need the flakes experimental feature to do the NIX_PATH thing cleanly + # below. Given that this is literally the default config for flake-based + # NixOS installations in the upcoming NixOS 24.05, future Nix/Lix releases + # will not get away with breaking it. + nix.settings = { + experimental-features = "nix-command flakes"; + }; + + # FIXME(24.05 or nixos-unstable): change following two rules to + # + # nixpkgs.flake.source = sources.nixpkgs; + # + # which does the exact same thing, using the same machinery as flake configs + # do as of 24.05. + nix.registry.nixpkgs.to = { + type = "path"; + path = sources.nixpkgs; + }; + nix.nixPath = ["nixpkgs=flake:nixpkgs"]; +} +``` + +# New workflow + +When you want to update NixOS, use `npins update`, then `./rebuild.sh` +(`./rebuild.sh dry-build` to check it evaluates, `./rebuild.sh boot` to switch +on next boot, etc). If it works, commit it to Git. The version of nixpkgs comes +from exactly one place now, and it is tracked along with the changes to your +configuration. Builds are faster now since we don't evaluate the configuration +multiple times. + +Multiple machines can no longer get desynchronized with each other. Config +commits *will* build to the same result in the future, since they are +self-contained now. + +# Conclusion and analysis + +We really need to improve `nixos-rebuild` as the NixOS development community. +It embodies, at basically every juncture, obsolescent practices that confuse +users and waste time. Modern configurations should be using either +npins/equivalent or flakes, both of which should be equally valid and easy to +use choices in all our tooling. + +Flags like `--no-rebuild-nix` come from an era where people were building +flake-based configs from a Nix that didn't even *have* flakes, so they needed +to be able to switch to an entirely different *Nix* to be able to evaluate +their config. We should never be rebuilding Nix by default before re-evaluating +the configuration in 2024. The Nix language is much, much more stable these +days, almost frozen like a delicious ice cream cone, and so the idea of +someone's config requiring a brand new Nix to merely evaluate is bordering on +absurd. + +It doesn't help that this old flakes hack actually breaks cross compiling +NixOS configs, for which `--fast` is thus mandatory. The re-execution of +`nixos-rebuild` is more excusable since there is [still work to do on that like +capturing output to the journal](https://github.com/NixOS/nixpkgs/pull/287968), +but it is still kind of bothersome to eat so much evaluation time about it; I +wonder if a happier medium is that it would just build `pkgs.nixos-rebuild` +instead of evaluating all the modules, but that has its own drawback of ignoring +overlays in the NixOS config... + +Another tool that [needs rewriting, documentedly +so](https://github.com/NixOS/nixpkgs/issues/293543) is `nixos-option`, which is +a bad pile of C++ that doesn't support flakes, and which could be altogether +replaced by a short bit of very normal Nix code and a shell script. + +There's a lot of work still to do on making NixOS and Nix a more friendly +toolset, and we hope you can join us. I (Jade) have been working along with +several friends on , a soon-to-be-released fork of CppNix +2.18 focused on friendliness, stability, and future evolution. People +in our community have been working on these UX problems outside Nix itself +as well. We would love for these tools to be better for everyone. diff --git a/content/posts/pinning-packages-in-nix.md b/content/posts/pinning-packages-in-nix.md index 05bef38..fa393d6 100644 --- a/content/posts/pinning-packages-in-nix.md +++ b/content/posts/pinning-packages-in-nix.md @@ -289,11 +289,11 @@ the NixOS option The primary reason that Nix doesn't allow trivially overriding packages with a different version is that it is a generalized build system building software -that has non-uniform expectations of how to be built. One can in indeed see +that has non-uniform expectations of how to be built. One can indeed see that the "replace one version with some other in some file" idea is *almost* -reality in languages using `mkDerivation` directly, though one might have to +reality in languages that use `mkDerivation` directly, though one might have to tweak other build properties sometimes. Architectural problems in nixpkgs -prevent this working for several ecosystems. +prevent this working for several ecosystems, though. Another sort of issue is that nixpkgs tries to provide a mostly [globally coherent] set of software versions, where, like most Linux distributions, there