@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.
Files changed (66) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +96 -0
  2. package/dist/bootstrap/bootstrap.js +285 -0
  3. package/dist/bootstrap/declare.d.ts +36 -0
  4. package/dist/bootstrap/declare.js +94 -0
  5. package/dist/bootstrap/drive.d.ts +40 -0
  6. package/dist/bootstrap/drive.js +51 -0
  7. package/dist/bootstrap/index.d.ts +3 -0
  8. package/dist/bootstrap/index.js +1 -0
  9. package/dist/calls-sfu-deploy-support.d.ts +9 -6
  10. package/dist/calls-sfu-deploy-support.js +9 -6
  11. package/dist/calls-sfu-keys.d.ts +21 -0
  12. package/dist/calls-sfu-keys.js +27 -0
  13. package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
  14. package/dist/calls-sfu-ssh-adapter.js +78 -0
  15. package/dist/calls-sfu-ssh-client.d.ts +49 -0
  16. package/dist/calls-sfu-ssh-client.js +82 -0
  17. package/dist/calls-sfu.d.ts +5 -4
  18. package/dist/calls-sfu.js +5 -4
  19. package/dist/index.d.ts +31 -6
  20. package/dist/index.js +16 -3
  21. package/dist/migrations/0016_platform.sql +61 -0
  22. package/dist/migrations/0017_requested_changes.sql +42 -0
  23. package/dist/migrations/0018_platform_apply.sql +19 -0
  24. package/dist/org.js +8 -0
  25. package/dist/plan/agora.d.ts +77 -0
  26. package/dist/plan/agora.js +127 -0
  27. package/dist/plan/cloudflared-connector.d.ts +70 -0
  28. package/dist/plan/cloudflared-connector.js +106 -0
  29. package/dist/plan/diff.d.ts +20 -3
  30. package/dist/plan/diff.js +631 -116
  31. package/dist/plan/executor.d.ts +475 -168
  32. package/dist/plan/executor.js +543 -175
  33. package/dist/plan/index.d.ts +22 -5
  34. package/dist/plan/index.js +12 -2
  35. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  36. package/dist/plan/jobs-scheduler-http.js +50 -0
  37. package/dist/plan/live.d.ts +51 -2
  38. package/dist/plan/live.js +8 -0
  39. package/dist/plan/plan-apply.d.ts +192 -3
  40. package/dist/plan/plan-apply.js +278 -9
  41. package/dist/plan/platform-diff.d.ts +312 -0
  42. package/dist/plan/platform-diff.js +867 -0
  43. package/dist/plan/platform-org-ports.d.ts +50 -0
  44. package/dist/plan/platform-org-ports.js +70 -0
  45. package/dist/plan/platform-org.d.ts +120 -0
  46. package/dist/plan/platform-org.js +115 -0
  47. package/dist/plan/platform-providers.d.ts +501 -0
  48. package/dist/plan/platform-providers.js +3029 -0
  49. package/dist/plan/platform-sfu.d.ts +93 -0
  50. package/dist/plan/platform-sfu.js +100 -0
  51. package/dist/plan/providers-readers.js +22 -1
  52. package/dist/plan/zitadel-machine-user.d.ts +34 -0
  53. package/dist/plan/zitadel-machine-user.js +95 -0
  54. package/dist/plan.d.ts +6 -0
  55. package/dist/platform.d.ts +55 -0
  56. package/dist/platform.js +46 -0
  57. package/dist/requested-changes.d.ts +108 -0
  58. package/dist/requested-changes.js +95 -0
  59. package/dist/slug.js +15 -2
  60. package/dist/store/audit.js +4 -2
  61. package/dist/store/postgres.d.ts +29 -1
  62. package/dist/store/postgres.js +172 -2
  63. package/dist/store/schema.d.ts +683 -0
  64. package/dist/store/schema.js +57 -0
  65. package/dist/store.d.ts +46 -0
  66. package/package.json +1 -1
@@ -1,6 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { computePlanDetail, toPlan } from './diff.js';
3
- import { readLiveResources } from './live.js';
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
- const needsConfirmation = plan.steps.some((step) => step.destroys || step.migrates);
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)