@wtfalch/foundry 0.8.0 → 0.9.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,12 +1,13 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { URL } from 'node:url';
3
3
  import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
4
+ import { APP_ENTRY_KEY, appEntry } from '../org.js';
4
5
  import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, agoraHostnameFor, desiredAgoraEnv, } from './agora.js';
5
6
  import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
6
7
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
7
8
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
8
9
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
9
- import { APP_ENV_APPLIED_SCOPE_KEY, CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, DB_ENV_APPLIED_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, resolveAppEnv, } from './platform-diff.js';
10
+ import { APP_ENV_APPLIED_SCOPE_KEY, CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, DB_ENV_APPLIED_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appStepKey, resolveAppEnv, } from './platform-diff.js';
10
11
  /**
11
12
  * #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
12
13
  * listApps` -- a plain array, with no `loginBaseUri` or completeness signal
@@ -47,6 +48,14 @@ export class NotBuiltError extends Error {
47
48
  // ---------------------------------------------------------------------------
48
49
  // Hostnames
49
50
  // ---------------------------------------------------------------------------
51
+ /**
52
+ * The Coolify name of a hosting entry's app: `<slug>` for the `app` entry, so
53
+ * the move to a list finds the app that runs today, `<slug>-<key>` for any
54
+ * other.
55
+ */
56
+ function appNameFor(org, entry) {
57
+ return entry.key === APP_ENTRY_KEY ? org.slug : `${org.slug}-${entry.key}`;
58
+ }
50
59
  /**
51
60
  * `<slug>.oikos.host` (`../slug.ts`), or `DeclaredOrg.domain` once
52
61
  * `hosting.usesDomain` is true — the same rule `diff.ts`'s `planApp` and
@@ -338,36 +347,94 @@ const CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY = 'cloudflare_tunnel_dns_domain';
338
347
  */
339
348
  const CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_dns_hostnames';
340
349
  /**
341
- * #236 round 5 finding 1: the domain, and the hostnames under it,
342
- * {@link FoundryStepExecutor.publishTunnelDnsRecords} is in the middle of
343
- * writing CNAMEs for -- recorded after *each* individual CNAME write
344
- * succeeds, well before the step as a whole returns and
345
- * {@link CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY}/
346
- * {@link CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY} themselves are updated
347
- * (which, on a domain change, waits for the *old* domain's cleanup to
348
- * succeed too -- round 4 finding 3). Without this, a `createDnsRecord`
349
- * failure partway through a hostname list, or an old-domain cleanup failure
350
- * right after a successful publish, left an already-created CNAME out of
351
- * every recorded list, so `destroy` had no way to find and remove it.
352
- * `destroy` checks this pair in addition to the authoritative one above.
353
- * Never read by `cloudflareTunnel` itself to decide anything -- it is
354
- * destroy's (and a retried apply's own idempotent re-check's) safety net,
355
- * not a second source of truth to branch planning on.
350
+ * #236 round 6 finding 1: every `${domain}|${hostname}` pair
351
+ * {@link FoundryStepExecutor.publishTunnelDnsRecords} or
352
+ * {@link FoundryStepExecutor.deleteStaleTunnelDnsRecords} has written (or
353
+ * is in the middle of writing) a CNAME for -- across every apply, and
354
+ * across however many domains an org has moved through without a clean
355
+ * resolution, not only the most recent one. Replaces round 5's single
356
+ * pending domain/hostnames pair (`cloudflare_tunnel_dns_pending_domain`/
357
+ * `_hostnames`), which tracked one domain at a time and was overwritten
358
+ * whole on every call: apply 1 moves domain A to B, creates `h1.B`, and
359
+ * old-domain cleanup throws, leaving pending at `B/[h1]`; the org then
360
+ * declares C, and apply 2's own pending write overwrites it with
361
+ * `C/[...]` -- `h1.B` is now in no recorded list at all, so `destroy`
362
+ * leaves it live forever. This key instead accumulates entries (adds,
363
+ * never replaces the set) via {@link parseTrackedTunnelDns}/
364
+ * {@link trackedTunnelDnsState}, and an entry is dropped only once its own
365
+ * CNAME is deleted, confirmed absent, or confirmed not this tunnel's.
366
+ * `destroy` and {@link FoundryStepExecutor.deleteStaleTunnelDnsRecords}
367
+ * both walk every domain this set still names, not only the org's current
368
+ * or last-recorded one. Neither a domain nor a DNS hostname can contain
369
+ * `,` or `|`, so the join is unambiguous; sorted (like
370
+ * {@link LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY}, `login-host.ts`) so the
371
+ * recorded value is stable regardless of write order.
356
372
  */
