A new selfupdate package asks one URL what the newest build is — a Gitea/Forgejo repo API base, or any directory holding VERSION, SHA256SUMS and hamlab-linux-<arch>, which is what make release already writes and what install.sh already downloads. The binary is checked against SHA256SUMS before it goes near the running one, and a token is sent when the source is behind a login, so a private Gitea serves its own fleet without opening the instance to everyone. Version ordering is install.sh's vnum rule, so the daemon and the installer never disagree about which build is newer. Settings gains an Updates card: installed, published, when it last looked, the source, release notes, Check for updates, and Install. The rail badges Settings when a newer build is out. Nothing installs unasked — it restarts the daemon and drops terminals. scripts/gitea-release.sh (make gitea-release) publishes dist/ as a Gitea release. Deliberate, and not part of deploy: deploy pushes to the machines this hub owns, publishing offers the build to every install anywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
218 lines
6.0 KiB
Go
218 lines
6.0 KiB
Go
package api
|
|
|
|
// Updating Ham Lab itself: is there a newer build published, and install it on request.
|
|
// The check is a plain HTTP GET against whatever `update.url` names — this project's Gitea
|
|
// releases by default — so a fleet, a stranger's install and an air-gapped copy pointed at
|
|
// a directory on a NAS all work the same way. Nothing is ever installed unasked: an update
|
|
// restarts the daemon and drops open terminals, which is not a thing to do to somebody.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"hamlab/internal/config"
|
|
"hamlab/internal/selfupdate"
|
|
)
|
|
|
|
type updateState struct {
|
|
mu sync.Mutex
|
|
checking bool
|
|
installing bool
|
|
checkedAt time.Time
|
|
latest selfupdate.Release
|
|
err string
|
|
}
|
|
|
|
// source is the update source as configured, and whether looking is switched off.
|
|
func (s *Server) source() (selfupdate.Source, bool) {
|
|
var u config.UpdateSource
|
|
s.cfg.View(func(c *config.Config) { u = c.Updates })
|
|
url := strings.TrimSpace(u.URL)
|
|
if url == "" {
|
|
url = selfupdate.DefaultURL
|
|
}
|
|
if strings.EqualFold(url, "off") || strings.EqualFold(url, "none") {
|
|
return selfupdate.Source{}, false
|
|
}
|
|
return selfupdate.Source{URL: url, Token: u.Token}, true
|
|
}
|
|
|
|
func (s *Server) checksEnabled() bool {
|
|
var u config.UpdateSource
|
|
s.cfg.View(func(c *config.Config) { u = c.Updates })
|
|
if strings.EqualFold(u.Check, "never") {
|
|
return false
|
|
}
|
|
_, on := s.source()
|
|
return on
|
|
}
|
|
|
|
// Behind reports whether a newer build than this one has been seen, and which.
|
|
func (s *Server) behind() (string, bool) {
|
|
s.update.mu.Lock()
|
|
defer s.update.mu.Unlock()
|
|
v := s.update.latest.Version
|
|
if v == "" || selfupdate.Compare(s.version, v) >= 0 {
|
|
return "", false
|
|
}
|
|
return v, true
|
|
}
|
|
|
|
// checkNow asks the source what the newest build is. One at a time.
|
|
func (s *Server) checkNow(ctx context.Context) {
|
|
src, on := s.source()
|
|
if !on {
|
|
return
|
|
}
|
|
s.update.mu.Lock()
|
|
if s.update.checking {
|
|
s.update.mu.Unlock()
|
|
return
|
|
}
|
|
s.update.checking = true
|
|
s.update.mu.Unlock()
|
|
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
|
defer cancel()
|
|
rel, err := src.Latest(ctx, runtime.GOARCH)
|
|
s.update.mu.Lock()
|
|
s.update.checking = false
|
|
s.update.checkedAt = time.Now()
|
|
if err != nil {
|
|
s.update.err = err.Error()
|
|
} else {
|
|
s.update.err, s.update.latest = "", rel
|
|
}
|
|
s.update.mu.Unlock()
|
|
if err != nil {
|
|
log.Printf("update check: %v", err)
|
|
return
|
|
}
|
|
if selfupdate.Compare(s.version, rel.Version) < 0 {
|
|
log.Printf("update check: %s is published; this is %s", rel.Version, s.version)
|
|
}
|
|
}
|
|
|
|
// watchForUpdates asks once shortly after boot and then daily.
|
|
func (s *Server) watchForUpdates(ctx context.Context) {
|
|
t := time.NewTimer(90 * time.Second)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
}
|
|
if s.checksEnabled() {
|
|
s.checkNow(ctx)
|
|
}
|
|
t.Reset(selfupdate.DefaultCheckEvery)
|
|
}
|
|
}
|
|
|
|
func (s *Server) updateStatus(w http.ResponseWriter, _ *http.Request) {
|
|
src, on := s.source()
|
|
s.update.mu.Lock()
|
|
defer s.update.mu.Unlock()
|
|
latest := s.update.latest.Version
|
|
out := map[string]any{
|
|
"current": s.version, "latest": latest, "arch": runtime.GOARCH,
|
|
"behind": latest != "" && selfupdate.Compare(s.version, latest) < 0,
|
|
"checking": s.update.checking,
|
|
"installing": s.update.installing,
|
|
"enabled": on, "auto": s.checksEnabled(), "url": src.URL,
|
|
"notes": s.update.latest.Notes, "published": s.update.latest.Published,
|
|
}
|
|
if !s.update.checkedAt.IsZero() {
|
|
out["checked"] = s.update.checkedAt
|
|
}
|
|
if s.update.err != "" {
|
|
out["error"] = s.update.err
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
jsonOK(w, 200, out)
|
|
}
|
|
|
|
func (s *Server) updateCheck(w http.ResponseWriter, r *http.Request) {
|
|
if _, on := s.source(); !on {
|
|
jsonErr(w, 400, "update checks are switched off (update.url is \"off\")")
|
|
return
|
|
}
|
|
s.checkNow(r.Context())
|
|
s.updateStatus(w, r)
|
|
}
|
|
|
|
// updateInstall fetches the published build for this machine and puts it in place.
|
|
func (s *Server) updateInstall(w http.ResponseWriter, r *http.Request) {
|
|
src, on := s.source()
|
|
if !on {
|
|
jsonErr(w, 400, "update checks are switched off (update.url is \"off\")")
|
|
return
|
|
}
|
|
force := r.URL.Query().Get("force") == "1"
|
|
if err := s.updateAllowed(force); err != nil {
|
|
jsonErr(w, 409, err.Error())
|
|
return
|
|
}
|
|
var body struct {
|
|
Version string `json:"version"` // optional: refuse if what is published is not this
|
|
}
|
|
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
|
|
|
|
s.update.mu.Lock()
|
|
if s.update.installing {
|
|
s.update.mu.Unlock()
|
|
jsonErr(w, 409, "an update is already being installed")
|
|
return
|
|
}
|
|
rel := s.update.latest
|
|
s.update.installing = true
|
|
s.update.mu.Unlock()
|
|
done := func() { s.update.mu.Lock(); s.update.installing = false; s.update.mu.Unlock() }
|
|
|
|
ctx := context.WithoutCancel(r.Context()) // a closed browser tab must not abort an install
|
|
if rel.Version == "" || body.Version != "" && body.Version != rel.Version {
|
|
var err error
|
|
if rel, err = src.Latest(ctx, runtime.GOARCH); err != nil {
|
|
done()
|
|
jsonErr(w, 502, err.Error())
|
|
return
|
|
}
|
|
s.update.mu.Lock()
|
|
s.update.latest, s.update.checkedAt, s.update.err = rel, time.Now(), ""
|
|
s.update.mu.Unlock()
|
|
}
|
|
if body.Version != "" && body.Version != rel.Version {
|
|
done()
|
|
jsonErr(w, 409, "what is published is now "+rel.Version+", not "+body.Version+"; look again")
|
|
return
|
|
}
|
|
if !force && selfupdate.Compare(s.version, rel.Version) >= 0 {
|
|
done()
|
|
jsonErr(w, 409, "this machine is already running "+s.version+"; nothing newer is published")
|
|
return
|
|
}
|
|
tmp := filepath.Join(s.cfg.DataDir(), "hamlab.new")
|
|
if err := src.Fetch(ctx, rel, runtime.GOARCH, tmp); err != nil {
|
|
done()
|
|
jsonErr(w, 502, err.Error())
|
|
return
|
|
}
|
|
newVersion, err := s.installBinary(ctx, tmp)
|
|
if err != nil {
|
|
done()
|
|
jsonErr(w, codeOf(err, 500), err.Error())
|
|
return
|
|
}
|
|
log.Printf("update: installed %s (was %s), asked for by %s; restarting", newVersion, s.version, clientIP(r))
|
|
jsonOK(w, 200, map[string]string{"version": newVersion, "previous": s.version})
|
|
s.restartAfterUpdate()
|
|
}
|