@wtfalch/foundry 0.21.1 → 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 +28 -0
- package/dist/plan/platform-diff.js +29 -3
- package/dist/plan/platform-org.d.ts +28 -0
- package/dist/plan/platform-org.js +17 -0
- package/dist/plan/platform-providers.d.ts +12 -1
- package/dist/plan/platform-providers.js +93 -6
- 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
|
/**
|
|
@@ -9,7 +9,7 @@ import { type AdoptCandidate, type AdoptInput, type AdoptPlan } from './adopt.js
|
|
|
9
9
|
import { type AgoraHostPort } from './agora.js';
|
|
10
10
|
import { type StepAction } from './diff.js';
|
|
11
11
|
import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
|
|
12
|
-
import { type HeldKeyResolver } from './platform-org.js';
|
|
12
|
+
import { type EdgePortName, type HeldKeyResolver } from './platform-org.js';
|
|
13
13
|
import type { CallsSfuConnectionPort } from './platform-sfu.js';
|
|
14
14
|
/** Who did it, for `@wtfalch/audit`'s "who, when, the plan, the result" (#9, #19). */
|
|
15
15
|
export interface AuditActor {
|
|
@@ -142,6 +142,8 @@ export interface StepExecutionContext {
|
|
|
142
142
|
* scope key can never collide with the `system:kind:key` form.
|
|
143
143
|
*/
|
|
144
144
|
recordScopeId(key: string, id: string): void;
|
|
145
|
+
/** Removes a recorded id (any key form). Optional: a context without it cannot forget one. */
|
|
146
|
+
forgetId?(key: string): void;
|
|
145
147
|
/**
|
|
146
148
|
* Writes every id recorded so far to the store now, not only at the end of
|
|
147
149
|
* the run. For an id that must survive a crash mid-step: the ai key's id
|
|
@@ -161,6 +163,14 @@ export interface StepExecutor {
|
|
|
161
163
|
execute(action: StepAction, context: StepExecutionContext): Promise<{
|
|
162
164
|
resourceId?: string;
|
|
163
165
|
} | undefined>;
|
|
166
|
+
/**
|
|
167
|
+
* #555: which of the edge-routed ports (`dns`, `zones`, `access`,
|
|
168
|
+
* `ratelimits`, `tunnels`) this executor was built with its own
|
|
169
|
+
* implementation of, which it uses before edge. The planner blocks a step
|
|
170
|
+
* that needs edge only if the port it needs is not listed here. Optional:
|
|
171
|
+
* an executor without it supplies none.
|
|
172
|
+
*/
|
|
173
|
+
ownEdgePorts?(): Partial<Record<EdgePortName, boolean>>;
|
|
164
174
|
}
|
|
165
175
|
/**
|
|
166
176
|
* #167: {@link StepExecutionContext}'s platform-scoped counterpart -- what
|
|
@@ -212,6 +222,10 @@ export interface PlatformStepExecutor {
|
|
|
212
222
|
hc?: string;
|
|
213
223
|
/** #540: see `PlanPlatformOptions.blocked.heartbeatsExecutorMissing` (`platform-diff.ts`). */
|
|
214
224
|
heartbeatsExecutorMissing?: boolean;
|
|
225
|
+
/** See `PlanPlatformOptions.blocked.archonRunsInsideApp`. */
|
|
226
|
+
archonRunsInsideApp?: boolean;
|
|
227
|
+
/** See `PlanPlatformOptions.blocked.archonDeployRunning`. */
|
|
228
|
+
archonDeployRunning?: string;
|
|
215
229
|
}>;
|
|
216
230
|
/**
|
|
217
231
|
* Steps whose live state differs from the declared one in a way the recorded
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -13,7 +13,7 @@ import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from
|
|
|
13
13
|
import { readLivePlatformResources, readLiveResources, } from './live.js';
|
|
14
14
|
import { lockedHostnames } from './locked-hostnames.js';
|
|
15
15
|
import { PLATFORM_ARCHON_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_KEY, appStepKey, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
|
|
16
|
-
import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
|
|
16
|
+
import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, dnsBlockedReason, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
|
|
17
17
|
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
18
18
|
import { liveWorkers, workerTraceScopeKeys } from './worker.js';
|
|
19
19
|
/** #441: the lease scope that serializes every mutation of one company, and the one for the shared platform. */
|
|
@@ -773,6 +773,9 @@ export class FoundryPlanApply {
|
|
|
773
773
|
recordScopeId: (key, id) => {
|
|
774
774
|
serviceIds[key] = id;
|
|
775
775
|
},
|
|
776
|
+
forgetId: (key) => {
|
|
777
|
+
delete serviceIds[key];
|
|
778
|
+
},
|
|
776
779
|
persistScopeIds: async () => {
|
|
777
780
|
await this.store.putAppliedState({
|
|
778
781
|
orgId,
|
|
@@ -938,7 +941,13 @@ export class FoundryPlanApply {
|
|
|
938
941
|
catch {
|
|
939
942
|
// not held
|
|
940
943
|
}
|
|
941
|
-
return {
|
|
944
|
+
return {
|
|
945
|
+
...readiness,
|
|
946
|
+
mailKeyHeld,
|
|
947
|
+
canHoldSecrets: typeof heldKeys?.put === 'function',
|
|
948
|
+
dnsBlocked: dnsBlockedReason(platform, recorded),
|
|
949
|
+
ownEdgePorts: this.executor.ownEdgePorts?.(),
|
|
950
|
+
};
|
|
942
951
|
}
|
|
943
952
|
/**
|
|
944
953
|
* #177: `AgoraHostValues` from the applied platform, the same
|
|
@@ -1171,7 +1180,7 @@ function destroyTargets(serviceIds, orgId) {
|
|
|
1171
1180
|
continue;
|
|
1172
1181
|
}
|
|
1173
1182
|
for (const [resourceKey, id] of Object.entries(serviceIds)) {
|
|
1174
|
-
if (!resourceKey.startsWith(prefix))
|
|
1183
|
+
if (!resourceKey.startsWith(prefix) || !id)
|
|
1175
1184
|
continue;
|
|
1176
1185
|
// Archon#271: foundry did not create an adopted app or database, so it never deletes it.
|
|
1177
1186
|
if (adopted.has(resourceKey))
|
|
@@ -1224,7 +1233,7 @@ function destroyTargets(serviceIds, orgId) {
|
|
|
1224
1233
|
targets.push({ system: 'edge', kind: 'key', key: entryKey, id });
|
|
1225
1234
|
}
|
|
1226
1235
|
for (const [resourceKey, id] of Object.entries(serviceIds)) {
|
|
1227
|
-
if (!resourceKey.startsWith('zitadel:app:'))
|
|
1236
|
+
if (!resourceKey.startsWith('zitadel:app:') || !id)
|
|
1228
1237
|
continue;
|
|
1229
1238
|
targets.push({
|
|
1230
1239
|
system: 'zitadel',
|
|
@@ -135,6 +135,15 @@ export declare const PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY = "archon
|
|
|
135
135
|
* values until someone redeploys by hand (#467).
|
|
136
136
|
*/
|
|
137
137
|
export declare const PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY = "archon_envs_deployed_hash";
|
|
138
|
+
/**
|
|
139
|
+
* The uuid of the deployment Archon's step triggered without waiting
|
|
140
|
+
* (`PlatformArchonDeps.runsInsideApp`), `pending` between the trigger and the
|
|
141
|
+
* moment its uuid is known. The next plan reads that deployment's status:
|
|
142
|
+
* running blocks the step, failed or unknown plans it again, finished lets the
|
|
143
|
+
* next run of the step confirm it (the integrations key-synced marker) and
|
|
144
|
+
* clear this.
|
|
145
|
+
*/
|
|
146
|
+
export declare const PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY = "archon_deployment";
|
|
138
147
|
/**
|
|
139
148
|
* #211 round 4: the broker app id Archon last registered its entry with.
|
|
140
149
|
* `provisionPlatformArchon` reads the broker to register with from
|
|
@@ -414,6 +423,16 @@ export declare function platformMailMxHost(apex: string): string;
|
|
|
414
423
|
*/
|
|
415
424
|
export declare const ORG_MAIL_APPLIED_APEX_SCOPE_KEY = "mail_applied_apex";
|
|
416
425
|
export declare const ORG_MAIL_APPLIED_HOST_SCOPE_KEY = "mail_applied_host";
|
|
426
|
+
/**
|
|
427
|
+
* #554: the mode and `usesDomain` the step last applied. No reader lists a
|
|
428
|
+
* live `coolify mail <org>` resource, so `planMail` decides "applied" from
|
|
429
|
+
* the record. Records written before these keys existed lack them; a
|
|
430
|
+
* missing mode vouches for `hosted` only, a missing `usesDomain` only when
|
|
431
|
+
* the declared domain equals the recorded `mail_domain`. A park records
|
|
432
|
+
* mode `off`.
|
|
433
|
+
*/
|
|
434
|
+
export declare const ORG_MAIL_APPLIED_MODE_SCOPE_KEY = "mail_applied_mode";
|
|
435
|
+
export declare const ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY = "mail_applied_uses_domain";
|
|
417
436
|
/**
|
|
418
437
|
* The key of a hosting entry in a `coolify:app` or `cloudflare:access` step
|
|
419
438
|
* and in the ids recorded for it: `<orgId>:<entry key>`, so a second entry
|
|
@@ -845,6 +864,15 @@ export interface PlanPlatformOptions {
|
|
|
845
864
|
* steps" note in `platform-providers.ts`'s `FoundryPlatformStepExecutor.blockedSteps`.
|
|
846
865
|
*/
|
|
847
866
|
heartbeatsApiKeyMissing?: boolean;
|
|
867
|
+
/**
|
|
868
|
+
* The host runs inside Archon's app (`PlatformArchonDeps.runsInsideApp`):
|
|
869
|
+
* Archon's step redeploys it, so it is ordered last in the plan, after
|
|
870
|
+
* every step that would otherwise be lost when the old container stops.
|
|
871
|
+
* Not a block.
|
|
872
|
+
*/
|
|
873
|
+
archonRunsInsideApp?: boolean;
|
|
874
|
+
/** Why Archon's step must wait: the deployment it triggered is still running. Blocks even an applied step. */
|
|
875
|
+
archonDeployRunning?: string;
|
|
848
876
|
};
|
|
849
877
|
/**
|
|
850
878
|
* Steps whose live state differs from the declared one in a way the recorded
|
|
@@ -156,6 +156,15 @@ export const PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY = 'archon_integra
|
|
|
156
156
|
* values until someone redeploys by hand (#467).
|
|
157
157
|
*/
|
|
158
158
|
export const PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY = 'archon_envs_deployed_hash';
|
|
159
|
+
/**
|
|
160
|
+
* The uuid of the deployment Archon's step triggered without waiting
|
|
161
|
+
* (`PlatformArchonDeps.runsInsideApp`), `pending` between the trigger and the
|
|
162
|
+
* moment its uuid is known. The next plan reads that deployment's status:
|
|
163
|
+
* running blocks the step, failed or unknown plans it again, finished lets the
|
|
164
|
+
* next run of the step confirm it (the integrations key-synced marker) and
|
|
165
|
+
* clear this.
|
|
166
|
+
*/
|
|
167
|
+
export const PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY = 'archon_deployment';
|
|
159
168
|
/**
|
|
160
169
|
* #211 round 4: the broker app id Archon last registered its entry with.
|
|
161
170
|
* `provisionPlatformArchon` reads the broker to register with from
|
|
@@ -451,6 +460,16 @@ export function platformMailMxHost(apex) {
|
|
|
451
460
|
*/
|
|
452
461
|
export const ORG_MAIL_APPLIED_APEX_SCOPE_KEY = 'mail_applied_apex';
|
|
453
462
|
export const ORG_MAIL_APPLIED_HOST_SCOPE_KEY = 'mail_applied_host';
|
|
463
|
+
/**
|
|
464
|
+
* #554: the mode and `usesDomain` the step last applied. No reader lists a
|
|
465
|
+
* live `coolify mail <org>` resource, so `planMail` decides "applied" from
|
|
466
|
+
* the record. Records written before these keys existed lack them; a
|
|
467
|
+
* missing mode vouches for `hosted` only, a missing `usesDomain` only when
|
|
468
|
+
* the declared domain equals the recorded `mail_domain`. A park records
|
|
469
|
+
* mode `off`.
|
|
470
|
+
*/
|
|
471
|
+
export const ORG_MAIL_APPLIED_MODE_SCOPE_KEY = 'mail_applied_mode';
|
|
472
|
+
export const ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY = 'mail_applied_uses_domain';
|
|
454
473
|
/**
|
|
455
474
|
* The key of a hosting entry in a `coolify:app` or `cloudflare:access` step
|
|
456
475
|
* and in the ids recorded for it: `<orgId>:<entry key>`, so a second entry
|
|
@@ -969,8 +988,10 @@ export function planPlatform(platform, live, options = {}) {
|
|
|
969
988
|
// #175: last, on purpose. Archon's sign-in client lives in the sign-in
|
|
970
989
|
// `planAuth` builds, and its env reads what every other service's step
|
|
971
990
|
// records or delivers, so in one apply it runs after all of them.
|
|
972
|
-
const archon = planArchon(platform, recordedIds, blocked.archon ?? dnsBlocked?.('Archon'), authReady, drifted.archon);
|
|
973
|
-
|
|
991
|
+
const archon = planArchon(platform, recordedIds, blocked.archon ?? dnsBlocked?.('Archon'), authReady, drifted.archon, blocked.archonDeployRunning);
|
|
992
|
+
// The host runs inside this app: its step ends by redeploying the process
|
|
993
|
+
// that runs the plan, so it goes after everything else (pushed below).
|
|
994
|
+
if (archon.step && !blocked.archonRunsInsideApp)
|
|
974
995
|
plannedSteps.push(archon.step);
|
|
975
996
|
// #257: after every service above, so a database any of them just
|
|
976
997
|
// recorded is already visible to `recordedIds` on this same plan.
|
|
@@ -982,6 +1003,8 @@ export function planPlatform(platform, live, options = {}) {
|
|
|
982
1003
|
const heartbeats = planHeartbeats(platform, recordedIds, hcReady, blocked.heartbeatsExecutorMissing ?? false, blocked.heartbeatsApiKeyMissing ?? false);
|
|
983
1004
|
if (heartbeats.step)
|
|
984
1005
|
plannedSteps.push(heartbeats.step);
|
|
1006
|
+
if (archon.step && blocked.archonRunsInsideApp)
|
|
1007
|
+
plannedSteps.push(archon.step);
|
|
985
1008
|
// #251: one line per service whose recorded applied ref no longer matches
|
|
986
1009
|
// what the next apply would use (or, for zitadel, what is actually live).
|
|
987
1010
|
// Never pushed into `plannedSteps` -- see `VersionChangeItem`'s own doc
|
|
@@ -2227,9 +2250,12 @@ function planAi(platform, recordedIds, executorBlocked) {
|
|
|
2227
2250
|
* ({@link PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY}) plans the step again, as
|
|
2228
2251
|
* {@link planAuth} does.
|
|
2229
2252
|
*/
|
|
2230
|
-
function planArchon(platform, recordedIds, executorBlocked, authReady, drifted) {
|
|
2253
|
+
function planArchon(platform, recordedIds, executorBlocked, authReady, drifted, deployRunning) {
|
|
2231
2254
|
if (!platform.archon.enabled)
|
|
2232
2255
|
return { step: null, blocked: null };
|
|
2256
|
+
// A deploy the step triggered is still running: planning it again would deploy twice.
|
|
2257
|
+
if (deployRunning)
|
|
2258
|
+
return { step: null, blocked: deployRunning };
|
|
2233
2259
|
const key = resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY);
|
|
2234
2260
|
const applied = Boolean(recordedIds[key]) &&
|
|
2235
2261
|
Boolean(recordedIds[PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY]) &&
|
|
@@ -35,6 +35,8 @@ export declare const NO_DOMAIN_MAIL_APEX = "oikos.host";
|
|
|
35
35
|
* names the same "positional arguments that shifted on every merge" problem
|
|
36
36
|
* this avoids).
|
|
37
37
|
*/
|
|
38
|
+
/** The edge-routed ports an executor may be given its own implementation of. */
|
|
39
|
+
export type EdgePortName = 'dns' | 'zones' | 'access' | 'ratelimits' | 'tunnels';
|
|
38
40
|
export interface OrgPlatformReadiness {
|
|
39
41
|
/**
|
|
40
42
|
* `null` until the platform's own mail server (#169) is enabled and
|
|
@@ -96,6 +98,22 @@ export interface OrgPlatformReadiness {
|
|
|
96
98
|
* used to say there was none; N1 is no longer deployed by hand).
|
|
97
99
|
*/
|
|
98
100
|
edgeReady: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* #555: why a company step that writes through edge cannot run yet, `null`
|
|
103
|
+
* or absent when none is blocked. DNS records, zones, Access apps, rate
|
|
104
|
+
* limit rules and tunnels all go through edge unless the host supplies its
|
|
105
|
+
* own port ({@link ownEdgePorts}); while edge is not applied, `diff.ts`
|
|
106
|
+
* reports each such step blocked instead of planning one that would fail
|
|
107
|
+
* half way.
|
|
108
|
+
*/
|
|
109
|
+
dnsBlocked?: string | null;
|
|
110
|
+
/**
|
|
111
|
+
* #555: the edge-routed ports the host's executor supplies itself
|
|
112
|
+
* (`FoundryStepExecutorPorts.dns` / `zones` / `access` / `ratelimits` /
|
|
113
|
+
* `tunnels`). The executor uses these before edge, so a step that needs
|
|
114
|
+
* only these is not blocked by {@link dnsBlocked}.
|
|
115
|
+
*/
|
|
116
|
+
ownEdgePorts?: Partial<Record<EdgePortName, boolean>>;
|
|
99
117
|
/** `https://<platform.edge.hostname>`, which an entry with `uses.edge` gets as `EDGE_URL`. `null` until `edgeReady`. */
|
|
100
118
|
edgeUrl?: string | null;
|
|
101
119
|
/**
|
|
@@ -149,6 +167,16 @@ export interface HeldKeyResolver {
|
|
|
149
167
|
* (which could carry provider detail) goes nowhere.
|
|
150
168
|
*/
|
|
151
169
|
export declare function heldPlatformKeyRefs(heldKeys: HeldKeyResolver): Promise<Set<string>>;
|
|
170
|
+
/**
|
|
171
|
+
* #555: the reason no company step may write DNS yet, `null` when one may.
|
|
172
|
+
* Every DNS record, zone, Access app, rate limit rule and tunnel call goes
|
|
173
|
+
* through edge unless the host supplies its own port
|
|
174
|
+
* ({@link OrgPlatformReadiness.ownEdgePorts}), so edge must be enabled and
|
|
175
|
+
* applied (the same check as `edgeReady`, without the host-port half). {@link FoundryPlanApply} sets
|
|
176
|
+
* {@link OrgPlatformReadiness.dnsBlocked} from this; the pure
|
|
177
|
+
* {@link orgPlatformReadinessFrom} leaves it unset (nothing blocked).
|
|
178
|
+
*/
|
|
179
|
+
export declare function dnsBlockedReason(platform: DeclaredPlatform, platformRecordedIds: Readonly<Record<string, string>>): string | null;
|
|
152
180
|
/**
|
|
153
181
|
* `FoundryPlanApply.computeFreshPlan`'s own precedent for turning a
|
|
154
182
|
* `PlatformStore`'s `get()`/`getServiceIds()` into what an org's plan needs.
|
|
@@ -63,6 +63,23 @@ export async function heldPlatformKeyRefs(heldKeys) {
|
|
|
63
63
|
function coolifyAppApplied(platformRecordedIds, key) {
|
|
64
64
|
return Boolean(platformRecordedIds[`coolify:app:${key}`]);
|
|
65
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* #555: the reason no company step may write DNS yet, `null` when one may.
|
|
68
|
+
* Every DNS record, zone, Access app, rate limit rule and tunnel call goes
|
|
69
|
+
* through edge unless the host supplies its own port
|
|
70
|
+
* ({@link OrgPlatformReadiness.ownEdgePorts}), so edge must be enabled and
|
|
71
|
+
* applied (the same check as `edgeReady`, without the host-port half). {@link FoundryPlanApply} sets
|
|
72
|
+
* {@link OrgPlatformReadiness.dnsBlocked} from this; the pure
|
|
73
|
+
* {@link orgPlatformReadinessFrom} leaves it unset (nothing blocked).
|
|
74
|
+
*/
|
|
75
|
+
export function dnsBlockedReason(platform, platformRecordedIds) {
|
|
76
|
+
if (!platform.edge.enabled) {
|
|
77
|
+
return 'every DNS record is written through edge, and the platform does not enable edge';
|
|
78
|
+
}
|
|
79
|
+
return coolifyAppApplied(platformRecordedIds, PLATFORM_EDGE_KEY)
|
|
80
|
+
? null
|
|
81
|
+
: 'every DNS record is written through edge, and edge is not applied yet';
|
|
82
|
+
}
|
|
66
83
|
/**
|
|
67
84
|
* `FoundryPlanApply.computeFreshPlan`'s own precedent for turning a
|
|
68
85
|
* `PlatformStore`'s `get()`/`getServiceIds()` into what an org's plan needs.
|
|
@@ -290,6 +290,8 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
|
|
|
290
290
|
hc?: string;
|
|
291
291
|
heartbeatsExecutorMissing?: boolean;
|
|
292
292
|
heartbeatsApiKeyMissing?: boolean;
|
|
293
|
+
archonRunsInsideApp?: boolean;
|
|
294
|
+
archonDeployRunning?: string;
|
|
293
295
|
}>;
|
|
294
296
|
/**
|
|
295
297
|
* Whether Archon's live Access policy lets through anyone but the declared
|
|
@@ -474,7 +476,7 @@ export interface PlatformMailRuntime {
|
|
|
474
476
|
* {@link PlatformStepExecutionContext}, bundled like {@link PlatformAuthDeps}.
|
|
475
477
|
*/
|
|
476
478
|
export interface PlatformArchonDeps {
|
|
477
|
-
coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw' | 'bulkUpsertEnvs' | 'listEnvs' | 'deleteEnv' | 'updateApplication' | 'deploy' | 'deployAndWait'>;
|
|
479
|
+
coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw' | 'bulkUpsertEnvs' | 'listEnvs' | 'deleteEnv' | 'updateApplication' | 'deploy' | 'deployAndWait' | 'getDeployment'>;
|
|
478
480
|
/** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
|
|
479
481
|
dns: PlatformDnsFactory;
|
|
480
482
|
/** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
|
|
@@ -493,6 +495,15 @@ export interface PlatformArchonDeps {
|
|
|
493
495
|
* Access app nobody can pass.
|
|
494
496
|
*/
|
|
495
497
|
accessEmails: readonly string[];
|
|
498
|
+
/**
|
|
499
|
+
* True when the host process runs inside the Coolify app this step
|
|
500
|
+
* redeploys (Archon's console is `platform:archon`). A deploy that waits
|
|
501
|
+
* would be killed with the old container, so the step then records every
|
|
502
|
+
* applied marker first, and triggers the deploy last, without waiting.
|
|
503
|
+
* The planner puts the step last in the plan. Next plan: the step runs
|
|
504
|
+
* again only if the app is not running (`archonRedeployFailed`).
|
|
505
|
+
*/
|
|
506
|
+
runsInsideApp?: boolean;
|
|
496
507
|
/**
|
|
497
508
|
* Reads a secret an earlier step handed to the host through
|
|
498
509
|
* `deliverSecret`, or `null` when none is held under `ref` yet -- the same
|
|
@@ -8,7 +8,7 @@ import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment,
|
|
|
8
8
|
import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
|
|
9
9
|
import { healthCheckSettings } from './health-check-settings.js';
|
|
10
10
|
import { coolifyDomains, filesShareHostname } from './hostnames.js';
|
|
11
|
-
import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, } from './platform-diff.js';
|
|
11
|
+
import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, } from './platform-diff.js';
|
|
12
12
|
import { applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
13
13
|
import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
|
|
14
14
|
/** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
|
|
@@ -318,6 +318,18 @@ export class FoundryPlatformStepExecutor {
|
|
|
318
318
|
if (!apiKey)
|
|
319
319
|
blocked.heartbeatsApiKeyMissing = true;
|
|
320
320
|
}
|
|
321
|
+
// Not a block: tells the planner to order Archon's step last (`PlanPlatformOptions.blocked.archonRunsInsideApp`).
|
|
322
|
+
if (this.archon?.runsInsideApp) {
|
|
323
|
+
blocked.archonRunsInsideApp = true;
|
|
324
|
+
// A deploy the step triggered and did not wait for: while it runs, a
|
|
325
|
+
// second deploy must not be planned.
|
|
326
|
+
const deployment = recordedIds[PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY];
|
|
327
|
+
if (platform.archon.enabled &&
|
|
328
|
+
deployment &&
|
|
329
|
+
(await archonDeploymentState(this.archon.coolify, deployment)) === 'running') {
|
|
330
|
+
blocked.archonDeployRunning = `Archon is blocked, not planned: its deployment ${deployment} is still running; plan again when it has finished`;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
321
333
|
return blocked;
|
|
322
334
|
}
|
|
323
335
|
/**
|
|
@@ -347,7 +359,18 @@ export class FoundryPlatformStepExecutor {
|
|
|
347
359
|
!brokerDrifted &&
|
|
348
360
|
!bootstrapOperatorDrifted &&
|
|
349
361
|
(await archonRollingUpdateDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]));
|
|
350
|
-
|
|
362
|
+
// The deployment the step triggered failed or Coolify does not know it:
|
|
363
|
+
// plan the step again, which deploys again. A finished one is confirmed
|
|
364
|
+
// by the next run of the step (keys-synced), see `provisionPlatformArchon`.
|
|
365
|
+
const deployment = recordedIds[PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY];
|
|
366
|
+
const redeployFailed = this.archon.runsInsideApp === true &&
|
|
367
|
+
Boolean(deployment) &&
|
|
368
|
+
(await archonDeploymentState(this.archon.coolify, deployment)) === 'failed';
|
|
369
|
+
if (accessDrifted ||
|
|
370
|
+
brokerDrifted ||
|
|
371
|
+
bootstrapOperatorDrifted ||
|
|
372
|
+
rollingUpdateDrifted ||
|
|
373
|
+
redeployFailed)
|
|
351
374
|
result.archon = true;
|
|
352
375
|
}
|
|
353
376
|
if (platform.auth.enabled && this.auth) {
|
|
@@ -2600,6 +2623,7 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2600
2623
|
const appliedGitRef = recorded[PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY];
|
|
2601
2624
|
const runtime = deps.runtime ?? {};
|
|
2602
2625
|
const sleep = runtime.sleep ?? defaultSleep;
|
|
2626
|
+
const selfRedeploy = deps.runsInsideApp === true;
|
|
2603
2627
|
if (deps.accessEmails.length === 0) {
|
|
2604
2628
|
throw new Error('platform:archon: no Access emails given; refusing to put Archon behind an Access app nobody can pass');
|
|
2605
2629
|
}
|
|
@@ -3030,7 +3054,17 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
3030
3054
|
// patch above actually takes effect on the running container, not just on
|
|
3031
3055
|
// Coolify's stored config.
|
|
3032
3056
|
const rollingUpdateApplied = recorded[PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY] === '1';
|
|
3033
|
-
|
|
3057
|
+
// The deployment an earlier run triggered without waiting: read before
|
|
3058
|
+
// deciding whether to deploy again.
|
|
3059
|
+
const priorDeployment = selfRedeploy ? recorded[PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY] : undefined;
|
|
3060
|
+
const deployState = priorDeployment
|
|
3061
|
+
? await archonDeploymentState(deps.coolify, priorDeployment)
|
|
3062
|
+
: 'none';
|
|
3063
|
+
if (deployState === 'running' || deployState === 'unreadable') {
|
|
3064
|
+
throw new Error(`platform:archon: the deployment ${priorDeployment} cannot be confirmed yet (${deployState === 'running' ? 'still running' : 'status unreadable'}); plan again later`);
|
|
3065
|
+
}
|
|
3066
|
+
const archonDeployedNow = (keyUnsynced && deployState !== 'finished') ||
|
|
3067
|
+
deployState === 'failed' ||
|
|
3034
3068
|
app.justCreated ||
|
|
3035
3069
|
!app.running ||
|
|
3036
3070
|
changed ||
|
|
@@ -3039,7 +3073,7 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
3039
3073
|
appliedGitRef !== gitRef ||
|
|
3040
3074
|
!rollingUpdateApplied ||
|
|
3041
3075
|
recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname;
|
|
3042
|
-
if (archonDeployedNow) {
|
|
3076
|
+
if (archonDeployedNow && !selfRedeploy) {
|
|
3043
3077
|
// #467: cleared before the deploy below is even attempted, not after --
|
|
3044
3078
|
// a deploy that then fails, or a crash before `waitForHealthyVersion`
|
|
3045
3079
|
// passes, must leave this run's attempt unconfirmed. Recording it only
|
|
@@ -3111,13 +3145,14 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
3111
3145
|
// Coolify's own deployment record finished, which is what actually closes
|
|
3112
3146
|
// the loophole named in #252 (an old container staying healthy while the
|
|
3113
3147
|
// deployment itself fails).
|
|
3114
|
-
if (archonDeployedNow) {
|
|
3148
|
+
if (archonDeployedNow && !selfRedeploy) {
|
|
3115
3149
|
await waitForHealthyVersion(appId, { kind: 'ready', url: `https://${hostname}/api/health` }, runtime, sleep, 'platform:archon', `archon stays at ${appliedGitRef ?? 'no recorded ref'} on ${recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] ?? 'no recorded hostname'}`);
|
|
3116
3150
|
}
|
|
3117
3151
|
// #398: the deployment finished and answers health, so Archon now runs with
|
|
3118
3152
|
// the key id its env carries. `provisionPlatformIntegrations` drops the
|
|
3119
3153
|
// previous public key only on this marker.
|
|
3120
|
-
|
|
3154
|
+
// With `runsInsideApp` only once Coolify's deployment record says it finished.
|
|
3155
|
+
if (keyUnsynced && integrationsKeyId && (!selfRedeploy || deployState === 'finished')) {
|
|
3121
3156
|
await record(PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, integrationsKeyId);
|
|
3122
3157
|
}
|
|
3123
3158
|
// #475: confirmed only now, after the health check above has passed on a
|
|
@@ -3140,6 +3175,36 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
3140
3175
|
// it.
|
|
3141
3176
|
await record(PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, brokerAppId);
|
|
3142
3177
|
await record(PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, bootstrapOperator ?? 'none');
|
|
3178
|
+
// The host runs inside this app: every marker is recorded above, so the
|
|
3179
|
+
// deploy goes last and is not waited for -- the old container stops during
|
|
3180
|
+
// it, and the response, the lease and the audit row must be done first.
|
|
3181
|
+
if (selfRedeploy && !archonDeployedNow && deployState === 'finished') {
|
|
3182
|
+
await record(PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, ''); // confirmed above
|
|
3183
|
+
}
|
|
3184
|
+
if (selfRedeploy && archonDeployedNow) {
|
|
3185
|
+
await record(`coolify:app:${PLATFORM_ARCHON_KEY}`, appId);
|
|
3186
|
+
await record(PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, 'pending');
|
|
3187
|
+
let deploymentUuid;
|
|
3188
|
+
try {
|
|
3189
|
+
// `force` takes the branch that always creates a real deployment record
|
|
3190
|
+
// (a skipped one answers with a uuid Coolify never stored).
|
|
3191
|
+
const started = (await deps.coolify.deploy(appId, true));
|
|
3192
|
+
const uuid = Array.isArray(started) ? started[0]?.deployment_uuid : undefined;
|
|
3193
|
+
if (typeof uuid !== 'string' || !uuid) {
|
|
3194
|
+
throw new Error('platform:archon: Coolify answered the deploy with no deployment_uuid');
|
|
3195
|
+
}
|
|
3196
|
+
deploymentUuid = uuid;
|
|
3197
|
+
}
|
|
3198
|
+
catch (error) {
|
|
3199
|
+
// Not triggered: forget the applied ref so the next plan runs the step again.
|
|
3200
|
+
await record(PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, '');
|
|
3201
|
+
await record(PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, '');
|
|
3202
|
+
throw error;
|
|
3203
|
+
}
|
|
3204
|
+
// The next plan reads this deployment's status; a kill before this write
|
|
3205
|
+
// leaves `pending`, which that plan reads as unconfirmed and plans again.
|
|
3206
|
+
await record(PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, deploymentUuid);
|
|
3207
|
+
}
|
|
3143
3208
|
return { resourceId: appId };
|
|
3144
3209
|
}
|
|
3145
3210
|
// #211 round 6: mirrors `wtfalch/auth/broker/src/apps.ts`'s own `isValidFrom`
|
|
@@ -3560,6 +3625,28 @@ async function archonBootstrapOperatorDrifted(coolify, appId, recordedIds) {
|
|
|
3560
3625
|
* version of this check never actually fired. `custom_network_aliases` and
|
|
3561
3626
|
* `health_check_enabled` are both top-level there.
|
|
3562
3627
|
*/
|
|
3628
|
+
/**
|
|
3629
|
+
* The state of the deployment Archon's step triggered without waiting
|
|
3630
|
+
* (`PlatformArchonDeps.runsInsideApp`), from Coolify's own deployment record:
|
|
3631
|
+
* `running` (queued or in progress), `finished`, `failed` (failed, cancelled,
|
|
3632
|
+
* its uuid never recorded, or unknown to Coolify -- a 404), or `unreadable`
|
|
3633
|
+
* for any other error. Status and a served request cannot tell a failed build
|
|
3634
|
+
* from a success (the old container keeps serving), so this record is the
|
|
3635
|
+
* only honest answer.
|
|
3636
|
+
*/
|
|
3637
|
+
async function archonDeploymentState(coolify, deploymentUuid) {
|
|
3638
|
+
if (deploymentUuid === 'pending')
|
|
3639
|
+
return 'failed';
|
|
3640
|
+
try {
|
|
3641
|
+
const { status } = await coolify.getDeployment(deploymentUuid);
|
|
3642
|
+
if (status === 'queued' || status === 'in_progress')
|
|
3643
|
+
return 'running';
|
|
3644
|
+
return status === 'finished' ? 'finished' : 'failed';
|
|
3645
|
+
}
|
|
3646
|
+
catch (error) {
|
|
3647
|
+
return isNotFoundError(error) ? 'failed' : 'unreadable';
|
|
3648
|
+
}
|
|
3649
|
+
}
|
|
3563
3650
|
async function archonRollingUpdateDrifted(coolify, appId) {
|
|
3564
3651
|
if (!appId)
|
|
3565
3652
|
return false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/foundry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"calls-sfu:deploy": "node scripts/calls-sfu-deploy.mjs"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
+
"@wtfalch/providers": ">=0.7.4",
|
|
40
41
|
"drizzle-orm": ">=0.45.2"
|
|
41
42
|
},
|
|
42
43
|
"dependencies": {
|
|
@@ -44,7 +45,7 @@
|
|
|
44
45
|
"@wtfalch/auth": "0.16.0",
|
|
45
46
|
"@wtfalch/edge": "0.9.0",
|
|
46
47
|
"@wtfalch/email": "^0.17.0",
|
|
47
|
-
"@wtfalch/providers": "0.7.
|
|
48
|
+
"@wtfalch/providers": "0.7.5",
|
|
48
49
|
"ssh2": "1.17.0",
|
|
49
50
|
"zod": "4.6.5"
|
|
50
51
|
},
|