@wtfalch/foundry 0.32.0 → 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/bootstrap.d.ts +18 -1
- package/dist/bootstrap/bootstrap.js +167 -28
- package/dist/bootstrap/declare.d.ts +5 -1
- package/dist/bootstrap/declare.js +3 -1
- package/dist/bootstrap/index.d.ts +1 -1
- 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 +18 -8
- package/dist/index.js +8 -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 +24 -2
- package/dist/org.js +42 -0
- package/dist/plan/boule.d.ts +24 -1
- package/dist/plan/boule.js +29 -1
- 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 +32 -1
- package/dist/plan/diff.js +383 -26
- package/dist/plan/executor.d.ts +177 -7
- package/dist/plan/executor.js +738 -51
- 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/health-targets.js +1 -1
- 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 +16 -6
- package/dist/plan/index.js +8 -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 +101 -7
- package/dist/plan/plan-apply.js +216 -12
- package/dist/plan/platform-diff.d.ts +61 -12
- package/dist/plan/platform-diff.js +140 -34
- package/dist/plan/platform-org.d.ts +36 -0
- package/dist/plan/platform-org.js +96 -2
- package/dist/plan/platform-providers.d.ts +407 -12
- package/dist/plan/platform-providers.js +947 -422
- package/dist/plan/sign-in-org.d.ts +99 -0
- package/dist/plan/sign-in-org.js +151 -0
- 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/plan.d.ts +18 -0
- 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,12 +146,17 @@ export class FoundryPlanApply {
|
|
|
137
146
|
agora;
|
|
138
147
|
jobsScheduler;
|
|
139
148
|
loginHostBindings;
|
|
149
|
+
loginHostDiscovery;
|
|
140
150
|
zitadelIdTokenUserinfo;
|
|
141
151
|
broker;
|
|
142
152
|
edgeWorkers;
|
|
153
|
+
ownerInviteMail;
|
|
143
154
|
hostFiles;
|
|
144
155
|
hostAi;
|
|
145
156
|
hostEdge;
|
|
157
|
+
hostOwnServices;
|
|
158
|
+
sfuPortsOpen;
|
|
159
|
+
onlySteps;
|
|
146
160
|
now;
|
|
147
161
|
leaseTtlMs;
|
|
148
162
|
appliedVersions;
|
|
@@ -159,12 +173,17 @@ export class FoundryPlanApply {
|
|
|
159
173
|
this.agora = options.agora;
|
|
160
174
|
this.jobsScheduler = options.jobsScheduler ?? false;
|
|
161
175
|
this.loginHostBindings = options.loginHostBindings ?? false;
|
|
176
|
+
this.loginHostDiscovery = options.loginHostDiscovery;
|
|
162
177
|
this.zitadelIdTokenUserinfo = options.zitadelIdTokenUserinfo ?? false;
|
|
163
178
|
this.broker = options.broker ?? false;
|
|
164
179
|
this.edgeWorkers = options.edgeWorkers ?? false;
|
|
180
|
+
this.ownerInviteMail = options.ownerInviteMail ?? false;
|
|
165
181
|
this.hostFiles = options.filesOrganisation ?? false;
|
|
166
182
|
this.hostAi = options.aiOrganisation ?? false;
|
|
167
183
|
this.hostEdge = options.edge ?? false;
|
|
184
|
+
this.hostOwnServices = options.ownServices ?? false;
|
|
185
|
+
this.sfuPortsOpen = options.sfuPortsOpen;
|
|
186
|
+
this.onlySteps = options.onlySteps;
|
|
168
187
|
this.now = options.now ?? (() => new Date());
|
|
169
188
|
this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
|
|
170
189
|
this.appliedVersions = options.appliedVersions;
|
|
@@ -893,29 +912,94 @@ export class FoundryPlanApply {
|
|
|
893
912
|
});
|
|
894
913
|
return applied;
|
|
895
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
|
+
}
|
|
896
930
|
async computeFreshPlan(id) {
|
|
897
931
|
const org = await this.store.get(id);
|
|
898
932
|
if (!org)
|
|
899
933
|
throw new DeclaredOrgNotFoundError(id);
|
|
900
|
-
const
|
|
901
|
-
|
|
902
|
-
this.
|
|
903
|
-
this.orgPlatformReadiness(),
|
|
904
|
-
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 ?? {}),
|
|
905
939
|
this.orgArchonUrl(),
|
|
906
940
|
]);
|
|
907
941
|
const otherSlugs = (await this.store.list()).filter((o) => o.id !== id).map((o) => o.slug);
|
|
908
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);
|
|
909
951
|
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
|
|
910
952
|
loginHostBindings: this.loginHostBindings,
|
|
953
|
+
loginHostServesDiscovery,
|
|
911
954
|
zitadelIdTokenUserinfo: this.zitadelIdTokenUserinfo,
|
|
912
955
|
broker: this.broker,
|
|
913
956
|
edgeWorkers: this.edgeWorkers,
|
|
957
|
+
ownerInviteMail: this.ownerInviteMail,
|
|
914
958
|
otherSlugs,
|
|
915
959
|
now: () => this.now().getTime(),
|
|
916
960
|
archonUrl,
|
|
917
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
|
+
: {}),
|
|
918
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
|
+
}
|
|
919
1003
|
const invalid = await this.invalidHosting(org);
|
|
920
1004
|
if (invalid) {
|
|
921
1005
|
// Rule 2: reported in `unmanaged`, not thrown. Steps that read the
|
|
@@ -926,6 +1010,11 @@ export class FoundryPlanApply {
|
|
|
926
1010
|
key: 'hosting:invalid',
|
|
927
1011
|
});
|
|
928
1012
|
}
|
|
1013
|
+
if (this.onlySteps) {
|
|
1014
|
+
const { step, blocked } = this.onlySteps;
|
|
1015
|
+
detail.plannedSteps = detail.plannedSteps.filter(({ action }) => step(action));
|
|
1016
|
+
detail.unmanaged = detail.unmanaged.filter((item) => item.key === undefined || blocked(item.key));
|
|
1017
|
+
}
|
|
929
1018
|
return { org, plan: toPlan(detail), detail };
|
|
930
1019
|
}
|
|
931
1020
|
/**
|
|
@@ -950,7 +1039,40 @@ export class FoundryPlanApply {
|
|
|
950
1039
|
* DEFAULT_ORG_PLATFORM_READINESS} reports every service not ready, so every
|
|
951
1040
|
* org step that needs one is blocked.
|
|
952
1041
|
*/
|
|
953
|
-
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() {
|
|
954
1076
|
if (!this.platformDeps)
|
|
955
1077
|
return DEFAULT_ORG_PLATFORM_READINESS;
|
|
956
1078
|
const platform = await this.platformDeps.store.get();
|
|
@@ -988,11 +1110,14 @@ export class FoundryPlanApply {
|
|
|
988
1110
|
* `platformDeps` {@link orgPlatformReadiness} reads -- but only when the
|
|
989
1111
|
* host supplies no `agora` port of its own. An explicit `agora` (#164)
|
|
990
1112
|
* always wins, unchanged, so a host that already has its own values
|
|
991
|
-
* (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.
|
|
992
1115
|
*/
|
|
993
|
-
async orgAgoraHost(
|
|
1116
|
+
async orgAgoraHost(org, orgRecordedIds) {
|
|
1117
|
+
if (ownsServices(org))
|
|
1118
|
+
return agoraHostValuesForOwn(org, orgRecordedIds);
|
|
994
1119
|
if (this.agora)
|
|
995
|
-
return readAgoraHost(this.agora, id);
|
|
1120
|
+
return readAgoraHost(this.agora, org.id);
|
|
996
1121
|
if (!this.platformDeps)
|
|
997
1122
|
return NO_AGORA_HOST_VALUES;
|
|
998
1123
|
const platform = await this.platformDeps.store.get();
|
|
@@ -1041,16 +1166,73 @@ export class FoundryPlanApply {
|
|
|
1041
1166
|
const callsOrgSlugs = (await this.store.list())
|
|
1042
1167
|
.filter((org) => org.calls.enabled)
|
|
1043
1168
|
.map((org) => org.slug);
|
|
1169
|
+
const mailRelay = platform.mail.relay === true ? await this.mailRelayInputs(platform) : null;
|
|
1044
1170
|
const detail = computePlatformPlanDetail(platform, live, {
|
|
1045
1171
|
jobsObservable: readers.coolify !== undefined,
|
|
1046
1172
|
sfuBlocked,
|
|
1047
1173
|
callsOrgSlugs,
|
|
1174
|
+
...(mailRelay ?? {}),
|
|
1048
1175
|
recordedIds: serviceIds,
|
|
1049
1176
|
blocked: (await executor.blockedSteps?.(platform, serviceIds)) ?? {},
|
|
1050
1177
|
drifted: (await executor.driftedSteps?.(platform, serviceIds)) ?? {},
|
|
1051
1178
|
});
|
|
1052
1179
|
return { platform, plan: toPlatformPlan(detail, platform.ownerOrgId), detail };
|
|
1053
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
|
+
}
|
|
1054
1236
|
async sfuBlockedReason(platform) {
|
|
1055
1237
|
if (!platform.sfu.enabled)
|
|
1056
1238
|
return null;
|
|
@@ -1192,6 +1374,8 @@ function destroyTargets(serviceIds, orgId) {
|
|
|
1192
1374
|
'cloudflare:worker:',
|
|
1193
1375
|
'coolify:database:',
|
|
1194
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:',
|
|
1195
1379
|
'zitadel:customers:',
|
|
1196
1380
|
// F6a: an entry's ai key, `<orgId>:<entry>`; the org's own key has no `:` and is left alone.
|
|
1197
1381
|
'ai:key:',
|
|
@@ -1271,6 +1455,16 @@ function destroyTargets(serviceIds, orgId) {
|
|
|
1271
1455
|
continue;
|
|
1272
1456
|
targets.push({ system: 'edge', kind: 'key', key: entryKey, id });
|
|
1273
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
|
+
}
|
|
1274
1468
|
for (const [resourceKey, id] of Object.entries(serviceIds)) {
|
|
1275
1469
|
if (!resourceKey.startsWith('zitadel:app:') || !id)
|
|
1276
1470
|
continue;
|
|
@@ -1327,6 +1521,9 @@ function destroyStepDescriptions(targets) {
|
|
|
1327
1521
|
function destroyServiceIdKey(target) {
|
|
1328
1522
|
if (target.kind === 'project' || target.kind === 'org')
|
|
1329
1523
|
return target.key;
|
|
1524
|
+
if (target.kind === 'own-files-r2' && target.key === 'undelivered') {
|
|
1525
|
+
return OWN_FILES_R2_UNDELIVERED_SCOPE_KEY;
|
|
1526
|
+
}
|
|
1330
1527
|
return `${target.system}:${target.kind}:${target.key}`;
|
|
1331
1528
|
}
|
|
1332
1529
|
/**
|
|
@@ -1342,6 +1539,12 @@ const STEP_FIELDS = {
|
|
|
1342
1539
|
'coolify:broker-entry': 'hosting.entries.uses.auth',
|
|
1343
1540
|
'coolify:mail': 'mail',
|
|
1344
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',
|
|
1345
1548
|
'coolify:calls': 'calls.enabled',
|
|
1346
1549
|
'coolify:calls-service': 'calls.enabled',
|
|
1347
1550
|
'coolify:calls-chat': 'calls.enabled',
|
|
@@ -1489,6 +1692,7 @@ function canonical(plan) {
|
|
|
1489
1692
|
// version-change line must still be caught as stale, the same as any
|
|
1490
1693
|
// other field here.
|
|
1491
1694
|
versionChanges: [...(plan.versionChanges ?? [])].sort((x, y) => x.description.localeCompare(y.description)),
|
|
1695
|
+
notes: [...(plan.notes ?? [])].sort((x, y) => x.key.localeCompare(y.key)),
|
|
1492
1696
|
};
|
|
1493
1697
|
}
|
|
1494
1698
|
/**
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { DeclaredOrg, EnvSharedSecret, HostingEntry, OrgId } from '../org.js';
|
|
2
|
-
import { type Plan, type UnmanagedItem, type VersionChangeItem } from '../plan.js';
|
|
2
|
+
import { type Plan, type PlanNote, type UnmanagedItem, type VersionChangeItem } from '../plan.js';
|
|
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
|
|
@@ -16,6 +17,8 @@ export interface PlatformPlanDetail {
|
|
|
16
17
|
unmanaged: UnmanagedItem[];
|
|
17
18
|
/** #251: see {@link VersionChangeItem}. Never folded into `plannedSteps`. */
|
|
18
19
|
versionChanges: VersionChangeItem[];
|
|
20
|
+
/** Advisory lines that block no step; see {@link PlanNote}. */
|
|
21
|
+
notes: PlanNote[];
|
|
19
22
|
}
|
|
20
23
|
/**
|
|
21
24
|
* The `Plan` (#19's public contract) a {@link PlatformPlanDetail} carries,
|
|
@@ -161,12 +164,11 @@ export declare const PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY = "archon_deployment";
|
|
|
161
164
|
*/
|
|
162
165
|
export declare const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = "archon_applied_broker_app";
|
|
163
166
|
/**
|
|
164
|
-
* The
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* has recorded it.
|
|
167
|
+
* The sign-in org id the last Archon apply set as `AUTH_ORGANIZATION_ID` and
|
|
168
|
+
* registered its broker entry under. `planArchon` plans the step again when
|
|
169
|
+
* {@link archonAuthOrganizationId} differs from it.
|
|
168
170
|
*/
|
|
169
|
-
export declare const
|
|
171
|
+
export declare const PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY = "archon_applied_auth_org";
|
|
170
172
|
/**
|
|
171
173
|
* #475: whether the last successful Archon apply patched the app with
|
|
172
174
|
* `custom_network_aliases` and `is_consistent_container_name_enabled: false`
|
|
@@ -183,11 +185,11 @@ export declare const PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY = "archon_applie
|
|
|
183
185
|
*/
|
|
184
186
|
export declare const PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY = "archon_applied_rolling_update";
|
|
185
187
|
/**
|
|
186
|
-
* The
|
|
187
|
-
* {@link
|
|
188
|
-
*
|
|
188
|
+
* The sign-in org Archon accepts: the platform owner company's own
|
|
189
|
+
* ({@link PLATFORM_OWNER_ORG_SCOPE_KEY}), else the platform's `foundry` org for
|
|
190
|
+
* a platform with no owner company declared. Undefined when neither is recorded.
|
|
189
191
|
*/
|
|
190
|
-
export declare function
|
|
192
|
+
export declare function archonAuthOrganizationId(recorded: Readonly<Record<string, string>>): string | undefined;
|
|
191
193
|
/**
|
|
192
194
|
* #175: set to '1' before the step points the tunnel at `http_status:404`
|
|
193
195
|
* (Access app rebuild), cleared to '' once the real ingress is written back.
|
|
@@ -207,6 +209,26 @@ export declare const PLATFORM_AUTH_SMTP_SCOPE_KEY = "zitadel_platform_smtp_provi
|
|
|
207
209
|
* requested, so the invite goes out once. `none` when no owner is declared.
|
|
208
210
|
*/
|
|
209
211
|
export declare const PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY = "zitadel_platform_owner_invite_user_id";
|
|
212
|
+
/** The platform's own ZITADEL org (`foundry`, the one `provisionPlatformAuth` mints its admin user in). */
|
|
213
|
+
export declare const PLATFORM_ZITADEL_ORG_SCOPE_KEY = "zitadel_platform_org";
|
|
214
|
+
/**
|
|
215
|
+
* The id of the platform owner company's own sign-in organisation, made by
|
|
216
|
+
* `platform:auth` with the same name and primary domain a company apply uses,
|
|
217
|
+
* so that apply finds this org and makes no second one. `none` when the owner
|
|
218
|
+
* company is not declared. Archon signs people in from this org; the
|
|
219
|
+
* `foundry` org ({@link PLATFORM_ZITADEL_ORG_SCOPE_KEY}) holds no people.
|
|
220
|
+
*/
|
|
221
|
+
export declare const PLATFORM_OWNER_ORG_SCOPE_KEY = "zitadel_platform_owner_org";
|
|
222
|
+
/** The primary domain `platform:auth` registered on the owner company's sign-in org. */
|
|
223
|
+
export declare const PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY = "zitadel_platform_owner_org_domain";
|
|
224
|
+
/** The owner org's sign-in domain the sign-in server refused to make primary; set instead of failing, reported as blocked. */
|
|
225
|
+
export declare const PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY = "zitadel_platform_owner_org_domain_blocked";
|
|
226
|
+
/**
|
|
227
|
+
* The sign-in org the recorded owner invite ({@link PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY})
|
|
228
|
+
* was made in. An invite recorded against another org (the `foundry` org, on a
|
|
229
|
+
* platform applied before the owner moved) plans the auth step again.
|
|
230
|
+
*/
|
|
231
|
+
export declare const PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY = "zitadel_platform_owner_invite_org";
|
|
210
232
|
/**
|
|
211
233
|
* #394: the id of the role-less ZITADEL machine user that reserves the
|
|
212
234
|
* username `admin` in the platform's own org, so nobody can sign up under a
|
|
@@ -216,8 +238,6 @@ export declare const PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY = "zitadel_platform_ow
|
|
|
216
238
|
export declare const PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY = "zitadel_platform_reserved_admin_username";
|
|
217
239
|
/** #394: recorded once the platform's own ZITADEL org has self-registration forced off. */
|
|
218
240
|
export declare const PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY = "zitadel_platform_registration_closed";
|
|
219
|
-
/** The platform's own ZITADEL org (`foundry`, the one `provisionPlatformAuth` mints its admin user in). */
|
|
220
|
-
export declare const PLATFORM_ZITADEL_ORG_SCOPE_KEY = "zitadel_platform_org";
|
|
221
241
|
export declare const PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY = "zitadel_platform_admin_user_id";
|
|
222
242
|
export declare const PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY = "zitadel_platform_admin_user_undelivered";
|
|
223
243
|
/** #283: the created instance's id, recorded once its owner token is delivered. */
|
|
@@ -413,6 +433,18 @@ export declare const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = "mail_applied_git
|
|
|
413
433
|
* no key and creates nothing in the sign-in server.
|
|
414
434
|
*/
|
|
415
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';
|
|
416
448
|
export declare const PLATFORM_MAIL_ROUTE_WANTED = "local-first";
|
|
417
449
|
/**
|
|
418
450
|
* #177: where the mail server answers on the Coolify network
|
|
@@ -952,6 +984,19 @@ export interface PlanPlatformOptions {
|
|
|
952
984
|
* only when the recorded fingerprint differs; absent, it always plans it.
|
|
953
985
|
*/
|
|
954
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[];
|
|
955
1000
|
/** The platform's recorded resource/scope ids (default `{}`). */
|
|
956
1001
|
recordedIds?: Readonly<Record<string, string>>;
|
|
957
1002
|
/** Per-step block reasons the executor reports (default `{}`). */
|
|
@@ -974,11 +1019,15 @@ export interface PlanPlatformOptions {
|
|
|
974
1019
|
* these. Default `[]`.
|
|
975
1020
|
*/
|
|
976
1021
|
authOtherInputsMissing?: string[];
|
|
1022
|
+
/** C3: why the owner's invite cannot be made (no work address, no mail port). The rest of the step still plans. */
|
|
1023
|
+
authOwnerInvite?: string;
|
|
977
1024
|
files?: string;
|
|
978
1025
|
integrations?: string;
|
|
979
1026
|
ai?: string;
|
|
980
1027
|
archon?: string;
|
|
981
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;
|
|
982
1031
|
/** #478: why the edge host is blocked, or absent when it is not. */
|
|
983
1032
|
edge?: string;
|
|
984
1033
|
/** #604: why the Access sign-in provider cannot be made yet (no dependencies, or edge cannot confirm Access is ready), or absent. */
|