@wtfalch/foundry 0.33.1 → 0.34.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/versions.d.ts +4 -0
- package/dist/calls-sfu.d.ts +10 -0
- package/dist/calls-sfu.js +41 -0
- package/dist/index.d.ts +14 -6
- package/dist/index.js +7 -3
- package/dist/migrations/0051_org_services.sql +13 -0
- package/dist/migrations/0052_platform_mail_relay.sql +8 -0
- package/dist/org.d.ts +18 -0
- package/dist/org.js +26 -0
- package/dist/plan/calls-matrix.d.ts +9 -0
- package/dist/plan/calls-matrix.js +17 -0
- package/dist/plan/cloudflared-connector.d.ts +7 -1
- package/dist/plan/cloudflared-connector.js +14 -1
- package/dist/plan/diff.d.ts +29 -0
- package/dist/plan/diff.js +259 -18
- package/dist/plan/executor.d.ts +112 -0
- package/dist/plan/executor.js +594 -33
- package/dist/plan/health-check-settings.d.ts +28 -0
- package/dist/plan/health-check-settings.js +36 -0
- package/dist/plan/health-gaps.d.ts +33 -0
- package/dist/plan/health-gaps.js +89 -0
- package/dist/plan/held-key-refs.d.ts +10 -0
- package/dist/plan/held-key-refs.js +10 -0
- package/dist/plan/index.d.ts +14 -6
- package/dist/plan/index.js +7 -3
- package/dist/plan/live.d.ts +5 -2
- package/dist/plan/live.js +15 -2
- package/dist/plan/locked-hostnames.d.ts +5 -2
- package/dist/plan/locked-hostnames.js +9 -1
- package/dist/plan/login-host.d.ts +62 -2
- package/dist/plan/login-host.js +104 -1
- package/dist/plan/mail-relay.d.ts +120 -0
- package/dist/plan/mail-relay.js +323 -0
- package/dist/plan/org-names.d.ts +24 -0
- package/dist/plan/org-names.js +52 -0
- package/dist/plan/own-diff.d.ts +169 -0
- package/dist/plan/own-diff.js +432 -0
- package/dist/plan/own-dns.d.ts +6 -0
- package/dist/plan/own-dns.js +15 -0
- package/dist/plan/own-limits.d.ts +22 -0
- package/dist/plan/own-limits.js +101 -0
- package/dist/plan/own-services.d.ts +193 -0
- package/dist/plan/own-services.js +167 -0
- package/dist/plan/own-sfu.d.ts +69 -0
- package/dist/plan/own-sfu.js +255 -0
- package/dist/plan/plan-apply.d.ts +78 -7
- package/dist/plan/plan-apply.js +205 -12
- package/dist/plan/platform-diff.d.ts +28 -0
- package/dist/plan/platform-diff.js +45 -3
- package/dist/plan/platform-org.d.ts +34 -0
- package/dist/plan/platform-org.js +94 -2
- package/dist/plan/platform-providers.d.ts +356 -8
- package/dist/plan/platform-providers.js +661 -353
- package/dist/plan/sign-in-org.d.ts +1 -1
- package/dist/plan/sign-in-org.js +20 -2
- package/dist/plan/zitadel-login-policy.js +1 -1
- package/dist/plan/zitadel-system-api.d.ts +22 -1
- package/dist/plan/zitadel-system-api.js +49 -8
- package/dist/platform.d.ts +8 -0
- package/dist/slug.js +1 -0
- package/dist/store/postgres.js +51 -2
- package/dist/store/schema.d.ts +51 -0
- package/dist/store/schema.js +5 -0
- package/package.json +1 -1
- package/versions.json +3 -1
package/dist/plan/plan-apply.js
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
3
|
-
import {
|
|
3
|
+
import { sfuSlotPorts } from '../calls-sfu.js';
|
|
4
|
+
import { ownsServices, validateHosting } from '../org.js';
|
|
4
5
|
import { PLATFORM_SERVICE_KEYS } from '../platform.js';
|
|
5
6
|
import { AlreadyRunningError, StepFailedError } from '../runner/errors.js';
|
|
6
7
|
import { StaleLeaseError, } from '../store.js';
|
|
7
8
|
import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
|
|
8
9
|
import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
|
|
9
10
|
import { unconfirmedUpgrades } from './confirm-upgrade.js';
|
|
10
|
-
import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
|
|
11
|
+
import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, isPlatformOwnOrg, toPlan, } from './diff.js';
|
|
11
12
|
import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
|
|
12
13
|
import { BOULE_EDGE_ENTRY_KEY, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
|
|
13
14
|
import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
|
|
14
15
|
import { readLivePlatformResources, readLiveResources, } from './live.js';
|
|
15
16
|
import { lockedHostnames } from './locked-hostnames.js';
|
|
17
|
+
import { agoraIssuerHostFor, loginHostServesAgora, readLoginHostDiscovery, } from './login-host.js';
|
|
18
|
+
import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, } from './mail-relay.js';
|
|
19
|
+
import { findNameClashes, nameClashMessage } from './org-names.js';
|
|
20
|
+
import { OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, orgTarget } from './own-services.js';
|
|
16
21
|
import { PLATFORM_ARCHON_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_KEY, appStepKey, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
|
|
17
|
-
import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, dnsBlockedReason, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
|
|
22
|
+
import { DEFAULT_ORG_PLATFORM_READINESS, NO_DOMAIN_MAIL_APEX, ORG_PLATFORM_KEY_REFS, agoraHostValuesForOwn, agoraHostValuesFromPlatform, dnsBlockedReason, heldOwnKeyRefs, heldPlatformKeyRefs, orgPlatformReadinessFor, orgPlatformReadinessFrom, serviceGitRefsOf, } from './platform-org.js';
|
|
18
23
|
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
19
24
|
import { liveWorkers, workerTraceScopeKeys } from './worker.js';
|
|
20
25
|
/** #441: the lease scope that serializes every mutation of one company, and the one for the shared platform. */
|
|
@@ -126,6 +131,10 @@ const HOSTNAME_STEPS = new Set([
|
|
|
126
131
|
* destructive or migrating step without the org's name typed, or one the
|
|
127
132
|
* actor is not authorized to run.
|
|
128
133
|
*/
|
|
134
|
+
/** `unmanaged` key of the fixed line for an own org on a host with no `ownServices` port. */
|
|
135
|
+
const OWN_SERVICES_PORT_ABSENT_KEY = 'own-services:port';
|
|
136
|
+
/** `unmanaged` key of the fixed line for an own org whose scoped ports have no files-organisation port. */
|
|
137
|
+
const OWN_FILES_ORGANISATION_PORT_ABSENT_KEY = 'own-services:files-port';
|
|
129
138
|
export class FoundryPlanApply {
|
|
130
139
|
store;
|
|
131
140
|
readers;
|
|
@@ -137,6 +146,7 @@ export class FoundryPlanApply {
|
|
|
137
146
|
agora;
|
|
138
147
|
jobsScheduler;
|
|
139
148
|
loginHostBindings;
|
|
149
|
+
loginHostDiscovery;
|
|
140
150
|
zitadelIdTokenUserinfo;
|
|
141
151
|
broker;
|
|
142
152
|
edgeWorkers;
|
|
@@ -144,6 +154,8 @@ export class FoundryPlanApply {
|
|
|
144
154
|
hostFiles;
|
|
145
155
|
hostAi;
|
|
146
156
|
hostEdge;
|
|
157
|
+
hostOwnServices;
|
|
158
|
+
sfuPortsOpen;
|
|
147
159
|
onlySteps;
|
|
148
160
|
now;
|
|
149
161
|
leaseTtlMs;
|
|
@@ -161,6 +173,7 @@ export class FoundryPlanApply {
|
|
|
161
173
|
this.agora = options.agora;
|
|
162
174
|
this.jobsScheduler = options.jobsScheduler ?? false;
|
|
163
175
|
this.loginHostBindings = options.loginHostBindings ?? false;
|
|
176
|
+
this.loginHostDiscovery = options.loginHostDiscovery;
|
|
164
177
|
this.zitadelIdTokenUserinfo = options.zitadelIdTokenUserinfo ?? false;
|
|
165
178
|
this.broker = options.broker ?? false;
|
|
166
179
|
this.edgeWorkers = options.edgeWorkers ?? false;
|
|
@@ -168,6 +181,8 @@ export class FoundryPlanApply {
|
|
|
168
181
|
this.hostFiles = options.filesOrganisation ?? false;
|
|
169
182
|
this.hostAi = options.aiOrganisation ?? false;
|
|
170
183
|
this.hostEdge = options.edge ?? false;
|
|
184
|
+
this.hostOwnServices = options.ownServices ?? false;
|
|
185
|
+
this.sfuPortsOpen = options.sfuPortsOpen;
|
|
171
186
|
this.onlySteps = options.onlySteps;
|
|
172
187
|
this.now = options.now ?? (() => new Date());
|
|
173
188
|
this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
|
|
@@ -897,21 +912,45 @@ export class FoundryPlanApply {
|
|
|
897
912
|
});
|
|
898
913
|
return applied;
|
|
899
914
|
}
|
|
915
|
+
/** An own org with calls on: the host's answer about its slot's ports. A failure or a slow answer is "not open". */
|
|
916
|
+
async readSfuPortsOpen(org) {
|
|
917
|
+
const slot = org.services?.sfuSlot;
|
|
918
|
+
if (!this.sfuPortsOpen || !ownsServices(org) || !org.calls.enabled || !slot)
|
|
919
|
+
return false;
|
|
920
|
+
let ports;
|
|
921
|
+
try {
|
|
922
|
+
ports = sfuSlotPorts(slot);
|
|
923
|
+
}
|
|
924
|
+
catch {
|
|
925
|
+
return false;
|
|
926
|
+
}
|
|
927
|
+
const timeout = new Promise((resolve) => setTimeout(() => resolve(false), 5_000));
|
|
928
|
+
return Promise.race([this.sfuPortsOpen(ports).catch(() => false), timeout]);
|
|
929
|
+
}
|
|
900
930
|
async computeFreshPlan(id) {
|
|
901
931
|
const org = await this.store.get(id);
|
|
902
932
|
if (!org)
|
|
903
933
|
throw new DeclaredOrgNotFoundError(id);
|
|
904
|
-
const
|
|
905
|
-
|
|
906
|
-
this.
|
|
907
|
-
this.orgPlatformReadiness(),
|
|
908
|
-
this.orgAgoraHost(
|
|
934
|
+
const applied = await this.store.getAppliedState(id);
|
|
935
|
+
const [live, platformReadiness, agoraHost, archonUrl] = await Promise.all([
|
|
936
|
+
readLiveResources(id, this.readers, org, applied?.serviceIds ?? {}),
|
|
937
|
+
this.orgPlatformReadiness(org, applied?.serviceIds ?? {}),
|
|
938
|
+
this.orgAgoraHost(org, applied?.serviceIds ?? {}),
|
|
909
939
|
this.orgArchonUrl(),
|
|
910
940
|
]);
|
|
911
941
|
const otherSlugs = (await this.store.list()).filter((o) => o.id !== id).map((o) => o.slug);
|
|
912
942
|
const platformHostnames = await this.platformServiceHostnames();
|
|
943
|
+
const recorded = applied?.serviceIds ?? {};
|
|
944
|
+
// Read only for an org Agora may switch and whose login host is applied.
|
|
945
|
+
const issuerHost = agoraIssuerHostFor(org, isPlatformOwnOrg(org, platformReadiness.platformOwnerOrgId)).host;
|
|
946
|
+
const loginHostServesDiscovery = issuerHost !== null && loginHostServesAgora(issuerHost, org, recorded)
|
|
947
|
+
? await readLoginHostDiscovery(this.loginHostDiscovery, issuerHost)
|
|
948
|
+
: false;
|
|
949
|
+
const sfuPortsOpen = await this.readSfuPortsOpen(org);
|
|
950
|
+
const resumable = await this.ownResumableRefs(org);
|
|
913
951
|
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
|
|
914
952
|
loginHostBindings: this.loginHostBindings,
|
|
953
|
+
loginHostServesDiscovery,
|
|
915
954
|
zitadelIdTokenUserinfo: this.zitadelIdTokenUserinfo,
|
|
916
955
|
broker: this.broker,
|
|
917
956
|
edgeWorkers: this.edgeWorkers,
|
|
@@ -920,7 +959,47 @@ export class FoundryPlanApply {
|
|
|
920
959
|
now: () => this.now().getTime(),
|
|
921
960
|
archonUrl,
|
|
922
961
|
platformHostnames,
|
|
962
|
+
ownServices: this.hostOwnServices,
|
|
963
|
+
ownMailKeyHeld: await this.ownMailKeyHeld(org),
|
|
964
|
+
ownMailKeyMintable: this.executor.ownMailKeyMintable?.() ?? false,
|
|
965
|
+
ownJobsTokenHeld: resumable.jobs,
|
|
966
|
+
ownSfuKeysHeld: resumable.sfu,
|
|
967
|
+
...(ownsServices(org) ? { mailRelayApplied: await this.mailRelayApplied() } : {}),
|
|
968
|
+
sfuPortsOpen,
|
|
969
|
+
ownFilesDeps: this.executor.ownFilesDeps?.(),
|
|
970
|
+
...(ownsServices(org) && this.executor.ownFilesR2Ports
|
|
971
|
+
? { ownFilesR2Ports: await this.executor.ownFilesR2Ports(org, recorded) }
|
|
972
|
+
: {}),
|
|
923
973
|
});
|
|
974
|
+
if (ownsServices(org) && !this.hostOwnServices) {
|
|
975
|
+
detail.unmanaged.push({
|
|
976
|
+
description: `${org.slug} runs its own services, and this host has no ownServices port`,
|
|
977
|
+
key: OWN_SERVICES_PORT_ABSENT_KEY,
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
if (ownsServices(org) &&
|
|
981
|
+
this.hostOwnServices &&
|
|
982
|
+
detail.plannedSteps.some(({ action }) => action.system === 'files' && action.kind === 'organisation') &&
|
|
983
|
+
(await this.executor.ownFilesOrganisationPort?.(org, recorded)) === false) {
|
|
984
|
+
detail.plannedSteps = detail.plannedSteps.filter(({ action }) => !(action.system === 'files' && action.kind === 'organisation'));
|
|
985
|
+
detail.unmanaged.push({
|
|
986
|
+
description: `${org.slug}'s Files organisation is blocked, not planned: the host gave the company no files-organisation port`,
|
|
987
|
+
key: OWN_FILES_ORGANISATION_PORT_ABSENT_KEY,
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
const clashes = findNameClashes(org, await this.store.list());
|
|
991
|
+
for (const source of ['own', 'hosting']) {
|
|
992
|
+
const clash = clashes.find((c) => c.source === source);
|
|
993
|
+
if (!clash)
|
|
994
|
+
continue;
|
|
995
|
+
detail.plannedSteps = detail.plannedSteps.filter(({ action }) => source === 'own'
|
|
996
|
+
? !(action.system === 'coolify' && action.kind.startsWith('own-'))
|
|
997
|
+
: !(action.system === 'coolify' && (action.kind === 'app' || action.kind === 'database')));
|
|
998
|
+
detail.unmanaged.push({
|
|
999
|
+
description: `${org.slug}'s ${source === 'own' ? 'own services are' : 'hosting is'} blocked, not planned: ${nameClashMessage(org.slug, clash)}`,
|
|
1000
|
+
key: `names:clash:${source}`,
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
924
1003
|
const invalid = await this.invalidHosting(org);
|
|
925
1004
|
if (invalid) {
|
|
926
1005
|
// Rule 2: reported in `unmanaged`, not thrown. Steps that read the
|
|
@@ -960,7 +1039,40 @@ export class FoundryPlanApply {
|
|
|
960
1039
|
* DEFAULT_ORG_PLATFORM_READINESS} reports every service not ready, so every
|
|
961
1040
|
* org step that needs one is blocked.
|
|
962
1041
|
*/
|
|
963
|
-
async orgPlatformReadiness() {
|
|
1042
|
+
async orgPlatformReadiness(org, orgRecordedIds) {
|
|
1043
|
+
const base = await this.sharedPlatformReadiness();
|
|
1044
|
+
if (!ownsServices(org))
|
|
1045
|
+
return base;
|
|
1046
|
+
const held = this.platformDeps?.heldKeys;
|
|
1047
|
+
const platform = await this.platformDeps?.store.get();
|
|
1048
|
+
const readiness = orgPlatformReadinessFor(org, base, orgRecordedIds, held ? await heldOwnKeyRefs(held, org) : new Set());
|
|
1049
|
+
return platform ? { ...readiness, serviceGitRefs: serviceGitRefsOf(platform) } : readiness;
|
|
1050
|
+
}
|
|
1051
|
+
/** An own org's Lettermint key is held. A resolver that throws counts as not held. */
|
|
1052
|
+
async ownMailKeyHeld(org) {
|
|
1053
|
+
const held = this.platformDeps?.heldKeys;
|
|
1054
|
+
if (!ownsServices(org) || !held)
|
|
1055
|
+
return false;
|
|
1056
|
+
return (await heldOwnKeyRefs(held, org)).has(orgTarget(org).refs.brokerMailKey);
|
|
1057
|
+
}
|
|
1058
|
+
/** Which of an own org's undelivered-secret refs hold a value now, so a rerun resumes instead of blocking. */
|
|
1059
|
+
async ownResumableRefs(org) {
|
|
1060
|
+
const held = this.platformDeps?.heldKeys;
|
|
1061
|
+
if (!ownsServices(org) || !held)
|
|
1062
|
+
return { jobs: false, sfu: false };
|
|
1063
|
+
const refs = orgTarget(org).refs;
|
|
1064
|
+
const heldRefs = await heldOwnKeyRefs(held, org);
|
|
1065
|
+
return { jobs: heldRefs.has(refs.jobsAdminToken), sfu: heldRefs.has(refs.livekitKeys) };
|
|
1066
|
+
}
|
|
1067
|
+
/** The relay is declared and has recorded the routes it last applied: port 25 reaches the mail servers. */
|
|
1068
|
+
async mailRelayApplied() {
|
|
1069
|
+
const platform = await this.platformDeps?.store.get();
|
|
1070
|
+
if (!platform?.mail.relay)
|
|
1071
|
+
return false;
|
|
1072
|
+
const recorded = await this.platformDeps?.store.getServiceIds();
|
|
1073
|
+
return Boolean(recorded?.[PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY]);
|
|
1074
|
+
}
|
|
1075
|
+
async sharedPlatformReadiness() {
|
|
964
1076
|
if (!this.platformDeps)
|
|
965
1077
|
return DEFAULT_ORG_PLATFORM_READINESS;
|
|
966
1078
|
const platform = await this.platformDeps.store.get();
|
|
@@ -998,11 +1110,14 @@ export class FoundryPlanApply {
|
|
|
998
1110
|
* `platformDeps` {@link orgPlatformReadiness} reads -- but only when the
|
|
999
1111
|
* host supplies no `agora` port of its own. An explicit `agora` (#164)
|
|
1000
1112
|
* always wins, unchanged, so a host that already has its own values
|
|
1001
|
-
* (perhaps not yet wired to this platform) keeps them.
|
|
1113
|
+
* (perhaps not yet wired to this platform) keeps them. An org that owns its
|
|
1114
|
+
* services never takes the host's values: they belong to the shared services.
|
|
1002
1115
|
*/
|
|
1003
|
-
async orgAgoraHost(
|
|
1116
|
+
async orgAgoraHost(org, orgRecordedIds) {
|
|
1117
|
+
if (ownsServices(org))
|
|
1118
|
+
return agoraHostValuesForOwn(org, orgRecordedIds);
|
|
1004
1119
|
if (this.agora)
|
|
1005
|
-
return readAgoraHost(this.agora, id);
|
|
1120
|
+
return readAgoraHost(this.agora, org.id);
|
|
1006
1121
|
if (!this.platformDeps)
|
|
1007
1122
|
return NO_AGORA_HOST_VALUES;
|
|
1008
1123
|
const platform = await this.platformDeps.store.get();
|
|
@@ -1051,16 +1166,73 @@ export class FoundryPlanApply {
|
|
|
1051
1166
|
const callsOrgSlugs = (await this.store.list())
|
|
1052
1167
|
.filter((org) => org.calls.enabled)
|
|
1053
1168
|
.map((org) => org.slug);
|
|
1169
|
+
const mailRelay = platform.mail.relay === true ? await this.mailRelayInputs(platform) : null;
|
|
1054
1170
|
const detail = computePlatformPlanDetail(platform, live, {
|
|
1055
1171
|
jobsObservable: readers.coolify !== undefined,
|
|
1056
1172
|
sfuBlocked,
|
|
1057
1173
|
callsOrgSlugs,
|
|
1174
|
+
...(mailRelay ?? {}),
|
|
1058
1175
|
recordedIds: serviceIds,
|
|
1059
1176
|
blocked: (await executor.blockedSteps?.(platform, serviceIds)) ?? {},
|
|
1060
1177
|
drifted: (await executor.driftedSteps?.(platform, serviceIds)) ?? {},
|
|
1061
1178
|
});
|
|
1062
1179
|
return { platform, plan: toPlatformPlan(detail, platform.ownerOrgId), detail };
|
|
1063
1180
|
}
|
|
1181
|
+
/**
|
|
1182
|
+
* What the shared mail relay needs from the orgs. A route exists for an org
|
|
1183
|
+
* that owns its services, serves mail on its own domain and has recorded its
|
|
1184
|
+
* mail server's SMTP address (`mail_smtp_internal`, written by the company
|
|
1185
|
+
* mail step). Every other org with mail on its own domain, the platform
|
|
1186
|
+
* apex and the shared no-domain apex are platform domains: the platform's Stalwart receives them. An org
|
|
1187
|
+
* that owns its services but has no mail server yet appears in neither list,
|
|
1188
|
+
* so the relay refuses its mail until the server exists.
|
|
1189
|
+
*/
|
|
1190
|
+
async mailRelayInputs(platform) {
|
|
1191
|
+
const candidates = [];
|
|
1192
|
+
const mailPlatformDomains = [platform.apex];
|
|
1193
|
+
for (const org of await this.store.list()) {
|
|
1194
|
+
if (org.mail.mode === 'off')
|
|
1195
|
+
continue;
|
|
1196
|
+
// Mail without the org's own domain is served on the shared no-domain apex.
|
|
1197
|
+
if (!org.domain || !org.mail.usesDomain) {
|
|
1198
|
+
if (!mailPlatformDomains.includes(NO_DOMAIN_MAIL_APEX)) {
|
|
1199
|
+
mailPlatformDomains.push(NO_DOMAIN_MAIL_APEX);
|
|
1200
|
+
}
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
if (!ownsServices(org)) {
|
|
1204
|
+
mailPlatformDomains.push(org.domain);
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
const recorded = (await this.store.getAppliedState(org.id))?.serviceIds ?? {};
|
|
1208
|
+
const target = recorded[OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY];
|
|
1209
|
+
if (recorded.coolify_platform_mail_app && target) {
|
|
1210
|
+
candidates.push({ slug: org.slug, route: { domain: org.domain, target } });
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
// A domain the platform serves, or two companies claim, gets no route; the other routes stay.
|
|
1214
|
+
const platformSet = new Set(mailPlatformDomains.map((d) => d.toLowerCase()));
|
|
1215
|
+
const claims = new Map();
|
|
1216
|
+
for (const { route } of candidates) {
|
|
1217
|
+
const domain = route.domain.toLowerCase();
|
|
1218
|
+
claims.set(domain, (claims.get(domain) ?? 0) + 1);
|
|
1219
|
+
}
|
|
1220
|
+
const mailRoutes = [];
|
|
1221
|
+
const mailRoutesBlocked = [];
|
|
1222
|
+
for (const { slug, route } of candidates) {
|
|
1223
|
+
const domain = route.domain.toLowerCase();
|
|
1224
|
+
if (platformSet.has(domain)) {
|
|
1225
|
+
mailRoutesBlocked.push(`${slug}'s mail route for ${route.domain} is blocked, not planned: ${route.domain} is a platform mail domain`);
|
|
1226
|
+
}
|
|
1227
|
+
else if ((claims.get(domain) ?? 0) > 1) {
|
|
1228
|
+
mailRoutesBlocked.push(`${slug}'s mail route for ${route.domain} is blocked, not planned: another company claims ${route.domain}`);
|
|
1229
|
+
}
|
|
1230
|
+
else {
|
|
1231
|
+
mailRoutes.push(route);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
return { mailRoutes, mailPlatformDomains, mailRoutesBlocked };
|
|
1235
|
+
}
|
|
1064
1236
|
async sfuBlockedReason(platform) {
|
|
1065
1237
|
if (!platform.sfu.enabled)
|
|
1066
1238
|
return null;
|
|
@@ -1202,6 +1374,8 @@ function destroyTargets(serviceIds, orgId) {
|
|
|
1202
1374
|
'cloudflare:worker:',
|
|
1203
1375
|
'coolify:database:',
|
|
1204
1376
|
'coolify:app:',
|
|
1377
|
+
// An own files host's storage key: the token id is the step's recorded resource id.
|
|
1378
|
+
'coolify:own-files-r2:',
|
|
1205
1379
|
'zitadel:customers:',
|
|
1206
1380
|
// F6a: an entry's ai key, `<orgId>:<entry>`; the org's own key has no `:` and is left alone.
|
|
1207
1381
|
'ai:key:',
|
|
@@ -1281,6 +1455,16 @@ function destroyTargets(serviceIds, orgId) {
|
|
|
1281
1455
|
continue;
|
|
1282
1456
|
targets.push({ system: 'edge', kind: 'key', key: entryKey, id });
|
|
1283
1457
|
}
|
|
1458
|
+
// A storage key minted but never delivered has no main id; its token is deleted too.
|
|
1459
|
+
const r2Undelivered = serviceIds[OWN_FILES_R2_UNDELIVERED_SCOPE_KEY];
|
|
1460
|
+
if (r2Undelivered) {
|
|
1461
|
+
targets.push({
|
|
1462
|
+
system: 'coolify',
|
|
1463
|
+
kind: 'own-files-r2',
|
|
1464
|
+
key: 'undelivered',
|
|
1465
|
+
id: r2Undelivered,
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1284
1468
|
for (const [resourceKey, id] of Object.entries(serviceIds)) {
|
|
1285
1469
|
if (!resourceKey.startsWith('zitadel:app:') || !id)
|
|
1286
1470
|
continue;
|
|
@@ -1337,6 +1521,9 @@ function destroyStepDescriptions(targets) {
|
|
|
1337
1521
|
function destroyServiceIdKey(target) {
|
|
1338
1522
|
if (target.kind === 'project' || target.kind === 'org')
|
|
1339
1523
|
return target.key;
|
|
1524
|
+
if (target.kind === 'own-files-r2' && target.key === 'undelivered') {
|
|
1525
|
+
return OWN_FILES_R2_UNDELIVERED_SCOPE_KEY;
|
|
1526
|
+
}
|
|
1340
1527
|
return `${target.system}:${target.kind}:${target.key}`;
|
|
1341
1528
|
}
|
|
1342
1529
|
/**
|
|
@@ -1352,6 +1539,12 @@ const STEP_FIELDS = {
|
|
|
1352
1539
|
'coolify:broker-entry': 'hosting.entries.uses.auth',
|
|
1353
1540
|
'coolify:mail': 'mail',
|
|
1354
1541
|
'coolify:chat': 'chat.usesDomain',
|
|
1542
|
+
'coolify:own-auth': 'services.own',
|
|
1543
|
+
'coolify:own-files': 'services.own',
|
|
1544
|
+
'coolify:own-files-r2': 'services.own',
|
|
1545
|
+
'coolify:own-jobs': 'services.own',
|
|
1546
|
+
'coolify:own-mail': 'services.own',
|
|
1547
|
+
'coolify:own-sfu': 'services.own',
|
|
1355
1548
|
'coolify:calls': 'calls.enabled',
|
|
1356
1549
|
'coolify:calls-service': 'calls.enabled',
|
|
1357
1550
|
'coolify:calls-chat': 'calls.enabled',
|
|
@@ -3,6 +3,7 @@ import { type Plan, type PlanNote, type UnmanagedItem, type VersionChangeItem }
|
|
|
3
3
|
import type { DeclaredPlatform } from '../platform.js';
|
|
4
4
|
import type { PlannedStep } from './diff.js';
|
|
5
5
|
import type { LiveResource } from './live.js';
|
|
6
|
+
import { type MailRoute, PLATFORM_MAIL_RELAY_KEY } from './mail-relay.js';
|
|
6
7
|
/**
|
|
7
8
|
* The platform's own diff result, the {@link DeclaredPlatform} counterpart to
|
|
8
9
|
* `diff.ts`'s `PlanDetail` -- everything the same, minus the `orgId` an org's
|
|
@@ -432,6 +433,18 @@ export declare const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = "mail_applied_git
|
|
|
432
433
|
* no key and creates nothing in the sign-in server.
|
|
433
434
|
*/
|
|
434
435
|
export declare const PLATFORM_MAIL_ROUTE_SCOPE_KEY = "mail_route_applied";
|
|
436
|
+
/**
|
|
437
|
+
* The host ports the platform Stalwart publishes, as the mapping string the
|
|
438
|
+
* last apply set on its app. Without the shared relay it binds SMTP port 25
|
|
439
|
+
* itself; with `mail.relay` it keeps only submission, because the relay owns
|
|
440
|
+
* port 25. A missing record means the mapping it was created with, `25:25,...`.
|
|
441
|
+
*/
|
|
442
|
+
export declare const PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY = "mail_host_ports_applied";
|
|
443
|
+
export declare const PLATFORM_MAIL_HOST_PORTS_DIRECT = "25:25,465:465,587:587";
|
|
444
|
+
export declare const PLATFORM_MAIL_HOST_PORTS_RELAY = "465:465,587:587";
|
|
445
|
+
export declare function platformMailHostPorts(platform: DeclaredPlatform): string;
|
|
446
|
+
export { PLATFORM_MAIL_RELAY_KEY };
|
|
447
|
+
export { PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, type MailRoute, } from './mail-relay.js';
|
|
435
448
|
export declare const PLATFORM_MAIL_ROUTE_WANTED = "local-first";
|
|
436
449
|
/**
|
|
437
450
|
* #177: where the mail server answers on the Coolify network
|
|
@@ -971,6 +984,19 @@ export interface PlanPlatformOptions {
|
|
|
971
984
|
* only when the recorded fingerprint differs; absent, it always plans it.
|
|
972
985
|
*/
|
|
973
986
|
callsOrgSlugs?: readonly string[];
|
|
987
|
+
/**
|
|
988
|
+
* Which company domain goes to which company mail server. Used only when
|
|
989
|
+
* `platform.mail.relay` is true (default `[]`).
|
|
990
|
+
*/
|
|
991
|
+
mailRoutes?: readonly MailRoute[];
|
|
992
|
+
/**
|
|
993
|
+
* The domains the platform's own Stalwart serves; the relay hands them to
|
|
994
|
+
* it and refuses any domain in neither list. Used only when
|
|
995
|
+
* `platform.mail.relay` is true (default `[]`).
|
|
996
|
+
*/
|
|
997
|
+
mailPlatformDomains?: readonly string[];
|
|
998
|
+
/** One line per company route left out of `mailRoutes`; each is reported blocked and the relay still plans without it. */
|
|
999
|
+
mailRoutesBlocked?: readonly string[];
|
|
974
1000
|
/** The platform's recorded resource/scope ids (default `{}`). */
|
|
975
1001
|
recordedIds?: Readonly<Record<string, string>>;
|
|
976
1002
|
/** Per-step block reasons the executor reports (default `{}`). */
|
|
@@ -1000,6 +1026,8 @@ export interface PlanPlatformOptions {
|
|
|
1000
1026
|
ai?: string;
|
|
1001
1027
|
archon?: string;
|
|
1002
1028
|
mail?: string;
|
|
1029
|
+
/** Why the shared mail relay cannot run: the executor lacks the mail dependencies or the base image tag floats. */
|
|
1030
|
+
relay?: string;
|
|
1003
1031
|
/** #478: why the edge host is blocked, or absent when it is not. */
|
|
1004
1032
|
edge?: string;
|
|
1005
1033
|
/** #604: why the Access sign-in provider cannot be made yet (no dependencies, or edge cannot confirm Access is ready), or absent. */
|
|
@@ -5,6 +5,7 @@ import { VERSION_CHANGE_KIND, } from '../plan.js';
|
|
|
5
5
|
import { versionChangeNeedsConfirmation } from './confirm-upgrade.js';
|
|
6
6
|
import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
|
|
7
7
|
import { legacyAppHostname } from './hostnames.js';
|
|
8
|
+
import { PLATFORM_MAIL_RELAY_KEY, planMailRelay } from './mail-relay.js';
|
|
8
9
|
import { callsSfuAppliedFingerprint } from './platform-sfu.js';
|
|
9
10
|
import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, signInDomainBlockedText } from './sign-in-org.js';
|
|
10
11
|
import { withStepSystem } from './step-system.js';
|
|
@@ -482,6 +483,26 @@ export const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = 'mail_applied_git_ref';
|
|
|
482
483
|
* no key and creates nothing in the sign-in server.
|
|
483
484
|
*/
|
|
484
485
|
export const PLATFORM_MAIL_ROUTE_SCOPE_KEY = 'mail_route_applied';
|
|
486
|
+
/**
|
|
487
|
+
* The host ports the platform Stalwart publishes, as the mapping string the
|
|
488
|
+
* last apply set on its app. Without the shared relay it binds SMTP port 25
|
|
489
|
+
* itself; with `mail.relay` it keeps only submission, because the relay owns
|
|
490
|
+
* port 25. A missing record means the mapping it was created with, `25:25,...`.
|
|
491
|
+
*/
|
|
492
|
+
export const PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY = 'mail_host_ports_applied';
|
|
493
|
+
export const PLATFORM_MAIL_HOST_PORTS_DIRECT = '25:25,465:465,587:587';
|
|
494
|
+
export const PLATFORM_MAIL_HOST_PORTS_RELAY = '465:465,587:587';
|
|
495
|
+
export function platformMailHostPorts(platform) {
|
|
496
|
+
return platform.mail.relay === true
|
|
497
|
+
? PLATFORM_MAIL_HOST_PORTS_RELAY
|
|
498
|
+
: PLATFORM_MAIL_HOST_PORTS_DIRECT;
|
|
499
|
+
}
|
|
500
|
+
function mailPortsApplied(platform, recordedIds) {
|
|
501
|
+
return ((recordedIds[PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY] ?? PLATFORM_MAIL_HOST_PORTS_DIRECT) ===
|
|
502
|
+
platformMailHostPorts(platform));
|
|
503
|
+
}
|
|
504
|
+
export { PLATFORM_MAIL_RELAY_KEY };
|
|
505
|
+
export { PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, } from './mail-relay.js';
|
|
485
506
|
export const PLATFORM_MAIL_ROUTE_WANTED = 'local-first';
|
|
486
507
|
/**
|
|
487
508
|
* #177: where the mail server answers on the Coolify network
|
|
@@ -1113,7 +1134,7 @@ export function uptimeRecorded(recordedIds) {
|
|
|
1113
1134
|
* unchanged).
|
|
1114
1135
|
*/
|
|
1115
1136
|
export function planPlatform(platform, live, options = {}) {
|
|
1116
|
-
const { jobsObservable = true, sfuBlocked = null, callsOrgSlugs, recordedIds = {}, blocked = {}, drifted = {}, } = options;
|
|
1137
|
+
const { jobsObservable = true, sfuBlocked = null, callsOrgSlugs, mailRoutes = [], mailPlatformDomains = [], mailRoutesBlocked = [], recordedIds = {}, blocked = {}, drifted = {}, } = options;
|
|
1117
1138
|
const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
|
|
1118
1139
|
const claimed = new Set();
|
|
1119
1140
|
const plannedSteps = [];
|
|
@@ -1178,6 +1199,18 @@ export function planPlatform(platform, live, options = {}) {
|
|
|
1178
1199
|
const mail = planMail(platform, recordedIds, blocked.mail ?? dnsBlocked?.('mail server'), authReady);
|
|
1179
1200
|
if (mail.step)
|
|
1180
1201
|
plannedSteps.push(mail.step);
|
|
1202
|
+
// The shared relay owns host port 25 once `mail.relay` is on, so it runs after the
|
|
1203
|
+
// mail step that gives the port up.
|
|
1204
|
+
const relay = planMailRelay(platform, recordedIds, {
|
|
1205
|
+
routes: mailRoutes,
|
|
1206
|
+
platformDomains: mailPlatformDomains,
|
|
1207
|
+
executorBlocked: blocked.relay,
|
|
1208
|
+
mailReady: mail.step !== null ||
|
|
1209
|
+
(Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_MAIL_KEY)]) &&
|
|
1210
|
+
mailPortsApplied(platform, recordedIds)),
|
|
1211
|
+
});
|
|
1212
|
+
if (relay.step)
|
|
1213
|
+
plannedSteps.push(relay.step);
|
|
1181
1214
|
// #172: a throw halts every step after it, so this one goes late.
|
|
1182
1215
|
const ai = planAi(platform, recordedIds, blocked.ai);
|
|
1183
1216
|
if (ai.step)
|
|
@@ -1280,6 +1313,13 @@ export function planPlatform(platform, live, options = {}) {
|
|
|
1280
1313
|
unmanaged.push({ description: auth.blocked, key: 'auth' });
|
|
1281
1314
|
if (mail.blocked)
|
|
1282
1315
|
unmanaged.push({ description: mail.blocked, key: 'mail' });
|
|
1316
|
+
if (relay.blocked)
|
|
1317
|
+
unmanaged.push({ description: relay.blocked, key: 'mail-relay' });
|
|
1318
|
+
if (platform.mail.relay === true) {
|
|
1319
|
+
for (const description of mailRoutesBlocked) {
|
|
1320
|
+
unmanaged.push({ description, key: 'mail-relay:route' });
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1283
1323
|
if (integrations.blocked)
|
|
1284
1324
|
unmanaged.push({ description: integrations.blocked, key: 'integrations' });
|
|
1285
1325
|
if (files.blocked)
|
|
@@ -1922,7 +1962,8 @@ function planMail(platform, recordedIds, executorBlocked, authReady) {
|
|
|
1922
1962
|
// #177: a mail step applied before this key existed never recorded it, and
|
|
1923
1963
|
// org steps read it for Agora's mail upstream. Missing means unapplied.
|
|
1924
1964
|
recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY] &&
|
|
1925
|
-
recordedIds[PLATFORM_MAIL_ROUTE_SCOPE_KEY] === PLATFORM_MAIL_ROUTE_WANTED
|
|
1965
|
+
recordedIds[PLATFORM_MAIL_ROUTE_SCOPE_KEY] === PLATFORM_MAIL_ROUTE_WANTED &&
|
|
1966
|
+
mailPortsApplied(platform, recordedIds)) {
|
|
1926
1967
|
return { step: null, blocked: null };
|
|
1927
1968
|
}
|
|
1928
1969
|
// Only the route is missing: say so, the app is not redeployed.
|
|
@@ -1930,7 +1971,8 @@ function planMail(platform, recordedIds, executorBlocked, authReady) {
|
|
|
1930
1971
|
recordedIds[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] &&
|
|
1931
1972
|
recordedIds[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] === platform.mail.hostname &&
|
|
1932
1973
|
recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === platform.mail.gitRef &&
|
|
1933
|
-
recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY]
|
|
1974
|
+
recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY] &&
|
|
1975
|
+
mailPortsApplied(platform, recordedIds);
|
|
1934
1976
|
// Stalwart's `x:ApiKey/set` shows a secret once. An id was minted but never
|
|
1935
1977
|
// reached the host: report it, plan nothing, the same rule {@link planAuth}
|
|
1936
1978
|
// follows for ZITADEL's own admin key.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type DeclaredOrg } from '../org.js';
|
|
1
2
|
import type { DeclaredPlatform } from '../platform.js';
|
|
2
3
|
import type { AgoraHostValues } from './agora.js';
|
|
3
4
|
/**
|
|
@@ -151,6 +152,19 @@ export interface OrgPlatformReadiness {
|
|
|
151
152
|
* A missing key is then reported as "no reader given" instead of "not held".
|
|
152
153
|
*/
|
|
153
154
|
noKeyReader?: boolean;
|
|
155
|
+
/** The platform's declared git ref for each service, which an own org's services deploy. */
|
|
156
|
+
serviceGitRefs?: {
|
|
157
|
+
auth: string;
|
|
158
|
+
mail: string;
|
|
159
|
+
files: string;
|
|
160
|
+
jobs: string;
|
|
161
|
+
};
|
|
162
|
+
/**
|
|
163
|
+
* The platform's recorded owner invite (`PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY`): the user id the
|
|
164
|
+
* platform's files host takes as `AUTHZ_BOOTSTRAP_OPERATOR`, or `'none'` when the platform has no
|
|
165
|
+
* owner to invite. `null` or absent until recorded. An own org's files host gets the same value.
|
|
166
|
+
*/
|
|
167
|
+
platformOwnerUserId?: string | null;
|
|
154
168
|
}
|
|
155
169
|
/**
|
|
156
170
|
* What an org's plan reads when no platform is declared: nothing is ready, so
|
|
@@ -207,6 +221,26 @@ heldRefs?: ReadonlySet<string>,
|
|
|
207
221
|
noKeyReader?: boolean,
|
|
208
222
|
/** #317: true when the host's executor was built with an `EdgePort` ({@link OrgPlatformReadiness.edgeReady}). */
|
|
209
223
|
edgePortPresent?: boolean): OrgPlatformReadiness;
|
|
224
|
+
/** The git refs the platform declares for the services an own org deploys. */
|
|
225
|
+
export declare function serviceGitRefsOf(platform: DeclaredPlatform): {
|
|
226
|
+
auth: string;
|
|
227
|
+
mail: string;
|
|
228
|
+
files: string;
|
|
229
|
+
jobs: string;
|
|
230
|
+
};
|
|
231
|
+
/** The runner app both the platform's and an own org's files step record. */
|
|
232
|
+
export declare const OWN_FILES_RUNNER_SCOPE_KEY = "coolify_platform_files_runner_app";
|
|
233
|
+
/**
|
|
234
|
+
* An own org (`services.own`) plans against its own services, so its readiness
|
|
235
|
+
* reads only its own recorded ids and its own held keys; nothing the shared
|
|
236
|
+
* services recorded reaches it. AI, edge and Access stay shared and come from
|
|
237
|
+
* `base`. Any other org gets `base` back unchanged.
|
|
238
|
+
*/
|
|
239
|
+
export declare function orgPlatformReadinessFor(org: DeclaredOrg, base: OrgPlatformReadiness, orgRecordedIds: Readonly<Record<string, string>>, heldRefs: ReadonlySet<string>): OrgPlatformReadiness;
|
|
240
|
+
/** The own org's held-key refs `heldKeys` resolves right now. A resolver that throws counts as not held, and its error goes nowhere. */
|
|
241
|
+
export declare function heldOwnKeyRefs(heldKeys: HeldKeyResolver, org: Pick<DeclaredOrg, 'slug'>): Promise<Set<string>>;
|
|
242
|
+
/** {@link agoraHostValuesFromPlatform}'s counterpart for an own org: every value is the org's own, null until recorded. */
|
|
243
|
+
export declare function agoraHostValuesForOwn(org: DeclaredOrg, orgRecordedIds: Readonly<Record<string, string>>): AgoraHostValues;
|
|
210
244
|
/**
|
|
211
245
|
* #177: {@link AgoraHostValues} derived from the same platform state, rather
|
|
212
246
|
* than requiring a bespoke host-supplied `AgoraHostPort` for values the
|