@wtfalch/foundry 0.32.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/bootstrap.d.ts +18 -1
- package/dist/bootstrap/bootstrap.js +167 -28
- package/dist/bootstrap/declare.d.ts +5 -1
- package/dist/bootstrap/declare.js +3 -1
- package/dist/bootstrap/index.d.ts +1 -1
- package/dist/bootstrap/versions.d.ts +4 -0
- package/dist/calls-sfu.d.ts +10 -0
- package/dist/calls-sfu.js +41 -0
- package/dist/index.d.ts +18 -8
- package/dist/index.js +8 -3
- package/dist/migrations/0051_org_services.sql +13 -0
- package/dist/migrations/0052_platform_mail_relay.sql +8 -0
- package/dist/org.d.ts +24 -2
- package/dist/org.js +42 -0
- package/dist/plan/boule.d.ts +24 -1
- package/dist/plan/boule.js +29 -1
- package/dist/plan/calls-matrix.d.ts +9 -0
- package/dist/plan/calls-matrix.js +17 -0
- package/dist/plan/cloudflared-connector.d.ts +7 -1
- package/dist/plan/cloudflared-connector.js +14 -1
- package/dist/plan/diff.d.ts +32 -1
- package/dist/plan/diff.js +383 -26
- package/dist/plan/executor.d.ts +177 -7
- package/dist/plan/executor.js +738 -51
- package/dist/plan/health-check-settings.d.ts +28 -0
- package/dist/plan/health-check-settings.js +36 -0
- package/dist/plan/health-gaps.d.ts +33 -0
- package/dist/plan/health-gaps.js +89 -0
- package/dist/plan/health-targets.js +1 -1
- package/dist/plan/held-key-refs.d.ts +10 -0
- package/dist/plan/held-key-refs.js +10 -0
- package/dist/plan/index.d.ts +16 -6
- package/dist/plan/index.js +8 -3
- package/dist/plan/live.d.ts +5 -2
- package/dist/plan/live.js +15 -2
- package/dist/plan/locked-hostnames.d.ts +5 -2
- package/dist/plan/locked-hostnames.js +9 -1
- package/dist/plan/login-host.d.ts +62 -2
- package/dist/plan/login-host.js +104 -1
- package/dist/plan/mail-relay.d.ts +120 -0
- package/dist/plan/mail-relay.js +323 -0
- package/dist/plan/org-names.d.ts +24 -0
- package/dist/plan/org-names.js +52 -0
- package/dist/plan/own-diff.d.ts +169 -0
- package/dist/plan/own-diff.js +432 -0
- package/dist/plan/own-dns.d.ts +6 -0
- package/dist/plan/own-dns.js +15 -0
- package/dist/plan/own-limits.d.ts +22 -0
- package/dist/plan/own-limits.js +101 -0
- package/dist/plan/own-services.d.ts +193 -0
- package/dist/plan/own-services.js +167 -0
- package/dist/plan/own-sfu.d.ts +69 -0
- package/dist/plan/own-sfu.js +255 -0
- package/dist/plan/plan-apply.d.ts +101 -7
- package/dist/plan/plan-apply.js +216 -12
- package/dist/plan/platform-diff.d.ts +61 -12
- package/dist/plan/platform-diff.js +140 -34
- package/dist/plan/platform-org.d.ts +36 -0
- package/dist/plan/platform-org.js +96 -2
- package/dist/plan/platform-providers.d.ts +407 -12
- package/dist/plan/platform-providers.js +947 -422
- package/dist/plan/sign-in-org.d.ts +99 -0
- package/dist/plan/sign-in-org.js +151 -0
- package/dist/plan/zitadel-login-policy.js +1 -1
- package/dist/plan/zitadel-system-api.d.ts +22 -1
- package/dist/plan/zitadel-system-api.js +49 -8
- package/dist/plan.d.ts +18 -0
- package/dist/platform.d.ts +8 -0
- package/dist/slug.js +1 -0
- package/dist/store/postgres.js +51 -2
- package/dist/store/schema.d.ts +51 -0
- package/dist/store/schema.js +5 -0
- package/package.json +1 -1
- package/versions.json +3 -1
package/dist/plan/executor.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
2
|
import { URL } from 'node:url';
|
|
3
|
+
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
3
4
|
import { resolveCallsSfuUrls } from '../calls-sfu.js';
|
|
4
|
-
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';
|
|
5
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';
|
|
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
|
+
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';
|
|
7
8
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
8
|
-
import {
|
|
9
|
+
import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
9
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';
|
|
10
11
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
11
12
|
import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
|
|
@@ -20,13 +21,20 @@ import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScope
|
|
|
20
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';
|
|
21
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';
|
|
22
23
|
import { GATE_CLAIM_NAME, gateHostnamesScopeKey, gateIdRecordKey, isBehindGate } from './gates.js';
|
|
23
|
-
import { healthCheckSettings } from './health-check-settings.js';
|
|
24
|
+
import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, wgetProbe, } from './health-check-settings.js';
|
|
24
25
|
import { appHostname, coolifyTunnelDomains, entryHostnames, tunnelDnsHostnames, tunnelDomainList, tunnelIngressHostnames, } from './hostnames.js';
|
|
25
|
-
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';
|
|
26
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';
|
|
27
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';
|
|
28
|
-
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';
|
|
29
36
|
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
|
|
37
|
+
import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
|
|
30
38
|
import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
|
|
31
39
|
/**
|
|
32
40
|
* The real {@link StepExecutor} (#20 "provision Company Factory end to
|
|
@@ -51,7 +59,7 @@ import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerR
|
|
|
51
59
|
*/
|
|
52
60
|
/** #569: the description foundry gives the Coolify project it creates for an org. */
|
|
53
61
|
const COOLIFY_PROJECT_MARK_PREFIX = 'foundry org ';
|
|
54
|
-
function coolifyProjectMark(org) {
|
|
62
|
+
export function coolifyProjectMark(org) {
|
|
55
63
|
return `${COOLIFY_PROJECT_MARK_PREFIX}${org.id}`;
|
|
56
64
|
}
|
|
57
65
|
/**
|
|
@@ -631,11 +639,6 @@ const CHAT_SECRET_ENV_KEYS = {
|
|
|
631
639
|
masRsaKeyPem: 'CHAT_MATRIX_MAS_RSA_KEY_PEM',
|
|
632
640
|
masEcKeyPem: 'CHAT_MATRIX_MAS_EC_KEY_PEM',
|
|
633
641
|
};
|
|
634
|
-
/** `FROM ${MAS_IMAGE}` plus the entry command its published image needs (chat's own `MAS_DOCKERFILE`, same reason: no arguments by default). */
|
|
635
|
-
const CALLS_MAS_DOCKERFILE = [
|
|
636
|
-
`FROM ${MAS_IMAGE}`,
|
|
637
|
-
'CMD ["server", "--config", "/config/config.yaml"]',
|
|
638
|
-
].join('\n');
|
|
639
642
|
/**
|
|
640
643
|
* `coolifyCallsService`'s own database (#145) -- no `system:kind:key` slot
|
|
641
644
|
* of its own, the same reason {@link CALLS_SYNAPSE_SCOPE_KEY}'s doc comment
|
|
@@ -663,6 +666,12 @@ const CALLS_HEALTH_CHECK_PATH = '/healthz';
|
|
|
663
666
|
* {@link CALLS_HEALTH_CHECK_PATH} above.
|
|
664
667
|
*/
|
|
665
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;
|
|
666
675
|
/**
|
|
667
676
|
* Whether an application's own Coolify `status` (`` `running:healthy`,
|
|
668
677
|
* `exited:unhealthy` `` -- `@wtfalch/providers`' own doc comment) means it is
|
|
@@ -753,6 +762,44 @@ export const databaseStartWait = {
|
|
|
753
762
|
// ---------------------------------------------------------------------------
|
|
754
763
|
// Executor
|
|
755
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
|
+
}
|
|
756
803
|
export class FoundryStepExecutor {
|
|
757
804
|
ports;
|
|
758
805
|
edgeDns;
|
|
@@ -761,9 +808,93 @@ export class FoundryStepExecutor {
|
|
|
761
808
|
edgeRateLimits;
|
|
762
809
|
edgeZones;
|
|
763
810
|
edgeWriteSlot;
|
|
811
|
+
/** Secrets this executor minted or was handed; see `StepExecutor.secretsHandled`. */
|
|
812
|
+
handledSecrets = new Set();
|
|
764
813
|
constructor(ports) {
|
|
765
814
|
this.ports = ports;
|
|
766
815
|
}
|
|
816
|
+
secretsHandled() {
|
|
817
|
+
return [...this.handledSecrets];
|
|
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
|
+
}
|
|
767
898
|
/** #555: the edge-routed ports this executor was built with itself (they win over edge). */
|
|
768
899
|
ownEdgePorts() {
|
|
769
900
|
return {
|
|
@@ -775,10 +906,21 @@ export class FoundryStepExecutor {
|
|
|
775
906
|
};
|
|
776
907
|
}
|
|
777
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) {
|
|
778
916
|
const { org } = context;
|
|
779
917
|
switch (`${action.system}:${action.kind}`) {
|
|
780
918
|
case 'zitadel:issuer':
|
|
781
919
|
return this.zitadelIssuer(action, org, context);
|
|
920
|
+
case 'zitadel:registration-closed':
|
|
921
|
+
return this.zitadelRegistrationClosed(org, context);
|
|
922
|
+
case 'zitadel:domain':
|
|
923
|
+
return this.zitadelDomain(action, org, context);
|
|
782
924
|
case 'zitadel:owner':
|
|
783
925
|
return this.zitadelOwner(action, org, context);
|
|
784
926
|
case 'zitadel:login-host':
|
|
@@ -849,6 +991,18 @@ export class FoundryStepExecutor {
|
|
|
849
991
|
return this.bouleBrokerEntry(org, context);
|
|
850
992
|
case 'jobs:registration':
|
|
851
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);
|
|
852
1006
|
default:
|
|
853
1007
|
throw new Error(`FoundryStepExecutor has no dispatch for ${action.system}:${action.kind}`);
|
|
854
1008
|
}
|
|
@@ -1290,17 +1444,60 @@ export class FoundryStepExecutor {
|
|
|
1290
1444
|
// their own design" -- not built in v1).
|
|
1291
1445
|
throw new NotBuiltError(`zitadel:issuer move for ${org.slug}`, 'moving the issuer onto or off the org\'s own domain is not built in v1; see foundry\'s .claude/decisions-2026-09-24-executor.md, "Executor decisions"');
|
|
1292
1446
|
}
|
|
1293
|
-
const {
|
|
1294
|
-
|
|
1295
|
-
|
|
1447
|
+
const { orgId } = await ensureSignInOrg({
|
|
1448
|
+
zitadel: this.ports.zitadel,
|
|
1449
|
+
name: org.slug,
|
|
1450
|
+
domain: signInOrgDomain(org),
|
|
1451
|
+
recordScopeId: (key, id) => context.recordScopeId(key, id),
|
|
1452
|
+
persistScopeIds: () => context.persistScopeIds(),
|
|
1453
|
+
registrationClosedScopeKey: ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY,
|
|
1454
|
+
});
|
|
1455
|
+
return { resourceId: orgId };
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* `plan/diff.ts`'s `planOrgRegistrationClosed`: closes self-registration on
|
|
1459
|
+
* the recorded ZITADEL org, for an org made before foundry did this or whose
|
|
1460
|
+
* close failed in the issuer step. Recorded only after the close returned.
|
|
1461
|
+
*/
|
|
1462
|
+
async zitadelRegistrationClosed(org, context) {
|
|
1463
|
+
const zitadelOrgId = context.recordedIds().zitadel_org;
|
|
1464
|
+
if (!zitadelOrgId) {
|
|
1465
|
+
throw new Error(`zitadel:registration-closed for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
1466
|
+
}
|
|
1467
|
+
await closeSignInOrgRegistration(this.ports.zitadel, zitadelOrgId, (key, id) => context.recordScopeId(key, id));
|
|
1468
|
+
return {};
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* `plan/diff.ts`'s `planOrgDomain`: registers the org's sign-in domain on
|
|
1472
|
+
* its recorded ZITADEL org and makes it primary, for an org whose ZITADEL
|
|
1473
|
+
* org was made before foundry did this. Records the domain only after both
|
|
1474
|
+
* calls succeeded.
|
|
1475
|
+
*/
|
|
1476
|
+
async zitadelDomain(action, org, context) {
|
|
1477
|
+
const zitadelOrgId = context.recordedIds().zitadel_org;
|
|
1478
|
+
if (!zitadelOrgId) {
|
|
1479
|
+
throw new Error(`zitadel:domain for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
1480
|
+
}
|
|
1481
|
+
const domain = action.desired.domain;
|
|
1482
|
+
// A refusal (the sign-in server's domain policy wants proof) is recorded and reported as
|
|
1483
|
+
// blocked by the planner; it does not fail every apply.
|
|
1484
|
+
const registered = await registerSignInDomainOrBlock(this.ports.zitadel, zitadelOrgId, domain, (key, id) => context.recordScopeId(key, id));
|
|
1485
|
+
if (registered)
|
|
1486
|
+
context.recordScopeId(ZITADEL_ORG_DOMAIN_SCOPE_KEY, domain);
|
|
1487
|
+
return {};
|
|
1296
1488
|
}
|
|
1297
1489
|
/**
|
|
1298
|
-
* `plan/diff.ts`'s `
|
|
1299
|
-
* org
|
|
1300
|
-
*
|
|
1301
|
-
*
|
|
1302
|
-
*
|
|
1303
|
-
*
|
|
1490
|
+
* `plan/diff.ts`'s `planOwners`: makes the owner's account in the org's
|
|
1491
|
+
* ZITADEL org and mails the first-sign-in link to their personal address.
|
|
1492
|
+
* The account's username is the local part of `mailboxAddress` and its email
|
|
1493
|
+
* is `mailboxAddress`, so the owner signs in as `name@<org domain>`; the
|
|
1494
|
+
* personal address is kept as the `personal_email` metadata. The invite code
|
|
1495
|
+
* ZITADEL returns goes only into that mail. Nothing here logs, returns or
|
|
1496
|
+
* throws the personal address or the code -- only the invited user id, handed
|
|
1497
|
+
* back as `resourceId` so `runSteps` records it under
|
|
1498
|
+
* `zitadel:owner:<orgId>:<email>`, which is exactly what makes the next
|
|
1499
|
+
* `plan()` call idempotent. A failure after the account exists records
|
|
1500
|
+
* nothing: the next apply finds the account by username and mints a new code.
|
|
1304
1501
|
*/
|
|
1305
1502
|
async zitadelOwner(action, org, context) {
|
|
1306
1503
|
const zitadelOrgId = context.recordedIds().zitadel_org;
|
|
@@ -1313,11 +1510,31 @@ export class FoundryStepExecutor {
|
|
|
1313
1510
|
if (!authIssuer) {
|
|
1314
1511
|
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`);
|
|
1315
1512
|
}
|
|
1316
|
-
const
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1513
|
+
const mailboxAddress = action.desired.mailboxAddress;
|
|
1514
|
+
if (!mailboxAddress) {
|
|
1515
|
+
throw new Error(`zitadel:owner for ${org.slug}: no mailboxAddress -- the planner reports this as blocked, so it should not reach the executor`);
|
|
1516
|
+
}
|
|
1517
|
+
const inviteMail = this.ports.ownerInviteMail;
|
|
1518
|
+
if (!inviteMail) {
|
|
1519
|
+
throw new Error(`zitadel:owner for ${org.slug}: no ownerInviteMail port -- the owner's work mailbox cannot be opened yet, so the invite is not sent there`);
|
|
1520
|
+
}
|
|
1521
|
+
// foundry's own record of this owner's account, so a returning owner is not mistaken for a stranger.
|
|
1522
|
+
const invitedBefore = context.recordedIds()[`zitadel:owner:${action.key}`];
|
|
1523
|
+
const { userId } = await inviteOwnerWithLink({
|
|
1524
|
+
zitadel: this.ports.zitadel,
|
|
1525
|
+
mail: inviteMail,
|
|
1526
|
+
orgId: zitadelOrgId,
|
|
1527
|
+
authIssuer,
|
|
1528
|
+
owner: {
|
|
1529
|
+
givenName: action.desired.givenName,
|
|
1530
|
+
familyName: action.desired.familyName,
|
|
1531
|
+
mailboxAddress,
|
|
1532
|
+
personalEmail: action.desired.email,
|
|
1533
|
+
},
|
|
1534
|
+
label: `zitadel:owner for ${org.slug}`,
|
|
1535
|
+
onSecret: (value) => this.handledSecrets.add(value),
|
|
1536
|
+
...(invitedBefore ? { invitedUserId: invitedBefore } : {}),
|
|
1537
|
+
});
|
|
1321
1538
|
// An owner invited after chat was built never reaches `coolifyChat`
|
|
1322
1539
|
// again, so the role is granted here once the messaging project exists.
|
|
1323
1540
|
const messagingProjectId = context.recordedIds()[ZITADEL_MESSAGING_PROJECT_SCOPE_KEY];
|
|
@@ -2493,6 +2710,23 @@ export class FoundryStepExecutor {
|
|
|
2493
2710
|
// Last: a run that failed above plans the step again.
|
|
2494
2711
|
context.recordScopeId(brokerEntryAppliedScopeKey(entryKey), JSON.stringify(want));
|
|
2495
2712
|
}
|
|
2713
|
+
/**
|
|
2714
|
+
* `null` once the app already has `AUTHZ_PEER_PASSWORD_OPERATOR`: minted only
|
|
2715
|
+
* the first time and never rotated, like {@link ensureAuthCookieSecret}.
|
|
2716
|
+
*/
|
|
2717
|
+
async ensurePeerPassword(appId) {
|
|
2718
|
+
return this.mintIfAbsent(appId, BOULE_PEER_PASSWORD_ENV, 24);
|
|
2719
|
+
}
|
|
2720
|
+
/** Same rule for the secret Archon's invite form sends to Boule. */
|
|
2721
|
+
async ensureBridgeSecret(appId) {
|
|
2722
|
+
return this.mintIfAbsent(appId, BOULE_BRIDGE_SECRET_ENV, 32);
|
|
2723
|
+
}
|
|
2724
|
+
async mintIfAbsent(appId, key, bytes) {
|
|
2725
|
+
const envs = await this.ports.coolify.listEnvs(appId);
|
|
2726
|
+
if (envs.some((e) => e.key === key && !e.is_preview))
|
|
2727
|
+
return null;
|
|
2728
|
+
return randomBytes(bytes).toString('base64url');
|
|
2729
|
+
}
|
|
2496
2730
|
/**
|
|
2497
2731
|
* `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
|
|
2498
2732
|
* only the first time (task brief), never regenerated or overwritten, so a
|
|
@@ -2834,6 +3068,40 @@ export class FoundryStepExecutor {
|
|
|
2834
3068
|
}
|
|
2835
3069
|
}
|
|
2836
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
|
+
}
|
|
2837
3105
|
/**
|
|
2838
3106
|
* Deletes a just-minted project from inside a `catch`. A failed delete is
|
|
2839
3107
|
* dropped so it never replaces the error being rethrown; the orphan is
|
|
@@ -2880,6 +3148,7 @@ export class FoundryStepExecutor {
|
|
|
2880
3148
|
async ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId) {
|
|
2881
3149
|
const appName = `${org.slug}-app`;
|
|
2882
3150
|
const relayName = `${org.slug}-relay`;
|
|
3151
|
+
const authName = `${org.slug}-auth`;
|
|
2883
3152
|
const isPlatformApexDomain = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
|
|
2884
3153
|
if (isPlatformApexDomain && platformLettermintProjectId) {
|
|
2885
3154
|
if (!allProjects.some((p) => p.id === platformLettermintProjectId)) {
|
|
@@ -2891,7 +3160,7 @@ export class FoundryStepExecutor {
|
|
|
2891
3160
|
? await this.ports.lettermint.getDomain(existing.id)
|
|
2892
3161
|
: await this.ports.lettermint.createDomain(mailDomain);
|
|
2893
3162
|
const scopedProjectIds = allProjects
|
|
2894
|
-
.filter((p) => p.name === appName || p.name === relayName)
|
|
3163
|
+
.filter((p) => p.name === appName || p.name === relayName || p.name === authName)
|
|
2895
3164
|
.map((p) => p.id);
|
|
2896
3165
|
if (isPlatformApexDomain &&
|
|
2897
3166
|
platformLettermintProjectId &&
|
|
@@ -3320,12 +3589,14 @@ export class FoundryStepExecutor {
|
|
|
3320
3589
|
description: `Synapse for ${org.slug}'s calls stack (${SYNAPSE_IMAGE}:${SYNAPSE_TAG})`,
|
|
3321
3590
|
instant_deploy: false,
|
|
3322
3591
|
}));
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
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
|
+
});
|
|
3329
3600
|
context.recordScopeId(CALLS_SYNAPSE_SCOPE_KEY, synapse.appId);
|
|
3330
3601
|
// #251: only on create -- nothing here patches `docker_registry_image_tag`
|
|
3331
3602
|
// on an app `findOrCreateCallsApp` found already running, so that is the
|
|
@@ -3340,14 +3611,14 @@ export class FoundryStepExecutor {
|
|
|
3340
3611
|
server_uuid: server.uuid,
|
|
3341
3612
|
environment_name: environment.name,
|
|
3342
3613
|
environment_uuid: environment.uuid,
|
|
3343
|
-
dockerfile:
|
|
3614
|
+
dockerfile: masDockerfile(),
|
|
3344
3615
|
name: names.mas,
|
|
3345
3616
|
description: `matrix-authentication-service for ${org.slug}'s calls stack`,
|
|
3346
3617
|
instant_deploy: false,
|
|
3347
3618
|
}));
|
|
3348
3619
|
if (!mas.existed) {
|
|
3349
3620
|
await this.ports.coolify.updateApplication(mas.appId, {
|
|
3350
|
-
|
|
3621
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
3351
3622
|
custom_internal_name: names.mas,
|
|
3352
3623
|
});
|
|
3353
3624
|
}
|
|
@@ -3784,7 +4055,7 @@ export class FoundryStepExecutor {
|
|
|
3784
4055
|
server_uuid: server.uuid,
|
|
3785
4056
|
environment_name: environment.name,
|
|
3786
4057
|
environment_uuid: environment.uuid,
|
|
3787
|
-
dockerfile:
|
|
4058
|
+
dockerfile: masDockerfile(),
|
|
3788
4059
|
name: names.mas,
|
|
3789
4060
|
description: `matrix-authentication-service for ${org.slug}'s chat`,
|
|
3790
4061
|
instant_deploy: false,
|
|
@@ -3804,21 +4075,26 @@ export class FoundryStepExecutor {
|
|
|
3804
4075
|
autogenerate_domain: false,
|
|
3805
4076
|
}), { startIfStopped: false });
|
|
3806
4077
|
// Fixed internal names, set on every run: the three apps and Agora
|
|
3807
|
-
// address each other by them.
|
|
3808
|
-
//
|
|
3809
|
-
//
|
|
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.
|
|
3810
4083
|
await this.ports.coolify.updateApplication(synapse.appId, {
|
|
3811
4084
|
custom_internal_name: names.synapse,
|
|
3812
|
-
|
|
4085
|
+
// Its Dockerfile's HEALTHCHECK (curl on /health) is the live check; MAS is deployed first.
|
|
4086
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
3813
4087
|
});
|
|
3814
4088
|
await this.ports.coolify.updateApplication(mas.appId, {
|
|
3815
4089
|
custom_internal_name: names.mas,
|
|
3816
|
-
|
|
4090
|
+
// Its Dockerfile's HEALTHCHECK (`masDockerfile`) is the live check.
|
|
4091
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
3817
4092
|
});
|
|
3818
4093
|
await this.ports.coolify.updateApplication(service.appId, {
|
|
3819
4094
|
custom_internal_name: names.service,
|
|
3820
|
-
//
|
|
3821
|
-
//
|
|
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)),
|
|
3822
4098
|
});
|
|
3823
4099
|
context.recordScopeId(CHAT_SYNAPSE_SCOPE_KEY, synapse.appId);
|
|
3824
4100
|
context.recordScopeId(CHAT_SERVICE_SCOPE_KEY, service.appId);
|
|
@@ -3993,18 +4269,20 @@ export class FoundryStepExecutor {
|
|
|
3993
4269
|
server_uuid: server.uuid,
|
|
3994
4270
|
environment_name: environment.name,
|
|
3995
4271
|
environment_uuid: environment.uuid,
|
|
3996
|
-
dockerfile:
|
|
4272
|
+
dockerfile: masDockerfile(),
|
|
3997
4273
|
name: names.mas,
|
|
3998
4274
|
description: `matrix-authentication-service for ${org.slug}'s re-keyed chat (#463)`,
|
|
3999
4275
|
instant_deploy: false,
|
|
4000
4276
|
}), { startIfStopped: false });
|
|
4001
4277
|
await this.ports.coolify.updateApplication(synapse.appId, {
|
|
4002
4278
|
custom_internal_name: names.synapse,
|
|
4003
|
-
|
|
4279
|
+
// Its Dockerfile's HEALTHCHECK (curl on /health) is the live check; MAS is deployed first.
|
|
4280
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
4004
4281
|
});
|
|
4005
4282
|
await this.ports.coolify.updateApplication(mas.appId, {
|
|
4006
4283
|
custom_internal_name: names.mas,
|
|
4007
|
-
|
|
4284
|
+
// Its Dockerfile's HEALTHCHECK (`masDockerfile`) is the live check.
|
|
4285
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
4008
4286
|
});
|
|
4009
4287
|
context.recordScopeId(CHAT_REKEY_SYNAPSE_SCOPE_KEY, synapse.appId);
|
|
4010
4288
|
context.recordScopeId(CHAT_REKEY_MAS_SCOPE_KEY, mas.appId);
|
|
@@ -4198,13 +4476,14 @@ export class FoundryStepExecutor {
|
|
|
4198
4476
|
// step again for a live app whose marker is missing or stale, so an app
|
|
4199
4477
|
// made before this existed gets its name here. The marker, recorded last,
|
|
4200
4478
|
// tells `desiredAgoraEnv` that the name and the env are both in place.
|
|
4201
|
-
//
|
|
4202
|
-
//
|
|
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.
|
|
4203
4481
|
// A found app may predate `ports_exposes` on create; patch a differing one.
|
|
4204
4482
|
const portDiffers = app.existed &&
|
|
4205
4483
|
(await this.ports.coolify.getApplication(app.appId)).ports_exposes !== CALLS_SERVICE_PORT;
|
|
4206
4484
|
await this.ports.coolify.updateApplication(app.appId, {
|
|
4207
4485
|
custom_internal_name: name,
|
|
4486
|
+
...commandHealthCheckSettings(wgetProbe(CALLS_SERVICE_PORT, CALLS_HEALTH_CHECK_PATH)),
|
|
4208
4487
|
...(portDiffers ? { ports_exposes: CALLS_SERVICE_PORT } : {}),
|
|
4209
4488
|
});
|
|
4210
4489
|
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
@@ -4707,7 +4986,29 @@ export class FoundryStepExecutor {
|
|
|
4707
4986
|
const appKey = await this.ensureAuthAppKey(app.appId);
|
|
4708
4987
|
if (appKey)
|
|
4709
4988
|
envs.push({ key: 'AUTH_APP_KEY', value: appKey });
|
|
4710
|
-
|
|
4989
|
+
// Archon's way into this Boule's people store: only the platform's own
|
|
4990
|
+
// org's Boule gets it, minted once and never rotated (the role Boule's boot
|
|
4991
|
+
// creates from it would otherwise keep the old password).
|
|
4992
|
+
const peerPassword = action.desired.platformOwner === true ? await this.ensurePeerPassword(app.appId) : null;
|
|
4993
|
+
if (peerPassword) {
|
|
4994
|
+
this.handledSecrets.add(peerPassword);
|
|
4995
|
+
envs.push({ key: BOULE_PEER_PASSWORD_ENV, value: peerPassword });
|
|
4996
|
+
}
|
|
4997
|
+
// Archon's invite form calls Boule with this secret; same rule as the password.
|
|
4998
|
+
const bridgeSecret = action.desired.platformOwner === true ? await this.ensureBridgeSecret(app.appId) : null;
|
|
4999
|
+
if (bridgeSecret) {
|
|
5000
|
+
this.handledSecrets.add(bridgeSecret);
|
|
5001
|
+
envs.push({ key: BOULE_BRIDGE_SECRET_ENV, value: bridgeSecret });
|
|
5002
|
+
}
|
|
5003
|
+
try {
|
|
5004
|
+
await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
|
|
5005
|
+
}
|
|
5006
|
+
catch (error) {
|
|
5007
|
+
const minted = [peerPassword, bridgeSecret].filter((v) => v !== null);
|
|
5008
|
+
if (minted.length === 0)
|
|
5009
|
+
throw error;
|
|
5010
|
+
throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), minted));
|
|
5011
|
+
}
|
|
4711
5012
|
const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName);
|
|
4712
5013
|
if (!dbPush.pushed) {
|
|
4713
5014
|
throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
@@ -4723,6 +5024,8 @@ export class FoundryStepExecutor {
|
|
|
4723
5024
|
// that a previous apply created but crashed before deploying) sat built
|
|
4724
5025
|
// but never started. Deployed once this executor has confirmed, never
|
|
4725
5026
|
// again -- a live app is only ever restarted from here on.
|
|
5027
|
+
const secretsNotRestarted = action.desired.platformOwner === true &&
|
|
5028
|
+
!context.recordedIds()[BOULE_SECRETS_RESTARTED_SCOPE_KEY];
|
|
4726
5029
|
if (!context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]) {
|
|
4727
5030
|
// #543: every deploy waits for health -- Boule's own public hostname.
|
|
4728
5031
|
const runtime = this.ports.runtime ?? {};
|
|
@@ -4731,10 +5034,21 @@ export class FoundryStepExecutor {
|
|
|
4731
5034
|
}
|
|
4732
5035
|
else if (app.existed &&
|
|
4733
5036
|
!mail.restarted &&
|
|
4734
|
-
(envChanged ||
|
|
5037
|
+
(envChanged ||
|
|
5038
|
+
cookieSecret !== null ||
|
|
5039
|
+
appKey !== null ||
|
|
5040
|
+
peerPassword !== null ||
|
|
5041
|
+
bridgeSecret !== null ||
|
|
5042
|
+
secretsNotRestarted ||
|
|
5043
|
+
dbPush.changed ||
|
|
5044
|
+
mail.changed)) {
|
|
4735
5045
|
// A restart inside the mail rule already came after every env write above.
|
|
4736
5046
|
await this.ports.coolify.restart(app.appId);
|
|
4737
5047
|
}
|
|
5048
|
+
// Last, after the restart or deploy: a run that failed before here repeats it, minting nothing.
|
|
5049
|
+
if (action.desired.platformOwner === true) {
|
|
5050
|
+
context.recordScopeId(BOULE_SECRETS_RESTARTED_SCOPE_KEY, '1');
|
|
5051
|
+
}
|
|
4738
5052
|
return { resourceId: app.appId };
|
|
4739
5053
|
}
|
|
4740
5054
|
/**
|
|
@@ -4896,6 +5210,349 @@ export class FoundryStepExecutor {
|
|
|
4896
5210
|
}
|
|
4897
5211
|
return port;
|
|
4898
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
|
+
}
|
|
4899
5556
|
async jobsRegistration(action, org, context) {
|
|
4900
5557
|
const scheduler = await this.jobsPort(org);
|
|
4901
5558
|
const name = action.desired.name;
|
|
@@ -5633,6 +6290,11 @@ export class FoundryStepExecutor {
|
|
|
5633
6290
|
* #491: when `mailDomain` is the platform's own apex, this must never
|
|
5634
6291
|
* delete the platform's own project (the delete loop skips it by id) or
|
|
5635
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.
|
|
5636
6298
|
*/
|
|
5637
6299
|
async parkLettermintMail(org, mailOrg, context, platformApex, platformLettermintProjectId) {
|
|
5638
6300
|
const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
@@ -5642,7 +6304,13 @@ export class FoundryStepExecutor {
|
|
|
5642
6304
|
// #345: `mailOrg.domain` is absent for a level-1 org -- the previously
|
|
5643
6305
|
// recorded mail domain is the one `MailPort.removeOrgRelay` needs.
|
|
5644
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());
|
|
5645
6311
|
const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
|
|
6312
|
+
if (!ownSignIn)
|
|
6313
|
+
names.add(`${org.slug}-auth`);
|
|
5646
6314
|
const projects = await this.ports.lettermint.listProjects();
|
|
5647
6315
|
for (const project of projects) {
|
|
5648
6316
|
if (names.has(project.name) && project.id !== platformLettermintProjectId) {
|
|
@@ -5651,6 +6319,8 @@ export class FoundryStepExecutor {
|
|
|
5651
6319
|
}
|
|
5652
6320
|
if (isPlatformApexOrg)
|
|
5653
6321
|
return;
|
|
6322
|
+
if (ownSignIn && mailDomain.toLowerCase() === org.domain?.toLowerCase())
|
|
6323
|
+
return;
|
|
5654
6324
|
const domain = await this.findExactLettermintDomain(mailDomain);
|
|
5655
6325
|
if (domain)
|
|
5656
6326
|
await this.ports.lettermint.deleteDomain(domain.id);
|
|
@@ -6653,6 +7323,23 @@ export class FoundryStepExecutor {
|
|
|
6653
7323
|
case 'coolify:database':
|
|
6654
7324
|
await this.ports.coolify.deleteDatabase(target.id);
|
|
6655
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
|
+
}
|
|
6656
7343
|
case 'zitadel:app': {
|
|
6657
7344
|
const zitadelOrgId = recordedIds.zitadel_org;
|
|
6658
7345
|
const projectId = recordedIds.zitadel_project;
|