diff --git a/SPECS.md b/SPECS.md index a081058..635b0b2 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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). +## 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 `//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) 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. 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". + +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). diff --git a/modules/router/options.nix b/modules/router/options.nix index c573aad..f37d5af 100644 --- a/modules/router/options.nix +++ b/modules/router/options.nix @@ -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 { type = types.listOf types.str; default = [ ]; diff --git a/modules/settings/default.nix b/modules/settings/default.nix index 0fc113a..97f28d8 100644 --- a/modules/settings/default.nix +++ b/modules/settings/default.nix @@ -124,7 +124,10 @@ let webui = pkgs.callPackage ../../pkgs/router-webui/package.nix { }; in { - imports = [ ./update.nix ]; + imports = [ + ./update.nix + ./upgrade.nix + ]; # Outside `router`: the settings file defines that whole tree. options.routerSettings = { diff --git a/modules/settings/update.nix b/modules/settings/update.nix index 632162a..8b4806a 100644 --- a/modules/settings/update.nix +++ b/modules/settings/update.nix @@ -144,6 +144,11 @@ let install -m 0644 "$release/flake.nix" "$config/flake.nix" 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" @@ -196,7 +201,9 @@ in assertions = lib.optional (config.routerSettings.release != null) { assertion = (settings.version or 0) == config.routerSettings.release.schema; 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 them by hand, set "version" accordingly. ''; diff --git a/modules/settings/upgrade.nix b/modules/settings/upgrade.nix new file mode 100644 index 0000000..7521dbc --- /dev/null +++ b/modules/settings/upgrade.nix @@ -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("^(?.*?)-(?[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 (`/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 ]; +} diff --git a/tests/apply.nix b/tests/apply.nix index 0e15e19..d570a95 100644 --- a/tests/apply.nix +++ b/tests/apply.nix @@ -26,6 +26,7 @@ let ]; }; router = { + update.channel = "26.05"; wan.interface = "eth1"; lans.main = { 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" '' { modulesPath, ... }: { 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.wait_for_unit("multi-user.target") + import json + def current(): 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"): 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 - import json - def mcp(tool, args=None, token=secret): 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") @@ -311,5 +317,104 @@ pkgs.testers.runNixOSTest { assert "/api/v1/settings/router/lans/{name}" in spec["paths"] # The log shows what was done, by whom. 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 ''; }