From bb35de0455dbe9c82f6bde5564abdd225befaba5 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 3 Sep 2026 04:31:07 -0700 Subject: [PATCH] feat: address review on self-hosted updates: the updater no longer re-locks its mutex when a job finishes (every job used to deadlock at completion and freeze the status API), the backend caches the updater view so the member version pill, the health checks and the admin poll share one read and an absent updater is reported as not running rather than broken, the bare-metal upgrade builds unprivileged and hands off to a root-owned fixed-path installer that refuses symlinks so sudoers allows one command instead of install/cp/rm/chown/chmod/systemctl/ln, the installer fails when the backend does not come back, the seed image gets the version build args, the dashboard gates the update action on manage_settings and stops polling a backend that answers 404, and revived timestamps are typed as Date --- admin/src/components/layout/UpdateDialog.tsx | 6 +- deploy/systemd/README.md | 10 +- deploy/systemd/warmbly-install-release.sh | 91 +++++++++++++++++++ deploy/systemd/warmbly-updater.service | 5 +- docker-compose.yml | 9 +- docs/content/docs/development/bare-metal.mdx | 3 +- docs/content/docs/development/updates.mdx | 11 ++- internal/app/instancecheck/checks_updates.go | 4 +- internal/app/updates/service.go | 65 +++++++++++-- internal/updater/runner.go | 14 ++- scripts/upgrade-bare-metal.sh | 81 +++++------------ web/src/components/layout/UpdateDialog.tsx | 6 +- web/src/components/layout/VersionPill.tsx | 13 ++- .../lib/api/hooks/auth/useInstanceVersion.ts | 7 +- web/src/lib/api/models/auth/InstanceUpdate.ts | 15 +-- .../lib/api/models/auth/InstanceVersion.ts | 5 +- 16 files changed, 242 insertions(+), 103 deletions(-) create mode 100755 deploy/systemd/warmbly-install-release.sh diff --git a/admin/src/components/layout/UpdateDialog.tsx b/admin/src/components/layout/UpdateDialog.tsx index 7bd2d6f1..491ea586 100644 --- a/admin/src/components/layout/UpdateDialog.tsx +++ b/admin/src/components/layout/UpdateDialog.tsx @@ -125,7 +125,11 @@ export function UpdateDialog({ open, onOpenChange }: Props) { const checkout = updater?.checkout; const job = updater?.job ?? updater?.last_job; const canApply = - canManage && updater?.status === "ok" && !!state?.update_available && phase === "idle"; + canManage && + updater?.status === "ok" && + !!state?.update_available && + !checkout?.dirty && + phase === "idle"; return ( diff --git a/deploy/systemd/README.md b/deploy/systemd/README.md index 1311f3ff..54df5e45 100644 --- a/deploy/systemd/README.md +++ b/deploy/systemd/README.md @@ -25,6 +25,10 @@ file is what `POST /api/v1/workers/enroll` returns for an enrollment token. `warmbly-updater.service` is the exception to the unprivileged rule: it runs as the user who owns the checkout (`deploy` in the unit; change it) and drives -`scripts/upgrade-bare-metal.sh`, which uses sudo for the install and restart -steps. `updater.env` holds only `UPDATER_TOKEN` (the backend's -`INTERNAL_API_TOKEN`). See [Updates](https://docs.warmbly.com/development/updates/). +`scripts/upgrade-bare-metal.sh`, which builds unprivileged and then runs +`warmbly-install-release.sh` (installed root-owned at +`/usr/local/sbin/warmbly-install-release`) through sudo. That installer takes no +arguments, touches only fixed paths and refuses symlinks, and is the single +command to allow in sudoers. `updater.env` holds only `UPDATER_TOKEN` (the +backend's `INTERNAL_API_TOKEN`). See +[Updates](https://docs.warmbly.com/development/updates/). diff --git a/deploy/systemd/warmbly-install-release.sh b/deploy/systemd/warmbly-install-release.sh new file mode 100755 index 00000000..d75136c3 --- /dev/null +++ b/deploy/systemd/warmbly-install-release.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# +# The privileged half of a bare-metal upgrade: install the artifacts that +# scripts/upgrade-bare-metal.sh built under /opt/warmbly/src/out and friends, +# then restart the units, backend first. It takes no arguments and touches only +# fixed paths, so it is the one command the checkout's owner may run through +# sudo without a password. Install it root-owned and not writable by that user: +# +# sudo install -o root -g root -m 0755 deploy/systemd/warmbly-install-release.sh /usr/local/sbin/warmbly-install-release +# deploy ALL=(root) NOPASSWD: /usr/local/sbin/warmbly-install-release # visudo +# +# A symlink under the build output is refused: a caller who owns the checkout +# must not be able to make root read or install a file from somewhere else. +set -euo pipefail + +SRC=/opt/warmbly/src +PREFIX=/opt/warmbly +HEALTH=http://127.0.0.1:8080/health + +[[ "$(id -u)" -eq 0 ]] || { echo "run through sudo" >&2; exit 1; } +[[ $# -eq 0 ]] || { echo "takes no arguments" >&2; exit 2; } + +log() { printf '==> %s\n' "$*"; } +regular() { [[ -f "$1" && ! -L "$1" ]]; } +plain_dir() { [[ -d "$1" && ! -L "$1" ]]; } +has_unit() { systemctl list-unit-files "warmbly-$1.service" --no-legend 2>/dev/null | grep -q .; } + +log "installing binaries" +for bin in backend forms consumer worker migrate warmblyctl updater; do + f="$SRC/out/$bin" + if regular "$f"; then + install -o root -g root -m 0755 "$f" "$PREFIX/bin/$bin" + fi +done +ln -sf "$PREFIX/bin/warmblyctl" /usr/local/bin/warmblyctl + +if has_unit tracking && regular "$SRC/tracking/target/release/tracking"; then + install -o root -g root -m 0755 "$SRC/tracking/target/release/tracking" "$PREFIX/bin/tracking" +fi + +if has_unit realtime && plain_dir "$SRC/realtime/_build/prod/rel/realtime"; then + log "installing realtime" + rm -rf "$PREFIX/realtime" + cp -r --no-dereference "$SRC/realtime/_build/prod/rel/realtime" "$PREFIX/realtime" + chown -R warmbly:warmbly "$PREFIX/realtime" +fi + +# The runtime config.js is written by hand on a bare-metal install and a +# rebuilt dist/ would drop it, so it is kept across the copy. +for app in web admin; do + if plain_dir "$SRC/$app/dist" && plain_dir "$PREFIX/$app"; then + log "installing $app" + cfg="$(mktemp)" + [[ -f "$PREFIX/$app/config.js" ]] && cp "$PREFIX/$app/config.js" "$cfg" + rm -rf "$PREFIX/$app" + cp -r --no-dereference "$SRC/$app/dist" "$PREFIX/$app" + [[ -s "$cfg" ]] && cp "$cfg" "$PREFIX/$app/config.js" + rm -f "$cfg" + chown -R root:root "$PREFIX/$app" + chmod -R a+rX "$PREFIX/$app" + fi +done +if plain_dir "$SRC/forms/dist" && plain_dir "$PREFIX/forms"; then + log "installing forms" + rm -rf "$PREFIX/forms/dist" + cp -r --no-dereference "$SRC/forms/dist" "$PREFIX/forms/dist" + chown -R root:root "$PREFIX/forms/dist" + chmod -R a+rX "$PREFIX/forms/dist" +fi + +log "restarting backend" +systemctl restart warmbly-backend +healthy=0 +for _ in $(seq 1 60); do + if curl -fsS "$HEALTH" >/dev/null 2>&1; then healthy=1; break; fi + sleep 2 +done +if [[ "$healthy" -ne 1 ]]; then + log "the backend did not answer at $HEALTH within two minutes; the other services were not restarted" + exit 1 +fi + +rest=() +for svc in forms consumer tracking realtime worker; do + has_unit "$svc" && rest+=("warmbly-$svc") +done +if [[ ${#rest[@]} -gt 0 ]]; then + log "restarting ${rest[*]}" + systemctl restart "${rest[@]}" +fi +log "installed" diff --git a/deploy/systemd/warmbly-updater.service b/deploy/systemd/warmbly-updater.service index 5f51f058..766b6883 100644 --- a/deploy/systemd/warmbly-updater.service +++ b/deploy/systemd/warmbly-updater.service @@ -7,8 +7,9 @@ Wants=network-online.target [Service] Type=simple # Runs as the user who owns the checkout, so git writes stay theirs. The -# upgrade script escalates for the install and restart steps through sudo; -# grant that user the sudoers line from the docs. +# upgrade script builds unprivileged and then runs the fixed-path installer +# (/usr/local/sbin/warmbly-install-release, root-owned) through sudo; that +# installer is the only command to allow in sudoers. See the docs. User=deploy Group=deploy EnvironmentFile=/etc/warmbly/updater.env diff --git a/docker-compose.yml b/docker-compose.yml index 70b98197..8e926504 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -346,7 +346,10 @@ services: WORKER_IMAGE: ${WORKER_IMAGE:-} # Update indicator: polls GitHub Releases and shows a newer version in the # admin panel's top bar. Applying it goes through the updater service - # below; unset UPDATER_URL to keep the panel report-only. + # below. The address is always set because a profile cannot be tested + # here; with the profile off the host does not resolve and the backend + # reports the updater as not running (report-only), never as broken. + # UPDATER_URL=none in .env turns the button off explicitly. UPDATE_CHECK_ENABLED: ${UPDATE_CHECK_ENABLED:-true} UPDATE_CHECK_INTERVAL: ${UPDATE_CHECK_INTERVAL:-30m} UPDATE_CHANNEL: ${UPDATE_CHANNEL:-stable} @@ -591,6 +594,10 @@ services: build: context: . dockerfile: deploy/docker/backend.Dockerfile + args: + VERSION: ${WARMBLY_BUILD_VERSION:-} + COMMIT: ${WARMBLY_BUILD_COMMIT:-} + BUILT_AT: ${WARMBLY_BUILD_TIME:-} entrypoint: ["/app/seed"] environment: <<: *selfhost-env diff --git a/docs/content/docs/development/bare-metal.mdx b/docs/content/docs/development/bare-metal.mdx index 3b204c33..6d797ea9 100644 --- a/docs/content/docs/development/bare-metal.mdx +++ b/docs/content/docs/development/bare-metal.mdx @@ -562,9 +562,10 @@ Because the worker is not in a container, the admin panel's SSH-driven day-two a ## Upgrading -`scripts/upgrade-bare-metal.sh` is the build steps above in order: build every artifact this host runs, install it, restart the backend first (migrations are forward-only and apply on its boot), then the rest, keeping each frontend's `config.js` across the copy. Run it as the user who owns the checkout: +`scripts/upgrade-bare-metal.sh` is the build steps above in order: build every artifact this host runs as the checkout's owner, then run the root-owned installer `warmbly-install-release` through sudo, which installs them, restarts the backend first (migrations are forward-only and apply on its boot), then the rest, keeping each frontend's `config.js` across the copy. Install that one script root-owned once, then run the upgrade as the user who owns the checkout: ```bash +sudo install -o root -g root -m 0755 /opt/warmbly/src/deploy/systemd/warmbly-install-release.sh /usr/local/sbin/warmbly-install-release cd /opt/warmbly/src && scripts/upgrade-bare-metal.sh --pull # git pull --ff-only, build, install, restart ``` diff --git a/docs/content/docs/development/updates.mdx b/docs/content/docs/development/updates.mdx index 6a798820..38c75204 100644 --- a/docs/content/docs/development/updates.mdx +++ b/docs/content/docs/development/updates.mdx @@ -84,24 +84,27 @@ To update by hand instead: `make upgrade` (a fast-forward pull, then `make up`). ### Without Docker -On a [bare-metal install](/development/bare-metal/) the updater is a systemd unit that runs `scripts/upgrade-bare-metal.sh` after pulling. The script is the documented upgrade procedure as a script: build every artifact this host runs, install it, restart the backend first, then the rest, keeping each frontend's `config.js` across the copy. +On a [bare-metal install](/development/bare-metal/) the updater is a systemd unit that runs `scripts/upgrade-bare-metal.sh` after pulling. The script builds every artifact this host runs as the checkout's owner, then hands off to one privileged installer, `warmbly-install-release`, which copies the artifacts into `/opt/warmbly`, keeps each frontend's `config.js`, restarts the backend first and the rest once it answers. ```bash cd /opt/warmbly/src go build -o out/updater ./cmd/updater && sudo install -m 0755 out/updater /opt/warmbly/bin/ +sudo install -o root -g root -m 0755 deploy/systemd/warmbly-install-release.sh /usr/local/sbin/warmbly-install-release sudo install -m 0644 deploy/systemd/warmbly-updater.service /etc/systemd/system/ printf 'UPDATER_TOKEN=%s\n' "$(grep ^INTERNAL_API_TOKEN= /etc/warmbly/warmbly.env | cut -d= -f2-)" | sudo tee /etc/warmbly/updater.env >/dev/null sudo chmod 0600 /etc/warmbly/updater.env sudo systemctl daemon-reload && sudo systemctl enable --now warmbly-updater ``` -The unit runs as the user who owns the checkout (`deploy` as shipped; edit it), and the script uses `sudo` for the install and restart steps, so that user needs a sudoers line for exactly those commands: +The unit runs as the user who owns the checkout (`deploy` as shipped; edit it). The only root step is the installer, so that user needs exactly one sudoers line, and nothing broader: ``` -deploy ALL=(root) NOPASSWD: /usr/bin/install, /bin/cp, /bin/rm, /bin/chown, /bin/chmod, /bin/systemctl, /bin/ln +deploy ALL=(root) NOPASSWD: /usr/local/sbin/warmbly-install-release ``` -Then point the backend at it in `warmbly.env` and restart it: +The installer is root-owned and not writable by that user, takes no arguments, reads only from fixed paths under the checkout and refuses symlinks there, so owning the checkout does not become owning the host. The binaries it installs run as the unprivileged `warmbly` user either way. + +Then point the backend at the updater in `warmbly.env` and restart it: ```bash UPDATER_URL=http://127.0.0.1:8095 diff --git a/internal/app/instancecheck/checks_updates.go b/internal/app/instancecheck/checks_updates.go index 32cb64b4..0681e4f9 100644 --- a/internal/app/instancecheck/checks_updates.go +++ b/internal/app/instancecheck/checks_updates.go @@ -36,7 +36,7 @@ func checkUpdateAvailable(ctx context.Context, d Deps, _ Input) *Finding { if st.Updater.Status == "ok" { msg += "Open the version pill in the top bar and choose Update and restart; the stack rebuilds, restarts and resumes on its own." } else { - msg += "Pull the repository and restart the stack, or enable the updater to do it from this panel." + msg += "Run make upgrade on the host, or enable the updater to do it from this panel." } return result(CategoryUpdates, SeverityInfo, "An update is available", msg, docsUpdates) } @@ -48,7 +48,7 @@ func checkUpdaterUnreachable(ctx context.Context, d Deps, _ Input) *Finding { return nil } st := d.Updates.State(ctx, false) - if !st.Updater.Configured || st.Updater.Status == "ok" { + if st.Updater.Status != "unreachable" { return nil } return result(CategoryUpdates, SeverityWarning, "The updater is not answering", diff --git a/internal/app/updates/service.go b/internal/app/updates/service.go index c76d200c..9c07911a 100644 --- a/internal/app/updates/service.go +++ b/internal/app/updates/service.go @@ -94,8 +94,22 @@ type Service struct { latest *Latest checkedAt time.Time checkErr string + + // The updater view is cached briefly so the member-facing version pill, + // the health checks and the admin poll share one read instead of each + // dialling the updater, and a stalled updater cannot slow every caller. + viewMu sync.Mutex + view UpdaterView + viewUntil time.Time } +// viewTTL is how long a good updater read is served from cache; viewFailTTL +// how long a failed one is, so an absent updater is dialled rarely. +const ( + viewTTL = 2 * time.Second + viewFailTTL = 30 * time.Second +) + func New(cfg Config) *Service { if cfg.HTTPClient == nil { cfg.HTTPClient = &http.Client{Timeout: 15 * time.Second} @@ -141,14 +155,38 @@ func (s *Service) Start(ctx context.Context) { func (s *Service) Check(ctx context.Context) State { s.checkGitHub(ctx) view := s.updaterStatus(ctx, http.MethodPost, "/check") + s.storeView(view) return s.compose(view, false) } -// State returns the cached release check plus a live read of the updater. -// withLog keeps job logs; the top-bar poll drops them to stay small. +// State returns the cached release check plus the updater's state, read live +// at most every viewTTL. withLog keeps job logs; the top-bar poll drops them. func (s *Service) State(ctx context.Context, withLog bool) State { + return s.compose(s.cachedView(ctx), !withLog) +} + +func (s *Service) cachedView(ctx context.Context) UpdaterView { + s.viewMu.Lock() + if time.Now().Before(s.viewUntil) { + v := s.view + s.viewMu.Unlock() + return v + } + s.viewMu.Unlock() view := s.updaterStatus(ctx, http.MethodGet, "/status") - return s.compose(view, !withLog) + s.storeView(view) + return view +} + +func (s *Service) storeView(view UpdaterView) { + ttl := viewTTL + if view.Status == "unreachable" || (view.Status == "off" && view.Configured) { + ttl = viewFailTTL + } + s.viewMu.Lock() + s.view = view + s.viewUntil = time.Now().Add(ttl) + s.viewMu.Unlock() } // Apply asks the updater to move to target: "latest" picks the tracked branch @@ -161,8 +199,12 @@ func (s *Service) Apply(ctx context.Context, target string) (*updater.Job, error switch strings.TrimSpace(target) { case "", "latest", "branch": view := s.updaterStatus(ctx, http.MethodGet, "/status") + s.storeView(view) if view.Status != "ok" { - return nil, fmt.Errorf("updater is %s: %s", view.Status, view.Error) + if view.Error == "" { + return nil, ErrUpdaterNotConfigured + } + return nil, errors.New(view.Error) } if view.Checkout != nil && view.Checkout.Detached { s.mu.Lock() @@ -278,10 +320,19 @@ func (s *Service) updaterStatus(ctx context.Context, method, path string) Update return UpdaterView{Status: "off"} } view := UpdaterView{Configured: true} - cctx, cancel := context.WithTimeout(ctx, 8*time.Second) + cctx, cancel := context.WithTimeout(ctx, 4*time.Second) defer cancel() resp, err := s.call(cctx, method, path, nil) if err != nil { + // Under compose the backend always gets UPDATER_URL=http://updater:8095, + // profile or not. A host that does not resolve is the profile being + // off, which is report-only by choice, not a broken updater. + var dns *net.DNSError + if errors.As(err, &dns) { + view.Status = "off" + view.Error = "the updater is not running; enable the updater compose profile (make up does) to update from here" + return view + } view.Status = "unreachable" view.Error = describeDialError(err) return view @@ -322,10 +373,6 @@ func (s *Service) call(ctx context.Context, method, path string, body io.Reader) // describeDialError turns the usual "not running" failures into the sentence // the panel shows, instead of a raw dial string. func describeDialError(err error) string { - var dns *net.DNSError - if errors.As(err, &dns) { - return "the updater host does not resolve; it is not running (enable the updater compose profile) or UPDATER_URL is wrong" - } if strings.Contains(err.Error(), "connection refused") { return "the updater is not accepting connections; it is not running or UPDATER_URL points at the wrong port" } diff --git a/internal/updater/runner.go b/internal/updater/runner.go index a986dbf2..9ba437a6 100644 --- a/internal/updater/runner.go +++ b/internal/updater/runner.go @@ -191,10 +191,10 @@ func (r *Runner) execute(ctx context.Context, job *Job, req UpdateRequest) { if err != nil { job.Status = JobFailed job.Error = err.Error() - r.logf(job, "update failed: %v", err) + r.appendLocked(job, fmt.Sprintf("update failed: %v", err)) } else { job.Status = JobSucceeded - r.logf(job, "update finished") + r.appendLocked(job, "update finished") } r.lastJob = job r.job = nil @@ -356,13 +356,19 @@ func (r *Runner) step(job *Job, name string) { } func (r *Runner) logf(job *Job, format string, args ...any) { - line := time.Now().UTC().Format("15:04:05") + " " + fmt.Sprintf(format, args...) r.mu.Lock() + r.appendLocked(job, fmt.Sprintf(format, args...)) + r.mu.Unlock() +} + +// appendLocked records one log line. The caller holds r.mu; the mutex is not +// reentrant, so the completion path in execute must use this, never logf. +func (r *Runner) appendLocked(job *Job, msg string) { + line := time.Now().UTC().Format("15:04:05") + " " + msg job.Log = append(job.Log, line) if len(job.Log) > maxLogLines { job.Log = job.Log[len(job.Log)-maxLogLines:] } - r.mu.Unlock() log.Printf("updater: %s", line) } diff --git a/scripts/upgrade-bare-metal.sh b/scripts/upgrade-bare-metal.sh index 43ea0fe5..36e3f7ee 100755 --- a/scripts/upgrade-bare-metal.sh +++ b/scripts/upgrade-bare-metal.sh @@ -1,26 +1,22 @@ #!/usr/bin/env bash # -# Rebuild and restart a Docker-free Warmbly install after the checkout moved. -# This is what the updater runs in UPDATER_MODE=command (deploy/systemd/ -# warmbly-updater.service), and what you run by hand after `git pull`. It is -# the "Upgrading" section of docs/content/docs/development/bare-metal.mdx as a -# script: build every artifact that exists on this host, install it, restart -# the backend first (migrations apply on its boot), then the rest. +# Rebuild a Docker-free Warmbly install after the checkout moved, then hand the +# artifacts to the privileged installer. This is what the updater runs in +# UPDATER_MODE=command (deploy/systemd/warmbly-updater.service), and what you +# run by hand after `git pull`. It is the "Upgrading" section of +# docs/content/docs/development/bare-metal.mdx as a script. # -# scripts/upgrade-bare-metal.sh # build + install + restart +# scripts/upgrade-bare-metal.sh # build, then install + restart # scripts/upgrade-bare-metal.sh --pull # git pull --ff-only first # -# Run it as the user who owns the checkout. Install and restart steps use sudo; -# give that user this sudoers line (visudo) so the updater can run unattended: -# -# deploy ALL=(root) NOPASSWD: /usr/bin/install, /bin/cp, /bin/rm, /bin/chown, /bin/chmod, /bin/systemctl, /bin/ln -# +# Run it as the user who owns the checkout. Everything here runs unprivileged; +# the only root step is the fixed-path installer, which is the single command +# that user may run through sudo (see deploy/systemd/warmbly-install-release.sh). set -euo pipefail SRC="${WARMBLY_SRC:-/opt/warmbly/src}" PREFIX="${WARMBLY_PREFIX:-/opt/warmbly}" -SUDO="sudo" -if [[ "$(id -u)" -eq 0 ]]; then SUDO=""; fi +INSTALLER="${WARMBLY_INSTALLER:-/usr/local/sbin/warmbly-install-release}" log() { printf '==> %s\n' "$*"; } @@ -29,6 +25,12 @@ if [[ "${1:-}" == "--pull" ]]; then git -C "$SRC" pull --ff-only fi +[[ -x "$INSTALLER" ]] || { + echo "$INSTALLER is missing. Install it root-owned first:" >&2 + echo " sudo install -o root -g root -m 0755 $SRC/deploy/systemd/warmbly-install-release.sh $INSTALLER" >&2 + exit 1 +} + cd "$SRC" VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo dev)" COMMIT="$(git rev-parse HEAD 2>/dev/null || true)" @@ -65,52 +67,11 @@ if command -v pnpm >/dev/null 2>&1; then done fi -log "installing binaries" -$SUDO install -m 0755 out/backend out/forms out/consumer out/worker out/migrate out/warmblyctl out/updater "$PREFIX/bin/" -$SUDO ln -sf "$PREFIX/bin/warmblyctl" /usr/local/bin/warmblyctl -if [[ -f tracking/target/release/tracking ]] && has_unit tracking; then - $SUDO install -m 0755 tracking/target/release/tracking "$PREFIX/bin/tracking" -fi -if [[ -d realtime/_build/prod/rel/realtime ]] && has_unit realtime; then - $SUDO rm -rf "$PREFIX/realtime" - $SUDO cp -r realtime/_build/prod/rel/realtime "$PREFIX/realtime" - $SUDO chown -R warmbly:warmbly "$PREFIX/realtime" -fi - -# Frontends: the runtime config.js is written by hand on a bare-metal install -# and a rebuilt dist/ would drop it, so keep it across the copy. -for app in web admin; do - if [[ -d "$app/dist" && -d "$PREFIX/$app" ]]; then - log "installing $app" - cfg="$(mktemp)" - [[ -f "$PREFIX/$app/config.js" ]] && cp "$PREFIX/$app/config.js" "$cfg" - $SUDO rm -rf "$PREFIX/$app" - $SUDO cp -r "$app/dist" "$PREFIX/$app" - [[ -s "$cfg" ]] && $SUDO cp "$cfg" "$PREFIX/$app/config.js" - rm -f "$cfg" - $SUDO chmod -R a+rX "$PREFIX/$app" - fi -done -if [[ -d forms/dist && -d "$PREFIX/forms" ]]; then - log "installing forms" - $SUDO rm -rf "$PREFIX/forms/dist" - $SUDO cp -r forms/dist "$PREFIX/forms/dist" -fi - -log "restarting backend" -$SUDO systemctl restart warmbly-backend -for i in $(seq 1 60); do - if curl -fsS http://127.0.0.1:8080/health >/dev/null 2>&1; then break; fi - sleep 2 -done - -rest=() -for svc in forms consumer tracking realtime worker; do - has_unit "$svc" && rest+=("warmbly-$svc") -done -if [[ ${#rest[@]} -gt 0 ]]; then - log "restarting ${rest[*]}" - $SUDO systemctl restart "${rest[@]}" +log "installing and restarting (sudo $INSTALLER)" +if [[ "$(id -u)" -eq 0 ]]; then + "$INSTALLER" +else + sudo -n "$INSTALLER" fi log "done: $VERSION" diff --git a/web/src/components/layout/UpdateDialog.tsx b/web/src/components/layout/UpdateDialog.tsx index 0516de52..02767140 100644 --- a/web/src/components/layout/UpdateDialog.tsx +++ b/web/src/components/layout/UpdateDialog.tsx @@ -892,8 +892,8 @@ function PrimaryButton({ ); } -function relative(iso: string): string { - const diff = Date.now() - new Date(iso).getTime(); +function relative(at: Date | string): string { + const diff = Date.now() - new Date(at).getTime(); const min = Math.round(diff / 60_000); if (min < 1) return "just now"; if (min < 60) return `${min} min ago`; @@ -901,5 +901,5 @@ function relative(iso: string): string { if (h < 24) return `${h} hour${h === 1 ? "" : "s"} ago`; const d = Math.round(h / 24); if (d < 30) return `${d} day${d === 1 ? "" : "s"} ago`; - return new Date(iso).toLocaleDateString(); + return new Date(at).toLocaleDateString(); } diff --git a/web/src/components/layout/VersionPill.tsx b/web/src/components/layout/VersionPill.tsx index b6a499d7..36951704 100644 --- a/web/src/components/layout/VersionPill.tsx +++ b/web/src/components/layout/VersionPill.tsx @@ -18,14 +18,23 @@ import { clearUpdateStarted, readUpdateStarted } from "@/lib/updateSession"; import { cn } from "@/lib/utils"; import UpdateDialog from "./UpdateDialog"; +// Platform admin permission bits, mirroring internal/models/admin_permission.go. +const ADMIN_VIEW_ANALYTICS = 1 << 11; +const ADMIN_MANAGE_SETTINGS = 1 << 14; + export function VersionPill() { const versionQ = useInstanceVersion(); const { data: user } = useUser(); - const isAdmin = user?.is_admin === true; + // Mirrors the backend gates: view_analytics reads the update state, + // manage_settings is what check and apply require. An admin without the + // latter sees the same read-only badge as a member. + const perms = user?.admin_permissions ?? 0; + const canView = (perms & ADMIN_VIEW_ANALYTICS) === ADMIN_VIEW_ANALYTICS; + const isAdmin = (perms & ADMIN_MANAGE_SETTINGS) === ADMIN_MANAGE_SETTINGS; const v = versionQ.data; const selfHosted = !!v?.self_hosted; - const adminQ = useInstanceUpdate(isAdmin && selfHosted); + const adminQ = useInstanceUpdate(canView && selfHosted); const admin = adminQ.data; const qc = useQueryClient(); const [open, setOpen] = React.useState(false); diff --git a/web/src/lib/api/hooks/auth/useInstanceVersion.ts b/web/src/lib/api/hooks/auth/useInstanceVersion.ts index fb943642..e61a8f29 100644 --- a/web/src/lib/api/hooks/auth/useInstanceVersion.ts +++ b/web/src/lib/api/hooks/auth/useInstanceVersion.ts @@ -1,16 +1,19 @@ import { useQuery } from "@tanstack/react-query"; import getInstanceVersion from "../../client/auth/getInstanceVersion"; +import type { AppError } from "../../client/normalizeError"; // Feeds the version pill in the header. The backend re-checks releases on its // own interval, so a five minute poll here is enough for the pill to turn // amber while the tab is open. A backend without the endpoint (an older -// self-host) fails once and the pill stays hidden. +// self-host) answers 404: the pill stays hidden and the poll stops. Any other +// failure keeps polling, because the backend restarting for an update is the +// one moment the pill must come back on its own. export default function useInstanceVersion() { return useQuery({ queryKey: ["auth", "instance"], queryFn: getInstanceVersion, staleTime: 5 * 60_000, - refetchInterval: 5 * 60_000, + refetchInterval: (q) => ((q.state.error as AppError | null)?.status === 404 ? false : 5 * 60_000), retry: false, }); } diff --git a/web/src/lib/api/models/auth/InstanceUpdate.ts b/web/src/lib/api/models/auth/InstanceUpdate.ts index da33831c..a54b9495 100644 --- a/web/src/lib/api/models/auth/InstanceUpdate.ts +++ b/web/src/lib/api/models/auth/InstanceUpdate.ts @@ -1,7 +1,8 @@ /** * The platform admin's view of updates, served by GET /admin/instance/update. * Mirrors internal/app/updates.State. Only a platform admin can read it; the - * member-facing summary is InstanceVersion. + * member-facing summary is InstanceVersion. Timestamps are Date objects: + * Request revives ISO strings on every response. */ export interface InstanceCheckout { branch: string; @@ -11,7 +12,7 @@ export interface InstanceCheckout { remote_commit: string; behind: number; dirty: boolean; - fetched_at: string; + fetched_at: Date; fetch_error?: string; } @@ -22,8 +23,8 @@ export interface UpdateJob { status: UpdateJobStatus; target: string; step: string; - started_at: string; - finished_at?: string; + started_at: Date; + finished_at?: Date; error?: string; from_commit: string; to_commit?: string; @@ -43,11 +44,11 @@ export interface InstanceUpdater { } export default interface InstanceUpdate { - running: { version: string; commit?: string; built_at?: string }; - latest?: { tag: string; name?: string; html_url?: string; published_at?: string; channel: string }; + running: { version: string; commit?: string; built_at?: Date }; + latest?: { tag: string; name?: string; html_url?: string; published_at?: Date; channel: string }; update_available: boolean; reason?: "release" | "commits"; - checked_at?: string; + checked_at?: Date; check_error?: string; enabled: boolean; interval: string; diff --git a/web/src/lib/api/models/auth/InstanceVersion.ts b/web/src/lib/api/models/auth/InstanceVersion.ts index ccd3b4f1..d99411a2 100644 --- a/web/src/lib/api/models/auth/InstanceVersion.ts +++ b/web/src/lib/api/models/auth/InstanceVersion.ts @@ -2,6 +2,7 @@ * The running Warmbly on a self-hosted instance and whether a newer one * exists, served by GET /auth/instance. A hosted deployment answers * `self_hosted: false` and nothing else, so the dashboard shows no pill there. + * Timestamps are Date objects: Request revives ISO strings on every response. */ export default interface InstanceVersion { self_hosted: boolean; @@ -11,7 +12,7 @@ export default interface InstanceVersion { latest?: { tag: string; html_url?: string; - published_at?: string; + published_at?: Date; }; - checked_at?: string; + checked_at?: Date; }