@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.
- package/dist/bootstrap/versions.d.ts +2 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.js +4 -2
- package/dist/migrations/0055_release_requests.sql +20 -0
- package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
- package/dist/migrations/0057_org_customers.sql +29 -0
- package/dist/org.d.ts +55 -0
- package/dist/org.js +90 -27
- package/dist/plan/archon-blue-green.d.ts +54 -0
- package/dist/plan/archon-blue-green.js +72 -0
- package/dist/plan/blue-green-apply.d.ts +82 -0
- package/dist/plan/blue-green-apply.js +360 -0
- package/dist/plan/blue-green.d.ts +223 -0
- package/dist/plan/blue-green.js +444 -0
- package/dist/plan/boule.d.ts +43 -2
- package/dist/plan/boule.js +48 -1
- package/dist/plan/broker-entries.d.ts +28 -0
- package/dist/plan/broker-entries.js +46 -1
- package/dist/plan/cloudflared-connector.d.ts +26 -0
- package/dist/plan/cloudflared-connector.js +37 -0
- package/dist/plan/diff.d.ts +20 -0
- package/dist/plan/diff.js +207 -20
- package/dist/plan/executor.d.ts +134 -2
- package/dist/plan/executor.js +517 -25
- package/dist/plan/hostnames.d.ts +7 -0
- package/dist/plan/hostnames.js +17 -2
- package/dist/plan/locked-hostnames.d.ts +11 -0
- package/dist/plan/locked-hostnames.js +13 -0
- package/dist/plan/mcp.d.ts +57 -0
- package/dist/plan/mcp.js +131 -0
- package/dist/plan/org-names.d.ts +2 -2
- package/dist/plan/org-names.js +16 -1
- package/dist/plan/plan-apply.d.ts +77 -0
- package/dist/plan/plan-apply.js +267 -5
- package/dist/plan/platform-diff.d.ts +9 -0
- package/dist/plan/platform-diff.js +24 -1
- package/dist/plan/platform-providers.d.ts +15 -1
- package/dist/plan/platform-providers.js +90 -5
- package/dist/plan/proxy-labels.d.ts +2 -2
- package/dist/plan/proxy-labels.js +14 -4
- package/dist/plan/release-host.d.ts +59 -0
- package/dist/plan/release-host.js +239 -0
- package/dist/plan/remote-database.d.ts +124 -0
- package/dist/plan/remote-database.js +373 -0
- package/dist/plan/worker-app-database.d.ts +148 -0
- package/dist/plan/worker-app-database.js +424 -0
- package/dist/plan/worker-app.d.ts +320 -0
- package/dist/plan/worker-app.js +792 -0
- package/dist/plan/zitadel-machine-user.d.ts +13 -1
- package/dist/plan/zitadel-machine-user.js +80 -0
- package/dist/platform.d.ts +11 -1
- package/dist/platform.js +4 -1
- package/dist/release/decide.d.ts +95 -0
- package/dist/release/decide.js +242 -0
- package/dist/release/health.d.ts +48 -0
- package/dist/release/health.js +61 -0
- package/dist/release/run.d.ts +40 -0
- package/dist/release/run.js +56 -0
- package/dist/release/state.d.ts +64 -0
- package/dist/release/state.js +126 -0
- package/dist/release/types.d.ts +54 -0
- package/dist/release/types.js +4 -0
- package/dist/requested-changes.d.ts +12 -3
- package/dist/requested-changes.js +22 -0
- package/dist/store/postgres.d.ts +8 -0
- package/dist/store/postgres.js +67 -8
- package/dist/store/schema.d.ts +188 -0
- package/dist/store/schema.js +15 -0
- package/dist/worker-app-config.d.ts +151 -0
- package/dist/worker-app-config.js +438 -0
- package/package.json +1 -1
- package/versions.digests.json +2 -1
- 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
|
+
}
|