@wtfalch/foundry 0.16.16 → 0.17.1
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/apply.d.ts +22 -1
- package/dist/bootstrap/bootstrap.d.ts +6 -1
- package/dist/bootstrap/bootstrap.js +26 -3
- package/dist/bootstrap/declare.d.ts +11 -1
- package/dist/bootstrap/declare.js +18 -6
- package/dist/index.d.ts +11 -6
- package/dist/index.js +8 -1
- package/dist/migrations/0038_calls_version_pin.sql +8 -0
- package/dist/migrations/0039_destroy_record.sql +7 -0
- package/dist/migrations/0040_company_included_uuid.sql +72 -0
- package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
- package/dist/migrations/0042_mutation_leases.sql +12 -0
- package/dist/migrations/0043_platform_names.sql +25 -0
- package/dist/migrations/0044_platform_mail_lettermint_project.sql +11 -0
- package/dist/org.d.ts +128 -3
- package/dist/org.js +186 -1
- package/dist/plan/adopt.d.ts +76 -0
- package/dist/plan/adopt.js +184 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +37 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +13 -5
- package/dist/plan/chat-matrix.d.ts +1 -2
- package/dist/plan/chat-matrix.js +15 -4
- package/dist/plan/core-files.d.ts +25 -0
- package/dist/plan/core-files.js +25 -0
- package/dist/plan/diff.d.ts +27 -3
- package/dist/plan/diff.js +264 -86
- package/dist/plan/edge-worker.d.ts +114 -0
- package/dist/plan/edge-worker.js +201 -0
- package/dist/plan/entry-ai-key.d.ts +3 -3
- package/dist/plan/entry-files-key.d.ts +3 -3
- package/dist/plan/entry-names-key.d.ts +73 -0
- package/dist/plan/entry-names-key.js +236 -0
- package/dist/plan/executor.d.ts +292 -24
- package/dist/plan/executor.js +745 -77
- package/dist/plan/index.d.ts +6 -2
- package/dist/plan/index.js +3 -1
- package/dist/plan/live.d.ts +9 -2
- package/dist/plan/login-host.d.ts +13 -2
- package/dist/plan/login-host.js +26 -7
- package/dist/plan/plan-apply.d.ts +153 -6
- package/dist/plan/plan-apply.js +639 -78
- package/dist/plan/platform-diff.d.ts +106 -1
- package/dist/plan/platform-diff.js +182 -2
- package/dist/plan/platform-org.d.ts +45 -1
- package/dist/plan/platform-org.js +34 -3
- package/dist/plan/platform-providers.d.ts +45 -1
- package/dist/plan/platform-providers.js +585 -16
- package/dist/plan/providers-readers.d.ts +2 -0
- package/dist/plan/providers-readers.js +4 -0
- package/dist/plan/rollout.d.ts +52 -0
- package/dist/plan/rollout.js +37 -0
- package/dist/plan/step-system.d.ts +22 -0
- package/dist/plan/step-system.js +38 -0
- package/dist/plan.d.ts +3 -0
- package/dist/platform.d.ts +22 -3
- package/dist/platform.js +6 -0
- package/dist/state.d.ts +13 -0
- package/dist/store/audit.js +15 -1
- package/dist/store/postgres.d.ts +1 -1
- package/dist/store/postgres.js +142 -15
- package/dist/store/schema.d.ts +202 -5
- package/dist/store/schema.js +18 -1
- package/dist/store.d.ts +47 -4
- package/dist/store.js +9 -1
- package/package.json +1 -1
package/dist/plan/executor.js
CHANGED
|
@@ -2,18 +2,22 @@ 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_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, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.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
6
|
import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_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
|
+
import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
|
|
12
|
+
import { DEFAULT_AI_LIMIT_MICROS } from './diff.js';
|
|
13
|
+
import { entryEdgeAppliedScopeKey, entryEdgeRetiredScopeKey, entryEdgeRouteIdScopeKey, entryEdgeRoutePatternScopeKey, entryEdgeRoutePendingScopeKey, entryEdgeRouteZoneScopeKey, entryEdgeUploadedScopeKey, } from './edge-worker.js';
|
|
11
14
|
import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-key.js';
|
|
12
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';
|
|
13
17
|
import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
14
18
|
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
15
19
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
16
|
-
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, } from './platform-diff.js';
|
|
20
|
+
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';
|
|
17
21
|
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
18
22
|
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
|
|
19
23
|
/**
|
|
@@ -210,14 +214,16 @@ const RATE_LIMIT_REQUESTS_PER_PERIOD = 10;
|
|
|
210
214
|
const DEFAULT_APP_REPO = 'wtfalch/foundry';
|
|
211
215
|
const DEFAULT_APP_DOCKERFILE = '/apps/hestia/Dockerfile';
|
|
212
216
|
/**
|
|
213
|
-
* The
|
|
214
|
-
* (https://github.com/wtfalch/cms) -- the
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
* `dockerfile_location`. `CMS_APP_DOCKERFILE`
|
|
219
|
-
* `Dockerfile` (`apps/host`, single-org mode);
|
|
220
|
-
* is `apps/site`'s own Dockerfile (the reference
|
|
217
|
+
* The shared repo `cmsSite` builds its cms admin app from, always, and its
|
|
218
|
+
* site app from by default (https://github.com/wtfalch/cms) -- the
|
|
219
|
+
* 2026-09-25 decision this campaign implements
|
|
220
|
+
* (`.plans/2026-09-25-org-sites.md`, that repo): foundry never calls a cms
|
|
221
|
+
* admin API for the org's site, it provisions two ordinary Coolify
|
|
222
|
+
* applications, told apart by `dockerfile_location`. `CMS_APP_DOCKERFILE`
|
|
223
|
+
* is the repo's own root `Dockerfile` (`apps/host`, single-org mode);
|
|
224
|
+
* `CMS_SITE_APP_DOCKERFILE` is `apps/site`'s own Dockerfile (the reference
|
|
225
|
+
* renderer). An org with `site.repo` set (#245) builds its site app from
|
|
226
|
+
* that repo instead, the sample skin otherwise ships to every org.
|
|
221
227
|
*/
|
|
222
228
|
const CMS_REPO = 'wtfalch/cms';
|
|
223
229
|
const CMS_APP_DOCKERFILE = '/Dockerfile';
|
|
@@ -277,6 +283,8 @@ const LOG_DIR = '/app/logs';
|
|
|
277
283
|
*/
|
|
278
284
|
const CMS_APP_SCOPE_KEY = 'coolify_cms_app';
|
|
279
285
|
const SITE_APP_SCOPE_KEY = 'coolify_site_app';
|
|
286
|
+
/** #445: the one Coolify server every resource of this org lands on, chosen once and recorded. */
|
|
287
|
+
const COOLIFY_SERVER_SCOPE_KEY = 'coolify_server';
|
|
280
288
|
/**
|
|
281
289
|
* #117: the cms app's own Postgres database (`<org.slug>-cms-db`), found or
|
|
282
290
|
* created inside `cmsSite` the same way `coolifyDatabase` does for the org's
|
|
@@ -652,6 +660,8 @@ export class FoundryStepExecutor {
|
|
|
652
660
|
return this.cloudflareAccess(action, org);
|
|
653
661
|
case 'cloudflare:ratelimit':
|
|
654
662
|
return this.cloudflareRateLimit(org);
|
|
663
|
+
case 'cloudflare:worker':
|
|
664
|
+
return this.cloudflareWorker(action, org, context);
|
|
655
665
|
case 'files:organisation':
|
|
656
666
|
return this.filesOrganisation(action, org, context);
|
|
657
667
|
case 'files:key':
|
|
@@ -659,8 +669,6 @@ export class FoundryStepExecutor {
|
|
|
659
669
|
return action.key.includes(':')
|
|
660
670
|
? this.filesEntryKey(action, org, context)
|
|
661
671
|
: this.filesRootKey(action, org, context);
|
|
662
|
-
case 'files:storage':
|
|
663
|
-
return this.filesStorage(action, org);
|
|
664
672
|
case 'ai:organisation':
|
|
665
673
|
return this.aiOrganisation(action, org);
|
|
666
674
|
case 'ai:budget':
|
|
@@ -670,6 +678,10 @@ export class FoundryStepExecutor {
|
|
|
670
678
|
return action.key.includes(':')
|
|
671
679
|
? this.aiEntryKey(action, org, context)
|
|
672
680
|
: this.aiKey(action, org, context);
|
|
681
|
+
case 'names:key':
|
|
682
|
+
return this.namesEntryKey(action, org, context);
|
|
683
|
+
case 'names:reserved-labels':
|
|
684
|
+
return this.namesReservedLabels(action, org, context);
|
|
673
685
|
case 'coolify:mail':
|
|
674
686
|
return this.coolifyMail(action, org, context);
|
|
675
687
|
case 'coolify:mail-copy':
|
|
@@ -837,6 +849,192 @@ export class FoundryStepExecutor {
|
|
|
837
849
|
}
|
|
838
850
|
return { resourceId: created.id };
|
|
839
851
|
}
|
|
852
|
+
/**
|
|
853
|
+
* `plan/diff.ts`'s `planEdgeWorkers` (#318, F8): deploys one entry's
|
|
854
|
+
* `edge` Worker and routes it on the org's own zone. Throws by name
|
|
855
|
+
* without a {@link WorkersPort}, the same `jobsRegistration` shape --
|
|
856
|
+
* `planEdgeWorkers` reports the step blocked instead while the host has
|
|
857
|
+
* none, so this only fires against a stale plan. The route is checked
|
|
858
|
+
* before upload: a foreign route already on the pattern is refused, never
|
|
859
|
+
* adopted or replaced (`cloudflareAccess`'s own precedent for a foreign
|
|
860
|
+
* resource on the same name), so a Worker is never deployed only to find
|
|
861
|
+
* nothing can route to it.
|
|
862
|
+
*
|
|
863
|
+
* Order on apply: record (and persist) the uploaded name and clear the
|
|
864
|
+
* retired marker, upload, record the pending route, create the route only
|
|
865
|
+
* if needed, delete any *previous* route this entry recorded on its own
|
|
866
|
+
* zone, then record the new route id/zone/pattern and the applied
|
|
867
|
+
* declaration last -- every crash point leaves a recorded trace that
|
|
868
|
+
* retire and destroy can clean up (#318 re-review), and per CLAUDE.md's
|
|
869
|
+
* "Declared changes apply" nothing is marked done until the
|
|
870
|
+
* live state actually matches it.
|
|
871
|
+
*/
|
|
872
|
+
async cloudflareWorker(action, org, context) {
|
|
873
|
+
const entryKey = action.key.slice(org.id.length + 1);
|
|
874
|
+
const at = `cloudflare:worker for ${org.slug} entry '${entryKey}'`;
|
|
875
|
+
const workers = this.ports.workers;
|
|
876
|
+
if (!workers)
|
|
877
|
+
throw new Error(`${at}: this executor was built without a WorkersPort (#318)`);
|
|
878
|
+
const desired = action.desired;
|
|
879
|
+
if (desired.mode === 'retire') {
|
|
880
|
+
const recorded = context.recordedIds();
|
|
881
|
+
const mainId = recorded[`cloudflare:worker:${action.key}`];
|
|
882
|
+
const scriptName = mainId || recorded[entryEdgeUploadedScopeKey(entryKey)];
|
|
883
|
+
await this.teardownEdgeWorker(entryKey, scriptName, recorded, workers, at);
|
|
884
|
+
context.recordScopeId(entryEdgeRouteIdScopeKey(entryKey), '');
|
|
885
|
+
context.recordScopeId(entryEdgeRouteZoneScopeKey(entryKey), '');
|
|
886
|
+
context.recordScopeId(entryEdgeRoutePatternScopeKey(entryKey), '');
|
|
887
|
+
context.recordScopeId(entryEdgeRoutePendingScopeKey(entryKey), '');
|
|
888
|
+
context.recordScopeId(entryEdgeAppliedScopeKey(entryKey), '');
|
|
889
|
+
context.recordScopeId(entryEdgeUploadedScopeKey(entryKey), '');
|
|
890
|
+
// `runSteps` only ever writes the main recorded id from a truthy
|
|
891
|
+
// `resourceId` (`plan-apply.ts`); this step returns none, so without
|
|
892
|
+
// this marker the planner's "gone" scan would retire the same,
|
|
893
|
+
// never-cleared id forever.
|
|
894
|
+
if (mainId)
|
|
895
|
+
context.recordScopeId(entryEdgeRetiredScopeKey(entryKey), mainId);
|
|
896
|
+
return {};
|
|
897
|
+
}
|
|
898
|
+
if (!org.domain) {
|
|
899
|
+
throw new Error(`${at}: edge needs org.domain, and validateHosting already refuses it without one`);
|
|
900
|
+
}
|
|
901
|
+
const zone = await this.ports.cloudflare.findZoneByApex(org.domain);
|
|
902
|
+
if (!zone) {
|
|
903
|
+
throw new Error(`${at}: no Cloudflare zone found for ${org.domain} -- cloudflare:zone must provision it first`);
|
|
904
|
+
}
|
|
905
|
+
const name = desired.name;
|
|
906
|
+
const route = desired.route;
|
|
907
|
+
const bindings = (desired.bindings?.rateLimits ?? []).map((r) => ({
|
|
908
|
+
type: 'ratelimit',
|
|
909
|
+
name: r.name,
|
|
910
|
+
namespace_id: r.namespaceId,
|
|
911
|
+
simple: { limit: r.limit, period: r.period },
|
|
912
|
+
}));
|
|
913
|
+
const existingRoute = await workers.findRoute(zone.id, route);
|
|
914
|
+
if (existingRoute && existingRoute.script !== name) {
|
|
915
|
+
throw new Error(`${at}: route '${route}' is already bound to Worker '${existingRoute.script}', not foundry's own '${name}'; foundry does not adopt or replace it`);
|
|
916
|
+
}
|
|
917
|
+
// Checked only on an entry's first-ever apply -- a name foundry has
|
|
918
|
+
// already recorded for itself (even a retired one: redeclaring reuses
|
|
919
|
+
// the same deterministic name) is foundry's own script to overwrite,
|
|
920
|
+
// never a stranger's.
|
|
921
|
+
const recordedBeforeUpload = context.recordedIds();
|
|
922
|
+
const everUploadedByFoundry = Boolean(recordedBeforeUpload[entryEdgeAppliedScopeKey(entryKey)] ||
|
|
923
|
+
recordedBeforeUpload[entryEdgeUploadedScopeKey(entryKey)] ||
|
|
924
|
+
recordedBeforeUpload[`cloudflare:worker:${action.key}`]);
|
|
925
|
+
if (!everUploadedByFoundry && (await workers.scriptExists(name))) {
|
|
926
|
+
throw new Error(`${at}: a Worker script named '${name}' already exists and foundry never created it; refuses to overwrite it`);
|
|
927
|
+
}
|
|
928
|
+
// Recorded and persisted *before* the upload (#318 re-review): a crash
|
|
929
|
+
// mid-upload or a failed route create still leaves a trail to the
|
|
930
|
+
// script, and a retry reads it as foundry's own rather than tripping
|
|
931
|
+
// the `scriptExists` guard above. The retired marker clears here too,
|
|
932
|
+
// not only on success: once a redeploy has touched the script, a later
|
|
933
|
+
// undeclare must retire it even if this apply never finishes.
|
|
934
|
+
context.recordScopeId(entryEdgeUploadedScopeKey(entryKey), name);
|
|
935
|
+
context.recordScopeId(entryEdgeRetiredScopeKey(entryKey), '');
|
|
936
|
+
await context.persistScopeIds();
|
|
937
|
+
await workers.uploadScript({
|
|
938
|
+
name,
|
|
939
|
+
artifactUrl: desired.artifactUrl,
|
|
940
|
+
artifactSha256: desired.artifactSha256,
|
|
941
|
+
compatibilityDate: action.desired.compatibilityDate,
|
|
942
|
+
bindings: bindings.length > 0 ? bindings : undefined,
|
|
943
|
+
});
|
|
944
|
+
const recorded = context.recordedIds();
|
|
945
|
+
const previousRouteId = recorded[entryEdgeRouteIdScopeKey(entryKey)];
|
|
946
|
+
const previousPending = recorded[entryEdgeRoutePendingScopeKey(entryKey)];
|
|
947
|
+
const zoneRecord = { id: zone.id, apex: org.domain };
|
|
948
|
+
let routeId = existingRoute?.id;
|
|
949
|
+
if (!routeId) {
|
|
950
|
+
// Recorded before the create, so a route Cloudflare made but foundry
|
|
951
|
+
// never got to record is still found (`findRoute`) and deleted on
|
|
952
|
+
// retire or destroy (#318 re-review).
|
|
953
|
+
const pending = { ...zoneRecord, pattern: route, script: name };
|
|
954
|
+
context.recordScopeId(entryEdgeRoutePendingScopeKey(entryKey), JSON.stringify(pending));
|
|
955
|
+
await context.persistScopeIds();
|
|
956
|
+
const created = await workers.createRoute(zone.id, route, name);
|
|
957
|
+
routeId = created.id;
|
|
958
|
+
}
|
|
959
|
+
// Any route this entry recorded before that is not the one it now uses
|
|
960
|
+
// -- the declared pattern moved, or the domain did -- is deleted on its
|
|
961
|
+
// own zone rather than left live beside the new one. A pending route
|
|
962
|
+
// from an earlier, interrupted apply under a different pattern likewise.
|
|
963
|
+
if (previousRouteId && previousRouteId !== routeId) {
|
|
964
|
+
const previousZone = await this.liveEdgeZone(recorded[entryEdgeRouteZoneScopeKey(entryKey)], at);
|
|
965
|
+
if (previousZone)
|
|
966
|
+
await workers.deleteRoute(previousZone, previousRouteId); // Idempotent.
|
|
967
|
+
}
|
|
968
|
+
if (previousPending) {
|
|
969
|
+
await this.deletePendingEdgeRoute(previousPending, routeId, workers, at);
|
|
970
|
+
}
|
|
971
|
+
context.recordScopeId(entryEdgeRouteIdScopeKey(entryKey), routeId);
|
|
972
|
+
context.recordScopeId(entryEdgeRouteZoneScopeKey(entryKey), JSON.stringify(zoneRecord));
|
|
973
|
+
context.recordScopeId(entryEdgeRoutePatternScopeKey(entryKey), route);
|
|
974
|
+
context.recordScopeId(entryEdgeRoutePendingScopeKey(entryKey), '');
|
|
975
|
+
context.recordScopeId(entryEdgeAppliedScopeKey(entryKey), JSON.stringify({
|
|
976
|
+
name,
|
|
977
|
+
artifactUrl: desired.artifactUrl,
|
|
978
|
+
artifactSha256: desired.artifactSha256,
|
|
979
|
+
route,
|
|
980
|
+
bindings: desired.bindings,
|
|
981
|
+
}));
|
|
982
|
+
context.recordScopeId(entryEdgeUploadedScopeKey(entryKey), '');
|
|
983
|
+
// The retired marker was already cleared before the upload above: a
|
|
984
|
+
// redeclare reuses this entry's deterministic script name, so a marker
|
|
985
|
+
// left equal to the (identical) main id would read as already retired.
|
|
986
|
+
return { resourceId: name };
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* The zone id a recorded route lives in, or `undefined` only when that
|
|
990
|
+
* zone is *positively* gone -- its apex resolves to no zone, or to a
|
|
991
|
+
* different zone than the one recorded (#318 re-review). Reads the zone
|
|
992
|
+
* recorded beside the route, never `org.domain`: the route id and its
|
|
993
|
+
* zone are always recorded together (the apply branch below writes both
|
|
994
|
+
* in the same batch, and the pending-route marker carries its own zone
|
|
995
|
+
* too), so a route id with no zone recorded cannot happen within this
|
|
996
|
+
* feature -- and re-resolving from `org.domain` as a fallback would risk
|
|
997
|
+
* looking up the *new* zone after a domain change and leaving the route
|
|
998
|
+
* live on the old one, the exact bug a recorded-per-route zone exists to
|
|
999
|
+
* avoid (#318 re-review).
|
|
1000
|
+
*/
|
|
1001
|
+
async liveEdgeZone(recordedZone, at) {
|
|
1002
|
+
if (!recordedZone) {
|
|
1003
|
+
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
|
+
}
|
|
1005
|
+
const { id, apex } = JSON.parse(recordedZone);
|
|
1006
|
+
const zone = await this.ports.cloudflare.findZoneByApex(apex);
|
|
1007
|
+
return zone && zone.id === id ? id : undefined;
|
|
1008
|
+
}
|
|
1009
|
+
/** 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 deletePendingEdgeRoute(pendingRaw, keepRouteId, workers, at) {
|
|
1011
|
+
const pending = JSON.parse(pendingRaw);
|
|
1012
|
+
const zoneId = await this.liveEdgeZone(JSON.stringify({ id: pending.id, apex: pending.apex }), at);
|
|
1013
|
+
if (!zoneId)
|
|
1014
|
+
return;
|
|
1015
|
+
const found = await workers.findRoute(zoneId, pending.pattern);
|
|
1016
|
+
if (found && found.script === pending.script && found.id !== keepRouteId) {
|
|
1017
|
+
await workers.deleteRoute(zoneId, found.id); // Idempotent.
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Retire and destroy share this: delete the recorded route on its own
|
|
1022
|
+
* zone, then any route an interrupted apply created but never recorded,
|
|
1023
|
+
* then the script. Records nothing -- the caller does.
|
|
1024
|
+
*/
|
|
1025
|
+
async teardownEdgeWorker(entryKey, scriptName, recorded, workers, at) {
|
|
1026
|
+
const routeId = recorded[entryEdgeRouteIdScopeKey(entryKey)];
|
|
1027
|
+
if (routeId) {
|
|
1028
|
+
const zoneId = await this.liveEdgeZone(recorded[entryEdgeRouteZoneScopeKey(entryKey)], at);
|
|
1029
|
+
if (zoneId)
|
|
1030
|
+
await workers.deleteRoute(zoneId, routeId); // Idempotent: a no-op if already gone.
|
|
1031
|
+
}
|
|
1032
|
+
const pending = recorded[entryEdgeRoutePendingScopeKey(entryKey)];
|
|
1033
|
+
if (pending)
|
|
1034
|
+
await this.deletePendingEdgeRoute(pending, undefined, workers, at);
|
|
1035
|
+
if (scriptName)
|
|
1036
|
+
await workers.deleteScript(scriptName); // Idempotent: a no-op if already gone.
|
|
1037
|
+
}
|
|
840
1038
|
// ---- zitadel --------------------------------------------------------
|
|
841
1039
|
async zitadelIssuer(action, org, context) {
|
|
842
1040
|
if (action.desired.provision !== true) {
|
|
@@ -983,6 +1181,7 @@ export class FoundryStepExecutor {
|
|
|
983
1181
|
.map((key) => key.slice(ENTRY_CLIENT_ID_SCOPE_PREFIX.length)),
|
|
984
1182
|
...(recorded[CMS_CLIENT_ID_SCOPE_KEY] ? ['cms'] : []),
|
|
985
1183
|
...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
|
|
1184
|
+
...(recorded[BOULE_CLIENT_ID_SCOPE_KEY] ? ['boule'] : []),
|
|
986
1185
|
].map((name) => recorded[`zitadel:app:${name}`]));
|
|
987
1186
|
const projectId = recorded.zitadel_project;
|
|
988
1187
|
// No project yet means no apps yet: vacuously safe to decide from (fix
|
|
@@ -1256,11 +1455,37 @@ export class FoundryStepExecutor {
|
|
|
1256
1455
|
context.recordScopeId('coolify_project', projectUuid);
|
|
1257
1456
|
return projectUuid;
|
|
1258
1457
|
}
|
|
1259
|
-
|
|
1458
|
+
/**
|
|
1459
|
+
* #445: one company lands on one Coolify server. The first call for an
|
|
1460
|
+
* org picks a usable, reachable server and records it
|
|
1461
|
+
* ({@link COOLIFY_SERVER_SCOPE_KEY}); every later call -- this apply, or a
|
|
1462
|
+
* later one -- reads that recording back and returns the same server,
|
|
1463
|
+
* regardless of what the pool's order or health looks like by then. A
|
|
1464
|
+
* recorded server that has gone unusable or unreachable is reported
|
|
1465
|
+
* blocked (never silently replaced with a second host, which would split
|
|
1466
|
+
* the org's resources across servers and break their internal container
|
|
1467
|
+
* URLs); a recorded server no longer registered at all is the same.
|
|
1468
|
+
* Deliberately no fallback to `servers[0]` when nothing is usable: an
|
|
1469
|
+
* unusable server picked blind is not a server anyone's resources could
|
|
1470
|
+
* actually be created on.
|
|
1471
|
+
*/
|
|
1472
|
+
async resolveServer(org, context) {
|
|
1260
1473
|
const servers = await this.ports.coolify.listServers();
|
|
1261
|
-
const
|
|
1474
|
+
const recorded = context.recordedIds()[COOLIFY_SERVER_SCOPE_KEY];
|
|
1475
|
+
if (recorded) {
|
|
1476
|
+
const server = servers.find((s) => s.uuid === recorded);
|
|
1477
|
+
if (!server) {
|
|
1478
|
+
throw new Error(`${org.slug}'s recorded Coolify server ${recorded} is no longer registered on this instance`);
|
|
1479
|
+
}
|
|
1480
|
+
if (!(server.is_usable && server.is_reachable)) {
|
|
1481
|
+
throw new Error(`${org.slug}'s recorded Coolify server ${recorded} is not usable/reachable right now; it is not moved to a second server`);
|
|
1482
|
+
}
|
|
1483
|
+
return server;
|
|
1484
|
+
}
|
|
1485
|
+
const server = servers.find((s) => s.is_usable && s.is_reachable);
|
|
1262
1486
|
if (!server)
|
|
1263
1487
|
throw new Error('no usable Coolify server is registered on this instance');
|
|
1488
|
+
context.recordScopeId(COOLIFY_SERVER_SCOPE_KEY, server.uuid);
|
|
1264
1489
|
return server;
|
|
1265
1490
|
}
|
|
1266
1491
|
async coolifyApp(action, org, context) {
|
|
@@ -1326,7 +1551,7 @@ export class FoundryStepExecutor {
|
|
|
1326
1551
|
haveNames = appliedAppDomains(org, context.recordedIds(), entry.key);
|
|
1327
1552
|
}
|
|
1328
1553
|
else {
|
|
1329
|
-
const server = await this.resolveServer();
|
|
1554
|
+
const server = await this.resolveServer(org, context);
|
|
1330
1555
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
1331
1556
|
const created = await this.ports.coolify.createGithubAppApplication({
|
|
1332
1557
|
project_uuid: projectUuid,
|
|
@@ -1468,7 +1693,15 @@ export class FoundryStepExecutor {
|
|
|
1468
1693
|
// One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
|
|
1469
1694
|
// second entry sharing the name would delete the first's live token.
|
|
1470
1695
|
if (entry.key === APP_ENTRY_KEY) {
|
|
1471
|
-
await this.ensureAppLettermintMail(appId, org, context, Boolean(existing)
|
|
1696
|
+
await this.ensureAppLettermintMail(appId, org, context, Boolean(existing),
|
|
1697
|
+
// #491 (review round 2, finding 3): `diff.ts`'s `planApps` always
|
|
1698
|
+
// sets `platformApex` on this step's `desired` once it's planned at
|
|
1699
|
+
// all -- `null` when no platform is declared, never omitted. The key
|
|
1700
|
+
// being absent outright (as opposed to present and `null`) means a
|
|
1701
|
+
// step planned before this field existed; `ensureAppLettermintMail`
|
|
1702
|
+
// refuses rather than silently falling back once it knows there is
|
|
1703
|
+
// a recorded mail domain to reason about.
|
|
1704
|
+
'platformApex' in action.desired, action.desired.platformApex ?? null, action.desired.platformLettermintProjectId ?? null);
|
|
1472
1705
|
}
|
|
1473
1706
|
return { resourceId: appId };
|
|
1474
1707
|
}
|
|
@@ -1529,7 +1762,7 @@ export class FoundryStepExecutor {
|
|
|
1529
1762
|
dbId = found.uuid;
|
|
1530
1763
|
}
|
|
1531
1764
|
else {
|
|
1532
|
-
const server = await this.resolveServer();
|
|
1765
|
+
const server = await this.resolveServer(org, context);
|
|
1533
1766
|
const dbName = org.slug.replace(/-/g, '_');
|
|
1534
1767
|
const created = await this.ports.coolify.createPostgresDatabase({
|
|
1535
1768
|
project_uuid: projectUuid,
|
|
@@ -1907,12 +2140,73 @@ export class FoundryStepExecutor {
|
|
|
1907
2140
|
* comment), never logged, never interpolated into a thrown error -- the
|
|
1908
2141
|
* same discipline `pushDatabaseUrl`'s doc comment states for a database's
|
|
1909
2142
|
* own connection string.
|
|
2143
|
+
*
|
|
2144
|
+
* #491: none of the above applies to the org whose mail domain is the
|
|
2145
|
+
* platform's own apex -- it shares the platform's existing Lettermint
|
|
2146
|
+
* project (`platformLettermintProjectId`, from `DeclaredPlatform.mail`)
|
|
2147
|
+
* instead of minting a `<slug>-app` one, and this method never creates or
|
|
2148
|
+
* deletes a project for it. `platformApex`/`platformLettermintProjectId`
|
|
2149
|
+
* come straight off this step's own `desired`, the same "the executor
|
|
2150
|
+
* writes exactly what the plan showed" rule `coolifyMail` already follows
|
|
2151
|
+
* for its own `apex`/`mailHost` -- the caller (`coolifyApp`) refuses a
|
|
2152
|
+
* `desired` missing the `platformApex` key outright as a stale plan --
|
|
2153
|
+
* checked here, after the no-op-until-a-mail-domain-exists return just
|
|
2154
|
+
* below, rather than unconditionally at the call site: every other
|
|
2155
|
+
* `coolify:app` concern (hostnames, ports, settings, the sign-in env...)
|
|
2156
|
+
* has nothing to do with Lettermint and must keep working the moment a
|
|
2157
|
+
* mail domain was never even recorded for this org, the same as before
|
|
2158
|
+
* this field existed (review round 2, finding 3).
|
|
1910
2159
|
*/
|
|
1911
|
-
async ensureAppLettermintMail(appId, org, context, running) {
|
|
2160
|
+
async ensureAppLettermintMail(appId, org, context, running, hasPlatformApexField, platformApex, platformLettermintProjectId) {
|
|
1912
2161
|
const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
1913
2162
|
if (!mailDomain)
|
|
1914
2163
|
return;
|
|
2164
|
+
if (!hasPlatformApexField) {
|
|
2165
|
+
throw new Error(`coolify:app for ${org.slug}: this step's plan carries no platformApex; plan this org's app again`);
|
|
2166
|
+
}
|
|
1915
2167
|
const appName = `${org.slug}-app`;
|
|
2168
|
+
// #491: an org whose mail domain is the platform's own apex (Company
|
|
2169
|
+
// Included on companyincluded.com, today) shares the platform's existing
|
|
2170
|
+
// Lettermint project rather than minting a `<slug>-app` one of its own --
|
|
2171
|
+
// `diff.ts`'s `planApp` blocks this whole piece while
|
|
2172
|
+
// `platformLettermintProjectId` is unset, so a plan carrying neither is
|
|
2173
|
+
// stale; never fall back to creating, restricting or deleting anything
|
|
2174
|
+
// here until a fresh plan supplies it.
|
|
2175
|
+
const isPlatformApexOrg = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
|
|
2176
|
+
if (isPlatformApexOrg) {
|
|
2177
|
+
if (!platformLettermintProjectId)
|
|
2178
|
+
return;
|
|
2179
|
+
const [projects, envs] = await Promise.all([
|
|
2180
|
+
this.ports.lettermint.listProjects(),
|
|
2181
|
+
this.ports.coolify.listEnvs(appId),
|
|
2182
|
+
]);
|
|
2183
|
+
// Review round 2, finding 5: a missing held token would otherwise
|
|
2184
|
+
// leave the app with a stale or absent `LETTERMINT_API_TOKEN` and no
|
|
2185
|
+
// signal that anything is wrong -- refused instead, the same "fail
|
|
2186
|
+
// rather than silently point mail at nothing" rule `coolifyMail`
|
|
2187
|
+
// already follows for its own `apex`/`mailHost`.
|
|
2188
|
+
const heldToken = (await this.ports.heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF)) ?? null;
|
|
2189
|
+
if (!heldToken) {
|
|
2190
|
+
throw new Error(`coolify:app for ${org.slug}: the platform's own Lettermint token is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`);
|
|
2191
|
+
}
|
|
2192
|
+
// Validates and restricts before any env is written -- `ensureLettermintDomainRestriction`'s
|
|
2193
|
+
// own doc comment (review round 2, finding 4) throws on a mistyped id,
|
|
2194
|
+
// and the app must never end up with a pushed token the restriction
|
|
2195
|
+
// step then fails on, the same "never half-ready" order
|
|
2196
|
+
// {@link ensureOrgLettermintRelay} already follows for its own token.
|
|
2197
|
+
await this.ensureLettermintDomainRestriction(org, mailDomain, projects, platformApex, platformLettermintProjectId);
|
|
2198
|
+
const currentToken = envs.find((e) => e.key === 'LETTERMINT_API_TOKEN' && !e.is_preview)?.value;
|
|
2199
|
+
const tokenChanged = currentToken !== heldToken;
|
|
2200
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, [
|
|
2201
|
+
{ key: 'MAIL_FROM', value: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}` },
|
|
2202
|
+
{ key: 'LETTERMINT_API_TOKEN', value: heldToken },
|
|
2203
|
+
]);
|
|
2204
|
+
// Same restart rule as the non-apex path below: a running container
|
|
2205
|
+
// read its env at start and keeps a stale token until restarted.
|
|
2206
|
+
if (tokenChanged && running)
|
|
2207
|
+
await this.ports.coolify.restart(appId);
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
1916
2210
|
const [projects, envs] = await Promise.all([
|
|
1917
2211
|
this.ports.lettermint.listProjects(),
|
|
1918
2212
|
this.ports.coolify.listEnvs(appId),
|
|
@@ -1943,9 +2237,11 @@ export class FoundryStepExecutor {
|
|
|
1943
2237
|
// old project is deleted below. A just-created app starts with the new env.
|
|
1944
2238
|
if (!healthy && running)
|
|
1945
2239
|
await this.ports.coolify.restart(appId);
|
|
1946
|
-
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
|
|
2240
|
+
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
|
|
1947
2241
|
for (const project of allProjects) {
|
|
1948
|
-
if (project.name === appName &&
|
|
2242
|
+
if (project.name === appName &&
|
|
2243
|
+
project.id !== keepProjectId &&
|
|
2244
|
+
project.id !== platformLettermintProjectId) {
|
|
1949
2245
|
await this.ports.lettermint.deleteProject(project.id);
|
|
1950
2246
|
}
|
|
1951
2247
|
}
|
|
@@ -1983,8 +2279,14 @@ export class FoundryStepExecutor {
|
|
|
1983
2279
|
* it the same way the app step does, rather than assuming it already
|
|
1984
2280
|
* exists -- the domain must never end up unrestricted, whichever step gets
|
|
1985
2281
|
* there first.
|
|
2282
|
+
*
|
|
2283
|
+
* #491: this org's own `<slug>-relay` project is unaffected by whether it
|
|
2284
|
+
* is the platform's own org -- only the domain restriction
|
|
2285
|
+
* (`platformApex`/`platformLettermintProjectId`, forwarded to
|
|
2286
|
+
* {@link ensureLettermintDomainRestriction}) and the delete loop below
|
|
2287
|
+
* (which must never delete the platform's own project) change.
|
|
1986
2288
|
*/
|
|
1987
|
-
async ensureOrgLettermintRelay(org, mailOrg, mailDomain, context) {
|
|
2289
|
+
async ensureOrgLettermintRelay(org, mailOrg, mailDomain, context, platformApex, platformLettermintProjectId) {
|
|
1988
2290
|
const relayName = `${org.slug}-relay`;
|
|
1989
2291
|
// #345: `mailOrg.domain` is absent for a level-1 org -- `mailDomain` is
|
|
1990
2292
|
// `applyOrgMail`'s own resolved result, the one domain `MailPort`'s
|
|
@@ -2003,7 +2305,7 @@ export class FoundryStepExecutor {
|
|
|
2003
2305
|
let keepProjectId;
|
|
2004
2306
|
if (healthy && held) {
|
|
2005
2307
|
keepProjectId = held.id;
|
|
2006
|
-
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
|
|
2308
|
+
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
|
|
2007
2309
|
}
|
|
2008
2310
|
else {
|
|
2009
2311
|
const created = await this.ports.lettermint.createProject(relayName);
|
|
@@ -2014,7 +2316,7 @@ export class FoundryStepExecutor {
|
|
|
2014
2316
|
// reaches Stalwart: a failure here leaves the working route alone.
|
|
2015
2317
|
// The restriction covers old and new projects, so the old one keeps
|
|
2016
2318
|
// sending until the route switches.
|
|
2017
|
-
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
|
|
2319
|
+
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
|
|
2018
2320
|
await this.ports.mail.applyOrgRelay(relayOrg, created.token);
|
|
2019
2321
|
}
|
|
2020
2322
|
catch (err) {
|
|
@@ -2027,7 +2329,9 @@ export class FoundryStepExecutor {
|
|
|
2027
2329
|
await context.persistScopeIds();
|
|
2028
2330
|
}
|
|
2029
2331
|
for (const project of allProjects) {
|
|
2030
|
-
if (project.name === relayName &&
|
|
2332
|
+
if (project.name === relayName &&
|
|
2333
|
+
project.id !== keepProjectId &&
|
|
2334
|
+
project.id !== platformLettermintProjectId) {
|
|
2031
2335
|
await this.ports.lettermint.deleteProject(project.id);
|
|
2032
2336
|
}
|
|
2033
2337
|
}
|
|
@@ -2059,10 +2363,31 @@ export class FoundryStepExecutor {
|
|
|
2059
2363
|
* unrestricted domain cannot verify and cannot send, whichever project
|
|
2060
2364
|
* owns it (post-#140 review finding, restated here rather than only at
|
|
2061
2365
|
* the one call site it used to have).
|
|
2366
|
+
*
|
|
2367
|
+
* #491: `platformApex`/`platformLettermintProjectId` cover both callers at
|
|
2368
|
+
* once -- when `mailDomain` is the platform's own apex, the platform's
|
|
2369
|
+
* project id is always folded into the restriction, so a plan that shares
|
|
2370
|
+
* the platform's project (rather than minting a `<slug>-app` one) never
|
|
2371
|
+
* ends up restricting that org's domain to nothing. A no-op for every
|
|
2372
|
+
* other org's domain, since `mailDomain` never matches `platformApex` there.
|
|
2373
|
+
* Review round 2, finding 4: a declared id that is not among `allProjects`
|
|
2374
|
+
* (a mistype, or the project was deleted in Lettermint) is refused rather
|
|
2375
|
+
* than folded in anyway -- that would restrict the apex to whatever *is*
|
|
2376
|
+
* live (the relay project, say) plus an id naming nothing, which excludes
|
|
2377
|
+
* the real platform project from its own domain exactly as completely as
|
|
2378
|
+
* never restricting it at all. Centralised here rather than duplicated at
|
|
2379
|
+
* each caller, since both {@link ensureAppLettermintMail} and
|
|
2380
|
+
* {@link ensureOrgLettermintRelay} reach the apex through this one method.
|
|
2062
2381
|
*/
|
|
2063
|
-
async ensureLettermintDomainRestriction(org, mailDomain, allProjects) {
|
|
2382
|
+
async ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId) {
|
|
2064
2383
|
const appName = `${org.slug}-app`;
|
|
2065
2384
|
const relayName = `${org.slug}-relay`;
|
|
2385
|
+
const isPlatformApexDomain = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
|
|
2386
|
+
if (isPlatformApexDomain && platformLettermintProjectId) {
|
|
2387
|
+
if (!allProjects.some((p) => p.id === platformLettermintProjectId)) {
|
|
2388
|
+
throw new Error(`Lettermint for ${org.slug}: platform.mail.lettermintProjectId ${platformLettermintProjectId} is not a live Lettermint project`);
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2066
2391
|
const existing = await this.findExactLettermintDomain(mailDomain);
|
|
2067
2392
|
const domain = existing
|
|
2068
2393
|
? await this.ports.lettermint.getDomain(existing.id)
|
|
@@ -2070,6 +2395,11 @@ export class FoundryStepExecutor {
|
|
|
2070
2395
|
const scopedProjectIds = allProjects
|
|
2071
2396
|
.filter((p) => p.name === appName || p.name === relayName)
|
|
2072
2397
|
.map((p) => p.id);
|
|
2398
|
+
if (isPlatformApexDomain &&
|
|
2399
|
+
platformLettermintProjectId &&
|
|
2400
|
+
!scopedProjectIds.includes(platformLettermintProjectId)) {
|
|
2401
|
+
scopedProjectIds.push(platformLettermintProjectId);
|
|
2402
|
+
}
|
|
2073
2403
|
await this.ports.lettermint.restrictDomainToProjects(domain.id, scopedProjectIds);
|
|
2074
2404
|
const zone = await this.ports.cloudflare.findZoneByApex(mailDomain);
|
|
2075
2405
|
if (!zone) {
|
|
@@ -2161,16 +2491,22 @@ export class FoundryStepExecutor {
|
|
|
2161
2491
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
2162
2492
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
2163
2493
|
const site = await this.findOrCreateCmsApp({
|
|
2494
|
+
org,
|
|
2495
|
+
context,
|
|
2164
2496
|
projectUuid,
|
|
2165
2497
|
environment,
|
|
2166
2498
|
name: `${org.slug}-site`,
|
|
2499
|
+
repo: org.site.repo ?? CMS_REPO,
|
|
2167
2500
|
dockerfileLocation: CMS_SITE_APP_DOCKERFILE,
|
|
2168
2501
|
domain,
|
|
2169
2502
|
});
|
|
2170
2503
|
const cms = await this.findOrCreateCmsApp({
|
|
2504
|
+
org,
|
|
2505
|
+
context,
|
|
2171
2506
|
projectUuid,
|
|
2172
2507
|
environment,
|
|
2173
2508
|
name: `${org.slug}-cms`,
|
|
2509
|
+
repo: CMS_REPO,
|
|
2174
2510
|
dockerfileLocation: CMS_APP_DOCKERFILE,
|
|
2175
2511
|
domain: cmsHostnameFor(org),
|
|
2176
2512
|
});
|
|
@@ -2281,7 +2617,7 @@ export class FoundryStepExecutor {
|
|
|
2281
2617
|
const found = (await this.ports.coolify.listDatabases()).find((d) => d.name === name && d.environment_id === environment.id);
|
|
2282
2618
|
if (found)
|
|
2283
2619
|
return found.uuid;
|
|
2284
|
-
const server = await this.resolveServer();
|
|
2620
|
+
const server = await this.resolveServer(org, context);
|
|
2285
2621
|
const dbName = name.replace(/-/g, '_');
|
|
2286
2622
|
const created = await this.ports.coolify.createPostgresDatabase({
|
|
2287
2623
|
project_uuid: projectUuid,
|
|
@@ -2368,6 +2704,10 @@ export class FoundryStepExecutor {
|
|
|
2368
2704
|
* `coolifyApp`'s own analogous gap (it never restarted a found-but-stopped
|
|
2369
2705
|
* app either) has since closed the same way, inline in that method (#130);
|
|
2370
2706
|
* this method is `cmsSite`'s only caller.
|
|
2707
|
+
*
|
|
2708
|
+
* `repo` (#245) lets the site app build from an org's own skin repo
|
|
2709
|
+
* (`org.site.repo`) instead of the shared `CMS_REPO`; `cmsSite` always
|
|
2710
|
+
* passes `CMS_REPO` for the cms admin app, which is never org-specific.
|
|
2371
2711
|
*/
|
|
2372
2712
|
async findOrCreateCmsApp(input) {
|
|
2373
2713
|
const existing = await this.ports.coolify.findApplicationByName(input.name, {
|
|
@@ -2380,7 +2720,7 @@ export class FoundryStepExecutor {
|
|
|
2380
2720
|
}
|
|
2381
2721
|
return { appId: existing.uuid, existed: true };
|
|
2382
2722
|
}
|
|
2383
|
-
const server = await this.resolveServer();
|
|
2723
|
+
const server = await this.resolveServer(input.org, input.context);
|
|
2384
2724
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
2385
2725
|
const created = await this.ports.coolify.createGithubAppApplication({
|
|
2386
2726
|
project_uuid: input.projectUuid,
|
|
@@ -2388,7 +2728,7 @@ export class FoundryStepExecutor {
|
|
|
2388
2728
|
environment_name: input.environment.name,
|
|
2389
2729
|
environment_uuid: input.environment.uuid,
|
|
2390
2730
|
github_app_uuid: githubApp.uuid,
|
|
2391
|
-
git_repository:
|
|
2731
|
+
git_repository: input.repo,
|
|
2392
2732
|
git_branch: 'main',
|
|
2393
2733
|
build_pack: 'dockerfile',
|
|
2394
2734
|
name: input.name,
|
|
@@ -2426,16 +2766,16 @@ export class FoundryStepExecutor {
|
|
|
2426
2766
|
}
|
|
2427
2767
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
2428
2768
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
2429
|
-
const server = await this.resolveServer();
|
|
2769
|
+
const server = await this.resolveServer(org, context);
|
|
2430
2770
|
const names = {
|
|
2431
2771
|
synapse: `${org.slug}-calls-synapse`,
|
|
2432
2772
|
mas: `${org.slug}-calls-mas`,
|
|
2433
2773
|
synapseDb: `${org.slug}-calls-synapse-db`,
|
|
2434
2774
|
masDb: `${org.slug}-calls-mas-db`,
|
|
2435
2775
|
};
|
|
2436
|
-
const synapseDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.synapseDb, 'synapse', CALLS_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
|
|
2776
|
+
const synapseDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.synapseDb, 'synapse', CALLS_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
|
|
2437
2777
|
context.recordScopeId(CALLS_SYNAPSE_DB_SCOPE_KEY, synapseDbId);
|
|
2438
|
-
const masDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.masDb, 'mas', CALLS_MAS_DB_SCOPE_KEY);
|
|
2778
|
+
const masDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.masDb, 'mas', CALLS_MAS_DB_SCOPE_KEY);
|
|
2439
2779
|
context.recordScopeId(CALLS_MAS_DB_SCOPE_KEY, masDbId);
|
|
2440
2780
|
const [synapseDbRow, masDbRow] = await Promise.all([
|
|
2441
2781
|
getDatabaseStarted(this.ports.coolify, synapseDbId),
|
|
@@ -2589,14 +2929,14 @@ export class FoundryStepExecutor {
|
|
|
2589
2929
|
* database, whose {@link CALLS_SERVICE_DB_SCOPE_KEY} has no
|
|
2590
2930
|
* `system:kind:key` slot of its own for the same reason.
|
|
2591
2931
|
*/
|
|
2592
|
-
async ensureCallsDatabase(context, projectUuid, environment, name, postgresDb, scopeKey, postgresInitdbArgs) {
|
|
2932
|
+
async ensureCallsDatabase(org, context, projectUuid, environment, name, postgresDb, scopeKey, postgresInitdbArgs) {
|
|
2593
2933
|
const existing = context.recordedIds()[scopeKey];
|
|
2594
2934
|
if (existing)
|
|
2595
2935
|
return existing;
|
|
2596
2936
|
const found = (await this.ports.coolify.listDatabases()).find((d) => d.name === name && d.environment_id === environment.id);
|
|
2597
2937
|
if (found)
|
|
2598
2938
|
return found.uuid;
|
|
2599
|
-
const server = await this.resolveServer();
|
|
2939
|
+
const server = await this.resolveServer(org, context);
|
|
2600
2940
|
const created = await this.ports.coolify.createPostgresDatabase({
|
|
2601
2941
|
project_uuid: projectUuid,
|
|
2602
2942
|
server_uuid: server.uuid,
|
|
@@ -2699,6 +3039,34 @@ export class FoundryStepExecutor {
|
|
|
2699
3039
|
async coolifyChat(action, org, context) {
|
|
2700
3040
|
const recorded = context.recordedIds();
|
|
2701
3041
|
if (recorded[`coolify:chat:${action.key}`]) {
|
|
3042
|
+
// #456: `planChat` plans this distinct, non-re-key action for a live
|
|
3043
|
+
// chat app that predates the owner/runtime database split (#239) --
|
|
3044
|
+
// push only the split and redeploy if it changed, never the re-key
|
|
3045
|
+
// paths below, which throw on literally any other action here.
|
|
3046
|
+
if (action.desired.refreshDatabaseSplit === true) {
|
|
3047
|
+
const serviceAppId = recorded[CHAT_SERVICE_SCOPE_KEY];
|
|
3048
|
+
const serviceDbId = recorded[CHAT_SERVICE_DB_SCOPE_KEY];
|
|
3049
|
+
if (!serviceAppId || !serviceDbId) {
|
|
3050
|
+
throw new Error(`coolify:chat database split for ${org.slug}: the chat service app or its database is not recorded yet`);
|
|
3051
|
+
}
|
|
3052
|
+
const push = await this.pushDatabaseUrl(serviceAppId, serviceDbId, 'chat');
|
|
3053
|
+
const markerKey = serviceDbEnvAppliedScopeKey('chat');
|
|
3054
|
+
if (!push.pushed) {
|
|
3055
|
+
// Unlike a fresh provision, this chat was already fully up and
|
|
3056
|
+
// running -- its service database was already started (the main
|
|
3057
|
+
// path below never creates an app before confirming that). A push
|
|
3058
|
+
// refused here means the recorded database id no longer resolves
|
|
3059
|
+
// to a started database with an `internal_db_url`, which is not a
|
|
3060
|
+
// "retry once Coolify catches up" condition: fail loudly instead of
|
|
3061
|
+
// replanning this same step silently forever (#456 review).
|
|
3062
|
+
throw new Error(`coolify:chat database split for ${org.slug}: the chat service's database (${serviceDbId}) has no internal_db_url -- it may have been deleted or is unreachable; this is not expected for an already-live chat`);
|
|
3063
|
+
}
|
|
3064
|
+
if (push.changed || !recorded[markerKey]) {
|
|
3065
|
+
await this.ports.coolify.deploy(serviceAppId);
|
|
3066
|
+
}
|
|
3067
|
+
context.recordScopeId(markerKey, '1');
|
|
3068
|
+
return { resourceId: serviceAppId };
|
|
3069
|
+
}
|
|
2702
3070
|
if (action.desired.usesDomain === false) {
|
|
2703
3071
|
throw new NotBuiltError(`coolify:chat reverse re-key for ${org.slug}`, 'moving its chat back onto the original server_name is not built (#7 amendment 3)');
|
|
2704
3072
|
}
|
|
@@ -2722,13 +3090,13 @@ export class FoundryStepExecutor {
|
|
|
2722
3090
|
await this.ensureChatAdmins(org, zitadelOrgId, project.id, recorded);
|
|
2723
3091
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
2724
3092
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
2725
|
-
const server = await this.resolveServer();
|
|
3093
|
+
const server = await this.resolveServer(org, context);
|
|
2726
3094
|
const names = chatInternalNames(org.slug);
|
|
2727
|
-
const synapseDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.synapseDb, 'synapse', CHAT_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
|
|
3095
|
+
const synapseDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.synapseDb, 'synapse', CHAT_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
|
|
2728
3096
|
context.recordScopeId(CHAT_SYNAPSE_DB_SCOPE_KEY, synapseDbId);
|
|
2729
|
-
const masDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.masDb, 'mas', CHAT_MAS_DB_SCOPE_KEY);
|
|
3097
|
+
const masDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.masDb, 'mas', CHAT_MAS_DB_SCOPE_KEY);
|
|
2730
3098
|
context.recordScopeId(CHAT_MAS_DB_SCOPE_KEY, masDbId);
|
|
2731
|
-
const serviceDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.serviceDb, 'chat', CHAT_SERVICE_DB_SCOPE_KEY);
|
|
3099
|
+
const serviceDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.serviceDb, 'chat', CHAT_SERVICE_DB_SCOPE_KEY);
|
|
2732
3100
|
context.recordScopeId(CHAT_SERVICE_DB_SCOPE_KEY, serviceDbId);
|
|
2733
3101
|
const [synapseDbRow, masDbRow, serviceDbRow] = await Promise.all([
|
|
2734
3102
|
getDatabaseStarted(this.ports.coolify, synapseDbId),
|
|
@@ -2841,8 +3209,13 @@ export class FoundryStepExecutor {
|
|
|
2841
3209
|
agoraHost,
|
|
2842
3210
|
masClientId: secrets.value.masClientId,
|
|
2843
3211
|
masClientSecret: secrets.value.masClientSecret,
|
|
2844
|
-
databaseUrl: serviceDbRow.internal_db_url,
|
|
2845
3212
|
}));
|
|
3213
|
+
// Split the same way `coolifyApp`'s and `cmsSite`'s own databases are
|
|
3214
|
+
// (#239, #456): `DATABASE_URL_OWNER` is the superuser string,
|
|
3215
|
+
// `DATABASE_URL` is a minted `chat_rt` role -- `chat` is the literal
|
|
3216
|
+
// `postgres_db`/`postgres_user` `ensureCallsDatabase` created the service
|
|
3217
|
+
// database with above, not the Coolify resource name `names.serviceDb`.
|
|
3218
|
+
const serviceDbPush = await this.pushDatabaseUrl(service.appId, serviceDbId, 'chat');
|
|
2846
3219
|
// A file-storage write alone never reaches a running container. Deploy an
|
|
2847
3220
|
// app that is new, whose config was freshly minted, or that is not
|
|
2848
3221
|
// running now. The last test reads the live status, not what this run
|
|
@@ -2853,10 +3226,19 @@ export class FoundryStepExecutor {
|
|
|
2853
3226
|
for (const app of [mas, synapse, service]) {
|
|
2854
3227
|
if (!app.existed ||
|
|
2855
3228
|
secrets.changed ||
|
|
3229
|
+
(app === service && serviceDbPush.changed) ||
|
|
2856
3230
|
!isRunningOrStarting((await this.ports.coolify.getApplication(app.appId)).status)) {
|
|
2857
3231
|
await this.ports.coolify.deploy(app.appId);
|
|
2858
3232
|
}
|
|
2859
3233
|
}
|
|
3234
|
+
// #456: recorded here too, not only on the `refreshDatabaseSplit` replan
|
|
3235
|
+
// path above -- without it a fresh provision's very first apply never
|
|
3236
|
+
// sets this marker, so `planChat` (`!recordedIds[serviceDbEnvAppliedScopeKey('chat')]`)
|
|
3237
|
+
// reads it as still missing and plans a pointless extra redeploy on the
|
|
3238
|
+
// very next apply for an org that was never behind.
|
|
3239
|
+
if (serviceDbPush.pushed) {
|
|
3240
|
+
context.recordScopeId(serviceDbEnvAppliedScopeKey('chat'), '1');
|
|
3241
|
+
}
|
|
2860
3242
|
// Last: the marker tells `desiredAgoraEnv` the names and envs are in place.
|
|
2861
3243
|
context.recordScopeId(CHAT_SERVICE_URL_SCOPE_KEY, chatUpstreams(org.slug).chat);
|
|
2862
3244
|
return { resourceId: mas.appId };
|
|
@@ -2904,13 +3286,13 @@ export class FoundryStepExecutor {
|
|
|
2904
3286
|
const synapseHost = `${org.slug}-calls-synapse`;
|
|
2905
3287
|
const masHost = `${org.slug}-calls-mas`;
|
|
2906
3288
|
const dbName = `${name}-db`;
|
|
2907
|
-
const dbId = await this.ensureCallsDatabase(context, projectUuid, environment, dbName, dbName.replace(/-/g, '_'), CALLS_SERVICE_DB_SCOPE_KEY);
|
|
3289
|
+
const dbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, dbName, dbName.replace(/-/g, '_'), CALLS_SERVICE_DB_SCOPE_KEY);
|
|
2908
3290
|
context.recordScopeId(CALLS_SERVICE_DB_SCOPE_KEY, dbId);
|
|
2909
3291
|
const db = await getDatabaseStarted(this.ports.coolify, dbId);
|
|
2910
3292
|
if (!db.internal_db_url) {
|
|
2911
3293
|
throw new Error(`coolify:calls-service for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
2912
3294
|
}
|
|
2913
|
-
const server = await this.resolveServer();
|
|
3295
|
+
const server = await this.resolveServer(org, context);
|
|
2914
3296
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
2915
3297
|
const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
2916
3298
|
project_uuid: projectUuid,
|
|
@@ -2936,7 +3318,6 @@ export class FoundryStepExecutor {
|
|
|
2936
3318
|
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
2937
3319
|
const bindingSecret = await this.ensureCallsBindingSecret(app.appId);
|
|
2938
3320
|
const envs = [
|
|
2939
|
-
{ key: 'DATABASE_URL', value: db.internal_db_url },
|
|
2940
3321
|
{ key: 'LIVEKIT_URL', value: CALLS_SFU_PRIVATE_URL },
|
|
2941
3322
|
{ key: 'LIVEKIT_PUBLIC_URL', value: CALLS_SFU_PUBLIC_URL },
|
|
2942
3323
|
{ key: 'LIVEKIT_API_KEY', value: sfu.apiKey },
|
|
@@ -2975,17 +3356,40 @@ export class FoundryStepExecutor {
|
|
|
2975
3356
|
// unguarded and the planner reports the lost secret instead of minting again.
|
|
2976
3357
|
if (token)
|
|
2977
3358
|
context.recordScopeId(CALLS_INTROSPECTION_SCOPE_KEY, token.appId);
|
|
3359
|
+
// Split the same way `coolifyApp`'s and `cmsSite`'s own databases are
|
|
3360
|
+
// (#239, #456): `DATABASE_URL_OWNER` is the superuser string,
|
|
3361
|
+
// `DATABASE_URL` is a minted `<dbName>_rt` role -- the real Postgres
|
|
3362
|
+
// database name `ensureCallsDatabase` created this database with, not
|
|
3363
|
+
// the Coolify resource name.
|
|
3364
|
+
const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName.replace(/-/g, '_'));
|
|
3365
|
+
// #456: a crash between the push above and the deploy/marker below
|
|
3366
|
+
// leaves the env right and no marker, so this replans and deploys once
|
|
3367
|
+
// more -- the same rule `coolifyApp`'s own `dbEnvAppliedScopeKey` marker
|
|
3368
|
+
// follows.
|
|
3369
|
+
const dbMarkerKey = serviceDbEnvAppliedScopeKey('calls-service');
|
|
3370
|
+
const dbSplitNeedsRefresh = dbPush.pushed && (dbPush.changed || !context.recordedIds()[dbMarkerKey]);
|
|
2978
3371
|
// A container reads its name and env only at start, so a running app is
|
|
2979
3372
|
// restarted once, when this run is the one that gave it its name or its
|
|
2980
3373
|
// token env (the same rule `coolifyApp` follows when an existing app's
|
|
2981
|
-
// env changed).
|
|
3374
|
+
// env changed). The database split is different: it is only safe once the
|
|
3375
|
+
// image actually contains the migrate step that creates the `_rt` role
|
|
3376
|
+
// (the sibling calls-service PR adding it), so that case redeploys --
|
|
3377
|
+
// rebuilding the image -- rather than restarting the one already running,
|
|
3378
|
+
// the same distinction `coolifyChat`'s own refresh path makes. A restart
|
|
3379
|
+
// here would bring back a container built before that migrate step
|
|
3380
|
+
// existed, still pointed at a role nothing has created.
|
|
2982
3381
|
const givesTokenEnv = token !== null && context.recordedIds()[CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY] !== tokenMarker;
|
|
2983
|
-
if (app.existed &&
|
|
3382
|
+
if (app.existed && dbSplitNeedsRefresh) {
|
|
3383
|
+
await this.ports.coolify.deploy(app.appId);
|
|
3384
|
+
}
|
|
3385
|
+
else if (app.existed && (!callsServiceHasName(org, context.recordedIds()) || givesTokenEnv)) {
|
|
2984
3386
|
await this.ports.coolify.restart(app.appId);
|
|
2985
3387
|
}
|
|
2986
3388
|
context.recordScopeId(CALLS_SERVICE_NAME_SCOPE_KEY, name);
|
|
2987
3389
|
if (token && tokenMarker)
|
|
2988
3390
|
context.recordScopeId(CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, tokenMarker);
|
|
3391
|
+
if (dbPush.pushed)
|
|
3392
|
+
context.recordScopeId(dbMarkerKey, '1');
|
|
2989
3393
|
return { resourceId: app.appId };
|
|
2990
3394
|
}
|
|
2991
3395
|
/**
|
|
@@ -3123,6 +3527,19 @@ export class FoundryStepExecutor {
|
|
|
3123
3527
|
* pushed once calls are on and this org's calls service app carries its
|
|
3124
3528
|
* internal name (`desiredAgoraEnv` reads that marker from the recorded ids,
|
|
3125
3529
|
* not the host values).
|
|
3530
|
+
*
|
|
3531
|
+
* #442: a found-but-already-running app's container binds env only at
|
|
3532
|
+
* start, so writing it alone leaves that process on its old config --
|
|
3533
|
+
* restarted (never redeployed: no new image, so `restart` is the same
|
|
3534
|
+
* primitive `ensureLogVolume`'s own restart uses) whenever the live env
|
|
3535
|
+
* actually differs, or whenever the last restart this step confirmed
|
|
3536
|
+
* ({@link AGORA_ACTIVATED_ENV_SCOPE_KEY}) is not this exact env (an org
|
|
3537
|
+
* applied before this existed, whose live env already matched desired
|
|
3538
|
+
* with nothing ever confirmed running it). A brand new app has nothing
|
|
3539
|
+
* running yet to restart. The activation marker is recorded last, after
|
|
3540
|
+
* the restart (if any) returns without throwing, so a failed restart
|
|
3541
|
+
* leaves it stale and the next plan replans this step even though
|
|
3542
|
+
* Coolify's own env store already matches desired.
|
|
3126
3543
|
*/
|
|
3127
3544
|
async coolifyAgora(action, org, context) {
|
|
3128
3545
|
// The host values the plan read, carried in the step: never read again
|
|
@@ -3138,7 +3555,7 @@ export class FoundryStepExecutor {
|
|
|
3138
3555
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
3139
3556
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
3140
3557
|
const name = `${org.slug}-agora`;
|
|
3141
|
-
const server = await this.resolveServer();
|
|
3558
|
+
const server = await this.resolveServer(org, context);
|
|
3142
3559
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
3143
3560
|
const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
3144
3561
|
project_uuid: projectUuid,
|
|
@@ -3155,7 +3572,20 @@ export class FoundryStepExecutor {
|
|
|
3155
3572
|
}));
|
|
3156
3573
|
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
3157
3574
|
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
3575
|
+
// #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
|
|
3576
|
+
// Coolify's own env store matching `env`, which is why that store alone
|
|
3577
|
+
// can no longer tell a changed value from one already live.
|
|
3578
|
+
const liveEnv = new Map((await this.ports.coolify.listEnvs(app.appId))
|
|
3579
|
+
.filter((e) => !e.is_preview)
|
|
3580
|
+
.map((e) => [e.key, e.value]));
|
|
3581
|
+
const envChanged = agoraEnvDrift(env, Object.fromEntries(liveEnv)).length > 0;
|
|
3158
3582
|
await this.ports.coolify.bulkUpsertEnvs(app.appId, Object.entries(env).map(([key, value]) => ({ key, value })));
|
|
3583
|
+
const revision = agoraEnvRevision(env);
|
|
3584
|
+
const alreadyActivated = context.recordedIds()[AGORA_ACTIVATED_ENV_SCOPE_KEY] === revision;
|
|
3585
|
+
if (app.existed && (envChanged || !alreadyActivated)) {
|
|
3586
|
+
await this.ports.coolify.restart(app.appId);
|
|
3587
|
+
}
|
|
3588
|
+
context.recordScopeId(AGORA_ACTIVATED_ENV_SCOPE_KEY, revision);
|
|
3159
3589
|
return { resourceId: app.appId };
|
|
3160
3590
|
}
|
|
3161
3591
|
// ---- boule (#161) --------------------------------------------------------
|
|
@@ -3195,9 +3625,9 @@ export class FoundryStepExecutor {
|
|
|
3195
3625
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
3196
3626
|
const name = `${org.slug}-boule`;
|
|
3197
3627
|
const dbName = `${org.slug}-boule-db`.replace(/-/g, '_');
|
|
3198
|
-
const dbId = await this.ensureCallsDatabase(context, projectUuid, environment, `${org.slug}-boule-db`, dbName, BOULE_DATABASE_SCOPE_KEY);
|
|
3628
|
+
const dbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, `${org.slug}-boule-db`, dbName, BOULE_DATABASE_SCOPE_KEY);
|
|
3199
3629
|
context.recordScopeId(BOULE_DATABASE_SCOPE_KEY, dbId);
|
|
3200
|
-
const server = await this.resolveServer();
|
|
3630
|
+
const server = await this.resolveServer(org, context);
|
|
3201
3631
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
3202
3632
|
const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
3203
3633
|
project_uuid: projectUuid,
|
|
@@ -3409,7 +3839,7 @@ export class FoundryStepExecutor {
|
|
|
3409
3839
|
appName: `${org.slug}-cloudflared`,
|
|
3410
3840
|
projectUuid,
|
|
3411
3841
|
environment,
|
|
3412
|
-
resolveServer: () => this.resolveServer(),
|
|
3842
|
+
resolveServer: () => this.resolveServer(org, context),
|
|
3413
3843
|
recordedConnectorAppId: context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
3414
3844
|
recordAppId: async (appId) => {
|
|
3415
3845
|
context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, appId);
|
|
@@ -3783,22 +4213,26 @@ export class FoundryStepExecutor {
|
|
|
3783
4213
|
throw new Error(`coolify:mail for ${org.slug}: this step's plan carries no platform mail ${!apex && !mailHost ? 'apex or host' : !apex ? 'apex' : 'host'}; plan this org's mail again`);
|
|
3784
4214
|
}
|
|
3785
4215
|
const options = { apex, mailHost };
|
|
4216
|
+
// #491: `null` unless `diff.ts`'s `planMail` carries them -- absent for
|
|
4217
|
+
// every org but the platform's own, and for a plan predating this field.
|
|
4218
|
+
const platformApex = action.desired.platformApex ?? null;
|
|
4219
|
+
const platformLettermintProjectId = action.desired.platformLettermintProjectId ?? null;
|
|
3786
4220
|
if (mode === 'off') {
|
|
3787
4221
|
const parkedMailOrg = mailOrgFor(org, usesDomain, 'hosted');
|
|
3788
4222
|
await this.ports.mail.parkOrgMail(parkedMailOrg, options);
|
|
3789
|
-
await this.parkLettermintMail(org, parkedMailOrg, context);
|
|
4223
|
+
await this.parkLettermintMail(org, parkedMailOrg, context, platformApex, platformLettermintProjectId);
|
|
3790
4224
|
return undefined;
|
|
3791
4225
|
}
|
|
3792
4226
|
const mailOrg = mailOrgFor(org, usesDomain, mode);
|
|
3793
4227
|
const result = await this.ports.mail.applyOrgMail(mailOrg, options);
|
|
3794
4228
|
await this.publishMailDnsRecords(org, result);
|
|
3795
|
-
await this.deleteStaleLettermintDomain(context, result.domain);
|
|
4229
|
+
await this.deleteStaleLettermintDomain(context, result.domain, platformApex);
|
|
3796
4230
|
context.recordScopeId(MAIL_DOMAIN_SCOPE_KEY, result.domain);
|
|
3797
4231
|
// #177: what this step wrote from the platform, so `planMail` can see a later change.
|
|
3798
4232
|
context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
|
|
3799
4233
|
context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
|
|
3800
4234
|
await this.ensureOwnerMailbox(org, mailOrg, options, context);
|
|
3801
|
-
await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context);
|
|
4235
|
+
await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context, platformApex, platformLettermintProjectId);
|
|
3802
4236
|
await this.ensureRoleMailboxes(org, mailOrg, result.domain, options, context);
|
|
3803
4237
|
return { resourceId: result.domain };
|
|
3804
4238
|
}
|
|
@@ -3894,11 +4328,20 @@ export class FoundryStepExecutor {
|
|
|
3894
4328
|
* domain recorded) and on a same-domain re-apply (compared
|
|
3895
4329
|
* case-insensitively, the same rule {@link findExactLettermintDomain}
|
|
3896
4330
|
* applies to the lookup itself).
|
|
4331
|
+
*
|
|
4332
|
+
* #491 review: also a no-op when the previous domain is the platform's own
|
|
4333
|
+
* apex -- an org that moves its mail *off* that domain (`usesDomain` flips,
|
|
4334
|
+
* or `org.domain` changes) must never delete it here, the same protection
|
|
4335
|
+
* {@link parkLettermintMail} already gives the apex on a move to `off`.
|
|
4336
|
+
* `planMail` only blocks a move *onto* the apex (no project id yet); a move
|
|
4337
|
+
* away from it reaches this method with nothing to block it otherwise.
|
|
3897
4338
|
*/
|
|
3898
|
-
async deleteStaleLettermintDomain(context, newDomain) {
|
|
4339
|
+
async deleteStaleLettermintDomain(context, newDomain, platformApex) {
|
|
3899
4340
|
const previous = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
3900
4341
|
if (!previous || previous.toLowerCase() === newDomain.toLowerCase())
|
|
3901
4342
|
return;
|
|
4343
|
+
if (platformApex && previous.toLowerCase() === platformApex.toLowerCase())
|
|
4344
|
+
return;
|
|
3902
4345
|
const stale = await this.findExactLettermintDomain(previous);
|
|
3903
4346
|
if (stale)
|
|
3904
4347
|
await this.ports.lettermint.deleteDomain(stale.id);
|
|
@@ -3970,20 +4413,28 @@ export class FoundryStepExecutor {
|
|
|
3970
4413
|
* A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
|
|
3971
4414
|
* nothing was ever provisioned for an org whose mail went straight from
|
|
3972
4415
|
* never-applied to off.
|
|
4416
|
+
*
|
|
4417
|
+
* #491: when `mailDomain` is the platform's own apex, this must never
|
|
4418
|
+
* delete the platform's own project (the delete loop skips it by id) or
|
|
4419
|
+
* the platform's own domain (returns before the domain lookup/delete).
|
|
3973
4420
|
*/
|
|
3974
|
-
async parkLettermintMail(org, mailOrg, context) {
|
|
4421
|
+
async parkLettermintMail(org, mailOrg, context, platformApex, platformLettermintProjectId) {
|
|
3975
4422
|
const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
3976
4423
|
if (!mailDomain)
|
|
3977
4424
|
return;
|
|
4425
|
+
const isPlatformApexOrg = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
|
|
3978
4426
|
// #345: `mailOrg.domain` is absent for a level-1 org -- the previously
|
|
3979
4427
|
// recorded mail domain is the one `MailPort.removeOrgRelay` needs.
|
|
3980
4428
|
await this.ports.mail.removeOrgRelay({ ...mailOrg, domain: mailDomain });
|
|
3981
4429
|
const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
|
|
3982
4430
|
const projects = await this.ports.lettermint.listProjects();
|
|
3983
4431
|
for (const project of projects) {
|
|
3984
|
-
if (names.has(project.name))
|
|
4432
|
+
if (names.has(project.name) && project.id !== platformLettermintProjectId) {
|
|
3985
4433
|
await this.ports.lettermint.deleteProject(project.id);
|
|
4434
|
+
}
|
|
3986
4435
|
}
|
|
4436
|
+
if (isPlatformApexOrg)
|
|
4437
|
+
return;
|
|
3987
4438
|
const domain = await this.findExactLettermintDomain(mailDomain);
|
|
3988
4439
|
if (domain)
|
|
3989
4440
|
await this.ports.lettermint.deleteDomain(domain.id);
|
|
@@ -4029,11 +4480,11 @@ export class FoundryStepExecutor {
|
|
|
4029
4480
|
}
|
|
4030
4481
|
// ---- files / ai --------------------------------------------------------
|
|
4031
4482
|
/**
|
|
4032
|
-
* #80: provisions the org's own organisation on Files
|
|
4033
|
-
*
|
|
4034
|
-
*
|
|
4035
|
-
*
|
|
4036
|
-
*
|
|
4483
|
+
* #80: provisions the org's own organisation on Files.
|
|
4484
|
+
* `provisionOrganisation` is safely repeatable on its own
|
|
4485
|
+
* (`FilesOrganisationPort`'s doc comment), so this is called every time
|
|
4486
|
+
* regardless of whether it already ran -- idempotent by find-or-create,
|
|
4487
|
+
* not by checking `recordedIds` first.
|
|
4037
4488
|
*
|
|
4038
4489
|
* F6b: `action.desired.shareHost` (`diff.ts`'s `planFilesOrganisation`) is
|
|
4039
4490
|
* passed through every time, even though `entry-files-key.ts` reports the
|
|
@@ -4046,15 +4497,23 @@ export class FoundryStepExecutor {
|
|
|
4046
4497
|
* repeat-is-a-no-op shape as `provisionOrganisation` itself -- every org
|
|
4047
4498
|
* gets an Agora (#164), so `diff.ts`'s `planFilesOrganisation` always sets
|
|
4048
4499
|
* `desired.agoraOrigin`.
|
|
4500
|
+
*
|
|
4501
|
+
* #437: the owner member/name, the core ZITADEL files project and the core
|
|
4502
|
+
* collection (`core-files.ts`) are no longer conditioned on a hosting entry
|
|
4503
|
+
* declaring `uses.files` -- the core bundle (Agora's own uploads) needs
|
|
4504
|
+
* all three just as much as a bespoke entry does, and withholding them
|
|
4505
|
+
* until one existed is why a plain Agora/Boule org never got a usable
|
|
4506
|
+
* Files setup at all. Both the project and the collection are idempotent
|
|
4507
|
+
* by name, so a re-run that already created either finds it instead.
|
|
4049
4508
|
*/
|
|
4050
4509
|
async filesOrganisation(action, org, context) {
|
|
4051
4510
|
const files = await this.filesPort();
|
|
4052
|
-
|
|
4053
|
-
// organisation
|
|
4054
|
-
//
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4511
|
+
const recorded = context.recordedIds();
|
|
4512
|
+
// F6b: an organisation takes an owner member and name only while it is
|
|
4513
|
+
// being created, so it is passed whenever the owner is known now --
|
|
4514
|
+
// every org, not only one with a hosting entry declaring `uses.files`
|
|
4515
|
+
// (#437).
|
|
4516
|
+
const owner = filesOwnerUserId(org, recorded);
|
|
4058
4517
|
const provisioned = await files.provisionOrganisation({
|
|
4059
4518
|
organisationId: org.id,
|
|
4060
4519
|
ceilingBytes: action.desired.ceilingBytes ?? null,
|
|
@@ -4071,6 +4530,22 @@ export class FoundryStepExecutor {
|
|
|
4071
4530
|
if (provisioned.agoraKey) {
|
|
4072
4531
|
await this.deliverFilesAgoraKey(files, org, provisioned.agoraKey, context);
|
|
4073
4532
|
}
|
|
4533
|
+
// #437: the audience Agora requests to read/browse Files
|
|
4534
|
+
// (`agoras.ts`'s `filesProjectId`), the same `urn:zitadel:iam:org:
|
|
4535
|
+
// project:id:<id>:aud` shape `messagingProjectId` already uses. Retried
|
|
4536
|
+
// on a later apply, same as the owner above, while the org's ZITADEL
|
|
4537
|
+
// org id is not recorded yet (`zitadel:issuer` has not run).
|
|
4538
|
+
const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
4539
|
+
if (zitadelOrgId) {
|
|
4540
|
+
const { project } = await this.ports.zitadel.findOrCreateProject(zitadelOrgId, `${org.slug}-files`);
|
|
4541
|
+
context.recordScopeId(ZITADEL_FILES_PROJECT_SCOPE_KEY, project.id);
|
|
4542
|
+
}
|
|
4543
|
+
// #437: the one collection every file Agora itself uploads goes into
|
|
4544
|
+
// (`agoras.ts`'s `filesCollectionId`) -- Files has no route to list an
|
|
4545
|
+
// organisation's collections and an ordinary member cannot create one,
|
|
4546
|
+
// so it has to exist before Agora ever tries.
|
|
4547
|
+
const collection = await files.ensureCollection(org.id, CORE_FILES_COLLECTION_NAME);
|
|
4548
|
+
context.recordScopeId(FILES_CORE_COLLECTION_SCOPE_KEY, collection.id);
|
|
4074
4549
|
return { resourceId: provisioned.organisationId };
|
|
4075
4550
|
}
|
|
4076
4551
|
/**
|
|
@@ -4107,12 +4582,6 @@ export class FoundryStepExecutor {
|
|
|
4107
4582
|
context.recordScopeId(FILES_AGORA_UNDELIVERED_SCOPE_KEY, '');
|
|
4108
4583
|
throw new Error(`${at}: Agora's key ${key.id} could not be delivered after ${AI_KEY_DELIVERY_ATTEMPTS} attempts (${cause}); ${revoked ? 'it was revoked again' : 'revoke it in files'}`);
|
|
4109
4584
|
}
|
|
4110
|
-
async filesStorage(action, org) {
|
|
4111
|
-
const files = await this.filesPort();
|
|
4112
|
-
await files.setQuota(org.id, action.desired.limitBytes ?? null);
|
|
4113
|
-
await files.setBudget(org.id, action.desired.limitMicros ?? null);
|
|
4114
|
-
return { resourceId: org.id };
|
|
4115
|
-
}
|
|
4116
4585
|
/**
|
|
4117
4586
|
* #80: provisions the org's own organisation on AI and switches its AI use
|
|
4118
4587
|
* on, before {@link aiBudget} can set its own spend limit. Both
|
|
@@ -4130,13 +4599,14 @@ export class FoundryStepExecutor {
|
|
|
4130
4599
|
return { resourceId: provisioned.organisationId };
|
|
4131
4600
|
}
|
|
4132
4601
|
async aiBudget(action, org) {
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4602
|
+
// #484: ai/packages/sdk/src/types.ts:134-137's UpdateBudget.limitMicros
|
|
4603
|
+
// is a required string -- there is no "uncapped" this API can express
|
|
4604
|
+
// yet (Finding, this campaign's report) -- so a null `desired.limitMicros`
|
|
4605
|
+
// (an org that declares no limit) gets DEFAULT_AI_LIMIT_MICROS here too,
|
|
4606
|
+
// the same substitution diff.ts's planners already make when they fill
|
|
4607
|
+
// this field. Belt and braces: by the time a plan reaches here,
|
|
4608
|
+
// `action.desired.limitMicros` should already be non-null.
|
|
4609
|
+
const limitMicros = action.desired.limitMicros ?? DEFAULT_AI_LIMIT_MICROS;
|
|
4140
4610
|
const ai = await this.aiPort();
|
|
4141
4611
|
await ai.updateBudget(org.id, { keyId: null, limitMicros });
|
|
4142
4612
|
return { resourceId: org.id };
|
|
@@ -4345,6 +4815,165 @@ export class FoundryStepExecutor {
|
|
|
4345
4815
|
ids.push(keyId);
|
|
4346
4816
|
context.recordScopeId(listKey, JSON.stringify(ids));
|
|
4347
4817
|
}
|
|
4818
|
+
/**
|
|
4819
|
+
* F7 (#317): mints one entry's names key directly below the org (names
|
|
4820
|
+
* takes no separate minter key id, unlike ai/files), writes `NAMES_URL`
|
|
4821
|
+
* and `NAMES_API_KEY` to the entry's app env, then restarts it. Retires
|
|
4822
|
+
* the key when `uses.names` is removed. Follows the "one mint" rule
|
|
4823
|
+
* (CLAUDE.md "Platform steps"): the id is recorded as undelivered and
|
|
4824
|
+
* persisted before delivery is attempted, so a crash or a failed write
|
|
4825
|
+
* never leads to a second mint -- `planEntryNamesKeys` blocks instead.
|
|
4826
|
+
*/
|
|
4827
|
+
async namesEntryKey(action, org, context) {
|
|
4828
|
+
const entryKey = action.key.slice(org.id.length + 1);
|
|
4829
|
+
const at = `names:key for ${org.slug} entry '${entryKey}'`;
|
|
4830
|
+
const names = this.ports.names;
|
|
4831
|
+
if (!names)
|
|
4832
|
+
throw new Error(`${at}: this executor was built without a NamesPort (#317)`);
|
|
4833
|
+
const desired = action.desired;
|
|
4834
|
+
if (desired.mode === 'retire') {
|
|
4835
|
+
const recorded = context.recordedIds();
|
|
4836
|
+
const mainId = recorded[`names:key:${action.key}`];
|
|
4837
|
+
// A key minted but never delivered never gets a main id (the mint
|
|
4838
|
+
// step throws before `runSteps` sees a `resourceId`), so it can
|
|
4839
|
+
// coexist with a separate, already-delivered id from an earlier mint
|
|
4840
|
+
// of the same entry: mint A, deliver, retire (main id A, retired
|
|
4841
|
+
// marker A); re-declare, mint B, fail delivery three times
|
|
4842
|
+
// (undelivered B, main id still A); undeclare again. The old `||`
|
|
4843
|
+
// here always picked A (truthy), leaving B live for up to 365 days
|
|
4844
|
+
// with nothing recorded (#317 re-review) -- both ids are revoked,
|
|
4845
|
+
// independently, whenever both are present.
|
|
4846
|
+
const undeliveredId = recorded[entryNamesUndeliveredScopeKey(entryKey)];
|
|
4847
|
+
const idsToRevoke = [...new Set([mainId, undeliveredId].filter((id) => !!id))];
|
|
4848
|
+
const keyId = mainId || undeliveredId;
|
|
4849
|
+
// Revoke first: the key going dead in names is what retire is for,
|
|
4850
|
+
// and the one thing that must still happen even when the entry's app
|
|
4851
|
+
// is itself already gone. Only once that has succeeded (or 404'd as
|
|
4852
|
+
// already gone) is the env cleanup attempted, itself tolerant of a
|
|
4853
|
+
// 404 on a listEnvs/deleteEnv against an app that no longer exists --
|
|
4854
|
+
// doing this the other way round meant an app already torn down (its
|
|
4855
|
+
// id still recorded) threw on `listEnvs` before `revokeKey` ever ran,
|
|
4856
|
+
// stranding a live key on every retry (#317 review).
|
|
4857
|
+
for (const id of idsToRevoke) {
|
|
4858
|
+
try {
|
|
4859
|
+
await names.revokeKey(org.id, id);
|
|
4860
|
+
}
|
|
4861
|
+
catch (error) {
|
|
4862
|
+
// A 404 means it is already gone -- recorded as retired below,
|
|
4863
|
+
// the same as a successful revoke. Anything else is a real
|
|
4864
|
+
// failure: thrown, not swallowed, so the key stays live and the
|
|
4865
|
+
// planner retires it again on the next apply instead of this
|
|
4866
|
+
// marking it done.
|
|
4867
|
+
if (!isNotFoundError(error))
|
|
4868
|
+
throw error;
|
|
4869
|
+
}
|
|
4870
|
+
}
|
|
4871
|
+
const appId = recorded[`coolify:app:${action.key}`];
|
|
4872
|
+
if (appId) {
|
|
4873
|
+
// A revoked key's env values are dead, not merely stale -- remove
|
|
4874
|
+
// them so nothing reads a credential names has already revoked. A
|
|
4875
|
+
// 404 on either call means that app or that env is already gone:
|
|
4876
|
+
// nothing to clean up there, and the key is already revoked above,
|
|
4877
|
+
// so it is not a reason to fail the step. Each delete tolerates its
|
|
4878
|
+
// own 404, so one env already gone does not leave the other behind.
|
|
4879
|
+
const tolerateNotFound = async (call, gone) => {
|
|
4880
|
+
try {
|
|
4881
|
+
return await call();
|
|
4882
|
+
}
|
|
4883
|
+
catch (error) {
|
|
4884
|
+
if (!isNotFoundError(error))
|
|
4885
|
+
throw error;
|
|
4886
|
+
return gone;
|
|
4887
|
+
}
|
|
4888
|
+
};
|
|
4889
|
+
const envs = await tolerateNotFound(() => this.ports.coolify.listEnvs(appId), []);
|
|
4890
|
+
for (const envKey of ['NAMES_URL', 'NAMES_API_KEY']) {
|
|
4891
|
+
const env = envs.find((e) => e.key === envKey && !e.is_preview);
|
|
4892
|
+
if (env?.uuid) {
|
|
4893
|
+
const uuid = env.uuid;
|
|
4894
|
+
await tolerateNotFound(() => this.ports.coolify.deleteEnv(appId, uuid), undefined);
|
|
4895
|
+
}
|
|
4896
|
+
}
|
|
4897
|
+
}
|
|
4898
|
+
context.recordScopeId(entryNamesUndeliveredScopeKey(entryKey), '');
|
|
4899
|
+
context.recordScopeId(entryNamesMintedAtScopeKey(entryKey), '');
|
|
4900
|
+
if (!keyId)
|
|
4901
|
+
return {};
|
|
4902
|
+
context.recordScopeId(entryNamesRetiredScopeKey(entryKey), keyId);
|
|
4903
|
+
return {};
|
|
4904
|
+
}
|
|
4905
|
+
const entry = org.hosting.entries.find((e) => e.key === entryKey && e.uses?.names);
|
|
4906
|
+
if (!entry)
|
|
4907
|
+
throw new Error(`${at}: the entry no longer declares uses.names`);
|
|
4908
|
+
const appId = context.recordedIds()[`coolify:app:${action.key}`];
|
|
4909
|
+
if (!appId)
|
|
4910
|
+
throw new Error(`${at}: the entry's app is not recorded yet`);
|
|
4911
|
+
if (!org.domain) {
|
|
4912
|
+
throw new Error(`${at}: names needs org.domain, and validateEntryNames already refuses it without one`);
|
|
4913
|
+
}
|
|
4914
|
+
// A key left over from a previous mint whose delivery never succeeded
|
|
4915
|
+
// is revoked first, not left blocking every later apply forever:
|
|
4916
|
+
// nothing here keeps its secret around to retry delivering it
|
|
4917
|
+
// (CLAUDE.md "No secret leaves"), so the only way to actually recover
|
|
4918
|
+
// is to revoke the stale id and mint a fresh one -- a 404 counts as
|
|
4919
|
+
// already revoked, same as any other retire (#317 re-review).
|
|
4920
|
+
const staleUndelivered = context.recordedIds()[entryNamesUndeliveredScopeKey(entryKey)];
|
|
4921
|
+
if (staleUndelivered) {
|
|
4922
|
+
try {
|
|
4923
|
+
await names.revokeKey(org.id, staleUndelivered);
|
|
4924
|
+
}
|
|
4925
|
+
catch (error) {
|
|
4926
|
+
if (!isNotFoundError(error))
|
|
4927
|
+
throw error;
|
|
4928
|
+
}
|
|
4929
|
+
context.recordScopeId(entryNamesUndeliveredScopeKey(entryKey), '');
|
|
4930
|
+
await context.persistScopeIds();
|
|
4931
|
+
}
|
|
4932
|
+
const minted = await names.mintKey({
|
|
4933
|
+
organisationId: org.id,
|
|
4934
|
+
domain: org.domain,
|
|
4935
|
+
name: desired.name,
|
|
4936
|
+
expiresDays: NAMES_ENTRY_KEY_DAYS,
|
|
4937
|
+
});
|
|
4938
|
+
context.recordScopeId(entryNamesUndeliveredScopeKey(entryKey), minted.id);
|
|
4939
|
+
await context.persistScopeIds();
|
|
4940
|
+
const envs = [
|
|
4941
|
+
{ key: 'NAMES_URL', value: desired.namesUrl, literal: true },
|
|
4942
|
+
{ key: 'NAMES_API_KEY', value: minted.secret, literal: true },
|
|
4943
|
+
];
|
|
4944
|
+
let lastError;
|
|
4945
|
+
for (let attempt = 1; attempt <= ENTRY_NAMES_KEY_DELIVERY_ATTEMPTS; attempt++) {
|
|
4946
|
+
try {
|
|
4947
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
4948
|
+
await this.ports.coolify.restart(appId);
|
|
4949
|
+
context.recordScopeId(entryNamesUndeliveredScopeKey(entryKey), '');
|
|
4950
|
+
// The only trace of this key's age, since nothing here renews it
|
|
4951
|
+
// (#317 review's warning-row ask reads this to flag an expiring key).
|
|
4952
|
+
context.recordScopeId(entryNamesMintedAtScopeKey(entryKey), new Date(context.now?.() ?? Date.now()).toISOString());
|
|
4953
|
+
return { resourceId: minted.id };
|
|
4954
|
+
}
|
|
4955
|
+
catch (e) {
|
|
4956
|
+
lastError = e;
|
|
4957
|
+
}
|
|
4958
|
+
}
|
|
4959
|
+
// 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 ${ENTRY_NAMES_KEY_DELIVERY_ATTEMPTS} attempts (${lastError instanceof Error ? lastError.name : 'error'}); the next apply revokes it and mints a fresh one automatically`);
|
|
4961
|
+
}
|
|
4962
|
+
/**
|
|
4963
|
+
* F7 (#317): `plan/entry-names-key.ts`'s `planNamesReservedLabels` --
|
|
4964
|
+
* tells the names service every label this org's own entries already
|
|
4965
|
+
* hold, so names refuses a customer claiming one of them.
|
|
4966
|
+
*/
|
|
4967
|
+
async namesReservedLabels(action, org, context) {
|
|
4968
|
+
const names = this.ports.names;
|
|
4969
|
+
if (!names) {
|
|
4970
|
+
throw new Error(`names:reserved-labels for ${org.slug}: this executor was built without a NamesPort (#317)`);
|
|
4971
|
+
}
|
|
4972
|
+
const labels = action.desired.labels;
|
|
4973
|
+
await names.setReservedLabels(org.id, labels);
|
|
4974
|
+
context.recordScopeId(NAMES_RESERVED_LABELS_APPLIED_SCOPE_KEY, JSON.stringify(labels));
|
|
4975
|
+
return {};
|
|
4976
|
+
}
|
|
4348
4977
|
/**
|
|
4349
4978
|
* F6b: mints or renews the org's own files key, or revokes the ones a
|
|
4350
4979
|
* renewal replaced. The key is minted below the files organisation's owner
|
|
@@ -4745,6 +5374,45 @@ export class FoundryStepExecutor {
|
|
|
4745
5374
|
await files.revokeOrganisationKey(org.id, target.id);
|
|
4746
5375
|
return;
|
|
4747
5376
|
}
|
|
5377
|
+
case 'cloudflare:worker': {
|
|
5378
|
+
// #318: an entry's edge Worker (`<orgId>:<entry>`) and its route.
|
|
5379
|
+
// No port means no way to delete either -- thrown, not silently
|
|
5380
|
+
// skipped, so it is reported blocked rather than left live with
|
|
5381
|
+
// nothing recorded ids point at any more.
|
|
5382
|
+
const workers = this.ports.workers;
|
|
5383
|
+
if (!workers) {
|
|
5384
|
+
throw new Error(`cloudflare:worker '${target.key}' for ${org.slug}: this executor was built without a WorkersPort (#318); cannot delete it`);
|
|
5385
|
+
}
|
|
5386
|
+
// The route on its recorded zone (skipped only once that zone is
|
|
5387
|
+
// positively gone), any route an interrupted apply created but
|
|
5388
|
+
// never recorded, then the script -- the same teardown retire runs.
|
|
5389
|
+
// `target.id` is `''` for a route-only partial deploy.
|
|
5390
|
+
const entryKey = target.key.slice(org.id.length + 1);
|
|
5391
|
+
await this.teardownEdgeWorker(entryKey, target.id || undefined, recordedIds, workers, `cloudflare:worker '${target.key}' for ${org.slug}`);
|
|
5392
|
+
return;
|
|
5393
|
+
}
|
|
5394
|
+
case 'names:key': {
|
|
5395
|
+
// F7 (#317): an entry's key (`<orgId>:<entry>`); names has no org-root key.
|
|
5396
|
+
// No port means no way to revoke it -- thrown, not silently
|
|
5397
|
+
// skipped, so the key is reported blocked rather than left live
|
|
5398
|
+
// with nothing saying so.
|
|
5399
|
+
const names = this.ports.names;
|
|
5400
|
+
if (!names) {
|
|
5401
|
+
throw new Error(`names:key '${target.key}' for ${org.slug}: this executor was built without a NamesPort (#317); cannot revoke it`);
|
|
5402
|
+
}
|
|
5403
|
+
try {
|
|
5404
|
+
await names.revokeKey(org.id, target.id);
|
|
5405
|
+
}
|
|
5406
|
+
catch (error) {
|
|
5407
|
+
// Already gone -- the same already-retired key a retire step
|
|
5408
|
+
// handled before org destroy ever ran can still reach here
|
|
5409
|
+
// (#317 review); a 404 is success, not a reason to block
|
|
5410
|
+
// teardown forever.
|
|
5411
|
+
if (!isNotFoundError(error))
|
|
5412
|
+
throw error;
|
|
5413
|
+
}
|
|
5414
|
+
return;
|
|
5415
|
+
}
|
|
4748
5416
|
case 'coolify:app':
|
|
4749
5417
|
await this.ports.coolify.deleteApplication(target.id);
|
|
4750
5418
|
return;
|