@wtfalch/foundry 0.10.0 → 0.11.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 +16 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/migrations/0031_adoptions.sql +37 -0
- package/dist/org.d.ts +19 -0
- package/dist/org.js +52 -0
- package/dist/plan/adopt.d.ts +112 -0
- package/dist/plan/adopt.js +252 -0
- package/dist/plan/adoption-vault.d.ts +9 -0
- package/dist/plan/adoption-vault.js +1 -0
- package/dist/plan/broker-entries.d.ts +5 -1
- package/dist/plan/cloudflared-connector.d.ts +10 -2
- package/dist/plan/cloudflared-connector.js +24 -16
- package/dist/plan/diff.d.ts +12 -0
- package/dist/plan/diff.js +232 -9
- package/dist/plan/executor.d.ts +97 -3
- package/dist/plan/executor.js +918 -96
- package/dist/plan/plan-apply.d.ts +47 -3
- package/dist/plan/plan-apply.js +122 -4
- package/dist/plan/platform-diff.d.ts +6 -0
- package/dist/plan/platform-diff.js +8 -0
- package/dist/plan/providers-readers.d.ts +8 -1
- package/dist/plan/providers-readers.js +58 -4
- package/dist/plan.d.ts +6 -0
- package/dist/store/postgres.d.ts +6 -1
- package/dist/store/postgres.js +69 -2
- package/dist/store/schema.d.ts +162 -0
- package/dist/store/schema.js +19 -1
- package/dist/store.d.ts +37 -0
- package/package.json +1 -1
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type { PlanApply } from '../apply.js';
|
|
1
|
+
import type { AdoptionRestored, PlanApply } from '../apply.js';
|
|
2
2
|
import type { DeclaredOrg, OrgId } from '../org.js';
|
|
3
|
-
import type { Plan, PlanStep } from '../plan.js';
|
|
3
|
+
import type { Plan, PlanStep, UnmanagedItem } from '../plan.js';
|
|
4
4
|
import type { DeclaredPlatform } from '../platform.js';
|
|
5
5
|
import type { AppliedState } from '../state.js';
|
|
6
|
-
import type { DeclaredOrgStore, PlatformStore } from '../store.js';
|
|
6
|
+
import type { Adoption, AdoptionStore, DeclaredOrgStore, PlatformStore } from '../store.js';
|
|
7
|
+
import type { AdoptionVault } from './adoption-vault.js';
|
|
7
8
|
import { type AgoraHostPort } from './agora.js';
|
|
8
9
|
import { type StepAction } from './diff.js';
|
|
9
10
|
import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
|
|
@@ -147,6 +148,26 @@ export interface StepExecutor {
|
|
|
147
148
|
execute(action: StepAction, context: StepExecutionContext): Promise<{
|
|
148
149
|
resourceId?: string;
|
|
149
150
|
} | 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
|
+
}>;
|
|
150
171
|
}
|
|
151
172
|
/**
|
|
152
173
|
* #167: {@link StepExecutionContext}'s platform-scoped counterpart -- what
|
|
@@ -250,6 +271,10 @@ export declare class StalePlanError extends Error {
|
|
|
250
271
|
export declare class ConfirmationRequiredError extends Error {
|
|
251
272
|
constructor(orgId: OrgId);
|
|
252
273
|
}
|
|
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
|
+
}
|
|
253
278
|
export declare class NotAuthorizedError extends Error {
|
|
254
279
|
constructor(actor: AuditActor, orgId: OrgId);
|
|
255
280
|
}
|
|
@@ -311,6 +336,13 @@ export interface FoundryPlanApplyOptions {
|
|
|
311
336
|
*/
|
|
312
337
|
filesOrganisation?: boolean;
|
|
313
338
|
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;
|
|
314
346
|
/** Overridable for tests; defaults to `() => new Date()`. */
|
|
315
347
|
now?: () => Date;
|
|
316
348
|
/**
|
|
@@ -361,11 +393,21 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
361
393
|
private readonly broker;
|
|
362
394
|
private readonly hostFiles;
|
|
363
395
|
private readonly hostAi;
|
|
396
|
+
private readonly adoptionVault;
|
|
397
|
+
private readonly adoptionStore;
|
|
364
398
|
private readonly now;
|
|
365
399
|
private readonly platformDeps;
|
|
366
400
|
constructor(options: FoundryPlanApplyOptions);
|
|
367
401
|
plan(id: OrgId): Promise<Plan>;
|
|
368
402
|
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>;
|
|
369
411
|
/**
|
|
370
412
|
* #167: {@link plan}, for the shared platform (#166) rather than an org --
|
|
371
413
|
* there is no id to pass, since `DeclaredPlatform` is one singleton record.
|
|
@@ -398,6 +440,8 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
398
440
|
private refuseDestroy;
|
|
399
441
|
private runSteps;
|
|
400
442
|
private computeFreshPlan;
|
|
443
|
+
/** #311: the `details` of each `coolify:adopt` step and the adoption items that are blocked. */
|
|
444
|
+
private addAdoptionReport;
|
|
401
445
|
/**
|
|
402
446
|
* An entry may not take a name foundry owns (`locked-hostnames.ts`). The
|
|
403
447
|
* store checks the names it knows; this adds the platform's own hostnames,
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -2,6 +2,7 @@ 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';
|
|
5
6
|
import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
|
|
6
7
|
import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
|
|
7
8
|
import { readLivePlatformResources, readLiveResources, } from './live.js';
|
|
@@ -28,6 +29,13 @@ export class ConfirmationRequiredError extends Error {
|
|
|
28
29
|
this.name = 'ConfirmationRequiredError';
|
|
29
30
|
}
|
|
30
31
|
}
|
|
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
|
+
}
|
|
31
39
|
export class NotAuthorizedError extends Error {
|
|
32
40
|
constructor(actor, orgId) {
|
|
33
41
|
super(`${actor.display} does not hold the permission to apply org ${orgId} (#9, #19)`);
|
|
@@ -68,6 +76,7 @@ export class PlatformConfirmationRequiredError extends Error {
|
|
|
68
76
|
}
|
|
69
77
|
/** The steps that read an entry's hostnames: `computeFreshPlan` drops them for an org whose hosting is invalid. */
|
|
70
78
|
const HOSTNAME_STEPS = new Set([
|
|
79
|
+
'coolify:adopt',
|
|
71
80
|
'coolify:app',
|
|
72
81
|
'coolify:broker-entry',
|
|
73
82
|
'coolify:database',
|
|
@@ -95,6 +104,8 @@ export class FoundryPlanApply {
|
|
|
95
104
|
broker;
|
|
96
105
|
hostFiles;
|
|
97
106
|
hostAi;
|
|
107
|
+
adoptionVault;
|
|
108
|
+
adoptionStore;
|
|
98
109
|
now;
|
|
99
110
|
platformDeps;
|
|
100
111
|
constructor(options) {
|
|
@@ -111,6 +122,8 @@ export class FoundryPlanApply {
|
|
|
111
122
|
this.broker = options.broker ?? false;
|
|
112
123
|
this.hostFiles = options.filesOrganisation ?? false;
|
|
113
124
|
this.hostAi = options.aiOrganisation ?? false;
|
|
125
|
+
this.adoptionVault = options.adoptionVault;
|
|
126
|
+
this.adoptionStore = options.adoptionStore;
|
|
114
127
|
this.now = options.now ?? (() => new Date());
|
|
115
128
|
this.platformDeps = options.platform;
|
|
116
129
|
}
|
|
@@ -137,6 +150,42 @@ export class FoundryPlanApply {
|
|
|
137
150
|
}
|
|
138
151
|
return this.runSteps(org, plan, detail);
|
|
139
152
|
}
|
|
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
|
+
}
|
|
140
189
|
/**
|
|
141
190
|
* #167: {@link plan}, for the shared platform (#166) rather than an org --
|
|
142
191
|
* there is no id to pass, since `DeclaredPlatform` is one singleton record.
|
|
@@ -217,8 +266,25 @@ export class FoundryPlanApply {
|
|
|
217
266
|
}
|
|
218
267
|
const state = await this.store.getAppliedState(id);
|
|
219
268
|
const serviceIds = { ...(state?.serviceIds ?? {}) };
|
|
220
|
-
|
|
269
|
+
// #311: an adopted resource is released, never deleted. Its rows go to
|
|
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));
|
|
221
274
|
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
|
+
}
|
|
222
288
|
for (const target of targets) {
|
|
223
289
|
// A provider 404 counts as deleted -- `StepDestroyer.destroy`'s own
|
|
224
290
|
// doc comment -- so nothing here needs to special-case "already
|
|
@@ -286,6 +352,7 @@ export class FoundryPlanApply {
|
|
|
286
352
|
const orgId = plan.orgId;
|
|
287
353
|
const previous = await this.store.getAppliedState(orgId);
|
|
288
354
|
const serviceIds = { ...(previous?.serviceIds ?? {}) };
|
|
355
|
+
const adoptedBefore = (await this.adoptionStore?.list(orgId))?.filter((row) => row.state === 'adopted');
|
|
289
356
|
const checkpoints = [];
|
|
290
357
|
// Backed by the very `serviceIds` map this method persists once, at the
|
|
291
358
|
// end — see `StepExecutionContext`'s doc comment for why.
|
|
@@ -349,6 +416,20 @@ export class FoundryPlanApply {
|
|
|
349
416
|
lastApplied: { at: this.now().toISOString(), declaredOrgVersion: hashOrg(org) },
|
|
350
417
|
};
|
|
351
418
|
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
|
+
}
|
|
352
433
|
await this.audit.recordApply({
|
|
353
434
|
actor: this.actor,
|
|
354
435
|
orgId,
|
|
@@ -362,13 +443,22 @@ export class FoundryPlanApply {
|
|
|
362
443
|
const org = await this.store.get(id);
|
|
363
444
|
if (!org)
|
|
364
445
|
throw new DeclaredOrgNotFoundError(id);
|
|
365
|
-
const [live, applied, platformReadiness, agoraHost] = await Promise.all([
|
|
446
|
+
const [live, applied, platformReadiness, agoraHost, adopted] = await Promise.all([
|
|
366
447
|
readLiveResources(id, this.readers),
|
|
367
448
|
this.store.getAppliedState(id),
|
|
368
449
|
this.orgPlatformReadiness(),
|
|
369
450
|
this.orgAgoraHost(id),
|
|
451
|
+
this.adoptionStore?.list(id) ?? [],
|
|
370
452
|
]);
|
|
371
|
-
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
|
|
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 ?? {});
|
|
372
462
|
const invalid = await this.invalidHosting(org);
|
|
373
463
|
if (invalid) {
|
|
374
464
|
// Rule 2: reported in `unmanaged`, not thrown. Steps that read the
|
|
@@ -381,6 +471,31 @@ export class FoundryPlanApply {
|
|
|
381
471
|
}
|
|
382
472
|
return { org, plan: toPlan(detail), detail };
|
|
383
473
|
}
|
|
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
|
+
}
|
|
384
499
|
/**
|
|
385
500
|
* An entry may not take a name foundry owns (`locked-hostnames.ts`). The
|
|
386
501
|
* store checks the names it knows; this adds the platform's own hostnames,
|
|
@@ -588,7 +703,7 @@ const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
|
|
|
588
703
|
* belongs to is already left in place -- deleting only the rule and not the
|
|
589
704
|
* zone is a decision left for whoever wires teardown in, not made here.
|
|
590
705
|
*/
|
|
591
|
-
function destroyTargets(serviceIds) {
|
|
706
|
+
function destroyTargets(serviceIds, adoptedKeys, adoptedUuids) {
|
|
592
707
|
// Reverse-teardown order: everything under one of these prefixes is
|
|
593
708
|
// collected in this order, then every ZITADEL app (there can be several,
|
|
594
709
|
// #71), then the two scope ids last.
|
|
@@ -605,6 +720,9 @@ function destroyTargets(serviceIds) {
|
|
|
605
720
|
for (const [resourceKey, id] of Object.entries(serviceIds)) {
|
|
606
721
|
if (!resourceKey.startsWith(prefix))
|
|
607
722
|
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;
|
|
608
726
|
const [system, kind] = prefix.split(':');
|
|
609
727
|
targets.push({ system, kind, key: resourceKey.slice(prefix.length), id });
|
|
610
728
|
}
|
|
@@ -357,6 +357,12 @@ 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;
|
|
360
366
|
/** The settings the app has: the marker, or the defaults when there is none. */
|
|
361
367
|
export declare function appliedAppSettings(recorded: Readonly<Record<string, string>>, entryKey: string): AppSettings;
|
|
362
368
|
/**
|
|
@@ -391,6 +391,14 @@ 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
|
+
}
|
|
394
402
|
/** The settings the app has: the marker, or the defaults when there is none. */
|
|
395
403
|
export function appliedAppSettings(recorded, entryKey) {
|
|
396
404
|
const marker = recorded[appSettingsAppliedScopeKey(entryKey)];
|
|
@@ -3,6 +3,7 @@ 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';
|
|
6
7
|
import type { CloudflareReader, CmsReader, CoolifyReader, LiveResource, ZitadelReader } from './live.js';
|
|
7
8
|
/**
|
|
8
9
|
* Real `CoolifyReader`, `CloudflareReader` and `ZitadelReader` implementations
|
|
@@ -92,7 +93,13 @@ export declare class ProvidersCoolifyReader implements CoolifyReader {
|
|
|
92
93
|
private readonly client;
|
|
93
94
|
private readonly resolveScope;
|
|
94
95
|
private readonly resolveRecordedIds;
|
|
95
|
-
|
|
96
|
+
private readonly options;
|
|
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;
|
|
96
103
|
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
97
104
|
}
|
|
98
105
|
/**
|
|
@@ -204,19 +204,73 @@ export class ProvidersCoolifyReader {
|
|
|
204
204
|
client;
|
|
205
205
|
resolveScope;
|
|
206
206
|
resolveRecordedIds;
|
|
207
|
-
|
|
207
|
+
options;
|
|
208
|
+
constructor(client, resolveScope, resolveRecordedIds, options = {}) {
|
|
208
209
|
this.client = client;
|
|
209
210
|
this.resolveScope = resolveScope;
|
|
210
211
|
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);
|
|
211
252
|
}
|
|
212
253
|
async listResources(orgId) {
|
|
213
254
|
const projectUuid = await this.resolveScope(orgId);
|
|
214
|
-
|
|
255
|
+
// The tunnel and its connector are read by `ProvidersCloudflareReader` and the executor (#312).
|
|
256
|
+
const adopted = ((await this.options.adopted?.(orgId)) ?? []).filter((a) => a.kind === 'app' || a.kind === 'database');
|
|
257
|
+
if (!projectUuid && adopted.length === 0)
|
|
215
258
|
return [];
|
|
216
|
-
const [
|
|
217
|
-
this.client.listResources(projectUuid),
|
|
259
|
+
const [listed, recordedById, adoptedLive] = await Promise.all([
|
|
260
|
+
projectUuid ? this.client.listResources(projectUuid) : [],
|
|
218
261
|
this.resolveRecordedIds(orgId),
|
|
262
|
+
this.readAdopted(adopted),
|
|
219
263
|
]);
|
|
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
|
+
};
|
|
220
274
|
const withLogConfig = await Promise.all(toFoundryResources('coolify', resources).map((r) => withLogVolumeConfig(this.client, r, recorded)));
|
|
221
275
|
const withEnvNames = await Promise.all(withLogConfig.map((r) => withEnvNamesConfig(this.client, r, recorded)));
|
|
222
276
|
const withDbUrl = await Promise.all(withEnvNames.map((r) => withDatabaseUrlConfig(this.client, r, recorded)));
|
package/dist/plan.d.ts
CHANGED
|
@@ -9,6 +9,12 @@ 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[];
|
|
12
18
|
/**
|
|
13
19
|
* True when this step deletes or replaces data the org cannot get back
|
|
14
20
|
* 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 { DeclaredOrgStore, PlatformStore, RequestedChangeStore } from '../store.js';
|
|
2
|
+
import type { AdoptionStore, 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,3 +90,8 @@ 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
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { asc, eq, sql } from 'drizzle-orm';
|
|
1
|
+
import { and, asc, eq, sql } from 'drizzle-orm';
|
|
2
2
|
import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBuyerIdentity, validateDomain, validateHosting, validateMail, validateOwner, validateSite, } from '../org.js';
|
|
3
3
|
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, foundryAppliedState, foundryDeclaredPlatform, foundryRequestedChanges, } from './schema.js';
|
|
7
|
+
import { declaredOrgs, foundryAdoptions, 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';
|
|
@@ -82,6 +82,7 @@ function rowToOrg(row) {
|
|
|
82
82
|
usesDomain: row.hostingUsesDomain,
|
|
83
83
|
tunnel: row.hostingTunnel,
|
|
84
84
|
issuerApps: row.hostingIssuerApps,
|
|
85
|
+
...(row.hostingAdopt ? { adopt: row.hostingAdopt } : {}),
|
|
85
86
|
// Migration 0030's default for an entry stored before `hostnames`.
|
|
86
87
|
entries: row.hostingEntries.map((entry) => ({
|
|
87
88
|
...entry,
|
|
@@ -126,6 +127,7 @@ function orgToRow(org) {
|
|
|
126
127
|
hostingTunnel: org.hosting.tunnel,
|
|
127
128
|
hostingIssuerApps: org.hosting.issuerApps,
|
|
128
129
|
hostingEntries: org.hosting.entries,
|
|
130
|
+
hostingAdopt: org.hosting.adopt ?? null,
|
|
129
131
|
ssoEnabled: org.sso.enabled,
|
|
130
132
|
guestsEnabled: org.guests.enabled,
|
|
131
133
|
soloEnabled: org.solo.enabled,
|
|
@@ -612,3 +614,68 @@ export function createPostgresRequestedChangeStore(db) {
|
|
|
612
614
|
},
|
|
613
615
|
};
|
|
614
616
|
}
|
|
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
|
+
}
|