@wtfalch/foundry 0.3.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.
- package/dist/bootstrap/bootstrap.d.ts +96 -0
- package/dist/bootstrap/bootstrap.js +285 -0
- package/dist/bootstrap/declare.d.ts +36 -0
- package/dist/bootstrap/declare.js +94 -0
- package/dist/bootstrap/drive.d.ts +40 -0
- package/dist/bootstrap/drive.js +51 -0
- package/dist/bootstrap/index.d.ts +3 -0
- package/dist/bootstrap/index.js +1 -0
- package/dist/calls-sfu-deploy-support.d.ts +9 -6
- package/dist/calls-sfu-deploy-support.js +9 -6
- package/dist/calls-sfu-keys.d.ts +21 -0
- package/dist/calls-sfu-keys.js +27 -0
- package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
- package/dist/calls-sfu-ssh-adapter.js +78 -0
- package/dist/calls-sfu-ssh-client.d.ts +49 -0
- package/dist/calls-sfu-ssh-client.js +82 -0
- package/dist/calls-sfu.d.ts +22 -4
- package/dist/calls-sfu.js +22 -4
- package/dist/index.d.ts +31 -6
- package/dist/index.js +16 -3
- package/dist/migrations/0014_calls_enabled.sql +7 -0
- package/dist/migrations/0015_domain_unique.sql +39 -0
- package/dist/migrations/0016_platform.sql +61 -0
- package/dist/migrations/0017_requested_changes.sql +42 -0
- package/dist/migrations/0018_platform_apply.sql +19 -0
- package/dist/org.d.ts +18 -2
- package/dist/org.js +56 -0
- package/dist/plan/agora.d.ts +77 -0
- package/dist/plan/agora.js +127 -0
- package/dist/plan/calls-matrix.d.ts +135 -0
- package/dist/plan/calls-matrix.js +275 -0
- package/dist/plan/cloudflared-connector.d.ts +70 -0
- package/dist/plan/cloudflared-connector.js +106 -0
- package/dist/plan/diff.d.ts +26 -5
- package/dist/plan/diff.js +720 -106
- package/dist/plan/executor.d.ts +731 -36
- package/dist/plan/executor.js +1180 -41
- package/dist/plan/index.d.ts +22 -5
- package/dist/plan/index.js +12 -2
- package/dist/plan/jobs-scheduler-http.d.ts +29 -0
- package/dist/plan/jobs-scheduler-http.js +50 -0
- package/dist/plan/live.d.ts +51 -2
- package/dist/plan/live.js +8 -0
- package/dist/plan/plan-apply.d.ts +192 -3
- package/dist/plan/plan-apply.js +290 -14
- package/dist/plan/platform-diff.d.ts +312 -0
- package/dist/plan/platform-diff.js +867 -0
- package/dist/plan/platform-org-ports.d.ts +50 -0
- package/dist/plan/platform-org-ports.js +70 -0
- package/dist/plan/platform-org.d.ts +120 -0
- package/dist/plan/platform-org.js +115 -0
- package/dist/plan/platform-providers.d.ts +501 -0
- package/dist/plan/platform-providers.js +3029 -0
- package/dist/plan/platform-sfu.d.ts +93 -0
- package/dist/plan/platform-sfu.js +100 -0
- package/dist/plan/providers-readers.js +22 -1
- package/dist/plan/zitadel-machine-user.d.ts +34 -0
- package/dist/plan/zitadel-machine-user.js +95 -0
- package/dist/plan.d.ts +6 -0
- package/dist/platform.d.ts +55 -0
- package/dist/platform.js +46 -0
- package/dist/requested-changes.d.ts +108 -0
- package/dist/requested-changes.js +95 -0
- package/dist/slug.js +21 -2
- package/dist/store/audit.js +4 -2
- package/dist/store/postgres.d.ts +40 -7
- package/dist/store/postgres.js +257 -29
- package/dist/store/schema.d.ts +700 -0
- package/dist/store/schema.js +58 -0
- package/dist/store.d.ts +56 -0
- package/package.json +2 -2
|
@@ -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
|
+
};
|