package main import ( "net/http" "sync" "time" ) // Instance-side update awareness. The instance never applies updates itself — // it runs sandboxed and can't restart anything; the box updater (`lchat update // run`, root) does the staging and swapping. The instance's two jobs here: // // - Tell the box updater what the admin chose: GET /api/update-prefs (read // over the instance's unix socket, like the deploy health checks). Un-authed // by design — it reveals less than the already-public /api/version build id, // and an old binary 404ing means "defaults" (auto-update on, stable). // - Know whether its channel has something newer, so the admin console can // say "a new version is available" — a periodic fetch+verify of the signed // release manifest (release.go), surfaced through GET /api/admin/settings. // A fetch failure just leaves the last result stale; nothing acts on it. // updateState is the last verified manifest fetch, guarded by mu — written by // the checker goroutine, read by getSettings. type updateState struct { mu sync.Mutex manifest *releaseManifest checkedAt time.Time } // getUpdatePrefs reports the admin's update settings to the box updater. func (s *Server) getUpdatePrefs(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "auto_update": s.db.updateAuto(), "auto_download": s.db.updateDownload(), "channel": s.db.updateChannel(), "pin": s.db.updatePin(), "major": s.db.updateMajor(), }) } // runUpdateChecker refreshes the availability info: once shortly after boot // (so a fresh admin console isn't blank for hours), then every 6h. The box // updater runs its own checks; this one is display-only, so failures are // logged by the caller of checkForUpdate and otherwise ignored. func (s *Server) runUpdateChecker() { time.Sleep(time.Minute) for { _ = s.checkForUpdate() time.Sleep(6 * time.Hour) } } // checkForUpdate fetches and verifies the release manifest (the same fetch // the box updater runs — fetchReleaseManifest, updater.go), keeping the whole // channel map — so an admin switching channels sees that channel's version // immediately, without waiting for the next fetch. func (s *Server) checkForUpdate() error { m, err := fetchReleaseManifest(releaseManifestURL) if err != nil { return err } s.upd.mu.Lock() s.upd.manifest, s.upd.checkedAt = m, time.Now() s.upd.mu.Unlock() // A changed channel head alerts the space's admins (releasealert.go) — // the detection half of the supply-chain bake window. s.noteReleaseSightings(m) return nil } // updateInfo is the availability block getSettings serves the admin consoles: // what we run, what the tracked channel offers, and how fresh that knowledge is. func (s *Server) updateInfo() map[string]any { info := map[string]any{"build": buildVersion} s.upd.mu.Lock() m, checkedAt := s.upd.manifest, s.upd.checkedAt s.upd.mu.Unlock() if m == nil { return info // no successful check yet — the console shows just the build } info["checked_at"] = checkedAt.Unix() if e, ok := m.Channels[s.db.updateChannel()]; ok { info["available_version"] = e.Version info["available_notes"] = e.Notes if e.Semver != "" { info["available_semver"] = e.Semver } // A major pin holding this head back is worth explaining in the console // — same reason bake_hours below is served. if hold := majorHold(s.db.updateMajor(), e); hold != "" { info["major_held"] = true } // The channel's supply-chain bake window (bake.go), so the console can // explain why a fresh release hasn't auto-installed yet. The instance // can't see the box's bake clock — only the policy — so this is an // "up to N hours" statement, never a countdown. if h := bakeHours(s.db.updateChannel()); h > 0 { info["bake_hours"] = h } } return info }