Add router-upgrade for nixpkgs upgrades on the router

router.update.channel names the nixpkgs branch to follow (unstable or a
release like 26.05). router-upgrade check compares its newest tested
revision with the running one; preview locks a copy of the configuration
flake to it and lists the packages that change version and the size of the
download without fetching anything; download fetches it; apply takes the
lock and activates with commit-confirm, the lock going back on a rollback
or a failed build. router-update keeps an upgraded nixpkgs pinned.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-11-08 21:06:10 +01:00
co-authored by Claude Sonnet 5.5
parent af614ea501
commit 56d6e153ae
6 changed files with 389 additions and 5 deletions
+19
View File
@@ -222,6 +222,19 @@ verity. The image module and its test (`checks.image`) stay as they are for anyo
- **`local` zone**: `firewall.rules` may have `to = "local"`, the router itself (SSH, the web UI, ...): those rules go on the `input` chain. From `wan` they need a protocol and ports (no destination); `natOnly` is refused; `local` cannot be a zone name. A port forward on the same port takes the traffic first, so it never reaches `local`. Checked by `tests/assertions.nix` (`local*`); the generated rules are covered by a subtest of `basic` (the named sender reaches the router's port, others and unopened ports do not, the LAN-only rule works). - **`local` zone**: `firewall.rules` may have `to = "local"`, the router itself (SSH, the web UI, ...): those rules go on the `input` chain. From `wan` they need a protocol and ports (no destination); `natOnly` is refused; `local` cannot be a zone name. A port forward on the same port takes the traffic first, so it never reaches `local`. Checked by `tests/assertions.nix` (`local*`); the generated rules are covered by a subtest of `basic` (the named sender reaches the router's port, others and unopened ports do not, the LAN-only rule works).
## Package upgrades (`router-upgrade`, 2026-11-08)
Upgrading nixpkgs (the kernel and every package) on the router, without a new release of nixos-router. Step 1 of 3: the CLI and the setting; the web UI page (with the package list) and the notice of a new NixOS release follow.
- **Setting** `router.update.channel`: `unstable` or a release like `26.05` (branch `nixos-26.05`); null: the packages that came with the installed release. Read from the saved `settings.json` when upgrading; applying settings does not change nixpkgs.
- **`router-upgrade check`**: the branch's newest revision from `<channels.nixos.org>/<branch>/git-revision` (the last one Hydra's tests passed), against the running one (`nixos-version --revision`). Writes `/var/lib/router/upgrade/check.json`.
- **`preview [--force]`**: locks a copy of the configuration flake to that revision (`nix flake lock --override-input nixos-router/nixpkgs`), evaluates it and lists the packages of the running system that have another version in the new system's derivations, how much `nix build --dry-run` would download, and how many derivations would be built here. Nothing is downloaded: evaluation fetches the nixpkgs source only. Approximate: a rebuild without a new version is not listed. Refuses a NixOS release older than the running one. A changed `linux` sets `rebootNeeded`. Result in `preview.json`.
- **`download`**: fetches and builds it (needs 3 GiB free), so a flaky uplink does not matter at `apply`.
- **`apply [timeout]`**: takes the previewed lock into the configuration directory and runs `router-apply` (commit-confirm). The directory as it was is kept in `config.pre-update` like for updates: a rollback or a failed build puts the lock back. Refused if the configuration flake changed since the preview.
- **Pin**: `nixpkgs-pin.json` in the configuration directory (channel, revision, flake reference with `narHash`). `router-update` locks it again after it installs a new release of nixos-router, so a release does not undo an upgrade; the reference is found in the store by its hash, offline.
- **Module options** (for tests): `routerSettings.upgrade.channelsUrl`, `routerSettings.upgrade.nixpkgsRef` (`%rev%`).
- **Test** (`apply`): the channel is a `file://` directory and the "newer" nixpkgs is the installed one under a `path:` reference with the same `lastModified` and `rev`: a really different nixpkgs cannot be built offline (any version change rebuilds the NixOS tools). Covers check, preview (including the package-list rules), download, an unconfirmed upgrade rolled back with its lock and pin, a confirmed one, and a failing build.
## Not done yet (2026-11-01, after 0.12.0) ## Not done yet (2026-11-01, after 0.12.0)
Remote access Remote access
@@ -258,3 +271,9 @@ On the user's own router (settings and infrastructure, not code)
3. **Images and hardware** — installer ISO, `router-setup`, signed updates. *Done for x86_64;* read-only images were considered and dropped (see above), aarch64 was tried and dropped. 3. **Images and hardware** — installer ISO, `router-setup`, signed updates. *Done for x86_64;* read-only images were considered and dropped (see above), aarch64 was tried and dropped.
4. **Operations** — commit-confirm rollback, signed updates, backups, DDNS and certificates. *Done.* Monitoring beyond the dashboard and multi-WAN failover: not done. 4. **Operations** — commit-confirm rollback, signed updates, backups, DDNS and certificates. *Done.* Monitoring beyond the dashboard and multi-WAN failover: not done.
5. **Web UI and remote access** — pages for every setting, themes, MCP server, REST API. *Done* (0.12.0); see "Not done yet". 5. **Web UI and remote access** — pages for every setting, themes, MCP server, REST API. *Done* (0.12.0); see "Not done yet".
Package upgrades (see above)
- **Web UI**: a page next to Update: check, preview with the package list, download, apply; the channel as a setting there.
- **New NixOS release**: look for newer `nixos-YY.MM` branches and say so; mark releases the installed nixos-router was not tested with.
- **Tested revisions**: a signed manifest in the release naming the nixpkgs revisions the tests passed on, as a gate in front of the channel's tip.
- **Reset**: go back to the nixpkgs that came with the release (`channel = null` and remove the pin).
+12
View File
@@ -440,6 +440,18 @@ in
''; '';
}; };
update.channel = mkOption {
type = types.nullOr (types.strMatching "unstable|[0-9]{2}\\.[0-9]{2}");
default = null;
example = "26.05";
description = ''
The nixpkgs branch whose packages and kernel `router-upgrade` offers:
`unstable`, or a release like `26.05` (the branch `nixos-26.05`). It
takes effect when you upgrade, not when you apply the settings. null:
the packages that came with the installed release of nixos-router.
'';
};
firewall.zones = mkOption { firewall.zones = mkOption {
type = types.listOf types.str; type = types.listOf types.str;
default = [ ]; default = [ ];
+4 -1
View File
@@ -124,7 +124,10 @@ let
webui = pkgs.callPackage ../../pkgs/router-webui/package.nix { }; webui = pkgs.callPackage ../../pkgs/router-webui/package.nix { };
in in
{ {
imports = [ ./update.nix ]; imports = [
./update.nix
./upgrade.nix
];
# Outside `router`: the settings file defines that whole tree. # Outside `router`: the settings file defines that whole tree.
options.routerSettings = { options.routerSettings = {
+8 -1
View File
@@ -144,6 +144,11 @@ let
install -m 0644 "$release/flake.nix" "$config/flake.nix" install -m 0644 "$release/flake.nix" "$config/flake.nix"
rm -f "$config/flake.lock" rm -f "$config/flake.lock"
# A nixpkgs that router-upgrade chose stays, rather than the release's.
if [ -r "$config/nixpkgs-pin.json" ]; then
nix flake lock --override-input nixos-router/nixpkgs "$(jq -r .ref "$config/nixpkgs-pin.json")" "path:$config" \
|| fail "pinning the upgraded nixpkgs failed"
fi
router-migrate-settings "$release" "$config/settings.json" || fail "migrating the settings failed" router-migrate-settings "$release" "$config/settings.json" || fail "migrating the settings failed"
@@ -196,7 +201,9 @@ in
assertions = lib.optional (config.routerSettings.release != null) { assertions = lib.optional (config.routerSettings.release != null) {
assertion = (settings.version or 0) == config.routerSettings.release.schema; assertion = (settings.version or 0) == config.routerSettings.release.schema;
message = '' message = ''
settings.json is at version ${toString (settings.version or 0)}, but this release of nixos-router expects settings.json is at version ${
toString (settings.version or 0)
}, but this release of nixos-router expects
version ${toString config.routerSettings.release.schema}. `router-update` migrates the settings; if you edited version ${toString config.routerSettings.release.schema}. `router-update` migrates the settings; if you edited
them by hand, set "version" accordingly. them by hand, set "version" accordingly.
''; '';
+238
View File
@@ -0,0 +1,238 @@
# Upgrading the packages (nixpkgs: the kernel and everything else) of an
# installed router without a new release of nixos-router.
#
# The branch to follow is the setting `router.update.channel` (unstable, or a
# release like 26.05). nixpkgs is pinned in the configuration flake's
# flake.lock, so an upgrade is a new lock:
#
# router-upgrade check what the channel's newest revision is, against the running one
# router-upgrade preview lock a copy of the configuration flake to it and evaluate the
# system: which packages change version, how much would be
# downloaded and built (nothing is downloaded or activated)
# router-upgrade download fetch and build it now (a flaky uplink is no problem later)
# router-upgrade apply take the lock and run `router-apply` (commit-confirm)
#
# The revision comes from channels.nixos.org: the branch's last revision that
# passed the NixOS tests. A configuration directory that was upgraded keeps
# the choice in nixpkgs-pin.json, which `router-update` locks again after it
# installs a new release of nixos-router. The directory as it was is kept in
# config.pre-update, as for updates: a rollback or a failure puts it back.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.routerSettings;
up = cfg.upgrade;
snapshot = "${cfg.stateDirectory}/config.pre-update";
dir = "${cfg.stateDirectory}/upgrade";
upgrade = pkgs.writeShellApplication {
name = "router-upgrade";
runtimeInputs = [
config.nix.package
config.systemd.package
pkgs.coreutils
pkgs.curl
pkgs.jq
pkgs.util-linux
];
text = ''
usage() {
echo "usage: router-upgrade check | preview [--force] | download | apply [timeout]" >&2
echo " check the newest revision of router.update.channel, against the running one" >&2
echo " preview the packages that change and the size of the download (nothing is fetched)" >&2
echo " download fetch and build what was previewed, without activating it" >&2
echo " apply activate what was previewed (fetching it if need be), with commit-confirm" >&2
exit 2
}
die() { echo "router-upgrade: $*" >&2; exit 1; }
[ $# -gt 0 ] || usage
cmd=$1
shift
[ "$(id -u)" -eq 0 ] || die "must run as root"
config=${cfg.directory}
mkdir -p ${dir}
channel=$(jq -r '.router.update.channel // empty' "$config/settings.json")
[ -n "$channel" ] || die "no channel: set router.update.channel (unstable, or a release like 26.05)"
if [ "$channel" = unstable ]; then branch=nixos-unstable; else branch="nixos-$channel"; fi
running_rev=$(/run/current-system/sw/bin/nixos-version --revision || true)
running_version=$(cat /run/current-system/nixos-version)
short() { echo "''${1:0:7}"; }
newest_rev() {
local rev
rev=$(curl -fsS --max-time 30 "${up.channelsUrl}/$branch/git-revision") \
|| die "cannot read ${up.channelsUrl}/$branch/git-revision (no internet, or no channel of that name)"
[[ $rev =~ ^[0-9a-f]{40}$ ]] || die "unexpected answer for $branch: ''${rev:0:60}"
echo "$rev"
}
check() {
newest=$(newest_rev)
if [ "$newest" = "$running_rev" ]; then same=true; else same=false; fi
jq -n --arg channel "$channel" --arg branch "$branch" --arg time "$(date -u +%FT%TZ)" \
--arg running "$running_rev" --arg version "$running_version" --arg newest "$newest" \
--argjson upToDate "$same" \
'{channel: $channel, branch: $branch, time: $time, running: {rev: $running, version: $version},
newest: {rev: $newest}, upToDate: $upToDate}' > ${dir}/check.json.new
mv ${dir}/check.json.new ${dir}/check.json
echo "channel $channel ($branch)"
echo " running $running_version"
echo " newest $(short "$newest")"
if [ "$same" = true ]; then echo "up to date"; else echo "a newer revision is available"; fi
}
# The names (with versions) of store paths, one per line.
names() { sed -e 's|^/nix/store/[a-z0-9]\{32\}-||' -e 's/\.drv$//'; }
# The packages of the running system none of whose versions is among the
# new system's derivations': {name, from, to}. Approximate: a package that is rebuilt
# without a new version is not listed, and build-time versions of a package that
# the system does not run are not either.
changes() { # file of the running system's paths, file of the new system's
jq -n --rawfile old "$1" --rawfile new "$2" '
def lines: split("\n") | map(select(. != "" and (test("\\.(tar|patch|diff|zip|tgz|tbz2?|txz|gz|xz|bz2|sh|py|nix|json|conf|asc|sig|lock)$") | not)));
def strip: sub("-(bin|dev|lib|out|man|doc|info|devdoc|static|lib32|modules|modules-shrunk|libgcc|getent|debug)$"; "");
def parse: capture("^(?<n>.*?)-(?<v>[0-9].*)$") // {n: ., v: ""};
def table: map(strip | parse) | group_by(.n) | map({key: .[0].n, value: (map(.v) | unique)}) | from_entries;
($old | lines | table) as $o | ($new | lines | table) as $n
| [ $o | to_entries[] | select($n[.key] != null and .value != [""])
| select(((.value - $n[.key]) | length) == (.value | length) and (($n[.key] - .value) | length) > 0)
| {name: .key, from: (.value | join(", ")), to: ($n[.key] - .value | join(", "))} ]
| sort_by(.name)'
}
preview() {
local force=0
[ "''${1:-}" != --force ] || force=1
check
rm -rf ${dir}/preview.json ${dir}/flake ${dir}/result
if [ "$same" = true ] && [ "$force" -eq 0 ]; then return 0; fi
mkdir ${dir}/flake
for f in flake.nix settings.json local.nix flake.lock; do
if [ -e "$config/$f" ]; then cp "$config/$f" ${dir}/flake/; fi
done
ref="${up.nixpkgsRef}"
ref=''${ref//%rev%/$newest}
echo "router-upgrade: locking nixpkgs to $(short "$newest")"
nix flake lock --override-input nixos-router/nixpkgs "$ref" "path:${dir}/flake"
node=$(jq -r '.nodes[.nodes[.root].inputs["nixos-router"]].inputs.nixpkgs' ${dir}/flake/flake.lock)
hash=$(jq -er --arg n "$node" '.nodes[$n].locked.narHash' ${dir}/flake/flake.lock)
case $ref in
*narHash=*) pin=$ref ;;
*\?*) pin="$ref&narHash=$hash" ;;
*) pin="$ref?narHash=$hash" ;;
esac
attr="path:${dir}/flake#nixosConfigurations.router.config.system"
echo "router-upgrade: evaluating the new system"
new_version=$(nix eval --raw "$attr.nixos.version")
# The release (26.05) of a version like 26.05.20260101.abcdef0 or 26.11pre-git.
release() { sed -E 's/^([0-9]+\.[0-9]+).*/\1/' <<<"$1"; }
newer=$(printf '%s\n%s\n' "$(release "$new_version")" "$(release "$running_version")" | sort -V | tail -1)
[ "$newer" = "$(release "$new_version")" ] \
|| die "$new_version is older than the running $running_version: NixOS cannot be downgraded"
drv=$(nix eval --raw "$attr.build.toplevel.drvPath")
nix path-info -r /run/current-system | names > ${dir}/old.names
nix path-info -r "$drv" | names > ${dir}/new.names
changes ${dir}/old.names ${dir}/new.names > ${dir}/changes.json
rm -f ${dir}/old.names ${dir}/new.names
if [ "$(jq length ${dir}/changes.json)" -gt 0 ]; then
jq -r '.[] | " \(.name): \(.from) -> \(.to)"' ${dir}/changes.json
else
echo " no package changes"
fi
if jq -e 'any(.[]; .name == "linux")' ${dir}/changes.json > /dev/null; then reboot=true; else reboot=false; fi
# Nothing is fetched: the substituters only say what they have.
dry=$(nix build --dry-run --no-link "$attr.build.toplevel" 2>&1 || true)
download=$(grep -o '([0-9.]* [KMGT]iB download, [0-9.]* [KMGT]iB unpacked)' <<<"$dry" | head -1 || true)
builds=$(grep -cE '^ +/nix/store/[^ ]*\.drv$' <<<"$dry" || true)
echo " to fetch: ''${download:-nothing, or no cache reachable}; to build here: $builds"
jq -n --arg channel "$channel" --arg rev "$newest" --arg ref "$pin" --arg version "$new_version" \
--arg time "$(date -u +%FT%TZ)" --arg download "$download" --argjson builds "$builds" \
--argjson reboot "$reboot" --slurpfile changes ${dir}/changes.json \
'{channel: $channel, rev: $rev, ref: $ref, version: $version, time: $time, download: $download,
builds: $builds, rebootNeeded: $reboot, changes: $changes[0]}' > ${dir}/preview.json
echo "$new_version$([ "$reboot" = false ] || echo ' (a new kernel runs after a reboot)'): router-upgrade apply activates it"
}
download() {
[ -r ${dir}/preview.json ] || die "nothing is previewed (router-upgrade preview)"
free=$(df --output=avail -B1M /nix/store | tail -1)
[ "$free" -ge 3072 ] || die "less than 3 GiB free on /nix/store (''${free} MiB): free some space first"
echo "router-upgrade: fetching and building (nothing is activated)"
nix build --no-link --out-link ${dir}/result \
"path:${dir}/flake#nixosConfigurations.router.config.system.build.toplevel"
echo "downloaded $(jq -r .version ${dir}/preview.json); router-upgrade apply activates it"
}
apply() {
local timeout=''${1:-}
[ -r ${dir}/preview.json ] || die "nothing is previewed (router-upgrade preview)"
exec 9>/run/router-update.lock
flock -n 9 || die "an update or upgrade is running"
[ ! -d ${snapshot} ] || die "an earlier update or upgrade is not confirmed yet"
cmp -s "$config/flake.nix" ${dir}/flake/flake.nix \
|| die "the configuration flake changed since it was previewed: preview again"
cp -a "$config" ${snapshot}
fail() {
echo "router-upgrade: $*" >&2
/run/current-system/sw/bin/router-update-restore
exit 1
}
cp ${dir}/flake/flake.lock "$config/flake.lock"
jq '{channel, rev, ref}' ${dir}/preview.json > "$config/nixpkgs-pin.json"
echo "router-upgrade: building and activating $(jq -r .version ${dir}/preview.json)"
if ! router-apply ''${timeout:+"$timeout"}; then
if systemctl is-active --quiet router-rollback.timer; then
# Activation failed half way: the rollback restores the configuration.
systemctl start router-rollback.service
die "the upgrade failed; the previous system is back"
fi
fail "the upgrade failed; the previous system is back"
fi
rm -rf ${dir}/preview.json ${dir}/flake ${dir}/result
if systemctl is-active --quiet router-rollback.timer; then
echo "router-upgrade: active; confirm it (router-confirm, or in the web UI) or the previous system comes back"
else
rm -rf ${snapshot} # nothing changed: nothing to confirm
fi
}
case $cmd in
check) check ;;
preview) preview "$@" ;;
download) download ;;
apply) apply "$@" ;;
*) usage ;;
esac
'';
};
in
{
options.routerSettings.upgrade = {
channelsUrl = lib.mkOption {
type = lib.types.str;
default = "https://channels.nixos.org";
description = "Where the newest revision of a nixpkgs branch is read (`<branch>/git-revision`).";
};
nixpkgsRef = lib.mkOption {
type = lib.types.str;
default = "github:NixOS/nixpkgs/%rev%";
description = "The flake reference of a nixpkgs revision (`%rev%` stands for it).";
};
};
config.environment.systemPackages = [ upgrade ];
}
+108 -3
View File
@@ -26,6 +26,7 @@ let
]; ];
}; };
router = { router = {
update.channel = "26.05";
wan.interface = "eth1"; wan.interface = "eth1";
lans.main = { lans.main = {
interface = "eth2"; interface = "eth2";
@@ -40,11 +41,16 @@ let
} }
); );
# Only so the test driver can talk to the installed system. # Only so the test driver can talk to the installed system; the channel is a
# directory, and a newer nixpkgs a copy of the installed one (made below).
local = pkgs.writeText "local.nix" '' local = pkgs.writeText "local.nix" ''
{ modulesPath, ... }: { modulesPath, ... }:
{ {
imports = [ (modulesPath + "/testing/test-instrumentation.nix") ]; imports = [ (modulesPath + "/testing/test-instrumentation.nix") ];
routerSettings.upgrade = {
channelsUrl = "file:///tmp/channels";
nixpkgsRef = "path:/var/lib/newer-nixpkgs?lastModified=${toString self.inputs.nixpkgs.lastModified}&rev=%rev%";
};
} }
''; '';
@@ -125,6 +131,8 @@ pkgs.testers.runNixOSTest {
target.start() target.start()
target.wait_for_unit("multi-user.target") target.wait_for_unit("multi-user.target")
import json
def current(): def current():
return target.succeed("readlink -f /run/current-system").strip() return target.succeed("readlink -f /run/current-system").strip()
@@ -250,8 +258,6 @@ pkgs.testers.runNixOSTest {
with subtest("an assistant with a change token edits and checks the settings through MCP"): with subtest("an assistant with a change token edits and checks the settings through MCP"):
secret = curl(f"-d csrf={csrf('/tokens')} -d name=assistant -d scope=change {ui}/tokens | grep -o 'rtr_[A-Za-z0-9_-]*' | sed -n 1p").strip() secret = curl(f"-d csrf={csrf('/tokens')} -d name=assistant -d scope=change {ui}/tokens | grep -o 'rtr_[A-Za-z0-9_-]*' | sed -n 1p").strip()
assert secret.startswith("rtr_"), secret assert secret.startswith("rtr_"), secret
import json
def mcp(tool, args=None, token=secret): def mcp(tool, args=None, token=secret):
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": args or {}}}) body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": args or {}}})
out = client.succeed(f"curl -sk -H 'Authorization: Bearer {token}' -d '{body}' {ui}/mcp") out = client.succeed(f"curl -sk -H 'Authorization: Bearer {token}' -d '{body}' {ui}/mcp")
@@ -311,5 +317,104 @@ pkgs.testers.runNixOSTest {
assert "/api/v1/settings/router/lans/{name}" in spec["paths"] assert "/api/v1/settings/router/lans/{name}" in spec["paths"]
# The log shows what was done, by whom. # The log shows what was done, by whom.
assert "update_settings" in curl(f"{ui}/tokens") and "assistant" in curl(f"{ui}/tokens") assert "update_settings" in curl(f"{ui}/tokens") and "assistant" in curl(f"{ui}/tokens")
# "A newer nixpkgs": the installed one, copied, and told it is the revision
# the channel names (the running one). A different nixpkgs could not be
# built here (even a changed version rebuilds the NixOS tools), so what is
# tested is everything around the build.
target.succeed("cp -r ${self.inputs.nixpkgs} /var/lib/newer-nixpkgs")
running_version = target.succeed("cat /run/current-system/nixos-version")
running_rev = "${self.inputs.nixpkgs.rev}"
def channel(rev):
target.succeed(f"mkdir -p /tmp/channels/nixos-26.05 && printf %s {rev} > /tmp/channels/nixos-26.05/git-revision")
with subtest("router-upgrade check needs a channel and a reachable revision"):
rc, out = target.execute("router-upgrade check 2>&1")
assert rc != 0 and "cannot read" in out, out
channel("not-a-revision")
rc, out = target.execute("router-upgrade check 2>&1")
assert rc != 0 and "unexpected answer" in out, out
target.succeed("jq 'del(.router.update)' /var/lib/router/config/settings.json > /tmp/s.json && cp /var/lib/router/config/settings.json /tmp/settings.orig && cp /tmp/s.json /var/lib/router/config/settings.json")
rc, out = target.execute("router-upgrade check 2>&1")
assert rc != 0 and "no channel" in out, out
target.succeed("cp /tmp/settings.orig /var/lib/router/config/settings.json")
rc, out = target.execute("router-upgrade apply 2>&1")
assert rc != 0 and "nothing is previewed" in out, out
with subtest("a newer revision is reported, the running one is up to date"):
rev = "0123456789abcdef0123456789abcdef01234567"
channel(rev)
out = target.succeed("router-upgrade check 2>&1")
assert "a newer revision is available" in out, out
assert json.loads(target.succeed("cat /var/lib/router/upgrade/check.json"))["upToDate"] is False
channel(running_rev)
out = target.succeed("router-upgrade check 2>&1")
assert "up to date" in out, out
assert json.loads(target.succeed("cat /var/lib/router/upgrade/check.json"))["upToDate"] is True
out = target.succeed("router-upgrade preview 2>&1")
assert "up to date" in out, out
target.succeed("test ! -e /var/lib/router/upgrade/preview.json")
with subtest("a preview lists the changes and the download without fetching or touching anything"):
before = current()
out = target.succeed("router-upgrade preview --force 2>&1")
assert "no package changes" in out and "to fetch:" in out, out
preview = json.loads(target.succeed("cat /var/lib/router/upgrade/preview.json"))
assert preview["rev"] == running_rev and preview["version"] == running_version and preview["changes"] == [], preview
assert preview["ref"].startswith("path:/var/lib/newer-nixpkgs?") and "rev=" + running_rev in preview["ref"] and "narHash=sha256-" in preview["ref"], preview
assert preview["rebootNeeded"] is False and preview["builds"] == 0, preview
target.succeed("test ! -e /var/lib/router/upgrade/result")
assert current() == before
target.succeed("test ! -e /var/lib/router/config/nixpkgs-pin.json")
with subtest("the package list names what changes in version, and only that"):
target.succeed("printf '%s\\n' linux-6.12.5 openssl-3.4.1 openssl-3.4.1-bin zlib-1.3.1 frr-10.1 ripgrep-14.1 rg-1.0 glibc-2.40-66 glibc-2.40-66-getent > /tmp/old")
target.succeed("printf '%s\\n' linux-6.12.8 openssl-3.4.2 zlib-1.3.1 zlib-1.3.2 frr-10.2 cmake-4.0 bash-5.3 glibc-2.40-66 foo-1.tar.gz > /tmp/new")
target.succeed("sed -n '/^changes() {/,/^}/p' /run/current-system/sw/bin/router-upgrade > /tmp/changes.sh && test -s /tmp/changes.sh")
out = target.succeed("bash -c 'source /tmp/changes.sh; changes /tmp/old /tmp/new' | jq -c 'map(.name + \":\" + .from + \">\" + .to)'")
assert json.loads(out) == ["frr:10.1>10.2", "linux:6.12.5>6.12.8", "openssl:3.4.1>3.4.2"], out
with subtest("an upgrade that is not confirmed is rolled back, the lock and the pin included"):
lock = target.succeed("cat /var/lib/router/config/flake.lock")
# The packages are the same, so there is nothing to activate unless the
# settings changed too (an upgrade applies what is saved, as `router-apply` does).
target.succeed("grep -q 192.168.10.130 /var/lib/router/config/settings.json && sed -i 's/192.168.10.130/192.168.10.140/' /var/lib/router/config/settings.json")
target.succeed("router-upgrade download >&2")
target.succeed("test -e /var/lib/router/upgrade/result")
target.succeed("router-upgrade apply 20s >&2")
assert current() != before
target.succeed("systemctl is-active router-rollback.timer")
assert json.loads(target.succeed("cat /var/lib/router/config/nixpkgs-pin.json"))["rev"] == running_rev
target.succeed("test ! -e /var/lib/router/upgrade/preview.json && test ! -e /var/lib/router/upgrade/result")
target.wait_until_succeeds(f"test $(readlink -f /run/current-system) = {before}", timeout=180)
target.succeed("test ! -e /var/lib/router/config/nixpkgs-pin.json && test ! -e /var/lib/router/config.pre-update")
assert target.succeed("cat /var/lib/router/config/flake.lock") == lock
with subtest("a confirmed upgrade stays, pins nixpkgs and rebuilds to exactly itself"):
target.succeed("router-upgrade preview --force >&2")
target.succeed("router-upgrade apply 5min >&2")
target.succeed("router-confirm >&2")
target.fail("systemctl is-active router-rollback.timer")
upgraded = current()
assert upgraded != before
assert target.succeed("readlink -f /nix/var/nix/profiles/system").strip() == upgraded
assert json.loads(target.succeed("cat /var/lib/router/config/nixpkgs-pin.json"))["rev"] == running_rev
assert "newer-nixpkgs" in target.succeed("cat /var/lib/router/config/flake.lock")
target.succeed("test ! -e /var/lib/router/config.pre-update")
assert "no changes" in target.succeed("router-apply 2>&1")
with subtest("an upgrade that fails to build puts the configuration back"):
channel("fedcba9876543210fedcba9876543210fedcba98")
target.succeed("router-upgrade preview >&2")
pin = target.succeed("cat /var/lib/router/config/nixpkgs-pin.json")
# An unknown option, after the preview: the build of the apply fails.
target.succeed("cp /var/lib/router/config/settings.json /tmp/settings.good && jq '.router.nonsense = true' /tmp/settings.good > /var/lib/router/config/settings.json")
rc, out = target.execute("router-upgrade apply 5min 2>&1")
assert rc != 0 and "the upgrade failed" in out, out
assert target.succeed("cat /var/lib/router/config/nixpkgs-pin.json") == pin
target.succeed("test ! -e /var/lib/router/config.pre-update")
target.succeed("cp /tmp/settings.good /var/lib/router/config/settings.json")
assert current() == upgraded
''; '';
} }