@wtfalch/foundry 0.34.0 → 0.35.2
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 +20 -0
- package/dist/bootstrap/versions.js +46 -0
- package/dist/calls-sfu.js +3 -3
- package/dist/index.d.ts +9 -4
- package/dist/index.js +5 -2
- package/dist/migrations/0053_heartbeats_provider.sql +9 -0
- package/dist/migrations/0053_org_version_pin.sql +6 -0
- package/dist/org.d.ts +60 -14
- package/dist/org.js +118 -28
- 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.js +3 -3
- package/dist/plan/cloudflared-connector.js +2 -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 +17 -8
- package/dist/plan/diff.js +131 -131
- package/dist/plan/edge-access.d.ts +15 -0
- package/dist/plan/edge-access.js +21 -2
- package/dist/plan/executor.d.ts +166 -7
- package/dist/plan/executor.js +547 -106
- 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 +5 -0
- package/dist/plan/held-key-refs.js +5 -0
- package/dist/plan/hostnames.d.ts +10 -0
- package/dist/plan/hostnames.js +14 -0
- package/dist/plan/index.d.ts +5 -0
- package/dist/plan/index.js +3 -0
- package/dist/plan/live.d.ts +6 -0
- package/dist/plan/live.js +3 -2
- package/dist/plan/locked-hostnames.d.ts +2 -1
- package/dist/plan/locked-hostnames.js +16 -3
- package/dist/plan/login-host.d.ts +9 -20
- package/dist/plan/login-host.js +10 -25
- package/dist/plan/mail-relay.d.ts +48 -29
- package/dist/plan/mail-relay.js +145 -68
- 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 +8 -2
- package/dist/plan/org-names.js +57 -1
- package/dist/plan/own-diff.d.ts +9 -8
- package/dist/plan/own-diff.js +42 -34
- package/dist/plan/own-hostnames.d.ts +27 -0
- package/dist/plan/own-hostnames.js +24 -0
- package/dist/plan/own-limits.d.ts +198 -7
- package/dist/plan/own-limits.js +163 -32
- package/dist/plan/own-services.js +3 -1
- package/dist/plan/own-sfu.js +3 -2
- package/dist/plan/plan-apply.d.ts +47 -7
- package/dist/plan/plan-apply.js +184 -38
- package/dist/plan/platform-diff.d.ts +133 -17
- package/dist/plan/platform-diff.js +231 -68
- package/dist/plan/platform-org.d.ts +79 -10
- package/dist/plan/platform-org.js +93 -13
- package/dist/plan/platform-providers.d.ts +118 -73
- package/dist/plan/platform-providers.js +799 -241
- 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/zitadel-login-policy.d.ts +1 -1
- package/dist/plan/zitadel-login-policy.js +76 -8
- package/dist/platform.d.ts +32 -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 +25 -3
- package/dist/store/postgres.js +43 -1
- package/dist/store/schema.d.ts +36 -0
- package/dist/store/schema.js +4 -0
- package/package.json +3 -3
- package/versions.digests.json +11 -0
package/dist/plan/executor.js
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
1
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
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';
|
|
12
|
+
import { recordBuiltCommit } from './build-record.js';
|
|
9
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';
|
|
@@ -22,18 +28,21 @@ import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED
|
|
|
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
30
|
import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, wgetProbe, } from './health-check-settings.js';
|
|
25
|
-
import {
|
|
31
|
+
import { PROXY_LABEL_TOKEN_REF } from './held-key-refs.js';
|
|
32
|
+
import { appHostname, cmsStudioHostnameFor, coolifyTunnelDomains, entryHostnames, siteHostnameFor, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
26
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';
|
|
28
|
-
import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY } from './mail-relay.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';
|
|
29
37
|
import { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, } from './own-diff.js';
|
|
30
|
-
import {
|
|
38
|
+
import { NO_DOMAIN_ZONE, ownHostnames } from './own-hostnames.js';
|
|
39
|
+
import { LIMITS, limited } from './own-limits.js';
|
|
31
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';
|
|
32
41
|
import { ownSfuUrls, provisionSfuApp } from './own-sfu.js';
|
|
33
42
|
import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
|
|
34
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';
|
|
35
|
-
import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
|
|
36
|
-
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';
|
|
37
46
|
import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
|
|
38
47
|
import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
|
|
39
48
|
/**
|
|
@@ -119,14 +128,13 @@ function normalizedLoginBaseUri(uri) {
|
|
|
119
128
|
* `cms.<domain>` -- the cms studio's own hostname (`cmsSite` below), apart
|
|
120
129
|
* from the public site itself at `org.domain` (used directly there). Called
|
|
121
130
|
* from `cmsSite` and from `zitadelApp` for the fixed `cms` client (#139),
|
|
122
|
-
* both of which only run once `org.site.enabled` is true
|
|
123
|
-
*
|
|
124
|
-
* {@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`).
|
|
125
133
|
*/
|
|
126
134
|
// #541: exported so `health-targets.ts` reuses this hostname rule rather
|
|
127
135
|
// than restating it; nothing else about this function changes.
|
|
128
136
|
export function cmsHostnameFor(org) {
|
|
129
|
-
return
|
|
137
|
+
return cmsStudioHostnameFor(org);
|
|
130
138
|
}
|
|
131
139
|
/**
|
|
132
140
|
* #145's "Decided" (2026-09-27): the calls service's own public hostname.
|
|
@@ -164,8 +172,11 @@ export function healthUrls(org) {
|
|
|
164
172
|
];
|
|
165
173
|
});
|
|
166
174
|
}
|
|
167
|
-
if (org.site.enabled
|
|
168
|
-
return [
|
|
175
|
+
if (org.site.enabled) {
|
|
176
|
+
return [
|
|
177
|
+
`https://${siteHostnameFor(org)}/api/health`,
|
|
178
|
+
`https://${cmsHostnameFor(org)}/api/health`,
|
|
179
|
+
];
|
|
169
180
|
}
|
|
170
181
|
return [];
|
|
171
182
|
}
|
|
@@ -192,7 +203,7 @@ function mailOrgFor(org, usesDomain, mode) {
|
|
|
192
203
|
* https://github.com/wtfalch/foundry/issues/71) names this same constant and
|
|
193
204
|
* value.
|
|
194
205
|
*/
|
|
195
|
-
const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
|
|
206
|
+
export const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
|
|
196
207
|
/** The one Access policy foundry puts on a platform Access app (#84). */
|
|
197
208
|
export const ACCESS_POLICY_NAME = 'foundry: allowed emails';
|
|
198
209
|
/**
|
|
@@ -306,7 +317,13 @@ const BOULE_PORT = 3000;
|
|
|
306
317
|
* it from this file, the same way `hostnameFor`'s hostname rule is
|
|
307
318
|
* restated rather than shared across the two files.
|
|
308
319
|
*/
|
|
309
|
-
|
|
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';
|
|
310
327
|
/**
|
|
311
328
|
* `cmsSite` provisions two applications from one `cms:site` step, but
|
|
312
329
|
* `execute`'s own return value auto-records only one `resourceId`, under
|
|
@@ -324,6 +341,8 @@ const CMS_APP_SCOPE_KEY = 'coolify_cms_app';
|
|
|
324
341
|
const SITE_APP_SCOPE_KEY = 'coolify_site_app';
|
|
325
342
|
/** #445: the one Coolify server every resource of this org lands on, chosen once and recorded. */
|
|
326
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';
|
|
327
346
|
/**
|
|
328
347
|
* #117: the cms app's own Postgres database (`<org.slug>-cms-db`), found or
|
|
329
348
|
* created inside `cmsSite` the same way `coolifyDatabase` does for the org's
|
|
@@ -606,8 +625,8 @@ const CHAT_SERVICE_DB_SCOPE_KEY = 'coolify_chat_service_database';
|
|
|
606
625
|
*/
|
|
607
626
|
const CHAT_REKEY_SYNAPSE_SCOPE_KEY = 'coolify_chat_rekey_synapse';
|
|
608
627
|
const CHAT_REKEY_MAS_SCOPE_KEY = 'coolify_chat_rekey_mas';
|
|
609
|
-
const CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY = 'coolify_chat_rekey_synapse_database';
|
|
610
|
-
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';
|
|
611
630
|
/**
|
|
612
631
|
* Set once {@link ChatRekeyPort.copyHomeserver} returns, so a retried or
|
|
613
632
|
* repeated apply never copies the same history twice -- the copy itself is
|
|
@@ -800,8 +819,47 @@ function blockedPort(service, ref) {
|
|
|
800
819
|
},
|
|
801
820
|
});
|
|
802
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
|
+
}
|
|
803
862
|
export class FoundryStepExecutor {
|
|
804
|
-
ports;
|
|
805
863
|
edgeDns;
|
|
806
864
|
edgeTunnels;
|
|
807
865
|
edgeAccess;
|
|
@@ -810,8 +868,37 @@ export class FoundryStepExecutor {
|
|
|
810
868
|
edgeWriteSlot;
|
|
811
869
|
/** Secrets this executor minted or was handed; see `StepExecutor.secretsHandled`. */
|
|
812
870
|
handledSecrets = new Set();
|
|
871
|
+
ports;
|
|
872
|
+
get rawCoolify() {
|
|
873
|
+
return this.ports.coolify;
|
|
874
|
+
}
|
|
813
875
|
constructor(ports) {
|
|
814
|
-
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
|
+
});
|
|
815
902
|
}
|
|
816
903
|
secretsHandled() {
|
|
817
904
|
return [...this.handledSecrets];
|
|
@@ -895,6 +982,18 @@ export class FoundryStepExecutor {
|
|
|
895
982
|
throw e;
|
|
896
983
|
}
|
|
897
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
|
+
}
|
|
898
997
|
/** #555: the edge-routed ports this executor was built with itself (they win over edge). */
|
|
899
998
|
ownEdgePorts() {
|
|
900
999
|
return {
|
|
@@ -913,6 +1012,42 @@ export class FoundryStepExecutor {
|
|
|
913
1012
|
return this.run(action, context);
|
|
914
1013
|
}
|
|
915
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) {
|
|
916
1051
|
const { org } = context;
|
|
917
1052
|
switch (`${action.system}:${action.kind}`) {
|
|
918
1053
|
case 'zitadel:issuer':
|
|
@@ -933,10 +1068,14 @@ export class FoundryStepExecutor {
|
|
|
933
1068
|
return this.zitadelCustomers(org, context);
|
|
934
1069
|
case 'coolify:app':
|
|
935
1070
|
return this.coolifyApp(action, org, context);
|
|
1071
|
+
case 'coolify:network':
|
|
1072
|
+
return this.coolifyNetwork(action, org, context);
|
|
936
1073
|
case 'coolify:broker-entry':
|
|
937
1074
|
return this.coolifyBrokerEntry(action, org, context);
|
|
938
1075
|
case 'coolify:database':
|
|
939
1076
|
return this.coolifyDatabase(action, org, context);
|
|
1077
|
+
case 'coolify:backup':
|
|
1078
|
+
return this.coolifyBackup(action, org, context);
|
|
940
1079
|
case 'coolify:calls':
|
|
941
1080
|
return this.coolifyCalls(action, org, context);
|
|
942
1081
|
case 'coolify:calls-service':
|
|
@@ -1922,10 +2061,9 @@ export class FoundryStepExecutor {
|
|
|
1922
2061
|
return { resourceId: created.appId };
|
|
1923
2062
|
}
|
|
1924
2063
|
// #139: the fixed `cms` client (`diff.ts`'s `planCmsClient`) signs
|
|
1925
|
-
// people into the cms studio at `cms.<domain>`,
|
|
1926
|
-
//
|
|
1927
|
-
//
|
|
1928
|
-
// 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
|
|
1929
2067
|
// `planAgoraClient`) is the same shape, always -- there is no
|
|
1930
2068
|
// `org.site.enabled`-style guard to check, since Agora is always on.
|
|
1931
2069
|
// The Agora client stays `appType: 'web'`: providers' `createOidcApp`
|
|
@@ -2024,12 +2162,110 @@ export class FoundryStepExecutor {
|
|
|
2024
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`);
|
|
2025
2163
|
}
|
|
2026
2164
|
}
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
:
|
|
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
|
+
}
|
|
2030
2183
|
context.recordScopeId('coolify_project', projectUuid);
|
|
2031
2184
|
return projectUuid;
|
|
2032
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
|
+
}
|
|
2033
2269
|
/**
|
|
2034
2270
|
* #445: one company lands on one Coolify server. The first call for an
|
|
2035
2271
|
* org picks a usable, reachable server and records it
|
|
@@ -2093,6 +2329,11 @@ export class FoundryStepExecutor {
|
|
|
2093
2329
|
throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' has no repo`);
|
|
2094
2330
|
}
|
|
2095
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
|
+
}
|
|
2096
2337
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
2097
2338
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
2098
2339
|
// The recorded id first; the name when nothing is recorded, or when the
|
|
@@ -2114,10 +2355,12 @@ export class FoundryStepExecutor {
|
|
|
2114
2355
|
environmentIds: [environment.id],
|
|
2115
2356
|
});
|
|
2116
2357
|
}
|
|
2117
|
-
//
|
|
2118
|
-
//
|
|
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.
|
|
2119
2362
|
const names = entryHostnames(org, entry);
|
|
2120
|
-
const wantDomains =
|
|
2363
|
+
const wantDomains = [];
|
|
2121
2364
|
const isStatic = entry.buildPack === 'static';
|
|
2122
2365
|
const settingsKey = appSettingsAppliedScopeKey(entry.key);
|
|
2123
2366
|
const domainsKey = appDomainsAppliedScopeKey(entry.key);
|
|
@@ -2157,8 +2400,7 @@ export class FoundryStepExecutor {
|
|
|
2157
2400
|
git_branch: entry.branch,
|
|
2158
2401
|
build_pack: entry.buildPack,
|
|
2159
2402
|
name: appNameFor(org, entry),
|
|
2160
|
-
|
|
2161
|
-
autogenerate_domain: names.length > 0 ? undefined : false,
|
|
2403
|
+
autogenerate_domain: false,
|
|
2162
2404
|
// A static site is served by Coolify's own web server on port 80.
|
|
2163
2405
|
ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
|
|
2164
2406
|
is_auto_deploy_enabled: entry.autoDeploy,
|
|
@@ -2203,9 +2445,9 @@ export class FoundryStepExecutor {
|
|
|
2203
2445
|
// redirect loops behind the tunnel. The internal name is set after
|
|
2204
2446
|
// create, as the calls stack's apps do.
|
|
2205
2447
|
if (JSON.stringify(wantDomains) !== JSON.stringify(haveDomains)) {
|
|
2206
|
-
patch.domains =
|
|
2448
|
+
patch.domains = '';
|
|
2207
2449
|
}
|
|
2208
|
-
if (
|
|
2450
|
+
if (!existing || patch.domains !== undefined) {
|
|
2209
2451
|
patch.custom_internal_name = appNameFor(org, entry);
|
|
2210
2452
|
}
|
|
2211
2453
|
// A `*` entry's proxy labels: Coolify takes `custom_labels` base64 encoded
|
|
@@ -2217,16 +2459,16 @@ export class FoundryStepExecutor {
|
|
|
2217
2459
|
// below, after the redeploy, so a failed run repeats this.
|
|
2218
2460
|
const labelsKey = appLabelsAppliedScopeKey(entry.key);
|
|
2219
2461
|
const haveLabelsHash = context.recordedIds()[labelsKey];
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
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;
|
|
2223
2466
|
const liveLabels = existing ? live?.custom_labels : undefined;
|
|
2224
|
-
const labelsDiffer =
|
|
2225
|
-
(
|
|
2226
|
-
(liveLabels
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
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) {
|
|
2230
2472
|
patch.custom_labels = Buffer.from(wantLabels).toString('base64');
|
|
2231
2473
|
}
|
|
2232
2474
|
if (Object.keys(patch).length > 0)
|
|
@@ -2268,7 +2510,6 @@ export class FoundryStepExecutor {
|
|
|
2268
2510
|
// What the plan decided is available (diff.ts `planApps`), so the marker
|
|
2269
2511
|
// recorded below is the one the plan compares against. Absent: none.
|
|
2270
2512
|
action.desired.secretSources ?? {
|
|
2271
|
-
platformMailKey: false,
|
|
2272
2513
|
sharedSecret: false,
|
|
2273
2514
|
});
|
|
2274
2515
|
// Rule 4 (#239, #243): a container reads its env only at start, so an app
|
|
@@ -2288,9 +2529,15 @@ export class FoundryStepExecutor {
|
|
|
2288
2529
|
const [hostname] = names;
|
|
2289
2530
|
const healthPath = entry.healthPath ?? (isStatic ? '/' : '/api/health');
|
|
2290
2531
|
const runtime = this.ports.runtime ?? {};
|
|
2291
|
-
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)
|
|
2292
2538
|
? publicHealthCheck(org, context.recordedIds(), hostname, healthPath)
|
|
2293
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));
|
|
2294
2541
|
}
|
|
2295
2542
|
else if ((appEnv.changed || dbNeedsRestart) && existing) {
|
|
2296
2543
|
await this.ports.coolify.restart(appId);
|
|
@@ -2301,8 +2548,7 @@ export class FoundryStepExecutor {
|
|
|
2301
2548
|
// one and `planApp` reports it instead of planning it.
|
|
2302
2549
|
context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
|
|
2303
2550
|
context.recordScopeId(domainsKey, JSON.stringify(wantDomains));
|
|
2304
|
-
|
|
2305
|
-
context.recordScopeId(labelsKey, wantLabelsHash);
|
|
2551
|
+
context.recordScopeId(labelsKey, wantLabelsHash);
|
|
2306
2552
|
context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
|
|
2307
2553
|
if (entry.uses?.auth) {
|
|
2308
2554
|
context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
|
|
@@ -2366,6 +2612,31 @@ export class FoundryStepExecutor {
|
|
|
2366
2612
|
await this.ports.coolify.restart(appId);
|
|
2367
2613
|
}
|
|
2368
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
|
+
}
|
|
2369
2640
|
async coolifyDatabase(action, org, context) {
|
|
2370
2641
|
if (action.desired.enabled === false) {
|
|
2371
2642
|
const dbId = context.recordedIds()[`coolify:database:${action.key}`];
|
|
@@ -2563,24 +2834,12 @@ export class FoundryStepExecutor {
|
|
|
2563
2834
|
// minted here; a source with no value yet (its own step has not run,
|
|
2564
2835
|
// or has not run for this apply) is simply omitted, the same way every
|
|
2565
2836
|
// other optional entry above is.
|
|
2566
|
-
// The secret
|
|
2837
|
+
// The secret source (`sharedSecret`) is filled here
|
|
2567
2838
|
// only, straight into `envs`: never recorded, returned or logged.
|
|
2568
2839
|
const heldKeys = this.ports.heldKeys;
|
|
2569
|
-
// Availability is the plan's (`secretSources`). If the plan said
|
|
2570
|
-
// held and this port cannot
|
|
2571
|
-
//
|
|
2572
|
-
let mailKey = null;
|
|
2573
|
-
if (secretSources.platformMailKey) {
|
|
2574
|
-
try {
|
|
2575
|
-
mailKey = (await heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF)) || null;
|
|
2576
|
-
}
|
|
2577
|
-
catch {
|
|
2578
|
-
// reported below, by ref
|
|
2579
|
-
}
|
|
2580
|
-
if (mailKey === null) {
|
|
2581
|
-
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`);
|
|
2582
|
-
}
|
|
2583
|
-
}
|
|
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.
|
|
2584
2843
|
if (secretSources.sharedSecret && typeof heldKeys?.put !== 'function') {
|
|
2585
2844
|
throw new Error(`coolify:app for ${org.slug}: the plan found held keys that can put but the executor's heldKeys cannot`);
|
|
2586
2845
|
}
|
|
@@ -2591,9 +2850,7 @@ export class FoundryStepExecutor {
|
|
|
2591
2850
|
for (const { name, source } of resolved.secrets) {
|
|
2592
2851
|
envs.push({
|
|
2593
2852
|
key: name,
|
|
2594
|
-
value: source
|
|
2595
|
-
? mailKey
|
|
2596
|
-
: await this.ensureSharedSecret(org, source.name, recorded),
|
|
2853
|
+
value: await this.ensureSharedSecret(org, source.name, recorded),
|
|
2597
2854
|
});
|
|
2598
2855
|
}
|
|
2599
2856
|
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
@@ -2612,6 +2869,40 @@ export class FoundryStepExecutor {
|
|
|
2612
2869
|
authApplied,
|
|
2613
2870
|
};
|
|
2614
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
|
+
}
|
|
2615
2906
|
/**
|
|
2616
2907
|
* The org's shared secret `name`: read from the held keys, else generated
|
|
2617
2908
|
* and held first, so a crash after the `put` and before the env write
|
|
@@ -3247,14 +3538,8 @@ export class FoundryStepExecutor {
|
|
|
3247
3538
|
await this.ports.coolify.raw('POST', `/applications/${siteAppId}/stop`);
|
|
3248
3539
|
return { resourceId: cmsAppId ?? siteAppId };
|
|
3249
3540
|
}
|
|
3250
|
-
//
|
|
3251
|
-
|
|
3252
|
-
// `cloudflareRateLimit` above guards its own `org.domain` read, since
|
|
3253
|
-
// TypeScript's `string | null` gives no static guarantee of it.
|
|
3254
|
-
if (!org.domain) {
|
|
3255
|
-
throw new Error(`cms:site for ${org.slug}: site.enabled is set but domain is null`);
|
|
3256
|
-
}
|
|
3257
|
-
const domain = org.domain;
|
|
3541
|
+
// The site's address: the org's domain, or `<slug>.oikos.host` without one.
|
|
3542
|
+
const domain = siteHostnameFor(org);
|
|
3258
3543
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
3259
3544
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
3260
3545
|
const site = await this.findOrCreateCmsApp({
|
|
@@ -3288,9 +3573,11 @@ export class FoundryStepExecutor {
|
|
|
3288
3573
|
// deploys, it does not add a deploy where none exists).
|
|
3289
3574
|
await this.ports.coolify.updateApplication(site.appId, {
|
|
3290
3575
|
...healthCheckSettings('/api/health', CMS_APP_PORT),
|
|
3576
|
+
...(await this.routedLabels(`${org.slug}-site`, domain, CMS_APP_PORT)),
|
|
3291
3577
|
});
|
|
3292
3578
|
await this.ports.coolify.updateApplication(cms.appId, {
|
|
3293
3579
|
...healthCheckSettings('/api/health', CMS_APP_PORT),
|
|
3580
|
+
...(await this.routedLabels(`${org.slug}-cms`, cmsHostnameFor(org), CMS_APP_PORT)),
|
|
3294
3581
|
});
|
|
3295
3582
|
// Every app foundry deploys keeps its own logs (#75) -- reused as-is for
|
|
3296
3583
|
// both of these two, the same unconditional call `coolifyApp` makes for
|
|
@@ -3577,16 +3864,17 @@ export class FoundryStepExecutor {
|
|
|
3577
3864
|
// yet.
|
|
3578
3865
|
throw new Error(`coolify:calls for ${org.slug}: a database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
3579
3866
|
}
|
|
3867
|
+
const synapseTag = typeof action.desired.synapseTag === 'string' ? action.desired.synapseTag : SYNAPSE_TAG;
|
|
3580
3868
|
const synapse = await this.findOrCreateCallsApp(names.synapse, environment.id, () => this.ports.coolify.createDockerImageApplication({
|
|
3581
3869
|
project_uuid: projectUuid,
|
|
3582
3870
|
server_uuid: server.uuid,
|
|
3583
3871
|
environment_name: environment.name,
|
|
3584
3872
|
environment_uuid: environment.uuid,
|
|
3585
3873
|
docker_registry_image_name: SYNAPSE_IMAGE,
|
|
3586
|
-
docker_registry_image_tag:
|
|
3874
|
+
docker_registry_image_tag: pinnedTagFor('synapse', synapseTag),
|
|
3587
3875
|
ports_exposes: '8008',
|
|
3588
3876
|
name: names.synapse,
|
|
3589
|
-
description: `Synapse for ${org.slug}'s calls stack (${SYNAPSE_IMAGE}:${
|
|
3877
|
+
description: `Synapse for ${org.slug}'s calls stack (${SYNAPSE_IMAGE}:${synapseTag})`,
|
|
3590
3878
|
instant_deploy: false,
|
|
3591
3879
|
}));
|
|
3592
3880
|
// The check goes on every run, so a Synapse made without one gets it. Synapse
|
|
@@ -3604,7 +3892,7 @@ export class FoundryStepExecutor {
|
|
|
3604
3892
|
// the app id above is, would make `versionNotAppliedLine` compare a
|
|
3605
3893
|
// bumped `VERSIONS.synapse` against a value never actually applied.
|
|
3606
3894
|
if (!synapse.existed) {
|
|
3607
|
-
context.recordScopeId(CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY,
|
|
3895
|
+
context.recordScopeId(CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, synapseTag);
|
|
3608
3896
|
}
|
|
3609
3897
|
const mas = await this.findOrCreateCallsApp(names.mas, environment.id, () => this.ports.coolify.createDockerfileApplication({
|
|
3610
3898
|
project_uuid: projectUuid,
|
|
@@ -4485,6 +4773,7 @@ export class FoundryStepExecutor {
|
|
|
4485
4773
|
custom_internal_name: name,
|
|
4486
4774
|
...commandHealthCheckSettings(wgetProbe(CALLS_SERVICE_PORT, CALLS_HEALTH_CHECK_PATH)),
|
|
4487
4775
|
...(portDiffers ? { ports_exposes: CALLS_SERVICE_PORT } : {}),
|
|
4776
|
+
...(await this.routedLabels(name, callsHostnameFor(org), Number(CALLS_SERVICE_PORT))),
|
|
4488
4777
|
});
|
|
4489
4778
|
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
4490
4779
|
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
@@ -4865,6 +5154,7 @@ export class FoundryStepExecutor {
|
|
|
4865
5154
|
await this.ports.coolify.updateApplication(app.appId, {
|
|
4866
5155
|
...(portDiffers ? { ports_exposes: AGORA_APP_PORTS_EXPOSES } : {}),
|
|
4867
5156
|
...healthCheckSettings('/', Number(AGORA_APP_PORTS_EXPOSES)),
|
|
5157
|
+
...(await this.routedLabels(name, agoraHostnameFor(org), Number(AGORA_APP_PORTS_EXPOSES))),
|
|
4868
5158
|
});
|
|
4869
5159
|
// #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
|
|
4870
5160
|
// Coolify's own env store matching `env`, which is why that store alone
|
|
@@ -4891,7 +5181,8 @@ export class FoundryStepExecutor {
|
|
|
4891
5181
|
// in the branch below, and this deploy already carries this run's env,
|
|
4892
5182
|
// pushed above, so the two never both run).
|
|
4893
5183
|
const runtime = this.ports.runtime ?? {};
|
|
4894
|
-
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));
|
|
4895
5186
|
context.recordScopeId(AGORA_DEPLOYED_SCOPE_KEY, '1');
|
|
4896
5187
|
}
|
|
4897
5188
|
else if (app.existed && (envChanged || !alreadyActivated)) {
|
|
@@ -4972,6 +5263,7 @@ export class FoundryStepExecutor {
|
|
|
4972
5263
|
// `EXPOSE 3000` ({@link BOULE_PORT}'s own doc comment).
|
|
4973
5264
|
await this.ports.coolify.updateApplication(app.appId, {
|
|
4974
5265
|
...healthCheckSettings('/api/health', BOULE_PORT),
|
|
5266
|
+
...(await this.routedLabels(name, bouleHostnameFor(org), BOULE_PORT)),
|
|
4975
5267
|
});
|
|
4976
5268
|
const liveEnv = new Map((await this.ports.coolify.listEnvs(app.appId))
|
|
4977
5269
|
.filter((e) => !e.is_preview)
|
|
@@ -5029,7 +5321,8 @@ export class FoundryStepExecutor {
|
|
|
5029
5321
|
if (!context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]) {
|
|
5030
5322
|
// #543: every deploy waits for health -- Boule's own public hostname.
|
|
5031
5323
|
const runtime = this.ports.runtime ?? {};
|
|
5032
|
-
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));
|
|
5033
5326
|
context.recordScopeId(BOULE_DEPLOYED_SCOPE_KEY, '1');
|
|
5034
5327
|
}
|
|
5035
5328
|
else if (app.existed &&
|
|
@@ -5220,7 +5513,7 @@ export class FoundryStepExecutor {
|
|
|
5220
5513
|
if (!own)
|
|
5221
5514
|
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
5222
5515
|
return provisionJobsHost(scopeFromOrg(context, {}), {
|
|
5223
|
-
coolify: limited(own.provision.jobs),
|
|
5516
|
+
coolify: limited(this.placedForCompany(own.provision.jobs, org, context), LIMITS.ownJobs),
|
|
5224
5517
|
deliverSecret: this.ports.deliverSecret,
|
|
5225
5518
|
readSecret: async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null,
|
|
5226
5519
|
runtime: this.ports.runtime ?? {},
|
|
@@ -5245,14 +5538,14 @@ export class FoundryStepExecutor {
|
|
|
5245
5538
|
if (!own)
|
|
5246
5539
|
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
5247
5540
|
const at = `coolify:own-files for ${org.slug}`;
|
|
5248
|
-
const domain = org.domain;
|
|
5249
|
-
if (!domain)
|
|
5250
|
-
throw new Error(`${at}: the org declares no domain`);
|
|
5251
5541
|
const zitadelOrgId = context.recordedIds()[ZITADEL_ORG_SCOPE_KEY];
|
|
5252
5542
|
if (!zitadelOrgId)
|
|
5253
5543
|
throw new Error(`${at}: the org's ZITADEL organisation is not recorded yet`);
|
|
5254
5544
|
const { r2Buckets, r2Keys: _r2Keys, keysMint, readSecret, ...ownFiles } = own.provision.files;
|
|
5255
|
-
const files = {
|
|
5545
|
+
const files = {
|
|
5546
|
+
...ownFiles,
|
|
5547
|
+
coolify: limited(this.placedForCompany(ownFiles.coolify, org, context), LIMITS.ownFiles),
|
|
5548
|
+
};
|
|
5256
5549
|
// Never the platform's credential in its place.
|
|
5257
5550
|
if (!keysMint)
|
|
5258
5551
|
throw new OwnServicesBlockedError('files', 'keysMint');
|
|
@@ -5267,7 +5560,7 @@ export class FoundryStepExecutor {
|
|
|
5267
5560
|
// The cause is dropped: a host port's message is not ours to repeat.
|
|
5268
5561
|
throw new Error(`${at}: the bucket ${target.names.filesBucket} could not be ensured`);
|
|
5269
5562
|
}
|
|
5270
|
-
const issuer =
|
|
5563
|
+
const issuer = ownHostnames(org).issuerUrl;
|
|
5271
5564
|
const deps = {
|
|
5272
5565
|
...files,
|
|
5273
5566
|
// The platform's keys settings stay out of a company's containers; the minted keyring replaces them.
|
|
@@ -5422,9 +5715,7 @@ export class FoundryStepExecutor {
|
|
|
5422
5715
|
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
5423
5716
|
const hostname = action.desired.hostname;
|
|
5424
5717
|
const gitRef = action.desired.gitRef;
|
|
5425
|
-
const
|
|
5426
|
-
if (!domain)
|
|
5427
|
-
throw new Error(`coolify:own-auth for ${org.slug}: the org declares no domain`);
|
|
5718
|
+
const hn = ownHostnames(org);
|
|
5428
5719
|
const scope = scopeFromOrg(context, {});
|
|
5429
5720
|
// The target (host, port, tls, user) is not secret; the platform's SMTP
|
|
5430
5721
|
// password is, so the company's own token is the password instead.
|
|
@@ -5434,7 +5725,7 @@ export class FoundryStepExecutor {
|
|
|
5434
5725
|
...(smtp
|
|
5435
5726
|
? { smtp: { host: smtp.host, port: smtp.port, tls: smtp.tls, user: smtp.user } }
|
|
5436
5727
|
: {}),
|
|
5437
|
-
coolify: limited(own.provision.auth.coolify),
|
|
5728
|
+
coolify: limited(this.placedForCompany(own.provision.auth.coolify, org, context), LIMITS.ownAuth),
|
|
5438
5729
|
runtime: {
|
|
5439
5730
|
...own.provision.auth.runtime,
|
|
5440
5731
|
onSecret: (value) => {
|
|
@@ -5453,15 +5744,15 @@ export class FoundryStepExecutor {
|
|
|
5453
5744
|
for (const id of gitApps)
|
|
5454
5745
|
await deps.coolify.updateApplication(id, { git_branch: gitRef });
|
|
5455
5746
|
}
|
|
5456
|
-
await this.ensureOwnAuthMailToken(org,
|
|
5747
|
+
await this.ensureOwnAuthMailToken(org, hn.mailDomain, deps);
|
|
5457
5748
|
const result = await provisionAuthStack(scope, deps, orgTarget(org), {
|
|
5458
5749
|
hostname,
|
|
5459
|
-
apex:
|
|
5750
|
+
apex: hn.zone,
|
|
5460
5751
|
gitRef,
|
|
5461
5752
|
project: { ensure: () => this.ensureCoolifyProject(org, context) },
|
|
5462
5753
|
network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:auth`),
|
|
5463
|
-
smtpSender: `noreply@${
|
|
5464
|
-
defaultRedirectUri: `https
|
|
5754
|
+
smtpSender: `noreply@${hn.mailDomain}`,
|
|
5755
|
+
defaultRedirectUri: `https://${agoraHostnameFor(org)}`,
|
|
5465
5756
|
ownerCompany: null,
|
|
5466
5757
|
});
|
|
5467
5758
|
if (refChanged) {
|
|
@@ -5482,8 +5773,9 @@ export class FoundryStepExecutor {
|
|
|
5482
5773
|
}
|
|
5483
5774
|
/**
|
|
5484
5775
|
* `plan/own-diff.ts`'s `planOwnMail`: the org's own Stalwart in the org's own
|
|
5485
|
-
* project,
|
|
5486
|
-
*
|
|
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
|
|
5487
5779
|
* `orgTarget(org).refs.mailAdminKey` and nowhere else. The address the relay
|
|
5488
5780
|
* routes to is recorded last, and the step's marker after it.
|
|
5489
5781
|
*/
|
|
@@ -5491,14 +5783,22 @@ export class FoundryStepExecutor {
|
|
|
5491
5783
|
const own = this.ports.ownServices;
|
|
5492
5784
|
if (!own)
|
|
5493
5785
|
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
5494
|
-
|
|
5495
|
-
throw new Error(`own step coolify:own-mail for ${org.slug}: the org has no domain`);
|
|
5786
|
+
const hn = ownHostnames(org);
|
|
5496
5787
|
const target = orgTarget(org);
|
|
5497
5788
|
const scope = scopeFromOrg(context, {});
|
|
5498
|
-
const issuerUrl =
|
|
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);
|
|
5499
5799
|
const result = await provisionMailServer(scope, {
|
|
5500
5800
|
...own.provision.mail,
|
|
5501
|
-
coolify: limited(
|
|
5801
|
+
coolify: limited(coolify, LIMITS.ownMail),
|
|
5502
5802
|
deliverSecret: (ref, secret) => {
|
|
5503
5803
|
this.handledSecrets.add(secret);
|
|
5504
5804
|
return this.ports.deliverSecret(ref, secret);
|
|
@@ -5511,16 +5811,27 @@ export class FoundryStepExecutor {
|
|
|
5511
5811
|
zitadelForInstance: undefined,
|
|
5512
5812
|
}, target, {
|
|
5513
5813
|
hostname: action.desired.hostname,
|
|
5514
|
-
apex:
|
|
5814
|
+
apex: hn.mailDomain,
|
|
5815
|
+
zone: hn.zone,
|
|
5515
5816
|
mailHost: action.desired.mailHost,
|
|
5516
5817
|
imageTag: action.desired.gitRef,
|
|
5517
|
-
hostPorts:
|
|
5818
|
+
hostPorts: bind ? `${bind}:${smtpPort}:25` : `${smtpPort}:25`,
|
|
5518
5819
|
issuerUrl,
|
|
5519
5820
|
mxRecord: null,
|
|
5520
5821
|
project: { ensure: () => this.ensureCoolifyProject(org, context) },
|
|
5521
5822
|
network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:mail`),
|
|
5522
5823
|
});
|
|
5523
|
-
|
|
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);
|
|
5524
5835
|
await scope.persist();
|
|
5525
5836
|
return result;
|
|
5526
5837
|
}
|
|
@@ -5536,7 +5847,7 @@ export class FoundryStepExecutor {
|
|
|
5536
5847
|
const scope = scopeFromOrg(context, {});
|
|
5537
5848
|
const { sfu } = own.provision;
|
|
5538
5849
|
return provisionSfuApp(scope, {
|
|
5539
|
-
coolify: limited(sfu.coolify),
|
|
5850
|
+
coolify: limited(this.placedForCompany(sfu.coolify, org, context), LIMITS.ownSfu),
|
|
5540
5851
|
network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:sfu`),
|
|
5541
5852
|
deliverSecret: sfu.deliverSecret,
|
|
5542
5853
|
readSecret: async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null,
|
|
@@ -5548,7 +5859,7 @@ export class FoundryStepExecutor {
|
|
|
5548
5859
|
}, orgTarget(org), {
|
|
5549
5860
|
slot: action.desired.slot,
|
|
5550
5861
|
hostname: action.desired.hostname,
|
|
5551
|
-
apex: org.
|
|
5862
|
+
apex: ownHostnames(org).zone,
|
|
5552
5863
|
version: action.desired.version,
|
|
5553
5864
|
project: { ensure: () => this.ensureCoolifyProject(org, context) },
|
|
5554
5865
|
});
|
|
@@ -5669,15 +5980,19 @@ export class FoundryStepExecutor {
|
|
|
5669
5980
|
// login app (the same container the platform auth tunnel routes to), not
|
|
5670
5981
|
// through Coolify's proxy, which has no route for it.
|
|
5671
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]));
|
|
5672
5986
|
const ingress = tunnelIngressHostnames(org, loginHost).map((hostname) => ({
|
|
5673
5987
|
hostname,
|
|
5674
5988
|
service: hostname === loginHost
|
|
5675
5989
|
? `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}`
|
|
5676
|
-
: COOLIFY_PROXY_ORIGIN,
|
|
5990
|
+
: (entryOrigins.get(hostname) ?? COOLIFY_PROXY_ORIGIN),
|
|
5677
5991
|
}));
|
|
5678
5992
|
await this.tunnelPort().putTunnelConfig(tunnel.id, {
|
|
5679
5993
|
ingress: [...ingress, { service: 'http_status:404' }],
|
|
5680
5994
|
});
|
|
5995
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, entryRoutesMarker(org));
|
|
5681
5996
|
// #235: a connector for this org's own tunnel, the same call every
|
|
5682
5997
|
// platform tunnel step already makes (Decided, William, 2026-10-01:
|
|
5683
5998
|
// one connector per org tunnel, never a shared tunnel with appended
|
|
@@ -5776,8 +6091,77 @@ export class FoundryStepExecutor {
|
|
|
5776
6091
|
context.recordScopeId(CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY, '');
|
|
5777
6092
|
}
|
|
5778
6093
|
}
|
|
6094
|
+
await this.publishOikosEntryRecords(org, tunnel.id, previousIds, context);
|
|
5779
6095
|
return { resourceId: tunnel.id };
|
|
5780
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
|
+
}
|
|
5781
6165
|
/**
|
|
5782
6166
|
* #236: when this tunnel's DNS was last published under a domain that
|
|
5783
6167
|
* differs from the one `org` now declares, deletes every CNAME in the
|
|
@@ -6094,7 +6478,7 @@ export class FoundryStepExecutor {
|
|
|
6094
6478
|
}
|
|
6095
6479
|
const mailOrg = mailOrgFor(org, usesDomain, mode);
|
|
6096
6480
|
const result = await this.ports.mail.applyOrgMail(mailOrg, options);
|
|
6097
|
-
await this.publishMailDnsRecords(org, result);
|
|
6481
|
+
await this.publishMailDnsRecords(org, result, context);
|
|
6098
6482
|
await this.deleteStaleLettermintDomain(context, result.domain, platformApex);
|
|
6099
6483
|
context.recordScopeId(MAIL_DOMAIN_SCOPE_KEY, result.domain);
|
|
6100
6484
|
await this.ensureOwnerMailbox(org, mailOrg, options, context);
|
|
@@ -6225,15 +6609,49 @@ export class FoundryStepExecutor {
|
|
|
6225
6609
|
* call, since Stalwart mints the key on a background task; the next apply
|
|
6226
6610
|
* publishes it.
|
|
6227
6611
|
*/
|
|
6228
|
-
async publishMailDnsRecords(org, result) {
|
|
6612
|
+
async publishMailDnsRecords(org, result, context) {
|
|
6229
6613
|
const zone = await this.zonePort().findZoneByApex(result.domain);
|
|
6230
6614
|
if (!zone) {
|
|
6231
6615
|
throw new Error(`coolify:mail for ${org.slug}: no Cloudflare zone found for ${result.domain}`);
|
|
6232
6616
|
}
|
|
6617
|
+
if (ownsServices(org) && result.domain.toLowerCase() === `${org.slug}.${NO_DOMAIN_ZONE}`) {
|
|
6618
|
+
await this.ensureOwnWebAddressRecord(org, zone.name, context);
|
|
6619
|
+
}
|
|
6233
6620
|
for (const record of result.records) {
|
|
6234
6621
|
await this.upsertMailDnsRecord(zone.name, record);
|
|
6235
6622
|
}
|
|
6236
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
|
+
}
|
|
6237
6655
|
/**
|
|
6238
6656
|
* Creates or updates one mail record (Stalwart's or Lettermint's).
|
|
6239
6657
|
*
|
|
@@ -6307,7 +6725,7 @@ export class FoundryStepExecutor {
|
|
|
6307
6725
|
// An own org's sign-in sends its mail through `<slug>-auth` and the domain it
|
|
6308
6726
|
// applied at; parking the org's mail leaves both, or the sign-in can no
|
|
6309
6727
|
// longer send (its held token would still resolve, so nothing re-mints it).
|
|
6310
|
-
const ownSignIn = ownsServices(org) &&
|
|
6728
|
+
const ownSignIn = ownsServices(org) && ownAuthApplied(org, context.recordedIds());
|
|
6311
6729
|
const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
|
|
6312
6730
|
if (!ownSignIn)
|
|
6313
6731
|
names.add(`${org.slug}-auth`);
|
|
@@ -6319,7 +6737,7 @@ export class FoundryStepExecutor {
|
|
|
6319
6737
|
}
|
|
6320
6738
|
if (isPlatformApexOrg)
|
|
6321
6739
|
return;
|
|
6322
|
-
if (ownSignIn && mailDomain.toLowerCase() === org.
|
|
6740
|
+
if (ownSignIn && mailDomain.toLowerCase() === ownHostnames(org).mailDomain.toLowerCase())
|
|
6323
6741
|
return;
|
|
6324
6742
|
const domain = await this.findExactLettermintDomain(mailDomain);
|
|
6325
6743
|
if (domain)
|
|
@@ -7255,6 +7673,29 @@ export class FoundryStepExecutor {
|
|
|
7255
7673
|
throw error;
|
|
7256
7674
|
}
|
|
7257
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
|
+
}
|
|
7258
7699
|
await this.tunnelPort().deleteTunnel(target.id);
|
|
7259
7700
|
return;
|
|
7260
7701
|
}
|