@wtfalch/foundry 0.41.2 → 0.43.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 +7 -3
- package/dist/index.js +4 -2
- package/dist/migrations/0054_org_signin_in_app.sql +6 -0
- package/dist/migrations/0055_release_requests.sql +20 -0
- package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
- package/dist/org.d.ts +30 -0
- package/dist/org.js +52 -1
- package/dist/plan/agora.d.ts +4 -1
- package/dist/plan/agora.js +16 -2
- 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 +5 -2
- package/dist/plan/boule.js +14 -2
- package/dist/plan/cloudflared-connector.d.ts +26 -0
- package/dist/plan/cloudflared-connector.js +37 -0
- package/dist/plan/coolify-previews.d.ts +10 -0
- package/dist/plan/coolify-previews.js +14 -0
- package/dist/plan/diff.d.ts +6 -0
- package/dist/plan/diff.js +123 -11
- package/dist/plan/edge-ratelimits.d.ts +5 -0
- package/dist/plan/edge-ratelimits.js +16 -6
- package/dist/plan/executor.d.ts +79 -0
- package/dist/plan/executor.js +527 -31
- package/dist/plan/login-host.d.ts +23 -2
- package/dist/plan/login-host.js +30 -5
- package/dist/plan/own-diff.js +6 -0
- package/dist/plan/plan-apply.d.ts +51 -0
- package/dist/plan/plan-apply.js +222 -0
- package/dist/plan/platform-diff.d.ts +9 -0
- package/dist/plan/platform-diff.js +28 -1
- package/dist/plan/platform-org.d.ts +6 -0
- package/dist/plan/platform-org.js +5 -0
- package/dist/plan/platform-providers.d.ts +31 -2
- package/dist/plan/platform-providers.js +345 -13
- package/dist/plan/providers-readers.js +6 -2
- package/dist/plan/proxy-labels.d.ts +2 -2
- package/dist/plan/proxy-labels.js +12 -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/sign-in-archon.d.ts +46 -0
- package/dist/plan/sign-in-archon.js +71 -0
- package/dist/plan/sign-in-bindings.d.ts +16 -0
- package/dist/plan/sign-in-bindings.js +44 -0
- package/dist/plan/sign-in.d.ts +135 -0
- package/dist/plan/sign-in.js +172 -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/store/postgres.d.ts +8 -0
- package/dist/store/postgres.js +62 -3
- package/dist/store/schema.d.ts +171 -0
- package/dist/store/schema.js +14 -0
- package/package.json +1 -1
- package/versions.digests.json +2 -1
- package/versions.json +2 -1
package/dist/plan/executor.js
CHANGED
|
@@ -7,6 +7,8 @@ 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 { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, } from './blue-green-apply.js';
|
|
11
|
+
import { bgKey, colorAppName, entrySubject, healthTokenName, liveAppNameFor, splitServers, } from './blue-green.js';
|
|
10
12
|
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';
|
|
11
13
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
12
14
|
import { recordBuiltCommit } from './build-record.js';
|
|
@@ -14,6 +16,7 @@ import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerNam
|
|
|
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';
|
|
15
17
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
16
18
|
import { BACKUP_SCOPE_PREFIX, COMPANY_BACKUP_DATABASE_NAMES, backupScopeKey, } from './company-backups.js';
|
|
19
|
+
import { previewsOn } from './coolify-previews.js';
|
|
17
20
|
import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
|
|
18
21
|
import { waitForNewDeployment, } from './deploy-health.js';
|
|
19
22
|
import { PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, onPlatformNetwork, placed, placedByScope, } from './destinations.js';
|
|
@@ -43,7 +46,11 @@ import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY
|
|
|
43
46
|
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';
|
|
44
47
|
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
48
|
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';
|
|
49
|
+
import { createReleaseHost } from './release-host.js';
|
|
50
|
+
import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
|
|
51
|
+
import { editSignInBindings } from './sign-in-bindings.js';
|
|
46
52
|
import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
|
|
53
|
+
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';
|
|
47
54
|
import { moveSigninDatabase } from './signin-db-move.js';
|
|
48
55
|
import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
|
|
49
56
|
/**
|
|
@@ -122,6 +129,32 @@ export class NotBuiltError extends Error {
|
|
|
122
129
|
* so a host is never read as "not in use" purely because ZITADEL's own
|
|
123
130
|
* casing or slashing differs from this step's.
|
|
124
131
|
*/
|
|
132
|
+
/**
|
|
133
|
+
* The in-app hosts a login-host step carries, read back and checked: a plan from an older build carries
|
|
134
|
+
* none, and a malformed entry is refused by name (never echoed) rather than bound.
|
|
135
|
+
*/
|
|
136
|
+
function inAppAppsOf(raw, slug) {
|
|
137
|
+
if (raw === undefined)
|
|
138
|
+
return [];
|
|
139
|
+
if (!Array.isArray(raw))
|
|
140
|
+
throw new Error(`zitadel:login-host for ${slug}: apps is not a list`);
|
|
141
|
+
return raw.map((entry) => {
|
|
142
|
+
const e = entry;
|
|
143
|
+
if (!e ||
|
|
144
|
+
(e.app !== 'agora' && e.app !== 'boule') ||
|
|
145
|
+
typeof e.host !== 'string' ||
|
|
146
|
+
!e.host ||
|
|
147
|
+
typeof e.clientId !== 'string' ||
|
|
148
|
+
!e.clientId) {
|
|
149
|
+
throw new Error(`zitadel:login-host for ${slug}: an in-app host entry is malformed`);
|
|
150
|
+
}
|
|
151
|
+
return { app: e.app, host: e.host, clientId: e.clientId };
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/** The ZITADEL ids of the company's Agora and Boule clients: the apps in-app sign-in can move. */
|
|
155
|
+
function ownInAppAppIds(recorded) {
|
|
156
|
+
return new Set([recorded['zitadel:app:agora'], recorded['zitadel:app:boule']].filter((id) => Boolean(id)));
|
|
157
|
+
}
|
|
125
158
|
function normalizedLoginBaseUri(uri) {
|
|
126
159
|
return uri.toLowerCase().replace(/\/$/, '');
|
|
127
160
|
}
|
|
@@ -854,7 +887,11 @@ function limitProfileOf(action) {
|
|
|
854
887
|
case 'coolify:boule':
|
|
855
888
|
case 'coolify:boule-broker-entry':
|
|
856
889
|
return LIMITS.boule;
|
|
890
|
+
case 'coolify:bluegreen':
|
|
891
|
+
case 'coolify:bluegreen-lb':
|
|
892
|
+
return LIMITS.companyApp;
|
|
857
893
|
case 'cloudflare:tunnel':
|
|
894
|
+
case 'coolify:bluegreen-connector':
|
|
858
895
|
return LIMITS.connector;
|
|
859
896
|
default:
|
|
860
897
|
return action.system === 'coolify' ? LIMITS.companyOther : undefined;
|
|
@@ -1080,6 +1117,10 @@ export class FoundryStepExecutor {
|
|
|
1080
1117
|
return this.coolifyApp(action, org, context);
|
|
1081
1118
|
case 'coolify:network':
|
|
1082
1119
|
return this.coolifyNetwork(action, org, context);
|
|
1120
|
+
case 'coolify:bluegreen':
|
|
1121
|
+
case 'coolify:bluegreen-lb':
|
|
1122
|
+
case 'coolify:bluegreen-connector':
|
|
1123
|
+
return this.coolifyBlueGreen(action, org, context);
|
|
1083
1124
|
case 'coolify:broker-entry':
|
|
1084
1125
|
return this.coolifyBrokerEntry(action, org, context);
|
|
1085
1126
|
case 'coolify:database':
|
|
@@ -1101,7 +1142,7 @@ export class FoundryStepExecutor {
|
|
|
1101
1142
|
case 'cloudflare:gate':
|
|
1102
1143
|
return this.cloudflareGate(action, org, context);
|
|
1103
1144
|
case 'cloudflare:ratelimit':
|
|
1104
|
-
return this.cloudflareRateLimit(org);
|
|
1145
|
+
return this.cloudflareRateLimit(org, context);
|
|
1105
1146
|
case 'cloudflare:worker':
|
|
1106
1147
|
return this.cloudflareWorker(action, org, context);
|
|
1107
1148
|
case 'files:organisation':
|
|
@@ -1140,6 +1181,8 @@ export class FoundryStepExecutor {
|
|
|
1140
1181
|
return this.bouleBrokerEntry(org, context);
|
|
1141
1182
|
case 'jobs:registration':
|
|
1142
1183
|
return this.jobsRegistration(action, org, context);
|
|
1184
|
+
case 'cloudflare:db-route':
|
|
1185
|
+
return this.remoteDatabaseRoute(action, org, context);
|
|
1143
1186
|
case 'coolify:own-auth':
|
|
1144
1187
|
return this.ownAuth(action, org, context);
|
|
1145
1188
|
case 'coolify:own-auth-db-move':
|
|
@@ -1310,7 +1353,7 @@ export class FoundryStepExecutor {
|
|
|
1310
1353
|
* `cloudflareAccess`'s own refusal when a foreign Access app already
|
|
1311
1354
|
* holds the hostname.
|
|
1312
1355
|
*/
|
|
1313
|
-
async cloudflareRateLimit(org) {
|
|
1356
|
+
async cloudflareRateLimit(org, context) {
|
|
1314
1357
|
if (!org.domain) {
|
|
1315
1358
|
throw new Error(`cloudflare:ratelimit for ${org.slug}: hosting.usesDomain is set but domain is null`);
|
|
1316
1359
|
}
|
|
@@ -1319,20 +1362,41 @@ export class FoundryStepExecutor {
|
|
|
1319
1362
|
if (!zone) {
|
|
1320
1363
|
throw new Error(`cloudflare:ratelimit for ${org.slug}: no Cloudflare zone found for ${org.domain} -- cloudflare:zone must provision it first`);
|
|
1321
1364
|
}
|
|
1365
|
+
// The zone's one Free-plan rule: the base one, or with in-app sign-in the same limit that also
|
|
1366
|
+
// covers POSTs under `/sign-in/` (static assets there stay unlimited).
|
|
1367
|
+
const inApp = inAppSignIn(org);
|
|
1368
|
+
const wanted = inApp
|
|
1369
|
+
? { description: SIGN_IN_RATE_LIMIT_DESCRIPTION, expression: SIGN_IN_RATE_LIMIT_EXPRESSION }
|
|
1370
|
+
: { description: RATE_LIMIT_RULE_DESCRIPTION, expression: SIGN_IN_PATH_EXPRESSION };
|
|
1371
|
+
const ours = [RATE_LIMIT_RULE_DESCRIPTION, SIGN_IN_RATE_LIMIT_DESCRIPTION];
|
|
1322
1372
|
const rateLimits = this.rateLimitPort();
|
|
1323
1373
|
const rules = await rateLimits.listRateLimitRules(zone.id);
|
|
1324
|
-
const own = rules.find((r) => r.description ===
|
|
1374
|
+
const own = rules.find((r) => r.description === wanted.description);
|
|
1325
1375
|
if (!own) {
|
|
1326
|
-
const foreign = rules
|
|
1376
|
+
const foreign = rules.find((r) => !ours.includes(r.description ?? ''));
|
|
1327
1377
|
if (foreign) {
|
|
1328
1378
|
throw new Error(`cloudflare:ratelimit for ${org.slug}: zone ${org.domain} already has a rate limiting rule ('${foreign.description ?? foreign.id}') foundry did not create; the Free plan allows only one rule per zone, so foundry does not adopt or replace it`);
|
|
1329
1379
|
}
|
|
1380
|
+
// The other variant of foundry's own rule holds the zone's one slot: remove it, then make this one.
|
|
1381
|
+
const stale = rules.find((r) => ours.includes(r.description ?? ''));
|
|
1382
|
+
if (stale) {
|
|
1383
|
+
if (!rateLimits.deleteRateLimitRule) {
|
|
1384
|
+
throw new Error(`cloudflare:ratelimit for ${org.slug}: the rate limit rule must change but this host's rate limit port cannot delete a rule`);
|
|
1385
|
+
}
|
|
1386
|
+
try {
|
|
1387
|
+
await rateLimits.deleteRateLimitRule(zone.id, stale.id);
|
|
1388
|
+
}
|
|
1389
|
+
catch (error) {
|
|
1390
|
+
if (!isNotFoundError(error))
|
|
1391
|
+
throw error;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1330
1394
|
}
|
|
1331
1395
|
// Edge adopts a live rule with no edge row (made before edge owned rate
|
|
1332
1396
|
// limits) and then makes the rule once: a repeat returns it unchanged.
|
|
1333
1397
|
const created = await rateLimits.createRateLimitRule(zone.id, {
|
|
1334
|
-
description:
|
|
1335
|
-
expression:
|
|
1398
|
+
description: wanted.description,
|
|
1399
|
+
expression: wanted.expression,
|
|
1336
1400
|
action: 'block',
|
|
1337
1401
|
ratelimit: {
|
|
1338
1402
|
characteristics: ['cf.colo.id', 'ip.src'],
|
|
@@ -1341,6 +1405,8 @@ export class FoundryStepExecutor {
|
|
|
1341
1405
|
mitigation_timeout: RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS,
|
|
1342
1406
|
},
|
|
1343
1407
|
});
|
|
1408
|
+
// Last: a crash before here plans the step again.
|
|
1409
|
+
context.recordScopeId(SIGN_IN_RATE_LIMIT_SCOPE_KEY, inApp ? '1' : '');
|
|
1344
1410
|
return { resourceId: created.id };
|
|
1345
1411
|
}
|
|
1346
1412
|
/**
|
|
@@ -1745,6 +1811,13 @@ export class FoundryStepExecutor {
|
|
|
1745
1811
|
* client id that binding holds. Apps created later are not in it yet:
|
|
1746
1812
|
* the next run replans (`diff.ts`'s `planLoginHost`) and moves them.
|
|
1747
1813
|
*
|
|
1814
|
+
* In-app sign-in (`signIn.inApp`, `plan/sign-in.ts` has the order): an app host is trusted first. It is
|
|
1815
|
+
* bound, and its app pointed at `/sign-in/`, only once its app runs `SIGNIN_UPSTREAM` (the app step
|
|
1816
|
+
* records that). The bind moves the app's client id off the company's binding in the same write (the
|
|
1817
|
+
* login refuses a client id in two bindings); a company binding left with no client is removed.
|
|
1818
|
+
* Switched off, the client id goes back and the base URI returns to the login host, and a trusted
|
|
1819
|
+
* domain is given up only after the app dropped the env.
|
|
1820
|
+
*
|
|
1748
1821
|
* #274: when the org's host has moved (`recorded[key]`, the *previous*
|
|
1749
1822
|
* run's resourceId, differs from this run's `host`), also removes every
|
|
1750
1823
|
* host that is no longer the org's current one from both places --
|
|
@@ -1812,8 +1885,13 @@ export class FoundryStepExecutor {
|
|
|
1812
1885
|
if (!zitadelOrgId) {
|
|
1813
1886
|
throw new Error(`zitadel:login-host for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
1814
1887
|
}
|
|
1815
|
-
//
|
|
1816
|
-
|
|
1888
|
+
// A company that runs its own services writes the host table of its own in-app login app; every
|
|
1889
|
+
// other company, the shared one's through the host's port. `planLoginHost` blocks the step while
|
|
1890
|
+
// the shared port is not wired.
|
|
1891
|
+
const own = ownsServices(org);
|
|
1892
|
+
const bindings = own
|
|
1893
|
+
? this.ownSignInBindings(org, recorded, action.desired.host)
|
|
1894
|
+
: this.ports.loginHostBindings;
|
|
1817
1895
|
if (!bindings) {
|
|
1818
1896
|
throw new Error(`zitadel:login-host for ${org.slug}: the loginHostBindings port is not wired`);
|
|
1819
1897
|
}
|
|
@@ -1821,13 +1899,84 @@ export class FoundryStepExecutor {
|
|
|
1821
1899
|
// The platform's own shared sign-in host: already trusted and routed by
|
|
1822
1900
|
// the shared instance, so it gets no trusted domain or binding here.
|
|
1823
1901
|
const shared = action.desired.shared === true;
|
|
1824
|
-
|
|
1902
|
+
// In-app sign-in: the plan carries the app hosts to bind (empty with the switch off).
|
|
1903
|
+
const inApp = inAppAppsOf(action.desired.apps, org.slug);
|
|
1904
|
+
// The instance ignores an app's own base URI unless it is told not to: a port that cannot say so
|
|
1905
|
+
// stops the step before anything is written, rather than recording a base URI that has no effect.
|
|
1906
|
+
if (!shared && own && inApp.length > 0 && !this.ports.zitadel.allowPerAppLoginBaseUri) {
|
|
1907
|
+
throw new Error(`zitadel:login-host for ${org.slug}: the ZITADEL port cannot allow a per-app login base URI, so the app hosts cannot sign in on their own addresses`);
|
|
1908
|
+
}
|
|
1909
|
+
// The app hosts that are bound and get the app's own address as base URI: only once their app runs
|
|
1910
|
+
// with `SIGNIN_UPSTREAM` (the app steps record it), never before. Every other app host is trusted
|
|
1911
|
+
// and waits, and the company's own binding keeps its client id, so the old path signs in meanwhile.
|
|
1912
|
+
const targets = inAppTargets(inApp, recorded);
|
|
1913
|
+
const activePairs = inAppStatePairs(targets.active);
|
|
1914
|
+
const activeHosts = new Set(activePairs.map((pair) => pair.host));
|
|
1915
|
+
const envLive = signInEnvLiveHosts(recorded);
|
|
1916
|
+
// The login refuses a client id held by two bindings: an active app host's client id is in its own binding only.
|
|
1917
|
+
const clientIds = loginHostClientIds(org, recorded, activePairs.map((pair) => pair.clientId));
|
|
1825
1918
|
if (!shared) {
|
|
1826
|
-
|
|
1919
|
+
// The company's own login host is its instance's external domain: trusted and routed already.
|
|
1920
|
+
if (!own)
|
|
1921
|
+
await this.ports.zitadel.ensureTrustedDomain(host);
|
|
1922
|
+
// Each app host is trusted first.
|
|
1923
|
+
for (const app of inApp)
|
|
1924
|
+
await this.ports.zitadel.ensureTrustedDomain(app.host);
|
|
1925
|
+
// An app host bound before and not active now gives its client id back to the company's binding:
|
|
1926
|
+
// its own binding goes first, so no write leaves the id in two bindings.
|
|
1927
|
+
let unbound = false;
|
|
1928
|
+
for (const old of inAppStateHosts(recorded[SIGN_IN_IN_APP_SCOPE_KEY])) {
|
|
1929
|
+
if (activeHosts.has(old))
|
|
1930
|
+
continue;
|
|
1931
|
+
try {
|
|
1932
|
+
await bindings.deleteBinding(old);
|
|
1933
|
+
}
|
|
1934
|
+
catch (error) {
|
|
1935
|
+
if (!isNotFoundError(error))
|
|
1936
|
+
throw error;
|
|
1937
|
+
}
|
|
1938
|
+
unbound = true;
|
|
1939
|
+
}
|
|
1940
|
+
if (unbound)
|
|
1941
|
+
await bindings.flush?.();
|
|
1827
1942
|
// zitadel-login's own routing table, written before any app is repointed
|
|
1828
1943
|
// so no app ever points at a host with no binding.
|
|
1829
|
-
|
|
1944
|
+
if (!own) {
|
|
1945
|
+
if (clientIds.length > 0 || activePairs.length === 0) {
|
|
1946
|
+
await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
|
|
1947
|
+
}
|
|
1948
|
+
else {
|
|
1949
|
+
// Every client of the company signs in on its app's own address: the login refuses a binding
|
|
1950
|
+
// with no client ids, so the company's own binding goes (nothing signs in on this host).
|
|
1951
|
+
try {
|
|
1952
|
+
await bindings.deleteBinding(host);
|
|
1953
|
+
}
|
|
1954
|
+
catch (error) {
|
|
1955
|
+
if (!isNotFoundError(error))
|
|
1956
|
+
throw error;
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
for (const app of inApp) {
|
|
1961
|
+
if (!activeHosts.has(app.host))
|
|
1962
|
+
continue;
|
|
1963
|
+
await bindings.upsertBinding({ ...inAppBindingFor(app), organizationId: zitadelOrgId });
|
|
1964
|
+
}
|
|
1965
|
+
// One write for every upsert above (one redeploy of the login), before any app is repointed.
|
|
1966
|
+
await bindings.flush?.();
|
|
1967
|
+
if (own && activeHosts.size > 0) {
|
|
1968
|
+
await this.ports.zitadel.allowPerAppLoginBaseUri?.(`https://${host}/`);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
// The base URI each app's sign-in starts from: its own address under `/sign-in/` in-app, else the login host.
|
|
1972
|
+
const appBaseUris = new Map();
|
|
1973
|
+
for (const app of inApp) {
|
|
1974
|
+
const appId = recorded[`zitadel:app:${app.app}`];
|
|
1975
|
+
if (appId && activeHosts.has(app.host)) {
|
|
1976
|
+
appBaseUris.set(appId, `https://${app.host}${SIGN_IN_BASE_PATH}/`);
|
|
1977
|
+
}
|
|
1830
1978
|
}
|
|
1979
|
+
const baseUriOf = (appId) => appBaseUris.get(appId) ?? `https://${host}/`;
|
|
1831
1980
|
// Only apps whose client id the binding now holds: any other app in the
|
|
1832
1981
|
// project would lose sign-in. The rest move on a later run, once the
|
|
1833
1982
|
// recorded client ids differ from the binding and replan this step.
|
|
@@ -1858,7 +2007,10 @@ export class FoundryStepExecutor {
|
|
|
1858
2007
|
for (const app of liveApps) {
|
|
1859
2008
|
if (!boundAppIds.has(app.id))
|
|
1860
2009
|
continue;
|
|
1861
|
-
|
|
2010
|
+
// A company's own instance has the login host as its default; only the in-app apps move.
|
|
2011
|
+
if (own && !appBaseUris.has(app.id) && !ownInAppAppIds(recorded).has(app.id))
|
|
2012
|
+
continue;
|
|
2013
|
+
await setOidcAppLoginBaseUri(this.ports.zitadel, zitadelOrgId, projectId, app.id, baseUriOf(app.id));
|
|
1862
2014
|
}
|
|
1863
2015
|
}
|
|
1864
2016
|
// Fix round 3 point 5: a bound app's current base URI is `host` for
|
|
@@ -1870,7 +2022,7 @@ export class FoundryStepExecutor {
|
|
|
1870
2022
|
// compare against, same as an app this loop never visits at all.
|
|
1871
2023
|
const currentLoginBaseUris = new Set();
|
|
1872
2024
|
for (const app of liveApps) {
|
|
1873
|
-
const uri = boundAppIds.has(app.id) ?
|
|
2025
|
+
const uri = boundAppIds.has(app.id) ? baseUriOf(app.id) : app.loginBaseUri;
|
|
1874
2026
|
if (typeof uri === 'string')
|
|
1875
2027
|
currentLoginBaseUris.add(normalizedLoginBaseUri(uri));
|
|
1876
2028
|
}
|
|
@@ -1897,6 +2049,19 @@ export class FoundryStepExecutor {
|
|
|
1897
2049
|
candidates.delete(host);
|
|
1898
2050
|
if (previousHost && previousHost !== host)
|
|
1899
2051
|
candidates.add(previousHost);
|
|
2052
|
+
// An in-app host applied before and no longer wanted (switch off, or the app is gone) is owed a removal
|
|
2053
|
+
// the same way an old login host is; a host still wanted is never a candidate.
|
|
2054
|
+
// An app that still holds `SIGNIN_UPSTREAM` keeps its binding and trusted domain: it is switched off
|
|
2055
|
+
// in reverse order, the base URI first, then the app's env, then these.
|
|
2056
|
+
for (const old of [
|
|
2057
|
+
...inAppStateHosts(recorded[SIGN_IN_TRUSTED_SCOPE_KEY]),
|
|
2058
|
+
...inAppStateHosts(recorded[SIGN_IN_IN_APP_SCOPE_KEY]),
|
|
2059
|
+
]) {
|
|
2060
|
+
if (!envLive.has(old))
|
|
2061
|
+
candidates.add(old);
|
|
2062
|
+
}
|
|
2063
|
+
for (const app of inApp)
|
|
2064
|
+
candidates.delete(app.host);
|
|
1900
2065
|
// #274: a removal, once the new host is fully live (trusted, bound, and
|
|
1901
2066
|
// every repointable app repointed) -- never the other way round, so an
|
|
1902
2067
|
// org is never left with no trusted host at all if this throws partway
|
|
@@ -1905,9 +2070,11 @@ export class FoundryStepExecutor {
|
|
|
1905
2070
|
// its connector app and its tunnel): a domain already gone must not skip
|
|
1906
2071
|
// the binding delete that follows it.
|
|
1907
2072
|
const stillPending = [];
|
|
2073
|
+
let removed = false;
|
|
1908
2074
|
for (const candidate of candidates) {
|
|
1909
2075
|
if (!canDecideSafely ||
|
|
1910
|
-
currentLoginBaseUris.has(normalizedLoginBaseUri(`https://${candidate}/`))
|
|
2076
|
+
currentLoginBaseUris.has(normalizedLoginBaseUri(`https://${candidate}/`)) ||
|
|
2077
|
+
currentLoginBaseUris.has(normalizedLoginBaseUri(`https://${candidate}${SIGN_IN_BASE_PATH}/`))) {
|
|
1911
2078
|
stillPending.push(candidate);
|
|
1912
2079
|
continue;
|
|
1913
2080
|
}
|
|
@@ -1918,6 +2085,7 @@ export class FoundryStepExecutor {
|
|
|
1918
2085
|
if (!isNotFoundError(error))
|
|
1919
2086
|
throw error;
|
|
1920
2087
|
}
|
|
2088
|
+
removed = true;
|
|
1921
2089
|
try {
|
|
1922
2090
|
await bindings.deleteBinding(candidate);
|
|
1923
2091
|
}
|
|
@@ -1926,6 +2094,9 @@ export class FoundryStepExecutor {
|
|
|
1926
2094
|
throw error;
|
|
1927
2095
|
}
|
|
1928
2096
|
}
|
|
2097
|
+
// Nothing queued, nothing to write: a step that only added hosts has already flushed once.
|
|
2098
|
+
if (removed)
|
|
2099
|
+
await bindings.flush?.();
|
|
1929
2100
|
// Both last, so a crash above reruns the whole step rather than losing
|
|
1930
2101
|
// track of either. A client id recorded after this point differs from
|
|
1931
2102
|
// the binding and replans the step next run; a nonempty pending list
|
|
@@ -1933,11 +2104,44 @@ export class FoundryStepExecutor {
|
|
|
1933
2104
|
// normally alone decides it.
|
|
1934
2105
|
if (shared)
|
|
1935
2106
|
context.recordScopeId(LOGIN_HOST_SHARED_SCOPE_KEY, '1');
|
|
1936
|
-
else
|
|
1937
|
-
|
|
2107
|
+
else {
|
|
2108
|
+
if (!own)
|
|
2109
|
+
context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
|
|
2110
|
+
context.recordScopeId(SIGN_IN_TRUSTED_SCOPE_KEY, targets.trusted);
|
|
2111
|
+
context.recordScopeId(SIGN_IN_IN_APP_SCOPE_KEY, targets.active);
|
|
2112
|
+
}
|
|
1938
2113
|
context.recordScopeId(LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, pendingRemovalState(stillPending));
|
|
1939
2114
|
return { resourceId: host };
|
|
1940
2115
|
}
|
|
2116
|
+
/**
|
|
2117
|
+
* The host table of a company's own in-app login app as a bindings port that collects its edits and
|
|
2118
|
+
* writes them once on `flush` (one redeploy, none when nothing changed). Used for a company that runs
|
|
2119
|
+
* its own services, which has no host-supplied bindings port.
|
|
2120
|
+
*/
|
|
2121
|
+
ownSignInBindings(org, recorded, instanceHost) {
|
|
2122
|
+
const appId = recorded[SIGN_IN_LOGIN_APP_SCOPE_KEY];
|
|
2123
|
+
if (!appId) {
|
|
2124
|
+
throw new Error(`zitadel:login-host for ${org.slug}: the company's in-app login app is not recorded yet`);
|
|
2125
|
+
}
|
|
2126
|
+
const upsert = [];
|
|
2127
|
+
const remove = [];
|
|
2128
|
+
return {
|
|
2129
|
+
upsertBinding: async (binding) => {
|
|
2130
|
+
upsert.push(binding);
|
|
2131
|
+
},
|
|
2132
|
+
deleteBinding: async (host) => {
|
|
2133
|
+
remove.push(host);
|
|
2134
|
+
},
|
|
2135
|
+
flush: async () => {
|
|
2136
|
+
if (upsert.length === 0 && remove.length === 0)
|
|
2137
|
+
return;
|
|
2138
|
+
const edits = { appId, instanceHost, upsert: [...upsert], remove: [...remove] };
|
|
2139
|
+
upsert.length = 0;
|
|
2140
|
+
remove.length = 0;
|
|
2141
|
+
await editSignInBindings(this.ports.coolify, edits);
|
|
2142
|
+
},
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
1941
2145
|
/**
|
|
1942
2146
|
* `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
|
|
1943
2147
|
* in through a second ZITADEL org, `<slug> customers`, with
|
|
@@ -2311,6 +2515,140 @@ export class FoundryStepExecutor {
|
|
|
2311
2515
|
context.recordScopeId(COOLIFY_SERVER_SCOPE_KEY, server.uuid);
|
|
2312
2516
|
return server;
|
|
2313
2517
|
}
|
|
2518
|
+
/**
|
|
2519
|
+
* Contract C3: every usable, reachable Coolify server, the org's primary first. The primary is
|
|
2520
|
+
* {@link resolveServer}'s (databases and single-copy services stay on it, unchanged). A server that is
|
|
2521
|
+
* down is named in `down` and left out of `servers`; it is not an error.
|
|
2522
|
+
*/
|
|
2523
|
+
async resolveServers(org, context) {
|
|
2524
|
+
const primary = await this.resolveServer(org, context);
|
|
2525
|
+
const { up, down } = splitServers(await this.blueGreenServers());
|
|
2526
|
+
return {
|
|
2527
|
+
primary,
|
|
2528
|
+
servers: [primary, ...up.filter((uuid) => uuid !== primary.uuid).map((uuid) => ({ uuid }))],
|
|
2529
|
+
down,
|
|
2530
|
+
};
|
|
2531
|
+
}
|
|
2532
|
+
/**
|
|
2533
|
+
* The release runner's outside calls for one blue/green entry (`plan/release-host.ts`), or `null` while its
|
|
2534
|
+
* blue copy is not recorded. The health token is only read here, never minted.
|
|
2535
|
+
*/
|
|
2536
|
+
releaseHost(org, subject, context, renew) {
|
|
2537
|
+
const recorded = context.recordedIds();
|
|
2538
|
+
const primaryBlueAppId = recorded[`coolify:app:${appStepKey(org, subject.id)}`];
|
|
2539
|
+
const primaryUuid = recorded[COOLIFY_SERVER_SCOPE_KEY];
|
|
2540
|
+
if (!primaryBlueAppId || !primaryUuid)
|
|
2541
|
+
return null;
|
|
2542
|
+
return createReleaseHost({
|
|
2543
|
+
subject,
|
|
2544
|
+
context,
|
|
2545
|
+
coolify: this.ports.coolify,
|
|
2546
|
+
healthToken: async () => (await this.ports.heldKeys?.resolve(sharedSecretRef(org.id, healthTokenName(subject.id)))) ?? null,
|
|
2547
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
2548
|
+
primaryUuid,
|
|
2549
|
+
primaryBlueAppId,
|
|
2550
|
+
runtime: this.ports.runtime ?? {},
|
|
2551
|
+
...(this.ports.runtime?.fetch ? { fetch: this.ports.runtime.fetch } : {}),
|
|
2552
|
+
renew,
|
|
2553
|
+
label: `coolify:release:${subject.id}`,
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
/** Every server Coolify lists with whether it is usable and reachable, for the planner. */
|
|
2557
|
+
async blueGreenServers() {
|
|
2558
|
+
return (await this.ports.coolify.listServers()).map((s) => ({
|
|
2559
|
+
uuid: s.uuid,
|
|
2560
|
+
usable: s.is_usable,
|
|
2561
|
+
reachable: s.is_reachable,
|
|
2562
|
+
}));
|
|
2563
|
+
}
|
|
2564
|
+
/**
|
|
2565
|
+
* The blue/green steps (`plan/blue-green.ts`): `bluegreen` makes the apps and the balancer on one server,
|
|
2566
|
+
* `bluegreen-lb` only writes the balancer's weights, `bluegreen-connector` runs the tunnel's connector on a
|
|
2567
|
+
* server. A server that went down since the plan is skipped by `bluegreen-connector`'s caller, not here:
|
|
2568
|
+
* a step for it fails, which the next plan reports instead.
|
|
2569
|
+
*/
|
|
2570
|
+
async coolifyBlueGreen(action, org, context) {
|
|
2571
|
+
const recorded = () => context.recordedIds();
|
|
2572
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
2573
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
2574
|
+
const serverUuid = String(action.desired.server ?? '');
|
|
2575
|
+
const primaryUuid = String(action.desired.primary ?? '');
|
|
2576
|
+
const live = await this.resolveServers(org, context);
|
|
2577
|
+
if (!live.servers.some((s) => s.uuid === serverUuid)) {
|
|
2578
|
+
throw new Error(`${action.kind} for ${org.slug}: Coolify server ${serverUuid} is not usable and reachable`);
|
|
2579
|
+
}
|
|
2580
|
+
if (live.primary.uuid !== primaryUuid) {
|
|
2581
|
+
throw new Error(`${action.kind} for ${org.slug}: the plan's primary server is not the recorded one`);
|
|
2582
|
+
}
|
|
2583
|
+
if (action.kind === 'bluegreen-connector') {
|
|
2584
|
+
const tunnelId = recorded()[`cloudflare:tunnel:${org.id}`];
|
|
2585
|
+
if (!tunnelId)
|
|
2586
|
+
throw new Error(`bluegreen-connector for ${org.slug}: the tunnel is not recorded yet`);
|
|
2587
|
+
await applyBlueGreenConnectors({
|
|
2588
|
+
coolify: this.ports.coolify,
|
|
2589
|
+
tunnels: this.tunnelPort(),
|
|
2590
|
+
tunnelId,
|
|
2591
|
+
baseAppName: `${org.slug}-cloudflared`,
|
|
2592
|
+
baseScopeKey: CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY,
|
|
2593
|
+
primaryUuid,
|
|
2594
|
+
serverUuids: [serverUuid],
|
|
2595
|
+
projectUuid,
|
|
2596
|
+
environment,
|
|
2597
|
+
recorded,
|
|
2598
|
+
record: (key, id) => context.recordScopeId(key, id),
|
|
2599
|
+
persist: () => context.persistScopeIds(),
|
|
2600
|
+
});
|
|
2601
|
+
return undefined;
|
|
2602
|
+
}
|
|
2603
|
+
const entry = org.hosting.entries.find((e) => e.key === action.desired.entry);
|
|
2604
|
+
if (!entry || !entrySubject(org, entry)) {
|
|
2605
|
+
throw new Error(`${action.kind} for ${org.slug}: no blue/green entry '${String(action.desired.entry)}'`);
|
|
2606
|
+
}
|
|
2607
|
+
const primaryBlueAppId = recorded()[`coolify:app:${appStepKey(org, entry.key)}`];
|
|
2608
|
+
if (!primaryBlueAppId) {
|
|
2609
|
+
throw new Error(`${action.kind} for ${org.slug}: entry '${entry.key}' has no blue app recorded yet`);
|
|
2610
|
+
}
|
|
2611
|
+
// The blue app keeps the name it has: an app that ran before blue/green is adopted under its own name.
|
|
2612
|
+
if (!recorded()[bgKey.blueName(entry.key)]) {
|
|
2613
|
+
const blue = await this.ports.coolify.getApplication(primaryBlueAppId);
|
|
2614
|
+
if (blue.name) {
|
|
2615
|
+
context.recordScopeId(bgKey.blueName(entry.key), blue.name);
|
|
2616
|
+
await context.persistScopeIds();
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
const subject = entrySubject(org, entry, recorded());
|
|
2620
|
+
const runtime = this.ports.runtime ?? {};
|
|
2621
|
+
const deps = {
|
|
2622
|
+
coolify: this.ports.coolify,
|
|
2623
|
+
recorded,
|
|
2624
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
2625
|
+
persist: () => context.persistScopeIds(),
|
|
2626
|
+
projectUuid,
|
|
2627
|
+
environment,
|
|
2628
|
+
healthToken: async () => {
|
|
2629
|
+
if (typeof this.ports.heldKeys?.put !== 'function') {
|
|
2630
|
+
throw new Error(`${action.kind} for ${org.slug}: the executor's heldKeys cannot put the health token`);
|
|
2631
|
+
}
|
|
2632
|
+
const token = await this.ensureSharedSecret(org, healthTokenName(entry.key), recorded());
|
|
2633
|
+
this.handledSecrets.add(token);
|
|
2634
|
+
return token;
|
|
2635
|
+
},
|
|
2636
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
2637
|
+
runtime,
|
|
2638
|
+
sleep: runtime.sleep ?? defaultSleep,
|
|
2639
|
+
label: `${action.system}:${action.kind}:${entry.key}`,
|
|
2640
|
+
primaryBlueAppId,
|
|
2641
|
+
logDir: LOG_DIR,
|
|
2642
|
+
isRunning: isRunningOrStarting,
|
|
2643
|
+
};
|
|
2644
|
+
if (action.kind === 'bluegreen-lb') {
|
|
2645
|
+
await applyBlueGreenWeights(deps, subject, { uuid: serverUuid, primaryUuid });
|
|
2646
|
+
}
|
|
2647
|
+
else {
|
|
2648
|
+
await applyBlueGreenServer(deps, subject, { uuid: serverUuid, primaryUuid });
|
|
2649
|
+
}
|
|
2650
|
+
return undefined;
|
|
2651
|
+
}
|
|
2314
2652
|
async coolifyApp(action, org, context) {
|
|
2315
2653
|
const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
|
|
2316
2654
|
if (action.desired.enabled === false) {
|
|
@@ -2348,6 +2686,11 @@ export class FoundryStepExecutor {
|
|
|
2348
2686
|
}
|
|
2349
2687
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
2350
2688
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
2689
|
+
// A blue/green entry's app is its blue copy. An app that ran before blue/green keeps its own name and is
|
|
2690
|
+
// adopted as blue; an entry with no app yet gets `<base>-blue`. The name is recorded once decided.
|
|
2691
|
+
const blueNameKey = bgKey.blueName(entry.key);
|
|
2692
|
+
const blueNameRecorded = Boolean(context.recordedIds()[blueNameKey]);
|
|
2693
|
+
let appName = liveAppNameFor(org, entry, context.recordedIds());
|
|
2351
2694
|
// The recorded id first; the name when nothing is recorded, or when the
|
|
2352
2695
|
// recorded app is gone.
|
|
2353
2696
|
let existing = null;
|
|
@@ -2356,17 +2699,35 @@ export class FoundryStepExecutor {
|
|
|
2356
2699
|
try {
|
|
2357
2700
|
live = await this.ports.coolify.getApplication(recordedAppId);
|
|
2358
2701
|
existing = { uuid: recordedAppId };
|
|
2702
|
+
if (entry.blueGreen &&
|
|
2703
|
+
!blueNameRecorded &&
|
|
2704
|
+
live.name === colorAppName(appNameFor(org, entry), 'blue')) {
|
|
2705
|
+
appName = live.name;
|
|
2706
|
+
}
|
|
2359
2707
|
}
|
|
2360
2708
|
catch (error) {
|
|
2361
2709
|
if (!isNotFoundError(error))
|
|
2362
2710
|
throw error;
|
|
2363
2711
|
}
|
|
2364
2712
|
}
|
|
2713
|
+
if (!existing && entry.blueGreen && !blueNameRecorded) {
|
|
2714
|
+
const own = await this.ports.coolify.findApplicationByName(appNameFor(org, entry), {
|
|
2715
|
+
environmentIds: [environment.id],
|
|
2716
|
+
});
|
|
2717
|
+
if (own) {
|
|
2718
|
+
existing = own;
|
|
2719
|
+
appName = appNameFor(org, entry);
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2365
2722
|
if (!existing) {
|
|
2366
|
-
existing = await this.ports.coolify.findApplicationByName(
|
|
2723
|
+
existing = await this.ports.coolify.findApplicationByName(appName, {
|
|
2367
2724
|
environmentIds: [environment.id],
|
|
2368
2725
|
});
|
|
2369
2726
|
}
|
|
2727
|
+
if (entry.blueGreen && !blueNameRecorded) {
|
|
2728
|
+
context.recordScopeId(blueNameKey, appName);
|
|
2729
|
+
await context.persistScopeIds();
|
|
2730
|
+
}
|
|
2370
2731
|
// No entry gets Coolify `domains`: Coolify would add a Traefik router for them. The company's
|
|
2371
2732
|
// tunnel routes an entry's names straight to its container (also `<slug>.oikos.host`, through a
|
|
2372
2733
|
// record in the platform's oikos.host zone), which every entry reaches as `<slug>-<key>` (its
|
|
@@ -2403,6 +2764,7 @@ export class FoundryStepExecutor {
|
|
|
2403
2764
|
const server = await this.resolveServer(org, context);
|
|
2404
2765
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
2405
2766
|
const created = await this.ports.coolify.createGithubAppApplication({
|
|
2767
|
+
is_preview_deployments_enabled: false,
|
|
2406
2768
|
project_uuid: projectUuid,
|
|
2407
2769
|
server_uuid: server.uuid,
|
|
2408
2770
|
environment_name: environment.name,
|
|
@@ -2411,7 +2773,7 @@ export class FoundryStepExecutor {
|
|
|
2411
2773
|
git_repository: appRepo,
|
|
2412
2774
|
git_branch: entry.branch,
|
|
2413
2775
|
build_pack: entry.buildPack,
|
|
2414
|
-
name:
|
|
2776
|
+
name: appName,
|
|
2415
2777
|
autogenerate_domain: false,
|
|
2416
2778
|
// A static site is served by Coolify's own web server on port 80.
|
|
2417
2779
|
ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
|
|
@@ -2438,6 +2800,10 @@ export class FoundryStepExecutor {
|
|
|
2438
2800
|
}
|
|
2439
2801
|
if (want.autoDeploy !== have.autoDeploy)
|
|
2440
2802
|
patch.is_auto_deploy_enabled = want.autoDeploy;
|
|
2803
|
+
// #788: an app found with previews on gets them off (`planApp` plans the
|
|
2804
|
+
// step for it). A created app has them off from the create.
|
|
2805
|
+
if (previewsOn(live))
|
|
2806
|
+
patch.is_preview_deployments_enabled = false;
|
|
2441
2807
|
// #543: the health-check quartet too, every run -- the same reason
|
|
2442
2808
|
// Archon's own doc comment gives (without one, Coolify's rolling update
|
|
2443
2809
|
// has nothing to wait on and stops the old container immediately). The
|
|
@@ -2460,7 +2826,7 @@ export class FoundryStepExecutor {
|
|
|
2460
2826
|
patch.domains = '';
|
|
2461
2827
|
}
|
|
2462
2828
|
if (!existing || patch.domains !== undefined) {
|
|
2463
|
-
patch.custom_internal_name =
|
|
2829
|
+
patch.custom_internal_name = appName;
|
|
2464
2830
|
}
|
|
2465
2831
|
// A `*` entry's proxy labels: Coolify takes `custom_labels` base64 encoded
|
|
2466
2832
|
// and uses them instead of the labels it generates from `domains`. Rule 4:
|
|
@@ -2514,8 +2880,8 @@ export class FoundryStepExecutor {
|
|
|
2514
2880
|
// `ensureLogVolume` whether this app was already running before this
|
|
2515
2881
|
// call -- see its own doc comment for why that decides whether to
|
|
2516
2882
|
// restart it.
|
|
2517
|
-
await this.ensureLogVolume(appId, org, Boolean(existing),
|
|
2518
|
-
await this.ensureEntryVolumes(appId, entry, Boolean(existing),
|
|
2883
|
+
await this.ensureLogVolume(appId, org, Boolean(existing), appName);
|
|
2884
|
+
await this.ensureEntryVolumes(appId, entry, Boolean(existing), appName);
|
|
2519
2885
|
// The app receives whatever it needs to sign in (gap P2). Unconditional,
|
|
2520
2886
|
// unlike the database push above: every app has a hostname, and an
|
|
2521
2887
|
// upsert of the same value on a later apply is a no-op, not a change.
|
|
@@ -2546,8 +2912,10 @@ export class FoundryStepExecutor {
|
|
|
2546
2912
|
// The public name answers from the container only once the tunnel step has written the
|
|
2547
2913
|
// direct route; before that (a first apply, or the one that moves it) the deployment is the check.
|
|
2548
2914
|
hostname &&
|
|
2915
|
+
// The public name goes through the balancer, which may answer from the other color.
|
|
2916
|
+
!entry.blueGreen &&
|
|
2549
2917
|
(context.recordedIds()[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY] ?? '[]') ===
|
|
2550
|
-
entryRoutesMarker(org)
|
|
2918
|
+
entryRoutesMarker(org, context.recordedIds())
|
|
2551
2919
|
? publicHealthCheck(org, context.recordedIds(), hostname, healthPath)
|
|
2552
2920
|
: { 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`);
|
|
2553
2921
|
await recordBuiltCommit(this.ports.coolify, { appId: appId, deploymentUuid: built.deploymentUuid, name: entry.key }, (key, value) => context.recordScopeId(key, value));
|
|
@@ -3857,6 +4225,7 @@ export class FoundryStepExecutor {
|
|
|
3857
4225
|
const server = await this.resolveServer(input.org, input.context);
|
|
3858
4226
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
3859
4227
|
const created = await this.ports.coolify.createGithubAppApplication({
|
|
4228
|
+
is_preview_deployments_enabled: false,
|
|
3860
4229
|
project_uuid: input.projectUuid,
|
|
3861
4230
|
server_uuid: server.uuid,
|
|
3862
4231
|
environment_name: input.environment.name,
|
|
@@ -4389,6 +4758,7 @@ export class FoundryStepExecutor {
|
|
|
4389
4758
|
}
|
|
4390
4759
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
4391
4760
|
const synapse = await this.findOrCreateCallsApp(names.synapse, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
4761
|
+
is_preview_deployments_enabled: false,
|
|
4392
4762
|
project_uuid: projectUuid,
|
|
4393
4763
|
server_uuid: server.uuid,
|
|
4394
4764
|
environment_name: environment.name,
|
|
@@ -4413,6 +4783,7 @@ export class FoundryStepExecutor {
|
|
|
4413
4783
|
instant_deploy: false,
|
|
4414
4784
|
}), { startIfStopped: false });
|
|
4415
4785
|
const service = await this.findOrCreateCallsApp(names.service, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
4786
|
+
is_preview_deployments_enabled: false,
|
|
4416
4787
|
project_uuid: projectUuid,
|
|
4417
4788
|
server_uuid: server.uuid,
|
|
4418
4789
|
environment_name: environment.name,
|
|
@@ -4613,6 +4984,7 @@ export class FoundryStepExecutor {
|
|
|
4613
4984
|
}
|
|
4614
4985
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
4615
4986
|
const synapse = await this.findOrCreateCallsApp(names.synapse, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
4987
|
+
is_preview_deployments_enabled: false,
|
|
4616
4988
|
project_uuid: projectUuid,
|
|
4617
4989
|
server_uuid: server.uuid,
|
|
4618
4990
|
environment_name: environment.name,
|
|
@@ -4819,6 +5191,7 @@ export class FoundryStepExecutor {
|
|
|
4819
5191
|
const server = await this.resolveServer(org, context);
|
|
4820
5192
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
4821
5193
|
const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
5194
|
+
is_preview_deployments_enabled: false,
|
|
4822
5195
|
project_uuid: projectUuid,
|
|
4823
5196
|
server_uuid: server.uuid,
|
|
4824
5197
|
environment_name: environment.name,
|
|
@@ -5199,6 +5572,7 @@ export class FoundryStepExecutor {
|
|
|
5199
5572
|
const server = await this.resolveServer(org, context);
|
|
5200
5573
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
5201
5574
|
const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
5575
|
+
is_preview_deployments_enabled: false,
|
|
5202
5576
|
project_uuid: projectUuid,
|
|
5203
5577
|
server_uuid: server.uuid,
|
|
5204
5578
|
environment_name: environment.name,
|
|
@@ -5233,10 +5607,10 @@ export class FoundryStepExecutor {
|
|
|
5233
5607
|
// #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
|
|
5234
5608
|
// Coolify's own env store matching `env`, which is why that store alone
|
|
5235
5609
|
// can no longer tell a changed value from one already live.
|
|
5236
|
-
const
|
|
5237
|
-
|
|
5238
|
-
.map((e) => [e.key, e.value]));
|
|
5610
|
+
const liveRows = (await this.ports.coolify.listEnvs(app.appId)).filter((e) => !e.is_preview);
|
|
5611
|
+
const liveEnv = new Map(liveRows.map((e) => [e.key, e.value]));
|
|
5239
5612
|
const envChanged = agoraEnvDrift(env, Object.fromEntries(liveEnv)).length > 0;
|
|
5613
|
+
await this.dropStaleSignInUpstream(app.appId, env, liveRows);
|
|
5240
5614
|
await this.ports.coolify.bulkUpsertEnvs(app.appId, Object.entries(env).map(([key, value]) => ({ key, value })));
|
|
5241
5615
|
const revision = agoraEnvRevision(env);
|
|
5242
5616
|
const alreadyActivated = context.recordedIds()[AGORA_ACTIVATED_ENV_SCOPE_KEY] === revision;
|
|
@@ -5262,9 +5636,43 @@ export class FoundryStepExecutor {
|
|
|
5262
5636
|
else if (app.existed && (envChanged || !alreadyActivated)) {
|
|
5263
5637
|
await this.ports.coolify.restart(app.appId);
|
|
5264
5638
|
}
|
|
5639
|
+
// The login-host step points the sign-in at this app's own address only once the env is running. Before the
|
|
5640
|
+
// activation marker, so a crash between the two plans this step again and records it.
|
|
5641
|
+
this.recordSignInEnvLive(context, 'agora', agoraHostnameFor(org), env);
|
|
5265
5642
|
context.recordScopeId(AGORA_ACTIVATED_ENV_SCOPE_KEY, revision);
|
|
5266
5643
|
return { resourceId: app.appId };
|
|
5267
5644
|
}
|
|
5645
|
+
/**
|
|
5646
|
+
* Records whether an app's running env holds `SIGNIN_UPSTREAM` (the app's host while it does, `''` once it
|
|
5647
|
+
* does not), so the login-host step knows when to point the base URI at the app and when it may give the
|
|
5648
|
+
* host up. Written only when the value changes.
|
|
5649
|
+
*/
|
|
5650
|
+
recordSignInEnvLive(context, app, host, env) {
|
|
5651
|
+
const key = `${SIGN_IN_ENV_LIVE_SCOPE_PREFIX}${app}`;
|
|
5652
|
+
const value = env[SIGN_IN_UPSTREAM_ENV] !== undefined ? host : '';
|
|
5653
|
+
if ((context.recordedIds()[key] ?? '') !== value)
|
|
5654
|
+
context.recordScopeId(key, value);
|
|
5655
|
+
}
|
|
5656
|
+
/**
|
|
5657
|
+
* In-app sign-in switched off (or its host given up): the app's live `SIGNIN_UPSTREAM` is deleted, because
|
|
5658
|
+
* `bulkUpsertEnvs` never removes a key. Left in place, the app would keep proxying `/sign-in/*` to the login.
|
|
5659
|
+
* Nothing happens while the env is desired or the app does not hold it.
|
|
5660
|
+
*/
|
|
5661
|
+
async dropStaleSignInUpstream(appId, desired, liveRows) {
|
|
5662
|
+
if (desired[SIGN_IN_UPSTREAM_ENV] !== undefined)
|
|
5663
|
+
return;
|
|
5664
|
+
for (const row of liveRows) {
|
|
5665
|
+
if (row.key !== SIGN_IN_UPSTREAM_ENV || !row.uuid)
|
|
5666
|
+
continue;
|
|
5667
|
+
try {
|
|
5668
|
+
await this.ports.coolify.deleteEnv(appId, row.uuid);
|
|
5669
|
+
}
|
|
5670
|
+
catch (error) {
|
|
5671
|
+
if (!isNotFoundError(error))
|
|
5672
|
+
throw error;
|
|
5673
|
+
}
|
|
5674
|
+
}
|
|
5675
|
+
}
|
|
5268
5676
|
// ---- boule (#161) --------------------------------------------------------
|
|
5269
5677
|
/**
|
|
5270
5678
|
* `plan/diff.ts`'s `planBoule`: provisions the org's own Boule
|
|
@@ -5318,6 +5726,7 @@ export class FoundryStepExecutor {
|
|
|
5318
5726
|
const server = await this.resolveServer(org, context);
|
|
5319
5727
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
5320
5728
|
const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
5729
|
+
is_preview_deployments_enabled: false,
|
|
5321
5730
|
project_uuid: projectUuid,
|
|
5322
5731
|
server_uuid: server.uuid,
|
|
5323
5732
|
environment_name: environment.name,
|
|
@@ -5339,10 +5748,10 @@ export class FoundryStepExecutor {
|
|
|
5339
5748
|
...healthCheckSettings('/api/health', BOULE_PORT),
|
|
5340
5749
|
...(await this.routedLabels(name, bouleHostnameFor(org), BOULE_PORT)),
|
|
5341
5750
|
});
|
|
5342
|
-
const
|
|
5343
|
-
|
|
5344
|
-
.map((e) => [e.key, e.value]));
|
|
5751
|
+
const liveRows = (await this.ports.coolify.listEnvs(app.appId)).filter((e) => !e.is_preview);
|
|
5752
|
+
const liveEnv = new Map(liveRows.map((e) => [e.key, e.value]));
|
|
5345
5753
|
const envChanged = bouleEnvDrift(env, Object.fromEntries(liveEnv)).length > 0;
|
|
5754
|
+
await this.dropStaleSignInUpstream(app.appId, env, liveRows);
|
|
5346
5755
|
const envs = Object.entries(env).map(([key, value]) => ({ key, value }));
|
|
5347
5756
|
const cookieSecret = await this.ensureAuthCookieSecret(app.appId);
|
|
5348
5757
|
if (cookieSecret)
|
|
@@ -5416,6 +5825,8 @@ export class FoundryStepExecutor {
|
|
|
5416
5825
|
if (action.desired.platformOwner === true) {
|
|
5417
5826
|
context.recordScopeId(BOULE_SECRETS_RESTARTED_SCOPE_KEY, '1');
|
|
5418
5827
|
}
|
|
5828
|
+
// Last: the login-host step points the sign-in at this app's own address only once the env is running.
|
|
5829
|
+
this.recordSignInEnvLive(context, 'boule', bouleHostnameFor(org), env);
|
|
5419
5830
|
return { resourceId: app.appId };
|
|
5420
5831
|
}
|
|
5421
5832
|
/**
|
|
@@ -5858,6 +6269,9 @@ export class FoundryStepExecutor {
|
|
|
5858
6269
|
smtpSender: `noreply@${hn.mailDomain}`,
|
|
5859
6270
|
defaultRedirectUri: `https://${agoraHostnameFor(org)}`,
|
|
5860
6271
|
ownerCompany: null,
|
|
6272
|
+
// In-app sign-in: the company's own stack gets the second login app. Its host table starts
|
|
6273
|
+
// empty; the login-host step writes it once the apps' clients exist.
|
|
6274
|
+
...(inAppSignIn(org) ? { signInLogin: { bindings: null } } : {}),
|
|
5861
6275
|
});
|
|
5862
6276
|
if (refChanged) {
|
|
5863
6277
|
const now = scope.recordedIds();
|
|
@@ -6017,6 +6431,42 @@ export class FoundryStepExecutor {
|
|
|
6017
6431
|
}
|
|
6018
6432
|
return { resourceId: registered.id };
|
|
6019
6433
|
}
|
|
6434
|
+
/** The blue/green database route (`plan/remote-database.ts`). */
|
|
6435
|
+
async remoteDatabaseRoute(action, org, context) {
|
|
6436
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
6437
|
+
const desired = action.desired;
|
|
6438
|
+
// The tunnel's TCP rule names the database by the host the apps' own `DATABASE_URL` uses.
|
|
6439
|
+
const db = await this.ports.coolify.getDatabase(desired.database);
|
|
6440
|
+
if (db.internal_db_url)
|
|
6441
|
+
this.handledSecrets.add(db.internal_db_url);
|
|
6442
|
+
let databaseHost = '';
|
|
6443
|
+
try {
|
|
6444
|
+
databaseHost = new URL(db.internal_db_url ?? '').hostname;
|
|
6445
|
+
}
|
|
6446
|
+
catch {
|
|
6447
|
+
// Fixed text below: the URL holds the database password.
|
|
6448
|
+
}
|
|
6449
|
+
if (!databaseHost) {
|
|
6450
|
+
throw new Error(`cloudflare:db-route for ${org.slug}: the database reports no usable address`);
|
|
6451
|
+
}
|
|
6452
|
+
await applyRemoteDatabase({
|
|
6453
|
+
tunnels: this.tunnelPort(),
|
|
6454
|
+
access: this.accessPort(),
|
|
6455
|
+
dns: this.dnsPort(),
|
|
6456
|
+
zones: this.zonePort(),
|
|
6457
|
+
coolify: this.ports.coolify,
|
|
6458
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
6459
|
+
}, {
|
|
6460
|
+
org,
|
|
6461
|
+
desired,
|
|
6462
|
+
databaseHost,
|
|
6463
|
+
projectUuid,
|
|
6464
|
+
recorded: () => context.recordedIds(),
|
|
6465
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
6466
|
+
persist: () => context.persistScopeIds(),
|
|
6467
|
+
});
|
|
6468
|
+
return {};
|
|
6469
|
+
}
|
|
6020
6470
|
/** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
|
|
6021
6471
|
async deliverWithRetry(ref, value) {
|
|
6022
6472
|
for (let attempt = 1; attempt <= JOBS_DELIVERY_ATTEMPTS; attempt++) {
|
|
@@ -6096,17 +6546,19 @@ export class FoundryStepExecutor {
|
|
|
6096
6546
|
const loginHost = action.desired.loginHost ?? null;
|
|
6097
6547
|
// A hosting entry's names (and its wildcard) go straight to its container on the company network:
|
|
6098
6548
|
// the container carries `traefik.enable=false`, so the proxy has no route for it.
|
|
6099
|
-
const entryOrigins = new Map(entryRoutes(org).map((r) => [r.hostname, r.service]));
|
|
6549
|
+
const entryOrigins = new Map(entryRoutes(org, context.recordedIds()).map((r) => [r.hostname, r.service]));
|
|
6100
6550
|
const ingress = tunnelIngressHostnames(org, loginHost).map((hostname) => ({
|
|
6101
6551
|
hostname,
|
|
6102
6552
|
service: hostname === loginHost
|
|
6103
6553
|
? `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}`
|
|
6104
6554
|
: (entryOrigins.get(hostname) ?? COOLIFY_PROXY_ORIGIN),
|
|
6105
6555
|
}));
|
|
6556
|
+
const httpIngress = [...ingress, { service: 'http_status:404' }];
|
|
6106
6557
|
await this.tunnelPort().putTunnelConfig(tunnel.id, {
|
|
6107
|
-
ingress:
|
|
6558
|
+
ingress: mergeTcpIngress(httpIngress, recordedTcpRoutes(context.recordedIds())),
|
|
6108
6559
|
});
|
|
6109
|
-
context.recordScopeId(
|
|
6560
|
+
context.recordScopeId(TUNNEL_HTTP_INGRESS_SCOPE_KEY, JSON.stringify(httpIngress));
|
|
6561
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, entryRoutesMarker(org, context.recordedIds()));
|
|
6110
6562
|
// #235: a connector for this org's own tunnel, the same call every
|
|
6111
6563
|
// platform tunnel step already makes (Decided, William, 2026-10-01:
|
|
6112
6564
|
// one connector per org tunnel, never a shared tunnel with appended
|
|
@@ -7873,8 +8325,32 @@ export class FoundryStepExecutor {
|
|
|
7873
8325
|
return;
|
|
7874
8326
|
}
|
|
7875
8327
|
case 'coolify:app':
|
|
8328
|
+
case 'coolify:bg-app':
|
|
7876
8329
|
await this.ports.coolify.deleteApplication(target.id);
|
|
7877
8330
|
return;
|
|
8331
|
+
case 'cloudflare:bg-access':
|
|
8332
|
+
await this.accessPort().deleteAccessApp(target.id);
|
|
8333
|
+
return;
|
|
8334
|
+
case 'cloudflare:bg-token':
|
|
8335
|
+
await this.accessPort().removeServiceToken(target.id);
|
|
8336
|
+
return;
|
|
8337
|
+
case 'cloudflare:bg-dns': {
|
|
8338
|
+
// `target.id` is the recorded route ({ hostname, service }); its name lives in the org's zone.
|
|
8339
|
+
const hostname = JSON.parse(target.id).hostname;
|
|
8340
|
+
const zone = org.domain ? await this.zonePort().findZoneByApex(org.domain) : undefined;
|
|
8341
|
+
if (typeof hostname !== 'string' || !zone)
|
|
8342
|
+
return;
|
|
8343
|
+
const dns = this.dnsPort();
|
|
8344
|
+
for (const record of await dns.findDnsRecord(zone.name, hostname, 'CNAME')) {
|
|
8345
|
+
if (record.content.endsWith('.cfargotunnel.com')) {
|
|
8346
|
+
await dns.deleteDnsRecord(zone.name, record.id);
|
|
8347
|
+
}
|
|
8348
|
+
}
|
|
8349
|
+
return;
|
|
8350
|
+
}
|
|
8351
|
+
case 'coolify:bg-database':
|
|
8352
|
+
await this.ports.coolify.deleteDatabase(target.id);
|
|
8353
|
+
return;
|
|
7878
8354
|
case 'coolify:database':
|
|
7879
8355
|
await this.ports.coolify.deleteDatabase(target.id);
|
|
7880
8356
|
return;
|
|
@@ -7953,3 +8429,23 @@ export function isNotFoundError(error) {
|
|
|
7953
8429
|
const message = error instanceof Error ? error.message : String(error);
|
|
7954
8430
|
return /^\S+ \S+ \S+ → 404:/.test(message);
|
|
7955
8431
|
}
|
|
8432
|
+
/** ZITADEL's error code for an update that changes nothing (`COMMAND-1m88i`, "No changes"). */
|
|
8433
|
+
const ZITADEL_NO_CHANGES_CODE = 'COMMAND-1m88i';
|
|
8434
|
+
/** True when ZITADEL refused an update because the value already is the wanted one. Matches the error code, never the free text. */
|
|
8435
|
+
export function isNoChangesError(error) {
|
|
8436
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8437
|
+
return message.includes(ZITADEL_NO_CHANGES_CODE);
|
|
8438
|
+
}
|
|
8439
|
+
/**
|
|
8440
|
+
* {@link ZitadelPort.updateOidcAppLoginBaseUri} that counts an app already holding the wanted base URI as
|
|
8441
|
+
* done: ZITADEL answers that write with "No changes" and a repeat apply must not fail on it.
|
|
8442
|
+
*/
|
|
8443
|
+
export async function setOidcAppLoginBaseUri(zitadel, orgId, projectId, appId, loginBaseUri) {
|
|
8444
|
+
try {
|
|
8445
|
+
await zitadel.updateOidcAppLoginBaseUri(orgId, projectId, appId, loginBaseUri);
|
|
8446
|
+
}
|
|
8447
|
+
catch (error) {
|
|
8448
|
+
if (!isNoChangesError(error))
|
|
8449
|
+
throw error;
|
|
8450
|
+
}
|
|
8451
|
+
}
|