@wtfalch/foundry 0.33.1 → 0.35.1
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 +1 -1
- package/dist/bootstrap/bootstrap.js +49 -11
- package/dist/bootstrap/declare.d.ts +12 -5
- package/dist/bootstrap/declare.js +33 -18
- package/dist/bootstrap/versions.d.ts +24 -0
- package/dist/bootstrap/versions.js +46 -0
- package/dist/calls-sfu.d.ts +10 -0
- package/dist/calls-sfu.js +44 -3
- package/dist/index.d.ts +21 -8
- package/dist/index.js +11 -4
- package/dist/migrations/0051_org_services.sql +13 -0
- package/dist/migrations/0052_platform_mail_relay.sql +8 -0
- package/dist/migrations/0053_heartbeats_provider.sql +9 -0
- package/dist/migrations/0053_org_version_pin.sql +6 -0
- package/dist/org.d.ts +76 -12
- package/dist/org.js +136 -20
- package/dist/plan/backup-gate.d.ts +93 -0
- package/dist/plan/backup-gate.js +261 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +20 -3
- package/dist/plan/build-record.d.ts +56 -0
- package/dist/plan/build-record.js +109 -0
- package/dist/plan/calls-matrix.d.ts +9 -0
- package/dist/plan/calls-matrix.js +18 -1
- package/dist/plan/cloudflared-connector.d.ts +7 -1
- package/dist/plan/cloudflared-connector.js +15 -2
- package/dist/plan/company-backups.d.ts +31 -0
- package/dist/plan/company-backups.js +101 -0
- package/dist/plan/deploy-health.d.ts +12 -2
- package/dist/plan/deploy-health.js +3 -1
- package/dist/plan/destinations.d.ts +73 -0
- package/dist/plan/destinations.js +217 -0
- package/dist/plan/diff.d.ts +46 -8
- package/dist/plan/diff.js +367 -126
- package/dist/plan/edge-access.d.ts +15 -0
- package/dist/plan/edge-access.js +21 -2
- package/dist/plan/executor.d.ts +276 -5
- package/dist/plan/executor.js +1111 -109
- 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.d.ts +1 -3
- package/dist/plan/health-targets.js +36 -36
- package/dist/plan/held-key-refs.d.ts +15 -0
- package/dist/plan/held-key-refs.js +15 -0
- package/dist/plan/hostnames.d.ts +10 -0
- package/dist/plan/hostnames.js +14 -0
- package/dist/plan/index.d.ts +19 -6
- package/dist/plan/index.js +10 -3
- package/dist/plan/live.d.ts +11 -2
- package/dist/plan/live.js +16 -2
- package/dist/plan/locked-hostnames.d.ts +7 -3
- package/dist/plan/locked-hostnames.js +25 -4
- package/dist/plan/login-host.d.ts +61 -12
- package/dist/plan/login-host.js +99 -11
- package/dist/plan/mail-relay.d.ts +139 -0
- package/dist/plan/mail-relay.js +400 -0
- package/dist/plan/network-move.d.ts +20 -0
- package/dist/plan/network-move.js +41 -0
- package/dist/plan/oikos-ingress.d.ts +111 -0
- package/dist/plan/oikos-ingress.js +231 -0
- package/dist/plan/org-names.d.ts +30 -0
- package/dist/plan/org-names.js +108 -0
- package/dist/plan/own-diff.d.ts +170 -0
- package/dist/plan/own-diff.js +440 -0
- package/dist/plan/own-dns.d.ts +6 -0
- package/dist/plan/own-dns.js +15 -0
- package/dist/plan/own-hostnames.d.ts +27 -0
- package/dist/plan/own-hostnames.js +24 -0
- package/dist/plan/own-limits.d.ts +213 -0
- package/dist/plan/own-limits.js +232 -0
- package/dist/plan/own-services.d.ts +193 -0
- package/dist/plan/own-services.js +169 -0
- package/dist/plan/own-sfu.d.ts +69 -0
- package/dist/plan/own-sfu.js +256 -0
- package/dist/plan/plan-apply.d.ts +121 -10
- package/dist/plan/plan-apply.js +357 -18
- package/dist/plan/platform-diff.d.ts +155 -11
- package/dist/plan/platform-diff.js +271 -66
- package/dist/plan/platform-org.d.ts +107 -4
- package/dist/plan/platform-org.js +175 -3
- package/dist/plan/platform-providers.d.ts +474 -81
- package/dist/plan/platform-providers.js +1407 -560
- package/dist/plan/proxy-labels.d.ts +57 -11
- package/dist/plan/proxy-labels.js +94 -14
- package/dist/plan/run-options.d.ts +19 -0
- package/dist/plan/run-options.js +46 -0
- package/dist/plan/sign-in-org.d.ts +1 -1
- package/dist/plan/sign-in-org.js +20 -2
- package/dist/plan/zitadel-login-policy.d.ts +1 -1
- package/dist/plan/zitadel-login-policy.js +77 -9
- package/dist/plan/zitadel-system-api.d.ts +22 -1
- package/dist/plan/zitadel-system-api.js +49 -8
- package/dist/platform.d.ts +40 -0
- package/dist/platform.js +16 -0
- package/dist/provision.d.ts +2 -0
- package/dist/provision.js +3 -1
- package/dist/slug.d.ts +3 -1
- package/dist/slug.js +26 -3
- package/dist/store/postgres.js +93 -2
- package/dist/store/schema.d.ts +87 -0
- package/dist/store/schema.js +9 -0
- package/package.json +3 -3
- package/versions.digests.json +11 -0
- package/versions.json +3 -1
|
@@ -1,24 +1,33 @@
|
|
|
1
1
|
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from 'node:crypto';
|
|
2
2
|
import { isIPv4 } from 'node:net';
|
|
3
3
|
import { applyObjects, openInstance, platformMailSettingsObjects } from '@wtfalch/email/postmaster';
|
|
4
|
-
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
4
|
+
import { VERSIONS, floatingTagBlocked, pinnedRef, pinnedTagFor } from '../bootstrap/versions.js';
|
|
5
5
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
6
6
|
import { ownerMailboxes } from '../org.js';
|
|
7
7
|
import { BOULE_BRIDGE_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_PEER_ROLE, bouleHostnameFor, } from './boule.js';
|
|
8
|
+
import { recordBuiltCommit } from './build-record.js';
|
|
8
9
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
9
10
|
import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
|
|
11
|
+
import { placedOnPlatform } from './destinations.js';
|
|
10
12
|
import { ACCESS_POLICY_NAME, coolifyProjectMark, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
|
|
11
13
|
import { ACCESS_PROVIDER_RECORD_KEY, GATE_CLAIM_NAME } from './gates.js';
|
|
12
|
-
import { healthCheckSettings } from './health-check-settings.js';
|
|
14
|
+
import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, tcpProbe, } from './health-check-settings.js';
|
|
15
|
+
import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
|
|
16
|
+
import { LIMITS, limited } from './own-limits.js';
|
|
17
|
+
export { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
|
|
13
18
|
import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
|
|
14
|
-
import {
|
|
19
|
+
import { MAIL_RELAY_BLOCKED, provisionMailRelay } from './mail-relay.js';
|
|
20
|
+
import { OIKOS_INGRESS_BLOCKED, PLATFORM_OIKOS_INGRESS_KEY, oikosIngressApplied, oikosIngressZoneBlocked, provisionOikosIngress, } from './oikos-ingress.js';
|
|
21
|
+
import { recordOwnDnsHostname } from './own-dns.js';
|
|
22
|
+
import { platformNetwork, platformTarget, scopeFromPlatform, } from './own-services.js';
|
|
23
|
+
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_ACCESS_ZITADEL_APP_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_CONNECTOR_APP_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_AI_TUNNEL_SCOPE_KEY, PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FILES_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_APP_KEY_ORG_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_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY, PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY, PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY, PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_PROJECT_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_HEARTBEATS_HOSTED_API_KEY_REF, 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, archonFilesMarker, archonFirstOwnerLink, archonOrgDomain, archonOrgName, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailHostPorts, platformMailMxHost, uptimeAccessTokenConflict, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
|
|
15
24
|
import { ensureSignInOrg, inviteOwnerWithLink, signInOrgDomain } from './sign-in-org.js';
|
|
16
25
|
export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
|
|
17
26
|
import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
|
|
18
27
|
import { heldUptimeValues, provisionPlatformUptime, } from './uptime-worker.js';
|
|
19
|
-
import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
|
|
28
|
+
import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, waitForSystemApi, } from './zitadel-system-api.js';
|
|
20
29
|
/** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
|
|
21
|
-
function apexZoneKey(apex) {
|
|
30
|
+
export function apexZoneKey(apex) {
|
|
22
31
|
return `cloudflare:zone:${apex}`;
|
|
23
32
|
}
|
|
24
33
|
/** The key `runPlatformSteps` records the jobs scheduler app's id under (`system:kind:key`, #173). */
|
|
@@ -29,7 +38,7 @@ const JOBS_APP_KEY = `coolify:jobs:${JOBS_SCHEDULER_KEY}`;
|
|
|
29
38
|
* those exist. Edge's step runs first and records the address
|
|
30
39
|
* (`planPlatform`), so a missing one means that step has not finished.
|
|
31
40
|
*/
|
|
32
|
-
async function platformDns(deps, recorded, step) {
|
|
41
|
+
export async function platformDns(deps, recorded, step) {
|
|
33
42
|
const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
|
|
34
43
|
if (!edgeUrl) {
|
|
35
44
|
throw new Error(`${step}: edge has not been applied yet (no recorded ${PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}); platform:edge runs first and every DNS record is written through it`);
|
|
@@ -37,7 +46,7 @@ async function platformDns(deps, recorded, step) {
|
|
|
37
46
|
return deps.dns(edgeUrl);
|
|
38
47
|
}
|
|
39
48
|
/** The tunnel port for a platform step; same address and same reason as {@link platformDns}. */
|
|
40
|
-
async function platformTunnels(deps, recorded, step) {
|
|
49
|
+
export async function platformTunnels(deps, recorded, step) {
|
|
41
50
|
const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
|
|
42
51
|
if (!edgeUrl) {
|
|
43
52
|
throw new Error(`${step}: edge has not been applied yet (no recorded ${PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}); platform:edge runs first and every tunnel is managed through it`);
|
|
@@ -69,81 +78,22 @@ const JOBS_DATABASE_SCOPE_KEY = 'coolify_jobs_database';
|
|
|
69
78
|
* never rotates a token the host already holds.
|
|
70
79
|
*/
|
|
71
80
|
const JOBS_ADMIN_TOKEN_SCOPE_KEY = 'jobs_admin_token_set';
|
|
72
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* An own org's scheduler only: the app's uuid, recorded and persisted before
|
|
83
|
+
* the admin token leaves and cleared once the token is delivered and written.
|
|
84
|
+
* Left set, the planner reports the scheduler blocked and never mints again.
|
|
85
|
+
*/
|
|
86
|
+
export const JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY = 'jobs_admin_token_undelivered';
|
|
87
|
+
/** An own org's scheduler only: the git ref its app last deployed, so a changed declared ref plans the step again. */
|
|
88
|
+
export const JOBS_APPLIED_GIT_REF_SCOPE_KEY = 'jobs_applied_git_ref';
|
|
89
|
+
/** How many times `provisionJobsHost` tries `deliverSecret` before giving up. */
|
|
73
90
|
const JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS = 3;
|
|
74
|
-
/** The ref the scheduler's admin token is delivered under: the host's real `JobsSchedulerPort` adapter reads it back. */
|
|
75
|
-
export const JOBS_ADMIN_TOKEN_REF = 'jobs:scheduler:adminToken';
|
|
76
91
|
/** The name of the Coolify project every platform-level service (starting with jobs, #173) lives in. */
|
|
77
92
|
const PLATFORM_COOLIFY_PROJECT_NAME = 'platform';
|
|
78
93
|
/** https://github.com/wtfalch/jobs -- the scheduler's own repo, Dockerfile at its root (its own `Dockerfile`'s doc comment). */
|
|
79
94
|
const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
|
|
80
|
-
/** The
|
|
81
|
-
const JOBS_INTERNAL_NAME = 'platform-jobs';
|
|
95
|
+
/** The port the scheduler listens on (jobs' `PORT`, default 8080). */
|
|
82
96
|
const JOBS_PORT = '8080';
|
|
83
|
-
/**
|
|
84
|
-
* The real {@link PlatformStepExecutor} for the two steps built so far:
|
|
85
|
-
* `cloudflare:zone` for the platform's apex (#167) and `coolify:jobs` for the
|
|
86
|
-
* jobs scheduler (#173). `coolify` is optional so every #167-era caller --
|
|
87
|
-
* anything that only ever plans the apex zone -- keeps constructing this with
|
|
88
|
-
* two arguments; a `coolify:jobs` step reaching this executor with no
|
|
89
|
-
* `coolify` port configured throws, naming what is missing, rather than
|
|
90
|
-
* silently doing nothing.
|
|
91
|
-
*
|
|
92
|
-
* `cloudflareZone` mirrors `executor.ts`'s `cloudflareZone`: find the zone by
|
|
93
|
-
* apex and adopt it, create it only when absent. companyincluded.com already
|
|
94
|
-
* exists on Cloudflare, empty, so the first apply adopts it. The owner org
|
|
95
|
-
* (Company Included, whose own domain is the apex) may record the same zone
|
|
96
|
-
* under its own applied state, so that owner is allowed. Any other org that
|
|
97
|
-
* recorded it makes this refuse to adopt. There is no delete here and
|
|
98
|
-
* `PlatformStepExecutor` has none: neither path ever removes the zone.
|
|
99
|
-
*
|
|
100
|
-
* `coolifyJobs` mirrors `executor.ts`'s `coolifyApp`/`coolifyDatabase`,
|
|
101
|
-
* simplified for one platform-level singleton rather than one per org: find
|
|
102
|
-
* or create a dedicated `platform` Coolify project, find or create the
|
|
103
|
-
* scheduler's own database, find or create the scheduler app itself (from
|
|
104
|
-
* https://github.com/wtfalch/jobs, one Dockerfile at its repo root, on the
|
|
105
|
-
* declared `gitRef`), then push the database's `internal_db_url` in as
|
|
106
|
-
* `DATABASE_URL`. There is no park branch yet, matching `planJobs`'s own
|
|
107
|
-
* scope note -- `platform.jobs.enabled` going from true to false plans
|
|
108
|
-
* nothing, so this method is never asked to stop or delete anything.
|
|
109
|
-
*
|
|
110
|
-
* #170 adds `sfu:livekit`, the platform's shared calls SFU. `sfu` is
|
|
111
|
-
* optional: an existing host that constructs this executor for the zone step
|
|
112
|
-
* alone keeps compiling, and a plan that includes an `sfu:livekit` step
|
|
113
|
-
* without it configured refuses clearly rather than silently doing nothing.
|
|
114
|
-
* #152: the step mints one LiveKit key per org with calls enabled rather
|
|
115
|
-
* than one shared pair, so it also needs `declaredOrgStore.list()` -- the
|
|
116
|
-
* same store this class already takes for `findOrgIdForZone`.
|
|
117
|
-
*
|
|
118
|
-
* #168 adds `coolify:app` keyed {@link PLATFORM_AUTH_KEY}, the platform's own
|
|
119
|
-
* sign-in ({@link provisionPlatformAuth}), once a host passes
|
|
120
|
-
* {@link PlatformAuthDeps}.
|
|
121
|
-
*
|
|
122
|
-
* #169 adds `coolify:app` keyed {@link PLATFORM_MAIL_KEY}, the platform's own
|
|
123
|
-
* mail server ({@link provisionPlatformMail}), once a host passes
|
|
124
|
-
* {@link PlatformMailDeps}.
|
|
125
|
-
*
|
|
126
|
-
* #174 adds `coolify:app` keyed {@link PLATFORM_INTEGRATIONS_KEY},
|
|
127
|
-
* the integrations host ({@link provisionPlatformIntegrations}), once a host
|
|
128
|
-
* passes {@link PlatformIntegrationsDeps}.
|
|
129
|
-
*
|
|
130
|
-
* #171 adds `coolify:app` keyed {@link PLATFORM_FILES_KEY}, the files host
|
|
131
|
-
* ({@link provisionPlatformFiles}), once a host passes {@link PlatformFilesDeps}.
|
|
132
|
-
*
|
|
133
|
-
* #172 adds `coolify:app` keyed {@link PLATFORM_AI_KEY}, the platform's AI
|
|
134
|
-
* host ({@link provisionPlatformAi}), once a host passes {@link PlatformAiDeps}.
|
|
135
|
-
*
|
|
136
|
-
* #478 adds `coolify:app` keyed {@link PLATFORM_EDGE_KEY}, the edge host
|
|
137
|
-
* ({@link provisionPlatformEdge}), once a host passes {@link PlatformEdgeDeps}.
|
|
138
|
-
*
|
|
139
|
-
* #175 adds `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, Archon itself
|
|
140
|
-
* ({@link provisionPlatformArchon}), once a host passes
|
|
141
|
-
* {@link PlatformArchonDeps}.
|
|
142
|
-
*
|
|
143
|
-
* #257 adds `coolify:backup:<service>`, a nightly R2 backup of each
|
|
144
|
-
* platform database ({@link provisionPlatformBackup}), once a host passes
|
|
145
|
-
* {@link PlatformBackupDeps}.
|
|
146
|
-
*/
|
|
147
97
|
export class FoundryPlatformStepExecutor {
|
|
148
98
|
cloudflare;
|
|
149
99
|
declaredOrgStore;
|
|
@@ -163,6 +113,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
163
113
|
heartbeats;
|
|
164
114
|
uptime;
|
|
165
115
|
accessProvider;
|
|
116
|
+
oikosIngress;
|
|
166
117
|
constructor(cloudflare, declaredOrgStore, options = {}) {
|
|
167
118
|
this.cloudflare = cloudflare;
|
|
168
119
|
this.declaredOrgStore = declaredOrgStore;
|
|
@@ -182,8 +133,23 @@ export class FoundryPlatformStepExecutor {
|
|
|
182
133
|
this.heartbeats = options.heartbeats;
|
|
183
134
|
this.uptime = options.uptime;
|
|
184
135
|
this.accessProvider = options.accessProvider;
|
|
136
|
+
this.oikosIngress = options.oikosIngress;
|
|
137
|
+
}
|
|
138
|
+
platformPorts = new WeakMap();
|
|
139
|
+
/** The port placed on the platform's destination, made once per port so a server is listed once. */
|
|
140
|
+
onPlatform(coolify) {
|
|
141
|
+
let made = this.platformPorts.get(coolify);
|
|
142
|
+
if (!made) {
|
|
143
|
+
made = placedOnPlatform(coolify);
|
|
144
|
+
this.platformPorts.set(coolify, made);
|
|
145
|
+
}
|
|
146
|
+
return made;
|
|
147
|
+
}
|
|
148
|
+
/** `deps` with its Coolify port placed on the platform destination, then limited by `profile`. */
|
|
149
|
+
limitedOnPlatform(deps, profile) {
|
|
150
|
+
return { ...deps, coolify: limited(this.onPlatform(deps.coolify), profile) };
|
|
185
151
|
}
|
|
186
|
-
async blockedSteps(platform, recordedIds = {}) {
|
|
152
|
+
async blockedSteps(platform, recordedIds = {}, owner = {}) {
|
|
187
153
|
const blocked = {};
|
|
188
154
|
if (platform.auth.enabled && !this.auth) {
|
|
189
155
|
blocked.auth =
|
|
@@ -223,6 +189,14 @@ export class FoundryPlatformStepExecutor {
|
|
|
223
189
|
if (mailBlocked)
|
|
224
190
|
blocked.mail = mailBlocked;
|
|
225
191
|
}
|
|
192
|
+
if (platform.mail.relay === true) {
|
|
193
|
+
const relayBlocked = this.mail
|
|
194
|
+
? (mailRelayBlockedReason() ??
|
|
195
|
+
(await relayServerBlockedReason(platform, this.mail, recordedIds)))
|
|
196
|
+
: MAIL_RELAY_BLOCKED.executorMissing;
|
|
197
|
+
if (relayBlocked)
|
|
198
|
+
blocked.relay = relayBlocked;
|
|
199
|
+
}
|
|
226
200
|
if (platform.integrations.enabled && !this.integrations) {
|
|
227
201
|
blocked.integrations =
|
|
228
202
|
'the integrations host is blocked, not planned: the host built the platform executor without its integrations dependencies';
|
|
@@ -281,6 +255,23 @@ export class FoundryPlatformStepExecutor {
|
|
|
281
255
|
if (edgeBlocked)
|
|
282
256
|
blocked.edge = edgeBlocked;
|
|
283
257
|
}
|
|
258
|
+
if (platform.edge.enabled && !oikosIngressApplied(recordedIds)) {
|
|
259
|
+
const edgeUrl = recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
|
|
260
|
+
if (!this.oikosIngress) {
|
|
261
|
+
blocked.oikosIngress = OIKOS_INGRESS_BLOCKED.executorMissing;
|
|
262
|
+
}
|
|
263
|
+
else if (edgeUrl) {
|
|
264
|
+
let zoneBlocked;
|
|
265
|
+
try {
|
|
266
|
+
zoneBlocked = await oikosIngressZoneBlocked(await this.oikosIngress.zones(edgeUrl), this.oikosIngress.callTimeoutMs);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
zoneBlocked = OIKOS_INGRESS_BLOCKED.zoneUnchecked;
|
|
270
|
+
}
|
|
271
|
+
if (zoneBlocked)
|
|
272
|
+
blocked.oikosIngress = zoneBlocked;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
284
275
|
if (platform.archon.enabled) {
|
|
285
276
|
if (!this.archon) {
|
|
286
277
|
blocked.archon =
|
|
@@ -291,7 +282,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
291
282
|
'Archon is blocked, not planned: the host names no email the Cloudflare Access app in front of it may let through';
|
|
292
283
|
}
|
|
293
284
|
else {
|
|
294
|
-
const inputs = await collectArchonServiceInputs(platform, this.archon, recordedIds);
|
|
285
|
+
const inputs = await collectArchonServiceInputs(platform, this.archon, recordedIds, owner.files ?? null);
|
|
295
286
|
if ('blocked' in inputs) {
|
|
296
287
|
blocked.archon = inputs.blocked;
|
|
297
288
|
}
|
|
@@ -304,6 +295,22 @@ export class FoundryPlatformStepExecutor {
|
|
|
304
295
|
}
|
|
305
296
|
}
|
|
306
297
|
}
|
|
298
|
+
// Archon's own sign-in organisation lives in the owner company's ZITADEL, reached through the sign-in dependencies.
|
|
299
|
+
if (platform.archon.enabled) {
|
|
300
|
+
if (!this.auth) {
|
|
301
|
+
blocked.archonOrg =
|
|
302
|
+
"Archon's sign-in organisation is blocked, not planned: the host built the platform executor without its auth dependencies";
|
|
303
|
+
}
|
|
304
|
+
else if (!this.auth.zitadelForInstance) {
|
|
305
|
+
blocked.archonOrg =
|
|
306
|
+
"Archon's sign-in organisation is blocked, not planned: the host built the platform's auth dependencies without zitadelForInstance, so it cannot reach the owner company's ZITADEL";
|
|
307
|
+
}
|
|
308
|
+
const ownerCompany = await this.declaredOrgStore.get(platform.ownerOrgId);
|
|
309
|
+
const inviteBlocked = ownerInviteBlockedReason(ownerCompany, Boolean(this.auth?.ownerInviteMail));
|
|
310
|
+
if (inviteBlocked) {
|
|
311
|
+
blocked.archonOrgInvite = inviteBlocked.replace("the platform owner's invite", "the first person's invite into Archon's sign-in organisation");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
307
314
|
// Only once a database actually exists to back up -- the same
|
|
308
315
|
// "nothing to report yet" rule `planBackups` (`platform-diff.ts`)
|
|
309
316
|
// itself follows for "no database", so an otherwise-unrelated platform
|
|
@@ -321,21 +328,30 @@ export class FoundryPlatformStepExecutor {
|
|
|
321
328
|
if (hcTagBlocked)
|
|
322
329
|
blocked.hc = hcTagBlocked;
|
|
323
330
|
}
|
|
324
|
-
//
|
|
325
|
-
//
|
|
331
|
+
// foundry#544: `'hosted'` needs none of `hc`'s own applied state -- only
|
|
332
|
+
// the self-hosted provider's standing note depends on `hc` being
|
|
333
|
+
// recorded applied at all. `planHeartbeats` itself already withholds the
|
|
334
|
+
// step while neither provider is ready, so a host with no heartbeats
|
|
326
335
|
// deps wired carries no standing blocked note before there is anything
|
|
327
336
|
// for it to reconcile.
|
|
328
|
-
|
|
337
|
+
const heartbeatsHosted = platform.heartbeatsProvider === 'hosted';
|
|
338
|
+
if (!this.heartbeats &&
|
|
339
|
+
(heartbeatsHosted || Boolean(recordedIds[`coolify:app:${PLATFORM_HC_KEY}`]))) {
|
|
329
340
|
blocked.heartbeatsExecutorMissing = true;
|
|
330
341
|
}
|
|
331
|
-
else if (platform.hc.enabled && this.heartbeats) {
|
|
342
|
+
else if ((heartbeatsHosted || platform.hc.enabled) && this.heartbeats) {
|
|
332
343
|
// #540 round 2: checked whether or not `hc` is recorded applied yet --
|
|
333
344
|
// `hcReady` (`planPlatform`) goes true the same round `hc` is merely
|
|
334
345
|
// *planned*, so this has to hold the heartbeats step back from the
|
|
335
346
|
// very first round, before `coolify:app:platform:hc` is ever recorded.
|
|
336
347
|
// See `PLATFORM_HC_API_KEY_REF`'s own doc comment (`platform-diff.ts`)
|
|
337
|
-
// for why nothing here can mint this key itself
|
|
338
|
-
|
|
348
|
+
// for why nothing here can mint this key itself; foundry#544's
|
|
349
|
+
// `PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF` is the same rule for the
|
|
350
|
+
// outside provider's own key.
|
|
351
|
+
const apiKeyRef = heartbeatsHosted
|
|
352
|
+
? PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF
|
|
353
|
+
: PLATFORM_HC_API_KEY_REF;
|
|
354
|
+
const apiKey = await this.heartbeats.heldKeys?.resolve?.(apiKeyRef);
|
|
339
355
|
if (!apiKey)
|
|
340
356
|
blocked.heartbeatsApiKeyMissing = true;
|
|
341
357
|
}
|
|
@@ -343,7 +359,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
343
359
|
// #604: only once edge and sign-in are applied (the planner reports
|
|
344
360
|
// that case itself) and no provider is recorded yet.
|
|
345
361
|
if (platform.edge.enabled &&
|
|
346
|
-
|
|
362
|
+
owner.signIn &&
|
|
347
363
|
!recordedIds[ACCESS_PROVIDER_RECORD_KEY] &&
|
|
348
364
|
recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY]) {
|
|
349
365
|
if (!this.accessProvider?.zitadel.createOidcAppWithSecret) {
|
|
@@ -365,9 +381,10 @@ export class FoundryPlatformStepExecutor {
|
|
|
365
381
|
platform.archon.enabled &&
|
|
366
382
|
recordedIds[PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY]) {
|
|
367
383
|
try {
|
|
368
|
-
const zitadel =
|
|
369
|
-
this.archon.
|
|
370
|
-
|
|
384
|
+
const zitadel = owner.signIn
|
|
385
|
+
? this.archon.zitadelForInstance?.(owner.signIn.issuerUrl, owner.signIn.bearerRef)
|
|
386
|
+
: undefined;
|
|
387
|
+
if (zitadel?.updateOidcAppIdTokenUserinfoAssertion)
|
|
371
388
|
blocked.archonIdTokenUserinfo = true;
|
|
372
389
|
}
|
|
373
390
|
catch (e) {
|
|
@@ -469,18 +486,25 @@ export class FoundryPlatformStepExecutor {
|
|
|
469
486
|
* login-client token is no longer the sole live PAT on its ZITADEL machine
|
|
470
487
|
* user.
|
|
471
488
|
*/
|
|
472
|
-
async driftedSteps(platform, recordedIds = {}) {
|
|
489
|
+
async driftedSteps(platform, recordedIds = {}, owner = {}) {
|
|
473
490
|
const result = {};
|
|
474
491
|
if (platform.archon.enabled &&
|
|
475
492
|
this.archon &&
|
|
476
493
|
recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]) {
|
|
477
|
-
const
|
|
494
|
+
const ownerCompany = await this.declaredOrgStore.get(platform.ownerOrgId);
|
|
495
|
+
const accessDrifted = await archonAccessDrifted(this.archon, recordedIds, platform.archon.hostname, archonAccessEmails(this.archon.accessEmails, ownerCompany), platform.uptime.enabled
|
|
478
496
|
? recordedIds[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY]
|
|
479
497
|
: undefined);
|
|
480
|
-
const
|
|
498
|
+
const archonOrgId = recordedIds[PLATFORM_ARCHON_ORG_SCOPE_KEY];
|
|
499
|
+
const brokerDrifted = !accessDrifted &&
|
|
500
|
+
(await archonBrokerRegistrationDrifted(this.archon.coolify, owner.signIn ?? null, archonOrgId, recordedIds));
|
|
481
501
|
const authOrgDrifted = !accessDrifted &&
|
|
482
502
|
!brokerDrifted &&
|
|
483
|
-
(await archonAuthOrgDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`],
|
|
503
|
+
(await archonAuthOrgDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`], {
|
|
504
|
+
AUTH_ORGANIZATION_ID: archonOrgId,
|
|
505
|
+
CI_ORGANIZATION_ID: owner.signIn?.organisationId,
|
|
506
|
+
ARCHON_FIRST_OWNER_LINK: archonFirstOwnerLink(recordedIds, owner.signIn ?? null),
|
|
507
|
+
}));
|
|
484
508
|
const authzDrifted = !accessDrifted &&
|
|
485
509
|
!brokerDrifted &&
|
|
486
510
|
!authOrgDrifted &&
|
|
@@ -585,13 +609,13 @@ export class FoundryPlatformStepExecutor {
|
|
|
585
609
|
throw new Error('platform:auth needs FoundryPlatformStepExecutor to be constructed with its auth dependencies (#168)');
|
|
586
610
|
}
|
|
587
611
|
const company = await this.declaredOrgStore.get(context.platform.ownerOrgId);
|
|
588
|
-
return provisionPlatformAuth(context, this.auth, company);
|
|
612
|
+
return provisionPlatformAuth(context, this.limitedOnPlatform(this.auth, LIMITS.platformAuth), company);
|
|
589
613
|
}
|
|
590
614
|
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_FILES_KEY) {
|
|
591
615
|
if (!this.files) {
|
|
592
616
|
throw new Error('platform:files needs FoundryPlatformStepExecutor to be constructed with its files dependencies (#171)');
|
|
593
617
|
}
|
|
594
|
-
return provisionPlatformFiles(context, this.files, filesShareDomains(await this.declaredOrgStore.list()));
|
|
618
|
+
return provisionPlatformFiles(context, this.limitedOnPlatform(this.files, LIMITS.platformFiles), filesShareDomains(await this.declaredOrgStore.list()));
|
|
595
619
|
}
|
|
596
620
|
if (action.system === 'coolify' &&
|
|
597
621
|
action.kind === 'app' &&
|
|
@@ -599,19 +623,19 @@ export class FoundryPlatformStepExecutor {
|
|
|
599
623
|
if (!this.integrations) {
|
|
600
624
|
throw new Error('platform:integrations needs FoundryPlatformStepExecutor to be constructed with its integrations dependencies (#174)');
|
|
601
625
|
}
|
|
602
|
-
return provisionPlatformIntegrations(action, context, this.integrations);
|
|
626
|
+
return provisionPlatformIntegrations(action, context, this.limitedOnPlatform(this.integrations, LIMITS.platformIntegrations));
|
|
603
627
|
}
|
|
604
628
|
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_AI_KEY) {
|
|
605
629
|
if (!this.ai) {
|
|
606
630
|
throw new Error('platform:ai needs FoundryPlatformStepExecutor to be constructed with its ai dependencies (#172)');
|
|
607
631
|
}
|
|
608
|
-
return provisionPlatformAi(context, this.ai);
|
|
632
|
+
return provisionPlatformAi(context, this.limitedOnPlatform(this.ai, LIMITS.platformAi));
|
|
609
633
|
}
|
|
610
634
|
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_EDGE_KEY) {
|
|
611
635
|
if (!this.edge) {
|
|
612
636
|
throw new Error('platform:edge needs FoundryPlatformStepExecutor to be constructed with its edge dependencies (#478)');
|
|
613
637
|
}
|
|
614
|
-
return provisionPlatformEdge(context, this.edge);
|
|
638
|
+
return provisionPlatformEdge(context, this.limitedOnPlatform(this.edge, LIMITS.platformEdge));
|
|
615
639
|
}
|
|
616
640
|
if (action.system === 'coolify' &&
|
|
617
641
|
action.kind === 'app' &&
|
|
@@ -620,19 +644,36 @@ export class FoundryPlatformStepExecutor {
|
|
|
620
644
|
throw new Error('platform:archon needs FoundryPlatformStepExecutor to be constructed with its archon dependencies (#175)');
|
|
621
645
|
}
|
|
622
646
|
const ownerCompany = await this.declaredOrgStore.get(context.platform.ownerOrgId);
|
|
623
|
-
return provisionPlatformArchon(context, this.archon, ownerCompany);
|
|
647
|
+
return provisionPlatformArchon(context, this.limitedOnPlatform(this.archon, LIMITS.platformArchon), ownerCompany);
|
|
624
648
|
}
|
|
625
649
|
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_MAIL_KEY) {
|
|
626
650
|
if (!this.mail) {
|
|
627
651
|
throw new Error('platform:mail needs FoundryPlatformStepExecutor to be constructed with its mail dependencies (#169)');
|
|
628
652
|
}
|
|
629
|
-
return provisionPlatformMail(context, this.mail);
|
|
653
|
+
return provisionPlatformMail(context, this.limitedOnPlatform(this.mail, LIMITS.platformMail));
|
|
654
|
+
}
|
|
655
|
+
if (action.system === 'coolify' &&
|
|
656
|
+
action.kind === 'app' &&
|
|
657
|
+
action.key === PLATFORM_MAIL_RELAY_KEY) {
|
|
658
|
+
if (!this.mail) {
|
|
659
|
+
throw new Error('platform:mail-relay needs FoundryPlatformStepExecutor to be constructed with its mail dependencies');
|
|
660
|
+
}
|
|
661
|
+
return provisionPlatformMailRelay(action, context, this.limitedOnPlatform(this.mail, LIMITS.platformMailRelay));
|
|
630
662
|
}
|
|
631
663
|
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_HC_KEY) {
|
|
632
664
|
if (!this.hc) {
|
|
633
665
|
throw new Error('platform:hc needs FoundryPlatformStepExecutor to be constructed with its hc dependencies (#540)');
|
|
634
666
|
}
|
|
635
|
-
return provisionPlatformHc(context, this.hc);
|
|
667
|
+
return provisionPlatformHc(context, this.limitedOnPlatform(this.hc, LIMITS.platformHc));
|
|
668
|
+
}
|
|
669
|
+
if (action.system === 'zitadel' &&
|
|
670
|
+
action.kind === 'archon-org' &&
|
|
671
|
+
action.key === PLATFORM_ARCHON_KEY) {
|
|
672
|
+
if (!this.auth) {
|
|
673
|
+
throw new Error("platform:archon-org needs FoundryPlatformStepExecutor to be constructed with its auth dependencies: Archon's sign-in organisation is made through them");
|
|
674
|
+
}
|
|
675
|
+
const ownerCompany = await this.declaredOrgStore.get(context.platform.ownerOrgId);
|
|
676
|
+
return provisionPlatformArchonOrg(context, this.auth, ownerCompany);
|
|
636
677
|
}
|
|
637
678
|
if (action.system === 'zitadel' &&
|
|
638
679
|
action.kind === 'id-token-userinfo' &&
|
|
@@ -670,6 +711,14 @@ export class FoundryPlatformStepExecutor {
|
|
|
670
711
|
}
|
|
671
712
|
return provisionPlatformUptime(context, this.uptime);
|
|
672
713
|
}
|
|
714
|
+
if (action.system === 'coolify' &&
|
|
715
|
+
action.kind === 'app' &&
|
|
716
|
+
action.key === PLATFORM_OIKOS_INGRESS_KEY) {
|
|
717
|
+
if (!this.oikosIngress) {
|
|
718
|
+
throw new Error('platform:oikos-ingress needs FoundryPlatformStepExecutor to be constructed with its oikos ingress dependencies');
|
|
719
|
+
}
|
|
720
|
+
return provisionPlatformOikosIngress(context, this.limitedOnPlatform(this.oikosIngress, LIMITS.platformIngress));
|
|
721
|
+
}
|
|
673
722
|
if (action.system === 'cloudflare' && action.kind === 'zone') {
|
|
674
723
|
return this.cloudflareZone(action, context);
|
|
675
724
|
}
|
|
@@ -743,104 +792,152 @@ export class FoundryPlatformStepExecutor {
|
|
|
743
792
|
if (!deliverSecret) {
|
|
744
793
|
throw new Error('platform step coolify:jobs needs a deliverSecret -- construct FoundryPlatformStepExecutor with one, to hand the scheduler admin token to the host (#173)');
|
|
745
794
|
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
: (await coolify.createProject(PLATFORM_COOLIFY_PROJECT_NAME)).uuid;
|
|
755
|
-
context.recordScopeId(JOBS_PROJECT_SCOPE_KEY, projectUuid);
|
|
756
|
-
}
|
|
757
|
-
const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
758
|
-
let dbId = recorded[JOBS_DATABASE_SCOPE_KEY];
|
|
759
|
-
if (!dbId) {
|
|
760
|
-
const dbName = `${JOBS_SCHEDULER_KEY}-scheduler-db`;
|
|
761
|
-
const found = (await coolify.listDatabases()).find((db) => db.name === dbName && db.environment_id === environment.id);
|
|
762
|
-
if (found) {
|
|
763
|
-
dbId = found.uuid;
|
|
764
|
-
}
|
|
765
|
-
else {
|
|
766
|
-
const server = await resolvePlatformServer(coolify);
|
|
767
|
-
const created = await coolify.createPostgresDatabase({
|
|
768
|
-
project_uuid: projectUuid,
|
|
769
|
-
server_uuid: server.uuid,
|
|
770
|
-
environment_name: environment.name,
|
|
771
|
-
environment_uuid: environment.uuid,
|
|
772
|
-
name: dbName,
|
|
773
|
-
postgres_db: 'jobs',
|
|
774
|
-
postgres_user: 'jobs',
|
|
775
|
-
});
|
|
776
|
-
dbId = created.uuid;
|
|
777
|
-
}
|
|
778
|
-
context.recordScopeId(JOBS_DATABASE_SCOPE_KEY, dbId);
|
|
779
|
-
}
|
|
780
|
-
const existingApp = await coolify.findApplicationByName(JOBS_SCHEDULER_KEY, {
|
|
781
|
-
environmentIds: [environment.id],
|
|
795
|
+
return provisionJobsHost(scopeFromPlatform(context), {
|
|
796
|
+
coolify: limited(this.onPlatform(coolify), LIMITS.platformJobs),
|
|
797
|
+
deliverSecret,
|
|
798
|
+
runtime: this.runtime,
|
|
799
|
+
}, platformTarget(), {
|
|
800
|
+
hostname: action.desired.hostname,
|
|
801
|
+
gitRef: action.desired.gitRef,
|
|
802
|
+
project: platformJobsProject,
|
|
782
803
|
});
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
/** The platform's jobs scheduler lives in the shared `platform` project. */
|
|
807
|
+
const platformJobsProject = {
|
|
808
|
+
async ensure(coolify, scope) {
|
|
809
|
+
const recorded = scope.recordedIds()[JOBS_PROJECT_SCOPE_KEY];
|
|
810
|
+
if (recorded)
|
|
811
|
+
return recorded;
|
|
812
|
+
const found = await coolify.findProjectByName(PLATFORM_COOLIFY_PROJECT_NAME);
|
|
813
|
+
const projectUuid = found
|
|
814
|
+
? found.uuid
|
|
815
|
+
: (await coolify.createProject(PLATFORM_COOLIFY_PROJECT_NAME)).uuid;
|
|
816
|
+
scope.recordScopeId(JOBS_PROJECT_SCOPE_KEY, projectUuid);
|
|
817
|
+
return projectUuid;
|
|
818
|
+
},
|
|
819
|
+
};
|
|
820
|
+
/**
|
|
821
|
+
* Provisions one jobs scheduler (database, app, admin token) for `target`:
|
|
822
|
+
* the platform's, or an own org's. Every name and the held-key ref come from
|
|
823
|
+
* the target.
|
|
824
|
+
*
|
|
825
|
+
* An org target also guards the mint (`CLAUDE.md`, "Platform steps" rules 1
|
|
826
|
+
* and 3): the token's id is recorded as undelivered and persisted before the
|
|
827
|
+
* token leaves, the delivered guard is recorded after the env write, and the
|
|
828
|
+
* applied git ref is recorded so a changed ref plans the step again. The
|
|
829
|
+
* platform's step keeps its provision-only behaviour.
|
|
830
|
+
*/
|
|
831
|
+
export async function provisionJobsHost(scope, deps, target, input) {
|
|
832
|
+
const { coolify, deliverSecret, runtime } = deps;
|
|
833
|
+
const { hostname, gitRef } = input;
|
|
834
|
+
const own = target.kind === 'org';
|
|
835
|
+
const label = own ? `own step coolify:own-jobs for ${target.id}` : 'platform step coolify:jobs';
|
|
836
|
+
const projectUuid = await input.project.ensure(coolify, scope);
|
|
837
|
+
const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
838
|
+
let dbId = scope.recordedIds()[JOBS_DATABASE_SCOPE_KEY];
|
|
839
|
+
if (!dbId) {
|
|
840
|
+
const dbName = target.names.jobsDb;
|
|
841
|
+
const found = (await coolify.listDatabases()).find((db) => db.name === dbName && db.environment_id === environment.id);
|
|
842
|
+
if (found) {
|
|
843
|
+
dbId = found.uuid;
|
|
786
844
|
}
|
|
787
845
|
else {
|
|
788
846
|
const server = await resolvePlatformServer(coolify);
|
|
789
|
-
const
|
|
790
|
-
const created = await coolify.createGithubAppApplication({
|
|
847
|
+
const created = await coolify.createPostgresDatabase({
|
|
791
848
|
project_uuid: projectUuid,
|
|
792
849
|
server_uuid: server.uuid,
|
|
793
850
|
environment_name: environment.name,
|
|
794
851
|
environment_uuid: environment.uuid,
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
build_pack: 'dockerfile',
|
|
799
|
-
name: JOBS_SCHEDULER_KEY,
|
|
800
|
-
domains: coolifyDomains([hostname]),
|
|
852
|
+
name: dbName,
|
|
853
|
+
postgres_db: 'jobs',
|
|
854
|
+
postgres_user: 'jobs',
|
|
801
855
|
});
|
|
802
|
-
|
|
856
|
+
dbId = created.uuid;
|
|
803
857
|
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
858
|
+
scope.recordScopeId(JOBS_DATABASE_SCOPE_KEY, dbId);
|
|
859
|
+
}
|
|
860
|
+
const existingApp = await coolify.findApplicationByName(target.names.jobsApp, {
|
|
861
|
+
environmentIds: [environment.id],
|
|
862
|
+
});
|
|
863
|
+
let appId;
|
|
864
|
+
if (existingApp) {
|
|
865
|
+
appId = existingApp.uuid;
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
868
|
+
const server = await resolvePlatformServer(coolify);
|
|
869
|
+
const githubApp = await coolify.requireGithubApp();
|
|
870
|
+
const created = await coolify.createGithubAppApplication({
|
|
871
|
+
project_uuid: projectUuid,
|
|
872
|
+
server_uuid: server.uuid,
|
|
873
|
+
environment_name: environment.name,
|
|
874
|
+
environment_uuid: environment.uuid,
|
|
875
|
+
github_app_uuid: githubApp.uuid,
|
|
876
|
+
git_repository: JOBS_SCHEDULER_REPO,
|
|
877
|
+
git_branch: gitRef,
|
|
878
|
+
build_pack: 'dockerfile',
|
|
879
|
+
name: target.names.jobsApp,
|
|
880
|
+
...(hostname ? { domains: coolifyDomains([hostname]) } : {}),
|
|
809
881
|
});
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
882
|
+
appId = created.uuid;
|
|
883
|
+
}
|
|
884
|
+
// An org's declared ref is applied to the app it already has; the platform's
|
|
885
|
+
// is a manual redeploy.
|
|
886
|
+
const refChanged = own && Boolean(existingApp) && scope.recordedIds()[JOBS_APPLIED_GIT_REF_SCOPE_KEY] !== gitRef;
|
|
887
|
+
// Set on every run, like `platform:files`' runner: the name another
|
|
888
|
+
// container reaches the scheduler by. It only takes effect on a deploy.
|
|
889
|
+
await coolify.updateApplication(appId, {
|
|
890
|
+
custom_internal_name: target.internal.jobs,
|
|
891
|
+
ports_exposes: JOBS_PORT,
|
|
892
|
+
// A connect, not a GET: every route but the admin one answers 401, 404 or 405, which a GET check fails.
|
|
893
|
+
...commandHealthCheckSettings(tcpProbe(JOBS_PORT)),
|
|
894
|
+
...(refChanged ? { git_branch: gitRef } : {}),
|
|
895
|
+
});
|
|
896
|
+
const db = await getDatabaseStarted(coolify, dbId);
|
|
897
|
+
if (!db.internal_db_url) {
|
|
898
|
+
throw new Error(`${label}: database ${dbId} has no internal_db_url yet; not deploying the scheduler without a database`);
|
|
899
|
+
}
|
|
900
|
+
// The scheduler migrates as `DATABASE_URL_OWNER` and creates its own
|
|
901
|
+
// `jobs_rt` role from `DATABASE_URL` (jobs README, "Deploying"). An app
|
|
902
|
+
// with no owner URL still holds the owner's URL in `DATABASE_URL`, so
|
|
903
|
+
// that is never reused: a runtime URL is minted, once, and kept after.
|
|
904
|
+
const hasOwnerUrl = (await coolify.listEnvs(appId)).some((e) => e.key === 'DATABASE_URL_OWNER' && !e.is_preview && e.value);
|
|
905
|
+
const runtimeUrl = await ensureAiRuntimeUrl(coolify, hasOwnerUrl ? [appId] : [], db.internal_db_url, label);
|
|
906
|
+
const envs = [
|
|
907
|
+
{ key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
|
|
908
|
+
{ key: 'DATABASE_URL', value: runtimeUrl },
|
|
909
|
+
];
|
|
910
|
+
// Whether the scheduler runs on envs other than the ones written below.
|
|
911
|
+
// Marked and persisted before the first write, and cleared only after the
|
|
912
|
+
// deploy, so a run that dies in between leaves the mark and the next run
|
|
913
|
+
// deploys again (`CLAUDE.md` rule 4).
|
|
914
|
+
const pending = Boolean(scope.recordedIds()[JOBS_DEPLOY_PENDING_SCOPE_KEY]) ||
|
|
915
|
+
refChanged ||
|
|
916
|
+
(await envsDiffer(coolify, appId, envs));
|
|
917
|
+
if (pending) {
|
|
918
|
+
scope.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '1');
|
|
919
|
+
await scope.persist();
|
|
920
|
+
}
|
|
921
|
+
// SCHEDULER_ADMIN_TOKEN gates the admin API the org registrations use
|
|
922
|
+
// (jobs README, "The admin API"); unset, every admin route answers 401.
|
|
923
|
+
// Delivered to the host before it is written to the app.
|
|
924
|
+
let tokenJustSet = false;
|
|
925
|
+
if (!scope.recordedIds()[JOBS_ADMIN_TOKEN_SCOPE_KEY]) {
|
|
926
|
+
const lost = own ? scope.recordedIds()[JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY] : undefined;
|
|
927
|
+
const resumed = lost ? await deps.readSecret?.(target.refs.jobsAdminToken) : null;
|
|
928
|
+
if (lost && !resumed) {
|
|
929
|
+
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`);
|
|
930
|
+
}
|
|
931
|
+
const token = resumed || randomBytes(32).toString('hex');
|
|
932
|
+
if (!resumed) {
|
|
933
|
+
if (own) {
|
|
934
|
+
scope.recordScopeId(JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY, appId);
|
|
935
|
+
await scope.persist();
|
|
936
|
+
}
|
|
840
937
|
let delivered = false;
|
|
841
938
|
for (let attempt = 1; attempt <= JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS && !delivered; attempt++) {
|
|
842
939
|
try {
|
|
843
|
-
await deliverSecret(
|
|
940
|
+
await deliverSecret(target.refs.jobsAdminToken, token);
|
|
844
941
|
delivered = true;
|
|
845
942
|
}
|
|
846
943
|
catch {
|
|
@@ -848,48 +945,49 @@ export class FoundryPlatformStepExecutor {
|
|
|
848
945
|
}
|
|
849
946
|
}
|
|
850
947
|
if (!delivered) {
|
|
851
|
-
throw new Error(
|
|
948
|
+
throw new Error(`${label}: the scheduler admin token could not be delivered after ${JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS} attempts`);
|
|
852
949
|
}
|
|
853
|
-
envs.push({ key: 'SCHEDULER_ADMIN_TOKEN', value: token });
|
|
854
|
-
await coolify.bulkUpsertEnvs(appId, envs);
|
|
855
|
-
context.recordScopeId(JOBS_ADMIN_TOKEN_SCOPE_KEY, appId);
|
|
856
|
-
tokenJustSet = true;
|
|
857
950
|
}
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
const running = (await coolify.getApplication(appId)).status?.startsWith('running') ?? false;
|
|
865
|
-
// An app applied before its internal address was recorded has no fixed
|
|
866
|
-
// name yet, so it deploys once to get it; after that it deploys on the
|
|
867
|
-
// other conditions only.
|
|
868
|
-
const addressNew = !context.recordedIds()[JOBS_INTERNAL_URL_SCOPE_KEY];
|
|
869
|
-
// The address is recorded only once the deployment finished: the scheduler
|
|
870
|
-
// has no route that answers without its admin token (every other path is a
|
|
871
|
-
// 401, 404 or 405), so the deployment record is the whole check, as for
|
|
872
|
-
// the AI worker. A deploy that fails or times out throws here, records
|
|
873
|
-
// nothing and leaves the step planned.
|
|
874
|
-
if (!existingApp || tokenJustSet || pending || !running || addressNew) {
|
|
875
|
-
await waitForNewDeployment(coolify, appId, { kind: 'deployment-only' }, this.runtime, this.runtime.sleep ?? defaultSleep, 'platform step coolify:jobs', 'the scheduler keeps its old address');
|
|
876
|
-
}
|
|
877
|
-
if (pending) {
|
|
878
|
-
context.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '');
|
|
879
|
-
await context.persist?.();
|
|
880
|
-
}
|
|
881
|
-
context.recordScopeId(JOBS_INTERNAL_URL_SCOPE_KEY, `http://${JOBS_INTERNAL_NAME}:${JOBS_PORT}`);
|
|
882
|
-
return { resourceId: appId };
|
|
951
|
+
envs.push({ key: 'SCHEDULER_ADMIN_TOKEN', value: token });
|
|
952
|
+
await coolify.bulkUpsertEnvs(appId, envs);
|
|
953
|
+
scope.recordScopeId(JOBS_ADMIN_TOKEN_SCOPE_KEY, appId);
|
|
954
|
+
if (own)
|
|
955
|
+
scope.recordScopeId(JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY, '');
|
|
956
|
+
tokenJustSet = true;
|
|
883
957
|
}
|
|
958
|
+
else {
|
|
959
|
+
await coolify.bulkUpsertEnvs(appId, envs);
|
|
960
|
+
}
|
|
961
|
+
// The app is created with `instant_deploy: false` and an env change needs
|
|
962
|
+
// a redeploy, so deploy after a create or a new token. Also when it is not
|
|
963
|
+
// running, so a deploy that failed on an earlier run is retried.
|
|
964
|
+
const running = (await coolify.getApplication(appId)).status?.startsWith('running') ?? false;
|
|
965
|
+
// An app applied before its internal address was recorded has no fixed
|
|
966
|
+
// name yet, so it deploys once to get it; after that it deploys on the
|
|
967
|
+
// other conditions only.
|
|
968
|
+
const addressNew = !scope.recordedIds()[JOBS_INTERNAL_URL_SCOPE_KEY];
|
|
969
|
+
// The address is recorded only once the deployment finished: the scheduler
|
|
970
|
+
// has no route that answers without its admin token (every other path is a
|
|
971
|
+
// 401, 404 or 405), so the deployment record is the whole check, as for
|
|
972
|
+
// the AI worker. A deploy that fails or times out throws here, records
|
|
973
|
+
// nothing and leaves the step planned.
|
|
974
|
+
if (!existingApp || tokenJustSet || pending || !running || addressNew) {
|
|
975
|
+
const built = await waitForNewDeployment(coolify, appId, { kind: 'deployment-only' }, runtime, runtime.sleep ?? defaultSleep, label, 'the scheduler keeps its old address');
|
|
976
|
+
await recordBuiltCommit(coolify, { appId: appId, deploymentUuid: built.deploymentUuid, name: 'jobs' }, (key, value) => scope.recordScopeId(key, value));
|
|
977
|
+
}
|
|
978
|
+
if (pending) {
|
|
979
|
+
scope.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '');
|
|
980
|
+
await scope.persist();
|
|
981
|
+
}
|
|
982
|
+
if (own)
|
|
983
|
+
scope.recordScopeId(JOBS_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
984
|
+
scope.recordScopeId(JOBS_INTERNAL_URL_SCOPE_KEY, `http://${target.internal.jobs}:${JOBS_PORT}`);
|
|
985
|
+
return { resourceId: appId };
|
|
884
986
|
}
|
|
885
987
|
// ---------------------------------------------------------------------------
|
|
886
988
|
// #168: the platform's own sign-in (ZITADEL)
|
|
887
989
|
// ---------------------------------------------------------------------------
|
|
888
990
|
const PLATFORM_AUTH_PROJECT = 'platform';
|
|
889
|
-
const PLATFORM_AUTH_DB_NAME = 'platform-auth-db';
|
|
890
|
-
const PLATFORM_AUTH_ZITADEL_APP_NAME = 'platform-auth-zitadel';
|
|
891
|
-
const PLATFORM_AUTH_LOGIN_APP_NAME = 'platform-auth-login';
|
|
892
|
-
const PLATFORM_AUTH_BROKER_APP_NAME = 'platform-auth-broker';
|
|
893
991
|
/**
|
|
894
992
|
* Fixed `custom_internal_name`s (`diff.ts`'s `coolifyCalls` sets the same
|
|
895
993
|
* kind of name on Synapse/MAS) so the Cloudflare tunnel below can address
|
|
@@ -897,12 +995,7 @@ const PLATFORM_AUTH_BROKER_APP_NAME = 'platform-auth-broker';
|
|
|
897
995
|
* -- bypassing Coolify's own Traefik, which dispatches purely by `Host`
|
|
898
996
|
* header and so cannot give two different apps the same public hostname.
|
|
899
997
|
*/
|
|
900
|
-
const PLATFORM_AUTH_ZITADEL_INTERNAL_NAME = 'platform-auth-zitadel';
|
|
901
998
|
export const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = 'platform-auth-login';
|
|
902
|
-
const PLATFORM_AUTH_BROKER_INTERNAL_NAME = 'platform-auth-broker';
|
|
903
|
-
const PLATFORM_AUTH_TUNNEL_NAME = 'platform-auth-tunnel';
|
|
904
|
-
/** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_AUTH_TUNNEL_NAME}. */
|
|
905
|
-
const PLATFORM_AUTH_CLOUDFLARED_APP_NAME = 'platform-auth-cloudflared';
|
|
906
999
|
/** `wtfalch/auth`'s `docker-compose.yml`: `image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION:-v4.17.3}`. Pinned in `versions.json` (#247). */
|
|
907
1000
|
const AUTH_ZITADEL_IMAGE = 'ghcr.io/zitadel/zitadel';
|
|
908
1001
|
const AUTH_ZITADEL_IMAGE_TAG = VERSIONS.zitadel;
|
|
@@ -917,6 +1010,22 @@ const AUTH_BROKER_DOCKERFILE = '/broker/Dockerfile';
|
|
|
917
1010
|
* own `ZITADEL_PORT: 8080` and the login image's own `EXPOSE`.
|
|
918
1011
|
*/
|
|
919
1012
|
const AUTH_ZITADEL_PORT = '8080';
|
|
1013
|
+
/**
|
|
1014
|
+
* The inline Dockerfile of the ZITADEL app. The published image is distroless (no shell, no `curl`) and has no
|
|
1015
|
+
* HEALTHCHECK, and Coolify runs its own check as `sh -c`. So the app is a Dockerfile app: the image's own `ready`
|
|
1016
|
+
* command is its exec-form HEALTHCHECK, which Coolify keeps while its own check is off. `ready` asks the port and
|
|
1017
|
+
* TLS mode from the same `ZITADEL_*` settings the server reads. The start period covers the first run, which sets
|
|
1018
|
+
* up the database and the first instance before it listens. The start command is the one
|
|
1019
|
+
* {@link AUTH_ZITADEL_RUN_OPTIONS} names.
|
|
1020
|
+
*/
|
|
1021
|
+
export function zitadelDockerfile() {
|
|
1022
|
+
return [
|
|
1023
|
+
`FROM ${AUTH_ZITADEL_IMAGE}:${pinnedRef('zitadel')}`,
|
|
1024
|
+
`EXPOSE ${AUTH_ZITADEL_PORT}`,
|
|
1025
|
+
'CMD ["start-from-init", "--masterkeyFromEnv", "--tlsMode", "external"]',
|
|
1026
|
+
'HEALTHCHECK --interval=15s --timeout=5s --start-period=180s --retries=5 CMD ["/app/zitadel", "ready"]',
|
|
1027
|
+
].join('\n');
|
|
1028
|
+
}
|
|
920
1029
|
export const AUTH_LOGIN_PORT = '3000';
|
|
921
1030
|
/** `broker/Dockerfile`'s own `ENV PORT=3000` / `EXPOSE 3000`. */
|
|
922
1031
|
const AUTH_BROKER_PORT = '3000';
|
|
@@ -942,8 +1051,8 @@ export const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|sy
|
|
|
942
1051
|
const AUTH_BROKER_PATH_PATTERN = '^/api(/|$)';
|
|
943
1052
|
const PLATFORM_AUTH_PROJECT_SCOPE_KEY = 'coolify_project_platform_auth';
|
|
944
1053
|
const PLATFORM_AUTH_DB_SCOPE_KEY = 'coolify_platform_auth_database';
|
|
945
|
-
const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = 'coolify_platform_auth_zitadel_app';
|
|
946
|
-
const PLATFORM_AUTH_LOGIN_SCOPE_KEY = 'coolify_platform_auth_login_app';
|
|
1054
|
+
export const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = 'coolify_platform_auth_zitadel_app';
|
|
1055
|
+
export const PLATFORM_AUTH_LOGIN_SCOPE_KEY = 'coolify_platform_auth_login_app';
|
|
947
1056
|
const PLATFORM_AUTH_TUNNEL_SCOPE_KEY = 'cloudflare_platform_auth_tunnel';
|
|
948
1057
|
/** Recorded once the ZITADEL app has been deployed, so ZITADEL may have written to its database. */
|
|
949
1058
|
const PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY = 'zitadel_platform_auth_deployed';
|
|
@@ -1072,6 +1181,10 @@ function loginClientRecordValue(userId, patId, token) {
|
|
|
1072
1181
|
* miss for a credential-liveness check than for Archon's own Access policy.
|
|
1073
1182
|
*/
|
|
1074
1183
|
async function authLoginClientDrifted(platform, auth, recordedIds) {
|
|
1184
|
+
return authLoginClientDriftedAt({ hostname: platform.auth.hostname }, auth, recordedIds);
|
|
1185
|
+
}
|
|
1186
|
+
/** {@link authLoginClientDrifted} for any instance; `bearerRef` is passed to `zitadelForInstance` only when set. */
|
|
1187
|
+
async function authLoginClientDriftedAt(instance, auth, recordedIds) {
|
|
1075
1188
|
const record = parseLoginClientRecord(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
|
|
1076
1189
|
if (!record)
|
|
1077
1190
|
return false;
|
|
@@ -1081,7 +1194,10 @@ async function authLoginClientDrifted(platform, auth, recordedIds) {
|
|
|
1081
1194
|
if (!orgId || !loginAppId || !brokerAppId)
|
|
1082
1195
|
return true;
|
|
1083
1196
|
try {
|
|
1084
|
-
const
|
|
1197
|
+
const issuer = `https://${instance.hostname}`;
|
|
1198
|
+
const zitadel = (instance.bearerRef && instance.bearerRef !== PLATFORM_AUTH_INSTANCE_BEARER_REF
|
|
1199
|
+
? auth.zitadelForInstance?.(issuer, instance.bearerRef)
|
|
1200
|
+
: auth.zitadelForInstance?.(issuer)) ?? auth.zitadel;
|
|
1085
1201
|
const livePats = await zitadel.listLoginClientPats(orgId, record.userId);
|
|
1086
1202
|
if (livePats.length !== 1 || livePats[0] !== record.patId)
|
|
1087
1203
|
return true;
|
|
@@ -1240,28 +1356,33 @@ async function liveLoginClientTokenHash(coolify, appId) {
|
|
|
1240
1356
|
* #197: a `cloudflared` connector for this tunnel is run
|
|
1241
1357
|
* ({@link ensureCloudflaredConnector}), so `hostname` answers.
|
|
1242
1358
|
*/
|
|
1243
|
-
async function
|
|
1244
|
-
const {
|
|
1359
|
+
export async function provisionAuthStack(scope, deps, target, input) {
|
|
1360
|
+
const { hostname, apex, gitRef, ownerCompany, network } = input;
|
|
1245
1361
|
const owners = ownerCompany?.owners ?? [];
|
|
1246
|
-
const
|
|
1247
|
-
const gitRef = platform.auth.gitRef;
|
|
1248
|
-
const recorded = context.recordedIds();
|
|
1362
|
+
const recorded = scope.recordedIds();
|
|
1249
1363
|
const runtime = deps.runtime ?? {};
|
|
1250
1364
|
const sleep = runtime.sleep ?? defaultSleep;
|
|
1365
|
+
const label = target.kind === 'platform' ? 'platform:auth' : `${target.id}:auth`;
|
|
1366
|
+
const subject = target.kind === 'platform' ? 'the platform' : target.id;
|
|
1367
|
+
const issuer = `https://${hostname}`;
|
|
1368
|
+
const instanceZitadel = () => {
|
|
1369
|
+
const forInstance = target.refs.instanceBearer === PLATFORM_AUTH_INSTANCE_BEARER_REF
|
|
1370
|
+
? deps.zitadelForInstance?.(issuer)
|
|
1371
|
+
: deps.zitadelForInstance?.(issuer, target.refs.instanceBearer);
|
|
1372
|
+
if (forInstance)
|
|
1373
|
+
return forInstance;
|
|
1374
|
+
if (target.kind === 'org') {
|
|
1375
|
+
throw new Error(`own step coolify:own-auth for ${target.id}: the host supplies no ZITADEL port for the company's own instance`);
|
|
1376
|
+
}
|
|
1377
|
+
return deps.zitadel;
|
|
1378
|
+
};
|
|
1251
1379
|
// Records an id and writes it out now, so a crash before the step ends
|
|
1252
1380
|
// cannot lose a resource a provider already created.
|
|
1253
1381
|
const record = async (key, id) => {
|
|
1254
|
-
|
|
1255
|
-
await
|
|
1382
|
+
scope.recordScopeId(key, id);
|
|
1383
|
+
await scope.persist();
|
|
1256
1384
|
};
|
|
1257
|
-
|
|
1258
|
-
if (!projectUuid) {
|
|
1259
|
-
const found = await deps.coolify.findProjectByName(PLATFORM_AUTH_PROJECT);
|
|
1260
|
-
projectUuid = found
|
|
1261
|
-
? found.uuid
|
|
1262
|
-
: (await deps.coolify.createProject(PLATFORM_AUTH_PROJECT)).uuid;
|
|
1263
|
-
await record(PLATFORM_AUTH_PROJECT_SCOPE_KEY, projectUuid);
|
|
1264
|
-
}
|
|
1385
|
+
const projectUuid = await input.project.ensure(deps.coolify, scope);
|
|
1265
1386
|
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1266
1387
|
// ---- database ----------------------------------------------------------
|
|
1267
1388
|
let dbId = recorded[PLATFORM_AUTH_DB_SCOPE_KEY];
|
|
@@ -1277,7 +1398,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1277
1398
|
server_uuid: server.uuid,
|
|
1278
1399
|
environment_name: environment.name,
|
|
1279
1400
|
environment_uuid: environment.uuid,
|
|
1280
|
-
name:
|
|
1401
|
+
name: target.names.authDb,
|
|
1281
1402
|
postgres_db: 'zitadel',
|
|
1282
1403
|
postgres_user: 'zitadel',
|
|
1283
1404
|
});
|
|
@@ -1290,22 +1411,19 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1290
1411
|
// `coolifyCalls` follows the same rule for its own databases): Coolify
|
|
1291
1412
|
// can take a moment to report a freshly created database's
|
|
1292
1413
|
// `internal_db_url`, so the next apply retries from a clean state.
|
|
1293
|
-
throw new Error(
|
|
1414
|
+
throw new Error(`coolify:app for ${subject} (auth): the database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
1294
1415
|
}
|
|
1295
1416
|
// ---- the ZITADEL app itself ---------------------------------------------
|
|
1296
|
-
const zitadelApp = await findOrCreatePlatformApp(deps.coolify,
|
|
1417
|
+
const zitadelApp = await findOrCreatePlatformApp(deps.coolify, target.names.authZitadel, environment, async () => {
|
|
1297
1418
|
const server = await resolvePlatformServer(deps.coolify);
|
|
1298
|
-
return deps.coolify.
|
|
1419
|
+
return deps.coolify.createDockerfileApplication({
|
|
1299
1420
|
project_uuid: projectUuid,
|
|
1300
1421
|
server_uuid: server.uuid,
|
|
1301
1422
|
environment_name: environment.name,
|
|
1302
1423
|
environment_uuid: environment.uuid,
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
custom_docker_run_options: AUTH_ZITADEL_RUN_OPTIONS,
|
|
1307
|
-
name: PLATFORM_AUTH_ZITADEL_APP_NAME,
|
|
1308
|
-
description: `ZITADEL for the platform's own sign-in (#168, ${AUTH_ZITADEL_IMAGE}:${AUTH_ZITADEL_IMAGE_TAG})`,
|
|
1424
|
+
dockerfile: zitadelDockerfile(),
|
|
1425
|
+
name: target.names.authZitadel,
|
|
1426
|
+
description: `ZITADEL for ${target.kind === 'platform' ? "the platform's" : `${target.id}'s`} own sign-in (#168, ${AUTH_ZITADEL_IMAGE}:${AUTH_ZITADEL_IMAGE_TAG})`,
|
|
1309
1427
|
instant_deploy: false,
|
|
1310
1428
|
});
|
|
1311
1429
|
});
|
|
@@ -1315,14 +1433,21 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1315
1433
|
// create must not leave the app unnamed for good, and the tunnel addresses
|
|
1316
1434
|
// the app by this name. (The port cannot read the current name back, and
|
|
1317
1435
|
// setting the same value again is harmless.)
|
|
1318
|
-
// The run options go in the same patch, so an app made before they existed
|
|
1319
|
-
// gets them too.
|
|
1320
|
-
await deps.coolify.updateApplication(zitadelAppId, {
|
|
1321
|
-
custom_internal_name: PLATFORM_AUTH_ZITADEL_INTERNAL_NAME,
|
|
1322
|
-
custom_docker_run_options: AUTH_ZITADEL_RUN_OPTIONS,
|
|
1323
|
-
});
|
|
1324
1436
|
// The container only takes new run options at its next deploy.
|
|
1325
1437
|
const zitadelRunOptionsNew = recorded[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] !== AUTH_ZITADEL_RUN_OPTIONS;
|
|
1438
|
+
// A new app is a Dockerfile app that starts with `zitadelDockerfile()`'s CMD, so it gets no run options.
|
|
1439
|
+
// An app made before that is an image app, found by name and never replaced: it keeps its run options (sent
|
|
1440
|
+
// here until the deploy that applied them is recorded), has no Docker-level check, and takes none until its
|
|
1441
|
+
// organisation is provisioned again from zero. The Coolify check stays off on both: the Dockerfile's
|
|
1442
|
+
// HEALTHCHECK is the live one, and Coolify's rolling update follows it.
|
|
1443
|
+
await deps.coolify.updateApplication(zitadelAppId, {
|
|
1444
|
+
custom_internal_name: target.internal.authZitadel,
|
|
1445
|
+
ports_exposes: AUTH_ZITADEL_PORT,
|
|
1446
|
+
health_check_enabled: false,
|
|
1447
|
+
...(zitadelRunOptionsNew && !zitadelApp.justCreated
|
|
1448
|
+
? { custom_docker_run_options: AUTH_ZITADEL_RUN_OPTIONS }
|
|
1449
|
+
: {}),
|
|
1450
|
+
});
|
|
1326
1451
|
// #251: only on create, never unconditionally -- nothing here patches
|
|
1327
1452
|
// `docker_registry_image_tag` on an app `findOrCreatePlatformApp` found
|
|
1328
1453
|
// already running, so that is the only moment this tag is actually live.
|
|
@@ -1333,7 +1458,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1333
1458
|
await record(PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, AUTH_ZITADEL_IMAGE_TAG);
|
|
1334
1459
|
}
|
|
1335
1460
|
// ---- the zitadel-login app -----------------------------------------------
|
|
1336
|
-
const loginApp = await findOrCreatePlatformApp(deps.coolify,
|
|
1461
|
+
const loginApp = await findOrCreatePlatformApp(deps.coolify, target.names.authLogin, environment, async () => {
|
|
1337
1462
|
const server = await resolvePlatformServer(deps.coolify);
|
|
1338
1463
|
const githubApp = await deps.coolify.requireGithubApp();
|
|
1339
1464
|
return deps.coolify.createGithubAppApplication({
|
|
@@ -1345,17 +1470,19 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1345
1470
|
git_repository: AUTH_REPO,
|
|
1346
1471
|
git_branch: gitRef,
|
|
1347
1472
|
build_pack: 'dockerfile',
|
|
1348
|
-
name:
|
|
1473
|
+
name: target.names.authLogin,
|
|
1349
1474
|
dockerfile_location: AUTH_LOGIN_DOCKERFILE,
|
|
1350
1475
|
});
|
|
1351
1476
|
});
|
|
1352
1477
|
const loginAppId = loginApp.appId;
|
|
1353
1478
|
await record(PLATFORM_AUTH_LOGIN_SCOPE_KEY, loginAppId);
|
|
1479
|
+
// The Dockerfile's own HEALTHCHECK is the live check (`IMAGE_HEALTHCHECK_SETTINGS`).
|
|
1354
1480
|
await deps.coolify.updateApplication(loginAppId, {
|
|
1355
|
-
custom_internal_name:
|
|
1481
|
+
custom_internal_name: target.internal.authLogin,
|
|
1482
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
1356
1483
|
});
|
|
1357
1484
|
// ---- the broker app (#204) -------------------------------------------------
|
|
1358
|
-
const brokerApp = await findOrCreatePlatformApp(deps.coolify,
|
|
1485
|
+
const brokerApp = await findOrCreatePlatformApp(deps.coolify, target.names.authBroker, environment, async () => {
|
|
1359
1486
|
const server = await resolvePlatformServer(deps.coolify);
|
|
1360
1487
|
const githubApp = await deps.coolify.requireGithubApp();
|
|
1361
1488
|
return deps.coolify.createGithubAppApplication({
|
|
@@ -1367,21 +1494,24 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1367
1494
|
git_repository: AUTH_REPO,
|
|
1368
1495
|
git_branch: gitRef,
|
|
1369
1496
|
build_pack: 'dockerfile',
|
|
1370
|
-
name:
|
|
1497
|
+
name: target.names.authBroker,
|
|
1371
1498
|
dockerfile_location: AUTH_BROKER_DOCKERFILE,
|
|
1372
1499
|
});
|
|
1373
1500
|
});
|
|
1374
1501
|
const brokerAppId = brokerApp.appId;
|
|
1375
1502
|
await record(PLATFORM_AUTH_BROKER_SCOPE_KEY, brokerAppId);
|
|
1376
1503
|
await deps.coolify.updateApplication(brokerAppId, {
|
|
1377
|
-
custom_internal_name:
|
|
1504
|
+
custom_internal_name: target.internal.authBroker,
|
|
1505
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
1378
1506
|
});
|
|
1379
1507
|
// ---- env -----------------------------------------------------------------
|
|
1380
1508
|
// Generated once, handed straight to `bulkUpsertEnvs` and nowhere else --
|
|
1381
1509
|
// never returned, recorded, logged or interpolated into a thrown error, the
|
|
1382
1510
|
// same rule `executor.ts`'s `ensureAuthCookieSecret` follows for a value
|
|
1383
1511
|
// this package mints itself rather than receiving from a provider.
|
|
1384
|
-
const masterkey = await ensureMasterkey(deps.coolify, zitadelAppId, databaseInUse);
|
|
1512
|
+
const masterkey = await ensureMasterkey(deps.coolify, zitadelAppId, databaseInUse, label);
|
|
1513
|
+
if (masterkey)
|
|
1514
|
+
runtime.onSecret?.(masterkey);
|
|
1385
1515
|
// #283: the instance is created once, with the system API, by a platform
|
|
1386
1516
|
// whose admin machine user is not recorded yet (one that already has it was
|
|
1387
1517
|
// built from a first instance and keeps it). The keypair is regenerated on
|
|
@@ -1391,7 +1521,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1391
1521
|
if (lostInstance &&
|
|
1392
1522
|
!recorded[PLATFORM_AUTH_INSTANCE_SCOPE_KEY] &&
|
|
1393
1523
|
!recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
|
|
1394
|
-
throw new Error(instanceWithoutBearerMessage(hostname, `instance ${lostInstance}
|
|
1524
|
+
throw new Error(instanceWithoutBearerMessage(hostname, `instance ${lostInstance}`, label));
|
|
1395
1525
|
}
|
|
1396
1526
|
const needsInstance = !recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY] && !recorded[PLATFORM_AUTH_INSTANCE_SCOPE_KEY];
|
|
1397
1527
|
const systemKeys = needsInstance ? generateSystemApiKeypair() : undefined;
|
|
@@ -1404,6 +1534,10 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1404
1534
|
{ key: 'ZITADEL_DATABASE_POSTGRES_DSN', value: db.internal_db_url },
|
|
1405
1535
|
{ key: 'ZITADEL_FIRSTINSTANCE_SKIP', value: 'true' },
|
|
1406
1536
|
{ key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED', value: 'true' },
|
|
1537
|
+
// A username is unique per organisation, and the login name is `name@<org domain>`. Without it a
|
|
1538
|
+
// name is unique across the instance, so the owner's account in Archon's own organisation
|
|
1539
|
+
// collides with the same owner's account in the company's organisation.
|
|
1540
|
+
{ key: 'ZITADEL_DEFAULTINSTANCE_DOMAINPOLICY_USERLOGINMUSTBEDOMAIN', value: 'true' },
|
|
1407
1541
|
{
|
|
1408
1542
|
key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI',
|
|
1409
1543
|
value: `https://${hostname}/`,
|
|
@@ -1421,6 +1555,10 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1421
1555
|
value: `https://${hostname}/login?samlRequest=`,
|
|
1422
1556
|
},
|
|
1423
1557
|
{ key: 'ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED', value: 'true' },
|
|
1558
|
+
// ZITADEL 4.17.3 logs every mail's template data at warning level ("missing
|
|
1559
|
+
// translation" for `<Type>.Footer`), which holds the one-time invite code.
|
|
1560
|
+
// Errors (a failed SMTP send among them) are still written.
|
|
1561
|
+
{ key: 'ZITADEL_LOG_LEVEL', value: 'error' },
|
|
1424
1562
|
];
|
|
1425
1563
|
if (masterkey)
|
|
1426
1564
|
zitadelEnvs.push({ key: 'ZITADEL_MASTERKEY', value: masterkey });
|
|
@@ -1433,7 +1571,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1433
1571
|
const loginEnvs = [
|
|
1434
1572
|
{
|
|
1435
1573
|
key: 'ZITADEL_API_URL',
|
|
1436
|
-
value: `http://${
|
|
1574
|
+
value: `http://${target.internal.authZitadel}:${AUTH_ZITADEL_PORT}`,
|
|
1437
1575
|
},
|
|
1438
1576
|
{
|
|
1439
1577
|
key: 'CUSTOM_REQUEST_HEADERS',
|
|
@@ -1447,8 +1585,8 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1447
1585
|
// invite's own path (set a password, then nowhere to go). Only set once
|
|
1448
1586
|
// Archon is declared, and only ever to Archon: the platform's one landing
|
|
1449
1587
|
// app for anyone who reaches the login app outside an app's own sign-in.
|
|
1450
|
-
...(
|
|
1451
|
-
? [{ key: 'DEFAULT_REDIRECT_URI', value:
|
|
1588
|
+
...(input.defaultRedirectUri
|
|
1589
|
+
? [{ key: 'DEFAULT_REDIRECT_URI', value: input.defaultRedirectUri }]
|
|
1452
1590
|
: []),
|
|
1453
1591
|
];
|
|
1454
1592
|
// #210: read back now, not inside the deploy-decision block below -- this
|
|
@@ -1456,7 +1594,9 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1456
1594
|
// the same way `envsDiffer`/`bulkUpsertEnvs` below already do for every
|
|
1457
1595
|
// other broker env, so a single apply that finds the key already held can
|
|
1458
1596
|
// also see `LETTERMINT_API_TOKEN` as met and deploy in that same run.
|
|
1459
|
-
const heldMailKey = await deps.heldKeys?.resolve(
|
|
1597
|
+
const heldMailKey = await deps.heldKeys?.resolve(target.refs.brokerMailKey);
|
|
1598
|
+
if (heldMailKey)
|
|
1599
|
+
runtime.onSecret?.(heldMailKey);
|
|
1460
1600
|
// `AUTH_APPS` and `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` are not pushed here
|
|
1461
1601
|
// -- this function's own doc comment, "Known gaps". `LETTERMINT_API_TOKEN`
|
|
1462
1602
|
// is, once held (#210): pushing it costs nothing even while the broker
|
|
@@ -1465,7 +1605,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1465
1605
|
const brokerEnvs = [
|
|
1466
1606
|
{
|
|
1467
1607
|
key: 'ZITADEL_API_URL',
|
|
1468
|
-
value: `http://${
|
|
1608
|
+
value: `http://${target.internal.authZitadel}:${AUTH_ZITADEL_PORT}`,
|
|
1469
1609
|
},
|
|
1470
1610
|
{ key: 'ZITADEL_DOMAIN', value: hostname },
|
|
1471
1611
|
...(heldMailKey ? [{ key: 'LETTERMINT_API_TOKEN', value: heldMailKey }] : []),
|
|
@@ -1517,25 +1657,25 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1517
1657
|
await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, authAppsRaw));
|
|
1518
1658
|
}
|
|
1519
1659
|
// ---- tunnel and DNS ------------------------------------------------------
|
|
1520
|
-
const tunnels = await
|
|
1521
|
-
const existingTunnel = await tunnels.findTunnelByName(
|
|
1522
|
-
const tunnel = existingTunnel ?? (await tunnels.createTunnel(
|
|
1660
|
+
const tunnels = await network.tunnels();
|
|
1661
|
+
const existingTunnel = await tunnels.findTunnelByName(target.names.authTunnel);
|
|
1662
|
+
const tunnel = existingTunnel ?? (await tunnels.createTunnel(target.names.authTunnel));
|
|
1523
1663
|
await record(PLATFORM_AUTH_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
1524
1664
|
await tunnels.putTunnelConfig(tunnel.id, {
|
|
1525
1665
|
ingress: [
|
|
1526
1666
|
{
|
|
1527
1667
|
hostname,
|
|
1528
1668
|
path: AUTH_ZITADEL_PATH_PATTERN,
|
|
1529
|
-
service: `http://${
|
|
1669
|
+
service: `http://${target.internal.authZitadel}:${AUTH_ZITADEL_PORT}`,
|
|
1530
1670
|
},
|
|
1531
1671
|
{
|
|
1532
1672
|
hostname,
|
|
1533
1673
|
path: AUTH_BROKER_PATH_PATTERN,
|
|
1534
|
-
service: `http://${
|
|
1674
|
+
service: `http://${target.internal.authBroker}:${AUTH_BROKER_PORT}`,
|
|
1535
1675
|
},
|
|
1536
1676
|
{
|
|
1537
1677
|
hostname,
|
|
1538
|
-
service: `http://${
|
|
1678
|
+
service: `http://${target.internal.authLogin}:${AUTH_LOGIN_PORT}`,
|
|
1539
1679
|
},
|
|
1540
1680
|
{ service: 'http_status:404' },
|
|
1541
1681
|
],
|
|
@@ -1548,7 +1688,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1548
1688
|
coolify: deps.coolify,
|
|
1549
1689
|
tunnels,
|
|
1550
1690
|
tunnelId: tunnel.id,
|
|
1551
|
-
appName:
|
|
1691
|
+
appName: target.names.authCloudflared,
|
|
1552
1692
|
projectUuid,
|
|
1553
1693
|
environment,
|
|
1554
1694
|
resolveServer: () => resolvePlatformServer(deps.coolify),
|
|
@@ -1557,15 +1697,15 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1557
1697
|
});
|
|
1558
1698
|
// The apex zone step records the zone id before this step runs, but it
|
|
1559
1699
|
// creates no record for the hostname, so it is created here.
|
|
1560
|
-
const zoneId =
|
|
1700
|
+
const zoneId = await network.zoneId(apex);
|
|
1561
1701
|
if (!zoneId) {
|
|
1562
|
-
throw new Error(
|
|
1702
|
+
throw new Error(`${label}: no Cloudflare zone for ${apex} to put ${hostname} in`);
|
|
1563
1703
|
}
|
|
1564
1704
|
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
1565
|
-
const dns = await
|
|
1566
|
-
const [dnsRecord] = await dns.findDnsRecord(
|
|
1705
|
+
const dns = await network.dns();
|
|
1706
|
+
const [dnsRecord] = await dns.findDnsRecord(apex, hostname, 'CNAME');
|
|
1567
1707
|
if (!dnsRecord) {
|
|
1568
|
-
await dns.createDnsRecord(
|
|
1708
|
+
await dns.createDnsRecord(apex, {
|
|
1569
1709
|
type: 'CNAME',
|
|
1570
1710
|
name: hostname,
|
|
1571
1711
|
content: dnsTarget,
|
|
@@ -1573,25 +1713,36 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1573
1713
|
});
|
|
1574
1714
|
}
|
|
1575
1715
|
else if (dnsRecord.content !== dnsTarget) {
|
|
1576
|
-
await dns.updateDnsRecord(
|
|
1716
|
+
await dns.updateDnsRecord(apex, dnsRecord.id, {
|
|
1577
1717
|
type: 'CNAME',
|
|
1578
1718
|
name: hostname,
|
|
1579
1719
|
content: dnsTarget,
|
|
1580
1720
|
proxied: true,
|
|
1581
1721
|
});
|
|
1582
1722
|
}
|
|
1723
|
+
if (target.kind === 'org')
|
|
1724
|
+
await recordOwnDnsHostname(scope, hostname);
|
|
1583
1725
|
// ---- foundry's own admin machine user, once ------------------------------
|
|
1584
1726
|
if (!recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
|
|
1585
1727
|
const lost = recorded[PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY];
|
|
1586
1728
|
if (lost) {
|
|
1587
1729
|
// The planner reports this and plans nothing; this is the same rule for
|
|
1588
1730
|
// a caller that reaches the step anyway.
|
|
1589
|
-
throw new Error(
|
|
1731
|
+
throw new Error(`${label}: the admin key of ZITADEL machine user ${lost} was minted but never delivered; delete that user and the recorded key ${PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
1590
1732
|
}
|
|
1591
1733
|
if (systemKeys) {
|
|
1592
1734
|
// ZITADEL answers /debug/healthz without an instance; the openid
|
|
1593
1735
|
// document below only exists once the instance does.
|
|
1594
|
-
await waitForInstance(`https://${hostname}/debug/healthz`, runtime, sleep);
|
|
1736
|
+
await waitForInstance(`https://${hostname}/debug/healthz`, runtime, sleep, label);
|
|
1737
|
+
// Healthy is not ready for the system API: its gateway answers 503 for a moment after the server listens.
|
|
1738
|
+
await waitForSystemApi({ customDomain: hostname, privateKey: systemKeys.privateKey, label }, {
|
|
1739
|
+
fetch: runtime.fetch,
|
|
1740
|
+
now: runtime.now ?? Date.now,
|
|
1741
|
+
sleep,
|
|
1742
|
+
timeoutMs: runtime.readyTimeoutMs,
|
|
1743
|
+
intervalMs: runtime.readyIntervalMs,
|
|
1744
|
+
requestTimeoutMs: runtime.readyRequestTimeoutMs,
|
|
1745
|
+
});
|
|
1595
1746
|
// Sent exactly once. Anything that may have reached ZITADEL leaves a
|
|
1596
1747
|
// marker first, so the next run refuses instead of creating again.
|
|
1597
1748
|
let created;
|
|
@@ -1601,6 +1752,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1601
1752
|
privateKey: systemKeys.privateKey,
|
|
1602
1753
|
instanceName: PLATFORM_AUTH_ORG_NAME,
|
|
1603
1754
|
firstOrgName: PLATFORM_AUTH_ORG_NAME,
|
|
1755
|
+
label,
|
|
1604
1756
|
owner: {
|
|
1605
1757
|
userName: PLATFORM_AUTH_BEARER_USER_NAME,
|
|
1606
1758
|
name: 'Foundry (instance owner)',
|
|
@@ -1614,12 +1766,13 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1614
1766
|
throw error;
|
|
1615
1767
|
}
|
|
1616
1768
|
const { instanceId, pat } = created;
|
|
1769
|
+
runtime.onSecret?.(pat);
|
|
1617
1770
|
// Recorded before the PAT leaves this function, like the admin key below.
|
|
1618
1771
|
await record(PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, instanceId);
|
|
1619
1772
|
const attempts = runtime.deliverAttempts ?? 3;
|
|
1620
1773
|
for (let attempt = 1;; attempt += 1) {
|
|
1621
1774
|
try {
|
|
1622
|
-
await deps.deliverSecret(
|
|
1775
|
+
await deps.deliverSecret(target.refs.instanceBearer, pat);
|
|
1623
1776
|
break;
|
|
1624
1777
|
}
|
|
1625
1778
|
catch (error) {
|
|
@@ -1627,7 +1780,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1627
1780
|
const reason = redactSecrets(error instanceof Error ? error.message : String(error), [
|
|
1628
1781
|
pat,
|
|
1629
1782
|
]);
|
|
1630
|
-
throw new Error(
|
|
1783
|
+
throw new Error(`${label}: the owner token of ZITADEL instance ${instanceId} was created but could not be delivered after ${attempts} tries (${reason}); ${instanceWithoutBearerMessage(hostname, `instance ${instanceId}`, label)}`);
|
|
1631
1784
|
}
|
|
1632
1785
|
await sleep(1000 * attempt);
|
|
1633
1786
|
}
|
|
@@ -1637,8 +1790,8 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1637
1790
|
}
|
|
1638
1791
|
// Minting needs a running instance behind the tunnel: the first apply
|
|
1639
1792
|
// gets here right after the deploy.
|
|
1640
|
-
await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep);
|
|
1641
|
-
const zitadel =
|
|
1793
|
+
await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep, label);
|
|
1794
|
+
const zitadel = instanceZitadel();
|
|
1642
1795
|
const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
|
|
1643
1796
|
// #216 round 5: recorded here too (mirroring `provisionPlatformArchon`),
|
|
1644
1797
|
// so `authLoginClientDrifted` (below) never needs its own ZITADEL call
|
|
@@ -1646,10 +1799,21 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1646
1799
|
if (recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY] !== org.id) {
|
|
1647
1800
|
await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, org.id);
|
|
1648
1801
|
}
|
|
1802
|
+
// The new instance starts with open self-registration and its host is
|
|
1803
|
+
// already public: close it in the people organisation before anything
|
|
1804
|
+
// else runs. The sign-in policy step records the close and repeats it;
|
|
1805
|
+
// closing twice changes nothing.
|
|
1806
|
+
try {
|
|
1807
|
+
await zitadel.closeSelfRegistration(org.id);
|
|
1808
|
+
}
|
|
1809
|
+
catch {
|
|
1810
|
+
throw new Error(`${label}: closing self-registration failed in ZITADEL org ${org.id}`);
|
|
1811
|
+
}
|
|
1649
1812
|
const { userId, token } = await zitadel.mintAdminMachineUser(org.id, {
|
|
1650
1813
|
userName: PLATFORM_AUTH_ADMIN_USER_NAME,
|
|
1651
1814
|
name: 'Foundry (platform admin)',
|
|
1652
1815
|
});
|
|
1816
|
+
runtime.onSecret?.(token);
|
|
1653
1817
|
// Recorded and persisted before the token leaves this function: if
|
|
1654
1818
|
// delivery fails for good, the next plan reports the lost key rather than
|
|
1655
1819
|
// skipping or minting again. Only a delivered key records the guard.
|
|
@@ -1661,12 +1825,12 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1661
1825
|
const attempts = runtime.deliverAttempts ?? 3;
|
|
1662
1826
|
for (let attempt = 1;; attempt += 1) {
|
|
1663
1827
|
try {
|
|
1664
|
-
await deps.deliverSecret(
|
|
1828
|
+
await deps.deliverSecret(target.refs.authAdmin, token);
|
|
1665
1829
|
break;
|
|
1666
1830
|
}
|
|
1667
1831
|
catch {
|
|
1668
1832
|
if (attempt >= attempts) {
|
|
1669
|
-
throw new Error(
|
|
1833
|
+
throw new Error(`${label}: the admin key of ZITADEL machine user ${userId} was minted but could not be delivered after ${attempts} tries`);
|
|
1670
1834
|
}
|
|
1671
1835
|
await sleep(1000 * attempt);
|
|
1672
1836
|
}
|
|
@@ -1731,11 +1895,19 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1731
1895
|
// make the same call, not just trust that a record exists, or a drifted
|
|
1732
1896
|
// platform would be planned again forever without ever actually healing.
|
|
1733
1897
|
const loginClientOtherInputsMissing = brokerMissingInputs.filter((m) => m !== 'ZITADEL_SERVICE_USER_TOKEN');
|
|
1734
|
-
const freshRecordedIds =
|
|
1898
|
+
const freshRecordedIds = scope.recordedIds();
|
|
1735
1899
|
const freshLoginClientRecord = parseLoginClientRecord(freshRecordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
|
|
1736
|
-
const loginClientNeedsMint = !freshLoginClientRecord ||
|
|
1900
|
+
const loginClientNeedsMint = !freshLoginClientRecord ||
|
|
1901
|
+
(await authLoginClientDriftedAt({ hostname, bearerRef: target.refs.instanceBearer }, deps, freshRecordedIds));
|
|
1737
1902
|
if (loginClientNeedsMint && loginClientOtherInputsMissing.length === 0) {
|
|
1738
|
-
|
|
1903
|
+
// An own org never mints a second time on top of a token that may be live:
|
|
1904
|
+
// the platform's re-mint revokes the old one, which would take the org's
|
|
1905
|
+
// sign-in down if the first delivery had in fact landed.
|
|
1906
|
+
const loginClientLost = freshRecordedIds[PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY];
|
|
1907
|
+
if (target.kind === 'org' && loginClientLost && !freshLoginClientRecord) {
|
|
1908
|
+
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`);
|
|
1909
|
+
}
|
|
1910
|
+
const loginClientZitadel = instanceZitadel();
|
|
1739
1911
|
const { org: loginClientOrg } = await loginClientZitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
|
|
1740
1912
|
// #216 round 5: backfills the org id for a platform whose admin block
|
|
1741
1913
|
// (above) ran before this recording existed and has not run since --
|
|
@@ -1747,6 +1919,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1747
1919
|
userName: PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME,
|
|
1748
1920
|
name: 'Foundry (platform login client)',
|
|
1749
1921
|
});
|
|
1922
|
+
runtime.onSecret?.(loginClientToken);
|
|
1750
1923
|
// Recorded and persisted before the token leaves this function -- the
|
|
1751
1924
|
// same "one mint" rule the admin block above follows. Never read back to
|
|
1752
1925
|
// block planning; `planAuth` (`platform-diff.ts`) reads it only to
|
|
@@ -1788,8 +1961,8 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1788
1961
|
// saying "its prior token was already revoked" there would describe a
|
|
1789
1962
|
// revoke that never happened, so the message only says so when it did.
|
|
1790
1963
|
throw new Error(revokedPriorPat
|
|
1791
|
-
?
|
|
1792
|
-
:
|
|
1964
|
+
? `${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`
|
|
1965
|
+
: `${label}: the login-client token of ZITADEL machine user ${loginClientUserId} could not be delivered after ${deliverAttempts} tries; this is its first token, so the login app and broker still have none until a later apply mints and delivers one`);
|
|
1793
1966
|
}
|
|
1794
1967
|
// #216 round 4/5: records what was actually delivered -- the user id,
|
|
1795
1968
|
// the exact PAT id, and (round 5) a hash of the exact token -- so the
|
|
@@ -1802,7 +1975,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1802
1975
|
// ---- the owner company's own sign-in org ----------------------------------
|
|
1803
1976
|
// Archon signs people in from this org; the `foundry` org holds none. The
|
|
1804
1977
|
// company executor finds the same org by name (its slug) and makes no second.
|
|
1805
|
-
const ownerOrgZitadel =
|
|
1978
|
+
const ownerOrgZitadel = instanceZitadel();
|
|
1806
1979
|
let ownerSignInOrgId;
|
|
1807
1980
|
if (ownerCompany) {
|
|
1808
1981
|
({ orgId: ownerSignInOrgId } = await ensureSignInOrg({
|
|
@@ -1810,9 +1983,9 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1810
1983
|
zitadel: ownerOrgZitadel,
|
|
1811
1984
|
name: ownerCompany.slug,
|
|
1812
1985
|
domain: signInOrgDomain(ownerCompany),
|
|
1813
|
-
recordScopeId: (key, id) =>
|
|
1986
|
+
recordScopeId: (key, id) => scope.recordScopeId(key, id),
|
|
1814
1987
|
persistScopeIds: async () => {
|
|
1815
|
-
await
|
|
1988
|
+
await scope.persist();
|
|
1816
1989
|
},
|
|
1817
1990
|
orgScopeKey: PLATFORM_OWNER_ORG_SCOPE_KEY,
|
|
1818
1991
|
domainScopeKey: PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY,
|
|
@@ -1827,19 +2000,22 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1827
2000
|
const mailToken = heldMailKey || (await currentEnvValue(deps.coolify, brokerAppId, 'LETTERMINT_API_TOKEN'));
|
|
1828
2001
|
const smtpTarget = resolveAuthSmtp(deps.smtp);
|
|
1829
2002
|
if (mailToken) {
|
|
1830
|
-
const mailZitadel =
|
|
2003
|
+
const mailZitadel = instanceZitadel();
|
|
1831
2004
|
let smtpPassword;
|
|
1832
2005
|
try {
|
|
1833
2006
|
// Runs every time the step does (idempotent), so a rotated token lands.
|
|
1834
2007
|
smtpPassword = (await deps.smtp?.password?.()) ?? mailToken;
|
|
2008
|
+
runtime.onSecret?.(smtpPassword);
|
|
1835
2009
|
const smtp = await mailZitadel.ensureSmtpProvider({
|
|
1836
2010
|
host: smtpTarget.host,
|
|
1837
2011
|
user: smtpTarget.user,
|
|
1838
2012
|
password: smtpPassword,
|
|
1839
|
-
senderAddress:
|
|
2013
|
+
senderAddress: input.smtpSender,
|
|
1840
2014
|
senderName: PLATFORM_AUTH_MAIL_SENDER_NAME,
|
|
1841
2015
|
tls: smtpTarget.tls,
|
|
1842
|
-
description:
|
|
2016
|
+
description: target.kind === 'platform'
|
|
2017
|
+
? PLATFORM_AUTH_SMTP_DESCRIPTION
|
|
2018
|
+
: `foundry:org:${target.id}:lettermint`,
|
|
1843
2019
|
});
|
|
1844
2020
|
await record(PLATFORM_AUTH_SMTP_SCOPE_KEY, smtp.id);
|
|
1845
2021
|
}
|
|
@@ -1849,10 +2025,10 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1849
2025
|
mailToken,
|
|
1850
2026
|
...(smtpPassword ? [smtpPassword] : []),
|
|
1851
2027
|
]);
|
|
1852
|
-
throw new Error(
|
|
2028
|
+
throw new Error(`${label}: could not configure ZITADEL's SMTP provider for ${hostname}: ${cause}`);
|
|
1853
2029
|
}
|
|
1854
|
-
const inviteRecorded =
|
|
1855
|
-
const inviteOrgRecorded =
|
|
2030
|
+
const inviteRecorded = scope.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
|
|
2031
|
+
const inviteOrgRecorded = scope.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY];
|
|
1856
2032
|
// An invite recorded for another org (the `foundry` org, before the owner moved) is made again.
|
|
1857
2033
|
if (!inviteRecorded || (inviteRecorded !== 'none' && inviteOrgRecorded !== ownerSignInOrgId)) {
|
|
1858
2034
|
const owner = owners[0];
|
|
@@ -1870,14 +2046,14 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1870
2046
|
zitadel: mailZitadel,
|
|
1871
2047
|
mail: deps.ownerInviteMail,
|
|
1872
2048
|
orgId: ownerSignInOrgId,
|
|
1873
|
-
authIssuer:
|
|
2049
|
+
authIssuer: issuer,
|
|
1874
2050
|
owner: {
|
|
1875
2051
|
givenName: owner.givenName,
|
|
1876
2052
|
familyName: owner.familyName,
|
|
1877
2053
|
mailboxAddress,
|
|
1878
2054
|
personalEmail: owner.email,
|
|
1879
2055
|
},
|
|
1880
|
-
label
|
|
2056
|
+
label,
|
|
1881
2057
|
...(inviteRecorded && inviteRecorded !== 'none'
|
|
1882
2058
|
? { invitedUserId: inviteRecorded }
|
|
1883
2059
|
: {}),
|
|
@@ -1892,7 +2068,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1892
2068
|
mailToken,
|
|
1893
2069
|
...(smtpPassword ? [smtpPassword] : []),
|
|
1894
2070
|
]);
|
|
1895
|
-
throw new Error(
|
|
2071
|
+
throw new Error(`${label}: could not invite the platform owner into ZITADEL org ${ownerSignInOrgId}: ${cause}`);
|
|
1896
2072
|
}
|
|
1897
2073
|
// The user id first: a crash between the two replans the invite, never skips it.
|
|
1898
2074
|
await record(PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, invitedUserId);
|
|
@@ -1903,6 +2079,137 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
1903
2079
|
}
|
|
1904
2080
|
return { resourceId: zitadelAppId };
|
|
1905
2081
|
}
|
|
2082
|
+
/** The platform's sign-in project: the shared `platform` project, its id recorded under the sign-in's own scope key. */
|
|
2083
|
+
const platformAuthProject = {
|
|
2084
|
+
async ensure(coolify, scope) {
|
|
2085
|
+
const recorded = scope.recordedIds()[PLATFORM_AUTH_PROJECT_SCOPE_KEY];
|
|
2086
|
+
if (recorded)
|
|
2087
|
+
return recorded;
|
|
2088
|
+
const found = await coolify.findProjectByName(PLATFORM_AUTH_PROJECT);
|
|
2089
|
+
const uuid = found ? found.uuid : (await coolify.createProject(PLATFORM_AUTH_PROJECT)).uuid;
|
|
2090
|
+
scope.recordScopeId(PLATFORM_AUTH_PROJECT_SCOPE_KEY, uuid);
|
|
2091
|
+
await scope.persist();
|
|
2092
|
+
return uuid;
|
|
2093
|
+
},
|
|
2094
|
+
};
|
|
2095
|
+
/** The platform's sign-in: {@link provisionAuthStack} with {@link platformTarget} and the platform's own inputs. */
|
|
2096
|
+
async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
2097
|
+
const { platform } = context;
|
|
2098
|
+
const scope = scopeFromPlatform(context);
|
|
2099
|
+
return provisionAuthStack(scope, deps, platformTarget(), {
|
|
2100
|
+
hostname: platform.auth.hostname,
|
|
2101
|
+
apex: platform.apex,
|
|
2102
|
+
gitRef: platform.auth.gitRef,
|
|
2103
|
+
project: platformAuthProject,
|
|
2104
|
+
network: platformNetwork(deps, scope, 'platform:auth'),
|
|
2105
|
+
smtpSender: `noreply@${platform.apex}`,
|
|
2106
|
+
defaultRedirectUri: platform.archon.enabled ? `https://${platform.archon.hostname}` : null,
|
|
2107
|
+
ownerCompany,
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
2110
|
+
/**
|
|
2111
|
+
* Archon's own sign-in organisation (`zitadel:archon-org` keyed
|
|
2112
|
+
* {@link PLATFORM_ARCHON_KEY}), in the owner company's ZITADEL and apart from
|
|
2113
|
+
* the organisation that holds the company's people: the org, its primary
|
|
2114
|
+
* domain, closed self-registration, a login policy that forces a second
|
|
2115
|
+
* factor, and the first person's invite (a link mailed to their personal
|
|
2116
|
+
* address). Each call records its key right after it succeeded, so a failure
|
|
2117
|
+
* leaves the earlier keys recorded and the next plan names the step again; a
|
|
2118
|
+
* repeat converges because every call looks before it writes. The errors name
|
|
2119
|
+
* the org only, never the cause: the cause can carry an address or the invite.
|
|
2120
|
+
* The invite code reaches only the mail port (30 second timeout, see
|
|
2121
|
+
* `inviteOwnerWithLink`) and is never recorded or logged.
|
|
2122
|
+
*/
|
|
2123
|
+
async function provisionPlatformArchonOrg(context, deps, ownerCompany) {
|
|
2124
|
+
const ownerSignIn = context.owner?.signIn ?? null;
|
|
2125
|
+
if (!ownerSignIn || !ownerCompany) {
|
|
2126
|
+
throw new Error("platform:archon-org: the owner company's sign-in has not been applied yet; Archon's organisation is made in its ZITADEL");
|
|
2127
|
+
}
|
|
2128
|
+
if (!deps.zitadelForInstance) {
|
|
2129
|
+
throw new Error("platform:archon-org: the host built the platform's auth dependencies without zitadelForInstance, so it cannot reach the owner company's ZITADEL");
|
|
2130
|
+
}
|
|
2131
|
+
const zitadel = deps.zitadelForInstance(ownerSignIn.issuerUrl, ownerSignIn.bearerRef);
|
|
2132
|
+
const record = async (key, value) => {
|
|
2133
|
+
context.recordScopeId(key, value);
|
|
2134
|
+
await context.persist?.();
|
|
2135
|
+
};
|
|
2136
|
+
const name = archonOrgName(ownerCompany.slug);
|
|
2137
|
+
let orgId;
|
|
2138
|
+
try {
|
|
2139
|
+
({ orgId } = await ensureSignInOrg({
|
|
2140
|
+
zitadel,
|
|
2141
|
+
name,
|
|
2142
|
+
domain: archonOrgDomain(signInOrgDomain(ownerCompany)),
|
|
2143
|
+
recordScopeId: (key, id) => context.recordScopeId(key, id),
|
|
2144
|
+
persistScopeIds: async () => {
|
|
2145
|
+
await context.persist?.();
|
|
2146
|
+
},
|
|
2147
|
+
orgScopeKey: PLATFORM_ARCHON_ORG_SCOPE_KEY,
|
|
2148
|
+
domainScopeKey: PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY,
|
|
2149
|
+
domainBlockedScopeKey: PLATFORM_ARCHON_ORG_DOMAIN_BLOCKED_SCOPE_KEY,
|
|
2150
|
+
registrationClosedScopeKey: PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY,
|
|
2151
|
+
}));
|
|
2152
|
+
}
|
|
2153
|
+
catch {
|
|
2154
|
+
throw new Error(`platform:archon-org: making the sign-in organisation ${name} (its domain and closed registration) failed in ZITADEL`);
|
|
2155
|
+
}
|
|
2156
|
+
await context.persist?.();
|
|
2157
|
+
try {
|
|
2158
|
+
await zitadel.enforceSecondFactor(orgId);
|
|
2159
|
+
}
|
|
2160
|
+
catch {
|
|
2161
|
+
throw new Error(`platform:archon-org: forcing a second factor failed in ZITADEL org ${orgId}`);
|
|
2162
|
+
}
|
|
2163
|
+
await record(PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY, orgId);
|
|
2164
|
+
const recorded = context.recordedIds();
|
|
2165
|
+
const inviteRecorded = recorded[PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY];
|
|
2166
|
+
// An invite made in another org (an earlier Archon org) is made again.
|
|
2167
|
+
if (!inviteRecorded ||
|
|
2168
|
+
(inviteRecorded !== 'none' && recorded[PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY] !== orgId)) {
|
|
2169
|
+
const owner = ownerCompany.owners[0];
|
|
2170
|
+
if (!owner) {
|
|
2171
|
+
await record(PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY, 'none');
|
|
2172
|
+
}
|
|
2173
|
+
else {
|
|
2174
|
+
const mailboxAddress = ownerMailboxes(ownerCompany)[0]?.address;
|
|
2175
|
+
// `ownerInviteBlockedReason` reports these two as blocked; the step goes on without the invite.
|
|
2176
|
+
if (mailboxAddress && deps.ownerInviteMail) {
|
|
2177
|
+
let invitedUserId;
|
|
2178
|
+
try {
|
|
2179
|
+
({ userId: invitedUserId } = await inviteOwnerWithLink({
|
|
2180
|
+
zitadel,
|
|
2181
|
+
mail: deps.ownerInviteMail,
|
|
2182
|
+
orgId,
|
|
2183
|
+
authIssuer: ownerSignIn.issuerUrl,
|
|
2184
|
+
owner: {
|
|
2185
|
+
givenName: owner.givenName,
|
|
2186
|
+
familyName: owner.familyName,
|
|
2187
|
+
mailboxAddress,
|
|
2188
|
+
personalEmail: owner.email,
|
|
2189
|
+
},
|
|
2190
|
+
label: 'platform:archon-org',
|
|
2191
|
+
...(deps.runtime?.onSecret ? { onSecret: deps.runtime.onSecret } : {}),
|
|
2192
|
+
...(inviteRecorded && inviteRecorded !== 'none'
|
|
2193
|
+
? { invitedUserId: inviteRecorded }
|
|
2194
|
+
: {}),
|
|
2195
|
+
}));
|
|
2196
|
+
}
|
|
2197
|
+
catch (error) {
|
|
2198
|
+
const cause = redactSecrets(error instanceof Error ? error.message : String(error), [
|
|
2199
|
+
owner.email,
|
|
2200
|
+
owner.email.toLowerCase(),
|
|
2201
|
+
mailboxAddress,
|
|
2202
|
+
]);
|
|
2203
|
+
throw new Error(`platform:archon-org: could not invite the first person into ZITADEL org ${orgId}: ${cause}`);
|
|
2204
|
+
}
|
|
2205
|
+
// The user id first: a crash between the two replans the invite, never skips it.
|
|
2206
|
+
await record(PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY, invitedUserId);
|
|
2207
|
+
await record(PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY, orgId);
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
return {};
|
|
2212
|
+
}
|
|
1906
2213
|
/**
|
|
1907
2214
|
* Reserves the username `admin` and switches self-registration off in the
|
|
1908
2215
|
* platform's sign-in org (`zitadel:sign-in-policy` keyed {@link PLATFORM_AUTH_KEY}).
|
|
@@ -1977,11 +2284,7 @@ const PLATFORM_AUTH_MAIL_SENDER_NAME = 'Company Included';
|
|
|
1977
2284
|
// #169: the platform's own mail server (Stalwart)
|
|
1978
2285
|
// ---------------------------------------------------------------------------
|
|
1979
2286
|
const PLATFORM_MAIL_PROJECT = 'platform';
|
|
1980
|
-
const
|
|
1981
|
-
const PLATFORM_MAIL_INTERNAL_NAME = 'platform-mail-stalwart';
|
|
1982
|
-
const PLATFORM_MAIL_TUNNEL_NAME = 'platform-mail-tunnel';
|
|
1983
|
-
/** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_MAIL_TUNNEL_NAME}. */
|
|
1984
|
-
const PLATFORM_MAIL_CLOUDFLARED_APP_NAME = 'platform-mail-cloudflared';
|
|
2287
|
+
export const PLATFORM_MAIL_INTERNAL_NAME = 'platform-mail-stalwart';
|
|
1985
2288
|
/**
|
|
1986
2289
|
* `stalw.art/docs/install/docker`: the official image. `platform.mail.gitRef`
|
|
1987
2290
|
* carries its tag -- `platform.ts`'s `PlatformService.gitRef` doc comment
|
|
@@ -2004,7 +2307,6 @@ const STALWART_PORTS_EXPOSES = [STALWART_HTTP_PORT, ...STALWART_SMTP_PORTS].join
|
|
|
2004
2307
|
* not here -- it stays behind the tunnel, exactly like `platform:auth`'s
|
|
2005
2308
|
* ZITADEL/login apps.
|
|
2006
2309
|
*/
|
|
2007
|
-
const STALWART_PORTS_MAPPINGS = STALWART_SMTP_PORTS.map((p) => `${p}:${p}`).join(',');
|
|
2008
2310
|
/**
|
|
2009
2311
|
* The v0.16 image's two VOLUMEs (its Dockerfile): the data store, and the
|
|
2010
2312
|
* `config.json` Stalwart writes when bootstrap completes. The pre-0.16 image
|
|
@@ -2028,9 +2330,10 @@ const STALWART_RECOVERY_USER = 'admin';
|
|
|
2028
2330
|
*/
|
|
2029
2331
|
const STALWART_READY_PATH = '/healthz/ready';
|
|
2030
2332
|
/** Where the server the app was created on is recorded, so the MX record follows the app, not a later pick. */
|
|
2031
|
-
const PLATFORM_MAIL_SERVER_SCOPE_KEY = 'coolify_platform_mail_server';
|
|
2333
|
+
export const PLATFORM_MAIL_SERVER_SCOPE_KEY = 'coolify_platform_mail_server';
|
|
2032
2334
|
const PLATFORM_MAIL_PROJECT_SCOPE_KEY = 'coolify_project_platform_mail';
|
|
2033
2335
|
const PLATFORM_MAIL_APP_SCOPE_KEY = 'coolify_platform_mail_app';
|
|
2336
|
+
const PLATFORM_MAIL_RELAY_APP_SCOPE_KEY = 'coolify_platform_mail_relay_app';
|
|
2034
2337
|
const PLATFORM_MAIL_TUNNEL_SCOPE_KEY = 'cloudflare_platform_mail_tunnel';
|
|
2035
2338
|
/**
|
|
2036
2339
|
* Recorded once the app has been deployed at least once -- same role as
|
|
@@ -2198,24 +2501,21 @@ const PLATFORM_MAIL_ZITADEL_PROJECT_NAME = 'mail';
|
|
|
2198
2501
|
* (the guard only ever blocks a retry and names an id to revoke by), but
|
|
2199
2502
|
* worth tightening if `createApiKey` ever starts returning its own id.
|
|
2200
2503
|
*/
|
|
2201
|
-
async function
|
|
2202
|
-
const {
|
|
2203
|
-
const
|
|
2204
|
-
const
|
|
2205
|
-
const
|
|
2206
|
-
const recorded =
|
|
2504
|
+
export async function provisionMailServer(scope, deps, target, input) {
|
|
2505
|
+
const { hostname, apex, mailHost, imageTag, hostPorts, issuerUrl, mxRecord, network } = input;
|
|
2506
|
+
const zone = input.zone ?? apex;
|
|
2507
|
+
const label = target.kind === 'org' ? `own step coolify:own-mail for ${target.id}` : 'platform:mail';
|
|
2508
|
+
const keyDescription = target.kind === 'org' ? `foundry:org:${target.id}:admin` : PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION;
|
|
2509
|
+
const recorded = scope.recordedIds();
|
|
2207
2510
|
const runtime = deps.runtime ?? {};
|
|
2208
2511
|
const sleep = runtime.sleep ?? defaultSleep;
|
|
2209
2512
|
const record = async (key, id) => {
|
|
2210
|
-
|
|
2211
|
-
await
|
|
2513
|
+
scope.recordScopeId(key, id);
|
|
2514
|
+
await scope.persist();
|
|
2212
2515
|
};
|
|
2213
2516
|
let projectUuid = recorded[PLATFORM_MAIL_PROJECT_SCOPE_KEY];
|
|
2214
2517
|
if (!projectUuid) {
|
|
2215
|
-
|
|
2216
|
-
projectUuid = found
|
|
2217
|
-
? found.uuid
|
|
2218
|
-
: (await deps.coolify.createProject(PLATFORM_MAIL_PROJECT)).uuid;
|
|
2518
|
+
projectUuid = await input.project.ensure(deps.coolify, scope);
|
|
2219
2519
|
await record(PLATFORM_MAIL_PROJECT_SCOPE_KEY, projectUuid);
|
|
2220
2520
|
}
|
|
2221
2521
|
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
@@ -2226,23 +2526,27 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2226
2526
|
// One server for both the app and the MX record: recorded the first time,
|
|
2227
2527
|
// read back after, so a later run never points `mx.<apex>` somewhere the
|
|
2228
2528
|
// app is not.
|
|
2529
|
+
const portsChanged = (recorded[PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY] ??
|
|
2530
|
+
(target.kind === 'org' ? '' : PLATFORM_MAIL_HOST_PORTS_DIRECT)) !== hostPorts;
|
|
2229
2531
|
const server = await resolveMailServer(deps.coolify, recorded[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
|
|
2230
2532
|
await record(PLATFORM_MAIL_SERVER_SCOPE_KEY, server.uuid);
|
|
2231
|
-
const serverIpBlocked = mailServerIpBlocked(server.ip,
|
|
2533
|
+
const serverIpBlocked = mxRecord ? mailServerIpBlocked(server.ip, mxRecord.serverIpv4) : null;
|
|
2232
2534
|
if (serverIpBlocked)
|
|
2233
2535
|
throw new Error(serverIpBlocked);
|
|
2234
|
-
const app = await findOrCreatePlatformApp(deps.coolify,
|
|
2536
|
+
const app = await findOrCreatePlatformApp(deps.coolify, target.names.mailApp, environment, async () => {
|
|
2235
2537
|
return deps.coolify.createDockerImageApplication({
|
|
2236
2538
|
project_uuid: projectUuid,
|
|
2237
2539
|
server_uuid: server.uuid,
|
|
2238
2540
|
environment_name: environment.name,
|
|
2239
2541
|
environment_uuid: environment.uuid,
|
|
2240
2542
|
docker_registry_image_name: STALWART_IMAGE,
|
|
2241
|
-
docker_registry_image_tag: imageTag,
|
|
2543
|
+
docker_registry_image_tag: pinnedTagFor('stalwart', imageTag),
|
|
2242
2544
|
ports_exposes: STALWART_PORTS_EXPOSES,
|
|
2243
|
-
ports_mappings:
|
|
2244
|
-
name:
|
|
2245
|
-
description:
|
|
2545
|
+
...(hostPorts ? { ports_mappings: hostPorts } : {}),
|
|
2546
|
+
name: target.names.mailApp,
|
|
2547
|
+
description: target.kind === 'org'
|
|
2548
|
+
? `Stalwart for ${target.id}'s own mail (${STALWART_IMAGE}:${imageTag})`
|
|
2549
|
+
: `Stalwart for the platform's own mail (#169, ${STALWART_IMAGE}:${imageTag})`,
|
|
2246
2550
|
instant_deploy: false,
|
|
2247
2551
|
});
|
|
2248
2552
|
});
|
|
@@ -2255,14 +2559,16 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2255
2559
|
// container before the new one can answer `waitForHealthyVersion`'s own
|
|
2256
2560
|
// `'ready'` probe below. Stalwart's own readiness route and HTTP port.
|
|
2257
2561
|
await deps.coolify.updateApplication(appId, {
|
|
2258
|
-
custom_internal_name:
|
|
2259
|
-
docker_registry_image_tag: imageTag,
|
|
2562
|
+
custom_internal_name: target.internal.mail,
|
|
2563
|
+
docker_registry_image_tag: pinnedTagFor('stalwart', imageTag),
|
|
2260
2564
|
...healthCheckSettings(STALWART_READY_PATH, Number(STALWART_HTTP_PORT)),
|
|
2565
|
+
// Only a platform that uses the shared relay, or one that did, changes a published port.
|
|
2566
|
+
...(portsChanged ? { ports_mappings: hostPorts } : {}),
|
|
2261
2567
|
});
|
|
2262
2568
|
// #177: org steps (an org's own `coolify:mail`/`coolify:agora`) read this
|
|
2263
2569
|
// back instead of a literal, the same way files/ai's own internal
|
|
2264
2570
|
// addresses are recorded.
|
|
2265
|
-
await record(PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, `http://${
|
|
2571
|
+
await record(PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, `http://${target.internal.mail}:${STALWART_HTTP_PORT}`);
|
|
2266
2572
|
// ---- storage ---------------------------------------------------------------
|
|
2267
2573
|
const storages = await deps.coolify.listStorages(appId);
|
|
2268
2574
|
// An app made before this release only has the old `/opt/stalwart` volume.
|
|
@@ -2274,7 +2580,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2274
2580
|
continue;
|
|
2275
2581
|
}
|
|
2276
2582
|
await deps.coolify.createPersistentStorage(appId, {
|
|
2277
|
-
name: `${
|
|
2583
|
+
name: `${target.names.mailApp}-${wanted.suffix}`,
|
|
2278
2584
|
mount_path: wanted.mount_path,
|
|
2279
2585
|
});
|
|
2280
2586
|
storagesAdded = true;
|
|
@@ -2295,7 +2601,12 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2295
2601
|
const declaredChanged = recorded[PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY] !== hostname ||
|
|
2296
2602
|
recorded[PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY] !== imageTag;
|
|
2297
2603
|
// A new storage binds into the container only at its next start.
|
|
2298
|
-
const mailDeployedNow = app.justCreated ||
|
|
2604
|
+
const mailDeployedNow = app.justCreated ||
|
|
2605
|
+
!app.running ||
|
|
2606
|
+
envsChanged ||
|
|
2607
|
+
declaredChanged ||
|
|
2608
|
+
storagesAdded ||
|
|
2609
|
+
portsChanged;
|
|
2299
2610
|
if (mailDeployedNow) {
|
|
2300
2611
|
// A container still up from before would answer the readiness probe with
|
|
2301
2612
|
// the old deployment's health. Stop it and see it stopped first, so the
|
|
@@ -2311,7 +2622,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2311
2622
|
// instead would just poll a hostname that cannot resolve yet on a first
|
|
2312
2623
|
// apply. Either way it runs before any applied ref is recorded.
|
|
2313
2624
|
// The old container was stopped above (or never ran), so nothing stays live.
|
|
2314
|
-
await waitForDeploymentFinished(deps.coolify, appId, runtime,
|
|
2625
|
+
await waitForDeploymentFinished(deps.coolify, appId, runtime, label, app.justCreated || !app.running
|
|
2315
2626
|
? `mail stays at ${recorded[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`
|
|
2316
2627
|
: 'mail is stopped until the next apply');
|
|
2317
2628
|
await record(PLATFORM_MAIL_DEPLOYED_SCOPE_KEY, appId);
|
|
@@ -2319,17 +2630,19 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2319
2630
|
// settings, mint) does not make the next run stop and redeploy the app.
|
|
2320
2631
|
await record(PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY, hostname);
|
|
2321
2632
|
await record(PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY, imageTag);
|
|
2633
|
+
if (hostPorts)
|
|
2634
|
+
await record(PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY, hostPorts);
|
|
2322
2635
|
}
|
|
2323
2636
|
// ---- tunnel and DNS: web/JMAP/management ------------------------------------
|
|
2324
|
-
const tunnels = await
|
|
2325
|
-
const existingTunnel = await tunnels.findTunnelByName(
|
|
2326
|
-
const tunnel = existingTunnel ?? (await tunnels.createTunnel(
|
|
2637
|
+
const tunnels = await network.tunnels();
|
|
2638
|
+
const existingTunnel = await tunnels.findTunnelByName(target.names.mailTunnel);
|
|
2639
|
+
const tunnel = existingTunnel ?? (await tunnels.createTunnel(target.names.mailTunnel));
|
|
2327
2640
|
await record(PLATFORM_MAIL_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
2328
2641
|
await tunnels.putTunnelConfig(tunnel.id, {
|
|
2329
2642
|
ingress: [
|
|
2330
2643
|
{
|
|
2331
2644
|
hostname,
|
|
2332
|
-
service: `http://${
|
|
2645
|
+
service: `http://${target.internal.mail}:${STALWART_HTTP_PORT}`,
|
|
2333
2646
|
},
|
|
2334
2647
|
{ service: 'http_status:404' },
|
|
2335
2648
|
],
|
|
@@ -2339,7 +2652,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2339
2652
|
coolify: deps.coolify,
|
|
2340
2653
|
tunnels,
|
|
2341
2654
|
tunnelId: tunnel.id,
|
|
2342
|
-
appName:
|
|
2655
|
+
appName: target.names.mailCloudflared,
|
|
2343
2656
|
projectUuid,
|
|
2344
2657
|
environment,
|
|
2345
2658
|
// The Stalwart app's own server, so the connector shares its Docker network.
|
|
@@ -2347,15 +2660,15 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2347
2660
|
recordedConnectorAppId: recorded[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
2348
2661
|
recordAppId: (appId) => record(PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, appId),
|
|
2349
2662
|
});
|
|
2350
|
-
const zoneId =
|
|
2663
|
+
const zoneId = await network.zoneId(zone);
|
|
2351
2664
|
if (!zoneId) {
|
|
2352
|
-
throw new Error(
|
|
2665
|
+
throw new Error(`${label}: no Cloudflare zone for ${zone} to put ${hostname} in`);
|
|
2353
2666
|
}
|
|
2354
2667
|
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
2355
|
-
const dns = await
|
|
2356
|
-
const [dnsRecord] = await dns.findDnsRecord(
|
|
2668
|
+
const dns = await network.dns();
|
|
2669
|
+
const [dnsRecord] = await dns.findDnsRecord(zone, hostname, 'CNAME');
|
|
2357
2670
|
if (!dnsRecord) {
|
|
2358
|
-
await dns.createDnsRecord(
|
|
2671
|
+
await dns.createDnsRecord(zone, {
|
|
2359
2672
|
type: 'CNAME',
|
|
2360
2673
|
name: hostname,
|
|
2361
2674
|
content: dnsTarget,
|
|
@@ -2363,31 +2676,35 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2363
2676
|
});
|
|
2364
2677
|
}
|
|
2365
2678
|
else if (dnsRecord.content !== dnsTarget) {
|
|
2366
|
-
await dns.updateDnsRecord(
|
|
2679
|
+
await dns.updateDnsRecord(zone, dnsRecord.id, {
|
|
2367
2680
|
type: 'CNAME',
|
|
2368
2681
|
name: hostname,
|
|
2369
2682
|
content: dnsTarget,
|
|
2370
2683
|
proxied: true,
|
|
2371
2684
|
});
|
|
2372
2685
|
}
|
|
2686
|
+
if (target.kind === 'org')
|
|
2687
|
+
await recordOwnDnsHostname(scope, hostname);
|
|
2373
2688
|
// ---- the MX hostname: a direct, unproxied A record --------------------------
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2689
|
+
if (mxRecord) {
|
|
2690
|
+
const serverIp = (mxRecord.serverIpv4 ?? server.ip); // checked by mailServerIpBlocked above
|
|
2691
|
+
const [existingMx] = await dns.findDnsRecord(zone, mailHost, 'A');
|
|
2692
|
+
if (!existingMx) {
|
|
2693
|
+
await dns.createDnsRecord(zone, {
|
|
2694
|
+
type: 'A',
|
|
2695
|
+
name: mailHost,
|
|
2696
|
+
content: serverIp,
|
|
2697
|
+
proxied: false,
|
|
2698
|
+
});
|
|
2699
|
+
}
|
|
2700
|
+
else if (existingMx.content !== serverIp) {
|
|
2701
|
+
await dns.updateDnsRecord(zone, existingMx.id, {
|
|
2702
|
+
type: 'A',
|
|
2703
|
+
name: mailHost,
|
|
2704
|
+
content: serverIp,
|
|
2705
|
+
proxied: false,
|
|
2706
|
+
});
|
|
2707
|
+
}
|
|
2391
2708
|
}
|
|
2392
2709
|
// #252: phase 2, now that the tunnel and DNS above actually route
|
|
2393
2710
|
// `hostname`. `'ready'`, not `'version'`: Stalwart's `/healthz/ready`
|
|
@@ -2404,7 +2721,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2404
2721
|
const ensureMailReady = async () => {
|
|
2405
2722
|
if (mailReadyConfirmed)
|
|
2406
2723
|
return;
|
|
2407
|
-
await waitForHealthyVersion(appId, { kind: 'ready', url: `https://${hostname}${STALWART_READY_PATH}` }, runtime, sleep,
|
|
2724
|
+
await waitForHealthyVersion(appId, { kind: 'ready', url: `https://${hostname}${STALWART_READY_PATH}` }, runtime, sleep, label, `mail stays at ${recorded[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
2408
2725
|
mailReadyConfirmed = true;
|
|
2409
2726
|
};
|
|
2410
2727
|
// Also probe when the applied ref lags the target: the deployed refs are
|
|
@@ -2419,7 +2736,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2419
2736
|
// projects -- its id is the OIDC audience Stalwart's directory checks an
|
|
2420
2737
|
// access token's `aud` claim against, so no host has to register a
|
|
2421
2738
|
// resource API for this by hand.
|
|
2422
|
-
const issuer =
|
|
2739
|
+
const issuer = issuerUrl;
|
|
2423
2740
|
const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
|
|
2424
2741
|
const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
|
|
2425
2742
|
const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_MAIL_ZITADEL_PROJECT_NAME);
|
|
@@ -2445,7 +2762,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2445
2762
|
await ensureMailReady();
|
|
2446
2763
|
const recoveryEnv = (await deps.coolify.listEnvs(appId)).find((e) => e.key === 'STALWART_RECOVERY_ADMIN' && !e.is_preview);
|
|
2447
2764
|
if (!recoveryEnv) {
|
|
2448
|
-
throw new Error(
|
|
2765
|
+
throw new Error(`${label}: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to re-apply the OIDC audience`);
|
|
2449
2766
|
}
|
|
2450
2767
|
const password = recoveryEnv.value.slice(`${STALWART_RECOVERY_USER}:`.length);
|
|
2451
2768
|
try {
|
|
@@ -2453,7 +2770,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2453
2770
|
url: `https://${hostname}`,
|
|
2454
2771
|
user: STALWART_RECOVERY_USER,
|
|
2455
2772
|
password,
|
|
2456
|
-
apex
|
|
2773
|
+
apex,
|
|
2457
2774
|
});
|
|
2458
2775
|
await applyMailSettings(admin, {
|
|
2459
2776
|
coolify: deps.coolify,
|
|
@@ -2462,8 +2779,8 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2462
2779
|
sleep,
|
|
2463
2780
|
hostname,
|
|
2464
2781
|
settings: {
|
|
2465
|
-
mailHost,
|
|
2466
|
-
apex
|
|
2782
|
+
mailHost: hostname,
|
|
2783
|
+
apex,
|
|
2467
2784
|
oidc: { issuerUrl: issuer, audience: project.id },
|
|
2468
2785
|
},
|
|
2469
2786
|
});
|
|
@@ -2481,12 +2798,12 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2481
2798
|
if (!recorded[PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY]) {
|
|
2482
2799
|
const lost = recorded[PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY];
|
|
2483
2800
|
if (lost) {
|
|
2484
|
-
throw new Error(
|
|
2801
|
+
throw new Error(`${label}: an admin API key ("${lost}") was minted but never delivered; revoke it in Stalwart and remove the recorded key ${PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
2485
2802
|
}
|
|
2486
2803
|
await ensureMailReady();
|
|
2487
2804
|
const recoveryEnv = (await deps.coolify.listEnvs(appId)).find((e) => e.key === 'STALWART_RECOVERY_ADMIN' && !e.is_preview);
|
|
2488
2805
|
if (!recoveryEnv) {
|
|
2489
|
-
throw new Error(
|
|
2806
|
+
throw new Error(`${label}: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to mint the admin key`);
|
|
2490
2807
|
}
|
|
2491
2808
|
const password = recoveryEnv.value.slice(`${STALWART_RECOVERY_USER}:`.length);
|
|
2492
2809
|
try {
|
|
@@ -2494,7 +2811,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2494
2811
|
url: `https://${hostname}`,
|
|
2495
2812
|
user: STALWART_RECOVERY_USER,
|
|
2496
2813
|
password,
|
|
2497
|
-
apex
|
|
2814
|
+
apex,
|
|
2498
2815
|
});
|
|
2499
2816
|
await applyMailSettings(admin, {
|
|
2500
2817
|
coolify: deps.coolify,
|
|
@@ -2503,8 +2820,8 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2503
2820
|
sleep,
|
|
2504
2821
|
hostname,
|
|
2505
2822
|
settings: {
|
|
2506
|
-
mailHost,
|
|
2507
|
-
apex
|
|
2823
|
+
mailHost: hostname,
|
|
2824
|
+
apex,
|
|
2508
2825
|
oidc: { issuerUrl: issuer, audience: project.id },
|
|
2509
2826
|
},
|
|
2510
2827
|
});
|
|
@@ -2512,17 +2829,17 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2512
2829
|
// Recorded and persisted before the secret leaves this function: if
|
|
2513
2830
|
// delivery fails for good, the next plan reports the lost key rather
|
|
2514
2831
|
// than skipping or minting again.
|
|
2515
|
-
await record(PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY,
|
|
2516
|
-
const secret = await admin.createApiKey(
|
|
2832
|
+
await record(PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, keyDescription);
|
|
2833
|
+
const secret = await admin.createApiKey(keyDescription, 'inherit');
|
|
2517
2834
|
const attempts = runtime.deliverAttempts ?? 3;
|
|
2518
2835
|
for (let attempt = 1;; attempt += 1) {
|
|
2519
2836
|
try {
|
|
2520
|
-
await deps.deliverSecret(
|
|
2837
|
+
await deps.deliverSecret(target.refs.mailAdminKey, secret);
|
|
2521
2838
|
break;
|
|
2522
2839
|
}
|
|
2523
2840
|
catch {
|
|
2524
2841
|
if (attempt >= attempts) {
|
|
2525
|
-
throw new Error(
|
|
2842
|
+
throw new Error(`${label}: the admin API key ("${keyDescription}") was minted but could not be delivered after ${attempts} tries`);
|
|
2526
2843
|
}
|
|
2527
2844
|
await sleep(1000 * attempt);
|
|
2528
2845
|
}
|
|
@@ -2539,6 +2856,29 @@ async function provisionPlatformMail(context, deps) {
|
|
|
2539
2856
|
await record(PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, imageTag);
|
|
2540
2857
|
return { resourceId: appId };
|
|
2541
2858
|
}
|
|
2859
|
+
/** The platform's mail project: the shared `platform` project, its id recorded by the mail step under its own key. */
|
|
2860
|
+
const platformMailProject = {
|
|
2861
|
+
async ensure(coolify) {
|
|
2862
|
+
const found = await coolify.findProjectByName(PLATFORM_MAIL_PROJECT);
|
|
2863
|
+
return found ? found.uuid : (await coolify.createProject(PLATFORM_MAIL_PROJECT)).uuid;
|
|
2864
|
+
},
|
|
2865
|
+
};
|
|
2866
|
+
/** The platform's mail server: {@link provisionMailServer} with {@link platformTarget} and the platform's own inputs. */
|
|
2867
|
+
async function provisionPlatformMail(context, deps) {
|
|
2868
|
+
const { platform } = context;
|
|
2869
|
+
const scope = scopeFromPlatform(context);
|
|
2870
|
+
return provisionMailServer(scope, deps, platformTarget(), {
|
|
2871
|
+
hostname: platform.mail.hostname,
|
|
2872
|
+
apex: platform.apex,
|
|
2873
|
+
mailHost: platformMailMxHost(platform.apex),
|
|
2874
|
+
imageTag: platform.mail.gitRef,
|
|
2875
|
+
hostPorts: platformMailHostPorts(platform),
|
|
2876
|
+
issuerUrl: `https://${platform.auth.hostname}`,
|
|
2877
|
+
mxRecord: { serverIpv4: platform.serverIpv4 },
|
|
2878
|
+
project: platformMailProject,
|
|
2879
|
+
network: platformNetwork(deps, scope, 'platform:mail'),
|
|
2880
|
+
});
|
|
2881
|
+
}
|
|
2542
2882
|
/**
|
|
2543
2883
|
* Why the Stalwart image tag cannot be used, or `null`. #248: a thin wrapper
|
|
2544
2884
|
* over the estate-wide {@link floatingTagBlocked} -- the mail step's own
|
|
@@ -2604,6 +2944,90 @@ async function mailBlockedReason(platform, mail, recordedIds) {
|
|
|
2604
2944
|
return "the platform's mail server is blocked, not planned: no usable Coolify server could be resolved for it";
|
|
2605
2945
|
}
|
|
2606
2946
|
}
|
|
2947
|
+
async function provisionPlatformOikosIngress(context, deps) {
|
|
2948
|
+
const recorded = context.recordedIds();
|
|
2949
|
+
const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
|
|
2950
|
+
if (!edgeUrl)
|
|
2951
|
+
throw new Error(OIKOS_INGRESS_BLOCKED.edgeNotApplied);
|
|
2952
|
+
return provisionOikosIngress(context, {
|
|
2953
|
+
coolify: deps.coolify,
|
|
2954
|
+
tunnels: await platformTunnels(deps, recorded, 'platform:oikos-ingress'),
|
|
2955
|
+
dns: await platformDns(deps, recorded, 'platform:oikos-ingress'),
|
|
2956
|
+
ratelimits: await deps.ratelimits(edgeUrl),
|
|
2957
|
+
zones: await deps.zones(edgeUrl),
|
|
2958
|
+
resolveServer: () => resolvePlatformServer(deps.coolify),
|
|
2959
|
+
callTimeoutMs: deps.callTimeoutMs,
|
|
2960
|
+
});
|
|
2961
|
+
}
|
|
2962
|
+
/** Why the shared mail relay cannot be built from its pinned base image, or `null`. */
|
|
2963
|
+
function mailRelayBlockedReason() {
|
|
2964
|
+
const tagBlocked = floatingTagBlocked('mail relay base', VERSIONS.alpine, '3.22.1');
|
|
2965
|
+
return tagBlocked ? `the platform's mail relay is blocked, not planned: ${tagBlocked}` : null;
|
|
2966
|
+
}
|
|
2967
|
+
/** Why `mx.<apex>` has no address to point at, or `null`. Skipped once the relay is applied, as the mail step's own check was. */
|
|
2968
|
+
async function relayServerBlockedReason(platform, mail, recordedIds) {
|
|
2969
|
+
if (platform.serverIpv4 || recordedIds[`coolify:app:${PLATFORM_MAIL_RELAY_KEY}`])
|
|
2970
|
+
return null;
|
|
2971
|
+
try {
|
|
2972
|
+
const server = await resolveMailServer(mail.coolify, recordedIds[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
|
|
2973
|
+
return mailServerIpBlocked(server.ip, platform.serverIpv4);
|
|
2974
|
+
}
|
|
2975
|
+
catch {
|
|
2976
|
+
return "the platform's mail relay is blocked, not planned: no usable Coolify server could be resolved for it";
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
/**
|
|
2980
|
+
* Runs the shared mail relay step in the platform mail project and on the
|
|
2981
|
+
* platform server (recorded by the mail step when one ran, else found or made).
|
|
2982
|
+
*/
|
|
2983
|
+
async function provisionPlatformMailRelay(action, context, deps) {
|
|
2984
|
+
const recorded = context.recordedIds();
|
|
2985
|
+
// No platform mail step runs any more (the owner company runs its own mail), so
|
|
2986
|
+
// the relay finds or makes its project and server itself, as the mail step did.
|
|
2987
|
+
let projectUuid = recorded[PLATFORM_MAIL_PROJECT_SCOPE_KEY];
|
|
2988
|
+
if (!projectUuid) {
|
|
2989
|
+
const found = await deps.coolify.findProjectByName(PLATFORM_MAIL_PROJECT);
|
|
2990
|
+
projectUuid = found
|
|
2991
|
+
? found.uuid
|
|
2992
|
+
: (await deps.coolify.createProject(PLATFORM_MAIL_PROJECT)).uuid;
|
|
2993
|
+
context.recordScopeId(PLATFORM_MAIL_PROJECT_SCOPE_KEY, projectUuid);
|
|
2994
|
+
await context.persist?.();
|
|
2995
|
+
}
|
|
2996
|
+
let serverUuid = recorded[PLATFORM_MAIL_SERVER_SCOPE_KEY];
|
|
2997
|
+
if (!serverUuid) {
|
|
2998
|
+
serverUuid = (await resolveMailServer(deps.coolify, undefined)).uuid;
|
|
2999
|
+
context.recordScopeId(PLATFORM_MAIL_SERVER_SCOPE_KEY, serverUuid);
|
|
3000
|
+
await context.persist?.();
|
|
3001
|
+
}
|
|
3002
|
+
// The relay answers port 25 on `mx.<apex>`, and no mail step writes that name any more: the relay does,
|
|
3003
|
+
// first, so a failed write leaves the step planned (the routes are recorded last).
|
|
3004
|
+
const mxHost = platformMailMxHost(context.platform.apex);
|
|
3005
|
+
const server = await resolveMailServer(deps.coolify, serverUuid);
|
|
3006
|
+
const serverIpBlocked = mailServerIpBlocked(server.ip, context.platform.serverIpv4);
|
|
3007
|
+
if (serverIpBlocked)
|
|
3008
|
+
throw new Error(serverIpBlocked);
|
|
3009
|
+
const serverIp = (context.platform.serverIpv4 ?? server.ip);
|
|
3010
|
+
const network = platformNetwork(deps, scopeFromPlatform(context), 'platform:mail-relay');
|
|
3011
|
+
const zoneId = await network.zoneId(context.platform.apex);
|
|
3012
|
+
if (!zoneId) {
|
|
3013
|
+
throw new Error(`platform:mail-relay: no Cloudflare zone for ${context.platform.apex} to put ${mxHost} in`);
|
|
3014
|
+
}
|
|
3015
|
+
const dns = await network.dns();
|
|
3016
|
+
const [existingMx] = await dns.findDnsRecord(context.platform.apex, mxHost, 'A');
|
|
3017
|
+
const mxRecord = { type: 'A', name: mxHost, content: serverIp, proxied: false };
|
|
3018
|
+
if (!existingMx) {
|
|
3019
|
+
await dns.createDnsRecord(context.platform.apex, mxRecord);
|
|
3020
|
+
}
|
|
3021
|
+
else if (existingMx.content !== serverIp) {
|
|
3022
|
+
await dns.updateDnsRecord(context.platform.apex, existingMx.id, mxRecord);
|
|
3023
|
+
}
|
|
3024
|
+
return provisionMailRelay(action, context, deps, {
|
|
3025
|
+
projectUuid,
|
|
3026
|
+
serverUuid,
|
|
3027
|
+
hostname: mxHost,
|
|
3028
|
+
appScopeKey: PLATFORM_MAIL_RELAY_APP_SCOPE_KEY,
|
|
3029
|
+
});
|
|
3030
|
+
}
|
|
2607
3031
|
/**
|
|
2608
3032
|
* #211: the broker cannot start without `ZITADEL_SERVICE_USER_TOKEN`/`_FILE`,
|
|
2609
3033
|
* `LETTERMINT_API_TOKEN` and `AUTH_APPS`. `provisionPlatformAuth` already
|
|
@@ -2834,9 +3258,75 @@ const PLATFORM_ARCHON_PROJECT_SCOPE_KEY = 'coolify_project_platform_archon';
|
|
|
2834
3258
|
const PLATFORM_ARCHON_DB_SCOPE_KEY = 'coolify_platform_archon_database';
|
|
2835
3259
|
const PLATFORM_ARCHON_TUNNEL_SCOPE_KEY = 'cloudflare_platform_archon_tunnel';
|
|
2836
3260
|
const PLATFORM_ARCHON_ACCESS_SCOPE_KEY = 'cloudflare_platform_archon_access_app';
|
|
2837
|
-
|
|
3261
|
+
/**
|
|
3262
|
+
* Archon paths a machine calls and Archon authenticates itself (its `proxy.ts`:
|
|
3263
|
+
* `PUBLIC_EXACT` and `ORG_CHANGES_PATH_RE`). Each gets its own Access app with
|
|
3264
|
+
* one bypass policy, because the hostname app would otherwise ask a machine for
|
|
3265
|
+
* an emailed code. `/api/uptime/targets` and `/api/uptime/results` are left out
|
|
3266
|
+
* on purpose: they pass the hostname app through the uptime service-token
|
|
3267
|
+
* policy, and a bypass would make that token meaningless. `*` is one path
|
|
3268
|
+
* segment (the organisation id).
|
|
3269
|
+
*/
|
|
3270
|
+
export const PLATFORM_ARCHON_PUBLIC_PATHS = [
|
|
3271
|
+
{ name: 'prices', path: '/api/prices' },
|
|
3272
|
+
{ name: 'vipps-webhook', path: '/vipps/webhook' },
|
|
3273
|
+
{ name: 'vipps-agreement', path: '/vipps/agreement' },
|
|
3274
|
+
{ name: 'usage-tick', path: '/api/usage/tick' },
|
|
3275
|
+
{ name: 'reporting-collect', path: '/api/reporting/collect' },
|
|
3276
|
+
{ name: 'org-changes', path: '/api/orgs/*/changes' },
|
|
3277
|
+
];
|
|
3278
|
+
const PLATFORM_ARCHON_PUBLIC_POLICY_NAME = 'foundry: public path';
|
|
3279
|
+
const archonPublicAppName = (name) => `${PLATFORM_ARCHON_ACCESS_APP_NAME}:public:${name}`;
|
|
3280
|
+
/** The scope key one bypass app's id is recorded under. */
|
|
3281
|
+
export const archonPublicAccessScopeKey = (name) => `cloudflare_platform_archon_access_public_${name.replace(/-/g, '_')}`;
|
|
3282
|
+
/** Whether a policy list is exactly the one bypass-for-everyone policy. */
|
|
3283
|
+
function onlyBypassEveryone(policies) {
|
|
3284
|
+
const only = policies[0];
|
|
3285
|
+
return (policies.length === 1 &&
|
|
3286
|
+
only?.name === PLATFORM_ARCHON_PUBLIC_POLICY_NAME &&
|
|
3287
|
+
only.decision === 'bypass' &&
|
|
3288
|
+
Array.isArray(only.include) &&
|
|
3289
|
+
only.include.length === 1 &&
|
|
3290
|
+
JSON.stringify(only.include[0]) === '{"everyone":{}}');
|
|
3291
|
+
}
|
|
3292
|
+
/**
|
|
3293
|
+
* One Access app per public path, each with the one policy bypass/everyone and
|
|
3294
|
+
* no identity provider. Adopts only an app of its own name on the exact domain,
|
|
3295
|
+
* refuses another name there, and rebuilds one whose policies are not exactly
|
|
3296
|
+
* the bypass. A rebuild needs no closed gate: the hostname app covers the path
|
|
3297
|
+
* meanwhile, which only asks for more.
|
|
3298
|
+
*/
|
|
3299
|
+
async function ensureArchonPublicAccess(cf, hostname, recorded, record) {
|
|
3300
|
+
for (const { name, path } of PLATFORM_ARCHON_PUBLIC_PATHS) {
|
|
3301
|
+
const domain = `${hostname}${path}`;
|
|
3302
|
+
const appName = archonPublicAppName(name);
|
|
3303
|
+
const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
|
|
3304
|
+
const foreign = onDomain.find((a) => a.name !== appName);
|
|
3305
|
+
if (foreign) {
|
|
3306
|
+
throw new Error(`platform:archon: Access app '${foreign.name}' already holds ${domain}; foundry does not adopt it`);
|
|
3307
|
+
}
|
|
3308
|
+
let id = onDomain[0]?.id;
|
|
3309
|
+
if (id && !onlyBypassEveryone(await cf.listAccessPolicies(id))) {
|
|
3310
|
+
await cf.deleteAccessApp(id);
|
|
3311
|
+
id = undefined;
|
|
3312
|
+
}
|
|
3313
|
+
if (!id) {
|
|
3314
|
+
id = (await cf.createAccessApp({ name: appName, domain, type: 'self_hosted' })).id;
|
|
3315
|
+
await cf.createAccessPolicy(id, {
|
|
3316
|
+
name: PLATFORM_ARCHON_PUBLIC_POLICY_NAME,
|
|
3317
|
+
decision: 'bypass',
|
|
3318
|
+
include: [{ everyone: {} }],
|
|
3319
|
+
});
|
|
3320
|
+
}
|
|
3321
|
+
const key = archonPublicAccessScopeKey(name);
|
|
3322
|
+
if (recorded[key] !== id)
|
|
3323
|
+
await record(key, id);
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
2838
3326
|
/** The hostname the sign-in client's redirect URIs were made for; a change makes a new client. */
|
|
2839
3327
|
const PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY = 'zitadel_platform_archon_client_hostname';
|
|
3328
|
+
/** The owner's ZITADEL organisation the sign-in client was made in; a change makes a new client. */
|
|
3329
|
+
const PLATFORM_ARCHON_CLIENT_ORG_SCOPE_KEY = 'zitadel_platform_archon_client_org';
|
|
2840
3330
|
/** Archon's OIDC client id: public, not a secret, so a plain scope id. */
|
|
2841
3331
|
export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_client_id';
|
|
2842
3332
|
/**
|
|
@@ -2847,13 +3337,16 @@ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_clie
|
|
|
2847
3337
|
* names the ids only, never the cause.
|
|
2848
3338
|
*/
|
|
2849
3339
|
async function switchOnArchonIdTokenUserinfo(context, deps) {
|
|
2850
|
-
const { platform } = context;
|
|
2851
3340
|
const recorded = context.recordedIds();
|
|
2852
|
-
|
|
3341
|
+
// The client lives in the owner company's ZITADEL; without its sign-in there is nothing to switch on.
|
|
3342
|
+
const ownerSignIn = context.owner?.signIn ?? null;
|
|
3343
|
+
const zitadel = ownerSignIn
|
|
3344
|
+
? deps.zitadelForInstance?.(ownerSignIn.issuerUrl, ownerSignIn.bearerRef)
|
|
3345
|
+
: undefined;
|
|
2853
3346
|
// The planner plans nothing without the method; a stale plan does nothing.
|
|
2854
|
-
if (!zitadel
|
|
3347
|
+
if (!ownerSignIn || !zitadel?.updateOidcAppIdTokenUserinfoAssertion)
|
|
2855
3348
|
return {};
|
|
2856
|
-
const orgId =
|
|
3349
|
+
const orgId = ownerSignIn.organisationId;
|
|
2857
3350
|
const projectId = recorded[PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY];
|
|
2858
3351
|
const appId = recorded[PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY];
|
|
2859
3352
|
if (!orgId || !projectId || !appId) {
|
|
@@ -2979,7 +3472,25 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
2979
3472
|
const hostname = platform.archon.hostname;
|
|
2980
3473
|
const gitRef = platform.archon.gitRef;
|
|
2981
3474
|
const recorded = context.recordedIds();
|
|
2982
|
-
|
|
3475
|
+
// Operators sign in through the owner company's own sign-in, never a platform one.
|
|
3476
|
+
const ownerSignIn = context.owner?.signIn ?? null;
|
|
3477
|
+
const ownerFiles = context.owner?.files ?? null;
|
|
3478
|
+
if (!ownerSignIn) {
|
|
3479
|
+
throw new Error("platform:archon: the owner company's sign-in has not been applied yet; Archon's sign-in client lives in it");
|
|
3480
|
+
}
|
|
3481
|
+
const issuer = ownerSignIn.issuerUrl;
|
|
3482
|
+
// Archon signs people in from its own organisation; the company's is only handed on as CI_ORGANIZATION_ID.
|
|
3483
|
+
const authOrgId = recorded[PLATFORM_ARCHON_ORG_SCOPE_KEY];
|
|
3484
|
+
const ciOrgId = ownerSignIn.organisationId;
|
|
3485
|
+
if (!authOrgId) {
|
|
3486
|
+
throw new Error("platform:archon: Archon's own sign-in organisation is not recorded yet; zitadel:archon-org runs first");
|
|
3487
|
+
}
|
|
3488
|
+
// Archon accepts sign-ins from that organisation only once it forces a second factor and keeps registration closed.
|
|
3489
|
+
if (recorded[PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY] !== authOrgId ||
|
|
3490
|
+
recorded[PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY] !== authOrgId) {
|
|
3491
|
+
throw new Error("platform:archon: Archon's sign-in organisation does not force a second factor and keep registration closed yet; zitadel:archon-org runs first");
|
|
3492
|
+
}
|
|
3493
|
+
const firstOwnerLink = archonFirstOwnerLink(recorded, ownerSignIn);
|
|
2983
3494
|
const appliedGitRef = recorded[PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY];
|
|
2984
3495
|
const runtime = deps.runtime ?? {};
|
|
2985
3496
|
const sleep = runtime.sleep ?? defaultSleep;
|
|
@@ -2987,13 +3498,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
2987
3498
|
if (deps.accessEmails.length === 0) {
|
|
2988
3499
|
throw new Error('platform:archon: no Access emails given; refusing to put Archon behind an Access app nobody can pass');
|
|
2989
3500
|
}
|
|
2990
|
-
|
|
2991
|
-
// which `platform:auth` builds. The planner orders that step first and
|
|
2992
|
-
// blocks this one while auth is off; this is the same rule for a caller
|
|
2993
|
-
// that reaches the step anyway.
|
|
2994
|
-
if (!platform.auth.enabled || !recorded[`coolify:app:${PLATFORM_AUTH_KEY}`]) {
|
|
2995
|
-
throw new Error("platform:archon: the platform's own sign-in (platform:auth) has not been applied yet; Archon's sign-in client lives in it");
|
|
2996
|
-
}
|
|
3501
|
+
const accessEmails = archonAccessEmails(deps.accessEmails, ownerCompany);
|
|
2997
3502
|
const record = async (key, id) => {
|
|
2998
3503
|
context.recordScopeId(key, id);
|
|
2999
3504
|
await context.persist?.();
|
|
@@ -3001,7 +3506,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3001
3506
|
// Every service the platform declares must have delivered what Archon reads.
|
|
3002
3507
|
// The planner blocks first; this is the same rule for a caller that reaches
|
|
3003
3508
|
// the step anyway, and it is where the values are read for the env below.
|
|
3004
|
-
const inputs = await collectArchonServiceInputs(platform, deps, recorded);
|
|
3509
|
+
const inputs = await collectArchonServiceInputs(platform, deps, recorded, ownerFiles);
|
|
3005
3510
|
if ('blocked' in inputs)
|
|
3006
3511
|
throw new Error(`platform:archon: ${inputs.blocked}`);
|
|
3007
3512
|
// #213: Archon's own onboarding reads these back as held keys before it
|
|
@@ -3106,11 +3611,25 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3106
3611
|
...healthCheckSettings(ARCHON_HEALTH_CHECK_PATH, Number(ARCHON_PORT), ARCHON_HEALTH_CHECK_START_PERIOD_SECONDS),
|
|
3107
3612
|
git_branch: gitRef,
|
|
3108
3613
|
});
|
|
3109
|
-
// ---- sign-in client in the
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
if (
|
|
3113
|
-
|
|
3614
|
+
// ---- sign-in client in the owner company's own ZITADEL --------------------
|
|
3615
|
+
// Only the owner's own instance, by its issuer and bearer reference: the
|
|
3616
|
+
// platform's `deps.zitadel` is never used, so a missing factory is an error.
|
|
3617
|
+
if (!deps.zitadelForInstance) {
|
|
3618
|
+
throw new Error("platform:archon: the host built the platform executor without zitadelForInstance, so it cannot reach the owner company's ZITADEL");
|
|
3619
|
+
}
|
|
3620
|
+
const zitadel = deps.zitadelForInstance(issuer, ownerSignIn.bearerRef);
|
|
3621
|
+
const org = { id: authOrgId };
|
|
3622
|
+
// The client made in another organisation (the company's, before Archon had its own) is deleted
|
|
3623
|
+
// once the new one serves: its ids are kept here before the recorded ones are overwritten.
|
|
3624
|
+
const oldClientOrg = recorded[PLATFORM_ARCHON_CLIENT_ORG_SCOPE_KEY];
|
|
3625
|
+
const oldProject = recorded[PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY];
|
|
3626
|
+
const oldApp = recorded[PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY];
|
|
3627
|
+
if (oldClientOrg &&
|
|
3628
|
+
oldClientOrg !== authOrgId &&
|
|
3629
|
+
oldProject &&
|
|
3630
|
+
oldApp &&
|
|
3631
|
+
!recorded[PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY]) {
|
|
3632
|
+
await record(PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, `${oldClientOrg}|${oldProject}|${oldApp}`);
|
|
3114
3633
|
}
|
|
3115
3634
|
const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_ARCHON_ZITADEL_PROJECT);
|
|
3116
3635
|
if (recorded[PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY] !== project.id) {
|
|
@@ -3119,7 +3638,8 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3119
3638
|
// The client's redirect URIs name the hostname, and no call updates them, so
|
|
3120
3639
|
// a client made for another hostname is deleted and made again. Its id is
|
|
3121
3640
|
// public and there is no secret to lose.
|
|
3122
|
-
let clientId = recorded[PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY] === hostname
|
|
3641
|
+
let clientId = recorded[PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY] === hostname &&
|
|
3642
|
+
recorded[PLATFORM_ARCHON_CLIENT_ORG_SCOPE_KEY] === org.id
|
|
3123
3643
|
? recorded[PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY]
|
|
3124
3644
|
: undefined;
|
|
3125
3645
|
if (!clientId) {
|
|
@@ -3151,18 +3671,18 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3151
3671
|
// Made with the setting on (providers 0.9.0): nothing to repair on this id.
|
|
3152
3672
|
context.recordScopeId(PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, created.appId);
|
|
3153
3673
|
context.recordScopeId(PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY, hostname);
|
|
3674
|
+
context.recordScopeId(PLATFORM_ARCHON_CLIENT_ORG_SCOPE_KEY, org.id);
|
|
3154
3675
|
await record(PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY, created.clientId);
|
|
3155
3676
|
clientId = created.clientId;
|
|
3156
3677
|
}
|
|
3157
3678
|
// ---- env ------------------------------------------------------------------
|
|
3158
|
-
// People sign in from the owner company's sign-in org. The project, client
|
|
3159
|
-
// and Access app above stay in the `foundry` org, which holds no people.
|
|
3160
|
-
const authOrgId = archonAuthOrganizationId(context.recordedIds()) ?? org.id;
|
|
3161
3679
|
const envs = [
|
|
3162
3680
|
{ key: 'APP_URL', value: `https://${hostname}` },
|
|
3163
3681
|
{ key: 'AUTH_ISSUER', value: issuer },
|
|
3164
3682
|
{ key: 'AUTH_ORGANIZATION_ID', value: authOrgId },
|
|
3683
|
+
{ key: 'CI_ORGANIZATION_ID', value: ciOrgId },
|
|
3165
3684
|
{ key: 'AUTH_CLIENT_ID', value: clientId },
|
|
3685
|
+
...(firstOwnerLink ? [{ key: 'ARCHON_FIRST_OWNER_LINK', value: firstOwnerLink }] : []),
|
|
3166
3686
|
];
|
|
3167
3687
|
// #265: an owner URL or reporting site already on the app is kept as it is.
|
|
3168
3688
|
// The owner URL must name the adopted database's host, or the app's two
|
|
@@ -3235,7 +3755,12 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3235
3755
|
// empty, and `readBrokerApps`/`validateBrokerAppEntry` would refuse it the
|
|
3236
3756
|
// moment this step tried to register it. See `ensureGeneratedEnv`'s own
|
|
3237
3757
|
// doc comment for why no other call site gets this argument.
|
|
3238
|
-
|
|
3758
|
+
// A key minted for another organisation (the company's) is never kept: Archon's new
|
|
3759
|
+
// entry gets a fresh one, so the key the old entry held stops being Archon's.
|
|
3760
|
+
const freshKey = recorded[PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY] !== authOrgId;
|
|
3761
|
+
const appKey = freshKey
|
|
3762
|
+
? randomBytes(32).toString('base64url')
|
|
3763
|
+
: ((await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_APP_KEY', () => randomBytes(32).toString('base64url'), 32)) ?? (await currentEnvValue(deps.coolify, appId, 'AUTH_APP_KEY')));
|
|
3239
3764
|
if (!appKey) {
|
|
3240
3765
|
throw new Error(`platform:archon: app ${appId} lost its AUTH_APP_KEY between two reads`);
|
|
3241
3766
|
}
|
|
@@ -3244,7 +3769,8 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3244
3769
|
// rule above), and hashed as the live value so it never reads as a changed
|
|
3245
3770
|
// env.
|
|
3246
3771
|
const keepLiveFilesEnv = new Set();
|
|
3247
|
-
|
|
3772
|
+
// The owner's own files host, once applied, replaces whatever is live.
|
|
3773
|
+
for (const key of ownerFiles ? [] : ['FILES_URL', 'FILES_AUDIENCE']) {
|
|
3248
3774
|
const live = await currentEnvValue(deps.coolify, appId, key);
|
|
3249
3775
|
if (live) {
|
|
3250
3776
|
keepLiveFilesEnv.add(key);
|
|
@@ -3302,6 +3828,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3302
3828
|
throw error;
|
|
3303
3829
|
throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), carried));
|
|
3304
3830
|
}
|
|
3831
|
+
// The fresh key is in Archon's env now: from here a repeat keeps it. Recorded before the broker write,
|
|
3832
|
+
// so a failure there is repaired by writing the same key, not by minting another.
|
|
3833
|
+
if (freshKey)
|
|
3834
|
+
await record(PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY, authOrgId);
|
|
3305
3835
|
// #436 round 2: `bulkUpsertEnvs` only adds or updates, so the
|
|
3306
3836
|
// `FILES_MANAGEMENT_KEY` this step used to write before the fix above
|
|
3307
3837
|
// would otherwise survive on an Archon already deployed with it --
|
|
@@ -3325,7 +3855,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3325
3855
|
const staleUptime = !platform.uptime.enabled && stale.key === 'UPTIME_TOKEN';
|
|
3326
3856
|
// People now come from the owner company's store, so a first operator set by an older apply goes.
|
|
3327
3857
|
const staleOperator = stale.key === 'AUTHZ_BOOTSTRAP_OPERATOR';
|
|
3328
|
-
|
|
3858
|
+
// The link is written only while both ids are known; one that is no longer wanted goes.
|
|
3859
|
+
const staleLink = !firstOwnerLink && stale.key === 'ARCHON_FIRST_OWNER_LINK';
|
|
3860
|
+
if ((stale.key === 'FILES_MANAGEMENT_KEY' || staleUptime || staleOperator || staleLink) &&
|
|
3861
|
+
stale.uuid) {
|
|
3329
3862
|
await deps.coolify.deleteEnv(appId, stale.uuid);
|
|
3330
3863
|
staleKeyDeleted = true;
|
|
3331
3864
|
}
|
|
@@ -3338,10 +3871,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3338
3871
|
// returns), and this step is unreachable until that resourceId is recorded
|
|
3339
3872
|
// (the guard at the top of this function) -- so a missing id here means a
|
|
3340
3873
|
// caller reached this step some other way.
|
|
3341
|
-
const brokerAppId =
|
|
3342
|
-
if (!brokerAppId) {
|
|
3343
|
-
throw new Error('platform:archon: platform:auth has not recorded its broker app id yet; apply platform:auth again');
|
|
3344
|
-
}
|
|
3874
|
+
const brokerAppId = ownerSignIn.brokerAppId;
|
|
3345
3875
|
const brokerApps = await readBrokerApps(deps.coolify, brokerAppId);
|
|
3346
3876
|
// #211 round 2: merged into the matching entry, not replacing it -- the
|
|
3347
3877
|
// broker's own `App` (`broker/src/apps.ts`) carries fields this step never
|
|
@@ -3355,14 +3885,22 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3355
3885
|
// first -- a previous bug, or a hand-edited AUTH_APPS, can leave more than
|
|
3356
3886
|
// one. The first is kept (and updated below); the rest are dropped, so no
|
|
3357
3887
|
// duplicate carries a stale key a broker client could still present.
|
|
3888
|
+
// No other entry may sign in Archon's organisation: its key would reach Archon's people.
|
|
3889
|
+
const conflicts = archonOrgConflicts(brokerApps, authOrgId);
|
|
3890
|
+
if (conflicts.length > 0) {
|
|
3891
|
+
throw new Error(`platform:archon: broker entry ${conflicts.map((n) => `'${n}'`).join(', ')} signs in Archon's own organisation; no other entry may`);
|
|
3892
|
+
}
|
|
3358
3893
|
const archonMatchIndices = brokerApps
|
|
3359
|
-
.map((a, i) =>
|
|
3360
|
-
a.name === PLATFORM_ARCHON_ZITADEL_APP
|
|
3361
|
-
? i
|
|
3362
|
-
: -1)
|
|
3894
|
+
.map((a, i) => a.organizationId === authOrgId && a.name === PLATFORM_ARCHON_ZITADEL_APP ? i : -1)
|
|
3363
3895
|
.filter((i) => i >= 0);
|
|
3364
3896
|
const archonEntryIndex = archonMatchIndices[0] ?? -1;
|
|
3365
|
-
|
|
3897
|
+
// Archon's entry in an organisation it used to sign in from (the company's, before Archon had its
|
|
3898
|
+
// own) goes, key and all. An entry of that name in any other organisation is not Archon's to touch.
|
|
3899
|
+
const oldOrgs = archonOldOrgIds(recorded, ciOrgId, authOrgId);
|
|
3900
|
+
const oldEntryIndices = new Set(brokerApps
|
|
3901
|
+
.map((a, i) => a.name === PLATFORM_ARCHON_ZITADEL_APP && oldOrgs.has(a.organizationId) ? i : -1)
|
|
3902
|
+
.filter((i) => i >= 0));
|
|
3903
|
+
const duplicateIndices = new Set([...archonMatchIndices.slice(1), ...oldEntryIndices]);
|
|
3366
3904
|
const archonEntry = {
|
|
3367
3905
|
...(archonEntryIndex >= 0 ? brokerApps[archonEntryIndex] : {}),
|
|
3368
3906
|
organizationId: authOrgId,
|
|
@@ -3377,11 +3915,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3377
3915
|
// the wrong platform or not at all.
|
|
3378
3916
|
from: `Company Included <noreply@${platform.apex}>`,
|
|
3379
3917
|
};
|
|
3380
|
-
const
|
|
3381
|
-
?
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
: [...brokerApps, archonEntry];
|
|
3918
|
+
const keptBrokerApps = brokerApps
|
|
3919
|
+
.map((a, i) => (i === archonEntryIndex ? archonEntry : a))
|
|
3920
|
+
.filter((_, i) => !duplicateIndices.has(i));
|
|
3921
|
+
const desiredBrokerApps = archonEntryIndex >= 0 ? keptBrokerApps : [...keptBrokerApps, archonEntry];
|
|
3385
3922
|
const desiredAuthApps = [{ key: 'AUTH_APPS', value: JSON.stringify(desiredBrokerApps) }];
|
|
3386
3923
|
// Rebuilt and compared every run (not gated on a freshly minted key), so a
|
|
3387
3924
|
// clientId or hostname change reaches the broker too, and so a previous
|
|
@@ -3416,6 +3953,23 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3416
3953
|
await deps.coolify.deploy(brokerAppId);
|
|
3417
3954
|
await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, desiredAuthAppsRaw));
|
|
3418
3955
|
}
|
|
3956
|
+
// ---- the old sign-in client ------------------------------------------------
|
|
3957
|
+
// The broker no longer knows it, and Archon's env names the new one: delete the old client, so
|
|
3958
|
+
// no account of the company's organisation can still sign in to it. A repeat deletes nothing twice.
|
|
3959
|
+
const retiredClient = context.recordedIds()[PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY];
|
|
3960
|
+
if (retiredClient) {
|
|
3961
|
+
const [oldOrgId, oldProjectId, oldAppId] = retiredClient.split('|');
|
|
3962
|
+
if (oldOrgId && oldProjectId && oldAppId) {
|
|
3963
|
+
try {
|
|
3964
|
+
await zitadel.deleteApp(oldOrgId, oldProjectId, oldAppId);
|
|
3965
|
+
}
|
|
3966
|
+
catch (error) {
|
|
3967
|
+
if (!isNotFoundError(error))
|
|
3968
|
+
throw error;
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3971
|
+
await record(PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, '');
|
|
3972
|
+
}
|
|
3419
3973
|
// ---- Cloudflare Access, before the hostname resolves ----------------------
|
|
3420
3974
|
const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:archon');
|
|
3421
3975
|
const access = await platformAccess(deps, context.recordedIds(), 'platform:archon');
|
|
@@ -3423,7 +3977,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3423
3977
|
const uptimeAccessTokenId = platform.uptime.enabled
|
|
3424
3978
|
? recorded[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY]
|
|
3425
3979
|
: undefined;
|
|
3426
|
-
const accessAppId = await ensureArchonAccess(access, hostname,
|
|
3980
|
+
const { id: accessAppId, pinId } = await ensureArchonAccess(access, hostname, accessEmails, uptimeAccessTokenId, async () => {
|
|
3427
3981
|
// The Access app is about to be rebuilt: answer 404 for every hostname
|
|
3428
3982
|
// meanwhile, so it is never served without the gate. The marker is
|
|
3429
3983
|
// persisted first: a failure before the real ingress is written back
|
|
@@ -3439,6 +3993,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3439
3993
|
if (recorded[PLATFORM_ARCHON_ACCESS_SCOPE_KEY] !== accessAppId) {
|
|
3440
3994
|
await record(PLATFORM_ARCHON_ACCESS_SCOPE_KEY, accessAppId);
|
|
3441
3995
|
}
|
|
3996
|
+
if (recorded[PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY] !== pinId) {
|
|
3997
|
+
await record(PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY, pinId);
|
|
3998
|
+
}
|
|
3999
|
+
await ensureArchonPublicAccess(access, hostname, context.recordedIds(), record);
|
|
3442
4000
|
// ---- deploy ---------------------------------------------------------------
|
|
3443
4001
|
// #252: phase 1 of the shared deploy-health helper only -- Coolify's own
|
|
3444
4002
|
// deployment record reaching `'finished'`, which is enough on its own to
|
|
@@ -3491,7 +4049,8 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3491
4049
|
// retry intends is identical to what a successful one intends, so
|
|
3492
4050
|
// leaving the old confirmation in place would read as still correct.
|
|
3493
4051
|
await record(PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, '');
|
|
3494
|
-
await waitForDeploymentFinished(deps.coolify, appId, runtime, 'platform:archon', `archon stays at ${appliedGitRef ?? 'no recorded ref'} on ${recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] ?? 'no recorded hostname'}`);
|
|
4052
|
+
const built = await waitForDeploymentFinished(deps.coolify, appId, runtime, 'platform:archon', `archon stays at ${appliedGitRef ?? 'no recorded ref'} on ${recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] ?? 'no recorded hostname'}`);
|
|
4053
|
+
await recordBuiltCommit(deps.coolify, { appId: appId, deploymentUuid: built.deploymentUuid, name: 'archon' }, record);
|
|
3495
4054
|
}
|
|
3496
4055
|
// ---- tunnel and DNS ---------------------------------------------------------
|
|
3497
4056
|
const existingTunnel = await tunnels.findTunnelByName(PLATFORM_ARCHON_TUNNEL_NAME);
|
|
@@ -3597,6 +4156,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3597
4156
|
// it.
|
|
3598
4157
|
await record(PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, brokerAppId);
|
|
3599
4158
|
await record(PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, authOrgId);
|
|
4159
|
+
await record(PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY, ciOrgId);
|
|
4160
|
+
await record(PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY, firstOwnerLink);
|
|
4161
|
+
await record(PLATFORM_ARCHON_APPLIED_AUTH_HOSTNAME_SCOPE_KEY, ownerSignIn.hostname);
|
|
4162
|
+
await record(PLATFORM_ARCHON_APPLIED_FILES_SCOPE_KEY, archonFilesMarker(ownerFiles));
|
|
3600
4163
|
// The host runs inside this app: every marker is recorded above, so the
|
|
3601
4164
|
// deploy goes last and is not waited for -- the old container stops during
|
|
3602
4165
|
// it, and the response, the lease and the audit row must be done first.
|
|
@@ -3908,6 +4471,9 @@ const PLATFORM_ARCHON_UPTIME_POLICY_NAME = 'foundry: uptime service token';
|
|
|
3908
4471
|
* is added in place, one for another token rebuilds the app.
|
|
3909
4472
|
*/
|
|
3910
4473
|
async function ensureArchonAccess(cf, domain, emails, uptimeTokenId, closeGate) {
|
|
4474
|
+
// The app accepts the one-time PIN and nothing else, so a person is mailed a code
|
|
4475
|
+
// instead of being offered the company's own sign-in provider.
|
|
4476
|
+
const { id: pinId } = await cf.ensureOneTimePinProvider();
|
|
3911
4477
|
const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
|
|
3912
4478
|
const foreign = onDomain.find((a) => a.name !== PLATFORM_ARCHON_ACCESS_APP_NAME);
|
|
3913
4479
|
if (foreign) {
|
|
@@ -3918,6 +4484,8 @@ async function ensureArchonAccess(cf, domain, emails, uptimeTokenId, closeGate)
|
|
|
3918
4484
|
name: PLATFORM_ARCHON_ACCESS_APP_NAME,
|
|
3919
4485
|
domain,
|
|
3920
4486
|
type: 'self_hosted',
|
|
4487
|
+
allowedIdps: [pinId],
|
|
4488
|
+
autoRedirectToIdentity: true,
|
|
3921
4489
|
});
|
|
3922
4490
|
await cf.createAccessPolicy(created.id, archonAccessPolicy(emails));
|
|
3923
4491
|
if (uptimeTokenId) {
|
|
@@ -3927,7 +4495,7 @@ async function ensureArchonAccess(cf, domain, emails, uptimeTokenId, closeGate)
|
|
|
3927
4495
|
};
|
|
3928
4496
|
const existing = onDomain[0];
|
|
3929
4497
|
if (!existing)
|
|
3930
|
-
return (await create()).id;
|
|
4498
|
+
return { id: (await create()).id, pinId };
|
|
3931
4499
|
const policies = await cf.listAccessPolicies(existing.id);
|
|
3932
4500
|
const other = policies.find((p) => p.name !== ACCESS_POLICY_NAME && p.name !== PLATFORM_ARCHON_UPTIME_POLICY_NAME);
|
|
3933
4501
|
if (other) {
|
|
@@ -3936,19 +4504,52 @@ async function ensureArchonAccess(cf, domain, emails, uptimeTokenId, closeGate)
|
|
|
3936
4504
|
const emailPolicies = policies.filter((p) => p.name === ACCESS_POLICY_NAME);
|
|
3937
4505
|
const tokenPolicies = policies.filter((p) => p.name === PLATFORM_ARCHON_UPTIME_POLICY_NAME);
|
|
3938
4506
|
const staleToken = tokenPolicies.some((p) => !uptimePolicyIntact(p, uptimeTokenId));
|
|
3939
|
-
if (emailPolicies.every((p) => sameEmails(p.include, emails)) &&
|
|
4507
|
+
if (emailPolicies.every((p) => sameEmails(p.include, emails)) &&
|
|
4508
|
+
!staleToken &&
|
|
4509
|
+
accessAppPinned(existing, pinId)) {
|
|
3940
4510
|
if (emailPolicies.length === 0) {
|
|
3941
4511
|
await cf.createAccessPolicy(existing.id, archonAccessPolicy(emails));
|
|
3942
4512
|
}
|
|
3943
4513
|
if (uptimeTokenId && tokenPolicies.length === 0) {
|
|
3944
4514
|
await cf.createAccessPolicy(existing.id, archonUptimePolicy(uptimeTokenId));
|
|
3945
4515
|
}
|
|
3946
|
-
return existing.id;
|
|
4516
|
+
return { id: existing.id, pinId };
|
|
3947
4517
|
}
|
|
3948
|
-
// No call updates an app's policy
|
|
4518
|
+
// No call updates an app's policy or its providers (edge keeps an existing app's
|
|
4519
|
+
// `allowedIdps`): rebuild the app, hostname closed meanwhile.
|
|
3949
4520
|
await closeGate();
|
|
3950
4521
|
await cf.deleteAccessApp(existing.id);
|
|
3951
|
-
return (await create()).id;
|
|
4522
|
+
return { id: (await create()).id, pinId };
|
|
4523
|
+
}
|
|
4524
|
+
/** Whether the Access app accepts the one-time PIN provider alone and sends a visitor straight to it. */
|
|
4525
|
+
function accessAppPinned(app, pinId) {
|
|
4526
|
+
return (Array.isArray(app.allowedIdps) &&
|
|
4527
|
+
app.allowedIdps.length === 1 &&
|
|
4528
|
+
app.allowedIdps[0] === pinId &&
|
|
4529
|
+
app.autoRedirectToIdentity === true);
|
|
4530
|
+
}
|
|
4531
|
+
/**
|
|
4532
|
+
* Who Archon's Access app lets through. Each person is let in on the personal
|
|
4533
|
+
* address their invite was mailed to, never on a work address: the work
|
|
4534
|
+
* mailbox is on the platform's own mail, which the platform's own sign-in
|
|
4535
|
+
* reaches. So the owner company's first owner's work addresses leave the host's
|
|
4536
|
+
* list and their personal address joins it; every other address stays as the
|
|
4537
|
+
* host gave it. Case-insensitive, de-duplicated, order kept.
|
|
4538
|
+
*/
|
|
4539
|
+
export function archonAccessEmails(hostEmails, ownerCompany) {
|
|
4540
|
+
const first = ownerCompany?.owners[0];
|
|
4541
|
+
// The first owner's work address: the one their Archon invite is made for.
|
|
4542
|
+
const firstWork = ownerCompany && first ? ownerMailboxes(ownerCompany)[0]?.address : null;
|
|
4543
|
+
const work = new Set(firstWork ? [firstWork.toLowerCase()] : []);
|
|
4544
|
+
const out = [];
|
|
4545
|
+
const seen = new Set();
|
|
4546
|
+
for (const email of [...hostEmails.filter((e) => !work.has(e.toLowerCase())), first?.email]) {
|
|
4547
|
+
if (!email || seen.has(email.toLowerCase()))
|
|
4548
|
+
continue;
|
|
4549
|
+
seen.add(email.toLowerCase());
|
|
4550
|
+
out.push(email);
|
|
4551
|
+
}
|
|
4552
|
+
return out;
|
|
3952
4553
|
}
|
|
3953
4554
|
function archonAccessPolicy(emails) {
|
|
3954
4555
|
return {
|
|
@@ -4006,6 +4607,10 @@ async function archonAccessDrifted(deps, recorded, domain, emails, uptimeTokenId
|
|
|
4006
4607
|
const app = (await cf.listAccessApps()).find((a) => a.domain === domain && a.name === PLATFORM_ARCHON_ACCESS_APP_NAME);
|
|
4007
4608
|
if (!app)
|
|
4008
4609
|
return true;
|
|
4610
|
+
// Read against the provider id the app was last made with: a plan asks edge to make nothing.
|
|
4611
|
+
const pinId = recorded[PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY];
|
|
4612
|
+
if (!pinId || !accessAppPinned(app, pinId))
|
|
4613
|
+
return true;
|
|
4009
4614
|
const policies = await cf.listAccessPolicies(app.id);
|
|
4010
4615
|
const emailPolicies = policies.filter((p) => p.name === ACCESS_POLICY_NAME);
|
|
4011
4616
|
const tokenPolicies = policies.filter((p) => p.name === PLATFORM_ARCHON_UPTIME_POLICY_NAME);
|
|
@@ -4016,7 +4621,15 @@ async function archonAccessDrifted(deps, recorded, domain, emails, uptimeTokenId
|
|
|
4016
4621
|
}
|
|
4017
4622
|
if (!tokenPolicies.every((p) => uptimePolicyIntact(p, uptimeTokenId)))
|
|
4018
4623
|
return true;
|
|
4019
|
-
|
|
4624
|
+
if (uptimeTokenId !== undefined && tokenPolicies.length === 0)
|
|
4625
|
+
return true;
|
|
4626
|
+
const apps = await cf.listAccessApps();
|
|
4627
|
+
for (const { name, path } of PLATFORM_ARCHON_PUBLIC_PATHS) {
|
|
4628
|
+
const bypass = apps.find((a) => a.domain === `${domain}${path}` && a.name === archonPublicAppName(name));
|
|
4629
|
+
if (!bypass || !onlyBypassEveryone(await cf.listAccessPolicies(bypass.id)))
|
|
4630
|
+
return true;
|
|
4631
|
+
}
|
|
4632
|
+
return false;
|
|
4020
4633
|
}
|
|
4021
4634
|
catch {
|
|
4022
4635
|
return false;
|
|
@@ -4036,19 +4649,43 @@ async function archonAccessDrifted(deps, recorded, domain, emails, uptimeTokenId
|
|
|
4036
4649
|
* recorded) is not drift either -- `planArchon`'s other checks already cover
|
|
4037
4650
|
* a step that has never applied.
|
|
4038
4651
|
*/
|
|
4039
|
-
async function archonBrokerRegistrationDrifted(coolify,
|
|
4040
|
-
|
|
4041
|
-
const orgId = archonAuthOrganizationId(recordedIds);
|
|
4042
|
-
if (!brokerAppId || !orgId)
|
|
4652
|
+
async function archonBrokerRegistrationDrifted(coolify, signIn, archonOrgId, recorded = {}) {
|
|
4653
|
+
if (!signIn || !archonOrgId)
|
|
4043
4654
|
return false;
|
|
4044
4655
|
try {
|
|
4045
|
-
const apps = await readBrokerApps(coolify, brokerAppId);
|
|
4046
|
-
|
|
4656
|
+
const apps = await readBrokerApps(coolify, signIn.brokerAppId);
|
|
4657
|
+
const own = apps.some((a) => a.organizationId === archonOrgId && a.name === PLATFORM_ARCHON_ZITADEL_APP);
|
|
4658
|
+
// The company's old entry for Archon must be gone, and no other entry may sign in Archon's org.
|
|
4659
|
+
const oldOrgs = archonOldOrgIds(recorded, signIn.organisationId, archonOrgId);
|
|
4660
|
+
const stale = apps.some((a) => a.name === PLATFORM_ARCHON_ZITADEL_APP && oldOrgs.has(a.organizationId));
|
|
4661
|
+
return !own || stale || archonOrgConflicts(apps, archonOrgId).length > 0;
|
|
4047
4662
|
}
|
|
4048
4663
|
catch {
|
|
4049
4664
|
return false;
|
|
4050
4665
|
}
|
|
4051
4666
|
}
|
|
4667
|
+
/** The organisations Archon signed in from before its own: the company's, and the one its last apply recorded. */
|
|
4668
|
+
function archonOldOrgIds(recorded, ciOrgId, archonOrgId) {
|
|
4669
|
+
const old = new Set([ciOrgId]);
|
|
4670
|
+
const applied = recorded[PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY];
|
|
4671
|
+
if (applied)
|
|
4672
|
+
old.add(applied);
|
|
4673
|
+
if (archonOrgId)
|
|
4674
|
+
old.delete(archonOrgId);
|
|
4675
|
+
return old;
|
|
4676
|
+
}
|
|
4677
|
+
/**
|
|
4678
|
+
* The names of the broker entries that sign in Archon's organisation without
|
|
4679
|
+
* being Archon's own. There must be none: a key of another app would then
|
|
4680
|
+
* reach Archon's people (passkeys, password reset, deactivation).
|
|
4681
|
+
*/
|
|
4682
|
+
export function archonOrgConflicts(entries, archonOrgId) {
|
|
4683
|
+
if (!archonOrgId)
|
|
4684
|
+
return [];
|
|
4685
|
+
return entries
|
|
4686
|
+
.filter((e) => e.organizationId === archonOrgId && e.name !== PLATFORM_ARCHON_ZITADEL_APP)
|
|
4687
|
+
.map((e) => e.name.slice(0, 100));
|
|
4688
|
+
}
|
|
4052
4689
|
/** The decoded password of a connection URL, or `undefined` when it has none or cannot be parsed. */
|
|
4053
4690
|
function passwordOf(url) {
|
|
4054
4691
|
try {
|
|
@@ -4181,17 +4818,31 @@ async function archonAuthzDatabaseDrifted(coolify, appId, owner) {
|
|
|
4181
4818
|
}
|
|
4182
4819
|
}
|
|
4183
4820
|
/**
|
|
4184
|
-
* Whether Archon's live
|
|
4185
|
-
*
|
|
4186
|
-
*
|
|
4187
|
-
*
|
|
4821
|
+
* Whether one of Archon's live sign-in envs is not what it should be:
|
|
4822
|
+
* `AUTH_ORGANIZATION_ID` (Archon's own organisation), `CI_ORGANIZATION_ID` (the
|
|
4823
|
+
* company's) and `ARCHON_FIRST_OWNER_LINK` (absent when no link is wanted).
|
|
4824
|
+
* A name with no expected value is not checked, except the link. Without this
|
|
4825
|
+
* nothing replans Archon when an org changes behind its back. A failed read is
|
|
4826
|
+
* not drift.
|
|
4188
4827
|
*/
|
|
4189
|
-
async function archonAuthOrgDrifted(coolify, appId,
|
|
4190
|
-
|
|
4191
|
-
if (!appId || !expected)
|
|
4828
|
+
async function archonAuthOrgDrifted(coolify, appId, expected) {
|
|
4829
|
+
if (!appId)
|
|
4192
4830
|
return false;
|
|
4193
4831
|
try {
|
|
4194
|
-
|
|
4832
|
+
for (const [key, want] of Object.entries(expected)) {
|
|
4833
|
+
// A value expected as absent (an empty link) must be absent too; the key itself is never named in a value.
|
|
4834
|
+
if (key === 'ARCHON_FIRST_OWNER_LINK') {
|
|
4835
|
+
if (((await currentEnvValue(coolify, appId, key)) || undefined) !== (want || undefined)) {
|
|
4836
|
+
return true;
|
|
4837
|
+
}
|
|
4838
|
+
continue;
|
|
4839
|
+
}
|
|
4840
|
+
if (!want)
|
|
4841
|
+
continue;
|
|
4842
|
+
if ((await currentEnvValue(coolify, appId, key)) !== want)
|
|
4843
|
+
return true;
|
|
4844
|
+
}
|
|
4845
|
+
return false;
|
|
4195
4846
|
}
|
|
4196
4847
|
catch {
|
|
4197
4848
|
return false;
|
|
@@ -4274,13 +4925,15 @@ async function archonRollingUpdateDrifted(coolify, appId) {
|
|
|
4274
4925
|
* held-key ref, never a value. Also read at plan time, so the text must not
|
|
4275
4926
|
* vary between calls.
|
|
4276
4927
|
*/
|
|
4277
|
-
async function collectArchonServiceInputs(platform, deps, recorded) {
|
|
4928
|
+
async function collectArchonServiceInputs(platform, deps, recorded, ownerFiles = null) {
|
|
4278
4929
|
const block = (why) => ({
|
|
4279
4930
|
blocked: `Archon is blocked, not planned: ${why}`,
|
|
4280
4931
|
});
|
|
4281
4932
|
const envs = [];
|
|
4282
4933
|
const heldByRef = new Map();
|
|
4283
|
-
|
|
4934
|
+
// Files is the owner company's own host, not a platform service: its env is
|
|
4935
|
+
// added once that step has applied, and Archon starts without it until then.
|
|
4936
|
+
const services = ['ai', 'integrations', 'jobs'].filter((s) => platform[s].enabled);
|
|
4284
4937
|
if (services.length > 0 && !deps.heldKeys) {
|
|
4285
4938
|
return block(`the host built the platform executor without a held-key reader, and ${services.join(', ')} ${services.length === 1 ? 'is' : 'are'} enabled`);
|
|
4286
4939
|
}
|
|
@@ -4333,31 +4986,7 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
|
|
|
4333
4986
|
}
|
|
4334
4987
|
const held = (ref) => heldByRef.get(ref);
|
|
4335
4988
|
for (const service of services) {
|
|
4336
|
-
if (service === '
|
|
4337
|
-
envs.push(
|
|
4338
|
-
// #501: Files is private and has no DNS, so FILES_URL is the recorded
|
|
4339
|
-
// internal address; FILES_AUDIENCE is the exact HTTPS origin the
|
|
4340
|
-
// signature is made for, equal to Files' own `APP_URL`.
|
|
4341
|
-
{ key: 'FILES_URL', value: recorded[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY] }, { key: 'FILES_AUDIENCE', value: `https://${platform.files.hostname}` },
|
|
4342
|
-
// #436: Company Included's own Files organisation, provisioned by
|
|
4343
|
-
// the ordinary per-org `planFilesOrganisation` step under its own
|
|
4344
|
-
// org id -- `executor.ts`'s `filesOrganisation` calls
|
|
4345
|
-
// `provisionOrganisation({ organisationId: org.id, ... })`, so
|
|
4346
|
-
// `platform.ownerOrgId` is already that organisation's id, with no
|
|
4347
|
-
// separate platform-level mint needed.
|
|
4348
|
-
{ key: 'FILES_ORGANISATION_ID', value: platform.ownerOrgId },
|
|
4349
|
-
// #436: the signed-assertion identity `filesSigner()` reads
|
|
4350
|
-
// (`archon`'s `src/lib/authz/files-management.ts`), not the bearer
|
|
4351
|
-
// token `FILES_MANAGEMENT_KEY` used to name here -- that name made
|
|
4352
|
-
// Archon take the issued-key branch of `filesManagementFetch`,
|
|
4353
|
-
// which then failed on the `FILES_MANAGEMENT_SCOPE` this step never
|
|
4354
|
-
// set, surfacing as "Files is not configured" (foundry#436).
|
|
4355
|
-
{ key: 'FILES_MANAGEMENT_ISSUER', value: FILES_MANAGEMENT_ISSUER }, { key: 'FILES_MANAGEMENT_KEY_ID', value: FILES_MANAGEMENT_KEY_ID }, {
|
|
4356
|
-
key: 'FILES_MANAGEMENT_PRIVATE_KEY',
|
|
4357
|
-
value: held(ARCHON_SERVICE_INPUTS.files.refs[0]),
|
|
4358
|
-
});
|
|
4359
|
-
}
|
|
4360
|
-
else if (service === 'ai') {
|
|
4989
|
+
if (service === 'ai') {
|
|
4361
4990
|
envs.push({
|
|
4362
4991
|
key: 'AI_URL',
|
|
4363
4992
|
value: recorded[PLATFORM_AI_INTERNAL_URL_SCOPE_KEY],
|
|
@@ -4393,6 +5022,22 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
|
|
|
4393
5022
|
{ key: 'JOBS_URL', value: recorded[JOBS_INTERNAL_URL_SCOPE_KEY] }, { key: 'JOBS_ADMIN_TOKEN', value: held(JOBS_ADMIN_TOKEN_REF) });
|
|
4394
5023
|
}
|
|
4395
5024
|
}
|
|
5025
|
+
if (ownerFiles) {
|
|
5026
|
+
let key = null;
|
|
5027
|
+
try {
|
|
5028
|
+
key = (await deps.heldKeys?.resolve(ownerFiles.managementKeyRef)) ?? null;
|
|
5029
|
+
}
|
|
5030
|
+
catch {
|
|
5031
|
+
// The cause is dropped in case it echoes a secret.
|
|
5032
|
+
return block(`the held key ${ownerFiles.managementKeyRef} could not be read`);
|
|
5033
|
+
}
|
|
5034
|
+
if (!key)
|
|
5035
|
+
return block(`the held key ${ownerFiles.managementKeyRef} is not held yet`);
|
|
5036
|
+
envs.push(
|
|
5037
|
+
// Files is private and has no DNS, so FILES_URL is the recorded internal
|
|
5038
|
+
// address; FILES_AUDIENCE is the exact HTTPS origin the signature is for.
|
|
5039
|
+
{ key: 'FILES_URL', value: ownerFiles.internalUrl }, { key: 'FILES_AUDIENCE', value: ownerFiles.audience }, { key: 'FILES_ORGANISATION_ID', value: ownerFiles.organisationId }, { key: 'FILES_MANAGEMENT_ISSUER', value: FILES_MANAGEMENT_ISSUER }, { key: 'FILES_MANAGEMENT_KEY_ID', value: FILES_MANAGEMENT_KEY_ID }, { key: 'FILES_MANAGEMENT_PRIVATE_KEY', value: key });
|
|
5040
|
+
}
|
|
4396
5041
|
return { envs };
|
|
4397
5042
|
}
|
|
4398
5043
|
/**
|
|
@@ -4534,8 +5179,6 @@ function parseAiKeyMintedBody(body) {
|
|
|
4534
5179
|
secret: record.secret,
|
|
4535
5180
|
};
|
|
4536
5181
|
}
|
|
4537
|
-
/** The ref the AI management key is delivered under; the host's `AiOrganisationPort` adapter reads it back. */
|
|
4538
|
-
export const PLATFORM_AI_MANAGEMENT_KEY_REF = 'ai:platform:managementKey';
|
|
4539
5182
|
/**
|
|
4540
5183
|
* What Archon's management key may do in ai: exactly `ai`'s own
|
|
4541
5184
|
* `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
|
|
@@ -4884,13 +5527,15 @@ async function provisionPlatformAi(context, deps) {
|
|
|
4884
5527
|
// Every run, so a changed gitRef reaches an app that already exists.
|
|
4885
5528
|
git_branch: gitRef,
|
|
4886
5529
|
};
|
|
4887
|
-
// #543: the health-check quartet too,
|
|
4888
|
-
//
|
|
4889
|
-
//
|
|
5530
|
+
// #543: the health-check quartet too, on the runner. The worker exposes no
|
|
5531
|
+
// port (`ports_exposes` is never set on it, just below); its Dockerfile's
|
|
5532
|
+
// HEALTHCHECK checks the age of the heartbeat file it writes, and that is the live one.
|
|
4890
5533
|
if (spec === PLATFORM_AI_RUNNER) {
|
|
4891
5534
|
patch.ports_exposes = PLATFORM_AI_PORT;
|
|
4892
5535
|
Object.assign(patch, healthCheckSettings('/api/health', Number(PLATFORM_AI_PORT)));
|
|
4893
5536
|
}
|
|
5537
|
+
if (spec === PLATFORM_AI_WORKER)
|
|
5538
|
+
Object.assign(patch, IMAGE_HEALTHCHECK_SETTINGS);
|
|
4894
5539
|
await coolify.updateApplication(app.appId, patch);
|
|
4895
5540
|
return app;
|
|
4896
5541
|
};
|
|
@@ -4991,7 +5636,8 @@ async function provisionPlatformAi(context, deps) {
|
|
|
4991
5636
|
// nothing public is wired to either app.
|
|
4992
5637
|
const internalUrl = `http://${PLATFORM_AI_RUNNER.internalName}:${PLATFORM_AI_PORT}`;
|
|
4993
5638
|
if (deployRunner) {
|
|
4994
|
-
await waitForNewDeployment(coolify, runner.appId, { kind: 'ready', url: `${internalUrl}/api/health` }, runtime, sleep, 'platform:ai', `the runner stays at ${recorded[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
5639
|
+
const built = await waitForNewDeployment(coolify, runner.appId, { kind: 'ready', url: `${internalUrl}/api/health` }, runtime, sleep, 'platform:ai', `the runner stays at ${recorded[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
5640
|
+
await recordBuiltCommit(coolify, { appId: runner.appId, deploymentUuid: built.deploymentUuid, name: 'ai' }, record);
|
|
4995
5641
|
}
|
|
4996
5642
|
if (deployWorker) {
|
|
4997
5643
|
await waitForNewDeployment(coolify, worker.appId, { kind: 'deployment-only' }, runtime, sleep, 'platform:ai', `the worker stays at ${recorded[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
@@ -5077,6 +5723,54 @@ async function provisionPlatformAi(context, deps) {
|
|
|
5077
5723
|
}
|
|
5078
5724
|
await record(PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, credentialId);
|
|
5079
5725
|
}
|
|
5726
|
+
// ---- the public route ------------------------------------------------------
|
|
5727
|
+
// A company's entries run on their own network and cannot dial `platform-ai`, so they call
|
|
5728
|
+
// `https://<hostname>`. The route is the heartbeat server's (`platform:hc`): a tunnel that dials the
|
|
5729
|
+
// runner by its internal name, a connector app, and a proxied CNAME. No Coolify `domains`, no Access
|
|
5730
|
+
// app: ai authenticates every caller by its bearer key, and a gate in front would stop that.
|
|
5731
|
+
const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:ai');
|
|
5732
|
+
const tunnel = (await tunnels.findTunnelByName(AI_TUNNEL_NAME)) ??
|
|
5733
|
+
(await tunnels.createTunnel(AI_TUNNEL_NAME));
|
|
5734
|
+
// Recorded last, once the DNS record is written: planAi and aiUrl read it as "the route is complete",
|
|
5735
|
+
// so a failure below is planned again and no entry is given a dead AI_URL. The tunnel is found by name on the rerun.
|
|
5736
|
+
await tunnels.putTunnelConfig(tunnel.id, {
|
|
5737
|
+
ingress: [
|
|
5738
|
+
{ hostname, path: AI_ENTRY_PATH_PATTERN, service: internalUrl },
|
|
5739
|
+
{ service: 'http_status:404' },
|
|
5740
|
+
],
|
|
5741
|
+
});
|
|
5742
|
+
const server = await resolvePlatformServer(coolify);
|
|
5743
|
+
await ensureCloudflaredConnector({
|
|
5744
|
+
coolify,
|
|
5745
|
+
tunnels,
|
|
5746
|
+
tunnelId: tunnel.id,
|
|
5747
|
+
appName: AI_CLOUDFLARED_APP_NAME,
|
|
5748
|
+
projectUuid,
|
|
5749
|
+
environment,
|
|
5750
|
+
resolveServer: async () => server,
|
|
5751
|
+
recordedConnectorAppId: context.recordedIds()[PLATFORM_AI_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
5752
|
+
recordAppId: (connectorAppId) => record(PLATFORM_AI_CONNECTOR_APP_SCOPE_KEY, connectorAppId),
|
|
5753
|
+
});
|
|
5754
|
+
const dns = await platformDns(deps, context.recordedIds(), 'platform:ai');
|
|
5755
|
+
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
5756
|
+
const [dnsRecord] = await dns.findDnsRecord(platform.apex, hostname, 'CNAME');
|
|
5757
|
+
if (!dnsRecord) {
|
|
5758
|
+
await dns.createDnsRecord(platform.apex, {
|
|
5759
|
+
type: 'CNAME',
|
|
5760
|
+
name: hostname,
|
|
5761
|
+
content: dnsTarget,
|
|
5762
|
+
proxied: true,
|
|
5763
|
+
});
|
|
5764
|
+
}
|
|
5765
|
+
else if (dnsRecord.content !== dnsTarget) {
|
|
5766
|
+
await dns.updateDnsRecord(platform.apex, dnsRecord.id, {
|
|
5767
|
+
type: 'CNAME',
|
|
5768
|
+
name: hostname,
|
|
5769
|
+
content: dnsTarget,
|
|
5770
|
+
proxied: true,
|
|
5771
|
+
});
|
|
5772
|
+
}
|
|
5773
|
+
await record(PLATFORM_AI_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
5080
5774
|
return { resourceId: runner.appId };
|
|
5081
5775
|
}
|
|
5082
5776
|
/**
|
|
@@ -5305,7 +5999,7 @@ async function missingEnvs(coolify, appId, defaults) {
|
|
|
5305
5999
|
}
|
|
5306
6000
|
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
5307
6001
|
/** Polls `url` until it answers 2xx, or throws once `readyTimeoutMs` has passed. */
|
|
5308
|
-
async function waitForInstance(url, runtime, sleep) {
|
|
6002
|
+
async function waitForInstance(url, runtime, sleep, label = 'platform:auth') {
|
|
5309
6003
|
const fetchImpl = runtime.fetch ?? globalThis.fetch;
|
|
5310
6004
|
const now = runtime.now ?? Date.now;
|
|
5311
6005
|
const timeout = runtime.readyTimeoutMs ?? 300_000;
|
|
@@ -5313,7 +6007,9 @@ async function waitForInstance(url, runtime, sleep) {
|
|
|
5313
6007
|
const deadline = now() + timeout;
|
|
5314
6008
|
for (;;) {
|
|
5315
6009
|
try {
|
|
5316
|
-
const response = await fetchImpl(url
|
|
6010
|
+
const response = await fetchImpl(url, {
|
|
6011
|
+
signal: AbortSignal.timeout(runtime.readyRequestTimeoutMs ?? 10_000),
|
|
6012
|
+
});
|
|
5317
6013
|
if (response.ok)
|
|
5318
6014
|
return;
|
|
5319
6015
|
}
|
|
@@ -5321,7 +6017,7 @@ async function waitForInstance(url, runtime, sleep) {
|
|
|
5321
6017
|
// Not up yet: DNS, the tunnel or the container.
|
|
5322
6018
|
}
|
|
5323
6019
|
if (now() >= deadline) {
|
|
5324
|
-
throw new Error(
|
|
6020
|
+
throw new Error(`${label}: the new ZITADEL instance did not answer at ${url} within ${Math.round(timeout / 1000)}s; retry this apply`);
|
|
5325
6021
|
}
|
|
5326
6022
|
await sleep(interval);
|
|
5327
6023
|
}
|
|
@@ -5547,12 +6243,12 @@ async function currentEnvValue(coolify, appId, key) {
|
|
|
5547
6243
|
* deployed app means someone removed it. This throws, and a person sets the key that database was
|
|
5548
6244
|
* initialised with (or deletes an unused database) and applies again.
|
|
5549
6245
|
*/
|
|
5550
|
-
async function ensureMasterkey(coolify, appId, databaseInUse) {
|
|
6246
|
+
async function ensureMasterkey(coolify, appId, databaseInUse, label = 'platform:auth') {
|
|
5551
6247
|
const envs = await coolify.listEnvs(appId);
|
|
5552
6248
|
if (envs.some((e) => e.key === 'ZITADEL_MASTERKEY' && !e.is_preview))
|
|
5553
6249
|
return null;
|
|
5554
6250
|
if (databaseInUse) {
|
|
5555
|
-
throw new Error(
|
|
6251
|
+
throw new Error(`${label}: app ${appId} has no ZITADEL_MASTERKEY but its database is already in use; not generating a new one. Set ZITADEL_MASTERKEY on that app to the key the database was initialised with, or delete the database if ZITADEL never ran on it, then apply again`);
|
|
5556
6252
|
}
|
|
5557
6253
|
return randomBytes(24).toString('base64');
|
|
5558
6254
|
}
|
|
@@ -5568,23 +6264,21 @@ async function ensureMasterkey(coolify, appId, databaseInUse) {
|
|
|
5568
6264
|
*/
|
|
5569
6265
|
export const FILES_MANAGEMENT_ISSUER = 'foundry';
|
|
5570
6266
|
export const FILES_MANAGEMENT_KEY_ID = 'foundry-1';
|
|
5571
|
-
/** The ref the management private key (PKCS#8 PEM) is delivered under. */
|
|
5572
|
-
export const FILES_MANAGEMENT_KEY_REF = 'files:platform:managementKey';
|
|
5573
6267
|
/** The ref the files host's introspection client secret is delivered under. */
|
|
5574
6268
|
export const FILES_INTROSPECTION_SECRET_REF = 'zitadel:platform:files:introspection';
|
|
5575
6269
|
/** https://github.com/wtfalch/files -- root Dockerfile with `migration` and `runner` targets. */
|
|
5576
6270
|
const FILES_REPO = 'wtfalch/files';
|
|
5577
6271
|
const PLATFORM_FILES_PROJECT = 'platform';
|
|
5578
|
-
const PLATFORM_FILES_DB_NAME = 'platform-files-db';
|
|
6272
|
+
export const PLATFORM_FILES_DB_NAME = 'platform-files-db';
|
|
5579
6273
|
/** The two Coolify apps files' `apps/host/README.md` ("Deploying on Coolify") names. */
|
|
5580
|
-
const FILES_MIGRATE_APP_NAME = 'files-migrate';
|
|
5581
|
-
const FILES_RUNNER_APP_NAME = 'files';
|
|
6274
|
+
export const FILES_MIGRATE_APP_NAME = 'files-migrate';
|
|
6275
|
+
export const FILES_RUNNER_APP_NAME = 'files';
|
|
5582
6276
|
/**
|
|
5583
6277
|
* The runner's fixed `custom_internal_name`: the name another container on
|
|
5584
6278
|
* the Coolify network reaches it by, and so the host part of
|
|
5585
6279
|
* {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY}'s recorded address.
|
|
5586
6280
|
*/
|
|
5587
|
-
const FILES_RUNNER_INTERNAL_NAME = 'platform-files';
|
|
6281
|
+
export const FILES_RUNNER_INTERNAL_NAME = 'platform-files';
|
|
5588
6282
|
/** The runner's port (`Dockerfile`'s `runner` stage: `PORT=3000`, `EXPOSE 3000`). */
|
|
5589
6283
|
const FILES_PORT = '3000';
|
|
5590
6284
|
/** files' own health route, the same one `healthUrls` (`executor.ts`) assumes every hosted app serves. */
|
|
@@ -5681,22 +6375,106 @@ async function provisionPlatformFiles(context, deps,
|
|
|
5681
6375
|
/** {@link filesShareDomains} of every declared org: each is a runner domain and a public host. */
|
|
5682
6376
|
shareDomains) {
|
|
5683
6377
|
const { platform } = context;
|
|
5684
|
-
|
|
5685
|
-
|
|
6378
|
+
return provisionFilesHost(scopeFromPlatform(context), deps, platformTarget(), {
|
|
6379
|
+
hostname: platform.files.hostname,
|
|
6380
|
+
gitRef: platform.files.gitRef,
|
|
6381
|
+
project: platformFilesProject,
|
|
6382
|
+
issuer: `https://${platform.auth.hostname}`,
|
|
6383
|
+
zitadelOrg: { name: PLATFORM_AUTH_ORG_NAME },
|
|
6384
|
+
zitadelProject: {
|
|
6385
|
+
name: FILES_ZITADEL_PROJECT_NAME,
|
|
6386
|
+
scopeKey: PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY,
|
|
6387
|
+
},
|
|
6388
|
+
shareDomains,
|
|
6389
|
+
});
|
|
6390
|
+
}
|
|
6391
|
+
/** The platform's files host lives in the shared `platform` project. */
|
|
6392
|
+
const platformFilesProject = {
|
|
6393
|
+
async ensure(coolify, scope) {
|
|
6394
|
+
const recorded = scope.recordedIds()[PLATFORM_FILES_PROJECT_SCOPE_KEY];
|
|
6395
|
+
if (recorded)
|
|
6396
|
+
return recorded;
|
|
6397
|
+
const found = await coolify.findProjectByName(PLATFORM_FILES_PROJECT);
|
|
6398
|
+
const projectUuid = found
|
|
6399
|
+
? found.uuid
|
|
6400
|
+
: (await coolify.createProject(PLATFORM_FILES_PROJECT)).uuid;
|
|
6401
|
+
scope.recordScopeId(PLATFORM_FILES_PROJECT_SCOPE_KEY, projectUuid);
|
|
6402
|
+
await scope.persist();
|
|
6403
|
+
return projectUuid;
|
|
6404
|
+
},
|
|
6405
|
+
};
|
|
6406
|
+
/** The credential id of the keyring an own files host runs on; recorded once both apps carry it. */
|
|
6407
|
+
export const OWN_FILES_KEYS_SCOPE_KEY = 'files_keys_id';
|
|
6408
|
+
/** The credential id minted and not yet confirmed delivered. */
|
|
6409
|
+
export const OWN_FILES_KEYS_UNDELIVERED_SCOPE_KEY = 'files_keys_undelivered';
|
|
6410
|
+
/**
|
|
6411
|
+
* An own files host's keyring, before its env writes. Both refs held: those values, no mint (a rerun
|
|
6412
|
+
* after a failed run resumes here). Nothing held: one mint, which is checked, registered as a
|
|
6413
|
+
* secret, recorded by id as undelivered, then delivered to both refs. A re-mint is harmless: it
|
|
6414
|
+
* yields an equivalent credential. Returns the guard id for {@link FilesHostInput.keysGuardId}.
|
|
6415
|
+
*/
|
|
6416
|
+
export async function resolveOwnFilesKeys(scope, deps, target, mint, workerUrl, label) {
|
|
6417
|
+
const runtime = deps.runtime ?? {};
|
|
6418
|
+
const recorded = scope.recordedIds();
|
|
6419
|
+
const credential = await deps.readSecret?.(target.refs.keysCredential);
|
|
6420
|
+
const verifyKeys = await deps.readSecret?.(target.refs.keysVerifyKeys);
|
|
6421
|
+
if (credential && verifyKeys) {
|
|
6422
|
+
runtime.onSecret?.(credential);
|
|
6423
|
+
return {
|
|
6424
|
+
keys: { workerUrl, credential, verifyKeys },
|
|
6425
|
+
guardId: recorded[OWN_FILES_KEYS_SCOPE_KEY] ||
|
|
6426
|
+
recorded[OWN_FILES_KEYS_UNDELIVERED_SCOPE_KEY] ||
|
|
6427
|
+
'held',
|
|
6428
|
+
};
|
|
6429
|
+
}
|
|
6430
|
+
const answer = await mint();
|
|
6431
|
+
runtime.onSecret?.(answer.credential);
|
|
6432
|
+
const keys = { workerUrl, credential: answer.credential, verifyKeys: answer.verifyKeys };
|
|
6433
|
+
const invalid = answer.credential
|
|
6434
|
+
? filesDepsInvalid({ keys })
|
|
6435
|
+
: 'credential';
|
|
6436
|
+
if (invalid)
|
|
6437
|
+
throw new Error(`${label}: the keys worker's answer is not usable (${invalid})`);
|
|
6438
|
+
// Recorded and persisted before the credential leaves this function.
|
|
6439
|
+
scope.recordScopeId(OWN_FILES_KEYS_UNDELIVERED_SCOPE_KEY, answer.credentialId);
|
|
6440
|
+
await scope.persist();
|
|
6441
|
+
const attempts = runtime.deliverAttempts ?? 3;
|
|
6442
|
+
const sleep = runtime.sleep ?? defaultSleep;
|
|
6443
|
+
await deliverWithRetry(deps.deliverSecret, target.refs.keysCredential, answer.credential, attempts, sleep, `the keys credential (${answer.credentialId})`, label);
|
|
6444
|
+
await deliverWithRetry(deps.deliverSecret, target.refs.keysVerifyKeys, answer.verifyKeys, attempts, sleep, `the verify keys of credential ${answer.credentialId}`, label);
|
|
6445
|
+
return { keys, guardId: answer.credentialId };
|
|
6446
|
+
}
|
|
6447
|
+
/**
|
|
6448
|
+
* The body of {@link provisionPlatformFiles} for any files host: the
|
|
6449
|
+
* platform's, or an own org's. Every name, the internal name and the two
|
|
6450
|
+
* held-key refs come from `target`; the project, sign-in and ZITADEL home
|
|
6451
|
+
* come from `input`. See {@link provisionPlatformFiles} for what the step does.
|
|
6452
|
+
*/
|
|
6453
|
+
export async function provisionFilesHost(scope, deps, target, input) {
|
|
6454
|
+
const { gitRef, shareDomains } = input;
|
|
6455
|
+
const own = target.kind === 'org';
|
|
6456
|
+
const label = own ? `own step coolify:own-files for ${target.id}` : 'platform:files';
|
|
6457
|
+
const recorded = scope.recordedIds();
|
|
5686
6458
|
const runtime = deps.runtime ?? {};
|
|
5687
6459
|
const sleep = runtime.sleep ?? defaultSleep;
|
|
5688
6460
|
const attempts = runtime.deliverAttempts ?? 3;
|
|
5689
6461
|
const keys = filesKeys(deps);
|
|
5690
6462
|
if (!keys) {
|
|
5691
|
-
throw new Error(
|
|
5692
|
-
}
|
|
6463
|
+
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`);
|
|
6464
|
+
}
|
|
6465
|
+
runtime.onSecret?.(keys.credential);
|
|
6466
|
+
// The platform's audit token and jobs secrets never go to a company's host (SC4).
|
|
6467
|
+
const auditToken = own ? undefined : deps.keys?.auditToken;
|
|
6468
|
+
const jobsSigningSecrets = own ? undefined : deps.jobsSigningSecrets;
|
|
6469
|
+
if (auditToken)
|
|
6470
|
+
runtime.onSecret?.(auditToken);
|
|
5693
6471
|
// #380: R2_BUCKET/R2_ENDPOINT, the same "block the whole step" treatment
|
|
5694
6472
|
// as a missing `keys` above -- the runner's `ensureStarted()` requires
|
|
5695
6473
|
// both, and only this function's caller (`blockedSteps`, `planFiles`) ever
|
|
5696
6474
|
// has anything useful to report while they are unset.
|
|
5697
6475
|
const r2 = filesR2(deps);
|
|
5698
6476
|
if (!r2) {
|
|
5699
|
-
throw new Error(
|
|
6477
|
+
throw new Error(`${label} needs the R2 bucket and endpoint in its files dependencies (R2_BUCKET, R2_ENDPOINT); files never becomes healthy without them`);
|
|
5700
6478
|
}
|
|
5701
6479
|
// Never plan-and-throw: the planner reports a lost secret and plans
|
|
5702
6480
|
// nothing; this is the same rule for a caller that reaches the step anyway.
|
|
@@ -5706,28 +6484,21 @@ shareDomains) {
|
|
|
5706
6484
|
]) {
|
|
5707
6485
|
const lost = recorded[undeliveredKey];
|
|
5708
6486
|
if (lost && !recorded[guardKey]) {
|
|
5709
|
-
throw new Error(
|
|
6487
|
+
throw new Error(`${label}: a secret (${lost}) was minted but never delivered; remove the recorded key ${undeliveredKey} to mint a new one`);
|
|
5710
6488
|
}
|
|
5711
6489
|
}
|
|
5712
6490
|
const record = async (key, id) => {
|
|
5713
|
-
|
|
5714
|
-
await
|
|
6491
|
+
scope.recordScopeId(key, id);
|
|
6492
|
+
await scope.persist();
|
|
5715
6493
|
};
|
|
5716
|
-
|
|
5717
|
-
if (!projectUuid) {
|
|
5718
|
-
const found = await deps.coolify.findProjectByName(PLATFORM_FILES_PROJECT);
|
|
5719
|
-
projectUuid = found
|
|
5720
|
-
? found.uuid
|
|
5721
|
-
: (await deps.coolify.createProject(PLATFORM_FILES_PROJECT)).uuid;
|
|
5722
|
-
await record(PLATFORM_FILES_PROJECT_SCOPE_KEY, projectUuid);
|
|
5723
|
-
}
|
|
6494
|
+
const projectUuid = await input.project.ensure(deps.coolify, scope);
|
|
5724
6495
|
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
5725
6496
|
// ---- database ----------------------------------------------------------
|
|
5726
6497
|
let dbId = recorded[PLATFORM_FILES_DB_SCOPE_KEY];
|
|
5727
6498
|
if (!dbId) {
|
|
5728
6499
|
// Found by name first, so a crash between create and record never makes
|
|
5729
6500
|
// a second one (`coolifyJobs` does the same).
|
|
5730
|
-
const found = (await deps.coolify.listDatabases()).find((db) => db.name ===
|
|
6501
|
+
const found = (await deps.coolify.listDatabases()).find((db) => db.name === target.names.filesDb && db.environment_id === environment.id);
|
|
5731
6502
|
if (found) {
|
|
5732
6503
|
dbId = found.uuid;
|
|
5733
6504
|
}
|
|
@@ -5738,7 +6509,7 @@ shareDomains) {
|
|
|
5738
6509
|
server_uuid: server.uuid,
|
|
5739
6510
|
environment_name: environment.name,
|
|
5740
6511
|
environment_uuid: environment.uuid,
|
|
5741
|
-
name:
|
|
6512
|
+
name: target.names.filesDb,
|
|
5742
6513
|
postgres_db: FILES_DB,
|
|
5743
6514
|
postgres_user: FILES_DB,
|
|
5744
6515
|
});
|
|
@@ -5748,7 +6519,7 @@ shareDomains) {
|
|
|
5748
6519
|
}
|
|
5749
6520
|
const db = await getDatabaseStarted(deps.coolify, dbId);
|
|
5750
6521
|
if (!db.internal_db_url) {
|
|
5751
|
-
throw new Error('coolify:app for the platform (files): the database has no internal_db_url yet -- retry this apply once Coolify reports one
|
|
6522
|
+
throw new Error(`${own ? label : 'coolify:app for the platform (files)'}: the database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
5752
6523
|
}
|
|
5753
6524
|
// ---- the two apps --------------------------------------------------------
|
|
5754
6525
|
const createFilesApp = (name) => async () => {
|
|
@@ -5766,7 +6537,7 @@ shareDomains) {
|
|
|
5766
6537
|
name,
|
|
5767
6538
|
});
|
|
5768
6539
|
};
|
|
5769
|
-
const migrateApp = await findOrCreatePlatformApp(deps.coolify,
|
|
6540
|
+
const migrateApp = await findOrCreatePlatformApp(deps.coolify, target.names.filesMigrate, environment, createFilesApp(target.names.filesMigrate));
|
|
5770
6541
|
await record(PLATFORM_FILES_MIGRATE_SCOPE_KEY, migrateApp.appId);
|
|
5771
6542
|
// Set on every run, like `platform:auth`'s `custom_internal_name`: a throw
|
|
5772
6543
|
// right after a create must not leave the app on the wrong target.
|
|
@@ -5775,11 +6546,11 @@ shareDomains) {
|
|
|
5775
6546
|
dockerfile_target_build: 'migration',
|
|
5776
6547
|
git_branch: gitRef,
|
|
5777
6548
|
});
|
|
5778
|
-
const runnerApp = await findOrCreatePlatformApp(deps.coolify,
|
|
6549
|
+
const runnerApp = await findOrCreatePlatformApp(deps.coolify, target.names.filesRunner, environment, createFilesApp(target.names.filesRunner));
|
|
5779
6550
|
await record(PLATFORM_FILES_RUNNER_SCOPE_KEY, runnerApp.appId);
|
|
5780
6551
|
await deps.coolify.updateApplication(runnerApp.appId, {
|
|
5781
6552
|
dockerfile_target_build: 'runner',
|
|
5782
|
-
custom_internal_name:
|
|
6553
|
+
custom_internal_name: target.internal.files,
|
|
5783
6554
|
git_branch: gitRef,
|
|
5784
6555
|
// #543: the health-check quartet, same reason Archon's doc comment
|
|
5785
6556
|
// gives. The migrate app gets none -- it idles rather than serves
|
|
@@ -5791,13 +6562,14 @@ shareDomains) {
|
|
|
5791
6562
|
// nobody gave a share host keeps no `domains`.
|
|
5792
6563
|
...filesShareDomainsPatch(recorded, shareDomains),
|
|
5793
6564
|
});
|
|
5794
|
-
|
|
6565
|
+
scope.recordScopeId(PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY, normalHosts(shareDomains).join(','));
|
|
5795
6566
|
// ---- database env ----------------------------------------------------------
|
|
5796
6567
|
// The migrate app sets `files_rt`'s password from DATABASE_URL on every
|
|
5797
6568
|
// boot (files' `scripts/db-runtime-role.sh`), and the runner connects with
|
|
5798
6569
|
// the same URL, so both apps carry one value, minted once and never
|
|
5799
6570
|
// rotated: an existing value on either app is reused.
|
|
5800
6571
|
const runtimeUrl = await ensureFilesRuntimeUrl(deps.coolify, [migrateApp.appId, runnerApp.appId], db.internal_db_url);
|
|
6572
|
+
runtime.onSecret?.(runtimeUrl);
|
|
5801
6573
|
// The keys worker the migrate app needs to become healthy (files' README:
|
|
5802
6574
|
// `DATABASE_URL_OWNER`, `DATABASE_URL`, `KEYS_WORKER_URL`, `KEYS_CREDENTIAL`).
|
|
5803
6575
|
// `files-migrate` never runs `ensureStarted()`, so it needs neither
|
|
@@ -5812,11 +6584,13 @@ shareDomains) {
|
|
|
5812
6584
|
...migrateKeysEnvs,
|
|
5813
6585
|
]);
|
|
5814
6586
|
// ---- the files ZITADEL project, in the platform's sign-in ----------------
|
|
5815
|
-
const issuer =
|
|
6587
|
+
const { issuer } = input;
|
|
5816
6588
|
const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
|
|
5817
|
-
const
|
|
5818
|
-
|
|
5819
|
-
|
|
6589
|
+
const zitadelOrgId = 'id' in input.zitadelOrg
|
|
6590
|
+
? input.zitadelOrg.id
|
|
6591
|
+
: (await zitadel.findOrCreateOrg(input.zitadelOrg.name)).org.id;
|
|
6592
|
+
const { project } = await zitadel.findOrCreateProject(zitadelOrgId, input.zitadelProject.name);
|
|
6593
|
+
await record(input.zitadelProject.scopeKey, project.id);
|
|
5820
6594
|
// #380: the runner's own required settings -- `KEYS_VERIFY_KEYS` and
|
|
5821
6595
|
// `R2_BUCKET`/`R2_ENDPOINT` -- plus whatever optional ones the host
|
|
5822
6596
|
// supplies. `files-migrate` needs none of these (see `migrateKeysEnvs`
|
|
@@ -5825,10 +6599,10 @@ shareDomains) {
|
|
|
5825
6599
|
...migrateKeysEnvs,
|
|
5826
6600
|
{ key: 'KEYS_VERIFY_KEYS', value: keys.verifyKeys },
|
|
5827
6601
|
];
|
|
5828
|
-
if (
|
|
6602
|
+
if (auditToken)
|
|
5829
6603
|
runnerKeysEnvs.push({
|
|
5830
6604
|
key: 'KEYS_AUDIT_TOKEN',
|
|
5831
|
-
value:
|
|
6605
|
+
value: auditToken,
|
|
5832
6606
|
});
|
|
5833
6607
|
const runnerOptionalEnvs = [];
|
|
5834
6608
|
if (deps.clamd?.host) {
|
|
@@ -5843,13 +6617,16 @@ shareDomains) {
|
|
|
5843
6617
|
});
|
|
5844
6618
|
}
|
|
5845
6619
|
runnerOptionalEnvs.push(...filesPublicHostsEnvs(recorded, deps.publicHosts, shareDomains));
|
|
5846
|
-
if (
|
|
6620
|
+
if (jobsSigningSecrets)
|
|
5847
6621
|
runnerOptionalEnvs.push({
|
|
5848
6622
|
key: 'JOBS_SIGNING_SECRETS',
|
|
5849
|
-
value:
|
|
6623
|
+
value: jobsSigningSecrets,
|
|
5850
6624
|
});
|
|
5851
6625
|
// #501: the same person platform:archon names; Files has no sign-in page.
|
|
5852
|
-
|
|
6626
|
+
// An own org's files host gets the platform's owner too, carried in by the planner: its own scope holds none.
|
|
6627
|
+
const ownerInviteUserId = own
|
|
6628
|
+
? input.bootstrapOperator
|
|
6629
|
+
: recorded[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
|
|
5853
6630
|
if (ownerInviteUserId && ownerInviteUserId !== 'none') {
|
|
5854
6631
|
runnerOptionalEnvs.push({ key: 'AUTHZ_BOOTSTRAP_OPERATOR', value: ownerInviteUserId });
|
|
5855
6632
|
}
|
|
@@ -5857,7 +6634,7 @@ shareDomains) {
|
|
|
5857
6634
|
{ key: 'DATABASE_URL', value: runtimeUrl },
|
|
5858
6635
|
// The management verifier's audience, which must be an HTTPS origin. The
|
|
5859
6636
|
// host has no public address, so this is a name, not a route.
|
|
5860
|
-
{ key: 'APP_URL', value: `https://${
|
|
6637
|
+
{ key: 'APP_URL', value: `https://${input.hostname}` },
|
|
5861
6638
|
{ key: 'AUTH_ISSUER', value: issuer },
|
|
5862
6639
|
{ key: 'AUTH_PERSON_TOKEN_AUDIENCE', value: project.id },
|
|
5863
6640
|
...runnerKeysEnvs,
|
|
@@ -5872,28 +6649,32 @@ shareDomains) {
|
|
|
5872
6649
|
// Base env first, so a secret's own write below cannot be the only one
|
|
5873
6650
|
// that lands, and a re-run with a changed hostname updates `APP_URL`.
|
|
5874
6651
|
await deps.coolify.bulkUpsertEnvs(runnerApp.appId, runnerEnvs);
|
|
6652
|
+
// Both apps now carry the company's own keyring: the delivery is confirmed.
|
|
6653
|
+
if (own && input.keysGuardId)
|
|
6654
|
+
await record(OWN_FILES_KEYS_SCOPE_KEY, input.keysGuardId);
|
|
5875
6655
|
// ---- the introspection client, once --------------------------------------
|
|
5876
6656
|
if (!recorded[PLATFORM_FILES_INTROSPECTION_SCOPE_KEY]) {
|
|
5877
6657
|
// An app by this name here has no recorded secret (no undelivered id, or
|
|
5878
6658
|
// the throw above would have fired): a crash between the create and the
|
|
5879
6659
|
// record. Nothing was delivered, so it is deleted and made again.
|
|
5880
|
-
const stale = (await zitadel.listApps(
|
|
6660
|
+
const stale = (await zitadel.listApps(zitadelOrgId, project.id)).apps.filter((app) => app.name === FILES_INTROSPECTION_APP_NAME && app.state !== 'APP_STATE_REMOVED');
|
|
5881
6661
|
for (const app of stale) {
|
|
5882
6662
|
try {
|
|
5883
|
-
await zitadel.deleteApp(
|
|
6663
|
+
await zitadel.deleteApp(zitadelOrgId, project.id, app.id);
|
|
5884
6664
|
}
|
|
5885
6665
|
catch (error) {
|
|
5886
6666
|
if (!isNotFoundError(error))
|
|
5887
6667
|
throw error;
|
|
5888
6668
|
}
|
|
5889
6669
|
}
|
|
5890
|
-
const client = await zitadel.createApiApp(
|
|
6670
|
+
const client = await zitadel.createApiApp(zitadelOrgId, project.id, {
|
|
5891
6671
|
name: FILES_INTROSPECTION_APP_NAME,
|
|
5892
6672
|
});
|
|
6673
|
+
runtime.onSecret?.(client.clientSecret);
|
|
5893
6674
|
// Recorded and persisted before the secret leaves this function: a failed
|
|
5894
6675
|
// delivery or env write is then reported by the planner, not remade.
|
|
5895
6676
|
await record(PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, client.appId);
|
|
5896
|
-
await deliverWithRetry(deps.deliverSecret,
|
|
6677
|
+
await deliverWithRetry(deps.deliverSecret, target.refs.filesIntrospection, client.clientSecret, attempts, sleep, `the introspection client secret (ZITADEL app ${client.appId})`, label);
|
|
5897
6678
|
await deps.coolify.bulkUpsertEnvs(runnerApp.appId, [
|
|
5898
6679
|
{
|
|
5899
6680
|
key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_ID',
|
|
@@ -5912,8 +6693,9 @@ shareDomains) {
|
|
|
5912
6693
|
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
5913
6694
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
5914
6695
|
});
|
|
6696
|
+
runtime.onSecret?.(privateKey);
|
|
5915
6697
|
await record(PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, FILES_MANAGEMENT_KEY_ID);
|
|
5916
|
-
await deliverWithRetry(deps.deliverSecret,
|
|
6698
|
+
await deliverWithRetry(deps.deliverSecret, target.refs.filesManagement, privateKey, attempts, sleep, 'the files management key', label);
|
|
5917
6699
|
await deps.coolify.bulkUpsertEnvs(runnerApp.appId, [
|
|
5918
6700
|
{ key: 'MANAGEMENT_ISSUER', value: FILES_MANAGEMENT_ISSUER },
|
|
5919
6701
|
{
|
|
@@ -5929,12 +6711,12 @@ shareDomains) {
|
|
|
5929
6711
|
// container before the new migration has run.
|
|
5930
6712
|
await stopIfRunning(deps.coolify, migrateApp.appId);
|
|
5931
6713
|
const migrateDeployed = await deps.coolify.deploy(migrateApp.appId);
|
|
5932
|
-
await waitForMigration(deps.coolify, migrateApp.appId, runtime, sleep);
|
|
6714
|
+
await waitForMigration(deps.coolify, migrateApp.appId, runtime, sleep, label, target.names.filesMigrate);
|
|
5933
6715
|
// #399: files-migrate idles rather than exits once it has migrated, so it
|
|
5934
6716
|
// reads healthy well before Coolify's own deployment does; stopping it on
|
|
5935
6717
|
// that signal alone can find the container already gone and make Coolify
|
|
5936
6718
|
// mark a deployment that actually migrated as `failed`.
|
|
5937
|
-
await waitForMigrateDeployment(deps.coolify, migrateApp.appId, queuedDeploymentUuid(migrateDeployed) ?? 'untracked',
|
|
6719
|
+
await waitForMigrateDeployment(deps.coolify, migrateApp.appId, queuedDeploymentUuid(migrateDeployed) ?? 'untracked', label, target.names.filesMigrate, runtime.now ?? Date.now, runtime.migrateTimeoutMs ?? 900_000, runtime.migrateIntervalMs ?? 10_000, sleep);
|
|
5938
6720
|
await deps.coolify.raw('POST', `/applications/${migrateApp.appId}/stop`);
|
|
5939
6721
|
// #252/#543: no public `domains`, tunnel or DNS record is wired to the
|
|
5940
6722
|
// runner (its own doc comment: "Nothing public is wired"), but it does
|
|
@@ -5945,11 +6727,12 @@ shareDomains) {
|
|
|
5945
6727
|
// container); `coolify.deployAndWait` reaching Coolify's own deployment
|
|
5946
6728
|
// record first is still what rules out the old container staying healthy
|
|
5947
6729
|
// through a failed deploy.
|
|
5948
|
-
const internalUrl = `http://${
|
|
5949
|
-
await waitForNewDeployment(deps.coolify, runnerApp.appId, { kind: 'ready', url: `${internalUrl}${FILES_HEALTH_CHECK_PATH}` }, runtime, sleep,
|
|
6730
|
+
const internalUrl = `http://${target.internal.files}:${FILES_PORT}`;
|
|
6731
|
+
const built = 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'}`);
|
|
6732
|
+
await recordBuiltCommit(deps.coolify, { appId: runnerApp.appId, deploymentUuid: built.deploymentUuid, name: 'files' }, record);
|
|
5950
6733
|
await record(PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, internalUrl);
|
|
5951
6734
|
// Last: a run that failed anywhere above is planned again.
|
|
5952
|
-
|
|
6735
|
+
scope.recordScopeId(PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, input.hostname);
|
|
5953
6736
|
await record(PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
5954
6737
|
return { resourceId: runnerApp.appId };
|
|
5955
6738
|
}
|
|
@@ -6272,7 +7055,6 @@ const PLATFORM_ACCESS_PROVIDER_NAME = 'foundry';
|
|
|
6272
7055
|
* again.
|
|
6273
7056
|
*/
|
|
6274
7057
|
async function provisionPlatformAccessProvider(context, deps) {
|
|
6275
|
-
const { platform } = context;
|
|
6276
7058
|
const recorded = context.recordedIds();
|
|
6277
7059
|
const sleep = deps.runtime?.sleep ?? defaultSleep;
|
|
6278
7060
|
const attempts = deps.runtime?.deliverAttempts ?? 3;
|
|
@@ -6291,10 +7073,19 @@ async function provisionPlatformAccessProvider(context, deps) {
|
|
|
6291
7073
|
catch {
|
|
6292
7074
|
throw new Error(`${label}: edge could not confirm that Cloudflare Access is ready`);
|
|
6293
7075
|
}
|
|
6294
|
-
|
|
6295
|
-
const
|
|
6296
|
-
|
|
7076
|
+
// The client lives in the owner company's own ZITADEL, never a platform one.
|
|
7077
|
+
const ownerSignIn = context.owner?.signIn ?? null;
|
|
7078
|
+
if (!ownerSignIn) {
|
|
7079
|
+
throw new Error(`${label}: the owner company's sign-in has not been applied yet`);
|
|
7080
|
+
}
|
|
7081
|
+
if (!deps.zitadelForInstance) {
|
|
7082
|
+
throw new Error(`${label}: the host built the Access provider dependencies without zitadelForInstance, so it cannot reach the owner company's ZITADEL`);
|
|
7083
|
+
}
|
|
7084
|
+
const issuer = ownerSignIn.issuerUrl;
|
|
7085
|
+
const zitadel = deps.zitadelForInstance(issuer, ownerSignIn.bearerRef);
|
|
7086
|
+
const org = { id: ownerSignIn.organisationId };
|
|
6297
7087
|
const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_ACCESS_ZITADEL_PROJECT);
|
|
7088
|
+
context.recordScopeId(PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY, project.id);
|
|
6298
7089
|
const orphan = (await zitadel.listApps(org.id, project.id)).apps.find((a) => a.name === PLATFORM_ACCESS_ZITADEL_APP && a.state !== 'APP_STATE_REMOVED');
|
|
6299
7090
|
if (orphan) {
|
|
6300
7091
|
try {
|
|
@@ -6311,6 +7102,7 @@ async function provisionPlatformAccessProvider(context, deps) {
|
|
|
6311
7102
|
postLogoutRedirectUris: [`https://${authDomain}/`],
|
|
6312
7103
|
});
|
|
6313
7104
|
context.recordScopeId(PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, client.appId);
|
|
7105
|
+
context.recordScopeId(PLATFORM_ACCESS_ZITADEL_APP_SCOPE_KEY, client.appId);
|
|
6314
7106
|
await context.persist?.();
|
|
6315
7107
|
let providerId = '';
|
|
6316
7108
|
await deliverWithRetry(async (_ref, secret) => {
|
|
@@ -6326,7 +7118,7 @@ async function provisionPlatformAccessProvider(context, deps) {
|
|
|
6326
7118
|
})).id;
|
|
6327
7119
|
}, ACCESS_PROVIDER_RECORD_KEY, client.clientSecret, attempts, sleep, `the ZITADEL client ${client.appId}'s secret`, label);
|
|
6328
7120
|
// Last: the guard says the secret reached edge.
|
|
6329
|
-
context.recordScopeId(PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY,
|
|
7121
|
+
context.recordScopeId(PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, ownerSignIn.hostname);
|
|
6330
7122
|
context.recordScopeId(PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, authDomain);
|
|
6331
7123
|
context.recordScopeId(ACCESS_PROVIDER_RECORD_KEY, providerId);
|
|
6332
7124
|
await context.persist?.();
|
|
@@ -6350,6 +7142,17 @@ const HC_PORT = '8000';
|
|
|
6350
7142
|
const HC_HEALTH_CHECK_PATH = '/';
|
|
6351
7143
|
const PLATFORM_HC_DB = 'hc';
|
|
6352
7144
|
const PLATFORM_HC_DB_USER = 'hc';
|
|
7145
|
+
/** The tunnel that carries the AI host's public hostname, and the app that runs its connector. */
|
|
7146
|
+
const AI_TUNNEL_NAME = 'platform-ai-tunnel';
|
|
7147
|
+
/**
|
|
7148
|
+
* The only paths the AI host's public route serves; everything else answers 404 at the tunnel. A company entry's key
|
|
7149
|
+
* holds `ai.runs:create` and `ai.offerings:invoke` (`AI_ENTRY_PERMISSIONS`), and `@wtfalch/ai`'s client reaches them
|
|
7150
|
+
* through `POST /v1/runs` (also `?wait=`), `POST /v1/runs/batch`, `GET /v1/runs`, `GET /v1/runs/:id` and
|
|
7151
|
+
* `GET /v1/offerings`. The management routes (`/v1/organisations`, `/v1/keys`, `/v1/budgets`, `/v1/webhooks`,
|
|
7152
|
+
* `/v1/connections`, `/v1/platform/keys`, ...) and `/api/health` are not on the internet.
|
|
7153
|
+
*/
|
|
7154
|
+
export const AI_ENTRY_PATH_PATTERN = '^/v1/(runs(/|$)|offerings$)';
|
|
7155
|
+
const AI_CLOUDFLARED_APP_NAME = 'platform-ai-cloudflared';
|
|
6353
7156
|
const HC_TUNNEL_NAME = 'platform-hc-tunnel';
|
|
6354
7157
|
/** #197: the one Coolify app that runs `cloudflared` for {@link HC_TUNNEL_NAME}. */
|
|
6355
7158
|
const HC_CLOUDFLARED_APP_NAME = 'platform-hc-cloudflared';
|
|
@@ -6443,7 +7246,7 @@ async function provisionPlatformHc(context, deps) {
|
|
|
6443
7246
|
environment_name: environment.name,
|
|
6444
7247
|
environment_uuid: environment.uuid,
|
|
6445
7248
|
docker_registry_image_name: HC_IMAGE,
|
|
6446
|
-
docker_registry_image_tag: gitRef,
|
|
7249
|
+
docker_registry_image_tag: pinnedTagFor('hc', gitRef),
|
|
6447
7250
|
ports_exposes: HC_PORT,
|
|
6448
7251
|
name: PLATFORM_HC_APP_NAME,
|
|
6449
7252
|
description: `Healthchecks for the platform's own heartbeats (#540, ${HC_IMAGE}:${gitRef})`,
|
|
@@ -6456,7 +7259,7 @@ async function provisionPlatformHc(context, deps) {
|
|
|
6456
7259
|
// own rule. The image tag too, so a declared release change reaches the app.
|
|
6457
7260
|
await deps.coolify.updateApplication(appId, {
|
|
6458
7261
|
custom_internal_name: HC_INTERNAL_NAME,
|
|
6459
|
-
docker_registry_image_tag: gitRef,
|
|
7262
|
+
docker_registry_image_tag: pinnedTagFor('hc', gitRef),
|
|
6460
7263
|
...healthCheckSettings(HC_HEALTH_CHECK_PATH, Number(HC_PORT)),
|
|
6461
7264
|
});
|
|
6462
7265
|
// ---- env, parsed off the database's own owner credentials -----------------
|
|
@@ -6577,7 +7380,7 @@ function aiKeys(deps) {
|
|
|
6577
7380
|
const keys = deps?.keys;
|
|
6578
7381
|
return keys?.workerUrl && keys.credential && keys.verifyKeys ? keys : null;
|
|
6579
7382
|
}
|
|
6580
|
-
function filesKeys(deps) {
|
|
7383
|
+
export function filesKeys(deps) {
|
|
6581
7384
|
const keys = deps?.keys;
|
|
6582
7385
|
return keys?.workerUrl && keys.credential && keys.verifyKeys
|
|
6583
7386
|
? {
|
|
@@ -6600,7 +7403,7 @@ function filesR2(deps) {
|
|
|
6600
7403
|
* naming the field instead of deploying a host that never turns healthy.
|
|
6601
7404
|
* Never echoes the value.
|
|
6602
7405
|
*/
|
|
6603
|
-
function filesDepsInvalid(deps) {
|
|
7406
|
+
export function filesDepsInvalid(deps) {
|
|
6604
7407
|
const r2 = filesR2(deps);
|
|
6605
7408
|
if (r2) {
|
|
6606
7409
|
let ok = false;
|
|
@@ -6727,7 +7530,7 @@ async function ensureFilesRuntimeUrl(coolify, appIds, dbInternalUrl) {
|
|
|
6727
7530
|
* stands in for the log line). Throws once `migrateTimeoutMs` has passed; the
|
|
6728
7531
|
* runner is then never deployed against an unmigrated database.
|
|
6729
7532
|
*/
|
|
6730
|
-
async function waitForMigration(coolify, appId, runtime, sleep) {
|
|
7533
|
+
async function waitForMigration(coolify, appId, runtime, sleep, label = 'platform:files', appName = 'files-migrate') {
|
|
6731
7534
|
const now = runtime.now ?? Date.now;
|
|
6732
7535
|
const timeout = runtime.migrateTimeoutMs ?? 900_000;
|
|
6733
7536
|
const interval = runtime.migrateIntervalMs ?? 10_000;
|
|
@@ -6740,7 +7543,7 @@ async function waitForMigration(coolify, appId, runtime, sleep) {
|
|
|
6740
7543
|
if (status !== 'running:healthy')
|
|
6741
7544
|
left = true;
|
|
6742
7545
|
if (now() >= deadline) {
|
|
6743
|
-
throw new Error(
|
|
7546
|
+
throw new Error(`${label}: ${appName} (${appId}) did not report healthy within ${Math.round(timeout / 1000)}s (last status: ${status ?? 'unknown'}); the files app was not deployed. Check its logs for "files-migrate: done" and retry this apply`);
|
|
6744
7547
|
}
|
|
6745
7548
|
await sleep(interval);
|
|
6746
7549
|
}
|
|
@@ -6950,6 +7753,8 @@ async function provisionPlatformIntegrations(action, context, deps) {
|
|
|
6950
7753
|
await coolify.updateApplication(runnerApp.appId, {
|
|
6951
7754
|
dockerfile_target_build: INTEGRATIONS_RUNNER_APP.target,
|
|
6952
7755
|
ports_exposes: INTEGRATIONS_RUNNER_PORT,
|
|
7756
|
+
// The runner's Dockerfile HEALTHCHECK (curl on /api/health) is the live check.
|
|
7757
|
+
...IMAGE_HEALTHCHECK_SETTINGS,
|
|
6953
7758
|
git_branch: gitRef,
|
|
6954
7759
|
domains: coolifyDomains([hostname]),
|
|
6955
7760
|
});
|
|
@@ -7163,7 +7968,8 @@ async function provisionPlatformIntegrations(action, context, deps) {
|
|
|
7163
7968
|
keyJustSet ||
|
|
7164
7969
|
runnerKeyStale ||
|
|
7165
7970
|
configChanged) {
|
|
7166
|
-
await waitForNewDeployment(coolify, runnerApp.appId, { kind: 'deployment-only' }, runtime, sleep, 'platform:integrations', `the runner stays at ${recorded[PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
7971
|
+
const built = await waitForNewDeployment(coolify, runnerApp.appId, { kind: 'deployment-only' }, runtime, sleep, 'platform:integrations', `the runner stays at ${recorded[PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
7972
|
+
await recordBuiltCommit(coolify, { appId: runnerApp.appId, deploymentUuid: built.deploymentUuid, name: 'integrations' }, record);
|
|
7167
7973
|
}
|
|
7168
7974
|
// #398: the runner is up with the keyring that holds the active id; only
|
|
7169
7975
|
// now may Archon move to it.
|
|
@@ -7294,7 +8100,7 @@ async function waitForIntegrationsMigration(coolify, appId, runtime, sleep) {
|
|
|
7294
8100
|
/** The name every platform database's backup is registered against -- one shared R2 bucket, one storage entry. */
|
|
7295
8101
|
const PLATFORM_BACKUP_S3_STORAGE_NAME = 'platform-backups';
|
|
7296
8102
|
/** The uuid `provisionPlatformBackup` records once {@link PLATFORM_BACKUP_S3_STORAGE_NAME} is registered on Coolify, so a later apply never re-registers it. */
|
|
7297
|
-
const PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY = 'coolify_platform_backup_s3_storage';
|
|
8103
|
+
export const PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY = 'coolify_platform_backup_s3_storage';
|
|
7298
8104
|
/**
|
|
7299
8105
|
* #257 fix round: a sha256 of the exact `endpoint|bucket|key|secret` the
|
|
7300
8106
|
* storage was last pushed with -- never the values themselves, the same
|
|
@@ -7342,8 +8148,11 @@ const PLATFORM_BACKUP_DATABASE_NAMES = {
|
|
|
7342
8148
|
* {@link repointPlatformBackupSchedules} uses for `s3_storage_uuid`.
|
|
7343
8149
|
*/
|
|
7344
8150
|
async function findPlatformBackupSchedule(coolify, service, databaseId) {
|
|
8151
|
+
return findDatabaseBackupSchedule(coolify, PLATFORM_BACKUP_DATABASE_NAMES[service], databaseId);
|
|
8152
|
+
}
|
|
8153
|
+
/** {@link findPlatformBackupSchedule} for any database; `database` undefined means the resource's first database. */
|
|
8154
|
+
async function findDatabaseBackupSchedule(coolify, database, databaseId) {
|
|
7345
8155
|
const schedules = (await coolify.listDatabaseBackups(databaseId)).filter((backup) => backup.enabled && backup.save_s3);
|
|
7346
|
-
const database = PLATFORM_BACKUP_DATABASE_NAMES[service];
|
|
7347
8156
|
if (!database)
|
|
7348
8157
|
return { schedule: schedules[0], named: true };
|
|
7349
8158
|
for (const schedule of schedules) {
|
|
@@ -7719,23 +8528,58 @@ async function provisionPlatformBackup(action, context, deps) {
|
|
|
7719
8528
|
const recorded = context.recordedIds();
|
|
7720
8529
|
const storageUuid = await ensurePlatformBackupStorage(coolify, r2, recorded, context);
|
|
7721
8530
|
const service = action.key;
|
|
7722
|
-
|
|
7723
|
-
|
|
7724
|
-
|
|
7725
|
-
|
|
8531
|
+
await provisionDatabaseBackup(coolify, {
|
|
8532
|
+
databaseUuid: databaseId,
|
|
8533
|
+
label: `platform ${service}`,
|
|
8534
|
+
storageUuid,
|
|
8535
|
+
frequency: deps.frequency,
|
|
8536
|
+
databaseName: PLATFORM_BACKUP_DATABASE_NAMES[service],
|
|
8537
|
+
});
|
|
8538
|
+
return undefined;
|
|
8539
|
+
}
|
|
8540
|
+
/**
|
|
8541
|
+
* F6: one nightly schedule of one database to a registered S3 storage, the
|
|
8542
|
+
* body `provisionPlatformBackup` had and every company database now shares.
|
|
8543
|
+
* An existing enabled, `save_s3` schedule is reused, never doubled: the lookup
|
|
8544
|
+
* runs before the create, so a run that created a schedule and died before
|
|
8545
|
+
* the caller recorded its uuid finds it again on the next apply. The
|
|
8546
|
+
* `PATCH` names the database to dump, because `createDatabaseBackup` takes no
|
|
8547
|
+
* database name; it also repairs an existing schedule that dumps the wrong
|
|
8548
|
+
* one. Returns the schedule's uuid.
|
|
8549
|
+
*/
|
|
8550
|
+
export async function provisionDatabaseBackup(coolify, input) {
|
|
8551
|
+
const { databaseUuid, label } = input;
|
|
8552
|
+
const { schedule, named } = await backupCall(label, () => findDatabaseBackupSchedule(coolify, input.databaseName, databaseUuid));
|
|
8553
|
+
const uuid = schedule?.uuid ??
|
|
8554
|
+
(await backupCall(label, () => coolify.createDatabaseBackup(databaseUuid, {
|
|
8555
|
+
frequency: input.frequency ?? PLATFORM_BACKUP_DEFAULT_FREQUENCY,
|
|
7726
8556
|
enabled: true,
|
|
7727
8557
|
save_s3: true,
|
|
7728
|
-
s3_storage_uuid: storageUuid,
|
|
7729
|
-
})).uuid;
|
|
7730
|
-
// `CoolifyPort.createDatabaseBackup` takes no database name, so a new
|
|
7731
|
-
// schedule is named here too, by the same PATCH that repairs an existing
|
|
7732
|
-
// one dumping the wrong database.
|
|
8558
|
+
s3_storage_uuid: input.storageUuid,
|
|
8559
|
+
}))).uuid;
|
|
7733
8560
|
if (!named) {
|
|
7734
|
-
await coolify.raw('PATCH', `/databases/${
|
|
7735
|
-
databases_to_backup:
|
|
7736
|
-
});
|
|
8561
|
+
await backupCall(label, () => coolify.raw('PATCH', `/databases/${databaseUuid}/backups/${uuid}`, {
|
|
8562
|
+
databases_to_backup: input.databaseName,
|
|
8563
|
+
}));
|
|
8564
|
+
}
|
|
8565
|
+
return { uuid };
|
|
8566
|
+
}
|
|
8567
|
+
/** How long one Coolify call of a database backup may take. */
|
|
8568
|
+
const BACKUP_CALL_TIMEOUT_MS = 30_000;
|
|
8569
|
+
/** Runs one Coolify call with a timeout; a timeout names the database, not the call's arguments. */
|
|
8570
|
+
async function backupCall(label, call) {
|
|
8571
|
+
let timer;
|
|
8572
|
+
try {
|
|
8573
|
+
return await Promise.race([
|
|
8574
|
+
call(),
|
|
8575
|
+
new Promise((_, reject) => {
|
|
8576
|
+
timer = setTimeout(() => reject(new Error(`backup of ${label}: Coolify did not answer within ${BACKUP_CALL_TIMEOUT_MS / 1000} seconds`)), BACKUP_CALL_TIMEOUT_MS);
|
|
8577
|
+
}),
|
|
8578
|
+
]);
|
|
8579
|
+
}
|
|
8580
|
+
finally {
|
|
8581
|
+
clearTimeout(timer);
|
|
7737
8582
|
}
|
|
7738
|
-
return undefined;
|
|
7739
8583
|
}
|
|
7740
8584
|
/**
|
|
7741
8585
|
* #540: reconciles the live Healthchecks checks against the ones foundry
|
|
@@ -7818,7 +8662,10 @@ async function provisionPlatformHeartbeats(context, deps) {
|
|
|
7818
8662
|
// Last: so a run that failed anywhere above (an unreconciled create or
|
|
7819
8663
|
// delete) leaves this unset and `planHeartbeats` plans the step again --
|
|
7820
8664
|
// the same "record markers last" rule `provisionPlatformHc` follows.
|
|
7821
|
-
|
|
8665
|
+
// foundry#544: the provider is folded in, matching `planHeartbeats`'s own
|
|
8666
|
+
// `desired` -- a provider switch with the same check names otherwise
|
|
8667
|
+
// desired is still a change from this marker.
|
|
8668
|
+
context.recordScopeId(PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, `${context.platform.heartbeatsProvider}:${[...desiredNames].sort().join(',')}`);
|
|
7822
8669
|
return undefined;
|
|
7823
8670
|
}
|
|
7824
8671
|
/**
|
|
@@ -7964,7 +8811,7 @@ export class ProvidersPlatformCoolifyReader {
|
|
|
7964
8811
|
* answers with the parent zone. The platform's zone is its apex exactly;
|
|
7965
8812
|
* any other zone is never adopted or reported as the platform's.
|
|
7966
8813
|
*/
|
|
7967
|
-
async function exactZone(client, apex) {
|
|
8814
|
+
export async function exactZone(client, apex) {
|
|
7968
8815
|
const zone = await client.findZoneByApex(apex);
|
|
7969
8816
|
return zone && zone.name === apex ? zone : undefined;
|
|
7970
8817
|
}
|