@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
|
@@ -9,16 +9,21 @@ import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
|
9
9
|
import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
|
|
10
10
|
import { ACCESS_POLICY_NAME, coolifyProjectMark, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
|
|
11
11
|
import { ACCESS_PROVIDER_RECORD_KEY, GATE_CLAIM_NAME } from './gates.js';
|
|
12
|
-
import { healthCheckSettings } from './health-check-settings.js';
|
|
12
|
+
import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, tcpProbe, } from './health-check-settings.js';
|
|
13
|
+
import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
|
|
14
|
+
export { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
|
|
13
15
|
import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
|
|
14
|
-
import {
|
|
16
|
+
import { MAIL_RELAY_BLOCKED, provisionMailRelay } from './mail-relay.js';
|
|
17
|
+
import { recordOwnDnsHostname } from './own-dns.js';
|
|
18
|
+
import { platformNetwork, platformTarget, scopeFromPlatform, } from './own-services.js';
|
|
19
|
+
import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_HOST_PORTS_DIRECT, PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY, PLATFORM_OWNER_ORG_SCOPE_KEY, PLATFORM_SFU_APPLIED_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_ZITADEL_ORG_SCOPE_KEY, archonAuthOrganizationId, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailHostPorts, platformMailMxHost, uptimeAccessTokenConflict, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
|
|
15
20
|
import { ensureSignInOrg, inviteOwnerWithLink, signInOrgDomain } from './sign-in-org.js';
|
|
16
21
|
export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
|
|
17
22
|
import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
|
|
18
23
|
import { heldUptimeValues, provisionPlatformUptime, } from './uptime-worker.js';
|
|
19
|
-
import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
|
|
24
|
+
import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, waitForSystemApi, } from './zitadel-system-api.js';
|
|
20
25
|
/** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
|
|
21
|
-
function apexZoneKey(apex) {
|
|
26
|
+
export function apexZoneKey(apex) {
|
|
22
27
|
return `cloudflare:zone:${apex}`;
|
|
23
28
|
}
|
|
24
29
|
/** The key `runPlatformSteps` records the jobs scheduler app's id under (`system:kind:key`, #173). */
|
|
@@ -29,7 +34,7 @@ const JOBS_APP_KEY = `coolify:jobs:${JOBS_SCHEDULER_KEY}`;
|
|
|
29
34
|
* those exist. Edge's step runs first and records the address
|
|
30
35
|
* (`planPlatform`), so a missing one means that step has not finished.
|
|
31
36
|
*/
|
|
32
|
-
async function platformDns(deps, recorded, step) {
|
|
37
|
+
export async function platformDns(deps, recorded, step) {
|
|
33
38
|
const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
|
|
34
39
|
if (!edgeUrl) {
|
|
35
40
|
throw new Error(`${step}: edge has not been applied yet (no recorded ${PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}); platform:edge runs first and every DNS record is written through it`);
|
|
@@ -37,7 +42,7 @@ async function platformDns(deps, recorded, step) {
|
|
|
37
42
|
return deps.dns(edgeUrl);
|
|
38
43
|
}
|
|
39
44
|
/** The tunnel port for a platform step; same address and same reason as {@link platformDns}. */
|
|
40
|
-
async function platformTunnels(deps, recorded, step) {
|
|
45
|
+
export async function platformTunnels(deps, recorded, step) {
|
|
41
46
|
const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
|
|
42
47
|
if (!edgeUrl) {
|
|
43
48
|
throw new Error(`${step}: edge has not been applied yet (no recorded ${PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}); platform:edge runs first and every tunnel is managed through it`);
|
|
@@ -69,16 +74,21 @@ const JOBS_DATABASE_SCOPE_KEY = 'coolify_jobs_database';
|
|
|
69
74
|
* never rotates a token the host already holds.
|
|
70
75
|
*/
|
|
71
76
|
const JOBS_ADMIN_TOKEN_SCOPE_KEY = 'jobs_admin_token_set';
|
|
72
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* An own org's scheduler only: the app's uuid, recorded and persisted before
|
|
79
|
+
* the admin token leaves and cleared once the token is delivered and written.
|
|
80
|
+
* Left set, the planner reports the scheduler blocked and never mints again.
|
|
81
|
+
*/
|
|
82
|
+
export const JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY = 'jobs_admin_token_undelivered';
|
|
83
|
+
/** An own org's scheduler only: the git ref its app last deployed, so a changed declared ref plans the step again. */
|
|
84
|
+
export const JOBS_APPLIED_GIT_REF_SCOPE_KEY = 'jobs_applied_git_ref';
|
|
85
|
+
/** How many times `provisionJobsHost` tries `deliverSecret` before giving up. */
|
|
73
86
|
const JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS = 3;
|
|
74
|
-
/** The ref the scheduler's admin token is delivered under: the host's real `JobsSchedulerPort` adapter reads it back. */
|
|
75
|
-
export const JOBS_ADMIN_TOKEN_REF = 'jobs:scheduler:adminToken';
|
|
76
87
|
/** The name of the Coolify project every platform-level service (starting with jobs, #173) lives in. */
|
|
77
88
|
const PLATFORM_COOLIFY_PROJECT_NAME = 'platform';
|
|
78
89
|
/** https://github.com/wtfalch/jobs -- the scheduler's own repo, Dockerfile at its root (its own `Dockerfile`'s doc comment). */
|
|
79
90
|
const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
|
|
80
|
-
/** The
|
|
81
|
-
const JOBS_INTERNAL_NAME = 'platform-jobs';
|
|
91
|
+
/** The port the scheduler listens on (jobs' `PORT`, default 8080). */
|
|
82
92
|
const JOBS_PORT = '8080';
|
|
83
93
|
/**
|
|
84
94
|
* The real {@link PlatformStepExecutor} for the two steps built so far:
|
|
@@ -223,6 +233,13 @@ export class FoundryPlatformStepExecutor {
|
|
|
223
233
|
if (mailBlocked)
|
|
224
234
|
blocked.mail = mailBlocked;
|
|
225
235
|
}
|
|
236
|
+
if (platform.mail.relay === true) {
|
|
237
|
+
const relayBlocked = this.mail
|
|
238
|
+
? mailRelayBlockedReason()
|
|
239
|
+
: MAIL_RELAY_BLOCKED.executorMissing;
|
|
240
|
+
if (relayBlocked)
|
|
241
|
+
blocked.relay = relayBlocked;
|
|
242
|
+
}
|
|
226
243
|
if (platform.integrations.enabled && !this.integrations) {
|
|
227
244
|
blocked.integrations =
|
|
228
245
|
'the integrations host is blocked, not planned: the host built the platform executor without its integrations dependencies';
|
|
@@ -628,6 +645,14 @@ export class FoundryPlatformStepExecutor {
|
|
|
628
645
|
}
|
|
629
646
|
return provisionPlatformMail(context, this.mail);
|
|
630
647
|
}
|
|
648
|
+
if (action.system === 'coolify' &&
|
|
649
|
+
action.kind === 'app' &&
|
|
650
|
+
action.key === PLATFORM_MAIL_RELAY_KEY) {
|
|
651
|
+
if (!this.mail) {
|
|
652
|
+
throw new Error('platform:mail-relay needs FoundryPlatformStepExecutor to be constructed with its mail dependencies');
|
|
653
|
+
}
|
|
654
|
+
return provisionPlatformMailRelay(action, context, this.mail);
|
|
655
|
+
}
|
|
631
656
|
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_HC_KEY) {
|
|
632
657
|
if (!this.hc) {
|
|
633
658
|
throw new Error('platform:hc needs FoundryPlatformStepExecutor to be constructed with its hc dependencies (#540)');
|
|
@@ -743,104 +768,148 @@ export class FoundryPlatformStepExecutor {
|
|
|
743
768
|
if (!deliverSecret) {
|
|
744
769
|
throw new Error('platform step coolify:jobs needs a deliverSecret -- construct FoundryPlatformStepExecutor with one, to hand the scheduler admin token to the host (#173)');
|
|
745
770
|
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
if (!projectUuid) {
|
|
751
|
-
const found = await coolify.findProjectByName(PLATFORM_COOLIFY_PROJECT_NAME);
|
|
752
|
-
projectUuid = found
|
|
753
|
-
? found.uuid
|
|
754
|
-
: (await coolify.createProject(PLATFORM_COOLIFY_PROJECT_NAME)).uuid;
|
|
755
|
-
context.recordScopeId(JOBS_PROJECT_SCOPE_KEY, projectUuid);
|
|
756
|
-
}
|
|
757
|
-
const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
758
|
-
let dbId = recorded[JOBS_DATABASE_SCOPE_KEY];
|
|
759
|
-
if (!dbId) {
|
|
760
|
-
const dbName = `${JOBS_SCHEDULER_KEY}-scheduler-db`;
|
|
761
|
-
const found = (await coolify.listDatabases()).find((db) => db.name === dbName && db.environment_id === environment.id);
|
|
762
|
-
if (found) {
|
|
763
|
-
dbId = found.uuid;
|
|
764
|
-
}
|
|
765
|
-
else {
|
|
766
|
-
const server = await resolvePlatformServer(coolify);
|
|
767
|
-
const created = await coolify.createPostgresDatabase({
|
|
768
|
-
project_uuid: projectUuid,
|
|
769
|
-
server_uuid: server.uuid,
|
|
770
|
-
environment_name: environment.name,
|
|
771
|
-
environment_uuid: environment.uuid,
|
|
772
|
-
name: dbName,
|
|
773
|
-
postgres_db: 'jobs',
|
|
774
|
-
postgres_user: 'jobs',
|
|
775
|
-
});
|
|
776
|
-
dbId = created.uuid;
|
|
777
|
-
}
|
|
778
|
-
context.recordScopeId(JOBS_DATABASE_SCOPE_KEY, dbId);
|
|
779
|
-
}
|
|
780
|
-
const existingApp = await coolify.findApplicationByName(JOBS_SCHEDULER_KEY, {
|
|
781
|
-
environmentIds: [environment.id],
|
|
771
|
+
return provisionJobsHost(scopeFromPlatform(context), { coolify, deliverSecret, runtime: this.runtime }, platformTarget(), {
|
|
772
|
+
hostname: action.desired.hostname,
|
|
773
|
+
gitRef: action.desired.gitRef,
|
|
774
|
+
project: platformJobsProject,
|
|
782
775
|
});
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
/** The platform's jobs scheduler lives in the shared `platform` project. */
|
|
779
|
+
const platformJobsProject = {
|
|
780
|
+
async ensure(coolify, scope) {
|
|
781
|
+
const recorded = scope.recordedIds()[JOBS_PROJECT_SCOPE_KEY];
|
|
782
|
+
if (recorded)
|
|
783
|
+
return recorded;
|
|
784
|
+
const found = await coolify.findProjectByName(PLATFORM_COOLIFY_PROJECT_NAME);
|
|
785
|
+
const projectUuid = found
|
|
786
|
+
? found.uuid
|
|
787
|
+
: (await coolify.createProject(PLATFORM_COOLIFY_PROJECT_NAME)).uuid;
|
|
788
|
+
scope.recordScopeId(JOBS_PROJECT_SCOPE_KEY, projectUuid);
|
|
789
|
+
return projectUuid;
|
|
790
|
+
},
|
|
791
|
+
};
|
|
792
|
+
/**
|
|
793
|
+
* Provisions one jobs scheduler (database, app, admin token) for `target`:
|
|
794
|
+
* the platform's, or an own org's. Every name and the held-key ref come from
|
|
795
|
+
* the target.
|
|
796
|
+
*
|
|
797
|
+
* An org target also guards the mint (`CLAUDE.md`, "Platform steps" rules 1
|
|
798
|
+
* and 3): the token's id is recorded as undelivered and persisted before the
|
|
799
|
+
* token leaves, the delivered guard is recorded after the env write, and the
|
|
800
|
+
* applied git ref is recorded so a changed ref plans the step again. The
|
|
801
|
+
* platform's step keeps its provision-only behaviour.
|
|
802
|
+
*/
|
|
803
|
+
export async function provisionJobsHost(scope, deps, target, input) {
|
|
804
|
+
const { coolify, deliverSecret, runtime } = deps;
|
|
805
|
+
const { hostname, gitRef } = input;
|
|
806
|
+
const own = target.kind === 'org';
|
|
807
|
+
const label = own ? `own step coolify:own-jobs for ${target.id}` : 'platform step coolify:jobs';
|
|
808
|
+
const projectUuid = await input.project.ensure(coolify, scope);
|
|
809
|
+
const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
810
|
+
let dbId = scope.recordedIds()[JOBS_DATABASE_SCOPE_KEY];
|
|
811
|
+
if (!dbId) {
|
|
812
|
+
const dbName = target.names.jobsDb;
|
|
813
|
+
const found = (await coolify.listDatabases()).find((db) => db.name === dbName && db.environment_id === environment.id);
|
|
814
|
+
if (found) {
|
|
815
|
+
dbId = found.uuid;
|
|
786
816
|
}
|
|
787
817
|
else {
|
|
788
818
|
const server = await resolvePlatformServer(coolify);
|
|
789
|
-
const
|
|
790
|
-
const created = await coolify.createGithubAppApplication({
|
|
819
|
+
const created = await coolify.createPostgresDatabase({
|
|
791
820
|
project_uuid: projectUuid,
|
|
792
821
|
server_uuid: server.uuid,
|
|
793
822
|
environment_name: environment.name,
|
|
794
823
|
environment_uuid: environment.uuid,
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
build_pack: 'dockerfile',
|
|
799
|
-
name: JOBS_SCHEDULER_KEY,
|
|
800
|
-
domains: coolifyDomains([hostname]),
|
|
824
|
+
name: dbName,
|
|
825
|
+
postgres_db: 'jobs',
|
|
826
|
+
postgres_user: 'jobs',
|
|
801
827
|
});
|
|
802
|
-
|
|
828
|
+
dbId = created.uuid;
|
|
803
829
|
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
830
|
+
scope.recordScopeId(JOBS_DATABASE_SCOPE_KEY, dbId);
|
|
831
|
+
}
|
|
832
|
+
const existingApp = await coolify.findApplicationByName(target.names.jobsApp, {
|
|
833
|
+
environmentIds: [environment.id],
|
|
834
|
+
});
|
|
835
|
+
let appId;
|
|
836
|
+
if (existingApp) {
|
|
837
|
+
appId = existingApp.uuid;
|
|
838
|
+
}
|
|
839
|
+
else {
|
|
840
|
+
const server = await resolvePlatformServer(coolify);
|
|
841
|
+
const githubApp = await coolify.requireGithubApp();
|
|
842
|
+
const created = await coolify.createGithubAppApplication({
|
|
843
|
+
project_uuid: projectUuid,
|
|
844
|
+
server_uuid: server.uuid,
|
|
845
|
+
environment_name: environment.name,
|
|
846
|
+
environment_uuid: environment.uuid,
|
|
847
|
+
github_app_uuid: githubApp.uuid,
|
|
848
|
+
git_repository: JOBS_SCHEDULER_REPO,
|
|
849
|
+
git_branch: gitRef,
|
|
850
|
+
build_pack: 'dockerfile',
|
|
851
|
+
name: target.names.jobsApp,
|
|
852
|
+
...(hostname ? { domains: coolifyDomains([hostname]) } : {}),
|
|
809
853
|
});
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
854
|
+
appId = created.uuid;
|
|
855
|
+
}
|
|
856
|
+
// An org's declared ref is applied to the app it already has; the platform's
|
|
857
|
+
// is a manual redeploy.
|
|
858
|
+
const refChanged = own && Boolean(existingApp) && scope.recordedIds()[JOBS_APPLIED_GIT_REF_SCOPE_KEY] !== gitRef;
|
|
859
|
+
// Set on every run, like `platform:files`' runner: the name another
|
|
860
|
+
// container reaches the scheduler by. It only takes effect on a deploy.
|
|
861
|
+
await coolify.updateApplication(appId, {
|
|
862
|
+
custom_internal_name: target.internal.jobs,
|
|
863
|
+
ports_exposes: JOBS_PORT,
|
|
864
|
+
// A connect, not a GET: every route but the admin one answers 401, 404 or 405, which a GET check fails.
|
|
865
|
+
...commandHealthCheckSettings(tcpProbe(JOBS_PORT)),
|
|
866
|
+
...(refChanged ? { git_branch: gitRef } : {}),
|
|
867
|
+
});
|
|
868
|
+
const db = await getDatabaseStarted(coolify, dbId);
|
|
869
|
+
if (!db.internal_db_url) {
|
|
870
|
+
throw new Error(`${label}: database ${dbId} has no internal_db_url yet; not deploying the scheduler without a database`);
|
|
871
|
+
}
|
|
872
|
+
// The scheduler migrates as `DATABASE_URL_OWNER` and creates its own
|
|
873
|
+
// `jobs_rt` role from `DATABASE_URL` (jobs README, "Deploying"). An app
|
|
874
|
+
// with no owner URL still holds the owner's URL in `DATABASE_URL`, so
|
|
875
|
+
// that is never reused: a runtime URL is minted, once, and kept after.
|
|
876
|
+
const hasOwnerUrl = (await coolify.listEnvs(appId)).some((e) => e.key === 'DATABASE_URL_OWNER' && !e.is_preview && e.value);
|
|
877
|
+
const runtimeUrl = await ensureAiRuntimeUrl(coolify, hasOwnerUrl ? [appId] : [], db.internal_db_url, label);
|
|
878
|
+
const envs = [
|
|
879
|
+
{ key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
|
|
880
|
+
{ key: 'DATABASE_URL', value: runtimeUrl },
|
|
881
|
+
];
|
|
882
|
+
// Whether the scheduler runs on envs other than the ones written below.
|
|
883
|
+
// Marked and persisted before the first write, and cleared only after the
|
|
884
|
+
// deploy, so a run that dies in between leaves the mark and the next run
|
|
885
|
+
// deploys again (`CLAUDE.md` rule 4).
|
|
886
|
+
const pending = Boolean(scope.recordedIds()[JOBS_DEPLOY_PENDING_SCOPE_KEY]) ||
|
|
887
|
+
refChanged ||
|
|
888
|
+
(await envsDiffer(coolify, appId, envs));
|
|
889
|
+
if (pending) {
|
|
890
|
+
scope.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '1');
|
|
891
|
+
await scope.persist();
|
|
892
|
+
}
|
|
893
|
+
// SCHEDULER_ADMIN_TOKEN gates the admin API the org registrations use
|
|
894
|
+
// (jobs README, "The admin API"); unset, every admin route answers 401.
|
|
895
|
+
// Delivered to the host before it is written to the app.
|
|
896
|
+
let tokenJustSet = false;
|
|
897
|
+
if (!scope.recordedIds()[JOBS_ADMIN_TOKEN_SCOPE_KEY]) {
|
|
898
|
+
const lost = own ? scope.recordedIds()[JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY] : undefined;
|
|
899
|
+
const resumed = lost ? await deps.readSecret?.(target.refs.jobsAdminToken) : null;
|
|
900
|
+
if (lost && !resumed) {
|
|
901
|
+
throw new Error(`${label}: a scheduler admin token (${lost}) was minted but never confirmed delivered; remove the recorded key ${JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
902
|
+
}
|
|
903
|
+
const token = resumed || randomBytes(32).toString('hex');
|
|
904
|
+
if (!resumed) {
|
|
905
|
+
if (own) {
|
|
906
|
+
scope.recordScopeId(JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY, appId);
|
|
907
|
+
await scope.persist();
|
|
908
|
+
}
|
|
840
909
|
let delivered = false;
|
|
841
910
|
for (let attempt = 1; attempt <= JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS && !delivered; attempt++) {
|
|
842
911
|
try {
|
|
843
|
-
await deliverSecret(
|
|
912
|
+
await deliverSecret(target.refs.jobsAdminToken, token);
|
|
844
913
|
delivered = true;
|
|
845
914
|
}
|
|
846
915
|
catch {
|
|
@@ -848,48 +917,48 @@ export class FoundryPlatformStepExecutor {
|
|
|
848
917
|
}
|
|
849
918
|
}
|
|
850
919
|
if (!delivered) {
|
|
851
|
-
throw new Error(
|
|
920
|
+
throw new Error(`${label}: the scheduler admin token could not be delivered after ${JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS} attempts`);
|
|
852
921
|
}
|
|
853
|
-
envs.push({ key: 'SCHEDULER_ADMIN_TOKEN', value: token });
|
|
854
|
-
await coolify.bulkUpsertEnvs(appId, envs);
|
|
855
|
-
context.recordScopeId(JOBS_ADMIN_TOKEN_SCOPE_KEY, appId);
|
|
856
|
-
tokenJustSet = true;
|
|
857
922
|
}
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
const running = (await coolify.getApplication(appId)).status?.startsWith('running') ?? false;
|
|
865
|
-
// An app applied before its internal address was recorded has no fixed
|
|
866
|
-
// name yet, so it deploys once to get it; after that it deploys on the
|
|
867
|
-
// other conditions only.
|
|
868
|
-
const addressNew = !context.recordedIds()[JOBS_INTERNAL_URL_SCOPE_KEY];
|
|
869
|
-
// The address is recorded only once the deployment finished: the scheduler
|
|
870
|
-
// has no route that answers without its admin token (every other path is a
|
|
871
|
-
// 401, 404 or 405), so the deployment record is the whole check, as for
|
|
872
|
-
// the AI worker. A deploy that fails or times out throws here, records
|
|
873
|
-
// nothing and leaves the step planned.
|
|
874
|
-
if (!existingApp || tokenJustSet || pending || !running || addressNew) {
|
|
875
|
-
await waitForNewDeployment(coolify, appId, { kind: 'deployment-only' }, this.runtime, this.runtime.sleep ?? defaultSleep, 'platform step coolify:jobs', 'the scheduler keeps its old address');
|
|
876
|
-
}
|
|
877
|
-
if (pending) {
|
|
878
|
-
context.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '');
|
|
879
|
-
await context.persist?.();
|
|
880
|
-
}
|
|
881
|
-
context.recordScopeId(JOBS_INTERNAL_URL_SCOPE_KEY, `http://${JOBS_INTERNAL_NAME}:${JOBS_PORT}`);
|
|
882
|
-
return { resourceId: appId };
|
|
923
|
+
envs.push({ key: 'SCHEDULER_ADMIN_TOKEN', value: token });
|
|
924
|
+
await coolify.bulkUpsertEnvs(appId, envs);
|
|
925
|
+
scope.recordScopeId(JOBS_ADMIN_TOKEN_SCOPE_KEY, appId);
|
|
926
|
+
if (own)
|
|
927
|
+
scope.recordScopeId(JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY, '');
|
|
928
|
+
tokenJustSet = true;
|
|
883
929
|
}
|
|
930
|
+
else {
|
|
931
|
+
await coolify.bulkUpsertEnvs(appId, envs);
|
|
932
|
+
}
|
|
933
|
+
// The app is created with `instant_deploy: false` and an env change needs
|
|
934
|
+
// a redeploy, so deploy after a create or a new token. Also when it is not
|
|
935
|
+
// running, so a deploy that failed on an earlier run is retried.
|
|
936
|
+
const running = (await coolify.getApplication(appId)).status?.startsWith('running') ?? false;
|
|
937
|
+
// An app applied before its internal address was recorded has no fixed
|
|
938
|
+
// name yet, so it deploys once to get it; after that it deploys on the
|
|
939
|
+
// other conditions only.
|
|
940
|
+
const addressNew = !scope.recordedIds()[JOBS_INTERNAL_URL_SCOPE_KEY];
|
|
941
|
+
// The address is recorded only once the deployment finished: the scheduler
|
|
942
|
+
// has no route that answers without its admin token (every other path is a
|
|
943
|
+
// 401, 404 or 405), so the deployment record is the whole check, as for
|
|
944
|
+
// the AI worker. A deploy that fails or times out throws here, records
|
|
945
|
+
// nothing and leaves the step planned.
|
|
946
|
+
if (!existingApp || tokenJustSet || pending || !running || addressNew) {
|
|
947
|
+
await waitForNewDeployment(coolify, appId, { kind: 'deployment-only' }, runtime, runtime.sleep ?? defaultSleep, label, 'the scheduler keeps its old address');
|
|
948
|
+
}
|
|
949
|
+
if (pending) {
|
|
950
|
+
scope.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '');
|
|
951
|
+
await scope.persist();
|
|
952
|
+
}
|
|
953
|
+
if (own)
|
|
954
|
+
scope.recordScopeId(JOBS_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
955
|
+
scope.recordScopeId(JOBS_INTERNAL_URL_SCOPE_KEY, `http://${target.internal.jobs}:${JOBS_PORT}`);
|
|
956
|
+
return { resourceId: appId };
|
|
884
957
|
}
|
|
885
958
|
// ---------------------------------------------------------------------------
|
|
886
959
|
// #168: the platform's own sign-in (ZITADEL)
|
|
887
960
|
// ---------------------------------------------------------------------------
|
|
888
961
|
const PLATFORM_AUTH_PROJECT = 'platform';
|
|
889
|
-
const PLATFORM_AUTH_DB_NAME = 'platform-auth-db';
|
|
890
|
-
const PLATFORM_AUTH_ZITADEL_APP_NAME = 'platform-auth-zitadel';
|
|
891
|
-
const PLATFORM_AUTH_LOGIN_APP_NAME = 'platform-auth-login';
|
|
892
|
-
const PLATFORM_AUTH_BROKER_APP_NAME = 'platform-auth-broker';
|
|
893
962
|
/**
|
|
894
963
|
* Fixed `custom_internal_name`s (`diff.ts`'s `coolifyCalls` sets the same
|
|
895
964
|
* kind of name on Synapse/MAS) so the Cloudflare tunnel below can address
|
|
@@ -897,12 +966,7 @@ const PLATFORM_AUTH_BROKER_APP_NAME = 'platform-auth-broker';
|
|
|
897
966
|
* -- bypassing Coolify's own Traefik, which dispatches purely by `Host`
|
|
898
967
|
* header and so cannot give two different apps the same public hostname.
|
|
899
968
|
*/
|
|
900
|
-
const PLATFORM_AUTH_ZITADEL_INTERNAL_NAME = 'platform-auth-zitadel';
|
|
901
969
|
export const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = 'platform-auth-login';
|
|
902
|
-
const PLATFORM_AUTH_BROKER_INTERNAL_NAME = 'platform-auth-broker';
|
|
903
|
-
const PLATFORM_AUTH_TUNNEL_NAME = 'platform-auth-tunnel';
|
|
904
|
-
/** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_AUTH_TUNNEL_NAME}. */
|
|
905
|
-
const PLATFORM_AUTH_CLOUDFLARED_APP_NAME = 'platform-auth-cloudflared';
|
|
906
970
|
/** `wtfalch/auth`'s `docker-compose.yml`: `image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION:-v4.17.3}`. Pinned in `versions.json` (#247). */
|
|
907
971
|
const AUTH_ZITADEL_IMAGE = 'ghcr.io/zitadel/zitadel';
|
|
908
972
|
const AUTH_ZITADEL_IMAGE_TAG = VERSIONS.zitadel;
|
|
@@ -917,6 +981,22 @@ const AUTH_BROKER_DOCKERFILE = '/broker/Dockerfile';
|
|
|
917
981
|
* own `ZITADEL_PORT: 8080` and the login image's own `EXPOSE`.
|
|
918
982
|
*/
|
|
919
983
|
const AUTH_ZITADEL_PORT = '8080';
|
|
984
|
+
/**
|
|
985
|
+
* The inline Dockerfile of the ZITADEL app. The published image is distroless (no shell, no `curl`) and has no
|
|
986
|
+
* HEALTHCHECK, and Coolify runs its own check as `sh -c`. So the app is a Dockerfile app: the image's own `ready`
|
|
987
|
+
* command is its exec-form HEALTHCHECK, which Coolify keeps while its own check is off. `ready` asks the port and
|
|
988
|
+
* TLS mode from the same `ZITADEL_*` settings the server reads. The start period covers the first run, which sets
|
|
989
|
+
* up the database and the first instance before it listens. The start command is the one
|
|
990
|
+
* {@link AUTH_ZITADEL_RUN_OPTIONS} names.
|
|
991
|
+
*/
|
|
992
|
+
export function zitadelDockerfile() {
|
|
993
|
+
return [
|
|
994
|
+
`FROM ${AUTH_ZITADEL_IMAGE}:${AUTH_ZITADEL_IMAGE_TAG}`,
|
|
995
|
+
`EXPOSE ${AUTH_ZITADEL_PORT}`,
|
|
996
|
+
'CMD ["start-from-init", "--masterkeyFromEnv", "--tlsMode", "external"]',
|
|
997
|
+
'HEALTHCHECK --interval=15s --timeout=5s --start-period=180s --retries=5 CMD ["/app/zitadel", "ready"]',
|
|
998
|
+
].join('\n');
|
|
999
|
+
}
|
|
920
1000
|
export const AUTH_LOGIN_PORT = '3000';
|
|
921
1001
|
/** `broker/Dockerfile`'s own `ENV PORT=3000` / `EXPOSE 3000`. */
|
|
922
1002
|
const AUTH_BROKER_PORT = '3000';
|
|
@@ -942,8 +1022,8 @@ export const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|sy
|
|
|
942
1022
|
const AUTH_BROKER_PATH_PATTERN = '^/api(/|$)';
|
|
943
1023
|
const PLATFORM_AUTH_PROJECT_SCOPE_KEY = 'coolify_project_platform_auth';
|
|
944
1024
|
const PLATFORM_AUTH_DB_SCOPE_KEY = 'coolify_platform_auth_database';
|
|
945
|
-
const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = 'coolify_platform_auth_zitadel_app';
|
|
946
|
-
const PLATFORM_AUTH_LOGIN_SCOPE_KEY = 'coolify_platform_auth_login_app';
|
|
1025
|
+
export const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = 'coolify_platform_auth_zitadel_app';
|
|
1026
|
+
export const PLATFORM_AUTH_LOGIN_SCOPE_KEY = 'coolify_platform_auth_login_app';
|
|
947
1027
|
const PLATFORM_AUTH_TUNNEL_SCOPE_KEY = 'cloudflare_platform_auth_tunnel';
|
|
948
1028
|
/** Recorded once the ZITADEL app has been deployed, so ZITADEL may have written to its database. */
|
|
949
1029
|
const PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY = 'zitadel_platform_auth_deployed';
|
|
@@ -1072,6 +1152,10 @@ function loginClientRecordValue(userId, patId, token) {
|
|
|
1072
1152
|
* miss for a credential-liveness check than for Archon's own Access policy.
|
|
1073
1153
|
*/
|
|
1074
1154
|
async function authLoginClientDrifted(platform, auth, recordedIds) {
|
|
1155
|
+
return authLoginClientDriftedAt({ hostname: platform.auth.hostname }, auth, recordedIds);
|
|
1156
|
+
}
|
|
1157
|
+
/** {@link authLoginClientDrifted} for any instance; `bearerRef` is passed to `zitadelForInstance` only when set. */
|
|
1158
|
+
async function authLoginClientDriftedAt(instance, auth, recordedIds) {
|
|
1075
1159
|
const record = parseLoginClientRecord(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
|
|
1076
1160
|
if (!record)
|
|
1077
1161
|
return false;
|
|
@@ -1081,7 +1165,10 @@ async function authLoginClientDrifted(platform, auth, recordedIds) {
|
|
|
1081
1165
|
if (!orgId || !loginAppId || !brokerAppId)
|
|
1082
1166
|
return true;
|
|
1083
1167
|
try {
|
|
1084
|
-
const
|
|
1168
|
+
const issuer = `https://${instance.hostname}`;
|
|
1169
|
+
const zitadel = (instance.bearerRef && instance.bearerRef !== PLATFORM_AUTH_INSTANCE_BEARER_REF
|
|
1170
|
+
? auth.zitadelForInstance?.(issuer, instance.bearerRef)
|
|
1171
|
+
: auth.zitadelForInstance?.(issuer)) ?? auth.zitadel;
|
|
1085
1172
|
const livePats = await zitadel.listLoginClientPats(orgId, record.userId);
|
|
1086
1173
|
if (livePats.length !== 1 || livePats[0] !== record.patId)
|
|
1087
1174
|
return true;
|
|
@@ -1240,28 +1327,33 @@ async function liveLoginClientTokenHash(coolify, appId) {
|
|
|
1240
1327
|
* #197: a `cloudflared` connector for this tunnel is run
|
|
1241
1328
|
* ({@link ensureCloudflaredConnector}), so `hostname` answers.
|
|
1242
1329
|
*/
|
|
1243
|
-
async function
|
|
1244
|
-
const {
|
|
1330
|
+
export async function provisionAuthStack(scope, deps, target, input) {
|
|
1331
|
+
const { hostname, apex, gitRef, ownerCompany, network } = input;
|
|
1245
1332
|
const owners = ownerCompany?.owners ?? [];
|
|
1246
|
-
const
|
|
1247
|
-
const gitRef = platform.auth.gitRef;
|
|
1248
|
-
const recorded = context.recordedIds();
|
|
1333
|
+
const recorded = scope.recordedIds();
|
|
1249
1334
|
const runtime = deps.runtime ?? {};
|
|
1250
1335
|
const sleep = runtime.sleep ?? defaultSleep;
|
|
1336
|
+
const label = target.kind === 'platform' ? 'platform:auth' : `${target.id}:auth`;
|
|
1337
|
+
const subject = target.kind === 'platform' ? 'the platform' : target.id;
|
|
1338
|
+
const issuer = `https://${hostname}`;
|
|
1339
|
+
const instanceZitadel = () => {
|
|
1340
|
+
const forInstance = target.refs.instanceBearer === PLATFORM_AUTH_INSTANCE_BEARER_REF
|
|
1341
|
+
? deps.zitadelForInstance?.(issuer)
|
|
1342
|
+
: deps.zitadelForInstance?.(issuer, target.refs.instanceBearer);
|
|
1343
|
+
if (forInstance)
|
|
1344
|
+
return forInstance;
|
|
1345
|
+
if (target.kind === 'org') {
|
|
1346
|
+
throw new Error(`own step coolify:own-auth for ${target.id}: the host supplies no ZITADEL port for the company's own instance`);
|
|
1347
|
+
}
|
|
1348
|
+
return deps.zitadel;
|
|
1349
|
+
};
|
|
1251
1350
|
// Records an id and writes it out now, so a crash before the step ends
|
|
1252
1351
|
// cannot lose a resource a provider already created.
|
|
1253
1352
|
const record = async (key, id) => {
|
|
1254
|
-
|
|
1255
|
-
await
|
|
1353
|
+
scope.recordScopeId(key, id);
|
|
1354
|
+
await scope.persist();
|
|
1256
1355
|
};
|
|
1257
|
-
|
|
1258
|
-
if (!projectUuid) {
|
|
1259
|
-
const found = await deps.coolify.findProjectByName(PLATFORM_AUTH_PROJECT);
|
|
1260
|
-
projectUuid = found
|
|
1261
|
-
? found.uuid
|
|
1262
|
-
: (await deps.coolify.createProject(PLATFORM_AUTH_PROJECT)).uuid;
|
|
1263
|
-
await record(PLATFORM_AUTH_PROJECT_SCOPE_KEY, projectUuid);
|
|
1264
|
-
}
|
|
1356
|
+
const projectUuid = await input.project.ensure(deps.coolify, scope);
|
|
1265
1357
|
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1266
1358
|
// ---- database ----------------------------------------------------------
|
|
1267
1359
|
let dbId = recorded[PLATFORM_AUTH_DB_SCOPE_KEY];
|
|
@@ -1277,7 +1369,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1277
1369
|
server_uuid: server.uuid,
|
|
1278
1370
|
environment_name: environment.name,
|
|
1279
1371
|
environment_uuid: environment.uuid,
|
|
1280
|
-
name:
|
|
1372
|
+
name: target.names.authDb,
|
|
1281
1373
|
postgres_db: 'zitadel',
|
|
1282
1374
|
postgres_user: 'zitadel',
|
|
1283
1375
|
});
|
|
@@ -1290,22 +1382,19 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1290
1382
|
// `coolifyCalls` follows the same rule for its own databases): Coolify
|
|
1291
1383
|
// can take a moment to report a freshly created database's
|
|
1292
1384
|
// `internal_db_url`, so the next apply retries from a clean state.
|
|
1293
|
-
throw new Error(
|
|
1385
|
+
throw new Error(`coolify:app for ${subject} (auth): the database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
1294
1386
|
}
|
|
1295
1387
|
// ---- the ZITADEL app itself ---------------------------------------------
|
|
1296
|
-
const zitadelApp = await findOrCreatePlatformApp(deps.coolify,
|
|
1388
|
+
const zitadelApp = await findOrCreatePlatformApp(deps.coolify, target.names.authZitadel, environment, async () => {
|
|
1297
1389
|
const server = await resolvePlatformServer(deps.coolify);
|
|
1298
|
-
return deps.coolify.
|
|
1390
|
+
return deps.coolify.createDockerfileApplication({
|
|
1299
1391
|
project_uuid: projectUuid,
|
|
1300
1392
|
server_uuid: server.uuid,
|
|
1301
1393
|
environment_name: environment.name,
|
|
1302
1394
|
environment_uuid: environment.uuid,
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
custom_docker_run_options: AUTH_ZITADEL_RUN_OPTIONS,
|
|
1307
|
-
name: PLATFORM_AUTH_ZITADEL_APP_NAME,
|
|
1308
|
-
description: `ZITADEL for the platform's own sign-in (#168, ${AUTH_ZITADEL_IMAGE}:${AUTH_ZITADEL_IMAGE_TAG})`,
|
|
1395
|
+
dockerfile: zitadelDockerfile(),
|
|
1396
|
+
name: target.names.authZitadel,
|
|
1397
|
+
description: `ZITADEL for ${target.kind === 'platform' ? "the platform's" : `${target.id}'s`} own sign-in (#168, ${AUTH_ZITADEL_IMAGE}:${AUTH_ZITADEL_IMAGE_TAG})`,
|
|
1309
1398
|
instant_deploy: false,
|
|
1310
1399
|
});
|
|
1311
1400
|
});
|
|
@@ -1315,14 +1404,21 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1315
1404
|
// create must not leave the app unnamed for good, and the tunnel addresses
|
|
1316
1405
|
// the app by this name. (The port cannot read the current name back, and
|
|
1317
1406
|
// setting the same value again is harmless.)
|
|
1318
|
-
// The run options go in the same patch, so an app made before they existed
|
|
1319
|
-
// gets them too.
|
|
1320
|
-
await deps.coolify.updateApplication(zitadelAppId, {
|
|
1321
|
-
custom_internal_name: PLATFORM_AUTH_ZITADEL_INTERNAL_NAME,
|
|
1322
|
-
custom_docker_run_options: AUTH_ZITADEL_RUN_OPTIONS,
|
|
1323
|
-
});
|
|
1324
1407
|
// The container only takes new run options at its next deploy.
|
|
1325
1408
|
const zitadelRunOptionsNew = recorded[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] !== AUTH_ZITADEL_RUN_OPTIONS;
|
|
1409
|
+
// A new app is a Dockerfile app that starts with `zitadelDockerfile()`'s CMD, so it gets no run options.
|
|
1410
|
+
// An app made before that is an image app, found by name and never replaced: it keeps its run options (sent
|
|
1411
|
+
// here until the deploy that applied them is recorded), has no Docker-level check, and takes none until its
|
|
1412
|
+
// organisation is provisioned again from zero. The Coolify check stays off on both: the Dockerfile's
|
|
1413
|
+
// HEALTHCHECK is the live one, and Coolify's rolling update follows it.
|
|
1414
|
+
await deps.coolify.updateApplication(zitadelAppId, {
|
|
1415
|
+
custom_internal_name: target.internal.authZitadel,
|
|
1416
|
+
ports_exposes: AUTH_ZITADEL_PORT,
|
|
1417
|
+
health_check_enabled: false,
|
|
1418
|
+
...(zitadelRunOptionsNew && !zitadelApp.justCreated
|
|
1419
|
+
? { custom_docker_run_options: AUTH_ZITADEL_RUN_OPTIONS }
|
|
1420
|
+
: {}),
|
|
1421
|
+
});
|
|
1326
1422
|
// #251: only on create, never unconditionally -- nothing here patches
|
|
1327
1423
|
// `docker_registry_image_tag` on an app `findOrCreatePlatformApp` found
|
|
1328
1424
|
// already running, so that is the only moment this tag is actually live.
|
|
@@ -1333,7 +1429,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1333
1429
|
await record(PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, AUTH_ZITADEL_IMAGE_TAG);
|
|
1334
1430
|
}
|
|
1335
1431
|
// ---- the zitadel-login app -----------------------------------------------
|
|
1336
|
-
const loginApp = await findOrCreatePlatformApp(deps.coolify,
|
|
1432
|
+
const loginApp = await findOrCreatePlatformApp(deps.coolify, target.names.authLogin, environment, async () => {
|
|
1337
1433
|
const server = await resolvePlatformServer(deps.coolify);
|
|
1338
1434
|
const githubApp = await deps.coolify.requireGithubApp();
|
|
1339
1435
|
return deps.coolify.createGithubAppApplication({
|
|
@@ -1345,17 +1441,19 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1345
1441
|
git_repository: AUTH_REPO,
|
|
1346
1442
|
git_branch: gitRef,
|
|
1347
1443
|
build_pack: 'dockerfile',
|
|
1348
|
-
name:
|
|
1444
|
+
name: target.names.authLogin,
|
|
1349
1445
|
dockerfile_location: AUTH_LOGIN_DOCKERFILE,
|
|
1350
1446
|
});
|
|
1351
1447
|
});
|
|
1352
1448
|
const loginAppId = loginApp.appId;
|
|
1353
1449
|
await record(PLATFORM_AUTH_LOGIN_SCOPE_KEY, loginAppId);
|
|
1450
|
+
// The Dockerfile's own HEALTHCHECK is the live check (`IMAGE_HEALTHCHECK_SETTINGS`).
|
|
1354
1451
|
await deps.coolify.updateApplication(loginAppId, {
|
|
1355
|
-
custom_internal_name:
|
|
1452
|
+
custom_internal_name: target.internal.authLogin,
|
|
1453
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
1356
1454
|
});
|
|
1357
1455
|
// ---- the broker app (#204) -------------------------------------------------
|
|
1358
|
-
const brokerApp = await findOrCreatePlatformApp(deps.coolify,
|
|
1456
|
+
const brokerApp = await findOrCreatePlatformApp(deps.coolify, target.names.authBroker, environment, async () => {
|
|
1359
1457
|
const server = await resolvePlatformServer(deps.coolify);
|
|
1360
1458
|
const githubApp = await deps.coolify.requireGithubApp();
|
|
1361
1459
|
return deps.coolify.createGithubAppApplication({
|
|
@@ -1367,21 +1465,24 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1367
1465
|
git_repository: AUTH_REPO,
|
|
1368
1466
|
git_branch: gitRef,
|
|
1369
1467
|
build_pack: 'dockerfile',
|
|
1370
|
-
name:
|
|
1468
|
+
name: target.names.authBroker,
|
|
1371
1469
|
dockerfile_location: AUTH_BROKER_DOCKERFILE,
|
|
1372
1470
|
});
|
|
1373
1471
|
});
|
|
1374
1472
|
const brokerAppId = brokerApp.appId;
|
|
1375
1473
|
await record(PLATFORM_AUTH_BROKER_SCOPE_KEY, brokerAppId);
|
|
1376
1474
|
await deps.coolify.updateApplication(brokerAppId, {
|
|
1377
|
-
custom_internal_name:
|
|
1475
|
+
custom_internal_name: target.internal.authBroker,
|
|
1476
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
1378
1477
|
});
|
|
1379
1478
|
// ---- env -----------------------------------------------------------------
|
|
1380
1479
|
// Generated once, handed straight to `bulkUpsertEnvs` and nowhere else --
|
|
1381
1480
|
// never returned, recorded, logged or interpolated into a thrown error, the
|
|
1382
1481
|
// same rule `executor.ts`'s `ensureAuthCookieSecret` follows for a value
|
|
1383
1482
|
// this package mints itself rather than receiving from a provider.
|
|
1384
|
-
const masterkey = await ensureMasterkey(deps.coolify, zitadelAppId, databaseInUse);
|
|
1483
|
+
const masterkey = await ensureMasterkey(deps.coolify, zitadelAppId, databaseInUse, label);
|
|
1484
|
+
if (masterkey)
|
|
1485
|
+
runtime.onSecret?.(masterkey);
|
|
1385
1486
|
// #283: the instance is created once, with the system API, by a platform
|
|
1386
1487
|
// whose admin machine user is not recorded yet (one that already has it was
|
|
1387
1488
|
// built from a first instance and keeps it). The keypair is regenerated on
|
|
@@ -1391,7 +1492,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1391
1492
|
if (lostInstance &&
|
|
1392
1493
|
!recorded[PLATFORM_AUTH_INSTANCE_SCOPE_KEY] &&
|
|
1393
1494
|
!recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
|
|
1394
|
-
throw new Error(instanceWithoutBearerMessage(hostname, `instance ${lostInstance}
|
|
1495
|
+
throw new Error(instanceWithoutBearerMessage(hostname, `instance ${lostInstance}`, label));
|
|
1395
1496
|
}
|
|
1396
1497
|
const needsInstance = !recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY] && !recorded[PLATFORM_AUTH_INSTANCE_SCOPE_KEY];
|
|
1397
1498
|
const systemKeys = needsInstance ? generateSystemApiKeypair() : undefined;
|
|
@@ -1421,6 +1522,10 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1421
1522
|
value: `https://${hostname}/login?samlRequest=`,
|
|
1422
1523
|
},
|
|
1423
1524
|
{ key: 'ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED', value: 'true' },
|
|
1525
|
+
// ZITADEL 4.17.3 logs every mail's template data at warning level ("missing
|
|
1526
|
+
// translation" for `<Type>.Footer`), which holds the one-time invite code.
|
|
1527
|
+
// Errors (a failed SMTP send among them) are still written.
|
|
1528
|
+
{ key: 'ZITADEL_LOG_LEVEL', value: 'error' },
|
|
1424
1529
|
];
|
|
1425
1530
|
if (masterkey)
|
|
1426
1531
|
zitadelEnvs.push({ key: 'ZITADEL_MASTERKEY', value: masterkey });
|
|
@@ -1433,7 +1538,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1433
1538
|
const loginEnvs = [
|
|
1434
1539
|
{
|
|
1435
1540
|
key: 'ZITADEL_API_URL',
|
|
1436
|
-
value: `http://${
|
|
1541
|
+
value: `http://${target.internal.authZitadel}:${AUTH_ZITADEL_PORT}`,
|
|
1437
1542
|
},
|
|
1438
1543
|
{
|
|
1439
1544
|
key: 'CUSTOM_REQUEST_HEADERS',
|
|
@@ -1447,8 +1552,8 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1447
1552
|
// invite's own path (set a password, then nowhere to go). Only set once
|
|
1448
1553
|
// Archon is declared, and only ever to Archon: the platform's one landing
|
|
1449
1554
|
// app for anyone who reaches the login app outside an app's own sign-in.
|
|
1450
|
-
...(
|
|
1451
|
-
? [{ key: 'DEFAULT_REDIRECT_URI', value:
|
|
1555
|
+
...(input.defaultRedirectUri
|
|
1556
|
+
? [{ key: 'DEFAULT_REDIRECT_URI', value: input.defaultRedirectUri }]
|
|
1452
1557
|
: []),
|
|
1453
1558
|
];
|
|
1454
1559
|
// #210: read back now, not inside the deploy-decision block below -- this
|
|
@@ -1456,7 +1561,9 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1456
1561
|
// the same way `envsDiffer`/`bulkUpsertEnvs` below already do for every
|
|
1457
1562
|
// other broker env, so a single apply that finds the key already held can
|
|
1458
1563
|
// also see `LETTERMINT_API_TOKEN` as met and deploy in that same run.
|
|
1459
|
-
const heldMailKey = await deps.heldKeys?.resolve(
|
|
1564
|
+
const heldMailKey = await deps.heldKeys?.resolve(target.refs.brokerMailKey);
|
|
1565
|
+
if (heldMailKey)
|
|
1566
|
+
runtime.onSecret?.(heldMailKey);
|
|
1460
1567
|
// `AUTH_APPS` and `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` are not pushed here
|
|
1461
1568
|
// -- this function's own doc comment, "Known gaps". `LETTERMINT_API_TOKEN`
|
|
1462
1569
|
// is, once held (#210): pushing it costs nothing even while the broker
|
|
@@ -1465,7 +1572,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1465
1572
|
const brokerEnvs = [
|
|
1466
1573
|
{
|
|
1467
1574
|
key: 'ZITADEL_API_URL',
|
|
1468
|
-
value: `http://${
|
|
1575
|
+
value: `http://${target.internal.authZitadel}:${AUTH_ZITADEL_PORT}`,
|
|
1469
1576
|
},
|
|
1470
1577
|
{ key: 'ZITADEL_DOMAIN', value: hostname },
|
|
1471
1578
|
...(heldMailKey ? [{ key: 'LETTERMINT_API_TOKEN', value: heldMailKey }] : []),
|
|
@@ -1517,25 +1624,25 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1517
1624
|
await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, authAppsRaw));
|
|
1518
1625
|
}
|
|
1519
1626
|
// ---- tunnel and DNS ------------------------------------------------------
|
|
1520
|
-
const tunnels = await
|
|
1521
|
-
const existingTunnel = await tunnels.findTunnelByName(
|
|
1522
|
-
const tunnel = existingTunnel ?? (await tunnels.createTunnel(
|
|
1627
|
+
const tunnels = await network.tunnels();
|
|
1628
|
+
const existingTunnel = await tunnels.findTunnelByName(target.names.authTunnel);
|
|
1629
|
+
const tunnel = existingTunnel ?? (await tunnels.createTunnel(target.names.authTunnel));
|
|
1523
1630
|
await record(PLATFORM_AUTH_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
1524
1631
|
await tunnels.putTunnelConfig(tunnel.id, {
|
|
1525
1632
|
ingress: [
|
|
1526
1633
|
{
|
|
1527
1634
|
hostname,
|
|
1528
1635
|
path: AUTH_ZITADEL_PATH_PATTERN,
|
|
1529
|
-
service: `http://${
|
|
1636
|
+
service: `http://${target.internal.authZitadel}:${AUTH_ZITADEL_PORT}`,
|
|
1530
1637
|
},
|
|
1531
1638
|
{
|
|
1532
1639
|
hostname,
|
|
1533
1640
|
path: AUTH_BROKER_PATH_PATTERN,
|
|
1534
|
-
service: `http://${
|
|
1641
|
+
service: `http://${target.internal.authBroker}:${AUTH_BROKER_PORT}`,
|
|
1535
1642
|
},
|
|
1536
1643
|
{
|
|
1537
1644
|
hostname,
|
|
1538
|
-
service: `http://${
|
|
1645
|
+
service: `http://${target.internal.authLogin}:${AUTH_LOGIN_PORT}`,
|
|
1539
1646
|
},
|
|
1540
1647
|
{ service: 'http_status:404' },
|
|
1541
1648
|
],
|
|
@@ -1548,7 +1655,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1548
1655
|
coolify: deps.coolify,
|
|
1549
1656
|
tunnels,
|
|
1550
1657
|
tunnelId: tunnel.id,
|
|
1551
|
-
appName:
|
|
1658
|
+
appName: target.names.authCloudflared,
|
|
1552
1659
|
projectUuid,
|
|
1553
1660
|
environment,
|
|
1554
1661
|
resolveServer: () => resolvePlatformServer(deps.coolify),
|
|
@@ -1557,15 +1664,15 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1557
1664
|
});
|
|
1558
1665
|
// The apex zone step records the zone id before this step runs, but it
|
|
1559
1666
|
// creates no record for the hostname, so it is created here.
|
|
1560
|
-
const zoneId =
|
|
1667
|
+
const zoneId = await network.zoneId(apex);
|
|
1561
1668
|
if (!zoneId) {
|
|
1562
|
-
throw new Error(
|
|
1669
|
+
throw new Error(`${label}: no Cloudflare zone for ${apex} to put ${hostname} in`);
|
|
1563
1670
|
}
|
|
1564
1671
|
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
1565
|
-
const dns = await
|
|
1566
|
-
const [dnsRecord] = await dns.findDnsRecord(
|
|
1672
|
+
const dns = await network.dns();
|
|
1673
|
+
const [dnsRecord] = await dns.findDnsRecord(apex, hostname, 'CNAME');
|
|
1567
1674
|
if (!dnsRecord) {
|
|
1568
|
-
await dns.createDnsRecord(
|
|
1675
|
+
await dns.createDnsRecord(apex, {
|
|
1569
1676
|
type: 'CNAME',
|
|
1570
1677
|
name: hostname,
|
|
1571
1678
|
content: dnsTarget,
|
|
@@ -1573,25 +1680,36 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1573
1680
|
});
|
|
1574
1681
|
}
|
|
1575
1682
|
else if (dnsRecord.content !== dnsTarget) {
|
|
1576
|
-
await dns.updateDnsRecord(
|
|
1683
|
+
await dns.updateDnsRecord(apex, dnsRecord.id, {
|
|
1577
1684
|
type: 'CNAME',
|
|
1578
1685
|
name: hostname,
|
|
1579
1686
|
content: dnsTarget,
|
|
1580
1687
|
proxied: true,
|
|
1581
1688
|
});
|
|
1582
1689
|
}
|
|
1690
|
+
if (target.kind === 'org')
|
|
1691
|
+
await recordOwnDnsHostname(scope, hostname);
|
|
1583
1692
|
// ---- foundry's own admin machine user, once ------------------------------
|
|
1584
1693
|
if (!recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
|
|
1585
1694
|
const lost = recorded[PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY];
|
|
1586
1695
|
if (lost) {
|
|
1587
1696
|
// The planner reports this and plans nothing; this is the same rule for
|
|
1588
1697
|
// a caller that reaches the step anyway.
|
|
1589
|
-
throw new Error(
|
|
1698
|
+
throw new Error(`${label}: the admin key of ZITADEL machine user ${lost} was minted but never delivered; delete that user and the recorded key ${PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
1590
1699
|
}
|
|
1591
1700
|
if (systemKeys) {
|
|
1592
1701
|
// ZITADEL answers /debug/healthz without an instance; the openid
|
|
1593
1702
|
// document below only exists once the instance does.
|
|
1594
|
-
await waitForInstance(`https://${hostname}/debug/healthz`, runtime, sleep);
|
|
1703
|
+
await waitForInstance(`https://${hostname}/debug/healthz`, runtime, sleep, label);
|
|
1704
|
+
// Healthy is not ready for the system API: its gateway answers 503 for a moment after the server listens.
|
|
1705
|
+
await waitForSystemApi({ customDomain: hostname, privateKey: systemKeys.privateKey, label }, {
|
|
1706
|
+
fetch: runtime.fetch,
|
|
1707
|
+
now: runtime.now ?? Date.now,
|
|
1708
|
+
sleep,
|
|
1709
|
+
timeoutMs: runtime.readyTimeoutMs,
|
|
1710
|
+
intervalMs: runtime.readyIntervalMs,
|
|
1711
|
+
requestTimeoutMs: runtime.readyRequestTimeoutMs,
|
|
1712
|
+
});
|
|
1595
1713
|
// Sent exactly once. Anything that may have reached ZITADEL leaves a
|
|
1596
1714
|
// marker first, so the next run refuses instead of creating again.
|
|
1597
1715
|
let created;
|
|
@@ -1601,6 +1719,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1601
1719
|
privateKey: systemKeys.privateKey,
|
|
1602
1720
|
instanceName: PLATFORM_AUTH_ORG_NAME,
|
|
1603
1721
|
firstOrgName: PLATFORM_AUTH_ORG_NAME,
|
|
1722
|
+
label,
|
|
1604
1723
|
owner: {
|
|
1605
1724
|
userName: PLATFORM_AUTH_BEARER_USER_NAME,
|
|
1606
1725
|
name: 'Foundry (instance owner)',
|
|
@@ -1614,12 +1733,13 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1614
1733
|
throw error;
|
|
1615
1734
|
}
|
|
1616
1735
|
const { instanceId, pat } = created;
|
|
1736
|
+
runtime.onSecret?.(pat);
|
|
1617
1737
|
// Recorded before the PAT leaves this function, like the admin key below.
|
|
1618
1738
|
await record(PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, instanceId);
|
|
1619
1739
|
const attempts = runtime.deliverAttempts ?? 3;
|
|
1620
1740
|
for (let attempt = 1;; attempt += 1) {
|
|
1621
1741
|
try {
|
|
1622
|
-
await deps.deliverSecret(
|
|
1742
|
+
await deps.deliverSecret(target.refs.instanceBearer, pat);
|
|
1623
1743
|
break;
|
|
1624
1744
|
}
|
|
1625
1745
|
catch (error) {
|
|
@@ -1627,7 +1747,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1627
1747
|
const reason = redactSecrets(error instanceof Error ? error.message : String(error), [
|
|
1628
1748
|
pat,
|
|
1629
1749
|
]);
|
|
1630
|
-
throw new Error(
|
|
1750
|
+
throw new Error(`${label}: the owner token of ZITADEL instance ${instanceId} was created but could not be delivered after ${attempts} tries (${reason}); ${instanceWithoutBearerMessage(hostname, `instance ${instanceId}`, label)}`);
|
|
1631
1751
|
}
|
|
1632
1752
|
await sleep(1000 * attempt);
|
|
1633
1753
|
}
|
|
@@ -1637,8 +1757,8 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1637
1757
|
}
|
|
1638
1758
|
// Minting needs a running instance behind the tunnel: the first apply
|
|
1639
1759
|
// gets here right after the deploy.
|
|
1640
|
-
await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep);
|
|
1641
|
-
const zitadel =
|
|
1760
|
+
await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep, label);
|
|
1761
|
+
const zitadel = instanceZitadel();
|
|
1642
1762
|
const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
|
|
1643
1763
|
// #216 round 5: recorded here too (mirroring `provisionPlatformArchon`),
|
|
1644
1764
|
// so `authLoginClientDrifted` (below) never needs its own ZITADEL call
|
|
@@ -1650,6 +1770,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1650
1770
|
userName: PLATFORM_AUTH_ADMIN_USER_NAME,
|
|
1651
1771
|
name: 'Foundry (platform admin)',
|
|
1652
1772
|
});
|
|
1773
|
+
runtime.onSecret?.(token);
|
|
1653
1774
|
// Recorded and persisted before the token leaves this function: if
|
|
1654
1775
|
// delivery fails for good, the next plan reports the lost key rather than
|
|
1655
1776
|
// skipping or minting again. Only a delivered key records the guard.
|
|
@@ -1661,12 +1782,12 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1661
1782
|
const attempts = runtime.deliverAttempts ?? 3;
|
|
1662
1783
|
for (let attempt = 1;; attempt += 1) {
|
|
1663
1784
|
try {
|
|
1664
|
-
await deps.deliverSecret(
|
|
1785
|
+
await deps.deliverSecret(target.refs.authAdmin, token);
|
|
1665
1786
|
break;
|
|
1666
1787
|
}
|
|
1667
1788
|
catch {
|
|
1668
1789
|
if (attempt >= attempts) {
|
|
1669
|
-
throw new Error(
|
|
1790
|
+
throw new Error(`${label}: the admin key of ZITADEL machine user ${userId} was minted but could not be delivered after ${attempts} tries`);
|
|
1670
1791
|
}
|
|
1671
1792
|
await sleep(1000 * attempt);
|
|
1672
1793
|
}
|
|
@@ -1731,11 +1852,19 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1731
1852
|
// make the same call, not just trust that a record exists, or a drifted
|
|
1732
1853
|
// platform would be planned again forever without ever actually healing.
|
|
1733
1854
|
const loginClientOtherInputsMissing = brokerMissingInputs.filter((m) => m !== 'ZITADEL_SERVICE_USER_TOKEN');
|
|
1734
|
-
const freshRecordedIds =
|
|
1855
|
+
const freshRecordedIds = scope.recordedIds();
|
|
1735
1856
|
const freshLoginClientRecord = parseLoginClientRecord(freshRecordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
|
|
1736
|
-
const loginClientNeedsMint = !freshLoginClientRecord ||
|
|
1857
|
+
const loginClientNeedsMint = !freshLoginClientRecord ||
|
|
1858
|
+
(await authLoginClientDriftedAt({ hostname, bearerRef: target.refs.instanceBearer }, deps, freshRecordedIds));
|
|
1737
1859
|
if (loginClientNeedsMint && loginClientOtherInputsMissing.length === 0) {
|
|
1738
|
-
|
|
1860
|
+
// An own org never mints a second time on top of a token that may be live:
|
|
1861
|
+
// the platform's re-mint revokes the old one, which would take the org's
|
|
1862
|
+
// sign-in down if the first delivery had in fact landed.
|
|
1863
|
+
const loginClientLost = freshRecordedIds[PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY];
|
|
1864
|
+
if (target.kind === 'org' && loginClientLost && !freshLoginClientRecord) {
|
|
1865
|
+
throw new Error(`${label}: the login-client token of ZITADEL machine user ${loginClientLost} was minted but never confirmed delivered; remove the recorded key ${PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
1866
|
+
}
|
|
1867
|
+
const loginClientZitadel = instanceZitadel();
|
|
1739
1868
|
const { org: loginClientOrg } = await loginClientZitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
|
|
1740
1869
|
// #216 round 5: backfills the org id for a platform whose admin block
|
|
1741
1870
|
// (above) ran before this recording existed and has not run since --
|
|
@@ -1747,6 +1876,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1747
1876
|
userName: PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME,
|
|
1748
1877
|
name: 'Foundry (platform login client)',
|
|
1749
1878
|
});
|
|
1879
|
+
runtime.onSecret?.(loginClientToken);
|
|
1750
1880
|
// Recorded and persisted before the token leaves this function -- the
|
|
1751
1881
|
// same "one mint" rule the admin block above follows. Never read back to
|
|
1752
1882
|
// block planning; `planAuth` (`platform-diff.ts`) reads it only to
|
|
@@ -1788,8 +1918,8 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1788
1918
|
// saying "its prior token was already revoked" there would describe a
|
|
1789
1919
|
// revoke that never happened, so the message only says so when it did.
|
|
1790
1920
|
throw new Error(revokedPriorPat
|
|
1791
|
-
?
|
|
1792
|
-
:
|
|
1921
|
+
? `${label}: the login-client token of ZITADEL machine user ${loginClientUserId} could not be delivered after ${deliverAttempts} tries; its prior token was already revoked, so the login app and broker are down until a later apply mints and delivers a fresh one`
|
|
1922
|
+
: `${label}: the login-client token of ZITADEL machine user ${loginClientUserId} could not be delivered after ${deliverAttempts} tries; this is its first token, so the login app and broker still have none until a later apply mints and delivers one`);
|
|
1793
1923
|
}
|
|
1794
1924
|
// #216 round 4/5: records what was actually delivered -- the user id,
|
|
1795
1925
|
// the exact PAT id, and (round 5) a hash of the exact token -- so the
|
|
@@ -1802,7 +1932,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1802
1932
|
// ---- the owner company's own sign-in org ----------------------------------
|
|
1803
1933
|
// Archon signs people in from this org; the `foundry` org holds none. The
|
|
1804
1934
|
// company executor finds the same org by name (its slug) and makes no second.
|
|
1805
|
-
const ownerOrgZitadel =
|
|
1935
|
+
const ownerOrgZitadel = instanceZitadel();
|
|
1806
1936
|
let ownerSignInOrgId;
|
|
1807
1937
|
if (ownerCompany) {
|
|
1808
1938
|
({ orgId: ownerSignInOrgId } = await ensureSignInOrg({
|
|
@@ -1810,9 +1940,9 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1810
1940
|
zitadel: ownerOrgZitadel,
|
|
1811
1941
|
name: ownerCompany.slug,
|
|
1812
1942
|
domain: signInOrgDomain(ownerCompany),
|
|
1813
|
-
recordScopeId: (key, id) =>
|
|
1943
|
+
recordScopeId: (key, id) => scope.recordScopeId(key, id),
|
|
1814
1944
|
persistScopeIds: async () => {
|
|
1815
|
-
await
|
|
1945
|
+
await scope.persist();
|
|
1816
1946
|
},
|
|
1817
1947
|
orgScopeKey: PLATFORM_OWNER_ORG_SCOPE_KEY,
|
|
1818
1948
|
domainScopeKey: PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY,
|
|
@@ -1827,19 +1957,22 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1827
1957
|
const mailToken = heldMailKey || (await currentEnvValue(deps.coolify, brokerAppId, 'LETTERMINT_API_TOKEN'));
|
|
1828
1958
|
const smtpTarget = resolveAuthSmtp(deps.smtp);
|
|
1829
1959
|
if (mailToken) {
|
|
1830
|
-
const mailZitadel =
|
|
1960
|
+
const mailZitadel = instanceZitadel();
|
|
1831
1961
|
let smtpPassword;
|
|
1832
1962
|
try {
|
|
1833
1963
|
// Runs every time the step does (idempotent), so a rotated token lands.
|
|
1834
1964
|
smtpPassword = (await deps.smtp?.password?.()) ?? mailToken;
|
|
1965
|
+
runtime.onSecret?.(smtpPassword);
|
|
1835
1966
|
const smtp = await mailZitadel.ensureSmtpProvider({
|
|
1836
1967
|
host: smtpTarget.host,
|
|
1837
1968
|
user: smtpTarget.user,
|
|
1838
1969
|
password: smtpPassword,
|
|
1839
|
-
senderAddress:
|
|
1970
|
+
senderAddress: input.smtpSender,
|
|
1840
1971
|
senderName: PLATFORM_AUTH_MAIL_SENDER_NAME,
|
|
1841
1972
|
tls: smtpTarget.tls,
|
|
1842
|
-
description:
|
|
1973
|
+
description: target.kind === 'platform'
|
|
1974
|
+
? PLATFORM_AUTH_SMTP_DESCRIPTION
|
|
1975
|
+
: `foundry:org:${target.id}:lettermint`,
|
|
1843
1976
|
});
|
|
1844
1977
|
await record(PLATFORM_AUTH_SMTP_SCOPE_KEY, smtp.id);
|
|
1845
1978
|
}
|
|
@@ -1849,10 +1982,10 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1849
1982
|
mailToken,
|
|
1850
1983
|
...(smtpPassword ? [smtpPassword] : []),
|
|
1851
1984
|
]);
|
|
1852
|
-
throw new Error(
|
|
1985
|
+
throw new Error(`${label}: could not configure ZITADEL's SMTP provider for ${hostname}: ${cause}`);
|
|
1853
1986
|
}
|
|
1854
|
-
const inviteRecorded =
|
|
1855
|
-
const inviteOrgRecorded =
|
|
1987
|
+
const inviteRecorded = scope.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
|
|
1988
|
+
const inviteOrgRecorded = scope.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY];
|
|
1856
1989
|
// An invite recorded for another org (the `foundry` org, before the owner moved) is made again.
|
|
1857
1990
|
if (!inviteRecorded || (inviteRecorded !== 'none' && inviteOrgRecorded !== ownerSignInOrgId)) {
|
|
1858
1991
|
const owner = owners[0];
|
|
@@ -1870,14 +2003,14 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1870
2003
|
zitadel: mailZitadel,
|
|
1871
2004
|
mail: deps.ownerInviteMail,
|
|
1872
2005
|
orgId: ownerSignInOrgId,
|
|
1873
|
-
authIssuer:
|
|
2006
|
+
authIssuer: issuer,
|
|
1874
2007
|
owner: {
|
|
1875
2008
|
givenName: owner.givenName,
|
|
1876
2009
|
familyName: owner.familyName,
|
|
1877
2010
|
mailboxAddress,
|
|
1878
2011
|
personalEmail: owner.email,
|
|
1879
2012
|
},
|
|
1880
|
-
label
|
|
2013
|
+
label,
|
|
1881
2014
|
...(inviteRecorded && inviteRecorded !== 'none'
|
|
1882
2015
|
? { invitedUserId: inviteRecorded }
|
|
1883
2016
|
: {}),
|
|
@@ -1892,7 +2025,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1892
2025
|
mailToken,
|
|
1893
2026
|
...(smtpPassword ? [smtpPassword] : []),
|
|
1894
2027
|
]);
|
|
1895
|
-
throw new Error(
|
|
2028
|
+
throw new Error(`${label}: could not invite the platform owner into ZITADEL org ${ownerSignInOrgId}: ${cause}`);
|
|
1896
2029
|
}
|
|
1897
2030
|
// The user id first: a crash between the two replans the invite, never skips it.
|
|
1898
2031
|
await record(PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, invitedUserId);
|
|
@@ -1903,6 +2036,34 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1903
2036
|
}
|
|
1904
2037
|
return { resourceId: zitadelAppId };
|
|
1905
2038
|
}
|
|
2039
|
+
/** The platform's sign-in project: the shared `platform` project, its id recorded under the sign-in's own scope key. */
|
|
2040
|
+
const platformAuthProject = {
|
|
2041
|
+
async ensure(coolify, scope) {
|
|
2042
|
+
const recorded = scope.recordedIds()[PLATFORM_AUTH_PROJECT_SCOPE_KEY];
|
|
2043
|
+
if (recorded)
|
|
2044
|
+
return recorded;
|
|
2045
|
+
const found = await coolify.findProjectByName(PLATFORM_AUTH_PROJECT);
|
|
2046
|
+
const uuid = found ? found.uuid : (await coolify.createProject(PLATFORM_AUTH_PROJECT)).uuid;
|
|
2047
|
+
scope.recordScopeId(PLATFORM_AUTH_PROJECT_SCOPE_KEY, uuid);
|
|
2048
|
+
await scope.persist();
|
|
2049
|
+
return uuid;
|
|
2050
|
+
},
|
|
2051
|
+
};
|
|
2052
|
+
/** The platform's sign-in: {@link provisionAuthStack} with {@link platformTarget} and the platform's own inputs. */
|
|
2053
|
+
async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
2054
|
+
const { platform } = context;
|
|
2055
|
+
const scope = scopeFromPlatform(context);
|
|
2056
|
+
return provisionAuthStack(scope, deps, platformTarget(), {
|
|
2057
|
+
hostname: platform.auth.hostname,
|
|
2058
|
+
apex: platform.apex,
|
|
2059
|
+
gitRef: platform.auth.gitRef,
|
|
2060
|
+
project: platformAuthProject,
|
|
2061
|
+
network: platformNetwork(deps, scope, 'platform:auth'),
|
|
2062
|
+
smtpSender: `noreply@${platform.apex}`,
|
|
2063
|
+
defaultRedirectUri: platform.archon.enabled ? `https://${platform.archon.hostname}` : null,
|
|
2064
|
+
ownerCompany,
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
1906
2067
|
/**
|
|
1907
2068
|
* Reserves the username `admin` and switches self-registration off in the
|
|
1908
2069
|
* platform's sign-in org (`zitadel:sign-in-policy` keyed {@link PLATFORM_AUTH_KEY}).
|
|
@@ -1977,11 +2138,7 @@ const PLATFORM_AUTH_MAIL_SENDER_NAME = 'Company Included';
|
|
|
1977
2138
|
// #169: the platform's own mail server (Stalwart)
|
|
1978
2139
|
// ---------------------------------------------------------------------------
|
|
1979
2140
|
const PLATFORM_MAIL_PROJECT = 'platform';
|
|
1980
|
-
const PLATFORM_MAIL_APP_NAME = 'platform-mail-stalwart';
|
|
1981
2141
|
const PLATFORM_MAIL_INTERNAL_NAME = 'platform-mail-stalwart';
|
|
1982
|
-
const PLATFORM_MAIL_TUNNEL_NAME = 'platform-mail-tunnel';
|
|
1983
|
-
/** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_MAIL_TUNNEL_NAME}. */
|
|
1984
|
-
const PLATFORM_MAIL_CLOUDFLARED_APP_NAME = 'platform-mail-cloudflared';
|
|
1985
2142
|
/**
|
|
1986
2143
|
* `stalw.art/docs/install/docker`: the official image. `platform.mail.gitRef`
|
|
1987
2144
|
* carries its tag -- `platform.ts`'s `PlatformService.gitRef` doc comment
|
|
@@ -2004,7 +2161,6 @@ const STALWART_PORTS_EXPOSES = [STALWART_HTTP_PORT, ...STALWART_SMTP_PORTS].join
|
|
|
2004
2161
|
* not here -- it stays behind the tunnel, exactly like `platform:auth`'s
|
|
2005
2162
|
* ZITADEL/login apps.
|
|
2006
2163
|
*/
|
|
2007
|
-
const STALWART_PORTS_MAPPINGS = STALWART_SMTP_PORTS.map((p) => `${p}:${p}`).join(',');
|
|
2008
2164
|
/**
|
|
2009
2165
|
* The v0.16 image's two VOLUMEs (its Dockerfile): the data store, and the
|
|
2010
2166
|
* `config.json` Stalwart writes when bootstrap completes. The pre-0.16 image
|
|
@@ -2031,6 +2187,7 @@ const STALWART_READY_PATH = '/healthz/ready';
|
|
|
2031
2187
|
const PLATFORM_MAIL_SERVER_SCOPE_KEY = 'coolify_platform_mail_server';
|
|
2032
2188
|
const PLATFORM_MAIL_PROJECT_SCOPE_KEY = 'coolify_project_platform_mail';
|
|
2033
2189
|
const PLATFORM_MAIL_APP_SCOPE_KEY = 'coolify_platform_mail_app';
|
|
2190
|
+
const PLATFORM_MAIL_RELAY_APP_SCOPE_KEY = 'coolify_platform_mail_relay_app';
|
|
2034
2191
|
const PLATFORM_MAIL_TUNNEL_SCOPE_KEY = 'cloudflare_platform_mail_tunnel';
|
|
2035
2192
|
/**
|
|
2036
2193
|
* Recorded once the app has been deployed at least once -- same role as
|
|
@@ -2198,24 +2355,20 @@ const PLATFORM_MAIL_ZITADEL_PROJECT_NAME = 'mail';
|
|
|
2198
2355
|
* (the guard only ever blocks a retry and names an id to revoke by), but
|
|
2199
2356
|
* worth tightening if `createApiKey` ever starts returning its own id.
|
|
2200
2357
|
*/
|
|
2201
|
-
async function
|
|
2202
|
-
const {
|
|
2203
|
-
const
|
|
2204
|
-
const
|
|
2205
|
-
const
|
|
2206
|
-
const recorded = context.recordedIds();
|
|
2358
|
+
export async function provisionMailServer(scope, deps, target, input) {
|
|
2359
|
+
const { hostname, apex, mailHost, imageTag, hostPorts, issuerUrl, mxRecord, network } = input;
|
|
2360
|
+
const label = target.kind === 'org' ? `own step coolify:own-mail for ${target.id}` : 'platform:mail';
|
|
2361
|
+
const keyDescription = target.kind === 'org' ? `foundry:org:${target.id}:admin` : PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION;
|
|
2362
|
+
const recorded = scope.recordedIds();
|
|
2207
2363
|
const runtime = deps.runtime ?? {};
|
|
2208
2364
|
const sleep = runtime.sleep ?? defaultSleep;
|
|
2209
2365
|
const record = async (key, id) => {
|
|
2210
|
-
|
|
2211
|
-
await
|
|
2366
|
+
scope.recordScopeId(key, id);
|
|
2367
|
+
await scope.persist();
|
|
2212
2368
|
};
|
|
2213
2369
|
let projectUuid = recorded[PLATFORM_MAIL_PROJECT_SCOPE_KEY];
|
|
2214
2370
|
if (!projectUuid) {
|
|
2215
|
-
|
|
2216
|
-
projectUuid = found
|
|
2217
|
-
? found.uuid
|
|
2218
|
-
: (await deps.coolify.createProject(PLATFORM_MAIL_PROJECT)).uuid;
|
|
2371
|
+
projectUuid = await input.project.ensure(deps.coolify, scope);
|
|
2219
2372
|
await record(PLATFORM_MAIL_PROJECT_SCOPE_KEY, projectUuid);
|
|
2220
2373
|
}
|
|
2221
2374
|
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
@@ -2226,12 +2379,14 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2226
2379
|
// One server for both the app and the MX record: recorded the first time,
|
|
2227
2380
|
// read back after, so a later run never points `mx.<apex>` somewhere the
|
|
2228
2381
|
// app is not.
|
|
2382
|
+
const portsChanged = (recorded[PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY] ??
|
|
2383
|
+
(target.kind === 'org' ? '' : PLATFORM_MAIL_HOST_PORTS_DIRECT)) !== hostPorts;
|
|
2229
2384
|
const server = await resolveMailServer(deps.coolify, recorded[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
|
|
2230
2385
|
await record(PLATFORM_MAIL_SERVER_SCOPE_KEY, server.uuid);
|
|
2231
|
-
const serverIpBlocked = mailServerIpBlocked(server.ip,
|
|
2386
|
+
const serverIpBlocked = mxRecord ? mailServerIpBlocked(server.ip, mxRecord.serverIpv4) : null;
|
|
2232
2387
|
if (serverIpBlocked)
|
|
2233
2388
|
throw new Error(serverIpBlocked);
|
|
2234
|
-
const app = await findOrCreatePlatformApp(deps.coolify,
|
|
2389
|
+
const app = await findOrCreatePlatformApp(deps.coolify, target.names.mailApp, environment, async () => {
|
|
2235
2390
|
return deps.coolify.createDockerImageApplication({
|
|
2236
2391
|
project_uuid: projectUuid,
|
|
2237
2392
|
server_uuid: server.uuid,
|
|
@@ -2240,9 +2395,11 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2240
2395
|
docker_registry_image_name: STALWART_IMAGE,
|
|
2241
2396
|
docker_registry_image_tag: imageTag,
|
|
2242
2397
|
ports_exposes: STALWART_PORTS_EXPOSES,
|
|
2243
|
-
ports_mappings:
|
|
2244
|
-
name:
|
|
2245
|
-
description:
|
|
2398
|
+
...(hostPorts ? { ports_mappings: hostPorts } : {}),
|
|
2399
|
+
name: target.names.mailApp,
|
|
2400
|
+
description: target.kind === 'org'
|
|
2401
|
+
? `Stalwart for ${target.id}'s own mail (${STALWART_IMAGE}:${imageTag})`
|
|
2402
|
+
: `Stalwart for the platform's own mail (#169, ${STALWART_IMAGE}:${imageTag})`,
|
|
2246
2403
|
instant_deploy: false,
|
|
2247
2404
|
});
|
|
2248
2405
|
});
|
|
@@ -2255,14 +2412,16 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2255
2412
|
// container before the new one can answer `waitForHealthyVersion`'s own
|
|
2256
2413
|
// `'ready'` probe below. Stalwart's own readiness route and HTTP port.
|
|
2257
2414
|
await deps.coolify.updateApplication(appId, {
|
|
2258
|
-
custom_internal_name:
|
|
2415
|
+
custom_internal_name: target.internal.mail,
|
|
2259
2416
|
docker_registry_image_tag: imageTag,
|
|
2260
2417
|
...healthCheckSettings(STALWART_READY_PATH, Number(STALWART_HTTP_PORT)),
|
|
2418
|
+
// Only a platform that uses the shared relay, or one that did, changes a published port.
|
|
2419
|
+
...(portsChanged ? { ports_mappings: hostPorts } : {}),
|
|
2261
2420
|
});
|
|
2262
2421
|
// #177: org steps (an org's own `coolify:mail`/`coolify:agora`) read this
|
|
2263
2422
|
// back instead of a literal, the same way files/ai's own internal
|
|
2264
2423
|
// addresses are recorded.
|
|
2265
|
-
await record(PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, `http://${
|
|
2424
|
+
await record(PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, `http://${target.internal.mail}:${STALWART_HTTP_PORT}`);
|
|
2266
2425
|
// ---- storage ---------------------------------------------------------------
|
|
2267
2426
|
const storages = await deps.coolify.listStorages(appId);
|
|
2268
2427
|
// An app made before this release only has the old `/opt/stalwart` volume.
|
|
@@ -2274,7 +2433,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2274
2433
|
continue;
|
|
2275
2434
|
}
|
|
2276
2435
|
await deps.coolify.createPersistentStorage(appId, {
|
|
2277
|
-
name: `${
|
|
2436
|
+
name: `${target.names.mailApp}-${wanted.suffix}`,
|
|
2278
2437
|
mount_path: wanted.mount_path,
|
|
2279
2438
|
});
|
|
2280
2439
|
storagesAdded = true;
|
|
@@ -2295,7 +2454,12 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2295
2454
|
const declaredChanged = recorded[PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY] !== hostname ||
|
|
2296
2455
|
recorded[PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY] !== imageTag;
|
|
2297
2456
|
// A new storage binds into the container only at its next start.
|
|
2298
|
-
const mailDeployedNow = app.justCreated ||
|
|
2457
|
+
const mailDeployedNow = app.justCreated ||
|
|
2458
|
+
!app.running ||
|
|
2459
|
+
envsChanged ||
|
|
2460
|
+
declaredChanged ||
|
|
2461
|
+
storagesAdded ||
|
|
2462
|
+
portsChanged;
|
|
2299
2463
|
if (mailDeployedNow) {
|
|
2300
2464
|
// A container still up from before would answer the readiness probe with
|
|
2301
2465
|
// the old deployment's health. Stop it and see it stopped first, so the
|
|
@@ -2311,7 +2475,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2311
2475
|
// instead would just poll a hostname that cannot resolve yet on a first
|
|
2312
2476
|
// apply. Either way it runs before any applied ref is recorded.
|
|
2313
2477
|
// The old container was stopped above (or never ran), so nothing stays live.
|
|
2314
|
-
await waitForDeploymentFinished(deps.coolify, appId, runtime,
|
|
2478
|
+
await waitForDeploymentFinished(deps.coolify, appId, runtime, label, app.justCreated || !app.running
|
|
2315
2479
|
? `mail stays at ${recorded[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`
|
|
2316
2480
|
: 'mail is stopped until the next apply');
|
|
2317
2481
|
await record(PLATFORM_MAIL_DEPLOYED_SCOPE_KEY, appId);
|
|
@@ -2319,17 +2483,19 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2319
2483
|
// settings, mint) does not make the next run stop and redeploy the app.
|
|
2320
2484
|
await record(PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY, hostname);
|
|
2321
2485
|
await record(PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY, imageTag);
|
|
2486
|
+
if (hostPorts)
|
|
2487
|
+
await record(PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY, hostPorts);
|
|
2322
2488
|
}
|
|
2323
2489
|
// ---- tunnel and DNS: web/JMAP/management ------------------------------------
|
|
2324
|
-
const tunnels = await
|
|
2325
|
-
const existingTunnel = await tunnels.findTunnelByName(
|
|
2326
|
-
const tunnel = existingTunnel ?? (await tunnels.createTunnel(
|
|
2490
|
+
const tunnels = await network.tunnels();
|
|
2491
|
+
const existingTunnel = await tunnels.findTunnelByName(target.names.mailTunnel);
|
|
2492
|
+
const tunnel = existingTunnel ?? (await tunnels.createTunnel(target.names.mailTunnel));
|
|
2327
2493
|
await record(PLATFORM_MAIL_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
2328
2494
|
await tunnels.putTunnelConfig(tunnel.id, {
|
|
2329
2495
|
ingress: [
|
|
2330
2496
|
{
|
|
2331
2497
|
hostname,
|
|
2332
|
-
service: `http://${
|
|
2498
|
+
service: `http://${target.internal.mail}:${STALWART_HTTP_PORT}`,
|
|
2333
2499
|
},
|
|
2334
2500
|
{ service: 'http_status:404' },
|
|
2335
2501
|
],
|
|
@@ -2339,7 +2505,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2339
2505
|
coolify: deps.coolify,
|
|
2340
2506
|
tunnels,
|
|
2341
2507
|
tunnelId: tunnel.id,
|
|
2342
|
-
appName:
|
|
2508
|
+
appName: target.names.mailCloudflared,
|
|
2343
2509
|
projectUuid,
|
|
2344
2510
|
environment,
|
|
2345
2511
|
// The Stalwart app's own server, so the connector shares its Docker network.
|
|
@@ -2347,15 +2513,15 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2347
2513
|
recordedConnectorAppId: recorded[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
2348
2514
|
recordAppId: (appId) => record(PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, appId),
|
|
2349
2515
|
});
|
|
2350
|
-
const zoneId =
|
|
2516
|
+
const zoneId = await network.zoneId(apex);
|
|
2351
2517
|
if (!zoneId) {
|
|
2352
|
-
throw new Error(
|
|
2518
|
+
throw new Error(`${label}: no Cloudflare zone for ${apex} to put ${hostname} in`);
|
|
2353
2519
|
}
|
|
2354
2520
|
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
2355
|
-
const dns = await
|
|
2356
|
-
const [dnsRecord] = await dns.findDnsRecord(
|
|
2521
|
+
const dns = await network.dns();
|
|
2522
|
+
const [dnsRecord] = await dns.findDnsRecord(apex, hostname, 'CNAME');
|
|
2357
2523
|
if (!dnsRecord) {
|
|
2358
|
-
await dns.createDnsRecord(
|
|
2524
|
+
await dns.createDnsRecord(apex, {
|
|
2359
2525
|
type: 'CNAME',
|
|
2360
2526
|
name: hostname,
|
|
2361
2527
|
content: dnsTarget,
|
|
@@ -2363,31 +2529,35 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2363
2529
|
});
|
|
2364
2530
|
}
|
|
2365
2531
|
else if (dnsRecord.content !== dnsTarget) {
|
|
2366
|
-
await dns.updateDnsRecord(
|
|
2532
|
+
await dns.updateDnsRecord(apex, dnsRecord.id, {
|
|
2367
2533
|
type: 'CNAME',
|
|
2368
2534
|
name: hostname,
|
|
2369
2535
|
content: dnsTarget,
|
|
2370
2536
|
proxied: true,
|
|
2371
2537
|
});
|
|
2372
2538
|
}
|
|
2539
|
+
if (target.kind === 'org')
|
|
2540
|
+
await recordOwnDnsHostname(scope, hostname);
|
|
2373
2541
|
// ---- the MX hostname: a direct, unproxied A record --------------------------
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2542
|
+
if (mxRecord) {
|
|
2543
|
+
const serverIp = (mxRecord.serverIpv4 ?? server.ip); // checked by mailServerIpBlocked above
|
|
2544
|
+
const [existingMx] = await dns.findDnsRecord(apex, mailHost, 'A');
|
|
2545
|
+
if (!existingMx) {
|
|
2546
|
+
await dns.createDnsRecord(apex, {
|
|
2547
|
+
type: 'A',
|
|
2548
|
+
name: mailHost,
|
|
2549
|
+
content: serverIp,
|
|
2550
|
+
proxied: false,
|
|
2551
|
+
});
|
|
2552
|
+
}
|
|
2553
|
+
else if (existingMx.content !== serverIp) {
|
|
2554
|
+
await dns.updateDnsRecord(apex, existingMx.id, {
|
|
2555
|
+
type: 'A',
|
|
2556
|
+
name: mailHost,
|
|
2557
|
+
content: serverIp,
|
|
2558
|
+
proxied: false,
|
|
2559
|
+
});
|
|
2560
|
+
}
|
|
2391
2561
|
}
|
|
2392
2562
|
// #252: phase 2, now that the tunnel and DNS above actually route
|
|
2393
2563
|
// `hostname`. `'ready'`, not `'version'`: Stalwart's `/healthz/ready`
|
|
@@ -2404,7 +2574,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2404
2574
|
const ensureMailReady = async () => {
|
|
2405
2575
|
if (mailReadyConfirmed)
|
|
2406
2576
|
return;
|
|
2407
|
-
await waitForHealthyVersion(appId, { kind: 'ready', url: `https://${hostname}${STALWART_READY_PATH}` }, runtime, sleep,
|
|
2577
|
+
await waitForHealthyVersion(appId, { kind: 'ready', url: `https://${hostname}${STALWART_READY_PATH}` }, runtime, sleep, label, `mail stays at ${recorded[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
2408
2578
|
mailReadyConfirmed = true;
|
|
2409
2579
|
};
|
|
2410
2580
|
// Also probe when the applied ref lags the target: the deployed refs are
|
|
@@ -2419,7 +2589,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2419
2589
|
// projects -- its id is the OIDC audience Stalwart's directory checks an
|
|
2420
2590
|
// access token's `aud` claim against, so no host has to register a
|
|
2421
2591
|
// resource API for this by hand.
|
|
2422
|
-
const issuer =
|
|
2592
|
+
const issuer = issuerUrl;
|
|
2423
2593
|
const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
|
|
2424
2594
|
const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
|
|
2425
2595
|
const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_MAIL_ZITADEL_PROJECT_NAME);
|
|
@@ -2445,7 +2615,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2445
2615
|
await ensureMailReady();
|
|
2446
2616
|
const recoveryEnv = (await deps.coolify.listEnvs(appId)).find((e) => e.key === 'STALWART_RECOVERY_ADMIN' && !e.is_preview);
|
|
2447
2617
|
if (!recoveryEnv) {
|
|
2448
|
-
throw new Error(
|
|
2618
|
+
throw new Error(`${label}: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to re-apply the OIDC audience`);
|
|
2449
2619
|
}
|
|
2450
2620
|
const password = recoveryEnv.value.slice(`${STALWART_RECOVERY_USER}:`.length);
|
|
2451
2621
|
try {
|
|
@@ -2453,7 +2623,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2453
2623
|
url: `https://${hostname}`,
|
|
2454
2624
|
user: STALWART_RECOVERY_USER,
|
|
2455
2625
|
password,
|
|
2456
|
-
apex
|
|
2626
|
+
apex,
|
|
2457
2627
|
});
|
|
2458
2628
|
await applyMailSettings(admin, {
|
|
2459
2629
|
coolify: deps.coolify,
|
|
@@ -2462,8 +2632,8 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2462
2632
|
sleep,
|
|
2463
2633
|
hostname,
|
|
2464
2634
|
settings: {
|
|
2465
|
-
mailHost,
|
|
2466
|
-
apex
|
|
2635
|
+
mailHost: hostname,
|
|
2636
|
+
apex,
|
|
2467
2637
|
oidc: { issuerUrl: issuer, audience: project.id },
|
|
2468
2638
|
},
|
|
2469
2639
|
});
|
|
@@ -2481,12 +2651,12 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2481
2651
|
if (!recorded[PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY]) {
|
|
2482
2652
|
const lost = recorded[PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY];
|
|
2483
2653
|
if (lost) {
|
|
2484
|
-
throw new Error(
|
|
2654
|
+
throw new Error(`${label}: an admin API key ("${lost}") was minted but never delivered; revoke it in Stalwart and remove the recorded key ${PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
2485
2655
|
}
|
|
2486
2656
|
await ensureMailReady();
|
|
2487
2657
|
const recoveryEnv = (await deps.coolify.listEnvs(appId)).find((e) => e.key === 'STALWART_RECOVERY_ADMIN' && !e.is_preview);
|
|
2488
2658
|
if (!recoveryEnv) {
|
|
2489
|
-
throw new Error(
|
|
2659
|
+
throw new Error(`${label}: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to mint the admin key`);
|
|
2490
2660
|
}
|
|
2491
2661
|
const password = recoveryEnv.value.slice(`${STALWART_RECOVERY_USER}:`.length);
|
|
2492
2662
|
try {
|
|
@@ -2494,7 +2664,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2494
2664
|
url: `https://${hostname}`,
|
|
2495
2665
|
user: STALWART_RECOVERY_USER,
|
|
2496
2666
|
password,
|
|
2497
|
-
apex
|
|
2667
|
+
apex,
|
|
2498
2668
|
});
|
|
2499
2669
|
await applyMailSettings(admin, {
|
|
2500
2670
|
coolify: deps.coolify,
|
|
@@ -2503,8 +2673,8 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2503
2673
|
sleep,
|
|
2504
2674
|
hostname,
|
|
2505
2675
|
settings: {
|
|
2506
|
-
mailHost,
|
|
2507
|
-
apex
|
|
2676
|
+
mailHost: hostname,
|
|
2677
|
+
apex,
|
|
2508
2678
|
oidc: { issuerUrl: issuer, audience: project.id },
|
|
2509
2679
|
},
|
|
2510
2680
|
});
|
|
@@ -2512,17 +2682,17 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2512
2682
|
// Recorded and persisted before the secret leaves this function: if
|
|
2513
2683
|
// delivery fails for good, the next plan reports the lost key rather
|
|
2514
2684
|
// than skipping or minting again.
|
|
2515
|
-
await record(PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY,
|
|
2516
|
-
const secret = await admin.createApiKey(
|
|
2685
|
+
await record(PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, keyDescription);
|
|
2686
|
+
const secret = await admin.createApiKey(keyDescription, 'inherit');
|
|
2517
2687
|
const attempts = runtime.deliverAttempts ?? 3;
|
|
2518
2688
|
for (let attempt = 1;; attempt += 1) {
|
|
2519
2689
|
try {
|
|
2520
|
-
await deps.deliverSecret(
|
|
2690
|
+
await deps.deliverSecret(target.refs.mailAdminKey, secret);
|
|
2521
2691
|
break;
|
|
2522
2692
|
}
|
|
2523
2693
|
catch {
|
|
2524
2694
|
if (attempt >= attempts) {
|
|
2525
|
-
throw new Error(
|
|
2695
|
+
throw new Error(`${label}: the admin API key ("${keyDescription}") was minted but could not be delivered after ${attempts} tries`);
|
|
2526
2696
|
}
|
|
2527
2697
|
await sleep(1000 * attempt);
|
|
2528
2698
|
}
|
|
@@ -2539,6 +2709,29 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2539
2709
|
await record(PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, imageTag);
|
|
2540
2710
|
return { resourceId: appId };
|
|
2541
2711
|
}
|
|
2712
|
+
/** The platform's mail project: the shared `platform` project, its id recorded by the mail step under its own key. */
|
|
2713
|
+
const platformMailProject = {
|
|
2714
|
+
async ensure(coolify) {
|
|
2715
|
+
const found = await coolify.findProjectByName(PLATFORM_MAIL_PROJECT);
|
|
2716
|
+
return found ? found.uuid : (await coolify.createProject(PLATFORM_MAIL_PROJECT)).uuid;
|
|
2717
|
+
},
|
|
2718
|
+
};
|
|
2719
|
+
/** The platform's mail server: {@link provisionMailServer} with {@link platformTarget} and the platform's own inputs. */
|
|
2720
|
+
async function provisionPlatformMail(context, deps) {
|
|
2721
|
+
const { platform } = context;
|
|
2722
|
+
const scope = scopeFromPlatform(context);
|
|
2723
|
+
return provisionMailServer(scope, deps, platformTarget(), {
|
|
2724
|
+
hostname: platform.mail.hostname,
|
|
2725
|
+
apex: platform.apex,
|
|
2726
|
+
mailHost: platformMailMxHost(platform.apex),
|
|
2727
|
+
imageTag: platform.mail.gitRef,
|
|
2728
|
+
hostPorts: platformMailHostPorts(platform),
|
|
2729
|
+
issuerUrl: `https://${platform.auth.hostname}`,
|
|
2730
|
+
mxRecord: { serverIpv4: platform.serverIpv4 },
|
|
2731
|
+
project: platformMailProject,
|
|
2732
|
+
network: platformNetwork(deps, scope, 'platform:mail'),
|
|
2733
|
+
});
|
|
2734
|
+
}
|
|
2542
2735
|
/**
|
|
2543
2736
|
* Why the Stalwart image tag cannot be used, or `null`. #248: a thin wrapper
|
|
2544
2737
|
* over the estate-wide {@link floatingTagBlocked} -- the mail step's own
|
|
@@ -2604,6 +2797,31 @@ async function mailBlockedReason(platform, mail, recordedIds) {
|
|
|
2604
2797
|
return "the platform's mail server is blocked, not planned: no usable Coolify server could be resolved for it";
|
|
2605
2798
|
}
|
|
2606
2799
|
}
|
|
2800
|
+
/** Why the shared mail relay cannot be built from its pinned base image, or `null`. */
|
|
2801
|
+
function mailRelayBlockedReason() {
|
|
2802
|
+
const tagBlocked = floatingTagBlocked('mail relay base', VERSIONS.alpine, '3.22.1');
|
|
2803
|
+
return tagBlocked ? `the platform's mail relay is blocked, not planned: ${tagBlocked}` : null;
|
|
2804
|
+
}
|
|
2805
|
+
/**
|
|
2806
|
+
* Runs the shared mail relay step against the project and server the platform
|
|
2807
|
+
* mail step recorded, so the relay sits on the Docker network and the server
|
|
2808
|
+
* address of the Stalwart it forwards to.
|
|
2809
|
+
*/
|
|
2810
|
+
async function provisionPlatformMailRelay(action, context, deps) {
|
|
2811
|
+
const recorded = context.recordedIds();
|
|
2812
|
+
const projectUuid = recorded[PLATFORM_MAIL_PROJECT_SCOPE_KEY];
|
|
2813
|
+
const serverUuid = recorded[PLATFORM_MAIL_SERVER_SCOPE_KEY];
|
|
2814
|
+
if (!projectUuid || !serverUuid) {
|
|
2815
|
+
throw new Error('platform:mail-relay: the platform mail server has not recorded its project and server yet; apply the mail server first');
|
|
2816
|
+
}
|
|
2817
|
+
return provisionMailRelay(action, context, deps, {
|
|
2818
|
+
projectUuid,
|
|
2819
|
+
serverUuid,
|
|
2820
|
+
hostname: platformMailMxHost(context.platform.apex),
|
|
2821
|
+
defaultTarget: `${PLATFORM_MAIL_INTERNAL_NAME}:25`,
|
|
2822
|
+
appScopeKey: PLATFORM_MAIL_RELAY_APP_SCOPE_KEY,
|
|
2823
|
+
});
|
|
2824
|
+
}
|
|
2607
2825
|
/**
|
|
2608
2826
|
* #211: the broker cannot start without `ZITADEL_SERVICE_USER_TOKEN`/`_FILE`,
|
|
2609
2827
|
* `LETTERMINT_API_TOKEN` and `AUTH_APPS`. `provisionPlatformAuth` already
|
|
@@ -4534,8 +4752,6 @@ function parseAiKeyMintedBody(body) {
|
|
|
4534
4752
|
secret: record.secret,
|
|
4535
4753
|
};
|
|
4536
4754
|
}
|
|
4537
|
-
/** The ref the AI management key is delivered under; the host's `AiOrganisationPort` adapter reads it back. */
|
|
4538
|
-
export const PLATFORM_AI_MANAGEMENT_KEY_REF = 'ai:platform:managementKey';
|
|
4539
4755
|
/**
|
|
4540
4756
|
* What Archon's management key may do in ai: exactly `ai`'s own
|
|
4541
4757
|
* `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
|
|
@@ -4884,13 +5100,15 @@ async function provisionPlatformAi(context, deps) {
|
|
|
4884
5100
|
// Every run, so a changed gitRef reaches an app that already exists.
|
|
4885
5101
|
git_branch: gitRef,
|
|
4886
5102
|
};
|
|
4887
|
-
// #543: the health-check quartet too,
|
|
4888
|
-
//
|
|
4889
|
-
//
|
|
5103
|
+
// #543: the health-check quartet too, on the runner. The worker exposes no
|
|
5104
|
+
// port (`ports_exposes` is never set on it, just below); its Dockerfile's
|
|
5105
|
+
// HEALTHCHECK checks the age of the heartbeat file it writes, and that is the live one.
|
|
4890
5106
|
if (spec === PLATFORM_AI_RUNNER) {
|
|
4891
5107
|
patch.ports_exposes = PLATFORM_AI_PORT;
|
|
4892
5108
|
Object.assign(patch, healthCheckSettings('/api/health', Number(PLATFORM_AI_PORT)));
|
|
4893
5109
|
}
|
|
5110
|
+
if (spec === PLATFORM_AI_WORKER)
|
|
5111
|
+
Object.assign(patch, IMAGE_HEALTHCHECK_SETTINGS);
|
|
4894
5112
|
await coolify.updateApplication(app.appId, patch);
|
|
4895
5113
|
return app;
|
|
4896
5114
|
};
|
|
@@ -5305,7 +5523,7 @@ async function missingEnvs(coolify, appId, defaults) {
|
|
|
5305
5523
|
}
|
|
5306
5524
|
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
5307
5525
|
/** Polls `url` until it answers 2xx, or throws once `readyTimeoutMs` has passed. */
|
|
5308
|
-
async function waitForInstance(url, runtime, sleep) {
|
|
5526
|
+
async function waitForInstance(url, runtime, sleep, label = 'platform:auth') {
|
|
5309
5527
|
const fetchImpl = runtime.fetch ?? globalThis.fetch;
|
|
5310
5528
|
const now = runtime.now ?? Date.now;
|
|
5311
5529
|
const timeout = runtime.readyTimeoutMs ?? 300_000;
|
|
@@ -5313,7 +5531,9 @@ async function waitForInstance(url, runtime, sleep) {
|
|
|
5313
5531
|
const deadline = now() + timeout;
|
|
5314
5532
|
for (;;) {
|
|
5315
5533
|
try {
|
|
5316
|
-
const response = await fetchImpl(url
|
|
5534
|
+
const response = await fetchImpl(url, {
|
|
5535
|
+
signal: AbortSignal.timeout(runtime.readyRequestTimeoutMs ?? 10_000),
|
|
5536
|
+
});
|
|
5317
5537
|
if (response.ok)
|
|
5318
5538
|
return;
|
|
5319
5539
|
}
|
|
@@ -5321,7 +5541,7 @@ async function waitForInstance(url, runtime, sleep) {
|
|
|
5321
5541
|
// Not up yet: DNS, the tunnel or the container.
|
|
5322
5542
|
}
|
|
5323
5543
|
if (now() >= deadline) {
|
|
5324
|
-
throw new Error(
|
|
5544
|
+
throw new Error(`${label}: the new ZITADEL instance did not answer at ${url} within ${Math.round(timeout / 1000)}s; retry this apply`);
|
|
5325
5545
|
}
|
|
5326
5546
|
await sleep(interval);
|
|
5327
5547
|
}
|
|
@@ -5547,12 +5767,12 @@ async function currentEnvValue(coolify, appId, key) {
|
|
|
5547
5767
|
* deployed app means someone removed it. This throws, and a person sets the key that database was
|
|
5548
5768
|
* initialised with (or deletes an unused database) and applies again.
|
|
5549
5769
|
*/
|
|
5550
|
-
async function ensureMasterkey(coolify, appId, databaseInUse) {
|
|
5770
|
+
async function ensureMasterkey(coolify, appId, databaseInUse, label = 'platform:auth') {
|
|
5551
5771
|
const envs = await coolify.listEnvs(appId);
|
|
5552
5772
|
if (envs.some((e) => e.key === 'ZITADEL_MASTERKEY' && !e.is_preview))
|
|
5553
5773
|
return null;
|
|
5554
5774
|
if (databaseInUse) {
|
|
5555
|
-
throw new Error(
|
|
5775
|
+
throw new Error(`${label}: app ${appId} has no ZITADEL_MASTERKEY but its database is already in use; not generating a new one. Set ZITADEL_MASTERKEY on that app to the key the database was initialised with, or delete the database if ZITADEL never ran on it, then apply again`);
|
|
5556
5776
|
}
|
|
5557
5777
|
return randomBytes(24).toString('base64');
|
|
5558
5778
|
}
|
|
@@ -5568,23 +5788,21 @@ async function ensureMasterkey(coolify, appId, databaseInUse) {
|
|
|
5568
5788
|
*/
|
|
5569
5789
|
export const FILES_MANAGEMENT_ISSUER = 'foundry';
|
|
5570
5790
|
export const FILES_MANAGEMENT_KEY_ID = 'foundry-1';
|
|
5571
|
-
/** The ref the management private key (PKCS#8 PEM) is delivered under. */
|
|
5572
|
-
export const FILES_MANAGEMENT_KEY_REF = 'files:platform:managementKey';
|
|
5573
5791
|
/** The ref the files host's introspection client secret is delivered under. */
|
|
5574
5792
|
export const FILES_INTROSPECTION_SECRET_REF = 'zitadel:platform:files:introspection';
|
|
5575
5793
|
/** https://github.com/wtfalch/files -- root Dockerfile with `migration` and `runner` targets. */
|
|
5576
5794
|
const FILES_REPO = 'wtfalch/files';
|
|
5577
5795
|
const PLATFORM_FILES_PROJECT = 'platform';
|
|
5578
|
-
const PLATFORM_FILES_DB_NAME = 'platform-files-db';
|
|
5796
|
+
export const PLATFORM_FILES_DB_NAME = 'platform-files-db';
|
|
5579
5797
|
/** The two Coolify apps files' `apps/host/README.md` ("Deploying on Coolify") names. */
|
|
5580
|
-
const FILES_MIGRATE_APP_NAME = 'files-migrate';
|
|
5581
|
-
const FILES_RUNNER_APP_NAME = 'files';
|
|
5798
|
+
export const FILES_MIGRATE_APP_NAME = 'files-migrate';
|
|
5799
|
+
export const FILES_RUNNER_APP_NAME = 'files';
|
|
5582
5800
|
/**
|
|
5583
5801
|
* The runner's fixed `custom_internal_name`: the name another container on
|
|
5584
5802
|
* the Coolify network reaches it by, and so the host part of
|
|
5585
5803
|
* {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY}'s recorded address.
|
|
5586
5804
|
*/
|
|
5587
|
-
const FILES_RUNNER_INTERNAL_NAME = 'platform-files';
|
|
5805
|
+
export const FILES_RUNNER_INTERNAL_NAME = 'platform-files';
|
|
5588
5806
|
/** The runner's port (`Dockerfile`'s `runner` stage: `PORT=3000`, `EXPOSE 3000`). */
|
|
5589
5807
|
const FILES_PORT = '3000';
|
|
5590
5808
|
/** files' own health route, the same one `healthUrls` (`executor.ts`) assumes every hosted app serves. */
|
|
@@ -5681,22 +5899,106 @@ async function provisionPlatformFiles(context, deps,
|
|
|
5681
5899
|
/** {@link filesShareDomains} of every declared org: each is a runner domain and a public host. */
|
|
5682
5900
|
shareDomains) {
|
|
5683
5901
|
const { platform } = context;
|
|
5684
|
-
|
|
5685
|
-
|
|
5902
|
+
return provisionFilesHost(scopeFromPlatform(context), deps, platformTarget(), {
|
|
5903
|
+
hostname: platform.files.hostname,
|
|
5904
|
+
gitRef: platform.files.gitRef,
|
|
5905
|
+
project: platformFilesProject,
|
|
5906
|
+
issuer: `https://${platform.auth.hostname}`,
|
|
5907
|
+
zitadelOrg: { name: PLATFORM_AUTH_ORG_NAME },
|
|
5908
|
+
zitadelProject: {
|
|
5909
|
+
name: FILES_ZITADEL_PROJECT_NAME,
|
|
5910
|
+
scopeKey: PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY,
|
|
5911
|
+
},
|
|
5912
|
+
shareDomains,
|
|
5913
|
+
});
|
|
5914
|
+
}
|
|
5915
|
+
/** The platform's files host lives in the shared `platform` project. */
|
|
5916
|
+
const platformFilesProject = {
|
|
5917
|
+
async ensure(coolify, scope) {
|
|
5918
|
+
const recorded = scope.recordedIds()[PLATFORM_FILES_PROJECT_SCOPE_KEY];
|
|
5919
|
+
if (recorded)
|
|
5920
|
+
return recorded;
|
|
5921
|
+
const found = await coolify.findProjectByName(PLATFORM_FILES_PROJECT);
|
|
5922
|
+
const projectUuid = found
|
|
5923
|
+
? found.uuid
|
|
5924
|
+
: (await coolify.createProject(PLATFORM_FILES_PROJECT)).uuid;
|
|
5925
|
+
scope.recordScopeId(PLATFORM_FILES_PROJECT_SCOPE_KEY, projectUuid);
|
|
5926
|
+
await scope.persist();
|
|
5927
|
+
return projectUuid;
|
|
5928
|
+
},
|
|
5929
|
+
};
|
|
5930
|
+
/** The credential id of the keyring an own files host runs on; recorded once both apps carry it. */
|
|
5931
|
+
export const OWN_FILES_KEYS_SCOPE_KEY = 'files_keys_id';
|
|
5932
|
+
/** The credential id minted and not yet confirmed delivered. */
|
|
5933
|
+
export const OWN_FILES_KEYS_UNDELIVERED_SCOPE_KEY = 'files_keys_undelivered';
|
|
5934
|
+
/**
|
|
5935
|
+
* An own files host's keyring, before its env writes. Both refs held: those values, no mint (a rerun
|
|
5936
|
+
* after a failed run resumes here). Nothing held: one mint, which is checked, registered as a
|
|
5937
|
+
* secret, recorded by id as undelivered, then delivered to both refs. A re-mint is harmless: it
|
|
5938
|
+
* yields an equivalent credential. Returns the guard id for {@link FilesHostInput.keysGuardId}.
|
|
5939
|
+
*/
|
|
5940
|
+
export async function resolveOwnFilesKeys(scope, deps, target, mint, workerUrl, label) {
|
|
5941
|
+
const runtime = deps.runtime ?? {};
|
|
5942
|
+
const recorded = scope.recordedIds();
|
|
5943
|
+
const credential = await deps.readSecret?.(target.refs.keysCredential);
|
|
5944
|
+
const verifyKeys = await deps.readSecret?.(target.refs.keysVerifyKeys);
|
|
5945
|
+
if (credential && verifyKeys) {
|
|
5946
|
+
runtime.onSecret?.(credential);
|
|
5947
|
+
return {
|
|
5948
|
+
keys: { workerUrl, credential, verifyKeys },
|
|
5949
|
+
guardId: recorded[OWN_FILES_KEYS_SCOPE_KEY] ||
|
|
5950
|
+
recorded[OWN_FILES_KEYS_UNDELIVERED_SCOPE_KEY] ||
|
|
5951
|
+
'held',
|
|
5952
|
+
};
|
|
5953
|
+
}
|
|
5954
|
+
const answer = await mint();
|
|
5955
|
+
runtime.onSecret?.(answer.credential);
|
|
5956
|
+
const keys = { workerUrl, credential: answer.credential, verifyKeys: answer.verifyKeys };
|
|
5957
|
+
const invalid = answer.credential
|
|
5958
|
+
? filesDepsInvalid({ keys })
|
|
5959
|
+
: 'credential';
|
|
5960
|
+
if (invalid)
|
|
5961
|
+
throw new Error(`${label}: the keys worker's answer is not usable (${invalid})`);
|
|
5962
|
+
// Recorded and persisted before the credential leaves this function.
|
|
5963
|
+
scope.recordScopeId(OWN_FILES_KEYS_UNDELIVERED_SCOPE_KEY, answer.credentialId);
|
|
5964
|
+
await scope.persist();
|
|
5965
|
+
const attempts = runtime.deliverAttempts ?? 3;
|
|
5966
|
+
const sleep = runtime.sleep ?? defaultSleep;
|
|
5967
|
+
await deliverWithRetry(deps.deliverSecret, target.refs.keysCredential, answer.credential, attempts, sleep, `the keys credential (${answer.credentialId})`, label);
|
|
5968
|
+
await deliverWithRetry(deps.deliverSecret, target.refs.keysVerifyKeys, answer.verifyKeys, attempts, sleep, `the verify keys of credential ${answer.credentialId}`, label);
|
|
5969
|
+
return { keys, guardId: answer.credentialId };
|
|
5970
|
+
}
|
|
5971
|
+
/**
|
|
5972
|
+
* The body of {@link provisionPlatformFiles} for any files host: the
|
|
5973
|
+
* platform's, or an own org's. Every name, the internal name and the two
|
|
5974
|
+
* held-key refs come from `target`; the project, sign-in and ZITADEL home
|
|
5975
|
+
* come from `input`. See {@link provisionPlatformFiles} for what the step does.
|
|
5976
|
+
*/
|
|
5977
|
+
export async function provisionFilesHost(scope, deps, target, input) {
|
|
5978
|
+
const { gitRef, shareDomains } = input;
|
|
5979
|
+
const own = target.kind === 'org';
|
|
5980
|
+
const label = own ? `own step coolify:own-files for ${target.id}` : 'platform:files';
|
|
5981
|
+
const recorded = scope.recordedIds();
|
|
5686
5982
|
const runtime = deps.runtime ?? {};
|
|
5687
5983
|
const sleep = runtime.sleep ?? defaultSleep;
|
|
5688
5984
|
const attempts = runtime.deliverAttempts ?? 3;
|
|
5689
5985
|
const keys = filesKeys(deps);
|
|
5690
5986
|
if (!keys) {
|
|
5691
|
-
throw new Error(
|
|
5692
|
-
}
|
|
5987
|
+
throw new Error(`${label} needs the keys worker URL, credential and verify keys in its files dependencies (KEYS_WORKER_URL, KEYS_CREDENTIAL, KEYS_VERIFY_KEYS); files never becomes healthy without them`);
|
|
5988
|
+
}
|
|
5989
|
+
runtime.onSecret?.(keys.credential);
|
|
5990
|
+
// The platform's audit token and jobs secrets never go to a company's host (SC4).
|
|
5991
|
+
const auditToken = own ? undefined : deps.keys?.auditToken;
|
|
5992
|
+
const jobsSigningSecrets = own ? undefined : deps.jobsSigningSecrets;
|
|
5993
|
+
if (auditToken)
|
|
5994
|
+
runtime.onSecret?.(auditToken);
|
|
5693
5995
|
// #380: R2_BUCKET/R2_ENDPOINT, the same "block the whole step" treatment
|
|
5694
5996
|
// as a missing `keys` above -- the runner's `ensureStarted()` requires
|
|
5695
5997
|
// both, and only this function's caller (`blockedSteps`, `planFiles`) ever
|
|
5696
5998
|
// has anything useful to report while they are unset.
|
|
5697
5999
|
const r2 = filesR2(deps);
|
|
5698
6000
|
if (!r2) {
|
|
5699
|
-
throw new Error(
|
|
6001
|
+
throw new Error(`${label} needs the R2 bucket and endpoint in its files dependencies (R2_BUCKET, R2_ENDPOINT); files never becomes healthy without them`);
|
|
5700
6002
|
}
|
|
5701
6003
|
// Never plan-and-throw: the planner reports a lost secret and plans
|
|
5702
6004
|
// nothing; this is the same rule for a caller that reaches the step anyway.
|
|
@@ -5706,28 +6008,21 @@ shareDomains) {
|
|
|
5706
6008
|
]) {
|
|
5707
6009
|
const lost = recorded[undeliveredKey];
|
|
5708
6010
|
if (lost && !recorded[guardKey]) {
|
|
5709
|
-
throw new Error(
|
|
6011
|
+
throw new Error(`${label}: a secret (${lost}) was minted but never delivered; remove the recorded key ${undeliveredKey} to mint a new one`);
|
|
5710
6012
|
}
|
|
5711
6013
|
}
|
|
5712
6014
|
const record = async (key, id) => {
|
|
5713
|
-
|
|
5714
|
-
await
|
|
6015
|
+
scope.recordScopeId(key, id);
|
|
6016
|
+
await scope.persist();
|
|
5715
6017
|
};
|
|
5716
|
-
|
|
5717
|
-
if (!projectUuid) {
|
|
5718
|
-
const found = await deps.coolify.findProjectByName(PLATFORM_FILES_PROJECT);
|
|
5719
|
-
projectUuid = found
|
|
5720
|
-
? found.uuid
|
|
5721
|
-
: (await deps.coolify.createProject(PLATFORM_FILES_PROJECT)).uuid;
|
|
5722
|
-
await record(PLATFORM_FILES_PROJECT_SCOPE_KEY, projectUuid);
|
|
5723
|
-
}
|
|
6018
|
+
const projectUuid = await input.project.ensure(deps.coolify, scope);
|
|
5724
6019
|
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
5725
6020
|
// ---- database ----------------------------------------------------------
|
|
5726
6021
|
let dbId = recorded[PLATFORM_FILES_DB_SCOPE_KEY];
|
|
5727
6022
|
if (!dbId) {
|
|
5728
6023
|
// Found by name first, so a crash between create and record never makes
|
|
5729
6024
|
// a second one (`coolifyJobs` does the same).
|
|
5730
|
-
const found = (await deps.coolify.listDatabases()).find((db) => db.name ===
|
|
6025
|
+
const found = (await deps.coolify.listDatabases()).find((db) => db.name === target.names.filesDb && db.environment_id === environment.id);
|
|
5731
6026
|
if (found) {
|
|
5732
6027
|
dbId = found.uuid;
|
|
5733
6028
|
}
|
|
@@ -5738,7 +6033,7 @@ shareDomains) {
|
|
|
5738
6033
|
server_uuid: server.uuid,
|
|
5739
6034
|
environment_name: environment.name,
|
|
5740
6035
|
environment_uuid: environment.uuid,
|
|
5741
|
-
name:
|
|
6036
|
+
name: target.names.filesDb,
|
|
5742
6037
|
postgres_db: FILES_DB,
|
|
5743
6038
|
postgres_user: FILES_DB,
|
|
5744
6039
|
});
|
|
@@ -5748,7 +6043,7 @@ shareDomains) {
|
|
|
5748
6043
|
}
|
|
5749
6044
|
const db = await getDatabaseStarted(deps.coolify, dbId);
|
|
5750
6045
|
if (!db.internal_db_url) {
|
|
5751
|
-
throw new Error('coolify:app for the platform (files): the database has no internal_db_url yet -- retry this apply once Coolify reports one
|
|
6046
|
+
throw new Error(`${own ? label : 'coolify:app for the platform (files)'}: the database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
5752
6047
|
}
|
|
5753
6048
|
// ---- the two apps --------------------------------------------------------
|
|
5754
6049
|
const createFilesApp = (name) => async () => {
|
|
@@ -5766,7 +6061,7 @@ shareDomains) {
|
|
|
5766
6061
|
name,
|
|
5767
6062
|
});
|
|
5768
6063
|
};
|
|
5769
|
-
const migrateApp = await findOrCreatePlatformApp(deps.coolify,
|
|
6064
|
+
const migrateApp = await findOrCreatePlatformApp(deps.coolify, target.names.filesMigrate, environment, createFilesApp(target.names.filesMigrate));
|
|
5770
6065
|
await record(PLATFORM_FILES_MIGRATE_SCOPE_KEY, migrateApp.appId);
|
|
5771
6066
|
// Set on every run, like `platform:auth`'s `custom_internal_name`: a throw
|
|
5772
6067
|
// right after a create must not leave the app on the wrong target.
|
|
@@ -5775,11 +6070,11 @@ shareDomains) {
|
|
|
5775
6070
|
dockerfile_target_build: 'migration',
|
|
5776
6071
|
git_branch: gitRef,
|
|
5777
6072
|
});
|
|
5778
|
-
const runnerApp = await findOrCreatePlatformApp(deps.coolify,
|
|
6073
|
+
const runnerApp = await findOrCreatePlatformApp(deps.coolify, target.names.filesRunner, environment, createFilesApp(target.names.filesRunner));
|
|
5779
6074
|
await record(PLATFORM_FILES_RUNNER_SCOPE_KEY, runnerApp.appId);
|
|
5780
6075
|
await deps.coolify.updateApplication(runnerApp.appId, {
|
|
5781
6076
|
dockerfile_target_build: 'runner',
|
|
5782
|
-
custom_internal_name:
|
|
6077
|
+
custom_internal_name: target.internal.files,
|
|
5783
6078
|
git_branch: gitRef,
|
|
5784
6079
|
// #543: the health-check quartet, same reason Archon's doc comment
|
|
5785
6080
|
// gives. The migrate app gets none -- it idles rather than serves
|
|
@@ -5791,13 +6086,14 @@ shareDomains) {
|
|
|
5791
6086
|
// nobody gave a share host keeps no `domains`.
|
|
5792
6087
|
...filesShareDomainsPatch(recorded, shareDomains),
|
|
5793
6088
|
});
|
|
5794
|
-
|
|
6089
|
+
scope.recordScopeId(PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY, normalHosts(shareDomains).join(','));
|
|
5795
6090
|
// ---- database env ----------------------------------------------------------
|
|
5796
6091
|
// The migrate app sets `files_rt`'s password from DATABASE_URL on every
|
|
5797
6092
|
// boot (files' `scripts/db-runtime-role.sh`), and the runner connects with
|
|
5798
6093
|
// the same URL, so both apps carry one value, minted once and never
|
|
5799
6094
|
// rotated: an existing value on either app is reused.
|
|
5800
6095
|
const runtimeUrl = await ensureFilesRuntimeUrl(deps.coolify, [migrateApp.appId, runnerApp.appId], db.internal_db_url);
|
|
6096
|
+
runtime.onSecret?.(runtimeUrl);
|
|
5801
6097
|
// The keys worker the migrate app needs to become healthy (files' README:
|
|
5802
6098
|
// `DATABASE_URL_OWNER`, `DATABASE_URL`, `KEYS_WORKER_URL`, `KEYS_CREDENTIAL`).
|
|
5803
6099
|
// `files-migrate` never runs `ensureStarted()`, so it needs neither
|
|
@@ -5812,11 +6108,13 @@ shareDomains) {
|
|
|
5812
6108
|
...migrateKeysEnvs,
|
|
5813
6109
|
]);
|
|
5814
6110
|
// ---- the files ZITADEL project, in the platform's sign-in ----------------
|
|
5815
|
-
const issuer =
|
|
6111
|
+
const { issuer } = input;
|
|
5816
6112
|
const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
|
|
5817
|
-
const
|
|
5818
|
-
|
|
5819
|
-
|
|
6113
|
+
const zitadelOrgId = 'id' in input.zitadelOrg
|
|
6114
|
+
? input.zitadelOrg.id
|
|
6115
|
+
: (await zitadel.findOrCreateOrg(input.zitadelOrg.name)).org.id;
|
|
6116
|
+
const { project } = await zitadel.findOrCreateProject(zitadelOrgId, input.zitadelProject.name);
|
|
6117
|
+
await record(input.zitadelProject.scopeKey, project.id);
|
|
5820
6118
|
// #380: the runner's own required settings -- `KEYS_VERIFY_KEYS` and
|
|
5821
6119
|
// `R2_BUCKET`/`R2_ENDPOINT` -- plus whatever optional ones the host
|
|
5822
6120
|
// supplies. `files-migrate` needs none of these (see `migrateKeysEnvs`
|
|
@@ -5825,10 +6123,10 @@ shareDomains) {
|
|
|
5825
6123
|
...migrateKeysEnvs,
|
|
5826
6124
|
{ key: 'KEYS_VERIFY_KEYS', value: keys.verifyKeys },
|
|
5827
6125
|
];
|
|
5828
|
-
if (
|
|
6126
|
+
if (auditToken)
|
|
5829
6127
|
runnerKeysEnvs.push({
|
|
5830
6128
|
key: 'KEYS_AUDIT_TOKEN',
|
|
5831
|
-
value:
|
|
6129
|
+
value: auditToken,
|
|
5832
6130
|
});
|
|
5833
6131
|
const runnerOptionalEnvs = [];
|
|
5834
6132
|
if (deps.clamd?.host) {
|
|
@@ -5843,13 +6141,16 @@ shareDomains) {
|
|
|
5843
6141
|
});
|
|
5844
6142
|
}
|
|
5845
6143
|
runnerOptionalEnvs.push(...filesPublicHostsEnvs(recorded, deps.publicHosts, shareDomains));
|
|
5846
|
-
if (
|
|
6144
|
+
if (jobsSigningSecrets)
|
|
5847
6145
|
runnerOptionalEnvs.push({
|
|
5848
6146
|
key: 'JOBS_SIGNING_SECRETS',
|
|
5849
|
-
value:
|
|
6147
|
+
value: jobsSigningSecrets,
|
|
5850
6148
|
});
|
|
5851
6149
|
// #501: the same person platform:archon names; Files has no sign-in page.
|
|
5852
|
-
|
|
6150
|
+
// An own org's files host gets the platform's owner too, carried in by the planner: its own scope holds none.
|
|
6151
|
+
const ownerInviteUserId = own
|
|
6152
|
+
? input.bootstrapOperator
|
|
6153
|
+
: recorded[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
|
|
5853
6154
|
if (ownerInviteUserId && ownerInviteUserId !== 'none') {
|
|
5854
6155
|
runnerOptionalEnvs.push({ key: 'AUTHZ_BOOTSTRAP_OPERATOR', value: ownerInviteUserId });
|
|
5855
6156
|
}
|
|
@@ -5857,7 +6158,7 @@ shareDomains) {
|
|
|
5857
6158
|
{ key: 'DATABASE_URL', value: runtimeUrl },
|
|
5858
6159
|
// The management verifier's audience, which must be an HTTPS origin. The
|
|
5859
6160
|
// host has no public address, so this is a name, not a route.
|
|
5860
|
-
{ key: 'APP_URL', value: `https://${
|
|
6161
|
+
{ key: 'APP_URL', value: `https://${input.hostname}` },
|
|
5861
6162
|
{ key: 'AUTH_ISSUER', value: issuer },
|
|
5862
6163
|
{ key: 'AUTH_PERSON_TOKEN_AUDIENCE', value: project.id },
|
|
5863
6164
|
...runnerKeysEnvs,
|
|
@@ -5872,28 +6173,32 @@ shareDomains) {
|
|
|
5872
6173
|
// Base env first, so a secret's own write below cannot be the only one
|
|
5873
6174
|
// that lands, and a re-run with a changed hostname updates `APP_URL`.
|
|
5874
6175
|
await deps.coolify.bulkUpsertEnvs(runnerApp.appId, runnerEnvs);
|
|
6176
|
+
// Both apps now carry the company's own keyring: the delivery is confirmed.
|
|
6177
|
+
if (own && input.keysGuardId)
|
|
6178
|
+
await record(OWN_FILES_KEYS_SCOPE_KEY, input.keysGuardId);
|
|
5875
6179
|
// ---- the introspection client, once --------------------------------------
|
|
5876
6180
|
if (!recorded[PLATFORM_FILES_INTROSPECTION_SCOPE_KEY]) {
|
|
5877
6181
|
// An app by this name here has no recorded secret (no undelivered id, or
|
|
5878
6182
|
// the throw above would have fired): a crash between the create and the
|
|
5879
6183
|
// record. Nothing was delivered, so it is deleted and made again.
|
|
5880
|
-
const stale = (await zitadel.listApps(
|
|
6184
|
+
const stale = (await zitadel.listApps(zitadelOrgId, project.id)).apps.filter((app) => app.name === FILES_INTROSPECTION_APP_NAME && app.state !== 'APP_STATE_REMOVED');
|
|
5881
6185
|
for (const app of stale) {
|
|
5882
6186
|
try {
|
|
5883
|
-
await zitadel.deleteApp(
|
|
6187
|
+
await zitadel.deleteApp(zitadelOrgId, project.id, app.id);
|
|
5884
6188
|
}
|
|
5885
6189
|
catch (error) {
|
|
5886
6190
|
if (!isNotFoundError(error))
|
|
5887
6191
|
throw error;
|
|
5888
6192
|
}
|
|
5889
6193
|
}
|
|
5890
|
-
const client = await zitadel.createApiApp(
|
|
6194
|
+
const client = await zitadel.createApiApp(zitadelOrgId, project.id, {
|
|
5891
6195
|
name: FILES_INTROSPECTION_APP_NAME,
|
|
5892
6196
|
});
|
|
6197
|
+
runtime.onSecret?.(client.clientSecret);
|
|
5893
6198
|
// Recorded and persisted before the secret leaves this function: a failed
|
|
5894
6199
|
// delivery or env write is then reported by the planner, not remade.
|
|
5895
6200
|
await record(PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, client.appId);
|
|
5896
|
-
await deliverWithRetry(deps.deliverSecret,
|
|
6201
|
+
await deliverWithRetry(deps.deliverSecret, target.refs.filesIntrospection, client.clientSecret, attempts, sleep, `the introspection client secret (ZITADEL app ${client.appId})`, label);
|
|
5897
6202
|
await deps.coolify.bulkUpsertEnvs(runnerApp.appId, [
|
|
5898
6203
|
{
|
|
5899
6204
|
key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_ID',
|
|
@@ -5912,8 +6217,9 @@ shareDomains) {
|
|
|
5912
6217
|
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
5913
6218
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
5914
6219
|
});
|
|
6220
|
+
runtime.onSecret?.(privateKey);
|
|
5915
6221
|
await record(PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, FILES_MANAGEMENT_KEY_ID);
|
|
5916
|
-
await deliverWithRetry(deps.deliverSecret,
|
|
6222
|
+
await deliverWithRetry(deps.deliverSecret, target.refs.filesManagement, privateKey, attempts, sleep, 'the files management key', label);
|
|
5917
6223
|
await deps.coolify.bulkUpsertEnvs(runnerApp.appId, [
|
|
5918
6224
|
{ key: 'MANAGEMENT_ISSUER', value: FILES_MANAGEMENT_ISSUER },
|
|
5919
6225
|
{
|
|
@@ -5929,12 +6235,12 @@ shareDomains) {
|
|
|
5929
6235
|
// container before the new migration has run.
|
|
5930
6236
|
await stopIfRunning(deps.coolify, migrateApp.appId);
|
|
5931
6237
|
const migrateDeployed = await deps.coolify.deploy(migrateApp.appId);
|
|
5932
|
-
await waitForMigration(deps.coolify, migrateApp.appId, runtime, sleep);
|
|
6238
|
+
await waitForMigration(deps.coolify, migrateApp.appId, runtime, sleep, label, target.names.filesMigrate);
|
|
5933
6239
|
// #399: files-migrate idles rather than exits once it has migrated, so it
|
|
5934
6240
|
// reads healthy well before Coolify's own deployment does; stopping it on
|
|
5935
6241
|
// that signal alone can find the container already gone and make Coolify
|
|
5936
6242
|
// mark a deployment that actually migrated as `failed`.
|
|
5937
|
-
await waitForMigrateDeployment(deps.coolify, migrateApp.appId, queuedDeploymentUuid(migrateDeployed) ?? 'untracked',
|
|
6243
|
+
await waitForMigrateDeployment(deps.coolify, migrateApp.appId, queuedDeploymentUuid(migrateDeployed) ?? 'untracked', label, target.names.filesMigrate, runtime.now ?? Date.now, runtime.migrateTimeoutMs ?? 900_000, runtime.migrateIntervalMs ?? 10_000, sleep);
|
|
5938
6244
|
await deps.coolify.raw('POST', `/applications/${migrateApp.appId}/stop`);
|
|
5939
6245
|
// #252/#543: no public `domains`, tunnel or DNS record is wired to the
|
|
5940
6246
|
// runner (its own doc comment: "Nothing public is wired"), but it does
|
|
@@ -5945,11 +6251,11 @@ shareDomains) {
|
|
|
5945
6251
|
// container); `coolify.deployAndWait` reaching Coolify's own deployment
|
|
5946
6252
|
// record first is still what rules out the old container staying healthy
|
|
5947
6253
|
// through a failed deploy.
|
|
5948
|
-
const internalUrl = `http://${
|
|
5949
|
-
await waitForNewDeployment(deps.coolify, runnerApp.appId, { kind: 'ready', url: `${internalUrl}${FILES_HEALTH_CHECK_PATH}` }, runtime, sleep,
|
|
6254
|
+
const internalUrl = `http://${target.internal.files}:${FILES_PORT}`;
|
|
6255
|
+
await waitForNewDeployment(deps.coolify, runnerApp.appId, { kind: 'ready', url: `${internalUrl}${FILES_HEALTH_CHECK_PATH}` }, runtime, sleep, label, `the runner stays at ${recorded[PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
5950
6256
|
await record(PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, internalUrl);
|
|
5951
6257
|
// Last: a run that failed anywhere above is planned again.
|
|
5952
|
-
|
|
6258
|
+
scope.recordScopeId(PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, input.hostname);
|
|
5953
6259
|
await record(PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
5954
6260
|
return { resourceId: runnerApp.appId };
|
|
5955
6261
|
}
|
|
@@ -6577,7 +6883,7 @@ function aiKeys(deps) {
|
|
|
6577
6883
|
const keys = deps?.keys;
|
|
6578
6884
|
return keys?.workerUrl && keys.credential && keys.verifyKeys ? keys : null;
|
|
6579
6885
|
}
|
|
6580
|
-
function filesKeys(deps) {
|
|
6886
|
+
export function filesKeys(deps) {
|
|
6581
6887
|
const keys = deps?.keys;
|
|
6582
6888
|
return keys?.workerUrl && keys.credential && keys.verifyKeys
|
|
6583
6889
|
? {
|
|
@@ -6600,7 +6906,7 @@ function filesR2(deps) {
|
|
|
6600
6906
|
* naming the field instead of deploying a host that never turns healthy.
|
|
6601
6907
|
* Never echoes the value.
|
|
6602
6908
|
*/
|
|
6603
|
-
function filesDepsInvalid(deps) {
|
|
6909
|
+
export function filesDepsInvalid(deps) {
|
|
6604
6910
|
const r2 = filesR2(deps);
|
|
6605
6911
|
if (r2) {
|
|
6606
6912
|
let ok = false;
|
|
@@ -6727,7 +7033,7 @@ async function ensureFilesRuntimeUrl(coolify, appIds, dbInternalUrl) {
|
|
|
6727
7033
|
* stands in for the log line). Throws once `migrateTimeoutMs` has passed; the
|
|
6728
7034
|
* runner is then never deployed against an unmigrated database.
|
|
6729
7035
|
*/
|
|
6730
|
-
async function waitForMigration(coolify, appId, runtime, sleep) {
|
|
7036
|
+
async function waitForMigration(coolify, appId, runtime, sleep, label = 'platform:files', appName = 'files-migrate') {
|
|
6731
7037
|
const now = runtime.now ?? Date.now;
|
|
6732
7038
|
const timeout = runtime.migrateTimeoutMs ?? 900_000;
|
|
6733
7039
|
const interval = runtime.migrateIntervalMs ?? 10_000;
|
|
@@ -6740,7 +7046,7 @@ async function waitForMigration(coolify, appId, runtime, sleep) {
|
|
|
6740
7046
|
if (status !== 'running:healthy')
|
|
6741
7047
|
left = true;
|
|
6742
7048
|
if (now() >= deadline) {
|
|
6743
|
-
throw new Error(
|
|
7049
|
+
throw new Error(`${label}: ${appName} (${appId}) did not report healthy within ${Math.round(timeout / 1000)}s (last status: ${status ?? 'unknown'}); the files app was not deployed. Check its logs for "files-migrate: done" and retry this apply`);
|
|
6744
7050
|
}
|
|
6745
7051
|
await sleep(interval);
|
|
6746
7052
|
}
|
|
@@ -6950,6 +7256,8 @@ async function provisionPlatformIntegrations(action, context, deps) {
|
|
|
6950
7256
|
await coolify.updateApplication(runnerApp.appId, {
|
|
6951
7257
|
dockerfile_target_build: INTEGRATIONS_RUNNER_APP.target,
|
|
6952
7258
|
ports_exposes: INTEGRATIONS_RUNNER_PORT,
|
|
7259
|
+
// The runner's Dockerfile HEALTHCHECK (curl on /api/health) is the live check.
|
|
7260
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
6953
7261
|
git_branch: gitRef,
|
|
6954
7262
|
domains: coolifyDomains([hostname]),
|
|
6955
7263
|
});
|
|
@@ -7964,7 +8272,7 @@ export class ProvidersPlatformCoolifyReader {
|
|
|
7964
8272
|
* answers with the parent zone. The platform's zone is its apex exactly;
|
|
7965
8273
|
* any other zone is never adopted or reported as the platform's.
|
|
7966
8274
|
*/
|
|
7967
|
-
async function exactZone(client, apex) {
|
|
8275
|
+
export async function exactZone(client, apex) {
|
|
7968
8276
|
const zone = await client.findZoneByApex(apex);
|
|
7969
8277
|
return zone && zone.name === apex ? zone : undefined;
|
|
7970
8278
|
}
|