@wtfalch/foundry 0.21.0 → 0.22.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/plan/diff.js +176 -22
- package/dist/plan/executor.d.ts +21 -4
- package/dist/plan/executor.js +104 -36
- package/dist/plan/plan-apply.d.ts +15 -1
- package/dist/plan/plan-apply.js +13 -4
- package/dist/plan/platform-diff.d.ts +34 -0
- package/dist/plan/platform-diff.js +44 -6
- package/dist/plan/platform-org.d.ts +28 -0
- package/dist/plan/platform-org.js +17 -0
- package/dist/plan/platform-providers.d.ts +14 -3
- package/dist/plan/platform-providers.js +132 -12
- package/package.json +3 -2
package/dist/plan/diff.js
CHANGED
|
@@ -9,8 +9,8 @@ import { planFilesKeys } from './entry-files-key.js';
|
|
|
9
9
|
import { appHostname, entryHostnames, tunnelDnsHostnames } from './hostnames.js';
|
|
10
10
|
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
|
|
11
11
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
|
|
12
|
-
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appEnvAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, } from './platform-diff.js';
|
|
13
|
-
import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
|
|
12
|
+
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appEnvAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, } from './platform-diff.js';
|
|
13
|
+
import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
|
|
14
14
|
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
15
15
|
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec } from './proxy-labels.js';
|
|
16
16
|
import { withStepSystem } from './step-system.js';
|
|
@@ -106,6 +106,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
106
106
|
const zone = planZone(org, byKey, claimed);
|
|
107
107
|
const zoneWaiting = zone.blocked !== null;
|
|
108
108
|
const zoneBlocked = [];
|
|
109
|
+
// #555: steps that write DNS records, which go through edge.
|
|
110
|
+
const dnsBlocked = [];
|
|
111
|
+
// #553: a ZITADEL app provision whose app id is recorded needs the typed confirmation.
|
|
112
|
+
const guardApp = (step) => guardAppProvision(step, org, recordedIds);
|
|
109
113
|
const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost, platformReadiness.platformApex, platformReadiness.platformLettermintProjectId);
|
|
110
114
|
// #238: own-domain mail publishes DNS records into the zone, so it waits for
|
|
111
115
|
// an active zone -- blocked, not planned (rule 2).
|
|
@@ -115,6 +119,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
115
119
|
key: 'coolify:mail:zone',
|
|
116
120
|
});
|
|
117
121
|
}
|
|
122
|
+
else if (mail.step && edgeNeeded(platformReadiness, ['zones', 'dns'])) {
|
|
123
|
+
// #555: its zone lookup and DNS records go through edge.
|
|
124
|
+
dnsBlocked.push(edgeBlockedItem(org, 'mail', 'coolify:mail', edgeNeeded(platformReadiness, ['zones', 'dns'])));
|
|
125
|
+
}
|
|
118
126
|
else if (mail.step) {
|
|
119
127
|
plannedSteps.push(mail.step);
|
|
120
128
|
}
|
|
@@ -166,8 +174,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
166
174
|
// step's recorded client id, the same way `coolifyApp` runs after the
|
|
167
175
|
// hosted app's own `zitadel:app` steps.
|
|
168
176
|
const cmsClient = planCmsClient(org, byKey, claimed);
|
|
169
|
-
|
|
170
|
-
|
|
177
|
+
const cmsGuarded = guardApp(cmsClient);
|
|
178
|
+
if (cmsGuarded)
|
|
179
|
+
plannedSteps.push(cmsGuarded);
|
|
171
180
|
const site = planSite(org, byKey, claimed);
|
|
172
181
|
if (site)
|
|
173
182
|
plannedSteps.push(site);
|
|
@@ -182,11 +191,29 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
182
191
|
const customers = planCustomers(org, live, claimed, recordedIds);
|
|
183
192
|
if (customers)
|
|
184
193
|
plannedSteps.push(customers);
|
|
185
|
-
|
|
186
|
-
|
|
194
|
+
for (const step of [
|
|
195
|
+
...planIssuerApps(org, byKey, claimed),
|
|
196
|
+
...planEntryClients(org, byKey, claimed),
|
|
197
|
+
]) {
|
|
198
|
+
const guarded = guardApp(step);
|
|
199
|
+
if (guarded)
|
|
200
|
+
plannedSteps.push(guarded);
|
|
201
|
+
}
|
|
187
202
|
// The Access gate before the database, app, tunnel and zone (#84): on a
|
|
188
203
|
// first apply the hostname is gated before anything answers on it.
|
|
189
|
-
|
|
204
|
+
// An Access gate that cannot be planned must not leave the hostname it
|
|
205
|
+
// guards routed: the tunnel (and its DNS) that exposes it waits too.
|
|
206
|
+
let accessGateBlocked = false;
|
|
207
|
+
for (const step of planAccess(org, recordedIds)) {
|
|
208
|
+
const reason = edgeNeeded(platformReadiness, ['access']);
|
|
209
|
+
if (reason) {
|
|
210
|
+
accessGateBlocked = true;
|
|
211
|
+
dnsBlocked.push(edgeBlockedItem(org, `Access gate for ${step.action.key}`, 'cloudflare:access', reason));
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
plannedSteps.push(step);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
190
217
|
// Database before app (hosting-wiring campaign): `plan-apply.ts`'s
|
|
191
218
|
// `runSteps` executes `plannedSteps` in this array order, and the app's own
|
|
192
219
|
// step (`executor.ts`'s `coolifyApp`) pushes the database's connection
|
|
@@ -217,7 +244,21 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
217
244
|
const tunnel = planTunnel(org, byKey, claimed, recordedIds);
|
|
218
245
|
// #238: a tunnel that is being provisioned waits for an active zone; parking
|
|
219
246
|
// one never does.
|
|
220
|
-
|
|
247
|
+
// #555: provisioning writes the tunnel config, the zone lookup and DNS; a
|
|
248
|
+
// park only writes the tunnel config (`cloudflareTunnel`'s `enabled === false`
|
|
249
|
+
// branch), so it needs the tunnels port alone.
|
|
250
|
+
const tunnelEdgeReason = tunnel
|
|
251
|
+
? edgeNeeded(platformReadiness, tunnel.action.desired.enabled === false ? ['tunnels'] : ['tunnels', 'zones', 'dns'])
|
|
252
|
+
: null;
|
|
253
|
+
// Parking closes a hostname, so only a provision waits for a blocked gate.
|
|
254
|
+
const tunnelReason = tunnelEdgeReason ??
|
|
255
|
+
(tunnel && accessGateBlocked && tunnel.action.desired.enabled !== false
|
|
256
|
+
? 'its Access gate is blocked, and the tunnel and DNS would expose the hostname without it'
|
|
257
|
+
: null);
|
|
258
|
+
if (tunnel && tunnelReason) {
|
|
259
|
+
dnsBlocked.push(edgeBlockedItem(org, 'Cloudflare tunnel', 'cloudflare:tunnel', tunnelReason));
|
|
260
|
+
}
|
|
261
|
+
else if (tunnel && zoneWaiting && tunnel.action.desired.enabled !== false) {
|
|
221
262
|
zoneBlocked.push({
|
|
222
263
|
description: `${org.slug}'s Cloudflare tunnel is blocked, not planned: the zone for ${org.domain} is not active yet`,
|
|
223
264
|
key: 'cloudflare:tunnel',
|
|
@@ -226,13 +267,22 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
226
267
|
else if (tunnel) {
|
|
227
268
|
plannedSteps.push(tunnel);
|
|
228
269
|
}
|
|
229
|
-
|
|
270
|
+
const zoneReason = zone.step ? edgeNeeded(platformReadiness, ['zones']) : null;
|
|
271
|
+
if (zone.step && zoneReason) {
|
|
272
|
+
dnsBlocked.push(edgeBlockedItem(org, 'Cloudflare zone', 'cloudflare:zone', zoneReason));
|
|
273
|
+
}
|
|
274
|
+
else if (zone.step) {
|
|
230
275
|
plannedSteps.push(zone.step);
|
|
276
|
+
}
|
|
231
277
|
// Right after the zone (#43): `cloudflareRateLimit` resolves the zone by
|
|
232
278
|
// apex domain, so the zone step above must already have run (or the zone
|
|
233
279
|
// must already exist live) by the time this one's action executes.
|
|
234
280
|
const rateLimit = planRateLimit(org, recordedIds);
|
|
235
|
-
|
|
281
|
+
const rateLimitReason = rateLimit ? edgeNeeded(platformReadiness, ['zones', 'ratelimits']) : null;
|
|
282
|
+
if (rateLimit && rateLimitReason) {
|
|
283
|
+
dnsBlocked.push(edgeBlockedItem(org, 'sign-in rate limit', 'cloudflare:ratelimit', rateLimitReason));
|
|
284
|
+
}
|
|
285
|
+
else if (rateLimit && zoneWaiting) {
|
|
236
286
|
zoneBlocked.push({
|
|
237
287
|
description: `${org.slug}'s sign-in rate limit is blocked, not planned: the zone for ${org.domain} is not active yet`,
|
|
238
288
|
key: 'cloudflare:ratelimit',
|
|
@@ -246,8 +296,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
246
296
|
// Its fixed ZITADEL client before the Coolify app it signs into:
|
|
247
297
|
// `coolifyAgora` reads this step's recorded client id.
|
|
248
298
|
const agoraClient = planAgoraClient(org, byKey, claimed);
|
|
249
|
-
|
|
250
|
-
|
|
299
|
+
const agoraGuarded = guardApp(agoraClient);
|
|
300
|
+
if (agoraGuarded)
|
|
301
|
+
plannedSteps.push(agoraGuarded);
|
|
251
302
|
const agora = planAgora(org, byKey, claimed, recordedIds, agoraHost);
|
|
252
303
|
if (agora.step)
|
|
253
304
|
plannedSteps.push(agora.step);
|
|
@@ -255,8 +306,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
255
306
|
// shape as Agora and planned right after it. Its fixed ZITADEL client first,
|
|
256
307
|
// for the same reason: `coolifyBoule` reads that step's recorded client id.
|
|
257
308
|
const bouleClient = planBouleClient(org, byKey, claimed);
|
|
258
|
-
|
|
259
|
-
|
|
309
|
+
const bouleGuarded = guardApp(bouleClient);
|
|
310
|
+
if (bouleGuarded)
|
|
311
|
+
plannedSteps.push(bouleGuarded);
|
|
260
312
|
const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null);
|
|
261
313
|
if (boule.step)
|
|
262
314
|
plannedSteps.push(boule.step);
|
|
@@ -285,7 +337,12 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
285
337
|
// retire, which needs no new zone) still waits for it.
|
|
286
338
|
const edgeWorkers = planWorkers(org, recordedIds, options.edgeWorkers ?? false, options.platformHostnames ?? []);
|
|
287
339
|
for (const step of edgeWorkers.steps) {
|
|
288
|
-
|
|
340
|
+
// A retire never looks up the zone (`cloudflareWorker`), so only a deploy needs edge.
|
|
341
|
+
const workerReason = step.action.desired.mode === 'apply' ? edgeNeeded(platformReadiness, ['zones']) : null;
|
|
342
|
+
if (workerReason) {
|
|
343
|
+
dnsBlocked.push(edgeBlockedItem(org, `${step.action.key.slice(org.id.length + 1)} Worker`, step.action.key, workerReason));
|
|
344
|
+
}
|
|
345
|
+
else if (zoneWaiting && step.action.desired.mode === 'apply') {
|
|
289
346
|
zoneBlocked.push({
|
|
290
347
|
description: `${org.slug}'s ${step.action.key.slice(org.id.length + 1)} Worker is blocked, not planned: the zone for ${org.domain} is not active yet`,
|
|
291
348
|
key: step.action.key,
|
|
@@ -361,6 +418,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
361
418
|
if (zone.blocked)
|
|
362
419
|
unmanaged.push({ description: zone.blocked, key: 'cloudflare:zone' });
|
|
363
420
|
unmanaged.push(...zoneBlocked);
|
|
421
|
+
unmanaged.push(...dnsBlocked);
|
|
364
422
|
// #243: an appEnv source nothing will record is reported, not silently skipped.
|
|
365
423
|
unmanaged.push(...app.blocked);
|
|
366
424
|
unmanaged.push(...brokerEntries.blocked);
|
|
@@ -372,6 +430,52 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
372
430
|
versionChanges,
|
|
373
431
|
};
|
|
374
432
|
}
|
|
433
|
+
/**
|
|
434
|
+
* #553: a plain ZITADEL app provision (not a `recreate`) is planned only when
|
|
435
|
+
* the live listing lacks the app by name. If an app id is recorded for it,
|
|
436
|
+
* the listing may simply have missed a live app, and nothing can read an app
|
|
437
|
+
* by id, so applying silently could replace a live sign-in client. The way
|
|
438
|
+
* out is the destroying `recreate` path: the step is planned as a
|
|
439
|
+
* replacement that asks for the typed confirmation, so a person who has
|
|
440
|
+
* checked the app is gone (deleted by hand in ZITADEL) says so, and apply
|
|
441
|
+
* then deletes the recorded id (a 404 is fine) and creates a new app.
|
|
442
|
+
* (A failed recreate clears the recorded id, so it is not caught here.)
|
|
443
|
+
*/
|
|
444
|
+
function guardAppProvision(step, org, recordedIds) {
|
|
445
|
+
if (!step || step.action.system !== 'zitadel' || step.action.kind !== 'app')
|
|
446
|
+
return step;
|
|
447
|
+
if (step.action.desired.recreate === true)
|
|
448
|
+
return step;
|
|
449
|
+
const recorded = recordedIds[`zitadel:app:${step.action.key}`];
|
|
450
|
+
if (!recorded)
|
|
451
|
+
return step;
|
|
452
|
+
return {
|
|
453
|
+
action: { ...step.action, desired: { ...step.action.desired, recreate: true } },
|
|
454
|
+
step: {
|
|
455
|
+
description: `replace ZITADEL app '${step.action.key}' for ${org.slug}: recorded app ${recorded} is not in ZITADEL's listing (a listing can miss a live app; if it is live, do not confirm -- apply deletes it and creates a new client id)`,
|
|
456
|
+
destroys: true,
|
|
457
|
+
migrates: false,
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* #555: why a step that needs these edge-routed ports cannot run, or `null`.
|
|
463
|
+
* `readiness.dnsBlocked` is set while edge is not applied; a port the host
|
|
464
|
+
* supplies itself (`readiness.ownEdgePorts`, mirroring the executor's
|
|
465
|
+
* `ports.dns` / `zones` / `access` / `ratelimits` / `tunnels`, which win over
|
|
466
|
+
* edge) needs no edge, so only a step that would really reach edge is blocked.
|
|
467
|
+
*/
|
|
468
|
+
function edgeNeeded(readiness, needs) {
|
|
469
|
+
if (!readiness.dnsBlocked)
|
|
470
|
+
return null;
|
|
471
|
+
return needs.every((port) => readiness.ownEdgePorts?.[port]) ? null : readiness.dnsBlocked;
|
|
472
|
+
}
|
|
473
|
+
function edgeBlockedItem(org, what, key, reason) {
|
|
474
|
+
return {
|
|
475
|
+
description: `${org.slug}'s ${what} is blocked, not planned: ${reason ?? ''} (#555)`,
|
|
476
|
+
key,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
375
479
|
/**
|
|
376
480
|
* The org's own ZITADEL org (issuer), a per-org singleton like every other
|
|
377
481
|
* planner in this file, with one extra wrinkle: unlike mail/chat/site/app,
|
|
@@ -776,7 +880,24 @@ platformApex, platformLettermintProjectId) {
|
|
|
776
880
|
const roleNote = rolesOwed > 0 ? `create ${rolesOwed} role mailbox(es) for ${org.slug}` : '';
|
|
777
881
|
const mailboxWork = [ownerNote, roleNote].filter(Boolean).join(' and ');
|
|
778
882
|
const mailboxNote = mailboxWork ? ` and ${mailboxWork}` : '';
|
|
779
|
-
|
|
883
|
+
// #554: no reader lists a live `coolify mail <org>` resource, so "applied"
|
|
884
|
+
// comes from the record: the domain, mode and `usesDomain` the step last
|
|
885
|
+
// wrote (`coolifyMail`). A changed declaration differs from it and plans.
|
|
886
|
+
const recordedDomain = recordedIds[MAIL_DOMAIN_SCOPE_KEY];
|
|
887
|
+
const recordedMode = recordedIds[ORG_MAIL_APPLIED_MODE_SCOPE_KEY];
|
|
888
|
+
const recordedUsesDomain = recordedIds[ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY];
|
|
889
|
+
const domainMatches = Boolean(recordedDomain && org.domain && recordedDomain.toLowerCase() === org.domain.toLowerCase());
|
|
890
|
+
const appliedFromRecord = !live &&
|
|
891
|
+
Boolean(recordedDomain) &&
|
|
892
|
+
// Before `mail_applied_mode` existed, only a received or hosted apply
|
|
893
|
+
// recorded `mail_domain` (`off` returns before it); production's record is
|
|
894
|
+
// hosted. An unrecorded mode vouches for `hosted` only.
|
|
895
|
+
(recordedMode === undefined ? desired.mode === 'hosted' : recordedMode === desired.mode) &&
|
|
896
|
+
(recordedUsesDomain === undefined
|
|
897
|
+
? desired.usesDomain && domainMatches
|
|
898
|
+
: recordedUsesDomain === String(desired.usesDomain) &&
|
|
899
|
+
(!desired.usesDomain || domainMatches));
|
|
900
|
+
if (!live && !appliedFromRecord) {
|
|
780
901
|
return {
|
|
781
902
|
step: {
|
|
782
903
|
action: { system: 'coolify', kind: 'mail', key: org.id, desired },
|
|
@@ -790,8 +911,8 @@ platformApex, platformLettermintProjectId) {
|
|
|
790
911
|
mailboxBlocked,
|
|
791
912
|
};
|
|
792
913
|
}
|
|
793
|
-
const liveMode = live.config.mode;
|
|
794
|
-
const liveUsesDomain = Boolean(live.config.usesDomain);
|
|
914
|
+
const liveMode = live ? live.config.mode : desired.mode;
|
|
915
|
+
const liveUsesDomain = live ? Boolean(live.config.usesDomain) : desired.usesDomain;
|
|
795
916
|
if (liveMode === desired.mode && liveUsesDomain === desired.usesDomain) {
|
|
796
917
|
// #177: the platform's apex or mail host moved since this step last wrote
|
|
797
918
|
// them (or it never recorded them). Re-applying is idempotent.
|
|
@@ -1476,7 +1597,12 @@ function planCmsClient(org, byKey, claimed) {
|
|
|
1476
1597
|
if (live.config.appType === 'web')
|
|
1477
1598
|
return null;
|
|
1478
1599
|
return {
|
|
1479
|
-
action: {
|
|
1600
|
+
action: {
|
|
1601
|
+
system: 'zitadel',
|
|
1602
|
+
kind: 'app',
|
|
1603
|
+
key: 'cms',
|
|
1604
|
+
desired: { ...desired, recreate: true },
|
|
1605
|
+
},
|
|
1480
1606
|
step: {
|
|
1481
1607
|
description: `recreate ZITADEL app 'cms' for ${org.slug} as web (was ${String(live.config.appType)})`,
|
|
1482
1608
|
destroys: true,
|
|
@@ -1580,7 +1706,12 @@ function planAgoraClient(org, byKey, claimed) {
|
|
|
1580
1706
|
if (live.config.appType === 'web')
|
|
1581
1707
|
return null;
|
|
1582
1708
|
return {
|
|
1583
|
-
action: {
|
|
1709
|
+
action: {
|
|
1710
|
+
system: 'zitadel',
|
|
1711
|
+
kind: 'app',
|
|
1712
|
+
key: 'agora',
|
|
1713
|
+
desired: { ...desired, recreate: true },
|
|
1714
|
+
},
|
|
1584
1715
|
step: {
|
|
1585
1716
|
description: `recreate ZITADEL app 'agora' for ${org.slug} as web (was ${String(live.config.appType)})`,
|
|
1586
1717
|
destroys: true,
|
|
@@ -1718,7 +1849,12 @@ function planBouleClient(org, byKey, claimed) {
|
|
|
1718
1849
|
if (live.config.appType === 'web')
|
|
1719
1850
|
return null;
|
|
1720
1851
|
return {
|
|
1721
|
-
action: {
|
|
1852
|
+
action: {
|
|
1853
|
+
system: 'zitadel',
|
|
1854
|
+
kind: 'app',
|
|
1855
|
+
key: 'boule',
|
|
1856
|
+
desired: { ...desired, recreate: true },
|
|
1857
|
+
},
|
|
1722
1858
|
step: {
|
|
1723
1859
|
description: `recreate ZITADEL app 'boule' for ${org.slug} as web (was ${String(live.config.appType)})`,
|
|
1724
1860
|
destroys: true,
|
|
@@ -2168,6 +2304,19 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
|
|
|
2168
2304
|
platformLettermintProjectId: readiness.platformLettermintProjectId,
|
|
2169
2305
|
};
|
|
2170
2306
|
}
|
|
2307
|
+
// #555: the `app` entry's step publishes the mail domain's DNS records
|
|
2308
|
+
// (`ensureAppLettermintMail`) once one is recorded, and those go through
|
|
2309
|
+
// edge: blocked, not planned, while edge is not applied.
|
|
2310
|
+
const appReason = edgeNeeded(readiness, ['zones', 'dns']);
|
|
2311
|
+
if (appReason &&
|
|
2312
|
+
key === APP_ENTRY_KEY &&
|
|
2313
|
+
app.step &&
|
|
2314
|
+
app.step.action.desired.enabled !== false &&
|
|
2315
|
+
recordedIds[MAIL_DOMAIN_SCOPE_KEY]) {
|
|
2316
|
+
blocked.push(edgeBlockedItem(org, 'app', 'coolify:app', appReason));
|
|
2317
|
+
blocked.push(...app.blocked);
|
|
2318
|
+
continue;
|
|
2319
|
+
}
|
|
2171
2320
|
if (app.step)
|
|
2172
2321
|
steps.push(app.step);
|
|
2173
2322
|
blocked.push(...app.blocked);
|
|
@@ -2458,7 +2607,12 @@ function planIssuerApps(org, byKey, claimed) {
|
|
|
2458
2607
|
if (live.config.appType === app.kind)
|
|
2459
2608
|
continue;
|
|
2460
2609
|
steps.push({
|
|
2461
|
-
action: {
|
|
2610
|
+
action: {
|
|
2611
|
+
system: 'zitadel',
|
|
2612
|
+
kind: 'app',
|
|
2613
|
+
key: app.name,
|
|
2614
|
+
desired: { ...desired, recreate: true },
|
|
2615
|
+
},
|
|
2462
2616
|
step: {
|
|
2463
2617
|
description: `recreate ZITADEL app '${app.name}' for ${org.slug} as ${app.kind} (was ${String(live.config.appType)})`,
|
|
2464
2618
|
destroys: true,
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { type EdgePlatformZones, type ZonePort } from './edge-zones.js';
|
|
|
12
12
|
import { type LoginHostBindingsPort } from './login-host.js';
|
|
13
13
|
import { type MailCopyPort } from './mail-copy.js';
|
|
14
14
|
import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
|
|
15
|
+
import type { EdgePortName } from './platform-org.js';
|
|
15
16
|
/**
|
|
16
17
|
* The real {@link StepExecutor} (#20 "provision Company Factory end to
|
|
17
18
|
* end"): makes each {@link StepAction} `diff.ts` plans true against a real
|
|
@@ -421,6 +422,16 @@ export interface CoolifyPort {
|
|
|
421
422
|
deploymentUuid: string;
|
|
422
423
|
status: string;
|
|
423
424
|
}>;
|
|
425
|
+
/**
|
|
426
|
+
* One deployment's status by its uuid (`queued`, `in_progress`, `finished`,
|
|
427
|
+
* `failed`, `cancelled-by-user`); throws a 404 for a uuid Coolify does not
|
|
428
|
+
* know. Matches `CoolifyClient.getDeployment` method-for-method. The
|
|
429
|
+
* platform's Archon step reads it to confirm a deploy it triggered and did
|
|
430
|
+
* not wait for.
|
|
431
|
+
*/
|
|
432
|
+
getDeployment(deploymentUuid: string): Promise<{
|
|
433
|
+
status: string;
|
|
434
|
+
}>;
|
|
424
435
|
}
|
|
425
436
|
/**
|
|
426
437
|
* The Cloudflare zone calls the platform steps make directly, shaped to match
|
|
@@ -799,6 +810,8 @@ export interface ZitadelPort {
|
|
|
799
810
|
appType: 'web' | 'native' | 'api';
|
|
800
811
|
state: string;
|
|
801
812
|
loginBaseUri?: string | null;
|
|
813
|
+
/** Present only when the host's listing reads it; `zitadelApp` adopts with it, else with the recorded one. */
|
|
814
|
+
clientId?: string;
|
|
802
815
|
}>;
|
|
803
816
|
complete: boolean;
|
|
804
817
|
}>;
|
|
@@ -987,10 +1000,10 @@ export interface ZitadelPort {
|
|
|
987
1000
|
* listApps` -- a plain array, with no `loginBaseUri` or completeness signal
|
|
988
1001
|
* -- into {@link ZitadelPort.listApps}'s own shape, so a host wiring that
|
|
989
1002
|
* real client in satisfies this port without reaching into `executor.ts`
|
|
990
|
-
* itself to build the wrapper by hand. `complete:
|
|
991
|
-
* the real client
|
|
992
|
-
*
|
|
993
|
-
*
|
|
1003
|
+
* itself to build the wrapper by hand. `complete: true` on every call:
|
|
1004
|
+
* since providers 0.7.4 the real client pages `_search` to the end. A host
|
|
1005
|
+
* that supplies its own `listApps` keeps whatever `complete` it reports.
|
|
1006
|
+
* Each app's `clientId` (providers 0.7.5) passes through. `loginBaseUri` comes back `undefined`
|
|
994
1007
|
* for every app for the identical reason -- the real client does not read
|
|
995
1008
|
* it back at all yet, not "read it back as absent" -- which is exactly the
|
|
996
1009
|
* state `zitadelLoginHost`'s fail-closed rule exists for: every pending
|
|
@@ -1011,6 +1024,8 @@ export interface ZitadelListAppsRawApp {
|
|
|
1011
1024
|
name: string;
|
|
1012
1025
|
appType: 'web' | 'native' | 'api';
|
|
1013
1026
|
state: string;
|
|
1027
|
+
/** providers 0.7.5: the app's client id. Passed through to `ZitadelPort.listApps`. */
|
|
1028
|
+
clientId?: string;
|
|
1014
1029
|
}
|
|
1015
1030
|
/**
|
|
1016
1031
|
* Provisions one org's organisation on Files (#80), matching
|
|
@@ -1555,6 +1570,8 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1555
1570
|
private edgeZones;
|
|
1556
1571
|
private edgeWriteSlot;
|
|
1557
1572
|
constructor(ports: FoundryStepExecutorPorts);
|
|
1573
|
+
/** #555: the edge-routed ports this executor was built with itself (they win over edge). */
|
|
1574
|
+
ownEdgePorts(): Partial<Record<EdgePortName, boolean>>;
|
|
1558
1575
|
execute(action: StepAction, context: StepExecutionContext): Promise<{
|
|
1559
1576
|
resourceId?: string;
|
|
1560
1577
|
} | undefined>;
|
package/dist/plan/executor.js
CHANGED
|
@@ -23,7 +23,7 @@ import { healthCheckSettings } from './health-check-settings.js';
|
|
|
23
23
|
import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
24
24
|
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
25
25
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
26
|
-
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, } from './platform-diff.js';
|
|
26
|
+
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, } from './platform-diff.js';
|
|
27
27
|
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
28
28
|
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
|
|
29
29
|
import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
|
|
@@ -32,10 +32,10 @@ import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerR
|
|
|
32
32
|
* listApps` -- a plain array, with no `loginBaseUri` or completeness signal
|
|
33
33
|
* -- into {@link ZitadelPort.listApps}'s own shape, so a host wiring that
|
|
34
34
|
* real client in satisfies this port without reaching into `executor.ts`
|
|
35
|
-
* itself to build the wrapper by hand. `complete:
|
|
36
|
-
* the real client
|
|
37
|
-
*
|
|
38
|
-
*
|
|
35
|
+
* itself to build the wrapper by hand. `complete: true` on every call:
|
|
36
|
+
* since providers 0.7.4 the real client pages `_search` to the end. A host
|
|
37
|
+
* that supplies its own `listApps` keeps whatever `complete` it reports.
|
|
38
|
+
* Each app's `clientId` (providers 0.7.5) passes through. `loginBaseUri` comes back `undefined`
|
|
39
39
|
* for every app for the identical reason -- the real client does not read
|
|
40
40
|
* it back at all yet, not "read it back as absent" -- which is exactly the
|
|
41
41
|
* state `zitadelLoginHost`'s fail-closed rule exists for: every pending
|
|
@@ -45,7 +45,9 @@ import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerR
|
|
|
45
45
|
export function adaptZitadelListApps(client) {
|
|
46
46
|
return async (orgId, projectId) => ({
|
|
47
47
|
apps: await client.listApps(orgId, projectId),
|
|
48
|
-
|
|
48
|
+
// Sound because package.json requires providers >=0.7.4 as a peer: from
|
|
49
|
+
// there `listApps` pages `_search` to the end, so this read every app.
|
|
50
|
+
complete: true,
|
|
49
51
|
});
|
|
50
52
|
}
|
|
51
53
|
// ---------------------------------------------------------------------------
|
|
@@ -731,6 +733,16 @@ export class FoundryStepExecutor {
|
|
|
731
733
|
constructor(ports) {
|
|
732
734
|
this.ports = ports;
|
|
733
735
|
}
|
|
736
|
+
/** #555: the edge-routed ports this executor was built with itself (they win over edge). */
|
|
737
|
+
ownEdgePorts() {
|
|
738
|
+
return {
|
|
739
|
+
dns: Boolean(this.ports.dns),
|
|
740
|
+
zones: Boolean(this.ports.zones),
|
|
741
|
+
access: Boolean(this.ports.access),
|
|
742
|
+
ratelimits: Boolean(this.ports.ratelimits),
|
|
743
|
+
tunnels: Boolean(this.ports.tunnels),
|
|
744
|
+
};
|
|
745
|
+
}
|
|
734
746
|
async execute(action, context) {
|
|
735
747
|
const { org } = context;
|
|
736
748
|
switch (`${action.system}:${action.kind}`) {
|
|
@@ -1459,14 +1471,69 @@ export class FoundryStepExecutor {
|
|
|
1459
1471
|
throw new Error(`zitadel:app '${action.key}' for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
1460
1472
|
}
|
|
1461
1473
|
const projectId = await this.ensureZitadelProject(org, zitadelOrgId, context);
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1474
|
+
const isCmsClient = action.key === 'cms' && org.site.enabled;
|
|
1475
|
+
const isAgoraClient = action.key === 'agora';
|
|
1476
|
+
const isBouleClient = action.key === 'boule';
|
|
1477
|
+
const authEntry = org.hosting.entries.find((e) => e.key === action.key && e.uses?.auth);
|
|
1478
|
+
const clientIdScopeKey = isCmsClient
|
|
1479
|
+
? CMS_CLIENT_ID_SCOPE_KEY
|
|
1480
|
+
: isAgoraClient
|
|
1481
|
+
? AGORA_CLIENT_ID_SCOPE_KEY
|
|
1482
|
+
: isBouleClient
|
|
1483
|
+
? BOULE_CLIENT_ID_SCOPE_KEY
|
|
1484
|
+
: authEntry
|
|
1485
|
+
? entryClientIdScopeKey(action.key)
|
|
1486
|
+
: APP_CLIENT_ID_SCOPE_KEY;
|
|
1468
1487
|
const priorAppId = recorded[`zitadel:app:${action.key}`];
|
|
1469
|
-
|
|
1488
|
+
// Only a step the planner flagged as destroying (`recreate`, set with
|
|
1489
|
+
// `destroys: true` so plan-apply asks for the typed confirmation) may
|
|
1490
|
+
// delete an app. A plain provision adopts a live app instead: one with
|
|
1491
|
+
// the recorded id, or one of the same name in the project (an app an
|
|
1492
|
+
// earlier apply created whose response was lost). ZITADEL's listing can
|
|
1493
|
+
// miss a live app (it once dropped both company sign-in clients on
|
|
1494
|
+
// production), and there is no read by app id, so a recorded app the
|
|
1495
|
+
// listing does not show is never replaced: the planner reports it
|
|
1496
|
+
// blocked, and this throws if a stale plan reaches it.
|
|
1497
|
+
if (action.desired.recreate !== true) {
|
|
1498
|
+
const listing = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
|
|
1499
|
+
const liveApps = listing.apps.filter((app) => app.state !== 'APP_STATE_REMOVED');
|
|
1500
|
+
const byId = priorAppId ? liveApps.find((app) => app.id === priorAppId) : undefined;
|
|
1501
|
+
const adopted = byId ?? liveApps.find((app) => app.name === action.key && app.appType === appType);
|
|
1502
|
+
if (adopted) {
|
|
1503
|
+
// Not adopted silently: a wrong type is a recreate (destroying, typed
|
|
1504
|
+
// confirmation) and an inactive app is a person's decision. Neither
|
|
1505
|
+
// can be known from the plan's name-keyed live read, so this throws.
|
|
1506
|
+
if (adopted.appType !== appType || adopted.state !== 'APP_STATE_ACTIVE') {
|
|
1507
|
+
throw new Error(`zitadel:app '${action.key}' for ${org.slug}: app ${adopted.id} is live as a ${adopted.appType} app in state ${adopted.state}, not an active ${appType} app -- nothing was created or deleted`);
|
|
1508
|
+
}
|
|
1509
|
+
if (appType === 'api') {
|
|
1510
|
+
// The secret was minted once, at creation; an app matched only by
|
|
1511
|
+
// name has no recorded delivery, and nothing can recover it.
|
|
1512
|
+
if (adopted.id !== priorAppId) {
|
|
1513
|
+
throw new Error(`zitadel:app '${action.key}' for ${org.slug}: an api app ${adopted.id} of that name exists but was never recorded, so its secret cannot be recovered -- nothing was created or deleted; delete that app in ZITADEL, then apply again to mint a new one`);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
else {
|
|
1517
|
+
// A client id recorded for another app is never carried over.
|
|
1518
|
+
const clientId = adopted.clientId ??
|
|
1519
|
+
(adopted.id === priorAppId ? recorded[clientIdScopeKey] : undefined);
|
|
1520
|
+
if (!clientId) {
|
|
1521
|
+
throw new Error(`zitadel:app '${action.key}' for ${org.slug}: app ${adopted.id} is live but the listing gives no client id for it -- nothing was created or deleted`);
|
|
1522
|
+
}
|
|
1523
|
+
context.recordScopeId(clientIdScopeKey, clientId);
|
|
1524
|
+
}
|
|
1525
|
+
return { resourceId: adopted.id };
|
|
1526
|
+
}
|
|
1527
|
+
if (priorAppId) {
|
|
1528
|
+
throw new Error(`zitadel:app '${action.key}' for ${org.slug}: recorded app ${priorAppId} is not in the listing, and a listing can miss a live app -- nothing was created or deleted; plan again and confirm the replacement step if the app is really gone`);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
// A kind change (`recreate`) deletes the recorded app then recreates it
|
|
1532
|
+
// (task brief); a 404 (already gone) is tolerated the same way `destroy`
|
|
1533
|
+
// tolerates one -- a retry after the delete half already succeeded but
|
|
1534
|
+
// the recreate half failed must find an already-deleted app a no-op, not
|
|
1535
|
+
// a new error that wedges the apply forever.
|
|
1536
|
+
if (action.desired.recreate === true && priorAppId) {
|
|
1470
1537
|
try {
|
|
1471
1538
|
await this.ports.zitadel.deleteApp(zitadelOrgId, projectId, priorAppId);
|
|
1472
1539
|
}
|
|
@@ -1474,16 +1541,22 @@ export class FoundryStepExecutor {
|
|
|
1474
1541
|
if (!isNotFoundError(error))
|
|
1475
1542
|
throw error;
|
|
1476
1543
|
}
|
|
1544
|
+
// Gone now: a retry after a failed create must not find this id and be
|
|
1545
|
+
// blocked as "recorded but missing" (`diff.ts`'s `guardAppProvision`).
|
|
1546
|
+
// Removed (not blanked) and persisted now: a death before the create
|
|
1547
|
+
// must not leave the stale id behind, nor an empty one for destroy.
|
|
1548
|
+
if (context.forgetId)
|
|
1549
|
+
context.forgetId(`zitadel:app:${action.key}`);
|
|
1550
|
+
else
|
|
1551
|
+
context.recordScopeId(`zitadel:app:${action.key}`, '');
|
|
1552
|
+
await context.persistScopeIds();
|
|
1477
1553
|
// The recreate half is retried on its own failure (the delete above
|
|
1478
1554
|
// already tolerates the resulting 404), so a retry can also land here
|
|
1479
1555
|
// after an earlier attempt's create call actually succeeded against
|
|
1480
|
-
// ZITADEL but whose response this executor never saw
|
|
1481
|
-
//
|
|
1482
|
-
//
|
|
1483
|
-
//
|
|
1484
|
-
// undelivered and the clientId unrecorded forever. The create below
|
|
1485
|
-
// then runs every side effect. A first-ever create (no `priorAppId`)
|
|
1486
|
-
// has nothing to find here and never pays this read.
|
|
1556
|
+
// ZITADEL but whose response this executor never saw. That app is
|
|
1557
|
+
// deleted, not reused: the lost response also lost its clientSecret
|
|
1558
|
+
// and clientId, which `listApps` cannot return, so reusing it would
|
|
1559
|
+
// leave the secret undelivered and the clientId unrecorded forever.
|
|
1487
1560
|
const orphan = await this.findRecreatedApp(zitadelOrgId, projectId, action.key, appType);
|
|
1488
1561
|
if (orphan) {
|
|
1489
1562
|
try {
|
|
@@ -1512,16 +1585,13 @@ export class FoundryStepExecutor {
|
|
|
1512
1585
|
// doc comment relies on. #164: the fixed `agora` client (`diff.ts`'s
|
|
1513
1586
|
// `planAgoraClient`) is the same shape, always -- there is no
|
|
1514
1587
|
// `org.site.enabled`-style guard to check, since Agora is always on.
|
|
1515
|
-
const isCmsClient = action.key === 'cms' && org.site.enabled;
|
|
1516
1588
|
// The Agora client stays `appType: 'web'`: providers' `createOidcApp`
|
|
1517
1589
|
// makes every non-native app a public client (authorization code + PKCE,
|
|
1518
1590
|
// `OIDC_AUTH_METHOD_TYPE_NONE`, no secret), which is what Agora's browser
|
|
1519
1591
|
// SPA needs. Providers cannot create ZITADEL's `USER_AGENT` app kind.
|
|
1520
|
-
const isAgoraClient = action.key === 'agora';
|
|
1521
1592
|
// #161: the fixed `boule` client signs people into the org's Boule at its
|
|
1522
1593
|
// own hostname, on the standard `/auth/callback` path (Boule is built on
|
|
1523
1594
|
// `@wtfalch/auth` like every other web app here).
|
|
1524
|
-
const isBouleClient = action.key === 'boule';
|
|
1525
1595
|
const hostname = isCmsClient
|
|
1526
1596
|
? cmsHostnameFor(org)
|
|
1527
1597
|
: isAgoraClient
|
|
@@ -1542,7 +1612,6 @@ export class FoundryStepExecutor {
|
|
|
1542
1612
|
: `https://${hostname}/auth/callback`;
|
|
1543
1613
|
// An entry with `uses.auth` signs in at its own exact hostnames, one pair
|
|
1544
1614
|
// of addresses each; a `*` adds none (`entryHostnames`).
|
|
1545
|
-
const authEntry = org.hosting.entries.find((e) => e.key === action.key && e.uses?.auth);
|
|
1546
1615
|
const hosts = authEntry ? entryHostnames(org, authEntry) : null;
|
|
1547
1616
|
const created = await this.ports.zitadel.createOidcApp(zitadelOrgId, projectId, {
|
|
1548
1617
|
name: action.key,
|
|
@@ -1567,15 +1636,7 @@ export class FoundryStepExecutor {
|
|
|
1567
1636
|
// plain scope id, not `deliverSecret`. A native app signs in via
|
|
1568
1637
|
// device code with no cookie session of its own, so its clientId is
|
|
1569
1638
|
// never something an app's env needs.
|
|
1570
|
-
context.recordScopeId(
|
|
1571
|
-
? CMS_CLIENT_ID_SCOPE_KEY
|
|
1572
|
-
: isAgoraClient
|
|
1573
|
-
? AGORA_CLIENT_ID_SCOPE_KEY
|
|
1574
|
-
: isBouleClient
|
|
1575
|
-
? BOULE_CLIENT_ID_SCOPE_KEY
|
|
1576
|
-
: authEntry
|
|
1577
|
-
? entryClientIdScopeKey(action.key)
|
|
1578
|
-
: APP_CLIENT_ID_SCOPE_KEY, created.clientId);
|
|
1639
|
+
context.recordScopeId(clientIdScopeKey, created.clientId);
|
|
1579
1640
|
}
|
|
1580
1641
|
return { resourceId: created.appId };
|
|
1581
1642
|
}
|
|
@@ -4715,6 +4776,9 @@ export class FoundryStepExecutor {
|
|
|
4715
4776
|
const parkedMailOrg = mailOrgFor(org, usesDomain, 'hosted');
|
|
4716
4777
|
await this.ports.mail.parkOrgMail(parkedMailOrg, options);
|
|
4717
4778
|
await this.parkLettermintMail(org, parkedMailOrg, context, platformApex, platformLettermintProjectId);
|
|
4779
|
+
// #554: so a later declaration of hosted or received is not mistaken for applied.
|
|
4780
|
+
context.recordScopeId(ORG_MAIL_APPLIED_MODE_SCOPE_KEY, mode);
|
|
4781
|
+
context.recordScopeId(ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, String(usesDomain));
|
|
4718
4782
|
return undefined;
|
|
4719
4783
|
}
|
|
4720
4784
|
const mailOrg = mailOrgFor(org, usesDomain, mode);
|
|
@@ -4722,12 +4786,16 @@ export class FoundryStepExecutor {
|
|
|
4722
4786
|
await this.publishMailDnsRecords(org, result);
|
|
4723
4787
|
await this.deleteStaleLettermintDomain(context, result.domain, platformApex);
|
|
4724
4788
|
context.recordScopeId(MAIL_DOMAIN_SCOPE_KEY, result.domain);
|
|
4725
|
-
// #177: what this step wrote from the platform, so `planMail` can see a later change.
|
|
4726
|
-
context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
|
|
4727
|
-
context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
|
|
4728
4789
|
await this.ensureOwnerMailbox(org, mailOrg, options, context);
|
|
4729
4790
|
await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context, platformApex, platformLettermintProjectId);
|
|
4730
4791
|
await this.ensureRoleMailboxes(org, mailOrg, result.domain, options, context);
|
|
4792
|
+
// #177, #554: what this step wrote, so `planMail` can see a later change
|
|
4793
|
+
// and knows mail is applied. Last on purpose: a crash in the relay or a
|
|
4794
|
+
// mailbox leaves these unrecorded, so the next plan still has the step.
|
|
4795
|
+
context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
|
|
4796
|
+
context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
|
|
4797
|
+
context.recordScopeId(ORG_MAIL_APPLIED_MODE_SCOPE_KEY, mode);
|
|
4798
|
+
context.recordScopeId(ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, String(usesDomain));
|
|
4731
4799
|
return { resourceId: result.domain };
|
|
4732
4800
|
}
|
|
4733
4801
|
/**
|