@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,867 @@
|
|
|
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
|
+
* #175: set to '1' before the step points the tunnel at `http_status:404`
|
|
52
|
+
* (Access app rebuild), cleared to '' once the real ingress is written back.
|
|
53
|
+
* While it is non-empty `planArchon` plans the step, so a run that failed in
|
|
54
|
+
* between is repaired by the next one.
|
|
55
|
+
*/
|
|
56
|
+
export const PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY = 'archon_gate_closed';
|
|
57
|
+
export const PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY = 'zitadel_platform_admin_user_id';
|
|
58
|
+
export const PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY = 'zitadel_platform_admin_user_undelivered';
|
|
59
|
+
/**
|
|
60
|
+
* #197: the `cloudflared` connector app `provisionPlatformAuth` runs for its
|
|
61
|
+
* own tunnel. Consulted by `planAuth` below so that a tunnel recorded with no
|
|
62
|
+
* connector recorded is not treated as fully applied -- the same rule that
|
|
63
|
+
* lets an instance provisioned before #197 shipped pick up a connector on its
|
|
64
|
+
* next apply, without minting anything again.
|
|
65
|
+
*/
|
|
66
|
+
export const PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_auth_connector_app';
|
|
67
|
+
/** #197: the same connector scope id for the mail step's own tunnel, read by `planMail`. */
|
|
68
|
+
export const PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_mail_connector_app';
|
|
69
|
+
/** #197: the same connector scope id for the Archon step's own tunnel, read by `planArchon`. */
|
|
70
|
+
export const PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_archon_connector_app';
|
|
71
|
+
/**
|
|
72
|
+
* #169: `platform:<service>` for the platform's own mail server (Stalwart),
|
|
73
|
+
* mirroring {@link PLATFORM_AUTH_KEY} exactly.
|
|
74
|
+
*/
|
|
75
|
+
export const PLATFORM_MAIL_KEY = 'platform:mail';
|
|
76
|
+
/**
|
|
77
|
+
* Scope keys for Stalwart's admin API key, mirroring
|
|
78
|
+
* {@link PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY}/{@link PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY}
|
|
79
|
+
* -- except `createApiKey` (`@wtfalch/email/postmaster`'s `StalwartClient`)
|
|
80
|
+
* returns only the minted secret, never an id, so there is no id to record
|
|
81
|
+
* the way ZITADEL's `userId` is. `PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION`
|
|
82
|
+
* (`platform-providers.ts`) is recorded instead -- the fixed description the
|
|
83
|
+
* key is minted under, which is what a human revokes it by in Stalwart's own
|
|
84
|
+
* admin UI once it is lost.
|
|
85
|
+
*/
|
|
86
|
+
export const PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY = 'stalwart_platform_admin_key_delivered';
|
|
87
|
+
export const PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY = 'stalwart_platform_admin_key_undelivered';
|
|
88
|
+
/**
|
|
89
|
+
* #169: the hostname and `gitRef` (the Stalwart image tag) the last successful
|
|
90
|
+
* mail run applied. `planMail` plans the step again when either differs from
|
|
91
|
+
* the declared value; a re-run patches the image tag and
|
|
92
|
+
* `STALWART_PUBLIC_URL`, redeploys, and mints nothing.
|
|
93
|
+
*/
|
|
94
|
+
export const PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY = 'mail_applied_hostname';
|
|
95
|
+
export const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = 'mail_applied_git_ref';
|
|
96
|
+
/**
|
|
97
|
+
* #177: where the mail server answers on the Coolify network
|
|
98
|
+
* (`http://<custom_internal_name>:<port>`), recorded by the step the same way
|
|
99
|
+
* {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY}/{@link PLATFORM_AI_INTERNAL_URL_SCOPE_KEY}
|
|
100
|
+
* already are -- what an org's own `coolify:mail`/`coolify:agora` steps read
|
|
101
|
+
* for `AGORA_MAIL_UPSTREAM` (`platform-org.ts`'s `agoraHostValuesFromPlatform`)
|
|
102
|
+
* instead of a literal a host used to have to supply. Not a secret: Stalwart's
|
|
103
|
+
* own internal address, never its admin credential.
|
|
104
|
+
*/
|
|
105
|
+
export const PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY = 'mail_internal_url';
|
|
106
|
+
/**
|
|
107
|
+
* #177: the one host the platform's mail server takes SMTP/MX on, and so the
|
|
108
|
+
* `mailHost` every org's own mail step writes. `provisionPlatformMail` (the
|
|
109
|
+
* DNS record and the server) and `platform-org.ts` (each org's mail step)
|
|
110
|
+
* both call this, so the two cannot name different hosts. Not
|
|
111
|
+
* `platform.mail.hostname`, which is the web (JMAP/admin) host.
|
|
112
|
+
*/
|
|
113
|
+
export function platformMailMxHost(apex) {
|
|
114
|
+
return `mx.${apex}`;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* #177: the platform apex and mail host an org's `coolify:mail` step last
|
|
118
|
+
* wrote, recorded in that org's applied state. `diff.ts`'s `planMail` plans
|
|
119
|
+
* the step again when either differs from the platform's current values, so a
|
|
120
|
+
* later platform address change reaches orgs that are already applied.
|
|
121
|
+
*/
|
|
122
|
+
export const ORG_MAIL_APPLIED_APEX_SCOPE_KEY = 'mail_applied_apex';
|
|
123
|
+
export const ORG_MAIL_APPLIED_HOST_SCOPE_KEY = 'mail_applied_host';
|
|
124
|
+
/**
|
|
125
|
+
* #172: the resource key `planAi` plans under and `FoundryPlatformStepExecutor`
|
|
126
|
+
* dispatches on, the same `platform:<service>` shape as
|
|
127
|
+
* {@link PLATFORM_AUTH_KEY}. The step's own recorded resource id (the ai
|
|
128
|
+
* host's runner app) lands at `coolify:app:platform:ai`.
|
|
129
|
+
*/
|
|
130
|
+
export const PLATFORM_AI_KEY = 'platform:ai';
|
|
131
|
+
/**
|
|
132
|
+
* #172: where the ai host answers on the Coolify network
|
|
133
|
+
* (`http://<custom_internal_name>:<port>`), recorded by the step so a host
|
|
134
|
+
* building `AiOrganisationPort` (`executor.ts`) can reach it without a
|
|
135
|
+
* public hostname. Holds whichever way the public-hostname decision goes.
|
|
136
|
+
*/
|
|
137
|
+
export const PLATFORM_AI_INTERNAL_URL_SCOPE_KEY = 'ai_platform_internal_url';
|
|
138
|
+
/**
|
|
139
|
+
* #172: the same two-key discipline as the ZITADEL admin key above, for
|
|
140
|
+
* foundry's AI management key (the bearer key `ai:organisation`, `ai:budget`
|
|
141
|
+
* and `ai:key` use). The undelivered key holds the credential id the moment
|
|
142
|
+
* it is minted; the delivered key is recorded only once the secret reached
|
|
143
|
+
* the host, and is what skips the mint on a later run.
|
|
144
|
+
*/
|
|
145
|
+
export const PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY = 'ai_platform_management_key_id';
|
|
146
|
+
export const PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY = 'ai_platform_management_key_undelivered';
|
|
147
|
+
/**
|
|
148
|
+
* #172: ai's platform (operator) tenant id, which the management key belongs
|
|
149
|
+
* to -- what ai's own README calls `AI_ORGANISATION_ID`. Not a secret.
|
|
150
|
+
*/
|
|
151
|
+
export const PLATFORM_AI_ORGANISATION_SCOPE_KEY = 'ai_platform_organisation_id';
|
|
152
|
+
/**
|
|
153
|
+
* #172: the hostname and gitRef the last apply finished with. A declared
|
|
154
|
+
* value that differs plans the step again (without minting again, the
|
|
155
|
+
* management key's own guard stays set).
|
|
156
|
+
*/
|
|
157
|
+
export const PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY = 'ai_platform_applied_hostname';
|
|
158
|
+
export const PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY = 'ai_platform_applied_gitref';
|
|
159
|
+
/**
|
|
160
|
+
* #172: set (with the migrate job's last status) when the migrate job did
|
|
161
|
+
* not turn healthy in time. The planner reports it as blocked and plans
|
|
162
|
+
* nothing, so an unhealthy migration is not retried and timed out on every
|
|
163
|
+
* apply. A person fixes the job and removes this recorded key.
|
|
164
|
+
*/
|
|
165
|
+
export const PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY = 'ai_platform_migrate_blocked';
|
|
166
|
+
/**
|
|
167
|
+
* #172: set before an env or branch change is written and cleared once the
|
|
168
|
+
* deploys that carry it finished, so a run that fails halfway (a migrate
|
|
169
|
+
* that throws) is repaired by the next one.
|
|
170
|
+
*/
|
|
171
|
+
export const PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY = 'ai_platform_deploy_pending';
|
|
172
|
+
/**
|
|
173
|
+
* #172: what the planner says while no AI management key minter is supplied.
|
|
174
|
+
* A fixed text: it is part of the stale-plan comparison. ai issues its first
|
|
175
|
+
* platform credential only through its `keys:issue-archon` script, run
|
|
176
|
+
* against its database with its keys worker credential; it has no HTTP route
|
|
177
|
+
* for that, so foundry cannot build a minter over ai's admin API yet.
|
|
178
|
+
*/
|
|
179
|
+
export const PLATFORM_AI_MINTER_BLOCKED = "the platform's AI host is blocked, not planned: no AI management key minter was supplied. ai mints its first platform key only through its keys:issue-archon script against its database, so the host must supply PlatformAiDeps.managementKeyMinter";
|
|
180
|
+
/**
|
|
181
|
+
* #171: the resource key `planFiles` plans under and `FoundryPlatformStepExecutor`
|
|
182
|
+
* dispatches on, the {@link PLATFORM_AUTH_KEY} shape for the files host.
|
|
183
|
+
* `runPlatformSteps` records the files runner app's uuid under
|
|
184
|
+
* `coolify:app:platform:files` once the step succeeds, which is what stops
|
|
185
|
+
* `planFiles` planning it again.
|
|
186
|
+
*/
|
|
187
|
+
export const PLATFORM_FILES_KEY = 'platform:files';
|
|
188
|
+
/**
|
|
189
|
+
* #171: scope keys (colon-free) the files step records in the applied
|
|
190
|
+
* platform.
|
|
191
|
+
*
|
|
192
|
+
* - {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY}: the files host's address on
|
|
193
|
+
* the Coolify network (`http://<custom_internal_name>:3000`), the value an
|
|
194
|
+
* Agora's `AGORA_FILES_UPSTREAM` (and a host's `FilesOrganisationPort`
|
|
195
|
+
* client) is read from. Not a secret. The host has no public address.
|
|
196
|
+
* - {@link PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY}: the files ZITADEL
|
|
197
|
+
* project's id in the platform's own sign-in, which is the person-token
|
|
198
|
+
* audience (`AUTH_PERSON_TOKEN_AUDIENCE`) an Agora requests as an extra
|
|
199
|
+
* scope (`urn:zitadel:iam:org:project:id:<id>:aud`).
|
|
200
|
+
*/
|
|
201
|
+
export const PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY = 'files_internal_url';
|
|
202
|
+
export const PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_platform_files_project';
|
|
203
|
+
/**
|
|
204
|
+
* #171: scope keys (colon-free) for the files host's two one-time secrets,
|
|
205
|
+
* the {@link PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY} pattern twice. Each
|
|
206
|
+
* `*_UNDELIVERED_*` key is recorded and persisted the moment its secret is
|
|
207
|
+
* minted, before delivery. Each guard is recorded only once the secret was
|
|
208
|
+
* delivered AND written to the runner's env. An undelivered id with no guard
|
|
209
|
+
* means the secret is lost: the planner reports it and never mints again.
|
|
210
|
+
* None of these holds a secret.
|
|
211
|
+
*/
|
|
212
|
+
export const PLATFORM_FILES_INTROSPECTION_SCOPE_KEY = 'zitadel_platform_files_introspection_app';
|
|
213
|
+
export const PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY = 'zitadel_platform_files_introspection_undelivered';
|
|
214
|
+
export const PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY = 'files_management_key_id';
|
|
215
|
+
export const PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY = 'files_management_key_undelivered';
|
|
216
|
+
/**
|
|
217
|
+
* #171: the hostname and `gitRef` the last successful files run applied.
|
|
218
|
+
* `planFiles` plans the step again when either differs from the declared
|
|
219
|
+
* value; a re-run updates `git_branch` and the runner env and redeploys,
|
|
220
|
+
* and mints nothing (the guards above hold).
|
|
221
|
+
*/
|
|
222
|
+
export const PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY = 'files_applied_hostname';
|
|
223
|
+
export const PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY = 'files_applied_git_ref';
|
|
224
|
+
/**
|
|
225
|
+
* #167: the platform's own diff engine, mirroring `diff.ts`'s
|
|
226
|
+
* `computePlanDetail` for the one shared {@link DeclaredPlatform} record
|
|
227
|
+
* (#166) rather than one `DeclaredOrg` per call. It reuses the same step
|
|
228
|
+
* kinds an org plans against (`coolify:app`, `coolify:database`,
|
|
229
|
+
* `cloudflare:tunnel`, `cloudflare:zone`, `zitadel:app`), keyed under the
|
|
230
|
+
* platform's own resources rather than an org id -- a platform service's
|
|
231
|
+
* eventual `coolify:app` step, for one example, is keyed `platform:<service>`
|
|
232
|
+
* (`platform.ts`'s `PlatformServiceKey`) rather than an `OrgId`, once that
|
|
233
|
+
* step exists.
|
|
234
|
+
*
|
|
235
|
+
* Scope for #167: the frame plus one step, {@link planApexZone}. #173 adds
|
|
236
|
+
* {@link planJobs}, #170 adds {@link planSfu}, #168 adds {@link planAuth},
|
|
237
|
+
* #169 adds {@link planMail}, #174 adds {@link planIntegrations}, #171 adds
|
|
238
|
+
* {@link planFiles}, #172 adds {@link planAi} and #175 adds
|
|
239
|
+
* {@link planArchon}, the same way #19 shipped org plan/apply against
|
|
240
|
+
* three singletons before #27, #71, #80 and the rest added the remaining
|
|
241
|
+
* twelve. Until then, a live resource under one of those eventual keys is
|
|
242
|
+
* reported unmanaged rather than silently ignored (#10's rule, carried over
|
|
243
|
+
* unchanged).
|
|
244
|
+
*/
|
|
245
|
+
export function planPlatform(platform, live, options = {}) {
|
|
246
|
+
const { jobsObservable = true, sfuBlocked = null, recordedIds = {}, blocked = {}, drifted = {}, } = options;
|
|
247
|
+
const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
|
|
248
|
+
const claimed = new Set();
|
|
249
|
+
const plannedSteps = [];
|
|
250
|
+
const zone = planApexZone(platform, byKey, claimed);
|
|
251
|
+
if (zone)
|
|
252
|
+
plannedSteps.push(zone);
|
|
253
|
+
// #173: the jobs scheduler, second of the eight services this frame leaves
|
|
254
|
+
// open (this file's own doc comment above).
|
|
255
|
+
const jobs = planJobs(platform, byKey, claimed, jobsObservable);
|
|
256
|
+
if (jobs.step)
|
|
257
|
+
plannedSteps.push(jobs.step);
|
|
258
|
+
// A blocked SFU is reported in `unmanaged`, not planned: a step the
|
|
259
|
+
// executor must refuse would halt every step after it (`diff.ts` does the
|
|
260
|
+
// same for a blocked Agora).
|
|
261
|
+
const sfu = platform.sfu.enabled && sfuBlocked ? null : planSfu(platform);
|
|
262
|
+
if (sfu)
|
|
263
|
+
plannedSteps.push(sfu);
|
|
264
|
+
const auth = planAuth(platform, recordedIds, blocked.auth);
|
|
265
|
+
if (auth.step)
|
|
266
|
+
plannedSteps.push(auth.step);
|
|
267
|
+
// #174: the integrations host. Planned after auth; neither step reads the
|
|
268
|
+
// other's recorded ids today, so the order is only for a readable plan.
|
|
269
|
+
const integrations = planIntegrations(platform, recordedIds, blocked.integrations);
|
|
270
|
+
if (integrations.step)
|
|
271
|
+
plannedSteps.push(integrations.step);
|
|
272
|
+
// #171: after auth, so a first apply builds the sign-in the files step
|
|
273
|
+
// creates its ZITADEL project in before that step runs.
|
|
274
|
+
const authReady = auth.step !== null || Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_AUTH_KEY)]);
|
|
275
|
+
const files = planFiles(platform, recordedIds, blocked.files, authReady);
|
|
276
|
+
if (files.step)
|
|
277
|
+
plannedSteps.push(files.step);
|
|
278
|
+
// #169: the platform's own mail server. After auth, because its OIDC
|
|
279
|
+
// directory is built against the platform's sign-in.
|
|
280
|
+
const mail = planMail(platform, recordedIds, blocked.mail, authReady);
|
|
281
|
+
if (mail.step)
|
|
282
|
+
plannedSteps.push(mail.step);
|
|
283
|
+
// #172: a throw halts every step after it, so this one goes late.
|
|
284
|
+
const ai = planAi(platform, recordedIds, blocked.ai);
|
|
285
|
+
if (ai.step)
|
|
286
|
+
plannedSteps.push(ai.step);
|
|
287
|
+
// #175: last, on purpose. Archon's sign-in client lives in the sign-in
|
|
288
|
+
// `planAuth` builds, and its env reads what every other service's step
|
|
289
|
+
// records or delivers, so in one apply it runs after all of them.
|
|
290
|
+
const archon = planArchon(platform, recordedIds, blocked.archon, authReady, drifted.archon);
|
|
291
|
+
if (archon.step)
|
|
292
|
+
plannedSteps.push(archon.step);
|
|
293
|
+
const unmanaged = live
|
|
294
|
+
.filter((r) => !claimed.has(resourceKey(r.system, r.kind, r.key)))
|
|
295
|
+
.map((r) => ({
|
|
296
|
+
description: `${r.system} ${r.kind} "${r.key}" is live but not named by the declared platform`,
|
|
297
|
+
}));
|
|
298
|
+
// A blocked jobs scheduler is reported here, not planned: a step that
|
|
299
|
+
// throws every apply, or that can never be seen live and so re-plans
|
|
300
|
+
// forever, is worse than none.
|
|
301
|
+
if (jobs.blocked)
|
|
302
|
+
unmanaged.push({ description: jobs.blocked, key: 'jobs' });
|
|
303
|
+
if (platform.sfu.enabled && sfuBlocked) {
|
|
304
|
+
unmanaged.push({ description: sfuBlocked, key: 'sfu' });
|
|
305
|
+
}
|
|
306
|
+
// Blocked, not planned: reported with the unmanaged items, like a blocked Agora.
|
|
307
|
+
if (auth.blocked)
|
|
308
|
+
unmanaged.push({ description: auth.blocked, key: 'auth' });
|
|
309
|
+
if (mail.blocked)
|
|
310
|
+
unmanaged.push({ description: mail.blocked, key: 'mail' });
|
|
311
|
+
if (integrations.blocked)
|
|
312
|
+
unmanaged.push({ description: integrations.blocked, key: 'integrations' });
|
|
313
|
+
if (files.blocked)
|
|
314
|
+
unmanaged.push({ description: files.blocked, key: 'files' });
|
|
315
|
+
if (ai.blocked)
|
|
316
|
+
unmanaged.push({ description: ai.blocked, key: 'ai' });
|
|
317
|
+
if (archon.blocked)
|
|
318
|
+
unmanaged.push({ description: archon.blocked, key: 'archon' });
|
|
319
|
+
return { plannedSteps, unmanaged };
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* The platform's own Cloudflare zone for its shared apex (`platform.ts`'s
|
|
323
|
+
* `DeclaredPlatform.apex`) -- modeled on `diff.ts`'s `planZone`, keyed by the
|
|
324
|
+
* domain itself rather than an org id, for the same reason: a Cloudflare
|
|
325
|
+
* zone belongs to a domain, not whoever declares it. Provision-only, like
|
|
326
|
+
* that planner: there is no `usesDomain` toggle to turn off here (the
|
|
327
|
+
* platform's apex is not optional the way an org's own domain is), so this
|
|
328
|
+
* step is never destructive and a live zone is never parked -- it only ever
|
|
329
|
+
* needs creating once, which is exactly #167's "so the empty
|
|
330
|
+
* companyincluded.com zone is managed."
|
|
331
|
+
*/
|
|
332
|
+
function planApexZone(platform, byKey, claimed) {
|
|
333
|
+
const key = resourceKey('cloudflare', 'zone', platform.apex);
|
|
334
|
+
const live = byKey.get(key);
|
|
335
|
+
if (live) {
|
|
336
|
+
claimed.add(key);
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
const action = {
|
|
340
|
+
system: 'cloudflare',
|
|
341
|
+
kind: 'zone',
|
|
342
|
+
key: platform.apex,
|
|
343
|
+
desired: { domain: platform.apex },
|
|
344
|
+
};
|
|
345
|
+
return {
|
|
346
|
+
action,
|
|
347
|
+
step: {
|
|
348
|
+
description: `provision a Cloudflare zone for ${platform.apex}`,
|
|
349
|
+
destroys: false,
|
|
350
|
+
migrates: false,
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* The one Coolify application (and key) every recorded jobs-scheduler
|
|
356
|
+
* resource, and its `coolify:jobs` step, is keyed under -- there is exactly
|
|
357
|
+
* one scheduler for the whole platform, so this is a fixed constant rather
|
|
358
|
+
* than something derived from `platform` (`platform-providers.ts`'s
|
|
359
|
+
* `FoundryPlatformStepExecutor`/`ProvidersPlatformCoolifyReader` both import
|
|
360
|
+
* it, so the planner and the executor/reader can never key it apart).
|
|
361
|
+
*/
|
|
362
|
+
export const JOBS_SCHEDULER_KEY = 'jobs';
|
|
363
|
+
/**
|
|
364
|
+
* #173: the jobs scheduler (https://github.com/wtfalch/jobs) itself, as a
|
|
365
|
+
* platform-level Coolify application with its own database -- provision-only,
|
|
366
|
+
* like {@link planApexZone}: there is no park branch yet (`platform.ts`'s
|
|
367
|
+
* `PlatformService.enabled` going from true to false plans nothing, the same
|
|
368
|
+
* scope note {@link planApexZone}'s own doc comment leaves for a future
|
|
369
|
+
* issue), and once live it is never replanned -- a hostname or `gitRef`
|
|
370
|
+
* change is a manual redeploy today, not a diffed field, matching #167's own
|
|
371
|
+
* "the frame plus one step" scope for this campaign.
|
|
372
|
+
*
|
|
373
|
+
* Not planned at all while `platform.jobs.enabled` is false -- an org step
|
|
374
|
+
* (`diff.ts`'s eventual `jobs:registration`) has nothing to register against
|
|
375
|
+
* before this exists.
|
|
376
|
+
*/
|
|
377
|
+
function planJobs(platform, byKey, claimed, observable) {
|
|
378
|
+
const key = resourceKey('coolify', 'jobs', JOBS_SCHEDULER_KEY);
|
|
379
|
+
const live = byKey.get(key);
|
|
380
|
+
if (live)
|
|
381
|
+
claimed.add(key);
|
|
382
|
+
if (!platform.jobs.enabled)
|
|
383
|
+
return { step: null, blocked: null };
|
|
384
|
+
if (live)
|
|
385
|
+
return { step: null, blocked: null };
|
|
386
|
+
// No Coolify reader means the host wired no Coolify at all for the
|
|
387
|
+
// platform: the step's executor would throw every apply, and the scheduler
|
|
388
|
+
// could never be seen live, so it would re-plan forever. A fixed text: it
|
|
389
|
+
// is part of the stale-plan comparison.
|
|
390
|
+
if (!observable) {
|
|
391
|
+
return {
|
|
392
|
+
step: null,
|
|
393
|
+
blocked: 'the jobs scheduler is blocked, not planned: the host supplies no Coolify reader for the platform',
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
blocked: null,
|
|
398
|
+
step: {
|
|
399
|
+
action: {
|
|
400
|
+
system: 'coolify',
|
|
401
|
+
kind: 'jobs',
|
|
402
|
+
key: JOBS_SCHEDULER_KEY,
|
|
403
|
+
desired: { hostname: platform.jobs.hostname, gitRef: platform.jobs.gitRef },
|
|
404
|
+
},
|
|
405
|
+
step: {
|
|
406
|
+
description: `provision the jobs scheduler at ${platform.jobs.hostname}`,
|
|
407
|
+
destroys: false,
|
|
408
|
+
migrates: false,
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* The platform's shared calls SFU (#170), keyed `sfu:livekit:platform` --
|
|
415
|
+
* there is exactly one, so `key` names the platform itself rather than a
|
|
416
|
+
* domain or an org id, the way `LiveResource`'s own doc comment explains for
|
|
417
|
+
* this system. Unlike {@link planApexZone}, this step is never matched
|
|
418
|
+
* against a live resource: there is no cheap way to read the SFU VM's own
|
|
419
|
+
* state over SSH during planning (`LiveResource`'s doc comment on the `sfu`
|
|
420
|
+
* system), so the step is planned whenever `platform.sfu.enabled` is true,
|
|
421
|
+
* full stop. That is safe rather than wasteful: `applyCallsSfu`
|
|
422
|
+
* (`calls-sfu.ts`'s own top doc comment) is built to be re-run, so an apply
|
|
423
|
+
* that runs this step again writes the same files and runs the same
|
|
424
|
+
* idempotent `docker compose`/`ufw` commands, changing nothing on a VM
|
|
425
|
+
* already caught up.
|
|
426
|
+
*/
|
|
427
|
+
function planSfu(platform) {
|
|
428
|
+
if (!platform.sfu.enabled)
|
|
429
|
+
return null;
|
|
430
|
+
const action = {
|
|
431
|
+
system: 'sfu',
|
|
432
|
+
kind: 'livekit',
|
|
433
|
+
key: 'platform',
|
|
434
|
+
desired: { enabled: true },
|
|
435
|
+
};
|
|
436
|
+
return {
|
|
437
|
+
action,
|
|
438
|
+
step: {
|
|
439
|
+
description: 'apply the shared calls SFU (LiveKit + cloudflared) over SSH',
|
|
440
|
+
destroys: false,
|
|
441
|
+
migrates: false,
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* #168: the platform's own sign-in (ZITADEL), built fresh rather than
|
|
447
|
+
* adopted (#165) -- a Coolify Postgres, a ZITADEL app and a zitadel-login
|
|
448
|
+
* app, reached through a Cloudflare tunnel at `platform.auth.hostname`, plus
|
|
449
|
+
* (once the instance is up) minting foundry's own ZITADEL machine user with
|
|
450
|
+
* the admin role. All of that is one step, `coolify:app` keyed
|
|
451
|
+
* {@link PLATFORM_AUTH_KEY} -- mirroring `diff.ts`'s `coolifyCalls`, which
|
|
452
|
+
* bundles two apps and two databases into its own single `coolify:calls`
|
|
453
|
+
* step the same way, rather than four separate ones.
|
|
454
|
+
*
|
|
455
|
+
* Idempotent on the *recorded* resource id
|
|
456
|
+
* (`recordedIds[resourceKey('coolify','app',PLATFORM_AUTH_KEY)]`), not a live
|
|
457
|
+
* resource -- the same choice `diff.ts`'s `planOwner` makes and explains: no
|
|
458
|
+
* reader reports this step's Coolify/Cloudflare resources yet (`planPlatform`'s
|
|
459
|
+
* own doc comment: "every other service ... is left for its own issue to
|
|
460
|
+
* plan"), so a live check here would never see them and would replan this
|
|
461
|
+
* step, and its one-time machine-user mint, forever. This also means a
|
|
462
|
+
* second `planPlatform()` call plans nothing further once the first apply's
|
|
463
|
+
* `runPlatformSteps` has recorded that key -- so a second `applyPlatform`
|
|
464
|
+
* never even calls the executor for this step, let alone mints again (#168
|
|
465
|
+
* "Done when").
|
|
466
|
+
*
|
|
467
|
+
* Off by default like every {@link PlatformService} (`platform.ts`'s
|
|
468
|
+
* `basePlatform` fixture: every service starts `enabled: false`); this
|
|
469
|
+
* planner never parks or updates what it already provisioned -- v1's scope,
|
|
470
|
+
* matching #167's own apex-zone step, is "provision once."
|
|
471
|
+
*
|
|
472
|
+
* #197: the resource id alone is not "fully applied" any more. A tunnel with
|
|
473
|
+
* no `cloudflared` connector answers every platform hostname with a
|
|
474
|
+
* Cloudflare error, so a recorded app with no recorded connector
|
|
475
|
+
* ({@link PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY}) still plans the step again
|
|
476
|
+
* -- `provisionPlatformAuth` re-runs idempotently against everything it
|
|
477
|
+
* already built and only adds the connector, so this never mints or
|
|
478
|
+
* redeploys what a prior run already finished.
|
|
479
|
+
*/
|
|
480
|
+
function planAuth(platform, recordedIds, executorBlocked) {
|
|
481
|
+
if (!platform.auth.enabled)
|
|
482
|
+
return { step: null, blocked: null };
|
|
483
|
+
const key = resourceKey('coolify', 'app', PLATFORM_AUTH_KEY);
|
|
484
|
+
const connectorApplied = Boolean(recordedIds[PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY]);
|
|
485
|
+
if (recordedIds[key] && connectorApplied)
|
|
486
|
+
return { step: null, blocked: null };
|
|
487
|
+
// The admin key was minted but never reached the host. ZITADEL shows a
|
|
488
|
+
// token once, so it is lost: minting another would hide that, and skipping
|
|
489
|
+
// would leave foundry without its admin key. Report it, plan nothing.
|
|
490
|
+
const undelivered = recordedIds[PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY];
|
|
491
|
+
if (undelivered && !recordedIds[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
|
|
492
|
+
return {
|
|
493
|
+
step: null,
|
|
494
|
+
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`,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
if (executorBlocked)
|
|
498
|
+
return { step: null, blocked: executorBlocked };
|
|
499
|
+
return {
|
|
500
|
+
step: {
|
|
501
|
+
action: {
|
|
502
|
+
system: 'coolify',
|
|
503
|
+
kind: 'app',
|
|
504
|
+
key: PLATFORM_AUTH_KEY,
|
|
505
|
+
desired: { hostname: platform.auth.hostname, gitRef: platform.auth.gitRef },
|
|
506
|
+
},
|
|
507
|
+
step: {
|
|
508
|
+
description: `provision the platform's own sign-in (ZITADEL) at ${platform.auth.hostname}`,
|
|
509
|
+
destroys: false,
|
|
510
|
+
migrates: false,
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
blocked: null,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* #169: the platform's own mail server (Stalwart), built fresh -- Stalwart on
|
|
518
|
+
* Coolify with its own storage, reached at `platform.mail.hostname` for
|
|
519
|
+
* web/JMAP/management and at `mx.<apex>` for SMTP, then (once the instance is
|
|
520
|
+
* up) the OIDC directory against the platform's own sign-in and foundry's
|
|
521
|
+
* admin API key, minted once. One `coolify:app` step, mirroring
|
|
522
|
+
* {@link planAuth} exactly -- see `platform-providers.ts`'s
|
|
523
|
+
* `provisionPlatformMail` for what it actually does and its own documented
|
|
524
|
+
* gaps.
|
|
525
|
+
*
|
|
526
|
+
* `platform.mail.gitRef` carries the Stalwart image tag rather than a git
|
|
527
|
+
* ref (`platform.ts`'s `PlatformService.gitRef` doc comment: "the release it
|
|
528
|
+
* runs" -- true of an image tag too, and Stalwart has no repo of its own to
|
|
529
|
+
* build from), so `validatePlatform`'s existing "gitRef must not be empty
|
|
530
|
+
* once enabled" rule already covers it with no change there.
|
|
531
|
+
*
|
|
532
|
+
* Idempotent on the recorded resource id plus the applied hostname and
|
|
533
|
+
* `gitRef`, for the reason {@link planAuth} gives: no reader reports this
|
|
534
|
+
* step's Coolify/Cloudflare resources yet. A changed hostname or image tag
|
|
535
|
+
* plans the step again (a redeploy, no new key). Blocked, not planned, while
|
|
536
|
+
* the platform's sign-in is neither applied nor planned.
|
|
537
|
+
*
|
|
538
|
+
* #197: a recorded app with no recorded `cloudflared` connector
|
|
539
|
+
* ({@link PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY}) plans the step again, as
|
|
540
|
+
* {@link planAuth} does.
|
|
541
|
+
*/
|
|
542
|
+
function planMail(platform, recordedIds, executorBlocked, authReady) {
|
|
543
|
+
if (!platform.mail.enabled)
|
|
544
|
+
return { step: null, blocked: null };
|
|
545
|
+
const key = resourceKey('coolify', 'app', PLATFORM_MAIL_KEY);
|
|
546
|
+
if (recordedIds[key] &&
|
|
547
|
+
recordedIds[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] &&
|
|
548
|
+
recordedIds[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] === platform.mail.hostname &&
|
|
549
|
+
recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === platform.mail.gitRef &&
|
|
550
|
+
// #177: a mail step applied before this key existed never recorded it, and
|
|
551
|
+
// org steps read it for Agora's mail upstream. Missing means unapplied.
|
|
552
|
+
recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY]) {
|
|
553
|
+
return { step: null, blocked: null };
|
|
554
|
+
}
|
|
555
|
+
// Stalwart's `x:ApiKey/set` shows a secret once. An id was minted but never
|
|
556
|
+
// reached the host: report it, plan nothing, the same rule {@link planAuth}
|
|
557
|
+
// follows for ZITADEL's own admin key.
|
|
558
|
+
const undelivered = recordedIds[PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY];
|
|
559
|
+
if (undelivered && !recordedIds[PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY]) {
|
|
560
|
+
return {
|
|
561
|
+
step: null,
|
|
562
|
+
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`,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
if (executorBlocked)
|
|
566
|
+
return { step: null, blocked: executorBlocked };
|
|
567
|
+
if (!authReady) {
|
|
568
|
+
return {
|
|
569
|
+
step: null,
|
|
570
|
+
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",
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
return {
|
|
574
|
+
step: {
|
|
575
|
+
action: {
|
|
576
|
+
system: 'coolify',
|
|
577
|
+
kind: 'app',
|
|
578
|
+
key: PLATFORM_MAIL_KEY,
|
|
579
|
+
desired: { hostname: platform.mail.hostname, gitRef: platform.mail.gitRef },
|
|
580
|
+
},
|
|
581
|
+
step: {
|
|
582
|
+
description: `provision mail (Stalwart) at ${platform.mail.hostname}`,
|
|
583
|
+
destroys: false,
|
|
584
|
+
migrates: false,
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
blocked: null,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* #174: the resource key the integrations host is planned and recorded under,
|
|
592
|
+
* `platform:integrations`, like {@link PLATFORM_AUTH_KEY}: the step is
|
|
593
|
+
* `coolify:app` keyed by it, and the id `runPlatformSteps` records is
|
|
594
|
+
* `coolify:app:platform:integrations`. The planner and the executor
|
|
595
|
+
* (`platform-providers.ts`) both import this one value.
|
|
596
|
+
*/
|
|
597
|
+
export const PLATFORM_INTEGRATIONS_KEY = 'platform:integrations';
|
|
598
|
+
/**
|
|
599
|
+
* Scope keys (colon-free) for the integrations host's Ed25519 management key.
|
|
600
|
+
* The undelivered key is recorded first, the moment the pair is minted. The
|
|
601
|
+
* set key is the guard, recorded only after the private half was delivered
|
|
602
|
+
* and the public half written to the runner. An id under the undelivered key
|
|
603
|
+
* with no guard means the pair may be lost or half-applied, and the planner
|
|
604
|
+
* reports that instead of minting again.
|
|
605
|
+
*/
|
|
606
|
+
export const PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY = 'integrations_management_key_undelivered';
|
|
607
|
+
export const PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY = 'integrations_management_key_set';
|
|
608
|
+
/** The hostname and gitRef the last successful apply put live; a difference from the declared ones plans the step again. */
|
|
609
|
+
export const PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY = 'integrations_applied_hostname';
|
|
610
|
+
export const PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY = 'integrations_applied_gitref';
|
|
611
|
+
/**
|
|
612
|
+
* #174: the integrations host (https://github.com/wtfalch/integrations) -- its
|
|
613
|
+
* migrate job, web app and queue worker, their shared database, and its
|
|
614
|
+
* Ed25519 management key, minted once. One step, `coolify:app` keyed
|
|
615
|
+
* {@link PLATFORM_INTEGRATIONS_KEY}, bundling every part the way
|
|
616
|
+
* {@link planAuth} bundles its database, two apps and tunnel.
|
|
617
|
+
*
|
|
618
|
+
* Idempotent on the *recorded* resource id, like {@link planAuth} and for the
|
|
619
|
+
* same reason: no platform reader reports these Coolify apps. Unlike auth, a
|
|
620
|
+
* declared `hostname` or `gitRef` that differs from the applied one (recorded
|
|
621
|
+
* as scope ids) plans the step again; the step then patches the apps and
|
|
622
|
+
* redeploys, and never mints the key again.
|
|
623
|
+
*
|
|
624
|
+
* Blocked, not planned: an undelivered key, and an executor that cannot run
|
|
625
|
+
* the step (no integrations dependencies, or no `@wtfalch/keys` values for
|
|
626
|
+
* the host).
|
|
627
|
+
*/
|
|
628
|
+
function planIntegrations(platform, recordedIds, executorBlocked) {
|
|
629
|
+
if (!platform.integrations.enabled)
|
|
630
|
+
return { step: null, blocked: null };
|
|
631
|
+
const { hostname, gitRef } = platform.integrations;
|
|
632
|
+
const applied = Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_INTEGRATIONS_KEY)]) &&
|
|
633
|
+
recordedIds[PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY] === hostname &&
|
|
634
|
+
recordedIds[PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY] === gitRef;
|
|
635
|
+
if (applied)
|
|
636
|
+
return { step: null, blocked: null };
|
|
637
|
+
const undelivered = recordedIds[PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY];
|
|
638
|
+
if (undelivered && !recordedIds[PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY]) {
|
|
639
|
+
return {
|
|
640
|
+
step: null,
|
|
641
|
+
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`,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
if (executorBlocked)
|
|
645
|
+
return { step: null, blocked: executorBlocked };
|
|
646
|
+
return {
|
|
647
|
+
step: {
|
|
648
|
+
action: {
|
|
649
|
+
system: 'coolify',
|
|
650
|
+
kind: 'app',
|
|
651
|
+
key: PLATFORM_INTEGRATIONS_KEY,
|
|
652
|
+
desired: { hostname, gitRef },
|
|
653
|
+
},
|
|
654
|
+
step: {
|
|
655
|
+
description: `provision the integrations host at ${hostname}`,
|
|
656
|
+
destroys: false,
|
|
657
|
+
migrates: false,
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
blocked: null,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* #171: the files host (https://github.com/wtfalch/files) -- its database, a
|
|
665
|
+
* one-shot `files-migrate` app and the `files` runner, all on Coolify's
|
|
666
|
+
* internal network with no public address, plus the files ZITADEL project and
|
|
667
|
+
* introspection client in the platform's own sign-in, and foundry's own
|
|
668
|
+
* management key. One `coolify:app` step keyed {@link PLATFORM_FILES_KEY},
|
|
669
|
+
* the same bundling {@link planAuth} does.
|
|
670
|
+
*
|
|
671
|
+
* Idempotent on the recorded runner app id plus the applied hostname and
|
|
672
|
+
* `gitRef`, for the reason {@link planAuth} gives: no platform reader reports
|
|
673
|
+
* these resources, so a live check would replan the step forever. A changed
|
|
674
|
+
* hostname or `gitRef` plans the step again (a redeploy, no new secrets).
|
|
675
|
+
*
|
|
676
|
+
* Blocked, not planned, while the platform's sign-in is neither recorded nor
|
|
677
|
+
* planned in this same plan: the step creates its ZITADEL project and client
|
|
678
|
+
* there, so it would throw on every apply.
|
|
679
|
+
*/
|
|
680
|
+
function planFiles(platform, recordedIds, executorBlocked, authReady) {
|
|
681
|
+
if (!platform.files.enabled)
|
|
682
|
+
return { step: null, blocked: null };
|
|
683
|
+
// A secret minted but never delivered and written is lost (the private key
|
|
684
|
+
// is never kept; ZITADEL shows a client secret once). Minting another would
|
|
685
|
+
// hide that, so report it and plan nothing.
|
|
686
|
+
for (const [undeliveredKey, guardKey, what] of [
|
|
687
|
+
[
|
|
688
|
+
PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY,
|
|
689
|
+
PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY,
|
|
690
|
+
'the files management key',
|
|
691
|
+
],
|
|
692
|
+
[
|
|
693
|
+
PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY,
|
|
694
|
+
PLATFORM_FILES_INTROSPECTION_SCOPE_KEY,
|
|
695
|
+
"the files host's introspection client secret",
|
|
696
|
+
],
|
|
697
|
+
]) {
|
|
698
|
+
const undelivered = recordedIds[undeliveredKey];
|
|
699
|
+
if (undelivered && !recordedIds[guardKey]) {
|
|
700
|
+
return {
|
|
701
|
+
step: null,
|
|
702
|
+
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`,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (recordedIds[resourceKey('coolify', 'app', PLATFORM_FILES_KEY)] &&
|
|
707
|
+
recordedIds[PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY] === platform.files.hostname &&
|
|
708
|
+
recordedIds[PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY] === platform.files.gitRef) {
|
|
709
|
+
return { step: null, blocked: null };
|
|
710
|
+
}
|
|
711
|
+
if (executorBlocked)
|
|
712
|
+
return { step: null, blocked: executorBlocked };
|
|
713
|
+
if (!authReady) {
|
|
714
|
+
return {
|
|
715
|
+
step: null,
|
|
716
|
+
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",
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
return {
|
|
720
|
+
step: {
|
|
721
|
+
action: {
|
|
722
|
+
system: 'coolify',
|
|
723
|
+
kind: 'app',
|
|
724
|
+
key: PLATFORM_FILES_KEY,
|
|
725
|
+
desired: { hostname: platform.files.hostname, gitRef: platform.files.gitRef },
|
|
726
|
+
},
|
|
727
|
+
step: {
|
|
728
|
+
description: "provision the files host on Coolify's internal network, with its ZITADEL project and foundry's management key",
|
|
729
|
+
destroys: false,
|
|
730
|
+
migrates: false,
|
|
731
|
+
},
|
|
732
|
+
},
|
|
733
|
+
blocked: null,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* #172: the platform's AI host (https://github.com/wtfalch/ai) -- a Coolify
|
|
738
|
+
* Postgres plus three apps built from that repo's root Dockerfile, one per
|
|
739
|
+
* build target (`migration`, `runner`, `worker`), and foundry's own AI
|
|
740
|
+
* management key. One `coolify:app` step keyed {@link PLATFORM_AI_KEY}, the
|
|
741
|
+
* same bundling {@link planAuth} uses.
|
|
742
|
+
*
|
|
743
|
+
* Idempotent on the recorded resource id, like {@link planAuth} and for the
|
|
744
|
+
* same reason: no reader reports these resources. The executor returns that
|
|
745
|
+
* id only after the management key was delivered, so the step keeps being
|
|
746
|
+
* planned until it has been.
|
|
747
|
+
*
|
|
748
|
+
* `desired.hostname` is `platform.ai.hostname` as declared. Whether ai gets a
|
|
749
|
+
* public route there (and Access in front) or stays on the Coolify network
|
|
750
|
+
* only is an open decision (#172 "Decide"); nothing here wires a tunnel, DNS
|
|
751
|
+
* record or Access app, so either choice can be added later on top.
|
|
752
|
+
*/
|
|
753
|
+
function planAi(platform, recordedIds, executorBlocked) {
|
|
754
|
+
if (!platform.ai.enabled)
|
|
755
|
+
return { step: null, blocked: null };
|
|
756
|
+
const key = resourceKey('coolify', 'app', PLATFORM_AI_KEY);
|
|
757
|
+
// A finished apply recorded the resource id (only once the key was
|
|
758
|
+
// delivered). It is planned again only when the declared hostname or gitRef
|
|
759
|
+
// is not the one that apply finished with; the management key's guard is
|
|
760
|
+
// already set then, so nothing is minted again.
|
|
761
|
+
const applied = Boolean(recordedIds[key]);
|
|
762
|
+
const drifted = recordedIds[PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY] !== platform.ai.hostname ||
|
|
763
|
+
recordedIds[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] !== platform.ai.gitRef;
|
|
764
|
+
if (applied && !drifted)
|
|
765
|
+
return { step: null, blocked: null };
|
|
766
|
+
// Minted but never delivered: ai shows a key's secret once, so it is lost.
|
|
767
|
+
// Minting another would hide that; report it and plan nothing.
|
|
768
|
+
const undelivered = recordedIds[PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY];
|
|
769
|
+
if (undelivered && !recordedIds[PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY]) {
|
|
770
|
+
return {
|
|
771
|
+
step: null,
|
|
772
|
+
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`,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
const migrateBlocked = recordedIds[PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY];
|
|
776
|
+
if (migrateBlocked) {
|
|
777
|
+
return {
|
|
778
|
+
step: null,
|
|
779
|
+
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}`,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
if (executorBlocked)
|
|
783
|
+
return { step: null, blocked: executorBlocked };
|
|
784
|
+
return {
|
|
785
|
+
step: {
|
|
786
|
+
action: {
|
|
787
|
+
system: 'coolify',
|
|
788
|
+
kind: 'app',
|
|
789
|
+
key: PLATFORM_AI_KEY,
|
|
790
|
+
desired: { hostname: platform.ai.hostname, gitRef: platform.ai.gitRef },
|
|
791
|
+
},
|
|
792
|
+
step: {
|
|
793
|
+
description: applied
|
|
794
|
+
? `apply the platform's AI host at ${platform.ai.hostname} on ${platform.ai.gitRef} (migrate job, host and worker)`
|
|
795
|
+
: `provision the platform's AI host for ${platform.ai.hostname} (migrate job, host and worker) and mint foundry's AI management key`,
|
|
796
|
+
destroys: false,
|
|
797
|
+
migrates: false,
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
blocked: null,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* #175: Archon (https://github.com/wtfalch/archon), the one shared console,
|
|
805
|
+
* built fresh at `platform.archon.hostname` (`archon.<apex>`) rather than the
|
|
806
|
+
* hand-deployed `operator.wtfalch.dev` it replaces (#165). One step,
|
|
807
|
+
* `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, covers the app, its
|
|
808
|
+
* database, its Cloudflare tunnel, Access app and DNS record, and its sign-in
|
|
809
|
+
* client in the platform's own ZITADEL -- the same bundling {@link planAuth}
|
|
810
|
+
* does, for the same reason.
|
|
811
|
+
*
|
|
812
|
+
* Idempotent on the recorded resource id plus the applied hostname and
|
|
813
|
+
* `gitRef`, for the reason {@link planAuth} gives: no platform reader reports
|
|
814
|
+
* these resources, so a live check would replan the step forever. A changed
|
|
815
|
+
* hostname or `gitRef` plans the step again, and so does `drifted` (the Access
|
|
816
|
+
* policy's include list is not the declared one). A re-run mints nothing.
|
|
817
|
+
*
|
|
818
|
+
* Blocked, not planned, while the platform's sign-in is neither recorded nor
|
|
819
|
+
* planned in this same plan: Archon's sign-in client is created inside it, so
|
|
820
|
+
* the step could only throw.
|
|
821
|
+
*
|
|
822
|
+
* #197: a recorded app with no recorded `cloudflared` connector
|
|
823
|
+
* ({@link PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY}) plans the step again, as
|
|
824
|
+
* {@link planAuth} does.
|
|
825
|
+
*/
|
|
826
|
+
function planArchon(platform, recordedIds, executorBlocked, authReady, drifted) {
|
|
827
|
+
if (!platform.archon.enabled)
|
|
828
|
+
return { step: null, blocked: null };
|
|
829
|
+
const key = resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY);
|
|
830
|
+
const applied = Boolean(recordedIds[key]) &&
|
|
831
|
+
Boolean(recordedIds[PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY]) &&
|
|
832
|
+
recordedIds[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] === platform.archon.hostname &&
|
|
833
|
+
recordedIds[PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY] === platform.archon.gitRef &&
|
|
834
|
+
!recordedIds[PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY];
|
|
835
|
+
if (applied && !drifted)
|
|
836
|
+
return { step: null, blocked: null };
|
|
837
|
+
if (!platform.auth.enabled) {
|
|
838
|
+
return {
|
|
839
|
+
step: null,
|
|
840
|
+
blocked: "Archon is blocked, not planned: its sign-in client lives in the platform's own sign-in, and auth is not enabled",
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
if (!authReady) {
|
|
844
|
+
return {
|
|
845
|
+
step: null,
|
|
846
|
+
blocked: "Archon is blocked, not planned: its sign-in client lives in the platform's own sign-in, which is neither applied nor planned",
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
if (executorBlocked)
|
|
850
|
+
return { step: null, blocked: executorBlocked };
|
|
851
|
+
return {
|
|
852
|
+
step: {
|
|
853
|
+
action: {
|
|
854
|
+
system: 'coolify',
|
|
855
|
+
kind: 'app',
|
|
856
|
+
key: PLATFORM_ARCHON_KEY,
|
|
857
|
+
desired: { hostname: platform.archon.hostname, gitRef: platform.archon.gitRef },
|
|
858
|
+
},
|
|
859
|
+
step: {
|
|
860
|
+
description: `provision Archon at ${platform.archon.hostname}`,
|
|
861
|
+
destroys: false,
|
|
862
|
+
migrates: false,
|
|
863
|
+
},
|
|
864
|
+
},
|
|
865
|
+
blocked: null,
|
|
866
|
+
};
|
|
867
|
+
}
|