@wtfalch/foundry 0.42.1 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/versions.d.ts +2 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.js +4 -2
- package/dist/migrations/0055_release_requests.sql +20 -0
- package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
- package/dist/migrations/0057_org_customers.sql +29 -0
- package/dist/org.d.ts +55 -0
- package/dist/org.js +90 -27
- package/dist/plan/archon-blue-green.d.ts +54 -0
- package/dist/plan/archon-blue-green.js +72 -0
- package/dist/plan/blue-green-apply.d.ts +82 -0
- package/dist/plan/blue-green-apply.js +360 -0
- package/dist/plan/blue-green.d.ts +223 -0
- package/dist/plan/blue-green.js +444 -0
- package/dist/plan/boule.d.ts +43 -2
- package/dist/plan/boule.js +48 -1
- package/dist/plan/broker-entries.d.ts +28 -0
- package/dist/plan/broker-entries.js +46 -1
- package/dist/plan/cloudflared-connector.d.ts +26 -0
- package/dist/plan/cloudflared-connector.js +37 -0
- package/dist/plan/diff.d.ts +20 -0
- package/dist/plan/diff.js +207 -20
- package/dist/plan/executor.d.ts +134 -2
- package/dist/plan/executor.js +517 -25
- package/dist/plan/hostnames.d.ts +7 -0
- package/dist/plan/hostnames.js +17 -2
- package/dist/plan/locked-hostnames.d.ts +11 -0
- package/dist/plan/locked-hostnames.js +13 -0
- package/dist/plan/mcp.d.ts +57 -0
- package/dist/plan/mcp.js +131 -0
- package/dist/plan/org-names.d.ts +2 -2
- package/dist/plan/org-names.js +16 -1
- package/dist/plan/plan-apply.d.ts +77 -0
- package/dist/plan/plan-apply.js +267 -5
- package/dist/plan/platform-diff.d.ts +9 -0
- package/dist/plan/platform-diff.js +24 -1
- package/dist/plan/platform-providers.d.ts +15 -1
- package/dist/plan/platform-providers.js +90 -5
- package/dist/plan/proxy-labels.d.ts +2 -2
- package/dist/plan/proxy-labels.js +14 -4
- package/dist/plan/release-host.d.ts +59 -0
- package/dist/plan/release-host.js +239 -0
- package/dist/plan/remote-database.d.ts +124 -0
- package/dist/plan/remote-database.js +373 -0
- package/dist/plan/worker-app-database.d.ts +148 -0
- package/dist/plan/worker-app-database.js +424 -0
- package/dist/plan/worker-app.d.ts +320 -0
- package/dist/plan/worker-app.js +792 -0
- package/dist/plan/zitadel-machine-user.d.ts +13 -1
- package/dist/plan/zitadel-machine-user.js +80 -0
- package/dist/platform.d.ts +11 -1
- package/dist/platform.js +4 -1
- package/dist/release/decide.d.ts +95 -0
- package/dist/release/decide.js +242 -0
- package/dist/release/health.d.ts +48 -0
- package/dist/release/health.js +61 -0
- package/dist/release/run.d.ts +40 -0
- package/dist/release/run.js +56 -0
- package/dist/release/state.d.ts +64 -0
- package/dist/release/state.js +126 -0
- package/dist/release/types.d.ts +54 -0
- package/dist/release/types.js +4 -0
- package/dist/requested-changes.d.ts +12 -3
- package/dist/requested-changes.js +22 -0
- package/dist/store/postgres.d.ts +8 -0
- package/dist/store/postgres.js +67 -8
- package/dist/store/schema.d.ts +188 -0
- package/dist/store/schema.js +15 -0
- package/dist/worker-app-config.d.ts +151 -0
- package/dist/worker-app-config.js +438 -0
- package/package.json +1 -1
- package/versions.digests.json +2 -1
- package/versions.json +2 -1
package/dist/plan/executor.js
CHANGED
|
@@ -7,8 +7,10 @@ import { resolveCallsSfuUrls } from '../calls-sfu.js';
|
|
|
7
7
|
import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, heldEnvRef, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
|
|
8
8
|
import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
|
|
9
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';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, } from './blue-green-apply.js';
|
|
11
|
+
import { bgKey, colorAppName, entrySubject, healthTokenName, liveAppNameFor, splitServers, } from './blue-green.js';
|
|
12
|
+
import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_CUSTOMERS_ORG_ENV, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleArchonCredentialRef, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, customersDirectoryRef, customersOrgIdOf, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
13
|
+
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, mcpCallbackUri, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
12
14
|
import { recordBuiltCommit } from './build-record.js';
|
|
13
15
|
import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
14
16
|
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';
|
|
@@ -34,6 +36,7 @@ import { appHostname, cmsStudioHostnameFor, coolifyTunnelDomains, entryHostnames
|
|
|
34
36
|
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, customersEntryKeys, loginBindingState, loginHostClientIds, ownAuthApplied, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
35
37
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
36
38
|
import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_SCOPE_KEY, isOwnMailSmtpPort, normalizeMailTarget, } from './mail-relay.js';
|
|
39
|
+
import { applyAccessTokenLifetime, applyMcpAccess, applyMcpProject } from './mcp.js';
|
|
37
40
|
import { NETWORK_APPLIED_SCOPE_KEY } from './network-move.js';
|
|
38
41
|
import { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, } from './own-diff.js';
|
|
39
42
|
import { NO_DOMAIN_ZONE, ownHostnames } from './own-hostnames.js';
|
|
@@ -44,10 +47,13 @@ import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY
|
|
|
44
47
|
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';
|
|
45
48
|
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';
|
|
46
49
|
import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
|
|
50
|
+
import { createReleaseHost } from './release-host.js';
|
|
51
|
+
import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
|
|
47
52
|
import { editSignInBindings } from './sign-in-bindings.js';
|
|
48
53
|
import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
|
|
49
54
|
import { SIGN_IN_BASE_PATH, SIGN_IN_ENV_LIVE_SCOPE_PREFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_RATE_LIMIT_DESCRIPTION, SIGN_IN_RATE_LIMIT_EXPRESSION, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, SIGN_IN_UPSTREAM_ENV, inAppBindingFor, inAppSignIn, inAppStateHosts, inAppStatePairs, inAppTargets, signInEnvLiveHosts, } from './sign-in.js';
|
|
50
55
|
import { moveSigninDatabase } from './signin-db-move.js';
|
|
56
|
+
import { WORKER_APP_CALL_TIMEOUT_MS, applyWorkerApp, heldBrokerKey, teardownWorkerApp, withCallTimeout, } from './worker-app.js';
|
|
51
57
|
import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
|
|
52
58
|
/**
|
|
53
59
|
* The real {@link StepExecutor} (#20 "provision Company Factory end to
|
|
@@ -98,6 +104,11 @@ export function adaptZitadelListApps(client) {
|
|
|
98
104
|
complete: true,
|
|
99
105
|
});
|
|
100
106
|
}
|
|
107
|
+
/** An outside call for Boule's credentials that has not answered in this long fails the step; the next apply repairs it. */
|
|
108
|
+
const BOULE_CREDENTIAL_TIMEOUT_MS = 60_000;
|
|
109
|
+
const BOULE_CREDENTIAL_ATTEMPTS = 3;
|
|
110
|
+
/** The ZITADEL machine user that reads one company's customers organisation. */
|
|
111
|
+
const CUSTOMERS_DIRECTORY_USER_NAME = 'foundry-customers-directory';
|
|
101
112
|
// ---------------------------------------------------------------------------
|
|
102
113
|
// Errors
|
|
103
114
|
// ---------------------------------------------------------------------------
|
|
@@ -883,7 +894,11 @@ function limitProfileOf(action) {
|
|
|
883
894
|
case 'coolify:boule':
|
|
884
895
|
case 'coolify:boule-broker-entry':
|
|
885
896
|
return LIMITS.boule;
|
|
897
|
+
case 'coolify:bluegreen':
|
|
898
|
+
case 'coolify:bluegreen-lb':
|
|
899
|
+
return LIMITS.companyApp;
|
|
886
900
|
case 'cloudflare:tunnel':
|
|
901
|
+
case 'coolify:bluegreen-connector':
|
|
887
902
|
return LIMITS.connector;
|
|
888
903
|
default:
|
|
889
904
|
return action.system === 'coolify' ? LIMITS.companyOther : undefined;
|
|
@@ -970,6 +985,15 @@ export class FoundryStepExecutor {
|
|
|
970
985
|
ownMailKeyMintable() {
|
|
971
986
|
return Boolean(this.ports.lettermint && this.ports.ownServices?.provision.auth.heldKeys);
|
|
972
987
|
}
|
|
988
|
+
/** C3: whether the ZITADEL port can mint and revoke a customers directory credential. */
|
|
989
|
+
customersDirectoryAvailable() {
|
|
990
|
+
return (typeof this.ports.zitadel.mintOrgDirectoryReader === 'function' &&
|
|
991
|
+
typeof this.ports.zitadel.revokeOrgDirectoryReader === 'function');
|
|
992
|
+
}
|
|
993
|
+
/** C3: whether the host supplied `mintBouleArchonCredential`. */
|
|
994
|
+
bouleArchonCredentialAvailable() {
|
|
995
|
+
return typeof this.ports.mintBouleArchonCredential === 'function';
|
|
996
|
+
}
|
|
973
997
|
/** Whether the held keys can store a new company's sign-in database CA and certificate (#746). */
|
|
974
998
|
ownSigninDbKeysPuttable() {
|
|
975
999
|
return Boolean(this.ports.ownServices?.provision.auth.heldKeys?.put);
|
|
@@ -1104,11 +1128,21 @@ export class FoundryStepExecutor {
|
|
|
1104
1128
|
case 'zitadel:id-token-userinfo':
|
|
1105
1129
|
return this.zitadelIdTokenUserinfo(action, org, context);
|
|
1106
1130
|
case 'zitadel:customers':
|
|
1107
|
-
return this.zitadelCustomers(org, context);
|
|
1131
|
+
return this.zitadelCustomers(action, org, context);
|
|
1132
|
+
case 'zitadel:mcp-project':
|
|
1133
|
+
return this.zitadelMcpProject(action, org, context);
|
|
1134
|
+
case 'zitadel:mcp-access':
|
|
1135
|
+
return this.zitadelMcpAccess(action, org, context);
|
|
1136
|
+
case 'zitadel:access-token-lifetime':
|
|
1137
|
+
return this.zitadelAccessTokenLifetime(context);
|
|
1108
1138
|
case 'coolify:app':
|
|
1109
1139
|
return this.coolifyApp(action, org, context);
|
|
1110
1140
|
case 'coolify:network':
|
|
1111
1141
|
return this.coolifyNetwork(action, org, context);
|
|
1142
|
+
case 'coolify:bluegreen':
|
|
1143
|
+
case 'coolify:bluegreen-lb':
|
|
1144
|
+
case 'coolify:bluegreen-connector':
|
|
1145
|
+
return this.coolifyBlueGreen(action, org, context);
|
|
1112
1146
|
case 'coolify:broker-entry':
|
|
1113
1147
|
return this.coolifyBrokerEntry(action, org, context);
|
|
1114
1148
|
case 'coolify:database':
|
|
@@ -1133,6 +1167,13 @@ export class FoundryStepExecutor {
|
|
|
1133
1167
|
return this.cloudflareRateLimit(org, context);
|
|
1134
1168
|
case 'cloudflare:worker':
|
|
1135
1169
|
return this.cloudflareWorker(action, org, context);
|
|
1170
|
+
case 'cloudflare:worker-app': {
|
|
1171
|
+
const workers = this.ports.workerApps;
|
|
1172
|
+
if (!workers) {
|
|
1173
|
+
throw new Error(`cloudflare:worker-app for ${org.slug}: this executor was built without a WorkerAppPort`);
|
|
1174
|
+
}
|
|
1175
|
+
return applyWorkerApp(this.workerAppDeps(workers), org, action, context);
|
|
1176
|
+
}
|
|
1136
1177
|
case 'files:organisation':
|
|
1137
1178
|
return this.filesOrganisation(action, org, context);
|
|
1138
1179
|
case 'files:key':
|
|
@@ -1169,6 +1210,8 @@ export class FoundryStepExecutor {
|
|
|
1169
1210
|
return this.bouleBrokerEntry(org, context);
|
|
1170
1211
|
case 'jobs:registration':
|
|
1171
1212
|
return this.jobsRegistration(action, org, context);
|
|
1213
|
+
case 'cloudflare:db-route':
|
|
1214
|
+
return this.remoteDatabaseRoute(action, org, context);
|
|
1172
1215
|
case 'coolify:own-auth':
|
|
1173
1216
|
return this.ownAuth(action, org, context);
|
|
1174
1217
|
case 'coolify:own-auth-db-move':
|
|
@@ -1415,6 +1458,35 @@ export class FoundryStepExecutor {
|
|
|
1415
1458
|
* "Declared changes apply" nothing is marked done until the
|
|
1416
1459
|
* live state actually matches it.
|
|
1417
1460
|
*/
|
|
1461
|
+
workerAppDeps(workers) {
|
|
1462
|
+
return {
|
|
1463
|
+
workers,
|
|
1464
|
+
...(this.ports.workerAppKeys ? { keys: this.ports.workerAppKeys } : {}),
|
|
1465
|
+
...(this.ports.workerAppDatabase
|
|
1466
|
+
? {
|
|
1467
|
+
database: {
|
|
1468
|
+
port: this.ports.workerAppDatabase,
|
|
1469
|
+
tunnels: this.tunnelPort(),
|
|
1470
|
+
access: this.accessPort(),
|
|
1471
|
+
dns: this.dnsPort(),
|
|
1472
|
+
zones: this.zonePort(),
|
|
1473
|
+
ownerUrl: async (databaseId) => {
|
|
1474
|
+
const db = await getDatabaseStarted(this.ports.coolify, databaseId);
|
|
1475
|
+
if (!db.internal_db_url) {
|
|
1476
|
+
throw new Error('the database reports no internal connection URL yet');
|
|
1477
|
+
}
|
|
1478
|
+
this.handledSecrets.add(db.internal_db_url);
|
|
1479
|
+
return db.internal_db_url;
|
|
1480
|
+
},
|
|
1481
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
1482
|
+
},
|
|
1483
|
+
}
|
|
1484
|
+
: {}),
|
|
1485
|
+
zones: this.zonePort(),
|
|
1486
|
+
dns: this.dnsPort(),
|
|
1487
|
+
...(this.ports.heldKeys ? { heldKeys: this.ports.heldKeys } : {}),
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1418
1490
|
async cloudflareWorker(action, org, context) {
|
|
1419
1491
|
const entryKey = action.key.slice(org.id.length + 1);
|
|
1420
1492
|
const at = `cloudflare:worker for ${org.slug} entry '${entryKey}'`;
|
|
@@ -1752,6 +1824,18 @@ export class FoundryStepExecutor {
|
|
|
1752
1824
|
}
|
|
1753
1825
|
return { resourceId: userId };
|
|
1754
1826
|
}
|
|
1827
|
+
/** `plan/mcp.ts`: the entry's MCP audience project. */
|
|
1828
|
+
zitadelMcpProject(action, org, context) {
|
|
1829
|
+
return applyMcpProject(this.ports.zitadel, org, action, context);
|
|
1830
|
+
}
|
|
1831
|
+
/** `plan/mcp.ts`: JWT access tokens and the broker's callback on the entry's app. */
|
|
1832
|
+
zitadelMcpAccess(action, org, context) {
|
|
1833
|
+
return applyMcpAccess(this.ports.zitadel, org, action, context);
|
|
1834
|
+
}
|
|
1835
|
+
/** `plan/mcp.ts`: the instance access-token lifetime. */
|
|
1836
|
+
zitadelAccessTokenLifetime(context) {
|
|
1837
|
+
return applyAccessTokenLifetime(this.ports.zitadel, context);
|
|
1838
|
+
}
|
|
1755
1839
|
/**
|
|
1756
1840
|
* Switches on "user info inside the ID token" for each live sign-in app
|
|
1757
1841
|
* the planner named. An app's marker (its ZITADEL app id) is recorded and
|
|
@@ -2131,20 +2215,27 @@ export class FoundryStepExecutor {
|
|
|
2131
2215
|
/**
|
|
2132
2216
|
* `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
|
|
2133
2217
|
* in through a second ZITADEL org, `<slug> customers`, with
|
|
2134
|
-
* self-registration
|
|
2218
|
+
* self-registration as `customers.signUp` says (off by default) and a grant to the org's app project. Staff stay in
|
|
2135
2219
|
* the org's own invite-only ZITADEL org. The space keeps the name apart from
|
|
2136
2220
|
* every issuer org, which is named by a slug and a slug has no spaces. Every call here is idempotent, so
|
|
2137
2221
|
* a re-run after a crash finishes the job.
|
|
2138
2222
|
*/
|
|
2139
|
-
async zitadelCustomers(org, context) {
|
|
2223
|
+
async zitadelCustomers(action, org, context) {
|
|
2140
2224
|
const zitadelOrgId = context.recordedIds().zitadel_org;
|
|
2141
2225
|
if (!zitadelOrgId) {
|
|
2142
2226
|
throw new Error(`zitadel:customers for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
2143
2227
|
}
|
|
2144
2228
|
const projectId = await this.ensureZitadelProject(org, zitadelOrgId, context);
|
|
2145
2229
|
const { org: customers } = await this.ports.zitadel.findOrCreateOrg(`${org.slug} customers`);
|
|
2146
|
-
|
|
2230
|
+
// Self-registration follows `customers.signUp` (off by default). The marker is written last, so a
|
|
2231
|
+
// run that failed before here is planned again.
|
|
2232
|
+
const signUp = action.desired.signUp === true;
|
|
2233
|
+
if (signUp)
|
|
2234
|
+
await this.ports.zitadel.allowSelfRegistration(customers.id);
|
|
2235
|
+
else
|
|
2236
|
+
await this.ports.zitadel.closeSelfRegistration(customers.id);
|
|
2147
2237
|
await this.ports.zitadel.findOrCreateProjectGrant(zitadelOrgId, projectId, customers.id);
|
|
2238
|
+
context.recordScopeId(CUSTOMERS_SIGN_UP_SCOPE_KEY, signUp ? '1' : '0');
|
|
2148
2239
|
return { resourceId: customers.id };
|
|
2149
2240
|
}
|
|
2150
2241
|
async zitadelApp(action, org, context) {
|
|
@@ -2302,13 +2393,17 @@ export class FoundryStepExecutor {
|
|
|
2302
2393
|
redirectUris: appType === 'native'
|
|
2303
2394
|
? []
|
|
2304
2395
|
: hosts
|
|
2305
|
-
?
|
|
2396
|
+
? [
|
|
2397
|
+
...hosts.map((host) => `https://${host}/auth/callback`),
|
|
2398
|
+
...(authEntry?.uses?.auth?.mcp ? [mcpCallbackUri(org)] : []),
|
|
2399
|
+
]
|
|
2306
2400
|
: [redirectUri],
|
|
2307
2401
|
postLogoutRedirectUris: appType === 'native'
|
|
2308
2402
|
? []
|
|
2309
2403
|
: hosts
|
|
2310
2404
|
? hosts.map((host) => `https://${host}/`)
|
|
2311
2405
|
: [`https://${hostname}/`],
|
|
2406
|
+
...(authEntry?.uses?.auth?.mcp ? { accessTokenType: 'jwt' } : {}),
|
|
2312
2407
|
});
|
|
2313
2408
|
if (appType === 'web') {
|
|
2314
2409
|
// `coolifyApp`'s own `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` to
|
|
@@ -2501,6 +2596,140 @@ export class FoundryStepExecutor {
|
|
|
2501
2596
|
context.recordScopeId(COOLIFY_SERVER_SCOPE_KEY, server.uuid);
|
|
2502
2597
|
return server;
|
|
2503
2598
|
}
|
|
2599
|
+
/**
|
|
2600
|
+
* Contract C3: every usable, reachable Coolify server, the org's primary first. The primary is
|
|
2601
|
+
* {@link resolveServer}'s (databases and single-copy services stay on it, unchanged). A server that is
|
|
2602
|
+
* down is named in `down` and left out of `servers`; it is not an error.
|
|
2603
|
+
*/
|
|
2604
|
+
async resolveServers(org, context) {
|
|
2605
|
+
const primary = await this.resolveServer(org, context);
|
|
2606
|
+
const { up, down } = splitServers(await this.blueGreenServers());
|
|
2607
|
+
return {
|
|
2608
|
+
primary,
|
|
2609
|
+
servers: [primary, ...up.filter((uuid) => uuid !== primary.uuid).map((uuid) => ({ uuid }))],
|
|
2610
|
+
down,
|
|
2611
|
+
};
|
|
2612
|
+
}
|
|
2613
|
+
/**
|
|
2614
|
+
* The release runner's outside calls for one blue/green entry (`plan/release-host.ts`), or `null` while its
|
|
2615
|
+
* blue copy is not recorded. The health token is only read here, never minted.
|
|
2616
|
+
*/
|
|
2617
|
+
releaseHost(org, subject, context, renew) {
|
|
2618
|
+
const recorded = context.recordedIds();
|
|
2619
|
+
const primaryBlueAppId = recorded[`coolify:app:${appStepKey(org, subject.id)}`];
|
|
2620
|
+
const primaryUuid = recorded[COOLIFY_SERVER_SCOPE_KEY];
|
|
2621
|
+
if (!primaryBlueAppId || !primaryUuid)
|
|
2622
|
+
return null;
|
|
2623
|
+
return createReleaseHost({
|
|
2624
|
+
subject,
|
|
2625
|
+
context,
|
|
2626
|
+
coolify: this.ports.coolify,
|
|
2627
|
+
healthToken: async () => (await this.ports.heldKeys?.resolve(sharedSecretRef(org.id, healthTokenName(subject.id)))) ?? null,
|
|
2628
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
2629
|
+
primaryUuid,
|
|
2630
|
+
primaryBlueAppId,
|
|
2631
|
+
runtime: this.ports.runtime ?? {},
|
|
2632
|
+
...(this.ports.runtime?.fetch ? { fetch: this.ports.runtime.fetch } : {}),
|
|
2633
|
+
renew,
|
|
2634
|
+
label: `coolify:release:${subject.id}`,
|
|
2635
|
+
});
|
|
2636
|
+
}
|
|
2637
|
+
/** Every server Coolify lists with whether it is usable and reachable, for the planner. */
|
|
2638
|
+
async blueGreenServers() {
|
|
2639
|
+
return (await this.ports.coolify.listServers()).map((s) => ({
|
|
2640
|
+
uuid: s.uuid,
|
|
2641
|
+
usable: s.is_usable,
|
|
2642
|
+
reachable: s.is_reachable,
|
|
2643
|
+
}));
|
|
2644
|
+
}
|
|
2645
|
+
/**
|
|
2646
|
+
* The blue/green steps (`plan/blue-green.ts`): `bluegreen` makes the apps and the balancer on one server,
|
|
2647
|
+
* `bluegreen-lb` only writes the balancer's weights, `bluegreen-connector` runs the tunnel's connector on a
|
|
2648
|
+
* server. A server that went down since the plan is skipped by `bluegreen-connector`'s caller, not here:
|
|
2649
|
+
* a step for it fails, which the next plan reports instead.
|
|
2650
|
+
*/
|
|
2651
|
+
async coolifyBlueGreen(action, org, context) {
|
|
2652
|
+
const recorded = () => context.recordedIds();
|
|
2653
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
2654
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
2655
|
+
const serverUuid = String(action.desired.server ?? '');
|
|
2656
|
+
const primaryUuid = String(action.desired.primary ?? '');
|
|
2657
|
+
const live = await this.resolveServers(org, context);
|
|
2658
|
+
if (!live.servers.some((s) => s.uuid === serverUuid)) {
|
|
2659
|
+
throw new Error(`${action.kind} for ${org.slug}: Coolify server ${serverUuid} is not usable and reachable`);
|
|
2660
|
+
}
|
|
2661
|
+
if (live.primary.uuid !== primaryUuid) {
|
|
2662
|
+
throw new Error(`${action.kind} for ${org.slug}: the plan's primary server is not the recorded one`);
|
|
2663
|
+
}
|
|
2664
|
+
if (action.kind === 'bluegreen-connector') {
|
|
2665
|
+
const tunnelId = recorded()[`cloudflare:tunnel:${org.id}`];
|
|
2666
|
+
if (!tunnelId)
|
|
2667
|
+
throw new Error(`bluegreen-connector for ${org.slug}: the tunnel is not recorded yet`);
|
|
2668
|
+
await applyBlueGreenConnectors({
|
|
2669
|
+
coolify: this.ports.coolify,
|
|
2670
|
+
tunnels: this.tunnelPort(),
|
|
2671
|
+
tunnelId,
|
|
2672
|
+
baseAppName: `${org.slug}-cloudflared`,
|
|
2673
|
+
baseScopeKey: CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY,
|
|
2674
|
+
primaryUuid,
|
|
2675
|
+
serverUuids: [serverUuid],
|
|
2676
|
+
projectUuid,
|
|
2677
|
+
environment,
|
|
2678
|
+
recorded,
|
|
2679
|
+
record: (key, id) => context.recordScopeId(key, id),
|
|
2680
|
+
persist: () => context.persistScopeIds(),
|
|
2681
|
+
});
|
|
2682
|
+
return undefined;
|
|
2683
|
+
}
|
|
2684
|
+
const entry = org.hosting.entries.find((e) => e.key === action.desired.entry);
|
|
2685
|
+
if (!entry || !entrySubject(org, entry)) {
|
|
2686
|
+
throw new Error(`${action.kind} for ${org.slug}: no blue/green entry '${String(action.desired.entry)}'`);
|
|
2687
|
+
}
|
|
2688
|
+
const primaryBlueAppId = recorded()[`coolify:app:${appStepKey(org, entry.key)}`];
|
|
2689
|
+
if (!primaryBlueAppId) {
|
|
2690
|
+
throw new Error(`${action.kind} for ${org.slug}: entry '${entry.key}' has no blue app recorded yet`);
|
|
2691
|
+
}
|
|
2692
|
+
// The blue app keeps the name it has: an app that ran before blue/green is adopted under its own name.
|
|
2693
|
+
if (!recorded()[bgKey.blueName(entry.key)]) {
|
|
2694
|
+
const blue = await this.ports.coolify.getApplication(primaryBlueAppId);
|
|
2695
|
+
if (blue.name) {
|
|
2696
|
+
context.recordScopeId(bgKey.blueName(entry.key), blue.name);
|
|
2697
|
+
await context.persistScopeIds();
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
const subject = entrySubject(org, entry, recorded());
|
|
2701
|
+
const runtime = this.ports.runtime ?? {};
|
|
2702
|
+
const deps = {
|
|
2703
|
+
coolify: this.ports.coolify,
|
|
2704
|
+
recorded,
|
|
2705
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
2706
|
+
persist: () => context.persistScopeIds(),
|
|
2707
|
+
projectUuid,
|
|
2708
|
+
environment,
|
|
2709
|
+
healthToken: async () => {
|
|
2710
|
+
if (typeof this.ports.heldKeys?.put !== 'function') {
|
|
2711
|
+
throw new Error(`${action.kind} for ${org.slug}: the executor's heldKeys cannot put the health token`);
|
|
2712
|
+
}
|
|
2713
|
+
const token = await this.ensureSharedSecret(org, healthTokenName(entry.key), recorded());
|
|
2714
|
+
this.handledSecrets.add(token);
|
|
2715
|
+
return token;
|
|
2716
|
+
},
|
|
2717
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
2718
|
+
runtime,
|
|
2719
|
+
sleep: runtime.sleep ?? defaultSleep,
|
|
2720
|
+
label: `${action.system}:${action.kind}:${entry.key}`,
|
|
2721
|
+
primaryBlueAppId,
|
|
2722
|
+
logDir: LOG_DIR,
|
|
2723
|
+
isRunning: isRunningOrStarting,
|
|
2724
|
+
};
|
|
2725
|
+
if (action.kind === 'bluegreen-lb') {
|
|
2726
|
+
await applyBlueGreenWeights(deps, subject, { uuid: serverUuid, primaryUuid });
|
|
2727
|
+
}
|
|
2728
|
+
else {
|
|
2729
|
+
await applyBlueGreenServer(deps, subject, { uuid: serverUuid, primaryUuid });
|
|
2730
|
+
}
|
|
2731
|
+
return undefined;
|
|
2732
|
+
}
|
|
2504
2733
|
async coolifyApp(action, org, context) {
|
|
2505
2734
|
const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
|
|
2506
2735
|
if (action.desired.enabled === false) {
|
|
@@ -2538,6 +2767,11 @@ export class FoundryStepExecutor {
|
|
|
2538
2767
|
}
|
|
2539
2768
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
2540
2769
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
2770
|
+
// A blue/green entry's app is its blue copy. An app that ran before blue/green keeps its own name and is
|
|
2771
|
+
// adopted as blue; an entry with no app yet gets `<base>-blue`. The name is recorded once decided.
|
|
2772
|
+
const blueNameKey = bgKey.blueName(entry.key);
|
|
2773
|
+
const blueNameRecorded = Boolean(context.recordedIds()[blueNameKey]);
|
|
2774
|
+
let appName = liveAppNameFor(org, entry, context.recordedIds());
|
|
2541
2775
|
// The recorded id first; the name when nothing is recorded, or when the
|
|
2542
2776
|
// recorded app is gone.
|
|
2543
2777
|
let existing = null;
|
|
@@ -2546,17 +2780,35 @@ export class FoundryStepExecutor {
|
|
|
2546
2780
|
try {
|
|
2547
2781
|
live = await this.ports.coolify.getApplication(recordedAppId);
|
|
2548
2782
|
existing = { uuid: recordedAppId };
|
|
2783
|
+
if (entry.blueGreen &&
|
|
2784
|
+
!blueNameRecorded &&
|
|
2785
|
+
live.name === colorAppName(appNameFor(org, entry), 'blue')) {
|
|
2786
|
+
appName = live.name;
|
|
2787
|
+
}
|
|
2549
2788
|
}
|
|
2550
2789
|
catch (error) {
|
|
2551
2790
|
if (!isNotFoundError(error))
|
|
2552
2791
|
throw error;
|
|
2553
2792
|
}
|
|
2554
2793
|
}
|
|
2794
|
+
if (!existing && entry.blueGreen && !blueNameRecorded) {
|
|
2795
|
+
const own = await this.ports.coolify.findApplicationByName(appNameFor(org, entry), {
|
|
2796
|
+
environmentIds: [environment.id],
|
|
2797
|
+
});
|
|
2798
|
+
if (own) {
|
|
2799
|
+
existing = own;
|
|
2800
|
+
appName = appNameFor(org, entry);
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2555
2803
|
if (!existing) {
|
|
2556
|
-
existing = await this.ports.coolify.findApplicationByName(
|
|
2804
|
+
existing = await this.ports.coolify.findApplicationByName(appName, {
|
|
2557
2805
|
environmentIds: [environment.id],
|
|
2558
2806
|
});
|
|
2559
2807
|
}
|
|
2808
|
+
if (entry.blueGreen && !blueNameRecorded) {
|
|
2809
|
+
context.recordScopeId(blueNameKey, appName);
|
|
2810
|
+
await context.persistScopeIds();
|
|
2811
|
+
}
|
|
2560
2812
|
// No entry gets Coolify `domains`: Coolify would add a Traefik router for them. The company's
|
|
2561
2813
|
// tunnel routes an entry's names straight to its container (also `<slug>.oikos.host`, through a
|
|
2562
2814
|
// record in the platform's oikos.host zone), which every entry reaches as `<slug>-<key>` (its
|
|
@@ -2602,7 +2854,7 @@ export class FoundryStepExecutor {
|
|
|
2602
2854
|
git_repository: appRepo,
|
|
2603
2855
|
git_branch: entry.branch,
|
|
2604
2856
|
build_pack: entry.buildPack,
|
|
2605
|
-
name:
|
|
2857
|
+
name: appName,
|
|
2606
2858
|
autogenerate_domain: false,
|
|
2607
2859
|
// A static site is served by Coolify's own web server on port 80.
|
|
2608
2860
|
ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
|
|
@@ -2655,7 +2907,7 @@ export class FoundryStepExecutor {
|
|
|
2655
2907
|
patch.domains = '';
|
|
2656
2908
|
}
|
|
2657
2909
|
if (!existing || patch.domains !== undefined) {
|
|
2658
|
-
patch.custom_internal_name =
|
|
2910
|
+
patch.custom_internal_name = appName;
|
|
2659
2911
|
}
|
|
2660
2912
|
// A `*` entry's proxy labels: Coolify takes `custom_labels` base64 encoded
|
|
2661
2913
|
// and uses them instead of the labels it generates from `domains`. Rule 4:
|
|
@@ -2709,8 +2961,8 @@ export class FoundryStepExecutor {
|
|
|
2709
2961
|
// `ensureLogVolume` whether this app was already running before this
|
|
2710
2962
|
// call -- see its own doc comment for why that decides whether to
|
|
2711
2963
|
// restart it.
|
|
2712
|
-
await this.ensureLogVolume(appId, org, Boolean(existing),
|
|
2713
|
-
await this.ensureEntryVolumes(appId, entry, Boolean(existing),
|
|
2964
|
+
await this.ensureLogVolume(appId, org, Boolean(existing), appName);
|
|
2965
|
+
await this.ensureEntryVolumes(appId, entry, Boolean(existing), appName);
|
|
2714
2966
|
// The app receives whatever it needs to sign in (gap P2). Unconditional,
|
|
2715
2967
|
// unlike the database push above: every app has a hostname, and an
|
|
2716
2968
|
// upsert of the same value on a later apply is a no-op, not a change.
|
|
@@ -2741,8 +2993,10 @@ export class FoundryStepExecutor {
|
|
|
2741
2993
|
// The public name answers from the container only once the tunnel step has written the
|
|
2742
2994
|
// direct route; before that (a first apply, or the one that moves it) the deployment is the check.
|
|
2743
2995
|
hostname &&
|
|
2996
|
+
// The public name goes through the balancer, which may answer from the other color.
|
|
2997
|
+
!entry.blueGreen &&
|
|
2744
2998
|
(context.recordedIds()[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY] ?? '[]') ===
|
|
2745
|
-
entryRoutesMarker(org)
|
|
2999
|
+
entryRoutesMarker(org, context.recordedIds())
|
|
2746
3000
|
? publicHealthCheck(org, context.recordedIds(), hostname, healthPath)
|
|
2747
3001
|
: { 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`);
|
|
2748
3002
|
await recordBuiltCommit(this.ports.coolify, { appId: appId, deploymentUuid: built.deploymentUuid, name: entry.key }, (key, value) => context.recordScopeId(key, value));
|
|
@@ -3216,11 +3470,21 @@ export class FoundryStepExecutor {
|
|
|
3216
3470
|
if ('missing' in wanted) {
|
|
3217
3471
|
throw new Error(`coolify:broker-entry for ${org.slug}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
|
|
3218
3472
|
}
|
|
3473
|
+
if (entry.workerApp) {
|
|
3474
|
+
// No Coolify app: the key is the held AUTH_APP_KEY the Worker app was given.
|
|
3475
|
+
const heldKey = await heldBrokerKey(this.ports.heldKeys, org, entry.key);
|
|
3476
|
+
await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, heldKey, context);
|
|
3477
|
+
return {};
|
|
3478
|
+
}
|
|
3219
3479
|
const appId = recorded[`coolify:app:${action.key}`];
|
|
3220
3480
|
if (!appId) {
|
|
3221
3481
|
throw new Error(`coolify:broker-entry for ${org.slug}: app ${action.key} is not recorded yet`);
|
|
3222
3482
|
}
|
|
3223
|
-
await this.
|
|
3483
|
+
const appKey = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview)?.value;
|
|
3484
|
+
if (!appKey || appKey.length < 32) {
|
|
3485
|
+
throw new Error(`coolify:broker-entry for ${org.slug}: app ${appId} has no AUTH_APP_KEY yet`);
|
|
3486
|
+
}
|
|
3487
|
+
await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, appKey, context);
|
|
3224
3488
|
return {};
|
|
3225
3489
|
}
|
|
3226
3490
|
/**
|
|
@@ -3242,15 +3506,15 @@ export class FoundryStepExecutor {
|
|
|
3242
3506
|
if (!appId) {
|
|
3243
3507
|
throw new Error(`coolify:boule-broker-entry for ${org.slug}: the Boule app is not recorded yet`);
|
|
3244
3508
|
}
|
|
3245
|
-
await this.writeAppBrokerEntry(org, broker, BOULE_BROKER_ENTRY_KEY, wanted.want, appId, context);
|
|
3246
|
-
return {};
|
|
3247
|
-
}
|
|
3248
|
-
/** The shared tail of both broker-entry steps: read the app's key, write the entry, record what was written. */
|
|
3249
|
-
async writeAppBrokerEntry(org, broker, entryKey, want, appId, context) {
|
|
3250
3509
|
const appKey = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview)?.value;
|
|
3251
3510
|
if (!appKey || appKey.length < 32) {
|
|
3252
3511
|
throw new Error(`coolify:broker-entry for ${org.slug}: app ${appId} has no AUTH_APP_KEY yet`);
|
|
3253
3512
|
}
|
|
3513
|
+
await this.writeAppBrokerEntry(org, broker, BOULE_BROKER_ENTRY_KEY, wanted.want, appKey, context);
|
|
3514
|
+
return {};
|
|
3515
|
+
}
|
|
3516
|
+
/** The shared tail of both broker-entry steps: read the app's key, write the entry, record what was written. */
|
|
3517
|
+
async writeAppBrokerEntry(_org, broker, entryKey, want, appKey, context) {
|
|
3254
3518
|
const previousRaw = context.recordedIds()[brokerEntryAppliedScopeKey(entryKey)];
|
|
3255
3519
|
const result = await writeBrokerEntry(broker, {
|
|
3256
3520
|
want,
|
|
@@ -5540,7 +5804,12 @@ export class FoundryStepExecutor {
|
|
|
5540
5804
|
if (mailWanted && !('platformApex' in action.desired)) {
|
|
5541
5805
|
throw new Error(`coolify:boule for ${org.slug}: this step's plan carries no platformApex; plan this org's Boule again`);
|
|
5542
5806
|
}
|
|
5543
|
-
|
|
5807
|
+
// What the plan decided Boule is given beyond its sign-in env (C3).
|
|
5808
|
+
const credentialWants = {
|
|
5809
|
+
archonCredential: action.desired.archonCredential === true,
|
|
5810
|
+
customersDirectory: action.desired.customersDirectory === true,
|
|
5811
|
+
};
|
|
5812
|
+
const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl, mailWanted, credentialWants);
|
|
5544
5813
|
if (missing.length > 0) {
|
|
5545
5814
|
throw new Error(`Boule for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
|
|
5546
5815
|
}
|
|
@@ -5577,8 +5846,11 @@ export class FoundryStepExecutor {
|
|
|
5577
5846
|
});
|
|
5578
5847
|
const liveRows = (await this.ports.coolify.listEnvs(app.appId)).filter((e) => !e.is_preview);
|
|
5579
5848
|
const liveEnv = new Map(liveRows.map((e) => [e.key, e.value]));
|
|
5580
|
-
|
|
5849
|
+
let envChanged = bouleEnvDrift(env, Object.fromEntries(liveEnv)).length > 0;
|
|
5581
5850
|
await this.dropStaleSignInUpstream(app.appId, env, liveRows);
|
|
5851
|
+
// Customers switched off: revoke the directory credential and take the env away (C3).
|
|
5852
|
+
if (await this.dropCustomersCredential(app.appId, org, context, liveRows))
|
|
5853
|
+
envChanged = true;
|
|
5582
5854
|
const envs = Object.entries(env).map(([key, value]) => ({ key, value }));
|
|
5583
5855
|
const cookieSecret = await this.ensureAuthCookieSecret(app.appId);
|
|
5584
5856
|
if (cookieSecret)
|
|
@@ -5602,11 +5874,39 @@ export class FoundryStepExecutor {
|
|
|
5602
5874
|
this.handledSecrets.add(bridgeSecret);
|
|
5603
5875
|
envs.push({ key: BOULE_BRIDGE_SECRET_ENV, value: bridgeSecret });
|
|
5604
5876
|
}
|
|
5877
|
+
// Held, minted once (C3). Compared with the live value so a changed one restarts the app.
|
|
5878
|
+
const credentialEnvs = [];
|
|
5879
|
+
let archonPushedAt = null;
|
|
5880
|
+
if (credentialWants.archonCredential) {
|
|
5881
|
+
const archon = await this.ensureBouleArchonCredential(org, context);
|
|
5882
|
+
archonPushedAt = archon.pushedAt;
|
|
5883
|
+
credentialEnvs.push({ key: BOULE_ARCHON_CREDENTIAL_ENV, value: archon.value });
|
|
5884
|
+
}
|
|
5885
|
+
if (credentialWants.customersDirectory) {
|
|
5886
|
+
const directory = await this.ensureCustomersDirectoryCredential(org, context);
|
|
5887
|
+
if (directory)
|
|
5888
|
+
credentialEnvs.push({ key: BOULE_CUSTOMERS_CREDENTIAL_ENV, value: directory });
|
|
5889
|
+
}
|
|
5890
|
+
for (const credential of credentialEnvs) {
|
|
5891
|
+
this.handledSecrets.add(credential.value);
|
|
5892
|
+
envs.push(credential);
|
|
5893
|
+
if (liveEnv.get(credential.key) !== credential.value)
|
|
5894
|
+
envChanged = true;
|
|
5895
|
+
}
|
|
5605
5896
|
try {
|
|
5606
5897
|
await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
|
|
5898
|
+
// Only a push that reached Boule's env counts: a failed one is minted and pushed again next apply.
|
|
5899
|
+
if (archonPushedAt) {
|
|
5900
|
+
context.recordScopeId(BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, archonPushedAt);
|
|
5901
|
+
await context.persistScopeIds();
|
|
5902
|
+
}
|
|
5607
5903
|
}
|
|
5608
5904
|
catch (error) {
|
|
5609
|
-
const minted = [
|
|
5905
|
+
const minted = [
|
|
5906
|
+
peerPassword,
|
|
5907
|
+
bridgeSecret,
|
|
5908
|
+
...credentialEnvs.map((credential) => credential.value),
|
|
5909
|
+
].filter((v) => v !== null);
|
|
5610
5910
|
if (minted.length === 0)
|
|
5611
5911
|
throw error;
|
|
5612
5912
|
throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), minted));
|
|
@@ -5656,6 +5956,125 @@ export class FoundryStepExecutor {
|
|
|
5656
5956
|
this.recordSignInEnvLive(context, 'boule', bouleHostnameFor(org), env);
|
|
5657
5957
|
return { resourceId: app.appId };
|
|
5658
5958
|
}
|
|
5959
|
+
/**
|
|
5960
|
+
* C3: the Archon credential of an org's Boule. Archon's mint rotates on every call, so it is called only
|
|
5961
|
+
* when none was pushed or the recorded push time (no secret) is 335 days old. Otherwise the held value is
|
|
5962
|
+
* written again unchanged. The secret is held first; the caller records `pushedAt` only after the env write
|
|
5963
|
+
* succeeded, so a failed hold or a failed push is mended by the next apply minting again. Errors name no secret.
|
|
5964
|
+
*/
|
|
5965
|
+
async ensureBouleArchonCredential(org, context) {
|
|
5966
|
+
const at = `coolify:boule for ${org.slug}`;
|
|
5967
|
+
const mint = this.ports.mintBouleArchonCredential;
|
|
5968
|
+
const heldKeys = this.ports.heldKeys;
|
|
5969
|
+
if (!mint)
|
|
5970
|
+
throw new Error(`${at}: this executor was built without mintBouleArchonCredential`);
|
|
5971
|
+
if (!heldKeys?.put) {
|
|
5972
|
+
throw new Error(`${at}: the Archon credential needs a held-key store that can put`);
|
|
5973
|
+
}
|
|
5974
|
+
const put = heldKeys.put;
|
|
5975
|
+
const ref = bouleArchonCredentialRef(org.id);
|
|
5976
|
+
const held = await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: read the held Archon credential`, () => heldKeys.resolve(ref));
|
|
5977
|
+
const nowMs = context.now?.() ?? Date.now();
|
|
5978
|
+
if (held && !archonCredentialRenewDue(context.recordedIds(), nowMs)) {
|
|
5979
|
+
return { value: held, pushedAt: null };
|
|
5980
|
+
}
|
|
5981
|
+
const secret = await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: mint the Archon credential`, () => mint(org.id));
|
|
5982
|
+
this.handledSecrets.add(secret);
|
|
5983
|
+
if (!(await this.holdWithRetry(ref, secret, put, `${at}: hold the Archon credential`))) {
|
|
5984
|
+
throw new Error(`${at}: the Archon credential could not be held after ${BOULE_CREDENTIAL_ATTEMPTS} attempts; the next apply mints it again`);
|
|
5985
|
+
}
|
|
5986
|
+
return { value: secret, pushedAt: new Date(nowMs).toISOString() };
|
|
5987
|
+
}
|
|
5988
|
+
/**
|
|
5989
|
+
* C3: the read-only credential for the company's customers organisation. Held first. Minting revokes every
|
|
5990
|
+
* earlier token of the machine user, so a hold that failed is repaired by the next apply minting again and
|
|
5991
|
+
* leaves one live token. `null` while the customers organisation is not recorded yet.
|
|
5992
|
+
*/
|
|
5993
|
+
async ensureCustomersDirectoryCredential(org, context) {
|
|
5994
|
+
const at = `coolify:boule for ${org.slug}`;
|
|
5995
|
+
const customersOrgId = customersOrgIdOf(org, context.recordedIds());
|
|
5996
|
+
if (!customersOrgId)
|
|
5997
|
+
return null;
|
|
5998
|
+
const zitadel = this.ports.zitadel;
|
|
5999
|
+
const heldKeys = this.ports.heldKeys;
|
|
6000
|
+
if (!zitadel.mintOrgDirectoryReader) {
|
|
6001
|
+
throw new Error(`${at}: this executor's ZITADEL port cannot mint a customers directory credential`);
|
|
6002
|
+
}
|
|
6003
|
+
if (!heldKeys?.put) {
|
|
6004
|
+
throw new Error(`${at}: the customers directory credential needs a held-key store that can put`);
|
|
6005
|
+
}
|
|
6006
|
+
const put = heldKeys.put;
|
|
6007
|
+
const ref = customersDirectoryRef(org.id);
|
|
6008
|
+
const held = await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: read the held directory credential`, () => heldKeys.resolve(ref));
|
|
6009
|
+
if (held)
|
|
6010
|
+
return held;
|
|
6011
|
+
const minted = await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: mint the directory credential`, () => zitadel.mintOrgDirectoryReader(customersOrgId, {
|
|
6012
|
+
userName: CUSTOMERS_DIRECTORY_USER_NAME,
|
|
6013
|
+
name: 'Foundry customers directory (read-only)',
|
|
6014
|
+
}));
|
|
6015
|
+
this.handledSecrets.add(minted.token);
|
|
6016
|
+
context.recordScopeId(CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, minted.userId);
|
|
6017
|
+
await context.persistScopeIds();
|
|
6018
|
+
if (!(await this.holdWithRetry(ref, minted.token, put, `${at}: hold the directory credential`))) {
|
|
6019
|
+
throw new Error(`${at}: the directory credential of ZITADEL user ${minted.userId} could not be held after ${BOULE_CREDENTIAL_ATTEMPTS} attempts; the next apply mints it again and revokes this one`);
|
|
6020
|
+
}
|
|
6021
|
+
return minted.token;
|
|
6022
|
+
}
|
|
6023
|
+
/** `put`, tried up to {@link BOULE_CREDENTIAL_ATTEMPTS} times; true once one attempt succeeds. The cause is dropped. */
|
|
6024
|
+
async holdWithRetry(ref, value, put, label) {
|
|
6025
|
+
for (let attempt = 1; attempt <= BOULE_CREDENTIAL_ATTEMPTS; attempt++) {
|
|
6026
|
+
try {
|
|
6027
|
+
await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, label, () => put(ref, value));
|
|
6028
|
+
return true;
|
|
6029
|
+
}
|
|
6030
|
+
catch {
|
|
6031
|
+
// Retried; the cause may echo the value.
|
|
6032
|
+
}
|
|
6033
|
+
}
|
|
6034
|
+
return false;
|
|
6035
|
+
}
|
|
6036
|
+
/**
|
|
6037
|
+
* C3: customers switched off. Revokes the directory credential's tokens first, then removes the customers
|
|
6038
|
+
* env from Boule, then forgets the held value and the user id. Each step is repeated safely, so a run that
|
|
6039
|
+
* died halfway is finished by the next. Returns true when Boule's env changed.
|
|
6040
|
+
*/
|
|
6041
|
+
async dropCustomersCredential(appId, org, context, liveRows) {
|
|
6042
|
+
if (org.customers?.enabled)
|
|
6043
|
+
return false;
|
|
6044
|
+
const at = `coolify:boule for ${org.slug}`;
|
|
6045
|
+
const recorded = context.recordedIds();
|
|
6046
|
+
const userId = recorded[CUSTOMERS_DIRECTORY_USER_SCOPE_KEY];
|
|
6047
|
+
const customersOrgId = customersOrgIdOf(org, recorded);
|
|
6048
|
+
const zitadel = this.ports.zitadel;
|
|
6049
|
+
if (userId && customersOrgId && zitadel.revokeOrgDirectoryReader) {
|
|
6050
|
+
await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: revoke the directory credential`, () => zitadel.revokeOrgDirectoryReader(customersOrgId, userId));
|
|
6051
|
+
}
|
|
6052
|
+
let changed = false;
|
|
6053
|
+
for (const row of liveRows) {
|
|
6054
|
+
if (row.key !== BOULE_CUSTOMERS_ORG_ENV && row.key !== BOULE_CUSTOMERS_CREDENTIAL_ENV)
|
|
6055
|
+
continue;
|
|
6056
|
+
if (!row.uuid)
|
|
6057
|
+
continue;
|
|
6058
|
+
try {
|
|
6059
|
+
await this.ports.coolify.deleteEnv(appId, row.uuid);
|
|
6060
|
+
changed = true;
|
|
6061
|
+
}
|
|
6062
|
+
catch (error) {
|
|
6063
|
+
if (!isNotFoundError(error))
|
|
6064
|
+
throw error;
|
|
6065
|
+
}
|
|
6066
|
+
}
|
|
6067
|
+
if (userId && (!customersOrgId || zitadel.revokeOrgDirectoryReader)) {
|
|
6068
|
+
const heldKeys = this.ports.heldKeys;
|
|
6069
|
+
if (heldKeys?.put) {
|
|
6070
|
+
const put = heldKeys.put;
|
|
6071
|
+
const ref = customersDirectoryRef(org.id);
|
|
6072
|
+
await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: forget the directory credential`, () => put(ref, ''));
|
|
6073
|
+
}
|
|
6074
|
+
context.recordScopeId(CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, '');
|
|
6075
|
+
}
|
|
6076
|
+
return changed;
|
|
6077
|
+
}
|
|
5659
6078
|
/**
|
|
5660
6079
|
* Gives Boule `LETTERMINT_API_TOKEN`: the platform's held key for an org on
|
|
5661
6080
|
* the platform's own apex, else the org's `<slug>-app` token through the
|
|
@@ -6258,6 +6677,42 @@ export class FoundryStepExecutor {
|
|
|
6258
6677
|
}
|
|
6259
6678
|
return { resourceId: registered.id };
|
|
6260
6679
|
}
|
|
6680
|
+
/** The blue/green database route (`plan/remote-database.ts`). */
|
|
6681
|
+
async remoteDatabaseRoute(action, org, context) {
|
|
6682
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
6683
|
+
const desired = action.desired;
|
|
6684
|
+
// The tunnel's TCP rule names the database by the host the apps' own `DATABASE_URL` uses.
|
|
6685
|
+
const db = await this.ports.coolify.getDatabase(desired.database);
|
|
6686
|
+
if (db.internal_db_url)
|
|
6687
|
+
this.handledSecrets.add(db.internal_db_url);
|
|
6688
|
+
let databaseHost = '';
|
|
6689
|
+
try {
|
|
6690
|
+
databaseHost = new URL(db.internal_db_url ?? '').hostname;
|
|
6691
|
+
}
|
|
6692
|
+
catch {
|
|
6693
|
+
// Fixed text below: the URL holds the database password.
|
|
6694
|
+
}
|
|
6695
|
+
if (!databaseHost) {
|
|
6696
|
+
throw new Error(`cloudflare:db-route for ${org.slug}: the database reports no usable address`);
|
|
6697
|
+
}
|
|
6698
|
+
await applyRemoteDatabase({
|
|
6699
|
+
tunnels: this.tunnelPort(),
|
|
6700
|
+
access: this.accessPort(),
|
|
6701
|
+
dns: this.dnsPort(),
|
|
6702
|
+
zones: this.zonePort(),
|
|
6703
|
+
coolify: this.ports.coolify,
|
|
6704
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
6705
|
+
}, {
|
|
6706
|
+
org,
|
|
6707
|
+
desired,
|
|
6708
|
+
databaseHost,
|
|
6709
|
+
projectUuid,
|
|
6710
|
+
recorded: () => context.recordedIds(),
|
|
6711
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
6712
|
+
persist: () => context.persistScopeIds(),
|
|
6713
|
+
});
|
|
6714
|
+
return {};
|
|
6715
|
+
}
|
|
6261
6716
|
/** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
|
|
6262
6717
|
async deliverWithRetry(ref, value) {
|
|
6263
6718
|
for (let attempt = 1; attempt <= JOBS_DELIVERY_ATTEMPTS; attempt++) {
|
|
@@ -6337,17 +6792,19 @@ export class FoundryStepExecutor {
|
|
|
6337
6792
|
const loginHost = action.desired.loginHost ?? null;
|
|
6338
6793
|
// A hosting entry's names (and its wildcard) go straight to its container on the company network:
|
|
6339
6794
|
// the container carries `traefik.enable=false`, so the proxy has no route for it.
|
|
6340
|
-
const entryOrigins = new Map(entryRoutes(org).map((r) => [r.hostname, r.service]));
|
|
6795
|
+
const entryOrigins = new Map(entryRoutes(org, context.recordedIds()).map((r) => [r.hostname, r.service]));
|
|
6341
6796
|
const ingress = tunnelIngressHostnames(org, loginHost).map((hostname) => ({
|
|
6342
6797
|
hostname,
|
|
6343
6798
|
service: hostname === loginHost
|
|
6344
6799
|
? `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}`
|
|
6345
6800
|
: (entryOrigins.get(hostname) ?? COOLIFY_PROXY_ORIGIN),
|
|
6346
6801
|
}));
|
|
6802
|
+
const httpIngress = [...ingress, { service: 'http_status:404' }];
|
|
6347
6803
|
await this.tunnelPort().putTunnelConfig(tunnel.id, {
|
|
6348
|
-
ingress:
|
|
6804
|
+
ingress: mergeTcpIngress(httpIngress, recordedTcpRoutes(context.recordedIds())),
|
|
6349
6805
|
});
|
|
6350
|
-
context.recordScopeId(
|
|
6806
|
+
context.recordScopeId(TUNNEL_HTTP_INGRESS_SCOPE_KEY, JSON.stringify(httpIngress));
|
|
6807
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, entryRoutesMarker(org, context.recordedIds()));
|
|
6351
6808
|
// #235: a connector for this org's own tunnel, the same call every
|
|
6352
6809
|
// platform tunnel step already makes (Decided, William, 2026-10-01:
|
|
6353
6810
|
// one connector per org tunnel, never a shared tunnel with appended
|
|
@@ -8091,6 +8548,17 @@ export class FoundryStepExecutor {
|
|
|
8091
8548
|
await this.teardownWorker(entryKey, target.id || undefined, recordedIds, workers, `cloudflare:worker '${target.key}' for ${org.slug}`);
|
|
8092
8549
|
return;
|
|
8093
8550
|
}
|
|
8551
|
+
case 'cloudflare:worker-app': {
|
|
8552
|
+
// ADR 0002: route, record, containers and script of a Worker app.
|
|
8553
|
+
const workers = this.ports.workerApps;
|
|
8554
|
+
if (!workers) {
|
|
8555
|
+
throw new Error(`cloudflare:worker-app '${target.key}' for ${org.slug}: this executor was built without a WorkerAppPort; cannot delete it`);
|
|
8556
|
+
}
|
|
8557
|
+
const entryKey = target.key.slice(org.id.length + 1);
|
|
8558
|
+
const at = `cloudflare:worker-app '${target.key}' for ${org.slug}`;
|
|
8559
|
+
await teardownWorkerApp(this.workerAppDeps(workers), entryKey, target.id || undefined, recordedIds, (label, task) => withCallTimeout(WORKER_APP_CALL_TIMEOUT_MS, `${at}: ${label}`, task), org.id);
|
|
8560
|
+
return;
|
|
8561
|
+
}
|
|
8094
8562
|
case 'edge:key': {
|
|
8095
8563
|
// F7 (#317): an entry's key (`<orgId>:<entry>`); edge has no org-root key.
|
|
8096
8564
|
// No port means no way to revoke it -- thrown, not silently
|
|
@@ -8114,8 +8582,32 @@ export class FoundryStepExecutor {
|
|
|
8114
8582
|
return;
|
|
8115
8583
|
}
|
|
8116
8584
|
case 'coolify:app':
|
|
8585
|
+
case 'coolify:bg-app':
|
|
8117
8586
|
await this.ports.coolify.deleteApplication(target.id);
|
|
8118
8587
|
return;
|
|
8588
|
+
case 'cloudflare:bg-access':
|
|
8589
|
+
await this.accessPort().deleteAccessApp(target.id);
|
|
8590
|
+
return;
|
|
8591
|
+
case 'cloudflare:bg-token':
|
|
8592
|
+
await this.accessPort().removeServiceToken(target.id);
|
|
8593
|
+
return;
|
|
8594
|
+
case 'cloudflare:bg-dns': {
|
|
8595
|
+
// `target.id` is the recorded route ({ hostname, service }); its name lives in the org's zone.
|
|
8596
|
+
const hostname = JSON.parse(target.id).hostname;
|
|
8597
|
+
const zone = org.domain ? await this.zonePort().findZoneByApex(org.domain) : undefined;
|
|
8598
|
+
if (typeof hostname !== 'string' || !zone)
|
|
8599
|
+
return;
|
|
8600
|
+
const dns = this.dnsPort();
|
|
8601
|
+
for (const record of await dns.findDnsRecord(zone.name, hostname, 'CNAME')) {
|
|
8602
|
+
if (record.content.endsWith('.cfargotunnel.com')) {
|
|
8603
|
+
await dns.deleteDnsRecord(zone.name, record.id);
|
|
8604
|
+
}
|
|
8605
|
+
}
|
|
8606
|
+
return;
|
|
8607
|
+
}
|
|
8608
|
+
case 'coolify:bg-database':
|
|
8609
|
+
await this.ports.coolify.deleteDatabase(target.id);
|
|
8610
|
+
return;
|
|
8119
8611
|
case 'coolify:database':
|
|
8120
8612
|
await this.ports.coolify.deleteDatabase(target.id);
|
|
8121
8613
|
return;
|