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:
VeillaxandClaude Sonnet 5.5 committed 2026-10-27 04:22:54 -06:00
1 parent 955e01333d
commit 73f45fb3ae
14 files changed
+610 -20

No files matched your search

+7
View File
@@ -23,6 +23,13 @@ SANDBAR_MASTER_KEY=
# Leave empty and /metrics does not exist.
#SANDBAR_METRICS_TOKEN=
# Install new releases by itself: checks every few hours, then downloads, verifies and restarts.
# Works in Docker too (the new binary is kept in <data>/bin). Off by default.
#SANDBAR_AUTO_UPDATE=1
# Where releases come from, if not this project on git.veillax.com.
#SANDBAR_UPDATE_SERVER=https://git.veillax.com
#SANDBAR_UPDATE_REPO=Veillax/sandbar
# How long topic messages and monitor results are kept, in days.
#SANDBAR_MESSAGE_DAYS=30
#SANDBAR_CHECK_DAYS=30
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+4
View File
@@ -31,6 +31,10 @@ jobs:
exit 1
fi
- name: Shell scripts parse
run: |
for f in deploy/*.sh; do sh -n "$f"; done
- name: Vet
run: go vet ./...
+21 -14
View File
@@ -1,18 +1,22 @@
name: Release
# Push a tag such as v0.1.0 to build and publish the container image:
# git tag -a v0.1.0 -m "sandbar v0.1.0" && git push origin v0.1.0
# Push a tag such as v0.2.0 to build and publish a release:
# git tag -a v0.2.0 -m "sandbar v0.2.0" && git push origin v0.2.0
# The release carries the archives and SHA256SUMS that self-update downloads, and the
# container image is pushed to the registry on this server.
on:
push:
tags: ['v*']
jobs:
image:
release:
runs-on: ubuntu-latest
env:
GOPRIVATE: git.veillax.com
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # release notes come from the history since the previous tag
- uses: actions/setup-go@v5
with:
@@ -28,19 +32,22 @@ jobs:
go vet ./...
go test ./...
# The Dockerfile packages these binaries, so the arm64 image needs no emulation.
- name: Build the binaries
run: |
for a in amd64 arm64; do
mkdir -p dist/bin/linux_$a
CGO_ENABLED=0 GOOS=linux GOARCH=$a go build -trimpath \
-ldflags "-s -w -X main.version=${GITHUB_REF_NAME}" \
-o dist/bin/linux_$a/sandbar ./cmd/sandbar
done
- name: Build release archives
run: sh deploy/build.sh "${GITHUB_REF_NAME}"
# Optional secret REGISTRY_TOKEN (and variable REGISTRY_USER): an access token with
# package write permission, for servers where the job token can't push.
# Optional secret RELEASE_TOKEN: an access token that can write releases, for
# servers where the automatic job token can't.
- name: Publish the release
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
run: sh deploy/publish-release.sh "${GITHUB_REF_NAME}"
# Skipped when the repository variable PUBLISH_DOCKER is "false", or fails alone
# if this runner has no Docker: the release above is already published by then.
# Optional secret REGISTRY_TOKEN (and variable REGISTRY_USER): an access token
# with package write permission, for servers where the job token can't push.
- name: Build and push the container image
if: ${{ vars.PUBLISH_DOCKER != 'false' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
REGISTRY_USER: ${{ vars.REGISTRY_USER || github.actor }}
+1
View File
@@ -5,3 +5,4 @@
master.key
.devdata/
bin/
dist/
+25 -3
View File
@@ -47,6 +47,7 @@ services:
env_file: .env
environment:
SANDBAR_URL: https://sandbar.example.com
SANDBAR_AUTO_UPDATE: "1" # optional, see Updating below
volumes:
- sandbar-data:/data
@@ -62,12 +63,12 @@ docker compose up -d
a folder instead, make it writable by 65532 (`chown 65532:65532 ./data`).
- Set `SANDBAR_MASTER_KEY` yourself. Without it a key is generated inside `/data`, next to the database it protects.
- To build the image locally, see the comment at the top of `Dockerfile`.
- Update with `docker compose pull && docker compose up -d`. Your data stays in the volume. Copy the data folder
before upgrading, since there is no migration tooling yet.
- Update by hand with `docker compose pull && docker compose up -d`, or let Sandbar do it (see Updating below).
Your data stays in the volume. Copy the data folder before upgrading, since there is no migration tooling yet.
### Binary
Build `sandbar` (`go build -o bin/ ./cmd/sandbar`; releases only publish the container image), copy it to the server with a `.env` file, and run it
Download an archive from the release page (Linux amd64 and arm64, Windows amd64) or build it (`go build -o bin/ ./cmd/sandbar`), copy it to the server with a `.env` file, and run it
under a service manager. A minimal systemd unit:
```ini
@@ -87,6 +88,27 @@ WantedBy=multi-user.target
Put `SANDBAR_DATA=/var/lib/sandbar` in the env file and make `/etc/sandbar/.env` readable only by the `sandbar` user.
### Updating
Pushing a version tag publishes a release with archives and `SHA256SUMS` as well as the image. Sandbar can install
new releases itself, in Docker and as a plain binary, on Linux and macOS.
- **Automatic:** set `SANDBAR_AUTO_UPDATE=1` (or pass `-auto-update`). Sandbar checks a few minutes after start and
then every few hours, downloads the archive for its platform, checks it against `SHA256SUMS`, checks that the new
binary runs, replaces itself and restarts. Pre-releases (`v1.0.0-rc1`) are never installed.
- **On demand:** an administrator can see the newest release and click **Update** on the Account page, with or
without automatic updates on.
- **In Docker:** the image is read-only to Sandbar, and a change inside the container would be lost when it is
recreated. So the new binary is written to `<data>/bin/sandbar` on the data volume. At every start Sandbar runs
that file when it is a newer release than the image, and deletes it once the image has caught up. The container
keeps running through the update (it re-executes in place, so the process id does not change), and
`docker compose pull` still works: a newer image takes over from the volume copy.
- **Pin a version:** leave automatic updates off and use an image tag such as `:v0.2.0` instead of `:latest`.
- **Requirements:** the release page must be readable without signing in, so the repository has to be public. A
development build (`dev`) and Windows never update themselves. `SANDBAR_UPDATE_SERVER` and `SANDBAR_UPDATE_REPO`
point it at a different Gitea server or fork. If a bad update leaves Sandbar unable to start, delete
`<data>/bin/sandbar` (or set `SANDBAR_NO_OVERLAY=1`) and it starts from the image again.
### HTTPS and a reverse proxy
Sandbar serves plain HTTP, so put a reverse proxy in front of it and set `SANDBAR_URL` to the public address, since
+27 -2
View File
@@ -18,6 +18,7 @@ import (
"git.veillax.com/Veillax/reef/dotenv"
"git.veillax.com/Veillax/reef/secretbox"
"git.veillax.com/Veillax/reef/selfupdate"
"git.veillax.com/Veillax/sandbar/internal/sandbar"
)
@@ -45,6 +46,7 @@ func main() {
data := flag.String("data", env("SANDBAR_DATA", "./sandbar-data"), "directory for the database")
url := flag.String("url", env("SANDBAR_URL", ""), "public URL (used for links in notifications)")
flag.String("env", ".env", "file to read secrets and settings from")
autoUpdate := flag.Bool("auto-update", os.Getenv("SANDBAR_AUTO_UPDATE") == "1", "check for new releases every few hours and update and restart by itself (Linux and macOS, including Docker)")
genkey := flag.Bool("genkey", false, "print a new random SANDBAR_MASTER_KEY and exit")
showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse()
@@ -58,6 +60,9 @@ func main() {
return
}
// In a container, an update lives on the data volume and takes over from the image.
sandbar.UseNewerOverlay(*data, version, log.Printf)
key, err := masterKey(*data)
if err != nil {
log.Fatal(err)
@@ -72,20 +77,40 @@ func main() {
}
defer st.Close()
var hs *http.Server
atoi := func(k string) int { n, _ := strconv.Atoi(os.Getenv(k)); return n }
srv := sandbar.New(sandbar.Options{
var srv *sandbar.Server
srv = sandbar.New(sandbar.Options{
DataDir: *data,
URL: strings.TrimRight(*url, "/"),
MetricsToken: os.Getenv("SANDBAR_METRICS_TOKEN"),
MessageDays: atoi("SANDBAR_MESSAGE_DAYS"),
CheckDays: atoi("SANDBAR_CHECK_DAYS"),
Version: version,
AutoUpdate: *autoUpdate,
UpdateServer: os.Getenv("SANDBAR_UPDATE_SERVER"),
UpdateRepo: os.Getenv("SANDBAR_UPDATE_REPO"),
Restart: func(exe string) error {
// Let go of the port and the database, then become the new binary. If that fails, exit
// so the container or service manager starts it again, now from the new file.
hs.Close()
srv.Close()
st.Close()
err := selfupdate.Restart(exe)
log.Printf("could not restart into %s, exiting: %v", exe, err)
os.Exit(1)
return err
},
SMTP: sandbar.SMTPConfig{Host: os.Getenv("SANDBAR_SMTP_HOST"), Port: os.Getenv("SANDBAR_SMTP_PORT"),
User: os.Getenv("SANDBAR_SMTP_USER"), Password: os.Getenv("SANDBAR_SMTP_PASSWORD"), From: env("SANDBAR_SMTP_FROM", "sandbar@localhost")},
}, st)
srv.BootstrapAdmin(os.Getenv("SANDBAR_ADMIN_EMAIL"), os.Getenv("SANDBAR_ADMIN_PASSWORD"))
srv.Start()
if *autoUpdate {
go srv.RunAutoUpdate(context.Background())
}
hs := &http.Server{Addr: *listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 2 * time.Minute}
hs = &http.Server{Addr: *listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 2 * time.Minute}
go func() {
log.Printf("sandbar %s listening on %s", version, *listen)
if err := hs.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
# Builds sandbar for every release platform and packs the archives into dist/.
#
# deploy/build.sh [version] version defaults to `git describe`
#
# Output:
# dist/bin/<os>_<arch>/sandbar the raw binaries (the Docker image uses the linux ones)
# dist/sandbar_<version>_<os>_<arch>.tar.gz or .zip
# dist/SHA256SUMS
#
# Self-update downloads the linux tar.gz archives, so both linux architectures are always built.
# Set TARGETS="linux/arm64 darwin/arm64" (any GOOS/GOARCH pair Go supports) to build something else by hand.
set -eu
NAME=sandbar
cd "$(dirname "$0")/.."
VERSION=${1:-$(git describe --tags --always 2>/dev/null || echo dev)}
TARGETS=${TARGETS:-"linux/amd64 linux/arm64 windows/amd64"}
rm -rf dist
mkdir -p dist/bin dist/stage
for t in $TARGETS; do
os=${t%/*}
arch=${t#*/}
ext=""
[ "$os" = windows ] && ext=".exe"
out="dist/bin/${os}_${arch}"
mkdir -p "$out"
echo "building $NAME $VERSION for $os/$arch"
CGO_ENABLED=0 GOOS=$os GOARCH=$arch GOARM=7 \
go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o "$out/$NAME$ext" "./cmd/$NAME"
pkg="${NAME}_${VERSION}_${os}_${arch}"
stage="dist/stage/$pkg"
mkdir -p "$stage"
cp "$out/$NAME$ext" "$stage/"
for f in LICENSE README.md .env.example; do
[ -f "$f" ] && cp "$f" "$stage/"
done
if [ "$os" = windows ] && command -v zip >/dev/null 2>&1; then
(cd dist/stage && zip -qr "../$pkg.zip" "$pkg")
else
tar -C dist/stage -czf "dist/$pkg.tar.gz" "$pkg"
fi
done
rm -rf dist/stage
(cd dist && sha256sum $(ls | grep -E '\.(tar\.gz|zip)$') > SHA256SUMS)
echo "done: $(ls dist | grep -cE '\.(tar\.gz|zip)$') archives in dist/"
+71
View File
@@ -0,0 +1,71 @@
#!/bin/sh
# Creates (or updates) the Gitea release for a tag and uploads what deploy/build.sh made.
#
# TOKEN=<access token that can write releases> deploy/publish-release.sh v0.2.0
#
# In Gitea Actions GITHUB_SERVER_URL and GITHUB_REPOSITORY are already set. Safe to
# re-run: an existing release is reused and assets with the same name are replaced.
set -eu
TAG=${1:?usage: publish-release.sh <tag>}
: "${TOKEN:?set TOKEN to a Gitea access token that can write releases}"
SERVER=${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-https://git.veillax.com}}
REPO=${GITHUB_REPOSITORY:-Veillax/sandbar}
API="$SERVER/api/v1/repos/$REPO"
AUTH="Authorization: token $TOKEN"
# a JSON helper without needing jq: any python will do
PY=python3
python3 -c 'pass' > /dev/null 2>&1 || PY=python
json() { "$PY" -c "$1"; }
# release notes: the commit subjects since the previous tag
prev=$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || true)
if [ -n "$prev" ]; then
notes=$(git log --pretty='format:- %s' "$prev..$TAG")
else
notes=$(git log --pretty='format:- %s' "$TAG")
fi
prerelease=false
case "$TAG" in *-*) prerelease=true ;; esac
body=$(NOTES="$notes" TAG="$TAG" PRE="$prerelease" json '
import json, os
print(json.dumps({"tag_name": os.environ["TAG"], "name": os.environ["TAG"], "body": os.environ["NOTES"],
"draft": False, "prerelease": os.environ["PRE"] == "true"}))')
# reuse the release when a failed job is re-run
id=$(curl -fsS -H "$AUTH" "$API/releases/tags/$TAG" 2>/dev/null | json 'import sys, json; print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
if [ -z "$id" ]; then
echo "creating release $TAG"
id=$(curl -fsS -X POST -H "$AUTH" -H 'Content-Type: application/json' -d "$body" "$API/releases" | json 'import sys, json; print(json.load(sys.stdin)["id"])')
else
echo "release $TAG already exists (id $id): replacing its files"
fi
# the ids of the release's assets with a given name (a failed upload can leave a partial one behind)
asset_ids() {
curl -fsS -H "$AUTH" "$API/releases/$id/assets" | "$PY" -c 'import sys, json
for a in json.load(sys.stdin):
if a["name"] == sys.argv[1]:
print(a["id"])' "$1"
}
for f in dist/*.tar.gz dist/*.zip dist/SHA256SUMS; do
[ -f "$f" ] || continue
name=$(basename "$f")
n=1
while :; do
for aid in $(asset_ids "$name"); do
curl -fsS -X DELETE -H "$AUTH" "$API/releases/$id/assets/$aid" > /dev/null
done
echo "uploading $name (attempt $n)"
if curl -fsS --max-time 1200 -X POST -H "$AUTH" -F "attachment=@$f" "$API/releases/$id/assets?name=$name" > /dev/null; then
break
fi
# large uploads can hit a proxy timeout (HTTP 502); try again
[ "$n" -lt 5 ] || { echo "giving up on $name after $n attempts" >&2; exit 1; }
n=$((n + 1))
sleep $((n * 10))
done
done
echo "published $SERVER/$REPO/releases/tag/$TAG"
+2
View File
@@ -105,6 +105,8 @@ func (s *Server) Handler() http.Handler {
}
return nil
}, nil)
mux.HandleFunc("GET /api/v1/system/update", s.auth(true, s.updateStatus))
mux.HandleFunc("POST /api/v1/system/update", s.auth(true, s.updateApply))
mux.HandleFunc("POST /api/v1/channels/{id}/test", s.auth(true, s.testChannel))
mux.HandleFunc("GET /api/v1/oncall", s.auth(false, s.onCallNow))
+11
View File
@@ -26,6 +26,15 @@ type Options struct {
MessageDays int // how long topic messages are kept (default 30)
CheckDays int // how long monitor results are kept (default 30)
// Self-update (see update.go). Version is the running release, such as v0.2.0; anything else
// ("dev") never updates. Restart is called after a new binary is installed and should close
// what the process holds open before re-executing it.
Version string
AutoUpdate bool
UpdateServer string // default https://git.veillax.com
UpdateRepo string // default Veillax/sandbar
Restart func(exe string) error
// Tests replace these.
Now func() time.Time
HTTPClient *http.Client
@@ -44,6 +53,8 @@ type Server struct {
loginMu sync.Mutex
loginTry map[string][]time.Time
upd updateState
stop chan struct{}
wg sync.WaitGroup
}
+255
View File
@@ -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
}
+109
View File
@@ -0,0 +1,109 @@
package sandbar
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestVersionNewer(t *testing.T) {
for _, c := range []struct {
a, b string
want bool
}{
{"v0.1.4", "v0.1.3", true},
{"v0.2.0", "v0.1.9", true},
{"v1.0.0", "v0.9.9", true},
{"v0.1.3", "v0.1.3", false},
{"v0.1.2", "v0.1.3", false},
{"v0.1.10", "v0.1.9", true},
{"v0.2.0-rc1", "v0.1.0", false}, // pre-releases are never offered
{"v0.2.0", "dev", false}, // a development build never updates
{"dev", "v0.1.0", false},
} {
if got := versionNewer(c.a, c.b); got != c.want {
t.Errorf("versionNewer(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
}
}
}
// The update page asks the release server and reports whether it has something newer.
func TestUpdateStatus(t *testing.T) {
release := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/Veillax/sandbar/releases/latest" {
http.NotFound(w, r)
return
}
w.Write([]byte(`{"tag_name":"v0.2.0","assets":[]}`))
}))
defer release.Close()
s, _, _ := newTestServer(t)
s.opts.Version, s.opts.UpdateServer = "v0.1.0", release.URL
ts := httptest.NewServer(s.Handler())
defer ts.Close()
c := newAPIClient(t, ts)
if code := c.do("GET", "/api/v1/system/update", nil, nil); code != 401 {
t.Fatalf("signed out: %d", code)
}
c.setup()
var st map[string]any
if code := c.do("GET", "/api/v1/system/update", nil, &st); code != 200 {
t.Fatalf("status: %d", code)
}
if st["current"] != "v0.1.0" || st["latest"] != "v0.2.0" || st["newer"] != true {
t.Fatalf("status: %v", st)
}
if st["error"] != "" {
t.Fatalf("unexpected error: %v", st["error"])
}
// A development build can look but not install.
s.opts.Version = "dev"
s.upd.checked = s.upd.checked.AddDate(-1, 0, 0)
if code := c.do("POST", "/api/v1/system/update", map[string]string{}, &st); code != 400 {
t.Fatalf("a development build must not install: %d %v", code, st)
}
}
// A newer binary on the data volume takes over from the image, and a stale one is removed.
func TestOverlay(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("self-update is not supported on Windows")
}
dir := t.TempDir()
bin := filepath.Join(dir, "bin")
os.MkdirAll(bin, 0o755)
overlay := filepath.Join(bin, "sandbar")
script := func(version string) {
os.WriteFile(overlay, []byte("#!/bin/sh\necho 'sandbar "+version+"'\n"), 0o755)
}
var logged []string
logf := func(f string, a ...any) { logged = append(logged, f) }
// An older overlay is deleted once the image is a release that has caught up.
script("v0.1.0")
UseNewerOverlay(dir, "v0.1.5", logf)
if _, err := os.Stat(overlay); err == nil {
t.Fatal("a stale overlay should be removed")
}
// A development image never deletes or switches to anything.
script("v0.9.0")
UseNewerOverlay(dir, "dev", logf)
if _, err := os.Stat(overlay); err != nil {
t.Fatal("a development build must leave the overlay alone")
}
// Something that does not report a version is ignored, not run.
os.WriteFile(overlay, []byte("#!/bin/sh\necho hello\n"), 0o755)
UseNewerOverlay(dir, "v0.1.5", logf)
if len(logged) == 0 || !strings.Contains(logged[len(logged)-1], "did not report a version") {
t.Fatalf("log: %v", logged)
}
}
+29 -1
View File
@@ -755,8 +755,36 @@ function passwordDialog(u) {
}
// ---------- account ----------
// Administrators see which release is running and can install a newer one. The server restarts
// into the new binary, so the page waits for it to come back.
function updateCard(u) {
const status = h('p', {style:'margin:0'}), detail = h('p', {class:'dim', style:'margin:0'}), actions = h('div', {class:'actions form-actions'});
const paint = u => {
status.replaceChildren(`Running ${u.current}. `, u.error ? 'The newest release could not be checked.' : u.newer ? `${u.latest} is available.` : u.latest ? 'This is the newest release.' : '');
detail.textContent = u.error ? u.error : !u.canInstall ? u.reason : u.autoUpdate ? 'Automatic updates are on. Sandbar checks every few hours.' : 'Automatic updates are off. Set SANDBAR_AUTO_UPDATE=1 and restart to turn them on.';
actions.replaceChildren(...[h('button', {class:'ghost', onclick:act(async () => { paint(await api('GET', V1 + '/system/update?fresh=1')); })}, 'Check again'),
u.newer && u.canInstall ? h('button', {class:'btn', onclick:ask({title:`Update to ${u.latest}?`, danger:false, confirm:'Update now',
body:'Sandbar downloads the release, checks it, and restarts. Monitoring and paging pause for a few seconds. Your data is not changed.'}, async () => {
await api('POST', V1 + '/system/update', {}); wait(u.current); })}, `Update to ${u.latest}`) : null].filter(Boolean));
};
const wait = async from => {
actions.replaceChildren(); status.textContent = 'Updating.';
for (let i = 0; i < 120; i++) {
await new Promise(r => setTimeout(r, 1500));
let s; try { s = await api('GET', V1 + '/system/update'); } catch (e) { detail.textContent = 'Restarting.'; continue; }
if (s.current !== from) { toast(`Updated to ${s.current}.`); return refresh(); }
if (s.failed) { toast(s.failed, true); return paint(s); }
detail.textContent = s.line || s.stage;
}
toast('Sandbar did not come back in time. Check the server.', true);
};
paint(u);
return card('Software update', h('div', {class:'stack'}, status, detail, actions));
}
views.account = async () => {
const keys = await api('GET', V1 + '/apikeys'), pw = h('input', {type:'password', autocomplete:'new-password'});
const upd = S.me.role === 'admin' ? await api('GET', V1 + '/system/update').catch(() => null) : null;
S.headerAction = null;
return h('div', {}, card('Password', h('div', {class:'stack'}, field('New password', pw, 'At least 10 characters. You are signed out on other devices.'),
h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:act(async () => { await api('POST', `${V1}/users/${S.me.id}/password`, {password:pw.value}); pw.value = ''; toast('Password changed. Sign in again.'); S.me = null; boot(); })}, 'Change password')))),
@@ -766,7 +794,7 @@ views.account = async () => {
h('button', {class:'ghost', onclick:() => { const n = h('input'); dialogForm('Create an API key', null, [field('Name', n, 'Where you will use it, such as "prometheus".')], 'Create key', async () => {
const k = await api('POST', V1 + '/apikeys', {name:n.value}); refresh();
const done = modal('Copy your API key', h('div', {class:'stack'}, h('p', {class:'dim', style:'margin:0'}, 'This is the only time it is shown.'), cmdBox(k.token), h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => done()}, 'Done')))); }); }}, icon('add', 16), 'Create API key')),
card('Your personal topic', h('p', {class:'dim'}, 'Subscribe to this topic in the ntfy app, or in a browser on the Topics page, to be paged when an escalation policy reaches you.'), cmdBox(S.me.topic)));
card('Your personal topic', h('p', {class:'dim'}, 'Subscribe to this topic in the ntfy app, or in a browser on the Topics page, to be paged when an escalation policy reaches you.'), cmdBox(S.me.topic)), upd ? updateCard(upd) : null);
};
boot();