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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-11-08 22:27:39 +01:00
co-authored by Claude Sonnet 5.5
parent 4ac8c6ea6f
commit dd03127a24
10 changed files with 653 additions and 11 deletions
+2 -2
View File
@@ -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) ## 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. - **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` (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`. - **`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.
- **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 `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". 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) 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. - **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. - **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). - **Reset**: go back to the nixpkgs that came with the release (`channel = null` and remove the pin).
+36 -2
View File
@@ -41,7 +41,7 @@ let
]; ];
text = '' text = ''
usage() { 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 " 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 " 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 " download fetch and build what was previewed, without activating it" >&2
@@ -50,6 +50,23 @@ let
} }
die() { echo "router-upgrade: $*" >&2; exit 1; } die() { echo "router-upgrade: $*" >&2; exit 1; }
[ $# -gt 0 ] || usage [ $# -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 cmd=$1
shift shift
[ "$(id -u)" -eq 0 ] || die "must run as root" [ "$(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";
};
};
};
} }
+4 -1
View File
@@ -270,7 +270,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app {
func (a *app) parseTemplates() (map[string]*template.Template, error) { func (a *app) parseTemplates() (map[string]*template.Template, error) {
files := map[string][]string{"overview": {"templates/dashboard.html"}} files := map[string][]string{"overview": {"templates/dashboard.html"}}
pages := map[string]*template.Template{} 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). t, err := template.New("layout.html").Funcs(a.funcs).
ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...) ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
if err != nil { 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/check", a.auth(a.updateCheck))
mux.HandleFunc("POST /update/install", a.auth(a.updateInstall)) mux.HandleFunc("POST /update/install", a.auth(a.updateInstall))
mux.HandleFunc("POST /update/upload", a.updateUpload) 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) mux.HandleFunc("/mcp", a.mcp)
a.registerAPI(mux) a.registerAPI(mux)
mux.HandleFunc("GET /tokens", a.auth(a.tokensPage)) mux.HandleFunc("GET /tokens", a.auth(a.tokensPage))
+2
View File
@@ -21,6 +21,8 @@ type System struct {
StateDir string // /var/lib/router: secrets, UI state StateDir string // /var/lib/router: secrets, UI state
// ReleaseFile describes the installed nixos-router; /etc/router-release if empty. // ReleaseFile describes the installed nixos-router; /etc/router-release if empty.
ReleaseFile string 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 is where leases-dns leaves its status; /run/router-leases-dns if empty.
LeasesDNSDir string LeasesDNSDir string
ACMEDir string // where the ACME client keeps the web UI's certificate (fullchain.pem, key.pem), if any ACMEDir string // where the ACME client keeps the web UI's certificate (fullchain.pem, key.pem), if any
+1
View File
@@ -29,6 +29,7 @@
</div> </div>
<div class="group"><span>System</span> <div class="group"><span>System</span>
<a href="/update"{{if eq .Title "Update"}} aria-current="page"{{end}}>Update</a> <a href="/update"{{if eq .Title "Update"}} aria-current="page"{{end}}>Update</a>
<a href="/upgrade"{{if eq .Title "Packages"}} aria-current="page"{{end}}>Packages</a>
<a href="/tokens"{{if eq .Title "API access"}} aria-current="page"{{end}}>API access</a> <a href="/tokens"{{if eq .Title "API access"}} aria-current="page"{{end}}>API access</a>
<a href="/advanced"{{if eq .Title "Advanced"}} aria-current="page"{{end}}>Advanced</a> <a href="/advanced"{{if eq .Title "Advanced"}} aria-current="page"{{end}}>Advanced</a>
</div> </div>
+99
View File
@@ -0,0 +1,99 @@
{{define "content"}}
<div class="cols">
{{$d := .Data}}
<section>
<h2>Packages</h2>
<p>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.</p>
<dl>
<dt>Running</dt><dd>{{or $d.Version "unknown"}}</dd>
</dl>
<form method="post" action="/upgrade/channel" class="actions">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Follow
<select name="channel">
{{range $d.Choices}}<option value="{{.Value}}"{{if eq .Value $d.Channel}} selected{{end}}>{{.Label}}</option>{{end}}
</select>
</label>
<button class="secondary">Save</button>
</form>
<p class="hint">The channel only chooses what an upgrade offers; saving it changes nothing on the router. NixOS cannot go back to
an older release.</p>
</section>
{{if $d.Updating}}
<section class="busy"><h2>Updating…</h2><p>The router is installing a new version of nixos-router.</p></section>
{{else if $d.Run.Running}}
<section class="busy"><h2>Working…</h2><p>This page refreshes on its own. The web UI restarts when an upgrade is switched on.</p></section>
{{else if $d.Pending}}
<section class="pending"><h2>Waiting for confirmation</h2>
<p>A new system is active and not confirmed yet. Confirm it on the <a href="/">Overview</a>, or the router goes back to the
previous one.</p></section>
{{end}}
{{if $d.Channel}}
<section>
<h2>Check</h2>
<form method="post" action="/upgrade/check" class="actions">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="secondary"{{if or $d.Run.Running $d.Updating}} disabled{{end}}>Check for upgrades</button>
</form>
{{with $d.Check}}
<dl>
<dt>Channel</dt><dd>{{.Branch}}</dd>
<dt>Running</dt><dd>{{.Running.Version}}</dd>
<dt>Newest</dt><dd>{{printf "%.7s" .Newest.Rev}} <span class="muted">checked {{.Time}}</span></dd>
</dl>
{{if .UpToDate}}<p>Up to date.</p>{{else}}<p>A newer revision is available.</p>{{end}}
{{end}}
</section>
<section>
<h2>What would change</h2>
<form method="post" action="/upgrade/preview" class="actions">
<input type="hidden" name="csrf" value="{{.CSRF}}">
{{if and $d.Check $d.Check.UpToDate}}<label><input type="checkbox" name="force"> look anyway</label>{{end}}
<button class="secondary"{{if or $d.Run.Running $d.Updating}} disabled{{end}}>Preview</button>
</form>
<p class="hint">Works it out from the new revision's description: no package is downloaded.</p>
{{with $d.Preview}}
<p><strong>{{.Version}}</strong> <span class="muted">{{printf "%.7s" .Rev}}, previewed {{.Time}}</span></p>
{{if .Changes}}
<table>
<thead><tr><th>Package</th><th>Now</th><th></th><th>New</th></tr></thead>
<tbody>{{range .Changes}}<tr><td>{{.Name}}</td><td>{{.From}}</td><td>→</td><td>{{.To}}</td></tr>{{end}}</tbody>
</table>
{{else}}<p>No package changes.</p>{{end}}
<p class="hint">The list shows the versions of programs the router runs; a program that is only rebuilt does not appear.</p>
<dl>
<dt>To download</dt><dd>{{or .Download "nothing, or the package cache cannot be reached"}}</dd>
<dt>Built here</dt><dd>{{.Builds}} derivation{{if ne .Builds 1}}s{{end}}{{if gt .Builds 50}} <span class="muted">(many: the package cache may not have them yet; this takes long)</span>{{end}}</dd>
{{if .RebootNeeded}}<dt>Kernel</dt><dd>A new kernel: it runs after the next reboot.</dd>{{end}}
</dl>
<div class="actions">
<form method="post" action="/upgrade/download">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="secondary"{{if or $d.Run.Running $d.Updating $d.Pending}} disabled{{end}}>Download now</button>
</form>
<form method="post" action="/upgrade/apply">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button{{if or $d.Run.Running $d.Updating $d.Pending}} disabled{{end}}>Upgrade to {{.Version}}</button>
</form>
</div>
<p class="hint">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.</p>
{{end}}
</section>
{{else}}
<section><p>Choose a channel to look for upgrades.</p></section>
{{end}}
{{if $d.Run.Log}}
<section>
<h2>Last run{{if $d.Run.Result}} — {{if eq $d.Run.Result "success"}}succeeded{{else}}failed ({{$d.Run.Result}}){{end}}{{end}}</h2>
<pre class="log">{{$d.Run.Log}}</pre>
</section>
{{end}}
</div>
{{end}}
+9 -6
View File
@@ -220,25 +220,28 @@ var startUnit = func(unit string) error {
return err 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 { type UpdateStatus struct {
Running bool Running bool
Result string // of the last run; "" if none Result string // of the last run; "" if none
Log string Log string
} }
func (s System) UpdateStatus() UpdateStatus { func unitRun(unit string) UpdateStatus {
st := UpdateStatus{} st := UpdateStatus{}
st.Running = unitProperty("router-update.service", "ActiveState") == "activating" st.Running = unitProperty(unit, "ActiveState") == "activating"
if inv := unitProperty("router-update.service", "InvocationID"); inv != "" { if inv := unitProperty(unit, "InvocationID"); inv != "" {
if !st.Running { 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) st.Log, _ = command("journalctl", "--no-pager", "-o", "cat", "-n", "200", "_SYSTEMD_INVOCATION_ID="+inv)
} }
return st return st
} }
func (s System) UpdateStatus() UpdateStatus { return unitRun("router-update.service") }
type updateData struct { type updateData struct {
Installed Release Installed Release
Status UpdateStatus Status UpdateStatus
@@ -273,7 +276,7 @@ func (a *app) updateBack(w http.ResponseWriter, r *http.Request, msg string) {
func (a *app) busy() bool { func (a *app) busy() bool {
st := a.sys.Status() 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) { func (a *app) updateSaveURL(w http.ResponseWriter, r *http.Request, _ session) {
+268
View File
@@ -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
}
+204
View File
@@ -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)
}
})
}
+28
View File
@@ -416,5 +416,33 @@ pkgs.testers.runNixOSTest {
target.succeed("test ! -e /var/lib/router/config.pre-update") target.succeed("test ! -e /var/lib/router/config.pre-update")
target.succeed("cp /tmp/settings.good /var/lib/router/config/settings.json") target.succeed("cp /tmp/settings.good /var/lib/router/config/settings.json")
assert current() == upgraded 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")
''; '';
} }