mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 16:02:48 +00:00
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
This commit is contained in:
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
||||
@@ -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/).
|
||||
|
||||
Executable
+91
@@ -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"
|
||||
@@ -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
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user