diff --git a/.env.example b/.env.example index 0250b28..5806ac5 100644 --- a/.env.example +++ b/.env.example @@ -72,6 +72,23 @@ 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= + +# 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`. +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..f20abe5 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,13 +126,14 @@ 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. +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: @@ -165,24 +167,73 @@ 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 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: + +```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. 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 +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 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 +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`) | +| `STOWAGE_RELEASE_REPO` | no | GitHub repo whose releases are offered (default `kroqdotdev/stowage`) | ## Tests diff --git a/RELEASING.md b/RELEASING.md index 0666e83..2d5c89f 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,12 +36,36 @@ 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 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: @@ -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..50ec763 --- /dev/null +++ b/deploy/updater/server.mjs @@ -0,0 +1,630 @@ +#!/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. 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"; +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; +// 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). +// --------------------------------------------------------------------------- + +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", + }; +} + +// 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}`] }), + ); + const containers = await docker( + "GET", + `/containers/json?all=true&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 ?? ""; + const expected = Buffer.from(`Bearer ${TOKEN}`); + const given = Buffer.from(header); + return given.length === expected.length && timingSafeEqual(given, expected); +} + +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 projectServices(labels.project); + if (!services.length) { + throw new Error( + `No 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-files", + (labels.envFiles.length + ? labels.envFiles + : [path.join(labels.workingDir, ".env")] + ).join(","), + "--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: { + 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, + })), + ], + }, + }, + ); + 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 }; +} + +// 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 = []; + 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") { + 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: 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, + ), + }; + 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") { + 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; + } + 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); + // Details go to the container log, not to the caller. + send(res, 500, { + error: "The updater could not start the update. See its container log.", + }); + } + }); + 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 { + // "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, " "), + ), + ); + } + }); + }); +} + +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 envFiles = (args["env-files"] ?? "").split(",").filter(Boolean); + const compose = [ + "compose", + "--project-name", + args.project, + "--project-directory", + args["working-dir"], + ...(args["config-files"] ?? "") + .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, + COMPOSE_PROFILES: process.env.COMPOSE_PROFILES ?? "", + }; + + const state = await readState(); + state.state = "running"; + state.targetVersion = version; + state.error = null; + try { + // 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( + 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)) { + // 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"; + 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..43936ac --- /dev/null +++ b/src/__tests__/components/settings/updates-section.test.tsx @@ -0,0 +1,378 @@ +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, + checkWarning: 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("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, + 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/__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/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..04b290c --- /dev/null +++ b/src/components/settings/release-notes.tsx @@ -0,0 +1,101 @@ +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 { + // 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("`") ? ( + + {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..4d029c1 --- /dev/null +++ b/src/components/settings/updates-section.tsx @@ -0,0 +1,518 @@ +"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; +// 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 }; + +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")); + // 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")); + } 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.checkWarning ? ( +

{data.checkWarning}

+ ) : null} +
+ +
+ + {!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 [slow, setSlow] = React.useState(false); + React.useEffect(() => { + if (!busy || !tracking) { + setSlow(false); + return; + } + const remaining = tracking.startedAt + SLOW_UPDATE_MS - Date.now(); + if (remaining <= 0) { + setSlow(true); + return; + } + const timer = setTimeout(() => setSlow(true), remaining); + return () => clearTimeout(timer); + }, [busy, tracking]); + + 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." + : slow + ? "This is taking longer than expected. Check `docker compose ps` and the container logs on the host; reload once the app answers." + : 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 === "succeeded" || slow ? ( + + ) : 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..248bd24 --- /dev/null +++ b/src/lib/api/updates.ts @@ -0,0 +1,60 @@ +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; + checkWarning: 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__/index.test.ts b/src/server/updates/__tests__/index.test.ts new file mode 100644 index 0000000..d608ff5 --- /dev/null +++ b/src/server/updates/__tests__/index.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { DomainError } from "@/server/pb/errors"; + +import { applyUpdate, getUpdatesView } from "../index"; +import { resetReleaseCache } from "../releases"; + +function gh(tag: string) { + return { + tag_name: tag, + name: tag, + body: "", + html_url: `https://github.com/kroqdotdev/stowage/releases/tag/${tag}`, + draft: false, + prerelease: false, + published_at: "2026-11-01T10:00:00Z", + }; +} + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("updates view", () => { + 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 new file mode 100644 index 0000000..90f2ce4 --- /dev/null +++ b/src/server/updates/__tests__/releases.test.ts @@ -0,0 +1,153 @@ +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.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, + ); + 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, 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); + }); + + 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..9bf4bda --- /dev/null +++ b/src/server/updates/__tests__/updater-client.test.ts @@ -0,0 +1,114 @@ +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", + startedAt: "soon", + error: { nested: true }, + }), + ); + 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..0381113 --- /dev/null +++ b/src/server/updates/index.ts @@ -0,0 +1,110 @@ +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; + /** 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; +}; + +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, + checkWarning: + releaseResult.ok && releaseResult.stale + ? `${releaseResult.error} Showing the list from the last successful check.` + : null, + 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..1e0f93b --- /dev/null +++ b/src/server/updates/releases.ts @@ -0,0 +1,166 @@ +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; + // 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, + 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 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 { + 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, stale: false, error: null }; + } + } + if (!inflight) { + inflight = fetchReleasesFromGitHub(options.fetchImpl).finally(() => { + inflight = null; + }); + } + try { + const releases = await inflight; + cache = { releases, fetchedAt: now }; + return { ...cache, stale: false, error: null }; + } catch (error) { + // 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; + } +} + +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..eb35936 --- /dev/null +++ b/src/server/updates/updater-client.ts @@ -0,0 +1,133 @@ +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 + 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; +} + +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; timeoutMs?: number } = {}, + 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(init.timeoutMs ?? 4000), + cache: "no-store", + }); + } catch { + 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, + ); + } + 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.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.", + }; + } +} + +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 }, timeoutMs: 30_000 }, + fetchImpl, + ); + return normalizeStatus(data); +}