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:
co-authored by
Claude Fable 5.1
parent
e9be94e32b
commit
254e0cdd58
+95
-1
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user