Follow the channel server's redirect and offer the stable releases

channels.nixos.org answers <branch>/git-revision with a redirect, which
router-upgrade check did not follow: on a real router it saw an empty
answer. The test channel is now an HTTP server that redirects the same way.

The page labelled the running release "stable", but nixos-unstable already
calls itself the next release (26.11 does not exist as a branch yet). It
offers none, unstable and the stable releases listed in the code (26.05).

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 23:28:47 +01:00
co-authored by Claude Sonnet 5.5
parent f4a192d416
commit 3d5ec77f57
5 changed files with 56 additions and 23 deletions
+3 -3
View File
@@ -227,14 +227,14 @@ verity. The image module and its test (`checks.image`) stay as they are for anyo
Upgrading nixpkgs (the kernel and every package) on the router, without a new release of nixos-router. Step 1 (CLI and setting) and step 2 (web UI page) are done; the notice of a new NixOS release follows. Upgrading nixpkgs (the kernel and every package) on the router, without a new release of nixos-router. Step 1 (CLI and setting) and step 2 (web UI page) are done; the notice of a new NixOS release follows.
- **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. - **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`. - **`router-upgrade check`**: the branch's newest revision from `<channels.nixos.org>/<branch>/git-revision` (a redirect to releases.nixos.org, so curl follows it; the last revision 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`. - **`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`. - **`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. - **`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. - **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.
- **Web UI page** `/upgrade` ("Packages", under System): the channel as a select (none, `unstable`, the running release; a configured one that is none of these stays listed; saved to `settings.json` and the confirmed settings, so it never shows as unapplied), buttons Check, Preview (with "look anyway" when up to date), Download now and Upgrade. The preview shows the version table, download size, local builds (a warning above 50) and a reboot note for a new kernel. The page asks for a step in `upgrade/request.json` and starts `router-upgrade.service`, which runs `router-upgrade --request` (not restarted or stopped by a switch, like `router-apply`); it shows `check.json`/`preview.json` (not those of another channel, nor a check from before the last upgrade) and `upgrade/last.log`, which the unit tees (a finished one-shot unit has no journal to find). Download and Upgrade need a preview and no change waiting for confirmation; a request without a `channel` field is refused rather than clearing it. - **Web UI page** `/upgrade` ("Packages", under System): the channel as a select (none, `unstable`, and the stable releases in `stableChannels` in `upgrade.go`, 26.05 for now, until the router can ask the channel server which exist: the running release says nothing, since nixos-unstable already calls itself the next one; a configured channel that is none of these stays listed; saved to `settings.json` and the confirmed settings, so it never shows as unapplied), buttons Check, Preview (with "look anyway" when up to date), Download now and Upgrade. The preview shows the version table, download size, local builds (a warning above 50) and a reboot note for a new kernel. The page asks for a step in `upgrade/request.json` and starts `router-upgrade.service`, which runs `router-upgrade --request` (not restarted or stopped by a switch, like `router-apply`); it shows `check.json`/`preview.json` (not those of another channel, nor a check from before the last upgrade) and `upgrade/last.log`, which the unit tees (a finished one-shot unit has no journal to find). Download and Upgrade need a preview and no change waiting for confirmation; a request without a `channel` field is refused rather than clearing it.
- **Module options** (for tests): `routerSettings.upgrade.channelsUrl`, `routerSettings.upgrade.nixpkgsRef` (`%rev%`). - **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. - **Test** (`apply`): the channel is a small HTTP server on the client that redirects like channels.nixos.org, 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)
+2 -1
View File
@@ -84,7 +84,8 @@ let
newest_rev() { newest_rev() {
local rev local rev
rev=$(curl -fsS --max-time 30 "${up.channelsUrl}/$branch/git-revision") \ # (channels.nixos.org answers with a redirect to releases.nixos.org.)
rev=$(curl -fsSL --max-time 30 "${up.channelsUrl}/$branch/git-revision") \
|| die "cannot read ${up.channelsUrl}/$branch/git-revision (no internet, or no channel of that name)" || 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}" [[ $rev =~ ^[0-9a-f]{40}$ ]] || die "unexpected answer for $branch: ''${rev:0:60}"
echo "$rev" echo "$rev"
+12 -6
View File
@@ -145,12 +145,18 @@ type upgradeData struct {
Pending bool // a system waits for confirmation Pending bool // a system waits for confirmation
} }
// stableChannels are the stable NixOS releases the page offers (the branch
// nixos-26.05 and so on). They are listed here until the router can ask the
// channel server which exist; the release a router runs says nothing about it:
// nixos-unstable already calls itself the release that comes next.
var stableChannels = []string{"26.05"}
// channelChoices are the branches worth offering: none (the packages that came // channelChoices are the branches worth offering: none (the packages that came
// with the release), unstable and the running release; NixOS cannot go back // with the release), unstable and the stable releases. A configured channel
// to an older one. A configured channel that is none of these stays listed. // that is none of these stays listed.
func channelChoices(version, current string) []channelChoice { func channelChoices(current string) []channelChoice {
choices := []channelChoice{{"", "None: the packages that came with this release of nixos-router"}, {"unstable", "unstable (newest, may break)"}} choices := []channelChoice{{"", "None: the packages that came with this release of nixos-router"}, {"unstable", "unstable (newest, may break)"}}
if r := releaseOf(version); r != "" { for _, r := range stableChannels {
choices = append(choices, channelChoice{r, r + " (stable)"}) choices = append(choices, channelChoice{r, r + " (stable)"})
} }
if current != "" && !slices.ContainsFunc(choices, func(c channelChoice) bool { return c.Value == current }) { if current != "" && !slices.ContainsFunc(choices, func(c channelChoice) bool { return c.Value == current }) {
@@ -166,7 +172,7 @@ func (a *app) upgradeData() upgradeData {
if s, err := loadSettings(a.sys.settingsPath()); err == nil { if s, err := loadSettings(a.sys.settingsPath()); err == nil {
d.Channel = s.channel() d.Channel = s.channel()
} }
d.Choices = channelChoices(d.Version, d.Channel) d.Choices = channelChoices(d.Channel)
// What router-upgrade printed: a finished one-shot unit has no journal to find. // What router-upgrade printed: a finished one-shot unit has no journal to find.
if data, err := os.ReadFile(filepath.Join(a.sys.upgradeDir(), "last.log")); err == nil { if data, err := os.ReadFile(filepath.Join(a.sys.upgradeDir(), "last.log")); err == nil {
d.Run.Log = tail(string(data), 64<<10) d.Run.Log = tail(string(data), 64<<10)
@@ -204,7 +210,7 @@ func (a *app) upgradeChannel(w http.ResponseWriter, r *http.Request, _ session)
return return
} }
if v != "" && !channelRe.MatchString(v) { if v != "" && !channelRe.MatchString(v) {
a.upgradeBack(w, r, "Not saved: the channel is unstable or a release like 26.11.") a.upgradeBack(w, r, "Not saved: the channel is unstable or a release like 26.05.")
return return
} }
if err := a.sys.setChannel(v); err != nil { if err := a.sys.setChannel(v); err != nil {
+10 -10
View File
@@ -24,14 +24,14 @@ func TestReleaseOfAndChannelChoices(t *testing.T) {
} }
return strings.Join(v, ",") return strings.Join(v, ",")
} }
if got := values(channelChoices("26.11.2026", "")); got != ",unstable,26.11" { if got := values(channelChoices("")); got != ",unstable,26.05" {
t.Fatalf("choices: %q", got) t.Fatalf("choices: %q", got)
} }
// A configured channel that is not offered (a release that is gone) is not lost. // A configured channel that is not offered (a release that is gone) is not lost.
if got := values(channelChoices("26.11.2026", "27.05")); got != ",unstable,26.11,27.05" { if got := values(channelChoices("25.11")); got != ",unstable,26.05,25.11" {
t.Fatalf("choices: %q", got) t.Fatalf("choices: %q", got)
} }
if got := values(channelChoices("26.11.2026", "unstable")); got != ",unstable,26.11" { if got := values(channelChoices("unstable")); got != ",unstable,26.05" {
t.Fatalf("choices: %q", got) t.Fatalf("choices: %q", got)
} }
} }
@@ -119,13 +119,13 @@ func TestUpgradePage(t *testing.T) {
if resp := post("/upgrade/channel", url.Values{}); !strings.Contains(resp.Header.Get("Location"), "no+channel") { if resp := post("/upgrade/channel", url.Values{}); !strings.Contains(resp.Header.Get("Location"), "no+channel") {
t.Fatalf("%s", resp.Header.Get("Location")) t.Fatalf("%s", resp.Header.Get("Location"))
} }
post("/upgrade/channel", url.Values{"channel": {"26.11"}}) post("/upgrade/channel", url.Values{"channel": {"26.05"}})
post("/upgrade/channel", url.Values{}) post("/upgrade/channel", url.Values{})
s, _ := loadSettings(sys.settingsPath()) s, _ := loadSettings(sys.settingsPath())
if s.channel() != "26.11" { if s.channel() != "26.05" {
t.Fatalf("channel %q", s.channel()) t.Fatalf("channel %q", s.channel())
} }
if !strings.Contains(page(), `value="26.11" selected`) { if !strings.Contains(page(), `value="26.05" selected`) {
t.Fatal("the saved channel is not selected") t.Fatal("the saved channel is not selected")
} }
}) })
@@ -159,7 +159,7 @@ func TestUpgradePage(t *testing.T) {
t.Fatal("the unit was started without a preview") t.Fatal("the unit was started without a preview")
} }
os.MkdirAll(a.sys.upgradeDir(), 0o700) os.MkdirAll(a.sys.upgradeDir(), 0o700)
os.WriteFile(filepath.Join(a.sys.upgradeDir(), "preview.json"), []byte(`{"channel":"26.11","rev":"0123456789abcdef0123456789abcdef01234567","version":"26.11.20261110.0123456","time":"2026-11-10T10:00:00Z","download":"(350.5 MiB download, 1.2 GiB unpacked)","builds":3,"rebootNeeded":true,"changes":[{"name":"frr","from":"10.1","to":"10.2"},{"name":"linux","from":"6.12.5","to":"6.12.8"}]}`), 0o600) os.WriteFile(filepath.Join(a.sys.upgradeDir(), "preview.json"), []byte(`{"channel":"26.05","rev":"0123456789abcdef0123456789abcdef01234567","version":"26.11.20261110.0123456","time":"2026-11-10T10:00:00Z","download":"(350.5 MiB download, 1.2 GiB unpacked)","builds":3,"rebootNeeded":true,"changes":[{"name":"frr","from":"10.1","to":"10.2"},{"name":"linux","from":"6.12.5","to":"6.12.8"}]}`), 0o600)
body := page() body := page()
for _, want := range []string{"frr", "10.1", "10.2", "linux", "350.5 MiB", "3 derivations", "after the next reboot", "Upgrade to 26.11.20261110.0123456"} { for _, want := range []string{"frr", "10.1", "10.2", "linux", "350.5 MiB", "3 derivations", "after the next reboot", "Upgrade to 26.11.20261110.0123456"} {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
@@ -185,13 +185,13 @@ func TestUpgradePage(t *testing.T) {
t.Run("a preview of another channel is not shown", func(t *testing.T) { t.Run("a preview of another channel is not shown", func(t *testing.T) {
post("/upgrade/channel", url.Values{"channel": {"unstable"}}) post("/upgrade/channel", url.Values{"channel": {"unstable"}})
if strings.Contains(page(), "linux") { if strings.Contains(page(), "linux") {
t.Fatal("the preview of 26.11 is shown for unstable") t.Fatal("the preview of 26.05 is shown for unstable")
} }
}) })
t.Run("a check from before the last upgrade is not shown", func(t *testing.T) { t.Run("a check from before the last upgrade is not shown", func(t *testing.T) {
post("/upgrade/channel", url.Values{"channel": {"26.11"}}) post("/upgrade/channel", url.Values{"channel": {"26.05"}})
check := `{"channel":"26.11","branch":"nixos-26.11","time":"2026-11-09T10:00:00Z","running":{"rev":"aaa","version":"%s"},"newest":{"rev":"0123456789abcdef"},"upToDate":false}` check := `{"channel":"26.05","branch":"nixos-26.05","time":"2026-11-09T10:00:00Z","running":{"rev":"aaa","version":"%s"},"newest":{"rev":"0123456789abcdef"},"upToDate":false}`
os.WriteFile(filepath.Join(a.sys.upgradeDir(), "check.json"), []byte(strings.Replace(check, "%s", "26.11.20261001.old", 1)), 0o600) os.WriteFile(filepath.Join(a.sys.upgradeDir(), "check.json"), []byte(strings.Replace(check, "%s", "26.11.20261001.old", 1)), 0o600)
if strings.Contains(page(), "A newer revision is available") { if strings.Contains(page(), "A newer revision is available") {
t.Fatal("an outdated check is shown") t.Fatal("an outdated check is shown")
+29 -3
View File
@@ -42,13 +42,13 @@ let
); );
# Only so the test driver can talk to the installed system; the channel is a # 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). # server on the client, 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 = { routerSettings.upgrade = {
channelsUrl = "file:///tmp/channels"; channelsUrl = "http://192.168.10.50:8080";
nixpkgsRef = "path:/var/lib/newer-nixpkgs?lastModified=${toString self.inputs.nixpkgs.lastModified}&rev=%rev%"; nixpkgsRef = "path:/var/lib/newer-nixpkgs?lastModified=${toString self.inputs.nixpkgs.lastModified}&rev=%rev%";
}; };
} }
@@ -63,6 +63,26 @@ let
modules = [ "${configDir}/local.nix" ]; modules = [ "${configDir}/local.nix" ];
}).config.system.build.toplevel; }).config.system.build.toplevel;
channelServer = pkgs.writeScript "channels.py" ''
#!${pkgs.python3}/bin/python3
import http.server
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory="/srv/channels", **kwargs)
def do_GET(self):
parts = self.path.strip("/").split("/")
if len(parts) == 2 and parts[1] == "git-revision":
self.send_response(302)
self.send_header("Location", "/files/" + self.path.strip("/"))
self.end_headers()
else:
super().do_GET()
http.server.ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
'';
sharedDisk = { sharedDisk = {
virtualisation.diskImage = "./target.qcow2"; virtualisation.diskImage = "./target.qcow2";
virtualisation.diskSize = 8192; virtualisation.diskSize = 8192;
@@ -99,6 +119,9 @@ pkgs.testers.runNixOSTest {
prefixLength = 24; prefixLength = 24;
} }
]; ];
# channels.nixos.org: <branch>/git-revision is a redirect to the file.
networking.firewall.allowedTCPPorts = [ 8080 ];
environment.etc."channels.py".source = channelServer;
}; };
target = { target = {
@@ -326,8 +349,11 @@ pkgs.testers.runNixOSTest {
running_version = target.succeed("cat /run/current-system/nixos-version") running_version = target.succeed("cat /run/current-system/nixos-version")
running_rev = "${self.inputs.nixpkgs.rev}" running_rev = "${self.inputs.nixpkgs.rev}"
client.succeed("mkdir -p /srv/channels/files/nixos-26.05 && systemd-run --unit=channels /etc/channels.py")
client.wait_for_open_port(8080)
def channel(rev): def channel(rev):
target.succeed(f"mkdir -p /tmp/channels/nixos-26.05 && printf %s {rev} > /tmp/channels/nixos-26.05/git-revision") client.succeed(f"printf %s {rev} > /srv/channels/files/nixos-26.05/git-revision")
with subtest("router-upgrade check needs a channel and a reachable revision"): with subtest("router-upgrade check needs a channel and a reachable revision"):
rc, out = target.execute("router-upgrade check 2>&1") rc, out = target.execute("router-upgrade check 2>&1")