@wtfalch/foundry 0.33.1 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/versions.d.ts +4 -0
- package/dist/calls-sfu.d.ts +10 -0
- package/dist/calls-sfu.js +41 -0
- package/dist/index.d.ts +14 -6
- package/dist/index.js +7 -3
- package/dist/migrations/0051_org_services.sql +13 -0
- package/dist/migrations/0052_platform_mail_relay.sql +8 -0
- package/dist/org.d.ts +18 -0
- package/dist/org.js +26 -0
- package/dist/plan/calls-matrix.d.ts +9 -0
- package/dist/plan/calls-matrix.js +17 -0
- package/dist/plan/cloudflared-connector.d.ts +7 -1
- package/dist/plan/cloudflared-connector.js +14 -1
- package/dist/plan/diff.d.ts +29 -0
- package/dist/plan/diff.js +259 -18
- package/dist/plan/executor.d.ts +112 -0
- package/dist/plan/executor.js +594 -33
- package/dist/plan/health-check-settings.d.ts +28 -0
- package/dist/plan/health-check-settings.js +36 -0
- package/dist/plan/health-gaps.d.ts +33 -0
- package/dist/plan/health-gaps.js +89 -0
- package/dist/plan/held-key-refs.d.ts +10 -0
- package/dist/plan/held-key-refs.js +10 -0
- package/dist/plan/index.d.ts +14 -6
- package/dist/plan/index.js +7 -3
- package/dist/plan/live.d.ts +5 -2
- package/dist/plan/live.js +15 -2
- package/dist/plan/locked-hostnames.d.ts +5 -2
- package/dist/plan/locked-hostnames.js +9 -1
- package/dist/plan/login-host.d.ts +62 -2
- package/dist/plan/login-host.js +104 -1
- package/dist/plan/mail-relay.d.ts +120 -0
- package/dist/plan/mail-relay.js +323 -0
- package/dist/plan/org-names.d.ts +24 -0
- package/dist/plan/org-names.js +52 -0
- package/dist/plan/own-diff.d.ts +169 -0
- package/dist/plan/own-diff.js +432 -0
- package/dist/plan/own-dns.d.ts +6 -0
- package/dist/plan/own-dns.js +15 -0
- package/dist/plan/own-limits.d.ts +22 -0
- package/dist/plan/own-limits.js +101 -0
- package/dist/plan/own-services.d.ts +193 -0
- package/dist/plan/own-services.js +167 -0
- package/dist/plan/own-sfu.d.ts +69 -0
- package/dist/plan/own-sfu.js +255 -0
- package/dist/plan/plan-apply.d.ts +78 -7
- package/dist/plan/plan-apply.js +205 -12
- package/dist/plan/platform-diff.d.ts +28 -0
- package/dist/plan/platform-diff.js +45 -3
- package/dist/plan/platform-org.d.ts +34 -0
- package/dist/plan/platform-org.js +94 -2
- package/dist/plan/platform-providers.d.ts +356 -8
- package/dist/plan/platform-providers.js +661 -353
- package/dist/plan/sign-in-org.d.ts +1 -1
- package/dist/plan/sign-in-org.js +20 -2
- package/dist/plan/zitadel-login-policy.js +1 -1
- package/dist/plan/zitadel-system-api.d.ts +22 -1
- package/dist/plan/zitadel-system-api.js +49 -8
- package/dist/platform.d.ts +8 -0
- package/dist/slug.js +1 -0
- package/dist/store/postgres.js +51 -2
- package/dist/store/schema.d.ts +51 -0
- package/dist/store/schema.js +5 -0
- package/package.json +1 -1
- package/versions.json +3 -1
package/dist/plan/executor.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
2
|
import { URL } from 'node:url';
|
|
3
3
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
4
4
|
import { resolveCallsSfuUrls } from '../calls-sfu.js';
|
|
5
|
-
import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
|
|
5
|
+
import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
|
|
6
6
|
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';
|
|
7
7
|
import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
8
8
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
9
|
-
import {
|
|
9
|
+
import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
10
10
|
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';
|
|
11
11
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
12
12
|
import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
|
|
@@ -21,12 +21,18 @@ import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScope
|
|
|
21
21
|
import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, edgeKeyAppRecordKey, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
|
|
22
22
|
import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
|
|
23
23
|
import { GATE_CLAIM_NAME, gateHostnamesScopeKey, gateIdRecordKey, isBehindGate } from './gates.js';
|
|
24
|
-
import { healthCheckSettings } from './health-check-settings.js';
|
|
24
|
+
import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, wgetProbe, } from './health-check-settings.js';
|
|
25
25
|
import { appHostname, coolifyTunnelDomains, entryHostnames, tunnelDnsHostnames, tunnelDomainList, tunnelIngressHostnames, } from './hostnames.js';
|
|
26
|
-
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, customersEntryKeys, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
26
|
+
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, customersEntryKeys, loginBindingState, loginHostClientIds, ownAuthApplied, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
27
27
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
28
|
+
import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY } from './mail-relay.js';
|
|
29
|
+
import { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, } from './own-diff.js';
|
|
30
|
+
import { limited } from './own-limits.js';
|
|
31
|
+
import { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, OwnServicesBlockedError, orgNetwork, orgTarget, scopeFromOrg, } from './own-services.js';
|
|
32
|
+
import { ownSfuUrls, provisionSfuApp } from './own-sfu.js';
|
|
33
|
+
import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
|
|
28
34
|
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
|
|
29
|
-
import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, } from './platform-providers.js';
|
|
35
|
+
import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
|
|
30
36
|
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
|
|
31
37
|
import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
|
|
32
38
|
import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
|
|
@@ -633,11 +639,6 @@ const CHAT_SECRET_ENV_KEYS = {
|
|
|
633
639
|
masRsaKeyPem: 'CHAT_MATRIX_MAS_RSA_KEY_PEM',
|
|
634
640
|
masEcKeyPem: 'CHAT_MATRIX_MAS_EC_KEY_PEM',
|
|
635
641
|
};
|
|
636
|
-
/** `FROM ${MAS_IMAGE}` plus the entry command its published image needs (chat's own `MAS_DOCKERFILE`, same reason: no arguments by default). */
|
|
637
|
-
const CALLS_MAS_DOCKERFILE = [
|
|
638
|
-
`FROM ${MAS_IMAGE}`,
|
|
639
|
-
'CMD ["server", "--config", "/config/config.yaml"]',
|
|
640
|
-
].join('\n');
|
|
641
642
|
/**
|
|
642
643
|
* `coolifyCallsService`'s own database (#145) -- no `system:kind:key` slot
|
|
643
644
|
* of its own, the same reason {@link CALLS_SYNAPSE_SCOPE_KEY}'s doc comment
|
|
@@ -665,6 +666,12 @@ const CALLS_HEALTH_CHECK_PATH = '/healthz';
|
|
|
665
666
|
* {@link CALLS_HEALTH_CHECK_PATH} above.
|
|
666
667
|
*/
|
|
667
668
|
const CHAT_SERVICE_HEALTH_CHECK_PATH = '/_chat/health';
|
|
669
|
+
/**
|
|
670
|
+
* Synapse's start period for its Coolify health check. It runs its database
|
|
671
|
+
* migrations before it listens, which is slower than the default start period
|
|
672
|
+
* of {@link healthCheckSettings} allows, and the image's own check waits 5 s.
|
|
673
|
+
*/
|
|
674
|
+
const SYNAPSE_HEALTH_CHECK_START_PERIOD_SECONDS = 120;
|
|
668
675
|
/**
|
|
669
676
|
* Whether an application's own Coolify `status` (`` `running:healthy`,
|
|
670
677
|
* `exited:unhealthy` `` -- `@wtfalch/providers`' own doc comment) means it is
|
|
@@ -755,6 +762,44 @@ export const databaseStartWait = {
|
|
|
755
762
|
// ---------------------------------------------------------------------------
|
|
756
763
|
// Executor
|
|
757
764
|
// ---------------------------------------------------------------------------
|
|
765
|
+
/** How long the host's bucket port may take to make or find a bucket. */
|
|
766
|
+
const BUCKET_TIMEOUT_MS = 60_000;
|
|
767
|
+
/** How long the keys worker may take to mint an own files host's credential. */
|
|
768
|
+
const KEYS_MINT_TIMEOUT_MS = 15_000;
|
|
769
|
+
/** How long one call to the R2 key port or the files route that stores the key may take. */
|
|
770
|
+
const R2_KEY_TIMEOUT_MS = 30_000;
|
|
771
|
+
/** Clears a recorded id; a context that cannot forget one records it empty, which reads as unset. */
|
|
772
|
+
function forgetScopeId(context, key) {
|
|
773
|
+
if (context.forgetId)
|
|
774
|
+
context.forgetId(key);
|
|
775
|
+
else
|
|
776
|
+
context.recordScopeId(key, '');
|
|
777
|
+
}
|
|
778
|
+
async function withTimeout(work, ms) {
|
|
779
|
+
let timer;
|
|
780
|
+
try {
|
|
781
|
+
return await Promise.race([
|
|
782
|
+
work,
|
|
783
|
+
new Promise((_, reject) => {
|
|
784
|
+
timer = setTimeout(() => reject(new Error('timeout')), ms);
|
|
785
|
+
}),
|
|
786
|
+
]);
|
|
787
|
+
}
|
|
788
|
+
finally {
|
|
789
|
+
clearTimeout(timer);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
/** How long one Lettermint call of an own company's sign-in mint may take. */
|
|
793
|
+
const LETTERMINT_TIMEOUT_MS = 60_000;
|
|
794
|
+
const OWN_SERVICES_PORT_ABSENT = 'this org runs its own services and the host has no ownServices port for it';
|
|
795
|
+
/** A port that refuses every call, naming the held key the org's service waits for. */
|
|
796
|
+
function blockedPort(service, ref) {
|
|
797
|
+
return new Proxy({}, {
|
|
798
|
+
get: () => () => {
|
|
799
|
+
throw new OwnServicesBlockedError(service, ref);
|
|
800
|
+
},
|
|
801
|
+
});
|
|
802
|
+
}
|
|
758
803
|
export class FoundryStepExecutor {
|
|
759
804
|
ports;
|
|
760
805
|
edgeDns;
|
|
@@ -771,6 +816,85 @@ export class FoundryStepExecutor {
|
|
|
771
816
|
secretsHandled() {
|
|
772
817
|
return [...this.handledSecrets];
|
|
773
818
|
}
|
|
819
|
+
/**
|
|
820
|
+
* An own org's step runs on a copy of this executor whose ZITADEL, mail,
|
|
821
|
+
* broker, files, jobs and calls ports are the org's own. A port the org has
|
|
822
|
+
* not got yet is absent or throws, never the shared one.
|
|
823
|
+
*/
|
|
824
|
+
async executeScoped(action, context) {
|
|
825
|
+
const own = this.ports.ownServices;
|
|
826
|
+
if (!own)
|
|
827
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
828
|
+
const scoped = await own.portsFor(context.org, context.recordedIds());
|
|
829
|
+
if (!scoped)
|
|
830
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
831
|
+
const refs = orgTarget(context.org).refs;
|
|
832
|
+
const copy = new FoundryStepExecutor({
|
|
833
|
+
...this.ports,
|
|
834
|
+
ownServices: undefined,
|
|
835
|
+
zitadel: scoped.zitadel ?? blockedPort('zitadel', refs.instanceBearer),
|
|
836
|
+
mail: scoped.mail ?? blockedPort('mail', refs.mailAdminKey),
|
|
837
|
+
callsSfu: scoped.callsSfu ?? blockedPort('calls', refs.livekitKeys),
|
|
838
|
+
broker: scoped.broker,
|
|
839
|
+
filesOrganisation: scoped.filesOrganisation ??
|
|
840
|
+
blockedPort('files', refs.filesManagement),
|
|
841
|
+
jobsScheduler: scoped.jobsScheduler ?? blockedPort('jobs', refs.jobsAdminToken),
|
|
842
|
+
callsSfuUrls: ownSfuUrls(context.recordedIds()),
|
|
843
|
+
});
|
|
844
|
+
try {
|
|
845
|
+
return await copy.run(action, context);
|
|
846
|
+
}
|
|
847
|
+
finally {
|
|
848
|
+
for (const secret of copy.secretsHandled())
|
|
849
|
+
this.handledSecrets.add(secret);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
/** Whether `ensureOwnAuthMailToken` can mint: the same two inputs it blocks on. */
|
|
853
|
+
ownMailKeyMintable() {
|
|
854
|
+
return Boolean(this.ports.lettermint && this.ports.ownServices?.provision.auth.heldKeys);
|
|
855
|
+
}
|
|
856
|
+
/** What the executor's own files dependencies supply, for the planner's blocked lines. */
|
|
857
|
+
ownFilesDeps() {
|
|
858
|
+
const files = this.ports.ownServices?.provision.files;
|
|
859
|
+
if (!files)
|
|
860
|
+
return { keys: false, bucket: false };
|
|
861
|
+
return {
|
|
862
|
+
keys: Boolean(files.keysMint),
|
|
863
|
+
bucket: Boolean(files.r2Buckets),
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
/** Which ports the own files storage key step needs this executor has, for the planner's blocked line. */
|
|
867
|
+
async ownFilesR2Ports(org, recordedIds) {
|
|
868
|
+
const r2Keys = Boolean(this.ports.ownServices?.provision.files.r2Keys);
|
|
869
|
+
// Only an own org whose files host is applied is asked for its scoped ports.
|
|
870
|
+
if (!ownsServices(org) || !recordedIds[`coolify:own-files:${org.id}`]) {
|
|
871
|
+
return { r2Keys, storeR2Credentials: false };
|
|
872
|
+
}
|
|
873
|
+
try {
|
|
874
|
+
const scoped = await this.ports.ownServices?.portsFor(org, recordedIds);
|
|
875
|
+
return {
|
|
876
|
+
r2Keys,
|
|
877
|
+
storeR2Credentials: typeof scoped?.filesOrganisation?.storeR2Credentials === 'function',
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
catch (e) {
|
|
881
|
+
if (e instanceof OwnServicesBlockedError)
|
|
882
|
+
return { r2Keys, storeR2Credentials: false };
|
|
883
|
+
throw e;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
/** Whether the host's scoped ports for this own org include a files-organisation port. A held key still missing counts as no port. */
|
|
887
|
+
async ownFilesOrganisationPort(org, recordedIds) {
|
|
888
|
+
try {
|
|
889
|
+
const scoped = await this.ports.ownServices?.portsFor(org, recordedIds);
|
|
890
|
+
return Boolean(scoped?.filesOrganisation);
|
|
891
|
+
}
|
|
892
|
+
catch (e) {
|
|
893
|
+
if (e instanceof OwnServicesBlockedError)
|
|
894
|
+
return false;
|
|
895
|
+
throw e;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
774
898
|
/** #555: the edge-routed ports this executor was built with itself (they win over edge). */
|
|
775
899
|
ownEdgePorts() {
|
|
776
900
|
return {
|
|
@@ -782,6 +906,13 @@ export class FoundryStepExecutor {
|
|
|
782
906
|
};
|
|
783
907
|
}
|
|
784
908
|
async execute(action, context) {
|
|
909
|
+
const { org } = context;
|
|
910
|
+
if (ownsServices(org) && !action.kind.startsWith('own-')) {
|
|
911
|
+
return this.executeScoped(action, context);
|
|
912
|
+
}
|
|
913
|
+
return this.run(action, context);
|
|
914
|
+
}
|
|
915
|
+
async run(action, context) {
|
|
785
916
|
const { org } = context;
|
|
786
917
|
switch (`${action.system}:${action.kind}`) {
|
|
787
918
|
case 'zitadel:issuer':
|
|
@@ -860,6 +991,18 @@ export class FoundryStepExecutor {
|
|
|
860
991
|
return this.bouleBrokerEntry(org, context);
|
|
861
992
|
case 'jobs:registration':
|
|
862
993
|
return this.jobsRegistration(action, org, context);
|
|
994
|
+
case 'coolify:own-auth':
|
|
995
|
+
return this.ownAuth(action, org, context);
|
|
996
|
+
case 'coolify:own-jobs':
|
|
997
|
+
return this.ownJobs(action, org, context);
|
|
998
|
+
case 'coolify:own-mail':
|
|
999
|
+
return this.ownMail(action, org, context);
|
|
1000
|
+
case 'coolify:own-sfu':
|
|
1001
|
+
return this.ownSfu(action, org, context);
|
|
1002
|
+
case 'coolify:own-files':
|
|
1003
|
+
return this.ownFiles(action, org, context);
|
|
1004
|
+
case 'coolify:own-files-r2':
|
|
1005
|
+
return this.ownFilesR2(org, context);
|
|
863
1006
|
default:
|
|
864
1007
|
throw new Error(`FoundryStepExecutor has no dispatch for ${action.system}:${action.kind}`);
|
|
865
1008
|
}
|
|
@@ -2925,6 +3068,40 @@ export class FoundryStepExecutor {
|
|
|
2925
3068
|
}
|
|
2926
3069
|
}
|
|
2927
3070
|
}
|
|
3071
|
+
/**
|
|
3072
|
+
* The company's own sign-in mail token: a Lettermint project `<slug>-auth`
|
|
3073
|
+
* whose token goes to `orgTarget(org).refs.brokerMailKey`, where the broker
|
|
3074
|
+
* and ZITADEL's SMTP settings read it. The platform-wide Lettermint key
|
|
3075
|
+
* stays with the executor and never reaches a company. A held ref is left
|
|
3076
|
+
* alone. Otherwise the mint rule of {@link ensureOrgAppMailToken}: create
|
|
3077
|
+
* the project, restrict the domain, deliver its token, and only then
|
|
3078
|
+
* delete every other `<slug>-auth` project (a token cannot be read back,
|
|
3079
|
+
* so a project without a held token is a leftover). The restriction comes
|
|
3080
|
+
* before the delivery so a failure there leaves no held ref: the next run
|
|
3081
|
+
* starts over instead of skipping it. A failure before the delivery leaves
|
|
3082
|
+
* that project for the next run to delete. Every Lettermint call is
|
|
3083
|
+
* bounded; the token is registered for redaction before anything can throw
|
|
3084
|
+
* with it.
|
|
3085
|
+
*/
|
|
3086
|
+
async ensureOwnAuthMailToken(org, mailDomain, deps) {
|
|
3087
|
+
const ref = orgTarget(org).refs.brokerMailKey;
|
|
3088
|
+
if (await deps.heldKeys?.resolve(ref))
|
|
3089
|
+
return;
|
|
3090
|
+
const lettermint = this.ports.lettermint;
|
|
3091
|
+
if (!lettermint || !deps.heldKeys)
|
|
3092
|
+
throw new OwnServicesBlockedError('lettermint', ref);
|
|
3093
|
+
const authName = `${org.slug}-auth`;
|
|
3094
|
+
const projects = await withTimeout(lettermint.listProjects(), LETTERMINT_TIMEOUT_MS);
|
|
3095
|
+
const created = await withTimeout(lettermint.createProject(authName), LETTERMINT_TIMEOUT_MS);
|
|
3096
|
+
deps.runtime?.onSecret?.(created.token);
|
|
3097
|
+
await withTimeout(this.ensureLettermintDomainRestriction(org, mailDomain, [...projects, created.project], null, null), LETTERMINT_TIMEOUT_MS);
|
|
3098
|
+
await deps.deliverSecret(ref, created.token);
|
|
3099
|
+
for (const project of projects) {
|
|
3100
|
+
if (project.name === authName && project.id !== created.project.id) {
|
|
3101
|
+
await withTimeout(lettermint.deleteProject(project.id), LETTERMINT_TIMEOUT_MS);
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
2928
3105
|
/**
|
|
2929
3106
|
* Deletes a just-minted project from inside a `catch`. A failed delete is
|
|
2930
3107
|
* dropped so it never replaces the error being rethrown; the orphan is
|
|
@@ -2971,6 +3148,7 @@ export class FoundryStepExecutor {
|
|
|
2971
3148
|
async ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId) {
|
|
2972
3149
|
const appName = `${org.slug}-app`;
|
|
2973
3150
|
const relayName = `${org.slug}-relay`;
|
|
3151
|
+
const authName = `${org.slug}-auth`;
|
|
2974
3152
|
const isPlatformApexDomain = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
|
|
2975
3153
|
if (isPlatformApexDomain && platformLettermintProjectId) {
|
|
2976
3154
|
if (!allProjects.some((p) => p.id === platformLettermintProjectId)) {
|
|
@@ -2982,7 +3160,7 @@ export class FoundryStepExecutor {
|
|
|
2982
3160
|
? await this.ports.lettermint.getDomain(existing.id)
|
|
2983
3161
|
: await this.ports.lettermint.createDomain(mailDomain);
|
|
2984
3162
|
const scopedProjectIds = allProjects
|
|
2985
|
-
.filter((p) => p.name === appName || p.name === relayName)
|
|
3163
|
+
.filter((p) => p.name === appName || p.name === relayName || p.name === authName)
|
|
2986
3164
|
.map((p) => p.id);
|
|
2987
3165
|
if (isPlatformApexDomain &&
|
|
2988
3166
|
platformLettermintProjectId &&
|
|
@@ -3411,12 +3589,14 @@ export class FoundryStepExecutor {
|
|
|
3411
3589
|
description: `Synapse for ${org.slug}'s calls stack (${SYNAPSE_IMAGE}:${SYNAPSE_TAG})`,
|
|
3412
3590
|
instant_deploy: false,
|
|
3413
3591
|
}));
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3592
|
+
// The check goes on every run, so a Synapse made without one gets it. Synapse
|
|
3593
|
+
// is deployed before MAS here. Its `/health` answers with MAS unreachable
|
|
3594
|
+
// (shown with a throwaway Synapse of the same image, MAS endpoint a name that
|
|
3595
|
+
// does not resolve: 200, healthy), and the start period covers first-boot migrations.
|
|
3596
|
+
await this.ports.coolify.updateApplication(synapse.appId, {
|
|
3597
|
+
...(synapse.existed ? {} : { custom_internal_name: names.synapse }),
|
|
3598
|
+
...healthCheckSettings('/health', 8008, SYNAPSE_HEALTH_CHECK_START_PERIOD_SECONDS),
|
|
3599
|
+
});
|
|
3420
3600
|
context.recordScopeId(CALLS_SYNAPSE_SCOPE_KEY, synapse.appId);
|
|
3421
3601
|
// #251: only on create -- nothing here patches `docker_registry_image_tag`
|
|
3422
3602
|
// on an app `findOrCreateCallsApp` found already running, so that is the
|
|
@@ -3431,14 +3611,14 @@ export class FoundryStepExecutor {
|
|
|
3431
3611
|
server_uuid: server.uuid,
|
|
3432
3612
|
environment_name: environment.name,
|
|
3433
3613
|
environment_uuid: environment.uuid,
|
|
3434
|
-
dockerfile:
|
|
3614
|
+
dockerfile: masDockerfile(),
|
|
3435
3615
|
name: names.mas,
|
|
3436
3616
|
description: `matrix-authentication-service for ${org.slug}'s calls stack`,
|
|
3437
3617
|
instant_deploy: false,
|
|
3438
3618
|
}));
|
|
3439
3619
|
if (!mas.existed) {
|
|
3440
3620
|
await this.ports.coolify.updateApplication(mas.appId, {
|
|
3441
|
-
|
|
3621
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
3442
3622
|
custom_internal_name: names.mas,
|
|
3443
3623
|
});
|
|
3444
3624
|
}
|
|
@@ -3875,7 +4055,7 @@ export class FoundryStepExecutor {
|
|
|
3875
4055
|
server_uuid: server.uuid,
|
|
3876
4056
|
environment_name: environment.name,
|
|
3877
4057
|
environment_uuid: environment.uuid,
|
|
3878
|
-
dockerfile:
|
|
4058
|
+
dockerfile: masDockerfile(),
|
|
3879
4059
|
name: names.mas,
|
|
3880
4060
|
description: `matrix-authentication-service for ${org.slug}'s chat`,
|
|
3881
4061
|
instant_deploy: false,
|
|
@@ -3895,21 +4075,26 @@ export class FoundryStepExecutor {
|
|
|
3895
4075
|
autogenerate_domain: false,
|
|
3896
4076
|
}), { startIfStopped: false });
|
|
3897
4077
|
// Fixed internal names, set on every run: the three apps and Agora
|
|
3898
|
-
// address each other by them.
|
|
3899
|
-
//
|
|
3900
|
-
//
|
|
4078
|
+
// address each other by them. Both are Dockerfile apps with a HEALTHCHECK
|
|
4079
|
+
// of their own (MAS's has a copied-in busybox, its image has no shell), so
|
|
4080
|
+
// Coolify's check is off and the image's is the live one. MAS is deployed
|
|
4081
|
+
// first and its `/health` needs only its database, so Synapse's check can
|
|
4082
|
+
// pass.
|
|
3901
4083
|
await this.ports.coolify.updateApplication(synapse.appId, {
|
|
3902
4084
|
custom_internal_name: names.synapse,
|
|
3903
|
-
|
|
4085
|
+
// Its Dockerfile's HEALTHCHECK (curl on /health) is the live check; MAS is deployed first.
|
|
4086
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
3904
4087
|
});
|
|
3905
4088
|
await this.ports.coolify.updateApplication(mas.appId, {
|
|
3906
4089
|
custom_internal_name: names.mas,
|
|
3907
|
-
|
|
4090
|
+
// Its Dockerfile's HEALTHCHECK (`masDockerfile`) is the live check.
|
|
4091
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
3908
4092
|
});
|
|
3909
4093
|
await this.ports.coolify.updateApplication(service.appId, {
|
|
3910
4094
|
custom_internal_name: names.service,
|
|
3911
|
-
//
|
|
3912
|
-
//
|
|
4095
|
+
// A command, not Coolify's HTTP check: the image is node:22-alpine with
|
|
4096
|
+
// no curl, and its busybox `wget` tries `localhost` as `::1` first.
|
|
4097
|
+
...commandHealthCheckSettings(wgetProbe(CHAT_SERVICE_PORT, CHAT_SERVICE_HEALTH_CHECK_PATH)),
|
|
3913
4098
|
});
|
|
3914
4099
|
context.recordScopeId(CHAT_SYNAPSE_SCOPE_KEY, synapse.appId);
|
|
3915
4100
|
context.recordScopeId(CHAT_SERVICE_SCOPE_KEY, service.appId);
|
|
@@ -4084,18 +4269,20 @@ export class FoundryStepExecutor {
|
|
|
4084
4269
|
server_uuid: server.uuid,
|
|
4085
4270
|
environment_name: environment.name,
|
|
4086
4271
|
environment_uuid: environment.uuid,
|
|
4087
|
-
dockerfile:
|
|
4272
|
+
dockerfile: masDockerfile(),
|
|
4088
4273
|
name: names.mas,
|
|
4089
4274
|
description: `matrix-authentication-service for ${org.slug}'s re-keyed chat (#463)`,
|
|
4090
4275
|
instant_deploy: false,
|
|
4091
4276
|
}), { startIfStopped: false });
|
|
4092
4277
|
await this.ports.coolify.updateApplication(synapse.appId, {
|
|
4093
4278
|
custom_internal_name: names.synapse,
|
|
4094
|
-
|
|
4279
|
+
// Its Dockerfile's HEALTHCHECK (curl on /health) is the live check; MAS is deployed first.
|
|
4280
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
4095
4281
|
});
|
|
4096
4282
|
await this.ports.coolify.updateApplication(mas.appId, {
|
|
4097
4283
|
custom_internal_name: names.mas,
|
|
4098
|
-
|
|
4284
|
+
// Its Dockerfile's HEALTHCHECK (`masDockerfile`) is the live check.
|
|
4285
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
4099
4286
|
});
|
|
4100
4287
|
context.recordScopeId(CHAT_REKEY_SYNAPSE_SCOPE_KEY, synapse.appId);
|
|
4101
4288
|
context.recordScopeId(CHAT_REKEY_MAS_SCOPE_KEY, mas.appId);
|
|
@@ -4289,13 +4476,14 @@ export class FoundryStepExecutor {
|
|
|
4289
4476
|
// step again for a live app whose marker is missing or stale, so an app
|
|
4290
4477
|
// made before this existed gets its name here. The marker, recorded last,
|
|
4291
4478
|
// tells `desiredAgoraEnv` that the name and the env are both in place.
|
|
4292
|
-
//
|
|
4293
|
-
//
|
|
4479
|
+
// A command, not Coolify's HTTP check: the image is node:24-alpine with no
|
|
4480
|
+
// curl, and its busybox `wget` tries `localhost` as `::1` first.
|
|
4294
4481
|
// A found app may predate `ports_exposes` on create; patch a differing one.
|
|
4295
4482
|
const portDiffers = app.existed &&
|
|
4296
4483
|
(await this.ports.coolify.getApplication(app.appId)).ports_exposes !== CALLS_SERVICE_PORT;
|
|
4297
4484
|
await this.ports.coolify.updateApplication(app.appId, {
|
|
4298
4485
|
custom_internal_name: name,
|
|
4486
|
+
...commandHealthCheckSettings(wgetProbe(CALLS_SERVICE_PORT, CALLS_HEALTH_CHECK_PATH)),
|
|
4299
4487
|
...(portDiffers ? { ports_exposes: CALLS_SERVICE_PORT } : {}),
|
|
4300
4488
|
});
|
|
4301
4489
|
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
@@ -5022,6 +5210,349 @@ export class FoundryStepExecutor {
|
|
|
5022
5210
|
}
|
|
5023
5211
|
return port;
|
|
5024
5212
|
}
|
|
5213
|
+
/**
|
|
5214
|
+
* `plan/own-diff.ts`'s `planOwnJobs`: the org's own scheduler, in the org's
|
|
5215
|
+
* own project, with the names and the held-key ref of `orgTarget(org)`. The
|
|
5216
|
+
* admin token goes to `jobs:org:<slug>:adminToken` and nowhere else.
|
|
5217
|
+
*/
|
|
5218
|
+
async ownJobs(action, org, context) {
|
|
5219
|
+
const own = this.ports.ownServices;
|
|
5220
|
+
if (!own)
|
|
5221
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
5222
|
+
return provisionJobsHost(scopeFromOrg(context, {}), {
|
|
5223
|
+
coolify: limited(own.provision.jobs),
|
|
5224
|
+
deliverSecret: this.ports.deliverSecret,
|
|
5225
|
+
readSecret: async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null,
|
|
5226
|
+
runtime: this.ports.runtime ?? {},
|
|
5227
|
+
}, orgTarget(org), {
|
|
5228
|
+
hostname: null,
|
|
5229
|
+
gitRef: action.desired.gitRef,
|
|
5230
|
+
project: { ensure: () => this.ensureCoolifyProject(org, context) },
|
|
5231
|
+
});
|
|
5232
|
+
}
|
|
5233
|
+
/**
|
|
5234
|
+
* `plan/own-diff.ts`'s `planOwnFiles`: the org's own files host in the
|
|
5235
|
+
* org's own project, trusting the company's issuer `https://auth.<domain>`.
|
|
5236
|
+
* Its ZITADEL project `<slug>-files` and introspection app are made in the
|
|
5237
|
+
* company's instance (`ownServices.zitadelFor`), never the shared one. The
|
|
5238
|
+
* bucket comes from the host's bucket port under the target's name. The
|
|
5239
|
+
* management key goes to `files:org:<slug>:managementKey` and the
|
|
5240
|
+
* introspection secret to `zitadel:org:<slug>:files:introspection`, both
|
|
5241
|
+
* once, and neither is recorded or returned.
|
|
5242
|
+
*/
|
|
5243
|
+
async ownFiles(action, org, context) {
|
|
5244
|
+
const own = this.ports.ownServices;
|
|
5245
|
+
if (!own)
|
|
5246
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
5247
|
+
const at = `coolify:own-files for ${org.slug}`;
|
|
5248
|
+
const domain = org.domain;
|
|
5249
|
+
if (!domain)
|
|
5250
|
+
throw new Error(`${at}: the org declares no domain`);
|
|
5251
|
+
const zitadelOrgId = context.recordedIds()[ZITADEL_ORG_SCOPE_KEY];
|
|
5252
|
+
if (!zitadelOrgId)
|
|
5253
|
+
throw new Error(`${at}: the org's ZITADEL organisation is not recorded yet`);
|
|
5254
|
+
const { r2Buckets, r2Keys: _r2Keys, keysMint, readSecret, ...ownFiles } = own.provision.files;
|
|
5255
|
+
const files = { ...ownFiles, coolify: limited(ownFiles.coolify) };
|
|
5256
|
+
// Never the platform's credential in its place.
|
|
5257
|
+
if (!keysMint)
|
|
5258
|
+
throw new OwnServicesBlockedError('files', 'keysMint');
|
|
5259
|
+
if (!r2Buckets)
|
|
5260
|
+
throw new Error(`${at}: the host supplies no bucket port`);
|
|
5261
|
+
const target = orgTarget(org);
|
|
5262
|
+
let r2;
|
|
5263
|
+
try {
|
|
5264
|
+
r2 = await withTimeout(r2Buckets.ensureBucket(target.names.filesBucket), BUCKET_TIMEOUT_MS);
|
|
5265
|
+
}
|
|
5266
|
+
catch {
|
|
5267
|
+
// The cause is dropped: a host port's message is not ours to repeat.
|
|
5268
|
+
throw new Error(`${at}: the bucket ${target.names.filesBucket} could not be ensured`);
|
|
5269
|
+
}
|
|
5270
|
+
const issuer = `https://auth.${domain}`;
|
|
5271
|
+
const deps = {
|
|
5272
|
+
...files,
|
|
5273
|
+
// The platform's keys settings stay out of a company's containers; the minted keyring replaces them.
|
|
5274
|
+
keys: undefined,
|
|
5275
|
+
jobsSigningSecrets: undefined,
|
|
5276
|
+
r2,
|
|
5277
|
+
zitadel: own.zitadelFor({ issuerUrl: issuer, bearerRef: target.refs.instanceBearer }),
|
|
5278
|
+
zitadelForInstance: undefined,
|
|
5279
|
+
deliverSecret: this.ports.deliverSecret,
|
|
5280
|
+
runtime: {
|
|
5281
|
+
...files.runtime,
|
|
5282
|
+
onSecret: (value) => {
|
|
5283
|
+
this.handledSecrets.add(value);
|
|
5284
|
+
},
|
|
5285
|
+
},
|
|
5286
|
+
};
|
|
5287
|
+
const invalid = filesDepsInvalid(deps);
|
|
5288
|
+
if (invalid)
|
|
5289
|
+
throw new Error(`${at}: ${invalid}`);
|
|
5290
|
+
const scope = scopeFromOrg(context, {});
|
|
5291
|
+
const mintOwn = async () => {
|
|
5292
|
+
try {
|
|
5293
|
+
return await withTimeout(keysMint.mint(`files-${org.slug}`, randomUUID()), KEYS_MINT_TIMEOUT_MS);
|
|
5294
|
+
}
|
|
5295
|
+
catch (error) {
|
|
5296
|
+
// The cause is dropped: a mint client's message is not ours to repeat.
|
|
5297
|
+
throw new Error(error instanceof Error && error.message === 'timeout'
|
|
5298
|
+
? `${at}: the keys mint did not answer within ${KEYS_MINT_TIMEOUT_MS / 1000} seconds`
|
|
5299
|
+
: `${at}: the keys mint failed`);
|
|
5300
|
+
}
|
|
5301
|
+
};
|
|
5302
|
+
const { keys, guardId } = await resolveOwnFilesKeys(scope, {
|
|
5303
|
+
deliverSecret: deps.deliverSecret,
|
|
5304
|
+
readSecret: readSecret ?? (async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null),
|
|
5305
|
+
runtime: deps.runtime,
|
|
5306
|
+
}, target, mintOwn, keysMint.workerUrl, `own step ${at}`);
|
|
5307
|
+
return provisionFilesHost(scope, { ...deps, keys }, target, {
|
|
5308
|
+
keysGuardId: guardId,
|
|
5309
|
+
hostname: action.desired.hostname,
|
|
5310
|
+
gitRef: action.desired.gitRef,
|
|
5311
|
+
project: { ensure: () => this.ensureCoolifyProject(org, context) },
|
|
5312
|
+
issuer,
|
|
5313
|
+
zitadelOrg: { id: zitadelOrgId },
|
|
5314
|
+
zitadelProject: {
|
|
5315
|
+
name: `${org.slug}-files`,
|
|
5316
|
+
scopeKey: ZITADEL_FILES_PROJECT_SCOPE_KEY,
|
|
5317
|
+
},
|
|
5318
|
+
shareDomains: filesShareDomains([org]),
|
|
5319
|
+
bootstrapOperator: action.desired.bootstrapOperator,
|
|
5320
|
+
});
|
|
5321
|
+
}
|
|
5322
|
+
/**
|
|
5323
|
+
* `plan/own-diff.ts`'s `planOwnFilesR2`: the storage key of the org's own files bucket. A token
|
|
5324
|
+
* recorded as undelivered is deleted first (its secret cannot be read back, so it can never be
|
|
5325
|
+
* finished), then one is minted, registered as a secret, recorded by id as undelivered and
|
|
5326
|
+
* persisted, and only then sent to the files host, the same pair on every retry. The secret is
|
|
5327
|
+
* never recorded and never written to a Coolify env. The id is recorded once the host has it.
|
|
5328
|
+
*/
|
|
5329
|
+
async ownFilesR2(org, context) {
|
|
5330
|
+
const own = this.ports.ownServices;
|
|
5331
|
+
if (!own)
|
|
5332
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
5333
|
+
const at = `coolify:own-files-r2 for ${org.slug}`;
|
|
5334
|
+
const { r2Keys, runtime } = own.provision.files;
|
|
5335
|
+
if (!r2Keys)
|
|
5336
|
+
throw new OwnServicesBlockedError('files', 'r2Keys');
|
|
5337
|
+
const scoped = await own.portsFor(org, context.recordedIds());
|
|
5338
|
+
const organisation = scoped?.filesOrganisation;
|
|
5339
|
+
if (!organisation?.storeR2Credentials) {
|
|
5340
|
+
throw new Error(`${at}: the host gave the company no storeR2Credentials on its files port`);
|
|
5341
|
+
}
|
|
5342
|
+
const store = organisation.storeR2Credentials.bind(organisation);
|
|
5343
|
+
const bucket = orgTarget(org).names.filesBucket;
|
|
5344
|
+
const timeoutText = `did not answer within ${R2_KEY_TIMEOUT_MS / 1000} seconds`;
|
|
5345
|
+
const failure = (what, error) =>
|
|
5346
|
+
// The cause is dropped: a port's message is not ours to repeat, and may hold the secret.
|
|
5347
|
+
new Error(`${at}: ${what} ${error instanceof Error && error.message === 'timeout' ? timeoutText : 'failed'}`);
|
|
5348
|
+
const recorded = context.recordedIds();
|
|
5349
|
+
if (recorded[OWN_FILES_R2_SCOPE_KEY])
|
|
5350
|
+
return { resourceId: recorded[OWN_FILES_R2_SCOPE_KEY] };
|
|
5351
|
+
// A leftover token is deleted before anything is minted: never two live tokens.
|
|
5352
|
+
const leftover = recorded[OWN_FILES_R2_UNDELIVERED_SCOPE_KEY];
|
|
5353
|
+
if (leftover) {
|
|
5354
|
+
try {
|
|
5355
|
+
await withTimeout(r2Keys.deleteBucketKey(leftover), R2_KEY_TIMEOUT_MS);
|
|
5356
|
+
}
|
|
5357
|
+
catch (error) {
|
|
5358
|
+
if (!isNotFoundError(error)) {
|
|
5359
|
+
throw failure(`deleting the earlier storage key ${leftover}, so no new one was made:`, error);
|
|
5360
|
+
}
|
|
5361
|
+
}
|
|
5362
|
+
forgetScopeId(context, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY);
|
|
5363
|
+
await context.persistScopeIds();
|
|
5364
|
+
}
|
|
5365
|
+
let key;
|
|
5366
|
+
try {
|
|
5367
|
+
key = await withTimeout(r2Keys.mintBucketKey(bucket), R2_KEY_TIMEOUT_MS);
|
|
5368
|
+
}
|
|
5369
|
+
catch (error) {
|
|
5370
|
+
throw failure('the storage key mint', error);
|
|
5371
|
+
}
|
|
5372
|
+
const text = (value) => typeof value === 'string' && value.length > 0 && value.length <= 200;
|
|
5373
|
+
if (text(key?.secretAccessKey))
|
|
5374
|
+
this.handledSecrets.add(key.secretAccessKey);
|
|
5375
|
+
// A token id we can read is recorded even when the rest of the answer is unusable, so it is deleted next run.
|
|
5376
|
+
if (text(key?.tokenId)) {
|
|
5377
|
+
context.recordScopeId(OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, key.tokenId);
|
|
5378
|
+
await context.persistScopeIds();
|
|
5379
|
+
}
|
|
5380
|
+
if (!text(key?.accessKeyId) || !text(key?.secretAccessKey) || !text(key?.tokenId)) {
|
|
5381
|
+
throw new Error(`${at}: the storage key mint answered with an unusable key`);
|
|
5382
|
+
}
|
|
5383
|
+
const pair = {
|
|
5384
|
+
accessKeyId: key.accessKeyId,
|
|
5385
|
+
secretAccessKey: key.secretAccessKey,
|
|
5386
|
+
tokenId: key.tokenId,
|
|
5387
|
+
};
|
|
5388
|
+
const attempts = runtime?.deliverAttempts ?? 3;
|
|
5389
|
+
const sleep = runtime?.sleep ?? defaultSleep;
|
|
5390
|
+
for (let attempt = 1;; attempt += 1) {
|
|
5391
|
+
try {
|
|
5392
|
+
await withTimeout(store({ ...pair }), R2_KEY_TIMEOUT_MS);
|
|
5393
|
+
break;
|
|
5394
|
+
}
|
|
5395
|
+
catch (error) {
|
|
5396
|
+
if (attempt >= attempts) {
|
|
5397
|
+
const cause = redactSecrets(error instanceof Error ? error.message : String(error), [
|
|
5398
|
+
pair.secretAccessKey,
|
|
5399
|
+
pair.accessKeyId,
|
|
5400
|
+
]);
|
|
5401
|
+
throw new Error(`${at}: the storage key ${pair.tokenId} could not be delivered after ${attempts} tries; the next run deletes it and makes a new one (last try: ${cause})`);
|
|
5402
|
+
}
|
|
5403
|
+
await sleep(1000 * attempt);
|
|
5404
|
+
}
|
|
5405
|
+
}
|
|
5406
|
+
context.recordScopeId(OWN_FILES_R2_SCOPE_KEY, pair.tokenId);
|
|
5407
|
+
forgetScopeId(context, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY);
|
|
5408
|
+
await context.persistScopeIds();
|
|
5409
|
+
return { resourceId: pair.tokenId };
|
|
5410
|
+
}
|
|
5411
|
+
/**
|
|
5412
|
+
* `plan/own-diff.ts`'s `planOwnAuth`: the org's own sign-in at
|
|
5413
|
+
* `auth.<domain>`, in the org's own project, through the org's own edge
|
|
5414
|
+
* routed DNS and tunnel ports. The instance owner token goes to
|
|
5415
|
+
* `zitadel:org:<slug>:instanceBearer` and is never recorded. A changed
|
|
5416
|
+
* declared auth ref patches both git apps' branch and redeploys them; the
|
|
5417
|
+
* applied marker comes last, so a run that died halfway is planned again.
|
|
5418
|
+
*/
|
|
5419
|
+
async ownAuth(action, org, context) {
|
|
5420
|
+
const own = this.ports.ownServices;
|
|
5421
|
+
if (!own)
|
|
5422
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
5423
|
+
const hostname = action.desired.hostname;
|
|
5424
|
+
const gitRef = action.desired.gitRef;
|
|
5425
|
+
const domain = org.domain;
|
|
5426
|
+
if (!domain)
|
|
5427
|
+
throw new Error(`coolify:own-auth for ${org.slug}: the org declares no domain`);
|
|
5428
|
+
const scope = scopeFromOrg(context, {});
|
|
5429
|
+
// The target (host, port, tls, user) is not secret; the platform's SMTP
|
|
5430
|
+
// password is, so the company's own token is the password instead.
|
|
5431
|
+
const { smtp, ...authDeps } = own.provision.auth;
|
|
5432
|
+
const deps = {
|
|
5433
|
+
...authDeps,
|
|
5434
|
+
...(smtp
|
|
5435
|
+
? { smtp: { host: smtp.host, port: smtp.port, tls: smtp.tls, user: smtp.user } }
|
|
5436
|
+
: {}),
|
|
5437
|
+
coolify: limited(own.provision.auth.coolify),
|
|
5438
|
+
runtime: {
|
|
5439
|
+
...own.provision.auth.runtime,
|
|
5440
|
+
onSecret: (value) => {
|
|
5441
|
+
this.handledSecrets.add(value);
|
|
5442
|
+
},
|
|
5443
|
+
},
|
|
5444
|
+
};
|
|
5445
|
+
const before = scope.recordedIds();
|
|
5446
|
+
const refChanged = before[OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY] !== undefined &&
|
|
5447
|
+
before[OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY] !== gitRef;
|
|
5448
|
+
const gitApps = [
|
|
5449
|
+
before[PLATFORM_AUTH_LOGIN_SCOPE_KEY],
|
|
5450
|
+
before[PLATFORM_AUTH_BROKER_SCOPE_KEY],
|
|
5451
|
+
].filter((id) => Boolean(id));
|
|
5452
|
+
if (refChanged) {
|
|
5453
|
+
for (const id of gitApps)
|
|
5454
|
+
await deps.coolify.updateApplication(id, { git_branch: gitRef });
|
|
5455
|
+
}
|
|
5456
|
+
await this.ensureOwnAuthMailToken(org, domain, deps);
|
|
5457
|
+
const result = await provisionAuthStack(scope, deps, orgTarget(org), {
|
|
5458
|
+
hostname,
|
|
5459
|
+
apex: domain,
|
|
5460
|
+
gitRef,
|
|
5461
|
+
project: { ensure: () => this.ensureCoolifyProject(org, context) },
|
|
5462
|
+
network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:auth`),
|
|
5463
|
+
smtpSender: `noreply@${domain}`,
|
|
5464
|
+
defaultRedirectUri: `https://agora.${domain}`,
|
|
5465
|
+
ownerCompany: null,
|
|
5466
|
+
});
|
|
5467
|
+
if (refChanged) {
|
|
5468
|
+
const now = scope.recordedIds();
|
|
5469
|
+
const brokerDeployed = Boolean(now[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]);
|
|
5470
|
+
for (const id of gitApps) {
|
|
5471
|
+
// The broker crash-loops until its inputs exist; it was never deployed, so it is not now.
|
|
5472
|
+
if (id === now[PLATFORM_AUTH_BROKER_SCOPE_KEY] && !brokerDeployed)
|
|
5473
|
+
continue;
|
|
5474
|
+
await deps.coolify.deploy(id, true);
|
|
5475
|
+
}
|
|
5476
|
+
}
|
|
5477
|
+
scope.recordScopeId(OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
|
|
5478
|
+
scope.recordScopeId(OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
5479
|
+
scope.recordScopeId(OWN_SERVICES_APPLIED_SCOPE_KEY, org.id);
|
|
5480
|
+
await scope.persist();
|
|
5481
|
+
return result;
|
|
5482
|
+
}
|
|
5483
|
+
/**
|
|
5484
|
+
* `plan/own-diff.ts`'s `planOwnMail`: the org's own Stalwart in the org's own
|
|
5485
|
+
* project, with no host port (the shared relay reaches it by internal name)
|
|
5486
|
+
* and a directory that trusts the org's own sign-in. The admin key goes to
|
|
5487
|
+
* `orgTarget(org).refs.mailAdminKey` and nowhere else. The address the relay
|
|
5488
|
+
* routes to is recorded last, and the step's marker after it.
|
|
5489
|
+
*/
|
|
5490
|
+
async ownMail(action, org, context) {
|
|
5491
|
+
const own = this.ports.ownServices;
|
|
5492
|
+
if (!own)
|
|
5493
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
5494
|
+
if (!org.domain)
|
|
5495
|
+
throw new Error(`own step coolify:own-mail for ${org.slug}: the org has no domain`);
|
|
5496
|
+
const target = orgTarget(org);
|
|
5497
|
+
const scope = scopeFromOrg(context, {});
|
|
5498
|
+
const issuerUrl = `https://auth.${org.domain}`;
|
|
5499
|
+
const result = await provisionMailServer(scope, {
|
|
5500
|
+
...own.provision.mail,
|
|
5501
|
+
coolify: limited(own.provision.mail.coolify),
|
|
5502
|
+
deliverSecret: (ref, secret) => {
|
|
5503
|
+
this.handledSecrets.add(secret);
|
|
5504
|
+
return this.ports.deliverSecret(ref, secret);
|
|
5505
|
+
},
|
|
5506
|
+
stalwartForInstance: (input) => {
|
|
5507
|
+
this.handledSecrets.add(input.password);
|
|
5508
|
+
return (own.provision.mail.stalwartForInstance ?? defaultStalwartForInstance)(input);
|
|
5509
|
+
},
|
|
5510
|
+
zitadel: own.zitadelFor({ issuerUrl, bearerRef: target.refs.instanceBearer }),
|
|
5511
|
+
zitadelForInstance: undefined,
|
|
5512
|
+
}, target, {
|
|
5513
|
+
hostname: action.desired.hostname,
|
|
5514
|
+
apex: org.domain,
|
|
5515
|
+
mailHost: action.desired.mailHost,
|
|
5516
|
+
imageTag: action.desired.gitRef,
|
|
5517
|
+
hostPorts: '',
|
|
5518
|
+
issuerUrl,
|
|
5519
|
+
mxRecord: null,
|
|
5520
|
+
project: { ensure: () => this.ensureCoolifyProject(org, context) },
|
|
5521
|
+
network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:mail`),
|
|
5522
|
+
});
|
|
5523
|
+
scope.recordScopeId(OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, `${target.internal.mail}:25`);
|
|
5524
|
+
await scope.persist();
|
|
5525
|
+
return result;
|
|
5526
|
+
}
|
|
5527
|
+
/**
|
|
5528
|
+
* `plan/own-diff.ts`'s `planOwnSfu`: the org's own LiveKit server in the
|
|
5529
|
+
* org's project, on the ports of its slot. The company's key pair goes to
|
|
5530
|
+
* `livekit:org:<slug>:keys` and the app's env, nowhere else.
|
|
5531
|
+
*/
|
|
5532
|
+
async ownSfu(action, org, context) {
|
|
5533
|
+
const own = this.ports.ownServices;
|
|
5534
|
+
if (!own)
|
|
5535
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
5536
|
+
const scope = scopeFromOrg(context, {});
|
|
5537
|
+
const { sfu } = own.provision;
|
|
5538
|
+
return provisionSfuApp(scope, {
|
|
5539
|
+
coolify: limited(sfu.coolify),
|
|
5540
|
+
network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:sfu`),
|
|
5541
|
+
deliverSecret: sfu.deliverSecret,
|
|
5542
|
+
readSecret: async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null,
|
|
5543
|
+
runtime: this.ports.runtime ?? {},
|
|
5544
|
+
...(sfu.mintLivekitCredentials
|
|
5545
|
+
? { mintLivekitCredentials: sfu.mintLivekitCredentials }
|
|
5546
|
+
: {}),
|
|
5547
|
+
onSecret: (value) => this.handledSecrets.add(value),
|
|
5548
|
+
}, orgTarget(org), {
|
|
5549
|
+
slot: action.desired.slot,
|
|
5550
|
+
hostname: action.desired.hostname,
|
|
5551
|
+
apex: org.domain,
|
|
5552
|
+
version: action.desired.version,
|
|
5553
|
+
project: { ensure: () => this.ensureCoolifyProject(org, context) },
|
|
5554
|
+
});
|
|
5555
|
+
}
|
|
5025
5556
|
async jobsRegistration(action, org, context) {
|
|
5026
5557
|
const scheduler = await this.jobsPort(org);
|
|
5027
5558
|
const name = action.desired.name;
|
|
@@ -5759,6 +6290,11 @@ export class FoundryStepExecutor {
|
|
|
5759
6290
|
* #491: when `mailDomain` is the platform's own apex, this must never
|
|
5760
6291
|
* delete the platform's own project (the delete loop skips it by id) or
|
|
5761
6292
|
* the platform's own domain (returns before the domain lookup/delete).
|
|
6293
|
+
*
|
|
6294
|
+
* An org whose own sign-in is applied keeps its `<slug>-auth` project, and
|
|
6295
|
+
* keeps the Lettermint domain when that is the domain the sign-in sends
|
|
6296
|
+
* from. Every other org deletes `<slug>-auth` too, which only a failed
|
|
6297
|
+
* first sign-in run can have left behind.
|
|
5762
6298
|
*/
|
|
5763
6299
|
async parkLettermintMail(org, mailOrg, context, platformApex, platformLettermintProjectId) {
|
|
5764
6300
|
const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
@@ -5768,7 +6304,13 @@ export class FoundryStepExecutor {
|
|
|
5768
6304
|
// #345: `mailOrg.domain` is absent for a level-1 org -- the previously
|
|
5769
6305
|
// recorded mail domain is the one `MailPort.removeOrgRelay` needs.
|
|
5770
6306
|
await this.ports.mail.removeOrgRelay({ ...mailOrg, domain: mailDomain });
|
|
6307
|
+
// An own org's sign-in sends its mail through `<slug>-auth` and the domain it
|
|
6308
|
+
// applied at; parking the org's mail leaves both, or the sign-in can no
|
|
6309
|
+
// longer send (its held token would still resolve, so nothing re-mints it).
|
|
6310
|
+
const ownSignIn = ownsServices(org) && Boolean(org.domain) && ownAuthApplied(org, context.recordedIds());
|
|
5771
6311
|
const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
|
|
6312
|
+
if (!ownSignIn)
|
|
6313
|
+
names.add(`${org.slug}-auth`);
|
|
5772
6314
|
const projects = await this.ports.lettermint.listProjects();
|
|
5773
6315
|
for (const project of projects) {
|
|
5774
6316
|
if (names.has(project.name) && project.id !== platformLettermintProjectId) {
|
|
@@ -5777,6 +6319,8 @@ export class FoundryStepExecutor {
|
|
|
5777
6319
|
}
|
|
5778
6320
|
if (isPlatformApexOrg)
|
|
5779
6321
|
return;
|
|
6322
|
+
if (ownSignIn && mailDomain.toLowerCase() === org.domain?.toLowerCase())
|
|
6323
|
+
return;
|
|
5780
6324
|
const domain = await this.findExactLettermintDomain(mailDomain);
|
|
5781
6325
|
if (domain)
|
|
5782
6326
|
await this.ports.lettermint.deleteDomain(domain.id);
|
|
@@ -6779,6 +7323,23 @@ export class FoundryStepExecutor {
|
|
|
6779
7323
|
case 'coolify:database':
|
|
6780
7324
|
await this.ports.coolify.deleteDatabase(target.id);
|
|
6781
7325
|
return;
|
|
7326
|
+
case 'coolify:own-files-r2': {
|
|
7327
|
+
// An own files bucket's storage key; a 404 is caught below as already gone.
|
|
7328
|
+
const r2Keys = this.ports.ownServices?.provision.files.r2Keys;
|
|
7329
|
+
if (!r2Keys) {
|
|
7330
|
+
throw new Error(`coolify:own-files-r2 for ${org.slug}: this host supplies no R2 key port, cannot delete token ${target.id}`);
|
|
7331
|
+
}
|
|
7332
|
+
try {
|
|
7333
|
+
await withTimeout(r2Keys.deleteBucketKey(target.id), R2_KEY_TIMEOUT_MS);
|
|
7334
|
+
}
|
|
7335
|
+
catch (error) {
|
|
7336
|
+
if (error instanceof Error && error.message === 'timeout') {
|
|
7337
|
+
throw new Error(`coolify:own-files-r2 for ${org.slug}: deleting token ${target.id} did not answer within ${R2_KEY_TIMEOUT_MS / 1000} seconds`);
|
|
7338
|
+
}
|
|
7339
|
+
throw error;
|
|
7340
|
+
}
|
|
7341
|
+
return;
|
|
7342
|
+
}
|
|
6782
7343
|
case 'zitadel:app': {
|
|
6783
7344
|
const zitadelOrgId = recordedIds.zitadel_org;
|
|
6784
7345
|
const projectId = recordedIds.zitadel_project;
|