@wtfalch/foundry 0.5.0 → 0.7.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/bootstrap/bootstrap.d.ts +16 -1
- package/dist/bootstrap/bootstrap.js +40 -2
- package/dist/bootstrap/declare.d.ts +3 -1
- package/dist/bootstrap/declare.js +12 -3
- package/dist/bootstrap/versions.d.ts +17 -0
- package/dist/bootstrap/versions.js +12 -0
- package/dist/calls-sfu-deploy-support.d.ts +8 -0
- package/dist/calls-sfu-deploy-support.js +10 -1
- package/dist/calls-sfu.d.ts +36 -12
- package/dist/calls-sfu.js +42 -9
- package/dist/index.d.ts +10 -4
- package/dist/index.js +6 -3
- package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
- package/dist/migrations/0020_mail_apps.sql +7 -0
- package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
- package/dist/migrations/0022_chat_enabled.sql +6 -0
- package/dist/migrations/0023_hosting_app_env.sql +8 -0
- package/dist/migrations/0024_audit_events.sql +211 -0
- package/dist/migrations/0025_audit_display.sql +147 -0
- package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
- package/dist/migrations/0027_audit_chain.sql +170 -0
- package/dist/migrations/0028_audit_rls.sql +111 -0
- package/dist/org.d.ts +105 -0
- package/dist/org.js +101 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +23 -0
- package/dist/plan/calls-matrix.d.ts +2 -2
- package/dist/plan/calls-matrix.js +3 -2
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +5 -3
- package/dist/plan/diff.d.ts +15 -2
- package/dist/plan/diff.js +399 -51
- package/dist/plan/executor.d.ts +307 -61
- package/dist/plan/executor.js +489 -129
- package/dist/plan/index.d.ts +9 -1
- package/dist/plan/index.js +5 -1
- package/dist/plan/login-host.d.ts +64 -0
- package/dist/plan/login-host.js +52 -0
- package/dist/plan/mail-copy.d.ts +125 -0
- package/dist/plan/mail-copy.js +166 -0
- package/dist/plan/mail-identity-allowlist.d.ts +50 -0
- package/dist/plan/mail-identity-allowlist.js +11 -0
- package/dist/plan/mail-identity.d.ts +68 -0
- package/dist/plan/mail-identity.js +17 -0
- package/dist/plan/plan-apply.d.ts +26 -8
- package/dist/plan/plan-apply.js +10 -9
- package/dist/plan/platform-diff.d.ts +207 -10
- package/dist/plan/platform-diff.js +403 -15
- package/dist/plan/platform-org.d.ts +3 -2
- package/dist/plan/platform-providers.d.ts +139 -25
- package/dist/plan/platform-providers.js +1531 -95
- package/dist/plan/platform-sfu.d.ts +54 -19
- package/dist/plan/platform-sfu.js +64 -22
- package/dist/plan/providers-readers.js +44 -1
- package/dist/plan/zitadel-machine-user.d.ts +60 -11
- package/dist/plan/zitadel-machine-user.js +182 -39
- package/dist/requested-changes.d.ts +17 -7
- package/dist/requested-changes.js +37 -7
- package/dist/store/audit.d.ts +40 -4
- package/dist/store/audit.js +114 -29
- package/dist/store/postgres.js +35 -3
- package/dist/store/schema.d.ts +109 -1
- package/dist/store/schema.js +20 -0
- package/dist/store.d.ts +7 -0
- package/package.json +5 -3
- package/versions.json +8 -0
package/dist/plan/diff.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { NO_AGORA_HOST_VALUES, agoraEnvDrift, agoraHostMissing, desiredAgoraEnv, } from './agora.js';
|
|
2
|
-
import {
|
|
1
|
+
import { NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, desiredAgoraEnv, } from './agora.js';
|
|
2
|
+
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, } from './login-host.js';
|
|
3
|
+
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
|
|
4
|
+
import { APP_ENV_APPLIED_SCOPE_KEY, DB_ENV_APPLIED_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, resolveAppEnv, } from './platform-diff.js';
|
|
3
5
|
import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
|
|
4
6
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
5
7
|
export function toPlan(detail) {
|
|
@@ -52,7 +54,11 @@ function resourceKey(system, kind, key) {
|
|
|
52
54
|
* app with the platform's jobs scheduler, idempotent on the *recorded*
|
|
53
55
|
* resource id like {@link planOwner} rather than a live resource, since
|
|
54
56
|
* nothing in `live` can report a scheduler registration (no reader exists for
|
|
55
|
-
* it).
|
|
57
|
+
* it). #267 adds one more, right after {@link planOwner} and before every
|
|
58
|
+
* app-creating step below: {@link planLoginHost}, the org's own company
|
|
59
|
+
* login host -- also idempotent on the *recorded* resource id, since
|
|
60
|
+
* `live` has no notion of a ZITADEL trusted domain or a login binding
|
|
61
|
+
* either. Everything
|
|
56
62
|
* else in `live` that none of these eighteen singletons and however many
|
|
57
63
|
* declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
|
|
58
64
|
* guests, calls on the org's own domain, adopted/bespoke apps, the actual
|
|
@@ -60,7 +66,7 @@ function resourceKey(system, kind, key) {
|
|
|
60
66
|
* Company Factory end to end", which wires real readers and executors
|
|
61
67
|
* against this seam.
|
|
62
68
|
*/
|
|
63
|
-
export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AGORA_HOST_VALUES, jobsAvailable = false, platformReadiness = DEFAULT_ORG_PLATFORM_READINESS) {
|
|
69
|
+
export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AGORA_HOST_VALUES, jobsAvailable = false, platformReadiness = DEFAULT_ORG_PLATFORM_READINESS, options = {}) {
|
|
64
70
|
const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
|
|
65
71
|
const claimed = new Set();
|
|
66
72
|
const plannedSteps = [];
|
|
@@ -70,9 +76,32 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
70
76
|
const owner = planOwner(org, recordedIds, platformReadiness.authReady);
|
|
71
77
|
if (owner)
|
|
72
78
|
plannedSteps.push(owner);
|
|
79
|
+
// #267: after the ZITADEL org step, before any app's own issuer
|
|
80
|
+
// application. Apps created later in this run are not in the binding yet;
|
|
81
|
+
// the next run replans this step and repoints them.
|
|
82
|
+
const loginHost = planLoginHost(org, recordedIds, platformReadiness.authReady, issuer.step !== null, options.loginHostBindings ?? false);
|
|
83
|
+
if (loginHost.step)
|
|
84
|
+
plannedSteps.push(loginHost.step);
|
|
85
|
+
// Planned ahead of everything that needs the zone live (#238); its own step
|
|
86
|
+
// is still pushed in its usual place below.
|
|
87
|
+
const zone = planZone(org, byKey, claimed);
|
|
88
|
+
const zoneWaiting = zone.blocked !== null;
|
|
89
|
+
const zoneBlocked = [];
|
|
73
90
|
const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost);
|
|
74
|
-
|
|
91
|
+
// #238: own-domain mail publishes DNS records into the zone, so it waits for
|
|
92
|
+
// an active zone -- blocked, not planned (rule 2).
|
|
93
|
+
if (mail.step && zoneWaiting && org.mail.usesDomain && org.domain) {
|
|
94
|
+
zoneBlocked.push({
|
|
95
|
+
description: `${org.slug}'s mail is blocked, not planned: the zone for ${org.domain} is not active yet`,
|
|
96
|
+
key: 'coolify:mail:zone',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
else if (mail.step) {
|
|
75
100
|
plannedSteps.push(mail.step);
|
|
101
|
+
}
|
|
102
|
+
const mailCopy = planMailCopy(org, byKey, recordedIds);
|
|
103
|
+
if (mailCopy)
|
|
104
|
+
plannedSteps.push(mailCopy);
|
|
76
105
|
const chat = planChat(org, byKey, claimed);
|
|
77
106
|
if (chat)
|
|
78
107
|
plannedSteps.push(chat);
|
|
@@ -131,21 +160,36 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
131
160
|
const database = planDatabase(org, byKey, claimed);
|
|
132
161
|
if (database)
|
|
133
162
|
plannedSteps.push(database);
|
|
134
|
-
const app = planApp(org, byKey, claimed);
|
|
135
|
-
if (app)
|
|
136
|
-
plannedSteps.push(app);
|
|
137
|
-
const tunnel = planTunnel(org, byKey, claimed);
|
|
138
|
-
|
|
163
|
+
const app = planApp(org, byKey, claimed, recordedIds, plannedSteps);
|
|
164
|
+
if (app.step)
|
|
165
|
+
plannedSteps.push(app.step);
|
|
166
|
+
const tunnel = planTunnel(org, byKey, claimed, recordedIds);
|
|
167
|
+
// #238: a tunnel that is being provisioned waits for an active zone; parking
|
|
168
|
+
// one never does.
|
|
169
|
+
if (tunnel && zoneWaiting && tunnel.action.desired.enabled !== false) {
|
|
170
|
+
zoneBlocked.push({
|
|
171
|
+
description: `${org.slug}'s Cloudflare tunnel is blocked, not planned: the zone for ${org.domain} is not active yet`,
|
|
172
|
+
key: 'cloudflare:tunnel',
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
else if (tunnel) {
|
|
139
176
|
plannedSteps.push(tunnel);
|
|
140
|
-
|
|
141
|
-
if (zone)
|
|
142
|
-
plannedSteps.push(zone);
|
|
177
|
+
}
|
|
178
|
+
if (zone.step)
|
|
179
|
+
plannedSteps.push(zone.step);
|
|
143
180
|
// Right after the zone (#43): `cloudflareRateLimit` resolves the zone by
|
|
144
181
|
// apex domain, so the zone step above must already have run (or the zone
|
|
145
182
|
// must already exist live) by the time this one's action executes.
|
|
146
183
|
const rateLimit = planRateLimit(org, recordedIds);
|
|
147
|
-
if (rateLimit)
|
|
184
|
+
if (rateLimit && zoneWaiting) {
|
|
185
|
+
zoneBlocked.push({
|
|
186
|
+
description: `${org.slug}'s sign-in rate limit is blocked, not planned: the zone for ${org.domain} is not active yet`,
|
|
187
|
+
key: 'cloudflare:ratelimit',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
else if (rateLimit) {
|
|
148
191
|
plannedSteps.push(rateLimit);
|
|
192
|
+
}
|
|
149
193
|
// After the apps: the customers org is granted the project the first app
|
|
150
194
|
// step creates.
|
|
151
195
|
const customers = planCustomers(org, live, claimed, recordedIds);
|
|
@@ -195,8 +239,17 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
195
239
|
// budget, key) would otherwise also have nothing to act on.
|
|
196
240
|
if (issuer.blocked)
|
|
197
241
|
unmanaged.push({ description: issuer.blocked, key: 'zitadel:issuer' });
|
|
242
|
+
// #267 (rule 2): a login host with no ZITADEL org to trust it in is blocked, not planned to throw.
|
|
243
|
+
if (loginHost.blocked) {
|
|
244
|
+
unmanaged.push({ description: loginHost.blocked, key: 'zitadel:login-host' });
|
|
245
|
+
}
|
|
198
246
|
if (mail.blocked)
|
|
199
247
|
unmanaged.push({ description: mail.blocked, key: 'coolify:mail' });
|
|
248
|
+
// #162: reported separately from `mail.blocked` -- missing an owner
|
|
249
|
+
// mailbox address never blocks the rest of this org's mail.
|
|
250
|
+
if (mail.mailboxBlocked) {
|
|
251
|
+
unmanaged.push({ description: mail.mailboxBlocked, key: 'coolify:mail:owner-mailbox' });
|
|
252
|
+
}
|
|
200
253
|
if (calls.blocked)
|
|
201
254
|
unmanaged.push({ description: calls.blocked, key: 'coolify:calls' });
|
|
202
255
|
if (filesOrganisation.blocked) {
|
|
@@ -209,6 +262,13 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
209
262
|
unmanaged.push({ description: storage.blocked, key: 'files:storage' });
|
|
210
263
|
if (aiBudget.blocked)
|
|
211
264
|
unmanaged.push({ description: aiBudget.blocked, key: 'ai:budget' });
|
|
265
|
+
// #238: a pending zone, reported here with its nameservers rather than
|
|
266
|
+
// silently treated as converged.
|
|
267
|
+
if (zone.blocked)
|
|
268
|
+
unmanaged.push({ description: zone.blocked, key: 'cloudflare:zone' });
|
|
269
|
+
unmanaged.push(...zoneBlocked);
|
|
270
|
+
// #243: an appEnv source nothing will record is reported, not silently skipped.
|
|
271
|
+
unmanaged.push(...app.blocked);
|
|
212
272
|
return { orgId: org.id, plannedSteps, unmanaged };
|
|
213
273
|
}
|
|
214
274
|
/**
|
|
@@ -346,6 +406,83 @@ function planOwner(org, recordedIds, authReady) {
|
|
|
346
406
|
},
|
|
347
407
|
};
|
|
348
408
|
}
|
|
409
|
+
/**
|
|
410
|
+
* #267: the org's own company login host (`login-host.ts`'s
|
|
411
|
+
* `loginHostnameFor`) -- `auth.<domain>` once the org has one,
|
|
412
|
+
* `<slug>-auth.oikos.host` for tier 1. Idempotent on the *recorded*
|
|
413
|
+
* resource id, the same shape `planOwner` above uses and for the same
|
|
414
|
+
* reason: `ZitadelReader.listResources` has no notion of a trusted domain
|
|
415
|
+
* or a login binding, so there is nothing in `live` this step could ever
|
|
416
|
+
* claim or compare against.
|
|
417
|
+
*
|
|
418
|
+
* Unlike `planOwner`, a re-run is not simply "already done or not": the
|
|
419
|
+
* recorded value is compared against {@link loginHostnameFor}'s own value
|
|
420
|
+
* for *this* apply, so an org moving from tier 1 onto its own domain (or
|
|
421
|
+
* losing one) replans this step rather than staying silent forever once
|
|
422
|
+
* first applied -- `executor.ts`'s `zitadelLoginHost` then also moves every
|
|
423
|
+
* already-live app onto the new base URI. Mints nothing either way: adding
|
|
424
|
+
* a trusted domain is not a one-time secret, so there is no "undelivered"
|
|
425
|
+
* guard to check the way `planJobsRegistration`'s own does.
|
|
426
|
+
*
|
|
427
|
+
* Ordered right after `planOwner` (not after mail, chat, or any hosting
|
|
428
|
+
* step), for the same reason `planOwner`'s own doc comment gives: on a
|
|
429
|
+
* first-ever apply the ZITADEL org this step's executor needs
|
|
430
|
+
* (`context.recordedIds().zitadel_org`) has already been recorded by the
|
|
431
|
+
* issuer step two steps before it in the same run, and this step itself
|
|
432
|
+
* runs well before `planIssuerApps`/`planCmsClient`/`planAgoraClient`
|
|
433
|
+
* create any app under the org's project -- #267's own "before any app's
|
|
434
|
+
* issuer application".
|
|
435
|
+
*/
|
|
436
|
+
function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired) {
|
|
437
|
+
const none = { step: null, blocked: null };
|
|
438
|
+
const key = resourceKey('zitadel', 'login-host', org.id);
|
|
439
|
+
const host = loginHostnameFor(org);
|
|
440
|
+
// Already applied -- a re-run plans nothing unless the host changed or an app
|
|
441
|
+
// client id appeared since the binding was last written (#267, rule 4).
|
|
442
|
+
if (recordedIds[key] === host &&
|
|
443
|
+
recordedIds[LOGIN_BINDING_SCOPE_KEY] ===
|
|
444
|
+
loginBindingState(host, loginHostClientIds(recordedIds))) {
|
|
445
|
+
return none;
|
|
446
|
+
}
|
|
447
|
+
// #177: nothing to add a trusted domain to yet -- `planIssuer` already
|
|
448
|
+
// reports the platform's sign-in as blocked, so this step stays silent
|
|
449
|
+
// rather than repeating it, the same rule `planOwner` follows.
|
|
450
|
+
if (!authReady)
|
|
451
|
+
return none;
|
|
452
|
+
// Isolation between orgs rests on zitadel-login's binding, and repointing
|
|
453
|
+
// an app at a host with no binding breaks its sign-in: trust nothing and
|
|
454
|
+
// repoint nothing until the port that writes the binding is wired.
|
|
455
|
+
if (!bindingsWired) {
|
|
456
|
+
return {
|
|
457
|
+
step: null,
|
|
458
|
+
blocked: `${org.slug}'s login host ${host} waits for the loginHostBindings port, which the host has not wired`,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
// No ZITADEL org to trust the host in: wait for it, unless the issuer step
|
|
462
|
+
// in this very plan creates it first (it runs before this one).
|
|
463
|
+
if (!recordedIds[ZITADEL_ORG_SCOPE_KEY] && !issuerPlanned) {
|
|
464
|
+
return {
|
|
465
|
+
step: null,
|
|
466
|
+
blocked: `${org.slug}'s login host ${host} waits for its ZITADEL org, which is not recorded yet`,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
return {
|
|
470
|
+
step: {
|
|
471
|
+
action: {
|
|
472
|
+
system: 'zitadel',
|
|
473
|
+
kind: 'login-host',
|
|
474
|
+
key: org.id,
|
|
475
|
+
desired: { host },
|
|
476
|
+
},
|
|
477
|
+
step: {
|
|
478
|
+
description: `set ${org.slug}'s login host to ${host}`,
|
|
479
|
+
destroys: false,
|
|
480
|
+
migrates: false,
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
blocked: null,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
349
486
|
/**
|
|
350
487
|
* #162: the flat scope id `executor.ts`'s `ensureOwnerMailbox` records once
|
|
351
488
|
* it has given the org's owner a mailbox (`MAIL_OWNER_MAILBOX_SCOPE_KEY`
|
|
@@ -353,6 +490,12 @@ function planOwner(org, recordedIds, authReady) {
|
|
|
353
490
|
* `LOG_DIR` precedent follows for a value shared with `executor.ts`.
|
|
354
491
|
*/
|
|
355
492
|
const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
|
|
493
|
+
/** #230: restated from `executor.ts`'s `MAIL_ROLE_MAILBOXES_SCOPE_KEY`, same rule: comma-joined local parts already created. */
|
|
494
|
+
const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
|
|
495
|
+
/** Restated from `executor.ts`, the same rule: the app id of the org tunnel's cloudflared connector. */
|
|
496
|
+
const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_app';
|
|
497
|
+
/** Restated from `executor.ts`, the same rule: the mail domain the last apply resolved. */
|
|
498
|
+
const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
|
|
356
499
|
/** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
|
|
357
500
|
export const AI_KEY_UNDELIVERED_SCOPE_KEY = 'ai_key_undelivered';
|
|
358
501
|
/** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
|
|
@@ -376,13 +519,14 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
|
|
|
376
519
|
// after having it would silently hide its own leftover mail resource from
|
|
377
520
|
// the unmanaged list.
|
|
378
521
|
if (org.mail.mode === 'off')
|
|
379
|
-
return { step: null, blocked: null };
|
|
522
|
+
return { step: null, blocked: null, mailboxBlocked: null };
|
|
380
523
|
if (live)
|
|
381
524
|
claimed.add(key);
|
|
382
525
|
if (!mailHost) {
|
|
383
526
|
return {
|
|
384
527
|
step: null,
|
|
385
528
|
blocked: `${org.slug}'s mail is blocked, not planned: the platform's mail server is off or not yet applied`,
|
|
529
|
+
mailboxBlocked: null,
|
|
386
530
|
};
|
|
387
531
|
}
|
|
388
532
|
const desired = {
|
|
@@ -391,12 +535,33 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
|
|
|
391
535
|
apex: mailHost.apex,
|
|
392
536
|
mailHost: mailHost.mailHost,
|
|
393
537
|
};
|
|
394
|
-
// #162: an owner's mailbox is minted inside the same
|
|
395
|
-
// after `applyOrgMail` -- so as long as one is still
|
|
396
|
-
// keep showing up even once mode/usesDomain
|
|
397
|
-
// to change, or the operator would apply and
|
|
398
|
-
|
|
399
|
-
|
|
538
|
+
// #162 (decided 2026-09-29): an owner's mailbox is minted inside the same
|
|
539
|
+
// `coolify:mail` step, after `applyOrgMail` -- so as long as one is still
|
|
540
|
+
// owed, this step must keep showing up even once mode/usesDomain
|
|
541
|
+
// themselves have nothing left to change, or the operator would apply and
|
|
542
|
+
// never see it happen. Nothing is guessed any more: without a declared
|
|
543
|
+
// `mail.ownerMailboxAddress`, the mailbox alone is reported blocked below
|
|
544
|
+
// (`mailboxBlocked`), the same "blocked, not planned" pattern a blocked
|
|
545
|
+
// Agora already follows (PR 180) -- the rest of this step, and every
|
|
546
|
+
// other step in the org's plan, is unaffected.
|
|
547
|
+
const mailboxAlreadyGiven = Boolean(recordedIds[MAIL_OWNER_MAILBOX_SCOPE_KEY]);
|
|
548
|
+
const hasOwner = Boolean(org.owner);
|
|
549
|
+
const ownerMailboxAddress = org.mail.ownerMailboxAddress ?? null;
|
|
550
|
+
const mailboxBlocked = hasOwner && !mailboxAlreadyGiven && !ownerMailboxAddress
|
|
551
|
+
? `${org.slug}'s owner mailbox is blocked, not planned: mail.ownerMailboxAddress is not declared`
|
|
552
|
+
: null;
|
|
553
|
+
const mailboxOwed = hasOwner && !mailboxAlreadyGiven && ownerMailboxAddress !== null;
|
|
554
|
+
// #230: declared role mailboxes not yet recorded as created. Names only
|
|
555
|
+
// ever count here, never appear in a description, same as the owner's address.
|
|
556
|
+
// Recorded per address, so a changed mail domain owes its own set. A hosted
|
|
557
|
+
// org's domain is only known once recorded; until then every role is owed.
|
|
558
|
+
const createdRoles = new Set((recordedIds[MAIL_ROLE_MAILBOXES_SCOPE_KEY] ?? '').split(','));
|
|
559
|
+
const roleDomain = (org.mail.usesDomain && org.domain) || recordedIds[MAIL_DOMAIN_SCOPE_KEY];
|
|
560
|
+
const rolesOwed = (org.mail.roleMailboxes ?? []).filter((r) => !roleDomain || !createdRoles.has(`${r}@${roleDomain}`.toLowerCase())).length;
|
|
561
|
+
const ownerNote = mailboxOwed ? `give ${org.slug}'s owner a mailbox` : '';
|
|
562
|
+
const roleNote = rolesOwed > 0 ? `create ${rolesOwed} role mailbox(es) for ${org.slug}` : '';
|
|
563
|
+
const mailboxWork = [ownerNote, roleNote].filter(Boolean).join(' and ');
|
|
564
|
+
const mailboxNote = mailboxWork ? ` and ${mailboxWork}` : '';
|
|
400
565
|
if (!live) {
|
|
401
566
|
return {
|
|
402
567
|
step: {
|
|
@@ -408,6 +573,7 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
|
|
|
408
573
|
},
|
|
409
574
|
},
|
|
410
575
|
blocked: null,
|
|
576
|
+
mailboxBlocked,
|
|
411
577
|
};
|
|
412
578
|
}
|
|
413
579
|
const liveMode = live.config.mode;
|
|
@@ -417,20 +583,21 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
|
|
|
417
583
|
// them (or it never recorded them). Re-applying is idempotent.
|
|
418
584
|
const addressMoved = recordedIds[ORG_MAIL_APPLIED_APEX_SCOPE_KEY] !== mailHost.apex ||
|
|
419
585
|
recordedIds[ORG_MAIL_APPLIED_HOST_SCOPE_KEY] !== mailHost.mailHost;
|
|
420
|
-
if (!
|
|
421
|
-
return { step: null, blocked: null };
|
|
586
|
+
if (!mailboxWork && !addressMoved)
|
|
587
|
+
return { step: null, blocked: null, mailboxBlocked };
|
|
422
588
|
return {
|
|
423
589
|
step: {
|
|
424
590
|
action: { system: 'coolify', kind: 'mail', key: org.id, desired },
|
|
425
591
|
step: {
|
|
426
592
|
description: addressMoved
|
|
427
593
|
? `re-apply ${org.slug}'s mail on the platform's mail host ${mailHost.mailHost}${mailboxNote}`
|
|
428
|
-
:
|
|
594
|
+
: mailboxWork,
|
|
429
595
|
destroys: false,
|
|
430
596
|
migrates: false,
|
|
431
597
|
},
|
|
432
598
|
},
|
|
433
599
|
blocked: null,
|
|
600
|
+
mailboxBlocked,
|
|
434
601
|
};
|
|
435
602
|
}
|
|
436
603
|
if (liveUsesDomain && !desired.usesDomain) {
|
|
@@ -446,6 +613,21 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
|
|
|
446
613
|
},
|
|
447
614
|
},
|
|
448
615
|
blocked: null,
|
|
616
|
+
mailboxBlocked,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
// #113: moving from `received` to `hosted` is an MX cutover, and foundry
|
|
620
|
+
// never cuts over before an org's old mail has copied into Stalwart --
|
|
621
|
+
// `planMailCopy`'s own step is what runs that copy. Blocked, not planned,
|
|
622
|
+
// the same "withhold the step rather than apply it half-ready" rule
|
|
623
|
+
// `mailboxBlocked` already follows just above.
|
|
624
|
+
if (liveMode === 'received' &&
|
|
625
|
+
desired.mode === 'hosted' &&
|
|
626
|
+
!recordedIds[MAIL_COPY_DONE_SCOPE_KEY]) {
|
|
627
|
+
return {
|
|
628
|
+
step: null,
|
|
629
|
+
blocked: `${org.slug}'s mail cannot move to hosted yet: its old mail is still being copied into Stalwart (coolify:mail-copy, #113)`,
|
|
630
|
+
mailboxBlocked,
|
|
449
631
|
};
|
|
450
632
|
}
|
|
451
633
|
// A mode transition (#7 "Transitions") or turning a domain on is a
|
|
@@ -460,9 +642,44 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
|
|
|
460
642
|
},
|
|
461
643
|
},
|
|
462
644
|
blocked: null,
|
|
645
|
+
mailboxBlocked,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* #113: the one-time copy of an org's old mail into Stalwart that must
|
|
650
|
+
* finish before `planMail` allows the `received` -> `hosted` MX cutover
|
|
651
|
+
* above. Planned only for that exact transition -- a fresh org declared
|
|
652
|
+
* `hosted` from the start, or one moving between any other pair of modes,
|
|
653
|
+
* has nothing to migrate from and never sees this step. Never matched
|
|
654
|
+
* against a live resource: like `platform-diff.ts`'s `planSfu`, there is no
|
|
655
|
+
* live state to probe here (`runMailCopyStep`'s own progress is recorded
|
|
656
|
+
* state, not something a reader lists), so this plans again on every apply
|
|
657
|
+
* until {@link MAIL_COPY_DONE_SCOPE_KEY} is recorded.
|
|
658
|
+
*/
|
|
659
|
+
function planMailCopy(org, byKey, recordedIds) {
|
|
660
|
+
if (org.mail.mode !== 'hosted')
|
|
661
|
+
return null;
|
|
662
|
+
if (recordedIds[MAIL_COPY_DONE_SCOPE_KEY])
|
|
663
|
+
return null;
|
|
664
|
+
const live = byKey.get(resourceKey('coolify', 'mail', org.id));
|
|
665
|
+
if (!live || live.config.mode !== 'received')
|
|
666
|
+
return null;
|
|
667
|
+
const completed = parseMailCopyCompleted(recordedIds[MAIL_COPY_COMPLETED_SCOPE_KEY]);
|
|
668
|
+
const progress = completed.length > 0 ? ` (${completed.length} account(s) already copied)` : '';
|
|
669
|
+
return {
|
|
670
|
+
action: { system: 'coolify', kind: 'mail-copy', key: org.id, desired: {} },
|
|
671
|
+
step: {
|
|
672
|
+
description: `copy ${org.slug}'s old mail into Stalwart before the MX cutover${progress} -- re-apply again to continue, or tomorrow if a provider's daily limit was hit`,
|
|
673
|
+
destroys: false,
|
|
674
|
+
migrates: false,
|
|
675
|
+
},
|
|
463
676
|
};
|
|
464
677
|
}
|
|
465
678
|
function planChat(org, byKey, claimed) {
|
|
679
|
+
// Chat off (not in v1): plan nothing, and leave any live chat unclaimed so
|
|
680
|
+
// it is reported in `unmanaged` and never adopted or destroyed.
|
|
681
|
+
if (!org.chat.enabled)
|
|
682
|
+
return null;
|
|
466
683
|
const key = resourceKey('coolify', 'chat', org.id);
|
|
467
684
|
const live = byKey.get(key);
|
|
468
685
|
if (live)
|
|
@@ -1120,23 +1337,103 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
|
|
|
1120
1337
|
* resource's `enabled` field -- a live resource nothing has actually
|
|
1121
1338
|
* checked yet is assumed fine rather than replanned every time.
|
|
1122
1339
|
*/
|
|
1123
|
-
function planApp(org, byKey, claimed) {
|
|
1340
|
+
function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
1341
|
+
// #243: an `appEnv` source with no recorded value and no step planned to
|
|
1342
|
+
// record one this run has nothing to push, now or after this apply.
|
|
1343
|
+
const sourceStep = {
|
|
1344
|
+
filesOrganisationId: ['files', 'organisation'],
|
|
1345
|
+
aiOrganisationId: ['ai', 'organisation'],
|
|
1346
|
+
mailDomain: ['coolify', 'mail'],
|
|
1347
|
+
};
|
|
1348
|
+
const { values: wantedEnv, unresolved } = resolveAppEnv(org, recordedIds);
|
|
1349
|
+
const blocked = org.hosting.enabled
|
|
1350
|
+
? unresolved
|
|
1351
|
+
.filter(([, source]) => !plannedSteps.some((p) => p.action.system === sourceStep[source][0] &&
|
|
1352
|
+
p.action.kind === sourceStep[source][1]))
|
|
1353
|
+
.map(([name, source]) => ({
|
|
1354
|
+
description: `${org.slug}'s app env ${name} is blocked, not planned: its source ${source} has no recorded value yet`,
|
|
1355
|
+
key: `coolify:app:env:${name}`,
|
|
1356
|
+
}))
|
|
1357
|
+
: [];
|
|
1124
1358
|
const toggled = planHostingToggle('coolify', 'app', org.hosting.enabled, org, byKey, claimed, () => `provision ${org.slug}'s app`, () => `park ${org.slug}'s app (stops serving; app data is retained, not deleted -- #7 corrections 1)`);
|
|
1125
1359
|
if (toggled)
|
|
1126
|
-
return toggled;
|
|
1360
|
+
return { step: toggled, blocked };
|
|
1127
1361
|
if (!org.hosting.enabled)
|
|
1128
|
-
return null;
|
|
1362
|
+
return { step: null, blocked };
|
|
1129
1363
|
const live = byKey.get(resourceKey('coolify', 'app', org.id));
|
|
1130
|
-
if (!live
|
|
1131
|
-
return null;
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
migrates: false,
|
|
1138
|
-
},
|
|
1364
|
+
if (!live)
|
|
1365
|
+
return { step: null, blocked };
|
|
1366
|
+
const action = {
|
|
1367
|
+
system: 'coolify',
|
|
1368
|
+
kind: 'app',
|
|
1369
|
+
key: org.id,
|
|
1370
|
+
desired: { enabled: true },
|
|
1139
1371
|
};
|
|
1372
|
+
if (live.config.hasLogVolume === false) {
|
|
1373
|
+
return {
|
|
1374
|
+
step: {
|
|
1375
|
+
action,
|
|
1376
|
+
step: {
|
|
1377
|
+
description: `add a durable log directory to ${org.slug}'s app (persistent volume at /app/logs + LOG_DIR) so a database outage does not erase the evidence (#75)`,
|
|
1378
|
+
destroys: false,
|
|
1379
|
+
migrates: false,
|
|
1380
|
+
},
|
|
1381
|
+
},
|
|
1382
|
+
blocked,
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
// #239 (rule 4), from live state: an app with a database but no
|
|
1386
|
+
// `DATABASE_URL_OWNER` env still has the pre-split superuser URL, or never
|
|
1387
|
+
// got the push. Names only, never values. A reader that did not list env
|
|
1388
|
+
// names (`envNames` absent) reads as compliant, like `hasLogVolume`.
|
|
1389
|
+
const envNames = live.config.envNames;
|
|
1390
|
+
const dbLive = byKey.get(resourceKey('coolify', 'database', org.id));
|
|
1391
|
+
const dbReady = org.hosting.database && Boolean(recordedIds[`coolify:database:${org.id}`]);
|
|
1392
|
+
if (dbReady && dbLive?.config.hasInternalUrl === false) {
|
|
1393
|
+
// Rule 2: the push cannot succeed yet, so report it rather than replan
|
|
1394
|
+
// forever.
|
|
1395
|
+
blocked.push({
|
|
1396
|
+
description: `${org.slug}'s app database URLs are blocked, not planned: the database reports no internal connection URL yet`,
|
|
1397
|
+
key: 'coolify:app:database-url',
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
else if (dbReady &&
|
|
1401
|
+
// Only a read that saw the URL replans: an unread database (a failed
|
|
1402
|
+
// read) would plan a step that pushes nothing, forever.
|
|
1403
|
+
dbLive?.config.hasInternalUrl === true &&
|
|
1404
|
+
((Array.isArray(envNames) && !envNames.includes('DATABASE_URL_OWNER')) ||
|
|
1405
|
+
// A crash between the push and the restart leaves the env right and no
|
|
1406
|
+
// marker: replan once more to restart and set it.
|
|
1407
|
+
!recordedIds[DB_ENV_APPLIED_SCOPE_KEY])) {
|
|
1408
|
+
return {
|
|
1409
|
+
step: {
|
|
1410
|
+
action,
|
|
1411
|
+
step: {
|
|
1412
|
+
description: `give ${org.slug}'s app its owner and runtime database URLs and restart it (#239)`,
|
|
1413
|
+
destroys: false,
|
|
1414
|
+
migrates: false,
|
|
1415
|
+
},
|
|
1416
|
+
},
|
|
1417
|
+
blocked,
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
// #243 (rule 3): a changed `hosting.appEnv` reaches an app that is already
|
|
1421
|
+
// live. `pushAppEnv` records what it pushed, so this plans the same step
|
|
1422
|
+
// again, with a redeploy, only once the resolved map differs from that.
|
|
1423
|
+
if (JSON.stringify(wantedEnv) !== (recordedIds[APP_ENV_APPLIED_SCOPE_KEY] ?? '{}')) {
|
|
1424
|
+
return {
|
|
1425
|
+
step: {
|
|
1426
|
+
action,
|
|
1427
|
+
step: {
|
|
1428
|
+
description: `push ${org.slug}'s changed app env (appEnv) and redeploy the app`,
|
|
1429
|
+
destroys: false,
|
|
1430
|
+
migrates: false,
|
|
1431
|
+
},
|
|
1432
|
+
},
|
|
1433
|
+
blocked,
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
return { step: null, blocked };
|
|
1140
1437
|
}
|
|
1141
1438
|
/**
|
|
1142
1439
|
* #173: register the org's own app with the platform's jobs scheduler
|
|
@@ -1244,12 +1541,48 @@ function planDatabase(org, byKey, claimed) {
|
|
|
1244
1541
|
* domain) plans no per-org tunnel for Agora: `<slug>-agora.oikos.host` rides
|
|
1245
1542
|
* the shared `*.oikos.host` wildcard tunnel (`scripts/shared-ingress.mjs`).
|
|
1246
1543
|
*/
|
|
1247
|
-
function planTunnel(org, byKey, claimed) {
|
|
1544
|
+
function planTunnel(org, byKey, claimed, recordedIds) {
|
|
1248
1545
|
const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
|
|
1249
1546
|
(org.site.enabled && Boolean(org.domain)) ||
|
|
1250
1547
|
(org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
|
|
1251
1548
|
Boolean(org.domain);
|
|
1252
|
-
|
|
1549
|
+
const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`);
|
|
1550
|
+
if (toggled || !desired)
|
|
1551
|
+
return toggled;
|
|
1552
|
+
// #235: a live, enabled tunnel with no connector recorded -- a first
|
|
1553
|
+
// connector create that failed, or a tunnel that predates connectors --
|
|
1554
|
+
// plans the same step again. The step finds or creates the connector by
|
|
1555
|
+
// name, so it never makes a second one.
|
|
1556
|
+
const live = byKey.get(resourceKey('cloudflare', 'tunnel', org.id));
|
|
1557
|
+
if (!live || recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY])
|
|
1558
|
+
return null;
|
|
1559
|
+
return {
|
|
1560
|
+
action: { system: 'cloudflare', kind: 'tunnel', key: org.id, desired: { enabled: true } },
|
|
1561
|
+
step: {
|
|
1562
|
+
description: `run a cloudflared connector for ${org.slug}'s Cloudflare tunnel`,
|
|
1563
|
+
destroys: false,
|
|
1564
|
+
migrates: false,
|
|
1565
|
+
},
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* #238: the org's own zone's live `status`/`name_servers`, read from the
|
|
1570
|
+
* same `kind: 'zone'` {@link LiveResource} `planZone` already matches by key
|
|
1571
|
+
* -- `@wtfalch/providers/cloudflare`'s `CloudflareClient.listResources`
|
|
1572
|
+
* already returns both (`config: { id, status, name_servers }`), so nothing
|
|
1573
|
+
* new has to be fetched; this just reads what was already there. `null`
|
|
1574
|
+
* means no zone is live yet at all (not a readiness problem `planZone`
|
|
1575
|
+
* itself reports -- it is still planning the zone's own creation).
|
|
1576
|
+
*/
|
|
1577
|
+
function zoneReadiness(domain, byKey) {
|
|
1578
|
+
const zone = byKey.get(resourceKey('cloudflare', 'zone', domain));
|
|
1579
|
+
if (!zone)
|
|
1580
|
+
return null;
|
|
1581
|
+
const config = zone.config;
|
|
1582
|
+
return {
|
|
1583
|
+
active: config.status === 'active',
|
|
1584
|
+
nameServers: config.name_servers ?? [],
|
|
1585
|
+
};
|
|
1253
1586
|
}
|
|
1254
1587
|
/**
|
|
1255
1588
|
* The org's Cloudflare zone for its own domain (`org.ts`'s
|
|
@@ -1281,25 +1614,40 @@ function planZone(org, byKey, claimed) {
|
|
|
1281
1614
|
(org.calls.enabled && org.calls.usesDomain) ||
|
|
1282
1615
|
Boolean(org.domain);
|
|
1283
1616
|
if (!usesDomain || !org.domain)
|
|
1284
|
-
return null;
|
|
1617
|
+
return { step: null, blocked: null };
|
|
1285
1618
|
const key = resourceKey('cloudflare', 'zone', org.domain);
|
|
1286
1619
|
const live = byKey.get(key);
|
|
1287
|
-
if (live)
|
|
1620
|
+
if (live) {
|
|
1288
1621
|
claimed.add(key);
|
|
1289
|
-
|
|
1290
|
-
|
|
1622
|
+
const readiness = zoneReadiness(org.domain, byKey);
|
|
1623
|
+
// #238: a zone stays pending until the customer changes nameservers at
|
|
1624
|
+
// their registrar -- reported here, not silently treated as converged,
|
|
1625
|
+
// so an apply says what it is waiting for. A later apply, once
|
|
1626
|
+
// Cloudflare marks the zone active, reports nothing and the DNS- and
|
|
1627
|
+
// mail-dependent steps below (`planMail`) resume.
|
|
1628
|
+
if (readiness && !readiness.active) {
|
|
1629
|
+
return {
|
|
1630
|
+
step: null,
|
|
1631
|
+
blocked: `${org.slug}'s zone for ${org.domain} is waiting for nameservers: ${readiness.nameServers.join(', ')}`,
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
return { step: null, blocked: null };
|
|
1635
|
+
}
|
|
1291
1636
|
return {
|
|
1292
|
-
action: {
|
|
1293
|
-
system: 'cloudflare',
|
|
1294
|
-
kind: 'zone',
|
|
1295
|
-
key: org.domain,
|
|
1296
|
-
desired: { domain: org.domain },
|
|
1297
|
-
},
|
|
1298
1637
|
step: {
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1638
|
+
action: {
|
|
1639
|
+
system: 'cloudflare',
|
|
1640
|
+
kind: 'zone',
|
|
1641
|
+
key: org.domain,
|
|
1642
|
+
desired: { domain: org.domain },
|
|
1643
|
+
},
|
|
1644
|
+
step: {
|
|
1645
|
+
description: `provision a Cloudflare zone for ${org.domain}`,
|
|
1646
|
+
destroys: false,
|
|
1647
|
+
migrates: false,
|
|
1648
|
+
},
|
|
1302
1649
|
},
|
|
1650
|
+
blocked: null,
|
|
1303
1651
|
};
|
|
1304
1652
|
}
|
|
1305
1653
|
/**
|