Portable install: the binary installs itself; static builds on five architectures; root via sudo, doas or uid 0; every package manager; older systemd; no lsblk
- hamlab install / uninstall (internal/setup): systemd or OpenRC, elevates through sudo or doas, runs as the invoking user, sets or generates the password, names only journal groups that exist, waits for /healthz, reports an active firewalld/ufw. --purge and the data chown never touch a directory that isn't Ham Lab's alone. - install.sh: curl-able bootstrap (arch detection, SHA256SUMS, offline beside the binaries, noexec /tmp fallback). make release builds dist/ for amd64, arm64, arm (ARMv6), 386, riscv64. Every build is CGO_ENABLED=0; the native one used to link glibc. - internal/priv replaces every literal `sudo -n`: uid 0, sudo or doas. - Updates acts on dnf, zypper, pacman and apk as well as apt; dnf shows installed versions. - Services falls back to the plain systemctl table before systemd 246 and says so on hosts without systemd. Disks falls back to /sys/block without lsblk. - Listener binds with IP_FREEBIND, so a LAN address that isn't up at boot is no failure. - Self-update: the new build must pass `check` against the live config, is put in place by atomic rename, and the daemon re-execs itself (same under any supervisor). - State and config writes are fsynced; a corrupt state.json is set aside, not fatal. - Terminal shutdown no longer empties the saved list, so names come back after a restart by design rather than by winning a race. - Config default is ~/.config/hamlab; an existing ~/apps/hamlab is still found. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
91b7f4f009
commit
488d929733
+30
-8
@@ -2,6 +2,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -32,7 +33,9 @@ import (
|
||||
"hamlab/internal/files"
|
||||
"hamlab/internal/metrics"
|
||||
"hamlab/internal/nodes"
|
||||
"hamlab/internal/priv"
|
||||
"hamlab/internal/services"
|
||||
"hamlab/internal/setup"
|
||||
"hamlab/internal/storage"
|
||||
"hamlab/internal/store"
|
||||
"hamlab/internal/tasks"
|
||||
@@ -43,6 +46,9 @@ import (
|
||||
const cookieName = "hamlab_session"
|
||||
|
||||
type Server struct {
|
||||
// Restart replaces the running process with the binary on disk; it only returns on failure.
|
||||
Restart func() error
|
||||
|
||||
cfg *config.Config
|
||||
sessions *auth.Sessions
|
||||
limiter auth.Limiter
|
||||
@@ -374,27 +380,43 @@ func (s *Server) selfUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
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")
|
||||
jsonErr(w, 400, "uploaded file is not a working hamlab binary for this machine ("+runtime.GOARCH+")")
|
||||
return
|
||||
}
|
||||
newVersion := strings.TrimSpace(strings.TrimPrefix(string(out), "hamlab "))
|
||||
// Builds that have `check` must also load this machine's config and state.
|
||||
if msg, err := exec.CommandContext(ctx, tmp, "-config", s.cfg.Path(), "check").CombinedOutput(); err != nil && !bytes.Contains(msg, []byte("usage:")) {
|
||||
os.Remove(tmp)
|
||||
jsonErr(w, 400, "the new build can't start here: "+strings.TrimSpace(string(msg)))
|
||||
return
|
||||
}
|
||||
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
|
||||
// Atomic replace, by the new binary itself: directly when the directory is ours
|
||||
// (root, or a user-owned prefix), else as root.
|
||||
var msg []byte
|
||||
if err = setup.CopyAtomic(tmp, exe); err != nil && priv.Available() {
|
||||
full := priv.Wrap(tmp, "replace", exe)
|
||||
if msg, err = exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput(); err != nil { // a build from before `replace`
|
||||
full = priv.Wrap("install", "-m", "0755", tmp, exe)
|
||||
msg, err = exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput()
|
||||
}
|
||||
}
|
||||
os.Remove(tmp)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "install failed: "+strings.TrimSpace(string(msg)+" "+err.Error()))
|
||||
return
|
||||
}
|
||||
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
|
||||
if s.Restart != nil {
|
||||
log.Printf("self-update: exec failed (%v); putting %s back", s.Restart(), s.version)
|
||||
full := priv.Wrap(prev, "replace", exe)
|
||||
_ = exec.Command(full[0], full[1:]...).Run()
|
||||
}
|
||||
os.Exit(1) // the supervisor starts whatever is on disk
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user