@wtfalch/foundry 0.47.0 → 0.49.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/declare.js +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +3 -2
- package/dist/migrations/0059_org_builtin_blue_green.sql +9 -0
- package/dist/migrations/0060_hosting_repos.sql +7 -0
- package/dist/org.d.ts +25 -1
- package/dist/org.js +51 -3
- 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 +3 -0
- package/dist/plan/executor.d.ts +48 -0
- package/dist/plan/executor.js +79 -20
- package/dist/plan/github-release-hook.d.ts +81 -0
- package/dist/plan/github-release-hook.js +159 -0
- package/dist/plan/index.d.ts +4 -1
- package/dist/plan/index.js +5 -1
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +1 -0
- package/dist/plan/locked-hostnames.js +1 -0
- package/dist/plan/plan-apply.js +16 -7
- package/dist/plan/platform-diff.d.ts +59 -0
- package/dist/plan/platform-diff.js +100 -2
- package/dist/plan/platform-providers.d.ts +10 -0
- package/dist/plan/platform-providers.js +73 -1
- 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/refuse-org.d.ts +1 -1
- package/dist/refuse-org.js +5 -3
- 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/store/postgres.js +32 -2
- package/dist/store/schema.d.ts +58 -1
- package/dist/store/schema.js +5 -0
- package/package.json +1 -1
package/dist/plan/executor.js
CHANGED
|
@@ -8,7 +8,7 @@ import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, file
|
|
|
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
|
/**
|
|
@@ -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.
|
|
@@ -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': {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { CoolifyPort } from './executor.js';
|
|
2
|
+
/** Every GitHub call is cut off after this long. */
|
|
3
|
+
export declare const GITHUB_API_TIMEOUT_MS = 20000;
|
|
4
|
+
/** A GitHub call that failed. The text is fixed: it never holds the token, the secret or a response body. */
|
|
5
|
+
export declare class GithubApiError extends Error {
|
|
6
|
+
readonly operation: string;
|
|
7
|
+
readonly status: number | null;
|
|
8
|
+
constructor(operation: string, status: number | null);
|
|
9
|
+
}
|
|
10
|
+
/** The shape of an organisation hook foundry reads. */
|
|
11
|
+
export interface GithubOrgHook {
|
|
12
|
+
id: number;
|
|
13
|
+
active: boolean;
|
|
14
|
+
events: string[];
|
|
15
|
+
config: {
|
|
16
|
+
url?: string;
|
|
17
|
+
content_type?: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export interface GithubReleaseHookBody {
|
|
21
|
+
name: 'web';
|
|
22
|
+
active: true;
|
|
23
|
+
events: ['release'];
|
|
24
|
+
config: {
|
|
25
|
+
url: string;
|
|
26
|
+
content_type: 'json';
|
|
27
|
+
secret: string;
|
|
28
|
+
insecure_ssl: '0';
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export interface GithubHooksClient {
|
|
32
|
+
list(org: string): Promise<GithubOrgHook[]>;
|
|
33
|
+
create(org: string, body: GithubReleaseHookBody): Promise<GithubOrgHook>;
|
|
34
|
+
update(org: string, id: number, body: GithubReleaseHookBody): Promise<GithubOrgHook>;
|
|
35
|
+
}
|
|
36
|
+
/** The GitHub REST organisation-hooks calls, over `fetchImpl` (global fetch by default) with a timeout on each. */
|
|
37
|
+
export declare function createGithubHooksClient(options: {
|
|
38
|
+
token: string;
|
|
39
|
+
fetchImpl?: typeof fetch;
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
}): GithubHooksClient;
|
|
42
|
+
export interface PlatformGithubReleaseHookDeps {
|
|
43
|
+
coolify: Pick<CoolifyPort, 'listEnvs' | 'bulkUpsertEnvs'>;
|
|
44
|
+
heldKeys: {
|
|
45
|
+
resolve(ref: string): Promise<string | null>;
|
|
46
|
+
};
|
|
47
|
+
/** Global fetch by default; tests give their own. */
|
|
48
|
+
fetchImpl?: typeof fetch;
|
|
49
|
+
/** Told of the token and the secret, so the host can scrub them from logs and audit rows. */
|
|
50
|
+
onSecret?: (value: string) => void;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* What the planner needs: why the step is blocked (the token is not held), or `ready` (the token is
|
|
54
|
+
* held) with the hash of the hook's url and Archon's live secret when Archon already holds one. With no
|
|
55
|
+
* secret yet the step still plans: `platform:archon` mints it earlier in the same apply.
|
|
56
|
+
*/
|
|
57
|
+
export declare function githubReleaseHookPlanInputs(deps: PlatformGithubReleaseHookDeps, hostname: string, recorded: Readonly<Record<string, string>>): Promise<{
|
|
58
|
+
blocked?: string;
|
|
59
|
+
ready?: true;
|
|
60
|
+
hash?: string;
|
|
61
|
+
}>;
|
|
62
|
+
/**
|
|
63
|
+
* Makes the organisation's release webhook. Adopts the hook whose url is Archon's, else creates one;
|
|
64
|
+
* an adopted hook is always written again, because GitHub never returns its secret.
|
|
65
|
+
*
|
|
66
|
+
* The secret is made here when Archon's app does not hold one yet (`ensureSecret`, which keeps a live
|
|
67
|
+
* value), so the step can run before `platform:archon`; that step then keeps the same value.
|
|
68
|
+
*
|
|
69
|
+
* A failure halfway: a hook made and not recorded is adopted by its url on the next run; a hook
|
|
70
|
+
* written and not recorded is written again. The id and the hash are recorded together, after the write.
|
|
71
|
+
*/
|
|
72
|
+
export declare function provisionGithubReleaseHook(context: {
|
|
73
|
+
platform: {
|
|
74
|
+
archon: {
|
|
75
|
+
hostname: string;
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
recordedIds(): Record<string, string>;
|
|
79
|
+
recordScopeId(key: string, id: string): void;
|
|
80
|
+
persist?(): Promise<void>;
|
|
81
|
+
}, deps: PlatformGithubReleaseHookDeps, ensureSecret: (appId: string) => Promise<string>): Promise<undefined>;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { GITHUB_RELEASE_HOOK_COOLIFY_READ_FAILED, GITHUB_RELEASE_HOOK_ORG, GITHUB_RELEASE_HOOK_TOKEN_MISSING, GITHUB_RELEASE_WEBHOOK_SECRET_ENV, PLATFORM_ARCHON_KEY, PLATFORM_GITHUB_ORG_HOOKS_TOKEN_REF, PLATFORM_GITHUB_RELEASE_HOOK_HASH_SCOPE_KEY, PLATFORM_GITHUB_RELEASE_HOOK_ID_SCOPE_KEY, githubReleaseHookHash, githubReleaseHookUrl, } from './platform-diff.js';
|
|
2
|
+
/** Every GitHub call is cut off after this long. */
|
|
3
|
+
export const GITHUB_API_TIMEOUT_MS = 20_000;
|
|
4
|
+
const GITHUB_API = 'https://api.github.com';
|
|
5
|
+
const PAGE_SIZE = 100;
|
|
6
|
+
const MAX_PAGES = 20;
|
|
7
|
+
/** A GitHub call that failed. The text is fixed: it never holds the token, the secret or a response body. */
|
|
8
|
+
export class GithubApiError extends Error {
|
|
9
|
+
operation;
|
|
10
|
+
status;
|
|
11
|
+
constructor(operation, status) {
|
|
12
|
+
super(status === null
|
|
13
|
+
? `GitHub API ${operation} did not answer`
|
|
14
|
+
: `GitHub API ${operation} failed with status ${status}`);
|
|
15
|
+
this.operation = operation;
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.name = 'GithubApiError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** The GitHub REST organisation-hooks calls, over `fetchImpl` (global fetch by default) with a timeout on each. */
|
|
21
|
+
export function createGithubHooksClient(options) {
|
|
22
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
23
|
+
const timeoutMs = options.timeoutMs ?? GITHUB_API_TIMEOUT_MS;
|
|
24
|
+
const call = async (operation, method, path, body) => {
|
|
25
|
+
let response;
|
|
26
|
+
try {
|
|
27
|
+
response = await doFetch(`${GITHUB_API}${path}`, {
|
|
28
|
+
method,
|
|
29
|
+
headers: {
|
|
30
|
+
Authorization: `Bearer ${options.token}`,
|
|
31
|
+
Accept: 'application/vnd.github+json',
|
|
32
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
33
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
34
|
+
},
|
|
35
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
36
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
console.error(`github release hook: ${operation} did not answer (${error instanceof Error ? error.name : 'error'})`);
|
|
41
|
+
throw new GithubApiError(operation, null);
|
|
42
|
+
}
|
|
43
|
+
if (!response.ok)
|
|
44
|
+
throw new GithubApiError(operation, response.status);
|
|
45
|
+
try {
|
|
46
|
+
return await response.json();
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
console.error(`github release hook: ${operation} answered with unreadable JSON (${error instanceof Error ? error.name : 'error'})`);
|
|
50
|
+
throw new GithubApiError(operation, response.status);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const asHook = (operation, value) => {
|
|
54
|
+
const hook = value;
|
|
55
|
+
if (!hook || typeof hook.id !== 'number')
|
|
56
|
+
throw new GithubApiError(operation, 200);
|
|
57
|
+
return {
|
|
58
|
+
id: hook.id,
|
|
59
|
+
active: hook.active === true,
|
|
60
|
+
events: Array.isArray(hook.events) ? hook.events.map(String) : [],
|
|
61
|
+
config: {
|
|
62
|
+
...(typeof hook.config?.url === 'string' ? { url: hook.config.url } : {}),
|
|
63
|
+
...(typeof hook.config?.content_type === 'string'
|
|
64
|
+
? { content_type: hook.config.content_type }
|
|
65
|
+
: {}),
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
async list(org) {
|
|
71
|
+
const hooks = [];
|
|
72
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
73
|
+
const value = await call('list hooks', 'GET', `/orgs/${org}/hooks?per_page=${PAGE_SIZE}&page=${page}`);
|
|
74
|
+
if (!Array.isArray(value))
|
|
75
|
+
throw new GithubApiError('list hooks', 200);
|
|
76
|
+
hooks.push(...value.map((v) => asHook('list hooks', v)));
|
|
77
|
+
if (value.length < PAGE_SIZE)
|
|
78
|
+
return hooks;
|
|
79
|
+
}
|
|
80
|
+
return hooks;
|
|
81
|
+
},
|
|
82
|
+
async create(org, body) {
|
|
83
|
+
return asHook('create hook', await call('create hook', 'POST', `/orgs/${org}/hooks`, body));
|
|
84
|
+
},
|
|
85
|
+
async update(org, id, body) {
|
|
86
|
+
return asHook('update hook', await call('update hook', 'PATCH', `/orgs/${org}/hooks/${id}`, body));
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
async function liveWebhookSecret(deps, recorded) {
|
|
91
|
+
const appId = recorded[`coolify:app:${PLATFORM_ARCHON_KEY}`];
|
|
92
|
+
if (!appId)
|
|
93
|
+
return undefined;
|
|
94
|
+
const envs = await deps.coolify.listEnvs(appId);
|
|
95
|
+
const value = envs.find((e) => e.key === GITHUB_RELEASE_WEBHOOK_SECRET_ENV && !e.is_preview)?.value;
|
|
96
|
+
return value || undefined;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* What the planner needs: why the step is blocked (the token is not held), or `ready` (the token is
|
|
100
|
+
* held) with the hash of the hook's url and Archon's live secret when Archon already holds one. With no
|
|
101
|
+
* secret yet the step still plans: `platform:archon` mints it earlier in the same apply.
|
|
102
|
+
*/
|
|
103
|
+
export async function githubReleaseHookPlanInputs(deps, hostname, recorded) {
|
|
104
|
+
const token = await deps.heldKeys.resolve(PLATFORM_GITHUB_ORG_HOOKS_TOKEN_REF);
|
|
105
|
+
if (!token)
|
|
106
|
+
return { blocked: GITHUB_RELEASE_HOOK_TOKEN_MISSING };
|
|
107
|
+
let secret;
|
|
108
|
+
try {
|
|
109
|
+
secret = await liveWebhookSecret(deps, recorded);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
console.error(`github release hook: could not read Archon's env from Coolify (${error instanceof Error ? error.name : 'error'})`);
|
|
113
|
+
return { blocked: GITHUB_RELEASE_HOOK_COOLIFY_READ_FAILED };
|
|
114
|
+
}
|
|
115
|
+
return secret
|
|
116
|
+
? { ready: true, hash: githubReleaseHookHash(githubReleaseHookUrl(hostname), secret) }
|
|
117
|
+
: { ready: true };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Makes the organisation's release webhook. Adopts the hook whose url is Archon's, else creates one;
|
|
121
|
+
* an adopted hook is always written again, because GitHub never returns its secret.
|
|
122
|
+
*
|
|
123
|
+
* The secret is made here when Archon's app does not hold one yet (`ensureSecret`, which keeps a live
|
|
124
|
+
* value), so the step can run before `platform:archon`; that step then keeps the same value.
|
|
125
|
+
*
|
|
126
|
+
* A failure halfway: a hook made and not recorded is adopted by its url on the next run; a hook
|
|
127
|
+
* written and not recorded is written again. The id and the hash are recorded together, after the write.
|
|
128
|
+
*/
|
|
129
|
+
export async function provisionGithubReleaseHook(context, deps, ensureSecret) {
|
|
130
|
+
const appId = context.recordedIds()[`coolify:app:${PLATFORM_ARCHON_KEY}`];
|
|
131
|
+
if (!appId) {
|
|
132
|
+
throw new Error("platform:github-release-hook: Archon's app is not recorded yet; platform:archon runs first");
|
|
133
|
+
}
|
|
134
|
+
const token = await deps.heldKeys.resolve(PLATFORM_GITHUB_ORG_HOOKS_TOKEN_REF);
|
|
135
|
+
if (!token)
|
|
136
|
+
throw new Error(`platform:github-release-hook: ${GITHUB_RELEASE_HOOK_TOKEN_MISSING}`);
|
|
137
|
+
const secret = await ensureSecret(appId);
|
|
138
|
+
deps.onSecret?.(secret);
|
|
139
|
+
deps.onSecret?.(token);
|
|
140
|
+
const url = githubReleaseHookUrl(context.platform.archon.hostname);
|
|
141
|
+
const body = {
|
|
142
|
+
name: 'web',
|
|
143
|
+
active: true,
|
|
144
|
+
events: ['release'],
|
|
145
|
+
config: { url, content_type: 'json', secret, insecure_ssl: '0' },
|
|
146
|
+
};
|
|
147
|
+
const client = createGithubHooksClient({
|
|
148
|
+
token,
|
|
149
|
+
...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
|
|
150
|
+
});
|
|
151
|
+
const existing = (await client.list(GITHUB_RELEASE_HOOK_ORG)).find((h) => h.config.url === url);
|
|
152
|
+
const hook = existing
|
|
153
|
+
? await client.update(GITHUB_RELEASE_HOOK_ORG, existing.id, body)
|
|
154
|
+
: await client.create(GITHUB_RELEASE_HOOK_ORG, body);
|
|
155
|
+
context.recordScopeId(PLATFORM_GITHUB_RELEASE_HOOK_ID_SCOPE_KEY, String(hook.id));
|
|
156
|
+
context.recordScopeId(PLATFORM_GITHUB_RELEASE_HOOK_HASH_SCOPE_KEY, githubReleaseHookHash(url, secret));
|
|
157
|
+
await context.persist?.();
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
package/dist/plan/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export { computePlanDetail, toPlan } from './diff.js';
|
|
|
5
5
|
export type { StepSystem } from './step-system.js';
|
|
6
6
|
export { stepSystemOf } from './step-system.js';
|
|
7
7
|
export type { PlatformPlanDetail, MailRoute } from './platform-diff.js';
|
|
8
|
-
export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, } from './platform-diff.js';
|
|
8
|
+
export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, GITHUB_RELEASE_HOOK_ORG, GITHUB_RELEASE_HOOK_PATH, GITHUB_RELEASE_WEBHOOK_SECRET_ENV, PLATFORM_GITHUB_ORG_HOOKS_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, } from './platform-diff.js';
|
|
9
9
|
export type { HeldKeyResolver, OrgPlatformReadiness } from './platform-org.js';
|
|
10
10
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesForOwn, agoraHostValuesFromPlatform, heldOwnKeyRefs, heldPlatformKeyRefs, orgPlatformReadinessFor, orgPlatformReadinessFrom, } from './platform-org.js';
|
|
11
11
|
export type { PlanOwnAuthOptions, PlanOwnFilesOptions, PlanOwnFilesR2Options, PlanOwnJobsOptions, } from './own-diff.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';
|
|
@@ -68,6 +70,7 @@ export { createEdgeAccess } from './edge-access.js';
|
|
|
68
70
|
export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, EdgePlatformAccess, } from './edge-access.js';
|
|
69
71
|
export { createEdgeWorkers } from './edge-workers.js';
|
|
70
72
|
export type { EdgePlatformWorkers, EdgeWorkersOptions, PlatformWorkersPort, } from './edge-workers.js';
|
|
73
|
+
export type { PlatformGithubReleaseHookDeps } from './github-release-hook.js';
|
|
71
74
|
export type { PlatformUptimeAccessFactory, PlatformUptimeAccessPort, PlatformUptimeChatDeps, PlatformUptimeDeps, PlatformWorkersFactory, } from './uptime-worker.js';
|
|
72
75
|
export { createEdgeRateLimits } from './edge-ratelimits.js';
|
|
73
76
|
export type { EdgePlatformRateLimits, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, } from './edge-ratelimits.js';
|
package/dist/plan/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { readLiveResources, readLivePlatformResources } from './live.js';
|
|
2
2
|
export { computePlanDetail, toPlan } from './diff.js';
|
|
3
3
|
export { stepSystemOf } from './step-system.js';
|
|
4
|
-
export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, } from './platform-diff.js';
|
|
4
|
+
export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, GITHUB_RELEASE_HOOK_ORG, GITHUB_RELEASE_HOOK_PATH, GITHUB_RELEASE_WEBHOOK_SECRET_ENV, PLATFORM_GITHUB_ORG_HOOKS_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, } from './platform-diff.js';
|
|
5
5
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesForOwn, agoraHostValuesFromPlatform, heldOwnKeyRefs, heldPlatformKeyRefs, orgPlatformReadinessFor, orgPlatformReadinessFrom, } from './platform-org.js';
|
|
6
6
|
export { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, OWN_AUTH_BLOCKED_KEY, OWN_FILES_BLOCKED_KEY, OWN_FILES_R2_BLOCKED_KEY, OWN_JOBS_BLOCKED_KEY, OWN_MAIL_BLOCKED_KEY, planOwnAuth, planOwnFiles, planOwnFilesR2, planOwnJobs, planOwnMail, } from './own-diff.js';
|
|
7
7
|
export { OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, planOwnSfu } from './own-diff.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';
|
package/dist/plan/live.d.ts
CHANGED
|
@@ -42,7 +42,7 @@ export interface LiveResource {
|
|
|
42
42
|
* see that function's own doc comment for why "always planned" broke
|
|
43
43
|
* `bootstrapPlatform`.
|
|
44
44
|
*/
|
|
45
|
-
system: 'zitadel' | 'coolify' | 'cloudflare' | 'files' | 'ai' | 'cms' | 'jobs' | 'sfu' | 'edge' | 'heartbeats';
|
|
45
|
+
system: 'zitadel' | 'coolify' | 'cloudflare' | 'files' | 'ai' | 'cms' | 'jobs' | 'sfu' | 'edge' | 'heartbeats' | 'github';
|
|
46
46
|
kind: string;
|
|
47
47
|
key: string;
|
|
48
48
|
config: Record<string, unknown>;
|
|
@@ -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
|
}
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -9,7 +9,7 @@ import { entryHasDatabase } from '../worker-app-config.js';
|
|
|
9
9
|
import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
|
|
10
10
|
import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
|
|
11
11
|
import { NetworkMoveBackupMissingError, OrgPostgresMigrationBackupMissingError, SigninDbMoveBackupMissingError, UpgradeBackupMissingError, platformDatabasesForService, requireFreshBackupForOrgPostgresMigration, requireFreshBackupForSigninMove, requireFreshBackupsForMove, requireFreshBackupsForUpgrades, } from './backup-gate.js';
|
|
12
|
-
import { ARCHON_REPO, PRIMARY_SERVER_SCOPE_KEY, archonSubject,
|
|
12
|
+
import { ARCHON_REPO, PRIMARY_SERVER_SCOPE_KEY, archonSubject, blueGreenSubjects, declaresBlueGreen, lbOrigin, } from './blue-green.js';
|
|
13
13
|
import { readLiveBuilds } from './build-record.js';
|
|
14
14
|
import { LEGACY_BACKUP_NAMES } from './company-backups.js';
|
|
15
15
|
import { unconfirmedUpgrades } from './confirm-upgrade.js';
|
|
@@ -1151,10 +1151,13 @@ export class FoundryPlanApply {
|
|
|
1151
1151
|
const primaryUuid = context.recordedIds()[PRIMARY_SERVER_SCOPE_KEY];
|
|
1152
1152
|
if (!store || !this.executor.releaseHost || !primaryUuid)
|
|
1153
1153
|
return;
|
|
1154
|
-
for (const
|
|
1155
|
-
|
|
1156
|
-
|
|
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)}"`)) {
|
|
1157
1159
|
continue;
|
|
1160
|
+
}
|
|
1158
1161
|
const host = this.executor.releaseHost(org, subject, context, () => this.renewLease(fence));
|
|
1159
1162
|
if (!host)
|
|
1160
1163
|
continue;
|
|
@@ -1224,7 +1227,7 @@ export class FoundryPlanApply {
|
|
|
1224
1227
|
async runReleaseTick() {
|
|
1225
1228
|
const results = [];
|
|
1226
1229
|
for (const org of await this.store.list()) {
|
|
1227
|
-
if (!org
|
|
1230
|
+
if (!declaresBlueGreen(org))
|
|
1228
1231
|
continue;
|
|
1229
1232
|
try {
|
|
1230
1233
|
await this.runOrgReleaseTick(org.id);
|
|
@@ -1307,7 +1310,7 @@ export class FoundryPlanApply {
|
|
|
1307
1310
|
const sfuPortsOpen = await this.readSfuPortsOpen(org);
|
|
1308
1311
|
const resumable = await this.ownResumableRefs(org);
|
|
1309
1312
|
// Read only for an org that declares it; a failed read plans none and says so (`planBlueGreen`).
|
|
1310
|
-
const blueGreenServers = org
|
|
1313
|
+
const blueGreenServers = declaresBlueGreen(org) && this.executor.blueGreenServers
|
|
1311
1314
|
? await this.executor.blueGreenServers().catch(() => undefined)
|
|
1312
1315
|
: undefined;
|
|
1313
1316
|
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
|
|
@@ -2137,11 +2140,12 @@ function destroyStepDescriptions(targets) {
|
|
|
2137
2140
|
/** Scope keys of the Coolify apps blue/green made: `bg.<entry>.app.<server>.<part>`, a database sidecar, a connector on another server. */
|
|
2138
2141
|
const BLUE_GREEN_APP_KEY = /^(bg\.[^.:]+\.app\.[^.:]+\.[a-z]+|bgdb\.[^.:]+\.sidecar\.[^.:]+|cloudflare_tunnel_connector_app\.[^.:]+)$/;
|
|
2139
2142
|
const BLUE_GREEN_DATABASE_KEY = /^bg\.[^.:]+\.stagedb$/;
|
|
2140
|
-
/**
|
|
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). */
|
|
2141
2144
|
const BLUE_GREEN_EDGE_KEYS = [
|
|
2142
2145
|
[/^bgdb\.[^.:]+\.access$/, 'bg-access'],
|
|
2143
2146
|
[/^bgdb\.[^.:]+\.(token|undelivered)$/, 'bg-token'],
|
|
2144
2147
|
[/^bgdb\.[^.:]+\.route$/, 'bg-dns'],
|
|
2148
|
+
[/^bg\.[^.:]+\.stagegate$/, 'bg-gate'],
|
|
2145
2149
|
];
|
|
2146
2150
|
/** Every recorded blue/green app (then stage's database), in key order. A destroy target's `key` is the scope key itself. */
|
|
2147
2151
|
function blueGreenDestroyTargets(serviceIds) {
|
|
@@ -2221,6 +2225,11 @@ const STEP_FIELDS = {
|
|
|
2221
2225
|
'cloudflare:worker-app': 'hosting.entries.workerApp',
|
|
2222
2226
|
};
|
|
2223
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
|
+
}
|
|
2224
2233
|
// An entry's ai key (F6a) is keyed `<orgId>:<entry>`; the org's own is `<orgId>`.
|
|
2225
2234
|
if (action.system === 'ai' && action.kind === 'key' && action.key.includes(':')) {
|
|
2226
2235
|
return 'hosting.entries.uses.ai';
|