@wtfalch/foundry 0.17.1 → 0.19.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.d.ts +11 -2
- package/dist/bootstrap/declare.js +20 -11
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/migrations/0045_names_to_edge.sql +107 -0
- package/dist/org.d.ts +17 -17
- package/dist/org.js +48 -58
- package/dist/plan/agora.d.ts +25 -0
- package/dist/plan/agora.js +25 -0
- package/dist/plan/boule.d.ts +8 -0
- package/dist/plan/boule.js +8 -0
- package/dist/plan/diff.d.ts +3 -1
- package/dist/plan/diff.js +115 -52
- package/dist/plan/entry-edge-key.d.ts +79 -0
- package/dist/plan/{entry-names-key.js → entry-edge-key.js} +90 -66
- package/dist/plan/executor.d.ts +87 -42
- package/dist/plan/executor.js +277 -120
- package/dist/plan/index.d.ts +2 -2
- package/dist/plan/index.js +1 -1
- package/dist/plan/live.d.ts +3 -3
- package/dist/plan/login-host.d.ts +15 -11
- package/dist/plan/login-host.js +16 -16
- package/dist/plan/plan-apply.d.ts +6 -5
- package/dist/plan/plan-apply.js +40 -38
- package/dist/plan/platform-diff.d.ts +22 -22
- package/dist/plan/platform-diff.js +45 -45
- package/dist/plan/platform-org-ports.d.ts +1 -0
- package/dist/plan/platform-org-ports.js +3 -0
- package/dist/plan/platform-org.d.ts +8 -8
- package/dist/plan/platform-org.js +7 -7
- package/dist/plan/platform-providers.d.ts +17 -16
- package/dist/plan/platform-providers.js +136 -118
- package/dist/plan/providers-readers.d.ts +34 -11
- package/dist/plan/providers-readers.js +72 -15
- package/dist/plan/step-system.js +1 -1
- package/dist/plan/{edge-worker.d.ts → worker.d.ts} +53 -33
- package/dist/plan/{edge-worker.js → worker.js} +92 -54
- package/dist/platform.d.ts +3 -3
- package/dist/platform.js +1 -1
- package/dist/store/postgres.js +7 -7
- package/dist/store/schema.d.ts +6 -6
- package/dist/store/schema.js +4 -4
- package/package.json +1 -1
- package/dist/plan/entry-names-key.d.ts +0 -73
package/dist/plan/executor.js
CHANGED
|
@@ -2,24 +2,24 @@ import { randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { URL } from 'node:url';
|
|
3
3
|
import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
|
|
4
4
|
import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
|
|
5
|
-
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_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, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
6
|
-
import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
5
|
+
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_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_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, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
6
|
+
import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
7
7
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
8
8
|
import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
9
9
|
import { CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, chatInternalNames, chatMasConfigYaml, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
|
|
10
10
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
11
11
|
import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
|
|
12
12
|
import { DEFAULT_AI_LIMIT_MICROS } from './diff.js';
|
|
13
|
-
import { entryEdgeAppliedScopeKey, entryEdgeRetiredScopeKey, entryEdgeRouteIdScopeKey, entryEdgeRoutePatternScopeKey, entryEdgeRoutePendingScopeKey, entryEdgeRouteZoneScopeKey, entryEdgeUploadedScopeKey, } from './edge-worker.js';
|
|
14
13
|
import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-key.js';
|
|
14
|
+
import { EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
|
|
15
15
|
import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
|
|
16
|
-
import { ENTRY_NAMES_KEY_DELIVERY_ATTEMPTS, NAMES_ENTRY_KEY_DAYS, NAMES_RESERVED_LABELS_APPLIED_SCOPE_KEY, entryNamesMintedAtScopeKey, entryNamesRetiredScopeKey, entryNamesUndeliveredScopeKey, } from './entry-names-key.js';
|
|
17
16
|
import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
18
|
-
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
17
|
+
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
19
18
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
20
19
|
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, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, } from './platform-diff.js';
|
|
21
20
|
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
22
21
|
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
|
|
22
|
+
import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
|
|
23
23
|
/**
|
|
24
24
|
* #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
|
|
25
25
|
* listApps` -- a plain array, with no `loginBaseUri` or completeness signal
|
|
@@ -240,10 +240,14 @@ const CMS_SITE_APP_DOCKERFILE = '/apps/site/Dockerfile';
|
|
|
240
240
|
* followed rather than reopened (#164 "Decide" 1). The agora repo's own
|
|
241
241
|
* Dockerfile (`apps/agora/Dockerfile`) already `EXPOSE`s 8080 and serves
|
|
242
242
|
* from a build built at the repository root, the same one-repo shape
|
|
243
|
-
* `CMS_REPO`'s own two apps use
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
243
|
+
* `CMS_REPO`'s own two apps use. This comment used to claim that needed no
|
|
244
|
+
* `ports_exposes` of its own, on the theory that Coolify's github-app build
|
|
245
|
+
* reads a Dockerfile's `EXPOSE` the same way it does for `cmsSite`'s two
|
|
246
|
+
* apps above -- wrong (#506): Coolify never reads `EXPOSE` for a build it
|
|
247
|
+
* runs itself, so without an explicit `agora.ts`'s `AGORA_APP_PORTS_EXPOSES`,
|
|
248
|
+
* Traefik is left routing to a port nothing listens on, and a company's
|
|
249
|
+
* Agora answers 502 behind the tunnel. Seen live on Company Included,
|
|
250
|
+
* 2026-10-05.
|
|
247
251
|
*/
|
|
248
252
|
const AGORA_REPO = 'wtfalch/agora';
|
|
249
253
|
const AGORA_APP_DOCKERFILE = '/apps/agora/Dockerfile';
|
|
@@ -508,11 +512,21 @@ const CALLS_MAS_DB_SCOPE_KEY = 'coolify_calls_mas_database';
|
|
|
508
512
|
* Synapse refuses to start against a database whose `LC_COLLATE`/`LC_CTYPE`
|
|
509
513
|
* is not `C` (https://element-hq.github.io/synapse/latest/postgres.html);
|
|
510
514
|
* Coolify's official postgres image otherwise creates one with `en_US.utf8`.
|
|
511
|
-
* `
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
515
|
+
* `createPostgresDatabase` forwards this to Coolify's own
|
|
516
|
+
* `postgres_initdb_args` field -- but Coolify stores that field and reads it
|
|
517
|
+
* back unchanged without ever handing it to `initdb`: its Postgres start
|
|
518
|
+
* action builds the container's env from `postgres_user`/`postgres_password`/
|
|
519
|
+
* `postgres_db` and runtime envs only
|
|
520
|
+
* (https://github.com/coollabsio/coolify/blob/main/app/Actions/Database/StartPostgresql.php).
|
|
521
|
+
* A database created this way still comes up `en_US.utf8` and crash-loops
|
|
522
|
+
* every Synapse app that reads it; live evidence and the real fix (not yet
|
|
523
|
+
* chosen) are tracked in #494. This constant and the field it feeds stay in
|
|
524
|
+
* place, inert, until that lands -- collation cannot be migrated onto an
|
|
525
|
+
* existing database under any of the candidate fixes either. MAS has no
|
|
526
|
+
* such requirement, so this is passed for the Synapse database only. Not
|
|
527
|
+
* using Synapse's own `allow_unsafe_locale` escape hatch regardless of
|
|
528
|
+
* which fix lands: that trades a loud startup refusal for silent index
|
|
529
|
+
* corruption after a future glibc upgrade.
|
|
516
530
|
*/
|
|
517
531
|
const CALLS_SYNAPSE_DB_INITDB_ARGS = '--locale=C --encoding=UTF8';
|
|
518
532
|
/**
|
|
@@ -655,7 +669,7 @@ export class FoundryStepExecutor {
|
|
|
655
669
|
case 'cloudflare:tunnel':
|
|
656
670
|
return this.cloudflareTunnel(action, org, context);
|
|
657
671
|
case 'cloudflare:zone':
|
|
658
|
-
return this.cloudflareZone(action, org);
|
|
672
|
+
return this.cloudflareZone(action, org, context);
|
|
659
673
|
case 'cloudflare:access':
|
|
660
674
|
return this.cloudflareAccess(action, org);
|
|
661
675
|
case 'cloudflare:ratelimit':
|
|
@@ -678,10 +692,10 @@ export class FoundryStepExecutor {
|
|
|
678
692
|
return action.key.includes(':')
|
|
679
693
|
? this.aiEntryKey(action, org, context)
|
|
680
694
|
: this.aiKey(action, org, context);
|
|
681
|
-
case '
|
|
682
|
-
return this.
|
|
683
|
-
case '
|
|
684
|
-
return this.
|
|
695
|
+
case 'edge:key':
|
|
696
|
+
return this.edgeEntryKey(action, org, context);
|
|
697
|
+
case 'edge:reserved-labels':
|
|
698
|
+
return this.edgeReservedLabels(action, org, context);
|
|
685
699
|
case 'coolify:mail':
|
|
686
700
|
return this.coolifyMail(action, org, context);
|
|
687
701
|
case 'coolify:mail-copy':
|
|
@@ -850,10 +864,10 @@ export class FoundryStepExecutor {
|
|
|
850
864
|
return { resourceId: created.id };
|
|
851
865
|
}
|
|
852
866
|
/**
|
|
853
|
-
* `plan/diff.ts`'s `
|
|
854
|
-
* `
|
|
867
|
+
* `plan/diff.ts`'s `planWorkers` (#318, F8): deploys one entry's
|
|
868
|
+
* `worker` Worker and routes it on the org's own zone. Throws by name
|
|
855
869
|
* without a {@link WorkersPort}, the same `jobsRegistration` shape --
|
|
856
|
-
* `
|
|
870
|
+
* `planWorkers` reports the step blocked instead while the host has
|
|
857
871
|
* none, so this only fires against a stale plan. The route is checked
|
|
858
872
|
* before upload: a foreign route already on the pattern is refused, never
|
|
859
873
|
* adopted or replaced (`cloudflareAccess`'s own precedent for a foreign
|
|
@@ -879,24 +893,25 @@ export class FoundryStepExecutor {
|
|
|
879
893
|
if (desired.mode === 'retire') {
|
|
880
894
|
const recorded = context.recordedIds();
|
|
881
895
|
const mainId = recorded[`cloudflare:worker:${action.key}`];
|
|
882
|
-
const scriptName = mainId || recorded[
|
|
883
|
-
await this.
|
|
884
|
-
context.recordScopeId(
|
|
885
|
-
context.recordScopeId(
|
|
886
|
-
context.recordScopeId(
|
|
887
|
-
context.recordScopeId(
|
|
888
|
-
context.recordScopeId(
|
|
889
|
-
context.recordScopeId(
|
|
896
|
+
const scriptName = mainId || recorded[entryWorkerUploadedScopeKey(entryKey)];
|
|
897
|
+
await this.teardownWorker(entryKey, scriptName, recorded, workers, at);
|
|
898
|
+
context.recordScopeId(entryWorkerRouteIdScopeKey(entryKey), '');
|
|
899
|
+
context.recordScopeId(entryWorkerRouteZoneScopeKey(entryKey), '');
|
|
900
|
+
context.recordScopeId(entryWorkerRoutePatternScopeKey(entryKey), '');
|
|
901
|
+
context.recordScopeId(entryWorkerRoutePendingScopeKey(entryKey), '');
|
|
902
|
+
context.recordScopeId(entryWorkerExclusionsScopeKey(entryKey), '');
|
|
903
|
+
context.recordScopeId(entryWorkerAppliedScopeKey(entryKey), '');
|
|
904
|
+
context.recordScopeId(entryWorkerUploadedScopeKey(entryKey), '');
|
|
890
905
|
// `runSteps` only ever writes the main recorded id from a truthy
|
|
891
906
|
// `resourceId` (`plan-apply.ts`); this step returns none, so without
|
|
892
907
|
// this marker the planner's "gone" scan would retire the same,
|
|
893
908
|
// never-cleared id forever.
|
|
894
909
|
if (mainId)
|
|
895
|
-
context.recordScopeId(
|
|
910
|
+
context.recordScopeId(entryWorkerRetiredScopeKey(entryKey), mainId);
|
|
896
911
|
return {};
|
|
897
912
|
}
|
|
898
913
|
if (!org.domain) {
|
|
899
|
-
throw new Error(`${at}:
|
|
914
|
+
throw new Error(`${at}: worker needs org.domain, and validateHosting already refuses it without one`);
|
|
900
915
|
}
|
|
901
916
|
const zone = await this.ports.cloudflare.findZoneByApex(org.domain);
|
|
902
917
|
if (!zone) {
|
|
@@ -919,8 +934,8 @@ export class FoundryStepExecutor {
|
|
|
919
934
|
// the same deterministic name) is foundry's own script to overwrite,
|
|
920
935
|
// never a stranger's.
|
|
921
936
|
const recordedBeforeUpload = context.recordedIds();
|
|
922
|
-
const everUploadedByFoundry = Boolean(recordedBeforeUpload[
|
|
923
|
-
recordedBeforeUpload[
|
|
937
|
+
const everUploadedByFoundry = Boolean(recordedBeforeUpload[entryWorkerAppliedScopeKey(entryKey)] ||
|
|
938
|
+
recordedBeforeUpload[entryWorkerUploadedScopeKey(entryKey)] ||
|
|
924
939
|
recordedBeforeUpload[`cloudflare:worker:${action.key}`]);
|
|
925
940
|
if (!everUploadedByFoundry && (await workers.scriptExists(name))) {
|
|
926
941
|
throw new Error(`${at}: a Worker script named '${name}' already exists and foundry never created it; refuses to overwrite it`);
|
|
@@ -931,8 +946,8 @@ export class FoundryStepExecutor {
|
|
|
931
946
|
// the `scriptExists` guard above. The retired marker clears here too,
|
|
932
947
|
// not only on success: once a redeploy has touched the script, a later
|
|
933
948
|
// undeclare must retire it even if this apply never finishes.
|
|
934
|
-
context.recordScopeId(
|
|
935
|
-
context.recordScopeId(
|
|
949
|
+
context.recordScopeId(entryWorkerUploadedScopeKey(entryKey), name);
|
|
950
|
+
context.recordScopeId(entryWorkerRetiredScopeKey(entryKey), '');
|
|
936
951
|
await context.persistScopeIds();
|
|
937
952
|
await workers.uploadScript({
|
|
938
953
|
name,
|
|
@@ -941,9 +956,12 @@ export class FoundryStepExecutor {
|
|
|
941
956
|
compatibilityDate: action.desired.compatibilityDate,
|
|
942
957
|
bindings: bindings.length > 0 ? bindings : undefined,
|
|
943
958
|
});
|
|
959
|
+
// Before the wildcard route exists, so a hostname foundry serves is never
|
|
960
|
+
// caught by the Worker, not even briefly.
|
|
961
|
+
await this.syncWorkerExclusions(entryKey, zone.id, org.domain, desired.exclusions ?? [], workers, context, at);
|
|
944
962
|
const recorded = context.recordedIds();
|
|
945
|
-
const previousRouteId = recorded[
|
|
946
|
-
const previousPending = recorded[
|
|
963
|
+
const previousRouteId = recorded[entryWorkerRouteIdScopeKey(entryKey)];
|
|
964
|
+
const previousPending = recorded[entryWorkerRoutePendingScopeKey(entryKey)];
|
|
947
965
|
const zoneRecord = { id: zone.id, apex: org.domain };
|
|
948
966
|
let routeId = existingRoute?.id;
|
|
949
967
|
if (!routeId) {
|
|
@@ -951,7 +969,7 @@ export class FoundryStepExecutor {
|
|
|
951
969
|
// never got to record is still found (`findRoute`) and deleted on
|
|
952
970
|
// retire or destroy (#318 re-review).
|
|
953
971
|
const pending = { ...zoneRecord, pattern: route, script: name };
|
|
954
|
-
context.recordScopeId(
|
|
972
|
+
context.recordScopeId(entryWorkerRoutePendingScopeKey(entryKey), JSON.stringify(pending));
|
|
955
973
|
await context.persistScopeIds();
|
|
956
974
|
const created = await workers.createRoute(zone.id, route, name);
|
|
957
975
|
routeId = created.id;
|
|
@@ -961,30 +979,75 @@ export class FoundryStepExecutor {
|
|
|
961
979
|
// own zone rather than left live beside the new one. A pending route
|
|
962
980
|
// from an earlier, interrupted apply under a different pattern likewise.
|
|
963
981
|
if (previousRouteId && previousRouteId !== routeId) {
|
|
964
|
-
const previousZone = await this.
|
|
982
|
+
const previousZone = await this.liveWorkerZone(recorded[entryWorkerRouteZoneScopeKey(entryKey)], at);
|
|
965
983
|
if (previousZone)
|
|
966
984
|
await workers.deleteRoute(previousZone, previousRouteId); // Idempotent.
|
|
967
985
|
}
|
|
968
986
|
if (previousPending) {
|
|
969
|
-
await this.
|
|
987
|
+
await this.deletePendingWorkerRoute(previousPending, routeId, workers, at);
|
|
970
988
|
}
|
|
971
|
-
context.recordScopeId(
|
|
972
|
-
context.recordScopeId(
|
|
973
|
-
context.recordScopeId(
|
|
974
|
-
context.recordScopeId(
|
|
975
|
-
context.recordScopeId(
|
|
989
|
+
context.recordScopeId(entryWorkerRouteIdScopeKey(entryKey), routeId);
|
|
990
|
+
context.recordScopeId(entryWorkerRouteZoneScopeKey(entryKey), JSON.stringify(zoneRecord));
|
|
991
|
+
context.recordScopeId(entryWorkerRoutePatternScopeKey(entryKey), route);
|
|
992
|
+
context.recordScopeId(entryWorkerRoutePendingScopeKey(entryKey), '');
|
|
993
|
+
context.recordScopeId(entryWorkerAppliedScopeKey(entryKey), JSON.stringify({
|
|
976
994
|
name,
|
|
977
995
|
artifactUrl: desired.artifactUrl,
|
|
978
996
|
artifactSha256: desired.artifactSha256,
|
|
979
997
|
route,
|
|
980
998
|
bindings: desired.bindings,
|
|
999
|
+
exclusions: desired.exclusions,
|
|
981
1000
|
}));
|
|
982
|
-
context.recordScopeId(
|
|
1001
|
+
context.recordScopeId(entryWorkerUploadedScopeKey(entryKey), '');
|
|
983
1002
|
// The retired marker was already cleared before the upload above: a
|
|
984
1003
|
// redeclare reuses this entry's deterministic script name, so a marker
|
|
985
1004
|
// left equal to the (identical) main id would read as already retired.
|
|
986
1005
|
return { resourceId: name };
|
|
987
1006
|
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Makes the no-Worker routes recorded for an entry match `patterns`: deletes
|
|
1009
|
+
* the ones no longer wanted (all of them if the zone changed), creates the
|
|
1010
|
+
* missing ones without a script. A route already on a pattern that is bound
|
|
1011
|
+
* to a Worker is foreign and refused; one with no script is adopted. Records
|
|
1012
|
+
* and persists after each change.
|
|
1013
|
+
*/
|
|
1014
|
+
async syncWorkerExclusions(entryKey, zoneId, apex, patterns, workers, context, at) {
|
|
1015
|
+
const key = entryWorkerExclusionsScopeKey(entryKey);
|
|
1016
|
+
const raw = context.recordedIds()[key];
|
|
1017
|
+
let state = raw
|
|
1018
|
+
? JSON.parse(raw)
|
|
1019
|
+
: { id: zoneId, apex, routes: {} };
|
|
1020
|
+
const save = async () => {
|
|
1021
|
+
context.recordScopeId(key, Object.keys(state.routes).length > 0 ? JSON.stringify(state) : '');
|
|
1022
|
+
await context.persistScopeIds();
|
|
1023
|
+
};
|
|
1024
|
+
if (state.id !== zoneId) {
|
|
1025
|
+
const oldZone = await this.liveWorkerZone(JSON.stringify({ id: state.id, apex: state.apex }), at);
|
|
1026
|
+
if (oldZone) {
|
|
1027
|
+
for (const id of Object.values(state.routes))
|
|
1028
|
+
await workers.deleteRoute(oldZone, id);
|
|
1029
|
+
}
|
|
1030
|
+
state = { id: zoneId, apex, routes: {} };
|
|
1031
|
+
await save();
|
|
1032
|
+
}
|
|
1033
|
+
for (const [pattern, id] of Object.entries(state.routes)) {
|
|
1034
|
+
if (patterns.includes(pattern))
|
|
1035
|
+
continue;
|
|
1036
|
+
await workers.deleteRoute(zoneId, id); // Idempotent.
|
|
1037
|
+
delete state.routes[pattern];
|
|
1038
|
+
await save();
|
|
1039
|
+
}
|
|
1040
|
+
for (const pattern of patterns) {
|
|
1041
|
+
if (state.routes[pattern])
|
|
1042
|
+
continue;
|
|
1043
|
+
const found = await workers.findRoute(zoneId, pattern);
|
|
1044
|
+
if (found?.script) {
|
|
1045
|
+
throw new Error(`${at}: route '${pattern}' is already bound to Worker '${found.script}'; foundry does not replace it`);
|
|
1046
|
+
}
|
|
1047
|
+
state.routes[pattern] = found ? found.id : (await workers.createRoute(zoneId, pattern)).id;
|
|
1048
|
+
await save();
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
988
1051
|
/**
|
|
989
1052
|
* The zone id a recorded route lives in, or `undefined` only when that
|
|
990
1053
|
* zone is *positively* gone -- its apex resolves to no zone, or to a
|
|
@@ -998,7 +1061,7 @@ export class FoundryStepExecutor {
|
|
|
998
1061
|
* live on the old one, the exact bug a recorded-per-route zone exists to
|
|
999
1062
|
* avoid (#318 re-review).
|
|
1000
1063
|
*/
|
|
1001
|
-
async
|
|
1064
|
+
async liveWorkerZone(recordedZone, at) {
|
|
1002
1065
|
if (!recordedZone) {
|
|
1003
1066
|
throw new Error(`${at}: a route is recorded but its zone is not, so foundry cannot tell which zone holds it; refuses to forget it`);
|
|
1004
1067
|
}
|
|
@@ -1007,9 +1070,9 @@ export class FoundryStepExecutor {
|
|
|
1007
1070
|
return zone && zone.id === id ? id : undefined;
|
|
1008
1071
|
}
|
|
1009
1072
|
/** Deletes the route an interrupted apply was creating, if `findRoute` shows it exists, points at that apply's script, and is not `keepRouteId`. */
|
|
1010
|
-
async
|
|
1073
|
+
async deletePendingWorkerRoute(pendingRaw, keepRouteId, workers, at) {
|
|
1011
1074
|
const pending = JSON.parse(pendingRaw);
|
|
1012
|
-
const zoneId = await this.
|
|
1075
|
+
const zoneId = await this.liveWorkerZone(JSON.stringify({ id: pending.id, apex: pending.apex }), at);
|
|
1013
1076
|
if (!zoneId)
|
|
1014
1077
|
return;
|
|
1015
1078
|
const found = await workers.findRoute(zoneId, pending.pattern);
|
|
@@ -1022,16 +1085,25 @@ export class FoundryStepExecutor {
|
|
|
1022
1085
|
* zone, then any route an interrupted apply created but never recorded,
|
|
1023
1086
|
* then the script. Records nothing -- the caller does.
|
|
1024
1087
|
*/
|
|
1025
|
-
async
|
|
1026
|
-
const routeId = recorded[
|
|
1088
|
+
async teardownWorker(entryKey, scriptName, recorded, workers, at) {
|
|
1089
|
+
const routeId = recorded[entryWorkerRouteIdScopeKey(entryKey)];
|
|
1027
1090
|
if (routeId) {
|
|
1028
|
-
const zoneId = await this.
|
|
1091
|
+
const zoneId = await this.liveWorkerZone(recorded[entryWorkerRouteZoneScopeKey(entryKey)], at);
|
|
1029
1092
|
if (zoneId)
|
|
1030
1093
|
await workers.deleteRoute(zoneId, routeId); // Idempotent: a no-op if already gone.
|
|
1031
1094
|
}
|
|
1032
|
-
const pending = recorded[
|
|
1095
|
+
const pending = recorded[entryWorkerRoutePendingScopeKey(entryKey)];
|
|
1033
1096
|
if (pending)
|
|
1034
|
-
await this.
|
|
1097
|
+
await this.deletePendingWorkerRoute(pending, undefined, workers, at);
|
|
1098
|
+
const exclusions = recorded[entryWorkerExclusionsScopeKey(entryKey)];
|
|
1099
|
+
if (exclusions) {
|
|
1100
|
+
const state = JSON.parse(exclusions);
|
|
1101
|
+
const zoneId = await this.liveWorkerZone(JSON.stringify({ id: state.id, apex: state.apex }), at);
|
|
1102
|
+
if (zoneId) {
|
|
1103
|
+
for (const id of Object.values(state.routes))
|
|
1104
|
+
await workers.deleteRoute(zoneId, id);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1035
1107
|
if (scriptName)
|
|
1036
1108
|
await workers.deleteScript(scriptName); // Idempotent: a no-op if already gone.
|
|
1037
1109
|
}
|
|
@@ -1164,11 +1236,16 @@ export class FoundryStepExecutor {
|
|
|
1164
1236
|
throw new Error(`zitadel:login-host for ${org.slug}: the loginHostBindings port is not wired`);
|
|
1165
1237
|
}
|
|
1166
1238
|
const host = action.desired.host;
|
|
1167
|
-
|
|
1168
|
-
//
|
|
1169
|
-
|
|
1239
|
+
// The platform's own shared sign-in host: already trusted and routed by
|
|
1240
|
+
// the shared instance, so it gets no trusted domain or binding here.
|
|
1241
|
+
const shared = action.desired.shared === true;
|
|
1170
1242
|
const clientIds = loginHostClientIds(recorded);
|
|
1171
|
-
|
|
1243
|
+
if (!shared) {
|
|
1244
|
+
await this.ports.zitadel.ensureTrustedDomain(host);
|
|
1245
|
+
// zitadel-login's own routing table, written before any app is repointed
|
|
1246
|
+
// so no app ever points at a host with no binding.
|
|
1247
|
+
await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
|
|
1248
|
+
}
|
|
1172
1249
|
// Only apps whose client id the binding now holds: any other app in the
|
|
1173
1250
|
// project would lose sign-in. The rest move on a later run, once the
|
|
1174
1251
|
// recorded client ids differ from the binding and replan this step.
|
|
@@ -1269,7 +1346,10 @@ export class FoundryStepExecutor {
|
|
|
1269
1346
|
// the binding and replans the step next run; a nonempty pending list
|
|
1270
1347
|
// does too (`planLoginHost`), independent of the host comparison that
|
|
1271
1348
|
// normally alone decides it.
|
|
1272
|
-
|
|
1349
|
+
if (shared)
|
|
1350
|
+
context.recordScopeId(LOGIN_HOST_SHARED_SCOPE_KEY, '1');
|
|
1351
|
+
else
|
|
1352
|
+
context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
|
|
1273
1353
|
context.recordScopeId(LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, pendingRemovalState(stillPending));
|
|
1274
1354
|
return { resourceId: host };
|
|
1275
1355
|
}
|
|
@@ -3535,11 +3615,25 @@ export class FoundryStepExecutor {
|
|
|
3535
3615
|
* actually differs, or whenever the last restart this step confirmed
|
|
3536
3616
|
* ({@link AGORA_ACTIVATED_ENV_SCOPE_KEY}) is not this exact env (an org
|
|
3537
3617
|
* applied before this existed, whose live env already matched desired
|
|
3538
|
-
* with nothing ever confirmed running it).
|
|
3539
|
-
*
|
|
3540
|
-
*
|
|
3541
|
-
*
|
|
3542
|
-
*
|
|
3618
|
+
* with nothing ever confirmed running it). The activation marker is
|
|
3619
|
+
* recorded last, after the restart (if any) returns without throwing, so a
|
|
3620
|
+
* failed restart leaves it stale and the next plan replans this step even
|
|
3621
|
+
* though Coolify's own env store already matches desired.
|
|
3622
|
+
*
|
|
3623
|
+
* #504: a brand new app is never restarted -- but it is deployed, once
|
|
3624
|
+
* ({@link AGORA_DEPLOYED_SCOPE_KEY}). `createGithubAppApplication` never
|
|
3625
|
+
* sets `instant_deploy`, and Coolify defaults it to false, so without an
|
|
3626
|
+
* explicit deploy the app sat created but never built or started.
|
|
3627
|
+
*
|
|
3628
|
+
* #506: `ports_exposes: AGORA_APP_PORTS_EXPOSES` goes on the create call
|
|
3629
|
+
* itself, so Traefik has a port to route to from the moment the app
|
|
3630
|
+
* exists. An app `findOrCreateCallsApp` instead *found* may predate this
|
|
3631
|
+
* fix and carry no `ports_exposes` at all (or the wrong one), so its live
|
|
3632
|
+
* value is read and, when it differs, patched -- and, since the container
|
|
3633
|
+
* listens on `ports_exposes` only at its next deploy (the same rule
|
|
3634
|
+
* `coolifyApp`'s own `settingsNeedDeploy` follows for a port change
|
|
3635
|
+
* there), redeployed even on a run that already has its one #504 deploy
|
|
3636
|
+
* behind it.
|
|
3543
3637
|
*/
|
|
3544
3638
|
async coolifyAgora(action, org, context) {
|
|
3545
3639
|
// The host values the plan read, carried in the step: never read again
|
|
@@ -3569,9 +3663,22 @@ export class FoundryStepExecutor {
|
|
|
3569
3663
|
name,
|
|
3570
3664
|
domains: coolifyDomains([agoraHostnameFor(org)]),
|
|
3571
3665
|
dockerfile_location: AGORA_APP_DOCKERFILE,
|
|
3666
|
+
ports_exposes: AGORA_APP_PORTS_EXPOSES,
|
|
3572
3667
|
}));
|
|
3573
3668
|
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
3574
3669
|
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
3670
|
+
// #506: an app found rather than just created may predate passing
|
|
3671
|
+
// `ports_exposes` at create -- Traefik then routes to a port nothing
|
|
3672
|
+
// listens on and Agora answers 502 behind the tunnel. A freshly created
|
|
3673
|
+
// app already got the right port above, so only a found one is checked.
|
|
3674
|
+
const portDiffers = app.existed &&
|
|
3675
|
+
(await this.ports.coolify.getApplication(app.appId)).ports_exposes !==
|
|
3676
|
+
AGORA_APP_PORTS_EXPOSES;
|
|
3677
|
+
if (portDiffers) {
|
|
3678
|
+
await this.ports.coolify.updateApplication(app.appId, {
|
|
3679
|
+
ports_exposes: AGORA_APP_PORTS_EXPOSES,
|
|
3680
|
+
});
|
|
3681
|
+
}
|
|
3575
3682
|
// #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
|
|
3576
3683
|
// Coolify's own env store matching `env`, which is why that store alone
|
|
3577
3684
|
// can no longer tell a changed value from one already live.
|
|
@@ -3582,7 +3689,25 @@ export class FoundryStepExecutor {
|
|
|
3582
3689
|
await this.ports.coolify.bulkUpsertEnvs(app.appId, Object.entries(env).map(([key, value]) => ({ key, value })));
|
|
3583
3690
|
const revision = agoraEnvRevision(env);
|
|
3584
3691
|
const alreadyActivated = context.recordedIds()[AGORA_ACTIVATED_ENV_SCOPE_KEY] === revision;
|
|
3585
|
-
|
|
3692
|
+
// #504: `createGithubAppApplication` never sets `instant_deploy`, and
|
|
3693
|
+
// Coolify defaults it to false -- a brand new app, and one found by name
|
|
3694
|
+
// that a previous apply created but crashed before deploying, both sat
|
|
3695
|
+
// built but never started. `restart` cannot fix either: it starts a
|
|
3696
|
+
// container that was never built. Deployed once this executor has
|
|
3697
|
+
// confirmed (the marker never changes value once set), never again -- a
|
|
3698
|
+
// live app is only ever restarted from here on, unless #506's port fix
|
|
3699
|
+
// below needs its own deploy.
|
|
3700
|
+
if (!context.recordedIds()[AGORA_DEPLOYED_SCOPE_KEY]) {
|
|
3701
|
+
await this.ports.coolify.deploy(app.appId);
|
|
3702
|
+
context.recordScopeId(AGORA_DEPLOYED_SCOPE_KEY, '1');
|
|
3703
|
+
}
|
|
3704
|
+
else if (portDiffers) {
|
|
3705
|
+
// #506: a port fix needs its own full deploy -- env drift alone only
|
|
3706
|
+
// needs the cheaper restart, and this deploy already carries this
|
|
3707
|
+
// run's env (pushed above), so the two never both run.
|
|
3708
|
+
await this.ports.coolify.deploy(app.appId);
|
|
3709
|
+
}
|
|
3710
|
+
else if (app.existed && (envChanged || !alreadyActivated)) {
|
|
3586
3711
|
await this.ports.coolify.restart(app.appId);
|
|
3587
3712
|
}
|
|
3588
3713
|
context.recordScopeId(AGORA_ACTIVATED_ENV_SCOPE_KEY, revision);
|
|
@@ -3611,6 +3736,10 @@ export class FoundryStepExecutor {
|
|
|
3611
3736
|
*
|
|
3612
3737
|
* `AUTH_APP_KEY` is minted once here; its broker entry is the next step
|
|
3613
3738
|
* (`bouleBrokerEntry`).
|
|
3739
|
+
*
|
|
3740
|
+
* #504: a brand new app is never restarted -- but it is deployed, once
|
|
3741
|
+
* ({@link BOULE_DEPLOYED_SCOPE_KEY}), the same reason and the same marker
|
|
3742
|
+
* shape {@link coolifyAgora}'s own doc comment explains.
|
|
3614
3743
|
*/
|
|
3615
3744
|
async coolifyBoule(action, org, context) {
|
|
3616
3745
|
// The issuer and Archon URL the plan read, carried in the step: the env
|
|
@@ -3662,7 +3791,17 @@ export class FoundryStepExecutor {
|
|
|
3662
3791
|
if (!dbPush.pushed) {
|
|
3663
3792
|
throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
3664
3793
|
}
|
|
3665
|
-
|
|
3794
|
+
// #504: see `coolifyAgora`'s own comment just above -- `createGithubAppApplication`
|
|
3795
|
+
// never sets `instant_deploy`, so a brand new app (or one found by name
|
|
3796
|
+
// that a previous apply created but crashed before deploying) sat built
|
|
3797
|
+
// but never started. Deployed once this executor has confirmed, never
|
|
3798
|
+
// again -- a live app is only ever restarted from here on.
|
|
3799
|
+
if (!context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]) {
|
|
3800
|
+
await this.ports.coolify.deploy(app.appId);
|
|
3801
|
+
context.recordScopeId(BOULE_DEPLOYED_SCOPE_KEY, '1');
|
|
3802
|
+
}
|
|
3803
|
+
else if (app.existed &&
|
|
3804
|
+
(envChanged || cookieSecret !== null || appKey !== null || dbPush.changed)) {
|
|
3666
3805
|
await this.ports.coolify.restart(app.appId);
|
|
3667
3806
|
}
|
|
3668
3807
|
return { resourceId: app.appId };
|
|
@@ -4151,8 +4290,17 @@ export class FoundryStepExecutor {
|
|
|
4151
4290
|
* shared-hostname collision this ticket exists to refuse. An org re-running
|
|
4152
4291
|
* its own already-recorded zone is unaffected: the store then names this
|
|
4153
4292
|
* same org as the owner.
|
|
4293
|
+
*
|
|
4294
|
+
* #510: also records the zone's own id under the flat `cloudflare_zone`
|
|
4295
|
+
* scope key, alongside the `cloudflare:zone:<domain>` resource id `runSteps`
|
|
4296
|
+
* already records from this method's return value. Without it, nothing
|
|
4297
|
+
* ever gives `ProvidersCloudflareReader` a zone id to resolve this org's
|
|
4298
|
+
* scope with (`providers-readers.ts`'s `storeScopeResolver` doc comment),
|
|
4299
|
+
* so that reader's `listResources` always returned `[]` and `diff.ts`'s
|
|
4300
|
+
* `planZone` re-proposed "provision a Cloudflare zone" on every apply, even
|
|
4301
|
+
* once the zone was live.
|
|
4154
4302
|
*/
|
|
4155
|
-
async cloudflareZone(action, org) {
|
|
4303
|
+
async cloudflareZone(action, org, context) {
|
|
4156
4304
|
const domain = action.key;
|
|
4157
4305
|
const cf = this.ports.cloudflare;
|
|
4158
4306
|
const existing = await cf.findZoneByApex(domain);
|
|
@@ -4161,9 +4309,11 @@ export class FoundryStepExecutor {
|
|
|
4161
4309
|
if (owner !== null && owner !== org.id) {
|
|
4162
4310
|
throw new Error(`cloudflare:zone for ${org.slug}: zone ${existing.id} (${domain}) is already recorded under org ${owner}; foundry does not adopt it`);
|
|
4163
4311
|
}
|
|
4312
|
+
context.recordScopeId('cloudflare_zone', existing.id);
|
|
4164
4313
|
return { resourceId: existing.id };
|
|
4165
4314
|
}
|
|
4166
4315
|
const created = await cf.createZone(domain);
|
|
4316
|
+
context.recordScopeId('cloudflare_zone', created.id);
|
|
4167
4317
|
return { resourceId: created.id };
|
|
4168
4318
|
}
|
|
4169
4319
|
// ---- mail -----------------------------------------------------------
|
|
@@ -4816,24 +4966,24 @@ export class FoundryStepExecutor {
|
|
|
4816
4966
|
context.recordScopeId(listKey, JSON.stringify(ids));
|
|
4817
4967
|
}
|
|
4818
4968
|
/**
|
|
4819
|
-
* F7 (#317): mints one entry's
|
|
4820
|
-
* takes no separate minter key id, unlike ai/files), writes `
|
|
4821
|
-
* and `
|
|
4822
|
-
* the key when `uses.
|
|
4969
|
+
* F7 (#317): mints one entry's edge key directly below the org (edge
|
|
4970
|
+
* takes no separate minter key id, unlike ai/files), writes `EDGE_URL`
|
|
4971
|
+
* and `EDGE_API_KEY` to the entry's app env, then restarts it. Retires
|
|
4972
|
+
* the key when `uses.edge` is removed. Follows the "one mint" rule
|
|
4823
4973
|
* (CLAUDE.md "Platform steps"): the id is recorded as undelivered and
|
|
4824
4974
|
* persisted before delivery is attempted, so a crash or a failed write
|
|
4825
|
-
* never leads to a second mint -- `
|
|
4975
|
+
* never leads to a second mint -- `planEntryEdgeKeys` blocks instead.
|
|
4826
4976
|
*/
|
|
4827
|
-
async
|
|
4977
|
+
async edgeEntryKey(action, org, context) {
|
|
4828
4978
|
const entryKey = action.key.slice(org.id.length + 1);
|
|
4829
|
-
const at = `
|
|
4830
|
-
const
|
|
4831
|
-
if (!
|
|
4832
|
-
throw new Error(`${at}: this executor was built without
|
|
4979
|
+
const at = `edge:key for ${org.slug} entry '${entryKey}'`;
|
|
4980
|
+
const edge = this.ports.edge;
|
|
4981
|
+
if (!edge)
|
|
4982
|
+
throw new Error(`${at}: this executor was built without an EdgePort (#317)`);
|
|
4833
4983
|
const desired = action.desired;
|
|
4834
4984
|
if (desired.mode === 'retire') {
|
|
4835
4985
|
const recorded = context.recordedIds();
|
|
4836
|
-
const mainId = recorded[`
|
|
4986
|
+
const mainId = recorded[`edge:key:${action.key}`];
|
|
4837
4987
|
// A key minted but never delivered never gets a main id (the mint
|
|
4838
4988
|
// step throws before `runSteps` sees a `resourceId`), so it can
|
|
4839
4989
|
// coexist with a separate, already-delivered id from an earlier mint
|
|
@@ -4843,10 +4993,10 @@ export class FoundryStepExecutor {
|
|
|
4843
4993
|
// here always picked A (truthy), leaving B live for up to 365 days
|
|
4844
4994
|
// with nothing recorded (#317 re-review) -- both ids are revoked,
|
|
4845
4995
|
// independently, whenever both are present.
|
|
4846
|
-
const undeliveredId = recorded[
|
|
4996
|
+
const undeliveredId = recorded[entryEdgeUndeliveredScopeKey(entryKey)];
|
|
4847
4997
|
const idsToRevoke = [...new Set([mainId, undeliveredId].filter((id) => !!id))];
|
|
4848
4998
|
const keyId = mainId || undeliveredId;
|
|
4849
|
-
// Revoke first: the key going dead in
|
|
4999
|
+
// Revoke first: the key going dead in edge is what retire is for,
|
|
4850
5000
|
// and the one thing that must still happen even when the entry's app
|
|
4851
5001
|
// is itself already gone. Only once that has succeeded (or 404'd as
|
|
4852
5002
|
// already gone) is the env cleanup attempted, itself tolerant of a
|
|
@@ -4856,7 +5006,7 @@ export class FoundryStepExecutor {
|
|
|
4856
5006
|
// stranding a live key on every retry (#317 review).
|
|
4857
5007
|
for (const id of idsToRevoke) {
|
|
4858
5008
|
try {
|
|
4859
|
-
await
|
|
5009
|
+
await edge.revokeKey(org.id, id);
|
|
4860
5010
|
}
|
|
4861
5011
|
catch (error) {
|
|
4862
5012
|
// A 404 means it is already gone -- recorded as retired below,
|
|
@@ -4871,7 +5021,7 @@ export class FoundryStepExecutor {
|
|
|
4871
5021
|
const appId = recorded[`coolify:app:${action.key}`];
|
|
4872
5022
|
if (appId) {
|
|
4873
5023
|
// A revoked key's env values are dead, not merely stale -- remove
|
|
4874
|
-
// them so nothing reads a credential
|
|
5024
|
+
// them so nothing reads a credential edge has already revoked. A
|
|
4875
5025
|
// 404 on either call means that app or that env is already gone:
|
|
4876
5026
|
// nothing to clean up there, and the key is already revoked above,
|
|
4877
5027
|
// so it is not a reason to fail the step. Each delete tolerates its
|
|
@@ -4887,7 +5037,7 @@ export class FoundryStepExecutor {
|
|
|
4887
5037
|
}
|
|
4888
5038
|
};
|
|
4889
5039
|
const envs = await tolerateNotFound(() => this.ports.coolify.listEnvs(appId), []);
|
|
4890
|
-
for (const envKey of ['
|
|
5040
|
+
for (const envKey of ['EDGE_URL', 'EDGE_API_KEY']) {
|
|
4891
5041
|
const env = envs.find((e) => e.key === envKey && !e.is_preview);
|
|
4892
5042
|
if (env?.uuid) {
|
|
4893
5043
|
const uuid = env.uuid;
|
|
@@ -4895,21 +5045,21 @@ export class FoundryStepExecutor {
|
|
|
4895
5045
|
}
|
|
4896
5046
|
}
|
|
4897
5047
|
}
|
|
4898
|
-
context.recordScopeId(
|
|
4899
|
-
context.recordScopeId(
|
|
5048
|
+
context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), '');
|
|
5049
|
+
context.recordScopeId(entryEdgeMintedAtScopeKey(entryKey), '');
|
|
4900
5050
|
if (!keyId)
|
|
4901
5051
|
return {};
|
|
4902
|
-
context.recordScopeId(
|
|
5052
|
+
context.recordScopeId(entryEdgeRetiredScopeKey(entryKey), keyId);
|
|
4903
5053
|
return {};
|
|
4904
5054
|
}
|
|
4905
|
-
const entry = org.hosting.entries.find((e) => e.key === entryKey && e.uses?.
|
|
5055
|
+
const entry = org.hosting.entries.find((e) => e.key === entryKey && e.uses?.edge);
|
|
4906
5056
|
if (!entry)
|
|
4907
|
-
throw new Error(`${at}: the entry no longer declares uses.
|
|
5057
|
+
throw new Error(`${at}: the entry no longer declares uses.edge`);
|
|
4908
5058
|
const appId = context.recordedIds()[`coolify:app:${action.key}`];
|
|
4909
5059
|
if (!appId)
|
|
4910
5060
|
throw new Error(`${at}: the entry's app is not recorded yet`);
|
|
4911
5061
|
if (!org.domain) {
|
|
4912
|
-
throw new Error(`${at}:
|
|
5062
|
+
throw new Error(`${at}: edge needs org.domain, and validateEntryEdge already refuses it without one`);
|
|
4913
5063
|
}
|
|
4914
5064
|
// A key left over from a previous mint whose delivery never succeeded
|
|
4915
5065
|
// is revoked first, not left blocking every later apply forever:
|
|
@@ -4917,39 +5067,39 @@ export class FoundryStepExecutor {
|
|
|
4917
5067
|
// (CLAUDE.md "No secret leaves"), so the only way to actually recover
|
|
4918
5068
|
// is to revoke the stale id and mint a fresh one -- a 404 counts as
|
|
4919
5069
|
// already revoked, same as any other retire (#317 re-review).
|
|
4920
|
-
const staleUndelivered = context.recordedIds()[
|
|
5070
|
+
const staleUndelivered = context.recordedIds()[entryEdgeUndeliveredScopeKey(entryKey)];
|
|
4921
5071
|
if (staleUndelivered) {
|
|
4922
5072
|
try {
|
|
4923
|
-
await
|
|
5073
|
+
await edge.revokeKey(org.id, staleUndelivered);
|
|
4924
5074
|
}
|
|
4925
5075
|
catch (error) {
|
|
4926
5076
|
if (!isNotFoundError(error))
|
|
4927
5077
|
throw error;
|
|
4928
5078
|
}
|
|
4929
|
-
context.recordScopeId(
|
|
5079
|
+
context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), '');
|
|
4930
5080
|
await context.persistScopeIds();
|
|
4931
5081
|
}
|
|
4932
|
-
const minted = await
|
|
5082
|
+
const minted = await edge.mintKey({
|
|
4933
5083
|
organisationId: org.id,
|
|
4934
5084
|
domain: org.domain,
|
|
4935
5085
|
name: desired.name,
|
|
4936
|
-
expiresDays:
|
|
5086
|
+
expiresDays: EDGE_ENTRY_KEY_DAYS,
|
|
4937
5087
|
});
|
|
4938
|
-
context.recordScopeId(
|
|
5088
|
+
context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), minted.id);
|
|
4939
5089
|
await context.persistScopeIds();
|
|
4940
5090
|
const envs = [
|
|
4941
|
-
{ key: '
|
|
4942
|
-
{ key: '
|
|
5091
|
+
{ key: 'EDGE_URL', value: desired.edgeUrl, literal: true },
|
|
5092
|
+
{ key: 'EDGE_API_KEY', value: minted.secret, literal: true },
|
|
4943
5093
|
];
|
|
4944
5094
|
let lastError;
|
|
4945
|
-
for (let attempt = 1; attempt <=
|
|
5095
|
+
for (let attempt = 1; attempt <= ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS; attempt++) {
|
|
4946
5096
|
try {
|
|
4947
5097
|
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
4948
5098
|
await this.ports.coolify.restart(appId);
|
|
4949
|
-
context.recordScopeId(
|
|
5099
|
+
context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), '');
|
|
4950
5100
|
// The only trace of this key's age, since nothing here renews it
|
|
4951
5101
|
// (#317 review's warning-row ask reads this to flag an expiring key).
|
|
4952
|
-
context.recordScopeId(
|
|
5102
|
+
context.recordScopeId(entryEdgeMintedAtScopeKey(entryKey), new Date(context.now?.() ?? Date.now()).toISOString());
|
|
4953
5103
|
return { resourceId: minted.id };
|
|
4954
5104
|
}
|
|
4955
5105
|
catch (e) {
|
|
@@ -4957,21 +5107,21 @@ export class FoundryStepExecutor {
|
|
|
4957
5107
|
}
|
|
4958
5108
|
}
|
|
4959
5109
|
// Names the key id only; the underlying error is dropped in case it echoes the secret.
|
|
4960
|
-
throw new Error(`${at}: key ${minted.id} was minted but could not be written to the app's env after ${
|
|
5110
|
+
throw new Error(`${at}: key ${minted.id} was minted but could not be written to the app's env after ${ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS} attempts (${lastError instanceof Error ? lastError.name : 'error'}); the next apply revokes it and mints a fresh one automatically`);
|
|
4961
5111
|
}
|
|
4962
5112
|
/**
|
|
4963
|
-
* F7 (#317): `plan/entry-
|
|
4964
|
-
* tells the
|
|
4965
|
-
* hold, so
|
|
5113
|
+
* F7 (#317): `plan/entry-edge-key.ts`'s `planEdgeReservedLabels` --
|
|
5114
|
+
* tells the edge service every label this org's own entries already
|
|
5115
|
+
* hold, so edge refuses a customer claiming one of them.
|
|
4966
5116
|
*/
|
|
4967
|
-
async
|
|
4968
|
-
const
|
|
4969
|
-
if (!
|
|
4970
|
-
throw new Error(`
|
|
5117
|
+
async edgeReservedLabels(action, org, context) {
|
|
5118
|
+
const edge = this.ports.edge;
|
|
5119
|
+
if (!edge) {
|
|
5120
|
+
throw new Error(`edge:reserved-labels for ${org.slug}: this executor was built without an EdgePort (#317)`);
|
|
4971
5121
|
}
|
|
4972
5122
|
const labels = action.desired.labels;
|
|
4973
|
-
await
|
|
4974
|
-
context.recordScopeId(
|
|
5123
|
+
await edge.setReservedLabels(org.id, labels);
|
|
5124
|
+
context.recordScopeId(EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, JSON.stringify(labels));
|
|
4975
5125
|
return {};
|
|
4976
5126
|
}
|
|
4977
5127
|
/**
|
|
@@ -5250,7 +5400,14 @@ export class FoundryStepExecutor {
|
|
|
5250
5400
|
// `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY` -- nothing else ever runs
|
|
5251
5401
|
// for this org again to finish removing it once destroy deletes
|
|
5252
5402
|
// the ZITADEL project and org below, so it has to go here too.
|
|
5253
|
-
|
|
5403
|
+
// A shared instance host's trusted domain is not this org's to remove.
|
|
5404
|
+
// The flag may be missing, so the recorded host is also compared
|
|
5405
|
+
// with the platform's shared auth host.
|
|
5406
|
+
const sharedHost = await this.ports.platformOrg?.authHostname?.();
|
|
5407
|
+
const isShared = recordedIds[LOGIN_HOST_SHARED_SCOPE_KEY] === '1' ||
|
|
5408
|
+
(sharedHost != null && target.id === sharedHost);
|
|
5409
|
+
const own = isShared ? [] : [target.id];
|
|
5410
|
+
for (const host of [...own, ...pendingRemovalHosts(recordedIds)]) {
|
|
5254
5411
|
try {
|
|
5255
5412
|
await this.ports.zitadel.deleteTrustedDomain(host);
|
|
5256
5413
|
}
|
|
@@ -5375,7 +5532,7 @@ export class FoundryStepExecutor {
|
|
|
5375
5532
|
return;
|
|
5376
5533
|
}
|
|
5377
5534
|
case 'cloudflare:worker': {
|
|
5378
|
-
// #318: an entry's
|
|
5535
|
+
// #318: an entry's Worker (`<orgId>:<entry>`) and its route.
|
|
5379
5536
|
// No port means no way to delete either -- thrown, not silently
|
|
5380
5537
|
// skipped, so it is reported blocked rather than left live with
|
|
5381
5538
|
// nothing recorded ids point at any more.
|
|
@@ -5388,20 +5545,20 @@ export class FoundryStepExecutor {
|
|
|
5388
5545
|
// never recorded, then the script -- the same teardown retire runs.
|
|
5389
5546
|
// `target.id` is `''` for a route-only partial deploy.
|
|
5390
5547
|
const entryKey = target.key.slice(org.id.length + 1);
|
|
5391
|
-
await this.
|
|
5548
|
+
await this.teardownWorker(entryKey, target.id || undefined, recordedIds, workers, `cloudflare:worker '${target.key}' for ${org.slug}`);
|
|
5392
5549
|
return;
|
|
5393
5550
|
}
|
|
5394
|
-
case '
|
|
5395
|
-
// F7 (#317): an entry's key (`<orgId>:<entry>`);
|
|
5551
|
+
case 'edge:key': {
|
|
5552
|
+
// F7 (#317): an entry's key (`<orgId>:<entry>`); edge has no org-root key.
|
|
5396
5553
|
// No port means no way to revoke it -- thrown, not silently
|
|
5397
5554
|
// skipped, so the key is reported blocked rather than left live
|
|
5398
5555
|
// with nothing saying so.
|
|
5399
|
-
const
|
|
5400
|
-
if (!
|
|
5401
|
-
throw new Error(`
|
|
5556
|
+
const edge = this.ports.edge;
|
|
5557
|
+
if (!edge) {
|
|
5558
|
+
throw new Error(`edge:key '${target.key}' for ${org.slug}: this executor was built without an EdgePort (#317); cannot revoke it`);
|
|
5402
5559
|
}
|
|
5403
5560
|
try {
|
|
5404
|
-
await
|
|
5561
|
+
await edge.revokeKey(org.id, target.id);
|
|
5405
5562
|
}
|
|
5406
5563
|
catch (error) {
|
|
5407
5564
|
// Already gone -- the same already-retired key a retire step
|