@wtfalch/foundry 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/bootstrap.d.ts +111 -0
- package/dist/bootstrap/bootstrap.js +323 -0
- package/dist/bootstrap/declare.d.ts +36 -0
- package/dist/bootstrap/declare.js +99 -0
- package/dist/bootstrap/drive.d.ts +40 -0
- package/dist/bootstrap/drive.js +51 -0
- package/dist/bootstrap/index.d.ts +3 -0
- package/dist/bootstrap/index.js +1 -0
- package/dist/calls-sfu-deploy-support.d.ts +17 -6
- package/dist/calls-sfu-deploy-support.js +19 -7
- package/dist/calls-sfu-keys.d.ts +21 -0
- package/dist/calls-sfu-keys.js +27 -0
- package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
- package/dist/calls-sfu-ssh-adapter.js +78 -0
- package/dist/calls-sfu-ssh-client.d.ts +49 -0
- package/dist/calls-sfu-ssh-client.js +82 -0
- package/dist/calls-sfu.d.ts +36 -12
- package/dist/calls-sfu.js +41 -9
- package/dist/index.d.ts +38 -7
- package/dist/index.js +20 -4
- package/dist/migrations/0016_platform.sql +61 -0
- package/dist/migrations/0017_requested_changes.sql +42 -0
- package/dist/migrations/0018_platform_apply.sql +19 -0
- package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
- package/dist/migrations/0020_mail_apps.sql +7 -0
- package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
- package/dist/org.d.ts +81 -0
- package/dist/org.js +96 -0
- package/dist/plan/agora.d.ts +77 -0
- package/dist/plan/agora.js +127 -0
- package/dist/plan/cloudflared-connector.d.ts +70 -0
- package/dist/plan/cloudflared-connector.js +106 -0
- package/dist/plan/diff.d.ts +20 -3
- package/dist/plan/diff.js +713 -115
- package/dist/plan/executor.d.ts +680 -172
- package/dist/plan/executor.js +755 -189
- package/dist/plan/index.d.ts +28 -5
- package/dist/plan/index.js +15 -2
- package/dist/plan/jobs-scheduler-http.d.ts +29 -0
- package/dist/plan/jobs-scheduler-http.js +50 -0
- package/dist/plan/live.d.ts +51 -2
- package/dist/plan/live.js +8 -0
- package/dist/plan/mail-copy.d.ts +125 -0
- package/dist/plan/mail-copy.js +166 -0
- package/dist/plan/mail-identity-allowlist.d.ts +50 -0
- package/dist/plan/mail-identity-allowlist.js +11 -0
- package/dist/plan/mail-identity.d.ts +68 -0
- package/dist/plan/mail-identity.js +17 -0
- package/dist/plan/plan-apply.d.ts +197 -3
- package/dist/plan/plan-apply.js +278 -9
- package/dist/plan/platform-diff.d.ts +487 -0
- package/dist/plan/platform-diff.js +1220 -0
- package/dist/plan/platform-org-ports.d.ts +50 -0
- package/dist/plan/platform-org-ports.js +70 -0
- package/dist/plan/platform-org.d.ts +121 -0
- package/dist/plan/platform-org.js +115 -0
- package/dist/plan/platform-providers.d.ts +615 -0
- package/dist/plan/platform-providers.js +4410 -0
- package/dist/plan/platform-sfu.d.ts +128 -0
- package/dist/plan/platform-sfu.js +142 -0
- package/dist/plan/providers-readers.js +22 -1
- package/dist/plan/zitadel-machine-user.d.ts +83 -0
- package/dist/plan/zitadel-machine-user.js +238 -0
- package/dist/plan.d.ts +6 -0
- package/dist/platform.d.ts +55 -0
- package/dist/platform.js +46 -0
- package/dist/requested-changes.d.ts +118 -0
- package/dist/requested-changes.js +125 -0
- package/dist/slug.js +15 -2
- package/dist/store/audit.js +4 -2
- package/dist/store/postgres.d.ts +29 -1
- package/dist/store/postgres.js +203 -4
- package/dist/store/schema.d.ts +755 -0
- package/dist/store/schema.js +70 -0
- package/dist/store.d.ts +53 -0
- package/package.json +1 -1
|
@@ -0,0 +1,4410 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from 'node:crypto';
|
|
2
|
+
import { isIPv4 } from 'node:net';
|
|
3
|
+
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
4
|
+
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
5
|
+
import { ACCESS_POLICY_NAME, isNotFoundError, isRunningOrStarting } from './executor.js';
|
|
6
|
+
import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, 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_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_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_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_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_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
|
|
7
|
+
import { applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
8
|
+
/** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
|
|
9
|
+
function apexZoneKey(apex) {
|
|
10
|
+
return `cloudflare:zone:${apex}`;
|
|
11
|
+
}
|
|
12
|
+
/** The key `runPlatformSteps` records the jobs scheduler app's id under (`system:kind:key`, #173). */
|
|
13
|
+
const JOBS_APP_KEY = `coolify:jobs:${JOBS_SCHEDULER_KEY}`;
|
|
14
|
+
/**
|
|
15
|
+
* #173: the jobs scheduler's own Coolify project and database, flat scope
|
|
16
|
+
* ids recorded via `PlatformStepExecutionContext.recordScopeId` -- not
|
|
17
|
+
* `system:kind:key` (that form is reserved for the one resource
|
|
18
|
+
* `runPlatformSteps` auto-records from a step's own `resourceId`, the app
|
|
19
|
+
* itself), the same distinction `executor.ts`'s `coolify_project`/
|
|
20
|
+
* `CALLS_SERVICE_DB_SCOPE_KEY` draw for an org's own steps.
|
|
21
|
+
*/
|
|
22
|
+
const JOBS_PROJECT_SCOPE_KEY = 'platform_coolify_project';
|
|
23
|
+
const JOBS_DATABASE_SCOPE_KEY = 'coolify_jobs_database';
|
|
24
|
+
/**
|
|
25
|
+
* Set (to the app's uuid) once the scheduler's `SCHEDULER_ADMIN_TOKEN` has
|
|
26
|
+
* been delivered to the host and written to the app. The token is only
|
|
27
|
+
* generated while this is unset, so a rerun of a step that failed earlier
|
|
28
|
+
* never rotates a token the host already holds.
|
|
29
|
+
*/
|
|
30
|
+
const JOBS_ADMIN_TOKEN_SCOPE_KEY = 'jobs_admin_token_set';
|
|
31
|
+
/** How many times `coolifyJobs` tries `deliverSecret` before giving up. */
|
|
32
|
+
const JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS = 3;
|
|
33
|
+
/** The ref the scheduler's admin token is delivered under: the host's real `JobsSchedulerPort` adapter reads it back. */
|
|
34
|
+
export const JOBS_ADMIN_TOKEN_REF = 'jobs:scheduler:adminToken';
|
|
35
|
+
/** The name of the Coolify project every platform-level service (starting with jobs, #173) lives in. */
|
|
36
|
+
const PLATFORM_COOLIFY_PROJECT_NAME = 'platform';
|
|
37
|
+
/** https://github.com/wtfalch/jobs -- the scheduler's own repo, Dockerfile at its root (its own `Dockerfile`'s doc comment). */
|
|
38
|
+
const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
|
|
39
|
+
/**
|
|
40
|
+
* The real {@link PlatformStepExecutor} for the two steps built so far:
|
|
41
|
+
* `cloudflare:zone` for the platform's apex (#167) and `coolify:jobs` for the
|
|
42
|
+
* jobs scheduler (#173). `coolify` is optional so every #167-era caller --
|
|
43
|
+
* anything that only ever plans the apex zone -- keeps constructing this with
|
|
44
|
+
* two arguments; a `coolify:jobs` step reaching this executor with no
|
|
45
|
+
* `coolify` port configured throws, naming what is missing, rather than
|
|
46
|
+
* silently doing nothing.
|
|
47
|
+
*
|
|
48
|
+
* `cloudflareZone` mirrors `executor.ts`'s `cloudflareZone`: find the zone by
|
|
49
|
+
* apex and adopt it, create it only when absent. companyincluded.com already
|
|
50
|
+
* exists on Cloudflare, empty, so the first apply adopts it. The owner org
|
|
51
|
+
* (Company Included, whose own domain is the apex) may record the same zone
|
|
52
|
+
* under its own applied state, so that owner is allowed. Any other org that
|
|
53
|
+
* recorded it makes this refuse to adopt. There is no delete here and
|
|
54
|
+
* `PlatformStepExecutor` has none: neither path ever removes the zone.
|
|
55
|
+
*
|
|
56
|
+
* `coolifyJobs` mirrors `executor.ts`'s `coolifyApp`/`coolifyDatabase`,
|
|
57
|
+
* simplified for one platform-level singleton rather than one per org: find
|
|
58
|
+
* or create a dedicated `platform` Coolify project, find or create the
|
|
59
|
+
* scheduler's own database, find or create the scheduler app itself (from
|
|
60
|
+
* https://github.com/wtfalch/jobs, one Dockerfile at its repo root, on the
|
|
61
|
+
* declared `gitRef`), then push the database's `internal_db_url` in as
|
|
62
|
+
* `DATABASE_URL`. There is no park branch yet, matching `planJobs`'s own
|
|
63
|
+
* scope note -- `platform.jobs.enabled` going from true to false plans
|
|
64
|
+
* nothing, so this method is never asked to stop or delete anything.
|
|
65
|
+
*
|
|
66
|
+
* #170 adds `sfu:livekit`, the platform's shared calls SFU. `sfu` is
|
|
67
|
+
* optional: an existing host that constructs this executor for the zone step
|
|
68
|
+
* alone keeps compiling, and a plan that includes an `sfu:livekit` step
|
|
69
|
+
* without it configured refuses clearly rather than silently doing nothing.
|
|
70
|
+
* #152: the step mints one LiveKit key per org with calls enabled rather
|
|
71
|
+
* than one shared pair, so it also needs `declaredOrgStore.list()` -- the
|
|
72
|
+
* same store this class already takes for `findOrgIdForZone`.
|
|
73
|
+
*
|
|
74
|
+
* #168 adds `coolify:app` keyed {@link PLATFORM_AUTH_KEY}, the platform's own
|
|
75
|
+
* sign-in ({@link provisionPlatformAuth}), once a host passes
|
|
76
|
+
* {@link PlatformAuthDeps}.
|
|
77
|
+
*
|
|
78
|
+
* #169 adds `coolify:app` keyed {@link PLATFORM_MAIL_KEY}, the platform's own
|
|
79
|
+
* mail server ({@link provisionPlatformMail}), once a host passes
|
|
80
|
+
* {@link PlatformMailDeps}.
|
|
81
|
+
*
|
|
82
|
+
* #174 adds `coolify:app` keyed {@link PLATFORM_INTEGRATIONS_KEY},
|
|
83
|
+
* the integrations host ({@link provisionPlatformIntegrations}), once a host
|
|
84
|
+
* passes {@link PlatformIntegrationsDeps}.
|
|
85
|
+
*
|
|
86
|
+
* #171 adds `coolify:app` keyed {@link PLATFORM_FILES_KEY}, the files host
|
|
87
|
+
* ({@link provisionPlatformFiles}), once a host passes {@link PlatformFilesDeps}.
|
|
88
|
+
*
|
|
89
|
+
* #172 adds `coolify:app` keyed {@link PLATFORM_AI_KEY}, the platform's AI
|
|
90
|
+
* host ({@link provisionPlatformAi}), once a host passes {@link PlatformAiDeps}.
|
|
91
|
+
*
|
|
92
|
+
* #175 adds `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, Archon itself
|
|
93
|
+
* ({@link provisionPlatformArchon}), once a host passes
|
|
94
|
+
* {@link PlatformArchonDeps}.
|
|
95
|
+
*/
|
|
96
|
+
export class FoundryPlatformStepExecutor {
|
|
97
|
+
cloudflare;
|
|
98
|
+
declaredOrgStore;
|
|
99
|
+
coolify;
|
|
100
|
+
deliverSecret;
|
|
101
|
+
sfu;
|
|
102
|
+
auth;
|
|
103
|
+
mail;
|
|
104
|
+
integrations;
|
|
105
|
+
files;
|
|
106
|
+
ai;
|
|
107
|
+
archon;
|
|
108
|
+
constructor(cloudflare, declaredOrgStore, options = {}) {
|
|
109
|
+
this.cloudflare = cloudflare;
|
|
110
|
+
this.declaredOrgStore = declaredOrgStore;
|
|
111
|
+
this.coolify = options.coolify;
|
|
112
|
+
this.deliverSecret = options.deliverSecret;
|
|
113
|
+
this.sfu = options.sfu;
|
|
114
|
+
this.auth = options.auth;
|
|
115
|
+
this.mail = options.mail;
|
|
116
|
+
this.integrations = options.integrations;
|
|
117
|
+
this.files = options.files;
|
|
118
|
+
this.ai = options.ai;
|
|
119
|
+
this.archon = options.archon;
|
|
120
|
+
}
|
|
121
|
+
async blockedSteps(platform, recordedIds = {}) {
|
|
122
|
+
const blocked = {};
|
|
123
|
+
if (platform.auth.enabled && !this.auth) {
|
|
124
|
+
blocked.auth =
|
|
125
|
+
"the platform's sign-in is blocked, not planned: the host built the platform executor without its auth dependencies";
|
|
126
|
+
// #211 round 6: its own signal, distinct from `auth` -- `planAuth` must
|
|
127
|
+
// withhold the step for this case whatever the recorded broker state
|
|
128
|
+
// says, unlike "broker created, deploy still blocked" (below).
|
|
129
|
+
blocked.authExecutorMissing = true;
|
|
130
|
+
}
|
|
131
|
+
else if (platform.auth.enabled && this.auth) {
|
|
132
|
+
const { reason: authBlocked, otherInputsMissing } = await authBrokerBlockedReason(this.auth, recordedIds);
|
|
133
|
+
if (authBlocked)
|
|
134
|
+
blocked.auth = authBlocked;
|
|
135
|
+
// #216 round 5: see `PlanPlatformOptions.blocked.authOtherInputsMissing`.
|
|
136
|
+
if (otherInputsMissing.length > 0)
|
|
137
|
+
blocked.authOtherInputsMissing = otherInputsMissing;
|
|
138
|
+
}
|
|
139
|
+
if (platform.mail.enabled) {
|
|
140
|
+
const mailBlocked = await mailBlockedReason(platform, this.mail, recordedIds);
|
|
141
|
+
if (mailBlocked)
|
|
142
|
+
blocked.mail = mailBlocked;
|
|
143
|
+
}
|
|
144
|
+
if (platform.integrations.enabled && !this.integrations) {
|
|
145
|
+
blocked.integrations =
|
|
146
|
+
'the integrations host is blocked, not planned: the host built the platform executor without its integrations dependencies';
|
|
147
|
+
}
|
|
148
|
+
else if (platform.integrations.enabled && this.integrations) {
|
|
149
|
+
const missing = INTEGRATIONS_KEYS_ENV_NAMES.filter((n) => !this.integrations?.keysEnv?.[n]);
|
|
150
|
+
if (missing.length > 0) {
|
|
151
|
+
blocked.integrations = `the integrations host is blocked, not planned: the host supplies no value for ${missing.join(', ')}, the host's own @wtfalch/keys settings`;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (platform.files.enabled && !this.files) {
|
|
155
|
+
blocked.files =
|
|
156
|
+
'the files host is blocked, not planned: the host built the platform executor without its files dependencies';
|
|
157
|
+
}
|
|
158
|
+
else if (platform.files.enabled && !filesKeys(this.files)) {
|
|
159
|
+
blocked.files =
|
|
160
|
+
'the files host is blocked, not planned: the host supplies no keys worker URL and credential (KEYS_WORKER_URL, KEYS_CREDENTIAL), so files-migrate would never become healthy';
|
|
161
|
+
}
|
|
162
|
+
if (platform.ai.enabled && !this.ai) {
|
|
163
|
+
blocked.ai =
|
|
164
|
+
"the platform's AI host is blocked, not planned: the host built the platform executor without its ai dependencies";
|
|
165
|
+
}
|
|
166
|
+
else if (platform.ai.enabled &&
|
|
167
|
+
!this.ai?.managementKeyMinter &&
|
|
168
|
+
!this.ai?.heldKeys &&
|
|
169
|
+
recordedIds[PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY] &&
|
|
170
|
+
!recordedIds[PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY]) {
|
|
171
|
+
// The signing pair was pinned on an earlier apply; minting the
|
|
172
|
+
// management key needs its private half read back, and no minter was
|
|
173
|
+
// injected to skip that (#201).
|
|
174
|
+
blocked.ai =
|
|
175
|
+
"the platform's AI host is blocked, not planned: its signing key is already pinned but the host built the platform executor without a held-key reader to read back its private half (PlatformAiDeps.heldKeys)";
|
|
176
|
+
}
|
|
177
|
+
if (platform.archon.enabled) {
|
|
178
|
+
if (!this.archon) {
|
|
179
|
+
blocked.archon =
|
|
180
|
+
'Archon is blocked, not planned: the host built the platform executor without its archon dependencies';
|
|
181
|
+
}
|
|
182
|
+
else if (this.archon.accessEmails.length === 0) {
|
|
183
|
+
blocked.archon =
|
|
184
|
+
'Archon is blocked, not planned: the host names no email the Cloudflare Access app in front of it may let through';
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
const inputs = await collectArchonServiceInputs(platform, this.archon, recordedIds);
|
|
188
|
+
if ('blocked' in inputs) {
|
|
189
|
+
blocked.archon = inputs.blocked;
|
|
190
|
+
}
|
|
191
|
+
else if (!this.archon.masterKeys || !this.archon.heldKeys?.put) {
|
|
192
|
+
// #213: Archon's own onboarding reads these back as held keys
|
|
193
|
+
// before it can start; a host that cannot put them blocks Archon,
|
|
194
|
+
// the same rule a missing service ref follows above.
|
|
195
|
+
blocked.archon =
|
|
196
|
+
'Archon is blocked, not planned: the host built the platform executor without a way to put its master keys (masterKeys, and heldKeys with a put)';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return blocked;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Whether Archon's live Access policy lets through anyone but the declared
|
|
204
|
+
* emails, or (#211 round 4) the broker it last registered with no longer
|
|
205
|
+
* carries its entry; or (#216 round 4) the platform's recorded
|
|
206
|
+
* login-client token is no longer the sole live PAT on its ZITADEL machine
|
|
207
|
+
* user.
|
|
208
|
+
*/
|
|
209
|
+
async driftedSteps(platform, recordedIds = {}) {
|
|
210
|
+
const result = {};
|
|
211
|
+
if (platform.archon.enabled &&
|
|
212
|
+
this.archon &&
|
|
213
|
+
recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]) {
|
|
214
|
+
const accessDrifted = await archonAccessDrifted(this.archon.cloudflare, platform.archon.hostname, this.archon.accessEmails);
|
|
215
|
+
const brokerDrifted = !accessDrifted && (await archonBrokerRegistrationDrifted(this.archon.coolify, recordedIds));
|
|
216
|
+
if (accessDrifted || brokerDrifted)
|
|
217
|
+
result.archon = true;
|
|
218
|
+
}
|
|
219
|
+
if (platform.auth.enabled && this.auth) {
|
|
220
|
+
const loginClientDrifted = await authLoginClientDrifted(platform, this.auth, recordedIds);
|
|
221
|
+
if (loginClientDrifted)
|
|
222
|
+
result.auth = true;
|
|
223
|
+
}
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
async execute(action, context) {
|
|
227
|
+
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_AUTH_KEY) {
|
|
228
|
+
if (!this.auth) {
|
|
229
|
+
throw new Error('platform:auth needs FoundryPlatformStepExecutor to be constructed with its auth dependencies (#168)');
|
|
230
|
+
}
|
|
231
|
+
return provisionPlatformAuth(context, this.auth);
|
|
232
|
+
}
|
|
233
|
+
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_FILES_KEY) {
|
|
234
|
+
if (!this.files) {
|
|
235
|
+
throw new Error('platform:files needs FoundryPlatformStepExecutor to be constructed with its files dependencies (#171)');
|
|
236
|
+
}
|
|
237
|
+
return provisionPlatformFiles(context, this.files);
|
|
238
|
+
}
|
|
239
|
+
if (action.system === 'coolify' &&
|
|
240
|
+
action.kind === 'app' &&
|
|
241
|
+
action.key === PLATFORM_INTEGRATIONS_KEY) {
|
|
242
|
+
if (!this.integrations) {
|
|
243
|
+
throw new Error('platform:integrations needs FoundryPlatformStepExecutor to be constructed with its integrations dependencies (#174)');
|
|
244
|
+
}
|
|
245
|
+
return provisionPlatformIntegrations(action, context, this.integrations);
|
|
246
|
+
}
|
|
247
|
+
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_AI_KEY) {
|
|
248
|
+
if (!this.ai) {
|
|
249
|
+
throw new Error('platform:ai needs FoundryPlatformStepExecutor to be constructed with its ai dependencies (#172)');
|
|
250
|
+
}
|
|
251
|
+
return provisionPlatformAi(context, this.ai);
|
|
252
|
+
}
|
|
253
|
+
if (action.system === 'coolify' &&
|
|
254
|
+
action.kind === 'app' &&
|
|
255
|
+
action.key === PLATFORM_ARCHON_KEY) {
|
|
256
|
+
if (!this.archon) {
|
|
257
|
+
throw new Error('platform:archon needs FoundryPlatformStepExecutor to be constructed with its archon dependencies (#175)');
|
|
258
|
+
}
|
|
259
|
+
return provisionPlatformArchon(context, this.archon);
|
|
260
|
+
}
|
|
261
|
+
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_MAIL_KEY) {
|
|
262
|
+
if (!this.mail) {
|
|
263
|
+
throw new Error('platform:mail needs FoundryPlatformStepExecutor to be constructed with its mail dependencies (#169)');
|
|
264
|
+
}
|
|
265
|
+
return provisionPlatformMail(context, this.mail);
|
|
266
|
+
}
|
|
267
|
+
if (action.system === 'cloudflare' && action.kind === 'zone') {
|
|
268
|
+
return this.cloudflareZone(action, context);
|
|
269
|
+
}
|
|
270
|
+
if (action.system === 'coolify' && action.kind === 'jobs') {
|
|
271
|
+
return this.coolifyJobs(action, context);
|
|
272
|
+
}
|
|
273
|
+
if (action.system === 'sfu' && action.kind === 'livekit') {
|
|
274
|
+
return this.executeSfu();
|
|
275
|
+
}
|
|
276
|
+
throw new Error(`platform step ${action.system}:${action.kind} is not built yet`);
|
|
277
|
+
}
|
|
278
|
+
async cloudflareZone(action, context) {
|
|
279
|
+
const domain = action.key;
|
|
280
|
+
const existing = await exactZone(this.cloudflare, domain);
|
|
281
|
+
if (existing) {
|
|
282
|
+
const owner = await this.declaredOrgStore.findOrgIdForZone(existing.id);
|
|
283
|
+
if (owner !== null && owner !== context.platform.ownerOrgId) {
|
|
284
|
+
throw new Error(`cloudflare:zone for the platform: zone ${existing.id} (${domain}) is already recorded under org ${owner}; foundry does not adopt it`);
|
|
285
|
+
}
|
|
286
|
+
return { resourceId: existing.id };
|
|
287
|
+
}
|
|
288
|
+
const created = await this.cloudflare.createZone(domain);
|
|
289
|
+
return { resourceId: created.id };
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* #152: the platform `sfu` step owns the shared LiveKit config, so it
|
|
293
|
+
* lists every org with calls enabled itself -- `DeclaredOrgStore.list()`,
|
|
294
|
+
* filtered the same way an org's own `calls.enabled` flag gates its
|
|
295
|
+
* `coolify:calls-service` step (`executor.ts`'s `coolifyCallsService`) --
|
|
296
|
+
* rather than taking the list as an argument nothing else would supply.
|
|
297
|
+
* Sorted by slug so a re-run with the same set of enabled orgs is a no-op
|
|
298
|
+
* even before `applyCallsSfuPlatformStep`'s own sort (its doc comment).
|
|
299
|
+
*/
|
|
300
|
+
async executeSfu() {
|
|
301
|
+
if (!this.sfu) {
|
|
302
|
+
throw new Error('platform step sfu:livekit is not built yet (#170: pass `sfu` to FoundryPlatformStepExecutor)');
|
|
303
|
+
}
|
|
304
|
+
const orgs = await this.declaredOrgStore.list();
|
|
305
|
+
const orgSlugs = orgs
|
|
306
|
+
.filter((org) => org.calls.enabled)
|
|
307
|
+
.map((org) => org.slug)
|
|
308
|
+
.sort();
|
|
309
|
+
await applyCallsSfuPlatformStep(this.sfu, orgSlugs);
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
async coolifyJobs(action, context) {
|
|
313
|
+
const coolify = this.coolify;
|
|
314
|
+
if (!coolify) {
|
|
315
|
+
throw new Error('platform step coolify:jobs needs a Coolify port -- construct FoundryPlatformStepExecutor with one (#173)');
|
|
316
|
+
}
|
|
317
|
+
const deliverSecret = this.deliverSecret;
|
|
318
|
+
if (!deliverSecret) {
|
|
319
|
+
throw new Error('platform step coolify:jobs needs a deliverSecret -- construct FoundryPlatformStepExecutor with one, to hand the scheduler admin token to the host (#173)');
|
|
320
|
+
}
|
|
321
|
+
const hostname = action.desired.hostname;
|
|
322
|
+
const gitRef = action.desired.gitRef;
|
|
323
|
+
const recorded = context.recordedIds();
|
|
324
|
+
let projectUuid = recorded[JOBS_PROJECT_SCOPE_KEY];
|
|
325
|
+
if (!projectUuid) {
|
|
326
|
+
const found = await coolify.findProjectByName(PLATFORM_COOLIFY_PROJECT_NAME);
|
|
327
|
+
projectUuid = found
|
|
328
|
+
? found.uuid
|
|
329
|
+
: (await coolify.createProject(PLATFORM_COOLIFY_PROJECT_NAME)).uuid;
|
|
330
|
+
context.recordScopeId(JOBS_PROJECT_SCOPE_KEY, projectUuid);
|
|
331
|
+
}
|
|
332
|
+
const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
333
|
+
let dbId = recorded[JOBS_DATABASE_SCOPE_KEY];
|
|
334
|
+
if (!dbId) {
|
|
335
|
+
const dbName = `${JOBS_SCHEDULER_KEY}-scheduler-db`;
|
|
336
|
+
const found = (await coolify.listDatabases()).find((db) => db.name === dbName && db.environment_id === environment.id);
|
|
337
|
+
if (found) {
|
|
338
|
+
dbId = found.uuid;
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
const server = await resolvePlatformServer(coolify);
|
|
342
|
+
const created = await coolify.createPostgresDatabase({
|
|
343
|
+
project_uuid: projectUuid,
|
|
344
|
+
server_uuid: server.uuid,
|
|
345
|
+
environment_name: environment.name,
|
|
346
|
+
environment_uuid: environment.uuid,
|
|
347
|
+
name: dbName,
|
|
348
|
+
postgres_db: 'jobs',
|
|
349
|
+
postgres_user: 'jobs',
|
|
350
|
+
});
|
|
351
|
+
dbId = created.uuid;
|
|
352
|
+
}
|
|
353
|
+
context.recordScopeId(JOBS_DATABASE_SCOPE_KEY, dbId);
|
|
354
|
+
}
|
|
355
|
+
const existingApp = await coolify.findApplicationByName(JOBS_SCHEDULER_KEY, {
|
|
356
|
+
environmentIds: [environment.id],
|
|
357
|
+
});
|
|
358
|
+
let appId;
|
|
359
|
+
if (existingApp) {
|
|
360
|
+
appId = existingApp.uuid;
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
const server = await resolvePlatformServer(coolify);
|
|
364
|
+
const githubApp = await coolify.requireGithubApp();
|
|
365
|
+
const created = await coolify.createGithubAppApplication({
|
|
366
|
+
project_uuid: projectUuid,
|
|
367
|
+
server_uuid: server.uuid,
|
|
368
|
+
environment_name: environment.name,
|
|
369
|
+
environment_uuid: environment.uuid,
|
|
370
|
+
github_app_uuid: githubApp.uuid,
|
|
371
|
+
git_repository: JOBS_SCHEDULER_REPO,
|
|
372
|
+
git_branch: gitRef,
|
|
373
|
+
build_pack: 'dockerfile',
|
|
374
|
+
name: JOBS_SCHEDULER_KEY,
|
|
375
|
+
domains: hostname,
|
|
376
|
+
});
|
|
377
|
+
appId = created.uuid;
|
|
378
|
+
}
|
|
379
|
+
const db = await coolify.getDatabase(dbId);
|
|
380
|
+
if (!db.internal_db_url) {
|
|
381
|
+
throw new Error(`platform step coolify:jobs: database ${dbId} has no internal_db_url yet; not deploying the scheduler without a database`);
|
|
382
|
+
}
|
|
383
|
+
const envs = [{ key: 'DATABASE_URL', value: db.internal_db_url }];
|
|
384
|
+
// SCHEDULER_ADMIN_TOKEN gates the admin API the org registrations use
|
|
385
|
+
// (jobs README, "The admin API"); unset, every admin route answers 401.
|
|
386
|
+
// Delivered to the host before it is written to the app, so a failed
|
|
387
|
+
// delivery leaves nothing set and the rerun simply generates a new one.
|
|
388
|
+
let tokenJustSet = false;
|
|
389
|
+
if (!context.recordedIds()[JOBS_ADMIN_TOKEN_SCOPE_KEY]) {
|
|
390
|
+
const token = randomBytes(32).toString('hex');
|
|
391
|
+
let delivered = false;
|
|
392
|
+
for (let attempt = 1; attempt <= JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS && !delivered; attempt++) {
|
|
393
|
+
try {
|
|
394
|
+
await deliverSecret(JOBS_ADMIN_TOKEN_REF, token);
|
|
395
|
+
delivered = true;
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
// Retried; the error is dropped in case it echoes the token.
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (!delivered) {
|
|
402
|
+
throw new Error(`platform step coolify:jobs: the scheduler admin token could not be delivered after ${JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS} attempts`);
|
|
403
|
+
}
|
|
404
|
+
envs.push({ key: 'SCHEDULER_ADMIN_TOKEN', value: token });
|
|
405
|
+
await coolify.bulkUpsertEnvs(appId, envs);
|
|
406
|
+
context.recordScopeId(JOBS_ADMIN_TOKEN_SCOPE_KEY, appId);
|
|
407
|
+
tokenJustSet = true;
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
await coolify.bulkUpsertEnvs(appId, envs);
|
|
411
|
+
}
|
|
412
|
+
// The app is created with `instant_deploy: false` and an env change needs
|
|
413
|
+
// a redeploy, so deploy after a create or a new token. Also when it is not
|
|
414
|
+
// running, so a deploy that failed on an earlier run is retried.
|
|
415
|
+
const running = (await coolify.getApplication(appId)).status?.startsWith('running') ?? false;
|
|
416
|
+
if (!existingApp || tokenJustSet || !running)
|
|
417
|
+
await coolify.deploy(appId);
|
|
418
|
+
return { resourceId: appId };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// #168: the platform's own sign-in (ZITADEL)
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
const PLATFORM_AUTH_PROJECT = 'platform';
|
|
425
|
+
const PLATFORM_AUTH_DB_NAME = 'platform-auth-db';
|
|
426
|
+
const PLATFORM_AUTH_ZITADEL_APP_NAME = 'platform-auth-zitadel';
|
|
427
|
+
const PLATFORM_AUTH_LOGIN_APP_NAME = 'platform-auth-login';
|
|
428
|
+
const PLATFORM_AUTH_BROKER_APP_NAME = 'platform-auth-broker';
|
|
429
|
+
/**
|
|
430
|
+
* Fixed `custom_internal_name`s (`diff.ts`'s `coolifyCalls` sets the same
|
|
431
|
+
* kind of name on Synapse/MAS) so the Cloudflare tunnel below can address
|
|
432
|
+
* each app directly, by container name, on the shared Coolify Docker network
|
|
433
|
+
* -- bypassing Coolify's own Traefik, which dispatches purely by `Host`
|
|
434
|
+
* header and so cannot give two different apps the same public hostname.
|
|
435
|
+
*/
|
|
436
|
+
const PLATFORM_AUTH_ZITADEL_INTERNAL_NAME = 'platform-auth-zitadel';
|
|
437
|
+
const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = 'platform-auth-login';
|
|
438
|
+
const PLATFORM_AUTH_BROKER_INTERNAL_NAME = 'platform-auth-broker';
|
|
439
|
+
const PLATFORM_AUTH_TUNNEL_NAME = 'platform-auth-tunnel';
|
|
440
|
+
/** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_AUTH_TUNNEL_NAME}. */
|
|
441
|
+
const PLATFORM_AUTH_CLOUDFLARED_APP_NAME = 'platform-auth-cloudflared';
|
|
442
|
+
/** `wtfalch/auth`'s `docker-compose.yml`: `image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION:-v4.17.3}`. */
|
|
443
|
+
const AUTH_ZITADEL_IMAGE = 'ghcr.io/zitadel/zitadel';
|
|
444
|
+
const AUTH_ZITADEL_IMAGE_TAG = 'v4.17.3';
|
|
445
|
+
/** `wtfalch/auth`'s own `login/Dockerfile` -- `docs/deploy.md`'s "One compose service, four containers... Two images are built from this repository: the proxy... and the login". */
|
|
446
|
+
const AUTH_REPO = 'wtfalch/auth';
|
|
447
|
+
const AUTH_LOGIN_DOCKERFILE = 'login/Dockerfile';
|
|
448
|
+
/** `wtfalch/auth`'s own `broker/Dockerfile` -- built from the repo root so the workspace resolves (#204). */
|
|
449
|
+
const AUTH_BROKER_DOCKERFILE = 'broker/Dockerfile';
|
|
450
|
+
/**
|
|
451
|
+
* `wtfalch/auth`'s login app answers on 3000 (its `Dockerfile`'s Next.js
|
|
452
|
+
* default); ZITADEL answers on 8080 -- both fixed by `docker-compose.yml`'s
|
|
453
|
+
* own `ZITADEL_PORT: 8080` and the login image's own `EXPOSE`.
|
|
454
|
+
*/
|
|
455
|
+
const AUTH_ZITADEL_PORT = '8080';
|
|
456
|
+
const AUTH_LOGIN_PORT = '3000';
|
|
457
|
+
/** `broker/Dockerfile`'s own `ENV PORT=3000` / `EXPOSE 3000`. */
|
|
458
|
+
const AUTH_BROKER_PORT = '3000';
|
|
459
|
+
/**
|
|
460
|
+
* Every ZITADEL OIDC/management/console/asset path -- `deploy/proxy/Caddyfile`'s
|
|
461
|
+
* own explicit list, restated here as one regex since the tunnel routes by a
|
|
462
|
+
* single `path` per ingress rule rather than a set. Anything not matching
|
|
463
|
+
* falls through to the login app's own ingress rule below (login v2 answers
|
|
464
|
+
* everything else, including the root).
|
|
465
|
+
*/
|
|
466
|
+
const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|system|auth|assets|ui/console|\\.well-known)(/|$)';
|
|
467
|
+
/**
|
|
468
|
+
* `@wtfalch/auth`'s own `brokerUrl` default, `<issuer>/api`
|
|
469
|
+
* (`packages/auth/src/config.ts`) -- `deploy/proxy/Caddyfile`'s own
|
|
470
|
+
* `handle /api/*`. Checked before {@link AUTH_ZITADEL_PATH_PATTERN}'s
|
|
471
|
+
* catch-all-else login rule below, so it must be its own ingress rule rather
|
|
472
|
+
* than folded into it.
|
|
473
|
+
*/
|
|
474
|
+
const AUTH_BROKER_PATH_PATTERN = '^/api(/|$)';
|
|
475
|
+
const PLATFORM_AUTH_PROJECT_SCOPE_KEY = 'coolify_project_platform_auth';
|
|
476
|
+
const PLATFORM_AUTH_DB_SCOPE_KEY = 'coolify_platform_auth_database';
|
|
477
|
+
const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = 'coolify_platform_auth_zitadel_app';
|
|
478
|
+
const PLATFORM_AUTH_LOGIN_SCOPE_KEY = 'coolify_platform_auth_login_app';
|
|
479
|
+
const PLATFORM_AUTH_TUNNEL_SCOPE_KEY = 'cloudflare_platform_auth_tunnel';
|
|
480
|
+
/** Recorded once the ZITADEL app has been deployed, so ZITADEL may have written to its database. */
|
|
481
|
+
const PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY = 'zitadel_platform_auth_deployed';
|
|
482
|
+
const PLATFORM_AUTH_ADMIN_USER_NAME = 'foundry-admin';
|
|
483
|
+
/** #216 (building on #209/#215): the narrower `IAM_LOGIN_CLIENT` machine user for the login app and the broker. */
|
|
484
|
+
const PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME = 'foundry-login-client';
|
|
485
|
+
const PLATFORM_AUTH_ORG_NAME = 'foundry';
|
|
486
|
+
/**
|
|
487
|
+
* Retries `action` up to `attempts` times, sleeping increasingly between
|
|
488
|
+
* tries -- the same shape the admin machine user's own delivery retry (just
|
|
489
|
+
* below) already uses inline, pulled out here because #216's login-client
|
|
490
|
+
* block needs it four times over (both apps' envs, both apps' deploys)
|
|
491
|
+
* rather than once. Rethrows the *last* failure unwrapped: callers that must
|
|
492
|
+
* never echo the underlying error (a Coolify validation error can include
|
|
493
|
+
* the rejected value -- here, the token itself, #216 follow-up review item
|
|
494
|
+
* 1) catch this function's own rejection and build a safe message instead of
|
|
495
|
+
* reading `.message` off what it throws.
|
|
496
|
+
*/
|
|
497
|
+
async function retryPlatformAuthCall(attempts, sleep, action) {
|
|
498
|
+
for (let attempt = 1;; attempt += 1) {
|
|
499
|
+
try {
|
|
500
|
+
return await action();
|
|
501
|
+
}
|
|
502
|
+
catch (e) {
|
|
503
|
+
if (attempt >= attempts)
|
|
504
|
+
throw e;
|
|
505
|
+
await sleep(1000 * attempt);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* #216 round 5: sha256 of the exact login-client token value -- never the
|
|
511
|
+
* token itself, only its hash, the same `hashAuthApps` rule for the same
|
|
512
|
+
* reason: a recorded scope id must never leak a secret.
|
|
513
|
+
*/
|
|
514
|
+
function hashLoginClientToken(token) {
|
|
515
|
+
return createHash('sha256').update(token).digest('hex');
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* #216 round 4/5: encodes the {@link PlatformAuthLoginClientRecord} a
|
|
519
|
+
* successful login-client delivery writes under
|
|
520
|
+
* {@link PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY}, replacing the plain
|
|
521
|
+
* user-id string round 2-3 wrote there. Paired with
|
|
522
|
+
* {@link parseLoginClientRecord} (`platform-diff.ts`), which `planAuth`
|
|
523
|
+
* reads back to answer whether the delivered PAT is still live, and with
|
|
524
|
+
* {@link authLoginClientDrifted} below, which (round 5) also reads `token`'s
|
|
525
|
+
* hash back to answer whether what was actually delivered is still live.
|
|
526
|
+
*/
|
|
527
|
+
function loginClientRecordValue(userId, patId, token) {
|
|
528
|
+
const record = {
|
|
529
|
+
userId,
|
|
530
|
+
patId,
|
|
531
|
+
tokenHash: hashLoginClientToken(token),
|
|
532
|
+
};
|
|
533
|
+
return JSON.stringify(record);
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* #216 round 4: whether the login-client PAT recorded for this platform
|
|
537
|
+
* (under {@link PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY}) is still, right
|
|
538
|
+
* now in ZITADEL, the one and only live PAT on that machine user. `false`
|
|
539
|
+
* when nothing is recorded yet -- that is "unapplied," which `planAuth`
|
|
540
|
+
* already plans for without this function's help, not drift.
|
|
541
|
+
*
|
|
542
|
+
* Every real mint revokes every other PAT on the user first
|
|
543
|
+
* (`zitadel-machine-user.ts`), so "exactly one live PAT, and it is the
|
|
544
|
+
* recorded one" is never a false negative: a concurrent apply's own mint
|
|
545
|
+
* landing at any point -- before this platform's last delivery, after it, or
|
|
546
|
+
* anywhere in between -- always leaves the recorded PAT either gone or
|
|
547
|
+
* joined by one it isn't. This is what lets `provisionPlatformAuth` stop
|
|
548
|
+
* trying to catch that interleaving from inside the run that delivers the
|
|
549
|
+
* token (round 2's check-after, round 3's check-before-every-write, each
|
|
550
|
+
* confirmed by review to still leave a window): whichever run's write
|
|
551
|
+
* actually won, the next plan's call here sees it and replans.
|
|
552
|
+
*
|
|
553
|
+
* #216 round 5: that ZITADEL-only check is itself not enough. Ordering that
|
|
554
|
+
* breaks it (confirmed by review): run A mints PAT a and is about to
|
|
555
|
+
* deliver; run B's own mint revokes a and mints PAT b first, delivers b to
|
|
556
|
+
* both apps, and records {b}; A's slower delivery of a then lands *last*,
|
|
557
|
+
* overwriting both apps' envs with the now-dead a, and A records {a} after
|
|
558
|
+
* B. Final state: ZITADEL's only live PAT is b, which is NOT what either
|
|
559
|
+
* record ends up naming last (a) -- so a check against ZITADEL's live PATs
|
|
560
|
+
* alone, compared only to whichever record happens to be the one read, can
|
|
561
|
+
* land on either side of this by accident. The fix is to also compare what
|
|
562
|
+
* is actually live in Coolify: `record.tokenHash` is a hash of the exact
|
|
563
|
+
* token this run itself delivered, and if what is live in Coolify right now
|
|
564
|
+
* does not hash to it, the record does not describe reality, whatever
|
|
565
|
+
* ZITADEL's PAT list says. Read fresh, every plan: a corrupted Coolify env
|
|
566
|
+
* is not something minting again fixes by itself, so if this is wrong, a
|
|
567
|
+
* later apply's own mint-and-redeliver is what actually repairs it, and the
|
|
568
|
+
* only way to know to run one is to keep checking.
|
|
569
|
+
*
|
|
570
|
+
* #216 round 5: reads the ZITADEL org id back from
|
|
571
|
+
* {@link PLATFORM_ZITADEL_ORG_SCOPE_KEY} rather than calling
|
|
572
|
+
* `zitadel.findOrCreateOrg` -- a plan must not create anything, and
|
|
573
|
+
* `findOrCreateOrg`'s name says it might. `provisionPlatformAuth` records
|
|
574
|
+
* that key itself now (mirroring `provisionPlatformArchon`), so it is always
|
|
575
|
+
* present by the time a login-client record exists to check. Any of the
|
|
576
|
+
* three ids this needs (`org`, the login app, the broker) missing is not a
|
|
577
|
+
* state this function can verify against, so it reads as drifted -- the
|
|
578
|
+
* next apply's own mint-and-deliver backfills all three.
|
|
579
|
+
*
|
|
580
|
+
* #216 round 5: wrapped in `try`/`catch` -- ZITADEL or Coolify being
|
|
581
|
+
* unreachable must read as "unknown, not drifted," the same rule
|
|
582
|
+
* `archonAccessDrifted`/`archonBrokerRegistrationDrifted` already follow and
|
|
583
|
+
* for the same reason (a plan must not fail outright over a transient read),
|
|
584
|
+
* but logged here: silently treating a real outage as healthy is a worse
|
|
585
|
+
* miss for a credential-liveness check than for Archon's own Access policy.
|
|
586
|
+
*/
|
|
587
|
+
async function authLoginClientDrifted(platform, auth, recordedIds) {
|
|
588
|
+
const record = parseLoginClientRecord(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
|
|
589
|
+
if (!record)
|
|
590
|
+
return false;
|
|
591
|
+
const orgId = recordedIds[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
|
|
592
|
+
const loginAppId = recordedIds[PLATFORM_AUTH_LOGIN_SCOPE_KEY];
|
|
593
|
+
const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
|
|
594
|
+
if (!orgId || !loginAppId || !brokerAppId)
|
|
595
|
+
return true;
|
|
596
|
+
try {
|
|
597
|
+
const zitadel = auth.zitadelForInstance?.(`https://${platform.auth.hostname}`) ?? auth.zitadel;
|
|
598
|
+
const livePats = await zitadel.listLoginClientPats(orgId, record.userId);
|
|
599
|
+
if (livePats.length !== 1 || livePats[0] !== record.patId)
|
|
600
|
+
return true;
|
|
601
|
+
const [loginHash, brokerHash] = await Promise.all([
|
|
602
|
+
liveLoginClientTokenHash(auth.coolify, loginAppId),
|
|
603
|
+
liveLoginClientTokenHash(auth.coolify, brokerAppId),
|
|
604
|
+
]);
|
|
605
|
+
// #216 (4th PR review): compare each side on its own. Either side can
|
|
606
|
+
// come back `undefined` -- hidden, with nothing to check it against, see
|
|
607
|
+
// `liveLoginClientTokenHash`'s own doc comment -- and that side alone
|
|
608
|
+
// reads as "unknown," the same as the outer `catch` below. But returning
|
|
609
|
+
// "not drifted" as soon as *either* side was unreadable (round 3's
|
|
610
|
+
// behavior) hid a stale token on the *other*, readable app whenever this
|
|
611
|
+
// app's own read happened to fail or come back hidden -- a readable and
|
|
612
|
+
// wrong hash must still drift even when its sibling is unknown.
|
|
613
|
+
for (const hash of [loginHash, brokerHash]) {
|
|
614
|
+
if (hash !== undefined && hash !== record.tokenHash)
|
|
615
|
+
return true;
|
|
616
|
+
}
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
catch (e) {
|
|
620
|
+
console.error(`platform:auth: could not check the login-client token's liveness for ZITADEL machine user ${record.userId}: ${e instanceof Error ? e.message : String(e)}`);
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* #216 round 5: `ZITADEL_SERVICE_USER_TOKEN`'s live value on one app, hashed
|
|
626
|
+
* -- see {@link authLoginClientDrifted}. `undefined` means "could not be
|
|
627
|
+
* read as a trustworthy value, treat as unknown" -- never a reason by itself
|
|
628
|
+
* to call the token missing.
|
|
629
|
+
*
|
|
630
|
+
* #216 (second PR review, round 2): Coolify's own `GET
|
|
631
|
+
* /applications/{uuid}/envs` response carries both `value` and `real_value`
|
|
632
|
+
* per row (its documented schema for `list-envs-by-application-uuid`), but
|
|
633
|
+
* `CoolifyPort.listEnvs`'s narrower return type (`executor.ts`, this
|
|
634
|
+
* package's own) names only `value` and `is_preview`. The real client
|
|
635
|
+
* (`@wtfalch/providers`' `CoolifyClient.listEnvs`, which does `await
|
|
636
|
+
* r.json()` and returns it as-is with no field-stripping, `coolify.ts:219-
|
|
637
|
+
* 253`) returns Coolify's full JSON row regardless of what this package's
|
|
638
|
+
* own narrower type declares, so `real_value` is present on the object at
|
|
639
|
+
* runtime to read even when absent from the declared type.
|
|
640
|
+
*
|
|
641
|
+
* #216 (4th PR review): "could not be read" has one real cause, confirmed
|
|
642
|
+
* against Coolify's own source rather than guessed -- there is no masked
|
|
643
|
+
* placeholder string anywhere in it. `routes/api.php` maps `GET
|
|
644
|
+
* /applications/{uuid}/envs` to `ApplicationsController::envs()`, which maps
|
|
645
|
+
* each row through `removeSensitiveData()`. That method calls
|
|
646
|
+
* `$env->makeVisible(['value', 'real_value'])` only when the request's
|
|
647
|
+
* `can_read_sensitive` attribute is true (set from the API token's
|
|
648
|
+
* `read:sensitive`/`root` ability), and separately calls
|
|
649
|
+
* `$env->makeHidden(['value', 'real_value'])` whenever the env var's own
|
|
650
|
+
* `is_shown_once` flag is set ("the saved value is hidden in the UI and API
|
|
651
|
+
* responses," per that flag's own API doc comment), regardless of ability.
|
|
652
|
+
* Laravel omits a `makeHidden` attribute from the JSON row entirely -- it is
|
|
653
|
+
* not present as a key, let alone populated with a placeholder string. So
|
|
654
|
+
* unreadable here never looks like `value: '••••••••'`; it looks like
|
|
655
|
+
* `found.value` and `found.real_value` both being `undefined`.
|
|
656
|
+
* `@wtfalch/providers`' own `CoolifyEnvVar` type documents a `real_value` of
|
|
657
|
+
* `null` for a genuinely empty variable (`coolify.ts:84`, "Empty variables
|
|
658
|
+
* come back as `\"\"` here and `null` in `real_value`"), which is a
|
|
659
|
+
* different, readable state -- a real, verified value (of the empty
|
|
660
|
+
* string), not a hidden one -- so only `undefined` on both counts as hidden.
|
|
661
|
+
*
|
|
662
|
+
* A wrong guess in the unsafe direction is costly here specifically: reading
|
|
663
|
+
* a hidden field as the empty string would never hash to what was recorded,
|
|
664
|
+
* so every single plan afterward would see "drift" and re-mint, revoking the
|
|
665
|
+
* one live PAT and redeploying both apps, taking sign-in down on every apply
|
|
666
|
+
* rather than the one apply a real drift deserves. So this reads as unknown
|
|
667
|
+
* (logged once, the same as `authLoginClientDrifted`'s own outer
|
|
668
|
+
* `try`/`catch` already treats a transient read failure) whenever the row
|
|
669
|
+
* exists but carries neither key.
|
|
670
|
+
*/
|
|
671
|
+
async function liveLoginClientTokenHash(coolify, appId) {
|
|
672
|
+
const envs = await coolify.listEnvs(appId);
|
|
673
|
+
const found = envs.find((e) => e.key === 'ZITADEL_SERVICE_USER_TOKEN' && !e.is_preview);
|
|
674
|
+
if (found && found.value === undefined && found.real_value === undefined) {
|
|
675
|
+
console.error(`platform:auth: ZITADEL_SERVICE_USER_TOKEN on Coolify app ${appId} came back with neither value nor real_value -- Coolify hides both unless the caller's token has read:sensitive/root ability, or the env var's own is_shown_once flag is set; treating its liveness as unknown rather than forcing a re-mint`);
|
|
676
|
+
return undefined;
|
|
677
|
+
}
|
|
678
|
+
const value = found?.real_value ?? found?.value;
|
|
679
|
+
return hashLoginClientToken(value ?? '');
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* #168: makes `coolify:app` keyed {@link PLATFORM_AUTH_KEY} true -- a Coolify
|
|
683
|
+
* Postgres, the ZITADEL app itself, the zitadel-login app and (#204) the
|
|
684
|
+
* sign-in broker, reached through one Cloudflare tunnel and DNS record at
|
|
685
|
+
* `platform.auth.hostname`, then (only the first time) foundry's own admin
|
|
686
|
+
* machine user, handed to {@link PlatformAuthDeps.deliverSecret}. Env values
|
|
687
|
+
* and instance settings are `wtfalch/auth`'s own `docker-compose.yml`
|
|
688
|
+
* (checked 2026-09-29), with `ZITADEL_DOMAIN`/`wtfalch.dev` replaced by this
|
|
689
|
+
* platform's own `apex`/`auth.hostname` wherever the compose named one --
|
|
690
|
+
* every other setting it leaves open (login v2 required, external port 443,
|
|
691
|
+
* TLS terminated at Cloudflare, `PASSWORDCHANGEREQUIRED` false) is kept
|
|
692
|
+
* exactly as that compose has it (#168's "Decide"). The estate's old, shared
|
|
693
|
+
* sign-in host carries nothing once this runs (#204, `WTFALCH-DEV-EXIT.md`
|
|
694
|
+
* row A4).
|
|
695
|
+
*
|
|
696
|
+
* Every part is safe to run again after a throw partway: each id is recorded
|
|
697
|
+
* (and persisted) as soon as its resource exists, each app is named and
|
|
698
|
+
* deployed on every run that needs it, and the admin key is delivered before
|
|
699
|
+
* the guard that skips the mint is recorded.
|
|
700
|
+
*
|
|
701
|
+
* Three apps, one public hostname, no separate `proxy` app: `wtfalch/auth`
|
|
702
|
+
* splits ZITADEL's API, its login UI and its broker with a built Caddy proxy
|
|
703
|
+
* container inside one docker-compose stack; foundry instead gives each app a
|
|
704
|
+
* fixed `custom_internal_name` and lets the Cloudflare tunnel itself
|
|
705
|
+
* path-route between them (`AUTH_ZITADEL_PATH_PATTERN`,
|
|
706
|
+
* `AUTH_BROKER_PATH_PATTERN`), skipping Coolify's own Traefik (which can only
|
|
707
|
+
* dispatch by `Host`, not by path) rather than provisioning a fourth Coolify
|
|
708
|
+
* resource to do the same job. No app is created with a Coolify `domains`
|
|
709
|
+
* binding for the same reason -- the tunnel reaches each one directly by its
|
|
710
|
+
* `custom_internal_name`, never through Traefik.
|
|
711
|
+
*
|
|
712
|
+
* Known gaps, left for review (#168's own "the PR stays open for William's
|
|
713
|
+
* review"):
|
|
714
|
+
* - ~~Neither the login app nor the broker ever receives
|
|
715
|
+
* `ZITADEL_SERVICE_USER_TOKEN`.~~ Closed by #216 (building on #209/#215):
|
|
716
|
+
* this function's own login-client block below mints a dedicated
|
|
717
|
+
* `IAM_LOGIN_CLIENT` machine user (never the admin one, which is
|
|
718
|
+
* `IAM_OWNER`) and delivers its token to both apps, once the broker's
|
|
719
|
+
* other required inputs are already met.
|
|
720
|
+
* - The broker never receives `RESEND_API_TOKEN` either, so it will not start
|
|
721
|
+
* on `AUTH_APPS` alone. `wtfalch/auth#138` moves the broker's account mail
|
|
722
|
+
* onto held keys and `@wtfalch/mailer`; until that lands there is no held
|
|
723
|
+
* key to read here, so this stays a plain gap rather than a wired value.
|
|
724
|
+
* - `AUTH_APPS` starts unset: the broker has nothing to serve until a
|
|
725
|
+
* platform app registers with it ({@link provisionPlatformArchon} is the
|
|
726
|
+
* first), so it will not start (`parseApps` throws on an empty list) until
|
|
727
|
+
* then either. Safe to run again: the next apply that registers an app
|
|
728
|
+
* redeploys the broker with a non-empty list.
|
|
729
|
+
* - Whoever supplies `deps.zitadel` (or `zitadelForInstance`) must hold a
|
|
730
|
+
* bearer token for the new instance, which foundry cannot read from it.
|
|
731
|
+
*
|
|
732
|
+
* #197: a `cloudflared` connector for this tunnel is run
|
|
733
|
+
* ({@link ensureCloudflaredConnector}), so `hostname` answers.
|
|
734
|
+
*/
|
|
735
|
+
async function provisionPlatformAuth(context, deps) {
|
|
736
|
+
const { platform } = context;
|
|
737
|
+
const hostname = platform.auth.hostname;
|
|
738
|
+
const gitRef = platform.auth.gitRef;
|
|
739
|
+
const recorded = context.recordedIds();
|
|
740
|
+
const runtime = deps.runtime ?? {};
|
|
741
|
+
const sleep = runtime.sleep ?? defaultSleep;
|
|
742
|
+
// Records an id and writes it out now, so a crash before the step ends
|
|
743
|
+
// cannot lose a resource a provider already created.
|
|
744
|
+
const record = async (key, id) => {
|
|
745
|
+
context.recordScopeId(key, id);
|
|
746
|
+
await context.persist?.();
|
|
747
|
+
};
|
|
748
|
+
let projectUuid = recorded[PLATFORM_AUTH_PROJECT_SCOPE_KEY];
|
|
749
|
+
if (!projectUuid) {
|
|
750
|
+
const found = await deps.coolify.findProjectByName(PLATFORM_AUTH_PROJECT);
|
|
751
|
+
projectUuid = found
|
|
752
|
+
? found.uuid
|
|
753
|
+
: (await deps.coolify.createProject(PLATFORM_AUTH_PROJECT)).uuid;
|
|
754
|
+
await record(PLATFORM_AUTH_PROJECT_SCOPE_KEY, projectUuid);
|
|
755
|
+
}
|
|
756
|
+
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
757
|
+
// ---- database ----------------------------------------------------------
|
|
758
|
+
let dbId = recorded[PLATFORM_AUTH_DB_SCOPE_KEY];
|
|
759
|
+
// A database ZITADEL has already run against may hold data encrypted with a
|
|
760
|
+
// masterkey this run cannot see (see `ensureMasterkey`). A database that
|
|
761
|
+
// exists but was never deployed against (a throw between its create and the
|
|
762
|
+
// first deploy) is still empty, so a retry may generate the key.
|
|
763
|
+
const databaseInUse = Boolean(dbId && recorded[PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY]);
|
|
764
|
+
if (!dbId) {
|
|
765
|
+
const server = await resolvePlatformServer(deps.coolify);
|
|
766
|
+
const created = await deps.coolify.createPostgresDatabase({
|
|
767
|
+
project_uuid: projectUuid,
|
|
768
|
+
server_uuid: server.uuid,
|
|
769
|
+
environment_name: environment.name,
|
|
770
|
+
environment_uuid: environment.uuid,
|
|
771
|
+
name: PLATFORM_AUTH_DB_NAME,
|
|
772
|
+
postgres_db: 'zitadel',
|
|
773
|
+
postgres_user: 'zitadel',
|
|
774
|
+
});
|
|
775
|
+
dbId = created.uuid;
|
|
776
|
+
await record(PLATFORM_AUTH_DB_SCOPE_KEY, dbId);
|
|
777
|
+
}
|
|
778
|
+
const db = await deps.coolify.getDatabase(dbId);
|
|
779
|
+
if (!db.internal_db_url) {
|
|
780
|
+
// Static config check before either app is touched (`diff.ts`'s
|
|
781
|
+
// `coolifyCalls` follows the same rule for its own databases): Coolify
|
|
782
|
+
// can take a moment to report a freshly created database's
|
|
783
|
+
// `internal_db_url`, so the next apply retries from a clean state.
|
|
784
|
+
throw new Error('coolify:app for the platform (auth): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
|
|
785
|
+
}
|
|
786
|
+
// ---- the ZITADEL app itself ---------------------------------------------
|
|
787
|
+
const zitadelApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_ZITADEL_APP_NAME, environment, async () => {
|
|
788
|
+
const server = await resolvePlatformServer(deps.coolify);
|
|
789
|
+
return deps.coolify.createDockerImageApplication({
|
|
790
|
+
project_uuid: projectUuid,
|
|
791
|
+
server_uuid: server.uuid,
|
|
792
|
+
environment_name: environment.name,
|
|
793
|
+
environment_uuid: environment.uuid,
|
|
794
|
+
docker_registry_image_name: AUTH_ZITADEL_IMAGE,
|
|
795
|
+
docker_registry_image_tag: AUTH_ZITADEL_IMAGE_TAG,
|
|
796
|
+
ports_exposes: AUTH_ZITADEL_PORT,
|
|
797
|
+
name: PLATFORM_AUTH_ZITADEL_APP_NAME,
|
|
798
|
+
description: `ZITADEL for the platform's own sign-in (#168, ${AUTH_ZITADEL_IMAGE}:${AUTH_ZITADEL_IMAGE_TAG})`,
|
|
799
|
+
instant_deploy: false,
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
const zitadelAppId = zitadelApp.appId;
|
|
803
|
+
await record(PLATFORM_AUTH_ZITADEL_SCOPE_KEY, zitadelAppId);
|
|
804
|
+
// Set on every run, not only after a create: a throw right after the
|
|
805
|
+
// create must not leave the app unnamed for good, and the tunnel addresses
|
|
806
|
+
// the app by this name. (The port cannot read the current name back, and
|
|
807
|
+
// setting the same value again is harmless.)
|
|
808
|
+
await deps.coolify.updateApplication(zitadelAppId, {
|
|
809
|
+
custom_internal_name: PLATFORM_AUTH_ZITADEL_INTERNAL_NAME,
|
|
810
|
+
});
|
|
811
|
+
// ---- the zitadel-login app -----------------------------------------------
|
|
812
|
+
const loginApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_LOGIN_APP_NAME, environment, async () => {
|
|
813
|
+
const server = await resolvePlatformServer(deps.coolify);
|
|
814
|
+
const githubApp = await deps.coolify.requireGithubApp();
|
|
815
|
+
return deps.coolify.createGithubAppApplication({
|
|
816
|
+
project_uuid: projectUuid,
|
|
817
|
+
server_uuid: server.uuid,
|
|
818
|
+
environment_name: environment.name,
|
|
819
|
+
environment_uuid: environment.uuid,
|
|
820
|
+
github_app_uuid: githubApp.uuid,
|
|
821
|
+
git_repository: AUTH_REPO,
|
|
822
|
+
git_branch: gitRef,
|
|
823
|
+
build_pack: 'dockerfile',
|
|
824
|
+
name: PLATFORM_AUTH_LOGIN_APP_NAME,
|
|
825
|
+
dockerfile_location: AUTH_LOGIN_DOCKERFILE,
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
const loginAppId = loginApp.appId;
|
|
829
|
+
await record(PLATFORM_AUTH_LOGIN_SCOPE_KEY, loginAppId);
|
|
830
|
+
await deps.coolify.updateApplication(loginAppId, {
|
|
831
|
+
custom_internal_name: PLATFORM_AUTH_LOGIN_INTERNAL_NAME,
|
|
832
|
+
});
|
|
833
|
+
// ---- the broker app (#204) -------------------------------------------------
|
|
834
|
+
const brokerApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_BROKER_APP_NAME, environment, async () => {
|
|
835
|
+
const server = await resolvePlatformServer(deps.coolify);
|
|
836
|
+
const githubApp = await deps.coolify.requireGithubApp();
|
|
837
|
+
return deps.coolify.createGithubAppApplication({
|
|
838
|
+
project_uuid: projectUuid,
|
|
839
|
+
server_uuid: server.uuid,
|
|
840
|
+
environment_name: environment.name,
|
|
841
|
+
environment_uuid: environment.uuid,
|
|
842
|
+
github_app_uuid: githubApp.uuid,
|
|
843
|
+
git_repository: AUTH_REPO,
|
|
844
|
+
git_branch: gitRef,
|
|
845
|
+
build_pack: 'dockerfile',
|
|
846
|
+
name: PLATFORM_AUTH_BROKER_APP_NAME,
|
|
847
|
+
dockerfile_location: AUTH_BROKER_DOCKERFILE,
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
const brokerAppId = brokerApp.appId;
|
|
851
|
+
await record(PLATFORM_AUTH_BROKER_SCOPE_KEY, brokerAppId);
|
|
852
|
+
await deps.coolify.updateApplication(brokerAppId, {
|
|
853
|
+
custom_internal_name: PLATFORM_AUTH_BROKER_INTERNAL_NAME,
|
|
854
|
+
});
|
|
855
|
+
// ---- env -----------------------------------------------------------------
|
|
856
|
+
// Generated once, handed straight to `bulkUpsertEnvs` and nowhere else --
|
|
857
|
+
// never returned, recorded, logged or interpolated into a thrown error, the
|
|
858
|
+
// same rule `executor.ts`'s `ensureAuthCookieSecret` follows for a value
|
|
859
|
+
// this package mints itself rather than receiving from a provider.
|
|
860
|
+
const masterkey = await ensureMasterkey(deps.coolify, zitadelAppId, databaseInUse);
|
|
861
|
+
const adminPassword = await ensureGeneratedEnv(deps.coolify, zitadelAppId, 'ZITADEL_ADMIN_PASSWORD', generateAdminPassword);
|
|
862
|
+
const zitadelEnvs = [
|
|
863
|
+
{ key: 'ZITADEL_PORT', value: AUTH_ZITADEL_PORT },
|
|
864
|
+
{ key: 'ZITADEL_EXTERNALDOMAIN', value: hostname },
|
|
865
|
+
{ key: 'ZITADEL_EXTERNALPORT', value: '443' },
|
|
866
|
+
{ key: 'ZITADEL_EXTERNALSECURE', value: 'true' },
|
|
867
|
+
{ key: 'ZITADEL_TLS_ENABLED', value: 'false' },
|
|
868
|
+
{ key: 'ZITADEL_DATABASE_POSTGRES_DSN', value: db.internal_db_url },
|
|
869
|
+
{ key: 'ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME', value: 'zitadel-admin' },
|
|
870
|
+
{ key: 'ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED', value: 'false' },
|
|
871
|
+
{ key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED', value: 'true' },
|
|
872
|
+
{ key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI', value: `https://${hostname}/` },
|
|
873
|
+
{ key: 'ZITADEL_OIDC_DEFAULTLOGINURLV2', value: `https://${hostname}/login?authRequest=` },
|
|
874
|
+
{
|
|
875
|
+
key: 'ZITADEL_OIDC_DEFAULTLOGOUTURLV2',
|
|
876
|
+
value: `https://${hostname}/logout?post_logout_redirect=`,
|
|
877
|
+
},
|
|
878
|
+
{ key: 'ZITADEL_SAML_DEFAULTLOGINURLV2', value: `https://${hostname}/login?samlRequest=` },
|
|
879
|
+
{ key: 'ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED', value: 'true' },
|
|
880
|
+
];
|
|
881
|
+
if (masterkey)
|
|
882
|
+
zitadelEnvs.push({ key: 'ZITADEL_MASTERKEY', value: masterkey });
|
|
883
|
+
if (adminPassword)
|
|
884
|
+
zitadelEnvs.push({ key: 'ZITADEL_ADMIN_PASSWORD', value: adminPassword });
|
|
885
|
+
const loginEnvs = [
|
|
886
|
+
{
|
|
887
|
+
key: 'ZITADEL_API_URL',
|
|
888
|
+
value: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
|
|
889
|
+
},
|
|
890
|
+
{ key: 'CUSTOM_REQUEST_HEADERS', value: `Host:${hostname},X-Forwarded-Proto:https` },
|
|
891
|
+
{ key: 'OTEL_NODE_RESOURCE_DETECTORS', value: 'none' },
|
|
892
|
+
{ key: 'API_CACHE_CONFIG', value: '{"defaultMinutes":2,"longMinutes":5}' },
|
|
893
|
+
];
|
|
894
|
+
// `AUTH_APPS`, `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` and `RESEND_API_TOKEN`
|
|
895
|
+
// are not pushed here -- this function's own doc comment, "Known gaps".
|
|
896
|
+
const brokerEnvs = [
|
|
897
|
+
{
|
|
898
|
+
key: 'ZITADEL_API_URL',
|
|
899
|
+
value: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
|
|
900
|
+
},
|
|
901
|
+
{ key: 'ZITADEL_DOMAIN', value: hostname },
|
|
902
|
+
];
|
|
903
|
+
// Compared before the write: an env change only reaches a running
|
|
904
|
+
// container on its next deploy (`diff.ts`'s `coolifyCalls` follows the
|
|
905
|
+
// identical rule).
|
|
906
|
+
const zitadelEnvsChanged = await envsDiffer(deps.coolify, zitadelAppId, zitadelEnvs);
|
|
907
|
+
const loginEnvsChanged = await envsDiffer(deps.coolify, loginAppId, loginEnvs);
|
|
908
|
+
const brokerEnvsChanged = await envsDiffer(deps.coolify, brokerAppId, brokerEnvs);
|
|
909
|
+
await deps.coolify.bulkUpsertEnvs(zitadelAppId, zitadelEnvs);
|
|
910
|
+
await deps.coolify.bulkUpsertEnvs(loginAppId, loginEnvs);
|
|
911
|
+
await deps.coolify.bulkUpsertEnvs(brokerAppId, brokerEnvs);
|
|
912
|
+
// Deploy an app that was just created, is not running (a throw after the
|
|
913
|
+
// create left it never deployed, or it stopped), or whose envs changed.
|
|
914
|
+
if (zitadelApp.justCreated || !zitadelApp.running || zitadelEnvsChanged) {
|
|
915
|
+
await deps.coolify.deploy(zitadelAppId);
|
|
916
|
+
await record(PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY, zitadelAppId);
|
|
917
|
+
}
|
|
918
|
+
if (loginApp.justCreated || !loginApp.running || loginEnvsChanged) {
|
|
919
|
+
await deps.coolify.deploy(loginAppId);
|
|
920
|
+
}
|
|
921
|
+
// #211: the broker's own `server.ts` refuses to start without
|
|
922
|
+
// `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` and `RESEND_API_TOKEN` -- deploying
|
|
923
|
+
// it without them only produces a container that crash-loops behind
|
|
924
|
+
// `/api`. The app is still created and recorded above either way, so #209
|
|
925
|
+
// and #210 (or a human, directly) have something to push the env onto;
|
|
926
|
+
// only the deploy waits.
|
|
927
|
+
// #211 round 5: `brokerDeployNeeded` is its own reason to deploy (not only
|
|
928
|
+
// alongside justCreated/running/envsChanged, none of which this function's
|
|
929
|
+
// own `brokerEnvs` write above ever touches `AUTH_APPS`): it is what
|
|
930
|
+
// catches a broker recreated under a new app id, or a crash between some
|
|
931
|
+
// other step writing `AUTH_APPS` and deploying, cases none of the other
|
|
932
|
+
// three conditions see. This function never writes `AUTH_APPS` itself
|
|
933
|
+
// (this function's own doc comment, "Known gaps"), so whatever is live now
|
|
934
|
+
// (`authAppsRaw`) is the value this deploy, if it runs, puts into effect.
|
|
935
|
+
const brokerMissingInputs = await missingBrokerInputs(deps.coolify, brokerAppId);
|
|
936
|
+
const authAppsRaw = await currentEnvValue(deps.coolify, brokerAppId, 'AUTH_APPS');
|
|
937
|
+
const recordedBrokerDeploy = parseBrokerDeployRecord(recorded[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]);
|
|
938
|
+
if (brokerMissingInputs.length === 0 &&
|
|
939
|
+
(brokerApp.justCreated ||
|
|
940
|
+
!brokerApp.running ||
|
|
941
|
+
brokerEnvsChanged ||
|
|
942
|
+
brokerDeployNeeded(brokerMissingInputs, brokerAppId, authAppsRaw, recordedBrokerDeploy))) {
|
|
943
|
+
await deps.coolify.deploy(brokerAppId);
|
|
944
|
+
await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, authAppsRaw));
|
|
945
|
+
}
|
|
946
|
+
// ---- tunnel and DNS ------------------------------------------------------
|
|
947
|
+
const existingTunnel = await deps.cloudflare.findTunnelByName(PLATFORM_AUTH_TUNNEL_NAME);
|
|
948
|
+
const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(PLATFORM_AUTH_TUNNEL_NAME));
|
|
949
|
+
await record(PLATFORM_AUTH_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
950
|
+
await deps.cloudflare.putTunnelConfig(tunnel.id, {
|
|
951
|
+
ingress: [
|
|
952
|
+
{
|
|
953
|
+
hostname,
|
|
954
|
+
path: AUTH_ZITADEL_PATH_PATTERN,
|
|
955
|
+
service: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
|
|
956
|
+
},
|
|
957
|
+
{
|
|
958
|
+
hostname,
|
|
959
|
+
path: AUTH_BROKER_PATH_PATTERN,
|
|
960
|
+
service: `http://${PLATFORM_AUTH_BROKER_INTERNAL_NAME}:${AUTH_BROKER_PORT}`,
|
|
961
|
+
},
|
|
962
|
+
{ hostname, service: `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}` },
|
|
963
|
+
{ service: 'http_status:404' },
|
|
964
|
+
],
|
|
965
|
+
});
|
|
966
|
+
// ---- cloudflared connector for the tunnel above (#197) --------------------
|
|
967
|
+
// Without this, `hostname` resolves to a tunnel nothing is dialed into and
|
|
968
|
+
// answers with a Cloudflare error -- the known gap #197 closes for this
|
|
969
|
+
// step's own tunnel only (see this function's doc comment).
|
|
970
|
+
await ensureCloudflaredConnector({
|
|
971
|
+
coolify: deps.coolify,
|
|
972
|
+
cloudflare: deps.cloudflare,
|
|
973
|
+
tunnelId: tunnel.id,
|
|
974
|
+
appName: PLATFORM_AUTH_CLOUDFLARED_APP_NAME,
|
|
975
|
+
projectUuid,
|
|
976
|
+
environment,
|
|
977
|
+
resolveServer: () => resolvePlatformServer(deps.coolify),
|
|
978
|
+
recordedConnectorAppId: recorded[PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
979
|
+
recordAppId: (appId) => record(PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, appId),
|
|
980
|
+
});
|
|
981
|
+
// The apex zone step records the zone id before this step runs, but it
|
|
982
|
+
// creates no record for the hostname, so it is created here.
|
|
983
|
+
const zoneId = recorded[apexZoneKey(platform.apex)] ?? (await exactZone(deps.cloudflare, platform.apex))?.id;
|
|
984
|
+
if (!zoneId) {
|
|
985
|
+
throw new Error(`platform:auth: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
|
|
986
|
+
}
|
|
987
|
+
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
988
|
+
const [dnsRecord] = await deps.cloudflare.findDnsRecord(zoneId, hostname, 'CNAME');
|
|
989
|
+
if (!dnsRecord) {
|
|
990
|
+
await deps.cloudflare.createDnsRecord(zoneId, {
|
|
991
|
+
type: 'CNAME',
|
|
992
|
+
name: hostname,
|
|
993
|
+
content: dnsTarget,
|
|
994
|
+
proxied: true,
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
else if (dnsRecord.content !== dnsTarget) {
|
|
998
|
+
await deps.cloudflare.updateDnsRecord(zoneId, dnsRecord.id, {
|
|
999
|
+
type: 'CNAME',
|
|
1000
|
+
name: hostname,
|
|
1001
|
+
content: dnsTarget,
|
|
1002
|
+
proxied: true,
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
// ---- foundry's own admin machine user, once ------------------------------
|
|
1006
|
+
if (!recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
|
|
1007
|
+
const lost = recorded[PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY];
|
|
1008
|
+
if (lost) {
|
|
1009
|
+
// The planner reports this and plans nothing; this is the same rule for
|
|
1010
|
+
// a caller that reaches the step anyway.
|
|
1011
|
+
throw new Error(`platform:auth: 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`);
|
|
1012
|
+
}
|
|
1013
|
+
// Minting needs a running instance behind the tunnel: the first apply
|
|
1014
|
+
// gets here right after the deploy.
|
|
1015
|
+
await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep);
|
|
1016
|
+
const zitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
|
|
1017
|
+
const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
|
|
1018
|
+
// #216 round 5: recorded here too (mirroring `provisionPlatformArchon`),
|
|
1019
|
+
// so `authLoginClientDrifted` (below) never needs its own ZITADEL call
|
|
1020
|
+
// to resolve the org -- see that function's own doc comment.
|
|
1021
|
+
if (recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY] !== org.id) {
|
|
1022
|
+
await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, org.id);
|
|
1023
|
+
}
|
|
1024
|
+
const { userId, token } = await zitadel.mintAdminMachineUser(org.id, {
|
|
1025
|
+
userName: PLATFORM_AUTH_ADMIN_USER_NAME,
|
|
1026
|
+
name: 'Foundry (platform admin)',
|
|
1027
|
+
});
|
|
1028
|
+
// Recorded and persisted before the token leaves this function: if
|
|
1029
|
+
// delivery fails for good, the next plan reports the lost key rather than
|
|
1030
|
+
// skipping or minting again. Only a delivered key records the guard.
|
|
1031
|
+
await record(PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, userId);
|
|
1032
|
+
// Handed off once, right here, before returning -- never in the return
|
|
1033
|
+
// value, `serviceIds`, the store, a log or a thrown error (see
|
|
1034
|
+
// `FoundryStepExecutorPorts.deliverSecret`'s own doc comment). The error
|
|
1035
|
+
// below names the user id only, never the token or the cause.
|
|
1036
|
+
const attempts = runtime.deliverAttempts ?? 3;
|
|
1037
|
+
for (let attempt = 1;; attempt += 1) {
|
|
1038
|
+
try {
|
|
1039
|
+
await deps.deliverSecret('zitadel:platform:admin', token);
|
|
1040
|
+
break;
|
|
1041
|
+
}
|
|
1042
|
+
catch {
|
|
1043
|
+
if (attempt >= attempts) {
|
|
1044
|
+
throw new Error(`platform:auth: the admin key of ZITADEL machine user ${userId} was minted but could not be delivered after ${attempts} tries`);
|
|
1045
|
+
}
|
|
1046
|
+
await sleep(1000 * attempt);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
await record(PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, userId);
|
|
1050
|
+
}
|
|
1051
|
+
// ---- foundry's own login-client machine user, once (#216, building on #209/#215) --
|
|
1052
|
+
// The login app and the broker both refuse to start without
|
|
1053
|
+
// `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` (this function's own doc comment,
|
|
1054
|
+
// "Known gaps") -- the admin token above must never go there (it is
|
|
1055
|
+
// `IAM_OWNER`), so this mints a second, narrower machine user
|
|
1056
|
+
// (`IAM_LOGIN_CLIENT` only) and delivers its token into both apps' own
|
|
1057
|
+
// envs, then redeploys both.
|
|
1058
|
+
//
|
|
1059
|
+
// Deferred, not blocked, while the broker's other required inputs
|
|
1060
|
+
// (`RESEND_API_TOKEN`, `AUTH_APPS`) are still missing (composing with
|
|
1061
|
+
// #211's own gating): minting and delivering here would only redeploy a
|
|
1062
|
+
// broker that still refuses to start on the other missing input, spending
|
|
1063
|
+
// a mint and two deploys on nothing this apply can fix. `blockedSteps`'s
|
|
1064
|
+
// own `missingBrokerInputs` read already names what is still missing; once
|
|
1065
|
+
// the other inputs land (#210, or Archon's own registration for
|
|
1066
|
+
// `AUTH_APPS`), a later apply's fresh read of `brokerMissingInputs` clears
|
|
1067
|
+
// this gate and this block runs.
|
|
1068
|
+
//
|
|
1069
|
+
// Unlike the admin key above, an undelivered login-client key never blocks
|
|
1070
|
+
// planning or throws a "delete this user by hand" error -- #216's own fix
|
|
1071
|
+
// for the bug PR #215 shipped with, which mirrored the admin pair's
|
|
1072
|
+
// forever-blocking behaviour here too. Revoking a stale login-client PAT
|
|
1073
|
+
// destroys nothing (unlike ZITADEL's own masterkey, nothing else was ever
|
|
1074
|
+
// encrypted with it), so `mintLoginClientMachineUser`
|
|
1075
|
+
// (`zitadel-machine-user.ts`) always lists and deletes every PAT the user
|
|
1076
|
+
// already holds before minting a fresh one. A crash between mint and
|
|
1077
|
+
// record, or every delivery retry failing, both leave this `if` still true
|
|
1078
|
+
// on the next run, which re-mints (revoking the stale PAT first) and
|
|
1079
|
+
// retries delivery, with no manual step.
|
|
1080
|
+
//
|
|
1081
|
+
// #216 round 2 and round 3 each tried to catch a concurrent apply's own
|
|
1082
|
+
// mint (idempotent revoke-then-create on the same `userName`) racing this
|
|
1083
|
+
// one -- round 2 checked liveness once, after every write; round 3 checked
|
|
1084
|
+
// again before every write, inside each retry. Review confirmed each still
|
|
1085
|
+
// left a window: a write that lands between the last check this run makes
|
|
1086
|
+
// and the very next network call is never caught by either. #216 round 4
|
|
1087
|
+
// drops both: this block no longer tries to detect the race from inside
|
|
1088
|
+
// the run that might lose it. It mints, delivers with ordinary retries,
|
|
1089
|
+
// and records whatever it actually delivered -- the user id *and* the
|
|
1090
|
+
// exact PAT id (`loginClientRecordValue`). `planAuth` (`platform-diff.ts`)
|
|
1091
|
+
// re-checks that recorded PAT against ZITADEL's live state on *every*
|
|
1092
|
+
// plan, not only this run's: whichever run's write actually won is caught
|
|
1093
|
+
// on the very next plan and replanned, however the writes interleaved.
|
|
1094
|
+
// See `authLoginClientDrifted`'s own doc comment for why that check can
|
|
1095
|
+
// never read a healthy delivery as drifted.
|
|
1096
|
+
//
|
|
1097
|
+
// Read fresh here, immediately before deciding whether to mint -- not
|
|
1098
|
+
// `recorded`, the snapshot this function took at the top, several hundred
|
|
1099
|
+
// lines above. A concurrent apply's own mint-deliver-record can land in
|
|
1100
|
+
// that gap; deciding off a stale snapshot would mint and redeliver on top
|
|
1101
|
+
// of a token another run just finished setting up.
|
|
1102
|
+
//
|
|
1103
|
+
// A record present is still not enough on its own: `planAuth` may have
|
|
1104
|
+
// replanned this step because `authLoginClientDrifted` (the same check,
|
|
1105
|
+
// reused here) found the recorded PAT no longer live -- this run has to
|
|
1106
|
+
// make the same call, not just trust that a record exists, or a drifted
|
|
1107
|
+
// platform would be planned again forever without ever actually healing.
|
|
1108
|
+
const loginClientOtherInputsMissing = brokerMissingInputs.filter((m) => m !== 'ZITADEL_SERVICE_USER_TOKEN');
|
|
1109
|
+
const freshRecordedIds = context.recordedIds();
|
|
1110
|
+
const freshLoginClientRecord = parseLoginClientRecord(freshRecordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
|
|
1111
|
+
const loginClientNeedsMint = !freshLoginClientRecord || (await authLoginClientDrifted(platform, deps, freshRecordedIds));
|
|
1112
|
+
if (loginClientNeedsMint && loginClientOtherInputsMissing.length === 0) {
|
|
1113
|
+
const loginClientZitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
|
|
1114
|
+
const { org: loginClientOrg } = await loginClientZitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
|
|
1115
|
+
// #216 round 5: backfills the org id for a platform whose admin block
|
|
1116
|
+
// (above) ran before this recording existed and has not run since --
|
|
1117
|
+
// see `authLoginClientDrifted`'s own doc comment.
|
|
1118
|
+
if (freshRecordedIds[PLATFORM_ZITADEL_ORG_SCOPE_KEY] !== loginClientOrg.id) {
|
|
1119
|
+
await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, loginClientOrg.id);
|
|
1120
|
+
}
|
|
1121
|
+
const { userId: loginClientUserId, token: loginClientToken, patId: loginClientPatId, revokedPriorPat, } = await loginClientZitadel.mintLoginClientMachineUser(loginClientOrg.id, {
|
|
1122
|
+
userName: PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME,
|
|
1123
|
+
name: 'Foundry (platform login client)',
|
|
1124
|
+
});
|
|
1125
|
+
// Recorded and persisted before the token leaves this function -- the
|
|
1126
|
+
// same "one mint" rule the admin block above follows. Never read back to
|
|
1127
|
+
// block planning; `planAuth` (`platform-diff.ts`) reads it only to
|
|
1128
|
+
// surface a non-blocking note, for the reason this block's own doc
|
|
1129
|
+
// comment above gives.
|
|
1130
|
+
await record(PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, loginClientUserId);
|
|
1131
|
+
const deliverAttempts = runtime.deliverAttempts ?? 3;
|
|
1132
|
+
try {
|
|
1133
|
+
await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.bulkUpsertEnvs(loginAppId, [
|
|
1134
|
+
{ key: 'ZITADEL_SERVICE_USER_TOKEN', value: loginClientToken },
|
|
1135
|
+
]));
|
|
1136
|
+
await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.bulkUpsertEnvs(brokerAppId, [
|
|
1137
|
+
{ key: 'ZITADEL_SERVICE_USER_TOKEN', value: loginClientToken },
|
|
1138
|
+
]));
|
|
1139
|
+
// Forced: an unforced deploy answers 200 "skipped" when one for the
|
|
1140
|
+
// same commit is already queued -- likely here, since this function's
|
|
1141
|
+
// own deploys above may just have queued one for either app. An
|
|
1142
|
+
// ignored "skipped" would let this function record delivery as
|
|
1143
|
+
// succeeded while the running containers never actually see the token
|
|
1144
|
+
// (#216 follow-up review, item 2).
|
|
1145
|
+
await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.deploy(loginAppId, true));
|
|
1146
|
+
await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.deploy(brokerAppId, true));
|
|
1147
|
+
}
|
|
1148
|
+
catch {
|
|
1149
|
+
// Never echo the underlying error: Coolify's validation error can
|
|
1150
|
+
// include the rejected env value -- the token itself -- in its
|
|
1151
|
+
// message (#216 follow-up review, item 1). Name the user id only,
|
|
1152
|
+
// never the token or the cause. Nothing records delivered, so the
|
|
1153
|
+
// next apply re-mints (revoking whatever is live) and re-delivers.
|
|
1154
|
+
//
|
|
1155
|
+
// #216 (second PR review, round 2; third PR review, nit): on a
|
|
1156
|
+
// re-mint, `mintLoginClientMachineUser` already revoked this user's
|
|
1157
|
+
// prior PAT before minting the one that just failed to deliver -- see
|
|
1158
|
+
// this file's own doc comment above and `zitadel-machine-user.ts`'s
|
|
1159
|
+
// revoke-before-mint sweep. So on that path the login app and broker
|
|
1160
|
+
// are not merely stale, they are down: neither has any live token
|
|
1161
|
+
// until a later apply mints and delivers a fresh one. `revokedPriorPat`
|
|
1162
|
+
// is false on a first-ever mint, where nothing was live to revoke --
|
|
1163
|
+
// saying "its prior token was already revoked" there would describe a
|
|
1164
|
+
// revoke that never happened, so the message only says so when it did.
|
|
1165
|
+
throw new Error(revokedPriorPat
|
|
1166
|
+
? `platform:auth: 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`
|
|
1167
|
+
: `platform:auth: 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`);
|
|
1168
|
+
}
|
|
1169
|
+
// #216 round 4/5: records what was actually delivered -- the user id,
|
|
1170
|
+
// the exact PAT id, and (round 5) a hash of the exact token -- so the
|
|
1171
|
+
// next plan's `authLoginClientDrifted` check can tell a healthy delivery
|
|
1172
|
+
// from one a concurrent apply has since clobbered, including the
|
|
1173
|
+
// ordering round 4's own check alone could not catch. See this block's
|
|
1174
|
+
// own doc comment, and `authLoginClientDrifted`'s, for why.
|
|
1175
|
+
await record(PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, loginClientRecordValue(loginClientUserId, loginClientPatId, loginClientToken));
|
|
1176
|
+
}
|
|
1177
|
+
return { resourceId: zitadelAppId };
|
|
1178
|
+
}
|
|
1179
|
+
// ---------------------------------------------------------------------------
|
|
1180
|
+
// #169: the platform's own mail server (Stalwart)
|
|
1181
|
+
// ---------------------------------------------------------------------------
|
|
1182
|
+
const PLATFORM_MAIL_PROJECT = 'platform';
|
|
1183
|
+
const PLATFORM_MAIL_APP_NAME = 'platform-mail-stalwart';
|
|
1184
|
+
const PLATFORM_MAIL_INTERNAL_NAME = 'platform-mail-stalwart';
|
|
1185
|
+
const PLATFORM_MAIL_TUNNEL_NAME = 'platform-mail-tunnel';
|
|
1186
|
+
/** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_MAIL_TUNNEL_NAME}. */
|
|
1187
|
+
const PLATFORM_MAIL_CLOUDFLARED_APP_NAME = 'platform-mail-cloudflared';
|
|
1188
|
+
/**
|
|
1189
|
+
* `stalw.art/docs/install/docker`: the official image. `platform.mail.gitRef`
|
|
1190
|
+
* carries its tag -- `platform.ts`'s `PlatformService.gitRef` doc comment
|
|
1191
|
+
* ("the release it runs") holds for an image tag too, and Stalwart has no
|
|
1192
|
+
* repo of its own to build from the way the login app does.
|
|
1193
|
+
*/
|
|
1194
|
+
const STALWART_IMAGE = 'stalwartlabs/stalwart';
|
|
1195
|
+
const STALWART_HTTP_PORT = '8080';
|
|
1196
|
+
/**
|
|
1197
|
+
* SMTP (25), submission (587) and submissions (465) -- the three ports
|
|
1198
|
+
* #169's own "Human steps" names. IMAP/POP3/ManageSieve are left off:
|
|
1199
|
+
* nothing in this estate speaks them to Stalwart directly yet, and every
|
|
1200
|
+
* port opened here is one Coolify's host firewall must also open.
|
|
1201
|
+
*/
|
|
1202
|
+
const STALWART_SMTP_PORTS = ['25', '465', '587'];
|
|
1203
|
+
const STALWART_PORTS_EXPOSES = [STALWART_HTTP_PORT, ...STALWART_SMTP_PORTS].join(',');
|
|
1204
|
+
/**
|
|
1205
|
+
* `host:container`, comma separated (`CoolifyPort.createDockerImageApplication`'s
|
|
1206
|
+
* own doc comment: "a mail server cannot do without" this). The HTTP port is
|
|
1207
|
+
* not here -- it stays behind the tunnel, exactly like `platform:auth`'s
|
|
1208
|
+
* ZITADEL/login apps.
|
|
1209
|
+
*/
|
|
1210
|
+
const STALWART_PORTS_MAPPINGS = STALWART_SMTP_PORTS.map((p) => `${p}:${p}`).join(',');
|
|
1211
|
+
const STALWART_DATA_MOUNT = '/opt/stalwart';
|
|
1212
|
+
/** `STALWART_RECOVERY_ADMIN=<user>:<password>` -- the fixed recovery account name; only the password half is generated. */
|
|
1213
|
+
const STALWART_RECOVERY_USER = 'admin';
|
|
1214
|
+
/**
|
|
1215
|
+
* Stalwart's unauthenticated readiness endpoint (200 once its data store is
|
|
1216
|
+
* up; `crates/http/src/request.rs`'s `healthz` route in v0.16.24). Not
|
|
1217
|
+
* `/.well-known/jmap`, which answers 401 to a client with no credentials.
|
|
1218
|
+
*/
|
|
1219
|
+
const STALWART_READY_PATH = '/healthz/ready';
|
|
1220
|
+
/** Where the server the app was created on is recorded, so the MX record follows the app, not a later pick. */
|
|
1221
|
+
const PLATFORM_MAIL_SERVER_SCOPE_KEY = 'coolify_platform_mail_server';
|
|
1222
|
+
const PLATFORM_MAIL_PROJECT_SCOPE_KEY = 'coolify_project_platform_mail';
|
|
1223
|
+
const PLATFORM_MAIL_APP_SCOPE_KEY = 'coolify_platform_mail_app';
|
|
1224
|
+
const PLATFORM_MAIL_TUNNEL_SCOPE_KEY = 'cloudflare_platform_mail_tunnel';
|
|
1225
|
+
/**
|
|
1226
|
+
* Recorded once the app has been deployed at least once -- same role as
|
|
1227
|
+
* `PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY`, kept for parity though nothing
|
|
1228
|
+
* here reads it back the way `ensureMasterkey` does: losing
|
|
1229
|
+
* `STALWART_RECOVERY_ADMIN` is not destructive the same way losing ZITADEL's
|
|
1230
|
+
* masterkey is.
|
|
1231
|
+
*/
|
|
1232
|
+
const PLATFORM_MAIL_DEPLOYED_SCOPE_KEY = 'stalwart_platform_mail_deployed';
|
|
1233
|
+
/**
|
|
1234
|
+
* The hostname and image tag the last successful deploy used, recorded right
|
|
1235
|
+
* after that deploy. `provisionPlatformMail` reads these (not the applied keys
|
|
1236
|
+
* the planner reads, which stay last) to decide on a redeploy, so a retry after
|
|
1237
|
+
* a later failure does not stop the running server again.
|
|
1238
|
+
*/
|
|
1239
|
+
const PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY = 'mail_deployed_hostname';
|
|
1240
|
+
const PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY = 'mail_deployed_git_ref';
|
|
1241
|
+
/**
|
|
1242
|
+
* The fixed description Stalwart's admin API key is minted under
|
|
1243
|
+
* (`StalwartClient.createApiKey`'s own signature returns only the secret,
|
|
1244
|
+
* never an id) -- what a human revokes it by in Stalwart's own admin UI if
|
|
1245
|
+
* it is ever lost between mint and delivery, and what
|
|
1246
|
+
* {@link PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY} records in that case.
|
|
1247
|
+
*/
|
|
1248
|
+
export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
|
|
1249
|
+
/**
|
|
1250
|
+
* #169: makes `coolify:app` keyed {@link PLATFORM_MAIL_KEY} true -- Stalwart
|
|
1251
|
+
* on Coolify with its own persistent storage, reached at
|
|
1252
|
+
* `platform.mail.hostname` through a Cloudflare tunnel for web, JMAP and its
|
|
1253
|
+
* management API (mirroring #168's ZITADEL/login apps: TLS terminated at
|
|
1254
|
+
* Cloudflare, plain HTTP behind the tunnel) and at `mx.<apex>` over a direct,
|
|
1255
|
+
* unproxied `A` record to the Coolify server's own address for SMTP -- raw
|
|
1256
|
+
* SMTP cannot ride Cloudflare's HTTP-only tunnel the way a web app does,
|
|
1257
|
+
* which is exactly why #169's own "Human steps" asks a person to open ports
|
|
1258
|
+
* 25, 465 and 587 on that server's host firewall. Then, once the instance
|
|
1259
|
+
* answers, the OIDC directory against the platform's own sign-in and
|
|
1260
|
+
* foundry's admin API key, minted once and handed to
|
|
1261
|
+
* {@link PlatformMailDeps.deliverSecret}.
|
|
1262
|
+
*
|
|
1263
|
+
* Every part is safe to run again after a throw partway, the same rules
|
|
1264
|
+
* {@link provisionPlatformAuth} follows: each id is recorded (and persisted)
|
|
1265
|
+
* as soon as its resource exists, the app is deployed on every run that
|
|
1266
|
+
* needs it, and the admin key is delivered before the guard that skips the
|
|
1267
|
+
* mint is recorded. Unlike ZITADEL's `ZITADEL_ADMIN_PASSWORD`, the recovery
|
|
1268
|
+
* credential this step generates (`STALWART_RECOVERY_ADMIN`) is read back
|
|
1269
|
+
* from Coolify's own env store on every run rather than held only in memory
|
|
1270
|
+
* -- `CoolifyPort.listEnvs`'s own doc comment says these values come back
|
|
1271
|
+
* live -- so a retry after a delivery failure re-authenticates with the same
|
|
1272
|
+
* credential instead of losing it.
|
|
1273
|
+
*
|
|
1274
|
+
* **Why not `@wtfalch/email/postmaster`'s `mailObjects`/`applyObjects`**, the
|
|
1275
|
+
* instance-bootstrap helper `wtfalch-manage` already uses for the hand-built
|
|
1276
|
+
* `mail.wtfalch.dev`: that plan also wires an `AcmeProvider` and `DnsServer`
|
|
1277
|
+
* for Stalwart's own DNS-01 ACME challenge, and an `MtaRoute` that relays
|
|
1278
|
+
* every outbound message through Resend -- the estate moved transactional
|
|
1279
|
+
* mail off Resend onto Lettermint before this issue (`@wtfalch/mailer` ADR
|
|
1280
|
+
* 0006), and this step provisions no Cloudflare DNS token for Stalwart's own
|
|
1281
|
+
* ACME either. Calling it as-is would mint an outbound relay this platform
|
|
1282
|
+
* does not want and a credential requirement this issue does not cover, so
|
|
1283
|
+
* {@link PlatformMailAdminPort.applySettings} is a narrower, hand-built
|
|
1284
|
+
* settings write instead: the default hostname and the OIDC directory only.
|
|
1285
|
+
*
|
|
1286
|
+
* Known gaps, left for review (like #168's own "the PR stays open for
|
|
1287
|
+
* William's review"):
|
|
1288
|
+
* - **No ACME/TLS for the SMTP listener.** `mx.<apex>` answers with
|
|
1289
|
+
* Stalwart's own self-signed certificate until a follow-up wires a real
|
|
1290
|
+
* one (`mailObjects`'s `AcmeProvider`/`DnsServer` shape, minus the Resend
|
|
1291
|
+
* relay it also carries).
|
|
1292
|
+
* - **`oidcAudience` is supplied, not resolved.** #168 mints foundry's own
|
|
1293
|
+
* ZITADEL admin machine user but registers no resource API for Stalwart's
|
|
1294
|
+
* access-token `aud` check; whoever supplies
|
|
1295
|
+
* {@link PlatformMailDeps.oidcAudience} must have registered one by hand
|
|
1296
|
+
* until that is built.
|
|
1297
|
+
* - **The Lettermint team token the org app step reads still comes from
|
|
1298
|
+
* wherever the host already reads it, not held keys.** Needs
|
|
1299
|
+
* `StepExecutionContext` to carry the platform, which `plan-apply.ts`'s
|
|
1300
|
+
* own doc comment already scoped out of #167 as more than a small change
|
|
1301
|
+
* -- a follow-up issue, not this one. (#205 removed the org step
|
|
1302
|
+
* `coolify:mail`'s own fallback to the hand-built `wtfalch.dev`
|
|
1303
|
+
* instance -- `diff.ts`'s `planMail` already blocks the step until the
|
|
1304
|
+
* platform's own mail is applied, so the fallback only ever served a
|
|
1305
|
+
* plan recorded before that, and now fails loudly instead.)
|
|
1306
|
+
*/
|
|
1307
|
+
async function provisionPlatformMail(context, deps) {
|
|
1308
|
+
const { platform } = context;
|
|
1309
|
+
const hostname = platform.mail.hostname;
|
|
1310
|
+
const mailHost = platformMailMxHost(platform.apex);
|
|
1311
|
+
const imageTag = platform.mail.gitRef;
|
|
1312
|
+
const recorded = context.recordedIds();
|
|
1313
|
+
const runtime = deps.runtime ?? {};
|
|
1314
|
+
const sleep = runtime.sleep ?? defaultSleep;
|
|
1315
|
+
const record = async (key, id) => {
|
|
1316
|
+
context.recordScopeId(key, id);
|
|
1317
|
+
await context.persist?.();
|
|
1318
|
+
};
|
|
1319
|
+
let projectUuid = recorded[PLATFORM_MAIL_PROJECT_SCOPE_KEY];
|
|
1320
|
+
if (!projectUuid) {
|
|
1321
|
+
const found = await deps.coolify.findProjectByName(PLATFORM_MAIL_PROJECT);
|
|
1322
|
+
projectUuid = found
|
|
1323
|
+
? found.uuid
|
|
1324
|
+
: (await deps.coolify.createProject(PLATFORM_MAIL_PROJECT)).uuid;
|
|
1325
|
+
await record(PLATFORM_MAIL_PROJECT_SCOPE_KEY, projectUuid);
|
|
1326
|
+
}
|
|
1327
|
+
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1328
|
+
// ---- the Stalwart app itself ---------------------------------------------
|
|
1329
|
+
const imageTagBlocked = mailImageTagBlocked(imageTag);
|
|
1330
|
+
if (imageTagBlocked)
|
|
1331
|
+
throw new Error(imageTagBlocked);
|
|
1332
|
+
// One server for both the app and the MX record: recorded the first time,
|
|
1333
|
+
// read back after, so a later run never points `mx.<apex>` somewhere the
|
|
1334
|
+
// app is not.
|
|
1335
|
+
const server = await resolveMailServer(deps.coolify, recorded[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
|
|
1336
|
+
await record(PLATFORM_MAIL_SERVER_SCOPE_KEY, server.uuid);
|
|
1337
|
+
const serverIpBlocked = mailServerIpBlocked(server.ip);
|
|
1338
|
+
if (serverIpBlocked)
|
|
1339
|
+
throw new Error(serverIpBlocked);
|
|
1340
|
+
const app = await findOrCreatePlatformApp(deps.coolify, PLATFORM_MAIL_APP_NAME, environment, async () => {
|
|
1341
|
+
return deps.coolify.createDockerImageApplication({
|
|
1342
|
+
project_uuid: projectUuid,
|
|
1343
|
+
server_uuid: server.uuid,
|
|
1344
|
+
environment_name: environment.name,
|
|
1345
|
+
environment_uuid: environment.uuid,
|
|
1346
|
+
docker_registry_image_name: STALWART_IMAGE,
|
|
1347
|
+
docker_registry_image_tag: imageTag,
|
|
1348
|
+
ports_exposes: STALWART_PORTS_EXPOSES,
|
|
1349
|
+
ports_mappings: STALWART_PORTS_MAPPINGS,
|
|
1350
|
+
name: PLATFORM_MAIL_APP_NAME,
|
|
1351
|
+
description: `Stalwart for the platform's own mail (#169, ${STALWART_IMAGE}:${imageTag})`,
|
|
1352
|
+
instant_deploy: false,
|
|
1353
|
+
});
|
|
1354
|
+
});
|
|
1355
|
+
const appId = app.appId;
|
|
1356
|
+
await record(PLATFORM_MAIL_APP_SCOPE_KEY, appId);
|
|
1357
|
+
// Set on every run, not only after a create -- same rule `provisionPlatformAuth` follows for its own apps.
|
|
1358
|
+
// The image tag too: a declared release change reaches the app here.
|
|
1359
|
+
await deps.coolify.updateApplication(appId, {
|
|
1360
|
+
custom_internal_name: PLATFORM_MAIL_INTERNAL_NAME,
|
|
1361
|
+
docker_registry_image_tag: imageTag,
|
|
1362
|
+
});
|
|
1363
|
+
// #177: org steps (an org's own `coolify:mail`/`coolify:agora`) read this
|
|
1364
|
+
// back instead of a literal, the same way files/ai's own internal
|
|
1365
|
+
// addresses are recorded.
|
|
1366
|
+
await record(PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, `http://${PLATFORM_MAIL_INTERNAL_NAME}:${STALWART_HTTP_PORT}`);
|
|
1367
|
+
// ---- storage ---------------------------------------------------------------
|
|
1368
|
+
const storages = await deps.coolify.listStorages(appId);
|
|
1369
|
+
const hasDataVolume = (storages.persistent_storages ?? []).some((s) => s.mount_path === STALWART_DATA_MOUNT);
|
|
1370
|
+
if (!hasDataVolume) {
|
|
1371
|
+
await deps.coolify.createPersistentStorage(appId, {
|
|
1372
|
+
name: `${PLATFORM_MAIL_APP_NAME}-data`,
|
|
1373
|
+
mount_path: STALWART_DATA_MOUNT,
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
// ---- env ---------------------------------------------------------------------
|
|
1377
|
+
// Generated once; unlike ZITADEL's masterkey this is never re-derived from
|
|
1378
|
+
// "is the database already in use", because losing it is not destructive
|
|
1379
|
+
// the same way -- worst case a later apply cannot read it back and the
|
|
1380
|
+
// admin-key mint below throws, naming exactly that.
|
|
1381
|
+
const recoveryGenerated = await ensureGeneratedEnv(deps.coolify, appId, 'STALWART_RECOVERY_ADMIN', () => `${STALWART_RECOVERY_USER}:${randomBytes(18).toString('base64')}`);
|
|
1382
|
+
const envs = [
|
|
1383
|
+
{ key: 'STALWART_PUBLIC_URL', value: `https://${hostname}` },
|
|
1384
|
+
];
|
|
1385
|
+
if (recoveryGenerated)
|
|
1386
|
+
envs.push({ key: 'STALWART_RECOVERY_ADMIN', value: recoveryGenerated });
|
|
1387
|
+
const envsChanged = await envsDiffer(deps.coolify, appId, envs);
|
|
1388
|
+
await deps.coolify.bulkUpsertEnvs(appId, envs);
|
|
1389
|
+
const declaredChanged = recorded[PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY] !== hostname ||
|
|
1390
|
+
recorded[PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY] !== imageTag;
|
|
1391
|
+
if (app.justCreated || !app.running || envsChanged || declaredChanged) {
|
|
1392
|
+
// A container still up from before would answer the readiness probe with
|
|
1393
|
+
// the old deployment's health. Stop it and see it stopped first, so the
|
|
1394
|
+
// wait below only ever reads the new deployment.
|
|
1395
|
+
if (!app.justCreated && app.running) {
|
|
1396
|
+
await deps.coolify.raw('POST', `/applications/${appId}/stop`);
|
|
1397
|
+
await waitForMailStopped(deps.coolify, appId, runtime, sleep);
|
|
1398
|
+
}
|
|
1399
|
+
await deps.coolify.deploy(appId);
|
|
1400
|
+
await record(PLATFORM_MAIL_DEPLOYED_SCOPE_KEY, appId);
|
|
1401
|
+
// Right after the deploy, so a later failure (tunnel, DNS, readiness,
|
|
1402
|
+
// settings, mint) does not make the next run stop and redeploy the app.
|
|
1403
|
+
await record(PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY, hostname);
|
|
1404
|
+
await record(PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY, imageTag);
|
|
1405
|
+
}
|
|
1406
|
+
// ---- tunnel and DNS: web/JMAP/management ------------------------------------
|
|
1407
|
+
const existingTunnel = await deps.cloudflare.findTunnelByName(PLATFORM_MAIL_TUNNEL_NAME);
|
|
1408
|
+
const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(PLATFORM_MAIL_TUNNEL_NAME));
|
|
1409
|
+
await record(PLATFORM_MAIL_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
1410
|
+
await deps.cloudflare.putTunnelConfig(tunnel.id, {
|
|
1411
|
+
ingress: [
|
|
1412
|
+
{ hostname, service: `http://${PLATFORM_MAIL_INTERNAL_NAME}:${STALWART_HTTP_PORT}` },
|
|
1413
|
+
{ service: 'http_status:404' },
|
|
1414
|
+
],
|
|
1415
|
+
});
|
|
1416
|
+
// #197: without a connector, `hostname` answers with a Cloudflare error.
|
|
1417
|
+
await ensureCloudflaredConnector({
|
|
1418
|
+
coolify: deps.coolify,
|
|
1419
|
+
cloudflare: deps.cloudflare,
|
|
1420
|
+
tunnelId: tunnel.id,
|
|
1421
|
+
appName: PLATFORM_MAIL_CLOUDFLARED_APP_NAME,
|
|
1422
|
+
projectUuid,
|
|
1423
|
+
environment,
|
|
1424
|
+
// The Stalwart app's own server, so the connector shares its Docker network.
|
|
1425
|
+
resolveServer: async () => server,
|
|
1426
|
+
recordedConnectorAppId: recorded[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
1427
|
+
recordAppId: (appId) => record(PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, appId),
|
|
1428
|
+
});
|
|
1429
|
+
const zoneId = recorded[apexZoneKey(platform.apex)] ?? (await exactZone(deps.cloudflare, platform.apex))?.id;
|
|
1430
|
+
if (!zoneId) {
|
|
1431
|
+
throw new Error(`platform:mail: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
|
|
1432
|
+
}
|
|
1433
|
+
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
1434
|
+
const [dnsRecord] = await deps.cloudflare.findDnsRecord(zoneId, hostname, 'CNAME');
|
|
1435
|
+
if (!dnsRecord) {
|
|
1436
|
+
await deps.cloudflare.createDnsRecord(zoneId, {
|
|
1437
|
+
type: 'CNAME',
|
|
1438
|
+
name: hostname,
|
|
1439
|
+
content: dnsTarget,
|
|
1440
|
+
proxied: true,
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
else if (dnsRecord.content !== dnsTarget) {
|
|
1444
|
+
await deps.cloudflare.updateDnsRecord(zoneId, dnsRecord.id, {
|
|
1445
|
+
type: 'CNAME',
|
|
1446
|
+
name: hostname,
|
|
1447
|
+
content: dnsTarget,
|
|
1448
|
+
proxied: true,
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
// ---- the MX hostname: a direct, unproxied A record --------------------------
|
|
1452
|
+
const serverIp = server.ip; // checked by mailServerIpBlocked above
|
|
1453
|
+
const [mxRecord] = await deps.cloudflare.findDnsRecord(zoneId, mailHost, 'A');
|
|
1454
|
+
if (!mxRecord) {
|
|
1455
|
+
await deps.cloudflare.createDnsRecord(zoneId, {
|
|
1456
|
+
type: 'A',
|
|
1457
|
+
name: mailHost,
|
|
1458
|
+
content: serverIp,
|
|
1459
|
+
proxied: false,
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
else if (mxRecord.content !== serverIp) {
|
|
1463
|
+
await deps.cloudflare.updateDnsRecord(zoneId, mxRecord.id, {
|
|
1464
|
+
type: 'A',
|
|
1465
|
+
name: mailHost,
|
|
1466
|
+
content: serverIp,
|
|
1467
|
+
proxied: false,
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
// ---- OIDC directory and foundry's own admin API key, once --------------------
|
|
1471
|
+
if (!recorded[PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY]) {
|
|
1472
|
+
const lost = recorded[PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY];
|
|
1473
|
+
if (lost) {
|
|
1474
|
+
throw new Error(`platform:mail: 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`);
|
|
1475
|
+
}
|
|
1476
|
+
await waitForMailInstance(`https://${hostname}${STALWART_READY_PATH}`, runtime, sleep);
|
|
1477
|
+
const recoveryEnv = (await deps.coolify.listEnvs(appId)).find((e) => e.key === 'STALWART_RECOVERY_ADMIN' && !e.is_preview);
|
|
1478
|
+
if (!recoveryEnv) {
|
|
1479
|
+
throw new Error('platform:mail: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to mint the admin key');
|
|
1480
|
+
}
|
|
1481
|
+
const password = recoveryEnv.value.slice(`${STALWART_RECOVERY_USER}:`.length);
|
|
1482
|
+
try {
|
|
1483
|
+
const admin = deps.stalwartForInstance({
|
|
1484
|
+
url: `https://${hostname}`,
|
|
1485
|
+
user: STALWART_RECOVERY_USER,
|
|
1486
|
+
password,
|
|
1487
|
+
});
|
|
1488
|
+
await admin.applySettings({
|
|
1489
|
+
mailHost,
|
|
1490
|
+
oidc: { issuerUrl: `https://${platform.auth.hostname}`, audience: deps.oidcAudience },
|
|
1491
|
+
});
|
|
1492
|
+
// Recorded and persisted before the secret leaves this function: if
|
|
1493
|
+
// delivery fails for good, the next plan reports the lost key rather
|
|
1494
|
+
// than skipping or minting again.
|
|
1495
|
+
await record(PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION);
|
|
1496
|
+
const secret = await admin.createApiKey(PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, 'inherit');
|
|
1497
|
+
const attempts = runtime.deliverAttempts ?? 3;
|
|
1498
|
+
for (let attempt = 1;; attempt += 1) {
|
|
1499
|
+
try {
|
|
1500
|
+
await deps.deliverSecret('stalwart:platform:admin', secret);
|
|
1501
|
+
break;
|
|
1502
|
+
}
|
|
1503
|
+
catch {
|
|
1504
|
+
if (attempt >= attempts) {
|
|
1505
|
+
throw new Error(`platform:mail: the admin API key ("${PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION}") was minted but could not be delivered after ${attempts} tries`);
|
|
1506
|
+
}
|
|
1507
|
+
await sleep(1000 * attempt);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
await record(PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, appId);
|
|
1511
|
+
}
|
|
1512
|
+
catch (error) {
|
|
1513
|
+
throw redactedMailError(error, [password]);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
// Last, after every write the applied values depend on, so a run that
|
|
1517
|
+
// failed halfway plans again.
|
|
1518
|
+
await record(PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
|
|
1519
|
+
await record(PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, imageTag);
|
|
1520
|
+
return { resourceId: appId };
|
|
1521
|
+
}
|
|
1522
|
+
/** Why the Stalwart image tag cannot be used, or `null`. A floating tag cannot be re-pulled by comparing tags. */
|
|
1523
|
+
function mailImageTagBlocked(tag) {
|
|
1524
|
+
if (tag.trim() === '' || tag.trim().toLowerCase() === 'latest') {
|
|
1525
|
+
return `the platform's mail server is blocked, not planned: the Stalwart image tag "${tag}" floats; declare a pinned release (for example v0.16.24) as the mail gitRef`;
|
|
1526
|
+
}
|
|
1527
|
+
return null;
|
|
1528
|
+
}
|
|
1529
|
+
/** Why the server's address cannot be the MX A record's content, or `null`. */
|
|
1530
|
+
function mailServerIpBlocked(ip) {
|
|
1531
|
+
if (!ip) {
|
|
1532
|
+
return "the platform's mail server is blocked, not planned: the Coolify server reports no ip, so mx.<apex> has no address to point at; set the server's IP in Coolify";
|
|
1533
|
+
}
|
|
1534
|
+
if (!isIPv4(ip)) {
|
|
1535
|
+
return `the platform's mail server is blocked, not planned: the Coolify server's ip "${ip}" is not an IPv4 address, so it cannot be the mx.<apex> A record; set the server's public IPv4 in Coolify`;
|
|
1536
|
+
}
|
|
1537
|
+
return null;
|
|
1538
|
+
}
|
|
1539
|
+
/** The recorded server when one is recorded and still listed, else the platform's usual pick. */
|
|
1540
|
+
async function resolveMailServer(coolify, recordedUuid) {
|
|
1541
|
+
if (!recordedUuid)
|
|
1542
|
+
return resolvePlatformServer(coolify);
|
|
1543
|
+
const server = (await coolify.listServers()).find((s) => s.uuid === recordedUuid);
|
|
1544
|
+
if (!server) {
|
|
1545
|
+
throw new Error(`platform:mail: the Coolify server ${recordedUuid} the mail app was created on is no longer listed`);
|
|
1546
|
+
}
|
|
1547
|
+
return server;
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* The mail step's own reasons it cannot run yet, as fixed text: missing
|
|
1551
|
+
* dependencies or audience, a floating image tag, a server with no usable
|
|
1552
|
+
* address. Skipped once the step is applied at the declared values, so a
|
|
1553
|
+
* live app is not blocked by a later Coolify change to the server's address.
|
|
1554
|
+
*/
|
|
1555
|
+
async function mailBlockedReason(platform, mail, recordedIds) {
|
|
1556
|
+
if (!mail) {
|
|
1557
|
+
return "the platform's mail server is blocked, not planned: the host built the platform executor without its mail dependencies";
|
|
1558
|
+
}
|
|
1559
|
+
if (recordedIds[`coolify:app:${PLATFORM_MAIL_KEY}`] &&
|
|
1560
|
+
recordedIds[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] === platform.mail.hostname &&
|
|
1561
|
+
recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === platform.mail.gitRef &&
|
|
1562
|
+
recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY]) {
|
|
1563
|
+
return null;
|
|
1564
|
+
}
|
|
1565
|
+
if (!mail.oidcAudience || mail.oidcAudience.trim() === '') {
|
|
1566
|
+
return "the platform's mail server is blocked, not planned: the host supplies no oidcAudience, the ZITADEL resource id Stalwart's OIDC directory checks access tokens against";
|
|
1567
|
+
}
|
|
1568
|
+
const tagBlocked = mailImageTagBlocked(platform.mail.gitRef);
|
|
1569
|
+
if (tagBlocked)
|
|
1570
|
+
return tagBlocked;
|
|
1571
|
+
try {
|
|
1572
|
+
const server = await resolveMailServer(mail.coolify, recordedIds[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
|
|
1573
|
+
return mailServerIpBlocked(server.ip);
|
|
1574
|
+
}
|
|
1575
|
+
catch {
|
|
1576
|
+
return "the platform's mail server is blocked, not planned: no usable Coolify server could be resolved for it";
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* #211: the broker cannot start without `ZITADEL_SERVICE_USER_TOKEN`/`_FILE`,
|
|
1581
|
+
* `RESEND_API_TOKEN` and `AUTH_APPS`. `provisionPlatformAuth` already skips
|
|
1582
|
+
* deploying the broker while any is missing ({@link missingBrokerInputs});
|
|
1583
|
+
* this is the same check surfaced through `blockedSteps`, so a plan says
|
|
1584
|
+
* why, by name, rather than the broker simply sitting undeployed with no
|
|
1585
|
+
* explanation. `null` while there is no broker app id recorded yet (nothing
|
|
1586
|
+
* to check) or every input is present.
|
|
1587
|
+
*
|
|
1588
|
+
* #211 round 3: wraps the live read the way {@link mailBlockedReason} wraps
|
|
1589
|
+
* its own Coolify call -- a transient `listEnvs` failure must report a
|
|
1590
|
+
* blocked reason naming the broker app id, never throw out of
|
|
1591
|
+
* `blockedSteps` and take the rest of the plan down with it.
|
|
1592
|
+
*
|
|
1593
|
+
* #211 round 5 addendum: when `AUTH_APPS` is the reason, names which problem
|
|
1594
|
+
* it actually is ({@link authAppsProblem}) instead of always saying "it has
|
|
1595
|
+
* no ... AUTH_APPS yet" -- that wording is only true when nothing has been
|
|
1596
|
+
* set; a present-but-invalid value is a different failure (a person needs to
|
|
1597
|
+
* go fix an entry, not merely wait for one of #209/#210/Archon's own
|
|
1598
|
+
* registration to supply one), and reporting it identically hid which one
|
|
1599
|
+
* was true.
|
|
1600
|
+
*/
|
|
1601
|
+
async function authBrokerBlockedReason(auth, recordedIds) {
|
|
1602
|
+
const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
|
|
1603
|
+
if (!brokerAppId)
|
|
1604
|
+
return { reason: null, otherInputsMissing: [] };
|
|
1605
|
+
let missing;
|
|
1606
|
+
let authAppsRaw;
|
|
1607
|
+
try {
|
|
1608
|
+
missing = await missingBrokerInputs(auth.coolify, brokerAppId);
|
|
1609
|
+
authAppsRaw = await currentEnvValue(auth.coolify, brokerAppId, 'AUTH_APPS');
|
|
1610
|
+
}
|
|
1611
|
+
catch {
|
|
1612
|
+
return {
|
|
1613
|
+
reason: `the platform's sign-in broker ${brokerAppId} is blocked, not deployed: its live env could not be read`,
|
|
1614
|
+
// #216 round 5: unreadable, not "known missing" -- `planAuth` must not
|
|
1615
|
+
// assume the login-client mint is blocked by anything in particular.
|
|
1616
|
+
otherInputsMissing: [],
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
// #216 round 5: what `provisionPlatformAuth`'s own `loginClientOtherInputsMissing`
|
|
1620
|
+
// gate is -- see `planAuth`'s own doc comment for why it needs this too.
|
|
1621
|
+
const otherInputsMissing = missing.filter((m) => m !== 'ZITADEL_SERVICE_USER_TOKEN');
|
|
1622
|
+
if (missing.length === 0)
|
|
1623
|
+
return { reason: null, otherInputsMissing };
|
|
1624
|
+
if (authAppsProblem(authAppsRaw, brokerAppId) === 'malformed') {
|
|
1625
|
+
const otherMissing = missing.filter((m) => m !== 'AUTH_APPS');
|
|
1626
|
+
const otherSuffix = otherMissing.length > 0
|
|
1627
|
+
? `, and it has no ${otherMissing.join(' and no ')} yet (#209, #210)`
|
|
1628
|
+
: '';
|
|
1629
|
+
return {
|
|
1630
|
+
reason: `the platform's sign-in broker ${brokerAppId} is blocked, not deployed: its AUTH_APPS is malformed${otherSuffix}`,
|
|
1631
|
+
otherInputsMissing,
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
// AUTH_APPS isn't gated on #209/#210 the way the two tokens are; only cite
|
|
1635
|
+
// those issues when one of them is actually among what's missing.
|
|
1636
|
+
const suffix = missing.some((m) => m !== 'AUTH_APPS') ? ' (#209, #210)' : '';
|
|
1637
|
+
return {
|
|
1638
|
+
reason: `the platform's sign-in broker is created but not deployed, blocked: it has no ${missing.join(' and no ')} yet${suffix}`,
|
|
1639
|
+
otherInputsMissing,
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
/** Polls until Coolify reports the mail app stopped, so a healthy answer read afterwards is the new deployment's. */
|
|
1643
|
+
async function waitForMailStopped(coolify, appId, runtime, sleep) {
|
|
1644
|
+
const now = runtime.now ?? Date.now;
|
|
1645
|
+
const timeout = runtime.readyTimeoutMs ?? 300_000;
|
|
1646
|
+
const interval = runtime.readyIntervalMs ?? 5_000;
|
|
1647
|
+
const deadline = now() + timeout;
|
|
1648
|
+
for (;;) {
|
|
1649
|
+
const { status } = await coolify.getApplication(appId);
|
|
1650
|
+
if (!status || !isRunningOrStarting(status))
|
|
1651
|
+
return;
|
|
1652
|
+
if (now() >= deadline) {
|
|
1653
|
+
throw new Error(`platform:mail: the Stalwart app ${appId} did not stop within ${Math.round(timeout / 1000)}s (last status ${status ?? 'unknown'}); apply again`);
|
|
1654
|
+
}
|
|
1655
|
+
await sleep(interval);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
/** Polls `url` until it answers 2xx, or throws once `readyTimeoutMs` has passed. */
|
|
1659
|
+
async function waitForMailInstance(url, runtime, sleep) {
|
|
1660
|
+
const fetchImpl = runtime.fetch ?? globalThis.fetch;
|
|
1661
|
+
const now = runtime.now ?? Date.now;
|
|
1662
|
+
const timeout = runtime.readyTimeoutMs ?? 300_000;
|
|
1663
|
+
const interval = runtime.readyIntervalMs ?? 5_000;
|
|
1664
|
+
const deadline = now() + timeout;
|
|
1665
|
+
for (;;) {
|
|
1666
|
+
try {
|
|
1667
|
+
const response = await fetchImpl(url);
|
|
1668
|
+
if (response.ok)
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
catch {
|
|
1672
|
+
// Not up yet: DNS, the tunnel or the container.
|
|
1673
|
+
}
|
|
1674
|
+
if (now() >= deadline) {
|
|
1675
|
+
throw new Error(`platform:mail: the new Stalwart instance did not answer at ${url} within ${Math.round(timeout / 1000)}s; retry this apply`);
|
|
1676
|
+
}
|
|
1677
|
+
await sleep(interval);
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
function redactedMailError(error, secrets) {
|
|
1681
|
+
const original = error instanceof Error ? error : new Error(String(error));
|
|
1682
|
+
const message = redactSecrets(original.message, secrets);
|
|
1683
|
+
const stack = redactSecrets(original.stack ?? message, secrets);
|
|
1684
|
+
if (message === original.message && stack === original.stack)
|
|
1685
|
+
return original;
|
|
1686
|
+
const clean = new Error(message);
|
|
1687
|
+
clean.name = original.name;
|
|
1688
|
+
clean.stack = stack;
|
|
1689
|
+
return clean;
|
|
1690
|
+
}
|
|
1691
|
+
const PLATFORM_ARCHON_PROJECT = 'platform';
|
|
1692
|
+
const PLATFORM_ARCHON_APP_NAME = 'platform-archon';
|
|
1693
|
+
const PLATFORM_ARCHON_DB_NAME = 'platform-archon-db';
|
|
1694
|
+
/**
|
|
1695
|
+
* The database Coolify creates for Archon. Archon's own
|
|
1696
|
+
* `drizzle/0001_runtime_role.sql` names its runtime role
|
|
1697
|
+
* `current_database() || '_rt'`, so {@link ARCHON_RUNTIME_ROLE} follows.
|
|
1698
|
+
*/
|
|
1699
|
+
const ARCHON_POSTGRES_DB = 'archon';
|
|
1700
|
+
const ARCHON_RUNTIME_ROLE = `${ARCHON_POSTGRES_DB}_rt`;
|
|
1701
|
+
/** Fixed so the tunnel addresses the container directly (see `provisionPlatformAuth`). */
|
|
1702
|
+
const PLATFORM_ARCHON_INTERNAL_NAME = 'platform-archon';
|
|
1703
|
+
const PLATFORM_ARCHON_TUNNEL_NAME = 'platform-archon-tunnel';
|
|
1704
|
+
/** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_ARCHON_TUNNEL_NAME}. */
|
|
1705
|
+
const PLATFORM_ARCHON_CLOUDFLARED_APP_NAME = 'platform-archon-cloudflared';
|
|
1706
|
+
/** https://github.com/wtfalch/archon: one Dockerfile at its root, `runner` stage on port 3000. */
|
|
1707
|
+
const ARCHON_REPO = 'wtfalch/archon';
|
|
1708
|
+
const ARCHON_PORT = '3000';
|
|
1709
|
+
/**
|
|
1710
|
+
* The name of foundry's own Cloudflare Access app in front of Archon. Same
|
|
1711
|
+
* `foundry:<key>` convention `executor.ts`'s `cloudflareAccess` uses for an
|
|
1712
|
+
* org (`foundry:<orgId>`), with the platform's step key in the org id's place.
|
|
1713
|
+
*/
|
|
1714
|
+
export const PLATFORM_ARCHON_ACCESS_APP_NAME = `foundry:${PLATFORM_ARCHON_KEY}`;
|
|
1715
|
+
/** The ZITADEL project and OIDC app Archon's sign-in client lives in. */
|
|
1716
|
+
const PLATFORM_ARCHON_ZITADEL_PROJECT = 'archon';
|
|
1717
|
+
const PLATFORM_ARCHON_ZITADEL_APP = 'archon';
|
|
1718
|
+
const PLATFORM_ARCHON_PROJECT_SCOPE_KEY = 'coolify_project_platform_archon';
|
|
1719
|
+
const PLATFORM_ARCHON_DB_SCOPE_KEY = 'coolify_platform_archon_database';
|
|
1720
|
+
const PLATFORM_ARCHON_TUNNEL_SCOPE_KEY = 'cloudflare_platform_archon_tunnel';
|
|
1721
|
+
const PLATFORM_ARCHON_ACCESS_SCOPE_KEY = 'cloudflare_platform_archon_access_app';
|
|
1722
|
+
/** The platform's own ZITADEL org (`foundry`, the one `provisionPlatformAuth` mints its admin user in). */
|
|
1723
|
+
export const PLATFORM_ZITADEL_ORG_SCOPE_KEY = 'zitadel_platform_org';
|
|
1724
|
+
const PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_platform_archon_project';
|
|
1725
|
+
const PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY = 'zitadel_platform_archon_app';
|
|
1726
|
+
/** The hostname the sign-in client's redirect URIs were made for; a change makes a new client. */
|
|
1727
|
+
const PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY = 'zitadel_platform_archon_client_hostname';
|
|
1728
|
+
/** Archon's OIDC client id: public, not a secret, so a plain scope id. */
|
|
1729
|
+
export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_client_id';
|
|
1730
|
+
/**
|
|
1731
|
+
* #175: makes `coolify:app` keyed {@link PLATFORM_ARCHON_KEY} true: Archon
|
|
1732
|
+
* from https://github.com/wtfalch/archon on `platform.archon.gitRef`, its own
|
|
1733
|
+
* Postgres, a sign-in client in the platform's own ZITADEL, a Cloudflare
|
|
1734
|
+
* Access app that lets only {@link PlatformArchonDeps.accessEmails} through,
|
|
1735
|
+
* and a tunnel plus DNS record at `platform.archon.hostname`.
|
|
1736
|
+
*
|
|
1737
|
+
* One Coolify app, no separate migrate job: Archon's `Dockerfile` has no
|
|
1738
|
+
* migration stage (its closing note says the old one was removed), and its
|
|
1739
|
+
* `runner` starts with `scripts/migrate-and-start.sh`, which applies
|
|
1740
|
+
* `drizzle/*.sql` as `DATABASE_URL_OWNER`, creates the `<database>_rt` runtime
|
|
1741
|
+
* role, sets its password from `DATABASE_URL`, unsets the owner URL and only
|
|
1742
|
+
* then starts the server.
|
|
1743
|
+
*
|
|
1744
|
+
* Order matters in two places. The Access app is created before the DNS
|
|
1745
|
+
* record, so the hostname never resolves without the gate in front of it
|
|
1746
|
+
* (Archon's `docs/operator-deployment.md`: "Install the Access gate before
|
|
1747
|
+
* publishing DNS"). The sign-in client is created before the env push, so
|
|
1748
|
+
* `AUTH_CLIENT_ID` is in the first deploy.
|
|
1749
|
+
*
|
|
1750
|
+
* Env written from the applied platform and held keys, never pasted:
|
|
1751
|
+
* - `APP_URL`, `AUTH_ISSUER` (`https://<auth.hostname>`),
|
|
1752
|
+
* `AUTH_ORGANIZATION_ID` and `AUTH_CLIENT_ID` (both from ZITADEL, this run),
|
|
1753
|
+
* `AUTH_COOKIE_SECRET` (generated once, never rotated).
|
|
1754
|
+
* - `DATABASE_URL_OWNER` (Coolify's `internal_db_url`) and `DATABASE_URL`
|
|
1755
|
+
* (the `archon_rt` runtime role, its password minted once, never rotated,
|
|
1756
|
+
* the same rule `executor.ts`'s `ensureCmsRuntimeUrl` follows). A URL that
|
|
1757
|
+
* does not parse fails the step without printing it.
|
|
1758
|
+
* - For each of files, ai, integrations and jobs that the platform declares
|
|
1759
|
+
* enabled: its `https://<hostname>` URL, and its management key when
|
|
1760
|
+
* {@link PlatformArchonDeps.heldKeys} holds one.
|
|
1761
|
+
*
|
|
1762
|
+
* The OIDC client is ZITADEL's public web client (authorization code with
|
|
1763
|
+
* PKCE, no client secret: `createOidcApp` never returns one, see
|
|
1764
|
+
* `executor.ts`'s `zitadelApp`), which is what Archon's `@wtfalch/auth`
|
|
1765
|
+
* sign-in reads -- it takes `AUTH_CLIENT_ID` and no client secret. So there
|
|
1766
|
+
* is no one-time secret to hand to `deliverSecret` here. The three values this
|
|
1767
|
+
* step does generate (cookie secret, runtime-role password, `AUTH_APP_KEY`)
|
|
1768
|
+
* go straight into the app's env and nowhere else: never returned, recorded,
|
|
1769
|
+
* logged or put in an error.
|
|
1770
|
+
*
|
|
1771
|
+
* `AUTH_APP_KEY` (#204): minted once, the same `ensureGeneratedEnv` way as
|
|
1772
|
+
* the cookie secret, and registered with the broker by rewriting its
|
|
1773
|
+
* `AUTH_APPS` env to include Archon's entry -- `deliverSecret` is not the
|
|
1774
|
+
* right shape here, since the destination is another app's env this same
|
|
1775
|
+
* function already writes to, not a human or a secret store. Requires
|
|
1776
|
+
* {@link PLATFORM_AUTH_BROKER_SCOPE_KEY} to be recorded, which
|
|
1777
|
+
* `provisionPlatformAuth` guarantees by the time this step is reachable (its
|
|
1778
|
+
* own resourceId records only once the broker app exists too). The broker
|
|
1779
|
+
* will not actually be able to serve a request with this key until
|
|
1780
|
+
* `provisionPlatformAuth`'s own "Known gaps" (`ZITADEL_SERVICE_USER_TOKEN`,
|
|
1781
|
+
* `RESEND_API_TOKEN`) are closed; wiring the key here is still correct and
|
|
1782
|
+
* needed so the two apps agree the moment the broker can run.
|
|
1783
|
+
*
|
|
1784
|
+
* Known gaps, left for review:
|
|
1785
|
+
* - `AUTHZ_BOOTSTRAP_OPERATOR`: the first operator's ZITADEL subject id. A
|
|
1786
|
+
* person has to exist in the new instance first; not something foundry
|
|
1787
|
+
* mints.
|
|
1788
|
+
* - Foundry's own database URL. Archon runs foundry in its panel, but foundry
|
|
1789
|
+
* does not know its own connection string at plan/apply time: that is host
|
|
1790
|
+
* config, not something `DeclaredPlatform` or held keys carry.
|
|
1791
|
+
* - The refs and scope ids Archon reads from the files, ai and integrations
|
|
1792
|
+
* steps are exported by those steps' own modules. A
|
|
1793
|
+
* missing one blocks the plan (`blockedSteps`); it is never skipped. The
|
|
1794
|
+
* env names `AI_*`, `INTEGRATIONS_*`, `JOBS_URL` and `JOBS_ADMIN_TOKEN` are
|
|
1795
|
+
* this step's proposal; Archon reads only `FILES_URL` and
|
|
1796
|
+
* `FILES_MANAGEMENT_KEY` of these today. Files' signed-assertion identity
|
|
1797
|
+
* (`FILES_MANAGEMENT_ISSUER`, `_KEY_ID`, `_PRIVATE_KEY`, `_SCOPE`) and
|
|
1798
|
+
* `FILES_ORGANISATION_ID` are not wired.
|
|
1799
|
+
* - The step plans again when the hostname, `gitRef` or the Access policy's
|
|
1800
|
+
* include list changes. Env for a service enabled later is not pushed until
|
|
1801
|
+
* one of those changes.
|
|
1802
|
+
* - No API updates an app's Access policy, so a changed include list closes
|
|
1803
|
+
* the tunnel hostname, deletes the Access app and builds it again.
|
|
1804
|
+
* - #197: a `cloudflared` connector runs for this tunnel, as in `provisionPlatformAuth`.
|
|
1805
|
+
* - Retiring `operator.wtfalch.dev` stays a human step.
|
|
1806
|
+
*/
|
|
1807
|
+
async function provisionPlatformArchon(context, deps) {
|
|
1808
|
+
const { platform } = context;
|
|
1809
|
+
const hostname = platform.archon.hostname;
|
|
1810
|
+
const gitRef = platform.archon.gitRef;
|
|
1811
|
+
const recorded = context.recordedIds();
|
|
1812
|
+
const issuer = `https://${platform.auth.hostname}`;
|
|
1813
|
+
const appliedGitRef = recorded[PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY];
|
|
1814
|
+
if (deps.accessEmails.length === 0) {
|
|
1815
|
+
throw new Error('platform:archon: no Access emails given; refusing to put Archon behind an Access app nobody can pass');
|
|
1816
|
+
}
|
|
1817
|
+
// The sign-in client below is created inside the platform's own ZITADEL,
|
|
1818
|
+
// which `platform:auth` builds. The planner orders that step first and
|
|
1819
|
+
// blocks this one while auth is off; this is the same rule for a caller
|
|
1820
|
+
// that reaches the step anyway.
|
|
1821
|
+
if (!platform.auth.enabled || !recorded[`coolify:app:${PLATFORM_AUTH_KEY}`]) {
|
|
1822
|
+
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");
|
|
1823
|
+
}
|
|
1824
|
+
const record = async (key, id) => {
|
|
1825
|
+
context.recordScopeId(key, id);
|
|
1826
|
+
await context.persist?.();
|
|
1827
|
+
};
|
|
1828
|
+
// Every service the platform declares must have delivered what Archon reads.
|
|
1829
|
+
// The planner blocks first; this is the same rule for a caller that reaches
|
|
1830
|
+
// the step anyway, and it is where the values are read for the env below.
|
|
1831
|
+
const inputs = await collectArchonServiceInputs(platform, deps, recorded);
|
|
1832
|
+
if ('blocked' in inputs)
|
|
1833
|
+
throw new Error(`platform:archon: ${inputs.blocked}`);
|
|
1834
|
+
// #213: Archon's own onboarding reads these back as held keys before it
|
|
1835
|
+
// can start. Put before anything below touches Coolify, so a host that
|
|
1836
|
+
// cannot put them never gets a half-built app.
|
|
1837
|
+
if (!deps.masterKeys || !deps.heldKeys?.put) {
|
|
1838
|
+
throw new Error("platform:archon: the host built the platform executor without a way to put its master keys (masterKeys, and heldKeys with a put) -- Archon's onboarding reads them as held keys before it can start (#213)");
|
|
1839
|
+
}
|
|
1840
|
+
await putArchonMasterKeys(deps.heldKeys, deps.masterKeys);
|
|
1841
|
+
// ---- Coolify project and database -----------------------------------------
|
|
1842
|
+
let projectUuid = recorded[PLATFORM_ARCHON_PROJECT_SCOPE_KEY];
|
|
1843
|
+
if (!projectUuid) {
|
|
1844
|
+
const found = await deps.coolify.findProjectByName(PLATFORM_ARCHON_PROJECT);
|
|
1845
|
+
projectUuid = found
|
|
1846
|
+
? found.uuid
|
|
1847
|
+
: (await deps.coolify.createProject(PLATFORM_ARCHON_PROJECT)).uuid;
|
|
1848
|
+
await record(PLATFORM_ARCHON_PROJECT_SCOPE_KEY, projectUuid);
|
|
1849
|
+
}
|
|
1850
|
+
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1851
|
+
let dbId = recorded[PLATFORM_ARCHON_DB_SCOPE_KEY];
|
|
1852
|
+
if (!dbId) {
|
|
1853
|
+
const server = await resolvePlatformServer(deps.coolify);
|
|
1854
|
+
const created = await deps.coolify.createPostgresDatabase({
|
|
1855
|
+
project_uuid: projectUuid,
|
|
1856
|
+
server_uuid: server.uuid,
|
|
1857
|
+
environment_name: environment.name,
|
|
1858
|
+
environment_uuid: environment.uuid,
|
|
1859
|
+
name: PLATFORM_ARCHON_DB_NAME,
|
|
1860
|
+
postgres_db: ARCHON_POSTGRES_DB,
|
|
1861
|
+
postgres_user: ARCHON_POSTGRES_DB,
|
|
1862
|
+
});
|
|
1863
|
+
dbId = created.uuid;
|
|
1864
|
+
await record(PLATFORM_ARCHON_DB_SCOPE_KEY, dbId);
|
|
1865
|
+
}
|
|
1866
|
+
const db = await deps.coolify.getDatabase(dbId);
|
|
1867
|
+
if (!db.internal_db_url) {
|
|
1868
|
+
throw new Error('coolify:app for the platform (archon): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
|
|
1869
|
+
}
|
|
1870
|
+
// ---- the app ----------------------------------------------------------------
|
|
1871
|
+
const app = await findOrCreatePlatformApp(deps.coolify, PLATFORM_ARCHON_APP_NAME, environment, async () => {
|
|
1872
|
+
const server = await resolvePlatformServer(deps.coolify);
|
|
1873
|
+
const githubApp = await deps.coolify.requireGithubApp();
|
|
1874
|
+
return deps.coolify.createGithubAppApplication({
|
|
1875
|
+
project_uuid: projectUuid,
|
|
1876
|
+
server_uuid: server.uuid,
|
|
1877
|
+
environment_name: environment.name,
|
|
1878
|
+
environment_uuid: environment.uuid,
|
|
1879
|
+
github_app_uuid: githubApp.uuid,
|
|
1880
|
+
git_repository: ARCHON_REPO,
|
|
1881
|
+
git_branch: gitRef,
|
|
1882
|
+
build_pack: 'dockerfile',
|
|
1883
|
+
name: PLATFORM_ARCHON_APP_NAME,
|
|
1884
|
+
});
|
|
1885
|
+
});
|
|
1886
|
+
const appId = app.appId;
|
|
1887
|
+
// Set on every run: the tunnel addresses the app by this name, and a throw
|
|
1888
|
+
// right after the create must not leave it unnamed for good.
|
|
1889
|
+
// `git_branch` is patched too, so a changed `gitRef` reaches the app; the
|
|
1890
|
+
// deploy below is decided from the last applied `gitRef`.
|
|
1891
|
+
await deps.coolify.updateApplication(appId, {
|
|
1892
|
+
custom_internal_name: PLATFORM_ARCHON_INTERNAL_NAME,
|
|
1893
|
+
git_branch: gitRef,
|
|
1894
|
+
});
|
|
1895
|
+
// ---- sign-in client in the platform's own ZITADEL -------------------------
|
|
1896
|
+
const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
|
|
1897
|
+
const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
|
|
1898
|
+
if (recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY] !== org.id) {
|
|
1899
|
+
await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, org.id);
|
|
1900
|
+
}
|
|
1901
|
+
const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_ARCHON_ZITADEL_PROJECT);
|
|
1902
|
+
if (recorded[PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY] !== project.id) {
|
|
1903
|
+
await record(PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY, project.id);
|
|
1904
|
+
}
|
|
1905
|
+
// The client's redirect URIs name the hostname, and no call updates them, so
|
|
1906
|
+
// a client made for another hostname is deleted and made again. Its id is
|
|
1907
|
+
// public and there is no secret to lose.
|
|
1908
|
+
let clientId = recorded[PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY] === hostname
|
|
1909
|
+
? recorded[PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY]
|
|
1910
|
+
: undefined;
|
|
1911
|
+
if (!clientId) {
|
|
1912
|
+
// An app under this name with no usable client id is one whose create
|
|
1913
|
+
// response was lost, or one made for another hostname: its client id
|
|
1914
|
+
// cannot be read back (`listApps` does not carry it), so it is deleted and
|
|
1915
|
+
// made again, as `zitadelApp` does.
|
|
1916
|
+
const orphan = (await zitadel.listApps(org.id, project.id)).find((a) => a.name === PLATFORM_ARCHON_ZITADEL_APP &&
|
|
1917
|
+
a.appType === 'web' &&
|
|
1918
|
+
a.state !== 'APP_STATE_REMOVED');
|
|
1919
|
+
if (orphan) {
|
|
1920
|
+
try {
|
|
1921
|
+
await zitadel.deleteApp(org.id, project.id, orphan.id);
|
|
1922
|
+
}
|
|
1923
|
+
catch (error) {
|
|
1924
|
+
if (!isNotFoundError(error))
|
|
1925
|
+
throw error;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
const created = await zitadel.createOidcApp(org.id, project.id, {
|
|
1929
|
+
name: PLATFORM_ARCHON_ZITADEL_APP,
|
|
1930
|
+
appType: 'web',
|
|
1931
|
+
// `@wtfalch/auth`'s default callback path (Archon's own
|
|
1932
|
+
// `docs/operator-deployment.md`, step 4).
|
|
1933
|
+
redirectUris: [`https://${hostname}/auth/callback`],
|
|
1934
|
+
postLogoutRedirectUris: [`https://${hostname}/`],
|
|
1935
|
+
});
|
|
1936
|
+
context.recordScopeId(PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, created.appId);
|
|
1937
|
+
context.recordScopeId(PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY, hostname);
|
|
1938
|
+
await record(PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY, created.clientId);
|
|
1939
|
+
clientId = created.clientId;
|
|
1940
|
+
}
|
|
1941
|
+
// ---- env ------------------------------------------------------------------
|
|
1942
|
+
const envs = [
|
|
1943
|
+
{ key: 'APP_URL', value: `https://${hostname}` },
|
|
1944
|
+
{ key: 'AUTH_ISSUER', value: issuer },
|
|
1945
|
+
{ key: 'AUTH_ORGANIZATION_ID', value: org.id },
|
|
1946
|
+
{ key: 'AUTH_CLIENT_ID', value: clientId },
|
|
1947
|
+
{ key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
|
|
1948
|
+
{ key: 'REPORTING_SITE', value: 'archon' },
|
|
1949
|
+
];
|
|
1950
|
+
const cookieSecret = await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_COOKIE_SECRET', () => randomBytes(32).toString('base64'));
|
|
1951
|
+
if (cookieSecret)
|
|
1952
|
+
envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
|
|
1953
|
+
const runtimeUrl = await ensureArchonRuntimeUrl(deps.coolify, appId, db.internal_db_url);
|
|
1954
|
+
if (runtimeUrl)
|
|
1955
|
+
envs.push({ key: 'DATABASE_URL', value: runtimeUrl });
|
|
1956
|
+
// `AUTH_APP_KEY` (#204): minted once like the cookie secret above, but read
|
|
1957
|
+
// back (never regenerated) rather than skipped when already set, since its
|
|
1958
|
+
// value is needed below to keep the broker's own registration in sync even
|
|
1959
|
+
// on a run that mints nothing new. `randomBytes(32)` (#211), the same
|
|
1960
|
+
// random half `wtfalch/auth/broker/src/apps.ts`'s own `mintKey` gives an
|
|
1961
|
+
// app key -- `parseApps` there rejects a key under 32 *characters*, and
|
|
1962
|
+
// `randomBytes(24)` base64url-encodes to exactly 32, landing right on that
|
|
1963
|
+
// edge with no margin for a different encoding or a trim.
|
|
1964
|
+
// #211 round 5: `minLength: 32` on this call only -- a live value shorter
|
|
1965
|
+
// than the broker itself will accept is exactly as unusable as one that is
|
|
1966
|
+
// empty, and `readBrokerApps`/`validateBrokerAppEntry` would refuse it the
|
|
1967
|
+
// moment this step tried to register it. See `ensureGeneratedEnv`'s own
|
|
1968
|
+
// doc comment for why no other call site gets this argument.
|
|
1969
|
+
const appKey = (await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_APP_KEY', () => randomBytes(32).toString('base64url'), 32)) ?? (await currentEnvValue(deps.coolify, appId, 'AUTH_APP_KEY'));
|
|
1970
|
+
if (!appKey) {
|
|
1971
|
+
throw new Error(`platform:archon: app ${appId} lost its AUTH_APP_KEY between two reads`);
|
|
1972
|
+
}
|
|
1973
|
+
envs.push({ key: 'AUTH_APP_KEY', value: appKey });
|
|
1974
|
+
envs.push(...inputs.envs);
|
|
1975
|
+
const changed = await envsDiffer(deps.coolify, appId, envs);
|
|
1976
|
+
await deps.coolify.bulkUpsertEnvs(appId, envs);
|
|
1977
|
+
// ---- register with the broker (#204) ---------------------------------------
|
|
1978
|
+
// After Archon's own `AUTH_APP_KEY` is durably written above, so a throw
|
|
1979
|
+
// earlier in this function (a bad database URL, a Cloudflare failure) never
|
|
1980
|
+
// touches the broker. `provisionPlatformAuth`'s own resourceId records only
|
|
1981
|
+
// once the broker app exists too (it is created before that function
|
|
1982
|
+
// returns), and this step is unreachable until that resourceId is recorded
|
|
1983
|
+
// (the guard at the top of this function) -- so a missing id here means a
|
|
1984
|
+
// caller reached this step some other way.
|
|
1985
|
+
const brokerAppId = recorded[PLATFORM_AUTH_BROKER_SCOPE_KEY];
|
|
1986
|
+
if (!brokerAppId) {
|
|
1987
|
+
throw new Error('platform:archon: platform:auth has not recorded its broker app id yet; apply platform:auth again');
|
|
1988
|
+
}
|
|
1989
|
+
const brokerApps = await readBrokerApps(deps.coolify, brokerAppId);
|
|
1990
|
+
// #211 round 2: merged into the matching entry, not replacing it -- the
|
|
1991
|
+
// broker's own `App` (`broker/src/apps.ts`) carries fields this step never
|
|
1992
|
+
// sets (`registration`, `locale`, `productName`, `emailLink`), and a
|
|
1993
|
+
// previous `filter`-then-append dropped every one of them, plus every
|
|
1994
|
+
// *other* app this organisation may have registered directly through the
|
|
1995
|
+
// broker's own registration API. Matched on `organizationId` and `name`
|
|
1996
|
+
// together: Archon is one app of possibly several this organisation runs,
|
|
1997
|
+
// and only Archon's own entry is this step's to touch.
|
|
1998
|
+
// #211 round 3: every entry matching Archon's org and name, not just the
|
|
1999
|
+
// first -- a previous bug, or a hand-edited AUTH_APPS, can leave more than
|
|
2000
|
+
// one. The first is kept (and updated below); the rest are dropped, so no
|
|
2001
|
+
// duplicate carries a stale key a broker client could still present.
|
|
2002
|
+
const archonMatchIndices = brokerApps
|
|
2003
|
+
.map((a, i) => (a.organizationId === org.id && a.name === PLATFORM_ARCHON_ZITADEL_APP ? i : -1))
|
|
2004
|
+
.filter((i) => i >= 0);
|
|
2005
|
+
const archonEntryIndex = archonMatchIndices[0] ?? -1;
|
|
2006
|
+
const duplicateIndices = new Set(archonMatchIndices.slice(1));
|
|
2007
|
+
const archonEntry = {
|
|
2008
|
+
...(archonEntryIndex >= 0 ? brokerApps[archonEntryIndex] : {}),
|
|
2009
|
+
organizationId: org.id,
|
|
2010
|
+
key: appKey,
|
|
2011
|
+
name: PLATFORM_ARCHON_ZITADEL_APP,
|
|
2012
|
+
clientIds: [clientId],
|
|
2013
|
+
origins: [`https://${hostname}`],
|
|
2014
|
+
// #211: `wtfalch/auth`'s broker (`broker/src/apps.ts`'s `parseAppFields`)
|
|
2015
|
+
// falls back to `wtfalch <noreply@wtfalch.dev>` for an entry with no
|
|
2016
|
+
// `from`, and (`wtfalch/auth#179`) an entry with no `from` is dropped
|
|
2017
|
+
// entirely -- either way, unset here means Archon's mail comes from
|
|
2018
|
+
// the wrong platform or not at all.
|
|
2019
|
+
from: `Company Included <noreply@${platform.apex}>`,
|
|
2020
|
+
};
|
|
2021
|
+
const desiredBrokerApps = archonEntryIndex >= 0
|
|
2022
|
+
? brokerApps
|
|
2023
|
+
.map((a, i) => (i === archonEntryIndex ? archonEntry : a))
|
|
2024
|
+
.filter((_, i) => !duplicateIndices.has(i))
|
|
2025
|
+
: [...brokerApps, archonEntry];
|
|
2026
|
+
const desiredAuthApps = [{ key: 'AUTH_APPS', value: JSON.stringify(desiredBrokerApps) }];
|
|
2027
|
+
// Rebuilt and compared every run (not gated on a freshly minted key), so a
|
|
2028
|
+
// clientId or hostname change reaches the broker too, and so a previous
|
|
2029
|
+
// run that wrote Archon's own key but failed before reaching the broker is
|
|
2030
|
+
// repaired by this one.
|
|
2031
|
+
const authAppsChanged = await envsDiffer(deps.coolify, brokerAppId, desiredAuthApps);
|
|
2032
|
+
if (authAppsChanged) {
|
|
2033
|
+
await deps.coolify.bulkUpsertEnvs(brokerAppId, desiredAuthApps);
|
|
2034
|
+
}
|
|
2035
|
+
// #211 round 2: deploying the broker before its own `server.ts` can start
|
|
2036
|
+
// (missing `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` or `RESEND_API_TOKEN`, #209,
|
|
2037
|
+
// #210) only produces a container that crash-loops behind `/api` -- the
|
|
2038
|
+
// same rule `provisionPlatformAuth` follows for its own deploy of this
|
|
2039
|
+
// app. The env is still written either way, so the broker serves the new
|
|
2040
|
+
// registration the moment it is next deployed, by either step.
|
|
2041
|
+
// #211 round 5: the deploy decision is {@link brokerDeployNeeded}, the same
|
|
2042
|
+
// rule `provisionPlatformAuth` uses -- it is what replaces the old
|
|
2043
|
+
// `!brokerAlreadyDeployed` boolean, and with it catches both a broker
|
|
2044
|
+
// recreated under a new app id (this step's own last-registered-broker
|
|
2045
|
+
// check, `archonBrokerRegistrationDrifted`, is a different, already-correct
|
|
2046
|
+
// mechanism for *replanning*; this is what stops the *executor* from
|
|
2047
|
+
// reading a stale record as already deployed) and a crash between this
|
|
2048
|
+
// run's own `AUTH_APPS` write above and its deploy -- a rerun recomputes
|
|
2049
|
+
// the identical `desiredAuthApps` value, sees no recorded deploy naming
|
|
2050
|
+
// that hash, and deploys again. `desiredAuthApps[0].value` (not a re-read)
|
|
2051
|
+
// is the exact string now live on the app either way: written just above
|
|
2052
|
+
// when `authAppsChanged`, already matching when it was not.
|
|
2053
|
+
const brokerMissingInputs = await missingBrokerInputs(deps.coolify, brokerAppId);
|
|
2054
|
+
const recordedBrokerDeploy = parseBrokerDeployRecord(recorded[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]);
|
|
2055
|
+
const desiredAuthAppsRaw = desiredAuthApps[0]?.value;
|
|
2056
|
+
if (brokerDeployNeeded(brokerMissingInputs, brokerAppId, desiredAuthAppsRaw, recordedBrokerDeploy)) {
|
|
2057
|
+
await deps.coolify.deploy(brokerAppId);
|
|
2058
|
+
await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, desiredAuthAppsRaw));
|
|
2059
|
+
}
|
|
2060
|
+
// ---- Cloudflare Access, before the hostname resolves ----------------------
|
|
2061
|
+
let gateClosed = Boolean(recorded[PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY]);
|
|
2062
|
+
const accessAppId = await ensureArchonAccess(deps.cloudflare, hostname, deps.accessEmails, async () => {
|
|
2063
|
+
// The Access app is about to be rebuilt: answer 404 for every hostname
|
|
2064
|
+
// meanwhile, so it is never served without the gate. The marker is
|
|
2065
|
+
// persisted first: a failure before the real ingress is written back
|
|
2066
|
+
// leaves it set, and the next plan repairs the step.
|
|
2067
|
+
gateClosed = true;
|
|
2068
|
+
await record(PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, '1');
|
|
2069
|
+
const tunnel = await deps.cloudflare.findTunnelByName(PLATFORM_ARCHON_TUNNEL_NAME);
|
|
2070
|
+
if (tunnel)
|
|
2071
|
+
await deps.cloudflare.putTunnelConfig(tunnel.id, {
|
|
2072
|
+
ingress: [{ service: 'http_status:404' }],
|
|
2073
|
+
});
|
|
2074
|
+
});
|
|
2075
|
+
if (recorded[PLATFORM_ARCHON_ACCESS_SCOPE_KEY] !== accessAppId) {
|
|
2076
|
+
await record(PLATFORM_ARCHON_ACCESS_SCOPE_KEY, accessAppId);
|
|
2077
|
+
}
|
|
2078
|
+
// ---- deploy ---------------------------------------------------------------
|
|
2079
|
+
if (app.justCreated ||
|
|
2080
|
+
!app.running ||
|
|
2081
|
+
changed ||
|
|
2082
|
+
appliedGitRef !== gitRef ||
|
|
2083
|
+
recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname) {
|
|
2084
|
+
await deps.coolify.deploy(appId);
|
|
2085
|
+
}
|
|
2086
|
+
// ---- tunnel and DNS ---------------------------------------------------------
|
|
2087
|
+
const existingTunnel = await deps.cloudflare.findTunnelByName(PLATFORM_ARCHON_TUNNEL_NAME);
|
|
2088
|
+
const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(PLATFORM_ARCHON_TUNNEL_NAME));
|
|
2089
|
+
if (recorded[PLATFORM_ARCHON_TUNNEL_SCOPE_KEY] !== tunnel.id) {
|
|
2090
|
+
await record(PLATFORM_ARCHON_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
2091
|
+
}
|
|
2092
|
+
await deps.cloudflare.putTunnelConfig(tunnel.id, {
|
|
2093
|
+
ingress: [
|
|
2094
|
+
{ hostname, service: `http://${PLATFORM_ARCHON_INTERNAL_NAME}:${ARCHON_PORT}` },
|
|
2095
|
+
{ service: 'http_status:404' },
|
|
2096
|
+
],
|
|
2097
|
+
});
|
|
2098
|
+
if (gateClosed) {
|
|
2099
|
+
await record(PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, '');
|
|
2100
|
+
}
|
|
2101
|
+
// #197: without a connector, `hostname` answers with a Cloudflare error.
|
|
2102
|
+
await ensureCloudflaredConnector({
|
|
2103
|
+
coolify: deps.coolify,
|
|
2104
|
+
cloudflare: deps.cloudflare,
|
|
2105
|
+
tunnelId: tunnel.id,
|
|
2106
|
+
appName: PLATFORM_ARCHON_CLOUDFLARED_APP_NAME,
|
|
2107
|
+
projectUuid,
|
|
2108
|
+
environment,
|
|
2109
|
+
resolveServer: () => resolvePlatformServer(deps.coolify),
|
|
2110
|
+
recordedConnectorAppId: recorded[PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
2111
|
+
recordAppId: (appId) => record(PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, appId),
|
|
2112
|
+
});
|
|
2113
|
+
const zoneId = recorded[apexZoneKey(platform.apex)] ?? (await exactZone(deps.cloudflare, platform.apex))?.id;
|
|
2114
|
+
if (!zoneId) {
|
|
2115
|
+
throw new Error(`platform:archon: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
|
|
2116
|
+
}
|
|
2117
|
+
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
2118
|
+
const [dnsRecord] = await deps.cloudflare.findDnsRecord(zoneId, hostname, 'CNAME');
|
|
2119
|
+
if (!dnsRecord) {
|
|
2120
|
+
await deps.cloudflare.createDnsRecord(zoneId, {
|
|
2121
|
+
type: 'CNAME',
|
|
2122
|
+
name: hostname,
|
|
2123
|
+
content: dnsTarget,
|
|
2124
|
+
proxied: true,
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2127
|
+
else if (dnsRecord.content !== dnsTarget) {
|
|
2128
|
+
await deps.cloudflare.updateDnsRecord(zoneId, dnsRecord.id, {
|
|
2129
|
+
type: 'CNAME',
|
|
2130
|
+
name: hostname,
|
|
2131
|
+
content: dnsTarget,
|
|
2132
|
+
proxied: true,
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
// Last, after every write the applied values depend on, so a run that failed
|
|
2136
|
+
// halfway plans again.
|
|
2137
|
+
await record(PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
|
|
2138
|
+
await record(PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
2139
|
+
// #211 round 4: the broker this run actually registered Archon's entry
|
|
2140
|
+
// with, so `planArchon` can tell a broker recreated under a new app id
|
|
2141
|
+
// (whose AUTH_APPS never carried this registration) from one still holding
|
|
2142
|
+
// it.
|
|
2143
|
+
await record(PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, brokerAppId);
|
|
2144
|
+
return { resourceId: appId };
|
|
2145
|
+
}
|
|
2146
|
+
// #211 round 6: mirrors `wtfalch/auth/broker/src/apps.ts`'s own `isValidFrom`
|
|
2147
|
+
// (and the `isSafeText`/control-character checks it shares with
|
|
2148
|
+
// `parseCommonAppFields`) exactly, character class for character class.
|
|
2149
|
+
// That module is `@wtfalch/auth-broker`, `private: true`, and exports
|
|
2150
|
+
// neither function, so there is nothing to import -- this has to stay a
|
|
2151
|
+
// faithful copy. Round 5's version only checked that an address sat inside
|
|
2152
|
+
// `<...>`, not the display name in front of it, so `bad<name> <a@b.co>` and
|
|
2153
|
+
// a name carrying a right-to-left override both passed this step's gate and
|
|
2154
|
+
// were then silently dropped by the broker's own parser at boot (`AUTH_APPS
|
|
2155
|
+
// has no usable entries` if it was the only one). Kept in lockstep by hand;
|
|
2156
|
+
// if the broker's rule ever changes, this one has to change with it.
|
|
2157
|
+
const FROM_MAX_LENGTH = 320;
|
|
2158
|
+
const LOCAL_PART = '[A-Za-z0-9._%+-]+';
|
|
2159
|
+
const DOMAIN_LABEL = '[A-Za-z0-9-]+';
|
|
2160
|
+
const SENDER_ADDRESS_RE = new RegExp(`^${LOCAL_PART}@${DOMAIN_LABEL}(?:\\.${DOMAIN_LABEL})*$`);
|
|
2161
|
+
const SENDER_NAME_CHAR = "[\\p{L}\\p{M}\\d .,'&()!\\u2019\\u2013-]";
|
|
2162
|
+
const SENDER_NAME_RE = new RegExp(`^${SENDER_NAME_CHAR}+$`, 'u');
|
|
2163
|
+
const NAMED_FROM_RE = /^(.*)<([^<>]+)>$/;
|
|
2164
|
+
// Every control character (`\p{Cc}`), every format character (`\p{Cf}`, how
|
|
2165
|
+
// an RTL override hides a reordering attack), both Unicode line/paragraph
|
|
2166
|
+
// separators, and the two blanks-in-disguise `trim()` cannot be trusted to
|
|
2167
|
+
// strip first (`\u00a0` NBSP, `\ufeff` BOM).
|
|
2168
|
+
const OTHER_UNSAFE_RE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u00a0\ufeff]/u;
|
|
2169
|
+
// #218 round 2: `\p{Cf}` also matches U+200C (ZWNJ) and U+200D (ZWJ), which
|
|
2170
|
+
// a real name can carry -- ZWNJ joins letters into one word in
|
|
2171
|
+
// Persian/Arabic script, ZWJ joins separate emoji into one glyph. Rejecting
|
|
2172
|
+
// every `\p{Cf}` character missed that the broker's own `hasUnsafeText`
|
|
2173
|
+
// exempts both, but only immediately between two letters or two emoji: a
|
|
2174
|
+
// loose one at either end, or beside anything else, is exactly the kind of
|
|
2175
|
+
// invisible character this check exists to catch, same as an RTL override.
|
|
2176
|
+
// Hand-copied from `wtfalch/auth/broker/src/apps.ts`'s own `hasUnsafeText`,
|
|
2177
|
+
// character class for character class, for the same reason the rest of
|
|
2178
|
+
// this block is a copy: that module is `@wtfalch/auth-broker`, `private:
|
|
2179
|
+
// true`.
|
|
2180
|
+
const JOINER_CHARS = new Set(['\u200c', '\u200d']);
|
|
2181
|
+
const LETTER_OR_EMOJI_RE = /^[\p{L}\p{Extended_Pictographic}]$/u;
|
|
2182
|
+
// A real joined sequence is rarely letter-immediately-next-to-joiner on
|
|
2183
|
+
// both sides. Devanagari's explicit half-form writes a virama (a combining
|
|
2184
|
+
// mark, `\p{M}`) before the ZWJ; a multi-part emoji chains a ZWJ onto a
|
|
2185
|
+
// variation selector (U+FE0F) or a skin-tone modifier
|
|
2186
|
+
// (`\p{Emoji_Modifier}`) rather than straight onto the base character. All
|
|
2187
|
+
// of these sit *before* the joiner, never after it, so only the "previous
|
|
2188
|
+
// character" side is widened.
|
|
2189
|
+
const JOINER_PREV_RE = /^(?:[\p{L}\p{M}\p{Extended_Pictographic}]|\u{FE0F}|\p{Emoji_Modifier})$/u;
|
|
2190
|
+
function hasUnsafeText(value) {
|
|
2191
|
+
const chars = Array.from(value);
|
|
2192
|
+
for (let i = 0; i < chars.length; i++) {
|
|
2193
|
+
const ch = chars[i]; // i < chars.length, so this index always exists
|
|
2194
|
+
if (JOINER_CHARS.has(ch)) {
|
|
2195
|
+
const prev = chars[i - 1];
|
|
2196
|
+
const next = chars[i + 1];
|
|
2197
|
+
if (!prev || !next || !JOINER_PREV_RE.test(prev) || !LETTER_OR_EMOJI_RE.test(next)) {
|
|
2198
|
+
return true;
|
|
2199
|
+
}
|
|
2200
|
+
continue;
|
|
2201
|
+
}
|
|
2202
|
+
if (OTHER_UNSAFE_RE.test(ch))
|
|
2203
|
+
return true;
|
|
2204
|
+
}
|
|
2205
|
+
return false;
|
|
2206
|
+
}
|
|
2207
|
+
function isSafeText(value, max) {
|
|
2208
|
+
return value.trim().length > 0 && value.length <= max && !hasUnsafeText(value);
|
|
2209
|
+
}
|
|
2210
|
+
function isValidSenderDisplayName(value) {
|
|
2211
|
+
return value.trim().length > 0 && SENDER_NAME_RE.test(value);
|
|
2212
|
+
}
|
|
2213
|
+
/**
|
|
2214
|
+
* A broker app entry's `from` -- not an RFC 5322 parser, just enough to
|
|
2215
|
+
* catch what the broker itself would refuse or silently drop. Accepts the
|
|
2216
|
+
* two shapes this package itself ever writes (`provisionPlatformArchon`'s
|
|
2217
|
+
* own `archonEntry.from`, `` `Company Included <noreply@${platform.apex}>` ``)
|
|
2218
|
+
* or reads back from a hand-edited `AUTH_APPS`: `Display Name <email@domain>`
|
|
2219
|
+
* (quoted or not) and a bare `email@domain` address.
|
|
2220
|
+
*/
|
|
2221
|
+
function isValidSenderFrom(from) {
|
|
2222
|
+
if (from.length === 0 || from.length > FROM_MAX_LENGTH)
|
|
2223
|
+
return false;
|
|
2224
|
+
if (SENDER_ADDRESS_RE.test(from))
|
|
2225
|
+
return true;
|
|
2226
|
+
const named = NAMED_FROM_RE.exec(from);
|
|
2227
|
+
if (!named)
|
|
2228
|
+
return false;
|
|
2229
|
+
const rawName = named[1] ?? '';
|
|
2230
|
+
const address = named[2] ?? '';
|
|
2231
|
+
if (!SENDER_ADDRESS_RE.test(address))
|
|
2232
|
+
return false;
|
|
2233
|
+
if (!isSafeText(rawName, FROM_MAX_LENGTH))
|
|
2234
|
+
return false;
|
|
2235
|
+
const name = rawName.trim();
|
|
2236
|
+
if (name.startsWith('"') && name.endsWith('"') && name.length >= 2) {
|
|
2237
|
+
return isValidSenderDisplayName(name.slice(1, -1));
|
|
2238
|
+
}
|
|
2239
|
+
return isValidSenderDisplayName(name);
|
|
2240
|
+
}
|
|
2241
|
+
/** #211 round 5: the two locales `@wtfalch/mailer`'s `Locale` type restricts a broker app entry's optional `locale` to. */
|
|
2242
|
+
const VALID_BROKER_APP_LOCALES = new Set(['en', 'nb']);
|
|
2243
|
+
/** #211 round 6: matches the broker's own `CLIENT_ID_MAX_LENGTH`. */
|
|
2244
|
+
const BROKER_CLIENT_ID_MAX_LENGTH = 200;
|
|
2245
|
+
/**
|
|
2246
|
+
* #218: matches the broker's own `PRODUCT_NAME_MAX_LENGTH`
|
|
2247
|
+
* (`broker/src/apps.ts`), which it uses as the cap for both `name` and
|
|
2248
|
+
* `productName` -- `parseCommonAppFields`'s own `isSafeText(app.name,
|
|
2249
|
+
* PRODUCT_NAME_MAX_LENGTH)` and `isValidProductName`'s
|
|
2250
|
+
* `isSafeText(value, PRODUCT_NAME_MAX_LENGTH)`.
|
|
2251
|
+
*/
|
|
2252
|
+
const BROKER_NAME_MAX_LENGTH = 100;
|
|
2253
|
+
/**
|
|
2254
|
+
* #211 round 5: whether `value` is already in the canonical origin form
|
|
2255
|
+
* `wtfalch/auth/broker/src/apps.ts`'s own `parseApps` requires for an entry's
|
|
2256
|
+
* `origins` -- http(s), and `new URL(value).origin === value` exactly, so no
|
|
2257
|
+
* path, query, trailing slash or differently-cased host survives. `new URL`
|
|
2258
|
+
* throws on an arbitrary string, so that failure is itself "not a URL".
|
|
2259
|
+
*/
|
|
2260
|
+
function isCanonicalOrigin(value) {
|
|
2261
|
+
try {
|
|
2262
|
+
const parsed = new URL(value);
|
|
2263
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.origin === value;
|
|
2264
|
+
}
|
|
2265
|
+
catch {
|
|
2266
|
+
return false;
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
/**
|
|
2270
|
+
* One `AUTH_APPS[index]` entry, checked the way
|
|
2271
|
+
* `wtfalch/auth/broker/src/apps.ts`'s own `parseApps`/`parseAppFields`
|
|
2272
|
+
* (https://github.com/wtfalch/auth/blob/main/broker/src/apps.ts) check it at
|
|
2273
|
+
* boot, in the same order: a `key` of at least 32 characters and a non-empty
|
|
2274
|
+
* `organizationId`; a `name` that passes the broker's own `isSafeText` rule
|
|
2275
|
+
* (#218); a `productName`, when present, that is a string and also passes it
|
|
2276
|
+
* (#218); a `from` that passes {@link isValidSenderFrom}; a `locale` of
|
|
2277
|
+
* `'en'` or `'nb'` when present; non-blank `clientIds`; and `origins` each in
|
|
2278
|
+
* {@link isCanonicalOrigin} form. #211 round 2: on a bad
|
|
2279
|
+
* entry this throws naming the broker app id and the entry's index, never
|
|
2280
|
+
* the value -- `key` may hold another organisation's secret, and even
|
|
2281
|
+
* `organizationId`/`name` are not this step's to print in an error only an
|
|
2282
|
+
* operator reads. `entry` may be anything at all (`null`, a string, an
|
|
2283
|
+
* array): every check below is a `typeof`/`Array.isArray` guard first, so a
|
|
2284
|
+
* malformed entry never reaches a property read on a non-object and throws a
|
|
2285
|
+
* `TypeError` instead of this named error.
|
|
2286
|
+
*/
|
|
2287
|
+
function validateBrokerAppEntry(entry, brokerAppId, index) {
|
|
2288
|
+
const label = `platform:archon: broker app ${brokerAppId}'s AUTH_APPS[${index}]`;
|
|
2289
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
2290
|
+
throw new Error(`${label} is not an object`);
|
|
2291
|
+
}
|
|
2292
|
+
const app = entry;
|
|
2293
|
+
if (typeof app.organizationId !== 'string' || !app.organizationId) {
|
|
2294
|
+
throw new Error(`${label} has no organizationId`);
|
|
2295
|
+
}
|
|
2296
|
+
if (typeof app.key !== 'string' || !app.key) {
|
|
2297
|
+
throw new Error(`${label} has no key`);
|
|
2298
|
+
}
|
|
2299
|
+
if (app.key.length < 32) {
|
|
2300
|
+
throw new Error(`${label} has too short a key`);
|
|
2301
|
+
}
|
|
2302
|
+
if (typeof app.name !== 'string' || !app.name) {
|
|
2303
|
+
throw new Error(`${label} has no name`);
|
|
2304
|
+
}
|
|
2305
|
+
// #218: the broker's own `parseCommonAppFields` runs `name` through
|
|
2306
|
+
// `isSafeText(app.name, PRODUCT_NAME_MAX_LENGTH)` too (`name` stands in for
|
|
2307
|
+
// `productName` in the mail subject whenever an entry declares no
|
|
2308
|
+
// `productName` of its own, so it needs the same safety check) -- this
|
|
2309
|
+
// step's own truthy check above missed a NUL, an RTL override, a line
|
|
2310
|
+
// separator, or a name over 100 characters, all of which this gate reads
|
|
2311
|
+
// back and keeps, and all of which the broker then throws on at boot.
|
|
2312
|
+
if (!isSafeText(app.name, BROKER_NAME_MAX_LENGTH)) {
|
|
2313
|
+
throw new Error(`${label} has an invalid name`);
|
|
2314
|
+
}
|
|
2315
|
+
if (app.productName !== undefined) {
|
|
2316
|
+
// #218: `productName` is optional on the broker's own `App`, but when
|
|
2317
|
+
// present it runs through `isValidProductName`
|
|
2318
|
+
// (`isSafeText(value, PRODUCT_NAME_MAX_LENGTH)`) exactly like `name`
|
|
2319
|
+
// above -- a non-string, a NUL, or one over 100 characters all throw at
|
|
2320
|
+
// the broker's boot the same way.
|
|
2321
|
+
if (typeof app.productName !== 'string') {
|
|
2322
|
+
throw new Error(`${label} has a non-string productName`);
|
|
2323
|
+
}
|
|
2324
|
+
if (!isSafeText(app.productName, BROKER_NAME_MAX_LENGTH)) {
|
|
2325
|
+
throw new Error(`${label} has an invalid productName`);
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
if (typeof app.from !== 'string' || !app.from) {
|
|
2329
|
+
throw new Error(`${label} has no from`);
|
|
2330
|
+
}
|
|
2331
|
+
if (!isValidSenderFrom(app.from)) {
|
|
2332
|
+
throw new Error(`${label} has an invalid from`);
|
|
2333
|
+
}
|
|
2334
|
+
if (app.locale !== undefined && !VALID_BROKER_APP_LOCALES.has(app.locale)) {
|
|
2335
|
+
throw new Error(`${label} has an invalid locale`);
|
|
2336
|
+
}
|
|
2337
|
+
if (!Array.isArray(app.clientIds) || app.clientIds.length === 0) {
|
|
2338
|
+
throw new Error(`${label} has no clientIds`);
|
|
2339
|
+
}
|
|
2340
|
+
if (app.clientIds.some((id) => typeof id !== 'string' || id.trim() === '')) {
|
|
2341
|
+
throw new Error(`${label} has a blank clientIds entry`);
|
|
2342
|
+
}
|
|
2343
|
+
// #211 round 6: `clientIds:["a\u0000b"]` passed this gate before -- a
|
|
2344
|
+
// control character is not blank, so the check above missed it, and the
|
|
2345
|
+
// broker's own `parseCommonAppFields` throws on it at boot
|
|
2346
|
+
// (`isSafeText(candidate, CLIENT_ID_MAX_LENGTH)`).
|
|
2347
|
+
if (app.clientIds.some((id) => typeof id === 'string' && !isSafeText(id, BROKER_CLIENT_ID_MAX_LENGTH))) {
|
|
2348
|
+
throw new Error(`${label} has an invalid clientIds entry`);
|
|
2349
|
+
}
|
|
2350
|
+
if (!Array.isArray(app.origins) || app.origins.length === 0) {
|
|
2351
|
+
throw new Error(`${label} has no origins`);
|
|
2352
|
+
}
|
|
2353
|
+
if (app.origins.some((origin) => typeof origin !== 'string' || !isCanonicalOrigin(origin))) {
|
|
2354
|
+
throw new Error(`${label} has an invalid origins entry`);
|
|
2355
|
+
}
|
|
2356
|
+
return app;
|
|
2357
|
+
}
|
|
2358
|
+
/**
|
|
2359
|
+
* The broker's current `AUTH_APPS` (#204), parsed back so an entry can be
|
|
2360
|
+
* merged without losing whichever other organisations -- or other apps of
|
|
2361
|
+
* this same organisation -- have registered. Empty only for a broker that
|
|
2362
|
+
* has never had one set. #211: an *unparseable* value (bad JSON, or JSON
|
|
2363
|
+
* that is not an array) is never treated as empty -- the write that follows
|
|
2364
|
+
* replaces the whole env, so silently reading it as `[]` would drop every
|
|
2365
|
+
* other organisation's entry the moment this run cannot parse what is
|
|
2366
|
+
* there. Throws instead, naming the broker app id (the app holding the
|
|
2367
|
+
* unreadable value), never the value itself -- it may hold another
|
|
2368
|
+
* organisation's key. #211 round 2: each entry is validated the same way
|
|
2369
|
+
* the broker itself validates `AUTH_APPS` at boot ({@link validateBrokerAppEntry}),
|
|
2370
|
+
* so a bad entry is caught here, by this step, rather than only discovered
|
|
2371
|
+
* once the broker refuses to start on it.
|
|
2372
|
+
*/
|
|
2373
|
+
async function readBrokerApps(coolify, brokerAppId) {
|
|
2374
|
+
const envs = await coolify.listEnvs(brokerAppId);
|
|
2375
|
+
const raw = envs.find((e) => e.key === 'AUTH_APPS' && !e.is_preview)?.value;
|
|
2376
|
+
if (!raw)
|
|
2377
|
+
return [];
|
|
2378
|
+
let parsed;
|
|
2379
|
+
try {
|
|
2380
|
+
parsed = JSON.parse(raw);
|
|
2381
|
+
}
|
|
2382
|
+
catch {
|
|
2383
|
+
throw new Error(`platform:archon: broker app ${brokerAppId} has an AUTH_APPS that is not JSON`);
|
|
2384
|
+
}
|
|
2385
|
+
if (!Array.isArray(parsed)) {
|
|
2386
|
+
throw new Error(`platform:archon: broker app ${brokerAppId} has an AUTH_APPS that is not a JSON array`);
|
|
2387
|
+
}
|
|
2388
|
+
return parsed.map((entry, index) => validateBrokerAppEntry(entry, brokerAppId, index));
|
|
2389
|
+
}
|
|
2390
|
+
/**
|
|
2391
|
+
* #213: puts {@link PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF},
|
|
2392
|
+
* {@link PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF} and
|
|
2393
|
+
* {@link PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF} as held keys, under
|
|
2394
|
+
* tenant `platform`, so Archon's own onboarding can read them back once its
|
|
2395
|
+
* app is live. Each ref is resolved first; `put` runs only when that comes
|
|
2396
|
+
* back empty, so an entry already held (put by an earlier apply, or by hand)
|
|
2397
|
+
* is left alone and never overwritten. Values never reach a log, a record or
|
|
2398
|
+
* an error: `heldKeys.put`'s own contract, mirroring `deliverSecret`'s.
|
|
2399
|
+
*/
|
|
2400
|
+
async function putArchonMasterKeys(heldKeys, masterKeys) {
|
|
2401
|
+
const entries = [
|
|
2402
|
+
[PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, masterKeys.coolifyUrl],
|
|
2403
|
+
[PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, masterKeys.coolifyToken],
|
|
2404
|
+
[PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, masterKeys.cloudflareParentToken],
|
|
2405
|
+
];
|
|
2406
|
+
for (const [ref, value] of entries) {
|
|
2407
|
+
const existing = await heldKeys.resolve(ref);
|
|
2408
|
+
if (existing)
|
|
2409
|
+
continue;
|
|
2410
|
+
await heldKeys.put?.(ref, value);
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
/**
|
|
2414
|
+
* `executor.ts`'s `cloudflareAccess`, for the platform's Archon: reuses only
|
|
2415
|
+
* an Access app named {@link PLATFORM_ARCHON_ACCESS_APP_NAME}, refuses to
|
|
2416
|
+
* adopt any other Access app already holding the hostname, and refuses when
|
|
2417
|
+
* its own app carries a policy foundry did not create, since that policy
|
|
2418
|
+
* could let others in.
|
|
2419
|
+
*/
|
|
2420
|
+
async function ensureArchonAccess(cf, domain, emails, closeGate) {
|
|
2421
|
+
const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
|
|
2422
|
+
const foreign = onDomain.find((a) => a.name !== PLATFORM_ARCHON_ACCESS_APP_NAME);
|
|
2423
|
+
if (foreign) {
|
|
2424
|
+
throw new Error(`platform:archon: Access app '${foreign.name}' already holds ${domain}; foundry does not adopt it`);
|
|
2425
|
+
}
|
|
2426
|
+
const create = async () => {
|
|
2427
|
+
const created = await cf.createAccessApp({
|
|
2428
|
+
name: PLATFORM_ARCHON_ACCESS_APP_NAME,
|
|
2429
|
+
domain,
|
|
2430
|
+
type: 'self_hosted',
|
|
2431
|
+
});
|
|
2432
|
+
await cf.createAccessPolicy(created.id, archonAccessPolicy(emails));
|
|
2433
|
+
return created;
|
|
2434
|
+
};
|
|
2435
|
+
const existing = onDomain[0];
|
|
2436
|
+
if (!existing)
|
|
2437
|
+
return (await create()).id;
|
|
2438
|
+
const policies = await cf.listAccessPolicies(existing.id);
|
|
2439
|
+
const other = policies.find((p) => p.name !== ACCESS_POLICY_NAME);
|
|
2440
|
+
if (other) {
|
|
2441
|
+
throw new Error(`platform:archon: Access app ${existing.id} has policy '${other.name}' that foundry did not create`);
|
|
2442
|
+
}
|
|
2443
|
+
if (policies.length === 0) {
|
|
2444
|
+
await cf.createAccessPolicy(existing.id, archonAccessPolicy(emails));
|
|
2445
|
+
return existing.id;
|
|
2446
|
+
}
|
|
2447
|
+
if (policies.every((p) => sameEmails(p.include, emails)))
|
|
2448
|
+
return existing.id;
|
|
2449
|
+
// No call updates an app's policy: rebuild the app, hostname closed meanwhile.
|
|
2450
|
+
await closeGate();
|
|
2451
|
+
await cf.deleteAccessApp(existing.id);
|
|
2452
|
+
return (await create()).id;
|
|
2453
|
+
}
|
|
2454
|
+
function archonAccessPolicy(emails) {
|
|
2455
|
+
return {
|
|
2456
|
+
name: ACCESS_POLICY_NAME,
|
|
2457
|
+
decision: 'allow',
|
|
2458
|
+
include: emails.map((email) => ({ email: { email } })),
|
|
2459
|
+
};
|
|
2460
|
+
}
|
|
2461
|
+
/** Whether an Access policy's include list is exactly `emails` (case-insensitive) and nothing else. */
|
|
2462
|
+
function sameEmails(include, emails) {
|
|
2463
|
+
if (!Array.isArray(include))
|
|
2464
|
+
return false;
|
|
2465
|
+
const live = new Set();
|
|
2466
|
+
for (const entry of include) {
|
|
2467
|
+
const email = entry?.email?.email;
|
|
2468
|
+
if (typeof email !== 'string' || Object.keys(entry).length !== 1)
|
|
2469
|
+
return false;
|
|
2470
|
+
live.add(email.toLowerCase());
|
|
2471
|
+
}
|
|
2472
|
+
const declared = new Set(emails.map((e) => e.toLowerCase()));
|
|
2473
|
+
return live.size === declared.size && [...declared].every((e) => live.has(e));
|
|
2474
|
+
}
|
|
2475
|
+
/**
|
|
2476
|
+
* Whether Archon's Access app or policy is gone or no longer lets through
|
|
2477
|
+
* exactly the declared emails. A failed read is not drift: the next plan reads
|
|
2478
|
+
* again.
|
|
2479
|
+
*/
|
|
2480
|
+
async function archonAccessDrifted(cf, domain, emails) {
|
|
2481
|
+
try {
|
|
2482
|
+
const app = (await cf.listAccessApps()).find((a) => a.domain === domain && a.name === PLATFORM_ARCHON_ACCESS_APP_NAME);
|
|
2483
|
+
if (!app)
|
|
2484
|
+
return true;
|
|
2485
|
+
const policies = await cf.listAccessPolicies(app.id);
|
|
2486
|
+
return !(policies.length > 0 && policies.every((p) => sameEmails(p.include, emails)));
|
|
2487
|
+
}
|
|
2488
|
+
catch {
|
|
2489
|
+
return false;
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
/**
|
|
2493
|
+
* #211 round 4: whether the broker Archon last registered with (its
|
|
2494
|
+
* `AUTH_APPS`) still carries an entry for Archon's organisation.
|
|
2495
|
+
* `planArchon`'s own recorded-id check
|
|
2496
|
+
* ({@link PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY} against
|
|
2497
|
+
* {@link PLATFORM_AUTH_BROKER_SCOPE_KEY}) catches the broker itself being
|
|
2498
|
+
* recreated under a new app id; this catches the same broker losing the
|
|
2499
|
+
* entry some other way -- a hand edit of `AUTH_APPS`, a previous write that
|
|
2500
|
+
* silently failed. A failed read is not drift, the same rule
|
|
2501
|
+
* {@link archonAccessDrifted} follows: the next plan reads again. Missing
|
|
2502
|
+
* `recordedIds` (no broker recorded yet, or Archon's own ZITADEL org not yet
|
|
2503
|
+
* recorded) is not drift either -- `planArchon`'s other checks already cover
|
|
2504
|
+
* a step that has never applied.
|
|
2505
|
+
*/
|
|
2506
|
+
async function archonBrokerRegistrationDrifted(coolify, recordedIds) {
|
|
2507
|
+
const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
|
|
2508
|
+
const orgId = recordedIds[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
|
|
2509
|
+
if (!brokerAppId || !orgId)
|
|
2510
|
+
return false;
|
|
2511
|
+
try {
|
|
2512
|
+
const apps = await readBrokerApps(coolify, brokerAppId);
|
|
2513
|
+
return !apps.some((a) => a.organizationId === orgId && a.name === PLATFORM_ARCHON_ZITADEL_APP);
|
|
2514
|
+
}
|
|
2515
|
+
catch {
|
|
2516
|
+
return false;
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
/**
|
|
2520
|
+
* What Archon reads from each enabled service, gathered before anything is
|
|
2521
|
+
* built. Either everything is there and the env values come back, or the first
|
|
2522
|
+
* missing piece comes back as one fixed sentence naming a step, scope id or
|
|
2523
|
+
* held-key ref, never a value. Also read at plan time, so the text must not
|
|
2524
|
+
* vary between calls.
|
|
2525
|
+
*/
|
|
2526
|
+
async function collectArchonServiceInputs(platform, deps, recorded) {
|
|
2527
|
+
const block = (why) => ({ blocked: `Archon is blocked, not planned: ${why}` });
|
|
2528
|
+
const envs = [];
|
|
2529
|
+
const heldByRef = new Map();
|
|
2530
|
+
const services = ['files', 'ai', 'integrations', 'jobs'].filter((s) => platform[s].enabled);
|
|
2531
|
+
if (services.length > 0 && !deps.heldKeys) {
|
|
2532
|
+
return block(`the host built the platform executor without a held-key reader, and ${services.join(', ')} ${services.length === 1 ? 'is' : 'are'} enabled`);
|
|
2533
|
+
}
|
|
2534
|
+
for (const service of services) {
|
|
2535
|
+
const needs = service === 'jobs'
|
|
2536
|
+
? { stepKey: null, scopeKeys: [], refs: [JOBS_ADMIN_TOKEN_REF] }
|
|
2537
|
+
: ARCHON_SERVICE_INPUTS[service];
|
|
2538
|
+
if (needs.stepKey && !recorded[`coolify:app:${needs.stepKey}`]) {
|
|
2539
|
+
return block(`the ${service} step (${needs.stepKey}) is not applied yet`);
|
|
2540
|
+
}
|
|
2541
|
+
for (const scopeKey of needs.scopeKeys) {
|
|
2542
|
+
if (!recorded[scopeKey]) {
|
|
2543
|
+
return block(`the ${service} step has not recorded the scope id ${scopeKey}`);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
for (const ref of needs.refs) {
|
|
2547
|
+
let held;
|
|
2548
|
+
try {
|
|
2549
|
+
held = (await deps.heldKeys?.resolve(ref)) ?? null;
|
|
2550
|
+
}
|
|
2551
|
+
catch {
|
|
2552
|
+
// The cause is dropped in case it echoes a secret.
|
|
2553
|
+
return block(`the held key ${ref} could not be read`);
|
|
2554
|
+
}
|
|
2555
|
+
if (!held)
|
|
2556
|
+
return block(`the held key ${ref} is not held yet`);
|
|
2557
|
+
heldByRef.set(ref, held);
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
const held = (ref) => heldByRef.get(ref);
|
|
2561
|
+
for (const service of services) {
|
|
2562
|
+
if (service === 'files') {
|
|
2563
|
+
envs.push({ key: 'FILES_URL', value: recorded[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY] }, { key: 'FILES_MANAGEMENT_KEY', value: held(ARCHON_SERVICE_INPUTS.files.refs[0]) });
|
|
2564
|
+
}
|
|
2565
|
+
else if (service === 'ai') {
|
|
2566
|
+
envs.push({ key: 'AI_URL', value: recorded[PLATFORM_AI_INTERNAL_URL_SCOPE_KEY] }, { key: 'AI_MANAGEMENT_KEY', value: held(ARCHON_SERVICE_INPUTS.ai.refs[0]) }, {
|
|
2567
|
+
key: 'AI_ORGANISATION_ID',
|
|
2568
|
+
value: recorded[PLATFORM_AI_ORGANISATION_SCOPE_KEY],
|
|
2569
|
+
});
|
|
2570
|
+
}
|
|
2571
|
+
else if (service === 'integrations') {
|
|
2572
|
+
const url = `https://${platform.integrations.hostname}`;
|
|
2573
|
+
envs.push({ key: 'INTEGRATIONS_URL', value: url }, {
|
|
2574
|
+
key: 'INTEGRATIONS_MANAGEMENT_PRIVATE_KEY',
|
|
2575
|
+
value: held(INTEGRATIONS_MANAGEMENT_KEY_REF),
|
|
2576
|
+
}, { key: 'INTEGRATIONS_MANAGEMENT_ISSUER', value: INTEGRATIONS_MANAGEMENT_ISSUER }, { key: 'INTEGRATIONS_MANAGEMENT_KEY_ID', value: INTEGRATIONS_MANAGEMENT_KEY_ID }, { key: 'INTEGRATIONS_MANAGEMENT_AUDIENCE', value: url });
|
|
2577
|
+
}
|
|
2578
|
+
else {
|
|
2579
|
+
envs.push({ key: 'JOBS_URL', value: `https://${platform.jobs.hostname}` }, { key: 'JOBS_ADMIN_TOKEN', value: held(JOBS_ADMIN_TOKEN_REF) });
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
return { envs };
|
|
2583
|
+
}
|
|
2584
|
+
/**
|
|
2585
|
+
* `null` once the app already has a `DATABASE_URL`, never rotated: Archon's
|
|
2586
|
+
* `db-runtime-role.sh` sets the `archon_rt` password from this URL on every
|
|
2587
|
+
* boot while the previous container's pool is still open, so it must stay
|
|
2588
|
+
* the same across deploys. `executor.ts`'s `ensureCmsRuntimeUrl`, with
|
|
2589
|
+
* Archon's role name; see that method for why a parse failure is swallowed
|
|
2590
|
+
* (Node's `ERR_INVALID_URL` carries the whole input, credential included).
|
|
2591
|
+
* `base64url` fits `db-runtime-role.sh`'s password alphabet
|
|
2592
|
+
* (`A-Za-z0-9_.~-`).
|
|
2593
|
+
*/
|
|
2594
|
+
async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
|
|
2595
|
+
const envs = await coolify.listEnvs(appId);
|
|
2596
|
+
if (envs.some((e) => e.key === 'DATABASE_URL' && !e.is_preview))
|
|
2597
|
+
return null;
|
|
2598
|
+
let host;
|
|
2599
|
+
let pathname;
|
|
2600
|
+
let search;
|
|
2601
|
+
try {
|
|
2602
|
+
({ host, pathname, search } = new URL(dbInternalUrl));
|
|
2603
|
+
}
|
|
2604
|
+
catch {
|
|
2605
|
+
// Node's `ERR_INVALID_URL` carries the whole input, credential included, so
|
|
2606
|
+
// neither it nor the URL goes into this message.
|
|
2607
|
+
throw new Error('platform:archon: the database internal_db_url could not be parsed; not generating DATABASE_URL. Check the database in Coolify and apply again');
|
|
2608
|
+
}
|
|
2609
|
+
const password = randomBytes(24).toString('base64url');
|
|
2610
|
+
return `postgres://${ARCHON_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
|
|
2611
|
+
}
|
|
2612
|
+
/**
|
|
2613
|
+
* Thrown by {@link createAiManagementKeyMinter} (never by a test's own
|
|
2614
|
+
* injected minter, unless it chooses to) when ai's `POST /v1/platform/keys`
|
|
2615
|
+
* answers 200: a live `archon` key already exists and no secret comes back.
|
|
2616
|
+
* See {@link PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY}.
|
|
2617
|
+
*/
|
|
2618
|
+
export class AiManagementKeyExistsError extends Error {
|
|
2619
|
+
credentialId;
|
|
2620
|
+
organisationId;
|
|
2621
|
+
constructor(credentialId, organisationId) {
|
|
2622
|
+
super(`ai already holds a management key named 'archon' (${credentialId}) with no secret to show`);
|
|
2623
|
+
this.credentialId = credentialId;
|
|
2624
|
+
this.organisationId = organisationId;
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
/**
|
|
2628
|
+
* Thrown by {@link createAiManagementKeyMinter} on a 409: a live `archon` key
|
|
2629
|
+
* already exists with different permissions or expiry than this request
|
|
2630
|
+
* asked for. ai never overwrites it, so this is never retried under another
|
|
2631
|
+
* name -- an operator revokes the live key in ai first.
|
|
2632
|
+
*/
|
|
2633
|
+
export class AiManagementKeyConflictError extends Error {
|
|
2634
|
+
constructor() {
|
|
2635
|
+
super("ai already holds a live 'archon' key minted with different permissions or expiry; revoke it in ai, then apply again");
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
/**
|
|
2639
|
+
* Thrown by {@link createAiManagementKeyMinter} on a 401 or 403: ai does not
|
|
2640
|
+
* trust foundry's signing key yet for this request. Most likely the runner
|
|
2641
|
+
* has not redeployed since {@link AI_SIGNING_KEY_REF}'s public half was
|
|
2642
|
+
* pinned as `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` -- a Coolify env
|
|
2643
|
+
* change reaches the container only on its next deploy.
|
|
2644
|
+
*/
|
|
2645
|
+
export class AiManagementKeyUntrustedError extends Error {
|
|
2646
|
+
status;
|
|
2647
|
+
constructor(status) {
|
|
2648
|
+
super(`ai's platform/keys route refused foundry's signing key (${status}); check MANAGEMENT_PUBLIC_KEYS/MANAGEMENT_ISSUER on the ai runner and that it has redeployed since they were pinned`);
|
|
2649
|
+
this.status = status;
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
/**
|
|
2653
|
+
* Both response bodies {@link createAiManagementKeyMinter} reads are parsed
|
|
2654
|
+
* rather than cast: a malformed body (an empty 201, a 200 with no
|
|
2655
|
+
* `credentialId`) must fail the mint outright rather than record an
|
|
2656
|
+
* `undefined` id or hand `deliverSecret` an `undefined` "secret". Neither
|
|
2657
|
+
* error message repeats the body, which could hold the very secret this
|
|
2658
|
+
* function must not leak.
|
|
2659
|
+
*/
|
|
2660
|
+
function parseAiKeyExistsBody(body) {
|
|
2661
|
+
const record = body;
|
|
2662
|
+
if (typeof record !== 'object' ||
|
|
2663
|
+
record === null ||
|
|
2664
|
+
typeof record.credentialId !== 'string' ||
|
|
2665
|
+
typeof record.organisationId !== 'string') {
|
|
2666
|
+
throw new Error("ai's platform/keys route answered 200 with a body missing credentialId/organisationId");
|
|
2667
|
+
}
|
|
2668
|
+
return { credentialId: record.credentialId, organisationId: record.organisationId };
|
|
2669
|
+
}
|
|
2670
|
+
function parseAiKeyMintedBody(body) {
|
|
2671
|
+
const record = body;
|
|
2672
|
+
if (typeof record !== 'object' ||
|
|
2673
|
+
record === null ||
|
|
2674
|
+
typeof record.credentialId !== 'string' ||
|
|
2675
|
+
typeof record.organisationId !== 'string' ||
|
|
2676
|
+
typeof record.secret !== 'string') {
|
|
2677
|
+
throw new Error("ai's platform/keys route answered 201 with a body missing credentialId/organisationId/secret");
|
|
2678
|
+
}
|
|
2679
|
+
return {
|
|
2680
|
+
credentialId: record.credentialId,
|
|
2681
|
+
organisationId: record.organisationId,
|
|
2682
|
+
secret: record.secret,
|
|
2683
|
+
};
|
|
2684
|
+
}
|
|
2685
|
+
/** The ref the AI management key is delivered under; the host's `AiOrganisationPort` adapter reads it back. */
|
|
2686
|
+
export const PLATFORM_AI_MANAGEMENT_KEY_REF = 'ai:platform:managementKey';
|
|
2687
|
+
/**
|
|
2688
|
+
* What Archon's management key may do in ai: exactly `ai`'s own
|
|
2689
|
+
* `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
|
|
2690
|
+
* configuration only (offerings, provisioning, offers, budgets, usage, cost)
|
|
2691
|
+
* plus deleting (archiving) an organisation, never connections, runs or
|
|
2692
|
+
* webhooks. ai's `/v1/platform/keys` route refuses any permission outside
|
|
2693
|
+
* this same list, so a request for more than this is a 403, not a wider key.
|
|
2694
|
+
*/
|
|
2695
|
+
export const PLATFORM_AI_MANAGEMENT_PERMISSIONS = [
|
|
2696
|
+
'ai.offerings:read',
|
|
2697
|
+
'ai.offerings:create',
|
|
2698
|
+
'ai.offerings:update',
|
|
2699
|
+
'ai.offerings:disable',
|
|
2700
|
+
'ai.organisations:provision',
|
|
2701
|
+
'ai.organisations:offer',
|
|
2702
|
+
'ai.organisations:delete',
|
|
2703
|
+
'ai.keys:issue',
|
|
2704
|
+
'ai.budgets:read',
|
|
2705
|
+
'ai.budgets:update',
|
|
2706
|
+
'ai.usage:read',
|
|
2707
|
+
'ai.cost:read',
|
|
2708
|
+
];
|
|
2709
|
+
/** The ref the AI signing key's private half (PKCS#8 PEM) is delivered under -- the {@link INTEGRATIONS_MANAGEMENT_KEY_REF} pattern, for ai instead of integrations. */
|
|
2710
|
+
export const AI_SIGNING_KEY_REF = 'ai:platform:signingPrivateKey';
|
|
2711
|
+
/** `MANAGEMENT_ISSUER` on the ai runner: whoever signs with the delivered key signs as this issuer. */
|
|
2712
|
+
export const AI_MANAGEMENT_ISSUER = 'foundry';
|
|
2713
|
+
/** The key id the public half is pinned under in `MANAGEMENT_PUBLIC_KEYS` (ai's keyring allows `^[A-Za-z0-9_-]{1,100}$`). Fixed, since there is one key until rotation is built. */
|
|
2714
|
+
export const AI_MANAGEMENT_KEY_ID = 'foundry-1';
|
|
2715
|
+
/**
|
|
2716
|
+
* ai's fixed application/platform identifier
|
|
2717
|
+
* (`apps/host/src/lib/authz/policy-catalogue.ts`'s `PLATFORM_ID`), bound
|
|
2718
|
+
* into every signed assertion's `platform` claim. foundry does not depend on
|
|
2719
|
+
* that app, so the value is mirrored here rather than imported; a mismatch
|
|
2720
|
+
* makes ai's `verifyManagementRequest` refuse every request.
|
|
2721
|
+
*/
|
|
2722
|
+
const AI_MANAGEMENT_PLATFORM_ID = 'wtfalch';
|
|
2723
|
+
/** The header ai's `service-assertion.ts` (`MANAGEMENT_HEADER`) reads the signed assertion from. */
|
|
2724
|
+
const AI_MANAGEMENT_HEADER = 'x-wtfalch-management';
|
|
2725
|
+
/** `service-assertion.ts`'s own signed-message prefix; the signature covers exactly `PREFIX + message`. */
|
|
2726
|
+
const AI_MANAGEMENT_ASSERTION_PREFIX = 'wtfalch.service-request.v1\n';
|
|
2727
|
+
/** `service-assertion.ts`'s own token TTL, in seconds. */
|
|
2728
|
+
const AI_MANAGEMENT_ASSERTION_TTL_SECONDS = 30;
|
|
2729
|
+
/**
|
|
2730
|
+
* ai's signed-assertion `organisation` claim is required and validated as a
|
|
2731
|
+
* uuid, but unused for tenant selection on the platform/keys route
|
|
2732
|
+
* (`platform-keys-endpoint.ts`'s own comment: "callers may sign it as the
|
|
2733
|
+
* nil uuid"). There is no organisation to name before one exists.
|
|
2734
|
+
*/
|
|
2735
|
+
const AI_MANAGEMENT_NIL_ORGANISATION = '00000000-0000-0000-0000-000000000000';
|
|
2736
|
+
/** ai's platform/keys route, relative to its internal address. */
|
|
2737
|
+
const AI_PLATFORM_KEYS_PATH = '/v1/platform/keys';
|
|
2738
|
+
/**
|
|
2739
|
+
* Signs one request to ai's `POST /v1/platform/keys` the way ai's own
|
|
2740
|
+
* `service-assertion.ts` (`signManagementRequest`) does. foundry does not
|
|
2741
|
+
* depend on that app's code, so the wire format -- header, prefix, claim
|
|
2742
|
+
* shape, digest -- is reproduced here exactly, rather than imported, so
|
|
2743
|
+
* ai's `verifyManagementRequest` accepts it unmodified.
|
|
2744
|
+
*/
|
|
2745
|
+
function signAiManagementRequest(input) {
|
|
2746
|
+
const digest = createHash('sha256').update(input.body).digest('hex');
|
|
2747
|
+
const issued = Math.floor(input.now() / 1000);
|
|
2748
|
+
const claims = {
|
|
2749
|
+
version: 1,
|
|
2750
|
+
issuer: input.issuer,
|
|
2751
|
+
audience: input.audience,
|
|
2752
|
+
platform: AI_MANAGEMENT_PLATFORM_ID,
|
|
2753
|
+
subject: input.issuer,
|
|
2754
|
+
organisation: AI_MANAGEMENT_NIL_ORGANISATION,
|
|
2755
|
+
method: 'POST',
|
|
2756
|
+
target: input.target,
|
|
2757
|
+
contentType: input.contentType,
|
|
2758
|
+
digest,
|
|
2759
|
+
issued,
|
|
2760
|
+
expires: issued + AI_MANAGEMENT_ASSERTION_TTL_SECONDS,
|
|
2761
|
+
nonce: randomUUID(),
|
|
2762
|
+
};
|
|
2763
|
+
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
|
2764
|
+
const message = `${input.keyId}.${payload}`;
|
|
2765
|
+
const signature = sign(null, Buffer.from(AI_MANAGEMENT_ASSERTION_PREFIX + message), input.privateKey).toString('base64url');
|
|
2766
|
+
return `${message}.${signature}`;
|
|
2767
|
+
}
|
|
2768
|
+
/**
|
|
2769
|
+
* The real {@link AiManagementKeyMinter}: signs a management assertion with
|
|
2770
|
+
* `privateKeyPem` (the PKCS#8 PEM {@link provisionPlatformAi} delivers under
|
|
2771
|
+
* {@link AI_SIGNING_KEY_REF} and pins on the runner under `keyId`) and calls
|
|
2772
|
+
* ai's `POST /v1/platform/keys`. `audience` must be the exact origin ai's own
|
|
2773
|
+
* `APP_URL` resolves to (`https://<platform.ai.hostname>`), since ai checks
|
|
2774
|
+
* the claim against that env var, not against the internal address the
|
|
2775
|
+
* request itself is sent to.
|
|
2776
|
+
*/
|
|
2777
|
+
export function createAiManagementKeyMinter(options) {
|
|
2778
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
2779
|
+
const now = options.now ?? Date.now;
|
|
2780
|
+
const privateKey = createPrivateKey(options.privateKeyPem);
|
|
2781
|
+
return {
|
|
2782
|
+
async mintManagementKey({ internalUrl, name, expiresDays, permissions }) {
|
|
2783
|
+
const contentType = 'application/json';
|
|
2784
|
+
const body = JSON.stringify({ name, expiresDays, permissions });
|
|
2785
|
+
const token = signAiManagementRequest({
|
|
2786
|
+
target: AI_PLATFORM_KEYS_PATH,
|
|
2787
|
+
contentType,
|
|
2788
|
+
body,
|
|
2789
|
+
issuer: options.issuer,
|
|
2790
|
+
audience: options.audience,
|
|
2791
|
+
keyId: options.keyId,
|
|
2792
|
+
privateKey,
|
|
2793
|
+
now,
|
|
2794
|
+
});
|
|
2795
|
+
const response = await fetchImpl(`${internalUrl}${AI_PLATFORM_KEYS_PATH}`, {
|
|
2796
|
+
method: 'POST',
|
|
2797
|
+
headers: { 'content-type': contentType, [AI_MANAGEMENT_HEADER]: token },
|
|
2798
|
+
body,
|
|
2799
|
+
// A hung ai must not hold the apply open.
|
|
2800
|
+
signal: AbortSignal.timeout(30_000),
|
|
2801
|
+
});
|
|
2802
|
+
if (response.status === 200) {
|
|
2803
|
+
const existing = parseAiKeyExistsBody(await response.json());
|
|
2804
|
+
throw new AiManagementKeyExistsError(existing.credentialId, existing.organisationId);
|
|
2805
|
+
}
|
|
2806
|
+
if (response.status === 409) {
|
|
2807
|
+
throw new AiManagementKeyConflictError();
|
|
2808
|
+
}
|
|
2809
|
+
if (response.status === 401 || response.status === 403) {
|
|
2810
|
+
throw new AiManagementKeyUntrustedError(response.status);
|
|
2811
|
+
}
|
|
2812
|
+
if (response.status !== 201) {
|
|
2813
|
+
throw new Error(`ai's platform/keys route answered ${response.status}`);
|
|
2814
|
+
}
|
|
2815
|
+
return parseAiKeyMintedBody(await response.json());
|
|
2816
|
+
},
|
|
2817
|
+
};
|
|
2818
|
+
}
|
|
2819
|
+
const PLATFORM_AI_PROJECT = 'platform';
|
|
2820
|
+
const PLATFORM_AI_REPO = 'wtfalch/ai';
|
|
2821
|
+
const PLATFORM_AI_DB_NAME = 'ai-db';
|
|
2822
|
+
const PLATFORM_AI_POSTGRES_DB = 'ai';
|
|
2823
|
+
/**
|
|
2824
|
+
* One app per build target of `wtfalch/ai`'s root Dockerfile (its
|
|
2825
|
+
* `apps/host/README.md`, "Images and validation"). `target` is set as
|
|
2826
|
+
* Coolify's `dockerfile_target_build`; `internalName` as the app's fixed
|
|
2827
|
+
* `custom_internal_name`, the way `platform:auth` names its two apps.
|
|
2828
|
+
*/
|
|
2829
|
+
const PLATFORM_AI_MIGRATE = {
|
|
2830
|
+
name: 'ai-migrate',
|
|
2831
|
+
target: 'migration',
|
|
2832
|
+
internalName: 'platform-ai-migrate',
|
|
2833
|
+
};
|
|
2834
|
+
const PLATFORM_AI_RUNNER = { name: 'ai', target: 'runner', internalName: 'platform-ai' };
|
|
2835
|
+
const PLATFORM_AI_WORKER = {
|
|
2836
|
+
name: 'ai-worker',
|
|
2837
|
+
target: 'worker',
|
|
2838
|
+
internalName: 'platform-ai-worker',
|
|
2839
|
+
};
|
|
2840
|
+
/** The runner target's `EXPOSE 3000` / `PORT=3000`. */
|
|
2841
|
+
const PLATFORM_AI_PORT = '3000';
|
|
2842
|
+
const PLATFORM_AI_PROJECT_SCOPE_KEY = 'coolify_project_platform_ai';
|
|
2843
|
+
const PLATFORM_AI_DB_SCOPE_KEY = 'coolify_platform_ai_database';
|
|
2844
|
+
const PLATFORM_AI_MIGRATE_SCOPE_KEY = 'coolify_platform_ai_migrate_app';
|
|
2845
|
+
const PLATFORM_AI_RUNNER_SCOPE_KEY = 'coolify_platform_ai_app';
|
|
2846
|
+
const PLATFORM_AI_WORKER_SCOPE_KEY = 'coolify_platform_ai_worker_app';
|
|
2847
|
+
/**
|
|
2848
|
+
* Written only when absent, never overwritten: an operator's own model list
|
|
2849
|
+
* or worker bounds survive every apply. Values are `.env.example`'s, and
|
|
2850
|
+
* `AI_MODELS=[]` is the README's "honest configuration for a host with no
|
|
2851
|
+
* approved text model".
|
|
2852
|
+
*/
|
|
2853
|
+
const PLATFORM_AI_RUNNER_DEFAULTS = [
|
|
2854
|
+
{ key: 'AI_MODELS', value: '[]' },
|
|
2855
|
+
{ key: 'AI_FORGE_BINDINGS', value: '[]' },
|
|
2856
|
+
];
|
|
2857
|
+
const PLATFORM_AI_WORKER_DEFAULTS = [
|
|
2858
|
+
...PLATFORM_AI_RUNNER_DEFAULTS,
|
|
2859
|
+
{ key: 'AI_WORKER_BATCH', value: '10' },
|
|
2860
|
+
{ key: 'AI_WORKER_TIMEOUT_MS', value: '60000' },
|
|
2861
|
+
];
|
|
2862
|
+
/**
|
|
2863
|
+
* #172: makes `coolify:app` keyed {@link PLATFORM_AI_KEY} true -- the ai host
|
|
2864
|
+
* (https://github.com/wtfalch/ai) on the platform's Coolify, then foundry's
|
|
2865
|
+
* own AI management key.
|
|
2866
|
+
*
|
|
2867
|
+
* The issue says to run the three parts "as `scripts/redeploy-dark.sh` in
|
|
2868
|
+
* that repo runs them". No such script exists anywhere in that repo's
|
|
2869
|
+
* history, so the shape here comes from what the repo does document: its
|
|
2870
|
+
* root Dockerfile's `migration`, `runner` and `worker` targets and
|
|
2871
|
+
* `apps/host/README.md`. One Coolify Postgres and three apps from the same
|
|
2872
|
+
* repo and `gitRef`, one per target:
|
|
2873
|
+
*
|
|
2874
|
+
* - `ai-migrate` (`migration`) gets `DATABASE_URL_OWNER` (the database's
|
|
2875
|
+
* own superuser URL, the only app that ever sees it) and `DATABASE_URL`,
|
|
2876
|
+
* from which `db-runtime-role.sh` sets the `<database>_rt` role's password.
|
|
2877
|
+
* Its container stays up after migrating ("Coolify reads an exited
|
|
2878
|
+
* container as a failed deploy") and turns healthy once the marker file is
|
|
2879
|
+
* written, so this waits for `running:healthy` and then stops it.
|
|
2880
|
+
* - `ai` (`runner`, port 3000) and `ai-worker` (`worker`, the continuously
|
|
2881
|
+
* polling `scripts/work.ts`) get only `DATABASE_URL`, the restricted
|
|
2882
|
+
* runtime role, plus the defaults in {@link PLATFORM_AI_WORKER_DEFAULTS}.
|
|
2883
|
+
*
|
|
2884
|
+
* Order: migrate first, wait, stop it, then the runner and worker, so neither
|
|
2885
|
+
* ever starts against an unmigrated schema. A migrate job that does not turn
|
|
2886
|
+
* healthy in time records {@link PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY}; the
|
|
2887
|
+
* planner then reports the step blocked instead of timing out on every apply.
|
|
2888
|
+
* A changed hostname or gitRef plans the step again (the applied values are
|
|
2889
|
+
* recorded last): `git_branch` is patched on every run, `APP_URL` follows the
|
|
2890
|
+
* hostname, and everything redeploys. The migration re-runs whenever
|
|
2891
|
+
* either of the other two is (re)deployed; it is idempotent and additive.
|
|
2892
|
+
* Coolify's auto-deploy on push is switched off on all three, because a push
|
|
2893
|
+
* would otherwise redeploy runner and worker without the migration first.
|
|
2894
|
+
*
|
|
2895
|
+
* `DATABASE_URL` is minted once (a base64url password, the alphabet
|
|
2896
|
+
* `db-runtime-role.sh` allows) and reused from whichever app already has it,
|
|
2897
|
+
* never rotated: the role's password is set from it at every migration.
|
|
2898
|
+
*
|
|
2899
|
+
* The runner's internal address, `http://platform-ai:3000`, is recorded under
|
|
2900
|
+
* {@link PLATFORM_AI_INTERNAL_URL_SCOPE_KEY}. Nothing public is wired: no
|
|
2901
|
+
* Coolify `domains`, tunnel, DNS record or Access app. Whether ai gets
|
|
2902
|
+
* `ai.<apex>` or stays internal like files is open (#172 "Decide"), and both
|
|
2903
|
+
* answers only add to this. `APP_URL` is set from `platform.ai.hostname` as
|
|
2904
|
+
* declared, since the host reads its own origin from it either way.
|
|
2905
|
+
*
|
|
2906
|
+
* The management key follows `platform:auth`'s admin-key discipline: the
|
|
2907
|
+
* credential id is recorded as undelivered before the secret leaves this
|
|
2908
|
+
* function, delivery is retried, and only a delivered key records the guard
|
|
2909
|
+
* that skips the mint. The secret is never returned, recorded, logged or
|
|
2910
|
+
* put in an error. The step's resource id is returned only after that, so
|
|
2911
|
+
* the planner keeps planning the step until the key is delivered.
|
|
2912
|
+
*
|
|
2913
|
+
* #201 built the real minter: ai's `POST /v1/platform/keys`
|
|
2914
|
+
* (github.com/wtfalch/ai/issues/141) mints the `archon` key over HTTP, signed
|
|
2915
|
+
* with an Ed25519 pair this function mints and pins on the runner once
|
|
2916
|
+
* ({@link ensureAiSigningKey}), the same two-key discipline as the
|
|
2917
|
+
* management key itself. A test may still inject a different
|
|
2918
|
+
* {@link AiManagementKeyMinter} in {@link PlatformAiDeps.managementKeyMinter},
|
|
2919
|
+
* which skips the signing-key machinery entirely.
|
|
2920
|
+
*
|
|
2921
|
+
* Known gaps, left for review (#172 stays open for review):
|
|
2922
|
+
* - Crash window: the credential id is recorded only after the minter
|
|
2923
|
+
* returns, so a crash between the mint and that record loses a key nobody
|
|
2924
|
+
* knows the id of. ai is idempotent by name, though, so the next apply's
|
|
2925
|
+
* mint call reaches the same live `archon` key rather than a second one --
|
|
2926
|
+
* it just cannot get the secret back, since ai shows it once. That is
|
|
2927
|
+
* reported as {@link PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY}, never
|
|
2928
|
+
* minted under another name.
|
|
2929
|
+
* - Coolify's `dockerfile_target_build` and `is_auto_deploy_enabled` are set
|
|
2930
|
+
* through the generic application PATCH; neither has been checked against
|
|
2931
|
+
* a live instance from this package.
|
|
2932
|
+
* - The key is minted for 90 days (the README's own examples); nothing
|
|
2933
|
+
* rotates it yet, so the platform loses its AI management key after 90 days.
|
|
2934
|
+
* - Deploys are decided from the recorded "pending" mark and the app's own
|
|
2935
|
+
* status, not from a deployment's age: the port cannot read one.
|
|
2936
|
+
* - Provider keys (`KEYS_WORKER_URL`, `KEYS_CREDENTIAL`, `KEYS_VERIFY_KEYS`)
|
|
2937
|
+
* and sign-in (`AUTH_*`) are not set: until an operator supplies them the
|
|
2938
|
+
* host answers `503 not_configured` for provider work, as its README says.
|
|
2939
|
+
*/
|
|
2940
|
+
async function provisionPlatformAi(context, deps) {
|
|
2941
|
+
const { platform } = context;
|
|
2942
|
+
const hostname = platform.ai.hostname;
|
|
2943
|
+
const gitRef = platform.ai.gitRef;
|
|
2944
|
+
const recorded = context.recordedIds();
|
|
2945
|
+
const runtime = deps.runtime ?? {};
|
|
2946
|
+
const sleep = runtime.sleep ?? defaultSleep;
|
|
2947
|
+
const { coolify } = deps;
|
|
2948
|
+
const record = async (key, id) => {
|
|
2949
|
+
context.recordScopeId(key, id);
|
|
2950
|
+
await context.persist?.();
|
|
2951
|
+
};
|
|
2952
|
+
let projectUuid = recorded[PLATFORM_AI_PROJECT_SCOPE_KEY];
|
|
2953
|
+
if (!projectUuid) {
|
|
2954
|
+
const found = await coolify.findProjectByName(PLATFORM_AI_PROJECT);
|
|
2955
|
+
projectUuid = found ? found.uuid : (await coolify.createProject(PLATFORM_AI_PROJECT)).uuid;
|
|
2956
|
+
await record(PLATFORM_AI_PROJECT_SCOPE_KEY, projectUuid);
|
|
2957
|
+
}
|
|
2958
|
+
const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
2959
|
+
// ---- database ----------------------------------------------------------
|
|
2960
|
+
let dbId = recorded[PLATFORM_AI_DB_SCOPE_KEY];
|
|
2961
|
+
if (!dbId) {
|
|
2962
|
+
const found = (await coolify.listDatabases()).find((d) => d.name === PLATFORM_AI_DB_NAME && d.environment_id === environment.id);
|
|
2963
|
+
if (found) {
|
|
2964
|
+
dbId = found.uuid;
|
|
2965
|
+
}
|
|
2966
|
+
else {
|
|
2967
|
+
const server = await resolvePlatformServer(coolify);
|
|
2968
|
+
const created = await coolify.createPostgresDatabase({
|
|
2969
|
+
project_uuid: projectUuid,
|
|
2970
|
+
server_uuid: server.uuid,
|
|
2971
|
+
environment_name: environment.name,
|
|
2972
|
+
environment_uuid: environment.uuid,
|
|
2973
|
+
name: PLATFORM_AI_DB_NAME,
|
|
2974
|
+
postgres_db: PLATFORM_AI_POSTGRES_DB,
|
|
2975
|
+
postgres_user: PLATFORM_AI_POSTGRES_DB,
|
|
2976
|
+
});
|
|
2977
|
+
dbId = created.uuid;
|
|
2978
|
+
}
|
|
2979
|
+
await record(PLATFORM_AI_DB_SCOPE_KEY, dbId);
|
|
2980
|
+
}
|
|
2981
|
+
const db = await coolify.getDatabase(dbId);
|
|
2982
|
+
if (!db.internal_db_url) {
|
|
2983
|
+
throw new Error('coolify:app for the platform (ai): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
|
|
2984
|
+
}
|
|
2985
|
+
const ownerUrl = db.internal_db_url;
|
|
2986
|
+
// ---- the three apps ------------------------------------------------------
|
|
2987
|
+
const ensureApp = async (spec, scopeKey) => {
|
|
2988
|
+
const app = await findOrCreatePlatformApp(coolify, spec.name, environment, async () => {
|
|
2989
|
+
const server = await resolvePlatformServer(coolify);
|
|
2990
|
+
const githubApp = await coolify.requireGithubApp();
|
|
2991
|
+
return coolify.createGithubAppApplication({
|
|
2992
|
+
project_uuid: projectUuid,
|
|
2993
|
+
server_uuid: server.uuid,
|
|
2994
|
+
environment_name: environment.name,
|
|
2995
|
+
environment_uuid: environment.uuid,
|
|
2996
|
+
github_app_uuid: githubApp.uuid,
|
|
2997
|
+
git_repository: PLATFORM_AI_REPO,
|
|
2998
|
+
git_branch: gitRef,
|
|
2999
|
+
build_pack: 'dockerfile',
|
|
3000
|
+
name: spec.name,
|
|
3001
|
+
});
|
|
3002
|
+
});
|
|
3003
|
+
await record(scopeKey, app.appId);
|
|
3004
|
+
// Set on every run, so a throw right after the create never leaves an app
|
|
3005
|
+
// building the wrong target or deploying itself on a push.
|
|
3006
|
+
const patch = {
|
|
3007
|
+
custom_internal_name: spec.internalName,
|
|
3008
|
+
dockerfile_target_build: spec.target,
|
|
3009
|
+
is_auto_deploy_enabled: false,
|
|
3010
|
+
// Every run, so a changed gitRef reaches an app that already exists.
|
|
3011
|
+
git_branch: gitRef,
|
|
3012
|
+
};
|
|
3013
|
+
if (spec === PLATFORM_AI_RUNNER)
|
|
3014
|
+
patch.ports_exposes = PLATFORM_AI_PORT;
|
|
3015
|
+
await coolify.updateApplication(app.appId, patch);
|
|
3016
|
+
return app;
|
|
3017
|
+
};
|
|
3018
|
+
const migrate = await ensureApp(PLATFORM_AI_MIGRATE, PLATFORM_AI_MIGRATE_SCOPE_KEY);
|
|
3019
|
+
const runner = await ensureApp(PLATFORM_AI_RUNNER, PLATFORM_AI_RUNNER_SCOPE_KEY);
|
|
3020
|
+
const worker = await ensureApp(PLATFORM_AI_WORKER, PLATFORM_AI_WORKER_SCOPE_KEY);
|
|
3021
|
+
// ---- env -----------------------------------------------------------------
|
|
3022
|
+
// Both database URLs are live credentials: handed to `bulkUpsertEnvs` and
|
|
3023
|
+
// nowhere else, never returned, recorded, logged or put in an error.
|
|
3024
|
+
const runtimeUrl = await ensureAiRuntimeUrl(coolify, [runner.appId, worker.appId, migrate.appId], ownerUrl);
|
|
3025
|
+
const migrateEnvs = [
|
|
3026
|
+
{ key: 'DATABASE_URL_OWNER', value: ownerUrl },
|
|
3027
|
+
{ key: 'DATABASE_URL', value: runtimeUrl },
|
|
3028
|
+
];
|
|
3029
|
+
const runnerEnvs = [
|
|
3030
|
+
{ key: 'DATABASE_URL', value: runtimeUrl },
|
|
3031
|
+
{ key: 'APP_URL', value: `https://${hostname}` },
|
|
3032
|
+
...(await missingEnvs(coolify, runner.appId, PLATFORM_AI_RUNNER_DEFAULTS)),
|
|
3033
|
+
];
|
|
3034
|
+
const workerEnvs = [
|
|
3035
|
+
{ key: 'DATABASE_URL', value: runtimeUrl },
|
|
3036
|
+
...(await missingEnvs(coolify, worker.appId, PLATFORM_AI_WORKER_DEFAULTS)),
|
|
3037
|
+
];
|
|
3038
|
+
// Whether anything the containers run on differs from what they last
|
|
3039
|
+
// deployed with. Marked and persisted before the first write, and cleared
|
|
3040
|
+
// only after the deploys below, so a run that fails halfway (the migrate
|
|
3041
|
+
// wait throwing, for one) leaves the mark and the next run deploys again.
|
|
3042
|
+
// #201: pin foundry's signing key before the deploy decision below, so the
|
|
3043
|
+
// runner starts with MANAGEMENT_ISSUER/MANAGEMENT_PUBLIC_KEYS: Coolify env
|
|
3044
|
+
// changes reach a container only on a redeploy. Only the real minter needs
|
|
3045
|
+
// it; an injected one answers for its own authentication.
|
|
3046
|
+
const signingKeyPem = !recorded[PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY] && !deps.managementKeyMinter
|
|
3047
|
+
? await ensureAiSigningKey(context, deps, runner.appId, runtime, sleep, record)
|
|
3048
|
+
: undefined;
|
|
3049
|
+
const migrateChanged = await envsDiffer(coolify, migrate.appId, migrateEnvs);
|
|
3050
|
+
const runnerChanged = await envsDiffer(coolify, runner.appId, runnerEnvs);
|
|
3051
|
+
const workerChanged = await envsDiffer(coolify, worker.appId, workerEnvs);
|
|
3052
|
+
const declaredChanged = recorded[PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname ||
|
|
3053
|
+
recorded[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] !== gitRef;
|
|
3054
|
+
const pending = Boolean(context.recordedIds()[PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY]) ||
|
|
3055
|
+
migrateChanged ||
|
|
3056
|
+
runnerChanged ||
|
|
3057
|
+
workerChanged ||
|
|
3058
|
+
declaredChanged;
|
|
3059
|
+
if (pending)
|
|
3060
|
+
await record(PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, '1');
|
|
3061
|
+
await coolify.bulkUpsertEnvs(migrate.appId, migrateEnvs);
|
|
3062
|
+
await coolify.bulkUpsertEnvs(runner.appId, runnerEnvs);
|
|
3063
|
+
await coolify.bulkUpsertEnvs(worker.appId, workerEnvs);
|
|
3064
|
+
const deployRunner = runner.justCreated || !runner.running || pending;
|
|
3065
|
+
const deployWorker = worker.justCreated || !worker.running || pending;
|
|
3066
|
+
const runMigration = migrate.justCreated || deployRunner || deployWorker;
|
|
3067
|
+
// ---- migrate, wait, stop, then runner and worker ------------------------
|
|
3068
|
+
if (runMigration) {
|
|
3069
|
+
// A migrate container still up from an earlier run (one that threw before
|
|
3070
|
+
// stopping it) would already read healthy. Stop it and wait until it
|
|
3071
|
+
// reads stopped, so the wait below only ever sees the new deployment.
|
|
3072
|
+
if (!migrate.justCreated && migrate.running) {
|
|
3073
|
+
await coolify.raw('POST', `/applications/${migrate.appId}/stop`);
|
|
3074
|
+
await waitForStopped(coolify, migrate.appId, runtime, sleep);
|
|
3075
|
+
}
|
|
3076
|
+
await coolify.deploy(migrate.appId);
|
|
3077
|
+
try {
|
|
3078
|
+
await waitForAiMigration(coolify, migrate.appId, runtime, sleep);
|
|
3079
|
+
}
|
|
3080
|
+
catch (error) {
|
|
3081
|
+
if (error instanceof MigrationNotHealthyError) {
|
|
3082
|
+
// Blocked from now on: the planner reports it and plans nothing until
|
|
3083
|
+
// a person fixes the job and removes this key, instead of waiting out
|
|
3084
|
+
// the timeout on every apply.
|
|
3085
|
+
await record(PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, error.lastStatus);
|
|
3086
|
+
}
|
|
3087
|
+
throw error;
|
|
3088
|
+
}
|
|
3089
|
+
await coolify.raw('POST', `/applications/${migrate.appId}/stop`);
|
|
3090
|
+
}
|
|
3091
|
+
if (deployRunner)
|
|
3092
|
+
await coolify.deploy(runner.appId);
|
|
3093
|
+
if (deployWorker)
|
|
3094
|
+
await coolify.deploy(worker.appId);
|
|
3095
|
+
await record(PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, '');
|
|
3096
|
+
// The deploys are done, so record what they deployed now. A later failure
|
|
3097
|
+
// at the mint or delivery must not make the next apply see a changed
|
|
3098
|
+
// hostname or gitRef and redeploy the live host again. The planner still
|
|
3099
|
+
// plans the step until the resource id is recorded, once the key is delivered.
|
|
3100
|
+
await record(PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
|
|
3101
|
+
await record(PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, gitRef);
|
|
3102
|
+
const internalUrl = `http://${PLATFORM_AI_RUNNER.internalName}:${PLATFORM_AI_PORT}`;
|
|
3103
|
+
await record(PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, internalUrl);
|
|
3104
|
+
// ---- Archon's AI management key, once ------------------------------------
|
|
3105
|
+
const now = context.recordedIds();
|
|
3106
|
+
if (!now[PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY]) {
|
|
3107
|
+
const lost = now[PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY];
|
|
3108
|
+
if (lost) {
|
|
3109
|
+
throw new Error(`platform:ai: AI management key ${lost} was minted but never delivered; revoke it and remove the recorded key ${PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
3110
|
+
}
|
|
3111
|
+
// #201: a test injects its own minter; without one, build the real one
|
|
3112
|
+
// over ai's own POST /v1/platform/keys, signed with the key pinned above.
|
|
3113
|
+
const minter = deps.managementKeyMinter ??
|
|
3114
|
+
createAiManagementKeyMinter({
|
|
3115
|
+
privateKeyPem: signingKeyPem,
|
|
3116
|
+
keyId: AI_MANAGEMENT_KEY_ID,
|
|
3117
|
+
issuer: AI_MANAGEMENT_ISSUER,
|
|
3118
|
+
audience: `https://${hostname}`,
|
|
3119
|
+
fetchImpl: deps.fetchImpl,
|
|
3120
|
+
});
|
|
3121
|
+
let minted;
|
|
3122
|
+
try {
|
|
3123
|
+
minted = await minter.mintManagementKey({
|
|
3124
|
+
internalUrl,
|
|
3125
|
+
name: 'archon',
|
|
3126
|
+
expiresDays: 90,
|
|
3127
|
+
permissions: PLATFORM_AI_MANAGEMENT_PERMISSIONS,
|
|
3128
|
+
});
|
|
3129
|
+
}
|
|
3130
|
+
catch (error) {
|
|
3131
|
+
if (error instanceof AiManagementKeyExistsError) {
|
|
3132
|
+
// ai already holds an 'archon' key this record never learned about
|
|
3133
|
+
// (most likely the crash window below's own comment describes,
|
|
3134
|
+
// happening one mint earlier): its secret cannot be shown again, so
|
|
3135
|
+
// this is reported and never retried under another name.
|
|
3136
|
+
await record(PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, error.credentialId);
|
|
3137
|
+
throw new Error(`platform:ai: ai already holds a management key named 'archon' (${error.credentialId}) that foundry never recorded delivering; its secret cannot be shown again. Revoke it in ai and remove the recorded key ${PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY} to mint a new one`);
|
|
3138
|
+
}
|
|
3139
|
+
// Both messages below are fixed strings with no interpolated body, so
|
|
3140
|
+
// safe to surface directly -- unlike the generic case, neither can
|
|
3141
|
+
// ever echo a credential the minter read off the wire.
|
|
3142
|
+
if (error instanceof AiManagementKeyConflictError) {
|
|
3143
|
+
throw new Error(`platform:ai: ${error.message}`);
|
|
3144
|
+
}
|
|
3145
|
+
if (error instanceof AiManagementKeyUntrustedError) {
|
|
3146
|
+
throw new Error(`platform:ai: ${error.message}`);
|
|
3147
|
+
}
|
|
3148
|
+
// The cause is dropped in case it echoes a credential.
|
|
3149
|
+
throw new Error('platform:ai: the AI management key could not be minted (the minter failed; its error is not shown). Nothing was recorded; apply again');
|
|
3150
|
+
}
|
|
3151
|
+
const { credentialId, organisationId, secret } = minted;
|
|
3152
|
+
await record(PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, credentialId);
|
|
3153
|
+
await record(PLATFORM_AI_ORGANISATION_SCOPE_KEY, organisationId);
|
|
3154
|
+
const attempts = runtime.deliverAttempts ?? 3;
|
|
3155
|
+
for (let attempt = 1;; attempt += 1) {
|
|
3156
|
+
try {
|
|
3157
|
+
await deps.deliverSecret(PLATFORM_AI_MANAGEMENT_KEY_REF, secret);
|
|
3158
|
+
break;
|
|
3159
|
+
}
|
|
3160
|
+
catch {
|
|
3161
|
+
// The cause is dropped in case it echoes the secret.
|
|
3162
|
+
if (attempt >= attempts) {
|
|
3163
|
+
throw new Error(`platform:ai: AI management key ${credentialId} was minted but could not be delivered after ${attempts} tries`);
|
|
3164
|
+
}
|
|
3165
|
+
await sleep(1000 * attempt);
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
await record(PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, credentialId);
|
|
3169
|
+
}
|
|
3170
|
+
return { resourceId: runner.appId };
|
|
3171
|
+
}
|
|
3172
|
+
/**
|
|
3173
|
+
* #201: ensures foundry's Ed25519 signing pair for ai's `POST
|
|
3174
|
+
* /v1/platform/keys` exists, and returns the private half (PKCS#8 PEM)
|
|
3175
|
+
* needed to sign the mint call that follows -- freshly minted this run, or
|
|
3176
|
+
* read back via `deps.heldKeys` when an earlier run already delivered and
|
|
3177
|
+
* pinned it but crashed before the management key itself was minted and
|
|
3178
|
+
* delivered. Follows `platform:integrations`'s own two-key discipline
|
|
3179
|
+
* exactly ({@link INTEGRATIONS_MANAGEMENT_KEY_REF}/`_ISSUER`/`_KEY_ID`): the
|
|
3180
|
+
* id is recorded and persisted before the private half is delivered, the
|
|
3181
|
+
* guard only once delivery and the pin on the runner both succeeded, so a
|
|
3182
|
+
* crash in between never mints a second pair.
|
|
3183
|
+
*/
|
|
3184
|
+
async function ensureAiSigningKey(context, deps, runnerAppId, runtime, sleep, record) {
|
|
3185
|
+
if (context.recordedIds()[PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY]) {
|
|
3186
|
+
if (!deps.heldKeys) {
|
|
3187
|
+
throw new Error('platform:ai: the AI management key cannot be minted: its signing key is already pinned but the host built the platform executor without a held-key reader to read back its private half (PlatformAiDeps.heldKeys)');
|
|
3188
|
+
}
|
|
3189
|
+
const held = await deps.heldKeys.resolve(AI_SIGNING_KEY_REF);
|
|
3190
|
+
if (!held) {
|
|
3191
|
+
throw new Error(`platform:ai: the AI management key cannot be minted: the signing key is pinned on the runner but its private half could not be read back (held key ${AI_SIGNING_KEY_REF})`);
|
|
3192
|
+
}
|
|
3193
|
+
return held;
|
|
3194
|
+
}
|
|
3195
|
+
if (context.recordedIds()[PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY]) {
|
|
3196
|
+
throw new Error(`platform:ai: signing key ${AI_MANAGEMENT_KEY_ID} was minted but not confirmed delivered and pinned; not minting another. Check the held key and the runner's MANAGEMENT_PUBLIC_KEYS, then remove the recorded key ${PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
3197
|
+
}
|
|
3198
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
|
|
3199
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
3200
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
3201
|
+
});
|
|
3202
|
+
await record(PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, AI_MANAGEMENT_KEY_ID);
|
|
3203
|
+
const attempts = runtime.deliverAttempts ?? 3;
|
|
3204
|
+
for (let attempt = 1;; attempt += 1) {
|
|
3205
|
+
try {
|
|
3206
|
+
await deps.deliverSecret(AI_SIGNING_KEY_REF, privateKey);
|
|
3207
|
+
break;
|
|
3208
|
+
}
|
|
3209
|
+
catch {
|
|
3210
|
+
// The cause is dropped in case it echoes the key.
|
|
3211
|
+
if (attempt >= attempts) {
|
|
3212
|
+
throw new Error(`platform:ai: signing key ${AI_MANAGEMENT_KEY_ID} was minted but could not be delivered after ${attempts} attempts`);
|
|
3213
|
+
}
|
|
3214
|
+
await sleep(1000 * attempt);
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
// The runner reads these only after a redeploy: mark it pending first, so
|
|
3218
|
+
// a crash between pinning and deploying still redeploys on the next apply.
|
|
3219
|
+
await record(PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, '1');
|
|
3220
|
+
await deps.coolify.bulkUpsertEnvs(runnerAppId, [
|
|
3221
|
+
{ key: 'MANAGEMENT_ISSUER', value: AI_MANAGEMENT_ISSUER },
|
|
3222
|
+
{
|
|
3223
|
+
key: 'MANAGEMENT_PUBLIC_KEYS',
|
|
3224
|
+
value: JSON.stringify({ [AI_MANAGEMENT_KEY_ID]: publicKey }),
|
|
3225
|
+
},
|
|
3226
|
+
]);
|
|
3227
|
+
await record(PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, runnerAppId);
|
|
3228
|
+
return privateKey;
|
|
3229
|
+
}
|
|
3230
|
+
/** The migrate job did not turn healthy in time; carries its last status, which holds no secret. */
|
|
3231
|
+
class MigrationNotHealthyError extends Error {
|
|
3232
|
+
lastStatus;
|
|
3233
|
+
constructor(lastStatus, message) {
|
|
3234
|
+
super(message);
|
|
3235
|
+
this.lastStatus = lastStatus;
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
/**
|
|
3239
|
+
* Polls the app until Coolify reports it stopped (`exited`/`stopped`), so a
|
|
3240
|
+
* healthy status read afterwards belongs to a deployment queued after this.
|
|
3241
|
+
*/
|
|
3242
|
+
async function waitForStopped(coolify, appId, runtime, sleep) {
|
|
3243
|
+
const now = runtime.now ?? Date.now;
|
|
3244
|
+
const timeout = runtime.migrateTimeoutMs ?? 600_000;
|
|
3245
|
+
const interval = runtime.migrateIntervalMs ?? 5_000;
|
|
3246
|
+
const deadline = now() + timeout;
|
|
3247
|
+
for (;;) {
|
|
3248
|
+
const { status } = await coolify.getApplication(appId);
|
|
3249
|
+
if (status && !isRunningOrStarting(status))
|
|
3250
|
+
return;
|
|
3251
|
+
if (now() >= deadline) {
|
|
3252
|
+
throw new Error(`platform:ai: the migrate job ${appId} did not stop within ${Math.round(timeout / 1000)}s (last status ${status ?? 'unknown'}); apply again`);
|
|
3253
|
+
}
|
|
3254
|
+
await sleep(interval);
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
/**
|
|
3258
|
+
* Polls the migrate app until Coolify reports it `running:healthy` -- the
|
|
3259
|
+
* migration target's healthcheck passes only once `migrate.mjs` has finished
|
|
3260
|
+
* -- or throws {@link MigrationNotHealthyError} once `migrateTimeoutMs` has
|
|
3261
|
+
* passed. The caller stopped the app and saw it stopped before deploying, so
|
|
3262
|
+
* a healthy status here is the new deployment's. Any other status (queued,
|
|
3263
|
+
* building, exited, starting unhealthy) keeps waiting. On a timeout the job
|
|
3264
|
+
* is left as it is: stopping it mid-migration is worse.
|
|
3265
|
+
*/
|
|
3266
|
+
async function waitForAiMigration(coolify, appId, runtime, sleep) {
|
|
3267
|
+
const now = runtime.now ?? Date.now;
|
|
3268
|
+
const timeout = runtime.migrateTimeoutMs ?? 600_000;
|
|
3269
|
+
const interval = runtime.migrateIntervalMs ?? 5_000;
|
|
3270
|
+
const deadline = now() + timeout;
|
|
3271
|
+
for (;;) {
|
|
3272
|
+
await sleep(interval);
|
|
3273
|
+
const { status } = await coolify.getApplication(appId);
|
|
3274
|
+
if (status?.startsWith('running:healthy'))
|
|
3275
|
+
return;
|
|
3276
|
+
if (now() >= deadline) {
|
|
3277
|
+
throw new MigrationNotHealthyError(status ?? 'unknown', `platform:ai: the migrate job ${appId} did not report healthy within ${Math.round(timeout / 1000)}s (last status ${status ?? 'unknown'}); the host and worker were not deployed. Check its logs; the step is blocked until the recorded key ${PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY} is removed`);
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3281
|
+
/**
|
|
3282
|
+
* The runtime role's `DATABASE_URL`, reused from the first of `appIds` that
|
|
3283
|
+
* already has one, else minted: `<database>_rt` (the name ai's
|
|
3284
|
+
* `0001_runtime_role.sql` derives) with a fresh base64url password, on the
|
|
3285
|
+
* owner URL's host, database and query string. The owner URL is parsed
|
|
3286
|
+
* inside a try: Node's parse error carries the whole input, credential
|
|
3287
|
+
* included, so it is replaced with a fixed message.
|
|
3288
|
+
*/
|
|
3289
|
+
async function ensureAiRuntimeUrl(coolify, appIds, ownerUrl) {
|
|
3290
|
+
for (const appId of appIds) {
|
|
3291
|
+
const existing = (await coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL' && !e.is_preview && e.value);
|
|
3292
|
+
if (existing)
|
|
3293
|
+
return existing.value;
|
|
3294
|
+
}
|
|
3295
|
+
let host;
|
|
3296
|
+
let pathname;
|
|
3297
|
+
let search;
|
|
3298
|
+
try {
|
|
3299
|
+
({ host, pathname, search } = new URL(ownerUrl));
|
|
3300
|
+
}
|
|
3301
|
+
catch {
|
|
3302
|
+
throw new Error("platform:ai: the database's internal_db_url could not be parsed");
|
|
3303
|
+
}
|
|
3304
|
+
const database = pathname.replace(/^\//, '');
|
|
3305
|
+
if (!/^[A-Za-z0-9_]+$/.test(database)) {
|
|
3306
|
+
throw new Error("platform:ai: the database's name cannot form a runtime role name (letters, digits and _ only)");
|
|
3307
|
+
}
|
|
3308
|
+
const password = randomBytes(24).toString('base64url');
|
|
3309
|
+
return `postgres://${database}_rt:${password}@${host}${pathname}${search}`;
|
|
3310
|
+
}
|
|
3311
|
+
/** The entries of `defaults` whose key the app does not have yet. */
|
|
3312
|
+
async function missingEnvs(coolify, appId, defaults) {
|
|
3313
|
+
const present = new Set((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => e.key));
|
|
3314
|
+
return defaults.filter((d) => !present.has(d.key));
|
|
3315
|
+
}
|
|
3316
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3317
|
+
/** Polls `url` until it answers 2xx, or throws once `readyTimeoutMs` has passed. */
|
|
3318
|
+
async function waitForInstance(url, runtime, sleep) {
|
|
3319
|
+
const fetchImpl = runtime.fetch ?? globalThis.fetch;
|
|
3320
|
+
const now = runtime.now ?? Date.now;
|
|
3321
|
+
const timeout = runtime.readyTimeoutMs ?? 300_000;
|
|
3322
|
+
const interval = runtime.readyIntervalMs ?? 5_000;
|
|
3323
|
+
const deadline = now() + timeout;
|
|
3324
|
+
for (;;) {
|
|
3325
|
+
try {
|
|
3326
|
+
const response = await fetchImpl(url);
|
|
3327
|
+
if (response.ok)
|
|
3328
|
+
return;
|
|
3329
|
+
}
|
|
3330
|
+
catch {
|
|
3331
|
+
// Not up yet: DNS, the tunnel or the container.
|
|
3332
|
+
}
|
|
3333
|
+
if (now() >= deadline) {
|
|
3334
|
+
throw new Error(`platform:auth: the new ZITADEL instance did not answer at ${url} within ${Math.round(timeout / 1000)}s; retry this apply`);
|
|
3335
|
+
}
|
|
3336
|
+
await sleep(interval);
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
async function resolvePlatformServer(coolify) {
|
|
3340
|
+
const servers = await coolify.listServers();
|
|
3341
|
+
const server = servers.find((s) => s.is_usable && s.is_reachable) ?? servers[0];
|
|
3342
|
+
if (!server)
|
|
3343
|
+
throw new Error('no usable Coolify server is registered on this instance');
|
|
3344
|
+
return server;
|
|
3345
|
+
}
|
|
3346
|
+
/**
|
|
3347
|
+
* `executor.ts`'s `coolifyCalls`/`findOrCreateCallsApp`, restated here rather
|
|
3348
|
+
* than imported: that method is private to `FoundryStepExecutor`, and this
|
|
3349
|
+
* file holds no instance of that class to call it on. `running` says whether
|
|
3350
|
+
* the app was already running or starting; the caller deploys it when not.
|
|
3351
|
+
*/
|
|
3352
|
+
async function findOrCreatePlatformApp(coolify, name, environment, create) {
|
|
3353
|
+
const existing = await coolify.findApplicationByName(name, { environmentIds: [environment.id] });
|
|
3354
|
+
if (existing) {
|
|
3355
|
+
const live = await coolify.getApplication(existing.uuid);
|
|
3356
|
+
return {
|
|
3357
|
+
appId: existing.uuid,
|
|
3358
|
+
justCreated: false,
|
|
3359
|
+
running: isRunningOrStarting(live.status),
|
|
3360
|
+
};
|
|
3361
|
+
}
|
|
3362
|
+
const created = await create();
|
|
3363
|
+
return { appId: created.uuid, justCreated: true, running: false };
|
|
3364
|
+
}
|
|
3365
|
+
/**
|
|
3366
|
+
* #211: names of the envs the platform's sign-in broker (`wtfalch/auth`'s
|
|
3367
|
+
* `broker/server.ts`) refuses to start without, that `provisionPlatformAuth`
|
|
3368
|
+
* itself has no way to supply yet -- `ZITADEL_SERVICE_USER_TOKEN`/`_FILE`
|
|
3369
|
+
* (#209 mints and pushes this once it lands) and `RESEND_API_TOKEN` (#210,
|
|
3370
|
+
* blocked on `wtfalch/auth#138`). Read from the broker's own live env, not a
|
|
3371
|
+
* recorded scope id: whichever of #209/#210 lands first (or a human, setting
|
|
3372
|
+
* either directly on the app) is enough to clear that one input, in either
|
|
3373
|
+
* order, with no coupling to this step's own execution.
|
|
3374
|
+
*
|
|
3375
|
+
* #211 round 3: `AUTH_APPS` is a third required input -- the broker's own
|
|
3376
|
+
* `parseApps` refuses to start with no `AUTH_APPS` set or an empty array
|
|
3377
|
+
* (`wtfalch/auth/broker/src/apps.ts`), and this check used to name only the
|
|
3378
|
+
* other two, so a deploy could go ahead with a broker that still would not
|
|
3379
|
+
* come up. Also treat `null`/`undefined` as missing, not only `''`: the
|
|
3380
|
+
* `CoolifyPort.listEnvs` doc comment already warns a live value does not
|
|
3381
|
+
* always match its declared `string` type.
|
|
3382
|
+
*/
|
|
3383
|
+
async function missingBrokerInputs(coolify, brokerAppId) {
|
|
3384
|
+
const envs = await coolify.listEnvs(brokerAppId);
|
|
3385
|
+
const value = (key) => envs.find((e) => e.key === key && !e.is_preview && e.value != null && e.value !== '')?.value;
|
|
3386
|
+
const has = (key) => value(key) !== undefined;
|
|
3387
|
+
const missing = [];
|
|
3388
|
+
if (!has('ZITADEL_SERVICE_USER_TOKEN') && !has('ZITADEL_SERVICE_USER_TOKEN_FILE')) {
|
|
3389
|
+
missing.push('ZITADEL_SERVICE_USER_TOKEN');
|
|
3390
|
+
}
|
|
3391
|
+
if (!has('RESEND_API_TOKEN'))
|
|
3392
|
+
missing.push('RESEND_API_TOKEN');
|
|
3393
|
+
if (authAppsProblem(value('AUTH_APPS'), brokerAppId) !== null)
|
|
3394
|
+
missing.push('AUTH_APPS');
|
|
3395
|
+
return missing;
|
|
3396
|
+
}
|
|
3397
|
+
/**
|
|
3398
|
+
* #211 round 5 addendum: why a live `AUTH_APPS` value would not pass the
|
|
3399
|
+
* broker's own `parseApps` at boot, distinguishing `'missing'` (nothing set,
|
|
3400
|
+
* or an empty array -- nobody has registered anything yet) from
|
|
3401
|
+
* `'malformed'` (something is set and it is wrong: bad JSON, not an array, or
|
|
3402
|
+
* an entry {@link validateBrokerAppEntry} itself refuses). `hasNonEmptyAuthApps`
|
|
3403
|
+
* (#211 round 4) collapsed both into one `false`, so `missingBrokerInputs`
|
|
3404
|
+
* (and, through it, `authBrokerBlockedReason`) always reported "it has no
|
|
3405
|
+
* ... AUTH_APPS yet" even when `AUTH_APPS` held garbage a person needs to go
|
|
3406
|
+
* fix, rather than an input nobody has supplied yet -- `authBrokerBlockedReason`
|
|
3407
|
+
* below is what turns this distinction into different wording. `null` when
|
|
3408
|
+
* the value is valid. A bad entry's own error is discarded here too: this
|
|
3409
|
+
* function only classifies the problem, and the named error still surfaces
|
|
3410
|
+
* wherever `readBrokerApps` is actually called.
|
|
3411
|
+
*/
|
|
3412
|
+
function authAppsProblem(raw, brokerAppId) {
|
|
3413
|
+
if (raw === undefined || raw === '')
|
|
3414
|
+
return 'missing';
|
|
3415
|
+
let parsed;
|
|
3416
|
+
try {
|
|
3417
|
+
parsed = JSON.parse(raw);
|
|
3418
|
+
}
|
|
3419
|
+
catch {
|
|
3420
|
+
return 'malformed';
|
|
3421
|
+
}
|
|
3422
|
+
if (!Array.isArray(parsed))
|
|
3423
|
+
return 'malformed';
|
|
3424
|
+
if (parsed.length === 0)
|
|
3425
|
+
return 'missing';
|
|
3426
|
+
try {
|
|
3427
|
+
parsed.forEach((entry, index) => validateBrokerAppEntry(entry, brokerAppId, index));
|
|
3428
|
+
}
|
|
3429
|
+
catch {
|
|
3430
|
+
return 'malformed';
|
|
3431
|
+
}
|
|
3432
|
+
return null;
|
|
3433
|
+
}
|
|
3434
|
+
/**
|
|
3435
|
+
* #211 round 5: sha256 of the exact `AUTH_APPS` string a {@link BrokerDeployRecord}
|
|
3436
|
+
* names -- never the value itself, only its hash, so a recorded scope id can
|
|
3437
|
+
* never leak another organisation's broker key. `undefined` (no `AUTH_APPS`
|
|
3438
|
+
* set) hashes the same as `''`, both meaning "nothing deployed yet".
|
|
3439
|
+
*/
|
|
3440
|
+
function hashAuthApps(raw) {
|
|
3441
|
+
return createHash('sha256')
|
|
3442
|
+
.update(raw ?? '')
|
|
3443
|
+
.digest('hex');
|
|
3444
|
+
}
|
|
3445
|
+
/**
|
|
3446
|
+
* #211 round 5: the one rule `provisionPlatformAuth` and
|
|
3447
|
+
* `provisionPlatformArchon` both call to decide whether the broker needs a
|
|
3448
|
+
* (re)deploy -- replacing the two ad hoc "was it ever ostensibly deployed"
|
|
3449
|
+
* booleans each function used to keep, which is what let a recreated broker,
|
|
3450
|
+
* and a crash between writing `AUTH_APPS` and deploying, both go unnoticed
|
|
3451
|
+
* (the round 5 issue text's own diagnosis: "they share one cause"). `false`
|
|
3452
|
+
* while any required input is still missing -- deploying a broker that
|
|
3453
|
+
* cannot start is pointless regardless of what changed. Otherwise `true`
|
|
3454
|
+
* when nothing has ever been recorded, when the broker that was deployed is
|
|
3455
|
+
* a different app (recreated under a new id), or when the `AUTH_APPS` that
|
|
3456
|
+
* was deployed is not the `AUTH_APPS` that is live/about-to-be-live now.
|
|
3457
|
+
* `authAppsRaw` is that exact string -- read live by `provisionPlatformAuth`,
|
|
3458
|
+
* which never writes `AUTH_APPS` itself, or supplied by
|
|
3459
|
+
* `provisionPlatformArchon` as the string it already knows it just wrote (or
|
|
3460
|
+
* left unchanged), so neither caller needs a second read to agree with
|
|
3461
|
+
* itself about what is now true of the app.
|
|
3462
|
+
*/
|
|
3463
|
+
function brokerDeployNeeded(missingInputs, brokerAppId, authAppsRaw, recordedDeploy) {
|
|
3464
|
+
if (missingInputs.length > 0)
|
|
3465
|
+
return false;
|
|
3466
|
+
if (recordedDeploy === null)
|
|
3467
|
+
return true;
|
|
3468
|
+
if (recordedDeploy.brokerAppId !== brokerAppId)
|
|
3469
|
+
return true;
|
|
3470
|
+
return recordedDeploy.authAppsHash !== hashAuthApps(authAppsRaw);
|
|
3471
|
+
}
|
|
3472
|
+
/**
|
|
3473
|
+
* #211 round 5: the {@link BrokerDeployRecord} JSON a successful deploy
|
|
3474
|
+
* writes under {@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY} (via each
|
|
3475
|
+
* caller's own `record()`), replacing the plain broker-id string round 2-4
|
|
3476
|
+
* wrote there. Paired with {@link brokerDeployNeeded}, which reads it back.
|
|
3477
|
+
*/
|
|
3478
|
+
function brokerDeployRecordValue(brokerAppId, authAppsRaw) {
|
|
3479
|
+
const record = { brokerAppId, authAppsHash: hashAuthApps(authAppsRaw) };
|
|
3480
|
+
return JSON.stringify(record);
|
|
3481
|
+
}
|
|
3482
|
+
/** Whether any desired env is missing from the app, or set to another value. */
|
|
3483
|
+
async function envsDiffer(coolify, appId, desired) {
|
|
3484
|
+
const live = new Map((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
|
|
3485
|
+
return desired.some((e) => live.get(e.key) !== e.value);
|
|
3486
|
+
}
|
|
3487
|
+
/**
|
|
3488
|
+
* `null` once the app already has this key set -- generated only the first
|
|
3489
|
+
* time, never regenerated or overwritten, mirroring `executor.ts`'s
|
|
3490
|
+
* `ensureAuthCookieSecret` exactly. #211 round 4: `''`, `null` and
|
|
3491
|
+
* `undefined` all count as not set, the same "missing" a live Coolify
|
|
3492
|
+
* response can carry despite its declared `string` type
|
|
3493
|
+
* (`CoolifyPort.listEnvs`'s own doc comment) -- an entry present with one of
|
|
3494
|
+
* those values used to read as "already has this key set" and return `null`,
|
|
3495
|
+
* so a caller reading the value back afterwards (`provisionPlatformArchon`'s
|
|
3496
|
+
* own `AUTH_APP_KEY`) threw "lost its ... between two reads" on every single
|
|
3497
|
+
* apply instead of regenerating it.
|
|
3498
|
+
*
|
|
3499
|
+
* #211 round 5: `minLength`, when given, also counts a *present, non-empty*
|
|
3500
|
+
* value shorter than it as not set -- the broker's own `parseApps`
|
|
3501
|
+
* (`wtfalch/auth/broker/src/apps.ts`) refuses a `key` under 32 characters,
|
|
3502
|
+
* and this package minted a 32-character `AUTH_APP_KEY` from the start
|
|
3503
|
+
* (`provisionPlatformArchon`'s own comment on `randomBytes(32)`), so this
|
|
3504
|
+
* only ever changes behaviour for a key that reached the app some other way
|
|
3505
|
+
* -- a hand-set value, or one carried over from before that guarantee held.
|
|
3506
|
+
* Optional, and unused by every other call site (`ensureMasterkey`,
|
|
3507
|
+
* `ZITADEL_ADMIN_PASSWORD`, `AUTH_COOKIE_SECRET`, this file's recovery admin
|
|
3508
|
+
* password): none of those have a broker-enforced minimum, so widening this
|
|
3509
|
+
* check to them would reject a value that has always been fine.
|
|
3510
|
+
*/
|
|
3511
|
+
async function ensureGeneratedEnv(coolify, appId, key, generate, minLength) {
|
|
3512
|
+
const envs = await coolify.listEnvs(appId);
|
|
3513
|
+
const entry = envs.find((e) => e.key === key && !e.is_preview);
|
|
3514
|
+
const already = entry !== undefined &&
|
|
3515
|
+
entry.value != null &&
|
|
3516
|
+
entry.value !== '' &&
|
|
3517
|
+
(minLength === undefined || entry.value.length >= minLength);
|
|
3518
|
+
if (already)
|
|
3519
|
+
return null;
|
|
3520
|
+
return generate();
|
|
3521
|
+
}
|
|
3522
|
+
/** The live value of `key` on `appId`, or `undefined` when unset -- `ensureGeneratedEnv`'s read half, for a caller that needs the value it already found. */
|
|
3523
|
+
async function currentEnvValue(coolify, appId, key) {
|
|
3524
|
+
const envs = await coolify.listEnvs(appId);
|
|
3525
|
+
return envs.find((e) => e.key === key && !e.is_preview)?.value;
|
|
3526
|
+
}
|
|
3527
|
+
/**
|
|
3528
|
+
* {@link ensureGeneratedEnv} for `ZITADEL_MASTERKEY`, which encrypts what
|
|
3529
|
+
* ZITADEL keeps in its database. A new key over a database ZITADEL has run
|
|
3530
|
+
* against (one that existed before this run and had its app deployed) makes
|
|
3531
|
+
* that data unreadable, so such a database never gets a generated one. The
|
|
3532
|
+
* env is always written before the first deploy, so a missing key on a
|
|
3533
|
+
* deployed app means someone removed it. This throws, and a person sets the key that database was
|
|
3534
|
+
* initialised with (or deletes an unused database) and applies again.
|
|
3535
|
+
*/
|
|
3536
|
+
async function ensureMasterkey(coolify, appId, databaseInUse) {
|
|
3537
|
+
const envs = await coolify.listEnvs(appId);
|
|
3538
|
+
if (envs.some((e) => e.key === 'ZITADEL_MASTERKEY' && !e.is_preview))
|
|
3539
|
+
return null;
|
|
3540
|
+
if (databaseInUse) {
|
|
3541
|
+
throw new Error(`platform:auth: 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`);
|
|
3542
|
+
}
|
|
3543
|
+
return randomBytes(24).toString('base64');
|
|
3544
|
+
}
|
|
3545
|
+
/** `docs/deploy.md`: "ZITADEL_ADMIN_PASSWORD: 8+, upper, lower, digit, symbol". */
|
|
3546
|
+
function generateAdminPassword() {
|
|
3547
|
+
return `${randomBytes(18).toString('base64')}Aa1!`;
|
|
3548
|
+
}
|
|
3549
|
+
/**
|
|
3550
|
+
* #171: how the host signs a files management request with the key this step
|
|
3551
|
+
* delivers under {@link FILES_MANAGEMENT_KEY_REF} (files'
|
|
3552
|
+
* `signManagementRequest`'s `SigningIdentity`): `issuer` is
|
|
3553
|
+
* {@link FILES_MANAGEMENT_ISSUER}, `keyId` is {@link FILES_MANAGEMENT_KEY_ID},
|
|
3554
|
+
* `audience` is `https://<platform.files.hostname>` (the files host's
|
|
3555
|
+
* `APP_URL`, which this step sets), and `privateKey` is the delivered PKCS#8
|
|
3556
|
+
* PEM. Neither constant is a secret. The files host pins the public half
|
|
3557
|
+
* through `MANAGEMENT_ISSUER`/`MANAGEMENT_PUBLIC_KEYS`, which this step sets.
|
|
3558
|
+
*/
|
|
3559
|
+
export const FILES_MANAGEMENT_ISSUER = 'foundry';
|
|
3560
|
+
export const FILES_MANAGEMENT_KEY_ID = 'foundry-1';
|
|
3561
|
+
/** The ref the management private key (PKCS#8 PEM) is delivered under. */
|
|
3562
|
+
export const FILES_MANAGEMENT_KEY_REF = 'files:platform:managementKey';
|
|
3563
|
+
/** The ref the files host's introspection client secret is delivered under. */
|
|
3564
|
+
export const FILES_INTROSPECTION_SECRET_REF = 'zitadel:platform:files:introspection';
|
|
3565
|
+
/** https://github.com/wtfalch/files -- root Dockerfile with `migration` and `runner` targets. */
|
|
3566
|
+
const FILES_REPO = 'wtfalch/files';
|
|
3567
|
+
const PLATFORM_FILES_PROJECT = 'platform';
|
|
3568
|
+
const PLATFORM_FILES_DB_NAME = 'platform-files-db';
|
|
3569
|
+
/** The two Coolify apps files' `apps/host/README.md` ("Deploying on Coolify") names. */
|
|
3570
|
+
const FILES_MIGRATE_APP_NAME = 'files-migrate';
|
|
3571
|
+
const FILES_RUNNER_APP_NAME = 'files';
|
|
3572
|
+
/**
|
|
3573
|
+
* The runner's fixed `custom_internal_name`: the name another container on
|
|
3574
|
+
* the Coolify network reaches it by, and so the host part of
|
|
3575
|
+
* {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY}'s recorded address.
|
|
3576
|
+
*/
|
|
3577
|
+
const FILES_RUNNER_INTERNAL_NAME = 'platform-files';
|
|
3578
|
+
/** The runner's port (`Dockerfile`'s `runner` stage: `PORT=3000`, `EXPOSE 3000`). */
|
|
3579
|
+
const FILES_PORT = '3000';
|
|
3580
|
+
/** The Postgres database name; files' runtime role is `<database>_rt`. */
|
|
3581
|
+
const FILES_DB = 'files';
|
|
3582
|
+
const FILES_RUNTIME_ROLE = `${FILES_DB}_rt`;
|
|
3583
|
+
const FILES_ZITADEL_PROJECT_NAME = 'files';
|
|
3584
|
+
const FILES_INTROSPECTION_APP_NAME = 'files-introspection';
|
|
3585
|
+
const PLATFORM_FILES_PROJECT_SCOPE_KEY = 'coolify_project_platform_files';
|
|
3586
|
+
const PLATFORM_FILES_DB_SCOPE_KEY = 'coolify_platform_files_database';
|
|
3587
|
+
const PLATFORM_FILES_MIGRATE_SCOPE_KEY = 'coolify_platform_files_migrate_app';
|
|
3588
|
+
const PLATFORM_FILES_RUNNER_SCOPE_KEY = 'coolify_platform_files_runner_app';
|
|
3589
|
+
/**
|
|
3590
|
+
* #171: makes `coolify:app` keyed {@link PLATFORM_FILES_KEY} true -- the files
|
|
3591
|
+
* host from https://github.com/wtfalch/files, on Coolify's internal network
|
|
3592
|
+
* only, following its `apps/host/README.md` ("Deploying on Coolify"):
|
|
3593
|
+
*
|
|
3594
|
+
* 1. A Coolify Postgres (`files`), and two apps from the repo's root
|
|
3595
|
+
* Dockerfile: `files-migrate` (the `migration` target) and `files` (the
|
|
3596
|
+
* `runner` target, no domain, a fixed `custom_internal_name`).
|
|
3597
|
+
* 2. In the platform's own sign-in: the `files` ZITADEL project (its id is the
|
|
3598
|
+
* person-token audience, `AUTH_PERSON_TOKEN_AUDIENCE`) and an API app, the
|
|
3599
|
+
* host's introspection client (`AUTH_PERSON_TOKEN_INTROSPECTION_*`).
|
|
3600
|
+
* 3. Foundry's management key: an Ed25519 keypair minted here. The public
|
|
3601
|
+
* half is pinned on the runner (`MANAGEMENT_ISSUER`,
|
|
3602
|
+
* `MANAGEMENT_PUBLIC_KEYS`); the private half goes to the host as a held
|
|
3603
|
+
* key, which is what the org steps' `FilesOrganisationPort` signs with.
|
|
3604
|
+
* 4. Deploy `files-migrate`, wait for it to report healthy, stop it, then
|
|
3605
|
+
* deploy `files`.
|
|
3606
|
+
* 5. Record the runner's internal address and the ZITADEL project id.
|
|
3607
|
+
*
|
|
3608
|
+
* Both secrets are handed to {@link PlatformFilesDeps.deliverSecret} and
|
|
3609
|
+
* written to the runner's env, and nowhere else: never returned, recorded,
|
|
3610
|
+
* logged or put in a thrown error. Each is made only while its guard is
|
|
3611
|
+
* unrecorded, and follows `platform:auth`'s admin-token pattern: an
|
|
3612
|
+
* "undelivered" id is recorded and persisted the moment the secret is made,
|
|
3613
|
+
* and the guard only after delivery AND the env write. A run that fails in
|
|
3614
|
+
* between leaves an undelivered id with no guard; the planner reports that
|
|
3615
|
+
* and never mints again, and a person clears the recorded key (and the
|
|
3616
|
+
* ZITADEL client) to allow a new one.
|
|
3617
|
+
*
|
|
3618
|
+
* A re-run after a changed hostname or `gitRef` (recorded as applied scope
|
|
3619
|
+
* ids) updates `git_branch` and the runner env and redeploys, and mints
|
|
3620
|
+
* nothing.
|
|
3621
|
+
*
|
|
3622
|
+
* Known gaps, left for review:
|
|
3623
|
+
* - The README says to wait for `files-migrate: done` in the migrate app's
|
|
3624
|
+
* logs. `CoolifyPort` cannot read logs, so this waits for Coolify to report
|
|
3625
|
+
* the app `running:healthy` instead. The `migration` stage's HEALTHCHECK
|
|
3626
|
+
* (`test -f /tmp/files-migrated`) passes on the file touched right before
|
|
3627
|
+
* that line is logged, so the two mean the same thing. The migrate app is stopped
|
|
3628
|
+
* before its deploy, so the poll cannot pass on a container left up by a
|
|
3629
|
+
* failed run. Whether Coolify's status still shows the old container for a
|
|
3630
|
+
* moment right after the deploy is not checked against a live Coolify.
|
|
3631
|
+
* - The Dockerfile target is chosen by setting `dockerfile_target_build`
|
|
3632
|
+
* through `updateApplication`, since `createGithubAppApplication` has no
|
|
3633
|
+
* field for it. Not yet checked against a live Coolify.
|
|
3634
|
+
* - The runner is created without `domains`, like `platform:auth`'s apps.
|
|
3635
|
+
* Whether Coolify adds a generated domain anyway is not yet checked.
|
|
3636
|
+
* - Foundry sets only the env it owns: the two database URLs, `APP_URL`,
|
|
3637
|
+
* `AUTH_ISSUER`, `AUTH_PERSON_TOKEN_*`, `MANAGEMENT_*` and the two `KEYS_*`
|
|
3638
|
+
* values the host supplies through {@link PlatformFilesDeps.keys}, which
|
|
3639
|
+
* files-migrate needs to become healthy. The rest of files' `.env.example`
|
|
3640
|
+
* (`R2_*`, `KEYS_VERIFY_KEYS`, `JOBS_*`, `AUTHZ_BOOTSTRAP_OPERATOR`, ...)
|
|
3641
|
+
* is set by a person on the runner.
|
|
3642
|
+
* - The files project lives in the platform's `foundry` ZITADEL org. An
|
|
3643
|
+
* org's Agora requests it as an audience scope from its own ZITADEL org;
|
|
3644
|
+
* whether that needs a project grant per org is not decided here.
|
|
3645
|
+
* - The recorded address is reachable only from a container on the same
|
|
3646
|
+
* Coolify network, so the host must run there to use it for management
|
|
3647
|
+
* calls.
|
|
3648
|
+
*/
|
|
3649
|
+
async function provisionPlatformFiles(context, deps) {
|
|
3650
|
+
const { platform } = context;
|
|
3651
|
+
const gitRef = platform.files.gitRef;
|
|
3652
|
+
const recorded = context.recordedIds();
|
|
3653
|
+
const runtime = deps.runtime ?? {};
|
|
3654
|
+
const sleep = runtime.sleep ?? defaultSleep;
|
|
3655
|
+
const attempts = runtime.deliverAttempts ?? 3;
|
|
3656
|
+
const keys = filesKeys(deps);
|
|
3657
|
+
if (!keys) {
|
|
3658
|
+
throw new Error('platform:files needs the keys worker URL and credential in its files dependencies (KEYS_WORKER_URL, KEYS_CREDENTIAL); files-migrate never becomes healthy without them');
|
|
3659
|
+
}
|
|
3660
|
+
// Never plan-and-throw: the planner reports a lost secret and plans
|
|
3661
|
+
// nothing; this is the same rule for a caller that reaches the step anyway.
|
|
3662
|
+
for (const [undeliveredKey, guardKey] of [
|
|
3663
|
+
[PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY],
|
|
3664
|
+
[PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY],
|
|
3665
|
+
]) {
|
|
3666
|
+
const lost = recorded[undeliveredKey];
|
|
3667
|
+
if (lost && !recorded[guardKey]) {
|
|
3668
|
+
throw new Error(`platform:files: a secret (${lost}) was minted but never delivered; remove the recorded key ${undeliveredKey} to mint a new one`);
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
const record = async (key, id) => {
|
|
3672
|
+
context.recordScopeId(key, id);
|
|
3673
|
+
await context.persist?.();
|
|
3674
|
+
};
|
|
3675
|
+
let projectUuid = recorded[PLATFORM_FILES_PROJECT_SCOPE_KEY];
|
|
3676
|
+
if (!projectUuid) {
|
|
3677
|
+
const found = await deps.coolify.findProjectByName(PLATFORM_FILES_PROJECT);
|
|
3678
|
+
projectUuid = found
|
|
3679
|
+
? found.uuid
|
|
3680
|
+
: (await deps.coolify.createProject(PLATFORM_FILES_PROJECT)).uuid;
|
|
3681
|
+
await record(PLATFORM_FILES_PROJECT_SCOPE_KEY, projectUuid);
|
|
3682
|
+
}
|
|
3683
|
+
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
3684
|
+
// ---- database ----------------------------------------------------------
|
|
3685
|
+
let dbId = recorded[PLATFORM_FILES_DB_SCOPE_KEY];
|
|
3686
|
+
if (!dbId) {
|
|
3687
|
+
// Found by name first, so a crash between create and record never makes
|
|
3688
|
+
// a second one (`coolifyJobs` does the same).
|
|
3689
|
+
const found = (await deps.coolify.listDatabases()).find((db) => db.name === PLATFORM_FILES_DB_NAME && db.environment_id === environment.id);
|
|
3690
|
+
if (found) {
|
|
3691
|
+
dbId = found.uuid;
|
|
3692
|
+
}
|
|
3693
|
+
else {
|
|
3694
|
+
const server = await resolvePlatformServer(deps.coolify);
|
|
3695
|
+
const created = await deps.coolify.createPostgresDatabase({
|
|
3696
|
+
project_uuid: projectUuid,
|
|
3697
|
+
server_uuid: server.uuid,
|
|
3698
|
+
environment_name: environment.name,
|
|
3699
|
+
environment_uuid: environment.uuid,
|
|
3700
|
+
name: PLATFORM_FILES_DB_NAME,
|
|
3701
|
+
postgres_db: FILES_DB,
|
|
3702
|
+
postgres_user: FILES_DB,
|
|
3703
|
+
});
|
|
3704
|
+
dbId = created.uuid;
|
|
3705
|
+
}
|
|
3706
|
+
await record(PLATFORM_FILES_DB_SCOPE_KEY, dbId);
|
|
3707
|
+
}
|
|
3708
|
+
const db = await deps.coolify.getDatabase(dbId);
|
|
3709
|
+
if (!db.internal_db_url) {
|
|
3710
|
+
throw new Error('coolify:app for the platform (files): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
|
|
3711
|
+
}
|
|
3712
|
+
// ---- the two apps --------------------------------------------------------
|
|
3713
|
+
const createFilesApp = (name) => async () => {
|
|
3714
|
+
const server = await resolvePlatformServer(deps.coolify);
|
|
3715
|
+
const githubApp = await deps.coolify.requireGithubApp();
|
|
3716
|
+
return deps.coolify.createGithubAppApplication({
|
|
3717
|
+
project_uuid: projectUuid,
|
|
3718
|
+
server_uuid: server.uuid,
|
|
3719
|
+
environment_name: environment.name,
|
|
3720
|
+
environment_uuid: environment.uuid,
|
|
3721
|
+
github_app_uuid: githubApp.uuid,
|
|
3722
|
+
git_repository: FILES_REPO,
|
|
3723
|
+
git_branch: gitRef,
|
|
3724
|
+
build_pack: 'dockerfile',
|
|
3725
|
+
name,
|
|
3726
|
+
});
|
|
3727
|
+
};
|
|
3728
|
+
const migrateApp = await findOrCreatePlatformApp(deps.coolify, FILES_MIGRATE_APP_NAME, environment, createFilesApp(FILES_MIGRATE_APP_NAME));
|
|
3729
|
+
await record(PLATFORM_FILES_MIGRATE_SCOPE_KEY, migrateApp.appId);
|
|
3730
|
+
// Set on every run, like `platform:auth`'s `custom_internal_name`: a throw
|
|
3731
|
+
// right after a create must not leave the app on the wrong target.
|
|
3732
|
+
// `git_branch` too, so a changed `gitRef` reaches an app made earlier.
|
|
3733
|
+
await deps.coolify.updateApplication(migrateApp.appId, {
|
|
3734
|
+
dockerfile_target_build: 'migration',
|
|
3735
|
+
git_branch: gitRef,
|
|
3736
|
+
});
|
|
3737
|
+
const runnerApp = await findOrCreatePlatformApp(deps.coolify, FILES_RUNNER_APP_NAME, environment, createFilesApp(FILES_RUNNER_APP_NAME));
|
|
3738
|
+
await record(PLATFORM_FILES_RUNNER_SCOPE_KEY, runnerApp.appId);
|
|
3739
|
+
await deps.coolify.updateApplication(runnerApp.appId, {
|
|
3740
|
+
dockerfile_target_build: 'runner',
|
|
3741
|
+
custom_internal_name: FILES_RUNNER_INTERNAL_NAME,
|
|
3742
|
+
git_branch: gitRef,
|
|
3743
|
+
});
|
|
3744
|
+
// ---- database env ----------------------------------------------------------
|
|
3745
|
+
// The migrate app sets `files_rt`'s password from DATABASE_URL on every
|
|
3746
|
+
// boot (files' `scripts/db-runtime-role.sh`), and the runner connects with
|
|
3747
|
+
// the same URL, so both apps carry one value, minted once and never
|
|
3748
|
+
// rotated: an existing value on either app is reused.
|
|
3749
|
+
const runtimeUrl = await ensureFilesRuntimeUrl(deps.coolify, [migrateApp.appId, runnerApp.appId], db.internal_db_url);
|
|
3750
|
+
// The keys worker the migrate app needs to become healthy (files' README:
|
|
3751
|
+
// `DATABASE_URL_OWNER`, `DATABASE_URL`, `KEYS_WORKER_URL`, `KEYS_CREDENTIAL`).
|
|
3752
|
+
const keysEnvs = [
|
|
3753
|
+
{ key: 'KEYS_WORKER_URL', value: keys.workerUrl },
|
|
3754
|
+
{ key: 'KEYS_CREDENTIAL', value: keys.credential },
|
|
3755
|
+
];
|
|
3756
|
+
await deps.coolify.bulkUpsertEnvs(migrateApp.appId, [
|
|
3757
|
+
{ key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
|
|
3758
|
+
{ key: 'DATABASE_URL', value: runtimeUrl },
|
|
3759
|
+
...keysEnvs,
|
|
3760
|
+
]);
|
|
3761
|
+
// ---- the files ZITADEL project, in the platform's sign-in ----------------
|
|
3762
|
+
const issuer = `https://${platform.auth.hostname}`;
|
|
3763
|
+
const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
|
|
3764
|
+
const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
|
|
3765
|
+
const { project } = await zitadel.findOrCreateProject(org.id, FILES_ZITADEL_PROJECT_NAME);
|
|
3766
|
+
await record(PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, project.id);
|
|
3767
|
+
const runnerEnvs = [
|
|
3768
|
+
{ key: 'DATABASE_URL', value: runtimeUrl },
|
|
3769
|
+
// The management verifier's audience, which must be an HTTPS origin. The
|
|
3770
|
+
// host has no public address, so this is a name, not a route.
|
|
3771
|
+
{ key: 'APP_URL', value: `https://${platform.files.hostname}` },
|
|
3772
|
+
{ key: 'AUTH_ISSUER', value: issuer },
|
|
3773
|
+
{ key: 'AUTH_PERSON_TOKEN_AUDIENCE', value: project.id },
|
|
3774
|
+
...keysEnvs,
|
|
3775
|
+
];
|
|
3776
|
+
// Base env first, so a secret's own write below cannot be the only one
|
|
3777
|
+
// that lands, and a re-run with a changed hostname updates `APP_URL`.
|
|
3778
|
+
await deps.coolify.bulkUpsertEnvs(runnerApp.appId, runnerEnvs);
|
|
3779
|
+
// ---- the introspection client, once --------------------------------------
|
|
3780
|
+
if (!recorded[PLATFORM_FILES_INTROSPECTION_SCOPE_KEY]) {
|
|
3781
|
+
// An app by this name here has no recorded secret (no undelivered id, or
|
|
3782
|
+
// the throw above would have fired): a crash between the create and the
|
|
3783
|
+
// record. Nothing was delivered, so it is deleted and made again.
|
|
3784
|
+
const stale = (await zitadel.listApps(org.id, project.id)).filter((app) => app.name === FILES_INTROSPECTION_APP_NAME && app.state !== 'APP_STATE_REMOVED');
|
|
3785
|
+
for (const app of stale) {
|
|
3786
|
+
try {
|
|
3787
|
+
await zitadel.deleteApp(org.id, project.id, app.id);
|
|
3788
|
+
}
|
|
3789
|
+
catch (error) {
|
|
3790
|
+
if (!isNotFoundError(error))
|
|
3791
|
+
throw error;
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3794
|
+
const client = await zitadel.createApiApp(org.id, project.id, {
|
|
3795
|
+
name: FILES_INTROSPECTION_APP_NAME,
|
|
3796
|
+
});
|
|
3797
|
+
// Recorded and persisted before the secret leaves this function: a failed
|
|
3798
|
+
// delivery or env write is then reported by the planner, not remade.
|
|
3799
|
+
await record(PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, client.appId);
|
|
3800
|
+
await deliverWithRetry(deps.deliverSecret, FILES_INTROSPECTION_SECRET_REF, client.clientSecret, attempts, sleep, `the introspection client secret (ZITADEL app ${client.appId})`);
|
|
3801
|
+
await deps.coolify.bulkUpsertEnvs(runnerApp.appId, [
|
|
3802
|
+
{ key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_ID', value: client.clientId },
|
|
3803
|
+
{ key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_SECRET', value: client.clientSecret },
|
|
3804
|
+
]);
|
|
3805
|
+
await record(PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, client.appId);
|
|
3806
|
+
}
|
|
3807
|
+
// ---- foundry's management key, once --------------------------------------
|
|
3808
|
+
if (!recorded[PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY]) {
|
|
3809
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
|
|
3810
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
3811
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
3812
|
+
});
|
|
3813
|
+
await record(PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, FILES_MANAGEMENT_KEY_ID);
|
|
3814
|
+
await deliverWithRetry(deps.deliverSecret, FILES_MANAGEMENT_KEY_REF, privateKey, attempts, sleep, 'the files management key');
|
|
3815
|
+
await deps.coolify.bulkUpsertEnvs(runnerApp.appId, [
|
|
3816
|
+
{ key: 'MANAGEMENT_ISSUER', value: FILES_MANAGEMENT_ISSUER },
|
|
3817
|
+
{
|
|
3818
|
+
key: 'MANAGEMENT_PUBLIC_KEYS',
|
|
3819
|
+
value: JSON.stringify({ [FILES_MANAGEMENT_KEY_ID]: publicKey }),
|
|
3820
|
+
},
|
|
3821
|
+
]);
|
|
3822
|
+
await record(PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, FILES_MANAGEMENT_KEY_ID);
|
|
3823
|
+
}
|
|
3824
|
+
// ---- deploy: stop migrate, deploy it, wait, stop it, then the runner -----
|
|
3825
|
+
// A failed run can leave `files-migrate` up and healthy from the last
|
|
3826
|
+
// deploy. Stopped first, so the wait below cannot pass on the old
|
|
3827
|
+
// container before the new migration has run.
|
|
3828
|
+
await stopIfRunning(deps.coolify, migrateApp.appId);
|
|
3829
|
+
await deps.coolify.deploy(migrateApp.appId);
|
|
3830
|
+
await waitForMigration(deps.coolify, migrateApp.appId, runtime, sleep);
|
|
3831
|
+
await deps.coolify.raw('POST', `/applications/${migrateApp.appId}/stop`);
|
|
3832
|
+
await deps.coolify.deploy(runnerApp.appId);
|
|
3833
|
+
await record(PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, `http://${FILES_RUNNER_INTERNAL_NAME}:${FILES_PORT}`);
|
|
3834
|
+
// Last: a run that failed anywhere above is planned again.
|
|
3835
|
+
context.recordScopeId(PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, platform.files.hostname);
|
|
3836
|
+
await record(PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
3837
|
+
return { resourceId: runnerApp.appId };
|
|
3838
|
+
}
|
|
3839
|
+
function filesKeys(deps) {
|
|
3840
|
+
const keys = deps?.keys;
|
|
3841
|
+
return keys?.workerUrl && keys.credential ? keys : null;
|
|
3842
|
+
}
|
|
3843
|
+
/** Stops the app when Coolify reports it running; a stop on an app that is not running can fail. */
|
|
3844
|
+
async function stopIfRunning(coolify, appId) {
|
|
3845
|
+
const { status } = await coolify.getApplication(appId);
|
|
3846
|
+
if (status?.startsWith('running'))
|
|
3847
|
+
await coolify.raw('POST', `/applications/${appId}/stop`);
|
|
3848
|
+
}
|
|
3849
|
+
/**
|
|
3850
|
+
* Hands `secret` to the host, retrying. The thrown error names `what` only,
|
|
3851
|
+
* never the secret or the cause, which could echo it.
|
|
3852
|
+
*/
|
|
3853
|
+
async function deliverWithRetry(deliverSecret, ref, secret, attempts, sleep, what) {
|
|
3854
|
+
for (let attempt = 1;; attempt += 1) {
|
|
3855
|
+
try {
|
|
3856
|
+
await deliverSecret(ref, secret);
|
|
3857
|
+
return;
|
|
3858
|
+
}
|
|
3859
|
+
catch {
|
|
3860
|
+
if (attempt >= attempts) {
|
|
3861
|
+
throw new Error(`platform:files: ${what} could not be delivered after ${attempts} tries; the next plan reports it as lost and mints no new one`);
|
|
3862
|
+
}
|
|
3863
|
+
await sleep(1000 * attempt);
|
|
3864
|
+
}
|
|
3865
|
+
}
|
|
3866
|
+
}
|
|
3867
|
+
/**
|
|
3868
|
+
* The runtime `DATABASE_URL` both files apps share: the first one already set
|
|
3869
|
+
* on either app, else a new `files_rt` credential on the database's host,
|
|
3870
|
+
* built the way `executor.ts`'s `ensureCmsRuntimeUrl` builds `cms_rt`'s. A
|
|
3871
|
+
* parse failure throws a message with no part of the URL in it, since the
|
|
3872
|
+
* URL carries the superuser's password.
|
|
3873
|
+
*/
|
|
3874
|
+
async function ensureFilesRuntimeUrl(coolify, appIds, dbInternalUrl) {
|
|
3875
|
+
for (const appId of appIds) {
|
|
3876
|
+
const existing = (await coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL' && !e.is_preview && e.value);
|
|
3877
|
+
if (existing)
|
|
3878
|
+
return existing.value;
|
|
3879
|
+
}
|
|
3880
|
+
let host;
|
|
3881
|
+
let pathname;
|
|
3882
|
+
let search;
|
|
3883
|
+
try {
|
|
3884
|
+
({ host, pathname, search } = new URL(dbInternalUrl));
|
|
3885
|
+
}
|
|
3886
|
+
catch {
|
|
3887
|
+
throw new Error("coolify:app for the platform (files): the database's internal_db_url could not be parsed to build the runtime DATABASE_URL");
|
|
3888
|
+
}
|
|
3889
|
+
const password = randomBytes(24).toString('base64url');
|
|
3890
|
+
return `postgres://${FILES_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
|
|
3891
|
+
}
|
|
3892
|
+
/**
|
|
3893
|
+
* Polls the migrate app until Coolify reports it `running:healthy` after
|
|
3894
|
+
* first reporting anything else -- a healthy status before that is the old
|
|
3895
|
+
* container, since the stop and deploy calls can return before Coolify's
|
|
3896
|
+
* status catches up. The
|
|
3897
|
+
* `migration` stage's HEALTHCHECK passes only once the migration has
|
|
3898
|
+
* succeeded (see {@link provisionPlatformFiles}'s known gaps for why this
|
|
3899
|
+
* stands in for the log line). Throws once `migrateTimeoutMs` has passed; the
|
|
3900
|
+
* runner is then never deployed against an unmigrated database.
|
|
3901
|
+
*/
|
|
3902
|
+
async function waitForMigration(coolify, appId, runtime, sleep) {
|
|
3903
|
+
const now = runtime.now ?? Date.now;
|
|
3904
|
+
const timeout = runtime.migrateTimeoutMs ?? 900_000;
|
|
3905
|
+
const interval = runtime.migrateIntervalMs ?? 10_000;
|
|
3906
|
+
const deadline = now() + timeout;
|
|
3907
|
+
let left = false;
|
|
3908
|
+
for (;;) {
|
|
3909
|
+
const { status } = await coolify.getApplication(appId);
|
|
3910
|
+
if (status === 'running:healthy' && left)
|
|
3911
|
+
return;
|
|
3912
|
+
if (status !== 'running:healthy')
|
|
3913
|
+
left = true;
|
|
3914
|
+
if (now() >= deadline) {
|
|
3915
|
+
throw new Error(`platform:files: files-migrate (${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`);
|
|
3916
|
+
}
|
|
3917
|
+
await sleep(interval);
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
export const INTEGRATIONS_KEYS_ENV_NAMES = [
|
|
3921
|
+
'KEYS_WORKER_URL',
|
|
3922
|
+
'KEYS_CREDENTIAL',
|
|
3923
|
+
'KEYS_VERIFY_KEYS',
|
|
3924
|
+
'KEYS_AUDIT_TOKEN',
|
|
3925
|
+
];
|
|
3926
|
+
/** https://github.com/wtfalch/integrations -- one root Dockerfile, three targets. */
|
|
3927
|
+
const INTEGRATIONS_REPO = 'wtfalch/integrations';
|
|
3928
|
+
/**
|
|
3929
|
+
* The three Coolify apps `apps/host/README.md`'s "Deploy: migrate, web,
|
|
3930
|
+
* worker" names, each with the root Dockerfile target it builds.
|
|
3931
|
+
*/
|
|
3932
|
+
const INTEGRATIONS_MIGRATE_APP = { name: 'integrations-migrate', target: 'migration' };
|
|
3933
|
+
const INTEGRATIONS_RUNNER_APP = { name: 'integrations', target: 'runner' };
|
|
3934
|
+
const INTEGRATIONS_WORKER_APP = { name: 'integrations-worker', target: 'worker' };
|
|
3935
|
+
/** The runner's `EXPOSE 3000`; readiness is its own `HEALTHCHECK` on `/api/health`. */
|
|
3936
|
+
const INTEGRATIONS_RUNNER_PORT = '3000';
|
|
3937
|
+
const INTEGRATIONS_DB_NAME = 'integrations-db';
|
|
3938
|
+
/**
|
|
3939
|
+
* The Postgres database and owner name. The host's runtime role is derived
|
|
3940
|
+
* from the database, never configured: `drizzle/0001_runtime_role.sql`
|
|
3941
|
+
* creates `current_database() || '_rt'`, so the runtime URL below must name
|
|
3942
|
+
* exactly {@link INTEGRATIONS_RUNTIME_ROLE}.
|
|
3943
|
+
*/
|
|
3944
|
+
const INTEGRATIONS_POSTGRES_DB = 'integrations';
|
|
3945
|
+
const INTEGRATIONS_RUNTIME_ROLE = `${INTEGRATIONS_POSTGRES_DB}_rt`;
|
|
3946
|
+
/**
|
|
3947
|
+
* `.env.example`'s own default. Only ever set when absent: every stored
|
|
3948
|
+
* registration client secret is bound to this id, so foundry never
|
|
3949
|
+
* overwrites a value a person changed.
|
|
3950
|
+
*/
|
|
3951
|
+
const INTEGRATIONS_OPERATOR_TENANT_ID = 'operator';
|
|
3952
|
+
/** `.env.example`'s own default for one worker pass; only ever set when absent. */
|
|
3953
|
+
const INTEGRATIONS_WORKER_BATCH = '50';
|
|
3954
|
+
const INTEGRATIONS_DATABASE_SCOPE_KEY = 'coolify_integrations_database';
|
|
3955
|
+
const INTEGRATIONS_MIGRATE_APP_SCOPE_KEY = 'coolify_integrations_migrate_app';
|
|
3956
|
+
const INTEGRATIONS_RUNNER_APP_SCOPE_KEY = 'coolify_integrations_app';
|
|
3957
|
+
const INTEGRATIONS_WORKER_APP_SCOPE_KEY = 'coolify_integrations_worker_app';
|
|
3958
|
+
/** Set (to the gitRef it migrated) once that ref's migration succeeded and the app was stopped. */
|
|
3959
|
+
const INTEGRATIONS_MIGRATED_SCOPE_KEY = 'integrations_migrated';
|
|
3960
|
+
/** The ref the management private key (PKCS#8 PEM) is delivered under. */
|
|
3961
|
+
export const INTEGRATIONS_MANAGEMENT_KEY_REF = 'integrations:management:privateKey';
|
|
3962
|
+
/** `MANAGEMENT_ISSUER` on the integrations host: whoever signs with the delivered key signs as this issuer. */
|
|
3963
|
+
export const INTEGRATIONS_MANAGEMENT_ISSUER = 'foundry';
|
|
3964
|
+
/**
|
|
3965
|
+
* The key id the public half is pinned under in `MANAGEMENT_PUBLIC_KEYS`
|
|
3966
|
+
* (the host's keyring allows `^[A-Za-z0-9_-]{1,100}$`). A signer names it in
|
|
3967
|
+
* its signed request; fixed, since there is one key until rotation is built.
|
|
3968
|
+
*/
|
|
3969
|
+
export const INTEGRATIONS_MANAGEMENT_KEY_ID = 'foundry-1';
|
|
3970
|
+
/**
|
|
3971
|
+
* #174: makes `coolify:app` keyed {@link PLATFORM_INTEGRATIONS_KEY} true --
|
|
3972
|
+
* the integrations host (https://github.com/wtfalch/integrations) as its own
|
|
3973
|
+
* `apps/host/README.md` ("Deploy: migrate, web, worker") describes it, in the
|
|
3974
|
+
* shared `platform` Coolify project `coolify:jobs` uses:
|
|
3975
|
+
*
|
|
3976
|
+
* 1. A Postgres database (`integrations-db`), whose superuser URL is
|
|
3977
|
+
* `DATABASE_URL_OWNER` and whose `integrations_rt` runtime role gets a
|
|
3978
|
+
* password minted here once, as `DATABASE_URL` -- the same owner/runtime
|
|
3979
|
+
* split `executor.ts`'s `ensureCmsRuntimeUrl` builds for the cms app.
|
|
3980
|
+
* 2. Three apps from the one repo, each on its own Dockerfile target (set by
|
|
3981
|
+
* `updateApplication`, since Coolify's create endpoint refuses
|
|
3982
|
+
* `dockerfile_target_build`): `integrations-migrate` (`migration`),
|
|
3983
|
+
* `integrations` (`runner`, at `hostname`) and `integrations-worker`
|
|
3984
|
+
* (`worker`). The host's own `@wtfalch/keys` settings
|
|
3985
|
+
* ({@link PlatformIntegrationsDeps.keysEnv}) are written to them.
|
|
3986
|
+
* 3. The migrate app first, once per `gitRef`: stop it if running, deploy it,
|
|
3987
|
+
* wait until it reports healthy, stop it, and record the ref it migrated.
|
|
3988
|
+
* Only then are the runner and worker deployed.
|
|
3989
|
+
* 4. The management key, once: an Ed25519 pair minted here, its id recorded
|
|
3990
|
+
* before delivery, its private half handed to
|
|
3991
|
+
* {@link PlatformIntegrationsDeps.deliverSecret} and its public half pinned
|
|
3992
|
+
* on the runner as `MANAGEMENT_PUBLIC_KEYS`, with `MANAGEMENT_ISSUER`. The
|
|
3993
|
+
* guard is recorded after both.
|
|
3994
|
+
* 5. The applied `hostname` and `gitRef` are recorded last. A later change
|
|
3995
|
+
* plans the step again (`planIntegrations`), which patches `git_branch`
|
|
3996
|
+
* and the runner's `domains`, reruns the migration for a new ref, and
|
|
3997
|
+
* redeploys. It never mints the key again.
|
|
3998
|
+
*
|
|
3999
|
+
* Every part is safe to run again after a throw partway: each id is recorded
|
|
4000
|
+
* and persisted as soon as its resource exists, and what to deploy is read
|
|
4001
|
+
* from the live apps. Secrets -- the database URLs, the `KEYS_*` values and
|
|
4002
|
+
* the private key -- go to `bulkUpsertEnvs`/`deliverSecret` and nowhere else:
|
|
4003
|
+
* never returned, recorded, logged or put in an error.
|
|
4004
|
+
*
|
|
4005
|
+
* Known gaps, left for review:
|
|
4006
|
+
* - "Wait for `integrations-migrate: done` in its logs" is approximated by
|
|
4007
|
+
* polling the app's Coolify `status` for `running:healthy`: `CoolifyPort`
|
|
4008
|
+
* has no way to read container logs. The `migration` target's own
|
|
4009
|
+
* `HEALTHCHECK` passes only once the file it touches right before printing
|
|
4010
|
+
* that line exists, so healthy means the same thing. `exited` never counts
|
|
4011
|
+
* as done.
|
|
4012
|
+
* - The worker is created and deployed when created or changed, but nothing
|
|
4013
|
+
* schedules it. Its image runs one queue pass and exits, so Coolify's
|
|
4014
|
+
* scheduled tasks (which run a command in a *running* container) have no
|
|
4015
|
+
* container to run in, and `wtfalch/jobs` delivers a schedule as an HTTP
|
|
4016
|
+
* POST to a receiver route the integrations host does not have. Choosing
|
|
4017
|
+
* between such a route and a long-running worker image is a decision for
|
|
4018
|
+
* the integrations repo. Until then queued messages wait for a manual pass.
|
|
4019
|
+
* - Changing `gitRef` to a ref that has new commits under the same name is
|
|
4020
|
+
* not seen: only a different ref name re-plans the step.
|
|
4021
|
+
* - Like `coolify:jobs`, the runner is reached through its Coolify `domains`
|
|
4022
|
+
* binding; no Cloudflare tunnel or DNS record is created for `hostname`.
|
|
4023
|
+
*/
|
|
4024
|
+
async function provisionPlatformIntegrations(action, context, deps) {
|
|
4025
|
+
const { coolify } = deps;
|
|
4026
|
+
const hostname = action.desired.hostname;
|
|
4027
|
+
const gitRef = action.desired.gitRef;
|
|
4028
|
+
const runtime = deps.runtime ?? {};
|
|
4029
|
+
const sleep = runtime.sleep ?? defaultSleep;
|
|
4030
|
+
const recorded = context.recordedIds();
|
|
4031
|
+
const record = async (key, id) => {
|
|
4032
|
+
context.recordScopeId(key, id);
|
|
4033
|
+
await context.persist?.();
|
|
4034
|
+
};
|
|
4035
|
+
// ---- project and environment: the one shared `platform` project ---------
|
|
4036
|
+
let projectUuid = recorded[JOBS_PROJECT_SCOPE_KEY];
|
|
4037
|
+
if (!projectUuid) {
|
|
4038
|
+
const found = await coolify.findProjectByName(PLATFORM_COOLIFY_PROJECT_NAME);
|
|
4039
|
+
projectUuid = found
|
|
4040
|
+
? found.uuid
|
|
4041
|
+
: (await coolify.createProject(PLATFORM_COOLIFY_PROJECT_NAME)).uuid;
|
|
4042
|
+
await record(JOBS_PROJECT_SCOPE_KEY, projectUuid);
|
|
4043
|
+
}
|
|
4044
|
+
const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
4045
|
+
// ---- database -------------------------------------------------------------
|
|
4046
|
+
let dbId = recorded[INTEGRATIONS_DATABASE_SCOPE_KEY];
|
|
4047
|
+
if (!dbId) {
|
|
4048
|
+
const found = (await coolify.listDatabases()).find((db) => db.name === INTEGRATIONS_DB_NAME && db.environment_id === environment.id);
|
|
4049
|
+
if (found) {
|
|
4050
|
+
dbId = found.uuid;
|
|
4051
|
+
}
|
|
4052
|
+
else {
|
|
4053
|
+
const server = await resolvePlatformServer(coolify);
|
|
4054
|
+
const created = await coolify.createPostgresDatabase({
|
|
4055
|
+
project_uuid: projectUuid,
|
|
4056
|
+
server_uuid: server.uuid,
|
|
4057
|
+
environment_name: environment.name,
|
|
4058
|
+
environment_uuid: environment.uuid,
|
|
4059
|
+
name: INTEGRATIONS_DB_NAME,
|
|
4060
|
+
postgres_db: INTEGRATIONS_POSTGRES_DB,
|
|
4061
|
+
postgres_user: INTEGRATIONS_POSTGRES_DB,
|
|
4062
|
+
});
|
|
4063
|
+
dbId = created.uuid;
|
|
4064
|
+
}
|
|
4065
|
+
await record(INTEGRATIONS_DATABASE_SCOPE_KEY, dbId);
|
|
4066
|
+
}
|
|
4067
|
+
const db = await coolify.getDatabase(dbId);
|
|
4068
|
+
if (!db.internal_db_url) {
|
|
4069
|
+
throw new Error(`platform:integrations: database ${dbId} has no internal_db_url yet; not deploying the integrations host without a database`);
|
|
4070
|
+
}
|
|
4071
|
+
const ownerUrl = db.internal_db_url;
|
|
4072
|
+
// ---- the three apps --------------------------------------------------------
|
|
4073
|
+
const createApp = (name, domains) => async () => {
|
|
4074
|
+
const server = await resolvePlatformServer(coolify);
|
|
4075
|
+
const githubApp = await coolify.requireGithubApp();
|
|
4076
|
+
return coolify.createGithubAppApplication({
|
|
4077
|
+
project_uuid: projectUuid,
|
|
4078
|
+
server_uuid: server.uuid,
|
|
4079
|
+
environment_name: environment.name,
|
|
4080
|
+
environment_uuid: environment.uuid,
|
|
4081
|
+
github_app_uuid: githubApp.uuid,
|
|
4082
|
+
git_repository: INTEGRATIONS_REPO,
|
|
4083
|
+
git_branch: gitRef,
|
|
4084
|
+
build_pack: 'dockerfile',
|
|
4085
|
+
name,
|
|
4086
|
+
...(domains ? { domains } : {}),
|
|
4087
|
+
});
|
|
4088
|
+
};
|
|
4089
|
+
const migrateApp = await findOrCreatePlatformApp(coolify, INTEGRATIONS_MIGRATE_APP.name, environment, createApp(INTEGRATIONS_MIGRATE_APP.name));
|
|
4090
|
+
await record(INTEGRATIONS_MIGRATE_APP_SCOPE_KEY, migrateApp.appId);
|
|
4091
|
+
const runnerApp = await findOrCreatePlatformApp(coolify, INTEGRATIONS_RUNNER_APP.name, environment, createApp(INTEGRATIONS_RUNNER_APP.name, hostname));
|
|
4092
|
+
await record(INTEGRATIONS_RUNNER_APP_SCOPE_KEY, runnerApp.appId);
|
|
4093
|
+
const workerApp = await findOrCreatePlatformApp(coolify, INTEGRATIONS_WORKER_APP.name, environment, createApp(INTEGRATIONS_WORKER_APP.name));
|
|
4094
|
+
await record(INTEGRATIONS_WORKER_APP_SCOPE_KEY, workerApp.appId);
|
|
4095
|
+
// Set on every run, not only after a create: a throw right after a create
|
|
4096
|
+
// must not leave an app building the Dockerfile's last stage for good. The
|
|
4097
|
+
// branch and the runner's domain are set here too, so a changed `gitRef` or
|
|
4098
|
+
// `hostname` reaches an app that already exists.
|
|
4099
|
+
const configChanged = recorded[PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname ||
|
|
4100
|
+
recorded[PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY] !== gitRef;
|
|
4101
|
+
await coolify.updateApplication(migrateApp.appId, {
|
|
4102
|
+
dockerfile_target_build: INTEGRATIONS_MIGRATE_APP.target,
|
|
4103
|
+
git_branch: gitRef,
|
|
4104
|
+
});
|
|
4105
|
+
await coolify.updateApplication(runnerApp.appId, {
|
|
4106
|
+
dockerfile_target_build: INTEGRATIONS_RUNNER_APP.target,
|
|
4107
|
+
ports_exposes: INTEGRATIONS_RUNNER_PORT,
|
|
4108
|
+
git_branch: gitRef,
|
|
4109
|
+
domains: hostname,
|
|
4110
|
+
});
|
|
4111
|
+
await coolify.updateApplication(workerApp.appId, {
|
|
4112
|
+
dockerfile_target_build: INTEGRATIONS_WORKER_APP.target,
|
|
4113
|
+
git_branch: gitRef,
|
|
4114
|
+
});
|
|
4115
|
+
// ---- env ---------------------------------------------------------------------
|
|
4116
|
+
// One runtime URL for all three apps: the migrate job sets the role's
|
|
4117
|
+
// password from it, and the runner and worker connect with it.
|
|
4118
|
+
const runtimeUrl = await ensureIntegrationsRuntimeUrl(coolify, migrateApp.appId, ownerUrl);
|
|
4119
|
+
const keys = INTEGRATIONS_KEYS_ENV_NAMES.map((key) => ({
|
|
4120
|
+
key,
|
|
4121
|
+
value: deps.keysEnv[key] ?? '',
|
|
4122
|
+
}));
|
|
4123
|
+
if (keys.some((k) => !k.value)) {
|
|
4124
|
+
throw new Error('platform:integrations: the host supplies no value for the @wtfalch/keys settings; not deploying the integrations host');
|
|
4125
|
+
}
|
|
4126
|
+
const sharedEnvs = [
|
|
4127
|
+
{ key: 'DATABASE_URL', value: runtimeUrl },
|
|
4128
|
+
{ key: 'APP_URL', value: `https://${hostname}` },
|
|
4129
|
+
...keys,
|
|
4130
|
+
];
|
|
4131
|
+
const runnerEnvs = [
|
|
4132
|
+
...sharedEnvs,
|
|
4133
|
+
...(await envsIfAbsent(coolify, runnerApp.appId, [
|
|
4134
|
+
{ key: 'INTEGRATIONS_OPERATOR_TENANT_ID', value: INTEGRATIONS_OPERATOR_TENANT_ID },
|
|
4135
|
+
])),
|
|
4136
|
+
];
|
|
4137
|
+
const workerEnvs = [
|
|
4138
|
+
...sharedEnvs,
|
|
4139
|
+
...(await envsIfAbsent(coolify, workerApp.appId, [
|
|
4140
|
+
{ key: 'INTEGRATIONS_OPERATOR_TENANT_ID', value: INTEGRATIONS_OPERATOR_TENANT_ID },
|
|
4141
|
+
{ key: 'INTEGRATIONS_WORKER_BATCH', value: INTEGRATIONS_WORKER_BATCH },
|
|
4142
|
+
])),
|
|
4143
|
+
];
|
|
4144
|
+
const migrateEnvs = [
|
|
4145
|
+
{ key: 'DATABASE_URL_OWNER', value: ownerUrl },
|
|
4146
|
+
{ key: 'DATABASE_URL', value: runtimeUrl },
|
|
4147
|
+
...keys.filter((k) => k.key === 'KEYS_WORKER_URL' || k.key === 'KEYS_CREDENTIAL'),
|
|
4148
|
+
];
|
|
4149
|
+
const runnerEnvsChanged = await envsDiffer(coolify, runnerApp.appId, runnerEnvs);
|
|
4150
|
+
const workerEnvsChanged = await envsDiffer(coolify, workerApp.appId, workerEnvs);
|
|
4151
|
+
await coolify.bulkUpsertEnvs(migrateApp.appId, migrateEnvs);
|
|
4152
|
+
await coolify.bulkUpsertEnvs(runnerApp.appId, runnerEnvs);
|
|
4153
|
+
await coolify.bulkUpsertEnvs(workerApp.appId, workerEnvs);
|
|
4154
|
+
// ---- migrate first, once per gitRef ---------------------------------------------
|
|
4155
|
+
if (context.recordedIds()[INTEGRATIONS_MIGRATED_SCOPE_KEY] !== gitRef) {
|
|
4156
|
+
// A running migrate app is stopped before the deploy is queued, so the old
|
|
4157
|
+
// container's healthy status is never read as the new deployment's.
|
|
4158
|
+
if (migrateApp.running)
|
|
4159
|
+
await coolify.raw('POST', `/applications/${migrateApp.appId}/stop`);
|
|
4160
|
+
await coolify.deploy(migrateApp.appId);
|
|
4161
|
+
await waitForIntegrationsMigration(coolify, migrateApp.appId, runtime, sleep);
|
|
4162
|
+
// It idles after migrating rather than exiting (the Dockerfile's own
|
|
4163
|
+
// comment), so it is stopped here; a person starts it again for
|
|
4164
|
+
// maintenance commands.
|
|
4165
|
+
await coolify.raw('POST', `/applications/${migrateApp.appId}/stop`);
|
|
4166
|
+
await record(INTEGRATIONS_MIGRATED_SCOPE_KEY, gitRef);
|
|
4167
|
+
}
|
|
4168
|
+
// ---- the management key, once --------------------------------------------------
|
|
4169
|
+
// The pair's id is recorded and persisted before the private half is
|
|
4170
|
+
// delivered. The guard is recorded only after delivery and the write of the
|
|
4171
|
+
// public half. A run that dies in between leaves the id without the guard:
|
|
4172
|
+
// the planner then reports the key and mints nothing, so a second pair can
|
|
4173
|
+
// never replace one the host may already hold.
|
|
4174
|
+
let keyJustSet = false;
|
|
4175
|
+
if (!context.recordedIds()[PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY]) {
|
|
4176
|
+
if (context.recordedIds()[PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY]) {
|
|
4177
|
+
throw new Error(`platform:integrations: management key ${INTEGRATIONS_MANAGEMENT_KEY_ID} was minted but not confirmed delivered and pinned; not minting another. Check the held key and the runner's MANAGEMENT_PUBLIC_KEYS, then remove the recorded key ${PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
4178
|
+
}
|
|
4179
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
|
|
4180
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
4181
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
4182
|
+
});
|
|
4183
|
+
await record(PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, INTEGRATIONS_MANAGEMENT_KEY_ID);
|
|
4184
|
+
const attempts = runtime.deliverAttempts ?? 3;
|
|
4185
|
+
for (let attempt = 1;; attempt += 1) {
|
|
4186
|
+
try {
|
|
4187
|
+
await deps.deliverSecret(INTEGRATIONS_MANAGEMENT_KEY_REF, privateKey);
|
|
4188
|
+
break;
|
|
4189
|
+
}
|
|
4190
|
+
catch {
|
|
4191
|
+
// Retried; the error is dropped in case it echoes the key.
|
|
4192
|
+
if (attempt >= attempts) {
|
|
4193
|
+
throw new Error(`platform:integrations: management key ${INTEGRATIONS_MANAGEMENT_KEY_ID} was minted but could not be delivered after ${attempts} attempts`);
|
|
4194
|
+
}
|
|
4195
|
+
await sleep(1000 * attempt);
|
|
4196
|
+
}
|
|
4197
|
+
}
|
|
4198
|
+
await coolify.bulkUpsertEnvs(runnerApp.appId, [
|
|
4199
|
+
{ key: 'MANAGEMENT_ISSUER', value: INTEGRATIONS_MANAGEMENT_ISSUER },
|
|
4200
|
+
{
|
|
4201
|
+
key: 'MANAGEMENT_PUBLIC_KEYS',
|
|
4202
|
+
value: JSON.stringify({ [INTEGRATIONS_MANAGEMENT_KEY_ID]: publicKey }),
|
|
4203
|
+
},
|
|
4204
|
+
]);
|
|
4205
|
+
await record(PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, runnerApp.appId);
|
|
4206
|
+
keyJustSet = true;
|
|
4207
|
+
}
|
|
4208
|
+
// ---- then the runner and the worker ------------------------------------------------
|
|
4209
|
+
// Decided from the live state: an env change only reaches a container on its
|
|
4210
|
+
// next deploy, and a stopped runner is deployed so an earlier failed deploy
|
|
4211
|
+
// is retried. The worker exits after one pass by design, so "not running"
|
|
4212
|
+
// says nothing about it and it is deployed only when created or changed.
|
|
4213
|
+
if (runnerApp.justCreated ||
|
|
4214
|
+
!runnerApp.running ||
|
|
4215
|
+
runnerEnvsChanged ||
|
|
4216
|
+
keyJustSet ||
|
|
4217
|
+
configChanged) {
|
|
4218
|
+
await coolify.deploy(runnerApp.appId);
|
|
4219
|
+
}
|
|
4220
|
+
if (workerApp.justCreated || workerEnvsChanged || configChanged) {
|
|
4221
|
+
await coolify.deploy(workerApp.appId);
|
|
4222
|
+
}
|
|
4223
|
+
await record(PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
|
|
4224
|
+
await record(PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, gitRef);
|
|
4225
|
+
return { resourceId: runnerApp.appId };
|
|
4226
|
+
}
|
|
4227
|
+
/**
|
|
4228
|
+
* The runtime `DATABASE_URL` already on the migrate app, or a new one:
|
|
4229
|
+
* {@link INTEGRATIONS_RUNTIME_ROLE} with a password minted once from a
|
|
4230
|
+
* URL-safe alphabet (the host's `db-runtime-role.sh` refuses anything but
|
|
4231
|
+
* `A-Za-z0-9_.~-`), on the owner URL's host, database and query string --
|
|
4232
|
+
* `executor.ts`'s `ensureCmsRuntimeUrl`, with the same rule: never rotated,
|
|
4233
|
+
* because the migrate job set the live role's password from the first one.
|
|
4234
|
+
* A parse failure throws a fixed message, never the URL (Node's own
|
|
4235
|
+
* `ERR_INVALID_URL` carries the whole input, password included).
|
|
4236
|
+
*/
|
|
4237
|
+
async function ensureIntegrationsRuntimeUrl(coolify, migrateAppId, ownerUrl) {
|
|
4238
|
+
const existing = (await coolify.listEnvs(migrateAppId)).find((e) => e.key === 'DATABASE_URL' && !e.is_preview && e.value);
|
|
4239
|
+
if (existing)
|
|
4240
|
+
return existing.value;
|
|
4241
|
+
let host;
|
|
4242
|
+
let pathname;
|
|
4243
|
+
let search;
|
|
4244
|
+
try {
|
|
4245
|
+
({ host, pathname, search } = new URL(ownerUrl));
|
|
4246
|
+
}
|
|
4247
|
+
catch {
|
|
4248
|
+
throw new Error('platform:integrations: the database internal_db_url could not be parsed; not deploying the integrations host');
|
|
4249
|
+
}
|
|
4250
|
+
const password = randomBytes(24).toString('base64url');
|
|
4251
|
+
return `postgres://${INTEGRATIONS_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
|
|
4252
|
+
}
|
|
4253
|
+
/** The entries of `desired` whose key the app does not have yet -- never overwriting a value a person set. */
|
|
4254
|
+
async function envsIfAbsent(coolify, appId, desired) {
|
|
4255
|
+
const live = new Set((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => e.key));
|
|
4256
|
+
return desired.filter((e) => !live.has(e.key));
|
|
4257
|
+
}
|
|
4258
|
+
/**
|
|
4259
|
+
* Polls the migrate app until Coolify reports it `running:healthy` after
|
|
4260
|
+
* first reporting anything else -- a healthy status before that is the old
|
|
4261
|
+
* container, since the stop and deploy calls can return before Coolify's
|
|
4262
|
+
* status catches up. Its `HEALTHCHECK` passes only after
|
|
4263
|
+
* `integrations-migrate: done` (see
|
|
4264
|
+
* {@link provisionPlatformIntegrations}'s known gaps). Throws once
|
|
4265
|
+
* `migrateTimeoutMs` has passed. The runner and worker are never deployed
|
|
4266
|
+
* after a timeout.
|
|
4267
|
+
*/
|
|
4268
|
+
async function waitForIntegrationsMigration(coolify, appId, runtime, sleep) {
|
|
4269
|
+
const now = runtime.now ?? Date.now;
|
|
4270
|
+
const timeout = runtime.migrateTimeoutMs ?? 20 * 60_000;
|
|
4271
|
+
const interval = runtime.migratePollMs ?? 10_000;
|
|
4272
|
+
const deadline = now() + timeout;
|
|
4273
|
+
let left = false;
|
|
4274
|
+
for (;;) {
|
|
4275
|
+
const { status } = await coolify.getApplication(appId);
|
|
4276
|
+
const healthy = status?.startsWith('running:healthy') === true;
|
|
4277
|
+
if (healthy && left)
|
|
4278
|
+
return;
|
|
4279
|
+
if (!healthy)
|
|
4280
|
+
left = true;
|
|
4281
|
+
if (now() >= deadline) {
|
|
4282
|
+
throw new Error(`platform:integrations: integrations-migrate (${appId}) did not report healthy within ${Math.round(timeout / 1000)}s (last status: ${status ?? 'unknown'}); check its logs, then apply again`);
|
|
4283
|
+
}
|
|
4284
|
+
await sleep(interval);
|
|
4285
|
+
}
|
|
4286
|
+
}
|
|
4287
|
+
/**
|
|
4288
|
+
* The real {@link PlatformCloudflareReader}: the platform's apex zone as
|
|
4289
|
+
* `planApexZone` expects to see it, and nothing else. The zone's DNS records
|
|
4290
|
+
* and tunnels belong to no declared platform step yet, so listing them would
|
|
4291
|
+
* only report them unmanaged.
|
|
4292
|
+
*
|
|
4293
|
+
* The zone is fetched by the id the last apply recorded when there is one,
|
|
4294
|
+
* because Cloudflare's zone list can lag a create; with no recorded id it is
|
|
4295
|
+
* found by apex.
|
|
4296
|
+
*/
|
|
4297
|
+
export class ProvidersPlatformCloudflareReader {
|
|
4298
|
+
client;
|
|
4299
|
+
platformStore;
|
|
4300
|
+
constructor(client, platformStore) {
|
|
4301
|
+
this.client = client;
|
|
4302
|
+
this.platformStore = platformStore;
|
|
4303
|
+
}
|
|
4304
|
+
async listResources() {
|
|
4305
|
+
const platform = await this.platformStore.get();
|
|
4306
|
+
if (!platform)
|
|
4307
|
+
return [];
|
|
4308
|
+
const recordedId = (await this.platformStore.getServiceIds())[apexZoneKey(platform.apex)];
|
|
4309
|
+
const zone = (recordedId && (await recordedZone(this.client, recordedId, platform.apex))) ||
|
|
4310
|
+
(await exactZone(this.client, platform.apex));
|
|
4311
|
+
if (!zone)
|
|
4312
|
+
return [];
|
|
4313
|
+
return [
|
|
4314
|
+
{
|
|
4315
|
+
system: 'cloudflare',
|
|
4316
|
+
kind: 'zone',
|
|
4317
|
+
key: platform.apex,
|
|
4318
|
+
config: { id: zone.id, status: zone.status, name_servers: zone.name_servers },
|
|
4319
|
+
},
|
|
4320
|
+
];
|
|
4321
|
+
}
|
|
4322
|
+
}
|
|
4323
|
+
/**
|
|
4324
|
+
* #173: the real {@link PlatformCoolifyReader} for the jobs scheduler app --
|
|
4325
|
+
* the recorded-id half of `ProvidersPlatformCloudflareReader`'s own strategy
|
|
4326
|
+
* above, with no list-by-name fallback: unlike a Cloudflare zone (which
|
|
4327
|
+
* exists before foundry ever touches it), the scheduler app has no identity
|
|
4328
|
+
* to adopt until this package creates it, so "nothing recorded yet" and
|
|
4329
|
+
* "nothing live yet" are the same state here. A recorded id Coolify no
|
|
4330
|
+
* longer has (deleted, or moved) reads as not-live, the same "stale id never
|
|
4331
|
+
* blocks planning for good" rule {@link recordedZone} follows.
|
|
4332
|
+
*/
|
|
4333
|
+
export class ProvidersPlatformCoolifyReader {
|
|
4334
|
+
client;
|
|
4335
|
+
platformStore;
|
|
4336
|
+
constructor(client, platformStore) {
|
|
4337
|
+
this.client = client;
|
|
4338
|
+
this.platformStore = platformStore;
|
|
4339
|
+
}
|
|
4340
|
+
async listResources() {
|
|
4341
|
+
const recordedId = (await this.platformStore.getServiceIds())[JOBS_APP_KEY];
|
|
4342
|
+
if (!recordedId)
|
|
4343
|
+
return [];
|
|
4344
|
+
try {
|
|
4345
|
+
const app = await this.client.getApplication(recordedId);
|
|
4346
|
+
return [
|
|
4347
|
+
{
|
|
4348
|
+
system: 'coolify',
|
|
4349
|
+
kind: 'jobs',
|
|
4350
|
+
key: JOBS_SCHEDULER_KEY,
|
|
4351
|
+
config: { uuid: app.uuid, status: app.status },
|
|
4352
|
+
},
|
|
4353
|
+
];
|
|
4354
|
+
}
|
|
4355
|
+
catch {
|
|
4356
|
+
return [];
|
|
4357
|
+
}
|
|
4358
|
+
}
|
|
4359
|
+
}
|
|
4360
|
+
/**
|
|
4361
|
+
* `findZoneByApex` returns the longest suffix match, so for a subdomain it
|
|
4362
|
+
* answers with the parent zone. The platform's zone is its apex exactly;
|
|
4363
|
+
* any other zone is never adopted or reported as the platform's.
|
|
4364
|
+
*/
|
|
4365
|
+
async function exactZone(client, apex) {
|
|
4366
|
+
const zone = await client.findZoneByApex(apex);
|
|
4367
|
+
return zone && zone.name === apex ? zone : undefined;
|
|
4368
|
+
}
|
|
4369
|
+
/**
|
|
4370
|
+
* The zone the last apply recorded, or `undefined` when Cloudflare no longer
|
|
4371
|
+
* has it under that id (deleted, or moved to another account) or it is no
|
|
4372
|
+
* longer the apex. The caller then falls back to finding it by apex, so a
|
|
4373
|
+
* stale recorded id never blocks planning for good.
|
|
4374
|
+
*/
|
|
4375
|
+
async function recordedZone(client, id, apex) {
|
|
4376
|
+
try {
|
|
4377
|
+
const zone = await client.getZone(id);
|
|
4378
|
+
return zone.name === apex ? zone : undefined;
|
|
4379
|
+
}
|
|
4380
|
+
catch {
|
|
4381
|
+
return undefined;
|
|
4382
|
+
}
|
|
4383
|
+
}
|
|
4384
|
+
/**
|
|
4385
|
+
* What Archon needs from each service before its step may run: the service's
|
|
4386
|
+
* own applied step (`coolify:app:<stepKey>`), scope ids its step recorded, and
|
|
4387
|
+
* held keys its step delivered. A missing one blocks Archon's plan. Archon
|
|
4388
|
+
* only reads these; it never mints them.
|
|
4389
|
+
*/
|
|
4390
|
+
const ARCHON_SERVICE_INPUTS = {
|
|
4391
|
+
files: {
|
|
4392
|
+
stepKey: PLATFORM_FILES_KEY,
|
|
4393
|
+
scopeKeys: [PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY],
|
|
4394
|
+
refs: [FILES_MANAGEMENT_KEY_REF, FILES_INTROSPECTION_SECRET_REF],
|
|
4395
|
+
},
|
|
4396
|
+
ai: {
|
|
4397
|
+
stepKey: PLATFORM_AI_KEY,
|
|
4398
|
+
scopeKeys: [
|
|
4399
|
+
PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY,
|
|
4400
|
+
PLATFORM_AI_ORGANISATION_SCOPE_KEY,
|
|
4401
|
+
PLATFORM_AI_INTERNAL_URL_SCOPE_KEY,
|
|
4402
|
+
],
|
|
4403
|
+
refs: [PLATFORM_AI_MANAGEMENT_KEY_REF],
|
|
4404
|
+
},
|
|
4405
|
+
integrations: {
|
|
4406
|
+
stepKey: PLATFORM_INTEGRATIONS_KEY,
|
|
4407
|
+
scopeKeys: [],
|
|
4408
|
+
refs: [INTEGRATIONS_MANAGEMENT_KEY_REF],
|
|
4409
|
+
},
|
|
4410
|
+
};
|