@wtfalch/foundry 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- const projectUuid = await this.ensureCoolifyProject(org, context);
1108
- const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
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
- if (recordedAppId) {
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
- live ??= await this.ports.coolify.getApplication(existing.uuid);
1147
- if (!isRunningOrStarting(live.status)) {
1148
- await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
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
- const patch = {};
1186
- if (want.folder !== have.folder)
1187
- patch[folderField] = want.folder ? `/${want.folder}` : '/';
1188
- if (want.port !== have.port && want.port !== null && !isStatic) {
1189
- patch.ports_exposes = String(want.port);
1190
- }
1191
- if (want.autoDeploy !== have.autoDeploy)
1192
- patch.is_auto_deploy_enabled = want.autoDeploy;
1193
- // Create names the app after `domains`; a changed name reaches a live app
1194
- // here (rule 3). The internal name is set after create, as the calls
1195
- // stack's apps do.
1196
- if (JSON.stringify(names) !== JSON.stringify(haveNames))
1197
- patch.domains = names.join(',');
1198
- if (names.length === 0 && (!existing || patch.domains !== undefined)) {
1199
- patch.custom_internal_name = appNameFor(org, entry);
1200
- }
1201
- if (Object.keys(patch).length > 0)
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(patch.base_directory ||
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
- if (entry.database) {
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
- await this.ensureLogVolume(appId, org, Boolean(existing), appNameFor(org, entry));
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 (existing) {
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
- await this.ports.coolify.bulkUpsertEnvs(appId, envs);
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.
@@ -2636,11 +3279,17 @@ export class FoundryStepExecutor {
2636
3279
  }
2637
3280
  // ---- cloudflare --------------------------------------------------------
2638
3281
  async cloudflareTunnel(action, org, context) {
3282
+ const adoptedTunnel = org.hosting.adopt?.tunnel;
3283
+ const connectorApp = org.hosting.adopt?.connectorApp;
2639
3284
  if (action.desired.enabled === false) {
2640
3285
  const tunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
2641
3286
  if (!tunnelId) {
2642
3287
  throw new Error(`cloudflare:tunnel for ${org.slug}: no live tunnel recorded, nothing to park`);
2643
3288
  }
3289
+ // Parking clears the ingress and stops the connector: never for what was adopted (#312).
3290
+ if (await this.isAdopted(org, adoptedTunnelKey(org), tunnelId)) {
3291
+ throw new Error(`cloudflare:tunnel ${action.key}: adopted, not stopped`);
3292
+ }
2644
3293
  // Cleared, not deleted (#7 corrections 1): an ingress with only the
2645
3294
  // mandatory catch-all routes nothing.
2646
3295
  await this.ports.cloudflare.putTunnelConfig(tunnelId, {
@@ -2661,6 +3310,17 @@ export class FoundryStepExecutor {
2661
3310
  }
2662
3311
  return { resourceId: tunnelId };
2663
3312
  }
3313
+ // An adopted tunnel and connector are written to only under an active
3314
+ // snapshot; one that was adopted and is no longer declared is not taken as
3315
+ // foundry's own (#312).
3316
+ if (adoptedTunnel)
3317
+ await this.requireSnapshot(org, adoptedTunnelKey(org), adoptedTunnel);
3318
+ else
3319
+ await this.refuseTakeover(org, adoptedTunnelKey(org));
3320
+ if (connectorApp)
3321
+ await this.requireSnapshot(org, adoptedConnectorKey(org), connectorApp);
3322
+ else
3323
+ await this.refuseTakeover(org, adoptedConnectorKey(org));
2664
3324
  const tunnelName = `${org.slug}-tunnel`;
2665
3325
  // #236 round 5 finding 2 / round 6 finding 2: read, and (if it is about
2666
3326
  // to change) added to the accumulated replaced-id set, before
@@ -2669,8 +3329,11 @@ export class FoundryStepExecutor {
2669
3329
  // domain declared still leaves `destroy` a way to recognise the old
2670
3330
  // id's CNAMEs as this tunnel's own rather than a foreign record.
2671
3331
  const recordedTunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
2672
- const existing = await this.ports.cloudflare.findTunnelByName(tunnelName);
2673
- const tunnel = existing ?? (await this.ports.cloudflare.createTunnel(tunnelName));
3332
+ // An adopted tunnel is the one declared by id: never found by name, never created (#312).
3333
+ const tunnel = adoptedTunnel
3334
+ ? { id: adoptedTunnel }
3335
+ : ((await this.ports.cloudflare.findTunnelByName(tunnelName)) ??
3336
+ (await this.ports.cloudflare.createTunnel(tunnelName)));
2674
3337
  const previousIds = parseTunnelPreviousIds(context.recordedIds());
2675
3338
  if (recordedTunnelId && recordedTunnelId !== tunnel.id) {
2676
3339
  previousIds.add(recordedTunnelId);
@@ -2693,13 +3356,18 @@ export class FoundryStepExecutor {
2693
3356
  // (#115) gets one ingress entry per hostname `tunnelIngressHostnames`
2694
3357
  // returns, all routed to the same proxy -- Traefik itself, not this list,
2695
3358
  // is what tells the public site apart from the cms studio.
2696
- const ingress = tunnelIngressHostnames(org).map((hostname) => ({
2697
- hostname,
2698
- service: COOLIFY_PROXY_ORIGIN,
2699
- }));
2700
- await this.ports.cloudflare.putTunnelConfig(tunnel.id, {
2701
- ingress: [...ingress, { service: 'http_status:404' }],
2702
- });
3359
+ if (adoptedTunnel) {
3360
+ await this.mergeAdoptedIngress(org, tunnel.id);
3361
+ }
3362
+ else {
3363
+ const ingress = tunnelIngressHostnames(org).map((hostname) => ({
3364
+ hostname,
3365
+ service: COOLIFY_PROXY_ORIGIN,
3366
+ }));
3367
+ await this.ports.cloudflare.putTunnelConfig(tunnel.id, {
3368
+ ingress: [...ingress, { service: 'http_status:404' }],
3369
+ });
3370
+ }
2703
3371
  // #235: a connector for this org's own tunnel, the same call every
2704
3372
  // platform tunnel step already makes (Decided, William, 2026-10-01:
2705
3373
  // one connector per org tunnel, never a shared tunnel with appended
@@ -2707,22 +3375,40 @@ export class FoundryStepExecutor {
2707
3375
  // nothing is dialed into it, so `hostname` answers with a Cloudflare
2708
3376
  // error -- #197's own gap, for an org's tunnel rather than a platform
2709
3377
  // one.
2710
- const projectUuid = await this.ensureCoolifyProject(org, context);
2711
- const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2712
- await ensureCloudflaredConnector({
3378
+ // An adopted connector is the app declared by uuid: it needs no project to
3379
+ // be found in or created under (#312).
3380
+ let place;
3381
+ if (!connectorApp) {
3382
+ const projectUuid = await this.ensureCoolifyProject(org, context);
3383
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
3384
+ place = { projectUuid, environment };
3385
+ }
3386
+ else {
3387
+ context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, connectorApp);
3388
+ }
3389
+ await this.guarded(Boolean(adoptedTunnel), `cloudflare:tunnel for ${org.slug}: updating the connector of tunnel ${tunnel.id}`, () => ensureCloudflaredConnector({
2713
3390
  coolify: this.ports.coolify,
2714
3391
  cloudflare: this.ports.cloudflare,
2715
3392
  tunnelId: tunnel.id,
2716
3393
  appName: `${org.slug}-cloudflared`,
2717
- projectUuid,
2718
- environment,
3394
+ ...place,
3395
+ adopted: Boolean(connectorApp),
3396
+ // Restore redeploys a connector whose token foundry rewrote, so this is recorded first.
3397
+ // The digest tells restore the live token is foundry's; the token itself is never stored.
3398
+ beforeTokenWrite: connectorApp
3399
+ ? (token) => this.updateAdoptionSettings(org, adoptedConnectorKey(org), (settings) => ({
3400
+ ...settings,
3401
+ tokenWritten: true,
3402
+ tokenDigest: sha256Hex(token),
3403
+ }))
3404
+ : undefined,
2719
3405
  resolveServer: () => this.resolveServer(),
2720
3406
  recordedConnectorAppId: context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY] ?? null,
2721
3407
  recordAppId: async (appId) => {
2722
3408
  context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, appId);
2723
3409
  await context.persistScopeIds();
2724
3410
  },
2725
- });
3411
+ }));
2726
3412
  // #236: an org with no domain (a level-1 org whose app still serves off
2727
3413
  // the shared `*.oikos.host` wildcard tunnel, `scripts/shared-ingress.mjs`)
2728
3414
  // has no zone of its own and nothing here to publish -- `org.domain` is
@@ -2755,12 +3441,14 @@ export class FoundryStepExecutor {
2755
3441
  // failure leaves the *old* domain recorded, so a retry sees the same
2756
3442
  // domain change again and tries cleanup again.
2757
3443
  if (org.domain) {
3444
+ const { domain } = org;
2758
3445
  const recordedBefore = context.recordedIds();
2759
3446
  const previousDomain = recordedBefore[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY];
2760
3447
  const previousHostnamesRaw = recordedBefore[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
2761
3448
  const previousHostnames = previousHostnamesRaw ? previousHostnamesRaw.split(',') : [];
2762
3449
  const domainChanged = previousDomain !== undefined && previousDomain !== org.domain;
2763
- const publishedHostnames = await this.publishTunnelDnsRecords(org, org.domain, tunnel.id,
3450
+ const dnsFailed = `cloudflare:tunnel for ${org.slug}: publishing DNS for tunnel ${tunnel.id}`;
3451
+ const publishedHostnames = await this.guarded(Boolean(adoptedTunnel), dnsFailed, () => this.publishTunnelDnsRecords(org, domain, tunnel.id,
2764
3452
  // Round 6 finding 2: every id this step has ever replaced, not only
2765
3453
  // the one `recordedTunnelId` captured above -- see the scope key's
2766
3454
  // own doc comment.
@@ -2770,9 +3458,13 @@ export class FoundryStepExecutor {
2770
3458
  // needs the domain's own previous hostname list for that. A domain
2771
3459
  // change instead cleans the *whole* old domain below, so it gets an
2772
3460
  // empty list here rather than a second, redundant pass over it.
2773
- domainChanged ? [] : previousHostnames, context);
3461
+ domainChanged ? [] : previousHostnames, context));
2774
3462
  if (domainChanged) {
2775
- await this.deleteStaleTunnelDnsRecords(tunnel.id, previousIds, previousDomain, previousHostnames, context);
3463
+ await this.guarded(Boolean(adoptedTunnel), dnsFailed, async () => this.deleteStaleTunnelDnsRecords(tunnel.id, previousIds, previousDomain, previousHostnames, context,
3464
+ // An adopted tunnel's CNAME that foundry did not create is never deleted.
3465
+ adoptedTunnel
3466
+ ? new Set(createdDnsRecords((await this.ports.adoptionStore?.get(org.id, adoptedTunnelKey(org)))?.settings).map((r) => r.id))
3467
+ : undefined));
2776
3468
  }
2777
3469
  context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY, org.domain);
2778
3470
  context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY, publishedHostnames.join(','));
@@ -2800,6 +3492,80 @@ export class FoundryStepExecutor {
2800
3492
  }
2801
3493
  return { resourceId: tunnel.id };
2802
3494
  }
3495
+ /**
3496
+ * Runs `call`; for an adopted resource a failure that is not one of this
3497
+ * file's own messages (which name hostnames and record types only) is
3498
+ * replaced by `label`, because a client's error text carries response
3499
+ * bodies (rule 6, #312).
3500
+ */
3501
+ async guarded(adopted, label, call) {
3502
+ if (!adopted)
3503
+ return call();
3504
+ try {
3505
+ return await call();
3506
+ }
3507
+ catch (error) {
3508
+ if (error instanceof Error && error.message.startsWith('cloudflare:tunnel for '))
3509
+ throw error;
3510
+ throw new Error(`${label} failed`);
3511
+ }
3512
+ }
3513
+ /**
3514
+ * #312: writes the org's routes into the adopted tunnel's LIVE ingress list
3515
+ * (`mergeIngress`), not the snapshot, so a route a person added since is
3516
+ * kept too. The declared names go into the tunnel row's `written` only after
3517
+ * the write that carried them succeeded, so a crash in between leaves a
3518
+ * route taken as foreign and kept. Writes nothing when the merged list is the
3519
+ * live one. Errors name the tunnel id and the operation only.
3520
+ */
3521
+ async mergeAdoptedIngress(org, tunnelId) {
3522
+ const cf = this.ports.cloudflare;
3523
+ const failed = (operation) => new Error(`cloudflare:tunnel for ${org.slug}: ${operation} of tunnel ${tunnelId} failed`);
3524
+ let live;
3525
+ try {
3526
+ live = (await cf.getTunnelConfig(tunnelId)).config;
3527
+ if (live && !Array.isArray(live.ingress))
3528
+ throw new Error('not a list');
3529
+ }
3530
+ catch {
3531
+ throw failed('reading the ingress');
3532
+ }
3533
+ const declared = tunnelIngressHostnames(org);
3534
+ const row = await this.ports.adoptionStore?.get(org.id, adoptedTunnelKey(org));
3535
+ // A name in `writing` may have been carried by a put whose row write never landed.
3536
+ const written = new Set([...writtenIngressNames(row), ...writingIngressNames(row)]);
3537
+ const snapshot = Array.isArray(row?.settings.ingress) ? row.settings.ingress : [];
3538
+ const merged = mergeIngress(live?.ingress ?? [], declared, COOLIFY_PROXY_ORIGIN, written, snapshot);
3539
+ try {
3540
+ const put = !isDeepStrictEqual(merged, live?.ingress ?? []);
3541
+ if (put) {
3542
+ // Recorded before the put: a failed row write after it must not strand foundry's routes.
3543
+ await this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
3544
+ ...settings,
3545
+ writing: [...new Set([...writingIngressNames(row), ...declared])],
3546
+ }));
3547
+ await cf.putTunnelConfig(tunnelId, { ...live, ingress: merged });
3548
+ }
3549
+ if (put || declared.some((hostname) => !written.has(hostname))) {
3550
+ await this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
3551
+ ...settings,
3552
+ written: [...new Set([...written, ...declared])],
3553
+ writing: [],
3554
+ }));
3555
+ }
3556
+ }
3557
+ catch {
3558
+ throw failed('writing the ingress');
3559
+ }
3560
+ }
3561
+ /** #312: replaces the `settings` of an adoption row, read fresh so no other key is lost. The state and `takenAt` stay. */
3562
+ async updateAdoptionSettings(org, resourceKey, update) {
3563
+ const { adoptionStore: store } = this.ports;
3564
+ const row = await store?.get(org.id, resourceKey);
3565
+ if (!store || !row)
3566
+ throw new Error('no adoption row to record in');
3567
+ await store.setSettings(org.id, resourceKey, update(row.settings));
3568
+ }
2803
3569
  /**
2804
3570
  * #236: when this tunnel's DNS was last published under a domain that
2805
3571
  * differs from the one `org` now declares, deletes every CNAME in the
@@ -2841,7 +3607,7 @@ export class FoundryStepExecutor {
2841
3607
  * hostnames) never added entries of its own to begin with, so there is
2842
3608
  * nothing of this domain's left in that set either way.
2843
3609
  */
2844
- async deleteStaleTunnelDnsRecords(tunnelId, previousIds, previousDomain, previousHostnames, context) {
3610
+ async deleteStaleTunnelDnsRecords(tunnelId, previousIds, previousDomain, previousHostnames, context, onlyIds) {
2845
3611
  const zone = await this.ports.cloudflare.findZoneByApex(previousDomain);
2846
3612
  if (!zone)
2847
3613
  return;
@@ -2855,7 +3621,7 @@ export class FoundryStepExecutor {
2855
3621
  ? (await Promise.all(previousHostnames.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
2856
3622
  : await cf.listDnsRecords(zone.id, { type: 'CNAME' });
2857
3623
  for (const record of records) {
2858
- if (dnsTargets.has(record.content)) {
3624
+ if (dnsTargets.has(record.content) && (!onlyIds || onlyIds.has(record.id))) {
2859
3625
  await cf.deleteDnsRecord(zone.id, record.id);
2860
3626
  }
2861
3627
  }
@@ -2929,12 +3695,15 @@ export class FoundryStepExecutor {
2929
3695
  if (!zone) {
2930
3696
  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
3697
  }
3698
+ const adopted = Boolean(org.hosting.adopt?.tunnel);
3699
+ const tunnelRow = () => this.ports.adoptionStore?.get(org.id, adoptedTunnelKey(org));
2932
3700
  const dnsTarget = `${tunnelId}.cfargotunnel.com`;
2933
3701
  const previousTargets = new Set([...previousIds].filter((id) => id !== tunnelId).map((id) => `${id}.cfargotunnel.com`));
2934
3702
  const hostnames = tunnelDnsHostnames(org, domain);
2935
3703
  const droppedHostnames = previousHostnames.filter((hostname) => !hostnames.includes(hostname));
2936
3704
  const conflicts = [];
2937
3705
  const toCreate = [];
3706
+ const toClaim = [];
2938
3707
  const toRepoint = [];
2939
3708
  for (const hostname of hostnames) {
2940
3709
  const [a] = await cf.findDnsRecord(zone.id, hostname, 'A');
@@ -2955,6 +3724,11 @@ export class FoundryStepExecutor {
2955
3724
  }
2956
3725
  if (!cname)
2957
3726
  toCreate.push(hostname);
3727
+ // A crash after the create left a CNAME to the tunnel that is foundry's.
3728
+ else if (adopted &&
3729
+ dnsCreatingNames((await tunnelRow())?.settings).some((n) => normalizeHostname(n) === normalizeHostname(hostname))) {
3730
+ toClaim.push({ hostname, recordId: cname.id });
3731
+ }
2958
3732
  }
2959
3733
  if (conflicts.length > 0) {
2960
3734
  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 +3749,43 @@ export class FoundryStepExecutor {
2975
3749
  };
2976
3750
  for (const hostname of settled)
2977
3751
  recordTracked(hostname);
3752
+ // The row learns of a record foundry made: `dnsCreating` first, then `dnsRecords` with its id.
3753
+ const recordCreated = (hostname, recordId) => this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
3754
+ ...settings,
3755
+ dnsRecords: [...createdDnsRecords(settings), { zoneId: zone.id, id: recordId, hostname }],
3756
+ dnsCreating: dnsCreatingNames(settings).filter((n) => normalizeHostname(n) !== normalizeHostname(hostname)),
3757
+ }));
3758
+ for (const { hostname, recordId } of toClaim)
3759
+ await recordCreated(hostname, recordId);
2978
3760
  for (const hostname of toCreate) {
2979
- await cf.createDnsRecord(zone.id, {
2980
- type: 'CNAME',
2981
- name: hostname,
2982
- content: dnsTarget,
2983
- proxied: true,
2984
- });
3761
+ if (adopted) {
3762
+ await this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
3763
+ ...settings,
3764
+ dnsCreating: [...new Set([...dnsCreatingNames(settings), hostname])],
3765
+ }));
3766
+ }
3767
+ let created;
3768
+ try {
3769
+ created = await cf.createDnsRecord(zone.id, {
3770
+ type: 'CNAME',
3771
+ name: hostname,
3772
+ content: dnsTarget,
3773
+ proxied: true,
3774
+ });
3775
+ }
3776
+ catch (error) {
3777
+ // The call answered with an error: no record of foundry's, so a later one is a person's.
3778
+ // A timeout or network failure may have created it: `dnsCreating` stays for the claim.
3779
+ if (adopted && isApiError(error)) {
3780
+ await this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
3781
+ ...settings,
3782
+ dnsCreating: dnsCreatingNames(settings).filter((n) => normalizeHostname(n) !== normalizeHostname(hostname)),
3783
+ })).catch(() => { });
3784
+ }
3785
+ throw error;
3786
+ }
3787
+ if (adopted)
3788
+ await recordCreated(hostname, created.id);
2985
3789
  recordTracked(hostname);
2986
3790
  }
2987
3791
  for (const { hostname, recordId } of toRepoint) {
@@ -3005,7 +3809,10 @@ export class FoundryStepExecutor {
3005
3809
  // way.
3006
3810
  for (const hostname of droppedHostnames) {
3007
3811
  const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
3008
- if (cname && (cname.content === dnsTarget || previousTargets.has(cname.content))) {
3812
+ // An adopted tunnel's CNAME is deleted only if foundry created it.
3813
+ const mine = !adopted ||
3814
+ createdDnsRecords((await tunnelRow())?.settings).some((r) => r.id === cname?.id);
3815
+ if (cname && mine && (cname.content === dnsTarget || previousTargets.has(cname.content))) {
3009
3816
  await cf.deleteDnsRecord(zone.id, cname.id);
3010
3817
  }
3011
3818
  tracked.delete(trackedTunnelDnsEntry(domain, hostname));
@@ -3104,9 +3911,9 @@ export class FoundryStepExecutor {
3104
3911
  // #177: what this step wrote from the platform, so `planMail` can see a later change.
3105
3912
  context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
3106
3913
  context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
3107
- await this.ensureOwnerMailbox(org, mailOrg, context);
3914
+ await this.ensureOwnerMailbox(org, mailOrg, options, context);
3108
3915
  await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context);
3109
- await this.ensureRoleMailboxes(org, mailOrg, result.domain, context);
3916
+ await this.ensureRoleMailboxes(org, mailOrg, result.domain, options, context);
3110
3917
  return { resourceId: result.domain };
3111
3918
  }
3112
3919
  /**
@@ -3147,7 +3954,7 @@ export class FoundryStepExecutor {
3147
3954
  * so a second apply never calls `MailPort.createMailbox` again, rather
3148
3955
  * than relying on that port method's own duplicate refusal.
3149
3956
  */
3150
- async ensureOwnerMailbox(org, mailOrg, context) {
3957
+ async ensureOwnerMailbox(org, mailOrg, options, context) {
3151
3958
  if (!org.owner)
3152
3959
  return;
3153
3960
  const address = org.mail.ownerMailboxAddress ?? null;
@@ -3158,7 +3965,7 @@ export class FoundryStepExecutor {
3158
3965
  await this.ports.mail.createMailbox(mailOrg, {
3159
3966
  address,
3160
3967
  name: `${org.owner.givenName} ${org.owner.familyName}`,
3161
- });
3968
+ }, options);
3162
3969
  context.recordScopeId(MAIL_OWNER_MAILBOX_SCOPE_KEY, address);
3163
3970
  }
3164
3971
  /**
@@ -3171,7 +3978,7 @@ export class FoundryStepExecutor {
3171
3978
  * apply (or a retry after a crash) skips what exists rather than relying
3172
3979
  * on the mail server's own duplicate refusal.
3173
3980
  */
3174
- async ensureRoleMailboxes(org, mailOrg, domain, context) {
3981
+ async ensureRoleMailboxes(org, mailOrg, domain, options, context) {
3175
3982
  const declared = org.mail.roleMailboxes ?? [];
3176
3983
  if (declared.length === 0)
3177
3984
  return;
@@ -3183,7 +3990,7 @@ export class FoundryStepExecutor {
3183
3990
  if (created.has(entry))
3184
3991
  continue;
3185
3992
  try {
3186
- await this.ports.mail.createMailbox(mailOrg, { address, name: local });
3993
+ await this.ports.mail.createMailbox(mailOrg, { address, name: local }, options);
3187
3994
  }
3188
3995
  catch (error) {
3189
3996
  // A crash between create and record leaves the mailbox made but
@@ -3445,12 +4252,16 @@ export class FoundryStepExecutor {
3445
4252
  return;
3446
4253
  }
3447
4254
  case 'cloudflare:tunnel': {
4255
+ // An adopted tunnel and connector are never deleted (#312).
4256
+ if (await this.isAdopted(org, adoptedTunnelKey(org), target.id))
4257
+ return;
3448
4258
  // #235: the connector app first, its own 404 handled locally --
3449
4259
  // a retry where this already succeeded must still reach
3450
4260
  // `deleteTunnel` below, which the outer catch's own 404 handling
3451
4261
  // would otherwise short-circuit before it ran.
3452
4262
  const connectorAppId = recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
3453
- if (connectorAppId) {
4263
+ if (connectorAppId &&
4264
+ !(await this.isAdopted(org, adoptedConnectorKey(org), connectorAppId))) {
3454
4265
  try {
3455
4266
  await this.ports.coolify.deleteApplication(connectorAppId);
3456
4267
  }
@@ -3535,9 +4346,13 @@ export class FoundryStepExecutor {
3535
4346
  await this.ports.cloudflare.deleteAccessApp(target.id);
3536
4347
  return;
3537
4348
  case 'coolify:app':
4349
+ if (declaredAdoptUuids(org).has(target.id))
4350
+ return;
3538
4351
  await this.ports.coolify.deleteApplication(target.id);
3539
4352
  return;
3540
4353
  case 'coolify:database':
4354
+ if (declaredAdoptUuids(org).has(target.id))
4355
+ return;
3541
4356
  await this.ports.coolify.deleteDatabase(target.id);
3542
4357
  return;
3543
4358
  case 'zitadel:app': {
@@ -3598,3 +4413,10 @@ export function isNotFoundError(error) {
3598
4413
  const message = error instanceof Error ? error.message : String(error);
3599
4414
  return /^\S+ \S+ \S+ → 404:/.test(message);
3600
4415
  }
4416
+ /** Whether the API answered with an error (a status), as opposed to a timeout or network failure. */
4417
+ function isApiError(error) {
4418
+ if (typeof error?.status === 'number')
4419
+ return true;
4420
+ const message = error instanceof Error ? error.message : String(error);
4421
+ return /^\S+ \S+ \S+ → \d{3}:/.test(message);
4422
+ }