Add self-update, including in Docker
Sandbar can install new releases itself using Reef's selfupdate, automatically with SANDBAR_AUTO_UPDATE=1 or from a button on the Account page. In a container the new binary is written to <data>/bin and takes over from the image at start, until the image catches up. Tags now publish a Gitea release with archives and SHA256SUMS as well as the image. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
955e01333d
commit
73f45fb3ae
14 files changed
+610
-20
No files matched your search
@@ -0,0 +1,255 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/reef/selfupdate"
|
||||
)
|
||||
|
||||
// Self-update replaces the running binary with the newest release published on the Gitea
|
||||
// server (Reef's selfupdate does the download, checksum and swap).
|
||||
//
|
||||
// In a container the binary lives in the image, where this user can't write and where a
|
||||
// change would be lost when the container is recreated. So an update is written to
|
||||
// <data>/bin/sandbar on the data volume instead, and every start checks that file first
|
||||
// (UseNewerOverlay): it runs when it is a newer release than the image, and is deleted once
|
||||
// the image has caught up.
|
||||
|
||||
const defaultUpdateServer = "https://git.veillax.com"
|
||||
|
||||
type updateState struct {
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
stage string
|
||||
pct int
|
||||
line string
|
||||
err string
|
||||
|
||||
checked time.Time // the cached answer from the release server
|
||||
latest string
|
||||
newer bool
|
||||
checkErr string
|
||||
}
|
||||
|
||||
func (s *Server) updateConfig() selfupdate.Config {
|
||||
o := s.opts
|
||||
server, repo := o.UpdateServer, o.UpdateRepo
|
||||
if server == "" {
|
||||
server = defaultUpdateServer
|
||||
}
|
||||
if repo == "" {
|
||||
repo = "Veillax/sandbar"
|
||||
}
|
||||
return selfupdate.Config{
|
||||
Server: server, Repo: repo, App: "sandbar", Current: o.Version,
|
||||
ExePath: UpdateTarget(o.DataDir), Restart: o.Restart, Logf: log.Printf,
|
||||
}
|
||||
}
|
||||
|
||||
// RunAutoUpdate checks for a new release shortly after start and then every few hours until
|
||||
// ctx ends, installing what it finds and restarting into it.
|
||||
func (s *Server) RunAutoUpdate(ctx context.Context) {
|
||||
selfupdate.Run(ctx, s.updateConfig())
|
||||
}
|
||||
|
||||
// canInstall says whether this build can update itself, and if not, why.
|
||||
func (s *Server) canInstall() (bool, string) {
|
||||
if _, ok := parseVersion(s.opts.Version); !ok {
|
||||
return false, "This is a development build, so it does not update itself."
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return false, "A running program can't be replaced on Windows. Download the new release and restart."
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// updateStatus reports the running and newest versions. The release server is asked at most
|
||||
// every ten minutes unless ?fresh=1.
|
||||
func (s *Server) updateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
ok, why := s.canInstall()
|
||||
u := &s.upd
|
||||
u.mu.Lock()
|
||||
stale := time.Since(u.checked) > 10*time.Minute || r.URL.Query().Get("fresh") == "1"
|
||||
running := u.running
|
||||
u.mu.Unlock()
|
||||
if stale && !running {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||
latest, newer, err := selfupdate.Check(ctx, s.updateConfig())
|
||||
cancel()
|
||||
u.mu.Lock()
|
||||
u.checked, u.latest, u.newer, u.checkErr = time.Now(), latest, newer, ""
|
||||
if err != nil {
|
||||
u.checkErr = err.Error()
|
||||
}
|
||||
u.mu.Unlock()
|
||||
}
|
||||
u.mu.Lock()
|
||||
out := map[string]any{
|
||||
"current": s.opts.Version, "autoUpdate": s.opts.AutoUpdate, "canInstall": ok, "reason": why,
|
||||
"latest": u.latest, "newer": u.newer, "error": u.checkErr,
|
||||
"running": u.running, "stage": u.stage, "pct": u.pct, "line": u.line, "failed": u.err,
|
||||
}
|
||||
u.mu.Unlock()
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
// updateApply installs the newest release now. It answers at once; the page polls updateStatus
|
||||
// for progress, and the server disappears for a moment when it restarts into the new binary.
|
||||
func (s *Server) updateApply(w http.ResponseWriter, r *http.Request) {
|
||||
if ok, why := s.canInstall(); !ok {
|
||||
bad(w, why)
|
||||
return
|
||||
}
|
||||
u := &s.upd
|
||||
u.mu.Lock()
|
||||
if u.running {
|
||||
u.mu.Unlock()
|
||||
writeJSON(w, 409, map[string]string{"error": "An update is already running."})
|
||||
return
|
||||
}
|
||||
u.running, u.stage, u.pct, u.line, u.err = true, "starting", 0, "", ""
|
||||
u.mu.Unlock()
|
||||
cfg := s.updateConfig()
|
||||
cfg.Progress = func(e selfupdate.Event) {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
u.stage = e.Stage
|
||||
if e.Pct >= 0 {
|
||||
u.pct = e.Pct
|
||||
}
|
||||
if e.Line != "" {
|
||||
u.line = e.Line
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
_, err := selfupdate.CheckOnce(ctx, cfg) // when it installs, the process is replaced and this never returns
|
||||
u.mu.Lock()
|
||||
u.running = false
|
||||
if err != nil {
|
||||
u.err = err.Error()
|
||||
}
|
||||
u.checked = time.Time{} // look again next time
|
||||
u.mu.Unlock()
|
||||
}()
|
||||
writeJSON(w, 200, map[string]bool{"started": true})
|
||||
}
|
||||
|
||||
// UpdateTarget is the file an update replaces: the running binary when its folder is writable
|
||||
// (a plain install), and otherwise <data>/bin/sandbar (a container).
|
||||
func UpdateTarget(dataDir string) string {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
if exe, err = filepath.EvalSymlinks(exe); err == nil && writableDir(filepath.Dir(exe)) {
|
||||
return exe
|
||||
}
|
||||
}
|
||||
dir, err := filepath.Abs(filepath.Join(dataDir, "bin"))
|
||||
if err != nil {
|
||||
dir = filepath.Join(dataDir, "bin")
|
||||
}
|
||||
os.MkdirAll(dir, 0o755)
|
||||
return filepath.Join(dir, "sandbar")
|
||||
}
|
||||
|
||||
func writableDir(dir string) bool {
|
||||
f, err := os.CreateTemp(dir, ".sandbar-write-test-*")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
return true
|
||||
}
|
||||
|
||||
// UseNewerOverlay runs <data>/bin/sandbar instead of this binary when that file is a newer
|
||||
// release. It returns when there is nothing to switch to, and otherwise replaces the process.
|
||||
func UseNewerOverlay(dataDir, current string, logf func(string, ...any)) {
|
||||
if runtime.GOOS == "windows" || os.Getenv("SANDBAR_NO_OVERLAY") == "1" {
|
||||
return
|
||||
}
|
||||
overlay, err := filepath.Abs(filepath.Join(dataDir, "bin", "sandbar"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ofi, err := os.Stat(overlay)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
if efi, err := os.Stat(exe); err == nil && os.SameFile(efi, ofi) {
|
||||
return // this already is the overlay
|
||||
}
|
||||
}
|
||||
ver := overlayVersion(overlay)
|
||||
switch {
|
||||
case ver == "":
|
||||
logf("ignoring %s: it did not report a version", overlay)
|
||||
case versionNewer(ver, current):
|
||||
logf("running %s from %s, which is newer than this image's %s", ver, overlay, current)
|
||||
if err := selfupdate.Restart(overlay); err != nil {
|
||||
logf("could not switch to %s: %v", overlay, err)
|
||||
}
|
||||
default:
|
||||
if _, ok := parseVersion(current); ok {
|
||||
os.Remove(overlay) // the image has caught up with it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// overlayVersion asks a binary for its version, or returns "" if it will not run.
|
||||
func overlayVersion(path string) string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, path, "-version").Output()
|
||||
f := strings.Fields(string(out))
|
||||
if err != nil || len(f) != 2 || f[0] != "sandbar" {
|
||||
return ""
|
||||
}
|
||||
return f[1]
|
||||
}
|
||||
|
||||
// parseVersion reads vX.Y.Z. Pre-releases and local builds are not versions for this purpose.
|
||||
func parseVersion(s string) ([3]int, bool) {
|
||||
var v [3]int
|
||||
if !strings.HasPrefix(s, "v") || strings.ContainsAny(s, "-+") {
|
||||
return v, false
|
||||
}
|
||||
parts := strings.Split(s[1:], ".")
|
||||
if len(parts) != 3 {
|
||||
return v, false
|
||||
}
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n < 0 {
|
||||
return v, false
|
||||
}
|
||||
v[i] = n
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// versionNewer reports whether a is a later release than b. Either not being a release is false.
|
||||
func versionNewer(a, b string) bool {
|
||||
x, ok1 := parseVersion(a)
|
||||
y, ok2 := parseVersion(b)
|
||||
if !ok1 || !ok2 {
|
||||
return false
|
||||
}
|
||||
for i := range x {
|
||||
if x[i] != y[i] {
|
||||
return x[i] > y[i]
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in new issue
Block a user