@wtfalch/foundry 0.42.1 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/bootstrap/versions.d.ts +2 -0
  2. package/dist/index.d.ts +8 -4
  3. package/dist/index.js +4 -2
  4. package/dist/migrations/0055_release_requests.sql +20 -0
  5. package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
  6. package/dist/migrations/0057_org_customers.sql +29 -0
  7. package/dist/org.d.ts +55 -0
  8. package/dist/org.js +90 -27
  9. package/dist/plan/archon-blue-green.d.ts +54 -0
  10. package/dist/plan/archon-blue-green.js +72 -0
  11. package/dist/plan/blue-green-apply.d.ts +82 -0
  12. package/dist/plan/blue-green-apply.js +360 -0
  13. package/dist/plan/blue-green.d.ts +223 -0
  14. package/dist/plan/blue-green.js +444 -0
  15. package/dist/plan/boule.d.ts +43 -2
  16. package/dist/plan/boule.js +48 -1
  17. package/dist/plan/broker-entries.d.ts +28 -0
  18. package/dist/plan/broker-entries.js +46 -1
  19. package/dist/plan/cloudflared-connector.d.ts +26 -0
  20. package/dist/plan/cloudflared-connector.js +37 -0
  21. package/dist/plan/diff.d.ts +20 -0
  22. package/dist/plan/diff.js +207 -20
  23. package/dist/plan/executor.d.ts +134 -2
  24. package/dist/plan/executor.js +517 -25
  25. package/dist/plan/hostnames.d.ts +7 -0
  26. package/dist/plan/hostnames.js +17 -2
  27. package/dist/plan/locked-hostnames.d.ts +11 -0
  28. package/dist/plan/locked-hostnames.js +13 -0
  29. package/dist/plan/mcp.d.ts +57 -0
  30. package/dist/plan/mcp.js +131 -0
  31. package/dist/plan/org-names.d.ts +2 -2
  32. package/dist/plan/org-names.js +16 -1
  33. package/dist/plan/plan-apply.d.ts +77 -0
  34. package/dist/plan/plan-apply.js +267 -5
  35. package/dist/plan/platform-diff.d.ts +9 -0
  36. package/dist/plan/platform-diff.js +24 -1
  37. package/dist/plan/platform-providers.d.ts +15 -1
  38. package/dist/plan/platform-providers.js +90 -5
  39. package/dist/plan/proxy-labels.d.ts +2 -2
  40. package/dist/plan/proxy-labels.js +14 -4
  41. package/dist/plan/release-host.d.ts +59 -0
  42. package/dist/plan/release-host.js +239 -0
  43. package/dist/plan/remote-database.d.ts +124 -0
  44. package/dist/plan/remote-database.js +373 -0
  45. package/dist/plan/worker-app-database.d.ts +148 -0
  46. package/dist/plan/worker-app-database.js +424 -0
  47. package/dist/plan/worker-app.d.ts +320 -0
  48. package/dist/plan/worker-app.js +792 -0
  49. package/dist/plan/zitadel-machine-user.d.ts +13 -1
  50. package/dist/plan/zitadel-machine-user.js +80 -0
  51. package/dist/platform.d.ts +11 -1
  52. package/dist/platform.js +4 -1
  53. package/dist/release/decide.d.ts +95 -0
  54. package/dist/release/decide.js +242 -0
  55. package/dist/release/health.d.ts +48 -0
  56. package/dist/release/health.js +61 -0
  57. package/dist/release/run.d.ts +40 -0
  58. package/dist/release/run.js +56 -0
  59. package/dist/release/state.d.ts +64 -0
  60. package/dist/release/state.js +126 -0
  61. package/dist/release/types.d.ts +54 -0
  62. package/dist/release/types.js +4 -0
  63. package/dist/requested-changes.d.ts +12 -3
  64. package/dist/requested-changes.js +22 -0
  65. package/dist/store/postgres.d.ts +8 -0
  66. package/dist/store/postgres.js +67 -8
  67. package/dist/store/schema.d.ts +188 -0
  68. package/dist/store/schema.js +15 -0
  69. package/dist/worker-app-config.d.ts +151 -0
  70. package/dist/worker-app-config.js +438 -0
  71. package/package.json +1 -1
  72. package/versions.digests.json +2 -1
  73. package/versions.json +2 -1
