g1t/scripts/ops/artifacts-namespaces.mjs

261 lines14,101 bytesCodeBlame
1#!/usr/bin/env node
2// How each git store namespace stands (docs/ARTIFACTS.md, R7): what it
3// holds, how busy its busiest minute was against Cloudflare's limit of
4// 2,000 control-plane requests per 10 seconds, how it has been failing,
5// whether it takes new repositories, and its limits. Also queues and lists
6// moves of repositories between namespaces (services/repos/src/moves.rs).
7//
8// node scripts/ops/artifacts-namespaces.mjs # the report, as a table
9// node scripts/ops/artifacts-namespaces.mjs --json # the same, as JSON
10// node scripts/ops/artifacts-namespaces.mjs --cloudflare # with Cloudflare's own event counts per namespace
11// node scripts/ops/artifacts-namespaces.mjs moves # moves asked for, newest first
12// node scripts/ops/artifacts-namespaces.mjs move acme/rocket g1t-us-1 # queue one; the hourly sweep runs it
13//
14// The report and `moves` are read-only: SELECTs against the g1t-repos
15// database through Wrangler (as you are logged in, or CLOUDFLARE_D1_TOKEN),
16// and with --cloudflare one GraphQL query (CLOUDFLARE_API_TOKEN with Account
17// Analytics: Read). `move` inserts one row into repo_moves, nothing else.
18// What is configured is read from services/repos/wrangler.jsonc, so the
19// report says what the next deploy will do.
20
21import { readFileSync } from "node:fs";
22import { join } from "node:path";
23
24import { ACCOUNT_ID, cloudflareAuth, exec, jsonFrom, wranglerEnv } from "../deploy/cloudflare.mjs";
25import { ROOT, parseJsonc } from "../deploy/stack.mjs";
26
27const WRANGLER = join(ROOT, "node_modules/wrangler/bin/wrangler.js");
28const DATABASE = "g1t-repos";
29/** Cloudflare's control-plane limit for one namespace, per minute (shards.rs). */
30export const LIMIT_PER_MINUTE = 12_000;
31/** Past this share of it, the repos service stops placing new repositories there. */
32export const HOT_SHARE = 0.7;
33
34/** What services/repos/wrangler.jsonc configures: each namespace, its binding and jurisdiction, and the placement variables. */
35export function configured(wrangler) {
36 const vars = wrangler.vars ?? {};
37 let named = {};
38 try {
39 named = JSON.parse(vars.ARTIFACTS_NAMESPACES ?? "{}");
40 } catch {}
41 const bindings = new Map((wrangler.artifacts ?? []).map((one) => [one.binding, one]));
42 if (!named.ARTIFACTS) named.ARTIFACTS = "g1t";
43 let limits = {};
44 try {
45 limits = JSON.parse(vars.ARTIFACTS_NAMESPACE_LIMITS ?? "{}");
46 } catch {}
47 const newRepos = String(vars.ARTIFACTS_NEW_REPOS ?? "")
48 .split(",")
49 .map((name) => name.trim())
50 .filter(Boolean);
51 const eu = vars.ARTIFACTS_EU_NAMESPACE?.trim() || null;
52 return Object.entries(named).map(([binding, namespace]) => ({
53 namespace,
54 binding,
55 bound: bindings.has(binding) && bindings.get(binding).namespace === namespace,
56 // Set when the namespace is made, not in the binding: known with --cloudflare.
57 jurisdiction: null,
58 default: binding === "ARTIFACTS",
59 eu: eu === namespace,
60 takes_new_repos: newRepos.includes(namespace),
61 max_repos: limits[namespace]?.max_repos ?? null,
62 }));
63}
64
65/** A store key's namespace, as the registry keeps it: none means the default. */
66export function namespaceOf(store, defaultNamespace = "g1t") {
67 const at = (store ?? "").indexOf("/");
68 return at > 0 ? store.slice(0, at) : defaultNamespace;
69}
70
71/**
72 * Every namespace's standing, from what is configured, what the registry
73 * holds (`held`: ns, repos, forks, stored_bytes), how it answered
74 * (`health`: store, peak, calls, errors, rate_limited, rejected, the last
75 * hour and the last day), Cloudflare's own counts (`events`) and the
76 * namespaces Cloudflare has (`made`: namespace, jurisdiction), when asked.
77 */
78export function standings(config, held, health, events = [], made = null) {
79 const defaultNamespace = config.find((one) => one.default)?.namespace ?? "g1t";
80 const names = [...new Set([...config.map((one) => one.namespace), ...held.map((row) => row.ns || defaultNamespace)])];
81 return names.map((namespace) => {
82 const known = made?.find((one) => one.namespace === namespace);
83 const set = { ...(config.find((one) => one.namespace === namespace) ?? { namespace, binding: null, bound: false }) };
84 if (known) set.jurisdiction = known.jurisdiction ?? "any";
85 const holds = held.filter((row) => (row.ns || defaultNamespace) === namespace);
86 const sum = (rows, field) => rows.reduce((total, row) => total + Number(row[field] ?? 0), 0);
87 const hour = health.find((row) => row.store === namespace && row.window === "hour") ?? {};
88 const day = health.find((row) => row.store === namespace && row.window === "day") ?? {};
89 const fallback = health.find((row) => row.store === `${namespace}@fallback` && row.window === "hour");
90 const peak = Number(day.peak ?? 0);
91 const repos = sum(holds, "repos");
92 const warnings = [];
93 if (!set.bound && repos > 0) warnings.push("holds repositories but is not bound");
94 if (set.takes_new_repos && !set.bound) warnings.push("named in ARTIFACTS_NEW_REPOS but not bound: passed over");
95 if (peak >= LIMIT_PER_MINUTE * HOT_SHARE) warnings.push(`busiest minute at ${Math.round((peak / LIMIT_PER_MINUTE) * 100)}% of the limit`);
96 if (set.max_repos && repos >= set.max_repos) warnings.push("at its max_repos: takes no new repositories while another can");
97 if (Number(hour.rate_limited ?? 0) > 0) warnings.push(`${hour.rate_limited} calls rate limited in the last hour`);
98 if (made && set.binding && !known) warnings.push("named in ARTIFACTS_NAMESPACES, but Cloudflare has no namespace of this name: make it before deploying");
99 if (set.eu && known && known.jurisdiction !== "eu") warnings.push(`named as the EU namespace, but Cloudflare says its jurisdiction is ${known.jurisdiction ?? "unrestricted"}`);
100 if (fallback) warnings.push(`served from the fallback store lately (${fallback.calls} calls in the last hour)`);
101 return {
102 ...set,
103 repos,
104 forks: sum(holds, "forks"),
105 stored_bytes: sum(holds, "stored_bytes"),
106 peak_per_minute_day: peak,
107 peak_per_minute_hour: Number(hour.peak ?? 0),
108 peak_share: peak / LIMIT_PER_MINUTE,
109 calls_day: Number(day.calls ?? 0),
110 errors_day: Number(day.errors ?? 0),
111 rate_limited_day: Number(day.rate_limited ?? 0),
112 rejected_day: Number(day.rejected ?? 0),
113 cloudflare_events: events.filter((event) => event.namespace === namespace).reduce((total, event) => total + event.count, 0),
114 warnings,
115 };
116 });
117}
118
119const gb = (bytes) => `${(bytes / 1e9).toFixed(2)} GB`;
120const pct = (share) => `${(share * 100).toFixed(1)}%`;
121
122export function table(rows) {
123 const header = ["namespace", "binding", "where", "new", "repos", "forks", "stored", "peak/min (24h)", "of limit", "calls 24h", "errors", "429s"];
124 const lines = rows.map((row) => [
125 row.namespace + (row.default ? " *" : ""),
126 row.bound ? row.binding : `${row.binding ?? "-"} (not bound)`,
127 row.jurisdiction ?? "?",
128 row.takes_new_repos ? "yes" : row.eu ? "eu" : "no",
129 String(row.repos),
130 String(row.forks),
131 gb(row.stored_bytes),
132 String(row.peak_per_minute_day),
133 pct(row.peak_share),
134 String(row.calls_day),
135 String(row.errors_day),
136 String(row.rate_limited_day),
137 ]);
138 const widths = header.map((title, at) => Math.max(title.length, ...lines.map((line) => line[at].length)));
139 const format = (line) => line.map((cell, at) => cell.padEnd(widths[at])).join(" ");
140 const out = [format(header), format(widths.map((width) => "-".repeat(width))), ...lines.map(format)];
141 for (const row of rows) for (const warning of row.warnings) out.push(`! ${row.namespace}: ${warning}`);
142 out.push("* the default namespace: keys without a namespace are in it. Limit: 12,000 control-plane requests a minute per namespace.");
143 return out.join("\n");
144}
145
146// ---------------------------------------------------------------------
147
148async function d1(sql) {
149 const env = { ...wranglerEnv({ ...process.env, CI: "true" }) };
150 if (process.env.CLOUDFLARE_D1_TOKEN) env.CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_D1_TOKEN;
151 const { code, out } = await exec(process.execPath, [WRANGLER, "d1", "execute", DATABASE, "--remote", "--json", "--command", sql], {
152 cwd: join(ROOT, "services/repos"),
153 env,
154 });
155 if (code !== 0) throw new Error(out.slice(-600));
156 return jsonFrom(out)[0]?.results ?? [];
157}
158
159const quoted = (text) => `'${String(text).replaceAll("'", "''")}'`;
160const minuteAgo = (minutes) => new Date(Date.now() - minutes * 60_000).toISOString().slice(0, 16);
161
162async function readHeld() {
163 return d1(`SELECT CASE WHEN instr(coalesce(store, ''), '/') > 0 THEN substr(store, 1, instr(store, '/') - 1) ELSE '' END AS ns,
164 count(*) AS repos, sum(CASE WHEN fork_of IS NULL THEN 0 ELSE 1 END) AS forks, sum(coalesce(stored_bytes, 0)) AS stored_bytes
165 FROM repos WHERE deleted_at IS NULL AND retired_at IS NULL GROUP BY ns`);
166}
167
168async function readHealth() {
169 const window = (name, minutes) =>
170 `SELECT '${name}' AS window, store, max(calls) AS peak, sum(calls) AS calls, sum(errors) AS errors,
171 sum(rate_limited) AS rate_limited, sum(rejected) AS rejected
172 FROM store_health WHERE minute >= '${minuteAgo(minutes)}' GROUP BY store`;
173 return d1(`${window("hour", 60)} UNION ALL ${window("day", 24 * 60)}`);
174}
175
176async function readEvents() {
177 const auth = cloudflareAuth();
178 if (!auth) throw new Error("--cloudflare needs CLOUDFLARE_API_TOKEN with Account Analytics: Read");
179 const end = new Date();
180 const start = new Date(end.getTime() - 24 * 3600 * 1000);
181 const query = `query Q($accountTag: String!, $start: Time!, $end: Time!) { viewer { accounts(filter: { accountTag: $accountTag }) {
182 artifactsEventsAdaptiveGroups(limit: 10000, filter: { datetime_geq: $start, datetime_leq: $end }) { count dimensions { repositoryNamespace } } } } }`;
183 const response = await fetch("https://api.cloudflare.com/client/v4/graphql", {
184 method: "POST",
185 headers: { ...auth, "content-type": "application/json" },
186 body: JSON.stringify({ query, variables: { accountTag: ACCOUNT_ID, start: start.toISOString(), end: end.toISOString() } }),
187 });
188 const body = await response.json();
189 if (body.errors?.length) throw new Error(`GraphQL: ${JSON.stringify(body.errors).slice(0, 400)}`);
190 return (body.data?.viewer?.accounts?.[0]?.artifactsEventsAdaptiveGroups ?? []).map((group) => ({
191 namespace: group.dimensions.repositoryNamespace,
192 count: group.count,
193 }));
194}
195
196/** The namespaces Cloudflare has, with their jurisdictions (CLOUDFLARE_API_TOKEN with Artifacts: Read). */
197async function readNamespaces() {
198 const auth = cloudflareAuth();
199 if (!auth) throw new Error("--cloudflare needs CLOUDFLARE_API_TOKEN");
200 const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/artifacts/namespaces`, { headers: auth });
201 const body = await response.json().catch(() => ({}));
202 if (!response.ok || body.success === false) throw new Error(`listing namespaces: ${response.status} ${JSON.stringify(body.errors ?? body).slice(0, 300)}`);
203 const list = Array.isArray(body.result) ? body.result : (body.result?.namespaces ?? []);
204 return list.map((one) => ({ namespace: one.namespace ?? one.name, jurisdiction: one.jurisdiction ?? null }));
205}
206
207function wranglerConfig() {
208 return parseJsonc(readFileSync(join(ROOT, "services/repos/wrangler.jsonc"), "utf8"));
209}
210
211async function main() {
212 const args = process.argv.slice(2);
213 const command = args[0] && !args[0].startsWith("--") ? args[0] : "report";
214
215 if (command === "moves") {
216 const rows = await d1(`SELECT m.*, r.namespace AS workspace, r.name FROM repo_moves m LEFT JOIN repos r ON r.id = m.repo_id
217 ORDER BY m.queued_ms DESC LIMIT 50`);
218 if (args.includes("--json")) console.log(JSON.stringify(rows, null, 2));
219 else for (const row of rows) console.log(`${row.id} ${row.status.padEnd(8)} ${row.workspace}/${row.name} -> ${row.to_namespace}${row.note ? ` (${row.note})` : ""}`);
220 return 0;
221 }
222
223 if (command === "move") {
224 const [path, namespace] = args.slice(1);
225 const [workspace, name] = String(path ?? "").toLowerCase().split("/");
226 if (!workspace || !name || !namespace) throw new Error("usage: move <workspace/name> <namespace>");
227 const config = configured(wranglerConfig());
228 if (!config.some((one) => one.namespace === namespace && one.bound)) throw new Error(`${namespace} is not a bound namespace in services/repos/wrangler.jsonc`);
229 const [repo] = await d1(`SELECT id, store FROM repos WHERE namespace = ${quoted(workspace)} AND name = ${quoted(name)} AND deleted_at IS NULL AND fork_of IS NULL`);
230 if (!repo) throw new Error(`no repository ${workspace}/${name}`);
231 if (namespaceOf(repo.store, config.find((one) => one.default)?.namespace) === namespace) throw new Error(`${workspace}/${name} is in ${namespace} already`);
232 const id = `mov_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
233 await d1(`INSERT INTO repo_moves (id, repo_id, to_namespace, status, requested_by, queued_ms)
234 VALUES (${quoted(id)}, ${quoted(repo.id)}, ${quoted(namespace)}, 'queued', 'scripts/ops/artifacts-namespaces.mjs', ${Date.now()})`);
235 console.log(`queued ${id}: ${workspace}/${name} (${repo.store}) -> ${namespace}. The hourly sweep (:23) moves it; watch with \`moves\`.`);
236 return 0;
237 }
238
239 const config = configured(wranglerConfig());
240 const cloudflare = args.includes("--cloudflare");
241 const [held, health, events, made] = await Promise.all([
242 readHeld(),
243 readHealth(),
244 cloudflare ? readEvents() : [],
245 cloudflare ? readNamespaces() : null,
246 ]);
247 const rows = standings(config, held, health, events, made);
248 if (args.includes("--json")) console.log(JSON.stringify(rows, null, 2));
249 else console.log(table(rows));
250 return rows.some((row) => row.warnings.length) ? 1 : 0;
251}
252
253if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/ops/artifacts-namespaces.mjs")) {
254 main().then(
255 (code) => process.exit(code),
256 (error) => {
257 console.error(`namespaces: ${error.message}`);
258 process.exit(2);
259 },
260 );
261}