diff --git a/deployment/cloud/README.md b/deployment/cloud/README.md index d65300d30..0de8d5a5f 100644 --- a/deployment/cloud/README.md +++ b/deployment/cloud/README.md @@ -66,6 +66,8 @@ there. Only then does it swap the new build in. | `OIDC_ADMIN_GROUPS` | comma-separated groups allowed to build and to open PocketBase's dashboard | | `PB_SUPERUSER_EMAIL`, `PB_SUPERUSER_PASSWORD` | PocketBase's superuser, set on start in both instances | | `PB_OIDC_CLIENT_SECRET` (+ `PB_OIDC_CLIENT_ID`, default `dx-maps-pb-dev`) | turns pb-dev into the team backend: laptops use it directly (`DX_PB_URL=https:///pb`), and people sign in to PocketBase through the same OIDC provider, with its own client whose redirect URI is `https:///pb/api/oauth2-redirect`. Only members of `OIDC_ALLOWED_GROUPS` get in (a sign-in hook baked into the image and pinned; the repo's own hooks never run). Password sign-in is off, new collections are superuser-only until a migration opens them, and data never leaves `/data` | +| `PRODUCTION_BRANCH` | the branch `/cdn/` is built from (default `production`); the dev server keeps following `BRANCH`. Promote with `git push origin main:production` | +| `CDN_AUTOBUILD_MINUTES` | rebuild `/cdn/` by itself once `PRODUCTION_BRANCH` has held a new commit this long (default `2`; `0` = only the admin button) | | `PUBLIC_API_BASE` | the public API URL written into the build's `runtime-config.js` (e.g. `https:///api-prod`) | | `BUILD_MAX_OLD_SPACE_MB` | Node heap cap for a build (default `1536`) | | `RUDDER_ENGINE` | `1` runs Rudder's voice-to-text engine here, fed by the browser's microphone (about 1 GB more; its runtime and model, ~742 MB, are baked into the image at `/opt/rudder`, and `/data/rudder` wins when it holds a model). The site serves at once; Rudder shows "starting" until its engine is built, then the dev server restarts once to start it. Give the container at least 2 vCPUs, or transcription falls behind speech | diff --git a/deployment/cloud/admin.mjs b/deployment/cloud/admin.mjs index 9d711ae0c..f15593595 100644 --- a/deployment/cloud/admin.mjs +++ b/deployment/cloud/admin.mjs @@ -3,7 +3,8 @@ // /api-prod/* PocketBase PROD, PUBLIC; only /api/ passes, never the /_/ dashboard // /pb/* PocketBase DEV, signed-in; its /_/ dashboard only for OIDC_ADMIN_GROUPS. With PB_OIDC_CLIENT_SECRET, // its /api/ is PUBLIC (PocketBase signs people in itself), except _superusers and _system collections -// /admin/build POST: start a build (OIDC_ADMIN_GROUPS only); /admin/status GET +// /admin/build POST: build PRODUCTION_BRANCH now (OIDC_ADMIN_GROUPS only); /admin/status GET. +// The public build also follows PRODUCTION_BRANCH by itself (CDN_AUTOBUILD_MINUTES, default 2). // /admin/pb-signin-check a page that signs in to the team PocketBase and shows who it thinks you are // Groups come from oauth2-proxy's X-Forwarded-Groups, which it sets on signed-in routes only. import { spawn } from 'node:child_process' @@ -25,6 +26,45 @@ async function proxy(req, url, prefix, port) { return new Response(r.body, { status: r.status, headers: r.headers }) } +// One public build at a time (build-cdn.sh also holds a lock), started by an admin or by the watcher below. +function startBuild(by) { + last = { state: 'building', by, branch: PROD, started: new Date().toISOString() } + const child = spawn('bash', ['/app/repo/deployment/cloud/build-cdn.sh'], { stdio: ['ignore', 'pipe', 'pipe'] }) + let log = '' + child.stdout.on('data', (d) => { log = (log + d).slice(-4000) }) + child.stderr.on('data', (d) => { log = (log + d).slice(-4000) }) + child.on('exit', (code) => { + last = { state: code === 0 ? 'done' : code === 75 ? 'busy' : 'failed', by, branch: PROD, code, finished: new Date().toISOString(), tail: log.slice(-1500), + commit: publishedCommit() } + }) +} +const publishedCommit = () => (existsSync('/data/cdn/COMMIT') ? readFileSync('/data/cdn/COMMIT', 'utf8').trim() : null) + +// AUTO-BUILD: the public build follows PRODUCTION_BRANCH by itself. main is the shared sandbox; production +// moves only when someone promotes (git push origin main:production), so rebuilding on every move is +// the point. Debounced: build once the branch has held the same commit for CDN_AUTOBUILD_MINUTES +// (default 2; 0 = off, admin button only). A commit whose build failed is not retried automatically. +const PROD = env.PRODUCTION_BRANCH || 'production' +const AUTO_MIN = env.CDN_AUTOBUILD_MINUTES === undefined || env.CDN_AUTOBUILD_MINUTES === '' ? 2 : Number(env.CDN_AUTOBUILD_MINUTES) +const watch = { seen: null, since: 0, failed: null, error: null } +async function checkProduction() { + const r = Bun.spawnSync(['git', '-C', '/app/repo', 'ls-remote', 'origin', `refs/heads/${PROD}`], { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }) + const sha = r.success ? String(r.stdout).split(/\s/)[0] : '' + watch.error = sha ? null : `cannot read branch '${PROD}' on origin` + if (!sha) return + if (sha !== watch.seen) { watch.seen = sha; watch.since = Date.now() } + const published = publishedCommit() + if (published && sha.startsWith(published)) return + if (last.state === 'building' || watch.failed === sha) return + if (Date.now() - watch.since < AUTO_MIN * 60000) return + startBuild('auto') + const poll = setInterval(() => { if (last.state !== 'building') { clearInterval(poll); if (last.state === 'failed') watch.failed = sha } }, 5000) +} +if (AUTO_MIN > 0) { + setInterval(() => checkProduction().catch(() => {}), 30000) + console.log(`[admin] public build follows ${PROD}, ${AUTO_MIN} min after it moves`) +} + Bun.serve({ hostname: '127.0.0.1', port: 5175, @@ -54,20 +94,12 @@ Bun.serve({ } // A one-click check of the team PocketBase sign-in (behind the site sign-in like every /admin/ path). if (p === '/admin/pb-signin-check') return new Response(Bun.file('/app/repo/deployment/cloud/pb-signin-check.html'), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } }) - if (p === '/admin/status') return Response.json(last) + if (p === '/admin/status') return Response.json({ ...last, production: { branch: PROD, autoBuildMinutes: AUTO_MIN, head: watch.seen, published: publishedCommit(), error: watch.error } }) if (p === '/admin/build') { if (req.method !== 'POST') return text('POST only', 405) if (!isAdmin(req)) return text('builds are for team admins', 403) if (last.state === 'building') return text('a build is already running', 409) - last = { state: 'building', started: new Date().toISOString() } - const child = spawn('bash', ['/app/repo/deployment/cloud/build-cdn.sh'], { stdio: ['ignore', 'pipe', 'pipe'] }) - let log = '' - child.stdout.on('data', (d) => { log = (log + d).slice(-4000) }) - child.stderr.on('data', (d) => { log = (log + d).slice(-4000) }) - child.on('exit', (code) => { - last = { state: code === 0 ? 'done' : code === 75 ? 'busy' : 'failed', code, finished: new Date().toISOString(), tail: log.slice(-1500), - commit: existsSync('/data/cdn/COMMIT') ? readFileSync('/data/cdn/COMMIT', 'utf8').trim() : null } - }) + startBuild('admin') return text('build started', 202) } return text('not found', 404) diff --git a/deployment/cloud/build-cdn.sh b/deployment/cloud/build-cdn.sh index 7a0d9de04..a256bc401 100755 --- a/deployment/cloud/build-cdn.sh +++ b/deployment/cloud/build-cdn.sh @@ -1,6 +1,8 @@ #!/bin/bash -# build-cdn.sh — one production build of the pulled commit into /data/cdn, one at a time. -# snapshot (so the pull loop can't change files mid-build) -> apply the public exclusions -> +# build-cdn.sh — one public build of the PRODUCTION branch into /data/cdn, one at a time. +# main is the shared sandbox the dev server follows; the public build comes only from +# PRODUCTION_BRANCH (default production), which moves when someone promotes main to it. +# fetch that branch into its own ref -> snapshot it -> apply ITS public exclusions -> # the build steps WITHOUT the gates (memory-capped) -> check the output -> the exclusion check again # (refuses the swap) -> atomic swap of /data/cdn/current. # No gates here: they already passed before the commit reached main, and a full gate run on a @@ -12,11 +14,26 @@ exec 9>/tmp/build-cdn.lock flock -n 9 || { echo "another build is running"; exit 75; } echo $$ > /tmp/build-cdn.pid cd /app/repo -SHA=$(git rev-parse --short HEAD); SNAP=/tmp/build-$SHA +PB="${PRODUCTION_BRANCH:-production}" +# Its own ref, and no FETCH_HEAD: the dev server's pull loop uses FETCH_HEAD for main at the same time. +git fetch -q --no-write-fetch-head --depth 1 origin "+refs/heads/$PB:refs/cdn/$PB" \ + || { echo "cannot fetch branch '$PB' (create it with: git push origin main:$PB)"; exit 1; } +SHA=$(git rev-parse --short "refs/cdn/$PB"); SNAP=/tmp/build-$SHA rm -rf "$SNAP"; mkdir -p "$SNAP" -git archive HEAD | tar -x -C "$SNAP" || { echo "snapshot failed"; exit 1; } -bash /app/repo/deployment/cloud/apply-exclusions.sh "$SNAP" -ln -s /app/repo/node_modules "$SNAP/node_modules" +git archive "refs/cdn/$PB" | tar -x -C "$SNAP" || { echo "snapshot failed"; exit 1; } +# The exclusion list and checker come from the branch being published, so loosening them takes a +# promotion too (falls back to the dev server's copy for a branch older than the list). +SRC="$SNAP/deployment/cloud"; [ -f "$SRC/apply-exclusions.sh" ] && [ -f "$SRC/public-exclusions.txt" ] || SRC=/app/repo/deployment/cloud +EXDIR=/tmp/exclusions-$SHA; rm -rf "$EXDIR"; mkdir -p "$EXDIR" +cp "$SRC/apply-exclusions.sh" "$SRC/public-exclusions.txt" "$EXDIR/" # the script reads the list beside it +bash "$EXDIR/apply-exclusions.sh" "$SNAP" +# Dependencies: reuse the dev server's install when the lockfile is the same, else install for this commit. +if cmp -s "$SNAP/bun.lock" /app/repo/bun.lock; then + ln -s /app/repo/node_modules "$SNAP/node_modules" +else + echo "$(date -u +%FT%TZ) $PB's lockfile differs from main's: installing its dependencies" + (cd "$SNAP" && bun install --frozen-lockfile >/dev/null) || { echo "dependency install failed"; rm -rf "$SNAP"; exit 1; } +fi cd "$SNAP" echo "$(date -u +%FT%TZ) building $SHA" export NODE_OPTIONS="--max-old-space-size=${BUILD_MAX_OLD_SPACE_MB:-1536}" @@ -28,9 +45,9 @@ OUT="$SNAP/public/dx-maps" bun -e 'console.log("window.MAP_API_BASE=" + JSON.stringify(process.env.PUBLIC_API_BASE || "") + ";")' > "$OUT/runtime-config.js" NEW=/data/cdn/build-$SHA rm -rf "$NEW"; mkdir -p /data/cdn; mv "$SNAP/public" "$NEW" -bash /app/repo/deployment/cloud/apply-exclusions.sh --check "$NEW" || { echo "REFUSED: excluded content in the build; not published"; rm -rf "$NEW" "$SNAP"; exit 1; } +bash "$EXDIR/apply-exclusions.sh" --check "$NEW" || { echo "REFUSED: excluded content in the build; not published"; rm -rf "$NEW" "$SNAP" "$EXDIR"; exit 1; } ln -sfn "$NEW" /data/cdn/current.tmp && mv -T /data/cdn/current.tmp /data/cdn/current echo "$SHA" > /data/cdn/COMMIT find /data/cdn -maxdepth 1 -name 'build-*' ! -path "$NEW" -exec rm -rf {} + -rm -rf "$SNAP" -echo "$(date -u +%FT%TZ) published $SHA at /cdn/dx-maps/" +rm -rf "$SNAP" "$EXDIR" +echo "$(date -u +%FT%TZ) published $SHA ($PB) at /cdn/dx-maps/" diff --git a/documentation/DEPLOY.md b/documentation/DEPLOY.md index bed1d8f78..83599b91e 100644 --- a/documentation/DEPLOY.md +++ b/documentation/DEPLOY.md @@ -64,10 +64,24 @@ Details: [deployment/cloud/README.md](../deployment/cloud/README.md) and The built folder, `dx-maps/`, is what other sites embed. Every link inside it is relative, so it works from any address. -- **From the cloud dev server:** an admin runs a build (`POST /admin/build`; about 2 minutes). It builds - the current commit, removes private files, refuses to publish if any remain, then swaps +- **From the cloud dev server:** `/cdn/` serves the **`production`** branch, never `main`. `main` is the + shared sandbox where everyone lands all day; `production` moves only when you decide the current state + is ready to be public. The container notices when `production` moves and rebuilds `/cdn` by itself + (about 2 minutes after the move, then about 2 minutes to build). Each build removes private files, + refuses to publish if any remain, keeps the last good build if anything fails, and swaps `/cdn/dx-maps/` over atomically. `/cdn/` is public (no sign-in) and sends `Access-Control-Allow-Origin: *`, so both iframes and JS embeds work on other sites. + + **To publish (promote main to production)**, once the checks pass on the commit you want public: + + ```bash + git fetch origin && git checkout main && git merge --ff-only origin/main + bun run gates:pre # the checks must pass on exactly this commit + git push origin main:production # /cdn follows within a few minutes + ``` + + An admin can also rebuild at once (`POST /admin/build`), and `/admin/status` shows what `/cdn` serves + and what `production` points at. - **On any static host:** build locally with `BUILD_ALLOW=1 bun run build` and upload `public/dx-maps/`. For JS embeds on other sites, the host must send `Access-Control-Allow-Origin: *`.