@wtfalch/foundry 0.4.0 → 0.5.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.
Files changed (66) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +96 -0
  2. package/dist/bootstrap/bootstrap.js +285 -0
  3. package/dist/bootstrap/declare.d.ts +36 -0
  4. package/dist/bootstrap/declare.js +94 -0
  5. package/dist/bootstrap/drive.d.ts +40 -0
  6. package/dist/bootstrap/drive.js +51 -0
  7. package/dist/bootstrap/index.d.ts +3 -0
  8. package/dist/bootstrap/index.js +1 -0
  9. package/dist/calls-sfu-deploy-support.d.ts +9 -6
  10. package/dist/calls-sfu-deploy-support.js +9 -6
  11. package/dist/calls-sfu-keys.d.ts +21 -0
  12. package/dist/calls-sfu-keys.js +27 -0
  13. package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
  14. package/dist/calls-sfu-ssh-adapter.js +78 -0
  15. package/dist/calls-sfu-ssh-client.d.ts +49 -0
  16. package/dist/calls-sfu-ssh-client.js +82 -0
  17. package/dist/calls-sfu.d.ts +5 -4
  18. package/dist/calls-sfu.js +5 -4
  19. package/dist/index.d.ts +31 -6
  20. package/dist/index.js +16 -3
  21. package/dist/migrations/0016_platform.sql +61 -0
  22. package/dist/migrations/0017_requested_changes.sql +42 -0
  23. package/dist/migrations/0018_platform_apply.sql +19 -0
  24. package/dist/org.js +8 -0
  25. package/dist/plan/agora.d.ts +77 -0
  26. package/dist/plan/agora.js +127 -0
  27. package/dist/plan/cloudflared-connector.d.ts +70 -0
  28. package/dist/plan/cloudflared-connector.js +106 -0
  29. package/dist/plan/diff.d.ts +20 -3
  30. package/dist/plan/diff.js +631 -116
  31. package/dist/plan/executor.d.ts +475 -168
  32. package/dist/plan/executor.js +543 -175
  33. package/dist/plan/index.d.ts +22 -5
  34. package/dist/plan/index.js +12 -2
  35. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  36. package/dist/plan/jobs-scheduler-http.js +50 -0
  37. package/dist/plan/live.d.ts +51 -2
  38. package/dist/plan/live.js +8 -0
  39. package/dist/plan/plan-apply.d.ts +192 -3
  40. package/dist/plan/plan-apply.js +278 -9
  41. package/dist/plan/platform-diff.d.ts +312 -0
  42. package/dist/plan/platform-diff.js +867 -0
  43. package/dist/plan/platform-org-ports.d.ts +50 -0
  44. package/dist/plan/platform-org-ports.js +70 -0
  45. package/dist/plan/platform-org.d.ts +120 -0
  46. package/dist/plan/platform-org.js +115 -0
  47. package/dist/plan/platform-providers.d.ts +501 -0
  48. package/dist/plan/platform-providers.js +3029 -0
  49. package/dist/plan/platform-sfu.d.ts +93 -0
  50. package/dist/plan/platform-sfu.js +100 -0
  51. package/dist/plan/providers-readers.js +22 -1
  52. package/dist/plan/zitadel-machine-user.d.ts +34 -0
  53. package/dist/plan/zitadel-machine-user.js +95 -0
  54. package/dist/plan.d.ts +6 -0
  55. package/dist/platform.d.ts +55 -0
  56. package/dist/platform.js +46 -0
  57. package/dist/requested-changes.d.ts +108 -0
  58. package/dist/requested-changes.js +95 -0
  59. package/dist/slug.js +15 -2
  60. package/dist/store/audit.js +4 -2
  61. package/dist/store/postgres.d.ts +29 -1
  62. package/dist/store/postgres.js +172 -2
  63. package/dist/store/schema.d.ts +683 -0
  64. package/dist/store/schema.js +57 -0
  65. package/dist/store.d.ts +46 -0
  66. package/package.json +1 -1
