| @@ -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]"); |