@wtfalch/foundry 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +5 -4
- package/dist/calls-sfu.js +5 -4
- package/dist/index.d.ts +31 -6
- package/dist/index.js +16 -3
- 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.js +8 -0
- package/dist/plan/agora.d.ts +77 -0
- package/dist/plan/agora.js +127 -0
- package/dist/plan/cloudflared-connector.d.ts +70 -0
- package/dist/plan/cloudflared-connector.js +106 -0
- package/dist/plan/diff.d.ts +20 -3
- package/dist/plan/diff.js +631 -116
- package/dist/plan/executor.d.ts +475 -168
- package/dist/plan/executor.js +543 -175
- 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 +278 -9
- 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 +15 -2
- package/dist/store/audit.js +4 -2
- package/dist/store/postgres.d.ts +29 -1
- package/dist/store/postgres.js +172 -2
- package/dist/store/schema.d.ts +683 -0
- package/dist/store/schema.js +57 -0
- package/dist/store.d.ts +46 -0
- package/package.json +1 -1
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 {
|
|
@@ -38,52 +41,66 @@ function resourceKey(system, kind, key) {
|
|
|
38
41
|
* by default unlike chat's own: {@link planCalls}, the org's own calls
|
|
39
42
|
* Matrix stack (`org.ts`'s `CallsConfig.enabled`). #145 adds one more,
|
|
40
43
|
* planned right after it and gated on the same flag: {@link planCallsService},
|
|
41
|
-
* the calls service itself (`wtfalch/calls`'s `packages/service`).
|
|
42
|
-
*
|
|
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
|
|
43
57
|
* declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
|
|
44
58
|
* guests, calls on the org's own domain, adopted/bespoke apps, the actual
|
|
45
59
|
* Zitadel/Coolify/Cloudflare/Files/AI/cms shapes) is #20's "provision
|
|
46
60
|
* Company Factory end to end", which wires real readers and executors
|
|
47
61
|
* against this seam.
|
|
48
62
|
*/
|
|
49
|
-
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) {
|
|
50
64
|
const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
|
|
51
65
|
const claimed = new Set();
|
|
52
66
|
const plannedSteps = [];
|
|
53
|
-
const issuer = planIssuer(org, byKey, claimed);
|
|
54
|
-
if (issuer)
|
|
55
|
-
plannedSteps.push(issuer);
|
|
56
|
-
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);
|
|
57
71
|
if (owner)
|
|
58
72
|
plannedSteps.push(owner);
|
|
59
|
-
const mail = planMail(org, byKey, claimed);
|
|
60
|
-
if (mail)
|
|
61
|
-
plannedSteps.push(mail);
|
|
73
|
+
const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost);
|
|
74
|
+
if (mail.step)
|
|
75
|
+
plannedSteps.push(mail.step);
|
|
62
76
|
const chat = planChat(org, byKey, claimed);
|
|
63
77
|
if (chat)
|
|
64
78
|
plannedSteps.push(chat);
|
|
65
|
-
const calls = planCalls(org, byKey, claimed);
|
|
66
|
-
if (calls)
|
|
67
|
-
plannedSteps.push(calls);
|
|
79
|
+
const calls = planCalls(org, byKey, claimed, platformReadiness.callsReady);
|
|
80
|
+
if (calls.step)
|
|
81
|
+
plannedSteps.push(calls.step);
|
|
68
82
|
// Right after the Matrix stack (#145): its own executor
|
|
69
83
|
// (`executor.ts`'s `coolifyCallsService`) reads the Synapse/MAS addresses
|
|
70
84
|
// and MAS client credentials `coolifyCalls` just recorded, the same
|
|
71
85
|
// ordering `planOwner` relies on for `zitadel:issuer`.
|
|
72
|
-
const callsService = planCallsService(org, byKey, claimed);
|
|
73
|
-
if (callsService)
|
|
74
|
-
plannedSteps.push(callsService);
|
|
75
|
-
const filesOrganisation = planFilesOrganisation(org, byKey, claimed);
|
|
76
|
-
if (filesOrganisation)
|
|
77
|
-
plannedSteps.push(filesOrganisation);
|
|
78
|
-
const storage = planStorage(org, byKey, claimed);
|
|
79
|
-
if (storage)
|
|
80
|
-
plannedSteps.push(storage);
|
|
81
|
-
const aiOrganisation = planAiOrganisation(org, byKey, claimed);
|
|
82
|
-
if (aiOrganisation)
|
|
83
|
-
plannedSteps.push(aiOrganisation);
|
|
84
|
-
const aiBudget = planAiBudget(org, byKey, claimed);
|
|
85
|
-
if (aiBudget)
|
|
86
|
-
plannedSteps.push(aiBudget);
|
|
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);
|
|
87
104
|
// Before `planSite`'s own step (#139): `cmsSite`'s executor reads this
|
|
88
105
|
// step's recorded client id, the same way `coolifyApp` runs after the
|
|
89
106
|
// hosted app's own `zitadel:app` steps.
|
|
@@ -134,6 +151,22 @@ export function computePlanDetail(org, live, recordedIds = {}) {
|
|
|
134
151
|
const customers = planCustomers(org, live, claimed, recordedIds);
|
|
135
152
|
if (customers)
|
|
136
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);
|
|
137
170
|
// #10 (resolved): "foundry manages only what the declared org names";
|
|
138
171
|
// anything left unclaimed is listed, never proposed for deletion.
|
|
139
172
|
const unmanaged = live
|
|
@@ -141,6 +174,41 @@ export function computePlanDetail(org, live, recordedIds = {}) {
|
|
|
141
174
|
.map((r) => ({
|
|
142
175
|
description: `${r.system} ${r.kind} "${r.key}" is live but not named by the declared org`,
|
|
143
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' });
|
|
144
212
|
return { orgId: org.id, plannedSteps, unmanaged };
|
|
145
213
|
}
|
|
146
214
|
/**
|
|
@@ -158,48 +226,67 @@ export function computePlanDetail(org, live, recordedIds = {}) {
|
|
|
158
226
|
* (#7 corrections 2) and is not built in v1 (decisions file, same section:
|
|
159
227
|
* "domain verification and passkey invalidation need their own design"), so
|
|
160
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.
|
|
161
236
|
*/
|
|
162
|
-
function planIssuer(org, byKey, claimed) {
|
|
237
|
+
function planIssuer(org, byKey, claimed, authReady) {
|
|
163
238
|
const key = resourceKey('zitadel', 'issuer', org.id);
|
|
164
239
|
const live = byKey.get(key);
|
|
165
240
|
const desiredUsesDomain = org.issuer.usesDomain;
|
|
166
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
|
+
}
|
|
167
248
|
return {
|
|
168
|
-
action: {
|
|
169
|
-
system: 'zitadel',
|
|
170
|
-
kind: 'issuer',
|
|
171
|
-
key: org.id,
|
|
172
|
-
desired: { provision: true, usesDomain: desiredUsesDomain },
|
|
173
|
-
},
|
|
174
249
|
step: {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
+
},
|
|
178
261
|
},
|
|
262
|
+
blocked: null,
|
|
179
263
|
};
|
|
180
264
|
}
|
|
181
265
|
claimed.add(key);
|
|
182
266
|
const liveUsesDomain = Boolean(live.config.usesDomain);
|
|
183
267
|
if (liveUsesDomain === desiredUsesDomain)
|
|
184
|
-
return null;
|
|
268
|
+
return { step: null, blocked: null };
|
|
185
269
|
const domain = org.domain ?? 'the org domain';
|
|
186
270
|
return {
|
|
187
|
-
action: {
|
|
188
|
-
system: 'zitadel',
|
|
189
|
-
kind: 'issuer',
|
|
190
|
-
key: org.id,
|
|
191
|
-
desired: { usesDomain: desiredUsesDomain },
|
|
192
|
-
},
|
|
193
271
|
step: {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
+
},
|
|
202
288
|
},
|
|
289
|
+
blocked: null,
|
|
203
290
|
};
|
|
204
291
|
}
|
|
205
292
|
/**
|
|
@@ -230,12 +317,17 @@ function planIssuer(org, byKey, claimed) {
|
|
|
230
317
|
* nothing here puts it anywhere beyond what audit already records for the
|
|
231
318
|
* org.
|
|
232
319
|
*/
|
|
233
|
-
function planOwner(org, recordedIds) {
|
|
320
|
+
function planOwner(org, recordedIds, authReady) {
|
|
234
321
|
if (!org.owner)
|
|
235
322
|
return null;
|
|
236
323
|
const key = resourceKey('zitadel', 'owner', org.id);
|
|
237
324
|
if (recordedIds[key])
|
|
238
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;
|
|
239
331
|
return {
|
|
240
332
|
action: {
|
|
241
333
|
system: 'zitadel',
|
|
@@ -254,7 +346,27 @@ function planOwner(org, recordedIds) {
|
|
|
254
346
|
},
|
|
255
347
|
};
|
|
256
348
|
}
|
|
257
|
-
|
|
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) {
|
|
258
370
|
const key = resourceKey('coolify', 'mail', org.id);
|
|
259
371
|
const live = byKey.get(key);
|
|
260
372
|
// `off` means mail is not foundry's concern here (org.ts's MailMode doc);
|
|
@@ -264,45 +376,90 @@ function planMail(org, byKey, claimed) {
|
|
|
264
376
|
// after having it would silently hide its own leftover mail resource from
|
|
265
377
|
// the unmanaged list.
|
|
266
378
|
if (org.mail.mode === 'off')
|
|
267
|
-
return null;
|
|
379
|
+
return { step: null, blocked: null };
|
|
268
380
|
if (live)
|
|
269
381
|
claimed.add(key);
|
|
270
|
-
|
|
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` : '';
|
|
271
400
|
if (!live) {
|
|
272
401
|
return {
|
|
273
|
-
action: { system: 'coolify', kind: 'mail', key: org.id, desired },
|
|
274
402
|
step: {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
+
},
|
|
278
409
|
},
|
|
410
|
+
blocked: null,
|
|
279
411
|
};
|
|
280
412
|
}
|
|
281
413
|
const liveMode = live.config.mode;
|
|
282
414
|
const liveUsesDomain = Boolean(live.config.usesDomain);
|
|
283
|
-
if (liveMode === desired.mode && liveUsesDomain === desired.usesDomain)
|
|
284
|
-
|
|
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
|
+
}
|
|
285
436
|
if (liveUsesDomain && !desired.usesDomain) {
|
|
286
437
|
// #7 corrections 1: parked, not deleted — mailboxes and messages stay —
|
|
287
438
|
// but still disruptive enough to gate like a destructive step.
|
|
288
439
|
return {
|
|
289
|
-
action: { system: 'coolify', kind: 'mail', key: org.id, desired },
|
|
290
440
|
step: {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
+
},
|
|
294
447
|
},
|
|
448
|
+
blocked: null,
|
|
295
449
|
};
|
|
296
450
|
}
|
|
297
451
|
// A mode transition (#7 "Transitions") or turning a domain on is a
|
|
298
452
|
// one-time copy, never a deletion.
|
|
299
453
|
return {
|
|
300
|
-
action: { system: 'coolify', kind: 'mail', key: org.id, desired },
|
|
301
454
|
step: {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
+
},
|
|
305
461
|
},
|
|
462
|
+
blocked: null,
|
|
306
463
|
};
|
|
307
464
|
}
|
|
308
465
|
function planChat(org, byKey, claimed) {
|
|
@@ -364,9 +521,26 @@ function planChat(org, byKey, claimed) {
|
|
|
364
521
|
* {@link planApp}/{@link planDatabase}/{@link planTunnel} below
|
|
365
522
|
* (`planHostingToggle`, #7 corrections 1) -- turning it off parks Synapse and
|
|
366
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.
|
|
367
531
|
*/
|
|
368
|
-
function planCalls(org, byKey, claimed) {
|
|
369
|
-
|
|
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
|
+
};
|
|
370
544
|
}
|
|
371
545
|
/**
|
|
372
546
|
* The org's own calls service (#145, part 2 of #128) -- `wtfalch/calls`'s
|
|
@@ -379,9 +553,20 @@ function planCalls(org, byKey, claimed) {
|
|
|
379
553
|
* planner steps. Same on/off, park-not-delete shape as {@link planCalls}
|
|
380
554
|
* (`planHostingToggle`, #7 corrections 1) -- turning it off parks the app,
|
|
381
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.
|
|
382
560
|
*/
|
|
383
|
-
function planCallsService(org, byKey, claimed) {
|
|
384
|
-
|
|
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
|
+
};
|
|
385
570
|
}
|
|
386
571
|
/**
|
|
387
572
|
* Deduped `ai.*` permissions offered to every organisation by default
|
|
@@ -394,6 +579,19 @@ function planCallsService(org, byKey, claimed) {
|
|
|
394
579
|
* explicit opt-in this default deliberately leaves out.
|
|
395
580
|
*/
|
|
396
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
|
+
}
|
|
397
595
|
/**
|
|
398
596
|
* #80: every org's own organisation on Files, a per-org singleton resource
|
|
399
597
|
* planned before {@link planStorage} -- `setQuota`/`setBudget` 404 against an
|
|
@@ -407,26 +605,39 @@ const AI_DEFAULT_OFFERS = ['ai.offerings:invoke', 'ai.runs:create'];
|
|
|
407
605
|
* concern (`org.ts`'s `StorageConfig` doc: "a separate, operator-driven hard
|
|
408
606
|
* cap this field has no opinion on"), so provisioning always asks for an
|
|
409
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.
|
|
410
612
|
*/
|
|
411
|
-
function planFilesOrganisation(org, byKey, claimed) {
|
|
613
|
+
function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, noKeyReader = false) {
|
|
412
614
|
const key = resourceKey('files', 'organisation', org.id);
|
|
413
615
|
const live = byKey.get(key);
|
|
414
616
|
if (live) {
|
|
415
617
|
claimed.add(key);
|
|
416
|
-
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
|
+
};
|
|
417
625
|
}
|
|
418
626
|
return {
|
|
419
|
-
action: {
|
|
420
|
-
system: 'files',
|
|
421
|
-
kind: 'organisation',
|
|
422
|
-
key: org.id,
|
|
423
|
-
desired: { provision: true, ceilingBytes: null, ceilingMicros: null },
|
|
424
|
-
},
|
|
425
627
|
step: {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
+
},
|
|
429
639
|
},
|
|
640
|
+
blocked: null,
|
|
430
641
|
};
|
|
431
642
|
}
|
|
432
643
|
/**
|
|
@@ -443,30 +654,42 @@ function planFilesOrganisation(org, byKey, claimed) {
|
|
|
443
654
|
* first budget row with whatever the org already declares (including
|
|
444
655
|
* `null` for uncapped), so a brand new org never needs a follow-up
|
|
445
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.
|
|
446
660
|
*/
|
|
447
|
-
function planAiOrganisation(org, byKey, claimed) {
|
|
661
|
+
function planAiOrganisation(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader = false) {
|
|
448
662
|
const key = resourceKey('ai', 'organisation', org.id);
|
|
449
663
|
const live = byKey.get(key);
|
|
450
664
|
if (live) {
|
|
451
665
|
claimed.add(key);
|
|
452
|
-
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
|
+
};
|
|
453
673
|
}
|
|
454
674
|
return {
|
|
455
|
-
action: {
|
|
456
|
-
system: 'ai',
|
|
457
|
-
kind: 'organisation',
|
|
458
|
-
key: org.id,
|
|
459
|
-
desired: {
|
|
460
|
-
provision: true,
|
|
461
|
-
limitMicros: org.ai.limitMicros,
|
|
462
|
-
offers: [...AI_DEFAULT_OFFERS],
|
|
463
|
-
},
|
|
464
|
-
},
|
|
465
675
|
step: {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
+
},
|
|
469
691
|
},
|
|
692
|
+
blocked: null,
|
|
470
693
|
};
|
|
471
694
|
}
|
|
472
695
|
/**
|
|
@@ -476,23 +699,40 @@ function planAiOrganisation(org, byKey, claimed) {
|
|
|
476
699
|
* reversible"). A missing live resource reads as uncapped (`null`), the same
|
|
477
700
|
* value a freshly-declared org's `storage` starts at, so a brand new org with
|
|
478
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.
|
|
479
706
|
*/
|
|
480
|
-
function planStorage(org, byKey, claimed) {
|
|
707
|
+
function planStorage(org, byKey, claimed, filesReady, missingKeyRef, noKeyReader = false) {
|
|
481
708
|
const key = resourceKey('files', 'storage', org.id);
|
|
482
709
|
const live = byKey.get(key);
|
|
483
710
|
if (live)
|
|
484
711
|
claimed.add(key);
|
|
712
|
+
if (!filesReady && !live)
|
|
713
|
+
return { step: null, blocked: null };
|
|
485
714
|
const desired = { limitBytes: org.storage.limitBytes, limitMicros: org.storage.limitMicros };
|
|
486
715
|
const liveLimitBytes = live ? (live.config.limitBytes ?? null) : null;
|
|
487
716
|
const liveLimitMicros = live ? (live.config.limitMicros ?? null) : null;
|
|
488
|
-
if (liveLimitBytes === desired.limitBytes && liveLimitMicros === desired.limitMicros)
|
|
489
|
-
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
|
+
}
|
|
490
727
|
return {
|
|
491
|
-
|
|
728
|
+
blocked: null,
|
|
492
729
|
step: {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
+
},
|
|
496
736
|
},
|
|
497
737
|
};
|
|
498
738
|
}
|
|
@@ -502,22 +742,93 @@ function planStorage(org, byKey, claimed) {
|
|
|
502
742
|
* (#80), which already seeds a brand new org's first budget row from this
|
|
503
743
|
* same `org.ai.limitMicros` -- this planner only fires again once that row's
|
|
504
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.
|
|
505
749
|
*/
|
|
506
|
-
function planAiBudget(org, byKey, claimed) {
|
|
750
|
+
function planAiBudget(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader = false) {
|
|
507
751
|
const key = resourceKey('ai', 'budget', org.id);
|
|
508
752
|
const live = byKey.get(key);
|
|
509
753
|
if (live)
|
|
510
754
|
claimed.add(key);
|
|
755
|
+
if (!aiReady && !live)
|
|
756
|
+
return { step: null, blocked: null };
|
|
511
757
|
const desired = { limitMicros: org.ai.limitMicros };
|
|
512
758
|
const liveLimitMicros = live ? (live.config.limitMicros ?? null) : null;
|
|
513
759
|
if (liveLimitMicros === desired.limitMicros)
|
|
514
|
-
return null;
|
|
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
|
+
}
|
|
515
767
|
return {
|
|
516
|
-
|
|
768
|
+
blocked: null,
|
|
517
769
|
step: {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
+
},
|
|
521
832
|
},
|
|
522
833
|
};
|
|
523
834
|
}
|
|
@@ -624,6 +935,129 @@ function planSite(org, byKey, claimed) {
|
|
|
624
935
|
},
|
|
625
936
|
};
|
|
626
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
|
+
}
|
|
627
1061
|
/**
|
|
628
1062
|
* The shared on/off shape behind {@link planApp}, {@link planDatabase} and
|
|
629
1063
|
* {@link planTunnel} (#71): a per-org singleton resource, keyed by the org
|
|
@@ -704,6 +1138,72 @@ function planApp(org, byKey, claimed) {
|
|
|
704
1138
|
},
|
|
705
1139
|
};
|
|
706
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
|
+
}
|
|
707
1207
|
/**
|
|
708
1208
|
* A Postgres database beside the org's app (`org.ts`'s
|
|
709
1209
|
* `HostingConfig.database`, #71) -- `validateHosting` requires
|
|
@@ -736,11 +1236,19 @@ function planDatabase(org, byKey, claimed) {
|
|
|
736
1236
|
* needs a tunnel too, for the same reason a site does -- an org that wants
|
|
737
1237
|
* `calls.<domain>` but declares no other own-domain feature must still get
|
|
738
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`).
|
|
739
1246
|
*/
|
|
740
1247
|
function planTunnel(org, byKey, claimed) {
|
|
741
1248
|
const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
|
|
742
1249
|
(org.site.enabled && Boolean(org.domain)) ||
|
|
743
|
-
(org.calls.enabled && org.calls.usesDomain && Boolean(org.domain))
|
|
1250
|
+
(org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
|
|
1251
|
+
Boolean(org.domain);
|
|
744
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)`);
|
|
745
1253
|
}
|
|
746
1254
|
/**
|
|
@@ -762,9 +1270,16 @@ function planTunnel(org, byKey, claimed) {
|
|
|
762
1270
|
* #145: an own-domain calls service (`org.ts`'s `CallsConfig.usesDomain`)
|
|
763
1271
|
* needs a zone for the same reason, whether or not the org also has hosting
|
|
764
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}).
|
|
765
1277
|
*/
|
|
766
1278
|
function planZone(org, byKey, claimed) {
|
|
767
|
-
const usesDomain = org.hosting.usesDomain ||
|
|
1279
|
+
const usesDomain = org.hosting.usesDomain ||
|
|
1280
|
+
org.site.enabled ||
|
|
1281
|
+
(org.calls.enabled && org.calls.usesDomain) ||
|
|
1282
|
+
Boolean(org.domain);
|
|
768
1283
|
if (!usesDomain || !org.domain)
|
|
769
1284
|
return null;
|
|
770
1285
|
const key = resourceKey('cloudflare', 'zone', org.domain);
|