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:
@@ -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