@wtfalch/foundry 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/bootstrap.d.ts +111 -0
- package/dist/bootstrap/bootstrap.js +323 -0
- package/dist/bootstrap/declare.d.ts +36 -0
- package/dist/bootstrap/declare.js +99 -0
- package/dist/bootstrap/drive.d.ts +40 -0
- package/dist/bootstrap/drive.js +51 -0
- package/dist/bootstrap/index.d.ts +3 -0
- package/dist/bootstrap/index.js +1 -0
- package/dist/calls-sfu-deploy-support.d.ts +17 -6
- package/dist/calls-sfu-deploy-support.js +19 -7
- package/dist/calls-sfu-keys.d.ts +21 -0
- package/dist/calls-sfu-keys.js +27 -0
- package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
- package/dist/calls-sfu-ssh-adapter.js +78 -0
- package/dist/calls-sfu-ssh-client.d.ts +49 -0
- package/dist/calls-sfu-ssh-client.js +82 -0
- package/dist/calls-sfu.d.ts +36 -12
- package/dist/calls-sfu.js +41 -9
- package/dist/index.d.ts +38 -7
- package/dist/index.js +20 -4
- package/dist/migrations/0016_platform.sql +61 -0
- package/dist/migrations/0017_requested_changes.sql +42 -0
- package/dist/migrations/0018_platform_apply.sql +19 -0
- package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
- package/dist/migrations/0020_mail_apps.sql +7 -0
- package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
- package/dist/org.d.ts +81 -0
- package/dist/org.js +96 -0
- package/dist/plan/agora.d.ts +77 -0
- package/dist/plan/agora.js +127 -0
- package/dist/plan/cloudflared-connector.d.ts +70 -0
- package/dist/plan/cloudflared-connector.js +106 -0
- package/dist/plan/diff.d.ts +20 -3
- package/dist/plan/diff.js +713 -115
- package/dist/plan/executor.d.ts +680 -172
- package/dist/plan/executor.js +755 -189
- package/dist/plan/index.d.ts +28 -5
- package/dist/plan/index.js +15 -2
- package/dist/plan/jobs-scheduler-http.d.ts +29 -0
- package/dist/plan/jobs-scheduler-http.js +50 -0
- package/dist/plan/live.d.ts +51 -2
- package/dist/plan/live.js +8 -0
- package/dist/plan/mail-copy.d.ts +125 -0
- package/dist/plan/mail-copy.js +166 -0
- package/dist/plan/mail-identity-allowlist.d.ts +50 -0
- package/dist/plan/mail-identity-allowlist.js +11 -0
- package/dist/plan/mail-identity.d.ts +68 -0
- package/dist/plan/mail-identity.js +17 -0
- package/dist/plan/plan-apply.d.ts +197 -3
- package/dist/plan/plan-apply.js +278 -9
- package/dist/plan/platform-diff.d.ts +487 -0
- package/dist/plan/platform-diff.js +1220 -0
- package/dist/plan/platform-org-ports.d.ts +50 -0
- package/dist/plan/platform-org-ports.js +70 -0
- package/dist/plan/platform-org.d.ts +121 -0
- package/dist/plan/platform-org.js +115 -0
- package/dist/plan/platform-providers.d.ts +615 -0
- package/dist/plan/platform-providers.js +4410 -0
- package/dist/plan/platform-sfu.d.ts +128 -0
- package/dist/plan/platform-sfu.js +142 -0
- package/dist/plan/providers-readers.js +22 -1
- package/dist/plan/zitadel-machine-user.d.ts +83 -0
- package/dist/plan/zitadel-machine-user.js +238 -0
- package/dist/plan.d.ts +6 -0
- package/dist/platform.d.ts +55 -0
- package/dist/platform.js +46 -0
- package/dist/requested-changes.d.ts +118 -0
- package/dist/requested-changes.js +125 -0
- package/dist/slug.js +15 -2
- package/dist/store/audit.js +4 -2
- package/dist/store/postgres.d.ts +29 -1
- package/dist/store/postgres.js +203 -4
- package/dist/store/schema.d.ts +755 -0
- package/dist/store/schema.js +70 -0
- package/dist/store.d.ts +53 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Plan` (#19's public contract) a {@link PlatformPlanDetail} carries,
|
|
3
|
+
* mirroring `diff.ts`'s `toPlan`. There is no separate platform-shaped `Plan`
|
|
4
|
+
* type: `orgId` is stamped with the platform's own `ownerOrgId` -- Company
|
|
5
|
+
* Included's own {@link OrgId}, the org whose money and access the platform's
|
|
6
|
+
* resources are billed and owned by (`platform.ts`'s `DeclaredPlatform.ownerOrgId`
|
|
7
|
+
* doc comment) -- so `AuditLog`/`AuthzGate` (`plan-apply.ts`), both keyed by
|
|
8
|
+
* `OrgId`, need no platform-specific shape of their own either.
|
|
9
|
+
*/
|
|
10
|
+
export function toPlatformPlan(detail, ownerOrgId) {
|
|
11
|
+
return {
|
|
12
|
+
orgId: ownerOrgId,
|
|
13
|
+
steps: detail.plannedSteps.map((p) => p.step),
|
|
14
|
+
unmanaged: detail.unmanaged,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function resourceKey(system, kind, key) {
|
|
18
|
+
return `${system}:${kind}:${key}`;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* #168: the resource key `planAuth` plans under and `FoundryPlatformStepExecutor`
|
|
22
|
+
* (`platform-providers.ts`) dispatches on -- `platform:<service>`, exactly the
|
|
23
|
+
* shape `planPlatform`'s own doc comment above already named as the eventual
|
|
24
|
+
* convention, so a platform service's `coolify:app` step never collides with
|
|
25
|
+
* an org's own (which is keyed by that org's id).
|
|
26
|
+
*/
|
|
27
|
+
export const PLATFORM_AUTH_KEY = 'platform:auth';
|
|
28
|
+
/**
|
|
29
|
+
* Scope keys (colon-free, so never mistaken for a `system:kind:key` resource
|
|
30
|
+
* id) for foundry's ZITADEL admin machine user. The delivered key is the guard
|
|
31
|
+
* that skips the mint, recorded only once the token reached the host. The
|
|
32
|
+
* undelivered key is recorded first, the moment the user is minted: an id
|
|
33
|
+
* under it with no delivered key means the one-time token was lost, and the
|
|
34
|
+
* planner reports that instead of minting again.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* #175: the resource key `planArchon` plans under and
|
|
38
|
+
* `FoundryPlatformStepExecutor` (`platform-providers.ts`) dispatches on --
|
|
39
|
+
* the same `platform:<service>` shape as {@link PLATFORM_AUTH_KEY}.
|
|
40
|
+
*/
|
|
41
|
+
export const PLATFORM_ARCHON_KEY = 'platform:archon';
|
|
42
|
+
/**
|
|
43
|
+
* #175: the hostname and `gitRef` the last successful Archon run applied.
|
|
44
|
+
* `planArchon` plans the step again when either differs from the declared
|
|
45
|
+
* value; a re-run patches `git_branch`, `APP_URL` and the sign-in client and
|
|
46
|
+
* redeploys, and mints nothing.
|
|
47
|
+
*/
|
|
48
|
+
export const PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY = 'archon_applied_hostname';
|
|
49
|
+
export const PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY = 'archon_applied_git_ref';
|
|
50
|
+
/**
|
|
51
|
+
* #211 round 4: the broker app id Archon last registered its entry with.
|
|
52
|
+
* `provisionPlatformArchon` reads the broker to register with from
|
|
53
|
+
* {@link PLATFORM_AUTH_BROKER_SCOPE_KEY} alone, so a broker recreated under a
|
|
54
|
+
* new app id (the old one deleted by hand, or `platform:auth` minting a fresh
|
|
55
|
+
* one) leaves Archon "applied" against a broker that no longer holds its
|
|
56
|
+
* entry. `planArchon` plans the step again whenever this differs from the
|
|
57
|
+
* currently recorded {@link PLATFORM_AUTH_BROKER_SCOPE_KEY}, the same way a
|
|
58
|
+
* changed hostname or `gitRef` does.
|
|
59
|
+
*/
|
|
60
|
+
export const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = 'archon_applied_broker_app';
|
|
61
|
+
/**
|
|
62
|
+
* #175: set to '1' before the step points the tunnel at `http_status:404`
|
|
63
|
+
* (Access app rebuild), cleared to '' once the real ingress is written back.
|
|
64
|
+
* While it is non-empty `planArchon` plans the step, so a run that failed in
|
|
65
|
+
* between is repaired by the next one.
|
|
66
|
+
*/
|
|
67
|
+
export const PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY = 'archon_gate_closed';
|
|
68
|
+
export const PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY = 'zitadel_platform_admin_user_id';
|
|
69
|
+
export const PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY = 'zitadel_platform_admin_user_undelivered';
|
|
70
|
+
/**
|
|
71
|
+
* #216 (building on #209/#215): the `IAM_LOGIN_CLIENT` machine user minted
|
|
72
|
+
* for the login app and the broker, mirroring
|
|
73
|
+
* {@link PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY}/
|
|
74
|
+
* {@link PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} in shape -- a recorded
|
|
75
|
+
* id once delivered, an undelivered id recorded first, the moment the user
|
|
76
|
+
* is minted.
|
|
77
|
+
*
|
|
78
|
+
* Unlike the admin pair, an undelivered login-client key never blocks
|
|
79
|
+
* {@link planAuth} from planning the step again -- #216 round 2: `planAuth`
|
|
80
|
+
* below does read it, but only to surface a non-blocking note (alongside a
|
|
81
|
+
* planned step, the way #211 round 3's broker-deploy block already is)
|
|
82
|
+
* rather than to hold the step back. Revoking a stale login-client PAT
|
|
83
|
+
* destroys nothing -- unlike the admin user's, whose loss the admin pair's
|
|
84
|
+
* own "delete that user by hand" message exists for because nothing else
|
|
85
|
+
* here re-derives the ZITADEL masterkey a lost admin PAT might have been the
|
|
86
|
+
* only record of -- so `mintLoginClientMachineUser` (`zitadel-machine-user.ts`)
|
|
87
|
+
* always lists and deletes every PAT the user already holds before minting a
|
|
88
|
+
* fresh one. That is what lets `provisionPlatformAuth` simply re-mint on the
|
|
89
|
+
* next run, with no manual step, which is #216's whole fix for the bug PR
|
|
90
|
+
* #215 shipped with: PR #215 mirrored the admin pair's forever-blocking
|
|
91
|
+
* behaviour for this key too, which meant one failed Coolify call lost the
|
|
92
|
+
* login-client token for good.
|
|
93
|
+
*
|
|
94
|
+
* #216 round 4: the value recorded here is JSON ({@link
|
|
95
|
+
* PlatformAuthLoginClientRecord}, parsed back by
|
|
96
|
+
* {@link parseLoginClientRecord}), not the bare user id round 2-3 wrote --
|
|
97
|
+
* the user id alone can never tell a healthy delivery from one a concurrent
|
|
98
|
+
* apply has since clobbered, because `mintLoginClientMachineUser` reuses the
|
|
99
|
+
* same user on every mint. The PAT id travels alongside it so {@link
|
|
100
|
+
* planAuth} can ask ZITADEL, on every plan, whether that exact PAT is still
|
|
101
|
+
* the sole live one.
|
|
102
|
+
*
|
|
103
|
+
* #216 round 5: that alone is still not enough -- a recorded PAT can be the
|
|
104
|
+
* sole live one in ZITADEL while Coolify holds a *different*, already-dead
|
|
105
|
+
* token, when two concurrent applies interleave their own writes and
|
|
106
|
+
* records in the right (wrong) order (see `authLoginClientDrifted`'s own
|
|
107
|
+
* doc comment in `platform-providers.ts` for the exact ordering). So the
|
|
108
|
+
* record also carries `tokenHash`, a sha256 of the exact token that was
|
|
109
|
+
* delivered -- never the token itself -- which `authLoginClientDrifted`
|
|
110
|
+
* compares against what is actually live in both apps' Coolify envs.
|
|
111
|
+
*/
|
|
112
|
+
export const PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY = 'zitadel_platform_login_client_user_id';
|
|
113
|
+
export const PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY = 'zitadel_platform_login_client_user_undelivered';
|
|
114
|
+
/**
|
|
115
|
+
* Parses a {@link PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY} scope id back
|
|
116
|
+
* into a {@link PlatformAuthLoginClientRecord}. `null` for anything that is
|
|
117
|
+
* not one -- absent, the pre-round-4 plain user-id string, the round 2-4
|
|
118
|
+
* `{userId, patId}` shape with no `tokenHash`, or corrupt JSON -- the same
|
|
119
|
+
* "malformed reads as unapplied, never throws" rule
|
|
120
|
+
* {@link parseBrokerDeployRecord} follows and for the same reason: this is a
|
|
121
|
+
* planning read, not a validation boundary. A pre-round-5 record reading as
|
|
122
|
+
* unapplied is the intended migration: the next apply's fresh mint recomputes
|
|
123
|
+
* it in the new shape, the same way a pre-round-4 plain-string record already
|
|
124
|
+
* did.
|
|
125
|
+
*/
|
|
126
|
+
export function parseLoginClientRecord(raw) {
|
|
127
|
+
if (!raw)
|
|
128
|
+
return null;
|
|
129
|
+
let parsed;
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(raw);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
137
|
+
return null;
|
|
138
|
+
const candidate = parsed;
|
|
139
|
+
if (typeof candidate.userId !== 'string' || !candidate.userId)
|
|
140
|
+
return null;
|
|
141
|
+
if (typeof candidate.patId !== 'string' || !candidate.patId)
|
|
142
|
+
return null;
|
|
143
|
+
if (typeof candidate.tokenHash !== 'string' || !candidate.tokenHash)
|
|
144
|
+
return null;
|
|
145
|
+
return { userId: candidate.userId, patId: candidate.patId, tokenHash: candidate.tokenHash };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* #204/#211: the sign-in broker app `provisionPlatformAuth` creates alongside
|
|
149
|
+
* ZITADEL and the login app. Defined here, not in `platform-providers.ts`
|
|
150
|
+
* (which imports it), because `planAuth` below needs it too -- a recorded
|
|
151
|
+
* `coolify:app:platform:auth` key with no recorded broker id is a platform
|
|
152
|
+
* provisioned before #204 shipped, and must still plan the step again so the
|
|
153
|
+
* broker gets created rather than being read as fully applied.
|
|
154
|
+
*/
|
|
155
|
+
export const PLATFORM_AUTH_BROKER_SCOPE_KEY = 'coolify_platform_auth_broker_app';
|
|
156
|
+
/**
|
|
157
|
+
* #211: recorded once the broker has actually been deployed -- distinct from
|
|
158
|
+
* {@link PLATFORM_AUTH_BROKER_SCOPE_KEY} (the app exists) precisely because
|
|
159
|
+
* the app can exist for a while before it is ever deployed (see
|
|
160
|
+
* `missingBrokerInputs` in `platform-providers.ts`). Defined here, not there,
|
|
161
|
+
* for the same reason as {@link PLATFORM_AUTH_BROKER_SCOPE_KEY}: `planAuth`
|
|
162
|
+
* below needs it too, so a broker that is created but not yet deployed still
|
|
163
|
+
* plans the step again instead of reading as fully applied for good.
|
|
164
|
+
*/
|
|
165
|
+
export const PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY = 'coolify_platform_auth_broker_deployed';
|
|
166
|
+
/**
|
|
167
|
+
* Parses a {@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY} scope id back into
|
|
168
|
+
* a {@link BrokerDeployRecord}. `null` for anything that is not one --
|
|
169
|
+
* absent, the pre-round-5 plain broker-id string, or corrupt JSON -- because
|
|
170
|
+
* this is a planning read, not a validation boundary: a malformed record
|
|
171
|
+
* must read as "not deployed" so the step plans (and overwrites it) again,
|
|
172
|
+
* never throw and take the rest of the plan down with it. Exported here
|
|
173
|
+
* rather than defined in `platform-providers.ts` for the same reason
|
|
174
|
+
* {@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY} itself was moved here in
|
|
175
|
+
* round 2 -- {@link planAuth} below needs it too, without a circular import.
|
|
176
|
+
*/
|
|
177
|
+
export function parseBrokerDeployRecord(raw) {
|
|
178
|
+
if (!raw)
|
|
179
|
+
return null;
|
|
180
|
+
let parsed;
|
|
181
|
+
try {
|
|
182
|
+
parsed = JSON.parse(raw);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
188
|
+
return null;
|
|
189
|
+
const candidate = parsed;
|
|
190
|
+
if (typeof candidate.brokerAppId !== 'string' || !candidate.brokerAppId)
|
|
191
|
+
return null;
|
|
192
|
+
if (typeof candidate.authAppsHash !== 'string' || !candidate.authAppsHash)
|
|
193
|
+
return null;
|
|
194
|
+
return { brokerAppId: candidate.brokerAppId, authAppsHash: candidate.authAppsHash };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* #197: the `cloudflared` connector app `provisionPlatformAuth` runs for its
|
|
198
|
+
* own tunnel. Consulted by `planAuth` below so that a tunnel recorded with no
|
|
199
|
+
* connector recorded is not treated as fully applied -- the same rule that
|
|
200
|
+
* lets an instance provisioned before #197 shipped pick up a connector on its
|
|
201
|
+
* next apply, without minting anything again.
|
|
202
|
+
*/
|
|
203
|
+
export const PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_auth_connector_app';
|
|
204
|
+
/** #197: the same connector scope id for the mail step's own tunnel, read by `planMail`. */
|
|
205
|
+
export const PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_mail_connector_app';
|
|
206
|
+
/** #197: the same connector scope id for the Archon step's own tunnel, read by `planArchon`. */
|
|
207
|
+
export const PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_archon_connector_app';
|
|
208
|
+
/**
|
|
209
|
+
* #213: the three held-key refs, under tenant `platform`, Archon's own
|
|
210
|
+
* onboarding reads before it can start (archon#101 round 3, archon#104).
|
|
211
|
+
* `planArchon`'s step puts each one, once, before Archon's app deploys; an
|
|
212
|
+
* entry already held is left alone, never overwritten
|
|
213
|
+
* (`putArchonMasterKeys` in `platform-providers.ts`). The Coolify URL and
|
|
214
|
+
* token are values this package already deploys with; the Cloudflare
|
|
215
|
+
* parent token ("API Tokens Write", so Archon can mint its own scoped
|
|
216
|
+
* child tokens from it) is a human-created credential the host supplies --
|
|
217
|
+
* neither Coolify nor Cloudflare lets either be minted over an API (#213
|
|
218
|
+
* "Human steps").
|
|
219
|
+
*/
|
|
220
|
+
export const PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF = 'platform:master:coolify-url';
|
|
221
|
+
export const PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF = 'platform:master:coolify-token';
|
|
222
|
+
export const PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF = 'platform:master:cloudflare-token';
|
|
223
|
+
/**
|
|
224
|
+
* #169: `platform:<service>` for the platform's own mail server (Stalwart),
|
|
225
|
+
* mirroring {@link PLATFORM_AUTH_KEY} exactly.
|
|
226
|
+
*/
|
|
227
|
+
export const PLATFORM_MAIL_KEY = 'platform:mail';
|
|
228
|
+
/**
|
|
229
|
+
* Scope keys for Stalwart's admin API key, mirroring
|
|
230
|
+
* {@link PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY}/{@link PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY}
|
|
231
|
+
* -- except `createApiKey` (`@wtfalch/email/postmaster`'s `StalwartClient`)
|
|
232
|
+
* returns only the minted secret, never an id, so there is no id to record
|
|
233
|
+
* the way ZITADEL's `userId` is. `PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION`
|
|
234
|
+
* (`platform-providers.ts`) is recorded instead -- the fixed description the
|
|
235
|
+
* key is minted under, which is what a human revokes it by in Stalwart's own
|
|
236
|
+
* admin UI once it is lost.
|
|
237
|
+
*/
|
|
238
|
+
export const PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY = 'stalwart_platform_admin_key_delivered';
|
|
239
|
+
export const PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY = 'stalwart_platform_admin_key_undelivered';
|
|
240
|
+
/**
|
|
241
|
+
* #169: the hostname and `gitRef` (the Stalwart image tag) the last successful
|
|
242
|
+
* mail run applied. `planMail` plans the step again when either differs from
|
|
243
|
+
* the declared value; a re-run patches the image tag and
|
|
244
|
+
* `STALWART_PUBLIC_URL`, redeploys, and mints nothing.
|
|
245
|
+
*/
|
|
246
|
+
export const PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY = 'mail_applied_hostname';
|
|
247
|
+
export const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = 'mail_applied_git_ref';
|
|
248
|
+
/**
|
|
249
|
+
* #177: where the mail server answers on the Coolify network
|
|
250
|
+
* (`http://<custom_internal_name>:<port>`), recorded by the step the same way
|
|
251
|
+
* {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY}/{@link PLATFORM_AI_INTERNAL_URL_SCOPE_KEY}
|
|
252
|
+
* already are -- what an org's own `coolify:mail`/`coolify:agora` steps read
|
|
253
|
+
* for `AGORA_MAIL_UPSTREAM` (`platform-org.ts`'s `agoraHostValuesFromPlatform`)
|
|
254
|
+
* instead of a literal a host used to have to supply. Not a secret: Stalwart's
|
|
255
|
+
* own internal address, never its admin credential.
|
|
256
|
+
*/
|
|
257
|
+
export const PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY = 'mail_internal_url';
|
|
258
|
+
/**
|
|
259
|
+
* #177: the one host the platform's mail server takes SMTP/MX on, and so the
|
|
260
|
+
* `mailHost` every org's own mail step writes. `provisionPlatformMail` (the
|
|
261
|
+
* DNS record and the server) and `platform-org.ts` (each org's mail step)
|
|
262
|
+
* both call this, so the two cannot name different hosts. Not
|
|
263
|
+
* `platform.mail.hostname`, which is the web (JMAP/admin) host.
|
|
264
|
+
*/
|
|
265
|
+
export function platformMailMxHost(apex) {
|
|
266
|
+
return `mx.${apex}`;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* #177: the platform apex and mail host an org's `coolify:mail` step last
|
|
270
|
+
* wrote, recorded in that org's applied state. `diff.ts`'s `planMail` plans
|
|
271
|
+
* the step again when either differs from the platform's current values, so a
|
|
272
|
+
* later platform address change reaches orgs that are already applied.
|
|
273
|
+
*/
|
|
274
|
+
export const ORG_MAIL_APPLIED_APEX_SCOPE_KEY = 'mail_applied_apex';
|
|
275
|
+
export const ORG_MAIL_APPLIED_HOST_SCOPE_KEY = 'mail_applied_host';
|
|
276
|
+
/**
|
|
277
|
+
* #172: the resource key `planAi` plans under and `FoundryPlatformStepExecutor`
|
|
278
|
+
* dispatches on, the same `platform:<service>` shape as
|
|
279
|
+
* {@link PLATFORM_AUTH_KEY}. The step's own recorded resource id (the ai
|
|
280
|
+
* host's runner app) lands at `coolify:app:platform:ai`.
|
|
281
|
+
*/
|
|
282
|
+
export const PLATFORM_AI_KEY = 'platform:ai';
|
|
283
|
+
/**
|
|
284
|
+
* #172: where the ai host answers on the Coolify network
|
|
285
|
+
* (`http://<custom_internal_name>:<port>`), recorded by the step so a host
|
|
286
|
+
* building `AiOrganisationPort` (`executor.ts`) can reach it without a
|
|
287
|
+
* public hostname. Holds whichever way the public-hostname decision goes.
|
|
288
|
+
*/
|
|
289
|
+
export const PLATFORM_AI_INTERNAL_URL_SCOPE_KEY = 'ai_platform_internal_url';
|
|
290
|
+
/**
|
|
291
|
+
* #172: the same two-key discipline as the ZITADEL admin key above, for
|
|
292
|
+
* foundry's AI management key (the bearer key `ai:organisation`, `ai:budget`
|
|
293
|
+
* and `ai:key` use). The undelivered key holds the credential id the moment
|
|
294
|
+
* it is minted; the delivered key is recorded only once the secret reached
|
|
295
|
+
* the host, and is what skips the mint on a later run.
|
|
296
|
+
*/
|
|
297
|
+
export const PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY = 'ai_platform_management_key_id';
|
|
298
|
+
export const PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY = 'ai_platform_management_key_undelivered';
|
|
299
|
+
/**
|
|
300
|
+
* #201: set when ai's `POST /v1/platform/keys` reports its `archon` key
|
|
301
|
+
* already exists but returns no secret -- a mint this record never learned
|
|
302
|
+
* about (most likely the crash window {@link PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY}'s
|
|
303
|
+
* own comment describes: the secret returned once and was never recorded).
|
|
304
|
+
* ai shows a credential's secret only at mint time, so it cannot be
|
|
305
|
+
* recovered; the planner reports this instead of minting under another
|
|
306
|
+
* name. A person revokes the live `archon` key in ai and removes this
|
|
307
|
+
* recorded key to let the step mint a fresh one.
|
|
308
|
+
*/
|
|
309
|
+
export const PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY = 'ai_platform_management_key_conflict';
|
|
310
|
+
/**
|
|
311
|
+
* #201: the same two-key discipline as {@link PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY}
|
|
312
|
+
* and {@link PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY}, for the Ed25519 pair
|
|
313
|
+
* foundry mints to sign its calls to ai's `POST /v1/platform/keys`. The
|
|
314
|
+
* undelivered key is recorded the moment the pair is minted; the set key,
|
|
315
|
+
* only once the private half was delivered and the public half pinned on
|
|
316
|
+
* the runner as `MANAGEMENT_PUBLIC_KEYS`.
|
|
317
|
+
*/
|
|
318
|
+
export const PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY = 'ai_platform_signing_key_undelivered';
|
|
319
|
+
export const PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY = 'ai_platform_signing_key_set';
|
|
320
|
+
/**
|
|
321
|
+
* #172: ai's platform (operator) tenant id, which the management key belongs
|
|
322
|
+
* to -- what ai's own README calls `AI_ORGANISATION_ID`. Not a secret.
|
|
323
|
+
*/
|
|
324
|
+
export const PLATFORM_AI_ORGANISATION_SCOPE_KEY = 'ai_platform_organisation_id';
|
|
325
|
+
/**
|
|
326
|
+
* #172: the hostname and gitRef the last apply finished with. A declared
|
|
327
|
+
* value that differs plans the step again (without minting again, the
|
|
328
|
+
* management key's own guard stays set).
|
|
329
|
+
*/
|
|
330
|
+
export const PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY = 'ai_platform_applied_hostname';
|
|
331
|
+
export const PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY = 'ai_platform_applied_gitref';
|
|
332
|
+
/**
|
|
333
|
+
* #172: set (with the migrate job's last status) when the migrate job did
|
|
334
|
+
* not turn healthy in time. The planner reports it as blocked and plans
|
|
335
|
+
* nothing, so an unhealthy migration is not retried and timed out on every
|
|
336
|
+
* apply. A person fixes the job and removes this recorded key.
|
|
337
|
+
*/
|
|
338
|
+
export const PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY = 'ai_platform_migrate_blocked';
|
|
339
|
+
/**
|
|
340
|
+
* #172: set before an env or branch change is written and cleared once the
|
|
341
|
+
* deploys that carry it finished, so a run that fails halfway (a migrate
|
|
342
|
+
* that throws) is repaired by the next one.
|
|
343
|
+
*/
|
|
344
|
+
export const PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY = 'ai_platform_deploy_pending';
|
|
345
|
+
/**
|
|
346
|
+
* #171: the resource key `planFiles` plans under and `FoundryPlatformStepExecutor`
|
|
347
|
+
* dispatches on, the {@link PLATFORM_AUTH_KEY} shape for the files host.
|
|
348
|
+
* `runPlatformSteps` records the files runner app's uuid under
|
|
349
|
+
* `coolify:app:platform:files` once the step succeeds, which is what stops
|
|
350
|
+
* `planFiles` planning it again.
|
|
351
|
+
*/
|
|
352
|
+
export const PLATFORM_FILES_KEY = 'platform:files';
|
|
353
|
+
/**
|
|
354
|
+
* #171: scope keys (colon-free) the files step records in the applied
|
|
355
|
+
* platform.
|
|
356
|
+
*
|
|
357
|
+
* - {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY}: the files host's address on
|
|
358
|
+
* the Coolify network (`http://<custom_internal_name>:3000`), the value an
|
|
359
|
+
* Agora's `AGORA_FILES_UPSTREAM` (and a host's `FilesOrganisationPort`
|
|
360
|
+
* client) is read from. Not a secret. The host has no public address.
|
|
361
|
+
* - {@link PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY}: the files ZITADEL
|
|
362
|
+
* project's id in the platform's own sign-in, which is the person-token
|
|
363
|
+
* audience (`AUTH_PERSON_TOKEN_AUDIENCE`) an Agora requests as an extra
|
|
364
|
+
* scope (`urn:zitadel:iam:org:project:id:<id>:aud`).
|
|
365
|
+
*/
|
|
366
|
+
export const PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY = 'files_internal_url';
|
|
367
|
+
export const PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_platform_files_project';
|
|
368
|
+
/**
|
|
369
|
+
* #171: scope keys (colon-free) for the files host's two one-time secrets,
|
|
370
|
+
* the {@link PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY} pattern twice. Each
|
|
371
|
+
* `*_UNDELIVERED_*` key is recorded and persisted the moment its secret is
|
|
372
|
+
* minted, before delivery. Each guard is recorded only once the secret was
|
|
373
|
+
* delivered AND written to the runner's env. An undelivered id with no guard
|
|
374
|
+
* means the secret is lost: the planner reports it and never mints again.
|
|
375
|
+
* None of these holds a secret.
|
|
376
|
+
*/
|
|
377
|
+
export const PLATFORM_FILES_INTROSPECTION_SCOPE_KEY = 'zitadel_platform_files_introspection_app';
|
|
378
|
+
export const PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY = 'zitadel_platform_files_introspection_undelivered';
|
|
379
|
+
export const PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY = 'files_management_key_id';
|
|
380
|
+
export const PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY = 'files_management_key_undelivered';
|
|
381
|
+
/**
|
|
382
|
+
* #171: the hostname and `gitRef` the last successful files run applied.
|
|
383
|
+
* `planFiles` plans the step again when either differs from the declared
|
|
384
|
+
* value; a re-run updates `git_branch` and the runner env and redeploys,
|
|
385
|
+
* and mints nothing (the guards above hold).
|
|
386
|
+
*/
|
|
387
|
+
export const PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY = 'files_applied_hostname';
|
|
388
|
+
export const PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY = 'files_applied_git_ref';
|
|
389
|
+
/**
|
|
390
|
+
* #167: the platform's own diff engine, mirroring `diff.ts`'s
|
|
391
|
+
* `computePlanDetail` for the one shared {@link DeclaredPlatform} record
|
|
392
|
+
* (#166) rather than one `DeclaredOrg` per call. It reuses the same step
|
|
393
|
+
* kinds an org plans against (`coolify:app`, `coolify:database`,
|
|
394
|
+
* `cloudflare:tunnel`, `cloudflare:zone`, `zitadel:app`), keyed under the
|
|
395
|
+
* platform's own resources rather than an org id -- a platform service's
|
|
396
|
+
* eventual `coolify:app` step, for one example, is keyed `platform:<service>`
|
|
397
|
+
* (`platform.ts`'s `PlatformServiceKey`) rather than an `OrgId`, once that
|
|
398
|
+
* step exists.
|
|
399
|
+
*
|
|
400
|
+
* Scope for #167: the frame plus one step, {@link planApexZone}. #173 adds
|
|
401
|
+
* {@link planJobs}, #170 adds {@link planSfu}, #168 adds {@link planAuth},
|
|
402
|
+
* #169 adds {@link planMail}, #174 adds {@link planIntegrations}, #171 adds
|
|
403
|
+
* {@link planFiles}, #172 adds {@link planAi} and #175 adds
|
|
404
|
+
* {@link planArchon}, the same way #19 shipped org plan/apply against
|
|
405
|
+
* three singletons before #27, #71, #80 and the rest added the remaining
|
|
406
|
+
* twelve. Until then, a live resource under one of those eventual keys is
|
|
407
|
+
* reported unmanaged rather than silently ignored (#10's rule, carried over
|
|
408
|
+
* unchanged).
|
|
409
|
+
*/
|
|
410
|
+
export function planPlatform(platform, live, options = {}) {
|
|
411
|
+
const { jobsObservable = true, sfuBlocked = null, recordedIds = {}, blocked = {}, drifted = {}, } = options;
|
|
412
|
+
const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
|
|
413
|
+
const claimed = new Set();
|
|
414
|
+
const plannedSteps = [];
|
|
415
|
+
const zone = planApexZone(platform, byKey, claimed);
|
|
416
|
+
if (zone)
|
|
417
|
+
plannedSteps.push(zone);
|
|
418
|
+
// #173: the jobs scheduler, second of the eight services this frame leaves
|
|
419
|
+
// open (this file's own doc comment above).
|
|
420
|
+
const jobs = planJobs(platform, byKey, claimed, jobsObservable);
|
|
421
|
+
if (jobs.step)
|
|
422
|
+
plannedSteps.push(jobs.step);
|
|
423
|
+
// A blocked SFU is reported in `unmanaged`, not planned: a step the
|
|
424
|
+
// executor must refuse would halt every step after it (`diff.ts` does the
|
|
425
|
+
// same for a blocked Agora).
|
|
426
|
+
const sfu = platform.sfu.enabled && sfuBlocked ? null : planSfu(platform);
|
|
427
|
+
if (sfu)
|
|
428
|
+
plannedSteps.push(sfu);
|
|
429
|
+
const auth = planAuth(platform, recordedIds, blocked.auth, blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? []);
|
|
430
|
+
if (auth.step)
|
|
431
|
+
plannedSteps.push(auth.step);
|
|
432
|
+
// #174: the integrations host. Planned after auth; neither step reads the
|
|
433
|
+
// other's recorded ids today, so the order is only for a readable plan.
|
|
434
|
+
const integrations = planIntegrations(platform, recordedIds, blocked.integrations);
|
|
435
|
+
if (integrations.step)
|
|
436
|
+
plannedSteps.push(integrations.step);
|
|
437
|
+
// #171: after auth, so a first apply builds the sign-in the files step
|
|
438
|
+
// creates its ZITADEL project in before that step runs.
|
|
439
|
+
const authReady = auth.step !== null || Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_AUTH_KEY)]);
|
|
440
|
+
const files = planFiles(platform, recordedIds, blocked.files, authReady);
|
|
441
|
+
if (files.step)
|
|
442
|
+
plannedSteps.push(files.step);
|
|
443
|
+
// #169: the platform's own mail server. After auth, because its OIDC
|
|
444
|
+
// directory is built against the platform's sign-in.
|
|
445
|
+
const mail = planMail(platform, recordedIds, blocked.mail, authReady);
|
|
446
|
+
if (mail.step)
|
|
447
|
+
plannedSteps.push(mail.step);
|
|
448
|
+
// #172: a throw halts every step after it, so this one goes late.
|
|
449
|
+
const ai = planAi(platform, recordedIds, blocked.ai);
|
|
450
|
+
if (ai.step)
|
|
451
|
+
plannedSteps.push(ai.step);
|
|
452
|
+
// #175: last, on purpose. Archon's sign-in client lives in the sign-in
|
|
453
|
+
// `planAuth` builds, and its env reads what every other service's step
|
|
454
|
+
// records or delivers, so in one apply it runs after all of them.
|
|
455
|
+
const archon = planArchon(platform, recordedIds, blocked.archon, authReady, drifted.archon);
|
|
456
|
+
if (archon.step)
|
|
457
|
+
plannedSteps.push(archon.step);
|
|
458
|
+
const unmanaged = live
|
|
459
|
+
.filter((r) => !claimed.has(resourceKey(r.system, r.kind, r.key)))
|
|
460
|
+
.map((r) => ({
|
|
461
|
+
description: `${r.system} ${r.kind} "${r.key}" is live but not named by the declared platform`,
|
|
462
|
+
}));
|
|
463
|
+
// A blocked jobs scheduler is reported here, not planned: a step that
|
|
464
|
+
// throws every apply, or that can never be seen live and so re-plans
|
|
465
|
+
// forever, is worse than none.
|
|
466
|
+
if (jobs.blocked)
|
|
467
|
+
unmanaged.push({ description: jobs.blocked, key: 'jobs' });
|
|
468
|
+
if (platform.sfu.enabled && sfuBlocked) {
|
|
469
|
+
unmanaged.push({ description: sfuBlocked, key: 'sfu' });
|
|
470
|
+
}
|
|
471
|
+
// Blocked, not planned: reported with the unmanaged items, like a blocked Agora.
|
|
472
|
+
if (auth.blocked)
|
|
473
|
+
unmanaged.push({ description: auth.blocked, key: 'auth' });
|
|
474
|
+
if (mail.blocked)
|
|
475
|
+
unmanaged.push({ description: mail.blocked, key: 'mail' });
|
|
476
|
+
if (integrations.blocked)
|
|
477
|
+
unmanaged.push({ description: integrations.blocked, key: 'integrations' });
|
|
478
|
+
if (files.blocked)
|
|
479
|
+
unmanaged.push({ description: files.blocked, key: 'files' });
|
|
480
|
+
if (ai.blocked)
|
|
481
|
+
unmanaged.push({ description: ai.blocked, key: 'ai' });
|
|
482
|
+
if (archon.blocked)
|
|
483
|
+
unmanaged.push({ description: archon.blocked, key: 'archon' });
|
|
484
|
+
return { plannedSteps, unmanaged };
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* The platform's own Cloudflare zone for its shared apex (`platform.ts`'s
|
|
488
|
+
* `DeclaredPlatform.apex`) -- modeled on `diff.ts`'s `planZone`, keyed by the
|
|
489
|
+
* domain itself rather than an org id, for the same reason: a Cloudflare
|
|
490
|
+
* zone belongs to a domain, not whoever declares it. Provision-only, like
|
|
491
|
+
* that planner: there is no `usesDomain` toggle to turn off here (the
|
|
492
|
+
* platform's apex is not optional the way an org's own domain is), so this
|
|
493
|
+
* step is never destructive and a live zone is never parked -- it only ever
|
|
494
|
+
* needs creating once, which is exactly #167's "so the empty
|
|
495
|
+
* companyincluded.com zone is managed."
|
|
496
|
+
*/
|
|
497
|
+
function planApexZone(platform, byKey, claimed) {
|
|
498
|
+
const key = resourceKey('cloudflare', 'zone', platform.apex);
|
|
499
|
+
const live = byKey.get(key);
|
|
500
|
+
if (live) {
|
|
501
|
+
claimed.add(key);
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
const action = {
|
|
505
|
+
system: 'cloudflare',
|
|
506
|
+
kind: 'zone',
|
|
507
|
+
key: platform.apex,
|
|
508
|
+
desired: { domain: platform.apex },
|
|
509
|
+
};
|
|
510
|
+
return {
|
|
511
|
+
action,
|
|
512
|
+
step: {
|
|
513
|
+
description: `provision a Cloudflare zone for ${platform.apex}`,
|
|
514
|
+
destroys: false,
|
|
515
|
+
migrates: false,
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* The one Coolify application (and key) every recorded jobs-scheduler
|
|
521
|
+
* resource, and its `coolify:jobs` step, is keyed under -- there is exactly
|
|
522
|
+
* one scheduler for the whole platform, so this is a fixed constant rather
|
|
523
|
+
* than something derived from `platform` (`platform-providers.ts`'s
|
|
524
|
+
* `FoundryPlatformStepExecutor`/`ProvidersPlatformCoolifyReader` both import
|
|
525
|
+
* it, so the planner and the executor/reader can never key it apart).
|
|
526
|
+
*/
|
|
527
|
+
export const JOBS_SCHEDULER_KEY = 'jobs';
|
|
528
|
+
/**
|
|
529
|
+
* #173: the jobs scheduler (https://github.com/wtfalch/jobs) itself, as a
|
|
530
|
+
* platform-level Coolify application with its own database -- provision-only,
|
|
531
|
+
* like {@link planApexZone}: there is no park branch yet (`platform.ts`'s
|
|
532
|
+
* `PlatformService.enabled` going from true to false plans nothing, the same
|
|
533
|
+
* scope note {@link planApexZone}'s own doc comment leaves for a future
|
|
534
|
+
* issue), and once live it is never replanned -- a hostname or `gitRef`
|
|
535
|
+
* change is a manual redeploy today, not a diffed field, matching #167's own
|
|
536
|
+
* "the frame plus one step" scope for this campaign.
|
|
537
|
+
*
|
|
538
|
+
* Not planned at all while `platform.jobs.enabled` is false -- an org step
|
|
539
|
+
* (`diff.ts`'s eventual `jobs:registration`) has nothing to register against
|
|
540
|
+
* before this exists.
|
|
541
|
+
*/
|
|
542
|
+
function planJobs(platform, byKey, claimed, observable) {
|
|
543
|
+
const key = resourceKey('coolify', 'jobs', JOBS_SCHEDULER_KEY);
|
|
544
|
+
const live = byKey.get(key);
|
|
545
|
+
if (live)
|
|
546
|
+
claimed.add(key);
|
|
547
|
+
if (!platform.jobs.enabled)
|
|
548
|
+
return { step: null, blocked: null };
|
|
549
|
+
if (live)
|
|
550
|
+
return { step: null, blocked: null };
|
|
551
|
+
// No Coolify reader means the host wired no Coolify at all for the
|
|
552
|
+
// platform: the step's executor would throw every apply, and the scheduler
|
|
553
|
+
// could never be seen live, so it would re-plan forever. A fixed text: it
|
|
554
|
+
// is part of the stale-plan comparison.
|
|
555
|
+
if (!observable) {
|
|
556
|
+
return {
|
|
557
|
+
step: null,
|
|
558
|
+
blocked: 'the jobs scheduler is blocked, not planned: the host supplies no Coolify reader for the platform',
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
blocked: null,
|
|
563
|
+
step: {
|
|
564
|
+
action: {
|
|
565
|
+
system: 'coolify',
|
|
566
|
+
kind: 'jobs',
|
|
567
|
+
key: JOBS_SCHEDULER_KEY,
|
|
568
|
+
desired: { hostname: platform.jobs.hostname, gitRef: platform.jobs.gitRef },
|
|
569
|
+
},
|
|
570
|
+
step: {
|
|
571
|
+
description: `provision the jobs scheduler at ${platform.jobs.hostname}`,
|
|
572
|
+
destroys: false,
|
|
573
|
+
migrates: false,
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* The platform's shared calls SFU (#170), keyed `sfu:livekit:platform` --
|
|
580
|
+
* there is exactly one, so `key` names the platform itself rather than a
|
|
581
|
+
* domain or an org id, the way `LiveResource`'s own doc comment explains for
|
|
582
|
+
* this system. Unlike {@link planApexZone}, this step is never matched
|
|
583
|
+
* against a live resource: there is no cheap way to read the SFU VM's own
|
|
584
|
+
* state over SSH during planning (`LiveResource`'s doc comment on the `sfu`
|
|
585
|
+
* system), so the step is planned whenever `platform.sfu.enabled` is true,
|
|
586
|
+
* full stop. That is safe rather than wasteful: `applyCallsSfu`
|
|
587
|
+
* (`calls-sfu.ts`'s own top doc comment) is built to be re-run, so an apply
|
|
588
|
+
* that runs this step again writes the same files and runs the same
|
|
589
|
+
* idempotent `docker compose`/`ufw` commands, changing nothing on a VM
|
|
590
|
+
* already caught up.
|
|
591
|
+
*/
|
|
592
|
+
function planSfu(platform) {
|
|
593
|
+
if (!platform.sfu.enabled)
|
|
594
|
+
return null;
|
|
595
|
+
const action = {
|
|
596
|
+
system: 'sfu',
|
|
597
|
+
kind: 'livekit',
|
|
598
|
+
key: 'platform',
|
|
599
|
+
desired: { enabled: true },
|
|
600
|
+
};
|
|
601
|
+
return {
|
|
602
|
+
action,
|
|
603
|
+
step: {
|
|
604
|
+
description: 'apply the shared calls SFU (LiveKit + cloudflared) over SSH',
|
|
605
|
+
destroys: false,
|
|
606
|
+
migrates: false,
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* #168: the platform's own sign-in (ZITADEL), built fresh rather than
|
|
612
|
+
* adopted (#165) -- a Coolify Postgres, a ZITADEL app and a zitadel-login
|
|
613
|
+
* app, reached through a Cloudflare tunnel at `platform.auth.hostname`, plus
|
|
614
|
+
* (once the instance is up) minting foundry's own ZITADEL machine user with
|
|
615
|
+
* the admin role. All of that is one step, `coolify:app` keyed
|
|
616
|
+
* {@link PLATFORM_AUTH_KEY} -- mirroring `diff.ts`'s `coolifyCalls`, which
|
|
617
|
+
* bundles two apps and two databases into its own single `coolify:calls`
|
|
618
|
+
* step the same way, rather than four separate ones.
|
|
619
|
+
*
|
|
620
|
+
* Idempotent on the *recorded* resource id
|
|
621
|
+
* (`recordedIds[resourceKey('coolify','app',PLATFORM_AUTH_KEY)]`), not a live
|
|
622
|
+
* resource -- the same choice `diff.ts`'s `planOwner` makes and explains: no
|
|
623
|
+
* reader reports this step's Coolify/Cloudflare resources yet (`planPlatform`'s
|
|
624
|
+
* own doc comment: "every other service ... is left for its own issue to
|
|
625
|
+
* plan"), so a live check here would never see them and would replan this
|
|
626
|
+
* step, and its one-time machine-user mint, forever. This also means a
|
|
627
|
+
* second `planPlatform()` call plans nothing further once the first apply's
|
|
628
|
+
* `runPlatformSteps` has recorded that key -- so a second `applyPlatform`
|
|
629
|
+
* never even calls the executor for this step, let alone mints again (#168
|
|
630
|
+
* "Done when").
|
|
631
|
+
*
|
|
632
|
+
* Off by default like every {@link PlatformService} (`platform.ts`'s
|
|
633
|
+
* `basePlatform` fixture: every service starts `enabled: false`); this
|
|
634
|
+
* planner never parks or updates what it already provisioned -- v1's scope,
|
|
635
|
+
* matching #167's own apex-zone step, is "provision once."
|
|
636
|
+
*
|
|
637
|
+
* #197: the resource id alone is not "fully applied" any more. A tunnel with
|
|
638
|
+
* no `cloudflared` connector answers every platform hostname with a
|
|
639
|
+
* Cloudflare error, so a recorded app with no recorded connector
|
|
640
|
+
* ({@link PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY}) still plans the step again
|
|
641
|
+
* -- `provisionPlatformAuth` re-runs idempotently against everything it
|
|
642
|
+
* already built and only adds the connector, so this never mints or
|
|
643
|
+
* redeploys what a prior run already finished.
|
|
644
|
+
*
|
|
645
|
+
* #211: the same holds for the broker ({@link PLATFORM_AUTH_BROKER_SCOPE_KEY}).
|
|
646
|
+
* A platform applied before #204 shipped the broker recorded the app and the
|
|
647
|
+
* connector and never the broker, so without this check it would read as
|
|
648
|
+
* fully applied and Archon would never find a broker to register with.
|
|
649
|
+
*
|
|
650
|
+
* #211 round 2: a recorded broker id is not the same as a *deployed* broker
|
|
651
|
+
* ({@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY}) -- `provisionPlatformAuth`
|
|
652
|
+
* creates and records the app before it has `ZITADEL_SERVICE_USER_TOKEN`/
|
|
653
|
+
* `RESEND_API_TOKEN` to deploy it with (#209, #210). So this still plans the
|
|
654
|
+
* step again while the broker sits undeployed: `executorBlocked` (from
|
|
655
|
+
* `authBrokerBlockedReason`) reports it blocked, by name, for as long as the
|
|
656
|
+
* inputs stay missing, and the moment they arrive the same replanned step
|
|
657
|
+
* deploys it -- no apply is left blocked for good once its inputs exist.
|
|
658
|
+
*
|
|
659
|
+
* #211 round 5: "deployed" ({@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY})
|
|
660
|
+
* is read back through {@link parseBrokerDeployRecord}, not `Boolean(...)`,
|
|
661
|
+
* and only counts while the record's `brokerAppId` matches the currently
|
|
662
|
+
* recorded {@link PLATFORM_AUTH_BROKER_SCOPE_KEY} -- a broker recreated under
|
|
663
|
+
* a new app id (the old one deleted by hand, or a fresh mint) leaves the old
|
|
664
|
+
* record pointing at an app that no longer exists, and a plain `Boolean`
|
|
665
|
+
* check (round 2-4) could never see that. This function has no live Coolify
|
|
666
|
+
* access, so it cannot also compare the record's `authAppsHash` against a
|
|
667
|
+
* live `AUTH_APPS` -- that half of the same rule (`brokerDeployNeeded` in
|
|
668
|
+
* `platform-providers.ts`) only runs inside the two `provision*` functions,
|
|
669
|
+
* which do have one. A malformed record parses to `null` and is treated the
|
|
670
|
+
* same as none recorded -- see {@link parseBrokerDeployRecord}'s own doc
|
|
671
|
+
* comment.
|
|
672
|
+
*
|
|
673
|
+
* #211 round 3: `executorBlocked` must not hold back the step itself once
|
|
674
|
+
* the broker exists (`brokerApplied`). `authBrokerBlockedReason` only ever
|
|
675
|
+
* returns non-null once a broker app id is recorded, so `executorBlocked &&
|
|
676
|
+
* brokerApplied` can only mean "broker created, not yet deployed" -- never
|
|
677
|
+
* "no executor". Blocking the whole step on that would deadlock: #209 mints
|
|
678
|
+
* the broker's own token as part of running this step, and a step that is
|
|
679
|
+
* never planned never runs. So this still plans the step -- surfacing
|
|
680
|
+
* `executorBlocked` in `blocked` for visibility, not to stop planning.
|
|
681
|
+
*
|
|
682
|
+
* #211 round 6: round 3's `!brokerApplied` guard assumed "no executor" and
|
|
683
|
+
* "broker not yet created" always coincide. They don't: a broker recorded on
|
|
684
|
+
* an earlier apply, then run again against a host built without its auth
|
|
685
|
+
* dependencies (`this.auth` undefined in `PlatformProvidersExecutor`), has
|
|
686
|
+
* `brokerApplied === true` and `executorBlocked` set to the no-deps message,
|
|
687
|
+
* so round 3's check let the step through and apply threw on the missing
|
|
688
|
+
* executor. `executorMissing` is that case's own signal
|
|
689
|
+
* ({@link PlanPlatformOptions.blocked}'s `authExecutorMissing`) and holds the
|
|
690
|
+
* step back unconditionally -- there is no executor to run it with, whatever
|
|
691
|
+
* `recordedIds` says.
|
|
692
|
+
*
|
|
693
|
+
* #216: a platform with every one of the above applied but no recorded
|
|
694
|
+
* {@link PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY} is not fully applied
|
|
695
|
+
* either -- the same "provisioned before this shipped" case
|
|
696
|
+
* {@link PLATFORM_AUTH_BROKER_SCOPE_KEY} already covers, here for a platform
|
|
697
|
+
* applied before #216's login-client mint existed at all.
|
|
698
|
+
* `provisionPlatformAuth` re-runs idempotently against everything it already
|
|
699
|
+
* built and only mints and delivers the login-client token, so this never
|
|
700
|
+
* redoes what a prior run already finished. Unlike the admin key, an
|
|
701
|
+
* undelivered login-client key never blocks this check -- see
|
|
702
|
+
* {@link PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY}'s own doc comment
|
|
703
|
+
* for why -- though it is still read, below, for a non-blocking note.
|
|
704
|
+
*
|
|
705
|
+
* #216 round 4: a *recorded* login-client key is also not enough on its own
|
|
706
|
+
* -- `authDrifted` (the host's own `driftedSteps`, reading ZITADEL's live
|
|
707
|
+
* PATs for the recorded user) means the PAT this platform's two apps were
|
|
708
|
+
* handed is no longer the sole live one: a concurrent apply's own mint
|
|
709
|
+
* revoked or replaced it, or minted an extra one alongside it. Round 2 and
|
|
710
|
+
* round 3 both tried to catch that from inside the same run that delivered
|
|
711
|
+
* the token (checking once after every write, then checking again before
|
|
712
|
+
* every write) and both left a window a slow concurrent write could still
|
|
713
|
+
* land in. This closes it from the other end instead: every plan re-checks
|
|
714
|
+
* the live state no matter which run last touched it, so whichever run's
|
|
715
|
+
* write actually won is caught on the very next plan, not raced against
|
|
716
|
+
* from inside the run that might lose.
|
|
717
|
+
*
|
|
718
|
+
* #216 round 5: `otherInputsMissing` keeps the note below honest when a
|
|
719
|
+
* re-mint is needed (undelivered or drifted) but cannot actually happen yet
|
|
720
|
+
* -- `provisionPlatformAuth` only mints and delivers once the broker's
|
|
721
|
+
* inputs *other than* `ZITADEL_SERVICE_USER_TOKEN` are all present
|
|
722
|
+
* (deferred, not blocked -- see that function's own doc comment), so a note
|
|
723
|
+
* promising "this apply re-mints" while one of those is still missing would
|
|
724
|
+
* be wrong the moment it is read.
|
|
725
|
+
*/
|
|
726
|
+
function planAuth(platform, recordedIds, executorBlocked, executorMissing, authDrifted, otherInputsMissing = []) {
|
|
727
|
+
if (!platform.auth.enabled)
|
|
728
|
+
return { step: null, blocked: null };
|
|
729
|
+
const key = resourceKey('coolify', 'app', PLATFORM_AUTH_KEY);
|
|
730
|
+
const connectorApplied = Boolean(recordedIds[PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY]);
|
|
731
|
+
// #211: a recorded app and connector with no recorded broker id is a
|
|
732
|
+
// platform provisioned before #204 shipped the broker, or a run that threw
|
|
733
|
+
// before the broker's own id was recorded -- still not fully applied, the
|
|
734
|
+
// same rule `connectorApplied` already follows for #197's connector.
|
|
735
|
+
const brokerApplied = Boolean(recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY]);
|
|
736
|
+
// #211 round 2: also require the broker to be deployed, not merely created.
|
|
737
|
+
// #211 round 5: and only while the deploy record still names the broker
|
|
738
|
+
// that is currently recorded -- see this function's own doc comment.
|
|
739
|
+
const brokerDeployRecord = parseBrokerDeployRecord(recordedIds[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]);
|
|
740
|
+
const brokerDeployed = brokerDeployRecord !== null &&
|
|
741
|
+
brokerDeployRecord.brokerAppId === recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
|
|
742
|
+
// #216 round 4: parsed back, not just `Boolean(...)` -- see this
|
|
743
|
+
// function's own doc comment and {@link parseLoginClientRecord}.
|
|
744
|
+
const loginClientRecord = parseLoginClientRecord(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
|
|
745
|
+
// #216 round 4: a recorded record whose PAT has since drifted (revoked,
|
|
746
|
+
// replaced, or joined by an extra live PAT) reads as unapplied, same as
|
|
747
|
+
// none recorded at all -- see this function's own doc comment.
|
|
748
|
+
const loginClientApplied = loginClientRecord !== null && !authDrifted;
|
|
749
|
+
// #216 round 2: read for a non-blocking note only -- see
|
|
750
|
+
// {@link PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY}'s own doc
|
|
751
|
+
// comment for why this never holds the step back the way the admin key's
|
|
752
|
+
// own `undelivered` check below does.
|
|
753
|
+
const loginClientUndelivered = recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY];
|
|
754
|
+
if (recordedIds[key] &&
|
|
755
|
+
connectorApplied &&
|
|
756
|
+
brokerApplied &&
|
|
757
|
+
brokerDeployed &&
|
|
758
|
+
loginClientApplied) {
|
|
759
|
+
return { step: null, blocked: null };
|
|
760
|
+
}
|
|
761
|
+
// The admin key was minted but never reached the host. ZITADEL shows a
|
|
762
|
+
// token once, so it is lost: minting another would hide that, and skipping
|
|
763
|
+
// would leave foundry without its admin key. Report it, plan nothing.
|
|
764
|
+
const undelivered = recordedIds[PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY];
|
|
765
|
+
if (undelivered && !recordedIds[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
|
|
766
|
+
return {
|
|
767
|
+
step: null,
|
|
768
|
+
blocked: `the platform's sign-in is blocked, not planned: the admin key of ZITADEL machine user ${undelivered} was minted but never delivered and cannot be shown again; delete that user in ZITADEL and remove the recorded key ${PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
// #211 round 6: the executor itself is unusable on this host -- nothing to
|
|
772
|
+
// plan, whatever the broker's recorded state says.
|
|
773
|
+
if (executorMissing)
|
|
774
|
+
return { step: null, blocked: executorBlocked ?? null };
|
|
775
|
+
// #211 round 3: a blocked reason with no broker yet created also means the
|
|
776
|
+
// executor is unusable (no auth dependencies) -- nothing to plan.
|
|
777
|
+
// A blocked reason with the broker already created means only its deploy
|
|
778
|
+
// is held back; plan the step anyway so it can keep retrying and, once
|
|
779
|
+
// #209/#210 land, deploy on the very next apply.
|
|
780
|
+
if (executorBlocked && !brokerApplied)
|
|
781
|
+
return { step: null, blocked: executorBlocked };
|
|
782
|
+
return {
|
|
783
|
+
step: {
|
|
784
|
+
action: {
|
|
785
|
+
system: 'coolify',
|
|
786
|
+
kind: 'app',
|
|
787
|
+
key: PLATFORM_AUTH_KEY,
|
|
788
|
+
desired: { hostname: platform.auth.hostname, gitRef: platform.auth.gitRef },
|
|
789
|
+
},
|
|
790
|
+
step: {
|
|
791
|
+
description: `provision the platform's own sign-in (ZITADEL, the login app and the sign-in broker) at ${platform.auth.hostname}`,
|
|
792
|
+
destroys: false,
|
|
793
|
+
migrates: false,
|
|
794
|
+
},
|
|
795
|
+
},
|
|
796
|
+
// #211 round 3: still surface a broker-deploy block for visibility even
|
|
797
|
+
// though the step above is planned -- see the doc comment above. #216
|
|
798
|
+
// round 2: when nothing executor-side is blocked, surface an in-flight
|
|
799
|
+
// (or abandoned) login-client mint the same way -- informational only,
|
|
800
|
+
// never a reason to skip planning. #216 round 4: same treatment for a
|
|
801
|
+
// drifted (already-delivered, now stale) login-client token. #216 round
|
|
802
|
+
// 5: and only claim "this apply re-mints" when `otherInputsMissing` is
|
|
803
|
+
// actually empty -- see this function's own doc comment.
|
|
804
|
+
blocked: executorBlocked ??
|
|
805
|
+
loginClientNeedsMintNote(loginClientUndelivered, loginClientApplied, authDrifted, loginClientRecord, otherInputsMissing),
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* #216 round 5: the note {@link planAuth} surfaces when the login-client key
|
|
810
|
+
* needs a re-mint it cannot yet perform, split out of `planAuth`'s own return
|
|
811
|
+
* statement so the "needs minting" question and the "can it actually mint
|
|
812
|
+
* right now" question are each answered in one place.
|
|
813
|
+
*/
|
|
814
|
+
function loginClientNeedsMintNote(loginClientUndelivered, loginClientApplied, authDrifted, loginClientRecord, otherInputsMissing) {
|
|
815
|
+
const undeliveredNeedsMint = Boolean(loginClientUndelivered) && !loginClientApplied;
|
|
816
|
+
let subject = null;
|
|
817
|
+
if (undeliveredNeedsMint) {
|
|
818
|
+
subject = `the login-client key of ZITADEL machine user ${loginClientUndelivered} was minted but not yet confirmed delivered`;
|
|
819
|
+
}
|
|
820
|
+
else if (authDrifted && loginClientRecord) {
|
|
821
|
+
subject = `the login-client token delivered for ZITADEL machine user ${loginClientRecord.userId} is no longer the sole live token on that user, likely revoked or replaced by a concurrent apply`;
|
|
822
|
+
}
|
|
823
|
+
if (subject === null)
|
|
824
|
+
return null;
|
|
825
|
+
if (otherInputsMissing.length > 0) {
|
|
826
|
+
return `${subject}; this apply cannot re-mint and redeliver it yet -- the platform's sign-in broker still needs ${otherInputsMissing.join(' and ')} first`;
|
|
827
|
+
}
|
|
828
|
+
const action = undeliveredNeedsMint
|
|
829
|
+
? 'this apply re-mints (revoking it) and retries'
|
|
830
|
+
: 'this apply re-mints and redelivers';
|
|
831
|
+
return `${subject}; ${action} -- no manual step needed`;
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* #169: the platform's own mail server (Stalwart), built fresh -- Stalwart on
|
|
835
|
+
* Coolify with its own storage, reached at `platform.mail.hostname` for
|
|
836
|
+
* web/JMAP/management and at `mx.<apex>` for SMTP, then (once the instance is
|
|
837
|
+
* up) the OIDC directory against the platform's own sign-in and foundry's
|
|
838
|
+
* admin API key, minted once. One `coolify:app` step, mirroring
|
|
839
|
+
* {@link planAuth} exactly -- see `platform-providers.ts`'s
|
|
840
|
+
* `provisionPlatformMail` for what it actually does and its own documented
|
|
841
|
+
* gaps.
|
|
842
|
+
*
|
|
843
|
+
* `platform.mail.gitRef` carries the Stalwart image tag rather than a git
|
|
844
|
+
* ref (`platform.ts`'s `PlatformService.gitRef` doc comment: "the release it
|
|
845
|
+
* runs" -- true of an image tag too, and Stalwart has no repo of its own to
|
|
846
|
+
* build from), so `validatePlatform`'s existing "gitRef must not be empty
|
|
847
|
+
* once enabled" rule already covers it with no change there.
|
|
848
|
+
*
|
|
849
|
+
* Idempotent on the recorded resource id plus the applied hostname and
|
|
850
|
+
* `gitRef`, for the reason {@link planAuth} gives: no reader reports this
|
|
851
|
+
* step's Coolify/Cloudflare resources yet. A changed hostname or image tag
|
|
852
|
+
* plans the step again (a redeploy, no new key). Blocked, not planned, while
|
|
853
|
+
* the platform's sign-in is neither applied nor planned.
|
|
854
|
+
*
|
|
855
|
+
* #197: a recorded app with no recorded `cloudflared` connector
|
|
856
|
+
* ({@link PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY}) plans the step again, as
|
|
857
|
+
* {@link planAuth} does.
|
|
858
|
+
*/
|
|
859
|
+
function planMail(platform, recordedIds, executorBlocked, authReady) {
|
|
860
|
+
if (!platform.mail.enabled)
|
|
861
|
+
return { step: null, blocked: null };
|
|
862
|
+
const key = resourceKey('coolify', 'app', PLATFORM_MAIL_KEY);
|
|
863
|
+
if (recordedIds[key] &&
|
|
864
|
+
recordedIds[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] &&
|
|
865
|
+
recordedIds[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] === platform.mail.hostname &&
|
|
866
|
+
recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === platform.mail.gitRef &&
|
|
867
|
+
// #177: a mail step applied before this key existed never recorded it, and
|
|
868
|
+
// org steps read it for Agora's mail upstream. Missing means unapplied.
|
|
869
|
+
recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY]) {
|
|
870
|
+
return { step: null, blocked: null };
|
|
871
|
+
}
|
|
872
|
+
// Stalwart's `x:ApiKey/set` shows a secret once. An id was minted but never
|
|
873
|
+
// reached the host: report it, plan nothing, the same rule {@link planAuth}
|
|
874
|
+
// follows for ZITADEL's own admin key.
|
|
875
|
+
const undelivered = recordedIds[PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY];
|
|
876
|
+
if (undelivered && !recordedIds[PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY]) {
|
|
877
|
+
return {
|
|
878
|
+
step: null,
|
|
879
|
+
blocked: `the platform's mail server is blocked, not planned: an admin API key ("${undelivered}") was minted but never delivered and cannot be shown again; revoke that key in Stalwart and remove the recorded key ${PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`,
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
if (executorBlocked)
|
|
883
|
+
return { step: null, blocked: executorBlocked };
|
|
884
|
+
if (!authReady) {
|
|
885
|
+
return {
|
|
886
|
+
step: null,
|
|
887
|
+
blocked: "the platform's mail server is blocked, not planned: its OIDC directory uses the platform's own sign-in, which is neither applied nor planned",
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
return {
|
|
891
|
+
step: {
|
|
892
|
+
action: {
|
|
893
|
+
system: 'coolify',
|
|
894
|
+
kind: 'app',
|
|
895
|
+
key: PLATFORM_MAIL_KEY,
|
|
896
|
+
desired: { hostname: platform.mail.hostname, gitRef: platform.mail.gitRef },
|
|
897
|
+
},
|
|
898
|
+
step: {
|
|
899
|
+
description: `provision mail (Stalwart) at ${platform.mail.hostname}`,
|
|
900
|
+
destroys: false,
|
|
901
|
+
migrates: false,
|
|
902
|
+
},
|
|
903
|
+
},
|
|
904
|
+
blocked: null,
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* #174: the resource key the integrations host is planned and recorded under,
|
|
909
|
+
* `platform:integrations`, like {@link PLATFORM_AUTH_KEY}: the step is
|
|
910
|
+
* `coolify:app` keyed by it, and the id `runPlatformSteps` records is
|
|
911
|
+
* `coolify:app:platform:integrations`. The planner and the executor
|
|
912
|
+
* (`platform-providers.ts`) both import this one value.
|
|
913
|
+
*/
|
|
914
|
+
export const PLATFORM_INTEGRATIONS_KEY = 'platform:integrations';
|
|
915
|
+
/**
|
|
916
|
+
* Scope keys (colon-free) for the integrations host's Ed25519 management key.
|
|
917
|
+
* The undelivered key is recorded first, the moment the pair is minted. The
|
|
918
|
+
* set key is the guard, recorded only after the private half was delivered
|
|
919
|
+
* and the public half written to the runner. An id under the undelivered key
|
|
920
|
+
* with no guard means the pair may be lost or half-applied, and the planner
|
|
921
|
+
* reports that instead of minting again.
|
|
922
|
+
*/
|
|
923
|
+
export const PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY = 'integrations_management_key_undelivered';
|
|
924
|
+
export const PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY = 'integrations_management_key_set';
|
|
925
|
+
/** The hostname and gitRef the last successful apply put live; a difference from the declared ones plans the step again. */
|
|
926
|
+
export const PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY = 'integrations_applied_hostname';
|
|
927
|
+
export const PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY = 'integrations_applied_gitref';
|
|
928
|
+
/**
|
|
929
|
+
* #174: the integrations host (https://github.com/wtfalch/integrations) -- its
|
|
930
|
+
* migrate job, web app and queue worker, their shared database, and its
|
|
931
|
+
* Ed25519 management key, minted once. One step, `coolify:app` keyed
|
|
932
|
+
* {@link PLATFORM_INTEGRATIONS_KEY}, bundling every part the way
|
|
933
|
+
* {@link planAuth} bundles its database, two apps and tunnel.
|
|
934
|
+
*
|
|
935
|
+
* Idempotent on the *recorded* resource id, like {@link planAuth} and for the
|
|
936
|
+
* same reason: no platform reader reports these Coolify apps. Unlike auth, a
|
|
937
|
+
* declared `hostname` or `gitRef` that differs from the applied one (recorded
|
|
938
|
+
* as scope ids) plans the step again; the step then patches the apps and
|
|
939
|
+
* redeploys, and never mints the key again.
|
|
940
|
+
*
|
|
941
|
+
* Blocked, not planned: an undelivered key, and an executor that cannot run
|
|
942
|
+
* the step (no integrations dependencies, or no `@wtfalch/keys` values for
|
|
943
|
+
* the host).
|
|
944
|
+
*/
|
|
945
|
+
function planIntegrations(platform, recordedIds, executorBlocked) {
|
|
946
|
+
if (!platform.integrations.enabled)
|
|
947
|
+
return { step: null, blocked: null };
|
|
948
|
+
const { hostname, gitRef } = platform.integrations;
|
|
949
|
+
const applied = Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_INTEGRATIONS_KEY)]) &&
|
|
950
|
+
recordedIds[PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY] === hostname &&
|
|
951
|
+
recordedIds[PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY] === gitRef;
|
|
952
|
+
if (applied)
|
|
953
|
+
return { step: null, blocked: null };
|
|
954
|
+
const undelivered = recordedIds[PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY];
|
|
955
|
+
if (undelivered && !recordedIds[PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY]) {
|
|
956
|
+
return {
|
|
957
|
+
step: null,
|
|
958
|
+
blocked: `the integrations host is blocked, not planned: its management key ${undelivered} was minted but not confirmed delivered and pinned, and the private half cannot be recovered; 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`,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
if (executorBlocked)
|
|
962
|
+
return { step: null, blocked: executorBlocked };
|
|
963
|
+
return {
|
|
964
|
+
step: {
|
|
965
|
+
action: {
|
|
966
|
+
system: 'coolify',
|
|
967
|
+
kind: 'app',
|
|
968
|
+
key: PLATFORM_INTEGRATIONS_KEY,
|
|
969
|
+
desired: { hostname, gitRef },
|
|
970
|
+
},
|
|
971
|
+
step: {
|
|
972
|
+
description: `provision the integrations host at ${hostname}`,
|
|
973
|
+
destroys: false,
|
|
974
|
+
migrates: false,
|
|
975
|
+
},
|
|
976
|
+
},
|
|
977
|
+
blocked: null,
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* #171: the files host (https://github.com/wtfalch/files) -- its database, a
|
|
982
|
+
* one-shot `files-migrate` app and the `files` runner, all on Coolify's
|
|
983
|
+
* internal network with no public address, plus the files ZITADEL project and
|
|
984
|
+
* introspection client in the platform's own sign-in, and foundry's own
|
|
985
|
+
* management key. One `coolify:app` step keyed {@link PLATFORM_FILES_KEY},
|
|
986
|
+
* the same bundling {@link planAuth} does.
|
|
987
|
+
*
|
|
988
|
+
* Idempotent on the recorded runner app id plus the applied hostname and
|
|
989
|
+
* `gitRef`, for the reason {@link planAuth} gives: no platform reader reports
|
|
990
|
+
* these resources, so a live check would replan the step forever. A changed
|
|
991
|
+
* hostname or `gitRef` plans the step again (a redeploy, no new secrets).
|
|
992
|
+
*
|
|
993
|
+
* Blocked, not planned, while the platform's sign-in is neither recorded nor
|
|
994
|
+
* planned in this same plan: the step creates its ZITADEL project and client
|
|
995
|
+
* there, so it would throw on every apply.
|
|
996
|
+
*/
|
|
997
|
+
function planFiles(platform, recordedIds, executorBlocked, authReady) {
|
|
998
|
+
if (!platform.files.enabled)
|
|
999
|
+
return { step: null, blocked: null };
|
|
1000
|
+
// A secret minted but never delivered and written is lost (the private key
|
|
1001
|
+
// is never kept; ZITADEL shows a client secret once). Minting another would
|
|
1002
|
+
// hide that, so report it and plan nothing.
|
|
1003
|
+
for (const [undeliveredKey, guardKey, what] of [
|
|
1004
|
+
[
|
|
1005
|
+
PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY,
|
|
1006
|
+
PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY,
|
|
1007
|
+
'the files management key',
|
|
1008
|
+
],
|
|
1009
|
+
[
|
|
1010
|
+
PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY,
|
|
1011
|
+
PLATFORM_FILES_INTROSPECTION_SCOPE_KEY,
|
|
1012
|
+
"the files host's introspection client secret",
|
|
1013
|
+
],
|
|
1014
|
+
]) {
|
|
1015
|
+
const undelivered = recordedIds[undeliveredKey];
|
|
1016
|
+
if (undelivered && !recordedIds[guardKey]) {
|
|
1017
|
+
return {
|
|
1018
|
+
step: null,
|
|
1019
|
+
blocked: `the files host is blocked, not planned: ${what} (${undelivered}) was minted but never delivered to the host and cannot be shown again; remove the recorded key ${undeliveredKey} (and delete the ZITADEL client for the introspection secret) to mint a new one`,
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
if (recordedIds[resourceKey('coolify', 'app', PLATFORM_FILES_KEY)] &&
|
|
1024
|
+
recordedIds[PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY] === platform.files.hostname &&
|
|
1025
|
+
recordedIds[PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY] === platform.files.gitRef) {
|
|
1026
|
+
return { step: null, blocked: null };
|
|
1027
|
+
}
|
|
1028
|
+
if (executorBlocked)
|
|
1029
|
+
return { step: null, blocked: executorBlocked };
|
|
1030
|
+
if (!authReady) {
|
|
1031
|
+
return {
|
|
1032
|
+
step: null,
|
|
1033
|
+
blocked: "the files host is blocked, not planned: its ZITADEL project and introspection client live in the platform's own sign-in, which is neither applied nor planned",
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
return {
|
|
1037
|
+
step: {
|
|
1038
|
+
action: {
|
|
1039
|
+
system: 'coolify',
|
|
1040
|
+
kind: 'app',
|
|
1041
|
+
key: PLATFORM_FILES_KEY,
|
|
1042
|
+
desired: { hostname: platform.files.hostname, gitRef: platform.files.gitRef },
|
|
1043
|
+
},
|
|
1044
|
+
step: {
|
|
1045
|
+
description: "provision the files host on Coolify's internal network, with its ZITADEL project and foundry's management key",
|
|
1046
|
+
destroys: false,
|
|
1047
|
+
migrates: false,
|
|
1048
|
+
},
|
|
1049
|
+
},
|
|
1050
|
+
blocked: null,
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* #172: the platform's AI host (https://github.com/wtfalch/ai) -- a Coolify
|
|
1055
|
+
* Postgres plus three apps built from that repo's root Dockerfile, one per
|
|
1056
|
+
* build target (`migration`, `runner`, `worker`), and foundry's own AI
|
|
1057
|
+
* management key. One `coolify:app` step keyed {@link PLATFORM_AI_KEY}, the
|
|
1058
|
+
* same bundling {@link planAuth} uses.
|
|
1059
|
+
*
|
|
1060
|
+
* Idempotent on the recorded resource id, like {@link planAuth} and for the
|
|
1061
|
+
* same reason: no reader reports these resources. The executor returns that
|
|
1062
|
+
* id only after the management key was delivered, so the step keeps being
|
|
1063
|
+
* planned until it has been.
|
|
1064
|
+
*
|
|
1065
|
+
* `desired.hostname` is `platform.ai.hostname` as declared. Whether ai gets a
|
|
1066
|
+
* public route there (and Access in front) or stays on the Coolify network
|
|
1067
|
+
* only is an open decision (#172 "Decide"); nothing here wires a tunnel, DNS
|
|
1068
|
+
* record or Access app, so either choice can be added later on top.
|
|
1069
|
+
*/
|
|
1070
|
+
function planAi(platform, recordedIds, executorBlocked) {
|
|
1071
|
+
if (!platform.ai.enabled)
|
|
1072
|
+
return { step: null, blocked: null };
|
|
1073
|
+
const key = resourceKey('coolify', 'app', PLATFORM_AI_KEY);
|
|
1074
|
+
// A finished apply recorded the resource id (only once the key was
|
|
1075
|
+
// delivered). It is planned again only when the declared hostname or gitRef
|
|
1076
|
+
// is not the one that apply finished with; the management key's guard is
|
|
1077
|
+
// already set then, so nothing is minted again.
|
|
1078
|
+
const applied = Boolean(recordedIds[key]);
|
|
1079
|
+
const drifted = recordedIds[PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY] !== platform.ai.hostname ||
|
|
1080
|
+
recordedIds[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] !== platform.ai.gitRef;
|
|
1081
|
+
if (applied && !drifted)
|
|
1082
|
+
return { step: null, blocked: null };
|
|
1083
|
+
// #201: the signing pair foundry mints to authenticate its own calls to
|
|
1084
|
+
// ai's platform/keys route. Minted but not confirmed delivered and pinned:
|
|
1085
|
+
// the private half cannot be recovered, so report it rather than mint
|
|
1086
|
+
// another (the same reason the management key check below never mints
|
|
1087
|
+
// twice).
|
|
1088
|
+
const signingUndelivered = recordedIds[PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY];
|
|
1089
|
+
if (signingUndelivered && !recordedIds[PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY]) {
|
|
1090
|
+
return {
|
|
1091
|
+
step: null,
|
|
1092
|
+
blocked: `the platform's AI host is blocked, not planned: its signing key ${signingUndelivered} was minted but not confirmed delivered and pinned, and the private half cannot be recovered; check the held key and the runner's MANAGEMENT_PUBLIC_KEYS, then remove the recorded key ${PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`,
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
// Minted but never delivered: ai shows a key's secret once, so it is lost.
|
|
1096
|
+
// Minting another would hide that; report it and plan nothing.
|
|
1097
|
+
const undelivered = recordedIds[PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY];
|
|
1098
|
+
if (undelivered && !recordedIds[PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY]) {
|
|
1099
|
+
return {
|
|
1100
|
+
step: null,
|
|
1101
|
+
blocked: `the platform's AI host is blocked, not planned: AI management key ${undelivered} was minted but never delivered and cannot be shown again; revoke it in ai (keys:revoke --id ${undelivered}) and remove the recorded key ${PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`,
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
// #201: ai reported its 'archon' key already exists with no secret to
|
|
1105
|
+
// show -- a mint this record never learned about. Never minted under
|
|
1106
|
+
// another name; a person must revoke the live key first.
|
|
1107
|
+
const conflict = recordedIds[PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY];
|
|
1108
|
+
if (conflict) {
|
|
1109
|
+
return {
|
|
1110
|
+
step: null,
|
|
1111
|
+
blocked: `the platform's AI host is blocked, not planned: ai already holds a management key named 'archon' (${conflict}) that was never recorded delivered, so its secret cannot be shown again; revoke it in ai and remove the recorded key ${PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY} to mint a new one`,
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
const migrateBlocked = recordedIds[PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY];
|
|
1115
|
+
if (migrateBlocked) {
|
|
1116
|
+
return {
|
|
1117
|
+
step: null,
|
|
1118
|
+
blocked: `the platform's AI host is blocked, not planned: the migrate job did not turn healthy (${migrateBlocked}); fix it from its logs in Coolify, then remove the recorded key ${PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY}`,
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
if (executorBlocked)
|
|
1122
|
+
return { step: null, blocked: executorBlocked };
|
|
1123
|
+
return {
|
|
1124
|
+
step: {
|
|
1125
|
+
action: {
|
|
1126
|
+
system: 'coolify',
|
|
1127
|
+
kind: 'app',
|
|
1128
|
+
key: PLATFORM_AI_KEY,
|
|
1129
|
+
desired: { hostname: platform.ai.hostname, gitRef: platform.ai.gitRef },
|
|
1130
|
+
},
|
|
1131
|
+
step: {
|
|
1132
|
+
description: applied
|
|
1133
|
+
? `apply the platform's AI host at ${platform.ai.hostname} on ${platform.ai.gitRef} (migrate job, host and worker)`
|
|
1134
|
+
: `provision the platform's AI host for ${platform.ai.hostname} (migrate job, host and worker) and mint foundry's AI management key`,
|
|
1135
|
+
destroys: false,
|
|
1136
|
+
migrates: false,
|
|
1137
|
+
},
|
|
1138
|
+
},
|
|
1139
|
+
blocked: null,
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* #175: Archon (https://github.com/wtfalch/archon), the one shared console,
|
|
1144
|
+
* built fresh at `platform.archon.hostname` (`archon.<apex>`) rather than the
|
|
1145
|
+
* hand-deployed `operator.wtfalch.dev` it replaces (#165). One step,
|
|
1146
|
+
* `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, covers the app, its
|
|
1147
|
+
* database, its Cloudflare tunnel, Access app and DNS record, and its sign-in
|
|
1148
|
+
* client in the platform's own ZITADEL -- the same bundling {@link planAuth}
|
|
1149
|
+
* does, for the same reason.
|
|
1150
|
+
*
|
|
1151
|
+
* Idempotent on the recorded resource id plus the applied hostname and
|
|
1152
|
+
* `gitRef`, for the reason {@link planAuth} gives: no platform reader reports
|
|
1153
|
+
* these resources, so a live check would replan the step forever. A changed
|
|
1154
|
+
* hostname or `gitRef` plans the step again, and so does `drifted` (the Access
|
|
1155
|
+
* policy's include list is not the declared one). A re-run mints nothing.
|
|
1156
|
+
*
|
|
1157
|
+
* Blocked, not planned, while the platform's sign-in is neither recorded nor
|
|
1158
|
+
* planned in this same plan: Archon's sign-in client is created inside it, so
|
|
1159
|
+
* the step could only throw.
|
|
1160
|
+
*
|
|
1161
|
+
* #213: also puts {@link PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF},
|
|
1162
|
+
* {@link PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF} and
|
|
1163
|
+
* {@link PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF} as held keys, before
|
|
1164
|
+
* the app deploys -- `putArchonMasterKeys` in `platform-providers.ts`.
|
|
1165
|
+
*
|
|
1166
|
+
* #197: a recorded app with no recorded `cloudflared` connector
|
|
1167
|
+
* ({@link PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY}) plans the step again, as
|
|
1168
|
+
* {@link planAuth} does.
|
|
1169
|
+
*/
|
|
1170
|
+
function planArchon(platform, recordedIds, executorBlocked, authReady, drifted) {
|
|
1171
|
+
if (!platform.archon.enabled)
|
|
1172
|
+
return { step: null, blocked: null };
|
|
1173
|
+
const key = resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY);
|
|
1174
|
+
const applied = Boolean(recordedIds[key]) &&
|
|
1175
|
+
Boolean(recordedIds[PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY]) &&
|
|
1176
|
+
recordedIds[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] === platform.archon.hostname &&
|
|
1177
|
+
recordedIds[PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY] === platform.archon.gitRef &&
|
|
1178
|
+
// #211 round 4: a broker recreated under a new app id (the old one
|
|
1179
|
+
// deleted by hand, or `platform:auth` minting a fresh one) leaves
|
|
1180
|
+
// Archon's last registration on a broker that no longer exists, and
|
|
1181
|
+
// nothing else here would ever plan the step again to redo it.
|
|
1182
|
+
Boolean(recordedIds[PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY]) &&
|
|
1183
|
+
recordedIds[PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY] ===
|
|
1184
|
+
recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY] &&
|
|
1185
|
+
!recordedIds[PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY];
|
|
1186
|
+
if (applied && !drifted)
|
|
1187
|
+
return { step: null, blocked: null };
|
|
1188
|
+
if (!platform.auth.enabled) {
|
|
1189
|
+
return {
|
|
1190
|
+
step: null,
|
|
1191
|
+
blocked: "Archon is blocked, not planned: its sign-in client lives in the platform's own sign-in, and auth is not enabled",
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
if (!authReady) {
|
|
1195
|
+
return {
|
|
1196
|
+
step: null,
|
|
1197
|
+
blocked: "Archon is blocked, not planned: its sign-in client lives in the platform's own sign-in, which is neither applied nor planned",
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
if (executorBlocked)
|
|
1201
|
+
return { step: null, blocked: executorBlocked };
|
|
1202
|
+
return {
|
|
1203
|
+
step: {
|
|
1204
|
+
action: {
|
|
1205
|
+
system: 'coolify',
|
|
1206
|
+
kind: 'app',
|
|
1207
|
+
key: PLATFORM_ARCHON_KEY,
|
|
1208
|
+
desired: { hostname: platform.archon.hostname, gitRef: platform.archon.gitRef },
|
|
1209
|
+
},
|
|
1210
|
+
step: {
|
|
1211
|
+
description: `provision Archon at ${platform.archon.hostname}, putting its master keys ` +
|
|
1212
|
+
`(${PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF}, ${PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF}, ` +
|
|
1213
|
+
`${PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF}) as held keys if they are not held yet`,
|
|
1214
|
+
destroys: false,
|
|
1215
|
+
migrates: false,
|
|
1216
|
+
},
|
|
1217
|
+
},
|
|
1218
|
+
blocked: null,
|
|
1219
|
+
};
|
|
1220
|
+
}
|