@wtfalch/foundry 0.33.1 → 0.35.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/bootstrap.d.ts +1 -1
- package/dist/bootstrap/bootstrap.js +49 -11
- package/dist/bootstrap/declare.d.ts +12 -5
- package/dist/bootstrap/declare.js +33 -18
- package/dist/bootstrap/versions.d.ts +24 -0
- package/dist/bootstrap/versions.js +46 -0
- package/dist/calls-sfu.d.ts +10 -0
- package/dist/calls-sfu.js +44 -3
- package/dist/index.d.ts +21 -8
- package/dist/index.js +11 -4
- package/dist/migrations/0051_org_services.sql +13 -0
- package/dist/migrations/0052_platform_mail_relay.sql +8 -0
- package/dist/migrations/0053_heartbeats_provider.sql +9 -0
- package/dist/migrations/0053_org_version_pin.sql +6 -0
- package/dist/org.d.ts +76 -12
- package/dist/org.js +136 -20
- package/dist/plan/backup-gate.d.ts +93 -0
- package/dist/plan/backup-gate.js +261 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +20 -3
- package/dist/plan/build-record.d.ts +56 -0
- package/dist/plan/build-record.js +109 -0
- package/dist/plan/calls-matrix.d.ts +9 -0
- package/dist/plan/calls-matrix.js +18 -1
- package/dist/plan/cloudflared-connector.d.ts +7 -1
- package/dist/plan/cloudflared-connector.js +15 -2
- package/dist/plan/company-backups.d.ts +31 -0
- package/dist/plan/company-backups.js +101 -0
- package/dist/plan/deploy-health.d.ts +12 -2
- package/dist/plan/deploy-health.js +3 -1
- package/dist/plan/destinations.d.ts +73 -0
- package/dist/plan/destinations.js +217 -0
- package/dist/plan/diff.d.ts +46 -8
- package/dist/plan/diff.js +367 -126
- package/dist/plan/edge-access.d.ts +15 -0
- package/dist/plan/edge-access.js +21 -2
- package/dist/plan/executor.d.ts +276 -5
- package/dist/plan/executor.js +1111 -109
- 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.d.ts +1 -3
- package/dist/plan/health-targets.js +36 -36
- package/dist/plan/held-key-refs.d.ts +15 -0
- package/dist/plan/held-key-refs.js +15 -0
- package/dist/plan/hostnames.d.ts +10 -0
- package/dist/plan/hostnames.js +14 -0
- package/dist/plan/index.d.ts +19 -6
- package/dist/plan/index.js +10 -3
- package/dist/plan/live.d.ts +11 -2
- package/dist/plan/live.js +16 -2
- package/dist/plan/locked-hostnames.d.ts +7 -3
- package/dist/plan/locked-hostnames.js +25 -4
- package/dist/plan/login-host.d.ts +61 -12
- package/dist/plan/login-host.js +99 -11
- package/dist/plan/mail-relay.d.ts +139 -0
- package/dist/plan/mail-relay.js +400 -0
- package/dist/plan/network-move.d.ts +20 -0
- package/dist/plan/network-move.js +41 -0
- package/dist/plan/oikos-ingress.d.ts +111 -0
- package/dist/plan/oikos-ingress.js +231 -0
- package/dist/plan/org-names.d.ts +30 -0
- package/dist/plan/org-names.js +108 -0
- package/dist/plan/own-diff.d.ts +170 -0
- package/dist/plan/own-diff.js +440 -0
- package/dist/plan/own-dns.d.ts +6 -0
- package/dist/plan/own-dns.js +15 -0
- package/dist/plan/own-hostnames.d.ts +27 -0
- package/dist/plan/own-hostnames.js +24 -0
- package/dist/plan/own-limits.d.ts +213 -0
- package/dist/plan/own-limits.js +232 -0
- package/dist/plan/own-services.d.ts +193 -0
- package/dist/plan/own-services.js +169 -0
- package/dist/plan/own-sfu.d.ts +69 -0
- package/dist/plan/own-sfu.js +256 -0
- package/dist/plan/plan-apply.d.ts +121 -10
- package/dist/plan/plan-apply.js +357 -18
- package/dist/plan/platform-diff.d.ts +155 -11
- package/dist/plan/platform-diff.js +271 -66
- package/dist/plan/platform-org.d.ts +107 -4
- package/dist/plan/platform-org.js +175 -3
- package/dist/plan/platform-providers.d.ts +474 -81
- package/dist/plan/platform-providers.js +1407 -560
- package/dist/plan/proxy-labels.d.ts +57 -11
- package/dist/plan/proxy-labels.js +94 -14
- package/dist/plan/run-options.d.ts +19 -0
- package/dist/plan/run-options.js +46 -0
- 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.d.ts +1 -1
- package/dist/plan/zitadel-login-policy.js +77 -9
- package/dist/plan/zitadel-system-api.d.ts +22 -1
- package/dist/plan/zitadel-system-api.js +49 -8
- package/dist/platform.d.ts +40 -0
- package/dist/platform.js +16 -0
- package/dist/provision.d.ts +2 -0
- package/dist/provision.js +3 -1
- package/dist/slug.d.ts +3 -1
- package/dist/slug.js +26 -3
- package/dist/store/postgres.js +93 -2
- package/dist/store/schema.d.ts +87 -0
- package/dist/store/schema.js +9 -0
- package/package.json +3 -3
- package/versions.digests.json +11 -0
- package/versions.json +3 -1
package/dist/plan/plan-apply.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
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';
|
|
10
|
+
import { NetworkMoveBackupMissingError, UpgradeBackupMissingError, platformDatabasesForService, requireFreshBackupsForMove, requireFreshBackupsForUpgrades, } from './backup-gate.js';
|
|
11
|
+
import { readLiveBuilds } from './build-record.js';
|
|
9
12
|
import { unconfirmedUpgrades } from './confirm-upgrade.js';
|
|
10
13
|
import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
|
|
11
14
|
import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
|
|
@@ -13,9 +16,15 @@ import { BOULE_EDGE_ENTRY_KEY, entryEdgeRetiredScopeKey, entryEdgeUndeliveredSco
|
|
|
13
16
|
import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
|
|
14
17
|
import { readLivePlatformResources, readLiveResources, } from './live.js';
|
|
15
18
|
import { lockedHostnames } from './locked-hostnames.js';
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
+
import { agoraIssuerHostFor, loginHostServesAgora, readLoginHostDiscovery, } from './login-host.js';
|
|
20
|
+
import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_BASE, OWN_MAIL_SMTP_PORT_MAX, OWN_MAIL_SMTP_PORT_SCOPE_KEY, PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, isOwnMailSmtpPort, mailRelayFingerprint, } from './mail-relay.js';
|
|
21
|
+
import { findNameClashes, nameClashMessage } from './org-names.js';
|
|
22
|
+
import { ownHostnames } from './own-hostnames.js';
|
|
23
|
+
import { OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, orgTarget } from './own-services.js';
|
|
24
|
+
import { PLATFORM_ARCHON_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_KEY, appStepKey, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
|
|
25
|
+
import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesForOwn, agoraHostValuesFromPlatform, dnsBlockedReason, heldOwnKeyRefs, heldPlatformKeyRefs, orgPlatformReadinessFor, orgPlatformReadinessFrom, ownerFilesFrom, ownerSignInFrom, platformOwnerUserIdFrom, serviceGitRefsOf, } from './platform-org.js';
|
|
26
|
+
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY, } from './platform-providers.js';
|
|
27
|
+
import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, } from './proxy-labels.js';
|
|
19
28
|
import { liveWorkers, workerTraceScopeKeys } from './worker.js';
|
|
20
29
|
/** #441: the lease scope that serializes every mutation of one company, and the one for the shared platform. */
|
|
21
30
|
const companyScope = (orgId) => `company:${orgId}`;
|
|
@@ -126,6 +135,10 @@ const HOSTNAME_STEPS = new Set([
|
|
|
126
135
|
* destructive or migrating step without the org's name typed, or one the
|
|
127
136
|
* actor is not authorized to run.
|
|
128
137
|
*/
|
|
138
|
+
/** `unmanaged` key of the fixed line for an own org on a host with no `ownServices` port. */
|
|
139
|
+
const OWN_SERVICES_PORT_ABSENT_KEY = 'own-services:port';
|
|
140
|
+
/** `unmanaged` key of the fixed line for an own org whose scoped ports have no files-organisation port. */
|
|
141
|
+
const OWN_FILES_ORGANISATION_PORT_ABSENT_KEY = 'own-services:files-port';
|
|
129
142
|
export class FoundryPlanApply {
|
|
130
143
|
store;
|
|
131
144
|
readers;
|
|
@@ -137,6 +150,7 @@ export class FoundryPlanApply {
|
|
|
137
150
|
agora;
|
|
138
151
|
jobsScheduler;
|
|
139
152
|
loginHostBindings;
|
|
153
|
+
loginHostDiscovery;
|
|
140
154
|
zitadelIdTokenUserinfo;
|
|
141
155
|
broker;
|
|
142
156
|
edgeWorkers;
|
|
@@ -144,10 +158,13 @@ export class FoundryPlanApply {
|
|
|
144
158
|
hostFiles;
|
|
145
159
|
hostAi;
|
|
146
160
|
hostEdge;
|
|
161
|
+
hostOwnServices;
|
|
162
|
+
sfuPortsOpen;
|
|
147
163
|
onlySteps;
|
|
148
164
|
now;
|
|
149
165
|
leaseTtlMs;
|
|
150
166
|
appliedVersions;
|
|
167
|
+
backupGate;
|
|
151
168
|
platformDeps;
|
|
152
169
|
runner;
|
|
153
170
|
constructor(options) {
|
|
@@ -161,6 +178,7 @@ export class FoundryPlanApply {
|
|
|
161
178
|
this.agora = options.agora;
|
|
162
179
|
this.jobsScheduler = options.jobsScheduler ?? false;
|
|
163
180
|
this.loginHostBindings = options.loginHostBindings ?? false;
|
|
181
|
+
this.loginHostDiscovery = options.loginHostDiscovery;
|
|
164
182
|
this.zitadelIdTokenUserinfo = options.zitadelIdTokenUserinfo ?? false;
|
|
165
183
|
this.broker = options.broker ?? false;
|
|
166
184
|
this.edgeWorkers = options.edgeWorkers ?? false;
|
|
@@ -168,10 +186,13 @@ export class FoundryPlanApply {
|
|
|
168
186
|
this.hostFiles = options.filesOrganisation ?? false;
|
|
169
187
|
this.hostAi = options.aiOrganisation ?? false;
|
|
170
188
|
this.hostEdge = options.edge ?? false;
|
|
189
|
+
this.hostOwnServices = options.ownServices ?? false;
|
|
190
|
+
this.sfuPortsOpen = options.sfuPortsOpen;
|
|
171
191
|
this.onlySteps = options.onlySteps;
|
|
172
192
|
this.now = options.now ?? (() => new Date());
|
|
173
193
|
this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
|
|
174
194
|
this.appliedVersions = options.appliedVersions;
|
|
195
|
+
this.backupGate = options.backupGate;
|
|
175
196
|
this.platformDeps = options.platform;
|
|
176
197
|
this.runner = options.runner;
|
|
177
198
|
}
|
|
@@ -345,6 +366,7 @@ export class FoundryPlanApply {
|
|
|
345
366
|
const reader = this.readers.coolify;
|
|
346
367
|
const live = project && reader.listProject ? await reader.listProject(project) : null;
|
|
347
368
|
const claimedElsewhere = [];
|
|
369
|
+
const platformRecorded = new Set(Object.values((await this.platformDeps?.store.getServiceIds()) ?? {}));
|
|
348
370
|
for (const [what, uuid] of [
|
|
349
371
|
['application', input.applicationUuid],
|
|
350
372
|
['database', input.databaseUuid],
|
|
@@ -352,6 +374,10 @@ export class FoundryPlanApply {
|
|
|
352
374
|
]) {
|
|
353
375
|
if (!uuid)
|
|
354
376
|
continue;
|
|
377
|
+
if (platformRecorded.has(uuid)) {
|
|
378
|
+
claimedElsewhere.push(`${what} ${uuid} is recorded by the platform`);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
355
381
|
const owner = await this.store.findOtherOrgRecordingCoolifyId(uuid, id);
|
|
356
382
|
if (owner)
|
|
357
383
|
claimedElsewhere.push(`${what} ${uuid} is already recorded by org ${owner}`);
|
|
@@ -379,12 +405,50 @@ export class FoundryPlanApply {
|
|
|
379
405
|
await this.refuse(plan, `version change without confirmUpgrade naming: ${unconfirmed.map((u) => u.service).join(', ')}`);
|
|
380
406
|
throw new UpgradeConfirmationRequiredError(unconfirmed);
|
|
381
407
|
}
|
|
408
|
+
// F6: before step one. No override.
|
|
409
|
+
let backupId = null;
|
|
410
|
+
if (this.backupGate) {
|
|
411
|
+
try {
|
|
412
|
+
({ backupId } = await requireFreshBackupsForUpgrades(freshPlan, options.confirmUpgrade, {
|
|
413
|
+
coolify: this.backupGate.coolify,
|
|
414
|
+
orgId,
|
|
415
|
+
recordedIds: (await this.store.getAppliedState(orgId))?.serviceIds ?? {},
|
|
416
|
+
now: () => this.now().getTime(),
|
|
417
|
+
}));
|
|
418
|
+
}
|
|
419
|
+
catch (e) {
|
|
420
|
+
if (e instanceof UpgradeBackupMissingError) {
|
|
421
|
+
await this.refuse(plan, `upgrade of ${e.service} without a fresh backup`);
|
|
422
|
+
}
|
|
423
|
+
throw e;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// A move to the company's own network deletes its databases: a fresh dump of each first.
|
|
427
|
+
if (detail.plannedSteps.some((s) => s.action.system === 'coolify' && s.action.kind === 'network')) {
|
|
428
|
+
try {
|
|
429
|
+
if (!this.backupGate)
|
|
430
|
+
throw new NetworkMoveBackupMissingError('no-gate');
|
|
431
|
+
await requireFreshBackupsForMove({
|
|
432
|
+
coolify: this.backupGate.coolify,
|
|
433
|
+
projectUuid: (await this.store.getAppliedState(orgId))?.serviceIds
|
|
434
|
+
?.coolify_project,
|
|
435
|
+
ownsMail: ownsServices(org) && org.mail.mode !== 'off',
|
|
436
|
+
now: () => this.now().getTime(),
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
catch (e) {
|
|
440
|
+
if (e instanceof NetworkMoveBackupMissingError) {
|
|
441
|
+
await this.refuse(plan, 'network move without a fresh backup');
|
|
442
|
+
}
|
|
443
|
+
throw e;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
382
446
|
if (planNeedsConfirmation(plan.steps) &&
|
|
383
447
|
confirmedOrgName.trim() !== org.displayName.trim()) {
|
|
384
448
|
await this.refuse(plan, "destructive or migrating step without the org's name typed correctly");
|
|
385
449
|
throw new ConfirmationRequiredError(orgId);
|
|
386
450
|
}
|
|
387
|
-
const applied = await this.runSteps(org, plan, detail, fence);
|
|
451
|
+
const applied = await this.runSteps(org, plan, detail, fence, backupId);
|
|
388
452
|
await this.refreshPlatformFilesShare();
|
|
389
453
|
return applied;
|
|
390
454
|
}));
|
|
@@ -479,6 +543,27 @@ export class FoundryPlanApply {
|
|
|
479
543
|
await this.refusePlatform(plan, platform.ownerOrgId, `version change without confirmUpgrade naming: ${unconfirmed.map((u) => u.service).join(', ')}`);
|
|
480
544
|
throw new UpgradeConfirmationRequiredError(unconfirmed);
|
|
481
545
|
}
|
|
546
|
+
// F6: before step one. No override. The platform records no applied versions, so no backup id is kept.
|
|
547
|
+
if (this.backupGate) {
|
|
548
|
+
try {
|
|
549
|
+
await requireFreshBackupsForUpgrades(freshPlan, options.confirmUpgrade, {
|
|
550
|
+
coolify: this.backupGate.coolify,
|
|
551
|
+
orgId: platform.ownerOrgId,
|
|
552
|
+
recordedIds: await this.requirePlatformDeps().store.getServiceIds(),
|
|
553
|
+
now: () => this.now().getTime(),
|
|
554
|
+
scope: {
|
|
555
|
+
keys: PLATFORM_BACKUP_DATABASE_SCOPE_KEYS,
|
|
556
|
+
forService: platformDatabasesForService,
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
catch (e) {
|
|
561
|
+
if (e instanceof UpgradeBackupMissingError) {
|
|
562
|
+
await this.refusePlatform(plan, platform.ownerOrgId, `upgrade of ${e.service} without a fresh backup`);
|
|
563
|
+
}
|
|
564
|
+
throw e;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
482
567
|
if (planNeedsConfirmation(plan.steps) && confirmedName.trim() !== 'platform') {
|
|
483
568
|
await this.refusePlatform(plan, platform.ownerOrgId, "destructive or migrating step without 'platform' typed correctly");
|
|
484
569
|
throw new PlatformConfirmationRequiredError();
|
|
@@ -572,6 +657,11 @@ export class FoundryPlanApply {
|
|
|
572
657
|
// gone" itself.
|
|
573
658
|
await this.destroyer.destroy(target, { org, recordedIds: { ...serviceIds } });
|
|
574
659
|
delete serviceIds[destroyServiceIdKey(target)];
|
|
660
|
+
if (target.system === 'cloudflare' && target.kind === 'tunnel') {
|
|
661
|
+
// The tunnel is gone, so what was written into it is too.
|
|
662
|
+
delete serviceIds[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY];
|
|
663
|
+
delete serviceIds[CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY];
|
|
664
|
+
}
|
|
575
665
|
if (target.system === 'cloudflare' && target.kind === 'worker') {
|
|
576
666
|
// #318: its partial-deploy traces too, or a retry after a later
|
|
577
667
|
// step fails would read this Worker as live again.
|
|
@@ -792,15 +882,17 @@ export class FoundryPlanApply {
|
|
|
792
882
|
at: this.now(),
|
|
793
883
|
});
|
|
794
884
|
}
|
|
795
|
-
async runSteps(org, plan, detail, fence) {
|
|
885
|
+
async runSteps(org, plan, detail, fence, backupId = null) {
|
|
796
886
|
const orgId = plan.orgId;
|
|
797
887
|
const previous = await this.store.getAppliedState(orgId);
|
|
798
888
|
const serviceIds = { ...(previous?.serviceIds ?? {}) };
|
|
799
889
|
const checkpoints = [];
|
|
800
890
|
// Backed by the very `serviceIds` map this method persists once, at the
|
|
801
891
|
// end — see `StepExecutionContext`'s doc comment for why.
|
|
892
|
+
const ownerCompany = (await this.platformDeps?.store.get())?.ownerOrgId === orgId;
|
|
802
893
|
const context = {
|
|
803
894
|
org,
|
|
895
|
+
ownerCompany,
|
|
804
896
|
recordedIds: () => ({ ...serviceIds }),
|
|
805
897
|
now: () => this.now().getTime(),
|
|
806
898
|
recordScopeId: (key, id) => {
|
|
@@ -887,6 +979,7 @@ export class FoundryPlanApply {
|
|
|
887
979
|
service: orgId,
|
|
888
980
|
ref: lastApplied.declaredOrgVersion,
|
|
889
981
|
appliedAt: lastApplied.at,
|
|
982
|
+
backupId,
|
|
890
983
|
});
|
|
891
984
|
await this.audit.recordApply({
|
|
892
985
|
actor: this.actor,
|
|
@@ -897,30 +990,118 @@ export class FoundryPlanApply {
|
|
|
897
990
|
});
|
|
898
991
|
return applied;
|
|
899
992
|
}
|
|
993
|
+
/** An own org with calls on: the host's answer about its slot's ports. A failure or a slow answer is "not open". */
|
|
994
|
+
async readSfuPortsOpen(org) {
|
|
995
|
+
const slot = org.services?.sfuSlot;
|
|
996
|
+
if (!this.sfuPortsOpen || !ownsServices(org) || !org.calls.enabled || !slot)
|
|
997
|
+
return false;
|
|
998
|
+
let ports;
|
|
999
|
+
try {
|
|
1000
|
+
ports = sfuSlotPorts(slot);
|
|
1001
|
+
}
|
|
1002
|
+
catch {
|
|
1003
|
+
return false;
|
|
1004
|
+
}
|
|
1005
|
+
const timeout = new Promise((resolve) => setTimeout(() => resolve(false), 5_000));
|
|
1006
|
+
return Promise.race([this.sfuPortsOpen(ports).catch(() => false), timeout]);
|
|
1007
|
+
}
|
|
900
1008
|
async computeFreshPlan(id) {
|
|
901
1009
|
const org = await this.store.get(id);
|
|
902
1010
|
if (!org)
|
|
903
1011
|
throw new DeclaredOrgNotFoundError(id);
|
|
904
|
-
const
|
|
905
|
-
|
|
906
|
-
this.
|
|
907
|
-
this.orgPlatformReadiness(),
|
|
908
|
-
this.orgAgoraHost(
|
|
1012
|
+
const applied = await this.store.getAppliedState(id);
|
|
1013
|
+
const [live, platformReadiness, agoraHost, archonUrl] = await Promise.all([
|
|
1014
|
+
readLiveResources(id, this.readers, org, applied?.serviceIds ?? {}),
|
|
1015
|
+
this.orgPlatformReadiness(org, applied?.serviceIds ?? {}),
|
|
1016
|
+
this.orgAgoraHost(org, applied?.serviceIds ?? {}),
|
|
909
1017
|
this.orgArchonUrl(),
|
|
910
1018
|
]);
|
|
911
1019
|
const otherSlugs = (await this.store.list()).filter((o) => o.id !== id).map((o) => o.slug);
|
|
912
1020
|
const platformHostnames = await this.platformServiceHostnames();
|
|
1021
|
+
const recorded = applied?.serviceIds ?? {};
|
|
1022
|
+
// Read only for an org Agora may switch and whose login host is applied.
|
|
1023
|
+
const issuerHost = agoraIssuerHostFor(org).host;
|
|
1024
|
+
const loginHostServesDiscovery = issuerHost !== null && loginHostServesAgora(issuerHost, org, recorded)
|
|
1025
|
+
? await readLoginHostDiscovery(this.loginHostDiscovery, issuerHost)
|
|
1026
|
+
: false;
|
|
1027
|
+
const sfuPortsOpen = await this.readSfuPortsOpen(org);
|
|
1028
|
+
const resumable = await this.ownResumableRefs(org);
|
|
913
1029
|
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
|
|
914
1030
|
loginHostBindings: this.loginHostBindings,
|
|
1031
|
+
loginHostServesDiscovery,
|
|
915
1032
|
zitadelIdTokenUserinfo: this.zitadelIdTokenUserinfo,
|
|
916
|
-
broker: this.
|
|
1033
|
+
broker: ownsServices(org) && this.hostOwnServices && this.executor.ownBrokerPort
|
|
1034
|
+
? await this.executor.ownBrokerPort(org, recorded)
|
|
1035
|
+
: this.broker,
|
|
917
1036
|
edgeWorkers: this.edgeWorkers,
|
|
918
1037
|
ownerInviteMail: this.ownerInviteMail,
|
|
919
1038
|
otherSlugs,
|
|
920
1039
|
now: () => this.now().getTime(),
|
|
921
1040
|
archonUrl,
|
|
922
1041
|
platformHostnames,
|
|
1042
|
+
ownServices: this.hostOwnServices,
|
|
1043
|
+
// The `oikos.host` zone, its wildcard and its tunnel are the platform's `platform:oikos-ingress` step.
|
|
1044
|
+
oikosZoneReady: platformReadiness.oikosIngressApplied === true,
|
|
1045
|
+
ownMailKeyHeld: await this.ownMailKeyHeld(org),
|
|
1046
|
+
ownMailKeyMintable: this.executor.ownMailKeyMintable?.() ?? false,
|
|
1047
|
+
...(this.backupGate
|
|
1048
|
+
? {
|
|
1049
|
+
backupStorageUuid: (await this.platformDeps?.store.getServiceIds())?.[PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY] || null,
|
|
1050
|
+
}
|
|
1051
|
+
: {}),
|
|
1052
|
+
ownJobsTokenHeld: resumable.jobs,
|
|
1053
|
+
ownSfuKeysHeld: resumable.sfu,
|
|
1054
|
+
...(ownsServices(org)
|
|
1055
|
+
? {
|
|
1056
|
+
mailRelayApplied: await this.mailRelayApplied(),
|
|
1057
|
+
ownMailSmtpPort: await this.ownMailSmtpPort(org),
|
|
1058
|
+
mailRelayRoute: await this.mailRelayHoldsRoute(org),
|
|
1059
|
+
}
|
|
1060
|
+
: {}),
|
|
1061
|
+
sfuPortsOpen,
|
|
1062
|
+
ownFilesDeps: this.executor.ownFilesDeps?.(),
|
|
1063
|
+
...(ownsServices(org) && this.executor.ownFilesR2Ports
|
|
1064
|
+
? { ownFilesR2Ports: await this.executor.ownFilesR2Ports(org, recorded) }
|
|
1065
|
+
: {}),
|
|
923
1066
|
});
|
|
1067
|
+
if (!ownsServices(org)) {
|
|
1068
|
+
// No company runs the shared services; each runs its own.
|
|
1069
|
+
detail.plannedSteps = [];
|
|
1070
|
+
detail.versionChanges = [];
|
|
1071
|
+
detail.unmanaged.push({
|
|
1072
|
+
description: `${org.slug} has no services.own: the shared services are removed, so a company runs its own. Set services.own.`,
|
|
1073
|
+
key: 'services:shared-removed',
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
if (ownsServices(org) && !this.hostOwnServices) {
|
|
1077
|
+
detail.unmanaged.push({
|
|
1078
|
+
description: `${org.slug} runs its own services, and this host has no ownServices port`,
|
|
1079
|
+
key: OWN_SERVICES_PORT_ABSENT_KEY,
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
if (ownsServices(org) &&
|
|
1083
|
+
this.hostOwnServices &&
|
|
1084
|
+
detail.plannedSteps.some(({ action }) => action.system === 'files' && action.kind === 'organisation') &&
|
|
1085
|
+
(await this.executor.ownFilesOrganisationPort?.(org, recorded)) === false) {
|
|
1086
|
+
detail.plannedSteps = detail.plannedSteps.filter(({ action }) => !(action.system === 'files' && action.kind === 'organisation'));
|
|
1087
|
+
detail.unmanaged.push({
|
|
1088
|
+
description: `${org.slug}'s Files organisation is blocked, not planned: the host gave the company no files-organisation port`,
|
|
1089
|
+
key: OWN_FILES_ORGANISATION_PORT_ABSENT_KEY,
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
const clashes = findNameClashes(org, await this.store.list());
|
|
1093
|
+
for (const source of ['own', 'hosting']) {
|
|
1094
|
+
const clash = clashes.find((c) => c.source === source);
|
|
1095
|
+
if (!clash)
|
|
1096
|
+
continue;
|
|
1097
|
+
detail.plannedSteps = detail.plannedSteps.filter(({ action }) => source === 'own'
|
|
1098
|
+
? !(action.system === 'coolify' && action.kind.startsWith('own-'))
|
|
1099
|
+
: !(action.system === 'coolify' && (action.kind === 'app' || action.kind === 'database')));
|
|
1100
|
+
detail.unmanaged.push({
|
|
1101
|
+
description: `${org.slug}'s ${source === 'own' ? 'own services are' : 'hosting is'} blocked, not planned: ${nameClashMessage(org.slug, clash)}`,
|
|
1102
|
+
key: `names:clash:${source}`,
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
924
1105
|
const invalid = await this.invalidHosting(org);
|
|
925
1106
|
if (invalid) {
|
|
926
1107
|
// Rule 2: reported in `unmanaged`, not thrown. Steps that read the
|
|
@@ -960,7 +1141,78 @@ export class FoundryPlanApply {
|
|
|
960
1141
|
* DEFAULT_ORG_PLATFORM_READINESS} reports every service not ready, so every
|
|
961
1142
|
* org step that needs one is blocked.
|
|
962
1143
|
*/
|
|
963
|
-
async orgPlatformReadiness() {
|
|
1144
|
+
async orgPlatformReadiness(org, orgRecordedIds) {
|
|
1145
|
+
const base = await this.sharedPlatformReadiness();
|
|
1146
|
+
if (!ownsServices(org))
|
|
1147
|
+
return base;
|
|
1148
|
+
const held = this.platformDeps?.heldKeys;
|
|
1149
|
+
const platform = await this.platformDeps?.store.get();
|
|
1150
|
+
const readiness = orgPlatformReadinessFor(org, base, orgRecordedIds, held ? await heldOwnKeyRefs(held, org) : new Set());
|
|
1151
|
+
return platform
|
|
1152
|
+
? { ...readiness, serviceGitRefs: serviceGitRefsOf(platform, org.versionPin) }
|
|
1153
|
+
: readiness;
|
|
1154
|
+
}
|
|
1155
|
+
/** An own org's Lettermint key is held. A resolver that throws counts as not held. */
|
|
1156
|
+
async ownMailKeyHeld(org) {
|
|
1157
|
+
const held = this.platformDeps?.heldKeys;
|
|
1158
|
+
if (!ownsServices(org) || !held)
|
|
1159
|
+
return false;
|
|
1160
|
+
return (await heldOwnKeyRefs(held, org)).has(orgTarget(org).refs.brokerMailKey);
|
|
1161
|
+
}
|
|
1162
|
+
/** Which of an own org's undelivered-secret refs hold a value now, so a rerun resumes instead of blocking. */
|
|
1163
|
+
async ownResumableRefs(org) {
|
|
1164
|
+
const held = this.platformDeps?.heldKeys;
|
|
1165
|
+
if (!ownsServices(org) || !held)
|
|
1166
|
+
return { jobs: false, sfu: false };
|
|
1167
|
+
const refs = orgTarget(org).refs;
|
|
1168
|
+
const heldRefs = await heldOwnKeyRefs(held, org);
|
|
1169
|
+
return { jobs: heldRefs.has(refs.jobsAdminToken), sfu: heldRefs.has(refs.livekitKeys) };
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* The host port an own org's Stalwart publishes for the relay: the one it already has recorded, else
|
|
1173
|
+
* the lowest port in the range no other org has recorded. Two plans made at the same moment could pick
|
|
1174
|
+
* the same port; the second Stalwart then fails to bind it and the step fails, with nothing recorded.
|
|
1175
|
+
*/
|
|
1176
|
+
async ownMailSmtpPort(org) {
|
|
1177
|
+
const own = (await this.store.getAppliedState(org.id))?.serviceIds?.[OWN_MAIL_SMTP_PORT_SCOPE_KEY];
|
|
1178
|
+
if (own && isOwnMailSmtpPort(Number(own)))
|
|
1179
|
+
return Number(own);
|
|
1180
|
+
const taken = new Set();
|
|
1181
|
+
for (const other of await this.store.list()) {
|
|
1182
|
+
const port = (await this.store.getAppliedState(other.id))?.serviceIds?.[OWN_MAIL_SMTP_PORT_SCOPE_KEY];
|
|
1183
|
+
if (port)
|
|
1184
|
+
taken.add(Number(port));
|
|
1185
|
+
}
|
|
1186
|
+
for (let port = OWN_MAIL_SMTP_PORT_BASE; port <= OWN_MAIL_SMTP_PORT_MAX; port += 1) {
|
|
1187
|
+
if (!taken.has(port))
|
|
1188
|
+
return port;
|
|
1189
|
+
}
|
|
1190
|
+
return OWN_MAIL_SMTP_PORT_MAX + 1;
|
|
1191
|
+
}
|
|
1192
|
+
/** The relay is declared and has recorded the routes it last applied: port 25 reaches the mail servers. */
|
|
1193
|
+
async mailRelayApplied() {
|
|
1194
|
+
const platform = await this.platformDeps?.store.get();
|
|
1195
|
+
if (!platform?.mail.relay)
|
|
1196
|
+
return false;
|
|
1197
|
+
const recorded = await this.platformDeps?.store.getServiceIds();
|
|
1198
|
+
return Boolean(recorded?.[PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY]);
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* The relay's last applied routes (a fingerprint, recorded when it deploys) are the routes the
|
|
1202
|
+
* orgs' recorded SMTP addresses give now, and one of them is this org's mail domain.
|
|
1203
|
+
*/
|
|
1204
|
+
async mailRelayHoldsRoute(org) {
|
|
1205
|
+
const platform = await this.platformDeps?.store.get();
|
|
1206
|
+
if (!platform?.mail.relay)
|
|
1207
|
+
return false;
|
|
1208
|
+
const { mailRoutes } = await this.mailRelayInputs();
|
|
1209
|
+
const domain = ownHostnames(org).mailDomain.toLowerCase();
|
|
1210
|
+
if (!mailRoutes.some((r) => r.domain.toLowerCase() === domain))
|
|
1211
|
+
return false;
|
|
1212
|
+
const recorded = await this.platformDeps?.store.getServiceIds();
|
|
1213
|
+
return recorded?.[PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY] === mailRelayFingerprint(mailRoutes);
|
|
1214
|
+
}
|
|
1215
|
+
async sharedPlatformReadiness() {
|
|
964
1216
|
if (!this.platformDeps)
|
|
965
1217
|
return DEFAULT_ORG_PLATFORM_READINESS;
|
|
966
1218
|
const platform = await this.platformDeps.store.get();
|
|
@@ -985,8 +1237,13 @@ export class FoundryPlanApply {
|
|
|
985
1237
|
catch {
|
|
986
1238
|
// not held
|
|
987
1239
|
}
|
|
1240
|
+
const ownerOrg = await this.store.get(platform.ownerOrgId);
|
|
1241
|
+
const ownerRecorded = ownerOrg
|
|
1242
|
+
? (await this.store.getAppliedState(ownerOrg.id))?.serviceIds
|
|
1243
|
+
: {};
|
|
988
1244
|
return {
|
|
989
1245
|
...readiness,
|
|
1246
|
+
platformOwnerUserId: platformOwnerUserIdFrom(ownerOrg, ownerRecorded ?? {}),
|
|
990
1247
|
mailKeyHeld,
|
|
991
1248
|
canHoldSecrets: typeof heldKeys?.put === 'function',
|
|
992
1249
|
dnsBlocked: dnsBlockedReason(platform, recorded),
|
|
@@ -998,11 +1255,14 @@ export class FoundryPlanApply {
|
|
|
998
1255
|
* `platformDeps` {@link orgPlatformReadiness} reads -- but only when the
|
|
999
1256
|
* host supplies no `agora` port of its own. An explicit `agora` (#164)
|
|
1000
1257
|
* always wins, unchanged, so a host that already has its own values
|
|
1001
|
-
* (perhaps not yet wired to this platform) keeps them.
|
|
1258
|
+
* (perhaps not yet wired to this platform) keeps them. An org that owns its
|
|
1259
|
+
* services never takes the host's values: they belong to the shared services.
|
|
1002
1260
|
*/
|
|
1003
|
-
async orgAgoraHost(
|
|
1261
|
+
async orgAgoraHost(org, orgRecordedIds) {
|
|
1262
|
+
if (ownsServices(org))
|
|
1263
|
+
return agoraHostValuesForOwn(org, orgRecordedIds);
|
|
1004
1264
|
if (this.agora)
|
|
1005
|
-
return readAgoraHost(this.agora, id);
|
|
1265
|
+
return readAgoraHost(this.agora, org.id);
|
|
1006
1266
|
if (!this.platformDeps)
|
|
1007
1267
|
return NO_AGORA_HOST_VALUES;
|
|
1008
1268
|
const platform = await this.platformDeps.store.get();
|
|
@@ -1051,16 +1311,73 @@ export class FoundryPlanApply {
|
|
|
1051
1311
|
const callsOrgSlugs = (await this.store.list())
|
|
1052
1312
|
.filter((org) => org.calls.enabled)
|
|
1053
1313
|
.map((org) => org.slug);
|
|
1314
|
+
const mailRelay = platform.mail.relay === true ? await this.mailRelayInputs() : null;
|
|
1315
|
+
const owner = await this.platformOwnerInput(platform);
|
|
1054
1316
|
const detail = computePlatformPlanDetail(platform, live, {
|
|
1317
|
+
owner,
|
|
1055
1318
|
jobsObservable: readers.coolify !== undefined,
|
|
1056
1319
|
sfuBlocked,
|
|
1057
1320
|
callsOrgSlugs,
|
|
1321
|
+
...(mailRelay ?? {}),
|
|
1058
1322
|
recordedIds: serviceIds,
|
|
1059
|
-
|
|
1060
|
-
|
|
1323
|
+
liveBuilds: await readLiveBuilds(readers.coolify, serviceIds),
|
|
1324
|
+
blocked: (await executor.blockedSteps?.(platform, serviceIds, owner)) ?? {},
|
|
1325
|
+
drifted: (await executor.driftedSteps?.(platform, serviceIds, owner)) ?? {},
|
|
1061
1326
|
});
|
|
1062
1327
|
return { platform, plan: toPlatformPlan(detail, platform.ownerOrgId), detail };
|
|
1063
1328
|
}
|
|
1329
|
+
/** The owner company's own sign-in and files host, read from its applied ids; `null` members until applied. */
|
|
1330
|
+
async platformOwnerInput(platform) {
|
|
1331
|
+
const owner = await this.store.get(platform.ownerOrgId);
|
|
1332
|
+
if (!owner)
|
|
1333
|
+
return { signIn: null, files: null };
|
|
1334
|
+
const recorded = (await this.store.getAppliedState(owner.id))?.serviceIds ?? {};
|
|
1335
|
+
return { signIn: ownerSignInFrom(owner, recorded), files: ownerFilesFrom(owner, recorded) };
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* What the shared mail relay needs from the orgs. A route exists for an org
|
|
1339
|
+
* that owns its services, serves mail on its own domain and has recorded its
|
|
1340
|
+
* mail server's SMTP address (`mail_smtp_internal`, written by the company
|
|
1341
|
+
* mail step), on its mail domain: its own domain, or `<slug>.oikos.host`.
|
|
1342
|
+
* There is no platform mail server: a domain with no route is refused. An org
|
|
1343
|
+
* that owns its services but has no mail server yet has no route, so the
|
|
1344
|
+
* relay refuses its mail until the server exists.
|
|
1345
|
+
*/
|
|
1346
|
+
async mailRelayInputs() {
|
|
1347
|
+
const candidates = [];
|
|
1348
|
+
for (const org of await this.store.list()) {
|
|
1349
|
+
if (org.mail.mode === 'off')
|
|
1350
|
+
continue;
|
|
1351
|
+
if (!ownsServices(org))
|
|
1352
|
+
continue;
|
|
1353
|
+
const recorded = (await this.store.getAppliedState(org.id))?.serviceIds ?? {};
|
|
1354
|
+
const target = recorded[OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY];
|
|
1355
|
+
if (recorded.coolify_platform_mail_app && target) {
|
|
1356
|
+
candidates.push({
|
|
1357
|
+
slug: org.slug,
|
|
1358
|
+
route: { domain: ownHostnames(org).mailDomain, target },
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
// A domain two companies claim gets no route; the other routes stay.
|
|
1363
|
+
const claims = new Map();
|
|
1364
|
+
for (const { route } of candidates) {
|
|
1365
|
+
const domain = route.domain.toLowerCase();
|
|
1366
|
+
claims.set(domain, (claims.get(domain) ?? 0) + 1);
|
|
1367
|
+
}
|
|
1368
|
+
const mailRoutes = [];
|
|
1369
|
+
const mailRoutesBlocked = [];
|
|
1370
|
+
for (const { slug, route } of candidates) {
|
|
1371
|
+
const domain = route.domain.toLowerCase();
|
|
1372
|
+
if ((claims.get(domain) ?? 0) > 1) {
|
|
1373
|
+
mailRoutesBlocked.push(`${slug}'s mail route for ${route.domain} is blocked, not planned: another company claims ${route.domain}`);
|
|
1374
|
+
}
|
|
1375
|
+
else {
|
|
1376
|
+
mailRoutes.push(route);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
return { mailRoutes, mailRoutesBlocked };
|
|
1380
|
+
}
|
|
1064
1381
|
async sfuBlockedReason(platform) {
|
|
1065
1382
|
if (!platform.sfu.enabled)
|
|
1066
1383
|
return null;
|
|
@@ -1100,6 +1417,7 @@ export class FoundryPlanApply {
|
|
|
1100
1417
|
const serviceIds = { ...(await store.getServiceIds()) };
|
|
1101
1418
|
const context = {
|
|
1102
1419
|
platform,
|
|
1420
|
+
owner: await this.platformOwnerInput(platform),
|
|
1103
1421
|
recordedIds: () => ({ ...serviceIds }),
|
|
1104
1422
|
recordScopeId: (key, id) => {
|
|
1105
1423
|
serviceIds[key] = id;
|
|
@@ -1202,6 +1520,8 @@ function destroyTargets(serviceIds, orgId) {
|
|
|
1202
1520
|
'cloudflare:worker:',
|
|
1203
1521
|
'coolify:database:',
|
|
1204
1522
|
'coolify:app:',
|
|
1523
|
+
// An own files host's storage key: the token id is the step's recorded resource id.
|
|
1524
|
+
'coolify:own-files-r2:',
|
|
1205
1525
|
'zitadel:customers:',
|
|
1206
1526
|
// F6a: an entry's ai key, `<orgId>:<entry>`; the org's own key has no `:` and is left alone.
|
|
1207
1527
|
'ai:key:',
|
|
@@ -1281,6 +1601,16 @@ function destroyTargets(serviceIds, orgId) {
|
|
|
1281
1601
|
continue;
|
|
1282
1602
|
targets.push({ system: 'edge', kind: 'key', key: entryKey, id });
|
|
1283
1603
|
}
|
|
1604
|
+
// A storage key minted but never delivered has no main id; its token is deleted too.
|
|
1605
|
+
const r2Undelivered = serviceIds[OWN_FILES_R2_UNDELIVERED_SCOPE_KEY];
|
|
1606
|
+
if (r2Undelivered) {
|
|
1607
|
+
targets.push({
|
|
1608
|
+
system: 'coolify',
|
|
1609
|
+
kind: 'own-files-r2',
|
|
1610
|
+
key: 'undelivered',
|
|
1611
|
+
id: r2Undelivered,
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1284
1614
|
for (const [resourceKey, id] of Object.entries(serviceIds)) {
|
|
1285
1615
|
if (!resourceKey.startsWith('zitadel:app:') || !id)
|
|
1286
1616
|
continue;
|
|
@@ -1337,6 +1667,9 @@ function destroyStepDescriptions(targets) {
|
|
|
1337
1667
|
function destroyServiceIdKey(target) {
|
|
1338
1668
|
if (target.kind === 'project' || target.kind === 'org')
|
|
1339
1669
|
return target.key;
|
|
1670
|
+
if (target.kind === 'own-files-r2' && target.key === 'undelivered') {
|
|
1671
|
+
return OWN_FILES_R2_UNDELIVERED_SCOPE_KEY;
|
|
1672
|
+
}
|
|
1340
1673
|
return `${target.system}:${target.kind}:${target.key}`;
|
|
1341
1674
|
}
|
|
1342
1675
|
/**
|
|
@@ -1352,6 +1685,12 @@ const STEP_FIELDS = {
|
|
|
1352
1685
|
'coolify:broker-entry': 'hosting.entries.uses.auth',
|
|
1353
1686
|
'coolify:mail': 'mail',
|
|
1354
1687
|
'coolify:chat': 'chat.usesDomain',
|
|
1688
|
+
'coolify:own-auth': 'services.own',
|
|
1689
|
+
'coolify:own-files': 'services.own',
|
|
1690
|
+
'coolify:own-files-r2': 'services.own',
|
|
1691
|
+
'coolify:own-jobs': 'services.own',
|
|
1692
|
+
'coolify:own-mail': 'services.own',
|
|
1693
|
+
'coolify:own-sfu': 'services.own',
|
|
1355
1694
|
'coolify:calls': 'calls.enabled',
|
|
1356
1695
|
'coolify:calls-service': 'calls.enabled',
|
|
1357
1696
|
'coolify:calls-chat': 'calls.enabled',
|