@@ -0,0 +1,3029 @@
1
+ import { generateKeyPairSync, randomBytes } 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_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_MINTER_BLOCKED, PLATFORM_AI_ORGANISATION_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_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_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, 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
+ *
71
+ * #168 adds `coolify:app` keyed {@link PLATFORM_AUTH_KEY}, the platform's own
72
+ * sign-in ({@link provisionPlatformAuth}), once a host passes
73
+ * {@link PlatformAuthDeps}.
74
+ *
75
+ * #169 adds `coolify:app` keyed {@link PLATFORM_MAIL_KEY}, the platform's own
76
+ * mail server ({@link provisionPlatformMail}), once a host passes
77
+ * {@link PlatformMailDeps}.
78
+ *
79
+ * #174 adds `coolify:app` keyed {@link PLATFORM_INTEGRATIONS_KEY},
80
+ * the integrations host ({@link provisionPlatformIntegrations}), once a host
81
+ * passes {@link PlatformIntegrationsDeps}.
82
+ *
83
+ * #171 adds `coolify:app` keyed {@link PLATFORM_FILES_KEY}, the files host
84
+ * ({@link provisionPlatformFiles}), once a host passes {@link PlatformFilesDeps}.
85
+ *
86
+ * #172 adds `coolify:app` keyed {@link PLATFORM_AI_KEY}, the platform's AI
87
+ * host ({@link provisionPlatformAi}), once a host passes {@link PlatformAiDeps}.
88
+ *
89
+ * #175 adds `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, Archon itself
90
+ * ({@link provisionPlatformArchon}), once a host passes
91
+ * {@link PlatformArchonDeps}.
92
+ */
93
+ export class FoundryPlatformStepExecutor {
94
+ cloudflare;
95
+ declaredOrgStore;
96
+ coolify;
97
+ deliverSecret;
98
+ sfu;
99
+ auth;
100
+ mail;
101
+ integrations;
102
+ files;
103
+ ai;
104
+ archon;
105
+ constructor(cloudflare, declaredOrgStore, options = {}) {
106
+ this.cloudflare = cloudflare;
107
+ this.declaredOrgStore = declaredOrgStore;
108
+ this.coolify = options.coolify;
109
+ this.deliverSecret = options.deliverSecret;
110
+ this.sfu = options.sfu;
111
+ this.auth = options.auth;
112
+ this.mail = options.mail;
113
+ this.integrations = options.integrations;
114
+ this.files = options.files;
115
+ this.ai = options.ai;
116
+ this.archon = options.archon;
117
+ }
118
+ async blockedSteps(platform, recordedIds = {}) {
119
+ const blocked = {};
120
+ if (platform.auth.enabled && !this.auth) {
121
+ blocked.auth =
122
+ "the platform's sign-in is blocked, not planned: the host built the platform executor without its auth dependencies";
123
+ }
124
+ if (platform.mail.enabled) {
125
+ const mailBlocked = await mailBlockedReason(platform, this.mail, recordedIds);
126
+ if (mailBlocked)
127
+ blocked.mail = mailBlocked;
128
+ }
129
+ if (platform.integrations.enabled && !this.integrations) {
130
+ blocked.integrations =
131
+ 'the integrations host is blocked, not planned: the host built the platform executor without its integrations dependencies';
132
+ }
133
+ else if (platform.integrations.enabled && this.integrations) {
134
+ const missing = INTEGRATIONS_KEYS_ENV_NAMES.filter((n) => !this.integrations?.keysEnv?.[n]);
135
+ if (missing.length > 0) {
136
+ blocked.integrations = `the integrations host is blocked, not planned: the host supplies no value for ${missing.join(', ')}, the host's own @wtfalch/keys settings`;
137
+ }
138
+ }
139
+ if (platform.files.enabled && !this.files) {
140
+ blocked.files =
141
+ 'the files host is blocked, not planned: the host built the platform executor without its files dependencies';
142
+ }
143
+ else if (platform.files.enabled && !filesKeys(this.files)) {
144
+ blocked.files =
145
+ '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';
146
+ }
147
+ if (platform.ai.enabled && !this.ai) {
148
+ blocked.ai =
149
+ "the platform's AI host is blocked, not planned: the host built the platform executor without its ai dependencies";
150
+ }
151
+ else if (platform.ai.enabled &&
152
+ !this.ai?.managementKeyMinter &&
153
+ !recordedIds[PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY]) {
154
+ blocked.ai = PLATFORM_AI_MINTER_BLOCKED;
155
+ }
156
+ if (platform.archon.enabled) {
157
+ if (!this.archon) {
158
+ blocked.archon =
159
+ 'Archon is blocked, not planned: the host built the platform executor without its archon dependencies';
160
+ }
161
+ else if (this.archon.accessEmails.length === 0) {
162
+ blocked.archon =
163
+ 'Archon is blocked, not planned: the host names no email the Cloudflare Access app in front of it may let through';
164
+ }
165
+ else {
166
+ const inputs = await collectArchonServiceInputs(platform, this.archon, recordedIds);
167
+ if ('blocked' in inputs)
168
+ blocked.archon = inputs.blocked;
169
+ }
170
+ }
171
+ return blocked;
172
+ }
173
+ /** Whether Archon's live Access policy lets through anyone but the declared emails. */
174
+ async driftedSteps(platform, recordedIds = {}) {
175
+ if (!platform.archon.enabled || !this.archon)
176
+ return {};
177
+ if (!recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`])
178
+ return {};
179
+ const drifted = await archonAccessDrifted(this.archon.cloudflare, platform.archon.hostname, this.archon.accessEmails);
180
+ return drifted ? { archon: true } : {};
181
+ }
182
+ async execute(action, context) {
183
+ if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_AUTH_KEY) {
184
+ if (!this.auth) {
185
+ throw new Error('platform:auth needs FoundryPlatformStepExecutor to be constructed with its auth dependencies (#168)');
186
+ }
187
+ return provisionPlatformAuth(context, this.auth);
188
+ }
189
+ if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_FILES_KEY) {
190
+ if (!this.files) {
191
+ throw new Error('platform:files needs FoundryPlatformStepExecutor to be constructed with its files dependencies (#171)');
192
+ }
193
+ return provisionPlatformFiles(context, this.files);
194
+ }
195
+ if (action.system === 'coolify' &&
196
+ action.kind === 'app' &&
197
+ action.key === PLATFORM_INTEGRATIONS_KEY) {
198
+ if (!this.integrations) {
199
+ throw new Error('platform:integrations needs FoundryPlatformStepExecutor to be constructed with its integrations dependencies (#174)');
200
+ }
201
+ return provisionPlatformIntegrations(action, context, this.integrations);
202
+ }
203
+ if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_AI_KEY) {
204
+ if (!this.ai) {
205
+ throw new Error('platform:ai needs FoundryPlatformStepExecutor to be constructed with its ai dependencies (#172)');
206
+ }
207
+ return provisionPlatformAi(context, this.ai);
208
+ }
209
+ if (action.system === 'coolify' &&
210
+ action.kind === 'app' &&
211
+ action.key === PLATFORM_ARCHON_KEY) {
212
+ if (!this.archon) {
213
+ throw new Error('platform:archon needs FoundryPlatformStepExecutor to be constructed with its archon dependencies (#175)');
214
+ }
215
+ return provisionPlatformArchon(context, this.archon);
216
+ }
217
+ if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_MAIL_KEY) {
218
+ if (!this.mail) {
219
+ throw new Error('platform:mail needs FoundryPlatformStepExecutor to be constructed with its mail dependencies (#169)');
220
+ }
221
+ return provisionPlatformMail(context, this.mail);
222
+ }
223
+ if (action.system === 'cloudflare' && action.kind === 'zone') {
224
+ return this.cloudflareZone(action, context);
225
+ }
226
+ if (action.system === 'coolify' && action.kind === 'jobs') {
227
+ return this.coolifyJobs(action, context);
228
+ }
229
+ if (action.system === 'sfu' && action.kind === 'livekit') {
230
+ return this.executeSfu();
231
+ }
232
+ throw new Error(`platform step ${action.system}:${action.kind} is not built yet`);
233
+ }
234
+ async cloudflareZone(action, context) {
235
+ const domain = action.key;
236
+ const existing = await exactZone(this.cloudflare, domain);
237
+ if (existing) {
238
+ const owner = await this.declaredOrgStore.findOrgIdForZone(existing.id);
239
+ if (owner !== null && owner !== context.platform.ownerOrgId) {
240
+ throw new Error(`cloudflare:zone for the platform: zone ${existing.id} (${domain}) is already recorded under org ${owner}; foundry does not adopt it`);
241
+ }
242
+ return { resourceId: existing.id };
243
+ }
244
+ const created = await this.cloudflare.createZone(domain);
245
+ return { resourceId: created.id };
246
+ }
247
+ async executeSfu() {
248
+ if (!this.sfu) {
249
+ throw new Error('platform step sfu:livekit is not built yet (#170: pass `sfu` to FoundryPlatformStepExecutor)');
250
+ }
251
+ await applyCallsSfuPlatformStep(this.sfu);
252
+ return undefined;
253
+ }
254
+ async coolifyJobs(action, context) {
255
+ const coolify = this.coolify;
256
+ if (!coolify) {
257
+ throw new Error('platform step coolify:jobs needs a Coolify port -- construct FoundryPlatformStepExecutor with one (#173)');
258
+ }
259
+ const deliverSecret = this.deliverSecret;
260
+ if (!deliverSecret) {
261
+ throw new Error('platform step coolify:jobs needs a deliverSecret -- construct FoundryPlatformStepExecutor with one, to hand the scheduler admin token to the host (#173)');
262
+ }
263
+ const hostname = action.desired.hostname;
264
+ const gitRef = action.desired.gitRef;
265
+ const recorded = context.recordedIds();
266
+ let projectUuid = recorded[JOBS_PROJECT_SCOPE_KEY];
267
+ if (!projectUuid) {
268
+ const found = await coolify.findProjectByName(PLATFORM_COOLIFY_PROJECT_NAME);
269
+ projectUuid = found
270
+ ? found.uuid
271
+ : (await coolify.createProject(PLATFORM_COOLIFY_PROJECT_NAME)).uuid;
272
+ context.recordScopeId(JOBS_PROJECT_SCOPE_KEY, projectUuid);
273
+ }
274
+ const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
275
+ let dbId = recorded[JOBS_DATABASE_SCOPE_KEY];
276
+ if (!dbId) {
277
+ const dbName = `${JOBS_SCHEDULER_KEY}-scheduler-db`;
278
+ const found = (await coolify.listDatabases()).find((db) => db.name === dbName && db.environment_id === environment.id);
279
+ if (found) {
280
+ dbId = found.uuid;
281
+ }
282
+ else {
283
+ const server = await resolvePlatformServer(coolify);
284
+ const created = await coolify.createPostgresDatabase({
285
+ project_uuid: projectUuid,
286
+ server_uuid: server.uuid,
287
+ environment_name: environment.name,
288
+ environment_uuid: environment.uuid,
289
+ name: dbName,
290
+ postgres_db: 'jobs',
291
+ postgres_user: 'jobs',
292
+ });
293
+ dbId = created.uuid;
294
+ }
295
+ context.recordScopeId(JOBS_DATABASE_SCOPE_KEY, dbId);
296
+ }
297
+ const existingApp = await coolify.findApplicationByName(JOBS_SCHEDULER_KEY, {
298
+ environmentIds: [environment.id],
299
+ });
300
+ let appId;
301
+ if (existingApp) {
302
+ appId = existingApp.uuid;
303
+ }
304
+ else {
305
+ const server = await resolvePlatformServer(coolify);
306
+ const githubApp = await coolify.requireGithubApp();
307
+ const created = await coolify.createGithubAppApplication({
308
+ project_uuid: projectUuid,
309
+ server_uuid: server.uuid,
310
+ environment_name: environment.name,
311
+ environment_uuid: environment.uuid,
312
+ github_app_uuid: githubApp.uuid,
313
+ git_repository: JOBS_SCHEDULER_REPO,
314
+ git_branch: gitRef,
315
+ build_pack: 'dockerfile',
316
+ name: JOBS_SCHEDULER_KEY,
317
+ domains: hostname,
318
+ });
319
+ appId = created.uuid;
320
+ }
321
+ const db = await coolify.getDatabase(dbId);
322
+ if (!db.internal_db_url) {
323
+ throw new Error(`platform step coolify:jobs: database ${dbId} has no internal_db_url yet; not deploying the scheduler without a database`);
324
+ }
325
+ const envs = [{ key: 'DATABASE_URL', value: db.internal_db_url }];
326
+ // SCHEDULER_ADMIN_TOKEN gates the admin API the org registrations use
327
+ // (jobs README, "The admin API"); unset, every admin route answers 401.
328
+ // Delivered to the host before it is written to the app, so a failed
329
+ // delivery leaves nothing set and the rerun simply generates a new one.
330
+ let tokenJustSet = false;
331
+ if (!context.recordedIds()[JOBS_ADMIN_TOKEN_SCOPE_KEY]) {
332
+ const token = randomBytes(32).toString('hex');
333
+ let delivered = false;
334
+ for (let attempt = 1; attempt <= JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS && !delivered; attempt++) {
335
+ try {
336
+ await deliverSecret(JOBS_ADMIN_TOKEN_REF, token);
337
+ delivered = true;
338
+ }
339
+ catch {
340
+ // Retried; the error is dropped in case it echoes the token.
341
+ }
342
+ }
343
+ if (!delivered) {
344
+ throw new Error(`platform step coolify:jobs: the scheduler admin token could not be delivered after ${JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS} attempts`);
345
+ }
346
+ envs.push({ key: 'SCHEDULER_ADMIN_TOKEN', value: token });
347
+ await coolify.bulkUpsertEnvs(appId, envs);
348
+ context.recordScopeId(JOBS_ADMIN_TOKEN_SCOPE_KEY, appId);
349
+ tokenJustSet = true;
350
+ }
351
+ else {
352
+ await coolify.bulkUpsertEnvs(appId, envs);
353
+ }
354
+ // The app is created with `instant_deploy: false` and an env change needs
355
+ // a redeploy, so deploy after a create or a new token. Also when it is not
356
+ // running, so a deploy that failed on an earlier run is retried.
357
+ const running = (await coolify.getApplication(appId)).status?.startsWith('running') ?? false;
358
+ if (!existingApp || tokenJustSet || !running)
359
+ await coolify.deploy(appId);
360
+ return { resourceId: appId };
361
+ }
362
+ }
363
+ // ---------------------------------------------------------------------------
364
+ // #168: the platform's own sign-in (ZITADEL)
365
+ // ---------------------------------------------------------------------------
366
+ const PLATFORM_AUTH_PROJECT = 'platform';
367
+ const PLATFORM_AUTH_DB_NAME = 'platform-auth-db';
368
+ const PLATFORM_AUTH_ZITADEL_APP_NAME = 'platform-auth-zitadel';
369
+ const PLATFORM_AUTH_LOGIN_APP_NAME = 'platform-auth-login';
370
+ /**
371
+ * Fixed `custom_internal_name`s (`diff.ts`'s `coolifyCalls` sets the same
372
+ * kind of name on Synapse/MAS) so the Cloudflare tunnel below can address
373
+ * each app directly, by container name, on the shared Coolify Docker network
374
+ * -- bypassing Coolify's own Traefik, which dispatches purely by `Host`
375
+ * header and so cannot give two different apps the same public hostname.
376
+ */
377
+ const PLATFORM_AUTH_ZITADEL_INTERNAL_NAME = 'platform-auth-zitadel';
378
+ const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = 'platform-auth-login';
379
+ const PLATFORM_AUTH_TUNNEL_NAME = 'platform-auth-tunnel';
380
+ /** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_AUTH_TUNNEL_NAME}. */
381
+ const PLATFORM_AUTH_CLOUDFLARED_APP_NAME = 'platform-auth-cloudflared';
382
+ /** `wtfalch/auth`'s `docker-compose.yml`: `image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION:-v4.17.3}`. */
383
+ const AUTH_ZITADEL_IMAGE = 'ghcr.io/zitadel/zitadel';
384
+ const AUTH_ZITADEL_IMAGE_TAG = 'v4.17.3';
385
+ /** `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". */
386
+ const AUTH_REPO = 'wtfalch/auth';
387
+ const AUTH_LOGIN_DOCKERFILE = 'login/Dockerfile';
388
+ /**
389
+ * `wtfalch/auth`'s login app answers on 3000 (its `Dockerfile`'s Next.js
390
+ * default); ZITADEL answers on 8080 -- both fixed by `docker-compose.yml`'s
391
+ * own `ZITADEL_PORT: 8080` and the login image's own `EXPOSE`.
392
+ */
393
+ const AUTH_ZITADEL_PORT = '8080';
394
+ const AUTH_LOGIN_PORT = '3000';
395
+ /**
396
+ * Every ZITADEL OIDC/management/console/asset path -- `deploy/proxy/Caddyfile`'s
397
+ * own explicit list, restated here as one regex since the tunnel routes by a
398
+ * single `path` per ingress rule rather than a set. Anything not matching
399
+ * falls through to the login app's own ingress rule below (login v2 answers
400
+ * everything else, including the root).
401
+ */
402
+ const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|system|auth|assets|ui/console|\\.well-known)(/|$)';
403
+ const PLATFORM_AUTH_PROJECT_SCOPE_KEY = 'coolify_project_platform_auth';
404
+ const PLATFORM_AUTH_DB_SCOPE_KEY = 'coolify_platform_auth_database';
405
+ const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = 'coolify_platform_auth_zitadel_app';
406
+ const PLATFORM_AUTH_LOGIN_SCOPE_KEY = 'coolify_platform_auth_login_app';
407
+ const PLATFORM_AUTH_TUNNEL_SCOPE_KEY = 'cloudflare_platform_auth_tunnel';
408
+ /** Recorded once the ZITADEL app has been deployed, so ZITADEL may have written to its database. */
409
+ const PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY = 'zitadel_platform_auth_deployed';
410
+ const PLATFORM_AUTH_ADMIN_USER_NAME = 'foundry-admin';
411
+ const PLATFORM_AUTH_ORG_NAME = 'foundry';
412
+ /**
413
+ * #168: makes `coolify:app` keyed {@link PLATFORM_AUTH_KEY} true -- a Coolify
414
+ * Postgres, the ZITADEL app itself and the zitadel-login app, reached through
415
+ * one Cloudflare tunnel and DNS record at `platform.auth.hostname`, then (only
416
+ * the first time) foundry's own admin machine user, handed to
417
+ * {@link PlatformAuthDeps.deliverSecret}. Env values and instance settings
418
+ * are `wtfalch/auth`'s own `docker-compose.yml` (checked 2026-09-29), with
419
+ * `ZITADEL_DOMAIN`/`wtfalch.dev` replaced by this platform's own
420
+ * `apex`/`auth.hostname` wherever the compose named one -- every other
421
+ * setting it leaves open (login v2 required, external port 443, TLS
422
+ * terminated at Cloudflare, `PASSWORDCHANGEREQUIRED` false) is kept exactly
423
+ * as that compose has it (#168's "Decide").
424
+ *
425
+ * Every part is safe to run again after a throw partway: each id is recorded
426
+ * (and persisted) as soon as its resource exists, each app is named and
427
+ * deployed on every run that needs it, and the admin key is delivered before
428
+ * the guard that skips the mint is recorded.
429
+ *
430
+ * Two apps, one public hostname, no third `proxy` app: `wtfalch/auth` splits
431
+ * ZITADEL's API from its login UI with a built Caddy proxy container inside
432
+ * one docker-compose stack; foundry instead gives each app a fixed
433
+ * `custom_internal_name` and lets the Cloudflare tunnel itself path-route
434
+ * between them (`AUTH_ZITADEL_PATH_PATTERN`), skipping Coolify's own Traefik
435
+ * (which can only dispatch by `Host`, not by path) rather than provisioning a
436
+ * fourth Coolify resource to do the same job. Neither app is created with a
437
+ * Coolify `domains` binding for the same reason -- the tunnel reaches each
438
+ * one directly by its `custom_internal_name`, never through Traefik.
439
+ *
440
+ * Known gaps, left for review (#168's own "the PR stays open for William's
441
+ * review"):
442
+ * - The login app never receives `ZITADEL_SERVICE_USER_TOKEN`. `wtfalch/auth`
443
+ * reads it from a bootstrap file two containers share inside one compose
444
+ * stack; two Coolify apps share none. The minted admin token must not go
445
+ * there (it is `IAM_OWNER`); a dedicated `IAM_LOGIN_CLIENT` machine user
446
+ * would need its own mint, delivery and redeploy. Sign-in through the login
447
+ * app is not proven working until that is wired.
448
+ * - Whoever supplies `deps.zitadel` (or `zitadelForInstance`) must hold a
449
+ * bearer token for the new instance, which foundry cannot read from it.
450
+ *
451
+ * #197: a `cloudflared` connector for this tunnel is run
452
+ * ({@link ensureCloudflaredConnector}), so `hostname` answers.
453
+ */
454
+ async function provisionPlatformAuth(context, deps) {
455
+ const { platform } = context;
456
+ const hostname = platform.auth.hostname;
457
+ const gitRef = platform.auth.gitRef;
458
+ const recorded = context.recordedIds();
459
+ const runtime = deps.runtime ?? {};
460
+ const sleep = runtime.sleep ?? defaultSleep;
461
+ // Records an id and writes it out now, so a crash before the step ends
462
+ // cannot lose a resource a provider already created.
463
+ const record = async (key, id) => {
464
+ context.recordScopeId(key, id);
465
+ await context.persist?.();
466
+ };
467
+ let projectUuid = recorded[PLATFORM_AUTH_PROJECT_SCOPE_KEY];
468
+ if (!projectUuid) {
469
+ const found = await deps.coolify.findProjectByName(PLATFORM_AUTH_PROJECT);
470
+ projectUuid = found
471
+ ? found.uuid
472
+ : (await deps.coolify.createProject(PLATFORM_AUTH_PROJECT)).uuid;
473
+ await record(PLATFORM_AUTH_PROJECT_SCOPE_KEY, projectUuid);
474
+ }
475
+ const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
476
+ // ---- database ----------------------------------------------------------
477
+ let dbId = recorded[PLATFORM_AUTH_DB_SCOPE_KEY];
478
+ // A database ZITADEL has already run against may hold data encrypted with a
479
+ // masterkey this run cannot see (see `ensureMasterkey`). A database that
480
+ // exists but was never deployed against (a throw between its create and the
481
+ // first deploy) is still empty, so a retry may generate the key.
482
+ const databaseInUse = Boolean(dbId && recorded[PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY]);
483
+ if (!dbId) {
484
+ const server = await resolvePlatformServer(deps.coolify);
485
+ const created = await deps.coolify.createPostgresDatabase({
486
+ project_uuid: projectUuid,
487
+ server_uuid: server.uuid,
488
+ environment_name: environment.name,
489
+ environment_uuid: environment.uuid,
490
+ name: PLATFORM_AUTH_DB_NAME,
491
+ postgres_db: 'zitadel',
492
+ postgres_user: 'zitadel',
493
+ });
494
+ dbId = created.uuid;
495
+ await record(PLATFORM_AUTH_DB_SCOPE_KEY, dbId);
496
+ }
497
+ const db = await deps.coolify.getDatabase(dbId);
498
+ if (!db.internal_db_url) {
499
+ // Static config check before either app is touched (`diff.ts`'s
500
+ // `coolifyCalls` follows the same rule for its own databases): Coolify
501
+ // can take a moment to report a freshly created database's
502
+ // `internal_db_url`, so the next apply retries from a clean state.
503
+ throw new Error('coolify:app for the platform (auth): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
504
+ }
505
+ // ---- the ZITADEL app itself ---------------------------------------------
506
+ const zitadelApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_ZITADEL_APP_NAME, environment, async () => {
507
+ const server = await resolvePlatformServer(deps.coolify);
508
+ return deps.coolify.createDockerImageApplication({
509
+ project_uuid: projectUuid,
510
+ server_uuid: server.uuid,
511
+ environment_name: environment.name,
512
+ environment_uuid: environment.uuid,
513
+ docker_registry_image_name: AUTH_ZITADEL_IMAGE,
514
+ docker_registry_image_tag: AUTH_ZITADEL_IMAGE_TAG,
515
+ ports_exposes: AUTH_ZITADEL_PORT,
516
+ name: PLATFORM_AUTH_ZITADEL_APP_NAME,
517
+ description: `ZITADEL for the platform's own sign-in (#168, ${AUTH_ZITADEL_IMAGE}:${AUTH_ZITADEL_IMAGE_TAG})`,
518
+ instant_deploy: false,
519
+ });
520
+ });
521
+ const zitadelAppId = zitadelApp.appId;
522
+ await record(PLATFORM_AUTH_ZITADEL_SCOPE_KEY, zitadelAppId);
523
+ // Set on every run, not only after a create: a throw right after the
524
+ // create must not leave the app unnamed for good, and the tunnel addresses
525
+ // the app by this name. (The port cannot read the current name back, and
526
+ // setting the same value again is harmless.)
527
+ await deps.coolify.updateApplication(zitadelAppId, {
528
+ custom_internal_name: PLATFORM_AUTH_ZITADEL_INTERNAL_NAME,
529
+ });
530
+ // ---- the zitadel-login app -----------------------------------------------
531
+ const loginApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_LOGIN_APP_NAME, environment, async () => {
532
+ const server = await resolvePlatformServer(deps.coolify);
533
+ const githubApp = await deps.coolify.requireGithubApp();
534
+ return deps.coolify.createGithubAppApplication({
535
+ project_uuid: projectUuid,
536
+ server_uuid: server.uuid,
537
+ environment_name: environment.name,
538
+ environment_uuid: environment.uuid,
539
+ github_app_uuid: githubApp.uuid,
540
+ git_repository: AUTH_REPO,
541
+ git_branch: gitRef,
542
+ build_pack: 'dockerfile',
543
+ name: PLATFORM_AUTH_LOGIN_APP_NAME,
544
+ dockerfile_location: AUTH_LOGIN_DOCKERFILE,
545
+ });
546
+ });
547
+ const loginAppId = loginApp.appId;
548
+ await record(PLATFORM_AUTH_LOGIN_SCOPE_KEY, loginAppId);
549
+ await deps.coolify.updateApplication(loginAppId, {
550
+ custom_internal_name: PLATFORM_AUTH_LOGIN_INTERNAL_NAME,
551
+ });
552
+ // ---- env -----------------------------------------------------------------
553
+ // Generated once, handed straight to `bulkUpsertEnvs` and nowhere else --
554
+ // never returned, recorded, logged or interpolated into a thrown error, the
555
+ // same rule `executor.ts`'s `ensureAuthCookieSecret` follows for a value
556
+ // this package mints itself rather than receiving from a provider.
557
+ const masterkey = await ensureMasterkey(deps.coolify, zitadelAppId, databaseInUse);
558
+ const adminPassword = await ensureGeneratedEnv(deps.coolify, zitadelAppId, 'ZITADEL_ADMIN_PASSWORD', generateAdminPassword);
559
+ const zitadelEnvs = [
560
+ { key: 'ZITADEL_PORT', value: AUTH_ZITADEL_PORT },
561
+ { key: 'ZITADEL_EXTERNALDOMAIN', value: hostname },
562
+ { key: 'ZITADEL_EXTERNALPORT', value: '443' },
563
+ { key: 'ZITADEL_EXTERNALSECURE', value: 'true' },
564
+ { key: 'ZITADEL_TLS_ENABLED', value: 'false' },
565
+ { key: 'ZITADEL_DATABASE_POSTGRES_DSN', value: db.internal_db_url },
566
+ { key: 'ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME', value: 'zitadel-admin' },
567
+ { key: 'ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED', value: 'false' },
568
+ { key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED', value: 'true' },
569
+ { key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI', value: `https://${hostname}/` },
570
+ { key: 'ZITADEL_OIDC_DEFAULTLOGINURLV2', value: `https://${hostname}/login?authRequest=` },
571
+ {
572
+ key: 'ZITADEL_OIDC_DEFAULTLOGOUTURLV2',
573
+ value: `https://${hostname}/logout?post_logout_redirect=`,
574
+ },
575
+ { key: 'ZITADEL_SAML_DEFAULTLOGINURLV2', value: `https://${hostname}/login?samlRequest=` },
576
+ { key: 'ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED', value: 'true' },
577
+ ];
578
+ if (masterkey)
579
+ zitadelEnvs.push({ key: 'ZITADEL_MASTERKEY', value: masterkey });
580
+ if (adminPassword)
581
+ zitadelEnvs.push({ key: 'ZITADEL_ADMIN_PASSWORD', value: adminPassword });
582
+ const loginEnvs = [
583
+ {
584
+ key: 'ZITADEL_API_URL',
585
+ value: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
586
+ },
587
+ { key: 'CUSTOM_REQUEST_HEADERS', value: `Host:${hostname},X-Forwarded-Proto:https` },
588
+ { key: 'OTEL_NODE_RESOURCE_DETECTORS', value: 'none' },
589
+ { key: 'API_CACHE_CONFIG', value: '{"defaultMinutes":2,"longMinutes":5}' },
590
+ ];
591
+ // Compared before the write: an env change only reaches a running
592
+ // container on its next deploy (`diff.ts`'s `coolifyCalls` follows the
593
+ // identical rule).
594
+ const zitadelEnvsChanged = await envsDiffer(deps.coolify, zitadelAppId, zitadelEnvs);
595
+ const loginEnvsChanged = await envsDiffer(deps.coolify, loginAppId, loginEnvs);
596
+ await deps.coolify.bulkUpsertEnvs(zitadelAppId, zitadelEnvs);
597
+ await deps.coolify.bulkUpsertEnvs(loginAppId, loginEnvs);
598
+ // Deploy an app that was just created, is not running (a throw after the
599
+ // create left it never deployed, or it stopped), or whose envs changed.
600
+ if (zitadelApp.justCreated || !zitadelApp.running || zitadelEnvsChanged) {
601
+ await deps.coolify.deploy(zitadelAppId);
602
+ await record(PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY, zitadelAppId);
603
+ }
604
+ if (loginApp.justCreated || !loginApp.running || loginEnvsChanged) {
605
+ await deps.coolify.deploy(loginAppId);
606
+ }
607
+ // ---- tunnel and DNS ------------------------------------------------------
608
+ const existingTunnel = await deps.cloudflare.findTunnelByName(PLATFORM_AUTH_TUNNEL_NAME);
609
+ const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(PLATFORM_AUTH_TUNNEL_NAME));
610
+ await record(PLATFORM_AUTH_TUNNEL_SCOPE_KEY, tunnel.id);
611
+ await deps.cloudflare.putTunnelConfig(tunnel.id, {
612
+ ingress: [
613
+ {
614
+ hostname,
615
+ path: AUTH_ZITADEL_PATH_PATTERN,
616
+ service: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
617
+ },
618
+ { hostname, service: `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}` },
619
+ { service: 'http_status:404' },
620
+ ],
621
+ });
622
+ // ---- cloudflared connector for the tunnel above (#197) --------------------
623
+ // Without this, `hostname` resolves to a tunnel nothing is dialed into and
624
+ // answers with a Cloudflare error -- the known gap #197 closes for this
625
+ // step's own tunnel only (see this function's doc comment).
626
+ await ensureCloudflaredConnector({
627
+ coolify: deps.coolify,
628
+ cloudflare: deps.cloudflare,
629
+ tunnelId: tunnel.id,
630
+ appName: PLATFORM_AUTH_CLOUDFLARED_APP_NAME,
631
+ projectUuid,
632
+ environment,
633
+ resolveServer: () => resolvePlatformServer(deps.coolify),
634
+ recordedConnectorAppId: recorded[PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY] ?? null,
635
+ recordAppId: (appId) => record(PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, appId),
636
+ });
637
+ // The apex zone step records the zone id before this step runs, but it
638
+ // creates no record for the hostname, so it is created here.
639
+ const zoneId = recorded[apexZoneKey(platform.apex)] ?? (await exactZone(deps.cloudflare, platform.apex))?.id;
640
+ if (!zoneId) {
641
+ throw new Error(`platform:auth: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
642
+ }
643
+ const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
644
+ const [dnsRecord] = await deps.cloudflare.findDnsRecord(zoneId, hostname, 'CNAME');
645
+ if (!dnsRecord) {
646
+ await deps.cloudflare.createDnsRecord(zoneId, {
647
+ type: 'CNAME',
648
+ name: hostname,
649
+ content: dnsTarget,
650
+ proxied: true,
651
+ });
652
+ }
653
+ else if (dnsRecord.content !== dnsTarget) {
654
+ await deps.cloudflare.updateDnsRecord(zoneId, dnsRecord.id, {
655
+ type: 'CNAME',
656
+ name: hostname,
657
+ content: dnsTarget,
658
+ proxied: true,
659
+ });
660
+ }
661
+ // ---- foundry's own admin machine user, once ------------------------------
662
+ if (!recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
663
+ const lost = recorded[PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY];
664
+ if (lost) {
665
+ // The planner reports this and plans nothing; this is the same rule for
666
+ // a caller that reaches the step anyway.
667
+ 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`);
668
+ }
669
+ // Minting needs a running instance behind the tunnel: the first apply
670
+ // gets here right after the deploy.
671
+ await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep);
672
+ const zitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
673
+ const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
674
+ const { userId, token } = await zitadel.mintAdminMachineUser(org.id, {
675
+ userName: PLATFORM_AUTH_ADMIN_USER_NAME,
676
+ name: 'Foundry (platform admin)',
677
+ });
678
+ // Recorded and persisted before the token leaves this function: if
679
+ // delivery fails for good, the next plan reports the lost key rather than
680
+ // skipping or minting again. Only a delivered key records the guard.
681
+ await record(PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, userId);
682
+ // Handed off once, right here, before returning -- never in the return
683
+ // value, `serviceIds`, the store, a log or a thrown error (see
684
+ // `FoundryStepExecutorPorts.deliverSecret`'s own doc comment). The error
685
+ // below names the user id only, never the token or the cause.
686
+ const attempts = runtime.deliverAttempts ?? 3;
687
+ for (let attempt = 1;; attempt += 1) {
688
+ try {
689
+ await deps.deliverSecret('zitadel:platform:admin', token);
690
+ break;
691
+ }
692
+ catch {
693
+ if (attempt >= attempts) {
694
+ throw new Error(`platform:auth: the admin key of ZITADEL machine user ${userId} was minted but could not be delivered after ${attempts} tries`);
695
+ }
696
+ await sleep(1000 * attempt);
697
+ }
698
+ }
699
+ await record(PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, userId);
700
+ }
701
+ return { resourceId: zitadelAppId };
702
+ }
703
+ // ---------------------------------------------------------------------------
704
+ // #169: the platform's own mail server (Stalwart)
705
+ // ---------------------------------------------------------------------------
706
+ const PLATFORM_MAIL_PROJECT = 'platform';
707
+ const PLATFORM_MAIL_APP_NAME = 'platform-mail-stalwart';
708
+ const PLATFORM_MAIL_INTERNAL_NAME = 'platform-mail-stalwart';
709
+ const PLATFORM_MAIL_TUNNEL_NAME = 'platform-mail-tunnel';
710
+ /** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_MAIL_TUNNEL_NAME}. */
711
+ const PLATFORM_MAIL_CLOUDFLARED_APP_NAME = 'platform-mail-cloudflared';
712
+ /**
713
+ * `stalw.art/docs/install/docker`: the official image. `platform.mail.gitRef`
714
+ * carries its tag -- `platform.ts`'s `PlatformService.gitRef` doc comment
715
+ * ("the release it runs") holds for an image tag too, and Stalwart has no
716
+ * repo of its own to build from the way the login app does.
717
+ */
718
+ const STALWART_IMAGE = 'stalwartlabs/stalwart';
719
+ const STALWART_HTTP_PORT = '8080';
720
+ /**
721
+ * SMTP (25), submission (587) and submissions (465) -- the three ports
722
+ * #169's own "Human steps" names. IMAP/POP3/ManageSieve are left off:
723
+ * nothing in this estate speaks them to Stalwart directly yet, and every
724
+ * port opened here is one Coolify's host firewall must also open.
725
+ */
726
+ const STALWART_SMTP_PORTS = ['25', '465', '587'];
727
+ const STALWART_PORTS_EXPOSES = [STALWART_HTTP_PORT, ...STALWART_SMTP_PORTS].join(',');
728
+ /**
729
+ * `host:container`, comma separated (`CoolifyPort.createDockerImageApplication`'s
730
+ * own doc comment: "a mail server cannot do without" this). The HTTP port is
731
+ * not here -- it stays behind the tunnel, exactly like `platform:auth`'s
732
+ * ZITADEL/login apps.
733
+ */
734
+ const STALWART_PORTS_MAPPINGS = STALWART_SMTP_PORTS.map((p) => `${p}:${p}`).join(',');
735
+ const STALWART_DATA_MOUNT = '/opt/stalwart';
736
+ /** `STALWART_RECOVERY_ADMIN=<user>:<password>` -- the fixed recovery account name; only the password half is generated. */
737
+ const STALWART_RECOVERY_USER = 'admin';
738
+ /**
739
+ * Stalwart's unauthenticated readiness endpoint (200 once its data store is
740
+ * up; `crates/http/src/request.rs`'s `healthz` route in v0.16.24). Not
741
+ * `/.well-known/jmap`, which answers 401 to a client with no credentials.
742
+ */
743
+ const STALWART_READY_PATH = '/healthz/ready';
744
+ /** Where the server the app was created on is recorded, so the MX record follows the app, not a later pick. */
745
+ const PLATFORM_MAIL_SERVER_SCOPE_KEY = 'coolify_platform_mail_server';
746
+ const PLATFORM_MAIL_PROJECT_SCOPE_KEY = 'coolify_project_platform_mail';
747
+ const PLATFORM_MAIL_APP_SCOPE_KEY = 'coolify_platform_mail_app';
748
+ const PLATFORM_MAIL_TUNNEL_SCOPE_KEY = 'cloudflare_platform_mail_tunnel';
749
+ /**
750
+ * Recorded once the app has been deployed at least once -- same role as
751
+ * `PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY`, kept for parity though nothing
752
+ * here reads it back the way `ensureMasterkey` does: losing
753
+ * `STALWART_RECOVERY_ADMIN` is not destructive the same way losing ZITADEL's
754
+ * masterkey is.
755
+ */
756
+ const PLATFORM_MAIL_DEPLOYED_SCOPE_KEY = 'stalwart_platform_mail_deployed';
757
+ /**
758
+ * The hostname and image tag the last successful deploy used, recorded right
759
+ * after that deploy. `provisionPlatformMail` reads these (not the applied keys
760
+ * the planner reads, which stay last) to decide on a redeploy, so a retry after
761
+ * a later failure does not stop the running server again.
762
+ */
763
+ const PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY = 'mail_deployed_hostname';
764
+ const PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY = 'mail_deployed_git_ref';
765
+ /**
766
+ * The fixed description Stalwart's admin API key is minted under
767
+ * (`StalwartClient.createApiKey`'s own signature returns only the secret,
768
+ * never an id) -- what a human revokes it by in Stalwart's own admin UI if
769
+ * it is ever lost between mint and delivery, and what
770
+ * {@link PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY} records in that case.
771
+ */
772
+ export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
773
+ /**
774
+ * #169: makes `coolify:app` keyed {@link PLATFORM_MAIL_KEY} true -- Stalwart
775
+ * on Coolify with its own persistent storage, reached at
776
+ * `platform.mail.hostname` through a Cloudflare tunnel for web, JMAP and its
777
+ * management API (mirroring #168's ZITADEL/login apps: TLS terminated at
778
+ * Cloudflare, plain HTTP behind the tunnel) and at `mx.<apex>` over a direct,
779
+ * unproxied `A` record to the Coolify server's own address for SMTP -- raw
780
+ * SMTP cannot ride Cloudflare's HTTP-only tunnel the way a web app does,
781
+ * which is exactly why #169's own "Human steps" asks a person to open ports
782
+ * 25, 465 and 587 on that server's host firewall. Then, once the instance
783
+ * answers, the OIDC directory against the platform's own sign-in and
784
+ * foundry's admin API key, minted once and handed to
785
+ * {@link PlatformMailDeps.deliverSecret}.
786
+ *
787
+ * Every part is safe to run again after a throw partway, the same rules
788
+ * {@link provisionPlatformAuth} follows: each id is recorded (and persisted)
789
+ * as soon as its resource exists, the app is deployed on every run that
790
+ * needs it, and the admin key is delivered before the guard that skips the
791
+ * mint is recorded. Unlike ZITADEL's `ZITADEL_ADMIN_PASSWORD`, the recovery
792
+ * credential this step generates (`STALWART_RECOVERY_ADMIN`) is read back
793
+ * from Coolify's own env store on every run rather than held only in memory
794
+ * -- `CoolifyPort.listEnvs`'s own doc comment says these values come back
795
+ * live -- so a retry after a delivery failure re-authenticates with the same
796
+ * credential instead of losing it.
797
+ *
798
+ * **Why not `@wtfalch/email/postmaster`'s `mailObjects`/`applyObjects`**, the
799
+ * instance-bootstrap helper `wtfalch-manage` already uses for the hand-built
800
+ * `mail.wtfalch.dev`: that plan also wires an `AcmeProvider` and `DnsServer`
801
+ * for Stalwart's own DNS-01 ACME challenge, and an `MtaRoute` that relays
802
+ * every outbound message through Resend -- the estate moved transactional
803
+ * mail off Resend onto Lettermint before this issue (`@wtfalch/mailer` ADR
804
+ * 0006), and this step provisions no Cloudflare DNS token for Stalwart's own
805
+ * ACME either. Calling it as-is would mint an outbound relay this platform
806
+ * does not want and a credential requirement this issue does not cover, so
807
+ * {@link PlatformMailAdminPort.applySettings} is a narrower, hand-built
808
+ * settings write instead: the default hostname and the OIDC directory only.
809
+ *
810
+ * Known gaps, left for review (like #168's own "the PR stays open for
811
+ * William's review"):
812
+ * - **No ACME/TLS for the SMTP listener.** `mx.<apex>` answers with
813
+ * Stalwart's own self-signed certificate until a follow-up wires a real
814
+ * one (`mailObjects`'s `AcmeProvider`/`DnsServer` shape, minus the Resend
815
+ * relay it also carries).
816
+ * - **`oidcAudience` is supplied, not resolved.** #168 mints foundry's own
817
+ * ZITADEL admin machine user but registers no resource API for Stalwart's
818
+ * access-token `aud` check; whoever supplies
819
+ * {@link PlatformMailDeps.oidcAudience} must have registered one by hand
820
+ * until that is built.
821
+ * - **The org step `coolify:mail`'s `MAIL_APEX`/`MAIL_HOST` constants
822
+ * (`executor.ts`) still point at the hand-built `wtfalch.dev` instance,
823
+ * not this one**, and the Lettermint team token it reads still comes from
824
+ * wherever the host already reads it, not held keys. Both need
825
+ * `StepExecutionContext` to carry the platform, which `plan-apply.ts`'s
826
+ * own doc comment already scoped out of #167 as more than a small change
827
+ * -- a follow-up issue, not this one.
828
+ */
829
+ async function provisionPlatformMail(context, deps) {
830
+ const { platform } = context;
831
+ const hostname = platform.mail.hostname;
832
+ const mailHost = platformMailMxHost(platform.apex);
833
+ const imageTag = platform.mail.gitRef;
834
+ const recorded = context.recordedIds();
835
+ const runtime = deps.runtime ?? {};
836
+ const sleep = runtime.sleep ?? defaultSleep;
837
+ const record = async (key, id) => {
838
+ context.recordScopeId(key, id);
839
+ await context.persist?.();
840
+ };
841
+ let projectUuid = recorded[PLATFORM_MAIL_PROJECT_SCOPE_KEY];
842
+ if (!projectUuid) {
843
+ const found = await deps.coolify.findProjectByName(PLATFORM_MAIL_PROJECT);
844
+ projectUuid = found
845
+ ? found.uuid
846
+ : (await deps.coolify.createProject(PLATFORM_MAIL_PROJECT)).uuid;
847
+ await record(PLATFORM_MAIL_PROJECT_SCOPE_KEY, projectUuid);
848
+ }
849
+ const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
850
+ // ---- the Stalwart app itself ---------------------------------------------
851
+ const imageTagBlocked = mailImageTagBlocked(imageTag);
852
+ if (imageTagBlocked)
853
+ throw new Error(imageTagBlocked);
854
+ // One server for both the app and the MX record: recorded the first time,
855
+ // read back after, so a later run never points `mx.<apex>` somewhere the
856
+ // app is not.
857
+ const server = await resolveMailServer(deps.coolify, recorded[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
858
+ await record(PLATFORM_MAIL_SERVER_SCOPE_KEY, server.uuid);
859
+ const serverIpBlocked = mailServerIpBlocked(server.ip);
860
+ if (serverIpBlocked)
861
+ throw new Error(serverIpBlocked);
862
+ const app = await findOrCreatePlatformApp(deps.coolify, PLATFORM_MAIL_APP_NAME, environment, async () => {
863
+ return deps.coolify.createDockerImageApplication({
864
+ project_uuid: projectUuid,
865
+ server_uuid: server.uuid,
866
+ environment_name: environment.name,
867
+ environment_uuid: environment.uuid,
868
+ docker_registry_image_name: STALWART_IMAGE,
869
+ docker_registry_image_tag: imageTag,
870
+ ports_exposes: STALWART_PORTS_EXPOSES,
871
+ ports_mappings: STALWART_PORTS_MAPPINGS,
872
+ name: PLATFORM_MAIL_APP_NAME,
873
+ description: `Stalwart for the platform's own mail (#169, ${STALWART_IMAGE}:${imageTag})`,
874
+ instant_deploy: false,
875
+ });
876
+ });
877
+ const appId = app.appId;
878
+ await record(PLATFORM_MAIL_APP_SCOPE_KEY, appId);
879
+ // Set on every run, not only after a create -- same rule `provisionPlatformAuth` follows for its own apps.
880
+ // The image tag too: a declared release change reaches the app here.
881
+ await deps.coolify.updateApplication(appId, {
882
+ custom_internal_name: PLATFORM_MAIL_INTERNAL_NAME,
883
+ docker_registry_image_tag: imageTag,
884
+ });
885
+ // #177: org steps (an org's own `coolify:mail`/`coolify:agora`) read this
886
+ // back instead of a literal, the same way files/ai's own internal
887
+ // addresses are recorded.
888
+ await record(PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, `http://${PLATFORM_MAIL_INTERNAL_NAME}:${STALWART_HTTP_PORT}`);
889
+ // ---- storage ---------------------------------------------------------------
890
+ const storages = await deps.coolify.listStorages(appId);
891
+ const hasDataVolume = (storages.persistent_storages ?? []).some((s) => s.mount_path === STALWART_DATA_MOUNT);
892
+ if (!hasDataVolume) {
893
+ await deps.coolify.createPersistentStorage(appId, {
894
+ name: `${PLATFORM_MAIL_APP_NAME}-data`,
895
+ mount_path: STALWART_DATA_MOUNT,
896
+ });
897
+ }
898
+ // ---- env ---------------------------------------------------------------------
899
+ // Generated once; unlike ZITADEL's masterkey this is never re-derived from
900
+ // "is the database already in use", because losing it is not destructive
901
+ // the same way -- worst case a later apply cannot read it back and the
902
+ // admin-key mint below throws, naming exactly that.
903
+ const recoveryGenerated = await ensureGeneratedEnv(deps.coolify, appId, 'STALWART_RECOVERY_ADMIN', () => `${STALWART_RECOVERY_USER}:${randomBytes(18).toString('base64')}`);
904
+ const envs = [
905
+ { key: 'STALWART_PUBLIC_URL', value: `https://${hostname}` },
906
+ ];
907
+ if (recoveryGenerated)
908
+ envs.push({ key: 'STALWART_RECOVERY_ADMIN', value: recoveryGenerated });
909
+ const envsChanged = await envsDiffer(deps.coolify, appId, envs);
910
+ await deps.coolify.bulkUpsertEnvs(appId, envs);
911
+ const declaredChanged = recorded[PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY] !== hostname ||
912
+ recorded[PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY] !== imageTag;
913
+ if (app.justCreated || !app.running || envsChanged || declaredChanged) {
914
+ // A container still up from before would answer the readiness probe with
915
+ // the old deployment's health. Stop it and see it stopped first, so the
916
+ // wait below only ever reads the new deployment.
917
+ if (!app.justCreated && app.running) {
918
+ await deps.coolify.raw('POST', `/applications/${appId}/stop`);
919
+ await waitForMailStopped(deps.coolify, appId, runtime, sleep);
920
+ }
921
+ await deps.coolify.deploy(appId);
922
+ await record(PLATFORM_MAIL_DEPLOYED_SCOPE_KEY, appId);
923
+ // Right after the deploy, so a later failure (tunnel, DNS, readiness,
924
+ // settings, mint) does not make the next run stop and redeploy the app.
925
+ await record(PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY, hostname);
926
+ await record(PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY, imageTag);
927
+ }
928
+ // ---- tunnel and DNS: web/JMAP/management ------------------------------------
929
+ const existingTunnel = await deps.cloudflare.findTunnelByName(PLATFORM_MAIL_TUNNEL_NAME);
930
+ const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(PLATFORM_MAIL_TUNNEL_NAME));
931
+ await record(PLATFORM_MAIL_TUNNEL_SCOPE_KEY, tunnel.id);
932
+ await deps.cloudflare.putTunnelConfig(tunnel.id, {
933
+ ingress: [
934
+ { hostname, service: `http://${PLATFORM_MAIL_INTERNAL_NAME}:${STALWART_HTTP_PORT}` },
935
+ { service: 'http_status:404' },
936
+ ],
937
+ });
938
+ // #197: without a connector, `hostname` answers with a Cloudflare error.
939
+ await ensureCloudflaredConnector({
940
+ coolify: deps.coolify,
941
+ cloudflare: deps.cloudflare,
942
+ tunnelId: tunnel.id,
943
+ appName: PLATFORM_MAIL_CLOUDFLARED_APP_NAME,
944
+ projectUuid,
945
+ environment,
946
+ // The Stalwart app's own server, so the connector shares its Docker network.
947
+ resolveServer: async () => server,
948
+ recordedConnectorAppId: recorded[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] ?? null,
949
+ recordAppId: (appId) => record(PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, appId),
950
+ });
951
+ const zoneId = recorded[apexZoneKey(platform.apex)] ?? (await exactZone(deps.cloudflare, platform.apex))?.id;
952
+ if (!zoneId) {
953
+ throw new Error(`platform:mail: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
954
+ }
955
+ const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
956
+ const [dnsRecord] = await deps.cloudflare.findDnsRecord(zoneId, hostname, 'CNAME');
957
+ if (!dnsRecord) {
958
+ await deps.cloudflare.createDnsRecord(zoneId, {
959
+ type: 'CNAME',
960
+ name: hostname,
961
+ content: dnsTarget,
962
+ proxied: true,
963
+ });
964
+ }
965
+ else if (dnsRecord.content !== dnsTarget) {
966
+ await deps.cloudflare.updateDnsRecord(zoneId, dnsRecord.id, {
967
+ type: 'CNAME',
968
+ name: hostname,
969
+ content: dnsTarget,
970
+ proxied: true,
971
+ });
972
+ }
973
+ // ---- the MX hostname: a direct, unproxied A record --------------------------
974
+ const serverIp = server.ip; // checked by mailServerIpBlocked above
975
+ const [mxRecord] = await deps.cloudflare.findDnsRecord(zoneId, mailHost, 'A');
976
+ if (!mxRecord) {
977
+ await deps.cloudflare.createDnsRecord(zoneId, {
978
+ type: 'A',
979
+ name: mailHost,
980
+ content: serverIp,
981
+ proxied: false,
982
+ });
983
+ }
984
+ else if (mxRecord.content !== serverIp) {
985
+ await deps.cloudflare.updateDnsRecord(zoneId, mxRecord.id, {
986
+ type: 'A',
987
+ name: mailHost,
988
+ content: serverIp,
989
+ proxied: false,
990
+ });
991
+ }
992
+ // ---- OIDC directory and foundry's own admin API key, once --------------------
993
+ if (!recorded[PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY]) {
994
+ const lost = recorded[PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY];
995
+ if (lost) {
996
+ 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`);
997
+ }
998
+ await waitForMailInstance(`https://${hostname}${STALWART_READY_PATH}`, runtime, sleep);
999
+ const recoveryEnv = (await deps.coolify.listEnvs(appId)).find((e) => e.key === 'STALWART_RECOVERY_ADMIN' && !e.is_preview);
1000
+ if (!recoveryEnv) {
1001
+ throw new Error('platform:mail: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to mint the admin key');
1002
+ }
1003
+ const password = recoveryEnv.value.slice(`${STALWART_RECOVERY_USER}:`.length);
1004
+ try {
1005
+ const admin = deps.stalwartForInstance({
1006
+ url: `https://${hostname}`,
1007
+ user: STALWART_RECOVERY_USER,
1008
+ password,
1009
+ });
1010
+ await admin.applySettings({
1011
+ mailHost,
1012
+ oidc: { issuerUrl: `https://${platform.auth.hostname}`, audience: deps.oidcAudience },
1013
+ });
1014
+ // Recorded and persisted before the secret leaves this function: if
1015
+ // delivery fails for good, the next plan reports the lost key rather
1016
+ // than skipping or minting again.
1017
+ await record(PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION);
1018
+ const secret = await admin.createApiKey(PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, 'inherit');
1019
+ const attempts = runtime.deliverAttempts ?? 3;
1020
+ for (let attempt = 1;; attempt += 1) {
1021
+ try {
1022
+ await deps.deliverSecret('stalwart:platform:admin', secret);
1023
+ break;
1024
+ }
1025
+ catch {
1026
+ if (attempt >= attempts) {
1027
+ throw new Error(`platform:mail: the admin API key ("${PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION}") was minted but could not be delivered after ${attempts} tries`);
1028
+ }
1029
+ await sleep(1000 * attempt);
1030
+ }
1031
+ }
1032
+ await record(PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, appId);
1033
+ }
1034
+ catch (error) {
1035
+ throw redactedMailError(error, [password]);
1036
+ }
1037
+ }
1038
+ // Last, after every write the applied values depend on, so a run that
1039
+ // failed halfway plans again.
1040
+ await record(PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
1041
+ await record(PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, imageTag);
1042
+ return { resourceId: appId };
1043
+ }
1044
+ /** Why the Stalwart image tag cannot be used, or `null`. A floating tag cannot be re-pulled by comparing tags. */
1045
+ function mailImageTagBlocked(tag) {
1046
+ if (tag.trim() === '' || tag.trim().toLowerCase() === 'latest') {
1047
+ 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`;
1048
+ }
1049
+ return null;
1050
+ }
1051
+ /** Why the server's address cannot be the MX A record's content, or `null`. */
1052
+ function mailServerIpBlocked(ip) {
1053
+ if (!ip) {
1054
+ 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";
1055
+ }
1056
+ if (!isIPv4(ip)) {
1057
+ 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`;
1058
+ }
1059
+ return null;
1060
+ }
1061
+ /** The recorded server when one is recorded and still listed, else the platform's usual pick. */
1062
+ async function resolveMailServer(coolify, recordedUuid) {
1063
+ if (!recordedUuid)
1064
+ return resolvePlatformServer(coolify);
1065
+ const server = (await coolify.listServers()).find((s) => s.uuid === recordedUuid);
1066
+ if (!server) {
1067
+ throw new Error(`platform:mail: the Coolify server ${recordedUuid} the mail app was created on is no longer listed`);
1068
+ }
1069
+ return server;
1070
+ }
1071
+ /**
1072
+ * The mail step's own reasons it cannot run yet, as fixed text: missing
1073
+ * dependencies or audience, a floating image tag, a server with no usable
1074
+ * address. Skipped once the step is applied at the declared values, so a
1075
+ * live app is not blocked by a later Coolify change to the server's address.
1076
+ */
1077
+ async function mailBlockedReason(platform, mail, recordedIds) {
1078
+ if (!mail) {
1079
+ return "the platform's mail server is blocked, not planned: the host built the platform executor without its mail dependencies";
1080
+ }
1081
+ if (recordedIds[`coolify:app:${PLATFORM_MAIL_KEY}`] &&
1082
+ recordedIds[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] === platform.mail.hostname &&
1083
+ recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === platform.mail.gitRef &&
1084
+ recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY]) {
1085
+ return null;
1086
+ }
1087
+ if (!mail.oidcAudience || mail.oidcAudience.trim() === '') {
1088
+ 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";
1089
+ }
1090
+ const tagBlocked = mailImageTagBlocked(platform.mail.gitRef);
1091
+ if (tagBlocked)
1092
+ return tagBlocked;
1093
+ try {
1094
+ const server = await resolveMailServer(mail.coolify, recordedIds[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
1095
+ return mailServerIpBlocked(server.ip);
1096
+ }
1097
+ catch {
1098
+ return "the platform's mail server is blocked, not planned: no usable Coolify server could be resolved for it";
1099
+ }
1100
+ }
1101
+ /** Polls until Coolify reports the mail app stopped, so a healthy answer read afterwards is the new deployment's. */
1102
+ async function waitForMailStopped(coolify, appId, runtime, sleep) {
1103
+ const now = runtime.now ?? Date.now;
1104
+ const timeout = runtime.readyTimeoutMs ?? 300_000;
1105
+ const interval = runtime.readyIntervalMs ?? 5_000;
1106
+ const deadline = now() + timeout;
1107
+ for (;;) {
1108
+ const { status } = await coolify.getApplication(appId);
1109
+ if (!status || !isRunningOrStarting(status))
1110
+ return;
1111
+ if (now() >= deadline) {
1112
+ throw new Error(`platform:mail: the Stalwart app ${appId} did not stop within ${Math.round(timeout / 1000)}s (last status ${status ?? 'unknown'}); apply again`);
1113
+ }
1114
+ await sleep(interval);
1115
+ }
1116
+ }
1117
+ /** Polls `url` until it answers 2xx, or throws once `readyTimeoutMs` has passed. */
1118
+ async function waitForMailInstance(url, runtime, sleep) {
1119
+ const fetchImpl = runtime.fetch ?? globalThis.fetch;
1120
+ const now = runtime.now ?? Date.now;
1121
+ const timeout = runtime.readyTimeoutMs ?? 300_000;
1122
+ const interval = runtime.readyIntervalMs ?? 5_000;
1123
+ const deadline = now() + timeout;
1124
+ for (;;) {
1125
+ try {
1126
+ const response = await fetchImpl(url);
1127
+ if (response.ok)
1128
+ return;
1129
+ }
1130
+ catch {
1131
+ // Not up yet: DNS, the tunnel or the container.
1132
+ }
1133
+ if (now() >= deadline) {
1134
+ throw new Error(`platform:mail: the new Stalwart instance did not answer at ${url} within ${Math.round(timeout / 1000)}s; retry this apply`);
1135
+ }
1136
+ await sleep(interval);
1137
+ }
1138
+ }
1139
+ function redactedMailError(error, secrets) {
1140
+ const original = error instanceof Error ? error : new Error(String(error));
1141
+ const message = redactSecrets(original.message, secrets);
1142
+ const stack = redactSecrets(original.stack ?? message, secrets);
1143
+ if (message === original.message && stack === original.stack)
1144
+ return original;
1145
+ const clean = new Error(message);
1146
+ clean.name = original.name;
1147
+ clean.stack = stack;
1148
+ return clean;
1149
+ }
1150
+ const PLATFORM_ARCHON_PROJECT = 'platform';
1151
+ const PLATFORM_ARCHON_APP_NAME = 'platform-archon';
1152
+ const PLATFORM_ARCHON_DB_NAME = 'platform-archon-db';
1153
+ /**
1154
+ * The database Coolify creates for Archon. Archon's own
1155
+ * `drizzle/0001_runtime_role.sql` names its runtime role
1156
+ * `current_database() || '_rt'`, so {@link ARCHON_RUNTIME_ROLE} follows.
1157
+ */
1158
+ const ARCHON_POSTGRES_DB = 'archon';
1159
+ const ARCHON_RUNTIME_ROLE = `${ARCHON_POSTGRES_DB}_rt`;
1160
+ /** Fixed so the tunnel addresses the container directly (see `provisionPlatformAuth`). */
1161
+ const PLATFORM_ARCHON_INTERNAL_NAME = 'platform-archon';
1162
+ const PLATFORM_ARCHON_TUNNEL_NAME = 'platform-archon-tunnel';
1163
+ /** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_ARCHON_TUNNEL_NAME}. */
1164
+ const PLATFORM_ARCHON_CLOUDFLARED_APP_NAME = 'platform-archon-cloudflared';
1165
+ /** https://github.com/wtfalch/archon: one Dockerfile at its root, `runner` stage on port 3000. */
1166
+ const ARCHON_REPO = 'wtfalch/archon';
1167
+ const ARCHON_PORT = '3000';
1168
+ /**
1169
+ * The name of foundry's own Cloudflare Access app in front of Archon. Same
1170
+ * `foundry:<key>` convention `executor.ts`'s `cloudflareAccess` uses for an
1171
+ * org (`foundry:<orgId>`), with the platform's step key in the org id's place.
1172
+ */
1173
+ export const PLATFORM_ARCHON_ACCESS_APP_NAME = `foundry:${PLATFORM_ARCHON_KEY}`;
1174
+ /** The ZITADEL project and OIDC app Archon's sign-in client lives in. */
1175
+ const PLATFORM_ARCHON_ZITADEL_PROJECT = 'archon';
1176
+ const PLATFORM_ARCHON_ZITADEL_APP = 'archon';
1177
+ const PLATFORM_ARCHON_PROJECT_SCOPE_KEY = 'coolify_project_platform_archon';
1178
+ const PLATFORM_ARCHON_DB_SCOPE_KEY = 'coolify_platform_archon_database';
1179
+ const PLATFORM_ARCHON_TUNNEL_SCOPE_KEY = 'cloudflare_platform_archon_tunnel';
1180
+ const PLATFORM_ARCHON_ACCESS_SCOPE_KEY = 'cloudflare_platform_archon_access_app';
1181
+ /** The platform's own ZITADEL org (`foundry`, the one `provisionPlatformAuth` mints its admin user in). */
1182
+ export const PLATFORM_ZITADEL_ORG_SCOPE_KEY = 'zitadel_platform_org';
1183
+ const PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_platform_archon_project';
1184
+ const PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY = 'zitadel_platform_archon_app';
1185
+ /** The hostname the sign-in client's redirect URIs were made for; a change makes a new client. */
1186
+ const PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY = 'zitadel_platform_archon_client_hostname';
1187
+ /** Archon's OIDC client id: public, not a secret, so a plain scope id. */
1188
+ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_client_id';
1189
+ /**
1190
+ * #175: makes `coolify:app` keyed {@link PLATFORM_ARCHON_KEY} true: Archon
1191
+ * from https://github.com/wtfalch/archon on `platform.archon.gitRef`, its own
1192
+ * Postgres, a sign-in client in the platform's own ZITADEL, a Cloudflare
1193
+ * Access app that lets only {@link PlatformArchonDeps.accessEmails} through,
1194
+ * and a tunnel plus DNS record at `platform.archon.hostname`.
1195
+ *
1196
+ * One Coolify app, no separate migrate job: Archon's `Dockerfile` has no
1197
+ * migration stage (its closing note says the old one was removed), and its
1198
+ * `runner` starts with `scripts/migrate-and-start.sh`, which applies
1199
+ * `drizzle/*.sql` as `DATABASE_URL_OWNER`, creates the `<database>_rt` runtime
1200
+ * role, sets its password from `DATABASE_URL`, unsets the owner URL and only
1201
+ * then starts the server.
1202
+ *
1203
+ * Order matters in two places. The Access app is created before the DNS
1204
+ * record, so the hostname never resolves without the gate in front of it
1205
+ * (Archon's `docs/operator-deployment.md`: "Install the Access gate before
1206
+ * publishing DNS"). The sign-in client is created before the env push, so
1207
+ * `AUTH_CLIENT_ID` is in the first deploy.
1208
+ *
1209
+ * Env written from the applied platform and held keys, never pasted:
1210
+ * - `APP_URL`, `AUTH_ISSUER` (`https://<auth.hostname>`),
1211
+ * `AUTH_ORGANIZATION_ID` and `AUTH_CLIENT_ID` (both from ZITADEL, this run),
1212
+ * `AUTH_COOKIE_SECRET` (generated once, never rotated).
1213
+ * - `DATABASE_URL_OWNER` (Coolify's `internal_db_url`) and `DATABASE_URL`
1214
+ * (the `archon_rt` runtime role, its password minted once, never rotated,
1215
+ * the same rule `executor.ts`'s `ensureCmsRuntimeUrl` follows). A URL that
1216
+ * does not parse fails the step without printing it.
1217
+ * - For each of files, ai, integrations and jobs that the platform declares
1218
+ * enabled: its `https://<hostname>` URL, and its management key when
1219
+ * {@link PlatformArchonDeps.heldKeys} holds one.
1220
+ *
1221
+ * The OIDC client is ZITADEL's public web client (authorization code with
1222
+ * PKCE, no client secret: `createOidcApp` never returns one, see
1223
+ * `executor.ts`'s `zitadelApp`), which is what Archon's `@wtfalch/auth`
1224
+ * sign-in reads -- it takes `AUTH_CLIENT_ID` and no client secret. So there
1225
+ * is no one-time secret to hand to `deliverSecret` here. The two values this
1226
+ * step does generate (cookie secret, runtime-role password) go straight into
1227
+ * the app's env and nowhere else: never returned, recorded, logged or put in
1228
+ * an error.
1229
+ *
1230
+ * Known gaps, left for review:
1231
+ * - `AUTH_APP_KEY`: the `auth.wtfalch.dev` broker's per-organisation
1232
+ * registry key. The platform's new sign-in has no broker registration API,
1233
+ * the same gap `executor.ts`'s `pushAppEnv` names for an org's app.
1234
+ * - `AUTHZ_BOOTSTRAP_OPERATOR`: the first operator's ZITADEL subject id. A
1235
+ * person has to exist in the new instance first; not something foundry
1236
+ * mints.
1237
+ * - Foundry's own database URL. Archon runs foundry in its panel, but foundry
1238
+ * does not know its own connection string at plan/apply time: that is host
1239
+ * config, not something `DeclaredPlatform` or held keys carry.
1240
+ * - The refs and scope ids Archon reads from the files, ai and integrations
1241
+ * steps are exported by those steps' own modules. A
1242
+ * missing one blocks the plan (`blockedSteps`); it is never skipped. The
1243
+ * env names `AI_*`, `INTEGRATIONS_*`, `JOBS_URL` and `JOBS_ADMIN_TOKEN` are
1244
+ * this step's proposal; Archon reads only `FILES_URL` and
1245
+ * `FILES_MANAGEMENT_KEY` of these today. Files' signed-assertion identity
1246
+ * (`FILES_MANAGEMENT_ISSUER`, `_KEY_ID`, `_PRIVATE_KEY`, `_SCOPE`) and
1247
+ * `FILES_ORGANISATION_ID` are not wired.
1248
+ * - The step plans again when the hostname, `gitRef` or the Access policy's
1249
+ * include list changes. Env for a service enabled later is not pushed until
1250
+ * one of those changes.
1251
+ * - No API updates an app's Access policy, so a changed include list closes
1252
+ * the tunnel hostname, deletes the Access app and builds it again.
1253
+ * - #197: a `cloudflared` connector runs for this tunnel, as in `provisionPlatformAuth`.
1254
+ * - Retiring `operator.wtfalch.dev` stays a human step.
1255
+ */
1256
+ async function provisionPlatformArchon(context, deps) {
1257
+ const { platform } = context;
1258
+ const hostname = platform.archon.hostname;
1259
+ const gitRef = platform.archon.gitRef;
1260
+ const recorded = context.recordedIds();
1261
+ const issuer = `https://${platform.auth.hostname}`;
1262
+ const appliedGitRef = recorded[PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY];
1263
+ if (deps.accessEmails.length === 0) {
1264
+ throw new Error('platform:archon: no Access emails given; refusing to put Archon behind an Access app nobody can pass');
1265
+ }
1266
+ // The sign-in client below is created inside the platform's own ZITADEL,
1267
+ // which `platform:auth` builds. The planner orders that step first and
1268
+ // blocks this one while auth is off; this is the same rule for a caller
1269
+ // that reaches the step anyway.
1270
+ if (!platform.auth.enabled || !recorded[`coolify:app:${PLATFORM_AUTH_KEY}`]) {
1271
+ 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");
1272
+ }
1273
+ const record = async (key, id) => {
1274
+ context.recordScopeId(key, id);
1275
+ await context.persist?.();
1276
+ };
1277
+ // Every service the platform declares must have delivered what Archon reads.
1278
+ // The planner blocks first; this is the same rule for a caller that reaches
1279
+ // the step anyway, and it is where the values are read for the env below.
1280
+ const inputs = await collectArchonServiceInputs(platform, deps, recorded);
1281
+ if ('blocked' in inputs)
1282
+ throw new Error(`platform:archon: ${inputs.blocked}`);
1283
+ // ---- Coolify project and database -----------------------------------------
1284
+ let projectUuid = recorded[PLATFORM_ARCHON_PROJECT_SCOPE_KEY];
1285
+ if (!projectUuid) {
1286
+ const found = await deps.coolify.findProjectByName(PLATFORM_ARCHON_PROJECT);
1287
+ projectUuid = found
1288
+ ? found.uuid
1289
+ : (await deps.coolify.createProject(PLATFORM_ARCHON_PROJECT)).uuid;
1290
+ await record(PLATFORM_ARCHON_PROJECT_SCOPE_KEY, projectUuid);
1291
+ }
1292
+ const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
1293
+ let dbId = recorded[PLATFORM_ARCHON_DB_SCOPE_KEY];
1294
+ if (!dbId) {
1295
+ const server = await resolvePlatformServer(deps.coolify);
1296
+ const created = await deps.coolify.createPostgresDatabase({
1297
+ project_uuid: projectUuid,
1298
+ server_uuid: server.uuid,
1299
+ environment_name: environment.name,
1300
+ environment_uuid: environment.uuid,
1301
+ name: PLATFORM_ARCHON_DB_NAME,
1302
+ postgres_db: ARCHON_POSTGRES_DB,
1303
+ postgres_user: ARCHON_POSTGRES_DB,
1304
+ });
1305
+ dbId = created.uuid;
1306
+ await record(PLATFORM_ARCHON_DB_SCOPE_KEY, dbId);
1307
+ }
1308
+ const db = await deps.coolify.getDatabase(dbId);
1309
+ if (!db.internal_db_url) {
1310
+ throw new Error('coolify:app for the platform (archon): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
1311
+ }
1312
+ // ---- the app ----------------------------------------------------------------
1313
+ const app = await findOrCreatePlatformApp(deps.coolify, PLATFORM_ARCHON_APP_NAME, environment, async () => {
1314
+ const server = await resolvePlatformServer(deps.coolify);
1315
+ const githubApp = await deps.coolify.requireGithubApp();
1316
+ return deps.coolify.createGithubAppApplication({
1317
+ project_uuid: projectUuid,
1318
+ server_uuid: server.uuid,
1319
+ environment_name: environment.name,
1320
+ environment_uuid: environment.uuid,
1321
+ github_app_uuid: githubApp.uuid,
1322
+ git_repository: ARCHON_REPO,
1323
+ git_branch: gitRef,
1324
+ build_pack: 'dockerfile',
1325
+ name: PLATFORM_ARCHON_APP_NAME,
1326
+ });
1327
+ });
1328
+ const appId = app.appId;
1329
+ // Set on every run: the tunnel addresses the app by this name, and a throw
1330
+ // right after the create must not leave it unnamed for good.
1331
+ // `git_branch` is patched too, so a changed `gitRef` reaches the app; the
1332
+ // deploy below is decided from the last applied `gitRef`.
1333
+ await deps.coolify.updateApplication(appId, {
1334
+ custom_internal_name: PLATFORM_ARCHON_INTERNAL_NAME,
1335
+ git_branch: gitRef,
1336
+ });
1337
+ // ---- sign-in client in the platform's own ZITADEL -------------------------
1338
+ const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
1339
+ const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
1340
+ if (recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY] !== org.id) {
1341
+ await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, org.id);
1342
+ }
1343
+ const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_ARCHON_ZITADEL_PROJECT);
1344
+ if (recorded[PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY] !== project.id) {
1345
+ await record(PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY, project.id);
1346
+ }
1347
+ // The client's redirect URIs name the hostname, and no call updates them, so
1348
+ // a client made for another hostname is deleted and made again. Its id is
1349
+ // public and there is no secret to lose.
1350
+ let clientId = recorded[PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY] === hostname
1351
+ ? recorded[PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY]
1352
+ : undefined;
1353
+ if (!clientId) {
1354
+ // An app under this name with no usable client id is one whose create
1355
+ // response was lost, or one made for another hostname: its client id
1356
+ // cannot be read back (`listApps` does not carry it), so it is deleted and
1357
+ // made again, as `zitadelApp` does.
1358
+ const orphan = (await zitadel.listApps(org.id, project.id)).find((a) => a.name === PLATFORM_ARCHON_ZITADEL_APP &&
1359
+ a.appType === 'web' &&
1360
+ a.state !== 'APP_STATE_REMOVED');
1361
+ if (orphan) {
1362
+ try {
1363
+ await zitadel.deleteApp(org.id, project.id, orphan.id);
1364
+ }
1365
+ catch (error) {
1366
+ if (!isNotFoundError(error))
1367
+ throw error;
1368
+ }
1369
+ }
1370
+ const created = await zitadel.createOidcApp(org.id, project.id, {
1371
+ name: PLATFORM_ARCHON_ZITADEL_APP,
1372
+ appType: 'web',
1373
+ // `@wtfalch/auth`'s default callback path (Archon's own
1374
+ // `docs/operator-deployment.md`, step 4).
1375
+ redirectUris: [`https://${hostname}/auth/callback`],
1376
+ postLogoutRedirectUris: [`https://${hostname}/`],
1377
+ });
1378
+ context.recordScopeId(PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, created.appId);
1379
+ context.recordScopeId(PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY, hostname);
1380
+ await record(PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY, created.clientId);
1381
+ clientId = created.clientId;
1382
+ }
1383
+ // ---- env ------------------------------------------------------------------
1384
+ const envs = [
1385
+ { key: 'APP_URL', value: `https://${hostname}` },
1386
+ { key: 'AUTH_ISSUER', value: issuer },
1387
+ { key: 'AUTH_ORGANIZATION_ID', value: org.id },
1388
+ { key: 'AUTH_CLIENT_ID', value: clientId },
1389
+ { key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
1390
+ { key: 'REPORTING_SITE', value: 'archon' },
1391
+ ];
1392
+ const cookieSecret = await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_COOKIE_SECRET', () => randomBytes(32).toString('base64'));
1393
+ if (cookieSecret)
1394
+ envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
1395
+ const runtimeUrl = await ensureArchonRuntimeUrl(deps.coolify, appId, db.internal_db_url);
1396
+ if (runtimeUrl)
1397
+ envs.push({ key: 'DATABASE_URL', value: runtimeUrl });
1398
+ envs.push(...inputs.envs);
1399
+ const changed = await envsDiffer(deps.coolify, appId, envs);
1400
+ await deps.coolify.bulkUpsertEnvs(appId, envs);
1401
+ // ---- Cloudflare Access, before the hostname resolves ----------------------
1402
+ let gateClosed = Boolean(recorded[PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY]);
1403
+ const accessAppId = await ensureArchonAccess(deps.cloudflare, hostname, deps.accessEmails, async () => {
1404
+ // The Access app is about to be rebuilt: answer 404 for every hostname
1405
+ // meanwhile, so it is never served without the gate. The marker is
1406
+ // persisted first: a failure before the real ingress is written back
1407
+ // leaves it set, and the next plan repairs the step.
1408
+ gateClosed = true;
1409
+ await record(PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, '1');
1410
+ const tunnel = await deps.cloudflare.findTunnelByName(PLATFORM_ARCHON_TUNNEL_NAME);
1411
+ if (tunnel)
1412
+ await deps.cloudflare.putTunnelConfig(tunnel.id, {
1413
+ ingress: [{ service: 'http_status:404' }],
1414
+ });
1415
+ });
1416
+ if (recorded[PLATFORM_ARCHON_ACCESS_SCOPE_KEY] !== accessAppId) {
1417
+ await record(PLATFORM_ARCHON_ACCESS_SCOPE_KEY, accessAppId);
1418
+ }
1419
+ // ---- deploy ---------------------------------------------------------------
1420
+ if (app.justCreated ||
1421
+ !app.running ||
1422
+ changed ||
1423
+ appliedGitRef !== gitRef ||
1424
+ recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname) {
1425
+ await deps.coolify.deploy(appId);
1426
+ }
1427
+ // ---- tunnel and DNS ---------------------------------------------------------
1428
+ const existingTunnel = await deps.cloudflare.findTunnelByName(PLATFORM_ARCHON_TUNNEL_NAME);
1429
+ const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(PLATFORM_ARCHON_TUNNEL_NAME));
1430
+ if (recorded[PLATFORM_ARCHON_TUNNEL_SCOPE_KEY] !== tunnel.id) {
1431
+ await record(PLATFORM_ARCHON_TUNNEL_SCOPE_KEY, tunnel.id);
1432
+ }
1433
+ await deps.cloudflare.putTunnelConfig(tunnel.id, {
1434
+ ingress: [
1435
+ { hostname, service: `http://${PLATFORM_ARCHON_INTERNAL_NAME}:${ARCHON_PORT}` },
1436
+ { service: 'http_status:404' },
1437
+ ],
1438
+ });
1439
+ if (gateClosed) {
1440
+ await record(PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, '');
1441
+ }
1442
+ // #197: without a connector, `hostname` answers with a Cloudflare error.
1443
+ await ensureCloudflaredConnector({
1444
+ coolify: deps.coolify,
1445
+ cloudflare: deps.cloudflare,
1446
+ tunnelId: tunnel.id,
1447
+ appName: PLATFORM_ARCHON_CLOUDFLARED_APP_NAME,
1448
+ projectUuid,
1449
+ environment,
1450
+ resolveServer: () => resolvePlatformServer(deps.coolify),
1451
+ recordedConnectorAppId: recorded[PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY] ?? null,
1452
+ recordAppId: (appId) => record(PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, appId),
1453
+ });
1454
+ const zoneId = recorded[apexZoneKey(platform.apex)] ?? (await exactZone(deps.cloudflare, platform.apex))?.id;
1455
+ if (!zoneId) {
1456
+ throw new Error(`platform:archon: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
1457
+ }
1458
+ const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
1459
+ const [dnsRecord] = await deps.cloudflare.findDnsRecord(zoneId, hostname, 'CNAME');
1460
+ if (!dnsRecord) {
1461
+ await deps.cloudflare.createDnsRecord(zoneId, {
1462
+ type: 'CNAME',
1463
+ name: hostname,
1464
+ content: dnsTarget,
1465
+ proxied: true,
1466
+ });
1467
+ }
1468
+ else if (dnsRecord.content !== dnsTarget) {
1469
+ await deps.cloudflare.updateDnsRecord(zoneId, dnsRecord.id, {
1470
+ type: 'CNAME',
1471
+ name: hostname,
1472
+ content: dnsTarget,
1473
+ proxied: true,
1474
+ });
1475
+ }
1476
+ // Last, after every write the applied values depend on, so a run that failed
1477
+ // halfway plans again.
1478
+ await record(PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
1479
+ await record(PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
1480
+ return { resourceId: appId };
1481
+ }
1482
+ /**
1483
+ * `executor.ts`'s `cloudflareAccess`, for the platform's Archon: reuses only
1484
+ * an Access app named {@link PLATFORM_ARCHON_ACCESS_APP_NAME}, refuses to
1485
+ * adopt any other Access app already holding the hostname, and refuses when
1486
+ * its own app carries a policy foundry did not create, since that policy
1487
+ * could let others in.
1488
+ */
1489
+ async function ensureArchonAccess(cf, domain, emails, closeGate) {
1490
+ const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
1491
+ const foreign = onDomain.find((a) => a.name !== PLATFORM_ARCHON_ACCESS_APP_NAME);
1492
+ if (foreign) {
1493
+ throw new Error(`platform:archon: Access app '${foreign.name}' already holds ${domain}; foundry does not adopt it`);
1494
+ }
1495
+ const create = async () => {
1496
+ const created = await cf.createAccessApp({
1497
+ name: PLATFORM_ARCHON_ACCESS_APP_NAME,
1498
+ domain,
1499
+ type: 'self_hosted',
1500
+ });
1501
+ await cf.createAccessPolicy(created.id, archonAccessPolicy(emails));
1502
+ return created;
1503
+ };
1504
+ const existing = onDomain[0];
1505
+ if (!existing)
1506
+ return (await create()).id;
1507
+ const policies = await cf.listAccessPolicies(existing.id);
1508
+ const other = policies.find((p) => p.name !== ACCESS_POLICY_NAME);
1509
+ if (other) {
1510
+ throw new Error(`platform:archon: Access app ${existing.id} has policy '${other.name}' that foundry did not create`);
1511
+ }
1512
+ if (policies.length === 0) {
1513
+ await cf.createAccessPolicy(existing.id, archonAccessPolicy(emails));
1514
+ return existing.id;
1515
+ }
1516
+ if (policies.every((p) => sameEmails(p.include, emails)))
1517
+ return existing.id;
1518
+ // No call updates an app's policy: rebuild the app, hostname closed meanwhile.
1519
+ await closeGate();
1520
+ await cf.deleteAccessApp(existing.id);
1521
+ return (await create()).id;
1522
+ }
1523
+ function archonAccessPolicy(emails) {
1524
+ return {
1525
+ name: ACCESS_POLICY_NAME,
1526
+ decision: 'allow',
1527
+ include: emails.map((email) => ({ email: { email } })),
1528
+ };
1529
+ }
1530
+ /** Whether an Access policy's include list is exactly `emails` (case-insensitive) and nothing else. */
1531
+ function sameEmails(include, emails) {
1532
+ if (!Array.isArray(include))
1533
+ return false;
1534
+ const live = new Set();
1535
+ for (const entry of include) {
1536
+ const email = entry?.email?.email;
1537
+ if (typeof email !== 'string' || Object.keys(entry).length !== 1)
1538
+ return false;
1539
+ live.add(email.toLowerCase());
1540
+ }
1541
+ const declared = new Set(emails.map((e) => e.toLowerCase()));
1542
+ return live.size === declared.size && [...declared].every((e) => live.has(e));
1543
+ }
1544
+ /**
1545
+ * Whether Archon's Access app or policy is gone or no longer lets through
1546
+ * exactly the declared emails. A failed read is not drift: the next plan reads
1547
+ * again.
1548
+ */
1549
+ async function archonAccessDrifted(cf, domain, emails) {
1550
+ try {
1551
+ const app = (await cf.listAccessApps()).find((a) => a.domain === domain && a.name === PLATFORM_ARCHON_ACCESS_APP_NAME);
1552
+ if (!app)
1553
+ return true;
1554
+ const policies = await cf.listAccessPolicies(app.id);
1555
+ return !(policies.length > 0 && policies.every((p) => sameEmails(p.include, emails)));
1556
+ }
1557
+ catch {
1558
+ return false;
1559
+ }
1560
+ }
1561
+ /**
1562
+ * What Archon reads from each enabled service, gathered before anything is
1563
+ * built. Either everything is there and the env values come back, or the first
1564
+ * missing piece comes back as one fixed sentence naming a step, scope id or
1565
+ * held-key ref, never a value. Also read at plan time, so the text must not
1566
+ * vary between calls.
1567
+ */
1568
+ async function collectArchonServiceInputs(platform, deps, recorded) {
1569
+ const block = (why) => ({ blocked: `Archon is blocked, not planned: ${why}` });
1570
+ const envs = [];
1571
+ const heldByRef = new Map();
1572
+ const services = ['files', 'ai', 'integrations', 'jobs'].filter((s) => platform[s].enabled);
1573
+ if (services.length > 0 && !deps.heldKeys) {
1574
+ return block(`the host built the platform executor without a held-key reader, and ${services.join(', ')} ${services.length === 1 ? 'is' : 'are'} enabled`);
1575
+ }
1576
+ for (const service of services) {
1577
+ const needs = service === 'jobs'
1578
+ ? { stepKey: null, scopeKeys: [], refs: [JOBS_ADMIN_TOKEN_REF] }
1579
+ : ARCHON_SERVICE_INPUTS[service];
1580
+ if (needs.stepKey && !recorded[`coolify:app:${needs.stepKey}`]) {
1581
+ return block(`the ${service} step (${needs.stepKey}) is not applied yet`);
1582
+ }
1583
+ for (const scopeKey of needs.scopeKeys) {
1584
+ if (!recorded[scopeKey]) {
1585
+ return block(`the ${service} step has not recorded the scope id ${scopeKey}`);
1586
+ }
1587
+ }
1588
+ for (const ref of needs.refs) {
1589
+ let held;
1590
+ try {
1591
+ held = (await deps.heldKeys?.resolve(ref)) ?? null;
1592
+ }
1593
+ catch {
1594
+ // The cause is dropped in case it echoes a secret.
1595
+ return block(`the held key ${ref} could not be read`);
1596
+ }
1597
+ if (!held)
1598
+ return block(`the held key ${ref} is not held yet`);
1599
+ heldByRef.set(ref, held);
1600
+ }
1601
+ }
1602
+ const held = (ref) => heldByRef.get(ref);
1603
+ for (const service of services) {
1604
+ if (service === 'files') {
1605
+ 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]) });
1606
+ }
1607
+ else if (service === 'ai') {
1608
+ 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]) }, {
1609
+ key: 'AI_ORGANISATION_ID',
1610
+ value: recorded[PLATFORM_AI_ORGANISATION_SCOPE_KEY],
1611
+ });
1612
+ }
1613
+ else if (service === 'integrations') {
1614
+ const url = `https://${platform.integrations.hostname}`;
1615
+ envs.push({ key: 'INTEGRATIONS_URL', value: url }, {
1616
+ key: 'INTEGRATIONS_MANAGEMENT_PRIVATE_KEY',
1617
+ value: held(INTEGRATIONS_MANAGEMENT_KEY_REF),
1618
+ }, { key: 'INTEGRATIONS_MANAGEMENT_ISSUER', value: INTEGRATIONS_MANAGEMENT_ISSUER }, { key: 'INTEGRATIONS_MANAGEMENT_KEY_ID', value: INTEGRATIONS_MANAGEMENT_KEY_ID }, { key: 'INTEGRATIONS_MANAGEMENT_AUDIENCE', value: url });
1619
+ }
1620
+ else {
1621
+ envs.push({ key: 'JOBS_URL', value: `https://${platform.jobs.hostname}` }, { key: 'JOBS_ADMIN_TOKEN', value: held(JOBS_ADMIN_TOKEN_REF) });
1622
+ }
1623
+ }
1624
+ return { envs };
1625
+ }
1626
+ /**
1627
+ * `null` once the app already has a `DATABASE_URL`, never rotated: Archon's
1628
+ * `db-runtime-role.sh` sets the `archon_rt` password from this URL on every
1629
+ * boot while the previous container's pool is still open, so it must stay
1630
+ * the same across deploys. `executor.ts`'s `ensureCmsRuntimeUrl`, with
1631
+ * Archon's role name; see that method for why a parse failure is swallowed
1632
+ * (Node's `ERR_INVALID_URL` carries the whole input, credential included).
1633
+ * `base64url` fits `db-runtime-role.sh`'s password alphabet
1634
+ * (`A-Za-z0-9_.~-`).
1635
+ */
1636
+ async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
1637
+ const envs = await coolify.listEnvs(appId);
1638
+ if (envs.some((e) => e.key === 'DATABASE_URL' && !e.is_preview))
1639
+ return null;
1640
+ let host;
1641
+ let pathname;
1642
+ let search;
1643
+ try {
1644
+ ({ host, pathname, search } = new URL(dbInternalUrl));
1645
+ }
1646
+ catch {
1647
+ // Node's `ERR_INVALID_URL` carries the whole input, credential included, so
1648
+ // neither it nor the URL goes into this message.
1649
+ 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');
1650
+ }
1651
+ const password = randomBytes(24).toString('base64url');
1652
+ return `postgres://${ARCHON_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
1653
+ }
1654
+ /** The ref the AI management key is delivered under; the host's `AiOrganisationPort` adapter reads it back. */
1655
+ export const PLATFORM_AI_MANAGEMENT_KEY_REF = 'ai:platform:managementKey';
1656
+ /**
1657
+ * What foundry's management key may do: exactly the grants `ai`'s README
1658
+ * ("Archon's first key", by-hand form) gives archon's key, which are the
1659
+ * ones `ai:organisation`, `ai:budget`, `ai:key` and the org destroy call.
1660
+ */
1661
+ export const PLATFORM_AI_MANAGEMENT_PERMISSIONS = [
1662
+ 'ai.organisations:provision',
1663
+ 'ai.organisations:offer',
1664
+ 'ai.organisations:delete',
1665
+ 'ai.keys:issue',
1666
+ 'ai.budgets:update',
1667
+ ];
1668
+ /** The message {@link provisionPlatformAi} throws when no {@link AiManagementKeyMinter} was supplied. */
1669
+ export const PLATFORM_AI_MINTER_MISSING = "platform:ai: the AI host is deployed but foundry's AI management key cannot be minted: no AiManagementKeyMinter was supplied (PlatformAiDeps.managementKeyMinter). ai mints its first platform credential only through its keys:issue-archon script run against the database (apps/host/scripts/authz/bootstrap-archon.ts); it has no HTTP route for that, and CoolifyPort cannot run a command inside a container (#172)";
1670
+ const PLATFORM_AI_PROJECT = 'platform';
1671
+ const PLATFORM_AI_REPO = 'wtfalch/ai';
1672
+ const PLATFORM_AI_DB_NAME = 'ai-db';
1673
+ const PLATFORM_AI_POSTGRES_DB = 'ai';
1674
+ /**
1675
+ * One app per build target of `wtfalch/ai`'s root Dockerfile (its
1676
+ * `apps/host/README.md`, "Images and validation"). `target` is set as
1677
+ * Coolify's `dockerfile_target_build`; `internalName` as the app's fixed
1678
+ * `custom_internal_name`, the way `platform:auth` names its two apps.
1679
+ */
1680
+ const PLATFORM_AI_MIGRATE = {
1681
+ name: 'ai-migrate',
1682
+ target: 'migration',
1683
+ internalName: 'platform-ai-migrate',
1684
+ };
1685
+ const PLATFORM_AI_RUNNER = { name: 'ai', target: 'runner', internalName: 'platform-ai' };
1686
+ const PLATFORM_AI_WORKER = {
1687
+ name: 'ai-worker',
1688
+ target: 'worker',
1689
+ internalName: 'platform-ai-worker',
1690
+ };
1691
+ /** The runner target's `EXPOSE 3000` / `PORT=3000`. */
1692
+ const PLATFORM_AI_PORT = '3000';
1693
+ const PLATFORM_AI_PROJECT_SCOPE_KEY = 'coolify_project_platform_ai';
1694
+ const PLATFORM_AI_DB_SCOPE_KEY = 'coolify_platform_ai_database';
1695
+ const PLATFORM_AI_MIGRATE_SCOPE_KEY = 'coolify_platform_ai_migrate_app';
1696
+ const PLATFORM_AI_RUNNER_SCOPE_KEY = 'coolify_platform_ai_app';
1697
+ const PLATFORM_AI_WORKER_SCOPE_KEY = 'coolify_platform_ai_worker_app';
1698
+ /**
1699
+ * Written only when absent, never overwritten: an operator's own model list
1700
+ * or worker bounds survive every apply. Values are `.env.example`'s, and
1701
+ * `AI_MODELS=[]` is the README's "honest configuration for a host with no
1702
+ * approved text model".
1703
+ */
1704
+ const PLATFORM_AI_RUNNER_DEFAULTS = [
1705
+ { key: 'AI_MODELS', value: '[]' },
1706
+ { key: 'AI_FORGE_BINDINGS', value: '[]' },
1707
+ ];
1708
+ const PLATFORM_AI_WORKER_DEFAULTS = [
1709
+ ...PLATFORM_AI_RUNNER_DEFAULTS,
1710
+ { key: 'AI_WORKER_BATCH', value: '10' },
1711
+ { key: 'AI_WORKER_TIMEOUT_MS', value: '60000' },
1712
+ ];
1713
+ /**
1714
+ * #172: makes `coolify:app` keyed {@link PLATFORM_AI_KEY} true -- the ai host
1715
+ * (https://github.com/wtfalch/ai) on the platform's Coolify, then foundry's
1716
+ * own AI management key.
1717
+ *
1718
+ * The issue says to run the three parts "as `scripts/redeploy-dark.sh` in
1719
+ * that repo runs them". No such script exists anywhere in that repo's
1720
+ * history, so the shape here comes from what the repo does document: its
1721
+ * root Dockerfile's `migration`, `runner` and `worker` targets and
1722
+ * `apps/host/README.md`. One Coolify Postgres and three apps from the same
1723
+ * repo and `gitRef`, one per target:
1724
+ *
1725
+ * - `ai-migrate` (`migration`) gets `DATABASE_URL_OWNER` (the database's
1726
+ * own superuser URL, the only app that ever sees it) and `DATABASE_URL`,
1727
+ * from which `db-runtime-role.sh` sets the `<database>_rt` role's password.
1728
+ * Its container stays up after migrating ("Coolify reads an exited
1729
+ * container as a failed deploy") and turns healthy once the marker file is
1730
+ * written, so this waits for `running:healthy` and then stops it.
1731
+ * - `ai` (`runner`, port 3000) and `ai-worker` (`worker`, the continuously
1732
+ * polling `scripts/work.ts`) get only `DATABASE_URL`, the restricted
1733
+ * runtime role, plus the defaults in {@link PLATFORM_AI_WORKER_DEFAULTS}.
1734
+ *
1735
+ * Order: migrate first, wait, stop it, then the runner and worker, so neither
1736
+ * ever starts against an unmigrated schema. A migrate job that does not turn
1737
+ * healthy in time records {@link PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY}; the
1738
+ * planner then reports the step blocked instead of timing out on every apply.
1739
+ * A changed hostname or gitRef plans the step again (the applied values are
1740
+ * recorded last): `git_branch` is patched on every run, `APP_URL` follows the
1741
+ * hostname, and everything redeploys. The migration re-runs whenever
1742
+ * either of the other two is (re)deployed; it is idempotent and additive.
1743
+ * Coolify's auto-deploy on push is switched off on all three, because a push
1744
+ * would otherwise redeploy runner and worker without the migration first.
1745
+ *
1746
+ * `DATABASE_URL` is minted once (a base64url password, the alphabet
1747
+ * `db-runtime-role.sh` allows) and reused from whichever app already has it,
1748
+ * never rotated: the role's password is set from it at every migration.
1749
+ *
1750
+ * The runner's internal address, `http://platform-ai:3000`, is recorded under
1751
+ * {@link PLATFORM_AI_INTERNAL_URL_SCOPE_KEY}. Nothing public is wired: no
1752
+ * Coolify `domains`, tunnel, DNS record or Access app. Whether ai gets
1753
+ * `ai.<apex>` or stays internal like files is open (#172 "Decide"), and both
1754
+ * answers only add to this. `APP_URL` is set from `platform.ai.hostname` as
1755
+ * declared, since the host reads its own origin from it either way.
1756
+ *
1757
+ * The management key follows `platform:auth`'s admin-key discipline: the
1758
+ * credential id is recorded as undelivered before the secret leaves this
1759
+ * function, delivery is retried, and only a delivered key records the guard
1760
+ * that skips the mint. The secret is never returned, recorded, logged or
1761
+ * put in an error. The step's resource id is returned only after that, so
1762
+ * the planner keeps planning the step until the key is delivered.
1763
+ *
1764
+ * Known gaps, left for review (#172 stays open for review):
1765
+ * - No minter ships, so the step is blocked in the planner (nothing is
1766
+ * deployed) until a host supplies an {@link AiManagementKeyMinter}. It
1767
+ * cannot be built over ai's admin API: ai issues its first platform-tenant
1768
+ * credential only from `apps/host/scripts/authz/bootstrap-archon.ts`
1769
+ * (`keys:issue-archon`), a script that writes to the database directly and
1770
+ * needs `AUTHZ_BOOTSTRAP_OPERATOR` and the `KEYS_*` settings. Its HTTP
1771
+ * routes cannot bootstrap one: `POST /v1/organisations/{id}/keys` needs an
1772
+ * existing `ai.keys:issue` key and refuses the platform tenant, and a
1773
+ * signed management proof needs a human who already holds authority there.
1774
+ * `CoolifyPort` has no call that runs a command in a container.
1775
+ * - Crash window: the credential id is recorded only after the minter
1776
+ * returns, so a crash between the mint and that record loses a key nobody
1777
+ * knows the id of. ai has no name or idempotency key on that path to find
1778
+ * it again by; {@link AiManagementKeyMinter} therefore requires a minter to
1779
+ * look up an existing key named `foundry` and return it before issuing one.
1780
+ * - Coolify's `dockerfile_target_build` and `is_auto_deploy_enabled` are set
1781
+ * through the generic application PATCH; neither has been checked against
1782
+ * a live instance from this package.
1783
+ * - The key is minted for 90 days (the README's own examples); nothing
1784
+ * rotates it yet, so the platform loses its AI management key after 90 days.
1785
+ * - Deploys are decided from the recorded "pending" mark and the app's own
1786
+ * status, not from a deployment's age: the port cannot read one.
1787
+ * - Provider keys (`KEYS_WORKER_URL`, `KEYS_CREDENTIAL`, `KEYS_VERIFY_KEYS`)
1788
+ * and sign-in (`AUTH_*`) are not set: until an operator supplies them the
1789
+ * host answers `503 not_configured` for provider work, as its README says.
1790
+ */
1791
+ async function provisionPlatformAi(context, deps) {
1792
+ const { platform } = context;
1793
+ const hostname = platform.ai.hostname;
1794
+ const gitRef = platform.ai.gitRef;
1795
+ const recorded = context.recordedIds();
1796
+ const runtime = deps.runtime ?? {};
1797
+ const sleep = runtime.sleep ?? defaultSleep;
1798
+ const { coolify } = deps;
1799
+ const record = async (key, id) => {
1800
+ context.recordScopeId(key, id);
1801
+ await context.persist?.();
1802
+ };
1803
+ let projectUuid = recorded[PLATFORM_AI_PROJECT_SCOPE_KEY];
1804
+ if (!projectUuid) {
1805
+ const found = await coolify.findProjectByName(PLATFORM_AI_PROJECT);
1806
+ projectUuid = found ? found.uuid : (await coolify.createProject(PLATFORM_AI_PROJECT)).uuid;
1807
+ await record(PLATFORM_AI_PROJECT_SCOPE_KEY, projectUuid);
1808
+ }
1809
+ const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
1810
+ // ---- database ----------------------------------------------------------
1811
+ let dbId = recorded[PLATFORM_AI_DB_SCOPE_KEY];
1812
+ if (!dbId) {
1813
+ const found = (await coolify.listDatabases()).find((d) => d.name === PLATFORM_AI_DB_NAME && d.environment_id === environment.id);
1814
+ if (found) {
1815
+ dbId = found.uuid;
1816
+ }
1817
+ else {
1818
+ const server = await resolvePlatformServer(coolify);
1819
+ const created = await coolify.createPostgresDatabase({
1820
+ project_uuid: projectUuid,
1821
+ server_uuid: server.uuid,
1822
+ environment_name: environment.name,
1823
+ environment_uuid: environment.uuid,
1824
+ name: PLATFORM_AI_DB_NAME,
1825
+ postgres_db: PLATFORM_AI_POSTGRES_DB,
1826
+ postgres_user: PLATFORM_AI_POSTGRES_DB,
1827
+ });
1828
+ dbId = created.uuid;
1829
+ }
1830
+ await record(PLATFORM_AI_DB_SCOPE_KEY, dbId);
1831
+ }
1832
+ const db = await coolify.getDatabase(dbId);
1833
+ if (!db.internal_db_url) {
1834
+ throw new Error('coolify:app for the platform (ai): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
1835
+ }
1836
+ const ownerUrl = db.internal_db_url;
1837
+ // ---- the three apps ------------------------------------------------------
1838
+ const ensureApp = async (spec, scopeKey) => {
1839
+ const app = await findOrCreatePlatformApp(coolify, spec.name, environment, async () => {
1840
+ const server = await resolvePlatformServer(coolify);
1841
+ const githubApp = await coolify.requireGithubApp();
1842
+ return coolify.createGithubAppApplication({
1843
+ project_uuid: projectUuid,
1844
+ server_uuid: server.uuid,
1845
+ environment_name: environment.name,
1846
+ environment_uuid: environment.uuid,
1847
+ github_app_uuid: githubApp.uuid,
1848
+ git_repository: PLATFORM_AI_REPO,
1849
+ git_branch: gitRef,
1850
+ build_pack: 'dockerfile',
1851
+ name: spec.name,
1852
+ });
1853
+ });
1854
+ await record(scopeKey, app.appId);
1855
+ // Set on every run, so a throw right after the create never leaves an app
1856
+ // building the wrong target or deploying itself on a push.
1857
+ const patch = {
1858
+ custom_internal_name: spec.internalName,
1859
+ dockerfile_target_build: spec.target,
1860
+ is_auto_deploy_enabled: false,
1861
+ // Every run, so a changed gitRef reaches an app that already exists.
1862
+ git_branch: gitRef,
1863
+ };
1864
+ if (spec === PLATFORM_AI_RUNNER)
1865
+ patch.ports_exposes = PLATFORM_AI_PORT;
1866
+ await coolify.updateApplication(app.appId, patch);
1867
+ return app;
1868
+ };
1869
+ const migrate = await ensureApp(PLATFORM_AI_MIGRATE, PLATFORM_AI_MIGRATE_SCOPE_KEY);
1870
+ const runner = await ensureApp(PLATFORM_AI_RUNNER, PLATFORM_AI_RUNNER_SCOPE_KEY);
1871
+ const worker = await ensureApp(PLATFORM_AI_WORKER, PLATFORM_AI_WORKER_SCOPE_KEY);
1872
+ // ---- env -----------------------------------------------------------------
1873
+ // Both database URLs are live credentials: handed to `bulkUpsertEnvs` and
1874
+ // nowhere else, never returned, recorded, logged or put in an error.
1875
+ const runtimeUrl = await ensureAiRuntimeUrl(coolify, [runner.appId, worker.appId, migrate.appId], ownerUrl);
1876
+ const migrateEnvs = [
1877
+ { key: 'DATABASE_URL_OWNER', value: ownerUrl },
1878
+ { key: 'DATABASE_URL', value: runtimeUrl },
1879
+ ];
1880
+ const runnerEnvs = [
1881
+ { key: 'DATABASE_URL', value: runtimeUrl },
1882
+ { key: 'APP_URL', value: `https://${hostname}` },
1883
+ ...(await missingEnvs(coolify, runner.appId, PLATFORM_AI_RUNNER_DEFAULTS)),
1884
+ ];
1885
+ const workerEnvs = [
1886
+ { key: 'DATABASE_URL', value: runtimeUrl },
1887
+ ...(await missingEnvs(coolify, worker.appId, PLATFORM_AI_WORKER_DEFAULTS)),
1888
+ ];
1889
+ // Whether anything the containers run on differs from what they last
1890
+ // deployed with. Marked and persisted before the first write, and cleared
1891
+ // only after the deploys below, so a run that fails halfway (the migrate
1892
+ // wait throwing, for one) leaves the mark and the next run deploys again.
1893
+ const migrateChanged = await envsDiffer(coolify, migrate.appId, migrateEnvs);
1894
+ const runnerChanged = await envsDiffer(coolify, runner.appId, runnerEnvs);
1895
+ const workerChanged = await envsDiffer(coolify, worker.appId, workerEnvs);
1896
+ const declaredChanged = recorded[PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname ||
1897
+ recorded[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] !== gitRef;
1898
+ const pending = Boolean(recorded[PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY]) ||
1899
+ migrateChanged ||
1900
+ runnerChanged ||
1901
+ workerChanged ||
1902
+ declaredChanged;
1903
+ if (pending)
1904
+ await record(PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, '1');
1905
+ await coolify.bulkUpsertEnvs(migrate.appId, migrateEnvs);
1906
+ await coolify.bulkUpsertEnvs(runner.appId, runnerEnvs);
1907
+ await coolify.bulkUpsertEnvs(worker.appId, workerEnvs);
1908
+ const deployRunner = runner.justCreated || !runner.running || pending;
1909
+ const deployWorker = worker.justCreated || !worker.running || pending;
1910
+ const runMigration = migrate.justCreated || deployRunner || deployWorker;
1911
+ // ---- migrate, wait, stop, then runner and worker ------------------------
1912
+ if (runMigration) {
1913
+ // A migrate container still up from an earlier run (one that threw before
1914
+ // stopping it) would already read healthy. Stop it and wait until it
1915
+ // reads stopped, so the wait below only ever sees the new deployment.
1916
+ if (!migrate.justCreated && migrate.running) {
1917
+ await coolify.raw('POST', `/applications/${migrate.appId}/stop`);
1918
+ await waitForStopped(coolify, migrate.appId, runtime, sleep);
1919
+ }
1920
+ await coolify.deploy(migrate.appId);
1921
+ try {
1922
+ await waitForAiMigration(coolify, migrate.appId, runtime, sleep);
1923
+ }
1924
+ catch (error) {
1925
+ if (error instanceof MigrationNotHealthyError) {
1926
+ // Blocked from now on: the planner reports it and plans nothing until
1927
+ // a person fixes the job and removes this key, instead of waiting out
1928
+ // the timeout on every apply.
1929
+ await record(PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, error.lastStatus);
1930
+ }
1931
+ throw error;
1932
+ }
1933
+ await coolify.raw('POST', `/applications/${migrate.appId}/stop`);
1934
+ }
1935
+ if (deployRunner)
1936
+ await coolify.deploy(runner.appId);
1937
+ if (deployWorker)
1938
+ await coolify.deploy(worker.appId);
1939
+ await record(PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, '');
1940
+ // The deploys are done, so record what they deployed now. A later failure
1941
+ // at the mint or delivery must not make the next apply see a changed
1942
+ // hostname or gitRef and redeploy the live host again. The planner still
1943
+ // plans the step until the resource id is recorded, once the key is delivered.
1944
+ await record(PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
1945
+ await record(PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, gitRef);
1946
+ const internalUrl = `http://${PLATFORM_AI_RUNNER.internalName}:${PLATFORM_AI_PORT}`;
1947
+ await record(PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, internalUrl);
1948
+ // ---- foundry's AI management key, once -----------------------------------
1949
+ const now = context.recordedIds();
1950
+ if (!now[PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY]) {
1951
+ const lost = now[PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY];
1952
+ if (lost) {
1953
+ 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`);
1954
+ }
1955
+ const minter = deps.managementKeyMinter;
1956
+ // The planner blocks the step while there is no minter; this is for a
1957
+ // caller that reaches the step anyway.
1958
+ if (!minter)
1959
+ throw new Error(PLATFORM_AI_MINTER_MISSING);
1960
+ let minted;
1961
+ try {
1962
+ minted = await minter.mintManagementKey({
1963
+ internalUrl,
1964
+ name: 'foundry',
1965
+ expiresDays: 90,
1966
+ permissions: PLATFORM_AI_MANAGEMENT_PERMISSIONS,
1967
+ });
1968
+ }
1969
+ catch {
1970
+ // The cause is dropped in case it echoes a credential.
1971
+ 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');
1972
+ }
1973
+ const { credentialId, organisationId, secret } = minted;
1974
+ await record(PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, credentialId);
1975
+ await record(PLATFORM_AI_ORGANISATION_SCOPE_KEY, organisationId);
1976
+ const attempts = runtime.deliverAttempts ?? 3;
1977
+ for (let attempt = 1;; attempt += 1) {
1978
+ try {
1979
+ await deps.deliverSecret(PLATFORM_AI_MANAGEMENT_KEY_REF, secret);
1980
+ break;
1981
+ }
1982
+ catch {
1983
+ // The cause is dropped in case it echoes the secret.
1984
+ if (attempt >= attempts) {
1985
+ throw new Error(`platform:ai: AI management key ${credentialId} was minted but could not be delivered after ${attempts} tries`);
1986
+ }
1987
+ await sleep(1000 * attempt);
1988
+ }
1989
+ }
1990
+ await record(PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, credentialId);
1991
+ }
1992
+ return { resourceId: runner.appId };
1993
+ }
1994
+ /** The migrate job did not turn healthy in time; carries its last status, which holds no secret. */
1995
+ class MigrationNotHealthyError extends Error {
1996
+ lastStatus;
1997
+ constructor(lastStatus, message) {
1998
+ super(message);
1999
+ this.lastStatus = lastStatus;
2000
+ }
2001
+ }
2002
+ /**
2003
+ * Polls the app until Coolify reports it stopped (`exited`/`stopped`), so a
2004
+ * healthy status read afterwards belongs to a deployment queued after this.
2005
+ */
2006
+ async function waitForStopped(coolify, appId, runtime, sleep) {
2007
+ const now = runtime.now ?? Date.now;
2008
+ const timeout = runtime.migrateTimeoutMs ?? 600_000;
2009
+ const interval = runtime.migrateIntervalMs ?? 5_000;
2010
+ const deadline = now() + timeout;
2011
+ for (;;) {
2012
+ const { status } = await coolify.getApplication(appId);
2013
+ if (status && !isRunningOrStarting(status))
2014
+ return;
2015
+ if (now() >= deadline) {
2016
+ throw new Error(`platform:ai: the migrate job ${appId} did not stop within ${Math.round(timeout / 1000)}s (last status ${status ?? 'unknown'}); apply again`);
2017
+ }
2018
+ await sleep(interval);
2019
+ }
2020
+ }
2021
+ /**
2022
+ * Polls the migrate app until Coolify reports it `running:healthy` -- the
2023
+ * migration target's healthcheck passes only once `migrate.mjs` has finished
2024
+ * -- or throws {@link MigrationNotHealthyError} once `migrateTimeoutMs` has
2025
+ * passed. The caller stopped the app and saw it stopped before deploying, so
2026
+ * a healthy status here is the new deployment's. Any other status (queued,
2027
+ * building, exited, starting unhealthy) keeps waiting. On a timeout the job
2028
+ * is left as it is: stopping it mid-migration is worse.
2029
+ */
2030
+ async function waitForAiMigration(coolify, appId, runtime, sleep) {
2031
+ const now = runtime.now ?? Date.now;
2032
+ const timeout = runtime.migrateTimeoutMs ?? 600_000;
2033
+ const interval = runtime.migrateIntervalMs ?? 5_000;
2034
+ const deadline = now() + timeout;
2035
+ for (;;) {
2036
+ await sleep(interval);
2037
+ const { status } = await coolify.getApplication(appId);
2038
+ if (status?.startsWith('running:healthy'))
2039
+ return;
2040
+ if (now() >= deadline) {
2041
+ 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`);
2042
+ }
2043
+ }
2044
+ }
2045
+ /**
2046
+ * The runtime role's `DATABASE_URL`, reused from the first of `appIds` that
2047
+ * already has one, else minted: `<database>_rt` (the name ai's
2048
+ * `0001_runtime_role.sql` derives) with a fresh base64url password, on the
2049
+ * owner URL's host, database and query string. The owner URL is parsed
2050
+ * inside a try: Node's parse error carries the whole input, credential
2051
+ * included, so it is replaced with a fixed message.
2052
+ */
2053
+ async function ensureAiRuntimeUrl(coolify, appIds, ownerUrl) {
2054
+ for (const appId of appIds) {
2055
+ const existing = (await coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL' && !e.is_preview && e.value);
2056
+ if (existing)
2057
+ return existing.value;
2058
+ }
2059
+ let host;
2060
+ let pathname;
2061
+ let search;
2062
+ try {
2063
+ ({ host, pathname, search } = new URL(ownerUrl));
2064
+ }
2065
+ catch {
2066
+ throw new Error("platform:ai: the database's internal_db_url could not be parsed");
2067
+ }
2068
+ const database = pathname.replace(/^\//, '');
2069
+ if (!/^[A-Za-z0-9_]+$/.test(database)) {
2070
+ throw new Error("platform:ai: the database's name cannot form a runtime role name (letters, digits and _ only)");
2071
+ }
2072
+ const password = randomBytes(24).toString('base64url');
2073
+ return `postgres://${database}_rt:${password}@${host}${pathname}${search}`;
2074
+ }
2075
+ /** The entries of `defaults` whose key the app does not have yet. */
2076
+ async function missingEnvs(coolify, appId, defaults) {
2077
+ const present = new Set((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => e.key));
2078
+ return defaults.filter((d) => !present.has(d.key));
2079
+ }
2080
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2081
+ /** Polls `url` until it answers 2xx, or throws once `readyTimeoutMs` has passed. */
2082
+ async function waitForInstance(url, runtime, sleep) {
2083
+ const fetchImpl = runtime.fetch ?? globalThis.fetch;
2084
+ const now = runtime.now ?? Date.now;
2085
+ const timeout = runtime.readyTimeoutMs ?? 300_000;
2086
+ const interval = runtime.readyIntervalMs ?? 5_000;
2087
+ const deadline = now() + timeout;
2088
+ for (;;) {
2089
+ try {
2090
+ const response = await fetchImpl(url);
2091
+ if (response.ok)
2092
+ return;
2093
+ }
2094
+ catch {
2095
+ // Not up yet: DNS, the tunnel or the container.
2096
+ }
2097
+ if (now() >= deadline) {
2098
+ throw new Error(`platform:auth: the new ZITADEL instance did not answer at ${url} within ${Math.round(timeout / 1000)}s; retry this apply`);
2099
+ }
2100
+ await sleep(interval);
2101
+ }
2102
+ }
2103
+ async function resolvePlatformServer(coolify) {
2104
+ const servers = await coolify.listServers();
2105
+ const server = servers.find((s) => s.is_usable && s.is_reachable) ?? servers[0];
2106
+ if (!server)
2107
+ throw new Error('no usable Coolify server is registered on this instance');
2108
+ return server;
2109
+ }
2110
+ /**
2111
+ * `executor.ts`'s `coolifyCalls`/`findOrCreateCallsApp`, restated here rather
2112
+ * than imported: that method is private to `FoundryStepExecutor`, and this
2113
+ * file holds no instance of that class to call it on. `running` says whether
2114
+ * the app was already running or starting; the caller deploys it when not.
2115
+ */
2116
+ async function findOrCreatePlatformApp(coolify, name, environment, create) {
2117
+ const existing = await coolify.findApplicationByName(name, { environmentIds: [environment.id] });
2118
+ if (existing) {
2119
+ const live = await coolify.getApplication(existing.uuid);
2120
+ return {
2121
+ appId: existing.uuid,
2122
+ justCreated: false,
2123
+ running: isRunningOrStarting(live.status),
2124
+ };
2125
+ }
2126
+ const created = await create();
2127
+ return { appId: created.uuid, justCreated: true, running: false };
2128
+ }
2129
+ /** Whether any desired env is missing from the app, or set to another value. */
2130
+ async function envsDiffer(coolify, appId, desired) {
2131
+ const live = new Map((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
2132
+ return desired.some((e) => live.get(e.key) !== e.value);
2133
+ }
2134
+ /**
2135
+ * `null` once the app already has this key set -- generated only the first
2136
+ * time, never regenerated or overwritten, mirroring `executor.ts`'s
2137
+ * `ensureAuthCookieSecret` exactly.
2138
+ */
2139
+ async function ensureGeneratedEnv(coolify, appId, key, generate) {
2140
+ const envs = await coolify.listEnvs(appId);
2141
+ const already = envs.some((e) => e.key === key && !e.is_preview);
2142
+ if (already)
2143
+ return null;
2144
+ return generate();
2145
+ }
2146
+ /**
2147
+ * {@link ensureGeneratedEnv} for `ZITADEL_MASTERKEY`, which encrypts what
2148
+ * ZITADEL keeps in its database. A new key over a database ZITADEL has run
2149
+ * against (one that existed before this run and had its app deployed) makes
2150
+ * that data unreadable, so such a database never gets a generated one. The
2151
+ * env is always written before the first deploy, so a missing key on a
2152
+ * deployed app means someone removed it. This throws, and a person sets the key that database was
2153
+ * initialised with (or deletes an unused database) and applies again.
2154
+ */
2155
+ async function ensureMasterkey(coolify, appId, databaseInUse) {
2156
+ const envs = await coolify.listEnvs(appId);
2157
+ if (envs.some((e) => e.key === 'ZITADEL_MASTERKEY' && !e.is_preview))
2158
+ return null;
2159
+ if (databaseInUse) {
2160
+ 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`);
2161
+ }
2162
+ return randomBytes(24).toString('base64');
2163
+ }
2164
+ /** `docs/deploy.md`: "ZITADEL_ADMIN_PASSWORD: 8+, upper, lower, digit, symbol". */
2165
+ function generateAdminPassword() {
2166
+ return `${randomBytes(18).toString('base64')}Aa1!`;
2167
+ }
2168
+ /**
2169
+ * #171: how the host signs a files management request with the key this step
2170
+ * delivers under {@link FILES_MANAGEMENT_KEY_REF} (files'
2171
+ * `signManagementRequest`'s `SigningIdentity`): `issuer` is
2172
+ * {@link FILES_MANAGEMENT_ISSUER}, `keyId` is {@link FILES_MANAGEMENT_KEY_ID},
2173
+ * `audience` is `https://<platform.files.hostname>` (the files host's
2174
+ * `APP_URL`, which this step sets), and `privateKey` is the delivered PKCS#8
2175
+ * PEM. Neither constant is a secret. The files host pins the public half
2176
+ * through `MANAGEMENT_ISSUER`/`MANAGEMENT_PUBLIC_KEYS`, which this step sets.
2177
+ */
2178
+ export const FILES_MANAGEMENT_ISSUER = 'foundry';
2179
+ export const FILES_MANAGEMENT_KEY_ID = 'foundry-1';
2180
+ /** The ref the management private key (PKCS#8 PEM) is delivered under. */
2181
+ export const FILES_MANAGEMENT_KEY_REF = 'files:platform:managementKey';
2182
+ /** The ref the files host's introspection client secret is delivered under. */
2183
+ export const FILES_INTROSPECTION_SECRET_REF = 'zitadel:platform:files:introspection';
2184
+ /** https://github.com/wtfalch/files -- root Dockerfile with `migration` and `runner` targets. */
2185
+ const FILES_REPO = 'wtfalch/files';
2186
+ const PLATFORM_FILES_PROJECT = 'platform';
2187
+ const PLATFORM_FILES_DB_NAME = 'platform-files-db';
2188
+ /** The two Coolify apps files' `apps/host/README.md` ("Deploying on Coolify") names. */
2189
+ const FILES_MIGRATE_APP_NAME = 'files-migrate';
2190
+ const FILES_RUNNER_APP_NAME = 'files';
2191
+ /**
2192
+ * The runner's fixed `custom_internal_name`: the name another container on
2193
+ * the Coolify network reaches it by, and so the host part of
2194
+ * {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY}'s recorded address.
2195
+ */
2196
+ const FILES_RUNNER_INTERNAL_NAME = 'platform-files';
2197
+ /** The runner's port (`Dockerfile`'s `runner` stage: `PORT=3000`, `EXPOSE 3000`). */
2198
+ const FILES_PORT = '3000';
2199
+ /** The Postgres database name; files' runtime role is `<database>_rt`. */
2200
+ const FILES_DB = 'files';
2201
+ const FILES_RUNTIME_ROLE = `${FILES_DB}_rt`;
2202
+ const FILES_ZITADEL_PROJECT_NAME = 'files';
2203
+ const FILES_INTROSPECTION_APP_NAME = 'files-introspection';
2204
+ const PLATFORM_FILES_PROJECT_SCOPE_KEY = 'coolify_project_platform_files';
2205
+ const PLATFORM_FILES_DB_SCOPE_KEY = 'coolify_platform_files_database';
2206
+ const PLATFORM_FILES_MIGRATE_SCOPE_KEY = 'coolify_platform_files_migrate_app';
2207
+ const PLATFORM_FILES_RUNNER_SCOPE_KEY = 'coolify_platform_files_runner_app';
2208
+ /**
2209
+ * #171: makes `coolify:app` keyed {@link PLATFORM_FILES_KEY} true -- the files
2210
+ * host from https://github.com/wtfalch/files, on Coolify's internal network
2211
+ * only, following its `apps/host/README.md` ("Deploying on Coolify"):
2212
+ *
2213
+ * 1. A Coolify Postgres (`files`), and two apps from the repo's root
2214
+ * Dockerfile: `files-migrate` (the `migration` target) and `files` (the
2215
+ * `runner` target, no domain, a fixed `custom_internal_name`).
2216
+ * 2. In the platform's own sign-in: the `files` ZITADEL project (its id is the
2217
+ * person-token audience, `AUTH_PERSON_TOKEN_AUDIENCE`) and an API app, the
2218
+ * host's introspection client (`AUTH_PERSON_TOKEN_INTROSPECTION_*`).
2219
+ * 3. Foundry's management key: an Ed25519 keypair minted here. The public
2220
+ * half is pinned on the runner (`MANAGEMENT_ISSUER`,
2221
+ * `MANAGEMENT_PUBLIC_KEYS`); the private half goes to the host as a held
2222
+ * key, which is what the org steps' `FilesOrganisationPort` signs with.
2223
+ * 4. Deploy `files-migrate`, wait for it to report healthy, stop it, then
2224
+ * deploy `files`.
2225
+ * 5. Record the runner's internal address and the ZITADEL project id.
2226
+ *
2227
+ * Both secrets are handed to {@link PlatformFilesDeps.deliverSecret} and
2228
+ * written to the runner's env, and nowhere else: never returned, recorded,
2229
+ * logged or put in a thrown error. Each is made only while its guard is
2230
+ * unrecorded, and follows `platform:auth`'s admin-token pattern: an
2231
+ * "undelivered" id is recorded and persisted the moment the secret is made,
2232
+ * and the guard only after delivery AND the env write. A run that fails in
2233
+ * between leaves an undelivered id with no guard; the planner reports that
2234
+ * and never mints again, and a person clears the recorded key (and the
2235
+ * ZITADEL client) to allow a new one.
2236
+ *
2237
+ * A re-run after a changed hostname or `gitRef` (recorded as applied scope
2238
+ * ids) updates `git_branch` and the runner env and redeploys, and mints
2239
+ * nothing.
2240
+ *
2241
+ * Known gaps, left for review:
2242
+ * - The README says to wait for `files-migrate: done` in the migrate app's
2243
+ * logs. `CoolifyPort` cannot read logs, so this waits for Coolify to report
2244
+ * the app `running:healthy` instead. The `migration` stage's HEALTHCHECK
2245
+ * (`test -f /tmp/files-migrated`) passes on the file touched right before
2246
+ * that line is logged, so the two mean the same thing. The migrate app is stopped
2247
+ * before its deploy, so the poll cannot pass on a container left up by a
2248
+ * failed run. Whether Coolify's status still shows the old container for a
2249
+ * moment right after the deploy is not checked against a live Coolify.
2250
+ * - The Dockerfile target is chosen by setting `dockerfile_target_build`
2251
+ * through `updateApplication`, since `createGithubAppApplication` has no
2252
+ * field for it. Not yet checked against a live Coolify.
2253
+ * - The runner is created without `domains`, like `platform:auth`'s apps.
2254
+ * Whether Coolify adds a generated domain anyway is not yet checked.
2255
+ * - Foundry sets only the env it owns: the two database URLs, `APP_URL`,
2256
+ * `AUTH_ISSUER`, `AUTH_PERSON_TOKEN_*`, `MANAGEMENT_*` and the two `KEYS_*`
2257
+ * values the host supplies through {@link PlatformFilesDeps.keys}, which
2258
+ * files-migrate needs to become healthy. The rest of files' `.env.example`
2259
+ * (`R2_*`, `KEYS_VERIFY_KEYS`, `JOBS_*`, `AUTHZ_BOOTSTRAP_OPERATOR`, ...)
2260
+ * is set by a person on the runner.
2261
+ * - The files project lives in the platform's `foundry` ZITADEL org. An
2262
+ * org's Agora requests it as an audience scope from its own ZITADEL org;
2263
+ * whether that needs a project grant per org is not decided here.
2264
+ * - The recorded address is reachable only from a container on the same
2265
+ * Coolify network, so the host must run there to use it for management
2266
+ * calls.
2267
+ */
2268
+ async function provisionPlatformFiles(context, deps) {
2269
+ const { platform } = context;
2270
+ const gitRef = platform.files.gitRef;
2271
+ const recorded = context.recordedIds();
2272
+ const runtime = deps.runtime ?? {};
2273
+ const sleep = runtime.sleep ?? defaultSleep;
2274
+ const attempts = runtime.deliverAttempts ?? 3;
2275
+ const keys = filesKeys(deps);
2276
+ if (!keys) {
2277
+ 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');
2278
+ }
2279
+ // Never plan-and-throw: the planner reports a lost secret and plans
2280
+ // nothing; this is the same rule for a caller that reaches the step anyway.
2281
+ for (const [undeliveredKey, guardKey] of [
2282
+ [PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY],
2283
+ [PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY],
2284
+ ]) {
2285
+ const lost = recorded[undeliveredKey];
2286
+ if (lost && !recorded[guardKey]) {
2287
+ throw new Error(`platform:files: a secret (${lost}) was minted but never delivered; remove the recorded key ${undeliveredKey} to mint a new one`);
2288
+ }
2289
+ }
2290
+ const record = async (key, id) => {
2291
+ context.recordScopeId(key, id);
2292
+ await context.persist?.();
2293
+ };
2294
+ let projectUuid = recorded[PLATFORM_FILES_PROJECT_SCOPE_KEY];
2295
+ if (!projectUuid) {
2296
+ const found = await deps.coolify.findProjectByName(PLATFORM_FILES_PROJECT);
2297
+ projectUuid = found
2298
+ ? found.uuid
2299
+ : (await deps.coolify.createProject(PLATFORM_FILES_PROJECT)).uuid;
2300
+ await record(PLATFORM_FILES_PROJECT_SCOPE_KEY, projectUuid);
2301
+ }
2302
+ const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
2303
+ // ---- database ----------------------------------------------------------
2304
+ let dbId = recorded[PLATFORM_FILES_DB_SCOPE_KEY];
2305
+ if (!dbId) {
2306
+ // Found by name first, so a crash between create and record never makes
2307
+ // a second one (`coolifyJobs` does the same).
2308
+ const found = (await deps.coolify.listDatabases()).find((db) => db.name === PLATFORM_FILES_DB_NAME && db.environment_id === environment.id);
2309
+ if (found) {
2310
+ dbId = found.uuid;
2311
+ }
2312
+ else {
2313
+ const server = await resolvePlatformServer(deps.coolify);
2314
+ const created = await deps.coolify.createPostgresDatabase({
2315
+ project_uuid: projectUuid,
2316
+ server_uuid: server.uuid,
2317
+ environment_name: environment.name,
2318
+ environment_uuid: environment.uuid,
2319
+ name: PLATFORM_FILES_DB_NAME,
2320
+ postgres_db: FILES_DB,
2321
+ postgres_user: FILES_DB,
2322
+ });
2323
+ dbId = created.uuid;
2324
+ }
2325
+ await record(PLATFORM_FILES_DB_SCOPE_KEY, dbId);
2326
+ }
2327
+ const db = await deps.coolify.getDatabase(dbId);
2328
+ if (!db.internal_db_url) {
2329
+ throw new Error('coolify:app for the platform (files): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
2330
+ }
2331
+ // ---- the two apps --------------------------------------------------------
2332
+ const createFilesApp = (name) => async () => {
2333
+ const server = await resolvePlatformServer(deps.coolify);
2334
+ const githubApp = await deps.coolify.requireGithubApp();
2335
+ return deps.coolify.createGithubAppApplication({
2336
+ project_uuid: projectUuid,
2337
+ server_uuid: server.uuid,
2338
+ environment_name: environment.name,
2339
+ environment_uuid: environment.uuid,
2340
+ github_app_uuid: githubApp.uuid,
2341
+ git_repository: FILES_REPO,
2342
+ git_branch: gitRef,
2343
+ build_pack: 'dockerfile',
2344
+ name,
2345
+ });
2346
+ };
2347
+ const migrateApp = await findOrCreatePlatformApp(deps.coolify, FILES_MIGRATE_APP_NAME, environment, createFilesApp(FILES_MIGRATE_APP_NAME));
2348
+ await record(PLATFORM_FILES_MIGRATE_SCOPE_KEY, migrateApp.appId);
2349
+ // Set on every run, like `platform:auth`'s `custom_internal_name`: a throw
2350
+ // right after a create must not leave the app on the wrong target.
2351
+ // `git_branch` too, so a changed `gitRef` reaches an app made earlier.
2352
+ await deps.coolify.updateApplication(migrateApp.appId, {
2353
+ dockerfile_target_build: 'migration',
2354
+ git_branch: gitRef,
2355
+ });
2356
+ const runnerApp = await findOrCreatePlatformApp(deps.coolify, FILES_RUNNER_APP_NAME, environment, createFilesApp(FILES_RUNNER_APP_NAME));
2357
+ await record(PLATFORM_FILES_RUNNER_SCOPE_KEY, runnerApp.appId);
2358
+ await deps.coolify.updateApplication(runnerApp.appId, {
2359
+ dockerfile_target_build: 'runner',
2360
+ custom_internal_name: FILES_RUNNER_INTERNAL_NAME,
2361
+ git_branch: gitRef,
2362
+ });
2363
+ // ---- database env ----------------------------------------------------------
2364
+ // The migrate app sets `files_rt`'s password from DATABASE_URL on every
2365
+ // boot (files' `scripts/db-runtime-role.sh`), and the runner connects with
2366
+ // the same URL, so both apps carry one value, minted once and never
2367
+ // rotated: an existing value on either app is reused.
2368
+ const runtimeUrl = await ensureFilesRuntimeUrl(deps.coolify, [migrateApp.appId, runnerApp.appId], db.internal_db_url);
2369
+ // The keys worker the migrate app needs to become healthy (files' README:
2370
+ // `DATABASE_URL_OWNER`, `DATABASE_URL`, `KEYS_WORKER_URL`, `KEYS_CREDENTIAL`).
2371
+ const keysEnvs = [
2372
+ { key: 'KEYS_WORKER_URL', value: keys.workerUrl },
2373
+ { key: 'KEYS_CREDENTIAL', value: keys.credential },
2374
+ ];
2375
+ await deps.coolify.bulkUpsertEnvs(migrateApp.appId, [
2376
+ { key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
2377
+ { key: 'DATABASE_URL', value: runtimeUrl },
2378
+ ...keysEnvs,
2379
+ ]);
2380
+ // ---- the files ZITADEL project, in the platform's sign-in ----------------
2381
+ const issuer = `https://${platform.auth.hostname}`;
2382
+ const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
2383
+ const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
2384
+ const { project } = await zitadel.findOrCreateProject(org.id, FILES_ZITADEL_PROJECT_NAME);
2385
+ await record(PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, project.id);
2386
+ const runnerEnvs = [
2387
+ { key: 'DATABASE_URL', value: runtimeUrl },
2388
+ // The management verifier's audience, which must be an HTTPS origin. The
2389
+ // host has no public address, so this is a name, not a route.
2390
+ { key: 'APP_URL', value: `https://${platform.files.hostname}` },
2391
+ { key: 'AUTH_ISSUER', value: issuer },
2392
+ { key: 'AUTH_PERSON_TOKEN_AUDIENCE', value: project.id },
2393
+ ...keysEnvs,
2394
+ ];
2395
+ // Base env first, so a secret's own write below cannot be the only one
2396
+ // that lands, and a re-run with a changed hostname updates `APP_URL`.
2397
+ await deps.coolify.bulkUpsertEnvs(runnerApp.appId, runnerEnvs);
2398
+ // ---- the introspection client, once --------------------------------------
2399
+ if (!recorded[PLATFORM_FILES_INTROSPECTION_SCOPE_KEY]) {
2400
+ // An app by this name here has no recorded secret (no undelivered id, or
2401
+ // the throw above would have fired): a crash between the create and the
2402
+ // record. Nothing was delivered, so it is deleted and made again.
2403
+ const stale = (await zitadel.listApps(org.id, project.id)).filter((app) => app.name === FILES_INTROSPECTION_APP_NAME && app.state !== 'APP_STATE_REMOVED');
2404
+ for (const app of stale) {
2405
+ try {
2406
+ await zitadel.deleteApp(org.id, project.id, app.id);
2407
+ }
2408
+ catch (error) {
2409
+ if (!isNotFoundError(error))
2410
+ throw error;
2411
+ }
2412
+ }
2413
+ const client = await zitadel.createApiApp(org.id, project.id, {
2414
+ name: FILES_INTROSPECTION_APP_NAME,
2415
+ });
2416
+ // Recorded and persisted before the secret leaves this function: a failed
2417
+ // delivery or env write is then reported by the planner, not remade.
2418
+ await record(PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, client.appId);
2419
+ await deliverWithRetry(deps.deliverSecret, FILES_INTROSPECTION_SECRET_REF, client.clientSecret, attempts, sleep, `the introspection client secret (ZITADEL app ${client.appId})`);
2420
+ await deps.coolify.bulkUpsertEnvs(runnerApp.appId, [
2421
+ { key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_ID', value: client.clientId },
2422
+ { key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_SECRET', value: client.clientSecret },
2423
+ ]);
2424
+ await record(PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, client.appId);
2425
+ }
2426
+ // ---- foundry's management key, once --------------------------------------
2427
+ if (!recorded[PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY]) {
2428
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
2429
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
2430
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
2431
+ });
2432
+ await record(PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, FILES_MANAGEMENT_KEY_ID);
2433
+ await deliverWithRetry(deps.deliverSecret, FILES_MANAGEMENT_KEY_REF, privateKey, attempts, sleep, 'the files management key');
2434
+ await deps.coolify.bulkUpsertEnvs(runnerApp.appId, [
2435
+ { key: 'MANAGEMENT_ISSUER', value: FILES_MANAGEMENT_ISSUER },
2436
+ {
2437
+ key: 'MANAGEMENT_PUBLIC_KEYS',
2438
+ value: JSON.stringify({ [FILES_MANAGEMENT_KEY_ID]: publicKey }),
2439
+ },
2440
+ ]);
2441
+ await record(PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, FILES_MANAGEMENT_KEY_ID);
2442
+ }
2443
+ // ---- deploy: stop migrate, deploy it, wait, stop it, then the runner -----
2444
+ // A failed run can leave `files-migrate` up and healthy from the last
2445
+ // deploy. Stopped first, so the wait below cannot pass on the old
2446
+ // container before the new migration has run.
2447
+ await stopIfRunning(deps.coolify, migrateApp.appId);
2448
+ await deps.coolify.deploy(migrateApp.appId);
2449
+ await waitForMigration(deps.coolify, migrateApp.appId, runtime, sleep);
2450
+ await deps.coolify.raw('POST', `/applications/${migrateApp.appId}/stop`);
2451
+ await deps.coolify.deploy(runnerApp.appId);
2452
+ await record(PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, `http://${FILES_RUNNER_INTERNAL_NAME}:${FILES_PORT}`);
2453
+ // Last: a run that failed anywhere above is planned again.
2454
+ context.recordScopeId(PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, platform.files.hostname);
2455
+ await record(PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
2456
+ return { resourceId: runnerApp.appId };
2457
+ }
2458
+ function filesKeys(deps) {
2459
+ const keys = deps?.keys;
2460
+ return keys?.workerUrl && keys.credential ? keys : null;
2461
+ }
2462
+ /** Stops the app when Coolify reports it running; a stop on an app that is not running can fail. */
2463
+ async function stopIfRunning(coolify, appId) {
2464
+ const { status } = await coolify.getApplication(appId);
2465
+ if (status?.startsWith('running'))
2466
+ await coolify.raw('POST', `/applications/${appId}/stop`);
2467
+ }
2468
+ /**
2469
+ * Hands `secret` to the host, retrying. The thrown error names `what` only,
2470
+ * never the secret or the cause, which could echo it.
2471
+ */
2472
+ async function deliverWithRetry(deliverSecret, ref, secret, attempts, sleep, what) {
2473
+ for (let attempt = 1;; attempt += 1) {
2474
+ try {
2475
+ await deliverSecret(ref, secret);
2476
+ return;
2477
+ }
2478
+ catch {
2479
+ if (attempt >= attempts) {
2480
+ 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`);
2481
+ }
2482
+ await sleep(1000 * attempt);
2483
+ }
2484
+ }
2485
+ }
2486
+ /**
2487
+ * The runtime `DATABASE_URL` both files apps share: the first one already set
2488
+ * on either app, else a new `files_rt` credential on the database's host,
2489
+ * built the way `executor.ts`'s `ensureCmsRuntimeUrl` builds `cms_rt`'s. A
2490
+ * parse failure throws a message with no part of the URL in it, since the
2491
+ * URL carries the superuser's password.
2492
+ */
2493
+ async function ensureFilesRuntimeUrl(coolify, appIds, dbInternalUrl) {
2494
+ for (const appId of appIds) {
2495
+ const existing = (await coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL' && !e.is_preview && e.value);
2496
+ if (existing)
2497
+ return existing.value;
2498
+ }
2499
+ let host;
2500
+ let pathname;
2501
+ let search;
2502
+ try {
2503
+ ({ host, pathname, search } = new URL(dbInternalUrl));
2504
+ }
2505
+ catch {
2506
+ 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");
2507
+ }
2508
+ const password = randomBytes(24).toString('base64url');
2509
+ return `postgres://${FILES_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
2510
+ }
2511
+ /**
2512
+ * Polls the migrate app until Coolify reports it `running:healthy` after
2513
+ * first reporting anything else -- a healthy status before that is the old
2514
+ * container, since the stop and deploy calls can return before Coolify's
2515
+ * status catches up. The
2516
+ * `migration` stage's HEALTHCHECK passes only once the migration has
2517
+ * succeeded (see {@link provisionPlatformFiles}'s known gaps for why this
2518
+ * stands in for the log line). Throws once `migrateTimeoutMs` has passed; the
2519
+ * runner is then never deployed against an unmigrated database.
2520
+ */
2521
+ async function waitForMigration(coolify, appId, runtime, sleep) {
2522
+ const now = runtime.now ?? Date.now;
2523
+ const timeout = runtime.migrateTimeoutMs ?? 900_000;
2524
+ const interval = runtime.migrateIntervalMs ?? 10_000;
2525
+ const deadline = now() + timeout;
2526
+ let left = false;
2527
+ for (;;) {
2528
+ const { status } = await coolify.getApplication(appId);
2529
+ if (status === 'running:healthy' && left)
2530
+ return;
2531
+ if (status !== 'running:healthy')
2532
+ left = true;
2533
+ if (now() >= deadline) {
2534
+ 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`);
2535
+ }
2536
+ await sleep(interval);
2537
+ }
2538
+ }
2539
+ export const INTEGRATIONS_KEYS_ENV_NAMES = [
2540
+ 'KEYS_WORKER_URL',
2541
+ 'KEYS_CREDENTIAL',
2542
+ 'KEYS_VERIFY_KEYS',
2543
+ 'KEYS_AUDIT_TOKEN',
2544
+ ];
2545
+ /** https://github.com/wtfalch/integrations -- one root Dockerfile, three targets. */
2546
+ const INTEGRATIONS_REPO = 'wtfalch/integrations';
2547
+ /**
2548
+ * The three Coolify apps `apps/host/README.md`'s "Deploy: migrate, web,
2549
+ * worker" names, each with the root Dockerfile target it builds.
2550
+ */
2551
+ const INTEGRATIONS_MIGRATE_APP = { name: 'integrations-migrate', target: 'migration' };
2552
+ const INTEGRATIONS_RUNNER_APP = { name: 'integrations', target: 'runner' };
2553
+ const INTEGRATIONS_WORKER_APP = { name: 'integrations-worker', target: 'worker' };
2554
+ /** The runner's `EXPOSE 3000`; readiness is its own `HEALTHCHECK` on `/api/health`. */
2555
+ const INTEGRATIONS_RUNNER_PORT = '3000';
2556
+ const INTEGRATIONS_DB_NAME = 'integrations-db';
2557
+ /**
2558
+ * The Postgres database and owner name. The host's runtime role is derived
2559
+ * from the database, never configured: `drizzle/0001_runtime_role.sql`
2560
+ * creates `current_database() || '_rt'`, so the runtime URL below must name
2561
+ * exactly {@link INTEGRATIONS_RUNTIME_ROLE}.
2562
+ */
2563
+ const INTEGRATIONS_POSTGRES_DB = 'integrations';
2564
+ const INTEGRATIONS_RUNTIME_ROLE = `${INTEGRATIONS_POSTGRES_DB}_rt`;
2565
+ /**
2566
+ * `.env.example`'s own default. Only ever set when absent: every stored
2567
+ * registration client secret is bound to this id, so foundry never
2568
+ * overwrites a value a person changed.
2569
+ */
2570
+ const INTEGRATIONS_OPERATOR_TENANT_ID = 'operator';
2571
+ /** `.env.example`'s own default for one worker pass; only ever set when absent. */
2572
+ const INTEGRATIONS_WORKER_BATCH = '50';
2573
+ const INTEGRATIONS_DATABASE_SCOPE_KEY = 'coolify_integrations_database';
2574
+ const INTEGRATIONS_MIGRATE_APP_SCOPE_KEY = 'coolify_integrations_migrate_app';
2575
+ const INTEGRATIONS_RUNNER_APP_SCOPE_KEY = 'coolify_integrations_app';
2576
+ const INTEGRATIONS_WORKER_APP_SCOPE_KEY = 'coolify_integrations_worker_app';
2577
+ /** Set (to the gitRef it migrated) once that ref's migration succeeded and the app was stopped. */
2578
+ const INTEGRATIONS_MIGRATED_SCOPE_KEY = 'integrations_migrated';
2579
+ /** The ref the management private key (PKCS#8 PEM) is delivered under. */
2580
+ export const INTEGRATIONS_MANAGEMENT_KEY_REF = 'integrations:management:privateKey';
2581
+ /** `MANAGEMENT_ISSUER` on the integrations host: whoever signs with the delivered key signs as this issuer. */
2582
+ export const INTEGRATIONS_MANAGEMENT_ISSUER = 'foundry';
2583
+ /**
2584
+ * The key id the public half is pinned under in `MANAGEMENT_PUBLIC_KEYS`
2585
+ * (the host's keyring allows `^[A-Za-z0-9_-]{1,100}$`). A signer names it in
2586
+ * its signed request; fixed, since there is one key until rotation is built.
2587
+ */
2588
+ export const INTEGRATIONS_MANAGEMENT_KEY_ID = 'foundry-1';
2589
+ /**
2590
+ * #174: makes `coolify:app` keyed {@link PLATFORM_INTEGRATIONS_KEY} true --
2591
+ * the integrations host (https://github.com/wtfalch/integrations) as its own
2592
+ * `apps/host/README.md` ("Deploy: migrate, web, worker") describes it, in the
2593
+ * shared `platform` Coolify project `coolify:jobs` uses:
2594
+ *
2595
+ * 1. A Postgres database (`integrations-db`), whose superuser URL is
2596
+ * `DATABASE_URL_OWNER` and whose `integrations_rt` runtime role gets a
2597
+ * password minted here once, as `DATABASE_URL` -- the same owner/runtime
2598
+ * split `executor.ts`'s `ensureCmsRuntimeUrl` builds for the cms app.
2599
+ * 2. Three apps from the one repo, each on its own Dockerfile target (set by
2600
+ * `updateApplication`, since Coolify's create endpoint refuses
2601
+ * `dockerfile_target_build`): `integrations-migrate` (`migration`),
2602
+ * `integrations` (`runner`, at `hostname`) and `integrations-worker`
2603
+ * (`worker`). The host's own `@wtfalch/keys` settings
2604
+ * ({@link PlatformIntegrationsDeps.keysEnv}) are written to them.
2605
+ * 3. The migrate app first, once per `gitRef`: stop it if running, deploy it,
2606
+ * wait until it reports healthy, stop it, and record the ref it migrated.
2607
+ * Only then are the runner and worker deployed.
2608
+ * 4. The management key, once: an Ed25519 pair minted here, its id recorded
2609
+ * before delivery, its private half handed to
2610
+ * {@link PlatformIntegrationsDeps.deliverSecret} and its public half pinned
2611
+ * on the runner as `MANAGEMENT_PUBLIC_KEYS`, with `MANAGEMENT_ISSUER`. The
2612
+ * guard is recorded after both.
2613
+ * 5. The applied `hostname` and `gitRef` are recorded last. A later change
2614
+ * plans the step again (`planIntegrations`), which patches `git_branch`
2615
+ * and the runner's `domains`, reruns the migration for a new ref, and
2616
+ * redeploys. It never mints the key again.
2617
+ *
2618
+ * Every part is safe to run again after a throw partway: each id is recorded
2619
+ * and persisted as soon as its resource exists, and what to deploy is read
2620
+ * from the live apps. Secrets -- the database URLs, the `KEYS_*` values and
2621
+ * the private key -- go to `bulkUpsertEnvs`/`deliverSecret` and nowhere else:
2622
+ * never returned, recorded, logged or put in an error.
2623
+ *
2624
+ * Known gaps, left for review:
2625
+ * - "Wait for `integrations-migrate: done` in its logs" is approximated by
2626
+ * polling the app's Coolify `status` for `running:healthy`: `CoolifyPort`
2627
+ * has no way to read container logs. The `migration` target's own
2628
+ * `HEALTHCHECK` passes only once the file it touches right before printing
2629
+ * that line exists, so healthy means the same thing. `exited` never counts
2630
+ * as done.
2631
+ * - The worker is created and deployed when created or changed, but nothing
2632
+ * schedules it. Its image runs one queue pass and exits, so Coolify's
2633
+ * scheduled tasks (which run a command in a *running* container) have no
2634
+ * container to run in, and `wtfalch/jobs` delivers a schedule as an HTTP
2635
+ * POST to a receiver route the integrations host does not have. Choosing
2636
+ * between such a route and a long-running worker image is a decision for
2637
+ * the integrations repo. Until then queued messages wait for a manual pass.
2638
+ * - Changing `gitRef` to a ref that has new commits under the same name is
2639
+ * not seen: only a different ref name re-plans the step.
2640
+ * - Like `coolify:jobs`, the runner is reached through its Coolify `domains`
2641
+ * binding; no Cloudflare tunnel or DNS record is created for `hostname`.
2642
+ */
2643
+ async function provisionPlatformIntegrations(action, context, deps) {
2644
+ const { coolify } = deps;
2645
+ const hostname = action.desired.hostname;
2646
+ const gitRef = action.desired.gitRef;
2647
+ const runtime = deps.runtime ?? {};
2648
+ const sleep = runtime.sleep ?? defaultSleep;
2649
+ const recorded = context.recordedIds();
2650
+ const record = async (key, id) => {
2651
+ context.recordScopeId(key, id);
2652
+ await context.persist?.();
2653
+ };
2654
+ // ---- project and environment: the one shared `platform` project ---------
2655
+ let projectUuid = recorded[JOBS_PROJECT_SCOPE_KEY];
2656
+ if (!projectUuid) {
2657
+ const found = await coolify.findProjectByName(PLATFORM_COOLIFY_PROJECT_NAME);
2658
+ projectUuid = found
2659
+ ? found.uuid
2660
+ : (await coolify.createProject(PLATFORM_COOLIFY_PROJECT_NAME)).uuid;
2661
+ await record(JOBS_PROJECT_SCOPE_KEY, projectUuid);
2662
+ }
2663
+ const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
2664
+ // ---- database -------------------------------------------------------------
2665
+ let dbId = recorded[INTEGRATIONS_DATABASE_SCOPE_KEY];
2666
+ if (!dbId) {
2667
+ const found = (await coolify.listDatabases()).find((db) => db.name === INTEGRATIONS_DB_NAME && db.environment_id === environment.id);
2668
+ if (found) {
2669
+ dbId = found.uuid;
2670
+ }
2671
+ else {
2672
+ const server = await resolvePlatformServer(coolify);
2673
+ const created = await coolify.createPostgresDatabase({
2674
+ project_uuid: projectUuid,
2675
+ server_uuid: server.uuid,
2676
+ environment_name: environment.name,
2677
+ environment_uuid: environment.uuid,
2678
+ name: INTEGRATIONS_DB_NAME,
2679
+ postgres_db: INTEGRATIONS_POSTGRES_DB,
2680
+ postgres_user: INTEGRATIONS_POSTGRES_DB,
2681
+ });
2682
+ dbId = created.uuid;
2683
+ }
2684
+ await record(INTEGRATIONS_DATABASE_SCOPE_KEY, dbId);
2685
+ }
2686
+ const db = await coolify.getDatabase(dbId);
2687
+ if (!db.internal_db_url) {
2688
+ throw new Error(`platform:integrations: database ${dbId} has no internal_db_url yet; not deploying the integrations host without a database`);
2689
+ }
2690
+ const ownerUrl = db.internal_db_url;
2691
+ // ---- the three apps --------------------------------------------------------
2692
+ const createApp = (name, domains) => async () => {
2693
+ const server = await resolvePlatformServer(coolify);
2694
+ const githubApp = await coolify.requireGithubApp();
2695
+ return coolify.createGithubAppApplication({
2696
+ project_uuid: projectUuid,
2697
+ server_uuid: server.uuid,
2698
+ environment_name: environment.name,
2699
+ environment_uuid: environment.uuid,
2700
+ github_app_uuid: githubApp.uuid,
2701
+ git_repository: INTEGRATIONS_REPO,
2702
+ git_branch: gitRef,
2703
+ build_pack: 'dockerfile',
2704
+ name,
2705
+ ...(domains ? { domains } : {}),
2706
+ });
2707
+ };
2708
+ const migrateApp = await findOrCreatePlatformApp(coolify, INTEGRATIONS_MIGRATE_APP.name, environment, createApp(INTEGRATIONS_MIGRATE_APP.name));
2709
+ await record(INTEGRATIONS_MIGRATE_APP_SCOPE_KEY, migrateApp.appId);
2710
+ const runnerApp = await findOrCreatePlatformApp(coolify, INTEGRATIONS_RUNNER_APP.name, environment, createApp(INTEGRATIONS_RUNNER_APP.name, hostname));
2711
+ await record(INTEGRATIONS_RUNNER_APP_SCOPE_KEY, runnerApp.appId);
2712
+ const workerApp = await findOrCreatePlatformApp(coolify, INTEGRATIONS_WORKER_APP.name, environment, createApp(INTEGRATIONS_WORKER_APP.name));
2713
+ await record(INTEGRATIONS_WORKER_APP_SCOPE_KEY, workerApp.appId);
2714
+ // Set on every run, not only after a create: a throw right after a create
2715
+ // must not leave an app building the Dockerfile's last stage for good. The
2716
+ // branch and the runner's domain are set here too, so a changed `gitRef` or
2717
+ // `hostname` reaches an app that already exists.
2718
+ const configChanged = recorded[PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname ||
2719
+ recorded[PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY] !== gitRef;
2720
+ await coolify.updateApplication(migrateApp.appId, {
2721
+ dockerfile_target_build: INTEGRATIONS_MIGRATE_APP.target,
2722
+ git_branch: gitRef,
2723
+ });
2724
+ await coolify.updateApplication(runnerApp.appId, {
2725
+ dockerfile_target_build: INTEGRATIONS_RUNNER_APP.target,
2726
+ ports_exposes: INTEGRATIONS_RUNNER_PORT,
2727
+ git_branch: gitRef,
2728
+ domains: hostname,
2729
+ });
2730
+ await coolify.updateApplication(workerApp.appId, {
2731
+ dockerfile_target_build: INTEGRATIONS_WORKER_APP.target,
2732
+ git_branch: gitRef,
2733
+ });
2734
+ // ---- env ---------------------------------------------------------------------
2735
+ // One runtime URL for all three apps: the migrate job sets the role's
2736
+ // password from it, and the runner and worker connect with it.
2737
+ const runtimeUrl = await ensureIntegrationsRuntimeUrl(coolify, migrateApp.appId, ownerUrl);
2738
+ const keys = INTEGRATIONS_KEYS_ENV_NAMES.map((key) => ({
2739
+ key,
2740
+ value: deps.keysEnv[key] ?? '',
2741
+ }));
2742
+ if (keys.some((k) => !k.value)) {
2743
+ throw new Error('platform:integrations: the host supplies no value for the @wtfalch/keys settings; not deploying the integrations host');
2744
+ }
2745
+ const sharedEnvs = [
2746
+ { key: 'DATABASE_URL', value: runtimeUrl },
2747
+ { key: 'APP_URL', value: `https://${hostname}` },
2748
+ ...keys,
2749
+ ];
2750
+ const runnerEnvs = [
2751
+ ...sharedEnvs,
2752
+ ...(await envsIfAbsent(coolify, runnerApp.appId, [
2753
+ { key: 'INTEGRATIONS_OPERATOR_TENANT_ID', value: INTEGRATIONS_OPERATOR_TENANT_ID },
2754
+ ])),
2755
+ ];
2756
+ const workerEnvs = [
2757
+ ...sharedEnvs,
2758
+ ...(await envsIfAbsent(coolify, workerApp.appId, [
2759
+ { key: 'INTEGRATIONS_OPERATOR_TENANT_ID', value: INTEGRATIONS_OPERATOR_TENANT_ID },
2760
+ { key: 'INTEGRATIONS_WORKER_BATCH', value: INTEGRATIONS_WORKER_BATCH },
2761
+ ])),
2762
+ ];
2763
+ const migrateEnvs = [
2764
+ { key: 'DATABASE_URL_OWNER', value: ownerUrl },
2765
+ { key: 'DATABASE_URL', value: runtimeUrl },
2766
+ ...keys.filter((k) => k.key === 'KEYS_WORKER_URL' || k.key === 'KEYS_CREDENTIAL'),
2767
+ ];
2768
+ const runnerEnvsChanged = await envsDiffer(coolify, runnerApp.appId, runnerEnvs);
2769
+ const workerEnvsChanged = await envsDiffer(coolify, workerApp.appId, workerEnvs);
2770
+ await coolify.bulkUpsertEnvs(migrateApp.appId, migrateEnvs);
2771
+ await coolify.bulkUpsertEnvs(runnerApp.appId, runnerEnvs);
2772
+ await coolify.bulkUpsertEnvs(workerApp.appId, workerEnvs);
2773
+ // ---- migrate first, once per gitRef ---------------------------------------------
2774
+ if (context.recordedIds()[INTEGRATIONS_MIGRATED_SCOPE_KEY] !== gitRef) {
2775
+ // A running migrate app is stopped before the deploy is queued, so the old
2776
+ // container's healthy status is never read as the new deployment's.
2777
+ if (migrateApp.running)
2778
+ await coolify.raw('POST', `/applications/${migrateApp.appId}/stop`);
2779
+ await coolify.deploy(migrateApp.appId);
2780
+ await waitForIntegrationsMigration(coolify, migrateApp.appId, runtime, sleep);
2781
+ // It idles after migrating rather than exiting (the Dockerfile's own
2782
+ // comment), so it is stopped here; a person starts it again for
2783
+ // maintenance commands.
2784
+ await coolify.raw('POST', `/applications/${migrateApp.appId}/stop`);
2785
+ await record(INTEGRATIONS_MIGRATED_SCOPE_KEY, gitRef);
2786
+ }
2787
+ // ---- the management key, once --------------------------------------------------
2788
+ // The pair's id is recorded and persisted before the private half is
2789
+ // delivered. The guard is recorded only after delivery and the write of the
2790
+ // public half. A run that dies in between leaves the id without the guard:
2791
+ // the planner then reports the key and mints nothing, so a second pair can
2792
+ // never replace one the host may already hold.
2793
+ let keyJustSet = false;
2794
+ if (!context.recordedIds()[PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY]) {
2795
+ if (context.recordedIds()[PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY]) {
2796
+ 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`);
2797
+ }
2798
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
2799
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
2800
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
2801
+ });
2802
+ await record(PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, INTEGRATIONS_MANAGEMENT_KEY_ID);
2803
+ const attempts = runtime.deliverAttempts ?? 3;
2804
+ for (let attempt = 1;; attempt += 1) {
2805
+ try {
2806
+ await deps.deliverSecret(INTEGRATIONS_MANAGEMENT_KEY_REF, privateKey);
2807
+ break;
2808
+ }
2809
+ catch {
2810
+ // Retried; the error is dropped in case it echoes the key.
2811
+ if (attempt >= attempts) {
2812
+ throw new Error(`platform:integrations: management key ${INTEGRATIONS_MANAGEMENT_KEY_ID} was minted but could not be delivered after ${attempts} attempts`);
2813
+ }
2814
+ await sleep(1000 * attempt);
2815
+ }
2816
+ }
2817
+ await coolify.bulkUpsertEnvs(runnerApp.appId, [
2818
+ { key: 'MANAGEMENT_ISSUER', value: INTEGRATIONS_MANAGEMENT_ISSUER },
2819
+ {
2820
+ key: 'MANAGEMENT_PUBLIC_KEYS',
2821
+ value: JSON.stringify({ [INTEGRATIONS_MANAGEMENT_KEY_ID]: publicKey }),
2822
+ },
2823
+ ]);
2824
+ await record(PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, runnerApp.appId);
2825
+ keyJustSet = true;
2826
+ }
2827
+ // ---- then the runner and the worker ------------------------------------------------
2828
+ // Decided from the live state: an env change only reaches a container on its
2829
+ // next deploy, and a stopped runner is deployed so an earlier failed deploy
2830
+ // is retried. The worker exits after one pass by design, so "not running"
2831
+ // says nothing about it and it is deployed only when created or changed.
2832
+ if (runnerApp.justCreated ||
2833
+ !runnerApp.running ||
2834
+ runnerEnvsChanged ||
2835
+ keyJustSet ||
2836
+ configChanged) {
2837
+ await coolify.deploy(runnerApp.appId);
2838
+ }
2839
+ if (workerApp.justCreated || workerEnvsChanged || configChanged) {
2840
+ await coolify.deploy(workerApp.appId);
2841
+ }
2842
+ await record(PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
2843
+ await record(PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, gitRef);
2844
+ return { resourceId: runnerApp.appId };
2845
+ }
2846
+ /**
2847
+ * The runtime `DATABASE_URL` already on the migrate app, or a new one:
2848
+ * {@link INTEGRATIONS_RUNTIME_ROLE} with a password minted once from a
2849
+ * URL-safe alphabet (the host's `db-runtime-role.sh` refuses anything but
2850
+ * `A-Za-z0-9_.~-`), on the owner URL's host, database and query string --
2851
+ * `executor.ts`'s `ensureCmsRuntimeUrl`, with the same rule: never rotated,
2852
+ * because the migrate job set the live role's password from the first one.
2853
+ * A parse failure throws a fixed message, never the URL (Node's own
2854
+ * `ERR_INVALID_URL` carries the whole input, password included).
2855
+ */
2856
+ async function ensureIntegrationsRuntimeUrl(coolify, migrateAppId, ownerUrl) {
2857
+ const existing = (await coolify.listEnvs(migrateAppId)).find((e) => e.key === 'DATABASE_URL' && !e.is_preview && e.value);
2858
+ if (existing)
2859
+ return existing.value;
2860
+ let host;
2861
+ let pathname;
2862
+ let search;
2863
+ try {
2864
+ ({ host, pathname, search } = new URL(ownerUrl));
2865
+ }
2866
+ catch {
2867
+ throw new Error('platform:integrations: the database internal_db_url could not be parsed; not deploying the integrations host');
2868
+ }
2869
+ const password = randomBytes(24).toString('base64url');
2870
+ return `postgres://${INTEGRATIONS_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
2871
+ }
2872
+ /** The entries of `desired` whose key the app does not have yet -- never overwriting a value a person set. */
2873
+ async function envsIfAbsent(coolify, appId, desired) {
2874
+ const live = new Set((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => e.key));
2875
+ return desired.filter((e) => !live.has(e.key));
2876
+ }
2877
+ /**
2878
+ * Polls the migrate app until Coolify reports it `running:healthy` after
2879
+ * first reporting anything else -- a healthy status before that is the old
2880
+ * container, since the stop and deploy calls can return before Coolify's
2881
+ * status catches up. Its `HEALTHCHECK` passes only after
2882
+ * `integrations-migrate: done` (see
2883
+ * {@link provisionPlatformIntegrations}'s known gaps). Throws once
2884
+ * `migrateTimeoutMs` has passed. The runner and worker are never deployed
2885
+ * after a timeout.
2886
+ */
2887
+ async function waitForIntegrationsMigration(coolify, appId, runtime, sleep) {
2888
+ const now = runtime.now ?? Date.now;
2889
+ const timeout = runtime.migrateTimeoutMs ?? 20 * 60_000;
2890
+ const interval = runtime.migratePollMs ?? 10_000;
2891
+ const deadline = now() + timeout;
2892
+ let left = false;
2893
+ for (;;) {
2894
+ const { status } = await coolify.getApplication(appId);
2895
+ const healthy = status?.startsWith('running:healthy') === true;
2896
+ if (healthy && left)
2897
+ return;
2898
+ if (!healthy)
2899
+ left = true;
2900
+ if (now() >= deadline) {
2901
+ 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`);
2902
+ }
2903
+ await sleep(interval);
2904
+ }
2905
+ }
2906
+ /**
2907
+ * The real {@link PlatformCloudflareReader}: the platform's apex zone as
2908
+ * `planApexZone` expects to see it, and nothing else. The zone's DNS records
2909
+ * and tunnels belong to no declared platform step yet, so listing them would
2910
+ * only report them unmanaged.
2911
+ *
2912
+ * The zone is fetched by the id the last apply recorded when there is one,
2913
+ * because Cloudflare's zone list can lag a create; with no recorded id it is
2914
+ * found by apex.
2915
+ */
2916
+ export class ProvidersPlatformCloudflareReader {
2917
+ client;
2918
+ platformStore;
2919
+ constructor(client, platformStore) {
2920
+ this.client = client;
2921
+ this.platformStore = platformStore;
2922
+ }
2923
+ async listResources() {
2924
+ const platform = await this.platformStore.get();
2925
+ if (!platform)
2926
+ return [];
2927
+ const recordedId = (await this.platformStore.getServiceIds())[apexZoneKey(platform.apex)];
2928
+ const zone = (recordedId && (await recordedZone(this.client, recordedId, platform.apex))) ||
2929
+ (await exactZone(this.client, platform.apex));
2930
+ if (!zone)
2931
+ return [];
2932
+ return [
2933
+ {
2934
+ system: 'cloudflare',
2935
+ kind: 'zone',
2936
+ key: platform.apex,
2937
+ config: { id: zone.id, status: zone.status, name_servers: zone.name_servers },
2938
+ },
2939
+ ];
2940
+ }
2941
+ }
2942
+ /**
2943
+ * #173: the real {@link PlatformCoolifyReader} for the jobs scheduler app --
2944
+ * the recorded-id half of `ProvidersPlatformCloudflareReader`'s own strategy
2945
+ * above, with no list-by-name fallback: unlike a Cloudflare zone (which
2946
+ * exists before foundry ever touches it), the scheduler app has no identity
2947
+ * to adopt until this package creates it, so "nothing recorded yet" and
2948
+ * "nothing live yet" are the same state here. A recorded id Coolify no
2949
+ * longer has (deleted, or moved) reads as not-live, the same "stale id never
2950
+ * blocks planning for good" rule {@link recordedZone} follows.
2951
+ */
2952
+ export class ProvidersPlatformCoolifyReader {
2953
+ client;
2954
+ platformStore;
2955
+ constructor(client, platformStore) {
2956
+ this.client = client;
2957
+ this.platformStore = platformStore;
2958
+ }
2959
+ async listResources() {
2960
+ const recordedId = (await this.platformStore.getServiceIds())[JOBS_APP_KEY];
2961
+ if (!recordedId)
2962
+ return [];
2963
+ try {
2964
+ const app = await this.client.getApplication(recordedId);
2965
+ return [
2966
+ {
2967
+ system: 'coolify',
2968
+ kind: 'jobs',
2969
+ key: JOBS_SCHEDULER_KEY,
2970
+ config: { uuid: app.uuid, status: app.status },
2971
+ },
2972
+ ];
2973
+ }
2974
+ catch {
2975
+ return [];
2976
+ }
2977
+ }
2978
+ }
2979
+ /**
2980
+ * `findZoneByApex` returns the longest suffix match, so for a subdomain it
2981
+ * answers with the parent zone. The platform's zone is its apex exactly;
2982
+ * any other zone is never adopted or reported as the platform's.
2983
+ */
2984
+ async function exactZone(client, apex) {
2985
+ const zone = await client.findZoneByApex(apex);
2986
+ return zone && zone.name === apex ? zone : undefined;
2987
+ }
2988
+ /**
2989
+ * The zone the last apply recorded, or `undefined` when Cloudflare no longer
2990
+ * has it under that id (deleted, or moved to another account) or it is no
2991
+ * longer the apex. The caller then falls back to finding it by apex, so a
2992
+ * stale recorded id never blocks planning for good.
2993
+ */
2994
+ async function recordedZone(client, id, apex) {
2995
+ try {
2996
+ const zone = await client.getZone(id);
2997
+ return zone.name === apex ? zone : undefined;
2998
+ }
2999
+ catch {
3000
+ return undefined;
3001
+ }
3002
+ }
3003
+ /**
3004
+ * What Archon needs from each service before its step may run: the service's
3005
+ * own applied step (`coolify:app:<stepKey>`), scope ids its step recorded, and
3006
+ * held keys its step delivered. A missing one blocks Archon's plan. Archon
3007
+ * only reads these; it never mints them.
3008
+ */
3009
+ const ARCHON_SERVICE_INPUTS = {
3010
+ files: {
3011
+ stepKey: PLATFORM_FILES_KEY,
3012
+ scopeKeys: [PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY],
3013
+ refs: [FILES_MANAGEMENT_KEY_REF, FILES_INTROSPECTION_SECRET_REF],
3014
+ },
3015
+ ai: {
3016
+ stepKey: PLATFORM_AI_KEY,
3017
+ scopeKeys: [
3018
+ PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY,
3019
+ PLATFORM_AI_ORGANISATION_SCOPE_KEY,
3020
+ PLATFORM_AI_INTERNAL_URL_SCOPE_KEY,
3021
+ ],
3022
+ refs: [PLATFORM_AI_MANAGEMENT_KEY_REF],
3023
+ },
3024
+ integrations: {
3025
+ stepKey: PLATFORM_INTEGRATIONS_KEY,
3026
+ scopeKeys: [],
3027
+ refs: [INTEGRATIONS_MANAGEMENT_KEY_REF],
3028
+ },
3029
+ };