From 73f45fb3ae4261f1c2c5445c2a823091bc79b550 Mon Sep 17 00:00:00 2001 From: Veillax <126523099+Veillax@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:22:54 -0600 Subject: [PATCH] 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 /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 --- .env.example | 7 + .gitattributes | 1 + .gitea/workflows/ci.yml | 4 + .gitea/workflows/release.yml | 35 +++-- .gitignore | 1 + README.md | 28 +++- cmd/sandbar/main.go | 29 +++- deploy/build.sh | 47 ++++++ deploy/publish-release.sh | 71 +++++++++ internal/sandbar/api.go | 2 + internal/sandbar/server.go | 11 ++ internal/sandbar/update.go | 255 ++++++++++++++++++++++++++++++++ internal/sandbar/update_test.go | 109 ++++++++++++++ internal/sandbar/web/index.html | 30 +++- 14 files changed, 610 insertions(+), 20 deletions(-) create mode 100644 .gitattributes create mode 100644 deploy/build.sh create mode 100644 deploy/publish-release.sh create mode 100644 internal/sandbar/update.go create mode 100644 internal/sandbar/update_test.go diff --git a/.env.example b/.env.example index dedc78a..87b9985 100644 --- a/.env.example +++ b/.env.example @@ -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 /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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6722d90..9252a85 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 ./... diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 2b34632..0a24532 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index 882ce02..559b3f7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ master.key .devdata/ bin/ +dist/ diff --git a/README.md b/README.md index d706561..66d5fd6 100644 --- a/README.md +++ b/README.md @@ -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 `/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 + `/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 diff --git a/cmd/sandbar/main.go b/cmd/sandbar/main.go index a92b832..37d1855 100644 --- a/cmd/sandbar/main.go +++ b/cmd/sandbar/main.go @@ -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) { diff --git a/deploy/build.sh b/deploy/build.sh new file mode 100644 index 0000000..f76ed4b --- /dev/null +++ b/deploy/build.sh @@ -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/_/sandbar the raw binaries (the Docker image uses the linux ones) +# dist/sandbar___.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/" diff --git a/deploy/publish-release.sh b/deploy/publish-release.sh new file mode 100644 index 0000000..e4ac71b --- /dev/null +++ b/deploy/publish-release.sh @@ -0,0 +1,71 @@ +#!/bin/sh +# Creates (or updates) the Gitea release for a tag and uploads what deploy/build.sh made. +# +# TOKEN= 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 } +: "${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" diff --git a/internal/sandbar/api.go b/internal/sandbar/api.go index 0f8af84..bd576df 100644 --- a/internal/sandbar/api.go +++ b/internal/sandbar/api.go @@ -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)) diff --git a/internal/sandbar/server.go b/internal/sandbar/server.go index b660441..d246405 100644 --- a/internal/sandbar/server.go +++ b/internal/sandbar/server.go @@ -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 } diff --git a/internal/sandbar/update.go b/internal/sandbar/update.go new file mode 100644 index 0000000..a947fce --- /dev/null +++ b/internal/sandbar/update.go @@ -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 +// /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 /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 /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 +} diff --git a/internal/sandbar/update_test.go b/internal/sandbar/update_test.go new file mode 100644 index 0000000..11a63e4 --- /dev/null +++ b/internal/sandbar/update_test.go @@ -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) + } +} diff --git a/internal/sandbar/web/index.html b/internal/sandbar/web/index.html index 0b8eea3..818e905 100644 --- a/internal/sandbar/web/index.html +++ b/internal/sandbar/web/index.html @@ -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();