@wtfalch/foundry 0.9.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/index.d.ts +1 -1
- package/dist/migrations/0030_hosting_entry_hostnames.sql +24 -0
- package/dist/org.d.ts +30 -4
- package/dist/org.js +82 -1
- 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 +260 -42
- package/dist/plan/executor.d.ts +44 -0
- package/dist/plan/executor.js +219 -90
- package/dist/plan/hostnames.d.ts +24 -0
- package/dist/plan/hostnames.js +56 -0
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- 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 +42 -2
- package/dist/plan/platform-diff.d.ts +60 -13
- package/dist/plan/platform-diff.js +109 -15
- package/dist/plan/platform-providers.d.ts +95 -2
- package/dist/plan/platform-providers.js +393 -27
- package/dist/store/postgres.js +11 -3
- package/package.json +1 -1
package/dist/plan/diff.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
|
-
import {
|
|
2
|
+
import { APP_ENTRY_KEY } from '../org.js';
|
|
3
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';
|
|
4
6
|
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
|
|
5
7
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
|
|
6
|
-
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';
|
|
7
9
|
import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
|
|
8
10
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
9
11
|
export function toPlan(detail) {
|
|
@@ -164,12 +166,16 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
164
166
|
// `AUTH_CLIENT_ID`, which only exists once that app's own `zitadel:app`
|
|
165
167
|
// step has run -- `plan-apply.ts`'s `runSteps` executes `plannedSteps` in
|
|
166
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);
|
|
167
174
|
plannedSteps.push(...planIssuerApps(org, byKey, claimed));
|
|
175
|
+
plannedSteps.push(...planEntryClients(org, byKey, claimed));
|
|
168
176
|
// The Access gate before the database, app, tunnel and zone (#84): on a
|
|
169
177
|
// first apply the hostname is gated before anything answers on it.
|
|
170
|
-
|
|
171
|
-
if (access)
|
|
172
|
-
plannedSteps.push(access);
|
|
178
|
+
plannedSteps.push(...planAccess(org, recordedIds));
|
|
173
179
|
// Database before app (hosting-wiring campaign): `plan-apply.ts`'s
|
|
174
180
|
// `runSteps` executes `plannedSteps` in this array order, and the app's own
|
|
175
181
|
// step (`executor.ts`'s `coolifyApp`) pushes the database's connection
|
|
@@ -179,9 +185,12 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
179
185
|
const database = planDatabase(org, byKey, claimed);
|
|
180
186
|
if (database)
|
|
181
187
|
plannedSteps.push(database);
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
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);
|
|
185
194
|
const tunnel = planTunnel(org, byKey, claimed, recordedIds);
|
|
186
195
|
// #238: a tunnel that is being provisioned waits for an active zone; parking
|
|
187
196
|
// one never does.
|
|
@@ -209,11 +218,6 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
209
218
|
else if (rateLimit) {
|
|
210
219
|
plannedSteps.push(rateLimit);
|
|
211
220
|
}
|
|
212
|
-
// After the apps: the customers org is granted the project the first app
|
|
213
|
-
// step creates.
|
|
214
|
-
const customers = planCustomers(org, live, claimed, recordedIds);
|
|
215
|
-
if (customers)
|
|
216
|
-
plannedSteps.push(customers);
|
|
217
221
|
// #164: every org's own Agora -- always on, no toggle. Last, so nothing
|
|
218
222
|
// else in the org waits on it: `runSteps` stops at the first failing step.
|
|
219
223
|
// Its fixed ZITADEL client before the Coolify app it signs into:
|
|
@@ -288,6 +292,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
288
292
|
unmanaged.push(...zoneBlocked);
|
|
289
293
|
// #243: an appEnv source nothing will record is reported, not silently skipped.
|
|
290
294
|
unmanaged.push(...app.blocked);
|
|
295
|
+
unmanaged.push(...brokerEntries.blocked);
|
|
291
296
|
return { orgId: org.id, plannedSteps, unmanaged, versionChanges };
|
|
292
297
|
}
|
|
293
298
|
/**
|
|
@@ -524,6 +529,7 @@ const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
|
|
|
524
529
|
const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_app';
|
|
525
530
|
/** #236: restated from `executor.ts`, the same rule: the domain the org tunnel's DNS was last published under. */
|
|
526
531
|
const CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY = 'cloudflare_tunnel_dns_domain';
|
|
532
|
+
const CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_dns_hostnames';
|
|
527
533
|
/** Restated from `executor.ts`, the same rule: the mail domain the last apply resolved. */
|
|
528
534
|
const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
|
|
529
535
|
/** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
|
|
@@ -1387,7 +1393,7 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
|
|
|
1387
1393
|
* resource's `enabled` field -- a live resource nothing has actually
|
|
1388
1394
|
* checked yet is assumed fine rather than replanned every time.
|
|
1389
1395
|
*/
|
|
1390
|
-
function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
1396
|
+
function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authIssuer) {
|
|
1391
1397
|
// #243: an `appEnv` source with no recorded value and no step planned to
|
|
1392
1398
|
// record one this run has nothing to push, now or after this apply.
|
|
1393
1399
|
const sourceStep = {
|
|
@@ -1395,21 +1401,27 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
|
1395
1401
|
aiOrganisationId: ['ai', 'organisation'],
|
|
1396
1402
|
mailDomain: ['coolify', 'mail'],
|
|
1397
1403
|
};
|
|
1398
|
-
const entry = org.hosting.enabled
|
|
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}`;
|
|
1399
1411
|
const { values: wantedEnv, unresolved } = entry
|
|
1400
1412
|
? resolveAppEnv(org, entry, recordedIds)
|
|
1401
1413
|
: { values: {}, unresolved: [] };
|
|
1402
|
-
const stepKey = appStepKey(org);
|
|
1414
|
+
const stepKey = appStepKey(org, entryKey);
|
|
1403
1415
|
const blocked = entry
|
|
1404
1416
|
? unresolved
|
|
1405
1417
|
.filter(([, source]) => !plannedSteps.some((p) => p.action.system === sourceStep[source][0] &&
|
|
1406
1418
|
p.action.kind === sourceStep[source][1]))
|
|
1407
1419
|
.map(([name, source]) => ({
|
|
1408
|
-
description: `${org.slug}'s
|
|
1409
|
-
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}`),
|
|
1410
1422
|
}))
|
|
1411
1423
|
: [];
|
|
1412
|
-
const toggled = planHostingToggle('coolify', 'app', entry !== undefined, org, byKey, claimed, () => `provision ${org.slug}'s
|
|
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);
|
|
1413
1425
|
if (toggled)
|
|
1414
1426
|
return { step: toggled, blocked };
|
|
1415
1427
|
if (!entry)
|
|
@@ -1428,7 +1440,7 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
|
1428
1440
|
step: {
|
|
1429
1441
|
action,
|
|
1430
1442
|
step: {
|
|
1431
|
-
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)`,
|
|
1432
1444
|
destroys: false,
|
|
1433
1445
|
migrates: false,
|
|
1434
1446
|
},
|
|
@@ -1447,8 +1459,8 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
|
1447
1459
|
// Rule 2: the push cannot succeed yet, so report it rather than replan
|
|
1448
1460
|
// forever.
|
|
1449
1461
|
blocked.push({
|
|
1450
|
-
description: `${org.slug}'s
|
|
1451
|
-
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'),
|
|
1452
1464
|
});
|
|
1453
1465
|
}
|
|
1454
1466
|
else if (dbReady &&
|
|
@@ -1458,12 +1470,12 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
|
1458
1470
|
((Array.isArray(envNames) && !envNames.includes('DATABASE_URL_OWNER')) ||
|
|
1459
1471
|
// A crash between the push and the restart leaves the env right and no
|
|
1460
1472
|
// marker: replan once more to restart and set it.
|
|
1461
|
-
!recordedIds[
|
|
1473
|
+
!recordedIds[dbEnvAppliedScopeKey(entryKey)])) {
|
|
1462
1474
|
return {
|
|
1463
1475
|
step: {
|
|
1464
1476
|
action,
|
|
1465
1477
|
step: {
|
|
1466
|
-
description: `give ${org.slug}'s
|
|
1478
|
+
description: `give ${org.slug}'s ${label} its owner and runtime database URLs and restart it (#239)`,
|
|
1467
1479
|
destroys: false,
|
|
1468
1480
|
migrates: false,
|
|
1469
1481
|
},
|
|
@@ -1474,12 +1486,71 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
|
1474
1486
|
// #243 (rule 3): a changed entry `env` reaches an app that is already
|
|
1475
1487
|
// live. `pushAppEnv` records what it pushed, so this plans the same step
|
|
1476
1488
|
// again, with a redeploy, only once the resolved map differs from that.
|
|
1477
|
-
if (JSON.stringify(wantedEnv) !== (recordedIds[
|
|
1489
|
+
if (JSON.stringify(wantedEnv) !== (recordedIds[appEnvAppliedScopeKey(entryKey)] ?? '{}')) {
|
|
1478
1490
|
return {
|
|
1479
1491
|
step: {
|
|
1480
1492
|
action,
|
|
1481
1493
|
step: {
|
|
1482
|
-
description: `push ${org.slug}'s changed
|
|
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))) {
|
|
1523
|
+
return {
|
|
1524
|
+
step: {
|
|
1525
|
+
action,
|
|
1526
|
+
step: {
|
|
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`,
|
|
1483
1554
|
destroys: false,
|
|
1484
1555
|
migrates: false,
|
|
1485
1556
|
},
|
|
@@ -1489,6 +1560,44 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
|
1489
1560
|
}
|
|
1490
1561
|
return { step: null, blocked };
|
|
1491
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
|
+
}
|
|
1492
1601
|
/**
|
|
1493
1602
|
* #173: register the org's own app with the platform's jobs scheduler
|
|
1494
1603
|
* (https://github.com/wtfalch/jobs's admin API, jobs#62), so the scheduler
|
|
@@ -1537,7 +1646,7 @@ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = n
|
|
|
1537
1646
|
: `the platform's jobs admin token ${missingKeyRef} is not held yet`}`,
|
|
1538
1647
|
};
|
|
1539
1648
|
}
|
|
1540
|
-
const hostname = org
|
|
1649
|
+
const hostname = appHostname(org);
|
|
1541
1650
|
return {
|
|
1542
1651
|
blocked: null,
|
|
1543
1652
|
step: {
|
|
@@ -1562,7 +1671,10 @@ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = n
|
|
|
1562
1671
|
* {@link planApp}.
|
|
1563
1672
|
*/
|
|
1564
1673
|
function planDatabase(org, byKey, claimed) {
|
|
1565
|
-
return planHostingToggle('coolify', 'database',
|
|
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(',');
|
|
1566
1678
|
}
|
|
1567
1679
|
/**
|
|
1568
1680
|
* A Cloudflare tunnel reaching the org's app (`org.ts`'s
|
|
@@ -1618,7 +1730,14 @@ function planTunnel(org, byKey, claimed, recordedIds) {
|
|
|
1618
1730
|
if (!live)
|
|
1619
1731
|
return null;
|
|
1620
1732
|
const connectorMissing = !recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
|
|
1621
|
-
|
|
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;
|
|
1622
1741
|
if (!connectorMissing && !dnsStale)
|
|
1623
1742
|
return null;
|
|
1624
1743
|
return {
|
|
@@ -1762,16 +1881,108 @@ function planIssuerApps(org, byKey, claimed) {
|
|
|
1762
1881
|
}
|
|
1763
1882
|
return steps;
|
|
1764
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
|
+
}
|
|
1765
1976
|
/**
|
|
1766
1977
|
* Plan gap A9: an org's customers sign in through a second ZITADEL org with
|
|
1767
|
-
* self-registration on, granted the org's app project. Planned once
|
|
1768
|
-
*
|
|
1978
|
+
* self-registration on, granted the org's app project. Planned once an entry
|
|
1979
|
+
* signs in its customers (`uses.auth.users: 'customers'`).
|
|
1769
1980
|
* Idempotent on the recorded customers org id, like `planOwner`. Once
|
|
1770
1981
|
* recorded, the live project grant to that org is claimed so it is not
|
|
1771
1982
|
* listed as unmanaged.
|
|
1772
1983
|
*/
|
|
1773
1984
|
function planCustomers(org, live, claimed, recordedIds) {
|
|
1774
|
-
if (org.hosting.
|
|
1985
|
+
if (!org.hosting.entries.some((entry) => entry.uses?.auth?.users === 'customers'))
|
|
1775
1986
|
return null;
|
|
1776
1987
|
const customersOrgId = recordedIds[resourceKey('zitadel', 'customers', org.id)];
|
|
1777
1988
|
if (customersOrgId) {
|
|
@@ -1795,24 +2006,31 @@ function planCustomers(org, live, claimed, recordedIds) {
|
|
|
1795
2006
|
}
|
|
1796
2007
|
/**
|
|
1797
2008
|
* #84: a Cloudflare Access gate on the org's app hostname, letting only
|
|
1798
|
-
*
|
|
2009
|
+
* each entry's `accessEmails` through. Idempotent on the recorded Access app id,
|
|
1799
2010
|
* like `planOwner`: Cloudflare's live listing has no Access apps. A later
|
|
1800
2011
|
* change to the email list is not reconciled yet.
|
|
1801
2012
|
*/
|
|
1802
2013
|
function planAccess(org, recordedIds) {
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
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
|
+
},
|
|
1810
2028
|
step: {
|
|
1811
|
-
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`,
|
|
1812
2030
|
destroys: false,
|
|
1813
2031
|
migrates: false,
|
|
1814
2032
|
},
|
|
1815
|
-
};
|
|
2033
|
+
}));
|
|
1816
2034
|
}
|
|
1817
2035
|
/**
|
|
1818
2036
|
* https://github.com/wtfalch/foundry/issues/43: one Cloudflare rate-limiting
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/postmaster';
|
|
2
2
|
import type { DeclaredOrg, HostingBuildPack } from '../org.js';
|
|
3
3
|
import type { DeclaredOrgStore } from '../store.js';
|
|
4
|
+
import { type BrokerPort } from './broker-entries.js';
|
|
4
5
|
import type { StepAction } from './diff.js';
|
|
5
6
|
import { type LoginHostBindingsPort } from './login-host.js';
|
|
6
7
|
import { type MailCopyPort } from './mail-copy.js';
|
|
@@ -97,6 +98,19 @@ export interface CoolifyPort {
|
|
|
97
98
|
domains?: string;
|
|
98
99
|
/** Selects which of two Dockerfiles in one repo to build (`cmsSite`'s own site/cms apps). */
|
|
99
100
|
dockerfile_location?: string;
|
|
101
|
+
/** The port the app listens on (`HostingEntry.port`). */
|
|
102
|
+
ports_exposes?: string;
|
|
103
|
+
/** `false` for an internal entry: Coolify makes no domain for it. */
|
|
104
|
+
autogenerate_domain?: boolean;
|
|
105
|
+
/** The folder a `static` entry serves (`HostingEntry.folder`). */
|
|
106
|
+
publish_directory?: string;
|
|
107
|
+
/** Whether a push to `git_branch` deploys (`HostingEntry.autoDeploy`). */
|
|
108
|
+
is_auto_deploy_enabled?: boolean;
|
|
109
|
+
/**
|
|
110
|
+
* The app's folder in the repo (`HostingEntry.folder`). Coolify's create
|
|
111
|
+
* endpoint takes it; `@wtfalch/providers` forwards the body as it is.
|
|
112
|
+
*/
|
|
113
|
+
base_directory?: string;
|
|
100
114
|
}): Promise<{
|
|
101
115
|
uuid: string;
|
|
102
116
|
}>;
|
|
@@ -316,12 +330,20 @@ export interface CoolifyPort {
|
|
|
316
330
|
* `CoolifyClient.listDatabaseBackups` method-for-method.
|
|
317
331
|
* `provisionPlatformBackup` reads this to decide whether a database
|
|
318
332
|
* already has an enabled, `save_s3` schedule before creating a second one.
|
|
333
|
+
* `s3_storage_uuid` (#257 fix round 3) is the field a replaced storage's
|
|
334
|
+
* repoint compares against -- Coolify's "Update database backup" operation
|
|
335
|
+
* (https://coolify.io/docs/api-reference/api/operations/update-database-backup)
|
|
336
|
+
* documents it as a field of the same scheduled-backup resource this list
|
|
337
|
+
* returns, but the list endpoint's own response has not been checked
|
|
338
|
+
* against a live instance, so it is optional here rather than assumed
|
|
339
|
+
* present.
|
|
319
340
|
*/
|
|
320
341
|
listDatabaseBackups(databaseUuid: string): Promise<Array<{
|
|
321
342
|
uuid: string;
|
|
322
343
|
frequency: string;
|
|
323
344
|
enabled: boolean;
|
|
324
345
|
save_s3: boolean;
|
|
346
|
+
s3_storage_uuid?: string;
|
|
325
347
|
}>>;
|
|
326
348
|
/**
|
|
327
349
|
* #257: schedules dumps of a database to the registered S3 storage --
|
|
@@ -1134,6 +1156,13 @@ export interface FoundryStepExecutorPorts {
|
|
|
1134
1156
|
* or repointed without the binding.
|
|
1135
1157
|
*/
|
|
1136
1158
|
loginHostBindings?: LoginHostBindingsPort;
|
|
1159
|
+
/**
|
|
1160
|
+
* F5: the broker's `AUTH_APPS`, where an entry with `uses.auth` gets its own
|
|
1161
|
+
* entry. Optional the same way `loginHostBindings` is: without it the plan
|
|
1162
|
+
* reports each broker entry blocked (the host also sets
|
|
1163
|
+
* `FoundryPlanApplyOptions.broker`).
|
|
1164
|
+
*/
|
|
1165
|
+
broker?: BrokerPort;
|
|
1137
1166
|
/**
|
|
1138
1167
|
* Hands a just-minted, one-time secret (today: an API app's
|
|
1139
1168
|
* `clientSecret`) to the host, immediately after the provider returns it
|
|
@@ -1401,6 +1430,21 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1401
1430
|
* provisioned, where this one is unconditional (#239).
|
|
1402
1431
|
*/
|
|
1403
1432
|
private pushAppEnv;
|
|
1433
|
+
/**
|
|
1434
|
+
* F5: the app's `AUTH_APP_KEY`, minted once and only when the app has none
|
|
1435
|
+
* of at least 32 characters (the broker refuses a shorter one). `null` once
|
|
1436
|
+
* it has one: the app's env is where the key lives, and
|
|
1437
|
+
* `coolifyBrokerEntry` reads it back from there. It goes to this env and
|
|
1438
|
+
* the broker's, nowhere else.
|
|
1439
|
+
*/
|
|
1440
|
+
private ensureAuthAppKey;
|
|
1441
|
+
/**
|
|
1442
|
+
* `plan/diff.ts`'s `planBrokerEntries` (F5): the entry's `AUTH_APPS` entry,
|
|
1443
|
+
* written by `writeBrokerEntry` with the key `pushAppEnv` put in the app's
|
|
1444
|
+
* env. Records what it wrote (never the key) and any foreign entry the
|
|
1445
|
+
* broker would drop.
|
|
1446
|
+
*/
|
|
1447
|
+
private coolifyBrokerEntry;
|
|
1404
1448
|
/**
|
|
1405
1449
|
* `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
|
|
1406
1450
|
* only the first time (task brief), never regenerated or overwritten, so a
|