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