@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
package/dist/plan/executor.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
import { URL } from 'node:url';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
4
|
import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
|
|
4
5
|
import { APP_ENTRY_KEY, appEntry } from '../org.js';
|
|
6
|
+
import { adoptedAppPatch, adoptedConnectorKey, adoptedPatchDetails, adoptedResources, adoptedTunnelKey, adoptionVaultRef, declaredAdoptUuids, foreignRoutes, isActiveSnapshot, mergeIngress, normalizeHostname, protectsKey, removedNames, replacedNames, restoreAppPatch, restoreIngress, snapshotAppSettings, } from './adopt.js';
|
|
5
7
|
import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, agoraHostnameFor, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
6
8
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
7
9
|
import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
8
|
-
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
10
|
+
import { ensureCloudflaredConnector, } from './cloudflared-connector.js';
|
|
9
11
|
import { appHostname, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
10
12
|
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
11
13
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
12
|
-
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, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, } from './platform-diff.js';
|
|
14
|
+
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, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, } from './platform-diff.js';
|
|
13
15
|
/**
|
|
14
16
|
* #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
|
|
15
17
|
* listApps` -- a plain array, with no `loginBaseUri` or completeness signal
|
|
@@ -293,6 +295,34 @@ const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_a
|
|
|
293
295
|
* already follows.
|
|
294
296
|
*/
|
|
295
297
|
const CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY = 'cloudflare_tunnel_dns_domain';
|
|
298
|
+
/**
|
|
299
|
+
* #312: the ingress hostnames foundry wrote on an adopted tunnel, kept in the
|
|
300
|
+
* tunnel row's `settings.written` for the life of the snapshot, so foundry's
|
|
301
|
+
* routes are told from a person's by this record, never by guessing. A name is
|
|
302
|
+
* added only after the write that carried it succeeded: a crash in between
|
|
303
|
+
* leaves a route taken as foreign and kept.
|
|
304
|
+
*/
|
|
305
|
+
function writtenIngressNames(row) {
|
|
306
|
+
const written = row?.settings.written;
|
|
307
|
+
return new Set(Array.isArray(written) ? written.filter((n) => typeof n === 'string') : []);
|
|
308
|
+
}
|
|
309
|
+
/** The names the row says a put may have carried (`writing`): restore treats one as written if its live route is exactly foundry's. */
|
|
310
|
+
function writingIngressNames(row) {
|
|
311
|
+
const writing = row?.settings.writing;
|
|
312
|
+
return Array.isArray(writing) ? writing.filter((n) => typeof n === 'string') : [];
|
|
313
|
+
}
|
|
314
|
+
/** {@link CreatedDnsRecord}s of the tunnel row's `settings`, ignoring any entry of another shape. */
|
|
315
|
+
function createdDnsRecords(settings) {
|
|
316
|
+
const list = settings?.dnsRecords;
|
|
317
|
+
return (Array.isArray(list) ? list : []).filter((r) => typeof r?.zoneId === 'string' && typeof r?.id === 'string' && typeof r?.hostname === 'string');
|
|
318
|
+
}
|
|
319
|
+
/** The hostnames the tunnel row says foundry is about to create a record for. */
|
|
320
|
+
const dnsCreatingNames = (settings) => {
|
|
321
|
+
const list = settings?.dnsCreating;
|
|
322
|
+
return (Array.isArray(list) ? list : []).filter((n) => typeof n === 'string');
|
|
323
|
+
};
|
|
324
|
+
/** The live `TUNNEL_TOKEN` row of a connector app, if Coolify lists one (#312). */
|
|
325
|
+
const tunnelTokenRows = (envs) => envs.filter((e) => e.key === 'TUNNEL_TOKEN' && !e.is_preview);
|
|
296
326
|
/**
|
|
297
327
|
* #236 round 3: every hostname {@link FoundryStepExecutor.publishTunnelDnsRecords}
|
|
298
328
|
* last wrote a CNAME for, joined by `,` (hostnames carry no `,`, the same
|
|
@@ -526,6 +556,100 @@ export function isRunningOrStarting(status) {
|
|
|
526
556
|
return true;
|
|
527
557
|
return !status.startsWith('exited') && !status.startsWith('stopped');
|
|
528
558
|
}
|
|
559
|
+
/**
|
|
560
|
+
* The env names `pushAppEnv` writes from what the plan already holds, before
|
|
561
|
+
* the cookie secret and the entry's declared `env`. `coolifyApp` and an
|
|
562
|
+
* adoption's plan lines read the same list, so what an adopted app is told
|
|
563
|
+
* "will change" is what the step writes (#311).
|
|
564
|
+
*/
|
|
565
|
+
function ownedAppEnv(org, entry, recorded, authIssuer = null) {
|
|
566
|
+
const envs = [
|
|
567
|
+
// Only the `app` entry has a public name; the others are internal.
|
|
568
|
+
...(entry.key === APP_ENTRY_KEY
|
|
569
|
+
? [{ key: 'APP_URL', value: `https://${appHostname(org)}` }]
|
|
570
|
+
: []),
|
|
571
|
+
// #75: read back by the package-side pino sink (not this repo's job,
|
|
572
|
+
// #75 "What belongs where") once `ensureLogVolume` has mounted the
|
|
573
|
+
// matching volume.
|
|
574
|
+
{ key: 'LOG_DIR', value: LOG_DIR },
|
|
575
|
+
];
|
|
576
|
+
if (entry.uses?.auth) {
|
|
577
|
+
// F5: an entry that signs in has its own client, organisation and issuer.
|
|
578
|
+
for (const [key, value] of Object.entries(entryAuthEnv(org, entry, recorded, authIssuer))) {
|
|
579
|
+
envs.push({ key, value });
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
const zitadelOrgId = recorded.zitadel_org;
|
|
584
|
+
if (zitadelOrgId)
|
|
585
|
+
envs.push({ key: 'AUTH_ORGANIZATION_ID', value: zitadelOrgId });
|
|
586
|
+
const clientId = recorded[APP_CLIENT_ID_SCOPE_KEY];
|
|
587
|
+
if (clientId)
|
|
588
|
+
envs.push({ key: 'AUTH_CLIENT_ID', value: clientId });
|
|
589
|
+
}
|
|
590
|
+
// The invited owner's ZITADEL user id (gap P3): the app uses it to make
|
|
591
|
+
// them its own owner in authz on first sign-in. `runSteps` records this
|
|
592
|
+
// under the same `system:kind:key` shape every step's `resourceId` uses
|
|
593
|
+
// (`zitadelOwner`'s own doc comment), not a scope id.
|
|
594
|
+
const ownerUserId = recorded[`zitadel:owner:${org.id}`];
|
|
595
|
+
if (ownerUserId)
|
|
596
|
+
envs.push({ key: 'OWNER_USER_ID', value: ownerUserId });
|
|
597
|
+
return envs;
|
|
598
|
+
}
|
|
599
|
+
/** Every env name foundry writes on an adopted app: what restore may touch (#311). */
|
|
600
|
+
function ownedEnvNames(org, entry, recorded) {
|
|
601
|
+
return new Set([
|
|
602
|
+
...ownedAppEnv(org, entry, recorded).map((e) => e.key),
|
|
603
|
+
...Object.keys(entry.env),
|
|
604
|
+
'AUTH_COOKIE_SECRET',
|
|
605
|
+
// An entry that signs in also gets the issuer and a minted broker key.
|
|
606
|
+
...(entry.uses?.auth ? ['AUTH_ISSUER', 'AUTH_APP_KEY'] : []),
|
|
607
|
+
]);
|
|
608
|
+
}
|
|
609
|
+
/** Owned names whose live non-preview row Coolify hid: no value to snapshot (#311). */
|
|
610
|
+
function hiddenOwnedEnv(owned, envs) {
|
|
611
|
+
return envs
|
|
612
|
+
.filter((e) => !e.is_preview && owned.has(e.key) && typeof e.value !== 'string')
|
|
613
|
+
.map((e) => e.key);
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* The app uuid and the env rows a snapshot's vault entry holds, for restore.
|
|
617
|
+
* Refuses, naming the resource only, when the uuid or the entry is missing or
|
|
618
|
+
* the entry is not a list of env rows (#311).
|
|
619
|
+
*/
|
|
620
|
+
async function readEnvSnapshot(vault, row) {
|
|
621
|
+
const { uuid } = row.settings;
|
|
622
|
+
const { vaultRef } = row;
|
|
623
|
+
const document = vaultRef && typeof uuid === 'string'
|
|
624
|
+
? await restoreStep(row.resourceKey, uuid, 'vault read', () => vault.resolve(vaultRef))
|
|
625
|
+
: null;
|
|
626
|
+
if (typeof uuid !== 'string' || document === null) {
|
|
627
|
+
throw new Error(`restore ${row.resourceKey}: the snapshot's vault entry or app uuid is missing; nothing was changed`);
|
|
628
|
+
}
|
|
629
|
+
let snapshot;
|
|
630
|
+
try {
|
|
631
|
+
snapshot = JSON.parse(document);
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
throw new Error(`restore ${row.resourceKey}: the vault entry is unreadable; nothing was changed`);
|
|
635
|
+
}
|
|
636
|
+
if (!Array.isArray(snapshot) ||
|
|
637
|
+
!snapshot.every((r) => r !== null && typeof r === 'object' && typeof r.key === 'string')) {
|
|
638
|
+
throw new Error(`restore ${row.resourceKey}: the vault entry is not a list of env rows; nothing was changed`);
|
|
639
|
+
}
|
|
640
|
+
return { uuid, envs: snapshot };
|
|
641
|
+
}
|
|
642
|
+
/** A sha256 hex digest: what the row keeps in place of a token. */
|
|
643
|
+
const sha256Hex = (value) => createHash('sha256').update(value).digest('hex');
|
|
644
|
+
/** Runs one restore call; a failure names the resource and the operation, never the cause (rule 6). */
|
|
645
|
+
async function restoreStep(resourceKey, uuid, operation, call) {
|
|
646
|
+
try {
|
|
647
|
+
return await call();
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
throw new Error(`restore ${resourceKey} (${uuid}): ${operation} failed`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
529
653
|
// ---------------------------------------------------------------------------
|
|
530
654
|
// Executor
|
|
531
655
|
// ---------------------------------------------------------------------------
|
|
@@ -547,6 +671,8 @@ export class FoundryStepExecutor {
|
|
|
547
671
|
return this.zitadelApp(action, org, context);
|
|
548
672
|
case 'zitadel:customers':
|
|
549
673
|
return this.zitadelCustomers(org, context);
|
|
674
|
+
case 'coolify:adopt':
|
|
675
|
+
return this.coolifyAdopt(action, org, context.recordedIds());
|
|
550
676
|
case 'coolify:app':
|
|
551
677
|
return this.coolifyApp(action, org, context);
|
|
552
678
|
case 'coolify:broker-entry':
|
|
@@ -1088,29 +1214,521 @@ export class FoundryStepExecutor {
|
|
|
1088
1214
|
throw new Error('no usable Coolify server is registered on this instance');
|
|
1089
1215
|
return server;
|
|
1090
1216
|
}
|
|
1217
|
+
/** The live app Coolify holds under `uuid`. The error names the id only (rule 6). */
|
|
1218
|
+
async readLiveApp(uuid) {
|
|
1219
|
+
try {
|
|
1220
|
+
return (await this.ports.coolify.raw('GET', `/applications/${uuid}`));
|
|
1221
|
+
}
|
|
1222
|
+
catch {
|
|
1223
|
+
throw new Error(`adopt: the live app ${uuid} could not be read`);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
/** The live ingress routes of the adopted tunnel `id`. The error names the id only (rule 6). */
|
|
1227
|
+
async readAdoptedIngress(id) {
|
|
1228
|
+
try {
|
|
1229
|
+
const { config } = await this.ports.cloudflare.getTunnelConfig(id);
|
|
1230
|
+
const ingress = config?.ingress ?? [];
|
|
1231
|
+
if (!Array.isArray(ingress))
|
|
1232
|
+
throw new Error('not a list');
|
|
1233
|
+
return ingress;
|
|
1234
|
+
}
|
|
1235
|
+
catch {
|
|
1236
|
+
throw new Error(`adopt: the live tunnel ${id} could not be read`);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* #311: for each adopted resource, the lines its `coolify:adopt` step shows
|
|
1241
|
+
* (a setting with old value beside new; an env var by name only) and what
|
|
1242
|
+
* is blocked. Read from the live app on every plan. No value of an env var
|
|
1243
|
+
* leaves this method (rule 6).
|
|
1244
|
+
*/
|
|
1245
|
+
async adoptionReport(org, recorded) {
|
|
1246
|
+
const details = {};
|
|
1247
|
+
const blocked = [];
|
|
1248
|
+
const withheld = [];
|
|
1249
|
+
for (const resource of adoptedResources(org)) {
|
|
1250
|
+
// A uuid that cannot be read blocks that resource, not the whole plan (rule 2).
|
|
1251
|
+
let live = {};
|
|
1252
|
+
let envs = [];
|
|
1253
|
+
let routes = [];
|
|
1254
|
+
try {
|
|
1255
|
+
if (resource.kind === 'database')
|
|
1256
|
+
await this.ports.coolify.getDatabase(resource.uuid);
|
|
1257
|
+
else if (resource.kind === 'tunnel')
|
|
1258
|
+
routes = await this.readAdoptedIngress(resource.uuid);
|
|
1259
|
+
else {
|
|
1260
|
+
live = await this.readLiveApp(resource.uuid);
|
|
1261
|
+
envs = await this.ports.coolify.listEnvs(resource.uuid);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
catch {
|
|
1265
|
+
withheld.push(resource.resourceKey);
|
|
1266
|
+
blocked.push({
|
|
1267
|
+
description: `${org.slug}'s adoption of ${resource.resourceKey} is blocked, not planned: the live resource ${resource.uuid} could not be read`,
|
|
1268
|
+
key: `coolify:adopt:${resource.resourceKey}`,
|
|
1269
|
+
});
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
if (resource.kind === 'database') {
|
|
1273
|
+
details[resource.resourceKey] = [
|
|
1274
|
+
'the database is taken over as it is; nothing in it changes',
|
|
1275
|
+
];
|
|
1276
|
+
continue;
|
|
1277
|
+
}
|
|
1278
|
+
if (resource.kind === 'tunnel') {
|
|
1279
|
+
// Hostnames only: a service address may carry credentials (rule 6).
|
|
1280
|
+
const declared = tunnelIngressHostnames(org);
|
|
1281
|
+
const row = await this.ports.adoptionStore?.get(org.id, adoptedTunnelKey(org));
|
|
1282
|
+
const written = new Set([...writtenIngressNames(row), ...writingIngressNames(row)]);
|
|
1283
|
+
const kept = foreignRoutes(routes, declared, COOLIFY_PROXY_ORIGIN, written);
|
|
1284
|
+
details[resource.resourceKey] = [
|
|
1285
|
+
...kept
|
|
1286
|
+
.filter((r) => r.hostname !== undefined)
|
|
1287
|
+
.map((r) => `kept, not managed: ${r.hostname}`),
|
|
1288
|
+
...(kept.some((r) => r.hostname === undefined)
|
|
1289
|
+
? ['kept, not managed: (no hostname)']
|
|
1290
|
+
: []),
|
|
1291
|
+
...replacedNames(routes, declared, COOLIFY_PROXY_ORIGIN).map((h) => `will replace: ${h}`),
|
|
1292
|
+
...removedNames(routes, declared, COOLIFY_PROXY_ORIGIN, written).map((h) => `will remove: ${h}`),
|
|
1293
|
+
];
|
|
1294
|
+
continue;
|
|
1295
|
+
}
|
|
1296
|
+
if (resource.kind === 'connector') {
|
|
1297
|
+
if (hiddenOwnedEnv(new Set(['TUNNEL_TOKEN']), envs).length > 0) {
|
|
1298
|
+
withheld.push(resource.resourceKey);
|
|
1299
|
+
blocked.push({
|
|
1300
|
+
description: `${org.slug}'s adoption of ${resource.resourceKey} is blocked, not planned: Coolify hides the value of TUNNEL_TOKEN, so it cannot be snapshotted`,
|
|
1301
|
+
key: `coolify:adopt:${resource.resourceKey}`,
|
|
1302
|
+
});
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
details[resource.resourceKey] = [];
|
|
1306
|
+
// The token is compared and never shown; a failed read adds no line.
|
|
1307
|
+
try {
|
|
1308
|
+
const token = await this.ports.cloudflare.getTunnelToken(org.hosting.adopt?.tunnel ?? '');
|
|
1309
|
+
if (tunnelTokenRows(envs).every((e) => e.value !== token)) {
|
|
1310
|
+
details[resource.resourceKey]?.push('will change: TUNNEL_TOKEN');
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
catch { }
|
|
1314
|
+
continue;
|
|
1315
|
+
}
|
|
1316
|
+
const { entry } = resource;
|
|
1317
|
+
const hidden = hiddenOwnedEnv(ownedEnvNames(org, entry, recorded), envs);
|
|
1318
|
+
if (hidden.length > 0) {
|
|
1319
|
+
withheld.push(resource.resourceKey);
|
|
1320
|
+
blocked.push({
|
|
1321
|
+
description: `${org.slug}'s adoption of ${resource.resourceKey} is blocked, not planned: Coolify hides the value of ${hidden.sort().join(', ')}, so it cannot be snapshotted`,
|
|
1322
|
+
key: `coolify:adopt:${resource.resourceKey}`,
|
|
1323
|
+
});
|
|
1324
|
+
continue;
|
|
1325
|
+
}
|
|
1326
|
+
const liveEnv = new Map(envs.filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
|
|
1327
|
+
const owned = new Map([
|
|
1328
|
+
...ownedAppEnv(org, entry, recorded).map((e) => [e.key, e.value]),
|
|
1329
|
+
...Object.entries(resolveAppEnv(org, entry, recorded).values),
|
|
1330
|
+
]);
|
|
1331
|
+
const lines = adoptedPatchDetails(adoptedAppPatch(entry, entryHostnames(org, entry), live), live);
|
|
1332
|
+
for (const [key, value] of owned) {
|
|
1333
|
+
if (!liveEnv.has(key))
|
|
1334
|
+
lines.push(`will add: ${key}`);
|
|
1335
|
+
else if (liveEnv.get(key) !== value)
|
|
1336
|
+
lines.push(`will change: ${key}`);
|
|
1337
|
+
}
|
|
1338
|
+
// The issuer's value is read at apply time, so only its presence shows here.
|
|
1339
|
+
if (entry.uses?.auth) {
|
|
1340
|
+
for (const key of ['AUTH_ISSUER', 'AUTH_APP_KEY']) {
|
|
1341
|
+
owned.set(key, liveEnv.get(key) ?? '');
|
|
1342
|
+
if (!liveEnv.has(key))
|
|
1343
|
+
lines.push(`will add: ${key}`);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
if (!liveEnv.has('AUTH_COOKIE_SECRET') && !owned.has('AUTH_COOKIE_SECRET')) {
|
|
1347
|
+
lines.push('will add: AUTH_COOKIE_SECRET');
|
|
1348
|
+
}
|
|
1349
|
+
for (const key of [...liveEnv.keys()].sort()) {
|
|
1350
|
+
if (!owned.has(key) && key !== 'AUTH_COOKIE_SECRET')
|
|
1351
|
+
lines.push(`not managed: ${key}`);
|
|
1352
|
+
}
|
|
1353
|
+
details[resource.resourceKey] = lines;
|
|
1354
|
+
// Choice A of #311's "Decide": an adopted app with no runtime role
|
|
1355
|
+
// address is reported, never given one.
|
|
1356
|
+
if (entry.database) {
|
|
1357
|
+
const address = liveEnv.get('DATABASE_URL');
|
|
1358
|
+
const ownerAddresses = [liveEnv.get('DATABASE_URL_OWNER')];
|
|
1359
|
+
const databaseUuid = org.hosting.adopt?.database;
|
|
1360
|
+
if (databaseUuid) {
|
|
1361
|
+
ownerAddresses.push(await this.ports.coolify.getDatabase(databaseUuid).then((db) => db.internal_db_url, () => undefined));
|
|
1362
|
+
}
|
|
1363
|
+
if (!address || ownerAddresses.includes(address)) {
|
|
1364
|
+
withheld.push(resource.resourceKey);
|
|
1365
|
+
const label = entry.key === APP_ENTRY_KEY ? 'app' : `${entry.key} app`;
|
|
1366
|
+
blocked.push({
|
|
1367
|
+
description: `${org.slug}'s ${label} runtime database address is blocked, not planned: DATABASE_URL is missing or is the owner address, so set DATABASE_URL on the app; foundry leaves its env alone`,
|
|
1368
|
+
key: entry.key === APP_ENTRY_KEY
|
|
1369
|
+
? 'coolify:app:database-url'
|
|
1370
|
+
: `coolify:app:${entry.key}:database-url`,
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
return { details, blocked, withheld };
|
|
1376
|
+
}
|
|
1377
|
+
/**
|
|
1378
|
+
* #311: puts each adopted app back as its snapshot recorded it, touching
|
|
1379
|
+
* only what foundry writes: the settings it patches and the env names it
|
|
1380
|
+
* owns (an edit a person made to anything else stays). Owned names the
|
|
1381
|
+
* snapshot has are written back with its value and flags; owned names it
|
|
1382
|
+
* lacks are deleted; preview rows are left alone. Every vault entry and
|
|
1383
|
+
* live read happens first, so a missing entry or a row that cannot be
|
|
1384
|
+
* deleted refuses before any Coolify write. Errors name ids only, and the
|
|
1385
|
+
* vault's env values go to `bulkUpsertEnvs` and nowhere else (rule 6).
|
|
1386
|
+
*/
|
|
1387
|
+
async restoreAdoption(org, recorded, rows) {
|
|
1388
|
+
const vault = this.ports.adoptionVault;
|
|
1389
|
+
if (!vault)
|
|
1390
|
+
throw new Error(`restore for ${org.slug}: no adoption vault is wired`);
|
|
1391
|
+
const apps = [];
|
|
1392
|
+
for (const row of rows.filter((r) => r.kind === 'app')) {
|
|
1393
|
+
// The row names the app: a declaration that changed or went away since
|
|
1394
|
+
// does not move the restore onto another app.
|
|
1395
|
+
const { uuid, envs: snapshot } = await readEnvSnapshot(vault, row);
|
|
1396
|
+
const entry = org.hosting.entries.find((e) => `coolify:app:${appStepKey(org, e.key)}` === row.resourceKey);
|
|
1397
|
+
// Without an entry, the names foundry writes with no declaration.
|
|
1398
|
+
const owned = ownedEnvNames(org, entry ?? {
|
|
1399
|
+
key: row.resourceKey.slice(`coolify:app:${org.id}:`.length),
|
|
1400
|
+
env: {},
|
|
1401
|
+
uses: undefined,
|
|
1402
|
+
}, recorded);
|
|
1403
|
+
const old = snapshot.filter((e) => !e.is_preview);
|
|
1404
|
+
const oldNames = new Set(old.map((e) => e.key));
|
|
1405
|
+
const live = (await restoreStep(row.resourceKey, uuid, 'env read', () => this.ports.coolify.listEnvs(uuid))).filter((e) => !e.is_preview);
|
|
1406
|
+
const liveApp = await restoreStep(row.resourceKey, uuid, 'settings read', () => this.readLiveApp(uuid));
|
|
1407
|
+
const patch = restoreAppPatch(entry, entry ? entryHostnames(org, entry) : [], row.settings, liveApp);
|
|
1408
|
+
const upserts = [];
|
|
1409
|
+
for (const e of old.filter((r) => owned.has(r.key))) {
|
|
1410
|
+
if (typeof e.value !== 'string') {
|
|
1411
|
+
throw new Error(`restore ${row.resourceKey}: the snapshot has no value for ${e.key}; nothing was changed`);
|
|
1412
|
+
}
|
|
1413
|
+
upserts.push({
|
|
1414
|
+
key: e.key,
|
|
1415
|
+
value: e.value,
|
|
1416
|
+
buildTime: Boolean(e.is_buildtime),
|
|
1417
|
+
literal: Boolean(e.is_literal),
|
|
1418
|
+
multiline: Boolean(e.is_multiline),
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
const deletes = [];
|
|
1422
|
+
for (const e of live.filter((l) => owned.has(l.key) && !oldNames.has(l.key))) {
|
|
1423
|
+
if (!e.uuid) {
|
|
1424
|
+
throw new Error(`restore ${row.resourceKey}: the live env ${e.key} has no uuid, so it is not deleted; nothing was changed`);
|
|
1425
|
+
}
|
|
1426
|
+
deletes.push({ key: e.key, uuid: e.uuid });
|
|
1427
|
+
}
|
|
1428
|
+
apps.push({ row, uuid, patch, upserts, deletes });
|
|
1429
|
+
}
|
|
1430
|
+
// #312: the tunnel's ingress with foundry's routes swapped for the snapshot's,
|
|
1431
|
+
// read live now so a route a person added since stays.
|
|
1432
|
+
const cf = this.ports.cloudflare;
|
|
1433
|
+
const tunnels = [];
|
|
1434
|
+
for (const row of rows.filter((r) => r.kind === 'tunnel')) {
|
|
1435
|
+
const { uuid: id, ingress } = row.settings;
|
|
1436
|
+
if (typeof id !== 'string' || !Array.isArray(ingress)) {
|
|
1437
|
+
throw new Error(`restore ${row.resourceKey}: the snapshot's tunnel id or ingress is missing; nothing was changed`);
|
|
1438
|
+
}
|
|
1439
|
+
const { config } = await restoreStep(row.resourceKey, id, 'ingress read', () => cf.getTunnelConfig(id));
|
|
1440
|
+
const live = config?.ingress ?? [];
|
|
1441
|
+
// The names the row says foundry wrote: the declaration may have changed or gone since.
|
|
1442
|
+
const written = new Set([...writtenIngressNames(row), ...writingIngressNames(row)]);
|
|
1443
|
+
const restored = restoreIngress(live, ingress, COOLIFY_PROXY_ORIGIN, written);
|
|
1444
|
+
const swapped = removedNames(live, [], COOLIFY_PROXY_ORIGIN, written).map(normalizeHostname);
|
|
1445
|
+
// A name with a live route that is not exactly foundry's keeps its CNAME, or that route stops resolving.
|
|
1446
|
+
const leftAlone = (hostname) => live.filter((r) => r.hostname !== undefined &&
|
|
1447
|
+
normalizeHostname(r.hostname) === normalizeHostname(hostname)).length > (swapped.includes(normalizeHostname(hostname)) ? 1 : 0);
|
|
1448
|
+
tunnels.push({
|
|
1449
|
+
row,
|
|
1450
|
+
id,
|
|
1451
|
+
config: isDeepStrictEqual(restored, live) ? null : { ...config, ingress: restored },
|
|
1452
|
+
dnsRecords: createdDnsRecords(row.settings).filter((r) => !leftAlone(r.hostname)),
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
// #312: the old TUNNEL_TOKEN back on the connector; a token the snapshot lacks is deleted.
|
|
1456
|
+
// Only a live token that is the one foundry wrote (its digest is in the row) is changed.
|
|
1457
|
+
const tokenLeft = [];
|
|
1458
|
+
const connectors = [];
|
|
1459
|
+
for (const row of rows.filter((r) => r.kind === 'connector')) {
|
|
1460
|
+
const { uuid, envs: snapshot } = await readEnvSnapshot(vault, row);
|
|
1461
|
+
const [old] = tunnelTokenRows(snapshot);
|
|
1462
|
+
const [live] = tunnelTokenRows(await restoreStep(row.resourceKey, uuid, 'env read', () => this.ports.coolify.listEnvs(uuid)));
|
|
1463
|
+
if (old && typeof old.value !== 'string') {
|
|
1464
|
+
throw new Error(`restore ${row.resourceKey}: the snapshot has no value for TUNNEL_TOKEN; nothing was changed`);
|
|
1465
|
+
}
|
|
1466
|
+
const foundrys = row.settings.tokenWritten === true &&
|
|
1467
|
+
typeof live?.value === 'string' &&
|
|
1468
|
+
row.settings.tokenDigest === sha256Hex(live.value);
|
|
1469
|
+
if (old && live?.value !== old.value && foundrys) {
|
|
1470
|
+
connectors.push({
|
|
1471
|
+
row,
|
|
1472
|
+
uuid,
|
|
1473
|
+
upsert: {
|
|
1474
|
+
key: 'TUNNEL_TOKEN',
|
|
1475
|
+
value: old.value ?? '',
|
|
1476
|
+
buildTime: Boolean(old.is_buildtime),
|
|
1477
|
+
literal: Boolean(old.is_literal),
|
|
1478
|
+
multiline: Boolean(old.is_multiline),
|
|
1479
|
+
},
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
else if (!old && live && foundrys) {
|
|
1483
|
+
if (!live.uuid) {
|
|
1484
|
+
throw new Error(`restore ${row.resourceKey}: the live TUNNEL_TOKEN has no uuid, so it is not deleted; nothing was changed`);
|
|
1485
|
+
}
|
|
1486
|
+
connectors.push({ row, uuid, del: { uuid: live.uuid } });
|
|
1487
|
+
}
|
|
1488
|
+
else if (live &&
|
|
1489
|
+
!foundrys &&
|
|
1490
|
+
row.settings.tokenWritten === true &&
|
|
1491
|
+
(!old || live.value !== old.value)) {
|
|
1492
|
+
tokenLeft.push(row.resourceKey);
|
|
1493
|
+
}
|
|
1494
|
+
else if (old && !live && row.settings.tokenWritten === true) {
|
|
1495
|
+
// A person deleted the token: a redeploy would start the connector with none.
|
|
1496
|
+
tokenLeft.push(row.resourceKey);
|
|
1497
|
+
}
|
|
1498
|
+
else if (row.settings.tokenWritten === true) {
|
|
1499
|
+
// Foundry rewrote the token: a retry after a failed deploy still deploys.
|
|
1500
|
+
connectors.push({ row, uuid });
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
const envDeleted = [];
|
|
1504
|
+
for (const { row, uuid, patch, upserts, deletes } of apps) {
|
|
1505
|
+
const key = row.resourceKey;
|
|
1506
|
+
if (Object.keys(patch).length > 0) {
|
|
1507
|
+
await restoreStep(key, uuid, 'settings update', () => this.ports.coolify.updateApplication(uuid, patch));
|
|
1508
|
+
}
|
|
1509
|
+
if (upserts.length > 0) {
|
|
1510
|
+
await restoreStep(key, uuid, 'env write', () => this.ports.coolify.bulkUpsertEnvs(uuid, upserts));
|
|
1511
|
+
}
|
|
1512
|
+
for (const del of deletes) {
|
|
1513
|
+
await restoreStep(key, uuid, 'env delete', () => this.ports.coolify.raw('DELETE', `/applications/${uuid}/envs/${del.uuid}`));
|
|
1514
|
+
if (!envDeleted.includes(del.key))
|
|
1515
|
+
envDeleted.push(del.key);
|
|
1516
|
+
}
|
|
1517
|
+
// A container reads env and domains only at start, a folder only at build (rule 4).
|
|
1518
|
+
// Decided from the snapshot, not from live: a retry after a failed deploy
|
|
1519
|
+
// finds live already written and must still deploy.
|
|
1520
|
+
if (Object.keys(patch).length > 0) {
|
|
1521
|
+
await restoreStep(key, uuid, 'deploy', () => this.ports.coolify.deploy(uuid));
|
|
1522
|
+
}
|
|
1523
|
+
else {
|
|
1524
|
+
await restoreStep(key, uuid, 'restart', () => this.ports.coolify.restart(uuid));
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
for (const { row, id, config, dnsRecords } of tunnels) {
|
|
1528
|
+
if (config) {
|
|
1529
|
+
await restoreStep(row.resourceKey, id, 'ingress write', () => cf.putTunnelConfig(id, config));
|
|
1530
|
+
}
|
|
1531
|
+
// Only the records foundry created, and only while each is still a CNAME
|
|
1532
|
+
// to this tunnel: one a person repointed, or one already gone, is left.
|
|
1533
|
+
for (const record of dnsRecords) {
|
|
1534
|
+
await restoreStep(row.resourceKey, id, 'dns delete', async () => {
|
|
1535
|
+
const live = (await cf.findDnsRecord(record.zoneId, record.hostname, 'CNAME')).find((r) => r.id === record.id);
|
|
1536
|
+
if (live?.content !== `${id}.cfargotunnel.com`)
|
|
1537
|
+
return;
|
|
1538
|
+
try {
|
|
1539
|
+
await cf.deleteDnsRecord(record.zoneId, record.id);
|
|
1540
|
+
}
|
|
1541
|
+
catch (error) {
|
|
1542
|
+
if (!isNotFoundError(error))
|
|
1543
|
+
throw error;
|
|
1544
|
+
}
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
for (const { row, uuid, upsert, del } of connectors) {
|
|
1549
|
+
const key = row.resourceKey;
|
|
1550
|
+
if (upsert) {
|
|
1551
|
+
await restoreStep(key, uuid, 'env write', () => this.ports.coolify.bulkUpsertEnvs(uuid, [upsert]));
|
|
1552
|
+
}
|
|
1553
|
+
if (del) {
|
|
1554
|
+
await restoreStep(key, uuid, 'env delete', () => this.ports.coolify.raw('DELETE', `/applications/${uuid}/envs/${del.uuid}`));
|
|
1555
|
+
}
|
|
1556
|
+
// The container reads its token only at start.
|
|
1557
|
+
await restoreStep(key, uuid, 'deploy', () => this.ports.coolify.deploy(uuid));
|
|
1558
|
+
}
|
|
1559
|
+
return { envDeleted, tokenLeft };
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* #311: records what a live app or database looked like before foundry
|
|
1563
|
+
* first writes to it, in this order: the env values to the vault, the
|
|
1564
|
+
* non-secret settings to the row, then both read back and compared. The
|
|
1565
|
+
* step writes nothing to Coolify, so a snapshot that fails stops the apply
|
|
1566
|
+
* before any later step does. Every error names ids only (rule 6).
|
|
1567
|
+
*/
|
|
1568
|
+
async coolifyAdopt(action, org, recorded) {
|
|
1569
|
+
const { adoptionVault: vault, adoptionStore: store } = this.ports;
|
|
1570
|
+
if (!vault || !store) {
|
|
1571
|
+
throw new Error(`coolify:adopt for ${org.slug}: no adoption vault is wired`);
|
|
1572
|
+
}
|
|
1573
|
+
const resource = adoptedResources(org).find((r) => r.resourceKey === action.key);
|
|
1574
|
+
if (!resource)
|
|
1575
|
+
throw new Error(`coolify:adopt for ${org.slug}: nothing declared for ${action.key}`);
|
|
1576
|
+
// Snapshotting another uuid now would record foundry's own earlier writes.
|
|
1577
|
+
const held = await store.get(org.id, action.key);
|
|
1578
|
+
if (held?.state === 'adopted' && held.settings.uuid !== resource.uuid) {
|
|
1579
|
+
throw new Error(`coolify:adopt ${action.key}: restore the adopted ${resource.kind} first (${String(held.settings.uuid)} is adopted, not ${resource.uuid}); nothing was changed`);
|
|
1580
|
+
}
|
|
1581
|
+
// Another key's snapshot of this uuid holds the original; a second one would record foundry's own writes.
|
|
1582
|
+
const holder = (await store.list(org.id)).find((r) => r.resourceKey !== action.key && protectsKey(r) && r.settings.uuid === resource.uuid);
|
|
1583
|
+
if (holder) {
|
|
1584
|
+
throw new Error(`coolify:adopt ${action.key}: ${resource.uuid} is already adopted under ${holder.resourceKey}; nothing was changed`);
|
|
1585
|
+
}
|
|
1586
|
+
let settings = { uuid: resource.uuid };
|
|
1587
|
+
let vaultRef = null;
|
|
1588
|
+
let envDocument = '';
|
|
1589
|
+
if (resource.kind === 'app') {
|
|
1590
|
+
settings = {
|
|
1591
|
+
uuid: resource.uuid,
|
|
1592
|
+
...snapshotAppSettings(await this.readLiveApp(resource.uuid)),
|
|
1593
|
+
};
|
|
1594
|
+
vaultRef = adoptionVaultRef(org, resource);
|
|
1595
|
+
let envs;
|
|
1596
|
+
try {
|
|
1597
|
+
envs = await this.ports.coolify.listEnvs(resource.uuid);
|
|
1598
|
+
}
|
|
1599
|
+
catch {
|
|
1600
|
+
throw new Error(`adopt: the env of the live app ${resource.uuid} could not be read`);
|
|
1601
|
+
}
|
|
1602
|
+
const hidden = hiddenOwnedEnv(ownedEnvNames(org, resource.entry, recorded), envs);
|
|
1603
|
+
if (hidden.length > 0) {
|
|
1604
|
+
throw new Error(`coolify:adopt ${action.key}: Coolify hides the value of ${hidden.sort().join(', ')}; nothing was changed`);
|
|
1605
|
+
}
|
|
1606
|
+
envDocument = JSON.stringify(envs);
|
|
1607
|
+
}
|
|
1608
|
+
else if (resource.kind === 'tunnel') {
|
|
1609
|
+
settings = {
|
|
1610
|
+
uuid: resource.uuid,
|
|
1611
|
+
ingress: await this.readAdoptedIngress(resource.uuid),
|
|
1612
|
+
written: [],
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
else if (resource.kind === 'connector') {
|
|
1616
|
+
await this.readLiveApp(resource.uuid);
|
|
1617
|
+
vaultRef = adoptionVaultRef(org, resource);
|
|
1618
|
+
let envs;
|
|
1619
|
+
try {
|
|
1620
|
+
envs = await this.ports.coolify.listEnvs(resource.uuid);
|
|
1621
|
+
}
|
|
1622
|
+
catch {
|
|
1623
|
+
throw new Error(`adopt: the env of the live app ${resource.uuid} could not be read`);
|
|
1624
|
+
}
|
|
1625
|
+
if (hiddenOwnedEnv(new Set(['TUNNEL_TOKEN']), envs).length > 0) {
|
|
1626
|
+
throw new Error(`coolify:adopt ${action.key}: Coolify hides the value of TUNNEL_TOKEN; nothing was changed`);
|
|
1627
|
+
}
|
|
1628
|
+
// The old token is a secret: it goes to the vault, never to `settings`.
|
|
1629
|
+
envDocument = JSON.stringify(tunnelTokenRows(envs));
|
|
1630
|
+
}
|
|
1631
|
+
let sealed = false;
|
|
1632
|
+
let written = false;
|
|
1633
|
+
try {
|
|
1634
|
+
if (vaultRef)
|
|
1635
|
+
await vault.put(vaultRef, envDocument);
|
|
1636
|
+
await store.put({
|
|
1637
|
+
orgId: org.id,
|
|
1638
|
+
resourceKey: action.key,
|
|
1639
|
+
kind: resource.kind,
|
|
1640
|
+
takenAt: (this.ports.now?.() ?? new Date()).toISOString(),
|
|
1641
|
+
settings,
|
|
1642
|
+
vaultRef,
|
|
1643
|
+
});
|
|
1644
|
+
written = true;
|
|
1645
|
+
const row = await store.get(org.id, action.key);
|
|
1646
|
+
sealed =
|
|
1647
|
+
row !== null &&
|
|
1648
|
+
row.state === 'adopted' &&
|
|
1649
|
+
row.vaultRef === vaultRef &&
|
|
1650
|
+
isDeepStrictEqual(row.settings, settings) &&
|
|
1651
|
+
(vaultRef === null || (await vault.resolve(vaultRef)) === envDocument);
|
|
1652
|
+
}
|
|
1653
|
+
catch {
|
|
1654
|
+
// The cause may quote a value; the message below names ids only.
|
|
1655
|
+
}
|
|
1656
|
+
if (!sealed) {
|
|
1657
|
+
// A row left as `adopted` would make the retry skip the snapshot (#311).
|
|
1658
|
+
if (written) {
|
|
1659
|
+
try {
|
|
1660
|
+
await store.setState(org.id, action.key, 'released');
|
|
1661
|
+
}
|
|
1662
|
+
catch {
|
|
1663
|
+
throw new Error(`coolify:adopt ${action.key}: the snapshot could not be read back and its row could not be released; the row may still be active`);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
throw new Error(`coolify:adopt ${action.key}: the snapshot could not be written and read back; nothing was changed`);
|
|
1667
|
+
}
|
|
1668
|
+
return undefined;
|
|
1669
|
+
}
|
|
1670
|
+
/** #311: whether the resource was adopted: its row is `adopted` or `confirmed`, or `id` is a declared adopt uuid. */
|
|
1671
|
+
async isAdopted(org, resourceKey, id) {
|
|
1672
|
+
const row = await this.ports.adoptionStore?.get(org.id, resourceKey);
|
|
1673
|
+
return protectsKey(row) || declaredAdoptUuids(org).has(id);
|
|
1674
|
+
}
|
|
1675
|
+
/** #311: refuses to manage as foundry's own a resource whose row protects its key and whose declaration has no `adopt`. */
|
|
1676
|
+
async refuseTakeover(org, resourceKey) {
|
|
1677
|
+
if (protectsKey(await this.ports.adoptionStore?.get(org.id, resourceKey))) {
|
|
1678
|
+
throw new Error(`${resourceKey}: adopted, so it needs adopt declared; nothing was changed`);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
/** #311: refuses to write to a declared adopt uuid that has no active snapshot. */
|
|
1682
|
+
async requireSnapshot(org, resourceKey, uuid) {
|
|
1683
|
+
const row = await this.ports.adoptionStore?.get(org.id, resourceKey);
|
|
1684
|
+
if (!isActiveSnapshot(row, uuid)) {
|
|
1685
|
+
throw new Error(`${resourceKey}: no active adoption snapshot; nothing was changed`);
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1091
1688
|
async coolifyApp(action, org, context) {
|
|
1092
1689
|
const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
|
|
1093
1690
|
if (action.desired.enabled === false) {
|
|
1094
1691
|
if (!recordedAppId) {
|
|
1095
1692
|
throw new Error(`coolify:app for ${org.slug}: no live app recorded, nothing to park`);
|
|
1096
1693
|
}
|
|
1694
|
+
if (await this.isAdopted(org, `coolify:app:${action.key}`, recordedAppId)) {
|
|
1695
|
+
throw new Error(`coolify:app ${action.key}: adopted, not stopped`);
|
|
1696
|
+
}
|
|
1097
1697
|
await this.ports.coolify.raw('POST', `/applications/${recordedAppId}/stop`);
|
|
1098
1698
|
return { resourceId: recordedAppId };
|
|
1099
1699
|
}
|
|
1100
1700
|
const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key);
|
|
1101
1701
|
if (!entry)
|
|
1102
1702
|
throw new Error(`coolify:app for ${org.slug}: no hosting entry for ${action.key}`);
|
|
1703
|
+
if (entry.adopt)
|
|
1704
|
+
await this.requireSnapshot(org, `coolify:app:${action.key}`, entry.adopt.app);
|
|
1705
|
+
else
|
|
1706
|
+
await this.refuseTakeover(org, `coolify:app:${action.key}`);
|
|
1103
1707
|
// A level-1 org with no bespoke app of its own gets the shared standard
|
|
1104
1708
|
// app instead (gap P1, `.plans/2026-09-26-level-1-org-map.md`): an entry
|
|
1105
1709
|
// with no `repo`.
|
|
1106
1710
|
const appRepo = entry.repo ?? DEFAULT_APP_REPO;
|
|
1107
|
-
|
|
1108
|
-
|
|
1711
|
+
// Read only by the name lookup and by create, so an adopted entry, which
|
|
1712
|
+
// does neither, makes no project (#311).
|
|
1713
|
+
let scoped;
|
|
1714
|
+
const scope = async () => {
|
|
1715
|
+
if (!scoped) {
|
|
1716
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
1717
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1718
|
+
scoped = { projectUuid, environment };
|
|
1719
|
+
}
|
|
1720
|
+
return scoped;
|
|
1721
|
+
};
|
|
1109
1722
|
// The recorded id first; the name when nothing is recorded, or when the
|
|
1110
1723
|
// recorded app is gone.
|
|
1111
1724
|
let existing = null;
|
|
1112
1725
|
let live;
|
|
1113
|
-
|
|
1726
|
+
// An adopted entry is the app it declares by uuid: never found by name,
|
|
1727
|
+
// never created (#311).
|
|
1728
|
+
if (entry.adopt) {
|
|
1729
|
+
existing = { uuid: entry.adopt.app };
|
|
1730
|
+
}
|
|
1731
|
+
else if (recordedAppId) {
|
|
1114
1732
|
try {
|
|
1115
1733
|
live = await this.ports.coolify.getApplication(recordedAppId);
|
|
1116
1734
|
existing = { uuid: recordedAppId };
|
|
@@ -1120,9 +1738,9 @@ export class FoundryStepExecutor {
|
|
|
1120
1738
|
throw error;
|
|
1121
1739
|
}
|
|
1122
1740
|
}
|
|
1123
|
-
if (!existing) {
|
|
1741
|
+
if (!existing && !entry.adopt) {
|
|
1124
1742
|
existing = await this.ports.coolify.findApplicationByName(appNameFor(org, entry), {
|
|
1125
|
-
environmentIds: [environment.id],
|
|
1743
|
+
environmentIds: [(await scope()).environment.id],
|
|
1126
1744
|
});
|
|
1127
1745
|
}
|
|
1128
1746
|
// An entry with no names is internal: the other entries reach it as
|
|
@@ -1132,6 +1750,7 @@ export class FoundryStepExecutor {
|
|
|
1132
1750
|
const folderField = isStatic ? 'publish_directory' : 'base_directory';
|
|
1133
1751
|
const settingsKey = appSettingsAppliedScopeKey(entry.key);
|
|
1134
1752
|
const domainsKey = appDomainsAppliedScopeKey(entry.key);
|
|
1753
|
+
const pendingKey = appDeployPendingScopeKey(entry.key);
|
|
1135
1754
|
let haveNames;
|
|
1136
1755
|
let have;
|
|
1137
1756
|
let appId;
|
|
@@ -1143,14 +1762,18 @@ export class FoundryStepExecutor {
|
|
|
1143
1762
|
// stops the app without deleting it, so re-enabling found it here,
|
|
1144
1763
|
// still stopped, and it stayed stopped forever despite `apply`
|
|
1145
1764
|
// reporting success.
|
|
1146
|
-
|
|
1147
|
-
if (!
|
|
1148
|
-
await this.ports.coolify.
|
|
1765
|
+
// An adopted app is left as it runs: starting it is not declared.
|
|
1766
|
+
if (!entry.adopt) {
|
|
1767
|
+
live ??= await this.ports.coolify.getApplication(existing.uuid);
|
|
1768
|
+
if (!isRunningOrStarting(live.status)) {
|
|
1769
|
+
await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
|
|
1770
|
+
}
|
|
1149
1771
|
}
|
|
1150
1772
|
have = appliedAppSettings(context.recordedIds(), entry.key);
|
|
1151
1773
|
haveNames = appliedAppDomains(org, context.recordedIds(), entry.key);
|
|
1152
1774
|
}
|
|
1153
1775
|
else {
|
|
1776
|
+
const { projectUuid, environment } = await scope();
|
|
1154
1777
|
const server = await this.resolveServer();
|
|
1155
1778
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
1156
1779
|
const created = await this.ports.coolify.createGithubAppApplication({
|
|
@@ -1182,26 +1805,39 @@ export class FoundryStepExecutor {
|
|
|
1182
1805
|
// recorded below leaves the old one, so the retry patches the same
|
|
1183
1806
|
// difference again.
|
|
1184
1807
|
const want = appSettingsOf(entry);
|
|
1185
|
-
|
|
1186
|
-
if (
|
|
1187
|
-
patch
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1808
|
+
let patch = {};
|
|
1809
|
+
if (entry.adopt) {
|
|
1810
|
+
patch = adoptedAppPatch(entry, names, await this.readLiveApp(entry.adopt.app));
|
|
1811
|
+
}
|
|
1812
|
+
else {
|
|
1813
|
+
if (want.folder !== have.folder)
|
|
1814
|
+
patch[folderField] = want.folder ? `/${want.folder}` : '/';
|
|
1815
|
+
if (want.port !== have.port && want.port !== null && !isStatic) {
|
|
1816
|
+
patch.ports_exposes = String(want.port);
|
|
1817
|
+
}
|
|
1818
|
+
if (want.autoDeploy !== have.autoDeploy)
|
|
1819
|
+
patch.is_auto_deploy_enabled = want.autoDeploy;
|
|
1820
|
+
// Create names the app after `domains`; a changed name reaches a live app
|
|
1821
|
+
// here (rule 3). The internal name is set after create, as the calls
|
|
1822
|
+
// stack's apps do.
|
|
1823
|
+
if (JSON.stringify(names) !== JSON.stringify(haveNames))
|
|
1824
|
+
patch.domains = names.join(',');
|
|
1825
|
+
if (names.length === 0 && (!existing || patch.domains !== undefined)) {
|
|
1826
|
+
patch.custom_internal_name = appNameFor(org, entry);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
if (Object.keys(patch).length > 0) {
|
|
1830
|
+
// An adopted app's patch is live from here, so a failed deploy must
|
|
1831
|
+
// replan the app step (#311).
|
|
1832
|
+
if (entry.adopt) {
|
|
1833
|
+
context.recordScopeId(pendingKey, '1');
|
|
1834
|
+
await context.persistScopeIds();
|
|
1835
|
+
}
|
|
1202
1836
|
await this.ports.coolify.updateApplication(appId, patch);
|
|
1837
|
+
}
|
|
1203
1838
|
// The container builds and listens from these only at its next deploy.
|
|
1204
|
-
const settingsNeedDeploy = Boolean(
|
|
1839
|
+
const settingsNeedDeploy = Boolean((entry.adopt && context.recordedIds()[pendingKey]) ||
|
|
1840
|
+
patch.base_directory ||
|
|
1205
1841
|
patch.publish_directory ||
|
|
1206
1842
|
patch.ports_exposes ||
|
|
1207
1843
|
patch.domains !== undefined ||
|
|
@@ -1212,7 +1848,8 @@ export class FoundryStepExecutor {
|
|
|
1212
1848
|
// covers the database being turned on after the app already exists (the
|
|
1213
1849
|
// app step still ran this apply, found the app by name, and lands here).
|
|
1214
1850
|
let dbPush = { pushed: false, changed: false };
|
|
1215
|
-
|
|
1851
|
+
// An adopted app keeps the database addresses it has (#311).
|
|
1852
|
+
if (entry.database && !entry.adopt && !org.hosting.adopt?.database) {
|
|
1216
1853
|
const dbId = context.recordedIds()[`coolify:database:${org.id}`];
|
|
1217
1854
|
if (dbId) {
|
|
1218
1855
|
dbPush = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
|
|
@@ -1229,7 +1866,10 @@ export class FoundryStepExecutor {
|
|
|
1229
1866
|
// `ensureLogVolume` whether this app was already running before this
|
|
1230
1867
|
// call -- see its own doc comment for why that decides whether to
|
|
1231
1868
|
// restart it.
|
|
1232
|
-
|
|
1869
|
+
// An adopted app gets no volume: the snapshot cannot undo one (#311).
|
|
1870
|
+
if (!entry.adopt) {
|
|
1871
|
+
await this.ensureLogVolume(appId, org, Boolean(existing), appNameFor(org, entry));
|
|
1872
|
+
}
|
|
1233
1873
|
// The app receives whatever it needs to sign in (gap P2). Unconditional,
|
|
1234
1874
|
// unlike the database push above: every app has a hostname, and an
|
|
1235
1875
|
// upsert of the same value on a later apply is a no-op, not a change.
|
|
@@ -1249,6 +1889,8 @@ export class FoundryStepExecutor {
|
|
|
1249
1889
|
// one and `planApp` reports it instead of planning it.
|
|
1250
1890
|
context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
|
|
1251
1891
|
context.recordScopeId(domainsKey, JSON.stringify(names));
|
|
1892
|
+
if (entry.adopt)
|
|
1893
|
+
context.recordScopeId(pendingKey, '');
|
|
1252
1894
|
context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
|
|
1253
1895
|
if (entry.uses?.auth) {
|
|
1254
1896
|
context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
|
|
@@ -1262,7 +1904,7 @@ export class FoundryStepExecutor {
|
|
|
1262
1904
|
// actually resolved a mail domain for this org.
|
|
1263
1905
|
// One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
|
|
1264
1906
|
// second entry sharing the name would delete the first's live token.
|
|
1265
|
-
if (entry.key === APP_ENTRY_KEY) {
|
|
1907
|
+
if (entry.key === APP_ENTRY_KEY && !entry.adopt) {
|
|
1266
1908
|
await this.ensureAppLettermintMail(appId, org, context, Boolean(existing));
|
|
1267
1909
|
}
|
|
1268
1910
|
return { resourceId: appId };
|
|
@@ -1307,12 +1949,24 @@ export class FoundryStepExecutor {
|
|
|
1307
1949
|
if (!dbId) {
|
|
1308
1950
|
throw new Error(`coolify:database for ${org.slug}: no live database recorded, nothing to park`);
|
|
1309
1951
|
}
|
|
1952
|
+
if (await this.isAdopted(org, `coolify:database:${action.key}`, dbId)) {
|
|
1953
|
+
throw new Error(`coolify:database ${action.key}: adopted, not stopped`);
|
|
1954
|
+
}
|
|
1310
1955
|
await this.ports.coolify.raw('POST', `/databases/${dbId}/stop`);
|
|
1311
1956
|
return { resourceId: dbId };
|
|
1312
1957
|
}
|
|
1313
1958
|
const existing = context.recordedIds()[`coolify:database:${action.key}`];
|
|
1314
1959
|
let dbId;
|
|
1315
|
-
if (
|
|
1960
|
+
if (!org.hosting.adopt?.database) {
|
|
1961
|
+
await this.refuseTakeover(org, `coolify:database:${action.key}`);
|
|
1962
|
+
}
|
|
1963
|
+
if (org.hosting.adopt?.database) {
|
|
1964
|
+
// The declared uuid is the database, over any recorded id: never found by
|
|
1965
|
+
// name or created (#311).
|
|
1966
|
+
dbId = org.hosting.adopt.database;
|
|
1967
|
+
await this.requireSnapshot(org, `coolify:database:${action.key}`, dbId);
|
|
1968
|
+
}
|
|
1969
|
+
else if (existing) {
|
|
1316
1970
|
dbId = existing;
|
|
1317
1971
|
}
|
|
1318
1972
|
else {
|
|
@@ -1343,7 +1997,7 @@ export class FoundryStepExecutor {
|
|
|
1343
1997
|
// before app on the same apply -- `diff.ts`'s `computePlanDetail`).
|
|
1344
1998
|
const dbEntry = org.hosting.entries.find((e) => e.database);
|
|
1345
1999
|
const appId = dbEntry && context.recordedIds()[`coolify:app:${appStepKey(org, dbEntry.key)}`];
|
|
1346
|
-
if (dbEntry && appId) {
|
|
2000
|
+
if (dbEntry && appId && !dbEntry.adopt && !org.hosting.adopt?.database) {
|
|
1347
2001
|
const markerKey = dbEnvAppliedScopeKey(dbEntry.key);
|
|
1348
2002
|
const push = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
|
|
1349
2003
|
if (push.pushed) {
|
|
@@ -1442,16 +2096,7 @@ export class FoundryStepExecutor {
|
|
|
1442
2096
|
*/
|
|
1443
2097
|
async pushAppEnv(appId, org, entry, context, authIssuer) {
|
|
1444
2098
|
const recorded = context.recordedIds();
|
|
1445
|
-
const envs =
|
|
1446
|
-
// Only the `app` entry has a public name; the others are internal.
|
|
1447
|
-
...(entry.key === APP_ENTRY_KEY
|
|
1448
|
-
? [{ key: 'APP_URL', value: `https://${appHostname(org)}` }]
|
|
1449
|
-
: []),
|
|
1450
|
-
// #75: read back by the package-side pino sink (not this repo's job,
|
|
1451
|
-
// #75 "What belongs where") once `ensureLogVolume` has mounted the
|
|
1452
|
-
// matching volume.
|
|
1453
|
-
{ key: 'LOG_DIR', value: LOG_DIR },
|
|
1454
|
-
];
|
|
2099
|
+
const envs = ownedAppEnv(org, entry, recorded, authIssuer);
|
|
1455
2100
|
// An entry with `uses.auth` signs in one organisation, with its own client
|
|
1456
2101
|
// and the issuer; any other entry keeps the org's staff organisation and
|
|
1457
2102
|
// the one hosted-app client.
|
|
@@ -1461,27 +2106,10 @@ export class FoundryStepExecutor {
|
|
|
1461
2106
|
if (!authEnv.AUTH_ORGANIZATION_ID) {
|
|
1462
2107
|
throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' signs in ${entry.uses.auth.users}, whose organisation id is not recorded yet`);
|
|
1463
2108
|
}
|
|
1464
|
-
for (const [key, value] of Object.entries(authEnv))
|
|
1465
|
-
envs.push({ key, value });
|
|
1466
2109
|
const appKey = await this.ensureAuthAppKey(appId);
|
|
1467
2110
|
if (appKey)
|
|
1468
2111
|
envs.push({ key: 'AUTH_APP_KEY', value: appKey });
|
|
1469
2112
|
}
|
|
1470
|
-
else {
|
|
1471
|
-
const zitadelOrgId = recorded.zitadel_org;
|
|
1472
|
-
if (zitadelOrgId)
|
|
1473
|
-
envs.push({ key: 'AUTH_ORGANIZATION_ID', value: zitadelOrgId });
|
|
1474
|
-
const clientId = recorded[APP_CLIENT_ID_SCOPE_KEY];
|
|
1475
|
-
if (clientId)
|
|
1476
|
-
envs.push({ key: 'AUTH_CLIENT_ID', value: clientId });
|
|
1477
|
-
}
|
|
1478
|
-
// The invited owner's ZITADEL user id (gap P3): the app uses it to make
|
|
1479
|
-
// them its own owner in authz on first sign-in. `runSteps` records this
|
|
1480
|
-
// under the same `system:kind:key` shape every step's `resourceId` uses
|
|
1481
|
-
// (`zitadelOwner`'s own doc comment), not a scope id.
|
|
1482
|
-
const ownerUserId = recorded[`zitadel:owner:${org.id}`];
|
|
1483
|
-
if (ownerUserId)
|
|
1484
|
-
envs.push({ key: 'OWNER_USER_ID', value: ownerUserId });
|
|
1485
2113
|
// Idempotent the same way `pushDatabaseUrl`'s credential is treated:
|
|
1486
2114
|
// generated once, handed straight to `bulkUpsertEnvs` and nowhere else in
|
|
1487
2115
|
// this class -- never returned, recorded, logged or interpolated into a
|
|
@@ -1498,7 +2126,22 @@ export class FoundryStepExecutor {
|
|
|
1498
2126
|
const appEnv = resolveAppEnv(org, entry, recorded).values;
|
|
1499
2127
|
for (const [key, value] of Object.entries(appEnv))
|
|
1500
2128
|
envs.push({ key, value });
|
|
1501
|
-
|
|
2129
|
+
// An omitted flag is written as false, so on an adopted app a name that
|
|
2130
|
+
// exists live keeps the flags it has (#311).
|
|
2131
|
+
const live = entry.adopt
|
|
2132
|
+
? (await this.ports.coolify.listEnvs(appId)).filter((e) => !e.is_preview)
|
|
2133
|
+
: [];
|
|
2134
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, envs.map((env) => {
|
|
2135
|
+
const row = live.find((e) => e.key === env.key);
|
|
2136
|
+
return row
|
|
2137
|
+
? {
|
|
2138
|
+
...env,
|
|
2139
|
+
buildTime: Boolean(row.is_buildtime),
|
|
2140
|
+
literal: Boolean(row.is_literal),
|
|
2141
|
+
multiline: Boolean(row.is_multiline),
|
|
2142
|
+
}
|
|
2143
|
+
: env;
|
|
2144
|
+
}));
|
|
1502
2145
|
// `coolifyApp` records `applied` once the restart succeeded, so `planApp`
|
|
1503
2146
|
// replans this step when the declared map changes (rule 3). Ids and a
|
|
1504
2147
|
// domain, never a secret.
|
|
@@ -1700,9 +2343,13 @@ export class FoundryStepExecutor {
|
|
|
1700
2343
|
*/
|
|
1701
2344
|
async ensureOrgLettermintRelay(org, mailOrg, mailDomain, context) {
|
|
1702
2345
|
const relayName = `${org.slug}-relay`;
|
|
2346
|
+
// #345: `mailOrg.domain` is absent for a level-1 org -- `mailDomain` is
|
|
2347
|
+
// `applyOrgMail`'s own resolved result, the one domain `MailPort`'s
|
|
2348
|
+
// relay methods ever need.
|
|
2349
|
+
const relayOrg = { ...mailOrg, domain: mailDomain };
|
|
1703
2350
|
const [projects, hasRelay] = await Promise.all([
|
|
1704
2351
|
this.ports.lettermint.listProjects(),
|
|
1705
|
-
this.ports.mail.hasOrgRelay(
|
|
2352
|
+
this.ports.mail.hasOrgRelay(relayOrg),
|
|
1706
2353
|
]);
|
|
1707
2354
|
// The recorded project, not "the only one": a leftover duplicate whose
|
|
1708
2355
|
// delete keeps failing must not force a fresh mint on every apply.
|
|
@@ -1725,7 +2372,7 @@ export class FoundryStepExecutor {
|
|
|
1725
2372
|
// The restriction covers old and new projects, so the old one keeps
|
|
1726
2373
|
// sending until the route switches.
|
|
1727
2374
|
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
|
|
1728
|
-
await this.ports.mail.applyOrgRelay(
|
|
2375
|
+
await this.ports.mail.applyOrgRelay(relayOrg, created.token);
|
|
1729
2376
|
}
|
|
1730
2377
|
catch (err) {
|
|
1731
2378
|
await this.discardLettermintProject(created.project.id);
|
|
@@ -2636,11 +3283,17 @@ export class FoundryStepExecutor {
|
|
|
2636
3283
|
}
|
|
2637
3284
|
// ---- cloudflare --------------------------------------------------------
|
|
2638
3285
|
async cloudflareTunnel(action, org, context) {
|
|
3286
|
+
const adoptedTunnel = org.hosting.adopt?.tunnel;
|
|
3287
|
+
const connectorApp = org.hosting.adopt?.connectorApp;
|
|
2639
3288
|
if (action.desired.enabled === false) {
|
|
2640
3289
|
const tunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
|
|
2641
3290
|
if (!tunnelId) {
|
|
2642
3291
|
throw new Error(`cloudflare:tunnel for ${org.slug}: no live tunnel recorded, nothing to park`);
|
|
2643
3292
|
}
|
|
3293
|
+
// Parking clears the ingress and stops the connector: never for what was adopted (#312).
|
|
3294
|
+
if (await this.isAdopted(org, adoptedTunnelKey(org), tunnelId)) {
|
|
3295
|
+
throw new Error(`cloudflare:tunnel ${action.key}: adopted, not stopped`);
|
|
3296
|
+
}
|
|
2644
3297
|
// Cleared, not deleted (#7 corrections 1): an ingress with only the
|
|
2645
3298
|
// mandatory catch-all routes nothing.
|
|
2646
3299
|
await this.ports.cloudflare.putTunnelConfig(tunnelId, {
|
|
@@ -2661,6 +3314,17 @@ export class FoundryStepExecutor {
|
|
|
2661
3314
|
}
|
|
2662
3315
|
return { resourceId: tunnelId };
|
|
2663
3316
|
}
|
|
3317
|
+
// An adopted tunnel and connector are written to only under an active
|
|
3318
|
+
// snapshot; one that was adopted and is no longer declared is not taken as
|
|
3319
|
+
// foundry's own (#312).
|
|
3320
|
+
if (adoptedTunnel)
|
|
3321
|
+
await this.requireSnapshot(org, adoptedTunnelKey(org), adoptedTunnel);
|
|
3322
|
+
else
|
|
3323
|
+
await this.refuseTakeover(org, adoptedTunnelKey(org));
|
|
3324
|
+
if (connectorApp)
|
|
3325
|
+
await this.requireSnapshot(org, adoptedConnectorKey(org), connectorApp);
|
|
3326
|
+
else
|
|
3327
|
+
await this.refuseTakeover(org, adoptedConnectorKey(org));
|
|
2664
3328
|
const tunnelName = `${org.slug}-tunnel`;
|
|
2665
3329
|
// #236 round 5 finding 2 / round 6 finding 2: read, and (if it is about
|
|
2666
3330
|
// to change) added to the accumulated replaced-id set, before
|
|
@@ -2669,8 +3333,11 @@ export class FoundryStepExecutor {
|
|
|
2669
3333
|
// domain declared still leaves `destroy` a way to recognise the old
|
|
2670
3334
|
// id's CNAMEs as this tunnel's own rather than a foreign record.
|
|
2671
3335
|
const recordedTunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
|
|
2672
|
-
|
|
2673
|
-
const tunnel =
|
|
3336
|
+
// An adopted tunnel is the one declared by id: never found by name, never created (#312).
|
|
3337
|
+
const tunnel = adoptedTunnel
|
|
3338
|
+
? { id: adoptedTunnel }
|
|
3339
|
+
: ((await this.ports.cloudflare.findTunnelByName(tunnelName)) ??
|
|
3340
|
+
(await this.ports.cloudflare.createTunnel(tunnelName)));
|
|
2674
3341
|
const previousIds = parseTunnelPreviousIds(context.recordedIds());
|
|
2675
3342
|
if (recordedTunnelId && recordedTunnelId !== tunnel.id) {
|
|
2676
3343
|
previousIds.add(recordedTunnelId);
|
|
@@ -2693,13 +3360,18 @@ export class FoundryStepExecutor {
|
|
|
2693
3360
|
// (#115) gets one ingress entry per hostname `tunnelIngressHostnames`
|
|
2694
3361
|
// returns, all routed to the same proxy -- Traefik itself, not this list,
|
|
2695
3362
|
// is what tells the public site apart from the cms studio.
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
3363
|
+
if (adoptedTunnel) {
|
|
3364
|
+
await this.mergeAdoptedIngress(org, tunnel.id);
|
|
3365
|
+
}
|
|
3366
|
+
else {
|
|
3367
|
+
const ingress = tunnelIngressHostnames(org).map((hostname) => ({
|
|
3368
|
+
hostname,
|
|
3369
|
+
service: COOLIFY_PROXY_ORIGIN,
|
|
3370
|
+
}));
|
|
3371
|
+
await this.ports.cloudflare.putTunnelConfig(tunnel.id, {
|
|
3372
|
+
ingress: [...ingress, { service: 'http_status:404' }],
|
|
3373
|
+
});
|
|
3374
|
+
}
|
|
2703
3375
|
// #235: a connector for this org's own tunnel, the same call every
|
|
2704
3376
|
// platform tunnel step already makes (Decided, William, 2026-10-01:
|
|
2705
3377
|
// one connector per org tunnel, never a shared tunnel with appended
|
|
@@ -2707,22 +3379,40 @@ export class FoundryStepExecutor {
|
|
|
2707
3379
|
// nothing is dialed into it, so `hostname` answers with a Cloudflare
|
|
2708
3380
|
// error -- #197's own gap, for an org's tunnel rather than a platform
|
|
2709
3381
|
// one.
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
3382
|
+
// An adopted connector is the app declared by uuid: it needs no project to
|
|
3383
|
+
// be found in or created under (#312).
|
|
3384
|
+
let place;
|
|
3385
|
+
if (!connectorApp) {
|
|
3386
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
3387
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
3388
|
+
place = { projectUuid, environment };
|
|
3389
|
+
}
|
|
3390
|
+
else {
|
|
3391
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, connectorApp);
|
|
3392
|
+
}
|
|
3393
|
+
await this.guarded(Boolean(adoptedTunnel), `cloudflare:tunnel for ${org.slug}: updating the connector of tunnel ${tunnel.id}`, () => ensureCloudflaredConnector({
|
|
2713
3394
|
coolify: this.ports.coolify,
|
|
2714
3395
|
cloudflare: this.ports.cloudflare,
|
|
2715
3396
|
tunnelId: tunnel.id,
|
|
2716
3397
|
appName: `${org.slug}-cloudflared`,
|
|
2717
|
-
|
|
2718
|
-
|
|
3398
|
+
...place,
|
|
3399
|
+
adopted: Boolean(connectorApp),
|
|
3400
|
+
// Restore redeploys a connector whose token foundry rewrote, so this is recorded first.
|
|
3401
|
+
// The digest tells restore the live token is foundry's; the token itself is never stored.
|
|
3402
|
+
beforeTokenWrite: connectorApp
|
|
3403
|
+
? (token) => this.updateAdoptionSettings(org, adoptedConnectorKey(org), (settings) => ({
|
|
3404
|
+
...settings,
|
|
3405
|
+
tokenWritten: true,
|
|
3406
|
+
tokenDigest: sha256Hex(token),
|
|
3407
|
+
}))
|
|
3408
|
+
: undefined,
|
|
2719
3409
|
resolveServer: () => this.resolveServer(),
|
|
2720
3410
|
recordedConnectorAppId: context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
2721
3411
|
recordAppId: async (appId) => {
|
|
2722
3412
|
context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, appId);
|
|
2723
3413
|
await context.persistScopeIds();
|
|
2724
3414
|
},
|
|
2725
|
-
});
|
|
3415
|
+
}));
|
|
2726
3416
|
// #236: an org with no domain (a level-1 org whose app still serves off
|
|
2727
3417
|
// the shared `*.oikos.host` wildcard tunnel, `scripts/shared-ingress.mjs`)
|
|
2728
3418
|
// has no zone of its own and nothing here to publish -- `org.domain` is
|
|
@@ -2755,12 +3445,14 @@ export class FoundryStepExecutor {
|
|
|
2755
3445
|
// failure leaves the *old* domain recorded, so a retry sees the same
|
|
2756
3446
|
// domain change again and tries cleanup again.
|
|
2757
3447
|
if (org.domain) {
|
|
3448
|
+
const { domain } = org;
|
|
2758
3449
|
const recordedBefore = context.recordedIds();
|
|
2759
3450
|
const previousDomain = recordedBefore[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY];
|
|
2760
3451
|
const previousHostnamesRaw = recordedBefore[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
|
|
2761
3452
|
const previousHostnames = previousHostnamesRaw ? previousHostnamesRaw.split(',') : [];
|
|
2762
3453
|
const domainChanged = previousDomain !== undefined && previousDomain !== org.domain;
|
|
2763
|
-
const
|
|
3454
|
+
const dnsFailed = `cloudflare:tunnel for ${org.slug}: publishing DNS for tunnel ${tunnel.id}`;
|
|
3455
|
+
const publishedHostnames = await this.guarded(Boolean(adoptedTunnel), dnsFailed, () => this.publishTunnelDnsRecords(org, domain, tunnel.id,
|
|
2764
3456
|
// Round 6 finding 2: every id this step has ever replaced, not only
|
|
2765
3457
|
// the one `recordedTunnelId` captured above -- see the scope key's
|
|
2766
3458
|
// own doc comment.
|
|
@@ -2770,9 +3462,13 @@ export class FoundryStepExecutor {
|
|
|
2770
3462
|
// needs the domain's own previous hostname list for that. A domain
|
|
2771
3463
|
// change instead cleans the *whole* old domain below, so it gets an
|
|
2772
3464
|
// empty list here rather than a second, redundant pass over it.
|
|
2773
|
-
domainChanged ? [] : previousHostnames, context);
|
|
3465
|
+
domainChanged ? [] : previousHostnames, context));
|
|
2774
3466
|
if (domainChanged) {
|
|
2775
|
-
await this.deleteStaleTunnelDnsRecords(tunnel.id, previousIds, previousDomain, previousHostnames, context
|
|
3467
|
+
await this.guarded(Boolean(adoptedTunnel), dnsFailed, async () => this.deleteStaleTunnelDnsRecords(tunnel.id, previousIds, previousDomain, previousHostnames, context,
|
|
3468
|
+
// An adopted tunnel's CNAME that foundry did not create is never deleted.
|
|
3469
|
+
adoptedTunnel
|
|
3470
|
+
? new Set(createdDnsRecords((await this.ports.adoptionStore?.get(org.id, adoptedTunnelKey(org)))?.settings).map((r) => r.id))
|
|
3471
|
+
: undefined));
|
|
2776
3472
|
}
|
|
2777
3473
|
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY, org.domain);
|
|
2778
3474
|
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY, publishedHostnames.join(','));
|
|
@@ -2800,6 +3496,80 @@ export class FoundryStepExecutor {
|
|
|
2800
3496
|
}
|
|
2801
3497
|
return { resourceId: tunnel.id };
|
|
2802
3498
|
}
|
|
3499
|
+
/**
|
|
3500
|
+
* Runs `call`; for an adopted resource a failure that is not one of this
|
|
3501
|
+
* file's own messages (which name hostnames and record types only) is
|
|
3502
|
+
* replaced by `label`, because a client's error text carries response
|
|
3503
|
+
* bodies (rule 6, #312).
|
|
3504
|
+
*/
|
|
3505
|
+
async guarded(adopted, label, call) {
|
|
3506
|
+
if (!adopted)
|
|
3507
|
+
return call();
|
|
3508
|
+
try {
|
|
3509
|
+
return await call();
|
|
3510
|
+
}
|
|
3511
|
+
catch (error) {
|
|
3512
|
+
if (error instanceof Error && error.message.startsWith('cloudflare:tunnel for '))
|
|
3513
|
+
throw error;
|
|
3514
|
+
throw new Error(`${label} failed`);
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3517
|
+
/**
|
|
3518
|
+
* #312: writes the org's routes into the adopted tunnel's LIVE ingress list
|
|
3519
|
+
* (`mergeIngress`), not the snapshot, so a route a person added since is
|
|
3520
|
+
* kept too. The declared names go into the tunnel row's `written` only after
|
|
3521
|
+
* the write that carried them succeeded, so a crash in between leaves a
|
|
3522
|
+
* route taken as foreign and kept. Writes nothing when the merged list is the
|
|
3523
|
+
* live one. Errors name the tunnel id and the operation only.
|
|
3524
|
+
*/
|
|
3525
|
+
async mergeAdoptedIngress(org, tunnelId) {
|
|
3526
|
+
const cf = this.ports.cloudflare;
|
|
3527
|
+
const failed = (operation) => new Error(`cloudflare:tunnel for ${org.slug}: ${operation} of tunnel ${tunnelId} failed`);
|
|
3528
|
+
let live;
|
|
3529
|
+
try {
|
|
3530
|
+
live = (await cf.getTunnelConfig(tunnelId)).config;
|
|
3531
|
+
if (live && !Array.isArray(live.ingress))
|
|
3532
|
+
throw new Error('not a list');
|
|
3533
|
+
}
|
|
3534
|
+
catch {
|
|
3535
|
+
throw failed('reading the ingress');
|
|
3536
|
+
}
|
|
3537
|
+
const declared = tunnelIngressHostnames(org);
|
|
3538
|
+
const row = await this.ports.adoptionStore?.get(org.id, adoptedTunnelKey(org));
|
|
3539
|
+
// A name in `writing` may have been carried by a put whose row write never landed.
|
|
3540
|
+
const written = new Set([...writtenIngressNames(row), ...writingIngressNames(row)]);
|
|
3541
|
+
const snapshot = Array.isArray(row?.settings.ingress) ? row.settings.ingress : [];
|
|
3542
|
+
const merged = mergeIngress(live?.ingress ?? [], declared, COOLIFY_PROXY_ORIGIN, written, snapshot);
|
|
3543
|
+
try {
|
|
3544
|
+
const put = !isDeepStrictEqual(merged, live?.ingress ?? []);
|
|
3545
|
+
if (put) {
|
|
3546
|
+
// Recorded before the put: a failed row write after it must not strand foundry's routes.
|
|
3547
|
+
await this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
|
|
3548
|
+
...settings,
|
|
3549
|
+
writing: [...new Set([...writingIngressNames(row), ...declared])],
|
|
3550
|
+
}));
|
|
3551
|
+
await cf.putTunnelConfig(tunnelId, { ...live, ingress: merged });
|
|
3552
|
+
}
|
|
3553
|
+
if (put || declared.some((hostname) => !written.has(hostname))) {
|
|
3554
|
+
await this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
|
|
3555
|
+
...settings,
|
|
3556
|
+
written: [...new Set([...written, ...declared])],
|
|
3557
|
+
writing: [],
|
|
3558
|
+
}));
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
3561
|
+
catch {
|
|
3562
|
+
throw failed('writing the ingress');
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
/** #312: replaces the `settings` of an adoption row, read fresh so no other key is lost. The state and `takenAt` stay. */
|
|
3566
|
+
async updateAdoptionSettings(org, resourceKey, update) {
|
|
3567
|
+
const { adoptionStore: store } = this.ports;
|
|
3568
|
+
const row = await store?.get(org.id, resourceKey);
|
|
3569
|
+
if (!store || !row)
|
|
3570
|
+
throw new Error('no adoption row to record in');
|
|
3571
|
+
await store.setSettings(org.id, resourceKey, update(row.settings));
|
|
3572
|
+
}
|
|
2803
3573
|
/**
|
|
2804
3574
|
* #236: when this tunnel's DNS was last published under a domain that
|
|
2805
3575
|
* differs from the one `org` now declares, deletes every CNAME in the
|
|
@@ -2841,7 +3611,7 @@ export class FoundryStepExecutor {
|
|
|
2841
3611
|
* hostnames) never added entries of its own to begin with, so there is
|
|
2842
3612
|
* nothing of this domain's left in that set either way.
|
|
2843
3613
|
*/
|
|
2844
|
-
async deleteStaleTunnelDnsRecords(tunnelId, previousIds, previousDomain, previousHostnames, context) {
|
|
3614
|
+
async deleteStaleTunnelDnsRecords(tunnelId, previousIds, previousDomain, previousHostnames, context, onlyIds) {
|
|
2845
3615
|
const zone = await this.ports.cloudflare.findZoneByApex(previousDomain);
|
|
2846
3616
|
if (!zone)
|
|
2847
3617
|
return;
|
|
@@ -2855,7 +3625,7 @@ export class FoundryStepExecutor {
|
|
|
2855
3625
|
? (await Promise.all(previousHostnames.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
|
|
2856
3626
|
: await cf.listDnsRecords(zone.id, { type: 'CNAME' });
|
|
2857
3627
|
for (const record of records) {
|
|
2858
|
-
if (dnsTargets.has(record.content)) {
|
|
3628
|
+
if (dnsTargets.has(record.content) && (!onlyIds || onlyIds.has(record.id))) {
|
|
2859
3629
|
await cf.deleteDnsRecord(zone.id, record.id);
|
|
2860
3630
|
}
|
|
2861
3631
|
}
|
|
@@ -2929,12 +3699,15 @@ export class FoundryStepExecutor {
|
|
|
2929
3699
|
if (!zone) {
|
|
2930
3700
|
throw new Error(`cloudflare:tunnel for ${org.slug}: no Cloudflare zone found for ${domain} to point its hostnames at -- cloudflare:zone must provision it first`);
|
|
2931
3701
|
}
|
|
3702
|
+
const adopted = Boolean(org.hosting.adopt?.tunnel);
|
|
3703
|
+
const tunnelRow = () => this.ports.adoptionStore?.get(org.id, adoptedTunnelKey(org));
|
|
2932
3704
|
const dnsTarget = `${tunnelId}.cfargotunnel.com`;
|
|
2933
3705
|
const previousTargets = new Set([...previousIds].filter((id) => id !== tunnelId).map((id) => `${id}.cfargotunnel.com`));
|
|
2934
3706
|
const hostnames = tunnelDnsHostnames(org, domain);
|
|
2935
3707
|
const droppedHostnames = previousHostnames.filter((hostname) => !hostnames.includes(hostname));
|
|
2936
3708
|
const conflicts = [];
|
|
2937
3709
|
const toCreate = [];
|
|
3710
|
+
const toClaim = [];
|
|
2938
3711
|
const toRepoint = [];
|
|
2939
3712
|
for (const hostname of hostnames) {
|
|
2940
3713
|
const [a] = await cf.findDnsRecord(zone.id, hostname, 'A');
|
|
@@ -2955,6 +3728,11 @@ export class FoundryStepExecutor {
|
|
|
2955
3728
|
}
|
|
2956
3729
|
if (!cname)
|
|
2957
3730
|
toCreate.push(hostname);
|
|
3731
|
+
// A crash after the create left a CNAME to the tunnel that is foundry's.
|
|
3732
|
+
else if (adopted &&
|
|
3733
|
+
dnsCreatingNames((await tunnelRow())?.settings).some((n) => normalizeHostname(n) === normalizeHostname(hostname))) {
|
|
3734
|
+
toClaim.push({ hostname, recordId: cname.id });
|
|
3735
|
+
}
|
|
2958
3736
|
}
|
|
2959
3737
|
if (conflicts.length > 0) {
|
|
2960
3738
|
throw new Error(`cloudflare:tunnel for ${org.slug}: will not point its tunnel at every hostname -- ${conflicts.join('; ')}; remove the conflicting record first`);
|
|
@@ -2975,13 +3753,43 @@ export class FoundryStepExecutor {
|
|
|
2975
3753
|
};
|
|
2976
3754
|
for (const hostname of settled)
|
|
2977
3755
|
recordTracked(hostname);
|
|
3756
|
+
// The row learns of a record foundry made: `dnsCreating` first, then `dnsRecords` with its id.
|
|
3757
|
+
const recordCreated = (hostname, recordId) => this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
|
|
3758
|
+
...settings,
|
|
3759
|
+
dnsRecords: [...createdDnsRecords(settings), { zoneId: zone.id, id: recordId, hostname }],
|
|
3760
|
+
dnsCreating: dnsCreatingNames(settings).filter((n) => normalizeHostname(n) !== normalizeHostname(hostname)),
|
|
3761
|
+
}));
|
|
3762
|
+
for (const { hostname, recordId } of toClaim)
|
|
3763
|
+
await recordCreated(hostname, recordId);
|
|
2978
3764
|
for (const hostname of toCreate) {
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
}
|
|
3765
|
+
if (adopted) {
|
|
3766
|
+
await this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
|
|
3767
|
+
...settings,
|
|
3768
|
+
dnsCreating: [...new Set([...dnsCreatingNames(settings), hostname])],
|
|
3769
|
+
}));
|
|
3770
|
+
}
|
|
3771
|
+
let created;
|
|
3772
|
+
try {
|
|
3773
|
+
created = await cf.createDnsRecord(zone.id, {
|
|
3774
|
+
type: 'CNAME',
|
|
3775
|
+
name: hostname,
|
|
3776
|
+
content: dnsTarget,
|
|
3777
|
+
proxied: true,
|
|
3778
|
+
});
|
|
3779
|
+
}
|
|
3780
|
+
catch (error) {
|
|
3781
|
+
// The call answered with an error: no record of foundry's, so a later one is a person's.
|
|
3782
|
+
// A timeout or network failure may have created it: `dnsCreating` stays for the claim.
|
|
3783
|
+
if (adopted && isApiError(error)) {
|
|
3784
|
+
await this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
|
|
3785
|
+
...settings,
|
|
3786
|
+
dnsCreating: dnsCreatingNames(settings).filter((n) => normalizeHostname(n) !== normalizeHostname(hostname)),
|
|
3787
|
+
})).catch(() => { });
|
|
3788
|
+
}
|
|
3789
|
+
throw error;
|
|
3790
|
+
}
|
|
3791
|
+
if (adopted)
|
|
3792
|
+
await recordCreated(hostname, created.id);
|
|
2985
3793
|
recordTracked(hostname);
|
|
2986
3794
|
}
|
|
2987
3795
|
for (const { hostname, recordId } of toRepoint) {
|
|
@@ -3005,7 +3813,10 @@ export class FoundryStepExecutor {
|
|
|
3005
3813
|
// way.
|
|
3006
3814
|
for (const hostname of droppedHostnames) {
|
|
3007
3815
|
const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
|
|
3008
|
-
|
|
3816
|
+
// An adopted tunnel's CNAME is deleted only if foundry created it.
|
|
3817
|
+
const mine = !adopted ||
|
|
3818
|
+
createdDnsRecords((await tunnelRow())?.settings).some((r) => r.id === cname?.id);
|
|
3819
|
+
if (cname && mine && (cname.content === dnsTarget || previousTargets.has(cname.content))) {
|
|
3009
3820
|
await cf.deleteDnsRecord(zone.id, cname.id);
|
|
3010
3821
|
}
|
|
3011
3822
|
tracked.delete(trackedTunnelDnsEntry(domain, hostname));
|
|
@@ -3104,9 +3915,9 @@ export class FoundryStepExecutor {
|
|
|
3104
3915
|
// #177: what this step wrote from the platform, so `planMail` can see a later change.
|
|
3105
3916
|
context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
|
|
3106
3917
|
context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
|
|
3107
|
-
await this.ensureOwnerMailbox(org, mailOrg, context);
|
|
3918
|
+
await this.ensureOwnerMailbox(org, mailOrg, options, context);
|
|
3108
3919
|
await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context);
|
|
3109
|
-
await this.ensureRoleMailboxes(org, mailOrg, result.domain, context);
|
|
3920
|
+
await this.ensureRoleMailboxes(org, mailOrg, result.domain, options, context);
|
|
3110
3921
|
return { resourceId: result.domain };
|
|
3111
3922
|
}
|
|
3112
3923
|
/**
|
|
@@ -3147,7 +3958,7 @@ export class FoundryStepExecutor {
|
|
|
3147
3958
|
* so a second apply never calls `MailPort.createMailbox` again, rather
|
|
3148
3959
|
* than relying on that port method's own duplicate refusal.
|
|
3149
3960
|
*/
|
|
3150
|
-
async ensureOwnerMailbox(org, mailOrg, context) {
|
|
3961
|
+
async ensureOwnerMailbox(org, mailOrg, options, context) {
|
|
3151
3962
|
if (!org.owner)
|
|
3152
3963
|
return;
|
|
3153
3964
|
const address = org.mail.ownerMailboxAddress ?? null;
|
|
@@ -3158,7 +3969,7 @@ export class FoundryStepExecutor {
|
|
|
3158
3969
|
await this.ports.mail.createMailbox(mailOrg, {
|
|
3159
3970
|
address,
|
|
3160
3971
|
name: `${org.owner.givenName} ${org.owner.familyName}`,
|
|
3161
|
-
});
|
|
3972
|
+
}, options);
|
|
3162
3973
|
context.recordScopeId(MAIL_OWNER_MAILBOX_SCOPE_KEY, address);
|
|
3163
3974
|
}
|
|
3164
3975
|
/**
|
|
@@ -3171,7 +3982,7 @@ export class FoundryStepExecutor {
|
|
|
3171
3982
|
* apply (or a retry after a crash) skips what exists rather than relying
|
|
3172
3983
|
* on the mail server's own duplicate refusal.
|
|
3173
3984
|
*/
|
|
3174
|
-
async ensureRoleMailboxes(org, mailOrg, domain, context) {
|
|
3985
|
+
async ensureRoleMailboxes(org, mailOrg, domain, options, context) {
|
|
3175
3986
|
const declared = org.mail.roleMailboxes ?? [];
|
|
3176
3987
|
if (declared.length === 0)
|
|
3177
3988
|
return;
|
|
@@ -3183,7 +3994,7 @@ export class FoundryStepExecutor {
|
|
|
3183
3994
|
if (created.has(entry))
|
|
3184
3995
|
continue;
|
|
3185
3996
|
try {
|
|
3186
|
-
await this.ports.mail.createMailbox(mailOrg, { address, name: local });
|
|
3997
|
+
await this.ports.mail.createMailbox(mailOrg, { address, name: local }, options);
|
|
3187
3998
|
}
|
|
3188
3999
|
catch (error) {
|
|
3189
4000
|
// A crash between create and record leaves the mailbox made but
|
|
@@ -3254,7 +4065,9 @@ export class FoundryStepExecutor {
|
|
|
3254
4065
|
const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
3255
4066
|
if (!mailDomain)
|
|
3256
4067
|
return;
|
|
3257
|
-
|
|
4068
|
+
// #345: `mailOrg.domain` is absent for a level-1 org -- the previously
|
|
4069
|
+
// recorded mail domain is the one `MailPort.removeOrgRelay` needs.
|
|
4070
|
+
await this.ports.mail.removeOrgRelay({ ...mailOrg, domain: mailDomain });
|
|
3258
4071
|
const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
|
|
3259
4072
|
const projects = await this.ports.lettermint.listProjects();
|
|
3260
4073
|
for (const project of projects) {
|
|
@@ -3445,12 +4258,16 @@ export class FoundryStepExecutor {
|
|
|
3445
4258
|
return;
|
|
3446
4259
|
}
|
|
3447
4260
|
case 'cloudflare:tunnel': {
|
|
4261
|
+
// An adopted tunnel and connector are never deleted (#312).
|
|
4262
|
+
if (await this.isAdopted(org, adoptedTunnelKey(org), target.id))
|
|
4263
|
+
return;
|
|
3448
4264
|
// #235: the connector app first, its own 404 handled locally --
|
|
3449
4265
|
// a retry where this already succeeded must still reach
|
|
3450
4266
|
// `deleteTunnel` below, which the outer catch's own 404 handling
|
|
3451
4267
|
// would otherwise short-circuit before it ran.
|
|
3452
4268
|
const connectorAppId = recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
|
|
3453
|
-
if (connectorAppId
|
|
4269
|
+
if (connectorAppId &&
|
|
4270
|
+
!(await this.isAdopted(org, adoptedConnectorKey(org), connectorAppId))) {
|
|
3454
4271
|
try {
|
|
3455
4272
|
await this.ports.coolify.deleteApplication(connectorAppId);
|
|
3456
4273
|
}
|
|
@@ -3535,9 +4352,13 @@ export class FoundryStepExecutor {
|
|
|
3535
4352
|
await this.ports.cloudflare.deleteAccessApp(target.id);
|
|
3536
4353
|
return;
|
|
3537
4354
|
case 'coolify:app':
|
|
4355
|
+
if (declaredAdoptUuids(org).has(target.id))
|
|
4356
|
+
return;
|
|
3538
4357
|
await this.ports.coolify.deleteApplication(target.id);
|
|
3539
4358
|
return;
|
|
3540
4359
|
case 'coolify:database':
|
|
4360
|
+
if (declaredAdoptUuids(org).has(target.id))
|
|
4361
|
+
return;
|
|
3541
4362
|
await this.ports.coolify.deleteDatabase(target.id);
|
|
3542
4363
|
return;
|
|
3543
4364
|
case 'zitadel:app': {
|
|
@@ -3598,3 +4419,10 @@ export function isNotFoundError(error) {
|
|
|
3598
4419
|
const message = error instanceof Error ? error.message : String(error);
|
|
3599
4420
|
return /^\S+ \S+ \S+ → 404:/.test(message);
|
|
3600
4421
|
}
|
|
4422
|
+
/** Whether the API answered with an error (a status), as opposed to a timeout or network failure. */
|
|
4423
|
+
function isApiError(error) {
|
|
4424
|
+
if (typeof error?.status === 'number')
|
|
4425
|
+
return true;
|
|
4426
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4427
|
+
return /^\S+ \S+ \S+ → \d{3}:/.test(message);
|
|
4428
|
+
}
|