@wtfalch/foundry 0.6.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/declare.d.ts +3 -1
- package/dist/bootstrap/declare.js +6 -2
- package/dist/bootstrap/versions.d.ts +17 -0
- package/dist/bootstrap/versions.js +12 -0
- package/dist/calls-sfu.d.ts +5 -4
- package/dist/calls-sfu.js +6 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- 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 +24 -0
- package/dist/org.js +13 -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 +306 -41
- package/dist/plan/executor.d.ts +73 -28
- package/dist/plan/executor.js +217 -55
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/login-host.d.ts +64 -0
- package/dist/plan/login-host.js +52 -0
- package/dist/plan/plan-apply.d.ts +21 -8
- package/dist/plan/plan-apply.js +10 -9
- package/dist/plan/platform-diff.d.ts +23 -1
- package/dist/plan/platform-diff.js +35 -0
- package/dist/plan/platform-providers.d.ts +1 -1
- package/dist/plan/platform-providers.js +71 -16
- package/dist/plan/providers-readers.js +44 -1
- package/dist/store/audit.d.ts +40 -4
- package/dist/store/audit.js +114 -29
- package/dist/store/postgres.js +4 -1
- package/dist/store/schema.d.ts +37 -1
- package/dist/store/schema.js +7 -0
- package/package.json +5 -3
- package/versions.json +8 -0
package/dist/plan/diff.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { NO_AGORA_HOST_VALUES, agoraEnvDrift, agoraHostMissing, desiredAgoraEnv, } from './agora.js';
|
|
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';
|
|
2
3
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
|
|
3
|
-
import { ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, } from './platform-diff.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';
|
|
4
5
|
import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
|
|
5
6
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
6
7
|
export function toPlan(detail) {
|
|
@@ -53,7 +54,11 @@ function resourceKey(system, kind, key) {
|
|
|
53
54
|
* app with the platform's jobs scheduler, idempotent on the *recorded*
|
|
54
55
|
* resource id like {@link planOwner} rather than a live resource, since
|
|
55
56
|
* nothing in `live` can report a scheduler registration (no reader exists for
|
|
56
|
-
* 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
|
|
57
62
|
* else in `live` that none of these eighteen singletons and however many
|
|
58
63
|
* declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
|
|
59
64
|
* guests, calls on the org's own domain, adopted/bespoke apps, the actual
|
|
@@ -61,7 +66,7 @@ function resourceKey(system, kind, key) {
|
|
|
61
66
|
* Company Factory end to end", which wires real readers and executors
|
|
62
67
|
* against this seam.
|
|
63
68
|
*/
|
|
64
|
-
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 = {}) {
|
|
65
70
|
const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
|
|
66
71
|
const claimed = new Set();
|
|
67
72
|
const plannedSteps = [];
|
|
@@ -71,9 +76,29 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
71
76
|
const owner = planOwner(org, recordedIds, platformReadiness.authReady);
|
|
72
77
|
if (owner)
|
|
73
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 = [];
|
|
74
90
|
const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost);
|
|
75
|
-
|
|
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) {
|
|
76
100
|
plannedSteps.push(mail.step);
|
|
101
|
+
}
|
|
77
102
|
const mailCopy = planMailCopy(org, byKey, recordedIds);
|
|
78
103
|
if (mailCopy)
|
|
79
104
|
plannedSteps.push(mailCopy);
|
|
@@ -135,21 +160,36 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
135
160
|
const database = planDatabase(org, byKey, claimed);
|
|
136
161
|
if (database)
|
|
137
162
|
plannedSteps.push(database);
|
|
138
|
-
const app = planApp(org, byKey, claimed);
|
|
139
|
-
if (app)
|
|
140
|
-
plannedSteps.push(app);
|
|
141
|
-
const tunnel = planTunnel(org, byKey, claimed);
|
|
142
|
-
|
|
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) {
|
|
143
176
|
plannedSteps.push(tunnel);
|
|
144
|
-
|
|
145
|
-
if (zone)
|
|
146
|
-
plannedSteps.push(zone);
|
|
177
|
+
}
|
|
178
|
+
if (zone.step)
|
|
179
|
+
plannedSteps.push(zone.step);
|
|
147
180
|
// Right after the zone (#43): `cloudflareRateLimit` resolves the zone by
|
|
148
181
|
// apex domain, so the zone step above must already have run (or the zone
|
|
149
182
|
// must already exist live) by the time this one's action executes.
|
|
150
183
|
const rateLimit = planRateLimit(org, recordedIds);
|
|
151
|
-
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) {
|
|
152
191
|
plannedSteps.push(rateLimit);
|
|
192
|
+
}
|
|
153
193
|
// After the apps: the customers org is granted the project the first app
|
|
154
194
|
// step creates.
|
|
155
195
|
const customers = planCustomers(org, live, claimed, recordedIds);
|
|
@@ -199,6 +239,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
199
239
|
// budget, key) would otherwise also have nothing to act on.
|
|
200
240
|
if (issuer.blocked)
|
|
201
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
|
+
}
|
|
202
246
|
if (mail.blocked)
|
|
203
247
|
unmanaged.push({ description: mail.blocked, key: 'coolify:mail' });
|
|
204
248
|
// #162: reported separately from `mail.blocked` -- missing an owner
|
|
@@ -218,6 +262,13 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
218
262
|
unmanaged.push({ description: storage.blocked, key: 'files:storage' });
|
|
219
263
|
if (aiBudget.blocked)
|
|
220
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);
|
|
221
272
|
return { orgId: org.id, plannedSteps, unmanaged };
|
|
222
273
|
}
|
|
223
274
|
/**
|
|
@@ -355,6 +406,83 @@ function planOwner(org, recordedIds, authReady) {
|
|
|
355
406
|
},
|
|
356
407
|
};
|
|
357
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
|
+
}
|
|
358
486
|
/**
|
|
359
487
|
* #162: the flat scope id `executor.ts`'s `ensureOwnerMailbox` records once
|
|
360
488
|
* it has given the org's owner a mailbox (`MAIL_OWNER_MAILBOX_SCOPE_KEY`
|
|
@@ -364,6 +492,8 @@ function planOwner(org, recordedIds, authReady) {
|
|
|
364
492
|
const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
|
|
365
493
|
/** #230: restated from `executor.ts`'s `MAIL_ROLE_MAILBOXES_SCOPE_KEY`, same rule: comma-joined local parts already created. */
|
|
366
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';
|
|
367
497
|
/** Restated from `executor.ts`, the same rule: the mail domain the last apply resolved. */
|
|
368
498
|
const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
|
|
369
499
|
/** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
|
|
@@ -546,6 +676,10 @@ function planMailCopy(org, byKey, recordedIds) {
|
|
|
546
676
|
};
|
|
547
677
|
}
|
|
548
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;
|
|
549
683
|
const key = resourceKey('coolify', 'chat', org.id);
|
|
550
684
|
const live = byKey.get(key);
|
|
551
685
|
if (live)
|
|
@@ -1203,23 +1337,103 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
|
|
|
1203
1337
|
* resource's `enabled` field -- a live resource nothing has actually
|
|
1204
1338
|
* checked yet is assumed fine rather than replanned every time.
|
|
1205
1339
|
*/
|
|
1206
|
-
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
|
+
: [];
|
|
1207
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)`);
|
|
1208
1359
|
if (toggled)
|
|
1209
|
-
return toggled;
|
|
1360
|
+
return { step: toggled, blocked };
|
|
1210
1361
|
if (!org.hosting.enabled)
|
|
1211
|
-
return null;
|
|
1362
|
+
return { step: null, blocked };
|
|
1212
1363
|
const live = byKey.get(resourceKey('coolify', 'app', org.id));
|
|
1213
|
-
if (!live
|
|
1214
|
-
return null;
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
migrates: false,
|
|
1221
|
-
},
|
|
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 },
|
|
1222
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 };
|
|
1223
1437
|
}
|
|
1224
1438
|
/**
|
|
1225
1439
|
* #173: register the org's own app with the platform's jobs scheduler
|
|
@@ -1327,12 +1541,48 @@ function planDatabase(org, byKey, claimed) {
|
|
|
1327
1541
|
* domain) plans no per-org tunnel for Agora: `<slug>-agora.oikos.host` rides
|
|
1328
1542
|
* the shared `*.oikos.host` wildcard tunnel (`scripts/shared-ingress.mjs`).
|
|
1329
1543
|
*/
|
|
1330
|
-
function planTunnel(org, byKey, claimed) {
|
|
1544
|
+
function planTunnel(org, byKey, claimed, recordedIds) {
|
|
1331
1545
|
const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
|
|
1332
1546
|
(org.site.enabled && Boolean(org.domain)) ||
|
|
1333
1547
|
(org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
|
|
1334
1548
|
Boolean(org.domain);
|
|
1335
|
-
|
|
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
|
+
};
|
|
1336
1586
|
}
|
|
1337
1587
|
/**
|
|
1338
1588
|
* The org's Cloudflare zone for its own domain (`org.ts`'s
|
|
@@ -1364,25 +1614,40 @@ function planZone(org, byKey, claimed) {
|
|
|
1364
1614
|
(org.calls.enabled && org.calls.usesDomain) ||
|
|
1365
1615
|
Boolean(org.domain);
|
|
1366
1616
|
if (!usesDomain || !org.domain)
|
|
1367
|
-
return null;
|
|
1617
|
+
return { step: null, blocked: null };
|
|
1368
1618
|
const key = resourceKey('cloudflare', 'zone', org.domain);
|
|
1369
1619
|
const live = byKey.get(key);
|
|
1370
|
-
if (live)
|
|
1620
|
+
if (live) {
|
|
1371
1621
|
claimed.add(key);
|
|
1372
|
-
|
|
1373
|
-
|
|
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
|
+
}
|
|
1374
1636
|
return {
|
|
1375
|
-
action: {
|
|
1376
|
-
system: 'cloudflare',
|
|
1377
|
-
kind: 'zone',
|
|
1378
|
-
key: org.domain,
|
|
1379
|
-
desired: { domain: org.domain },
|
|
1380
|
-
},
|
|
1381
1637
|
step: {
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
+
},
|
|
1385
1649
|
},
|
|
1650
|
+
blocked: null,
|
|
1386
1651
|
};
|
|
1387
1652
|
}
|
|
1388
1653
|
/**
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/p
|
|
|
2
2
|
import type { DeclaredOrg, HostingBuildPack } from '../org.js';
|
|
3
3
|
import type { DeclaredOrgStore } from '../store.js';
|
|
4
4
|
import type { StepAction } from './diff.js';
|
|
5
|
+
import { type LoginHostBindingsPort } from './login-host.js';
|
|
5
6
|
import { type MailCopyPort } from './mail-copy.js';
|
|
6
7
|
import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
|
|
7
8
|
/**
|
|
@@ -140,13 +141,14 @@ export interface CoolifyPort {
|
|
|
140
141
|
}>>;
|
|
141
142
|
/**
|
|
142
143
|
* Upsert environment variables on an application in one call — used here to
|
|
143
|
-
* push the
|
|
144
|
-
* (hosting-wiring campaign
|
|
145
|
-
*
|
|
146
|
-
* value is a live credential: it must reach
|
|
147
|
-
* never a step's return value, `serviceIds`
|
|
148
|
-
* record, a log or a thrown error. See
|
|
149
|
-
* `
|
|
144
|
+
* push the org's own app's `DATABASE_URL_OWNER` plus a freshly minted
|
|
145
|
+
* `DATABASE_URL` runtime-role credential (hosting-wiring campaign, split
|
|
146
|
+
* per #239 the same way #117 already splits the cms app's), and likewise
|
|
147
|
+
* for the cms app itself. Each value is a live credential: it must reach
|
|
148
|
+
* this call and nothing else — never a step's return value, `serviceIds`
|
|
149
|
+
* (hence the store), an audit record, a log or a thrown error. See
|
|
150
|
+
* `coolifyApp`'s `pushDatabaseUrl`/`ensureAppRuntimeUrl` and `cmsSite`'s
|
|
151
|
+
* `ensureCmsRuntimeUrl`.
|
|
150
152
|
*/
|
|
151
153
|
bulkUpsertEnvs(applicationUuid: string, envs: Array<{
|
|
152
154
|
key: string;
|
|
@@ -766,6 +768,24 @@ export interface ZitadelPort {
|
|
|
766
768
|
* reports no longer live is never attempted at all.
|
|
767
769
|
*/
|
|
768
770
|
listLoginClientPats(orgId: string, userId: string): Promise<string[]>;
|
|
771
|
+
/**
|
|
772
|
+
* #267: adds the org's own company login host to the ZITADEL instance's
|
|
773
|
+
* trusted domains (`@wtfalch/providers`' `ZitadelClient.ensureTrustedDomain`)
|
|
774
|
+
* -- the functionally critical half of `zitadelLoginHost` below, per
|
|
775
|
+
* `auth/docs/company-login-hosts.md`'s enforcement section: "the issuer
|
|
776
|
+
* refuses a public host that is not one of the instance's trusted
|
|
777
|
+
* domains." Idempotent: lists first, adds only if not already trusted.
|
|
778
|
+
*/
|
|
779
|
+
ensureTrustedDomain(domain: string): Promise<{
|
|
780
|
+
added: boolean;
|
|
781
|
+
}>;
|
|
782
|
+
/**
|
|
783
|
+
* #267: moves an already-created app's Login V2 base URI, read-modify-
|
|
784
|
+
* write (`@wtfalch/providers`' `ZitadelClient.updateOidcAppLoginBaseUri`).
|
|
785
|
+
* `zitadelLoginHost` below calls this for each live app whose client id
|
|
786
|
+
* the binding holds; `zitadelApp` never sets it at creation.
|
|
787
|
+
*/
|
|
788
|
+
updateOidcAppLoginBaseUri(orgId: string, projectId: string, appId: string, loginBaseUri: string): Promise<void>;
|
|
769
789
|
}
|
|
770
790
|
/**
|
|
771
791
|
* Provisions one org's organisation on Files (#80), matching
|
|
@@ -959,6 +979,15 @@ export interface FoundryStepExecutorPorts {
|
|
|
959
979
|
* step throws. The planner blocks these steps first while a key is not held.
|
|
960
980
|
*/
|
|
961
981
|
platformOrg?: PlatformOrgPortSource;
|
|
982
|
+
/**
|
|
983
|
+
* #267: zitadel-login's own `LOGIN_HOST_BINDINGS` entry for the org -- see
|
|
984
|
+
* {@link LoginHostBindingsPort}'s own doc comment. Optional the same way
|
|
985
|
+
* `jobsScheduler`/`mailCopy` are: a host that has not wired this yet gets no
|
|
986
|
+
* login-host step at all -- the planner reports it blocked (the host also
|
|
987
|
+
* sets `FoundryPlanApplyOptions.loginHostBindings`), so nothing is trusted
|
|
988
|
+
* or repointed without the binding.
|
|
989
|
+
*/
|
|
990
|
+
loginHostBindings?: LoginHostBindingsPort;
|
|
962
991
|
/**
|
|
963
992
|
* Hands a just-minted, one-time secret (today: an API app's
|
|
964
993
|
* `clientSecret`) to the host, immediately after the provider returns it
|
|
@@ -1060,6 +1089,14 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1060
1089
|
* recorded).
|
|
1061
1090
|
*/
|
|
1062
1091
|
private zitadelOwner;
|
|
1092
|
+
/**
|
|
1093
|
+
* `plan/diff.ts`'s `planLoginHost` (#267): adds the org's own company
|
|
1094
|
+
* login host (`action.desired.host`) to the ZITADEL instance's trusted
|
|
1095
|
+
* domains, writes zitadel-login's binding, then repoints the apps whose
|
|
1096
|
+
* client id that binding holds. Apps created later are not in it yet:
|
|
1097
|
+
* the next run replans (`diff.ts`'s `planLoginHost`) and moves them.
|
|
1098
|
+
*/
|
|
1099
|
+
private zitadelLoginHost;
|
|
1063
1100
|
/**
|
|
1064
1101
|
* `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
|
|
1065
1102
|
* in through a second ZITADEL org, `<slug> customers`, with
|
|
@@ -1108,28 +1145,34 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1108
1145
|
private ensureLogVolume;
|
|
1109
1146
|
private coolifyDatabase;
|
|
1110
1147
|
/**
|
|
1111
|
-
* Pushes the database's connection string into the app's environment
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1114
|
-
*
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
1119
|
-
* `db-role-guard.sh` then refuses to boot, since `DATABASE_URL_OWNER` being
|
|
1120
|
-
* set means it expects `DATABASE_URL` to connect as a distinct,
|
|
1121
|
-
* non-superuser runtime role (`<database>_rt`), which nothing here creates.
|
|
1122
|
-
* Minting that role's own password and URL is a provisioning step of its
|
|
1123
|
-
* own, not built here (Finding, this campaign's report).
|
|
1148
|
+
* Pushes the database's connection string into the app's environment,
|
|
1149
|
+
* split the same way `cmsSite`'s own database is (#117, #239): the
|
|
1150
|
+
* superuser connection as `DATABASE_URL_OWNER`, and a distinct, restricted
|
|
1151
|
+
* `<dbName>_rt` role's connection as `DATABASE_URL`. Archon's and manage's
|
|
1152
|
+
* own boot scripts (`db-role-guard.sh`) refuse to start against a
|
|
1153
|
+
* superuser `DATABASE_URL`, and lokessmie's refuses to start without
|
|
1154
|
+
* `DATABASE_URL_OWNER` at all -- a bare superuser `DATABASE_URL` with no
|
|
1155
|
+
* `DATABASE_URL_OWNER` is "before the split" there too.
|
|
1124
1156
|
*
|
|
1125
|
-
*
|
|
1126
|
-
* (`postgres://user:password@host/db`):
|
|
1127
|
-
*
|
|
1128
|
-
* nowhere else in this class -- never returned, never
|
|
1129
|
-
* `serviceIds` (hence never in the store or an audit record),
|
|
1130
|
-
* never interpolated into a thrown error.
|
|
1157
|
+
* Both connection strings are live credentials
|
|
1158
|
+
* (`postgres://user:password@host/db`): read from `CoolifyPort.getDatabase`
|
|
1159
|
+
* (or minted here, for the runtime one) and handed straight to
|
|
1160
|
+
* `bulkUpsertEnvs` and nowhere else in this class -- never returned, never
|
|
1161
|
+
* recorded in `serviceIds` (hence never in the store or an audit record),
|
|
1162
|
+
* never logged, never interpolated into a thrown error.
|
|
1131
1163
|
*/
|
|
1132
1164
|
private pushDatabaseUrl;
|
|
1165
|
+
/**
|
|
1166
|
+
* `null` once the app already has a `DATABASE_URL` -- never rotated, the
|
|
1167
|
+
* same rule `ensureCmsRuntimeUrl` follows and for the same reason: the
|
|
1168
|
+
* app's own boot script creates or updates the live `<dbName>_rt` Postgres
|
|
1169
|
+
* role from exactly the username/password this method mints, once:
|
|
1170
|
+
* overwriting `DATABASE_URL` on a later apply would leave that
|
|
1171
|
+
* already-created role's real password out of sync with whatever foundry
|
|
1172
|
+
* hands the app next. See `ensureCmsRuntimeUrl`'s own doc comment for the
|
|
1173
|
+
* parsing and encoding choices this mirrors exactly.
|
|
1174
|
+
*/
|
|
1175
|
+
private ensureAppRuntimeUrl;
|
|
1133
1176
|
/**
|
|
1134
1177
|
* Pushes what the org's app needs to sign a user in through ZITADEL (gap
|
|
1135
1178
|
* P2, `.plans/2026-09-26-level-1-org-map.md`) -- a second `bulkUpsertEnvs`
|
|
@@ -1146,8 +1189,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1146
1189
|
* (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
|
|
1147
1190
|
* reason. `AUTH_APP_KEY` (#206 -- which broker an org's app registers with
|
|
1148
1191
|
* is its own design question, left out of #204's own "How" rather than
|
|
1149
|
-
* guessed)
|
|
1150
|
-
* `
|
|
1192
|
+
* guessed) is out of scope here (task brief). `DATABASE_URL_OWNER`/
|
|
1193
|
+
* `DATABASE_URL` are pushed by `pushDatabaseUrl` instead, not here --
|
|
1194
|
+
* that call runs only once `hosting.database` is declared and
|
|
1195
|
+
* provisioned, where this one is unconditional (#239).
|
|
1151
1196
|
*/
|
|
1152
1197
|
private pushAppEnv;
|
|
1153
1198
|
/**
|