@wtfalch/foundry 0.12.0 → 0.13.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 +0 -16
- package/dist/index.d.ts +2 -3
- package/dist/index.js +1 -1
- package/dist/migrations/0032_drop_adoptions.sql +16 -0
- package/dist/migrations/0033_declared_org_version.sql +13 -0
- package/dist/org.d.ts +6 -19
- package/dist/org.js +0 -52
- package/dist/plan/agora.d.ts +19 -1
- package/dist/plan/agora.js +28 -1
- package/dist/plan/broker-entries.d.ts +1 -5
- package/dist/plan/cloudflared-connector.d.ts +2 -10
- package/dist/plan/cloudflared-connector.js +16 -24
- package/dist/plan/diff.d.ts +0 -12
- package/dist/plan/diff.js +42 -239
- package/dist/plan/executor.d.ts +5 -93
- package/dist/plan/executor.js +111 -918
- package/dist/plan/plan-apply.d.ts +3 -47
- package/dist/plan/plan-apply.js +9 -125
- package/dist/plan/platform-diff.d.ts +0 -6
- package/dist/plan/platform-diff.js +0 -8
- package/dist/plan/providers-readers.d.ts +1 -8
- package/dist/plan/providers-readers.js +4 -58
- package/dist/plan.d.ts +0 -6
- package/dist/store/postgres.d.ts +1 -6
- package/dist/store/postgres.js +25 -74
- package/dist/store/schema.d.ts +17 -162
- package/dist/store/schema.js +3 -19
- package/dist/store.d.ts +18 -37
- package/package.json +1 -1
- package/dist/plan/adopt.d.ts +0 -112
- package/dist/plan/adopt.js +0 -252
- package/dist/plan/adoption-vault.d.ts +0 -9
- package/dist/plan/adoption-vault.js +0 -1
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PlanApply } from '../apply.js';
|
|
2
2
|
import type { DeclaredOrg, OrgId } from '../org.js';
|
|
3
|
-
import type { Plan, PlanStep
|
|
3
|
+
import type { Plan, PlanStep } from '../plan.js';
|
|
4
4
|
import type { DeclaredPlatform } from '../platform.js';
|
|
5
5
|
import type { AppliedState } from '../state.js';
|
|
6
|
-
import type {
|
|
7
|
-
import type { AdoptionVault } from './adoption-vault.js';
|
|
6
|
+
import type { DeclaredOrgStore, PlatformStore } from '../store.js';
|
|
8
7
|
import { type AgoraHostPort } from './agora.js';
|
|
9
8
|
import { type StepAction } from './diff.js';
|
|
10
9
|
import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
|
|
@@ -148,26 +147,6 @@ export interface StepExecutor {
|
|
|
148
147
|
execute(action: StepAction, context: StepExecutionContext): Promise<{
|
|
149
148
|
resourceId?: string;
|
|
150
149
|
} | undefined>;
|
|
151
|
-
/**
|
|
152
|
-
* #311: what each `coolify:adopt` step will change, keyed by the step's key,
|
|
153
|
-
* and the adoption items that are blocked. Read from the live app on every
|
|
154
|
-
* plan. Optional: an executor without it plans the step with no details.
|
|
155
|
-
*/
|
|
156
|
-
adoptionReport?(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>): Promise<{
|
|
157
|
-
details: Record<string, string[]>;
|
|
158
|
-
blocked: UnmanagedItem[];
|
|
159
|
-
/** Resource keys whose live resource could not be read: their steps are not planned. */
|
|
160
|
-
withheld: string[];
|
|
161
|
-
}>;
|
|
162
|
-
/**
|
|
163
|
-
* #311: puts the adopted apps back as their snapshots recorded them and
|
|
164
|
-
* returns the env names it deleted. Refuses before any write when a
|
|
165
|
-
* snapshot's vault entry is missing. Optional like `adoptionReport`.
|
|
166
|
-
*/
|
|
167
|
-
restoreAdoption?(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, rows: readonly Adoption[]): Promise<{
|
|
168
|
-
envDeleted: string[];
|
|
169
|
-
tokenLeft?: string[];
|
|
170
|
-
}>;
|
|
171
150
|
}
|
|
172
151
|
/**
|
|
173
152
|
* #167: {@link StepExecutionContext}'s platform-scoped counterpart -- what
|
|
@@ -271,10 +250,6 @@ export declare class StalePlanError extends Error {
|
|
|
271
250
|
export declare class ConfirmationRequiredError extends Error {
|
|
272
251
|
constructor(orgId: OrgId);
|
|
273
252
|
}
|
|
274
|
-
/** #311: the first successful apply after the adopting one confirmed the snapshot, so it is no longer offered. */
|
|
275
|
-
export declare class AdoptionConfirmedError extends Error {
|
|
276
|
-
constructor(orgId: OrgId, resourceKeys: readonly string[]);
|
|
277
|
-
}
|
|
278
253
|
export declare class NotAuthorizedError extends Error {
|
|
279
254
|
constructor(actor: AuditActor, orgId: OrgId);
|
|
280
255
|
}
|
|
@@ -336,13 +311,6 @@ export interface FoundryPlanApplyOptions {
|
|
|
336
311
|
*/
|
|
337
312
|
filesOrganisation?: boolean;
|
|
338
313
|
aiOrganisation?: boolean;
|
|
339
|
-
/**
|
|
340
|
-
* #311: where an adoption keeps the env values of the app it takes over, and
|
|
341
|
-
* the rows that hold the rest of the snapshot. Both are needed: with either
|
|
342
|
-
* absent, each adopted resource is reported blocked and planned for nothing.
|
|
343
|
-
*/
|
|
344
|
-
adoptionVault?: AdoptionVault;
|
|
345
|
-
adoptionStore?: AdoptionStore;
|
|
346
314
|
/** Overridable for tests; defaults to `() => new Date()`. */
|
|
347
315
|
now?: () => Date;
|
|
348
316
|
/**
|
|
@@ -393,21 +361,11 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
393
361
|
private readonly broker;
|
|
394
362
|
private readonly hostFiles;
|
|
395
363
|
private readonly hostAi;
|
|
396
|
-
private readonly adoptionVault;
|
|
397
|
-
private readonly adoptionStore;
|
|
398
364
|
private readonly now;
|
|
399
365
|
private readonly platformDeps;
|
|
400
366
|
constructor(options: FoundryPlanApplyOptions);
|
|
401
367
|
plan(id: OrgId): Promise<Plan>;
|
|
402
368
|
apply(plan: Plan, confirmedOrgName: string): Promise<AppliedState>;
|
|
403
|
-
/**
|
|
404
|
-
* #311: puts the org's adopted apps back as their snapshots recorded them.
|
|
405
|
-
* Refused unless `confirmedOrgName` matches the org's `displayName`, and
|
|
406
|
-
* when every snapshot is `confirmed`; a `confirmed` row beside an `adopted`
|
|
407
|
-
* one is left as it is. Returns resource keys and env names, never a value
|
|
408
|
-
* (rule 6).
|
|
409
|
-
*/
|
|
410
|
-
restore(id: OrgId, confirmedOrgName: string): Promise<AdoptionRestored>;
|
|
411
369
|
/**
|
|
412
370
|
* #167: {@link plan}, for the shared platform (#166) rather than an org --
|
|
413
371
|
* there is no id to pass, since `DeclaredPlatform` is one singleton record.
|
|
@@ -440,8 +398,6 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
440
398
|
private refuseDestroy;
|
|
441
399
|
private runSteps;
|
|
442
400
|
private computeFreshPlan;
|
|
443
|
-
/** #311: the `details` of each `coolify:adopt` step and the adoption items that are blocked. */
|
|
444
|
-
private addAdoptionReport;
|
|
445
401
|
/**
|
|
446
402
|
* An entry may not take a name foundry owns (`locked-hostnames.ts`). The
|
|
447
403
|
* store checks the names it knows; this adds the platform's own hostnames,
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -2,7 +2,6 @@ import { createHash } 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 { adoptedConnectorKey, adoptedTunnelKey, declaredAdoptUuids } from './adopt.js';
|
|
6
5
|
import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
|
|
7
6
|
import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
|
|
8
7
|
import { readLivePlatformResources, readLiveResources, } from './live.js';
|
|
@@ -29,13 +28,6 @@ export class ConfirmationRequiredError extends Error {
|
|
|
29
28
|
this.name = 'ConfirmationRequiredError';
|
|
30
29
|
}
|
|
31
30
|
}
|
|
32
|
-
/** #311: the first successful apply after the adopting one confirmed the snapshot, so it is no longer offered. */
|
|
33
|
-
export class AdoptionConfirmedError extends Error {
|
|
34
|
-
constructor(orgId, resourceKeys) {
|
|
35
|
-
super(`the snapshot of org ${orgId} is confirmed (${resourceKeys.join(', ')}); it can no longer be restored`);
|
|
36
|
-
this.name = 'AdoptionConfirmedError';
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
31
|
export class NotAuthorizedError extends Error {
|
|
40
32
|
constructor(actor, orgId) {
|
|
41
33
|
super(`${actor.display} does not hold the permission to apply org ${orgId} (#9, #19)`);
|
|
@@ -76,7 +68,6 @@ export class PlatformConfirmationRequiredError extends Error {
|
|
|
76
68
|
}
|
|
77
69
|
/** The steps that read an entry's hostnames: `computeFreshPlan` drops them for an org whose hosting is invalid. */
|
|
78
70
|
const HOSTNAME_STEPS = new Set([
|
|
79
|
-
'coolify:adopt',
|
|
80
71
|
'coolify:app',
|
|
81
72
|
'coolify:broker-entry',
|
|
82
73
|
'coolify:database',
|
|
@@ -104,8 +95,6 @@ export class FoundryPlanApply {
|
|
|
104
95
|
broker;
|
|
105
96
|
hostFiles;
|
|
106
97
|
hostAi;
|
|
107
|
-
adoptionVault;
|
|
108
|
-
adoptionStore;
|
|
109
98
|
now;
|
|
110
99
|
platformDeps;
|
|
111
100
|
constructor(options) {
|
|
@@ -122,8 +111,6 @@ export class FoundryPlanApply {
|
|
|
122
111
|
this.broker = options.broker ?? false;
|
|
123
112
|
this.hostFiles = options.filesOrganisation ?? false;
|
|
124
113
|
this.hostAi = options.aiOrganisation ?? false;
|
|
125
|
-
this.adoptionVault = options.adoptionVault;
|
|
126
|
-
this.adoptionStore = options.adoptionStore;
|
|
127
114
|
this.now = options.now ?? (() => new Date());
|
|
128
115
|
this.platformDeps = options.platform;
|
|
129
116
|
}
|
|
@@ -150,42 +137,6 @@ export class FoundryPlanApply {
|
|
|
150
137
|
}
|
|
151
138
|
return this.runSteps(org, plan, detail);
|
|
152
139
|
}
|
|
153
|
-
/**
|
|
154
|
-
* #311: puts the org's adopted apps back as their snapshots recorded them.
|
|
155
|
-
* Refused unless `confirmedOrgName` matches the org's `displayName`, and
|
|
156
|
-
* when every snapshot is `confirmed`; a `confirmed` row beside an `adopted`
|
|
157
|
-
* one is left as it is. Returns resource keys and env names, never a value
|
|
158
|
-
* (rule 6).
|
|
159
|
-
*/
|
|
160
|
-
async restore(id, confirmedOrgName) {
|
|
161
|
-
const authorized = await this.authz.canApply(this.actor, id);
|
|
162
|
-
if (!authorized)
|
|
163
|
-
throw new NotAuthorizedError(this.actor, id);
|
|
164
|
-
const org = await this.store.get(id);
|
|
165
|
-
if (!org)
|
|
166
|
-
throw new DeclaredOrgNotFoundError(id);
|
|
167
|
-
if (confirmedOrgName.trim() !== org.displayName.trim())
|
|
168
|
-
throw new ConfirmationRequiredError(id);
|
|
169
|
-
const { adoptionStore, executor } = this;
|
|
170
|
-
if (!this.adoptionVault || !adoptionStore || !executor.restoreAdoption) {
|
|
171
|
-
throw new Error(`restore for org ${id}: no adoption vault is wired`);
|
|
172
|
-
}
|
|
173
|
-
const rows = await adoptionStore.list(id);
|
|
174
|
-
const adopted = rows.filter((row) => row.state === 'adopted');
|
|
175
|
-
const confirmed = rows.filter((row) => row.state === 'confirmed');
|
|
176
|
-
if (adopted.length === 0 && confirmed.length > 0) {
|
|
177
|
-
throw new AdoptionConfirmedError(id, confirmed.map((row) => row.resourceKey));
|
|
178
|
-
}
|
|
179
|
-
const state = await this.store.getAppliedState(id);
|
|
180
|
-
const { envDeleted, tokenLeft } = await executor.restoreAdoption(org, state?.serviceIds ?? {}, adopted);
|
|
181
|
-
for (const row of adopted)
|
|
182
|
-
await adoptionStore.setState(id, row.resourceKey, 'restored');
|
|
183
|
-
return {
|
|
184
|
-
restored: adopted.map((row) => row.resourceKey),
|
|
185
|
-
envDeleted,
|
|
186
|
-
...(tokenLeft?.length ? { tokenLeft } : {}),
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
140
|
/**
|
|
190
141
|
* #167: {@link plan}, for the shared platform (#166) rather than an org --
|
|
191
142
|
* there is no id to pass, since `DeclaredPlatform` is one singleton record.
|
|
@@ -266,25 +217,8 @@ export class FoundryPlanApply {
|
|
|
266
217
|
}
|
|
267
218
|
const state = await this.store.getAppliedState(id);
|
|
268
219
|
const serviceIds = { ...(state?.serviceIds ?? {}) };
|
|
269
|
-
|
|
270
|
-
// `released` and its ids leave `serviceIds` before the first delete, so a
|
|
271
|
-
// retried destroy skips them the same way.
|
|
272
|
-
const adoptedKeys = new Set((await this.adoptionStore?.list(id))?.map((r) => r.resourceKey));
|
|
273
|
-
const targets = destroyTargets(serviceIds, adoptedKeys, declaredAdoptUuids(org));
|
|
220
|
+
const targets = destroyTargets(serviceIds);
|
|
274
221
|
try {
|
|
275
|
-
for (const resourceKey of adoptedKeys) {
|
|
276
|
-
delete serviceIds[resourceKey];
|
|
277
|
-
await this.adoptionStore?.setState(id, resourceKey, 'released');
|
|
278
|
-
}
|
|
279
|
-
if (adoptedKeys.size > 0) {
|
|
280
|
-
await this.store.putAppliedState({
|
|
281
|
-
orgId: id,
|
|
282
|
-
serviceIds,
|
|
283
|
-
checkpoints: state?.checkpoints ?? [],
|
|
284
|
-
failedFields: state?.failedFields ?? [],
|
|
285
|
-
lastApplied: state?.lastApplied ?? null,
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
222
|
for (const target of targets) {
|
|
289
223
|
// A provider 404 counts as deleted -- `StepDestroyer.destroy`'s own
|
|
290
224
|
// doc comment -- so nothing here needs to special-case "already
|
|
@@ -352,7 +286,6 @@ export class FoundryPlanApply {
|
|
|
352
286
|
const orgId = plan.orgId;
|
|
353
287
|
const previous = await this.store.getAppliedState(orgId);
|
|
354
288
|
const serviceIds = { ...(previous?.serviceIds ?? {}) };
|
|
355
|
-
const adoptedBefore = (await this.adoptionStore?.list(orgId))?.filter((row) => row.state === 'adopted');
|
|
356
289
|
const checkpoints = [];
|
|
357
290
|
// Backed by the very `serviceIds` map this method persists once, at the
|
|
358
291
|
// end — see `StepExecutionContext`'s doc comment for why.
|
|
@@ -416,20 +349,6 @@ export class FoundryPlanApply {
|
|
|
416
349
|
lastApplied: { at: this.now().toISOString(), declaredOrgVersion: hashOrg(org) },
|
|
417
350
|
};
|
|
418
351
|
await this.store.putAppliedState(applied);
|
|
419
|
-
// #311: the first successful apply after the adopting one confirms the
|
|
420
|
-
// snapshot. A row taken after the last successful apply was taken by this
|
|
421
|
-
// one, or by one that failed, and is not confirmed yet.
|
|
422
|
-
// Only that same snapshot: one this apply took again is not confirmed by it.
|
|
423
|
-
for (const row of adoptedBefore ?? []) {
|
|
424
|
-
if (previous?.lastApplied && row.takenAt <= previous.lastApplied.at) {
|
|
425
|
-
const now = await this.adoptionStore?.get(orgId, row.resourceKey);
|
|
426
|
-
if (now?.state === 'adopted' &&
|
|
427
|
-
now.takenAt === row.takenAt &&
|
|
428
|
-
now.settings.uuid === row.settings.uuid) {
|
|
429
|
-
await this.adoptionStore?.setState(orgId, row.resourceKey, 'confirmed');
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
352
|
await this.audit.recordApply({
|
|
434
353
|
actor: this.actor,
|
|
435
354
|
orgId,
|
|
@@ -443,22 +362,13 @@ export class FoundryPlanApply {
|
|
|
443
362
|
const org = await this.store.get(id);
|
|
444
363
|
if (!org)
|
|
445
364
|
throw new DeclaredOrgNotFoundError(id);
|
|
446
|
-
const [live, applied, platformReadiness, agoraHost
|
|
365
|
+
const [live, applied, platformReadiness, agoraHost] = await Promise.all([
|
|
447
366
|
readLiveResources(id, this.readers),
|
|
448
367
|
this.store.getAppliedState(id),
|
|
449
368
|
this.orgPlatformReadiness(),
|
|
450
369
|
this.orgAgoraHost(id),
|
|
451
|
-
this.adoptionStore?.list(id) ?? [],
|
|
452
370
|
]);
|
|
453
|
-
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
|
|
454
|
-
loginHostBindings: this.loginHostBindings,
|
|
455
|
-
broker: this.broker,
|
|
456
|
-
adoption: {
|
|
457
|
-
wired: Boolean(this.adoptionVault && this.adoptionStore),
|
|
458
|
-
adopted,
|
|
459
|
-
},
|
|
460
|
-
});
|
|
461
|
-
await this.addAdoptionReport(org, detail, applied?.serviceIds ?? {});
|
|
371
|
+
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, { loginHostBindings: this.loginHostBindings, broker: this.broker });
|
|
462
372
|
const invalid = await this.invalidHosting(org);
|
|
463
373
|
if (invalid) {
|
|
464
374
|
// Rule 2: reported in `unmanaged`, not thrown. Steps that read the
|
|
@@ -471,31 +381,6 @@ export class FoundryPlanApply {
|
|
|
471
381
|
}
|
|
472
382
|
return { org, plan: toPlan(detail), detail };
|
|
473
383
|
}
|
|
474
|
-
/** #311: the `details` of each `coolify:adopt` step and the adoption items that are blocked. */
|
|
475
|
-
async addAdoptionReport(org, detail, recordedIds) {
|
|
476
|
-
if (!this.adoptionVault || !this.adoptionStore || !this.executor.adoptionReport)
|
|
477
|
-
return;
|
|
478
|
-
const report = await this.executor.adoptionReport(org, recordedIds);
|
|
479
|
-
// A resource that cannot be read gets neither its adopt step nor its own (rule 2).
|
|
480
|
-
const withheld = new Set(report.withheld);
|
|
481
|
-
// The tunnel step waits for the adoption of the tunnel and of its connector (#312).
|
|
482
|
-
const tunnelHeld = withheld.has(adoptedTunnelKey(org)) || withheld.has(adoptedConnectorKey(org));
|
|
483
|
-
detail.plannedSteps = detail.plannedSteps.filter(({ action }) => !(action.system === 'coolify' && action.kind === 'adopt' && withheld.has(action.key)) &&
|
|
484
|
-
!(action.system === 'coolify' &&
|
|
485
|
-
(action.kind === 'app' || action.kind === 'database') &&
|
|
486
|
-
withheld.has(`coolify:${action.kind}:${action.key}`)) &&
|
|
487
|
-
!(action.system === 'cloudflare' && action.kind === 'tunnel' && tunnelHeld));
|
|
488
|
-
for (const { action, step } of detail.plannedSteps) {
|
|
489
|
-
const key = action.system === 'coolify' && action.kind === 'adopt'
|
|
490
|
-
? action.key
|
|
491
|
-
: action.system === 'cloudflare' && action.kind === 'tunnel'
|
|
492
|
-
? adoptedTunnelKey(org)
|
|
493
|
-
: undefined;
|
|
494
|
-
if (key !== undefined && report.details[key])
|
|
495
|
-
step.details = report.details[key];
|
|
496
|
-
}
|
|
497
|
-
detail.unmanaged.push(...report.blocked);
|
|
498
|
-
}
|
|
499
384
|
/**
|
|
500
385
|
* An entry may not take a name foundry owns (`locked-hostnames.ts`). The
|
|
501
386
|
* store checks the names it knows; this adds the platform's own hostnames,
|
|
@@ -703,7 +588,7 @@ const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
|
|
|
703
588
|
* belongs to is already left in place -- deleting only the rule and not the
|
|
704
589
|
* zone is a decision left for whoever wires teardown in, not made here.
|
|
705
590
|
*/
|
|
706
|
-
function destroyTargets(serviceIds
|
|
591
|
+
function destroyTargets(serviceIds) {
|
|
707
592
|
// Reverse-teardown order: everything under one of these prefixes is
|
|
708
593
|
// collected in this order, then every ZITADEL app (there can be several,
|
|
709
594
|
// #71), then the two scope ids last.
|
|
@@ -720,9 +605,6 @@ function destroyTargets(serviceIds, adoptedKeys, adoptedUuids) {
|
|
|
720
605
|
for (const [resourceKey, id] of Object.entries(serviceIds)) {
|
|
721
606
|
if (!resourceKey.startsWith(prefix))
|
|
722
607
|
continue;
|
|
723
|
-
// Never delete what was adopted: a row in any state, or a declared uuid (#311).
|
|
724
|
-
if (adoptedKeys.has(resourceKey) || adoptedUuids.has(id))
|
|
725
|
-
continue;
|
|
726
608
|
const [system, kind] = prefix.split(':');
|
|
727
609
|
targets.push({ system, kind, key: resourceKey.slice(prefix.length), id });
|
|
728
610
|
}
|
|
@@ -867,9 +749,11 @@ function canonical(plan) {
|
|
|
867
749
|
}
|
|
868
750
|
/**
|
|
869
751
|
* A structural version of the declared org, recorded on {@link AppliedState}
|
|
870
|
-
* so a later reader can tell whether
|
|
871
|
-
* `
|
|
752
|
+
* so a later reader can tell whether its content changed since this apply.
|
|
753
|
+
* The store's write counter `version` (#351) is left out, so the hash reads
|
|
754
|
+
* the same before and after that column existed.
|
|
872
755
|
*/
|
|
873
756
|
function hashOrg(org) {
|
|
874
|
-
|
|
757
|
+
const { version: _version, ...content } = org;
|
|
758
|
+
return createHash('sha256').update(JSON.stringify(content)).digest('hex');
|
|
875
759
|
}
|
|
@@ -357,12 +357,6 @@ export declare function appSettingsOf(entry: HostingEntry): AppSettings;
|
|
|
357
357
|
/** What an app that predates the marker has: Coolify's own defaults, so it plans no change. */
|
|
358
358
|
export declare const DEFAULT_APP_SETTINGS: AppSettings;
|
|
359
359
|
export declare function appSettingsAppliedScopeKey(entryKey: string): string;
|
|
360
|
-
/**
|
|
361
|
-
* Set before an adopted app's settings are patched, cleared once its deploy
|
|
362
|
-
* succeeded (an empty value is not pending): a failed deploy replans whatever
|
|
363
|
-
* the markers say (#311).
|
|
364
|
-
*/
|
|
365
|
-
export declare function appDeployPendingScopeKey(entryKey: string): string;
|
|
366
360
|
/** The settings the app has: the marker, or the defaults when there is none. */
|
|
367
361
|
export declare function appliedAppSettings(recorded: Readonly<Record<string, string>>, entryKey: string): AppSettings;
|
|
368
362
|
/**
|
|
@@ -391,14 +391,6 @@ export const DEFAULT_APP_SETTINGS = { folder: null, port: null, autoDeploy: true
|
|
|
391
391
|
export function appSettingsAppliedScopeKey(entryKey) {
|
|
392
392
|
return `app_settings_applied.${entryKey}`;
|
|
393
393
|
}
|
|
394
|
-
/**
|
|
395
|
-
* Set before an adopted app's settings are patched, cleared once its deploy
|
|
396
|
-
* succeeded (an empty value is not pending): a failed deploy replans whatever
|
|
397
|
-
* the markers say (#311).
|
|
398
|
-
*/
|
|
399
|
-
export function appDeployPendingScopeKey(entryKey) {
|
|
400
|
-
return `app_deploy_pending.${entryKey}`;
|
|
401
|
-
}
|
|
402
394
|
/** The settings the app has: the marker, or the defaults when there is none. */
|
|
403
395
|
export function appliedAppSettings(recorded, entryKey) {
|
|
404
396
|
const marker = recorded[appSettingsAppliedScopeKey(entryKey)];
|
|
@@ -3,7 +3,6 @@ import type { CoolifyClient } from '@wtfalch/providers/coolify';
|
|
|
3
3
|
import type { ZitadelClient } from '@wtfalch/providers/zitadel';
|
|
4
4
|
import type { OrgId } from '../org.js';
|
|
5
5
|
import type { DeclaredOrgStore } from '../store.js';
|
|
6
|
-
import type { AdoptedResource } from './adopt.js';
|
|
7
6
|
import type { CloudflareReader, CmsReader, CoolifyReader, LiveResource, ZitadelReader } from './live.js';
|
|
8
7
|
/**
|
|
9
8
|
* Real `CoolifyReader`, `CloudflareReader` and `ZitadelReader` implementations
|
|
@@ -93,13 +92,7 @@ export declare class ProvidersCoolifyReader implements CoolifyReader {
|
|
|
93
92
|
private readonly client;
|
|
94
93
|
private readonly resolveScope;
|
|
95
94
|
private readonly resolveRecordedIds;
|
|
96
|
-
|
|
97
|
-
constructor(client: CoolifyClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds, options?: {
|
|
98
|
-
/** #311: what the org adopts by uuid; each is read directly, not found by project. */
|
|
99
|
-
adopted?: (orgId: OrgId) => Promise<AdoptedResource[]>;
|
|
100
|
-
});
|
|
101
|
-
/** #311: each adopted uuid, read by uuid, under the key the planner matches. An unreadable one is absent. */
|
|
102
|
-
private readAdopted;
|
|
95
|
+
constructor(client: CoolifyClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
|
|
103
96
|
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
104
97
|
}
|
|
105
98
|
/**
|
|
@@ -204,73 +204,19 @@ export class ProvidersCoolifyReader {
|
|
|
204
204
|
client;
|
|
205
205
|
resolveScope;
|
|
206
206
|
resolveRecordedIds;
|
|
207
|
-
|
|
208
|
-
constructor(client, resolveScope, resolveRecordedIds, options = {}) {
|
|
207
|
+
constructor(client, resolveScope, resolveRecordedIds) {
|
|
209
208
|
this.client = client;
|
|
210
209
|
this.resolveScope = resolveScope;
|
|
211
210
|
this.resolveRecordedIds = resolveRecordedIds;
|
|
212
|
-
this.options = options;
|
|
213
|
-
}
|
|
214
|
-
/** #311: each adopted uuid, read by uuid, under the key the planner matches. An unreadable one is absent. */
|
|
215
|
-
async readAdopted(adopted) {
|
|
216
|
-
const read = await Promise.all(adopted.map(async (a) => {
|
|
217
|
-
try {
|
|
218
|
-
if (a.kind === 'database') {
|
|
219
|
-
const { name, status } = await this.client.getDatabase(a.uuid);
|
|
220
|
-
return {
|
|
221
|
-
resource: {
|
|
222
|
-
kind: 'database',
|
|
223
|
-
key: a.uuid,
|
|
224
|
-
config: { name, status },
|
|
225
|
-
},
|
|
226
|
-
recorded: `database:${a.resourceKey.slice('coolify:database:'.length)}`,
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
const app = await this.client.getApplication(a.uuid);
|
|
230
|
-
return {
|
|
231
|
-
resource: {
|
|
232
|
-
kind: 'application',
|
|
233
|
-
key: a.uuid,
|
|
234
|
-
config: {
|
|
235
|
-
name: app.name,
|
|
236
|
-
fqdn: app.fqdn,
|
|
237
|
-
status: app.status,
|
|
238
|
-
git_repository: app.git_repository,
|
|
239
|
-
git_branch: app.git_branch,
|
|
240
|
-
is_auto_deploy_enabled: app.is_auto_deploy_enabled,
|
|
241
|
-
environment_id: app.environment_id,
|
|
242
|
-
},
|
|
243
|
-
},
|
|
244
|
-
recorded: `app:${a.resourceKey.slice('coolify:app:'.length)}`,
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
catch {
|
|
248
|
-
return null;
|
|
249
|
-
}
|
|
250
|
-
}));
|
|
251
|
-
return read.filter((r) => r !== null);
|
|
252
211
|
}
|
|
253
212
|
async listResources(orgId) {
|
|
254
213
|
const projectUuid = await this.resolveScope(orgId);
|
|
255
|
-
|
|
256
|
-
const adopted = ((await this.options.adopted?.(orgId)) ?? []).filter((a) => a.kind === 'app' || a.kind === 'database');
|
|
257
|
-
if (!projectUuid && adopted.length === 0)
|
|
214
|
+
if (!projectUuid)
|
|
258
215
|
return [];
|
|
259
|
-
const [
|
|
260
|
-
|
|
216
|
+
const [resources, recorded] = await Promise.all([
|
|
217
|
+
this.client.listResources(projectUuid),
|
|
261
218
|
this.resolveRecordedIds(orgId),
|
|
262
|
-
this.readAdopted(adopted),
|
|
263
219
|
]);
|
|
264
|
-
// The declared uuid wins over a recorded id and over the project listing.
|
|
265
|
-
const uuids = new Set(adoptedLive.map((a) => a.resource.key));
|
|
266
|
-
const resources = [
|
|
267
|
-
...listed.filter((r) => !uuids.has(r.key)),
|
|
268
|
-
...adoptedLive.map((a) => a.resource),
|
|
269
|
-
];
|
|
270
|
-
const recorded = {
|
|
271
|
-
...recordedById,
|
|
272
|
-
...Object.fromEntries(adoptedLive.map((a) => [a.resource.key, a.recorded])),
|
|
273
|
-
};
|
|
274
220
|
const withLogConfig = await Promise.all(toFoundryResources('coolify', resources).map((r) => withLogVolumeConfig(this.client, r, recorded)));
|
|
275
221
|
const withEnvNames = await Promise.all(withLogConfig.map((r) => withEnvNamesConfig(this.client, r, recorded)));
|
|
276
222
|
const withDbUrl = await Promise.all(withEnvNames.map((r) => withDatabaseUrlConfig(this.client, r, recorded)));
|
package/dist/plan.d.ts
CHANGED
|
@@ -9,12 +9,6 @@ import type { OrgId } from './org.js';
|
|
|
9
9
|
*/
|
|
10
10
|
export interface PlanStep {
|
|
11
11
|
description: string;
|
|
12
|
-
/**
|
|
13
|
-
* Lines that say what the step will change, shown beside the description.
|
|
14
|
-
* `coolify:adopt` (#311) gives one per setting, old value beside new, and
|
|
15
|
-
* env vars by name only. Never a secret.
|
|
16
|
-
*/
|
|
17
|
-
details?: string[];
|
|
18
12
|
/**
|
|
19
13
|
* True when this step deletes or replaces data the org cannot get back
|
|
20
14
|
* without foundry's own backup: a chat re-key, an issuer move, a mail
|
package/dist/store/postgres.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
|
|
2
|
-
import type {
|
|
2
|
+
import type { DeclaredOrgStore, PlatformStore, RequestedChangeStore } from '../store.js';
|
|
3
3
|
/**
|
|
4
4
|
* The host's own drizzle handle or a transaction open on it, same shape
|
|
5
5
|
* `@wtfalch/keys`'s `HeldKeysDb` uses: postgres-js in a real host, PGlite in
|
|
@@ -90,8 +90,3 @@ export declare function createPostgresPlatformStore(db: FoundryStoreDb): Platfor
|
|
|
90
90
|
* argument makes -- and stores the result as the request's initial `state`.
|
|
91
91
|
*/
|
|
92
92
|
export declare function createPostgresRequestedChangeStore(db: FoundryStoreDb): RequestedChangeStore;
|
|
93
|
-
/**
|
|
94
|
-
* `AdoptionStore` (`../store.ts`) on Postgres, for #311: the snapshot row of
|
|
95
|
-
* each live resource foundry takes over, beside the stores above.
|
|
96
|
-
*/
|
|
97
|
-
export declare function createPostgresAdoptionStore(db: FoundryStoreDb): AdoptionStore;
|
package/dist/store/postgres.js
CHANGED
|
@@ -4,7 +4,7 @@ import { lockedHostnames } from '../plan/locked-hostnames.js';
|
|
|
4
4
|
import { validatePlatform } from '../platform.js';
|
|
5
5
|
import { RequestedChangeError, classifyChange } from '../requested-changes.js';
|
|
6
6
|
import { validateSlug } from '../slug.js';
|
|
7
|
-
import { declaredOrgs,
|
|
7
|
+
import { declaredOrgs, foundryAppliedState, foundryDeclaredPlatform, foundryRequestedChanges, } from './schema.js';
|
|
8
8
|
const UNIQUE_VIOLATION = '23505';
|
|
9
9
|
/** `migrations/0004_slug_unique.sql` / `migrations/0015_domain_unique.sql`'s index names, read back off a caught unique-violation to tell which one fired. */
|
|
10
10
|
const SLUG_UNIQUE_INDEX = 'foundry_declared_orgs_slug_idx';
|
|
@@ -62,6 +62,7 @@ export class DeclaredOrgError extends Error {
|
|
|
62
62
|
function rowToOrg(row) {
|
|
63
63
|
return {
|
|
64
64
|
id: row.id,
|
|
65
|
+
version: row.version,
|
|
65
66
|
slug: row.slug,
|
|
66
67
|
displayName: row.displayName,
|
|
67
68
|
domain: row.domain,
|
|
@@ -82,7 +83,6 @@ function rowToOrg(row) {
|
|
|
82
83
|
usesDomain: row.hostingUsesDomain,
|
|
83
84
|
tunnel: row.hostingTunnel,
|
|
84
85
|
issuerApps: row.hostingIssuerApps,
|
|
85
|
-
...(row.hostingAdopt ? { adopt: row.hostingAdopt } : {}),
|
|
86
86
|
// Migration 0030's default for an entry stored before `hostnames`.
|
|
87
87
|
entries: row.hostingEntries.map((entry) => ({
|
|
88
88
|
...entry,
|
|
@@ -127,7 +127,6 @@ function orgToRow(org) {
|
|
|
127
127
|
hostingTunnel: org.hosting.tunnel,
|
|
128
128
|
hostingIssuerApps: org.hosting.issuerApps,
|
|
129
129
|
hostingEntries: org.hosting.entries,
|
|
130
|
-
hostingAdopt: org.hosting.adopt ?? null,
|
|
131
130
|
ssoEnabled: org.sso.enabled,
|
|
132
131
|
guestsEnabled: org.guests.enabled,
|
|
133
132
|
soloEnabled: org.solo.enabled,
|
|
@@ -287,6 +286,7 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
287
286
|
if (reason)
|
|
288
287
|
throw new DeclaredOrgError(`mail invalid: ${reason} (#162)`);
|
|
289
288
|
}
|
|
289
|
+
// `as`: `update` is overloaded in the interface (#351), which one object-literal method cannot express.
|
|
290
290
|
return {
|
|
291
291
|
async create(org) {
|
|
292
292
|
requireValidSlug(org.slug);
|
|
@@ -352,8 +352,11 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
352
352
|
}
|
|
353
353
|
return null;
|
|
354
354
|
},
|
|
355
|
-
async update(id, patch) {
|
|
355
|
+
async update(id, patch, opts) {
|
|
356
356
|
const current = rowToOrg(await requireRow(id));
|
|
357
|
+
// #351: already behind, so refuse without validating a write that will not happen.
|
|
358
|
+
if (opts && current.version !== opts.expectedVersion)
|
|
359
|
+
return { stale: true, current };
|
|
357
360
|
// The slug freeze (#7 corrections 3): once mail exists, the mail
|
|
358
361
|
// subdomain already reads the slug, so the slug this update would
|
|
359
362
|
// leave in place — not the one it started from — is what decides.
|
|
@@ -369,7 +372,7 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
369
372
|
// just be re-litigating a write this update isn't making.
|
|
370
373
|
if (patch.slug !== undefined && patch.slug !== current.slug)
|
|
371
374
|
requireValidSlug(patch.slug);
|
|
372
|
-
const next = { ...current, ...patch, id: current.id };
|
|
375
|
+
const next = { ...current, ...patch, id: current.id, version: current.version };
|
|
373
376
|
requireValidDomain(next.domain);
|
|
374
377
|
requireValidLimits(next);
|
|
375
378
|
requireValidBuyer(next);
|
|
@@ -378,10 +381,24 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
378
381
|
requireValidOwner(next);
|
|
379
382
|
requireValidMail(next);
|
|
380
383
|
try {
|
|
381
|
-
|
|
384
|
+
// #351: with `expectedVersion` the version check is part of this one
|
|
385
|
+
// statement, so an edit that lands after the read above makes it match
|
|
386
|
+
// no row instead of being overwritten.
|
|
387
|
+
const written = await db
|
|
382
388
|
.update(declaredOrgs)
|
|
383
|
-
.set({
|
|
384
|
-
|
|
389
|
+
.set({
|
|
390
|
+
...orgToRow(next),
|
|
391
|
+
version: sql `${declaredOrgs.version} + 1`,
|
|
392
|
+
updatedAt: sql `now()`,
|
|
393
|
+
})
|
|
394
|
+
.where(opts
|
|
395
|
+
? and(eq(declaredOrgs.id, id), eq(declaredOrgs.version, opts.expectedVersion))
|
|
396
|
+
: eq(declaredOrgs.id, id))
|
|
397
|
+
.returning({ version: declaredOrgs.version });
|
|
398
|
+
if (opts && written.length === 0) {
|
|
399
|
+
return { stale: true, current: rowToOrg(await requireRow(id)) };
|
|
400
|
+
}
|
|
401
|
+
return { ...next, version: written[0]?.version ?? (current.version ?? 1) + 1 };
|
|
385
402
|
}
|
|
386
403
|
catch (error) {
|
|
387
404
|
// Same race as `create`: another org's concurrent write took this
|
|
@@ -396,7 +413,6 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
396
413
|
}
|
|
397
414
|
throw error;
|
|
398
415
|
}
|
|
399
|
-
return next;
|
|
400
416
|
},
|
|
401
417
|
async getAppliedState(id) {
|
|
402
418
|
const [row] = await db
|
|
@@ -614,68 +630,3 @@ export function createPostgresRequestedChangeStore(db) {
|
|
|
614
630
|
},
|
|
615
631
|
};
|
|
616
632
|
}
|
|
617
|
-
function rowToAdoption(row) {
|
|
618
|
-
return {
|
|
619
|
-
orgId: row.orgId,
|
|
620
|
-
resourceKey: row.resourceKey,
|
|
621
|
-
kind: row.kind,
|
|
622
|
-
takenAt: row.takenAt.toISOString(),
|
|
623
|
-
settings: row.settings,
|
|
624
|
-
vaultRef: row.vaultRef,
|
|
625
|
-
state: row.state,
|
|
626
|
-
};
|
|
627
|
-
}
|
|
628
|
-
/**
|
|
629
|
-
* `AdoptionStore` (`../store.ts`) on Postgres, for #311: the snapshot row of
|
|
630
|
-
* each live resource foundry takes over, beside the stores above.
|
|
631
|
-
*/
|
|
632
|
-
export function createPostgresAdoptionStore(db) {
|
|
633
|
-
return {
|
|
634
|
-
async put(adoption) {
|
|
635
|
-
const values = {
|
|
636
|
-
orgId: adoption.orgId,
|
|
637
|
-
resourceKey: adoption.resourceKey,
|
|
638
|
-
kind: adoption.kind,
|
|
639
|
-
takenAt: new Date(adoption.takenAt),
|
|
640
|
-
settings: adoption.settings,
|
|
641
|
-
vaultRef: adoption.vaultRef,
|
|
642
|
-
state: 'adopted',
|
|
643
|
-
};
|
|
644
|
-
await db
|
|
645
|
-
.insert(foundryAdoptions)
|
|
646
|
-
.values(values)
|
|
647
|
-
.onConflictDoUpdate({
|
|
648
|
-
target: [foundryAdoptions.orgId, foundryAdoptions.resourceKey],
|
|
649
|
-
set: values,
|
|
650
|
-
});
|
|
651
|
-
},
|
|
652
|
-
async get(orgId, resourceKey) {
|
|
653
|
-
const [row] = await db
|
|
654
|
-
.select()
|
|
655
|
-
.from(foundryAdoptions)
|
|
656
|
-
.where(and(eq(foundryAdoptions.orgId, orgId), eq(foundryAdoptions.resourceKey, resourceKey)))
|
|
657
|
-
.limit(1);
|
|
658
|
-
return row ? rowToAdoption(row) : null;
|
|
659
|
-
},
|
|
660
|
-
async list(orgId) {
|
|
661
|
-
const rows = await db
|
|
662
|
-
.select()
|
|
663
|
-
.from(foundryAdoptions)
|
|
664
|
-
.where(eq(foundryAdoptions.orgId, orgId))
|
|
665
|
-
.orderBy(asc(foundryAdoptions.resourceKey));
|
|
666
|
-
return rows.map(rowToAdoption);
|
|
667
|
-
},
|
|
668
|
-
async setState(orgId, resourceKey, state) {
|
|
669
|
-
await db
|
|
670
|
-
.update(foundryAdoptions)
|
|
671
|
-
.set({ state })
|
|
672
|
-
.where(and(eq(foundryAdoptions.orgId, orgId), eq(foundryAdoptions.resourceKey, resourceKey)));
|
|
673
|
-
},
|
|
674
|
-
async setSettings(orgId, resourceKey, settings) {
|
|
675
|
-
await db
|
|
676
|
-
.update(foundryAdoptions)
|
|
677
|
-
.set({ settings })
|
|
678
|
-
.where(and(eq(foundryAdoptions.orgId, orgId), eq(foundryAdoptions.resourceKey, resourceKey)));
|
|
679
|
-
},
|
|
680
|
-
};
|
|
681
|
-
}
|