Files
nixos-router/pkgs/router-webui/upgrade.go
T
nilsandClaude Sonnet 5.5 dd03127a24 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
2026-11-08 22:27:39 +01:00

269 lines
8.4 KiB
Go

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
}