@@ -0,0 +1,792 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { WORKER_APP_HYPERDRIVE_BINDING, WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHostId, } from '../worker-app-config.js';
3
+ import { entryAuthEnv } from './broker-entries.js';
4
+ import { workerAppHostname } from './hostnames.js';
5
+ import { appStepKey } from './platform-diff.js';
6
+ import { remoteDatabaseKeys } from './remote-database.js';
7
+ import { applyWorkerAppDatabase, deleteWorkerAppDatabaseRoute, deleteWorkerAppHyperdrive, workerAppDbRouteMarkerScopeKey, workerAppHyperdriveScopeKey, workerAppMigrationsScopeKey, } from './worker-app-database.js';
8
+ import { WORKER_COMPATIBILITY_DATE } from './worker.js';
9
+ /**
10
+ * ADR 0002: a hosting entry that declares `workerApp` runs as one Cloudflare
11
+ * Worker with Durable Objects and Containers on the platform account. One step
12
+ * per entry, `cloudflare:worker-app`, keyed `<orgId>:<entryKey>`; its resource
13
+ * id is the script name. The module is fetched, checked and uploaded behind
14
+ * {@link WorkerAppPort}; foundry builds and checks out nothing.
15
+ *
16
+ * Order on apply, so a failure at any point leaves a state the next apply
17
+ * repairs and the Worker is never routed before it can serve:
18
+ * trace -> upload (bindings, vars, only the unapplied migrations) -> secrets ->
19
+ * containers -> DNS record (own domain only) -> route -> applied marker.
20
+ * The marker is written last, so a run that fails anywhere is planned again.
21
+ */
22
+ // ---- scope keys. None starts with `edge_worker_`, which `liveWorkers` scans. ----
23
+ export const workerAppAppliedScopeKey = (entryKey) => `workerapp_applied_${entryKey}`;
24
+ /** The script name, recorded and persisted before the upload, so a crash mid-deploy leaves a trail to it. */
25
+ export const workerAppUploadedScopeKey = (entryKey) => `workerapp_uploaded_${entryKey}`;
26
+ /** The migration tags applied, as a JSON list, recorded after the upload that carried them. */
27
+ export const workerAppTagsScopeKey = (entryKey) => `workerapp_tags_${entryKey}`;
28
+ /** Secret name -> delivered version, as JSON. Versions only, never a value. */
29
+ export const workerAppSecretsScopeKey = (entryKey) => `workerapp_secrets_${entryKey}`;
30
+ /** The delivered custody credential, as `{hostId, credentialId, expiresAt}` JSON. Never the credential. */
31
+ export const workerAppKeysScopeKey = (entryKey) => `workerapp_keys_${entryKey}`;
32
+ /** Class name -> `{id, image, instanceType, maxInstances}` of its container application, as JSON. */
33
+ export const workerAppContainersScopeKey = (entryKey) => `workerapp_containers_${entryKey}`;
34
+ /** The route: `{zoneId, apex, pattern, id}` as JSON. */
35
+ export const workerAppRouteScopeKey = (entryKey) => `workerapp_route_${entryKey}`;
36
+ /** A route about to be created, same shape without `id`; recorded and persisted before `createRoute`. */
37
+ export const workerAppRoutePendingScopeKey = (entryKey) => `workerapp_route_pending_${entryKey}`;
38
+ /** The proxied record in the org's zone: `{zone, id, name}` as JSON. */
39
+ export const workerAppDnsScopeKey = (entryKey) => `workerapp_dns_${entryKey}`;
40
+ /** The script name retired, once a retire step is done. */
41
+ export const workerAppRetiredScopeKey = (entryKey) => `workerapp_retired_${entryKey}`;
42
+ /** Every per-entry scope key that marks something deployed, half deployed or handled. */
43
+ export const workerAppTraceScopeKeys = (entryKey) => [
44
+ workerAppAppliedScopeKey(entryKey),
45
+ workerAppUploadedScopeKey(entryKey),
46
+ workerAppTagsScopeKey(entryKey),
47
+ workerAppSecretsScopeKey(entryKey),
48
+ workerAppKeysScopeKey(entryKey),
49
+ workerAppContainersScopeKey(entryKey),
50
+ workerAppRouteScopeKey(entryKey),
51
+ workerAppRoutePendingScopeKey(entryKey),
52
+ workerAppDnsScopeKey(entryKey),
53
+ workerAppRetiredScopeKey(entryKey),
54
+ workerAppHyperdriveScopeKey(entryKey),
55
+ workerAppMigrationsScopeKey(entryKey),
56
+ workerAppDbRouteMarkerScopeKey(entryKey),
57
+ ...['route', 'access', 'token', 'undelivered'].map((name) => remoteDatabaseKeys(entryKey)[name]),
58
+ ];
59
+ /** The recorded id of the step: the script name. */
60
+ export const workerAppResourceKey = (stepKey) => `cloudflare:worker-app:${stepKey}`;
61
+ export const WORKER_APP_ORG_HASH_LENGTH = 8;
62
+ /** `${8 hex of the org id's sha256}-${entry key}-app`. */
63
+ export const workerAppName = (org, entry) => `${createHash('sha256').update(org.id).digest('hex').slice(0, WORKER_APP_ORG_HASH_LENGTH)}-${entry.key}-app`;
64
+ /** The zone that holds the routes of an org with no domain of its own. */
65
+ export const WORKER_APP_OIKOS_ZONE = 'oikos.host';
66
+ /** The record a Worker route needs on the org's own zone: Cloudflare's documented dummy, proxied. */
67
+ export const WORKER_APP_DNS_RECORD = {
68
+ type: 'AAAA',
69
+ content: '100::',
70
+ };
71
+ /** True once the Worker app holds the broker key (`authEnv` delivered it). */
72
+ export const brokerKeyDelivered = (recorded, entryKey) => Boolean(parseJson(recorded[workerAppSecretsScopeKey(entryKey)], {})
73
+ .AUTH_APP_KEY);
74
+ /**
75
+ * The broker key of a Worker app with `authEnv`, read from the held-key store
76
+ * (it was put there before it was delivered to the Worker). Throws, naming no
77
+ * value, when it is missing or shorter than the broker accepts.
78
+ */
79
+ export async function heldBrokerKey(heldKeys, org, entryKey) {
80
+ const key = await heldKeys?.resolve(workerAppSecretRef(org.id, entryKey, WORKER_APP_AUTH_KEY_SECRET));
81
+ if (!key || key.length < 32) {
82
+ throw new Error(`coolify:broker-entry for ${org.slug}: the Worker app ${entryKey} has no held AUTH_APP_KEY yet`);
83
+ }
84
+ return key;
85
+ }
86
+ /** The foundry-written secret the broker entry shares with the app when `authEnv` is on. */
87
+ export const WORKER_APP_AUTH_KEY_SECRET = 'AUTH_APP_KEY';
88
+ /**
89
+ * Where foundry keeps a generated secret (the held-key store), so the value
90
+ * is minted once and delivered again unchanged after a crash. The entry key
91
+ * and name are hashed: a held ref is at most 128 characters.
92
+ */
93
+ export const workerAppSecretRef = (orgId, entryKey, name) => `foundry:worker-app:${orgId}:${createHash('sha256').update(`${entryKey}:${name}`).digest('hex').slice(0, 32)}`;
94
+ const secretVersion = (value) => `g1-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`;
95
+ /** Foundry mints a new credential this long before the held one expires. */
96
+ export const WORKER_APP_CREDENTIAL_RENEW_BEFORE_MS = 30 * 24 * 60 * 60 * 1000;
97
+ /**
98
+ * True when the delivered custody credential is within 30 days of expiry (or
99
+ * its record is unreadable), so the plan renews it.
100
+ */
101
+ export function custodyRenewalDue(recorded, entryKey, now) {
102
+ const current = parseJson(recorded[workerAppKeysScopeKey(entryKey)], null);
103
+ if (!current)
104
+ return false; // Never delivered: the first apply mints it.
105
+ const expires = Date.parse(current.expiresAt ?? '');
106
+ return Number.isNaN(expires) || expires - WORKER_APP_CREDENTIAL_RENEW_BEFORE_MS <= now.getTime();
107
+ }
108
+ const sortedEntries = (record) => Object.entries(record ?? {}).sort(([a], [b]) => a.localeCompare(b));
109
+ /** The zone apex the entry's route lives in. */
110
+ export const workerAppApex = (org) => org.hosting.usesDomain && org.domain ? org.domain : WORKER_APP_OIKOS_ZONE;
111
+ /** True when the want has a secret that must be held (a generated one, the broker key, a custody credential). */
112
+ const holdsGenerated = (want) => want.secrets.length > 0;
113
+ /**
114
+ * What the Worker must look like, or the inputs still missing (names only).
115
+ * `issuer` is the sign-in issuer the plan read; `keysUrl` the custody Worker.
116
+ */
117
+ export function wantedWorkerApp(org, entry, recorded, input) {
118
+ const app = entry.workerApp;
119
+ const missing = [];
120
+ const vars = { ...app.vars };
121
+ const secrets = [];
122
+ for (const [name, source] of Object.entries(app.secrets ?? {})) {
123
+ if (source.from === 'keysCustody') {
124
+ const hostId = workerAppHostId(org.id, entry.key);
125
+ if (input.keysUrl === undefined)
126
+ missing.push('the keys custody port');
127
+ else
128
+ vars[WORKER_APP_KEYS_URL_VAR] = input.keysUrl;
129
+ if (hostId === null)
130
+ missing.push('a valid custody host id');
131
+ else
132
+ secrets.push({ name, source: 'keysCustody', hostId });
133
+ }
134
+ else {
135
+ secrets.push({ name, source: 'generated' });
136
+ }
137
+ }
138
+ if (app.authEnv) {
139
+ const env = entryAuthEnv(org, entry, recorded, input.issuer);
140
+ for (const [name, what] of [
141
+ ['AUTH_ORGANIZATION_ID', 'its sign-in organisation id'],
142
+ ['AUTH_CLIENT_ID', 'its sign-in client id'],
143
+ ['AUTH_ISSUER', 'the sign-in issuer'],
144
+ ]) {
145
+ if (env[name])
146
+ vars[name] = env[name];
147
+ else
148
+ missing.push(what);
149
+ }
150
+ secrets.push({ name: WORKER_APP_AUTH_KEY_SECRET, source: 'generated' });
151
+ if (entry.uses?.auth?.mcp) {
152
+ for (const name of ['MCP_AUDIENCE', 'MCP_RESOURCE', 'MCP_ISSUER']) {
153
+ if (env[name])
154
+ vars[name] = env[name];
155
+ else if (!missing.includes('its MCP audience project id')) {
156
+ missing.push('its MCP audience project id');
157
+ }
158
+ }
159
+ }
160
+ }
161
+ if (missing.length > 0)
162
+ return { missing };
163
+ return {
164
+ want: {
165
+ name: workerAppName(org, entry),
166
+ artifactUrl: app.artifactUrl,
167
+ artifactSha256: app.artifactSha256,
168
+ compatibilityDate: app.compatibilityDate ?? WORKER_COMPATIBILITY_DATE,
169
+ compatibilityFlags: [...(app.compatibilityFlags ?? [])].sort(),
170
+ host: workerAppHostname(org, entry),
171
+ apex: workerAppApex(org),
172
+ classes: Object.entries(app.durableObjects.classes)
173
+ .map(([binding, className]) => ({ binding, className }))
174
+ .sort((a, b) => a.binding.localeCompare(b.binding)),
175
+ migrations: app.durableObjects.migrations,
176
+ containers: (app.containers ?? [])
177
+ .map((c) => ({
178
+ className: c.className,
179
+ image: `${c.image.repository}@${c.image.digest}`,
180
+ instanceType: c.instanceType,
181
+ maxInstances: c.maxInstances,
182
+ }))
183
+ .sort((a, b) => a.className.localeCompare(b.className)),
184
+ vars: sortedEntries(vars),
185
+ ...(app.database
186
+ ? {
187
+ database: {
188
+ appendOnly: [...(app.database.appendOnly ?? [])].sort(),
189
+ ...(app.database.migrationsSha256
190
+ ? { migrationsSha256: app.database.migrationsSha256 }
191
+ : {}),
192
+ },
193
+ }
194
+ : {}),
195
+ secrets: secrets.sort((a, b) => a.name.localeCompare(b.name)),
196
+ },
197
+ };
198
+ }
199
+ const parseJson = (raw, fallback) => {
200
+ if (!raw)
201
+ return fallback;
202
+ try {
203
+ return JSON.parse(raw);
204
+ }
205
+ catch {
206
+ return fallback;
207
+ }
208
+ };
209
+ /**
210
+ * The migration tags the recorded list says are applied must be the first tags
211
+ * of the declared list, in order. `null` when they are; else the refusal.
212
+ */
213
+ export function migrationPrefixError(applied, declared) {
214
+ for (const [index, tag] of applied.entries()) {
215
+ if (declared[index] === tag)
216
+ continue;
217
+ return declared.includes(tag)
218
+ ? `migration '${tag}' was applied and has been reordered; migrations are append-only`
219
+ : `migration '${tag}' was applied and has been removed; migrations are append-only`;
220
+ }
221
+ return null;
222
+ }
223
+ /** The unapplied migrations after the last tag in `applied`. */
224
+ export const unappliedMigrations = (declared, applied) => declared.slice(applied.length);
225
+ /** The registry prefix a container image of this platform must start with. */
226
+ export const workerAppRegistryPrefix = (accountId) => `registry.cloudflare.com/${accountId}/`;
227
+ /** Every entry key with a deployed or half-deployed Worker app recorded. */
228
+ export function liveWorkerApps(orgId, recordedIds) {
229
+ const mainPrefix = `cloudflare:worker-app:${orgId}:`;
230
+ const entryKeys = new Set();
231
+ for (const [name, id] of Object.entries(recordedIds)) {
232
+ if (!id)
233
+ continue;
234
+ if (name.startsWith(mainPrefix))
235
+ entryKeys.add(name.slice(mainPrefix.length));
236
+ for (const prefix of [
237
+ 'workerapp_uploaded_',
238
+ 'workerapp_route_',
239
+ 'workerapp_route_pending_',
240
+ 'workerapp_containers_',
241
+ 'workerapp_keys_',
242
+ 'workerapp_hyperdrive_',
243
+ 'workerapp_dbroute_',
244
+ ]) {
245
+ if (name.startsWith(prefix))
246
+ entryKeys.add(name.slice(prefix.length));
247
+ }
248
+ }
249
+ const live = new Map();
250
+ for (const entryKey of entryKeys) {
251
+ const mainId = recordedIds[`${mainPrefix}${entryKey}`] ?? '';
252
+ const retired = mainId !== '' && recordedIds[workerAppRetiredScopeKey(entryKey)] === mainId;
253
+ const partial = recordedIds[workerAppUploadedScopeKey(entryKey)] ||
254
+ recordedIds[workerAppRouteScopeKey(entryKey)] ||
255
+ recordedIds[workerAppRoutePendingScopeKey(entryKey)] ||
256
+ recordedIds[workerAppContainersScopeKey(entryKey)] ||
257
+ recordedIds[workerAppKeysScopeKey(entryKey)] ||
258
+ recordedIds[workerAppHyperdriveScopeKey(entryKey)] ||
259
+ recordedIds[workerAppDbRouteMarkerScopeKey(entryKey)] ||
260
+ recordedIds[workerAppDnsScopeKey(entryKey)];
261
+ if (!partial && (!mainId || retired))
262
+ continue;
263
+ live.set(entryKey, mainId || recordedIds[workerAppUploadedScopeKey(entryKey)] || '');
264
+ }
265
+ return live;
266
+ }
267
+ /**
268
+ * One step per Worker app whose declaration or inputs changed, plus a retire
269
+ * step per recorded Worker app no longer declared. Blocked, not planned
270
+ * (`CLAUDE.md` platform rule 2), while the host lacks a port or a value, while
271
+ * the declared migrations contradict the applied ones, and for each input the
272
+ * Worker needs that does not exist yet. A migration that deletes or renames a
273
+ * class makes the step `destroys` or `migrates`, so the org's name must be typed
274
+ * to apply it.
275
+ */
276
+ export function planWorkerApps(org, recordedIds, input) {
277
+ const steps = [];
278
+ const blocked = [];
279
+ const declared = new Set(org.hosting.enabled ? org.hosting.entries.filter((e) => e.workerApp).map((e) => e.key) : []);
280
+ for (const entryKey of liveWorkerApps(org.id, recordedIds).keys()) {
281
+ if (declared.has(entryKey))
282
+ continue;
283
+ const key = workerAppResourceKey(`${org.id}:${entryKey}`);
284
+ if (!input.available) {
285
+ blocked.push({
286
+ description: `${org.slug}'s ${entryKey} Worker app is blocked, not planned: the host supplies no WorkerAppPort`,
287
+ key,
288
+ });
289
+ continue;
290
+ }
291
+ steps.push({
292
+ action: {
293
+ system: 'cloudflare',
294
+ kind: 'worker-app',
295
+ key: `${org.id}:${entryKey}`,
296
+ desired: { mode: 'retire' },
297
+ },
298
+ step: {
299
+ description: `delete ${org.slug}'s ${entryKey} Worker app, its route and its containers: workerApp is no longer declared`,
300
+ destroys: true,
301
+ migrates: false,
302
+ },
303
+ });
304
+ }
305
+ if (!org.hosting.enabled)
306
+ return { steps, blocked };
307
+ for (const entry of org.hosting.entries) {
308
+ const app = entry.workerApp;
309
+ if (!app)
310
+ continue;
311
+ const stepKey = appStepKey(org, entry.key);
312
+ const key = workerAppResourceKey(stepKey);
313
+ const at = `${org.slug}'s ${entry.key} Worker app`;
314
+ const block = (why) => blocked.push({
315
+ description: `${at} is blocked, not planned: ${why}`,
316
+ key,
317
+ });
318
+ if (!input.available) {
319
+ block('the host supplies no WorkerAppPort');
320
+ continue;
321
+ }
322
+ const wanted = wantedWorkerApp(org, entry, recordedIds, input);
323
+ if ('missing' in wanted) {
324
+ block(`${wanted.missing.join(', ')} not recorded yet`);
325
+ continue;
326
+ }
327
+ const { want } = wanted;
328
+ const stray = (app.containers ?? []).find((c) => input.registryAccountId === undefined ||
329
+ !c.image.repository.startsWith(workerAppRegistryPrefix(input.registryAccountId)) ||
330
+ c.image.repository.split('/').some((part) => /^\.+$/.test(part)));
331
+ if (stray) {
332
+ block(input.registryAccountId === undefined
333
+ ? 'the host supplies no Cloudflare account id for the container registry'
334
+ : `container '${stray.className}' image repository must start with ${workerAppRegistryPrefix(input.registryAccountId)} and hold no '..' segment`);
335
+ continue;
336
+ }
337
+ if (want.database) {
338
+ const db = input.database;
339
+ const why = !db
340
+ ? 'the host supplies no WorkerAppDatabasePort'
341
+ : !db.accessReady
342
+ ? 'the host supplies no Access, tunnel, DNS or zone port'
343
+ : want.apex === WORKER_APP_OIKOS_ZONE
344
+ ? "its database route needs the org's own domain: the shared oikos.host zone has no per-org tunnel"
345
+ : !db.databaseReady
346
+ ? `${org.slug}'s database is not applied yet`
347
+ : !db.tunnelReady
348
+ ? `${org.slug}'s Cloudflare tunnel is not applied yet`
349
+ : recordedIds[remoteDatabaseKeys(entry.key).undelivered]
350
+ ? `its Access service token ${recordedIds[remoteDatabaseKeys(entry.key).undelivered]} was minted and its secret never held; delete the token in Cloudflare and clear the recorded id before minting again`
351
+ : !input.canHoldSecrets
352
+ ? 'its database credentials need a held-key store that can put, and the host has none'
353
+ : null;
354
+ if (why) {
355
+ block(why);
356
+ continue;
357
+ }
358
+ }
359
+ if (holdsGenerated(want) && !input.canHoldSecrets) {
360
+ block('its generated secrets need a held-key store that can put, and the host has none');
361
+ continue;
362
+ }
363
+ const appliedTags = parseJson(recordedIds[workerAppTagsScopeKey(entry.key)], []);
364
+ const declaredTags = want.migrations.map((m) => m.tag);
365
+ const prefixError = migrationPrefixError(appliedTags, declaredTags);
366
+ if (prefixError) {
367
+ block(prefixError);
368
+ continue;
369
+ }
370
+ const renew = want.secrets.some((s) => s.source === 'keysCustody') &&
371
+ custodyRenewalDue(recordedIds, entry.key, input.now ?? new Date());
372
+ if (!renew && recordedIds[workerAppAppliedScopeKey(entry.key)] === JSON.stringify(want))
373
+ continue;
374
+ const pending = unappliedMigrations(want.migrations, appliedTags);
375
+ const deletes = pending.flatMap((m) => m.deletedClasses ?? []);
376
+ const renames = pending.flatMap((m) => m.renamedClasses ?? []);
377
+ const risk = deletes.length > 0 || renames.length > 0
378
+ ? ` (migration ${deletes.length > 0 ? `deletes class ${deletes.join(', ')}` : ''}${deletes.length > 0 && renames.length > 0 ? ' and ' : ''}${renames.length > 0 ? `renames ${renames.map((r) => `${r.from} to ${r.to}`).join(', ')}` : ''}: type the org's name to apply it)`
379
+ : '';
380
+ const first = !recordedIds[workerAppAppliedScopeKey(entry.key)];
381
+ steps.push({
382
+ action: {
383
+ system: 'cloudflare',
384
+ kind: 'worker-app',
385
+ key: stepKey,
386
+ desired: { mode: 'apply', ...want },
387
+ },
388
+ step: {
389
+ description: `${first ? 'deploy' : 'update'} ${at} at ${want.host}${risk}${renew ? ' (renews its keys custody credential, which expires within 30 days)' : ''}`,
390
+ destroys: deletes.length > 0,
391
+ migrates: renames.length > 0,
392
+ },
393
+ });
394
+ }
395
+ return { steps, blocked };
396
+ }
397
+ /** An outside call that has not answered in this long fails the step; the next apply repairs it. */
398
+ export const WORKER_APP_CALL_TIMEOUT_MS = 120_000;
399
+ export const WORKER_APP_KEYS_DELIVERY_ATTEMPTS = 3;
400
+ /**
401
+ * Races `task` against a timer. An error names the call, never the cause.
402
+ */
403
+ export function withCallTimeout(ms, label, task) {
404
+ return new Promise((resolve, reject) => {
405
+ const timer = setTimeout(() => reject(new Error(`${label}: no answer within ${ms} ms`)), ms);
406
+ task().then((value) => {
407
+ clearTimeout(timer);
408
+ resolve(value);
409
+ }, (error) => {
410
+ clearTimeout(timer);
411
+ // The cause is dropped: a provider error can echo a request body. The
412
+ // HTTP status stays, because callers tell "already gone" by it.
413
+ const failure = new Error(`${label} failed`);
414
+ const status = error?.status;
415
+ if (typeof status === 'number')
416
+ failure.status = status;
417
+ reject(failure);
418
+ });
419
+ });
420
+ }
421
+ const isGone = (error) => {
422
+ const status = error?.status;
423
+ return typeof status === 'number' && status === 404;
424
+ };
425
+ /**
426
+ * Makes the Worker app true. Throws a fixed message on a failure, naming the
427
+ * step and never a value or a provider's cause for anything that carried a
428
+ * secret. Every recorded id is persisted before the call that could lose it.
429
+ */
430
+ export async function applyWorkerApp(deps, org, action, context) {
431
+ const entryKey = action.key.slice(org.id.length + 1);
432
+ const at = `cloudflare:worker-app for ${org.slug} entry '${entryKey}'`;
433
+ const ms = deps.timeoutMs ?? WORKER_APP_CALL_TIMEOUT_MS;
434
+ const call = (label, task) => withCallTimeout(ms, `${at}: ${label}`, task);
435
+ const mode = action.desired.mode;
436
+ if (mode === 'retire')
437
+ return retireWorkerApp(deps, org, entryKey, context, call);
438
+ const want = action.desired;
439
+ const entry = org.hosting.entries.find((e) => e.key === entryKey);
440
+ if (!entry?.workerApp)
441
+ throw new Error(`${at}: the entry no longer declares workerApp`);
442
+ const { workers } = deps;
443
+ // Route and zone first: a foreign route is refused before anything is uploaded.
444
+ const zone = await call('find the zone', () => deps.zones.findZoneByApex(want.apex));
445
+ if (!zone) {
446
+ throw new Error(`${at}: no Cloudflare zone found for ${want.apex} -- the zone must exist first`);
447
+ }
448
+ const pattern = `${want.host}/*`;
449
+ const existingRoute = await call('find the route', () => workers.findRoute(zone.id, pattern));
450
+ if (existingRoute && existingRoute.script !== want.name) {
451
+ throw new Error(`${at}: route '${pattern}' is already bound to Worker '${existingRoute.script}', not foundry's own '${want.name}'; foundry does not adopt or replace it`);
452
+ }
453
+ const recorded0 = context.recordedIds();
454
+ const everUploaded = Boolean(recorded0[workerAppAppliedScopeKey(entryKey)] ||
455
+ recorded0[workerAppUploadedScopeKey(entryKey)] ||
456
+ recorded0[workerAppResourceKey(action.key)]);
457
+ if (!everUploaded &&
458
+ (await call('check the script name', () => workers.scriptExists(want.name)))) {
459
+ throw new Error(`${at}: a Worker script named '${want.name}' already exists and foundry never created it; refuses to overwrite it`);
460
+ }
461
+ // 0. The database, before the upload: a failed migration leaves the live Worker as it was.
462
+ let hyperdriveId;
463
+ if (want.database) {
464
+ if (!deps.database) {
465
+ throw new Error(`${at}: this executor was built without a WorkerAppDatabasePort`);
466
+ }
467
+ ({ hyperdriveId } = await applyWorkerAppDatabase({ ...deps.database, heldKeys: deps.heldKeys, timeoutMs: ms }, org, entry, want.name, { artifactUrl: want.artifactUrl, database: want.database }, context, want.apex));
468
+ }
469
+ // 1. Trace, then upload with only the migrations the script does not hold.
470
+ context.recordScopeId(workerAppUploadedScopeKey(entryKey), want.name);
471
+ context.recordScopeId(workerAppRetiredScopeKey(entryKey), '');
472
+ await context.persistScopeIds();
473
+ const declaredTags = want.migrations.map((m) => m.tag);
474
+ const recordedTags = parseJson(recorded0[workerAppTagsScopeKey(entryKey)], []);
475
+ // The live tag is the fact; the record can be behind it after a crash between upload and record.
476
+ const liveTag = await call('read the migration tag', () => workers.liveMigrationTag(want.name));
477
+ let appliedCount;
478
+ if (liveTag === undefined)
479
+ appliedCount = 0;
480
+ else {
481
+ const index = declaredTags.indexOf(liveTag);
482
+ if (index < 0) {
483
+ throw new Error(`${at}: the live Worker holds migration '${liveTag}', which is not in the declared list; migrations are append-only and foundry will not guess`);
484
+ }
485
+ appliedCount = index + 1;
486
+ }
487
+ const recordedError = migrationPrefixError(recordedTags, declaredTags);
488
+ if (recordedError)
489
+ throw new Error(`${at}: ${recordedError}`);
490
+ const pending = want.migrations.slice(appliedCount);
491
+ const migrations = pending.length > 0
492
+ ? {
493
+ ...(liveTag !== undefined ? { oldTag: liveTag } : {}),
494
+ newTag: pending[pending.length - 1]?.tag,
495
+ steps: pending.map((m) => ({
496
+ ...(m.newSqliteClasses ? { newSqliteClasses: m.newSqliteClasses } : {}),
497
+ ...(m.deletedClasses ? { deletedClasses: m.deletedClasses } : {}),
498
+ ...(m.renamedClasses ? { renamedClasses: m.renamedClasses } : {}),
499
+ })),
500
+ }
501
+ : undefined;
502
+ await call('upload the script', () => workers.uploadWorkerApp({
503
+ name: want.name,
504
+ artifactUrl: want.artifactUrl,
505
+ artifactSha256: want.artifactSha256,
506
+ compatibilityDate: want.compatibilityDate,
507
+ compatibilityFlags: want.compatibilityFlags,
508
+ bindings: [
509
+ ...want.classes.map((c) => ({
510
+ type: 'durable_object_namespace',
511
+ name: c.binding,
512
+ class_name: c.className,
513
+ })),
514
+ ...want.vars.map(([name, text]) => ({
515
+ type: 'plain_text',
516
+ name,
517
+ text,
518
+ })),
519
+ ...(hyperdriveId
520
+ ? [{ type: 'hyperdrive', name: WORKER_APP_HYPERDRIVE_BINDING, id: hyperdriveId }]
521
+ : []),
522
+ ],
523
+ ...(migrations ? { migrations } : {}),
524
+ }));
525
+ context.recordScopeId(workerAppTagsScopeKey(entryKey), JSON.stringify(declaredTags));
526
+ await context.persistScopeIds();
527
+ // A Worker that no longer declares a database no longer binds its config: delete it now.
528
+ if (!want.database) {
529
+ await deleteWorkerAppHyperdrive(deps.database?.port, entryKey, context.recordedIds(), context, call);
530
+ await deleteWorkerAppDatabaseRoute(deps.database, deps.heldKeys, org.id, entryKey, context.recordedIds(), context, call);
531
+ }
532
+ // 2. Secrets: before the route, so the Worker never serves without them.
533
+ await deliverSecrets(deps, org, entryKey, want, context, at, call);
534
+ // 3. Containers.
535
+ await syncContainers(deps, entryKey, want, context, at, call);
536
+ // 4. The record on the org's own zone, 5. the route.
537
+ if (want.apex !== WORKER_APP_OIKOS_ZONE) {
538
+ await ensureRecord(deps, entryKey, zone.name, want.host, context, at, call);
539
+ }
540
+ let routeId = existingRoute?.id;
541
+ const routeZone = {
542
+ zoneId: zone.id,
543
+ apex: want.apex,
544
+ pattern,
545
+ };
546
+ if (!routeId) {
547
+ context.recordScopeId(workerAppRoutePendingScopeKey(entryKey), JSON.stringify(routeZone));
548
+ await context.persistScopeIds();
549
+ routeId = (await call('create the route', () => workers.createRoute(zone.id, pattern, want.name))).id;
550
+ }
551
+ const previous = parseJson(context.recordedIds()[workerAppRouteScopeKey(entryKey)], null);
552
+ if (previous?.id && (previous.id !== routeId || previous.zoneId !== zone.id)) {
553
+ await call('delete the previous route', () => workers.deleteRoute(previous.zoneId, previous.id));
554
+ }
555
+ context.recordScopeId(workerAppRouteScopeKey(entryKey), JSON.stringify({ ...routeZone, id: routeId }));
556
+ context.recordScopeId(workerAppRoutePendingScopeKey(entryKey), '');
557
+ // 6. Last: a run that failed above plans the step again.
558
+ const { mode: _mode, ...marker } = want;
559
+ context.recordScopeId(workerAppAppliedScopeKey(entryKey), JSON.stringify(marker));
560
+ context.recordScopeId(workerAppUploadedScopeKey(entryKey), '');
561
+ return { resourceId: want.name };
562
+ }
563
+ async function deliverSecrets(deps, org, entryKey, want, context, at, call) {
564
+ const { workers, heldKeys } = deps;
565
+ const delivered = parseJson(context.recordedIds()[workerAppSecretsScopeKey(entryKey)], {});
566
+ const wanted = new Set(want.secrets.map((s) => s.name));
567
+ // A secret no longer declared is deleted from the Worker.
568
+ for (const name of Object.keys(delivered)) {
569
+ if (wanted.has(name))
570
+ continue;
571
+ await call(`delete secret ${name}`, () => workers.deleteSecret(want.name, name));
572
+ delete delivered[name];
573
+ context.recordScopeId(workerAppSecretsScopeKey(entryKey), JSON.stringify(delivered));
574
+ await context.persistScopeIds();
575
+ }
576
+ if (!wanted.has(WORKER_APP_KEYS_SECRET) &&
577
+ context.recordedIds()[workerAppKeysScopeKey(entryKey)]) {
578
+ // The custody Worker has no revoke; the credential expires on its own and the held copy stays.
579
+ context.recordScopeId(workerAppKeysScopeKey(entryKey), '');
580
+ await context.persistScopeIds();
581
+ }
582
+ // Generated secrets: held first, so a crash hands the same value out again.
583
+ const generated = [];
584
+ for (const secret of want.secrets) {
585
+ if (secret.source !== 'generated')
586
+ continue;
587
+ if (!heldKeys?.put) {
588
+ throw new Error(`${at}: generated secret ${secret.name} needs a held-key store that can put`);
589
+ }
590
+ const ref = workerAppSecretRef(org.id, entryKey, secret.name);
591
+ let value = await call('read a held secret', () => heldKeys.resolve(ref)).then((v) => v ?? '');
592
+ if (!value) {
593
+ value = randomBytes(32).toString('base64url');
594
+ const minted = value;
595
+ await call('hold a generated secret', () => heldKeys.put(ref, minted));
596
+ }
597
+ const version = secretVersion(value);
598
+ if (delivered[secret.name] !== version)
599
+ generated.push({ name: secret.name, value, version });
600
+ }
601
+ if (generated.length > 0) {
602
+ await call('deliver secrets', () => workers.setSecrets(want.name, generated));
603
+ for (const { name, version } of generated)
604
+ delivered[name] = version;
605
+ context.recordScopeId(workerAppSecretsScopeKey(entryKey), JSON.stringify(delivered));
606
+ await context.persistScopeIds();
607
+ }
608
+ const custody = want.secrets.find((s) => s.source === 'keysCustody');
609
+ if (custody?.hostId) {
610
+ await deliverCustodyCredential(deps, org, entryKey, want, custody.hostId, delivered, context, at, call);
611
+ }
612
+ }
613
+ const parseHeldCredential = (raw) => {
614
+ const held = parseJson(raw ?? undefined, null);
615
+ return held &&
616
+ typeof held.credentialId === 'string' &&
617
+ typeof held.credential === 'string' &&
618
+ held.credential !== '' &&
619
+ typeof held.expiresAt === 'string' &&
620
+ !Number.isNaN(Date.parse(held.expiresAt))
621
+ ? held
622
+ : null;
623
+ };
624
+ /**
625
+ * Mints the custody credential once, holds it (the held-key store, JSON with
626
+ * the expiry) before delivery, and delivers the held value again unchanged
627
+ * after a crash. Mints again only when the held one is within 30 days of
628
+ * expiry. The credential never reaches an error, a log or a recorded id: the
629
+ * recorded `workerapp_keys_<entry>` carries the host id, credential id and
630
+ * expiry only.
631
+ */
632
+ async function deliverCustodyCredential(deps, org, entryKey, want, hostId, delivered, context, at, call) {
633
+ const { keys, heldKeys } = deps;
634
+ if (!keys)
635
+ throw new Error(`${at}: a keysCustody secret needs the host's keys custody port`);
636
+ if (!heldKeys?.put) {
637
+ throw new Error(`${at}: the custody credential needs a held-key store that can put`);
638
+ }
639
+ const put = heldKeys.put;
640
+ const ref = workerAppSecretRef(org.id, entryKey, WORKER_APP_KEYS_SECRET);
641
+ const now = (deps.now ?? (() => new Date()))().getTime();
642
+ let held = parseHeldCredential(await call('read the held credential', () => heldKeys.resolve(ref)));
643
+ if (!held || Date.parse(held.expiresAt) - WORKER_APP_CREDENTIAL_RENEW_BEFORE_MS <= now) {
644
+ const minted = await call('mint a custody credential', () => keys.mintDerivedCredential(hostId));
645
+ const fresh = parseHeldCredential(JSON.stringify(minted));
646
+ if (!fresh) {
647
+ throw new Error(`${at}: the custody Worker returned a credential without an id, a value or an expiry`);
648
+ }
649
+ await call('hold the custody credential', () => put(ref, JSON.stringify(fresh)));
650
+ held = fresh;
651
+ }
652
+ const credential = held;
653
+ if (delivered[WORKER_APP_KEYS_SECRET] !== credential.credentialId) {
654
+ let failed = false;
655
+ for (let attempt = 1; attempt <= WORKER_APP_KEYS_DELIVERY_ATTEMPTS; attempt++) {
656
+ try {
657
+ await call('deliver the custody credential', () => deps.workers.setSecrets(want.name, [
658
+ {
659
+ name: WORKER_APP_KEYS_SECRET,
660
+ value: credential.credential,
661
+ version: credential.credentialId,
662
+ },
663
+ ]));
664
+ failed = false;
665
+ break;
666
+ }
667
+ catch {
668
+ failed = true; // The cause may echo the value: dropped.
669
+ }
670
+ }
671
+ if (failed) {
672
+ throw new Error(`${at}: the custody credential ${credential.credentialId} is held but could not be delivered after ${WORKER_APP_KEYS_DELIVERY_ATTEMPTS} attempts; the next apply delivers it again`);
673
+ }
674
+ delivered[WORKER_APP_KEYS_SECRET] = credential.credentialId;
675
+ context.recordScopeId(workerAppSecretsScopeKey(entryKey), JSON.stringify(delivered));
676
+ }
677
+ context.recordScopeId(workerAppKeysScopeKey(entryKey), JSON.stringify({
678
+ hostId,
679
+ credentialId: credential.credentialId,
680
+ expiresAt: credential.expiresAt,
681
+ }));
682
+ await context.persistScopeIds();
683
+ }
684
+ async function syncContainers(deps, entryKey, want, context, at, call) {
685
+ const key = workerAppContainersScopeKey(entryKey);
686
+ const recorded = parseJson(context.recordedIds()[key], {});
687
+ const save = async () => {
688
+ context.recordScopeId(key, Object.keys(recorded).length > 0 ? JSON.stringify(recorded) : '');
689
+ await context.persistScopeIds();
690
+ };
691
+ const wanted = new Set(want.containers.map((c) => c.className));
692
+ for (const [className, held] of Object.entries(recorded)) {
693
+ if (wanted.has(className))
694
+ continue;
695
+ await call(`delete the ${className} container`, () => deps.workers.deleteContainerApplication(held.id));
696
+ delete recorded[className];
697
+ await save();
698
+ }
699
+ for (const container of want.containers) {
700
+ const held = recorded[container.className];
701
+ if (held &&
702
+ held.image === container.image &&
703
+ held.instanceType === container.instanceType &&
704
+ held.maxInstances === container.maxInstances) {
705
+ continue;
706
+ }
707
+ const { id } = await call(`ensure the ${container.className} container`, () => deps.workers.ensureContainerApplication({
708
+ ...container,
709
+ scriptName: want.name,
710
+ }));
711
+ recorded[container.className] = { ...container, id };
712
+ await save();
713
+ }
714
+ void at;
715
+ }
716
+ async function ensureRecord(deps, entryKey, zoneName, host, context, at, call) {
717
+ const key = workerAppDnsScopeKey(entryKey);
718
+ const previous = parseJson(context.recordedIds()[key], null);
719
+ if (previous?.name === host && previous.zone === zoneName)
720
+ return;
721
+ const found = await call('look for the DNS record', () => deps.dns.findDnsRecord(zoneName, host));
722
+ let id;
723
+ const ours = found.find((r) => r.type === WORKER_APP_DNS_RECORD.type && r.content === WORKER_APP_DNS_RECORD.content);
724
+ if (ours)
725
+ id = ours.id;
726
+ else if (found.length > 0) {
727
+ throw new Error(`${at}: ${host} already has a DNS record that foundry did not make; it does not replace it`);
728
+ }
729
+ else {
730
+ id = (await call('create the DNS record', () => deps.dns.createDnsRecord(zoneName, {
731
+ ...WORKER_APP_DNS_RECORD,
732
+ name: host,
733
+ proxied: true,
734
+ }))).id;
735
+ }
736
+ if (previous && (previous.name !== host || previous.zone !== zoneName)) {
737
+ await call('delete the previous DNS record', () => deps.dns.deleteDnsRecord(previous.zone, previous.id));
738
+ }
739
+ context.recordScopeId(key, JSON.stringify({ zone: zoneName, id, name: host }));
740
+ await context.persistScopeIds();
741
+ }
742
+ /**
743
+ * Tears a Worker app down in the order that never leaves a route to nothing:
744
+ * route, record, containers, then the script (its secrets go with it).
745
+ * Held generated secrets stay, so redeclaring reuses them. Used by retire and
746
+ * by org destroy.
747
+ */
748
+ export async function teardownWorkerApp(deps, entryKey, scriptName, recorded, call, orgId, context = null) {
749
+ const { workers } = deps;
750
+ const route = parseJson(recorded[workerAppRouteScopeKey(entryKey)], null);
751
+ if (route?.id)
752
+ await call('delete the route', () => workers.deleteRoute(route.zoneId, route.id));
753
+ const pending = parseJson(recorded[workerAppRoutePendingScopeKey(entryKey)], null);
754
+ if (pending) {
755
+ const found = await call('find the pending route', () => workers.findRoute(pending.zoneId, pending.pattern));
756
+ if (found && found.script === scriptName) {
757
+ await call('delete the pending route', () => workers.deleteRoute(pending.zoneId, found.id));
758
+ }
759
+ }
760
+ const record = parseJson(recorded[workerAppDnsScopeKey(entryKey)], null);
761
+ if (record) {
762
+ try {
763
+ await call('delete the DNS record', () => deps.dns.deleteDnsRecord(record.zone, record.id));
764
+ }
765
+ catch (error) {
766
+ if (!isGone(error))
767
+ throw error;
768
+ }
769
+ }
770
+ const containers = parseJson(recorded[workerAppContainersScopeKey(entryKey)], {});
771
+ for (const [className, held] of Object.entries(containers)) {
772
+ await call(`delete the ${className} container`, () => workers.deleteContainerApplication(held.id));
773
+ }
774
+ if (scriptName)
775
+ await call('delete the script', () => workers.deleteScript(scriptName));
776
+ // After the script: Cloudflare refuses to delete a config a live Worker binds.
777
+ await deleteWorkerAppHyperdrive(deps.database?.port, entryKey, recorded, null, call);
778
+ // After the config: it presents the service token and dials the route.
779
+ await deleteWorkerAppDatabaseRoute(deps.database, deps.heldKeys, orgId, entryKey, recorded, context, call);
780
+ }
781
+ async function retireWorkerApp(deps, org, entryKey, context, call) {
782
+ const recorded = context.recordedIds();
783
+ const mainId = recorded[workerAppResourceKey(`${org.id}:${entryKey}`)];
784
+ const scriptName = mainId || recorded[workerAppUploadedScopeKey(entryKey)];
785
+ await teardownWorkerApp(deps, entryKey, scriptName, recorded, call, org.id, context);
786
+ for (const scopeKey of workerAppTraceScopeKeys(entryKey))
787
+ context.recordScopeId(scopeKey, '');
788
+ // `runSteps` writes the main id only from a truthy `resourceId`, so the marker keeps the scan from retiring it forever.
789
+ if (mainId)
790
+ context.recordScopeId(workerAppRetiredScopeKey(entryKey), mainId);
791
+ return {};
792
+ }