@wtfalch/foundry 0.10.0 → 0.12.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 +119 -9
- package/dist/plan/executor.js +927 -99
- 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
|
@@ -54,8 +54,15 @@ export async function ensureCloudflaredConnector(input) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
if (!appId) {
|
|
57
|
+
if (input.adopted) {
|
|
58
|
+
throw new Error(`cloudflared connector ${input.appName}: the adopted app was not found`);
|
|
59
|
+
}
|
|
60
|
+
const { projectUuid, environment } = input;
|
|
61
|
+
if (!projectUuid || !environment) {
|
|
62
|
+
throw new Error(`cloudflared connector ${input.appName}: no project to find or create it in`);
|
|
63
|
+
}
|
|
57
64
|
const found = await coolify.findApplicationByName(input.appName, {
|
|
58
|
-
environmentIds: [
|
|
65
|
+
environmentIds: [environment.id],
|
|
59
66
|
});
|
|
60
67
|
if (found) {
|
|
61
68
|
const live = await liveStatus(found.uuid);
|
|
@@ -64,21 +71,21 @@ export async function ensureCloudflaredConnector(input) {
|
|
|
64
71
|
status = live.status;
|
|
65
72
|
}
|
|
66
73
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
74
|
+
if (!appId) {
|
|
75
|
+
const server = await input.resolveServer();
|
|
76
|
+
const created = await coolify.createDockerfileApplication({
|
|
77
|
+
project_uuid: projectUuid,
|
|
78
|
+
server_uuid: server.uuid,
|
|
79
|
+
environment_name: environment.name,
|
|
80
|
+
environment_uuid: environment.uuid,
|
|
81
|
+
dockerfile: cloudflaredDockerfile(),
|
|
82
|
+
name: input.appName,
|
|
83
|
+
description: 'cloudflared connector for a Cloudflare tunnel',
|
|
84
|
+
instant_deploy: false,
|
|
85
|
+
});
|
|
86
|
+
appId = created.uuid;
|
|
87
|
+
justCreated = true;
|
|
88
|
+
}
|
|
82
89
|
}
|
|
83
90
|
if (appId !== input.recordedConnectorAppId)
|
|
84
91
|
await input.recordAppId(appId);
|
|
@@ -86,6 +93,7 @@ export async function ensureCloudflaredConnector(input) {
|
|
|
86
93
|
const tokenChanged = justCreated || (await tunnelTokenDiffers(coolify, appId, token));
|
|
87
94
|
if (tokenChanged) {
|
|
88
95
|
try {
|
|
96
|
+
await input.beforeTokenWrite?.(token);
|
|
89
97
|
await coolify.bulkUpsertEnvs(appId, [{ key: 'TUNNEL_TOKEN', value: token, literal: true }]);
|
|
90
98
|
}
|
|
91
99
|
catch {
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DeclaredOrg, OrgId } from '../org.js';
|
|
2
2
|
import type { Plan, PlanStep, UnmanagedItem, VersionChangeItem } from '../plan.js';
|
|
3
|
+
import type { Adoption } from '../store.js';
|
|
3
4
|
import { type AgoraHostReading } from './agora.js';
|
|
4
5
|
import type { LiveResource } from './live.js';
|
|
5
6
|
import { type OrgPlatformReadiness } from './platform-org.js';
|
|
@@ -34,6 +35,17 @@ export interface PlannedStep {
|
|
|
34
35
|
export interface PlanOrgOptions {
|
|
35
36
|
/** #267: the executor was built with a `loginHostBindings` port. */
|
|
36
37
|
loginHostBindings?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* #311: the host wired an `AdoptionVault` and an `AdoptionStore`, and the
|
|
40
|
+
* rows it holds. An adopting org plans a `coolify:adopt` step for each
|
|
41
|
+
* resource with no active snapshot (`isActiveSnapshot`), or, with nothing
|
|
42
|
+
* wired, reports adoption blocked and plans neither it nor the resource's
|
|
43
|
+
* own step.
|
|
44
|
+
*/
|
|
45
|
+
adoption?: {
|
|
46
|
+
wired: boolean;
|
|
47
|
+
adopted: readonly Adoption[];
|
|
48
|
+
};
|
|
37
49
|
/** F5: the executor was built with a `broker` port. */
|
|
38
50
|
broker?: boolean;
|
|
39
51
|
}
|
package/dist/plan/diff.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
2
|
import { APP_ENTRY_KEY } from '../org.js';
|
|
3
|
+
import { adoptedConnectorKey, adoptedResources, adoptedTunnelKey, declaredAdoptUuids, isActiveSnapshot, protectsKey, } from './adopt.js';
|
|
3
4
|
import { NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, desiredAgoraEnv, } from './agora.js';
|
|
4
5
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
|
|
5
6
|
import { appHostname, entryHostnames, tunnelDnsHostnames } from './hostnames.js';
|
|
6
7
|
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
|
|
7
8
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
|
|
8
|
-
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appEnvAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, versionNotAppliedLine, } from './platform-diff.js';
|
|
9
|
+
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appDeployPendingScopeKey, appEnvAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, versionNotAppliedLine, } from './platform-diff.js';
|
|
9
10
|
import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
|
|
10
11
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
11
12
|
export function toPlan(detail) {
|
|
@@ -173,6 +174,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
173
174
|
plannedSteps.push(customers);
|
|
174
175
|
plannedSteps.push(...planIssuerApps(org, byKey, claimed));
|
|
175
176
|
plannedSteps.push(...planEntryClients(org, byKey, claimed));
|
|
177
|
+
// #311: the snapshot of each adopted resource, before the database and app
|
|
178
|
+
// steps that first write to them.
|
|
179
|
+
const adoption = planAdoptions(org, options.adoption);
|
|
180
|
+
plannedSteps.push(...adoption.steps);
|
|
176
181
|
// The Access gate before the database, app, tunnel and zone (#84): on a
|
|
177
182
|
// first apply the hostname is gated before anything answers on it.
|
|
178
183
|
plannedSteps.push(...planAccess(org, recordedIds));
|
|
@@ -182,16 +187,90 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
182
187
|
// string into the app's environment once the database exists -- it can
|
|
183
188
|
// only do that if the database step already ran and recorded the
|
|
184
189
|
// database's resource id.
|
|
185
|
-
|
|
186
|
-
|
|
190
|
+
// A park of an adopted resource is reported, never planned (#311).
|
|
191
|
+
const parks = [];
|
|
192
|
+
const notAdoptedPark = (s) => {
|
|
193
|
+
const held = adoptedPark(org, options.adoption?.adopted ?? [], recordedIds, s);
|
|
194
|
+
if (held)
|
|
195
|
+
parks.push(held);
|
|
196
|
+
return !held;
|
|
197
|
+
};
|
|
198
|
+
// An adopt step plans its resource's step after it, whatever the markers
|
|
199
|
+
// say: the adopted resource has not been configured by foundry yet.
|
|
200
|
+
const adopting = new Set(adoption.steps.map((s) => s.action.key));
|
|
201
|
+
const database = planDatabase(org, byKey, claimed) ??
|
|
202
|
+
(adopting.has(`coolify:database:${org.id}`)
|
|
203
|
+
? {
|
|
204
|
+
action: {
|
|
205
|
+
system: 'coolify',
|
|
206
|
+
kind: 'database',
|
|
207
|
+
key: org.id,
|
|
208
|
+
desired: { enabled: true },
|
|
209
|
+
},
|
|
210
|
+
step: {
|
|
211
|
+
description: `record ${org.slug}'s adopted database`,
|
|
212
|
+
destroys: false,
|
|
213
|
+
migrates: false,
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
: null);
|
|
217
|
+
if (database &&
|
|
218
|
+
notAdoptedPark(database) &&
|
|
219
|
+
!adoption.withheld.has(`coolify:database:${org.id}`)) {
|
|
187
220
|
plannedSteps.push(database);
|
|
221
|
+
}
|
|
188
222
|
const authIssuer = 'error' in agoraHost ? null : agoraHost.issuer;
|
|
189
|
-
const app = planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer);
|
|
190
|
-
plannedSteps.push(...app.steps);
|
|
223
|
+
const app = planApps(org, byKey, claimed, recordedIds, plannedSteps, adopting, authIssuer);
|
|
224
|
+
plannedSteps.push(...app.steps.filter((s) => notAdoptedPark(s) && !adoption.withheld.has(`coolify:app:${s.action.key}`)));
|
|
191
225
|
// After the apps: the broker entry's key is minted into each app's env.
|
|
192
226
|
const brokerEntries = planBrokerEntries(org, recordedIds, plannedSteps, options.broker ?? false);
|
|
193
227
|
plannedSteps.push(...brokerEntries.steps);
|
|
194
|
-
|
|
228
|
+
// An adopt step of the tunnel or its connector plans the tunnel step after
|
|
229
|
+
// it, whatever the markers say: foundry has not merged its routes yet (#312).
|
|
230
|
+
const tunnelKey = adoptedTunnelKey(org);
|
|
231
|
+
const connectorKey = adoptedConnectorKey(org);
|
|
232
|
+
const tunnelHeld = adoption.withheld.has(tunnelKey) || adoption.withheld.has(connectorKey);
|
|
233
|
+
const planned = planTunnel(org, byKey, claimed, recordedIds);
|
|
234
|
+
const merging = org.hosting.adopt?.tunnel !== undefined &&
|
|
235
|
+
(planned !== null || adopting.has(tunnelKey) || adopting.has(connectorKey));
|
|
236
|
+
const tunnelPlanned = merging && planned?.action.desired.enabled !== false
|
|
237
|
+
? {
|
|
238
|
+
action: {
|
|
239
|
+
system: 'cloudflare',
|
|
240
|
+
kind: 'tunnel',
|
|
241
|
+
key: org.id,
|
|
242
|
+
desired: { enabled: true },
|
|
243
|
+
},
|
|
244
|
+
step: {
|
|
245
|
+
description: `merge ${org.slug}'s routes into its adopted Cloudflare tunnel and keep the others`,
|
|
246
|
+
destroys: false,
|
|
247
|
+
migrates: false,
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
: planned;
|
|
251
|
+
// An adopted tunnel with no active snapshot and no adopt step in this plan
|
|
252
|
+
// (hosting off plans none) cannot be written to: reported blocked, no tunnel
|
|
253
|
+
// step, or the step would throw on every apply (rule 2, #312).
|
|
254
|
+
const adoptedRows = options.adoption?.adopted ?? [];
|
|
255
|
+
const activeRow = (key, uuid) => isActiveSnapshot(adoptedRows.find((r) => r.resourceKey === key), uuid ?? '');
|
|
256
|
+
const adoptable = org.hosting.adopt?.tunnel !== undefined &&
|
|
257
|
+
!tunnelHeld &&
|
|
258
|
+
!adopting.has(tunnelKey) &&
|
|
259
|
+
!adopting.has(connectorKey) &&
|
|
260
|
+
(!org.hosting.enabled ||
|
|
261
|
+
!(activeRow(tunnelKey, org.hosting.adopt.tunnel) &&
|
|
262
|
+
activeRow(connectorKey, org.hosting.adopt.connectorApp)));
|
|
263
|
+
if (adoptable) {
|
|
264
|
+
adoption.blocked.push({
|
|
265
|
+
description: `${org.slug}'s ${tunnelKey} is blocked, not planned: hosting is off, so its routes are not merged into the adopted tunnel`,
|
|
266
|
+
key: `coolify:adopt:${tunnelKey}`,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
// Neither a snapshot-less adopted tunnel nor an undeclared one is written to
|
|
270
|
+
// or parked (#312); a blocked adoption plans no tunnel step (rule 2).
|
|
271
|
+
const tunnel = tunnelPlanned && notAdoptedPark(tunnelPlanned) && !tunnelHeld && !adoptable
|
|
272
|
+
? tunnelPlanned
|
|
273
|
+
: null;
|
|
195
274
|
// #238: a tunnel that is being provisioned waits for an active zone; parking
|
|
196
275
|
// one never does.
|
|
197
276
|
if (tunnel && zoneWaiting && tunnel.action.desired.enabled !== false) {
|
|
@@ -292,6 +371,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
292
371
|
unmanaged.push(...zoneBlocked);
|
|
293
372
|
// #243: an appEnv source nothing will record is reported, not silently skipped.
|
|
294
373
|
unmanaged.push(...app.blocked);
|
|
374
|
+
unmanaged.push(...adoption.blocked, ...parks);
|
|
295
375
|
unmanaged.push(...brokerEntries.blocked);
|
|
296
376
|
return { orgId: org.id, plannedSteps, unmanaged, versionChanges };
|
|
297
377
|
}
|
|
@@ -1331,6 +1411,117 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
|
|
|
1331
1411
|
blocked: null,
|
|
1332
1412
|
};
|
|
1333
1413
|
}
|
|
1414
|
+
/**
|
|
1415
|
+
* #311: one `coolify:adopt` step per adopted resource that has no adoption row
|
|
1416
|
+
* yet. It `migrates`, so apply needs the org's name typed. With no vault and
|
|
1417
|
+
* store wired, each is reported blocked instead, and `withheld` names the
|
|
1418
|
+
* resources whose own step must not plan either: it would write to a live
|
|
1419
|
+
* resource nothing has snapshotted (rule 2).
|
|
1420
|
+
*/
|
|
1421
|
+
function planAdoptions(org, adoption) {
|
|
1422
|
+
const steps = [];
|
|
1423
|
+
const blocked = [];
|
|
1424
|
+
const withheld = new Set();
|
|
1425
|
+
for (const resource of adoptedResources(org)) {
|
|
1426
|
+
const what = resource.kind === 'app'
|
|
1427
|
+
? `${resource.entry.key} app`
|
|
1428
|
+
: resource.kind === 'connector'
|
|
1429
|
+
? 'cloudflared connector'
|
|
1430
|
+
: resource.kind;
|
|
1431
|
+
if (!adoption?.wired) {
|
|
1432
|
+
withheld.add(resource.resourceKey);
|
|
1433
|
+
blocked.push({
|
|
1434
|
+
description: `${org.slug}'s adoption of its ${what} is blocked, not planned: no adoption vault is wired`,
|
|
1435
|
+
key: `coolify:adopt:${resource.resourceKey}`,
|
|
1436
|
+
});
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1439
|
+
const row = adoption.adopted.find((r) => r.resourceKey === resource.resourceKey);
|
|
1440
|
+
// A new snapshot of another uuid would record foundry's own earlier writes.
|
|
1441
|
+
if (row?.state === 'adopted' && row.settings.uuid !== resource.uuid) {
|
|
1442
|
+
withheld.add(resource.resourceKey);
|
|
1443
|
+
blocked.push({
|
|
1444
|
+
description: `${org.slug}'s ${resource.resourceKey} is adopted as ${String(row.settings.uuid)}, not ${resource.uuid}: restore the adopted ${resource.kind} first, not planned`,
|
|
1445
|
+
key: `coolify:adopt:${resource.resourceKey}`,
|
|
1446
|
+
});
|
|
1447
|
+
continue;
|
|
1448
|
+
}
|
|
1449
|
+
if (isActiveSnapshot(row, resource.uuid))
|
|
1450
|
+
continue;
|
|
1451
|
+
// A snapshot of a uuid another key holds would record foundry's own writes.
|
|
1452
|
+
const holder = adoption.adopted.find((r) => r.resourceKey !== resource.resourceKey &&
|
|
1453
|
+
protectsKey(r) &&
|
|
1454
|
+
r.settings.uuid === resource.uuid);
|
|
1455
|
+
if (holder) {
|
|
1456
|
+
withheld.add(resource.resourceKey);
|
|
1457
|
+
blocked.push({
|
|
1458
|
+
description: `${org.slug}'s ${resource.resourceKey} declares ${resource.uuid}, which ${holder.resourceKey} already adopted: declare it under one key, not planned`,
|
|
1459
|
+
key: `coolify:adopt:${resource.resourceKey}`,
|
|
1460
|
+
});
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
steps.push({
|
|
1464
|
+
action: {
|
|
1465
|
+
system: 'coolify',
|
|
1466
|
+
kind: 'adopt',
|
|
1467
|
+
key: resource.resourceKey,
|
|
1468
|
+
desired: { uuid: resource.uuid },
|
|
1469
|
+
},
|
|
1470
|
+
step: {
|
|
1471
|
+
description: `adopt ${org.slug}'s ${what} (Coolify ${resource.uuid}): snapshot it, then change only what the org declares`,
|
|
1472
|
+
destroys: false,
|
|
1473
|
+
migrates: true,
|
|
1474
|
+
},
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
// An entry that was adopted and no longer declares `adopt` is not managed
|
|
1478
|
+
// as foundry's own app or database (rule 2), restored or not.
|
|
1479
|
+
const held = (key) => adoption?.adopted.some((r) => r.resourceKey === key && protectsKey(r));
|
|
1480
|
+
const undeclared = [
|
|
1481
|
+
...(org.hosting.enabled
|
|
1482
|
+
? [
|
|
1483
|
+
...(org.hosting.adopt?.database ? [] : [`coolify:database:${org.id}`]),
|
|
1484
|
+
...org.hosting.entries
|
|
1485
|
+
.filter((entry) => !entry.adopt)
|
|
1486
|
+
.map((entry) => `coolify:app:${appStepKey(org, entry.key)}`),
|
|
1487
|
+
]
|
|
1488
|
+
: []),
|
|
1489
|
+
// The tunnel and its connector are held with hosting off too (#312).
|
|
1490
|
+
...(org.hosting.adopt?.tunnel ? [] : [adoptedTunnelKey(org)]),
|
|
1491
|
+
...(org.hosting.adopt?.connectorApp ? [] : [adoptedConnectorKey(org)]),
|
|
1492
|
+
];
|
|
1493
|
+
for (const key of undeclared.filter(held)) {
|
|
1494
|
+
withheld.add(key);
|
|
1495
|
+
blocked.push({
|
|
1496
|
+
description: `${org.slug}'s ${key} was adopted, so foundry does not manage it as its own: declare adopt again, or run restore to put it back as it was, not planned`,
|
|
1497
|
+
key: `coolify:adopt:${key}`,
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
return { steps, blocked, withheld };
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* The blocked item for a park step on a resource foundry adopted: its key has
|
|
1504
|
+
* a row that protects it (`adopted`, `confirmed` or `restored`), or its recorded id is a uuid the org
|
|
1505
|
+
* declares as adopted. `null` for any other step (#311).
|
|
1506
|
+
*/
|
|
1507
|
+
function adoptedPark(org, rows, recordedIds, { action }) {
|
|
1508
|
+
if (action.desired.enabled !== false)
|
|
1509
|
+
return null;
|
|
1510
|
+
const parkable = action.system === 'coolify'
|
|
1511
|
+
? action.kind === 'app' || action.kind === 'database'
|
|
1512
|
+
: action.system === 'cloudflare' && action.kind === 'tunnel';
|
|
1513
|
+
if (!parkable)
|
|
1514
|
+
return null;
|
|
1515
|
+
const key = `${action.system}:${action.kind}:${action.key}`;
|
|
1516
|
+
const id = recordedIds[key];
|
|
1517
|
+
const held = rows.some((r) => r.resourceKey === key && protectsKey(r));
|
|
1518
|
+
if (!held && !(id && declaredAdoptUuids(org).has(id)))
|
|
1519
|
+
return null;
|
|
1520
|
+
return {
|
|
1521
|
+
description: `${org.slug}'s ${key} is adopted, not stopped: foundry never stops a resource it adopted`,
|
|
1522
|
+
key: `coolify:adopt:${key}`,
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1334
1525
|
/**
|
|
1335
1526
|
* The shared on/off shape behind {@link planApp}, {@link planDatabase} and
|
|
1336
1527
|
* {@link planTunnel} (#71): a per-org singleton resource, keyed by the org
|
|
@@ -1435,7 +1626,24 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
1435
1626
|
key: stepKey,
|
|
1436
1627
|
desired: { enabled: true },
|
|
1437
1628
|
};
|
|
1438
|
-
|
|
1629
|
+
// An adopted app patched but not yet deployed: replanned whatever the
|
|
1630
|
+
// markers say (#311).
|
|
1631
|
+
if (entry.adopt && recordedIds[appDeployPendingScopeKey(entryKey)]) {
|
|
1632
|
+
return {
|
|
1633
|
+
step: {
|
|
1634
|
+
action,
|
|
1635
|
+
step: {
|
|
1636
|
+
description: `deploy ${org.slug}'s ${label} settings to the adopted app`,
|
|
1637
|
+
destroys: false,
|
|
1638
|
+
migrates: false,
|
|
1639
|
+
},
|
|
1640
|
+
},
|
|
1641
|
+
blocked,
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
// An adopted app gets no volume (`executor.ts`'s `coolifyApp`), so a missing
|
|
1645
|
+
// one is not a drift (#311).
|
|
1646
|
+
if (live.config.hasLogVolume === false && !entry.adopt) {
|
|
1439
1647
|
return {
|
|
1440
1648
|
step: {
|
|
1441
1649
|
action,
|
|
@@ -1454,7 +1662,11 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
1454
1662
|
// names (`envNames` absent) reads as compliant, like `hasLogVolume`.
|
|
1455
1663
|
const envNames = live.config.envNames;
|
|
1456
1664
|
const dbLive = byKey.get(resourceKey('coolify', 'database', org.id));
|
|
1457
|
-
|
|
1665
|
+
// An adopted app or database keeps the addresses it has (#311).
|
|
1666
|
+
const dbReady = entry.database &&
|
|
1667
|
+
!entry.adopt &&
|
|
1668
|
+
!org.hosting.adopt?.database &&
|
|
1669
|
+
Boolean(recordedIds[`coolify:database:${org.id}`]);
|
|
1458
1670
|
if (dbReady && dbLive?.config.hasInternalUrl === false) {
|
|
1459
1671
|
// Rule 2: the push cannot succeed yet, so report it rather than replan
|
|
1460
1672
|
// forever.
|
|
@@ -1564,7 +1776,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
1564
1776
|
* {@link planApp} once per hosting entry. A key with no declared entry still
|
|
1565
1777
|
* runs for `app`, so an org that turns hosting off parks the app it has live.
|
|
1566
1778
|
*/
|
|
1567
|
-
function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer) {
|
|
1779
|
+
function planApps(org, byKey, claimed, recordedIds, plannedSteps, adopting, authIssuer) {
|
|
1568
1780
|
const steps = [];
|
|
1569
1781
|
const blocked = [];
|
|
1570
1782
|
// An entry removed from the list while its app is recorded is parked like a
|
|
@@ -1586,6 +1798,17 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer) {
|
|
|
1586
1798
|
continue;
|
|
1587
1799
|
}
|
|
1588
1800
|
const app = planApp(org, key, byKey, claimed, recordedIds, plannedSteps, authIssuer);
|
|
1801
|
+
const stepKey = appStepKey(org, key);
|
|
1802
|
+
if (!app.step && adopting.has(`coolify:app:${stepKey}`)) {
|
|
1803
|
+
app.step = {
|
|
1804
|
+
action: { system: 'coolify', kind: 'app', key: stepKey, desired: { enabled: true } },
|
|
1805
|
+
step: {
|
|
1806
|
+
description: `apply ${org.slug}'s ${key === APP_ENTRY_KEY ? 'app' : `${key} app`} settings, hostnames and env to the adopted app`,
|
|
1807
|
+
destroys: false,
|
|
1808
|
+
migrates: false,
|
|
1809
|
+
},
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1589
1812
|
// The executor pushes the issuer the plan read, the way Agora gets its host values.
|
|
1590
1813
|
if (app.step && app.step.action.desired.enabled !== false) {
|
|
1591
1814
|
const signsIn = org.hosting.entries.some((e) => e.key === key && e.uses?.auth);
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/postmaster';
|
|
2
2
|
import type { DeclaredOrg, HostingBuildPack } from '../org.js';
|
|
3
|
-
import type {
|
|
3
|
+
import type { UnmanagedItem } from '../plan.js';
|
|
4
|
+
import type { Adoption, AdoptionStore, DeclaredOrgStore } from '../store.js';
|
|
5
|
+
import { type IngressRoute } from './adopt.js';
|
|
6
|
+
import type { AdoptionVault } from './adoption-vault.js';
|
|
4
7
|
import { type BrokerPort } from './broker-entries.js';
|
|
5
8
|
import type { StepAction } from './diff.js';
|
|
6
9
|
import { type LoginHostBindingsPort } from './login-host.js';
|
|
@@ -181,9 +184,13 @@ export interface CoolifyPort {
|
|
|
181
184
|
* `is_preview === false` the same way a deployed-config reader must.
|
|
182
185
|
*/
|
|
183
186
|
listEnvs(applicationUuid: string): Promise<Array<{
|
|
187
|
+
uuid?: string;
|
|
184
188
|
key: string;
|
|
185
189
|
value: string;
|
|
186
190
|
is_preview: boolean;
|
|
191
|
+
is_buildtime?: boolean;
|
|
192
|
+
is_literal?: boolean;
|
|
193
|
+
is_multiline?: boolean;
|
|
187
194
|
}>>;
|
|
188
195
|
/** Escape hatch (`CoolifyClient.raw`) — used for stop/start, which have no typed method yet. */
|
|
189
196
|
raw(method: string, path: string, body?: unknown): Promise<unknown>;
|
|
@@ -380,6 +387,18 @@ export interface CloudflarePort {
|
|
|
380
387
|
name: string;
|
|
381
388
|
}>;
|
|
382
389
|
putTunnelConfig(tunnelId: string, config: unknown): Promise<unknown>;
|
|
390
|
+
/**
|
|
391
|
+
* #312: the tunnel's live configuration, `config` null for a tunnel never
|
|
392
|
+
* configured. Matches `@wtfalch/providers`' `CloudflareClient.getTunnelConfig`.
|
|
393
|
+
* `cloudflareTunnel` reads it on every apply of an adopted tunnel, to merge
|
|
394
|
+
* its routes into the live ingress list rather than overwrite it.
|
|
395
|
+
*/
|
|
396
|
+
getTunnelConfig(tunnelId: string): Promise<{
|
|
397
|
+
config: {
|
|
398
|
+
ingress: IngressRoute[];
|
|
399
|
+
[field: string]: unknown;
|
|
400
|
+
} | null;
|
|
401
|
+
}>;
|
|
383
402
|
/**
|
|
384
403
|
* The tunnel's connector token (#197) -- a `cloudflared` container reads
|
|
385
404
|
* this at boot to dial into the tunnel. A live credential: matches
|
|
@@ -585,6 +604,18 @@ export interface LettermintPort {
|
|
|
585
604
|
*/
|
|
586
605
|
restrictDomainToProjects(domainId: string, projectIds: readonly string[]): Promise<void>;
|
|
587
606
|
}
|
|
607
|
+
/**
|
|
608
|
+
* `OrgMailOrg` with `domain` required (#345): a relay route is keyed to a
|
|
609
|
+
* domain, and `OrgMailOrg.domain` is absent for a level-1 org (its domain is
|
|
610
|
+
* `${slug}.${apex}`, resolved only once `applyOrgMail` returns
|
|
611
|
+
* `OrgMailResult.domain`) -- so a caller of `MailPort`'s three relay methods
|
|
612
|
+
* below must already hold that resolved domain and pass it explicitly,
|
|
613
|
+
* rather than the port guessing or refusing on a level-1 org's bare
|
|
614
|
+
* `mailOrgFor` result.
|
|
615
|
+
*/
|
|
616
|
+
export type OrgRelayMailOrg = OrgMailOrg & {
|
|
617
|
+
readonly domain: string;
|
|
618
|
+
};
|
|
588
619
|
/**
|
|
589
620
|
* The Stalwart calls this executor makes for one org's mail, shaped to match
|
|
590
621
|
* `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` method-for-method
|
|
@@ -599,14 +630,17 @@ export interface MailPort {
|
|
|
599
630
|
* (#162). The host implements this with `@wtfalch/email/postmaster`'s own
|
|
600
631
|
* `createMailbox`, resolving `org`'s domain to a `domainId` the same way
|
|
601
632
|
* `applyOrgMail` does -- that function already refuses a duplicate `local`
|
|
602
|
-
* on the resolved domain, so this port method never has to. `
|
|
633
|
+
* on the resolved domain, so this port method never has to. `options` is
|
|
634
|
+
* the same `{ apex, mailHost }` `applyOrgMail`/`parkOrgMail` take, needed
|
|
635
|
+
* for the same reason: `@wtfalch/email/postmaster`'s `createMailbox` takes
|
|
636
|
+
* it to resolve the shared mail apex (#335). `ensureOwnerMailbox`
|
|
603
637
|
* and `ensureRoleMailboxes` (#230) below are this executor's callers, and each checks its own recorded scope
|
|
604
638
|
* id first, so a second apply never calls this at all.
|
|
605
639
|
*/
|
|
606
640
|
createMailbox(org: OrgMailOrg, mailbox: {
|
|
607
641
|
address: string;
|
|
608
642
|
name: string;
|
|
609
|
-
}): Promise<void>;
|
|
643
|
+
}, options: OrgMailOptions): Promise<void>;
|
|
610
644
|
/**
|
|
611
645
|
* Upserts this org's own outbound relay route on Stalwart (#149): an
|
|
612
646
|
* `MtaRoute` through Lettermint, restricted to `org`'s domain, keyed by
|
|
@@ -617,25 +651,29 @@ export interface MailPort {
|
|
|
617
651
|
* minted (`ensureOrgLettermintRelay`'s own mint rule) -- it goes straight
|
|
618
652
|
* here and nowhere else: never returned, never recorded, never logged, the
|
|
619
653
|
* same discipline `ensureAppLettermintMail`'s own doc comment states for
|
|
620
|
-
* `LETTERMINT_API_TOKEN`.
|
|
654
|
+
* `LETTERMINT_API_TOKEN`. `org.domain` is required (#345): a level-1 org
|
|
655
|
+
* has none of its own on `OrgMailOrg`, so the caller passes the domain
|
|
656
|
+
* `applyOrgMail` already resolved for it.
|
|
621
657
|
*/
|
|
622
|
-
applyOrgRelay(org:
|
|
658
|
+
applyOrgRelay(org: OrgRelayMailOrg, secret: string): Promise<void>;
|
|
623
659
|
/**
|
|
624
660
|
* Drops this org's relay route (#149), implemented with
|
|
625
661
|
* `@wtfalch/email/postmaster`'s own `removeOrgRelay` -- a missing route is
|
|
626
662
|
* a no-op, so `parkLettermintMail` can call this unconditionally before it
|
|
627
|
-
* deletes the Lettermint project the route pointed at.
|
|
663
|
+
* deletes the Lettermint project the route pointed at. `org.domain` is
|
|
664
|
+
* required (#345), the same reason {@link MailPort.applyOrgRelay} needs it.
|
|
628
665
|
*/
|
|
629
|
-
removeOrgRelay(org:
|
|
666
|
+
removeOrgRelay(org: OrgRelayMailOrg): Promise<void>;
|
|
630
667
|
/**
|
|
631
668
|
* True once this org's relay route exists on Stalwart with a secret set
|
|
632
669
|
* (#149). `ensureOrgLettermintRelay`'s own mint rule reads this alongside
|
|
633
670
|
* Lettermint's own project listing: a project without a live route (the
|
|
634
671
|
* route recorded a token this executor never minted) is exactly as
|
|
635
672
|
* unhealthy as a route with no project behind it, so a fresh mint fixes
|
|
636
|
-
* either.
|
|
673
|
+
* either. `org.domain` is required (#345), the same reason
|
|
674
|
+
* {@link MailPort.applyOrgRelay} needs it.
|
|
637
675
|
*/
|
|
638
|
-
hasOrgRelay(org:
|
|
676
|
+
hasOrgRelay(org: OrgRelayMailOrg): Promise<boolean>;
|
|
639
677
|
}
|
|
640
678
|
/**
|
|
641
679
|
* The shared calls SFU's own per-org LiveKit API credentials (#122, #127,
|
|
@@ -1185,6 +1223,17 @@ export interface FoundryStepExecutorPorts {
|
|
|
1185
1223
|
* passes the same store instance it constructs `FoundryPlanApply` with.
|
|
1186
1224
|
*/
|
|
1187
1225
|
declaredOrgStore: Pick<DeclaredOrgStore, 'findOrgIdForZone'>;
|
|
1226
|
+
/**
|
|
1227
|
+
* #311: where an adoption keeps the env values of the live app it takes
|
|
1228
|
+
* over. Optional with `adoptionStore`: without both, the planner reports
|
|
1229
|
+
* adoption as blocked and plans no `coolify:adopt` step
|
|
1230
|
+
* (`FoundryPlanApplyOptions`).
|
|
1231
|
+
*/
|
|
1232
|
+
adoptionVault?: AdoptionVault;
|
|
1233
|
+
/** #311: the rows that hold the non-secret part of each snapshot. */
|
|
1234
|
+
adoptionStore?: AdoptionStore;
|
|
1235
|
+
/** #311: the clock of a snapshot's `takenAt`; the host passes `FoundryPlanApply`'s own. Defaults to the real clock. */
|
|
1236
|
+
now?: () => Date;
|
|
1188
1237
|
}
|
|
1189
1238
|
/**
|
|
1190
1239
|
* Thrown by name for a step this executor deliberately does not implement:
|
|
@@ -1355,6 +1404,49 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1355
1404
|
private ensureZitadelProject;
|
|
1356
1405
|
private ensureCoolifyProject;
|
|
1357
1406
|
private resolveServer;
|
|
1407
|
+
/** The live app Coolify holds under `uuid`. The error names the id only (rule 6). */
|
|
1408
|
+
private readLiveApp;
|
|
1409
|
+
/** The live ingress routes of the adopted tunnel `id`. The error names the id only (rule 6). */
|
|
1410
|
+
private readAdoptedIngress;
|
|
1411
|
+
/**
|
|
1412
|
+
* #311: for each adopted resource, the lines its `coolify:adopt` step shows
|
|
1413
|
+
* (a setting with old value beside new; an env var by name only) and what
|
|
1414
|
+
* is blocked. Read from the live app on every plan. No value of an env var
|
|
1415
|
+
* leaves this method (rule 6).
|
|
1416
|
+
*/
|
|
1417
|
+
adoptionReport(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): Promise<{
|
|
1418
|
+
details: Record<string, string[]>;
|
|
1419
|
+
blocked: UnmanagedItem[];
|
|
1420
|
+
withheld: string[];
|
|
1421
|
+
}>;
|
|
1422
|
+
/**
|
|
1423
|
+
* #311: puts each adopted app back as its snapshot recorded it, touching
|
|
1424
|
+
* only what foundry writes: the settings it patches and the env names it
|
|
1425
|
+
* owns (an edit a person made to anything else stays). Owned names the
|
|
1426
|
+
* snapshot has are written back with its value and flags; owned names it
|
|
1427
|
+
* lacks are deleted; preview rows are left alone. Every vault entry and
|
|
1428
|
+
* live read happens first, so a missing entry or a row that cannot be
|
|
1429
|
+
* deleted refuses before any Coolify write. Errors name ids only, and the
|
|
1430
|
+
* vault's env values go to `bulkUpsertEnvs` and nowhere else (rule 6).
|
|
1431
|
+
*/
|
|
1432
|
+
restoreAdoption(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, rows: readonly Adoption[]): Promise<{
|
|
1433
|
+
envDeleted: string[];
|
|
1434
|
+
tokenLeft: string[];
|
|
1435
|
+
}>;
|
|
1436
|
+
/**
|
|
1437
|
+
* #311: records what a live app or database looked like before foundry
|
|
1438
|
+
* first writes to it, in this order: the env values to the vault, the
|
|
1439
|
+
* non-secret settings to the row, then both read back and compared. The
|
|
1440
|
+
* step writes nothing to Coolify, so a snapshot that fails stops the apply
|
|
1441
|
+
* before any later step does. Every error names ids only (rule 6).
|
|
1442
|
+
*/
|
|
1443
|
+
private coolifyAdopt;
|
|
1444
|
+
/** #311: whether the resource was adopted: its row is `adopted` or `confirmed`, or `id` is a declared adopt uuid. */
|
|
1445
|
+
private isAdopted;
|
|
1446
|
+
/** #311: refuses to manage as foundry's own a resource whose row protects its key and whose declaration has no `adopt`. */
|
|
1447
|
+
private refuseTakeover;
|
|
1448
|
+
/** #311: refuses to write to a declared adopt uuid that has no active snapshot. */
|
|
1449
|
+
private requireSnapshot;
|
|
1358
1450
|
private coolifyApp;
|
|
1359
1451
|
/**
|
|
1360
1452
|
* #75: mounts a persistent volume at {@link LOG_DIR} on the app, unless one
|
|
@@ -1818,6 +1910,24 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1818
1910
|
/** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
|
|
1819
1911
|
private deliverWithRetry;
|
|
1820
1912
|
private cloudflareTunnel;
|
|
1913
|
+
/**
|
|
1914
|
+
* Runs `call`; for an adopted resource a failure that is not one of this
|
|
1915
|
+
* file's own messages (which name hostnames and record types only) is
|
|
1916
|
+
* replaced by `label`, because a client's error text carries response
|
|
1917
|
+
* bodies (rule 6, #312).
|
|
1918
|
+
*/
|
|
1919
|
+
private guarded;
|
|
1920
|
+
/**
|
|
1921
|
+
* #312: writes the org's routes into the adopted tunnel's LIVE ingress list
|
|
1922
|
+
* (`mergeIngress`), not the snapshot, so a route a person added since is
|
|
1923
|
+
* kept too. The declared names go into the tunnel row's `written` only after
|
|
1924
|
+
* the write that carried them succeeded, so a crash in between leaves a
|
|
1925
|
+
* route taken as foreign and kept. Writes nothing when the merged list is the
|
|
1926
|
+
* live one. Errors name the tunnel id and the operation only.
|
|
1927
|
+
*/
|
|
1928
|
+
private mergeAdoptedIngress;
|
|
1929
|
+
/** #312: replaces the `settings` of an adoption row, read fresh so no other key is lost. The state and `takenAt` stay. */
|
|
1930
|
+
private updateAdoptionSettings;
|
|
1821
1931
|
/**
|
|
1822
1932
|
* #236: when this tunnel's DNS was last published under a domain that
|
|
1823
1933
|
* differs from the one `org` now declares, deletes every CNAME in the
|