@wtfalch/foundry 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/declare.js +1 -5
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/migrations/0029_hosting_entries.sql +109 -0
- package/dist/migrations/0030_hosting_entry_hostnames.sql +24 -0
- package/dist/org.d.ts +87 -29
- package/dist/org.js +180 -27
- package/dist/plan/agora.d.ts +7 -0
- package/dist/plan/agora.js +9 -0
- package/dist/plan/broker-entries.d.ts +104 -0
- package/dist/plan/broker-entries.js +214 -0
- package/dist/plan/diff.d.ts +2 -0
- package/dist/plan/diff.js +274 -50
- package/dist/plan/executor.d.ts +89 -29
- package/dist/plan/executor.js +495 -233
- package/dist/plan/hostnames.d.ts +24 -0
- package/dist/plan/hostnames.js +56 -0
- package/dist/plan/index.d.ts +3 -1
- package/dist/plan/index.js +2 -1
- package/dist/plan/locked-hostnames.d.ts +8 -0
- package/dist/plan/locked-hostnames.js +26 -0
- package/dist/plan/login-host.d.ts +1 -1
- package/dist/plan/login-host.js +5 -2
- package/dist/plan/plan-apply.d.ts +16 -0
- package/dist/plan/plan-apply.js +48 -11
- package/dist/plan/platform-diff.d.ts +69 -10
- package/dist/plan/platform-diff.js +133 -14
- package/dist/plan/platform-providers.d.ts +102 -2
- package/dist/plan/platform-providers.js +486 -40
- package/dist/plan/zitadel-system-api.d.ts +64 -0
- package/dist/plan/zitadel-system-api.js +125 -0
- package/dist/platform.d.ts +1 -1
- package/dist/store/postgres.js +12 -12
- package/dist/store/schema.d.ts +20 -1
- package/dist/store/schema.js +7 -1
- package/package.json +1 -1
package/dist/plan/diff.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
|
+
import { APP_ENTRY_KEY } from '../org.js';
|
|
2
3
|
import { NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, desiredAgoraEnv, } from './agora.js';
|
|
4
|
+
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
|
|
5
|
+
import { appHostname, entryHostnames, tunnelDnsHostnames } from './hostnames.js';
|
|
3
6
|
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
|
|
4
7
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
|
|
5
|
-
import {
|
|
8
|
+
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, versionNotAppliedLine, } from './platform-diff.js';
|
|
6
9
|
import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
|
|
7
10
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
8
11
|
export function toPlan(detail) {
|
|
@@ -163,12 +166,16 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
163
166
|
// `AUTH_CLIENT_ID`, which only exists once that app's own `zitadel:app`
|
|
164
167
|
// step has run -- `plan-apply.ts`'s `runSteps` executes `plannedSteps` in
|
|
165
168
|
// this array order, one at a time.
|
|
169
|
+
// Customers before the entries that sign them in: `pushAppEnv` and the
|
|
170
|
+
// broker entry read the customers organisation's id.
|
|
171
|
+
const customers = planCustomers(org, live, claimed, recordedIds);
|
|
172
|
+
if (customers)
|
|
173
|
+
plannedSteps.push(customers);
|
|
166
174
|
plannedSteps.push(...planIssuerApps(org, byKey, claimed));
|
|
175
|
+
plannedSteps.push(...planEntryClients(org, byKey, claimed));
|
|
167
176
|
// The Access gate before the database, app, tunnel and zone (#84): on a
|
|
168
177
|
// first apply the hostname is gated before anything answers on it.
|
|
169
|
-
|
|
170
|
-
if (access)
|
|
171
|
-
plannedSteps.push(access);
|
|
178
|
+
plannedSteps.push(...planAccess(org, recordedIds));
|
|
172
179
|
// Database before app (hosting-wiring campaign): `plan-apply.ts`'s
|
|
173
180
|
// `runSteps` executes `plannedSteps` in this array order, and the app's own
|
|
174
181
|
// step (`executor.ts`'s `coolifyApp`) pushes the database's connection
|
|
@@ -178,9 +185,12 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
178
185
|
const database = planDatabase(org, byKey, claimed);
|
|
179
186
|
if (database)
|
|
180
187
|
plannedSteps.push(database);
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
188
|
+
const authIssuer = 'error' in agoraHost ? null : agoraHost.issuer;
|
|
189
|
+
const app = planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer);
|
|
190
|
+
plannedSteps.push(...app.steps);
|
|
191
|
+
// After the apps: the broker entry's key is minted into each app's env.
|
|
192
|
+
const brokerEntries = planBrokerEntries(org, recordedIds, plannedSteps, options.broker ?? false);
|
|
193
|
+
plannedSteps.push(...brokerEntries.steps);
|
|
184
194
|
const tunnel = planTunnel(org, byKey, claimed, recordedIds);
|
|
185
195
|
// #238: a tunnel that is being provisioned waits for an active zone; parking
|
|
186
196
|
// one never does.
|
|
@@ -208,11 +218,6 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
208
218
|
else if (rateLimit) {
|
|
209
219
|
plannedSteps.push(rateLimit);
|
|
210
220
|
}
|
|
211
|
-
// After the apps: the customers org is granted the project the first app
|
|
212
|
-
// step creates.
|
|
213
|
-
const customers = planCustomers(org, live, claimed, recordedIds);
|
|
214
|
-
if (customers)
|
|
215
|
-
plannedSteps.push(customers);
|
|
216
221
|
// #164: every org's own Agora -- always on, no toggle. Last, so nothing
|
|
217
222
|
// else in the org waits on it: `runSteps` stops at the first failing step.
|
|
218
223
|
// Its fixed ZITADEL client before the Coolify app it signs into:
|
|
@@ -287,6 +292,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
287
292
|
unmanaged.push(...zoneBlocked);
|
|
288
293
|
// #243: an appEnv source nothing will record is reported, not silently skipped.
|
|
289
294
|
unmanaged.push(...app.blocked);
|
|
295
|
+
unmanaged.push(...brokerEntries.blocked);
|
|
290
296
|
return { orgId: org.id, plannedSteps, unmanaged, versionChanges };
|
|
291
297
|
}
|
|
292
298
|
/**
|
|
@@ -523,6 +529,7 @@ const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
|
|
|
523
529
|
const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_app';
|
|
524
530
|
/** #236: restated from `executor.ts`, the same rule: the domain the org tunnel's DNS was last published under. */
|
|
525
531
|
const CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY = 'cloudflare_tunnel_dns_domain';
|
|
532
|
+
const CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_dns_hostnames';
|
|
526
533
|
/** Restated from `executor.ts`, the same rule: the mail domain the last apply resolved. */
|
|
527
534
|
const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
|
|
528
535
|
/** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
|
|
@@ -1331,8 +1338,8 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
|
|
|
1331
1338
|
* park-not-delete rule (#7 corrections 1) -- turning it off never deletes
|
|
1332
1339
|
* the org's own app, database or tunnel configuration, it parks it.
|
|
1333
1340
|
*/
|
|
1334
|
-
function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark) {
|
|
1335
|
-
const key = resourceKey(system, kind,
|
|
1341
|
+
function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark, stepKey = org.id) {
|
|
1342
|
+
const key = resourceKey(system, kind, stepKey);
|
|
1336
1343
|
const live = byKey.get(key);
|
|
1337
1344
|
// A live resource is on unless it says it is parked. Providers' own
|
|
1338
1345
|
// configs carry no `enabled` field at all, so only a park step's
|
|
@@ -1348,7 +1355,7 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
|
|
|
1348
1355
|
if (!liveEnabled)
|
|
1349
1356
|
return null;
|
|
1350
1357
|
return {
|
|
1351
|
-
action: { system, kind, key:
|
|
1358
|
+
action: { system, kind, key: stepKey, desired: { enabled: false } },
|
|
1352
1359
|
step: { description: describePark(), destroys: true, migrates: false },
|
|
1353
1360
|
};
|
|
1354
1361
|
}
|
|
@@ -1359,7 +1366,7 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
|
|
|
1359
1366
|
if (liveEnabled)
|
|
1360
1367
|
return null;
|
|
1361
1368
|
return {
|
|
1362
|
-
action: { system, kind, key:
|
|
1369
|
+
action: { system, kind, key: stepKey, desired: { enabled: true } },
|
|
1363
1370
|
step: { description: describeProvision(), destroys: false, migrates: false },
|
|
1364
1371
|
};
|
|
1365
1372
|
}
|
|
@@ -1386,7 +1393,7 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
|
|
|
1386
1393
|
* resource's `enabled` field -- a live resource nothing has actually
|
|
1387
1394
|
* checked yet is assumed fine rather than replanned every time.
|
|
1388
1395
|
*/
|
|
1389
|
-
function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
1396
|
+
function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authIssuer) {
|
|
1390
1397
|
// #243: an `appEnv` source with no recorded value and no step planned to
|
|
1391
1398
|
// record one this run has nothing to push, now or after this apply.
|
|
1392
1399
|
const sourceStep = {
|
|
@@ -1394,28 +1401,38 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
|
1394
1401
|
aiOrganisationId: ['ai', 'organisation'],
|
|
1395
1402
|
mailDomain: ['coolify', 'mail'],
|
|
1396
1403
|
};
|
|
1397
|
-
const
|
|
1398
|
-
|
|
1404
|
+
const entry = org.hosting.enabled
|
|
1405
|
+
? org.hosting.entries.find((e) => e.key === entryKey)
|
|
1406
|
+
: undefined;
|
|
1407
|
+
// Descriptions and unmanaged keys of the `app` entry stay as they were; any
|
|
1408
|
+
// other entry is named in them.
|
|
1409
|
+
const label = entryKey === APP_ENTRY_KEY ? 'app' : `${entryKey} app`;
|
|
1410
|
+
const blockedKey = (rest) => entryKey === APP_ENTRY_KEY ? `coolify:app:${rest}` : `coolify:app:${entryKey}:${rest}`;
|
|
1411
|
+
const { values: wantedEnv, unresolved } = entry
|
|
1412
|
+
? resolveAppEnv(org, entry, recordedIds)
|
|
1413
|
+
: { values: {}, unresolved: [] };
|
|
1414
|
+
const stepKey = appStepKey(org, entryKey);
|
|
1415
|
+
const blocked = entry
|
|
1399
1416
|
? unresolved
|
|
1400
1417
|
.filter(([, source]) => !plannedSteps.some((p) => p.action.system === sourceStep[source][0] &&
|
|
1401
1418
|
p.action.kind === sourceStep[source][1]))
|
|
1402
1419
|
.map(([name, source]) => ({
|
|
1403
|
-
description: `${org.slug}'s
|
|
1404
|
-
key: `
|
|
1420
|
+
description: `${org.slug}'s ${label} env ${name} is blocked, not planned: its source ${source} has no recorded value yet`,
|
|
1421
|
+
key: blockedKey(`env:${name}`),
|
|
1405
1422
|
}))
|
|
1406
1423
|
: [];
|
|
1407
|
-
const toggled = planHostingToggle('coolify', 'app',
|
|
1424
|
+
const toggled = planHostingToggle('coolify', 'app', entry !== undefined, org, byKey, claimed, () => `provision ${org.slug}'s ${label}`, () => `park ${org.slug}'s ${label} (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
|
|
1408
1425
|
if (toggled)
|
|
1409
1426
|
return { step: toggled, blocked };
|
|
1410
|
-
if (!
|
|
1427
|
+
if (!entry)
|
|
1411
1428
|
return { step: null, blocked };
|
|
1412
|
-
const live = byKey.get(resourceKey('coolify', 'app',
|
|
1429
|
+
const live = byKey.get(resourceKey('coolify', 'app', stepKey));
|
|
1413
1430
|
if (!live)
|
|
1414
1431
|
return { step: null, blocked };
|
|
1415
1432
|
const action = {
|
|
1416
1433
|
system: 'coolify',
|
|
1417
1434
|
kind: 'app',
|
|
1418
|
-
key:
|
|
1435
|
+
key: stepKey,
|
|
1419
1436
|
desired: { enabled: true },
|
|
1420
1437
|
};
|
|
1421
1438
|
if (live.config.hasLogVolume === false) {
|
|
@@ -1423,7 +1440,7 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
|
1423
1440
|
step: {
|
|
1424
1441
|
action,
|
|
1425
1442
|
step: {
|
|
1426
|
-
description: `add a durable log directory to ${org.slug}'s
|
|
1443
|
+
description: `add a durable log directory to ${org.slug}'s ${label} (persistent volume at /app/logs + LOG_DIR) so a database outage does not erase the evidence (#75)`,
|
|
1427
1444
|
destroys: false,
|
|
1428
1445
|
migrates: false,
|
|
1429
1446
|
},
|
|
@@ -1437,13 +1454,13 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
|
1437
1454
|
// names (`envNames` absent) reads as compliant, like `hasLogVolume`.
|
|
1438
1455
|
const envNames = live.config.envNames;
|
|
1439
1456
|
const dbLive = byKey.get(resourceKey('coolify', 'database', org.id));
|
|
1440
|
-
const dbReady =
|
|
1457
|
+
const dbReady = entry.database && Boolean(recordedIds[`coolify:database:${org.id}`]);
|
|
1441
1458
|
if (dbReady && dbLive?.config.hasInternalUrl === false) {
|
|
1442
1459
|
// Rule 2: the push cannot succeed yet, so report it rather than replan
|
|
1443
1460
|
// forever.
|
|
1444
1461
|
blocked.push({
|
|
1445
|
-
description: `${org.slug}'s
|
|
1446
|
-
key: '
|
|
1462
|
+
description: `${org.slug}'s ${label} database URLs are blocked, not planned: the database reports no internal connection URL yet`,
|
|
1463
|
+
key: blockedKey('database-url'),
|
|
1447
1464
|
});
|
|
1448
1465
|
}
|
|
1449
1466
|
else if (dbReady &&
|
|
@@ -1453,12 +1470,12 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
|
1453
1470
|
((Array.isArray(envNames) && !envNames.includes('DATABASE_URL_OWNER')) ||
|
|
1454
1471
|
// A crash between the push and the restart leaves the env right and no
|
|
1455
1472
|
// marker: replan once more to restart and set it.
|
|
1456
|
-
!recordedIds[
|
|
1473
|
+
!recordedIds[dbEnvAppliedScopeKey(entryKey)])) {
|
|
1457
1474
|
return {
|
|
1458
1475
|
step: {
|
|
1459
1476
|
action,
|
|
1460
1477
|
step: {
|
|
1461
|
-
description: `give ${org.slug}'s
|
|
1478
|
+
description: `give ${org.slug}'s ${label} its owner and runtime database URLs and restart it (#239)`,
|
|
1462
1479
|
destroys: false,
|
|
1463
1480
|
migrates: false,
|
|
1464
1481
|
},
|
|
@@ -1466,15 +1483,74 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
|
1466
1483
|
blocked,
|
|
1467
1484
|
};
|
|
1468
1485
|
}
|
|
1469
|
-
// #243 (rule 3): a changed `
|
|
1486
|
+
// #243 (rule 3): a changed entry `env` reaches an app that is already
|
|
1470
1487
|
// live. `pushAppEnv` records what it pushed, so this plans the same step
|
|
1471
1488
|
// again, with a redeploy, only once the resolved map differs from that.
|
|
1472
|
-
if (JSON.stringify(wantedEnv) !== (recordedIds[
|
|
1489
|
+
if (JSON.stringify(wantedEnv) !== (recordedIds[appEnvAppliedScopeKey(entryKey)] ?? '{}')) {
|
|
1490
|
+
return {
|
|
1491
|
+
step: {
|
|
1492
|
+
action,
|
|
1493
|
+
step: {
|
|
1494
|
+
description: `push ${org.slug}'s changed ${label} env (appEnv) and redeploy the app`,
|
|
1495
|
+
destroys: false,
|
|
1496
|
+
migrates: false,
|
|
1497
|
+
},
|
|
1498
|
+
},
|
|
1499
|
+
blocked,
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
// F5 (rule 3): who signs in, which client and which issuer reach an app
|
|
1503
|
+
// that is already live. `pushAppEnv` records what it pushed.
|
|
1504
|
+
if (entry.uses?.auth &&
|
|
1505
|
+
JSON.stringify(entryAuthEnv(org, entry, recordedIds, authIssuer)) !==
|
|
1506
|
+
(recordedIds[entryAuthEnvAppliedScopeKey(entryKey)] ?? '{}')) {
|
|
1507
|
+
return {
|
|
1508
|
+
step: {
|
|
1509
|
+
action,
|
|
1510
|
+
step: {
|
|
1511
|
+
description: `push ${org.slug}'s ${label} sign-in env (organisation, client, issuer) and redeploy the app`,
|
|
1512
|
+
destroys: false,
|
|
1513
|
+
migrates: false,
|
|
1514
|
+
},
|
|
1515
|
+
},
|
|
1516
|
+
blocked,
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
// A changed hostname reaches an app that is already live: its domains are
|
|
1520
|
+
// patched and it is redeployed.
|
|
1521
|
+
if (JSON.stringify(entryHostnames(org, entry)) !==
|
|
1522
|
+
JSON.stringify(appliedAppDomains(org, recordedIds, entryKey))) {
|
|
1473
1523
|
return {
|
|
1474
1524
|
step: {
|
|
1475
1525
|
action,
|
|
1476
1526
|
step: {
|
|
1477
|
-
description: `
|
|
1527
|
+
description: `apply ${org.slug}'s ${label} hostnames and redeploy the app`,
|
|
1528
|
+
destroys: false,
|
|
1529
|
+
migrates: false,
|
|
1530
|
+
},
|
|
1531
|
+
},
|
|
1532
|
+
blocked,
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
// A changed `folder`, `port` or `autoDeploy` reaches an app that is already live.
|
|
1536
|
+
const want = appSettingsOf(entry);
|
|
1537
|
+
const have = appliedAppSettings(recordedIds, entryKey);
|
|
1538
|
+
// Coolify has no value that resets a port to its default, so a port taken
|
|
1539
|
+
// back to null cannot be applied (rule 2): reported, not planned.
|
|
1540
|
+
if (want.port === null && have.port !== null) {
|
|
1541
|
+
blocked.push({
|
|
1542
|
+
description: `${org.slug}'s ${label} port is blocked, not planned: Coolify cannot reset it to its default, so set a port`,
|
|
1543
|
+
key: blockedKey('port'),
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
if (want.folder !== have.folder ||
|
|
1547
|
+
want.autoDeploy !== have.autoDeploy ||
|
|
1548
|
+
(want.port !== null && want.port !== have.port)) {
|
|
1549
|
+
return {
|
|
1550
|
+
step: {
|
|
1551
|
+
action,
|
|
1552
|
+
step: {
|
|
1553
|
+
description: `apply ${org.slug}'s ${label} folder, port and auto-deploy settings`,
|
|
1478
1554
|
destroys: false,
|
|
1479
1555
|
migrates: false,
|
|
1480
1556
|
},
|
|
@@ -1484,6 +1560,44 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
|
1484
1560
|
}
|
|
1485
1561
|
return { step: null, blocked };
|
|
1486
1562
|
}
|
|
1563
|
+
/**
|
|
1564
|
+
* {@link planApp} once per hosting entry. A key with no declared entry still
|
|
1565
|
+
* runs for `app`, so an org that turns hosting off parks the app it has live.
|
|
1566
|
+
*/
|
|
1567
|
+
function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer) {
|
|
1568
|
+
const steps = [];
|
|
1569
|
+
const blocked = [];
|
|
1570
|
+
// An entry removed from the list while its app is recorded is parked like a
|
|
1571
|
+
// removed `app`: `planApp` finds no entry for its key.
|
|
1572
|
+
const recordedKeys = Object.keys(recordedIds)
|
|
1573
|
+
.filter((id) => id.startsWith(`coolify:app:${org.id}:`))
|
|
1574
|
+
.map((id) => id.slice(`coolify:app:${org.id}:`.length));
|
|
1575
|
+
const declared = new Set([APP_ENTRY_KEY, ...org.hosting.entries.map((e) => e.key)]);
|
|
1576
|
+
const keys = new Set([...declared, ...recordedKeys]);
|
|
1577
|
+
for (const key of keys) {
|
|
1578
|
+
if (!declared.has(key)) {
|
|
1579
|
+
// The reader gives a parked app no `enabled` field, so only a running
|
|
1580
|
+
// app is parked; a stopped one plans nothing and is still claimed.
|
|
1581
|
+
const resource = byKey.get(resourceKey('coolify', 'app', appStepKey(org, key)));
|
|
1582
|
+
const status = resource?.config.status;
|
|
1583
|
+
if (resource)
|
|
1584
|
+
claimed.add(resourceKey('coolify', 'app', appStepKey(org, key)));
|
|
1585
|
+
if (!resource || (typeof status === 'string' && /^(exited|stopped)/.test(status)))
|
|
1586
|
+
continue;
|
|
1587
|
+
}
|
|
1588
|
+
const app = planApp(org, key, byKey, claimed, recordedIds, plannedSteps, authIssuer);
|
|
1589
|
+
// The executor pushes the issuer the plan read, the way Agora gets its host values.
|
|
1590
|
+
if (app.step && app.step.action.desired.enabled !== false) {
|
|
1591
|
+
const signsIn = org.hosting.entries.some((e) => e.key === key && e.uses?.auth);
|
|
1592
|
+
if (signsIn)
|
|
1593
|
+
app.step.action.desired = { ...app.step.action.desired, authIssuer };
|
|
1594
|
+
}
|
|
1595
|
+
if (app.step)
|
|
1596
|
+
steps.push(app.step);
|
|
1597
|
+
blocked.push(...app.blocked);
|
|
1598
|
+
}
|
|
1599
|
+
return { steps, blocked };
|
|
1600
|
+
}
|
|
1487
1601
|
/**
|
|
1488
1602
|
* #173: register the org's own app with the platform's jobs scheduler
|
|
1489
1603
|
* (https://github.com/wtfalch/jobs's admin API, jobs#62), so the scheduler
|
|
@@ -1532,7 +1646,7 @@ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = n
|
|
|
1532
1646
|
: `the platform's jobs admin token ${missingKeyRef} is not held yet`}`,
|
|
1533
1647
|
};
|
|
1534
1648
|
}
|
|
1535
|
-
const hostname = org
|
|
1649
|
+
const hostname = appHostname(org);
|
|
1536
1650
|
return {
|
|
1537
1651
|
blocked: null,
|
|
1538
1652
|
step: {
|
|
@@ -1552,12 +1666,15 @@ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = n
|
|
|
1552
1666
|
}
|
|
1553
1667
|
/**
|
|
1554
1668
|
* A Postgres database beside the org's app (`org.ts`'s
|
|
1555
|
-
* `
|
|
1669
|
+
* `HostingEntry.database`, #71) -- `validateHosting` requires
|
|
1556
1670
|
* `hosting.enabled` before this can be true. Same park-not-delete shape as
|
|
1557
1671
|
* {@link planApp}.
|
|
1558
1672
|
*/
|
|
1559
1673
|
function planDatabase(org, byKey, claimed) {
|
|
1560
|
-
return planHostingToggle('coolify', 'database', org.hosting.database, org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`);
|
|
1674
|
+
return planHostingToggle('coolify', 'database', org.hosting.entries.some((entry) => entry.database), org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`);
|
|
1675
|
+
}
|
|
1676
|
+
function sortedNames(names) {
|
|
1677
|
+
return names.filter(Boolean).sort().join(',');
|
|
1561
1678
|
}
|
|
1562
1679
|
/**
|
|
1563
1680
|
* A Cloudflare tunnel reaching the org's app (`org.ts`'s
|
|
@@ -1613,7 +1730,14 @@ function planTunnel(org, byKey, claimed, recordedIds) {
|
|
|
1613
1730
|
if (!live)
|
|
1614
1731
|
return null;
|
|
1615
1732
|
const connectorMissing = !recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
|
|
1616
|
-
|
|
1733
|
+
// A hostname added or dropped on an entry: the names published last time
|
|
1734
|
+
// differ from the names now declared. An org that never recorded a list
|
|
1735
|
+
// has nothing to compare, so only the domain check above applies to it.
|
|
1736
|
+
const published = recordedIds[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
|
|
1737
|
+
const namesChanged = published !== undefined &&
|
|
1738
|
+
org.domain !== null &&
|
|
1739
|
+
sortedNames(published.split(',')) !== sortedNames(tunnelDnsHostnames(org, org.domain));
|
|
1740
|
+
const dnsStale = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain || namesChanged;
|
|
1617
1741
|
if (!connectorMissing && !dnsStale)
|
|
1618
1742
|
return null;
|
|
1619
1743
|
return {
|
|
@@ -1757,16 +1881,108 @@ function planIssuerApps(org, byKey, claimed) {
|
|
|
1757
1881
|
}
|
|
1758
1882
|
return steps;
|
|
1759
1883
|
}
|
|
1884
|
+
/**
|
|
1885
|
+
* One ZITADEL web client per entry with `uses.auth`, named by the entry key,
|
|
1886
|
+
* in the org's project. Planned once; its redirect addresses name the entry's
|
|
1887
|
+
* exact hostnames when it is created (`executor.ts`'s `zitadelApp`).
|
|
1888
|
+
*/
|
|
1889
|
+
function planEntryClients(org, byKey, claimed) {
|
|
1890
|
+
const steps = [];
|
|
1891
|
+
for (const entry of org.hosting.entries) {
|
|
1892
|
+
if (!entry.uses?.auth)
|
|
1893
|
+
continue;
|
|
1894
|
+
const key = resourceKey('zitadel', 'app', entry.key);
|
|
1895
|
+
if (byKey.has(key)) {
|
|
1896
|
+
claimed.add(key);
|
|
1897
|
+
continue;
|
|
1898
|
+
}
|
|
1899
|
+
steps.push({
|
|
1900
|
+
action: { system: 'zitadel', kind: 'app', key: entry.key, desired: { appType: 'web' } },
|
|
1901
|
+
step: {
|
|
1902
|
+
description: `provision the sign-in client '${entry.key}' for ${org.slug}`,
|
|
1903
|
+
destroys: false,
|
|
1904
|
+
migrates: false,
|
|
1905
|
+
},
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
return steps;
|
|
1909
|
+
}
|
|
1910
|
+
/**
|
|
1911
|
+
* F5: one broker `AUTH_APPS` entry per entry with `uses.auth`. Idempotent on
|
|
1912
|
+
* the recorded marker of what the last write said (ids, names, origins; never
|
|
1913
|
+
* the key), so a rerun plans nothing. The values it needs come from steps
|
|
1914
|
+
* that run earlier; one that nothing records and no step in this plan will
|
|
1915
|
+
* record blocks it (rule 2). A foreign broker entry the last write found
|
|
1916
|
+
* invalid is listed as a warning: the broker drops it, foundry keeps it.
|
|
1917
|
+
*/
|
|
1918
|
+
function planBrokerEntries(org, recordedIds, plannedSteps, brokerWired) {
|
|
1919
|
+
const steps = [];
|
|
1920
|
+
const blocked = [];
|
|
1921
|
+
for (const entry of org.hosting.entries) {
|
|
1922
|
+
const auth = entry.uses?.auth;
|
|
1923
|
+
if (!auth)
|
|
1924
|
+
continue;
|
|
1925
|
+
const at = `${org.slug}'s ${entry.key} broker entry`;
|
|
1926
|
+
const key = `coolify:broker-entry:${entry.key}`;
|
|
1927
|
+
const warnings = recordedIds[brokerEntryWarningsScopeKey(entry.key)];
|
|
1928
|
+
for (const name of warnings ? JSON.parse(warnings) : []) {
|
|
1929
|
+
blocked.push({
|
|
1930
|
+
description: `warning: the broker's AUTH_APPS entry '${name}' is invalid and the broker drops it; foundry leaves it as it is`,
|
|
1931
|
+
key: `${key}:invalid:${name}`,
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
if (!brokerWired) {
|
|
1935
|
+
blocked.push({
|
|
1936
|
+
description: `${at} is blocked, not planned: the host has not wired the broker port`,
|
|
1937
|
+
key,
|
|
1938
|
+
});
|
|
1939
|
+
continue;
|
|
1940
|
+
}
|
|
1941
|
+
const wanted = wantedBrokerEntry(org, entry, recordedIds);
|
|
1942
|
+
if ('want' in wanted) {
|
|
1943
|
+
if (recordedIds[brokerEntryAppliedScopeKey(entry.key)] === JSON.stringify(wanted.want)) {
|
|
1944
|
+
continue;
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
else {
|
|
1948
|
+
const unplanned = wanted.missing.filter(({ producedBy: [system, kind] }) => !plannedSteps.some((p) => p.action.system === system && p.action.kind === kind));
|
|
1949
|
+
if (unplanned.length > 0) {
|
|
1950
|
+
blocked.push({
|
|
1951
|
+
description: `${at} is blocked, not planned: ${unplanned.map((m) => m.what).join(', ')} not recorded yet`,
|
|
1952
|
+
key,
|
|
1953
|
+
});
|
|
1954
|
+
continue;
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
const replaces = auth.replacesBrokerEntry
|
|
1958
|
+
? ` and replace the hand-written entry '${auth.replacesBrokerEntry}'`
|
|
1959
|
+
: '';
|
|
1960
|
+
steps.push({
|
|
1961
|
+
action: {
|
|
1962
|
+
system: 'coolify',
|
|
1963
|
+
kind: 'broker-entry',
|
|
1964
|
+
key: appStepKey(org, entry.key),
|
|
1965
|
+
desired: {},
|
|
1966
|
+
},
|
|
1967
|
+
step: {
|
|
1968
|
+
description: `write ${at} beside the others${replaces}`,
|
|
1969
|
+
destroys: false,
|
|
1970
|
+
migrates: false,
|
|
1971
|
+
},
|
|
1972
|
+
});
|
|
1973
|
+
}
|
|
1974
|
+
return { steps, blocked };
|
|
1975
|
+
}
|
|
1760
1976
|
/**
|
|
1761
1977
|
* Plan gap A9: an org's customers sign in through a second ZITADEL org with
|
|
1762
|
-
* self-registration on, granted the org's app project. Planned once
|
|
1763
|
-
*
|
|
1978
|
+
* self-registration on, granted the org's app project. Planned once an entry
|
|
1979
|
+
* signs in its customers (`uses.auth.users: 'customers'`).
|
|
1764
1980
|
* Idempotent on the recorded customers org id, like `planOwner`. Once
|
|
1765
1981
|
* recorded, the live project grant to that org is claimed so it is not
|
|
1766
1982
|
* listed as unmanaged.
|
|
1767
1983
|
*/
|
|
1768
1984
|
function planCustomers(org, live, claimed, recordedIds) {
|
|
1769
|
-
if (org.hosting.
|
|
1985
|
+
if (!org.hosting.entries.some((entry) => entry.uses?.auth?.users === 'customers'))
|
|
1770
1986
|
return null;
|
|
1771
1987
|
const customersOrgId = recordedIds[resourceKey('zitadel', 'customers', org.id)];
|
|
1772
1988
|
if (customersOrgId) {
|
|
@@ -1790,23 +2006,31 @@ function planCustomers(org, live, claimed, recordedIds) {
|
|
|
1790
2006
|
}
|
|
1791
2007
|
/**
|
|
1792
2008
|
* #84: a Cloudflare Access gate on the org's app hostname, letting only
|
|
1793
|
-
* `
|
|
2009
|
+
* each entry's `accessEmails` through. Idempotent on the recorded Access app id,
|
|
1794
2010
|
* like `planOwner`: Cloudflare's live listing has no Access apps. A later
|
|
1795
2011
|
* change to the email list is not reconciled yet.
|
|
1796
2012
|
*/
|
|
1797
2013
|
function planAccess(org, recordedIds) {
|
|
1798
|
-
if (!org.hosting.enabled
|
|
1799
|
-
return
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
return
|
|
1803
|
-
|
|
2014
|
+
if (!org.hosting.enabled)
|
|
2015
|
+
return [];
|
|
2016
|
+
// Only the `app` entry has a public name, and so only it can sit behind
|
|
2017
|
+
// Access; per-entry hostnames are issue 309.
|
|
2018
|
+
return org.hosting.entries
|
|
2019
|
+
.filter((entry) => entry.key === APP_ENTRY_KEY && entry.accessEmails.length > 0)
|
|
2020
|
+
.filter((entry) => !recordedIds[resourceKey('cloudflare', 'access', appStepKey(org, entry.key))])
|
|
2021
|
+
.map((entry) => ({
|
|
2022
|
+
action: {
|
|
2023
|
+
system: 'cloudflare',
|
|
2024
|
+
kind: 'access',
|
|
2025
|
+
key: appStepKey(org, entry.key),
|
|
2026
|
+
desired: {},
|
|
2027
|
+
},
|
|
1804
2028
|
step: {
|
|
1805
|
-
description: `put ${org.slug}'s app behind Cloudflare Access`,
|
|
2029
|
+
description: `put ${org.slug}'s ${entry.key === APP_ENTRY_KEY ? 'app' : `${entry.key} app`} behind Cloudflare Access`,
|
|
1806
2030
|
destroys: false,
|
|
1807
2031
|
migrates: false,
|
|
1808
2032
|
},
|
|
1809
|
-
};
|
|
2033
|
+
}));
|
|
1810
2034
|
}
|
|
1811
2035
|
/**
|
|
1812
2036
|
* https://github.com/wtfalch/foundry/issues/43: one Cloudflare rate-limiting
|