@wtfalch/foundry 0.32.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/bootstrap.d.ts +18 -1
- package/dist/bootstrap/bootstrap.js +167 -28
- package/dist/bootstrap/declare.d.ts +5 -1
- package/dist/bootstrap/declare.js +3 -1
- package/dist/bootstrap/index.d.ts +1 -1
- package/dist/bootstrap/versions.d.ts +4 -0
- package/dist/calls-sfu.d.ts +10 -0
- package/dist/calls-sfu.js +41 -0
- package/dist/index.d.ts +18 -8
- package/dist/index.js +8 -3
- package/dist/migrations/0051_org_services.sql +13 -0
- package/dist/migrations/0052_platform_mail_relay.sql +8 -0
- package/dist/org.d.ts +24 -2
- package/dist/org.js +42 -0
- package/dist/plan/boule.d.ts +24 -1
- package/dist/plan/boule.js +29 -1
- package/dist/plan/calls-matrix.d.ts +9 -0
- package/dist/plan/calls-matrix.js +17 -0
- package/dist/plan/cloudflared-connector.d.ts +7 -1
- package/dist/plan/cloudflared-connector.js +14 -1
- package/dist/plan/diff.d.ts +32 -1
- package/dist/plan/diff.js +383 -26
- package/dist/plan/executor.d.ts +177 -7
- package/dist/plan/executor.js +738 -51
- package/dist/plan/health-check-settings.d.ts +28 -0
- package/dist/plan/health-check-settings.js +36 -0
- package/dist/plan/health-gaps.d.ts +33 -0
- package/dist/plan/health-gaps.js +89 -0
- package/dist/plan/health-targets.js +1 -1
- package/dist/plan/held-key-refs.d.ts +10 -0
- package/dist/plan/held-key-refs.js +10 -0
- package/dist/plan/index.d.ts +16 -6
- package/dist/plan/index.js +8 -3
- package/dist/plan/live.d.ts +5 -2
- package/dist/plan/live.js +15 -2
- package/dist/plan/locked-hostnames.d.ts +5 -2
- package/dist/plan/locked-hostnames.js +9 -1
- package/dist/plan/login-host.d.ts +62 -2
- package/dist/plan/login-host.js +104 -1
- package/dist/plan/mail-relay.d.ts +120 -0
- package/dist/plan/mail-relay.js +323 -0
- package/dist/plan/org-names.d.ts +24 -0
- package/dist/plan/org-names.js +52 -0
- package/dist/plan/own-diff.d.ts +169 -0
- package/dist/plan/own-diff.js +432 -0
- package/dist/plan/own-dns.d.ts +6 -0
- package/dist/plan/own-dns.js +15 -0
- package/dist/plan/own-limits.d.ts +22 -0
- package/dist/plan/own-limits.js +101 -0
- package/dist/plan/own-services.d.ts +193 -0
- package/dist/plan/own-services.js +167 -0
- package/dist/plan/own-sfu.d.ts +69 -0
- package/dist/plan/own-sfu.js +255 -0
- package/dist/plan/plan-apply.d.ts +101 -7
- package/dist/plan/plan-apply.js +216 -12
- package/dist/plan/platform-diff.d.ts +61 -12
- package/dist/plan/platform-diff.js +140 -34
- package/dist/plan/platform-org.d.ts +36 -0
- package/dist/plan/platform-org.js +96 -2
- package/dist/plan/platform-providers.d.ts +407 -12
- package/dist/plan/platform-providers.js +947 -422
- package/dist/plan/sign-in-org.d.ts +99 -0
- package/dist/plan/sign-in-org.js +151 -0
- package/dist/plan/zitadel-login-policy.js +1 -1
- package/dist/plan/zitadel-system-api.d.ts +22 -1
- package/dist/plan/zitadel-system-api.js +49 -8
- package/dist/plan.d.ts +18 -0
- package/dist/platform.d.ts +8 -0
- package/dist/slug.js +1 -0
- package/dist/store/postgres.js +51 -2
- package/dist/store/schema.d.ts +51 -0
- package/dist/store/schema.js +5 -0
- package/package.json +1 -1
- package/versions.json +3 -1
|
@@ -3,18 +3,27 @@ import { isIPv4 } from 'node:net';
|
|
|
3
3
|
import { applyObjects, openInstance, platformMailSettingsObjects } from '@wtfalch/email/postmaster';
|
|
4
4
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
5
5
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
6
|
+
import { ownerMailboxes } from '../org.js';
|
|
7
|
+
import { BOULE_BRIDGE_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_PEER_ROLE, bouleHostnameFor, } from './boule.js';
|
|
6
8
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
7
9
|
import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
|
|
8
|
-
import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
|
|
10
|
+
import { ACCESS_POLICY_NAME, coolifyProjectMark, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
|
|
9
11
|
import { ACCESS_PROVIDER_RECORD_KEY, GATE_CLAIM_NAME } from './gates.js';
|
|
10
|
-
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';
|
|
11
15
|
import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
|
|
12
|
-
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';
|
|
20
|
+
import { ensureSignInOrg, inviteOwnerWithLink, signInOrgDomain } from './sign-in-org.js';
|
|
21
|
+
export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
|
|
13
22
|
import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
|
|
14
23
|
import { heldUptimeValues, provisionPlatformUptime, } from './uptime-worker.js';
|
|
15
|
-
import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
|
|
24
|
+
import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, waitForSystemApi, } from './zitadel-system-api.js';
|
|
16
25
|
/** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
|
|
17
|
-
function apexZoneKey(apex) {
|
|
26
|
+
export function apexZoneKey(apex) {
|
|
18
27
|
return `cloudflare:zone:${apex}`;
|
|
19
28
|
}
|
|
20
29
|
/** The key `runPlatformSteps` records the jobs scheduler app's id under (`system:kind:key`, #173). */
|
|
@@ -25,7 +34,7 @@ const JOBS_APP_KEY = `coolify:jobs:${JOBS_SCHEDULER_KEY}`;
|
|
|
25
34
|
* those exist. Edge's step runs first and records the address
|
|
26
35
|
* (`planPlatform`), so a missing one means that step has not finished.
|
|
27
36
|
*/
|
|
28
|
-
async function platformDns(deps, recorded, step) {
|
|
37
|
+
export async function platformDns(deps, recorded, step) {
|
|
29
38
|
const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
|
|
30
39
|
if (!edgeUrl) {
|
|
31
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`);
|
|
@@ -33,7 +42,7 @@ async function platformDns(deps, recorded, step) {
|
|
|
33
42
|
return deps.dns(edgeUrl);
|
|
34
43
|
}
|
|
35
44
|
/** The tunnel port for a platform step; same address and same reason as {@link platformDns}. */
|
|
36
|
-
async function platformTunnels(deps, recorded, step) {
|
|
45
|
+
export async function platformTunnels(deps, recorded, step) {
|
|
37
46
|
const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
|
|
38
47
|
if (!edgeUrl) {
|
|
39
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`);
|
|
@@ -65,16 +74,21 @@ const JOBS_DATABASE_SCOPE_KEY = 'coolify_jobs_database';
|
|
|
65
74
|
* never rotates a token the host already holds.
|
|
66
75
|
*/
|
|
67
76
|
const JOBS_ADMIN_TOKEN_SCOPE_KEY = 'jobs_admin_token_set';
|
|
68
|
-
/**
|
|
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. */
|
|
69
86
|
const JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS = 3;
|
|
70
|
-
/** The ref the scheduler's admin token is delivered under: the host's real `JobsSchedulerPort` adapter reads it back. */
|
|
71
|
-
export const JOBS_ADMIN_TOKEN_REF = 'jobs:scheduler:adminToken';
|
|
72
87
|
/** The name of the Coolify project every platform-level service (starting with jobs, #173) lives in. */
|
|
73
88
|
const PLATFORM_COOLIFY_PROJECT_NAME = 'platform';
|
|
74
89
|
/** https://github.com/wtfalch/jobs -- the scheduler's own repo, Dockerfile at its root (its own `Dockerfile`'s doc comment). */
|
|
75
90
|
const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
|
|
76
|
-
/** The
|
|
77
|
-
const JOBS_INTERNAL_NAME = 'platform-jobs';
|
|
91
|
+
/** The port the scheduler listens on (jobs' `PORT`, default 8080). */
|
|
78
92
|
const JOBS_PORT = '8080';
|
|
79
93
|
/**
|
|
80
94
|
* The real {@link PlatformStepExecutor} for the two steps built so far:
|
|
@@ -207,12 +221,25 @@ export class FoundryPlatformStepExecutor {
|
|
|
207
221
|
// #216 round 5: see `PlanPlatformOptions.blocked.authOtherInputsMissing`.
|
|
208
222
|
if (otherInputsMissing.length > 0)
|
|
209
223
|
blocked.authOtherInputsMissing = otherInputsMissing;
|
|
224
|
+
// C3: the owner's invite needs a work address and the host's mail port. Without them it is
|
|
225
|
+
// a blocked item, never a throw on every apply.
|
|
226
|
+
const ownerCompany = await this.declaredOrgStore.get(platform.ownerOrgId);
|
|
227
|
+
const inviteBlocked = ownerInviteBlockedReason(ownerCompany, Boolean(this.auth.ownerInviteMail));
|
|
228
|
+
if (inviteBlocked)
|
|
229
|
+
blocked.authOwnerInvite = inviteBlocked;
|
|
210
230
|
}
|
|
211
231
|
if (platform.mail.enabled) {
|
|
212
232
|
const mailBlocked = await mailBlockedReason(platform, this.mail, recordedIds);
|
|
213
233
|
if (mailBlocked)
|
|
214
234
|
blocked.mail = mailBlocked;
|
|
215
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
|
+
}
|
|
216
243
|
if (platform.integrations.enabled && !this.integrations) {
|
|
217
244
|
blocked.integrations =
|
|
218
245
|
'the integrations host is blocked, not planned: the host built the platform executor without its integrations dependencies';
|
|
@@ -468,9 +495,18 @@ export class FoundryPlatformStepExecutor {
|
|
|
468
495
|
? recordedIds[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY]
|
|
469
496
|
: undefined);
|
|
470
497
|
const brokerDrifted = !accessDrifted && (await archonBrokerRegistrationDrifted(this.archon.coolify, recordedIds));
|
|
471
|
-
const
|
|
498
|
+
const authOrgDrifted = !accessDrifted &&
|
|
472
499
|
!brokerDrifted &&
|
|
473
|
-
(await
|
|
500
|
+
(await archonAuthOrgDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`], recordedIds));
|
|
501
|
+
const authzDrifted = !accessDrifted &&
|
|
502
|
+
!brokerDrifted &&
|
|
503
|
+
!authOrgDrifted &&
|
|
504
|
+
(await archonAuthzDatabaseDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`], await this.declaredOrgStore.get(platform.ownerOrgId)));
|
|
505
|
+
const bridgeDrifted = !accessDrifted &&
|
|
506
|
+
!brokerDrifted &&
|
|
507
|
+
!authOrgDrifted &&
|
|
508
|
+
!authzDrifted &&
|
|
509
|
+
(await archonBridgeDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`], await this.declaredOrgStore.get(platform.ownerOrgId)));
|
|
474
510
|
// #480: re-reads the live app's rolling-update/health-check settings
|
|
475
511
|
// even once `PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY` is
|
|
476
512
|
// recorded -- that marker only ever proves one past apply patched them,
|
|
@@ -479,7 +515,9 @@ export class FoundryPlatformStepExecutor {
|
|
|
479
515
|
// back off.
|
|
480
516
|
const rollingUpdateDrifted = !accessDrifted &&
|
|
481
517
|
!brokerDrifted &&
|
|
482
|
-
!
|
|
518
|
+
!authOrgDrifted &&
|
|
519
|
+
!authzDrifted &&
|
|
520
|
+
!bridgeDrifted &&
|
|
483
521
|
(await archonRollingUpdateDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]));
|
|
484
522
|
// The deployment the step triggered failed or Coolify does not know it:
|
|
485
523
|
// plan the step again, which deploys again. A finished one is confirmed
|
|
@@ -490,7 +528,9 @@ export class FoundryPlatformStepExecutor {
|
|
|
490
528
|
(await archonDeploymentState(this.archon.coolify, deployment)) === 'failed';
|
|
491
529
|
if (accessDrifted ||
|
|
492
530
|
brokerDrifted ||
|
|
493
|
-
|
|
531
|
+
authOrgDrifted ||
|
|
532
|
+
authzDrifted ||
|
|
533
|
+
bridgeDrifted ||
|
|
494
534
|
rollingUpdateDrifted ||
|
|
495
535
|
redeployFailed)
|
|
496
536
|
result.archon = true;
|
|
@@ -562,7 +602,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
562
602
|
throw new Error('platform:auth needs FoundryPlatformStepExecutor to be constructed with its auth dependencies (#168)');
|
|
563
603
|
}
|
|
564
604
|
const company = await this.declaredOrgStore.get(context.platform.ownerOrgId);
|
|
565
|
-
return provisionPlatformAuth(context, this.auth, company
|
|
605
|
+
return provisionPlatformAuth(context, this.auth, company);
|
|
566
606
|
}
|
|
567
607
|
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_FILES_KEY) {
|
|
568
608
|
if (!this.files) {
|
|
@@ -596,7 +636,8 @@ export class FoundryPlatformStepExecutor {
|
|
|
596
636
|
if (!this.archon) {
|
|
597
637
|
throw new Error('platform:archon needs FoundryPlatformStepExecutor to be constructed with its archon dependencies (#175)');
|
|
598
638
|
}
|
|
599
|
-
|
|
639
|
+
const ownerCompany = await this.declaredOrgStore.get(context.platform.ownerOrgId);
|
|
640
|
+
return provisionPlatformArchon(context, this.archon, ownerCompany);
|
|
600
641
|
}
|
|
601
642
|
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_MAIL_KEY) {
|
|
602
643
|
if (!this.mail) {
|
|
@@ -604,6 +645,14 @@ export class FoundryPlatformStepExecutor {
|
|
|
604
645
|
}
|
|
605
646
|
return provisionPlatformMail(context, this.mail);
|
|
606
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
|
+
}
|
|
607
656
|
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_HC_KEY) {
|
|
608
657
|
if (!this.hc) {
|
|
609
658
|
throw new Error('platform:hc needs FoundryPlatformStepExecutor to be constructed with its hc dependencies (#540)');
|
|
@@ -719,104 +768,148 @@ export class FoundryPlatformStepExecutor {
|
|
|
719
768
|
if (!deliverSecret) {
|
|
720
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)');
|
|
721
770
|
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
if (!projectUuid) {
|
|
727
|
-
const found = await coolify.findProjectByName(PLATFORM_COOLIFY_PROJECT_NAME);
|
|
728
|
-
projectUuid = found
|
|
729
|
-
? found.uuid
|
|
730
|
-
: (await coolify.createProject(PLATFORM_COOLIFY_PROJECT_NAME)).uuid;
|
|
731
|
-
context.recordScopeId(JOBS_PROJECT_SCOPE_KEY, projectUuid);
|
|
732
|
-
}
|
|
733
|
-
const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
734
|
-
let dbId = recorded[JOBS_DATABASE_SCOPE_KEY];
|
|
735
|
-
if (!dbId) {
|
|
736
|
-
const dbName = `${JOBS_SCHEDULER_KEY}-scheduler-db`;
|
|
737
|
-
const found = (await coolify.listDatabases()).find((db) => db.name === dbName && db.environment_id === environment.id);
|
|
738
|
-
if (found) {
|
|
739
|
-
dbId = found.uuid;
|
|
740
|
-
}
|
|
741
|
-
else {
|
|
742
|
-
const server = await resolvePlatformServer(coolify);
|
|
743
|
-
const created = await coolify.createPostgresDatabase({
|
|
744
|
-
project_uuid: projectUuid,
|
|
745
|
-
server_uuid: server.uuid,
|
|
746
|
-
environment_name: environment.name,
|
|
747
|
-
environment_uuid: environment.uuid,
|
|
748
|
-
name: dbName,
|
|
749
|
-
postgres_db: 'jobs',
|
|
750
|
-
postgres_user: 'jobs',
|
|
751
|
-
});
|
|
752
|
-
dbId = created.uuid;
|
|
753
|
-
}
|
|
754
|
-
context.recordScopeId(JOBS_DATABASE_SCOPE_KEY, dbId);
|
|
755
|
-
}
|
|
756
|
-
const existingApp = await coolify.findApplicationByName(JOBS_SCHEDULER_KEY, {
|
|
757
|
-
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,
|
|
758
775
|
});
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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;
|
|
762
816
|
}
|
|
763
817
|
else {
|
|
764
818
|
const server = await resolvePlatformServer(coolify);
|
|
765
|
-
const
|
|
766
|
-
const created = await coolify.createGithubAppApplication({
|
|
819
|
+
const created = await coolify.createPostgresDatabase({
|
|
767
820
|
project_uuid: projectUuid,
|
|
768
821
|
server_uuid: server.uuid,
|
|
769
822
|
environment_name: environment.name,
|
|
770
823
|
environment_uuid: environment.uuid,
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
build_pack: 'dockerfile',
|
|
775
|
-
name: JOBS_SCHEDULER_KEY,
|
|
776
|
-
domains: coolifyDomains([hostname]),
|
|
824
|
+
name: dbName,
|
|
825
|
+
postgres_db: 'jobs',
|
|
826
|
+
postgres_user: 'jobs',
|
|
777
827
|
});
|
|
778
|
-
|
|
828
|
+
dbId = created.uuid;
|
|
779
829
|
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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]) } : {}),
|
|
785
853
|
});
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
+
}
|
|
816
909
|
let delivered = false;
|
|
817
910
|
for (let attempt = 1; attempt <= JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS && !delivered; attempt++) {
|
|
818
911
|
try {
|
|
819
|
-
await deliverSecret(
|
|
912
|
+
await deliverSecret(target.refs.jobsAdminToken, token);
|
|
820
913
|
delivered = true;
|
|
821
914
|
}
|
|
822
915
|
catch {
|
|
@@ -824,48 +917,48 @@ export class FoundryPlatformStepExecutor {
|
|
|
824
917
|
}
|
|
825
918
|
}
|
|
826
919
|
if (!delivered) {
|
|
827
|
-
throw new Error(
|
|
920
|
+
throw new Error(`${label}: the scheduler admin token could not be delivered after ${JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS} attempts`);
|
|
828
921
|
}
|
|
829
|
-
envs.push({ key: 'SCHEDULER_ADMIN_TOKEN', value: token });
|
|
830
|
-
await coolify.bulkUpsertEnvs(appId, envs);
|
|
831
|
-
context.recordScopeId(JOBS_ADMIN_TOKEN_SCOPE_KEY, appId);
|
|
832
|
-
tokenJustSet = true;
|
|
833
922
|
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
const running = (await coolify.getApplication(appId)).status?.startsWith('running') ?? false;
|
|
841
|
-
// An app applied before its internal address was recorded has no fixed
|
|
842
|
-
// name yet, so it deploys once to get it; after that it deploys on the
|
|
843
|
-
// other conditions only.
|
|
844
|
-
const addressNew = !context.recordedIds()[JOBS_INTERNAL_URL_SCOPE_KEY];
|
|
845
|
-
// The address is recorded only once the deployment finished: the scheduler
|
|
846
|
-
// has no route that answers without its admin token (every other path is a
|
|
847
|
-
// 401, 404 or 405), so the deployment record is the whole check, as for
|
|
848
|
-
// the AI worker. A deploy that fails or times out throws here, records
|
|
849
|
-
// nothing and leaves the step planned.
|
|
850
|
-
if (!existingApp || tokenJustSet || pending || !running || addressNew) {
|
|
851
|
-
await waitForNewDeployment(coolify, appId, { kind: 'deployment-only' }, this.runtime, this.runtime.sleep ?? defaultSleep, 'platform step coolify:jobs', 'the scheduler keeps its old address');
|
|
852
|
-
}
|
|
853
|
-
if (pending) {
|
|
854
|
-
context.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '');
|
|
855
|
-
await context.persist?.();
|
|
856
|
-
}
|
|
857
|
-
context.recordScopeId(JOBS_INTERNAL_URL_SCOPE_KEY, `http://${JOBS_INTERNAL_NAME}:${JOBS_PORT}`);
|
|
858
|
-
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;
|
|
859
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 };
|
|
860
957
|
}
|
|
861
958
|
// ---------------------------------------------------------------------------
|
|
862
959
|
// #168: the platform's own sign-in (ZITADEL)
|
|
863
960
|
// ---------------------------------------------------------------------------
|
|
864
961
|
const PLATFORM_AUTH_PROJECT = 'platform';
|
|
865
|
-
const PLATFORM_AUTH_DB_NAME = 'platform-auth-db';
|
|
866
|
-
const PLATFORM_AUTH_ZITADEL_APP_NAME = 'platform-auth-zitadel';
|
|
867
|
-
const PLATFORM_AUTH_LOGIN_APP_NAME = 'platform-auth-login';
|
|
868
|
-
const PLATFORM_AUTH_BROKER_APP_NAME = 'platform-auth-broker';
|
|
869
962
|
/**
|
|
870
963
|
* Fixed `custom_internal_name`s (`diff.ts`'s `coolifyCalls` sets the same
|
|
871
964
|
* kind of name on Synapse/MAS) so the Cloudflare tunnel below can address
|
|
@@ -873,12 +966,7 @@ const PLATFORM_AUTH_BROKER_APP_NAME = 'platform-auth-broker';
|
|
|
873
966
|
* -- bypassing Coolify's own Traefik, which dispatches purely by `Host`
|
|
874
967
|
* header and so cannot give two different apps the same public hostname.
|
|
875
968
|
*/
|
|
876
|
-
const PLATFORM_AUTH_ZITADEL_INTERNAL_NAME = 'platform-auth-zitadel';
|
|
877
969
|
export const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = 'platform-auth-login';
|
|
878
|
-
const PLATFORM_AUTH_BROKER_INTERNAL_NAME = 'platform-auth-broker';
|
|
879
|
-
const PLATFORM_AUTH_TUNNEL_NAME = 'platform-auth-tunnel';
|
|
880
|
-
/** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_AUTH_TUNNEL_NAME}. */
|
|
881
|
-
const PLATFORM_AUTH_CLOUDFLARED_APP_NAME = 'platform-auth-cloudflared';
|
|
882
970
|
/** `wtfalch/auth`'s `docker-compose.yml`: `image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION:-v4.17.3}`. Pinned in `versions.json` (#247). */
|
|
883
971
|
const AUTH_ZITADEL_IMAGE = 'ghcr.io/zitadel/zitadel';
|
|
884
972
|
const AUTH_ZITADEL_IMAGE_TAG = VERSIONS.zitadel;
|
|
@@ -893,6 +981,22 @@ const AUTH_BROKER_DOCKERFILE = '/broker/Dockerfile';
|
|
|
893
981
|
* own `ZITADEL_PORT: 8080` and the login image's own `EXPOSE`.
|
|
894
982
|
*/
|
|
895
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
|
+
}
|
|
896
1000
|
export const AUTH_LOGIN_PORT = '3000';
|
|
897
1001
|
/** `broker/Dockerfile`'s own `ENV PORT=3000` / `EXPOSE 3000`. */
|
|
898
1002
|
const AUTH_BROKER_PORT = '3000';
|
|
@@ -918,8 +1022,8 @@ export const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|sy
|
|
|
918
1022
|
const AUTH_BROKER_PATH_PATTERN = '^/api(/|$)';
|
|
919
1023
|
const PLATFORM_AUTH_PROJECT_SCOPE_KEY = 'coolify_project_platform_auth';
|
|
920
1024
|
const PLATFORM_AUTH_DB_SCOPE_KEY = 'coolify_platform_auth_database';
|
|
921
|
-
const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = 'coolify_platform_auth_zitadel_app';
|
|
922
|
-
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';
|
|
923
1027
|
const PLATFORM_AUTH_TUNNEL_SCOPE_KEY = 'cloudflare_platform_auth_tunnel';
|
|
924
1028
|
/** Recorded once the ZITADEL app has been deployed, so ZITADEL may have written to its database. */
|
|
925
1029
|
const PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY = 'zitadel_platform_auth_deployed';
|
|
@@ -1048,6 +1152,10 @@ function loginClientRecordValue(userId, patId, token) {
|
|
|
1048
1152
|
* miss for a credential-liveness check than for Archon's own Access policy.
|
|
1049
1153
|
*/
|
|
1050
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) {
|
|
1051
1159
|
const record = parseLoginClientRecord(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
|
|
1052
1160
|
if (!record)
|
|
1053
1161
|
return false;
|
|
@@ -1057,7 +1165,10 @@ async function authLoginClientDrifted(platform, auth, recordedIds) {
|
|
|
1057
1165
|
if (!orgId || !loginAppId || !brokerAppId)
|
|
1058
1166
|
return true;
|
|
1059
1167
|
try {
|
|
1060
|
-
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;
|
|
1061
1172
|
const livePats = await zitadel.listLoginClientPats(orgId, record.userId);
|
|
1062
1173
|
if (livePats.length !== 1 || livePats[0] !== record.patId)
|
|
1063
1174
|
return true;
|
|
@@ -1216,27 +1327,33 @@ async function liveLoginClientTokenHash(coolify, appId) {
|
|
|
1216
1327
|
* #197: a `cloudflared` connector for this tunnel is run
|
|
1217
1328
|
* ({@link ensureCloudflaredConnector}), so `hostname` answers.
|
|
1218
1329
|
*/
|
|
1219
|
-
async function
|
|
1220
|
-
const {
|
|
1221
|
-
const
|
|
1222
|
-
const
|
|
1223
|
-
const recorded = context.recordedIds();
|
|
1330
|
+
export async function provisionAuthStack(scope, deps, target, input) {
|
|
1331
|
+
const { hostname, apex, gitRef, ownerCompany, network } = input;
|
|
1332
|
+
const owners = ownerCompany?.owners ?? [];
|
|
1333
|
+
const recorded = scope.recordedIds();
|
|
1224
1334
|
const runtime = deps.runtime ?? {};
|
|
1225
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
|
+
};
|
|
1226
1350
|
// Records an id and writes it out now, so a crash before the step ends
|
|
1227
1351
|
// cannot lose a resource a provider already created.
|
|
1228
1352
|
const record = async (key, id) => {
|
|
1229
|
-
|
|
1230
|
-
await
|
|
1353
|
+
scope.recordScopeId(key, id);
|
|
1354
|
+
await scope.persist();
|
|
1231
1355
|
};
|
|
1232
|
-
|
|
1233
|
-
if (!projectUuid) {
|
|
1234
|
-
const found = await deps.coolify.findProjectByName(PLATFORM_AUTH_PROJECT);
|
|
1235
|
-
projectUuid = found
|
|
1236
|
-
? found.uuid
|
|
1237
|
-
: (await deps.coolify.createProject(PLATFORM_AUTH_PROJECT)).uuid;
|
|
1238
|
-
await record(PLATFORM_AUTH_PROJECT_SCOPE_KEY, projectUuid);
|
|
1239
|
-
}
|
|
1356
|
+
const projectUuid = await input.project.ensure(deps.coolify, scope);
|
|
1240
1357
|
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1241
1358
|
// ---- database ----------------------------------------------------------
|
|
1242
1359
|
let dbId = recorded[PLATFORM_AUTH_DB_SCOPE_KEY];
|
|
@@ -1252,7 +1369,7 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1252
1369
|
server_uuid: server.uuid,
|
|
1253
1370
|
environment_name: environment.name,
|
|
1254
1371
|
environment_uuid: environment.uuid,
|
|
1255
|
-
name:
|
|
1372
|
+
name: target.names.authDb,
|
|
1256
1373
|
postgres_db: 'zitadel',
|
|
1257
1374
|
postgres_user: 'zitadel',
|
|
1258
1375
|
});
|
|
@@ -1265,22 +1382,19 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1265
1382
|
// `coolifyCalls` follows the same rule for its own databases): Coolify
|
|
1266
1383
|
// can take a moment to report a freshly created database's
|
|
1267
1384
|
// `internal_db_url`, so the next apply retries from a clean state.
|
|
1268
|
-
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`);
|
|
1269
1386
|
}
|
|
1270
1387
|
// ---- the ZITADEL app itself ---------------------------------------------
|
|
1271
|
-
const zitadelApp = await findOrCreatePlatformApp(deps.coolify,
|
|
1388
|
+
const zitadelApp = await findOrCreatePlatformApp(deps.coolify, target.names.authZitadel, environment, async () => {
|
|
1272
1389
|
const server = await resolvePlatformServer(deps.coolify);
|
|
1273
|
-
return deps.coolify.
|
|
1390
|
+
return deps.coolify.createDockerfileApplication({
|
|
1274
1391
|
project_uuid: projectUuid,
|
|
1275
1392
|
server_uuid: server.uuid,
|
|
1276
1393
|
environment_name: environment.name,
|
|
1277
1394
|
environment_uuid: environment.uuid,
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
custom_docker_run_options: AUTH_ZITADEL_RUN_OPTIONS,
|
|
1282
|
-
name: PLATFORM_AUTH_ZITADEL_APP_NAME,
|
|
1283
|
-
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})`,
|
|
1284
1398
|
instant_deploy: false,
|
|
1285
1399
|
});
|
|
1286
1400
|
});
|
|
@@ -1290,14 +1404,21 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1290
1404
|
// create must not leave the app unnamed for good, and the tunnel addresses
|
|
1291
1405
|
// the app by this name. (The port cannot read the current name back, and
|
|
1292
1406
|
// setting the same value again is harmless.)
|
|
1293
|
-
// The run options go in the same patch, so an app made before they existed
|
|
1294
|
-
// gets them too.
|
|
1295
|
-
await deps.coolify.updateApplication(zitadelAppId, {
|
|
1296
|
-
custom_internal_name: PLATFORM_AUTH_ZITADEL_INTERNAL_NAME,
|
|
1297
|
-
custom_docker_run_options: AUTH_ZITADEL_RUN_OPTIONS,
|
|
1298
|
-
});
|
|
1299
1407
|
// The container only takes new run options at its next deploy.
|
|
1300
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
|
+
});
|
|
1301
1422
|
// #251: only on create, never unconditionally -- nothing here patches
|
|
1302
1423
|
// `docker_registry_image_tag` on an app `findOrCreatePlatformApp` found
|
|
1303
1424
|
// already running, so that is the only moment this tag is actually live.
|
|
@@ -1308,7 +1429,7 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1308
1429
|
await record(PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, AUTH_ZITADEL_IMAGE_TAG);
|
|
1309
1430
|
}
|
|
1310
1431
|
// ---- the zitadel-login app -----------------------------------------------
|
|
1311
|
-
const loginApp = await findOrCreatePlatformApp(deps.coolify,
|
|
1432
|
+
const loginApp = await findOrCreatePlatformApp(deps.coolify, target.names.authLogin, environment, async () => {
|
|
1312
1433
|
const server = await resolvePlatformServer(deps.coolify);
|
|
1313
1434
|
const githubApp = await deps.coolify.requireGithubApp();
|
|
1314
1435
|
return deps.coolify.createGithubAppApplication({
|
|
@@ -1320,17 +1441,19 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1320
1441
|
git_repository: AUTH_REPO,
|
|
1321
1442
|
git_branch: gitRef,
|
|
1322
1443
|
build_pack: 'dockerfile',
|
|
1323
|
-
name:
|
|
1444
|
+
name: target.names.authLogin,
|
|
1324
1445
|
dockerfile_location: AUTH_LOGIN_DOCKERFILE,
|
|
1325
1446
|
});
|
|
1326
1447
|
});
|
|
1327
1448
|
const loginAppId = loginApp.appId;
|
|
1328
1449
|
await record(PLATFORM_AUTH_LOGIN_SCOPE_KEY, loginAppId);
|
|
1450
|
+
// The Dockerfile's own HEALTHCHECK is the live check (`IMAGE_HEALTHCHECK_SETTINGS`).
|
|
1329
1451
|
await deps.coolify.updateApplication(loginAppId, {
|
|
1330
|
-
custom_internal_name:
|
|
1452
|
+
custom_internal_name: target.internal.authLogin,
|
|
1453
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
1331
1454
|
});
|
|
1332
1455
|
// ---- the broker app (#204) -------------------------------------------------
|
|
1333
|
-
const brokerApp = await findOrCreatePlatformApp(deps.coolify,
|
|
1456
|
+
const brokerApp = await findOrCreatePlatformApp(deps.coolify, target.names.authBroker, environment, async () => {
|
|
1334
1457
|
const server = await resolvePlatformServer(deps.coolify);
|
|
1335
1458
|
const githubApp = await deps.coolify.requireGithubApp();
|
|
1336
1459
|
return deps.coolify.createGithubAppApplication({
|
|
@@ -1342,21 +1465,24 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1342
1465
|
git_repository: AUTH_REPO,
|
|
1343
1466
|
git_branch: gitRef,
|
|
1344
1467
|
build_pack: 'dockerfile',
|
|
1345
|
-
name:
|
|
1468
|
+
name: target.names.authBroker,
|
|
1346
1469
|
dockerfile_location: AUTH_BROKER_DOCKERFILE,
|
|
1347
1470
|
});
|
|
1348
1471
|
});
|
|
1349
1472
|
const brokerAppId = brokerApp.appId;
|
|
1350
1473
|
await record(PLATFORM_AUTH_BROKER_SCOPE_KEY, brokerAppId);
|
|
1351
1474
|
await deps.coolify.updateApplication(brokerAppId, {
|
|
1352
|
-
custom_internal_name:
|
|
1475
|
+
custom_internal_name: target.internal.authBroker,
|
|
1476
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
1353
1477
|
});
|
|
1354
1478
|
// ---- env -----------------------------------------------------------------
|
|
1355
1479
|
// Generated once, handed straight to `bulkUpsertEnvs` and nowhere else --
|
|
1356
1480
|
// never returned, recorded, logged or interpolated into a thrown error, the
|
|
1357
1481
|
// same rule `executor.ts`'s `ensureAuthCookieSecret` follows for a value
|
|
1358
1482
|
// this package mints itself rather than receiving from a provider.
|
|
1359
|
-
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);
|
|
1360
1486
|
// #283: the instance is created once, with the system API, by a platform
|
|
1361
1487
|
// whose admin machine user is not recorded yet (one that already has it was
|
|
1362
1488
|
// built from a first instance and keeps it). The keypair is regenerated on
|
|
@@ -1366,7 +1492,7 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1366
1492
|
if (lostInstance &&
|
|
1367
1493
|
!recorded[PLATFORM_AUTH_INSTANCE_SCOPE_KEY] &&
|
|
1368
1494
|
!recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
|
|
1369
|
-
throw new Error(instanceWithoutBearerMessage(hostname, `instance ${lostInstance}
|
|
1495
|
+
throw new Error(instanceWithoutBearerMessage(hostname, `instance ${lostInstance}`, label));
|
|
1370
1496
|
}
|
|
1371
1497
|
const needsInstance = !recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY] && !recorded[PLATFORM_AUTH_INSTANCE_SCOPE_KEY];
|
|
1372
1498
|
const systemKeys = needsInstance ? generateSystemApiKeypair() : undefined;
|
|
@@ -1396,6 +1522,10 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1396
1522
|
value: `https://${hostname}/login?samlRequest=`,
|
|
1397
1523
|
},
|
|
1398
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' },
|
|
1399
1529
|
];
|
|
1400
1530
|
if (masterkey)
|
|
1401
1531
|
zitadelEnvs.push({ key: 'ZITADEL_MASTERKEY', value: masterkey });
|
|
@@ -1408,7 +1538,7 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1408
1538
|
const loginEnvs = [
|
|
1409
1539
|
{
|
|
1410
1540
|
key: 'ZITADEL_API_URL',
|
|
1411
|
-
value: `http://${
|
|
1541
|
+
value: `http://${target.internal.authZitadel}:${AUTH_ZITADEL_PORT}`,
|
|
1412
1542
|
},
|
|
1413
1543
|
{
|
|
1414
1544
|
key: 'CUSTOM_REQUEST_HEADERS',
|
|
@@ -1422,8 +1552,8 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1422
1552
|
// invite's own path (set a password, then nowhere to go). Only set once
|
|
1423
1553
|
// Archon is declared, and only ever to Archon: the platform's one landing
|
|
1424
1554
|
// app for anyone who reaches the login app outside an app's own sign-in.
|
|
1425
|
-
...(
|
|
1426
|
-
? [{ key: 'DEFAULT_REDIRECT_URI', value:
|
|
1555
|
+
...(input.defaultRedirectUri
|
|
1556
|
+
? [{ key: 'DEFAULT_REDIRECT_URI', value: input.defaultRedirectUri }]
|
|
1427
1557
|
: []),
|
|
1428
1558
|
];
|
|
1429
1559
|
// #210: read back now, not inside the deploy-decision block below -- this
|
|
@@ -1431,7 +1561,9 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1431
1561
|
// the same way `envsDiffer`/`bulkUpsertEnvs` below already do for every
|
|
1432
1562
|
// other broker env, so a single apply that finds the key already held can
|
|
1433
1563
|
// also see `LETTERMINT_API_TOKEN` as met and deploy in that same run.
|
|
1434
|
-
const heldMailKey = await deps.heldKeys?.resolve(
|
|
1564
|
+
const heldMailKey = await deps.heldKeys?.resolve(target.refs.brokerMailKey);
|
|
1565
|
+
if (heldMailKey)
|
|
1566
|
+
runtime.onSecret?.(heldMailKey);
|
|
1435
1567
|
// `AUTH_APPS` and `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` are not pushed here
|
|
1436
1568
|
// -- this function's own doc comment, "Known gaps". `LETTERMINT_API_TOKEN`
|
|
1437
1569
|
// is, once held (#210): pushing it costs nothing even while the broker
|
|
@@ -1440,7 +1572,7 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1440
1572
|
const brokerEnvs = [
|
|
1441
1573
|
{
|
|
1442
1574
|
key: 'ZITADEL_API_URL',
|
|
1443
|
-
value: `http://${
|
|
1575
|
+
value: `http://${target.internal.authZitadel}:${AUTH_ZITADEL_PORT}`,
|
|
1444
1576
|
},
|
|
1445
1577
|
{ key: 'ZITADEL_DOMAIN', value: hostname },
|
|
1446
1578
|
...(heldMailKey ? [{ key: 'LETTERMINT_API_TOKEN', value: heldMailKey }] : []),
|
|
@@ -1492,25 +1624,25 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1492
1624
|
await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, authAppsRaw));
|
|
1493
1625
|
}
|
|
1494
1626
|
// ---- tunnel and DNS ------------------------------------------------------
|
|
1495
|
-
const tunnels = await
|
|
1496
|
-
const existingTunnel = await tunnels.findTunnelByName(
|
|
1497
|
-
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));
|
|
1498
1630
|
await record(PLATFORM_AUTH_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
1499
1631
|
await tunnels.putTunnelConfig(tunnel.id, {
|
|
1500
1632
|
ingress: [
|
|
1501
1633
|
{
|
|
1502
1634
|
hostname,
|
|
1503
1635
|
path: AUTH_ZITADEL_PATH_PATTERN,
|
|
1504
|
-
service: `http://${
|
|
1636
|
+
service: `http://${target.internal.authZitadel}:${AUTH_ZITADEL_PORT}`,
|
|
1505
1637
|
},
|
|
1506
1638
|
{
|
|
1507
1639
|
hostname,
|
|
1508
1640
|
path: AUTH_BROKER_PATH_PATTERN,
|
|
1509
|
-
service: `http://${
|
|
1641
|
+
service: `http://${target.internal.authBroker}:${AUTH_BROKER_PORT}`,
|
|
1510
1642
|
},
|
|
1511
1643
|
{
|
|
1512
1644
|
hostname,
|
|
1513
|
-
service: `http://${
|
|
1645
|
+
service: `http://${target.internal.authLogin}:${AUTH_LOGIN_PORT}`,
|
|
1514
1646
|
},
|
|
1515
1647
|
{ service: 'http_status:404' },
|
|
1516
1648
|
],
|
|
@@ -1523,7 +1655,7 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1523
1655
|
coolify: deps.coolify,
|
|
1524
1656
|
tunnels,
|
|
1525
1657
|
tunnelId: tunnel.id,
|
|
1526
|
-
appName:
|
|
1658
|
+
appName: target.names.authCloudflared,
|
|
1527
1659
|
projectUuid,
|
|
1528
1660
|
environment,
|
|
1529
1661
|
resolveServer: () => resolvePlatformServer(deps.coolify),
|
|
@@ -1532,15 +1664,15 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1532
1664
|
});
|
|
1533
1665
|
// The apex zone step records the zone id before this step runs, but it
|
|
1534
1666
|
// creates no record for the hostname, so it is created here.
|
|
1535
|
-
const zoneId =
|
|
1667
|
+
const zoneId = await network.zoneId(apex);
|
|
1536
1668
|
if (!zoneId) {
|
|
1537
|
-
throw new Error(
|
|
1669
|
+
throw new Error(`${label}: no Cloudflare zone for ${apex} to put ${hostname} in`);
|
|
1538
1670
|
}
|
|
1539
1671
|
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
1540
|
-
const dns = await
|
|
1541
|
-
const [dnsRecord] = await dns.findDnsRecord(
|
|
1672
|
+
const dns = await network.dns();
|
|
1673
|
+
const [dnsRecord] = await dns.findDnsRecord(apex, hostname, 'CNAME');
|
|
1542
1674
|
if (!dnsRecord) {
|
|
1543
|
-
await dns.createDnsRecord(
|
|
1675
|
+
await dns.createDnsRecord(apex, {
|
|
1544
1676
|
type: 'CNAME',
|
|
1545
1677
|
name: hostname,
|
|
1546
1678
|
content: dnsTarget,
|
|
@@ -1548,25 +1680,36 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1548
1680
|
});
|
|
1549
1681
|
}
|
|
1550
1682
|
else if (dnsRecord.content !== dnsTarget) {
|
|
1551
|
-
await dns.updateDnsRecord(
|
|
1683
|
+
await dns.updateDnsRecord(apex, dnsRecord.id, {
|
|
1552
1684
|
type: 'CNAME',
|
|
1553
1685
|
name: hostname,
|
|
1554
1686
|
content: dnsTarget,
|
|
1555
1687
|
proxied: true,
|
|
1556
1688
|
});
|
|
1557
1689
|
}
|
|
1690
|
+
if (target.kind === 'org')
|
|
1691
|
+
await recordOwnDnsHostname(scope, hostname);
|
|
1558
1692
|
// ---- foundry's own admin machine user, once ------------------------------
|
|
1559
1693
|
if (!recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
|
|
1560
1694
|
const lost = recorded[PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY];
|
|
1561
1695
|
if (lost) {
|
|
1562
1696
|
// The planner reports this and plans nothing; this is the same rule for
|
|
1563
1697
|
// a caller that reaches the step anyway.
|
|
1564
|
-
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`);
|
|
1565
1699
|
}
|
|
1566
1700
|
if (systemKeys) {
|
|
1567
1701
|
// ZITADEL answers /debug/healthz without an instance; the openid
|
|
1568
1702
|
// document below only exists once the instance does.
|
|
1569
|
-
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
|
+
});
|
|
1570
1713
|
// Sent exactly once. Anything that may have reached ZITADEL leaves a
|
|
1571
1714
|
// marker first, so the next run refuses instead of creating again.
|
|
1572
1715
|
let created;
|
|
@@ -1576,6 +1719,7 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1576
1719
|
privateKey: systemKeys.privateKey,
|
|
1577
1720
|
instanceName: PLATFORM_AUTH_ORG_NAME,
|
|
1578
1721
|
firstOrgName: PLATFORM_AUTH_ORG_NAME,
|
|
1722
|
+
label,
|
|
1579
1723
|
owner: {
|
|
1580
1724
|
userName: PLATFORM_AUTH_BEARER_USER_NAME,
|
|
1581
1725
|
name: 'Foundry (instance owner)',
|
|
@@ -1589,12 +1733,13 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1589
1733
|
throw error;
|
|
1590
1734
|
}
|
|
1591
1735
|
const { instanceId, pat } = created;
|
|
1736
|
+
runtime.onSecret?.(pat);
|
|
1592
1737
|
// Recorded before the PAT leaves this function, like the admin key below.
|
|
1593
1738
|
await record(PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, instanceId);
|
|
1594
1739
|
const attempts = runtime.deliverAttempts ?? 3;
|
|
1595
1740
|
for (let attempt = 1;; attempt += 1) {
|
|
1596
1741
|
try {
|
|
1597
|
-
await deps.deliverSecret(
|
|
1742
|
+
await deps.deliverSecret(target.refs.instanceBearer, pat);
|
|
1598
1743
|
break;
|
|
1599
1744
|
}
|
|
1600
1745
|
catch (error) {
|
|
@@ -1602,7 +1747,7 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1602
1747
|
const reason = redactSecrets(error instanceof Error ? error.message : String(error), [
|
|
1603
1748
|
pat,
|
|
1604
1749
|
]);
|
|
1605
|
-
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)}`);
|
|
1606
1751
|
}
|
|
1607
1752
|
await sleep(1000 * attempt);
|
|
1608
1753
|
}
|
|
@@ -1612,8 +1757,8 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1612
1757
|
}
|
|
1613
1758
|
// Minting needs a running instance behind the tunnel: the first apply
|
|
1614
1759
|
// gets here right after the deploy.
|
|
1615
|
-
await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep);
|
|
1616
|
-
const zitadel =
|
|
1760
|
+
await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep, label);
|
|
1761
|
+
const zitadel = instanceZitadel();
|
|
1617
1762
|
const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
|
|
1618
1763
|
// #216 round 5: recorded here too (mirroring `provisionPlatformArchon`),
|
|
1619
1764
|
// so `authLoginClientDrifted` (below) never needs its own ZITADEL call
|
|
@@ -1625,6 +1770,7 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1625
1770
|
userName: PLATFORM_AUTH_ADMIN_USER_NAME,
|
|
1626
1771
|
name: 'Foundry (platform admin)',
|
|
1627
1772
|
});
|
|
1773
|
+
runtime.onSecret?.(token);
|
|
1628
1774
|
// Recorded and persisted before the token leaves this function: if
|
|
1629
1775
|
// delivery fails for good, the next plan reports the lost key rather than
|
|
1630
1776
|
// skipping or minting again. Only a delivered key records the guard.
|
|
@@ -1636,12 +1782,12 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1636
1782
|
const attempts = runtime.deliverAttempts ?? 3;
|
|
1637
1783
|
for (let attempt = 1;; attempt += 1) {
|
|
1638
1784
|
try {
|
|
1639
|
-
await deps.deliverSecret(
|
|
1785
|
+
await deps.deliverSecret(target.refs.authAdmin, token);
|
|
1640
1786
|
break;
|
|
1641
1787
|
}
|
|
1642
1788
|
catch {
|
|
1643
1789
|
if (attempt >= attempts) {
|
|
1644
|
-
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`);
|
|
1645
1791
|
}
|
|
1646
1792
|
await sleep(1000 * attempt);
|
|
1647
1793
|
}
|
|
@@ -1706,11 +1852,19 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1706
1852
|
// make the same call, not just trust that a record exists, or a drifted
|
|
1707
1853
|
// platform would be planned again forever without ever actually healing.
|
|
1708
1854
|
const loginClientOtherInputsMissing = brokerMissingInputs.filter((m) => m !== 'ZITADEL_SERVICE_USER_TOKEN');
|
|
1709
|
-
const freshRecordedIds =
|
|
1855
|
+
const freshRecordedIds = scope.recordedIds();
|
|
1710
1856
|
const freshLoginClientRecord = parseLoginClientRecord(freshRecordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
|
|
1711
|
-
const loginClientNeedsMint = !freshLoginClientRecord ||
|
|
1857
|
+
const loginClientNeedsMint = !freshLoginClientRecord ||
|
|
1858
|
+
(await authLoginClientDriftedAt({ hostname, bearerRef: target.refs.instanceBearer }, deps, freshRecordedIds));
|
|
1712
1859
|
if (loginClientNeedsMint && loginClientOtherInputsMissing.length === 0) {
|
|
1713
|
-
|
|
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();
|
|
1714
1868
|
const { org: loginClientOrg } = await loginClientZitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
|
|
1715
1869
|
// #216 round 5: backfills the org id for a platform whose admin block
|
|
1716
1870
|
// (above) ran before this recording existed and has not run since --
|
|
@@ -1722,6 +1876,7 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1722
1876
|
userName: PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME,
|
|
1723
1877
|
name: 'Foundry (platform login client)',
|
|
1724
1878
|
});
|
|
1879
|
+
runtime.onSecret?.(loginClientToken);
|
|
1725
1880
|
// Recorded and persisted before the token leaves this function -- the
|
|
1726
1881
|
// same "one mint" rule the admin block above follows. Never read back to
|
|
1727
1882
|
// block planning; `planAuth` (`platform-diff.ts`) reads it only to
|
|
@@ -1763,8 +1918,8 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1763
1918
|
// saying "its prior token was already revoked" there would describe a
|
|
1764
1919
|
// revoke that never happened, so the message only says so when it did.
|
|
1765
1920
|
throw new Error(revokedPriorPat
|
|
1766
|
-
?
|
|
1767
|
-
:
|
|
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`);
|
|
1768
1923
|
}
|
|
1769
1924
|
// #216 round 4/5: records what was actually delivered -- the user id,
|
|
1770
1925
|
// the exact PAT id, and (round 5) a hash of the exact token -- so the
|
|
@@ -1774,25 +1929,50 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1774
1929
|
// own doc comment, and `authLoginClientDrifted`'s, for why.
|
|
1775
1930
|
await record(PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, loginClientRecordValue(loginClientUserId, loginClientPatId, loginClientToken));
|
|
1776
1931
|
}
|
|
1932
|
+
// ---- the owner company's own sign-in org ----------------------------------
|
|
1933
|
+
// Archon signs people in from this org; the `foundry` org holds none. The
|
|
1934
|
+
// company executor finds the same org by name (its slug) and makes no second.
|
|
1935
|
+
const ownerOrgZitadel = instanceZitadel();
|
|
1936
|
+
let ownerSignInOrgId;
|
|
1937
|
+
if (ownerCompany) {
|
|
1938
|
+
({ orgId: ownerSignInOrgId } = await ensureSignInOrg({
|
|
1939
|
+
domainBlockedScopeKey: PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY,
|
|
1940
|
+
zitadel: ownerOrgZitadel,
|
|
1941
|
+
name: ownerCompany.slug,
|
|
1942
|
+
domain: signInOrgDomain(ownerCompany),
|
|
1943
|
+
recordScopeId: (key, id) => scope.recordScopeId(key, id),
|
|
1944
|
+
persistScopeIds: async () => {
|
|
1945
|
+
await scope.persist();
|
|
1946
|
+
},
|
|
1947
|
+
orgScopeKey: PLATFORM_OWNER_ORG_SCOPE_KEY,
|
|
1948
|
+
domainScopeKey: PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY,
|
|
1949
|
+
}));
|
|
1950
|
+
}
|
|
1951
|
+
else {
|
|
1952
|
+
await record(PLATFORM_OWNER_ORG_SCOPE_KEY, 'none');
|
|
1953
|
+
}
|
|
1777
1954
|
// ---- ZITADEL mail and the owner's invite ----------------------------------
|
|
1778
1955
|
// The token is the held key, else the broker's live env. Absent, nothing
|
|
1779
1956
|
// here can run; `authBrokerBlockedReason` already reports it by name.
|
|
1780
1957
|
const mailToken = heldMailKey || (await currentEnvValue(deps.coolify, brokerAppId, 'LETTERMINT_API_TOKEN'));
|
|
1781
1958
|
const smtpTarget = resolveAuthSmtp(deps.smtp);
|
|
1782
1959
|
if (mailToken) {
|
|
1783
|
-
const mailZitadel =
|
|
1960
|
+
const mailZitadel = instanceZitadel();
|
|
1784
1961
|
let smtpPassword;
|
|
1785
1962
|
try {
|
|
1786
1963
|
// Runs every time the step does (idempotent), so a rotated token lands.
|
|
1787
1964
|
smtpPassword = (await deps.smtp?.password?.()) ?? mailToken;
|
|
1965
|
+
runtime.onSecret?.(smtpPassword);
|
|
1788
1966
|
const smtp = await mailZitadel.ensureSmtpProvider({
|
|
1789
1967
|
host: smtpTarget.host,
|
|
1790
1968
|
user: smtpTarget.user,
|
|
1791
1969
|
password: smtpPassword,
|
|
1792
|
-
senderAddress:
|
|
1970
|
+
senderAddress: input.smtpSender,
|
|
1793
1971
|
senderName: PLATFORM_AUTH_MAIL_SENDER_NAME,
|
|
1794
1972
|
tls: smtpTarget.tls,
|
|
1795
|
-
description:
|
|
1973
|
+
description: target.kind === 'platform'
|
|
1974
|
+
? PLATFORM_AUTH_SMTP_DESCRIPTION
|
|
1975
|
+
: `foundry:org:${target.id}:lettermint`,
|
|
1796
1976
|
});
|
|
1797
1977
|
await record(PLATFORM_AUTH_SMTP_SCOPE_KEY, smtp.id);
|
|
1798
1978
|
}
|
|
@@ -1802,44 +1982,88 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1802
1982
|
mailToken,
|
|
1803
1983
|
...(smtpPassword ? [smtpPassword] : []),
|
|
1804
1984
|
]);
|
|
1805
|
-
throw new Error(
|
|
1985
|
+
throw new Error(`${label}: could not configure ZITADEL's SMTP provider for ${hostname}: ${cause}`);
|
|
1806
1986
|
}
|
|
1807
|
-
|
|
1987
|
+
const inviteRecorded = scope.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
|
|
1988
|
+
const inviteOrgRecorded = scope.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY];
|
|
1989
|
+
// An invite recorded for another org (the `foundry` org, before the owner moved) is made again.
|
|
1990
|
+
if (!inviteRecorded || (inviteRecorded !== 'none' && inviteOrgRecorded !== ownerSignInOrgId)) {
|
|
1808
1991
|
const owner = owners[0];
|
|
1809
|
-
if (!owner) {
|
|
1992
|
+
if (!owner || !ownerCompany || !ownerSignInOrgId) {
|
|
1810
1993
|
// No owner declared: nothing to invite. Recorded so the step is not replanned for it.
|
|
1811
1994
|
await record(PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, 'none');
|
|
1812
1995
|
}
|
|
1813
1996
|
else {
|
|
1814
|
-
const
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1997
|
+
const mailboxAddress = ownerMailboxes(ownerCompany)[0]?.address;
|
|
1998
|
+
// C3: `ownerInviteBlockedReason` reports these two as blocked; the step goes on without the invite.
|
|
1999
|
+
if (mailboxAddress && deps.ownerInviteMail) {
|
|
2000
|
+
let invitedUserId;
|
|
2001
|
+
try {
|
|
2002
|
+
({ userId: invitedUserId } = await inviteOwnerWithLink({
|
|
2003
|
+
zitadel: mailZitadel,
|
|
2004
|
+
mail: deps.ownerInviteMail,
|
|
2005
|
+
orgId: ownerSignInOrgId,
|
|
2006
|
+
authIssuer: issuer,
|
|
2007
|
+
owner: {
|
|
2008
|
+
givenName: owner.givenName,
|
|
2009
|
+
familyName: owner.familyName,
|
|
2010
|
+
mailboxAddress,
|
|
2011
|
+
personalEmail: owner.email,
|
|
2012
|
+
},
|
|
2013
|
+
label,
|
|
2014
|
+
...(inviteRecorded && inviteRecorded !== 'none'
|
|
2015
|
+
? { invitedUserId: inviteRecorded }
|
|
2016
|
+
: {}),
|
|
2017
|
+
}));
|
|
2018
|
+
}
|
|
2019
|
+
catch (error) {
|
|
2020
|
+
// The cause, with both addresses and the Lettermint key redacted, so a failure says why.
|
|
2021
|
+
const cause = redactSecrets(error instanceof Error ? error.message : String(error), [
|
|
2022
|
+
owner.email,
|
|
2023
|
+
owner.email.toLowerCase(),
|
|
2024
|
+
mailboxAddress,
|
|
2025
|
+
mailToken,
|
|
2026
|
+
...(smtpPassword ? [smtpPassword] : []),
|
|
2027
|
+
]);
|
|
2028
|
+
throw new Error(`${label}: could not invite the platform owner into ZITADEL org ${ownerSignInOrgId}: ${cause}`);
|
|
2029
|
+
}
|
|
2030
|
+
// The user id first: a crash between the two replans the invite, never skips it.
|
|
2031
|
+
await record(PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, invitedUserId);
|
|
2032
|
+
await record(PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY, ownerSignInOrgId);
|
|
1836
2033
|
}
|
|
1837
|
-
await record(PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, invitedUserId);
|
|
1838
2034
|
}
|
|
1839
2035
|
}
|
|
1840
2036
|
}
|
|
1841
2037
|
return { resourceId: zitadelAppId };
|
|
1842
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
|
+
}
|
|
1843
2067
|
/**
|
|
1844
2068
|
* Reserves the username `admin` and switches self-registration off in the
|
|
1845
2069
|
* platform's sign-in org (`zitadel:sign-in-policy` keyed {@link PLATFORM_AUTH_KEY}).
|
|
@@ -1914,11 +2138,7 @@ const PLATFORM_AUTH_MAIL_SENDER_NAME = 'Company Included';
|
|
|
1914
2138
|
// #169: the platform's own mail server (Stalwart)
|
|
1915
2139
|
// ---------------------------------------------------------------------------
|
|
1916
2140
|
const PLATFORM_MAIL_PROJECT = 'platform';
|
|
1917
|
-
const PLATFORM_MAIL_APP_NAME = 'platform-mail-stalwart';
|
|
1918
2141
|
const PLATFORM_MAIL_INTERNAL_NAME = 'platform-mail-stalwart';
|
|
1919
|
-
const PLATFORM_MAIL_TUNNEL_NAME = 'platform-mail-tunnel';
|
|
1920
|
-
/** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_MAIL_TUNNEL_NAME}. */
|
|
1921
|
-
const PLATFORM_MAIL_CLOUDFLARED_APP_NAME = 'platform-mail-cloudflared';
|
|
1922
2142
|
/**
|
|
1923
2143
|
* `stalw.art/docs/install/docker`: the official image. `platform.mail.gitRef`
|
|
1924
2144
|
* carries its tag -- `platform.ts`'s `PlatformService.gitRef` doc comment
|
|
@@ -1941,7 +2161,6 @@ const STALWART_PORTS_EXPOSES = [STALWART_HTTP_PORT, ...STALWART_SMTP_PORTS].join
|
|
|
1941
2161
|
* not here -- it stays behind the tunnel, exactly like `platform:auth`'s
|
|
1942
2162
|
* ZITADEL/login apps.
|
|
1943
2163
|
*/
|
|
1944
|
-
const STALWART_PORTS_MAPPINGS = STALWART_SMTP_PORTS.map((p) => `${p}:${p}`).join(',');
|
|
1945
2164
|
/**
|
|
1946
2165
|
* The v0.16 image's two VOLUMEs (its Dockerfile): the data store, and the
|
|
1947
2166
|
* `config.json` Stalwart writes when bootstrap completes. The pre-0.16 image
|
|
@@ -1968,6 +2187,7 @@ const STALWART_READY_PATH = '/healthz/ready';
|
|
|
1968
2187
|
const PLATFORM_MAIL_SERVER_SCOPE_KEY = 'coolify_platform_mail_server';
|
|
1969
2188
|
const PLATFORM_MAIL_PROJECT_SCOPE_KEY = 'coolify_project_platform_mail';
|
|
1970
2189
|
const PLATFORM_MAIL_APP_SCOPE_KEY = 'coolify_platform_mail_app';
|
|
2190
|
+
const PLATFORM_MAIL_RELAY_APP_SCOPE_KEY = 'coolify_platform_mail_relay_app';
|
|
1971
2191
|
const PLATFORM_MAIL_TUNNEL_SCOPE_KEY = 'cloudflare_platform_mail_tunnel';
|
|
1972
2192
|
/**
|
|
1973
2193
|
* Recorded once the app has been deployed at least once -- same role as
|
|
@@ -2135,24 +2355,20 @@ const PLATFORM_MAIL_ZITADEL_PROJECT_NAME = 'mail';
|
|
|
2135
2355
|
* (the guard only ever blocks a retry and names an id to revoke by), but
|
|
2136
2356
|
* worth tightening if `createApiKey` ever starts returning its own id.
|
|
2137
2357
|
*/
|
|
2138
|
-
async function
|
|
2139
|
-
const {
|
|
2140
|
-
const
|
|
2141
|
-
const
|
|
2142
|
-
const
|
|
2143
|
-
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();
|
|
2144
2363
|
const runtime = deps.runtime ?? {};
|
|
2145
2364
|
const sleep = runtime.sleep ?? defaultSleep;
|
|
2146
2365
|
const record = async (key, id) => {
|
|
2147
|
-
|
|
2148
|
-
await
|
|
2366
|
+
scope.recordScopeId(key, id);
|
|
2367
|
+
await scope.persist();
|
|
2149
2368
|
};
|
|
2150
2369
|
let projectUuid = recorded[PLATFORM_MAIL_PROJECT_SCOPE_KEY];
|
|
2151
2370
|
if (!projectUuid) {
|
|
2152
|
-
|
|
2153
|
-
projectUuid = found
|
|
2154
|
-
? found.uuid
|
|
2155
|
-
: (await deps.coolify.createProject(PLATFORM_MAIL_PROJECT)).uuid;
|
|
2371
|
+
projectUuid = await input.project.ensure(deps.coolify, scope);
|
|
2156
2372
|
await record(PLATFORM_MAIL_PROJECT_SCOPE_KEY, projectUuid);
|
|
2157
2373
|
}
|
|
2158
2374
|
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
@@ -2163,12 +2379,14 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2163
2379
|
// One server for both the app and the MX record: recorded the first time,
|
|
2164
2380
|
// read back after, so a later run never points `mx.<apex>` somewhere the
|
|
2165
2381
|
// app is not.
|
|
2382
|
+
const portsChanged = (recorded[PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY] ??
|
|
2383
|
+
(target.kind === 'org' ? '' : PLATFORM_MAIL_HOST_PORTS_DIRECT)) !== hostPorts;
|
|
2166
2384
|
const server = await resolveMailServer(deps.coolify, recorded[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
|
|
2167
2385
|
await record(PLATFORM_MAIL_SERVER_SCOPE_KEY, server.uuid);
|
|
2168
|
-
const serverIpBlocked = mailServerIpBlocked(server.ip,
|
|
2386
|
+
const serverIpBlocked = mxRecord ? mailServerIpBlocked(server.ip, mxRecord.serverIpv4) : null;
|
|
2169
2387
|
if (serverIpBlocked)
|
|
2170
2388
|
throw new Error(serverIpBlocked);
|
|
2171
|
-
const app = await findOrCreatePlatformApp(deps.coolify,
|
|
2389
|
+
const app = await findOrCreatePlatformApp(deps.coolify, target.names.mailApp, environment, async () => {
|
|
2172
2390
|
return deps.coolify.createDockerImageApplication({
|
|
2173
2391
|
project_uuid: projectUuid,
|
|
2174
2392
|
server_uuid: server.uuid,
|
|
@@ -2177,9 +2395,11 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2177
2395
|
docker_registry_image_name: STALWART_IMAGE,
|
|
2178
2396
|
docker_registry_image_tag: imageTag,
|
|
2179
2397
|
ports_exposes: STALWART_PORTS_EXPOSES,
|
|
2180
|
-
ports_mappings:
|
|
2181
|
-
name:
|
|
2182
|
-
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})`,
|
|
2183
2403
|
instant_deploy: false,
|
|
2184
2404
|
});
|
|
2185
2405
|
});
|
|
@@ -2192,14 +2412,16 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2192
2412
|
// container before the new one can answer `waitForHealthyVersion`'s own
|
|
2193
2413
|
// `'ready'` probe below. Stalwart's own readiness route and HTTP port.
|
|
2194
2414
|
await deps.coolify.updateApplication(appId, {
|
|
2195
|
-
custom_internal_name:
|
|
2415
|
+
custom_internal_name: target.internal.mail,
|
|
2196
2416
|
docker_registry_image_tag: imageTag,
|
|
2197
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 } : {}),
|
|
2198
2420
|
});
|
|
2199
2421
|
// #177: org steps (an org's own `coolify:mail`/`coolify:agora`) read this
|
|
2200
2422
|
// back instead of a literal, the same way files/ai's own internal
|
|
2201
2423
|
// addresses are recorded.
|
|
2202
|
-
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}`);
|
|
2203
2425
|
// ---- storage ---------------------------------------------------------------
|
|
2204
2426
|
const storages = await deps.coolify.listStorages(appId);
|
|
2205
2427
|
// An app made before this release only has the old `/opt/stalwart` volume.
|
|
@@ -2211,7 +2433,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2211
2433
|
continue;
|
|
2212
2434
|
}
|
|
2213
2435
|
await deps.coolify.createPersistentStorage(appId, {
|
|
2214
|
-
name: `${
|
|
2436
|
+
name: `${target.names.mailApp}-${wanted.suffix}`,
|
|
2215
2437
|
mount_path: wanted.mount_path,
|
|
2216
2438
|
});
|
|
2217
2439
|
storagesAdded = true;
|
|
@@ -2232,7 +2454,12 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2232
2454
|
const declaredChanged = recorded[PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY] !== hostname ||
|
|
2233
2455
|
recorded[PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY] !== imageTag;
|
|
2234
2456
|
// A new storage binds into the container only at its next start.
|
|
2235
|
-
const mailDeployedNow = app.justCreated ||
|
|
2457
|
+
const mailDeployedNow = app.justCreated ||
|
|
2458
|
+
!app.running ||
|
|
2459
|
+
envsChanged ||
|
|
2460
|
+
declaredChanged ||
|
|
2461
|
+
storagesAdded ||
|
|
2462
|
+
portsChanged;
|
|
2236
2463
|
if (mailDeployedNow) {
|
|
2237
2464
|
// A container still up from before would answer the readiness probe with
|
|
2238
2465
|
// the old deployment's health. Stop it and see it stopped first, so the
|
|
@@ -2248,7 +2475,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2248
2475
|
// instead would just poll a hostname that cannot resolve yet on a first
|
|
2249
2476
|
// apply. Either way it runs before any applied ref is recorded.
|
|
2250
2477
|
// The old container was stopped above (or never ran), so nothing stays live.
|
|
2251
|
-
await waitForDeploymentFinished(deps.coolify, appId, runtime,
|
|
2478
|
+
await waitForDeploymentFinished(deps.coolify, appId, runtime, label, app.justCreated || !app.running
|
|
2252
2479
|
? `mail stays at ${recorded[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`
|
|
2253
2480
|
: 'mail is stopped until the next apply');
|
|
2254
2481
|
await record(PLATFORM_MAIL_DEPLOYED_SCOPE_KEY, appId);
|
|
@@ -2256,17 +2483,19 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2256
2483
|
// settings, mint) does not make the next run stop and redeploy the app.
|
|
2257
2484
|
await record(PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY, hostname);
|
|
2258
2485
|
await record(PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY, imageTag);
|
|
2486
|
+
if (hostPorts)
|
|
2487
|
+
await record(PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY, hostPorts);
|
|
2259
2488
|
}
|
|
2260
2489
|
// ---- tunnel and DNS: web/JMAP/management ------------------------------------
|
|
2261
|
-
const tunnels = await
|
|
2262
|
-
const existingTunnel = await tunnels.findTunnelByName(
|
|
2263
|
-
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));
|
|
2264
2493
|
await record(PLATFORM_MAIL_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
2265
2494
|
await tunnels.putTunnelConfig(tunnel.id, {
|
|
2266
2495
|
ingress: [
|
|
2267
2496
|
{
|
|
2268
2497
|
hostname,
|
|
2269
|
-
service: `http://${
|
|
2498
|
+
service: `http://${target.internal.mail}:${STALWART_HTTP_PORT}`,
|
|
2270
2499
|
},
|
|
2271
2500
|
{ service: 'http_status:404' },
|
|
2272
2501
|
],
|
|
@@ -2276,7 +2505,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2276
2505
|
coolify: deps.coolify,
|
|
2277
2506
|
tunnels,
|
|
2278
2507
|
tunnelId: tunnel.id,
|
|
2279
|
-
appName:
|
|
2508
|
+
appName: target.names.mailCloudflared,
|
|
2280
2509
|
projectUuid,
|
|
2281
2510
|
environment,
|
|
2282
2511
|
// The Stalwart app's own server, so the connector shares its Docker network.
|
|
@@ -2284,15 +2513,15 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2284
2513
|
recordedConnectorAppId: recorded[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
2285
2514
|
recordAppId: (appId) => record(PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, appId),
|
|
2286
2515
|
});
|
|
2287
|
-
const zoneId =
|
|
2516
|
+
const zoneId = await network.zoneId(apex);
|
|
2288
2517
|
if (!zoneId) {
|
|
2289
|
-
throw new Error(
|
|
2518
|
+
throw new Error(`${label}: no Cloudflare zone for ${apex} to put ${hostname} in`);
|
|
2290
2519
|
}
|
|
2291
2520
|
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
2292
|
-
const dns = await
|
|
2293
|
-
const [dnsRecord] = await dns.findDnsRecord(
|
|
2521
|
+
const dns = await network.dns();
|
|
2522
|
+
const [dnsRecord] = await dns.findDnsRecord(apex, hostname, 'CNAME');
|
|
2294
2523
|
if (!dnsRecord) {
|
|
2295
|
-
await dns.createDnsRecord(
|
|
2524
|
+
await dns.createDnsRecord(apex, {
|
|
2296
2525
|
type: 'CNAME',
|
|
2297
2526
|
name: hostname,
|
|
2298
2527
|
content: dnsTarget,
|
|
@@ -2300,31 +2529,35 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2300
2529
|
});
|
|
2301
2530
|
}
|
|
2302
2531
|
else if (dnsRecord.content !== dnsTarget) {
|
|
2303
|
-
await dns.updateDnsRecord(
|
|
2532
|
+
await dns.updateDnsRecord(apex, dnsRecord.id, {
|
|
2304
2533
|
type: 'CNAME',
|
|
2305
2534
|
name: hostname,
|
|
2306
2535
|
content: dnsTarget,
|
|
2307
2536
|
proxied: true,
|
|
2308
2537
|
});
|
|
2309
2538
|
}
|
|
2539
|
+
if (target.kind === 'org')
|
|
2540
|
+
await recordOwnDnsHostname(scope, hostname);
|
|
2310
2541
|
// ---- the MX hostname: a direct, unproxied A record --------------------------
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
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
|
+
}
|
|
2328
2561
|
}
|
|
2329
2562
|
// #252: phase 2, now that the tunnel and DNS above actually route
|
|
2330
2563
|
// `hostname`. `'ready'`, not `'version'`: Stalwart's `/healthz/ready`
|
|
@@ -2341,7 +2574,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2341
2574
|
const ensureMailReady = async () => {
|
|
2342
2575
|
if (mailReadyConfirmed)
|
|
2343
2576
|
return;
|
|
2344
|
-
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'}`);
|
|
2345
2578
|
mailReadyConfirmed = true;
|
|
2346
2579
|
};
|
|
2347
2580
|
// Also probe when the applied ref lags the target: the deployed refs are
|
|
@@ -2356,7 +2589,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2356
2589
|
// projects -- its id is the OIDC audience Stalwart's directory checks an
|
|
2357
2590
|
// access token's `aud` claim against, so no host has to register a
|
|
2358
2591
|
// resource API for this by hand.
|
|
2359
|
-
const issuer =
|
|
2592
|
+
const issuer = issuerUrl;
|
|
2360
2593
|
const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
|
|
2361
2594
|
const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
|
|
2362
2595
|
const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_MAIL_ZITADEL_PROJECT_NAME);
|
|
@@ -2382,7 +2615,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2382
2615
|
await ensureMailReady();
|
|
2383
2616
|
const recoveryEnv = (await deps.coolify.listEnvs(appId)).find((e) => e.key === 'STALWART_RECOVERY_ADMIN' && !e.is_preview);
|
|
2384
2617
|
if (!recoveryEnv) {
|
|
2385
|
-
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`);
|
|
2386
2619
|
}
|
|
2387
2620
|
const password = recoveryEnv.value.slice(`${STALWART_RECOVERY_USER}:`.length);
|
|
2388
2621
|
try {
|
|
@@ -2390,7 +2623,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2390
2623
|
url: `https://${hostname}`,
|
|
2391
2624
|
user: STALWART_RECOVERY_USER,
|
|
2392
2625
|
password,
|
|
2393
|
-
apex
|
|
2626
|
+
apex,
|
|
2394
2627
|
});
|
|
2395
2628
|
await applyMailSettings(admin, {
|
|
2396
2629
|
coolify: deps.coolify,
|
|
@@ -2399,8 +2632,8 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2399
2632
|
sleep,
|
|
2400
2633
|
hostname,
|
|
2401
2634
|
settings: {
|
|
2402
|
-
mailHost,
|
|
2403
|
-
apex
|
|
2635
|
+
mailHost: hostname,
|
|
2636
|
+
apex,
|
|
2404
2637
|
oidc: { issuerUrl: issuer, audience: project.id },
|
|
2405
2638
|
},
|
|
2406
2639
|
});
|
|
@@ -2418,12 +2651,12 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2418
2651
|
if (!recorded[PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY]) {
|
|
2419
2652
|
const lost = recorded[PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY];
|
|
2420
2653
|
if (lost) {
|
|
2421
|
-
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`);
|
|
2422
2655
|
}
|
|
2423
2656
|
await ensureMailReady();
|
|
2424
2657
|
const recoveryEnv = (await deps.coolify.listEnvs(appId)).find((e) => e.key === 'STALWART_RECOVERY_ADMIN' && !e.is_preview);
|
|
2425
2658
|
if (!recoveryEnv) {
|
|
2426
|
-
throw new Error(
|
|
2659
|
+
throw new Error(`${label}: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to mint the admin key`);
|
|
2427
2660
|
}
|
|
2428
2661
|
const password = recoveryEnv.value.slice(`${STALWART_RECOVERY_USER}:`.length);
|
|
2429
2662
|
try {
|
|
@@ -2431,7 +2664,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2431
2664
|
url: `https://${hostname}`,
|
|
2432
2665
|
user: STALWART_RECOVERY_USER,
|
|
2433
2666
|
password,
|
|
2434
|
-
apex
|
|
2667
|
+
apex,
|
|
2435
2668
|
});
|
|
2436
2669
|
await applyMailSettings(admin, {
|
|
2437
2670
|
coolify: deps.coolify,
|
|
@@ -2440,8 +2673,8 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2440
2673
|
sleep,
|
|
2441
2674
|
hostname,
|
|
2442
2675
|
settings: {
|
|
2443
|
-
mailHost,
|
|
2444
|
-
apex
|
|
2676
|
+
mailHost: hostname,
|
|
2677
|
+
apex,
|
|
2445
2678
|
oidc: { issuerUrl: issuer, audience: project.id },
|
|
2446
2679
|
},
|
|
2447
2680
|
});
|
|
@@ -2449,17 +2682,17 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2449
2682
|
// Recorded and persisted before the secret leaves this function: if
|
|
2450
2683
|
// delivery fails for good, the next plan reports the lost key rather
|
|
2451
2684
|
// than skipping or minting again.
|
|
2452
|
-
await record(PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY,
|
|
2453
|
-
const secret = await admin.createApiKey(
|
|
2685
|
+
await record(PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, keyDescription);
|
|
2686
|
+
const secret = await admin.createApiKey(keyDescription, 'inherit');
|
|
2454
2687
|
const attempts = runtime.deliverAttempts ?? 3;
|
|
2455
2688
|
for (let attempt = 1;; attempt += 1) {
|
|
2456
2689
|
try {
|
|
2457
|
-
await deps.deliverSecret(
|
|
2690
|
+
await deps.deliverSecret(target.refs.mailAdminKey, secret);
|
|
2458
2691
|
break;
|
|
2459
2692
|
}
|
|
2460
2693
|
catch {
|
|
2461
2694
|
if (attempt >= attempts) {
|
|
2462
|
-
throw new Error(
|
|
2695
|
+
throw new Error(`${label}: the admin API key ("${keyDescription}") was minted but could not be delivered after ${attempts} tries`);
|
|
2463
2696
|
}
|
|
2464
2697
|
await sleep(1000 * attempt);
|
|
2465
2698
|
}
|
|
@@ -2476,6 +2709,29 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2476
2709
|
await record(PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, imageTag);
|
|
2477
2710
|
return { resourceId: appId };
|
|
2478
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
|
+
}
|
|
2479
2735
|
/**
|
|
2480
2736
|
* Why the Stalwart image tag cannot be used, or `null`. #248: a thin wrapper
|
|
2481
2737
|
* over the estate-wide {@link floatingTagBlocked} -- the mail step's own
|
|
@@ -2541,6 +2797,31 @@ async function mailBlockedReason(platform, mail, recordedIds) {
|
|
|
2541
2797
|
return "the platform's mail server is blocked, not planned: no usable Coolify server could be resolved for it";
|
|
2542
2798
|
}
|
|
2543
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
|
+
}
|
|
2544
2825
|
/**
|
|
2545
2826
|
* #211: the broker cannot start without `ZITADEL_SERVICE_USER_TOKEN`/`_FILE`,
|
|
2546
2827
|
* `LETTERMINT_API_TOKEN` and `AUTH_APPS`. `provisionPlatformAuth` already
|
|
@@ -2578,6 +2859,18 @@ async function mailBlockedReason(platform, mail, recordedIds) {
|
|
|
2578
2859
|
* push it (see that function's own `heldMailKey` read) and clears the gap
|
|
2579
2860
|
* for good.
|
|
2580
2861
|
*/
|
|
2862
|
+
/** Why the platform owner cannot be invited yet, or `null`. Fixed text: names no address. */
|
|
2863
|
+
export function ownerInviteBlockedReason(ownerCompany, hasInviteMail) {
|
|
2864
|
+
if (!ownerCompany || ownerCompany.owners.length === 0)
|
|
2865
|
+
return null;
|
|
2866
|
+
if (!ownerMailboxes(ownerCompany)[0]?.address) {
|
|
2867
|
+
return "the platform owner's invite is blocked, not planned: the owner has no work address (mailboxAddress), and the owner signs in as name@<the org's domain>";
|
|
2868
|
+
}
|
|
2869
|
+
if (!hasInviteMail) {
|
|
2870
|
+
return "the platform owner's invite is blocked, not planned: the host built the platform's auth dependencies without ownerInviteMail, so the first-sign-in link cannot be sent";
|
|
2871
|
+
}
|
|
2872
|
+
return null;
|
|
2873
|
+
}
|
|
2581
2874
|
async function authBrokerBlockedReason(auth, recordedIds) {
|
|
2582
2875
|
const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
|
|
2583
2876
|
if (!brokerAppId)
|
|
@@ -2760,7 +3053,6 @@ const PLATFORM_ARCHON_DB_SCOPE_KEY = 'coolify_platform_archon_database';
|
|
|
2760
3053
|
const PLATFORM_ARCHON_TUNNEL_SCOPE_KEY = 'cloudflare_platform_archon_tunnel';
|
|
2761
3054
|
const PLATFORM_ARCHON_ACCESS_SCOPE_KEY = 'cloudflare_platform_archon_access_app';
|
|
2762
3055
|
const PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_platform_archon_project';
|
|
2763
|
-
export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
|
|
2764
3056
|
/** The hostname the sign-in client's redirect URIs were made for; a change makes a new client. */
|
|
2765
3057
|
const PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY = 'zitadel_platform_archon_client_hostname';
|
|
2766
3058
|
/** Archon's OIDC client id: public, not a secret, so a plain scope id. */
|
|
@@ -2900,7 +3192,7 @@ async function switchOnArchonIdTokenUserinfo(context, deps) {
|
|
|
2900
3192
|
* and `platform:integrations` are left for a further follow-up, since
|
|
2901
3193
|
* #543 did not establish a reachable url for either.
|
|
2902
3194
|
*/
|
|
2903
|
-
async function provisionPlatformArchon(context, deps) {
|
|
3195
|
+
async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
2904
3196
|
const { platform } = context;
|
|
2905
3197
|
const hostname = platform.archon.hostname;
|
|
2906
3198
|
const gitRef = platform.archon.gitRef;
|
|
@@ -3081,10 +3373,13 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
3081
3373
|
clientId = created.clientId;
|
|
3082
3374
|
}
|
|
3083
3375
|
// ---- env ------------------------------------------------------------------
|
|
3376
|
+
// People sign in from the owner company's sign-in org. The project, client
|
|
3377
|
+
// and Access app above stay in the `foundry` org, which holds no people.
|
|
3378
|
+
const authOrgId = archonAuthOrganizationId(context.recordedIds()) ?? org.id;
|
|
3084
3379
|
const envs = [
|
|
3085
3380
|
{ key: 'APP_URL', value: `https://${hostname}` },
|
|
3086
3381
|
{ key: 'AUTH_ISSUER', value: issuer },
|
|
3087
|
-
{ key: 'AUTH_ORGANIZATION_ID', value:
|
|
3382
|
+
{ key: 'AUTH_ORGANIZATION_ID', value: authOrgId },
|
|
3088
3383
|
{ key: 'AUTH_CLIENT_ID', value: clientId },
|
|
3089
3384
|
];
|
|
3090
3385
|
// #265: an owner URL or reporting site already on the app is kept as it is.
|
|
@@ -3108,6 +3403,24 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
3108
3403
|
const runtimeUrl = await ensureArchonRuntimeUrl(deps.coolify, appId, db.internal_db_url);
|
|
3109
3404
|
if (runtimeUrl)
|
|
3110
3405
|
envs.push({ key: 'DATABASE_URL', value: runtimeUrl });
|
|
3406
|
+
// Archon's way into the owner company's people store. Not there until that
|
|
3407
|
+
// company's Boule holds the peer password; `driftedSteps` then plans this
|
|
3408
|
+
// step again. Its host is the company's database, not Archon's, so
|
|
3409
|
+
// `assertSameDatabaseHost` is not applied to this key.
|
|
3410
|
+
const authzDatabaseUrl = ownerCompany
|
|
3411
|
+
? await ownerAuthzDatabaseUrl(deps.coolify, ownerCompany, 'platform:archon')
|
|
3412
|
+
: null;
|
|
3413
|
+
if (authzDatabaseUrl)
|
|
3414
|
+
envs.push({ key: ARCHON_AUTHZ_DATABASE_URL_ENV, value: authzDatabaseUrl });
|
|
3415
|
+
// The invite form's secret and Boule's address, written together and only
|
|
3416
|
+
// once Boule holds the secret: the secret is Boule's, copied here.
|
|
3417
|
+
const bridge = ownerCompany
|
|
3418
|
+
? await ownerBridge(deps.coolify, ownerCompany, 'platform:archon')
|
|
3419
|
+
: null;
|
|
3420
|
+
if (bridge) {
|
|
3421
|
+
envs.push({ key: BOULE_BRIDGE_SECRET_ENV, value: bridge.secret });
|
|
3422
|
+
envs.push({ key: ARCHON_BOULE_URL_ENV, value: bridge.url });
|
|
3423
|
+
}
|
|
3111
3424
|
// #467: `envs` above only ever carries these four keys on the run that
|
|
3112
3425
|
// generates or adopts them -- a later run that keeps them as they are
|
|
3113
3426
|
// pushes none of it, so `envs` itself shrinks from one run to the next
|
|
@@ -3145,24 +3458,6 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
3145
3458
|
throw new Error(`platform:archon: app ${appId} lost its AUTH_APP_KEY between two reads`);
|
|
3146
3459
|
}
|
|
3147
3460
|
envs.push({ key: 'AUTH_APP_KEY', value: appKey });
|
|
3148
|
-
// The owner `platform:auth` invited is Archon's first operator.
|
|
3149
|
-
const bootstrapOperator = archonBootstrapOperator(recorded);
|
|
3150
|
-
if (bootstrapOperator)
|
|
3151
|
-
envs.push({ key: 'AUTHZ_BOOTSTRAP_OPERATOR', value: bootstrapOperator });
|
|
3152
|
-
// #406: the invited platform owner's ZITADEL subject id, once
|
|
3153
|
-
// `platform:auth` has recorded one -- the guard at the top of this
|
|
3154
|
-
// function already requires `platform:auth` to have been applied before
|
|
3155
|
-
// this step is reached, and `provisionPlatformAuth` records the owner
|
|
3156
|
-
// invite (or, with no owner declared, the literal string 'none') as part
|
|
3157
|
-
// of that same step, so by the time this runs the value here is whatever
|
|
3158
|
-
// is actually true. `bootstrapOperator` (archon's `src/lib/authz/
|
|
3159
|
-
// bootstrap.ts`) compares this verbatim against a signed-in user's
|
|
3160
|
-
// subject id, so 'none' is skipped rather than written -- it would simply
|
|
3161
|
-
// never match, but it is not a real id and should not look like one.
|
|
3162
|
-
const ownerInviteUserId = recorded[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
|
|
3163
|
-
if (ownerInviteUserId && ownerInviteUserId !== 'none') {
|
|
3164
|
-
envs.push({ key: 'AUTHZ_BOOTSTRAP_OPERATOR', value: ownerInviteUserId });
|
|
3165
|
-
}
|
|
3166
3461
|
// #501: a FILES_URL or FILES_AUDIENCE already on the app is kept (the #265
|
|
3167
3462
|
// rule above), and hashed as the live value so it never reads as a changed
|
|
3168
3463
|
// env.
|
|
@@ -3211,7 +3506,20 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
3211
3506
|
// deploy between this run's write and its confirmation).
|
|
3212
3507
|
const envsHash = hashArchonEnvs(envs, archonEnvHashOverrides);
|
|
3213
3508
|
const envsHashChanged = recorded[PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY] !== envsHash;
|
|
3214
|
-
|
|
3509
|
+
try {
|
|
3510
|
+
await deps.coolify.bulkUpsertEnvs(appId, envs);
|
|
3511
|
+
}
|
|
3512
|
+
catch (error) {
|
|
3513
|
+
// Every value this write carries that came from the owner company's Boule.
|
|
3514
|
+
const carried = [
|
|
3515
|
+
bridge?.secret,
|
|
3516
|
+
authzDatabaseUrl,
|
|
3517
|
+
authzDatabaseUrl ? passwordOf(authzDatabaseUrl) : undefined,
|
|
3518
|
+
].filter((v) => Boolean(v));
|
|
3519
|
+
if (carried.length === 0)
|
|
3520
|
+
throw error;
|
|
3521
|
+
throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), carried));
|
|
3522
|
+
}
|
|
3215
3523
|
// #436 round 2: `bulkUpsertEnvs` only adds or updates, so the
|
|
3216
3524
|
// `FILES_MANAGEMENT_KEY` this step used to write before the fix above
|
|
3217
3525
|
// would otherwise survive on an Archon already deployed with it --
|
|
@@ -3233,7 +3541,9 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
3233
3541
|
for (const stale of await deps.coolify.listEnvs(appId)) {
|
|
3234
3542
|
// foundry#542: a disabled Worker's token leaves Archon's env too.
|
|
3235
3543
|
const staleUptime = !platform.uptime.enabled && stale.key === 'UPTIME_TOKEN';
|
|
3236
|
-
|
|
3544
|
+
// People now come from the owner company's store, so a first operator set by an older apply goes.
|
|
3545
|
+
const staleOperator = stale.key === 'AUTHZ_BOOTSTRAP_OPERATOR';
|
|
3546
|
+
if ((stale.key === 'FILES_MANAGEMENT_KEY' || staleUptime || staleOperator) && stale.uuid) {
|
|
3237
3547
|
await deps.coolify.deleteEnv(appId, stale.uuid);
|
|
3238
3548
|
staleKeyDeleted = true;
|
|
3239
3549
|
}
|
|
@@ -3264,13 +3574,16 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
3264
3574
|
// one. The first is kept (and updated below); the rest are dropped, so no
|
|
3265
3575
|
// duplicate carries a stale key a broker client could still present.
|
|
3266
3576
|
const archonMatchIndices = brokerApps
|
|
3267
|
-
.map((a, i) => (a.organizationId ===
|
|
3577
|
+
.map((a, i) => (a.organizationId === authOrgId || a.organizationId === org.id) &&
|
|
3578
|
+
a.name === PLATFORM_ARCHON_ZITADEL_APP
|
|
3579
|
+
? i
|
|
3580
|
+
: -1)
|
|
3268
3581
|
.filter((i) => i >= 0);
|
|
3269
3582
|
const archonEntryIndex = archonMatchIndices[0] ?? -1;
|
|
3270
3583
|
const duplicateIndices = new Set(archonMatchIndices.slice(1));
|
|
3271
3584
|
const archonEntry = {
|
|
3272
3585
|
...(archonEntryIndex >= 0 ? brokerApps[archonEntryIndex] : {}),
|
|
3273
|
-
organizationId:
|
|
3586
|
+
organizationId: authOrgId,
|
|
3274
3587
|
key: appKey,
|
|
3275
3588
|
name: PLATFORM_ARCHON_ZITADEL_APP,
|
|
3276
3589
|
clientIds: [clientId],
|
|
@@ -3501,7 +3814,7 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
3501
3814
|
// (whose AUTH_APPS never carried this registration) from one still holding
|
|
3502
3815
|
// it.
|
|
3503
3816
|
await record(PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, brokerAppId);
|
|
3504
|
-
await record(
|
|
3817
|
+
await record(PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, authOrgId);
|
|
3505
3818
|
// The host runs inside this app: every marker is recorded above, so the
|
|
3506
3819
|
// deploy goes last and is not waited for -- the old container stops during
|
|
3507
3820
|
// it, and the response, the lease and the audit row must be done first.
|
|
@@ -3943,7 +4256,7 @@ async function archonAccessDrifted(deps, recorded, domain, emails, uptimeTokenId
|
|
|
3943
4256
|
*/
|
|
3944
4257
|
async function archonBrokerRegistrationDrifted(coolify, recordedIds) {
|
|
3945
4258
|
const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
|
|
3946
|
-
const orgId = recordedIds
|
|
4259
|
+
const orgId = archonAuthOrganizationId(recordedIds);
|
|
3947
4260
|
if (!brokerAppId || !orgId)
|
|
3948
4261
|
return false;
|
|
3949
4262
|
try {
|
|
@@ -3954,34 +4267,156 @@ async function archonBrokerRegistrationDrifted(coolify, recordedIds) {
|
|
|
3954
4267
|
return false;
|
|
3955
4268
|
}
|
|
3956
4269
|
}
|
|
4270
|
+
/** The decoded password of a connection URL, or `undefined` when it has none or cannot be parsed. */
|
|
4271
|
+
function passwordOf(url) {
|
|
4272
|
+
try {
|
|
4273
|
+
const password = new URL(url).password;
|
|
4274
|
+
return password ? decodeURIComponent(password) : undefined;
|
|
4275
|
+
}
|
|
4276
|
+
catch (_error) {
|
|
4277
|
+
// Not a parseable URL: there is no password to mask beyond the whole string.
|
|
4278
|
+
return undefined;
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
3957
4281
|
/**
|
|
3958
|
-
*
|
|
3959
|
-
*
|
|
3960
|
-
*
|
|
3961
|
-
*
|
|
3962
|
-
*
|
|
3963
|
-
* record after Archon's `coolify:app` id already has -- the owner invite
|
|
3964
|
-
* happens inside `platform:auth` only once its Lettermint token is held,
|
|
3965
|
-
* which Archon's own apply does not wait on -- so without this, an owner
|
|
3966
|
-
* invited after Archon's first deploy would need a redeploy by hand, the
|
|
3967
|
-
* way companyincluded.com's did. A failed read is not drift, the same rule
|
|
3968
|
-
* {@link archonAccessDrifted} and {@link archonBrokerRegistrationDrifted}
|
|
3969
|
-
* follow: the next plan reads again.
|
|
4282
|
+
* The owner company's own Boule: the app named `<slug>-boule` in the
|
|
4283
|
+
* `production` environment of the Coolify project foundry made for that org
|
|
4284
|
+
* (marked with the org id). Never the first app of that name across Coolify,
|
|
4285
|
+
* which another org's hosting entry can also produce. `null` when the project
|
|
4286
|
+
* is missing or not foundry's for this org, or holds no such app.
|
|
3970
4287
|
*/
|
|
3971
|
-
async function
|
|
3972
|
-
const
|
|
3973
|
-
if (!
|
|
4288
|
+
async function findOwnerBoule(coolify, owner) {
|
|
4289
|
+
const project = await coolify.findProjectByName(owner.slug);
|
|
4290
|
+
if (!project || project.description !== coolifyProjectMark(owner))
|
|
4291
|
+
return null;
|
|
4292
|
+
const environment = (await coolify.listEnvironments(project.uuid)).find((e) => e.name === 'production');
|
|
4293
|
+
if (!environment)
|
|
4294
|
+
return null;
|
|
4295
|
+
return coolify.findApplicationByName(`${owner.slug}-boule`, { environmentIds: [environment.id] });
|
|
4296
|
+
}
|
|
4297
|
+
/** Archon's connection to the owner company's people store (role {@link BOULE_PEER_ROLE}). */
|
|
4298
|
+
const ARCHON_AUTHZ_DATABASE_URL_ENV = 'AUTHZ_DATABASE_URL';
|
|
4299
|
+
/**
|
|
4300
|
+
* Archon's `AUTHZ_DATABASE_URL`: the owner company's Boule database, as the
|
|
4301
|
+
* peer role with the password Boule's env holds. The host and database come
|
|
4302
|
+
* from that Boule's own `DATABASE_URL` (built from the database's internal
|
|
4303
|
+
* URL); only they are read from it, never its credentials. `null` while the
|
|
4304
|
+
* company's Boule, its password or its database URL does not exist yet. A
|
|
4305
|
+
* failed read throws a fixed message with the error's name, never its text,
|
|
4306
|
+
* which can carry Coolify's response.
|
|
4307
|
+
*/
|
|
4308
|
+
async function ownerAuthzDatabaseUrl(coolify, owner, label) {
|
|
4309
|
+
let password;
|
|
4310
|
+
let databaseUrl;
|
|
4311
|
+
try {
|
|
4312
|
+
const boule = await findOwnerBoule(coolify, owner);
|
|
4313
|
+
if (!boule)
|
|
4314
|
+
return null;
|
|
4315
|
+
password = await currentEnvValue(coolify, boule.uuid, BOULE_PEER_PASSWORD_ENV);
|
|
4316
|
+
databaseUrl = await currentEnvValue(coolify, boule.uuid, 'DATABASE_URL');
|
|
4317
|
+
}
|
|
4318
|
+
catch (error) {
|
|
4319
|
+
throw new Error(`${label}: could not read the owner company's Boule env (${error instanceof Error ? error.name : 'error'})`);
|
|
4320
|
+
}
|
|
4321
|
+
if (!password || !databaseUrl)
|
|
4322
|
+
return null;
|
|
4323
|
+
let host;
|
|
4324
|
+
let pathname;
|
|
4325
|
+
let search;
|
|
4326
|
+
try {
|
|
4327
|
+
({ host, pathname, search } = new URL(databaseUrl));
|
|
4328
|
+
}
|
|
4329
|
+
catch (error) {
|
|
4330
|
+
// Node's `ERR_INVALID_URL` carries the whole input, credential included.
|
|
4331
|
+
throw new Error(`${label}: the owner company's Boule DATABASE_URL could not be parsed (${error instanceof Error ? error.name : 'error'})`);
|
|
4332
|
+
}
|
|
4333
|
+
return `postgres://${BOULE_PEER_ROLE}:${encodeURIComponent(password)}@${host}${pathname}${search}`;
|
|
4334
|
+
}
|
|
4335
|
+
/** Where Archon's invite form calls Boule. */
|
|
4336
|
+
const ARCHON_BOULE_URL_ENV = 'BOULE_URL';
|
|
4337
|
+
/**
|
|
4338
|
+
* The invite form's secret (read from the owner company's Boule env, never
|
|
4339
|
+
* minted here) and the origin Archon calls Boule on. The origin is Boule's
|
|
4340
|
+
* public one, `https://<bouleHostnameFor>`: it is the `APP_URL` foundry already
|
|
4341
|
+
* writes to that Boule, derived from the declared org alone. Boule's internal
|
|
4342
|
+
* address would need its Coolify uuid and port, which foundry does not record,
|
|
4343
|
+
* and goes stale when the app is recreated. `null` while the company's Boule
|
|
4344
|
+
* does not hold the secret. A failed read throws a fixed message with the
|
|
4345
|
+
* error's name only.
|
|
4346
|
+
*/
|
|
4347
|
+
async function ownerBridge(coolify, owner, label) {
|
|
4348
|
+
let secret;
|
|
4349
|
+
try {
|
|
4350
|
+
const boule = await findOwnerBoule(coolify, owner);
|
|
4351
|
+
if (!boule)
|
|
4352
|
+
return null;
|
|
4353
|
+
secret = await currentEnvValue(coolify, boule.uuid, BOULE_BRIDGE_SECRET_ENV);
|
|
4354
|
+
}
|
|
4355
|
+
catch (error) {
|
|
4356
|
+
throw new Error(`${label}: could not read the owner company's Boule env (${error instanceof Error ? error.name : 'error'})`);
|
|
4357
|
+
}
|
|
4358
|
+
if (!secret)
|
|
4359
|
+
return null;
|
|
4360
|
+
return { secret, url: `https://${bouleHostnameFor(owner)}` };
|
|
4361
|
+
}
|
|
4362
|
+
/**
|
|
4363
|
+
* Whether Archon's live `ARCHON_BRIDGE_SECRET` or `BOULE_URL` is not what the
|
|
4364
|
+
* owner company's Boule now implies. Nothing to compare while that Boule holds
|
|
4365
|
+
* no secret. A failed read is not drift: the next plan reads again.
|
|
4366
|
+
*/
|
|
4367
|
+
async function archonBridgeDrifted(coolify, appId, owner) {
|
|
4368
|
+
if (!appId || !owner)
|
|
3974
4369
|
return false;
|
|
3975
4370
|
try {
|
|
3976
|
-
const
|
|
3977
|
-
|
|
4371
|
+
const wanted = await ownerBridge(coolify, owner, 'platform:archon');
|
|
4372
|
+
if (!wanted)
|
|
4373
|
+
return false;
|
|
4374
|
+
return ((await currentEnvValue(coolify, appId, BOULE_BRIDGE_SECRET_ENV)) !== wanted.secret ||
|
|
4375
|
+
(await currentEnvValue(coolify, appId, ARCHON_BOULE_URL_ENV)) !== wanted.url);
|
|
4376
|
+
}
|
|
4377
|
+
catch (_error) {
|
|
4378
|
+
// Nothing logged: the error can carry Coolify's response, and the values read are credentials.
|
|
4379
|
+
return false;
|
|
4380
|
+
}
|
|
4381
|
+
}
|
|
4382
|
+
/**
|
|
4383
|
+
* Whether Archon's live `AUTHZ_DATABASE_URL` is not the one the owner
|
|
4384
|
+
* company's Boule now allows. Nothing to compare while that Boule holds no
|
|
4385
|
+
* password. A failed read is not drift: the next plan reads again.
|
|
4386
|
+
*/
|
|
4387
|
+
async function archonAuthzDatabaseDrifted(coolify, appId, owner) {
|
|
4388
|
+
if (!appId || !owner)
|
|
4389
|
+
return false;
|
|
4390
|
+
try {
|
|
4391
|
+
const wanted = await ownerAuthzDatabaseUrl(coolify, owner, 'platform:archon');
|
|
4392
|
+
if (!wanted)
|
|
4393
|
+
return false;
|
|
4394
|
+
return (await currentEnvValue(coolify, appId, ARCHON_AUTHZ_DATABASE_URL_ENV)) !== wanted;
|
|
4395
|
+
}
|
|
4396
|
+
catch (_error) {
|
|
4397
|
+
// Nothing logged: the error can carry Coolify's response, and the values read are credentials.
|
|
4398
|
+
return false;
|
|
4399
|
+
}
|
|
4400
|
+
}
|
|
4401
|
+
/**
|
|
4402
|
+
* Whether Archon's live `AUTH_ORGANIZATION_ID` is not the sign-in org it
|
|
4403
|
+
* should accept ({@link archonAuthOrganizationId}). Without this nothing
|
|
4404
|
+
* replans Archon when the owner company's org changes, since the step plans
|
|
4405
|
+
* only on hostname, `gitRef` and the Access list. A failed read is not drift.
|
|
4406
|
+
*/
|
|
4407
|
+
async function archonAuthOrgDrifted(coolify, appId, recordedIds) {
|
|
4408
|
+
const expected = archonAuthOrganizationId(recordedIds);
|
|
4409
|
+
if (!appId || !expected)
|
|
4410
|
+
return false;
|
|
4411
|
+
try {
|
|
4412
|
+
return (await currentEnvValue(coolify, appId, 'AUTH_ORGANIZATION_ID')) !== expected;
|
|
3978
4413
|
}
|
|
3979
4414
|
catch {
|
|
3980
4415
|
return false;
|
|
3981
4416
|
}
|
|
3982
4417
|
}
|
|
3983
4418
|
/**
|
|
3984
|
-
* #501:
|
|
4419
|
+
* #501: The files runner's check that it carries the owner's `AUTHZ_BOOTSTRAP_OPERATOR`. The owner
|
|
3985
4420
|
* invite can record after the files step's app id does, so an owner invited
|
|
3986
4421
|
* later still gets `AUTHZ_BOOTSTRAP_OPERATOR`. A failed read is not drift.
|
|
3987
4422
|
*/
|
|
@@ -4317,8 +4752,6 @@ function parseAiKeyMintedBody(body) {
|
|
|
4317
4752
|
secret: record.secret,
|
|
4318
4753
|
};
|
|
4319
4754
|
}
|
|
4320
|
-
/** The ref the AI management key is delivered under; the host's `AiOrganisationPort` adapter reads it back. */
|
|
4321
|
-
export const PLATFORM_AI_MANAGEMENT_KEY_REF = 'ai:platform:managementKey';
|
|
4322
4755
|
/**
|
|
4323
4756
|
* What Archon's management key may do in ai: exactly `ai`'s own
|
|
4324
4757
|
* `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
|
|
@@ -4667,13 +5100,15 @@ async function provisionPlatformAi(context, deps) {
|
|
|
4667
5100
|
// Every run, so a changed gitRef reaches an app that already exists.
|
|
4668
5101
|
git_branch: gitRef,
|
|
4669
5102
|
};
|
|
4670
|
-
// #543: the health-check quartet too,
|
|
4671
|
-
//
|
|
4672
|
-
//
|
|
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.
|
|
4673
5106
|
if (spec === PLATFORM_AI_RUNNER) {
|
|
4674
5107
|
patch.ports_exposes = PLATFORM_AI_PORT;
|
|
4675
5108
|
Object.assign(patch, healthCheckSettings('/api/health', Number(PLATFORM_AI_PORT)));
|
|
4676
5109
|
}
|
|
5110
|
+
if (spec === PLATFORM_AI_WORKER)
|
|
5111
|
+
Object.assign(patch, IMAGE_HEALTHCHECK_SETTINGS);
|
|
4677
5112
|
await coolify.updateApplication(app.appId, patch);
|
|
4678
5113
|
return app;
|
|
4679
5114
|
};
|
|
@@ -5088,7 +5523,7 @@ async function missingEnvs(coolify, appId, defaults) {
|
|
|
5088
5523
|
}
|
|
5089
5524
|
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
5090
5525
|
/** Polls `url` until it answers 2xx, or throws once `readyTimeoutMs` has passed. */
|
|
5091
|
-
async function waitForInstance(url, runtime, sleep) {
|
|
5526
|
+
async function waitForInstance(url, runtime, sleep, label = 'platform:auth') {
|
|
5092
5527
|
const fetchImpl = runtime.fetch ?? globalThis.fetch;
|
|
5093
5528
|
const now = runtime.now ?? Date.now;
|
|
5094
5529
|
const timeout = runtime.readyTimeoutMs ?? 300_000;
|
|
@@ -5096,7 +5531,9 @@ async function waitForInstance(url, runtime, sleep) {
|
|
|
5096
5531
|
const deadline = now() + timeout;
|
|
5097
5532
|
for (;;) {
|
|
5098
5533
|
try {
|
|
5099
|
-
const response = await fetchImpl(url
|
|
5534
|
+
const response = await fetchImpl(url, {
|
|
5535
|
+
signal: AbortSignal.timeout(runtime.readyRequestTimeoutMs ?? 10_000),
|
|
5536
|
+
});
|
|
5100
5537
|
if (response.ok)
|
|
5101
5538
|
return;
|
|
5102
5539
|
}
|
|
@@ -5104,7 +5541,7 @@ async function waitForInstance(url, runtime, sleep) {
|
|
|
5104
5541
|
// Not up yet: DNS, the tunnel or the container.
|
|
5105
5542
|
}
|
|
5106
5543
|
if (now() >= deadline) {
|
|
5107
|
-
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`);
|
|
5108
5545
|
}
|
|
5109
5546
|
await sleep(interval);
|
|
5110
5547
|
}
|
|
@@ -5330,12 +5767,12 @@ async function currentEnvValue(coolify, appId, key) {
|
|
|
5330
5767
|
* deployed app means someone removed it. This throws, and a person sets the key that database was
|
|
5331
5768
|
* initialised with (or deletes an unused database) and applies again.
|
|
5332
5769
|
*/
|
|
5333
|
-
async function ensureMasterkey(coolify, appId, databaseInUse) {
|
|
5770
|
+
async function ensureMasterkey(coolify, appId, databaseInUse, label = 'platform:auth') {
|
|
5334
5771
|
const envs = await coolify.listEnvs(appId);
|
|
5335
5772
|
if (envs.some((e) => e.key === 'ZITADEL_MASTERKEY' && !e.is_preview))
|
|
5336
5773
|
return null;
|
|
5337
5774
|
if (databaseInUse) {
|
|
5338
|
-
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`);
|
|
5339
5776
|
}
|
|
5340
5777
|
return randomBytes(24).toString('base64');
|
|
5341
5778
|
}
|
|
@@ -5351,23 +5788,21 @@ async function ensureMasterkey(coolify, appId, databaseInUse) {
|
|
|
5351
5788
|
*/
|
|
5352
5789
|
export const FILES_MANAGEMENT_ISSUER = 'foundry';
|
|
5353
5790
|
export const FILES_MANAGEMENT_KEY_ID = 'foundry-1';
|
|
5354
|
-
/** The ref the management private key (PKCS#8 PEM) is delivered under. */
|
|
5355
|
-
export const FILES_MANAGEMENT_KEY_REF = 'files:platform:managementKey';
|
|
5356
5791
|
/** The ref the files host's introspection client secret is delivered under. */
|
|
5357
5792
|
export const FILES_INTROSPECTION_SECRET_REF = 'zitadel:platform:files:introspection';
|
|
5358
5793
|
/** https://github.com/wtfalch/files -- root Dockerfile with `migration` and `runner` targets. */
|
|
5359
5794
|
const FILES_REPO = 'wtfalch/files';
|
|
5360
5795
|
const PLATFORM_FILES_PROJECT = 'platform';
|
|
5361
|
-
const PLATFORM_FILES_DB_NAME = 'platform-files-db';
|
|
5796
|
+
export const PLATFORM_FILES_DB_NAME = 'platform-files-db';
|
|
5362
5797
|
/** The two Coolify apps files' `apps/host/README.md` ("Deploying on Coolify") names. */
|
|
5363
|
-
const FILES_MIGRATE_APP_NAME = 'files-migrate';
|
|
5364
|
-
const FILES_RUNNER_APP_NAME = 'files';
|
|
5798
|
+
export const FILES_MIGRATE_APP_NAME = 'files-migrate';
|
|
5799
|
+
export const FILES_RUNNER_APP_NAME = 'files';
|
|
5365
5800
|
/**
|
|
5366
5801
|
* The runner's fixed `custom_internal_name`: the name another container on
|
|
5367
5802
|
* the Coolify network reaches it by, and so the host part of
|
|
5368
5803
|
* {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY}'s recorded address.
|
|
5369
5804
|
*/
|
|
5370
|
-
const FILES_RUNNER_INTERNAL_NAME = 'platform-files';
|
|
5805
|
+
export const FILES_RUNNER_INTERNAL_NAME = 'platform-files';
|
|
5371
5806
|
/** The runner's port (`Dockerfile`'s `runner` stage: `PORT=3000`, `EXPOSE 3000`). */
|
|
5372
5807
|
const FILES_PORT = '3000';
|
|
5373
5808
|
/** files' own health route, the same one `healthUrls` (`executor.ts`) assumes every hosted app serves. */
|
|
@@ -5464,22 +5899,106 @@ async function provisionPlatformFiles(context, deps,
|
|
|
5464
5899
|
/** {@link filesShareDomains} of every declared org: each is a runner domain and a public host. */
|
|
5465
5900
|
shareDomains) {
|
|
5466
5901
|
const { platform } = context;
|
|
5467
|
-
|
|
5468
|
-
|
|
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();
|
|
5469
5982
|
const runtime = deps.runtime ?? {};
|
|
5470
5983
|
const sleep = runtime.sleep ?? defaultSleep;
|
|
5471
5984
|
const attempts = runtime.deliverAttempts ?? 3;
|
|
5472
5985
|
const keys = filesKeys(deps);
|
|
5473
5986
|
if (!keys) {
|
|
5474
|
-
throw new Error(
|
|
5475
|
-
}
|
|
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);
|
|
5476
5995
|
// #380: R2_BUCKET/R2_ENDPOINT, the same "block the whole step" treatment
|
|
5477
5996
|
// as a missing `keys` above -- the runner's `ensureStarted()` requires
|
|
5478
5997
|
// both, and only this function's caller (`blockedSteps`, `planFiles`) ever
|
|
5479
5998
|
// has anything useful to report while they are unset.
|
|
5480
5999
|
const r2 = filesR2(deps);
|
|
5481
6000
|
if (!r2) {
|
|
5482
|
-
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`);
|
|
5483
6002
|
}
|
|
5484
6003
|
// Never plan-and-throw: the planner reports a lost secret and plans
|
|
5485
6004
|
// nothing; this is the same rule for a caller that reaches the step anyway.
|
|
@@ -5489,28 +6008,21 @@ shareDomains) {
|
|
|
5489
6008
|
]) {
|
|
5490
6009
|
const lost = recorded[undeliveredKey];
|
|
5491
6010
|
if (lost && !recorded[guardKey]) {
|
|
5492
|
-
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`);
|
|
5493
6012
|
}
|
|
5494
6013
|
}
|
|
5495
6014
|
const record = async (key, id) => {
|
|
5496
|
-
|
|
5497
|
-
await
|
|
6015
|
+
scope.recordScopeId(key, id);
|
|
6016
|
+
await scope.persist();
|
|
5498
6017
|
};
|
|
5499
|
-
|
|
5500
|
-
if (!projectUuid) {
|
|
5501
|
-
const found = await deps.coolify.findProjectByName(PLATFORM_FILES_PROJECT);
|
|
5502
|
-
projectUuid = found
|
|
5503
|
-
? found.uuid
|
|
5504
|
-
: (await deps.coolify.createProject(PLATFORM_FILES_PROJECT)).uuid;
|
|
5505
|
-
await record(PLATFORM_FILES_PROJECT_SCOPE_KEY, projectUuid);
|
|
5506
|
-
}
|
|
6018
|
+
const projectUuid = await input.project.ensure(deps.coolify, scope);
|
|
5507
6019
|
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
5508
6020
|
// ---- database ----------------------------------------------------------
|
|
5509
6021
|
let dbId = recorded[PLATFORM_FILES_DB_SCOPE_KEY];
|
|
5510
6022
|
if (!dbId) {
|
|
5511
6023
|
// Found by name first, so a crash between create and record never makes
|
|
5512
6024
|
// a second one (`coolifyJobs` does the same).
|
|
5513
|
-
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);
|
|
5514
6026
|
if (found) {
|
|
5515
6027
|
dbId = found.uuid;
|
|
5516
6028
|
}
|
|
@@ -5521,7 +6033,7 @@ shareDomains) {
|
|
|
5521
6033
|
server_uuid: server.uuid,
|
|
5522
6034
|
environment_name: environment.name,
|
|
5523
6035
|
environment_uuid: environment.uuid,
|
|
5524
|
-
name:
|
|
6036
|
+
name: target.names.filesDb,
|
|
5525
6037
|
postgres_db: FILES_DB,
|
|
5526
6038
|
postgres_user: FILES_DB,
|
|
5527
6039
|
});
|
|
@@ -5531,7 +6043,7 @@ shareDomains) {
|
|
|
5531
6043
|
}
|
|
5532
6044
|
const db = await getDatabaseStarted(deps.coolify, dbId);
|
|
5533
6045
|
if (!db.internal_db_url) {
|
|
5534
|
-
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`);
|
|
5535
6047
|
}
|
|
5536
6048
|
// ---- the two apps --------------------------------------------------------
|
|
5537
6049
|
const createFilesApp = (name) => async () => {
|
|
@@ -5549,7 +6061,7 @@ shareDomains) {
|
|
|
5549
6061
|
name,
|
|
5550
6062
|
});
|
|
5551
6063
|
};
|
|
5552
|
-
const migrateApp = await findOrCreatePlatformApp(deps.coolify,
|
|
6064
|
+
const migrateApp = await findOrCreatePlatformApp(deps.coolify, target.names.filesMigrate, environment, createFilesApp(target.names.filesMigrate));
|
|
5553
6065
|
await record(PLATFORM_FILES_MIGRATE_SCOPE_KEY, migrateApp.appId);
|
|
5554
6066
|
// Set on every run, like `platform:auth`'s `custom_internal_name`: a throw
|
|
5555
6067
|
// right after a create must not leave the app on the wrong target.
|
|
@@ -5558,11 +6070,11 @@ shareDomains) {
|
|
|
5558
6070
|
dockerfile_target_build: 'migration',
|
|
5559
6071
|
git_branch: gitRef,
|
|
5560
6072
|
});
|
|
5561
|
-
const runnerApp = await findOrCreatePlatformApp(deps.coolify,
|
|
6073
|
+
const runnerApp = await findOrCreatePlatformApp(deps.coolify, target.names.filesRunner, environment, createFilesApp(target.names.filesRunner));
|
|
5562
6074
|
await record(PLATFORM_FILES_RUNNER_SCOPE_KEY, runnerApp.appId);
|
|
5563
6075
|
await deps.coolify.updateApplication(runnerApp.appId, {
|
|
5564
6076
|
dockerfile_target_build: 'runner',
|
|
5565
|
-
custom_internal_name:
|
|
6077
|
+
custom_internal_name: target.internal.files,
|
|
5566
6078
|
git_branch: gitRef,
|
|
5567
6079
|
// #543: the health-check quartet, same reason Archon's doc comment
|
|
5568
6080
|
// gives. The migrate app gets none -- it idles rather than serves
|
|
@@ -5574,13 +6086,14 @@ shareDomains) {
|
|
|
5574
6086
|
// nobody gave a share host keeps no `domains`.
|
|
5575
6087
|
...filesShareDomainsPatch(recorded, shareDomains),
|
|
5576
6088
|
});
|
|
5577
|
-
|
|
6089
|
+
scope.recordScopeId(PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY, normalHosts(shareDomains).join(','));
|
|
5578
6090
|
// ---- database env ----------------------------------------------------------
|
|
5579
6091
|
// The migrate app sets `files_rt`'s password from DATABASE_URL on every
|
|
5580
6092
|
// boot (files' `scripts/db-runtime-role.sh`), and the runner connects with
|
|
5581
6093
|
// the same URL, so both apps carry one value, minted once and never
|
|
5582
6094
|
// rotated: an existing value on either app is reused.
|
|
5583
6095
|
const runtimeUrl = await ensureFilesRuntimeUrl(deps.coolify, [migrateApp.appId, runnerApp.appId], db.internal_db_url);
|
|
6096
|
+
runtime.onSecret?.(runtimeUrl);
|
|
5584
6097
|
// The keys worker the migrate app needs to become healthy (files' README:
|
|
5585
6098
|
// `DATABASE_URL_OWNER`, `DATABASE_URL`, `KEYS_WORKER_URL`, `KEYS_CREDENTIAL`).
|
|
5586
6099
|
// `files-migrate` never runs `ensureStarted()`, so it needs neither
|
|
@@ -5595,11 +6108,13 @@ shareDomains) {
|
|
|
5595
6108
|
...migrateKeysEnvs,
|
|
5596
6109
|
]);
|
|
5597
6110
|
// ---- the files ZITADEL project, in the platform's sign-in ----------------
|
|
5598
|
-
const issuer =
|
|
6111
|
+
const { issuer } = input;
|
|
5599
6112
|
const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
|
|
5600
|
-
const
|
|
5601
|
-
|
|
5602
|
-
|
|
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);
|
|
5603
6118
|
// #380: the runner's own required settings -- `KEYS_VERIFY_KEYS` and
|
|
5604
6119
|
// `R2_BUCKET`/`R2_ENDPOINT` -- plus whatever optional ones the host
|
|
5605
6120
|
// supplies. `files-migrate` needs none of these (see `migrateKeysEnvs`
|
|
@@ -5608,10 +6123,10 @@ shareDomains) {
|
|
|
5608
6123
|
...migrateKeysEnvs,
|
|
5609
6124
|
{ key: 'KEYS_VERIFY_KEYS', value: keys.verifyKeys },
|
|
5610
6125
|
];
|
|
5611
|
-
if (
|
|
6126
|
+
if (auditToken)
|
|
5612
6127
|
runnerKeysEnvs.push({
|
|
5613
6128
|
key: 'KEYS_AUDIT_TOKEN',
|
|
5614
|
-
value:
|
|
6129
|
+
value: auditToken,
|
|
5615
6130
|
});
|
|
5616
6131
|
const runnerOptionalEnvs = [];
|
|
5617
6132
|
if (deps.clamd?.host) {
|
|
@@ -5626,13 +6141,16 @@ shareDomains) {
|
|
|
5626
6141
|
});
|
|
5627
6142
|
}
|
|
5628
6143
|
runnerOptionalEnvs.push(...filesPublicHostsEnvs(recorded, deps.publicHosts, shareDomains));
|
|
5629
|
-
if (
|
|
6144
|
+
if (jobsSigningSecrets)
|
|
5630
6145
|
runnerOptionalEnvs.push({
|
|
5631
6146
|
key: 'JOBS_SIGNING_SECRETS',
|
|
5632
|
-
value:
|
|
6147
|
+
value: jobsSigningSecrets,
|
|
5633
6148
|
});
|
|
5634
6149
|
// #501: the same person platform:archon names; Files has no sign-in page.
|
|
5635
|
-
|
|
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];
|
|
5636
6154
|
if (ownerInviteUserId && ownerInviteUserId !== 'none') {
|
|
5637
6155
|
runnerOptionalEnvs.push({ key: 'AUTHZ_BOOTSTRAP_OPERATOR', value: ownerInviteUserId });
|
|
5638
6156
|
}
|
|
@@ -5640,7 +6158,7 @@ shareDomains) {
|
|
|
5640
6158
|
{ key: 'DATABASE_URL', value: runtimeUrl },
|
|
5641
6159
|
// The management verifier's audience, which must be an HTTPS origin. The
|
|
5642
6160
|
// host has no public address, so this is a name, not a route.
|
|
5643
|
-
{ key: 'APP_URL', value: `https://${
|
|
6161
|
+
{ key: 'APP_URL', value: `https://${input.hostname}` },
|
|
5644
6162
|
{ key: 'AUTH_ISSUER', value: issuer },
|
|
5645
6163
|
{ key: 'AUTH_PERSON_TOKEN_AUDIENCE', value: project.id },
|
|
5646
6164
|
...runnerKeysEnvs,
|
|
@@ -5655,28 +6173,32 @@ shareDomains) {
|
|
|
5655
6173
|
// Base env first, so a secret's own write below cannot be the only one
|
|
5656
6174
|
// that lands, and a re-run with a changed hostname updates `APP_URL`.
|
|
5657
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);
|
|
5658
6179
|
// ---- the introspection client, once --------------------------------------
|
|
5659
6180
|
if (!recorded[PLATFORM_FILES_INTROSPECTION_SCOPE_KEY]) {
|
|
5660
6181
|
// An app by this name here has no recorded secret (no undelivered id, or
|
|
5661
6182
|
// the throw above would have fired): a crash between the create and the
|
|
5662
6183
|
// record. Nothing was delivered, so it is deleted and made again.
|
|
5663
|
-
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');
|
|
5664
6185
|
for (const app of stale) {
|
|
5665
6186
|
try {
|
|
5666
|
-
await zitadel.deleteApp(
|
|
6187
|
+
await zitadel.deleteApp(zitadelOrgId, project.id, app.id);
|
|
5667
6188
|
}
|
|
5668
6189
|
catch (error) {
|
|
5669
6190
|
if (!isNotFoundError(error))
|
|
5670
6191
|
throw error;
|
|
5671
6192
|
}
|
|
5672
6193
|
}
|
|
5673
|
-
const client = await zitadel.createApiApp(
|
|
6194
|
+
const client = await zitadel.createApiApp(zitadelOrgId, project.id, {
|
|
5674
6195
|
name: FILES_INTROSPECTION_APP_NAME,
|
|
5675
6196
|
});
|
|
6197
|
+
runtime.onSecret?.(client.clientSecret);
|
|
5676
6198
|
// Recorded and persisted before the secret leaves this function: a failed
|
|
5677
6199
|
// delivery or env write is then reported by the planner, not remade.
|
|
5678
6200
|
await record(PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, client.appId);
|
|
5679
|
-
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);
|
|
5680
6202
|
await deps.coolify.bulkUpsertEnvs(runnerApp.appId, [
|
|
5681
6203
|
{
|
|
5682
6204
|
key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_ID',
|
|
@@ -5695,8 +6217,9 @@ shareDomains) {
|
|
|
5695
6217
|
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
5696
6218
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
5697
6219
|
});
|
|
6220
|
+
runtime.onSecret?.(privateKey);
|
|
5698
6221
|
await record(PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, FILES_MANAGEMENT_KEY_ID);
|
|
5699
|
-
await deliverWithRetry(deps.deliverSecret,
|
|
6222
|
+
await deliverWithRetry(deps.deliverSecret, target.refs.filesManagement, privateKey, attempts, sleep, 'the files management key', label);
|
|
5700
6223
|
await deps.coolify.bulkUpsertEnvs(runnerApp.appId, [
|
|
5701
6224
|
{ key: 'MANAGEMENT_ISSUER', value: FILES_MANAGEMENT_ISSUER },
|
|
5702
6225
|
{
|
|
@@ -5712,12 +6235,12 @@ shareDomains) {
|
|
|
5712
6235
|
// container before the new migration has run.
|
|
5713
6236
|
await stopIfRunning(deps.coolify, migrateApp.appId);
|
|
5714
6237
|
const migrateDeployed = await deps.coolify.deploy(migrateApp.appId);
|
|
5715
|
-
await waitForMigration(deps.coolify, migrateApp.appId, runtime, sleep);
|
|
6238
|
+
await waitForMigration(deps.coolify, migrateApp.appId, runtime, sleep, label, target.names.filesMigrate);
|
|
5716
6239
|
// #399: files-migrate idles rather than exits once it has migrated, so it
|
|
5717
6240
|
// reads healthy well before Coolify's own deployment does; stopping it on
|
|
5718
6241
|
// that signal alone can find the container already gone and make Coolify
|
|
5719
6242
|
// mark a deployment that actually migrated as `failed`.
|
|
5720
|
-
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);
|
|
5721
6244
|
await deps.coolify.raw('POST', `/applications/${migrateApp.appId}/stop`);
|
|
5722
6245
|
// #252/#543: no public `domains`, tunnel or DNS record is wired to the
|
|
5723
6246
|
// runner (its own doc comment: "Nothing public is wired"), but it does
|
|
@@ -5728,11 +6251,11 @@ shareDomains) {
|
|
|
5728
6251
|
// container); `coolify.deployAndWait` reaching Coolify's own deployment
|
|
5729
6252
|
// record first is still what rules out the old container staying healthy
|
|
5730
6253
|
// through a failed deploy.
|
|
5731
|
-
const internalUrl = `http://${
|
|
5732
|
-
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'}`);
|
|
5733
6256
|
await record(PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, internalUrl);
|
|
5734
6257
|
// Last: a run that failed anywhere above is planned again.
|
|
5735
|
-
|
|
6258
|
+
scope.recordScopeId(PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, input.hostname);
|
|
5736
6259
|
await record(PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
5737
6260
|
return { resourceId: runnerApp.appId };
|
|
5738
6261
|
}
|
|
@@ -6360,7 +6883,7 @@ function aiKeys(deps) {
|
|
|
6360
6883
|
const keys = deps?.keys;
|
|
6361
6884
|
return keys?.workerUrl && keys.credential && keys.verifyKeys ? keys : null;
|
|
6362
6885
|
}
|
|
6363
|
-
function filesKeys(deps) {
|
|
6886
|
+
export function filesKeys(deps) {
|
|
6364
6887
|
const keys = deps?.keys;
|
|
6365
6888
|
return keys?.workerUrl && keys.credential && keys.verifyKeys
|
|
6366
6889
|
? {
|
|
@@ -6383,7 +6906,7 @@ function filesR2(deps) {
|
|
|
6383
6906
|
* naming the field instead of deploying a host that never turns healthy.
|
|
6384
6907
|
* Never echoes the value.
|
|
6385
6908
|
*/
|
|
6386
|
-
function filesDepsInvalid(deps) {
|
|
6909
|
+
export function filesDepsInvalid(deps) {
|
|
6387
6910
|
const r2 = filesR2(deps);
|
|
6388
6911
|
if (r2) {
|
|
6389
6912
|
let ok = false;
|
|
@@ -6510,7 +7033,7 @@ async function ensureFilesRuntimeUrl(coolify, appIds, dbInternalUrl) {
|
|
|
6510
7033
|
* stands in for the log line). Throws once `migrateTimeoutMs` has passed; the
|
|
6511
7034
|
* runner is then never deployed against an unmigrated database.
|
|
6512
7035
|
*/
|
|
6513
|
-
async function waitForMigration(coolify, appId, runtime, sleep) {
|
|
7036
|
+
async function waitForMigration(coolify, appId, runtime, sleep, label = 'platform:files', appName = 'files-migrate') {
|
|
6514
7037
|
const now = runtime.now ?? Date.now;
|
|
6515
7038
|
const timeout = runtime.migrateTimeoutMs ?? 900_000;
|
|
6516
7039
|
const interval = runtime.migrateIntervalMs ?? 10_000;
|
|
@@ -6523,7 +7046,7 @@ async function waitForMigration(coolify, appId, runtime, sleep) {
|
|
|
6523
7046
|
if (status !== 'running:healthy')
|
|
6524
7047
|
left = true;
|
|
6525
7048
|
if (now() >= deadline) {
|
|
6526
|
-
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`);
|
|
6527
7050
|
}
|
|
6528
7051
|
await sleep(interval);
|
|
6529
7052
|
}
|
|
@@ -6733,6 +7256,8 @@ async function provisionPlatformIntegrations(action, context, deps) {
|
|
|
6733
7256
|
await coolify.updateApplication(runnerApp.appId, {
|
|
6734
7257
|
dockerfile_target_build: INTEGRATIONS_RUNNER_APP.target,
|
|
6735
7258
|
ports_exposes: INTEGRATIONS_RUNNER_PORT,
|
|
7259
|
+
// The runner's Dockerfile HEALTHCHECK (curl on /api/health) is the live check.
|
|
7260
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
6736
7261
|
git_branch: gitRef,
|
|
6737
7262
|
domains: coolifyDomains([hostname]),
|
|
6738
7263
|
});
|
|
@@ -7747,7 +8272,7 @@ export class ProvidersPlatformCoolifyReader {
|
|
|
7747
8272
|
* answers with the parent zone. The platform's zone is its apex exactly;
|
|
7748
8273
|
* any other zone is never adopted or reported as the platform's.
|
|
7749
8274
|
*/
|
|
7750
|
-
async function exactZone(client, apex) {
|
|
8275
|
+
export async function exactZone(client, apex) {
|
|
7751
8276
|
const zone = await client.findZoneByApex(apex);
|
|
7752
8277
|
return zone && zone.name === apex ? zone : undefined;
|
|
7753
8278
|
}
|