channels.nixos.org answers <branch>/git-revision with a redirect, which router-upgrade check did not follow: on a real router it saw an empty answer. The test channel is now an HTTP server that redirects the same way. The page labelled the running release "stable", but nixos-unstable already calls itself the next release (26.11 does not exist as a branch yet). It offers none, unstable and the stable releases listed in the code (26.05). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
275 lines
8.7 KiB
Go
275 lines
8.7 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
|
|
}
|
|
|
|
// stableChannels are the stable NixOS releases the page offers (the branch
|
|
// nixos-26.05 and so on). They are listed here until the router can ask the
|
|
// channel server which exist; the release a router runs says nothing about it:
|
|
// nixos-unstable already calls itself the release that comes next.
|
|
var stableChannels = []string{"26.05"}
|
|
|
|
// channelChoices are the branches worth offering: none (the packages that came
|
|
// with the release), unstable and the stable releases. A configured channel
|
|
// that is none of these stays listed.
|
|
func channelChoices(current string) []channelChoice {
|
|
choices := []channelChoice{{"", "None: the packages that came with this release of nixos-router"}, {"unstable", "unstable (newest, may break)"}}
|
|
for _, r := range stableChannels {
|
|
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.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.05.")
|
|
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
|
|
}
|