diff --git a/deployment/cloud/admin.mjs b/deployment/cloud/admin.mjs index c38a2ba58..a39939e50 100644 --- a/deployment/cloud/admin.mjs +++ b/deployment/cloud/admin.mjs @@ -9,8 +9,8 @@ // /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' -import { existsSync, readFileSync, statSync } from 'node:fs' -import { join, normalize } from 'node:path' +import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' +import { basename, join, normalize } from 'node:path' import { serveTreeFile } from './cdn-cache.mjs' const env = process.env @@ -41,6 +41,8 @@ function startBuild(by) { }) } const publishedCommit = () => (existsSync('/data/cdn/COMMIT') ? readFileSync('/data/cdn/COMMIT', 'utf8').trim() : null) +// The production PocketBase's schema: the snapshot its migrations came from (pb-prod-migrations.sh), or 'empty'. +const prodSchema = () => { try { return basename(realpathSync('/data/pb-prod-migrations/current')) } catch { return 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 @@ -140,7 +142,7 @@ 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, production: { branch: PROD, autoBuildMinutes: AUTO_MIN, head: watch.seen, published: publishedCommit(), error: watch.error } }) + if (p === '/admin/status') return Response.json({ ...last, production: { branch: PROD, autoBuildMinutes: AUTO_MIN, head: watch.seen, published: publishedCommit(), prodSchema: prodSchema(), 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) diff --git a/deployment/cloud/build-cdn.sh b/deployment/cloud/build-cdn.sh index a256bc401..a419fefc9 100755 --- a/deployment/cloud/build-cdn.sh +++ b/deployment/cloud/build-cdn.sh @@ -48,6 +48,12 @@ rm -rf "$NEW"; mkdir -p /data/cdn; mv "$SNAP/public" "$NEW" 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 +# The production PocketBase follows the SAME snapshot: its migrations, then a restart of pb-prod only (exit 0 = the +# migrations changed). A failure keeps pb-prod on its last good schema; the publish itself stands. +bash /app/repo/deployment/cloud/pb-prod-migrations.sh from "$SNAP/backend/pocketbase/pb_migrations" "$SHA" +if [ $? -eq 0 ] && [ -f /tmp/pb-prod.pid ]; then + kill "$(cat /tmp/pb-prod.pid)" 2>/dev/null && echo "$(date -u +%FT%TZ) pb-prod restarting on schema $SHA" +fi find /data/cdn -maxdepth 1 -name 'build-*' ! -path "$NEW" -exec rm -rf {} + rm -rf "$SNAP" "$EXDIR" echo "$(date -u +%FT%TZ) published $SHA ($PB) at /cdn/dx-maps/" diff --git a/deployment/cloud/entry.sh b/deployment/cloud/entry.sh index c1845bbe6..2415a7600 100755 --- a/deployment/cloud/entry.sh +++ b/deployment/cloud/entry.sh @@ -93,9 +93,13 @@ for g in "${G[@]}"; do g="${g// /}"; [ -n "$g" ] && ALLOW+=(--allowed-group="$g" export OAUTH2_PROXY_CLIENT_SECRET="$OIDC_CLIENT_SECRET" export OAUTH2_PROXY_COOKIE_SECRET="${OIDC_COOKIE_SECRET:-$(head -c 32 /dev/urandom | base64 | tr -d '=+/' | head -c 32)}" -# Backend: PocketBase DEV (/pb/, signed-in) and PROD (/api-prod/, public API only). Schema = the -# migrations in the repo; data = the /data volume. The superuser comes from env, never printed. +# Backend: PocketBase DEV (/pb/, signed-in) and PROD (/api-prod/, public API only); data = the /data volume. +# The superuser comes from env, never printed. SCHEMA: dev runs the migrations in this checkout (BRANCH, the +# table); prod runs ONLY the production branch's (PRODUCTION_BRANCH), from its own snapshot under /data +# (pb-prod-migrations.sh), refreshed by build-cdn.sh when a publish lands. Never from this checkout. MIG=/app/repo/backend/pocketbase/pb_migrations; mkdir -p "$MIG" /data/pb-dev /data/pb-prod /data/cdn +bash /app/repo/deployment/cloud/pb-prod-migrations.sh fetch +PROD_MIG=/data/pb-prod-migrations/current for inst in dev prod; do if [ -n "${PB_SUPERUSER_EMAIL:-}" ] && [ -n "${PB_SUPERUSER_PASSWORD:-}" ]; then pocketbase superuser upsert "$PB_SUPERUSER_EMAIL" "$PB_SUPERUSER_PASSWORD" --dir "/data/pb-$inst" >/dev/null 2>&1 \ @@ -109,7 +113,16 @@ NOHOOKS=/var/empty/pb_hooks_disabled GATE=/app/pb_hooks_gate DEV_HOOKS=$NOHOOKS; [ -f "$GATE/team-gate.pb.js" ] && DEV_HOOKS=$GATE pocketbase serve --http 127.0.0.1:8090 --dir /data/pb-dev --migrationsDir "$MIG" --hooksDir "$DEV_HOOKS" & -pocketbase serve --http 127.0.0.1:8091 --dir /data/pb-prod --migrationsDir "$MIG" --hooksDir "$NOHOOKS" & +# pb-prod runs in a restart loop: after a publish that changes its schema, build-cdn.sh stops it (PID in +# /tmp/pb-prod.pid) and it comes straight back on the new migrations, which PocketBase applies at start. The loop +# never ends, so a pb-prod exit restarts pb-prod, not the container. --automigrate=false: a change made in its +# dashboard writes no migration file anywhere. +( while :; do + pocketbase serve --http 127.0.0.1:8091 --dir /data/pb-prod --migrationsDir "$PROD_MIG" --automigrate=false \ + --hooksDir "$NOHOOKS" & + echo $! > /tmp/pb-prod.pid; wait $! + echo "$(date -u +%FT%TZ) [pb-prod] exited ($?); starting again on $(basename "$(readlink "$PROD_MIG")")"; sleep 1 + done ) & bun /app/repo/deployment/cloud/admin.mjs & # TEAM PB: laptops reach pb-dev's API WITHOUT the site sign-in (DX_PB_URL=https:///pb), and PocketBase diff --git a/deployment/cloud/pb-prod-migrations.sh b/deployment/cloud/pb-prod-migrations.sh new file mode 100755 index 000000000..c62e63faf --- /dev/null +++ b/deployment/cloud/pb-prod-migrations.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# pb-prod-migrations.sh — the PRODUCTION PocketBase (pb-prod, the public API) takes its schema from the production +# branch, never from the checkout the dev server follows. The dev server's checkout is BRANCH (the table, e.g. +# development); production is PRODUCTION_BRANCH (e.g. main). PocketBase migrations are code with full access to +# the database, so production runs only what was published. +# +# Keeps /data/pb-prod-migrations//, a `current` symlink that pb-prod reads, and `previous` for rollback. +# pb-prod-migrations.sh fetch at container start (entry.sh): fetch PRODUCTION_BRANCH into +# refs/cdn/, the ref build-cdn.sh uses, and snapshot its migrations +# pb-prod-migrations.sh from after a publish (build-cdn.sh): the snapshot that was just published +# Exit: 0 = current now holds different migrations (restart pb-prod to apply them); 3 = unchanged; 1 = failed, and +# the last good `current` is kept. FAIL-CLOSED: with no good snapshot yet, `current` is an EMPTY folder (no app +# schema). It never points at the development checkout. +set -u +ROOT=/data/pb-prod-migrations +REL=backend/pocketbase/pb_migrations +log() { echo "$(date -u +%FT%TZ) [pb-prod schema] $*"; } +mkdir -p "$ROOT/empty" +[ -L "$ROOT/current" ] || ln -sfn "$ROOT/empty" "$ROOT/current" + +# install +install() { + local src=$1 sha=$2 new="$ROOT/$2" old + old=$(readlink "$ROOT/current") + rm -rf "$new.tmp" && mkdir -p "$new.tmp" || return 1 + if [ -d "$src" ]; then cp -a "$src/." "$new.tmp/" || return 1; fi + if [ -d "$old" ] && diff -rq "$old" "$new.tmp" >/dev/null 2>&1; then + rm -rf "$new.tmp"; log "unchanged at $sha ($(ls "$old" | wc -l) files, from $(basename "$old"))"; return 3 + fi + rm -rf "$new" && mv "$new.tmp" "$new" || return 1 + ln -sfn "$new" "$ROOT/current.tmp" && mv -T "$ROOT/current.tmp" "$ROOT/current" || return 1 + [ -d "$old" ] && [ "$old" != "$new" ] && ln -sfn "$old" "$ROOT/previous.tmp" && mv -T "$ROOT/previous.tmp" "$ROOT/previous" + # keep empty/, current and previous; drop older snapshots + for d in "$ROOT"/*/; do d=${d%/} + case "$d" in "$ROOT/empty"|"$(readlink "$ROOT/current")"|"$(readlink "$ROOT/previous" 2>/dev/null)") ;; *) rm -rf "$d" ;; esac + done + log "now $sha: $(ls "$new" | wc -l) migration files (was $(basename "$old"))"; return 0 +} + +case "${1:-}" in + fetch) + PB="${PRODUCTION_BRANCH:-production}" + cd /app/repo || exit 1 + # Its own ref and no FETCH_HEAD, exactly as build-cdn.sh: the dev server's pull loop uses FETCH_HEAD. + if ! git fetch -q --no-write-fetch-head --depth 1 origin "+refs/heads/$PB:refs/cdn/$PB"; then + log "cannot fetch '$PB': keeping $(basename "$(readlink "$ROOT/current")")"; exit 1 + fi + SHA=$(git rev-parse --short "refs/cdn/$PB") + T=$(mktemp -d) + if git ls-tree -d "refs/cdn/$PB" "$REL" | grep -q .; then + git archive "refs/cdn/$PB" "$REL" | tar -x -C "$T" || { log "snapshot of $PB failed: keeping the last good"; rm -rf "$T"; exit 1; } + fi + install "$T/$REL" "$SHA"; rc=$?; rm -rf "$T"; exit $rc ;; + from) + [ $# -eq 3 ] || { echo "usage: $0 from "; exit 1; } + install "$2" "$3"; exit $? ;; + *) echo "usage: $0 fetch | from "; exit 1 ;; +esac