@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.
- package/dist/bootstrap/declare.js +1 -5
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/migrations/0029_hosting_entries.sql +109 -0
- package/dist/org.d.ts +59 -27
- package/dist/org.js +98 -26
- package/dist/plan/diff.js +25 -19
- package/dist/plan/executor.d.ts +45 -29
- package/dist/plan/executor.js +291 -158
- package/dist/plan/index.d.ts +1 -1
- package/dist/plan/index.js +1 -1
- package/dist/plan/plan-apply.js +6 -9
- package/dist/plan/platform-diff.d.ts +23 -11
- package/dist/plan/platform-diff.js +39 -14
- package/dist/plan/platform-providers.d.ts +7 -0
- package/dist/plan/platform-providers.js +93 -13
- package/dist/plan/zitadel-system-api.d.ts +64 -0
- package/dist/plan/zitadel-system-api.js +125 -0
- package/dist/platform.d.ts +1 -1
- package/dist/store/postgres.js +2 -10
- package/dist/store/schema.d.ts +20 -1
- package/dist/store/schema.js +7 -1
- package/package.json +1 -1
package/dist/plan/executor.js
CHANGED
|
@@ -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
|
|
342
|
-
* {@link FoundryStepExecutor.publishTunnelDnsRecords}
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
* `
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
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
|
|
358
|
-
|
|
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
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
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
|
|
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 `
|
|
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
|
|
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
|
-
|
|
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/${
|
|
1045
|
-
return { resourceId:
|
|
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`)
|
|
1049
|
-
//
|
|
1050
|
-
const appRepo =
|
|
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
|
-
|
|
1054
|
-
|
|
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
|
-
|
|
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:
|
|
1081
|
-
build_pack:
|
|
1082
|
-
name: org
|
|
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:
|
|
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 (
|
|
1097
|
-
const dbId = context.recordedIds()[`coolify:database:${
|
|
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:${
|
|
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
|
-
* `
|
|
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 `
|
|
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
|
|
2451
|
-
//
|
|
2452
|
-
//
|
|
2453
|
-
//
|
|
2454
|
-
//
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
2562
|
-
//
|
|
2563
|
-
//
|
|
2564
|
-
//
|
|
2565
|
-
//
|
|
2566
|
-
|
|
2567
|
-
|
|
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
|
-
* `
|
|
2591
|
-
* published under whatever tunnel id was live the *last*
|
|
2592
|
-
* recorded that domain, which
|
|
2593
|
-
* `tunnelId` -- a tunnel recreated under
|
|
2594
|
-
* (`findTunnelByName` returning a new id)
|
|
2595
|
-
*
|
|
2596
|
-
*
|
|
2597
|
-
*
|
|
2598
|
-
*
|
|
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,
|
|
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
|
-
|
|
2606
|
-
|
|
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: `
|
|
2643
|
-
*
|
|
2644
|
-
*
|
|
2645
|
-
*
|
|
2646
|
-
*
|
|
2647
|
-
*
|
|
2648
|
-
*
|
|
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
|
|
2659
|
-
*
|
|
2660
|
-
* simply left out of the returned list -- nothing here is
|
|
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
|
|
2664
|
-
*
|
|
2665
|
-
* {@link
|
|
2666
|
-
*
|
|
2667
|
-
*
|
|
2668
|
-
*
|
|
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,
|
|
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
|
|
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 (
|
|
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`
|
|
2709
|
-
// confirmed to have the right CNAME so far -- starting
|
|
2710
|
-
// that needed no write at all, then one more per
|
|
2711
|
-
// repoint below -- and is
|
|
2712
|
-
//
|
|
2713
|
-
//
|
|
2714
|
-
//
|
|
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
|
|
2717
|
-
|
|
2718
|
-
|
|
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
|
-
|
|
2721
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
3217
|
-
// *or*
|
|
3218
|
-
//
|
|
3219
|
-
//
|
|
3220
|
-
//
|
|
3221
|
-
//
|
|
3222
|
-
//
|
|
3223
|
-
//
|
|
3224
|
-
//
|
|
3225
|
-
|
|
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
|
-
|
|
3233
|
-
|
|
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
|
|
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 =
|
|
3253
|
-
? (await Promise.all(
|
|
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)) {
|