@wtfalch/foundry 0.45.1 → 0.47.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/build-json.d.ts +28 -0
- package/dist/build-json.js +95 -0
- package/dist/index.d.ts +10 -4
- package/dist/index.js +6 -2
- package/dist/migrations/0058_app_requests.sql +32 -0
- package/dist/org.d.ts +68 -5
- package/dist/org.js +287 -135
- package/dist/plan/agora.d.ts +14 -0
- package/dist/plan/agora.js +21 -0
- package/dist/plan/backup-gate.d.ts +15 -0
- package/dist/plan/backup-gate.js +38 -0
- package/dist/plan/boule-owners-http.d.ts +12 -0
- package/dist/plan/boule-owners-http.js +59 -0
- package/dist/plan/boule.d.ts +38 -2
- package/dist/plan/boule.js +72 -1
- package/dist/plan/company-backups.d.ts +7 -1
- package/dist/plan/company-backups.js +42 -2
- package/dist/plan/cookie-domain.d.ts +41 -0
- package/dist/plan/cookie-domain.js +71 -0
- package/dist/plan/desktop-auth.d.ts +58 -0
- package/dist/plan/desktop-auth.js +144 -0
- package/dist/plan/desktop-callback.d.ts +26 -0
- package/dist/plan/desktop-callback.js +59 -0
- package/dist/plan/diff.d.ts +13 -4
- package/dist/plan/diff.js +277 -88
- package/dist/plan/executor.d.ts +136 -6
- package/dist/plan/executor.js +732 -105
- package/dist/plan/health-gaps.js +5 -0
- package/dist/plan/health-targets.js +1 -2
- package/dist/plan/hostnames.d.ts +2 -2
- package/dist/plan/hostnames.js +6 -6
- package/dist/plan/index.d.ts +4 -1
- package/dist/plan/index.js +3 -1
- package/dist/plan/job-app.d.ts +23 -0
- package/dist/plan/job-app.js +41 -0
- package/dist/plan/live.d.ts +6 -0
- package/dist/plan/live.js +16 -0
- package/dist/plan/login-host.d.ts +28 -0
- package/dist/plan/login-host.js +36 -0
- package/dist/plan/org-names.js +9 -1
- package/dist/plan/org-postgres-job.d.ts +22 -0
- package/dist/plan/org-postgres-job.js +91 -0
- package/dist/plan/org-postgres-migrate.d.ts +115 -0
- package/dist/plan/org-postgres-migrate.js +466 -0
- package/dist/plan/org-postgres.d.ts +90 -0
- package/dist/plan/org-postgres.js +293 -0
- package/dist/plan/plan-apply.d.ts +60 -3
- package/dist/plan/plan-apply.js +174 -8
- package/dist/plan/platform-diff.d.ts +45 -0
- package/dist/plan/platform-diff.js +159 -4
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-org.js +4 -0
- package/dist/plan/platform-providers.d.ts +12 -2
- package/dist/plan/platform-providers.js +282 -9
- package/dist/plan/post-logout-uris.d.ts +22 -0
- package/dist/plan/post-logout-uris.js +43 -0
- package/dist/plan/providers-readers.d.ts +10 -1
- package/dist/plan/providers-readers.js +56 -5
- package/dist/plan/sign-in-bindings.d.ts +4 -0
- package/dist/plan/sign-in-bindings.js +10 -0
- package/dist/plan/sign-in-org.d.ts +19 -6
- package/dist/plan/sign-in-org.js +35 -10
- package/dist/plan/sign-in.d.ts +12 -5
- package/dist/plan/sign-in.js +12 -5
- package/dist/plan/signin-db-move.js +10 -34
- package/dist/plan/worker-app.js +3 -1
- package/dist/refuse-org.d.ts +7 -0
- package/dist/refuse-org.js +10 -0
- package/dist/requested-changes.d.ts +50 -4
- package/dist/requested-changes.js +9 -1
- package/dist/store/postgres.d.ts +5 -1
- package/dist/store/postgres.js +67 -8
- package/dist/store/schema.d.ts +72 -0
- package/dist/store/schema.js +4 -0
- package/dist/store.d.ts +24 -1
- package/dist/worker-app-config.d.ts +8 -2
- package/dist/worker-app-config.js +20 -6
- package/package.json +5 -5
package/dist/plan/executor.js
CHANGED
|
@@ -4,21 +4,25 @@ import { URL } from 'node:url';
|
|
|
4
4
|
import { coolifyImageFor } from '../bootstrap/versions.js';
|
|
5
5
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
6
6
|
import { resolveCallsSfuUrls } from '../calls-sfu.js';
|
|
7
|
-
import { APP_ENTRY_KEY,
|
|
7
|
+
import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, filesCollectionEnvName, heldEnvRef, isMainEntry, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
|
|
8
8
|
import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
|
|
9
|
-
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
9
|
+
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, DESKTOP_AUTH_PORT, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, desktopAuthInternalName, entryClientIdScopeKey, } from './agora.js';
|
|
10
10
|
import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, } from './blue-green-apply.js';
|
|
11
11
|
import { bgKey, colorAppName, entrySubject, healthTokenName, liveAppNameFor, splitServers, } from './blue-green.js';
|
|
12
|
-
import {
|
|
12
|
+
import { BouleOwnersError, httpBouleOwners } from './boule-owners-http.js';
|
|
13
|
+
import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_CUSTOMERS_ORG_ENV, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_SIGN_IN_METADATA_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleArchonCredentialRef, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, bouleOwnerRefusedScopeKey, bouleOwnerSeededScopeKey, bouleOwnerSeeds, customersDirectoryRef, customersOrgIdOf, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
13
14
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, mcpCallbackUri, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
14
15
|
import { recordBuiltCommit } from './build-record.js';
|
|
15
16
|
import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
16
17
|
import { CHAT_MAS_PORT, CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, assertChatServerName, chatInternalNames, chatMasConfigYaml, chatRekeyedInternalNames, chatServerName, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
|
|
17
18
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
18
|
-
import { BACKUP_SCOPE_PREFIX, COMPANY_BACKUP_DATABASE_NAMES, backupScopeKey, } from './company-backups.js';
|
|
19
|
+
import { BACKUP_POSTGRES_DATABASES_SCOPE_KEY, BACKUP_SCOPE_PREFIX, COMPANY_BACKUP_DATABASE_NAMES, backupScopeKey, } from './company-backups.js';
|
|
20
|
+
import { LOGIN_COOKIE_DOMAIN_SCOPE_KEY } from './cookie-domain.js';
|
|
19
21
|
import { previewsOn } from './coolify-previews.js';
|
|
20
22
|
import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
|
|
21
|
-
import { waitForNewDeployment, } from './deploy-health.js';
|
|
23
|
+
import { waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
|
|
24
|
+
import { DESKTOP_AUTH_APP_SCOPE_KEY, DESKTOP_AUTH_DEPLOYED_SCOPE_KEY, DESKTOP_AUTH_DOCKERFILE, DESKTOP_AUTH_HOME, DESKTOP_AUTH_ORIGIN_SCOPE_KEY, DESKTOP_AUTH_READY_PATH, DESKTOP_AUTH_READY_SCOPE_KEY, DESKTOP_AUTH_REDIRECT_SCOPE_KEY, DESKTOP_AUTH_REVISION_SCOPE_KEY, desktopAuthCallbackUri, desktopAuthEnv, desktopAuthInputs, desktopAuthRedirectUnsupported, desktopAuthRevision, } from './desktop-auth.js';
|
|
25
|
+
import { desktopCallbackUri, unionRedirectUris, zitadelAppDesktopCallbackAppliedScopeKey, } from './desktop-callback.js';
|
|
22
26
|
import { PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, onPlatformNetwork, placed, placedByScope, } from './destinations.js';
|
|
23
27
|
import { DEFAULT_AI_LIMIT_MICROS } from './diff.js';
|
|
24
28
|
import { createEdgeAccess, isNotFound as isEdgeNotFound, } from './edge-access.js';
|
|
@@ -33,11 +37,13 @@ import { GATE_CLAIM_NAME, gateHostnamesScopeKey, gateIdRecordKey, isBehindGate }
|
|
|
33
37
|
import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, wgetProbe, } from './health-check-settings.js';
|
|
34
38
|
import { PROXY_LABEL_TOKEN_REF } from './held-key-refs.js';
|
|
35
39
|
import { appHostname, cmsStudioHostnameFor, coolifyTunnelDomains, entryHostnames, siteHostnameFor, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
36
|
-
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, customersEntryKeys, loginBindingState, loginHostClientIds, ownAuthApplied, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
40
|
+
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, clientOrganizationIds, clientOrganizationsState, customersClientOrganizations, customersEntryKeys, loginBindingState, loginHostClientIds, ownAuthApplied, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
37
41
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
38
42
|
import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_SCOPE_KEY, isOwnMailSmtpPort, normalizeMailTarget, } from './mail-relay.js';
|
|
39
43
|
import { applyAccessTokenLifetime, applyMcpAccess, applyMcpProject } from './mcp.js';
|
|
40
44
|
import { NETWORK_APPLIED_SCOPE_KEY } from './network-move.js';
|
|
45
|
+
import { ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationNotConfirmedError, migrateHomeOf, migrateOrgPostgresHome, parseMigrationConfirmation, } from './org-postgres-migrate.js';
|
|
46
|
+
import { orgPostgresSecretRef, provisionOrgPostgresDatabases, provisionOrgPostgresServer, serviceEndpoint, serviceOnServer, serviceUrls, } from './org-postgres.js';
|
|
41
47
|
import { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, } from './own-diff.js';
|
|
42
48
|
import { NO_DOMAIN_ZONE, ownHostnames } from './own-hostnames.js';
|
|
43
49
|
import { LIMITS, limited } from './own-limits.js';
|
|
@@ -46,6 +52,7 @@ import { ownSfuUrls, provisionSfuApp } from './own-sfu.js';
|
|
|
46
52
|
import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
|
|
47
53
|
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, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
|
|
48
54
|
import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
|
|
55
|
+
import { companyAppPostLogoutUris, postLogoutAppliedValue, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
|
|
49
56
|
import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
|
|
50
57
|
import { createReleaseHost } from './release-host.js';
|
|
51
58
|
import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
|
|
@@ -86,8 +93,8 @@ export function coolifyProjectMark(org) {
|
|
|
86
93
|
* listApps` -- a plain array, with no `loginBaseUri` or completeness signal
|
|
87
94
|
* -- into {@link ZitadelPort.listApps}'s own shape, so a host wiring that
|
|
88
95
|
* real client in satisfies this port without reaching into `executor.ts`
|
|
89
|
-
* itself to build the wrapper by hand. `complete
|
|
90
|
-
*
|
|
96
|
+
* itself to build the wrapper by hand. `complete` is true only when the client
|
|
97
|
+
* has `getApp` (#557; providers 0.12.0, which pages `_search` to the end). A host
|
|
91
98
|
* that supplies its own `listApps` keeps whatever `complete` it reports.
|
|
92
99
|
* Each app's `clientId` (providers 0.7.5) passes through. `loginBaseUri` comes back `undefined`
|
|
93
100
|
* for every app for the identical reason -- the real client does not read
|
|
@@ -99,9 +106,10 @@ export function coolifyProjectMark(org) {
|
|
|
99
106
|
export function adaptZitadelListApps(client) {
|
|
100
107
|
return async (orgId, projectId) => ({
|
|
101
108
|
apps: await client.listApps(orgId, projectId),
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
109
|
+
// #557: `getApp` arrived with providers 0.12.0, after `listApps` paged to
|
|
110
|
+
// the end (0.7.4), so a client that has it read every app. An older one
|
|
111
|
+
// may have returned a first page only; the peer range alone cannot tell.
|
|
112
|
+
complete: typeof client.getApp === 'function',
|
|
105
113
|
});
|
|
106
114
|
}
|
|
107
115
|
/** An outside call for Boule's credentials that has not answered in this long fails the step; the next apply repairs it. */
|
|
@@ -203,7 +211,7 @@ export function callsHostnameFor(org) {
|
|
|
203
211
|
*/
|
|
204
212
|
export function healthUrls(org) {
|
|
205
213
|
// One URL per entry that has a public name; an internal entry has none.
|
|
206
|
-
if (org.hosting.enabled &&
|
|
214
|
+
if (org.hosting.enabled && org.hosting.entries.length > 0) {
|
|
207
215
|
return org.hosting.entries.flatMap((entry) => {
|
|
208
216
|
const [hostname] = entryHostnames(org, entry);
|
|
209
217
|
if (!hostname)
|
|
@@ -890,6 +898,7 @@ function limitProfileOf(action) {
|
|
|
890
898
|
case 'coolify:calls-chat':
|
|
891
899
|
return LIMITS.calls;
|
|
892
900
|
case 'coolify:agora':
|
|
901
|
+
case 'coolify:desktop-auth':
|
|
893
902
|
return LIMITS.agora;
|
|
894
903
|
case 'coolify:boule':
|
|
895
904
|
case 'coolify:boule-broker-entry':
|
|
@@ -904,6 +913,10 @@ function limitProfileOf(action) {
|
|
|
904
913
|
return action.system === 'coolify' ? LIMITS.companyOther : undefined;
|
|
905
914
|
}
|
|
906
915
|
}
|
|
916
|
+
/** The cookie domain a login-host action carries: a bare lower-case domain of two or more labels, else none. */
|
|
917
|
+
function inAppCookieDomain(value) {
|
|
918
|
+
return typeof value === 'string' && /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(value) ? value : undefined;
|
|
919
|
+
}
|
|
907
920
|
export class FoundryStepExecutor {
|
|
908
921
|
edgeDns;
|
|
909
922
|
edgeTunnels;
|
|
@@ -1127,6 +1140,10 @@ export class FoundryStepExecutor {
|
|
|
1127
1140
|
return this.zitadelApp(action, org, context);
|
|
1128
1141
|
case 'zitadel:id-token-userinfo':
|
|
1129
1142
|
return this.zitadelIdTokenUserinfo(action, org, context);
|
|
1143
|
+
case 'zitadel:post-logout-uris':
|
|
1144
|
+
return this.zitadelPostLogoutUris(action, org, context);
|
|
1145
|
+
case 'zitadel:desktop-callback':
|
|
1146
|
+
return this.zitadelDesktopCallback(action, org, context);
|
|
1130
1147
|
case 'zitadel:customers':
|
|
1131
1148
|
return this.zitadelCustomers(action, org, context);
|
|
1132
1149
|
case 'zitadel:mcp-project':
|
|
@@ -1147,6 +1164,12 @@ export class FoundryStepExecutor {
|
|
|
1147
1164
|
return this.coolifyBrokerEntry(action, org, context);
|
|
1148
1165
|
case 'coolify:database':
|
|
1149
1166
|
return this.coolifyDatabase(action, org, context);
|
|
1167
|
+
case 'coolify:org-postgres':
|
|
1168
|
+
return this.orgPostgresServer(org, context);
|
|
1169
|
+
case 'coolify:org-postgres-databases':
|
|
1170
|
+
return this.orgPostgresDatabases(action, org, context);
|
|
1171
|
+
case 'coolify:org-postgres-migrate':
|
|
1172
|
+
return this.orgPostgresMigrate(action, org, context);
|
|
1150
1173
|
case 'coolify:backup':
|
|
1151
1174
|
return this.coolifyBackup(action, org, context);
|
|
1152
1175
|
case 'coolify:calls':
|
|
@@ -1204,8 +1227,12 @@ export class FoundryStepExecutor {
|
|
|
1204
1227
|
return this.cmsSite(action, org, context);
|
|
1205
1228
|
case 'coolify:agora':
|
|
1206
1229
|
return this.coolifyAgora(action, org, context);
|
|
1230
|
+
case 'coolify:desktop-auth':
|
|
1231
|
+
return this.coolifyDesktopAuth(action, org, context);
|
|
1207
1232
|
case 'coolify:boule':
|
|
1208
1233
|
return this.coolifyBoule(action, org, context);
|
|
1234
|
+
case 'coolify:boule-owners':
|
|
1235
|
+
return this.bouleOwners(org, context);
|
|
1209
1236
|
case 'coolify:boule-broker-entry':
|
|
1210
1237
|
return this.bouleBrokerEntry(org, context);
|
|
1211
1238
|
case 'jobs:registration':
|
|
@@ -1407,6 +1434,7 @@ export class FoundryStepExecutor {
|
|
|
1407
1434
|
throw new Error(`cloudflare:ratelimit for ${org.slug}: zone ${org.domain} already has a rate limiting rule ('${foreign.description ?? foreign.id}') foundry did not create; the Free plan allows only one rule per zone, so foundry does not adopt or replace it`);
|
|
1408
1435
|
}
|
|
1409
1436
|
// The other variant of foundry's own rule holds the zone's one slot: remove it, then make this one.
|
|
1437
|
+
// Known gap: between the delete and the create the zone has no rate limit (the Free plan has one slot).
|
|
1410
1438
|
const stale = rules.find((r) => ours.includes(r.description ?? ''));
|
|
1411
1439
|
if (stale) {
|
|
1412
1440
|
if (!rateLimits.deleteRateLimitRule) {
|
|
@@ -1874,6 +1902,96 @@ export class FoundryStepExecutor {
|
|
|
1874
1902
|
}
|
|
1875
1903
|
return {};
|
|
1876
1904
|
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Replaces the post-logout redirect URIs of each live app the planner named.
|
|
1907
|
+
* An app's marker (its id and the set it holds) is recorded and persisted
|
|
1908
|
+
* only after its call returned, so a call that throws leaves none and the
|
|
1909
|
+
* next plan names the app again. The error names ids only, never the cause.
|
|
1910
|
+
*/
|
|
1911
|
+
async zitadelPostLogoutUris(action, org, context) {
|
|
1912
|
+
const update = this.ports.zitadel.updateOidcAppPostLogoutRedirectUris?.bind(this.ports.zitadel);
|
|
1913
|
+
// The planner plans nothing without the method; a stale plan does nothing.
|
|
1914
|
+
if (!update)
|
|
1915
|
+
return {};
|
|
1916
|
+
const recorded = context.recordedIds();
|
|
1917
|
+
const zitadelOrgId = recorded.zitadel_org;
|
|
1918
|
+
const projectId = recorded.zitadel_project;
|
|
1919
|
+
if (!zitadelOrgId || !projectId) {
|
|
1920
|
+
throw new Error(`zitadel:post-logout-uris for ${org.slug}: no ZITADEL org and project recorded yet -- the issuer must be provisioned first`);
|
|
1921
|
+
}
|
|
1922
|
+
for (const appKey of action.desired.apps) {
|
|
1923
|
+
const appId = recorded[`zitadel:app:${appKey}`];
|
|
1924
|
+
if (!appId)
|
|
1925
|
+
continue;
|
|
1926
|
+
// From the declared org, never from the plan, so a stale plan writes the current set.
|
|
1927
|
+
const uris = companyAppPostLogoutUris(org, appKey);
|
|
1928
|
+
if (uris.length === 0)
|
|
1929
|
+
continue;
|
|
1930
|
+
try {
|
|
1931
|
+
await update(zitadelOrgId, projectId, appId, uris);
|
|
1932
|
+
}
|
|
1933
|
+
catch {
|
|
1934
|
+
throw new Error(`zitadel:post-logout-uris for ${org.slug}: updating the post-logout addresses failed for app '${appKey}' (${appId})`);
|
|
1935
|
+
}
|
|
1936
|
+
context.recordScopeId(zitadelAppPostLogoutAppliedScopeKey(appKey), postLogoutAppliedValue(appId, uris));
|
|
1937
|
+
await context.persistScopeIds();
|
|
1938
|
+
}
|
|
1939
|
+
return {};
|
|
1940
|
+
}
|
|
1941
|
+
/**
|
|
1942
|
+
* #339: registers `/auth/desktop/callback` on each live web sign-in app the
|
|
1943
|
+
* planner named, keeping every redirect URI the app already holds (the
|
|
1944
|
+
* union of the current list and the wanted one; a write only when the
|
|
1945
|
+
* union is larger). An app's marker (its ZITADEL app id) is recorded and
|
|
1946
|
+
* persisted only after its read and write resolved, so a failure leaves no
|
|
1947
|
+
* marker and the next plan names the app again. The error names ids only,
|
|
1948
|
+
* never the cause.
|
|
1949
|
+
*/
|
|
1950
|
+
async zitadelDesktopCallback(action, org, context) {
|
|
1951
|
+
const read = this.ports.zitadel.getOidcAppRedirectUris?.bind(this.ports.zitadel);
|
|
1952
|
+
const write = this.ports.zitadel.updateOidcAppRedirectUris?.bind(this.ports.zitadel);
|
|
1953
|
+
// The planner plans nothing without the methods; a stale plan does nothing.
|
|
1954
|
+
if (!read || !write)
|
|
1955
|
+
return {};
|
|
1956
|
+
const recorded = context.recordedIds();
|
|
1957
|
+
const zitadelOrgId = recorded.zitadel_org;
|
|
1958
|
+
const projectId = recorded.zitadel_project;
|
|
1959
|
+
if (!zitadelOrgId || !projectId) {
|
|
1960
|
+
throw new Error(`zitadel:desktop-callback for ${org.slug}: no ZITADEL org and project recorded yet -- the issuer must be provisioned first`);
|
|
1961
|
+
}
|
|
1962
|
+
for (const appKey of action.desired.apps) {
|
|
1963
|
+
const appId = recorded[`zitadel:app:${appKey}`];
|
|
1964
|
+
if (!appId)
|
|
1965
|
+
continue;
|
|
1966
|
+
const wanted = this.signInHostnames(org, appKey).map(desktopCallbackUri);
|
|
1967
|
+
try {
|
|
1968
|
+
const current = await read(zitadelOrgId, projectId, appId);
|
|
1969
|
+
const union = unionRedirectUris(current, wanted);
|
|
1970
|
+
if (union.length > current.length)
|
|
1971
|
+
await write(zitadelOrgId, projectId, appId, union);
|
|
1972
|
+
}
|
|
1973
|
+
catch {
|
|
1974
|
+
throw new Error(`zitadel:desktop-callback for ${org.slug}: registering the desktop callback failed for app '${appKey}' (${appId})`);
|
|
1975
|
+
}
|
|
1976
|
+
context.recordScopeId(zitadelAppDesktopCallbackAppliedScopeKey(appKey), appId);
|
|
1977
|
+
await context.persistScopeIds();
|
|
1978
|
+
}
|
|
1979
|
+
return {};
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* The hostnames a company web app signs in at: the same rule `zitadelApp`
|
|
1983
|
+
* uses for the redirect URIs it creates the app with.
|
|
1984
|
+
*/
|
|
1985
|
+
signInHostnames(org, appKey) {
|
|
1986
|
+
const authEntry = org.hosting.entries.find((e) => e.key === appKey && e.uses?.auth);
|
|
1987
|
+
if (appKey === 'cms' && org.site.enabled)
|
|
1988
|
+
return [cmsHostnameFor(org)];
|
|
1989
|
+
if (appKey === 'boule')
|
|
1990
|
+
return [bouleHostnameFor(org)];
|
|
1991
|
+
if (authEntry)
|
|
1992
|
+
return entryHostnames(org, authEntry);
|
|
1993
|
+
return [appHostname(org)];
|
|
1994
|
+
}
|
|
1877
1995
|
/**
|
|
1878
1996
|
* `plan/diff.ts`'s `planLoginHost` (#267): adds the org's own company
|
|
1879
1997
|
* login host (`action.desired.host`) to the ZITADEL instance's trusted
|
|
@@ -1883,10 +2001,12 @@ export class FoundryStepExecutor {
|
|
|
1883
2001
|
*
|
|
1884
2002
|
* In-app sign-in (`signIn.inApp`, `plan/sign-in.ts` has the order): an app host is trusted first. It is
|
|
1885
2003
|
* bound, and its app pointed at `/sign-in/`, only once its app runs `SIGNIN_UPSTREAM` (the app step
|
|
1886
|
-
* records that).
|
|
1887
|
-
*
|
|
1888
|
-
*
|
|
1889
|
-
*
|
|
2004
|
+
* records that). From auth 0.28.0 the move is three writes (the login then allows one client in two
|
|
2005
|
+
* bindings of one company): the app host's binding with the id kept on the company's, the base URI, then
|
|
2006
|
+
* the company's binding without the id. Before it, one write moves the id (the login refuses a client in
|
|
2007
|
+
* two bindings) and a short gap follows. A company binding left with no client is removed.
|
|
2008
|
+
* Switched off, the id goes back to the company binding, then the base URI returns to the login host, then
|
|
2009
|
+
* the app binding goes; a trusted domain is given up only after the app dropped the env.
|
|
1890
2010
|
*
|
|
1891
2011
|
* #274: when the org's host has moved (`recorded[key]`, the *previous*
|
|
1892
2012
|
* run's resourceId, differs from this run's `host`), also removes every
|
|
@@ -1985,6 +2105,55 @@ export class FoundryStepExecutor {
|
|
|
1985
2105
|
const envLive = signInEnvLiveHosts(recorded);
|
|
1986
2106
|
// The login refuses a client id held by two bindings: an active app host's client id is in its own binding only.
|
|
1987
2107
|
const clientIds = loginHostClientIds(org, recorded, activePairs.map((pair) => pair.clientId));
|
|
2108
|
+
let deferredUnbound = [];
|
|
2109
|
+
// From the login release that lets one client sit in two bindings of one company, an app host's client id
|
|
2110
|
+
// moves in three writes: the app host's binding with the id kept on the company's, then the base URI, then
|
|
2111
|
+
// the company's binding without it. Sign-in works at every point. Before that release: one write, and a
|
|
2112
|
+
// short gap between it and the base URI.
|
|
2113
|
+
const authReleased = action.desired.authReleased === true;
|
|
2114
|
+
const companyIdsBefore = new Set((recorded[LOGIN_BINDING_SCOPE_KEY]?.startsWith(`${host} `)
|
|
2115
|
+
? recorded[LOGIN_BINDING_SCOPE_KEY].slice(host.length + 1)
|
|
2116
|
+
: '')
|
|
2117
|
+
.split(',')
|
|
2118
|
+
.filter((id) => id.length > 0));
|
|
2119
|
+
const movedIds = new Set(!own && authReleased
|
|
2120
|
+
? activePairs.map((pair) => pair.clientId).filter((id) => companyIdsBefore.has(id))
|
|
2121
|
+
: []);
|
|
2122
|
+
const threeStep = movedIds.size > 0;
|
|
2123
|
+
const firstWriteIds = threeStep
|
|
2124
|
+
? loginHostClientIds(org, recorded, activePairs.map((pair) => pair.clientId).filter((id) => !movedIds.has(id)))
|
|
2125
|
+
: clientIds;
|
|
2126
|
+
const cookieDomain = inAppCookieDomain(action.desired.cookieDomain);
|
|
2127
|
+
const writeCompanyBinding = async (ids) => {
|
|
2128
|
+
if (ids.length > 0 || activePairs.length === 0) {
|
|
2129
|
+
await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds: ids });
|
|
2130
|
+
}
|
|
2131
|
+
else {
|
|
2132
|
+
// Every client of the company signs in on its app's own address: the login refuses a binding
|
|
2133
|
+
// with no client ids, so the company's own binding goes (nothing signs in on this host).
|
|
2134
|
+
try {
|
|
2135
|
+
await bindings.deleteBinding(host);
|
|
2136
|
+
}
|
|
2137
|
+
catch (error) {
|
|
2138
|
+
if (!isNotFoundError(error))
|
|
2139
|
+
throw error;
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
};
|
|
2143
|
+
// The customers clients and the organisation each signs in to, for the shared login host to check.
|
|
2144
|
+
const wantedOrgs = customersClientOrganizations(org, recorded);
|
|
2145
|
+
const staleOrgIds = clientOrganizationIds(recorded[LOGIN_CLIENT_ORGS_SCOPE_KEY]).filter((id) => !(id in wantedOrgs));
|
|
2146
|
+
const orgsWritable = (Object.keys(wantedOrgs).length === 0 || Boolean(bindings.upsertClientOrganizations)) &&
|
|
2147
|
+
(staleOrgIds.length === 0 || Boolean(bindings.deleteClientOrganizations));
|
|
2148
|
+
const writeClientOrgs = async () => {
|
|
2149
|
+
if (!orgsWritable)
|
|
2150
|
+
return;
|
|
2151
|
+
if (Object.keys(wantedOrgs).length > 0) {
|
|
2152
|
+
await bindings.upsertClientOrganizations?.(wantedOrgs);
|
|
2153
|
+
}
|
|
2154
|
+
if (staleOrgIds.length > 0)
|
|
2155
|
+
await bindings.deleteClientOrganizations?.(staleOrgIds);
|
|
2156
|
+
};
|
|
1988
2157
|
if (!shared) {
|
|
1989
2158
|
// The company's own login host is its instance's external domain: trusted and routed already.
|
|
1990
2159
|
if (!own)
|
|
@@ -1994,10 +2163,12 @@ export class FoundryStepExecutor {
|
|
|
1994
2163
|
await this.ports.zitadel.ensureTrustedDomain(app.host);
|
|
1995
2164
|
// An app host bound before and not active now gives its client id back to the company's binding:
|
|
1996
2165
|
// its own binding goes first, so no write leaves the id in two bindings.
|
|
2166
|
+
// From the release that allows it the company's binding takes the id back first and the app host's
|
|
2167
|
+
// binding goes after the base URI (below), so no write leaves a client without a binding.
|
|
2168
|
+
const unboundHosts = inAppStateHosts(recorded[SIGN_IN_IN_APP_SCOPE_KEY]).filter((old) => !activeHosts.has(old));
|
|
2169
|
+
deferredUnbound = authReleased && !own ? unboundHosts : [];
|
|
1997
2170
|
let unbound = false;
|
|
1998
|
-
for (const old of
|
|
1999
|
-
if (activeHosts.has(old))
|
|
2000
|
-
continue;
|
|
2171
|
+
for (const old of authReleased && !own ? [] : unboundHosts) {
|
|
2001
2172
|
try {
|
|
2002
2173
|
await bindings.deleteBinding(old);
|
|
2003
2174
|
}
|
|
@@ -2011,27 +2182,20 @@ export class FoundryStepExecutor {
|
|
|
2011
2182
|
await bindings.flush?.();
|
|
2012
2183
|
// zitadel-login's own routing table, written before any app is repointed
|
|
2013
2184
|
// so no app ever points at a host with no binding.
|
|
2014
|
-
if (!own)
|
|
2015
|
-
|
|
2016
|
-
await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
|
|
2017
|
-
}
|
|
2018
|
-
else {
|
|
2019
|
-
// Every client of the company signs in on its app's own address: the login refuses a binding
|
|
2020
|
-
// with no client ids, so the company's own binding goes (nothing signs in on this host).
|
|
2021
|
-
try {
|
|
2022
|
-
await bindings.deleteBinding(host);
|
|
2023
|
-
}
|
|
2024
|
-
catch (error) {
|
|
2025
|
-
if (!isNotFoundError(error))
|
|
2026
|
-
throw error;
|
|
2027
|
-
}
|
|
2028
|
-
}
|
|
2029
|
-
}
|
|
2185
|
+
if (!own)
|
|
2186
|
+
await writeCompanyBinding(firstWriteIds);
|
|
2030
2187
|
for (const app of inApp) {
|
|
2031
2188
|
if (!activeHosts.has(app.host))
|
|
2032
2189
|
continue;
|
|
2033
|
-
await bindings.upsertBinding({
|
|
2190
|
+
await bindings.upsertBinding({
|
|
2191
|
+
...inAppBindingFor(app),
|
|
2192
|
+
organizationId: zitadelOrgId,
|
|
2193
|
+
...(cookieDomain && (app.host === cookieDomain || app.host.endsWith(`.${cookieDomain}`))
|
|
2194
|
+
? { cookieDomain }
|
|
2195
|
+
: {}),
|
|
2196
|
+
});
|
|
2034
2197
|
}
|
|
2198
|
+
await writeClientOrgs();
|
|
2035
2199
|
// One write for every upsert above (one redeploy of the login), before any app is repointed.
|
|
2036
2200
|
await bindings.flush?.();
|
|
2037
2201
|
if (own && activeHosts.size > 0) {
|
|
@@ -2070,11 +2234,28 @@ export class FoundryStepExecutor {
|
|
|
2070
2234
|
// on), and nothing to repoint either.
|
|
2071
2235
|
let liveApps = [];
|
|
2072
2236
|
let listingComplete = true;
|
|
2237
|
+
// #580: a customers entry's app signs in on the shared host, wherever its base URI points now.
|
|
2238
|
+
// A company's own instance has no shared host to move to.
|
|
2239
|
+
const customersAppIds = new Set();
|
|
2240
|
+
const sharedBaseUri = own
|
|
2241
|
+
? null
|
|
2242
|
+
: await this.ports.platformOrg?.authHostname?.().then((h) => (h ? `https://${h}/` : null));
|
|
2243
|
+
if (sharedBaseUri) {
|
|
2244
|
+
for (const key of customersKeys) {
|
|
2245
|
+
const appId = recorded[`zitadel:app:${key}`];
|
|
2246
|
+
if (appId)
|
|
2247
|
+
customersAppIds.add(appId);
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2073
2250
|
if (projectId) {
|
|
2074
2251
|
const listed = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
|
|
2075
2252
|
listingComplete = listed.complete;
|
|
2076
2253
|
liveApps = listed.apps.filter((app) => app.state !== 'APP_STATE_REMOVED');
|
|
2077
2254
|
for (const app of liveApps) {
|
|
2255
|
+
if (sharedBaseUri && customersAppIds.has(app.id)) {
|
|
2256
|
+
await setOidcAppLoginBaseUri(this.ports.zitadel, zitadelOrgId, projectId, app.id, sharedBaseUri);
|
|
2257
|
+
continue;
|
|
2258
|
+
}
|
|
2078
2259
|
if (!boundAppIds.has(app.id))
|
|
2079
2260
|
continue;
|
|
2080
2261
|
// A company's own instance has the login host as its default; only the in-app apps move.
|
|
@@ -2083,6 +2264,27 @@ export class FoundryStepExecutor {
|
|
|
2083
2264
|
await setOidcAppLoginBaseUri(this.ports.zitadel, zitadelOrgId, projectId, app.id, baseUriOf(app.id));
|
|
2084
2265
|
}
|
|
2085
2266
|
}
|
|
2267
|
+
// The base URIs are set: the company's binding gives up the ids that moved to the app hosts, and an app
|
|
2268
|
+
// host given up goes last.
|
|
2269
|
+
if (!shared) {
|
|
2270
|
+
if (threeStep)
|
|
2271
|
+
await writeCompanyBinding(clientIds);
|
|
2272
|
+
for (const old of deferredUnbound) {
|
|
2273
|
+
try {
|
|
2274
|
+
await bindings.deleteBinding(old);
|
|
2275
|
+
}
|
|
2276
|
+
catch (error) {
|
|
2277
|
+
if (!isNotFoundError(error))
|
|
2278
|
+
throw error;
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
if (threeStep || deferredUnbound.length > 0)
|
|
2282
|
+
await bindings.flush?.();
|
|
2283
|
+
}
|
|
2284
|
+
else if (orgsWritable) {
|
|
2285
|
+
await writeClientOrgs();
|
|
2286
|
+
await bindings.flush?.();
|
|
2287
|
+
}
|
|
2086
2288
|
// Fix round 3 point 5: a bound app's current base URI is `host` for
|
|
2087
2289
|
// certain, just set above, whatever `listApps` read before that --
|
|
2088
2290
|
// never its stale pre-repoint snapshot. Every other live app's is
|
|
@@ -2092,7 +2294,11 @@ export class FoundryStepExecutor {
|
|
|
2092
2294
|
// compare against, same as an app this loop never visits at all.
|
|
2093
2295
|
const currentLoginBaseUris = new Set();
|
|
2094
2296
|
for (const app of liveApps) {
|
|
2095
|
-
const uri =
|
|
2297
|
+
const uri = customersAppIds.has(app.id) && sharedBaseUri
|
|
2298
|
+
? sharedBaseUri
|
|
2299
|
+
: boundAppIds.has(app.id)
|
|
2300
|
+
? baseUriOf(app.id)
|
|
2301
|
+
: app.loginBaseUri;
|
|
2096
2302
|
if (typeof uri === 'string')
|
|
2097
2303
|
currentLoginBaseUris.add(normalizedLoginBaseUri(uri));
|
|
2098
2304
|
}
|
|
@@ -2177,9 +2383,15 @@ export class FoundryStepExecutor {
|
|
|
2177
2383
|
else {
|
|
2178
2384
|
if (!own)
|
|
2179
2385
|
context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
|
|
2386
|
+
if (!own) {
|
|
2387
|
+
context.recordScopeId(LOGIN_COOKIE_DOMAIN_SCOPE_KEY, activePairs.length > 0 && cookieDomain ? cookieDomain : '');
|
|
2388
|
+
}
|
|
2180
2389
|
context.recordScopeId(SIGN_IN_TRUSTED_SCOPE_KEY, targets.trusted);
|
|
2181
2390
|
context.recordScopeId(SIGN_IN_IN_APP_SCOPE_KEY, targets.active);
|
|
2182
2391
|
}
|
|
2392
|
+
if (orgsWritable) {
|
|
2393
|
+
context.recordScopeId(LOGIN_CLIENT_ORGS_SCOPE_KEY, clientOrganizationsState(wantedOrgs));
|
|
2394
|
+
}
|
|
2183
2395
|
context.recordScopeId(LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, pendingRemovalState(stillPending));
|
|
2184
2396
|
return { resourceId: host };
|
|
2185
2397
|
}
|
|
@@ -2195,6 +2407,8 @@ export class FoundryStepExecutor {
|
|
|
2195
2407
|
}
|
|
2196
2408
|
const upsert = [];
|
|
2197
2409
|
const remove = [];
|
|
2410
|
+
let orgsUpsert = {};
|
|
2411
|
+
const orgsRemove = [];
|
|
2198
2412
|
return {
|
|
2199
2413
|
upsertBinding: async (binding) => {
|
|
2200
2414
|
upsert.push(binding);
|
|
@@ -2202,12 +2416,29 @@ export class FoundryStepExecutor {
|
|
|
2202
2416
|
deleteBinding: async (host) => {
|
|
2203
2417
|
remove.push(host);
|
|
2204
2418
|
},
|
|
2419
|
+
upsertClientOrganizations: async (entries) => {
|
|
2420
|
+
Object.assign(orgsUpsert, entries);
|
|
2421
|
+
},
|
|
2422
|
+
deleteClientOrganizations: async (clientIds) => {
|
|
2423
|
+
orgsRemove.push(...clientIds);
|
|
2424
|
+
},
|
|
2205
2425
|
flush: async () => {
|
|
2206
|
-
|
|
2426
|
+
const orgsEmpty = Object.keys(orgsUpsert).length === 0 && orgsRemove.length === 0;
|
|
2427
|
+
if (upsert.length === 0 && remove.length === 0 && orgsEmpty)
|
|
2207
2428
|
return;
|
|
2208
|
-
const edits = {
|
|
2429
|
+
const edits = {
|
|
2430
|
+
appId,
|
|
2431
|
+
instanceHost,
|
|
2432
|
+
upsert: [...upsert],
|
|
2433
|
+
remove: [...remove],
|
|
2434
|
+
...(orgsEmpty
|
|
2435
|
+
? {}
|
|
2436
|
+
: { clientOrganizations: { upsert: { ...orgsUpsert }, remove: [...orgsRemove] } }),
|
|
2437
|
+
};
|
|
2209
2438
|
upsert.length = 0;
|
|
2210
2439
|
remove.length = 0;
|
|
2440
|
+
orgsUpsert = {};
|
|
2441
|
+
orgsRemove.length = 0;
|
|
2211
2442
|
await editSignInBindings(this.ports.coolify, edits);
|
|
2212
2443
|
},
|
|
2213
2444
|
};
|
|
@@ -2420,6 +2651,9 @@ export class FoundryStepExecutor {
|
|
|
2420
2651
|
// An entry with `uses.auth` signs in at its own exact hostnames, one pair
|
|
2421
2652
|
// of addresses each; a `*` adds none (`entryHostnames`).
|
|
2422
2653
|
const hosts = authEntry ? entryHostnames(org, authEntry) : null;
|
|
2654
|
+
const postLogoutUris = appType === 'native' ? [] : companyAppPostLogoutUris(org, action.key);
|
|
2655
|
+
// #339: every `@wtfalch/auth` web client also takes the desktop callback (Agora's own client does not).
|
|
2656
|
+
const desktopCallbacks = appType === 'web' && !isAgoraClient ? (hosts ?? [hostname]).map(desktopCallbackUri) : [];
|
|
2423
2657
|
const created = await this.ports.zitadel.createOidcApp(zitadelOrgId, projectId, {
|
|
2424
2658
|
name: action.key,
|
|
2425
2659
|
appType,
|
|
@@ -2428,14 +2662,11 @@ export class FoundryStepExecutor {
|
|
|
2428
2662
|
: hosts
|
|
2429
2663
|
? [
|
|
2430
2664
|
...hosts.map((host) => `https://${host}/auth/callback`),
|
|
2665
|
+
...desktopCallbacks,
|
|
2431
2666
|
...(authEntry?.uses?.auth?.mcp ? [mcpCallbackUri(org)] : []),
|
|
2432
2667
|
]
|
|
2433
|
-
: [redirectUri],
|
|
2434
|
-
postLogoutRedirectUris:
|
|
2435
|
-
? []
|
|
2436
|
-
: hosts
|
|
2437
|
-
? hosts.map((host) => `https://${host}/`)
|
|
2438
|
-
: [`https://${hostname}/`],
|
|
2668
|
+
: [redirectUri, ...desktopCallbacks],
|
|
2669
|
+
postLogoutRedirectUris: postLogoutUris,
|
|
2439
2670
|
...(authEntry?.uses?.auth?.mcp ? { accessTokenType: 'jwt' } : {}),
|
|
2440
2671
|
});
|
|
2441
2672
|
if (appType === 'web') {
|
|
@@ -2451,6 +2682,12 @@ export class FoundryStepExecutor {
|
|
|
2451
2682
|
}
|
|
2452
2683
|
// Made with the setting on (providers 0.9.0): nothing to repair on this id.
|
|
2453
2684
|
context.recordScopeId(zitadelAppIdTokenUserinfoAppliedScopeKey(action.key), created.appId);
|
|
2685
|
+
if (appType === 'web') {
|
|
2686
|
+
context.recordScopeId(zitadelAppPostLogoutAppliedScopeKey(action.key), postLogoutAppliedValue(created.appId, postLogoutUris));
|
|
2687
|
+
}
|
|
2688
|
+
if (desktopCallbacks.length > 0) {
|
|
2689
|
+
context.recordScopeId(zitadelAppDesktopCallbackAppliedScopeKey(action.key), created.appId);
|
|
2690
|
+
}
|
|
2454
2691
|
return { resourceId: created.appId };
|
|
2455
2692
|
}
|
|
2456
2693
|
/**
|
|
@@ -2979,7 +3216,11 @@ export class FoundryStepExecutor {
|
|
|
2979
3216
|
let dbPush = { pushed: false, changed: false };
|
|
2980
3217
|
if (entry.database) {
|
|
2981
3218
|
const dbId = context.recordedIds()[`coolify:database:${org.id}`];
|
|
2982
|
-
if (
|
|
3219
|
+
if (this.homeOnServer(org, context, 'app')) {
|
|
3220
|
+
const onServer = await this.serverHome(org, context, 'app');
|
|
3221
|
+
dbPush = await this.pushDatabaseUrl(appId, '', org.slug.replace(/-/g, '_'), onServer);
|
|
3222
|
+
}
|
|
3223
|
+
else if (dbId) {
|
|
2983
3224
|
dbPush = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
|
|
2984
3225
|
}
|
|
2985
3226
|
}
|
|
@@ -3057,7 +3298,7 @@ export class FoundryStepExecutor {
|
|
|
3057
3298
|
// actually resolved a mail domain for this org.
|
|
3058
3299
|
// One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
|
|
3059
3300
|
// second entry sharing the name would delete the first's live token.
|
|
3060
|
-
if (entry
|
|
3301
|
+
if (isMainEntry(entry)) {
|
|
3061
3302
|
await this.ensureAppLettermintMail(appId, org, context,
|
|
3062
3303
|
// Deployed or started above on every path, a created app included
|
|
3063
3304
|
// (#566), so it has read its env before the mail env is written.
|
|
@@ -3141,7 +3382,7 @@ export class FoundryStepExecutor {
|
|
|
3141
3382
|
* the connection URL or a response body.
|
|
3142
3383
|
*/
|
|
3143
3384
|
async coolifyBackup(action, org, context) {
|
|
3144
|
-
const { databaseId, database, storageUuid } = action.desired;
|
|
3385
|
+
const { databaseId, database, storageUuid, databaseNames } = action.desired;
|
|
3145
3386
|
if (typeof databaseId !== 'string' ||
|
|
3146
3387
|
typeof database !== 'string' ||
|
|
3147
3388
|
typeof storageUuid !== 'string' ||
|
|
@@ -3153,11 +3394,115 @@ export class FoundryStepExecutor {
|
|
|
3153
3394
|
databaseUuid: databaseId,
|
|
3154
3395
|
label: `${org.slug} ${database}`,
|
|
3155
3396
|
storageUuid,
|
|
3156
|
-
databaseName:
|
|
3397
|
+
databaseName: typeof databaseNames === 'string' && databaseNames
|
|
3398
|
+
? databaseNames
|
|
3399
|
+
: COMPANY_BACKUP_DATABASE_NAMES[database],
|
|
3157
3400
|
});
|
|
3158
3401
|
context.recordScopeId(backupScopeKey(database), uuid);
|
|
3402
|
+
if (typeof databaseNames === 'string' && databaseNames) {
|
|
3403
|
+
context.recordScopeId(BACKUP_POSTGRES_DATABASES_SCOPE_KEY, databaseNames);
|
|
3404
|
+
}
|
|
3159
3405
|
return undefined;
|
|
3160
3406
|
}
|
|
3407
|
+
orgPostgresDeps(org, context) {
|
|
3408
|
+
return {
|
|
3409
|
+
coolify: this.ports.coolify,
|
|
3410
|
+
ensureProject: () => this.ensureCoolifyProject(org, context),
|
|
3411
|
+
serverUuid: async () => (await this.resolveServer(org, context)).uuid,
|
|
3412
|
+
startDatabase: (uuid) => getDatabaseStarted(this.ports.coolify, uuid),
|
|
3413
|
+
deliverSecret: (ref, secret) => this.ports.deliverSecret(ref, secret),
|
|
3414
|
+
onSecret: (value) => void this.handledSecrets.add(value),
|
|
3415
|
+
runtime: {
|
|
3416
|
+
...(this.ports.runtime?.sleep ? { sleep: this.ports.runtime.sleep } : {}),
|
|
3417
|
+
...(this.ports.runtime?.now ? { now: this.ports.runtime.now } : {}),
|
|
3418
|
+
},
|
|
3419
|
+
};
|
|
3420
|
+
}
|
|
3421
|
+
/** `coolify:org-postgres` (#444): the company's one Postgres server. */
|
|
3422
|
+
async orgPostgresServer(org, context) {
|
|
3423
|
+
return provisionOrgPostgresServer(scopeFromOrg(context, {}), this.orgPostgresDeps(org, context), org);
|
|
3424
|
+
}
|
|
3425
|
+
/** `coolify:org-postgres-databases` (#444): a database and its roles for each home that has none. */
|
|
3426
|
+
async orgPostgresDatabases(action, org, context) {
|
|
3427
|
+
const homes = action.desired.homes;
|
|
3428
|
+
if (!Array.isArray(homes) || !homes.every((home) => typeof home === 'string')) {
|
|
3429
|
+
throw new Error(`coolify:org-postgres-databases for ${org.slug}: the plan named no homes`);
|
|
3430
|
+
}
|
|
3431
|
+
await provisionOrgPostgresDatabases(scopeFromOrg(context, {}), this.orgPostgresDeps(org, context), org, homes);
|
|
3432
|
+
return {};
|
|
3433
|
+
}
|
|
3434
|
+
/**
|
|
3435
|
+
* `coolify:org-postgres-migrate` (#444): one home's data from its old Coolify database to the
|
|
3436
|
+
* company's server. Refuses before any port call unless the staging confirmation is recorded.
|
|
3437
|
+
*/
|
|
3438
|
+
async orgPostgresMigrate(action, org, context) {
|
|
3439
|
+
const scope = scopeFromOrg(context, {});
|
|
3440
|
+
if (!parseMigrationConfirmation(scope.recordedIds()[ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY])) {
|
|
3441
|
+
throw new OrgPostgresMigrationNotConfirmedError();
|
|
3442
|
+
}
|
|
3443
|
+
const home = migrateHomeOf(action);
|
|
3444
|
+
await migrateOrgPostgresHome(scope, {
|
|
3445
|
+
coolify: this.ports.coolify,
|
|
3446
|
+
ensureProject: () => this.ensureCoolifyProject(org, context),
|
|
3447
|
+
serverUuid: async () => (await this.resolveServer(org, context)).uuid,
|
|
3448
|
+
startServer: (uuid) => getDatabaseStarted(this.ports.coolify, uuid),
|
|
3449
|
+
readLegacyDatabase: (uuid) => this.ports.coolify.getDatabase(uuid),
|
|
3450
|
+
resolveSecret: async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? undefined,
|
|
3451
|
+
consumers: (schema) => this.migrationConsumers(org, context, schema.schema),
|
|
3452
|
+
onSecret: (value) => void this.handledSecrets.add(value),
|
|
3453
|
+
runtime: {
|
|
3454
|
+
...(this.ports.runtime?.sleep ? { sleep: this.ports.runtime.sleep } : {}),
|
|
3455
|
+
...(this.ports.runtime?.now ? { now: this.ports.runtime.now } : {}),
|
|
3456
|
+
},
|
|
3457
|
+
}, org, home);
|
|
3458
|
+
return {};
|
|
3459
|
+
}
|
|
3460
|
+
/** The apps that use a home's database, found the way each service finds its own app. */
|
|
3461
|
+
async migrationConsumers(org, context, schema) {
|
|
3462
|
+
const recorded = context.recordedIds();
|
|
3463
|
+
const byName = async (...names) => {
|
|
3464
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
3465
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
3466
|
+
const ids = [];
|
|
3467
|
+
for (const name of names) {
|
|
3468
|
+
const found = await this.ports.coolify.findApplicationByName(name, {
|
|
3469
|
+
environmentIds: [environment.id],
|
|
3470
|
+
});
|
|
3471
|
+
if (found)
|
|
3472
|
+
ids.push(found.uuid);
|
|
3473
|
+
}
|
|
3474
|
+
return ids;
|
|
3475
|
+
};
|
|
3476
|
+
const chat = chatInternalNames(org.slug);
|
|
3477
|
+
switch (schema) {
|
|
3478
|
+
case 'app': {
|
|
3479
|
+
const entry = org.hosting.entries.find((e) => e.database);
|
|
3480
|
+
const id = entry && recorded[`coolify:app:${appStepKey(org, entry.key)}`];
|
|
3481
|
+
return { appIds: id ? [id] : [], template: true };
|
|
3482
|
+
}
|
|
3483
|
+
case 'cms': {
|
|
3484
|
+
const id = recorded[CMS_APP_SCOPE_KEY];
|
|
3485
|
+
return { appIds: id ? [id] : [], template: true };
|
|
3486
|
+
}
|
|
3487
|
+
case 'boule':
|
|
3488
|
+
return { appIds: await byName(`${org.slug}-boule`), template: true };
|
|
3489
|
+
case 'chat_service':
|
|
3490
|
+
return { appIds: await byName(chat.service), template: true };
|
|
3491
|
+
case 'calls_service':
|
|
3492
|
+
return { appIds: await byName(callsServiceInternalName(org)), template: true };
|
|
3493
|
+
case 'chat_synapse':
|
|
3494
|
+
case 'chat_mas':
|
|
3495
|
+
return { appIds: await byName(chat.synapse, chat.mas), template: false };
|
|
3496
|
+
case 'calls_synapse':
|
|
3497
|
+
case 'calls_mas':
|
|
3498
|
+
return {
|
|
3499
|
+
appIds: await byName(`${org.slug}-calls-synapse`, `${org.slug}-calls-mas`),
|
|
3500
|
+
template: false,
|
|
3501
|
+
};
|
|
3502
|
+
default:
|
|
3503
|
+
throw new Error(`${org.slug}: ${schema} has no apps to stop`);
|
|
3504
|
+
}
|
|
3505
|
+
}
|
|
3161
3506
|
async coolifyDatabase(action, org, context) {
|
|
3162
3507
|
if (action.desired.enabled === false) {
|
|
3163
3508
|
const dbId = context.recordedIds()[`coolify:database:${action.key}`];
|
|
@@ -3169,6 +3514,10 @@ export class FoundryStepExecutor {
|
|
|
3169
3514
|
}
|
|
3170
3515
|
const existing = context.recordedIds()[`coolify:database:${action.key}`];
|
|
3171
3516
|
let dbId;
|
|
3517
|
+
if (this.homeOnServer(org, context, 'app')) {
|
|
3518
|
+
// #444: the app's database is on the company's server; this step makes none.
|
|
3519
|
+
return {};
|
|
3520
|
+
}
|
|
3172
3521
|
if (existing) {
|
|
3173
3522
|
dbId = existing;
|
|
3174
3523
|
}
|
|
@@ -3229,22 +3578,91 @@ export class FoundryStepExecutor {
|
|
|
3229
3578
|
* recorded in `serviceIds` (hence never in the store or an audit record),
|
|
3230
3579
|
* never logged, never interpolated into a thrown error.
|
|
3231
3580
|
*/
|
|
3232
|
-
async pushDatabaseUrl(appId, dbId, dbName) {
|
|
3233
|
-
|
|
3581
|
+
async pushDatabaseUrl(appId, dbId, dbName, onServer) {
|
|
3582
|
+
// On the company's server (#444) the owner URL is already built from the
|
|
3583
|
+
// held password; `dbId` is then unused.
|
|
3584
|
+
const ownerUrl = onServer
|
|
3585
|
+
? onServer.ownerUrl
|
|
3586
|
+
: (await getDatabaseStarted(this.ports.coolify, dbId)).internal_db_url;
|
|
3234
3587
|
// Nothing to push; `planApp` reports this as blocked from the live reader.
|
|
3235
|
-
if (!
|
|
3588
|
+
if (!ownerUrl)
|
|
3236
3589
|
return { pushed: false, changed: false };
|
|
3237
3590
|
const envs = [
|
|
3238
|
-
{ key: 'DATABASE_URL_OWNER', value:
|
|
3591
|
+
{ key: 'DATABASE_URL_OWNER', value: ownerUrl },
|
|
3239
3592
|
];
|
|
3240
|
-
const runtimeUrl = await this.ensureAppRuntimeUrl(appId,
|
|
3593
|
+
const runtimeUrl = await this.ensureAppRuntimeUrl(appId, ownerUrl, onServer?.runtimeUser ?? `${dbName}_rt`);
|
|
3241
3594
|
if (runtimeUrl)
|
|
3242
3595
|
envs.push({ key: 'DATABASE_URL', value: runtimeUrl });
|
|
3243
3596
|
// True when a value actually changed, so the caller restarts the app only
|
|
3244
3597
|
// then (the owner URL is upserted on every apply).
|
|
3245
3598
|
const ownerBefore = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL_OWNER' && !e.is_preview)?.value;
|
|
3246
3599
|
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
3247
|
-
return { pushed: true, changed: runtimeUrl !== null || ownerBefore !==
|
|
3600
|
+
return { pushed: true, changed: runtimeUrl !== null || ownerBefore !== ownerUrl };
|
|
3601
|
+
}
|
|
3602
|
+
/**
|
|
3603
|
+
* #444: the home's owner URL on the company's Postgres server, built from
|
|
3604
|
+
* the server's live host and the owner password the host holds
|
|
3605
|
+
* (`deliverSecret` under `org-postgres:<slug>:<home>:owner`). Only for a
|
|
3606
|
+
* home {@link homeLocation} puts on the server. The password is registered
|
|
3607
|
+
* for redaction and goes only into the returned URL.
|
|
3608
|
+
*/
|
|
3609
|
+
async serverHome(org, context, schema, options = {}) {
|
|
3610
|
+
const home = ORG_SERVICE_SCHEMAS.find((h) => h.schema === schema);
|
|
3611
|
+
const serverId = context.recordedIds()[ORG_POSTGRES_SERVER_SCOPE_KEY];
|
|
3612
|
+
if (!home || !serverId) {
|
|
3613
|
+
throw new Error(`${org.slug}: the Postgres server for ${schema} is not recorded yet`);
|
|
3614
|
+
}
|
|
3615
|
+
const server = await getDatabaseStarted(this.ports.coolify, serverId);
|
|
3616
|
+
const endpoint = server.internal_db_url
|
|
3617
|
+
? serviceEndpoint(org, home, server.internal_db_url)
|
|
3618
|
+
: null;
|
|
3619
|
+
if (!endpoint) {
|
|
3620
|
+
throw new Error(`${org.slug}: the Postgres server has no internal URL yet -- retry this apply once Coolify reports one`);
|
|
3621
|
+
}
|
|
3622
|
+
const password = await this.ports.heldKeys?.resolve(orgPostgresSecretRef(org, schema, 'owner'));
|
|
3623
|
+
if (!password) {
|
|
3624
|
+
throw new Error(`${org.slug}: the owner password for ${schema} is not held yet`);
|
|
3625
|
+
}
|
|
3626
|
+
this.handledSecrets.add(password);
|
|
3627
|
+
const runtimePassword = options.runtime
|
|
3628
|
+
? await this.ports.heldKeys?.resolve(orgPostgresSecretRef(org, schema, 'runtime'))
|
|
3629
|
+
: null;
|
|
3630
|
+
if (options.runtime && !runtimePassword) {
|
|
3631
|
+
throw new Error(`${org.slug}: the runtime password for ${schema} is not held yet`);
|
|
3632
|
+
}
|
|
3633
|
+
if (runtimePassword)
|
|
3634
|
+
this.handledSecrets.add(runtimePassword);
|
|
3635
|
+
const { owner, runtime } = serviceUrls(endpoint, password, runtimePassword ?? null);
|
|
3636
|
+
this.handledSecrets.add(owner);
|
|
3637
|
+
if (runtime)
|
|
3638
|
+
this.handledSecrets.add(runtime);
|
|
3639
|
+
return { ownerUrl: owner, runtimeUser: endpoint.runtimeUser, runtimeUrl: runtime };
|
|
3640
|
+
}
|
|
3641
|
+
/** #444: whether `schema`'s database is on the company's server (recorded, and not waiting for its migration). */
|
|
3642
|
+
homeOnServer(org, context, schema) {
|
|
3643
|
+
const home = ORG_SERVICE_SCHEMAS.find((h) => h.schema === schema);
|
|
3644
|
+
return home !== undefined && serviceOnServer(org, home, context.recordedIds());
|
|
3645
|
+
}
|
|
3646
|
+
/**
|
|
3647
|
+
* #444: a home's database. On the company's server nothing is made or
|
|
3648
|
+
* recorded (the legacy id stays unset, or the home would read as unmigrated)
|
|
3649
|
+
* and `ensureLegacy` is never called, so no `POSTGRES_INITDB_ARGS` is set:
|
|
3650
|
+
* the provisioning job creates the database with its collation. Otherwise
|
|
3651
|
+
* today's calls, unchanged.
|
|
3652
|
+
*/
|
|
3653
|
+
async ensureHomeDatabase(org, context, schema, scopeKey, ensureLegacy) {
|
|
3654
|
+
if (this.homeOnServer(org, context, schema)) {
|
|
3655
|
+
return { id: '', server: await this.serverHome(org, context, schema) };
|
|
3656
|
+
}
|
|
3657
|
+
const id = await ensureLegacy();
|
|
3658
|
+
context.recordScopeId(scopeKey, id);
|
|
3659
|
+
return { id, server: null };
|
|
3660
|
+
}
|
|
3661
|
+
/** The owner URL of a home from {@link ensureHomeDatabase}; the legacy one is read live and started. */
|
|
3662
|
+
async homeOwnerUrl(home) {
|
|
3663
|
+
return home.server
|
|
3664
|
+
? home.server.ownerUrl
|
|
3665
|
+
: (await getDatabaseStarted(this.ports.coolify, home.id)).internal_db_url;
|
|
3248
3666
|
}
|
|
3249
3667
|
/**
|
|
3250
3668
|
* `null` once the app already has a `DATABASE_URL` -- never rotated, the
|
|
@@ -3256,7 +3674,7 @@ export class FoundryStepExecutor {
|
|
|
3256
3674
|
* hands the app next. See `ensureCmsRuntimeUrl`'s own doc comment for the
|
|
3257
3675
|
* parsing and encoding choices this mirrors exactly.
|
|
3258
3676
|
*/
|
|
3259
|
-
async ensureAppRuntimeUrl(appId, dbInternalUrl,
|
|
3677
|
+
async ensureAppRuntimeUrl(appId, dbInternalUrl, runtimeUser) {
|
|
3260
3678
|
const envs = await this.ports.coolify.listEnvs(appId);
|
|
3261
3679
|
// An existing `DATABASE_URL` that is still the superuser string is what an
|
|
3262
3680
|
// app got before the split (#239): treat it as absent so the runtime role
|
|
@@ -3274,7 +3692,7 @@ export class FoundryStepExecutor {
|
|
|
3274
3692
|
return null;
|
|
3275
3693
|
}
|
|
3276
3694
|
const password = randomBytes(24).toString('base64url');
|
|
3277
|
-
return `postgres://${
|
|
3695
|
+
return `postgres://${runtimeUser}:${password}@${host}${pathname}${search}`;
|
|
3278
3696
|
}
|
|
3279
3697
|
/**
|
|
3280
3698
|
* Pushes what the org's app needs to sign a user in through ZITADEL (gap
|
|
@@ -3300,10 +3718,8 @@ export class FoundryStepExecutor {
|
|
|
3300
3718
|
async pushAppEnv(appId, org, entry, context, authIssuer, secretSources) {
|
|
3301
3719
|
const recorded = context.recordedIds();
|
|
3302
3720
|
const envs = [
|
|
3303
|
-
// Only the
|
|
3304
|
-
...(entry
|
|
3305
|
-
? [{ key: 'APP_URL', value: `https://${appHostname(org)}` }]
|
|
3306
|
-
: []),
|
|
3721
|
+
// Only the main entry has a public name; the others are internal.
|
|
3722
|
+
...(isMainEntry(entry) ? [{ key: 'APP_URL', value: `https://${appHostname(org)}` }] : []),
|
|
3307
3723
|
// #75: read back by the package-side pino sink (not this repo's job,
|
|
3308
3724
|
// #75 "What belongs where") once `ensureLogVolume` has mounted the
|
|
3309
3725
|
// matching volume.
|
|
@@ -3525,6 +3941,51 @@ export class FoundryStepExecutor {
|
|
|
3525
3941
|
* hosting entry's is: with the `AUTH_APP_KEY` `coolifyBoule` put in the
|
|
3526
3942
|
* Boule app's env.
|
|
3527
3943
|
*/
|
|
3944
|
+
async bouleOwners(org, context) {
|
|
3945
|
+
const at = `coolify:boule-owners for ${org.slug}`;
|
|
3946
|
+
const recorded = context.recordedIds();
|
|
3947
|
+
const appId = recorded[`coolify:boule:${org.id}`];
|
|
3948
|
+
const organisationId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
3949
|
+
if (!appId || !organisationId) {
|
|
3950
|
+
throw new Error(`${at}: the Boule app or its sign-in organisation is not recorded yet`);
|
|
3951
|
+
}
|
|
3952
|
+
const secret = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === BOULE_OWNER_SEED_SECRET_ENV && !e.is_preview)?.value;
|
|
3953
|
+
if (!secret)
|
|
3954
|
+
throw new Error(`${at}: the app has no ${BOULE_OWNER_SEED_SECRET_ENV} yet`);
|
|
3955
|
+
this.handledSecrets.add(secret);
|
|
3956
|
+
const owners = bouleOwnerSeeds(org, recorded);
|
|
3957
|
+
if (owners.length === 0)
|
|
3958
|
+
return {};
|
|
3959
|
+
let results;
|
|
3960
|
+
try {
|
|
3961
|
+
results = (await (this.ports.bouleOwners ?? httpBouleOwners()).seed({
|
|
3962
|
+
hostname: bouleHostnameFor(org),
|
|
3963
|
+
organisationId,
|
|
3964
|
+
secret,
|
|
3965
|
+
owners,
|
|
3966
|
+
})).results;
|
|
3967
|
+
}
|
|
3968
|
+
catch (error) {
|
|
3969
|
+
const status = error instanceof BouleOwnersError ? error.status : 0;
|
|
3970
|
+
throw new Error(status === 404
|
|
3971
|
+
? `Boule for ${org.slug} has no owner-seeding door yet: its deploy is older than the foundry release; retry once Boule has deployed main`
|
|
3972
|
+
: `${at}: ${status === 0 ? 'Boule did not answer' : `Boule answered ${status}`}`);
|
|
3973
|
+
}
|
|
3974
|
+
// One at a time, only after Boule answered for that owner.
|
|
3975
|
+
owners.forEach((owner, index) => {
|
|
3976
|
+
const result = results.find((r) => r.subject === owner.subject);
|
|
3977
|
+
if (!result)
|
|
3978
|
+
throw new Error(`${at}: Boule gave no result for owner ${index + 1}`);
|
|
3979
|
+
context.recordScopeId(result.outcome === 'refused'
|
|
3980
|
+
? bouleOwnerRefusedScopeKey(owner.email)
|
|
3981
|
+
: bouleOwnerSeededScopeKey(owner.email), result.outcome === 'refused'
|
|
3982
|
+
? result.code === 'platform_frozen' || result.code === 'holds_other_role'
|
|
3983
|
+
? result.code
|
|
3984
|
+
: 'refused'
|
|
3985
|
+
: '1');
|
|
3986
|
+
});
|
|
3987
|
+
return {};
|
|
3988
|
+
}
|
|
3528
3989
|
async bouleBrokerEntry(org, context) {
|
|
3529
3990
|
const broker = this.ports.broker;
|
|
3530
3991
|
if (!broker) {
|
|
@@ -3740,7 +4201,9 @@ export class FoundryStepExecutor {
|
|
|
3740
4201
|
const { org, context, mailDomain, self, mailFrom } = args;
|
|
3741
4202
|
const appName = `${org.slug}-app`;
|
|
3742
4203
|
const recorded = context.recordedIds();
|
|
3743
|
-
const appId = self.role === 'app'
|
|
4204
|
+
const appId = self.role === 'app'
|
|
4205
|
+
? self.id
|
|
4206
|
+
: recorded[`coolify:app:${appStepKey(org, mainEntry(org.hosting)?.key)}`];
|
|
3744
4207
|
const bouleId = self.role === 'boule' ? self.id : recorded[`coolify:boule:${org.id}`];
|
|
3745
4208
|
const peerListEnvs = (id) => !id || id === self.id
|
|
3746
4209
|
? Promise.resolve(null)
|
|
@@ -4193,8 +4656,15 @@ export class FoundryStepExecutor {
|
|
|
4193
4656
|
// and retried on the next one, rather than silently skipped -- the two
|
|
4194
4657
|
// `bulkUpsertEnvs` calls above have already landed by the time either
|
|
4195
4658
|
// could throw.
|
|
4196
|
-
|
|
4197
|
-
|
|
4659
|
+
// #444: on the company's server there is no Coolify database to make, and the
|
|
4660
|
+
// legacy id stays unset (recording it would mark the home as unmigrated).
|
|
4661
|
+
const cmsOnServer = this.homeOnServer(org, context, 'cms');
|
|
4662
|
+
const cmsServer = cmsOnServer ? await this.serverHome(org, context, 'cms') : null;
|
|
4663
|
+
const dbId = cmsOnServer
|
|
4664
|
+
? ''
|
|
4665
|
+
: await this.ensureCmsDatabase(org, context, projectUuid, environment);
|
|
4666
|
+
if (!cmsOnServer)
|
|
4667
|
+
context.recordScopeId(CMS_DATABASE_SCOPE_KEY, dbId);
|
|
4198
4668
|
// Read back from Coolify's own record (`pushDatabaseUrl`'s own doc
|
|
4199
4669
|
// comment on why: live state, not `createPostgresDatabase`'s return
|
|
4200
4670
|
// value, since this run may have just found the database rather than
|
|
@@ -4203,12 +4673,14 @@ export class FoundryStepExecutor {
|
|
|
4203
4673
|
// recorded, logged, or interpolated into a thrown error. Its own call,
|
|
4204
4674
|
// separate from the two app-env calls above, since it has nothing to
|
|
4205
4675
|
// push once `internal_db_url` is absent.
|
|
4206
|
-
const
|
|
4207
|
-
|
|
4676
|
+
const ownerUrl = cmsServer
|
|
4677
|
+
? cmsServer.ownerUrl
|
|
4678
|
+
: (await getDatabaseStarted(this.ports.coolify, dbId)).internal_db_url;
|
|
4679
|
+
if (ownerUrl) {
|
|
4208
4680
|
const dbEnvs = [
|
|
4209
|
-
{ key: 'DATABASE_URL_OWNER', value:
|
|
4681
|
+
{ key: 'DATABASE_URL_OWNER', value: ownerUrl },
|
|
4210
4682
|
];
|
|
4211
|
-
const runtimeUrl = await this.ensureCmsRuntimeUrl(cms.appId,
|
|
4683
|
+
const runtimeUrl = await this.ensureCmsRuntimeUrl(cms.appId, ownerUrl);
|
|
4212
4684
|
if (runtimeUrl)
|
|
4213
4685
|
dbEnvs.push({ key: 'DATABASE_URL', value: runtimeUrl });
|
|
4214
4686
|
await this.ports.coolify.bulkUpsertEnvs(cms.appId, dbEnvs);
|
|
@@ -4400,18 +4872,14 @@ export class FoundryStepExecutor {
|
|
|
4400
4872
|
synapseDb: `${org.slug}-calls-synapse-db`,
|
|
4401
4873
|
masDb: `${org.slug}-calls-mas-db`,
|
|
4402
4874
|
};
|
|
4403
|
-
const
|
|
4404
|
-
context.
|
|
4405
|
-
const
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
getDatabaseStarted(this.ports.coolify, synapseDbId),
|
|
4409
|
-
getDatabaseStarted(this.ports.coolify, masDbId),
|
|
4875
|
+
const synapseHome = await this.ensureHomeDatabase(org, context, 'calls_synapse', CALLS_SYNAPSE_DB_SCOPE_KEY, () => this.ensureCallsDatabase(org, context, projectUuid, environment, names.synapseDb, 'synapse', CALLS_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS));
|
|
4876
|
+
const masHome = await this.ensureHomeDatabase(org, context, 'calls_mas', CALLS_MAS_DB_SCOPE_KEY, () => this.ensureCallsDatabase(org, context, projectUuid, environment, names.masDb, 'mas', CALLS_MAS_DB_SCOPE_KEY));
|
|
4877
|
+
const [synapseDbUrl, masDbUrl] = await Promise.all([
|
|
4878
|
+
this.homeOwnerUrl(synapseHome),
|
|
4879
|
+
this.homeOwnerUrl(masHome),
|
|
4410
4880
|
]);
|
|
4411
|
-
const synapseDb =
|
|
4412
|
-
|
|
4413
|
-
: null;
|
|
4414
|
-
const masDb = masDbRow.internal_db_url ? parseCallsDbUrl(masDbRow.internal_db_url) : null;
|
|
4881
|
+
const synapseDb = synapseDbUrl ? parseCallsDbUrl(synapseDbUrl) : null;
|
|
4882
|
+
const masDb = masDbUrl ? parseCallsDbUrl(masDbUrl) : null;
|
|
4415
4883
|
if (!synapseDb || !masDb) {
|
|
4416
4884
|
// Static config check before the two apps below are ever touched
|
|
4417
4885
|
// (issue #128's "static config checks run before the first Coolify
|
|
@@ -4790,10 +5258,11 @@ export class FoundryStepExecutor {
|
|
|
4790
5258
|
if (action.desired.refreshDatabaseSplit === true) {
|
|
4791
5259
|
const serviceAppId = recorded[CHAT_SERVICE_SCOPE_KEY];
|
|
4792
5260
|
const serviceDbId = recorded[CHAT_SERVICE_DB_SCOPE_KEY];
|
|
4793
|
-
|
|
5261
|
+
const serviceOnServer = this.homeOnServer(org, context, 'chat_service');
|
|
5262
|
+
if (!serviceAppId || (!serviceDbId && !serviceOnServer)) {
|
|
4794
5263
|
throw new Error(`coolify:chat database split for ${org.slug}: the chat service app or its database is not recorded yet`);
|
|
4795
5264
|
}
|
|
4796
|
-
const push = await this.pushDatabaseUrl(serviceAppId, serviceDbId, 'chat');
|
|
5265
|
+
const push = await this.pushDatabaseUrl(serviceAppId, serviceDbId ?? '', 'chat', serviceOnServer ? await this.serverHome(org, context, 'chat_service') : undefined);
|
|
4797
5266
|
const markerKey = serviceDbEnvAppliedScopeKey('chat');
|
|
4798
5267
|
if (!push.pushed) {
|
|
4799
5268
|
// Unlike a fresh provision, this chat was already fully up and
|
|
@@ -4860,22 +5329,17 @@ export class FoundryStepExecutor {
|
|
|
4860
5329
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
4861
5330
|
const server = await this.resolveServer(org, context);
|
|
4862
5331
|
const names = chatInternalNames(org.slug);
|
|
4863
|
-
const
|
|
4864
|
-
context.
|
|
4865
|
-
const
|
|
4866
|
-
|
|
4867
|
-
|
|
4868
|
-
|
|
4869
|
-
|
|
4870
|
-
getDatabaseStarted(this.ports.coolify, synapseDbId),
|
|
4871
|
-
getDatabaseStarted(this.ports.coolify, masDbId),
|
|
4872
|
-
getDatabaseStarted(this.ports.coolify, serviceDbId),
|
|
5332
|
+
const synapseHome = await this.ensureHomeDatabase(org, context, 'chat_synapse', CHAT_SYNAPSE_DB_SCOPE_KEY, () => this.ensureCallsDatabase(org, context, projectUuid, environment, names.synapseDb, 'synapse', CHAT_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS));
|
|
5333
|
+
const masHome = await this.ensureHomeDatabase(org, context, 'chat_mas', CHAT_MAS_DB_SCOPE_KEY, () => this.ensureCallsDatabase(org, context, projectUuid, environment, names.masDb, 'mas', CHAT_MAS_DB_SCOPE_KEY));
|
|
5334
|
+
const serviceHome = await this.ensureHomeDatabase(org, context, 'chat_service', CHAT_SERVICE_DB_SCOPE_KEY, () => this.ensureCallsDatabase(org, context, projectUuid, environment, names.serviceDb, 'chat', CHAT_SERVICE_DB_SCOPE_KEY));
|
|
5335
|
+
const [synapseDbUrl, masDbUrl, serviceDbUrl] = await Promise.all([
|
|
5336
|
+
this.homeOwnerUrl(synapseHome),
|
|
5337
|
+
this.homeOwnerUrl(masHome),
|
|
5338
|
+
this.homeOwnerUrl(serviceHome),
|
|
4873
5339
|
]);
|
|
4874
|
-
const synapseDb =
|
|
4875
|
-
|
|
4876
|
-
|
|
4877
|
-
const masDb = masDbRow.internal_db_url ? parseCallsDbUrl(masDbRow.internal_db_url) : null;
|
|
4878
|
-
if (!synapseDb || !masDb || !serviceDbRow.internal_db_url) {
|
|
5340
|
+
const synapseDb = synapseDbUrl ? parseCallsDbUrl(synapseDbUrl) : null;
|
|
5341
|
+
const masDb = masDbUrl ? parseCallsDbUrl(masDbUrl) : null;
|
|
5342
|
+
if (!synapseDb || !masDb || !serviceDbUrl) {
|
|
4879
5343
|
// Before any app is touched: Coolify can take a moment to report a new
|
|
4880
5344
|
// database's url, and the next apply retries from a clean state.
|
|
4881
5345
|
throw new Error(`coolify:chat for ${org.slug}: a database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
@@ -5004,7 +5468,7 @@ export class FoundryStepExecutor {
|
|
|
5004
5468
|
// `DATABASE_URL` is a minted `chat_rt` role -- `chat` is the literal
|
|
5005
5469
|
// `postgres_db`/`postgres_user` `ensureCallsDatabase` created the service
|
|
5006
5470
|
// database with above, not the Coolify resource name `names.serviceDb`.
|
|
5007
|
-
const serviceDbPush = await this.pushDatabaseUrl(service.appId,
|
|
5471
|
+
const serviceDbPush = await this.pushDatabaseUrl(service.appId, serviceHome.id, 'chat', serviceHome.server ?? undefined);
|
|
5008
5472
|
// A file-storage write alone never reaches a running container. Deploy an
|
|
5009
5473
|
// app that is new, whose config was freshly minted, or that is not
|
|
5010
5474
|
// running now. The last test reads the live status, not what this run
|
|
@@ -5306,10 +5770,8 @@ export class FoundryStepExecutor {
|
|
|
5306
5770
|
const synapseHost = `${org.slug}-calls-synapse`;
|
|
5307
5771
|
const masHost = `${org.slug}-calls-mas`;
|
|
5308
5772
|
const dbName = `${name}-db`;
|
|
5309
|
-
const
|
|
5310
|
-
|
|
5311
|
-
const db = await getDatabaseStarted(this.ports.coolify, dbId);
|
|
5312
|
-
if (!db.internal_db_url) {
|
|
5773
|
+
const serviceHome = await this.ensureHomeDatabase(org, context, 'calls_service', CALLS_SERVICE_DB_SCOPE_KEY, () => this.ensureCallsDatabase(org, context, projectUuid, environment, dbName, dbName.replace(/-/g, '_'), CALLS_SERVICE_DB_SCOPE_KEY));
|
|
5774
|
+
if (!(await this.homeOwnerUrl(serviceHome))) {
|
|
5313
5775
|
throw new Error(`coolify:calls-service for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
5314
5776
|
}
|
|
5315
5777
|
const server = await this.resolveServer(org, context);
|
|
@@ -5393,7 +5855,7 @@ export class FoundryStepExecutor {
|
|
|
5393
5855
|
// `DATABASE_URL` is a minted `<dbName>_rt` role -- the real Postgres
|
|
5394
5856
|
// database name `ensureCallsDatabase` created this database with, not
|
|
5395
5857
|
// the Coolify resource name.
|
|
5396
|
-
const dbPush = await this.pushDatabaseUrl(app.appId,
|
|
5858
|
+
const dbPush = await this.pushDatabaseUrl(app.appId, serviceHome.id, dbName.replace(/-/g, '_'), serviceHome.server ?? undefined);
|
|
5397
5859
|
// #456: a crash between the push above and the deploy/marker below
|
|
5398
5860
|
// leaves the env right and no marker, so this replans and deploys once
|
|
5399
5861
|
// more -- the same rule `coolifyApp`'s own `dbEnvAppliedScopeKey` marker
|
|
@@ -5766,6 +6228,155 @@ export class FoundryStepExecutor {
|
|
|
5766
6228
|
context.recordScopeId(AGORA_ACTIVATED_ENV_SCOPE_KEY, revision);
|
|
5767
6229
|
return { resourceId: app.appId };
|
|
5768
6230
|
}
|
|
6231
|
+
// ---- desktop-auth (#440) -------------------------------------------------
|
|
6232
|
+
/**
|
|
6233
|
+
* `plan/desktop-auth.ts`'s `planDesktopAuth`: Agora's desktop sign-in backend, built from the agora repo's
|
|
6234
|
+
* `apps/desktop-auth`, internal only (Agora proxies `/auth/desktop/*` to `<slug>-desktop-auth:8080`).
|
|
6235
|
+
*
|
|
6236
|
+
* Env: the non-secret values from `desktopAuthEnv`, the owner and runtime URLs of the company server's
|
|
6237
|
+
* `desktop_auth` home (rebuilt from the held passwords on every run; the owner URL is for the migrate that
|
|
6238
|
+
* runs first in the image, which drops it before the server starts), and two secrets generated once. A secret
|
|
6239
|
+
* is minted only when the app's env lacks it, and both go into the one write with the rest, so a reapply or a
|
|
6240
|
+
* crash before the write never leaves two. Neither is delivered anywhere else.
|
|
6241
|
+
*
|
|
6242
|
+
* Deployed once after creation, again when the app is not running, and restarted when only the env changed
|
|
6243
|
+
* (decided from the live app, not from this run). The readiness probe goes through Agora once Agora carries
|
|
6244
|
+
* the upstream; the first apply has none to probe, and the next plan turns the upstream on. The callback URI is
|
|
6245
|
+
* added to Agora's client last, as the union of what the client holds and the callback (never removing one).
|
|
6246
|
+
*/
|
|
6247
|
+
async coolifyDesktopAuth(action, org, context) {
|
|
6248
|
+
const host = action.desired.host;
|
|
6249
|
+
if (!host) {
|
|
6250
|
+
throw new Error(`desktop sign-in for ${org.slug} cannot be configured: the step carries no host values`);
|
|
6251
|
+
}
|
|
6252
|
+
const { inputs, missing } = desktopAuthInputs(org, context.recordedIds(), host);
|
|
6253
|
+
if (!inputs) {
|
|
6254
|
+
throw new Error(`desktop sign-in for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
|
|
6255
|
+
}
|
|
6256
|
+
const home = await this.serverHome(org, context, DESKTOP_AUTH_HOME, { runtime: true });
|
|
6257
|
+
if (!home.runtimeUrl) {
|
|
6258
|
+
throw new Error(`desktop sign-in for ${org.slug}: the runtime URL could not be built`);
|
|
6259
|
+
}
|
|
6260
|
+
const env = desktopAuthEnv(inputs, home.runtimeUser);
|
|
6261
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
6262
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
6263
|
+
const name = desktopAuthInternalName(org);
|
|
6264
|
+
const recordedApp = context.recordedIds()[DESKTOP_AUTH_APP_SCOPE_KEY];
|
|
6265
|
+
let app;
|
|
6266
|
+
if (recordedApp) {
|
|
6267
|
+
app = { appId: recordedApp, existed: true };
|
|
6268
|
+
}
|
|
6269
|
+
else {
|
|
6270
|
+
const server = await this.resolveServer(org, context);
|
|
6271
|
+
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
6272
|
+
app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
6273
|
+
is_preview_deployments_enabled: false,
|
|
6274
|
+
project_uuid: projectUuid,
|
|
6275
|
+
server_uuid: server.uuid,
|
|
6276
|
+
environment_name: environment.name,
|
|
6277
|
+
environment_uuid: environment.uuid,
|
|
6278
|
+
github_app_uuid: githubApp.uuid,
|
|
6279
|
+
git_repository: AGORA_REPO,
|
|
6280
|
+
git_branch: 'main',
|
|
6281
|
+
build_pack: 'dockerfile',
|
|
6282
|
+
name,
|
|
6283
|
+
dockerfile_location: DESKTOP_AUTH_DOCKERFILE,
|
|
6284
|
+
ports_exposes: DESKTOP_AUTH_PORT,
|
|
6285
|
+
autogenerate_domain: false,
|
|
6286
|
+
}), { startIfStopped: false });
|
|
6287
|
+
}
|
|
6288
|
+
context.recordScopeId(DESKTOP_AUTH_APP_SCOPE_KEY, app.appId);
|
|
6289
|
+
const live = app.existed ? await this.ports.coolify.getApplication(app.appId) : null;
|
|
6290
|
+
const portDiffers = live !== null && live.ports_exposes !== DESKTOP_AUTH_PORT;
|
|
6291
|
+
// The image carries its own HEALTHCHECK; Coolify's generated one would replace it.
|
|
6292
|
+
await this.ports.coolify.updateApplication(app.appId, {
|
|
6293
|
+
custom_internal_name: name,
|
|
6294
|
+
health_check_enabled: false,
|
|
6295
|
+
...(portDiffers ? { ports_exposes: DESKTOP_AUTH_PORT } : {}),
|
|
6296
|
+
});
|
|
6297
|
+
const liveRows = (await this.ports.coolify.listEnvs(app.appId)).filter((e) => !e.is_preview);
|
|
6298
|
+
const liveEnv = new Map(liveRows.map((e) => [e.key, e.value]));
|
|
6299
|
+
const secrets = [];
|
|
6300
|
+
if (!liveEnv.has('AUTH_COOKIE_SECRET')) {
|
|
6301
|
+
secrets.push({ key: 'AUTH_COOKIE_SECRET', value: randomBytes(32).toString('base64url') });
|
|
6302
|
+
}
|
|
6303
|
+
if (!liveEnv.has('MESSAGING_HANDOFF_KEY')) {
|
|
6304
|
+
secrets.push({ key: 'MESSAGING_HANDOFF_KEY', value: randomBytes(32).toString('hex') });
|
|
6305
|
+
}
|
|
6306
|
+
for (const secret of secrets)
|
|
6307
|
+
this.handledSecrets.add(secret.value);
|
|
6308
|
+
const desired = {
|
|
6309
|
+
...env,
|
|
6310
|
+
MESSAGING_DATABASE_URL_OWNER: home.ownerUrl,
|
|
6311
|
+
MESSAGING_DATABASE_URL: home.runtimeUrl,
|
|
6312
|
+
};
|
|
6313
|
+
const envChanged = Object.entries(desired).some(([key, value]) => liveEnv.get(key) !== value);
|
|
6314
|
+
await this.ports.coolify.bulkUpsertEnvs(app.appId, [
|
|
6315
|
+
...Object.entries(desired).map(([key, value]) => ({ key, value })),
|
|
6316
|
+
...secrets,
|
|
6317
|
+
]);
|
|
6318
|
+
const recorded = context.recordedIds();
|
|
6319
|
+
const upstreamLive = await this.agoraUpstreamLive(org, recorded);
|
|
6320
|
+
const health = upstreamLive
|
|
6321
|
+
? publicHealthCheck(org, recorded, agoraHostnameFor(org), DESKTOP_AUTH_READY_PATH, {
|
|
6322
|
+
viaAgora: true,
|
|
6323
|
+
})
|
|
6324
|
+
: { kind: 'deployment-only' };
|
|
6325
|
+
const runtime = this.ports.runtime ?? {};
|
|
6326
|
+
const sleep = runtime.sleep ?? defaultSleep;
|
|
6327
|
+
const running = live !== null && isRunningOrStarting(live.status);
|
|
6328
|
+
const label = 'coolify:desktop-auth';
|
|
6329
|
+
const previous = `${org.slug}'s desktop sign-in service stays at its previous deploy`;
|
|
6330
|
+
if (!app.existed || !recorded[DESKTOP_AUTH_DEPLOYED_SCOPE_KEY] || !running || portDiffers) {
|
|
6331
|
+
await waitForNewDeployment(this.ports.coolify, app.appId, health, runtime, sleep, label, previous);
|
|
6332
|
+
context.recordScopeId(DESKTOP_AUTH_DEPLOYED_SCOPE_KEY, '1');
|
|
6333
|
+
}
|
|
6334
|
+
else {
|
|
6335
|
+
if (envChanged)
|
|
6336
|
+
await this.ports.coolify.restart(app.appId);
|
|
6337
|
+
await waitForHealthyVersion(app.appId, health, runtime, sleep, label, previous);
|
|
6338
|
+
}
|
|
6339
|
+
if (upstreamLive)
|
|
6340
|
+
context.recordScopeId(DESKTOP_AUTH_READY_SCOPE_KEY, '1');
|
|
6341
|
+
await this.addDesktopAuthCallback(org, context, desktopAuthCallbackUri(inputs.origin));
|
|
6342
|
+
context.recordScopeId(DESKTOP_AUTH_ORIGIN_SCOPE_KEY, inputs.origin);
|
|
6343
|
+
context.recordScopeId(DESKTOP_AUTH_REVISION_SCOPE_KEY, desktopAuthRevision(env));
|
|
6344
|
+
return { resourceId: app.appId };
|
|
6345
|
+
}
|
|
6346
|
+
/** Whether the org's running Agora carries `AGORA_DESKTOP_AUTH_UPSTREAM`, read from its live env. */
|
|
6347
|
+
async agoraUpstreamLive(org, recorded) {
|
|
6348
|
+
const agoraId = recorded[`coolify:agora:${org.id}`];
|
|
6349
|
+
if (!agoraId)
|
|
6350
|
+
return false;
|
|
6351
|
+
const rows = await this.ports.coolify.listEnvs(agoraId);
|
|
6352
|
+
return rows.some((e) => e.key === 'AGORA_DESKTOP_AUTH_UPSTREAM' && !e.is_preview && e.value);
|
|
6353
|
+
}
|
|
6354
|
+
/**
|
|
6355
|
+
* Adds `callback` to the Agora client's redirect URIs, keeping the ones it has. A providers client older
|
|
6356
|
+
* than 0.12.0 cannot do it: the step records that and the planner reports it blocked, rather than failing.
|
|
6357
|
+
*/
|
|
6358
|
+
async addDesktopAuthCallback(org, context, callback) {
|
|
6359
|
+
const zitadel = this.ports.zitadel;
|
|
6360
|
+
const read = zitadel.getOidcAppRedirectUris?.bind(zitadel);
|
|
6361
|
+
const write = zitadel.updateOidcAppRedirectUris?.bind(zitadel);
|
|
6362
|
+
if (!read || !write) {
|
|
6363
|
+
context.recordScopeId(DESKTOP_AUTH_REDIRECT_SCOPE_KEY, desktopAuthRedirectUnsupported(callback));
|
|
6364
|
+
return;
|
|
6365
|
+
}
|
|
6366
|
+
const recorded = context.recordedIds();
|
|
6367
|
+
const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
6368
|
+
const clientAppId = recorded['zitadel:app:agora'];
|
|
6369
|
+
if (!zitadelOrgId || !clientAppId) {
|
|
6370
|
+
throw new Error(`desktop sign-in for ${org.slug}: the Agora ZITADEL client is not recorded yet -- nothing was changed on it`);
|
|
6371
|
+
}
|
|
6372
|
+
const projectId = await this.ensureZitadelProject(org, zitadelOrgId, context);
|
|
6373
|
+
const at = `desktop sign-in for ${org.slug}`;
|
|
6374
|
+
const current = await withCallTimeout(WORKER_APP_CALL_TIMEOUT_MS, `${at}: read redirect URIs`, () => read(zitadelOrgId, projectId, clientAppId));
|
|
6375
|
+
if (!current.includes(callback)) {
|
|
6376
|
+
await withCallTimeout(WORKER_APP_CALL_TIMEOUT_MS, `${at}: add redirect URI`, () => write(zitadelOrgId, projectId, clientAppId, [...current, callback]));
|
|
6377
|
+
}
|
|
6378
|
+
context.recordScopeId(DESKTOP_AUTH_REDIRECT_SCOPE_KEY, callback);
|
|
6379
|
+
}
|
|
5769
6380
|
/**
|
|
5770
6381
|
* Records whether an app's running env holds `SIGNIN_UPSTREAM` (the app's host while it does, `''` once it
|
|
5771
6382
|
* does not), so the login-host step knows when to point the base URI at the app and when it may give the
|
|
@@ -5850,8 +6461,16 @@ export class FoundryStepExecutor {
|
|
|
5850
6461
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
5851
6462
|
const name = `${org.slug}-boule`;
|
|
5852
6463
|
const dbName = `${org.slug}-boule-db`.replace(/-/g, '_');
|
|
5853
|
-
|
|
5854
|
-
|
|
6464
|
+
// #444: on the company's server there is no Coolify database to make, and the
|
|
6465
|
+
// legacy id stays unset (recording it would mark the home as unmigrated).
|
|
6466
|
+
const bouleServer = this.homeOnServer(org, context, 'boule')
|
|
6467
|
+
? await this.serverHome(org, context, 'boule')
|
|
6468
|
+
: null;
|
|
6469
|
+
const dbId = bouleServer
|
|
6470
|
+
? ''
|
|
6471
|
+
: await this.ensureCallsDatabase(org, context, projectUuid, environment, `${org.slug}-boule-db`, dbName, BOULE_DATABASE_SCOPE_KEY);
|
|
6472
|
+
if (!bouleServer)
|
|
6473
|
+
context.recordScopeId(BOULE_DATABASE_SCOPE_KEY, dbId);
|
|
5855
6474
|
const server = await this.resolveServer(org, context);
|
|
5856
6475
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
5857
6476
|
const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
@@ -5907,6 +6526,12 @@ export class FoundryStepExecutor {
|
|
|
5907
6526
|
this.handledSecrets.add(bridgeSecret);
|
|
5908
6527
|
envs.push({ key: BOULE_BRIDGE_SECRET_ENV, value: bridgeSecret });
|
|
5909
6528
|
}
|
|
6529
|
+
// The owner-seeding door's secret, for every org's Boule: minted once, read back from this env when seeding.
|
|
6530
|
+
const ownerSeedSecret = await this.mintIfAbsent(app.appId, BOULE_OWNER_SEED_SECRET_ENV, 32);
|
|
6531
|
+
if (ownerSeedSecret) {
|
|
6532
|
+
this.handledSecrets.add(ownerSeedSecret);
|
|
6533
|
+
envs.push({ key: BOULE_OWNER_SEED_SECRET_ENV, value: ownerSeedSecret });
|
|
6534
|
+
}
|
|
5910
6535
|
// Held, minted once (C3). Compared with the live value so a changed one restarts the app.
|
|
5911
6536
|
const credentialEnvs = [];
|
|
5912
6537
|
let archonPushedAt = null;
|
|
@@ -5938,13 +6563,14 @@ export class FoundryStepExecutor {
|
|
|
5938
6563
|
const minted = [
|
|
5939
6564
|
peerPassword,
|
|
5940
6565
|
bridgeSecret,
|
|
6566
|
+
ownerSeedSecret,
|
|
5941
6567
|
...credentialEnvs.map((credential) => credential.value),
|
|
5942
6568
|
].filter((v) => v !== null);
|
|
5943
6569
|
if (minted.length === 0)
|
|
5944
6570
|
throw error;
|
|
5945
6571
|
throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), minted));
|
|
5946
6572
|
}
|
|
5947
|
-
const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName);
|
|
6573
|
+
const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName, bouleServer ?? undefined);
|
|
5948
6574
|
if (!dbPush.pushed) {
|
|
5949
6575
|
throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
5950
6576
|
}
|
|
@@ -5975,6 +6601,7 @@ export class FoundryStepExecutor {
|
|
|
5975
6601
|
appKey !== null ||
|
|
5976
6602
|
peerPassword !== null ||
|
|
5977
6603
|
bridgeSecret !== null ||
|
|
6604
|
+
ownerSeedSecret !== null ||
|
|
5978
6605
|
secretsNotRestarted ||
|
|
5979
6606
|
dbPush.changed ||
|
|
5980
6607
|
mail.changed)) {
|