@wtfalch/foundry 0.21.1 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/plan/diff.js +202 -30
- package/dist/plan/executor.d.ts +33 -28
- package/dist/plan/executor.js +176 -53
- package/dist/plan/hostnames.d.ts +5 -3
- package/dist/plan/hostnames.js +8 -4
- package/dist/plan/login-host.d.ts +17 -2
- package/dist/plan/login-host.js +24 -3
- package/dist/plan/plan-apply.d.ts +15 -1
- package/dist/plan/plan-apply.js +13 -4
- package/dist/plan/platform-diff.d.ts +28 -0
- package/dist/plan/platform-diff.js +29 -3
- package/dist/plan/platform-org.d.ts +28 -0
- package/dist/plan/platform-org.js +17 -0
- package/dist/plan/platform-providers.d.ts +14 -1
- package/dist/plan/platform-providers.js +95 -8
- package/package.json +3 -2
package/dist/plan/executor.js
CHANGED
|
@@ -21,21 +21,47 @@ import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED
|
|
|
21
21
|
import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
|
|
22
22
|
import { healthCheckSettings } from './health-check-settings.js';
|
|
23
23
|
import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
24
|
-
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
24
|
+
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, customersEntryKeys, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
25
25
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
26
|
-
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, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, } from './platform-diff.js';
|
|
27
|
-
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
26
|
+
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, } from './platform-diff.js';
|
|
27
|
+
import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, } from './platform-providers.js';
|
|
28
28
|
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
|
|
29
29
|
import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
|
|
30
|
+
/**
|
|
31
|
+
* The real {@link StepExecutor} (#20 "provision Company Factory end to
|
|
32
|
+
* end"): makes each {@link StepAction} `diff.ts` plans true against a real
|
|
33
|
+
* Coolify, Cloudflare and ZITADEL, plus per-org organisations, spend limits
|
|
34
|
+
* and (for AI) offers on Files and AI (#80). Scope and design are exactly
|
|
35
|
+
* what `.claude/decisions-2026-09-24-executor.md`'s "Executor decisions"
|
|
36
|
+
* section settled, referenced by name at each dispatch branch below.
|
|
37
|
+
*
|
|
38
|
+
* Built entirely from narrow ports (interfaces), never a concrete
|
|
39
|
+
* `@wtfalch/providers` client: this package holds no credential and never
|
|
40
|
+
* will (executor decisions: "the executor is constructed with narrow ports
|
|
41
|
+
* ... a host [archon] supplies real clients. How the host gets provider
|
|
42
|
+
* tokens from the keys Worker is the host's job, not this campaign's").
|
|
43
|
+
* Every port method here is named and shaped to match a real method on
|
|
44
|
+
* `@wtfalch/providers`' `CoolifyClient`/`CloudflareClient`/`ZitadelClient`
|
|
45
|
+
* (checked against `providers` `origin/main`, 2026-09-24), or on
|
|
46
|
+
* `@wtfalch/files`'/`@wtfalch/ai`'s own SDK clients (#80, checked against
|
|
47
|
+
* each repo's own `main`, 2026-09-25), so a real client satisfies the port
|
|
48
|
+
* structurally, with no adapter needed — see each port's own doc comment for
|
|
49
|
+
* the one real method it does not yet have.
|
|
50
|
+
*/
|
|
51
|
+
/** #569: the description foundry gives the Coolify project it creates for an org. */
|
|
52
|
+
const COOLIFY_PROJECT_MARK_PREFIX = 'foundry org ';
|
|
53
|
+
function coolifyProjectMark(org) {
|
|
54
|
+
return `${COOLIFY_PROJECT_MARK_PREFIX}${org.id}`;
|
|
55
|
+
}
|
|
30
56
|
/**
|
|
31
57
|
* #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
|
|
32
58
|
* listApps` -- a plain array, with no `loginBaseUri` or completeness signal
|
|
33
59
|
* -- into {@link ZitadelPort.listApps}'s own shape, so a host wiring that
|
|
34
60
|
* real client in satisfies this port without reaching into `executor.ts`
|
|
35
|
-
* itself to build the wrapper by hand. `complete:
|
|
36
|
-
* the real client
|
|
37
|
-
*
|
|
38
|
-
*
|
|
61
|
+
* itself to build the wrapper by hand. `complete: true` on every call:
|
|
62
|
+
* since providers 0.7.4 the real client pages `_search` to the end. A host
|
|
63
|
+
* that supplies its own `listApps` keeps whatever `complete` it reports.
|
|
64
|
+
* Each app's `clientId` (providers 0.7.5) passes through. `loginBaseUri` comes back `undefined`
|
|
39
65
|
* for every app for the identical reason -- the real client does not read
|
|
40
66
|
* it back at all yet, not "read it back as absent" -- which is exactly the
|
|
41
67
|
* state `zitadelLoginHost`'s fail-closed rule exists for: every pending
|
|
@@ -45,7 +71,9 @@ import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerR
|
|
|
45
71
|
export function adaptZitadelListApps(client) {
|
|
46
72
|
return async (orgId, projectId) => ({
|
|
47
73
|
apps: await client.listApps(orgId, projectId),
|
|
48
|
-
|
|
74
|
+
// Sound because package.json requires providers >=0.7.4 as a peer: from
|
|
75
|
+
// there `listApps` pages `_search` to the end, so this read every app.
|
|
76
|
+
complete: true,
|
|
49
77
|
});
|
|
50
78
|
}
|
|
51
79
|
// ---------------------------------------------------------------------------
|
|
@@ -731,6 +759,16 @@ export class FoundryStepExecutor {
|
|
|
731
759
|
constructor(ports) {
|
|
732
760
|
this.ports = ports;
|
|
733
761
|
}
|
|
762
|
+
/** #555: the edge-routed ports this executor was built with itself (they win over edge). */
|
|
763
|
+
ownEdgePorts() {
|
|
764
|
+
return {
|
|
765
|
+
dns: Boolean(this.ports.dns),
|
|
766
|
+
zones: Boolean(this.ports.zones),
|
|
767
|
+
access: Boolean(this.ports.access),
|
|
768
|
+
ratelimits: Boolean(this.ports.ratelimits),
|
|
769
|
+
tunnels: Boolean(this.ports.tunnels),
|
|
770
|
+
};
|
|
771
|
+
}
|
|
734
772
|
async execute(action, context) {
|
|
735
773
|
const { org } = context;
|
|
736
774
|
switch (`${action.system}:${action.kind}`) {
|
|
@@ -1318,7 +1356,7 @@ export class FoundryStepExecutor {
|
|
|
1318
1356
|
// The platform's own shared sign-in host: already trusted and routed by
|
|
1319
1357
|
// the shared instance, so it gets no trusted domain or binding here.
|
|
1320
1358
|
const shared = action.desired.shared === true;
|
|
1321
|
-
const clientIds = loginHostClientIds(recorded);
|
|
1359
|
+
const clientIds = loginHostClientIds(org, recorded);
|
|
1322
1360
|
if (!shared) {
|
|
1323
1361
|
await this.ports.zitadel.ensureTrustedDomain(host);
|
|
1324
1362
|
// zitadel-login's own routing table, written before any app is repointed
|
|
@@ -1328,13 +1366,16 @@ export class FoundryStepExecutor {
|
|
|
1328
1366
|
// Only apps whose client id the binding now holds: any other app in the
|
|
1329
1367
|
// project would lose sign-in. The rest move on a later run, once the
|
|
1330
1368
|
// recorded client ids differ from the binding and replan this step.
|
|
1369
|
+
const customersKeys = customersEntryKeys(org);
|
|
1331
1370
|
const boundAppIds = new Set([
|
|
1332
1371
|
...(recorded[APP_CLIENT_ID_SCOPE_KEY]
|
|
1333
1372
|
? org.hosting.issuerApps.filter((a) => a.kind === 'web').map((a) => a.name)
|
|
1334
1373
|
: []),
|
|
1335
1374
|
...Object.keys(recorded)
|
|
1336
1375
|
.filter((key) => key.startsWith(ENTRY_CLIENT_ID_SCOPE_PREFIX))
|
|
1337
|
-
.map((key) => key.slice(ENTRY_CLIENT_ID_SCOPE_PREFIX.length))
|
|
1376
|
+
.map((key) => key.slice(ENTRY_CLIENT_ID_SCOPE_PREFIX.length))
|
|
1377
|
+
// #573: a customers entry signs in on the shared host.
|
|
1378
|
+
.filter((name) => !customersKeys.has(name)),
|
|
1338
1379
|
...(recorded[CMS_CLIENT_ID_SCOPE_KEY] ? ['cms'] : []),
|
|
1339
1380
|
...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
|
|
1340
1381
|
...(recorded[BOULE_CLIENT_ID_SCOPE_KEY] ? ['boule'] : []),
|
|
@@ -1459,14 +1500,69 @@ export class FoundryStepExecutor {
|
|
|
1459
1500
|
throw new Error(`zitadel:app '${action.key}' for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
1460
1501
|
}
|
|
1461
1502
|
const projectId = await this.ensureZitadelProject(org, zitadelOrgId, context);
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1503
|
+
const isCmsClient = action.key === 'cms' && org.site.enabled;
|
|
1504
|
+
const isAgoraClient = action.key === 'agora';
|
|
1505
|
+
const isBouleClient = action.key === 'boule';
|
|
1506
|
+
const authEntry = org.hosting.entries.find((e) => e.key === action.key && e.uses?.auth);
|
|
1507
|
+
const clientIdScopeKey = isCmsClient
|
|
1508
|
+
? CMS_CLIENT_ID_SCOPE_KEY
|
|
1509
|
+
: isAgoraClient
|
|
1510
|
+
? AGORA_CLIENT_ID_SCOPE_KEY
|
|
1511
|
+
: isBouleClient
|
|
1512
|
+
? BOULE_CLIENT_ID_SCOPE_KEY
|
|
1513
|
+
: authEntry
|
|
1514
|
+
? entryClientIdScopeKey(action.key)
|
|
1515
|
+
: APP_CLIENT_ID_SCOPE_KEY;
|
|
1468
1516
|
const priorAppId = recorded[`zitadel:app:${action.key}`];
|
|
1469
|
-
|
|
1517
|
+
// Only a step the planner flagged as destroying (`recreate`, set with
|
|
1518
|
+
// `destroys: true` so plan-apply asks for the typed confirmation) may
|
|
1519
|
+
// delete an app. A plain provision adopts a live app instead: one with
|
|
1520
|
+
// the recorded id, or one of the same name in the project (an app an
|
|
1521
|
+
// earlier apply created whose response was lost). ZITADEL's listing can
|
|
1522
|
+
// miss a live app (it once dropped both company sign-in clients on
|
|
1523
|
+
// production), and there is no read by app id, so a recorded app the
|
|
1524
|
+
// listing does not show is never replaced: the planner reports it
|
|
1525
|
+
// blocked, and this throws if a stale plan reaches it.
|
|
1526
|
+
if (action.desired.recreate !== true) {
|
|
1527
|
+
const listing = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
|
|
1528
|
+
const liveApps = listing.apps.filter((app) => app.state !== 'APP_STATE_REMOVED');
|
|
1529
|
+
const byId = priorAppId ? liveApps.find((app) => app.id === priorAppId) : undefined;
|
|
1530
|
+
const adopted = byId ?? liveApps.find((app) => app.name === action.key && app.appType === appType);
|
|
1531
|
+
if (adopted) {
|
|
1532
|
+
// Not adopted silently: a wrong type is a recreate (destroying, typed
|
|
1533
|
+
// confirmation) and an inactive app is a person's decision. Neither
|
|
1534
|
+
// can be known from the plan's name-keyed live read, so this throws.
|
|
1535
|
+
if (adopted.appType !== appType || adopted.state !== 'APP_STATE_ACTIVE') {
|
|
1536
|
+
throw new Error(`zitadel:app '${action.key}' for ${org.slug}: app ${adopted.id} is live as a ${adopted.appType} app in state ${adopted.state}, not an active ${appType} app -- nothing was created or deleted`);
|
|
1537
|
+
}
|
|
1538
|
+
if (appType === 'api') {
|
|
1539
|
+
// The secret was minted once, at creation; an app matched only by
|
|
1540
|
+
// name has no recorded delivery, and nothing can recover it.
|
|
1541
|
+
if (adopted.id !== priorAppId) {
|
|
1542
|
+
throw new Error(`zitadel:app '${action.key}' for ${org.slug}: an api app ${adopted.id} of that name exists but was never recorded, so its secret cannot be recovered -- nothing was created or deleted; delete that app in ZITADEL, then apply again to mint a new one`);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
else {
|
|
1546
|
+
// A client id recorded for another app is never carried over.
|
|
1547
|
+
const clientId = adopted.clientId ??
|
|
1548
|
+
(adopted.id === priorAppId ? recorded[clientIdScopeKey] : undefined);
|
|
1549
|
+
if (!clientId) {
|
|
1550
|
+
throw new Error(`zitadel:app '${action.key}' for ${org.slug}: app ${adopted.id} is live but the listing gives no client id for it -- nothing was created or deleted`);
|
|
1551
|
+
}
|
|
1552
|
+
context.recordScopeId(clientIdScopeKey, clientId);
|
|
1553
|
+
}
|
|
1554
|
+
return { resourceId: adopted.id };
|
|
1555
|
+
}
|
|
1556
|
+
if (priorAppId) {
|
|
1557
|
+
throw new Error(`zitadel:app '${action.key}' for ${org.slug}: recorded app ${priorAppId} is not in the listing, and a listing can miss a live app -- nothing was created or deleted; plan again and confirm the replacement step if the app is really gone`);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
// A kind change (`recreate`) deletes the recorded app then recreates it
|
|
1561
|
+
// (task brief); a 404 (already gone) is tolerated the same way `destroy`
|
|
1562
|
+
// tolerates one -- a retry after the delete half already succeeded but
|
|
1563
|
+
// the recreate half failed must find an already-deleted app a no-op, not
|
|
1564
|
+
// a new error that wedges the apply forever.
|
|
1565
|
+
if (action.desired.recreate === true && priorAppId) {
|
|
1470
1566
|
try {
|
|
1471
1567
|
await this.ports.zitadel.deleteApp(zitadelOrgId, projectId, priorAppId);
|
|
1472
1568
|
}
|
|
@@ -1474,16 +1570,22 @@ export class FoundryStepExecutor {
|
|
|
1474
1570
|
if (!isNotFoundError(error))
|
|
1475
1571
|
throw error;
|
|
1476
1572
|
}
|
|
1573
|
+
// Gone now: a retry after a failed create must not find this id and be
|
|
1574
|
+
// blocked as "recorded but missing" (`diff.ts`'s `guardAppProvision`).
|
|
1575
|
+
// Removed (not blanked) and persisted now: a death before the create
|
|
1576
|
+
// must not leave the stale id behind, nor an empty one for destroy.
|
|
1577
|
+
if (context.forgetId)
|
|
1578
|
+
context.forgetId(`zitadel:app:${action.key}`);
|
|
1579
|
+
else
|
|
1580
|
+
context.recordScopeId(`zitadel:app:${action.key}`, '');
|
|
1581
|
+
await context.persistScopeIds();
|
|
1477
1582
|
// The recreate half is retried on its own failure (the delete above
|
|
1478
1583
|
// already tolerates the resulting 404), so a retry can also land here
|
|
1479
1584
|
// after an earlier attempt's create call actually succeeded against
|
|
1480
|
-
// ZITADEL but whose response this executor never saw
|
|
1481
|
-
//
|
|
1482
|
-
//
|
|
1483
|
-
//
|
|
1484
|
-
// undelivered and the clientId unrecorded forever. The create below
|
|
1485
|
-
// then runs every side effect. A first-ever create (no `priorAppId`)
|
|
1486
|
-
// has nothing to find here and never pays this read.
|
|
1585
|
+
// ZITADEL but whose response this executor never saw. That app is
|
|
1586
|
+
// deleted, not reused: the lost response also lost its clientSecret
|
|
1587
|
+
// and clientId, which `listApps` cannot return, so reusing it would
|
|
1588
|
+
// leave the secret undelivered and the clientId unrecorded forever.
|
|
1487
1589
|
const orphan = await this.findRecreatedApp(zitadelOrgId, projectId, action.key, appType);
|
|
1488
1590
|
if (orphan) {
|
|
1489
1591
|
try {
|
|
@@ -1512,16 +1614,13 @@ export class FoundryStepExecutor {
|
|
|
1512
1614
|
// doc comment relies on. #164: the fixed `agora` client (`diff.ts`'s
|
|
1513
1615
|
// `planAgoraClient`) is the same shape, always -- there is no
|
|
1514
1616
|
// `org.site.enabled`-style guard to check, since Agora is always on.
|
|
1515
|
-
const isCmsClient = action.key === 'cms' && org.site.enabled;
|
|
1516
1617
|
// The Agora client stays `appType: 'web'`: providers' `createOidcApp`
|
|
1517
1618
|
// makes every non-native app a public client (authorization code + PKCE,
|
|
1518
1619
|
// `OIDC_AUTH_METHOD_TYPE_NONE`, no secret), which is what Agora's browser
|
|
1519
1620
|
// SPA needs. Providers cannot create ZITADEL's `USER_AGENT` app kind.
|
|
1520
|
-
const isAgoraClient = action.key === 'agora';
|
|
1521
1621
|
// #161: the fixed `boule` client signs people into the org's Boule at its
|
|
1522
1622
|
// own hostname, on the standard `/auth/callback` path (Boule is built on
|
|
1523
1623
|
// `@wtfalch/auth` like every other web app here).
|
|
1524
|
-
const isBouleClient = action.key === 'boule';
|
|
1525
1624
|
const hostname = isCmsClient
|
|
1526
1625
|
? cmsHostnameFor(org)
|
|
1527
1626
|
: isAgoraClient
|
|
@@ -1542,7 +1641,6 @@ export class FoundryStepExecutor {
|
|
|
1542
1641
|
: `https://${hostname}/auth/callback`;
|
|
1543
1642
|
// An entry with `uses.auth` signs in at its own exact hostnames, one pair
|
|
1544
1643
|
// of addresses each; a `*` adds none (`entryHostnames`).
|
|
1545
|
-
const authEntry = org.hosting.entries.find((e) => e.key === action.key && e.uses?.auth);
|
|
1546
1644
|
const hosts = authEntry ? entryHostnames(org, authEntry) : null;
|
|
1547
1645
|
const created = await this.ports.zitadel.createOidcApp(zitadelOrgId, projectId, {
|
|
1548
1646
|
name: action.key,
|
|
@@ -1567,15 +1665,7 @@ export class FoundryStepExecutor {
|
|
|
1567
1665
|
// plain scope id, not `deliverSecret`. A native app signs in via
|
|
1568
1666
|
// device code with no cookie session of its own, so its clientId is
|
|
1569
1667
|
// never something an app's env needs.
|
|
1570
|
-
context.recordScopeId(
|
|
1571
|
-
? CMS_CLIENT_ID_SCOPE_KEY
|
|
1572
|
-
: isAgoraClient
|
|
1573
|
-
? AGORA_CLIENT_ID_SCOPE_KEY
|
|
1574
|
-
: isBouleClient
|
|
1575
|
-
? BOULE_CLIENT_ID_SCOPE_KEY
|
|
1576
|
-
: authEntry
|
|
1577
|
-
? entryClientIdScopeKey(action.key)
|
|
1578
|
-
: APP_CLIENT_ID_SCOPE_KEY, created.clientId);
|
|
1668
|
+
context.recordScopeId(clientIdScopeKey, created.clientId);
|
|
1579
1669
|
}
|
|
1580
1670
|
return { resourceId: created.appId };
|
|
1581
1671
|
}
|
|
@@ -1608,9 +1698,19 @@ export class FoundryStepExecutor {
|
|
|
1608
1698
|
if (existing)
|
|
1609
1699
|
return existing;
|
|
1610
1700
|
const found = await this.ports.coolify.findProjectByName(org.slug);
|
|
1701
|
+
// #569: a project foundry did not create is never adopted. With no id
|
|
1702
|
+
// recorded, a found project is reused only when it carries this org's
|
|
1703
|
+
// mark, or holds no application or database (a crash between create and
|
|
1704
|
+
// record). Refused before any write otherwise.
|
|
1705
|
+
if (found && found.description !== coolifyProjectMark(org)) {
|
|
1706
|
+
const resources = await this.ports.coolify.listResources?.(found.uuid);
|
|
1707
|
+
if (!resources || resources.some((r) => r.kind !== 'environment')) {
|
|
1708
|
+
throw new Error(`coolify project ${org.slug} (${found.uuid}) was not created by foundry for this org and may hold other resources: rename or delete it, then apply again`);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1611
1711
|
const projectUuid = found
|
|
1612
1712
|
? found.uuid
|
|
1613
|
-
: (await this.ports.coolify.createProject(org.slug)).uuid;
|
|
1713
|
+
: (await this.ports.coolify.createProject(org.slug, coolifyProjectMark(org))).uuid;
|
|
1614
1714
|
context.recordScopeId('coolify_project', projectUuid);
|
|
1615
1715
|
return projectUuid;
|
|
1616
1716
|
}
|
|
@@ -1694,6 +1794,10 @@ export class FoundryStepExecutor {
|
|
|
1694
1794
|
let haveNames;
|
|
1695
1795
|
let have;
|
|
1696
1796
|
let appId;
|
|
1797
|
+
// A stopped app with no settings marker was never deployed successfully
|
|
1798
|
+
// (the marker is recorded only after the redeploy), e.g. a first deploy
|
|
1799
|
+
// that failed: starting it builds nothing. A parked app has its marker.
|
|
1800
|
+
let neverDeployed = false;
|
|
1697
1801
|
if (existing) {
|
|
1698
1802
|
appId = existing.uuid;
|
|
1699
1803
|
// Found by name (rather than just created) is started back up if not
|
|
@@ -1705,6 +1809,7 @@ export class FoundryStepExecutor {
|
|
|
1705
1809
|
live ??= await this.ports.coolify.getApplication(existing.uuid);
|
|
1706
1810
|
if (!isRunningOrStarting(live.status)) {
|
|
1707
1811
|
await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
|
|
1812
|
+
neverDeployed = context.recordedIds()[settingsKey] === undefined;
|
|
1708
1813
|
}
|
|
1709
1814
|
have = appliedAppSettings(context.recordedIds(), entry.key);
|
|
1710
1815
|
haveNames = appliedAppDomains(org, context.recordedIds(), entry.key);
|
|
@@ -1839,8 +1944,10 @@ export class FoundryStepExecutor {
|
|
|
1839
1944
|
// that was already there is restarted, once, when its database URLs or its
|
|
1840
1945
|
// declared appEnv changed.
|
|
1841
1946
|
// A new folder needs a build, which a restart does not do, so a settings
|
|
1842
|
-
// change deploys instead (rule 3), and that carries the env too.
|
|
1843
|
-
|
|
1947
|
+
// change deploys instead (rule 3), and that carries the env too. A
|
|
1948
|
+
// created app is never deployed by Coolify itself (create sends no
|
|
1949
|
+
// `instant_deploy`), so it deploys here too (#566).
|
|
1950
|
+
if (settingsNeedDeploy || !existing || neverDeployed) {
|
|
1844
1951
|
// #543: every deploy waits for health through the shared helper, never
|
|
1845
1952
|
// a fire-and-forget `deploy` call -- `names[0]` is the same hostname
|
|
1846
1953
|
// `healthUrls` probes for this entry; an internal entry (no hostname)
|
|
@@ -1879,7 +1986,10 @@ export class FoundryStepExecutor {
|
|
|
1879
1986
|
// One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
|
|
1880
1987
|
// second entry sharing the name would delete the first's live token.
|
|
1881
1988
|
if (entry.key === APP_ENTRY_KEY) {
|
|
1882
|
-
await this.ensureAppLettermintMail(appId, org, context,
|
|
1989
|
+
await this.ensureAppLettermintMail(appId, org, context,
|
|
1990
|
+
// Deployed or started above on every path, a created app included
|
|
1991
|
+
// (#566), so it has read its env before the mail env is written.
|
|
1992
|
+
true,
|
|
1883
1993
|
// #491 (review round 2, finding 3): `diff.ts`'s `planApps` always
|
|
1884
1994
|
// sets `platformApex` on this step's `desired` once it's planned at
|
|
1885
1995
|
// all -- `null` when no platform is declared, never omitted. The key
|
|
@@ -1908,9 +2018,9 @@ export class FoundryStepExecutor {
|
|
|
1908
2018
|
* container itself still writing nowhere). `wasAlreadyRunning` tells this
|
|
1909
2019
|
* method whether `appId` names an app `coolifyApp` found already there
|
|
1910
2020
|
* (restart it once the volume is created) or one it just created fresh
|
|
1911
|
-
* (
|
|
1912
|
-
*
|
|
1913
|
-
*
|
|
2021
|
+
* (`coolifyApp` deploys it after this call, #566, and that first start
|
|
2022
|
+
* carries the storage, so a restart here would only be a redundant extra
|
|
2023
|
+
* one).
|
|
1914
2024
|
*/
|
|
1915
2025
|
async ensureLogVolume(appId, org, wasAlreadyRunning, appName = org.slug) {
|
|
1916
2026
|
const storages = await this.ports.coolify.listStorages(appId);
|
|
@@ -2420,7 +2530,7 @@ export class FoundryStepExecutor {
|
|
|
2420
2530
|
await this.ports.coolify.bulkUpsertEnvs(appId, envUpdates);
|
|
2421
2531
|
// A running container read its env at start and still holds the old
|
|
2422
2532
|
// token, so a fresh one only reaches it through a restart -- before the
|
|
2423
|
-
// old project is deleted below. A just-created app
|
|
2533
|
+
// old project is deleted below. A just-created app is deployed first (#566).
|
|
2424
2534
|
if (!healthy && running)
|
|
2425
2535
|
await this.ports.coolify.restart(appId);
|
|
2426
2536
|
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
|
|
@@ -4299,9 +4409,15 @@ export class FoundryStepExecutor {
|
|
|
4299
4409
|
// (#115) gets one ingress entry per hostname `tunnelIngressHostnames`
|
|
4300
4410
|
// returns, all routed to the same proxy -- Traefik itself, not this list,
|
|
4301
4411
|
// is what tells the public site apart from the cms studio.
|
|
4302
|
-
|
|
4412
|
+
// #568: the company's own login host goes straight to the platform's
|
|
4413
|
+
// login app (the same container the platform auth tunnel routes to), not
|
|
4414
|
+
// through Coolify's proxy, which has no route for it.
|
|
4415
|
+
const loginHost = action.desired.loginHost ?? null;
|
|
4416
|
+
const ingress = tunnelIngressHostnames(org, loginHost).map((hostname) => ({
|
|
4303
4417
|
hostname,
|
|
4304
|
-
service:
|
|
4418
|
+
service: hostname === loginHost
|
|
4419
|
+
? `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}`
|
|
4420
|
+
: COOLIFY_PROXY_ORIGIN,
|
|
4305
4421
|
}));
|
|
4306
4422
|
await this.tunnelPort().putTunnelConfig(tunnel.id, {
|
|
4307
4423
|
ingress: [...ingress, { service: 'http_status:404' }],
|
|
@@ -4366,7 +4482,7 @@ export class FoundryStepExecutor {
|
|
|
4366
4482
|
const previousHostnamesRaw = recordedBefore[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
|
|
4367
4483
|
const previousHostnames = previousHostnamesRaw ? previousHostnamesRaw.split(',') : [];
|
|
4368
4484
|
const domainChanged = previousDomain !== undefined && previousDomain !== org.domain;
|
|
4369
|
-
const publishedHostnames = await this.publishTunnelDnsRecords(org, org.domain, tunnel.id,
|
|
4485
|
+
const publishedHostnames = await this.publishTunnelDnsRecords(org, org.domain, loginHost, tunnel.id,
|
|
4370
4486
|
// Round 6 finding 2: every id this step has ever replaced, not only
|
|
4371
4487
|
// the one `recordedTunnelId` captured above -- see the scope key's
|
|
4372
4488
|
// own doc comment.
|
|
@@ -4529,7 +4645,7 @@ export class FoundryStepExecutor {
|
|
|
4529
4645
|
* overwriting them. `destroy` and {@link deleteStaleTunnelDnsRecords}
|
|
4530
4646
|
* both read this set.
|
|
4531
4647
|
*/
|
|
4532
|
-
async publishTunnelDnsRecords(org, domain, tunnelId, previousIds, previousHostnames, context) {
|
|
4648
|
+
async publishTunnelDnsRecords(org, domain, loginHost, tunnelId, previousIds, previousHostnames, context) {
|
|
4533
4649
|
const dns = this.dnsPort();
|
|
4534
4650
|
const zone = await this.zonePort().findZoneByApex(domain);
|
|
4535
4651
|
if (!zone) {
|
|
@@ -4537,7 +4653,7 @@ export class FoundryStepExecutor {
|
|
|
4537
4653
|
}
|
|
4538
4654
|
const dnsTarget = `${tunnelId}.cfargotunnel.com`;
|
|
4539
4655
|
const previousTargets = new Set([...previousIds].filter((id) => id !== tunnelId).map((id) => `${id}.cfargotunnel.com`));
|
|
4540
|
-
const hostnames = tunnelDnsHostnames(org, domain);
|
|
4656
|
+
const hostnames = tunnelDnsHostnames(org, domain, loginHost);
|
|
4541
4657
|
const droppedHostnames = previousHostnames.filter((hostname) => !hostnames.includes(hostname));
|
|
4542
4658
|
const conflicts = [];
|
|
4543
4659
|
const toCreate = [];
|
|
@@ -4715,6 +4831,9 @@ export class FoundryStepExecutor {
|
|
|
4715
4831
|
const parkedMailOrg = mailOrgFor(org, usesDomain, 'hosted');
|
|
4716
4832
|
await this.ports.mail.parkOrgMail(parkedMailOrg, options);
|
|
4717
4833
|
await this.parkLettermintMail(org, parkedMailOrg, context, platformApex, platformLettermintProjectId);
|
|
4834
|
+
// #554: so a later declaration of hosted or received is not mistaken for applied.
|
|
4835
|
+
context.recordScopeId(ORG_MAIL_APPLIED_MODE_SCOPE_KEY, mode);
|
|
4836
|
+
context.recordScopeId(ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, String(usesDomain));
|
|
4718
4837
|
return undefined;
|
|
4719
4838
|
}
|
|
4720
4839
|
const mailOrg = mailOrgFor(org, usesDomain, mode);
|
|
@@ -4722,12 +4841,16 @@ export class FoundryStepExecutor {
|
|
|
4722
4841
|
await this.publishMailDnsRecords(org, result);
|
|
4723
4842
|
await this.deleteStaleLettermintDomain(context, result.domain, platformApex);
|
|
4724
4843
|
context.recordScopeId(MAIL_DOMAIN_SCOPE_KEY, result.domain);
|
|
4725
|
-
// #177: what this step wrote from the platform, so `planMail` can see a later change.
|
|
4726
|
-
context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
|
|
4727
|
-
context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
|
|
4728
4844
|
await this.ensureOwnerMailbox(org, mailOrg, options, context);
|
|
4729
4845
|
await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context, platformApex, platformLettermintProjectId);
|
|
4730
4846
|
await this.ensureRoleMailboxes(org, mailOrg, result.domain, options, context);
|
|
4847
|
+
// #177, #554: what this step wrote, so `planMail` can see a later change
|
|
4848
|
+
// and knows mail is applied. Last on purpose: a crash in the relay or a
|
|
4849
|
+
// mailbox leaves these unrecorded, so the next plan still has the step.
|
|
4850
|
+
context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
|
|
4851
|
+
context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
|
|
4852
|
+
context.recordScopeId(ORG_MAIL_APPLIED_MODE_SCOPE_KEY, mode);
|
|
4853
|
+
context.recordScopeId(ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, String(usesDomain));
|
|
4731
4854
|
return { resourceId: result.domain };
|
|
4732
4855
|
}
|
|
4733
4856
|
/**
|
package/dist/plan/hostnames.d.ts
CHANGED
|
@@ -36,8 +36,10 @@ export declare function filesShareHostname(org: DeclaredOrg): string | null;
|
|
|
36
36
|
* The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
|
|
37
37
|
* #164): a site's two names, every entry's names, an own-domain calls name,
|
|
38
38
|
* Agora's and Boule's, then the wildcard last. An org that does not host keeps the single
|
|
39
|
-
* name it always had.
|
|
39
|
+
* name it always had. `loginHost` (#568) is the company's own `auth.<domain>`
|
|
40
|
+
* when it has one; it is listed before the wildcard, and the tunnel step routes
|
|
41
|
+
* it to the platform's login app, not to Coolify's proxy.
|
|
40
42
|
*/
|
|
41
|
-
export declare function tunnelIngressHostnames(org: DeclaredOrg): string[];
|
|
43
|
+
export declare function tunnelIngressHostnames(org: DeclaredOrg, loginHost?: string | null): string[];
|
|
42
44
|
/** The ingress names that get a DNS record: those under the org's own domain. */
|
|
43
|
-
export declare function tunnelDnsHostnames(org: DeclaredOrg, domain: string): string[];
|
|
45
|
+
export declare function tunnelDnsHostnames(org: DeclaredOrg, domain: string, loginHost?: string | null): string[];
|
package/dist/plan/hostnames.js
CHANGED
|
@@ -61,9 +61,11 @@ export function filesShareHostname(org) {
|
|
|
61
61
|
* The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
|
|
62
62
|
* #164): a site's two names, every entry's names, an own-domain calls name,
|
|
63
63
|
* Agora's and Boule's, then the wildcard last. An org that does not host keeps the single
|
|
64
|
-
* name it always had.
|
|
64
|
+
* name it always had. `loginHost` (#568) is the company's own `auth.<domain>`
|
|
65
|
+
* when it has one; it is listed before the wildcard, and the tunnel step routes
|
|
66
|
+
* it to the platform's login app, not to Coolify's proxy.
|
|
65
67
|
*/
|
|
66
|
-
export function tunnelIngressHostnames(org) {
|
|
68
|
+
export function tunnelIngressHostnames(org, loginHost = null) {
|
|
67
69
|
const hostnames = org.site.enabled && org.domain ? [org.domain, `cms.${org.domain}`] : [];
|
|
68
70
|
if (org.hosting.enabled) {
|
|
69
71
|
for (const entry of org.hosting.entries)
|
|
@@ -84,6 +86,8 @@ export function tunnelIngressHostnames(org) {
|
|
|
84
86
|
const share = filesShareHostname(org);
|
|
85
87
|
if (share)
|
|
86
88
|
hostnames.push(share);
|
|
89
|
+
if (loginHost)
|
|
90
|
+
hostnames.push(loginHost);
|
|
87
91
|
// The wildcard goes last: Cloudflare takes the first ingress rule that
|
|
88
92
|
// matches, so every exact name above must come before `*.<domain>`.
|
|
89
93
|
const wildcard = wildcardHostname(org);
|
|
@@ -92,6 +96,6 @@ export function tunnelIngressHostnames(org) {
|
|
|
92
96
|
return [...new Set(hostnames)];
|
|
93
97
|
}
|
|
94
98
|
/** The ingress names that get a DNS record: those under the org's own domain. */
|
|
95
|
-
export function tunnelDnsHostnames(org, domain) {
|
|
96
|
-
return tunnelIngressHostnames(org).filter((hostname) => hostname === domain || hostname.endsWith(`.${domain}`));
|
|
99
|
+
export function tunnelDnsHostnames(org, domain, loginHost = null) {
|
|
100
|
+
return tunnelIngressHostnames(org, loginHost).filter((hostname) => hostname === domain || hostname.endsWith(`.${domain}`));
|
|
97
101
|
}
|
|
@@ -81,8 +81,15 @@ export interface LoginHostBindingsPort {
|
|
|
81
81
|
* on the next run.
|
|
82
82
|
*/
|
|
83
83
|
export declare const LOGIN_BINDING_SCOPE_KEY = "login_host_binding";
|
|
84
|
-
/**
|
|
85
|
-
|
|
84
|
+
/**
|
|
85
|
+
* #573: keys of the entries that sign in the customers organisation. The login
|
|
86
|
+
* host's binding admits the staff organisation only, so these entries stay
|
|
87
|
+
* out of it and sign in on the shared host. A recorded client id whose entry
|
|
88
|
+
* is no longer declared is not here, so it stays bound as before.
|
|
89
|
+
*/
|
|
90
|
+
export declare function customersEntryKeys(org: DeclaredOrg): Set<string>;
|
|
91
|
+
/** The org's recorded app client ids (hosted app, each entry's own bar a customers entry, cms, agora, boule), deduplicated and sorted. */
|
|
92
|
+
export declare function loginHostClientIds(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): string[];
|
|
86
93
|
/** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
|
|
87
94
|
export declare function loginBindingState(host: string, clientIds: readonly string[]): string;
|
|
88
95
|
/**
|
|
@@ -101,3 +108,11 @@ export declare const LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY = "zitadel_login_host_
|
|
|
101
108
|
export declare function pendingRemovalHosts(recorded: Readonly<Record<string, string>>): string[];
|
|
102
109
|
/** {@link pendingRemovalHosts}' own serialisation, sorted so the recorded value is stable regardless of check order. */
|
|
103
110
|
export declare function pendingRemovalState(hosts: readonly string[]): string;
|
|
111
|
+
/**
|
|
112
|
+
* #568: the company's own login host when the company tunnel has to publish
|
|
113
|
+
* it: `auth.<domain>`, only when it is under the org's own domain
|
|
114
|
+
* (`issuer.usesDomain`) and the org is not the platform's own, whose host is
|
|
115
|
+
* the shared instance's and is routed by the platform auth tunnel. Otherwise
|
|
116
|
+
* `null`. The tier 1 host rides the shared `*.oikos.host` tunnel.
|
|
117
|
+
*/
|
|
118
|
+
export declare function companyLoginTunnelHost(org: DeclaredOrg, isPlatformOrg: boolean): string | null;
|
package/dist/plan/login-host.js
CHANGED
|
@@ -55,15 +55,26 @@ export const LOGIN_HOST_SHARED_SCOPE_KEY = 'zitadel_login_host_shared';
|
|
|
55
55
|
* on the next run.
|
|
56
56
|
*/
|
|
57
57
|
export const LOGIN_BINDING_SCOPE_KEY = 'login_host_binding';
|
|
58
|
-
/**
|
|
59
|
-
|
|
58
|
+
/**
|
|
59
|
+
* #573: keys of the entries that sign in the customers organisation. The login
|
|
60
|
+
* host's binding admits the staff organisation only, so these entries stay
|
|
61
|
+
* out of it and sign in on the shared host. A recorded client id whose entry
|
|
62
|
+
* is no longer declared is not here, so it stays bound as before.
|
|
63
|
+
*/
|
|
64
|
+
export function customersEntryKeys(org) {
|
|
65
|
+
return new Set(org.hosting.entries.filter((e) => e.uses?.auth?.users === 'customers').map((e) => e.key));
|
|
66
|
+
}
|
|
67
|
+
/** The org's recorded app client ids (hosted app, each entry's own bar a customers entry, cms, agora, boule), deduplicated and sorted. */
|
|
68
|
+
export function loginHostClientIds(org, recorded) {
|
|
69
|
+
const customers = customersEntryKeys(org);
|
|
60
70
|
const ids = [
|
|
61
71
|
recorded[APP_CLIENT_ID_SCOPE_KEY],
|
|
62
72
|
recorded[CMS_CLIENT_ID_SCOPE_KEY],
|
|
63
73
|
recorded[AGORA_CLIENT_ID_SCOPE_KEY],
|
|
64
74
|
recorded[BOULE_CLIENT_ID_SCOPE_KEY],
|
|
65
75
|
...Object.entries(recorded)
|
|
66
|
-
.filter(([key]) => key.startsWith(ENTRY_CLIENT_ID_SCOPE_PREFIX)
|
|
76
|
+
.filter(([key]) => key.startsWith(ENTRY_CLIENT_ID_SCOPE_PREFIX) &&
|
|
77
|
+
!customers.has(key.slice(ENTRY_CLIENT_ID_SCOPE_PREFIX.length)))
|
|
67
78
|
.map(([, id]) => id),
|
|
68
79
|
].filter((id) => Boolean(id));
|
|
69
80
|
return [...new Set(ids)].sort();
|
|
@@ -93,3 +104,13 @@ export function pendingRemovalHosts(recorded) {
|
|
|
93
104
|
export function pendingRemovalState(hosts) {
|
|
94
105
|
return [...hosts].sort().join(',');
|
|
95
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* #568: the company's own login host when the company tunnel has to publish
|
|
109
|
+
* it: `auth.<domain>`, only when it is under the org's own domain
|
|
110
|
+
* (`issuer.usesDomain`) and the org is not the platform's own, whose host is
|
|
111
|
+
* the shared instance's and is routed by the platform auth tunnel. Otherwise
|
|
112
|
+
* `null`. The tier 1 host rides the shared `*.oikos.host` tunnel.
|
|
113
|
+
*/
|
|
114
|
+
export function companyLoginTunnelHost(org, isPlatformOrg) {
|
|
115
|
+
return org.domain && org.issuer.usesDomain && !isPlatformOrg ? loginHostnameFor(org) : null;
|
|
116
|
+
}
|
|
@@ -9,7 +9,7 @@ import { type AdoptCandidate, type AdoptInput, type AdoptPlan } from './adopt.js
|
|
|
9
9
|
import { type AgoraHostPort } from './agora.js';
|
|
10
10
|
import { type StepAction } from './diff.js';
|
|
11
11
|
import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
|
|
12
|
-
import { type HeldKeyResolver } from './platform-org.js';
|
|
12
|
+
import { type EdgePortName, type HeldKeyResolver } from './platform-org.js';
|
|
13
13
|
import type { CallsSfuConnectionPort } from './platform-sfu.js';
|
|
14
14
|
/** Who did it, for `@wtfalch/audit`'s "who, when, the plan, the result" (#9, #19). */
|
|
15
15
|
export interface AuditActor {
|
|
@@ -142,6 +142,8 @@ export interface StepExecutionContext {
|
|
|
142
142
|
* scope key can never collide with the `system:kind:key` form.
|
|
143
143
|
*/
|
|
144
144
|
recordScopeId(key: string, id: string): void;
|
|
145
|
+
/** Removes a recorded id (any key form). Optional: a context without it cannot forget one. */
|
|
146
|
+
forgetId?(key: string): void;
|
|
145
147
|
/**
|
|
146
148
|
* Writes every id recorded so far to the store now, not only at the end of
|
|
147
149
|
* the run. For an id that must survive a crash mid-step: the ai key's id
|
|
@@ -161,6 +163,14 @@ export interface StepExecutor {
|
|
|
161
163
|
execute(action: StepAction, context: StepExecutionContext): Promise<{
|
|
162
164
|
resourceId?: string;
|
|
163
165
|
} | undefined>;
|
|
166
|
+
/**
|
|
167
|
+
* #555: which of the edge-routed ports (`dns`, `zones`, `access`,
|
|
168
|
+
* `ratelimits`, `tunnels`) this executor was built with its own
|
|
169
|
+
* implementation of, which it uses before edge. The planner blocks a step
|
|
170
|
+
* that needs edge only if the port it needs is not listed here. Optional:
|
|
171
|
+
* an executor without it supplies none.
|
|
172
|
+
*/
|
|
173
|
+
ownEdgePorts?(): Partial<Record<EdgePortName, boolean>>;
|
|
164
174
|
}
|
|
165
175
|
/**
|
|
166
176
|
* #167: {@link StepExecutionContext}'s platform-scoped counterpart -- what
|
|
@@ -212,6 +222,10 @@ export interface PlatformStepExecutor {
|
|
|
212
222
|
hc?: string;
|
|
213
223
|
/** #540: see `PlanPlatformOptions.blocked.heartbeatsExecutorMissing` (`platform-diff.ts`). */
|
|
214
224
|
heartbeatsExecutorMissing?: boolean;
|
|
225
|
+
/** See `PlanPlatformOptions.blocked.archonRunsInsideApp`. */
|
|
226
|
+
archonRunsInsideApp?: boolean;
|
|
227
|
+
/** See `PlanPlatformOptions.blocked.archonDeployRunning`. */
|
|
228
|
+
archonDeployRunning?: string;
|
|
215
229
|
}>;
|
|
216
230
|
/**
|
|
217
231
|
* Steps whose live state differs from the declared one in a way the recorded
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -13,7 +13,7 @@ import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from
|
|
|
13
13
|
import { readLivePlatformResources, readLiveResources, } from './live.js';
|
|
14
14
|
import { lockedHostnames } from './locked-hostnames.js';
|
|
15
15
|
import { PLATFORM_ARCHON_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_KEY, appStepKey, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
|
|
16
|
-
import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
|
|
16
|
+
import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, dnsBlockedReason, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
|
|
17
17
|
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
18
18
|
import { liveWorkers, workerTraceScopeKeys } from './worker.js';
|
|
19
19
|
/** #441: the lease scope that serializes every mutation of one company, and the one for the shared platform. */
|
|
@@ -773,6 +773,9 @@ export class FoundryPlanApply {
|
|
|
773
773
|
recordScopeId: (key, id) => {
|
|
774
774
|
serviceIds[key] = id;
|
|
775
775
|
},
|
|
776
|
+
forgetId: (key) => {
|
|
777
|
+
delete serviceIds[key];
|
|
778
|
+
},
|
|
776
779
|
persistScopeIds: async () => {
|
|
777
780
|
await this.store.putAppliedState({
|
|
778
781
|
orgId,
|
|
@@ -938,7 +941,13 @@ export class FoundryPlanApply {
|
|
|
938
941
|
catch {
|
|
939
942
|
// not held
|
|
940
943
|
}
|
|
941
|
-
return {
|
|
944
|
+
return {
|
|
945
|
+
...readiness,
|
|
946
|
+
mailKeyHeld,
|
|
947
|
+
canHoldSecrets: typeof heldKeys?.put === 'function',
|
|
948
|
+
dnsBlocked: dnsBlockedReason(platform, recorded),
|
|
949
|
+
ownEdgePorts: this.executor.ownEdgePorts?.(),
|
|
950
|
+
};
|
|
942
951
|
}
|
|
943
952
|
/**
|
|
944
953
|
* #177: `AgoraHostValues` from the applied platform, the same
|
|
@@ -1171,7 +1180,7 @@ function destroyTargets(serviceIds, orgId) {
|
|
|
1171
1180
|
continue;
|
|
1172
1181
|
}
|
|
1173
1182
|
for (const [resourceKey, id] of Object.entries(serviceIds)) {
|
|
1174
|
-
if (!resourceKey.startsWith(prefix))
|
|
1183
|
+
if (!resourceKey.startsWith(prefix) || !id)
|
|
1175
1184
|
continue;
|
|
1176
1185
|
// Archon#271: foundry did not create an adopted app or database, so it never deletes it.
|
|
1177
1186
|
if (adopted.has(resourceKey))
|
|
@@ -1224,7 +1233,7 @@ function destroyTargets(serviceIds, orgId) {
|
|
|
1224
1233
|
targets.push({ system: 'edge', kind: 'key', key: entryKey, id });
|
|
1225
1234
|
}
|
|
1226
1235
|
for (const [resourceKey, id] of Object.entries(serviceIds)) {
|
|
1227
|
-
if (!resourceKey.startsWith('zitadel:app:'))
|
|
1236
|
+
if (!resourceKey.startsWith('zitadel:app:') || !id)
|
|
1228
1237
|
continue;
|
|
1229
1238
|
targets.push({
|
|
1230
1239
|
system: 'zitadel',
|