|
| 1 | +/* oxlint-disable executor/no-try-catch-or-throw -- boundary: out-of-band deploy gate over a raw postgres connection */ |
| 2 | +// --------------------------------------------------------------------------- |
| 3 | +// Deploy gate: make the membership mirror READY before the build that |
| 4 | +// authorizes from it goes live, and fail the deploy if it cannot be. |
| 5 | +// |
| 6 | +// bun run db:ensure-workos-mirror-ready:prod # op run --env-file=.env.production |
| 7 | +// (deploy.yml runs it after the migrations, before the cloud deploy) |
| 8 | +// |
| 9 | +// Readiness is the SAME rule the request path applies |
| 10 | +// (`src/auth/mirror-readiness-store.ts`): the one-off backfill has written |
| 11 | +// every organization (`workos_sync.backfill_completed_at`) AND the events |
| 12 | +// reconciler has drained the stream within its lag budget |
| 13 | +// (`workos_sync.drained_at`). Until both hold the deployed build reads |
| 14 | +// membership from WorkOS instead of the mirror, so an unready mirror never |
| 15 | +// locks anyone out or lets a revoked member in — but a deploy that leaves it |
| 16 | +// unready would run every request through that fallback, which is the state |
| 17 | +// this whole cutover exists to leave behind. So this gate: |
| 18 | +// 1. reads the readiness row; |
| 19 | +// 2. if the backfill has not completed, RUNS it (scripts/backfill-workos-mirror.ts, |
| 20 | +// idempotent) and reads again; |
| 21 | +// 3. if the reconciler has not drained recently, DRAINS the stream itself |
| 22 | +// (scripts/drain-workos-events.ts: the same replay the Worker's cron |
| 23 | +// runs, over this connection) and reads again — never merely waits for |
| 24 | +// the cron: this gate runs BEFORE the build that carries the cron may |
| 25 | +// have been deployed, and a gate that only waited could not pass until |
| 26 | +// the reconciler build had shipped on its own, by hand. A cron that is |
| 27 | +// already live is safe beside it (the cursor's compare-and-set gives |
| 28 | +// the stream one owner at a time); |
| 29 | +// 4. exits 0 only when the mirror is ready, and 1 with the reason otherwise. |
| 30 | +// Needs DATABASE_URL and WORKOS_API_KEY (the backfill and the drain read WorkOS). |
| 31 | +// --------------------------------------------------------------------------- |
| 32 | + |
| 33 | +import { spawnSync } from "node:child_process"; |
| 34 | +import { dirname, resolve } from "node:path"; |
| 35 | +import { fileURLToPath } from "node:url"; |
| 36 | + |
| 37 | +import { drizzle } from "drizzle-orm/postgres-js"; |
| 38 | +import postgres from "postgres"; |
| 39 | + |
| 40 | +import { |
| 41 | + MirrorReadinessState, |
| 42 | + describeMirrorReadiness, |
| 43 | + readMirrorReadiness, |
| 44 | +} from "../src/auth/mirror-readiness-store"; |
| 45 | + |
| 46 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 47 | +const BACKFILL_SCRIPT = resolve(__dirname, "backfill-workos-mirror.ts"); |
| 48 | +const DRAIN_SCRIPT = resolve(__dirname, "drain-workos-events.ts"); |
| 49 | + |
| 50 | +const connectionString = process.env.DATABASE_URL; |
| 51 | +if (!connectionString) { |
| 52 | + console.error("DATABASE_URL is not set"); |
| 53 | + process.exit(1); |
| 54 | +} |
| 55 | + |
| 56 | +const usesLocalDatabase = |
| 57 | + connectionString.includes("127.0.0.1") || connectionString.includes("localhost"); |
| 58 | + |
| 59 | +const sql = postgres(connectionString, { |
| 60 | + max: 1, |
| 61 | + prepare: false, |
| 62 | + ...(usesLocalDatabase ? {} : { ssl: "require" as const }), |
| 63 | +}); |
| 64 | +const db = drizzle(sql); |
| 65 | + |
| 66 | +const log = (line: string) => console.log(`[mirror-ready] ${line}`); |
| 67 | + |
| 68 | +const readiness = () => readMirrorReadiness(db, new Date()); |
| 69 | + |
| 70 | +// The backfill and drain scripts own their own WorkOS + database wiring; |
| 71 | +// running them as subprocesses (with this process's env) keeps that wiring |
| 72 | +// in one place. |
| 73 | +const runScript = (what: string, script: string) => { |
| 74 | + if (!process.env.WORKOS_API_KEY) { |
| 75 | + throw new Error(`WORKOS_API_KEY is not set; the mirror ${what} cannot run`); |
| 76 | + } |
| 77 | + const result = spawnSync("bun", ["run", script], { |
| 78 | + stdio: "inherit", |
| 79 | + env: process.env, |
| 80 | + }); |
| 81 | + if (result.status !== 0) { |
| 82 | + throw new Error(`the mirror ${what} exited with status ${result.status ?? "unknown"}`); |
| 83 | + } |
| 84 | +}; |
| 85 | + |
| 86 | +try { |
| 87 | + let state = await readiness(); |
| 88 | + log(describeMirrorReadiness(state)); |
| 89 | + |
| 90 | + if (MirrorReadinessState.$is("BackfillPending")(state)) { |
| 91 | + log("backfill not completed; running scripts/backfill-workos-mirror.ts"); |
| 92 | + runScript("backfill", BACKFILL_SCRIPT); |
| 93 | + state = await readiness(); |
| 94 | + log(describeMirrorReadiness(state)); |
| 95 | + } |
| 96 | + |
| 97 | + if (MirrorReadinessState.$is("ReconcilerStale")(state)) { |
| 98 | + log("events stream not drained recently; running scripts/drain-workos-events.ts"); |
| 99 | + runScript("drain", DRAIN_SCRIPT); |
| 100 | + state = await readiness(); |
| 101 | + log(describeMirrorReadiness(state)); |
| 102 | + } |
| 103 | + |
| 104 | + if (!MirrorReadinessState.$is("Ready")(state)) { |
| 105 | + console.error( |
| 106 | + `[mirror-ready] the membership mirror is not ready: ${describeMirrorReadiness(state)}. ` + |
| 107 | + "The deployed build would read membership from WorkOS on every request until it is. " + |
| 108 | + "Check that WorkOS is reachable and the backfill has run, then rerun the deploy.", |
| 109 | + ); |
| 110 | + process.exit(1); |
| 111 | + } |
| 112 | + log("the membership mirror is ready"); |
| 113 | +} finally { |
| 114 | + await sql.end({ timeout: 5 }); |
| 115 | +} |
0 commit comments