@wtfalch/foundry 0.46.0 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-json.d.ts +28 -0
- package/dist/build-json.js +95 -0
- package/dist/index.d.ts +10 -4
- package/dist/index.js +6 -2
- package/dist/migrations/0058_app_requests.sql +32 -0
- package/dist/migrations/0059_org_builtin_blue_green.sql +9 -0
- package/dist/org.d.ts +47 -5
- package/dist/org.js +176 -128
- package/dist/plan/archon-blue-green.js +15 -1
- package/dist/plan/blue-green-apply.d.ts +35 -0
- package/dist/plan/blue-green-apply.js +167 -16
- package/dist/plan/blue-green.d.ts +97 -2
- package/dist/plan/blue-green.js +214 -13
- package/dist/plan/database-dump.d.ts +37 -0
- package/dist/plan/database-dump.js +101 -0
- package/dist/plan/diff.js +9 -5
- package/dist/plan/executor.d.ts +48 -0
- package/dist/plan/executor.js +87 -28
- package/dist/plan/health-targets.js +1 -2
- package/dist/plan/hostnames.d.ts +2 -2
- package/dist/plan/hostnames.js +6 -6
- package/dist/plan/index.d.ts +3 -1
- package/dist/plan/index.js +5 -1
- package/dist/plan/locked-hostnames.d.ts +1 -0
- package/dist/plan/locked-hostnames.js +1 -0
- package/dist/plan/plan-apply.d.ts +30 -1
- package/dist/plan/plan-apply.js +86 -8
- package/dist/plan/platform-diff.js +2 -1
- package/dist/plan/platform-providers.js +5 -0
- package/dist/plan/providers-readers.js +15 -4
- package/dist/plan/proxy-labels.d.ts +13 -1
- package/dist/plan/proxy-labels.js +26 -3
- package/dist/plan/release-host.d.ts +1 -1
- package/dist/plan/release-host.js +36 -14
- package/dist/plan/stage-env.d.ts +38 -0
- package/dist/plan/stage-env.js +52 -0
- package/dist/plan/stage-guards.d.ts +104 -0
- package/dist/plan/stage-guards.js +149 -0
- package/dist/plan/stage-isolation.d.ts +57 -0
- package/dist/plan/stage-isolation.js +248 -0
- package/dist/plan/worker-app.js +3 -1
- package/dist/refuse-org.d.ts +7 -0
- package/dist/refuse-org.js +12 -0
- package/dist/release/decide.d.ts +5 -0
- package/dist/release/decide.js +10 -6
- package/dist/release/health.d.ts +6 -1
- package/dist/release/health.js +10 -2
- package/dist/release/run.d.ts +2 -0
- package/dist/release/run.js +2 -1
- package/dist/requested-changes.d.ts +50 -4
- package/dist/requested-changes.js +9 -1
- package/dist/store/postgres.d.ts +5 -1
- package/dist/store/postgres.js +86 -8
- package/dist/store/schema.d.ts +111 -1
- package/dist/store/schema.js +7 -0
- package/dist/store.d.ts +24 -1
- package/dist/worker-app-config.d.ts +8 -2
- package/dist/worker-app-config.js +20 -6
- package/package.json +1 -1
package/dist/plan/executor.js
CHANGED
|
@@ -4,11 +4,11 @@ import { URL } from 'node:url';
|
|
|
4
4
|
import { coolifyImageFor } from '../bootstrap/versions.js';
|
|
5
5
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
6
6
|
import { resolveCallsSfuUrls } from '../calls-sfu.js';
|
|
7
|
-
import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS,
|
|
7
|
+
import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, filesCollectionEnvName, heldEnvRef, isMainEntry, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
|
|
8
8
|
import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
|
|
9
9
|
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, DESKTOP_AUTH_PORT, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, desktopAuthInternalName, entryClientIdScopeKey, } from './agora.js';
|
|
10
10
|
import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, } from './blue-green-apply.js';
|
|
11
|
-
import { bgKey,
|
|
11
|
+
import { actionsKeyName, bgKey, blueGreenSubjects, colorAppName, healthTokenName, liveAppNameFor, mintActionsKey, splitServers, } from './blue-green.js';
|
|
12
12
|
import { BouleOwnersError, httpBouleOwners } from './boule-owners-http.js';
|
|
13
13
|
import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_CUSTOMERS_ORG_ENV, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_SIGN_IN_METADATA_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleArchonCredentialRef, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, bouleOwnerRefusedScopeKey, bouleOwnerSeededScopeKey, bouleOwnerSeeds, customersDirectoryRef, customersOrgIdOf, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
14
14
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, mcpCallbackUri, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
@@ -53,13 +53,14 @@ import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY
|
|
|
53
53
|
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
|
|
54
54
|
import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
|
|
55
55
|
import { companyAppPostLogoutUris, postLogoutAppliedValue, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
|
|
56
|
-
import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
|
|
56
|
+
import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, builtinRoutes, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
|
|
57
57
|
import { createReleaseHost } from './release-host.js';
|
|
58
58
|
import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
|
|
59
59
|
import { editSignInBindings } from './sign-in-bindings.js';
|
|
60
60
|
import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
|
|
61
61
|
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';
|
|
62
62
|
import { moveSigninDatabase } from './signin-db-move.js';
|
|
63
|
+
import { stageGateKey, stageHostname, } from './stage-guards.js';
|
|
63
64
|
import { WORKER_APP_CALL_TIMEOUT_MS, applyWorkerApp, heldBrokerKey, teardownWorkerApp, withCallTimeout, } from './worker-app.js';
|
|
64
65
|
import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
|
|
65
66
|
/**
|
|
@@ -211,7 +212,7 @@ export function callsHostnameFor(org) {
|
|
|
211
212
|
*/
|
|
212
213
|
export function healthUrls(org) {
|
|
213
214
|
// One URL per entry that has a public name; an internal entry has none.
|
|
214
|
-
if (org.hosting.enabled &&
|
|
215
|
+
if (org.hosting.enabled && org.hosting.entries.length > 0) {
|
|
215
216
|
return org.hosting.entries.flatMap((entry) => {
|
|
216
217
|
const [hostname] = entryHostnames(org, entry);
|
|
217
218
|
if (!hostname)
|
|
@@ -342,8 +343,8 @@ const CMS_APP_PORT = 3000;
|
|
|
342
343
|
* Agora answers 502 behind the tunnel. Seen live on Company Included,
|
|
343
344
|
* 2026-10-05.
|
|
344
345
|
*/
|
|
345
|
-
const AGORA_REPO = 'wtfalch/agora';
|
|
346
|
-
const AGORA_APP_DOCKERFILE = '/apps/agora/Dockerfile';
|
|
346
|
+
export const AGORA_REPO = 'wtfalch/agora';
|
|
347
|
+
export const AGORA_APP_DOCKERFILE = '/apps/agora/Dockerfile';
|
|
347
348
|
/**
|
|
348
349
|
* #161 (Boule part): the repo `coolifyBoule` builds the org's Boule from
|
|
349
350
|
* (https://github.com/wtfalch/boule), by the same convention as
|
|
@@ -352,10 +353,10 @@ const AGORA_APP_DOCKERFILE = '/apps/agora/Dockerfile';
|
|
|
352
353
|
* 3000 (`EXPOSE 3000`) and runs `scripts/migrate-and-start.sh`, which applies
|
|
353
354
|
* its migrations as the owner and then serves as the `<database>_rt` role.
|
|
354
355
|
*/
|
|
355
|
-
const BOULE_REPO = 'wtfalch/boule';
|
|
356
|
-
const BOULE_APP_DOCKERFILE = '/Dockerfile';
|
|
356
|
+
export const BOULE_REPO = 'wtfalch/boule';
|
|
357
|
+
export const BOULE_APP_DOCKERFILE = '/Dockerfile';
|
|
357
358
|
/** Boule's own `EXPOSE 3000` (its doc comment just above) -- the port its health check (#543) probes. */
|
|
358
|
-
const BOULE_PORT = 3000;
|
|
359
|
+
export const BOULE_PORT = 3000;
|
|
359
360
|
/**
|
|
360
361
|
* Every app foundry deploys keeps its own logs across a redeploy, so a
|
|
361
362
|
* database outage does not also erase the only other copy of the evidence
|
|
@@ -2886,7 +2887,7 @@ export class FoundryStepExecutor {
|
|
|
2886
2887
|
*/
|
|
2887
2888
|
releaseHost(org, subject, context, renew) {
|
|
2888
2889
|
const recorded = context.recordedIds();
|
|
2889
|
-
const primaryBlueAppId = recorded[
|
|
2890
|
+
const primaryBlueAppId = recorded[subject.blueAppKey];
|
|
2890
2891
|
const primaryUuid = recorded[COOLIFY_SERVER_SCOPE_KEY];
|
|
2891
2892
|
if (!primaryBlueAppId || !primaryUuid)
|
|
2892
2893
|
return null;
|
|
@@ -2951,23 +2952,23 @@ export class FoundryStepExecutor {
|
|
|
2951
2952
|
});
|
|
2952
2953
|
return undefined;
|
|
2953
2954
|
}
|
|
2954
|
-
const
|
|
2955
|
-
if (!
|
|
2956
|
-
throw new Error(`${action.kind} for ${org.slug}: no blue/green
|
|
2955
|
+
const found = blueGreenSubjects(org).find((s) => s.id === action.desired.entry);
|
|
2956
|
+
if (!found) {
|
|
2957
|
+
throw new Error(`${action.kind} for ${org.slug}: no blue/green app '${String(action.desired.entry)}'`);
|
|
2957
2958
|
}
|
|
2958
|
-
const primaryBlueAppId = recorded()[
|
|
2959
|
+
const primaryBlueAppId = recorded()[found.blueAppKey];
|
|
2959
2960
|
if (!primaryBlueAppId) {
|
|
2960
|
-
throw new Error(`${action.kind} for ${org.slug}:
|
|
2961
|
+
throw new Error(`${action.kind} for ${org.slug}: app '${found.id}' has no blue app recorded yet`);
|
|
2961
2962
|
}
|
|
2962
2963
|
// The blue app keeps the name it has: an app that ran before blue/green is adopted under its own name.
|
|
2963
|
-
if (!recorded()[bgKey.blueName(
|
|
2964
|
+
if (!recorded()[bgKey.blueName(found.id)]) {
|
|
2964
2965
|
const blue = await this.ports.coolify.getApplication(primaryBlueAppId);
|
|
2965
2966
|
if (blue.name) {
|
|
2966
|
-
context.recordScopeId(bgKey.blueName(
|
|
2967
|
+
context.recordScopeId(bgKey.blueName(found.id), blue.name);
|
|
2967
2968
|
await context.persistScopeIds();
|
|
2968
2969
|
}
|
|
2969
2970
|
}
|
|
2970
|
-
const subject =
|
|
2971
|
+
const subject = blueGreenSubjects(org, recorded()).find((s) => s.id === found.id);
|
|
2971
2972
|
const runtime = this.ports.runtime ?? {};
|
|
2972
2973
|
const deps = {
|
|
2973
2974
|
coolify: this.ports.coolify,
|
|
@@ -2980,17 +2981,39 @@ export class FoundryStepExecutor {
|
|
|
2980
2981
|
if (typeof this.ports.heldKeys?.put !== 'function') {
|
|
2981
2982
|
throw new Error(`${action.kind} for ${org.slug}: the executor's heldKeys cannot put the health token`);
|
|
2982
2983
|
}
|
|
2983
|
-
const token = await this.ensureSharedSecret(org, healthTokenName(
|
|
2984
|
+
const token = await this.ensureSharedSecret(org, healthTokenName(subject.id), recorded());
|
|
2984
2985
|
this.handledSecrets.add(token);
|
|
2985
2986
|
return token;
|
|
2986
2987
|
},
|
|
2988
|
+
actionsKey: async () => {
|
|
2989
|
+
const heldKeys = this.ports.heldKeys;
|
|
2990
|
+
if (typeof heldKeys?.put !== 'function') {
|
|
2991
|
+
throw new Error(`${action.kind} for ${org.slug}: the executor's heldKeys cannot put the server actions key`);
|
|
2992
|
+
}
|
|
2993
|
+
const put = heldKeys.put;
|
|
2994
|
+
const key = await mintActionsKey({
|
|
2995
|
+
id: subject.id,
|
|
2996
|
+
ref: sharedSecretRef(org.id, actionsKeyName(subject.id)),
|
|
2997
|
+
recorded,
|
|
2998
|
+
record: (k, v) => context.recordScopeId(k, v),
|
|
2999
|
+
persist: () => context.persistScopeIds(),
|
|
3000
|
+
heldKeys: {
|
|
3001
|
+
resolve: async (r) => (await heldKeys.resolve(r)) ?? null,
|
|
3002
|
+
put: (r, v) => put.call(heldKeys, r, v),
|
|
3003
|
+
},
|
|
3004
|
+
generate: () => randomBytes(32).toString('base64'),
|
|
3005
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
3006
|
+
});
|
|
3007
|
+
return key;
|
|
3008
|
+
},
|
|
2987
3009
|
secret: (value) => void this.handledSecrets.add(value),
|
|
2988
3010
|
runtime,
|
|
2989
3011
|
sleep: runtime.sleep ?? defaultSleep,
|
|
2990
|
-
label: `${action.system}:${action.kind}:${
|
|
3012
|
+
label: `${action.system}:${action.kind}:${subject.id}`,
|
|
2991
3013
|
primaryBlueAppId,
|
|
2992
3014
|
logDir: LOG_DIR,
|
|
2993
3015
|
isRunning: isRunningOrStarting,
|
|
3016
|
+
stageGuards: this.stageGuards(org, subject, recorded(), action.desired.accessProviderId),
|
|
2994
3017
|
};
|
|
2995
3018
|
if (action.kind === 'bluegreen-lb') {
|
|
2996
3019
|
await applyBlueGreenWeights(deps, subject, { uuid: serverUuid, primaryUuid });
|
|
@@ -3000,6 +3023,38 @@ export class FoundryStepExecutor {
|
|
|
3000
3023
|
}
|
|
3001
3024
|
return undefined;
|
|
3002
3025
|
}
|
|
3026
|
+
/**
|
|
3027
|
+
* Stage's guards (`plan/stage-guards.ts`): the probe port the host supplied, and the Access gate over the stage
|
|
3028
|
+
* host. The gate is offered only when everything it needs is in hand (a domain, the platform's Access provider,
|
|
3029
|
+
* the company's sign-in organisation, an owner, an Access port) and no `hosting.access` rule names the host: a
|
|
3030
|
+
* bypass or service-token rule there would open stage, so no gate is made and the guard stays unrecorded.
|
|
3031
|
+
*/
|
|
3032
|
+
stageGuards(org, subject, recorded, providerId) {
|
|
3033
|
+
const guards = this.ports.stageProbes ? { probes: this.ports.stageProbes } : {};
|
|
3034
|
+
const claimValue = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
3035
|
+
const emails = org.owners.map((owner) => owner.email);
|
|
3036
|
+
if (!org.domain || typeof providerId !== 'string' || !providerId || !claimValue)
|
|
3037
|
+
return guards;
|
|
3038
|
+
if (emails.length === 0 || !(this.ports.access || this.ports.edge))
|
|
3039
|
+
return guards;
|
|
3040
|
+
const hostname = stageHostname(org.domain, subject.id);
|
|
3041
|
+
if ((org.hosting.access ?? []).some((rule) => rule.hostname === hostname))
|
|
3042
|
+
return guards;
|
|
3043
|
+
guards.gate = {
|
|
3044
|
+
hostname,
|
|
3045
|
+
create: () => this.accessPort().createGate({
|
|
3046
|
+
organisationId: org.id,
|
|
3047
|
+
key: stageGateKey(subject.id),
|
|
3048
|
+
entry: subject.id,
|
|
3049
|
+
hostnames: [hostname],
|
|
3050
|
+
providerId,
|
|
3051
|
+
claimName: GATE_CLAIM_NAME,
|
|
3052
|
+
claimValue,
|
|
3053
|
+
emails,
|
|
3054
|
+
}),
|
|
3055
|
+
};
|
|
3056
|
+
return guards;
|
|
3057
|
+
}
|
|
3003
3058
|
async coolifyApp(action, org, context) {
|
|
3004
3059
|
const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
|
|
3005
3060
|
if (action.desired.enabled === false) {
|
|
@@ -3128,7 +3183,7 @@ export class FoundryStepExecutor {
|
|
|
3128
3183
|
autogenerate_domain: false,
|
|
3129
3184
|
// A static site is served by Coolify's own web server on port 80.
|
|
3130
3185
|
ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
|
|
3131
|
-
is_auto_deploy_enabled: entry.autoDeploy,
|
|
3186
|
+
is_auto_deploy_enabled: appSettingsOf(entry).autoDeploy,
|
|
3132
3187
|
// Also the folder a static entry serves: Coolify's static build
|
|
3133
3188
|
// copies `base_directory`, and reads `publish_directory` only for a
|
|
3134
3189
|
// built app.
|
|
@@ -3298,7 +3353,7 @@ export class FoundryStepExecutor {
|
|
|
3298
3353
|
// actually resolved a mail domain for this org.
|
|
3299
3354
|
// One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
|
|
3300
3355
|
// second entry sharing the name would delete the first's live token.
|
|
3301
|
-
if (entry
|
|
3356
|
+
if (isMainEntry(entry)) {
|
|
3302
3357
|
await this.ensureAppLettermintMail(appId, org, context,
|
|
3303
3358
|
// Deployed or started above on every path, a created app included
|
|
3304
3359
|
// (#566), so it has read its env before the mail env is written.
|
|
@@ -3718,10 +3773,8 @@ export class FoundryStepExecutor {
|
|
|
3718
3773
|
async pushAppEnv(appId, org, entry, context, authIssuer, secretSources) {
|
|
3719
3774
|
const recorded = context.recordedIds();
|
|
3720
3775
|
const envs = [
|
|
3721
|
-
// Only the
|
|
3722
|
-
...(entry
|
|
3723
|
-
? [{ key: 'APP_URL', value: `https://${appHostname(org)}` }]
|
|
3724
|
-
: []),
|
|
3776
|
+
// Only the main entry has a public name; the others are internal.
|
|
3777
|
+
...(isMainEntry(entry) ? [{ key: 'APP_URL', value: `https://${appHostname(org)}` }] : []),
|
|
3725
3778
|
// #75: read back by the package-side pino sink (not this repo's job,
|
|
3726
3779
|
// #75 "What belongs where") once `ensureLogVolume` has mounted the
|
|
3727
3780
|
// matching volume.
|
|
@@ -4203,7 +4256,9 @@ export class FoundryStepExecutor {
|
|
|
4203
4256
|
const { org, context, mailDomain, self, mailFrom } = args;
|
|
4204
4257
|
const appName = `${org.slug}-app`;
|
|
4205
4258
|
const recorded = context.recordedIds();
|
|
4206
|
-
const appId = self.role === 'app'
|
|
4259
|
+
const appId = self.role === 'app'
|
|
4260
|
+
? self.id
|
|
4261
|
+
: recorded[`coolify:app:${appStepKey(org, mainEntry(org.hosting)?.key)}`];
|
|
4207
4262
|
const bouleId = self.role === 'boule' ? self.id : recorded[`coolify:boule:${org.id}`];
|
|
4208
4263
|
const peerListEnvs = (id) => !id || id === self.id
|
|
4209
4264
|
? Promise.resolve(null)
|
|
@@ -7452,7 +7507,10 @@ export class FoundryStepExecutor {
|
|
|
7452
7507
|
const loginHost = action.desired.loginHost ?? null;
|
|
7453
7508
|
// A hosting entry's names (and its wildcard) go straight to its container on the company network:
|
|
7454
7509
|
// the container carries `traefik.enable=false`, so the proxy has no route for it.
|
|
7455
|
-
const entryOrigins = new Map(
|
|
7510
|
+
const entryOrigins = new Map([
|
|
7511
|
+
...entryRoutes(org, context.recordedIds()),
|
|
7512
|
+
...builtinRoutes(org, context.recordedIds()),
|
|
7513
|
+
].map((r) => [r.hostname, r.service]));
|
|
7456
7514
|
const ingress = tunnelIngressHostnames(org, loginHost).map((hostname) => ({
|
|
7457
7515
|
hostname,
|
|
7458
7516
|
service: hostname === loginHost
|
|
@@ -9175,6 +9233,7 @@ export class FoundryStepExecutor {
|
|
|
9175
9233
|
await this.accessPort().deleteAccessApp(target.id);
|
|
9176
9234
|
return;
|
|
9177
9235
|
case 'cloudflare:gate':
|
|
9236
|
+
case 'cloudflare:bg-gate':
|
|
9178
9237
|
await this.accessPort().deleteGate(target.id);
|
|
9179
9238
|
return;
|
|
9180
9239
|
case 'ai:key': {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { appEntry } from '../org.js';
|
|
2
1
|
import { agoraHostnameFor } from './agora.js';
|
|
3
2
|
import { bouleHostnameFor } from './boule.js';
|
|
4
3
|
import { callsHostnameFor, cmsHostnameFor } from './executor.js';
|
|
@@ -187,7 +186,7 @@ function hostingEntryTarget(org, entry) {
|
|
|
187
186
|
* differently-written version of the same choice.
|
|
188
187
|
*/
|
|
189
188
|
function appTargets(org) {
|
|
190
|
-
if (org.hosting.enabled &&
|
|
189
|
+
if (org.hosting.enabled && org.hosting.entries.length > 0) {
|
|
191
190
|
return org.hosting.entries.map((entry) => hostingEntryTarget(org, entry));
|
|
192
191
|
}
|
|
193
192
|
if (org.site.enabled) {
|
package/dist/plan/hostnames.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { DeclaredOrg, HostingEntry } from '../org.js';
|
|
2
2
|
/**
|
|
3
3
|
* The full names an entry is served at. With `hosting.usesDomain` off, or no
|
|
4
|
-
* domain, `hostnames` is not read: the
|
|
4
|
+
* domain, `hostnames` is not read: the main entry is at `<slug>.oikos.host`
|
|
5
5
|
* and every other entry is internal. `'*'` names nothing here; the wildcard is
|
|
6
6
|
* acted on elsewhere.
|
|
7
7
|
*/
|
|
@@ -10,7 +10,7 @@ export declare function entryHostnames(org: DeclaredOrg, entry: HostingEntry): s
|
|
|
10
10
|
* The one exact name a Worker app is served at (ADR 0002): `<host>.<domain>` or
|
|
11
11
|
* the apex on the org's own domain, else `<slug>-<key>.oikos.host` (one label
|
|
12
12
|
* under `oikos.host`, so the free Universal certificate covers it), and
|
|
13
|
-
* `<slug>.oikos.host` for the entry
|
|
13
|
+
* `<slug>.oikos.host` for the main entry.
|
|
14
14
|
*/
|
|
15
15
|
export declare function workerAppHostname(org: DeclaredOrg, entry: HostingEntry): string;
|
|
16
16
|
/**
|
package/dist/plan/hostnames.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isMainEntry, mainEntry } from '../org.js';
|
|
2
2
|
import { workerAppHostnameOf } from '../worker-app-config.js';
|
|
3
3
|
import { agoraHostnameFor } from './agora.js';
|
|
4
4
|
import { bouleHostnameFor } from './boule.js';
|
|
5
5
|
/**
|
|
6
6
|
* The full names an entry is served at. With `hosting.usesDomain` off, or no
|
|
7
|
-
* domain, `hostnames` is not read: the
|
|
7
|
+
* domain, `hostnames` is not read: the main entry is at `<slug>.oikos.host`
|
|
8
8
|
* and every other entry is internal. `'*'` names nothing here; the wildcard is
|
|
9
9
|
* acted on elsewhere.
|
|
10
10
|
*/
|
|
@@ -13,7 +13,7 @@ export function entryHostnames(org, entry) {
|
|
|
13
13
|
if (entry.workerApp)
|
|
14
14
|
return [workerAppHostname(org, entry)];
|
|
15
15
|
if (!org.hosting.usesDomain || !domain) {
|
|
16
|
-
return entry
|
|
16
|
+
return isMainEntry(entry) ? [`${org.slug}.oikos.host`] : [];
|
|
17
17
|
}
|
|
18
18
|
return entry.hostnames.flatMap((name) => name === '*' ? [] : name === '@' ? [domain] : [`${name}.${domain}`]);
|
|
19
19
|
}
|
|
@@ -21,10 +21,10 @@ export function entryHostnames(org, entry) {
|
|
|
21
21
|
* The one exact name a Worker app is served at (ADR 0002): `<host>.<domain>` or
|
|
22
22
|
* the apex on the org's own domain, else `<slug>-<key>.oikos.host` (one label
|
|
23
23
|
* under `oikos.host`, so the free Universal certificate covers it), and
|
|
24
|
-
* `<slug>.oikos.host` for the entry
|
|
24
|
+
* `<slug>.oikos.host` for the main entry.
|
|
25
25
|
*/
|
|
26
26
|
export function workerAppHostname(org, entry) {
|
|
27
|
-
return workerAppHostnameOf({ slug: org.slug, domain: org.domain, usesDomain: org.hosting.usesDomain }, entry
|
|
27
|
+
return workerAppHostnameOf({ slug: org.slug, domain: org.domain, usesDomain: org.hosting.usesDomain }, entry, entry.workerApp?.host ?? '@');
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
30
|
* The wildcard name (`*.<domain>`) when an entry declares `'*'`, else `null`.
|
|
@@ -43,7 +43,7 @@ export function wildcardHostname(org) {
|
|
|
43
43
|
* and the jobs registration read it.
|
|
44
44
|
*/
|
|
45
45
|
export function appHostname(org) {
|
|
46
|
-
const entry = org.hosting
|
|
46
|
+
const entry = mainEntry(org.hosting);
|
|
47
47
|
const first = entry ? entryHostnames(org, entry)[0] : undefined;
|
|
48
48
|
return first ?? legacyAppHostname(org);
|
|
49
49
|
}
|
package/dist/plan/index.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCO
|
|
|
24
24
|
export type { OrgPostgresMigrationConfirmation } from './org-postgres-migrate.js';
|
|
25
25
|
export { OrgPostgresMigrationBackupMissingError } from './backup-gate.js';
|
|
26
26
|
export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, FoundryApplyOptions, } from './plan-apply.js';
|
|
27
|
-
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
|
|
27
|
+
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, stepEntryKey, } from './plan-apply.js';
|
|
28
28
|
export type { UnconfirmedUpgrade } from './confirm-upgrade.js';
|
|
29
29
|
export { MAIL_STORE_SWITCH_SERVICE } from './mail-store.js';
|
|
30
30
|
export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './backup-gate.js';
|
|
@@ -39,6 +39,8 @@ export { createZitadelAdminMinter } from './zitadel-machine-user.js';
|
|
|
39
39
|
export { createZitadelLoginPolicy } from './zitadel-login-policy.js';
|
|
40
40
|
export { createZitadelOrgMetadata } from './zitadel-org-metadata.js';
|
|
41
41
|
export { createZitadelProjectRoles } from './zitadel-project-roles.js';
|
|
42
|
+
export { DumpError, dumpNow } from './database-dump.js';
|
|
43
|
+
export type { DatabaseDump } from './database-dump.js';
|
|
42
44
|
export type { CallsSfuConnection, CallsSfuConnectionPort, CallsSfuKeysPort, CallsSfuSshConnector, CallsSfuPlatformDeps, } from './platform-sfu.js';
|
|
43
45
|
export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
|
|
44
46
|
export type { MailCopyJob, MailCopyPort, MailCopyStepResult } from './mail-copy.js';
|
package/dist/plan/index.js
CHANGED
|
@@ -11,7 +11,7 @@ export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.
|
|
|
11
11
|
export { AdoptRefusedError } from './adopt.js';
|
|
12
12
|
export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationNotConfirmedError, } from './org-postgres-migrate.js';
|
|
13
13
|
export { OrgPostgresMigrationBackupMissingError } from './backup-gate.js';
|
|
14
|
-
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
|
|
14
|
+
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, stepEntryKey, } from './plan-apply.js';
|
|
15
15
|
export { MAIL_STORE_SWITCH_SERVICE } from './mail-store.js';
|
|
16
16
|
export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './backup-gate.js';
|
|
17
17
|
export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, provisionFilesHost, provisionJobsHost, provisionMailServer, } from './platform-providers.js';
|
|
@@ -22,6 +22,10 @@ export { createZitadelAdminMinter } from './zitadel-machine-user.js';
|
|
|
22
22
|
export { createZitadelLoginPolicy } from './zitadel-login-policy.js';
|
|
23
23
|
export { createZitadelOrgMetadata } from './zitadel-org-metadata.js';
|
|
24
24
|
export { createZitadelProjectRoles } from './zitadel-project-roles.js';
|
|
25
|
+
// #170: the platform's shared calls SFU step -- narrow ports a host (archon)
|
|
26
|
+
// implements over held keys and its own SSH client, the same shape the rest
|
|
27
|
+
// of `platform-providers.ts` uses for Cloudflare.
|
|
28
|
+
export { DumpError, dumpNow } from './database-dump.js';
|
|
25
29
|
export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
|
|
26
30
|
export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
27
31
|
export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeLoginHostResolver, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
|
|
@@ -58,5 +58,6 @@ export function hostingNames(org, platformHostnames) {
|
|
|
58
58
|
locked: lockedHostnames(org, platformHostnames),
|
|
59
59
|
slug: org.slug,
|
|
60
60
|
siteOwnsApex: org.site.enabled && org.site.usesDomain && org.domain !== null,
|
|
61
|
+
builtinBlueGreen: org.boule?.blueGreen !== undefined || org.agora?.blueGreen !== undefined,
|
|
61
62
|
};
|
|
62
63
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DestroyProgress, PlanApply } from '../apply.js';
|
|
2
|
-
import type { DeclaredOrg, OrgId } from '../org.js';
|
|
2
|
+
import type { DeclaredOrg, HostingEntry, OrgId } from '../org.js';
|
|
3
3
|
import type { Plan, PlanStep } from '../plan.js';
|
|
4
4
|
import type { DeclaredPlatform } from '../platform.js';
|
|
5
5
|
import type { ReleaseStore } from '../release/state.js';
|
|
@@ -443,6 +443,18 @@ export declare class PlatformConfirmationRequiredError extends Error {
|
|
|
443
443
|
export interface FoundryApplyOptions {
|
|
444
444
|
/** Service names (`VersionChangeItem.service`), matched exactly, whose version change the operator confirmed. */
|
|
445
445
|
confirmUpgrade?: readonly string[];
|
|
446
|
+
/**
|
|
447
|
+
* A company's own confirmation for one of its apps, in place of the org's name. `apply` passes a
|
|
448
|
+
* destroying or migrating step only when {@link stepEntryKey} says it belongs to `entryKey` and
|
|
449
|
+
* `typed` equals `entryKey` exactly. Every other such step still needs the org's name, as does
|
|
450
|
+
* the whole plan when `typed` differs. It never passes a platform step.
|
|
451
|
+
*/
|
|
452
|
+
entryConfirmation?: {
|
|
453
|
+
entryKey: string;
|
|
454
|
+
typed: string;
|
|
455
|
+
/** The removed entry's gate keys, read by the caller from the company before the change. */
|
|
456
|
+
gates?: readonly string[];
|
|
457
|
+
};
|
|
446
458
|
}
|
|
447
459
|
export interface FoundryPlanApplyOptions {
|
|
448
460
|
store: DeclaredOrgStore;
|
|
@@ -947,3 +959,20 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
947
959
|
* than a second copy that could drift from it.
|
|
948
960
|
*/
|
|
949
961
|
export declare function planNeedsConfirmation(steps: readonly PlanStep[]): boolean;
|
|
962
|
+
/**
|
|
963
|
+
* The key of the hosting entry a step belongs to, or `null` for a step of the org or the platform.
|
|
964
|
+
* An entry's own steps are keyed `<orgId>:<entry key>` (its Coolify app, Worker, Worker app, and
|
|
965
|
+
* ai, edge and files keys); with `orgId` the prefix must be exactly that id. With `entries` (the
|
|
966
|
+
* declared ones) two more are known: the ZITADEL sign-in client named like an entry that declares
|
|
967
|
+
* `uses.auth`, and a gate named in an entry's `gates`. The org database, the org-wide files key,
|
|
968
|
+
* the customers organisation and the issuer belong to no entry. (`apply` lets an entry's
|
|
969
|
+
* confirmation pass the park of the org database once no declared entry uses a database.)
|
|
970
|
+
*/
|
|
971
|
+
export declare function stepEntryKey(step: {
|
|
972
|
+
action: Pick<StepAction, 'system' | 'kind' | 'key'>;
|
|
973
|
+
}, orgId?: string, entries?: readonly Pick<HostingEntry, 'key' | 'uses' | 'gates'>[]): string | null;
|
|
974
|
+
/** Whether `confirmation` passes every destroying or migrating step of `steps`: all belong to its entry, and it typed the key. */
|
|
975
|
+
export declare function entryConfirms(confirmation: FoundryApplyOptions['entryConfirmation'], steps: readonly {
|
|
976
|
+
step: PlanStep;
|
|
977
|
+
action: StepAction;
|
|
978
|
+
}[], orgId: string, entries: readonly HostingEntry[]): boolean;
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -5,10 +5,11 @@ import { ORG_SERVICE_SCHEMAS, heldEnvRef, ownsServices, validateHosting } from '
|
|
|
5
5
|
import { PLATFORM_SERVICE_KEYS } from '../platform.js';
|
|
6
6
|
import { AlreadyRunningError, StepFailedError } from '../runner/errors.js';
|
|
7
7
|
import { StaleLeaseError, } from '../store.js';
|
|
8
|
+
import { entryHasDatabase } from '../worker-app-config.js';
|
|
8
9
|
import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
|
|
9
10
|
import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
|
|
10
11
|
import { NetworkMoveBackupMissingError, OrgPostgresMigrationBackupMissingError, SigninDbMoveBackupMissingError, UpgradeBackupMissingError, platformDatabasesForService, requireFreshBackupForOrgPostgresMigration, requireFreshBackupForSigninMove, requireFreshBackupsForMove, requireFreshBackupsForUpgrades, } from './backup-gate.js';
|
|
11
|
-
import { ARCHON_REPO, PRIMARY_SERVER_SCOPE_KEY, archonSubject,
|
|
12
|
+
import { ARCHON_REPO, PRIMARY_SERVER_SCOPE_KEY, archonSubject, blueGreenSubjects, declaresBlueGreen, lbOrigin, } from './blue-green.js';
|
|
12
13
|
import { readLiveBuilds } from './build-record.js';
|
|
13
14
|
import { LEGACY_BACKUP_NAMES } from './company-backups.js';
|
|
14
15
|
import { unconfirmedUpgrades } from './confirm-upgrade.js';
|
|
@@ -577,7 +578,8 @@ export class FoundryPlanApply {
|
|
|
577
578
|
}
|
|
578
579
|
}
|
|
579
580
|
if (planNeedsConfirmation(plan.steps) &&
|
|
580
|
-
confirmedOrgName.trim() !== org.displayName.trim()
|
|
581
|
+
confirmedOrgName.trim() !== org.displayName.trim() &&
|
|
582
|
+
!entryConfirms(options.entryConfirmation, detail.plannedSteps, org.id, org.hosting.entries)) {
|
|
581
583
|
await this.refuse(plan, "destructive or migrating step without the org's name typed correctly");
|
|
582
584
|
throw new ConfirmationRequiredError(orgId);
|
|
583
585
|
}
|
|
@@ -1149,10 +1151,13 @@ export class FoundryPlanApply {
|
|
|
1149
1151
|
const primaryUuid = context.recordedIds()[PRIMARY_SERVER_SCOPE_KEY];
|
|
1150
1152
|
if (!store || !this.executor.releaseHost || !primaryUuid)
|
|
1151
1153
|
return;
|
|
1152
|
-
for (const
|
|
1153
|
-
|
|
1154
|
-
|
|
1154
|
+
for (const subject of blueGreenSubjects(org, context.recordedIds())) {
|
|
1155
|
+
// A built-in app's names reach its balancer only once the tunnel was last written with it: until then a
|
|
1156
|
+
// pinned check would answer from blue whatever color it names.
|
|
1157
|
+
if (subject.builtin &&
|
|
1158
|
+
!(context.recordedIds()[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY] ?? '').includes(`"service":"${lbOrigin(subject)}"`)) {
|
|
1155
1159
|
continue;
|
|
1160
|
+
}
|
|
1156
1161
|
const host = this.executor.releaseHost(org, subject, context, () => this.renewLease(fence));
|
|
1157
1162
|
if (!host)
|
|
1158
1163
|
continue;
|
|
@@ -1222,7 +1227,7 @@ export class FoundryPlanApply {
|
|
|
1222
1227
|
async runReleaseTick() {
|
|
1223
1228
|
const results = [];
|
|
1224
1229
|
for (const org of await this.store.list()) {
|
|
1225
|
-
if (!org
|
|
1230
|
+
if (!declaresBlueGreen(org))
|
|
1226
1231
|
continue;
|
|
1227
1232
|
try {
|
|
1228
1233
|
await this.runOrgReleaseTick(org.id);
|
|
@@ -1305,7 +1310,7 @@ export class FoundryPlanApply {
|
|
|
1305
1310
|
const sfuPortsOpen = await this.readSfuPortsOpen(org);
|
|
1306
1311
|
const resumable = await this.ownResumableRefs(org);
|
|
1307
1312
|
// Read only for an org that declares it; a failed read plans none and says so (`planBlueGreen`).
|
|
1308
|
-
const blueGreenServers = org
|
|
1313
|
+
const blueGreenServers = declaresBlueGreen(org) && this.executor.blueGreenServers
|
|
1309
1314
|
? await this.executor.blueGreenServers().catch(() => undefined)
|
|
1310
1315
|
: undefined;
|
|
1311
1316
|
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
|
|
@@ -1859,6 +1864,73 @@ export class FoundryPlanApply {
|
|
|
1859
1864
|
export function planNeedsConfirmation(steps) {
|
|
1860
1865
|
return steps.some((step) => step.destroys || step.migrates);
|
|
1861
1866
|
}
|
|
1867
|
+
/**
|
|
1868
|
+
* The key of the hosting entry a step belongs to, or `null` for a step of the org or the platform.
|
|
1869
|
+
* An entry's own steps are keyed `<orgId>:<entry key>` (its Coolify app, Worker, Worker app, and
|
|
1870
|
+
* ai, edge and files keys); with `orgId` the prefix must be exactly that id. With `entries` (the
|
|
1871
|
+
* declared ones) two more are known: the ZITADEL sign-in client named like an entry that declares
|
|
1872
|
+
* `uses.auth`, and a gate named in an entry's `gates`. The org database, the org-wide files key,
|
|
1873
|
+
* the customers organisation and the issuer belong to no entry. (`apply` lets an entry's
|
|
1874
|
+
* confirmation pass the park of the org database once no declared entry uses a database.)
|
|
1875
|
+
*/
|
|
1876
|
+
export function stepEntryKey(step, orgId, entries = []) {
|
|
1877
|
+
const { system, kind, key } = step.action;
|
|
1878
|
+
if (system === 'zitadel' && kind === 'app') {
|
|
1879
|
+
return entries.find((e) => e.key === key && e.uses?.auth)?.key ?? null;
|
|
1880
|
+
}
|
|
1881
|
+
const entryOwned = (system === 'coolify' && kind === 'app') ||
|
|
1882
|
+
(system === 'cloudflare' && (kind === 'worker' || kind === 'worker-app' || kind === 'gate')) ||
|
|
1883
|
+
(kind === 'key' && (system === 'ai' || system === 'edge' || system === 'files'));
|
|
1884
|
+
if (!entryOwned)
|
|
1885
|
+
return null;
|
|
1886
|
+
const colon = key.indexOf(':');
|
|
1887
|
+
if (colon < 1)
|
|
1888
|
+
return null;
|
|
1889
|
+
const prefix = key.slice(0, colon);
|
|
1890
|
+
if (orgId === undefined ? prefix === 'platform' : prefix !== orgId)
|
|
1891
|
+
return null;
|
|
1892
|
+
const rest = key.slice(colon + 1);
|
|
1893
|
+
if (rest === '' || rest.includes(':'))
|
|
1894
|
+
return null;
|
|
1895
|
+
if (kind === 'gate')
|
|
1896
|
+
return entries.find((e) => e.gates?.some((g) => g.key === rest))?.key ?? null;
|
|
1897
|
+
return rest;
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* The step that parks the org's one database. It belongs to the org, not to an entry, but the
|
|
1901
|
+
* entry that removes the last use of it may confirm it (see `entryConfirms`).
|
|
1902
|
+
*/
|
|
1903
|
+
function isOrgDatabasePark(step, orgId) {
|
|
1904
|
+
const { system, kind, key } = step.action;
|
|
1905
|
+
return system === 'coolify' && kind === 'database' && key === orgId && step.step.destroys;
|
|
1906
|
+
}
|
|
1907
|
+
/**
|
|
1908
|
+
* The delete of one of the org's gates that no entry declares any more. The step names only the
|
|
1909
|
+
* gate, so the caller names the removed entry's gates (`entryConfirmation.gates`) and only those pass.
|
|
1910
|
+
*/
|
|
1911
|
+
function isUndeclaredGateDelete(step, orgId) {
|
|
1912
|
+
const { system, kind, key, desired } = step.action;
|
|
1913
|
+
return (system === 'cloudflare' &&
|
|
1914
|
+
kind === 'gate' &&
|
|
1915
|
+
key.startsWith(`${orgId}:`) &&
|
|
1916
|
+
desired?.mode === 'delete');
|
|
1917
|
+
}
|
|
1918
|
+
/** Whether `confirmation` passes every destroying or migrating step of `steps`: all belong to its entry, and it typed the key. */
|
|
1919
|
+
export function entryConfirms(confirmation, steps, orgId, entries) {
|
|
1920
|
+
if (!confirmation ||
|
|
1921
|
+
confirmation.entryKey === '' ||
|
|
1922
|
+
confirmation.typed !== confirmation.entryKey) {
|
|
1923
|
+
return false;
|
|
1924
|
+
}
|
|
1925
|
+
return steps
|
|
1926
|
+
.filter(({ step }) => step.destroys || step.migrates)
|
|
1927
|
+
.every((planned) => stepEntryKey(planned, orgId, entries) === confirmation.entryKey ||
|
|
1928
|
+
(isOrgDatabasePark(planned, orgId) && !entries.some(entryHasDatabase)) ||
|
|
1929
|
+
(isUndeclaredGateDelete(planned, orgId) &&
|
|
1930
|
+
!planned.step.migrates &&
|
|
1931
|
+
(confirmation.gates ?? []).includes(planned.action.key.slice(orgId.length + 1)) &&
|
|
1932
|
+
!entries.some((e) => e.key === confirmation.entryKey)));
|
|
1933
|
+
}
|
|
1862
1934
|
/** `AppliedState.serviceIds`' two flat ZITADEL scope keys (`executor.ts`'s `ensureZitadelProject`/`zitadelIssuer`) -- no `:`, unlike a resource key. */
|
|
1863
1935
|
const ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_project';
|
|
1864
1936
|
const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
|
|
@@ -2068,11 +2140,12 @@ function destroyStepDescriptions(targets) {
|
|
|
2068
2140
|
/** Scope keys of the Coolify apps blue/green made: `bg.<entry>.app.<server>.<part>`, a database sidecar, a connector on another server. */
|
|
2069
2141
|
const BLUE_GREEN_APP_KEY = /^(bg\.[^.:]+\.app\.[^.:]+\.[a-z]+|bgdb\.[^.:]+\.sidecar\.[^.:]+|cloudflare_tunnel_connector_app\.[^.:]+)$/;
|
|
2070
2142
|
const BLUE_GREEN_DATABASE_KEY = /^bg\.[^.:]+\.stagedb$/;
|
|
2071
|
-
/**
|
|
2143
|
+
/** Stage's Access gate (`bg.<entry>.stagegate`), and the database route's Cloudflare parts: its Access app, its service token (also one minted and never delivered) and its DNS record (recorded as the route). */
|
|
2072
2144
|
const BLUE_GREEN_EDGE_KEYS = [
|
|
2073
2145
|
[/^bgdb\.[^.:]+\.access$/, 'bg-access'],
|
|
2074
2146
|
[/^bgdb\.[^.:]+\.(token|undelivered)$/, 'bg-token'],
|
|
2075
2147
|
[/^bgdb\.[^.:]+\.route$/, 'bg-dns'],
|
|
2148
|
+
[/^bg\.[^.:]+\.stagegate$/, 'bg-gate'],
|
|
2076
2149
|
];
|
|
2077
2150
|
/** Every recorded blue/green app (then stage's database), in key order. A destroy target's `key` is the scope key itself. */
|
|
2078
2151
|
function blueGreenDestroyTargets(serviceIds) {
|
|
@@ -2152,6 +2225,11 @@ const STEP_FIELDS = {
|
|
|
2152
2225
|
'cloudflare:worker-app': 'hosting.entries.workerApp',
|
|
2153
2226
|
};
|
|
2154
2227
|
function stepField(action) {
|
|
2228
|
+
if (action.system === 'coolify' &&
|
|
2229
|
+
action.kind.startsWith('bluegreen') &&
|
|
2230
|
+
(action.desired.entry === 'boule' || action.desired.entry === 'agora')) {
|
|
2231
|
+
return `${action.desired.entry}.blueGreen`;
|
|
2232
|
+
}
|
|
2155
2233
|
// An entry's ai key (F6a) is keyed `<orgId>:<entry>`; the org's own is `<orgId>`.
|
|
2156
2234
|
if (action.system === 'ai' && action.kind === 'key' && action.key.includes(':')) {
|
|
2157
2235
|
return 'hosting.entries.uses.ai';
|
|
@@ -730,7 +730,8 @@ export function appSettingsOf(entry) {
|
|
|
730
730
|
return {
|
|
731
731
|
folder: entry.folder,
|
|
732
732
|
port: entry.port,
|
|
733
|
-
|
|
733
|
+
// A blue/green entry deploys only through the release runner.
|
|
734
|
+
autoDeploy: entry.blueGreen ? false : entry.autoDeploy,
|
|
734
735
|
...(entry.buildPack === 'static' ? { staticFolderIn: 'base_directory' } : {}),
|
|
735
736
|
};
|
|
736
737
|
}
|
|
@@ -3890,8 +3890,13 @@ export const PLATFORM_ARCHON_PUBLIC_PATHS = [
|
|
|
3890
3890
|
{ name: 'vipps-agreement', path: '/vipps/agreement' },
|
|
3891
3891
|
{ name: 'usage-tick', path: '/api/usage/tick' },
|
|
3892
3892
|
{ name: 'reporting-collect', path: '/api/reporting/collect' },
|
|
3893
|
+
{ name: 'release-health', path: '/api/reporting/release-health' },
|
|
3894
|
+
{ name: 'health', path: '/api/health' },
|
|
3893
3895
|
{ name: 'org-changes', path: '/api/orgs/*/changes' },
|
|
3894
3896
|
{ name: 'org-settings', path: '/api/orgs/*/settings' },
|
|
3897
|
+
{ name: 'org-apps', path: '/api/orgs/*/apps' },
|
|
3898
|
+
{ name: 'org-requests', path: '/api/orgs/*/requests' },
|
|
3899
|
+
{ name: 'github-releases', path: '/api/github/releases' },
|
|
3895
3900
|
];
|
|
3896
3901
|
const PLATFORM_ARCHON_PUBLIC_POLICY_NAME = 'foundry: public path';
|
|
3897
3902
|
const archonPublicAppName = (name) => `${PLATFORM_ARCHON_ACCESS_APP_NAME}:public:${name}`;
|