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>
256 lines
7.1 KiB
Go
256 lines
7.1 KiB
Go
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
|
|
}
|