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
+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) {
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))
+2
View File
@@ -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
+1
View File
@@ -29,6 +29,7 @@
</div>
<div class="group"><span>System</span>
<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="/advanced"{{if eq .Title "Advanced"}} aria-current="page"{{end}}>Advanced</a>
</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
}
// 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) {
+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)
}
})
}