| @@ -0,0 +1,313 @@ |
| | 1 | // Per-PR preview deploys of the Cloudflare host. |
| | 2 | // |
| | 3 | // Each pull request gets its own fully isolated stack on a dedicated |
| | 4 | // Cloudflare account: a Worker (`executor-preview-pr-<n>`), its own D1 |
| | 5 | // database, a fresh EXECUTOR_SECRET_KEY, and its own Cloudflare Access |
| | 6 | // application (MCP-OAuth-aware, gating the workers.dev hostname at the edge) |
| | 7 | // so previews are never publicly open. The R2 blob bucket is shared across |
| | 8 | // previews (`executor-preview-blobs`) — blobs are content-addressed overflow |
| | 9 | // values reached only via pointers in each preview's private D1, and leaving |
| | 10 | // the bucket in place keeps teardown trivial. |
| | 11 | // |
| | 12 | // Teardown is stateless by naming convention: everything a preview owns is |
| | 13 | // derived from its PR number, so `destroy` (and the scheduled sweeper) can |
| | 14 | // clean up without any deploy-time state. |
| | 15 | // |
| | 16 | // bun scripts/preview.ts deploy --pr 123 [--skip-build] |
| | 17 | // bun scripts/preview.ts destroy --pr 123 |
| | 18 | // bun scripts/preview.ts list # JSON [{pr, name}] of live previews |
| | 19 | // |
| | 20 | // Env: CLOUDFLARE_API_TOKEN (Workers/D1/R2/Access edit) + CLOUDFLARE_ACCOUNT_ID |
| | 21 | // always; deploy additionally needs PREVIEW_ACCESS_TEAM_DOMAIN (Zero Trust team) |
| | 22 | // and PREVIEW_ACCESS_EMAILS (comma-separated emails allowed through Access; the |
| | 23 | // first one is also the admin unless PREVIEW_ADMIN_EMAILS overrides it). |
| | 24 | |
| | 25 | import { spawnSync } from "node:child_process"; |
| | 26 | import { randomBytes } from "node:crypto"; |
| | 27 | import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; |
| | 28 | import { resolve } from "node:path"; |
| | 29 | |
| | 30 | const CF_API = "https://api.cloudflare.com/client/v4"; |
| | 31 | const APP_DIR = resolve(import.meta.dirname, ".."); |
| | 32 | const WORKER_PREFIX = "executor-preview-pr-"; |
| | 33 | const SHARED_BLOBS_BUCKET = "executor-preview-blobs"; |
| | 34 | |
| | 35 | const TOKEN = process.env.CLOUDFLARE_API_TOKEN ?? ""; |
| | 36 | const ACCOUNT = process.env.CLOUDFLARE_ACCOUNT_ID ?? ""; |
| | 37 | |
| | 38 | const fail = (message: string): never => { |
| | 39 | process.stderr.write(`${message}\n`); |
| | 40 | process.exit(1); |
| | 41 | }; |
| | 42 | |
| | 43 | interface CfFetchResult { |
| | 44 | readonly ok: boolean; |
| | 45 | readonly errors: string; |
| | 46 | readonly result: any; |
| | 47 | } |
| | 48 | |
| | 49 | const cf = async (method: string, path: string, body?: unknown): Promise<CfFetchResult> => { |
| | 50 | const response = await fetch(`${CF_API}${path}`, { |
| | 51 | method, |
| | 52 | headers: { |
| | 53 | authorization: `Bearer ${TOKEN}`, |
| | 54 | "content-type": "application/json", |
| | 55 | }, |
| | 56 | body: body === undefined ? undefined : JSON.stringify(body), |
| | 57 | }); |
| | 58 | const json: any = await response.json().catch(() => ({ success: false, errors: [] })); |
| | 59 | return { |
| | 60 | ok: json.success === true, |
| | 61 | errors: JSON.stringify(json.errors ?? []), |
| | 62 | result: json.result, |
| | 63 | }; |
| | 64 | }; |
| | 65 | |
| | 66 | const cfOk = async (method: string, path: string, body?: unknown): Promise<any> => { |
| | 67 | const response = await cf(method, path, body); |
| | 68 | if (!response.ok) fail(`${method} ${path} failed: ${response.errors}`); |
| | 69 | return response.result; |
| | 70 | }; |
| | 71 | |
| | 72 | /** Paginated GET that concatenates every page of `result`. */ |
| | 73 | const cfList = async (path: string): Promise<any[]> => { |
| | 74 | const all: any[] = []; |
| | 75 | const separator = path.includes("?") ? "&" : "?"; |
| | 76 | for (let page = 1; ; page++) { |
| | 77 | const items = await cfOk("GET", `${path}${separator}page=${page}&per_page=100`); |
| | 78 | all.push(...items); |
| | 79 | if (items.length < 100) return all; |
| | 80 | } |
| | 81 | }; |
| | 82 | |
| | 83 | const run = (command: string, args: string[], options?: { input?: string; cwd?: string }): void => { |
| | 84 | const result = spawnSync(command, args, { |
| | 85 | cwd: options?.cwd ?? APP_DIR, |
| | 86 | stdio: [options?.input === undefined ? "ignore" : "pipe", "inherit", "inherit"], |
| | 87 | input: options?.input, |
| | 88 | env: process.env, |
| | 89 | }); |
| | 90 | if (result.status !== 0) fail(`${command} ${args.join(" ")} exited with ${result.status}`); |
| | 91 | }; |
| | 92 | |
| | 93 | const prNumber = (): number => { |
| | 94 | const index = process.argv.indexOf("--pr"); |
| | 95 | const value = index === -1 ? Number.NaN : Number(process.argv[index + 1]); |
| | 96 | if (!Number.isInteger(value) || value < 0) fail("--pr <number> is required"); |
| | 97 | return value; |
| | 98 | }; |
| | 99 | |
| | 100 | const workerName = (pr: number): string => `${WORKER_PREFIX}${pr}`; |
| | 101 | |
| | 102 | // --- resources --------------------------------------------------------------- |
| | 103 | |
| | 104 | const findD1 = async (name: string): Promise<string | null> => { |
| | 105 | const databases = await cfList(`/accounts/${ACCOUNT}/d1/database?name=${name}`); |
| | 106 | const match = databases.find((database: any) => database.name === name); |
| | 107 | return match ? match.uuid : null; |
| | 108 | }; |
| | 109 | |
| | 110 | const ensureD1 = async (name: string): Promise<string> => { |
| | 111 | const existing = await findD1(name); |
| | 112 | if (existing) { |
| | 113 | process.stderr.write(`reusing D1 ${name} (${existing})\n`); |
| | 114 | return existing; |
| | 115 | } |
| | 116 | const created = await cfOk("POST", `/accounts/${ACCOUNT}/d1/database`, { |
| | 117 | name, |
| | 118 | }); |
| | 119 | process.stderr.write(`created D1 ${name} (${created.uuid})\n`); |
| | 120 | return created.uuid; |
| | 121 | }; |
| | 122 | |
| | 123 | const ensureSharedBucket = async (): Promise<void> => { |
| | 124 | const existing = await cf("GET", `/accounts/${ACCOUNT}/r2/buckets/${SHARED_BLOBS_BUCKET}`); |
| | 125 | if (existing.ok) return; |
| | 126 | const created = await cf("POST", `/accounts/${ACCOUNT}/r2/buckets`, { |
| | 127 | name: SHARED_BLOBS_BUCKET, |
| | 128 | }); |
| | 129 | if (!created.ok) fail(`creating R2 bucket ${SHARED_BLOBS_BUCKET} failed: ${created.errors}`); |
| | 130 | process.stderr.write(`created shared R2 bucket ${SHARED_BLOBS_BUCKET}\n`); |
| | 131 | }; |
| | 132 | |
| | 133 | const workersSubdomain = async (): Promise<string> => { |
| | 134 | const result = await cfOk("GET", `/accounts/${ACCOUNT}/workers/subdomain`); |
| | 135 | return result.subdomain; |
| | 136 | }; |
| | 137 | |
| | 138 | const findAccessApp = async (name: string): Promise<any | null> => { |
| | 139 | const apps = await cfList(`/accounts/${ACCOUNT}/access/apps`); |
| | 140 | return apps.find((app: any) => app.name === name) ?? null; |
| | 141 | }; |
| | 142 | |
| | 143 | /** |
| | 144 | * One Access application per preview, gating its workers.dev hostname at the |
| | 145 | * edge. `oauth_configuration` must be present AT CREATION for the app to speak |
| | 146 | * MCP OAuth (401 + resource_metadata challenge with Access as the authorization |
| | 147 | * server, dynamic client registration for local MCP clients) — without it, |
| | 148 | * unauthenticated /mcp requests get a browser login redirect instead. |
| | 149 | */ |
| | 150 | const ensureAccessApp = async ( |
| | 151 | name: string, |
| | 152 | hostname: string, |
| | 153 | allowedEmails: readonly string[], |
| | 154 | ): Promise<{ id: string; aud: string }> => { |
| | 155 | const existing = await findAccessApp(name); |
| | 156 | if (existing) { |
| | 157 | process.stderr.write(`reusing Access app ${name} (aud ${existing.aud.slice(0, 8)}…)\n`); |
| | 158 | return { id: existing.id, aud: existing.aud }; |
| | 159 | } |
| | 160 | const app = await cfOk("POST", `/accounts/${ACCOUNT}/access/apps`, { |
| | 161 | name, |
| | 162 | type: "self_hosted", |
| | 163 | domain: hostname, |
| | 164 | session_duration: "24h", |
| | 165 | oauth_configuration: { |
| | 166 | enabled: true, |
| | 167 | grant: { session_duration: "24h", access_token_lifetime: "15m" }, |
| | 168 | dynamic_client_registration: { |
| | 169 | enabled: true, |
| | 170 | allowed_uris: [], |
| | 171 | allow_any_on_localhost: true, |
| | 172 | allow_any_on_loopback: true, |
| | 173 | }, |
| | 174 | }, |
| | 175 | }); |
| | 176 | await cfOk("POST", `/accounts/${ACCOUNT}/access/apps/${app.id}/policies`, { |
| | 177 | name: `${name}-allowed-emails`, |
| | 178 | decision: "allow", |
| | 179 | include: allowedEmails.map((email) => ({ email: { email } })), |
| | 180 | }); |
| | 181 | process.stderr.write(`created Access app ${name} for ${hostname}\n`); |
| | 182 | return { id: app.id, aud: app.aud }; |
| | 183 | }; |
| | 184 | |
| | 185 | /** |
| | 186 | * Derive the preview wrangler config from the committed one (same bindings, |
| | 187 | * preview-specific names) so previews always deploy the real config users get. |
| | 188 | * Each substitution is asserted so config drift fails loudly here instead of |
| | 189 | * silently deploying previews against the wrong resources. |
| | 190 | */ |
| | 191 | const writePreviewConfig = (worker: string, databaseName: string, databaseId: string): string => { |
| | 192 | const basePath = resolve(APP_DIR, "wrangler.jsonc"); |
| | 193 | let text = readFileSync(basePath, "utf8"); |
| | 194 | const substitute = (pattern: RegExp, replacement: string): void => { |
| | 195 | if (!pattern.test(text)) |
| | 196 | fail(`wrangler.jsonc no longer matches ${pattern} — update preview.ts`); |
| | 197 | text = text.replace(pattern, replacement); |
| | 198 | }; |
| | 199 | substitute(/"name":\s*"executor-cloudflare"/, `"name": "${worker}"`); |
| | 200 | substitute(/"database_name":\s*"[^"]*"/, `"database_name": "${databaseName}"`); |
| | 201 | substitute(/"database_id":\s*"[^"]*"/, `"database_id": "${databaseId}"`); |
| | 202 | substitute(/"bucket_name":\s*"[^"]*"/, `"bucket_name": "${SHARED_BLOBS_BUCKET}"`); |
| | 203 | const outPath = resolve(APP_DIR, "wrangler.preview.jsonc"); |
| | 204 | writeFileSync(outPath, `// Generated by scripts/preview.ts — do not edit.\n${text}`); |
| | 205 | return outPath; |
| | 206 | }; |
| | 207 | |
| | 208 | // --- commands ---------------------------------------------------------------- |
| | 209 | |
| | 210 | const deploy = async (): Promise<void> => { |
| | 211 | const pr = prNumber(); |
| | 212 | const worker = workerName(pr); |
| | 213 | const teamDomain = process.env.PREVIEW_ACCESS_TEAM_DOMAIN ?? ""; |
| | 214 | const allowedEmails = (process.env.PREVIEW_ACCESS_EMAILS ?? "") |
| | 215 | .split(",") |
| | 216 | .map((email) => email.trim()) |
| | 217 | .filter((email) => email.length > 0); |
| | 218 | if (teamDomain.length === 0) fail("PREVIEW_ACCESS_TEAM_DOMAIN is required"); |
| | 219 | if (allowedEmails.length === 0) fail("PREVIEW_ACCESS_EMAILS is required"); |
| | 220 | const adminEmails = process.env.PREVIEW_ADMIN_EMAILS ?? allowedEmails[0]!; |
| | 221 | |
| | 222 | const databaseId = await ensureD1(worker); |
| | 223 | await ensureSharedBucket(); |
| | 224 | const subdomain = await workersSubdomain(); |
| | 225 | const hostname = `${worker}.${subdomain}.workers.dev`; |
| | 226 | const app = await ensureAccessApp(worker, hostname, allowedEmails); |
| | 227 | const configPath = writePreviewConfig(worker, worker, databaseId); |
| | 228 | |
| | 229 | // turbo so workspace dependencies with build steps (@executor-js/vite-plugin) |
| | 230 | // are built first — a fresh checkout has no dist/ anywhere. |
| | 231 | if (!process.argv.includes("--skip-build")) { |
| | 232 | run("bunx", ["turbo", "build", "--filter=@executor-js/host-cloudflare"], { |
| | 233 | cwd: resolve(APP_DIR, "../.."), |
| | 234 | }); |
| | 235 | } |
| | 236 | run("bunx", [ |
| | 237 | "wrangler", |
| | 238 | "deploy", |
| | 239 | "--config", |
| | 240 | configPath, |
| | 241 | "--var", |
| | 242 | `ACCESS_TEAM_DOMAIN:${teamDomain}`, |
| | 243 | "--var", |
| | 244 | `ACCESS_AUD:${app.aud}`, |
| | 245 | "--var", |
| | 246 | `ADMIN_EMAILS:${adminEmails}`, |
| | 247 | ]); |
| | 248 | |
| | 249 | // The at-rest encryption key: stable across redeploys of the same PR (a new |
| | 250 | // key would orphan secrets already stored in the preview's D1), fresh per |
| | 251 | // preview, set after deploy because the Worker must exist first. |
| | 252 | const secretList = spawnSync("bunx", ["wrangler", "secret", "list", "--config", configPath], { |
| | 253 | cwd: APP_DIR, |
| | 254 | encoding: "utf8", |
| | 255 | env: process.env, |
| | 256 | }); |
| | 257 | if (!(secretList.stdout ?? "").includes("EXECUTOR_SECRET_KEY")) { |
| | 258 | run("bunx", ["wrangler", "secret", "put", "EXECUTOR_SECRET_KEY", "--config", configPath], { |
| | 259 | input: randomBytes(32).toString("hex"), |
| | 260 | }); |
| | 261 | } |
| | 262 | |
| | 263 | const url = `https://${hostname}`; |
| | 264 | process.stderr.write(`preview live at ${url}\n`); |
| | 265 | process.stdout.write(`${JSON.stringify({ pr, worker, url, mcpUrl: `${url}/mcp` })}\n`); |
| | 266 | if (process.env.GITHUB_OUTPUT) { |
| | 267 | appendFileSync(process.env.GITHUB_OUTPUT, `url=${url}\nworker=${worker}\n`); |
| | 268 | } |
| | 269 | }; |
| | 270 | |
| | 271 | const destroy = async (): Promise<void> => { |
| | 272 | const pr = prNumber(); |
| | 273 | const worker = workerName(pr); |
| | 274 | |
| | 275 | // force=true also removes the Worker's Durable Objects + their storage. |
| | 276 | const script = await cf("DELETE", `/accounts/${ACCOUNT}/workers/scripts/${worker}?force=true`); |
| | 277 | process.stderr.write( |
| | 278 | script.ok ? `deleted worker ${worker}\n` : `worker ${worker} already gone\n`, |
| | 279 | ); |
| | 280 | |
| | 281 | const databaseId = await findD1(worker); |
| | 282 | if (databaseId) { |
| | 283 | await cfOk("DELETE", `/accounts/${ACCOUNT}/d1/database/${databaseId}`); |
| | 284 | process.stderr.write(`deleted D1 ${worker}\n`); |
| | 285 | } |
| | 286 | |
| | 287 | const app = await findAccessApp(worker); |
| | 288 | if (app) { |
| | 289 | await cfOk("DELETE", `/accounts/${ACCOUNT}/access/apps/${app.id}`); |
| | 290 | process.stderr.write(`deleted Access app ${worker}\n`); |
| | 291 | } |
| | 292 | }; |
| | 293 | |
| | 294 | const list = async (): Promise<void> => { |
| | 295 | const scripts = await cfList(`/accounts/${ACCOUNT}/workers/scripts`); |
| | 296 | const previews = scripts |
| | 297 | .map((script: any) => String(script.id)) |
| | 298 | .filter((id) => new RegExp(`^${WORKER_PREFIX}\\d+$`).test(id)) |
| | 299 | .map((id) => ({ pr: Number(id.slice(WORKER_PREFIX.length)), name: id })) |
| | 300 | .sort((a, b) => a.pr - b.pr); |
| | 301 | process.stdout.write(`${JSON.stringify(previews)}\n`); |
| | 302 | }; |
| | 303 | |
| | 304 | // --- main -------------------------------------------------------------------- |
| | 305 | |
| | 306 | if (TOKEN.length === 0) fail("CLOUDFLARE_API_TOKEN is required"); |
| | 307 | if (ACCOUNT.length === 0) fail("CLOUDFLARE_ACCOUNT_ID is required"); |
| | 308 | |
| | 309 | const command = process.argv[2]; |
| | 310 | if (command === "deploy") await deploy(); |
| | 311 | else if (command === "destroy") await destroy(); |
| | 312 | else if (command === "list") await list(); |
| | 313 | else fail("usage: preview.ts <deploy|destroy|list> [--pr <number>] [--skip-build]"); |