If you’re a NixOS user, knowing what will change on your system when performing updates can be tricky. We can make the experience better!
The Problem
Here’s an example of what I see when I run a pacman -Syu
on one of my Arch Linux systems:
Packages (343) alsa-lib-1.2.8-1 alsa-ucm-conf-1.2.8-1 aom-3.5.0-1 apr-util-1.6.1-10 arch-install-scripts-27-1
archlinux-keyring-20221110-1 archlinuxarm-keyring-20140119-2 base-3-1 bind-9.18.8-2 blas-3.11.0-1
bluez-libs-5.66-1 breezy-3.3.0-1 ca-certificates-20220905-1 ca-certificates-mozilla-3.85-1
...
Knowing the pending package version on a Linux host with important services like MySQL or Elasticsearch before upgrading can be critical. Applications that rely on services with changing versions may be impacted, file storage formats may be changed, and more.
NixOS manages to mitigate many of these concerns by baking in the intelligence for upgrades into many services. For example, I recently upgraded Mastodon, and NixOS inherently knows how to run Rails migrations for me. That’s great!
However, there are plenty of cases where that’s much harder to automate or I need to know about changes beforehand. If my system is about to undergo a major kernel version upgrade, I need to know! Similarly, upgrading services like Postgresql require manual steps in order to perform major version updates.
The only problem here is that NixOS doesn’t have an obvious way of telling you what upgrading your system will actually do. Here’s the typical way to update a NixOS system per the documentation:
$ nix-channel --update nixos
$ nixos-rebuild switch
This will retrieve the latest version of the nixos
channel and then upgrade your system, and only after the fact will you find the magnitude of the changes.
This puzzle perplexed me during the few first months of my full-time use of NixOS and, surprisingly, I didn’t find much in the way of guides or explanations to remediate the issue. Fortunately, with a little bit of deeper understanding about how NixOS works, this is a very easy problem to solve.
Some Background
To re-tread some basic NixOS ground before proceeding: assuming no compilation errors, evaluating your system’s configuration.nix
constructs a closure, which is a total set of dependencies necessary to “construct” your system.
When you call nixos-rebuild
, one of the first things that it needs to do before upgrading your system is actually build the closure before “switching” to it (Note that “switch
” is an important term here, we’ll come back to it later).
Note: Flake users undergo essentially the same process, except that the target is the right nixosConfiguration
in their flake output. nixos-rebuild
accepts the --flake
flag if you’re targeting a flake.nix
instead of a configuration.nix
.
In fact, nixos-rebuild
accepts a number of different positional arguments, each very useful:
dry-build
just informs you what will be builtbuild
performs the actual process of building the closuretest
will upgrade your system to the new build but will not update your bootloader’s default selection, which means that if you completely bork the system, a simple reboot will fix it.switch
does pretty much everything above: build the closure, update the bootloader, and make the configuration currently active.
The eagle-eyed reader will note that, if we can step in between steps 2 and 3 in the list of above, we could maybe… find out the differences? And you would be right!
Diffing Closures
A NixOS system closure is a singular path in the nix store. For example, I just ran this command on my system:
$ ls -la /var/run/current-system
lrwxrwxrwx 1 root root 86 Nov 19 14:03 /var/run/current-system ->
/nix/store/mk60q9dcdqvv8l0mnbzlfxi46hv6d0s7-nixos-system-diesel-22.05.20221118.f42a45c
Like any other closure, my system closure resides in the nix store - along with every other closure that nix builds, from programs to older system profiles, and that includes intermediate closures like the one nixos-rebuild
needs to construct you switch
to it.
The magic skeleton key to this whole solution is the nix store diff-closures
command:
$ nix store diff-closures --help
nix store diff-closures - show what packages and versions were added and removed between two closures
Given two store paths, the diff-closures
subcommand can walk the closure and compare the differences between to paths. Here’s a short snippet from some older system generations on my laptop:
$ nix store diff-closures /nix/var/nix/profiles/system-{408,409}-link | head
SDL2: 2.24.0 → 2.24.2, -16.0 KiB
acl: -117.5 KiB
alsa-lib: -1624.6 KiB
alsa-topology-conf: -336.1 KiB
alsa-ucm-conf: -331.8 KiB
at-spi2-core: -2179.0 KiB
attr: -78.8 KiB
audit: -714.0 KiB
avahi: -1736.9 KiB
bash: -1526.4 KiB
Neat! Due to the fact that nix may construct a slightly different closure even though the actual software version remains the same, you may see both “moving to a different version” rendered in the output of diff-closure
– as is the case with my SDL2
package – but you’ll also see packages that may have different sizes, but no version changes. diff-closure
tries to provide as much helpful information as it can.
diff-closure
is very useful, but it turns out that there tools out there optimized for our “let a human see version changes” use case. nvd is that tool.
The nvd home page is nearly a summarized repeat of this blog post, so you can head there to get started quickly if you’d like to.
Here’s an example of what the output looks like, screenshotted instead of copy/pasted so you can appreciate the pretty colors:
You’ll notice that I’m calling nvd diff
on two paths that are older system generations of mine.
If we want to preview a system update, there’s one more piece to plug into this puzzle before we end up with a solid approach.
The Solution
Due to the fact that your system profile is always linked to /var/run/current-system
, the first argument of nvd diff
is always available to us, but we’ll need to build the path for the second argument.
On a typical NixOS system, that’s easy.
First, update the revision of nixpkgs
that you’re pointing at:
$ nix-channel --update
Note: this will bump up your channel to point at the latest upstream version. This means that if you need to, for example, change your NixOS configuration to apply some setting but remain on your existing nixpkgs
version, you’d need to either nix-channel --rollback
or take a slightly different approach, like adding another channel to act as a “preview” before making the --update
to your main channel.
Then build the new system closure:
$ nixos-rebuild build
Per most nix build
-type commands, the new system closure store path arrives as a ./result
symlink.
All that’s left is to visualize the pending updates:
$ nvd diff /run/current-system ./result
…and you can easily see what a system update will bring you.
As an added bonus, performing the build
prepares your system for making the upgrade, so any subsequent switch
will be (mostly) instant (or at least avoid any need to re-do what build
has already done.)
Flakes
If you’re a flakes user, this process becomes significantly easier because your system nixpkgs
is a well-defined input
to your system flake.
Just update your nixpkgs
input:
$ nix flake lock --update-input nixpkgs
Build the system (toplevel
is the general-purpose “this is my operating system profile” build target):
$ nix build ".#nixosConfigurations.$(hostname).config.system.build.toplevel"
…and run the same nvd diff
command.
That’s it!
Bonus Round
My original motivation for these strategies came from a desire for a “pending updates” widget in my polybar. Here’s an example script that can run periodically to return a “pending updates” number. Note that my script is slightly different than this and I’ve edited it for general consumption, so I don’t guarantee this will work, so adjust as necessary:
#!/usr/bin/env bash
set -euo pipefail
tempdir=$(mktemp -d /tmp/tmp.nix-updateinfo.XXX)
git clone --reference /etc/nixos /etc/nixos $tempdir
cd $tempdir
nix flake lock --update-input nixpkgs
nix build ".#nixosConfigurations.$(hostname).config.system.build.toplevel"
nix store diff-closures /run/current-system ./result \
| awk '/[0-9] →|→ [0-9]/ && !/nixos/' || echo
cd ~-
rm -rf "$tempdir"
I’ve relied on this “pending updates” periodic job for a long time now with great success and been very happy with the results.