357
- const CLOUDFLARE_TUNNEL_DNS_PENDING_DOMAIN_SCOPE_KEY = 'cloudflare_tunnel_dns_pending_domain';
358
- const CLOUDFLARE_TUNNEL_DNS_PENDING_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_dns_pending_hostnames';
373
+ const CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY = 'cloudflare_tunnel_dns_tracked';
374
+ /** {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY}'s entries, parsed back out. */
375
+ function parseTrackedTunnelDns(recorded) {
376
+ const raw = recorded[CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY];
377
+ return new Set(raw ? raw.split(',').filter((entry) => entry.length > 0) : []);
378
+ }
379
+ /** One {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY} entry for `domain`'s `hostname`. */
380
+ function trackedTunnelDnsEntry(domain, hostname) {
381
+ return `${domain}|${hostname}`;
382
+ }
383
+ /** {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY}'s own serialisation, sorted so the recorded value is stable regardless of write order. */
384
+ function trackedTunnelDnsState(entries) {
385
+ return [...entries].sort().join(',');
386
+ }
387
+ /** {@link parseTrackedTunnelDns}'s entries, grouped by domain so cleanup can walk one zone at a time. */
388
+ function trackedTunnelDnsByDomain(entries) {
389
+ const byDomain = new Map();
390
+ for (const entry of entries) {
391
+ const separator = entry.indexOf('|');
392
+ if (separator === -1)
393
+ continue;
394
+ const domain = entry.slice(0, separator);
395
+ const hostname = entry.slice(separator + 1);
396
+ const hostnames = byDomain.get(domain) ?? [];
397
+ hostnames.push(hostname);
398
+ byDomain.set(domain, hostnames);
399
+ }
400
+ return byDomain;
401
+ }
359
402
  /**
360
- * #236 round 5 finding 2: the tunnel id this step most recently replaced
361
- * with a new one -- a recreate, `findTunnelByName` returning a different id
362
- * for the same name -- recorded unconditionally, even on an apply where
363
- * `org.domain` is unset and the DNS-publish block below never runs. A CNAME
364
- * published while a domain *was* declared can still be live under that old
365
- * id after the domain is later cleared, so `destroy` needs this even then.
366
- * `publishTunnelDnsRecords`'s own repoint logic already accepts either the
367
- * current tunnel id or this one when matching a CNAME as this org's own
368
- * rather than a foreign conflict (round 3); `destroy` now does the same.
403
+ * #236 round 6 finding 2: every tunnel id this step has ever replaced with
404
+ * a new one, not only the immediately prior one. Round 5's singular
405
+ * `cloudflare_tunnel_previous_id` was overwritten on each recreate, so two
406
+ * recreates with no successful DNS resolution in between (an apply with
407
+ * no domain declared, or one that keeps failing) left the CNAMEs from the
408
+ * *first* recreate matched by nothing -- `destroy` and this step's own
409
+ * repoint logic only ever compared against the latest id. Recorded
410
+ * unconditionally, even on an apply where `org.domain` is unset and the
411
+ * DNS-publish block in {@link FoundryStepExecutor.cloudflareTunnel} never
412
+ * runs: a CNAME published while a domain *was* declared can still be live
413
+ * under an old id after the domain is later cleared. Comma-joined, sorted,
414
+ * the same shape {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY} above
415
+ * uses (tunnel ids carry no `,`). Cleared once that DNS-publish block
416
+ * completes *and* (round 7) the tracked set names no domain but the one
417
+ * just published -- only then has every tracked CNAME that could have
418
+ * pointed at any of them been repointed to the current id or cleaned up.
419
+ * A tracked entry left under some other domain by an earlier apply whose
420
+ * own cleanup never ran, or itself threw, can still point at an old id, so
421
+ * clearing unconditionally (round 6) left `destroy` unable to recognise
422
+ * that CNAME as this tunnel's own once a later apply resolved a different
423
+ * domain change and moved on. An apply with no domain declared never
424
+ * reaches that point either, so the set is left to accumulate across such
425
+ * applies, and across an unresolved domain change, until one apply
426
+ * publishes with nothing tracked outside its own domain.
369
427
  */
