jevstrudel.git / tools / deploy / resources.mjs
1#!/usr/bin/env node

Run by nix run .#deploy after release-check.mjs and before wrangler deploy: the Worker's storage, made to exist and brought up to date first.

resources.mjs <wrangler> <worker dir>

<wrangler> is the deploy's 1Password-fed wrangler (`jevstrudel-wrangler

<dir> <args…>`); <worker dir> is the deploy's temporary copy of worker/, whose wrangler.json this rewrites. For the production config's (top level)
  • d1_databases without a database_id: finds the database by database_name, creating it if the account has none, and writes its id into the copy. Then applies its migrations (d1 migrations apply --remote), so the Worker about to be published never runs against a schema older than its code.
  • kv_namespaces without an id: finds the namespace by title, <worker name>-<binding, lowercased, _ as -> (the title wrangler's own automatic provisioning gives it, so either finds the other's), creating it if missing, and writes its id into the copy.
  • r2_buckets: an R2 binding names its bucket (bucket_name, a name, not an account's id), so there is no id to write; the bucket is created if the account has none by that name, since wrangler deploy refuses a binding to a bucket that does not exist.

Everything is looked up by name each run, so it is idempotent, and the repo's wrangler.json holds no account's ids: a first deploy to an empty account creates what it needs, and every later one finds it. wrangler's automatic provisioning at deploy time is not enough by itself: it creates a database during wrangler deploy, after which it is too late to migrate it; and a KV title it created in a deploy that then failed is taken (KV titles are unique per account) but bound to no Worker, which its next attempt does not look for.

Nothing here prints a credential: the wrapper reads the token itself, 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';

wrangler's own automatic-provisioning title (autoProvisionedResourceName).

42export const kvTitle = (worker, binding) => `${worker}-${binding.toLowerCase().replaceAll('_', '-')}`;

The JSON array a list command printed, from the first line that opens one (past any banner; a "[WARNING]" line goes to stderr, but is not taken 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}

The bucket names wrangler r2 bucket list printed. It has no --json (wrangler 4.132.0): each bucket is a block of label: value lines, the first name:.

58export function bucketNames(out) {
59  return [...out.matchAll(/^name:\s+(\S+)\s*$/gm)].map((m) => m[1]);
60}

wrangler(args, { capture }) runs the deploy's wrangler in the copy and returns its stdout when capturing. config is the copy's wrangler.json, 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}

After the ids are in the copy's config: each database's pending migrations, on the real database. wrangler lists them and asks before 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}
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}