@wtfalch/foundry 0.7.1 → 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/bootstrap.d.ts +12 -8
- package/dist/bootstrap/bootstrap.js +4 -12
- package/dist/bootstrap/declare.js +1 -5
- package/dist/bootstrap/versions.d.ts +8 -0
- package/dist/bootstrap/versions.js +31 -0
- package/dist/calls-sfu-deploy-support.d.ts +1 -1
- package/dist/calls-sfu-deploy-support.js +1 -1
- package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
- package/dist/calls-sfu-ssh-adapter.js +1 -1
- package/dist/calls-sfu.d.ts +5 -4
- package/dist/calls-sfu.js +5 -4
- package/dist/index.d.ts +4 -5
- package/dist/index.js +6 -3
- package/dist/migrations/0024_audit_events.sql +1 -1
- package/dist/migrations/0025_audit_display.sql +1 -1
- package/dist/migrations/0026_audit_erase_email_guard.sql +1 -1
- package/dist/migrations/0027_audit_chain.sql +1 -1
- package/dist/migrations/0028_audit_rls.sql +2 -2
- package/dist/migrations/0029_hosting_entries.sql +109 -0
- package/dist/migrations-dir.d.ts +9 -0
- package/dist/migrations-dir.js +10 -0
- package/dist/org.d.ts +59 -27
- package/dist/org.js +98 -26
- package/dist/plan/calls-matrix.d.ts +2 -0
- package/dist/plan/calls-matrix.js +2 -0
- package/dist/plan/diff.d.ts +3 -1
- package/dist/plan/diff.js +100 -32
- package/dist/plan/executor.d.ts +324 -19
- package/dist/plan/executor.js +764 -34
- package/dist/plan/index.d.ts +1 -1
- package/dist/plan/index.js +1 -1
- package/dist/plan/login-host.d.ts +24 -0
- package/dist/plan/login-host.js +21 -0
- package/dist/plan/plan-apply.d.ts +2 -0
- package/dist/plan/plan-apply.js +26 -10
- package/dist/plan/platform-diff.d.ts +102 -12
- package/dist/plan/platform-diff.js +236 -15
- package/dist/plan/platform-providers.d.ts +43 -1
- package/dist/plan/platform-providers.js +245 -33
- package/dist/plan/zitadel-system-api.d.ts +64 -0
- package/dist/plan/zitadel-system-api.js +125 -0
- package/dist/plan.d.ts +43 -0
- package/dist/plan.js +16 -1
- package/dist/platform.d.ts +1 -1
- package/dist/store/audit.d.ts +13 -17
- package/dist/store/audit.js +14 -18
- package/dist/store/postgres.d.ts +14 -0
- package/dist/store/postgres.js +15 -18
- package/dist/store/schema.d.ts +20 -1
- package/dist/store/schema.js +7 -1
- package/package.json +6 -3
- package/versions.json +1 -1
- package/dist/migrate.d.ts +0 -38
- package/dist/migrate.js +0 -47
package/dist/plan/executor.js
CHANGED
|
@@ -1,12 +1,34 @@
|
|
|
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
|
-
import { MAS_IMAGE, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
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
|
-
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, } from './login-host.js';
|
|
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, 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';
|
|
11
|
+
/**
|
|
12
|
+
* #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
|
|
13
|
+
* listApps` -- a plain array, with no `loginBaseUri` or completeness signal
|
|
14
|
+
* -- into {@link ZitadelPort.listApps}'s own shape, so a host wiring that
|
|
15
|
+
* real client in satisfies this port without reaching into `executor.ts`
|
|
16
|
+
* itself to build the wrapper by hand. `complete: false` on every call:
|
|
17
|
+
* the real client's own `_search` request has no pagination on it yet
|
|
18
|
+
* (https://github.com/wtfalch/providers/issues/29), so this adapter cannot
|
|
19
|
+
* promise it read more than one page. `loginBaseUri` comes back `undefined`
|
|
20
|
+
* for every app for the identical reason -- the real client does not read
|
|
21
|
+
* it back at all yet, not "read it back as absent" -- which is exactly the
|
|
22
|
+
* state `zitadelLoginHost`'s fail-closed rule exists for: every pending
|
|
23
|
+
* host stays pending until the tracked change above lands and this adapter
|
|
24
|
+
* (or its replacement) can start reporting `string | null` for real.
|
|
25
|
+
*/
|
|
26
|
+
export function adaptZitadelListApps(client) {
|
|
27
|
+
return async (orgId, projectId) => ({
|
|
28
|
+
apps: await client.listApps(orgId, projectId),
|
|
29
|
+
complete: false,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
10
32
|
// ---------------------------------------------------------------------------
|
|
11
33
|
// Errors
|
|
12
34
|
// ---------------------------------------------------------------------------
|
|
@@ -26,6 +48,14 @@ export class NotBuiltError extends Error {
|
|
|
26
48
|
// ---------------------------------------------------------------------------
|
|
27
49
|
// Hostnames
|
|
28
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
|
+
}
|
|
29
59
|
/**
|
|
30
60
|
* `<slug>.oikos.host` (`../slug.ts`), or `DeclaredOrg.domain` once
|
|
31
61
|
* `hosting.usesDomain` is true — the same rule `diff.ts`'s `planApp` and
|
|
@@ -37,6 +67,17 @@ export class NotBuiltError extends Error {
|
|
|
37
67
|
function hostnameFor(org) {
|
|
38
68
|
return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
|
|
39
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* #274 fix round 3 point 4: `zitadelLoginHost`'s own comparison between a
|
|
72
|
+
* live app's ZITADEL-reported login base URI and the one this step itself
|
|
73
|
+
* constructs (`https://<host>/`) -- lowercased and with its trailing `/`
|
|
74
|
+
* (there is always exactly one, a URI's path component, never more) trimmed,
|
|
75
|
+
* so a host is never read as "not in use" purely because ZITADEL's own
|
|
76
|
+
* casing or slashing differs from this step's.
|
|
77
|
+
*/
|
|
78
|
+
function normalizedLoginBaseUri(uri) {
|
|
79
|
+
return uri.toLowerCase().replace(/\/$/, '');
|
|
80
|
+
}
|
|
40
81
|
/**
|
|
41
82
|
* `cms.<domain>` -- the cms studio's own hostname (`cmsSite` below), apart
|
|
42
83
|
* from the public site itself at `org.domain` (used directly there). Called
|
|
@@ -278,6 +319,122 @@ const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
|
|
|
278
319
|
* (`platform-providers.ts`), never a shared tunnel with appended ingress.
|
|
279
320
|
*/
|
|
280
321
|
const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_app';
|
|
322
|
+
/**
|
|
323
|
+
* #236: the domain `publishTunnelDnsRecords` last published this org's
|
|
324
|
+
* tunnel's hostnames under -- compared against the org's current `domain`
|
|
325
|
+
* so `plan/diff.ts`'s `planTunnel` can tell a stale publish (never done at
|
|
326
|
+
* all, or the org's domain has since changed) from a steady state with
|
|
327
|
+
* nothing left to do, and so `destroy` and `deleteStaleTunnelDnsRecords`
|
|
328
|
+
* know which zone to clean up even once the org's declared domain has moved
|
|
329
|
+
* on. `diff.ts` keeps its own copy of this exact string, the same
|
|
330
|
+
* duplication {@link CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY} above
|
|
331
|
+
* already follows.
|
|
332
|
+
*/
|
|
333
|
+
const CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY = 'cloudflare_tunnel_dns_domain';
|
|
334
|
+
/**
|
|
335
|
+
* #236 round 3: every hostname {@link FoundryStepExecutor.publishTunnelDnsRecords}
|
|
336
|
+
* last wrote a CNAME for, joined by `,` (hostnames carry no `,`, the same
|
|
337
|
+
* join {@link MAIL_ROLE_MAILBOXES_SCOPE_KEY} already relies on) -- so
|
|
338
|
+
* cleanup (`destroy`'s `cloudflare:tunnel` case and
|
|
339
|
+
* {@link FoundryStepExecutor.deleteStaleTunnelDnsRecords}) can look each one
|
|
340
|
+
* up by name instead of listing the zone. The real adapter's
|
|
341
|
+
* `listDnsRecords` (`@wtfalch/providers`' `cloudflare.ts`) asks for one page
|
|
342
|
+
* of 100 and never walks `result_info.total_pages`, so a zone with more
|
|
343
|
+
* records than that silently lost everything past the first page. A record
|
|
344
|
+
* from before this key existed has no entry here yet: cleanup falls back to
|
|
345
|
+
* the old `listDnsRecords` zone-wide pass for that one run, and the next
|
|
346
|
+
* successful publish leaves this key set so later cleanups never need to.
|
|
347
|
+
*/
|
|
348
|
+
const CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_dns_hostnames';
|
|
349
|
+
/**
|
|
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.
|
|
372
|
+
*/
|
|
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
|
+
}
|
|
402
|
+
/**
|
|
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.
|
|
427
|
+
*/
|
|
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
|
+
}
|
|
281
438
|
/**
|
|
282
439
|
* #162: the owner's own mailbox, created once inside `coolify:mail` after
|
|
283
440
|
* `applyOrgMail` succeeds, alongside {@link MAIL_DOMAIN_SCOPE_KEY} -- another
|
|
@@ -472,7 +629,7 @@ export class FoundryStepExecutor {
|
|
|
472
629
|
}
|
|
473
630
|
/**
|
|
474
631
|
* `plan/diff.ts`'s `planAccess` (#84): puts the org's app hostname behind a
|
|
475
|
-
* Cloudflare Access app that lets only `
|
|
632
|
+
* Cloudflare Access app that lets only the `app` entry's `accessEmails` through.
|
|
476
633
|
* foundry names its Access app `foundry:<orgId>` and reuses only an app with
|
|
477
634
|
* that name, so a re-run after a crash does not duplicate it. It refuses,
|
|
478
635
|
* rather than adopt, when another Access app already holds the hostname:
|
|
@@ -498,7 +655,7 @@ export class FoundryStepExecutor {
|
|
|
498
655
|
await cf.createAccessPolicy(app.id, {
|
|
499
656
|
name: ACCESS_POLICY_NAME,
|
|
500
657
|
decision: 'allow',
|
|
501
|
-
include: org.hosting
|
|
658
|
+
include: (appEntry(org.hosting)?.accessEmails ?? []).map((email) => ({ email: { email } })),
|
|
502
659
|
});
|
|
503
660
|
}
|
|
504
661
|
return { resourceId: app.id };
|
|
@@ -599,9 +756,70 @@ export class FoundryStepExecutor {
|
|
|
599
756
|
* domains, writes zitadel-login's binding, then repoints the apps whose
|
|
600
757
|
* client id that binding holds. Apps created later are not in it yet:
|
|
601
758
|
* the next run replans (`diff.ts`'s `planLoginHost`) and moves them.
|
|
759
|
+
*
|
|
760
|
+
* #274: when the org's host has moved (`recorded[key]`, the *previous*
|
|
761
|
+
* run's resourceId, differs from this run's `host`), also removes every
|
|
762
|
+
* host that is no longer the org's current one from both places --
|
|
763
|
+
* `ensureTrustedDomain` and `upsertBinding` above only ever add, so
|
|
764
|
+
* without this an old host stays trusted and bound forever, and a later
|
|
765
|
+
* org that reuses the old slug inherits it unasked.
|
|
766
|
+
*
|
|
767
|
+
* #274 fix round 2: this always returns `host` -- the *current* desired
|
|
768
|
+
* host -- as its `resourceId`, never an old one, however many of its
|
|
769
|
+
* removals are still outstanding. Fix round 1 returned `previousHost`
|
|
770
|
+
* while a removal was skipped, banking on `planLoginHost` replanning the
|
|
771
|
+
* whole step off that mismatch alone; that loses track of the *new* host
|
|
772
|
+
* the moment a second host change moves `previousHost` on again (A -> B
|
|
773
|
+
* skipped, then B -> C: A's own removal has nothing left recording it was
|
|
774
|
+
* ever owed). `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY`
|
|
775
|
+
* (`pendingRemovalHosts`/`pendingRemovalState`, `login-host.ts`) is the
|
|
776
|
+
* durable record instead -- every host still awaiting removal, independent
|
|
777
|
+
* of which one is current -- and `planLoginHost` replans the step whenever
|
|
778
|
+
* it is non-empty, not only when the current host itself differs.
|
|
779
|
+
*
|
|
780
|
+
* Whether a host is still "in use" is decided from each live app's actual
|
|
781
|
+
* ZITADEL Login V2 base URI (`ZitadelPort.listApps`' `loginBaseUri`), not
|
|
782
|
+
* from `boundAppIds` membership: fix round 1 skipped removal only while an
|
|
783
|
+
* app outside `boundAppIds` existed, but `boundAppIds` is deliberately
|
|
784
|
+
* narrower than "every live app" (it excludes `native`/`api` apps and any
|
|
785
|
+
* `web` app without a recorded client id yet) and a host can safely be
|
|
786
|
+
* removed even while such an app exists, so long as nothing is actually
|
|
787
|
+
* pointed at it. Removing by the wrong signal either breaks sign-in (an
|
|
788
|
+
* app repointed is not the same fact as an app with no client id yet) or
|
|
789
|
+
* strands a host forever (an org with a `native`/`api` app would never
|
|
790
|
+
* satisfy "every live app bound").
|
|
791
|
+
*
|
|
792
|
+
* #274 fix round 3, four more defects fix round 2 left:
|
|
793
|
+
*
|
|
794
|
+
* 1. The *current* host is excluded from the candidates for removal.
|
|
795
|
+
* Without this, A -> B (A pending, not yet safe) -> A deletes the host
|
|
796
|
+
* this very call just (re-)trusted and bound: a host already in
|
|
797
|
+
* `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY` from an earlier run can be
|
|
798
|
+
* exactly the one the org's host is moving back to.
|
|
799
|
+
* 2. Fail closed on missing information, rather than ever reading
|
|
800
|
+
* "nothing uses it" off of one: an app whose `loginBaseUri` cannot be
|
|
801
|
+
* read at all (`undefined` -- which, until a real adapter is wired per
|
|
802
|
+
* `ZitadelPort.listApps`'s own doc comment, is every app, every call)
|
|
803
|
+
* means every pending host stays pending this run, not just the ones a
|
|
804
|
+
* narrower check happened to miss.
|
|
805
|
+
* 3. Same fail-closed rule for `listApps`' own `complete` flag: an
|
|
806
|
+
* incomplete read (today, always, for the identical reason) means the
|
|
807
|
+
* set of apps this call saw could be missing one still pointed at a
|
|
808
|
+
* pending host, so nothing is removed off of it either.
|
|
809
|
+
* 4. A base URI compares after lowercasing and trimming its trailing
|
|
810
|
+
* `/` (`normalizedLoginBaseUri`) -- ZITADEL's own value and the one
|
|
811
|
+
* this step constructs are not guaranteed to agree on case or a
|
|
812
|
+
* trailing slash, and a comparison that is case- or slash-sensitive
|
|
813
|
+
* both ways can report "in use" as "not" by accident.
|
|
814
|
+
* 5. Each live app's base URI is read as of *after* this call's own
|
|
815
|
+
* repoint loop above, not the stale value `listApps` returned before
|
|
816
|
+
* it: a bound app this very call just repointed away from a pending
|
|
817
|
+
* host must not hold that host pending for one more run only because
|
|
818
|
+
* the snapshot taken before the repoint still named it.
|
|
602
819
|
*/
|
|
603
820
|
async zitadelLoginHost(action, org, context) {
|
|
604
821
|
const recorded = context.recordedIds();
|
|
822
|
+
const previousHost = recorded[`zitadel:login-host:${action.key}`] ?? null;
|
|
605
823
|
const zitadelOrgId = recorded.zitadel_org;
|
|
606
824
|
if (!zitadelOrgId) {
|
|
607
825
|
throw new Error(`zitadel:login-host for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
@@ -628,17 +846,93 @@ export class FoundryStepExecutor {
|
|
|
628
846
|
...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
|
|
629
847
|
].map((name) => recorded[`zitadel:app:${name}`]));
|
|
630
848
|
const projectId = recorded.zitadel_project;
|
|
849
|
+
// No project yet means no apps yet: vacuously safe to decide from (fix
|
|
850
|
+
// round 3 point 2/3 apply only once there is something to fail closed
|
|
851
|
+
// on), and nothing to repoint either.
|
|
852
|
+
let liveApps = [];
|
|
853
|
+
let listingComplete = true;
|
|
631
854
|
if (projectId) {
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
855
|
+
const listed = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
|
|
856
|
+
listingComplete = listed.complete;
|
|
857
|
+
liveApps = listed.apps.filter((app) => app.state !== 'APP_STATE_REMOVED');
|
|
858
|
+
for (const app of liveApps) {
|
|
859
|
+
if (!boundAppIds.has(app.id))
|
|
635
860
|
continue;
|
|
636
861
|
await this.ports.zitadel.updateOidcAppLoginBaseUri(zitadelOrgId, projectId, app.id, `https://${host}/`);
|
|
637
862
|
}
|
|
638
863
|
}
|
|
639
|
-
//
|
|
640
|
-
//
|
|
864
|
+
// Fix round 3 point 5: a bound app's current base URI is `host` for
|
|
865
|
+
// certain, just set above, whatever `listApps` read before that --
|
|
866
|
+
// never its stale pre-repoint snapshot. Every other live app's is
|
|
867
|
+
// exactly what `listApps` reported, unmodified by this call. `null`
|
|
868
|
+
// (fix round 4 point 1: read, and confirmed this app has never had one)
|
|
869
|
+
// is deliberately not added -- it names no host, so there is nothing to
|
|
870
|
+
// compare against, same as an app this loop never visits at all.
|
|
871
|
+
const currentLoginBaseUris = new Set();
|
|
872
|
+
for (const app of liveApps) {
|
|
873
|
+
const uri = boundAppIds.has(app.id) ? `https://${host}/` : app.loginBaseUri;
|
|
874
|
+
if (typeof uri === 'string')
|
|
875
|
+
currentLoginBaseUris.add(normalizedLoginBaseUri(uri));
|
|
876
|
+
}
|
|
877
|
+
// Fix round 3 points 2/3, fix round 4 point 1: an unbound app's base
|
|
878
|
+
// URI this call could not read *at all* (`undefined`, never `null` --
|
|
879
|
+
// {@link ZitadelPort.listApps}'s own doc comment has the three-state
|
|
880
|
+
// distinction), or a listing this call cannot be sure is complete,
|
|
881
|
+
// means there is no way to tell whether a pending host is still in use
|
|
882
|
+
// -- fail closed for every pending host this run rather than trust a
|
|
883
|
+
// partial picture. A bound app always has a known-good URI as of just
|
|
884
|
+
// above, so it is never the reason to fail closed; nor is an unbound
|
|
885
|
+
// app `listApps` confirmed has no base URI at all (`null`) -- fail
|
|
886
|
+
// round 3 collapsed that into "unreadable" too, which would have kept
|
|
887
|
+
// a host pending forever against an org with such an app, even once
|
|
888
|
+
// the real client can tell the two apart.
|
|
889
|
+
const canDecideSafely = listingComplete &&
|
|
890
|
+
liveApps.every((app) => boundAppIds.has(app.id) || app.loginBaseUri !== undefined);
|
|
891
|
+
// Every host up for removal this run: whatever was already pending,
|
|
892
|
+
// plus the host this run is moving away from (if any) -- deduplicated,
|
|
893
|
+
// since a host can arrive here twice across two runs that both saw it
|
|
894
|
+
// pending -- and, fix round 3 point 1, never the host this very call
|
|
895
|
+
// just (re-)trusted and bound, however it got into either set.
|
|
896
|
+
const candidates = new Set(pendingRemovalHosts(recorded));
|
|
897
|
+
candidates.delete(host);
|
|
898
|
+
if (previousHost && previousHost !== host)
|
|
899
|
+
candidates.add(previousHost);
|
|
900
|
+
// #274: a removal, once the new host is fully live (trusted, bound, and
|
|
901
|
+
// every repointable app repointed) -- never the other way round, so an
|
|
902
|
+
// org is never left with no trusted host at all if this throws partway
|
|
903
|
+
// through. Each removal catches its own not-found separately (`destroy`'s
|
|
904
|
+
// own `cloudflare:tunnel` case below takes the identical precaution, for
|
|
905
|
+
// its connector app and its tunnel): a domain already gone must not skip
|
|
906
|
+
// the binding delete that follows it.
|
|
907
|
+
const stillPending = [];
|
|
908
|
+
for (const candidate of candidates) {
|
|
909
|
+
if (!canDecideSafely ||
|
|
910
|
+
currentLoginBaseUris.has(normalizedLoginBaseUri(`https://${candidate}/`))) {
|
|
911
|
+
stillPending.push(candidate);
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
try {
|
|
915
|
+
await this.ports.zitadel.deleteTrustedDomain(candidate);
|
|
916
|
+
}
|
|
917
|
+
catch (error) {
|
|
918
|
+
if (!isNotFoundError(error))
|
|
919
|
+
throw error;
|
|
920
|
+
}
|
|
921
|
+
try {
|
|
922
|
+
await bindings.deleteBinding(candidate);
|
|
923
|
+
}
|
|
924
|
+
catch (error) {
|
|
925
|
+
if (!isNotFoundError(error))
|
|
926
|
+
throw error;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
// Both last, so a crash above reruns the whole step rather than losing
|
|
930
|
+
// track of either. A client id recorded after this point differs from
|
|
931
|
+
// the binding and replans the step next run; a nonempty pending list
|
|
932
|
+
// does too (`planLoginHost`), independent of the host comparison that
|
|
933
|
+
// normally alone decides it.
|
|
641
934
|
context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
|
|
935
|
+
context.recordScopeId(LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, pendingRemovalState(stillPending));
|
|
642
936
|
return { resourceId: host };
|
|
643
937
|
}
|
|
644
938
|
/**
|
|
@@ -776,7 +1070,7 @@ export class FoundryStepExecutor {
|
|
|
776
1070
|
* that is not the survivor this is looking for.
|
|
777
1071
|
*/
|
|
778
1072
|
async findRecreatedApp(zitadelOrgId, projectId, name, appType) {
|
|
779
|
-
const apps = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
|
|
1073
|
+
const { apps } = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
|
|
780
1074
|
return apps.find((app) => app.name === name && app.appType === appType && app.state !== 'APP_STATE_REMOVED');
|
|
781
1075
|
}
|
|
782
1076
|
async ensureZitadelProject(org, zitadelOrgId, context) {
|
|
@@ -809,23 +1103,42 @@ export class FoundryStepExecutor {
|
|
|
809
1103
|
return server;
|
|
810
1104
|
}
|
|
811
1105
|
async coolifyApp(action, org, context) {
|
|
1106
|
+
const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
|
|
812
1107
|
if (action.desired.enabled === false) {
|
|
813
|
-
|
|
814
|
-
if (!appId) {
|
|
1108
|
+
if (!recordedAppId) {
|
|
815
1109
|
throw new Error(`coolify:app for ${org.slug}: no live app recorded, nothing to park`);
|
|
816
1110
|
}
|
|
817
|
-
await this.ports.coolify.raw('POST', `/applications/${
|
|
818
|
-
return { resourceId:
|
|
1111
|
+
await this.ports.coolify.raw('POST', `/applications/${recordedAppId}/stop`);
|
|
1112
|
+
return { resourceId: recordedAppId };
|
|
819
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`);
|
|
820
1117
|
// A level-1 org with no bespoke app of its own gets the shared standard
|
|
821
|
-
// app instead (gap P1, `.plans/2026-09-26-level-1-org-map.md`)
|
|
822
|
-
//
|
|
823
|
-
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;
|
|
824
1121
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
825
1122
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
+
}
|
|
829
1142
|
let appId;
|
|
830
1143
|
if (existing) {
|
|
831
1144
|
appId = existing.uuid;
|
|
@@ -835,7 +1148,7 @@ export class FoundryStepExecutor {
|
|
|
835
1148
|
// stops the app without deleting it, so re-enabling found it here,
|
|
836
1149
|
// still stopped, and it stayed stopped forever despite `apply`
|
|
837
1150
|
// reporting success.
|
|
838
|
-
|
|
1151
|
+
live ??= await this.ports.coolify.getApplication(existing.uuid);
|
|
839
1152
|
if (!isRunningOrStarting(live.status)) {
|
|
840
1153
|
await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
|
|
841
1154
|
}
|
|
@@ -850,13 +1163,13 @@ export class FoundryStepExecutor {
|
|
|
850
1163
|
environment_uuid: environment.uuid,
|
|
851
1164
|
github_app_uuid: githubApp.uuid,
|
|
852
1165
|
git_repository: appRepo,
|
|
853
|
-
git_branch:
|
|
854
|
-
build_pack:
|
|
855
|
-
name: org
|
|
1166
|
+
git_branch: entry.branch,
|
|
1167
|
+
build_pack: entry.buildPack,
|
|
1168
|
+
name: appNameFor(org, entry),
|
|
856
1169
|
domains: hostnameFor(org),
|
|
857
1170
|
// Only the shared standard app builds from this repo; a bespoke
|
|
858
1171
|
// app's Dockerfile is at its own repo's root.
|
|
859
|
-
dockerfile_location:
|
|
1172
|
+
dockerfile_location: entry.repo ? undefined : DEFAULT_APP_DOCKERFILE,
|
|
860
1173
|
});
|
|
861
1174
|
appId = created.uuid;
|
|
862
1175
|
}
|
|
@@ -866,8 +1179,8 @@ export class FoundryStepExecutor {
|
|
|
866
1179
|
// covers the database being turned on after the app already exists (the
|
|
867
1180
|
// app step still ran this apply, found the app by name, and lands here).
|
|
868
1181
|
let dbPush = { pushed: false, changed: false };
|
|
869
|
-
if (
|
|
870
|
-
const dbId = context.recordedIds()[`coolify:database:${
|
|
1182
|
+
if (entry.database) {
|
|
1183
|
+
const dbId = context.recordedIds()[`coolify:database:${org.id}`];
|
|
871
1184
|
if (dbId) {
|
|
872
1185
|
dbPush = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
|
|
873
1186
|
}
|
|
@@ -887,7 +1200,7 @@ export class FoundryStepExecutor {
|
|
|
887
1200
|
// The app receives whatever it needs to sign in (gap P2). Unconditional,
|
|
888
1201
|
// unlike the database push above: every app has a hostname, and an
|
|
889
1202
|
// upsert of the same value on a later apply is a no-op, not a change.
|
|
890
|
-
const appEnv = await this.pushAppEnv(appId, org, context);
|
|
1203
|
+
const appEnv = await this.pushAppEnv(appId, org, entry, context);
|
|
891
1204
|
// Rule 4 (#239, #243): a container reads its env only at start, so an app
|
|
892
1205
|
// that was already there is restarted, once, when its database URLs or its
|
|
893
1206
|
// declared appEnv changed.
|
|
@@ -980,7 +1293,7 @@ export class FoundryStepExecutor {
|
|
|
980
1293
|
// Handles the database being provisioned after the app already exists
|
|
981
1294
|
// (`coolifyApp`'s own push handles the more common order, database
|
|
982
1295
|
// before app on the same apply -- `diff.ts`'s `computePlanDetail`).
|
|
983
|
-
const appId = context.recordedIds()[`coolify:app:${
|
|
1296
|
+
const appId = context.recordedIds()[`coolify:app:${appStepKey(org)}`];
|
|
984
1297
|
if (appId) {
|
|
985
1298
|
const push = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
|
|
986
1299
|
if (push.pushed) {
|
|
@@ -1061,7 +1374,7 @@ export class FoundryStepExecutor {
|
|
|
1061
1374
|
* P2, `.plans/2026-09-26-level-1-org-map.md`) -- a second `bulkUpsertEnvs`
|
|
1062
1375
|
* call rather than folding into `pushDatabaseUrl`'s, since this one runs
|
|
1063
1376
|
* unconditionally (`coolifyApp`) where that one runs only once
|
|
1064
|
-
* `
|
|
1377
|
+
* the `app` entry's `database` is declared and provisioned.
|
|
1065
1378
|
*
|
|
1066
1379
|
* `APP_URL` and `LOG_DIR` (#75) are pushed unconditionally; every other
|
|
1067
1380
|
* value is pushed only once its own source step has actually recorded it
|
|
@@ -1074,10 +1387,10 @@ export class FoundryStepExecutor {
|
|
|
1074
1387
|
* is its own design question, left out of #204's own "How" rather than
|
|
1075
1388
|
* guessed) is out of scope here (task brief). `DATABASE_URL_OWNER`/
|
|
1076
1389
|
* `DATABASE_URL` are pushed by `pushDatabaseUrl` instead, not here --
|
|
1077
|
-
* that call runs only once `
|
|
1390
|
+
* that call runs only once the `app` entry's `database` is declared and
|
|
1078
1391
|
* provisioned, where this one is unconditional (#239).
|
|
1079
1392
|
*/
|
|
1080
|
-
async pushAppEnv(appId, org, context) {
|
|
1393
|
+
async pushAppEnv(appId, org, entry, context) {
|
|
1081
1394
|
const recorded = context.recordedIds();
|
|
1082
1395
|
const envs = [
|
|
1083
1396
|
{ key: 'APP_URL', value: `https://${hostnameFor(org)}` },
|
|
@@ -1112,7 +1425,7 @@ export class FoundryStepExecutor {
|
|
|
1112
1425
|
// minted here; a source with no value yet (its own step has not run,
|
|
1113
1426
|
// or has not run for this apply) is simply omitted, the same way every
|
|
1114
1427
|
// other optional entry above is.
|
|
1115
|
-
const appEnv = resolveAppEnv(org, recorded).values;
|
|
1428
|
+
const appEnv = resolveAppEnv(org, entry, recorded).values;
|
|
1116
1429
|
for (const [key, value] of Object.entries(appEnv))
|
|
1117
1430
|
envs.push({ key, value });
|
|
1118
1431
|
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
@@ -1742,6 +2055,14 @@ export class FoundryStepExecutor {
|
|
|
1742
2055
|
});
|
|
1743
2056
|
}
|
|
1744
2057
|
context.recordScopeId(CALLS_SYNAPSE_SCOPE_KEY, synapse.appId);
|
|
2058
|
+
// #251: only on create -- nothing here patches `docker_registry_image_tag`
|
|
2059
|
+
// on an app `findOrCreateCallsApp` found already running, so that is the
|
|
2060
|
+
// only moment this tag is actually live. Recording it every run, the way
|
|
2061
|
+
// the app id above is, would make `versionNotAppliedLine` compare a
|
|
2062
|
+
// bumped `VERSIONS.synapse` against a value never actually applied.
|
|
2063
|
+
if (!synapse.existed) {
|
|
2064
|
+
context.recordScopeId(CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, SYNAPSE_TAG);
|
|
2065
|
+
}
|
|
1745
2066
|
const mas = await this.findOrCreateCallsApp(names.mas, environment.id, () => this.ports.coolify.createDockerfileApplication({
|
|
1746
2067
|
project_uuid: projectUuid,
|
|
1747
2068
|
server_uuid: server.uuid,
|
|
@@ -1759,6 +2080,10 @@ export class FoundryStepExecutor {
|
|
|
1759
2080
|
});
|
|
1760
2081
|
}
|
|
1761
2082
|
context.recordScopeId(CALLS_MAS_SCOPE_KEY, mas.appId);
|
|
2083
|
+
// #251: only on create -- see the matching comment on synapse above.
|
|
2084
|
+
if (!mas.existed) {
|
|
2085
|
+
context.recordScopeId(CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, MAS_TAG);
|
|
2086
|
+
}
|
|
1762
2087
|
// Synapse's own signing key, media store and pid file (#75's own
|
|
1763
2088
|
// durable-volume convention, reused here for a different reason: this
|
|
1764
2089
|
// volume is Synapse's own state, not a log).
|
|
@@ -2208,8 +2533,20 @@ export class FoundryStepExecutor {
|
|
|
2208
2533
|
return { resourceId: tunnelId };
|
|
2209
2534
|
}
|
|
2210
2535
|
const tunnelName = `${org.slug}-tunnel`;
|
|
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.
|
|
2542
|
+
const recordedTunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
|
|
2211
2543
|
const existing = await this.ports.cloudflare.findTunnelByName(tunnelName);
|
|
2212
2544
|
const tunnel = existing ?? (await this.ports.cloudflare.createTunnel(tunnelName));
|
|
2545
|
+
const previousIds = parseTunnelPreviousIds(context.recordedIds());
|
|
2546
|
+
if (recordedTunnelId && recordedTunnelId !== tunnel.id) {
|
|
2547
|
+
previousIds.add(recordedTunnelId);
|
|
2548
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_PREVIOUS_IDS_SCOPE_KEY, tunnelPreviousIdsState(previousIds));
|
|
2549
|
+
}
|
|
2213
2550
|
// Routes each of the org's hostnames (`tunnelIngressHostnames`) to
|
|
2214
2551
|
// Coolify's own Traefik reverse proxy, not a per-app container address --
|
|
2215
2552
|
// `COOLIFY_PROXY_ORIGIN`, the same constant name and value
|
|
@@ -2257,8 +2594,296 @@ export class FoundryStepExecutor {
|
|
|
2257
2594
|
await context.persistScopeIds();
|
|
2258
2595
|
},
|
|
2259
2596
|
});
|
|
2597
|
+
// #236: an org with no domain (a level-1 org whose app still serves off
|
|
2598
|
+
// the shared `*.oikos.host` wildcard tunnel, `scripts/shared-ingress.mjs`)
|
|
2599
|
+
// has no zone of its own and nothing here to publish -- `org.domain` is
|
|
2600
|
+
// set whenever `plan/diff.ts`'s `planTunnel` actually plans this step,
|
|
2601
|
+
// but this executor is also exercised directly against an action it
|
|
2602
|
+
// would never plan, so the check stays explicit rather than assumed.
|
|
2603
|
+
//
|
|
2604
|
+
// Round 3 finding: publish the new domain *before* cleaning up the old
|
|
2605
|
+
// one. `previousDomain`/`previousHostnames`/`previousTunnelId` are
|
|
2606
|
+
// captured here, before either scope id is recorded below, because
|
|
2607
|
+
// `context.recordedIds()` is a live view of the same map this method
|
|
2608
|
+
// writes to (`StepExecutionContext.recordedIds`'s own doc comment) --
|
|
2609
|
+
// reading any of them any later would already see the new values. The
|
|
2610
|
+
// old order (delete, then publish) could take a domain change's
|
|
2611
|
+
// hostnames dark: if the new zone was missing or foreign-conflicted,
|
|
2612
|
+
// `publishTunnelDnsRecords` threw *after* the old CNAMEs were already
|
|
2613
|
+
// gone and nothing had replaced them. Publish throwing now leaves the
|
|
2614
|
+
// old domain's records and its recorded scope ids untouched, so a
|
|
2615
|
+
// conflict there is a no-op here rather than an outage.
|
|
2616
|
+
//
|
|
2617
|
+
// Round 4 finding 3: the new domain/hostnames are recorded here, by this
|
|
2618
|
+
// method, only *after* `deleteStaleTunnelDnsRecords` has succeeded --
|
|
2619
|
+
// never inside `publishTunnelDnsRecords` itself. The old order recorded
|
|
2620
|
+
// the new domain as soon as publish returned; `runSteps`'s own catch
|
|
2621
|
+
// block persists whatever was recorded so far even when the step then
|
|
2622
|
+
// throws, so a cleanup failure right after a successful publish left the
|
|
2623
|
+
// new domain durably recorded. A retry then read `previousDomain` as
|
|
2624
|
+
// already equal to `org.domain` and skipped cleanup forever, orphaning
|
|
2625
|
+
// the old domain's records. Recording after cleanup means a cleanup
|
|
2626
|
+
// failure leaves the *old* domain recorded, so a retry sees the same
|
|
2627
|
+
// domain change again and tries cleanup again.
|
|
2628
|
+
if (org.domain) {
|
|
2629
|
+
const recordedBefore = context.recordedIds();
|
|
2630
|
+
const previousDomain = recordedBefore[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY];
|
|
2631
|
+
const previousHostnamesRaw = recordedBefore[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
|
|
2632
|
+
const previousHostnames = previousHostnamesRaw ? previousHostnamesRaw.split(',') : [];
|
|
2633
|
+
const domainChanged = previousDomain !== undefined && previousDomain !== org.domain;
|
|
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,
|
|
2639
|
+
// Round 4 finding 2: a hostname this domain itself dropped (no domain
|
|
2640
|
+
// change) is cleaned up inline by `publishTunnelDnsRecords`, which
|
|
2641
|
+
// needs the domain's own previous hostname list for that. A domain
|
|
2642
|
+
// change instead cleans the *whole* old domain below, so it gets an
|
|
2643
|
+
// empty list here rather than a second, redundant pass over it.
|
|
2644
|
+
domainChanged ? [] : previousHostnames, context);
|
|
2645
|
+
if (domainChanged) {
|
|
2646
|
+
await this.deleteStaleTunnelDnsRecords(tunnel.id, previousIds, previousDomain, previousHostnames, context);
|
|
2647
|
+
}
|
|
2648
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY, org.domain);
|
|
2649
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY, publishedHostnames.join(','));
|
|
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
|
+
}
|
|
2671
|
+
}
|
|
2260
2672
|
return { resourceId: tunnel.id };
|
|
2261
2673
|
}
|
|
2674
|
+
/**
|
|
2675
|
+
* #236: when this tunnel's DNS was last published under a domain that
|
|
2676
|
+
* differs from the one `org` now declares, deletes every CNAME in the
|
|
2677
|
+
* *old* domain's zone that points at this tunnel, so a domain switch
|
|
2678
|
+
* never leaves the old domain still answering for it. Takes the previous
|
|
2679
|
+
* domain and hostnames as plain arguments rather than reading them off
|
|
2680
|
+
* `context` itself (round 3): `cloudflareTunnel` reads the old values
|
|
2681
|
+
* before calling either this or `publishTunnelDnsRecords`, and (round 4
|
|
2682
|
+
* finding 3) does not record the new domain/hostnames until *after* this
|
|
2683
|
+
* method has returned, so a crash here still sees the old domain on retry.
|
|
2684
|
+
* A no-op when the old zone itself is already gone.
|
|
2685
|
+
*
|
|
2686
|
+
* `previousHostnames` (round 3) is {@link CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY}'s
|
|
2687
|
+
* recorded list, looked up one by one instead of a zone-wide
|
|
2688
|
+
* `listDnsRecords` -- the real adapter's version of that call asks for one
|
|
2689
|
+
* page and never walks the rest. A domain change recorded before that key
|
|
2690
|
+
* existed falls back to the old zone-wide pass, the one case where no
|
|
2691
|
+
* hostname list was ever written to look up instead.
|
|
2692
|
+
*
|
|
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.
|
|
2714
|
+
*/
|
|
2715
|
+
async deleteStaleTunnelDnsRecords(tunnelId, previousIds, previousDomain, previousHostnames, context) {
|
|
2716
|
+
const zone = await this.ports.cloudflare.findZoneByApex(previousDomain);
|
|
2717
|
+
if (!zone)
|
|
2718
|
+
return;
|
|
2719
|
+
const dnsTargets = new Set([`${tunnelId}.cfargotunnel.com`]);
|
|
2720
|
+
for (const id of previousIds) {
|
|
2721
|
+
if (id !== tunnelId)
|
|
2722
|
+
dnsTargets.add(`${id}.cfargotunnel.com`);
|
|
2723
|
+
}
|
|
2724
|
+
const cf = this.ports.cloudflare;
|
|
2725
|
+
const records = previousHostnames.length > 0
|
|
2726
|
+
? (await Promise.all(previousHostnames.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
|
|
2727
|
+
: await cf.listDnsRecords(zone.id, { type: 'CNAME' });
|
|
2728
|
+
for (const record of records) {
|
|
2729
|
+
if (dnsTargets.has(record.content)) {
|
|
2730
|
+
await cf.deleteDnsRecord(zone.id, record.id);
|
|
2731
|
+
}
|
|
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
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
/**
|
|
2742
|
+
* #236: upserts a proxied CNAME to this tunnel for each of
|
|
2743
|
+
* `tunnelIngressHostnames(org)` that actually belongs to `domain`'s own
|
|
2744
|
+
* zone -- a hosting app still served off the shared `*.oikos.host`
|
|
2745
|
+
* wildcard tunnel (`scripts/shared-ingress.mjs`) can appear in that list
|
|
2746
|
+
* alongside `agora.<domain>` (#164's unconditional Agora hostname) when
|
|
2747
|
+
* `hosting.usesDomain` is off, and `<slug>.oikos.host` is not this org's
|
|
2748
|
+
* zone to touch.
|
|
2749
|
+
*
|
|
2750
|
+
* Checks every hostname for a conflicting record *before* writing any
|
|
2751
|
+
* (round 2 finding: a conflict found only partway through a hostname-by-
|
|
2752
|
+
* hostname pass left earlier hostnames written but the domain never
|
|
2753
|
+
* recorded -- an orphaned partial write). An existing A or AAAA record,
|
|
2754
|
+
* or a CNAME already pointed anywhere but this tunnel or (round 3) its own
|
|
2755
|
+
* immediately prior tunnel id, blocks the whole step with a named
|
|
2756
|
+
* conflict rather than being adopted or replaced, the same do-not-adopt
|
|
2757
|
+
* policy {@link cloudflareZone} itself follows for the zone. A CNAME
|
|
2758
|
+
* already pointed at this tunnel is left alone, so a second apply changes
|
|
2759
|
+
* nothing. The caller ({@link cloudflareTunnel}) records the domain and the
|
|
2760
|
+
* returned hostname list as scope ids only once this method -- and, on a
|
|
2761
|
+
* domain change, the old domain's own cleanup -- has returned successfully
|
|
2762
|
+
* (round 4 finding 3), so a crash mid-write is recovered by re-running
|
|
2763
|
+
* this same idempotent step, never silently skipped.
|
|
2764
|
+
*
|
|
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.
|
|
2774
|
+
*
|
|
2775
|
+
* Round 4 finding 2: `previousHostnames` is this domain's own previously
|
|
2776
|
+
* recorded hostname list (empty when the domain just changed --
|
|
2777
|
+
* {@link cloudflareTunnel} hands the *whole* old domain to
|
|
2778
|
+
* {@link deleteStaleTunnelDnsRecords} for that case instead). A name in it
|
|
2779
|
+
* that the org's current config no longer produces is dropped from
|
|
2780
|
+
* `hostnames` below but was never cleaned up before this round: its CNAME
|
|
2781
|
+
* stayed live and its name stayed in the recorded list forever, because
|
|
2782
|
+
* only a domain *change* ever ran cleanup. Each dropped name's CNAME is
|
|
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.
|
|
2787
|
+
*
|
|
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.
|
|
2796
|
+
*/
|
|
2797
|
+
async publishTunnelDnsRecords(org, domain, tunnelId, previousIds, previousHostnames, context) {
|
|
2798
|
+
const cf = this.ports.cloudflare;
|
|
2799
|
+
const zone = await cf.findZoneByApex(domain);
|
|
2800
|
+
if (!zone) {
|
|
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`);
|
|
2802
|
+
}
|
|
2803
|
+
const dnsTarget = `${tunnelId}.cfargotunnel.com`;
|
|
2804
|
+
const previousTargets = new Set([...previousIds].filter((id) => id !== tunnelId).map((id) => `${id}.cfargotunnel.com`));
|
|
2805
|
+
const hostnames = tunnelIngressHostnames(org).filter((hostname) => hostname === domain || hostname.endsWith(`.${domain}`));
|
|
2806
|
+
const droppedHostnames = previousHostnames.filter((hostname) => !hostnames.includes(hostname));
|
|
2807
|
+
const conflicts = [];
|
|
2808
|
+
const toCreate = [];
|
|
2809
|
+
const toRepoint = [];
|
|
2810
|
+
for (const hostname of hostnames) {
|
|
2811
|
+
const [a] = await cf.findDnsRecord(zone.id, hostname, 'A');
|
|
2812
|
+
const [aaaa] = await cf.findDnsRecord(zone.id, hostname, 'AAAA');
|
|
2813
|
+
const foreign = a ?? aaaa;
|
|
2814
|
+
if (foreign) {
|
|
2815
|
+
conflicts.push(`${hostname} already has a ${foreign.type} record`);
|
|
2816
|
+
continue;
|
|
2817
|
+
}
|
|
2818
|
+
const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
|
|
2819
|
+
if (cname && cname.content !== dnsTarget) {
|
|
2820
|
+
if (previousTargets.has(cname.content)) {
|
|
2821
|
+
toRepoint.push({ hostname, recordId: cname.id });
|
|
2822
|
+
continue;
|
|
2823
|
+
}
|
|
2824
|
+
conflicts.push(`${hostname} is already a CNAME to ${cname.content}`);
|
|
2825
|
+
continue;
|
|
2826
|
+
}
|
|
2827
|
+
if (!cname)
|
|
2828
|
+
toCreate.push(hostname);
|
|
2829
|
+
}
|
|
2830
|
+
if (conflicts.length > 0) {
|
|
2831
|
+
throw new Error(`cloudflare:tunnel for ${org.slug}: will not point its tunnel at every hostname -- ${conflicts.join('; ')}; remove the conflicting record first`);
|
|
2832
|
+
}
|
|
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.
|
|
2841
|
+
const settled = hostnames.filter((hostname) => !toCreate.includes(hostname) && !toRepoint.some((r) => r.hostname === hostname));
|
|
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));
|
|
2846
|
+
};
|
|
2847
|
+
for (const hostname of settled)
|
|
2848
|
+
recordTracked(hostname);
|
|
2849
|
+
for (const hostname of toCreate) {
|
|
2850
|
+
await cf.createDnsRecord(zone.id, {
|
|
2851
|
+
type: 'CNAME',
|
|
2852
|
+
name: hostname,
|
|
2853
|
+
content: dnsTarget,
|
|
2854
|
+
proxied: true,
|
|
2855
|
+
});
|
|
2856
|
+
recordTracked(hostname);
|
|
2857
|
+
}
|
|
2858
|
+
for (const { hostname, recordId } of toRepoint) {
|
|
2859
|
+
await cf.updateDnsRecord(zone.id, recordId, {
|
|
2860
|
+
type: 'CNAME',
|
|
2861
|
+
name: hostname,
|
|
2862
|
+
content: dnsTarget,
|
|
2863
|
+
proxied: true,
|
|
2864
|
+
});
|
|
2865
|
+
recordTracked(hostname);
|
|
2866
|
+
}
|
|
2867
|
+
// Round 4 finding 2: clean up a hostname this domain itself dropped.
|
|
2868
|
+
// Not a conflict check -- there is nothing to create or repoint here,
|
|
2869
|
+
// only a possible stale CNAME of this tunnel's own to remove. Never in
|
|
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.
|
|
2877
|
+
for (const hostname of droppedHostnames) {
|
|
2878
|
+
const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
|
|
2879
|
+
if (cname && (cname.content === dnsTarget || previousTargets.has(cname.content))) {
|
|
2880
|
+
await cf.deleteDnsRecord(zone.id, cname.id);
|
|
2881
|
+
}
|
|
2882
|
+
tracked.delete(trackedTunnelDnsEntry(domain, hostname));
|
|
2883
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY, trackedTunnelDnsState(tracked));
|
|
2884
|
+
}
|
|
2885
|
+
return hostnames;
|
|
2886
|
+
}
|
|
2262
2887
|
/**
|
|
2263
2888
|
* `plan/diff.ts`'s `planZone`: finds or creates the Cloudflare zone for the
|
|
2264
2889
|
* org's own domain. Refuses to adopt a zone the store already records
|
|
@@ -2654,6 +3279,42 @@ export class FoundryStepExecutor {
|
|
|
2654
3279
|
const { org, recordedIds } = context;
|
|
2655
3280
|
try {
|
|
2656
3281
|
switch (`${target.system}:${target.kind}`) {
|
|
3282
|
+
case 'zitadel:login-host': {
|
|
3283
|
+
// #274: `target.id` is the host itself (`zitadelLoginHost`'s own
|
|
3284
|
+
// `resourceId: host`), the same shape `plan-apply.ts`'s
|
|
3285
|
+
// `destroyServiceIdKey` and `ORDERED_PREFIXES` rely on. Each
|
|
3286
|
+
// removal catches its own not-found separately -- the domain
|
|
3287
|
+
// already gone must not skip the binding delete that follows it,
|
|
3288
|
+
// the same reason `cloudflare:tunnel` below handles its connector
|
|
3289
|
+
// app's 404 locally rather than leaning on this method's own
|
|
3290
|
+
// outer catch.
|
|
3291
|
+
const bindings = this.ports.loginHostBindings;
|
|
3292
|
+
// #274 fix round 2: any host `zitadelLoginHost` had already moved
|
|
3293
|
+
// away from but could not yet remove (some live app's login base
|
|
3294
|
+
// URI still pointed at it) is named only in
|
|
3295
|
+
// `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY` -- nothing else ever runs
|
|
3296
|
+
// for this org again to finish removing it once destroy deletes
|
|
3297
|
+
// the ZITADEL project and org below, so it has to go here too.
|
|
3298
|
+
for (const host of [target.id, ...pendingRemovalHosts(recordedIds)]) {
|
|
3299
|
+
try {
|
|
3300
|
+
await this.ports.zitadel.deleteTrustedDomain(host);
|
|
3301
|
+
}
|
|
3302
|
+
catch (error) {
|
|
3303
|
+
if (!isNotFoundError(error))
|
|
3304
|
+
throw error;
|
|
3305
|
+
}
|
|
3306
|
+
if (bindings) {
|
|
3307
|
+
try {
|
|
3308
|
+
await bindings.deleteBinding(host);
|
|
3309
|
+
}
|
|
3310
|
+
catch (error) {
|
|
3311
|
+
if (!isNotFoundError(error))
|
|
3312
|
+
throw error;
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
return;
|
|
3317
|
+
}
|
|
2657
3318
|
case 'cloudflare:tunnel': {
|
|
2658
3319
|
// #235: the connector app first, its own 404 handled locally --
|
|
2659
3320
|
// a retry where this already succeeded must still reach
|
|
@@ -2669,6 +3330,75 @@ export class FoundryStepExecutor {
|
|
|
2669
3330
|
throw error;
|
|
2670
3331
|
}
|
|
2671
3332
|
}
|
|
3333
|
+
// #236: every CNAME `publishTunnelDnsRecords` created for this
|
|
3334
|
+
// org's tunnel, in the zone it was *last* published under -- the
|
|
3335
|
+
// recorded domain, not whatever `org.domain` says now, since a
|
|
3336
|
+
// domain change (or the org being deleted entirely) can leave
|
|
3337
|
+
// them disagreeing. Round 3: looks each recorded hostname up by
|
|
3338
|
+
// name (`CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY`) rather than
|
|
3339
|
+
// listing every CNAME in the zone -- the real adapter's
|
|
3340
|
+
// `listDnsRecords` asks for one page of 100 and never walks the
|
|
3341
|
+
// rest, so a zone with more records than that silently lost
|
|
3342
|
+
// everything past the first page. A hostname a later config
|
|
3343
|
+
// change dropped is still in that recorded list, so it still gets
|
|
3344
|
+
// cleaned up. A record from before that key existed falls back to
|
|
3345
|
+
// the old zone-wide list, the one case with nothing recorded to
|
|
3346
|
+
// look up instead. 404 handled locally, same reason as the
|
|
3347
|
+
// connector above.
|
|
3348
|
+
//
|
|
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);
|
|
3359
|
+
const dnsTargets = new Set([`${target.id}.cfargotunnel.com`]);
|
|
3360
|
+
for (const id of previousTunnelIds) {
|
|
3361
|
+
if (id !== target.id)
|
|
3362
|
+
dnsTargets.add(`${id}.cfargotunnel.com`);
|
|
3363
|
+
}
|
|
3364
|
+
// Round 6 finding 1: every domain the shared tracked set still
|
|
3365
|
+
// names, folded together with the authoritative domain/hostnames
|
|
3366
|
+
// pair (an org that predates that set has only the latter). A
|
|
3367
|
+
// domain the tracked set names but the authoritative pair does
|
|
3368
|
+
// not is exactly the case this round exists for -- a hostname
|
|
3369
|
+
// published under a domain the org has since moved away from
|
|
3370
|
+
// twice over, never caught by any single "previous domain" field.
|
|
3371
|
+
const hostnamesByDomain = trackedTunnelDnsByDomain(parseTrackedTunnelDns(recordedIds));
|
|
3372
|
+
const authoritativeDomain = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY];
|
|
3373
|
+
if (authoritativeDomain) {
|
|
3374
|
+
const authoritativeHostnamesRaw = recordedIds[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
|
|
3375
|
+
const merged = new Set(hostnamesByDomain.get(authoritativeDomain) ?? []);
|
|
3376
|
+
if (authoritativeHostnamesRaw) {
|
|
3377
|
+
for (const hostname of authoritativeHostnamesRaw.split(','))
|
|
3378
|
+
merged.add(hostname);
|
|
3379
|
+
}
|
|
3380
|
+
hostnamesByDomain.set(authoritativeDomain, [...merged]);
|
|
3381
|
+
}
|
|
3382
|
+
for (const [dnsDomain, dnsHostnames] of hostnamesByDomain) {
|
|
3383
|
+
try {
|
|
3384
|
+
const zone = await this.ports.cloudflare.findZoneByApex(dnsDomain);
|
|
3385
|
+
if (zone) {
|
|
3386
|
+
const cf = this.ports.cloudflare;
|
|
3387
|
+
const records = dnsHostnames.length > 0
|
|
3388
|
+
? (await Promise.all(dnsHostnames.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
|
|
3389
|
+
: await cf.listDnsRecords(zone.id, { type: 'CNAME' });
|
|
3390
|
+
for (const record of records) {
|
|
3391
|
+
if (dnsTargets.has(record.content)) {
|
|
3392
|
+
await cf.deleteDnsRecord(zone.id, record.id);
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
catch (error) {
|
|
3398
|
+
if (!isNotFoundError(error))
|
|
3399
|
+
throw error;
|
|
3400
|
+
}
|
|
3401
|
+
}
|
|
2672
3402
|
await this.ports.cloudflare.deleteTunnel(target.id);
|
|
2673
3403
|
return;
|
|
2674
3404
|
}
|