370
- const CLOUDFLARE_TUNNEL_PREVIOUS_ID_SCOPE_KEY = 'cloudflare_tunnel_previous_id';
428
+ const CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY = 'cloudflare_tunnel_previous_ids';
429
+ /** {@link CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY}'s entries, parsed back out. */
430
+ function parseTunnelPreviousIds(recorded) {
431
+ const raw = recorded[CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY];
432
+ return new Set(raw ? raw.split(',').filter((id) => id.length > 0) : []);
433
+ }
434
+ /** {@link CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY}'s own serialisation, sorted so the recorded value is stable regardless of write order. */
435
+ function tunnelPreviousIdsState(ids) {
436
+ return [...ids].sort().join(',');
437
+ }
371
438
  /**
372
439
  * #162: the owner's own mailbox, created once inside `coolify:mail` after
373
440
  * `applyOrgMail` succeeds, alongside {@link MAIL_DOMAIN_SCOPE_KEY} -- another
@@ -562,7 +629,7 @@ export class FoundryStepExecutor {
562
629
  }
563
630
  /**
564
631
  * `plan/diff.ts`'s `planAccess` (#84): puts the org's app hostname behind a
565
- * Cloudflare Access app that lets only `hosting.accessEmails` through.
632
+ * Cloudflare Access app that lets only the `app` entry's `accessEmails` through.
566
633
  * foundry names its Access app `foundry:<orgId>` and reuses only an app with
567
634
  * that name, so a re-run after a crash does not duplicate it. It refuses,
568
635
  * rather than adopt, when another Access app already holds the hostname:
@@ -588,7 +655,7 @@ export class FoundryStepExecutor {
588
655
  await cf.createAccessPolicy(app.id, {
589
656
  name: ACCESS_POLICY_NAME,
590
657
  decision: 'allow',
591
- include: org.hosting.accessEmails.map((email) => ({ email: { email } })),
658
+ include: (appEntry(org.hosting)?.accessEmails ?? []).map((email) => ({ email: { email } })),
592
659
  });
593
660
  }
594
661
  return { resourceId: app.id };
@@ -1036,23 +1103,42 @@ export class FoundryStepExecutor {
1036
1103
  return server;
1037
1104
  }
1038
1105
  async coolifyApp(action, org, context) {
1106
+ const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
1039
1107
  if (action.desired.enabled === false) {
1040
- const appId = context.recordedIds()[`coolify:app:${action.key}`];
1041
- if (!appId) {
1108
+ if (!recordedAppId) {
1042
1109
  throw new Error(`coolify:app for ${org.slug}: no live app recorded, nothing to park`);
1043
1110
  }
1044
- await this.ports.coolify.raw('POST', `/applications/${appId}/stop`);
1045
- return { resourceId: appId };
1111
+ await this.ports.coolify.raw('POST', `/applications/${recordedAppId}/stop`);
1112
+ return { resourceId: recordedAppId };
1046
1113
  }
1114
+ const entry = appEntry(org.hosting);
1115
+ if (!entry)
1116
+ throw new Error(`coolify:app for ${org.slug}: no '${APP_ENTRY_KEY}' hosting entry`);
1047
1117
  // A level-1 org with no bespoke app of its own gets the shared standard
1048
- // app instead (gap P1, `.plans/2026-09-26-level-1-org-map.md`), in the
1049
- // same repo shape `bespokeAppRepo` itself uses.
1050
- const appRepo = org.bespokeAppRepo ?? DEFAULT_APP_REPO;
1118
+ // app instead (gap P1, `.plans/2026-09-26-level-1-org-map.md`): an entry
1119
+ // with no `repo`.
1120
+ const appRepo = entry.repo ?? DEFAULT_APP_REPO;
1051
1121
  const projectUuid = await this.ensureCoolifyProject(org, context);
1052
1122
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
1053
- const existing = await this.ports.coolify.findApplicationByName(org.slug, {
1054
- environmentIds: [environment.id],
1055
- });
1123
+ // The recorded id first; the name when nothing is recorded, or when the
1124
+ // recorded app is gone.
1125
+ let existing = null;
1126
+ let live;
1127
+ if (recordedAppId) {
1128
+ try {
1129
+ live = await this.ports.coolify.getApplication(recordedAppId);
1130
+ existing = { uuid: recordedAppId };
1131
+ }
1132
+ catch (error) {
1133
+ if (!isNotFoundError(error))
1134
+ throw error;
1135
+ }
1136
+ }
1137
+ if (!existing) {
1138
+ existing = await this.ports.coolify.findApplicationByName(appNameFor(org, entry), {
1139
+ environmentIds: [environment.id],
1140
+ });
1141
+ }
1056
1142
  let appId;
1057
1143
  if (existing) {
1058
1144
  appId = existing.uuid;
@@ -1062,7 +1148,7 @@ export class FoundryStepExecutor {
1062
1148
  // stops the app without deleting it, so re-enabling found it here,
1063
1149
  // still stopped, and it stayed stopped forever despite `apply`
1064
1150
  // reporting success.
1065
- const live = await this.ports.coolify.getApplication(existing.uuid);
1151
+ live ??= await this.ports.coolify.getApplication(existing.uuid);
1066
1152
  if (!isRunningOrStarting(live.status)) {
1067
1153
  await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
1068
1154
  }
@@ -1077,13 +1163,13 @@ export class FoundryStepExecutor {
1077
1163
  environment_uuid: environment.uuid,
1078
1164
  github_app_uuid: githubApp.uuid,
1079
1165
  git_repository: appRepo,
1080
- git_branch: org.hosting.gitBranch,
1081
- build_pack: org.hosting.buildPack,
1082
- name: org.slug,
1166
+ git_branch: entry.branch,
1167
+ build_pack: entry.buildPack,
1168
+ name: appNameFor(org, entry),
1083
1169
  domains: hostnameFor(org),
1084
1170
  // Only the shared standard app builds from this repo; a bespoke
1085
1171
  // app's Dockerfile is at its own repo's root.
1086
- dockerfile_location: org.bespokeAppRepo ? undefined : DEFAULT_APP_DOCKERFILE,
1172
+ dockerfile_location: entry.repo ? undefined : DEFAULT_APP_DOCKERFILE,
1087
1173
  });
1088
1174
  appId = created.uuid;
1089
1175
  }
@@ -1093,8 +1179,8 @@ export class FoundryStepExecutor {
1093
1179
  // covers the database being turned on after the app already exists (the
1094
1180
  // app step still ran this apply, found the app by name, and lands here).
1095
1181
  let dbPush = { pushed: false, changed: false };
1096
- if (org.hosting.database) {
1097
- const dbId = context.recordedIds()[`coolify:database:${action.key}`];
1182
+ if (entry.database) {
1183
+ const dbId = context.recordedIds()[`coolify:database:${org.id}`];
1098
1184
  if (dbId) {
1099
1185
  dbPush = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
1100
1186
  }
@@ -1114,7 +1200,7 @@ export class FoundryStepExecutor {
1114
1200
  // The app receives whatever it needs to sign in (gap P2). Unconditional,
1115
1201
  // unlike the database push above: every app has a hostname, and an
1116
1202
  // upsert of the same value on a later apply is a no-op, not a change.
1117
- const appEnv = await this.pushAppEnv(appId, org, context);
1203
+ const appEnv = await this.pushAppEnv(appId, org, entry, context);
1118
1204
  // Rule 4 (#239, #243): a container reads its env only at start, so an app
1119
1205
  // that was already there is restarted, once, when its database URLs or its
1120
1206
  // declared appEnv changed.
@@ -1207,7 +1293,7 @@ export class FoundryStepExecutor {
1207
1293
  // Handles the database being provisioned after the app already exists
1208
1294
  // (`coolifyApp`'s own push handles the more common order, database
1209
1295
  // before app on the same apply -- `diff.ts`'s `computePlanDetail`).
1210
- const appId = context.recordedIds()[`coolify:app:${action.key}`];
1296
+ const appId = context.recordedIds()[`coolify:app:${appStepKey(org)}`];
1211
1297
  if (appId) {
1212
1298
  const push = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
1213
1299
  if (push.pushed) {
@@ -1288,7 +1374,7 @@ export class FoundryStepExecutor {
1288
1374
  * P2, `.plans/2026-09-26-level-1-org-map.md`) -- a second `bulkUpsertEnvs`
1289
1375
  * call rather than folding into `pushDatabaseUrl`'s, since this one runs
1290
1376
  * unconditionally (`coolifyApp`) where that one runs only once
1291
- * `hosting.database` is declared and provisioned.
1377
+ * the `app` entry's `database` is declared and provisioned.
1292
1378
  *
1293
1379
  * `APP_URL` and `LOG_DIR` (#75) are pushed unconditionally; every other
1294
1380
  * value is pushed only once its own source step has actually recorded it
@@ -1301,10 +1387,10 @@ export class FoundryStepExecutor {
1301
1387
  * is its own design question, left out of #204's own "How" rather than
1302
1388
  * guessed) is out of scope here (task brief). `DATABASE_URL_OWNER`/
1303
1389
  * `DATABASE_URL` are pushed by `pushDatabaseUrl` instead, not here --
1304
- * that call runs only once `hosting.database` is declared and
1390
+ * that call runs only once the `app` entry's `database` is declared and
1305
1391
  * provisioned, where this one is unconditional (#239).
1306
1392
  */
