@wtfalch/foundry 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/declare.js +6 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/migrations/0043_platform_names.sql +25 -0
- package/dist/migrations/0044_platform_mail_lettermint_project.sql +11 -0
- package/dist/migrations/0045_names_to_edge.sql +107 -0
- package/dist/org.d.ts +74 -3
- package/dist/org.js +133 -0
- package/dist/plan/chat-matrix.d.ts +0 -1
- package/dist/plan/chat-matrix.js +4 -1
- package/dist/plan/diff.d.ts +18 -0
- package/dist/plan/diff.js +239 -26
- package/dist/plan/entry-edge-key.d.ts +79 -0
- package/dist/plan/entry-edge-key.js +260 -0
- package/dist/plan/executor.d.ts +246 -1
- package/dist/plan/executor.js +684 -32
- package/dist/plan/index.d.ts +2 -2
- package/dist/plan/index.js +1 -1
- package/dist/plan/live.d.ts +4 -2
- package/dist/plan/login-host.d.ts +12 -1
- package/dist/plan/login-host.js +20 -2
- package/dist/plan/plan-apply.d.ts +15 -0
- package/dist/plan/plan-apply.js +102 -9
- package/dist/plan/platform-diff.d.ts +92 -1
- package/dist/plan/platform-diff.js +166 -1
- package/dist/plan/platform-org.d.ts +29 -1
- package/dist/plan/platform-org.js +17 -2
- package/dist/plan/platform-providers.d.ts +45 -1
- package/dist/plan/platform-providers.js +514 -7
- package/dist/plan/step-system.js +1 -0
- package/dist/plan/worker.d.ts +134 -0
- package/dist/plan/worker.js +239 -0
- package/dist/platform.d.ts +22 -3
- package/dist/platform.js +6 -0
- package/dist/store/postgres.js +15 -1
- package/dist/store/schema.d.ts +70 -2
- package/dist/store/schema.js +6 -0
- package/package.json +1 -1
package/dist/plan/executor.js
CHANGED
|
@@ -9,14 +9,17 @@ import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, cal
|
|
|
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
|
+
import { DEFAULT_AI_LIMIT_MICROS } from './diff.js';
|
|
12
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';
|
|
13
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';
|
|
14
16
|
import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
15
17
|
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
16
18
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
17
|
-
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';
|
|
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';
|
|
18
20
|
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
19
21
|
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
|
|
22
|
+
import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
|
|
20
23
|
/**
|
|
21
24
|
* #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
|
|
22
25
|
* listApps` -- a plain array, with no `loginBaseUri` or completeness signal
|
|
@@ -505,11 +508,21 @@ const CALLS_MAS_DB_SCOPE_KEY = 'coolify_calls_mas_database';
|
|
|
505
508
|
* Synapse refuses to start against a database whose `LC_COLLATE`/`LC_CTYPE`
|
|
506
509
|
* is not `C` (https://element-hq.github.io/synapse/latest/postgres.html);
|
|
507
510
|
* Coolify's official postgres image otherwise creates one with `en_US.utf8`.
|
|
508
|
-
* `
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
511
|
+
* `createPostgresDatabase` forwards this to Coolify's own
|
|
512
|
+
* `postgres_initdb_args` field -- but Coolify stores that field and reads it
|
|
513
|
+
* back unchanged without ever handing it to `initdb`: its Postgres start
|
|
514
|
+
* action builds the container's env from `postgres_user`/`postgres_password`/
|
|
515
|
+
* `postgres_db` and runtime envs only
|
|
516
|
+
* (https://github.com/coollabsio/coolify/blob/main/app/Actions/Database/StartPostgresql.php).
|
|
517
|
+
* A database created this way still comes up `en_US.utf8` and crash-loops
|
|
518
|
+
* every Synapse app that reads it; live evidence and the real fix (not yet
|
|
519
|
+
* chosen) are tracked in #494. This constant and the field it feeds stay in
|
|
520
|
+
* place, inert, until that lands -- collation cannot be migrated onto an
|
|
521
|
+
* existing database under any of the candidate fixes either. MAS has no
|
|
522
|
+
* such requirement, so this is passed for the Synapse database only. Not
|
|
523
|
+
* using Synapse's own `allow_unsafe_locale` escape hatch regardless of
|
|
524
|
+
* which fix lands: that trades a loud startup refusal for silent index
|
|
525
|
+
* corruption after a future glibc upgrade.
|
|
513
526
|
*/
|
|
514
527
|
const CALLS_SYNAPSE_DB_INITDB_ARGS = '--locale=C --encoding=UTF8';
|
|
515
528
|
/**
|
|
@@ -657,6 +670,8 @@ export class FoundryStepExecutor {
|
|
|
657
670
|
return this.cloudflareAccess(action, org);
|
|
658
671
|
case 'cloudflare:ratelimit':
|
|
659
672
|
return this.cloudflareRateLimit(org);
|
|
673
|
+
case 'cloudflare:worker':
|
|
674
|
+
return this.cloudflareWorker(action, org, context);
|
|
660
675
|
case 'files:organisation':
|
|
661
676
|
return this.filesOrganisation(action, org, context);
|
|
662
677
|
case 'files:key':
|
|
@@ -673,6 +688,10 @@ export class FoundryStepExecutor {
|
|
|
673
688
|
return action.key.includes(':')
|
|
674
689
|
? this.aiEntryKey(action, org, context)
|
|
675
690
|
: this.aiKey(action, org, context);
|
|
691
|
+
case 'edge:key':
|
|
692
|
+
return this.edgeEntryKey(action, org, context);
|
|
693
|
+
case 'edge:reserved-labels':
|
|
694
|
+
return this.edgeReservedLabels(action, org, context);
|
|
676
695
|
case 'coolify:mail':
|
|
677
696
|
return this.coolifyMail(action, org, context);
|
|
678
697
|
case 'coolify:mail-copy':
|
|
@@ -840,6 +859,250 @@ export class FoundryStepExecutor {
|
|
|
840
859
|
}
|
|
841
860
|
return { resourceId: created.id };
|
|
842
861
|
}
|
|
862
|
+
/**
|
|
863
|
+
* `plan/diff.ts`'s `planWorkers` (#318, F8): deploys one entry's
|
|
864
|
+
* `worker` Worker and routes it on the org's own zone. Throws by name
|
|
865
|
+
* without a {@link WorkersPort}, the same `jobsRegistration` shape --
|
|
866
|
+
* `planWorkers` reports the step blocked instead while the host has
|
|
867
|
+
* none, so this only fires against a stale plan. The route is checked
|
|
868
|
+
* before upload: a foreign route already on the pattern is refused, never
|
|
869
|
+
* adopted or replaced (`cloudflareAccess`'s own precedent for a foreign
|
|
870
|
+
* resource on the same name), so a Worker is never deployed only to find
|
|
871
|
+
* nothing can route to it.
|
|
872
|
+
*
|
|
873
|
+
* Order on apply: record (and persist) the uploaded name and clear the
|
|
874
|
+
* retired marker, upload, record the pending route, create the route only
|
|
875
|
+
* if needed, delete any *previous* route this entry recorded on its own
|
|
876
|
+
* zone, then record the new route id/zone/pattern and the applied
|
|
877
|
+
* declaration last -- every crash point leaves a recorded trace that
|
|
878
|
+
* retire and destroy can clean up (#318 re-review), and per CLAUDE.md's
|
|
879
|
+
* "Declared changes apply" nothing is marked done until the
|
|
880
|
+
* live state actually matches it.
|
|
881
|
+
*/
|
|
882
|
+
async cloudflareWorker(action, org, context) {
|
|
883
|
+
const entryKey = action.key.slice(org.id.length + 1);
|
|
884
|
+
const at = `cloudflare:worker for ${org.slug} entry '${entryKey}'`;
|
|
885
|
+
const workers = this.ports.workers;
|
|
886
|
+
if (!workers)
|
|
887
|
+
throw new Error(`${at}: this executor was built without a WorkersPort (#318)`);
|
|
888
|
+
const desired = action.desired;
|
|
889
|
+
if (desired.mode === 'retire') {
|
|
890
|
+
const recorded = context.recordedIds();
|
|
891
|
+
const mainId = recorded[`cloudflare:worker:${action.key}`];
|
|
892
|
+
const scriptName = mainId || recorded[entryWorkerUploadedScopeKey(entryKey)];
|
|
893
|
+
await this.teardownWorker(entryKey, scriptName, recorded, workers, at);
|
|
894
|
+
context.recordScopeId(entryWorkerRouteIdScopeKey(entryKey), '');
|
|
895
|
+
context.recordScopeId(entryWorkerRouteZoneScopeKey(entryKey), '');
|
|
896
|
+
context.recordScopeId(entryWorkerRoutePatternScopeKey(entryKey), '');
|
|
897
|
+
context.recordScopeId(entryWorkerRoutePendingScopeKey(entryKey), '');
|
|
898
|
+
context.recordScopeId(entryWorkerExclusionsScopeKey(entryKey), '');
|
|
899
|
+
context.recordScopeId(entryWorkerAppliedScopeKey(entryKey), '');
|
|
900
|
+
context.recordScopeId(entryWorkerUploadedScopeKey(entryKey), '');
|
|
901
|
+
// `runSteps` only ever writes the main recorded id from a truthy
|
|
902
|
+
// `resourceId` (`plan-apply.ts`); this step returns none, so without
|
|
903
|
+
// this marker the planner's "gone" scan would retire the same,
|
|
904
|
+
// never-cleared id forever.
|
|
905
|
+
if (mainId)
|
|
906
|
+
context.recordScopeId(entryWorkerRetiredScopeKey(entryKey), mainId);
|
|
907
|
+
return {};
|
|
908
|
+
}
|
|
909
|
+
if (!org.domain) {
|
|
910
|
+
throw new Error(`${at}: worker needs org.domain, and validateHosting already refuses it without one`);
|
|
911
|
+
}
|
|
912
|
+
const zone = await this.ports.cloudflare.findZoneByApex(org.domain);
|
|
913
|
+
if (!zone) {
|
|
914
|
+
throw new Error(`${at}: no Cloudflare zone found for ${org.domain} -- cloudflare:zone must provision it first`);
|
|
915
|
+
}
|
|
916
|
+
const name = desired.name;
|
|
917
|
+
const route = desired.route;
|
|
918
|
+
const bindings = (desired.bindings?.rateLimits ?? []).map((r) => ({
|
|
919
|
+
type: 'ratelimit',
|
|
920
|
+
name: r.name,
|
|
921
|
+
namespace_id: r.namespaceId,
|
|
922
|
+
simple: { limit: r.limit, period: r.period },
|
|
923
|
+
}));
|
|
924
|
+
const existingRoute = await workers.findRoute(zone.id, route);
|
|
925
|
+
if (existingRoute && existingRoute.script !== name) {
|
|
926
|
+
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`);
|
|
927
|
+
}
|
|
928
|
+
// Checked only on an entry's first-ever apply -- a name foundry has
|
|
929
|
+
// already recorded for itself (even a retired one: redeclaring reuses
|
|
930
|
+
// the same deterministic name) is foundry's own script to overwrite,
|
|
931
|
+
// never a stranger's.
|
|
932
|
+
const recordedBeforeUpload = context.recordedIds();
|
|
933
|
+
const everUploadedByFoundry = Boolean(recordedBeforeUpload[entryWorkerAppliedScopeKey(entryKey)] ||
|
|
934
|
+
recordedBeforeUpload[entryWorkerUploadedScopeKey(entryKey)] ||
|
|
935
|
+
recordedBeforeUpload[`cloudflare:worker:${action.key}`]);
|
|
936
|
+
if (!everUploadedByFoundry && (await workers.scriptExists(name))) {
|
|
937
|
+
throw new Error(`${at}: a Worker script named '${name}' already exists and foundry never created it; refuses to overwrite it`);
|
|
938
|
+
}
|
|
939
|
+
// Recorded and persisted *before* the upload (#318 re-review): a crash
|
|
940
|
+
// mid-upload or a failed route create still leaves a trail to the
|
|
941
|
+
// script, and a retry reads it as foundry's own rather than tripping
|
|
942
|
+
// the `scriptExists` guard above. The retired marker clears here too,
|
|
943
|
+
// not only on success: once a redeploy has touched the script, a later
|
|
944
|
+
// undeclare must retire it even if this apply never finishes.
|
|
945
|
+
context.recordScopeId(entryWorkerUploadedScopeKey(entryKey), name);
|
|
946
|
+
context.recordScopeId(entryWorkerRetiredScopeKey(entryKey), '');
|
|
947
|
+
await context.persistScopeIds();
|
|
948
|
+
await workers.uploadScript({
|
|
949
|
+
name,
|
|
950
|
+
artifactUrl: desired.artifactUrl,
|
|
951
|
+
artifactSha256: desired.artifactSha256,
|
|
952
|
+
compatibilityDate: action.desired.compatibilityDate,
|
|
953
|
+
bindings: bindings.length > 0 ? bindings : undefined,
|
|
954
|
+
});
|
|
955
|
+
// Before the wildcard route exists, so a hostname foundry serves is never
|
|
956
|
+
// caught by the Worker, not even briefly.
|
|
957
|
+
await this.syncWorkerExclusions(entryKey, zone.id, org.domain, desired.exclusions ?? [], workers, context, at);
|
|
958
|
+
const recorded = context.recordedIds();
|
|
959
|
+
const previousRouteId = recorded[entryWorkerRouteIdScopeKey(entryKey)];
|
|
960
|
+
const previousPending = recorded[entryWorkerRoutePendingScopeKey(entryKey)];
|
|
961
|
+
const zoneRecord = { id: zone.id, apex: org.domain };
|
|
962
|
+
let routeId = existingRoute?.id;
|
|
963
|
+
if (!routeId) {
|
|
964
|
+
// Recorded before the create, so a route Cloudflare made but foundry
|
|
965
|
+
// never got to record is still found (`findRoute`) and deleted on
|
|
966
|
+
// retire or destroy (#318 re-review).
|
|
967
|
+
const pending = { ...zoneRecord, pattern: route, script: name };
|
|
968
|
+
context.recordScopeId(entryWorkerRoutePendingScopeKey(entryKey), JSON.stringify(pending));
|
|
969
|
+
await context.persistScopeIds();
|
|
970
|
+
const created = await workers.createRoute(zone.id, route, name);
|
|
971
|
+
routeId = created.id;
|
|
972
|
+
}
|
|
973
|
+
// Any route this entry recorded before that is not the one it now uses
|
|
974
|
+
// -- the declared pattern moved, or the domain did -- is deleted on its
|
|
975
|
+
// own zone rather than left live beside the new one. A pending route
|
|
976
|
+
// from an earlier, interrupted apply under a different pattern likewise.
|
|
977
|
+
if (previousRouteId && previousRouteId !== routeId) {
|
|
978
|
+
const previousZone = await this.liveWorkerZone(recorded[entryWorkerRouteZoneScopeKey(entryKey)], at);
|
|
979
|
+
if (previousZone)
|
|
980
|
+
await workers.deleteRoute(previousZone, previousRouteId); // Idempotent.
|
|
981
|
+
}
|
|
982
|
+
if (previousPending) {
|
|
983
|
+
await this.deletePendingWorkerRoute(previousPending, routeId, workers, at);
|
|
984
|
+
}
|
|
985
|
+
context.recordScopeId(entryWorkerRouteIdScopeKey(entryKey), routeId);
|
|
986
|
+
context.recordScopeId(entryWorkerRouteZoneScopeKey(entryKey), JSON.stringify(zoneRecord));
|
|
987
|
+
context.recordScopeId(entryWorkerRoutePatternScopeKey(entryKey), route);
|
|
988
|
+
context.recordScopeId(entryWorkerRoutePendingScopeKey(entryKey), '');
|
|
989
|
+
context.recordScopeId(entryWorkerAppliedScopeKey(entryKey), JSON.stringify({
|
|
990
|
+
name,
|
|
991
|
+
artifactUrl: desired.artifactUrl,
|
|
992
|
+
artifactSha256: desired.artifactSha256,
|
|
993
|
+
route,
|
|
994
|
+
bindings: desired.bindings,
|
|
995
|
+
exclusions: desired.exclusions,
|
|
996
|
+
}));
|
|
997
|
+
context.recordScopeId(entryWorkerUploadedScopeKey(entryKey), '');
|
|
998
|
+
// The retired marker was already cleared before the upload above: a
|
|
999
|
+
// redeclare reuses this entry's deterministic script name, so a marker
|
|
1000
|
+
// left equal to the (identical) main id would read as already retired.
|
|
1001
|
+
return { resourceId: name };
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Makes the no-Worker routes recorded for an entry match `patterns`: deletes
|
|
1005
|
+
* the ones no longer wanted (all of them if the zone changed), creates the
|
|
1006
|
+
* missing ones without a script. A route already on a pattern that is bound
|
|
1007
|
+
* to a Worker is foreign and refused; one with no script is adopted. Records
|
|
1008
|
+
* and persists after each change.
|
|
1009
|
+
*/
|
|
1010
|
+
async syncWorkerExclusions(entryKey, zoneId, apex, patterns, workers, context, at) {
|
|
1011
|
+
const key = entryWorkerExclusionsScopeKey(entryKey);
|
|
1012
|
+
const raw = context.recordedIds()[key];
|
|
1013
|
+
let state = raw
|
|
1014
|
+
? JSON.parse(raw)
|
|
1015
|
+
: { id: zoneId, apex, routes: {} };
|
|
1016
|
+
const save = async () => {
|
|
1017
|
+
context.recordScopeId(key, Object.keys(state.routes).length > 0 ? JSON.stringify(state) : '');
|
|
1018
|
+
await context.persistScopeIds();
|
|
1019
|
+
};
|
|
1020
|
+
if (state.id !== zoneId) {
|
|
1021
|
+
const oldZone = await this.liveWorkerZone(JSON.stringify({ id: state.id, apex: state.apex }), at);
|
|
1022
|
+
if (oldZone) {
|
|
1023
|
+
for (const id of Object.values(state.routes))
|
|
1024
|
+
await workers.deleteRoute(oldZone, id);
|
|
1025
|
+
}
|
|
1026
|
+
state = { id: zoneId, apex, routes: {} };
|
|
1027
|
+
await save();
|
|
1028
|
+
}
|
|
1029
|
+
for (const [pattern, id] of Object.entries(state.routes)) {
|
|
1030
|
+
if (patterns.includes(pattern))
|
|
1031
|
+
continue;
|
|
1032
|
+
await workers.deleteRoute(zoneId, id); // Idempotent.
|
|
1033
|
+
delete state.routes[pattern];
|
|
1034
|
+
await save();
|
|
1035
|
+
}
|
|
1036
|
+
for (const pattern of patterns) {
|
|
1037
|
+
if (state.routes[pattern])
|
|
1038
|
+
continue;
|
|
1039
|
+
const found = await workers.findRoute(zoneId, pattern);
|
|
1040
|
+
if (found?.script) {
|
|
1041
|
+
throw new Error(`${at}: route '${pattern}' is already bound to Worker '${found.script}'; foundry does not replace it`);
|
|
1042
|
+
}
|
|
1043
|
+
state.routes[pattern] = found ? found.id : (await workers.createRoute(zoneId, pattern)).id;
|
|
1044
|
+
await save();
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* The zone id a recorded route lives in, or `undefined` only when that
|
|
1049
|
+
* zone is *positively* gone -- its apex resolves to no zone, or to a
|
|
1050
|
+
* different zone than the one recorded (#318 re-review). Reads the zone
|
|
1051
|
+
* recorded beside the route, never `org.domain`: the route id and its
|
|
1052
|
+
* zone are always recorded together (the apply branch below writes both
|
|
1053
|
+
* in the same batch, and the pending-route marker carries its own zone
|
|
1054
|
+
* too), so a route id with no zone recorded cannot happen within this
|
|
1055
|
+
* feature -- and re-resolving from `org.domain` as a fallback would risk
|
|
1056
|
+
* looking up the *new* zone after a domain change and leaving the route
|
|
1057
|
+
* live on the old one, the exact bug a recorded-per-route zone exists to
|
|
1058
|
+
* avoid (#318 re-review).
|
|
1059
|
+
*/
|
|
1060
|
+
async liveWorkerZone(recordedZone, at) {
|
|
1061
|
+
if (!recordedZone) {
|
|
1062
|
+
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`);
|
|
1063
|
+
}
|
|
1064
|
+
const { id, apex } = JSON.parse(recordedZone);
|
|
1065
|
+
const zone = await this.ports.cloudflare.findZoneByApex(apex);
|
|
1066
|
+
return zone && zone.id === id ? id : undefined;
|
|
1067
|
+
}
|
|
1068
|
+
/** Deletes the route an interrupted apply was creating, if `findRoute` shows it exists, points at that apply's script, and is not `keepRouteId`. */
|
|
1069
|
+
async deletePendingWorkerRoute(pendingRaw, keepRouteId, workers, at) {
|
|
1070
|
+
const pending = JSON.parse(pendingRaw);
|
|
1071
|
+
const zoneId = await this.liveWorkerZone(JSON.stringify({ id: pending.id, apex: pending.apex }), at);
|
|
1072
|
+
if (!zoneId)
|
|
1073
|
+
return;
|
|
1074
|
+
const found = await workers.findRoute(zoneId, pending.pattern);
|
|
1075
|
+
if (found && found.script === pending.script && found.id !== keepRouteId) {
|
|
1076
|
+
await workers.deleteRoute(zoneId, found.id); // Idempotent.
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Retire and destroy share this: delete the recorded route on its own
|
|
1081
|
+
* zone, then any route an interrupted apply created but never recorded,
|
|
1082
|
+
* then the script. Records nothing -- the caller does.
|
|
1083
|
+
*/
|
|
1084
|
+
async teardownWorker(entryKey, scriptName, recorded, workers, at) {
|
|
1085
|
+
const routeId = recorded[entryWorkerRouteIdScopeKey(entryKey)];
|
|
1086
|
+
if (routeId) {
|
|
1087
|
+
const zoneId = await this.liveWorkerZone(recorded[entryWorkerRouteZoneScopeKey(entryKey)], at);
|
|
1088
|
+
if (zoneId)
|
|
1089
|
+
await workers.deleteRoute(zoneId, routeId); // Idempotent: a no-op if already gone.
|
|
1090
|
+
}
|
|
1091
|
+
const pending = recorded[entryWorkerRoutePendingScopeKey(entryKey)];
|
|
1092
|
+
if (pending)
|
|
1093
|
+
await this.deletePendingWorkerRoute(pending, undefined, workers, at);
|
|
1094
|
+
const exclusions = recorded[entryWorkerExclusionsScopeKey(entryKey)];
|
|
1095
|
+
if (exclusions) {
|
|
1096
|
+
const state = JSON.parse(exclusions);
|
|
1097
|
+
const zoneId = await this.liveWorkerZone(JSON.stringify({ id: state.id, apex: state.apex }), at);
|
|
1098
|
+
if (zoneId) {
|
|
1099
|
+
for (const id of Object.values(state.routes))
|
|
1100
|
+
await workers.deleteRoute(zoneId, id);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
if (scriptName)
|
|
1104
|
+
await workers.deleteScript(scriptName); // Idempotent: a no-op if already gone.
|
|
1105
|
+
}
|
|
843
1106
|
// ---- zitadel --------------------------------------------------------
|
|
844
1107
|
async zitadelIssuer(action, org, context) {
|
|
845
1108
|
if (action.desired.provision !== true) {
|
|
@@ -1498,7 +1761,15 @@ export class FoundryStepExecutor {
|
|
|
1498
1761
|
// One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
|
|
1499
1762
|
// second entry sharing the name would delete the first's live token.
|
|
1500
1763
|
if (entry.key === APP_ENTRY_KEY) {
|
|
1501
|
-
await this.ensureAppLettermintMail(appId, org, context, Boolean(existing)
|
|
1764
|
+
await this.ensureAppLettermintMail(appId, org, context, Boolean(existing),
|
|
1765
|
+
// #491 (review round 2, finding 3): `diff.ts`'s `planApps` always
|
|
1766
|
+
// sets `platformApex` on this step's `desired` once it's planned at
|
|
1767
|
+
// all -- `null` when no platform is declared, never omitted. The key
|
|
1768
|
+
// being absent outright (as opposed to present and `null`) means a
|
|
1769
|
+
// step planned before this field existed; `ensureAppLettermintMail`
|
|
1770
|
+
// refuses rather than silently falling back once it knows there is
|
|
1771
|
+
// a recorded mail domain to reason about.
|
|
1772
|
+
'platformApex' in action.desired, action.desired.platformApex ?? null, action.desired.platformLettermintProjectId ?? null);
|
|
1502
1773
|
}
|
|
1503
1774
|
return { resourceId: appId };
|
|
1504
1775
|
}
|
|
@@ -1937,12 +2208,73 @@ export class FoundryStepExecutor {
|
|
|
1937
2208
|
* comment), never logged, never interpolated into a thrown error -- the
|
|
1938
2209
|
* same discipline `pushDatabaseUrl`'s doc comment states for a database's
|
|
1939
2210
|
* own connection string.
|
|
2211
|
+
*
|
|
2212
|
+
* #491: none of the above applies to the org whose mail domain is the
|
|
2213
|
+
* platform's own apex -- it shares the platform's existing Lettermint
|
|
2214
|
+
* project (`platformLettermintProjectId`, from `DeclaredPlatform.mail`)
|
|
2215
|
+
* instead of minting a `<slug>-app` one, and this method never creates or
|
|
2216
|
+
* deletes a project for it. `platformApex`/`platformLettermintProjectId`
|
|
2217
|
+
* come straight off this step's own `desired`, the same "the executor
|
|
2218
|
+
* writes exactly what the plan showed" rule `coolifyMail` already follows
|
|
2219
|
+
* for its own `apex`/`mailHost` -- the caller (`coolifyApp`) refuses a
|
|
2220
|
+
* `desired` missing the `platformApex` key outright as a stale plan --
|
|
2221
|
+
* checked here, after the no-op-until-a-mail-domain-exists return just
|
|
2222
|
+
* below, rather than unconditionally at the call site: every other
|
|
2223
|
+
* `coolify:app` concern (hostnames, ports, settings, the sign-in env...)
|
|
2224
|
+
* has nothing to do with Lettermint and must keep working the moment a
|
|
2225
|
+
* mail domain was never even recorded for this org, the same as before
|
|
2226
|
+
* this field existed (review round 2, finding 3).
|
|
1940
2227
|
*/
|
|
1941
|
-
async ensureAppLettermintMail(appId, org, context, running) {
|
|
2228
|
+
async ensureAppLettermintMail(appId, org, context, running, hasPlatformApexField, platformApex, platformLettermintProjectId) {
|
|
1942
2229
|
const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
1943
2230
|
if (!mailDomain)
|
|
1944
2231
|
return;
|
|
2232
|
+
if (!hasPlatformApexField) {
|
|
2233
|
+
throw new Error(`coolify:app for ${org.slug}: this step's plan carries no platformApex; plan this org's app again`);
|
|
2234
|
+
}
|
|
1945
2235
|
const appName = `${org.slug}-app`;
|
|
2236
|
+
// #491: an org whose mail domain is the platform's own apex (Company
|
|
2237
|
+
// Included on companyincluded.com, today) shares the platform's existing
|
|
2238
|
+
// Lettermint project rather than minting a `<slug>-app` one of its own --
|
|
2239
|
+
// `diff.ts`'s `planApp` blocks this whole piece while
|
|
2240
|
+
// `platformLettermintProjectId` is unset, so a plan carrying neither is
|
|
2241
|
+
// stale; never fall back to creating, restricting or deleting anything
|
|
2242
|
+
// here until a fresh plan supplies it.
|
|
2243
|
+
const isPlatformApexOrg = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
|
|
2244
|
+
if (isPlatformApexOrg) {
|
|
2245
|
+
if (!platformLettermintProjectId)
|
|
2246
|
+
return;
|
|
2247
|
+
const [projects, envs] = await Promise.all([
|
|
2248
|
+
this.ports.lettermint.listProjects(),
|
|
2249
|
+
this.ports.coolify.listEnvs(appId),
|
|
2250
|
+
]);
|
|
2251
|
+
// Review round 2, finding 5: a missing held token would otherwise
|
|
2252
|
+
// leave the app with a stale or absent `LETTERMINT_API_TOKEN` and no
|
|
2253
|
+
// signal that anything is wrong -- refused instead, the same "fail
|
|
2254
|
+
// rather than silently point mail at nothing" rule `coolifyMail`
|
|
2255
|
+
// already follows for its own `apex`/`mailHost`.
|
|
2256
|
+
const heldToken = (await this.ports.heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF)) ?? null;
|
|
2257
|
+
if (!heldToken) {
|
|
2258
|
+
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})`);
|
|
2259
|
+
}
|
|
2260
|
+
// Validates and restricts before any env is written -- `ensureLettermintDomainRestriction`'s
|
|
2261
|
+
// own doc comment (review round 2, finding 4) throws on a mistyped id,
|
|
2262
|
+
// and the app must never end up with a pushed token the restriction
|
|
2263
|
+
// step then fails on, the same "never half-ready" order
|
|
2264
|
+
// {@link ensureOrgLettermintRelay} already follows for its own token.
|
|
2265
|
+
await this.ensureLettermintDomainRestriction(org, mailDomain, projects, platformApex, platformLettermintProjectId);
|
|
2266
|
+
const currentToken = envs.find((e) => e.key === 'LETTERMINT_API_TOKEN' && !e.is_preview)?.value;
|
|
2267
|
+
const tokenChanged = currentToken !== heldToken;
|
|
2268
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, [
|
|
2269
|
+
{ key: 'MAIL_FROM', value: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}` },
|
|
2270
|
+
{ key: 'LETTERMINT_API_TOKEN', value: heldToken },
|
|
2271
|
+
]);
|
|
2272
|
+
// Same restart rule as the non-apex path below: a running container
|
|
2273
|
+
// read its env at start and keeps a stale token until restarted.
|
|
2274
|
+
if (tokenChanged && running)
|
|
2275
|
+
await this.ports.coolify.restart(appId);
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
1946
2278
|
const [projects, envs] = await Promise.all([
|
|
1947
2279
|
this.ports.lettermint.listProjects(),
|
|
1948
2280
|
this.ports.coolify.listEnvs(appId),
|
|
@@ -1973,9 +2305,11 @@ export class FoundryStepExecutor {
|
|
|
1973
2305
|
// old project is deleted below. A just-created app starts with the new env.
|
|
1974
2306
|
if (!healthy && running)
|
|
1975
2307
|
await this.ports.coolify.restart(appId);
|
|
1976
|
-
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
|
|
2308
|
+
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
|
|
1977
2309
|
for (const project of allProjects) {
|
|
1978
|
-
if (project.name === appName &&
|
|
2310
|
+
if (project.name === appName &&
|
|
2311
|
+
project.id !== keepProjectId &&
|
|
2312
|
+
project.id !== platformLettermintProjectId) {
|
|
1979
2313
|
await this.ports.lettermint.deleteProject(project.id);
|
|
1980
2314
|
}
|
|
1981
2315
|
}
|
|
@@ -2013,8 +2347,14 @@ export class FoundryStepExecutor {
|
|
|
2013
2347
|
* it the same way the app step does, rather than assuming it already
|
|
2014
2348
|
* exists -- the domain must never end up unrestricted, whichever step gets
|
|
2015
2349
|
* there first.
|
|
2350
|
+
*
|
|
2351
|
+
* #491: this org's own `<slug>-relay` project is unaffected by whether it
|
|
2352
|
+
* is the platform's own org -- only the domain restriction
|
|
2353
|
+
* (`platformApex`/`platformLettermintProjectId`, forwarded to
|
|
2354
|
+
* {@link ensureLettermintDomainRestriction}) and the delete loop below
|
|
2355
|
+
* (which must never delete the platform's own project) change.
|
|
2016
2356
|
*/
|
|
2017
|
-
async ensureOrgLettermintRelay(org, mailOrg, mailDomain, context) {
|
|
2357
|
+
async ensureOrgLettermintRelay(org, mailOrg, mailDomain, context, platformApex, platformLettermintProjectId) {
|
|
2018
2358
|
const relayName = `${org.slug}-relay`;
|
|
2019
2359
|
// #345: `mailOrg.domain` is absent for a level-1 org -- `mailDomain` is
|
|
2020
2360
|
// `applyOrgMail`'s own resolved result, the one domain `MailPort`'s
|
|
@@ -2033,7 +2373,7 @@ export class FoundryStepExecutor {
|
|
|
2033
2373
|
let keepProjectId;
|
|
2034
2374
|
if (healthy && held) {
|
|
2035
2375
|
keepProjectId = held.id;
|
|
2036
|
-
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
|
|
2376
|
+
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
|
|
2037
2377
|
}
|
|
2038
2378
|
else {
|
|
2039
2379
|
const created = await this.ports.lettermint.createProject(relayName);
|
|
@@ -2044,7 +2384,7 @@ export class FoundryStepExecutor {
|
|
|
2044
2384
|
// reaches Stalwart: a failure here leaves the working route alone.
|
|
2045
2385
|
// The restriction covers old and new projects, so the old one keeps
|
|
2046
2386
|
// sending until the route switches.
|
|
2047
|
-
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
|
|
2387
|
+
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
|
|
2048
2388
|
await this.ports.mail.applyOrgRelay(relayOrg, created.token);
|
|
2049
2389
|
}
|
|
2050
2390
|
catch (err) {
|
|
@@ -2057,7 +2397,9 @@ export class FoundryStepExecutor {
|
|
|
2057
2397
|
await context.persistScopeIds();
|
|
2058
2398
|
}
|
|
2059
2399
|
for (const project of allProjects) {
|
|
2060
|
-
if (project.name === relayName &&
|
|
2400
|
+
if (project.name === relayName &&
|
|
2401
|
+
project.id !== keepProjectId &&
|
|
2402
|
+
project.id !== platformLettermintProjectId) {
|
|
2061
2403
|
await this.ports.lettermint.deleteProject(project.id);
|
|
2062
2404
|
}
|
|
2063
2405
|
}
|
|
@@ -2089,10 +2431,31 @@ export class FoundryStepExecutor {
|
|
|
2089
2431
|
* unrestricted domain cannot verify and cannot send, whichever project
|
|
2090
2432
|
* owns it (post-#140 review finding, restated here rather than only at
|
|
2091
2433
|
* the one call site it used to have).
|
|
2434
|
+
*
|
|
2435
|
+
* #491: `platformApex`/`platformLettermintProjectId` cover both callers at
|
|
2436
|
+
* once -- when `mailDomain` is the platform's own apex, the platform's
|
|
2437
|
+
* project id is always folded into the restriction, so a plan that shares
|
|
2438
|
+
* the platform's project (rather than minting a `<slug>-app` one) never
|
|
2439
|
+
* ends up restricting that org's domain to nothing. A no-op for every
|
|
2440
|
+
* other org's domain, since `mailDomain` never matches `platformApex` there.
|
|
2441
|
+
* Review round 2, finding 4: a declared id that is not among `allProjects`
|
|
2442
|
+
* (a mistype, or the project was deleted in Lettermint) is refused rather
|
|
2443
|
+
* than folded in anyway -- that would restrict the apex to whatever *is*
|
|
2444
|
+
* live (the relay project, say) plus an id naming nothing, which excludes
|
|
2445
|
+
* the real platform project from its own domain exactly as completely as
|
|
2446
|
+
* never restricting it at all. Centralised here rather than duplicated at
|
|
2447
|
+
* each caller, since both {@link ensureAppLettermintMail} and
|
|
2448
|
+
* {@link ensureOrgLettermintRelay} reach the apex through this one method.
|
|
2092
2449
|
*/
|
|
2093
|
-
async ensureLettermintDomainRestriction(org, mailDomain, allProjects) {
|
|
2450
|
+
async ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId) {
|
|
2094
2451
|
const appName = `${org.slug}-app`;
|
|
2095
2452
|
const relayName = `${org.slug}-relay`;
|
|
2453
|
+
const isPlatformApexDomain = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
|
|
2454
|
+
if (isPlatformApexDomain && platformLettermintProjectId) {
|
|
2455
|
+
if (!allProjects.some((p) => p.id === platformLettermintProjectId)) {
|
|
2456
|
+
throw new Error(`Lettermint for ${org.slug}: platform.mail.lettermintProjectId ${platformLettermintProjectId} is not a live Lettermint project`);
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2096
2459
|
const existing = await this.findExactLettermintDomain(mailDomain);
|
|
2097
2460
|
const domain = existing
|
|
2098
2461
|
? await this.ports.lettermint.getDomain(existing.id)
|
|
@@ -2100,6 +2463,11 @@ export class FoundryStepExecutor {
|
|
|
2100
2463
|
const scopedProjectIds = allProjects
|
|
2101
2464
|
.filter((p) => p.name === appName || p.name === relayName)
|
|
2102
2465
|
.map((p) => p.id);
|
|
2466
|
+
if (isPlatformApexDomain &&
|
|
2467
|
+
platformLettermintProjectId &&
|
|
2468
|
+
!scopedProjectIds.includes(platformLettermintProjectId)) {
|
|
2469
|
+
scopedProjectIds.push(platformLettermintProjectId);
|
|
2470
|
+
}
|
|
2103
2471
|
await this.ports.lettermint.restrictDomainToProjects(domain.id, scopedProjectIds);
|
|
2104
2472
|
const zone = await this.ports.cloudflare.findZoneByApex(mailDomain);
|
|
2105
2473
|
if (!zone) {
|
|
@@ -2739,6 +3107,34 @@ export class FoundryStepExecutor {
|
|
|
2739
3107
|
async coolifyChat(action, org, context) {
|
|
2740
3108
|
const recorded = context.recordedIds();
|
|
2741
3109
|
if (recorded[`coolify:chat:${action.key}`]) {
|
|
3110
|
+
// #456: `planChat` plans this distinct, non-re-key action for a live
|
|
3111
|
+
// chat app that predates the owner/runtime database split (#239) --
|
|
3112
|
+
// push only the split and redeploy if it changed, never the re-key
|
|
3113
|
+
// paths below, which throw on literally any other action here.
|
|
3114
|
+
if (action.desired.refreshDatabaseSplit === true) {
|
|
3115
|
+
const serviceAppId = recorded[CHAT_SERVICE_SCOPE_KEY];
|
|
3116
|
+
const serviceDbId = recorded[CHAT_SERVICE_DB_SCOPE_KEY];
|
|
3117
|
+
if (!serviceAppId || !serviceDbId) {
|
|
3118
|
+
throw new Error(`coolify:chat database split for ${org.slug}: the chat service app or its database is not recorded yet`);
|
|
3119
|
+
}
|
|
3120
|
+
const push = await this.pushDatabaseUrl(serviceAppId, serviceDbId, 'chat');
|
|
3121
|
+
const markerKey = serviceDbEnvAppliedScopeKey('chat');
|
|
3122
|
+
if (!push.pushed) {
|
|
3123
|
+
// Unlike a fresh provision, this chat was already fully up and
|
|
3124
|
+
// running -- its service database was already started (the main
|
|
3125
|
+
// path below never creates an app before confirming that). A push
|
|
3126
|
+
// refused here means the recorded database id no longer resolves
|
|
3127
|
+
// to a started database with an `internal_db_url`, which is not a
|
|
3128
|
+
// "retry once Coolify catches up" condition: fail loudly instead of
|
|
3129
|
+
// replanning this same step silently forever (#456 review).
|
|
3130
|
+
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`);
|
|
3131
|
+
}
|
|
3132
|
+
if (push.changed || !recorded[markerKey]) {
|
|
3133
|
+
await this.ports.coolify.deploy(serviceAppId);
|
|
3134
|
+
}
|
|
3135
|
+
context.recordScopeId(markerKey, '1');
|
|
3136
|
+
return { resourceId: serviceAppId };
|
|
3137
|
+
}
|
|
2742
3138
|
if (action.desired.usesDomain === false) {
|
|
2743
3139
|
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)');
|
|
2744
3140
|
}
|
|
@@ -2881,8 +3277,13 @@ export class FoundryStepExecutor {
|
|
|
2881
3277
|
agoraHost,
|
|
2882
3278
|
masClientId: secrets.value.masClientId,
|
|
2883
3279
|
masClientSecret: secrets.value.masClientSecret,
|
|
2884
|
-
databaseUrl: serviceDbRow.internal_db_url,
|
|
2885
3280
|
}));
|
|
3281
|
+
// Split the same way `coolifyApp`'s and `cmsSite`'s own databases are
|
|
3282
|
+
// (#239, #456): `DATABASE_URL_OWNER` is the superuser string,
|
|
3283
|
+
// `DATABASE_URL` is a minted `chat_rt` role -- `chat` is the literal
|
|
3284
|
+
// `postgres_db`/`postgres_user` `ensureCallsDatabase` created the service
|
|
3285
|
+
// database with above, not the Coolify resource name `names.serviceDb`.
|
|
3286
|
+
const serviceDbPush = await this.pushDatabaseUrl(service.appId, serviceDbId, 'chat');
|
|
2886
3287
|
// A file-storage write alone never reaches a running container. Deploy an
|
|
2887
3288
|
// app that is new, whose config was freshly minted, or that is not
|
|
2888
3289
|
// running now. The last test reads the live status, not what this run
|
|
@@ -2893,10 +3294,19 @@ export class FoundryStepExecutor {
|
|
|
2893
3294
|
for (const app of [mas, synapse, service]) {
|
|
2894
3295
|
if (!app.existed ||
|
|
2895
3296
|
secrets.changed ||
|
|
3297
|
+
(app === service && serviceDbPush.changed) ||
|
|
2896
3298
|
!isRunningOrStarting((await this.ports.coolify.getApplication(app.appId)).status)) {
|
|
2897
3299
|
await this.ports.coolify.deploy(app.appId);
|
|
2898
3300
|
}
|
|
2899
3301
|
}
|
|
3302
|
+
// #456: recorded here too, not only on the `refreshDatabaseSplit` replan
|
|
3303
|
+
// path above -- without it a fresh provision's very first apply never
|
|
3304
|
+
// sets this marker, so `planChat` (`!recordedIds[serviceDbEnvAppliedScopeKey('chat')]`)
|
|
3305
|
+
// reads it as still missing and plans a pointless extra redeploy on the
|
|
3306
|
+
// very next apply for an org that was never behind.
|
|
3307
|
+
if (serviceDbPush.pushed) {
|
|
3308
|
+
context.recordScopeId(serviceDbEnvAppliedScopeKey('chat'), '1');
|
|
3309
|
+
}
|
|
2900
3310
|
// Last: the marker tells `desiredAgoraEnv` the names and envs are in place.
|
|
2901
3311
|
context.recordScopeId(CHAT_SERVICE_URL_SCOPE_KEY, chatUpstreams(org.slug).chat);
|
|
2902
3312
|
return { resourceId: mas.appId };
|
|
@@ -2976,7 +3386,6 @@ export class FoundryStepExecutor {
|
|
|
2976
3386
|
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
2977
3387
|
const bindingSecret = await this.ensureCallsBindingSecret(app.appId);
|
|
2978
3388
|
const envs = [
|
|
2979
|
-
{ key: 'DATABASE_URL', value: db.internal_db_url },
|
|
2980
3389
|
{ key: 'LIVEKIT_URL', value: CALLS_SFU_PRIVATE_URL },
|
|
2981
3390
|
{ key: 'LIVEKIT_PUBLIC_URL', value: CALLS_SFU_PUBLIC_URL },
|
|
2982
3391
|
{ key: 'LIVEKIT_API_KEY', value: sfu.apiKey },
|
|
@@ -3015,17 +3424,40 @@ export class FoundryStepExecutor {
|
|
|
3015
3424
|
// unguarded and the planner reports the lost secret instead of minting again.
|
|
3016
3425
|
if (token)
|
|
3017
3426
|
context.recordScopeId(CALLS_INTROSPECTION_SCOPE_KEY, token.appId);
|
|
3427
|
+
// Split the same way `coolifyApp`'s and `cmsSite`'s own databases are
|
|
3428
|
+
// (#239, #456): `DATABASE_URL_OWNER` is the superuser string,
|
|
3429
|
+
// `DATABASE_URL` is a minted `<dbName>_rt` role -- the real Postgres
|
|
3430
|
+
// database name `ensureCallsDatabase` created this database with, not
|
|
3431
|
+
// the Coolify resource name.
|
|
3432
|
+
const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName.replace(/-/g, '_'));
|
|
3433
|
+
// #456: a crash between the push above and the deploy/marker below
|
|
3434
|
+
// leaves the env right and no marker, so this replans and deploys once
|
|
3435
|
+
// more -- the same rule `coolifyApp`'s own `dbEnvAppliedScopeKey` marker
|
|
3436
|
+
// follows.
|
|
3437
|
+
const dbMarkerKey = serviceDbEnvAppliedScopeKey('calls-service');
|
|
3438
|
+
const dbSplitNeedsRefresh = dbPush.pushed && (dbPush.changed || !context.recordedIds()[dbMarkerKey]);
|
|
3018
3439
|
// A container reads its name and env only at start, so a running app is
|
|
3019
3440
|
// restarted once, when this run is the one that gave it its name or its
|
|
3020
3441
|
// token env (the same rule `coolifyApp` follows when an existing app's
|
|
3021
|
-
// env changed).
|
|
3442
|
+
// env changed). The database split is different: it is only safe once the
|
|
3443
|
+
// image actually contains the migrate step that creates the `_rt` role
|
|
3444
|
+
// (the sibling calls-service PR adding it), so that case redeploys --
|
|
3445
|
+
// rebuilding the image -- rather than restarting the one already running,
|
|
3446
|
+
// the same distinction `coolifyChat`'s own refresh path makes. A restart
|
|
3447
|
+
// here would bring back a container built before that migrate step
|
|
3448
|
+
// existed, still pointed at a role nothing has created.
|
|
3022
3449
|
const givesTokenEnv = token !== null && context.recordedIds()[CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY] !== tokenMarker;
|
|
3023
|
-
if (app.existed &&
|
|
3450
|
+
if (app.existed && dbSplitNeedsRefresh) {
|
|
3451
|
+
await this.ports.coolify.deploy(app.appId);
|
|
3452
|
+
}
|
|
3453
|
+
else if (app.existed && (!callsServiceHasName(org, context.recordedIds()) || givesTokenEnv)) {
|
|
3024
3454
|
await this.ports.coolify.restart(app.appId);
|
|
3025
3455
|
}
|
|
3026
3456
|
context.recordScopeId(CALLS_SERVICE_NAME_SCOPE_KEY, name);
|
|
3027
3457
|
if (token && tokenMarker)
|
|
3028
3458
|
context.recordScopeId(CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, tokenMarker);
|
|
3459
|
+
if (dbPush.pushed)
|
|
3460
|
+
context.recordScopeId(dbMarkerKey, '1');
|
|
3029
3461
|
return { resourceId: app.appId };
|
|
3030
3462
|
}
|
|
3031
3463
|
/**
|
|
@@ -3849,22 +4281,26 @@ export class FoundryStepExecutor {
|
|
|
3849
4281
|
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`);
|
|
3850
4282
|
}
|
|
3851
4283
|
const options = { apex, mailHost };
|
|
4284
|
+
// #491: `null` unless `diff.ts`'s `planMail` carries them -- absent for
|
|
4285
|
+
// every org but the platform's own, and for a plan predating this field.
|
|
4286
|
+
const platformApex = action.desired.platformApex ?? null;
|
|
4287
|
+
const platformLettermintProjectId = action.desired.platformLettermintProjectId ?? null;
|
|
3852
4288
|
if (mode === 'off') {
|
|
3853
4289
|
const parkedMailOrg = mailOrgFor(org, usesDomain, 'hosted');
|
|
3854
4290
|
await this.ports.mail.parkOrgMail(parkedMailOrg, options);
|
|
3855
|
-
await this.parkLettermintMail(org, parkedMailOrg, context);
|
|
4291
|
+
await this.parkLettermintMail(org, parkedMailOrg, context, platformApex, platformLettermintProjectId);
|
|
3856
4292
|
return undefined;
|
|
3857
4293
|
}
|
|
3858
4294
|
const mailOrg = mailOrgFor(org, usesDomain, mode);
|
|
3859
4295
|
const result = await this.ports.mail.applyOrgMail(mailOrg, options);
|
|
3860
4296
|
await this.publishMailDnsRecords(org, result);
|
|
3861
|
-
await this.deleteStaleLettermintDomain(context, result.domain);
|
|
4297
|
+
await this.deleteStaleLettermintDomain(context, result.domain, platformApex);
|
|
3862
4298
|
context.recordScopeId(MAIL_DOMAIN_SCOPE_KEY, result.domain);
|
|
3863
4299
|
// #177: what this step wrote from the platform, so `planMail` can see a later change.
|
|
3864
4300
|
context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
|
|
3865
4301
|
context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
|
|
3866
4302
|
await this.ensureOwnerMailbox(org, mailOrg, options, context);
|
|
3867
|
-
await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context);
|
|
4303
|
+
await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context, platformApex, platformLettermintProjectId);
|
|
3868
4304
|
await this.ensureRoleMailboxes(org, mailOrg, result.domain, options, context);
|
|
3869
4305
|
return { resourceId: result.domain };
|
|
3870
4306
|
}
|
|
@@ -3960,11 +4396,20 @@ export class FoundryStepExecutor {
|
|
|
3960
4396
|
* domain recorded) and on a same-domain re-apply (compared
|
|
3961
4397
|
* case-insensitively, the same rule {@link findExactLettermintDomain}
|
|
3962
4398
|
* applies to the lookup itself).
|
|
4399
|
+
*
|
|
4400
|
+
* #491 review: also a no-op when the previous domain is the platform's own
|
|
4401
|
+
* apex -- an org that moves its mail *off* that domain (`usesDomain` flips,
|
|
4402
|
+
* or `org.domain` changes) must never delete it here, the same protection
|
|
4403
|
+
* {@link parkLettermintMail} already gives the apex on a move to `off`.
|
|
4404
|
+
* `planMail` only blocks a move *onto* the apex (no project id yet); a move
|
|
4405
|
+
* away from it reaches this method with nothing to block it otherwise.
|
|
3963
4406
|
*/
|
|
3964
|
-
async deleteStaleLettermintDomain(context, newDomain) {
|
|
4407
|
+
async deleteStaleLettermintDomain(context, newDomain, platformApex) {
|
|
3965
4408
|
const previous = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
3966
4409
|
if (!previous || previous.toLowerCase() === newDomain.toLowerCase())
|
|
3967
4410
|
return;
|
|
4411
|
+
if (platformApex && previous.toLowerCase() === platformApex.toLowerCase())
|
|
4412
|
+
return;
|
|
3968
4413
|
const stale = await this.findExactLettermintDomain(previous);
|
|
3969
4414
|
if (stale)
|
|
3970
4415
|
await this.ports.lettermint.deleteDomain(stale.id);
|
|
@@ -4036,20 +4481,28 @@ export class FoundryStepExecutor {
|
|
|
4036
4481
|
* A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
|
|
4037
4482
|
* nothing was ever provisioned for an org whose mail went straight from
|
|
4038
4483
|
* never-applied to off.
|
|
4484
|
+
*
|
|
4485
|
+
* #491: when `mailDomain` is the platform's own apex, this must never
|
|
4486
|
+
* delete the platform's own project (the delete loop skips it by id) or
|
|
4487
|
+
* the platform's own domain (returns before the domain lookup/delete).
|
|
4039
4488
|
*/
|
|
4040
|
-
async parkLettermintMail(org, mailOrg, context) {
|
|
4489
|
+
async parkLettermintMail(org, mailOrg, context, platformApex, platformLettermintProjectId) {
|
|
4041
4490
|
const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
4042
4491
|
if (!mailDomain)
|
|
4043
4492
|
return;
|
|
4493
|
+
const isPlatformApexOrg = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
|
|
4044
4494
|
// #345: `mailOrg.domain` is absent for a level-1 org -- the previously
|
|
4045
4495
|
// recorded mail domain is the one `MailPort.removeOrgRelay` needs.
|
|
4046
4496
|
await this.ports.mail.removeOrgRelay({ ...mailOrg, domain: mailDomain });
|
|
4047
4497
|
const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
|
|
4048
4498
|
const projects = await this.ports.lettermint.listProjects();
|
|
4049
4499
|
for (const project of projects) {
|
|
4050
|
-
if (names.has(project.name))
|
|
4500
|
+
if (names.has(project.name) && project.id !== platformLettermintProjectId) {
|
|
4051
4501
|
await this.ports.lettermint.deleteProject(project.id);
|
|
4502
|
+
}
|
|
4052
4503
|
}
|
|
4504
|
+
if (isPlatformApexOrg)
|
|
4505
|
+
return;
|
|
4053
4506
|
const domain = await this.findExactLettermintDomain(mailDomain);
|
|
4054
4507
|
if (domain)
|
|
4055
4508
|
await this.ports.lettermint.deleteDomain(domain.id);
|
|
@@ -4214,13 +4667,14 @@ export class FoundryStepExecutor {
|
|
|
4214
4667
|
return { resourceId: provisioned.organisationId };
|
|
4215
4668
|
}
|
|
4216
4669
|
async aiBudget(action, org) {
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4670
|
+
// #484: ai/packages/sdk/src/types.ts:134-137's UpdateBudget.limitMicros
|
|
4671
|
+
// is a required string -- there is no "uncapped" this API can express
|
|
4672
|
+
// yet (Finding, this campaign's report) -- so a null `desired.limitMicros`
|
|
4673
|
+
// (an org that declares no limit) gets DEFAULT_AI_LIMIT_MICROS here too,
|
|
4674
|
+
// the same substitution diff.ts's planners already make when they fill
|
|
4675
|
+
// this field. Belt and braces: by the time a plan reaches here,
|
|
4676
|
+
// `action.desired.limitMicros` should already be non-null.
|
|
4677
|
+
const limitMicros = action.desired.limitMicros ?? DEFAULT_AI_LIMIT_MICROS;
|
|
4224
4678
|
const ai = await this.aiPort();
|
|
4225
4679
|
await ai.updateBudget(org.id, { keyId: null, limitMicros });
|
|
4226
4680
|
return { resourceId: org.id };
|
|
@@ -4429,6 +4883,165 @@ export class FoundryStepExecutor {
|
|
|
4429
4883
|
ids.push(keyId);
|
|
4430
4884
|
context.recordScopeId(listKey, JSON.stringify(ids));
|
|
4431
4885
|
}
|
|
4886
|
+
/**
|
|
4887
|
+
* F7 (#317): mints one entry's edge key directly below the org (edge
|
|
4888
|
+
* takes no separate minter key id, unlike ai/files), writes `EDGE_URL`
|
|
4889
|
+
* and `EDGE_API_KEY` to the entry's app env, then restarts it. Retires
|
|
4890
|
+
* the key when `uses.edge` is removed. Follows the "one mint" rule
|
|
4891
|
+
* (CLAUDE.md "Platform steps"): the id is recorded as undelivered and
|
|
4892
|
+
* persisted before delivery is attempted, so a crash or a failed write
|
|
4893
|
+
* never leads to a second mint -- `planEntryEdgeKeys` blocks instead.
|
|
4894
|
+
*/
|
|
4895
|
+
async edgeEntryKey(action, org, context) {
|
|
4896
|
+
const entryKey = action.key.slice(org.id.length + 1);
|
|
4897
|
+
const at = `edge:key for ${org.slug} entry '${entryKey}'`;
|
|
4898
|
+
const edge = this.ports.edge;
|
|
4899
|
+
if (!edge)
|
|
4900
|
+
throw new Error(`${at}: this executor was built without an EdgePort (#317)`);
|
|
4901
|
+
const desired = action.desired;
|
|
4902
|
+
if (desired.mode === 'retire') {
|
|
4903
|
+
const recorded = context.recordedIds();
|
|
4904
|
+
const mainId = recorded[`edge:key:${action.key}`];
|
|
4905
|
+
// A key minted but never delivered never gets a main id (the mint
|
|
4906
|
+
// step throws before `runSteps` sees a `resourceId`), so it can
|
|
4907
|
+
// coexist with a separate, already-delivered id from an earlier mint
|
|
4908
|
+
// of the same entry: mint A, deliver, retire (main id A, retired
|
|
4909
|
+
// marker A); re-declare, mint B, fail delivery three times
|
|
4910
|
+
// (undelivered B, main id still A); undeclare again. The old `||`
|
|
4911
|
+
// here always picked A (truthy), leaving B live for up to 365 days
|
|
4912
|
+
// with nothing recorded (#317 re-review) -- both ids are revoked,
|
|
4913
|
+
// independently, whenever both are present.
|
|
4914
|
+
const undeliveredId = recorded[entryEdgeUndeliveredScopeKey(entryKey)];
|
|
4915
|
+
const idsToRevoke = [...new Set([mainId, undeliveredId].filter((id) => !!id))];
|
|
4916
|
+
const keyId = mainId || undeliveredId;
|
|
4917
|
+
// Revoke first: the key going dead in edge is what retire is for,
|
|
4918
|
+
// and the one thing that must still happen even when the entry's app
|
|
4919
|
+
// is itself already gone. Only once that has succeeded (or 404'd as
|
|
4920
|
+
// already gone) is the env cleanup attempted, itself tolerant of a
|
|
4921
|
+
// 404 on a listEnvs/deleteEnv against an app that no longer exists --
|
|
4922
|
+
// doing this the other way round meant an app already torn down (its
|
|
4923
|
+
// id still recorded) threw on `listEnvs` before `revokeKey` ever ran,
|
|
4924
|
+
// stranding a live key on every retry (#317 review).
|
|
4925
|
+
for (const id of idsToRevoke) {
|
|
4926
|
+
try {
|
|
4927
|
+
await edge.revokeKey(org.id, id);
|
|
4928
|
+
}
|
|
4929
|
+
catch (error) {
|
|
4930
|
+
// A 404 means it is already gone -- recorded as retired below,
|
|
4931
|
+
// the same as a successful revoke. Anything else is a real
|
|
4932
|
+
// failure: thrown, not swallowed, so the key stays live and the
|
|
4933
|
+
// planner retires it again on the next apply instead of this
|
|
4934
|
+
// marking it done.
|
|
4935
|
+
if (!isNotFoundError(error))
|
|
4936
|
+
throw error;
|
|
4937
|
+
}
|
|
4938
|
+
}
|
|
4939
|
+
const appId = recorded[`coolify:app:${action.key}`];
|
|
4940
|
+
if (appId) {
|
|
4941
|
+
// A revoked key's env values are dead, not merely stale -- remove
|
|
4942
|
+
// them so nothing reads a credential edge has already revoked. A
|
|
4943
|
+
// 404 on either call means that app or that env is already gone:
|
|
4944
|
+
// nothing to clean up there, and the key is already revoked above,
|
|
4945
|
+
// so it is not a reason to fail the step. Each delete tolerates its
|
|
4946
|
+
// own 404, so one env already gone does not leave the other behind.
|
|
4947
|
+
const tolerateNotFound = async (call, gone) => {
|
|
4948
|
+
try {
|
|
4949
|
+
return await call();
|
|
4950
|
+
}
|
|
4951
|
+
catch (error) {
|
|
4952
|
+
if (!isNotFoundError(error))
|
|
4953
|
+
throw error;
|
|
4954
|
+
return gone;
|
|
4955
|
+
}
|
|
4956
|
+
};
|
|
4957
|
+
const envs = await tolerateNotFound(() => this.ports.coolify.listEnvs(appId), []);
|
|
4958
|
+
for (const envKey of ['EDGE_URL', 'EDGE_API_KEY']) {
|
|
4959
|
+
const env = envs.find((e) => e.key === envKey && !e.is_preview);
|
|
4960
|
+
if (env?.uuid) {
|
|
4961
|
+
const uuid = env.uuid;
|
|
4962
|
+
await tolerateNotFound(() => this.ports.coolify.deleteEnv(appId, uuid), undefined);
|
|
4963
|
+
}
|
|
4964
|
+
}
|
|
4965
|
+
}
|
|
4966
|
+
context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), '');
|
|
4967
|
+
context.recordScopeId(entryEdgeMintedAtScopeKey(entryKey), '');
|
|
4968
|
+
if (!keyId)
|
|
4969
|
+
return {};
|
|
4970
|
+
context.recordScopeId(entryEdgeRetiredScopeKey(entryKey), keyId);
|
|
4971
|
+
return {};
|
|
4972
|
+
}
|
|
4973
|
+
const entry = org.hosting.entries.find((e) => e.key === entryKey && e.uses?.edge);
|
|
4974
|
+
if (!entry)
|
|
4975
|
+
throw new Error(`${at}: the entry no longer declares uses.edge`);
|
|
4976
|
+
const appId = context.recordedIds()[`coolify:app:${action.key}`];
|
|
4977
|
+
if (!appId)
|
|
4978
|
+
throw new Error(`${at}: the entry's app is not recorded yet`);
|
|
4979
|
+
if (!org.domain) {
|
|
4980
|
+
throw new Error(`${at}: edge needs org.domain, and validateEntryEdge already refuses it without one`);
|
|
4981
|
+
}
|
|
4982
|
+
// A key left over from a previous mint whose delivery never succeeded
|
|
4983
|
+
// is revoked first, not left blocking every later apply forever:
|
|
4984
|
+
// nothing here keeps its secret around to retry delivering it
|
|
4985
|
+
// (CLAUDE.md "No secret leaves"), so the only way to actually recover
|
|
4986
|
+
// is to revoke the stale id and mint a fresh one -- a 404 counts as
|
|
4987
|
+
// already revoked, same as any other retire (#317 re-review).
|
|
4988
|
+
const staleUndelivered = context.recordedIds()[entryEdgeUndeliveredScopeKey(entryKey)];
|
|
4989
|
+
if (staleUndelivered) {
|
|
4990
|
+
try {
|
|
4991
|
+
await edge.revokeKey(org.id, staleUndelivered);
|
|
4992
|
+
}
|
|
4993
|
+
catch (error) {
|
|
4994
|
+
if (!isNotFoundError(error))
|
|
4995
|
+
throw error;
|
|
4996
|
+
}
|
|
4997
|
+
context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), '');
|
|
4998
|
+
await context.persistScopeIds();
|
|
4999
|
+
}
|
|
5000
|
+
const minted = await edge.mintKey({
|
|
5001
|
+
organisationId: org.id,
|
|
5002
|
+
domain: org.domain,
|
|
5003
|
+
name: desired.name,
|
|
5004
|
+
expiresDays: EDGE_ENTRY_KEY_DAYS,
|
|
5005
|
+
});
|
|
5006
|
+
context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), minted.id);
|
|
5007
|
+
await context.persistScopeIds();
|
|
5008
|
+
const envs = [
|
|
5009
|
+
{ key: 'EDGE_URL', value: desired.edgeUrl, literal: true },
|
|
5010
|
+
{ key: 'EDGE_API_KEY', value: minted.secret, literal: true },
|
|
5011
|
+
];
|
|
5012
|
+
let lastError;
|
|
5013
|
+
for (let attempt = 1; attempt <= ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS; attempt++) {
|
|
5014
|
+
try {
|
|
5015
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
5016
|
+
await this.ports.coolify.restart(appId);
|
|
5017
|
+
context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), '');
|
|
5018
|
+
// The only trace of this key's age, since nothing here renews it
|
|
5019
|
+
// (#317 review's warning-row ask reads this to flag an expiring key).
|
|
5020
|
+
context.recordScopeId(entryEdgeMintedAtScopeKey(entryKey), new Date(context.now?.() ?? Date.now()).toISOString());
|
|
5021
|
+
return { resourceId: minted.id };
|
|
5022
|
+
}
|
|
5023
|
+
catch (e) {
|
|
5024
|
+
lastError = e;
|
|
5025
|
+
}
|
|
5026
|
+
}
|
|
5027
|
+
// Names the key id only; the underlying error is dropped in case it echoes the secret.
|
|
5028
|
+
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`);
|
|
5029
|
+
}
|
|
5030
|
+
/**
|
|
5031
|
+
* F7 (#317): `plan/entry-edge-key.ts`'s `planEdgeReservedLabels` --
|
|
5032
|
+
* tells the edge service every label this org's own entries already
|
|
5033
|
+
* hold, so edge refuses a customer claiming one of them.
|
|
5034
|
+
*/
|
|
5035
|
+
async edgeReservedLabels(action, org, context) {
|
|
5036
|
+
const edge = this.ports.edge;
|
|
5037
|
+
if (!edge) {
|
|
5038
|
+
throw new Error(`edge:reserved-labels for ${org.slug}: this executor was built without an EdgePort (#317)`);
|
|
5039
|
+
}
|
|
5040
|
+
const labels = action.desired.labels;
|
|
5041
|
+
await edge.setReservedLabels(org.id, labels);
|
|
5042
|
+
context.recordScopeId(EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, JSON.stringify(labels));
|
|
5043
|
+
return {};
|
|
5044
|
+
}
|
|
4432
5045
|
/**
|
|
4433
5046
|
* F6b: mints or renews the org's own files key, or revokes the ones a
|
|
4434
5047
|
* renewal replaced. The key is minted below the files organisation's owner
|
|
@@ -4829,6 +5442,45 @@ export class FoundryStepExecutor {
|
|
|
4829
5442
|
await files.revokeOrganisationKey(org.id, target.id);
|
|
4830
5443
|
return;
|
|
4831
5444
|
}
|
|
5445
|
+
case 'cloudflare:worker': {
|
|
5446
|
+
// #318: an entry's Worker (`<orgId>:<entry>`) and its route.
|
|
5447
|
+
// No port means no way to delete either -- thrown, not silently
|
|
5448
|
+
// skipped, so it is reported blocked rather than left live with
|
|
5449
|
+
// nothing recorded ids point at any more.
|
|
5450
|
+
const workers = this.ports.workers;
|
|
5451
|
+
if (!workers) {
|
|
5452
|
+
throw new Error(`cloudflare:worker '${target.key}' for ${org.slug}: this executor was built without a WorkersPort (#318); cannot delete it`);
|
|
5453
|
+
}
|
|
5454
|
+
// The route on its recorded zone (skipped only once that zone is
|
|
5455
|
+
// positively gone), any route an interrupted apply created but
|
|
5456
|
+
// never recorded, then the script -- the same teardown retire runs.
|
|
5457
|
+
// `target.id` is `''` for a route-only partial deploy.
|
|
5458
|
+
const entryKey = target.key.slice(org.id.length + 1);
|
|
5459
|
+
await this.teardownWorker(entryKey, target.id || undefined, recordedIds, workers, `cloudflare:worker '${target.key}' for ${org.slug}`);
|
|
5460
|
+
return;
|
|
5461
|
+
}
|
|
5462
|
+
case 'edge:key': {
|
|
5463
|
+
// F7 (#317): an entry's key (`<orgId>:<entry>`); edge has no org-root key.
|
|
5464
|
+
// No port means no way to revoke it -- thrown, not silently
|
|
5465
|
+
// skipped, so the key is reported blocked rather than left live
|
|
5466
|
+
// with nothing saying so.
|
|
5467
|
+
const edge = this.ports.edge;
|
|
5468
|
+
if (!edge) {
|
|
5469
|
+
throw new Error(`edge:key '${target.key}' for ${org.slug}: this executor was built without an EdgePort (#317); cannot revoke it`);
|
|
5470
|
+
}
|
|
5471
|
+
try {
|
|
5472
|
+
await edge.revokeKey(org.id, target.id);
|
|
5473
|
+
}
|
|
5474
|
+
catch (error) {
|
|
5475
|
+
// Already gone -- the same already-retired key a retire step
|
|
5476
|
+
// handled before org destroy ever ran can still reach here
|
|
5477
|
+
// (#317 review); a 404 is success, not a reason to block
|
|
5478
|
+
// teardown forever.
|
|
5479
|
+
if (!isNotFoundError(error))
|
|
5480
|
+
throw error;
|
|
5481
|
+
}
|
|
5482
|
+
return;
|
|
5483
|
+
}
|
|
4832
5484
|
case 'coolify:app':
|
|
4833
5485
|
await this.ports.coolify.deleteApplication(target.id);
|
|
4834
5486
|
return;
|