@wtfalch/foundry 0.33.1 → 0.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/bootstrap.d.ts +1 -1
- package/dist/bootstrap/bootstrap.js +49 -11
- package/dist/bootstrap/declare.d.ts +12 -5
- package/dist/bootstrap/declare.js +33 -18
- package/dist/bootstrap/versions.d.ts +24 -0
- package/dist/bootstrap/versions.js +46 -0
- package/dist/calls-sfu.d.ts +10 -0
- package/dist/calls-sfu.js +44 -3
- package/dist/index.d.ts +21 -8
- package/dist/index.js +11 -4
- package/dist/migrations/0051_org_services.sql +13 -0
- package/dist/migrations/0052_platform_mail_relay.sql +8 -0
- package/dist/migrations/0053_heartbeats_provider.sql +9 -0
- package/dist/migrations/0053_org_version_pin.sql +6 -0
- package/dist/org.d.ts +76 -12
- package/dist/org.js +136 -20
- package/dist/plan/backup-gate.d.ts +93 -0
- package/dist/plan/backup-gate.js +261 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +20 -3
- package/dist/plan/build-record.d.ts +56 -0
- package/dist/plan/build-record.js +109 -0
- package/dist/plan/calls-matrix.d.ts +9 -0
- package/dist/plan/calls-matrix.js +18 -1
- package/dist/plan/cloudflared-connector.d.ts +7 -1
- package/dist/plan/cloudflared-connector.js +15 -2
- package/dist/plan/company-backups.d.ts +31 -0
- package/dist/plan/company-backups.js +101 -0
- package/dist/plan/deploy-health.d.ts +12 -2
- package/dist/plan/deploy-health.js +3 -1
- package/dist/plan/destinations.d.ts +73 -0
- package/dist/plan/destinations.js +217 -0
- package/dist/plan/diff.d.ts +46 -8
- package/dist/plan/diff.js +367 -126
- package/dist/plan/edge-access.d.ts +15 -0
- package/dist/plan/edge-access.js +21 -2
- package/dist/plan/executor.d.ts +276 -5
- package/dist/plan/executor.js +1111 -109
- package/dist/plan/health-check-settings.d.ts +28 -0
- package/dist/plan/health-check-settings.js +36 -0
- package/dist/plan/health-gaps.d.ts +33 -0
- package/dist/plan/health-gaps.js +89 -0
- package/dist/plan/health-targets.d.ts +1 -3
- package/dist/plan/health-targets.js +36 -36
- package/dist/plan/held-key-refs.d.ts +15 -0
- package/dist/plan/held-key-refs.js +15 -0
- package/dist/plan/hostnames.d.ts +10 -0
- package/dist/plan/hostnames.js +14 -0
- package/dist/plan/index.d.ts +19 -6
- package/dist/plan/index.js +10 -3
- package/dist/plan/live.d.ts +11 -2
- package/dist/plan/live.js +16 -2
- package/dist/plan/locked-hostnames.d.ts +7 -3
- package/dist/plan/locked-hostnames.js +25 -4
- package/dist/plan/login-host.d.ts +61 -12
- package/dist/plan/login-host.js +99 -11
- package/dist/plan/mail-relay.d.ts +139 -0
- package/dist/plan/mail-relay.js +400 -0
- package/dist/plan/network-move.d.ts +20 -0
- package/dist/plan/network-move.js +41 -0
- package/dist/plan/oikos-ingress.d.ts +111 -0
- package/dist/plan/oikos-ingress.js +231 -0
- package/dist/plan/org-names.d.ts +30 -0
- package/dist/plan/org-names.js +108 -0
- package/dist/plan/own-diff.d.ts +170 -0
- package/dist/plan/own-diff.js +440 -0
- package/dist/plan/own-dns.d.ts +6 -0
- package/dist/plan/own-dns.js +15 -0
- package/dist/plan/own-hostnames.d.ts +27 -0
- package/dist/plan/own-hostnames.js +24 -0
- package/dist/plan/own-limits.d.ts +213 -0
- package/dist/plan/own-limits.js +232 -0
- package/dist/plan/own-services.d.ts +193 -0
- package/dist/plan/own-services.js +169 -0
- package/dist/plan/own-sfu.d.ts +69 -0
- package/dist/plan/own-sfu.js +256 -0
- package/dist/plan/plan-apply.d.ts +121 -10
- package/dist/plan/plan-apply.js +357 -18
- package/dist/plan/platform-diff.d.ts +155 -11
- package/dist/plan/platform-diff.js +271 -66
- package/dist/plan/platform-org.d.ts +107 -4
- package/dist/plan/platform-org.js +175 -3
- package/dist/plan/platform-providers.d.ts +474 -81
- package/dist/plan/platform-providers.js +1407 -560
- package/dist/plan/proxy-labels.d.ts +57 -11
- package/dist/plan/proxy-labels.js +94 -14
- package/dist/plan/run-options.d.ts +19 -0
- package/dist/plan/run-options.js +46 -0
- package/dist/plan/sign-in-org.d.ts +1 -1
- package/dist/plan/sign-in-org.js +20 -2
- package/dist/plan/zitadel-login-policy.d.ts +1 -1
- package/dist/plan/zitadel-login-policy.js +77 -9
- package/dist/plan/zitadel-system-api.d.ts +22 -1
- package/dist/plan/zitadel-system-api.js +49 -8
- package/dist/platform.d.ts +40 -0
- package/dist/platform.js +16 -0
- package/dist/provision.d.ts +2 -0
- package/dist/provision.js +3 -1
- package/dist/slug.d.ts +3 -1
- package/dist/slug.js +26 -3
- package/dist/store/postgres.js +93 -2
- package/dist/store/schema.d.ts +87 -0
- package/dist/store/schema.js +9 -0
- package/package.json +3 -3
- package/versions.digests.json +11 -0
- package/versions.json +3 -1
package/dist/plan/executor.js
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
3
|
import { URL } from 'node:url';
|
|
4
|
+
import { pinnedTagFor } from '../bootstrap/versions.js';
|
|
3
5
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
4
6
|
import { resolveCallsSfuUrls } from '../calls-sfu.js';
|
|
5
|
-
import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
|
|
7
|
+
import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
|
|
8
|
+
import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
|
|
6
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';
|
|
7
10
|
import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
8
11
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
9
|
-
import {
|
|
12
|
+
import { recordBuiltCommit } from './build-record.js';
|
|
13
|
+
import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
10
14
|
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';
|
|
11
15
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
16
|
+
import { BACKUP_SCOPE_PREFIX, backupScopeKey } from './company-backups.js';
|
|
12
17
|
import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
|
|
13
18
|
import { waitForNewDeployment, } from './deploy-health.js';
|
|
19
|
+
import { PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, onPlatformNetwork, placed, placedByScope, } from './destinations.js';
|
|
14
20
|
import { DEFAULT_AI_LIMIT_MICROS } from './diff.js';
|
|
15
21
|
import { createEdgeAccess, isNotFound as isEdgeNotFound, } from './edge-access.js';
|
|
16
22
|
import { EDGE_DNS_WRITE_CONCURRENCY, createEdgeDns, limiter, } from './edge-dns.js';
|
|
@@ -21,13 +27,22 @@ import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScope
|
|
|
21
27
|
import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, edgeKeyAppRecordKey, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
|
|
22
28
|
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';
|
|
23
29
|
import { GATE_CLAIM_NAME, gateHostnamesScopeKey, gateIdRecordKey, isBehindGate } from './gates.js';
|
|
24
|
-
import { healthCheckSettings } from './health-check-settings.js';
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
30
|
+
import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, wgetProbe, } from './health-check-settings.js';
|
|
31
|
+
import { PROXY_LABEL_TOKEN_REF } from './held-key-refs.js';
|
|
32
|
+
import { appHostname, cmsStudioHostnameFor, coolifyTunnelDomains, entryHostnames, siteHostnameFor, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
33
|
+
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';
|
|
27
34
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
35
|
+
import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_SCOPE_KEY, isOwnMailSmtpPort, normalizeMailTarget, } from './mail-relay.js';
|
|
36
|
+
import { NETWORK_APPLIED_SCOPE_KEY } from './network-move.js';
|
|
37
|
+
import { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, } from './own-diff.js';
|
|
38
|
+
import { NO_DOMAIN_ZONE, ownHostnames } from './own-hostnames.js';
|
|
39
|
+
import { LIMITS, limited } from './own-limits.js';
|
|
40
|
+
import { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, OwnServicesBlockedError, orgNetwork, orgTarget, scopeFromOrg, } from './own-services.js';
|
|
41
|
+
import { ownSfuUrls, provisionSfuApp } from './own-sfu.js';
|
|
42
|
+
import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
|
|
28
43
|
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';
|
|
29
|
-
import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, } from './platform-providers.js';
|
|
30
|
-
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
|
|
44
|
+
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';
|
|
45
|
+
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';
|
|
31
46
|
import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
|
|
32
47
|
import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
|
|
33
48
|
/**
|
|
@@ -113,14 +128,13 @@ function normalizedLoginBaseUri(uri) {
|
|
|
113
128
|
* `cms.<domain>` -- the cms studio's own hostname (`cmsSite` below), apart
|
|
114
129
|
* from the public site itself at `org.domain` (used directly there). Called
|
|
115
130
|
* from `cmsSite` and from `zitadelApp` for the fixed `cms` client (#139),
|
|
116
|
-
* both of which only run once `org.site.enabled` is true
|
|
117
|
-
*
|
|
118
|
-
* {@link tunnelIngressHostnames}'s own doc comment cites.
|
|
131
|
+
* both of which only run once `org.site.enabled` is true. A company without
|
|
132
|
+
* a domain has its studio at `<slug>-cms.oikos.host` (`cmsStudioHostnameFor`).
|
|
119
133
|
*/
|
|
120
134
|
// #541: exported so `health-targets.ts` reuses this hostname rule rather
|
|
121
135
|
// than restating it; nothing else about this function changes.
|
|
122
136
|
export function cmsHostnameFor(org) {
|
|
123
|
-
return
|
|
137
|
+
return cmsStudioHostnameFor(org);
|
|
124
138
|
}
|
|
125
139
|
/**
|
|
126
140
|
* #145's "Decided" (2026-09-27): the calls service's own public hostname.
|
|
@@ -158,8 +172,11 @@ export function healthUrls(org) {
|
|
|
158
172
|
];
|
|
159
173
|
});
|
|
160
174
|
}
|
|
161
|
-
if (org.site.enabled
|
|
162
|
-
return [
|
|
175
|
+
if (org.site.enabled) {
|
|
176
|
+
return [
|
|
177
|
+
`https://${siteHostnameFor(org)}/api/health`,
|
|
178
|
+
`https://${cmsHostnameFor(org)}/api/health`,
|
|
179
|
+
];
|
|
163
180
|
}
|
|
164
181
|
return [];
|
|
165
182
|
}
|
|
@@ -186,7 +203,7 @@ function mailOrgFor(org, usesDomain, mode) {
|
|
|
186
203
|
* https://github.com/wtfalch/foundry/issues/71) names this same constant and
|
|
187
204
|
* value.
|
|
188
205
|
*/
|
|
189
|
-
const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
|
|
206
|
+
export const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
|
|
190
207
|
/** The one Access policy foundry puts on a platform Access app (#84). */
|
|
191
208
|
export const ACCESS_POLICY_NAME = 'foundry: allowed emails';
|
|
192
209
|
/**
|
|
@@ -300,7 +317,13 @@ const BOULE_PORT = 3000;
|
|
|
300
317
|
* it from this file, the same way `hostnameFor`'s hostname rule is
|
|
301
318
|
* restated rather than shared across the two files.
|
|
302
319
|
*/
|
|
303
|
-
|
|
320
|
+
/** Four dotted decimal numbers from 0 to 255, nothing else (the value goes into `ports_mappings`). */
|
|
321
|
+
function isIpv4Address(value) {
|
|
322
|
+
const parts = value.split('.');
|
|
323
|
+
return (parts.length === 4 &&
|
|
324
|
+
parts.every((part) => /^(0|[1-9][0-9]{0,2})$/.test(part) && Number(part) <= 255));
|
|
325
|
+
}
|
|
326
|
+
export const LOG_DIR = '/app/logs';
|
|
304
327
|
/**
|
|
305
328
|
* `cmsSite` provisions two applications from one `cms:site` step, but
|
|
306
329
|
* `execute`'s own return value auto-records only one `resourceId`, under
|
|
@@ -318,6 +341,8 @@ const CMS_APP_SCOPE_KEY = 'coolify_cms_app';
|
|
|
318
341
|
const SITE_APP_SCOPE_KEY = 'coolify_site_app';
|
|
319
342
|
/** #445: the one Coolify server every resource of this org lands on, chosen once and recorded. */
|
|
320
343
|
const COOLIFY_SERVER_SCOPE_KEY = 'coolify_server';
|
|
344
|
+
/** The company's Coolify destination (its Docker network `co-<slug>`), recorded when its first resource is created. */
|
|
345
|
+
export const COOLIFY_DESTINATION_SCOPE_KEY = 'coolify_destination';
|
|
321
346
|
/**
|
|
322
347
|
* #117: the cms app's own Postgres database (`<org.slug>-cms-db`), found or
|
|
323
348
|
* created inside `cmsSite` the same way `coolifyDatabase` does for the org's
|
|
@@ -600,8 +625,8 @@ const CHAT_SERVICE_DB_SCOPE_KEY = 'coolify_chat_service_database';
|
|
|
600
625
|
*/
|
|
601
626
|
const CHAT_REKEY_SYNAPSE_SCOPE_KEY = 'coolify_chat_rekey_synapse';
|
|
602
627
|
const CHAT_REKEY_MAS_SCOPE_KEY = 'coolify_chat_rekey_mas';
|
|
603
|
-
const CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY = 'coolify_chat_rekey_synapse_database';
|
|
604
|
-
const CHAT_REKEY_MAS_DB_SCOPE_KEY = 'coolify_chat_rekey_mas_database';
|
|
628
|
+
export const CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY = 'coolify_chat_rekey_synapse_database';
|
|
629
|
+
export const CHAT_REKEY_MAS_DB_SCOPE_KEY = 'coolify_chat_rekey_mas_database';
|
|
605
630
|
/**
|
|
606
631
|
* Set once {@link ChatRekeyPort.copyHomeserver} returns, so a retried or
|
|
607
632
|
* repeated apply never copies the same history twice -- the copy itself is
|
|
@@ -633,11 +658,6 @@ const CHAT_SECRET_ENV_KEYS = {
|
|
|
633
658
|
masRsaKeyPem: 'CHAT_MATRIX_MAS_RSA_KEY_PEM',
|
|
634
659
|
masEcKeyPem: 'CHAT_MATRIX_MAS_EC_KEY_PEM',
|
|
635
660
|
};
|
|
636
|
-
/** `FROM ${MAS_IMAGE}` plus the entry command its published image needs (chat's own `MAS_DOCKERFILE`, same reason: no arguments by default). */
|
|
637
|
-
const CALLS_MAS_DOCKERFILE = [
|
|
638
|
-
`FROM ${MAS_IMAGE}`,
|
|
639
|
-
'CMD ["server", "--config", "/config/config.yaml"]',
|
|
640
|
-
].join('\n');
|
|
641
661
|
/**
|
|
642
662
|
* `coolifyCallsService`'s own database (#145) -- no `system:kind:key` slot
|
|
643
663
|
* of its own, the same reason {@link CALLS_SYNAPSE_SCOPE_KEY}'s doc comment
|
|
@@ -665,6 +685,12 @@ const CALLS_HEALTH_CHECK_PATH = '/healthz';
|
|
|
665
685
|
* {@link CALLS_HEALTH_CHECK_PATH} above.
|
|
666
686
|
*/
|
|
667
687
|
const CHAT_SERVICE_HEALTH_CHECK_PATH = '/_chat/health';
|
|
688
|
+
/**
|
|
689
|
+
* Synapse's start period for its Coolify health check. It runs its database
|
|
690
|
+
* migrations before it listens, which is slower than the default start period
|
|
691
|
+
* of {@link healthCheckSettings} allows, and the image's own check waits 5 s.
|
|
692
|
+
*/
|
|
693
|
+
const SYNAPSE_HEALTH_CHECK_START_PERIOD_SECONDS = 120;
|
|
668
694
|
/**
|
|
669
695
|
* Whether an application's own Coolify `status` (`` `running:healthy`,
|
|
670
696
|
* `exited:unhealthy` `` -- `@wtfalch/providers`' own doc comment) means it is
|
|
@@ -755,8 +781,85 @@ export const databaseStartWait = {
|
|
|
755
781
|
// ---------------------------------------------------------------------------
|
|
756
782
|
// Executor
|
|
757
783
|
// ---------------------------------------------------------------------------
|
|
784
|
+
/** How long the host's bucket port may take to make or find a bucket. */
|
|
785
|
+
const BUCKET_TIMEOUT_MS = 60_000;
|
|
786
|
+
/** How long the keys worker may take to mint an own files host's credential. */
|
|
787
|
+
const KEYS_MINT_TIMEOUT_MS = 15_000;
|
|
788
|
+
/** How long one call to the R2 key port or the files route that stores the key may take. */
|
|
789
|
+
const R2_KEY_TIMEOUT_MS = 30_000;
|
|
790
|
+
/** Clears a recorded id; a context that cannot forget one records it empty, which reads as unset. */
|
|
791
|
+
function forgetScopeId(context, key) {
|
|
792
|
+
if (context.forgetId)
|
|
793
|
+
context.forgetId(key);
|
|
794
|
+
else
|
|
795
|
+
context.recordScopeId(key, '');
|
|
796
|
+
}
|
|
797
|
+
async function withTimeout(work, ms) {
|
|
798
|
+
let timer;
|
|
799
|
+
try {
|
|
800
|
+
return await Promise.race([
|
|
801
|
+
work,
|
|
802
|
+
new Promise((_, reject) => {
|
|
803
|
+
timer = setTimeout(() => reject(new Error('timeout')), ms);
|
|
804
|
+
}),
|
|
805
|
+
]);
|
|
806
|
+
}
|
|
807
|
+
finally {
|
|
808
|
+
clearTimeout(timer);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
/** How long one Lettermint call of an own company's sign-in mint may take. */
|
|
812
|
+
const LETTERMINT_TIMEOUT_MS = 60_000;
|
|
813
|
+
const OWN_SERVICES_PORT_ABSENT = 'this org runs its own services and the host has no ownServices port for it';
|
|
814
|
+
/** A port that refuses every call, naming the held key the org's service waits for. */
|
|
815
|
+
function blockedPort(service, ref) {
|
|
816
|
+
return new Proxy({}, {
|
|
817
|
+
get: () => () => {
|
|
818
|
+
throw new OwnServicesBlockedError(service, ref);
|
|
819
|
+
},
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
/** The network a company's resources are made on: the platform's for its owner, `co-<slug>` for the rest. */
|
|
823
|
+
function appliedNetworkOf(org, context) {
|
|
824
|
+
return onPlatformNetwork(context.ownerCompany) ? PLATFORM_NETWORK : companyNetworkName(org.slug);
|
|
825
|
+
}
|
|
826
|
+
/** The limit profile of the step now running; `limited` reads it on every Coolify call. */
|
|
827
|
+
const limitScope = new AsyncLocalStorage();
|
|
828
|
+
/** The company and run of the step now running; `placedByScope` reads it on every create. */
|
|
829
|
+
const companyScope = new AsyncLocalStorage();
|
|
830
|
+
/**
|
|
831
|
+
* The limit profile of a step on `this.ports.coolify`. Own-services steps
|
|
832
|
+
* (`coolify:own-*`) wrap their own ports with their own row of `LIMITS`.
|
|
833
|
+
*/
|
|
834
|
+
function limitProfileOf(action) {
|
|
835
|
+
if (action.kind.startsWith('own-'))
|
|
836
|
+
return undefined;
|
|
837
|
+
switch (`${action.system}:${action.kind}`) {
|
|
838
|
+
case 'coolify:app':
|
|
839
|
+
case 'coolify:database':
|
|
840
|
+
return LIMITS.companyApp;
|
|
841
|
+
case 'coolify:broker-entry':
|
|
842
|
+
return LIMITS.brokerEntry;
|
|
843
|
+
case 'cms:site':
|
|
844
|
+
return LIMITS.cms;
|
|
845
|
+
case 'coolify:chat':
|
|
846
|
+
return LIMITS.chat;
|
|
847
|
+
case 'coolify:calls':
|
|
848
|
+
case 'coolify:calls-service':
|
|
849
|
+
case 'coolify:calls-chat':
|
|
850
|
+
return LIMITS.calls;
|
|
851
|
+
case 'coolify:agora':
|
|
852
|
+
return LIMITS.agora;
|
|
853
|
+
case 'coolify:boule':
|
|
854
|
+
case 'coolify:boule-broker-entry':
|
|
855
|
+
return LIMITS.boule;
|
|
856
|
+
case 'cloudflare:tunnel':
|
|
857
|
+
return LIMITS.connector;
|
|
858
|
+
default:
|
|
859
|
+
return action.system === 'coolify' ? LIMITS.companyOther : undefined;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
758
862
|
export class FoundryStepExecutor {
|
|
759
|
-
ports;
|
|
760
863
|
edgeDns;
|
|
761
864
|
edgeTunnels;
|
|
762
865
|
edgeAccess;
|
|
@@ -765,12 +868,132 @@ export class FoundryStepExecutor {
|
|
|
765
868
|
edgeWriteSlot;
|
|
766
869
|
/** Secrets this executor minted or was handed; see `StepExecutor.secretsHandled`. */
|
|
767
870
|
handledSecrets = new Set();
|
|
871
|
+
ports;
|
|
872
|
+
get rawCoolify() {
|
|
873
|
+
return this.ports.coolify;
|
|
874
|
+
}
|
|
768
875
|
constructor(ports) {
|
|
769
|
-
this.
|
|
876
|
+
const destinationOf = (coolify, step, serverUuid) => this.companyDestination(coolify, step.org, step.context, serverUuid);
|
|
877
|
+
// A live view of `ports` whose Coolify port is the limited one; a port set later is seen.
|
|
878
|
+
const limitedPort = new WeakMap();
|
|
879
|
+
this.ports = new Proxy(ports, {
|
|
880
|
+
get(target, prop) {
|
|
881
|
+
const value = Reflect.get(target, prop, target);
|
|
882
|
+
if (prop !== 'coolify' || !value)
|
|
883
|
+
return value;
|
|
884
|
+
const coolify = value;
|
|
885
|
+
let made = limitedPort.get(coolify);
|
|
886
|
+
if (!made) {
|
|
887
|
+
const placedPort = placedByScope(coolify, () => {
|
|
888
|
+
const step = companyScope.getStore();
|
|
889
|
+
if (!step)
|
|
890
|
+
return undefined;
|
|
891
|
+
return {
|
|
892
|
+
key: step.org.slug,
|
|
893
|
+
resolve: (serverUuid) => destinationOf(coolify, step, serverUuid),
|
|
894
|
+
};
|
|
895
|
+
});
|
|
896
|
+
made = limited(placedPort, () => limitScope.getStore());
|
|
897
|
+
limitedPort.set(coolify, made);
|
|
898
|
+
}
|
|
899
|
+
return made;
|
|
900
|
+
},
|
|
901
|
+
});
|
|
770
902
|
}
|
|
771
903
|
secretsHandled() {
|
|
772
904
|
return [...this.handledSecrets];
|
|
773
905
|
}
|
|
906
|
+
/**
|
|
907
|
+
* An own org's step runs on a copy of this executor whose ZITADEL, mail,
|
|
908
|
+
* broker, files, jobs and calls ports are the org's own. A port the org has
|
|
909
|
+
* not got yet is absent or throws, never the shared one.
|
|
910
|
+
*/
|
|
911
|
+
async executeScoped(action, context) {
|
|
912
|
+
const own = this.ports.ownServices;
|
|
913
|
+
if (!own)
|
|
914
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
915
|
+
const scoped = await own.portsFor(context.org, context.recordedIds());
|
|
916
|
+
if (!scoped)
|
|
917
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
918
|
+
const refs = orgTarget(context.org).refs;
|
|
919
|
+
const copy = new FoundryStepExecutor({
|
|
920
|
+
...this.ports,
|
|
921
|
+
ownServices: undefined,
|
|
922
|
+
zitadel: scoped.zitadel ?? blockedPort('zitadel', refs.instanceBearer),
|
|
923
|
+
mail: scoped.mail ?? blockedPort('mail', refs.mailAdminKey),
|
|
924
|
+
callsSfu: scoped.callsSfu ?? blockedPort('calls', refs.livekitKeys),
|
|
925
|
+
broker: scoped.broker,
|
|
926
|
+
filesOrganisation: scoped.filesOrganisation ??
|
|
927
|
+
blockedPort('files', refs.filesManagement),
|
|
928
|
+
jobsScheduler: scoped.jobsScheduler ?? blockedPort('jobs', refs.jobsAdminToken),
|
|
929
|
+
callsSfuUrls: ownSfuUrls(context.recordedIds()),
|
|
930
|
+
});
|
|
931
|
+
try {
|
|
932
|
+
return await copy.run(action, context);
|
|
933
|
+
}
|
|
934
|
+
finally {
|
|
935
|
+
for (const secret of copy.secretsHandled())
|
|
936
|
+
this.handledSecrets.add(secret);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
/** Whether `ensureOwnAuthMailToken` can mint: the same two inputs it blocks on. */
|
|
940
|
+
ownMailKeyMintable() {
|
|
941
|
+
return Boolean(this.ports.lettermint && this.ports.ownServices?.provision.auth.heldKeys);
|
|
942
|
+
}
|
|
943
|
+
/** What the executor's own files dependencies supply, for the planner's blocked lines. */
|
|
944
|
+
ownFilesDeps() {
|
|
945
|
+
const files = this.ports.ownServices?.provision.files;
|
|
946
|
+
if (!files)
|
|
947
|
+
return { keys: false, bucket: false };
|
|
948
|
+
return {
|
|
949
|
+
keys: Boolean(files.keysMint),
|
|
950
|
+
bucket: Boolean(files.r2Buckets),
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
/** Which ports the own files storage key step needs this executor has, for the planner's blocked line. */
|
|
954
|
+
async ownFilesR2Ports(org, recordedIds) {
|
|
955
|
+
const r2Keys = Boolean(this.ports.ownServices?.provision.files.r2Keys);
|
|
956
|
+
// Only an own org whose files host is applied is asked for its scoped ports.
|
|
957
|
+
if (!ownsServices(org) || !recordedIds[`coolify:own-files:${org.id}`]) {
|
|
958
|
+
return { r2Keys, storeR2Credentials: false };
|
|
959
|
+
}
|
|
960
|
+
try {
|
|
961
|
+
const scoped = await this.ports.ownServices?.portsFor(org, recordedIds);
|
|
962
|
+
return {
|
|
963
|
+
r2Keys,
|
|
964
|
+
storeR2Credentials: typeof scoped?.filesOrganisation?.storeR2Credentials === 'function',
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
catch (e) {
|
|
968
|
+
if (e instanceof OwnServicesBlockedError)
|
|
969
|
+
return { r2Keys, storeR2Credentials: false };
|
|
970
|
+
throw e;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
/** Whether the host's scoped ports for this own org include a files-organisation port. A held key still missing counts as no port. */
|
|
974
|
+
async ownFilesOrganisationPort(org, recordedIds) {
|
|
975
|
+
try {
|
|
976
|
+
const scoped = await this.ports.ownServices?.portsFor(org, recordedIds);
|
|
977
|
+
return Boolean(scoped?.filesOrganisation);
|
|
978
|
+
}
|
|
979
|
+
catch (e) {
|
|
980
|
+
if (e instanceof OwnServicesBlockedError)
|
|
981
|
+
return false;
|
|
982
|
+
throw e;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
/** Whether the host's scoped ports for this own org include a broker port: the company's own broker, which its broker entries are written to. */
|
|
986
|
+
async ownBrokerPort(org, recordedIds) {
|
|
987
|
+
try {
|
|
988
|
+
const scoped = await this.ports.ownServices?.portsFor(org, recordedIds);
|
|
989
|
+
return Boolean(scoped?.broker);
|
|
990
|
+
}
|
|
991
|
+
catch (e) {
|
|
992
|
+
if (e instanceof OwnServicesBlockedError)
|
|
993
|
+
return false;
|
|
994
|
+
throw e;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
774
997
|
/** #555: the edge-routed ports this executor was built with itself (they win over edge). */
|
|
775
998
|
ownEdgePorts() {
|
|
776
999
|
return {
|
|
@@ -782,6 +1005,49 @@ export class FoundryStepExecutor {
|
|
|
782
1005
|
};
|
|
783
1006
|
}
|
|
784
1007
|
async execute(action, context) {
|
|
1008
|
+
const { org } = context;
|
|
1009
|
+
if (ownsServices(org) && !action.kind.startsWith('own-')) {
|
|
1010
|
+
return this.executeScoped(action, context);
|
|
1011
|
+
}
|
|
1012
|
+
return this.run(action, context);
|
|
1013
|
+
}
|
|
1014
|
+
async run(action, context) {
|
|
1015
|
+
let profile = limitProfileOf(action);
|
|
1016
|
+
// An entry that declares `capabilities: 'standard'` is the fallback from tier B to tier A.
|
|
1017
|
+
if (profile === LIMITS.companyApp && action.kind === 'app') {
|
|
1018
|
+
const entry = context.org.hosting.entries.find((e) => appStepKey(context.org, e.key) === action.key);
|
|
1019
|
+
if (entry?.capabilities === 'standard')
|
|
1020
|
+
profile = LIMITS.companyAppStandard;
|
|
1021
|
+
}
|
|
1022
|
+
const dispatch = () => this.dispatch(action, context);
|
|
1023
|
+
return companyScope.run({ org: context.org, context }, () => profile ? limitScope.run(profile, dispatch) : dispatch());
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* The company's destination on `serverUuid`: its Docker network `co-<slug>`, made when absent and
|
|
1027
|
+
* recorded as {@link COOLIFY_DESTINATION_SCOPE_KEY}. Called before the first create of a step;
|
|
1028
|
+
* a server that already holds too many networks fails here (`DestinationCapacityError`), before
|
|
1029
|
+
* the destination or the resource exists. A crash after the destination is made and before it is
|
|
1030
|
+
* recorded leaves a destination the next run finds by its network name, so a re-run converges.
|
|
1031
|
+
* Each Coolify call has a 60 second timeout (`destinations.ts`).
|
|
1032
|
+
*/
|
|
1033
|
+
async companyDestination(coolify, org, context, serverUuid) {
|
|
1034
|
+
// The platform's own company stays on the platform network: Archon reads its database by name.
|
|
1035
|
+
if (onPlatformNetwork(context.ownerCompany)) {
|
|
1036
|
+
return findDestination(coolify, serverUuid, PLATFORM_NETWORK);
|
|
1037
|
+
}
|
|
1038
|
+
const uuid = await ensureDestination(coolify, serverUuid, companyNetworkName(org.slug));
|
|
1039
|
+
if (context.recordedIds()[COOLIFY_DESTINATION_SCOPE_KEY] !== uuid) {
|
|
1040
|
+
context.recordScopeId(COOLIFY_DESTINATION_SCOPE_KEY, uuid);
|
|
1041
|
+
}
|
|
1042
|
+
return uuid;
|
|
1043
|
+
}
|
|
1044
|
+
/** `coolify` for an own-services step: its creates go to the company's destination. */
|
|
1045
|
+
placedForCompany(coolify, org, context) {
|
|
1046
|
+
const own = coolify;
|
|
1047
|
+
const listing = typeof own.raw === 'function' ? own : this.rawCoolify;
|
|
1048
|
+
return placed(coolify, (serverUuid) => this.companyDestination(listing, org, context, serverUuid));
|
|
1049
|
+
}
|
|
1050
|
+
async dispatch(action, context) {
|
|
785
1051
|
const { org } = context;
|
|
786
1052
|
switch (`${action.system}:${action.kind}`) {
|
|
787
1053
|
case 'zitadel:issuer':
|
|
@@ -802,10 +1068,14 @@ export class FoundryStepExecutor {
|
|
|
802
1068
|
return this.zitadelCustomers(org, context);
|
|
803
1069
|
case 'coolify:app':
|
|
804
1070
|
return this.coolifyApp(action, org, context);
|
|
1071
|
+
case 'coolify:network':
|
|
1072
|
+
return this.coolifyNetwork(action, org, context);
|
|
805
1073
|
case 'coolify:broker-entry':
|
|
806
1074
|
return this.coolifyBrokerEntry(action, org, context);
|
|
807
1075
|
case 'coolify:database':
|
|
808
1076
|
return this.coolifyDatabase(action, org, context);
|
|
1077
|
+
case 'coolify:backup':
|
|
1078
|
+
return this.coolifyBackup(action, org, context);
|
|
809
1079
|
case 'coolify:calls':
|
|
810
1080
|
return this.coolifyCalls(action, org, context);
|
|
811
1081
|
case 'coolify:calls-service':
|
|
@@ -860,6 +1130,18 @@ export class FoundryStepExecutor {
|
|
|
860
1130
|
return this.bouleBrokerEntry(org, context);
|
|
861
1131
|
case 'jobs:registration':
|
|
862
1132
|
return this.jobsRegistration(action, org, context);
|
|
1133
|
+
case 'coolify:own-auth':
|
|
1134
|
+
return this.ownAuth(action, org, context);
|
|
1135
|
+
case 'coolify:own-jobs':
|
|
1136
|
+
return this.ownJobs(action, org, context);
|
|
1137
|
+
case 'coolify:own-mail':
|
|
1138
|
+
return this.ownMail(action, org, context);
|
|
1139
|
+
case 'coolify:own-sfu':
|
|
1140
|
+
return this.ownSfu(action, org, context);
|
|
1141
|
+
case 'coolify:own-files':
|
|
1142
|
+
return this.ownFiles(action, org, context);
|
|
1143
|
+
case 'coolify:own-files-r2':
|
|
1144
|
+
return this.ownFilesR2(org, context);
|
|
863
1145
|
default:
|
|
864
1146
|
throw new Error(`FoundryStepExecutor has no dispatch for ${action.system}:${action.kind}`);
|
|
865
1147
|
}
|
|
@@ -1779,10 +2061,9 @@ export class FoundryStepExecutor {
|
|
|
1779
2061
|
return { resourceId: created.appId };
|
|
1780
2062
|
}
|
|
1781
2063
|
// #139: the fixed `cms` client (`diff.ts`'s `planCmsClient`) signs
|
|
1782
|
-
// people into the cms studio at `cms.<domain>`,
|
|
1783
|
-
//
|
|
1784
|
-
//
|
|
1785
|
-
// doc comment relies on. #164: the fixed `agora` client (`diff.ts`'s
|
|
2064
|
+
// people into the cms studio at `cmsHostnameFor(org)` -- `cms.<domain>`,
|
|
2065
|
+
// or `<slug>-cms.oikos.host` for a company without a domain -- not the
|
|
2066
|
+
// org's own app hostname. #164: the fixed `agora` client (`diff.ts`'s
|
|
1786
2067
|
// `planAgoraClient`) is the same shape, always -- there is no
|
|
1787
2068
|
// `org.site.enabled`-style guard to check, since Agora is always on.
|
|
1788
2069
|
// The Agora client stays `appType: 'web'`: providers' `createOidcApp`
|
|
@@ -1881,12 +2162,110 @@ export class FoundryStepExecutor {
|
|
|
1881
2162
|
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`);
|
|
1882
2163
|
}
|
|
1883
2164
|
}
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
:
|
|
2165
|
+
if (!found) {
|
|
2166
|
+
// A project foundry makes now is empty and on the company's own network. Record both, and persist
|
|
2167
|
+
// them, before any resource is made in it: a crash later must never leave a project with
|
|
2168
|
+
// resources and no marker, which the next plan would move (delete) over what was just built.
|
|
2169
|
+
const created = await this.ports.coolify.createProject(org.slug, coolifyProjectMark(org));
|
|
2170
|
+
context.recordScopeId(NETWORK_APPLIED_SCOPE_KEY, appliedNetworkOf(org, context));
|
|
2171
|
+
context.recordScopeId('coolify_project', created.uuid);
|
|
2172
|
+
await context.persistScopeIds();
|
|
2173
|
+
return created.uuid;
|
|
2174
|
+
}
|
|
2175
|
+
const projectUuid = found.uuid;
|
|
2176
|
+
// A project that holds nothing but its environment starts on the company's own network; one that
|
|
2177
|
+
// holds resources was built on the shared network and is moved by `coolify:network`
|
|
2178
|
+
// (`plan/network-move.ts`).
|
|
2179
|
+
const holds = await this.ports.coolify.listResources?.(found.uuid);
|
|
2180
|
+
if (holds?.every((r) => r.kind === 'environment')) {
|
|
2181
|
+
context.recordScopeId(NETWORK_APPLIED_SCOPE_KEY, appliedNetworkOf(org, context));
|
|
2182
|
+
}
|
|
1887
2183
|
context.recordScopeId('coolify_project', projectUuid);
|
|
1888
2184
|
return projectUuid;
|
|
1889
2185
|
}
|
|
2186
|
+
/**
|
|
2187
|
+
* `plan/network-move.ts`'s `planNetworkMove`: a company built on the shared network is moved to its
|
|
2188
|
+
* own. Coolify cannot move a resource in production, so every application and database in the
|
|
2189
|
+
* company's project that foundry did not adopt is deleted, applications first, and the ids recorded
|
|
2190
|
+
* for them are forgotten; the next apply builds them again on `co-<slug>`. Apply has already asked
|
|
2191
|
+
* for the typed company name and checked a fresh backup of every database. A failure part way
|
|
2192
|
+
* leaves what is left recorded and the marker unset, so the same step is planned again and goes on
|
|
2193
|
+
* from there (a 404 counts as deleted). The marker is recorded last. No new timeout: each delete
|
|
2194
|
+
* has the Coolify port's own.
|
|
2195
|
+
*/
|
|
2196
|
+
async coolifyNetwork(action, org, context) {
|
|
2197
|
+
const at = `coolify:network for ${org.slug}`;
|
|
2198
|
+
const projectUuid = context.recordedIds().coolify_project;
|
|
2199
|
+
if (!projectUuid)
|
|
2200
|
+
throw new Error(`${at}: no Coolify project is recorded, nothing to move`);
|
|
2201
|
+
const network = companyNetworkName(org.slug);
|
|
2202
|
+
if (action.desired.network !== network) {
|
|
2203
|
+
throw new Error(`${at}: the plan names another network; plan this org again`);
|
|
2204
|
+
}
|
|
2205
|
+
const list = this.ports.coolify.listResources;
|
|
2206
|
+
if (!list) {
|
|
2207
|
+
throw new Error(`${at}: this Coolify port cannot list a project's resources, so the move cannot tell what to delete`);
|
|
2208
|
+
}
|
|
2209
|
+
// Fail closed: an adopted record that cannot be read could name a resource foundry must keep.
|
|
2210
|
+
const unreadable = adoptedKeysUnreadable(context.recordedIds());
|
|
2211
|
+
if (unreadable.length > 0) {
|
|
2212
|
+
throw new Error(`${at}: an adopted record cannot be read, so the move cannot tell what to keep`);
|
|
2213
|
+
}
|
|
2214
|
+
const kept = new Set();
|
|
2215
|
+
for (const [key, raw] of Object.entries(context.recordedIds())) {
|
|
2216
|
+
if (!key.startsWith('adopted.'))
|
|
2217
|
+
continue;
|
|
2218
|
+
const snapshot = parseAdoptSnapshot(raw);
|
|
2219
|
+
if (snapshot)
|
|
2220
|
+
kept.add(snapshot.application.uuid).add(snapshot.database.uuid);
|
|
2221
|
+
}
|
|
2222
|
+
const resources = await list.call(this.ports.coolify, projectUuid);
|
|
2223
|
+
// Delete only what foundry recorded. Anything else in the project (a resource a person made there)
|
|
2224
|
+
// stops the move before the first delete; it is moved by hand.
|
|
2225
|
+
const recorded = new Set(Object.entries(context.recordedIds())
|
|
2226
|
+
.filter(([key]) => !key.startsWith('adopted.'))
|
|
2227
|
+
.map(([, id]) => id));
|
|
2228
|
+
const candidates = resources.filter((r) => (r.kind === 'application' || r.kind === 'database') &&
|
|
2229
|
+
typeof r.key === 'string' &&
|
|
2230
|
+
!kept.has(r.key));
|
|
2231
|
+
const stranger = candidates.find((r) => !recorded.has(r.key));
|
|
2232
|
+
if (stranger) {
|
|
2233
|
+
const name = typeof stranger.config?.name === 'string' ? stranger.config.name : stranger.key;
|
|
2234
|
+
throw new Error(`${at}: the project holds the ${stranger.kind} "${name}", which foundry did not create; move this company by hand (docs/runbooks/network-move.md)`);
|
|
2235
|
+
}
|
|
2236
|
+
const doomed = candidates.sort((a, b) => a.kind === b.kind ? 0 : a.kind === 'application' ? -1 : 1);
|
|
2237
|
+
for (const resource of doomed) {
|
|
2238
|
+
try {
|
|
2239
|
+
if (resource.kind === 'application') {
|
|
2240
|
+
await this.ports.coolify.deleteApplication(resource.key);
|
|
2241
|
+
}
|
|
2242
|
+
else {
|
|
2243
|
+
await this.ports.coolify.deleteDatabase(resource.key);
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
catch (error) {
|
|
2247
|
+
if (!isNotFoundError(error))
|
|
2248
|
+
throw error;
|
|
2249
|
+
}
|
|
2250
|
+
for (const [key, id] of Object.entries(context.recordedIds())) {
|
|
2251
|
+
if (id === resource.key)
|
|
2252
|
+
context.forgetId?.(key);
|
|
2253
|
+
}
|
|
2254
|
+
await context.persistScopeIds();
|
|
2255
|
+
}
|
|
2256
|
+
// Every database is gone, so every recorded backup schedule is too. A kept marker would stop
|
|
2257
|
+
// `planCompanyBackups` from scheduling the rebuilt databases, and the upgrade gate would look for
|
|
2258
|
+
// executions of a schedule that no longer exists.
|
|
2259
|
+
for (const key of Object.keys(context.recordedIds())) {
|
|
2260
|
+
if (key.startsWith(BACKUP_SCOPE_PREFIX))
|
|
2261
|
+
context.forgetId?.(key);
|
|
2262
|
+
}
|
|
2263
|
+
// The company's connector is gone, so the tunnel's ingress is written again after the move.
|
|
2264
|
+
context.forgetId?.(CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY);
|
|
2265
|
+
context.recordScopeId(NETWORK_APPLIED_SCOPE_KEY, network);
|
|
2266
|
+
await context.persistScopeIds();
|
|
2267
|
+
return {};
|
|
2268
|
+
}
|
|
1890
2269
|
/**
|
|
1891
2270
|
* #445: one company lands on one Coolify server. The first call for an
|
|
1892
2271
|
* org picks a usable, reachable server and records it
|
|
@@ -1950,6 +2329,11 @@ export class FoundryStepExecutor {
|
|
|
1950
2329
|
throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' has no repo`);
|
|
1951
2330
|
}
|
|
1952
2331
|
const appRepo = entry.repo;
|
|
2332
|
+
// A compose file can set `privileged`, host networking or a Docker socket mount, and its labels
|
|
2333
|
+
// are its own: refused for every company until compose files are checked (`diff.ts` blocks it).
|
|
2334
|
+
if (entry.buildPack === 'dockercompose') {
|
|
2335
|
+
throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' is compose-built, which is refused`);
|
|
2336
|
+
}
|
|
1953
2337
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
1954
2338
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1955
2339
|
// The recorded id first; the name when nothing is recorded, or when the
|
|
@@ -1971,10 +2355,12 @@ export class FoundryStepExecutor {
|
|
|
1971
2355
|
environmentIds: [environment.id],
|
|
1972
2356
|
});
|
|
1973
2357
|
}
|
|
1974
|
-
//
|
|
1975
|
-
//
|
|
2358
|
+
// No entry gets Coolify `domains`: Coolify would add a Traefik router for them. The company's
|
|
2359
|
+
// tunnel routes an entry's names straight to its container (also `<slug>.oikos.host`, through a
|
|
2360
|
+
// record in the platform's oikos.host zone), which every entry reaches as `<slug>-<key>` (its
|
|
2361
|
+
// internal name) on the company network.
|
|
1976
2362
|
const names = entryHostnames(org, entry);
|
|
1977
|
-
const wantDomains =
|
|
2363
|
+
const wantDomains = [];
|
|
1978
2364
|
const isStatic = entry.buildPack === 'static';
|
|
1979
2365
|
const settingsKey = appSettingsAppliedScopeKey(entry.key);
|
|
1980
2366
|
const domainsKey = appDomainsAppliedScopeKey(entry.key);
|
|
@@ -2014,8 +2400,7 @@ export class FoundryStepExecutor {
|
|
|
2014
2400
|
git_branch: entry.branch,
|
|
2015
2401
|
build_pack: entry.buildPack,
|
|
2016
2402
|
name: appNameFor(org, entry),
|
|
2017
|
-
|
|
2018
|
-
autogenerate_domain: names.length > 0 ? undefined : false,
|
|
2403
|
+
autogenerate_domain: false,
|
|
2019
2404
|
// A static site is served by Coolify's own web server on port 80.
|
|
2020
2405
|
ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
|
|
2021
2406
|
is_auto_deploy_enabled: entry.autoDeploy,
|
|
@@ -2060,9 +2445,9 @@ export class FoundryStepExecutor {
|
|
|
2060
2445
|
// redirect loops behind the tunnel. The internal name is set after
|
|
2061
2446
|
// create, as the calls stack's apps do.
|
|
2062
2447
|
if (JSON.stringify(wantDomains) !== JSON.stringify(haveDomains)) {
|
|
2063
|
-
patch.domains =
|
|
2448
|
+
patch.domains = '';
|
|
2064
2449
|
}
|
|
2065
|
-
if (
|
|
2450
|
+
if (!existing || patch.domains !== undefined) {
|
|
2066
2451
|
patch.custom_internal_name = appNameFor(org, entry);
|
|
2067
2452
|
}
|
|
2068
2453
|
// A `*` entry's proxy labels: Coolify takes `custom_labels` base64 encoded
|
|
@@ -2074,16 +2459,16 @@ export class FoundryStepExecutor {
|
|
|
2074
2459
|
// below, after the redeploy, so a failed run repeats this.
|
|
2075
2460
|
const labelsKey = appLabelsAppliedScopeKey(entry.key);
|
|
2076
2461
|
const haveLabelsHash = context.recordedIds()[labelsKey];
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
const
|
|
2462
|
+
// Every entry container carries `traefik.enable=false` (a run label overrides the image's own), so
|
|
2463
|
+
// Traefik never routes it, whatever its image declares.
|
|
2464
|
+
const wantLabelsHash = entryLabelHash(entryLabelSpec(org, entry));
|
|
2465
|
+
const wantLabels = ENTRY_CONTAINER_LABELS;
|
|
2080
2466
|
const liveLabels = existing ? live?.custom_labels : undefined;
|
|
2081
|
-
const labelsDiffer =
|
|
2082
|
-
(
|
|
2083
|
-
(liveLabels
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
if (wantLabels !== undefined && labelsDiffer) {
|
|
2467
|
+
const labelsDiffer = !existing ||
|
|
2468
|
+
(liveLabels !== undefined
|
|
2469
|
+
? !sameLabelSets(Buffer.from(liveLabels ?? '', 'base64').toString('utf8'), wantLabels)
|
|
2470
|
+
: wantLabelsHash !== haveLabelsHash);
|
|
2471
|
+
if (labelsDiffer) {
|
|
2087
2472
|
patch.custom_labels = Buffer.from(wantLabels).toString('base64');
|
|
2088
2473
|
}
|
|
2089
2474
|
if (Object.keys(patch).length > 0)
|
|
@@ -2125,7 +2510,6 @@ export class FoundryStepExecutor {
|
|
|
2125
2510
|
// What the plan decided is available (diff.ts `planApps`), so the marker
|
|
2126
2511
|
// recorded below is the one the plan compares against. Absent: none.
|
|
2127
2512
|
action.desired.secretSources ?? {
|
|
2128
|
-
platformMailKey: false,
|
|
2129
2513
|
sharedSecret: false,
|
|
2130
2514
|
});
|
|
2131
2515
|
// Rule 4 (#239, #243): a container reads its env only at start, so an app
|
|
@@ -2145,9 +2529,15 @@ export class FoundryStepExecutor {
|
|
|
2145
2529
|
const [hostname] = names;
|
|
2146
2530
|
const healthPath = entry.healthPath ?? (isStatic ? '/' : '/api/health');
|
|
2147
2531
|
const runtime = this.ports.runtime ?? {};
|
|
2148
|
-
await waitForNewDeployment(this.ports.coolify, appId,
|
|
2532
|
+
const built = await waitForNewDeployment(this.ports.coolify, appId,
|
|
2533
|
+
// The public name answers from the container only once the tunnel step has written the
|
|
2534
|
+
// direct route; before that (a first apply, or the one that moves it) the deployment is the check.
|
|
2535
|
+
hostname &&
|
|
2536
|
+
(context.recordedIds()[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY] ?? '[]') ===
|
|
2537
|
+
entryRoutesMarker(org)
|
|
2149
2538
|
? publicHealthCheck(org, context.recordedIds(), hostname, healthPath)
|
|
2150
2539
|
: { kind: 'deployment-only' }, runtime, runtime.sleep ?? defaultSleep, `coolify:app:${entry.key}`, `${org.slug}'s ${entry.key === APP_ENTRY_KEY ? 'app' : `${entry.key} app`} stays at its previous deploy`);
|
|
2540
|
+
await recordBuiltCommit(this.ports.coolify, { appId: appId, deploymentUuid: built.deploymentUuid, name: entry.key }, (key, value) => context.recordScopeId(key, value));
|
|
2151
2541
|
}
|
|
2152
2542
|
else if ((appEnv.changed || dbNeedsRestart) && existing) {
|
|
2153
2543
|
await this.ports.coolify.restart(appId);
|
|
@@ -2158,8 +2548,7 @@ export class FoundryStepExecutor {
|
|
|
2158
2548
|
// one and `planApp` reports it instead of planning it.
|
|
2159
2549
|
context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
|
|
2160
2550
|
context.recordScopeId(domainsKey, JSON.stringify(wantDomains));
|
|
2161
|
-
|
|
2162
|
-
context.recordScopeId(labelsKey, wantLabelsHash);
|
|
2551
|
+
context.recordScopeId(labelsKey, wantLabelsHash);
|
|
2163
2552
|
context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
|
|
2164
2553
|
if (entry.uses?.auth) {
|
|
2165
2554
|
context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
|
|
@@ -2223,6 +2612,31 @@ export class FoundryStepExecutor {
|
|
|
2223
2612
|
await this.ports.coolify.restart(appId);
|
|
2224
2613
|
}
|
|
2225
2614
|
}
|
|
2615
|
+
/**
|
|
2616
|
+
* F6: a nightly dump of one of the company's databases to the platform's
|
|
2617
|
+
* backup bucket. The schedule is found before it is created
|
|
2618
|
+
* (`provisionDatabaseBackup`), so a run that died after the create and
|
|
2619
|
+
* before `backup_<db>` was recorded reuses the schedule on the next apply
|
|
2620
|
+
* instead of adding a second. The error carries the database's name, never
|
|
2621
|
+
* the connection URL or a response body.
|
|
2622
|
+
*/
|
|
2623
|
+
async coolifyBackup(action, org, context) {
|
|
2624
|
+
const { databaseId, database, storageUuid } = action.desired;
|
|
2625
|
+
if (typeof databaseId !== 'string' ||
|
|
2626
|
+
typeof database !== 'string' ||
|
|
2627
|
+
typeof storageUuid !== 'string' ||
|
|
2628
|
+
!databaseId ||
|
|
2629
|
+
!storageUuid) {
|
|
2630
|
+
throw new Error(`coolify:backup for ${org.slug}: the plan named no database or no storage`);
|
|
2631
|
+
}
|
|
2632
|
+
const { uuid } = await provisionDatabaseBackup(this.ports.coolify, {
|
|
2633
|
+
databaseUuid: databaseId,
|
|
2634
|
+
label: `${org.slug} ${database}`,
|
|
2635
|
+
storageUuid,
|
|
2636
|
+
});
|
|
2637
|
+
context.recordScopeId(backupScopeKey(database), uuid);
|
|
2638
|
+
return undefined;
|
|
2639
|
+
}
|
|
2226
2640
|
async coolifyDatabase(action, org, context) {
|
|
2227
2641
|
if (action.desired.enabled === false) {
|
|
2228
2642
|
const dbId = context.recordedIds()[`coolify:database:${action.key}`];
|
|
@@ -2420,24 +2834,12 @@ export class FoundryStepExecutor {
|
|
|
2420
2834
|
// minted here; a source with no value yet (its own step has not run,
|
|
2421
2835
|
// or has not run for this apply) is simply omitted, the same way every
|
|
2422
2836
|
// other optional entry above is.
|
|
2423
|
-
// The secret
|
|
2837
|
+
// The secret source (`sharedSecret`) is filled here
|
|
2424
2838
|
// only, straight into `envs`: never recorded, returned or logged.
|
|
2425
2839
|
const heldKeys = this.ports.heldKeys;
|
|
2426
|
-
// Availability is the plan's (`secretSources`). If the plan said
|
|
2427
|
-
// held and this port cannot
|
|
2428
|
-
//
|
|
2429
|
-
let mailKey = null;
|
|
2430
|
-
if (secretSources.platformMailKey) {
|
|
2431
|
-
try {
|
|
2432
|
-
mailKey = (await heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF)) || null;
|
|
2433
|
-
}
|
|
2434
|
-
catch {
|
|
2435
|
-
// reported below, by ref
|
|
2436
|
-
}
|
|
2437
|
-
if (mailKey === null) {
|
|
2438
|
-
throw new Error(`coolify:app for ${org.slug}: the plan found ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF} held but the executor's heldKeys cannot read it`);
|
|
2439
|
-
}
|
|
2440
|
-
}
|
|
2840
|
+
// Availability is the plan's (`secretSources`). If the plan said secrets
|
|
2841
|
+
// can be held and this port cannot put, the host wired the two differently:
|
|
2842
|
+
// never push a marker for an env that was not written.
|
|
2441
2843
|
if (secretSources.sharedSecret && typeof heldKeys?.put !== 'function') {
|
|
2442
2844
|
throw new Error(`coolify:app for ${org.slug}: the plan found held keys that can put but the executor's heldKeys cannot`);
|
|
2443
2845
|
}
|
|
@@ -2448,9 +2850,7 @@ export class FoundryStepExecutor {
|
|
|
2448
2850
|
for (const { name, source } of resolved.secrets) {
|
|
2449
2851
|
envs.push({
|
|
2450
2852
|
key: name,
|
|
2451
|
-
value: source
|
|
2452
|
-
? mailKey
|
|
2453
|
-
: await this.ensureSharedSecret(org, source.name, recorded),
|
|
2853
|
+
value: await this.ensureSharedSecret(org, source.name, recorded),
|
|
2454
2854
|
});
|
|
2455
2855
|
}
|
|
2456
2856
|
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
@@ -2469,6 +2869,40 @@ export class FoundryStepExecutor {
|
|
|
2469
2869
|
authApplied,
|
|
2470
2870
|
};
|
|
2471
2871
|
}
|
|
2872
|
+
/**
|
|
2873
|
+
* The install's edge label token: read from the held keys, else minted and held first, so a crash
|
|
2874
|
+
* after the `put` hands the same value out on the next apply. Read once more after the `put`, so two
|
|
2875
|
+
* runs that mint at the same moment end on the value that was held last. An error names the ref,
|
|
2876
|
+
* never a value.
|
|
2877
|
+
*/
|
|
2878
|
+
async edgeLabelToken() {
|
|
2879
|
+
const heldKeys = this.ports.heldKeys;
|
|
2880
|
+
const held = await heldKeys?.resolve(PROXY_LABEL_TOKEN_REF);
|
|
2881
|
+
if (held)
|
|
2882
|
+
return held;
|
|
2883
|
+
if (typeof heldKeys?.put !== 'function') {
|
|
2884
|
+
throw new Error(`proxy labels: the edge label token (${PROXY_LABEL_TOKEN_REF}) is not held and the executor's heldKeys cannot put one`);
|
|
2885
|
+
}
|
|
2886
|
+
const minted = randomBytes(32).toString('base64url');
|
|
2887
|
+
this.handledSecrets.add(minted);
|
|
2888
|
+
await heldKeys.put(PROXY_LABEL_TOKEN_REF, minted);
|
|
2889
|
+
const final = (await heldKeys.resolve(PROXY_LABEL_TOKEN_REF)) ?? minted;
|
|
2890
|
+
this.handledSecrets.add(final);
|
|
2891
|
+
return final;
|
|
2892
|
+
}
|
|
2893
|
+
/**
|
|
2894
|
+
* The `custom_labels` patch of an app foundry routes through Traefik at `hostname`: the same http
|
|
2895
|
+
* router a `domains` entry gets, plus the edge token. Coolify uses these instead of the labels it
|
|
2896
|
+
* generates from `domains`. The router names use the app's own name, which is unique per company.
|
|
2897
|
+
*/
|
|
2898
|
+
async routedLabels(name, hostname, port) {
|
|
2899
|
+
const token = await this.edgeLabelToken();
|
|
2900
|
+
const labels = entryProxyLabels(name, { names: [hostname], wildcardDomain: null, port }, token);
|
|
2901
|
+
if (routesWithoutToken(labels, token)) {
|
|
2902
|
+
throw new Error('proxy labels: a routed app would be sent without the edge token');
|
|
2903
|
+
}
|
|
2904
|
+
return { custom_labels: Buffer.from(labels).toString('base64') };
|
|
2905
|
+
}
|
|
2472
2906
|
/**
|
|
2473
2907
|
* The org's shared secret `name`: read from the held keys, else generated
|
|
2474
2908
|
* and held first, so a crash after the `put` and before the env write
|
|
@@ -2925,6 +3359,40 @@ export class FoundryStepExecutor {
|
|
|
2925
3359
|
}
|
|
2926
3360
|
}
|
|
2927
3361
|
}
|
|
3362
|
+
/**
|
|
3363
|
+
* The company's own sign-in mail token: a Lettermint project `<slug>-auth`
|
|
3364
|
+
* whose token goes to `orgTarget(org).refs.brokerMailKey`, where the broker
|
|
3365
|
+
* and ZITADEL's SMTP settings read it. The platform-wide Lettermint key
|
|
3366
|
+
* stays with the executor and never reaches a company. A held ref is left
|
|
3367
|
+
* alone. Otherwise the mint rule of {@link ensureOrgAppMailToken}: create
|
|
3368
|
+
* the project, restrict the domain, deliver its token, and only then
|
|
3369
|
+
* delete every other `<slug>-auth` project (a token cannot be read back,
|
|
3370
|
+
* so a project without a held token is a leftover). The restriction comes
|
|
3371
|
+
* before the delivery so a failure there leaves no held ref: the next run
|
|
3372
|
+
* starts over instead of skipping it. A failure before the delivery leaves
|
|
3373
|
+
* that project for the next run to delete. Every Lettermint call is
|
|
3374
|
+
* bounded; the token is registered for redaction before anything can throw
|
|
3375
|
+
* with it.
|
|
3376
|
+
*/
|
|
3377
|
+
async ensureOwnAuthMailToken(org, mailDomain, deps) {
|
|
3378
|
+
const ref = orgTarget(org).refs.brokerMailKey;
|
|
3379
|
+
if (await deps.heldKeys?.resolve(ref))
|
|
3380
|
+
return;
|
|
3381
|
+
const lettermint = this.ports.lettermint;
|
|
3382
|
+
if (!lettermint || !deps.heldKeys)
|
|
3383
|
+
throw new OwnServicesBlockedError('lettermint', ref);
|
|
3384
|
+
const authName = `${org.slug}-auth`;
|
|
3385
|
+
const projects = await withTimeout(lettermint.listProjects(), LETTERMINT_TIMEOUT_MS);
|
|
3386
|
+
const created = await withTimeout(lettermint.createProject(authName), LETTERMINT_TIMEOUT_MS);
|
|
3387
|
+
deps.runtime?.onSecret?.(created.token);
|
|
3388
|
+
await withTimeout(this.ensureLettermintDomainRestriction(org, mailDomain, [...projects, created.project], null, null), LETTERMINT_TIMEOUT_MS);
|
|
3389
|
+
await deps.deliverSecret(ref, created.token);
|
|
3390
|
+
for (const project of projects) {
|
|
3391
|
+
if (project.name === authName && project.id !== created.project.id) {
|
|
3392
|
+
await withTimeout(lettermint.deleteProject(project.id), LETTERMINT_TIMEOUT_MS);
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
2928
3396
|
/**
|
|
2929
3397
|
* Deletes a just-minted project from inside a `catch`. A failed delete is
|
|
2930
3398
|
* dropped so it never replaces the error being rethrown; the orphan is
|
|
@@ -2971,6 +3439,7 @@ export class FoundryStepExecutor {
|
|
|
2971
3439
|
async ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId) {
|
|
2972
3440
|
const appName = `${org.slug}-app`;
|
|
2973
3441
|
const relayName = `${org.slug}-relay`;
|
|
3442
|
+
const authName = `${org.slug}-auth`;
|
|
2974
3443
|
const isPlatformApexDomain = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
|
|
2975
3444
|
if (isPlatformApexDomain && platformLettermintProjectId) {
|
|
2976
3445
|
if (!allProjects.some((p) => p.id === platformLettermintProjectId)) {
|
|
@@ -2982,7 +3451,7 @@ export class FoundryStepExecutor {
|
|
|
2982
3451
|
? await this.ports.lettermint.getDomain(existing.id)
|
|
2983
3452
|
: await this.ports.lettermint.createDomain(mailDomain);
|
|
2984
3453
|
const scopedProjectIds = allProjects
|
|
2985
|
-
.filter((p) => p.name === appName || p.name === relayName)
|
|
3454
|
+
.filter((p) => p.name === appName || p.name === relayName || p.name === authName)
|
|
2986
3455
|
.map((p) => p.id);
|
|
2987
3456
|
if (isPlatformApexDomain &&
|
|
2988
3457
|
platformLettermintProjectId &&
|
|
@@ -3069,14 +3538,8 @@ export class FoundryStepExecutor {
|
|
|
3069
3538
|
await this.ports.coolify.raw('POST', `/applications/${siteAppId}/stop`);
|
|
3070
3539
|
return { resourceId: cmsAppId ?? siteAppId };
|
|
3071
3540
|
}
|
|
3072
|
-
//
|
|
3073
|
-
|
|
3074
|
-
// `cloudflareRateLimit` above guards its own `org.domain` read, since
|
|
3075
|
-
// TypeScript's `string | null` gives no static guarantee of it.
|
|
3076
|
-
if (!org.domain) {
|
|
3077
|
-
throw new Error(`cms:site for ${org.slug}: site.enabled is set but domain is null`);
|
|
3078
|
-
}
|
|
3079
|
-
const domain = org.domain;
|
|
3541
|
+
// The site's address: the org's domain, or `<slug>.oikos.host` without one.
|
|
3542
|
+
const domain = siteHostnameFor(org);
|
|
3080
3543
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
3081
3544
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
3082
3545
|
const site = await this.findOrCreateCmsApp({
|
|
@@ -3110,9 +3573,11 @@ export class FoundryStepExecutor {
|
|
|
3110
3573
|
// deploys, it does not add a deploy where none exists).
|
|
3111
3574
|
await this.ports.coolify.updateApplication(site.appId, {
|
|
3112
3575
|
...healthCheckSettings('/api/health', CMS_APP_PORT),
|
|
3576
|
+
...(await this.routedLabels(`${org.slug}-site`, domain, CMS_APP_PORT)),
|
|
3113
3577
|
});
|
|
3114
3578
|
await this.ports.coolify.updateApplication(cms.appId, {
|
|
3115
3579
|
...healthCheckSettings('/api/health', CMS_APP_PORT),
|
|
3580
|
+
...(await this.routedLabels(`${org.slug}-cms`, cmsHostnameFor(org), CMS_APP_PORT)),
|
|
3116
3581
|
});
|
|
3117
3582
|
// Every app foundry deploys keeps its own logs (#75) -- reused as-is for
|
|
3118
3583
|
// both of these two, the same unconditional call `coolifyApp` makes for
|
|
@@ -3399,24 +3864,27 @@ export class FoundryStepExecutor {
|
|
|
3399
3864
|
// yet.
|
|
3400
3865
|
throw new Error(`coolify:calls for ${org.slug}: a database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
3401
3866
|
}
|
|
3867
|
+
const synapseTag = typeof action.desired.synapseTag === 'string' ? action.desired.synapseTag : SYNAPSE_TAG;
|
|
3402
3868
|
const synapse = await this.findOrCreateCallsApp(names.synapse, environment.id, () => this.ports.coolify.createDockerImageApplication({
|
|
3403
3869
|
project_uuid: projectUuid,
|
|
3404
3870
|
server_uuid: server.uuid,
|
|
3405
3871
|
environment_name: environment.name,
|
|
3406
3872
|
environment_uuid: environment.uuid,
|
|
3407
3873
|
docker_registry_image_name: SYNAPSE_IMAGE,
|
|
3408
|
-
docker_registry_image_tag:
|
|
3874
|
+
docker_registry_image_tag: pinnedTagFor('synapse', synapseTag),
|
|
3409
3875
|
ports_exposes: '8008',
|
|
3410
3876
|
name: names.synapse,
|
|
3411
|
-
description: `Synapse for ${org.slug}'s calls stack (${SYNAPSE_IMAGE}:${
|
|
3877
|
+
description: `Synapse for ${org.slug}'s calls stack (${SYNAPSE_IMAGE}:${synapseTag})`,
|
|
3412
3878
|
instant_deploy: false,
|
|
3413
3879
|
}));
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3880
|
+
// The check goes on every run, so a Synapse made without one gets it. Synapse
|
|
3881
|
+
// is deployed before MAS here. Its `/health` answers with MAS unreachable
|
|
3882
|
+
// (shown with a throwaway Synapse of the same image, MAS endpoint a name that
|
|
3883
|
+
// does not resolve: 200, healthy), and the start period covers first-boot migrations.
|
|
3884
|
+
await this.ports.coolify.updateApplication(synapse.appId, {
|
|
3885
|
+
...(synapse.existed ? {} : { custom_internal_name: names.synapse }),
|
|
3886
|
+
...healthCheckSettings('/health', 8008, SYNAPSE_HEALTH_CHECK_START_PERIOD_SECONDS),
|
|
3887
|
+
});
|
|
3420
3888
|
context.recordScopeId(CALLS_SYNAPSE_SCOPE_KEY, synapse.appId);
|
|
3421
3889
|
// #251: only on create -- nothing here patches `docker_registry_image_tag`
|
|
3422
3890
|
// on an app `findOrCreateCallsApp` found already running, so that is the
|
|
@@ -3424,21 +3892,21 @@ export class FoundryStepExecutor {
|
|
|
3424
3892
|
// the app id above is, would make `versionNotAppliedLine` compare a
|
|
3425
3893
|
// bumped `VERSIONS.synapse` against a value never actually applied.
|
|
3426
3894
|
if (!synapse.existed) {
|
|
3427
|
-
context.recordScopeId(CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY,
|
|
3895
|
+
context.recordScopeId(CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, synapseTag);
|
|
3428
3896
|
}
|
|
3429
3897
|
const mas = await this.findOrCreateCallsApp(names.mas, environment.id, () => this.ports.coolify.createDockerfileApplication({
|
|
3430
3898
|
project_uuid: projectUuid,
|
|
3431
3899
|
server_uuid: server.uuid,
|
|
3432
3900
|
environment_name: environment.name,
|
|
3433
3901
|
environment_uuid: environment.uuid,
|
|
3434
|
-
dockerfile:
|
|
3902
|
+
dockerfile: masDockerfile(),
|
|
3435
3903
|
name: names.mas,
|
|
3436
3904
|
description: `matrix-authentication-service for ${org.slug}'s calls stack`,
|
|
3437
3905
|
instant_deploy: false,
|
|
3438
3906
|
}));
|
|
3439
3907
|
if (!mas.existed) {
|
|
3440
3908
|
await this.ports.coolify.updateApplication(mas.appId, {
|
|
3441
|
-
|
|
3909
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
3442
3910
|
custom_internal_name: names.mas,
|
|
3443
3911
|
});
|
|
3444
3912
|
}
|
|
@@ -3875,7 +4343,7 @@ export class FoundryStepExecutor {
|
|
|
3875
4343
|
server_uuid: server.uuid,
|
|
3876
4344
|
environment_name: environment.name,
|
|
3877
4345
|
environment_uuid: environment.uuid,
|
|
3878
|
-
dockerfile:
|
|
4346
|
+
dockerfile: masDockerfile(),
|
|
3879
4347
|
name: names.mas,
|
|
3880
4348
|
description: `matrix-authentication-service for ${org.slug}'s chat`,
|
|
3881
4349
|
instant_deploy: false,
|
|
@@ -3895,21 +4363,26 @@ export class FoundryStepExecutor {
|
|
|
3895
4363
|
autogenerate_domain: false,
|
|
3896
4364
|
}), { startIfStopped: false });
|
|
3897
4365
|
// Fixed internal names, set on every run: the three apps and Agora
|
|
3898
|
-
// address each other by them.
|
|
3899
|
-
//
|
|
3900
|
-
//
|
|
4366
|
+
// address each other by them. Both are Dockerfile apps with a HEALTHCHECK
|
|
4367
|
+
// of their own (MAS's has a copied-in busybox, its image has no shell), so
|
|
4368
|
+
// Coolify's check is off and the image's is the live one. MAS is deployed
|
|
4369
|
+
// first and its `/health` needs only its database, so Synapse's check can
|
|
4370
|
+
// pass.
|
|
3901
4371
|
await this.ports.coolify.updateApplication(synapse.appId, {
|
|
3902
4372
|
custom_internal_name: names.synapse,
|
|
3903
|
-
|
|
4373
|
+
// Its Dockerfile's HEALTHCHECK (curl on /health) is the live check; MAS is deployed first.
|
|
4374
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
3904
4375
|
});
|
|
3905
4376
|
await this.ports.coolify.updateApplication(mas.appId, {
|
|
3906
4377
|
custom_internal_name: names.mas,
|
|
3907
|
-
|
|
4378
|
+
// Its Dockerfile's HEALTHCHECK (`masDockerfile`) is the live check.
|
|
4379
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
3908
4380
|
});
|
|
3909
4381
|
await this.ports.coolify.updateApplication(service.appId, {
|
|
3910
4382
|
custom_internal_name: names.service,
|
|
3911
|
-
//
|
|
3912
|
-
//
|
|
4383
|
+
// A command, not Coolify's HTTP check: the image is node:22-alpine with
|
|
4384
|
+
// no curl, and its busybox `wget` tries `localhost` as `::1` first.
|
|
4385
|
+
...commandHealthCheckSettings(wgetProbe(CHAT_SERVICE_PORT, CHAT_SERVICE_HEALTH_CHECK_PATH)),
|
|
3913
4386
|
});
|
|
3914
4387
|
context.recordScopeId(CHAT_SYNAPSE_SCOPE_KEY, synapse.appId);
|
|
3915
4388
|
context.recordScopeId(CHAT_SERVICE_SCOPE_KEY, service.appId);
|
|
@@ -4084,18 +4557,20 @@ export class FoundryStepExecutor {
|
|
|
4084
4557
|
server_uuid: server.uuid,
|
|
4085
4558
|
environment_name: environment.name,
|
|
4086
4559
|
environment_uuid: environment.uuid,
|
|
4087
|
-
dockerfile:
|
|
4560
|
+
dockerfile: masDockerfile(),
|
|
4088
4561
|
name: names.mas,
|
|
4089
4562
|
description: `matrix-authentication-service for ${org.slug}'s re-keyed chat (#463)`,
|
|
4090
4563
|
instant_deploy: false,
|
|
4091
4564
|
}), { startIfStopped: false });
|
|
4092
4565
|
await this.ports.coolify.updateApplication(synapse.appId, {
|
|
4093
4566
|
custom_internal_name: names.synapse,
|
|
4094
|
-
|
|
4567
|
+
// Its Dockerfile's HEALTHCHECK (curl on /health) is the live check; MAS is deployed first.
|
|
4568
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
4095
4569
|
});
|
|
4096
4570
|
await this.ports.coolify.updateApplication(mas.appId, {
|
|
4097
4571
|
custom_internal_name: names.mas,
|
|
4098
|
-
|
|
4572
|
+
// Its Dockerfile's HEALTHCHECK (`masDockerfile`) is the live check.
|
|
4573
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
4099
4574
|
});
|
|
4100
4575
|
context.recordScopeId(CHAT_REKEY_SYNAPSE_SCOPE_KEY, synapse.appId);
|
|
4101
4576
|
context.recordScopeId(CHAT_REKEY_MAS_SCOPE_KEY, mas.appId);
|
|
@@ -4289,14 +4764,16 @@ export class FoundryStepExecutor {
|
|
|
4289
4764
|
// step again for a live app whose marker is missing or stale, so an app
|
|
4290
4765
|
// made before this existed gets its name here. The marker, recorded last,
|
|
4291
4766
|
// tells `desiredAgoraEnv` that the name and the env are both in place.
|
|
4292
|
-
//
|
|
4293
|
-
//
|
|
4767
|
+
// A command, not Coolify's HTTP check: the image is node:24-alpine with no
|
|
4768
|
+
// curl, and its busybox `wget` tries `localhost` as `::1` first.
|
|
4294
4769
|
// A found app may predate `ports_exposes` on create; patch a differing one.
|
|
4295
4770
|
const portDiffers = app.existed &&
|
|
4296
4771
|
(await this.ports.coolify.getApplication(app.appId)).ports_exposes !== CALLS_SERVICE_PORT;
|
|
4297
4772
|
await this.ports.coolify.updateApplication(app.appId, {
|
|
4298
4773
|
custom_internal_name: name,
|
|
4774
|
+
...commandHealthCheckSettings(wgetProbe(CALLS_SERVICE_PORT, CALLS_HEALTH_CHECK_PATH)),
|
|
4299
4775
|
...(portDiffers ? { ports_exposes: CALLS_SERVICE_PORT } : {}),
|
|
4776
|
+
...(await this.routedLabels(name, callsHostnameFor(org), Number(CALLS_SERVICE_PORT))),
|
|
4300
4777
|
});
|
|
4301
4778
|
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
4302
4779
|
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
@@ -4677,6 +5154,7 @@ export class FoundryStepExecutor {
|
|
|
4677
5154
|
await this.ports.coolify.updateApplication(app.appId, {
|
|
4678
5155
|
...(portDiffers ? { ports_exposes: AGORA_APP_PORTS_EXPOSES } : {}),
|
|
4679
5156
|
...healthCheckSettings('/', Number(AGORA_APP_PORTS_EXPOSES)),
|
|
5157
|
+
...(await this.routedLabels(name, agoraHostnameFor(org), Number(AGORA_APP_PORTS_EXPOSES))),
|
|
4680
5158
|
});
|
|
4681
5159
|
// #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
|
|
4682
5160
|
// Coolify's own env store matching `env`, which is why that store alone
|
|
@@ -4703,7 +5181,8 @@ export class FoundryStepExecutor {
|
|
|
4703
5181
|
// in the branch below, and this deploy already carries this run's env,
|
|
4704
5182
|
// pushed above, so the two never both run).
|
|
4705
5183
|
const runtime = this.ports.runtime ?? {};
|
|
4706
|
-
await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), agoraHostnameFor(org), '/'), runtime, runtime.sleep ?? defaultSleep, 'coolify:agora', `${org.slug}'s Agora stays at its previous deploy`);
|
|
5184
|
+
const built = await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), agoraHostnameFor(org), '/'), runtime, runtime.sleep ?? defaultSleep, 'coolify:agora', `${org.slug}'s Agora stays at its previous deploy`);
|
|
5185
|
+
await recordBuiltCommit(this.ports.coolify, { appId: app.appId, deploymentUuid: built.deploymentUuid, name: 'agora' }, (key, value) => context.recordScopeId(key, value));
|
|
4707
5186
|
context.recordScopeId(AGORA_DEPLOYED_SCOPE_KEY, '1');
|
|
4708
5187
|
}
|
|
4709
5188
|
else if (app.existed && (envChanged || !alreadyActivated)) {
|
|
@@ -4784,6 +5263,7 @@ export class FoundryStepExecutor {
|
|
|
4784
5263
|
// `EXPOSE 3000` ({@link BOULE_PORT}'s own doc comment).
|
|
4785
5264
|
await this.ports.coolify.updateApplication(app.appId, {
|
|
4786
5265
|
...healthCheckSettings('/api/health', BOULE_PORT),
|
|
5266
|
+
...(await this.routedLabels(name, bouleHostnameFor(org), BOULE_PORT)),
|
|
4787
5267
|
});
|
|
4788
5268
|
const liveEnv = new Map((await this.ports.coolify.listEnvs(app.appId))
|
|
4789
5269
|
.filter((e) => !e.is_preview)
|
|
@@ -4841,7 +5321,8 @@ export class FoundryStepExecutor {
|
|
|
4841
5321
|
if (!context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]) {
|
|
4842
5322
|
// #543: every deploy waits for health -- Boule's own public hostname.
|
|
4843
5323
|
const runtime = this.ports.runtime ?? {};
|
|
4844
|
-
await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), bouleHostnameFor(org), '/api/health'), runtime, runtime.sleep ?? defaultSleep, 'coolify:boule', `${org.slug}'s Boule stays at its previous deploy`);
|
|
5324
|
+
const built = await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), bouleHostnameFor(org), '/api/health'), runtime, runtime.sleep ?? defaultSleep, 'coolify:boule', `${org.slug}'s Boule stays at its previous deploy`);
|
|
5325
|
+
await recordBuiltCommit(this.ports.coolify, { appId: app.appId, deploymentUuid: built.deploymentUuid, name: 'boule' }, (key, value) => context.recordScopeId(key, value));
|
|
4845
5326
|
context.recordScopeId(BOULE_DEPLOYED_SCOPE_KEY, '1');
|
|
4846
5327
|
}
|
|
4847
5328
|
else if (app.existed &&
|
|
@@ -5022,6 +5503,367 @@ export class FoundryStepExecutor {
|
|
|
5022
5503
|
}
|
|
5023
5504
|
return port;
|
|
5024
5505
|
}
|
|
5506
|
+
/**
|
|
5507
|
+
* `plan/own-diff.ts`'s `planOwnJobs`: the org's own scheduler, in the org's
|
|
5508
|
+
* own project, with the names and the held-key ref of `orgTarget(org)`. The
|
|
5509
|
+
* admin token goes to `jobs:org:<slug>:adminToken` and nowhere else.
|
|
5510
|
+
*/
|
|
5511
|
+
async ownJobs(action, org, context) {
|
|
5512
|
+
const own = this.ports.ownServices;
|
|
5513
|
+
if (!own)
|
|
5514
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
5515
|
+
return provisionJobsHost(scopeFromOrg(context, {}), {
|
|
5516
|
+
coolify: limited(this.placedForCompany(own.provision.jobs, org, context), LIMITS.ownJobs),
|
|
5517
|
+
deliverSecret: this.ports.deliverSecret,
|
|
5518
|
+
readSecret: async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null,
|
|
5519
|
+
runtime: this.ports.runtime ?? {},
|
|
5520
|
+
}, orgTarget(org), {
|
|
5521
|
+
hostname: null,
|
|
5522
|
+
gitRef: action.desired.gitRef,
|
|
5523
|
+
project: { ensure: () => this.ensureCoolifyProject(org, context) },
|
|
5524
|
+
});
|
|
5525
|
+
}
|
|
5526
|
+
/**
|
|
5527
|
+
* `plan/own-diff.ts`'s `planOwnFiles`: the org's own files host in the
|
|
5528
|
+
* org's own project, trusting the company's issuer `https://auth.<domain>`.
|
|
5529
|
+
* Its ZITADEL project `<slug>-files` and introspection app are made in the
|
|
5530
|
+
* company's instance (`ownServices.zitadelFor`), never the shared one. The
|
|
5531
|
+
* bucket comes from the host's bucket port under the target's name. The
|
|
5532
|
+
* management key goes to `files:org:<slug>:managementKey` and the
|
|
5533
|
+
* introspection secret to `zitadel:org:<slug>:files:introspection`, both
|
|
5534
|
+
* once, and neither is recorded or returned.
|
|
5535
|
+
*/
|
|
5536
|
+
async ownFiles(action, org, context) {
|
|
5537
|
+
const own = this.ports.ownServices;
|
|
5538
|
+
if (!own)
|
|
5539
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
5540
|
+
const at = `coolify:own-files for ${org.slug}`;
|
|
5541
|
+
const zitadelOrgId = context.recordedIds()[ZITADEL_ORG_SCOPE_KEY];
|
|
5542
|
+
if (!zitadelOrgId)
|
|
5543
|
+
throw new Error(`${at}: the org's ZITADEL organisation is not recorded yet`);
|
|
5544
|
+
const { r2Buckets, r2Keys: _r2Keys, keysMint, readSecret, ...ownFiles } = own.provision.files;
|
|
5545
|
+
const files = {
|
|
5546
|
+
...ownFiles,
|
|
5547
|
+
coolify: limited(this.placedForCompany(ownFiles.coolify, org, context), LIMITS.ownFiles),
|
|
5548
|
+
};
|
|
5549
|
+
// Never the platform's credential in its place.
|
|
5550
|
+
if (!keysMint)
|
|
5551
|
+
throw new OwnServicesBlockedError('files', 'keysMint');
|
|
5552
|
+
if (!r2Buckets)
|
|
5553
|
+
throw new Error(`${at}: the host supplies no bucket port`);
|
|
5554
|
+
const target = orgTarget(org);
|
|
5555
|
+
let r2;
|
|
5556
|
+
try {
|
|
5557
|
+
r2 = await withTimeout(r2Buckets.ensureBucket(target.names.filesBucket), BUCKET_TIMEOUT_MS);
|
|
5558
|
+
}
|
|
5559
|
+
catch {
|
|
5560
|
+
// The cause is dropped: a host port's message is not ours to repeat.
|
|
5561
|
+
throw new Error(`${at}: the bucket ${target.names.filesBucket} could not be ensured`);
|
|
5562
|
+
}
|
|
5563
|
+
const issuer = ownHostnames(org).issuerUrl;
|
|
5564
|
+
const deps = {
|
|
5565
|
+
...files,
|
|
5566
|
+
// The platform's keys settings stay out of a company's containers; the minted keyring replaces them.
|
|
5567
|
+
keys: undefined,
|
|
5568
|
+
jobsSigningSecrets: undefined,
|
|
5569
|
+
r2,
|
|
5570
|
+
zitadel: own.zitadelFor({ issuerUrl: issuer, bearerRef: target.refs.instanceBearer }),
|
|
5571
|
+
zitadelForInstance: undefined,
|
|
5572
|
+
deliverSecret: this.ports.deliverSecret,
|
|
5573
|
+
runtime: {
|
|
5574
|
+
...files.runtime,
|
|
5575
|
+
onSecret: (value) => {
|
|
5576
|
+
this.handledSecrets.add(value);
|
|
5577
|
+
},
|
|
5578
|
+
},
|
|
5579
|
+
};
|
|
5580
|
+
const invalid = filesDepsInvalid(deps);
|
|
5581
|
+
if (invalid)
|
|
5582
|
+
throw new Error(`${at}: ${invalid}`);
|
|
5583
|
+
const scope = scopeFromOrg(context, {});
|
|
5584
|
+
const mintOwn = async () => {
|
|
5585
|
+
try {
|
|
5586
|
+
return await withTimeout(keysMint.mint(`files-${org.slug}`, randomUUID()), KEYS_MINT_TIMEOUT_MS);
|
|
5587
|
+
}
|
|
5588
|
+
catch (error) {
|
|
5589
|
+
// The cause is dropped: a mint client's message is not ours to repeat.
|
|
5590
|
+
throw new Error(error instanceof Error && error.message === 'timeout'
|
|
5591
|
+
? `${at}: the keys mint did not answer within ${KEYS_MINT_TIMEOUT_MS / 1000} seconds`
|
|
5592
|
+
: `${at}: the keys mint failed`);
|
|
5593
|
+
}
|
|
5594
|
+
};
|
|
5595
|
+
const { keys, guardId } = await resolveOwnFilesKeys(scope, {
|
|
5596
|
+
deliverSecret: deps.deliverSecret,
|
|
5597
|
+
readSecret: readSecret ?? (async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null),
|
|
5598
|
+
runtime: deps.runtime,
|
|
5599
|
+
}, target, mintOwn, keysMint.workerUrl, `own step ${at}`);
|
|
5600
|
+
return provisionFilesHost(scope, { ...deps, keys }, target, {
|
|
5601
|
+
keysGuardId: guardId,
|
|
5602
|
+
hostname: action.desired.hostname,
|
|
5603
|
+
gitRef: action.desired.gitRef,
|
|
5604
|
+
project: { ensure: () => this.ensureCoolifyProject(org, context) },
|
|
5605
|
+
issuer,
|
|
5606
|
+
zitadelOrg: { id: zitadelOrgId },
|
|
5607
|
+
zitadelProject: {
|
|
5608
|
+
name: `${org.slug}-files`,
|
|
5609
|
+
scopeKey: ZITADEL_FILES_PROJECT_SCOPE_KEY,
|
|
5610
|
+
},
|
|
5611
|
+
shareDomains: filesShareDomains([org]),
|
|
5612
|
+
bootstrapOperator: action.desired.bootstrapOperator,
|
|
5613
|
+
});
|
|
5614
|
+
}
|
|
5615
|
+
/**
|
|
5616
|
+
* `plan/own-diff.ts`'s `planOwnFilesR2`: the storage key of the org's own files bucket. A token
|
|
5617
|
+
* recorded as undelivered is deleted first (its secret cannot be read back, so it can never be
|
|
5618
|
+
* finished), then one is minted, registered as a secret, recorded by id as undelivered and
|
|
5619
|
+
* persisted, and only then sent to the files host, the same pair on every retry. The secret is
|
|
5620
|
+
* never recorded and never written to a Coolify env. The id is recorded once the host has it.
|
|
5621
|
+
*/
|
|
5622
|
+
async ownFilesR2(org, context) {
|
|
5623
|
+
const own = this.ports.ownServices;
|
|
5624
|
+
if (!own)
|
|
5625
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
5626
|
+
const at = `coolify:own-files-r2 for ${org.slug}`;
|
|
5627
|
+
const { r2Keys, runtime } = own.provision.files;
|
|
5628
|
+
if (!r2Keys)
|
|
5629
|
+
throw new OwnServicesBlockedError('files', 'r2Keys');
|
|
5630
|
+
const scoped = await own.portsFor(org, context.recordedIds());
|
|
5631
|
+
const organisation = scoped?.filesOrganisation;
|
|
5632
|
+
if (!organisation?.storeR2Credentials) {
|
|
5633
|
+
throw new Error(`${at}: the host gave the company no storeR2Credentials on its files port`);
|
|
5634
|
+
}
|
|
5635
|
+
const store = organisation.storeR2Credentials.bind(organisation);
|
|
5636
|
+
const bucket = orgTarget(org).names.filesBucket;
|
|
5637
|
+
const timeoutText = `did not answer within ${R2_KEY_TIMEOUT_MS / 1000} seconds`;
|
|
5638
|
+
const failure = (what, error) =>
|
|
5639
|
+
// The cause is dropped: a port's message is not ours to repeat, and may hold the secret.
|
|
5640
|
+
new Error(`${at}: ${what} ${error instanceof Error && error.message === 'timeout' ? timeoutText : 'failed'}`);
|
|
5641
|
+
const recorded = context.recordedIds();
|
|
5642
|
+
if (recorded[OWN_FILES_R2_SCOPE_KEY])
|
|
5643
|
+
return { resourceId: recorded[OWN_FILES_R2_SCOPE_KEY] };
|
|
5644
|
+
// A leftover token is deleted before anything is minted: never two live tokens.
|
|
5645
|
+
const leftover = recorded[OWN_FILES_R2_UNDELIVERED_SCOPE_KEY];
|
|
5646
|
+
if (leftover) {
|
|
5647
|
+
try {
|
|
5648
|
+
await withTimeout(r2Keys.deleteBucketKey(leftover), R2_KEY_TIMEOUT_MS);
|
|
5649
|
+
}
|
|
5650
|
+
catch (error) {
|
|
5651
|
+
if (!isNotFoundError(error)) {
|
|
5652
|
+
throw failure(`deleting the earlier storage key ${leftover}, so no new one was made:`, error);
|
|
5653
|
+
}
|
|
5654
|
+
}
|
|
5655
|
+
forgetScopeId(context, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY);
|
|
5656
|
+
await context.persistScopeIds();
|
|
5657
|
+
}
|
|
5658
|
+
let key;
|
|
5659
|
+
try {
|
|
5660
|
+
key = await withTimeout(r2Keys.mintBucketKey(bucket), R2_KEY_TIMEOUT_MS);
|
|
5661
|
+
}
|
|
5662
|
+
catch (error) {
|
|
5663
|
+
throw failure('the storage key mint', error);
|
|
5664
|
+
}
|
|
5665
|
+
const text = (value) => typeof value === 'string' && value.length > 0 && value.length <= 200;
|
|
5666
|
+
if (text(key?.secretAccessKey))
|
|
5667
|
+
this.handledSecrets.add(key.secretAccessKey);
|
|
5668
|
+
// A token id we can read is recorded even when the rest of the answer is unusable, so it is deleted next run.
|
|
5669
|
+
if (text(key?.tokenId)) {
|
|
5670
|
+
context.recordScopeId(OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, key.tokenId);
|
|
5671
|
+
await context.persistScopeIds();
|
|
5672
|
+
}
|
|
5673
|
+
if (!text(key?.accessKeyId) || !text(key?.secretAccessKey) || !text(key?.tokenId)) {
|
|
5674
|
+
throw new Error(`${at}: the storage key mint answered with an unusable key`);
|
|
5675
|
+
}
|
|
5676
|
+
const pair = {
|
|
5677
|
+
accessKeyId: key.accessKeyId,
|
|
5678
|
+
secretAccessKey: key.secretAccessKey,
|
|
5679
|
+
tokenId: key.tokenId,
|
|
5680
|
+
};
|
|
5681
|
+
const attempts = runtime?.deliverAttempts ?? 3;
|
|
5682
|
+
const sleep = runtime?.sleep ?? defaultSleep;
|
|
5683
|
+
for (let attempt = 1;; attempt += 1) {
|
|
5684
|
+
try {
|
|
5685
|
+
await withTimeout(store({ ...pair }), R2_KEY_TIMEOUT_MS);
|
|
5686
|
+
break;
|
|
5687
|
+
}
|
|
5688
|
+
catch (error) {
|
|
5689
|
+
if (attempt >= attempts) {
|
|
5690
|
+
const cause = redactSecrets(error instanceof Error ? error.message : String(error), [
|
|
5691
|
+
pair.secretAccessKey,
|
|
5692
|
+
pair.accessKeyId,
|
|
5693
|
+
]);
|
|
5694
|
+
throw new Error(`${at}: the storage key ${pair.tokenId} could not be delivered after ${attempts} tries; the next run deletes it and makes a new one (last try: ${cause})`);
|
|
5695
|
+
}
|
|
5696
|
+
await sleep(1000 * attempt);
|
|
5697
|
+
}
|
|
5698
|
+
}
|
|
5699
|
+
context.recordScopeId(OWN_FILES_R2_SCOPE_KEY, pair.tokenId);
|
|
5700
|
+
forgetScopeId(context, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY);
|
|
5701
|
+
await context.persistScopeIds();
|
|
5702
|
+
return { resourceId: pair.tokenId };
|
|
5703
|
+
}
|
|
5704
|
+
/**
|
|
5705
|
+
* `plan/own-diff.ts`'s `planOwnAuth`: the org's own sign-in at
|
|
5706
|
+
* `auth.<domain>`, in the org's own project, through the org's own edge
|
|
5707
|
+
* routed DNS and tunnel ports. The instance owner token goes to
|
|
5708
|
+
* `zitadel:org:<slug>:instanceBearer` and is never recorded. A changed
|
|
5709
|
+
* declared auth ref patches both git apps' branch and redeploys them; the
|
|
5710
|
+
* applied marker comes last, so a run that died halfway is planned again.
|
|
5711
|
+
*/
|
|
5712
|
+
async ownAuth(action, org, context) {
|
|
5713
|
+
const own = this.ports.ownServices;
|
|
5714
|
+
if (!own)
|
|
5715
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
5716
|
+
const hostname = action.desired.hostname;
|
|
5717
|
+
const gitRef = action.desired.gitRef;
|
|
5718
|
+
const hn = ownHostnames(org);
|
|
5719
|
+
const scope = scopeFromOrg(context, {});
|
|
5720
|
+
// The target (host, port, tls, user) is not secret; the platform's SMTP
|
|
5721
|
+
// password is, so the company's own token is the password instead.
|
|
5722
|
+
const { smtp, ...authDeps } = own.provision.auth;
|
|
5723
|
+
const deps = {
|
|
5724
|
+
...authDeps,
|
|
5725
|
+
...(smtp
|
|
5726
|
+
? { smtp: { host: smtp.host, port: smtp.port, tls: smtp.tls, user: smtp.user } }
|
|
5727
|
+
: {}),
|
|
5728
|
+
coolify: limited(this.placedForCompany(own.provision.auth.coolify, org, context), LIMITS.ownAuth),
|
|
5729
|
+
runtime: {
|
|
5730
|
+
...own.provision.auth.runtime,
|
|
5731
|
+
onSecret: (value) => {
|
|
5732
|
+
this.handledSecrets.add(value);
|
|
5733
|
+
},
|
|
5734
|
+
},
|
|
5735
|
+
};
|
|
5736
|
+
const before = scope.recordedIds();
|
|
5737
|
+
const refChanged = before[OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY] !== undefined &&
|
|
5738
|
+
before[OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY] !== gitRef;
|
|
5739
|
+
const gitApps = [
|
|
5740
|
+
before[PLATFORM_AUTH_LOGIN_SCOPE_KEY],
|
|
5741
|
+
before[PLATFORM_AUTH_BROKER_SCOPE_KEY],
|
|
5742
|
+
].filter((id) => Boolean(id));
|
|
5743
|
+
if (refChanged) {
|
|
5744
|
+
for (const id of gitApps)
|
|
5745
|
+
await deps.coolify.updateApplication(id, { git_branch: gitRef });
|
|
5746
|
+
}
|
|
5747
|
+
await this.ensureOwnAuthMailToken(org, hn.mailDomain, deps);
|
|
5748
|
+
const result = await provisionAuthStack(scope, deps, orgTarget(org), {
|
|
5749
|
+
hostname,
|
|
5750
|
+
apex: hn.zone,
|
|
5751
|
+
gitRef,
|
|
5752
|
+
project: { ensure: () => this.ensureCoolifyProject(org, context) },
|
|
5753
|
+
network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:auth`),
|
|
5754
|
+
smtpSender: `noreply@${hn.mailDomain}`,
|
|
5755
|
+
defaultRedirectUri: `https://${agoraHostnameFor(org)}`,
|
|
5756
|
+
ownerCompany: null,
|
|
5757
|
+
});
|
|
5758
|
+
if (refChanged) {
|
|
5759
|
+
const now = scope.recordedIds();
|
|
5760
|
+
const brokerDeployed = Boolean(now[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]);
|
|
5761
|
+
for (const id of gitApps) {
|
|
5762
|
+
// The broker crash-loops until its inputs exist; it was never deployed, so it is not now.
|
|
5763
|
+
if (id === now[PLATFORM_AUTH_BROKER_SCOPE_KEY] && !brokerDeployed)
|
|
5764
|
+
continue;
|
|
5765
|
+
await deps.coolify.deploy(id, true);
|
|
5766
|
+
}
|
|
5767
|
+
}
|
|
5768
|
+
scope.recordScopeId(OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
|
|
5769
|
+
scope.recordScopeId(OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
5770
|
+
scope.recordScopeId(OWN_SERVICES_APPLIED_SCOPE_KEY, org.id);
|
|
5771
|
+
await scope.persist();
|
|
5772
|
+
return result;
|
|
5773
|
+
}
|
|
5774
|
+
/**
|
|
5775
|
+
* `plan/own-diff.ts`'s `planOwnMail`: the org's own Stalwart in the org's own
|
|
5776
|
+
* project, publishing its port 25 on one host port of the server (the shared
|
|
5777
|
+
* relay is on another network and dials the server's address) and a directory
|
|
5778
|
+
* that trusts the org's own sign-in. The admin key goes to
|
|
5779
|
+
* `orgTarget(org).refs.mailAdminKey` and nowhere else. The address the relay
|
|
5780
|
+
* routes to is recorded last, and the step's marker after it.
|
|
5781
|
+
*/
|
|
5782
|
+
async ownMail(action, org, context) {
|
|
5783
|
+
const own = this.ports.ownServices;
|
|
5784
|
+
if (!own)
|
|
5785
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
5786
|
+
const hn = ownHostnames(org);
|
|
5787
|
+
const target = orgTarget(org);
|
|
5788
|
+
const scope = scopeFromOrg(context, {});
|
|
5789
|
+
const issuerUrl = hn.issuerUrl;
|
|
5790
|
+
const smtpPort = action.desired.smtpPort;
|
|
5791
|
+
if (!isOwnMailSmtpPort(smtpPort)) {
|
|
5792
|
+
throw new Error(`coolify:own-mail for ${org.slug}: the plan gives no valid mail port`);
|
|
5793
|
+
}
|
|
5794
|
+
const bind = this.ports.mailBindAddress;
|
|
5795
|
+
if (bind !== undefined && !isIpv4Address(bind)) {
|
|
5796
|
+
throw new Error(`coolify:own-mail for ${org.slug}: the mail bind address is not an IPv4 address`);
|
|
5797
|
+
}
|
|
5798
|
+
const coolify = this.placedForCompany(own.provision.mail.coolify, org, context);
|
|
5799
|
+
const result = await provisionMailServer(scope, {
|
|
5800
|
+
...own.provision.mail,
|
|
5801
|
+
coolify: limited(coolify, LIMITS.ownMail),
|
|
5802
|
+
deliverSecret: (ref, secret) => {
|
|
5803
|
+
this.handledSecrets.add(secret);
|
|
5804
|
+
return this.ports.deliverSecret(ref, secret);
|
|
5805
|
+
},
|
|
5806
|
+
stalwartForInstance: (input) => {
|
|
5807
|
+
this.handledSecrets.add(input.password);
|
|
5808
|
+
return (own.provision.mail.stalwartForInstance ?? defaultStalwartForInstance)(input);
|
|
5809
|
+
},
|
|
5810
|
+
zitadel: own.zitadelFor({ issuerUrl, bearerRef: target.refs.instanceBearer }),
|
|
5811
|
+
zitadelForInstance: undefined,
|
|
5812
|
+
}, target, {
|
|
5813
|
+
hostname: action.desired.hostname,
|
|
5814
|
+
apex: hn.mailDomain,
|
|
5815
|
+
zone: hn.zone,
|
|
5816
|
+
mailHost: action.desired.mailHost,
|
|
5817
|
+
imageTag: action.desired.gitRef,
|
|
5818
|
+
hostPorts: bind ? `${bind}:${smtpPort}:25` : `${smtpPort}:25`,
|
|
5819
|
+
issuerUrl,
|
|
5820
|
+
mxRecord: null,
|
|
5821
|
+
project: { ensure: () => this.ensureCoolifyProject(org, context) },
|
|
5822
|
+
network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:mail`),
|
|
5823
|
+
});
|
|
5824
|
+
// The relay is on the platform's network, so it reaches the company's Stalwart through the server's
|
|
5825
|
+
// address and the port published above. The port is recorded first, then the address the relay is given.
|
|
5826
|
+
const mailServer = (await coolify.listServers()).find((s) => s.uuid === context.recordedIds()[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
|
|
5827
|
+
const wanted = this.ports.mailRelayTarget?.({ serverAddress: mailServer?.ip, port: smtpPort }) ??
|
|
5828
|
+
(bind ? `${bind}:${smtpPort}` : mailServer?.ip ? `${mailServer.ip}:${smtpPort}` : null);
|
|
5829
|
+
const relayTarget = wanted ? normalizeMailTarget(wanted) : null;
|
|
5830
|
+
if (!relayTarget) {
|
|
5831
|
+
throw new Error(`coolify:own-mail for ${org.slug}: the mail server has no address the relay can dial`);
|
|
5832
|
+
}
|
|
5833
|
+
scope.recordScopeId(OWN_MAIL_SMTP_PORT_SCOPE_KEY, String(smtpPort));
|
|
5834
|
+
scope.recordScopeId(OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, relayTarget);
|
|
5835
|
+
await scope.persist();
|
|
5836
|
+
return result;
|
|
5837
|
+
}
|
|
5838
|
+
/**
|
|
5839
|
+
* `plan/own-diff.ts`'s `planOwnSfu`: the org's own LiveKit server in the
|
|
5840
|
+
* org's project, on the ports of its slot. The company's key pair goes to
|
|
5841
|
+
* `livekit:org:<slug>:keys` and the app's env, nowhere else.
|
|
5842
|
+
*/
|
|
5843
|
+
async ownSfu(action, org, context) {
|
|
5844
|
+
const own = this.ports.ownServices;
|
|
5845
|
+
if (!own)
|
|
5846
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
5847
|
+
const scope = scopeFromOrg(context, {});
|
|
5848
|
+
const { sfu } = own.provision;
|
|
5849
|
+
return provisionSfuApp(scope, {
|
|
5850
|
+
coolify: limited(this.placedForCompany(sfu.coolify, org, context), LIMITS.ownSfu),
|
|
5851
|
+
network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:sfu`),
|
|
5852
|
+
deliverSecret: sfu.deliverSecret,
|
|
5853
|
+
readSecret: async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null,
|
|
5854
|
+
runtime: this.ports.runtime ?? {},
|
|
5855
|
+
...(sfu.mintLivekitCredentials
|
|
5856
|
+
? { mintLivekitCredentials: sfu.mintLivekitCredentials }
|
|
5857
|
+
: {}),
|
|
5858
|
+
onSecret: (value) => this.handledSecrets.add(value),
|
|
5859
|
+
}, orgTarget(org), {
|
|
5860
|
+
slot: action.desired.slot,
|
|
5861
|
+
hostname: action.desired.hostname,
|
|
5862
|
+
apex: ownHostnames(org).zone,
|
|
5863
|
+
version: action.desired.version,
|
|
5864
|
+
project: { ensure: () => this.ensureCoolifyProject(org, context) },
|
|
5865
|
+
});
|
|
5866
|
+
}
|
|
5025
5867
|
async jobsRegistration(action, org, context) {
|
|
5026
5868
|
const scheduler = await this.jobsPort(org);
|
|
5027
5869
|
const name = action.desired.name;
|
|
@@ -5138,15 +5980,19 @@ export class FoundryStepExecutor {
|
|
|
5138
5980
|
// login app (the same container the platform auth tunnel routes to), not
|
|
5139
5981
|
// through Coolify's proxy, which has no route for it.
|
|
5140
5982
|
const loginHost = action.desired.loginHost ?? null;
|
|
5983
|
+
// A hosting entry's names (and its wildcard) go straight to its container on the company network:
|
|
5984
|
+
// the container carries `traefik.enable=false`, so the proxy has no route for it.
|
|
5985
|
+
const entryOrigins = new Map(entryRoutes(org).map((r) => [r.hostname, r.service]));
|
|
5141
5986
|
const ingress = tunnelIngressHostnames(org, loginHost).map((hostname) => ({
|
|
5142
5987
|
hostname,
|
|
5143
5988
|
service: hostname === loginHost
|
|
5144
5989
|
? `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}`
|
|
5145
|
-
: COOLIFY_PROXY_ORIGIN,
|
|
5990
|
+
: (entryOrigins.get(hostname) ?? COOLIFY_PROXY_ORIGIN),
|
|
5146
5991
|
}));
|
|
5147
5992
|
await this.tunnelPort().putTunnelConfig(tunnel.id, {
|
|
5148
5993
|
ingress: [...ingress, { service: 'http_status:404' }],
|
|
5149
5994
|
});
|
|
5995
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, entryRoutesMarker(org));
|
|
5150
5996
|
// #235: a connector for this org's own tunnel, the same call every
|
|
5151
5997
|
// platform tunnel step already makes (Decided, William, 2026-10-01:
|
|
5152
5998
|
// one connector per org tunnel, never a shared tunnel with appended
|
|
@@ -5245,8 +6091,77 @@ export class FoundryStepExecutor {
|
|
|
5245
6091
|
context.recordScopeId(CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY, '');
|
|
5246
6092
|
}
|
|
5247
6093
|
}
|
|
6094
|
+
await this.publishOikosEntryRecords(org, tunnel.id, previousIds, context);
|
|
5248
6095
|
return { resourceId: tunnel.id };
|
|
5249
6096
|
}
|
|
6097
|
+
/**
|
|
6098
|
+
* The entry names a company has in the platform's `oikos.host` zone (a company with no domain of its own):
|
|
6099
|
+
* one proxied CNAME each, to this company's tunnel, which routes them straight to the entry container. A
|
|
6100
|
+
* specific record beats the shared `*.oikos.host` wildcard, so the shared ingress stops carrying the name
|
|
6101
|
+
* without being edited. The company writes only its own `<slug>.oikos.host` ({@link ownsOikosName}); an
|
|
6102
|
+
* A or AAAA record, or a CNAME that points anywhere but this tunnel, an earlier id of it or the shared
|
|
6103
|
+
* wildcard's target, blocks the step. A name the company no longer declares has its record deleted when
|
|
6104
|
+
* it still points at this tunnel. The names are recorded only after every record is in place, so a failure
|
|
6105
|
+
* halfway is planned again.
|
|
6106
|
+
*/
|
|
6107
|
+
async publishOikosEntryRecords(org, tunnelId, previousIds, context) {
|
|
6108
|
+
const wanted = entryOikosHostnames(org);
|
|
6109
|
+
const recorded = context.recordedIds()[CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY];
|
|
6110
|
+
const previous = recorded ? recorded.split(',') : [];
|
|
6111
|
+
if (wanted.length === 0 && previous.length === 0)
|
|
6112
|
+
return;
|
|
6113
|
+
for (const name of wanted) {
|
|
6114
|
+
if (!ownsOikosName(org, name)) {
|
|
6115
|
+
throw new Error(`cloudflare:tunnel for ${org.slug}: ${name} is not a name this company may write in the ${NO_DOMAIN_ZONE} zone`);
|
|
6116
|
+
}
|
|
6117
|
+
}
|
|
6118
|
+
const zone = await this.zonePort().findZoneByApex(NO_DOMAIN_ZONE);
|
|
6119
|
+
if (!zone) {
|
|
6120
|
+
throw new Error(`cloudflare:tunnel for ${org.slug}: no Cloudflare zone found for ${NO_DOMAIN_ZONE} to point its entry names at`);
|
|
6121
|
+
}
|
|
6122
|
+
const dns = this.dnsPort();
|
|
6123
|
+
const dnsTarget = `${tunnelId}.cfargotunnel.com`;
|
|
6124
|
+
const ownTargets = new Set([
|
|
6125
|
+
dnsTarget,
|
|
6126
|
+
...[...previousIds].map((id) => `${id}.cfargotunnel.com`),
|
|
6127
|
+
]);
|
|
6128
|
+
const [shared] = await dns.findDnsRecord(zone.name, `*.${NO_DOMAIN_ZONE}`, 'CNAME');
|
|
6129
|
+
const writes = [];
|
|
6130
|
+
const conflicts = [];
|
|
6131
|
+
for (const name of wanted) {
|
|
6132
|
+
const [a] = await dns.findDnsRecord(zone.name, name, 'A');
|
|
6133
|
+
const [aaaa] = await dns.findDnsRecord(zone.name, name, 'AAAA');
|
|
6134
|
+
if (a ?? aaaa) {
|
|
6135
|
+
conflicts.push(`${name} already has an ${(a ?? aaaa)?.type} record`);
|
|
6136
|
+
continue;
|
|
6137
|
+
}
|
|
6138
|
+
const [cname] = await dns.findDnsRecord(zone.name, name, 'CNAME');
|
|
6139
|
+
const record = { type: 'CNAME', name, content: dnsTarget, proxied: true };
|
|
6140
|
+
if (!cname)
|
|
6141
|
+
writes.push(() => dns.createDnsRecord(zone.name, record));
|
|
6142
|
+
else if (cname.content === dnsTarget)
|
|
6143
|
+
continue;
|
|
6144
|
+
else if (ownTargets.has(cname.content) || cname.content === shared?.content) {
|
|
6145
|
+
writes.push(() => dns.updateDnsRecord(zone.name, cname.id, record));
|
|
6146
|
+
}
|
|
6147
|
+
else
|
|
6148
|
+
conflicts.push(`${name} is already a CNAME to ${cname.content}`);
|
|
6149
|
+
}
|
|
6150
|
+
if (conflicts.length > 0) {
|
|
6151
|
+
throw new Error(`cloudflare:tunnel for ${org.slug}: will not point its tunnel at every entry name -- ${conflicts.join('; ')}; remove the conflicting record first`);
|
|
6152
|
+
}
|
|
6153
|
+
for (const write of writes)
|
|
6154
|
+
await write();
|
|
6155
|
+
for (const name of previous.filter((n) => !wanted.includes(n))) {
|
|
6156
|
+
if (!ownsOikosName(org, name))
|
|
6157
|
+
continue;
|
|
6158
|
+
for (const record of await dns.findDnsRecord(zone.name, name, 'CNAME')) {
|
|
6159
|
+
if (ownTargets.has(record.content))
|
|
6160
|
+
await dns.deleteDnsRecord(zone.name, record.id);
|
|
6161
|
+
}
|
|
6162
|
+
}
|
|
6163
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, wanted.join(','));
|
|
6164
|
+
}
|
|
5250
6165
|
/**
|
|
5251
6166
|
* #236: when this tunnel's DNS was last published under a domain that
|
|
5252
6167
|
* differs from the one `org` now declares, deletes every CNAME in the
|
|
@@ -5563,7 +6478,7 @@ export class FoundryStepExecutor {
|
|
|
5563
6478
|
}
|
|
5564
6479
|
const mailOrg = mailOrgFor(org, usesDomain, mode);
|
|
5565
6480
|
const result = await this.ports.mail.applyOrgMail(mailOrg, options);
|
|
5566
|
-
await this.publishMailDnsRecords(org, result);
|
|
6481
|
+
await this.publishMailDnsRecords(org, result, context);
|
|
5567
6482
|
await this.deleteStaleLettermintDomain(context, result.domain, platformApex);
|
|
5568
6483
|
context.recordScopeId(MAIL_DOMAIN_SCOPE_KEY, result.domain);
|
|
5569
6484
|
await this.ensureOwnerMailbox(org, mailOrg, options, context);
|
|
@@ -5694,15 +6609,49 @@ export class FoundryStepExecutor {
|
|
|
5694
6609
|
* call, since Stalwart mints the key on a background task; the next apply
|
|
5695
6610
|
* publishes it.
|
|
5696
6611
|
*/
|
|
5697
|
-
async publishMailDnsRecords(org, result) {
|
|
6612
|
+
async publishMailDnsRecords(org, result, context) {
|
|
5698
6613
|
const zone = await this.zonePort().findZoneByApex(result.domain);
|
|
5699
6614
|
if (!zone) {
|
|
5700
6615
|
throw new Error(`coolify:mail for ${org.slug}: no Cloudflare zone found for ${result.domain}`);
|
|
5701
6616
|
}
|
|
6617
|
+
if (ownsServices(org) && result.domain.toLowerCase() === `${org.slug}.${NO_DOMAIN_ZONE}`) {
|
|
6618
|
+
await this.ensureOwnWebAddressRecord(org, zone.name, context);
|
|
6619
|
+
}
|
|
5702
6620
|
for (const record of result.records) {
|
|
5703
6621
|
await this.upsertMailDnsRecord(zone.name, record);
|
|
5704
6622
|
}
|
|
5705
6623
|
}
|
|
6624
|
+
/**
|
|
6625
|
+
* A company without a domain keeps its web address at `<slug>.oikos.host`,
|
|
6626
|
+
* which only the shared `*.oikos.host` wildcard answers. A wildcard applies
|
|
6627
|
+
* only to a name that holds no record of its own, so the MX and TXT records
|
|
6628
|
+
* of the company's mail domain on that same name would take the web address
|
|
6629
|
+
* away. This writes the name's own proxied CNAME, to wherever the wildcard
|
|
6630
|
+
* points, before any mail record. Found by name and type, so a rerun after a
|
|
6631
|
+
* half-finished apply never doubles it. Cloudflare lists only A, AAAA and
|
|
6632
|
+
* CNAME as clashing with a CNAME, so MX and TXT sit beside it.
|
|
6633
|
+
* https://developers.cloudflare.com/dns/manage-dns-records/reference/wildcard-dns-records/
|
|
6634
|
+
* https://developers.cloudflare.com/dns/manage-dns-records/troubleshooting/records-with-same-name/
|
|
6635
|
+
*/
|
|
6636
|
+
async ensureOwnWebAddressRecord(org, zone, context) {
|
|
6637
|
+
const dns = this.dnsPort();
|
|
6638
|
+
const name = `${org.slug}.${NO_DOMAIN_ZONE}`;
|
|
6639
|
+
// The company's own tunnel already carries this name (an entry is served at it): that record is ours.
|
|
6640
|
+
const own = context.recordedIds()[CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY];
|
|
6641
|
+
if (own?.split(',').includes(name))
|
|
6642
|
+
return;
|
|
6643
|
+
const [wildcard] = await dns.findDnsRecord(zone, `*.${NO_DOMAIN_ZONE}`, 'CNAME');
|
|
6644
|
+
if (!wildcard) {
|
|
6645
|
+
throw new Error(`coolify:mail for ${org.slug}: the zone ${zone} has no *.${NO_DOMAIN_ZONE} record to copy, so ${name} would stop answering once its mail records exist`);
|
|
6646
|
+
}
|
|
6647
|
+
const record = { type: 'CNAME', name, content: wildcard.content, proxied: true };
|
|
6648
|
+
const [existing] = await dns.findDnsRecord(zone, name, 'CNAME');
|
|
6649
|
+
if (!existing)
|
|
6650
|
+
await dns.createDnsRecord(zone, record);
|
|
6651
|
+
else if (existing.content !== wildcard.content) {
|
|
6652
|
+
await dns.updateDnsRecord(zone, existing.id, record);
|
|
6653
|
+
}
|
|
6654
|
+
}
|
|
5706
6655
|
/**
|
|
5707
6656
|
* Creates or updates one mail record (Stalwart's or Lettermint's).
|
|
5708
6657
|
*
|
|
@@ -5759,6 +6708,11 @@ export class FoundryStepExecutor {
|
|
|
5759
6708
|
* #491: when `mailDomain` is the platform's own apex, this must never
|
|
5760
6709
|
* delete the platform's own project (the delete loop skips it by id) or
|
|
5761
6710
|
* the platform's own domain (returns before the domain lookup/delete).
|
|
6711
|
+
*
|
|
6712
|
+
* An org whose own sign-in is applied keeps its `<slug>-auth` project, and
|
|
6713
|
+
* keeps the Lettermint domain when that is the domain the sign-in sends
|
|
6714
|
+
* from. Every other org deletes `<slug>-auth` too, which only a failed
|
|
6715
|
+
* first sign-in run can have left behind.
|
|
5762
6716
|
*/
|
|
5763
6717
|
async parkLettermintMail(org, mailOrg, context, platformApex, platformLettermintProjectId) {
|
|
5764
6718
|
const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
@@ -5768,7 +6722,13 @@ export class FoundryStepExecutor {
|
|
|
5768
6722
|
// #345: `mailOrg.domain` is absent for a level-1 org -- the previously
|
|
5769
6723
|
// recorded mail domain is the one `MailPort.removeOrgRelay` needs.
|
|
5770
6724
|
await this.ports.mail.removeOrgRelay({ ...mailOrg, domain: mailDomain });
|
|
6725
|
+
// An own org's sign-in sends its mail through `<slug>-auth` and the domain it
|
|
6726
|
+
// applied at; parking the org's mail leaves both, or the sign-in can no
|
|
6727
|
+
// longer send (its held token would still resolve, so nothing re-mints it).
|
|
6728
|
+
const ownSignIn = ownsServices(org) && ownAuthApplied(org, context.recordedIds());
|
|
5771
6729
|
const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
|
|
6730
|
+
if (!ownSignIn)
|
|
6731
|
+
names.add(`${org.slug}-auth`);
|
|
5772
6732
|
const projects = await this.ports.lettermint.listProjects();
|
|
5773
6733
|
for (const project of projects) {
|
|
5774
6734
|
if (names.has(project.name) && project.id !== platformLettermintProjectId) {
|
|
@@ -5777,6 +6737,8 @@ export class FoundryStepExecutor {
|
|
|
5777
6737
|
}
|
|
5778
6738
|
if (isPlatformApexOrg)
|
|
5779
6739
|
return;
|
|
6740
|
+
if (ownSignIn && mailDomain.toLowerCase() === ownHostnames(org).mailDomain.toLowerCase())
|
|
6741
|
+
return;
|
|
5780
6742
|
const domain = await this.findExactLettermintDomain(mailDomain);
|
|
5781
6743
|
if (domain)
|
|
5782
6744
|
await this.ports.lettermint.deleteDomain(domain.id);
|
|
@@ -6711,6 +7673,29 @@ export class FoundryStepExecutor {
|
|
|
6711
7673
|
throw error;
|
|
6712
7674
|
}
|
|
6713
7675
|
}
|
|
7676
|
+
// The entry names it has in the platform's oikos.host zone (its own `<slug>.oikos.host` only).
|
|
7677
|
+
const oikosNames = (recordedIds[CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY] ?? '')
|
|
7678
|
+
.split(',')
|
|
7679
|
+
.filter((name) => name !== '' && ownsOikosName(org, name));
|
|
7680
|
+
if (oikosNames.length > 0) {
|
|
7681
|
+
try {
|
|
7682
|
+
const zone = await this.zonePort().findZoneByApex(NO_DOMAIN_ZONE);
|
|
7683
|
+
if (zone) {
|
|
7684
|
+
const dns = this.dnsPort();
|
|
7685
|
+
for (const name of oikosNames) {
|
|
7686
|
+
for (const record of await dns.findDnsRecord(zone.name, name, 'CNAME')) {
|
|
7687
|
+
if (dnsTargets.has(record.content)) {
|
|
7688
|
+
await dns.deleteDnsRecord(zone.name, record.id);
|
|
7689
|
+
}
|
|
7690
|
+
}
|
|
7691
|
+
}
|
|
7692
|
+
}
|
|
7693
|
+
}
|
|
7694
|
+
catch (error) {
|
|
7695
|
+
if (!isNotFoundError(error))
|
|
7696
|
+
throw error;
|
|
7697
|
+
}
|
|
7698
|
+
}
|
|
6714
7699
|
await this.tunnelPort().deleteTunnel(target.id);
|
|
6715
7700
|
return;
|
|
6716
7701
|
}
|
|
@@ -6779,6 +7764,23 @@ export class FoundryStepExecutor {
|
|
|
6779
7764
|
case 'coolify:database':
|
|
6780
7765
|
await this.ports.coolify.deleteDatabase(target.id);
|
|
6781
7766
|
return;
|
|
7767
|
+
case 'coolify:own-files-r2': {
|
|
7768
|
+
// An own files bucket's storage key; a 404 is caught below as already gone.
|
|
7769
|
+
const r2Keys = this.ports.ownServices?.provision.files.r2Keys;
|
|
7770
|
+
if (!r2Keys) {
|
|
7771
|
+
throw new Error(`coolify:own-files-r2 for ${org.slug}: this host supplies no R2 key port, cannot delete token ${target.id}`);
|
|
7772
|
+
}
|
|
7773
|
+
try {
|
|
7774
|
+
await withTimeout(r2Keys.deleteBucketKey(target.id), R2_KEY_TIMEOUT_MS);
|
|
7775
|
+
}
|
|
7776
|
+
catch (error) {
|
|
7777
|
+
if (error instanceof Error && error.message === 'timeout') {
|
|
7778
|
+
throw new Error(`coolify:own-files-r2 for ${org.slug}: deleting token ${target.id} did not answer within ${R2_KEY_TIMEOUT_MS / 1000} seconds`);
|
|
7779
|
+
}
|
|
7780
|
+
throw error;
|
|
7781
|
+
}
|
|
7782
|
+
return;
|
|
7783
|
+
}
|
|
6782
7784
|
case 'zitadel:app': {
|
|
6783
7785
|
const zitadelOrgId = recordedIds.zitadel_org;
|
|
6784
7786
|
const projectId = recordedIds.zitadel_project;
|