Skip to content

Commit a5990b1

Browse files
committed
Sweep e2e leftovers from the preview account nightly
The e2e harness shares the preview Cloudflare account and leaks two resource classes: suite runs that crash before their teardown leave an executor-e2e-<hex> worker + D1 + Access app trio behind, 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). Both recently piled up enough to hit account limits (7 orphaned stacks at the 10-database D1 cap, 29 of 50 seats). preview.ts gains a sweep-e2e subcommand that deletes e2e stacks older than a day (live suite runs are younger; the persistent OTP/emulator/ state-store workers don't match the stack pattern) and revokes seats held by the synthetic e2e domain, and the nightly sweep workflow runs it after the PR-preview pass. Requires the deploy token to carry Access: Users Write (added to the account token).
1 parent 3530d42 commit a5990b1

2 files changed

Lines changed: 82 additions & 2 deletions

File tree

.github/workflows/preview-sweep.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ name: Preview sweep
33
# Safety net for the per-PR previews (.github/workflows/preview.yml): force
44
# pushes, cancelled runs, or teardown failures can orphan a preview stack.
55
# Nightly, list every deployed preview and destroy any whose PR is no longer
6-
# open.
6+
# open. A second pass (sweep-e2e) cleans up after the e2e harness on the same
7+
# account: crashed suite runs leak executor-e2e-* stacks, and synthetic OTP
8+
# logins permanently occupy Zero Trust seats.
79

810
on:
911
schedule:
@@ -41,3 +43,9 @@ jobs:
4143
echo "PR #$pr is open — keeping its preview"
4244
fi
4345
done
46+
47+
- name: Sweep e2e leftovers
48+
env:
49+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN }}
50+
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_PREVIEW_ACCOUNT_ID }}
51+
run: bun apps/host-cloudflare/scripts/preview.ts sweep-e2e

apps/host-cloudflare/scripts/preview.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@
1616
// bun scripts/preview.ts deploy --pr 123 [--skip-build]
1717
// bun scripts/preview.ts destroy --pr 123
1818
// bun scripts/preview.ts list # JSON [{pr, name}] of live previews
19+
// bun scripts/preview.ts sweep-e2e # clean e2e leftovers (see below)
20+
//
21+
// sweep-e2e cleans up after the e2e harness, which shares this account:
22+
// suite runs that crash before their teardown leak an `executor-e2e-<hex>`
23+
// worker + D1 + Access app trio, and every real Access OTP login by a
24+
// synthetic ci-…@executor-dev.xyz identity permanently occupies a Zero Trust
25+
// seat (50 on the free plan). Stacks older than a day are deleted; seats held
26+
// by the synthetic domain are revoked. Persistent e2e infrastructure
27+
// (executor-e2e-otp, executor-e2e-emulators, alchemy-state-store) is never
28+
// touched.
1929
//
2030
// Env: CLOUDFLARE_API_TOKEN (Workers/D1/R2/Access edit) + CLOUDFLARE_ACCOUNT_ID
2131
// always; deploy additionally needs PREVIEW_ACCESS_TEAM_DOMAIN (Zero Trust team)
@@ -301,6 +311,67 @@ const list = async (): Promise<void> => {
301311
process.stdout.write(`${JSON.stringify(previews)}\n`);
302312
};
303313

314+
// E2e stacks are worker `executor-e2e-<8 hex>` + D1 `…-db` + Access app of the
315+
// same name; the fixed-name workers are this account's persistent e2e infra.
316+
const E2E_STACK_PATTERN = /^executor-e2e-[0-9a-f]{8}$/;
317+
const E2E_SEAT_DOMAIN = "@executor-dev.xyz";
318+
const E2E_MIN_AGE_MS = 24 * 60 * 60 * 1000;
319+
320+
const sweepE2e = async (): Promise<void> => {
321+
const now = Date.now();
322+
const scripts = await cfList(`/accounts/${ACCOUNT}/workers/scripts`);
323+
const stale = scripts.filter((script: any) => {
324+
if (!E2E_STACK_PATTERN.test(String(script.id))) return false;
325+
const created = Date.parse(script.created_on ?? "");
326+
// Skip stacks younger than a day — they may belong to a live suite run.
327+
return Number.isFinite(created) && now - created > E2E_MIN_AGE_MS;
328+
});
329+
330+
const databases = stale.length > 0 ? await cfList(`/accounts/${ACCOUNT}/d1/database`) : [];
331+
const apps = stale.length > 0 ? await cfList(`/accounts/${ACCOUNT}/access/apps`) : [];
332+
for (const script of stale) {
333+
const name = String(script.id);
334+
await cfOk("DELETE", `/accounts/${ACCOUNT}/workers/scripts/${name}?force=true`);
335+
process.stderr.write(`deleted e2e worker ${name}\n`);
336+
const database = databases.find((d: any) => d.name === `${name}-db`);
337+
if (database) {
338+
await cfOk("DELETE", `/accounts/${ACCOUNT}/d1/database/${database.uuid}`);
339+
process.stderr.write(`deleted e2e D1 ${name}-db\n`);
340+
}
341+
const app = apps.find((a: any) => a.name === name);
342+
if (app) {
343+
await cfOk("DELETE", `/accounts/${ACCOUNT}/access/apps/${app.id}`);
344+
process.stderr.write(`deleted e2e Access app ${name}\n`);
345+
}
346+
}
347+
348+
// Seat revocation only — the user records stay (harmless), but they stop
349+
// counting against the Zero Trust seat quota.
350+
const users = await cfList(`/accounts/${ACCOUNT}/access/users`);
351+
const seated = users.filter(
352+
(user: any) =>
353+
String(user.email ?? "").endsWith(E2E_SEAT_DOMAIN) &&
354+
(user.access_seat === true || user.gateway_seat === true) &&
355+
typeof user.seat_uid === "string",
356+
);
357+
if (seated.length > 0) {
358+
await cfOk(
359+
"PATCH",
360+
`/accounts/${ACCOUNT}/access/seats`,
361+
seated.map((user: any) => ({
362+
seat_uid: user.seat_uid,
363+
access_seat: false,
364+
gateway_seat: false,
365+
})),
366+
);
367+
for (const user of seated) process.stderr.write(`revoked seat for ${user.email}\n`);
368+
}
369+
370+
process.stdout.write(
371+
`${JSON.stringify({ deletedStacks: stale.map((s: any) => s.id), revokedSeats: seated.length })}\n`,
372+
);
373+
};
374+
304375
// --- main --------------------------------------------------------------------
305376

306377
if (TOKEN.length === 0) fail("CLOUDFLARE_API_TOKEN is required");
@@ -310,4 +381,5 @@ const command = process.argv[2];
310381
if (command === "deploy") await deploy();
311382
else if (command === "destroy") await destroy();
312383
else if (command === "list") await list();
313-
else fail("usage: preview.ts <deploy|destroy|list> [--pr <number>] [--skip-build]");
384+
else if (command === "sweep-e2e") await sweepE2e();
385+
else fail("usage: preview.ts <deploy|destroy|list|sweep-e2e> [--pr <number>] [--skip-build]");

0 commit comments

Comments
 (0)