@wtfalch/foundry 0.21.1 → 0.22.1
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 +202 -30
- package/dist/plan/executor.d.ts +33 -28
- package/dist/plan/executor.js +176 -53
- package/dist/plan/hostnames.d.ts +5 -3
- package/dist/plan/hostnames.js +8 -4
- package/dist/plan/login-host.d.ts +17 -2
- package/dist/plan/login-host.js +24 -3
- 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 +14 -1
- package/dist/plan/platform-providers.js +95 -8
- package/package.json +3 -2
package/dist/plan/diff.js
CHANGED
|
@@ -7,10 +7,10 @@ import { planEntryAiKeys } from './entry-ai-key.js';
|
|
|
7
7
|
import { planEdgeReservedLabels, planEntryEdgeKeys } from './entry-edge-key.js';
|
|
8
8
|
import { planFilesKeys } from './entry-files-key.js';
|
|
9
9
|
import { appHostname, entryHostnames, tunnelDnsHostnames } from './hostnames.js';
|
|
10
|
-
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
|
|
10
|
+
import { LOGIN_BINDING_SCOPE_KEY, companyLoginTunnelHost, 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';
|
|
@@ -102,10 +102,23 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
102
102
|
if (loginHost.step)
|
|
103
103
|
plannedSteps.push(loginHost.step);
|
|
104
104
|
// Planned ahead of everything that needs the zone live (#238); its own step
|
|
105
|
-
// is
|
|
105
|
+
// is pushed just below, before mail.
|
|
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);
|
|
113
|
+
// #567: before the first step that reads the zone (mail, the tunnel, the
|
|
114
|
+
// rate limit), so a domain with no zone yet gets it provisioned first.
|
|
115
|
+
const zoneReason = zone.step ? edgeNeeded(platformReadiness, ['zones']) : null;
|
|
116
|
+
if (zone.step && zoneReason) {
|
|
117
|
+
dnsBlocked.push(edgeBlockedItem(org, 'Cloudflare zone', 'cloudflare:zone', zoneReason));
|
|
118
|
+
}
|
|
119
|
+
else if (zone.step) {
|
|
120
|
+
plannedSteps.push(zone.step);
|
|
121
|
+
}
|
|
109
122
|
const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost, platformReadiness.platformApex, platformReadiness.platformLettermintProjectId);
|
|
110
123
|
// #238: own-domain mail publishes DNS records into the zone, so it waits for
|
|
111
124
|
// an active zone -- blocked, not planned (rule 2).
|
|
@@ -115,6 +128,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
115
128
|
key: 'coolify:mail:zone',
|
|
116
129
|
});
|
|
117
130
|
}
|
|
131
|
+
else if (mail.step && edgeNeeded(platformReadiness, ['zones', 'dns'])) {
|
|
132
|
+
// #555: its zone lookup and DNS records go through edge.
|
|
133
|
+
dnsBlocked.push(edgeBlockedItem(org, 'mail', 'coolify:mail', edgeNeeded(platformReadiness, ['zones', 'dns'])));
|
|
134
|
+
}
|
|
118
135
|
else if (mail.step) {
|
|
119
136
|
plannedSteps.push(mail.step);
|
|
120
137
|
}
|
|
@@ -166,8 +183,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
166
183
|
// step's recorded client id, the same way `coolifyApp` runs after the
|
|
167
184
|
// hosted app's own `zitadel:app` steps.
|
|
168
185
|
const cmsClient = planCmsClient(org, byKey, claimed);
|
|
169
|
-
|
|
170
|
-
|
|
186
|
+
const cmsGuarded = guardApp(cmsClient);
|
|
187
|
+
if (cmsGuarded)
|
|
188
|
+
plannedSteps.push(cmsGuarded);
|
|
171
189
|
const site = planSite(org, byKey, claimed);
|
|
172
190
|
if (site)
|
|
173
191
|
plannedSteps.push(site);
|
|
@@ -182,11 +200,29 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
182
200
|
const customers = planCustomers(org, live, claimed, recordedIds);
|
|
183
201
|
if (customers)
|
|
184
202
|
plannedSteps.push(customers);
|
|
185
|
-
|
|
186
|
-
|
|
203
|
+
for (const step of [
|
|
204
|
+
...planIssuerApps(org, byKey, claimed),
|
|
205
|
+
...planEntryClients(org, byKey, claimed),
|
|
206
|
+
]) {
|
|
207
|
+
const guarded = guardApp(step);
|
|
208
|
+
if (guarded)
|
|
209
|
+
plannedSteps.push(guarded);
|
|
210
|
+
}
|
|
187
211
|
// The Access gate before the database, app, tunnel and zone (#84): on a
|
|
188
212
|
// first apply the hostname is gated before anything answers on it.
|
|
189
|
-
|
|
213
|
+
// An Access gate that cannot be planned must not leave the hostname it
|
|
214
|
+
// guards routed: the tunnel (and its DNS) that exposes it waits too.
|
|
215
|
+
let accessGateBlocked = false;
|
|
216
|
+
for (const step of planAccess(org, recordedIds)) {
|
|
217
|
+
const reason = edgeNeeded(platformReadiness, ['access']);
|
|
218
|
+
if (reason) {
|
|
219
|
+
accessGateBlocked = true;
|
|
220
|
+
dnsBlocked.push(edgeBlockedItem(org, `Access gate for ${step.action.key}`, 'cloudflare:access', reason));
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
plannedSteps.push(step);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
190
226
|
// Database before app (hosting-wiring campaign): `plan-apply.ts`'s
|
|
191
227
|
// `runSteps` executes `plannedSteps` in this array order, and the app's own
|
|
192
228
|
// step (`executor.ts`'s `coolifyApp`) pushes the database's connection
|
|
@@ -214,10 +250,24 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
214
250
|
const edgeReservedLabels = planEdgeReservedLabels(org, recordedIds, platformReadiness, options.platformHostnames ?? []);
|
|
215
251
|
if (edgeReservedLabels.step)
|
|
216
252
|
plannedSteps.push(edgeReservedLabels.step);
|
|
217
|
-
const tunnel = planTunnel(org, byKey, claimed, recordedIds);
|
|
253
|
+
const tunnel = planTunnel(org, byKey, claimed, recordedIds, isPlatformOrg, platformReadiness.platformApex);
|
|
218
254
|
// #238: a tunnel that is being provisioned waits for an active zone; parking
|
|
219
255
|
// one never does.
|
|
220
|
-
|
|
256
|
+
// #555: provisioning writes the tunnel config, the zone lookup and DNS; a
|
|
257
|
+
// park only writes the tunnel config (`cloudflareTunnel`'s `enabled === false`
|
|
258
|
+
// branch), so it needs the tunnels port alone.
|
|
259
|
+
const tunnelEdgeReason = tunnel
|
|
260
|
+
? edgeNeeded(platformReadiness, tunnel.action.desired.enabled === false ? ['tunnels'] : ['tunnels', 'zones', 'dns'])
|
|
261
|
+
: null;
|
|
262
|
+
// Parking closes a hostname, so only a provision waits for a blocked gate.
|
|
263
|
+
const tunnelReason = tunnelEdgeReason ??
|
|
264
|
+
(tunnel && accessGateBlocked && tunnel.action.desired.enabled !== false
|
|
265
|
+
? 'its Access gate is blocked, and the tunnel and DNS would expose the hostname without it'
|
|
266
|
+
: null);
|
|
267
|
+
if (tunnel && tunnelReason) {
|
|
268
|
+
dnsBlocked.push(edgeBlockedItem(org, 'Cloudflare tunnel', 'cloudflare:tunnel', tunnelReason));
|
|
269
|
+
}
|
|
270
|
+
else if (tunnel && zoneWaiting && tunnel.action.desired.enabled !== false) {
|
|
221
271
|
zoneBlocked.push({
|
|
222
272
|
description: `${org.slug}'s Cloudflare tunnel is blocked, not planned: the zone for ${org.domain} is not active yet`,
|
|
223
273
|
key: 'cloudflare:tunnel',
|
|
@@ -226,13 +276,15 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
226
276
|
else if (tunnel) {
|
|
227
277
|
plannedSteps.push(tunnel);
|
|
228
278
|
}
|
|
229
|
-
if (zone.step)
|
|
230
|
-
plannedSteps.push(zone.step);
|
|
231
279
|
// Right after the zone (#43): `cloudflareRateLimit` resolves the zone by
|
|
232
280
|
// apex domain, so the zone step above must already have run (or the zone
|
|
233
281
|
// must already exist live) by the time this one's action executes.
|
|
234
282
|
const rateLimit = planRateLimit(org, recordedIds);
|
|
235
|
-
|
|
283
|
+
const rateLimitReason = rateLimit ? edgeNeeded(platformReadiness, ['zones', 'ratelimits']) : null;
|
|
284
|
+
if (rateLimit && rateLimitReason) {
|
|
285
|
+
dnsBlocked.push(edgeBlockedItem(org, 'sign-in rate limit', 'cloudflare:ratelimit', rateLimitReason));
|
|
286
|
+
}
|
|
287
|
+
else if (rateLimit && zoneWaiting) {
|
|
236
288
|
zoneBlocked.push({
|
|
237
289
|
description: `${org.slug}'s sign-in rate limit is blocked, not planned: the zone for ${org.domain} is not active yet`,
|
|
238
290
|
key: 'cloudflare:ratelimit',
|
|
@@ -246,8 +298,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
246
298
|
// Its fixed ZITADEL client before the Coolify app it signs into:
|
|
247
299
|
// `coolifyAgora` reads this step's recorded client id.
|
|
248
300
|
const agoraClient = planAgoraClient(org, byKey, claimed);
|
|
249
|
-
|
|
250
|
-
|
|
301
|
+
const agoraGuarded = guardApp(agoraClient);
|
|
302
|
+
if (agoraGuarded)
|
|
303
|
+
plannedSteps.push(agoraGuarded);
|
|
251
304
|
const agora = planAgora(org, byKey, claimed, recordedIds, agoraHost);
|
|
252
305
|
if (agora.step)
|
|
253
306
|
plannedSteps.push(agora.step);
|
|
@@ -255,8 +308,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
255
308
|
// shape as Agora and planned right after it. Its fixed ZITADEL client first,
|
|
256
309
|
// for the same reason: `coolifyBoule` reads that step's recorded client id.
|
|
257
310
|
const bouleClient = planBouleClient(org, byKey, claimed);
|
|
258
|
-
|
|
259
|
-
|
|
311
|
+
const bouleGuarded = guardApp(bouleClient);
|
|
312
|
+
if (bouleGuarded)
|
|
313
|
+
plannedSteps.push(bouleGuarded);
|
|
260
314
|
const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null);
|
|
261
315
|
if (boule.step)
|
|
262
316
|
plannedSteps.push(boule.step);
|
|
@@ -285,7 +339,12 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
285
339
|
// retire, which needs no new zone) still waits for it.
|
|
286
340
|
const edgeWorkers = planWorkers(org, recordedIds, options.edgeWorkers ?? false, options.platformHostnames ?? []);
|
|
287
341
|
for (const step of edgeWorkers.steps) {
|
|
288
|
-
|
|
342
|
+
// A retire never looks up the zone (`cloudflareWorker`), so only a deploy needs edge.
|
|
343
|
+
const workerReason = step.action.desired.mode === 'apply' ? edgeNeeded(platformReadiness, ['zones']) : null;
|
|
344
|
+
if (workerReason) {
|
|
345
|
+
dnsBlocked.push(edgeBlockedItem(org, `${step.action.key.slice(org.id.length + 1)} Worker`, step.action.key, workerReason));
|
|
346
|
+
}
|
|
347
|
+
else if (zoneWaiting && step.action.desired.mode === 'apply') {
|
|
289
348
|
zoneBlocked.push({
|
|
290
349
|
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
350
|
key: step.action.key,
|
|
@@ -361,6 +420,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
361
420
|
if (zone.blocked)
|
|
362
421
|
unmanaged.push({ description: zone.blocked, key: 'cloudflare:zone' });
|
|
363
422
|
unmanaged.push(...zoneBlocked);
|
|
423
|
+
unmanaged.push(...dnsBlocked);
|
|
364
424
|
// #243: an appEnv source nothing will record is reported, not silently skipped.
|
|
365
425
|
unmanaged.push(...app.blocked);
|
|
366
426
|
unmanaged.push(...brokerEntries.blocked);
|
|
@@ -372,6 +432,52 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
372
432
|
versionChanges,
|
|
373
433
|
};
|
|
374
434
|
}
|
|
435
|
+
/**
|
|
436
|
+
* #553: a plain ZITADEL app provision (not a `recreate`) is planned only when
|
|
437
|
+
* the live listing lacks the app by name. If an app id is recorded for it,
|
|
438
|
+
* the listing may simply have missed a live app, and nothing can read an app
|
|
439
|
+
* by id, so applying silently could replace a live sign-in client. The way
|
|
440
|
+
* out is the destroying `recreate` path: the step is planned as a
|
|
441
|
+
* replacement that asks for the typed confirmation, so a person who has
|
|
442
|
+
* checked the app is gone (deleted by hand in ZITADEL) says so, and apply
|
|
443
|
+
* then deletes the recorded id (a 404 is fine) and creates a new app.
|
|
444
|
+
* (A failed recreate clears the recorded id, so it is not caught here.)
|
|
445
|
+
*/
|
|
446
|
+
function guardAppProvision(step, org, recordedIds) {
|
|
447
|
+
if (!step || step.action.system !== 'zitadel' || step.action.kind !== 'app')
|
|
448
|
+
return step;
|
|
449
|
+
if (step.action.desired.recreate === true)
|
|
450
|
+
return step;
|
|
451
|
+
const recorded = recordedIds[`zitadel:app:${step.action.key}`];
|
|
452
|
+
if (!recorded)
|
|
453
|
+
return step;
|
|
454
|
+
return {
|
|
455
|
+
action: { ...step.action, desired: { ...step.action.desired, recreate: true } },
|
|
456
|
+
step: {
|
|
457
|
+
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)`,
|
|
458
|
+
destroys: true,
|
|
459
|
+
migrates: false,
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* #555: why a step that needs these edge-routed ports cannot run, or `null`.
|
|
465
|
+
* `readiness.dnsBlocked` is set while edge is not applied; a port the host
|
|
466
|
+
* supplies itself (`readiness.ownEdgePorts`, mirroring the executor's
|
|
467
|
+
* `ports.dns` / `zones` / `access` / `ratelimits` / `tunnels`, which win over
|
|
468
|
+
* edge) needs no edge, so only a step that would really reach edge is blocked.
|
|
469
|
+
*/
|
|
470
|
+
function edgeNeeded(readiness, needs) {
|
|
471
|
+
if (!readiness.dnsBlocked)
|
|
472
|
+
return null;
|
|
473
|
+
return needs.every((port) => readiness.ownEdgePorts?.[port]) ? null : readiness.dnsBlocked;
|
|
474
|
+
}
|
|
475
|
+
function edgeBlockedItem(org, what, key, reason) {
|
|
476
|
+
return {
|
|
477
|
+
description: `${org.slug}'s ${what} is blocked, not planned: ${reason ?? ''} (#555)`,
|
|
478
|
+
key,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
375
481
|
/**
|
|
376
482
|
* The org's own ZITADEL org (issuer), a per-org singleton like every other
|
|
377
483
|
* planner in this file, with one extra wrinkle: unlike mail/chat/site/app,
|
|
@@ -637,7 +743,7 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
|
|
|
637
743
|
// nothing else here would replan the step to retry it.
|
|
638
744
|
if (recordedIds[key] === host &&
|
|
639
745
|
recordedIds[LOGIN_BINDING_SCOPE_KEY] ===
|
|
640
|
-
loginBindingState(host, loginHostClientIds(recordedIds)) &&
|
|
746
|
+
loginBindingState(host, loginHostClientIds(org, recordedIds)) &&
|
|
641
747
|
pendingRemovalHosts(recordedIds).length === 0) {
|
|
642
748
|
return none;
|
|
643
749
|
}
|
|
@@ -776,7 +882,24 @@ platformApex, platformLettermintProjectId) {
|
|
|
776
882
|
const roleNote = rolesOwed > 0 ? `create ${rolesOwed} role mailbox(es) for ${org.slug}` : '';
|
|
777
883
|
const mailboxWork = [ownerNote, roleNote].filter(Boolean).join(' and ');
|
|
778
884
|
const mailboxNote = mailboxWork ? ` and ${mailboxWork}` : '';
|
|
779
|
-
|
|
885
|
+
// #554: no reader lists a live `coolify mail <org>` resource, so "applied"
|
|
886
|
+
// comes from the record: the domain, mode and `usesDomain` the step last
|
|
887
|
+
// wrote (`coolifyMail`). A changed declaration differs from it and plans.
|
|
888
|
+
const recordedDomain = recordedIds[MAIL_DOMAIN_SCOPE_KEY];
|
|
889
|
+
const recordedMode = recordedIds[ORG_MAIL_APPLIED_MODE_SCOPE_KEY];
|
|
890
|
+
const recordedUsesDomain = recordedIds[ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY];
|
|
891
|
+
const domainMatches = Boolean(recordedDomain && org.domain && recordedDomain.toLowerCase() === org.domain.toLowerCase());
|
|
892
|
+
const appliedFromRecord = !live &&
|
|
893
|
+
Boolean(recordedDomain) &&
|
|
894
|
+
// Before `mail_applied_mode` existed, only a received or hosted apply
|
|
895
|
+
// recorded `mail_domain` (`off` returns before it); production's record is
|
|
896
|
+
// hosted. An unrecorded mode vouches for `hosted` only.
|
|
897
|
+
(recordedMode === undefined ? desired.mode === 'hosted' : recordedMode === desired.mode) &&
|
|
898
|
+
(recordedUsesDomain === undefined
|
|
899
|
+
? desired.usesDomain && domainMatches
|
|
900
|
+
: recordedUsesDomain === String(desired.usesDomain) &&
|
|
901
|
+
(!desired.usesDomain || domainMatches));
|
|
902
|
+
if (!live && !appliedFromRecord) {
|
|
780
903
|
return {
|
|
781
904
|
step: {
|
|
782
905
|
action: { system: 'coolify', kind: 'mail', key: org.id, desired },
|
|
@@ -790,8 +913,8 @@ platformApex, platformLettermintProjectId) {
|
|
|
790
913
|
mailboxBlocked,
|
|
791
914
|
};
|
|
792
915
|
}
|
|
793
|
-
const liveMode = live.config.mode;
|
|
794
|
-
const liveUsesDomain = Boolean(live.config.usesDomain);
|
|
916
|
+
const liveMode = live ? live.config.mode : desired.mode;
|
|
917
|
+
const liveUsesDomain = live ? Boolean(live.config.usesDomain) : desired.usesDomain;
|
|
795
918
|
if (liveMode === desired.mode && liveUsesDomain === desired.usesDomain) {
|
|
796
919
|
// #177: the platform's apex or mail host moved since this step last wrote
|
|
797
920
|
// them (or it never recorded them). Re-applying is idempotent.
|
|
@@ -1476,7 +1599,12 @@ function planCmsClient(org, byKey, claimed) {
|
|
|
1476
1599
|
if (live.config.appType === 'web')
|
|
1477
1600
|
return null;
|
|
1478
1601
|
return {
|
|
1479
|
-
action: {
|
|
1602
|
+
action: {
|
|
1603
|
+
system: 'zitadel',
|
|
1604
|
+
kind: 'app',
|
|
1605
|
+
key: 'cms',
|
|
1606
|
+
desired: { ...desired, recreate: true },
|
|
1607
|
+
},
|
|
1480
1608
|
step: {
|
|
1481
1609
|
description: `recreate ZITADEL app 'cms' for ${org.slug} as web (was ${String(live.config.appType)})`,
|
|
1482
1610
|
destroys: true,
|
|
@@ -1580,7 +1708,12 @@ function planAgoraClient(org, byKey, claimed) {
|
|
|
1580
1708
|
if (live.config.appType === 'web')
|
|
1581
1709
|
return null;
|
|
1582
1710
|
return {
|
|
1583
|
-
action: {
|
|
1711
|
+
action: {
|
|
1712
|
+
system: 'zitadel',
|
|
1713
|
+
kind: 'app',
|
|
1714
|
+
key: 'agora',
|
|
1715
|
+
desired: { ...desired, recreate: true },
|
|
1716
|
+
},
|
|
1584
1717
|
step: {
|
|
1585
1718
|
description: `recreate ZITADEL app 'agora' for ${org.slug} as web (was ${String(live.config.appType)})`,
|
|
1586
1719
|
destroys: true,
|
|
@@ -1718,7 +1851,12 @@ function planBouleClient(org, byKey, claimed) {
|
|
|
1718
1851
|
if (live.config.appType === 'web')
|
|
1719
1852
|
return null;
|
|
1720
1853
|
return {
|
|
1721
|
-
action: {
|
|
1854
|
+
action: {
|
|
1855
|
+
system: 'zitadel',
|
|
1856
|
+
kind: 'app',
|
|
1857
|
+
key: 'boule',
|
|
1858
|
+
desired: { ...desired, recreate: true },
|
|
1859
|
+
},
|
|
1722
1860
|
step: {
|
|
1723
1861
|
description: `recreate ZITADEL app 'boule' for ${org.slug} as web (was ${String(live.config.appType)})`,
|
|
1724
1862
|
destroys: true,
|
|
@@ -2168,6 +2306,19 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
|
|
|
2168
2306
|
platformLettermintProjectId: readiness.platformLettermintProjectId,
|
|
2169
2307
|
};
|
|
2170
2308
|
}
|
|
2309
|
+
// #555: the `app` entry's step publishes the mail domain's DNS records
|
|
2310
|
+
// (`ensureAppLettermintMail`) once one is recorded, and those go through
|
|
2311
|
+
// edge: blocked, not planned, while edge is not applied.
|
|
2312
|
+
const appReason = edgeNeeded(readiness, ['zones', 'dns']);
|
|
2313
|
+
if (appReason &&
|
|
2314
|
+
key === APP_ENTRY_KEY &&
|
|
2315
|
+
app.step &&
|
|
2316
|
+
app.step.action.desired.enabled !== false &&
|
|
2317
|
+
recordedIds[MAIL_DOMAIN_SCOPE_KEY]) {
|
|
2318
|
+
blocked.push(edgeBlockedItem(org, 'app', 'coolify:app', appReason));
|
|
2319
|
+
blocked.push(...app.blocked);
|
|
2320
|
+
continue;
|
|
2321
|
+
}
|
|
2171
2322
|
if (app.step)
|
|
2172
2323
|
steps.push(app.step);
|
|
2173
2324
|
blocked.push(...app.blocked);
|
|
@@ -2283,7 +2434,7 @@ function sortedNames(names) {
|
|
|
2283
2434
|
* domain) plans no per-org tunnel for Agora: `<slug>-agora.oikos.host` rides
|
|
2284
2435
|
* the shared `*.oikos.host` wildcard tunnel (`scripts/shared-ingress.mjs`).
|
|
2285
2436
|
*/
|
|
2286
|
-
function planTunnel(org, byKey, claimed, recordedIds) {
|
|
2437
|
+
function planTunnel(org, byKey, claimed, recordedIds, isPlatformOrg, platformApex) {
|
|
2287
2438
|
const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
|
|
2288
2439
|
(org.site.enabled && Boolean(org.domain)) ||
|
|
2289
2440
|
(org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
|
|
@@ -2300,6 +2451,16 @@ function planTunnel(org, byKey, claimed, recordedIds) {
|
|
|
2300
2451
|
return live.config.enabled !== false;
|
|
2301
2452
|
return desired ? status !== 'down' : status === 'healthy' || status === 'degraded';
|
|
2302
2453
|
});
|
|
2454
|
+
// #568: the company's own login host is routed by the step, so a first
|
|
2455
|
+
// provision carries it too; a park does not. Without a known platform apex
|
|
2456
|
+
// the platform's own org cannot be told apart, so none is planned.
|
|
2457
|
+
const loginHost = platformApex ? companyLoginTunnelHost(org, isPlatformOrg) : null;
|
|
2458
|
+
if (toggled && toggled.action.desired.enabled !== false && loginHost) {
|
|
2459
|
+
return {
|
|
2460
|
+
...toggled,
|
|
2461
|
+
action: { ...toggled.action, desired: { ...toggled.action.desired, loginHost } },
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
2303
2464
|
if (toggled || !desired)
|
|
2304
2465
|
return toggled;
|
|
2305
2466
|
// A live, enabled tunnel re-plans the same step again when either:
|
|
@@ -2323,12 +2484,18 @@ function planTunnel(org, byKey, claimed, recordedIds) {
|
|
|
2323
2484
|
const published = recordedIds[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
|
|
2324
2485
|
const edgeChanged = published !== undefined &&
|
|
2325
2486
|
org.domain !== null &&
|
|
2326
|
-
sortedNames(published.split(',')) !==
|
|
2487
|
+
sortedNames(published.split(',')) !==
|
|
2488
|
+
sortedNames(tunnelDnsHostnames(org, org.domain, loginHost));
|
|
2327
2489
|
const dnsStale = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain || edgeChanged;
|
|
2328
2490
|
if (!connectorMissing && !dnsStale)
|
|
2329
2491
|
return null;
|
|
2330
2492
|
return {
|
|
2331
|
-
action: {
|
|
2493
|
+
action: {
|
|
2494
|
+
system: 'cloudflare',
|
|
2495
|
+
kind: 'tunnel',
|
|
2496
|
+
key: org.id,
|
|
2497
|
+
desired: loginHost ? { enabled: true, loginHost } : { enabled: true },
|
|
2498
|
+
},
|
|
2332
2499
|
step: {
|
|
2333
2500
|
description: connectorMissing
|
|
2334
2501
|
? `run a cloudflared connector for ${org.slug}'s Cloudflare tunnel`
|
|
@@ -2458,7 +2625,12 @@ function planIssuerApps(org, byKey, claimed) {
|
|
|
2458
2625
|
if (live.config.appType === app.kind)
|
|
2459
2626
|
continue;
|
|
2460
2627
|
steps.push({
|
|
2461
|
-
action: {
|
|
2628
|
+
action: {
|
|
2629
|
+
system: 'zitadel',
|
|
2630
|
+
kind: 'app',
|
|
2631
|
+
key: app.name,
|
|
2632
|
+
desired: { ...desired, recreate: true },
|
|
2633
|
+
},
|
|
2462
2634
|
step: {
|
|
2463
2635
|
description: `recreate ZITADEL app '${app.name}' for ${org.slug} as ${app.kind} (was ${String(live.config.appType)})`,
|
|
2464
2636
|
destroys: true,
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -12,27 +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
|
-
|
|
16
|
-
* The real {@link StepExecutor} (#20 "provision Company Factory end to
|
|
17
|
-
* end"): makes each {@link StepAction} `diff.ts` plans true against a real
|
|
18
|
-
* Coolify, Cloudflare and ZITADEL, plus per-org organisations, spend limits
|
|
19
|
-
* and (for AI) offers on Files and AI (#80). Scope and design are exactly
|
|
20
|
-
* what `.claude/decisions-2026-09-24-executor.md`'s "Executor decisions"
|
|
21
|
-
* section settled, referenced by name at each dispatch branch below.
|
|
22
|
-
*
|
|
23
|
-
* Built entirely from narrow ports (interfaces), never a concrete
|
|
24
|
-
* `@wtfalch/providers` client: this package holds no credential and never
|
|
25
|
-
* will (executor decisions: "the executor is constructed with narrow ports
|
|
26
|
-
* ... a host [archon] supplies real clients. How the host gets provider
|
|
27
|
-
* tokens from the keys Worker is the host's job, not this campaign's").
|
|
28
|
-
* Every port method here is named and shaped to match a real method on
|
|
29
|
-
* `@wtfalch/providers`' `CoolifyClient`/`CloudflareClient`/`ZitadelClient`
|
|
30
|
-
* (checked against `providers` `origin/main`, 2026-09-24), or on
|
|
31
|
-
* `@wtfalch/files`'/`@wtfalch/ai`'s own SDK clients (#80, checked against
|
|
32
|
-
* each repo's own `main`, 2026-09-25), so a real client satisfies the port
|
|
33
|
-
* structurally, with no adapter needed — see each port's own doc comment for
|
|
34
|
-
* the one real method it does not yet have.
|
|
35
|
-
*/
|
|
15
|
+
import type { EdgePortName } from './platform-org.js';
|
|
36
16
|
/**
|
|
37
17
|
* The Coolify calls this executor makes, shaped to match
|
|
38
18
|
* `@wtfalch/providers/coolify`'s `CoolifyClient` method-for-method — a real
|
|
@@ -53,10 +33,19 @@ export interface CoolifyPort {
|
|
|
53
33
|
findProjectByName(name: string): Promise<{
|
|
54
34
|
uuid: string;
|
|
55
35
|
name: string;
|
|
36
|
+
description?: string | null;
|
|
56
37
|
} | null>;
|
|
57
38
|
createProject(name: string, description?: string): Promise<{
|
|
58
39
|
uuid: string;
|
|
59
40
|
}>;
|
|
41
|
+
/**
|
|
42
|
+
* #569: what a project holds (`CoolifyClient.listResources`: its
|
|
43
|
+
* environments, applications and databases). Optional: without it an
|
|
44
|
+
* unmarked project cannot be shown empty, so it is refused.
|
|
45
|
+
*/
|
|
46
|
+
listResources?(projectUuid: string): Promise<Array<{
|
|
47
|
+
kind: string;
|
|
48
|
+
}>>;
|
|
60
49
|
findOrCreateEnvironment(projectUuid: string, name: string): Promise<{
|
|
61
50
|
environment: {
|
|
62
51
|
id: number;
|
|
@@ -421,6 +410,16 @@ export interface CoolifyPort {
|
|
|
421
410
|
deploymentUuid: string;
|
|
422
411
|
status: string;
|
|
423
412
|
}>;
|
|
413
|
+
/**
|
|
414
|
+
* One deployment's status by its uuid (`queued`, `in_progress`, `finished`,
|
|
415
|
+
* `failed`, `cancelled-by-user`); throws a 404 for a uuid Coolify does not
|
|
416
|
+
* know. Matches `CoolifyClient.getDeployment` method-for-method. The
|
|
417
|
+
* platform's Archon step reads it to confirm a deploy it triggered and did
|
|
418
|
+
* not wait for.
|
|
419
|
+
*/
|
|
420
|
+
getDeployment(deploymentUuid: string): Promise<{
|
|
421
|
+
status: string;
|
|
422
|
+
}>;
|
|
424
423
|
}
|
|
425
424
|
/**
|
|
426
425
|
* The Cloudflare zone calls the platform steps make directly, shaped to match
|
|
@@ -799,6 +798,8 @@ export interface ZitadelPort {
|
|
|
799
798
|
appType: 'web' | 'native' | 'api';
|
|
800
799
|
state: string;
|
|
801
800
|
loginBaseUri?: string | null;
|
|
801
|
+
/** Present only when the host's listing reads it; `zitadelApp` adopts with it, else with the recorded one. */
|
|
802
|
+
clientId?: string;
|
|
802
803
|
}>;
|
|
803
804
|
complete: boolean;
|
|
804
805
|
}>;
|
|
@@ -987,10 +988,10 @@ export interface ZitadelPort {
|
|
|
987
988
|
* listApps` -- a plain array, with no `loginBaseUri` or completeness signal
|
|
988
989
|
* -- into {@link ZitadelPort.listApps}'s own shape, so a host wiring that
|
|
989
990
|
* 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
|
-
*
|
|
991
|
+
* itself to build the wrapper by hand. `complete: true` on every call:
|
|
992
|
+
* since providers 0.7.4 the real client pages `_search` to the end. A host
|
|
993
|
+
* that supplies its own `listApps` keeps whatever `complete` it reports.
|
|
994
|
+
* Each app's `clientId` (providers 0.7.5) passes through. `loginBaseUri` comes back `undefined`
|
|
994
995
|
* for every app for the identical reason -- the real client does not read
|
|
995
996
|
* it back at all yet, not "read it back as absent" -- which is exactly the
|
|
996
997
|
* state `zitadelLoginHost`'s fail-closed rule exists for: every pending
|
|
@@ -1011,6 +1012,8 @@ export interface ZitadelListAppsRawApp {
|
|
|
1011
1012
|
name: string;
|
|
1012
1013
|
appType: 'web' | 'native' | 'api';
|
|
1013
1014
|
state: string;
|
|
1015
|
+
/** providers 0.7.5: the app's client id. Passed through to `ZitadelPort.listApps`. */
|
|
1016
|
+
clientId?: string;
|
|
1014
1017
|
}
|
|
1015
1018
|
/**
|
|
1016
1019
|
* Provisions one org's organisation on Files (#80), matching
|
|
@@ -1555,6 +1558,8 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1555
1558
|
private edgeZones;
|
|
1556
1559
|
private edgeWriteSlot;
|
|
1557
1560
|
constructor(ports: FoundryStepExecutorPorts);
|
|
1561
|
+
/** #555: the edge-routed ports this executor was built with itself (they win over edge). */
|
|
1562
|
+
ownEdgePorts(): Partial<Record<EdgePortName, boolean>>;
|
|
1558
1563
|
execute(action: StepAction, context: StepExecutionContext): Promise<{
|
|
1559
1564
|
resourceId?: string;
|
|
1560
1565
|
} | undefined>;
|
|
@@ -1784,9 +1789,9 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1784
1789
|
* container itself still writing nowhere). `wasAlreadyRunning` tells this
|
|
1785
1790
|
* method whether `appId` names an app `coolifyApp` found already there
|
|
1786
1791
|
* (restart it once the volume is created) or one it just created fresh
|
|
1787
|
-
* (
|
|
1788
|
-
*
|
|
1789
|
-
*
|
|
1792
|
+
* (`coolifyApp` deploys it after this call, #566, and that first start
|
|
1793
|
+
* carries the storage, so a restart here would only be a redundant extra
|
|
1794
|
+
* one).
|
|
1790
1795
|
*/
|
|
1791
1796
|
private ensureLogVolume;
|
|
1792
1797
|
private coolifyDatabase;
|