@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/plan-apply.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
|
|
3
|
+
import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
|
|
4
|
+
import { readLivePlatformResources, readLiveResources, } from './live.js';
|
|
5
|
+
import { planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
|
|
6
|
+
import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
|
|
4
7
|
export class DeclaredOrgNotFoundError extends Error {
|
|
5
8
|
constructor(orgId) {
|
|
6
9
|
super(`no declared org ${orgId}`);
|
|
@@ -34,6 +37,31 @@ export class NotATestOrgError extends Error {
|
|
|
34
37
|
this.name = 'NotATestOrgError';
|
|
35
38
|
}
|
|
36
39
|
}
|
|
40
|
+
/** #167: {@link DeclaredOrgNotFoundError}, for the platform's own singleton record (#166). */
|
|
41
|
+
export class DeclaredPlatformNotFoundError extends Error {
|
|
42
|
+
constructor() {
|
|
43
|
+
super('no declared platform (#166); declare one before planning or applying it');
|
|
44
|
+
this.name = 'DeclaredPlatformNotFoundError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** #167: {@link StalePlanError}, for `planPlatform`/`applyPlatform`. */
|
|
48
|
+
export class PlatformStalePlanError extends Error {
|
|
49
|
+
constructor() {
|
|
50
|
+
super('the platform plan no longer matches live state; recompute planPlatform() and try again (#9, #10, #167)');
|
|
51
|
+
this.name = 'PlatformStalePlanError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* #167: {@link ConfirmationRequiredError}, for `applyPlatform` -- typed as
|
|
56
|
+
* the literal word `platform` rather than a declared name, since
|
|
57
|
+
* {@link DeclaredPlatform} has no `displayName` of its own to type (#166).
|
|
58
|
+
*/
|
|
59
|
+
export class PlatformConfirmationRequiredError extends Error {
|
|
60
|
+
constructor() {
|
|
61
|
+
super("the platform plan has a step that destroys or migrates data; type 'platform' exactly to apply it (#9, #167)");
|
|
62
|
+
this.name = 'PlatformConfirmationRequiredError';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
37
65
|
/**
|
|
38
66
|
* The #19 implementation of {@link PlanApply}: computes a plan against live
|
|
39
67
|
* state, and refuses to apply one that has gone stale, one that touches a
|
|
@@ -48,7 +76,12 @@ export class FoundryPlanApply {
|
|
|
48
76
|
audit;
|
|
49
77
|
authz;
|
|
50
78
|
actor;
|
|
79
|
+
agora;
|
|
80
|
+
jobsScheduler;
|
|
81
|
+
hostFiles;
|
|
82
|
+
hostAi;
|
|
51
83
|
now;
|
|
84
|
+
platformDeps;
|
|
52
85
|
constructor(options) {
|
|
53
86
|
this.store = options.store;
|
|
54
87
|
this.readers = options.readers;
|
|
@@ -57,7 +90,12 @@ export class FoundryPlanApply {
|
|
|
57
90
|
this.audit = options.audit;
|
|
58
91
|
this.authz = options.authz;
|
|
59
92
|
this.actor = options.actor;
|
|
93
|
+
this.agora = options.agora;
|
|
94
|
+
this.jobsScheduler = options.jobsScheduler ?? false;
|
|
95
|
+
this.hostFiles = options.filesOrganisation ?? false;
|
|
96
|
+
this.hostAi = options.aiOrganisation ?? false;
|
|
60
97
|
this.now = options.now ?? (() => new Date());
|
|
98
|
+
this.platformDeps = options.platform;
|
|
61
99
|
}
|
|
62
100
|
async plan(id) {
|
|
63
101
|
const { plan } = await this.computeFreshPlan(id);
|
|
@@ -76,13 +114,62 @@ export class FoundryPlanApply {
|
|
|
76
114
|
await this.refuse(plan, 'stale: live state has moved since this plan was computed');
|
|
77
115
|
throw new StalePlanError(orgId);
|
|
78
116
|
}
|
|
79
|
-
|
|
80
|
-
if (needsConfirmation && confirmedOrgName.trim() !== org.displayName.trim()) {
|
|
117
|
+
if (planNeedsConfirmation(plan.steps) && confirmedOrgName.trim() !== org.displayName.trim()) {
|
|
81
118
|
await this.refuse(plan, "destructive or migrating step without the org's name typed correctly");
|
|
82
119
|
throw new ConfirmationRequiredError(orgId);
|
|
83
120
|
}
|
|
84
121
|
return this.runSteps(org, plan, detail);
|
|
85
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* #167: {@link plan}, for the shared platform (#166) rather than an org --
|
|
125
|
+
* there is no id to pass, since `DeclaredPlatform` is one singleton record.
|
|
126
|
+
*/
|
|
127
|
+
async planPlatform() {
|
|
128
|
+
const { plan } = await this.computeFreshPlatformPlan();
|
|
129
|
+
await this.audit.recordPlan({
|
|
130
|
+
actor: this.actor,
|
|
131
|
+
orgId: plan.orgId,
|
|
132
|
+
plan,
|
|
133
|
+
target: 'platform',
|
|
134
|
+
at: this.now(),
|
|
135
|
+
});
|
|
136
|
+
return plan;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* #167: {@link apply}, for the platform. `plan.orgId` is the platform's own
|
|
140
|
+
* `ownerOrgId` (`toPlatformPlan`'s own doc comment), so the same
|
|
141
|
+
* `AuditLog`/`AuthzGate` seams `apply` uses need no platform-specific
|
|
142
|
+
* shape of their own. `confirmedName` must be the literal word `platform`
|
|
143
|
+
* rather than a declared name -- `DeclaredPlatform` has none.
|
|
144
|
+
*/
|
|
145
|
+
async applyPlatform(plan, confirmedName) {
|
|
146
|
+
const { store, executor } = this.requirePlatformDeps();
|
|
147
|
+
const declared = await store.get();
|
|
148
|
+
if (!declared)
|
|
149
|
+
throw new DeclaredPlatformNotFoundError();
|
|
150
|
+
// A plan for any other org id is forged or misrouted; refuse it before
|
|
151
|
+
// the authz gate is asked about an org this platform does not belong to.
|
|
152
|
+
if (plan.orgId !== declared.ownerOrgId) {
|
|
153
|
+
await this.refusePlatform(plan, declared.ownerOrgId, 'plan is not the platform’s own');
|
|
154
|
+
throw new PlatformStalePlanError();
|
|
155
|
+
}
|
|
156
|
+
// A gate that cannot answer for the platform refuses it (secure default).
|
|
157
|
+
const authorized = (await this.authz.canApplyPlatform?.(this.actor)) === true;
|
|
158
|
+
if (!authorized) {
|
|
159
|
+
await this.refusePlatform(plan, declared.ownerOrgId, 'not authorized');
|
|
160
|
+
throw new NotAuthorizedError(this.actor, plan.orgId);
|
|
161
|
+
}
|
|
162
|
+
const { platform, plan: freshPlan, detail } = await this.computeFreshPlatformPlan();
|
|
163
|
+
if (!plansMatch(plan, freshPlan)) {
|
|
164
|
+
await this.refusePlatform(plan, platform.ownerOrgId, 'stale: live state has moved since this plan was computed');
|
|
165
|
+
throw new PlatformStalePlanError();
|
|
166
|
+
}
|
|
167
|
+
if (planNeedsConfirmation(plan.steps) && confirmedName.trim() !== 'platform') {
|
|
168
|
+
await this.refusePlatform(plan, platform.ownerOrgId, "destructive or migrating step without 'platform' typed correctly");
|
|
169
|
+
throw new PlatformConfirmationRequiredError();
|
|
170
|
+
}
|
|
171
|
+
return this.runPlatformSteps(platform, plan, detail, executor);
|
|
172
|
+
}
|
|
86
173
|
/**
|
|
87
174
|
* Deletes every resource foundry recorded for `id`, in reverse creation
|
|
88
175
|
* order, then the declared org and its applied state themselves (goal
|
|
@@ -158,6 +245,17 @@ export class FoundryPlanApply {
|
|
|
158
245
|
at: this.now(),
|
|
159
246
|
});
|
|
160
247
|
}
|
|
248
|
+
async refusePlatform(plan, ownerOrgId, reason) {
|
|
249
|
+
await this.audit.recordApply({
|
|
250
|
+
actor: this.actor,
|
|
251
|
+
orgId: ownerOrgId,
|
|
252
|
+
plan,
|
|
253
|
+
outcome: 'refused',
|
|
254
|
+
reason,
|
|
255
|
+
target: 'platform',
|
|
256
|
+
at: this.now(),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
161
259
|
async refuseDestroy(orgId, reason) {
|
|
162
260
|
await this.audit.recordDestroy({
|
|
163
261
|
actor: this.actor,
|
|
@@ -180,6 +278,15 @@ export class FoundryPlanApply {
|
|
|
180
278
|
recordScopeId: (key, id) => {
|
|
181
279
|
serviceIds[key] = id;
|
|
182
280
|
},
|
|
281
|
+
persistScopeIds: async () => {
|
|
282
|
+
await this.store.putAppliedState({
|
|
283
|
+
orgId,
|
|
284
|
+
serviceIds: { ...serviceIds },
|
|
285
|
+
checkpoints: [...checkpoints],
|
|
286
|
+
failedFields: previous?.failedFields ?? [],
|
|
287
|
+
lastApplied: previous?.lastApplied ?? null,
|
|
288
|
+
});
|
|
289
|
+
},
|
|
183
290
|
};
|
|
184
291
|
try {
|
|
185
292
|
for (const planned of detail.plannedSteps) {
|
|
@@ -204,7 +311,7 @@ export class FoundryPlanApply {
|
|
|
204
311
|
orgId,
|
|
205
312
|
serviceIds,
|
|
206
313
|
checkpoints,
|
|
207
|
-
failedFields: failedFields(org, detail, checkpoints.length),
|
|
314
|
+
failedFields: failedFields(org, detail, checkpoints.length, serviceIds),
|
|
208
315
|
lastApplied: previous?.lastApplied ?? null,
|
|
209
316
|
});
|
|
210
317
|
await this.audit.recordApply({
|
|
@@ -221,7 +328,7 @@ export class FoundryPlanApply {
|
|
|
221
328
|
orgId,
|
|
222
329
|
serviceIds,
|
|
223
330
|
checkpoints,
|
|
224
|
-
failedFields: failedFields(org, detail, detail.plannedSteps.length),
|
|
331
|
+
failedFields: failedFields(org, detail, detail.plannedSteps.length, serviceIds),
|
|
225
332
|
lastApplied: { at: this.now().toISOString(), declaredOrgVersion: hashOrg(org) },
|
|
226
333
|
};
|
|
227
334
|
await this.store.putAppliedState(applied);
|
|
@@ -238,13 +345,164 @@ export class FoundryPlanApply {
|
|
|
238
345
|
const org = await this.store.get(id);
|
|
239
346
|
if (!org)
|
|
240
347
|
throw new DeclaredOrgNotFoundError(id);
|
|
241
|
-
const [live, applied] = await Promise.all([
|
|
348
|
+
const [live, applied, platformReadiness, agoraHost] = await Promise.all([
|
|
242
349
|
readLiveResources(id, this.readers),
|
|
243
350
|
this.store.getAppliedState(id),
|
|
351
|
+
this.orgPlatformReadiness(),
|
|
352
|
+
this.orgAgoraHost(id),
|
|
244
353
|
]);
|
|
245
|
-
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {});
|
|
354
|
+
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness);
|
|
246
355
|
return { org, plan: toPlan(detail), detail };
|
|
247
356
|
}
|
|
357
|
+
/**
|
|
358
|
+
* #177: an org's plan reads the shared platform's own addresses and
|
|
359
|
+
* readiness through the very `platform` deps `planPlatform`/`applyPlatform`
|
|
360
|
+
* already use. With no platform wired or declared, {@link
|
|
361
|
+
* DEFAULT_ORG_PLATFORM_READINESS} reports every service not ready, so every
|
|
362
|
+
* org step that needs one is blocked.
|
|
363
|
+
*/
|
|
364
|
+
async orgPlatformReadiness() {
|
|
365
|
+
if (!this.platformDeps)
|
|
366
|
+
return DEFAULT_ORG_PLATFORM_READINESS;
|
|
367
|
+
const platform = await this.platformDeps.store.get();
|
|
368
|
+
if (!platform)
|
|
369
|
+
return DEFAULT_ORG_PLATFORM_READINESS;
|
|
370
|
+
const recorded = await this.platformDeps.store.getServiceIds();
|
|
371
|
+
const heldKeys = this.platformDeps.heldKeys;
|
|
372
|
+
// A service the host supplies its own port for needs no platform key.
|
|
373
|
+
const refs = new Set(heldKeys ? await heldPlatformKeyRefs(heldKeys) : []);
|
|
374
|
+
if (this.hostFiles)
|
|
375
|
+
refs.add(ORG_PLATFORM_KEY_REFS.files);
|
|
376
|
+
if (this.hostAi)
|
|
377
|
+
refs.add(ORG_PLATFORM_KEY_REFS.ai);
|
|
378
|
+
if (this.jobsScheduler)
|
|
379
|
+
refs.add(ORG_PLATFORM_KEY_REFS.jobs);
|
|
380
|
+
return orgPlatformReadinessFrom(platform, recorded, refs, !heldKeys);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* #177: `AgoraHostValues` from the applied platform, the same
|
|
384
|
+
* `platformDeps` {@link orgPlatformReadiness} reads -- but only when the
|
|
385
|
+
* host supplies no `agora` port of its own. An explicit `agora` (#164)
|
|
386
|
+
* always wins, unchanged, so a host that already has its own values
|
|
387
|
+
* (perhaps not yet wired to this platform) keeps them.
|
|
388
|
+
*/
|
|
389
|
+
async orgAgoraHost(id) {
|
|
390
|
+
if (this.agora)
|
|
391
|
+
return readAgoraHost(this.agora, id);
|
|
392
|
+
if (!this.platformDeps)
|
|
393
|
+
return NO_AGORA_HOST_VALUES;
|
|
394
|
+
const platform = await this.platformDeps.store.get();
|
|
395
|
+
if (!platform)
|
|
396
|
+
return NO_AGORA_HOST_VALUES;
|
|
397
|
+
const recorded = await this.platformDeps.store.getServiceIds();
|
|
398
|
+
return agoraHostValuesFromPlatform(platform, recorded);
|
|
399
|
+
}
|
|
400
|
+
/** #167: `FoundryPlanApplyOptions.platform`, or a plain `Error` naming what's missing. */
|
|
401
|
+
requirePlatformDeps() {
|
|
402
|
+
if (!this.platformDeps) {
|
|
403
|
+
throw new Error('FoundryPlanApply: planPlatform/applyPlatform need `platform` in FoundryPlanApplyOptions (#167)');
|
|
404
|
+
}
|
|
405
|
+
return this.platformDeps;
|
|
406
|
+
}
|
|
407
|
+
async computeFreshPlatformPlan() {
|
|
408
|
+
const { store, readers, executor } = this.requirePlatformDeps();
|
|
409
|
+
const platform = await store.get();
|
|
410
|
+
if (!platform)
|
|
411
|
+
throw new DeclaredPlatformNotFoundError();
|
|
412
|
+
const [live, serviceIds] = await Promise.all([
|
|
413
|
+
readLivePlatformResources(readers),
|
|
414
|
+
store.getServiceIds(),
|
|
415
|
+
]);
|
|
416
|
+
const sfuBlocked = await this.sfuBlockedReason(platform);
|
|
417
|
+
const detail = computePlatformPlanDetail(platform, live, {
|
|
418
|
+
jobsObservable: readers.coolify !== undefined,
|
|
419
|
+
sfuBlocked,
|
|
420
|
+
recordedIds: serviceIds,
|
|
421
|
+
blocked: (await executor.blockedSteps?.(platform, serviceIds)) ?? {},
|
|
422
|
+
drifted: (await executor.driftedSteps?.(platform, serviceIds)) ?? {},
|
|
423
|
+
});
|
|
424
|
+
return { platform, plan: toPlatformPlan(detail, platform.ownerOrgId), detail };
|
|
425
|
+
}
|
|
426
|
+
async sfuBlockedReason(platform) {
|
|
427
|
+
if (!platform.sfu.enabled)
|
|
428
|
+
return null;
|
|
429
|
+
const port = this.requirePlatformDeps().sfuConnection;
|
|
430
|
+
if (!port) {
|
|
431
|
+
return 'the platform SFU is blocked, not planned: the host supplies no SFU connection port (#170)';
|
|
432
|
+
}
|
|
433
|
+
let held = null;
|
|
434
|
+
try {
|
|
435
|
+
held = await port.resolveConnection();
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
held = null;
|
|
439
|
+
}
|
|
440
|
+
if (!held) {
|
|
441
|
+
return 'the platform SFU is blocked, not planned: no SSH key and host key fingerprint are held for the SFU VM yet (#170 "Human steps")';
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* {@link runSteps}, for the platform. The ids the steps return are recorded
|
|
447
|
+
* in `PlatformStore`'s applied state (`system:kind:key`, like an org's
|
|
448
|
+
* `serviceIds`), so a later plan finds a resource by its recorded id even
|
|
449
|
+
* when a provider's list has not caught up with a create.
|
|
450
|
+
*/
|
|
451
|
+
async runPlatformSteps(platform, plan, detail, executor) {
|
|
452
|
+
const { store } = this.requirePlatformDeps();
|
|
453
|
+
const serviceIds = { ...(await store.getServiceIds()) };
|
|
454
|
+
const context = {
|
|
455
|
+
platform,
|
|
456
|
+
recordedIds: () => ({ ...serviceIds }),
|
|
457
|
+
recordScopeId: (key, id) => {
|
|
458
|
+
serviceIds[key] = id;
|
|
459
|
+
},
|
|
460
|
+
persist: () => store.putServiceIds(serviceIds),
|
|
461
|
+
};
|
|
462
|
+
try {
|
|
463
|
+
for (const planned of detail.plannedSteps) {
|
|
464
|
+
const result = await executor.execute(planned.action, context);
|
|
465
|
+
if (result?.resourceId) {
|
|
466
|
+
const { system, kind, key } = planned.action;
|
|
467
|
+
serviceIds[`${system}:${kind}:${key}`] = result.resourceId;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
catch (e) {
|
|
472
|
+
// Whatever completed stays recorded, as an org's apply does.
|
|
473
|
+
await store.putServiceIds(serviceIds);
|
|
474
|
+
await this.audit.recordApply({
|
|
475
|
+
actor: this.actor,
|
|
476
|
+
orgId: platform.ownerOrgId,
|
|
477
|
+
plan,
|
|
478
|
+
outcome: 'failed',
|
|
479
|
+
reason: e instanceof Error ? e.message : String(e),
|
|
480
|
+
target: 'platform',
|
|
481
|
+
at: this.now(),
|
|
482
|
+
});
|
|
483
|
+
throw e;
|
|
484
|
+
}
|
|
485
|
+
await store.putServiceIds(serviceIds);
|
|
486
|
+
await this.audit.recordApply({
|
|
487
|
+
actor: this.actor,
|
|
488
|
+
orgId: platform.ownerOrgId,
|
|
489
|
+
plan,
|
|
490
|
+
outcome: 'applied',
|
|
491
|
+
target: 'platform',
|
|
492
|
+
at: this.now(),
|
|
493
|
+
});
|
|
494
|
+
return { serviceIds };
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* #9 (resolved): whether any step in a plan needs its name typed before
|
|
499
|
+
* `apply` runs it. Shared by `apply` (the org's `displayName`) and
|
|
500
|
+
* `applyPlatform` (the literal word `platform`, #167) so the platform gets
|
|
501
|
+
* the identical gate an org already has, proven end-to-end above, rather
|
|
502
|
+
* than a second copy that could drift from it.
|
|
503
|
+
*/
|
|
504
|
+
export function planNeedsConfirmation(steps) {
|
|
505
|
+
return steps.some((step) => step.destroys || step.migrates);
|
|
248
506
|
}
|
|
249
507
|
/** `AppliedState.serviceIds`' two flat ZITADEL scope keys (`executor.ts`'s `ensureZitadelProject`/`zitadelIssuer`) -- no `:`, unlike a resource key. */
|
|
250
508
|
const ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_project';
|
|
@@ -337,6 +595,7 @@ const STEP_FIELDS = {
|
|
|
337
595
|
'coolify:calls-service': 'calls.enabled',
|
|
338
596
|
'files:storage': 'storage',
|
|
339
597
|
'ai:budget': 'ai',
|
|
598
|
+
'ai:key': 'ai',
|
|
340
599
|
'cms:site': 'site',
|
|
341
600
|
'coolify:app': 'hosting.enabled',
|
|
342
601
|
'coolify:database': 'hosting.database',
|
|
@@ -369,8 +628,18 @@ function stepField(action) {
|
|
|
369
628
|
* - on a failed apply, the field of every planned step from the one that
|
|
370
629
|
* threw onwards (`completed` is how many steps finished).
|
|
371
630
|
*/
|
|
372
|
-
function failedFields(org, detail, completed) {
|
|
631
|
+
function failedFields(org, detail, completed, serviceIds) {
|
|
373
632
|
const failed = new Set();
|
|
633
|
+
// `planAiKey` plans no step for a key minted but never delivered, so no
|
|
634
|
+
// step is left to fail. Without this, the next apply would record the org
|
|
635
|
+
// as converged with no working key.
|
|
636
|
+
if (serviceIds[AI_KEY_UNDELIVERED_SCOPE_KEY] && !serviceIds[`ai:key:${org.id}`])
|
|
637
|
+
failed.add('ai');
|
|
638
|
+
// The same for a jobs registration whose credentials never reached the host.
|
|
639
|
+
if (serviceIds[JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY] &&
|
|
640
|
+
!serviceIds[`jobs:registration:${org.id}`]) {
|
|
641
|
+
failed.add('jobs.registration');
|
|
642
|
+
}
|
|
374
643
|
if (org.sso.enabled)
|
|
375
644
|
failed.add('sso.enabled');
|
|
376
645
|
if (org.guests.enabled)
|