@wtfalch/foundry 0.7.1 → 0.8.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/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 +1 -2
- package/dist/index.js +4 -1
- 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-dir.d.ts +9 -0
- package/dist/migrations-dir.js +10 -0
- 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 +76 -14
- package/dist/plan/executor.d.ts +305 -16
- package/dist/plan/executor.js +606 -9
- 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 +20 -1
- package/dist/plan/platform-diff.d.ts +79 -1
- package/dist/plan/platform-diff.js +197 -1
- package/dist/plan/platform-providers.d.ts +36 -1
- package/dist/plan/platform-providers.js +153 -21
- package/dist/plan.d.ts +43 -0
- package/dist/plan.js +16 -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 +13 -8
- 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
|
@@ -2,11 +2,32 @@ 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
4
|
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';
|
|
5
|
+
import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
6
6
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
7
|
-
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, } from './login-host.js';
|
|
7
|
+
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
8
8
|
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';
|
|
9
|
+
import { APP_ENV_APPLIED_SCOPE_KEY, CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, DB_ENV_APPLIED_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, resolveAppEnv, } from './platform-diff.js';
|
|
10
|
+
/**
|
|
11
|
+
* #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
|
|
12
|
+
* listApps` -- a plain array, with no `loginBaseUri` or completeness signal
|
|
13
|
+
* -- into {@link ZitadelPort.listApps}'s own shape, so a host wiring that
|
|
14
|
+
* real client in satisfies this port without reaching into `executor.ts`
|
|
15
|
+
* itself to build the wrapper by hand. `complete: false` on every call:
|
|
16
|
+
* the real client's own `_search` request has no pagination on it yet
|
|
17
|
+
* (https://github.com/wtfalch/providers/issues/29), so this adapter cannot
|
|
18
|
+
* promise it read more than one page. `loginBaseUri` comes back `undefined`
|
|
19
|
+
* for every app for the identical reason -- the real client does not read
|
|
20
|
+
* it back at all yet, not "read it back as absent" -- which is exactly the
|
|
21
|
+
* state `zitadelLoginHost`'s fail-closed rule exists for: every pending
|
|
22
|
+
* host stays pending until the tracked change above lands and this adapter
|
|
23
|
+
* (or its replacement) can start reporting `string | null` for real.
|
|
24
|
+
*/
|
|
25
|
+
export function adaptZitadelListApps(client) {
|
|
26
|
+
return async (orgId, projectId) => ({
|
|
27
|
+
apps: await client.listApps(orgId, projectId),
|
|
28
|
+
complete: false,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
10
31
|
// ---------------------------------------------------------------------------
|
|
11
32
|
// Errors
|
|
12
33
|
// ---------------------------------------------------------------------------
|
|
@@ -37,6 +58,17 @@ export class NotBuiltError extends Error {
|
|
|
37
58
|
function hostnameFor(org) {
|
|
38
59
|
return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
|
|
39
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* #274 fix round 3 point 4: `zitadelLoginHost`'s own comparison between a
|
|
63
|
+
* live app's ZITADEL-reported login base URI and the one this step itself
|
|
64
|
+
* constructs (`https://<host>/`) -- lowercased and with its trailing `/`
|
|
65
|
+
* (there is always exactly one, a URI's path component, never more) trimmed,
|
|
66
|
+
* so a host is never read as "not in use" purely because ZITADEL's own
|
|
67
|
+
* casing or slashing differs from this step's.
|
|
68
|
+
*/
|
|
69
|
+
function normalizedLoginBaseUri(uri) {
|
|
70
|
+
return uri.toLowerCase().replace(/\/$/, '');
|
|
71
|
+
}
|
|
40
72
|
/**
|
|
41
73
|
* `cms.<domain>` -- the cms studio's own hostname (`cmsSite` below), apart
|
|
42
74
|
* from the public site itself at `org.domain` (used directly there). Called
|
|
@@ -278,6 +310,64 @@ const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
|
|
|
278
310
|
* (`platform-providers.ts`), never a shared tunnel with appended ingress.
|
|
279
311
|
*/
|
|
280
312
|
const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_app';
|
|
313
|
+
/**
|
|
314
|
+
* #236: the domain `publishTunnelDnsRecords` last published this org's
|
|
315
|
+
* tunnel's hostnames under -- compared against the org's current `domain`
|
|
316
|
+
* so `plan/diff.ts`'s `planTunnel` can tell a stale publish (never done at
|
|
317
|
+
* all, or the org's domain has since changed) from a steady state with
|
|
318
|
+
* nothing left to do, and so `destroy` and `deleteStaleTunnelDnsRecords`
|
|
319
|
+
* know which zone to clean up even once the org's declared domain has moved
|
|
320
|
+
* on. `diff.ts` keeps its own copy of this exact string, the same
|
|
321
|
+
* duplication {@link CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY} above
|
|
322
|
+
* already follows.
|
|
323
|
+
*/
|
|
324
|
+
const CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY = 'cloudflare_tunnel_dns_domain';
|
|
325
|
+
/**
|
|
326
|
+
* #236 round 3: every hostname {@link FoundryStepExecutor.publishTunnelDnsRecords}
|
|
327
|
+
* last wrote a CNAME for, joined by `,` (hostnames carry no `,`, the same
|
|
328
|
+
* join {@link MAIL_ROLE_MAILBOXES_SCOPE_KEY} already relies on) -- so
|
|
329
|
+
* cleanup (`destroy`'s `cloudflare:tunnel` case and
|
|
330
|
+
* {@link FoundryStepExecutor.deleteStaleTunnelDnsRecords}) can look each one
|
|
331
|
+
* up by name instead of listing the zone. The real adapter's
|
|
332
|
+
* `listDnsRecords` (`@wtfalch/providers`' `cloudflare.ts`) asks for one page
|
|
333
|
+
* of 100 and never walks `result_info.total_pages`, so a zone with more
|
|
334
|
+
* records than that silently lost everything past the first page. A record
|
|
335
|
+
* from before this key existed has no entry here yet: cleanup falls back to
|
|
336
|
+
* the old `listDnsRecords` zone-wide pass for that one run, and the next
|
|
337
|
+
* successful publish leaves this key set so later cleanups never need to.
|
|
338
|
+
*/
|
|
339
|
+
const CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_dns_hostnames';
|
|
340
|
+
/**
|
|
341
|
+
* #236 round 5 finding 1: the domain, and the hostnames under it,
|
|
342
|
+
* {@link FoundryStepExecutor.publishTunnelDnsRecords} is in the middle of
|
|
343
|
+
* writing CNAMEs for -- recorded after *each* individual CNAME write
|
|
344
|
+
* succeeds, well before the step as a whole returns and
|
|
345
|
+
* {@link CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY}/
|
|
346
|
+
* {@link CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY} themselves are updated
|
|
347
|
+
* (which, on a domain change, waits for the *old* domain's cleanup to
|
|
348
|
+
* succeed too -- round 4 finding 3). Without this, a `createDnsRecord`
|
|
349
|
+
* failure partway through a hostname list, or an old-domain cleanup failure
|
|
350
|
+
* right after a successful publish, left an already-created CNAME out of
|
|
351
|
+
* every recorded list, so `destroy` had no way to find and remove it.
|
|
352
|
+
* `destroy` checks this pair in addition to the authoritative one above.
|
|
353
|
+
* Never read by `cloudflareTunnel` itself to decide anything -- it is
|
|
354
|
+
* destroy's (and a retried apply's own idempotent re-check's) safety net,
|
|
355
|
+
* not a second source of truth to branch planning on.
|
|
356
|
+
*/
|
|
357
|
+
const CLOUDFLARE_TUNNEL_DNS_PENDING_DOMAIN_SCOPE_KEY = 'cloudflare_tunnel_dns_pending_domain';
|
|
358
|
+
const CLOUDFLARE_TUNNEL_DNS_PENDING_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_dns_pending_hostnames';
|
|
359
|
+
/**
|
|
360
|
+
* #236 round 5 finding 2: the tunnel id this step most recently replaced
|
|
361
|
+
* with a new one -- a recreate, `findTunnelByName` returning a different id
|
|
362
|
+
* for the same name -- recorded unconditionally, even on an apply where
|
|
363
|
+
* `org.domain` is unset and the DNS-publish block below never runs. A CNAME
|
|
364
|
+
* published while a domain *was* declared can still be live under that old
|
|
365
|
+
* id after the domain is later cleared, so `destroy` needs this even then.
|
|
366
|
+
* `publishTunnelDnsRecords`'s own repoint logic already accepts either the
|
|
367
|
+
* current tunnel id or this one when matching a CNAME as this org's own
|
|
368
|
+
* rather than a foreign conflict (round 3); `destroy` now does the same.
|
|
369
|
+
*/
|
|
370
|
+
const CLOUDFLARE_TUNNEL_PREVIOUS_ID_SCOPE_KEY = 'cloudflare_tunnel_previous_id';
|
|
281
371
|
/**
|
|
282
372
|
* #162: the owner's own mailbox, created once inside `coolify:mail` after
|
|
283
373
|
* `applyOrgMail` succeeds, alongside {@link MAIL_DOMAIN_SCOPE_KEY} -- another
|
|
@@ -599,9 +689,70 @@ export class FoundryStepExecutor {
|
|
|
599
689
|
* domains, writes zitadel-login's binding, then repoints the apps whose
|
|
600
690
|
* client id that binding holds. Apps created later are not in it yet:
|
|
601
691
|
* the next run replans (`diff.ts`'s `planLoginHost`) and moves them.
|
|
692
|
+
*
|
|
693
|
+
* #274: when the org's host has moved (`recorded[key]`, the *previous*
|
|
694
|
+
* run's resourceId, differs from this run's `host`), also removes every
|
|
695
|
+
* host that is no longer the org's current one from both places --
|
|
696
|
+
* `ensureTrustedDomain` and `upsertBinding` above only ever add, so
|
|
697
|
+
* without this an old host stays trusted and bound forever, and a later
|
|
698
|
+
* org that reuses the old slug inherits it unasked.
|
|
699
|
+
*
|
|
700
|
+
* #274 fix round 2: this always returns `host` -- the *current* desired
|
|
701
|
+
* host -- as its `resourceId`, never an old one, however many of its
|
|
702
|
+
* removals are still outstanding. Fix round 1 returned `previousHost`
|
|
703
|
+
* while a removal was skipped, banking on `planLoginHost` replanning the
|
|
704
|
+
* whole step off that mismatch alone; that loses track of the *new* host
|
|
705
|
+
* the moment a second host change moves `previousHost` on again (A -> B
|
|
706
|
+
* skipped, then B -> C: A's own removal has nothing left recording it was
|
|
707
|
+
* ever owed). `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY`
|
|
708
|
+
* (`pendingRemovalHosts`/`pendingRemovalState`, `login-host.ts`) is the
|
|
709
|
+
* durable record instead -- every host still awaiting removal, independent
|
|
710
|
+
* of which one is current -- and `planLoginHost` replans the step whenever
|
|
711
|
+
* it is non-empty, not only when the current host itself differs.
|
|
712
|
+
*
|
|
713
|
+
* Whether a host is still "in use" is decided from each live app's actual
|
|
714
|
+
* ZITADEL Login V2 base URI (`ZitadelPort.listApps`' `loginBaseUri`), not
|
|
715
|
+
* from `boundAppIds` membership: fix round 1 skipped removal only while an
|
|
716
|
+
* app outside `boundAppIds` existed, but `boundAppIds` is deliberately
|
|
717
|
+
* narrower than "every live app" (it excludes `native`/`api` apps and any
|
|
718
|
+
* `web` app without a recorded client id yet) and a host can safely be
|
|
719
|
+
* removed even while such an app exists, so long as nothing is actually
|
|
720
|
+
* pointed at it. Removing by the wrong signal either breaks sign-in (an
|
|
721
|
+
* app repointed is not the same fact as an app with no client id yet) or
|
|
722
|
+
* strands a host forever (an org with a `native`/`api` app would never
|
|
723
|
+
* satisfy "every live app bound").
|
|
724
|
+
*
|
|
725
|
+
* #274 fix round 3, four more defects fix round 2 left:
|
|
726
|
+
*
|
|
727
|
+
* 1. The *current* host is excluded from the candidates for removal.
|
|
728
|
+
* Without this, A -> B (A pending, not yet safe) -> A deletes the host
|
|
729
|
+
* this very call just (re-)trusted and bound: a host already in
|
|
730
|
+
* `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY` from an earlier run can be
|
|
731
|
+
* exactly the one the org's host is moving back to.
|
|
732
|
+
* 2. Fail closed on missing information, rather than ever reading
|
|
733
|
+
* "nothing uses it" off of one: an app whose `loginBaseUri` cannot be
|
|
734
|
+
* read at all (`undefined` -- which, until a real adapter is wired per
|
|
735
|
+
* `ZitadelPort.listApps`'s own doc comment, is every app, every call)
|
|
736
|
+
* means every pending host stays pending this run, not just the ones a
|
|
737
|
+
* narrower check happened to miss.
|
|
738
|
+
* 3. Same fail-closed rule for `listApps`' own `complete` flag: an
|
|
739
|
+
* incomplete read (today, always, for the identical reason) means the
|
|
740
|
+
* set of apps this call saw could be missing one still pointed at a
|
|
741
|
+
* pending host, so nothing is removed off of it either.
|
|
742
|
+
* 4. A base URI compares after lowercasing and trimming its trailing
|
|
743
|
+
* `/` (`normalizedLoginBaseUri`) -- ZITADEL's own value and the one
|
|
744
|
+
* this step constructs are not guaranteed to agree on case or a
|
|
745
|
+
* trailing slash, and a comparison that is case- or slash-sensitive
|
|
746
|
+
* both ways can report "in use" as "not" by accident.
|
|
747
|
+
* 5. Each live app's base URI is read as of *after* this call's own
|
|
748
|
+
* repoint loop above, not the stale value `listApps` returned before
|
|
749
|
+
* it: a bound app this very call just repointed away from a pending
|
|
750
|
+
* host must not hold that host pending for one more run only because
|
|
751
|
+
* the snapshot taken before the repoint still named it.
|
|
602
752
|
*/
|
|
603
753
|
async zitadelLoginHost(action, org, context) {
|
|
604
754
|
const recorded = context.recordedIds();
|
|
755
|
+
const previousHost = recorded[`zitadel:login-host:${action.key}`] ?? null;
|
|
605
756
|
const zitadelOrgId = recorded.zitadel_org;
|
|
606
757
|
if (!zitadelOrgId) {
|
|
607
758
|
throw new Error(`zitadel:login-host for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
@@ -628,17 +779,93 @@ export class FoundryStepExecutor {
|
|
|
628
779
|
...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
|
|
629
780
|
].map((name) => recorded[`zitadel:app:${name}`]));
|
|
630
781
|
const projectId = recorded.zitadel_project;
|
|
782
|
+
// No project yet means no apps yet: vacuously safe to decide from (fix
|
|
783
|
+
// round 3 point 2/3 apply only once there is something to fail closed
|
|
784
|
+
// on), and nothing to repoint either.
|
|
785
|
+
let liveApps = [];
|
|
786
|
+
let listingComplete = true;
|
|
631
787
|
if (projectId) {
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
788
|
+
const listed = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
|
|
789
|
+
listingComplete = listed.complete;
|
|
790
|
+
liveApps = listed.apps.filter((app) => app.state !== 'APP_STATE_REMOVED');
|
|
791
|
+
for (const app of liveApps) {
|
|
792
|
+
if (!boundAppIds.has(app.id))
|
|
635
793
|
continue;
|
|
636
794
|
await this.ports.zitadel.updateOidcAppLoginBaseUri(zitadelOrgId, projectId, app.id, `https://${host}/`);
|
|
637
795
|
}
|
|
638
796
|
}
|
|
639
|
-
//
|
|
640
|
-
//
|
|
797
|
+
// Fix round 3 point 5: a bound app's current base URI is `host` for
|
|
798
|
+
// certain, just set above, whatever `listApps` read before that --
|
|
799
|
+
// never its stale pre-repoint snapshot. Every other live app's is
|
|
800
|
+
// exactly what `listApps` reported, unmodified by this call. `null`
|
|
801
|
+
// (fix round 4 point 1: read, and confirmed this app has never had one)
|
|
802
|
+
// is deliberately not added -- it names no host, so there is nothing to
|
|
803
|
+
// compare against, same as an app this loop never visits at all.
|
|
804
|
+
const currentLoginBaseUris = new Set();
|
|
805
|
+
for (const app of liveApps) {
|
|
806
|
+
const uri = boundAppIds.has(app.id) ? `https://${host}/` : app.loginBaseUri;
|
|
807
|
+
if (typeof uri === 'string')
|
|
808
|
+
currentLoginBaseUris.add(normalizedLoginBaseUri(uri));
|
|
809
|
+
}
|
|
810
|
+
// Fix round 3 points 2/3, fix round 4 point 1: an unbound app's base
|
|
811
|
+
// URI this call could not read *at all* (`undefined`, never `null` --
|
|
812
|
+
// {@link ZitadelPort.listApps}'s own doc comment has the three-state
|
|
813
|
+
// distinction), or a listing this call cannot be sure is complete,
|
|
814
|
+
// means there is no way to tell whether a pending host is still in use
|
|
815
|
+
// -- fail closed for every pending host this run rather than trust a
|
|
816
|
+
// partial picture. A bound app always has a known-good URI as of just
|
|
817
|
+
// above, so it is never the reason to fail closed; nor is an unbound
|
|
818
|
+
// app `listApps` confirmed has no base URI at all (`null`) -- fail
|
|
819
|
+
// round 3 collapsed that into "unreadable" too, which would have kept
|
|
820
|
+
// a host pending forever against an org with such an app, even once
|
|
821
|
+
// the real client can tell the two apart.
|
|
822
|
+
const canDecideSafely = listingComplete &&
|
|
823
|
+
liveApps.every((app) => boundAppIds.has(app.id) || app.loginBaseUri !== undefined);
|
|
824
|
+
// Every host up for removal this run: whatever was already pending,
|
|
825
|
+
// plus the host this run is moving away from (if any) -- deduplicated,
|
|
826
|
+
// since a host can arrive here twice across two runs that both saw it
|
|
827
|
+
// pending -- and, fix round 3 point 1, never the host this very call
|
|
828
|
+
// just (re-)trusted and bound, however it got into either set.
|
|
829
|
+
const candidates = new Set(pendingRemovalHosts(recorded));
|
|
830
|
+
candidates.delete(host);
|
|
831
|
+
if (previousHost && previousHost !== host)
|
|
832
|
+
candidates.add(previousHost);
|
|
833
|
+
// #274: a removal, once the new host is fully live (trusted, bound, and
|
|
834
|
+
// every repointable app repointed) -- never the other way round, so an
|
|
835
|
+
// org is never left with no trusted host at all if this throws partway
|
|
836
|
+
// through. Each removal catches its own not-found separately (`destroy`'s
|
|
837
|
+
// own `cloudflare:tunnel` case below takes the identical precaution, for
|
|
838
|
+
// its connector app and its tunnel): a domain already gone must not skip
|
|
839
|
+
// the binding delete that follows it.
|
|
840
|
+
const stillPending = [];
|
|
841
|
+
for (const candidate of candidates) {
|
|
842
|
+
if (!canDecideSafely ||
|
|
843
|
+
currentLoginBaseUris.has(normalizedLoginBaseUri(`https://${candidate}/`))) {
|
|
844
|
+
stillPending.push(candidate);
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
try {
|
|
848
|
+
await this.ports.zitadel.deleteTrustedDomain(candidate);
|
|
849
|
+
}
|
|
850
|
+
catch (error) {
|
|
851
|
+
if (!isNotFoundError(error))
|
|
852
|
+
throw error;
|
|
853
|
+
}
|
|
854
|
+
try {
|
|
855
|
+
await bindings.deleteBinding(candidate);
|
|
856
|
+
}
|
|
857
|
+
catch (error) {
|
|
858
|
+
if (!isNotFoundError(error))
|
|
859
|
+
throw error;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
// Both last, so a crash above reruns the whole step rather than losing
|
|
863
|
+
// track of either. A client id recorded after this point differs from
|
|
864
|
+
// the binding and replans the step next run; a nonempty pending list
|
|
865
|
+
// does too (`planLoginHost`), independent of the host comparison that
|
|
866
|
+
// normally alone decides it.
|
|
641
867
|
context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
|
|
868
|
+
context.recordScopeId(LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, pendingRemovalState(stillPending));
|
|
642
869
|
return { resourceId: host };
|
|
643
870
|
}
|
|
644
871
|
/**
|
|
@@ -776,7 +1003,7 @@ export class FoundryStepExecutor {
|
|
|
776
1003
|
* that is not the survivor this is looking for.
|
|
777
1004
|
*/
|
|
778
1005
|
async findRecreatedApp(zitadelOrgId, projectId, name, appType) {
|
|
779
|
-
const apps = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
|
|
1006
|
+
const { apps } = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
|
|
780
1007
|
return apps.find((app) => app.name === name && app.appType === appType && app.state !== 'APP_STATE_REMOVED');
|
|
781
1008
|
}
|
|
782
1009
|
async ensureZitadelProject(org, zitadelOrgId, context) {
|
|
@@ -1742,6 +1969,14 @@ export class FoundryStepExecutor {
|
|
|
1742
1969
|
});
|
|
1743
1970
|
}
|
|
1744
1971
|
context.recordScopeId(CALLS_SYNAPSE_SCOPE_KEY, synapse.appId);
|
|
1972
|
+
// #251: only on create -- nothing here patches `docker_registry_image_tag`
|
|
1973
|
+
// on an app `findOrCreateCallsApp` found already running, so that is the
|
|
1974
|
+
// only moment this tag is actually live. Recording it every run, the way
|
|
1975
|
+
// the app id above is, would make `versionNotAppliedLine` compare a
|
|
1976
|
+
// bumped `VERSIONS.synapse` against a value never actually applied.
|
|
1977
|
+
if (!synapse.existed) {
|
|
1978
|
+
context.recordScopeId(CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, SYNAPSE_TAG);
|
|
1979
|
+
}
|
|
1745
1980
|
const mas = await this.findOrCreateCallsApp(names.mas, environment.id, () => this.ports.coolify.createDockerfileApplication({
|
|
1746
1981
|
project_uuid: projectUuid,
|
|
1747
1982
|
server_uuid: server.uuid,
|
|
@@ -1759,6 +1994,10 @@ export class FoundryStepExecutor {
|
|
|
1759
1994
|
});
|
|
1760
1995
|
}
|
|
1761
1996
|
context.recordScopeId(CALLS_MAS_SCOPE_KEY, mas.appId);
|
|
1997
|
+
// #251: only on create -- see the matching comment on synapse above.
|
|
1998
|
+
if (!mas.existed) {
|
|
1999
|
+
context.recordScopeId(CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, MAS_TAG);
|
|
2000
|
+
}
|
|
1762
2001
|
// Synapse's own signing key, media store and pid file (#75's own
|
|
1763
2002
|
// durable-volume convention, reused here for a different reason: this
|
|
1764
2003
|
// volume is Synapse's own state, not a log).
|
|
@@ -2208,8 +2447,17 @@ export class FoundryStepExecutor {
|
|
|
2208
2447
|
return { resourceId: tunnelId };
|
|
2209
2448
|
}
|
|
2210
2449
|
const tunnelName = `${org.slug}-tunnel`;
|
|
2450
|
+
// #236 round 5 finding 2: read, and (if it is about to change) recorded,
|
|
2451
|
+
// before `createTunnel` can replace it -- unconditionally, not only
|
|
2452
|
+
// inside the `if (org.domain)` block below, so a recreate on an apply
|
|
2453
|
+
// with no domain declared still leaves `destroy` a way to recognise the
|
|
2454
|
+
// old id's CNAMEs as this tunnel's own rather than a foreign record.
|
|
2455
|
+
const recordedTunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
|
|
2211
2456
|
const existing = await this.ports.cloudflare.findTunnelByName(tunnelName);
|
|
2212
2457
|
const tunnel = existing ?? (await this.ports.cloudflare.createTunnel(tunnelName));
|
|
2458
|
+
if (recordedTunnelId && recordedTunnelId !== tunnel.id) {
|
|
2459
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_PREVIOUS_ID_SCOPE_KEY, recordedTunnelId);
|
|
2460
|
+
}
|
|
2213
2461
|
// Routes each of the org's hostnames (`tunnelIngressHostnames`) to
|
|
2214
2462
|
// Coolify's own Traefik reverse proxy, not a per-app container address --
|
|
2215
2463
|
// `COOLIFY_PROXY_ORIGIN`, the same constant name and value
|
|
@@ -2257,8 +2505,252 @@ export class FoundryStepExecutor {
|
|
|
2257
2505
|
await context.persistScopeIds();
|
|
2258
2506
|
},
|
|
2259
2507
|
});
|
|
2508
|
+
// #236: an org with no domain (a level-1 org whose app still serves off
|
|
2509
|
+
// the shared `*.oikos.host` wildcard tunnel, `scripts/shared-ingress.mjs`)
|
|
2510
|
+
// has no zone of its own and nothing here to publish -- `org.domain` is
|
|
2511
|
+
// set whenever `plan/diff.ts`'s `planTunnel` actually plans this step,
|
|
2512
|
+
// but this executor is also exercised directly against an action it
|
|
2513
|
+
// would never plan, so the check stays explicit rather than assumed.
|
|
2514
|
+
//
|
|
2515
|
+
// Round 3 finding: publish the new domain *before* cleaning up the old
|
|
2516
|
+
// one. `previousDomain`/`previousHostnames`/`previousTunnelId` are
|
|
2517
|
+
// captured here, before either scope id is recorded below, because
|
|
2518
|
+
// `context.recordedIds()` is a live view of the same map this method
|
|
2519
|
+
// writes to (`StepExecutionContext.recordedIds`'s own doc comment) --
|
|
2520
|
+
// reading any of them any later would already see the new values. The
|
|
2521
|
+
// old order (delete, then publish) could take a domain change's
|
|
2522
|
+
// hostnames dark: if the new zone was missing or foreign-conflicted,
|
|
2523
|
+
// `publishTunnelDnsRecords` threw *after* the old CNAMEs were already
|
|
2524
|
+
// gone and nothing had replaced them. Publish throwing now leaves the
|
|
2525
|
+
// old domain's records and its recorded scope ids untouched, so a
|
|
2526
|
+
// conflict there is a no-op here rather than an outage.
|
|
2527
|
+
//
|
|
2528
|
+
// Round 4 finding 3: the new domain/hostnames are recorded here, by this
|
|
2529
|
+
// method, only *after* `deleteStaleTunnelDnsRecords` has succeeded --
|
|
2530
|
+
// never inside `publishTunnelDnsRecords` itself. The old order recorded
|
|
2531
|
+
// the new domain as soon as publish returned; `runSteps`'s own catch
|
|
2532
|
+
// block persists whatever was recorded so far even when the step then
|
|
2533
|
+
// throws, so a cleanup failure right after a successful publish left the
|
|
2534
|
+
// new domain durably recorded. A retry then read `previousDomain` as
|
|
2535
|
+
// already equal to `org.domain` and skipped cleanup forever, orphaning
|
|
2536
|
+
// the old domain's records. Recording after cleanup means a cleanup
|
|
2537
|
+
// failure leaves the *old* domain recorded, so a retry sees the same
|
|
2538
|
+
// domain change again and tries cleanup again.
|
|
2539
|
+
if (org.domain) {
|
|
2540
|
+
const recordedBefore = context.recordedIds();
|
|
2541
|
+
const previousDomain = recordedBefore[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY];
|
|
2542
|
+
const previousHostnamesRaw = recordedBefore[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
|
|
2543
|
+
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
|
+
const domainChanged = previousDomain !== undefined && previousDomain !== org.domain;
|
|
2549
|
+
const publishedHostnames = await this.publishTunnelDnsRecords(org, org.domain, tunnel.id, previousTunnelId,
|
|
2550
|
+
// Round 4 finding 2: a hostname this domain itself dropped (no domain
|
|
2551
|
+
// change) is cleaned up inline by `publishTunnelDnsRecords`, which
|
|
2552
|
+
// needs the domain's own previous hostname list for that. A domain
|
|
2553
|
+
// change instead cleans the *whole* old domain below, so it gets an
|
|
2554
|
+
// empty list here rather than a second, redundant pass over it.
|
|
2555
|
+
domainChanged ? [] : previousHostnames, context);
|
|
2556
|
+
if (domainChanged) {
|
|
2557
|
+
await this.deleteStaleTunnelDnsRecords(tunnel.id, previousTunnelId, previousDomain, previousHostnames);
|
|
2558
|
+
}
|
|
2559
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY, org.domain);
|
|
2560
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY, publishedHostnames.join(','));
|
|
2561
|
+
// #236 round 5 finding 1: the authoritative pair above now carries
|
|
2562
|
+
// everything the pending pair was tracking mid-write -- clear it so a
|
|
2563
|
+
// later destroy (or crash-recovery apply) never reads a stale pending
|
|
2564
|
+
// domain that no longer matches reality. A no-op the first time this
|
|
2565
|
+
// step ever runs (both keys already absent).
|
|
2566
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_PENDING_DOMAIN_SCOPE_KEY, '');
|
|
2567
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_PENDING_HOSTNAMES_SCOPE_KEY, '');
|
|
2568
|
+
}
|
|
2260
2569
|
return { resourceId: tunnel.id };
|
|
2261
2570
|
}
|
|
2571
|
+
/**
|
|
2572
|
+
* #236: when this tunnel's DNS was last published under a domain that
|
|
2573
|
+
* differs from the one `org` now declares, deletes every CNAME in the
|
|
2574
|
+
* *old* domain's zone that points at this tunnel, so a domain switch
|
|
2575
|
+
* never leaves the old domain still answering for it. Takes the previous
|
|
2576
|
+
* domain and hostnames as plain arguments rather than reading them off
|
|
2577
|
+
* `context` itself (round 3): `cloudflareTunnel` reads the old values
|
|
2578
|
+
* before calling either this or `publishTunnelDnsRecords`, and (round 4
|
|
2579
|
+
* finding 3) does not record the new domain/hostnames until *after* this
|
|
2580
|
+
* method has returned, so a crash here still sees the old domain on retry.
|
|
2581
|
+
* A no-op when the old zone itself is already gone.
|
|
2582
|
+
*
|
|
2583
|
+
* `previousHostnames` (round 3) is {@link CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY}'s
|
|
2584
|
+
* recorded list, looked up one by one instead of a zone-wide
|
|
2585
|
+
* `listDnsRecords` -- the real adapter's version of that call asks for one
|
|
2586
|
+
* page and never walks the rest. A domain change recorded before that key
|
|
2587
|
+
* existed falls back to the old zone-wide pass, the one case where no
|
|
2588
|
+
* hostname list was ever written to look up instead.
|
|
2589
|
+
*
|
|
2590
|
+
* `previousTunnelId` (round 4 finding 1): the old domain's CNAMEs were
|
|
2591
|
+
* published under whatever tunnel id was live the *last* time this step
|
|
2592
|
+
* recorded that domain, which is `previousTunnelId`, not necessarily
|
|
2593
|
+
* `tunnelId` -- a tunnel recreated under the same name
|
|
2594
|
+
* (`findTunnelByName` returning a new id) in the same apply that also
|
|
2595
|
+
* changes the domain leaves the old domain's records pointed at the old
|
|
2596
|
+
* id. Matching only `tunnelId`'s target left them live forever. Both
|
|
2597
|
+
* targets are checked so this stays a no-op once a retry has already
|
|
2598
|
+
* moved the old domain onto the new id.
|
|
2599
|
+
*/
|
|
2600
|
+
async deleteStaleTunnelDnsRecords(tunnelId, previousTunnelId, previousDomain, previousHostnames) {
|
|
2601
|
+
const zone = await this.ports.cloudflare.findZoneByApex(previousDomain);
|
|
2602
|
+
if (!zone)
|
|
2603
|
+
return;
|
|
2604
|
+
const dnsTargets = new Set([`${tunnelId}.cfargotunnel.com`]);
|
|
2605
|
+
if (previousTunnelId && previousTunnelId !== tunnelId) {
|
|
2606
|
+
dnsTargets.add(`${previousTunnelId}.cfargotunnel.com`);
|
|
2607
|
+
}
|
|
2608
|
+
const cf = this.ports.cloudflare;
|
|
2609
|
+
const records = previousHostnames.length > 0
|
|
2610
|
+
? (await Promise.all(previousHostnames.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
|
|
2611
|
+
: await cf.listDnsRecords(zone.id, { type: 'CNAME' });
|
|
2612
|
+
for (const record of records) {
|
|
2613
|
+
if (dnsTargets.has(record.content)) {
|
|
2614
|
+
await cf.deleteDnsRecord(zone.id, record.id);
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
/**
|
|
2619
|
+
* #236: upserts a proxied CNAME to this tunnel for each of
|
|
2620
|
+
* `tunnelIngressHostnames(org)` that actually belongs to `domain`'s own
|
|
2621
|
+
* zone -- a hosting app still served off the shared `*.oikos.host`
|
|
2622
|
+
* wildcard tunnel (`scripts/shared-ingress.mjs`) can appear in that list
|
|
2623
|
+
* alongside `agora.<domain>` (#164's unconditional Agora hostname) when
|
|
2624
|
+
* `hosting.usesDomain` is off, and `<slug>.oikos.host` is not this org's
|
|
2625
|
+
* zone to touch.
|
|
2626
|
+
*
|
|
2627
|
+
* Checks every hostname for a conflicting record *before* writing any
|
|
2628
|
+
* (round 2 finding: a conflict found only partway through a hostname-by-
|
|
2629
|
+
* hostname pass left earlier hostnames written but the domain never
|
|
2630
|
+
* recorded -- an orphaned partial write). An existing A or AAAA record,
|
|
2631
|
+
* or a CNAME already pointed anywhere but this tunnel or (round 3) its own
|
|
2632
|
+
* immediately prior tunnel id, blocks the whole step with a named
|
|
2633
|
+
* conflict rather than being adopted or replaced, the same do-not-adopt
|
|
2634
|
+
* policy {@link cloudflareZone} itself follows for the zone. A CNAME
|
|
2635
|
+
* already pointed at this tunnel is left alone, so a second apply changes
|
|
2636
|
+
* nothing. The caller ({@link cloudflareTunnel}) records the domain and the
|
|
2637
|
+
* returned hostname list as scope ids only once this method -- and, on a
|
|
2638
|
+
* domain change, the old domain's own cleanup -- has returned successfully
|
|
2639
|
+
* (round 4 finding 3), so a crash mid-write is recovered by re-running
|
|
2640
|
+
* this same idempotent step, never silently skipped.
|
|
2641
|
+
*
|
|
2642
|
+
* Round 3: `previousTunnelId` is the tunnel id this same step recorded
|
|
2643
|
+
* last time it ran (`cloudflareTunnel`'s own `cloudflare:tunnel:<key>`
|
|
2644
|
+
* resource id, read before `tunnel` above is computed for this run) -- a
|
|
2645
|
+
* tunnel Cloudflare shows as recreated under the same name
|
|
2646
|
+
* (`findTunnelByName` returning a new id for `<slug>-tunnel`) leaves every
|
|
2647
|
+
* CNAME still pointed at the *old* id. That is this org's own stale
|
|
2648
|
+
* record, not a foreign one, so it is repointed rather than refused.
|
|
2649
|
+
*
|
|
2650
|
+
* Round 4 finding 2: `previousHostnames` is this domain's own previously
|
|
2651
|
+
* recorded hostname list (empty when the domain just changed --
|
|
2652
|
+
* {@link cloudflareTunnel} hands the *whole* old domain to
|
|
2653
|
+
* {@link deleteStaleTunnelDnsRecords} for that case instead). A name in it
|
|
2654
|
+
* that the org's current config no longer produces is dropped from
|
|
2655
|
+
* `hostnames` below but was never cleaned up before this round: its CNAME
|
|
2656
|
+
* stayed live and its name stayed in the recorded list forever, because
|
|
2657
|
+
* only a domain *change* ever ran cleanup. Each dropped name's CNAME is
|
|
2658
|
+
* deleted here when it still points at this tunnel or its immediately
|
|
2659
|
+
* prior id; a name already gone, or now pointed at something foreign, is
|
|
2660
|
+
* simply left out of the returned list -- nothing here is this method's to
|
|
2661
|
+
* reclaim or report as a conflict.
|
|
2662
|
+
*
|
|
2663
|
+
* Round 5 finding 1: `context` is used only to record
|
|
2664
|
+
* {@link CLOUDFLARE_TUNNEL_DNS_PENDING_DOMAIN_SCOPE_KEY}/
|
|
2665
|
+
* {@link CLOUDFLARE_TUNNEL_DNS_PENDING_HOSTNAMES_SCOPE_KEY} after each
|
|
2666
|
+
* individual CNAME write below succeeds, so a crash or a thrown conflict
|
|
2667
|
+
* partway through never leaves an already-created CNAME out of every
|
|
2668
|
+
* recorded list -- `destroy` checks this pending pair too.
|
|
2669
|
+
*/
|
|
2670
|
+
async publishTunnelDnsRecords(org, domain, tunnelId, previousTunnelId, previousHostnames, context) {
|
|
2671
|
+
const cf = this.ports.cloudflare;
|
|
2672
|
+
const zone = await cf.findZoneByApex(domain);
|
|
2673
|
+
if (!zone) {
|
|
2674
|
+
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
|
+
}
|
|
2676
|
+
const dnsTarget = `${tunnelId}.cfargotunnel.com`;
|
|
2677
|
+
const previousDnsTarget = previousTunnelId && previousTunnelId !== tunnelId
|
|
2678
|
+
? `${previousTunnelId}.cfargotunnel.com`
|
|
2679
|
+
: undefined;
|
|
2680
|
+
const hostnames = tunnelIngressHostnames(org).filter((hostname) => hostname === domain || hostname.endsWith(`.${domain}`));
|
|
2681
|
+
const droppedHostnames = previousHostnames.filter((hostname) => !hostnames.includes(hostname));
|
|
2682
|
+
const conflicts = [];
|
|
2683
|
+
const toCreate = [];
|
|
2684
|
+
const toRepoint = [];
|
|
2685
|
+
for (const hostname of hostnames) {
|
|
2686
|
+
const [a] = await cf.findDnsRecord(zone.id, hostname, 'A');
|
|
2687
|
+
const [aaaa] = await cf.findDnsRecord(zone.id, hostname, 'AAAA');
|
|
2688
|
+
const foreign = a ?? aaaa;
|
|
2689
|
+
if (foreign) {
|
|
2690
|
+
conflicts.push(`${hostname} already has a ${foreign.type} record`);
|
|
2691
|
+
continue;
|
|
2692
|
+
}
|
|
2693
|
+
const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
|
|
2694
|
+
if (cname && cname.content !== dnsTarget) {
|
|
2695
|
+
if (previousDnsTarget && cname.content === previousDnsTarget) {
|
|
2696
|
+
toRepoint.push({ hostname, recordId: cname.id });
|
|
2697
|
+
continue;
|
|
2698
|
+
}
|
|
2699
|
+
conflicts.push(`${hostname} is already a CNAME to ${cname.content}`);
|
|
2700
|
+
continue;
|
|
2701
|
+
}
|
|
2702
|
+
if (!cname)
|
|
2703
|
+
toCreate.push(hostname);
|
|
2704
|
+
}
|
|
2705
|
+
if (conflicts.length > 0) {
|
|
2706
|
+
throw new Error(`cloudflare:tunnel for ${org.slug}: will not point its tunnel at every hostname -- ${conflicts.join('; ')}; remove the conflicting record first`);
|
|
2707
|
+
}
|
|
2708
|
+
// Round 5 finding 1: `settled` tracks every hostname under `domain`
|
|
2709
|
+
// confirmed to have the right CNAME so far -- starting with the ones
|
|
2710
|
+
// that needed no write at all, then one more per successful create or
|
|
2711
|
+
// repoint below -- and is persisted to the pending scope ids after each
|
|
2712
|
+
// addition, well before this method returns. A failure on any later
|
|
2713
|
+
// hostname still leaves everything settled up to that point recorded,
|
|
2714
|
+
// rather than only in this function's own local state.
|
|
2715
|
+
const settled = hostnames.filter((hostname) => !toCreate.includes(hostname) && !toRepoint.some((r) => r.hostname === hostname));
|
|
2716
|
+
const recordPending = () => {
|
|
2717
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_PENDING_DOMAIN_SCOPE_KEY, domain);
|
|
2718
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_PENDING_HOSTNAMES_SCOPE_KEY, settled.join(','));
|
|
2719
|
+
};
|
|
2720
|
+
if (settled.length > 0)
|
|
2721
|
+
recordPending();
|
|
2722
|
+
for (const hostname of toCreate) {
|
|
2723
|
+
await cf.createDnsRecord(zone.id, {
|
|
2724
|
+
type: 'CNAME',
|
|
2725
|
+
name: hostname,
|
|
2726
|
+
content: dnsTarget,
|
|
2727
|
+
proxied: true,
|
|
2728
|
+
});
|
|
2729
|
+
settled.push(hostname);
|
|
2730
|
+
recordPending();
|
|
2731
|
+
}
|
|
2732
|
+
for (const { hostname, recordId } of toRepoint) {
|
|
2733
|
+
await cf.updateDnsRecord(zone.id, recordId, {
|
|
2734
|
+
type: 'CNAME',
|
|
2735
|
+
name: hostname,
|
|
2736
|
+
content: dnsTarget,
|
|
2737
|
+
proxied: true,
|
|
2738
|
+
});
|
|
2739
|
+
settled.push(hostname);
|
|
2740
|
+
recordPending();
|
|
2741
|
+
}
|
|
2742
|
+
// Round 4 finding 2: clean up a hostname this domain itself dropped.
|
|
2743
|
+
// Not a conflict check -- there is nothing to create or repoint here,
|
|
2744
|
+
// only a possible stale CNAME of this tunnel's own to remove. Never in
|
|
2745
|
+
// `hostnames`/`settled` to begin with, so nothing to update there.
|
|
2746
|
+
for (const hostname of droppedHostnames) {
|
|
2747
|
+
const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
|
|
2748
|
+
if (cname && (cname.content === dnsTarget || cname.content === previousDnsTarget)) {
|
|
2749
|
+
await cf.deleteDnsRecord(zone.id, cname.id);
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
return hostnames;
|
|
2753
|
+
}
|
|
2262
2754
|
/**
|
|
2263
2755
|
* `plan/diff.ts`'s `planZone`: finds or creates the Cloudflare zone for the
|
|
2264
2756
|
* org's own domain. Refuses to adopt a zone the store already records
|
|
@@ -2654,6 +3146,42 @@ export class FoundryStepExecutor {
|
|
|
2654
3146
|
const { org, recordedIds } = context;
|
|
2655
3147
|
try {
|
|
2656
3148
|
switch (`${target.system}:${target.kind}`) {
|
|
3149
|
+
case 'zitadel:login-host': {
|
|
3150
|
+
// #274: `target.id` is the host itself (`zitadelLoginHost`'s own
|
|
3151
|
+
// `resourceId: host`), the same shape `plan-apply.ts`'s
|
|
3152
|
+
// `destroyServiceIdKey` and `ORDERED_PREFIXES` rely on. Each
|
|
3153
|
+
// removal catches its own not-found separately -- the domain
|
|
3154
|
+
// already gone must not skip the binding delete that follows it,
|
|
3155
|
+
// the same reason `cloudflare:tunnel` below handles its connector
|
|
3156
|
+
// app's 404 locally rather than leaning on this method's own
|
|
3157
|
+
// outer catch.
|
|
3158
|
+
const bindings = this.ports.loginHostBindings;
|
|
3159
|
+
// #274 fix round 2: any host `zitadelLoginHost` had already moved
|
|
3160
|
+
// away from but could not yet remove (some live app's login base
|
|
3161
|
+
// URI still pointed at it) is named only in
|
|
3162
|
+
// `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY` -- nothing else ever runs
|
|
3163
|
+
// for this org again to finish removing it once destroy deletes
|
|
3164
|
+
// the ZITADEL project and org below, so it has to go here too.
|
|
3165
|
+
for (const host of [target.id, ...pendingRemovalHosts(recordedIds)]) {
|
|
3166
|
+
try {
|
|
3167
|
+
await this.ports.zitadel.deleteTrustedDomain(host);
|
|
3168
|
+
}
|
|
3169
|
+
catch (error) {
|
|
3170
|
+
if (!isNotFoundError(error))
|
|
3171
|
+
throw error;
|
|
3172
|
+
}
|
|
3173
|
+
if (bindings) {
|
|
3174
|
+
try {
|
|
3175
|
+
await bindings.deleteBinding(host);
|
|
3176
|
+
}
|
|
3177
|
+
catch (error) {
|
|
3178
|
+
if (!isNotFoundError(error))
|
|
3179
|
+
throw error;
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
return;
|
|
3184
|
+
}
|
|
2657
3185
|
case 'cloudflare:tunnel': {
|
|
2658
3186
|
// #235: the connector app first, its own 404 handled locally --
|
|
2659
3187
|
// a retry where this already succeeded must still reach
|
|
@@ -2669,6 +3197,75 @@ export class FoundryStepExecutor {
|
|
|
2669
3197
|
throw error;
|
|
2670
3198
|
}
|
|
2671
3199
|
}
|
|
3200
|
+
// #236: every CNAME `publishTunnelDnsRecords` created for this
|
|
3201
|
+
// org's tunnel, in the zone it was *last* published under -- the
|
|
3202
|
+
// recorded domain, not whatever `org.domain` says now, since a
|
|
3203
|
+
// domain change (or the org being deleted entirely) can leave
|
|
3204
|
+
// them disagreeing. Round 3: looks each recorded hostname up by
|
|
3205
|
+
// name (`CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY`) rather than
|
|
3206
|
+
// listing every CNAME in the zone -- the real adapter's
|
|
3207
|
+
// `listDnsRecords` asks for one page of 100 and never walks the
|
|
3208
|
+
// rest, so a zone with more records than that silently lost
|
|
3209
|
+
// everything past the first page. A hostname a later config
|
|
3210
|
+
// change dropped is still in that recorded list, so it still gets
|
|
3211
|
+
// cleaned up. A record from before that key existed falls back to
|
|
3212
|
+
// the old zone-wide list, the one case with nothing recorded to
|
|
3213
|
+
// look up instead. 404 handled locally, same reason as the
|
|
3214
|
+
// connector above.
|
|
3215
|
+
//
|
|
3216
|
+
// Round 5 finding 2: `dnsTargets` accepts this tunnel's current id
|
|
3217
|
+
// *or* {@link CLOUDFLARE_TUNNEL_PREVIOUS_ID_SCOPE_KEY} -- the id a
|
|
3218
|
+
// recreate most recently replaced -- the same two-target match
|
|
3219
|
+
// `publishTunnelDnsRecords`'s own repoint logic already uses, so a
|
|
3220
|
+
// CNAME a domain-less apply's recreate left pointed at the old id
|
|
3221
|
+
// (never repointed, since that apply never ran the DNS-publish
|
|
3222
|
+
// block at all) still gets removed here.
|
|
3223
|
+
//
|
|
3224
|
+
// Round 5 finding 1: `dnsSources` also checks the *pending*
|
|
3225
|
+
// domain/hostnames pair alongside the authoritative one, so a
|
|
3226
|
+
// CNAME `publishTunnelDnsRecords` created but never got to record
|
|
3227
|
+
// in the authoritative pair (a later hostname's write failing, or
|
|
3228
|
+
// an old-domain cleanup failure right after a successful publish)
|
|
3229
|
+
// is still found and removed.
|
|
3230
|
+
const previousTunnelId = recordedIds[CLOUDFLARE_TUNNEL_PREVIOUS_ID_SCOPE_KEY];
|
|
3231
|
+
const dnsTargets = new Set([`${target.id}.cfargotunnel.com`]);
|
|
3232
|
+
if (previousTunnelId && previousTunnelId !== target.id) {
|
|
3233
|
+
dnsTargets.add(`${previousTunnelId}.cfargotunnel.com`);
|
|
3234
|
+
}
|
|
3235
|
+
const dnsSources = [
|
|
3236
|
+
{
|
|
3237
|
+
domain: recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY],
|
|
3238
|
+
hostnamesRaw: recordedIds[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY],
|
|
3239
|
+
},
|
|
3240
|
+
{
|
|
3241
|
+
domain: recordedIds[CLOUDFLARE_TUNNEL_DNS_PENDING_DOMAIN_SCOPE_KEY],
|
|
3242
|
+
hostnamesRaw: recordedIds[CLOUDFLARE_TUNNEL_DNS_PENDING_HOSTNAMES_SCOPE_KEY],
|
|
3243
|
+
},
|
|
3244
|
+
];
|
|
3245
|
+
for (const { domain: dnsDomain, hostnamesRaw: dnsHostnamesRaw } of dnsSources) {
|
|
3246
|
+
if (!dnsDomain)
|
|
3247
|
+
continue;
|
|
3248
|
+
try {
|
|
3249
|
+
const zone = await this.ports.cloudflare.findZoneByApex(dnsDomain);
|
|
3250
|
+
if (zone) {
|
|
3251
|
+
const cf = this.ports.cloudflare;
|
|
3252
|
+
const records = dnsHostnamesRaw
|
|
3253
|
+
? (await Promise.all(dnsHostnamesRaw
|
|
3254
|
+
.split(',')
|
|
3255
|
+
.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
|
|
3256
|
+
: await cf.listDnsRecords(zone.id, { type: 'CNAME' });
|
|
3257
|
+
for (const record of records) {
|
|
3258
|
+
if (dnsTargets.has(record.content)) {
|
|
3259
|
+
await cf.deleteDnsRecord(zone.id, record.id);
|
|
3260
|
+
}
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
catch (error) {
|
|
3265
|
+
if (!isNotFoundError(error))
|
|
3266
|
+
throw error;
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
2672
3269
|
await this.ports.cloudflare.deleteTunnel(target.id);
|
|
2673
3270
|
return;
|
|
2674
3271
|
}
|