@wtfalch/foundry 0.17.0 → 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/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/org.d.ts +74 -3
- package/dist/org.js +143 -0
- package/dist/plan/chat-matrix.d.ts +0 -1
- package/dist/plan/chat-matrix.js +4 -1
- package/dist/plan/diff.d.ts +16 -0
- package/dist/plan/diff.js +237 -24
- package/dist/plan/edge-worker.d.ts +114 -0
- package/dist/plan/edge-worker.js +201 -0
- 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 +235 -1
- package/dist/plan/executor.js +611 -27
- 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 +14 -0
- package/dist/plan/plan-apply.js +95 -4
- 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 +44 -0
- package/dist/plan/platform-providers.js +514 -7
- package/dist/plan/step-system.js +1 -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,12 +9,15 @@ 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';
|
|
13
|
+
import { entryEdgeAppliedScopeKey, entryEdgeRetiredScopeKey, entryEdgeRouteIdScopeKey, entryEdgeRoutePatternScopeKey, entryEdgeRoutePendingScopeKey, entryEdgeRouteZoneScopeKey, entryEdgeUploadedScopeKey, } from './edge-worker.js';
|
|
12
14
|
import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-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';
|
|
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';
|
|
14
17
|
import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
15
18
|
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
16
19
|
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';
|
|
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';
|
|
18
21
|
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
19
22
|
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
|
|
20
23
|
/**
|
|
@@ -657,6 +660,8 @@ export class FoundryStepExecutor {
|
|
|
657
660
|
return this.cloudflareAccess(action, org);
|
|
658
661
|
case 'cloudflare:ratelimit':
|
|
659
662
|
return this.cloudflareRateLimit(org);
|
|
663
|
+
case 'cloudflare:worker':
|
|
664
|
+
return this.cloudflareWorker(action, org, context);
|
|
660
665
|
case 'files:organisation':
|
|
661
666
|
return this.filesOrganisation(action, org, context);
|
|
662
667
|
case 'files:key':
|
|
@@ -673,6 +678,10 @@ export class FoundryStepExecutor {
|
|
|
673
678
|
return action.key.includes(':')
|
|
674
679
|
? this.aiEntryKey(action, org, context)
|
|
675
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);
|
|
676
685
|
case 'coolify:mail':
|
|
677
686
|
return this.coolifyMail(action, org, context);
|
|
678
687
|
case 'coolify:mail-copy':
|
|
@@ -840,6 +849,192 @@ export class FoundryStepExecutor {
|
|
|
840
849
|
}
|
|
841
850
|
return { resourceId: created.id };
|
|
842
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
|
+
}
|
|
843
1038
|
// ---- zitadel --------------------------------------------------------
|
|
844
1039
|
async zitadelIssuer(action, org, context) {
|
|
845
1040
|
if (action.desired.provision !== true) {
|
|
@@ -1498,7 +1693,15 @@ export class FoundryStepExecutor {
|
|
|
1498
1693
|
// One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
|
|
1499
1694
|
// second entry sharing the name would delete the first's live token.
|
|
1500
1695
|
if (entry.key === APP_ENTRY_KEY) {
|
|
1501
|
-
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);
|
|
1502
1705
|
}
|
|
1503
1706
|
return { resourceId: appId };
|
|
1504
1707
|
}
|
|
@@ -1937,12 +2140,73 @@ export class FoundryStepExecutor {
|
|
|
1937
2140
|
* comment), never logged, never interpolated into a thrown error -- the
|
|
1938
2141
|
* same discipline `pushDatabaseUrl`'s doc comment states for a database's
|
|
1939
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).
|
|
1940
2159
|
*/
|
|
1941
|
-
async ensureAppLettermintMail(appId, org, context, running) {
|
|
2160
|
+
async ensureAppLettermintMail(appId, org, context, running, hasPlatformApexField, platformApex, platformLettermintProjectId) {
|
|
1942
2161
|
const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
1943
2162
|
if (!mailDomain)
|
|
1944
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
|
+
}
|
|
1945
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
|
+
}
|
|
1946
2210
|
const [projects, envs] = await Promise.all([
|
|
1947
2211
|
this.ports.lettermint.listProjects(),
|
|
1948
2212
|
this.ports.coolify.listEnvs(appId),
|
|
@@ -1973,9 +2237,11 @@ export class FoundryStepExecutor {
|
|
|
1973
2237
|
// old project is deleted below. A just-created app starts with the new env.
|
|
1974
2238
|
if (!healthy && running)
|
|
1975
2239
|
await this.ports.coolify.restart(appId);
|
|
1976
|
-
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
|
|
2240
|
+
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
|
|
1977
2241
|
for (const project of allProjects) {
|
|
1978
|
-
if (project.name === appName &&
|
|
2242
|
+
if (project.name === appName &&
|
|
2243
|
+
project.id !== keepProjectId &&
|
|
2244
|
+
project.id !== platformLettermintProjectId) {
|
|
1979
2245
|
await this.ports.lettermint.deleteProject(project.id);
|
|
1980
2246
|
}
|
|
1981
2247
|
}
|
|
@@ -2013,8 +2279,14 @@ export class FoundryStepExecutor {
|
|
|
2013
2279
|
* it the same way the app step does, rather than assuming it already
|
|
2014
2280
|
* exists -- the domain must never end up unrestricted, whichever step gets
|
|
2015
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.
|
|
2016
2288
|
*/
|
|
2017
|
-
async ensureOrgLettermintRelay(org, mailOrg, mailDomain, context) {
|
|
2289
|
+
async ensureOrgLettermintRelay(org, mailOrg, mailDomain, context, platformApex, platformLettermintProjectId) {
|
|
2018
2290
|
const relayName = `${org.slug}-relay`;
|
|
2019
2291
|
// #345: `mailOrg.domain` is absent for a level-1 org -- `mailDomain` is
|
|
2020
2292
|
// `applyOrgMail`'s own resolved result, the one domain `MailPort`'s
|
|
@@ -2033,7 +2305,7 @@ export class FoundryStepExecutor {
|
|
|
2033
2305
|
let keepProjectId;
|
|
2034
2306
|
if (healthy && held) {
|
|
2035
2307
|
keepProjectId = held.id;
|
|
2036
|
-
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
|
|
2308
|
+
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
|
|
2037
2309
|
}
|
|
2038
2310
|
else {
|
|
2039
2311
|
const created = await this.ports.lettermint.createProject(relayName);
|
|
@@ -2044,7 +2316,7 @@ export class FoundryStepExecutor {
|
|
|
2044
2316
|
// reaches Stalwart: a failure here leaves the working route alone.
|
|
2045
2317
|
// The restriction covers old and new projects, so the old one keeps
|
|
2046
2318
|
// sending until the route switches.
|
|
2047
|
-
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
|
|
2319
|
+
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
|
|
2048
2320
|
await this.ports.mail.applyOrgRelay(relayOrg, created.token);
|
|
2049
2321
|
}
|
|
2050
2322
|
catch (err) {
|
|
@@ -2057,7 +2329,9 @@ export class FoundryStepExecutor {
|
|
|
2057
2329
|
await context.persistScopeIds();
|
|
2058
2330
|
}
|
|
2059
2331
|
for (const project of allProjects) {
|
|
2060
|
-
if (project.name === relayName &&
|
|
2332
|
+
if (project.name === relayName &&
|
|
2333
|
+
project.id !== keepProjectId &&
|
|
2334
|
+
project.id !== platformLettermintProjectId) {
|
|
2061
2335
|
await this.ports.lettermint.deleteProject(project.id);
|
|
2062
2336
|
}
|
|
2063
2337
|
}
|
|
@@ -2089,10 +2363,31 @@ export class FoundryStepExecutor {
|
|
|
2089
2363
|
* unrestricted domain cannot verify and cannot send, whichever project
|
|
2090
2364
|
* owns it (post-#140 review finding, restated here rather than only at
|
|
2091
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.
|
|
2092
2381
|
*/
|
|
2093
|
-
async ensureLettermintDomainRestriction(org, mailDomain, allProjects) {
|
|
2382
|
+
async ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId) {
|
|
2094
2383
|
const appName = `${org.slug}-app`;
|
|
2095
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
|
+
}
|
|
2096
2391
|
const existing = await this.findExactLettermintDomain(mailDomain);
|
|
2097
2392
|
const domain = existing
|
|
2098
2393
|
? await this.ports.lettermint.getDomain(existing.id)
|
|
@@ -2100,6 +2395,11 @@ export class FoundryStepExecutor {
|
|
|
2100
2395
|
const scopedProjectIds = allProjects
|
|
2101
2396
|
.filter((p) => p.name === appName || p.name === relayName)
|
|
2102
2397
|
.map((p) => p.id);
|
|
2398
|
+
if (isPlatformApexDomain &&
|
|
2399
|
+
platformLettermintProjectId &&
|
|
2400
|
+
!scopedProjectIds.includes(platformLettermintProjectId)) {
|
|
2401
|
+
scopedProjectIds.push(platformLettermintProjectId);
|
|
2402
|
+
}
|
|
2103
2403
|
await this.ports.lettermint.restrictDomainToProjects(domain.id, scopedProjectIds);
|
|
2104
2404
|
const zone = await this.ports.cloudflare.findZoneByApex(mailDomain);
|
|
2105
2405
|
if (!zone) {
|
|
@@ -2739,6 +3039,34 @@ export class FoundryStepExecutor {
|
|
|
2739
3039
|
async coolifyChat(action, org, context) {
|
|
2740
3040
|
const recorded = context.recordedIds();
|
|
2741
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
|
+
}
|
|
2742
3070
|
if (action.desired.usesDomain === false) {
|
|
2743
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)');
|
|
2744
3072
|
}
|
|
@@ -2881,8 +3209,13 @@ export class FoundryStepExecutor {
|
|
|
2881
3209
|
agoraHost,
|
|
2882
3210
|
masClientId: secrets.value.masClientId,
|
|
2883
3211
|
masClientSecret: secrets.value.masClientSecret,
|
|
2884
|
-
databaseUrl: serviceDbRow.internal_db_url,
|
|
2885
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');
|
|
2886
3219
|
// A file-storage write alone never reaches a running container. Deploy an
|
|
2887
3220
|
// app that is new, whose config was freshly minted, or that is not
|
|
2888
3221
|
// running now. The last test reads the live status, not what this run
|
|
@@ -2893,10 +3226,19 @@ export class FoundryStepExecutor {
|
|
|
2893
3226
|
for (const app of [mas, synapse, service]) {
|
|
2894
3227
|
if (!app.existed ||
|
|
2895
3228
|
secrets.changed ||
|
|
3229
|
+
(app === service && serviceDbPush.changed) ||
|
|
2896
3230
|
!isRunningOrStarting((await this.ports.coolify.getApplication(app.appId)).status)) {
|
|
2897
3231
|
await this.ports.coolify.deploy(app.appId);
|
|
2898
3232
|
}
|
|
2899
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
|
+
}
|
|
2900
3242
|
// Last: the marker tells `desiredAgoraEnv` the names and envs are in place.
|
|
2901
3243
|
context.recordScopeId(CHAT_SERVICE_URL_SCOPE_KEY, chatUpstreams(org.slug).chat);
|
|
2902
3244
|
return { resourceId: mas.appId };
|
|
@@ -2976,7 +3318,6 @@ export class FoundryStepExecutor {
|
|
|
2976
3318
|
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
2977
3319
|
const bindingSecret = await this.ensureCallsBindingSecret(app.appId);
|
|
2978
3320
|
const envs = [
|
|
2979
|
-
{ key: 'DATABASE_URL', value: db.internal_db_url },
|
|
2980
3321
|
{ key: 'LIVEKIT_URL', value: CALLS_SFU_PRIVATE_URL },
|
|
2981
3322
|
{ key: 'LIVEKIT_PUBLIC_URL', value: CALLS_SFU_PUBLIC_URL },
|
|
2982
3323
|
{ key: 'LIVEKIT_API_KEY', value: sfu.apiKey },
|
|
@@ -3015,17 +3356,40 @@ export class FoundryStepExecutor {
|
|
|
3015
3356
|
// unguarded and the planner reports the lost secret instead of minting again.
|
|
3016
3357
|
if (token)
|
|
3017
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]);
|
|
3018
3371
|
// A container reads its name and env only at start, so a running app is
|
|
3019
3372
|
// restarted once, when this run is the one that gave it its name or its
|
|
3020
3373
|
// token env (the same rule `coolifyApp` follows when an existing app's
|
|
3021
|
-
// 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.
|
|
3022
3381
|
const givesTokenEnv = token !== null && context.recordedIds()[CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY] !== tokenMarker;
|
|
3023
|
-
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)) {
|
|
3024
3386
|
await this.ports.coolify.restart(app.appId);
|
|
3025
3387
|
}
|
|
3026
3388
|
context.recordScopeId(CALLS_SERVICE_NAME_SCOPE_KEY, name);
|
|
3027
3389
|
if (token && tokenMarker)
|
|
3028
3390
|
context.recordScopeId(CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, tokenMarker);
|
|
3391
|
+
if (dbPush.pushed)
|
|
3392
|
+
context.recordScopeId(dbMarkerKey, '1');
|
|
3029
3393
|
return { resourceId: app.appId };
|
|
3030
3394
|
}
|
|
3031
3395
|
/**
|
|
@@ -3849,22 +4213,26 @@ export class FoundryStepExecutor {
|
|
|
3849
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`);
|
|
3850
4214
|
}
|
|
3851
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;
|
|
3852
4220
|
if (mode === 'off') {
|
|
3853
4221
|
const parkedMailOrg = mailOrgFor(org, usesDomain, 'hosted');
|
|
3854
4222
|
await this.ports.mail.parkOrgMail(parkedMailOrg, options);
|
|
3855
|
-
await this.parkLettermintMail(org, parkedMailOrg, context);
|
|
4223
|
+
await this.parkLettermintMail(org, parkedMailOrg, context, platformApex, platformLettermintProjectId);
|
|
3856
4224
|
return undefined;
|
|
3857
4225
|
}
|
|
3858
4226
|
const mailOrg = mailOrgFor(org, usesDomain, mode);
|
|
3859
4227
|
const result = await this.ports.mail.applyOrgMail(mailOrg, options);
|
|
3860
4228
|
await this.publishMailDnsRecords(org, result);
|
|
3861
|
-
await this.deleteStaleLettermintDomain(context, result.domain);
|
|
4229
|
+
await this.deleteStaleLettermintDomain(context, result.domain, platformApex);
|
|
3862
4230
|
context.recordScopeId(MAIL_DOMAIN_SCOPE_KEY, result.domain);
|
|
3863
4231
|
// #177: what this step wrote from the platform, so `planMail` can see a later change.
|
|
3864
4232
|
context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
|
|
3865
4233
|
context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
|
|
3866
4234
|
await this.ensureOwnerMailbox(org, mailOrg, options, context);
|
|
3867
|
-
await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context);
|
|
4235
|
+
await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context, platformApex, platformLettermintProjectId);
|
|
3868
4236
|
await this.ensureRoleMailboxes(org, mailOrg, result.domain, options, context);
|
|
3869
4237
|
return { resourceId: result.domain };
|
|
3870
4238
|
}
|
|
@@ -3960,11 +4328,20 @@ export class FoundryStepExecutor {
|
|
|
3960
4328
|
* domain recorded) and on a same-domain re-apply (compared
|
|
3961
4329
|
* case-insensitively, the same rule {@link findExactLettermintDomain}
|
|
3962
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.
|
|
3963
4338
|
*/
|
|
3964
|
-
async deleteStaleLettermintDomain(context, newDomain) {
|
|
4339
|
+
async deleteStaleLettermintDomain(context, newDomain, platformApex) {
|
|
3965
4340
|
const previous = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
3966
4341
|
if (!previous || previous.toLowerCase() === newDomain.toLowerCase())
|
|
3967
4342
|
return;
|
|
4343
|
+
if (platformApex && previous.toLowerCase() === platformApex.toLowerCase())
|
|
4344
|
+
return;
|
|
3968
4345
|
const stale = await this.findExactLettermintDomain(previous);
|
|
3969
4346
|
if (stale)
|
|
3970
4347
|
await this.ports.lettermint.deleteDomain(stale.id);
|
|
@@ -4036,20 +4413,28 @@ export class FoundryStepExecutor {
|
|
|
4036
4413
|
* A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
|
|
4037
4414
|
* nothing was ever provisioned for an org whose mail went straight from
|
|
4038
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).
|
|
4039
4420
|
*/
|
|
4040
|
-
async parkLettermintMail(org, mailOrg, context) {
|
|
4421
|
+
async parkLettermintMail(org, mailOrg, context, platformApex, platformLettermintProjectId) {
|
|
4041
4422
|
const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
4042
4423
|
if (!mailDomain)
|
|
4043
4424
|
return;
|
|
4425
|
+
const isPlatformApexOrg = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
|
|
4044
4426
|
// #345: `mailOrg.domain` is absent for a level-1 org -- the previously
|
|
4045
4427
|
// recorded mail domain is the one `MailPort.removeOrgRelay` needs.
|
|
4046
4428
|
await this.ports.mail.removeOrgRelay({ ...mailOrg, domain: mailDomain });
|
|
4047
4429
|
const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
|
|
4048
4430
|
const projects = await this.ports.lettermint.listProjects();
|
|
4049
4431
|
for (const project of projects) {
|
|
4050
|
-
if (names.has(project.name))
|
|
4432
|
+
if (names.has(project.name) && project.id !== platformLettermintProjectId) {
|
|
4051
4433
|
await this.ports.lettermint.deleteProject(project.id);
|
|
4434
|
+
}
|
|
4052
4435
|
}
|
|
4436
|
+
if (isPlatformApexOrg)
|
|
4437
|
+
return;
|
|
4053
4438
|
const domain = await this.findExactLettermintDomain(mailDomain);
|
|
4054
4439
|
if (domain)
|
|
4055
4440
|
await this.ports.lettermint.deleteDomain(domain.id);
|
|
@@ -4214,13 +4599,14 @@ export class FoundryStepExecutor {
|
|
|
4214
4599
|
return { resourceId: provisioned.organisationId };
|
|
4215
4600
|
}
|
|
4216
4601
|
async aiBudget(action, org) {
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
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;
|
|
4224
4610
|
const ai = await this.aiPort();
|
|
4225
4611
|
await ai.updateBudget(org.id, { keyId: null, limitMicros });
|
|
4226
4612
|
return { resourceId: org.id };
|
|
@@ -4429,6 +4815,165 @@ export class FoundryStepExecutor {
|
|
|
4429
4815
|
ids.push(keyId);
|
|
4430
4816
|
context.recordScopeId(listKey, JSON.stringify(ids));
|
|
4431
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
|
+
}
|
|
4432
4977
|
/**
|
|
4433
4978
|
* F6b: mints or renews the org's own files key, or revokes the ones a
|
|
4434
4979
|
* renewal replaced. The key is minted below the files organisation's owner
|
|
@@ -4829,6 +5374,45 @@ export class FoundryStepExecutor {
|
|
|
4829
5374
|
await files.revokeOrganisationKey(org.id, target.id);
|
|
4830
5375
|
return;
|
|
4831
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
|
+
}
|
|
4832
5416
|
case 'coolify:app':
|
|
4833
5417
|
await this.ports.coolify.deleteApplication(target.id);
|
|
4834
5418
|
return;
|