Tyblog

Technology, open source, unsolicited opinions & digital sovereignty
blog.tjll.net

« How to Preview System Updates on NixOS

  • 8 February, 2023
  • 1,533 words
  • eight minutes read time

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!

Explaining how to preview NixOS package updates
Explaining how to preview NixOS package updates

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:

  1. dry-build just informs you what will be built
  2. build performs the actual process of building the closure
  3. test 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.
  4. 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.