@wtfalch/foundry 0.8.0 → 0.10.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/migrations/0030_hosting_entry_hostnames.sql +24 -0
- package/dist/org.d.ts +87 -29
- package/dist/org.js +180 -27
- package/dist/plan/agora.d.ts +7 -0
- package/dist/plan/agora.js +9 -0
- package/dist/plan/broker-entries.d.ts +104 -0
- package/dist/plan/broker-entries.js +214 -0
- package/dist/plan/diff.d.ts +2 -0
- package/dist/plan/diff.js +274 -50
- package/dist/plan/executor.d.ts +89 -29
- package/dist/plan/executor.js +495 -233
- package/dist/plan/hostnames.d.ts +24 -0
- package/dist/plan/hostnames.js +56 -0
- package/dist/plan/index.d.ts +3 -1
- package/dist/plan/index.js +2 -1
- package/dist/plan/locked-hostnames.d.ts +8 -0
- package/dist/plan/locked-hostnames.js +26 -0
- package/dist/plan/login-host.d.ts +1 -1
- package/dist/plan/login-host.js +5 -2
- package/dist/plan/plan-apply.d.ts +16 -0
- package/dist/plan/plan-apply.js +48 -11
- package/dist/plan/platform-diff.d.ts +69 -10
- package/dist/plan/platform-diff.js +133 -14
- package/dist/plan/platform-providers.d.ts +102 -2
- package/dist/plan/platform-providers.js +486 -40
- 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 +12 -12
- 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,15 @@
|
|
|
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 {
|
|
4
|
+
import { APP_ENTRY_KEY, appEntry } from '../org.js';
|
|
5
|
+
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
|
+
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
5
7
|
import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
6
8
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
9
|
+
import { appHostname, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
7
10
|
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
8
11
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
9
|
-
import {
|
|
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';
|
|
10
13
|
/**
|
|
11
14
|
* #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
|
|
12
15
|
* listApps` -- a plain array, with no `loginBaseUri` or completeness signal
|
|
@@ -47,17 +50,6 @@ export class NotBuiltError extends Error {
|
|
|
47
50
|
// ---------------------------------------------------------------------------
|
|
48
51
|
// Hostnames
|
|
49
52
|
// ---------------------------------------------------------------------------
|
|
50
|
-
/**
|
|
51
|
-
* `<slug>.oikos.host` (`../slug.ts`), or `DeclaredOrg.domain` once
|
|
52
|
-
* `hosting.usesDomain` is true — the same rule `diff.ts`'s `planApp` and
|
|
53
|
-
* `planSite` document, reused here for the org's own hosted app, its
|
|
54
|
-
* Cloudflare tunnel ingress and its ZITADEL issuer apps' redirect URIs
|
|
55
|
-
* (task brief: "Hostnames: `<slug>.oikos.host` ... or `DeclaredOrg.domain`
|
|
56
|
-
* when `hosting.usesDomain`").
|
|
57
|
-
*/
|
|
58
|
-
function hostnameFor(org) {
|
|
59
|
-
return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
|
|
60
|
-
}
|
|
61
53
|
/**
|
|
62
54
|
* #274 fix round 3 point 4: `zitadelLoginHost`'s own comparison between a
|
|
63
55
|
* live app's ZITADEL-reported login base URI and the one this step itself
|
|
@@ -95,36 +87,6 @@ function callsHostnameFor(org) {
|
|
|
95
87
|
? `calls.${org.domain}`
|
|
96
88
|
: `${org.slug}-calls.oikos.host`;
|
|
97
89
|
}
|
|
98
|
-
/**
|
|
99
|
-
* The hostnames `cloudflareTunnel` routes to `COOLIFY_PROXY_ORIGIN`
|
|
100
|
-
* (https://github.com/wtfalch/foundry/issues/115). A site-enabled org
|
|
101
|
-
* (`org.ts`'s `SiteConfig.enabled`) has no single app hostname to reuse -- it
|
|
102
|
-
* needs both its public site at `org.domain` and its cms studio (the sign-in
|
|
103
|
-
* and editing surface, `@wtfalch/cms`) at `cms.<domain>`, so both are named
|
|
104
|
-
* here; `org.domain` is non-null in that branch, guaranteed by `org.ts`'s
|
|
105
|
-
* `validateSite` ("site.enabled requires domain"). Every other org --
|
|
106
|
-
* concretely a hosting org, whose one app hostname this served before #115
|
|
107
|
-
* ever existed -- keeps exactly the prior single-hostname behavior
|
|
108
|
-
* ({@link hostnameFor}), unchanged.
|
|
109
|
-
*
|
|
110
|
-
* #145: an own-domain calls service adds `calls.<domain>` on top of whichever
|
|
111
|
-
* of those a site/hosting org already gets, since calls can run alongside
|
|
112
|
-
* either (or neither).
|
|
113
|
-
*/
|
|
114
|
-
function tunnelIngressHostnames(org) {
|
|
115
|
-
const hostnames = org.site.enabled && org.domain ? [org.domain, `cms.${org.domain}`] : [hostnameFor(org)];
|
|
116
|
-
if (org.calls.enabled && org.calls.usesDomain && org.domain) {
|
|
117
|
-
hostnames.push(`calls.${org.domain}`);
|
|
118
|
-
}
|
|
119
|
-
// #164: Agora always moves onto `agora.<domain>` once `org.domain` is set
|
|
120
|
-
// -- no toggle to gate this on. A level-1 org (no domain) adds nothing
|
|
121
|
-
// here: `<slug>-agora.oikos.host` rides the shared `*.oikos.host` wildcard
|
|
122
|
-
// tunnel (`scripts/shared-ingress.mjs`), not a per-org tunnel.
|
|
123
|
-
if (org.domain) {
|
|
124
|
-
hostnames.push(agoraHostnameFor(org));
|
|
125
|
-
}
|
|
126
|
-
return hostnames;
|
|
127
|
-
}
|
|
128
90
|
/**
|
|
129
91
|
* The health URLs an outside prober should call for this org (#43): one per
|
|
130
92
|
* app foundry runs for it. A hosting org's app answers at its own hostname,
|
|
@@ -134,8 +96,17 @@ function tunnelIngressHostnames(org) {
|
|
|
134
96
|
* this file's hostname rules.
|
|
135
97
|
*/
|
|
136
98
|
export function healthUrls(org) {
|
|
137
|
-
|
|
138
|
-
|
|
99
|
+
// One URL per entry that has a public name; an internal entry has none.
|
|
100
|
+
if (org.hosting.enabled && appEntry(org.hosting)) {
|
|
101
|
+
return org.hosting.entries.flatMap((entry) => {
|
|
102
|
+
const [hostname] = entryHostnames(org, entry);
|
|
103
|
+
if (!hostname)
|
|
104
|
+
return [];
|
|
105
|
+
return [
|
|
106
|
+
`https://${hostname}${entry.healthPath ?? (entry.buildPack === 'static' ? '/' : '/api/health')}`,
|
|
107
|
+
];
|
|
108
|
+
});
|
|
109
|
+
}
|
|
139
110
|
if (org.site.enabled && org.domain) {
|
|
140
111
|
return [`https://${org.domain}/api/health`, `https://${cmsHostnameFor(org)}/api/health`];
|
|
141
112
|
}
|
|
@@ -157,7 +128,7 @@ function mailOrgFor(org, usesDomain, mode) {
|
|
|
157
128
|
/**
|
|
158
129
|
* Every app on the Coolify box shares one Traefik reverse proxy, and Coolify
|
|
159
130
|
* itself points that proxy's routing at whatever `domains` an application
|
|
160
|
-
* declares (`coolifyApp`'s `
|
|
131
|
+
* declares (`coolifyApp`'s `appHostname(org)`) -- so a tunnel's ingress only
|
|
161
132
|
* has to reach the proxy, never a specific app container.
|
|
162
133
|
* `wtfalch/app-template`'s bootstrap (`bootstrap/steps/cf-tunnel.ts`, prior
|
|
163
134
|
* art for the exact org this file's `cloudflareTunnel` implements, cited by
|
|
@@ -338,36 +309,94 @@ const CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY = 'cloudflare_tunnel_dns_domain';
|
|
|
338
309
|
*/
|
|
339
310
|
const CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_dns_hostnames';
|
|
340
311
|
/**
|
|
341
|
-
* #236 round
|
|
342
|
-
* {@link FoundryStepExecutor.publishTunnelDnsRecords}
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
* `
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
312
|
+
* #236 round 6 finding 1: every `${domain}|${hostname}` pair
|
|
313
|
+
* {@link FoundryStepExecutor.publishTunnelDnsRecords} or
|
|
314
|
+
* {@link FoundryStepExecutor.deleteStaleTunnelDnsRecords} has written (or
|
|
315
|
+
* is in the middle of writing) a CNAME for -- across every apply, and
|
|
316
|
+
* across however many domains an org has moved through without a clean
|
|
317
|
+
* resolution, not only the most recent one. Replaces round 5's single
|
|
318
|
+
* pending domain/hostnames pair (`cloudflare_tunnel_dns_pending_domain`/
|
|
319
|
+
* `_hostnames`), which tracked one domain at a time and was overwritten
|
|
320
|
+
* whole on every call: apply 1 moves domain A to B, creates `h1.B`, and
|
|
321
|
+
* old-domain cleanup throws, leaving pending at `B/[h1]`; the org then
|
|
322
|
+
* declares C, and apply 2's own pending write overwrites it with
|
|
323
|
+
* `C/[...]` -- `h1.B` is now in no recorded list at all, so `destroy`
|
|
324
|
+
* leaves it live forever. This key instead accumulates entries (adds,
|
|
325
|
+
* never replaces the set) via {@link parseTrackedTunnelDns}/
|
|
326
|
+
* {@link trackedTunnelDnsState}, and an entry is dropped only once its own
|
|
327
|
+
* CNAME is deleted, confirmed absent, or confirmed not this tunnel's.
|
|
328
|
+
* `destroy` and {@link FoundryStepExecutor.deleteStaleTunnelDnsRecords}
|
|
329
|
+
* both walk every domain this set still names, not only the org's current
|
|
330
|
+
* or last-recorded one. Neither a domain nor a DNS hostname can contain
|
|
331
|
+
* `,` or `|`, so the join is unambiguous; sorted (like
|
|
332
|
+
* {@link LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY}, `login-host.ts`) so the
|
|
333
|
+
* recorded value is stable regardless of write order.
|
|
356
334
|
*/
|
|
357
|
-
const
|
|
358
|
-
|
|
335
|
+
const CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY = 'cloudflare_tunnel_dns_tracked';
|
|
336
|
+
/** {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY}'s entries, parsed back out. */
|
|
337
|
+
function parseTrackedTunnelDns(recorded) {
|
|
338
|
+
const raw = recorded[CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY];
|
|
339
|
+
return new Set(raw ? raw.split(',').filter((entry) => entry.length > 0) : []);
|
|
340
|
+
}
|
|
341
|
+
/** One {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY} entry for `domain`'s `hostname`. */
|
|
342
|
+
function trackedTunnelDnsEntry(domain, hostname) {
|
|
343
|
+
return `${domain}|${hostname}`;
|
|
344
|
+
}
|
|
345
|
+
/** {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY}'s own serialisation, sorted so the recorded value is stable regardless of write order. */
|
|
346
|
+
function trackedTunnelDnsState(entries) {
|
|
347
|
+
return [...entries].sort().join(',');
|
|
348
|
+
}
|
|
349
|
+
/** {@link parseTrackedTunnelDns}'s entries, grouped by domain so cleanup can walk one zone at a time. */
|
|
350
|
+
function trackedTunnelDnsByDomain(entries) {
|
|
351
|
+
const byDomain = new Map();
|
|
352
|
+
for (const entry of entries) {
|
|
353
|
+
const separator = entry.indexOf('|');
|
|
354
|
+
if (separator === -1)
|
|
355
|
+
continue;
|
|
356
|
+
const domain = entry.slice(0, separator);
|
|
357
|
+
const hostname = entry.slice(separator + 1);
|
|
358
|
+
const hostnames = byDomain.get(domain) ?? [];
|
|
359
|
+
hostnames.push(hostname);
|
|
360
|
+
byDomain.set(domain, hostnames);
|
|
361
|
+
}
|
|
362
|
+
return byDomain;
|
|
363
|
+
}
|
|
359
364
|
/**
|
|
360
|
-
* #236 round
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
365
|
+
* #236 round 6 finding 2: every tunnel id this step has ever replaced with
|
|
366
|
+
* a new one, not only the immediately prior one. Round 5's singular
|
|
367
|
+
* `cloudflare_tunnel_previous_id` was overwritten on each recreate, so two
|
|
368
|
+
* recreates with no successful DNS resolution in between (an apply with
|
|
369
|
+
* no domain declared, or one that keeps failing) left the CNAMEs from the
|
|
370
|
+
* *first* recreate matched by nothing -- `destroy` and this step's own
|
|
371
|
+
* repoint logic only ever compared against the latest id. Recorded
|
|
372
|
+
* unconditionally, even on an apply where `org.domain` is unset and the
|
|
373
|
+
* DNS-publish block in {@link FoundryStepExecutor.cloudflareTunnel} never
|
|
374
|
+
* runs: a CNAME published while a domain *was* declared can still be live
|
|
375
|
+
* under an old id after the domain is later cleared. Comma-joined, sorted,
|
|
376
|
+
* the same shape {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY} above
|
|
377
|
+
* uses (tunnel ids carry no `,`). Cleared once that DNS-publish block
|
|
378
|
+
* completes *and* (round 7) the tracked set names no domain but the one
|
|
379
|
+
* just published -- only then has every tracked CNAME that could have
|
|
380
|
+
* pointed at any of them been repointed to the current id or cleaned up.
|
|
381
|
+
* A tracked entry left under some other domain by an earlier apply whose
|
|
382
|
+
* own cleanup never ran, or itself threw, can still point at an old id, so
|
|
383
|
+
* clearing unconditionally (round 6) left `destroy` unable to recognise
|
|
384
|
+
* that CNAME as this tunnel's own once a later apply resolved a different
|
|
385
|
+
* domain change and moved on. An apply with no domain declared never
|
|
386
|
+
* reaches that point either, so the set is left to accumulate across such
|
|
387
|
+
* applies, and across an unresolved domain change, until one apply
|
|
388
|
+
* publishes with nothing tracked outside its own domain.
|
|
369
389
|
*/
|
|
370
|
-
const
|
|
390
|
+
const CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY = 'cloudflare_tunnel_previous_ids';
|
|
391
|
+
/** {@link CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY}'s entries, parsed back out. */
|
|
392
|
+
function parseTunnelPreviousIds(recorded) {
|
|
393
|
+
const raw = recorded[CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY];
|
|
394
|
+
return new Set(raw ? raw.split(',').filter((id) => id.length > 0) : []);
|
|
395
|
+
}
|
|
396
|
+
/** {@link CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY}'s own serialisation, sorted so the recorded value is stable regardless of write order. */
|
|
397
|
+
function tunnelPreviousIdsState(ids) {
|
|
398
|
+
return [...ids].sort().join(',');
|
|
399
|
+
}
|
|
371
400
|
/**
|
|
372
401
|
* #162: the owner's own mailbox, created once inside `coolify:mail` after
|
|
373
402
|
* `applyOrgMail` succeeds, alongside {@link MAIL_DOMAIN_SCOPE_KEY} -- another
|
|
@@ -520,6 +549,8 @@ export class FoundryStepExecutor {
|
|
|
520
549
|
return this.zitadelCustomers(org, context);
|
|
521
550
|
case 'coolify:app':
|
|
522
551
|
return this.coolifyApp(action, org, context);
|
|
552
|
+
case 'coolify:broker-entry':
|
|
553
|
+
return this.coolifyBrokerEntry(action, org, context);
|
|
523
554
|
case 'coolify:database':
|
|
524
555
|
return this.coolifyDatabase(action, org, context);
|
|
525
556
|
case 'coolify:calls':
|
|
@@ -562,7 +593,7 @@ export class FoundryStepExecutor {
|
|
|
562
593
|
}
|
|
563
594
|
/**
|
|
564
595
|
* `plan/diff.ts`'s `planAccess` (#84): puts the org's app hostname behind a
|
|
565
|
-
* Cloudflare Access app that lets only `
|
|
596
|
+
* Cloudflare Access app that lets only the `app` entry's `accessEmails` through.
|
|
566
597
|
* foundry names its Access app `foundry:<orgId>` and reuses only an app with
|
|
567
598
|
* that name, so a re-run after a crash does not duplicate it. It refuses,
|
|
568
599
|
* rather than adopt, when another Access app already holds the hostname:
|
|
@@ -570,7 +601,12 @@ export class FoundryStepExecutor {
|
|
|
570
601
|
* any policy but foundry's, since that policy could let others in.
|
|
571
602
|
*/
|
|
572
603
|
async cloudflareAccess(org) {
|
|
573
|
-
|
|
604
|
+
// Only the `app` entry has a public name (`planAccess`); per-entry
|
|
605
|
+
// hostnames are issue 309.
|
|
606
|
+
const entry = appEntry(org.hosting);
|
|
607
|
+
if (!entry)
|
|
608
|
+
throw new Error(`cloudflare:access for ${org.slug}: no '${APP_ENTRY_KEY}' hosting entry`);
|
|
609
|
+
const domain = appHostname(org);
|
|
574
610
|
const name = `foundry:${org.id}`;
|
|
575
611
|
const cf = this.ports.cloudflare;
|
|
576
612
|
const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
|
|
@@ -588,7 +624,7 @@ export class FoundryStepExecutor {
|
|
|
588
624
|
await cf.createAccessPolicy(app.id, {
|
|
589
625
|
name: ACCESS_POLICY_NAME,
|
|
590
626
|
decision: 'allow',
|
|
591
|
-
include:
|
|
627
|
+
include: entry.accessEmails.map((email) => ({ email: { email } })),
|
|
592
628
|
});
|
|
593
629
|
}
|
|
594
630
|
return { resourceId: app.id };
|
|
@@ -775,6 +811,9 @@ export class FoundryStepExecutor {
|
|
|
775
811
|
...(recorded[APP_CLIENT_ID_SCOPE_KEY]
|
|
776
812
|
? org.hosting.issuerApps.filter((a) => a.kind === 'web').map((a) => a.name)
|
|
777
813
|
: []),
|
|
814
|
+
...Object.keys(recorded)
|
|
815
|
+
.filter((key) => key.startsWith(ENTRY_CLIENT_ID_SCOPE_PREFIX))
|
|
816
|
+
.map((key) => key.slice(ENTRY_CLIENT_ID_SCOPE_PREFIX.length)),
|
|
778
817
|
...(recorded[CMS_CLIENT_ID_SCOPE_KEY] ? ['cms'] : []),
|
|
779
818
|
...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
|
|
780
819
|
].map((name) => recorded[`zitadel:app:${name}`]));
|
|
@@ -958,7 +997,7 @@ export class FoundryStepExecutor {
|
|
|
958
997
|
? cmsHostnameFor(org)
|
|
959
998
|
: isAgoraClient
|
|
960
999
|
? agoraHostnameFor(org)
|
|
961
|
-
:
|
|
1000
|
+
: appHostname(org);
|
|
962
1001
|
// Native apps sign in via device code, with no redirect of their own
|
|
963
1002
|
// (auth/scripts/provision.mjs:475-497, prior art cited in the decisions
|
|
964
1003
|
// file) -- empty arrays rather than a web app's callback/logout URLs.
|
|
@@ -970,11 +1009,23 @@ export class FoundryStepExecutor {
|
|
|
970
1009
|
const redirectUri = isAgoraClient
|
|
971
1010
|
? `https://${hostname}/callback`
|
|
972
1011
|
: `https://${hostname}/auth/callback`;
|
|
1012
|
+
// An entry with `uses.auth` signs in at its own exact hostnames, one pair
|
|
1013
|
+
// of addresses each; a `*` adds none (`entryHostnames`).
|
|
1014
|
+
const authEntry = org.hosting.entries.find((e) => e.key === action.key && e.uses?.auth);
|
|
1015
|
+
const hosts = authEntry ? entryHostnames(org, authEntry) : null;
|
|
973
1016
|
const created = await this.ports.zitadel.createOidcApp(zitadelOrgId, projectId, {
|
|
974
1017
|
name: action.key,
|
|
975
1018
|
appType,
|
|
976
|
-
redirectUris: appType === 'native'
|
|
977
|
-
|
|
1019
|
+
redirectUris: appType === 'native'
|
|
1020
|
+
? []
|
|
1021
|
+
: hosts
|
|
1022
|
+
? hosts.map((host) => `https://${host}/auth/callback`)
|
|
1023
|
+
: [redirectUri],
|
|
1024
|
+
postLogoutRedirectUris: appType === 'native'
|
|
1025
|
+
? []
|
|
1026
|
+
: hosts
|
|
1027
|
+
? hosts.map((host) => `https://${host}/`)
|
|
1028
|
+
: [`https://${hostname}/`],
|
|
978
1029
|
});
|
|
979
1030
|
if (appType === 'web') {
|
|
980
1031
|
// `coolifyApp`'s own `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` to
|
|
@@ -989,7 +1040,9 @@ export class FoundryStepExecutor {
|
|
|
989
1040
|
? CMS_CLIENT_ID_SCOPE_KEY
|
|
990
1041
|
: isAgoraClient
|
|
991
1042
|
? AGORA_CLIENT_ID_SCOPE_KEY
|
|
992
|
-
:
|
|
1043
|
+
: authEntry
|
|
1044
|
+
? entryClientIdScopeKey(action.key)
|
|
1045
|
+
: APP_CLIENT_ID_SCOPE_KEY, created.clientId);
|
|
993
1046
|
}
|
|
994
1047
|
return { resourceId: created.appId };
|
|
995
1048
|
}
|
|
@@ -1036,23 +1089,51 @@ export class FoundryStepExecutor {
|
|
|
1036
1089
|
return server;
|
|
1037
1090
|
}
|
|
1038
1091
|
async coolifyApp(action, org, context) {
|
|
1092
|
+
const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
|
|
1039
1093
|
if (action.desired.enabled === false) {
|
|
1040
|
-
|
|
1041
|
-
if (!appId) {
|
|
1094
|
+
if (!recordedAppId) {
|
|
1042
1095
|
throw new Error(`coolify:app for ${org.slug}: no live app recorded, nothing to park`);
|
|
1043
1096
|
}
|
|
1044
|
-
await this.ports.coolify.raw('POST', `/applications/${
|
|
1045
|
-
return { resourceId:
|
|
1097
|
+
await this.ports.coolify.raw('POST', `/applications/${recordedAppId}/stop`);
|
|
1098
|
+
return { resourceId: recordedAppId };
|
|
1046
1099
|
}
|
|
1100
|
+
const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key);
|
|
1101
|
+
if (!entry)
|
|
1102
|
+
throw new Error(`coolify:app for ${org.slug}: no hosting entry for ${action.key}`);
|
|
1047
1103
|
// 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 =
|
|
1104
|
+
// app instead (gap P1, `.plans/2026-09-26-level-1-org-map.md`): an entry
|
|
1105
|
+
// with no `repo`.
|
|
1106
|
+
const appRepo = entry.repo ?? DEFAULT_APP_REPO;
|
|
1051
1107
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
1052
1108
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1109
|
+
// The recorded id first; the name when nothing is recorded, or when the
|
|
1110
|
+
// recorded app is gone.
|
|
1111
|
+
let existing = null;
|
|
1112
|
+
let live;
|
|
1113
|
+
if (recordedAppId) {
|
|
1114
|
+
try {
|
|
1115
|
+
live = await this.ports.coolify.getApplication(recordedAppId);
|
|
1116
|
+
existing = { uuid: recordedAppId };
|
|
1117
|
+
}
|
|
1118
|
+
catch (error) {
|
|
1119
|
+
if (!isNotFoundError(error))
|
|
1120
|
+
throw error;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
if (!existing) {
|
|
1124
|
+
existing = await this.ports.coolify.findApplicationByName(appNameFor(org, entry), {
|
|
1125
|
+
environmentIds: [environment.id],
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
// An entry with no names is internal: the other entries reach it as
|
|
1129
|
+
// `<slug>-<key>`, and it gets no domain, route or DNS record.
|
|
1130
|
+
const names = entryHostnames(org, entry);
|
|
1131
|
+
const isStatic = entry.buildPack === 'static';
|
|
1132
|
+
const folderField = isStatic ? 'publish_directory' : 'base_directory';
|
|
1133
|
+
const settingsKey = appSettingsAppliedScopeKey(entry.key);
|
|
1134
|
+
const domainsKey = appDomainsAppliedScopeKey(entry.key);
|
|
1135
|
+
let haveNames;
|
|
1136
|
+
let have;
|
|
1056
1137
|
let appId;
|
|
1057
1138
|
if (existing) {
|
|
1058
1139
|
appId = existing.uuid;
|
|
@@ -1062,10 +1143,12 @@ export class FoundryStepExecutor {
|
|
|
1062
1143
|
// stops the app without deleting it, so re-enabling found it here,
|
|
1063
1144
|
// still stopped, and it stayed stopped forever despite `apply`
|
|
1064
1145
|
// reporting success.
|
|
1065
|
-
|
|
1146
|
+
live ??= await this.ports.coolify.getApplication(existing.uuid);
|
|
1066
1147
|
if (!isRunningOrStarting(live.status)) {
|
|
1067
1148
|
await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
|
|
1068
1149
|
}
|
|
1150
|
+
have = appliedAppSettings(context.recordedIds(), entry.key);
|
|
1151
|
+
haveNames = appliedAppDomains(org, context.recordedIds(), entry.key);
|
|
1069
1152
|
}
|
|
1070
1153
|
else {
|
|
1071
1154
|
const server = await this.resolveServer();
|
|
@@ -1077,24 +1160,60 @@ export class FoundryStepExecutor {
|
|
|
1077
1160
|
environment_uuid: environment.uuid,
|
|
1078
1161
|
github_app_uuid: githubApp.uuid,
|
|
1079
1162
|
git_repository: appRepo,
|
|
1080
|
-
git_branch:
|
|
1081
|
-
build_pack:
|
|
1082
|
-
name: org
|
|
1083
|
-
domains:
|
|
1163
|
+
git_branch: entry.branch,
|
|
1164
|
+
build_pack: entry.buildPack,
|
|
1165
|
+
name: appNameFor(org, entry),
|
|
1166
|
+
domains: names.length > 0 ? names.join(',') : undefined,
|
|
1167
|
+
autogenerate_domain: names.length > 0 ? undefined : false,
|
|
1168
|
+
// A static site is served by Coolify's own web server on port 80.
|
|
1169
|
+
ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
|
|
1170
|
+
is_auto_deploy_enabled: entry.autoDeploy,
|
|
1171
|
+
[folderField]: entry.folder === null ? undefined : `/${entry.folder}`,
|
|
1084
1172
|
// Only the shared standard app builds from this repo; a bespoke
|
|
1085
1173
|
// app's Dockerfile is at its own repo's root.
|
|
1086
|
-
dockerfile_location:
|
|
1174
|
+
dockerfile_location: entry.repo ? undefined : DEFAULT_APP_DOCKERFILE,
|
|
1087
1175
|
});
|
|
1088
1176
|
appId = created.uuid;
|
|
1089
|
-
|
|
1177
|
+
// Create sent every setting, so nothing is left to patch.
|
|
1178
|
+
have = appSettingsOf(entry);
|
|
1179
|
+
haveNames = names;
|
|
1180
|
+
}
|
|
1181
|
+
// Patch what differs from what the app has. A crash before the marker is
|
|
1182
|
+
// recorded below leaves the old one, so the retry patches the same
|
|
1183
|
+
// difference again.
|
|
1184
|
+
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)
|
|
1202
|
+
await this.ports.coolify.updateApplication(appId, patch);
|
|
1203
|
+
// The container builds and listens from these only at its next deploy.
|
|
1204
|
+
const settingsNeedDeploy = Boolean(patch.base_directory ||
|
|
1205
|
+
patch.publish_directory ||
|
|
1206
|
+
patch.ports_exposes ||
|
|
1207
|
+
patch.domains !== undefined ||
|
|
1208
|
+
patch.custom_internal_name);
|
|
1090
1209
|
// The database plans before the app (`diff.ts`'s `computePlanDetail`,
|
|
1091
1210
|
// hosting-wiring campaign) so that, on the same apply, the database's
|
|
1092
1211
|
// resource id is already recorded by the time this runs -- but this also
|
|
1093
1212
|
// covers the database being turned on after the app already exists (the
|
|
1094
1213
|
// app step still ran this apply, found the app by name, and lands here).
|
|
1095
1214
|
let dbPush = { pushed: false, changed: false };
|
|
1096
|
-
if (
|
|
1097
|
-
const dbId = context.recordedIds()[`coolify:database:${
|
|
1215
|
+
if (entry.database) {
|
|
1216
|
+
const dbId = context.recordedIds()[`coolify:database:${org.id}`];
|
|
1098
1217
|
if (dbId) {
|
|
1099
1218
|
dbPush = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
|
|
1100
1219
|
}
|
|
@@ -1102,7 +1221,7 @@ export class FoundryStepExecutor {
|
|
|
1102
1221
|
// Restart when the push changed something, or when no earlier run got as
|
|
1103
1222
|
// far as restarting after one (marker missing): a crash between the push
|
|
1104
1223
|
// and the restart leaves the env right and the container stale.
|
|
1105
|
-
const dbNeedsRestart = dbPush.pushed && (dbPush.changed || !context.recordedIds()[
|
|
1224
|
+
const dbNeedsRestart = dbPush.pushed && (dbPush.changed || !context.recordedIds()[dbEnvAppliedScopeKey(entry.key)]);
|
|
1106
1225
|
// Every app keeps its own logs across a redeploy (#75). Unconditional,
|
|
1107
1226
|
// like the env push below: found by mount path before create, so a
|
|
1108
1227
|
// re-run (or the drift step `diff.ts`'s `planApp` plans for an app that
|
|
@@ -1110,27 +1229,42 @@ export class FoundryStepExecutor {
|
|
|
1110
1229
|
// `ensureLogVolume` whether this app was already running before this
|
|
1111
1230
|
// call -- see its own doc comment for why that decides whether to
|
|
1112
1231
|
// restart it.
|
|
1113
|
-
await this.ensureLogVolume(appId, org, Boolean(existing));
|
|
1232
|
+
await this.ensureLogVolume(appId, org, Boolean(existing), appNameFor(org, entry));
|
|
1114
1233
|
// The app receives whatever it needs to sign in (gap P2). Unconditional,
|
|
1115
1234
|
// unlike the database push above: every app has a hostname, and an
|
|
1116
1235
|
// 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);
|
|
1236
|
+
const appEnv = await this.pushAppEnv(appId, org, entry, context, action.desired.authIssuer ?? null);
|
|
1118
1237
|
// Rule 4 (#239, #243): a container reads its env only at start, so an app
|
|
1119
1238
|
// that was already there is restarted, once, when its database URLs or its
|
|
1120
1239
|
// declared appEnv changed.
|
|
1121
|
-
|
|
1240
|
+
// A new folder needs a build, which a restart does not do, so a settings
|
|
1241
|
+
// change deploys instead (rule 3), and that carries the env too.
|
|
1242
|
+
if (settingsNeedDeploy)
|
|
1243
|
+
await this.ports.coolify.deploy(appId);
|
|
1244
|
+
else if ((appEnv.changed || dbNeedsRestart) && existing)
|
|
1122
1245
|
await this.ports.coolify.restart(appId);
|
|
1123
|
-
// Recorded only after the
|
|
1124
|
-
//
|
|
1125
|
-
|
|
1246
|
+
// Recorded only after the redeploy succeeded: a failed one leaves the old
|
|
1247
|
+
// value, so the next plan replans (rule 3). The db marker is not a URL.
|
|
1248
|
+
// A port taken back to null was not patched, so the marker keeps the old
|
|
1249
|
+
// one and `planApp` reports it instead of planning it.
|
|
1250
|
+
context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
|
|
1251
|
+
context.recordScopeId(domainsKey, JSON.stringify(names));
|
|
1252
|
+
context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
|
|
1253
|
+
if (entry.uses?.auth) {
|
|
1254
|
+
context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
|
|
1255
|
+
}
|
|
1126
1256
|
if (dbPush.pushed)
|
|
1127
|
-
context.recordScopeId(
|
|
1257
|
+
context.recordScopeId(dbEnvAppliedScopeKey(entry.key), '1');
|
|
1128
1258
|
// #140 rework (2026-09-28 lettermint-org-mail swap): the hosted app's own
|
|
1129
1259
|
// Lettermint project, domain restriction and `MAIL_FROM` (only the app
|
|
1130
1260
|
// started by this same step, per the design's "only apps that send mail
|
|
1131
1261
|
// get it" -- cms and calls do not). A no-op until `coolify:mail` has
|
|
1132
1262
|
// actually resolved a mail domain for this org.
|
|
1133
|
-
|
|
1263
|
+
// One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
|
|
1264
|
+
// second entry sharing the name would delete the first's live token.
|
|
1265
|
+
if (entry.key === APP_ENTRY_KEY) {
|
|
1266
|
+
await this.ensureAppLettermintMail(appId, org, context, Boolean(existing));
|
|
1267
|
+
}
|
|
1134
1268
|
return { resourceId: appId };
|
|
1135
1269
|
}
|
|
1136
1270
|
/**
|
|
@@ -1154,13 +1288,13 @@ export class FoundryStepExecutor {
|
|
|
1154
1288
|
* `createGithubAppApplication`'s own instant-deploy path, so a restart
|
|
1155
1289
|
* here would only be a redundant extra one).
|
|
1156
1290
|
*/
|
|
1157
|
-
async ensureLogVolume(appId, org, wasAlreadyRunning) {
|
|
1291
|
+
async ensureLogVolume(appId, org, wasAlreadyRunning, appName = org.slug) {
|
|
1158
1292
|
const storages = await this.ports.coolify.listStorages(appId);
|
|
1159
1293
|
const already = (storages.persistent_storages ?? []).some((s) => s.mount_path === LOG_DIR);
|
|
1160
1294
|
if (already)
|
|
1161
1295
|
return;
|
|
1162
1296
|
await this.ports.coolify.createPersistentStorage(appId, {
|
|
1163
|
-
name: `${
|
|
1297
|
+
name: `${appName}-logs`,
|
|
1164
1298
|
mount_path: LOG_DIR,
|
|
1165
1299
|
});
|
|
1166
1300
|
if (wasAlreadyRunning) {
|
|
@@ -1207,14 +1341,16 @@ export class FoundryStepExecutor {
|
|
|
1207
1341
|
// Handles the database being provisioned after the app already exists
|
|
1208
1342
|
// (`coolifyApp`'s own push handles the more common order, database
|
|
1209
1343
|
// before app on the same apply -- `diff.ts`'s `computePlanDetail`).
|
|
1210
|
-
const
|
|
1211
|
-
|
|
1344
|
+
const dbEntry = org.hosting.entries.find((e) => e.database);
|
|
1345
|
+
const appId = dbEntry && context.recordedIds()[`coolify:app:${appStepKey(org, dbEntry.key)}`];
|
|
1346
|
+
if (dbEntry && appId) {
|
|
1347
|
+
const markerKey = dbEnvAppliedScopeKey(dbEntry.key);
|
|
1212
1348
|
const push = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
|
|
1213
1349
|
if (push.pushed) {
|
|
1214
|
-
if (push.changed || !context.recordedIds()[
|
|
1350
|
+
if (push.changed || !context.recordedIds()[markerKey]) {
|
|
1215
1351
|
await this.ports.coolify.restart(appId);
|
|
1216
1352
|
}
|
|
1217
|
-
context.recordScopeId(
|
|
1353
|
+
context.recordScopeId(markerKey, '1');
|
|
1218
1354
|
}
|
|
1219
1355
|
}
|
|
1220
1356
|
return { resourceId: dbId };
|
|
@@ -1288,7 +1424,7 @@ export class FoundryStepExecutor {
|
|
|
1288
1424
|
* P2, `.plans/2026-09-26-level-1-org-map.md`) -- a second `bulkUpsertEnvs`
|
|
1289
1425
|
* call rather than folding into `pushDatabaseUrl`'s, since this one runs
|
|
1290
1426
|
* unconditionally (`coolifyApp`) where that one runs only once
|
|
1291
|
-
* `
|
|
1427
|
+
* the `app` entry's `database` is declared and provisioned.
|
|
1292
1428
|
*
|
|
1293
1429
|
* `APP_URL` and `LOG_DIR` (#75) are pushed unconditionally; every other
|
|
1294
1430
|
* value is pushed only once its own source step has actually recorded it
|
|
@@ -1301,24 +1437,44 @@ export class FoundryStepExecutor {
|
|
|
1301
1437
|
* is its own design question, left out of #204's own "How" rather than
|
|
1302
1438
|
* guessed) is out of scope here (task brief). `DATABASE_URL_OWNER`/
|
|
1303
1439
|
* `DATABASE_URL` are pushed by `pushDatabaseUrl` instead, not here --
|
|
1304
|
-
* that call runs only once `
|
|
1440
|
+
* that call runs only once the `app` entry's `database` is declared and
|
|
1305
1441
|
* provisioned, where this one is unconditional (#239).
|
|
1306
1442
|
*/
|
|
1307
|
-
async pushAppEnv(appId, org, context) {
|
|
1443
|
+
async pushAppEnv(appId, org, entry, context, authIssuer) {
|
|
1308
1444
|
const recorded = context.recordedIds();
|
|
1309
1445
|
const envs = [
|
|
1310
|
-
|
|
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
|
+
: []),
|
|
1311
1450
|
// #75: read back by the package-side pino sink (not this repo's job,
|
|
1312
1451
|
// #75 "What belongs where") once `ensureLogVolume` has mounted the
|
|
1313
1452
|
// matching volume.
|
|
1314
1453
|
{ key: 'LOG_DIR', value: LOG_DIR },
|
|
1315
1454
|
];
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
const
|
|
1320
|
-
if (
|
|
1321
|
-
|
|
1455
|
+
// An entry with `uses.auth` signs in one organisation, with its own client
|
|
1456
|
+
// and the issuer; any other entry keeps the org's staff organisation and
|
|
1457
|
+
// the one hosted-app client.
|
|
1458
|
+
const authEnv = entryAuthEnv(org, entry, recorded, authIssuer);
|
|
1459
|
+
if (entry.uses?.auth) {
|
|
1460
|
+
// A customers entry never falls back to the staff organisation's id.
|
|
1461
|
+
if (!authEnv.AUTH_ORGANIZATION_ID) {
|
|
1462
|
+
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
|
+
}
|
|
1464
|
+
for (const [key, value] of Object.entries(authEnv))
|
|
1465
|
+
envs.push({ key, value });
|
|
1466
|
+
const appKey = await this.ensureAuthAppKey(appId);
|
|
1467
|
+
if (appKey)
|
|
1468
|
+
envs.push({ key: 'AUTH_APP_KEY', value: appKey });
|
|
1469
|
+
}
|
|
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
|
+
}
|
|
1322
1478
|
// The invited owner's ZITADEL user id (gap P3): the app uses it to make
|
|
1323
1479
|
// them its own owner in authz on first sign-in. `runSteps` records this
|
|
1324
1480
|
// under the same `system:kind:key` shape every step's `resourceId` uses
|
|
@@ -1339,7 +1495,7 @@ export class FoundryStepExecutor {
|
|
|
1339
1495
|
// minted here; a source with no value yet (its own step has not run,
|
|
1340
1496
|
// or has not run for this apply) is simply omitted, the same way every
|
|
1341
1497
|
// other optional entry above is.
|
|
1342
|
-
const appEnv = resolveAppEnv(org, recorded).values;
|
|
1498
|
+
const appEnv = resolveAppEnv(org, entry, recorded).values;
|
|
1343
1499
|
for (const [key, value] of Object.entries(appEnv))
|
|
1344
1500
|
envs.push({ key, value });
|
|
1345
1501
|
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
@@ -1347,7 +1503,66 @@ export class FoundryStepExecutor {
|
|
|
1347
1503
|
// replans this step when the declared map changes (rule 3). Ids and a
|
|
1348
1504
|
// domain, never a secret.
|
|
1349
1505
|
const applied = JSON.stringify(appEnv);
|
|
1350
|
-
|
|
1506
|
+
// The sign-in values, recorded by `coolifyApp` the same way; the key is
|
|
1507
|
+
// never part of them.
|
|
1508
|
+
const authApplied = JSON.stringify(authEnv);
|
|
1509
|
+
return {
|
|
1510
|
+
changed: applied !== (recorded[appEnvAppliedScopeKey(entry.key)] ?? '{}') ||
|
|
1511
|
+
(Boolean(entry.uses?.auth) &&
|
|
1512
|
+
authApplied !== (recorded[entryAuthEnvAppliedScopeKey(entry.key)] ?? '{}')),
|
|
1513
|
+
applied,
|
|
1514
|
+
authApplied,
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* F5: the app's `AUTH_APP_KEY`, minted once and only when the app has none
|
|
1519
|
+
* of at least 32 characters (the broker refuses a shorter one). `null` once
|
|
1520
|
+
* it has one: the app's env is where the key lives, and
|
|
1521
|
+
* `coolifyBrokerEntry` reads it back from there. It goes to this env and
|
|
1522
|
+
* the broker's, nowhere else.
|
|
1523
|
+
*/
|
|
1524
|
+
async ensureAuthAppKey(appId) {
|
|
1525
|
+
const envs = await this.ports.coolify.listEnvs(appId);
|
|
1526
|
+
const already = envs.some((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview && (e.value ?? '').length >= 32);
|
|
1527
|
+
return already ? null : randomBytes(32).toString('base64url');
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* `plan/diff.ts`'s `planBrokerEntries` (F5): the entry's `AUTH_APPS` entry,
|
|
1531
|
+
* written by `writeBrokerEntry` with the key `pushAppEnv` put in the app's
|
|
1532
|
+
* env. Records what it wrote (never the key) and any foreign entry the
|
|
1533
|
+
* broker would drop.
|
|
1534
|
+
*/
|
|
1535
|
+
async coolifyBrokerEntry(action, org, context) {
|
|
1536
|
+
const broker = this.ports.broker;
|
|
1537
|
+
if (!broker)
|
|
1538
|
+
throw new Error(`coolify:broker-entry for ${org.slug}: the broker port is not wired`);
|
|
1539
|
+
const recorded = context.recordedIds();
|
|
1540
|
+
const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key);
|
|
1541
|
+
if (!entry?.uses?.auth) {
|
|
1542
|
+
throw new Error(`coolify:broker-entry for ${org.slug}: no uses.auth entry for ${action.key}`);
|
|
1543
|
+
}
|
|
1544
|
+
const wanted = wantedBrokerEntry(org, entry, recorded);
|
|
1545
|
+
if ('missing' in wanted) {
|
|
1546
|
+
throw new Error(`coolify:broker-entry for ${org.slug}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
|
|
1547
|
+
}
|
|
1548
|
+
const appId = recorded[`coolify:app:${action.key}`];
|
|
1549
|
+
if (!appId) {
|
|
1550
|
+
throw new Error(`coolify:broker-entry for ${org.slug}: app ${action.key} is not recorded yet`);
|
|
1551
|
+
}
|
|
1552
|
+
const appKey = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview)?.value;
|
|
1553
|
+
if (!appKey || appKey.length < 32) {
|
|
1554
|
+
throw new Error(`coolify:broker-entry for ${org.slug}: app ${appId} has no AUTH_APP_KEY yet`);
|
|
1555
|
+
}
|
|
1556
|
+
const previousRaw = recorded[brokerEntryAppliedScopeKey(entry.key)];
|
|
1557
|
+
const result = await writeBrokerEntry(broker, {
|
|
1558
|
+
want: wanted.want,
|
|
1559
|
+
appKey,
|
|
1560
|
+
previous: previousRaw ? JSON.parse(previousRaw) : null,
|
|
1561
|
+
});
|
|
1562
|
+
context.recordScopeId(brokerEntryWarningsScopeKey(entry.key), JSON.stringify(result.invalidForeign));
|
|
1563
|
+
// Last: a run that failed above plans the step again.
|
|
1564
|
+
context.recordScopeId(brokerEntryAppliedScopeKey(entry.key), JSON.stringify(wanted.want));
|
|
1565
|
+
return {};
|
|
1351
1566
|
}
|
|
1352
1567
|
/**
|
|
1353
1568
|
* `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
|
|
@@ -2447,16 +2662,19 @@ export class FoundryStepExecutor {
|
|
|
2447
2662
|
return { resourceId: tunnelId };
|
|
2448
2663
|
}
|
|
2449
2664
|
const tunnelName = `${org.slug}-tunnel`;
|
|
2450
|
-
// #236 round 5 finding 2: read, and (if it is about
|
|
2451
|
-
//
|
|
2452
|
-
//
|
|
2453
|
-
//
|
|
2454
|
-
//
|
|
2665
|
+
// #236 round 5 finding 2 / round 6 finding 2: read, and (if it is about
|
|
2666
|
+
// to change) added to the accumulated replaced-id set, before
|
|
2667
|
+
// `createTunnel` can replace it -- unconditionally, not only inside the
|
|
2668
|
+
// `if (org.domain)` block below, so a recreate on an apply with no
|
|
2669
|
+
// domain declared still leaves `destroy` a way to recognise the old
|
|
2670
|
+
// id's CNAMEs as this tunnel's own rather than a foreign record.
|
|
2455
2671
|
const recordedTunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
|
|
2456
2672
|
const existing = await this.ports.cloudflare.findTunnelByName(tunnelName);
|
|
2457
2673
|
const tunnel = existing ?? (await this.ports.cloudflare.createTunnel(tunnelName));
|
|
2674
|
+
const previousIds = parseTunnelPreviousIds(context.recordedIds());
|
|
2458
2675
|
if (recordedTunnelId && recordedTunnelId !== tunnel.id) {
|
|
2459
|
-
|
|
2676
|
+
previousIds.add(recordedTunnelId);
|
|
2677
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY, tunnelPreviousIdsState(previousIds));
|
|
2460
2678
|
}
|
|
2461
2679
|
// Routes each of the org's hostnames (`tunnelIngressHostnames`) to
|
|
2462
2680
|
// Coolify's own Traefik reverse proxy, not a per-app container address --
|
|
@@ -2465,7 +2683,7 @@ export class FoundryStepExecutor {
|
|
|
2465
2683
|
// offline.tf's bootstrap, cited by
|
|
2466
2684
|
// https://github.com/wtfalch/foundry/issues/71): every app on the
|
|
2467
2685
|
// Coolify box already gets a Traefik route from the `domains` its
|
|
2468
|
-
// `coolify:app` step declared (`
|
|
2686
|
+
// `coolify:app` step declared (`appHostname(org)`, this file's
|
|
2469
2687
|
// `coolifyApp`), so a cloudflared connector joined to the same Docker
|
|
2470
2688
|
// network only has to reach Traefik and let it route by Host header --
|
|
2471
2689
|
// the connector never needs to know the app's own container name or
|
|
@@ -2541,12 +2759,12 @@ export class FoundryStepExecutor {
|
|
|
2541
2759
|
const previousDomain = recordedBefore[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY];
|
|
2542
2760
|
const previousHostnamesRaw = recordedBefore[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
|
|
2543
2761
|
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
2762
|
const domainChanged = previousDomain !== undefined && previousDomain !== org.domain;
|
|
2549
|
-
const publishedHostnames = await this.publishTunnelDnsRecords(org, org.domain, tunnel.id,
|
|
2763
|
+
const publishedHostnames = await this.publishTunnelDnsRecords(org, org.domain, tunnel.id,
|
|
2764
|
+
// Round 6 finding 2: every id this step has ever replaced, not only
|
|
2765
|
+
// the one `recordedTunnelId` captured above -- see the scope key's
|
|
2766
|
+
// own doc comment.
|
|
2767
|
+
previousIds,
|
|
2550
2768
|
// Round 4 finding 2: a hostname this domain itself dropped (no domain
|
|
2551
2769
|
// change) is cleaned up inline by `publishTunnelDnsRecords`, which
|
|
2552
2770
|
// needs the domain's own previous hostname list for that. A domain
|
|
@@ -2554,17 +2772,31 @@ export class FoundryStepExecutor {
|
|
|
2554
2772
|
// empty list here rather than a second, redundant pass over it.
|
|
2555
2773
|
domainChanged ? [] : previousHostnames, context);
|
|
2556
2774
|
if (domainChanged) {
|
|
2557
|
-
await this.deleteStaleTunnelDnsRecords(tunnel.id,
|
|
2775
|
+
await this.deleteStaleTunnelDnsRecords(tunnel.id, previousIds, previousDomain, previousHostnames, context);
|
|
2558
2776
|
}
|
|
2559
2777
|
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY, org.domain);
|
|
2560
2778
|
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY, publishedHostnames.join(','));
|
|
2561
|
-
// #236 round
|
|
2562
|
-
//
|
|
2563
|
-
//
|
|
2564
|
-
//
|
|
2565
|
-
//
|
|
2566
|
-
|
|
2567
|
-
|
|
2779
|
+
// #236 round 7: every previous id's CNAMEs *under `org.domain`* have
|
|
2780
|
+
// now been repointed to `tunnel.id` or cleaned up by publish/cleanup
|
|
2781
|
+
// above (current-domain hostnames by the write loop inside
|
|
2782
|
+
// `publishTunnelDnsRecords`, the old domain's by
|
|
2783
|
+
// `deleteStaleTunnelDnsRecords` above) -- but a tracked entry left
|
|
2784
|
+
// under some *other* domain by an earlier apply whose own cleanup
|
|
2785
|
+
// never ran, or itself threw, can still point at one of `previousIds`.
|
|
2786
|
+
// Round 6 finding 2's unconditional clear missed exactly that: apply 1
|
|
2787
|
+
// moves domain A to B, creates `h1.B` on tunnel T1, and A's cleanup
|
|
2788
|
+
// throws; the tunnel is then recreated as T2 out of band and the org
|
|
2789
|
+
// declares domain C; apply 2 publishes C and cleans up only A, leaving
|
|
2790
|
+
// `h1.B` tracked and still pointed at T1 -- clearing the id set here
|
|
2791
|
+
// would make `destroy` and the next `deleteStaleTunnelDnsRecords`
|
|
2792
|
+
// match only T2 and leave `h1.B` live forever. So the set is cleared
|
|
2793
|
+
// only once the tracked set names no domain but the one just
|
|
2794
|
+
// published: the one case where nothing tracked can still reference
|
|
2795
|
+
// an old id. A no-op the first time this step ever runs.
|
|
2796
|
+
const trackedDomains = trackedTunnelDnsByDomain(parseTrackedTunnelDns(context.recordedIds())).keys();
|
|
2797
|
+
if ([...trackedDomains].every((domain) => domain === org.domain)) {
|
|
2798
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY, '');
|
|
2799
|
+
}
|
|
2568
2800
|
}
|
|
2569
2801
|
return { resourceId: tunnel.id };
|
|
2570
2802
|
}
|
|
@@ -2587,23 +2819,36 @@ export class FoundryStepExecutor {
|
|
|
2587
2819
|
* existed falls back to the old zone-wide pass, the one case where no
|
|
2588
2820
|
* hostname list was ever written to look up instead.
|
|
2589
2821
|
*
|
|
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
|
-
*
|
|
2822
|
+
* `previousIds` (round 4 finding 1, round 6 finding 2): the old domain's
|
|
2823
|
+
* CNAMEs were published under whatever tunnel id was live the *last*
|
|
2824
|
+
* time this step recorded that domain, which may be any id this step has
|
|
2825
|
+
* ever replaced, not necessarily `tunnelId` -- a tunnel recreated under
|
|
2826
|
+
* the same name (`findTunnelByName` returning a new id), possibly more
|
|
2827
|
+
* than once with no successful publish in between, in the same apply
|
|
2828
|
+
* that also changes the domain leaves the old domain's records pointed
|
|
2829
|
+
* at an old id. Matching only `tunnelId`'s target left them live
|
|
2830
|
+
* forever; matching only the single most recent previous id (round 4)
|
|
2831
|
+
* still missed an older one two unresolved recreates back. Every target
|
|
2832
|
+
* is checked so this stays a no-op once a retry has already moved the
|
|
2833
|
+
* old domain onto the current id.
|
|
2834
|
+
*
|
|
2835
|
+
* Round 6 finding 1: once `previousHostnames` is non-empty, every name in
|
|
2836
|
+
* it has now been looked up -- deleted (its CNAME matched one of
|
|
2837
|
+
* `dnsTargets`), left alone (found but foreign), or never found at all
|
|
2838
|
+
* (already gone) -- so none of them needs tracking under `previousDomain`
|
|
2839
|
+
* in {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY} any longer; dropped
|
|
2840
|
+
* from that set accordingly. The zone-wide fallback path (no recorded
|
|
2841
|
+
* hostnames) never added entries of its own to begin with, so there is
|
|
2842
|
+
* nothing of this domain's left in that set either way.
|
|
2599
2843
|
*/
|
|
2600
|
-
async deleteStaleTunnelDnsRecords(tunnelId,
|
|
2844
|
+
async deleteStaleTunnelDnsRecords(tunnelId, previousIds, previousDomain, previousHostnames, context) {
|
|
2601
2845
|
const zone = await this.ports.cloudflare.findZoneByApex(previousDomain);
|
|
2602
2846
|
if (!zone)
|
|
2603
2847
|
return;
|
|
2604
2848
|
const dnsTargets = new Set([`${tunnelId}.cfargotunnel.com`]);
|
|
2605
|
-
|
|
2606
|
-
|
|
2849
|
+
for (const id of previousIds) {
|
|
2850
|
+
if (id !== tunnelId)
|
|
2851
|
+
dnsTargets.add(`${id}.cfargotunnel.com`);
|
|
2607
2852
|
}
|
|
2608
2853
|
const cf = this.ports.cloudflare;
|
|
2609
2854
|
const records = previousHostnames.length > 0
|
|
@@ -2614,6 +2859,13 @@ export class FoundryStepExecutor {
|
|
|
2614
2859
|
await cf.deleteDnsRecord(zone.id, record.id);
|
|
2615
2860
|
}
|
|
2616
2861
|
}
|
|
2862
|
+
if (previousHostnames.length > 0) {
|
|
2863
|
+
const tracked = parseTrackedTunnelDns(context.recordedIds());
|
|
2864
|
+
for (const hostname of previousHostnames) {
|
|
2865
|
+
tracked.delete(trackedTunnelDnsEntry(previousDomain, hostname));
|
|
2866
|
+
}
|
|
2867
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY, trackedTunnelDnsState(tracked));
|
|
2868
|
+
}
|
|
2617
2869
|
}
|
|
2618
2870
|
/**
|
|
2619
2871
|
* #236: upserts a proxied CNAME to this tunnel for each of
|
|
@@ -2639,13 +2891,15 @@ export class FoundryStepExecutor {
|
|
|
2639
2891
|
* (round 4 finding 3), so a crash mid-write is recovered by re-running
|
|
2640
2892
|
* this same idempotent step, never silently skipped.
|
|
2641
2893
|
*
|
|
2642
|
-
* Round 3: `
|
|
2643
|
-
*
|
|
2644
|
-
*
|
|
2645
|
-
*
|
|
2646
|
-
*
|
|
2647
|
-
*
|
|
2648
|
-
*
|
|
2894
|
+
* Round 3, round 6 finding 2: `previousIds` is every tunnel id this same
|
|
2895
|
+
* step has ever replaced (`cloudflareTunnel`'s own accumulated set,
|
|
2896
|
+
* captured before `tunnel` above is computed for this run) -- a tunnel
|
|
2897
|
+
* Cloudflare shows as recreated under the same name (`findTunnelByName`
|
|
2898
|
+
* returning a new id for `<slug>-tunnel`), possibly more than once with
|
|
2899
|
+
* no successful publish in between, leaves every CNAME still pointed at
|
|
2900
|
+
* one of those old ids. That is this org's own stale record, not a
|
|
2901
|
+
* foreign one, so it is repointed rather than refused, whichever of
|
|
2902
|
+
* `previousIds` it still names.
|
|
2649
2903
|
*
|
|
2650
2904
|
* Round 4 finding 2: `previousHostnames` is this domain's own previously
|
|
2651
2905
|
* recorded hostname list (empty when the domain just changed --
|
|
@@ -2655,29 +2909,29 @@ export class FoundryStepExecutor {
|
|
|
2655
2909
|
* `hostnames` below but was never cleaned up before this round: its CNAME
|
|
2656
2910
|
* stayed live and its name stayed in the recorded list forever, because
|
|
2657
2911
|
* 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.
|
|
2912
|
+
* deleted here when it still points at this tunnel or any of
|
|
2913
|
+
* `previousIds`; a name already gone, or now pointed at something
|
|
2914
|
+
* foreign, is simply left out of the returned list -- nothing here is
|
|
2915
|
+
* this method's to reclaim or report as a conflict.
|
|
2662
2916
|
*
|
|
2663
|
-
* Round 5 finding 1: `context` is used
|
|
2664
|
-
*
|
|
2665
|
-
* {@link
|
|
2666
|
-
*
|
|
2667
|
-
*
|
|
2668
|
-
*
|
|
2917
|
+
* Round 5 finding 1, round 6 finding 1: `context` is used to add every
|
|
2918
|
+
* hostname this call confirms correct (settled, created or repointed) to
|
|
2919
|
+
* {@link CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY} as soon as it is
|
|
2920
|
+
* confirmed, and to drop a dropped hostname from it once that hostname's
|
|
2921
|
+
* own CNAME is resolved one way or another -- additively, alongside
|
|
2922
|
+
* whatever other domains that shared set already names, never
|
|
2923
|
+
* overwriting them. `destroy` and {@link deleteStaleTunnelDnsRecords}
|
|
2924
|
+
* both read this set.
|
|
2669
2925
|
*/
|
|
2670
|
-
async publishTunnelDnsRecords(org, domain, tunnelId,
|
|
2926
|
+
async publishTunnelDnsRecords(org, domain, tunnelId, previousIds, previousHostnames, context) {
|
|
2671
2927
|
const cf = this.ports.cloudflare;
|
|
2672
2928
|
const zone = await cf.findZoneByApex(domain);
|
|
2673
2929
|
if (!zone) {
|
|
2674
2930
|
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
2931
|
}
|
|
2676
2932
|
const dnsTarget = `${tunnelId}.cfargotunnel.com`;
|
|
2677
|
-
const
|
|
2678
|
-
|
|
2679
|
-
: undefined;
|
|
2680
|
-
const hostnames = tunnelIngressHostnames(org).filter((hostname) => hostname === domain || hostname.endsWith(`.${domain}`));
|
|
2933
|
+
const previousTargets = new Set([...previousIds].filter((id) => id !== tunnelId).map((id) => `${id}.cfargotunnel.com`));
|
|
2934
|
+
const hostnames = tunnelDnsHostnames(org, domain);
|
|
2681
2935
|
const droppedHostnames = previousHostnames.filter((hostname) => !hostnames.includes(hostname));
|
|
2682
2936
|
const conflicts = [];
|
|
2683
2937
|
const toCreate = [];
|
|
@@ -2692,7 +2946,7 @@ export class FoundryStepExecutor {
|
|
|
2692
2946
|
}
|
|
2693
2947
|
const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
|
|
2694
2948
|
if (cname && cname.content !== dnsTarget) {
|
|
2695
|
-
if (
|
|
2949
|
+
if (previousTargets.has(cname.content)) {
|
|
2696
2950
|
toRepoint.push({ hostname, recordId: cname.id });
|
|
2697
2951
|
continue;
|
|
2698
2952
|
}
|
|
@@ -2705,20 +2959,22 @@ export class FoundryStepExecutor {
|
|
|
2705
2959
|
if (conflicts.length > 0) {
|
|
2706
2960
|
throw new Error(`cloudflare:tunnel for ${org.slug}: will not point its tunnel at every hostname -- ${conflicts.join('; ')}; remove the conflicting record first`);
|
|
2707
2961
|
}
|
|
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
|
-
//
|
|
2962
|
+
// Round 5 finding 1, round 6 finding 1: `settled` is every hostname
|
|
2963
|
+
// under `domain` confirmed to have the right CNAME so far -- starting
|
|
2964
|
+
// with the ones that needed no write at all, then one more per
|
|
2965
|
+
// successful create or repoint below -- and each is added to the
|
|
2966
|
+
// shared tracked set as soon as it is confirmed, well before this
|
|
2967
|
+
// method returns. A failure on any later hostname still leaves
|
|
2968
|
+
// everything settled up to that point recorded, rather than only in
|
|
2969
|
+
// this function's own local state.
|
|
2715
2970
|
const settled = hostnames.filter((hostname) => !toCreate.includes(hostname) && !toRepoint.some((r) => r.hostname === hostname));
|
|
2716
|
-
const
|
|
2717
|
-
|
|
2718
|
-
|
|
2971
|
+
const tracked = parseTrackedTunnelDns(context.recordedIds());
|
|
2972
|
+
const recordTracked = (hostname) => {
|
|
2973
|
+
tracked.add(trackedTunnelDnsEntry(domain, hostname));
|
|
2974
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY, trackedTunnelDnsState(tracked));
|
|
2719
2975
|
};
|
|
2720
|
-
|
|
2721
|
-
|
|
2976
|
+
for (const hostname of settled)
|
|
2977
|
+
recordTracked(hostname);
|
|
2722
2978
|
for (const hostname of toCreate) {
|
|
2723
2979
|
await cf.createDnsRecord(zone.id, {
|
|
2724
2980
|
type: 'CNAME',
|
|
@@ -2726,8 +2982,7 @@ export class FoundryStepExecutor {
|
|
|
2726
2982
|
content: dnsTarget,
|
|
2727
2983
|
proxied: true,
|
|
2728
2984
|
});
|
|
2729
|
-
|
|
2730
|
-
recordPending();
|
|
2985
|
+
recordTracked(hostname);
|
|
2731
2986
|
}
|
|
2732
2987
|
for (const { hostname, recordId } of toRepoint) {
|
|
2733
2988
|
await cf.updateDnsRecord(zone.id, recordId, {
|
|
@@ -2736,18 +2991,25 @@ export class FoundryStepExecutor {
|
|
|
2736
2991
|
content: dnsTarget,
|
|
2737
2992
|
proxied: true,
|
|
2738
2993
|
});
|
|
2739
|
-
|
|
2740
|
-
recordPending();
|
|
2994
|
+
recordTracked(hostname);
|
|
2741
2995
|
}
|
|
2742
2996
|
// Round 4 finding 2: clean up a hostname this domain itself dropped.
|
|
2743
2997
|
// Not a conflict check -- there is nothing to create or repoint here,
|
|
2744
2998
|
// only a possible stale CNAME of this tunnel's own to remove. Never in
|
|
2745
|
-
// `hostnames`/`settled` to begin with, so nothing to
|
|
2999
|
+
// `hostnames`/`settled` to begin with, so nothing to track-add there.
|
|
3000
|
+
//
|
|
3001
|
+
// Round 6 finding 1: whatever happens -- deleted (its CNAME matched),
|
|
3002
|
+
// left alone (found but foreign), or never found at all (already gone)
|
|
3003
|
+
// -- this hostname's own entry (if it had one) no longer needs
|
|
3004
|
+
// tracking under `domain`, so it is dropped from the shared set either
|
|
3005
|
+
// way.
|
|
2746
3006
|
for (const hostname of droppedHostnames) {
|
|
2747
3007
|
const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
|
|
2748
|
-
if (cname && (cname.content === dnsTarget || cname.content
|
|
3008
|
+
if (cname && (cname.content === dnsTarget || previousTargets.has(cname.content))) {
|
|
2749
3009
|
await cf.deleteDnsRecord(zone.id, cname.id);
|
|
2750
3010
|
}
|
|
3011
|
+
tracked.delete(trackedTunnelDnsEntry(domain, hostname));
|
|
3012
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY, trackedTunnelDnsState(tracked));
|
|
2751
3013
|
}
|
|
2752
3014
|
return hostnames;
|
|
2753
3015
|
}
|
|
@@ -3213,46 +3475,46 @@ export class FoundryStepExecutor {
|
|
|
3213
3475
|
// look up instead. 404 handled locally, same reason as the
|
|
3214
3476
|
// connector above.
|
|
3215
3477
|
//
|
|
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];
|
|
3478
|
+
// Round 5 finding 2, round 6 finding 2: `dnsTargets` accepts this
|
|
3479
|
+
// tunnel's current id *or* any id
|
|
3480
|
+
// {@link CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY} still names --
|
|
3481
|
+
// every id this step has ever replaced, not only the latest -- the
|
|
3482
|
+
// same match `publishTunnelDnsRecords`'s own repoint logic already
|
|
3483
|
+
// uses, so a CNAME a domain-less apply's recreate left pointed at
|
|
3484
|
+
// an old id (never repointed, since that apply never ran the
|
|
3485
|
+
// DNS-publish block at all), possibly more than one recreate back,
|
|
3486
|
+
// still gets removed here.
|
|
3487
|
+
const previousTunnelIds = parseTunnelPreviousIds(recordedIds);
|
|
3231
3488
|
const dnsTargets = new Set([`${target.id}.cfargotunnel.com`]);
|
|
3232
|
-
|
|
3233
|
-
|
|
3489
|
+
for (const id of previousTunnelIds) {
|
|
3490
|
+
if (id !== target.id)
|
|
3491
|
+
dnsTargets.add(`${id}.cfargotunnel.com`);
|
|
3492
|
+
}
|
|
3493
|
+
// Round 6 finding 1: every domain the shared tracked set still
|
|
3494
|
+
// names, folded together with the authoritative domain/hostnames
|
|
3495
|
+
// pair (an org that predates that set has only the latter). A
|
|
3496
|
+
// domain the tracked set names but the authoritative pair does
|
|
3497
|
+
// not is exactly the case this round exists for -- a hostname
|
|
3498
|
+
// published under a domain the org has since moved away from
|
|
3499
|
+
// twice over, never caught by any single "previous domain" field.
|
|
3500
|
+
const hostnamesByDomain = trackedTunnelDnsByDomain(parseTrackedTunnelDns(recordedIds));
|
|
3501
|
+
const authoritativeDomain = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY];
|
|
3502
|
+
if (authoritativeDomain) {
|
|
3503
|
+
const authoritativeHostnamesRaw = recordedIds[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
|
|
3504
|
+
const merged = new Set(hostnamesByDomain.get(authoritativeDomain) ?? []);
|
|
3505
|
+
if (authoritativeHostnamesRaw) {
|
|
3506
|
+
for (const hostname of authoritativeHostnamesRaw.split(','))
|
|
3507
|
+
merged.add(hostname);
|
|
3508
|
+
}
|
|
3509
|
+
hostnamesByDomain.set(authoritativeDomain, [...merged]);
|
|
3234
3510
|
}
|
|
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;
|
|
3511
|
+
for (const [dnsDomain, dnsHostnames] of hostnamesByDomain) {
|
|
3248
3512
|
try {
|
|
3249
3513
|
const zone = await this.ports.cloudflare.findZoneByApex(dnsDomain);
|
|
3250
3514
|
if (zone) {
|
|
3251
3515
|
const cf = this.ports.cloudflare;
|
|
3252
|
-
const records =
|
|
3253
|
-
? (await Promise.all(
|
|
3254
|
-
.split(',')
|
|
3255
|
-
.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
|
|
3516
|
+
const records = dnsHostnames.length > 0
|
|
3517
|
+
? (await Promise.all(dnsHostnames.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
|
|
3256
3518
|
: await cf.listDnsRecords(zone.id, { type: 'CNAME' });
|
|
3257
3519
|
for (const record of records) {
|
|
3258
3520
|
if (dnsTargets.has(record.content)) {
|