@wtfalch/foundry 0.16.16 → 0.17.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 (59) hide show
  1. package/dist/apply.d.ts +22 -1
  2. package/dist/bootstrap/bootstrap.d.ts +6 -1
  3. package/dist/bootstrap/bootstrap.js +26 -3
  4. package/dist/bootstrap/declare.d.ts +11 -1
  5. package/dist/bootstrap/declare.js +12 -2
  6. package/dist/index.d.ts +9 -4
  7. package/dist/index.js +7 -0
  8. package/dist/migrations/0038_calls_version_pin.sql +8 -0
  9. package/dist/migrations/0039_destroy_record.sql +7 -0
  10. package/dist/migrations/0040_company_included_uuid.sql +72 -0
  11. package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
  12. package/dist/migrations/0042_mutation_leases.sql +12 -0
  13. package/dist/org.d.ts +54 -0
  14. package/dist/org.js +43 -1
  15. package/dist/plan/adopt.d.ts +76 -0
  16. package/dist/plan/adopt.js +184 -0
  17. package/dist/plan/agora.d.ts +21 -0
  18. package/dist/plan/agora.js +37 -0
  19. package/dist/plan/broker-entries.d.ts +9 -1
  20. package/dist/plan/broker-entries.js +13 -5
  21. package/dist/plan/chat-matrix.d.ts +1 -1
  22. package/dist/plan/chat-matrix.js +11 -3
  23. package/dist/plan/core-files.d.ts +25 -0
  24. package/dist/plan/core-files.js +25 -0
  25. package/dist/plan/diff.d.ts +11 -3
  26. package/dist/plan/diff.js +27 -62
  27. package/dist/plan/entry-ai-key.d.ts +3 -3
  28. package/dist/plan/entry-files-key.d.ts +3 -3
  29. package/dist/plan/executor.d.ts +57 -23
  30. package/dist/plan/executor.js +134 -50
  31. package/dist/plan/index.d.ts +4 -0
  32. package/dist/plan/index.js +2 -0
  33. package/dist/plan/live.d.ts +5 -0
  34. package/dist/plan/login-host.d.ts +1 -1
  35. package/dist/plan/login-host.js +6 -5
  36. package/dist/plan/plan-apply.d.ts +139 -6
  37. package/dist/plan/plan-apply.js +546 -76
  38. package/dist/plan/platform-diff.d.ts +14 -0
  39. package/dist/plan/platform-diff.js +16 -1
  40. package/dist/plan/platform-org.d.ts +16 -0
  41. package/dist/plan/platform-org.js +17 -1
  42. package/dist/plan/platform-providers.d.ts +1 -1
  43. package/dist/plan/platform-providers.js +73 -11
  44. package/dist/plan/providers-readers.d.ts +2 -0
  45. package/dist/plan/providers-readers.js +4 -0
  46. package/dist/plan/rollout.d.ts +52 -0
  47. package/dist/plan/rollout.js +37 -0
  48. package/dist/plan/step-system.d.ts +22 -0
  49. package/dist/plan/step-system.js +37 -0
  50. package/dist/plan.d.ts +3 -0
  51. package/dist/state.d.ts +13 -0
  52. package/dist/store/audit.js +15 -1
  53. package/dist/store/postgres.d.ts +1 -1
  54. package/dist/store/postgres.js +127 -14
  55. package/dist/store/schema.d.ts +134 -5
  56. package/dist/store/schema.js +12 -1
  57. package/dist/store.d.ts +47 -4
  58. package/dist/store.js +9 -1
  59. package/package.json +1 -1
@@ -1,16 +1,36 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
3
3
  import { validateHosting } from '../org.js';
4
4
  import { PLATFORM_SERVICE_KEYS } from '../platform.js';
5
+ import { AlreadyRunningError, StepFailedError } from '../runner/errors.js';
6
+ import { StaleLeaseError, } from '../store.js';
7
+ import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
5
8
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
6
9
  import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
7
10
  import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
8
11
  import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
9
12
  import { readLivePlatformResources, readLiveResources, } from './live.js';
10
13
  import { lockedHostnames } from './locked-hostnames.js';
11
- import { PLATFORM_ARCHON_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_KEY, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
14
+ import { PLATFORM_ARCHON_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_KEY, appStepKey, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
12
15
  import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
13
16
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
17
+ /** #441: the lease scope that serializes every mutation of one company, and the one for the shared platform. */
18
+ const companyScope = (orgId) => `company:${orgId}`;
19
+ const PLATFORM_SCOPE = 'platform';
20
+ /** A lease nobody renews: long enough for one apply, short enough that a dead worker's scope frees itself. The fence stops a slow worker that outlives it. */
21
+ const DEFAULT_LEASE_TTL_MS = 30 * 60 * 1000;
22
+ /** #441: another apply, destroy, adopt or release holds the lease for this company or the platform. Nothing ran; try again when it finishes. */
23
+ export class MutationInProgressError extends Error {
24
+ scope;
25
+ heldUntil;
26
+ /** @param heldUntil when the holder's lease runs out if it is never released. */
27
+ constructor(scope, heldUntil) {
28
+ super(`another change to ${scope} is in progress (lease until ${heldUntil.toISOString()}); try again when it finishes`);
29
+ this.scope = scope;
30
+ this.heldUntil = heldUntil;
31
+ this.name = 'MutationInProgressError';
32
+ }
33
+ }
14
34
  export class DeclaredOrgNotFoundError extends Error {
15
35
  constructor(orgId) {
16
36
  super(`no declared org ${orgId}`);
@@ -99,8 +119,10 @@ export class FoundryPlanApply {
99
119
  hostFiles;
100
120
  hostAi;
101
121
  now;
122
+ leaseTtlMs;
102
123
  appliedVersions;
103
124
  platformDeps;
125
+ runner;
104
126
  constructor(options) {
105
127
  this.store = options.store;
106
128
  this.readers = options.readers;
@@ -116,14 +138,197 @@ export class FoundryPlanApply {
116
138
  this.hostFiles = options.filesOrganisation ?? false;
117
139
  this.hostAi = options.aiOrganisation ?? false;
118
140
  this.now = options.now ?? (() => new Date());
141
+ this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
119
142
  this.appliedVersions = options.appliedVersions;
120
143
  this.platformDeps = options.platform;
144
+ this.runner = options.runner;
121
145
  }
122
146
  async plan(id) {
123
147
  const { plan } = await this.computeFreshPlan(id);
124
148
  await this.audit.recordPlan({ actor: this.actor, orgId: id, plan, at: this.now() });
125
149
  return plan;
126
150
  }
151
+ /**
152
+ * Archon#271: what adopting a live app and database into entry
153
+ * `input.entryKey` would do. A read, like {@link plan}: it reads the project
154
+ * through the reader and changes nothing. A mismatch is reported in the
155
+ * result, with no steps. Needs the apply permission, since it reads a
156
+ * project the caller names.
157
+ */
158
+ async planAdopt(id, input) {
159
+ if (!(await this.authz.canApply(this.actor, id)))
160
+ throw new NotAuthorizedError(this.actor, id);
161
+ const { adoptPlan } = await this.computeAdopt(id, input);
162
+ await this.audit.recordPlan({
163
+ actor: this.actor,
164
+ orgId: id,
165
+ plan: adoptPlan.plan,
166
+ at: this.now(),
167
+ });
168
+ return adoptPlan;
169
+ }
170
+ /**
171
+ * Archon#271: the live apps and databases in `projectUuid` this org has not
172
+ * recorded, for Changes' "Live, not theirs". A read, but it needs the apply
173
+ * permission: it reads a project the caller names.
174
+ */
175
+ async adoptCandidates(id, entryKey, projectUuid) {
176
+ if (!(await this.authz.canApply(this.actor, id)))
177
+ throw new NotAuthorizedError(this.actor, id);
178
+ const org = await this.store.get(id);
179
+ if (!org)
180
+ throw new DeclaredOrgNotFoundError(id);
181
+ const recorded = (await this.store.getAppliedState(id))?.serviceIds ?? {};
182
+ const project = projectUuid ?? recorded.coolify_project;
183
+ const live = project ? ((await this.readers.coolify.listProject?.(project)) ?? []) : [];
184
+ return adoptCandidates(org, entryKey, recorded, live);
185
+ }
186
+ /**
187
+ * Archon#271: records an app and database that already run in Coolify as
188
+ * entry `input.entryKey`'s resources, so the next plan finds them and plans
189
+ * no create. Verifies first and refuses on a mismatch or an entry that is
190
+ * already applied or held by another org. Same permission as {@link apply}.
191
+ * It never calls an executor: nothing in Coolify changes. Nothing redeploys
192
+ * here, but the next normal plan may propose updates to the adopted app
193
+ * that do (see `adopt.ts`); show that plan before applying it. Two
194
+ * concurrent adopts of one resource are not locked against each other.
195
+ */
196
+ async adopt(id, input) {
197
+ const refuse = async (reason) => {
198
+ await this.audit.recordAdoption({
199
+ actor: this.actor,
200
+ orgId: id,
201
+ action: 'adopt',
202
+ entryKey: input.entryKey,
203
+ outcome: 'refused',
204
+ reason,
205
+ at: this.now(),
206
+ });
207
+ };
208
+ if (!(await this.authz.canApply(this.actor, id))) {
209
+ await refuse('not authorized');
210
+ throw new NotAuthorizedError(this.actor, id);
211
+ }
212
+ return this.withMutationLease(companyScope(id), refuse, (fence) => this.withRunnerSerialization(id, refuse, async () => {
213
+ const { adoptPlan, previous } = await this.computeAdopt(id, input);
214
+ const { snapshot } = adoptPlan;
215
+ if (adoptPlan.mismatches.length > 0 || !snapshot) {
216
+ await refuse(adoptPlan.mismatches.join('; '));
217
+ throw new AdoptRefusedError(adoptPlan.mismatches);
218
+ }
219
+ const org = await this.store.get(id);
220
+ if (!org)
221
+ throw new DeclaredOrgNotFoundError(id);
222
+ const serviceIds = {
223
+ ...(previous?.serviceIds ?? {}),
224
+ coolify_project: snapshot.projectUuid,
225
+ [`coolify:app:${appStepKey(org, input.entryKey)}`]: snapshot.application.uuid,
226
+ [`coolify:database:${id}`]: snapshot.database.uuid,
227
+ [adoptedScopeKey(input.entryKey)]: JSON.stringify(snapshot),
228
+ };
229
+ const applied = {
230
+ orgId: id,
231
+ serviceIds,
232
+ checkpoints: previous?.checkpoints ?? [],
233
+ failedFields: previous?.failedFields ?? [],
234
+ lastApplied: previous?.lastApplied ?? null,
235
+ };
236
+ await this.store.putAppliedState(applied, fence);
237
+ await this.audit.recordAdoption({
238
+ actor: this.actor,
239
+ orgId: id,
240
+ action: 'adopt',
241
+ entryKey: input.entryKey,
242
+ outcome: 'adopted',
243
+ at: this.now(),
244
+ });
245
+ return applied;
246
+ }));
247
+ }
248
+ /**
249
+ * Archon#271: the undo of {@link adopt}. Drops the entry's recorded Coolify
250
+ * ids and its `adopted.<entryKey>` snapshot (and `coolify_project` when
251
+ * adopt recorded it and no other Coolify id remains). It leaves the
252
+ * declaration alone: the entry goes back to declared and not applied, so the
253
+ * next plan creates it, exactly as before adopt. The host then adopts it
254
+ * again or removes the entry from the declaration. Deletes and stops nothing
255
+ * in Coolify. Only an adopted entry can be released. Same permission as
256
+ * {@link apply}. Every path writes an audit row.
257
+ */
258
+ async release(id, entryKey) {
259
+ const record = (outcome, reason) => this.audit.recordAdoption({
260
+ actor: this.actor,
261
+ orgId: id,
262
+ action: 'release',
263
+ entryKey,
264
+ outcome,
265
+ ...(reason === undefined ? {} : { reason }),
266
+ at: this.now(),
267
+ });
268
+ if (!(await this.authz.canApply(this.actor, id))) {
269
+ await record('refused', 'not authorized');
270
+ throw new NotAuthorizedError(this.actor, id);
271
+ }
272
+ return this.withMutationLease(companyScope(id), (reason) => record('refused', reason), (fence) => this.withRunnerSerialization(id, (reason) => record('refused', reason), async () => {
273
+ const org = await this.store.get(id);
274
+ if (!org) {
275
+ await record('refused', `no declared org ${id}`);
276
+ throw new DeclaredOrgNotFoundError(id);
277
+ }
278
+ const previous = await this.store.getAppliedState(id);
279
+ const snapshot = parseAdoptSnapshot(previous?.serviceIds[adoptedScopeKey(entryKey)]);
280
+ if (!previous || !snapshot) {
281
+ await record('refused', `entry ${entryKey} was not adopted`);
282
+ throw new AdoptRefusedError([`entry ${entryKey} was not adopted`]);
283
+ }
284
+ const dropped = new Set([
285
+ adoptedScopeKey(entryKey),
286
+ `coolify:app:${appStepKey(org, entryKey)}`,
287
+ `coolify:database:${id}`,
288
+ ]);
289
+ const kept = Object.entries(previous.serviceIds).filter(([k]) => !dropped.has(k));
290
+ const otherCoolify = kept.some(([k]) => k.startsWith('coolify:'));
291
+ const serviceIds = Object.fromEntries(kept.filter(([k]) => !(k === 'coolify_project' && snapshot.projectRecorded && !otherCoolify)));
292
+ const released = { ...previous, serviceIds };
293
+ try {
294
+ await this.store.putAppliedState(released, fence);
295
+ }
296
+ catch (e) {
297
+ if (e instanceof StaleLeaseError)
298
+ throw e;
299
+ await record('refused', e instanceof Error ? e.message : String(e));
300
+ throw e;
301
+ }
302
+ await record('released');
303
+ return released;
304
+ }));
305
+ }
306
+ async computeAdopt(id, input) {
307
+ const org = await this.store.get(id);
308
+ if (!org)
309
+ throw new DeclaredOrgNotFoundError(id);
310
+ const previous = await this.store.getAppliedState(id);
311
+ const recorded = previous?.serviceIds ?? {};
312
+ const project = input.projectUuid ?? recorded.coolify_project;
313
+ const reader = this.readers.coolify;
314
+ const live = project && reader.listProject ? await reader.listProject(project) : null;
315
+ const claimedElsewhere = [];
316
+ for (const [what, uuid] of [
317
+ ['application', input.applicationUuid],
318
+ ['database', input.databaseUuid],
319
+ ['project', project],
320
+ ]) {
321
+ if (!uuid)
322
+ continue;
323
+ const owner = await this.store.findOtherOrgRecordingCoolifyId(uuid, id);
324
+ if (owner)
325
+ claimedElsewhere.push(`${what} ${uuid} is already recorded by org ${owner}`);
326
+ }
327
+ return {
328
+ adoptPlan: planAdoptFrom(org, recorded, input, live, this.now(), claimedElsewhere),
329
+ previous,
330
+ };
331
+ }
127
332
  async apply(plan, confirmedOrgName) {
128
333
  const orgId = plan.orgId;
129
334
  const authorized = await this.authz.canApply(this.actor, orgId);
@@ -131,18 +336,21 @@ export class FoundryPlanApply {
131
336
  await this.refuse(plan, 'not authorized');
132
337
  throw new NotAuthorizedError(this.actor, orgId);
133
338
  }
134
- const { org, plan: freshPlan, detail } = await this.computeFreshPlan(orgId);
135
- if (!plansMatch(plan, freshPlan)) {
136
- await this.refuse(plan, 'stale: live state has moved since this plan was computed');
137
- throw new StalePlanError(orgId);
138
- }
139
- if (planNeedsConfirmation(plan.steps) && confirmedOrgName.trim() !== org.displayName.trim()) {
140
- await this.refuse(plan, "destructive or migrating step without the org's name typed correctly");
141
- throw new ConfirmationRequiredError(orgId);
142
- }
143
- const applied = await this.runSteps(org, plan, detail);
144
- await this.refreshPlatformFilesShare();
145
- return applied;
339
+ return this.withMutationLease(companyScope(orgId), (reason) => this.refuse(plan, reason), (fence) => this.withRunnerSerialization(orgId, (reason) => this.refuse(plan, reason), async () => {
340
+ const { org, plan: freshPlan, detail } = await this.computeFreshPlan(orgId);
341
+ if (!plansMatch(plan, freshPlan)) {
342
+ await this.refuse(plan, 'stale: live state has moved since this plan was computed');
343
+ throw new StalePlanError(orgId);
344
+ }
345
+ if (planNeedsConfirmation(plan.steps) &&
346
+ confirmedOrgName.trim() !== org.displayName.trim()) {
347
+ await this.refuse(plan, "destructive or migrating step without the org's name typed correctly");
348
+ throw new ConfirmationRequiredError(orgId);
349
+ }
350
+ const applied = await this.runSteps(org, plan, detail, fence);
351
+ await this.refreshPlatformFilesShare();
352
+ return applied;
353
+ }));
146
354
  }
147
355
  /**
148
356
  * After an org apply: gives the platform files runner a changed share-domain
@@ -150,9 +358,10 @@ export class FoundryPlanApply {
150
358
  * declared orgs) without a platform pass. Touches only the share hosts and
151
359
  * redeploys the runner; the platform files step itself never runs here.
152
360
  * Skips quietly for an actor who may not apply the platform, and when the
153
- * platform files step was never applied. There is no platform lock, so the
154
- * service ids are re-read just before the write and only the share-domains
155
- * key is recorded. A failure is rethrown naming that the org part succeeded.
361
+ * platform files step was never applied. It holds the platform lease for
362
+ * the refresh and fences its write with it; while another platform change
363
+ * holds the lease it skips (this class has no log), and the next platform
364
+ * apply does the refresh. Only the share-domains key is recorded. A failure is rethrown naming that the org part succeeded.
156
365
  */
157
366
  async refreshPlatformFilesShare() {
158
367
  const deps = this.platformDeps;
@@ -160,6 +369,9 @@ export class FoundryPlanApply {
160
369
  return;
161
370
  if ((await this.authz.canApplyPlatform?.(this.actor)) !== true)
162
371
  return;
372
+ const lease = await this.store.acquireMutationLease(PLATFORM_SCOPE, randomUUID(), this.leaseTtlMs);
373
+ if (!('token' in lease))
374
+ return;
163
375
  try {
164
376
  const platform = await deps.store.get();
165
377
  if (!platform?.files.enabled)
@@ -171,11 +383,14 @@ export class FoundryPlanApply {
171
383
  const scopeIds = await deps.executor.refreshFilesShare(platform, recorded);
172
384
  if (scopeIds === null)
173
385
  return;
174
- await deps.store.putServiceIds({ ...(await deps.store.getServiceIds()), ...scopeIds });
386
+ await deps.store.putServiceIds({ ...(await deps.store.getServiceIds()), ...scopeIds }, lease);
175
387
  }
176
388
  catch (e) {
177
389
  throw new Error(`org applied; platform files share refresh failed: ${e instanceof Error ? e.message : String(e)}`);
178
390
  }
391
+ finally {
392
+ await this.store.releaseMutationLease(lease).catch(() => undefined);
393
+ }
179
394
  }
180
395
  /**
181
396
  * #167: {@link plan}, for the shared platform (#166) rather than an org --
@@ -216,16 +431,18 @@ export class FoundryPlanApply {
216
431
  await this.refusePlatform(plan, declared.ownerOrgId, 'not authorized');
217
432
  throw new NotAuthorizedError(this.actor, plan.orgId);
218
433
  }
219
- const { platform, plan: freshPlan, detail } = await this.computeFreshPlatformPlan();
220
- if (!plansMatch(plan, freshPlan)) {
221
- await this.refusePlatform(plan, platform.ownerOrgId, 'stale: live state has moved since this plan was computed');
222
- throw new PlatformStalePlanError();
223
- }
224
- if (planNeedsConfirmation(plan.steps) && confirmedName.trim() !== 'platform') {
225
- await this.refusePlatform(plan, platform.ownerOrgId, "destructive or migrating step without 'platform' typed correctly");
226
- throw new PlatformConfirmationRequiredError();
227
- }
228
- return this.runPlatformSteps(platform, plan, detail, executor);
434
+ return this.withMutationLease(PLATFORM_SCOPE, (reason) => this.refusePlatform(plan, declared.ownerOrgId, reason), async (fence) => {
435
+ const { platform, plan: freshPlan, detail } = await this.computeFreshPlatformPlan();
436
+ if (!plansMatch(plan, freshPlan)) {
437
+ await this.refusePlatform(plan, platform.ownerOrgId, 'stale: live state has moved since this plan was computed');
438
+ throw new PlatformStalePlanError();
439
+ }
440
+ if (planNeedsConfirmation(plan.steps) && confirmedName.trim() !== 'platform') {
441
+ await this.refusePlatform(plan, platform.ownerOrgId, "destructive or migrating step without 'platform' typed correctly");
442
+ throw new PlatformConfirmationRequiredError();
443
+ }
444
+ return this.runPlatformSteps(platform, plan, detail, executor, fence);
445
+ });
229
446
  }
230
447
  /**
231
448
  * Deletes every resource foundry recorded for `id`, in reverse creation
@@ -238,59 +455,262 @@ export class FoundryPlanApply {
238
455
  * declared org in place with whatever remains recorded in its applied
239
456
  * state, so a retried `destroy` only tears down what's left.
240
457
  */
241
- async destroy(id, confirmedOrgName) {
458
+ async destroy(id, confirmedOrgName, options) {
242
459
  const authorized = await this.authz.canDestroy(this.actor, id);
243
460
  if (!authorized) {
244
461
  await this.refuseDestroy(id, 'not authorized');
245
462
  throw new NotAuthorizedError(this.actor, id);
246
463
  }
464
+ return this.withMutationLease(companyScope(id), (reason) => this.refuseDestroy(id, reason), (fence) => this.withRunnerSerialization(id, (reason) => this.refuseDestroy(id, reason), async () => {
465
+ const org = await this.store.get(id);
466
+ if (!org)
467
+ throw new DeclaredOrgNotFoundError(id);
468
+ if (!org.test) {
469
+ await this.refuseDestroy(id, 'org is not marked test; only a test org can be destroyed');
470
+ throw new NotATestOrgError(id);
471
+ }
472
+ if (confirmedOrgName.trim() !== org.displayName.trim()) {
473
+ await this.refuseDestroy(id, "org's name not typed correctly");
474
+ throw new ConfirmationRequiredError(id);
475
+ }
476
+ const state = await this.store.getAppliedState(id);
477
+ const serviceIds = { ...(state?.serviceIds ?? {}) };
478
+ let targets;
479
+ try {
480
+ targets = destroyTargets(serviceIds);
481
+ }
482
+ catch (e) {
483
+ await this.refuseDestroy(id, e instanceof Error ? e.message : String(e));
484
+ throw e;
485
+ }
486
+ const descriptions = destroyStepDescriptions(targets);
487
+ const steps = descriptions.map((step) => ({
488
+ step,
489
+ status: 'planned',
490
+ }));
491
+ const persist = () => this.store.putAppliedState({
492
+ orgId: id,
493
+ serviceIds,
494
+ checkpoints: state?.checkpoints ?? [],
495
+ failedFields: state?.failedFields ?? [],
496
+ lastApplied: state?.lastApplied ?? null,
497
+ destroy: { steps: steps.map((s) => ({ ...s })) },
498
+ }, fence);
499
+ // A throwing listener must never change the record, the audit row or the error.
500
+ const emit = (index, status, error) => {
501
+ const step = descriptions[index];
502
+ try {
503
+ options?.onProgress?.({
504
+ step,
505
+ index: index + 1,
506
+ total: steps.length,
507
+ status,
508
+ error,
509
+ });
510
+ }
511
+ catch {
512
+ // ignored on purpose
513
+ }
514
+ };
515
+ let current = 0;
516
+ // True once the step's `started` record is saved, i.e. the step is about to run.
517
+ let running = false;
518
+ try {
519
+ for (; current < steps.length; current++) {
520
+ running = false;
521
+ await this.renewLease(fence);
522
+ steps[current] = { step: descriptions[current], status: 'started' };
523
+ await persist();
524
+ running = true;
525
+ emit(current, 'started');
526
+ const target = targets[current];
527
+ if (target) {
528
+ // A provider 404 counts as deleted -- `StepDestroyer.destroy`'s own
529
+ // doc comment -- so nothing here needs to special-case "already
530
+ // gone" itself.
531
+ await this.destroyer.destroy(target, { org, recordedIds: { ...serviceIds } });
532
+ delete serviceIds[destroyServiceIdKey(target)];
533
+ steps[current] = {
534
+ step: descriptions[current],
535
+ status: 'finished',
536
+ completedAt: this.now().toISOString(),
537
+ };
538
+ await persist();
539
+ }
540
+ else {
541
+ // The last step: the org and its applied state (and this record) go together.
542
+ await this.store.delete(id, fence);
543
+ }
544
+ emit(current, 'finished');
545
+ }
546
+ }
547
+ catch (e) {
548
+ // A lost lease is audited once, by `withMutationLease`.
549
+ if (e instanceof StaleLeaseError)
550
+ throw e;
551
+ const reason = e instanceof Error ? e.message : String(e);
552
+ if (running) {
553
+ steps[current] = {
554
+ step: descriptions[current],
555
+ status: 'failed',
556
+ error: reason,
557
+ };
558
+ emit(current, 'failed', reason);
559
+ }
560
+ else {
561
+ // The step never ran (its `started` write failed): leave it planned.
562
+ steps[current] = { step: descriptions[current], status: 'planned' };
563
+ }
564
+ // Best effort: the failure the caller sees is the step's, not this write's.
565
+ await persist().catch(() => undefined);
566
+ await this.audit.recordDestroy({
567
+ actor: this.actor,
568
+ orgId: id,
569
+ outcome: 'failed',
570
+ reason,
571
+ at: this.now(),
572
+ });
573
+ throw e;
574
+ }
575
+ await this.audit.recordDestroy({
576
+ actor: this.actor,
577
+ orgId: id,
578
+ outcome: 'destroyed',
579
+ at: this.now(),
580
+ });
581
+ }));
582
+ }
583
+ /** The steps `destroy` runs for `id`, in order. Refuses a non-test org like `destroy` does; writes no audit row since it changes nothing. */
584
+ async planDestroy(id) {
585
+ if (!(await this.authz.canDestroy(this.actor, id))) {
586
+ throw new NotAuthorizedError(this.actor, id);
587
+ }
247
588
  const org = await this.store.get(id);
248
589
  if (!org)
249
590
  throw new DeclaredOrgNotFoundError(id);
250
- if (!org.test) {
251
- await this.refuseDestroy(id, 'org is not marked test; only a test org can be destroyed');
591
+ if (!org.test)
252
592
  throw new NotATestOrgError(id);
253
- }
254
- if (confirmedOrgName.trim() !== org.displayName.trim()) {
255
- await this.refuseDestroy(id, "org's name not typed correctly");
256
- throw new ConfirmationRequiredError(id);
257
- }
258
593
  const state = await this.store.getAppliedState(id);
259
594
  const serviceIds = { ...(state?.serviceIds ?? {}) };
260
- const targets = destroyTargets(serviceIds);
261
- try {
262
- for (const target of targets) {
263
- // A provider 404 counts as deleted -- `StepDestroyer.destroy`'s own
264
- // doc comment -- so nothing here needs to special-case "already
265
- // gone" itself.
266
- await this.destroyer.destroy(target, { org, recordedIds: { ...serviceIds } });
267
- delete serviceIds[destroyServiceIdKey(target)];
268
- await this.store.putAppliedState({
269
- orgId: id,
270
- serviceIds,
271
- checkpoints: state?.checkpoints ?? [],
272
- failedFields: state?.failedFields ?? [],
273
- lastApplied: state?.lastApplied ?? null,
274
- });
595
+ return {
596
+ steps: destroyStepDescriptions(destroyTargets(serviceIds)),
597
+ kept: keptDescriptions(serviceIds),
598
+ };
599
+ }
600
+ /**
601
+ * #441 (part b): records `fn`'s run in the durable runner, when one is
602
+ * configured, and calls it. Must run with the mutation lease already
603
+ * held -- every one of this method's four callers (`apply`, `destroy`,
604
+ * `adopt`, `release`) calls it from inside its own `withMutationLease`
605
+ * callback, never around it. The reason is `JobRunner.runSteps` itself:
606
+ * it checkpoints a run's one step (the literal id `'mutate'`, the same
607
+ * for all four callers so they share one row per org) by a plain read of
608
+ * the row followed by a later blind write, with nothing to stop two
609
+ * concurrent calls for the same `runId` from both reading "not yet
610
+ * checkpointed" and both calling `fn` -- the runner sequences and
611
+ * checkpoints (its own doc comment), it does not itself exclude a second
612
+ * caller the way the lease's atomic acquire does. Holding the lease first
613
+ * is what makes it safe to reuse an existing, not-yet-succeeded run
614
+ * ({@link JobRunner.currentRun}) rather than refuse it outright: by the
615
+ * time this method runs, this caller is already the lease's sole holder,
616
+ * so any row `currentRun` finds can only be a previous, now-abandoned
617
+ * attempt (the lease's own atomic acquire already refused a second live
618
+ * caller before either of them got here) -- reusing it is the resume #9
619
+ * calls "a human decides when to call it again", not a race.
620
+ *
621
+ * Two *first* attempts for the same org can still race the insert itself
622
+ * when neither has an existing row yet -- `JobRunner.enqueueApply`'s own
623
+ * unique constraint decides it at the database level, a real race rather
624
+ * than one decided by this process's await order, and the loser's
625
+ * {@link AlreadyRunningError} is refused and audited through `refuse`,
626
+ * the same as a lost lease. In practice this needs two lease holders at
627
+ * once to reach it, which the lease itself already prevents; it is this
628
+ * method's defence against a bug in the lease's own acquire path, not
629
+ * the primary lock.
630
+ *
631
+ * `fn`'s result is threaded out through a closure, since
632
+ * {@link RunnerStep.run} returns nothing.
633
+ */
634
+ async withRunnerSerialization(orgId, refuse, fn) {
635
+ if (!this.runner)
636
+ return fn();
637
+ let result;
638
+ const steps = [
639
+ {
640
+ id: 'mutate',
641
+ run: async () => {
642
+ result = await fn();
643
+ },
644
+ },
645
+ ];
646
+ const current = await this.runner.currentRun(orgId);
647
+ let runId;
648
+ if (current) {
649
+ runId = current.id;
650
+ }
651
+ else {
652
+ try {
653
+ ({ runId } = await this.runner.enqueueApply(orgId, steps));
275
654
  }
276
- await this.store.delete(id);
655
+ catch (e) {
656
+ if (e instanceof AlreadyRunningError) {
657
+ await refuse(e.message);
658
+ throw e;
659
+ }
660
+ throw e;
661
+ }
662
+ }
663
+ try {
664
+ await this.runner.runSteps(runId, steps);
277
665
  }
278
666
  catch (e) {
279
- await this.audit.recordDestroy({
280
- actor: this.actor,
281
- orgId: id,
282
- outcome: 'failed',
283
- reason: e instanceof Error ? e.message : String(e),
284
- at: this.now(),
285
- });
667
+ // `StepFailedError` wraps whatever `fn` threw only to say which run
668
+ // and step failed; every caller of `apply`/`destroy`/`adopt`/
669
+ // `release` expects that original error (`StalePlanError`, a
670
+ // provider error, whatever `fn` itself throws), not the runner's
671
+ // wrapper around it. `refuse` already ran inside `fn` (every
672
+ // `withMutationLease` callback audits its own refusal), so this does
673
+ // not call it again.
674
+ if (e instanceof StepFailedError && e.cause !== undefined)
675
+ throw e.cause;
286
676
  throw e;
287
677
  }
288
- await this.audit.recordDestroy({
289
- actor: this.actor,
290
- orgId: id,
291
- outcome: 'destroyed',
292
- at: this.now(),
293
- });
678
+ return result;
679
+ }
680
+ /**
681
+ * #441: runs `fn` while holding the one mutation lease for `scope`, so two
682
+ * `FoundryPlanApply` instances on one store never run provider steps for the
683
+ * same company or platform at once. A second caller is refused with
684
+ * {@link MutationInProgressError}; `fn` gets the lease as the fence its
685
+ * store writes carry, so a worker that lost the lease cannot overwrite the
686
+ * newer holder's state. Both refusals are audited through `refuse`.
687
+ */
688
+ async withMutationLease(scope, refuse, fn) {
689
+ const lease = await this.store.acquireMutationLease(scope, randomUUID(), this.leaseTtlMs);
690
+ if (!('token' in lease)) {
691
+ const held = new MutationInProgressError(scope, lease.heldUntil);
692
+ await refuse(held.message);
693
+ throw held;
694
+ }
695
+ try {
696
+ return await fn(lease);
697
+ }
698
+ catch (e) {
699
+ // The paths inside `fn` rethrow a lost lease without auditing it, so this is its one audit row.
700
+ if (e instanceof StaleLeaseError)
701
+ await refuse('mutation lease lost to a newer holder');
702
+ throw e;
703
+ }
704
+ finally {
705
+ // Best effort: an unreleased lease frees itself when it expires.
706
+ await this.store.releaseMutationLease(lease).catch(() => undefined);
707
+ }
708
+ }
709
+ /** #441: before each provider step, extends the lease; a holder that lost it stops here, before the step runs. */
710
+ async renewLease(fence) {
711
+ if (!(await this.store.renewMutationLease(fence, this.leaseTtlMs))) {
712
+ throw new StaleLeaseError(fence.scope);
713
+ }
294
714
  }
295
715
  async refuse(plan, reason) {
296
716
  await this.audit.recordApply({
@@ -322,7 +742,7 @@ export class FoundryPlanApply {
322
742
  at: this.now(),
323
743
  });
324
744
  }
325
- async runSteps(org, plan, detail) {
745
+ async runSteps(org, plan, detail, fence) {
326
746
  const orgId = plan.orgId;
327
747
  const previous = await this.store.getAppliedState(orgId);
328
748
  const serviceIds = { ...(previous?.serviceIds ?? {}) };
@@ -343,11 +763,13 @@ export class FoundryPlanApply {
343
763
  checkpoints: [...checkpoints],
344
764
  failedFields: previous?.failedFields ?? [],
345
765
  lastApplied: previous?.lastApplied ?? null,
346
- });
766
+ destroy: previous?.destroy,
767
+ }, fence);
347
768
  },
348
769
  };
349
770
  try {
350
771
  for (const planned of detail.plannedSteps) {
772
+ await this.renewLease(fence);
351
773
  const result = await this.executor.execute(planned.action, context);
352
774
  // `system:kind:key`, not just `kind`: two steps can share a `kind`
353
775
  // (an org's several declared ZITADEL apps, #71) and would otherwise
@@ -365,13 +787,20 @@ export class FoundryPlanApply {
365
787
  // #9 (resolved): "it stops at the checkpoint... there is no automatic
366
788
  // rollback." Whatever completed stays recorded; the failure is
367
789
  // rethrown for a human to fix and resume.
368
- await this.store.putAppliedState({
790
+ // A lost lease is audited once, by `withMutationLease`.
791
+ if (e instanceof StaleLeaseError)
792
+ throw e;
793
+ // The failure the caller sees is the step's, not a refused write's.
794
+ await this.store
795
+ .putAppliedState({
369
796
  orgId,
370
797
  serviceIds,
371
798
  checkpoints,
372
799
  failedFields: failedFields(org, detail, checkpoints.length, serviceIds),
373
800
  lastApplied: previous?.lastApplied ?? null,
374
- });
801
+ destroy: previous?.destroy,
802
+ }, fence)
803
+ .catch(() => undefined);
375
804
  await this.audit.recordApply({
376
805
  actor: this.actor,
377
806
  orgId,
@@ -389,8 +818,9 @@ export class FoundryPlanApply {
389
818
  checkpoints,
390
819
  failedFields: failedFields(org, detail, detail.plannedSteps.length, serviceIds),
391
820
  lastApplied,
821
+ destroy: previous?.destroy,
392
822
  };
393
- await this.store.putAppliedState(applied);
823
+ await this.store.putAppliedState(applied, fence);
394
824
  // #253: the single-snapshot write just above replaces whatever
395
825
  // `lastApplied` this org had; this is the point in today's code where a
396
826
  // version is recorded as applied, so it's also where the appended
@@ -588,7 +1018,7 @@ export class FoundryPlanApply {
588
1018
  * `serviceIds`), so a later plan finds a resource by its recorded id even
589
1019
  * when a provider's list has not caught up with a create.
590
1020
  */
591
- async runPlatformSteps(platform, plan, detail, executor) {
1021
+ async runPlatformSteps(platform, plan, detail, executor, fence) {
592
1022
  const { store } = this.requirePlatformDeps();
593
1023
  const serviceIds = { ...(await store.getServiceIds()) };
594
1024
  const context = {
@@ -597,10 +1027,11 @@ export class FoundryPlanApply {
597
1027
  recordScopeId: (key, id) => {
598
1028
  serviceIds[key] = id;
599
1029
  },
600
- persist: () => store.putServiceIds(serviceIds),
1030
+ persist: () => store.putServiceIds(serviceIds, fence),
601
1031
  };
602
1032
  try {
603
1033
  for (const planned of detail.plannedSteps) {
1034
+ await this.renewLease(fence);
604
1035
  const result = await executor.execute(planned.action, context);
605
1036
  if (result?.resourceId) {
606
1037
  const { system, kind, key } = planned.action;
@@ -609,8 +1040,12 @@ export class FoundryPlanApply {
609
1040
  }
610
1041
  }
611
1042
  catch (e) {
612
- // Whatever completed stays recorded, as an org's apply does.
613
- await store.putServiceIds(serviceIds);
1043
+ // Whatever completed stays recorded, as an org's apply does. A lost
1044
+ // lease is audited once, by `withMutationLease`; any other failure
1045
+ // stays the step's, not a refused write's.
1046
+ if (e instanceof StaleLeaseError)
1047
+ throw e;
1048
+ await store.putServiceIds(serviceIds, fence).catch(() => undefined);
614
1049
  await this.audit.recordApply({
615
1050
  actor: this.actor,
616
1051
  orgId: platform.ownerOrgId,
@@ -622,7 +1057,7 @@ export class FoundryPlanApply {
622
1057
  });
623
1058
  throw e;
624
1059
  }
625
- await store.putServiceIds(serviceIds);
1060
+ await store.putServiceIds(serviceIds, fence);
626
1061
  await this.audit.recordApply({
627
1062
  actor: this.actor,
628
1063
  orgId: platform.ownerOrgId,
@@ -694,10 +1129,14 @@ function destroyTargets(serviceIds) {
694
1129
  'files:key:',
695
1130
  ];
696
1131
  const targets = [];
1132
+ const adopted = adoptedResourceIds(serviceIds);
697
1133
  for (const prefix of ORDERED_PREFIXES) {
698
1134
  for (const [resourceKey, id] of Object.entries(serviceIds)) {
699
1135
  if (!resourceKey.startsWith(prefix))
700
1136
  continue;
1137
+ // Archon#271: foundry did not create an adopted app or database, so it never deletes it.
1138
+ if (adopted.has(resourceKey))
1139
+ continue;
701
1140
  if (prefix === 'ai:key:' && !resourceKey.slice(prefix.length).includes(':'))
702
1141
  continue;
703
1142
  if (prefix === 'files:key:' && !resourceKey.slice(prefix.length).includes(':'))
@@ -726,6 +1165,38 @@ function destroyTargets(serviceIds) {
726
1165
  }
727
1166
  return targets;
728
1167
  }
1168
+ /** One step per target, then the declared org's own deletion, which has no target. */
1169
+ /** The `serviceIds` keys of the coolify app and database every `adopted.<entry>` record names. */
1170
+ function adoptedResourceIds(serviceIds) {
1171
+ // Fail closed: an adopted record that cannot be read could name a resource destroy must keep.
1172
+ const unreadable = adoptedKeysUnreadable(serviceIds);
1173
+ if (unreadable.length > 0) {
1174
+ throw new Error(`destroy refused: the adopted record ${unreadable.join(', ')} cannot be read, so foundry cannot tell which resources it must keep`);
1175
+ }
1176
+ const uuids = new Set();
1177
+ for (const [key, raw] of Object.entries(serviceIds)) {
1178
+ if (!key.startsWith('adopted.'))
1179
+ continue;
1180
+ const snapshot = parseAdoptSnapshot(raw);
1181
+ if (snapshot) {
1182
+ uuids.add(snapshot.application.uuid);
1183
+ uuids.add(snapshot.database.uuid);
1184
+ }
1185
+ }
1186
+ return new Set(Object.entries(serviceIds)
1187
+ .filter(([k, id]) => (k.startsWith('coolify:app:') || k.startsWith('coolify:database:')) && uuids.has(id))
1188
+ .map(([k]) => k));
1189
+ }
1190
+ /** Lines for adopted resources `destroy` keeps, so the operator sees them beside the deletes. */
1191
+ function keptDescriptions(serviceIds) {
1192
+ return [...adoptedResourceIds(serviceIds)].map((k) => `keep ${k.split(':')[0]} ${k.split(':')[1]} ${k.split(':').slice(2).join(':')} (adopted, not deleted)`);
1193
+ }
1194
+ function destroyStepDescriptions(targets) {
1195
+ return [
1196
+ ...targets.map((t) => `delete ${t.system} ${t.kind} ${t.key}`),
1197
+ 'delete the declared org',
1198
+ ];
1199
+ }
729
1200
  /** The `AppliedState.serviceIds` key a completed {@link DestroyTarget} should be removed from. */
730
1201
  function destroyServiceIdKey(target) {
731
1202
  if (target.kind === 'project' || target.kind === 'org')
@@ -746,7 +1217,6 @@ const STEP_FIELDS = {
746
1217
  'coolify:chat': 'chat.usesDomain',
747
1218
  'coolify:calls': 'calls.enabled',
748
1219
  'coolify:calls-service': 'calls.enabled',
749
- 'files:storage': 'storage',
750
1220
  'ai:budget': 'ai',
751
1221
  'ai:key': 'ai',
752
1222
  'cms:site': 'site',