From dd03127a24dbcd79fe7af17e001d4ad2be7c6f97 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 8 Nov 2026 22:27:39 +0100 Subject: [PATCH] Add the Packages page for nixpkgs upgrades A page under System for router-upgrade: the channel to follow, Check, Preview (the package versions that change, the download size, what would be built here, a reboot note for a new kernel), Download now and Upgrade. The steps run in router-upgrade.service, which the page asks through a request file; the last run's output is kept in upgrade/last.log because a finished one-shot unit has no journal to look up. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F --- SPECS.md | 4 +- modules/settings/upgrade.nix | 38 +++- pkgs/router-webui/main.go | 5 +- pkgs/router-webui/system.go | 2 + pkgs/router-webui/templates/layout.html | 1 + pkgs/router-webui/templates/upgrade.html | 99 +++++++++ pkgs/router-webui/update.go | 15 +- pkgs/router-webui/upgrade.go | 268 +++++++++++++++++++++++ pkgs/router-webui/upgrade_test.go | 204 +++++++++++++++++ tests/apply.nix | 28 +++ 10 files changed, 653 insertions(+), 11 deletions(-) create mode 100644 pkgs/router-webui/templates/upgrade.html create mode 100644 pkgs/router-webui/upgrade.go create mode 100644 pkgs/router-webui/upgrade_test.go diff --git a/SPECS.md b/SPECS.md index 635b0b2..bac9309 100644 --- a/SPECS.md +++ b/SPECS.md @@ -224,7 +224,7 @@ verity. The image module and its test (`checks.image`) stay as they are for anyo ## 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. +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. - **`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`. @@ -232,6 +232,7 @@ Upgrading nixpkgs (the kernel and every package) on the router, without a new re - **`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. +- **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. - **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. @@ -273,7 +274,6 @@ On the user's own router (settings and infrastructure, not code) 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/settings/upgrade.nix b/modules/settings/upgrade.nix index 7521dbc..7b00872 100644 --- a/modules/settings/upgrade.nix +++ b/modules/settings/upgrade.nix @@ -41,7 +41,7 @@ let ]; text = '' usage() { - echo "usage: router-upgrade check | preview [--force] | download | apply [timeout]" >&2 + echo "usage: router-upgrade check | preview [--force] | download | apply [timeout] | --request" >&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 @@ -50,6 +50,23 @@ let } die() { echo "router-upgrade: $*" >&2; exit 1; } [ $# -gt 0 ] || usage + if [ "$1" = --request ]; then + # What the web UI asked for (${dir}/request.json). + req=${dir}/request.json + [ -r "$req" ] || die "no request" + action=$(jq -er .action "$req") + force=$(jq -r 'if .force then "--force" else "" end' "$req") + timeout=$(jq -r '.timeout // ""' "$req") + rm -f "$req" + # The web UI shows the last run (a finished unit forgets its journal). + exec > >(tee ${dir}/last.log) 2>&1 + case $action in + preview) set -- preview ''${force:+"$force"} ;; + apply) set -- apply ''${timeout:+"$timeout"} ;; + check | download) set -- "$action" ;; + *) die "unknown request: $action" ;; + esac + fi cmd=$1 shift [ "$(id -u)" -eq 0 ] || die "must run as root" @@ -234,5 +251,22 @@ in }; }; - config.environment.systemPackages = [ upgrade ]; + config = { + environment.systemPackages = [ upgrade ]; + + # The web UI starts this rather than running anything itself, so a switch + # that restarts the UI does not cut an upgrade short; a switch must not + # restart or stop it either (router-apply is what runs the switch). + systemd.services.router-upgrade = { + description = "Look for, prepare or apply a nixpkgs upgrade"; + restartIfChanged = false; + stopIfChanged = false; + path = [ "/run/current-system/sw" ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${lib.getExe upgrade} --request"; + TimeoutStartSec = "2h"; + }; + }; + }; } diff --git a/pkgs/router-webui/main.go b/pkgs/router-webui/main.go index 90f98d3..e586fcf 100644 --- a/pkgs/router-webui/main.go +++ b/pkgs/router-webui/main.go @@ -270,7 +270,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app { func (a *app) parseTemplates() (map[string]*template.Template, error) { files := map[string][]string{"overview": {"templates/dashboard.html"}} pages := map[string]*template.Template{} - for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "firewall", "ddns", "dnsproviders", "certificate", "advanced", "update", "tokens", "changes"} { + for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "firewall", "ddns", "dnsproviders", "certificate", "advanced", "update", "upgrade", "tokens", "changes"} { t, err := template.New("layout.html").Funcs(a.funcs). ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...) if err != nil { @@ -340,6 +340,9 @@ func (a *app) routes() http.Handler { mux.HandleFunc("POST /update/check", a.auth(a.updateCheck)) mux.HandleFunc("POST /update/install", a.auth(a.updateInstall)) mux.HandleFunc("POST /update/upload", a.updateUpload) + mux.HandleFunc("GET /upgrade", a.auth(a.upgradePage)) + mux.HandleFunc("POST /upgrade/channel", a.auth(a.upgradeChannel)) + mux.HandleFunc("POST /upgrade/{step}", a.auth(a.upgradeStep)) mux.HandleFunc("/mcp", a.mcp) a.registerAPI(mux) mux.HandleFunc("GET /tokens", a.auth(a.tokensPage)) diff --git a/pkgs/router-webui/system.go b/pkgs/router-webui/system.go index 5f40d01..e0e9179 100644 --- a/pkgs/router-webui/system.go +++ b/pkgs/router-webui/system.go @@ -21,6 +21,8 @@ type System struct { StateDir string // /var/lib/router: secrets, UI state // ReleaseFile describes the installed nixos-router; /etc/router-release if empty. ReleaseFile string + // VersionFile holds the running NixOS version; /run/current-system/nixos-version if empty. + VersionFile string // LeasesDNSDir is where leases-dns leaves its status; /run/router-leases-dns if empty. LeasesDNSDir string ACMEDir string // where the ACME client keeps the web UI's certificate (fullchain.pem, key.pem), if any diff --git a/pkgs/router-webui/templates/layout.html b/pkgs/router-webui/templates/layout.html index 86c0abd..7888564 100644 --- a/pkgs/router-webui/templates/layout.html +++ b/pkgs/router-webui/templates/layout.html @@ -29,6 +29,7 @@ diff --git a/pkgs/router-webui/templates/upgrade.html b/pkgs/router-webui/templates/upgrade.html new file mode 100644 index 0000000..6200e08 --- /dev/null +++ b/pkgs/router-webui/templates/upgrade.html @@ -0,0 +1,99 @@ +{{define "content"}} +
+{{$d := .Data}} +
+

Packages

+

Upgrades the kernel and all programs (NixOS) without a new release of nixos-router. Nothing is downloaded until + you ask for it, and an upgrade is activated like a settings change: unless you confirm it in time, the router + goes back to the previous system.

+
+
Running
{{or $d.Version "unknown"}}
+
+
+ + + +
+

The channel only chooses what an upgrade offers; saving it changes nothing on the router. NixOS cannot go back to + an older release.

+
+ +{{if $d.Updating}} +

Updating…

The router is installing a new version of nixos-router.

+{{else if $d.Run.Running}} +

Working…

This page refreshes on its own. The web UI restarts when an upgrade is switched on.

+{{else if $d.Pending}} +

Waiting for confirmation

+

A new system is active and not confirmed yet. Confirm it on the Overview, or the router goes back to the + previous one.

+{{end}} + +{{if $d.Channel}} +
+

Check

+
+ + +
+ {{with $d.Check}} +
+
Channel
{{.Branch}}
+
Running
{{.Running.Version}}
+
Newest
{{printf "%.7s" .Newest.Rev}} checked {{.Time}}
+
+ {{if .UpToDate}}

Up to date.

{{else}}

A newer revision is available.

{{end}} + {{end}} +
+ +
+

What would change

+
+ + {{if and $d.Check $d.Check.UpToDate}}{{end}} + +
+

Works it out from the new revision's description: no package is downloaded.

+ {{with $d.Preview}} +

{{.Version}} {{printf "%.7s" .Rev}}, previewed {{.Time}}

+ {{if .Changes}} + + + {{range .Changes}}{{end}} +
PackageNowNew
{{.Name}}{{.From}}→{{.To}}
+ {{else}}

No package changes.

{{end}} +

The list shows the versions of programs the router runs; a program that is only rebuilt does not appear.

+
+
To download
{{or .Download "nothing, or the package cache cannot be reached"}}
+
Built here
{{.Builds}} derivation{{if ne .Builds 1}}s{{end}}{{if gt .Builds 50}} (many: the package cache may not have them yet; this takes long){{end}}
+ {{if .RebootNeeded}}
Kernel
A new kernel: it runs after the next reboot.
{{end}} +
+
+
+ + +
+
+ + Upgrade to {{.Version}} + +
+

Downloading first lets you start the upgrade later without needing the internet. Upgrading + downloads what is missing, builds the system and switches to it; confirm it on the Overview.

+ {{end}} +
+{{else}} +

Choose a channel to look for upgrades.

+{{end}} + +{{if $d.Run.Log}} +
+

Last run{{if $d.Run.Result}} — {{if eq $d.Run.Result "success"}}succeeded{{else}}failed ({{$d.Run.Result}}){{end}}{{end}}

+
{{$d.Run.Log}}
+
+{{end}} +
+{{end}} diff --git a/pkgs/router-webui/update.go b/pkgs/router-webui/update.go index 42aa25d..7a63834 100644 --- a/pkgs/router-webui/update.go +++ b/pkgs/router-webui/update.go @@ -220,25 +220,28 @@ var startUnit = func(unit string) error { return err } -// UpdateStatus is the state of the router-update unit. +// UpdateStatus is the state of a one-shot unit (router-update, router-upgrade) +// and what its last run logged. type UpdateStatus struct { Running bool Result string // of the last run; "" if none Log string } -func (s System) UpdateStatus() UpdateStatus { +func unitRun(unit string) UpdateStatus { st := UpdateStatus{} - st.Running = unitProperty("router-update.service", "ActiveState") == "activating" - if inv := unitProperty("router-update.service", "InvocationID"); inv != "" { + st.Running = unitProperty(unit, "ActiveState") == "activating" + if inv := unitProperty(unit, "InvocationID"); inv != "" { if !st.Running { - st.Result = unitProperty("router-update.service", "Result") + st.Result = unitProperty(unit, "Result") } st.Log, _ = command("journalctl", "--no-pager", "-o", "cat", "-n", "200", "_SYSTEMD_INVOCATION_ID="+inv) } return st } +func (s System) UpdateStatus() UpdateStatus { return unitRun("router-update.service") } + type updateData struct { Installed Release Status UpdateStatus @@ -273,7 +276,7 @@ func (a *app) updateBack(w http.ResponseWriter, r *http.Request, msg string) { func (a *app) busy() bool { st := a.sys.Status() - return st.Applying || st.ConfirmRunning || st.RollbackIn > 0 || a.sys.UpdateStatus().Running + return st.Applying || st.ConfirmRunning || st.RollbackIn > 0 || a.sys.UpdateStatus().Running || a.sys.UpgradeStatus().Running } func (a *app) updateSaveURL(w http.ResponseWriter, r *http.Request, _ session) { diff --git a/pkgs/router-webui/upgrade.go b/pkgs/router-webui/upgrade.go new file mode 100644 index 0000000..5c05dea --- /dev/null +++ b/pkgs/router-webui/upgrade.go @@ -0,0 +1,268 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "slices" + "strings" +) + +// The packages page: upgrading nixpkgs (the kernel and every package) without +// a new release of nixos-router. The work is done by router-upgrade (root, the +// systemd unit router-upgrade.service); the page asks for a step in +// upgrade/request.json and shows what the steps left in upgrade/: check.json, +// preview.json and the unit's log. The channel is the setting +// router.update.channel. + +func (s System) upgradeDir() string { return filepath.Join(s.StateDir, "upgrade") } + +func (s System) versionPath() string { + if s.VersionFile != "" { + return s.VersionFile + } + return "/run/current-system/nixos-version" +} + +// runningVersion is the NixOS version of the running system (26.11.20261027.31ecbac). +func (s System) runningVersion() string { + data, _ := os.ReadFile(s.versionPath()) + return strings.TrimSpace(string(data)) +} + +var ( + releaseRe = regexp.MustCompile(`^[0-9]+\.[0-9]+`) + channelRe = regexp.MustCompile(`^(unstable|[0-9]{2}\.[0-9]{2})$`) +) + +// releaseOf is the release (26.11) of a version like 26.11.20261027.31ecbac. +func releaseOf(version string) string { return releaseRe.FindString(version) } + +// UpgradeCheck is check.json: the channel's newest revision against the running one. +type UpgradeCheck struct { + Channel string `json:"channel"` + Branch string `json:"branch"` + Time string `json:"time"` + Running struct { + Rev string `json:"rev"` + Version string `json:"version"` + } `json:"running"` + Newest struct { + Rev string `json:"rev"` + } `json:"newest"` + UpToDate bool `json:"upToDate"` +} + +// UpgradeChange is a package that has another version in the new system. +type UpgradeChange struct { + Name string `json:"name"` + From string `json:"from"` + To string `json:"to"` +} + +// UpgradePreview is preview.json. +type UpgradePreview struct { + Channel string `json:"channel"` + Rev string `json:"rev"` + Version string `json:"version"` + Time string `json:"time"` + Download string `json:"download"` + Builds int `json:"builds"` + RebootNeeded bool `json:"rebootNeeded"` + Changes []UpgradeChange `json:"changes"` +} + +func readUpgradeFile(path string, v any) bool { + data, err := os.ReadFile(path) + return err == nil && json.Unmarshal(data, v) == nil +} + +// channel is the saved setting router.update.channel ("" for none). +func (s Settings) channel() string { return str(s, "router", "update", "channel") } + +// setChannel changes router.update.channel in settings.json and, since it takes +// effect when upgrading rather than when applying, in the confirmed settings +// too, so it does not show up as an unapplied change. +func (sys System) setChannel(channel string) error { + set := func(path string, required bool) error { + s, err := loadSettings(path) + if err != nil { + if !required && os.IsNotExist(err) { + return nil + } + return err + } + router := obj(s, "router") + if channel == "" { + if u, ok := router["update"].(map[string]any); ok { + delete(u, "channel") + if len(u) == 0 { + delete(router, "update") + } + } + } else { + obj(router, "update")["channel"] = channel + } + return s.save(path) + } + if err := set(sys.settingsPath(), true); err != nil { + return err + } + return set(sys.confirmedPath(), false) +} + +// UpgradeRun is the state of the router-upgrade unit. +type UpgradeRun = UpdateStatus + +func (s System) UpgradeStatus() UpgradeRun { return unitRun("router-upgrade.service") } + +// requestUpgrade asks the router-upgrade unit for one step. +func (s System) requestUpgrade(action string, force bool, timeout string) error { + data, _ := json.Marshal(map[string]any{"action": action, "force": force, "timeout": timeout}) + if err := os.MkdirAll(s.upgradeDir(), 0o700); err != nil { + return err + } + if err := writeFileAtomic(filepath.Join(s.upgradeDir(), "request.json"), data, 0o600); err != nil { + return err + } + return startUnit("router-upgrade.service") +} + +type channelChoice struct{ Value, Label string } + +type upgradeData struct { + Version string // the running NixOS version + Channel string + Choices []channelChoice + Check *UpgradeCheck + Preview *UpgradePreview + Run UpgradeRun + Updating bool // router-update is running + Pending bool // a system waits for confirmation +} + +// channelChoices are the branches worth offering: none (the packages that came +// with the release), unstable and the running release; NixOS cannot go back +// to an older one. A configured channel that is none of these stays listed. +func channelChoices(version, current string) []channelChoice { + choices := []channelChoice{{"", "None: the packages that came with this release of nixos-router"}, {"unstable", "unstable (newest, may break)"}} + if r := releaseOf(version); r != "" { + choices = append(choices, channelChoice{r, r + " (stable)"}) + } + if current != "" && !slices.ContainsFunc(choices, func(c channelChoice) bool { return c.Value == current }) { + choices = append(choices, channelChoice{current, current}) + } + return choices +} + +func (a *app) upgradeData() upgradeData { + d := upgradeData{Version: a.sys.runningVersion(), Run: a.sys.UpgradeStatus()} + d.Updating = a.sys.UpdateStatus().Running + d.Pending = a.sys.Status().RollbackIn > 0 + if s, err := loadSettings(a.sys.settingsPath()); err == nil { + d.Channel = s.channel() + } + d.Choices = channelChoices(d.Version, d.Channel) + // 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 { + d.Run.Log = tail(string(data), 64<<10) + } + var c UpgradeCheck + // What was checked before another channel was chosen or before the last + // upgrade says nothing about now. + if readUpgradeFile(filepath.Join(a.sys.upgradeDir(), "check.json"), &c) && c.Channel == d.Channel && c.Running.Version == d.Version { + d.Check = &c + } + var p UpgradePreview + if readUpgradeFile(filepath.Join(a.sys.upgradeDir(), "preview.json"), &p) && p.Channel == d.Channel { + d.Preview = &p + } + return d +} + +func (a *app) upgradePage(w http.ResponseWriter, r *http.Request, sess session) { + d := a.upgradeData() + p := page{Title: "Packages", CSRF: sess.csrf, LoggedIn: true, Data: d, Flash: r.URL.Query().Get("msg")} + if d.Run.Running { + p.Refresh = 3 + } + a.render(w, "upgrade", p) +} + +func (a *app) upgradeBack(w http.ResponseWriter, r *http.Request, msg string) { + http.Redirect(w, r, "/upgrade?msg="+url.QueryEscape(msg), http.StatusSeeOther) +} + +func (a *app) upgradeChannel(w http.ResponseWriter, r *http.Request, _ session) { + v := strings.TrimSpace(r.PostFormValue("channel")) + if !r.PostForm.Has("channel") { + a.upgradeBack(w, r, "Not saved: no channel was sent.") + return + } + if v != "" && !channelRe.MatchString(v) { + a.upgradeBack(w, r, "Not saved: the channel is unstable or a release like 26.11.") + return + } + if err := a.sys.setChannel(v); err != nil { + a.upgradeBack(w, r, "Not saved: "+err.Error()) + return + } + a.upgradeBack(w, r, "Saved.") +} + +var upgradeSteps = map[string]string{ + "check": "Checking.", + "preview": "Working out what would change.", + "download": "Downloading.", + "apply": "Upgrading.", +} + +// upgradeStep starts one step of router-upgrade. +func (a *app) upgradeStep(w http.ResponseWriter, r *http.Request, _ session) { + action := r.PathValue("step") + msg, ok := upgradeSteps[action] + if !ok { + http.NotFound(w, r) + return + } + // Looking is harmless beside a change that waits for confirmation; + // changing the system or filling the disk is not. + if a.sys.UpgradeStatus().Running || a.sys.UpdateStatus().Running { + a.upgradeBack(w, r, "Another upgrade or update is running.") + return + } + if (action == "download" || action == "apply") && a.busy() { + a.upgradeBack(w, r, "Another change is in progress or waiting for confirmation.") + return + } + d := a.upgradeData() + if d.Channel == "" { + a.upgradeBack(w, r, "Choose a channel first.") + return + } + if (action == "download" || action == "apply") && d.Preview == nil { + a.upgradeBack(w, r, "Preview first: it says what would change.") + return + } + if err := a.sys.requestUpgrade(action, r.PostFormValue("force") == "on", ""); err != nil { + a.upgradeBack(w, r, fmt.Sprintf("Could not start it: %v", err)) + return + } + a.upgradeBack(w, r, msg) +} + +// tail is the last n bytes of s, from a line start. +func tail(s string, n int) string { + if len(s) <= n { + return s + } + s = s[len(s)-n:] + if i := strings.IndexByte(s, '\n'); i >= 0 { + s = s[i+1:] + } + return s +} diff --git a/pkgs/router-webui/upgrade_test.go b/pkgs/router-webui/upgrade_test.go new file mode 100644 index 0000000..6e97ddb --- /dev/null +++ b/pkgs/router-webui/upgrade_test.go @@ -0,0 +1,204 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReleaseOfAndChannelChoices(t *testing.T) { + if got := releaseOf("26.11.20261027.31ecbac"); got != "26.11" { + t.Fatalf("releaseOf: %q", got) + } + if got := releaseOf("26.11pre-git"); got != "26.11" { + t.Fatalf("releaseOf: %q", got) + } + values := func(cs []channelChoice) string { + var v []string + for _, c := range cs { + v = append(v, c.Value) + } + return strings.Join(v, ",") + } + if got := values(channelChoices("26.11.2026", "")); got != ",unstable,26.11" { + t.Fatalf("choices: %q", got) + } + // 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" { + t.Fatalf("choices: %q", got) + } + if got := values(channelChoices("26.11.2026", "unstable")); got != ",unstable,26.11" { + t.Fatalf("choices: %q", got) + } +} + +func TestSetChannelKeepsTheRest(t *testing.T) { + a, sys := newTestApp(t) + os.WriteFile(sys.confirmedPath(), []byte(sample), 0o600) + if err := a.sys.setChannel("26.11"); err != nil { + t.Fatal(err) + } + for _, path := range []string{sys.settingsPath(), sys.confirmedPath()} { + s, err := loadSettings(path) + if err != nil || s.channel() != "26.11" { + t.Fatalf("%s: %v %q", path, err, s.channel()) + } + if str(s, "router", "wan", "interface") == "" { + t.Fatalf("%s lost its other settings", path) + } + } + if st := a.sys.Status(); st.Unapplied { + t.Fatal("the channel shows up as an unapplied change") + } + if err := a.sys.setChannel(""); err != nil { + t.Fatal(err) + } + s, _ := loadSettings(sys.settingsPath()) + if _, ok := obj(s, "router")["update"]; ok { + t.Fatalf("an empty update object is left behind: %v", s["router"]) + } +} + +func TestUpgradePage(t *testing.T) { + a, sys := newTestApp(t) + a.sys.VersionFile = filepath.Join(t.TempDir(), "nixos-version") + os.WriteFile(a.sys.VersionFile, []byte("26.11.20261027.31ecbac\n"), 0o600) + var started int + orig := startUnit + startUnit = func(unit string) error { + if unit != "router-upgrade.service" { + t.Errorf("started %s", unit) + } + started++ + return nil + } + defer func() { startUnit = orig }() + h := a.routes() + cookie := login(t, h) + page := func() string { return do(h, "GET", "/upgrade", nil, cookie).Body.String() } + post := func(path string, form url.Values) *http.Response { + form.Set("csrf", csrfFrom(t, page())) + return do(h, "POST", path, form, cookie).Result() + } + request := func() map[string]any { + var v map[string]any + data, err := os.ReadFile(filepath.Join(a.sys.upgradeDir(), "request.json")) + if err != nil { + return nil + } + json.Unmarshal(data, &v) + os.Remove(filepath.Join(a.sys.upgradeDir(), "request.json")) + return v + } + + body := page() + if !strings.Contains(body, "26.11.20261027.31ecbac") || !strings.Contains(body, "Choose a channel") { + t.Fatalf("no channel yet:\n%s", body) + } + if rec := do(h, "GET", "/upgrade", nil, nil); rec.Code != http.StatusSeeOther { + t.Fatalf("without a session: %d", rec.Code) + } + + t.Run("steps need a channel", func(t *testing.T) { + resp := post("/upgrade/check", url.Values{}) + if !strings.Contains(resp.Header.Get("Location"), "Choose+a+channel") || started != 0 { + t.Fatalf("%s started %d", resp.Header.Get("Location"), started) + } + }) + + t.Run("the channel is validated and saved", func(t *testing.T) { + resp := post("/upgrade/channel", url.Values{"channel": {"nixos-26.11; rm -rf /"}}) + if !strings.Contains(resp.Header.Get("Location"), "Not+saved") { + t.Fatalf("%s", resp.Header.Get("Location")) + } + // No field at all must not clear it either. + if resp := post("/upgrade/channel", url.Values{}); !strings.Contains(resp.Header.Get("Location"), "no+channel") { + t.Fatalf("%s", resp.Header.Get("Location")) + } + post("/upgrade/channel", url.Values{"channel": {"26.11"}}) + post("/upgrade/channel", url.Values{}) + s, _ := loadSettings(sys.settingsPath()) + if s.channel() != "26.11" { + t.Fatalf("channel %q", s.channel()) + } + if !strings.Contains(page(), `value="26.11" selected`) { + t.Fatal("the saved channel is not selected") + } + }) + + t.Run("a step is requested and an unknown one refused", func(t *testing.T) { + post("/upgrade/check", url.Values{}) + if r := request(); r["action"] != "check" || started != 1 { + t.Fatalf("%v started %d", r, started) + } + post("/upgrade/preview", url.Values{"force": {"on"}}) + if r := request(); r["action"] != "preview" || r["force"] != true { + t.Fatalf("%v", r) + } + if resp := post("/upgrade/format-disk", url.Values{}); resp.StatusCode != http.StatusNotFound { + t.Fatalf("unknown step: %d", resp.StatusCode) + } + if request() != nil { + t.Fatal("an unknown step left a request") + } + }) + + t.Run("download and apply wait for a preview", func(t *testing.T) { + before := started + for _, step := range []string{"download", "apply"} { + resp := post("/upgrade/"+step, url.Values{}) + if !strings.Contains(resp.Header.Get("Location"), "Preview+first") || request() != nil { + t.Fatalf("%s: %s", step, resp.Header.Get("Location")) + } + } + if started != before { + t.Fatal("the unit was started without a preview") + } + 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) + 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"} { + if !strings.Contains(body, want) { + t.Errorf("the preview does not show %q", want) + } + } + post("/upgrade/apply", url.Values{}) + if r := request(); r["action"] != "apply" { + t.Fatalf("%v", r) + } + }) + + t.Run("the last run's output is shown", func(t *testing.T) { + os.WriteFile(filepath.Join(a.sys.upgradeDir(), "last.log"), []byte("channel 26.11 (nixos-26.11)\nup to date\n"), 0o600) + if body := page(); !strings.Contains(body, "Last run") || !strings.Contains(body, "nixos-26.11)") { + t.Fatalf("no log:\n%s", body) + } + if got := tail("a\nbb\nccc\n", 6); got != "ccc\n" { + t.Fatalf("tail: %q", got) + } + }) + + t.Run("a preview of another channel is not shown", func(t *testing.T) { + post("/upgrade/channel", url.Values{"channel": {"unstable"}}) + if strings.Contains(page(), "linux") { + t.Fatal("the preview of 26.11 is shown for unstable") + } + }) + + t.Run("a check from before the last upgrade is not shown", func(t *testing.T) { + post("/upgrade/channel", url.Values{"channel": {"26.11"}}) + check := `{"channel":"26.11","branch":"nixos-26.11","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) + if strings.Contains(page(), "A newer revision is available") { + t.Fatal("an outdated check is shown") + } + os.WriteFile(filepath.Join(a.sys.upgradeDir(), "check.json"), []byte(strings.Replace(check, "%s", "26.11.20261027.31ecbac", 1)), 0o600) + if body := page(); !strings.Contains(body, "A newer revision is available") || !strings.Contains(body, "0123456") { + t.Fatalf("the current check is not shown:\n%s", body) + } + }) +} diff --git a/tests/apply.nix b/tests/apply.nix index d570a95..18ac8e0 100644 --- a/tests/apply.nix +++ b/tests/apply.nix @@ -416,5 +416,33 @@ pkgs.testers.runNixOSTest { 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 + + with subtest("the packages page drives the same steps"): + channel(running_rev) + + def unit_done(unit): + target.wait_until_succeeds(f"test $(systemctl show -p ActiveState --value {unit}) = inactive" + f" && test $(systemctl show -p Result --value {unit}) = success", timeout=300) + + def step(name, extra=""): + code = curl(f"-o /dev/null -w '%{{http_code}}' -d csrf={csrf('/upgrade')} {extra} {ui}/upgrade/{name}") + assert code == "303", (name, code) + unit_done("router-upgrade.service") + + page = curl(f"{ui}/upgrade") + assert "Packages" in page and 'value="26.05" selected' in page and target.succeed("cat /run/current-system/nixos-version").strip() in page, page + assert curl(f"-o /dev/null -w '%{{http_code}}' -d csrf={csrf('/upgrade')} -d 'channel=nixos-26.05%3Bx' {ui}/upgrade/channel") == "303" + assert "26.05" in target.succeed("jq -r .router.update.channel /var/lib/router/config/settings.json") + assert curl(f"-o /dev/null -w '%{{http_code}}' -d csrf=wrong {ui}/upgrade/check") == "403" + step("check") + assert "Up to date." in curl(f"{ui}/upgrade") + step("preview", "-d force=on") + page = curl(f"{ui}/upgrade") + assert "No package changes" in page and "Upgrade to" in page, page + step("download") + step("apply") + assert "Upgrade to" not in curl(f"{ui}/upgrade") + assert json.loads(target.succeed("cat /var/lib/router/config/nixpkgs-pin.json"))["rev"] == running_rev + assert "Last run" in curl(f"{ui}/upgrade") ''; }