1307
- async pushAppEnv(appId, org, context) {
1393
+ async pushAppEnv(appId, org, entry, context) {
1308
1394
  const recorded = context.recordedIds();
1309
1395
  const envs = [
1310
1396
  { key: 'APP_URL', value: `https://${hostnameFor(org)}` },
@@ -1339,7 +1425,7 @@ export class FoundryStepExecutor {
1339
1425
  // minted here; a source with no value yet (its own step has not run,
1340
1426
  // or has not run for this apply) is simply omitted, the same way every
1341
1427
  // other optional entry above is.
1342
- const appEnv = resolveAppEnv(org, recorded).values;
1428
+ const appEnv = resolveAppEnv(org, entry, recorded).values;
1343
1429
  for (const [key, value] of Object.entries(appEnv))
1344
1430
  envs.push({ key, value });
1345
1431
  await this.ports.coolify.bulkUpsertEnvs(appId, envs);
@@ -2447,16 +2533,19 @@ export class FoundryStepExecutor {
2447
2533
  return { resourceId: tunnelId };
2448
2534
  }
2449
2535
  const tunnelName = `${org.slug}-tunnel`;
2450
- // #236 round 5 finding 2: read, and (if it is about to change) recorded,
2451
- // before `createTunnel` can replace it -- unconditionally, not only
2452
- // inside the `if (org.domain)` block below, so a recreate on an apply
2453
- // with no domain declared still leaves `destroy` a way to recognise the
2454
- // old id's CNAMEs as this tunnel's own rather than a foreign record.
2536
+ // #236 round 5 finding 2 / round 6 finding 2: read, and (if it is about
2537
+ // to change) added to the accumulated replaced-id set, before
2538
+ // `createTunnel` can replace it -- unconditionally, not only inside the
2539
+ // `if (org.domain)` block below, so a recreate on an apply with no
2540
+ // domain declared still leaves `destroy` a way to recognise the old
2541
+ // id's CNAMEs as this tunnel's own rather than a foreign record.
2455
2542
  const recordedTunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
2456
2543
  const existing = await this.ports.cloudflare.findTunnelByName(tunnelName);
2457
2544
  const tunnel = existing ?? (await this.ports.cloudflare.createTunnel(tunnelName));
2545
+ const previousIds = parseTunnelPreviousIds(context.recordedIds());
2458
2546
  if (recordedTunnelId && recordedTunnelId !== tunnel.id) {
2459
- context.recordScopeId(CLOUDFLARE_TUNNEL_PREVIOUS_ID_SCOPE_KEY, recordedTunnelId);
2547
+ previousIds.add(recordedTunnelId);
2548
+ context.recordScopeId(CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY, tunnelPreviousIdsState(previousIds));
2460
2549
  }
2461
2550
  // Routes each of the org's hostnames (`tunnelIngressHostnames`) to
2462
2551
  // Coolify's own Traefik reverse proxy, not a per-app container address --
@@ -2541,12 +2630,12 @@ export class FoundryStepExecutor {
2541
2630
  const previousDomain = recordedBefore[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY];
2542
2631
  const previousHostnamesRaw = recordedBefore[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
2543
2632
  const previousHostnames = previousHostnamesRaw ? previousHostnamesRaw.split(',') : [];
2544
- // Same value `recordedTunnelId` captured above, before `createTunnel`
2545
- // could replace it -- not re-read here, since this block's own
2546
- // `context.recordScopeId` calls never touch that key.
2547
- const previousTunnelId = recordedTunnelId;
2548
2633
  const domainChanged = previousDomain !== undefined && previousDomain !== org.domain;
2549
- const publishedHostnames = await this.publishTunnelDnsRecords(org, org.domain, tunnel.id, previousTunnelId,
2634
+ const publishedHostnames = await this.publishTunnelDnsRecords(org, org.domain, tunnel.id,
2635
+ // Round 6 finding 2: every id this step has ever replaced, not only
2636
+ // the one `recordedTunnelId` captured above -- see the scope key's
2637
+ // own doc comment.
2638
+ previousIds,
2550
2639
  // Round 4 finding 2: a hostname this domain itself dropped (no domain
2551
2640
  // change) is cleaned up inline by `publishTunnelDnsRecords`, which
2552
2641
  // needs the domain's own previous hostname list for that. A domain
@@ -2554,17 +2643,31 @@ export class FoundryStepExecutor {
2554
2643
  // empty list here rather than a second, redundant pass over it.
2555
2644
  domainChanged ? [] : previousHostnames, context);
2556
2645
  if (domainChanged) {
2557
- await this.deleteStaleTunnelDnsRecords(tunnel.id, previousTunnelId, previousDomain, previousHostnames);
2646
+ await this.deleteStaleTunnelDnsRecords(tunnel.id, previousIds, previousDomain, previousHostnames, context);
2558
2647
  }
2559
2648
  context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY, org.domain);
2560
2649
  context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY, publishedHostnames.join(','));
2561
- // #236 round 5 finding 1: the authoritative pair above now carries
2562
- // everything the pending pair was tracking mid-write -- clear it so a
2563
- // later destroy (or crash-recovery apply) never reads a stale pending
2564
- // domain that no longer matches reality. A no-op the first time this
2565
- // step ever runs (both keys already absent).
2566
- context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_PENDING_DOMAIN_SCOPE_KEY, '');
2567
- context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_PENDING_HOSTNAMES_SCOPE_KEY, '');
2650
+ // #236 round 7: every previous id's CNAMEs *under `org.domain`* have
2651
+ // now been repointed to `tunnel.id` or cleaned up by publish/cleanup
2652
+ // above (current-domain hostnames by the write loop inside
2653
+ // `publishTunnelDnsRecords`, the old domain's by
2654
+ // `deleteStaleTunnelDnsRecords` above) -- but a tracked entry left
2655
+ // under some *other* domain by an earlier apply whose own cleanup
2656
+ // never ran, or itself threw, can still point at one of `previousIds`.
2657
+ // Round 6 finding 2's unconditional clear missed exactly that: apply 1
2658
+ // moves domain A to B, creates `h1.B` on tunnel T1, and A's cleanup
2659
+ // throws; the tunnel is then recreated as T2 out of band and the org
2660
+ // declares domain C; apply 2 publishes C and cleans up only A, leaving
2661
+ // `h1.B` tracked and still pointed at T1 -- clearing the id set here
2662
+ // would make `destroy` and the next `deleteStaleTunnelDnsRecords`
2663
+ // match only T2 and leave `h1.B` live forever. So the set is cleared
2664
+ // only once the tracked set names no domain but the one just
2665
+ // published: the one case where nothing tracked can still reference
2666
+ // an old id. A no-op the first time this step ever runs.
2667
+ const trackedDomains = trackedTunnelDnsByDomain(parseTrackedTunnelDns(context.recordedIds())).keys();
2668
+ if ([...trackedDomains].every((domain) => domain === org.domain)) {
2669
+ context.recordScopeId(CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY, '');
2670
+ }
2568
2671
  }
2569
2672
  return { resourceId: tunnel.id };
2570
2673
  }
@@ -2587,23 +2690,36 @@ export class FoundryStepExecutor {
2587
2690
  * existed falls back to the old zone-wide pass, the one case where no
2588
2691
  * hostname list was ever written to look up instead.
2589
2692
  *
2590
- * `previousTunnelId` (round 4 finding 1): the old domain's CNAMEs were
2591
- * published under whatever tunnel id was live the *last* time this step
2592
- * recorded that domain, which is `previousTunnelId`, not necessarily
2593
- * `tunnelId` -- a tunnel recreated under the same name
2594
- * (`findTunnelByName` returning a new id) in the same apply that also
2595
- * changes the domain leaves the old domain's records pointed at the old
2596
- * id. Matching only `tunnelId`'s target left them live forever. Both
2597
- * targets are checked so this stays a no-op once a retry has already
2598
- * moved the old domain onto the new id.
2693
+ * `previousIds` (round 4 finding 1, round 6 finding 2): the old domain's
2694
+ * CNAMEs were published under whatever tunnel id was live the *last*
2695
+ * time this step recorded that domain, which may be any id this step has
2696
+ * ever replaced, not necessarily `tunnelId` -- a tunnel recreated under
2697
+ * the same name (`findTunnelByName` returning a new id), possibly more
2698
+ * than once with no successful publish in between, in the same apply
2699
+ * that also changes the domain leaves the old domain's records pointed
2700
+ * at an old id. Matching only `tunnelId`'s target left them live
2701
+ * forever; matching only the single most recent previous id (round 4)
2702
+ * still missed an older one two unresolved recreates back. Every target
2703
+ * is checked so this stays a no-op once a retry has already moved the
2704
+ * old domain onto the current id.
2705
+ *
2706
+ * Round 6 finding 1: once `previousHostnames` is non-empty, every name in
2707
+ * it has now been looked up -- deleted (its CNAME matched one of
2708
+ * `dnsTargets`), left alone (found but foreign), or never found at all
2709
+ * (already gone) -- so none of them needs tracking under `previousDomain`
2710
+ * in {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY} any longer; dropped
2711
+ * from that set accordingly. The zone-wide fallback path (no recorded
2712
+ * hostnames) never added entries of its own to begin with, so there is
2713
+ * nothing of this domain's left in that set either way.
2599
2714
  */
2600
- async deleteStaleTunnelDnsRecords(tunnelId, previousTunnelId, previousDomain, previousHostnames) {
2715
+ async deleteStaleTunnelDnsRecords(tunnelId, previousIds, previousDomain, previousHostnames, context) {
2601
2716
  const zone = await this.ports.cloudflare.findZoneByApex(previousDomain);
2602
2717
  if (!zone)
2603
2718
  return;
2604
2719
  const dnsTargets = new Set([`${tunnelId}.cfargotunnel.com`]);
2605
- if (previousTunnelId && previousTunnelId !== tunnelId) {
2606
- dnsTargets.add(`${previousTunnelId}.cfargotunnel.com`);
2720
+ for (const id of previousIds) {
2721
+ if (id !== tunnelId)
2722
+ dnsTargets.add(`${id}.cfargotunnel.com`);
2607
2723
  }
2608
2724
  const cf = this.ports.cloudflare;
2609
2725
  const records = previousHostnames.length > 0
@@ -2614,6 +2730,13 @@ export class FoundryStepExecutor {
2614
2730
  await cf.deleteDnsRecord(zone.id, record.id);
2615
2731
  }
2616
2732
  }
2733
+ if (previousHostnames.length > 0) {
2734
+ const tracked = parseTrackedTunnelDns(context.recordedIds());
2735
+ for (const hostname of previousHostnames) {
2736
+ tracked.delete(trackedTunnelDnsEntry(previousDomain, hostname));
2737
+ }
2738
+ context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY, trackedTunnelDnsState(tracked));
2739
+ }
2617
2740
  }
2618
2741
  /**
2619
2742
  * #236: upserts a proxied CNAME to this tunnel for each of
@@ -2639,13 +2762,15 @@ export class FoundryStepExecutor {
2639
2762
  * (round 4 finding 3), so a crash mid-write is recovered by re-running
2640
2763
  * this same idempotent step, never silently skipped.
2641
2764
  *
2642
- * Round 3: `previousTunnelId` is the tunnel id this same step recorded
2643
- * last time it ran (`cloudflareTunnel`'s own `cloudflare:tunnel:<key>`
2644
- * resource id, read before `tunnel` above is computed for this run) -- a
2645
- * tunnel Cloudflare shows as recreated under the same name
2646
- * (`findTunnelByName` returning a new id for `<slug>-tunnel`) leaves every
2647
- * CNAME still pointed at the *old* id. That is this org's own stale
2648
- * record, not a foreign one, so it is repointed rather than refused.
2765
+ * Round 3, round 6 finding 2: `previousIds` is every tunnel id this same
2766
+ * step has ever replaced (`cloudflareTunnel`'s own accumulated set,
2767
+ * captured before `tunnel` above is computed for this run) -- a tunnel
2768
+ * Cloudflare shows as recreated under the same name (`findTunnelByName`
2769
+ * returning a new id for `<slug>-tunnel`), possibly more than once with
2770
+ * no successful publish in between, leaves every CNAME still pointed at
2771
+ * one of those old ids. That is this org's own stale record, not a
2772
+ * foreign one, so it is repointed rather than refused, whichever of
2773
+ * `previousIds` it still names.
2649
2774
  *
2650
2775
  * Round 4 finding 2: `previousHostnames` is this domain's own previously
2651
2776
  * recorded hostname list (empty when the domain just changed --
@@ -2655,28 +2780,28 @@ export class FoundryStepExecutor {
2655
2780
  * `hostnames` below but was never cleaned up before this round: its CNAME
2656
2781
  * stayed live and its name stayed in the recorded list forever, because
2657
2782
  * only a domain *change* ever ran cleanup. Each dropped name's CNAME is
2658
- * deleted here when it still points at this tunnel or its immediately
2659
- * prior id; a name already gone, or now pointed at something foreign, is
2660
- * simply left out of the returned list -- nothing here is this method's to
2661
- * reclaim or report as a conflict.
2783
+ * deleted here when it still points at this tunnel or any of
2784
+ * `previousIds`; a name already gone, or now pointed at something
2785
+ * foreign, is simply left out of the returned list -- nothing here is
2786
+ * this method's to reclaim or report as a conflict.
2662
2787
  *
2663
- * Round 5 finding 1: `context` is used only to record
2664
- * {@link CLOUDFLARE_TUNNEL_DNS_PENDING_DOMAIN_SCOPE_KEY}/
2665
- * {@link CLOUDFLARE_TUNNEL_DNS_PENDING_HOSTNAMES_SCOPE_KEY} after each
2666
- * individual CNAME write below succeeds, so a crash or a thrown conflict
2667
- * partway through never leaves an already-created CNAME out of every
2668
- * recorded list -- `destroy` checks this pending pair too.
2788
+ * Round 5 finding 1, round 6 finding 1: `context` is used to add every
2789
+ * hostname this call confirms correct (settled, created or repointed) to
2790
+ * {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY} as soon as it is
2791
+ * confirmed, and to drop a dropped hostname from it once that hostname's
2792
+ * own CNAME is resolved one way or another -- additively, alongside
2793
+ * whatever other domains that shared set already names, never
2794
+ * overwriting them. `destroy` and {@link deleteStaleTunnelDnsRecords}
2795
+ * both read this set.
2669
2796
  */
2670
- async publishTunnelDnsRecords(org, domain, tunnelId, previousTunnelId, previousHostnames, context) {
2797
+ async publishTunnelDnsRecords(org, domain, tunnelId, previousIds, previousHostnames, context) {
2671
2798
  const cf = this.ports.cloudflare;
2672
2799
  const zone = await cf.findZoneByApex(domain);
2673
2800
  if (!zone) {
2674
2801
  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`);
2675
2802
  }
2676
2803
  const dnsTarget = `${tunnelId}.cfargotunnel.com`;
2677
- const previousDnsTarget = previousTunnelId && previousTunnelId !== tunnelId
2678
- ? `${previousTunnelId}.cfargotunnel.com`
2679
- : undefined;
2804
+ const previousTargets = new Set([...previousIds].filter((id) => id !== tunnelId).map((id) => `${id}.cfargotunnel.com`));
2680
2805
  const hostnames = tunnelIngressHostnames(org).filter((hostname) => hostname === domain || hostname.endsWith(`.${domain}`));
2681
2806
  const droppedHostnames = previousHostnames.filter((hostname) => !hostnames.includes(hostname));
2682
2807
  const conflicts = [];
@@ -2692,7 +2817,7 @@ export class FoundryStepExecutor {
2692
2817
  }
2693
2818
  const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
2694
2819
  if (cname && cname.content !== dnsTarget) {
2695
- if (previousDnsTarget && cname.content === previousDnsTarget) {
2820
+ if (previousTargets.has(cname.content)) {
2696
2821
  toRepoint.push({ hostname, recordId: cname.id });
2697
2822
  continue;
2698
2823
  }
@@ -2705,20 +2830,22 @@ export class FoundryStepExecutor {
2705
2830
  if (conflicts.length > 0) {
2706
2831
  throw new Error(`cloudflare:tunnel for ${org.slug}: will not point its tunnel at every hostname -- ${conflicts.join('; ')}; remove the conflicting record first`);
2707
2832
  }
2708
- // Round 5 finding 1: `settled` tracks every hostname under `domain`
2709
- // confirmed to have the right CNAME so far -- starting with the ones
2710
- // that needed no write at all, then one more per successful create or
2711
- // repoint below -- and is persisted to the pending scope ids after each
2712
- // addition, well before this method returns. A failure on any later
2713
- // hostname still leaves everything settled up to that point recorded,
2714
- // rather than only in this function's own local state.
2833
+ // Round 5 finding 1, round 6 finding 1: `settled` is every hostname
2834
+ // under `domain` confirmed to have the right CNAME so far -- starting
2835
+ // with the ones that needed no write at all, then one more per
2836
+ // successful create or repoint below -- and each is added to the
2837
+ // shared tracked set as soon as it is confirmed, well before this
2838
+ // method returns. A failure on any later hostname still leaves
2839
+ // everything settled up to that point recorded, rather than only in
2840
+ // this function's own local state.
2715
2841
  const settled = hostnames.filter((hostname) => !toCreate.includes(hostname) && !toRepoint.some((r) => r.hostname === hostname));
2716
- const recordPending = () => {
2717
- context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_PENDING_DOMAIN_SCOPE_KEY, domain);
2718
- context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_PENDING_HOSTNAMES_SCOPE_KEY, settled.join(','));
2842
+ const tracked = parseTrackedTunnelDns(context.recordedIds());
2843
+ const recordTracked = (hostname) => {
2844
+ tracked.add(trackedTunnelDnsEntry(domain, hostname));
2845
+ context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY, trackedTunnelDnsState(tracked));
2719
2846
  };
2720
- if (settled.length > 0)
2721
- recordPending();
2847
+ for (const hostname of settled)
2848
+ recordTracked(hostname);
2722
2849
  for (const hostname of toCreate) {
2723
2850
  await cf.createDnsRecord(zone.id, {
2724
2851
  type: 'CNAME',
@@ -2726,8 +2853,7 @@ export class FoundryStepExecutor {
2726
2853
  content: dnsTarget,
2727
2854
  proxied: true,
2728
2855
  });
2729
- settled.push(hostname);
2730
- recordPending();
2856
+ recordTracked(hostname);
2731
2857
  }
2732
2858
  for (const { hostname, recordId } of toRepoint) {
2733
2859
  await cf.updateDnsRecord(zone.id, recordId, {
@@ -2736,18 +2862,25 @@ export class FoundryStepExecutor {
2736
2862
  content: dnsTarget,
2737
2863
  proxied: true,
2738
2864
  });
2739
- settled.push(hostname);
2740
- recordPending();
2865
+ recordTracked(hostname);
2741
2866
  }
2742
2867
  // Round 4 finding 2: clean up a hostname this domain itself dropped.
2743
2868
  // Not a conflict check -- there is nothing to create or repoint here,
2744
2869
  // only a possible stale CNAME of this tunnel's own to remove. Never in
2745
- // `hostnames`/`settled` to begin with, so nothing to update there.
2870
+ // `hostnames`/`settled` to begin with, so nothing to track-add there.
2871
+ //
2872
+ // Round 6 finding 1: whatever happens -- deleted (its CNAME matched),
2873
+ // left alone (found but foreign), or never found at all (already gone)
2874
+ // -- this hostname's own entry (if it had one) no longer needs
2875
+ // tracking under `domain`, so it is dropped from the shared set either
2876
+ // way.
2746
2877
  for (const hostname of droppedHostnames) {
2747
2878
  const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
2748
- if (cname && (cname.content === dnsTarget || cname.content === previousDnsTarget)) {
2879
+ if (cname && (cname.content === dnsTarget || previousTargets.has(cname.content))) {
2749
2880
  await cf.deleteDnsRecord(zone.id, cname.id);
2750
2881
  }
2882
+ tracked.delete(trackedTunnelDnsEntry(domain, hostname));
2883
+ context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY, trackedTunnelDnsState(tracked));
2751
2884
  }
2752
2885
  return hostnames;
2753
2886
  }
@@ -3213,46 +3346,46 @@ export class FoundryStepExecutor {
3213
3346
  // look up instead. 404 handled locally, same reason as the
3214
3347
  // connector above.
3215
3348
  //
3216
- // Round 5 finding 2: `dnsTargets` accepts this tunnel's current id
3217
- // *or* {@link CLOUDFLARE_TUNNEL_PREVIOUS_ID_SCOPE_KEY} -- the id a
3218
- // recreate most recently replaced -- the same two-target match
3219
- // `publishTunnelDnsRecords`'s own repoint logic already uses, so a
3220
- // CNAME a domain-less apply's recreate left pointed at the old id
3221
- // (never repointed, since that apply never ran the DNS-publish
3222
- // block at all) still gets removed here.
3223
- //
3224
- // Round 5 finding 1: `dnsSources` also checks the *pending*
3225
- // domain/hostnames pair alongside the authoritative one, so a
3226
- // CNAME `publishTunnelDnsRecords` created but never got to record
3227
- // in the authoritative pair (a later hostname's write failing, or
3228
- // an old-domain cleanup failure right after a successful publish)
3229
- // is still found and removed.
3230
- const previousTunnelId = recordedIds[CLOUDFLARE_TUNNEL_PREVIOUS_ID_SCOPE_KEY];
3349
+ // Round 5 finding 2, round 6 finding 2: `dnsTargets` accepts this
3350
+ // tunnel's current id *or* any id
3351
+ // {@link CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY} still names --
3352
+ // every id this step has ever replaced, not only the latest -- the
3353
+ // same match `publishTunnelDnsRecords`'s own repoint logic already
3354
+ // uses, so a CNAME a domain-less apply's recreate left pointed at
3355
+ // an old id (never repointed, since that apply never ran the
3356
+ // DNS-publish block at all), possibly more than one recreate back,
3357
+ // still gets removed here.
3358
+ const previousTunnelIds = parseTunnelPreviousIds(recordedIds);
3231
3359
  const dnsTargets = new Set([`${target.id}.cfargotunnel.com`]);
3232
- if (previousTunnelId && previousTunnelId !== target.id) {
3233
- dnsTargets.add(`${previousTunnelId}.cfargotunnel.com`);
3360
+ for (const id of previousTunnelIds) {
3361
+ if (id !== target.id)
3362
+ dnsTargets.add(`${id}.cfargotunnel.com`);
3363
+ }
3364
+ // Round 6 finding 1: every domain the shared tracked set still
3365
+ // names, folded together with the authoritative domain/hostnames
3366
+ // pair (an org that predates that set has only the latter). A
3367
+ // domain the tracked set names but the authoritative pair does
3368
+ // not is exactly the case this round exists for -- a hostname
3369
+ // published under a domain the org has since moved away from
3370
+ // twice over, never caught by any single "previous domain" field.
3371
+ const hostnamesByDomain = trackedTunnelDnsByDomain(parseTrackedTunnelDns(recordedIds));
3372
+ const authoritativeDomain = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY];
3373
+ if (authoritativeDomain) {
3374
+ const authoritativeHostnamesRaw = recordedIds[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
3375
+ const merged = new Set(hostnamesByDomain.get(authoritativeDomain) ?? []);
3376
+ if (authoritativeHostnamesRaw) {
3377
+ for (const hostname of authoritativeHostnamesRaw.split(','))
3378
+ merged.add(hostname);
3379
+ }
3380
+ hostnamesByDomain.set(authoritativeDomain, [...merged]);
3234
3381
  }
3235
- const dnsSources = [
3236
- {
3237
- domain: recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY],
3238
- hostnamesRaw: recordedIds[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY],
3239
- },
3240
- {
3241
- domain: recordedIds[CLOUDFLARE_TUNNEL_DNS_PENDING_DOMAIN_SCOPE_KEY],
3242
- hostnamesRaw: recordedIds[CLOUDFLARE_TUNNEL_DNS_PENDING_HOSTNAMES_SCOPE_KEY],
3243
- },
3244
- ];
3245
- for (const { domain: dnsDomain, hostnamesRaw: dnsHostnamesRaw } of dnsSources) {
3246
- if (!dnsDomain)
3247
- continue;
3382
+ for (const [dnsDomain, dnsHostnames] of hostnamesByDomain) {
3248
3383
  try {
3249
3384
  const zone = await this.ports.cloudflare.findZoneByApex(dnsDomain);
3250
3385
  if (zone) {
3251
3386
  const cf = this.ports.cloudflare;
3252
- const records = dnsHostnamesRaw
3253
- ? (await Promise.all(dnsHostnamesRaw
3254
- .split(',')
3255
- .map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
3387
+ const records = dnsHostnames.length > 0
3388
+ ? (await Promise.all(dnsHostnames.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
3256
3389
  : await cf.listDnsRecords(zone.id, { type: 'CNAME' });
3257
3390
  for (const record of records) {
3258
3391
  if (dnsTargets.has(record.content)) {