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. # Leave empty and /metrics does not exist.
#SANDBAR_METRICS_TOKEN= #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. # How long topic messages and monitor results are kept, in days.
#SANDBAR_MESSAGE_DAYS=30 #SANDBAR_MESSAGE_DAYS=30
#SANDBAR_CHECK_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 exit 1
fi fi
- name: Shell scripts parse
run: |
for f in deploy/*.sh; do sh -n "$f"; done
- name: Vet - name: Vet
run: go vet ./... run: go vet ./...
+21 -14
View File
@@ -1,18 +1,22 @@
name: Release name: Release
# Push a tag such as v0.1.0 to build and publish the container image: # Push a tag such as v0.2.0 to build and publish a release:
# git tag -a v0.1.0 -m "sandbar v0.1.0" && git push origin v0.1.0 # 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: on:
push: push:
tags: ['v*'] tags: ['v*']
jobs: jobs:
image: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
GOPRIVATE: git.veillax.com GOPRIVATE: git.veillax.com
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0 # release notes come from the history since the previous tag
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
@@ -28,19 +32,22 @@ jobs:
go vet ./... go vet ./...
go test ./... go test ./...
# The Dockerfile packages these binaries, so the arm64 image needs no emulation. - name: Build release archives
- name: Build the binaries run: sh deploy/build.sh "${GITHUB_REF_NAME}"
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
# Optional secret REGISTRY_TOKEN (and variable REGISTRY_USER): an access token with # Optional secret RELEASE_TOKEN: an access token that can write releases, for
# package write permission, for servers where the job token can't push. # 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 - name: Build and push the container image
if: ${{ vars.PUBLISH_DOCKER != 'false' }}
env: env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
REGISTRY_USER: ${{ vars.REGISTRY_USER || github.actor }} REGISTRY_USER: ${{ vars.REGISTRY_USER || github.actor }}
+1
View File
@@ -5,3 +5,4 @@
master.key master.key
.devdata/ .devdata/
bin/ bin/
dist/
+25 -3
View File
@@ -47,6 +47,7 @@ services:
env_file: .env env_file: .env
environment: environment:
SANDBAR_URL: https://sandbar.example.com SANDBAR_URL: https://sandbar.example.com
SANDBAR_AUTO_UPDATE: "1" # optional, see Updating below
volumes: volumes:
- sandbar-data:/data - sandbar-data:/data
@@ -62,12 +63,12 @@ docker compose up -d
a folder instead, make it writable by 65532 (`chown 65532:65532 ./data`). 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. - 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`. - 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 - Update by hand with `docker compose pull && docker compose up -d`, or let Sandbar do it (see Updating below).
before upgrading, since there is no migration tooling yet. Your data stays in the volume. Copy the data folder before upgrading, since there is no migration tooling yet.
### Binary ### 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: under a service manager. A minimal systemd unit:
```ini ```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. 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 ### 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 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/dotenv"
"git.veillax.com/Veillax/reef/secretbox" "git.veillax.com/Veillax/reef/secretbox"
"git.veillax.com/Veillax/reef/selfupdate"
"git.veillax.com/Veillax/sandbar/internal/sandbar" "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") 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)") 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") 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") genkey := flag.Bool("genkey", false, "print a new random SANDBAR_MASTER_KEY and exit")
showVersion := flag.Bool("version", false, "print the version and exit") showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse() flag.Parse()
@@ -58,6 +60,9 @@ func main() {
return 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) key, err := masterKey(*data)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@@ -72,20 +77,40 @@ func main() {
} }
defer st.Close() defer st.Close()
var hs *http.Server
atoi := func(k string) int { n, _ := strconv.Atoi(os.Getenv(k)); return n } 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, DataDir: *data,
URL: strings.TrimRight(*url, "/"), URL: strings.TrimRight(*url, "/"),
MetricsToken: os.Getenv("SANDBAR_METRICS_TOKEN"), MetricsToken: os.Getenv("SANDBAR_METRICS_TOKEN"),
MessageDays: atoi("SANDBAR_MESSAGE_DAYS"), MessageDays: atoi("SANDBAR_MESSAGE_DAYS"),
CheckDays: atoi("SANDBAR_CHECK_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"), 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")}, User: os.Getenv("SANDBAR_SMTP_USER"), Password: os.Getenv("SANDBAR_SMTP_PASSWORD"), From: env("SANDBAR_SMTP_FROM", "sandbar@localhost")},
}, st) }, st)
srv.BootstrapAdmin(os.Getenv("SANDBAR_ADMIN_EMAIL"), os.Getenv("SANDBAR_ADMIN_PASSWORD")) srv.BootstrapAdmin(os.Getenv("SANDBAR_ADMIN_EMAIL"), os.Getenv("SANDBAR_ADMIN_PASSWORD"))
srv.Start() 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() { go func() {
log.Printf("sandbar %s listening on %s", version, *listen) log.Printf("sandbar %s listening on %s", version, *listen)
if err := hs.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 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 return nil
}, 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("POST /api/v1/channels/{id}/test", s.auth(true, s.testChannel))
mux.HandleFunc("GET /api/v1/oncall", s.auth(false, s.onCallNow)) 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) MessageDays int // how long topic messages are kept (default 30)
CheckDays int // how long monitor results 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. // Tests replace these.
Now func() time.Time Now func() time.Time
HTTPClient *http.Client HTTPClient *http.Client
@@ -44,6 +53,8 @@ type Server struct {
loginMu sync.Mutex loginMu sync.Mutex
loginTry map[string][]time.Time loginTry map[string][]time.Time
upd updateState
stop chan struct{} stop chan struct{}
wg sync.WaitGroup 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 ---------- // ---------- 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 () => { views.account = async () => {
const keys = await api('GET', V1 + '/apikeys'), pw = h('input', {type:'password', autocomplete:'new-password'}); 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; 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.'), 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')))), 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 () => { 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 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')), 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(); boot();