Files
Hammerhead/update.go
T
VeillaxandClaude Sonnet 5.5 2b7cb5dd35
CI / test (push) Successful in 5m21s
Release / release (push) Successful in 6m9s
Show a progress bar and live log while Hammerhead, edges and sites update
The updater reports each step (checking, downloading with percent, verifying, installing, restarting).
Edges and sites send that progress and their log with their reports; Hammerhead serves it to a dialog
that opens when an update starts, and keeps its own last update log on disk across the restart.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 18:05:42 -06:00

184 lines
5.7 KiB
Go

package hammerhead
import (
"context"
"errors"
"net/http"
"os"
"strings"
"sync"
"time"
"git.veillax.com/Veillax/reef/selfupdate"
)
func (s *Server) updateConfig(app, current string) selfupdate.Config {
o := s.opts
server := o.UpdateServer
if server == "" {
server = "https://git.veillax.com"
}
owner := "Veillax"
if o.UpdateRepo != "" {
owner = strings.SplitN(o.UpdateRepo, "/", 2)[0]
}
return selfupdate.Config{Server: server, Repo: owner + "/" + app, App: app, Current: current}
}
// checkUpdate asks the release server whether a newer Hammerhead exists. It only looks.
func (s *Server) checkUpdate(w http.ResponseWriter, r *http.Request, p *principal) {
o := s.opts
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
out := map[string]any{"current": o.Version, "autoUpdate": o.AutoUpdate, "checked": time.Now()}
latest, newer, err := selfupdate.Check(ctx, s.updateConfig("hammerhead", o.Version))
out["latest"], out["newer"] = latest, newer
if err != nil {
out["error"] = err.Error()
}
writeJSON(w, 200, out)
}
// applyUpdate installs the newest release of Hammerhead itself and restarts into it. Like the
// scheduled check it needs auto-update to be on, which is also what makes the binary replaceable.
func (s *Server) applyUpdate(w http.ResponseWriter, r *http.Request, p *principal) {
if !s.opts.AutoUpdate {
fail(w, 400, errors.New("auto-update is off: set HAMMERHEAD_AUTO_UPDATE=1 and restart Hammerhead, or update it by hand"))
return
}
u := &s.selfUpd
u.mu.Lock()
if u.running {
u.mu.Unlock()
fail(w, 409, errors.New("an update is already running"))
return
}
u.running, u.stage, u.pct, u.lines = true, "starting", 0, nil
u.mu.Unlock()
os.WriteFile(s.updateLogPath(), nil, 0o600) // a new run starts a new log
cfg := s.updateConfig("hammerhead", s.opts.Version)
cfg.Progress = func(e selfupdate.Event) { s.selfNote(e.Stage, e.Pct, e.Line) }
cfg.Restart = func(exe string) error {
s.selfNote("restarting", 100, "Closing the database and restarting")
s.Close()
return selfupdate.Restart(exe)
}
s.selfNote("starting", 0, "Update requested by "+p.User.Email)
s.audit("", p.User.Email, "server.update", "")
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
selfupdate.CheckOnce(ctx, cfg) // on success the process is replaced and this never returns
u.mu.Lock()
u.running = false
u.mu.Unlock()
}()
writeJSON(w, 200, map[string]any{"started": true})
}
// latestVersions returns the newest release of each program, for "update available" badges. Results
// are cached for ten minutes so a busy dashboard doesn't hammer the release server.
func (s *Server) latestVersions(w http.ResponseWriter, r *http.Request, p *principal) {
s.verMu.Lock()
if time.Since(s.verAt) > 10*time.Minute || s.verCache == nil {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
out := map[string]string{}
var mu sync.Mutex
var wg sync.WaitGroup
for _, app := range []string{"hammerhead", "blacktip", "bamboo"} {
wg.Add(1)
go func(app string) {
defer wg.Done()
if tag, _, err := selfupdate.Check(ctx, s.updateConfig(app, "")); err == nil && tag != "" {
mu.Lock()
out[app] = tag
mu.Unlock()
}
}(app)
}
wg.Wait()
cancel()
s.verCache, s.verAt = out, time.Now()
}
out := s.verCache
s.verMu.Unlock()
writeJSON(w, 200, out)
}
// noteUpdateReport records what an edge or site says about its version and any update that was
// requested of it. It reports whether a pending request was answered, which means the config the
// agent polls has changed (it no longer carries the request).
func noteUpdateReport(version *string, auto *bool, updateID, updateMsg *string, inVersion string, inAuto bool, inID, inMsg string) bool {
upgraded := *version != "" && inVersion != "" && inVersion != *version
if inVersion != "" {
*version = inVersion
}
*auto = inAuto
if *updateID == "" {
return false
}
switch {
case inID == *updateID && inMsg != "":
*updateMsg = inMsg
case upgraded:
*updateMsg = "updated to " + inVersion
default:
return false
}
*updateID = ""
return true
}
func (s *Server) updateEdge(w http.ResponseWriter, r *http.Request, p *principal) {
s.mu.Lock()
defer s.mu.Unlock()
e := s.findEdge(r.PathValue("id"))
if e == nil {
fail(w, 404, errors.New("no such edge"))
return
}
if err := requestUpdate(&e.AutoUpdate, &e.UpdateID, &e.UpdateMsg, s.edgeOnline(e), "BLACKTIP"); err != nil {
fail(w, 400, err)
return
}
delete(s.updRuns, e.ID) // a new request starts a new log
s.bump()
s.audit("", p.User.Email, "edge.update", e.Name)
ok(w)
}
func (s *Server) updateSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.findSite(r.PathValue("id"))
if st == nil || !s.siteVisible(c, st) {
fail(w, 404, errors.New("no such site"))
return
}
if st.Local {
fail(w, 400, errors.New("this site is the edge's own machine; update the edge instead"))
return
}
if err := requestUpdate(&st.AutoUpdate, &st.UpdateID, &st.UpdateMsg, s.siteOnline(st), "BAMBOO"); err != nil {
fail(w, 400, err)
return
}
delete(s.updRuns, st.ID)
s.bump()
s.audit(c.Org.ID, c.Actor, "site.update", st.Name)
ok(w)
}
// requestUpdate marks an update as wanted. The agent installs it only if it has auto-update on, and
// the request stays until the agent answers (or reports a new version). Asking again replaces it.
func requestUpdate(auto *bool, updateID, updateMsg *string, online bool, prefix string) error {
if !online {
return errors.New("it is offline right now")
}
if !*auto {
return errors.New("it isn't running with auto-update on, so it won't install updates from here (set " + prefix + "_AUTO_UPDATE=1 and restart it)")
}
*updateID, *updateMsg = randHex(8), ""
return nil
}