Skip to content
226 linesCodeBlameRaw
1#!/usr/bin/env node
2// Do Artifacts forks copy their source's objects, or share them?
3//
4// Cloudflare does not document it, and it decides
5// how much every pull request costs in storage. This makes a throwaway
6// repository holding ~100 MB that cannot be compressed, forks it five
7// times, and reads whatever storage figures Cloudflare reports before and
8// after, then deletes everything it made. Nothing of g1t's is touched: it
9// works in its own namespace (default `g1t-storage-test`), straight
10// against Cloudflare's API, never through g1t.sh.
11//
12// export CLOUDFLARE_API_TOKEN=<token: Artifacts edit, Account Analytics read>
13// (or a global API key: CLOUDFLARE_API_KEY with CLOUDFLARE_EMAIL)
14// node scripts/ops/fork-storage-test.mjs run # make, fork 5x, measure for 20 min, delete
15// node scripts/ops/fork-storage-test.mjs run --keep # ... and keep it, to measure again tomorrow
16// node scripts/ops/fork-storage-test.mjs measure # read the figures again (e.g. the next day)
17// node scripts/ops/fork-storage-test.mjs cleanup # delete the test repositories
18// node scripts/ops/fork-storage-test.mjs schema # list the analytics datasets Cloudflare offers for Artifacts
19//
20// Options: --namespace <name> (default g1t-storage-test), --mb <size> (100),
21// --forks <n> (5), --minutes <n> to keep measuring (20).
22//
23// Needs git on PATH. Costs a few cents of Artifacts storage and operations.
24
25import { execFileSync } from "node:child_process";
26import { randomBytes } from "node:crypto";
27import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
28import { tmpdir } from "node:os";
29import { join } from "node:path";
30
31import { cloudflareAuth } from "../deploy/cloudflare.mjs";
32
33const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID || "1e6f2cffa3f445920836e8ebe446bb58";
34const API = `https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}`;
35const args = process.argv.slice(2);
36const command = args[0] ?? "run";
37const option = (name, fallback) => {
38 const at = args.indexOf(name);
39 return at >= 0 && args[at + 1] ? args[at + 1] : fallback;
40};
41const NAMESPACE = option("--namespace", "g1t-storage-test");
42const MB = Number(option("--mb", "100"));
43const FORKS = Number(option("--forks", "5"));
44const MINUTES = Number(option("--minutes", "20"));
45const KEEP = args.includes("--keep");
46const SOURCE = "fork-test-source";
47const forkName = (n) => `fork-test-copy-${n}`;
48
49const auth = cloudflareAuth();
50if (!auth) {
51 console.error("Set CLOUDFLARE_API_TOKEN (Artifacts edit, Account Analytics read), or CLOUDFLARE_API_KEY and CLOUDFLARE_EMAIL.");
52 process.exit(2);
53}
54
55async function api(method, path, body) {
56 const response = await fetch(`${API}${path}`, {
57 method,
58 headers: { ...auth, "content-type": "application/json" },
59 body: body ? JSON.stringify(body) : undefined,
60 });
61 const json = await response.json().catch(() => ({}));
62 return { status: response.status, ok: response.ok && json.success !== false, json };
63}
64
65async function graphql(query, variables = {}) {
66 const response = await fetch("https://api.cloudflare.com/client/v4/graphql", {
67 method: "POST",
68 headers: { ...auth, "content-type": "application/json" },
69 body: JSON.stringify({ query, variables }),
70 });
71 const json = await response.json();
72 if (json.errors?.length) throw new Error(JSON.stringify(json.errors).slice(0, 500));
73 return json.data;
74}
75
76/** The account's analytics datasets about Artifacts, and their numeric fields. */
77async function datasets() {
78 const data = await graphql(`{ __type(name: "account") { fields { name type { name ofType { name ofType { name ofType { name } } } } } } }`);
79 const fields = data.__type?.fields ?? [];
80 const found = [];
81 for (const field of fields.filter((f) => /artifact/i.test(f.name))) {
82 let type = field.type;
83 while (type && !type.name) type = type.ofType;
84 while (type?.ofType) type = type.ofType;
85 const typeName = type?.name;
86 const shape = typeName ? await graphql(`{ __type(name: "${typeName}") { fields { name type { name kind ofType { name kind } } } } }`) : null;
87 found.push({ dataset: field.name, type: typeName, fields: (shape?.__type?.fields ?? []).map((f) => f.name) });
88 }
89 return found;
90}
91
92/** Every storage-looking figure Cloudflare reports for the test repositories. */
93async function storageFigures() {
94 const out = {};
95 const sets = await datasets();
96 const start = new Date(Date.now() - 2 * 24 * 3600 * 1000).toISOString();
97 const end = new Date().toISOString();
98 for (const set of sets) {
99 if (set.dataset === "artifactsEventsAdaptiveGroups") continue;
100 // A dataset other than events: ask for its sums, maxes and dimensions,
101 // filtered to this namespace where it can be.
102 for (const aggregate of ["max", "sum", "avg"]) {
103 if (!set.fields.includes(aggregate)) continue;
104 try {
105 const inner = await graphql(`{ __type(name: "${set.type}") { fields { name type { name ofType { name } } } } }`);
106 const aggregateType = inner.__type.fields.find((f) => f.name === aggregate)?.type;
107 const aggregateName = aggregateType?.name ?? aggregateType?.ofType?.name;
108 const numeric = aggregateName ? await graphql(`{ __type(name: "${aggregateName}") { fields { name } } }`) : null;
109 const names = (numeric?.__type?.fields ?? []).map((f) => f.name);
110 if (!names.length) continue;
111 const data = await graphql(
112 `query Q($account: String!, $start: Time!, $end: Time!) { viewer { accounts(filter: { accountTag: $account }) {
113 ${set.dataset}(limit: 1000, filter: { datetime_geq: $start, datetime_leq: $end }) {
114 ${aggregate} { ${names.join(" ")} } dimensions { repositoryNamespace repositoryName date }
115 } } } }`,
116 { account: ACCOUNT_ID, start, end },
117 );
118 const rows = data.viewer.accounts[0][set.dataset].filter((row) => !row.dimensions?.repositoryNamespace || row.dimensions.repositoryNamespace === NAMESPACE);
119 out[`${set.dataset}.${aggregate}`] = rows;
120 } catch (error) {
121 out[`${set.dataset}.${aggregate}`] = `not readable: ${String(error.message).slice(0, 200)}`;
122 }
123 }
124 }
125 // The events themselves: errors such as storageLimitReached, and pushes.
126 const events = await graphql(
127 `query Q($account: String!, $start: Time!, $end: Time!, $ns: String!) { viewer { accounts(filter: { accountTag: $account }) {
128 artifactsEventsAdaptiveGroups(limit: 1000, filter: { datetime_geq: $start, datetime_leq: $end, repositoryNamespace: $ns }) {
129 count sum { durationMs } dimensions { repositoryName eventKind eventType }
130 } } } }`,
131 { account: ACCOUNT_ID, start, end, ns: NAMESPACE },
132 );
133 out.events = events.viewer.accounts[0].artifactsEventsAdaptiveGroups;
134 return out;
135}
136
137async function repoInfo(name) {
138 const got = await api("GET", `/artifacts/namespaces/${NAMESPACE}/repos/${name}`);
139 return got.ok ? got.json.result : null;
140}
141
142async function setup() {
143 const made = await api("POST", "/artifacts/namespaces", { namespace: NAMESPACE });
144 if (!made.ok && made.status !== 409) console.log(`namespace: ${made.status} ${JSON.stringify(made.json.errors ?? "")}`);
145 const created = await api("POST", `/artifacts/namespaces/${NAMESPACE}/repos`, { name: SOURCE, description: "g1t fork storage test; safe to delete" });
146 if (!created.ok) throw new Error(`create: ${created.status} ${JSON.stringify(created.json.errors ?? created.json)}`);
147 const { remote, token: repoToken } = created.json.result;
148 console.log(`made ${NAMESPACE}/${SOURCE}`);
149 // ~MB of random bytes, in files under the 32 MB limit, so nothing compresses.
150 const dir = mkdtempSync(join(tmpdir(), "g1t-fork-test-"));
151 try {
152 const git = (...a) => execFileSync("git", a, { cwd: dir, stdio: ["ignore", "pipe", "pipe"] }).toString();
153 git("init", "-q", "-b", "main");
154 git("config", "user.email", "fork-test@g1t.invalid");
155 git("config", "user.name", "g1t fork test");
156 const files = Math.ceil(MB / 25);
157 for (let n = 0; n < files; n++) writeFileSync(join(dir, `random-${n}.bin`), randomBytes(Math.min(25, MB - n * 25) * 1024 * 1024));
158 git("add", ".");
159 git("commit", "-q", "-m", "incompressible data");
160 const started = Date.now();
161 execFileSync("git", ["-c", `http.extraHeader=Authorization: Bearer ${repoToken}`, "push", "-q", remote, "main"], { cwd: dir, stdio: "inherit" });
162 console.log(`pushed ${MB} MB in ${((Date.now() - started) / 1000).toFixed(1)} s`);
163 } finally {
164 rmSync(dir, { recursive: true, force: true });
165 }
166 const forks = [];
167 for (let n = 1; n <= FORKS; n++) {
168 const started = Date.now();
169 const forked = await api("POST", `/artifacts/namespaces/${NAMESPACE}/repos/${SOURCE}/fork`, { name: forkName(n), default_branch_only: true });
170 const ms = Date.now() - started;
171 forks.push({ name: forkName(n), status: forked.status, ms, result: forked.json.result ?? forked.json.errors });
172 console.log(`fork ${n}: ${forked.status} in ${ms} ms ${JSON.stringify(forked.json.result ?? forked.json.errors ?? {}).slice(0, 300)}`);
173 }
174 return forks;
175}
176
177async function cleanup() {
178 for (const name of [SOURCE, ...Array.from({ length: FORKS }, (_, n) => forkName(n + 1))]) {
179 const deleted = await api("DELETE", `/artifacts/namespaces/${NAMESPACE}/repos/${name}`);
180 console.log(`delete ${name}: ${deleted.status}`);
181 }
182 console.log(`The namespace ${NAMESPACE} is left, empty (the API has no call to delete one).`);
183}
184
185async function measure(label) {
186 const figures = await storageFigures();
187 const info = {};
188 for (const name of [SOURCE, ...Array.from({ length: FORKS }, (_, n) => forkName(n + 1))]) info[name] = await repoInfo(name);
189 console.log(`\n== ${label} (${new Date().toISOString()}) ==`);
190 console.log(JSON.stringify({ figures, repos: info }, null, 2));
191 return figures;
192}
193
194async function main() {
195 if (command === "schema") {
196 console.log(JSON.stringify(await datasets(), null, 2));
197 return;
198 }
199 if (command === "cleanup") return cleanup();
200 if (command === "measure") {
201 await measure("now");
202 return;
203 }
204 if (command !== "run") throw new Error(`unknown command ${command}`);
205 console.log("Artifacts analytics datasets:", JSON.stringify((await datasets()).map((d) => d.dataset)));
206 await measure("before");
207 let forks;
208 try {
209 forks = await setup();
210 await measure("right after");
211 const until = Date.now() + MINUTES * 60 * 1000;
212 while (Date.now() < until) {
213 await new Promise((resolve) => setTimeout(resolve, 5 * 60 * 1000));
214 await measure(`after, ${Math.round((MINUTES * 60 * 1000 - (until - Date.now())) / 60000)} min`);
215 }
216 } finally {
217 if (KEEP) console.log(`\nKept. Run \`measure\` tomorrow, then \`cleanup\`.`);
218 else await cleanup();
219 }
220 console.log("\nForks:", JSON.stringify(forks, null, 2));
221}
222
223main().catch((error) => {
224 console.error(error.message);
225 process.exit(1);
226});