Public build follows the production branch, and rebuilds itself when production moves
main is the shared sandbox the dev server follows; /cdn is built only from PRODUCTION_BRANCH (default production), which moves when someone promotes: git push origin main:production. The container watches that branch and rebuilds /cdn once it has held a new commit for CDN_AUTOBUILD_MINUTES (default 2; 0 = the admin button only); a failed commit is not retried automatically. The build fetches the branch into its own ref (never touching the dev server's FETCH_HEAD) and uses that commit's own exclusion list, so loosening it takes a promotion too. DEPLOY.md documents the promote step.
This commit is contained in:
parent
53363ba80d
commit
febeff4990
4 changed files with 87 additions and 22 deletions
|
|
@ -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://<host>/pb`), and people sign in to PocketBase through the same OIDC provider, with its own client whose redirect URI is `https://<host>/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://<host>/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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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/"
|
||||
|
|
|
|||
|
|
@ -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: *`.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue