@wtfalch/foundry 0.16.15 → 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.
- package/dist/apply.d.ts +22 -1
- package/dist/bootstrap/bootstrap.d.ts +6 -1
- package/dist/bootstrap/bootstrap.js +26 -3
- package/dist/bootstrap/declare.d.ts +11 -1
- package/dist/bootstrap/declare.js +12 -2
- package/dist/index.d.ts +9 -4
- package/dist/index.js +7 -0
- package/dist/migrations/0038_calls_version_pin.sql +8 -0
- package/dist/migrations/0039_destroy_record.sql +7 -0
- package/dist/migrations/0040_company_included_uuid.sql +72 -0
- package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
- package/dist/migrations/0042_mutation_leases.sql +12 -0
- package/dist/org.d.ts +82 -5
- package/dist/org.js +62 -1
- package/dist/plan/adopt.d.ts +76 -0
- package/dist/plan/adopt.js +184 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +37 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +13 -5
- package/dist/plan/chat-matrix.d.ts +1 -1
- package/dist/plan/chat-matrix.js +11 -3
- package/dist/plan/core-files.d.ts +25 -0
- package/dist/plan/core-files.js +25 -0
- package/dist/plan/deploy-health.d.ts +136 -0
- package/dist/plan/deploy-health.js +153 -0
- package/dist/plan/diff.d.ts +11 -3
- package/dist/plan/diff.js +77 -71
- package/dist/plan/entry-ai-key.d.ts +3 -3
- package/dist/plan/entry-files-key.d.ts +6 -4
- package/dist/plan/entry-files-key.js +7 -10
- package/dist/plan/executor.d.ts +109 -28
- package/dist/plan/executor.js +213 -56
- package/dist/plan/hostnames.d.ts +6 -0
- package/dist/plan/hostnames.js +13 -0
- package/dist/plan/index.d.ts +5 -1
- package/dist/plan/index.js +3 -1
- package/dist/plan/live.d.ts +5 -0
- package/dist/plan/login-host.d.ts +1 -1
- package/dist/plan/login-host.js +6 -5
- package/dist/plan/plan-apply.d.ts +153 -3
- package/dist/plan/plan-apply.js +584 -69
- package/dist/plan/platform-diff.d.ts +103 -9
- package/dist/plan/platform-diff.js +192 -14
- package/dist/plan/platform-org.d.ts +25 -0
- package/dist/plan/platform-org.js +17 -1
- package/dist/plan/platform-providers.d.ts +38 -5
- package/dist/plan/platform-providers.js +592 -109
- package/dist/plan/providers-readers.d.ts +2 -0
- package/dist/plan/providers-readers.js +4 -0
- package/dist/plan/rollout.d.ts +52 -0
- package/dist/plan/rollout.js +37 -0
- package/dist/plan/step-system.d.ts +22 -0
- package/dist/plan/step-system.js +37 -0
- package/dist/plan.d.ts +3 -0
- package/dist/state.d.ts +13 -0
- package/dist/store/audit.js +15 -1
- package/dist/store/postgres.d.ts +1 -1
- package/dist/store/postgres.js +127 -14
- package/dist/store/schema.d.ts +134 -5
- package/dist/store/schema.js +12 -1
- package/dist/store.d.ts +47 -4
- package/dist/store.js +9 -1
- package/package.json +2 -2
package/dist/plan/plan-apply.js
CHANGED
|
@@ -1,15 +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, 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';
|
|
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
|
+
}
|
|
13
34
|
export class DeclaredOrgNotFoundError extends Error {
|
|
14
35
|
constructor(orgId) {
|
|
15
36
|
super(`no declared org ${orgId}`);
|
|
@@ -98,8 +119,10 @@ export class FoundryPlanApply {
|
|
|
98
119
|
hostFiles;
|
|
99
120
|
hostAi;
|
|
100
121
|
now;
|
|
122
|
+
leaseTtlMs;
|
|
101
123
|
appliedVersions;
|
|
102
124
|
platformDeps;
|
|
125
|
+
runner;
|
|
103
126
|
constructor(options) {
|
|
104
127
|
this.store = options.store;
|
|
105
128
|
this.readers = options.readers;
|
|
@@ -115,14 +138,197 @@ export class FoundryPlanApply {
|
|
|
115
138
|
this.hostFiles = options.filesOrganisation ?? false;
|
|
116
139
|
this.hostAi = options.aiOrganisation ?? false;
|
|
117
140
|
this.now = options.now ?? (() => new Date());
|
|
141
|
+
this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
|
|
118
142
|
this.appliedVersions = options.appliedVersions;
|
|
119
143
|
this.platformDeps = options.platform;
|
|
144
|
+
this.runner = options.runner;
|
|
120
145
|
}
|
|
121
146
|
async plan(id) {
|
|
122
147
|
const { plan } = await this.computeFreshPlan(id);
|
|
123
148
|
await this.audit.recordPlan({ actor: this.actor, orgId: id, plan, at: this.now() });
|
|
124
149
|
return plan;
|
|
125
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
|
+
}
|
|
126
332
|
async apply(plan, confirmedOrgName) {
|
|
127
333
|
const orgId = plan.orgId;
|
|
128
334
|
const authorized = await this.authz.canApply(this.actor, orgId);
|
|
@@ -130,16 +336,61 @@ export class FoundryPlanApply {
|
|
|
130
336
|
await this.refuse(plan, 'not authorized');
|
|
131
337
|
throw new NotAuthorizedError(this.actor, orgId);
|
|
132
338
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* After an org apply: gives the platform files runner a changed share-domain
|
|
357
|
+
* set (its Coolify `domains` and `FILES_PUBLIC_HOSTS`, derived from the
|
|
358
|
+
* declared orgs) without a platform pass. Touches only the share hosts and
|
|
359
|
+
* redeploys the runner; the platform files step itself never runs here.
|
|
360
|
+
* Skips quietly for an actor who may not apply the platform, and when the
|
|
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.
|
|
365
|
+
*/
|
|
366
|
+
async refreshPlatformFilesShare() {
|
|
367
|
+
const deps = this.platformDeps;
|
|
368
|
+
if (!deps?.executor.refreshFilesShare)
|
|
369
|
+
return;
|
|
370
|
+
if ((await this.authz.canApplyPlatform?.(this.actor)) !== true)
|
|
371
|
+
return;
|
|
372
|
+
const lease = await this.store.acquireMutationLease(PLATFORM_SCOPE, randomUUID(), this.leaseTtlMs);
|
|
373
|
+
if (!('token' in lease))
|
|
374
|
+
return;
|
|
375
|
+
try {
|
|
376
|
+
const platform = await deps.store.get();
|
|
377
|
+
if (!platform?.files.enabled)
|
|
378
|
+
return;
|
|
379
|
+
const recorded = await deps.store.getServiceIds();
|
|
380
|
+
if (!recorded[`coolify:app:${PLATFORM_FILES_KEY}`] ||
|
|
381
|
+
!recorded[PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY])
|
|
382
|
+
return;
|
|
383
|
+
const scopeIds = await deps.executor.refreshFilesShare(platform, recorded);
|
|
384
|
+
if (scopeIds === null)
|
|
385
|
+
return;
|
|
386
|
+
await deps.store.putServiceIds({ ...(await deps.store.getServiceIds()), ...scopeIds }, lease);
|
|
387
|
+
}
|
|
388
|
+
catch (e) {
|
|
389
|
+
throw new Error(`org applied; platform files share refresh failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
137
390
|
}
|
|
138
|
-
|
|
139
|
-
await this.
|
|
140
|
-
throw new ConfirmationRequiredError(orgId);
|
|
391
|
+
finally {
|
|
392
|
+
await this.store.releaseMutationLease(lease).catch(() => undefined);
|
|
141
393
|
}
|
|
142
|
-
return this.runSteps(org, plan, detail);
|
|
143
394
|
}
|
|
144
395
|
/**
|
|
145
396
|
* #167: {@link plan}, for the shared platform (#166) rather than an org --
|
|
@@ -180,16 +431,18 @@ export class FoundryPlanApply {
|
|
|
180
431
|
await this.refusePlatform(plan, declared.ownerOrgId, 'not authorized');
|
|
181
432
|
throw new NotAuthorizedError(this.actor, plan.orgId);
|
|
182
433
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
});
|
|
193
446
|
}
|
|
194
447
|
/**
|
|
195
448
|
* Deletes every resource foundry recorded for `id`, in reverse creation
|
|
@@ -202,59 +455,262 @@ export class FoundryPlanApply {
|
|
|
202
455
|
* declared org in place with whatever remains recorded in its applied
|
|
203
456
|
* state, so a retried `destroy` only tears down what's left.
|
|
204
457
|
*/
|
|
205
|
-
async destroy(id, confirmedOrgName) {
|
|
458
|
+
async destroy(id, confirmedOrgName, options) {
|
|
206
459
|
const authorized = await this.authz.canDestroy(this.actor, id);
|
|
207
460
|
if (!authorized) {
|
|
208
461
|
await this.refuseDestroy(id, 'not authorized');
|
|
209
462
|
throw new NotAuthorizedError(this.actor, id);
|
|
210
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
|
+
}
|
|
211
588
|
const org = await this.store.get(id);
|
|
212
589
|
if (!org)
|
|
213
590
|
throw new DeclaredOrgNotFoundError(id);
|
|
214
|
-
if (!org.test)
|
|
215
|
-
await this.refuseDestroy(id, 'org is not marked test; only a test org can be destroyed');
|
|
591
|
+
if (!org.test)
|
|
216
592
|
throw new NotATestOrgError(id);
|
|
217
|
-
}
|
|
218
|
-
if (confirmedOrgName.trim() !== org.displayName.trim()) {
|
|
219
|
-
await this.refuseDestroy(id, "org's name not typed correctly");
|
|
220
|
-
throw new ConfirmationRequiredError(id);
|
|
221
|
-
}
|
|
222
593
|
const state = await this.store.getAppliedState(id);
|
|
223
594
|
const serviceIds = { ...(state?.serviceIds ?? {}) };
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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));
|
|
654
|
+
}
|
|
655
|
+
catch (e) {
|
|
656
|
+
if (e instanceof AlreadyRunningError) {
|
|
657
|
+
await refuse(e.message);
|
|
658
|
+
throw e;
|
|
659
|
+
}
|
|
660
|
+
throw e;
|
|
239
661
|
}
|
|
240
|
-
|
|
662
|
+
}
|
|
663
|
+
try {
|
|
664
|
+
await this.runner.runSteps(runId, steps);
|
|
241
665
|
}
|
|
242
666
|
catch (e) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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;
|
|
250
676
|
throw e;
|
|
251
677
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
+
}
|
|
258
714
|
}
|
|
259
715
|
async refuse(plan, reason) {
|
|
260
716
|
await this.audit.recordApply({
|
|
@@ -286,7 +742,7 @@ export class FoundryPlanApply {
|
|
|
286
742
|
at: this.now(),
|
|
287
743
|
});
|
|
288
744
|
}
|
|
289
|
-
async runSteps(org, plan, detail) {
|
|
745
|
+
async runSteps(org, plan, detail, fence) {
|
|
290
746
|
const orgId = plan.orgId;
|
|
291
747
|
const previous = await this.store.getAppliedState(orgId);
|
|
292
748
|
const serviceIds = { ...(previous?.serviceIds ?? {}) };
|
|
@@ -307,11 +763,13 @@ export class FoundryPlanApply {
|
|
|
307
763
|
checkpoints: [...checkpoints],
|
|
308
764
|
failedFields: previous?.failedFields ?? [],
|
|
309
765
|
lastApplied: previous?.lastApplied ?? null,
|
|
310
|
-
|
|
766
|
+
destroy: previous?.destroy,
|
|
767
|
+
}, fence);
|
|
311
768
|
},
|
|
312
769
|
};
|
|
313
770
|
try {
|
|
314
771
|
for (const planned of detail.plannedSteps) {
|
|
772
|
+
await this.renewLease(fence);
|
|
315
773
|
const result = await this.executor.execute(planned.action, context);
|
|
316
774
|
// `system:kind:key`, not just `kind`: two steps can share a `kind`
|
|
317
775
|
// (an org's several declared ZITADEL apps, #71) and would otherwise
|
|
@@ -329,13 +787,20 @@ export class FoundryPlanApply {
|
|
|
329
787
|
// #9 (resolved): "it stops at the checkpoint... there is no automatic
|
|
330
788
|
// rollback." Whatever completed stays recorded; the failure is
|
|
331
789
|
// rethrown for a human to fix and resume.
|
|
332
|
-
|
|
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({
|
|
333
796
|
orgId,
|
|
334
797
|
serviceIds,
|
|
335
798
|
checkpoints,
|
|
336
799
|
failedFields: failedFields(org, detail, checkpoints.length, serviceIds),
|
|
337
800
|
lastApplied: previous?.lastApplied ?? null,
|
|
338
|
-
|
|
801
|
+
destroy: previous?.destroy,
|
|
802
|
+
}, fence)
|
|
803
|
+
.catch(() => undefined);
|
|
339
804
|
await this.audit.recordApply({
|
|
340
805
|
actor: this.actor,
|
|
341
806
|
orgId,
|
|
@@ -353,8 +818,9 @@ export class FoundryPlanApply {
|
|
|
353
818
|
checkpoints,
|
|
354
819
|
failedFields: failedFields(org, detail, detail.plannedSteps.length, serviceIds),
|
|
355
820
|
lastApplied,
|
|
821
|
+
destroy: previous?.destroy,
|
|
356
822
|
};
|
|
357
|
-
await this.store.putAppliedState(applied);
|
|
823
|
+
await this.store.putAppliedState(applied, fence);
|
|
358
824
|
// #253: the single-snapshot write just above replaces whatever
|
|
359
825
|
// `lastApplied` this org had; this is the point in today's code where a
|
|
360
826
|
// version is recorded as applied, so it's also where the appended
|
|
@@ -443,7 +909,16 @@ export class FoundryPlanApply {
|
|
|
443
909
|
refs.add(ORG_PLATFORM_KEY_REFS.ai);
|
|
444
910
|
if (this.jobsScheduler)
|
|
445
911
|
refs.add(ORG_PLATFORM_KEY_REFS.jobs);
|
|
446
|
-
|
|
912
|
+
const readiness = orgPlatformReadinessFrom(platform, recorded, refs, !heldKeys);
|
|
913
|
+
// Entry env from the platform's mail key or a shared secret (#T8).
|
|
914
|
+
let mailKeyHeld = false;
|
|
915
|
+
try {
|
|
916
|
+
mailKeyHeld = Boolean(await heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF));
|
|
917
|
+
}
|
|
918
|
+
catch {
|
|
919
|
+
// not held
|
|
920
|
+
}
|
|
921
|
+
return { ...readiness, mailKeyHeld, canHoldSecrets: typeof heldKeys?.put === 'function' };
|
|
447
922
|
}
|
|
448
923
|
/**
|
|
449
924
|
* #177: `AgoraHostValues` from the applied platform, the same
|
|
@@ -543,7 +1018,7 @@ export class FoundryPlanApply {
|
|
|
543
1018
|
* `serviceIds`), so a later plan finds a resource by its recorded id even
|
|
544
1019
|
* when a provider's list has not caught up with a create.
|
|
545
1020
|
*/
|
|
546
|
-
async runPlatformSteps(platform, plan, detail, executor) {
|
|
1021
|
+
async runPlatformSteps(platform, plan, detail, executor, fence) {
|
|
547
1022
|
const { store } = this.requirePlatformDeps();
|
|
548
1023
|
const serviceIds = { ...(await store.getServiceIds()) };
|
|
549
1024
|
const context = {
|
|
@@ -552,10 +1027,11 @@ export class FoundryPlanApply {
|
|
|
552
1027
|
recordScopeId: (key, id) => {
|
|
553
1028
|
serviceIds[key] = id;
|
|
554
1029
|
},
|
|
555
|
-
persist: () => store.putServiceIds(serviceIds),
|
|
1030
|
+
persist: () => store.putServiceIds(serviceIds, fence),
|
|
556
1031
|
};
|
|
557
1032
|
try {
|
|
558
1033
|
for (const planned of detail.plannedSteps) {
|
|
1034
|
+
await this.renewLease(fence);
|
|
559
1035
|
const result = await executor.execute(planned.action, context);
|
|
560
1036
|
if (result?.resourceId) {
|
|
561
1037
|
const { system, kind, key } = planned.action;
|
|
@@ -564,8 +1040,12 @@ export class FoundryPlanApply {
|
|
|
564
1040
|
}
|
|
565
1041
|
}
|
|
566
1042
|
catch (e) {
|
|
567
|
-
// Whatever completed stays recorded, as an org's apply does.
|
|
568
|
-
|
|
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);
|
|
569
1049
|
await this.audit.recordApply({
|
|
570
1050
|
actor: this.actor,
|
|
571
1051
|
orgId: platform.ownerOrgId,
|
|
@@ -577,7 +1057,7 @@ export class FoundryPlanApply {
|
|
|
577
1057
|
});
|
|
578
1058
|
throw e;
|
|
579
1059
|
}
|
|
580
|
-
await store.putServiceIds(serviceIds);
|
|
1060
|
+
await store.putServiceIds(serviceIds, fence);
|
|
581
1061
|
await this.audit.recordApply({
|
|
582
1062
|
actor: this.actor,
|
|
583
1063
|
orgId: platform.ownerOrgId,
|
|
@@ -649,10 +1129,14 @@ function destroyTargets(serviceIds) {
|
|
|
649
1129
|
'files:key:',
|
|
650
1130
|
];
|
|
651
1131
|
const targets = [];
|
|
1132
|
+
const adopted = adoptedResourceIds(serviceIds);
|
|
652
1133
|
for (const prefix of ORDERED_PREFIXES) {
|
|
653
1134
|
for (const [resourceKey, id] of Object.entries(serviceIds)) {
|
|
654
1135
|
if (!resourceKey.startsWith(prefix))
|
|
655
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;
|
|
656
1140
|
if (prefix === 'ai:key:' && !resourceKey.slice(prefix.length).includes(':'))
|
|
657
1141
|
continue;
|
|
658
1142
|
if (prefix === 'files:key:' && !resourceKey.slice(prefix.length).includes(':'))
|
|
@@ -681,6 +1165,38 @@ function destroyTargets(serviceIds) {
|
|
|
681
1165
|
}
|
|
682
1166
|
return targets;
|
|
683
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
|
+
}
|
|
684
1200
|
/** The `AppliedState.serviceIds` key a completed {@link DestroyTarget} should be removed from. */
|
|
685
1201
|
function destroyServiceIdKey(target) {
|
|
686
1202
|
if (target.kind === 'project' || target.kind === 'org')
|
|
@@ -701,7 +1217,6 @@ const STEP_FIELDS = {
|
|
|
701
1217
|
'coolify:chat': 'chat.usesDomain',
|
|
702
1218
|
'coolify:calls': 'calls.enabled',
|
|
703
1219
|
'coolify:calls-service': 'calls.enabled',
|
|
704
|
-
'files:storage': 'storage',
|
|
705
1220
|
'ai:budget': 'ai',
|
|
706
1221
|
'ai:key': 'ai',
|
|
707
1222
|
'cms:site': 'site',
|