@wtfalch/foundry 0.28.0 → 0.30.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/calls-sfu-ssh-client.d.ts +11 -0
- package/dist/calls-sfu-ssh-client.js +65 -11
- package/dist/index.d.ts +7 -5
- package/dist/index.js +4 -3
- package/dist/org.d.ts +88 -0
- package/dist/org.js +68 -0
- package/dist/plan/agora.d.ts +13 -0
- package/dist/plan/agora.js +15 -0
- package/dist/plan/boule.d.ts +27 -3
- package/dist/plan/boule.js +39 -1
- package/dist/plan/chat-matrix.d.ts +31 -2
- package/dist/plan/chat-matrix.js +40 -6
- package/dist/plan/diff.d.ts +45 -2
- package/dist/plan/diff.js +211 -16
- package/dist/plan/executor.d.ts +132 -0
- package/dist/plan/executor.js +510 -31
- package/dist/plan/index.d.ts +5 -2
- package/dist/plan/index.js +3 -1
- package/dist/plan/plan-apply.js +1 -0
- package/dist/plan/platform-diff.d.ts +9 -0
- package/dist/plan/platform-diff.js +15 -3
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-org.js +4 -1
- package/dist/plan/platform-providers.d.ts +1 -1
- package/dist/plan/platform-providers.js +79 -22
- package/dist/plan/providers-readers.d.ts +15 -2
- package/dist/plan/providers-readers.js +44 -6
- package/dist/plan/step-system.js +1 -0
- package/dist/plan/version-status.d.ts +200 -0
- package/dist/plan/version-status.js +213 -0
- package/dist/plan/zitadel-login-policy.d.ts +23 -0
- package/dist/plan/zitadel-login-policy.js +103 -0
- package/dist/plan/zitadel-machine-user.d.ts +1 -1
- package/dist/plan/zitadel-machine-user.js +34 -0
- package/dist/platform.d.ts +1 -1
- package/package.json +3 -2
package/dist/plan/executor.js
CHANGED
|
@@ -2,11 +2,11 @@ import { randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { URL } from 'node:url';
|
|
3
3
|
import { resolveCallsSfuUrls } from '../calls-sfu.js';
|
|
4
4
|
import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
|
|
5
|
-
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
6
|
-
import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
5
|
+
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
6
|
+
import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
7
7
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
8
8
|
import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
9
|
-
import { CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, chatInternalNames, chatMasConfigYaml, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
|
|
9
|
+
import { CHAT_MAS_PORT, CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, assertChatServerName, chatInternalNames, chatMasConfigYaml, chatRekeyedInternalNames, chatServerName, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
|
|
10
10
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
11
11
|
import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
|
|
12
12
|
import { waitForNewDeployment, } from './deploy-health.js';
|
|
@@ -607,6 +607,39 @@ const CHAT_SERVICE_SCOPE_KEY = 'coolify_chat_service';
|
|
|
607
607
|
const CHAT_SYNAPSE_DB_SCOPE_KEY = 'coolify_chat_synapse_database';
|
|
608
608
|
const CHAT_MAS_DB_SCOPE_KEY = 'coolify_chat_mas_database';
|
|
609
609
|
const CHAT_SERVICE_DB_SCOPE_KEY = 'coolify_chat_service_database';
|
|
610
|
+
/**
|
|
611
|
+
* #463: `coolifyChatRekey`'s own scope ids for the new, domain-named
|
|
612
|
+
* Synapse+MAS pair the re-key provisions beside the original. The chat
|
|
613
|
+
* service and its own database keep their original scope keys above --
|
|
614
|
+
* only the two Matrix apps and their databases are new resources; the
|
|
615
|
+
* service is repointed in place, never re-created.
|
|
616
|
+
*/
|
|
617
|
+
const CHAT_REKEY_SYNAPSE_SCOPE_KEY = 'coolify_chat_rekey_synapse';
|
|
618
|
+
const CHAT_REKEY_MAS_SCOPE_KEY = 'coolify_chat_rekey_mas';
|
|
619
|
+
const CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY = 'coolify_chat_rekey_synapse_database';
|
|
620
|
+
const CHAT_REKEY_MAS_DB_SCOPE_KEY = 'coolify_chat_rekey_mas_database';
|
|
621
|
+
/**
|
|
622
|
+
* Set once {@link ChatRekeyPort.copyHomeserver} returns, so a retried or
|
|
623
|
+
* repeated apply never copies the same history twice -- the copy itself is
|
|
624
|
+
* also idempotent by server name (that port's own doc comment), so this is
|
|
625
|
+
* belt and suspenders, not the only guard.
|
|
626
|
+
*/
|
|
627
|
+
const CHAT_REKEY_COPIED_SCOPE_KEY = 'coolify_chat_rekey_copied';
|
|
628
|
+
/**
|
|
629
|
+
* A copy of the level-1 MAS app's id, taken the first time
|
|
630
|
+
* `coolifyChatRekey` runs for an org, before anything else happens. The
|
|
631
|
+
* generic `coolify:chat:<orgId>` scope id this step returns as its own
|
|
632
|
+
* `resourceId` is overwritten with the *new* MAS app's id once the re-key
|
|
633
|
+
* finishes, so a second run reading that generic id to decide what to park
|
|
634
|
+
* would stop the live, just-rekeyed MAS instead of the original one --
|
|
635
|
+
* this dedicated copy is what every run parks from, immune to that
|
|
636
|
+
* overwrite.
|
|
637
|
+
*/
|
|
638
|
+
const CHAT_REKEY_ORIGINAL_MAS_SCOPE_KEY = 'coolify_chat_rekey_original_mas';
|
|
639
|
+
/** Set once the chat service runs healthy on the new pair; the original is parked only after it. */
|
|
640
|
+
const CHAT_REKEY_REPOINTED_SCOPE_KEY = 'coolify_chat_rekey_repointed';
|
|
641
|
+
/** The copy is bounded; an interrupted one is safe to run again because the port is idempotent. */
|
|
642
|
+
const CHAT_REKEY_COPY_TIMEOUT_MS = 60 * 60 * 1000;
|
|
610
643
|
/** {@link CALLS_SECRET_ENV_KEYS}' counterpart on chat's own MAS app. */
|
|
611
644
|
const CHAT_SECRET_ENV_KEYS = {
|
|
612
645
|
masSynapseSharedSecret: 'CHAT_MATRIX_MAS_SYNAPSE_SHARED_SECRET',
|
|
@@ -784,6 +817,8 @@ export class FoundryStepExecutor {
|
|
|
784
817
|
return this.coolifyCalls(action, org, context);
|
|
785
818
|
case 'coolify:calls-service':
|
|
786
819
|
return this.coolifyCallsService(action, org, context);
|
|
820
|
+
case 'coolify:calls-chat':
|
|
821
|
+
return this.coolifyCallsChat(action, org, context);
|
|
787
822
|
case 'cloudflare:tunnel':
|
|
788
823
|
return this.cloudflareTunnel(action, org, context);
|
|
789
824
|
case 'cloudflare:zone':
|
|
@@ -1293,13 +1328,14 @@ export class FoundryStepExecutor {
|
|
|
1293
1328
|
// ZITADEL's default invite link (`/ui/v2/login/verify`) 404s; the platform's login app, which
|
|
1294
1329
|
// every org's users sign in through on this instance, serves `/verify` at the root.
|
|
1295
1330
|
const authIssuer = action.desired.authIssuer;
|
|
1331
|
+
if (!authIssuer) {
|
|
1332
|
+
throw new Error(`zitadel:owner for ${org.slug}: no invite template issuer -- refusing to send ZITADEL's default invite link, which 404s on this platform`);
|
|
1333
|
+
}
|
|
1296
1334
|
const { userId } = await this.ports.zitadel.inviteOrgOwner(zitadelOrgId, {
|
|
1297
1335
|
givenName: action.desired.givenName,
|
|
1298
1336
|
familyName: action.desired.familyName,
|
|
1299
1337
|
email: action.desired.email,
|
|
1300
|
-
}, authIssuer
|
|
1301
|
-
? `${authIssuer}/verify?userId={{.UserID}}&code={{.Code}}&organization={{.OrgID}}&invite=true`
|
|
1302
|
-
: undefined);
|
|
1338
|
+
}, `${authIssuer}/verify?userId={{.UserID}}&code={{.Code}}&organization={{.OrgID}}&invite=true`);
|
|
1303
1339
|
// An owner invited after chat was built never reaches `coolifyChat`
|
|
1304
1340
|
// again, so the role is granted here once the messaging project exists.
|
|
1305
1341
|
const messagingProjectId = context.recordedIds()[ZITADEL_MESSAGING_PROJECT_SCOPE_KEY];
|
|
@@ -2493,6 +2529,10 @@ export class FoundryStepExecutor {
|
|
|
2493
2529
|
* domain restricted to them before any old project is deleted, so the
|
|
2494
2530
|
* restriction list never becomes empty.
|
|
2495
2531
|
*
|
|
2532
|
+
* #654: the non-platform-apex part now lives in {@link ensureOrgAppMailToken},
|
|
2533
|
+
* shared with Boule, which holds the same token; the rule below is its rule
|
|
2534
|
+
* with the consumers read from the hosted app and Boule.
|
|
2535
|
+
*
|
|
2496
2536
|
* Mint rule ("Credential model"): the project is healthy when the app env
|
|
2497
2537
|
* holds `LETTERMINT_API_TOKEN` and Lettermint lists exactly one project
|
|
2498
2538
|
* named `<slug>-app` -- unlike the earlier provider's key, a Lettermint project's name
|
|
@@ -2545,7 +2585,6 @@ export class FoundryStepExecutor {
|
|
|
2545
2585
|
if (!hasPlatformApexField) {
|
|
2546
2586
|
throw new Error(`coolify:app for ${org.slug}: this step's plan carries no platformApex; plan this org's app again`);
|
|
2547
2587
|
}
|
|
2548
|
-
const appName = `${org.slug}-app`;
|
|
2549
2588
|
// #491: an org whose mail domain is the platform's own apex (Company
|
|
2550
2589
|
// Included on companyincluded.com, today) shares the platform's existing
|
|
2551
2590
|
// Lettermint project rather than minting a `<slug>-app` one of its own --
|
|
@@ -2588,44 +2627,129 @@ export class FoundryStepExecutor {
|
|
|
2588
2627
|
await this.ports.coolify.restart(appId);
|
|
2589
2628
|
return;
|
|
2590
2629
|
}
|
|
2591
|
-
|
|
2630
|
+
await this.ensureOrgAppMailToken({
|
|
2631
|
+
org,
|
|
2632
|
+
context,
|
|
2633
|
+
mailDomain,
|
|
2634
|
+
self: { role: 'app', id: appId, restart: running },
|
|
2635
|
+
mailFrom: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}`,
|
|
2636
|
+
platformApex,
|
|
2637
|
+
platformLettermintProjectId,
|
|
2638
|
+
});
|
|
2639
|
+
}
|
|
2640
|
+
/**
|
|
2641
|
+
* The one mint rule for the org's `<slug>-app` Lettermint token, shared by
|
|
2642
|
+
* the hosted app (`ensureAppLettermintMail`) and Boule
|
|
2643
|
+
* (`ensureBouleMail`), which hold the same token. Lettermint shows a
|
|
2644
|
+
* token once, so the token lives only in those apps' envs: the rule reads
|
|
2645
|
+
* every consumer that is recorded (the hosted app first, Boule second).
|
|
2646
|
+
* The project is healthy when Lettermint lists exactly one `<slug>-app`
|
|
2647
|
+
* project and some consumer holds a token. A healthy rule gives `self` that
|
|
2648
|
+
* token (the app's wins) when it lacks it or holds another; a peer gets it
|
|
2649
|
+
* in its own step. An
|
|
2650
|
+
* unhealthy one mints a fresh project and writes its token to every
|
|
2651
|
+
* consumer, restarting those that run, before any other `<slug>-app`
|
|
2652
|
+
* project is deleted. Either role may run first: an app added after Boule
|
|
2653
|
+
* adopts Boule's token instead of deleting its project.
|
|
2654
|
+
*
|
|
2655
|
+
* The token goes straight to `bulkUpsertEnvs` and nowhere else: never
|
|
2656
|
+
* returned, recorded, logged or interpolated into an error. Returns
|
|
2657
|
+
* whether `self`'s own token changed and whether it was restarted here.
|
|
2658
|
+
*
|
|
2659
|
+
* A peer recorded but gone from Coolify (404) is no consumer. A healthy
|
|
2660
|
+
* company's step writes only its own env; a re-mint writes every live
|
|
2661
|
+
* consumer, restarts the running ones, restricts the domain, then deletes
|
|
2662
|
+
* the old projects.
|
|
2663
|
+
*/
|
|
2664
|
+
async ensureOrgAppMailToken(args) {
|
|
2665
|
+
const { org, context, mailDomain, self, mailFrom } = args;
|
|
2666
|
+
const appName = `${org.slug}-app`;
|
|
2667
|
+
const recorded = context.recordedIds();
|
|
2668
|
+
const appId = self.role === 'app' ? self.id : recorded[`coolify:app:${appStepKey(org)}`];
|
|
2669
|
+
const bouleId = self.role === 'boule' ? self.id : recorded[`coolify:boule:${org.id}`];
|
|
2670
|
+
const peerListEnvs = (id) => !id || id === self.id
|
|
2671
|
+
? Promise.resolve(null)
|
|
2672
|
+
: // A recorded peer that Coolify no longer has is not a consumer: not
|
|
2673
|
+
// read, written or restarted (same 404 rule as the retire step).
|
|
2674
|
+
this.ports.coolify
|
|
2675
|
+
.listEnvs(id)
|
|
2676
|
+
.catch((error) => {
|
|
2677
|
+
if (!isNotFoundError(error))
|
|
2678
|
+
throw error;
|
|
2679
|
+
return null;
|
|
2680
|
+
});
|
|
2681
|
+
const [projects, selfEnvs, appEnvs, bouleEnvs] = await Promise.all([
|
|
2592
2682
|
this.ports.lettermint.listProjects(),
|
|
2593
|
-
this.ports.coolify.listEnvs(
|
|
2683
|
+
this.ports.coolify.listEnvs(self.id),
|
|
2684
|
+
self.role === 'app' ? Promise.resolve(null) : peerListEnvs(appId),
|
|
2685
|
+
self.role === 'boule' ? Promise.resolve(null) : peerListEnvs(bouleId),
|
|
2594
2686
|
]);
|
|
2595
|
-
const
|
|
2687
|
+
const tokenOf = (envs) => envs.find((e) => e.key === 'LETTERMINT_API_TOKEN' && !e.is_preview)?.value || undefined;
|
|
2688
|
+
// The hosted app first, Boule second: the app's token wins a disagreement.
|
|
2689
|
+
const consumers = [];
|
|
2690
|
+
const appSlot = self.role === 'app' ? selfEnvs : appEnvs;
|
|
2691
|
+
const bouleSlot = self.role === 'boule' ? selfEnvs : bouleEnvs;
|
|
2692
|
+
if (appSlot && appId)
|
|
2693
|
+
consumers.push({ id: appId, token: tokenOf(appSlot) });
|
|
2694
|
+
if (bouleSlot && bouleId)
|
|
2695
|
+
consumers.push({ id: bouleId, token: tokenOf(bouleSlot) });
|
|
2696
|
+
const existingToken = consumers.find((c) => c.token)?.token;
|
|
2596
2697
|
const ours = projects.filter((p) => p.name === appName);
|
|
2597
2698
|
const held = ours.length === 1 ? ours[0] : undefined;
|
|
2598
|
-
const healthy = held !== undefined &&
|
|
2699
|
+
const healthy = held !== undefined && existingToken !== undefined;
|
|
2599
2700
|
let allProjects = projects;
|
|
2600
2701
|
let keepProjectId;
|
|
2601
|
-
|
|
2602
|
-
if (healthy && held) {
|
|
2702
|
+
let token;
|
|
2703
|
+
if (healthy && held && existingToken) {
|
|
2603
2704
|
keepProjectId = held.id;
|
|
2705
|
+
token = existingToken;
|
|
2604
2706
|
}
|
|
2605
2707
|
else {
|
|
2606
2708
|
const created = await this.ports.lettermint.createProject(appName);
|
|
2607
2709
|
keepProjectId = created.project.id;
|
|
2608
2710
|
allProjects = [...projects, created.project];
|
|
2609
|
-
|
|
2711
|
+
token = created.token;
|
|
2712
|
+
}
|
|
2713
|
+
// A healthy company: this step writes only its own env; a peer that lacks
|
|
2714
|
+
// the token gets it in its own step. A re-mint kills the old token, so it
|
|
2715
|
+
// reaches every live consumer here.
|
|
2716
|
+
const targets = consumers.filter((c) => c.token !== token && (!healthy || c.id === self.id));
|
|
2717
|
+
const selfChanged = targets.some((c) => c.id === self.id);
|
|
2718
|
+
for (const consumer of consumers) {
|
|
2719
|
+
const updates = [];
|
|
2720
|
+
if (targets.includes(consumer))
|
|
2721
|
+
updates.push({ key: 'LETTERMINT_API_TOKEN', value: token });
|
|
2722
|
+
if (consumer.id === self.id && mailFrom)
|
|
2723
|
+
updates.push({ key: 'MAIL_FROM', value: mailFrom });
|
|
2724
|
+
if (updates.length > 0)
|
|
2725
|
+
await this.ports.coolify.bulkUpsertEnvs(consumer.id, updates);
|
|
2610
2726
|
}
|
|
2611
|
-
envUpdates.push({
|
|
2612
|
-
key: 'MAIL_FROM',
|
|
2613
|
-
value: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}`,
|
|
2614
|
-
});
|
|
2615
|
-
await this.ports.coolify.bulkUpsertEnvs(appId, envUpdates);
|
|
2616
2727
|
// A running container read its env at start and still holds the old
|
|
2617
|
-
// token, so a fresh one only reaches it through a restart --
|
|
2618
|
-
// old project is deleted below
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2728
|
+
// token, so a fresh one only reaches it through a restart -- after every
|
|
2729
|
+
// write and before the old project is deleted below, so a failure in
|
|
2730
|
+
// between leaves the old project for the next apply to re-mint. A just-created
|
|
2731
|
+
// app is deployed first (#566).
|
|
2732
|
+
let selfRestarted = false;
|
|
2733
|
+
for (const consumer of targets) {
|
|
2734
|
+
const isSelf = consumer.id === self.id;
|
|
2735
|
+
const running = isSelf
|
|
2736
|
+
? self.restart
|
|
2737
|
+
: isRunningOrStarting((await this.ports.coolify.getApplication(consumer.id)).status);
|
|
2738
|
+
if (!running)
|
|
2739
|
+
continue;
|
|
2740
|
+
await this.ports.coolify.restart(consumer.id);
|
|
2741
|
+
if (isSelf)
|
|
2742
|
+
selfRestarted = true;
|
|
2743
|
+
}
|
|
2744
|
+
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, args.platformApex, args.platformLettermintProjectId);
|
|
2622
2745
|
for (const project of allProjects) {
|
|
2623
2746
|
if (project.name === appName &&
|
|
2624
2747
|
project.id !== keepProjectId &&
|
|
2625
|
-
project.id !== platformLettermintProjectId) {
|
|
2748
|
+
project.id !== args.platformLettermintProjectId) {
|
|
2626
2749
|
await this.ports.lettermint.deleteProject(project.id);
|
|
2627
2750
|
}
|
|
2628
2751
|
}
|
|
2752
|
+
return { selfChanged, selfRestarted };
|
|
2629
2753
|
}
|
|
2630
2754
|
/**
|
|
2631
2755
|
* #149: mints this org's own Lettermint relay credential and writes it to
|
|
@@ -3605,7 +3729,7 @@ export class FoundryStepExecutor {
|
|
|
3605
3729
|
if (action.desired.usesDomain === false) {
|
|
3606
3730
|
throw new NotBuiltError(`coolify:chat reverse re-key for ${org.slug}`, 'moving its chat back onto the original server_name is not built (#7 amendment 3)');
|
|
3607
3731
|
}
|
|
3608
|
-
|
|
3732
|
+
return this.coolifyChatRekey(org, context, recorded);
|
|
3609
3733
|
}
|
|
3610
3734
|
const issuer = action.desired.issuer;
|
|
3611
3735
|
if (typeof issuer !== 'string' || issuer === '') {
|
|
@@ -3790,6 +3914,224 @@ export class FoundryStepExecutor {
|
|
|
3790
3914
|
context.recordScopeId(CHAT_SERVICE_URL_SCOPE_KEY, chatUpstreams(org.slug).chat);
|
|
3791
3915
|
return { resourceId: mas.appId };
|
|
3792
3916
|
}
|
|
3917
|
+
/**
|
|
3918
|
+
* `coolifyChat`'s forward re-key (#463, design note
|
|
3919
|
+
* `docs/chat-rekey-design.md` Option 1): a live chat recorded, and
|
|
3920
|
+
* `org.chat.usesDomain` flipped on. Provisions a second Synapse+MAS pair
|
|
3921
|
+
* named for the org's own domain (`chatRekeyedInternalNames`), copies
|
|
3922
|
+
* every room, member and message across through
|
|
3923
|
+
* {@link FoundryStepExecutorPorts.chatRekey}, repoints the existing chat
|
|
3924
|
+
* service at the new pair, and parks -- never deletes, so a stalled
|
|
3925
|
+
* migration can be rolled back by simply restarting them -- the original
|
|
3926
|
+
* Synapse and MAS apps. The chat service and its own database are never
|
|
3927
|
+
* re-created; only the Matrix half of the stack moves.
|
|
3928
|
+
*
|
|
3929
|
+
* Idempotent on recorded ids: new resources are found by name before they
|
|
3930
|
+
* are created, the new MAS's secrets are read back from its own envs, and
|
|
3931
|
+
* the copy, the repoint and the park each run once
|
|
3932
|
+
* ({@link CHAT_REKEY_COPIED_SCOPE_KEY}, {@link CHAT_REKEY_REPOINTED_SCOPE_KEY}).
|
|
3933
|
+
* The original pair keeps serving until the chat service is healthy on the
|
|
3934
|
+
* new one; a failure before that leaves it running.
|
|
3935
|
+
*/
|
|
3936
|
+
async coolifyChatRekey(org, context, recorded) {
|
|
3937
|
+
if (!this.ports.chatRekey) {
|
|
3938
|
+
throw new NotBuiltError(`coolify:chat re-key for ${org.slug}`, 'construct FoundryStepExecutor with `chatRekey` -- the copy mechanism must be proven against a real Synapse and MAS pair first (#463 Human step)');
|
|
3939
|
+
}
|
|
3940
|
+
if (!org.domain) {
|
|
3941
|
+
throw new Error(`coolify:chat re-key for ${org.slug}: the org has no domain to re-key onto`);
|
|
3942
|
+
}
|
|
3943
|
+
try {
|
|
3944
|
+
assertChatServerName(org.domain);
|
|
3945
|
+
}
|
|
3946
|
+
catch {
|
|
3947
|
+
throw new Error(`coolify:chat re-key for ${org.slug}: the org's domain is not a valid server_name`);
|
|
3948
|
+
}
|
|
3949
|
+
const runtime = this.ports.runtime ?? {};
|
|
3950
|
+
const sleep = runtime.sleep ?? defaultSleep;
|
|
3951
|
+
const oldSynapseAppId = recorded[CHAT_SYNAPSE_SCOPE_KEY];
|
|
3952
|
+
const oldServiceAppId = recorded[CHAT_SERVICE_SCOPE_KEY];
|
|
3953
|
+
if (!oldSynapseAppId || !oldServiceAppId) {
|
|
3954
|
+
throw new Error(`coolify:chat re-key for ${org.slug}: its level-1 Synapse or chat service is not recorded yet`);
|
|
3955
|
+
}
|
|
3956
|
+
// See {@link CHAT_REKEY_ORIGINAL_MAS_SCOPE_KEY}'s own doc comment: taken
|
|
3957
|
+
// once, from the generic resourceId this step returns, before that id is
|
|
3958
|
+
// ever repointed at the new MAS.
|
|
3959
|
+
const oldMasAppId = recorded[CHAT_REKEY_ORIGINAL_MAS_SCOPE_KEY] ?? recorded[`coolify:chat:${org.id}`];
|
|
3960
|
+
if (!oldMasAppId) {
|
|
3961
|
+
throw new Error(`coolify:chat re-key for ${org.slug}: its level-1 MAS is not recorded yet`);
|
|
3962
|
+
}
|
|
3963
|
+
context.recordScopeId(CHAT_REKEY_ORIGINAL_MAS_SCOPE_KEY, oldMasAppId);
|
|
3964
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
3965
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
3966
|
+
const server = await this.resolveServer(org, context);
|
|
3967
|
+
const names = chatRekeyedInternalNames(org.slug);
|
|
3968
|
+
const synapseDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.synapseDb, 'synapse', CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
|
|
3969
|
+
context.recordScopeId(CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY, synapseDbId);
|
|
3970
|
+
const masDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.masDb, 'mas', CHAT_REKEY_MAS_DB_SCOPE_KEY);
|
|
3971
|
+
context.recordScopeId(CHAT_REKEY_MAS_DB_SCOPE_KEY, masDbId);
|
|
3972
|
+
const [synapseDbRow, masDbRow] = await Promise.all([
|
|
3973
|
+
getDatabaseStarted(this.ports.coolify, synapseDbId),
|
|
3974
|
+
getDatabaseStarted(this.ports.coolify, masDbId),
|
|
3975
|
+
]);
|
|
3976
|
+
const synapseDb = synapseDbRow.internal_db_url
|
|
3977
|
+
? parseCallsDbUrl(synapseDbRow.internal_db_url)
|
|
3978
|
+
: null;
|
|
3979
|
+
const masDb = masDbRow.internal_db_url ? parseCallsDbUrl(masDbRow.internal_db_url) : null;
|
|
3980
|
+
if (!synapseDb || !masDb) {
|
|
3981
|
+
throw new Error(`coolify:chat re-key for ${org.slug}: its new database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
3982
|
+
}
|
|
3983
|
+
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
3984
|
+
const synapse = await this.findOrCreateCallsApp(names.synapse, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
3985
|
+
project_uuid: projectUuid,
|
|
3986
|
+
server_uuid: server.uuid,
|
|
3987
|
+
environment_name: environment.name,
|
|
3988
|
+
environment_uuid: environment.uuid,
|
|
3989
|
+
github_app_uuid: githubApp.uuid,
|
|
3990
|
+
git_repository: CHAT_REPO,
|
|
3991
|
+
git_branch: 'main',
|
|
3992
|
+
build_pack: 'dockerfile',
|
|
3993
|
+
name: names.synapse,
|
|
3994
|
+
dockerfile_location: CHAT_SYNAPSE_DOCKERFILE,
|
|
3995
|
+
ports_exposes: CHAT_SYNAPSE_PORT,
|
|
3996
|
+
autogenerate_domain: false,
|
|
3997
|
+
}), { startIfStopped: false });
|
|
3998
|
+
const mas = await this.findOrCreateCallsApp(names.mas, environment.id, () => this.ports.coolify.createDockerfileApplication({
|
|
3999
|
+
project_uuid: projectUuid,
|
|
4000
|
+
server_uuid: server.uuid,
|
|
4001
|
+
environment_name: environment.name,
|
|
4002
|
+
environment_uuid: environment.uuid,
|
|
4003
|
+
dockerfile: CALLS_MAS_DOCKERFILE,
|
|
4004
|
+
name: names.mas,
|
|
4005
|
+
description: `matrix-authentication-service for ${org.slug}'s re-keyed chat (#463)`,
|
|
4006
|
+
instant_deploy: false,
|
|
4007
|
+
}), { startIfStopped: false });
|
|
4008
|
+
await this.ports.coolify.updateApplication(synapse.appId, {
|
|
4009
|
+
custom_internal_name: names.synapse,
|
|
4010
|
+
...(synapse.existed ? {} : { health_check_enabled: false }),
|
|
4011
|
+
});
|
|
4012
|
+
await this.ports.coolify.updateApplication(mas.appId, {
|
|
4013
|
+
custom_internal_name: names.mas,
|
|
4014
|
+
...(mas.existed ? {} : { health_check_enabled: false }),
|
|
4015
|
+
});
|
|
4016
|
+
context.recordScopeId(CHAT_REKEY_SYNAPSE_SCOPE_KEY, synapse.appId);
|
|
4017
|
+
context.recordScopeId(CHAT_REKEY_MAS_SCOPE_KEY, mas.appId);
|
|
4018
|
+
// The new MAS holds the minted secrets, so its id is on record before they exist.
|
|
4019
|
+
await context.persistScopeIds();
|
|
4020
|
+
const synapseStorages = await this.ports.coolify.listStorages(synapse.appId);
|
|
4021
|
+
if (!(synapseStorages.persistent_storages ?? []).some((s) => s.mount_path === '/data')) {
|
|
4022
|
+
await this.ports.coolify.createPersistentStorage(synapse.appId, {
|
|
4023
|
+
name: `${names.synapse}-data`,
|
|
4024
|
+
mount_path: '/data',
|
|
4025
|
+
});
|
|
4026
|
+
}
|
|
4027
|
+
const secrets = await this.ensureCallsMatrixSecrets(mas.appId, CHAT_SECRET_ENV_KEYS);
|
|
4028
|
+
const agoraHost = agoraHostnameFor(org);
|
|
4029
|
+
await this.ensureCallsFileStorage(synapse.appId, '/data/homeserver.yaml', chatSynapseHomeserverYaml({
|
|
4030
|
+
orgSlug: org.slug,
|
|
4031
|
+
db: synapseDb,
|
|
4032
|
+
masHost: names.mas,
|
|
4033
|
+
masSharedSecret: secrets.value.masSynapseSharedSecret,
|
|
4034
|
+
serverName: org.domain,
|
|
4035
|
+
}));
|
|
4036
|
+
await this.ports.coolify.bulkUpsertEnvs(synapse.appId, [
|
|
4037
|
+
{ key: 'SYNAPSE_CONFIG_PATH', value: '/data/homeserver.yaml' },
|
|
4038
|
+
{ key: 'UID', value: '0' },
|
|
4039
|
+
{ key: 'GID', value: '0' },
|
|
4040
|
+
]);
|
|
4041
|
+
await this.ensureCallsFileStorage(mas.appId, '/config/config.yaml', chatMasConfigYaml({
|
|
4042
|
+
orgSlug: org.slug,
|
|
4043
|
+
publicHost: agoraHost,
|
|
4044
|
+
db: masDb,
|
|
4045
|
+
synapseHost: names.synapse,
|
|
4046
|
+
sharedSecret: secrets.value.masSynapseSharedSecret,
|
|
4047
|
+
clientId: secrets.value.masClientId,
|
|
4048
|
+
clientSecret: secrets.value.masClientSecret,
|
|
4049
|
+
encryptionKey: secrets.value.masEncryptionKey,
|
|
4050
|
+
rsaKeyPem: secrets.value.masRsaKeyPem,
|
|
4051
|
+
ecKeyPem: secrets.value.masEcKeyPem,
|
|
4052
|
+
serverName: org.domain,
|
|
4053
|
+
}));
|
|
4054
|
+
// MAS first: Synapse asks it for token introspection. Both are healthy
|
|
4055
|
+
// before the copy reads or writes either of them.
|
|
4056
|
+
for (const app of [mas, synapse]) {
|
|
4057
|
+
if (!app.existed ||
|
|
4058
|
+
secrets.changed ||
|
|
4059
|
+
!isRunningOrStarting((await this.ports.coolify.getApplication(app.appId)).status)) {
|
|
4060
|
+
await waitForNewDeployment(this.ports.coolify, app.appId, { kind: 'deployment-only' }, runtime, sleep, 'coolify:chat', `${org.slug}'s original chat pair stays live; the new ${app === mas ? 'MAS' : 'Synapse'} is not up`);
|
|
4061
|
+
}
|
|
4062
|
+
}
|
|
4063
|
+
if (!recorded[CHAT_REKEY_COPIED_SCOPE_KEY]) {
|
|
4064
|
+
const oldNames = chatInternalNames(org.slug);
|
|
4065
|
+
const abort = new AbortController();
|
|
4066
|
+
let timer;
|
|
4067
|
+
try {
|
|
4068
|
+
await Promise.race([
|
|
4069
|
+
this.ports.chatRekey.copyHomeserver({
|
|
4070
|
+
orgSlug: org.slug,
|
|
4071
|
+
fromServerName: chatServerName(org.slug),
|
|
4072
|
+
fromInternalHost: `http://${oldNames.synapse}:${CHAT_SYNAPSE_PORT}`,
|
|
4073
|
+
toServerName: org.domain,
|
|
4074
|
+
toInternalHost: `http://${names.synapse}:${CHAT_SYNAPSE_PORT}`,
|
|
4075
|
+
signal: abort.signal,
|
|
4076
|
+
}),
|
|
4077
|
+
new Promise((_, reject) => {
|
|
4078
|
+
timer = setTimeout(() => {
|
|
4079
|
+
abort.abort();
|
|
4080
|
+
reject(new Error('timed out'));
|
|
4081
|
+
}, CHAT_REKEY_COPY_TIMEOUT_MS);
|
|
4082
|
+
}),
|
|
4083
|
+
]);
|
|
4084
|
+
}
|
|
4085
|
+
catch {
|
|
4086
|
+
throw new Error(`coolify:chat re-key for ${org.slug}: the copy did not finish; the original chat pair stays live. Apply again`);
|
|
4087
|
+
}
|
|
4088
|
+
finally {
|
|
4089
|
+
clearTimeout(timer);
|
|
4090
|
+
}
|
|
4091
|
+
context.recordScopeId(CHAT_REKEY_COPIED_SCOPE_KEY, '1');
|
|
4092
|
+
await context.persistScopeIds();
|
|
4093
|
+
}
|
|
4094
|
+
// The marker is set only after the service is healthy on the new pair; an
|
|
4095
|
+
// env write whose deploy failed must not let the original be parked.
|
|
4096
|
+
if (!recorded[CHAT_REKEY_REPOINTED_SCOPE_KEY]) {
|
|
4097
|
+
const envs = [
|
|
4098
|
+
{ key: 'CHAT_SERVER_NAME', value: org.domain },
|
|
4099
|
+
{
|
|
4100
|
+
key: 'CHAT_INTERNAL_HOMESERVER_URL',
|
|
4101
|
+
value: `http://${names.synapse}:${CHAT_SYNAPSE_PORT}`,
|
|
4102
|
+
},
|
|
4103
|
+
{ key: 'MAS_URL', value: `http://${names.mas}:${CHAT_MAS_PORT}` },
|
|
4104
|
+
{ key: 'MAS_CLIENT_ID', value: secrets.value.masClientId },
|
|
4105
|
+
{ key: 'MAS_CLIENT_SECRET', value: secrets.value.masClientSecret },
|
|
4106
|
+
];
|
|
4107
|
+
let written = false;
|
|
4108
|
+
for (let attempt = 1; attempt <= JOBS_DELIVERY_ATTEMPTS && !written; attempt++) {
|
|
4109
|
+
try {
|
|
4110
|
+
await this.ports.coolify.bulkUpsertEnvs(oldServiceAppId, envs);
|
|
4111
|
+
written = true;
|
|
4112
|
+
}
|
|
4113
|
+
catch {
|
|
4114
|
+
// Retried; the error is dropped in case it echoes the client secret.
|
|
4115
|
+
}
|
|
4116
|
+
}
|
|
4117
|
+
if (!written) {
|
|
4118
|
+
throw new Error(`coolify:chat re-key for ${org.slug}: the new MAS client could not be written to the chat service (${oldServiceAppId}) after ${JOBS_DELIVERY_ATTEMPTS} attempts; the original chat pair stays live`);
|
|
4119
|
+
}
|
|
4120
|
+
await waitForNewDeployment(this.ports.coolify, oldServiceAppId, publicHealthCheck(org, context.recordedIds(), agoraHostnameFor(org), CHAT_SERVICE_HEALTH_CHECK_PATH, { viaAgora: true }), runtime, sleep, 'coolify:chat', `${org.slug}'s original chat pair stays live`);
|
|
4121
|
+
context.recordScopeId(CHAT_REKEY_REPOINTED_SCOPE_KEY, '1');
|
|
4122
|
+
await context.persistScopeIds();
|
|
4123
|
+
}
|
|
4124
|
+
// Park, never delete (#7 corrections 1's park-not-delete rule), the
|
|
4125
|
+
// original Synapse and MAS: cheap to roll back onto before members are
|
|
4126
|
+
// told to use the new id, the design note's own rollback story. Skipped
|
|
4127
|
+
// once already stopped, so a third run touches nothing here either.
|
|
4128
|
+
for (const appId of [oldSynapseAppId, oldMasAppId]) {
|
|
4129
|
+
if (isRunningOrStarting((await this.ports.coolify.getApplication(appId)).status)) {
|
|
4130
|
+
await this.ports.coolify.raw('POST', `/applications/${appId}/stop`);
|
|
4131
|
+
}
|
|
4132
|
+
}
|
|
4133
|
+
return { resourceId: mas.appId };
|
|
4134
|
+
}
|
|
3793
4135
|
// ---- calls service (#145, part 2 of #128) -------------------------------
|
|
3794
4136
|
/**
|
|
3795
4137
|
* `plan/diff.ts`'s `planCallsService`: the org's own calls service --
|
|
@@ -3899,9 +4241,9 @@ export class FoundryStepExecutor {
|
|
|
3899
4241
|
envs.push({ key: 'CALLS_BINDING_SECRET', value: bindingSecret });
|
|
3900
4242
|
// The seven ZITADEL token vars, all or none (the service refuses to boot
|
|
3901
4243
|
// on a partial set). Deliberately absent: `CALLS_CHAT_URL` and
|
|
3902
|
-
// `CALLS_CHAT_TOKEN`.
|
|
3903
|
-
//
|
|
3904
|
-
//
|
|
4244
|
+
// `CALLS_CHAT_TOKEN`. `coolifyCallsChat` sets and removes the pair, after
|
|
4245
|
+
// this step and chat, so a chat-less org keeps event-only calls and this
|
|
4246
|
+
// write never leaves one of the two behind.
|
|
3905
4247
|
const tokenHost = action.desired.host;
|
|
3906
4248
|
const tokenMarker = callsTokenEnvMarker(tokenHost ?? null, context.recordedIds());
|
|
3907
4249
|
const token = tokenHost && tokenMarker
|
|
@@ -3960,6 +4302,88 @@ export class FoundryStepExecutor {
|
|
|
3960
4302
|
context.recordScopeId(dbMarkerKey, '1');
|
|
3961
4303
|
return { resourceId: app.appId };
|
|
3962
4304
|
}
|
|
4305
|
+
/**
|
|
4306
|
+
* `plan/diff.ts`'s `planCallsChat`: the one internal token that lets the
|
|
4307
|
+
* calls service ask chat who is in a room, and the chat address. The token
|
|
4308
|
+
* is `CHAT_INTERNAL_TOKEN` on chat-service and `CALLS_CHAT_TOKEN` on the
|
|
4309
|
+
* calls service, which gets `CALLS_CHAT_URL` in the same write: it refuses
|
|
4310
|
+
* to boot on one without the other. Chat-service's env is the only memory
|
|
4311
|
+
* of the token across a run, the way {@link ensureCallsMatrixSecrets} keeps
|
|
4312
|
+
* the MAS secrets on the MAS app, so it is generated only when that env has
|
|
4313
|
+
* none. Its id is recorded and persisted before either app holds it, and the
|
|
4314
|
+
* guard after both do (platform rule 1). Both apps are redeployed last; the
|
|
4315
|
+
* applied marker is recorded after that, so a run that fails in between is
|
|
4316
|
+
* planned and finished again. `remove` (chat is off) deletes both vars from
|
|
4317
|
+
* the calls service and restarts it.
|
|
4318
|
+
*/
|
|
4319
|
+
async coolifyCallsChat(action, org, context) {
|
|
4320
|
+
const at = `coolify:calls-chat for ${org.slug}`;
|
|
4321
|
+
const recorded = context.recordedIds();
|
|
4322
|
+
const callsAppId = recorded[`coolify:calls-service:${action.key}`];
|
|
4323
|
+
const chatAppId = recorded[CHAT_SERVICE_SCOPE_KEY];
|
|
4324
|
+
const envOf = async (appId, key) => (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === key && !e.is_preview);
|
|
4325
|
+
if (!callsAppId)
|
|
4326
|
+
throw new Error(`${at}: the calls service has not been provisioned yet`);
|
|
4327
|
+
if (action.desired.remove === true) {
|
|
4328
|
+
for (const key of ['CALLS_CHAT_URL', 'CALLS_CHAT_TOKEN']) {
|
|
4329
|
+
const env = await envOf(callsAppId, key);
|
|
4330
|
+
if (env?.uuid)
|
|
4331
|
+
await this.ports.coolify.deleteEnv(callsAppId, env.uuid);
|
|
4332
|
+
}
|
|
4333
|
+
if (isRunningOrStarting((await this.ports.coolify.getApplication(callsAppId)).status)) {
|
|
4334
|
+
await this.ports.coolify.restart(callsAppId);
|
|
4335
|
+
}
|
|
4336
|
+
context.recordScopeId(CHAT_CALLS_APPLIED_SCOPE_KEY, '');
|
|
4337
|
+
return {};
|
|
4338
|
+
}
|
|
4339
|
+
const chatUrl = chatUpstreams(org.slug).chat;
|
|
4340
|
+
if (!chatAppId || !chatHasUpstreams(org, recorded)) {
|
|
4341
|
+
throw new Error(`${at}: chat has not been provisioned yet`);
|
|
4342
|
+
}
|
|
4343
|
+
let tokenId = recorded[CHAT_CALLS_TOKEN_SCOPE_KEY];
|
|
4344
|
+
const undelivered = recorded[CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY];
|
|
4345
|
+
if (undelivered && !tokenId) {
|
|
4346
|
+
throw new Error(`${at}: token ${undelivered} was minted but never written to both apps; remove the recorded key ${CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY} to write it again`);
|
|
4347
|
+
}
|
|
4348
|
+
// Chat refuses a token under 32 characters; the calls service needs the same bytes.
|
|
4349
|
+
const held = (await envOf(chatAppId, 'CHAT_INTERNAL_TOKEN'))?.value;
|
|
4350
|
+
const usable = held !== undefined && /^\S{32,}$/.test(held) ? held : undefined;
|
|
4351
|
+
if (tokenId && !usable) {
|
|
4352
|
+
throw new Error(`${at}: token ${tokenId} is recorded but chat-service no longer holds a usable one; remove the recorded keys ${CHAT_CALLS_TOKEN_SCOPE_KEY} and ${CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
4353
|
+
}
|
|
4354
|
+
const token = usable ?? randomBytes(32).toString('hex');
|
|
4355
|
+
if (!tokenId) {
|
|
4356
|
+
tokenId = randomBytes(8).toString('hex');
|
|
4357
|
+
context.recordScopeId(CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, tokenId);
|
|
4358
|
+
await context.persistScopeIds();
|
|
4359
|
+
}
|
|
4360
|
+
const id = tokenId;
|
|
4361
|
+
const write = async (appId, app, envs) => {
|
|
4362
|
+
for (let attempt = 1; attempt <= JOBS_DELIVERY_ATTEMPTS; attempt++) {
|
|
4363
|
+
try {
|
|
4364
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
4365
|
+
return;
|
|
4366
|
+
}
|
|
4367
|
+
catch {
|
|
4368
|
+
// Retried; the error is dropped in case it echoes the token.
|
|
4369
|
+
}
|
|
4370
|
+
}
|
|
4371
|
+
throw new Error(`${at}: token ${id} could not be written to ${app} after ${JOBS_DELIVERY_ATTEMPTS} attempts`);
|
|
4372
|
+
};
|
|
4373
|
+
await write(chatAppId, 'chat-service', [{ key: 'CHAT_INTERNAL_TOKEN', value: token }]);
|
|
4374
|
+
await write(callsAppId, 'the calls service', [
|
|
4375
|
+
{ key: 'CALLS_CHAT_URL', value: chatUrl },
|
|
4376
|
+
{ key: 'CALLS_CHAT_TOKEN', value: token },
|
|
4377
|
+
]);
|
|
4378
|
+
context.recordScopeId(CHAT_CALLS_TOKEN_SCOPE_KEY, id);
|
|
4379
|
+
// Chat first: the calls service's first question must find the route.
|
|
4380
|
+
const runtime = this.ports.runtime ?? {};
|
|
4381
|
+
const sleep = runtime.sleep ?? defaultSleep;
|
|
4382
|
+
await waitForNewDeployment(this.ports.coolify, chatAppId, publicHealthCheck(org, context.recordedIds(), agoraHostnameFor(org), CHAT_SERVICE_HEALTH_CHECK_PATH, { viaAgora: true }), runtime, sleep, 'coolify:calls-chat', `${org.slug}'s chat service stays at its previous deploy`);
|
|
4383
|
+
await waitForNewDeployment(this.ports.coolify, callsAppId, publicHealthCheck(org, context.recordedIds(), callsHostnameFor(org), CALLS_HEALTH_CHECK_PATH), runtime, sleep, 'coolify:calls-chat', `${org.slug}'s calls service stays at its previous deploy`);
|
|
4384
|
+
context.recordScopeId(CHAT_CALLS_APPLIED_SCOPE_KEY, chatCallsApplied(id, chatUrl));
|
|
4385
|
+
return {};
|
|
4386
|
+
}
|
|
3963
4387
|
/**
|
|
3964
4388
|
* The seven ZITADEL token env vars the calls service needs to accept
|
|
3965
4389
|
* Agora's sign-in token (`calls/docs/member-auth.md`). Only called with all
|
|
@@ -4237,7 +4661,14 @@ export class FoundryStepExecutor {
|
|
|
4237
4661
|
// written is the env the plan showed.
|
|
4238
4662
|
const issuer = action.desired.issuer ?? null;
|
|
4239
4663
|
const archonUrl = action.desired.archonUrl ?? null;
|
|
4240
|
-
|
|
4664
|
+
// The plan decides blocked mail (`mailBlocked`); a plan with no
|
|
4665
|
+
// `platformApex` key predates this field and is refused before any mail
|
|
4666
|
+
// env is written.
|
|
4667
|
+
const mailWanted = bouleMailWanted(org, context.recordedIds()) && action.desired.mailBlocked !== true;
|
|
4668
|
+
if (mailWanted && !('platformApex' in action.desired)) {
|
|
4669
|
+
throw new Error(`coolify:boule for ${org.slug}: this step's plan carries no platformApex; plan this org's Boule again`);
|
|
4670
|
+
}
|
|
4671
|
+
const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl, mailWanted);
|
|
4241
4672
|
if (missing.length > 0) {
|
|
4242
4673
|
throw new Error(`Boule for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
|
|
4243
4674
|
}
|
|
@@ -4288,6 +4719,12 @@ export class FoundryStepExecutor {
|
|
|
4288
4719
|
if (!dbPush.pushed) {
|
|
4289
4720
|
throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
4290
4721
|
}
|
|
4722
|
+
// The org's Lettermint token, the same one its hosted app holds. A no-op
|
|
4723
|
+
// for an org with mail off or no mail domain recorded: Boule then shows
|
|
4724
|
+
// the invite link.
|
|
4725
|
+
const mail = mailWanted
|
|
4726
|
+
? await this.ensureBouleMail(app.appId, org, context, action, app.existed && Boolean(context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]))
|
|
4727
|
+
: { changed: false, restarted: false };
|
|
4291
4728
|
// #504: see `coolifyAgora`'s own comment just above -- `createGithubAppApplication`
|
|
4292
4729
|
// never sets `instant_deploy`, so a brand new app (or one found by name
|
|
4293
4730
|
// that a previous apply created but crashed before deploying) sat built
|
|
@@ -4300,11 +4737,53 @@ export class FoundryStepExecutor {
|
|
|
4300
4737
|
context.recordScopeId(BOULE_DEPLOYED_SCOPE_KEY, '1');
|
|
4301
4738
|
}
|
|
4302
4739
|
else if (app.existed &&
|
|
4303
|
-
|
|
4740
|
+
!mail.restarted &&
|
|
4741
|
+
(envChanged || cookieSecret !== null || appKey !== null || dbPush.changed || mail.changed)) {
|
|
4742
|
+
// A restart inside the mail rule already came after every env write above.
|
|
4304
4743
|
await this.ports.coolify.restart(app.appId);
|
|
4305
4744
|
}
|
|
4306
4745
|
return { resourceId: app.appId };
|
|
4307
4746
|
}
|
|
4747
|
+
/**
|
|
4748
|
+
* Gives Boule `LETTERMINT_API_TOKEN`: the platform's held key for an org on
|
|
4749
|
+
* the platform's own apex, else the org's `<slug>-app` token through the
|
|
4750
|
+
* shared mint rule ({@link ensureOrgAppMailToken}), which mints that project
|
|
4751
|
+
* when no hosted app made it yet. `MAIL_FROM` is written with the rest of
|
|
4752
|
+
* Boule's env. Returns whether Boule's token changed, so the caller
|
|
4753
|
+
* restarts a running Boule, unless `restarted`: the shared rule then already
|
|
4754
|
+
* restarted it (`running`), before it deleted the old project.
|
|
4755
|
+
*/
|
|
4756
|
+
async ensureBouleMail(appId, org, context, action, running) {
|
|
4757
|
+
const none = { changed: false, restarted: false };
|
|
4758
|
+
const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
4759
|
+
if (!mailDomain)
|
|
4760
|
+
return none;
|
|
4761
|
+
const platformApex = action.desired.platformApex ?? null;
|
|
4762
|
+
const platformLettermintProjectId = action.desired.platformLettermintProjectId ?? null;
|
|
4763
|
+
if (platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase()) {
|
|
4764
|
+
const heldToken = (await this.ports.heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF)) ?? null;
|
|
4765
|
+
if (!heldToken) {
|
|
4766
|
+
throw new Error(`coolify:boule for ${org.slug}: the platform's own Lettermint token is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`);
|
|
4767
|
+
}
|
|
4768
|
+
const current = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'LETTERMINT_API_TOKEN' && !e.is_preview)?.value;
|
|
4769
|
+
if (current === heldToken)
|
|
4770
|
+
return none;
|
|
4771
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, [
|
|
4772
|
+
{ key: 'LETTERMINT_API_TOKEN', value: heldToken },
|
|
4773
|
+
]);
|
|
4774
|
+
return { changed: true, restarted: false };
|
|
4775
|
+
}
|
|
4776
|
+
const { selfChanged, selfRestarted } = await this.ensureOrgAppMailToken({
|
|
4777
|
+
org,
|
|
4778
|
+
context,
|
|
4779
|
+
mailDomain,
|
|
4780
|
+
self: { role: 'boule', id: appId, restart: running },
|
|
4781
|
+
mailFrom: null,
|
|
4782
|
+
platformApex,
|
|
4783
|
+
platformLettermintProjectId,
|
|
4784
|
+
});
|
|
4785
|
+
return { changed: selfChanged, restarted: selfRestarted };
|
|
4786
|
+
}
|
|
4308
4787
|
// ---- jobs (#173) ---------------------------------------------------------
|
|
4309
4788
|
/**
|
|
4310
4789
|
* `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with
|