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:
@@ -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).
|
||||||
|
|||||||
@@ -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 = [ ];
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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.
|
||||||
'';
|
'';
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user