Update from hub: node self-update endpoint, hub push per machine / all, push-nodes CLI run by make deploy, outdated badges + Update buttons on Machines

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-18 12:04:31 -07:00
co-authored by Claude Fable 5.1
parent e9be94e32b
commit 254e0cdd58
8 changed files with 248 additions and 7 deletions
+95 -1
View File
@@ -12,8 +12,10 @@ import (
"net/http"
"net/url"
"os"
"os/exec"
"os/user"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
@@ -86,6 +88,9 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("POST /api/nodes", s.auth(s.addNode))
m.HandleFunc("DELETE /api/nodes/{name}", s.auth(s.removeNode))
m.HandleFunc("/api/nodes/{name}/{rest...}", s.auth(s.proxyNode))
m.HandleFunc("POST /api/self-update", s.auth(s.selfUpdate))
m.HandleFunc("POST /api/machines/{name}/update", s.auth(s.updateMachine))
m.HandleFunc("POST /api/machines-update-all", s.auth(s.updateAllMachines))
m.HandleFunc("POST /api/logout", s.auth(s.logout))
m.HandleFunc("GET /api/me", s.auth(s.me))
m.HandleFunc("GET /api/terminals", s.auth(s.listTerminals))
@@ -306,6 +311,95 @@ func (s *Server) removeNode(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204)
}
// selfUpdate receives a new binary from a hub, checks it runs, installs it over the
// current executable (keeping a .prev copy) and restarts the service.
func (s *Server) selfUpdate(w http.ResponseWriter, r *http.Request) {
exe, err := os.Executable()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
exe, _ = filepath.EvalSymlinks(exe)
tmp := filepath.Join(s.cfg.DataDir(), "hamlab.new")
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
n, err := io.Copy(f, io.LimitReader(r.Body, 512<<20))
f.Close()
if err != nil || n < 1<<20 {
os.Remove(tmp)
jsonErr(w, 400, "upload failed or too small to be hamlab")
return
}
// It must at least run and identify itself before it replaces us.
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, tmp, "version").Output()
if err != nil || !strings.HasPrefix(string(out), "hamlab ") {
os.Remove(tmp)
jsonErr(w, 400, "uploaded file is not a working hamlab binary")
return
}
newVersion := strings.TrimSpace(strings.TrimPrefix(string(out), "hamlab "))
prev := filepath.Join(s.cfg.DataDir(), "hamlab.prev")
_ = copyFile(exe, prev)
install := exec.CommandContext(ctx, "sudo", "-n", "install", "-m", "0755", tmp, exe)
if errb, err := install.CombinedOutput(); err != nil {
if err2 := os.Rename(tmp, exe); err2 != nil { // no sudo: try in place
jsonErr(w, 500, "install failed: "+strings.TrimSpace(string(errb)))
return
}
}
os.Remove(tmp)
log.Printf("self-update: installed %s (was %s) from %s; restarting", newVersion, s.version, clientIP(r))
jsonOK(w, 200, map[string]string{"version": newVersion, "previous": s.version})
go func() {
time.Sleep(800 * time.Millisecond)
if exec.Command("sudo", "-n", "systemctl", "restart", "hamlab").Run() != nil {
os.Exit(0) // Restart=always brings the new binary up
}
}()
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
func (s *Server) updateMachine(w http.ResponseWriter, r *http.Request) {
exe, _ := os.Executable()
res := s.nodes.Push(r.Context(), r.PathValue("name"), exe, runtime.GOARCH)
log.Printf("machines: update %s -> ok=%v %s %s (from %s)", res.Name, res.OK, res.Version, res.Error, clientIP(r))
if !res.OK {
jsonErr(w, 400, res.Error)
return
}
jsonOK(w, 200, res)
}
func (s *Server) updateAllMachines(w http.ResponseWriter, r *http.Request) {
exe, _ := os.Executable()
res := s.nodes.PushAll(r.Context(), exe, runtime.GOARCH)
for _, x := range res {
log.Printf("machines: update %s -> ok=%v %s %s (from %s)", x.Name, x.OK, x.Version, x.Error, clientIP(r))
}
jsonOK(w, 200, res)
}
func (s *Server) proxyNode(w http.ResponseWriter, r *http.Request) {
p, ok := s.nodes.Proxy(r.PathValue("name"))
if !ok {
@@ -381,7 +475,7 @@ func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
jsonOK(w, 200, map[string]any{
"host": s.host, "user": s.user, "version": s.version,
"load": load, "uptime": uptime, "service_started": s.started,
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint,
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH,
})
}
+89
View File
@@ -16,6 +16,7 @@ import (
"net/http"
"net/http/httputil"
"net/url"
"os"
"regexp"
"sort"
"strings"
@@ -278,6 +279,94 @@ func (m *Manager) Remove(ctx context.Context, name string) error {
return err
}
// ---- update from hub ----
type PushResult struct {
Name string `json:"name"`
OK bool `json:"ok"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
}
// Push uploads binPath (normally the hub's own executable) to the node's self-update
// endpoint. The node verifies it, installs it and restarts itself. Refused when the
// node's architecture differs from ours.
func (m *Manager) Push(ctx context.Context, name, binPath, ourArch string) PushResult {
res := PushResult{Name: name}
n, ok := m.node(name)
if !ok {
res.Error = "no such machine"
return res
}
// arch check via /api/me
req, _ := http.NewRequestWithContext(ctx, "GET", n.URL+"/api/me", nil)
req.Header.Set("Authorization", "Bearer "+n.Token)
resp, err := m.client(n).Do(req)
if err != nil {
res.Error = "unreachable: " + shortErr(err)
return res
}
var me struct {
Arch string `json:"arch"`
Version string `json:"version"`
}
_ = json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&me)
resp.Body.Close()
if me.Arch != "" && ourArch != "" && me.Arch != ourArch {
res.Error = fmt.Sprintf("architecture mismatch: node is %s, this binary is %s — build for it and deploy by hand", me.Arch, ourArch)
return res
}
f, err := os.Open(binPath)
if err != nil {
res.Error = err.Error()
return res
}
defer f.Close()
st, _ := f.Stat()
up, _ := http.NewRequestWithContext(ctx, "POST", n.URL+"/api/self-update", f)
up.ContentLength = st.Size()
up.Header.Set("Authorization", "Bearer "+n.Token)
up.Header.Set("Content-Type", "application/octet-stream")
c := &http.Client{Transport: transportFor(n), Timeout: 5 * time.Minute}
r2, err := c.Do(up)
if err != nil {
res.Error = "upload: " + shortErr(err)
return res
}
defer r2.Body.Close()
var reply struct {
Version string `json:"version"`
Error string `json:"error"`
}
_ = json.NewDecoder(io.LimitReader(r2.Body, 64<<10)).Decode(&reply)
if r2.StatusCode != 200 {
if reply.Error == "" {
reply.Error = fmt.Sprintf("HTTP %d", r2.StatusCode)
}
res.Error = reply.Error
return res
}
res.OK, res.Version = true, reply.Version
return res
}
// PushAll updates every node, in parallel.
func (m *Manager) PushAll(ctx context.Context, binPath, ourArch string) []PushResult {
var list []config.Node
m.cfg.View(func(c *config.Config) { list = append(list, c.Nodes...) })
out := make([]PushResult, len(list))
var wg sync.WaitGroup
for i, n := range list {
wg.Add(1)
go func(i int, name string) {
defer wg.Done()
out[i] = m.Push(ctx, name, binPath, ourArch)
}(i, n.Name)
}
wg.Wait()
return out
}
// ---- proxy ----
// Proxy returns the handler for /api/nodes/<name>/<rest>: it rewrites to <node>/api/<rest>,