Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

263 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(`
79 {
80 __type(name: "account") {
81 fields {
82 name
83 type {
84 name
85 ofType {
86 name
87 ofType {
88 name
89 ofType {
90 name
91 }
92 }
93 }
94 }
95 }
96 }
97 }
98 `);
99 const fields = data.__type?.fields ?? [];
100 const found = [];
101 for (const field of fields.filter((f) => /artifact/i.test(f.name))) {
102 let type = field.type;
103 while (type && !type.name) type = type.ofType;
104 while (type?.ofType) type = type.ofType;
105 const typeName = type?.name;
106 const shape = typeName ? await graphql(`{ __type(name: "${typeName}") { fields { name type { name kind ofType { name kind } } } } }`) : null;
107 found.push({ dataset: field.name, type: typeName, fields: (shape?.__type?.fields ?? []).map((f) => f.name) });
108 }
109 return found;
110}
111
112/** Every storage-looking figure Cloudflare reports for the test repositories. */
113async function storageFigures() {
114 const out = {};
115 const sets = await datasets();
116 const start = new Date(Date.now() - 2 * 24 * 3600 * 1000).toISOString();
117 const end = new Date().toISOString();
118 for (const set of sets) {
119 if (set.dataset === "artifactsEventsAdaptiveGroups") continue;
120 // A dataset other than events: ask for its sums, maxes and dimensions,
121 // filtered to this namespace where it can be.
122 for (const aggregate of ["max", "sum", "avg"]) {
123 if (!set.fields.includes(aggregate)) continue;
124 try {
125 const inner = await graphql(`{ __type(name: "${set.type}") { fields { name type { name ofType { name } } } } }`);
126 const aggregateType = inner.__type.fields.find((f) => f.name === aggregate)?.type;
127 const aggregateName = aggregateType?.name ?? aggregateType?.ofType?.name;
128 const numeric = aggregateName ? await graphql(`{ __type(name: "${aggregateName}") { fields { name } } }`) : null;
129 const names = (numeric?.__type?.fields ?? []).map((f) => f.name);
130 if (!names.length) continue;
131 const data = await graphql(
132 `query Q($account: String!, $start: Time!, $end: Time!) { viewer { accounts(filter: { accountTag: $account }) {
133 ${set.dataset}(limit: 1000, filter: { datetime_geq: $start, datetime_leq: $end }) {
134 ${aggregate} { ${names.join(" ")} } dimensions { repositoryNamespace repositoryName date }
135 } } } }`,
136 { account: ACCOUNT_ID, start, end },
137 );
138 const rows = data.viewer.accounts[0][set.dataset].filter(
139 (row) => !row.dimensions?.repositoryNamespace || row.dimensions.repositoryNamespace === NAMESPACE,
140 );
141 out[`${set.dataset}.${aggregate}`] = rows;
142 } catch (error) {
143 out[`${set.dataset}.${aggregate}`] = `not readable: ${String(error.message).slice(0, 200)}`;
144 }
145 }
146 }
147 // The events themselves: errors such as storageLimitReached, and pushes.
148 const events = await graphql(
149 `
150 query Q($account: String!, $start: Time!, $end: Time!, $ns: String!) {
151 viewer {
152 accounts(filter: { accountTag: $account }) {
153 artifactsEventsAdaptiveGroups(limit: 1000, filter: { datetime_geq: $start, datetime_leq: $end, repositoryNamespace: $ns }) {
154 count
155 sum {
156 durationMs
157 }
158 dimensions {
159 repositoryName
160 eventKind
161 eventType
162 }
163 }
164 }
165 }
166 }
167 `,
168 { account: ACCOUNT_ID, start, end, ns: NAMESPACE },
169 );
170 out.events = events.viewer.accounts[0].artifactsEventsAdaptiveGroups;
171 return out;
172}
173
174async function repoInfo(name) {
175 const got = await api("GET", `/artifacts/namespaces/${NAMESPACE}/repos/${name}`);
176 return got.ok ? got.json.result : null;
177}
178
179async function setup() {
180 const made = await api("POST", "/artifacts/namespaces", { namespace: NAMESPACE });
181 if (!made.ok && made.status !== 409) console.log(`namespace: ${made.status} ${JSON.stringify(made.json.errors ?? "")}`);
182 const created = await api("POST", `/artifacts/namespaces/${NAMESPACE}/repos`, { name: SOURCE, description: "g1t fork storage test; safe to delete" });
183 if (!created.ok) throw new Error(`create: ${created.status} ${JSON.stringify(created.json.errors ?? created.json)}`);
184 const { remote, token: repoToken } = created.json.result;
185 console.log(`made ${NAMESPACE}/${SOURCE}`);
186 // ~MB of random bytes, in files under the 32 MB limit, so nothing compresses.
187 const dir = mkdtempSync(join(tmpdir(), "g1t-fork-test-"));
188 try {
189 const git = (...a) => execFileSync("git", a, { cwd: dir, stdio: ["ignore", "pipe", "pipe"] }).toString();
190 git("init", "-q", "-b", "main");
191 git("config", "user.email", "fork-test@g1t.invalid");
192 git("config", "user.name", "g1t fork test");
193 const files = Math.ceil(MB / 25);
194 for (let n = 0; n < files; n++) writeFileSync(join(dir, `random-${n}.bin`), randomBytes(Math.min(25, MB - n * 25) * 1024 * 1024));
195 git("add", ".");
196 git("commit", "-q", "-m", "incompressible data");
197 const started = Date.now();
198 execFileSync("git", ["-c", `http.extraHeader=Authorization: Bearer ${repoToken}`, "push", "-q", remote, "main"], { cwd: dir, stdio: "inherit" });
199 console.log(`pushed ${MB} MB in ${((Date.now() - started) / 1000).toFixed(1)} s`);
200 } finally {
201 rmSync(dir, { recursive: true, force: true });
202 }
203 const forks = [];
204 for (let n = 1; n <= FORKS; n++) {
205 const started = Date.now();
206 const forked = await api("POST", `/artifacts/namespaces/${NAMESPACE}/repos/${SOURCE}/fork`, { name: forkName(n), default_branch_only: true });
207 const ms = Date.now() - started;
208 forks.push({ name: forkName(n), status: forked.status, ms, result: forked.json.result ?? forked.json.errors });
209 console.log(`fork ${n}: ${forked.status} in ${ms} ms ${JSON.stringify(forked.json.result ?? forked.json.errors ?? {}).slice(0, 300)}`);
210 }
211 return forks;
212}
213
214async function cleanup() {
215 for (const name of [SOURCE, ...Array.from({ length: FORKS }, (_, n) => forkName(n + 1))]) {
216 const deleted = await api("DELETE", `/artifacts/namespaces/${NAMESPACE}/repos/${name}`);
217 console.log(`delete ${name}: ${deleted.status}`);
218 }
219 console.log(`The namespace ${NAMESPACE} is left, empty (the API has no call to delete one).`);
220}
221
222async function measure(label) {
223 const figures = await storageFigures();
224 const info = {};
225 for (const name of [SOURCE, ...Array.from({ length: FORKS }, (_, n) => forkName(n + 1))]) info[name] = await repoInfo(name);
226 console.log(`\n== ${label} (${new Date().toISOString()}) ==`);
227 console.log(JSON.stringify({ figures, repos: info }, null, 2));
228 return figures;
229}
230
231async function main() {
232 if (command === "schema") {
233 console.log(JSON.stringify(await datasets(), null, 2));
234 return;
235 }
236 if (command === "cleanup") return cleanup();
237 if (command === "measure") {
238 await measure("now");
239 return;
240 }
241 if (command !== "run") throw new Error(`unknown command ${command}`);
242 console.log("Artifacts analytics datasets:", JSON.stringify((await datasets()).map((d) => d.dataset)));
243 await measure("before");
244 let forks;
245 try {
246 forks = await setup();
247 await measure("right after");
248 const until = Date.now() + MINUTES * 60 * 1000;
249 while (Date.now() < until) {
250 await new Promise((resolve) => setTimeout(resolve, 5 * 60 * 1000));
251 await measure(`after, ${Math.round((MINUTES * 60 * 1000 - (until - Date.now())) / 60000)} min`);
252 }
253 } finally {
254 if (KEEP) console.log(`\nKept. Run \`measure\` tomorrow, then \`cleanup\`.`);
255 else await cleanup();
256 }
257 console.log("\nForks:", JSON.stringify(forks, null, 2));
258}
259
260main().catch((error) => {
261 console.error(error.message);
262 process.exit(1);
263});