@wtfalch/foundry 0.1.0 → 0.2.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/apply.d.ts +14 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +3 -2
- package/dist/migrations/0008_hosting.sql +24 -0
- package/dist/migrations/0009_hosting_git_branch_build_pack.sql +14 -0
- package/dist/migrations/0010_owner.sql +13 -0
- package/dist/migrations/0011_test_org.sql +12 -0
- package/dist/org.d.ts +107 -0
- package/dist/org.js +65 -0
- package/dist/plan/diff.d.ts +20 -5
- package/dist/plan/diff.js +402 -9
- package/dist/plan/executor.d.ts +474 -0
- package/dist/plan/executor.js +507 -0
- package/dist/plan/index.d.ts +6 -4
- package/dist/plan/index.js +3 -2
- package/dist/plan/live.d.ts +8 -4
- package/dist/plan/plan-apply.d.ts +114 -4
- package/dist/plan/plan-apply.js +251 -14
- package/dist/plan/providers-readers.d.ts +48 -13
- package/dist/plan/providers-readers.js +93 -16
- package/dist/state.d.ts +26 -5
- package/dist/store/postgres.d.ts +3 -1
- package/dist/store/postgres.js +65 -2
- package/dist/store/schema.d.ts +190 -0
- package/dist/store/schema.js +18 -0
- package/dist/store.d.ts +10 -0
- package/package.json +9 -11
package/dist/plan/diff.js
CHANGED
|
@@ -19,36 +19,84 @@ function resourceKey(system, kind, key) {
|
|
|
19
19
|
* exist for every org — the issuer, mail, and chat (always provisioned at
|
|
20
20
|
* level 1, per #7 "Chat", regardless of `usesDomain`). #27 adds two more,
|
|
21
21
|
* in the same singleton shape: storage's spend limit on Files and AI's
|
|
22
|
-
* budget.
|
|
23
|
-
* (`org.ts`'s `SiteConfig`).
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* budget. T10 adds a sixth, in the same shape: the org's cms site
|
|
23
|
+
* (`org.ts`'s `SiteConfig`). #71 adds four more singleton resources, in the
|
|
24
|
+
* same shape — the org's own hosted app, its database, its Cloudflare
|
|
25
|
+
* tunnel and its Cloudflare zone (`org.ts`'s `HostingConfig`) — plus one
|
|
26
|
+
* list-shaped resource: `planIssuerApps` below emits one step per declared
|
|
27
|
+
* `hosting.issuerApps[]` entry rather than a single singleton, since an org
|
|
28
|
+
* can declare more than one ZITADEL app. #80 adds two more singletons,
|
|
29
|
+
* modeled on {@link planIssuer}: `planFilesOrganisation` and
|
|
30
|
+
* `planAiOrganisation`, each planned before its own system's limit step
|
|
31
|
+
* ({@link planStorage}, {@link planAiBudget}) so an org has somewhere for a
|
|
32
|
+
* limit to land, and AI use is switched on, before either limit is ever set.
|
|
33
|
+
* `planOwner` below adds one more singleton, in a different shape from the
|
|
34
|
+
* rest: the org's owner, invited by ZITADEL email, idempotent on the
|
|
35
|
+
* *recorded* resource id (`recordedIds`, sourced from
|
|
36
|
+
* `AppliedState.serviceIds`) rather than a live resource, since a human user
|
|
37
|
+
* is never something `live` can contain. Everything else in `live` that none
|
|
38
|
+
* of these twelve singletons and however many declared issuer apps claim is
|
|
39
|
+
* unmanaged. The full field-by-field provisioning surface (SSO, guests,
|
|
40
|
+
* calls, adopted/bespoke apps, the actual
|
|
26
41
|
* Zitadel/Coolify/Cloudflare/Files/AI/cms shapes) is #20's "provision
|
|
27
42
|
* Company Factory end to end", which wires real readers and executors
|
|
28
43
|
* against this seam.
|
|
29
44
|
*/
|
|
30
|
-
export function computePlanDetail(org, live) {
|
|
45
|
+
export function computePlanDetail(org, live, recordedIds = {}) {
|
|
31
46
|
const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
|
|
32
47
|
const claimed = new Set();
|
|
33
48
|
const plannedSteps = [];
|
|
34
49
|
const issuer = planIssuer(org, byKey, claimed);
|
|
35
50
|
if (issuer)
|
|
36
51
|
plannedSteps.push(issuer);
|
|
52
|
+
const owner = planOwner(org, recordedIds);
|
|
53
|
+
if (owner)
|
|
54
|
+
plannedSteps.push(owner);
|
|
37
55
|
const mail = planMail(org, byKey, claimed);
|
|
38
56
|
if (mail)
|
|
39
57
|
plannedSteps.push(mail);
|
|
40
58
|
const chat = planChat(org, byKey, claimed);
|
|
41
59
|
if (chat)
|
|
42
60
|
plannedSteps.push(chat);
|
|
61
|
+
const filesOrganisation = planFilesOrganisation(org, byKey, claimed);
|
|
62
|
+
if (filesOrganisation)
|
|
63
|
+
plannedSteps.push(filesOrganisation);
|
|
43
64
|
const storage = planStorage(org, byKey, claimed);
|
|
44
65
|
if (storage)
|
|
45
66
|
plannedSteps.push(storage);
|
|
67
|
+
const aiOrganisation = planAiOrganisation(org, byKey, claimed);
|
|
68
|
+
if (aiOrganisation)
|
|
69
|
+
plannedSteps.push(aiOrganisation);
|
|
46
70
|
const aiBudget = planAiBudget(org, byKey, claimed);
|
|
47
71
|
if (aiBudget)
|
|
48
72
|
plannedSteps.push(aiBudget);
|
|
49
73
|
const site = planSite(org, byKey, claimed);
|
|
50
74
|
if (site)
|
|
51
75
|
plannedSteps.push(site);
|
|
76
|
+
// Database before app (hosting-wiring campaign): `plan-apply.ts`'s
|
|
77
|
+
// `runSteps` executes `plannedSteps` in this array order, and the app's own
|
|
78
|
+
// step (`executor.ts`'s `coolifyApp`) pushes the database's connection
|
|
79
|
+
// string into the app's environment once the database exists -- it can
|
|
80
|
+
// only do that if the database step already ran and recorded the
|
|
81
|
+
// database's resource id.
|
|
82
|
+
const database = planDatabase(org, byKey, claimed);
|
|
83
|
+
if (database)
|
|
84
|
+
plannedSteps.push(database);
|
|
85
|
+
const app = planApp(org, byKey, claimed);
|
|
86
|
+
if (app)
|
|
87
|
+
plannedSteps.push(app);
|
|
88
|
+
const tunnel = planTunnel(org, byKey, claimed);
|
|
89
|
+
if (tunnel)
|
|
90
|
+
plannedSteps.push(tunnel);
|
|
91
|
+
const zone = planZone(org, byKey, claimed);
|
|
92
|
+
if (zone)
|
|
93
|
+
plannedSteps.push(zone);
|
|
94
|
+
plannedSteps.push(...planIssuerApps(org, byKey, claimed));
|
|
95
|
+
// After the apps: the customers org is granted the project the first app
|
|
96
|
+
// step creates.
|
|
97
|
+
const customers = planCustomers(org, live, claimed, recordedIds);
|
|
98
|
+
if (customers)
|
|
99
|
+
plannedSteps.push(customers);
|
|
52
100
|
// #10 (resolved): "foundry manages only what the declared org names";
|
|
53
101
|
// anything left unclaimed is listed, never proposed for deletion.
|
|
54
102
|
const unmanaged = live
|
|
@@ -58,13 +106,43 @@ export function computePlanDetail(org, live) {
|
|
|
58
106
|
}));
|
|
59
107
|
return { orgId: org.id, plannedSteps, unmanaged };
|
|
60
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* The org's own ZITADEL org (issuer), a per-org singleton like every other
|
|
111
|
+
* planner in this file, with one extra wrinkle: unlike mail/chat/site/app,
|
|
112
|
+
* there is nothing to "provision" versus "move" from the same live check --
|
|
113
|
+
* a missing live resource means the org has no ZITADEL org at all yet, which
|
|
114
|
+
* `apply` must create before anything else can act inside it (declared:
|
|
115
|
+
* `.claude/decisions-2026-09-24-executor.md`, "Executor decisions" -- "when
|
|
116
|
+
* no ZITADEL org exists, the planner now plans 'provision the org's ZITADEL
|
|
117
|
+
* org' ... the executor creates it named after the slug"). That provision
|
|
118
|
+
* step is never destructive: nothing live is touched. Once a live issuer
|
|
119
|
+
* resource exists, the original move logic applies unchanged -- moving the
|
|
120
|
+
* issuer onto or off the org's domain invalidates every passkey in the org
|
|
121
|
+
* (#7 corrections 2) and is not built in v1 (decisions file, same section:
|
|
122
|
+
* "domain verification and passkey invalidation need their own design"), so
|
|
123
|
+
* `executor.ts` throws `NotBuiltError` on it rather than run it.
|
|
124
|
+
*/
|
|
61
125
|
function planIssuer(org, byKey, claimed) {
|
|
62
126
|
const key = resourceKey('zitadel', 'issuer', org.id);
|
|
63
127
|
const live = byKey.get(key);
|
|
64
|
-
if (live)
|
|
65
|
-
claimed.add(key);
|
|
66
128
|
const desiredUsesDomain = org.issuer.usesDomain;
|
|
67
|
-
|
|
129
|
+
if (!live) {
|
|
130
|
+
return {
|
|
131
|
+
action: {
|
|
132
|
+
system: 'zitadel',
|
|
133
|
+
kind: 'issuer',
|
|
134
|
+
key: org.id,
|
|
135
|
+
desired: { provision: true, usesDomain: desiredUsesDomain },
|
|
136
|
+
},
|
|
137
|
+
step: {
|
|
138
|
+
description: `provision ${org.slug}'s own ZITADEL org`,
|
|
139
|
+
destroys: false,
|
|
140
|
+
migrates: false,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
claimed.add(key);
|
|
145
|
+
const liveUsesDomain = Boolean(live.config.usesDomain);
|
|
68
146
|
if (liveUsesDomain === desiredUsesDomain)
|
|
69
147
|
return null;
|
|
70
148
|
const domain = org.domain ?? 'the org domain';
|
|
@@ -87,6 +165,58 @@ function planIssuer(org, byKey, claimed) {
|
|
|
87
165
|
},
|
|
88
166
|
};
|
|
89
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* The org's owner, invited by ZITADEL email once the org's ZITADEL org
|
|
170
|
+
* exists (goal line: "the org's owner is invited by ZITADEL email and sets a
|
|
171
|
+
* passkey — no password passes through foundry"). A per-org singleton like
|
|
172
|
+
* every other planner here, but idempotent on a different signal: the
|
|
173
|
+
* *recorded* resource id (`recordedIds['zitadel:owner:<orgId>']`, written by
|
|
174
|
+
* `plan-apply.ts`'s `runSteps` from this step's own `resourceId` — the
|
|
175
|
+
* invited user id) rather than a live resource. `ZitadelReader.listResources`
|
|
176
|
+
* (`@wtfalch/providers`' `ZitadelClient.listResources`) reads projects and
|
|
177
|
+
* grants, never a human user, so there is nothing in `live` an invited owner
|
|
178
|
+
* could ever appear as; this step neither claims nor reads from `byKey` for
|
|
179
|
+
* that reason.
|
|
180
|
+
*
|
|
181
|
+
* Ordered right after `planIssuer` in `computePlanDetail` (not after mail,
|
|
182
|
+
* chat, or any hosting step) so that on a first-ever apply — when nothing is
|
|
183
|
+
* live yet — the ZITADEL org this step's executor needs
|
|
184
|
+
* (`context.recordedIds().zitadel_org`) has already been recorded by the
|
|
185
|
+
* issuer step immediately before it in the same run: `plan-apply.ts`'s
|
|
186
|
+
* `runSteps` awaits each planned step in array order, one at a time.
|
|
187
|
+
*
|
|
188
|
+
* `desired` carries the owner's email so the executor can pass it to
|
|
189
|
+
* `ZitadelClient.inviteOrgOwner` — but the step's own `description` never
|
|
190
|
+
* does. `Plan` (the public contract `AuditLog.recordPlan`/`recordApply`
|
|
191
|
+
* store) carries only `PlanStep`, never `StepAction`, so `desired` never
|
|
192
|
+
* reaches the audit log; keeping the email out of `description` too means
|
|
193
|
+
* nothing here puts it anywhere beyond what audit already records for the
|
|
194
|
+
* org.
|
|
195
|
+
*/
|
|
196
|
+
function planOwner(org, recordedIds) {
|
|
197
|
+
if (!org.owner)
|
|
198
|
+
return null;
|
|
199
|
+
const key = resourceKey('zitadel', 'owner', org.id);
|
|
200
|
+
if (recordedIds[key])
|
|
201
|
+
return null; // already invited -- a re-run plans nothing.
|
|
202
|
+
return {
|
|
203
|
+
action: {
|
|
204
|
+
system: 'zitadel',
|
|
205
|
+
kind: 'owner',
|
|
206
|
+
key: org.id,
|
|
207
|
+
desired: {
|
|
208
|
+
email: org.owner.email,
|
|
209
|
+
givenName: org.owner.givenName,
|
|
210
|
+
familyName: org.owner.familyName,
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
step: {
|
|
214
|
+
description: `invite ${org.slug}'s owner by email`,
|
|
215
|
+
destroys: false,
|
|
216
|
+
migrates: false,
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
90
220
|
function planMail(org, byKey, claimed) {
|
|
91
221
|
const key = resourceKey('coolify', 'mail', org.id);
|
|
92
222
|
const live = byKey.get(key);
|
|
@@ -188,6 +318,92 @@ function planChat(org, byKey, claimed) {
|
|
|
188
318
|
},
|
|
189
319
|
};
|
|
190
320
|
}
|
|
321
|
+
/**
|
|
322
|
+
* Deduped `ai.*` permissions offered to every organisation by default
|
|
323
|
+
* (Decision, this campaign's report): the minimum set that lets an org
|
|
324
|
+
* actually run jobs against the platform's own offered models --
|
|
325
|
+
* `ai.offerings:invoke` to invoke an offering, `ai.runs:create` to submit the
|
|
326
|
+
* run itself. `ai/packages/service/src/catalogue.ts`'s five `offered: true`
|
|
327
|
+
* permissions also include the three `ai.connections:*` ones, which let an
|
|
328
|
+
* organisation bring its own provider credentials (BYOK) -- a separate,
|
|
329
|
+
* explicit opt-in this default deliberately leaves out.
|
|
330
|
+
*/
|
|
331
|
+
const AI_DEFAULT_OFFERS = ['ai.offerings:invoke', 'ai.runs:create'];
|
|
332
|
+
/**
|
|
333
|
+
* #80: every org's own organisation on Files, a per-org singleton resource
|
|
334
|
+
* planned before {@link planStorage} -- `setQuota`/`setBudget` 404 against an
|
|
335
|
+
* org Files has never provisioned (`files/packages/sdk/src/client.ts`'s
|
|
336
|
+
* tenant routes need the row `provisionOrganisation` creates). Modeled on
|
|
337
|
+
* {@link planIssuer}: nothing to diff once it exists -- Files' own
|
|
338
|
+
* `provisionOrganisation` is safely repeatable (`executor.ts`'s
|
|
339
|
+
* `filesOrganisation` calls it every time regardless), so the only planned
|
|
340
|
+
* state is "not provisioned yet". The operator-driven `ceilingBytes`/
|
|
341
|
+
* `ceilingMicros` hard cap (`OrganisationSummary`) is never this planner's
|
|
342
|
+
* concern (`org.ts`'s `StorageConfig` doc: "a separate, operator-driven hard
|
|
343
|
+
* cap this field has no opinion on"), so provisioning always asks for an
|
|
344
|
+
* uncapped ceiling -- Decision, this campaign's report.
|
|
345
|
+
*/
|
|
346
|
+
function planFilesOrganisation(org, byKey, claimed) {
|
|
347
|
+
const key = resourceKey('files', 'organisation', org.id);
|
|
348
|
+
const live = byKey.get(key);
|
|
349
|
+
if (live) {
|
|
350
|
+
claimed.add(key);
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
action: {
|
|
355
|
+
system: 'files',
|
|
356
|
+
kind: 'organisation',
|
|
357
|
+
key: org.id,
|
|
358
|
+
desired: { provision: true, ceilingBytes: null, ceilingMicros: null },
|
|
359
|
+
},
|
|
360
|
+
step: {
|
|
361
|
+
description: `provision ${org.slug}'s own organisation on Files`,
|
|
362
|
+
destroys: false,
|
|
363
|
+
migrates: false,
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* #80: every org's own organisation on AI, a per-org singleton resource
|
|
369
|
+
* planned before {@link planAiBudget} -- a provisioned-but-unoffered
|
|
370
|
+
* organisation holds no `ai.*` permission, so running a job is refused
|
|
371
|
+
* (foundry issue #80). Modeled on {@link planIssuer} and
|
|
372
|
+
* {@link planFilesOrganisation}: nothing to diff once it exists -- AI's own
|
|
373
|
+
* `provisionOrganisation` and `setOrganisationOffers` are both safely
|
|
374
|
+
* repeatable (`executor.ts`'s `aiOrganisation` calls both every time
|
|
375
|
+
* regardless), so the only planned state is "not provisioned yet".
|
|
376
|
+
* `AI_DEFAULT_OFFERS` is this campaign's Decision for what "AI use is
|
|
377
|
+
* switched on" means by default; `limitMicros` seeds the organisation's
|
|
378
|
+
* first budget row with whatever the org already declares (including
|
|
379
|
+
* `null` for uncapped), so a brand new org never needs a follow-up
|
|
380
|
+
* {@link planAiBudget} step just to reach its own declared default.
|
|
381
|
+
*/
|
|
382
|
+
function planAiOrganisation(org, byKey, claimed) {
|
|
383
|
+
const key = resourceKey('ai', 'organisation', org.id);
|
|
384
|
+
const live = byKey.get(key);
|
|
385
|
+
if (live) {
|
|
386
|
+
claimed.add(key);
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
action: {
|
|
391
|
+
system: 'ai',
|
|
392
|
+
kind: 'organisation',
|
|
393
|
+
key: org.id,
|
|
394
|
+
desired: {
|
|
395
|
+
provision: true,
|
|
396
|
+
limitMicros: org.ai.limitMicros,
|
|
397
|
+
offers: [...AI_DEFAULT_OFFERS],
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
step: {
|
|
401
|
+
description: `provision ${org.slug}'s own organisation on AI and switch AI use on`,
|
|
402
|
+
destroys: false,
|
|
403
|
+
migrates: false,
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
}
|
|
191
407
|
/**
|
|
192
408
|
* #27: the org's own spend limit on Files, a per-org singleton resource in
|
|
193
409
|
* the same shape as issuer/mail/chat above. Setting a limit is a config
|
|
@@ -217,7 +433,10 @@ function planStorage(org, byKey, claimed) {
|
|
|
217
433
|
}
|
|
218
434
|
/**
|
|
219
435
|
* #27: the org's own spend limit on AI, a per-org singleton resource in the
|
|
220
|
-
* same shape as {@link planStorage}.
|
|
436
|
+
* same shape as {@link planStorage}. Planned after {@link planAiOrganisation}
|
|
437
|
+
* (#80), which already seeds a brand new org's first budget row from this
|
|
438
|
+
* same `org.ai.limitMicros` -- this planner only fires again once that row's
|
|
439
|
+
* live value has drifted from what is declared.
|
|
221
440
|
*/
|
|
222
441
|
function planAiBudget(org, byKey, claimed) {
|
|
223
442
|
const key = resourceKey('ai', 'budget', org.id);
|
|
@@ -300,3 +519,177 @@ function planSite(org, byKey, claimed) {
|
|
|
300
519
|
},
|
|
301
520
|
};
|
|
302
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* The shared on/off shape behind {@link planApp}, {@link planDatabase} and
|
|
524
|
+
* {@link planTunnel} (#71): a per-org singleton resource, keyed by the org
|
|
525
|
+
* id like {@link planMail} and {@link planSite}, with the same
|
|
526
|
+
* park-not-delete rule (#7 corrections 1) -- turning it off never deletes
|
|
527
|
+
* the org's own app, database or tunnel configuration, it parks it.
|
|
528
|
+
*/
|
|
529
|
+
function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark) {
|
|
530
|
+
const key = resourceKey(system, kind, org.id);
|
|
531
|
+
const live = byKey.get(key);
|
|
532
|
+
// A live resource is on unless it says it is parked. Providers' own
|
|
533
|
+
// configs carry no `enabled` field at all, so only a park step's
|
|
534
|
+
// explicit `enabled: false` reads as off.
|
|
535
|
+
const liveEnabled = live !== undefined && live.config.enabled !== false;
|
|
536
|
+
if (!desiredEnabled) {
|
|
537
|
+
// Nothing declared and nothing live: there is nothing to park.
|
|
538
|
+
if (!live)
|
|
539
|
+
return null;
|
|
540
|
+
claimed.add(key);
|
|
541
|
+
// Already parked: no step, or a plan would never reach empty (#14
|
|
542
|
+
// "repeat until plan returns empty") once the park step below ran.
|
|
543
|
+
if (!liveEnabled)
|
|
544
|
+
return null;
|
|
545
|
+
return {
|
|
546
|
+
action: { system, kind, key: org.id, desired: { enabled: false } },
|
|
547
|
+
step: { description: describePark(), destroys: true, migrates: false },
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
if (live)
|
|
551
|
+
claimed.add(key);
|
|
552
|
+
// Live and already enabled: matches declared, no step. Otherwise
|
|
553
|
+
// (nothing live, or live but parked) this provisions/re-provisions it.
|
|
554
|
+
if (liveEnabled)
|
|
555
|
+
return null;
|
|
556
|
+
return {
|
|
557
|
+
action: { system, kind, key: org.id, desired: { enabled: true } },
|
|
558
|
+
step: { description: describeProvision(), destroys: false, migrates: false },
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* The org's own app on Coolify (`org.ts`'s `HostingConfig.enabled`, #71), a
|
|
563
|
+
* per-org singleton resource modeled on {@link planSite}: park, not delete,
|
|
564
|
+
* on the way off (#7 corrections 1).
|
|
565
|
+
*/
|
|
566
|
+
function planApp(org, byKey, claimed) {
|
|
567
|
+
return planHostingToggle('coolify', 'app', org.hosting.enabled, org, byKey, claimed, () => `provision ${org.slug}'s app`, () => `park ${org.slug}'s app (stops serving; app data is retained, not deleted -- #7 corrections 1)`);
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* A Postgres database beside the org's app (`org.ts`'s
|
|
571
|
+
* `HostingConfig.database`, #71) -- `validateHosting` requires
|
|
572
|
+
* `hosting.enabled` before this can be true. Same park-not-delete shape as
|
|
573
|
+
* {@link planApp}.
|
|
574
|
+
*/
|
|
575
|
+
function planDatabase(org, byKey, claimed) {
|
|
576
|
+
return planHostingToggle('coolify', 'database', org.hosting.database, org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* A Cloudflare tunnel reaching the org's app (`org.ts`'s
|
|
580
|
+
* `HostingConfig.tunnel`, #71) -- `validateHosting` requires
|
|
581
|
+
* `hosting.enabled` before this can be true. Same park-not-delete shape as
|
|
582
|
+
* {@link planApp}.
|
|
583
|
+
*/
|
|
584
|
+
function planTunnel(org, byKey, claimed) {
|
|
585
|
+
return planHostingToggle('cloudflare', 'tunnel', org.hosting.tunnel, 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)`);
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* The org's Cloudflare zone for its own domain (`org.ts`'s
|
|
589
|
+
* `HostingConfig.usesDomain`, #71). Unlike every other resource in this
|
|
590
|
+
* file, a zone is keyed by the domain name itself, not the org id -- a
|
|
591
|
+
* Cloudflare zone belongs to a domain, not an org. Provision-only: a plan
|
|
592
|
+
* never parks a zone, since nothing about turning `usesDomain` off says
|
|
593
|
+
* foundry should stop managing a domain the org may still own elsewhere, so
|
|
594
|
+
* a live zone under a not-`usesDomain` org is left unmanaged rather than
|
|
595
|
+
* claimed -- the same choice {@link planMail} makes for a `mode: 'off'` org.
|
|
596
|
+
*/
|
|
597
|
+
function planZone(org, byKey, claimed) {
|
|
598
|
+
if (!org.hosting.usesDomain || !org.domain)
|
|
599
|
+
return null;
|
|
600
|
+
const key = resourceKey('cloudflare', 'zone', org.domain);
|
|
601
|
+
const live = byKey.get(key);
|
|
602
|
+
if (live)
|
|
603
|
+
claimed.add(key);
|
|
604
|
+
if (live)
|
|
605
|
+
return null;
|
|
606
|
+
return {
|
|
607
|
+
action: {
|
|
608
|
+
system: 'cloudflare',
|
|
609
|
+
kind: 'zone',
|
|
610
|
+
key: org.domain,
|
|
611
|
+
desired: { domain: org.domain },
|
|
612
|
+
},
|
|
613
|
+
step: {
|
|
614
|
+
description: `provision a Cloudflare zone for ${org.domain}`,
|
|
615
|
+
destroys: false,
|
|
616
|
+
migrates: false,
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* The org's declared ZITADEL OIDC applications (`org.ts`'s
|
|
622
|
+
* `HostingConfig.issuerApps`, #71), each keyed by its own declared `name`
|
|
623
|
+
* rather than the org id -- an org can declare more than one. A live
|
|
624
|
+
* ZITADEL `app` resource this list does not name by `name` is left
|
|
625
|
+
* unmanaged, the same #10 rule every other resource in this file follows.
|
|
626
|
+
* There is no park for an issuer app -- ZITADEL has no notion of a
|
|
627
|
+
* disabled-but-retained OIDC client the way Coolify has a parked app or
|
|
628
|
+
* database -- so a declared `kind` that differs from the live `appType` is
|
|
629
|
+
* a destructive step (recreate), never a park, and removing an app from
|
|
630
|
+
* `issuerApps` entirely produces no step at all (nothing here deletes it;
|
|
631
|
+
* it simply stops being claimed and shows up as unmanaged next plan).
|
|
632
|
+
*/
|
|
633
|
+
function planIssuerApps(org, byKey, claimed) {
|
|
634
|
+
const steps = [];
|
|
635
|
+
for (const app of org.hosting.issuerApps) {
|
|
636
|
+
const key = resourceKey('zitadel', 'app', app.name);
|
|
637
|
+
const live = byKey.get(key);
|
|
638
|
+
if (live)
|
|
639
|
+
claimed.add(key);
|
|
640
|
+
const desired = { appType: app.kind };
|
|
641
|
+
if (!live) {
|
|
642
|
+
steps.push({
|
|
643
|
+
action: { system: 'zitadel', kind: 'app', key: app.name, desired },
|
|
644
|
+
step: {
|
|
645
|
+
description: `provision the ${app.kind} ZITADEL app '${app.name}' for ${org.slug}`,
|
|
646
|
+
destroys: false,
|
|
647
|
+
migrates: false,
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
if (live.config.appType === app.kind)
|
|
653
|
+
continue;
|
|
654
|
+
steps.push({
|
|
655
|
+
action: { system: 'zitadel', kind: 'app', key: app.name, desired },
|
|
656
|
+
step: {
|
|
657
|
+
description: `recreate ZITADEL app '${app.name}' for ${org.slug} as ${app.kind} (was ${String(live.config.appType)})`,
|
|
658
|
+
destroys: true,
|
|
659
|
+
migrates: false,
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
return steps;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Plan gap A9: an org's customers sign in through a second ZITADEL org with
|
|
667
|
+
* self-registration on, granted the org's app project. Planned once the org
|
|
668
|
+
* declares an issuer app, since customers need an app to sign in to.
|
|
669
|
+
* Idempotent on the recorded customers org id, like `planOwner`. Once
|
|
670
|
+
* recorded, the live project grant to that org is claimed so it is not
|
|
671
|
+
* listed as unmanaged.
|
|
672
|
+
*/
|
|
673
|
+
function planCustomers(org, live, claimed, recordedIds) {
|
|
674
|
+
if (org.hosting.issuerApps.length === 0)
|
|
675
|
+
return null;
|
|
676
|
+
const customersOrgId = recordedIds[resourceKey('zitadel', 'customers', org.id)];
|
|
677
|
+
if (customersOrgId) {
|
|
678
|
+
for (const r of live) {
|
|
679
|
+
if (r.system === 'zitadel' &&
|
|
680
|
+
r.kind === 'project_grant' &&
|
|
681
|
+
r.config.grantedOrgId === customersOrgId) {
|
|
682
|
+
claimed.add(resourceKey(r.system, r.kind, r.key));
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
return {
|
|
688
|
+
action: { system: 'zitadel', kind: 'customers', key: org.id, desired: {} },
|
|
689
|
+
step: {
|
|
690
|
+
description: `provision a self-registration ZITADEL org for ${org.slug}'s customers`,
|
|
691
|
+
destroys: false,
|
|
692
|
+
migrates: false,
|
|
693
|
+
},
|
|
694
|
+
};
|
|
695
|
+
}
|