From b796faeacff145e048aab89234575b8b11e3fc83 Mon Sep 17 00:00:00 2001 From: Sauer Date: Mon, 9 Nov 2026 14:04:06 +0100 Subject: [PATCH 1/2] Add in-app updates and build release images on self-hosted runners Settings > Updates (admins): installed version and build sha, newer releases from GitHub with their notes, and either an "Update to x.y.z" button or the compose commands to run by hand. - src/server/updates: tiny semver, GitHub Releases client (1h cache, stale on failure, refresh at most once a minute), updater client - GET /api/updates, POST /api/updates/apply (admin), version in /api/health - deploy/updater: sidecar with the Docker socket. The app asks it to move the compose project to a version; a helper container started from the same image pins STOWAGE_VERSION in .env, pulls, recreates the running services and finally the updater itself. State lives in a shared volume so the recreated updater reports the run; a vanished helper is reported as failed. Opt-in via `--profile updater` in docker-compose.release.yml. - Release workflow: per-platform native builds (amd64 on the runner named by the CI_RUNNER variable, arm64 on ubuntu-24.04-arm), digests merged into multi-arch manifests, registry layer caches, GIT_SHA build arg, stowage-updater image. - Docs: README "Updating", RELEASING "Where builds run", env vars, changelog. Co-Authored-By: Claude Fable 5.1 --- .env.example | 13 + .github/workflows/release.yml | 169 ++++-- CHANGELOG.md | 18 + Dockerfile | 5 +- Dockerfile.updater | 16 + README.md | 72 ++- RELEASING.md | 27 +- deploy/updater/server.mjs | 556 ++++++++++++++++++ docker-compose.release.yml | 22 + .../settings/settings-page-client.test.tsx | 17 +- .../settings/updates-section.test.tsx | 266 +++++++++ .../settings/user-management-section.test.tsx | 17 +- src/app/(app)/settings/page.tsx | 2 +- src/app/api/health/route.ts | 7 +- src/app/api/updates/apply/route.ts | 18 + src/app/api/updates/route.ts | 9 + src/components/settings/release-notes.tsx | 96 +++ .../settings/settings-page-client.tsx | 4 +- src/components/settings/updates-section.tsx | 488 +++++++++++++++ src/lib/api/updates.ts | 59 ++ src/server/updates/__tests__/releases.test.ts | 146 +++++ src/server/updates/__tests__/semver.test.ts | 47 ++ .../updates/__tests__/updater-client.test.ts | 110 ++++ src/server/updates/index.ts | 102 ++++ src/server/updates/releases.ts | 143 +++++ src/server/updates/semver.ts | 63 ++ src/server/updates/updater-client.ts | 127 ++++ 27 files changed, 2547 insertions(+), 72 deletions(-) create mode 100644 Dockerfile.updater create mode 100644 deploy/updater/server.mjs create mode 100644 src/__tests__/components/settings/updates-section.test.tsx create mode 100644 src/app/api/updates/apply/route.ts create mode 100644 src/app/api/updates/route.ts create mode 100644 src/components/settings/release-notes.tsx create mode 100644 src/components/settings/updates-section.tsx create mode 100644 src/lib/api/updates.ts create mode 100644 src/server/updates/__tests__/releases.test.ts create mode 100644 src/server/updates/__tests__/semver.test.ts create mode 100644 src/server/updates/__tests__/updater-client.test.ts create mode 100644 src/server/updates/index.ts create mode 100644 src/server/updates/releases.ts create mode 100644 src/server/updates/semver.ts create mode 100644 src/server/updates/updater-client.ts diff --git a/.env.example b/.env.example index 0250b28..3e3e264 100644 --- a/.env.example +++ b/.env.example @@ -72,6 +72,19 @@ STORAGE_LIMIT_GB= # Leave blank if you do not want a label URL base configured. SITE_URL=http://localhost:3000 +# In-app updates (Settings > Updates) +# +# The app always shows newer releases from GitHub. To update from the browser, +# start the release stack with the `updater` profile and give both services the +# same long random token (for example from `openssl rand -hex 32`). The updater +# needs the Docker socket, so only enable it on a host you control. +STOWAGE_UPDATER_TOKEN= + +# Profiles Compose treats as active for every command. Listing them here keeps +# `docker compose up -d` and the in-app updater in agreement about which +# services run. Examples: `pocketbase,updater` or `full,updater`. +COMPOSE_PROFILES= + # E2E tests # # Optional credentials for a real user/admin on the target PocketBase instance. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae3176f..9231924 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,19 +76,49 @@ jobs: - name: Vitest (PocketBase domain) run: pnpm test:pb - docker: - name: Publish Docker images - runs-on: ubuntu-latest + # Each image is built natively per platform: amd64 on the self-hosted + # runner named by the CI_RUNNER repo variable (GitHub's when it is unset), + # arm64 on GitHub's free arm runner. The platform builds push by digest and + # the merge job joins them into one multi-arch tag, so no QEMU emulation is + # needed anywhere. Layer caches live in GHCR next to the images. + build: + name: Build ${{ matrix.image }} (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} needs: verify + timeout-minutes: 40 permissions: contents: read packages: write + strategy: + fail-fast: false + matrix: + image: [app, pocketbase, caddy, updater] + platform: [linux/amd64, linux/arm64] + include: + - image: app + dockerfile: ./Dockerfile + - image: pocketbase + dockerfile: ./Dockerfile.pocketbase + - image: caddy + dockerfile: ./Dockerfile.caddy + - image: updater + dockerfile: ./Dockerfile.updater + - platform: linux/amd64 + runner: ${{ vars.CI_RUNNER || 'ubuntu-latest' }} + - platform: linux/arm64 + runner: ubuntu-24.04-arm + env: + IMAGE: ghcr.io/${{ github.repository_owner }}/stowage-${{ matrix.image }} steps: - uses: actions/checkout@v6 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + - name: Platform slug + id: platform + shell: bash + run: | + platform="${{ matrix.platform }}" + echo "slug=${platform//\//-}" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -100,72 +130,106 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Docker metadata (app) - id: app_meta + - name: Docker metadata + id: meta uses: docker/metadata-action@v5 with: - images: ghcr.io/${{ github.repository_owner }}/stowage-app - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}},enable=${{ !contains(github.ref_name, '-') }} - type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }} + images: ${{ env.IMAGE }} - - name: Docker metadata (pocketbase) - id: pb_meta - uses: docker/metadata-action@v5 + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 with: - images: ghcr.io/${{ github.repository_owner }}/stowage-pocketbase - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}},enable=${{ !contains(github.ref_name, '-') }} - type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }} + context: . + file: ${{ matrix.dockerfile }} + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + tags: ${{ env.IMAGE }} + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + build-args: | + GIT_SHA=${{ github.sha }} + cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache-${{ steps.platform.outputs.slug }} + cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache-${{ steps.platform.outputs.slug }},mode=max + + - name: Export digest + shell: bash + run: | + mkdir -p "${{ runner.temp }}/digests" + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" - - name: Docker metadata (caddy) - id: caddy_meta + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.image }}-${{ steps.platform.outputs.slug }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + publish: + name: Publish ${{ matrix.image }} manifest + runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }} + needs: + - verify + - build + timeout-minutes: 15 + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + image: [app, pocketbase, caddy, updater] + env: + IMAGE: ghcr.io/${{ github.repository_owner }}/stowage-${{ matrix.image }} + + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-${{ matrix.image }}-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta uses: docker/metadata-action@v5 with: - images: ghcr.io/${{ github.repository_owner }}/stowage-caddy + images: ${{ env.IMAGE }} tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}},enable=${{ !contains(github.ref_name, '-') }} type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }} - - name: Build and publish app image - uses: docker/build-push-action@v6 - with: - context: . - file: ./Dockerfile - push: true - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.app_meta.outputs.tags }} - labels: ${{ steps.app_meta.outputs.labels }} - - - name: Build and publish PocketBase image - uses: docker/build-push-action@v6 - with: - context: . - file: ./Dockerfile.pocketbase - push: true - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.pb_meta.outputs.tags }} - labels: ${{ steps.pb_meta.outputs.labels }} + - name: Create multi-arch manifest + shell: bash + working-directory: ${{ runner.temp }}/digests + run: | + set -euo pipefail + tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") + # shellcheck disable=SC2046,SC2086 + docker buildx imagetools create $tags \ + $(printf "$IMAGE@sha256:%s " *) - - name: Build and publish Caddy image - uses: docker/build-push-action@v6 - with: - context: . - file: ./Dockerfile.caddy - push: true - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.caddy_meta.outputs.tags }} - labels: ${{ steps.caddy_meta.outputs.labels }} + - name: Inspect + run: docker buildx imagetools inspect "$IMAGE:${{ steps.meta.outputs.version }}" release: name: Create GitHub release runs-on: ubuntu-latest needs: - verify - - docker + - publish permissions: contents: write @@ -198,6 +262,7 @@ jobs: echo "- \`ghcr.io/$OWNER/stowage-app:$VERSION\`" echo "- \`ghcr.io/$OWNER/stowage-pocketbase:$VERSION\`" echo "- \`ghcr.io/$OWNER/stowage-caddy:$VERSION\`" + echo "- \`ghcr.io/$OWNER/stowage-updater:$VERSION\`" echo echo "## Deployment bundle" echo diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c3526f..9933f69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,24 @@ Until then, version numbers still communicate upgrade risk: ## [Unreleased] +### Added + +- Settings > Updates for admins: the installed version and build, newer + releases with their notes, and a one-click update when the optional + `updater` sidecar runs (`--profile updater`), with the compose commands shown + otherwise. +- Published `stowage-updater` release image. +- `/api/health` now reports the running version. + +### Changed + +- Rebuilt the UI in the Workshop design: concrete background, charcoal + sidebar, one amber primary, cards for objects and sheets for lists, a + phone layout with bottom navigation, and an asset scan flow. +- Release images are built natively per platform (amd64 on the self-hosted + runner, arm64 on GitHub's arm runner) and merged into multi-arch manifests, + with layer caches kept in GHCR. + ## [0.2.0] - 2026-04-21 ### Added diff --git a/Dockerfile b/Dockerfile index 43df6fd..fc20288 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,10 +18,13 @@ RUN pnpm build # Stage 3 — runtime FROM node:22-alpine AS runner ARG APP_PORT=3000 +# Short git sha of the build; shown next to the version in Settings > Updates. +ARG GIT_SHA= WORKDIR /app ENV NODE_ENV=production \ HOSTNAME=0.0.0.0 \ - PORT=${APP_PORT} + PORT=${APP_PORT} \ + STOWAGE_BUILD_SHA=${GIT_SHA} RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs COPY --from=builder /app/.next/standalone ./ diff --git a/Dockerfile.updater b/Dockerfile.updater new file mode 100644 index 0000000..2a982b0 --- /dev/null +++ b/Dockerfile.updater @@ -0,0 +1,16 @@ +# syntax=docker/dockerfile:1.6 + +# The updater sidecar: a small Node HTTP server plus the Docker CLI and the +# compose plugin. The app calls it to pull a published release and recreate +# the compose services. It needs the Docker socket mounted (see +# docker-compose.release.yml, profile "updater"). +FROM node:22-alpine +RUN apk add --no-cache docker-cli docker-cli-compose +WORKDIR /updater +COPY deploy/updater/server.mjs ./server.mjs +ENV NODE_ENV=production +VOLUME ["/state"] +EXPOSE 9876 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ + CMD wget -q --spider http://127.0.0.1:9876/health || exit 1 +CMD ["node", "/updater/server.mjs", "serve"] diff --git a/README.md b/README.md index 623c1e2..b4d2702 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ Each release publishes: - `ghcr.io/kroqdotdev/stowage-app:` - `ghcr.io/kroqdotdev/stowage-pocketbase:` - `ghcr.io/kroqdotdev/stowage-caddy:` +- `ghcr.io/kroqdotdev/stowage-updater:` - `docker-compose.release.yml` as a release deployment bundle For production/self-hosted deploys, pin exact image tags instead of floating to a @@ -125,7 +126,8 @@ Image contents: - `stowage-app` contains the production Next.js standalone server plus static/public assets - `stowage-pocketbase` contains the pinned PocketBase binary plus this repo's `pb_migrations/` - `stowage-caddy` contains the bundled Caddy config for TLS termination and `/pb` proxying -- neither image contains your persistent PocketBase data; that still lives in `pb_data/` +- `stowage-updater` is the optional sidecar behind one-click updates (see [Updating](#updating)) +- none of the images contain your persistent PocketBase data; that still lives in `pb_data/` See [RELEASING.md](/Users/sauer/Documents/cc/stowage.cc/RELEASING.md) for the release checklist and tag flow. @@ -165,24 +167,68 @@ By default, the release compose file binds the app and PocketBase ports to `127.0.0.1`; set `APP_HOST_BIND=0.0.0.0` and/or `POCKETBASE_HOST_BIND=0.0.0.0` if you intentionally want them reachable off-host without Caddy. +### Updating + +Admins see the installed version and any newer release, with its notes, under +**Settings > Updates**. The app checks GitHub Releases about once an hour and on +demand with **Check now**. + +**By hand.** Pin the new version and recreate the services, adding the +profiles you deploy with: + +```bash +STOWAGE_VERSION=0.3.0 docker compose -f docker-compose.release.yml --profile pocketbase pull +STOWAGE_VERSION=0.3.0 docker compose -f docker-compose.release.yml --profile pocketbase up -d +``` + +Or set `STOWAGE_VERSION` in `.env` and run the same two commands without the +prefix. PocketBase migrations bundled with the new image apply on start, and +`pb_data/` is untouched, but take a backup first. + +**From the browser.** Add the `updater` profile and a shared token, and the +Updates page gets an **Update to x.y.z** button: + +```bash +# .env +STOWAGE_UPDATER_TOKEN= +COMPOSE_PROFILES=pocketbase,updater # the profiles you deploy with, plus updater + +docker compose -f docker-compose.release.yml up -d +``` + +When an admin confirms an update, the sidecar writes `STOWAGE_VERSION=` +into the project's `.env`, pulls the images for the services that are running, +recreates them, and finally recreates itself. The page shows the log and +reloads when the new version answers. A failed pull leaves everything running +as it was. + +The updater mounts the Docker socket, which is equivalent to root on the host, +and is only reachable on the compose network with the token. Leave the profile +out on hosts where that is not acceptable; the Updates page then shows the +commands above instead. + ### Production deployment The browser talks to PocketBase directly for realtime subscriptions, so for a public deployment put both services behind a reverse proxy on a single domain (Caddy, nginx, Traefik). Set `NEXT_PUBLIC_POCKETBASE_URL` to the proxy URL the browser should use (e.g. `https://stowage.example.com/pb`), and `POCKETBASE_URL` to the internal address Next uses to reach PB (e.g. `http://pocketbase:8090`). `pb_data/` is the only stateful directory — back it up. Environment variables: -| Var | Required | Notes | -| ------------------------------------------ | -------- | ------------------------------------------------------- | -| `APP_PORT` | no | Port the Next.js app listens on inside the container | -| `APP_HOST_PORT` | no | Host port mapped to the Next.js container | -| `POCKETBASE_HOST_PORT` | no | Host port mapped to PocketBase | -| `APP_DOMAIN` | no | Public domain for the optional built-in TLS profile | -| `HTTP_PORT` / `HTTPS_PORT` | no | Host ports published by the optional Caddy TLS service | -| `POCKETBASE_URL` | yes | Server-to-PB URL (e.g. `http://pocketbase:8090`) | -| `NEXT_PUBLIC_POCKETBASE_URL` | yes | Browser-to-PB URL (same origin as the app in prod) | -| `POCKETBASE_SUPERUSER_EMAIL` / `_PASSWORD` | yes | Used by `pnpm pb:bootstrap` and the app's admin client | -| `STORAGE_LIMIT_GB` | no | Global cap on total attachment bytes; omit for no limit | -| `SITE_URL` | no | Base URL embedded in label QR codes | +| Var | Required | Notes | +| ------------------------------------------ | -------- | ------------------------------------------------------------- | +| `APP_PORT` | no | Port the Next.js app listens on inside the container | +| `APP_HOST_PORT` | no | Host port mapped to the Next.js container | +| `POCKETBASE_HOST_PORT` | no | Host port mapped to PocketBase | +| `APP_DOMAIN` | no | Public domain for the optional built-in TLS profile | +| `HTTP_PORT` / `HTTPS_PORT` | no | Host ports published by the optional Caddy TLS service | +| `POCKETBASE_URL` | yes | Server-to-PB URL (e.g. `http://pocketbase:8090`) | +| `NEXT_PUBLIC_POCKETBASE_URL` | yes | Browser-to-PB URL (same origin as the app in prod) | +| `POCKETBASE_SUPERUSER_EMAIL` / `_PASSWORD` | yes | Used by `pnpm pb:bootstrap` and the app's admin client | +| `STORAGE_LIMIT_GB` | no | Global cap on total attachment bytes; omit for no limit | +| `SITE_URL` | no | Base URL embedded in label QR codes | +| `STOWAGE_VERSION` | no | Release image tag used by `docker-compose.release.yml` | +| `STOWAGE_UPDATER_TOKEN` | no | Shared secret between the app and the `updater` sidecar | +| `STOWAGE_UPDATER_URL` | no | Where the app reaches the updater (`http://updater:9876`) | +| `COMPOSE_PROFILES` | no | Profiles Compose treats as active (e.g. `pocketbase,updater`) | ## Tests diff --git a/RELEASING.md b/RELEASING.md index 0666e83..e908204 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -22,6 +22,7 @@ Each tagged release publishes: - a multi-arch app image at `ghcr.io/kroqdotdev/stowage-app:` - a multi-arch PocketBase image at `ghcr.io/kroqdotdev/stowage-pocketbase:` - a multi-arch Caddy image at `ghcr.io/kroqdotdev/stowage-caddy:` +- a multi-arch updater image at `ghcr.io/kroqdotdev/stowage-updater:` - the release deployment bundle file `docker-compose.release.yml` Those image tags are meant to be pinned in deployment config so upgrades happen @@ -35,6 +36,30 @@ Image contents: bundled `pb_migrations/`; persistent user data still lives in `/pb_data` - `stowage-caddy` contains the repo's Caddyfile for TLS termination and `/pb` reverse proxy routing, while certificate state lives in `/data` and `/config` +- `stowage-updater` contains the sidecar from `deploy/updater/` that applies + in-app updates (Settings > Updates) by pulling a release and recreating the + compose services + +The in-app update check reads the GitHub Releases of this repo, so the release +notes admins see are the changelog section the workflow copies into the +release. Draft releases are ignored; prereleases are only offered to +installations already running a prerelease. + +## Where builds run + +The `Release` workflow builds each image natively per platform and merges the +results into one multi-arch manifest, so nothing runs under QEMU: + +- `linux/amd64` builds run on the self-hosted runner named by the `CI_RUNNER` + repository variable (label `proxmox`, runner `gh-runner-stowage` in Proxmox + CT 105). Delete the variable to send them back to GitHub's runners. +- `linux/arm64` builds run on GitHub's `ubuntu-24.04-arm` runner, which is + free for public repositories. +- Layer caches live in GHCR as `:buildcache-linux-amd64` and + `:buildcache-linux-arm64` tags; they can be deleted at any time. + +The verify step (lint, typecheck, tests) and the GitHub Release step still run +on GitHub's runners. ## Release checklist @@ -52,7 +77,7 @@ git push origin main --tags 6. Wait for the `Release` GitHub Actions workflow to: - verify the tag matches `package.json` - run lint, typecheck, and tests - - build and publish app, PocketBase, and Caddy images to GHCR + - build and publish the app, PocketBase, Caddy, and updater images to GHCR - create the GitHub Release from the matching changelog section ## Prereleases diff --git a/deploy/updater/server.mjs b/deploy/updater/server.mjs new file mode 100644 index 0000000..6d0b4a4 --- /dev/null +++ b/deploy/updater/server.mjs @@ -0,0 +1,556 @@ +#!/usr/bin/env node +/* + Stowage updater sidecar. + + Runs next to the app in the release compose project with the Docker socket + mounted. The app asks it (over the compose network, with a shared token) to + move the project to a published release: + + GET /status current state and the tail of the last run's log + POST /update {version} start an update to that version + + The update itself runs in a short-lived helper container started from this + same image. The helper mounts the compose project directory at its own host + path, so relative volume paths in the compose file (./pb_data) resolve the + way they do on the host, and it survives this container being recreated. + Status and log live in the /state volume, which both containers share, so + the new updater instance reports the run that replaced it. + + The helper: + 1. pins STOWAGE_VERSION= in the project's .env + 2. docker compose pull + 3. docker compose up -d --no-deps + 4. docker compose up -d --no-deps updater (recreates this container) +*/ +import { spawn } from "node:child_process"; +import { promises as fs } from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; + +const SOCKET = process.env.DOCKER_HOST_SOCKET ?? "/var/run/docker.sock"; +const STATE_DIR = process.env.STOWAGE_UPDATER_STATE_DIR ?? "/state"; +const STATE_FILE = path.join(STATE_DIR, "status.json"); +const PORT = Number(process.env.STOWAGE_UPDATER_PORT ?? 9876); +const TOKEN = (process.env.STOWAGE_UPDATER_TOKEN ?? "").trim(); +const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +const LOG_LINES = 200; + +// --------------------------------------------------------------------------- +// Docker Engine API over the unix socket (no dependencies). +// --------------------------------------------------------------------------- + +function docker(method, apiPath, body) { + return new Promise((resolve, reject) => { + const req = http.request( + { + socketPath: SOCKET, + method, + path: apiPath, + headers: body ? { "content-type": "application/json" } : undefined, + }, + (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + const status = res.statusCode ?? 0; + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + if (status >= 400) { + reject( + new Error( + `Docker API ${method} ${apiPath} failed (${status}): ${json?.message ?? text}`, + ), + ); + return; + } + resolve(json); + }); + }, + ); + req.on("error", reject); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +async function inspectSelf() { + const id = os.hostname(); + return docker("GET", `/containers/${encodeURIComponent(id)}/json`); +} + +function composeLabels(info) { + const labels = info?.Config?.Labels ?? {}; + const project = labels["com.docker.compose.project"]; + const workingDir = labels["com.docker.compose.project.working_dir"]; + const configFiles = labels["com.docker.compose.project.config_files"]; + const envFiles = labels["com.docker.compose.project.environment_file"]; + const service = labels["com.docker.compose.service"]; + if (!project || !workingDir || !configFiles) { + throw new Error( + "The updater must run as a service of the Stowage compose project (compose labels missing).", + ); + } + return { + project, + workingDir, + configFiles: configFiles.split(",").filter(Boolean), + envFiles: (envFiles ?? "").split(",").filter(Boolean), + service: service ?? "updater", + }; +} + +async function runningServices(project) { + const filters = encodeURIComponent( + JSON.stringify({ + label: [`com.docker.compose.project=${project}`], + status: ["running"], + }), + ); + const containers = await docker("GET", `/containers/json?filters=${filters}`); + const services = new Set(); + for (const container of containers) { + const service = container.Labels?.["com.docker.compose.service"]; + if (service) services.add(service); + } + return [...services].sort(); +} + +// --------------------------------------------------------------------------- +// State file shared with the helper through the /state volume. +// --------------------------------------------------------------------------- + +const IDLE = { + state: "idle", + targetVersion: null, + startedAt: null, + finishedAt: null, + log: [], + error: null, + runId: null, +}; + +async function readState() { + try { + const raw = await fs.readFile(STATE_FILE, "utf8"); + const parsed = JSON.parse(raw); + return { + ...IDLE, + ...parsed, + log: Array.isArray(parsed.log) ? parsed.log : [], + }; + } catch { + return { ...IDLE }; + } +} + +async function writeState(state) { + await fs.mkdir(STATE_DIR, { recursive: true }); + const tmp = `${STATE_FILE}.tmp`; + await fs.writeFile(tmp, JSON.stringify(state), "utf8"); + await fs.rename(tmp, STATE_FILE); +} + +// --------------------------------------------------------------------------- +// serve: the HTTP API the app talks to. +// --------------------------------------------------------------------------- + +function authorized(req) { + const header = req.headers.authorization ?? ""; + return header === `Bearer ${TOKEN}`; +} + +function send(res, status, body) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (chunk) => { + chunks.push(chunk); + if (Buffer.concat(chunks).length > 4096) { + reject(new Error("Body too large")); + req.destroy(); + } + }); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +let starting = false; + +async function startUpdate(version) { + const self = await inspectSelf(); + const labels = composeLabels(self); + const services = await runningServices(labels.project); + if (!services.length) { + throw new Error( + `No running services found for compose project "${labels.project}".`, + ); + } + const stateMount = (self.Mounts ?? []).find( + (m) => m.Destination === STATE_DIR, + ); + if (!stateMount) { + throw new Error(`The updater has no volume mounted at ${STATE_DIR}.`); + } + const stateSource = + stateMount.Type === "volume" ? stateMount.Name : stateMount.Source; + const socketMount = (self.Mounts ?? []).find((m) => m.Destination === SOCKET); + const socketSource = socketMount?.Source ?? SOCKET; + + await cleanupHelper(await readState()); + + const name = `${labels.project}-updater-run-${Date.now()}`; + const created = await docker( + "POST", + `/containers/create?name=${encodeURIComponent(name)}`, + { + Image: self.Config.Image, + Cmd: [ + "node", + "/updater/server.mjs", + "apply", + "--version", + version, + "--project", + labels.project, + "--working-dir", + labels.workingDir, + "--config-files", + labels.configFiles.join(","), + "--env-file", + labels.envFiles[0] ?? path.join(labels.workingDir, ".env"), + "--services", + services.join(","), + "--self", + labels.service, + ], + Env: [ + `STOWAGE_UPDATER_STATE_DIR=${STATE_DIR}`, + `COMPOSE_PROFILES=${process.env.COMPOSE_PROFILES ?? ""}`, + ], + WorkingDir: labels.workingDir, + Labels: { "dev.stowage.updater.run": "true" }, + // The image's healthcheck probes the HTTP server, which the helper does + // not run. + Healthcheck: { Test: ["NONE"] }, + HostConfig: { + Binds: [ + `${socketSource}:${SOCKET}`, + `${stateSource}:${STATE_DIR}`, + `${labels.workingDir}:${labels.workingDir}`, + ], + }, + }, + ); + await writeState({ + state: "running", + targetVersion: version, + startedAt: Date.now(), + finishedAt: null, + log: [`Starting update to ${version} for project ${labels.project}`], + error: null, + runId: created.Id, + }); + await docker("POST", `/containers/${created.Id}/start`); + return { id: created.Id, services }; +} + +async function helperLogs(id) { + return new Promise((resolve) => { + const chunks = []; + const req = http.request( + { + socketPath: SOCKET, + method: "GET", + path: `/containers/${id}/logs?stdout=1&stderr=1&tail=40`, + }, + (res) => { + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + // Demultiplex the 8-byte frame headers Docker adds without a TTY. + let buffer = Buffer.concat(chunks); + let text = ""; + while (buffer.length >= 8) { + const size = buffer.readUInt32BE(4); + text += buffer.subarray(8, 8 + size).toString("utf8"); + buffer = buffer.subarray(8 + size); + } + resolve(text.split("\n").filter(Boolean)); + }); + res.on("error", () => resolve([])); + }, + ); + req.on("error", () => resolve([])); + req.end(); + }); +} + +// A "running" state whose helper is gone means the helper crashed before it +// could report; a finished helper is removed once it has exited. +async function reconcile(state) { + if (!state.runId) return state; + let info = null; + try { + info = await docker("GET", `/containers/${state.runId}/json`); + } catch { + info = null; + } + if (state.state === "running") { + if (info && info.State?.Running) return state; + const logs = info ? await helperLogs(state.runId) : []; + const next = { + ...state, + state: "failed", + finishedAt: Date.now(), + error: + "The update helper stopped before finishing. Check the services with `docker compose ps` on the host.", + log: [...state.log, ...logs.map((line) => `helper: ${line}`)].slice( + -LOG_LINES, + ), + }; + await writeState(next); + await cleanupHelper(next); + return next; + } + if (info && !info.State?.Running) await cleanupHelper(state); + return state; +} + +async function cleanupHelper(state) { + if (!state.runId) return; + try { + await docker("DELETE", `/containers/${state.runId}?force=true`); + } catch { + // already gone + } +} + +function serve() { + if (!TOKEN) { + console.error( + "STOWAGE_UPDATER_TOKEN is not set. Refusing to start the updater.", + ); + process.exit(1); + } + const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url ?? "/", "http://updater"); + if (url.pathname === "/health") { + send(res, 200, { status: "ok" }); + return; + } + if (!authorized(req)) { + send(res, 401, { error: "Unauthorized" }); + return; + } + if (req.method === "GET" && url.pathname === "/status") { + const state = await reconcile(await readState()); + send(res, 200, { + ...state, + runId: undefined, + log: state.log.slice(-LOG_LINES), + }); + return; + } + if (req.method === "POST" && url.pathname === "/update") { + const body = JSON.parse((await readBody(req)) || "{}"); + const version = String(body.version ?? "").trim(); + if (!VERSION_RE.test(version)) { + send(res, 400, { error: "Invalid version" }); + return; + } + const current = await reconcile(await readState()); + if (starting || current.state === "running") { + send(res, 409, { error: "An update is already running" }); + return; + } + starting = true; + try { + const run = await startUpdate(version); + send(res, 202, { + ...(await readState()), + run: run.id, + services: run.services, + }); + } finally { + starting = false; + } + return; + } + send(res, 404, { error: "Not found" }); + } catch (error) { + console.error(error); + send(res, 500, { + error: error instanceof Error ? error.message : "Updater error", + }); + } + }); + server.listen(PORT, "0.0.0.0", () => { + console.log(`Stowage updater listening on :${PORT}`); + }); +} + +// --------------------------------------------------------------------------- +// apply: runs inside the helper container. +// --------------------------------------------------------------------------- + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i += 1) { + const key = argv[i]; + if (key.startsWith("--")) { + out[key.slice(2)] = argv[i + 1]; + i += 1; + } + } + return out; +} + +// Appends are serialised: compose prints several lines at once and two +// concurrent tmp-file writes of the state would race each other. +let logChain = Promise.resolve(); +function appendLog(state, line) { + logChain = logChain + .then(async () => { + const stamp = new Date().toISOString().slice(11, 19); + state.log.push(`${stamp} ${line}`); + if (state.log.length > LOG_LINES) + state.log.splice(0, state.log.length - LOG_LINES); + process.stdout.write(`${line}\n`); + await writeState(state); + }) + .catch((error) => { + console.error("state write failed", error); + }); + return logChain; +} + +function runCommand(state, cmd, args, env) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { env: { ...process.env, ...env } }); + const onData = (chunk) => { + for (const line of chunk.toString("utf8").split("\n")) { + if (line.trim()) appendLog(state, line.trimEnd()); + } + }; + child.stdout.on("data", onData); + child.stderr.on("data", onData); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`${cmd} ${args.join(" ")} exited with ${code}`)); + }); + }); +} + +async function pinVersion(envFile, version) { + let content = ""; + try { + content = await fs.readFile(envFile, "utf8"); + } catch { + content = ""; + } + const line = `STOWAGE_VERSION=${version}`; + const lines = content.split("\n"); + let replaced = false; + const next = lines.map((entry) => { + if (/^\s*(export\s+)?STOWAGE_VERSION\s*=/.test(entry)) { + replaced = true; + return line; + } + return entry; + }); + if (!replaced) { + if (next.length && next[next.length - 1] !== "") next.push(""); + next.push(line, ""); + } + await fs.writeFile(envFile, next.join("\n"), "utf8"); +} + +async function apply(args) { + const version = args.version; + if (!VERSION_RE.test(version ?? "")) throw new Error("Invalid version"); + const services = (args.services ?? "").split(",").filter(Boolean); + const self = args.self ?? "updater"; + const others = services.filter((s) => s !== self); + const compose = [ + "compose", + "--project-name", + args.project, + "--project-directory", + args["working-dir"], + ...(args["config-files"] ?? "") + .split(",") + .filter(Boolean) + .flatMap((f) => ["--file", f]), + ]; + const env = { + STOWAGE_VERSION: version, + COMPOSE_PROFILES: process.env.COMPOSE_PROFILES ?? "", + }; + + const state = await readState(); + state.state = "running"; + state.targetVersion = version; + state.error = null; + try { + await appendLog( + state, + `Pinning STOWAGE_VERSION=${version} in ${args["env-file"]}`, + ); + await pinVersion(args["env-file"], version); + + await appendLog(state, `Pulling images for: ${services.join(", ")}`); + await runCommand(state, "docker", [...compose, "pull", ...services], env); + + if (others.length) { + await appendLog(state, `Recreating: ${others.join(", ")}`); + await runCommand( + state, + "docker", + [...compose, "up", "-d", "--no-deps", ...others], + env, + ); + } + + state.state = "succeeded"; + state.finishedAt = Date.now(); + await appendLog(state, `Stowage is now on ${version}.`); + + if (services.includes(self)) { + await appendLog(state, `Recreating the updater (${self}) last.`); + await runCommand( + state, + "docker", + [...compose, "up", "-d", "--no-deps", self], + env, + ); + } + } catch (error) { + state.state = "failed"; + state.finishedAt = Date.now(); + state.error = error instanceof Error ? error.message : String(error); + await appendLog(state, `Update failed: ${state.error}`); + process.exitCode = 1; + } +} + +const [mode, ...rest] = process.argv.slice(2); +if (mode === "apply") { + apply(parseArgs(rest)).catch((error) => { + console.error(error); + process.exit(1); + }); +} else { + serve(); +} diff --git a/docker-compose.release.yml b/docker-compose.release.yml index e21001e..8ecd4f2 100644 --- a/docker-compose.release.yml +++ b/docker-compose.release.yml @@ -11,6 +11,11 @@ services: POCKETBASE_SUPERUSER_PASSWORD: ${POCKETBASE_SUPERUSER_PASSWORD} STORAGE_LIMIT_GB: ${STORAGE_LIMIT_GB:-} SITE_URL: ${SITE_URL:-http://localhost:3000} + # In-app updates (Settings > Updates). The app checks GitHub Releases + # for new versions on its own; applying one needs the "updater" + # profile below and the same token in both services. + STOWAGE_UPDATER_URL: ${STOWAGE_UPDATER_URL:-http://updater:9876} + STOWAGE_UPDATER_TOKEN: ${STOWAGE_UPDATER_TOKEN:-} ports: - "${APP_HOST_BIND:-127.0.0.1}:${APP_HOST_PORT:-3000}:${APP_PORT:-3000}" @@ -53,6 +58,23 @@ services: - caddy_data:/data - caddy_config:/config + # One-click updates from Settings > Updates. Opt in with + # `--profile updater` (combine with the other profiles you use). The + # sidecar needs the Docker socket to pull images and recreate the services + # of this compose project, so only run it on a host you control. Set + # STOWAGE_UPDATER_TOKEN to a long random string in .env. + updater: + profiles: ["updater"] + image: ghcr.io/kroqdotdev/stowage-updater:${STOWAGE_VERSION:-latest} + restart: unless-stopped + environment: + STOWAGE_UPDATER_TOKEN: ${STOWAGE_UPDATER_TOKEN:-} + COMPOSE_PROFILES: ${COMPOSE_PROFILES:-} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - updater_state:/state + volumes: caddy_data: caddy_config: + updater_state: diff --git a/src/__tests__/components/settings/settings-page-client.test.tsx b/src/__tests__/components/settings/settings-page-client.test.tsx index fad632c..55651ad 100644 --- a/src/__tests__/components/settings/settings-page-client.test.tsx +++ b/src/__tests__/components/settings/settings-page-client.test.tsx @@ -23,6 +23,21 @@ vi.mock("@/lib/api/users", () => ({ changePassword: vi.fn(), })); +vi.mock("@/lib/api/updates", () => ({ + getUpdates: vi.fn().mockResolvedValue({ + repo: "kroqdotdev/stowage", + current: { version: "0.2.0", build: null }, + latest: null, + newer: [], + updateAvailable: false, + checkedAt: null, + checkError: null, + updater: { configured: false, reason: "not-configured" }, + }), + applyUpdate: vi.fn(), + getHealth: vi.fn(), +})); + vi.mock("@/lib/use-app-date-format", () => ({ useAppDateFormat: () => "DD-MM-YYYY", })); @@ -110,7 +125,7 @@ describe("SettingsPageClient", () => { await waitFor(() => { expect( screen.getByText( - "Only admins can manage date format, features, and users.", + "Only admins can manage date format, features, users, and updates.", ), ).toBeInTheDocument(); }); diff --git a/src/__tests__/components/settings/updates-section.test.tsx b/src/__tests__/components/settings/updates-section.test.tsx new file mode 100644 index 0000000..b53b7d8 --- /dev/null +++ b/src/__tests__/components/settings/updates-section.test.tsx @@ -0,0 +1,266 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import type { UpdatesView } from "@/lib/api/updates"; + +const { getUpdatesMock, applyUpdateMock, getHealthMock, toastMock } = + vi.hoisted(() => ({ + getUpdatesMock: vi.fn(), + applyUpdateMock: vi.fn(), + getHealthMock: vi.fn(), + toastMock: { error: vi.fn(), success: vi.fn() }, + })); + +vi.mock("@/lib/api/updates", () => ({ + getUpdates: (...args: unknown[]) => getUpdatesMock(...args), + applyUpdate: (...args: unknown[]) => applyUpdateMock(...args), + getHealth: () => getHealthMock(), +})); + +vi.mock("@/lib/use-app-date-format", () => ({ + useAppDateFormat: () => "DD-MM-YYYY", +})); + +vi.mock("sonner", () => ({ toast: toastMock })); + +import { UpdatesSection } from "@/components/settings/updates-section"; + +const release = { + version: "0.3.0", + tag: "v0.3.0", + name: "Stowage 0.3.0", + publishedAt: Date.parse("2026-11-02T09:00:00Z"), + url: "https://github.com/kroqdotdev/stowage/releases/tag/v0.3.0", + notes: "## Added\n\n- In-app updates\n- A `thing`", + prerelease: false, +}; + +function view(overrides: Partial = {}): UpdatesView { + return { + repo: "kroqdotdev/stowage", + current: { version: "0.2.0", build: "abc1234" }, + latest: null, + newer: [], + updateAvailable: false, + checkedAt: Date.now(), + checkError: null, + updater: { configured: false, reason: "not-configured" }, + ...overrides, + }; +} + +function renderWithClient(ui: React.ReactElement) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render({ui}); +} + +describe("UpdatesSection", () => { + beforeEach(() => { + getUpdatesMock.mockReset(); + applyUpdateMock.mockReset(); + getHealthMock.mockReset(); + toastMock.error.mockReset(); + toastMock.success.mockReset(); + }); + + it("shows the installed version and that it is up to date", async () => { + getUpdatesMock.mockResolvedValue(view()); + renderWithClient(); + + expect( + screen.getByRole("heading", { name: "Updates" }), + ).toBeInTheDocument(); + expect(await screen.findByText("Stowage 0.2.0")).toBeInTheDocument(); + expect(screen.getByText("abc1234")).toBeInTheDocument(); + expect(screen.getByText("Stowage is up to date.")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "All releases" })).toHaveAttribute( + "href", + "https://github.com/kroqdotdev/stowage/releases", + ); + expect(screen.queryByText("Update by hand")).not.toBeInTheDocument(); + }); + + it("lists what is new and shows compose commands when there is no updater", async () => { + getUpdatesMock.mockResolvedValue( + view({ latest: release, newer: [release], updateAvailable: true }), + ); + renderWithClient(); + + expect(await screen.findByText("0.3.0 is available")).toBeInTheDocument(); + const list = screen.getByRole("list", { name: "What's new" }); + expect( + within(list).getByRole("link", { name: "Stowage 0.3.0" }), + ).toHaveAttribute("href", release.url); + expect(within(list).getByText("In-app updates")).toBeInTheDocument(); + expect(within(list).getByText("thing")).toBeInTheDocument(); + + expect(screen.getByText("Update by hand")).toBeInTheDocument(); + expect( + screen.getByText(/STOWAGE_VERSION=0\.3\.0 docker compose/), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Update to 0.3.0" }), + ).not.toBeInTheDocument(); + }); + + it("explains an updater that is configured but unreachable", async () => { + getUpdatesMock.mockResolvedValue( + view({ + latest: release, + newer: [release], + updateAvailable: true, + updater: { + configured: true, + reachable: false, + reason: "The updater service is not running.", + }, + }), + ); + renderWithClient(); + + expect( + await screen.findByText("The updater service is not running."), + ).toBeInTheDocument(); + }); + + it("shows the GitHub error instead of a verdict when the check fails", async () => { + getUpdatesMock.mockResolvedValue( + view({ checkedAt: null, checkError: "GitHub rate limit reached." }), + ); + renderWithClient(); + + expect( + await screen.findByText("GitHub rate limit reached."), + ).toBeInTheDocument(); + expect( + screen.queryByText("Stowage is up to date."), + ).not.toBeInTheDocument(); + }); + + it("re-checks on demand with refresh", async () => { + getUpdatesMock.mockResolvedValue(view()); + renderWithClient(); + await screen.findByText("Stowage is up to date."); + + getUpdatesMock.mockResolvedValue( + view({ latest: release, newer: [release], updateAvailable: true }), + ); + await userEvent.click(screen.getByRole("button", { name: "Check now" })); + + expect(await screen.findByText("0.3.0 is available")).toBeInTheDocument(); + expect(getUpdatesMock).toHaveBeenLastCalledWith({ refresh: true }); + }); + + it("confirms, starts the update and follows its log", async () => { + const idle = view({ + latest: release, + newer: [release], + updateAvailable: true, + updater: { + configured: true, + reachable: true, + status: { + state: "idle", + targetVersion: null, + startedAt: null, + finishedAt: null, + log: [], + error: null, + }, + }, + }); + getUpdatesMock.mockResolvedValue(idle); + applyUpdateMock.mockResolvedValue({ + state: "running", + targetVersion: "0.3.0", + startedAt: Date.now(), + finishedAt: null, + log: ["Starting update to 0.3.0"], + error: null, + }); + getHealthMock.mockResolvedValue({ status: "ok", version: "0.2.0" }); + renderWithClient(); + + await userEvent.click( + await screen.findByRole("button", { name: "Update to 0.3.0" }), + ); + const dialog = screen.getByRole("dialog"); + expect( + within(dialog).getByRole("heading", { name: "Update to 0.3.0?" }), + ).toBeInTheDocument(); + await userEvent.click( + within(dialog).getByRole("button", { name: "Update now" }), + ); + + await waitFor(() => expect(applyUpdateMock).toHaveBeenCalledWith("0.3.0")); + expect(await screen.findByText("Updating to 0.3.0…")).toBeInTheDocument(); + expect(screen.getByLabelText("Update log")).toHaveTextContent( + "Starting update to 0.3.0", + ); + expect(screen.queryByText("Update by hand")).not.toBeInTheDocument(); + }); + + it("reports a failed run with its error and lets you dismiss it", async () => { + getUpdatesMock.mockResolvedValue( + view({ + latest: release, + newer: [release], + updateAvailable: true, + updater: { + configured: true, + reachable: true, + status: { + state: "running", + targetVersion: "0.3.0", + startedAt: Date.now(), + finishedAt: null, + log: ["Pulling images"], + error: null, + }, + }, + }), + ); + getHealthMock.mockResolvedValue({ status: "ok", version: "0.2.0" }); + renderWithClient(); + expect(await screen.findByText("Updating to 0.3.0…")).toBeInTheDocument(); + + // The next poll (every two seconds while an update runs) reports failure. + getUpdatesMock.mockResolvedValue( + view({ + latest: release, + newer: [release], + updateAvailable: true, + updater: { + configured: true, + reachable: true, + status: { + state: "failed", + targetVersion: "0.3.0", + startedAt: Date.now() - 5000, + finishedAt: Date.now(), + log: ["Pulling images", "manifest unknown"], + error: "docker compose pull exited with 1", + }, + }, + }), + ); + + expect( + await screen.findByText("Update to 0.3.0 failed", {}, { timeout: 6000 }), + ).toBeInTheDocument(); + expect( + screen.getByText("docker compose pull exited with 1"), + ).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Dismiss" })); + expect( + screen.queryByText("Update to 0.3.0 failed"), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Update to 0.3.0" }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/components/settings/user-management-section.test.tsx b/src/__tests__/components/settings/user-management-section.test.tsx index 4aabedc..dec4af0 100644 --- a/src/__tests__/components/settings/user-management-section.test.tsx +++ b/src/__tests__/components/settings/user-management-section.test.tsx @@ -27,6 +27,21 @@ vi.mock("@/lib/api/app-settings", () => ({ setServiceSchedulingEnabled: vi.fn(), })); +vi.mock("@/lib/api/updates", () => ({ + getUpdates: vi.fn().mockResolvedValue({ + repo: "kroqdotdev/stowage", + current: { version: "0.2.0", build: null }, + latest: null, + newer: [], + updateAvailable: false, + checkedAt: null, + checkError: null, + updater: { configured: false, reason: "not-configured" }, + }), + applyUpdate: vi.fn(), + getHealth: vi.fn(), +})); + vi.mock("@/lib/use-app-date-format", () => ({ useAppDateFormat: () => "DD-MM-YYYY", })); @@ -177,7 +192,7 @@ describe("SettingsPageClient with non-admin", () => { await waitFor(() => { expect( screen.getByText( - "Only admins can manage date format, features, and users.", + "Only admins can manage date format, features, users, and updates.", ), ).toBeInTheDocument(); }); diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 3ad10fc..c8ad5cf 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -6,7 +6,7 @@ export default function SettingsPage() {
diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 41063cf..6ae0f2e 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,3 +1,8 @@ +export const dynamic = "force-dynamic"; + export function GET() { - return Response.json({ status: "ok" }); + return Response.json({ + status: "ok", + version: process.env.NEXT_PUBLIC_APP_VERSION ?? null, + }); } diff --git a/src/app/api/updates/apply/route.ts b/src/app/api/updates/apply/route.ts new file mode 100644 index 0000000..5447414 --- /dev/null +++ b/src/app/api/updates/apply/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { parseJsonBody, withAdmin } from "@/server/auth/route"; +import { applyUpdate } from "@/server/updates"; + +const Body = z.object({ + version: z + .string() + .trim() + .regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/, "Invalid version"), +}); + +export const POST = withAdmin("api/updates/apply", async (req) => { + const body = Body.parse(await parseJsonBody(req)); + const status = await applyUpdate(body.version); + return NextResponse.json({ status }, { status: 202 }); +}); diff --git a/src/app/api/updates/route.ts b/src/app/api/updates/route.ts new file mode 100644 index 0000000..6c83552 --- /dev/null +++ b/src/app/api/updates/route.ts @@ -0,0 +1,9 @@ +import { withAdmin } from "@/server/auth/route"; +import { getUpdatesView } from "@/server/updates"; + +export const dynamic = "force-dynamic"; + +export const GET = withAdmin("api/updates", async (req) => { + const refresh = req.nextUrl.searchParams.get("refresh") === "1"; + return getUpdatesView({ refresh }); +}); diff --git a/src/components/settings/release-notes.tsx b/src/components/settings/release-notes.tsx new file mode 100644 index 0000000..c8889f0 --- /dev/null +++ b/src/components/settings/release-notes.tsx @@ -0,0 +1,96 @@ +import * as React from "react"; + +/* + Release notes arrive as the Markdown of a CHANGELOG section. We render the + handful of constructs it uses (headings, bullets, paragraphs, inline code) + and show anything else as plain text, so nothing from GitHub becomes HTML. +*/ + +function inline(text: string, key: string): React.ReactNode { + const parts = text.split(/(`[^`]+`)/g); + if (parts.length === 1) return text; + return parts.map((part, index) => + part.startsWith("`") && part.endsWith("`") ? ( + + {part.slice(1, -1)} + + ) : ( + {part} + ), + ); +} + +export function ReleaseNotes({ + notes, + className, +}: { + notes: string; + className?: string; +}) { + const blocks: React.ReactNode[] = []; + let bullets: string[] = []; + let paragraph: string[] = []; + + const flushBullets = () => { + if (!bullets.length) return; + const key = `ul-${blocks.length}`; + blocks.push( +
    + {bullets.map((item, index) => ( +
  • {inline(item, `${key}-${index}`)}
  • + ))} +
, + ); + bullets = []; + }; + const flushParagraph = () => { + if (!paragraph.length) return; + const key = `p-${blocks.length}`; + blocks.push(

{inline(paragraph.join(" "), key)}

); + paragraph = []; + }; + + for (const raw of notes.split(/\r?\n/)) { + const line = raw.trimEnd(); + const heading = /^(#{1,6})\s+(.*)$/.exec(line); + const bullet = /^\s*[-*]\s+(.*)$/.exec(line); + if (heading) { + flushBullets(); + flushParagraph(); + const title = heading[2].replace(/^\[([^\]]+)\].*$/, "$1"); + blocks.push( +

+ {inline(title, `h-${blocks.length}`)} +

, + ); + } else if (bullet) { + flushParagraph(); + bullets.push(bullet[1]); + } else if (!line.trim()) { + flushBullets(); + flushParagraph(); + } else { + flushBullets(); + paragraph.push(line.trim()); + } + } + flushBullets(); + flushParagraph(); + + if (!blocks.length) { + return ( +

+ + No notes for this release. + +

+ ); + } + return
{blocks}
; +} diff --git a/src/components/settings/settings-page-client.tsx b/src/components/settings/settings-page-client.tsx index def6ed4..a06cebd 100644 --- a/src/components/settings/settings-page-client.tsx +++ b/src/components/settings/settings-page-client.tsx @@ -3,6 +3,7 @@ import { FeaturesSection } from "@/components/settings/features-section"; import { PasswordChangeSection } from "@/components/settings/password-change-section"; import { RegionalSettingsSection } from "@/components/settings/regional-settings-section"; +import { UpdatesSection } from "@/components/settings/updates-section"; import { UserManagementSection } from "@/components/settings/user-management-section"; import { SheetHeading } from "@/components/ui/sheet-heading"; import { Skeleton } from "@/components/ui/skeleton"; @@ -47,12 +48,13 @@ export function SettingsPageClient() { + ) : (

- Only admins can manage date format, features, and users. + Only admins can manage date format, features, users, and updates.

)} diff --git a/src/components/settings/updates-section.tsx b/src/components/settings/updates-section.tsx new file mode 100644 index 0000000..4d09642 --- /dev/null +++ b/src/components/settings/updates-section.tsx @@ -0,0 +1,488 @@ +"use client"; + +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2, RefreshCw } from "lucide-react"; +import { toast } from "sonner"; + +import { CrudModal } from "@/components/crud/modal"; +import { ReleaseNotes } from "@/components/settings/release-notes"; +import { Button } from "@/components/ui/button"; +import { SheetHeading } from "@/components/ui/sheet-heading"; +import { ApiRequestError } from "@/lib/api-client"; +import { + applyUpdate, + getHealth, + getUpdates, + type Release, + type UpdaterStatus, + type UpdatesView, +} from "@/lib/api/updates"; +import { formatDateFromTimestamp } from "@/lib/date-format"; +import { formatRelativeTime } from "@/lib/relative-time"; +import { useAppDateFormat } from "@/lib/use-app-date-format"; +import { cn } from "@/lib/utils"; + +/* + Settings > Updates: what is installed, what is published, and the way to + move between them. With the updater sidecar running, admins update in + place and this page reloads when the new version answers. Without it, the + section shows the compose commands instead. +*/ + +const POLL_MS = 2000; +const LOG_TAIL = 12; +const PREVIEW_RELEASES = 3; + +type Tracking = { from: string; target: string; startedAt: number }; + +type Phase = "idle" | "updating" | "restarting" | "succeeded" | "failed"; + +function mapError(error: unknown, fallback: string) { + if (error instanceof ApiRequestError) { + if (error.status === 403) return "Only admins can update Stowage."; + return error.message || fallback; + } + return fallback; +} + +function runStatusOf(data: UpdatesView | undefined): UpdaterStatus | null { + const updater = data?.updater; + if (!updater || !updater.configured || !updater.reachable) return null; + return updater.status; +} + +function manualCommands(version: string) { + return [ + "cd /path/to/stowage # the folder with docker-compose.release.yml", + `STOWAGE_VERSION=${version} docker compose -f docker-compose.release.yml pull`, + `STOWAGE_VERSION=${version} docker compose -f docker-compose.release.yml up -d`, + ].join("\n"); +} + +export function UpdatesSection() { + const queryClient = useQueryClient(); + const dateFormat = useAppDateFormat(); + const [tracking, setTracking] = React.useState(null); + const [confirmVersion, setConfirmVersion] = React.useState( + null, + ); + const [checking, setChecking] = React.useState(false); + const [showAllReleases, setShowAllReleases] = React.useState(false); + + const updatesQuery = useQuery({ + queryKey: ["updates"], + queryFn: () => getUpdates(), + retry: false, + staleTime: 60_000, + refetchInterval: tracking ? POLL_MS : false, + }); + const data = updatesQuery.data; + const runStatus = runStatusOf(data); + + // Someone (us, or another admin) started an update: follow it. + React.useEffect(() => { + if (tracking || !data || runStatus?.state !== "running") return; + setTracking({ + from: data.current.version, + target: runStatus.targetVersion ?? "", + startedAt: runStatus.startedAt ?? Date.now(), + }); + }, [data, runStatus, tracking]); + + // While an update runs, watch the unauthenticated health endpoint. The + // moment it answers with another version, the new build is serving. + const healthQuery = useQuery({ + queryKey: ["health"], + queryFn: getHealth, + enabled: tracking !== null, + retry: false, + refetchInterval: tracking ? POLL_MS : false, + }); + React.useEffect(() => { + if (!tracking) return; + const version = healthQuery.data?.version; + if (version && version !== tracking.from) { + window.location.reload(); + } + }, [healthQuery.data, tracking]); + + const applyMutation = useMutation({ + mutationFn: (version: string) => applyUpdate(version), + onSuccess: (status, version) => { + setConfirmVersion(null); + setTracking({ + from: data?.current.version ?? "", + target: version, + startedAt: status.startedAt ?? Date.now(), + }); + queryClient.setQueryData(["updates"], (previous) => + previous + ? { + ...previous, + updater: { configured: true, reachable: true, status }, + } + : previous, + ); + }, + onError: (error) => { + toast.error(mapError(error, "Unable to start the update")); + }, + }); + + const checkNow = async () => { + setChecking(true); + try { + const fresh = await getUpdates({ refresh: true }); + queryClient.setQueryData(["updates"], fresh); + if (fresh.checkError) toast.error(fresh.checkError); + else if (!fresh.updateAvailable) toast.success("Stowage is up to date"); + } catch (error) { + toast.error(mapError(error, "Unable to check for updates")); + } finally { + setChecking(false); + } + }; + + let phase: Phase = "idle"; + if (tracking) { + if (runStatus?.state === "failed") phase = "failed"; + else if (runStatus?.state === "succeeded") phase = "succeeded"; + else if (updatesQuery.isError) phase = "restarting"; + else phase = "updating"; + } + + const updater = data?.updater; + const canApply = + updater?.configured === true && updater.reachable && phase === "idle"; + const latest = data?.latest ?? null; + const newer = data?.newer ?? []; + const visibleReleases = showAllReleases + ? newer + : newer.slice(0, PREVIEW_RELEASES); + + return ( +
+ Updates} + aside={ + data ? ( + + All releases + + ) : null + } + /> + + {updatesQuery.isPending ? ( +

Loading...

+ ) : updatesQuery.isError && !data ? ( +

+ {mapError(updatesQuery.error, "Unable to load update information.")} +

+ ) : data ? ( + <> +
+
+

+ Stowage {data.current.version} + {data.current.build ? ( + + {data.current.build} + + ) : null} +

+

+ {data.checkError + ? data.checkError + : data.checkedAt + ? `Checked ${formatRelativeTime(data.checkedAt).long.toLowerCase()}` + : "Not checked yet"} +

+
+ +
+ + {!data.checkError ? ( +
+ {latest ? ( + <> +
+
+

+

+

+ {latest.publishedAt + ? `Released ${formatDateFromTimestamp(latest.publishedAt, dateFormat)}. ` + : ""} + {newer.length > 1 + ? `${newer.length} releases since ${data.current.version}.` + : null} +

+
+ {canApply ? ( + + ) : null} +
+ +
    + {visibleReleases.map((release) => ( + + ))} +
+ {newer.length > PREVIEW_RELEASES && !showAllReleases ? ( + + ) : null} + + ) : ( +

Stowage is up to date.

+ )} +
+ ) : null} + + {phase !== "idle" ? ( + setTracking(null)} + /> + ) : null} + + {latest && !canApply && phase === "idle" ? ( + + ) : null} + + ) : null} + + { + if (!applyMutation.isPending) setConfirmVersion(null); + }} + footer={ + <> + + + + } + > +
+

+ The app is unavailable for a minute or so while the containers are + recreated. This page reloads when the new version answers. +

+

+ Your data in pb_data{" "} + is kept. Take a backup first if you have not recently. +

+
+
+
+ ); +} + +function ReleaseItem({ + release, + dateFormat, +}: { + release: Release; + dateFormat: ReturnType; +}) { + return ( +
  • +
    + + {release.name} + + {release.publishedAt ? ( + + {formatDateFromTimestamp(release.publishedAt, dateFormat)} + + ) : null} +
    + +
  • + ); +} + +function UpdateProgress({ + phase, + tracking, + status, + onDismiss, +}: { + phase: Phase; + tracking: Tracking | null; + status: UpdaterStatus | null; + onDismiss: () => void; +}) { + const target = tracking?.target || status?.targetVersion || ""; + const log = status?.log.slice(-LOG_TAIL) ?? []; + const busy = phase === "updating" || phase === "restarting"; + + const title = + phase === "failed" + ? `Update to ${target} failed` + : phase === "succeeded" + ? `Updated to ${target}` + : phase === "restarting" + ? "Restarting Stowage…" + : `Updating to ${target}…`; + const detail = + phase === "failed" + ? (status?.error ?? + "The updater reported a failure. Check the log and the container logs on the host.") + : phase === "succeeded" + ? "Waiting for the new version to answer. Reload if this page does not refresh on its own." + : phase === "restarting" + ? "The containers are being recreated. This page reloads when the app is back." + : "Pulling images and recreating services. Keep this page open."; + + return ( +
    +
    +
    +

    + {busy ? ( +

    +

    {detail}

    +
    + {phase === "failed" ? ( + + ) : phase === "succeeded" ? ( + + ) : null} +
    + {log.length ? ( +
    +          {log.join("\n")}
    +        
    + ) : null} +
    + ); +} + +function ManualUpdate({ + version, + updater, +}: { + version: string; + updater: UpdatesView["updater"]; +}) { + const reason = + updater.configured && !updater.reachable + ? updater.reason + : 'One-click updates need the updater service. Start the release stack with the "updater" profile and set STOWAGE_UPDATER_TOKEN to turn them on.'; + return ( +
    +

    Update by hand

    +

    {reason}

    +
    +        {manualCommands(version)}
    +      
    +

    + Add the profiles you started with, for example{" "} + --profile pocketbase, or pin{" "} + STOWAGE_VERSION in .env and run the + same commands without the prefix. +

    +
    + ); +} diff --git a/src/lib/api/updates.ts b/src/lib/api/updates.ts new file mode 100644 index 0000000..c7457e9 --- /dev/null +++ b/src/lib/api/updates.ts @@ -0,0 +1,59 @@ +import { apiFetch } from "@/lib/api-client"; + +export type Release = { + version: string; + tag: string; + name: string; + publishedAt: number | null; + url: string; + notes: string; + prerelease: boolean; +}; + +export type UpdaterStatus = { + state: "idle" | "running" | "succeeded" | "failed"; + targetVersion: string | null; + startedAt: number | null; + finishedAt: number | null; + log: string[]; + error: string | null; +}; + +export type UpdaterView = + | { configured: false; reason: "not-configured" } + | { configured: true; reachable: false; reason: string } + | { configured: true; reachable: true; status: UpdaterStatus }; + +export type UpdatesView = { + repo: string; + current: { version: string; build: string | null }; + latest: Release | null; + newer: Release[]; + updateAvailable: boolean; + checkedAt: number | null; + checkError: string | null; + updater: UpdaterView; +}; + +export async function getUpdates(options: { refresh?: boolean } = {}) { + return apiFetch( + options.refresh ? "/api/updates?refresh=1" : "/api/updates", + ); +} + +export async function applyUpdate(version: string): Promise { + const { status } = await apiFetch<{ status: UpdaterStatus }>( + "/api/updates/apply", + { method: "POST", body: { version } }, + ); + return status; +} + +export type HealthView = { status: string; version: string | null }; + +/** Unauthenticated; used to notice the app coming back after an update. */ +export async function getHealth(): Promise { + const res = await fetch("/api/health", { cache: "no-store" }); + if (!res.ok) throw new Error(`Health check failed (${res.status})`); + return (await res.json()) as HealthView; +} diff --git a/src/server/updates/__tests__/releases.test.ts b/src/server/updates/__tests__/releases.test.ts new file mode 100644 index 0000000..4022612 --- /dev/null +++ b/src/server/updates/__tests__/releases.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ReleaseFetchError, + fetchReleasesFromGitHub, + getReleases, + resetReleaseCache, + selectNewer, + toRelease, + type Release, +} from "../releases"; + +function gh(tag: string, extra: Record = {}) { + return { + tag_name: tag, + name: `Stowage ${tag}`, + body: "## Added\n\n- Things", + html_url: `https://github.com/kroqdotdev/stowage/releases/tag/${tag}`, + draft: false, + prerelease: false, + published_at: "2026-11-01T10:00:00Z", + ...extra, + }; +} + +function okResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +describe("releases", () => { + beforeEach(() => resetReleaseCache()); + afterEach(() => vi.restoreAllMocks()); + + it("maps GitHub releases and drops drafts and odd tags", () => { + expect(toRelease(gh("v0.3.0"))).toMatchObject({ + version: "0.3.0", + tag: "v0.3.0", + name: "Stowage v0.3.0", + prerelease: false, + publishedAt: Date.parse("2026-11-01T10:00:00Z"), + }); + expect(toRelease(gh("v0.3.0", { draft: true }))).toBeNull(); + expect(toRelease(gh("nightly"))).toBeNull(); + expect(toRelease(gh("v0.4.0-rc.1"))?.prerelease).toBe(true); + expect(toRelease(gh("v0.4.0", { prerelease: true }))?.prerelease).toBe( + true, + ); + expect(toRelease(gh("v0.3.0", { name: " ", body: null }))).toMatchObject({ + name: "v0.3.0", + notes: "", + }); + }); + + it("selects only stable releases newer than the current one, newest first", () => { + const releases = [ + gh("v0.1.0"), + gh("v0.2.0"), + gh("v0.2.1"), + gh("v0.3.0-rc.1"), + gh("v0.10.0"), + ] + .map(toRelease) + .filter((r): r is Release => r !== null); + + expect(selectNewer(releases, "0.2.0").map((r) => r.version)).toEqual([ + "0.10.0", + "0.2.1", + ]); + expect(selectNewer(releases, "0.10.0")).toEqual([]); + }); + + it("lets a prerelease build see newer prereleases too", () => { + const releases = [gh("v0.3.0-rc.1"), gh("v0.3.0-rc.2"), gh("v0.2.1")] + .map(toRelease) + .filter((r): r is Release => r !== null); + expect(selectNewer(releases, "0.3.0-rc.1").map((r) => r.version)).toEqual([ + "0.3.0-rc.2", + ]); + }); + + it("fetches with GitHub headers and turns rate limits into a clear error", async () => { + const fetchImpl = vi.fn().mockResolvedValue(okResponse([gh("v0.3.0")])); + const releases = await fetchReleasesFromGitHub( + fetchImpl as unknown as typeof fetch, + ); + expect(releases).toHaveLength(1); + const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/repos/kroqdotdev/stowage/releases"); + expect((init.headers as Record)["user-agent"]).toBe( + "stowage-update-check", + ); + + const limited = vi + .fn() + .mockResolvedValue(new Response("{}", { status: 403 })); + await expect( + fetchReleasesFromGitHub(limited as unknown as typeof fetch), + ).rejects.toThrow(ReleaseFetchError); + await expect( + fetchReleasesFromGitHub(limited as unknown as typeof fetch), + ).rejects.toThrow(/rate limit/i); + }); + + it("caches for an hour, refreshes at most once a minute, and serves stale on failure", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(okResponse([gh("v0.3.0")])) + .mockResolvedValueOnce(okResponse([gh("v0.3.0"), gh("v0.3.1")])) + .mockRejectedValueOnce(new Error("offline")); + const t0 = Date.parse("2026-11-09T12:00:00Z"); + + const first = await getReleases({ fetchImpl, now: t0 }); + expect(first.releases).toHaveLength(1); + + // Within the TTL and without refresh: cached. + const second = await getReleases({ fetchImpl, now: t0 + 10 * 60_000 }); + expect(second.releases).toHaveLength(1); + expect(fetchImpl).toHaveBeenCalledTimes(1); + + // Refresh too soon after the last fetch: still cached. + await getReleases({ fetchImpl, now: t0 + 30_000, refresh: true }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + + // Refresh after a minute: fetched again. + const third = await getReleases({ + fetchImpl, + now: t0 + 2 * 60_000, + refresh: true, + }); + expect(third.releases).toHaveLength(2); + expect(fetchImpl).toHaveBeenCalledTimes(2); + + // Expired and GitHub down: the stale list is returned. + const fourth = await getReleases({ fetchImpl, now: t0 + 3 * 3_600_000 }); + expect(fourth.releases).toHaveLength(2); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + + it("throws when there is no cache and GitHub is unreachable", async () => { + const fetchImpl = vi.fn().mockRejectedValue(new Error("offline")); + await expect(getReleases({ fetchImpl })).rejects.toThrow("offline"); + }); +}); diff --git a/src/server/updates/__tests__/semver.test.ts b/src/server/updates/__tests__/semver.test.ts new file mode 100644 index 0000000..954be6f --- /dev/null +++ b/src/server/updates/__tests__/semver.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { + compareVersions, + isNewerVersion, + isPrereleaseVersion, + parseVersion, +} from "../semver"; + +describe("semver", () => { + it("parses release and prerelease versions, with or without a v", () => { + expect(parseVersion("v0.2.0")).toEqual({ + major: 0, + minor: 2, + patch: 0, + prerelease: [], + }); + expect(parseVersion("1.4.2-rc.1+build.5")?.prerelease).toEqual(["rc", "1"]); + expect(parseVersion("1.2")).toBeNull(); + expect(parseVersion("latest")).toBeNull(); + }); + + it("orders versions numerically, not lexically", () => { + expect(isNewerVersion("0.10.0", "0.9.9")).toBe(true); + expect(isNewerVersion("1.0.0", "0.99.99")).toBe(true); + expect(isNewerVersion("0.2.0", "0.2.0")).toBe(false); + expect(isNewerVersion("0.2.0", "0.2.1")).toBe(false); + }); + + it("ranks a release above its prereleases and orders prereleases", () => { + expect(isNewerVersion("0.3.0", "0.3.0-rc.2")).toBe(true); + expect(isNewerVersion("0.3.0-rc.2", "0.3.0-rc.1")).toBe(true); + expect(isNewerVersion("0.3.0-rc.10", "0.3.0-rc.9")).toBe(true); + expect(isNewerVersion("0.3.0-beta", "0.3.0-alpha")).toBe(true); + expect(isNewerVersion("0.3.0-rc.1", "0.3.0")).toBe(false); + const a = parseVersion("1.0.0-alpha")!; + const b = parseVersion("1.0.0-alpha.1")!; + expect(compareVersions(a, b)).toBeLessThan(0); + }); + + it("treats unparsable input as not newer", () => { + expect(isNewerVersion("abc", "0.1.0")).toBe(false); + expect(isNewerVersion("0.2.0", "")).toBe(false); + expect(isPrereleaseVersion("0.3.0-rc.1")).toBe(true); + expect(isPrereleaseVersion("0.3.0")).toBe(false); + }); +}); diff --git a/src/server/updates/__tests__/updater-client.test.ts b/src/server/updates/__tests__/updater-client.test.ts new file mode 100644 index 0000000..4d63499 --- /dev/null +++ b/src/server/updates/__tests__/updater-client.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + getUpdaterView, + isUpdaterConfigured, + requestUpdate, +} from "../updater-client"; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("updater client", () => { + const env = { ...process.env }; + + beforeEach(() => { + process.env.STOWAGE_UPDATER_URL = "http://updater:9876/"; + process.env.STOWAGE_UPDATER_TOKEN = "secret"; + }); + + afterEach(() => { + process.env = { ...env }; + vi.restoreAllMocks(); + }); + + it("reports not configured without a URL and token", async () => { + delete process.env.STOWAGE_UPDATER_TOKEN; + expect(isUpdaterConfigured()).toBe(false); + expect(await getUpdaterView()).toEqual({ + configured: false, + reason: "not-configured", + }); + }); + + it("reads status with the bearer token and normalises unknown states", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue( + json({ state: "weird", log: ["a", 2], targetVersion: "0.3.0" }), + ); + const view = await getUpdaterView(fetchImpl as unknown as typeof fetch); + expect(view).toEqual({ + configured: true, + reachable: true, + status: { + state: "idle", + targetVersion: "0.3.0", + startedAt: null, + finishedAt: null, + log: ["a", "2"], + error: null, + }, + }); + const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("http://updater:9876/status"); + expect((init.headers as Record).authorization).toBe( + "Bearer secret", + ); + }); + + it("explains an unreachable updater and a rejected token", async () => { + const down = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + expect(await getUpdaterView(down as unknown as typeof fetch)).toMatchObject( + { + configured: true, + reachable: false, + reason: expect.stringMatching(/not running or not reachable/), + }, + ); + + const unauthorized = vi + .fn() + .mockResolvedValue(json({ error: "Unauthorized" }, 401)); + expect( + await getUpdaterView(unauthorized as unknown as typeof fetch), + ).toMatchObject({ + reachable: false, + reason: expect.stringMatching(/STOWAGE_UPDATER_TOKEN/), + }); + }); + + it("posts the version and surfaces updater errors with their status", async () => { + const accepted = vi + .fn() + .mockResolvedValue( + json({ state: "running", targetVersion: "0.3.0", log: ["go"] }, 202), + ); + const status = await requestUpdate( + "0.3.0", + accepted as unknown as typeof fetch, + ); + expect(status.state).toBe("running"); + const [, init] = accepted.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ version: "0.3.0" }); + + const busy = vi + .fn() + .mockResolvedValue(json({ error: "An update is already running" }, 409)); + await expect( + requestUpdate("0.3.0", busy as unknown as typeof fetch), + ).rejects.toMatchObject({ + status: 409, + message: "An update is already running", + }); + }); +}); diff --git a/src/server/updates/index.ts b/src/server/updates/index.ts new file mode 100644 index 0000000..62529e4 --- /dev/null +++ b/src/server/updates/index.ts @@ -0,0 +1,102 @@ +import "server-only"; + +import { DomainError } from "@/server/pb/errors"; + +import { + RELEASE_REPO, + ReleaseFetchError, + getReleases, + selectNewer, + type Release, +} from "./releases"; +import { + UpdaterError, + getUpdaterView, + requestUpdate, + type UpdaterView, +} from "./updater-client"; + +export type UpdatesView = { + repo: string; + current: { version: string; build: string | null }; + latest: Release | null; + newer: Release[]; + updateAvailable: boolean; + checkedAt: number | null; + checkError: string | null; + updater: UpdaterView; +}; + +export function currentVersion() { + return process.env.NEXT_PUBLIC_APP_VERSION?.trim() || "0.0.0"; +} + +export function currentBuild() { + const sha = process.env.STOWAGE_BUILD_SHA?.trim(); + return sha ? sha.slice(0, 7) : null; +} + +export async function getUpdatesView( + options: { + refresh?: boolean; + } = {}, +): Promise { + const current = currentVersion(); + const [releaseResult, updater] = await Promise.all([ + getReleases({ refresh: options.refresh }).then( + (result) => ({ ok: true as const, ...result }), + (error: unknown) => ({ + ok: false as const, + error: + error instanceof ReleaseFetchError + ? error.message + : "Could not reach GitHub to check for updates.", + }), + ), + getUpdaterView(), + ]); + + const newer = releaseResult.ok + ? selectNewer(releaseResult.releases, current) + : []; + return { + repo: RELEASE_REPO, + current: { version: current, build: currentBuild() }, + latest: newer[0] ?? null, + newer, + updateAvailable: newer.length > 0, + checkedAt: releaseResult.ok ? releaseResult.fetchedAt : null, + checkError: releaseResult.ok ? null : releaseResult.error, + updater, + }; +} + +export async function applyUpdate(version: string) { + const current = currentVersion(); + let releases: Release[]; + try { + releases = (await getReleases()).releases; + } catch { + throw new DomainError( + "Could not reach GitHub to confirm the release before updating.", + 502, + ); + } + const target = selectNewer(releases, current).find( + (release) => release.version === version, + ); + if (!target) { + throw new DomainError( + `${version} is not a published release newer than ${current}.`, + 400, + ); + } + try { + return await requestUpdate(target.version); + } catch (error) { + if (error instanceof UpdaterError) { + throw new DomainError(error.message, error.status); + } + throw error; + } +} diff --git a/src/server/updates/releases.ts b/src/server/updates/releases.ts new file mode 100644 index 0000000..0e25bbd --- /dev/null +++ b/src/server/updates/releases.ts @@ -0,0 +1,143 @@ +import "server-only"; + +import { isNewerVersion, isPrereleaseVersion, parseVersion } from "./semver"; + +/* + Published releases come from the GitHub Releases API of the repo the images + are built from. Results are cached in memory for an hour; `refresh` forces a + new request, no more than once a minute (the unauthenticated limit is 60 + requests an hour per address). +*/ + +export const RELEASE_REPO = + process.env.STOWAGE_RELEASE_REPO?.trim() || "kroqdotdev/stowage"; + +export type Release = { + version: string; + tag: string; + name: string; + publishedAt: number | null; + url: string; + notes: string; + prerelease: boolean; +}; + +type GitHubRelease = { + tag_name: string; + name: string | null; + body: string | null; + html_url: string; + draft: boolean; + prerelease: boolean; + published_at: string | null; +}; + +type Cache = { + releases: Release[]; + fetchedAt: number; +}; + +const CACHE_TTL_MS = 60 * 60 * 1000; +const REFRESH_MIN_INTERVAL_MS = 60 * 1000; +const PER_PAGE = 30; + +let cache: Cache | null = null; +let inflight: Promise | null = null; + +export class ReleaseFetchError extends Error { + constructor(message: string) { + super(message); + this.name = "ReleaseFetchError"; + } +} + +export function toRelease(raw: GitHubRelease): Release | null { + if (raw.draft) return null; + const parsed = parseVersion(raw.tag_name); + if (!parsed) return null; + const version = raw.tag_name.replace(/^v/, ""); + return { + version, + tag: raw.tag_name, + name: raw.name?.trim() || raw.tag_name, + publishedAt: raw.published_at ? Date.parse(raw.published_at) : null, + url: raw.html_url, + notes: raw.body?.trim() ?? "", + prerelease: raw.prerelease || isPrereleaseVersion(version), + }; +} + +export async function fetchReleasesFromGitHub( + fetchImpl: typeof fetch = fetch, +): Promise { + const res = await fetchImpl( + `https://api.github.com/repos/${RELEASE_REPO}/releases?per_page=${PER_PAGE}`, + { + headers: { + accept: "application/vnd.github+json", + "user-agent": "stowage-update-check", + "x-github-api-version": "2022-11-28", + }, + signal: AbortSignal.timeout(8000), + cache: "no-store", + }, + ); + if (!res.ok) { + throw new ReleaseFetchError( + res.status === 403 || res.status === 429 + ? "GitHub rate limit reached. Try again in a few minutes." + : `GitHub answered ${res.status} when listing releases.`, + ); + } + const data = (await res.json()) as GitHubRelease[]; + return data + .map(toRelease) + .filter((release): release is Release => release !== null); +} + +export async function getReleases( + options: { + refresh?: boolean; + now?: number; + fetchImpl?: typeof fetch; + } = {}, +): Promise<{ releases: Release[]; fetchedAt: number }> { + const now = options.now ?? Date.now(); + if (cache) { + const age = now - cache.fetchedAt; + const fresh = age < CACHE_TTL_MS; + const refreshable = age >= REFRESH_MIN_INTERVAL_MS; + if (fresh && !(options.refresh && refreshable)) return cache; + } + if (!inflight) { + inflight = fetchReleasesFromGitHub(options.fetchImpl).finally(() => { + inflight = null; + }); + } + try { + const releases = await inflight; + cache = { releases, fetchedAt: now }; + return cache; + } catch (error) { + // Stale is better than nothing when GitHub is unreachable. + if (cache) return cache; + throw error; + } +} + +export function resetReleaseCache() { + cache = null; + inflight = null; +} + +/** + * Stable releases newer than `current`, newest first. A prerelease build only + * sees prereleases ahead of it too, so an rc can move to the next rc. + */ +export function selectNewer(releases: Release[], current: string): Release[] { + const onPrerelease = isPrereleaseVersion(current); + return releases + .filter((release) => onPrerelease || !release.prerelease) + .filter((release) => isNewerVersion(release.version, current)) + .sort((a, b) => (isNewerVersion(a.version, b.version) ? -1 : 1)); +} diff --git a/src/server/updates/semver.ts b/src/server/updates/semver.ts new file mode 100644 index 0000000..b6275ee --- /dev/null +++ b/src/server/updates/semver.ts @@ -0,0 +1,63 @@ +/* + Just enough semver for release versions: MAJOR.MINOR.PATCH with an optional + prerelease suffix. Build metadata is ignored. +*/ +export type SemVer = { + major: number; + minor: number; + patch: number; + prerelease: string[]; +}; + +const RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; + +export function parseVersion(input: string): SemVer | null { + const match = RE.exec(input.trim()); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ? match[4].split(".") : [], + }; +} + +function compareIdentifiers(a: string, b: string) { + const aNum = /^\d+$/.test(a); + const bNum = /^\d+$/.test(b); + if (aNum && bNum) return Math.sign(Number(a) - Number(b)); + if (aNum) return -1; // numeric identifiers sort before alphanumeric + if (bNum) return 1; + return a < b ? -1 : a > b ? 1 : 0; +} + +/** Negative when a < b, zero when equal, positive when a > b. */ +export function compareVersions(a: SemVer, b: SemVer): number { + if (a.major !== b.major) return Math.sign(a.major - b.major); + if (a.minor !== b.minor) return Math.sign(a.minor - b.minor); + if (a.patch !== b.patch) return Math.sign(a.patch - b.patch); + // A release outranks any of its prereleases. + if (!a.prerelease.length && b.prerelease.length) return 1; + if (a.prerelease.length && !b.prerelease.length) return -1; + const length = Math.max(a.prerelease.length, b.prerelease.length); + for (let i = 0; i < length; i += 1) { + const left = a.prerelease[i]; + const right = b.prerelease[i]; + if (left === undefined) return -1; + if (right === undefined) return 1; + const result = compareIdentifiers(left, right); + if (result !== 0) return result; + } + return 0; +} + +export function isNewerVersion(candidate: string, current: string): boolean { + const a = parseVersion(candidate); + const b = parseVersion(current); + if (!a || !b) return false; + return compareVersions(a, b) > 0; +} + +export function isPrereleaseVersion(version: string): boolean { + return (parseVersion(version)?.prerelease.length ?? 0) > 0; +} diff --git a/src/server/updates/updater-client.ts b/src/server/updates/updater-client.ts new file mode 100644 index 0000000..1231f87 --- /dev/null +++ b/src/server/updates/updater-client.ts @@ -0,0 +1,127 @@ +import "server-only"; + +/* + Talks to the updater sidecar (deploy/updater/server.mjs) when the release + compose project runs it. Without STOWAGE_UPDATER_URL and a token the app + only reports what is available and leaves the update to the operator. +*/ + +export type UpdaterState = "idle" | "running" | "succeeded" | "failed"; + +export type UpdaterStatus = { + state: UpdaterState; + targetVersion: string | null; + startedAt: number | null; + finishedAt: number | null; + log: string[]; + error: string | null; +}; + +export type UpdaterView = + | { configured: false; reason: "not-configured" } + | { configured: true; reachable: false; reason: string } + | { configured: true; reachable: true; status: UpdaterStatus }; + +export class UpdaterError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "UpdaterError"; + } +} + +function config() { + const url = process.env.STOWAGE_UPDATER_URL?.trim(); + const token = process.env.STOWAGE_UPDATER_TOKEN?.trim(); + if (!url || !token) return null; + return { url: url.replace(/\/+$/, ""), token }; +} + +export function isUpdaterConfigured() { + return config() !== null; +} + +function normalizeStatus(raw: unknown): UpdaterStatus { + const data = (raw ?? {}) as Partial; + const state: UpdaterState = + data.state === "running" || + data.state === "succeeded" || + data.state === "failed" + ? data.state + : "idle"; + return { + state, + targetVersion: data.targetVersion ?? null, + startedAt: data.startedAt ?? null, + finishedAt: data.finishedAt ?? null, + log: Array.isArray(data.log) ? data.log.map(String) : [], + error: data.error ?? null, + }; +} + +async function call( + path: string, + init: { method?: "GET" | "POST"; body?: unknown } = {}, + fetchImpl: typeof fetch = fetch, +): Promise { + const cfg = config(); + if (!cfg) throw new UpdaterError("Updater is not configured", 501); + let res: Response; + try { + res = await fetchImpl(`${cfg.url}${path}`, { + method: init.method ?? "GET", + headers: { + authorization: `Bearer ${cfg.token}`, + ...(init.body ? { "content-type": "application/json" } : {}), + }, + body: init.body ? JSON.stringify(init.body) : undefined, + signal: AbortSignal.timeout(4000), + cache: "no-store", + }); + } catch { + throw new UpdaterError("Updater is not reachable", 502); + } + const data = (await res.json().catch(() => ({}))) as { error?: string }; + if (!res.ok) { + throw new UpdaterError( + data.error ?? `Updater answered ${res.status}`, + res.status === 401 ? 502 : res.status, + ); + } + return data; +} + +export async function getUpdaterView( + fetchImpl: typeof fetch = fetch, +): Promise { + if (!config()) return { configured: false, reason: "not-configured" }; + try { + const status = normalizeStatus(await call("/status", {}, fetchImpl)); + return { configured: true, reachable: true, status }; + } catch (error) { + return { + configured: true, + reachable: false, + reason: + error instanceof UpdaterError && error.status === 502 + ? error.message === "Updater is not reachable" + ? "The updater service is not running or not reachable from the app." + : "The updater rejected the app's token. Set the same STOWAGE_UPDATER_TOKEN for both services." + : "The updater returned an error.", + }; + } +} + +export async function requestUpdate( + version: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const data = await call( + "/update", + { method: "POST", body: { version } }, + fetchImpl, + ); + return normalizeStatus(data); +} From c0a5bd1cbe7076dc44734d27753c7038f236fd4f Mon Sep 17 00:00:00 2001 From: Sauer Date: Mon, 9 Nov 2026 21:50:14 +0100 Subject: [PATCH 2/2] Address review findings on in-app updates Sidecar: pass the operator's env files to compose and pin the version in them only after the pull succeeds; update every service of the project, not just running ones; stop and fail a run after 30 minutes; a failure while recreating the updater itself no longer fails the app update; constant-time token compare; 400 on bad JSON; generic 500 body; mount compose/env file directories outside the project; Mounts instead of bind strings; shorter error messages. App: 30 s timeout to start an update and a refetch after apply errors so a started run is followed; Stop following/Reload in every phase and slow copy after 10 minutes; Check now reports stale lists and recent checks honestly; zod-validated updater status; github.com-only release links; token hint on 401; links and bold rendered as text in notes. Docs and tests: STOWAGE_RELEASE_REPO, check cadence and exported STOWAGE_VERSION notes; route, view and component tests. Co-Authored-By: Claude Fable 5.1 --- .env.example | 4 + README.md | 57 ++++--- RELEASING.md | 4 +- deploy/updater/server.mjs | 150 +++++++++++++----- .../settings/updates-section.test.tsx | 112 +++++++++++++ .../updates/__tests__/updates-routes.test.ts | 142 +++++++++++++++++ src/components/settings/release-notes.tsx | 7 +- src/components/settings/updates-section.tsx | 62 ++++++-- src/lib/api/updates.ts | 1 + src/server/updates/__tests__/index.test.ts | 124 +++++++++++++++ src/server/updates/__tests__/releases.test.ts | 9 +- .../updates/__tests__/updater-client.test.ts | 14 +- src/server/updates/index.ts | 8 + src/server/updates/releases.ts | 33 +++- src/server/updates/updater-client.ts | 58 ++++--- 15 files changed, 665 insertions(+), 120 deletions(-) create mode 100644 src/app/api/updates/__tests__/updates-routes.test.ts create mode 100644 src/server/updates/__tests__/index.test.ts diff --git a/.env.example b/.env.example index 3e3e264..5806ac5 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,10 @@ SITE_URL=http://localhost:3000 # needs the Docker socket, so only enable it on a host you control. STOWAGE_UPDATER_TOKEN= +# Repo whose GitHub Releases the Updates page offers. Only change this for a +# fork that publishes its own images. +# STOWAGE_RELEASE_REPO=kroqdotdev/stowage + # Profiles Compose treats as active for every command. Listing them here keeps # `docker compose up -d` and the in-app updater in agreement about which # services run. Examples: `pocketbase,updater` or `full,updater`. diff --git a/README.md b/README.md index b4d2702..f20abe5 100644 --- a/README.md +++ b/README.md @@ -129,11 +129,11 @@ Image contents: - `stowage-updater` is the optional sidecar behind one-click updates (see [Updating](#updating)) - none of the images contain your persistent PocketBase data; that still lives in `pb_data/` -See [RELEASING.md](/Users/sauer/Documents/cc/stowage.cc/RELEASING.md) for the release checklist and tag flow. +See [RELEASING.md](RELEASING.md) for the release checklist and tag flow. ### Release Deployment Modes -Tagged releases can now be deployed directly with [docker-compose.release.yml](/Users/sauer/Documents/cc/stowage.cc/docker-compose.release.yml). +Tagged releases can now be deployed directly with [docker-compose.release.yml](docker-compose.release.yml). App only: @@ -170,8 +170,8 @@ if you intentionally want them reachable off-host without Caddy. ### Updating Admins see the installed version and any newer release, with its notes, under -**Settings > Updates**. The app checks GitHub Releases about once an hour and on -demand with **Check now**. +**Settings > Updates**. The app asks GitHub Releases when an admin opens the +page (cached for an hour) and on demand with **Check now**. **By hand.** Pin the new version and recreate the services, adding the profiles you deploy with: @@ -182,7 +182,9 @@ STOWAGE_VERSION=0.3.0 docker compose -f docker-compose.release.yml --profile poc ``` Or set `STOWAGE_VERSION` in `.env` and run the same two commands without the -prefix. PocketBase migrations bundled with the new image apply on start, and +prefix. A variable exported in your shell wins over `.env`, so do not keep +`STOWAGE_VERSION` exported permanently or a later `up -d` will roll back to it. +PocketBase migrations bundled with the new image apply on start, and `pb_data/` is untouched, but take a backup first. **From the browser.** Add the `updater` profile and a shared token, and the @@ -196,11 +198,13 @@ COMPOSE_PROFILES=pocketbase,updater # the profiles you deploy with, plus upd docker compose -f docker-compose.release.yml up -d ``` -When an admin confirms an update, the sidecar writes `STOWAGE_VERSION=` -into the project's `.env`, pulls the images for the services that are running, -recreates them, and finally recreates itself. The page shows the log and -reloads when the new version answers. A failed pull leaves everything running -as it was. +When an admin confirms an update, the sidecar pulls the new images for the +project's services, writes `STOWAGE_VERSION=` into the project's `.env` +(or the files you passed with `--env-file`), recreates the services, and +finally recreates itself. The page shows the log and reloads when the new +version answers. A failed pull changes nothing: containers and `.env` stay as +they were. The sidecar only sees variables from those env files, not ones +exported in your shell, so keep deployment settings in `.env`. The updater mounts the Docker socket, which is equivalent to root on the host, and is only reachable on the compose network with the token. Leave the profile @@ -213,22 +217,23 @@ The browser talks to PocketBase directly for realtime subscriptions, so for a pu Environment variables: -| Var | Required | Notes | -| ------------------------------------------ | -------- | ------------------------------------------------------------- | -| `APP_PORT` | no | Port the Next.js app listens on inside the container | -| `APP_HOST_PORT` | no | Host port mapped to the Next.js container | -| `POCKETBASE_HOST_PORT` | no | Host port mapped to PocketBase | -| `APP_DOMAIN` | no | Public domain for the optional built-in TLS profile | -| `HTTP_PORT` / `HTTPS_PORT` | no | Host ports published by the optional Caddy TLS service | -| `POCKETBASE_URL` | yes | Server-to-PB URL (e.g. `http://pocketbase:8090`) | -| `NEXT_PUBLIC_POCKETBASE_URL` | yes | Browser-to-PB URL (same origin as the app in prod) | -| `POCKETBASE_SUPERUSER_EMAIL` / `_PASSWORD` | yes | Used by `pnpm pb:bootstrap` and the app's admin client | -| `STORAGE_LIMIT_GB` | no | Global cap on total attachment bytes; omit for no limit | -| `SITE_URL` | no | Base URL embedded in label QR codes | -| `STOWAGE_VERSION` | no | Release image tag used by `docker-compose.release.yml` | -| `STOWAGE_UPDATER_TOKEN` | no | Shared secret between the app and the `updater` sidecar | -| `STOWAGE_UPDATER_URL` | no | Where the app reaches the updater (`http://updater:9876`) | -| `COMPOSE_PROFILES` | no | Profiles Compose treats as active (e.g. `pocketbase,updater`) | +| Var | Required | Notes | +| ------------------------------------------ | -------- | --------------------------------------------------------------------- | +| `APP_PORT` | no | Port the Next.js app listens on inside the container | +| `APP_HOST_PORT` | no | Host port mapped to the Next.js container | +| `POCKETBASE_HOST_PORT` | no | Host port mapped to PocketBase | +| `APP_DOMAIN` | no | Public domain for the optional built-in TLS profile | +| `HTTP_PORT` / `HTTPS_PORT` | no | Host ports published by the optional Caddy TLS service | +| `POCKETBASE_URL` | yes | Server-to-PB URL (e.g. `http://pocketbase:8090`) | +| `NEXT_PUBLIC_POCKETBASE_URL` | yes | Browser-to-PB URL (same origin as the app in prod) | +| `POCKETBASE_SUPERUSER_EMAIL` / `_PASSWORD` | yes | Used by `pnpm pb:bootstrap` and the app's admin client | +| `STORAGE_LIMIT_GB` | no | Global cap on total attachment bytes; omit for no limit | +| `SITE_URL` | no | Base URL embedded in label QR codes | +| `STOWAGE_VERSION` | no | Release image tag used by `docker-compose.release.yml` | +| `STOWAGE_UPDATER_TOKEN` | no | Shared secret between the app and the `updater` sidecar | +| `STOWAGE_UPDATER_URL` | no | Where the app reaches the updater (`http://updater:9876`) | +| `COMPOSE_PROFILES` | no | Profiles Compose treats as active (e.g. `pocketbase,updater`) | +| `STOWAGE_RELEASE_REPO` | no | GitHub repo whose releases are offered (default `kroqdotdev/stowage`) | ## Tests diff --git a/RELEASING.md b/RELEASING.md index e908204..2d5c89f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -64,8 +64,8 @@ on GitHub's runners. ## Release checklist 1. Make sure `main` is green and ready to ship. -2. Update [CHANGELOG.md](/Users/sauer/Documents/cc/stowage.cc/CHANGELOG.md) by moving release-worthy items out of `Unreleased`. -3. Bump the version in [package.json](/Users/sauer/Documents/cc/stowage.cc/package.json). +2. Update [CHANGELOG.md](CHANGELOG.md) by moving release-worthy items out of `Unreleased`. +3. Bump the version in [package.json](package.json). 4. Commit the version/changelog update. 5. Create and push the release tag: diff --git a/deploy/updater/server.mjs b/deploy/updater/server.mjs index 6d0b4a4..50ec763 100644 --- a/deploy/updater/server.mjs +++ b/deploy/updater/server.mjs @@ -17,12 +17,13 @@ the new updater instance reports the run that replaced it. The helper: - 1. pins STOWAGE_VERSION= in the project's .env - 2. docker compose pull - 3. docker compose up -d --no-deps + 1. docker compose pull + 2. pins STOWAGE_VERSION= in the project's env file(s) + 3. docker compose up -d --no-deps 4. docker compose up -d --no-deps updater (recreates this container) */ import { spawn } from "node:child_process"; +import { timingSafeEqual } from "node:crypto"; import { promises as fs } from "node:fs"; import http from "node:http"; import os from "node:os"; @@ -35,6 +36,8 @@ const PORT = Number(process.env.STOWAGE_UPDATER_PORT ?? 9876); const TOKEN = (process.env.STOWAGE_UPDATER_TOKEN ?? "").trim(); const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; const LOG_LINES = 200; +// A run that has not reported in this long is force-stopped and failed. +const RUN_TIMEOUT_MS = 30 * 60 * 1000; // --------------------------------------------------------------------------- // Docker Engine API over the unix socket (no dependencies). @@ -105,14 +108,16 @@ function composeLabels(info) { }; } -async function runningServices(project) { +// Every service of the project with a container, whatever its state: a +// service that is restarting at that moment must move with the others. +async function projectServices(project) { const filters = encodeURIComponent( - JSON.stringify({ - label: [`com.docker.compose.project=${project}`], - status: ["running"], - }), + JSON.stringify({ label: [`com.docker.compose.project=${project}`] }), + ); + const containers = await docker( + "GET", + `/containers/json?all=true&filters=${filters}`, ); - const containers = await docker("GET", `/containers/json?filters=${filters}`); const services = new Set(); for (const container of containers) { const service = container.Labels?.["com.docker.compose.service"]; @@ -162,7 +167,9 @@ async function writeState(state) { function authorized(req) { const header = req.headers.authorization ?? ""; - return header === `Bearer ${TOKEN}`; + const expected = Buffer.from(`Bearer ${TOKEN}`); + const given = Buffer.from(header); + return given.length === expected.length && timingSafeEqual(given, expected); } function send(res, status, body) { @@ -190,10 +197,10 @@ let starting = false; async function startUpdate(version) { const self = await inspectSelf(); const labels = composeLabels(self); - const services = await runningServices(labels.project); + const services = await projectServices(labels.project); if (!services.length) { throw new Error( - `No running services found for compose project "${labels.project}".`, + `No services found for compose project "${labels.project}".`, ); } const stateMount = (self.Mounts ?? []).find( @@ -227,8 +234,11 @@ async function startUpdate(version) { labels.workingDir, "--config-files", labels.configFiles.join(","), - "--env-file", - labels.envFiles[0] ?? path.join(labels.workingDir, ".env"), + "--env-files", + (labels.envFiles.length + ? labels.envFiles + : [path.join(labels.workingDir, ".env")] + ).join(","), "--services", services.join(","), "--self", @@ -244,10 +254,18 @@ async function startUpdate(version) { // not run. Healthcheck: { Test: ["NONE"] }, HostConfig: { - Binds: [ - `${socketSource}:${SOCKET}`, - `${stateSource}:${STATE_DIR}`, - `${labels.workingDir}:${labels.workingDir}`, + Mounts: [ + { Type: "bind", Source: socketSource, Target: SOCKET }, + { + Type: stateMount.Type === "volume" ? "volume" : "bind", + Source: stateSource, + Target: STATE_DIR, + }, + ...hostDirs(labels).map((dir) => ({ + Type: "bind", + Source: dir, + Target: dir, + })), ], }, }, @@ -265,6 +283,21 @@ async function startUpdate(version) { return { id: created.Id, services }; } +// The project directory, plus the directories of compose and env files that +// live outside it (`-f ../stack.yml`), each at its own host path so compose +// resolves relative paths exactly as it does on the host. +function hostDirs(labels) { + const dirs = new Set([labels.workingDir]); + for (const file of [...labels.configFiles, ...labels.envFiles]) { + const dir = path.dirname(file); + const inside = [...dirs].some( + (known) => dir === known || dir.startsWith(`${known}${path.sep}`), + ); + if (!inside) dirs.add(dir); + } + return [...dirs]; +} + async function helperLogs(id) { return new Promise((resolve) => { const chunks = []; @@ -306,14 +339,18 @@ async function reconcile(state) { info = null; } if (state.state === "running") { - if (info && info.State?.Running) return state; + const alive = Boolean(info && info.State?.Running); + const timedOut = + Date.now() - (state.startedAt ?? Date.now()) > RUN_TIMEOUT_MS; + if (alive && !timedOut) return state; const logs = info ? await helperLogs(state.runId) : []; const next = { ...state, state: "failed", finishedAt: Date.now(), - error: - "The update helper stopped before finishing. Check the services with `docker compose ps` on the host.", + error: timedOut + ? "The update did not finish within 30 minutes and was stopped. Check the services with `docker compose ps` on the host." + : "The update helper stopped before finishing. Check the services with `docker compose ps` on the host.", log: [...state.log, ...logs.map((line) => `helper: ${line}`)].slice( -LOG_LINES, ), @@ -363,8 +400,14 @@ function serve() { return; } if (req.method === "POST" && url.pathname === "/update") { - const body = JSON.parse((await readBody(req)) || "{}"); - const version = String(body.version ?? "").trim(); + let body; + try { + body = JSON.parse((await readBody(req)) || "{}"); + } catch { + send(res, 400, { error: "Invalid JSON body" }); + return; + } + const version = String(body?.version ?? "").trim(); if (!VERSION_RE.test(version)) { send(res, 400, { error: "Invalid version" }); return; @@ -390,8 +433,9 @@ function serve() { send(res, 404, { error: "Not found" }); } catch (error) { console.error(error); + // Details go to the container log, not to the caller. send(res, 500, { - error: error instanceof Error ? error.message : "Updater error", + error: "The updater could not start the update. See its container log.", }); } }); @@ -448,7 +492,22 @@ function runCommand(state, cmd, args, env) { child.on("error", reject); child.on("close", (code) => { if (code === 0) resolve(); - else reject(new Error(`${cmd} ${args.join(" ")} exited with ${code}`)); + else { + // "docker compose pull" rather than the whole argv with host paths. + const verb = + args.find( + (arg, i) => + i > 0 && + !args[i - 1].startsWith("--") && + !arg.startsWith("--") && + arg !== "compose", + ) ?? ""; + reject( + new Error( + `${cmd} compose ${verb} exited with ${code}`.replace(/\s+/g, " "), + ), + ); + } }); }); } @@ -483,6 +542,7 @@ async function apply(args) { const services = (args.services ?? "").split(",").filter(Boolean); const self = args.self ?? "updater"; const others = services.filter((s) => s !== self); + const envFiles = (args["env-files"] ?? "").split(",").filter(Boolean); const compose = [ "compose", "--project-name", @@ -493,6 +553,10 @@ async function apply(args) { .split(",") .filter(Boolean) .flatMap((f) => ["--file", f]), + // The same env files the operator started the stack with, so secrets + // interpolate the same way. Variables exported only in their shell are + // not available here; .env is the place for them. + ...envFiles.flatMap((f) => ["--env-file", f]), ]; const env = { STOWAGE_VERSION: version, @@ -504,15 +568,16 @@ async function apply(args) { state.targetVersion = version; state.error = null; try { - await appendLog( - state, - `Pinning STOWAGE_VERSION=${version} in ${args["env-file"]}`, - ); - await pinVersion(args["env-file"], version); - + // Pull first: a bad tag or an unreachable registry leaves both the + // containers and the .env pin exactly as they were. await appendLog(state, `Pulling images for: ${services.join(", ")}`); await runCommand(state, "docker", [...compose, "pull", ...services], env); + for (const file of envFiles) { + await appendLog(state, `Pinning STOWAGE_VERSION=${version} in ${file}`); + await pinVersion(file, version); + } + if (others.length) { await appendLog(state, `Recreating: ${others.join(", ")}`); await runCommand( @@ -528,13 +593,22 @@ async function apply(args) { await appendLog(state, `Stowage is now on ${version}.`); if (services.includes(self)) { - await appendLog(state, `Recreating the updater (${self}) last.`); - await runCommand( - state, - "docker", - [...compose, "up", "-d", "--no-deps", self], - env, - ); + // The app is already updated; a problem here is only the updater's. + try { + await appendLog(state, `Recreating the updater (${self}) last.`); + await runCommand( + state, + "docker", + [...compose, "up", "-d", "--no-deps", self], + env, + ); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + await appendLog( + state, + `The updater itself could not be recreated (${reason}). Stowage is on ${version}; run \`docker compose up -d ${self}\` on the host.`, + ); + } } } catch (error) { state.state = "failed"; diff --git a/src/__tests__/components/settings/updates-section.test.tsx b/src/__tests__/components/settings/updates-section.test.tsx index b53b7d8..43936ac 100644 --- a/src/__tests__/components/settings/updates-section.test.tsx +++ b/src/__tests__/components/settings/updates-section.test.tsx @@ -46,6 +46,7 @@ function view(overrides: Partial = {}): UpdatesView { updateAvailable: false, checkedAt: Date.now(), checkError: null, + checkWarning: null, updater: { configured: false, reason: "not-configured" }, ...overrides, }; @@ -155,6 +156,117 @@ describe("UpdatesSection", () => { expect(getUpdatesMock).toHaveBeenLastCalledWith({ refresh: true }); }); + it("shows a warning when the list is from an earlier check", async () => { + getUpdatesMock.mockResolvedValue( + view({ + checkWarning: + "GitHub rate limit reached. Showing the list from the last successful check.", + }), + ); + renderWithClient(); + + expect( + await screen.findByText( + /Showing the list from the last successful check/, + ), + ).toBeInTheDocument(); + expect(screen.getByText("Stowage is up to date.")).toBeInTheDocument(); + }); + + it("reloads once the health endpoint answers with another version", async () => { + const reload = vi.fn(); + const original = window.location; + Object.defineProperty(window, "location", { + configurable: true, + value: { ...original, reload }, + }); + try { + getUpdatesMock.mockResolvedValue( + view({ + latest: release, + newer: [release], + updateAvailable: true, + updater: { + configured: true, + reachable: true, + status: { + state: "running", + targetVersion: "0.3.0", + startedAt: Date.now(), + finishedAt: null, + log: ["Recreating: app"], + error: null, + }, + }, + }), + ); + getHealthMock.mockResolvedValue({ status: "ok", version: "0.3.0" }); + renderWithClient(); + + expect(await screen.findByText("Updating to 0.3.0…")).toBeInTheDocument(); + await waitFor(() => expect(reload).toHaveBeenCalled()); + } finally { + Object.defineProperty(window, "location", { + configurable: true, + value: original, + }); + } + }); + + it("offers Reload after success and lets you stop following a run", async () => { + getUpdatesMock.mockResolvedValue( + view({ + latest: release, + newer: [release], + updateAvailable: true, + updater: { + configured: true, + reachable: true, + status: { + state: "running", + targetVersion: "0.3.0", + startedAt: Date.now(), + finishedAt: null, + log: [], + error: null, + }, + }, + }), + ); + getHealthMock.mockResolvedValue({ status: "ok", version: "0.2.0" }); + renderWithClient(); + expect(await screen.findByText("Updating to 0.3.0…")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Stop following" }), + ).toBeInTheDocument(); + + getUpdatesMock.mockResolvedValue( + view({ + latest: release, + newer: [release], + updateAvailable: true, + updater: { + configured: true, + reachable: true, + status: { + state: "succeeded", + targetVersion: "0.3.0", + startedAt: Date.now() - 20_000, + finishedAt: Date.now(), + log: ["Stowage is now on 0.3.0."], + error: null, + }, + }, + }), + ); + expect( + await screen.findByText("Updated to 0.3.0", {}, { timeout: 6000 }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Reload" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Dismiss" })); + expect(screen.queryByText("Updated to 0.3.0")).not.toBeInTheDocument(); + }); + it("confirms, starts the update and follows its log", async () => { const idle = view({ latest: release, diff --git a/src/app/api/updates/__tests__/updates-routes.test.ts b/src/app/api/updates/__tests__/updates-routes.test.ts new file mode 100644 index 0000000..efdafe8 --- /dev/null +++ b/src/app/api/updates/__tests__/updates-routes.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { makeRequest, readJson, seedUser } from "@/app/api/__tests__/helpers"; +import { usePbHarness } from "@/test/pb-harness"; + +function gh(tag: string) { + return { + tag_name: tag, + name: tag, + body: "- notes", + html_url: `https://github.com/kroqdotdev/stowage/releases/tag/${tag}`, + draft: false, + prerelease: false, + published_at: "2026-11-01T10:00:00Z", + }; +} + +describe("updates routes", () => { + const getHarness = usePbHarness(); + const realFetch = globalThis.fetch; + + beforeEach(async () => { + const h = getHarness(); + vi.stubEnv("POCKETBASE_URL", h.url); + vi.stubEnv("POCKETBASE_SUPERUSER_EMAIL", "test@stowage.local"); + vi.stubEnv("POCKETBASE_SUPERUSER_PASSWORD", "test-password-12345"); + vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "0.2.0"); + vi.stubEnv("STOWAGE_UPDATER_URL", ""); + vi.stubEnv("STOWAGE_UPDATER_TOKEN", ""); + const { resetReleaseCache } = await import("@/server/updates/releases"); + resetReleaseCache(); + // GitHub is stubbed; PocketBase traffic keeps using the real fetch. + vi.stubGlobal("fetch", (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).includes("api.github.com")) { + return Promise.resolve( + new Response(JSON.stringify([gh("v0.3.0"), gh("v0.2.0")]), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + return realFetch(input, init); + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + describe("GET /api/updates", () => { + it("returns 401 without a token", async () => { + const { GET } = await import("@/app/api/updates/route"); + const res = await GET(makeRequest("http://localhost/api/updates")); + expect(res.status).toBe(401); + }); + + it("returns 403 for a non-admin", async () => { + const user = await seedUser(getHarness(), "user"); + const { GET } = await import("@/app/api/updates/route"); + const res = await GET( + makeRequest("http://localhost/api/updates", { token: user.token }), + ); + expect(res.status).toBe(403); + }); + + it("returns the update view for an admin", async () => { + const admin = await seedUser(getHarness(), "admin"); + const { GET } = await import("@/app/api/updates/route"); + const res = await GET( + makeRequest("http://localhost/api/updates", { token: admin.token }), + ); + expect(res.status).toBe(200); + const body = await readJson<{ + current: { version: string }; + latest: { version: string } | null; + updateAvailable: boolean; + updater: { configured: boolean }; + }>(res); + expect(body.current.version).toBe("0.2.0"); + expect(body.latest?.version).toBe("0.3.0"); + expect(body.updateAvailable).toBe(true); + expect(body.updater.configured).toBe(false); + }); + }); + + describe("POST /api/updates/apply", () => { + it("returns 403 for a non-admin", async () => { + const user = await seedUser(getHarness(), "user"); + const { POST } = await import("@/app/api/updates/apply/route"); + const res = await POST( + makeRequest("http://localhost/api/updates/apply", { + method: "POST", + token: user.token, + json: { version: "0.3.0" }, + }), + ); + expect(res.status).toBe(403); + }); + + it("returns 400 for a malformed version", async () => { + const admin = await seedUser(getHarness(), "admin"); + const { POST } = await import("@/app/api/updates/apply/route"); + const res = await POST( + makeRequest("http://localhost/api/updates/apply", { + method: "POST", + token: admin.token, + json: { version: "latest; rm -rf /" }, + }), + ); + expect(res.status).toBe(400); + }); + + it("returns 400 for a version that is not a newer release", async () => { + const admin = await seedUser(getHarness(), "admin"); + const { POST } = await import("@/app/api/updates/apply/route"); + const res = await POST( + makeRequest("http://localhost/api/updates/apply", { + method: "POST", + token: admin.token, + json: { version: "0.2.0" }, + }), + ); + expect(res.status).toBe(400); + const body = await readJson<{ error: string }>(res); + expect(body.error).toMatch(/not a published release newer/); + }); + + it("returns 501 when no updater is configured", async () => { + const admin = await seedUser(getHarness(), "admin"); + const { POST } = await import("@/app/api/updates/apply/route"); + const res = await POST( + makeRequest("http://localhost/api/updates/apply", { + method: "POST", + token: admin.token, + json: { version: "0.3.0" }, + }), + ); + expect(res.status).toBe(501); + }); + }); +}); diff --git a/src/components/settings/release-notes.tsx b/src/components/settings/release-notes.tsx index c8889f0..04b290c 100644 --- a/src/components/settings/release-notes.tsx +++ b/src/components/settings/release-notes.tsx @@ -7,7 +7,12 @@ import * as React from "react"; */ function inline(text: string, key: string): React.ReactNode { - const parts = text.split(/(`[^`]+`)/g); + // Links keep their text, emphasis markers are dropped; both would + // otherwise show as raw Markdown. + const plain = text + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1"); + const parts = plain.split(/(`[^`]+`)/g); if (parts.length === 1) return text; return parts.map((part, index) => part.startsWith("`") && part.endsWith("`") ? ( diff --git a/src/components/settings/updates-section.tsx b/src/components/settings/updates-section.tsx index 4d09642..4d029c1 100644 --- a/src/components/settings/updates-section.tsx +++ b/src/components/settings/updates-section.tsx @@ -33,6 +33,9 @@ import { cn } from "@/lib/utils"; const POLL_MS = 2000; const LOG_TAIL = 12; const PREVIEW_RELEASES = 3; +// After this long without a verdict the controls come back and the copy +// says what to check, rather than spinning forever. +const SLOW_UPDATE_MS = 10 * 60 * 1000; type Tracking = { from: string; target: string; startedAt: number }; @@ -127,15 +130,22 @@ export function UpdatesSection() { }, onError: (error) => { toast.error(mapError(error, "Unable to start the update")); + // A timeout or "already running" may mean the run did start; the + // refetch lets the tracking effect above pick it up. + void queryClient.invalidateQueries({ queryKey: ["updates"] }); }, }); const checkNow = async () => { setChecking(true); try { + const before = data?.checkedAt ?? null; const fresh = await getUpdates({ refresh: true }); queryClient.setQueryData(["updates"], fresh); if (fresh.checkError) toast.error(fresh.checkError); + else if (fresh.checkWarning) toast.error(fresh.checkWarning); + else if (fresh.checkedAt === before) + toast.message("Checked less than a minute ago"); else if (!fresh.updateAvailable) toast.success("Stowage is up to date"); } catch (error) { toast.error(mapError(error, "Unable to check for updates")); @@ -203,6 +213,9 @@ export function UpdatesSection() { ? `Checked ${formatRelativeTime(data.checkedAt).long.toLowerCase()}` : "Not checked yet"}

    + {data.checkWarning ? ( +

    {data.checkWarning}

    + ) : null}
    + ) : null} - ) : phase === "succeeded" ? ( - - ) : null} + {log.length ? (
     {
    +  const env = { ...process.env };
    +  let fetchMock: ReturnType;
    +
    +  beforeEach(() => {
    +    resetReleaseCache();
    +    process.env.NEXT_PUBLIC_APP_VERSION = "0.2.0";
    +    process.env.STOWAGE_BUILD_SHA = "abcdef1234567890";
    +    delete process.env.STOWAGE_UPDATER_URL;
    +    delete process.env.STOWAGE_UPDATER_TOKEN;
    +    fetchMock = vi.fn();
    +    vi.stubGlobal("fetch", fetchMock);
    +  });
    +
    +  afterEach(() => {
    +    process.env = { ...env };
    +    vi.unstubAllGlobals();
    +    vi.useRealTimers();
    +  });
    +
    +  it("reports newer stable releases and the short build sha", async () => {
    +    fetchMock.mockResolvedValue(
    +      json([gh("v0.3.0"), gh("v0.2.0"), gh("v0.1.0")]),
    +    );
    +    const view = await getUpdatesView();
    +    expect(view.current).toEqual({ version: "0.2.0", build: "abcdef1" });
    +    expect(view.updateAvailable).toBe(true);
    +    expect(view.latest?.version).toBe("0.3.0");
    +    expect(view.newer.map((r) => r.version)).toEqual(["0.3.0"]);
    +    expect(view.checkError).toBeNull();
    +    expect(view.checkWarning).toBeNull();
    +    expect(view.updater).toEqual({
    +      configured: false,
    +      reason: "not-configured",
    +    });
    +  });
    +
    +  it("reports a check error when GitHub is unreachable and nothing is cached", async () => {
    +    fetchMock.mockRejectedValue(new Error("offline"));
    +    const view = await getUpdatesView();
    +    expect(view.updateAvailable).toBe(false);
    +    expect(view.checkedAt).toBeNull();
    +    expect(view.checkError).toMatch(/Could not reach GitHub/);
    +  });
    +
    +  it("warns when the list is stale because the refresh failed", async () => {
    +    fetchMock
    +      .mockResolvedValueOnce(json([gh("v0.3.0")]))
    +      .mockResolvedValueOnce(new Response("{}", { status: 403 }));
    +    const first = await getUpdatesView();
    +    expect(first.checkWarning).toBeNull();
    +
    +    vi.useFakeTimers();
    +    vi.setSystemTime(Date.now() + 2 * 60 * 60 * 1000);
    +    const second = await getUpdatesView();
    +    expect(second.latest?.version).toBe("0.3.0");
    +    expect(second.checkError).toBeNull();
    +    expect(second.checkWarning).toMatch(/rate limit/i);
    +    expect(second.checkWarning).toMatch(/last successful check/);
    +  });
    +
    +  it("refuses versions that are not newer published releases", async () => {
    +    fetchMock.mockResolvedValue(json([gh("v0.3.0"), gh("v0.2.0")]));
    +    await expect(applyUpdate("0.2.0")).rejects.toMatchObject({
    +      status: 400,
    +      message: expect.stringMatching(
    +        /not a published release newer than 0\.2\.0/,
    +      ),
    +    });
    +    await expect(applyUpdate("9.9.9")).rejects.toBeInstanceOf(DomainError);
    +  });
    +
    +  it("fails with 502 when GitHub cannot confirm the release", async () => {
    +    fetchMock.mockRejectedValue(new Error("offline"));
    +    await expect(applyUpdate("0.3.0")).rejects.toMatchObject({ status: 502 });
    +  });
    +
    +  it("maps updater errors to domain errors with their status", async () => {
    +    process.env.STOWAGE_UPDATER_URL = "http://updater:9876";
    +    process.env.STOWAGE_UPDATER_TOKEN = "secret";
    +    fetchMock.mockImplementation((input: string | URL | Request) => {
    +      const url = String(input);
    +      if (url.includes("api.github.com")) {
    +        return Promise.resolve(json([gh("v0.3.0")]));
    +      }
    +      if (url.endsWith("/update")) {
    +        return Promise.resolve(
    +          json({ error: "An update is already running" }, 409),
    +        );
    +      }
    +      return Promise.resolve(json({ state: "idle" }));
    +    });
    +    await expect(applyUpdate("0.3.0")).rejects.toMatchObject({
    +      status: 409,
    +      message: "An update is already running",
    +    });
    +  });
    +});
    diff --git a/src/server/updates/__tests__/releases.test.ts b/src/server/updates/__tests__/releases.test.ts
    index 4022612..90f2ce4 100644
    --- a/src/server/updates/__tests__/releases.test.ts
    +++ b/src/server/updates/__tests__/releases.test.ts
    @@ -44,6 +44,9 @@ describe("releases", () => {
         });
         expect(toRelease(gh("v0.3.0", { draft: true }))).toBeNull();
         expect(toRelease(gh("nightly"))).toBeNull();
    +    expect(
    +      toRelease(gh("v0.3.0", { html_url: "javascript:alert(1)" })),
    +    ).toBeNull();
         expect(toRelease(gh("v0.4.0-rc.1"))?.prerelease).toBe(true);
         expect(toRelease(gh("v0.4.0", { prerelease: true }))?.prerelease).toBe(
           true,
    @@ -133,9 +136,13 @@ describe("releases", () => {
         expect(third.releases).toHaveLength(2);
         expect(fetchImpl).toHaveBeenCalledTimes(2);
     
    -    // Expired and GitHub down: the stale list is returned.
    +    // Expired and GitHub down: the stale list is returned, and says so.
         const fourth = await getReleases({ fetchImpl, now: t0 + 3 * 3_600_000 });
         expect(fourth.releases).toHaveLength(2);
    +    expect(fourth.stale).toBe(true);
    +    expect(fourth.error).toMatch(/Could not reach GitHub/);
    +    expect(fourth.fetchedAt).toBe(t0 + 2 * 60_000);
    +    expect(third.stale).toBe(false);
         expect(fetchImpl).toHaveBeenCalledTimes(3);
       });
     
    diff --git a/src/server/updates/__tests__/updater-client.test.ts b/src/server/updates/__tests__/updater-client.test.ts
    index 4d63499..9bf4bda 100644
    --- a/src/server/updates/__tests__/updater-client.test.ts
    +++ b/src/server/updates/__tests__/updater-client.test.ts
    @@ -36,11 +36,15 @@ describe("updater client", () => {
       });
     
       it("reads status with the bearer token and normalises unknown states", async () => {
    -    const fetchImpl = vi
    -      .fn()
    -      .mockResolvedValue(
    -        json({ state: "weird", log: ["a", 2], targetVersion: "0.3.0" }),
    -      );
    +    const fetchImpl = vi.fn().mockResolvedValue(
    +      json({
    +        state: "weird",
    +        log: ["a", 2],
    +        targetVersion: "0.3.0",
    +        startedAt: "soon",
    +        error: { nested: true },
    +      }),
    +    );
         const view = await getUpdaterView(fetchImpl as unknown as typeof fetch);
         expect(view).toEqual({
           configured: true,
    diff --git a/src/server/updates/index.ts b/src/server/updates/index.ts
    index 62529e4..0381113 100644
    --- a/src/server/updates/index.ts
    +++ b/src/server/updates/index.ts
    @@ -22,8 +22,12 @@ export type UpdatesView = {
       latest: Release | null;
       newer: Release[];
       updateAvailable: boolean;
    +  /** When the release list was last fetched from GitHub. */
       checkedAt: number | null;
    +  /** Set when there is no release list at all. */
       checkError: string | null;
    +  /** Set when the list is from an earlier check because GitHub failed now. */
    +  checkWarning: string | null;
       updater: UpdaterView;
     };
     
    @@ -67,6 +71,10 @@ export async function getUpdatesView(
         updateAvailable: newer.length > 0,
         checkedAt: releaseResult.ok ? releaseResult.fetchedAt : null,
         checkError: releaseResult.ok ? null : releaseResult.error,
    +    checkWarning:
    +      releaseResult.ok && releaseResult.stale
    +        ? `${releaseResult.error} Showing the list from the last successful check.`
    +        : null,
         updater,
       };
     }
    diff --git a/src/server/updates/releases.ts b/src/server/updates/releases.ts
    index 0e25bbd..1e0f93b 100644
    --- a/src/server/updates/releases.ts
    +++ b/src/server/updates/releases.ts
    @@ -55,6 +55,8 @@ export function toRelease(raw: GitHubRelease): Release | null {
       if (raw.draft) return null;
       const parsed = parseVersion(raw.tag_name);
       if (!parsed) return null;
    +  // The link is rendered as an href; only accept GitHub's own release pages.
    +  if (!/^https:\/\/github\.com\//.test(raw.html_url ?? "")) return null;
       const version = raw.tag_name.replace(/^v/, "");
       return {
         version,
    @@ -95,19 +97,31 @@ export async function fetchReleasesFromGitHub(
         .filter((release): release is Release => release !== null);
     }
     
    +export type ReleaseList = {
    +  releases: Release[];
    +  /** When the list was fetched from GitHub. */
    +  fetchedAt: number;
    +  /** True when GitHub could not be reached and this is the previous list. */
    +  stale: boolean;
    +  /** Why the latest attempt failed, when `stale`. */
    +  error: string | null;
    +};
    +
     export async function getReleases(
       options: {
         refresh?: boolean;
         now?: number;
         fetchImpl?: typeof fetch;
       } = {},
    -): Promise<{ releases: Release[]; fetchedAt: number }> {
    +): Promise {
       const now = options.now ?? Date.now();
       if (cache) {
         const age = now - cache.fetchedAt;
         const fresh = age < CACHE_TTL_MS;
         const refreshable = age >= REFRESH_MIN_INTERVAL_MS;
    -    if (fresh && !(options.refresh && refreshable)) return cache;
    +    if (fresh && !(options.refresh && refreshable)) {
    +      return { ...cache, stale: false, error: null };
    +    }
       }
       if (!inflight) {
         inflight = fetchReleasesFromGitHub(options.fetchImpl).finally(() => {
    @@ -117,10 +131,19 @@ export async function getReleases(
       try {
         const releases = await inflight;
         cache = { releases, fetchedAt: now };
    -    return cache;
    +    return { ...cache, stale: false, error: null };
       } catch (error) {
    -    // Stale is better than nothing when GitHub is unreachable.
    -    if (cache) return cache;
    +    // Stale is better than nothing when GitHub is unreachable, but say so.
    +    if (cache) {
    +      return {
    +        ...cache,
    +        stale: true,
    +        error:
    +          error instanceof ReleaseFetchError
    +            ? error.message
    +            : "Could not reach GitHub to check for updates.",
    +      };
    +    }
         throw error;
       }
     }
    diff --git a/src/server/updates/updater-client.ts b/src/server/updates/updater-client.ts
    index 1231f87..eb35936 100644
    --- a/src/server/updates/updater-client.ts
    +++ b/src/server/updates/updater-client.ts
    @@ -1,5 +1,7 @@
     import "server-only";
     
    +import { z } from "zod";
    +
     /*
       Talks to the updater sidecar (deploy/updater/server.mjs) when the release
       compose project runs it. Without STOWAGE_UPDATER_URL and a token the app
    @@ -43,27 +45,28 @@ export function isUpdaterConfigured() {
       return config() !== null;
     }
     
    -function normalizeStatus(raw: unknown): UpdaterStatus {
    -  const data = (raw ?? {}) as Partial;
    -  const state: UpdaterState =
    -    data.state === "running" ||
    -    data.state === "succeeded" ||
    -    data.state === "failed"
    -      ? data.state
    -      : "idle";
    -  return {
    -    state,
    -    targetVersion: data.targetVersion ?? null,
    -    startedAt: data.startedAt ?? null,
    -    finishedAt: data.finishedAt ?? null,
    -    log: Array.isArray(data.log) ? data.log.map(String) : [],
    -    error: data.error ?? null,
    -  };
    +const StatusSchema = z.object({
    +  state: z.enum(["idle", "running", "succeeded", "failed"]).catch("idle"),
    +  targetVersion: z.string().nullable().catch(null),
    +  startedAt: z.number().nullable().catch(null),
    +  finishedAt: z.number().nullable().catch(null),
    +  log: z.array(z.unknown()).catch([]),
    +  error: z.string().nullable().catch(null),
    +});
    +
    +export function normalizeStatus(raw: unknown): UpdaterStatus {
    +  const parsed = StatusSchema.safeParse(raw ?? {});
    +  const data = parsed.success ? parsed.data : StatusSchema.parse({});
    +  return { ...data, log: data.log.map(String) };
     }
     
    +const TOKEN_REJECTED =
    +  "The updater rejected the app's token. Set the same STOWAGE_UPDATER_TOKEN for both services.";
    +const NOT_REACHABLE = "Updater is not reachable";
    +
     async function call(
       path: string,
    -  init: { method?: "GET" | "POST"; body?: unknown } = {},
    +  init: { method?: "GET" | "POST"; body?: unknown; timeoutMs?: number } = {},
       fetchImpl: typeof fetch = fetch,
     ): Promise {
       const cfg = config();
    @@ -77,17 +80,18 @@ async function call(
             ...(init.body ? { "content-type": "application/json" } : {}),
           },
           body: init.body ? JSON.stringify(init.body) : undefined,
    -      signal: AbortSignal.timeout(4000),
    +      signal: AbortSignal.timeout(init.timeoutMs ?? 4000),
           cache: "no-store",
         });
       } catch {
    -    throw new UpdaterError("Updater is not reachable", 502);
    +    throw new UpdaterError(NOT_REACHABLE, 502);
       }
       const data = (await res.json().catch(() => ({}))) as { error?: string };
    +  if (res.status === 401) throw new UpdaterError(TOKEN_REJECTED, 502);
       if (!res.ok) {
         throw new UpdaterError(
           data.error ?? `Updater answered ${res.status}`,
    -      res.status === 401 ? 502 : res.status,
    +      res.status,
         );
       }
       return data;
    @@ -105,11 +109,11 @@ export async function getUpdaterView(
           configured: true,
           reachable: false,
           reason:
    -        error instanceof UpdaterError && error.status === 502
    -          ? error.message === "Updater is not reachable"
    -            ? "The updater service is not running or not reachable from the app."
    -            : "The updater rejected the app's token. Set the same STOWAGE_UPDATER_TOKEN for both services."
    -          : "The updater returned an error.",
    +        error instanceof UpdaterError && error.message === NOT_REACHABLE
    +          ? "The updater service is not running or not reachable from the app."
    +          : error instanceof UpdaterError
    +            ? error.message
    +            : "The updater returned an error.",
         };
       }
     }
    @@ -118,9 +122,11 @@ export async function requestUpdate(
       version: string,
       fetchImpl: typeof fetch = fetch,
     ): Promise {
    +  // Starting an update means inspecting containers and creating the helper;
    +  // give a slow host time to answer so the UI follows the run it started.
       const data = await call(
         "/update",
    -    { method: "POST", body: { version } },
    +    { method: "POST", body: { version }, timeoutMs: 30_000 },
         fetchImpl,
       );
       return normalizeStatus(data);