70bf21e064
Handle Changesets / Handle Changesets (push) Waiting to run
Semgrep OSS scan / semgrep-oss (push) Waiting to run
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
501 lines
12 KiB
TypeScript
501 lines
12 KiB
TypeScript
import assert from "node:assert";
|
|
import { spawnSync } from "node:child_process";
|
|
import { fetch } from "undici";
|
|
import type { RequestInit, Response } from "undici";
|
|
|
|
type ApiErrorBody = {
|
|
errors: string[];
|
|
};
|
|
|
|
type ApiSuccessBody<T> = {
|
|
result: T[];
|
|
};
|
|
|
|
export type Project = {
|
|
name: string;
|
|
created_on: string;
|
|
};
|
|
|
|
export type R2Bucket = {
|
|
name: string;
|
|
creation_date: string;
|
|
};
|
|
|
|
export type ContainerApplication = {
|
|
created_at: string;
|
|
id: string;
|
|
name: string;
|
|
};
|
|
|
|
export type Worker = {
|
|
script_name: string;
|
|
created_on: string;
|
|
};
|
|
|
|
export interface KVNamespaceInfo {
|
|
id: string;
|
|
title: string;
|
|
}
|
|
|
|
export type Database = {
|
|
created_at: string;
|
|
uuid: string;
|
|
name: string;
|
|
};
|
|
|
|
export type HyperdriveConfig = {
|
|
id: string;
|
|
name: string;
|
|
created_on: string;
|
|
};
|
|
|
|
export type AgentMemoryNamespace = {
|
|
id: string;
|
|
name: string;
|
|
account_id: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
export type MTlsCertificateResponse = {
|
|
id: string;
|
|
name?: string;
|
|
ca: boolean;
|
|
certificates: string;
|
|
expires_on: string;
|
|
issuer: string;
|
|
serial_number: string;
|
|
signature: string;
|
|
uploaded_on: string;
|
|
};
|
|
|
|
class ApiError extends Error {
|
|
readonly status: number;
|
|
readonly statusText: string;
|
|
readonly text: Promise<string>;
|
|
|
|
constructor(
|
|
readonly url: string,
|
|
readonly init: RequestInit,
|
|
response: Response
|
|
) {
|
|
super();
|
|
this.status = response.status;
|
|
this.statusText = response.statusText;
|
|
this.text = response.text();
|
|
}
|
|
}
|
|
|
|
async function apiFetchResponse(
|
|
path: string,
|
|
init = { method: "GET" },
|
|
queryParams = {}
|
|
): Promise<Response> {
|
|
const baseUrl = `https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT_ID}`;
|
|
let queryString = new URLSearchParams(queryParams).toString();
|
|
if (queryString) {
|
|
queryString = "?" + queryString;
|
|
}
|
|
const url = `${baseUrl}${path}${queryString}`;
|
|
|
|
const response = await fetch(url, {
|
|
...init,
|
|
headers: {
|
|
Authorization: `Bearer ${process.env.CLOUDFLARE_API_TOKEN}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok || response.status >= 400) {
|
|
throw new ApiError(url, init, response);
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
async function apiFetch<T>(
|
|
path: string,
|
|
method: string,
|
|
queryParams: Record<string, unknown> = {},
|
|
failSilently: boolean = false
|
|
): Promise<false | T> {
|
|
try {
|
|
const response = await apiFetchResponse(path, { method }, queryParams);
|
|
const json = (await response.json()) as ApiSuccessBody<T>;
|
|
return json.result as T;
|
|
} catch (e) {
|
|
if (!failSilently) {
|
|
if (e instanceof ApiError) {
|
|
console.error(e.url, e.init);
|
|
console.error(`(${e.status}) ${e.statusText}`);
|
|
const body = JSON.parse(await e.text) as ApiErrorBody;
|
|
console.error(body.errors);
|
|
} else {
|
|
console.error(e);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function apiFetchAllPages<T>(
|
|
path: string,
|
|
queryParams = {}
|
|
): Promise<T[]> {
|
|
const result: T[] = [];
|
|
try {
|
|
let page = 1;
|
|
while (true) {
|
|
const response = await apiFetchResponse(
|
|
path,
|
|
{ method: "GET" },
|
|
{ page, per_page: 100, ...queryParams }
|
|
);
|
|
const json = (await response.json()) as ApiSuccessBody<T>;
|
|
if (json.result.length === 0) {
|
|
// We hit an empty page so we are done.
|
|
break;
|
|
}
|
|
result.push(...json.result);
|
|
page++;
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof ApiError) {
|
|
console.error(e.url, e.init);
|
|
console.error(`(${e.status}) ${e.statusText}`);
|
|
const body = JSON.parse(await e.text) as ApiErrorBody;
|
|
console.error(body.errors);
|
|
} else {
|
|
console.error(e);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function apiFetchList<T>(path: string, queryParams = {}): Promise<T[]> {
|
|
try {
|
|
let page = 1;
|
|
let totalCount = 0;
|
|
let result: unknown[] = [];
|
|
while (true) {
|
|
const response = await apiFetchResponse(
|
|
path,
|
|
{ method: "GET" },
|
|
{
|
|
page,
|
|
...queryParams,
|
|
}
|
|
);
|
|
|
|
if (!response || response.ok === false) {
|
|
return [];
|
|
}
|
|
|
|
const json = (await response.json()) as ApiSuccessBody<T>;
|
|
if ("result_info" in json && Array.isArray(json.result)) {
|
|
const result_info = json.result_info as {
|
|
page: number;
|
|
count: number;
|
|
total_count?: number;
|
|
total_pages?: number;
|
|
};
|
|
console.log(path, result_info);
|
|
|
|
result = [...result, ...json.result];
|
|
|
|
if (result_info.count === 0) {
|
|
return result as T[];
|
|
}
|
|
|
|
totalCount += result_info.count;
|
|
if (totalCount === result_info.total_count) {
|
|
return result as T[];
|
|
}
|
|
|
|
if (page === result_info.total_pages) {
|
|
return result as T[];
|
|
}
|
|
page = result_info.page + 1;
|
|
} else {
|
|
return json.result as T[];
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof ApiError) {
|
|
console.error(e.url, e.init);
|
|
console.error(`(${e.status}) ${e.statusText}`);
|
|
const body = JSON.parse(await e.text) as ApiErrorBody;
|
|
console.error(body.errors);
|
|
} else {
|
|
console.error(e);
|
|
}
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export const listTmpE2EProjects = async () => {
|
|
return (await apiFetchList<Project>(`/pages/projects`)).filter(
|
|
(p) =>
|
|
p.name.startsWith("tmp-e2e-") &&
|
|
// Projects are more than an hour old
|
|
Date.now() - new Date(p.created_on).valueOf() > 1000 * 60 * 60
|
|
);
|
|
};
|
|
|
|
export const deleteProject = async (project: string) => {
|
|
return await apiFetch(`/pages/projects/${project}`, "DELETE");
|
|
};
|
|
|
|
export const listTmpE2EWorkers = async () => {
|
|
const workers = await apiFetchAllPages<Worker>(`/workers/scripts-search`);
|
|
return workers.filter(
|
|
(p) =>
|
|
!p.script_name.startsWith("preserve-e2e-") &&
|
|
p.script_name !== "stratus-e2e-test-worker" &&
|
|
p.script_name !== "existing-script-test-do-not-delete" &&
|
|
// Workers are more than an hour old
|
|
Date.now() - new Date(p.created_on).valueOf() > 1000 * 60 * 60
|
|
);
|
|
};
|
|
|
|
export const deleteWorker = async (script_name: string) => {
|
|
return await apiFetch(`/workers/scripts/${script_name}`, "DELETE");
|
|
};
|
|
|
|
export const listTmpE2EContainerApplications = async () => {
|
|
const res = await apiFetchResponse(`/cloudchamber/applications`, {
|
|
method: "GET",
|
|
});
|
|
if (!res) {
|
|
// unreachable, but assert() is failing to pin down the type
|
|
throw res;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`${res.status}: ${await res.text()}`);
|
|
}
|
|
|
|
const apps = (await res.json()) as ContainerApplication[];
|
|
return apps.filter(
|
|
(app) =>
|
|
app.name.includes("e2e") &&
|
|
Date.now() - new Date(app.created_at).valueOf() > 1000 * 60 * 60
|
|
);
|
|
};
|
|
|
|
export const deleteContainerApplication = async (app: ContainerApplication) => {
|
|
return await apiFetchResponse(`/cloudchamber/applications/${app.id}`, {
|
|
method: "DELETE",
|
|
});
|
|
};
|
|
|
|
export const listTmpKVNamespaces = async () => {
|
|
return (await apiFetchList<KVNamespaceInfo>(`/storage/kv/namespaces`)).filter(
|
|
(kv) => {
|
|
const isTempE2E =
|
|
kv.title.includes("tmp-e2e") || kv.title.includes("tmp_e2e");
|
|
// Since KV namespaces don't have creation date metadata, we encode the date-time in the title
|
|
const creationDate = new Date(
|
|
Number(kv.title.match(/tmp[-_]e2e[-_]kv(\d+)-$/)?.[1] ?? 0)
|
|
);
|
|
// Temp KV namespaces that are more than an hour old (or any age if no date is found)
|
|
return isTempE2E && Date.now() - creationDate.valueOf() > 1000 * 60 * 60;
|
|
}
|
|
);
|
|
};
|
|
|
|
export const deleteKVNamespace = async (id: string) => {
|
|
return await apiFetch(
|
|
`/storage/kv/namespaces/${id}`,
|
|
"DELETE",
|
|
{},
|
|
/* failSilently */ true
|
|
);
|
|
};
|
|
|
|
export const listTmpR2Buckets = async () => {
|
|
const response = await apiFetchResponse(`/r2/buckets`, { method: "GET" });
|
|
if (!response || !response.ok) {
|
|
return [];
|
|
}
|
|
const json = (await response.json()) as {
|
|
result: { buckets: R2Bucket[] };
|
|
};
|
|
return json.result.buckets.filter(
|
|
(bucket) =>
|
|
bucket.name.startsWith("tmp-e2e-") &&
|
|
// Buckets are more than an hour old
|
|
Date.now() - new Date(bucket.creation_date).valueOf() > 1000 * 60 * 60
|
|
);
|
|
};
|
|
|
|
export const deleteR2Bucket = async (name: string) => {
|
|
return await apiFetch(
|
|
`/r2/buckets/${name}`,
|
|
"DELETE",
|
|
{},
|
|
/* failSilently */ true
|
|
);
|
|
};
|
|
|
|
export const listTmpDatabases = async () => {
|
|
return (await apiFetchList<Database>(`/d1/database`)).filter(
|
|
(db) =>
|
|
db.name.includes("tmp-e2e") && // Databases are more than an hour old
|
|
Date.now() - new Date(db.created_at).valueOf() > 1000 * 60 * 60
|
|
);
|
|
};
|
|
|
|
export const deleteDatabase = async (id: string) => {
|
|
return (await apiFetch(`/d1/database/${id}`, "DELETE")) !== false;
|
|
};
|
|
|
|
export const listHyperdriveConfigs = async () => {
|
|
return (await apiFetchList<HyperdriveConfig>(`/hyperdrive/configs`)).filter(
|
|
(config) =>
|
|
config.name.includes("tmp-e2e") && // Databases are more than an hour old
|
|
Date.now() - new Date(config.created_on).valueOf() > 1000 * 60 * 60
|
|
);
|
|
};
|
|
|
|
export const deleteHyperdriveConfig = async (id: string) => {
|
|
return await apiFetch(`/hyperdrive/configs/${id}`, "DELETE");
|
|
};
|
|
|
|
export const listCertificates = async () => {
|
|
return (
|
|
await apiFetchList<MTlsCertificateResponse>(`/mtls_certificates`)
|
|
).filter(
|
|
(cert) =>
|
|
cert.name?.includes("tmp-e2e") && // Certs are more than an hour old
|
|
Date.now() - new Date(cert.uploaded_on).valueOf() > 1000 * 60 * 60
|
|
);
|
|
};
|
|
|
|
export const deleteCertificate = async (id: string) => {
|
|
return await apiFetch(`/mtls_certificates/${id}`, "DELETE");
|
|
};
|
|
|
|
export const listTmpAgentMemoryNamespaces = async () => {
|
|
// The Agent Memory API uses cursor-based pagination, so we follow cursors manually
|
|
// rather than using apiFetchList (which uses page-number pagination).
|
|
const baseUrl = `https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT_ID}`;
|
|
const results: AgentMemoryNamespace[] = [];
|
|
let cursor: string | undefined;
|
|
|
|
while (true) {
|
|
const queryString = cursor
|
|
? "?" + new URLSearchParams({ cursor }).toString()
|
|
: "";
|
|
const url = `${baseUrl}/agent-memory/namespaces${queryString}`;
|
|
|
|
const response = await fetch(url, {
|
|
method: "GET",
|
|
headers: {
|
|
Authorization: `Bearer ${process.env.CLOUDFLARE_API_TOKEN}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.error(
|
|
"Failed to list Agent Memory namespaces",
|
|
response.status,
|
|
await response.text()
|
|
);
|
|
return [];
|
|
}
|
|
|
|
const json = (await response.json()) as {
|
|
result: AgentMemoryNamespace[];
|
|
result_info?: { cursor?: string };
|
|
};
|
|
|
|
results.push(...json.result);
|
|
|
|
const nextCursor = json.result_info?.cursor;
|
|
if (!nextCursor) {
|
|
break;
|
|
}
|
|
cursor = nextCursor;
|
|
}
|
|
|
|
return results.filter(
|
|
(ns) =>
|
|
ns.name.startsWith("tmp-e2e-") &&
|
|
// Namespaces are more than an hour old
|
|
Date.now() - new Date(ns.created_at).valueOf() > 1000 * 60 * 60
|
|
);
|
|
};
|
|
|
|
export const deleteAgentMemoryNamespace = async (name: string) => {
|
|
const result = await apiFetch(`/agent-memory/namespaces/${name}`, "DELETE");
|
|
// If successful the result is `null`, if the namespace is not found the result is `false`
|
|
return result !== false;
|
|
};
|
|
|
|
// Note: the container images functions below don't directly use the REST API since
|
|
// they interact with the cloudflare images registry which has it's own
|
|
// non-trivial auth mechanism, so instead of duplicating a bunch of logic
|
|
// here we instead directly call wrangler
|
|
//
|
|
// TODO: Consider if it would make sense for all the functions here to use wrangler
|
|
// directly, what is the advantage of hitting the REST API directly?
|
|
|
|
export const listE2eContainerImages = () => {
|
|
const output = runWranglerCommand("wrangler containers images list", {
|
|
// This operation can take literally minutes to run...
|
|
timeout: 3 * 60_000,
|
|
}).stdout;
|
|
|
|
return output
|
|
.split("\n")
|
|
.map((line) => {
|
|
const match = line.match(
|
|
/^(?<imageName>tmp-e2e-worker[a-z0-9-]+)\s+tmp-e2e$/
|
|
);
|
|
if (!match?.groups) {
|
|
return null;
|
|
}
|
|
|
|
return { name: match.groups.imageName, tag: "tmp-e2e" };
|
|
})
|
|
.filter(Boolean) as { name: string; tag: string }[];
|
|
};
|
|
|
|
export const deleteContainerImage = (image: { name: string; tag: string }) => {
|
|
const run = runWranglerCommand(
|
|
`wrangler containers images delete ${image.name}:${image.tag}`
|
|
);
|
|
|
|
return run.status === 0;
|
|
};
|
|
|
|
function runWranglerCommand(
|
|
wranglerCommand: string,
|
|
{ timeout = 10_000 } = {}
|
|
) {
|
|
// Enforce a `wrangler` prefix to make commands clearer to read
|
|
assert(
|
|
wranglerCommand.startsWith("wrangler "),
|
|
"Commands must start with `wrangler` (e.g. `wrangler dev`) but got " +
|
|
wranglerCommand
|
|
);
|
|
|
|
const { status, stdout, stderr, output } = spawnSync(
|
|
"pnpm",
|
|
[wranglerCommand],
|
|
{
|
|
shell: true,
|
|
stdio: "pipe",
|
|
encoding: "utf8",
|
|
timeout,
|
|
}
|
|
);
|
|
|
|
return {
|
|
status,
|
|
stdout,
|
|
stderr,
|
|
output: output.filter((line) => line !== null).join("\n"),
|
|
};
|
|
}
|