jevstrudel.git / tools / deploy / resources.mjs
1#!/usr/bin/env node
2// Run by `nix run .#deploy` after release-check.mjs and before `wrangler
3// deploy`: the Worker's storage, made to exist and brought up to date first.
4//
5//   resources.mjs <wrangler> <worker dir>
6//
7// <wrangler> is the deploy's 1Password-fed wrangler (`jevstrudel-wrangler
8// <dir> <args…>`); <worker dir> is the deploy's temporary copy of worker/,
9// whose wrangler.json this rewrites. For the production config's (top level)
10//
11// - `d1_databases` without a `database_id`: finds the database by
12//   `database_name`, creating it if the account has none, and writes its id
13//   into the copy. Then applies its migrations (`d1 migrations apply
14//   --remote`), so the Worker about to be published never runs against a
15//   schema older than its code.
16// - `kv_namespaces` without an `id`: finds the namespace by title,
17//   `<worker name>-<binding, lowercased, _ as ->` (the title wrangler's own
18//   automatic provisioning gives it, so either finds the other's), creating
19//   it if missing, and writes its id into the copy.
20// - `r2_buckets`: an R2 binding names its bucket (`bucket_name`, a name,
21//   not an account's id), so there is no id to write; the bucket is
22//   created if the account has none by that name, since `wrangler deploy`
23//   refuses a binding to a bucket that does not exist.
24//
25// Everything is looked up by name each run, so it is idempotent, and the
26// repo's wrangler.json holds no account's ids: a first deploy to an empty
27// account creates what it needs, and every later one finds it. wrangler's
28// automatic provisioning at deploy time is not enough by itself: it
29// creates a database during `wrangler deploy`, after which it is too late
30// to migrate it; and a KV title it created in a deploy that then failed is
31// taken (KV titles are unique per account) but bound to no Worker, which
32// its next attempt does not look for.
33//
34// Nothing here prints a credential: the wrapper reads the token itself,
35// and only ids and names pass through this process.
36import { execFileSync } from 'node:child_process';
37import { readFileSync, writeFileSync } from 'node:fs';
38import { join } from 'node:path';
39import { pathToFileURL } from 'node:url';
40
41// wrangler's own automatic-provisioning title (autoProvisionedResourceName).
42export const kvTitle = (worker, binding) => `${worker}-${binding.toLowerCase().replaceAll('_', '-')}`;
43
44// The JSON array a list command printed, from the first line that opens
45// one (past any banner; a "[WARNING]" line goes to stderr, but is not taken
46// for the list even if it did not).
47export function parseList(out, what) {
48  const start = out.search(/^\[\s*(\{|\])/m);
49  if (start < 0) throw new Error(`${what}: no JSON list in wrangler's output`);
50  const list = JSON.parse(out.slice(start));
51  if (!Array.isArray(list)) throw new Error(`${what}: not a list`);
52  return list;
53}
54
55// The bucket names `wrangler r2 bucket list` printed. It has no --json
56// (wrangler 4.132.0): each bucket is a block of `label:  value` lines, the
57// first `name:`.
58export function bucketNames(out) {
59  return [...out.matchAll(/^name:\s+(\S+)\s*$/gm)].map((m) => m[1]);
60}
61
62// `wrangler(args, { capture })` runs the deploy's wrangler in the copy and
63// returns its stdout when capturing. `config` is the copy's wrangler.json,
64// parsed; it is updated in place and returned.
65export function provision(config, wrangler, log = console.error) {
66  const databases = config.d1_databases ?? [];
67  const pendingD1 = databases.filter((db) => !db.database_id);
68  if (pendingD1.length) {
69    const list = () =>
70      parseList(wrangler(['d1', 'list', '--json'], { capture: true }), 'd1 list').filter(
71        (d) => typeof d?.name === 'string' && typeof d?.uuid === 'string',
72      );
73    let existing = list();
74    for (const db of pendingD1) {
75      if (!existing.some((d) => d.name === db.database_name)) {
76        log(`resources: creating the D1 database ${db.database_name}`);
77        wrangler(['d1', 'create', db.database_name, '--update-config=false']);
78        existing = list();
79      }
80      const found = existing.find((d) => d.name === db.database_name);
81      if (!found) throw new Error(`D1 database ${db.database_name} is still missing after creating it`);
82      db.database_id = found.uuid;
83      log(`resources: ${db.binding} is D1 ${db.database_name} (${found.uuid})`);
84    }
85  }
86
87  const pendingKV = (config.kv_namespaces ?? []).filter((ns) => !ns.id);
88  if (pendingKV.length) {
89    const list = () =>
90      parseList(wrangler(['kv', 'namespace', 'list'], { capture: true }), 'kv namespace list').filter(
91        (n) => typeof n?.title === 'string' && typeof n?.id === 'string',
92      );
93    let existing = list();
94    for (const ns of pendingKV) {
95      const title = kvTitle(config.name, ns.binding);
96      if (!existing.some((n) => n.title === title)) {
97        log(`resources: creating the KV namespace ${title}`);
98        wrangler(['kv', 'namespace', 'create', title, '--update-config=false']);
99        existing = list();
100      }
101      const found = existing.find((n) => n.title === title);
102      if (!found) throw new Error(`KV namespace ${title} is still missing after creating it`);
103      ns.id = found.id;
104      log(`resources: ${ns.binding} is KV ${title} (${found.id})`);
105    }
106  }
107
108  const buckets = (config.r2_buckets ?? []).filter((b) => b.bucket_name);
109  if (buckets.length) {
110    let existing = bucketNames(wrangler(['r2', 'bucket', 'list'], { capture: true }));
111    for (const b of buckets) {
112      if (!existing.includes(b.bucket_name)) {
113        log(`resources: creating the R2 bucket ${b.bucket_name}`);
114        wrangler(['r2', 'bucket', 'create', b.bucket_name, '--update-config=false']);
115        existing = bucketNames(wrangler(['r2', 'bucket', 'list'], { capture: true }));
116      }
117      if (!existing.includes(b.bucket_name)) throw new Error(`R2 bucket ${b.bucket_name} is still missing after creating it`);
118      log(`resources: ${b.binding} is R2 ${b.bucket_name}`);
119    }
120  }
121  return config;
122}
123
124// After the ids are in the copy's config: each database's pending
125// migrations, on the real database. wrangler lists them and asks before
126// applying when run from a terminal.
127export function migrate(config, wrangler) {
128  for (const db of config.d1_databases ?? []) {
129    wrangler(['d1', 'migrations', 'apply', db.database_name, '--remote', '--env=']);
130  }
131}
132
133if (import.meta.url === pathToFileURL(process.argv[1]).href) {
134  const [bin, dir] = process.argv.slice(2);
135  if (!bin || !dir) {
136    console.error('usage: resources.mjs <wrangler> <worker dir>');
137    process.exit(2);
138  }
139  const path = join(dir, 'wrangler.json');
140  const wrangler = (args, { capture = false } = {}) =>
141    execFileSync(bin, [dir, ...args], {
142      encoding: 'utf8',
143      stdio: ['inherit', capture ? 'pipe' : 'inherit', 'inherit'],
144      env: { ...process.env, WRANGLER_SEND_METRICS: 'false' },
145    });
146  const config = provision(JSON.parse(readFileSync(path, 'utf8')), wrangler);
147  writeFileSync(path, JSON.stringify(config, null, 2));
148  migrate(config, wrangler);
149}