diff --git a/.github/workflows/preview-sweep.yml b/.github/workflows/preview-sweep.yml index b498c2864..977ba31c9 100644 --- a/.github/workflows/preview-sweep.yml +++ b/.github/workflows/preview-sweep.yml @@ -3,7 +3,9 @@ name: Preview sweep # Safety net for the per-PR previews (.github/workflows/preview.yml): force # pushes, cancelled runs, or teardown failures can orphan a preview stack. # Nightly, list every deployed preview and destroy any whose PR is no longer -# open. +# open. A second pass (sweep-e2e) cleans up after the e2e harness on the same +# account: crashed suite runs leak executor-e2e-* stacks, and synthetic OTP +# logins permanently occupy Zero Trust seats. on: schedule: @@ -41,3 +43,9 @@ jobs: echo "PR #$pr is open — keeping its preview" fi done + + - name: Sweep e2e leftovers + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_PREVIEW_ACCOUNT_ID }} + run: bun apps/host-cloudflare/scripts/preview.ts sweep-e2e diff --git a/apps/host-cloudflare/scripts/preview.ts b/apps/host-cloudflare/scripts/preview.ts index 5909d7e9b..2f114465b 100644 --- a/apps/host-cloudflare/scripts/preview.ts +++ b/apps/host-cloudflare/scripts/preview.ts @@ -16,6 +16,16 @@ // bun scripts/preview.ts deploy --pr 123 [--skip-build] // bun scripts/preview.ts destroy --pr 123 // bun scripts/preview.ts list # JSON [{pr, name}] of live previews +// bun scripts/preview.ts sweep-e2e # clean e2e leftovers (see below) +// +// sweep-e2e cleans up after the e2e harness, which shares this account: +// suite runs that crash before their teardown leak an `executor-e2e-` +// worker + D1 + Access app trio, and every real Access OTP login by a +// synthetic ci-…@executor-dev.xyz identity permanently occupies a Zero Trust +// seat (50 on the free plan). Stacks older than a day are deleted; seats held +// by the synthetic domain are revoked. Persistent e2e infrastructure +// (executor-e2e-otp, executor-e2e-emulators, alchemy-state-store) is never +// touched. // // Env: CLOUDFLARE_API_TOKEN (Workers/D1/R2/Access edit) + CLOUDFLARE_ACCOUNT_ID // always; deploy additionally needs PREVIEW_ACCESS_TEAM_DOMAIN (Zero Trust team) @@ -301,6 +311,67 @@ const list = async (): Promise => { process.stdout.write(`${JSON.stringify(previews)}\n`); }; +// E2e stacks are worker `executor-e2e-<8 hex>` + D1 `…-db` + Access app of the +// same name; the fixed-name workers are this account's persistent e2e infra. +const E2E_STACK_PATTERN = /^executor-e2e-[0-9a-f]{8}$/; +const E2E_SEAT_DOMAIN = "@executor-dev.xyz"; +const E2E_MIN_AGE_MS = 24 * 60 * 60 * 1000; + +const sweepE2e = async (): Promise => { + const now = Date.now(); + const scripts = await cfList(`/accounts/${ACCOUNT}/workers/scripts`); + const stale = scripts.filter((script: any) => { + if (!E2E_STACK_PATTERN.test(String(script.id))) return false; + const created = Date.parse(script.created_on ?? ""); + // Skip stacks younger than a day — they may belong to a live suite run. + return Number.isFinite(created) && now - created > E2E_MIN_AGE_MS; + }); + + const databases = stale.length > 0 ? await cfList(`/accounts/${ACCOUNT}/d1/database`) : []; + const apps = stale.length > 0 ? await cfList(`/accounts/${ACCOUNT}/access/apps`) : []; + for (const script of stale) { + const name = String(script.id); + await cfOk("DELETE", `/accounts/${ACCOUNT}/workers/scripts/${name}?force=true`); + process.stderr.write(`deleted e2e worker ${name}\n`); + const database = databases.find((d: any) => d.name === `${name}-db`); + if (database) { + await cfOk("DELETE", `/accounts/${ACCOUNT}/d1/database/${database.uuid}`); + process.stderr.write(`deleted e2e D1 ${name}-db\n`); + } + const app = apps.find((a: any) => a.name === name); + if (app) { + await cfOk("DELETE", `/accounts/${ACCOUNT}/access/apps/${app.id}`); + process.stderr.write(`deleted e2e Access app ${name}\n`); + } + } + + // Seat revocation only — the user records stay (harmless), but they stop + // counting against the Zero Trust seat quota. + const users = await cfList(`/accounts/${ACCOUNT}/access/users`); + const seated = users.filter( + (user: any) => + String(user.email ?? "").endsWith(E2E_SEAT_DOMAIN) && + (user.access_seat === true || user.gateway_seat === true) && + typeof user.seat_uid === "string", + ); + if (seated.length > 0) { + await cfOk( + "PATCH", + `/accounts/${ACCOUNT}/access/seats`, + seated.map((user: any) => ({ + seat_uid: user.seat_uid, + access_seat: false, + gateway_seat: false, + })), + ); + for (const user of seated) process.stderr.write(`revoked seat for ${user.email}\n`); + } + + process.stdout.write( + `${JSON.stringify({ deletedStacks: stale.map((s: any) => s.id), revokedSeats: seated.length })}\n`, + ); +}; + // --- main -------------------------------------------------------------------- if (TOKEN.length === 0) fail("CLOUDFLARE_API_TOKEN is required"); @@ -310,4 +381,5 @@ const command = process.argv[2]; if (command === "deploy") await deploy(); else if (command === "destroy") await destroy(); else if (command === "list") await list(); -else fail("usage: preview.ts [--pr ] [--skip-build]"); +else if (command === "sweep-e2e") await sweepE2e(); +else fail("usage: preview.ts [--pr ] [--skip-build]");