@wtfalch/foundry 0.6.0 → 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/declare.d.ts +3 -1
- package/dist/bootstrap/declare.js +6 -2
- package/dist/bootstrap/versions.d.ts +25 -0
- package/dist/bootstrap/versions.js +43 -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 +8 -6
- package/dist/calls-sfu.js +9 -6
- package/dist/index.d.ts +3 -4
- package/dist/index.js +5 -2
- package/dist/migrations/0022_chat_enabled.sql +6 -0
- package/dist/migrations/0023_hosting_app_env.sql +8 -0
- package/dist/migrations/0024_audit_events.sql +211 -0
- package/dist/migrations/0025_audit_display.sql +147 -0
- package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
- package/dist/migrations/0027_audit_chain.sql +170 -0
- package/dist/migrations/0028_audit_rls.sql +111 -0
- package/dist/migrations-dir.d.ts +9 -0
- package/dist/migrations-dir.js +10 -0
- package/dist/org.d.ts +24 -0
- package/dist/org.js +13 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +23 -0
- package/dist/plan/calls-matrix.d.ts +4 -2
- package/dist/plan/calls-matrix.js +5 -2
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +5 -3
- package/dist/plan/diff.d.ts +18 -3
- package/dist/plan/diff.js +369 -42
- package/dist/plan/executor.d.ts +378 -44
- package/dist/plan/executor.js +816 -57
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/login-host.d.ts +88 -0
- package/dist/plan/login-host.js +73 -0
- package/dist/plan/plan-apply.d.ts +23 -8
- package/dist/plan/plan-apply.js +30 -10
- package/dist/plan/platform-diff.d.ts +102 -2
- package/dist/plan/platform-diff.js +232 -1
- package/dist/plan/platform-providers.d.ts +37 -2
- package/dist/plan/platform-providers.js +223 -36
- package/dist/plan/providers-readers.js +44 -1
- package/dist/plan.d.ts +43 -0
- package/dist/plan.js +16 -1
- package/dist/store/audit.d.ts +36 -4
- package/dist/store/audit.js +110 -29
- package/dist/store/postgres.d.ts +14 -0
- package/dist/store/postgres.js +17 -9
- package/dist/store/schema.d.ts +37 -1
- package/dist/store/schema.js +7 -0
- package/package.json +10 -5
- package/versions.json +8 -0
- package/dist/migrate.d.ts +0 -38
- package/dist/migrate.js +0 -47
package/dist/plan/executor.js
CHANGED
|
@@ -1,10 +1,33 @@
|
|
|
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 { AGORA_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';
|
|
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, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
6
|
+
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
7
|
+
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
6
8
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
7
|
-
import { ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, } 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
|
+
}
|
|
8
31
|
// ---------------------------------------------------------------------------
|
|
9
32
|
// Errors
|
|
10
33
|
// ---------------------------------------------------------------------------
|
|
@@ -35,6 +58,17 @@ export class NotBuiltError extends Error {
|
|
|
35
58
|
function hostnameFor(org) {
|
|
36
59
|
return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
|
|
37
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
|
+
}
|
|
38
72
|
/**
|
|
39
73
|
* `cms.<domain>` -- the cms studio's own hostname (`cmsSite` below), apart
|
|
40
74
|
* from the public site itself at `org.domain` (used directly there). Called
|
|
@@ -228,27 +262,6 @@ const AGORA_APP_DOCKERFILE = 'apps/agora/Dockerfile';
|
|
|
228
262
|
* restated rather than shared across the two files.
|
|
229
263
|
*/
|
|
230
264
|
const LOG_DIR = '/app/logs';
|
|
231
|
-
/**
|
|
232
|
-
* The recorded OIDC app's `clientId` (level-1 app-env campaign, gap P2) —
|
|
233
|
-
* not a `system:kind:key` resource id (that slot already holds the app's
|
|
234
|
-
* `appId`, `zitadelApp`'s own `resourceId`) and not a secret (`deliverSecret`
|
|
235
|
-
* is for that), so a flat scope id like `zitadel_org`/`zitadel_project`
|
|
236
|
-
* above. A level-1 org declares exactly one web `issuerApps` entry for its
|
|
237
|
-
* one standard app, so one flat key is enough; an org with more than one web
|
|
238
|
-
* app would need a richer key, not built here (Finding, this campaign's
|
|
239
|
-
* report).
|
|
240
|
-
*/
|
|
241
|
-
const APP_CLIENT_ID_SCOPE_KEY = 'zitadel_app_client_id';
|
|
242
|
-
/**
|
|
243
|
-
* #139: the `cms` app's own ZITADEL web client's `clientId`, recorded
|
|
244
|
-
* separately from {@link APP_CLIENT_ID_SCOPE_KEY} -- that key belongs to the
|
|
245
|
-
* org's own hosted app (`diff.ts`'s `planIssuerApps`), a different
|
|
246
|
-
* `zitadel:app` step from the fixed `cms` client `diff.ts`'s `planCmsClient`
|
|
247
|
-
* plans. `cmsSite` reads this key to push `AUTH_CLIENT_ID` onto the cms app,
|
|
248
|
-
* the same way `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` for the hosted
|
|
249
|
-
* app.
|
|
250
|
-
*/
|
|
251
|
-
const CMS_CLIENT_ID_SCOPE_KEY = 'cms_client_id';
|
|
252
265
|
/**
|
|
253
266
|
* `cmsSite` provisions two applications from one `cms:site` step, but
|
|
254
267
|
* `execute`'s own return value auto-records only one `resourceId`, under
|
|
@@ -290,6 +303,71 @@ const CMS_DATABASE_SCOPE_KEY = 'coolify_cms_database';
|
|
|
290
303
|
* `resend_mail_domain`.
|
|
291
304
|
*/
|
|
292
305
|
const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
|
|
306
|
+
/**
|
|
307
|
+
* #235: the Coolify app running `cloudflared` for this org's own tunnel --
|
|
308
|
+
* one connector per org tunnel (Decided, William, 2026-10-01), the same
|
|
309
|
+
* `ensureCloudflaredConnector` every platform tunnel step already calls
|
|
310
|
+
* (`platform-providers.ts`), never a shared tunnel with appended ingress.
|
|
311
|
+
*/
|
|
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';
|
|
293
371
|
/**
|
|
294
372
|
* #162: the owner's own mailbox, created once inside `coolify:mail` after
|
|
295
373
|
* `applyOrgMail` succeeds, alongside {@link MAIL_DOMAIN_SCOPE_KEY} -- another
|
|
@@ -434,6 +512,8 @@ export class FoundryStepExecutor {
|
|
|
434
512
|
return this.zitadelIssuer(action, org, context);
|
|
435
513
|
case 'zitadel:owner':
|
|
436
514
|
return this.zitadelOwner(action, org, context);
|
|
515
|
+
case 'zitadel:login-host':
|
|
516
|
+
return this.zitadelLoginHost(action, org, context);
|
|
437
517
|
case 'zitadel:app':
|
|
438
518
|
return this.zitadelApp(action, org, context);
|
|
439
519
|
case 'zitadel:customers':
|
|
@@ -603,6 +683,191 @@ export class FoundryStepExecutor {
|
|
|
603
683
|
});
|
|
604
684
|
return { resourceId: userId };
|
|
605
685
|
}
|
|
686
|
+
/**
|
|
687
|
+
* `plan/diff.ts`'s `planLoginHost` (#267): adds the org's own company
|
|
688
|
+
* login host (`action.desired.host`) to the ZITADEL instance's trusted
|
|
689
|
+
* domains, writes zitadel-login's binding, then repoints the apps whose
|
|
690
|
+
* client id that binding holds. Apps created later are not in it yet:
|
|
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.
|
|
752
|
+
*/
|
|
753
|
+
async zitadelLoginHost(action, org, context) {
|
|
754
|
+
const recorded = context.recordedIds();
|
|
755
|
+
const previousHost = recorded[`zitadel:login-host:${action.key}`] ?? null;
|
|
756
|
+
const zitadelOrgId = recorded.zitadel_org;
|
|
757
|
+
if (!zitadelOrgId) {
|
|
758
|
+
throw new Error(`zitadel:login-host for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
759
|
+
}
|
|
760
|
+
// `planLoginHost` blocks the step while this port is not wired.
|
|
761
|
+
const bindings = this.ports.loginHostBindings;
|
|
762
|
+
if (!bindings) {
|
|
763
|
+
throw new Error(`zitadel:login-host for ${org.slug}: the loginHostBindings port is not wired`);
|
|
764
|
+
}
|
|
765
|
+
const host = action.desired.host;
|
|
766
|
+
await this.ports.zitadel.ensureTrustedDomain(host);
|
|
767
|
+
// zitadel-login's own routing table, written before any app is repointed
|
|
768
|
+
// so no app ever points at a host with no binding.
|
|
769
|
+
const clientIds = loginHostClientIds(recorded);
|
|
770
|
+
await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
|
|
771
|
+
// Only apps whose client id the binding now holds: any other app in the
|
|
772
|
+
// project would lose sign-in. The rest move on a later run, once the
|
|
773
|
+
// recorded client ids differ from the binding and replan this step.
|
|
774
|
+
const boundAppIds = new Set([
|
|
775
|
+
...(recorded[APP_CLIENT_ID_SCOPE_KEY]
|
|
776
|
+
? org.hosting.issuerApps.filter((a) => a.kind === 'web').map((a) => a.name)
|
|
777
|
+
: []),
|
|
778
|
+
...(recorded[CMS_CLIENT_ID_SCOPE_KEY] ? ['cms'] : []),
|
|
779
|
+
...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
|
|
780
|
+
].map((name) => recorded[`zitadel:app:${name}`]));
|
|
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;
|
|
787
|
+
if (projectId) {
|
|
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))
|
|
793
|
+
continue;
|
|
794
|
+
await this.ports.zitadel.updateOidcAppLoginBaseUri(zitadelOrgId, projectId, app.id, `https://${host}/`);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
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.
|
|
867
|
+
context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
|
|
868
|
+
context.recordScopeId(LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, pendingRemovalState(stillPending));
|
|
869
|
+
return { resourceId: host };
|
|
870
|
+
}
|
|
606
871
|
/**
|
|
607
872
|
* `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
|
|
608
873
|
* in through a second ZITADEL org, `<slug> customers`, with
|
|
@@ -738,7 +1003,7 @@ export class FoundryStepExecutor {
|
|
|
738
1003
|
* that is not the survivor this is looking for.
|
|
739
1004
|
*/
|
|
740
1005
|
async findRecreatedApp(zitadelOrgId, projectId, name, appType) {
|
|
741
|
-
const apps = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
|
|
1006
|
+
const { apps } = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
|
|
742
1007
|
return apps.find((app) => app.name === name && app.appType === appType && app.state !== 'APP_STATE_REMOVED');
|
|
743
1008
|
}
|
|
744
1009
|
async ensureZitadelProject(org, zitadelOrgId, context) {
|
|
@@ -827,11 +1092,17 @@ export class FoundryStepExecutor {
|
|
|
827
1092
|
// resource id is already recorded by the time this runs -- but this also
|
|
828
1093
|
// covers the database being turned on after the app already exists (the
|
|
829
1094
|
// app step still ran this apply, found the app by name, and lands here).
|
|
1095
|
+
let dbPush = { pushed: false, changed: false };
|
|
830
1096
|
if (org.hosting.database) {
|
|
831
1097
|
const dbId = context.recordedIds()[`coolify:database:${action.key}`];
|
|
832
|
-
if (dbId)
|
|
833
|
-
await this.pushDatabaseUrl(appId, dbId);
|
|
1098
|
+
if (dbId) {
|
|
1099
|
+
dbPush = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
|
|
1100
|
+
}
|
|
834
1101
|
}
|
|
1102
|
+
// Restart when the push changed something, or when no earlier run got as
|
|
1103
|
+
// far as restarting after one (marker missing): a crash between the push
|
|
1104
|
+
// and the restart leaves the env right and the container stale.
|
|
1105
|
+
const dbNeedsRestart = dbPush.pushed && (dbPush.changed || !context.recordedIds()[DB_ENV_APPLIED_SCOPE_KEY]);
|
|
835
1106
|
// Every app keeps its own logs across a redeploy (#75). Unconditional,
|
|
836
1107
|
// like the env push below: found by mount path before create, so a
|
|
837
1108
|
// re-run (or the drift step `diff.ts`'s `planApp` plans for an app that
|
|
@@ -843,7 +1114,17 @@ export class FoundryStepExecutor {
|
|
|
843
1114
|
// The app receives whatever it needs to sign in (gap P2). Unconditional,
|
|
844
1115
|
// unlike the database push above: every app has a hostname, and an
|
|
845
1116
|
// upsert of the same value on a later apply is a no-op, not a change.
|
|
846
|
-
await this.pushAppEnv(appId, org, context);
|
|
1117
|
+
const appEnv = await this.pushAppEnv(appId, org, context);
|
|
1118
|
+
// Rule 4 (#239, #243): a container reads its env only at start, so an app
|
|
1119
|
+
// that was already there is restarted, once, when its database URLs or its
|
|
1120
|
+
// declared appEnv changed.
|
|
1121
|
+
if ((appEnv.changed || dbNeedsRestart) && existing)
|
|
1122
|
+
await this.ports.coolify.restart(appId);
|
|
1123
|
+
// Recorded only after the restart succeeded: a failed restart leaves the
|
|
1124
|
+
// old value, so the next plan replans (rule 3). The db marker is not a URL.
|
|
1125
|
+
context.recordScopeId(APP_ENV_APPLIED_SCOPE_KEY, appEnv.applied);
|
|
1126
|
+
if (dbPush.pushed)
|
|
1127
|
+
context.recordScopeId(DB_ENV_APPLIED_SCOPE_KEY, '1');
|
|
847
1128
|
// #140 rework (2026-09-28 lettermint-org-mail swap): the hosted app's own
|
|
848
1129
|
// Lettermint project, domain restriction and `MAIL_FROM` (only the app
|
|
849
1130
|
// started by this same step, per the design's "only apps that send mail
|
|
@@ -927,39 +1208,80 @@ export class FoundryStepExecutor {
|
|
|
927
1208
|
// (`coolifyApp`'s own push handles the more common order, database
|
|
928
1209
|
// before app on the same apply -- `diff.ts`'s `computePlanDetail`).
|
|
929
1210
|
const appId = context.recordedIds()[`coolify:app:${action.key}`];
|
|
930
|
-
if (appId)
|
|
931
|
-
await this.pushDatabaseUrl(appId, dbId);
|
|
1211
|
+
if (appId) {
|
|
1212
|
+
const push = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
|
|
1213
|
+
if (push.pushed) {
|
|
1214
|
+
if (push.changed || !context.recordedIds()[DB_ENV_APPLIED_SCOPE_KEY]) {
|
|
1215
|
+
await this.ports.coolify.restart(appId);
|
|
1216
|
+
}
|
|
1217
|
+
context.recordScopeId(DB_ENV_APPLIED_SCOPE_KEY, '1');
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
932
1220
|
return { resourceId: dbId };
|
|
933
1221
|
}
|
|
934
1222
|
/**
|
|
935
|
-
* Pushes the database's connection string into the app's environment
|
|
936
|
-
*
|
|
937
|
-
*
|
|
938
|
-
*
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
* `db-role-guard.sh` then refuses to boot, since `DATABASE_URL_OWNER` being
|
|
944
|
-
* set means it expects `DATABASE_URL` to connect as a distinct,
|
|
945
|
-
* non-superuser runtime role (`<database>_rt`), which nothing here creates.
|
|
946
|
-
* Minting that role's own password and URL is a provisioning step of its
|
|
947
|
-
* own, not built here (Finding, this campaign's report).
|
|
1223
|
+
* Pushes the database's connection string into the app's environment,
|
|
1224
|
+
* split the same way `cmsSite`'s own database is (#117, #239): the
|
|
1225
|
+
* superuser connection as `DATABASE_URL_OWNER`, and a distinct, restricted
|
|
1226
|
+
* `<dbName>_rt` role's connection as `DATABASE_URL`. Archon's and manage's
|
|
1227
|
+
* own boot scripts (`db-role-guard.sh`) refuse to start against a
|
|
1228
|
+
* superuser `DATABASE_URL`, and lokessmie's refuses to start without
|
|
1229
|
+
* `DATABASE_URL_OWNER` at all -- a bare superuser `DATABASE_URL` with no
|
|
1230
|
+
* `DATABASE_URL_OWNER` is "before the split" there too.
|
|
948
1231
|
*
|
|
949
|
-
*
|
|
950
|
-
* (`postgres://user:password@host/db`):
|
|
951
|
-
*
|
|
952
|
-
* nowhere else in this class -- never returned, never
|
|
953
|
-
* `serviceIds` (hence never in the store or an audit record),
|
|
954
|
-
* never interpolated into a thrown error.
|
|
1232
|
+
* Both connection strings are live credentials
|
|
1233
|
+
* (`postgres://user:password@host/db`): read from `CoolifyPort.getDatabase`
|
|
1234
|
+
* (or minted here, for the runtime one) and handed straight to
|
|
1235
|
+
* `bulkUpsertEnvs` and nowhere else in this class -- never returned, never
|
|
1236
|
+
* recorded in `serviceIds` (hence never in the store or an audit record),
|
|
1237
|
+
* never logged, never interpolated into a thrown error.
|
|
955
1238
|
*/
|
|
956
|
-
async pushDatabaseUrl(appId, dbId) {
|
|
1239
|
+
async pushDatabaseUrl(appId, dbId, dbName) {
|
|
957
1240
|
const db = await this.ports.coolify.getDatabase(dbId);
|
|
1241
|
+
// Nothing to push; `planApp` reports this as blocked from the live reader.
|
|
958
1242
|
if (!db.internal_db_url)
|
|
959
|
-
return;
|
|
960
|
-
|
|
961
|
-
{ key: '
|
|
962
|
-
]
|
|
1243
|
+
return { pushed: false, changed: false };
|
|
1244
|
+
const envs = [
|
|
1245
|
+
{ key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
|
|
1246
|
+
];
|
|
1247
|
+
const runtimeUrl = await this.ensureAppRuntimeUrl(appId, db.internal_db_url, dbName);
|
|
1248
|
+
if (runtimeUrl)
|
|
1249
|
+
envs.push({ key: 'DATABASE_URL', value: runtimeUrl });
|
|
1250
|
+
// True when a value actually changed, so the caller restarts the app only
|
|
1251
|
+
// then (the owner URL is upserted on every apply).
|
|
1252
|
+
const ownerBefore = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL_OWNER' && !e.is_preview)?.value;
|
|
1253
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
1254
|
+
return { pushed: true, changed: runtimeUrl !== null || ownerBefore !== db.internal_db_url };
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* `null` once the app already has a `DATABASE_URL` -- never rotated, the
|
|
1258
|
+
* same rule `ensureCmsRuntimeUrl` follows and for the same reason: the
|
|
1259
|
+
* app's own boot script creates or updates the live `<dbName>_rt` Postgres
|
|
1260
|
+
* role from exactly the username/password this method mints, once:
|
|
1261
|
+
* overwriting `DATABASE_URL` on a later apply would leave that
|
|
1262
|
+
* already-created role's real password out of sync with whatever foundry
|
|
1263
|
+
* hands the app next. See `ensureCmsRuntimeUrl`'s own doc comment for the
|
|
1264
|
+
* parsing and encoding choices this mirrors exactly.
|
|
1265
|
+
*/
|
|
1266
|
+
async ensureAppRuntimeUrl(appId, dbInternalUrl, dbName) {
|
|
1267
|
+
const envs = await this.ports.coolify.listEnvs(appId);
|
|
1268
|
+
// An existing `DATABASE_URL` that is still the superuser string is what an
|
|
1269
|
+
// app got before the split (#239): treat it as absent so the runtime role
|
|
1270
|
+
// is minted and replaces it, or the app's db-role-guard refuses to boot.
|
|
1271
|
+
const already = envs.some((e) => e.key === 'DATABASE_URL' && !e.is_preview && e.value !== dbInternalUrl);
|
|
1272
|
+
if (already)
|
|
1273
|
+
return null;
|
|
1274
|
+
let host;
|
|
1275
|
+
let pathname;
|
|
1276
|
+
let search;
|
|
1277
|
+
try {
|
|
1278
|
+
({ host, pathname, search } = new URL(dbInternalUrl));
|
|
1279
|
+
}
|
|
1280
|
+
catch {
|
|
1281
|
+
return null;
|
|
1282
|
+
}
|
|
1283
|
+
const password = randomBytes(24).toString('base64url');
|
|
1284
|
+
return `postgres://${dbName}_rt:${password}@${host}${pathname}${search}`;
|
|
963
1285
|
}
|
|
964
1286
|
/**
|
|
965
1287
|
* Pushes what the org's app needs to sign a user in through ZITADEL (gap
|
|
@@ -977,8 +1299,10 @@ export class FoundryStepExecutor {
|
|
|
977
1299
|
* (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
|
|
978
1300
|
* reason. `AUTH_APP_KEY` (#206 -- which broker an org's app registers with
|
|
979
1301
|
* is its own design question, left out of #204's own "How" rather than
|
|
980
|
-
* guessed)
|
|
981
|
-
* `
|
|
1302
|
+
* guessed) is out of scope here (task brief). `DATABASE_URL_OWNER`/
|
|
1303
|
+
* `DATABASE_URL` are pushed by `pushDatabaseUrl` instead, not here --
|
|
1304
|
+
* that call runs only once `hosting.database` is declared and
|
|
1305
|
+
* provisioned, where this one is unconditional (#239).
|
|
982
1306
|
*/
|
|
983
1307
|
async pushAppEnv(appId, org, context) {
|
|
984
1308
|
const recorded = context.recordedIds();
|
|
@@ -1009,7 +1333,21 @@ export class FoundryStepExecutor {
|
|
|
1009
1333
|
const cookieSecret = await this.ensureAuthCookieSecret(appId);
|
|
1010
1334
|
if (cookieSecret)
|
|
1011
1335
|
envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
|
|
1336
|
+
// #243: the app's own declared map from its env var names to values the
|
|
1337
|
+
// plan already mints for this org (Files, AI, mail) -- each bespoke app
|
|
1338
|
+
// names these differently (lokessmie uses STORAGE_*/AI_*). Nothing is
|
|
1339
|
+
// minted here; a source with no value yet (its own step has not run,
|
|
1340
|
+
// or has not run for this apply) is simply omitted, the same way every
|
|
1341
|
+
// other optional entry above is.
|
|
1342
|
+
const appEnv = resolveAppEnv(org, recorded).values;
|
|
1343
|
+
for (const [key, value] of Object.entries(appEnv))
|
|
1344
|
+
envs.push({ key, value });
|
|
1012
1345
|
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
1346
|
+
// `coolifyApp` records `applied` once the restart succeeded, so `planApp`
|
|
1347
|
+
// replans this step when the declared map changes (rule 3). Ids and a
|
|
1348
|
+
// domain, never a secret.
|
|
1349
|
+
const applied = JSON.stringify(appEnv);
|
|
1350
|
+
return { changed: applied !== (recorded[APP_ENV_APPLIED_SCOPE_KEY] ?? '{}'), applied };
|
|
1013
1351
|
}
|
|
1014
1352
|
/**
|
|
1015
1353
|
* `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
|
|
@@ -1631,6 +1969,14 @@ export class FoundryStepExecutor {
|
|
|
1631
1969
|
});
|
|
1632
1970
|
}
|
|
1633
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
|
+
}
|
|
1634
1980
|
const mas = await this.findOrCreateCallsApp(names.mas, environment.id, () => this.ports.coolify.createDockerfileApplication({
|
|
1635
1981
|
project_uuid: projectUuid,
|
|
1636
1982
|
server_uuid: server.uuid,
|
|
@@ -1648,6 +1994,10 @@ export class FoundryStepExecutor {
|
|
|
1648
1994
|
});
|
|
1649
1995
|
}
|
|
1650
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
|
+
}
|
|
1651
2001
|
// Synapse's own signing key, media store and pid file (#75's own
|
|
1652
2002
|
// durable-volume convention, reused here for a different reason: this
|
|
1653
2003
|
// volume is Synapse's own state, not a log).
|
|
@@ -2081,11 +2431,33 @@ export class FoundryStepExecutor {
|
|
|
2081
2431
|
await this.ports.cloudflare.putTunnelConfig(tunnelId, {
|
|
2082
2432
|
ingress: [{ service: 'http_status:404' }],
|
|
2083
2433
|
});
|
|
2434
|
+
// #235: park stops the connector too -- an ingress with nothing
|
|
2435
|
+
// routed is still a running container dialed into the tunnel.
|
|
2436
|
+
const connectorAppId = context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
|
|
2437
|
+
if (connectorAppId) {
|
|
2438
|
+
try {
|
|
2439
|
+
await this.ports.coolify.raw('POST', `/applications/${connectorAppId}/stop`);
|
|
2440
|
+
}
|
|
2441
|
+
catch (error) {
|
|
2442
|
+
// Already gone (destroyed out of band): nothing left to stop.
|
|
2443
|
+
if (!isNotFoundError(error))
|
|
2444
|
+
throw error;
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2084
2447
|
return { resourceId: tunnelId };
|
|
2085
2448
|
}
|
|
2086
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}`];
|
|
2087
2456
|
const existing = await this.ports.cloudflare.findTunnelByName(tunnelName);
|
|
2088
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
|
+
}
|
|
2089
2461
|
// Routes each of the org's hostnames (`tunnelIngressHostnames`) to
|
|
2090
2462
|
// Coolify's own Traefik reverse proxy, not a per-app container address --
|
|
2091
2463
|
// `COOLIFY_PROXY_ORIGIN`, the same constant name and value
|
|
@@ -2110,8 +2482,275 @@ export class FoundryStepExecutor {
|
|
|
2110
2482
|
await this.ports.cloudflare.putTunnelConfig(tunnel.id, {
|
|
2111
2483
|
ingress: [...ingress, { service: 'http_status:404' }],
|
|
2112
2484
|
});
|
|
2485
|
+
// #235: a connector for this org's own tunnel, the same call every
|
|
2486
|
+
// platform tunnel step already makes (Decided, William, 2026-10-01:
|
|
2487
|
+
// one connector per org tunnel, never a shared tunnel with appended
|
|
2488
|
+
// ingress). Without this, the tunnel exists and is configured but
|
|
2489
|
+
// nothing is dialed into it, so `hostname` answers with a Cloudflare
|
|
2490
|
+
// error -- #197's own gap, for an org's tunnel rather than a platform
|
|
2491
|
+
// one.
|
|
2492
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
2493
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
2494
|
+
await ensureCloudflaredConnector({
|
|
2495
|
+
coolify: this.ports.coolify,
|
|
2496
|
+
cloudflare: this.ports.cloudflare,
|
|
2497
|
+
tunnelId: tunnel.id,
|
|
2498
|
+
appName: `${org.slug}-cloudflared`,
|
|
2499
|
+
projectUuid,
|
|
2500
|
+
environment,
|
|
2501
|
+
resolveServer: () => this.resolveServer(),
|
|
2502
|
+
recordedConnectorAppId: context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
2503
|
+
recordAppId: async (appId) => {
|
|
2504
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, appId);
|
|
2505
|
+
await context.persistScopeIds();
|
|
2506
|
+
},
|
|
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
|
+
}
|
|
2113
2569
|
return { resourceId: tunnel.id };
|
|
2114
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
|
+
}
|
|
2115
2754
|
/**
|
|
2116
2755
|
* `plan/diff.ts`'s `planZone`: finds or creates the Cloudflare zone for the
|
|
2117
2756
|
* org's own domain. Refuses to adopt a zone the store already records
|
|
@@ -2507,9 +3146,129 @@ export class FoundryStepExecutor {
|
|
|
2507
3146
|
const { org, recordedIds } = context;
|
|
2508
3147
|
try {
|
|
2509
3148
|
switch (`${target.system}:${target.kind}`) {
|
|
2510
|
-
case '
|
|
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
|
+
}
|
|
3185
|
+
case 'cloudflare:tunnel': {
|
|
3186
|
+
// #235: the connector app first, its own 404 handled locally --
|
|
3187
|
+
// a retry where this already succeeded must still reach
|
|
3188
|
+
// `deleteTunnel` below, which the outer catch's own 404 handling
|
|
3189
|
+
// would otherwise short-circuit before it ran.
|
|
3190
|
+
const connectorAppId = recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
|
|
3191
|
+
if (connectorAppId) {
|
|
3192
|
+
try {
|
|
3193
|
+
await this.ports.coolify.deleteApplication(connectorAppId);
|
|
3194
|
+
}
|
|
3195
|
+
catch (error) {
|
|
3196
|
+
if (!isNotFoundError(error))
|
|
3197
|
+
throw error;
|
|
3198
|
+
}
|
|
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
|
+
}
|
|
2511
3269
|
await this.ports.cloudflare.deleteTunnel(target.id);
|
|
2512
3270
|
return;
|
|
3271
|
+
}
|
|
2513
3272
|
case 'cloudflare:access':
|
|
2514
3273
|
await this.ports.cloudflare.deleteAccessApp(target.id);
|
|
2515
3274
|
return;
|