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:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user