@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
package/dist/plan/diff.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { NO_AGORA_HOST_VALUES, agoraEnvDrift, agoraHostMissing, desiredAgoraEnv, } from './agora.js';
|
|
2
|
+
import { ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, } from './platform-diff.js';
|
|
3
|
+
import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
|
|
1
4
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
2
5
|
export function toPlan(detail) {
|
|
3
6
|
return {
|
|
@@ -34,42 +37,76 @@ function resourceKey(system, kind, key) {
|
|
|
34
37
|
* rest: the org's owner, invited by ZITADEL email, idempotent on the
|
|
35
38
|
* *recorded* resource id (`recordedIds`, sourced from
|
|
36
39
|
* `AppliedState.serviceIds`) rather than a live resource, since a human user
|
|
37
|
-
* is never something `live` can contain.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
40
|
+
* is never something `live` can contain. #128 adds one more singleton, off
|
|
41
|
+
* by default unlike chat's own: {@link planCalls}, the org's own calls
|
|
42
|
+
* Matrix stack (`org.ts`'s `CallsConfig.enabled`). #145 adds one more,
|
|
43
|
+
* planned right after it and gated on the same flag: {@link planCallsService},
|
|
44
|
+
* the calls service itself (`wtfalch/calls`'s `packages/service`). #164 adds
|
|
45
|
+
* two more, unconditionally -- no `DeclaredOrg` field gates either, since
|
|
46
|
+
* every org's own Agora is always on: {@link planAgoraClient}, a fixed
|
|
47
|
+
* ZITADEL web client named `agora` (modeled on {@link planCmsClient}), and
|
|
48
|
+
* {@link planAgora}, the Agora itself as a Coolify app (modeled on
|
|
49
|
+
* {@link planSite}, minus the park branch -- foundry never deletes an
|
|
50
|
+
* Agora). #173 adds one more, gated on `org.hosting.enabled` like
|
|
51
|
+
* {@link planApp} itself: {@link planJobsRegistration}, registering the org's
|
|
52
|
+
* app with the platform's jobs scheduler, idempotent on the *recorded*
|
|
53
|
+
* resource id like {@link planOwner} rather than a live resource, since
|
|
54
|
+
* nothing in `live` can report a scheduler registration (no reader exists for
|
|
55
|
+
* it). Everything
|
|
56
|
+
* else in `live` that none of these eighteen singletons and however many
|
|
57
|
+
* declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
|
|
58
|
+
* guests, calls on the org's own domain, adopted/bespoke apps, the actual
|
|
41
59
|
* Zitadel/Coolify/Cloudflare/Files/AI/cms shapes) is #20's "provision
|
|
42
60
|
* Company Factory end to end", which wires real readers and executors
|
|
43
61
|
* against this seam.
|
|
44
62
|
*/
|
|
45
|
-
export function computePlanDetail(org, live, recordedIds = {}) {
|
|
63
|
+
export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AGORA_HOST_VALUES, jobsAvailable = false, platformReadiness = DEFAULT_ORG_PLATFORM_READINESS) {
|
|
46
64
|
const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
|
|
47
65
|
const claimed = new Set();
|
|
48
66
|
const plannedSteps = [];
|
|
49
|
-
const issuer = planIssuer(org, byKey, claimed);
|
|
50
|
-
if (issuer)
|
|
51
|
-
plannedSteps.push(issuer);
|
|
52
|
-
const owner = planOwner(org, recordedIds);
|
|
67
|
+
const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady);
|
|
68
|
+
if (issuer.step)
|
|
69
|
+
plannedSteps.push(issuer.step);
|
|
70
|
+
const owner = planOwner(org, recordedIds, platformReadiness.authReady);
|
|
53
71
|
if (owner)
|
|
54
72
|
plannedSteps.push(owner);
|
|
55
|
-
const mail = planMail(org, byKey, claimed);
|
|
56
|
-
if (mail)
|
|
57
|
-
plannedSteps.push(mail);
|
|
73
|
+
const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost);
|
|
74
|
+
if (mail.step)
|
|
75
|
+
plannedSteps.push(mail.step);
|
|
58
76
|
const chat = planChat(org, byKey, claimed);
|
|
59
77
|
if (chat)
|
|
60
78
|
plannedSteps.push(chat);
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
plannedSteps.push(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
const calls = planCalls(org, byKey, claimed, platformReadiness.callsReady);
|
|
80
|
+
if (calls.step)
|
|
81
|
+
plannedSteps.push(calls.step);
|
|
82
|
+
// Right after the Matrix stack (#145): its own executor
|
|
83
|
+
// (`executor.ts`'s `coolifyCallsService`) reads the Synapse/MAS addresses
|
|
84
|
+
// and MAS client credentials `coolifyCalls` just recorded, the same
|
|
85
|
+
// ordering `planOwner` relies on for `zitadel:issuer`.
|
|
86
|
+
const callsService = planCallsService(org, byKey, claimed, platformReadiness.callsReady);
|
|
87
|
+
if (callsService.step)
|
|
88
|
+
plannedSteps.push(callsService.step);
|
|
89
|
+
const filesOrganisation = planFilesOrganisation(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader);
|
|
90
|
+
if (filesOrganisation.step)
|
|
91
|
+
plannedSteps.push(filesOrganisation.step);
|
|
92
|
+
const storage = planStorage(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader);
|
|
93
|
+
if (storage.step)
|
|
94
|
+
plannedSteps.push(storage.step);
|
|
95
|
+
const aiOrganisation = planAiOrganisation(org, byKey, claimed, platformReadiness.aiReady, platformReadiness.missingKeys.ai, platformReadiness.noKeyReader);
|
|
96
|
+
if (aiOrganisation.step)
|
|
97
|
+
plannedSteps.push(aiOrganisation.step);
|
|
98
|
+
const aiBudget = planAiBudget(org, byKey, claimed, platformReadiness.aiReady, platformReadiness.missingKeys.ai, platformReadiness.noKeyReader);
|
|
99
|
+
if (aiBudget.step)
|
|
100
|
+
plannedSteps.push(aiBudget.step);
|
|
101
|
+
const aiKey = planAiKey(org, recordedIds, platformReadiness.aiReady);
|
|
102
|
+
if (aiKey.step)
|
|
103
|
+
plannedSteps.push(aiKey.step);
|
|
104
|
+
// Before `planSite`'s own step (#139): `cmsSite`'s executor reads this
|
|
105
|
+
// step's recorded client id, the same way `coolifyApp` runs after the
|
|
106
|
+
// hosted app's own `zitadel:app` steps.
|
|
107
|
+
const cmsClient = planCmsClient(org, byKey, claimed);
|
|
108
|
+
if (cmsClient)
|
|
109
|
+
plannedSteps.push(cmsClient);
|
|
73
110
|
const site = planSite(org, byKey, claimed);
|
|
74
111
|
if (site)
|
|
75
112
|
plannedSteps.push(site);
|
|
@@ -114,6 +151,22 @@ export function computePlanDetail(org, live, recordedIds = {}) {
|
|
|
114
151
|
const customers = planCustomers(org, live, claimed, recordedIds);
|
|
115
152
|
if (customers)
|
|
116
153
|
plannedSteps.push(customers);
|
|
154
|
+
// #164: every org's own Agora -- always on, no toggle. Last, so nothing
|
|
155
|
+
// else in the org waits on it: `runSteps` stops at the first failing step.
|
|
156
|
+
// Its fixed ZITADEL client before the Coolify app it signs into:
|
|
157
|
+
// `coolifyAgora` reads this step's recorded client id.
|
|
158
|
+
const agoraClient = planAgoraClient(org, byKey, claimed);
|
|
159
|
+
if (agoraClient)
|
|
160
|
+
plannedSteps.push(agoraClient);
|
|
161
|
+
const agora = planAgora(org, byKey, claimed, recordedIds, agoraHost);
|
|
162
|
+
if (agora.step)
|
|
163
|
+
plannedSteps.push(agora.step);
|
|
164
|
+
// #173: register the org's own app with the platform's jobs scheduler.
|
|
165
|
+
// After Agora, so a failing registration can never stop Agora from
|
|
166
|
+
// provisioning (`runSteps` stops at the first failing step).
|
|
167
|
+
const jobsRegistration = planJobsRegistration(org, recordedIds, jobsAvailable || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader);
|
|
168
|
+
if (jobsRegistration.step)
|
|
169
|
+
plannedSteps.push(jobsRegistration.step);
|
|
117
170
|
// #10 (resolved): "foundry manages only what the declared org names";
|
|
118
171
|
// anything left unclaimed is listed, never proposed for deletion.
|
|
119
172
|
const unmanaged = live
|
|
@@ -121,6 +174,41 @@ export function computePlanDetail(org, live, recordedIds = {}) {
|
|
|
121
174
|
.map((r) => ({
|
|
122
175
|
description: `${r.system} ${r.kind} "${r.key}" is live but not named by the declared org`,
|
|
123
176
|
}));
|
|
177
|
+
// A blocked Agora is reported here, not planned: a step the executor must
|
|
178
|
+
// refuse would halt every step after it. This is the plan's one non-step
|
|
179
|
+
// item list, so it rides along with the unmanaged items.
|
|
180
|
+
if (agora.blocked)
|
|
181
|
+
unmanaged.push({ description: agora.blocked, key: 'coolify:agora' });
|
|
182
|
+
// Same for an ai key minted but never delivered: planning a new mint would
|
|
183
|
+
// orphan a second live key.
|
|
184
|
+
if (aiKey.blocked)
|
|
185
|
+
unmanaged.push({ description: aiKey.blocked, key: 'ai:key' });
|
|
186
|
+
// Same for a jobs registration that cannot run (no scheduler wired) or must
|
|
187
|
+
// not run again (minted, never delivered).
|
|
188
|
+
if (jobsRegistration.blocked) {
|
|
189
|
+
unmanaged.push({ description: jobsRegistration.blocked, key: 'jobs:registration' });
|
|
190
|
+
}
|
|
191
|
+
// #177: an org step whose own shared platform service is off or not yet
|
|
192
|
+
// applied is blocked here, not planned -- the same rule, now driven by
|
|
193
|
+
// `platformReadiness` instead of a host-supplied port. Each is reported at
|
|
194
|
+
// most once even though a dependent step (owner, calls-service, storage,
|
|
195
|
+
// budget, key) would otherwise also have nothing to act on.
|
|
196
|
+
if (issuer.blocked)
|
|
197
|
+
unmanaged.push({ description: issuer.blocked, key: 'zitadel:issuer' });
|
|
198
|
+
if (mail.blocked)
|
|
199
|
+
unmanaged.push({ description: mail.blocked, key: 'coolify:mail' });
|
|
200
|
+
if (calls.blocked)
|
|
201
|
+
unmanaged.push({ description: calls.blocked, key: 'coolify:calls' });
|
|
202
|
+
if (filesOrganisation.blocked) {
|
|
203
|
+
unmanaged.push({ description: filesOrganisation.blocked, key: 'files:organisation' });
|
|
204
|
+
}
|
|
205
|
+
if (aiOrganisation.blocked) {
|
|
206
|
+
unmanaged.push({ description: aiOrganisation.blocked, key: 'ai:organisation' });
|
|
207
|
+
}
|
|
208
|
+
if (storage.blocked)
|
|
209
|
+
unmanaged.push({ description: storage.blocked, key: 'files:storage' });
|
|
210
|
+
if (aiBudget.blocked)
|
|
211
|
+
unmanaged.push({ description: aiBudget.blocked, key: 'ai:budget' });
|
|
124
212
|
return { orgId: org.id, plannedSteps, unmanaged };
|
|
125
213
|
}
|
|
126
214
|
/**
|
|
@@ -138,48 +226,67 @@ export function computePlanDetail(org, live, recordedIds = {}) {
|
|
|
138
226
|
* (#7 corrections 2) and is not built in v1 (decisions file, same section:
|
|
139
227
|
* "domain verification and passkey invalidation need their own design"), so
|
|
140
228
|
* `executor.ts` throws `NotBuiltError` on it rather than run it.
|
|
229
|
+
*
|
|
230
|
+
* #177: every org's ZITADEL org lives inside the platform's own shared
|
|
231
|
+
* sign-in (#168), so a first-ever provision (no live issuer yet) is blocked,
|
|
232
|
+
* not planned, while `platformReadiness.authReady` is false -- the same rule
|
|
233
|
+
* every platform-dependent org step in this file now follows. An issuer that
|
|
234
|
+
* already exists live is unaffected: its own domain-move logic runs
|
|
235
|
+
* regardless, since that never re-provisions the ZITADEL org itself.
|
|
141
236
|
*/
|
|
142
|
-
function planIssuer(org, byKey, claimed) {
|
|
237
|
+
function planIssuer(org, byKey, claimed, authReady) {
|
|
143
238
|
const key = resourceKey('zitadel', 'issuer', org.id);
|
|
144
239
|
const live = byKey.get(key);
|
|
145
240
|
const desiredUsesDomain = org.issuer.usesDomain;
|
|
146
241
|
if (!live) {
|
|
242
|
+
if (!authReady) {
|
|
243
|
+
return {
|
|
244
|
+
step: null,
|
|
245
|
+
blocked: `${org.slug}'s ZITADEL org is blocked, not planned: the platform's sign-in is off or not yet applied`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
147
248
|
return {
|
|
148
|
-
action: {
|
|
149
|
-
system: 'zitadel',
|
|
150
|
-
kind: 'issuer',
|
|
151
|
-
key: org.id,
|
|
152
|
-
desired: { provision: true, usesDomain: desiredUsesDomain },
|
|
153
|
-
},
|
|
154
249
|
step: {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
250
|
+
action: {
|
|
251
|
+
system: 'zitadel',
|
|
252
|
+
kind: 'issuer',
|
|
253
|
+
key: org.id,
|
|
254
|
+
desired: { provision: true, usesDomain: desiredUsesDomain },
|
|
255
|
+
},
|
|
256
|
+
step: {
|
|
257
|
+
description: `provision ${org.slug}'s own ZITADEL org`,
|
|
258
|
+
destroys: false,
|
|
259
|
+
migrates: false,
|
|
260
|
+
},
|
|
158
261
|
},
|
|
262
|
+
blocked: null,
|
|
159
263
|
};
|
|
160
264
|
}
|
|
161
265
|
claimed.add(key);
|
|
162
266
|
const liveUsesDomain = Boolean(live.config.usesDomain);
|
|
163
267
|
if (liveUsesDomain === desiredUsesDomain)
|
|
164
|
-
return null;
|
|
268
|
+
return { step: null, blocked: null };
|
|
165
269
|
const domain = org.domain ?? 'the org domain';
|
|
166
270
|
return {
|
|
167
|
-
action: {
|
|
168
|
-
system: 'zitadel',
|
|
169
|
-
kind: 'issuer',
|
|
170
|
-
key: org.id,
|
|
171
|
-
desired: { usesDomain: desiredUsesDomain },
|
|
172
|
-
},
|
|
173
271
|
step: {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
:
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
272
|
+
action: {
|
|
273
|
+
system: 'zitadel',
|
|
274
|
+
kind: 'issuer',
|
|
275
|
+
key: org.id,
|
|
276
|
+
desired: { usesDomain: desiredUsesDomain },
|
|
277
|
+
},
|
|
278
|
+
step: {
|
|
279
|
+
description: desiredUsesDomain
|
|
280
|
+
? `move the issuer onto ${domain}, invalidating every passkey in the org`
|
|
281
|
+
: `move the issuer off ${domain} back onto the shared issuer, invalidating every passkey in the org`,
|
|
282
|
+
// Any issuer move invalidates every passkey in the org (#7 corrections
|
|
283
|
+
// 2, #19 "Done when"). It never moves data between systems, so it is
|
|
284
|
+
// never `migrates`.
|
|
285
|
+
destroys: true,
|
|
286
|
+
migrates: false,
|
|
287
|
+
},
|
|
182
288
|
},
|
|
289
|
+
blocked: null,
|
|
183
290
|
};
|
|
184
291
|
}
|
|
185
292
|
/**
|
|
@@ -210,12 +317,17 @@ function planIssuer(org, byKey, claimed) {
|
|
|
210
317
|
* nothing here puts it anywhere beyond what audit already records for the
|
|
211
318
|
* org.
|
|
212
319
|
*/
|
|
213
|
-
function planOwner(org, recordedIds) {
|
|
320
|
+
function planOwner(org, recordedIds, authReady) {
|
|
214
321
|
if (!org.owner)
|
|
215
322
|
return null;
|
|
216
323
|
const key = resourceKey('zitadel', 'owner', org.id);
|
|
217
324
|
if (recordedIds[key])
|
|
218
325
|
return null; // already invited -- a re-run plans nothing.
|
|
326
|
+
// #177: nothing to invite into yet -- `planIssuer` already reports the
|
|
327
|
+
// platform's sign-in as blocked, so this step stays silent rather than
|
|
328
|
+
// repeating it.
|
|
329
|
+
if (!authReady)
|
|
330
|
+
return null;
|
|
219
331
|
return {
|
|
220
332
|
action: {
|
|
221
333
|
system: 'zitadel',
|
|
@@ -234,7 +346,27 @@ function planOwner(org, recordedIds) {
|
|
|
234
346
|
},
|
|
235
347
|
};
|
|
236
348
|
}
|
|
237
|
-
|
|
349
|
+
/**
|
|
350
|
+
* #162: the flat scope id `executor.ts`'s `ensureOwnerMailbox` records once
|
|
351
|
+
* it has given the org's owner a mailbox (`MAIL_OWNER_MAILBOX_SCOPE_KEY`
|
|
352
|
+
* there) — restated here rather than imported, the same rule this file's own
|
|
353
|
+
* `LOG_DIR` precedent follows for a value shared with `executor.ts`.
|
|
354
|
+
*/
|
|
355
|
+
const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
|
|
356
|
+
/** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
|
|
357
|
+
export const AI_KEY_UNDELIVERED_SCOPE_KEY = 'ai_key_undelivered';
|
|
358
|
+
/** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
|
|
359
|
+
export const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = 'jobs_registration_undelivered';
|
|
360
|
+
/**
|
|
361
|
+
* #177: `mailHost` is `platformReadiness.mailHost` -- the platform's own
|
|
362
|
+
* `apex`/`mail.hostname` once its mail server is enabled and applied, `null`
|
|
363
|
+
* otherwise. `executor.ts`'s `coolifyMail` reads `apex`/`mailHost` straight
|
|
364
|
+
* off this step's own `desired`, the same "the executor writes exactly these
|
|
365
|
+
* host values, so what the plan showed is what gets applied" rule
|
|
366
|
+
* `planAgora` already follows for its own host values -- never rebuilding
|
|
367
|
+
* them itself from a literal or a second lookup.
|
|
368
|
+
*/
|
|
369
|
+
function planMail(org, byKey, claimed, recordedIds, mailHost) {
|
|
238
370
|
const key = resourceKey('coolify', 'mail', org.id);
|
|
239
371
|
const live = byKey.get(key);
|
|
240
372
|
// `off` means mail is not foundry's concern here (org.ts's MailMode doc);
|
|
@@ -244,45 +376,90 @@ function planMail(org, byKey, claimed) {
|
|
|
244
376
|
// after having it would silently hide its own leftover mail resource from
|
|
245
377
|
// the unmanaged list.
|
|
246
378
|
if (org.mail.mode === 'off')
|
|
247
|
-
return null;
|
|
379
|
+
return { step: null, blocked: null };
|
|
248
380
|
if (live)
|
|
249
381
|
claimed.add(key);
|
|
250
|
-
|
|
382
|
+
if (!mailHost) {
|
|
383
|
+
return {
|
|
384
|
+
step: null,
|
|
385
|
+
blocked: `${org.slug}'s mail is blocked, not planned: the platform's mail server is off or not yet applied`,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
const desired = {
|
|
389
|
+
mode: org.mail.mode,
|
|
390
|
+
usesDomain: org.mail.usesDomain,
|
|
391
|
+
apex: mailHost.apex,
|
|
392
|
+
mailHost: mailHost.mailHost,
|
|
393
|
+
};
|
|
394
|
+
// #162: an owner's mailbox is minted inside the same `coolify:mail` step,
|
|
395
|
+
// after `applyOrgMail` -- so as long as one is still owed, this step must
|
|
396
|
+
// keep showing up even once mode/usesDomain themselves have nothing left
|
|
397
|
+
// to change, or the operator would apply and never see it happen.
|
|
398
|
+
const mailboxOwed = Boolean(org.owner) && !recordedIds[MAIL_OWNER_MAILBOX_SCOPE_KEY];
|
|
399
|
+
const mailboxNote = mailboxOwed ? ` and give ${org.slug}'s owner a mailbox` : '';
|
|
251
400
|
if (!live) {
|
|
252
401
|
return {
|
|
253
|
-
action: { system: 'coolify', kind: 'mail', key: org.id, desired },
|
|
254
402
|
step: {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
403
|
+
action: { system: 'coolify', kind: 'mail', key: org.id, desired },
|
|
404
|
+
step: {
|
|
405
|
+
description: `provision mail (${org.mail.mode}) for ${org.slug}${mailboxNote}`,
|
|
406
|
+
destroys: false,
|
|
407
|
+
migrates: false,
|
|
408
|
+
},
|
|
258
409
|
},
|
|
410
|
+
blocked: null,
|
|
259
411
|
};
|
|
260
412
|
}
|
|
261
413
|
const liveMode = live.config.mode;
|
|
262
414
|
const liveUsesDomain = Boolean(live.config.usesDomain);
|
|
263
|
-
if (liveMode === desired.mode && liveUsesDomain === desired.usesDomain)
|
|
264
|
-
|
|
415
|
+
if (liveMode === desired.mode && liveUsesDomain === desired.usesDomain) {
|
|
416
|
+
// #177: the platform's apex or mail host moved since this step last wrote
|
|
417
|
+
// them (or it never recorded them). Re-applying is idempotent.
|
|
418
|
+
const addressMoved = recordedIds[ORG_MAIL_APPLIED_APEX_SCOPE_KEY] !== mailHost.apex ||
|
|
419
|
+
recordedIds[ORG_MAIL_APPLIED_HOST_SCOPE_KEY] !== mailHost.mailHost;
|
|
420
|
+
if (!mailboxOwed && !addressMoved)
|
|
421
|
+
return { step: null, blocked: null };
|
|
422
|
+
return {
|
|
423
|
+
step: {
|
|
424
|
+
action: { system: 'coolify', kind: 'mail', key: org.id, desired },
|
|
425
|
+
step: {
|
|
426
|
+
description: addressMoved
|
|
427
|
+
? `re-apply ${org.slug}'s mail on the platform's mail host ${mailHost.mailHost}${mailboxNote}`
|
|
428
|
+
: `give ${org.slug}'s owner a mailbox`,
|
|
429
|
+
destroys: false,
|
|
430
|
+
migrates: false,
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
blocked: null,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
265
436
|
if (liveUsesDomain && !desired.usesDomain) {
|
|
266
437
|
// #7 corrections 1: parked, not deleted — mailboxes and messages stay —
|
|
267
438
|
// but still disruptive enough to gate like a destructive step.
|
|
268
439
|
return {
|
|
269
|
-
action: { system: 'coolify', kind: 'mail', key: org.id, desired },
|
|
270
440
|
step: {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
441
|
+
action: { system: 'coolify', kind: 'mail', key: org.id, desired },
|
|
442
|
+
step: {
|
|
443
|
+
description: `park ${org.slug}'s mail domain (stops receiving; mailboxes and messages are retained, not deleted — #7 corrections 1)`,
|
|
444
|
+
destroys: true,
|
|
445
|
+
migrates: false,
|
|
446
|
+
},
|
|
274
447
|
},
|
|
448
|
+
blocked: null,
|
|
275
449
|
};
|
|
276
450
|
}
|
|
277
451
|
// A mode transition (#7 "Transitions") or turning a domain on is a
|
|
278
452
|
// one-time copy, never a deletion.
|
|
279
453
|
return {
|
|
280
|
-
action: { system: 'coolify', kind: 'mail', key: org.id, desired },
|
|
281
454
|
step: {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
455
|
+
action: { system: 'coolify', kind: 'mail', key: org.id, desired },
|
|
456
|
+
step: {
|
|
457
|
+
description: `move ${org.slug}'s mail from ${String(liveMode)} to ${desired.mode}${mailboxNote}`,
|
|
458
|
+
destroys: false,
|
|
459
|
+
migrates: true,
|
|
460
|
+
},
|
|
285
461
|
},
|
|
462
|
+
blocked: null,
|
|
286
463
|
};
|
|
287
464
|
}
|
|
288
465
|
function planChat(org, byKey, claimed) {
|
|
@@ -335,6 +512,62 @@ function planChat(org, byKey, claimed) {
|
|
|
335
512
|
},
|
|
336
513
|
};
|
|
337
514
|
}
|
|
515
|
+
/**
|
|
516
|
+
* The org's own calls Matrix stack (#128) -- a dedicated Synapse and
|
|
517
|
+
* matrix-authentication-service, entirely separate from chat's
|
|
518
|
+
* (`plan/calls-matrix.ts`'s own top doc comment: a call guest must never gain
|
|
519
|
+
* chat membership). Unlike chat, off by default: `org.ts`'s
|
|
520
|
+
* `CallsConfig.enabled` doc comment. Same on/off, park-not-delete shape as
|
|
521
|
+
* {@link planApp}/{@link planDatabase}/{@link planTunnel} below
|
|
522
|
+
* (`planHostingToggle`, #7 corrections 1) -- turning it off parks Synapse and
|
|
523
|
+
* MAS, never either database (`executor.ts`'s `coolifyCalls`).
|
|
524
|
+
*
|
|
525
|
+
* #177: a first-ever provision (no live resource yet) is blocked, not
|
|
526
|
+
* planned, while `platformReadiness.callsReady` is false -- an org's own
|
|
527
|
+
* Matrix stack is wired to the shared calls SFU (#122/#127), so there is
|
|
528
|
+
* nothing for it to authenticate calls against until the platform's own
|
|
529
|
+
* `sfu:livekit` step is declared. Parking, and an already-provisioned stack,
|
|
530
|
+
* are unaffected.
|
|
531
|
+
*/
|
|
532
|
+
function planCalls(org, byKey, claimed, callsReady) {
|
|
533
|
+
const key = resourceKey('coolify', 'calls', org.id);
|
|
534
|
+
if (org.calls.enabled && !byKey.get(key) && !callsReady) {
|
|
535
|
+
return {
|
|
536
|
+
step: null,
|
|
537
|
+
blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: the platform's shared calls SFU is off`,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
step: planHostingToggle('coolify', 'calls', org.calls.enabled, org, byKey, claimed, () => `provision ${org.slug}'s calls Matrix stack (Synapse + matrix-authentication-service)`, () => `park ${org.slug}'s calls Matrix stack (stops serving; data is retained, not deleted -- #7 corrections 1)`),
|
|
542
|
+
blocked: null,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* The org's own calls service (#145, part 2 of #128) -- `wtfalch/calls`'s
|
|
547
|
+
* `packages/service`, deployed alongside the calls Matrix stack {@link planCalls}
|
|
548
|
+
* provisions and wired to it and to the shared LiveKit SFU (foundry#122/#127).
|
|
549
|
+
* Gated on the same `org.calls.enabled` flag as the Matrix stack, not a flag
|
|
550
|
+
* of its own: a calls service with no Matrix stack behind it has nothing to
|
|
551
|
+
* authenticate a guest against, and a Matrix stack alone answers no browser,
|
|
552
|
+
* so the two are declared as one on/off switch even though they are two
|
|
553
|
+
* planner steps. Same on/off, park-not-delete shape as {@link planCalls}
|
|
554
|
+
* (`planHostingToggle`, #7 corrections 1) -- turning it off parks the app,
|
|
555
|
+
* never its database (`executor.ts`'s `coolifyCallsService`).
|
|
556
|
+
*
|
|
557
|
+
* #177: silently plans nothing (no second blocked message) while the Matrix
|
|
558
|
+
* stack it rides beside is itself blocked on `callsReady` -- {@link planCalls}
|
|
559
|
+
* already reports the platform's shared SFU as missing once.
|
|
560
|
+
*/
|
|
561
|
+
function planCallsService(org, byKey, claimed, callsReady) {
|
|
562
|
+
const key = resourceKey('coolify', 'calls-service', org.id);
|
|
563
|
+
if (org.calls.enabled && !byKey.get(key) && !callsReady) {
|
|
564
|
+
return { step: null, blocked: null };
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
step: planHostingToggle('coolify', 'calls-service', org.calls.enabled, org, byKey, claimed, () => `provision ${org.slug}'s calls service`, () => `park ${org.slug}'s calls service (stops serving; data is retained, not deleted -- #7 corrections 1)`),
|
|
568
|
+
blocked: null,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
338
571
|
/**
|
|
339
572
|
* Deduped `ai.*` permissions offered to every organisation by default
|
|
340
573
|
* (Decision, this campaign's report): the minimum set that lets an org
|
|
@@ -346,6 +579,19 @@ function planChat(org, byKey, claimed) {
|
|
|
346
579
|
* explicit opt-in this default deliberately leaves out.
|
|
347
580
|
*/
|
|
348
581
|
const AI_DEFAULT_OFFERS = ['ai.offerings:invoke', 'ai.runs:create'];
|
|
582
|
+
/**
|
|
583
|
+
* Why an org step on a shared platform service is blocked: a key that is not
|
|
584
|
+
* held, no held-keys reader given at all, or the service off or not applied.
|
|
585
|
+
* Fixed text (part of the stale-plan comparison); a ref only, never a value.
|
|
586
|
+
*/
|
|
587
|
+
function notReadyReason(service, keyName, missingKeyRef, noKeyReader) {
|
|
588
|
+
if (missingKeyRef && noKeyReader) {
|
|
589
|
+
return `the host gave no held-keys reader and no ${service} port, so the platform's ${keyName} ${missingKeyRef} cannot be checked`;
|
|
590
|
+
}
|
|
591
|
+
return missingKeyRef
|
|
592
|
+
? `the platform's ${keyName} ${missingKeyRef} is not held yet`
|
|
593
|
+
: `the platform's ${service} host is off or not yet applied`;
|
|
594
|
+
}
|
|
349
595
|
/**
|
|
350
596
|
* #80: every org's own organisation on Files, a per-org singleton resource
|
|
351
597
|
* planned before {@link planStorage} -- `setQuota`/`setBudget` 404 against an
|
|
@@ -359,26 +605,39 @@ const AI_DEFAULT_OFFERS = ['ai.offerings:invoke', 'ai.runs:create'];
|
|
|
359
605
|
* concern (`org.ts`'s `StorageConfig` doc: "a separate, operator-driven hard
|
|
360
606
|
* cap this field has no opinion on"), so provisioning always asks for an
|
|
361
607
|
* uncapped ceiling -- Decision, this campaign's report.
|
|
608
|
+
*
|
|
609
|
+
* #177: blocked, not planned, while `filesReady` is false -- the platform's
|
|
610
|
+
* own files host (#171) is off or not yet applied, so there is nothing here
|
|
611
|
+
* for `FilesOrganisationPort.provisionOrganisation` to reach.
|
|
362
612
|
*/
|
|
363
|
-
function planFilesOrganisation(org, byKey, claimed) {
|
|
613
|
+
function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, noKeyReader = false) {
|
|
364
614
|
const key = resourceKey('files', 'organisation', org.id);
|
|
365
615
|
const live = byKey.get(key);
|
|
366
616
|
if (live) {
|
|
367
617
|
claimed.add(key);
|
|
368
|
-
return null;
|
|
618
|
+
return { step: null, blocked: null };
|
|
619
|
+
}
|
|
620
|
+
if (!filesReady) {
|
|
621
|
+
return {
|
|
622
|
+
step: null,
|
|
623
|
+
blocked: `${org.slug}'s Files organisation is blocked, not planned: ${notReadyReason('files', 'files management key', missingKeyRef, noKeyReader)}`,
|
|
624
|
+
};
|
|
369
625
|
}
|
|
370
626
|
return {
|
|
371
|
-
action: {
|
|
372
|
-
system: 'files',
|
|
373
|
-
kind: 'organisation',
|
|
374
|
-
key: org.id,
|
|
375
|
-
desired: { provision: true, ceilingBytes: null, ceilingMicros: null },
|
|
376
|
-
},
|
|
377
627
|
step: {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
628
|
+
action: {
|
|
629
|
+
system: 'files',
|
|
630
|
+
kind: 'organisation',
|
|
631
|
+
key: org.id,
|
|
632
|
+
desired: { provision: true, ceilingBytes: null, ceilingMicros: null },
|
|
633
|
+
},
|
|
634
|
+
step: {
|
|
635
|
+
description: `provision ${org.slug}'s own organisation on Files`,
|
|
636
|
+
destroys: false,
|
|
637
|
+
migrates: false,
|
|
638
|
+
},
|
|
381
639
|
},
|
|
640
|
+
blocked: null,
|
|
382
641
|
};
|
|
383
642
|
}
|
|
384
643
|
/**
|
|
@@ -395,30 +654,42 @@ function planFilesOrganisation(org, byKey, claimed) {
|
|
|
395
654
|
* first budget row with whatever the org already declares (including
|
|
396
655
|
* `null` for uncapped), so a brand new org never needs a follow-up
|
|
397
656
|
* {@link planAiBudget} step just to reach its own declared default.
|
|
657
|
+
*
|
|
658
|
+
* #177: blocked, not planned, while `aiReady` is false -- the platform's own
|
|
659
|
+
* AI host (#172) is off or not yet applied.
|
|
398
660
|
*/
|
|
399
|
-
function planAiOrganisation(org, byKey, claimed) {
|
|
661
|
+
function planAiOrganisation(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader = false) {
|
|
400
662
|
const key = resourceKey('ai', 'organisation', org.id);
|
|
401
663
|
const live = byKey.get(key);
|
|
402
664
|
if (live) {
|
|
403
665
|
claimed.add(key);
|
|
404
|
-
return null;
|
|
666
|
+
return { step: null, blocked: null };
|
|
667
|
+
}
|
|
668
|
+
if (!aiReady) {
|
|
669
|
+
return {
|
|
670
|
+
step: null,
|
|
671
|
+
blocked: `${org.slug}'s AI organisation is blocked, not planned: ${notReadyReason('AI', 'AI management key', missingKeyRef, noKeyReader)}`,
|
|
672
|
+
};
|
|
405
673
|
}
|
|
406
674
|
return {
|
|
407
|
-
action: {
|
|
408
|
-
system: 'ai',
|
|
409
|
-
kind: 'organisation',
|
|
410
|
-
key: org.id,
|
|
411
|
-
desired: {
|
|
412
|
-
provision: true,
|
|
413
|
-
limitMicros: org.ai.limitMicros,
|
|
414
|
-
offers: [...AI_DEFAULT_OFFERS],
|
|
415
|
-
},
|
|
416
|
-
},
|
|
417
675
|
step: {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
676
|
+
action: {
|
|
677
|
+
system: 'ai',
|
|
678
|
+
kind: 'organisation',
|
|
679
|
+
key: org.id,
|
|
680
|
+
desired: {
|
|
681
|
+
provision: true,
|
|
682
|
+
limitMicros: org.ai.limitMicros,
|
|
683
|
+
offers: [...AI_DEFAULT_OFFERS],
|
|
684
|
+
},
|
|
685
|
+
},
|
|
686
|
+
step: {
|
|
687
|
+
description: `provision ${org.slug}'s own organisation on AI and switch AI use on`,
|
|
688
|
+
destroys: false,
|
|
689
|
+
migrates: false,
|
|
690
|
+
},
|
|
421
691
|
},
|
|
692
|
+
blocked: null,
|
|
422
693
|
};
|
|
423
694
|
}
|
|
424
695
|
/**
|
|
@@ -428,23 +699,40 @@ function planAiOrganisation(org, byKey, claimed) {
|
|
|
428
699
|
* reversible"). A missing live resource reads as uncapped (`null`), the same
|
|
429
700
|
* value a freshly-declared org's `storage` starts at, so a brand new org with
|
|
430
701
|
* no limits declared produces no step.
|
|
702
|
+
*
|
|
703
|
+
* #177: silently plans nothing while there is no organisation to size a limit
|
|
704
|
+
* on yet and `filesReady` is false -- {@link planFilesOrganisation} already
|
|
705
|
+
* reports the platform's files host as missing once.
|
|
431
706
|
*/
|
|
432
|
-
function planStorage(org, byKey, claimed) {
|
|
707
|
+
function planStorage(org, byKey, claimed, filesReady, missingKeyRef, noKeyReader = false) {
|
|
433
708
|
const key = resourceKey('files', 'storage', org.id);
|
|
434
709
|
const live = byKey.get(key);
|
|
435
710
|
if (live)
|
|
436
711
|
claimed.add(key);
|
|
712
|
+
if (!filesReady && !live)
|
|
713
|
+
return { step: null, blocked: null };
|
|
437
714
|
const desired = { limitBytes: org.storage.limitBytes, limitMicros: org.storage.limitMicros };
|
|
438
715
|
const liveLimitBytes = live ? (live.config.limitBytes ?? null) : null;
|
|
439
716
|
const liveLimitMicros = live ? (live.config.limitMicros ?? null) : null;
|
|
440
|
-
if (liveLimitBytes === desired.limitBytes && liveLimitMicros === desired.limitMicros)
|
|
441
|
-
return null;
|
|
717
|
+
if (liveLimitBytes === desired.limitBytes && liveLimitMicros === desired.limitMicros) {
|
|
718
|
+
return { step: null, blocked: null };
|
|
719
|
+
}
|
|
720
|
+
// A live resource with drift needs the platform's key to change; blocked, not planned to throw at apply.
|
|
721
|
+
if (!filesReady) {
|
|
722
|
+
return {
|
|
723
|
+
step: null,
|
|
724
|
+
blocked: `${org.slug}'s storage limit change is blocked, not planned: ${notReadyReason('files', 'files management key', missingKeyRef, noKeyReader)}`,
|
|
725
|
+
};
|
|
726
|
+
}
|
|
442
727
|
return {
|
|
443
|
-
|
|
728
|
+
blocked: null,
|
|
444
729
|
step: {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
730
|
+
action: { system: 'files', kind: 'storage', key: org.id, desired },
|
|
731
|
+
step: {
|
|
732
|
+
description: `set ${org.slug}'s storage limit to ${desired.limitBytes ?? 'uncapped'} bytes / ${desired.limitMicros ?? 'uncapped'} micros`,
|
|
733
|
+
destroys: false,
|
|
734
|
+
migrates: false,
|
|
735
|
+
},
|
|
448
736
|
},
|
|
449
737
|
};
|
|
450
738
|
}
|
|
@@ -454,21 +742,132 @@ function planStorage(org, byKey, claimed) {
|
|
|
454
742
|
* (#80), which already seeds a brand new org's first budget row from this
|
|
455
743
|
* same `org.ai.limitMicros` -- this planner only fires again once that row's
|
|
456
744
|
* live value has drifted from what is declared.
|
|
745
|
+
*
|
|
746
|
+
* #177: silently plans nothing while there is no organisation to size a
|
|
747
|
+
* budget on yet and `aiReady` is false -- {@link planAiOrganisation} already
|
|
748
|
+
* reports the platform's AI host as missing once.
|
|
457
749
|
*/
|
|
458
|
-
function planAiBudget(org, byKey, claimed) {
|
|
750
|
+
function planAiBudget(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader = false) {
|
|
459
751
|
const key = resourceKey('ai', 'budget', org.id);
|
|
460
752
|
const live = byKey.get(key);
|
|
461
753
|
if (live)
|
|
462
754
|
claimed.add(key);
|
|
755
|
+
if (!aiReady && !live)
|
|
756
|
+
return { step: null, blocked: null };
|
|
463
757
|
const desired = { limitMicros: org.ai.limitMicros };
|
|
464
758
|
const liveLimitMicros = live ? (live.config.limitMicros ?? null) : null;
|
|
465
759
|
if (liveLimitMicros === desired.limitMicros)
|
|
760
|
+
return { step: null, blocked: null };
|
|
761
|
+
if (!aiReady) {
|
|
762
|
+
return {
|
|
763
|
+
step: null,
|
|
764
|
+
blocked: `${org.slug}'s AI budget change is blocked, not planned: ${notReadyReason('AI', 'AI management key', missingKeyRef, noKeyReader)}`,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
return {
|
|
768
|
+
blocked: null,
|
|
769
|
+
step: {
|
|
770
|
+
action: { system: 'ai', kind: 'budget', key: org.id, desired },
|
|
771
|
+
step: {
|
|
772
|
+
description: `set ${org.slug}'s AI budget limit to ${desired.limitMicros ?? 'uncapped'} micros`,
|
|
773
|
+
destroys: false,
|
|
774
|
+
migrates: false,
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* #80 step 3: the org's own ai key, minted once its organisation is
|
|
781
|
+
* provisioned and offered ({@link planAiOrganisation}). Idempotent on the
|
|
782
|
+
* *recorded* resource id (`recordedIds['ai:key:<orgId>']`), the same shape
|
|
783
|
+
* {@link planOwner} uses and for the same reason: ai's `issueOrganisationKey`
|
|
784
|
+
* (`wtfalch/ai#108`/`#113`) shows a key's secret exactly once and offers no
|
|
785
|
+
* way to list or look up an already-minted key by name, so there is nothing
|
|
786
|
+
* in `live` an existing key could ever appear as. `permissions` mirrors
|
|
787
|
+
* {@link AI_DEFAULT_OFFERS} -- the key can only do what the org's own offers
|
|
788
|
+
* already allow, so the two only ever widen together.
|
|
789
|
+
*
|
|
790
|
+
* Not built here: rotating the key before its `expiresDays` runs out. #80
|
|
791
|
+
* only asks for "give every org... a working ai key"; renewal is its own
|
|
792
|
+
* follow-up once a real org has carried one for a while.
|
|
793
|
+
*
|
|
794
|
+
* #177: silently plans nothing while `aiReady` is false -- there is no
|
|
795
|
+
* organisation to mint a key inside yet, and {@link planAiOrganisation}
|
|
796
|
+
* already reports the platform's AI host as missing once.
|
|
797
|
+
*/
|
|
798
|
+
function planAiKey(org, recordedIds, aiReady) {
|
|
799
|
+
const key = resourceKey('ai', 'key', org.id);
|
|
800
|
+
if (recordedIds[key])
|
|
801
|
+
return { step: null, blocked: null }; // delivered -- a re-run plans nothing.
|
|
802
|
+
if (!aiReady)
|
|
803
|
+
return { step: null, blocked: null };
|
|
804
|
+
// Minted but never delivered (`executor.ts`'s `aiKey`): nobody holds the
|
|
805
|
+
// secret and ai has no per-key revoke yet, so a new mint would only add
|
|
806
|
+
// another orphan. Reported, not planned.
|
|
807
|
+
const undelivered = recordedIds[AI_KEY_UNDELIVERED_SCOPE_KEY];
|
|
808
|
+
if (undelivered) {
|
|
809
|
+
return {
|
|
810
|
+
step: null,
|
|
811
|
+
blocked: `${org.slug}'s ai key ${undelivered} was minted but not delivered and must be revoked in ai before a new one is minted (https://github.com/wtfalch/ai/issues/138)`,
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
return {
|
|
815
|
+
blocked: null,
|
|
816
|
+
step: {
|
|
817
|
+
action: {
|
|
818
|
+
system: 'ai',
|
|
819
|
+
kind: 'key',
|
|
820
|
+
key: org.id,
|
|
821
|
+
desired: {
|
|
822
|
+
name: `${org.slug}-app`,
|
|
823
|
+
expiresDays: 365,
|
|
824
|
+
permissions: [...AI_DEFAULT_OFFERS],
|
|
825
|
+
},
|
|
826
|
+
},
|
|
827
|
+
step: {
|
|
828
|
+
description: `mint ${org.slug}'s own ai key`,
|
|
829
|
+
destroys: false,
|
|
830
|
+
migrates: false,
|
|
831
|
+
},
|
|
832
|
+
},
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* https://github.com/wtfalch/foundry/issues/139 (parent decision:
|
|
837
|
+
* https://github.com/wtfalch/foundry/issues/99): every site-enabled org gets
|
|
838
|
+
* one fixed ZITADEL web client named `cms`, so its studio
|
|
839
|
+
* (`@wtfalch/cms`'s `apps/host`) can sign anyone in -- automatically, with no
|
|
840
|
+
* new field on `DeclaredOrg`. Modeled on {@link planIssuerApps}'s single-app
|
|
841
|
+
* shape: provision when missing, recreate when the live `appType` differs,
|
|
842
|
+
* nothing otherwise. There is no park for this client either, for the same
|
|
843
|
+
* reason `planIssuerApps`'s own doc comment gives -- ZITADEL has no notion of
|
|
844
|
+
* a disabled-but-retained OIDC client.
|
|
845
|
+
*/
|
|
846
|
+
function planCmsClient(org, byKey, claimed) {
|
|
847
|
+
if (!org.site.enabled)
|
|
848
|
+
return null;
|
|
849
|
+
const key = resourceKey('zitadel', 'app', 'cms');
|
|
850
|
+
const live = byKey.get(key);
|
|
851
|
+
if (live)
|
|
852
|
+
claimed.add(key);
|
|
853
|
+
const desired = { appType: 'web' };
|
|
854
|
+
if (!live) {
|
|
855
|
+
return {
|
|
856
|
+
action: { system: 'zitadel', kind: 'app', key: 'cms', desired },
|
|
857
|
+
step: {
|
|
858
|
+
description: `provision the web ZITADEL app 'cms' for ${org.slug}'s cms studio sign-in`,
|
|
859
|
+
destroys: false,
|
|
860
|
+
migrates: false,
|
|
861
|
+
},
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
if (live.config.appType === 'web')
|
|
466
865
|
return null;
|
|
467
866
|
return {
|
|
468
|
-
action: { system: '
|
|
867
|
+
action: { system: 'zitadel', kind: 'app', key: 'cms', desired },
|
|
469
868
|
step: {
|
|
470
|
-
description: `
|
|
471
|
-
destroys:
|
|
869
|
+
description: `recreate ZITADEL app 'cms' for ${org.slug} as web (was ${String(live.config.appType)})`,
|
|
870
|
+
destroys: true,
|
|
472
871
|
migrates: false,
|
|
473
872
|
},
|
|
474
873
|
};
|
|
@@ -536,6 +935,129 @@ function planSite(org, byKey, claimed) {
|
|
|
536
935
|
},
|
|
537
936
|
};
|
|
538
937
|
}
|
|
938
|
+
/**
|
|
939
|
+
* https://github.com/wtfalch/foundry/issues/164 (per William,
|
|
940
|
+
* https://github.com/wtfalch/foundry/issues/161#issuecomment-5890087180:
|
|
941
|
+
* "every company gets its own Agora, always on"): every org gets one fixed
|
|
942
|
+
* ZITADEL web client named `agora`, so its Agora workspace
|
|
943
|
+
* (https://github.com/wtfalch/agora) can sign anyone in -- automatically,
|
|
944
|
+
* with no new field on `DeclaredOrg` (unlike {@link planCmsClient}'s own
|
|
945
|
+
* `cms` client, which only exists once `org.site.enabled` is true). Modeled
|
|
946
|
+
* on {@link planIssuerApps}'s/{@link planCmsClient}'s single-app shape:
|
|
947
|
+
* provision when missing, recreate when the live `appType` differs, nothing
|
|
948
|
+
* otherwise. There is no park for this client either, for the same reason
|
|
949
|
+
* `planIssuerApps`'s own doc comment gives -- ZITADEL has no notion of a
|
|
950
|
+
* disabled-but-retained OIDC client.
|
|
951
|
+
*/
|
|
952
|
+
function planAgoraClient(org, byKey, claimed) {
|
|
953
|
+
const key = resourceKey('zitadel', 'app', 'agora');
|
|
954
|
+
const live = byKey.get(key);
|
|
955
|
+
if (live)
|
|
956
|
+
claimed.add(key);
|
|
957
|
+
const desired = { appType: 'web' };
|
|
958
|
+
if (!live) {
|
|
959
|
+
return {
|
|
960
|
+
action: { system: 'zitadel', kind: 'app', key: 'agora', desired },
|
|
961
|
+
step: {
|
|
962
|
+
description: `provision the web ZITADEL app 'agora' for ${org.slug}'s Agora sign-in`,
|
|
963
|
+
destroys: false,
|
|
964
|
+
migrates: false,
|
|
965
|
+
},
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
if (live.config.appType === 'web')
|
|
969
|
+
return null;
|
|
970
|
+
return {
|
|
971
|
+
action: { system: 'zitadel', kind: 'app', key: 'agora', desired },
|
|
972
|
+
step: {
|
|
973
|
+
description: `recreate ZITADEL app 'agora' for ${org.slug} as web (was ${String(live.config.appType)})`,
|
|
974
|
+
destroys: true,
|
|
975
|
+
migrates: false,
|
|
976
|
+
},
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* #164: the org's own Agora, a per-org singleton resource keyed by the org
|
|
981
|
+
* id like {@link planMail}/{@link planSite}, but under its own `agora` kind
|
|
982
|
+
* rather than `app` -- it is a separate Coolify application from the org's
|
|
983
|
+
* own hosted app ({@link planApp}, `org.hosting.enabled`), provisioned from
|
|
984
|
+
* https://github.com/wtfalch/agora's `apps/agora/Dockerfile`
|
|
985
|
+
* (`executor.ts`'s `coolifyAgora`), never toggled by any declared field.
|
|
986
|
+
*
|
|
987
|
+
* Once the app is live this still plans an update whenever the live app's
|
|
988
|
+
* Agora env (`config.env`, read by `providers-readers.ts`) lacks a desired
|
|
989
|
+
* key or holds a different value, using `agora.ts`'s `desiredAgoraEnv`, the
|
|
990
|
+
* same function the executor writes from. There is no park branch: foundry
|
|
991
|
+
* never deletes an Agora.
|
|
992
|
+
*
|
|
993
|
+
* The host supplies the issuer and the mail/messaging project ids
|
|
994
|
+
* (`agora.ts`'s `AgoraHostPort`). While it lacks any (or has no port, or the
|
|
995
|
+
* port throws), no step is planned at all: the blocked Agora comes back as a
|
|
996
|
+
* message naming each missing value, which `computePlanDetail` lists with the
|
|
997
|
+
* plan's non-step items, so every other step still applies.
|
|
998
|
+
*/
|
|
999
|
+
function planAgora(org, byKey, claimed, recordedIds, reading) {
|
|
1000
|
+
const key = resourceKey('coolify', 'agora', org.id);
|
|
1001
|
+
const live = byKey.get(key);
|
|
1002
|
+
if (live)
|
|
1003
|
+
claimed.add(key);
|
|
1004
|
+
const error = 'error' in reading ? reading.error : null;
|
|
1005
|
+
const host = 'error' in reading ? NO_AGORA_HOST_VALUES : reading;
|
|
1006
|
+
const missing = agoraHostMissing(host);
|
|
1007
|
+
if (missing.length > 0) {
|
|
1008
|
+
// A fixed text, never the port's own message: this line is part of the
|
|
1009
|
+
// stale-plan comparison, and an error that varies per call would refuse
|
|
1010
|
+
// every apply as stale.
|
|
1011
|
+
const why = error ? ' (reading them failed)' : '';
|
|
1012
|
+
return {
|
|
1013
|
+
step: null,
|
|
1014
|
+
blocked: `${org.slug}'s Agora is blocked, not planned: the host supplies no ${missing.join(', ')}${why}`,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
// The executor writes exactly these host values, so what the plan showed
|
|
1018
|
+
// is what gets applied; the ids only the apply itself records are read at
|
|
1019
|
+
// run time.
|
|
1020
|
+
const action = {
|
|
1021
|
+
system: 'coolify',
|
|
1022
|
+
kind: 'agora',
|
|
1023
|
+
key: org.id,
|
|
1024
|
+
desired: { host },
|
|
1025
|
+
};
|
|
1026
|
+
if (!live) {
|
|
1027
|
+
return {
|
|
1028
|
+
step: {
|
|
1029
|
+
action,
|
|
1030
|
+
step: {
|
|
1031
|
+
description: `provision ${org.slug}'s Agora`,
|
|
1032
|
+
destroys: false,
|
|
1033
|
+
migrates: false,
|
|
1034
|
+
},
|
|
1035
|
+
},
|
|
1036
|
+
blocked: null,
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
const liveEnv = (live.config.env ?? {});
|
|
1040
|
+
const { env } = desiredAgoraEnv(org, recordedIds, host);
|
|
1041
|
+
const drift = agoraEnvDrift(env, liveEnv);
|
|
1042
|
+
// The ZITADEL ids may be unrecorded (so `env` has no AGORA_CONFIG to
|
|
1043
|
+
// compare); a live app with no AGORA_CONFIG at all still needs one.
|
|
1044
|
+
if (liveEnv.AGORA_CONFIG === undefined && !drift.includes('AGORA_CONFIG')) {
|
|
1045
|
+
drift.push('AGORA_CONFIG');
|
|
1046
|
+
}
|
|
1047
|
+
if (drift.length === 0)
|
|
1048
|
+
return { step: null, blocked: null };
|
|
1049
|
+
return {
|
|
1050
|
+
step: {
|
|
1051
|
+
action,
|
|
1052
|
+
step: {
|
|
1053
|
+
description: `update ${org.slug}'s Agora config (${drift.join(', ')} missing or different)`,
|
|
1054
|
+
destroys: false,
|
|
1055
|
+
migrates: false,
|
|
1056
|
+
},
|
|
1057
|
+
},
|
|
1058
|
+
blocked: null,
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
539
1061
|
/**
|
|
540
1062
|
* The shared on/off shape behind {@link planApp}, {@link planDatabase} and
|
|
541
1063
|
* {@link planTunnel} (#71): a per-org singleton resource, keyed by the org
|
|
@@ -616,6 +1138,72 @@ function planApp(org, byKey, claimed) {
|
|
|
616
1138
|
},
|
|
617
1139
|
};
|
|
618
1140
|
}
|
|
1141
|
+
/**
|
|
1142
|
+
* #173: register the org's own app with the platform's jobs scheduler
|
|
1143
|
+
* (https://github.com/wtfalch/jobs's admin API, jobs#62), so the scheduler
|
|
1144
|
+
* knows where to deliver that org's schedules. Idempotent on the *recorded*
|
|
1145
|
+
* resource id, the same shape {@link planOwner} uses for inviting a human
|
|
1146
|
+
* owner by email: nothing in `live` can report "is this org registered" (no
|
|
1147
|
+
* reader exists for the scheduler's own admin API), and the admin API's own
|
|
1148
|
+
* `POST /admin/projects` is itself idempotent by `(name, baseUrl)` -- a
|
|
1149
|
+
* repeat call returns `200` with just the project id, never a fresh secret --
|
|
1150
|
+
* but this plan-level gate means a re-plan after a successful apply proposes
|
|
1151
|
+
* nothing at all, rather than relying on the executor to notice the repeat
|
|
1152
|
+
* every time.
|
|
1153
|
+
*
|
|
1154
|
+
* Only planned once the org has its own app to register
|
|
1155
|
+
* (`org.hosting.enabled`) -- an org with no hosted app has no `baseUrl` for
|
|
1156
|
+
* the scheduler to deliver to. The hostname is derived the same way
|
|
1157
|
+
* {@link planSite}'s own `hostname` local does: `DeclaredOrg.domain` once
|
|
1158
|
+
* `hosting.usesDomain` is true, `<slug>.oikos.host` otherwise.
|
|
1159
|
+
*/
|
|
1160
|
+
function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = null, noKeyReader = false) {
|
|
1161
|
+
if (!org.hosting.enabled)
|
|
1162
|
+
return { step: null, blocked: null };
|
|
1163
|
+
const key = resourceKey('jobs', 'registration', org.id);
|
|
1164
|
+
if (recordedIds[key])
|
|
1165
|
+
return { step: null, blocked: null }; // already registered -- a re-run plans nothing.
|
|
1166
|
+
// Registered but the credentials never reached the host (`executor.ts`'s
|
|
1167
|
+
// `jobsRegistration`): the scheduler shows them once and a repeat call
|
|
1168
|
+
// returns only the id, so planning the step again would record success with
|
|
1169
|
+
// no sync key. Reported, not planned.
|
|
1170
|
+
const undelivered = recordedIds[JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY];
|
|
1171
|
+
if (undelivered) {
|
|
1172
|
+
return {
|
|
1173
|
+
step: null,
|
|
1174
|
+
blocked: `${org.slug}'s jobs registration ${undelivered} was registered but its credentials were not delivered; deregister it in the scheduler (DELETE /admin/projects/${org.slug}) and clear the recorded id before registering again`,
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
// The host says whether its executor has a scheduler to register with. A
|
|
1178
|
+
// fixed text: it is part of the stale-plan comparison.
|
|
1179
|
+
if (!jobsAvailable) {
|
|
1180
|
+
return {
|
|
1181
|
+
step: null,
|
|
1182
|
+
blocked: !missingKeyRef
|
|
1183
|
+
? `${org.slug}'s jobs registration is blocked, not planned: the host supplies no jobs scheduler`
|
|
1184
|
+
: `${org.slug}'s jobs registration is blocked, not planned: ${noKeyReader
|
|
1185
|
+
? `the host gave no held-keys reader and no jobs scheduler port, so the platform's jobs admin token ${missingKeyRef} cannot be checked`
|
|
1186
|
+
: `the platform's jobs admin token ${missingKeyRef} is not held yet`}`,
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
const hostname = org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
|
|
1190
|
+
return {
|
|
1191
|
+
blocked: null,
|
|
1192
|
+
step: {
|
|
1193
|
+
action: {
|
|
1194
|
+
system: 'jobs',
|
|
1195
|
+
kind: 'registration',
|
|
1196
|
+
key: org.id,
|
|
1197
|
+
desired: { name: org.slug, baseUrl: `https://${hostname}` },
|
|
1198
|
+
},
|
|
1199
|
+
step: {
|
|
1200
|
+
description: `register ${org.slug}'s app with the jobs scheduler`,
|
|
1201
|
+
destroys: false,
|
|
1202
|
+
migrates: false,
|
|
1203
|
+
},
|
|
1204
|
+
},
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
619
1207
|
/**
|
|
620
1208
|
* A Postgres database beside the org's app (`org.ts`'s
|
|
621
1209
|
* `HostingConfig.database`, #71) -- `validateHosting` requires
|
|
@@ -643,10 +1231,24 @@ function planDatabase(org, byKey, claimed) {
|
|
|
643
1231
|
* (`executor.ts`'s `tunnelIngressHostnames`) -- there is no separate
|
|
644
1232
|
* `site.tunnel` toggle to gate on the way `hosting.tunnel` does, since a site
|
|
645
1233
|
* has no meaning without one.
|
|
1234
|
+
*
|
|
1235
|
+
* #145: an own-domain calls service (`org.ts`'s `CallsConfig.usesDomain`)
|
|
1236
|
+
* needs a tunnel too, for the same reason a site does -- an org that wants
|
|
1237
|
+
* `calls.<domain>` but declares no other own-domain feature must still get
|
|
1238
|
+
* one provisioned, not silently have nowhere for that hostname to route.
|
|
1239
|
+
*
|
|
1240
|
+
* #164: Agora needs a tunnel for the same reason, the moment `org.domain` is
|
|
1241
|
+
* set. Agora is always on (William, 2026-09-29), so every org with a domain
|
|
1242
|
+
* needs routing for `agora.<domain>` even with hosting and site off -- there
|
|
1243
|
+
* is no `usesDomain` flag of its own to gate this on. A level-1 org (no
|
|
1244
|
+
* domain) plans no per-org tunnel for Agora: `<slug>-agora.oikos.host` rides
|
|
1245
|
+
* the shared `*.oikos.host` wildcard tunnel (`scripts/shared-ingress.mjs`).
|
|
646
1246
|
*/
|
|
647
1247
|
function planTunnel(org, byKey, claimed) {
|
|
648
1248
|
const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
|
|
649
|
-
(org.site.enabled && Boolean(org.domain))
|
|
1249
|
+
(org.site.enabled && Boolean(org.domain)) ||
|
|
1250
|
+
(org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
|
|
1251
|
+
Boolean(org.domain);
|
|
650
1252
|
return planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`);
|
|
651
1253
|
}
|
|
652
1254
|
/**
|
|
@@ -664,9 +1266,21 @@ function planTunnel(org, byKey, claimed) {
|
|
|
664
1266
|
* though it can never have `hosting` on at the same time (`validateSite`) --
|
|
665
1267
|
* `org.site.enabled` already guarantees both `domain` and `site.usesDomain`
|
|
666
1268
|
* are set, so the `org.domain` check below still covers it.
|
|
1269
|
+
*
|
|
1270
|
+
* #145: an own-domain calls service (`org.ts`'s `CallsConfig.usesDomain`)
|
|
1271
|
+
* needs a zone for the same reason, whether or not the org also has hosting
|
|
1272
|
+
* or a site on its domain.
|
|
1273
|
+
*
|
|
1274
|
+
* #164: Agora needs a zone for the same reason, the moment `org.domain` is
|
|
1275
|
+
* set -- Agora is always on, so every org with a domain wants its zone even
|
|
1276
|
+
* with hosting and site off (see {@link planTunnel}).
|
|
667
1277
|
*/
|
|
668
1278
|
function planZone(org, byKey, claimed) {
|
|
669
|
-
|
|
1279
|
+
const usesDomain = org.hosting.usesDomain ||
|
|
1280
|
+
org.site.enabled ||
|
|
1281
|
+
(org.calls.enabled && org.calls.usesDomain) ||
|
|
1282
|
+
Boolean(org.domain);
|
|
1283
|
+
if (!usesDomain || !org.domain)
|
|
670
1284
|
return null;
|
|
671
1285
|
const key = resourceKey('cloudflare', 'zone', org.domain);
|
|
672
1286
|
const live = byKey.get(key);
|