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 }