@wtfalch/foundry 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/declare.d.ts +11 -2
- package/dist/bootstrap/declare.js +17 -8
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/plan/agora.d.ts +25 -0
- package/dist/plan/agora.js +25 -0
- package/dist/plan/boule.d.ts +8 -0
- package/dist/plan/boule.js +8 -0
- package/dist/plan/diff.js +99 -36
- package/dist/plan/executor.d.ts +39 -5
- package/dist/plan/executor.js +111 -22
- package/dist/plan/login-host.d.ts +15 -11
- package/dist/plan/login-host.js +16 -16
- package/dist/plan/platform-org-ports.d.ts +1 -0
- package/dist/plan/platform-org-ports.js +3 -0
- package/dist/plan/platform-providers.d.ts +3 -2
- package/dist/plan/platform-providers.js +29 -11
- package/dist/plan/providers-readers.d.ts +34 -11
- package/dist/plan/providers-readers.js +72 -15
- package/package.json +1 -1
|
@@ -37,8 +37,17 @@ export interface BootstrapOwner {
|
|
|
37
37
|
*/
|
|
38
38
|
export declare function ownerIdentity(owner: BootstrapOwner): OwnerIdentity;
|
|
39
39
|
/**
|
|
40
|
-
* Company Included's declared org: the platform's owner,
|
|
41
|
-
*
|
|
40
|
+
* Company Included's declared org: the platform's owner, tier 3 on the
|
|
41
|
+
* platform's own apex from the start (#512) -- it runs on that domain with
|
|
42
|
+
* hosted mail, not a level-1 org whose domain gets set by hand afterward.
|
|
43
|
+
* `issuer`/`calls`/`site` go on the domain too, the same tier-3 defaults
|
|
44
|
+
* archon#388 gives every company declared there. `calls` and `site` stay
|
|
45
|
+
* disabled (calls is not in v1; no site is declared), so `usesDomain` is
|
|
46
|
+
* inert on both until one is switched on. Chat stays off the domain: its
|
|
47
|
+
* re-key onto a per-org homeserver is not built yet (#463). The org's own
|
|
48
|
+
* sign-in still never collides with the shared ZITADEL instance's host --
|
|
49
|
+
* `loginHostnameFor` (#488) falls back to `<slug>-auth.oikos.host` whenever
|
|
50
|
+
* `auth.<domain>` would equal the platform's own auth hostname.
|
|
42
51
|
*/
|
|
43
52
|
export declare function declaredCompanyOrg(company: BootstrapCompany, owner: BootstrapOwner): DeclaredOrg;
|
|
44
53
|
/**
|
|
@@ -55,25 +55,34 @@ export function ownerIdentity(owner) {
|
|
|
55
55
|
return identity;
|
|
56
56
|
}
|
|
57
57
|
/**
|
|
58
|
-
* Company Included's declared org: the platform's owner,
|
|
59
|
-
*
|
|
58
|
+
* Company Included's declared org: the platform's owner, tier 3 on the
|
|
59
|
+
* platform's own apex from the start (#512) -- it runs on that domain with
|
|
60
|
+
* hosted mail, not a level-1 org whose domain gets set by hand afterward.
|
|
61
|
+
* `issuer`/`calls`/`site` go on the domain too, the same tier-3 defaults
|
|
62
|
+
* archon#388 gives every company declared there. `calls` and `site` stay
|
|
63
|
+
* disabled (calls is not in v1; no site is declared), so `usesDomain` is
|
|
64
|
+
* inert on both until one is switched on. Chat stays off the domain: its
|
|
65
|
+
* re-key onto a per-org homeserver is not built yet (#463). The org's own
|
|
66
|
+
* sign-in still never collides with the shared ZITADEL instance's host --
|
|
67
|
+
* `loginHostnameFor` (#488) falls back to `<slug>-auth.oikos.host` whenever
|
|
68
|
+
* `auth.<domain>` would equal the platform's own auth hostname.
|
|
60
69
|
*/
|
|
61
70
|
export function declaredCompanyOrg(company, owner) {
|
|
62
71
|
return {
|
|
63
72
|
id: COMPANY_INCLUDED_ORG_ID,
|
|
64
73
|
slug: company.slug,
|
|
65
74
|
displayName: company.name,
|
|
66
|
-
domain:
|
|
75
|
+
domain: company.apex,
|
|
67
76
|
mail: {
|
|
68
|
-
mode: '
|
|
69
|
-
usesDomain:
|
|
77
|
+
mode: 'hosted',
|
|
78
|
+
usesDomain: true,
|
|
70
79
|
ownerMailboxAddress: null,
|
|
71
80
|
noReplyLocalPart: 'no-reply',
|
|
72
81
|
},
|
|
73
82
|
chat: { enabled: true, usesDomain: false },
|
|
74
|
-
calls: { enabled: false, usesDomain:
|
|
75
|
-
issuer: { usesDomain:
|
|
76
|
-
site: { enabled: false, usesDomain:
|
|
83
|
+
calls: { enabled: false, usesDomain: true },
|
|
84
|
+
issuer: { usesDomain: true },
|
|
85
|
+
site: { enabled: false, usesDomain: true },
|
|
77
86
|
hosting: {
|
|
78
87
|
enabled: false,
|
|
79
88
|
usesDomain: false,
|
package/dist/index.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
|
|
|
28
28
|
export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, LoginHostBinding, LoginHostBindingsPort, } from './plan/index.js';
|
|
29
29
|
export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
|
|
30
30
|
export { HttpJobsSchedulerClient } from './plan/index.js';
|
|
31
|
+
export type { ScopeResolver, RecordedIds } from './plan/index.js';
|
|
32
|
+
export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
|
|
31
33
|
export type { ZitadelListAppsRawApp } from './plan/index.js';
|
|
32
34
|
export { adaptZitadelListApps } from './plan/index.js';
|
|
33
35
|
export type { MailIdentityApp, MailIdentityAudienceCheck } from './plan/index.js';
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesF
|
|
|
17
17
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
|
|
18
18
|
export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
|
|
19
19
|
export { HttpJobsSchedulerClient } from './plan/index.js';
|
|
20
|
+
export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
|
|
20
21
|
export { adaptZitadelListApps } from './plan/index.js';
|
|
21
22
|
export { checkMailIdentityAudience } from './plan/index.js';
|
|
22
23
|
export { checkMailAppsAllowlist } from './plan/index.js';
|
package/dist/plan/agora.d.ts
CHANGED
|
@@ -110,6 +110,18 @@ export declare function agoraHostMissing(host: AgoraHostValues): string[];
|
|
|
110
110
|
export declare function agoraHostnameFor(org: DeclaredOrg): string;
|
|
111
111
|
/** The port `calls/packages/service/Dockerfile` documents (`EXPOSE 8080`); `config.mjs`'s `PORT` has no default, so the executor pushes it. */
|
|
112
112
|
export declare const CALLS_SERVICE_PORT = "8080";
|
|
113
|
+
/**
|
|
114
|
+
* The port `wtfalch/agora`'s `apps/agora/Dockerfile` serves Caddy on
|
|
115
|
+
* (`EXPOSE 8080`). Coolify's github-app build never reads a Dockerfile's
|
|
116
|
+
* `EXPOSE` on its own, so without this passed as `ports_exposes` Traefik is
|
|
117
|
+
* left routing to a port nothing listens on and Agora answers 502 behind the
|
|
118
|
+
* tunnel (#506, seen live on Company Included, 2026-10-05). `executor.ts`'s
|
|
119
|
+
* `coolifyAgora` sets it at create time and fixes it on a found app whose
|
|
120
|
+
* live value differs; `diff.ts`'s `planAgora` plans that fix the same way it
|
|
121
|
+
* plans an env difference, by comparing this constant against the live
|
|
122
|
+
* app's own `ports_exposes` (`providers-readers.ts`'s `withAgoraPortConfig`).
|
|
123
|
+
*/
|
|
124
|
+
export declare const AGORA_APP_PORTS_EXPOSES = "8080";
|
|
113
125
|
/**
|
|
114
126
|
* Recorded by the executor once the calls service app carries its fixed
|
|
115
127
|
* internal name (`callsServiceInternalName`). `desiredAgoraEnv` waits for it,
|
|
@@ -196,6 +208,19 @@ export declare function agoraEnvDrift(desired: Readonly<Record<string, string>>,
|
|
|
196
208
|
* against (#442's own probe: "one env write, zero restarts, zero deploys").
|
|
197
209
|
*/
|
|
198
210
|
export declare const AGORA_ACTIVATED_ENV_SCOPE_KEY = "coolify_agora_activated_env";
|
|
211
|
+
/**
|
|
212
|
+
* #504: recorded once `coolifyAgora` has actually called `deploy` on this
|
|
213
|
+
* org's Agora app -- `createGithubAppApplication` never sets
|
|
214
|
+
* `instant_deploy`, and Coolify defaults it to false, so a freshly created
|
|
215
|
+
* app (or one found by name that a previous apply created but crashed
|
|
216
|
+
* before deploying) sat at `exited:unhealthy` with zero deployments
|
|
217
|
+
* forever: nothing ever deployed it, and `restart` cannot start a container
|
|
218
|
+
* that was never built. Absent means "never deployed", regardless of
|
|
219
|
+
* whether the app is found live this run or created fresh -- unlike
|
|
220
|
+
* {@link AGORA_ACTIVATED_ENV_SCOPE_KEY}, this never changes value once set,
|
|
221
|
+
* so a later env change restarts rather than deploys again.
|
|
222
|
+
*/
|
|
223
|
+
export declare const AGORA_DEPLOYED_SCOPE_KEY = "coolify_agora_deployed";
|
|
199
224
|
/**
|
|
200
225
|
* A stable digest of `env`, for comparison against what was last recorded
|
|
201
226
|
* under {@link AGORA_ACTIVATED_ENV_SCOPE_KEY}. `AGORA_CONFIG` is compared by
|
package/dist/plan/agora.js
CHANGED
|
@@ -118,6 +118,18 @@ export function agoraHostnameFor(org) {
|
|
|
118
118
|
}
|
|
119
119
|
/** The port `calls/packages/service/Dockerfile` documents (`EXPOSE 8080`); `config.mjs`'s `PORT` has no default, so the executor pushes it. */
|
|
120
120
|
export const CALLS_SERVICE_PORT = '8080';
|
|
121
|
+
/**
|
|
122
|
+
* The port `wtfalch/agora`'s `apps/agora/Dockerfile` serves Caddy on
|
|
123
|
+
* (`EXPOSE 8080`). Coolify's github-app build never reads a Dockerfile's
|
|
124
|
+
* `EXPOSE` on its own, so without this passed as `ports_exposes` Traefik is
|
|
125
|
+
* left routing to a port nothing listens on and Agora answers 502 behind the
|
|
126
|
+
* tunnel (#506, seen live on Company Included, 2026-10-05). `executor.ts`'s
|
|
127
|
+
* `coolifyAgora` sets it at create time and fixes it on a found app whose
|
|
128
|
+
* live value differs; `diff.ts`'s `planAgora` plans that fix the same way it
|
|
129
|
+
* plans an env difference, by comparing this constant against the live
|
|
130
|
+
* app's own `ports_exposes` (`providers-readers.ts`'s `withAgoraPortConfig`).
|
|
131
|
+
*/
|
|
132
|
+
export const AGORA_APP_PORTS_EXPOSES = '8080';
|
|
121
133
|
/**
|
|
122
134
|
* Recorded by the executor once the calls service app carries its fixed
|
|
123
135
|
* internal name (`callsServiceInternalName`). `desiredAgoraEnv` waits for it,
|
|
@@ -305,6 +317,19 @@ export function agoraEnvDrift(desired, live) {
|
|
|
305
317
|
* against (#442's own probe: "one env write, zero restarts, zero deploys").
|
|
306
318
|
*/
|
|
307
319
|
export const AGORA_ACTIVATED_ENV_SCOPE_KEY = 'coolify_agora_activated_env';
|
|
320
|
+
/**
|
|
321
|
+
* #504: recorded once `coolifyAgora` has actually called `deploy` on this
|
|
322
|
+
* org's Agora app -- `createGithubAppApplication` never sets
|
|
323
|
+
* `instant_deploy`, and Coolify defaults it to false, so a freshly created
|
|
324
|
+
* app (or one found by name that a previous apply created but crashed
|
|
325
|
+
* before deploying) sat at `exited:unhealthy` with zero deployments
|
|
326
|
+
* forever: nothing ever deployed it, and `restart` cannot start a container
|
|
327
|
+
* that was never built. Absent means "never deployed", regardless of
|
|
328
|
+
* whether the app is found live this run or created fresh -- unlike
|
|
329
|
+
* {@link AGORA_ACTIVATED_ENV_SCOPE_KEY}, this never changes value once set,
|
|
330
|
+
* so a later env change restarts rather than deploys again.
|
|
331
|
+
*/
|
|
332
|
+
export const AGORA_DEPLOYED_SCOPE_KEY = 'coolify_agora_deployed';
|
|
308
333
|
/**
|
|
309
334
|
* A stable digest of `env`, for comparison against what was last recorded
|
|
310
335
|
* under {@link AGORA_ACTIVATED_ENV_SCOPE_KEY}. `AGORA_CONFIG` is compared by
|
package/dist/plan/boule.d.ts
CHANGED
|
@@ -20,6 +20,14 @@ export declare const BOULE_CLIENT_ID_SCOPE_KEY = "boule_client_id";
|
|
|
20
20
|
* scope id, like the cms and calls databases.
|
|
21
21
|
*/
|
|
22
22
|
export declare const BOULE_DATABASE_SCOPE_KEY = "coolify_boule_database";
|
|
23
|
+
/**
|
|
24
|
+
* #504: the same marker `agora.ts`'s `AGORA_DEPLOYED_SCOPE_KEY` is, for
|
|
25
|
+
* Boule -- recorded once `coolifyBoule` has actually called `deploy` on this
|
|
26
|
+
* org's Boule app. See that constant's own doc comment for why: Coolify
|
|
27
|
+
* defaults `instant_deploy` to false, so without this a freshly created app
|
|
28
|
+
* sat built but never started, and `restart` cannot fix that.
|
|
29
|
+
*/
|
|
30
|
+
export declare const BOULE_DEPLOYED_SCOPE_KEY = "coolify_boule_deployed";
|
|
23
31
|
/**
|
|
24
32
|
* `boule.<domain>` when the org has a domain, `<slug>-boule.oikos.host`
|
|
25
33
|
* otherwise (the tier rule of boule#128 and foundry#147; `../slug.ts`'s
|
package/dist/plan/boule.js
CHANGED
|
@@ -19,6 +19,14 @@ export const BOULE_CLIENT_ID_SCOPE_KEY = 'boule_client_id';
|
|
|
19
19
|
* scope id, like the cms and calls databases.
|
|
20
20
|
*/
|
|
21
21
|
export const BOULE_DATABASE_SCOPE_KEY = 'coolify_boule_database';
|
|
22
|
+
/**
|
|
23
|
+
* #504: the same marker `agora.ts`'s `AGORA_DEPLOYED_SCOPE_KEY` is, for
|
|
24
|
+
* Boule -- recorded once `coolifyBoule` has actually called `deploy` on this
|
|
25
|
+
* org's Boule app. See that constant's own doc comment for why: Coolify
|
|
26
|
+
* defaults `instant_deploy` to false, so without this a freshly created app
|
|
27
|
+
* sat built but never started, and `restart` cannot fix that.
|
|
28
|
+
*/
|
|
29
|
+
export const BOULE_DEPLOYED_SCOPE_KEY = 'coolify_boule_deployed';
|
|
22
30
|
/**
|
|
23
31
|
* `boule.<domain>` when the org has a domain, `<slug>-boule.oikos.host`
|
|
24
32
|
* otherwise (the tier rule of boule#128 and foundry#147; `../slug.ts`'s
|
package/dist/plan/diff.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
2
|
import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
|
|
3
|
-
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
3
|
+
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
4
4
|
import { BOULE_BROKER_ENTRY_KEY, bouleEnvDrift, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
5
5
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
|
|
6
6
|
import { planEntryAiKeys } from './entry-ai-key.js';
|
|
@@ -82,7 +82,11 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
82
82
|
const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
|
|
83
83
|
const claimed = new Set();
|
|
84
84
|
const plannedSteps = [];
|
|
85
|
-
|
|
85
|
+
// The platform's own org: its domain is the platform apex (the same fact
|
|
86
|
+
// #491 reads for mail). It signs in on the shared instance's own host, so
|
|
87
|
+
// `issuer.usesDomain` never moves it.
|
|
88
|
+
const isPlatformOrg = isPlatformOwnOrg(org, platformReadiness.platformApex);
|
|
89
|
+
const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady, isPlatformOrg);
|
|
86
90
|
if (issuer.step)
|
|
87
91
|
plannedSteps.push(issuer.step);
|
|
88
92
|
plannedSteps.push(...planOwners(org, recordedIds, platformReadiness.authReady, 'error' in agoraHost ? null : agoraHost.issuer));
|
|
@@ -94,7 +98,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
94
98
|
// URL (`https://<platform.auth.hostname>`), the same value every org's
|
|
95
99
|
// sign-in already reads through `planOwners` above -- no separate port
|
|
96
100
|
// or plumbing needed to know the platform's own auth hostname.
|
|
97
|
-
platformAuthHostnameFrom('error' in agoraHost ? null : agoraHost.issuer));
|
|
101
|
+
platformAuthHostnameFrom('error' in agoraHost ? null : agoraHost.issuer), isPlatformOrg);
|
|
98
102
|
if (loginHost.step)
|
|
99
103
|
plannedSteps.push(loginHost.step);
|
|
100
104
|
// Planned ahead of everything that needs the zone live (#238); its own step
|
|
@@ -391,7 +395,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
391
395
|
* already exists live is unaffected: its own domain-move logic runs
|
|
392
396
|
* regardless, since that never re-provisions the ZITADEL org itself.
|
|
393
397
|
*/
|
|
394
|
-
function planIssuer(org, byKey, claimed, authReady) {
|
|
398
|
+
function planIssuer(org, byKey, claimed, authReady, isPlatformOrg = false) {
|
|
395
399
|
const key = resourceKey('zitadel', 'issuer', org.id);
|
|
396
400
|
const live = byKey.get(key);
|
|
397
401
|
const desiredUsesDomain = org.issuer.usesDomain;
|
|
@@ -420,9 +424,14 @@ function planIssuer(org, byKey, claimed, authReady) {
|
|
|
420
424
|
};
|
|
421
425
|
}
|
|
422
426
|
claimed.add(key);
|
|
423
|
-
|
|
424
|
-
|
|
427
|
+
// The platform's own org already signs in on the shared host: no move. A
|
|
428
|
+
// live issuer that does not say (the reader never does: foundry v1 never
|
|
429
|
+
// moves one) is not read as a different one either.
|
|
430
|
+
if (isPlatformOrg ||
|
|
431
|
+
live.config.usesDomain === undefined ||
|
|
432
|
+
Boolean(live.config.usesDomain) === desiredUsesDomain) {
|
|
425
433
|
return { step: null, blocked: null };
|
|
434
|
+
}
|
|
426
435
|
const domain = org.domain ?? 'the org domain';
|
|
427
436
|
return {
|
|
428
437
|
step: {
|
|
@@ -519,6 +528,9 @@ function planOwners(org, recordedIds, authReady, authIssuer) {
|
|
|
519
528
|
* computed `auth.<domain>` against, so the platform's own org never plans
|
|
520
529
|
* its login host equal to the shared ZITADEL instance's own.
|
|
521
530
|
*/
|
|
531
|
+
function isPlatformOwnOrg(org, platformApex) {
|
|
532
|
+
return Boolean(org.domain && platformApex && org.domain.toLowerCase() === platformApex.toLowerCase());
|
|
533
|
+
}
|
|
522
534
|
function platformAuthHostnameFrom(issuer) {
|
|
523
535
|
if (!issuer)
|
|
524
536
|
return null;
|
|
@@ -565,16 +577,58 @@ function platformAuthHostnameFrom(issuer) {
|
|
|
565
577
|
* platform's own domain, never plans the shared ZITADEL instance's own login
|
|
566
578
|
* host as its own -- `auth.companyincluded.com` colliding with itself.
|
|
567
579
|
*/
|
|
568
|
-
function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired, platformAuthHostname) {
|
|
580
|
+
function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired, platformAuthHostname, isPlatformOrg) {
|
|
569
581
|
const none = { step: null, blocked: null };
|
|
570
582
|
const key = resourceKey('zitadel', 'login-host', org.id);
|
|
571
|
-
|
|
583
|
+
// The platform's own org signs in on the shared instance's host whatever
|
|
584
|
+
// `issuer.usesDomain` says (turning it on would move the issuer).
|
|
585
|
+
// Before that host is known, plan nothing: a guessed host would delete the
|
|
586
|
+
// working one, and a normal step would trust a domain `destroy` later deletes.
|
|
587
|
+
if (isPlatformOrg && platformAuthHostname === null) {
|
|
588
|
+
return {
|
|
589
|
+
step: null,
|
|
590
|
+
blocked: `${org.slug}'s login host is waiting for the platform auth host`,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
const sharedInstanceHost = isPlatformOrg && platformAuthHostname !== null;
|
|
594
|
+
const host = sharedInstanceHost ? platformAuthHostname : loginHostnameFor(org);
|
|
572
595
|
// #488: the guard in `loginHostnameFor` fired -- the org's own domain host
|
|
573
596
|
// would have been the platform's shared ZITADEL instance host.
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
host
|
|
597
|
+
if (sharedInstanceHost) {
|
|
598
|
+
// The host is the shared instance's own: nothing to trust or bind, and an
|
|
599
|
+
// org with no recorded host already signs in there. Only an org recorded
|
|
600
|
+
// on another host (the old `<slug>-auth.oikos.host`) plans one step, to
|
|
601
|
+
// move its apps over and remove the old host; then it plans nothing.
|
|
602
|
+
const recordedHost = recordedIds[key];
|
|
603
|
+
const moving = recordedHost !== undefined && recordedHost !== host;
|
|
604
|
+
const removalOwed = pendingRemovalHosts(recordedIds).length > 0;
|
|
605
|
+
if (!authReady || !(moving || removalOwed))
|
|
606
|
+
return none;
|
|
607
|
+
// The same gates as the normal branch: the executor's shared step also
|
|
608
|
+
// needs the binding port and the ZITADEL org.
|
|
609
|
+
const waiting = loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned);
|
|
610
|
+
return waiting ?? stepFor(true);
|
|
611
|
+
}
|
|
612
|
+
function stepFor(shared) {
|
|
613
|
+
return {
|
|
614
|
+
step: {
|
|
615
|
+
action: {
|
|
616
|
+
system: 'zitadel',
|
|
617
|
+
kind: 'login-host',
|
|
618
|
+
key: org.id,
|
|
619
|
+
desired: shared ? { host, shared: true } : { host },
|
|
620
|
+
},
|
|
621
|
+
step: {
|
|
622
|
+
description: shared
|
|
623
|
+
? `move ${org.slug}'s apps onto the platform's own sign-in host ${host}`
|
|
624
|
+
: `set ${org.slug}'s login host to ${host}`,
|
|
625
|
+
destroys: false,
|
|
626
|
+
migrates: false,
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
blocked: null,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
578
632
|
// Already applied -- a re-run plans nothing unless the host changed, an app
|
|
579
633
|
// client id appeared since the binding was last written (#267, rule 4), or
|
|
580
634
|
// an earlier host change still has a removal owed (#274 fix round 2):
|
|
@@ -595,6 +649,10 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
|
|
|
595
649
|
// Isolation between orgs rests on zitadel-login's binding, and repointing
|
|
596
650
|
// an app at a host with no binding breaks its sign-in: trust nothing and
|
|
597
651
|
// repoint nothing until the port that writes the binding is wired.
|
|
652
|
+
return loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned) ?? stepFor(false);
|
|
653
|
+
}
|
|
654
|
+
/** Why a login-host step cannot run yet, or `null` when nothing is missing. */
|
|
655
|
+
function loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned) {
|
|
598
656
|
if (!bindingsWired) {
|
|
599
657
|
return {
|
|
600
658
|
step: null,
|
|
@@ -609,24 +667,7 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
|
|
|
609
667
|
blocked: `${org.slug}'s login host ${host} waits for its ZITADEL org, which is not recorded yet`,
|
|
610
668
|
};
|
|
611
669
|
}
|
|
612
|
-
return
|
|
613
|
-
step: {
|
|
614
|
-
action: {
|
|
615
|
-
system: 'zitadel',
|
|
616
|
-
kind: 'login-host',
|
|
617
|
-
key: org.id,
|
|
618
|
-
desired: { host },
|
|
619
|
-
},
|
|
620
|
-
step: {
|
|
621
|
-
description: sharedInstanceHost
|
|
622
|
-
? `set ${org.slug}'s login host to ${host}: its domain host (auth.${org.domain}) is the platform's own shared ZITADEL instance host, so it stays on oikos.host`
|
|
623
|
-
: `set ${org.slug}'s login host to ${host}`,
|
|
624
|
-
destroys: false,
|
|
625
|
-
migrates: false,
|
|
626
|
-
},
|
|
627
|
-
},
|
|
628
|
-
blocked: null,
|
|
629
|
-
};
|
|
670
|
+
return null;
|
|
630
671
|
}
|
|
631
672
|
/** #230: restated from `executor.ts`'s `MAIL_ROLE_MAILBOXES_SCOPE_KEY`, same rule: comma-joined local parts already created. */
|
|
632
673
|
const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
|
|
@@ -1594,6 +1635,13 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
|
|
|
1594
1635
|
if (liveEnv.AGORA_CONFIG === undefined && !drift.includes('AGORA_CONFIG')) {
|
|
1595
1636
|
drift.push('AGORA_CONFIG');
|
|
1596
1637
|
}
|
|
1638
|
+
// #506: an app Coolify never got `ports_exposes` on (or got the wrong one)
|
|
1639
|
+
// answers 502 behind the tunnel even though its env already matches --
|
|
1640
|
+
// exactly the Company Included incident this issue names. Compared
|
|
1641
|
+
// separately from `drift` above: it is a Coolify application setting, not
|
|
1642
|
+
// an env var, and `providers-readers.ts`'s `withAgoraEnvConfig` reads it
|
|
1643
|
+
// from `getApplication`, never `listEnvs`.
|
|
1644
|
+
const portDrift = live.config.ports_exposes !== AGORA_APP_PORTS_EXPOSES;
|
|
1597
1645
|
// #442: `drift` alone, once this step has ever run, is comparing `env`
|
|
1598
1646
|
// against Coolify's own env store -- which the executor's `bulkUpsertEnvs`
|
|
1599
1647
|
// leaves matching `env` the moment the write succeeds, restarted or not.
|
|
@@ -1604,14 +1652,18 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
|
|
|
1604
1652
|
// actually serving this exact `env`; while it disagrees, the step stays
|
|
1605
1653
|
// planned no matter what Coolify's env store already shows.
|
|
1606
1654
|
const activated = recordedIds[AGORA_ACTIVATED_ENV_SCOPE_KEY] === agoraEnvRevision(env);
|
|
1607
|
-
if (drift.length === 0 && activated)
|
|
1655
|
+
if (drift.length === 0 && activated && !portDrift)
|
|
1608
1656
|
return { step: null, blocked: null };
|
|
1657
|
+
const reasons = [
|
|
1658
|
+
...(drift.length > 0 ? [`${drift.join(', ')} missing or different`] : []),
|
|
1659
|
+
...(portDrift ? ["ports_exposes wrong -- Traefik can't reach it"] : []),
|
|
1660
|
+
];
|
|
1609
1661
|
return {
|
|
1610
1662
|
step: {
|
|
1611
1663
|
action,
|
|
1612
1664
|
step: {
|
|
1613
|
-
description:
|
|
1614
|
-
? `update ${org.slug}'s Agora config (${
|
|
1665
|
+
description: reasons.length > 0
|
|
1666
|
+
? `update ${org.slug}'s Agora config (${reasons.join('; ')})`
|
|
1615
1667
|
: `activate ${org.slug}'s Agora config -- written but not yet confirmed restarted`,
|
|
1616
1668
|
destroys: false,
|
|
1617
1669
|
migrates: false,
|
|
@@ -1735,13 +1787,13 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
|
|
|
1735
1787
|
* park-not-delete rule (#7 corrections 1) -- turning it off never deletes
|
|
1736
1788
|
* the org's own app, database or tunnel configuration, it parks it.
|
|
1737
1789
|
*/
|
|
1738
|
-
function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark, stepKey = org.id) {
|
|
1790
|
+
function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark, stepKey = org.id, isOn = (live) => live.config.enabled !== false) {
|
|
1739
1791
|
const key = resourceKey(system, kind, stepKey);
|
|
1740
1792
|
const live = byKey.get(key);
|
|
1741
1793
|
// A live resource is on unless it says it is parked. Providers' own
|
|
1742
1794
|
// configs carry no `enabled` field at all, so only a park step's
|
|
1743
1795
|
// explicit `enabled: false` reads as off.
|
|
1744
|
-
const liveEnabled = live !== undefined && live
|
|
1796
|
+
const liveEnabled = live !== undefined && isOn(live);
|
|
1745
1797
|
if (!desiredEnabled) {
|
|
1746
1798
|
// Nothing declared and nothing live: there is nothing to park.
|
|
1747
1799
|
if (!live)
|
|
@@ -2192,7 +2244,18 @@ function planTunnel(org, byKey, claimed, recordedIds) {
|
|
|
2192
2244
|
(org.site.enabled && Boolean(org.domain)) ||
|
|
2193
2245
|
(org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
|
|
2194
2246
|
Boolean(org.domain);
|
|
2195
|
-
const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)
|
|
2247
|
+
const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`, org.id,
|
|
2248
|
+
// Cloudflare's status, read against what is declared: a park cannot change
|
|
2249
|
+
// it, so judging on/off by status alone would never let the plan empty.
|
|
2250
|
+
// Wanted on: only `down` needs a repair (`inactive` is a fresh tunnel
|
|
2251
|
+
// whose connector has not dialled in yet). Wanted off: only a connected
|
|
2252
|
+
// tunnel needs parking; `inactive` and `down` are already parked.
|
|
2253
|
+
(live) => {
|
|
2254
|
+
const status = live.config.status;
|
|
2255
|
+
if (typeof status !== 'string')
|
|
2256
|
+
return live.config.enabled !== false;
|
|
2257
|
+
return desired ? status !== 'down' : status === 'healthy' || status === 'degraded';
|
|
2258
|
+
});
|
|
2196
2259
|
if (toggled || !desired)
|
|
2197
2260
|
return toggled;
|
|
2198
2261
|
// A live, enabled tunnel re-plans the same step again when either:
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -89,6 +89,10 @@ export interface CoolifyPort {
|
|
|
89
89
|
* `settings`, not at the top level -- confirmed against live Coolify
|
|
90
90
|
* 4.3.23 (PR #482 review); `custom_network_aliases` and
|
|
91
91
|
* `health_check_enabled` are both top-level there.
|
|
92
|
+
*
|
|
93
|
+
* #506: also the port Traefik routes to, so `coolifyAgora` can tell an app
|
|
94
|
+
* that predates passing `ports_exposes` at create apart from one already
|
|
95
|
+
* fixed.
|
|
92
96
|
*/
|
|
93
97
|
getApplication(uuid: string): Promise<{
|
|
94
98
|
uuid: string;
|
|
@@ -99,6 +103,7 @@ export interface CoolifyPort {
|
|
|
99
103
|
};
|
|
100
104
|
custom_network_aliases?: string | null;
|
|
101
105
|
health_check_enabled?: boolean;
|
|
106
|
+
ports_exposes?: string;
|
|
102
107
|
}>;
|
|
103
108
|
createGithubAppApplication(input: {
|
|
104
109
|
project_uuid: string;
|
|
@@ -1410,6 +1415,8 @@ export interface PlatformOrgPortSource {
|
|
|
1410
1415
|
files(): Promise<FilesOrganisationPort>;
|
|
1411
1416
|
ai(): Promise<AiOrganisationPort>;
|
|
1412
1417
|
jobs(): Promise<JobsSchedulerPort>;
|
|
1418
|
+
/** The platform's shared sign-in host, or `null` when none is declared. */
|
|
1419
|
+
authHostname?(): Promise<string | null>;
|
|
1413
1420
|
}
|
|
1414
1421
|
export interface FoundryStepExecutorPorts {
|
|
1415
1422
|
coolify: CoolifyPort;
|
|
@@ -2331,11 +2338,25 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2331
2338
|
* actually differs, or whenever the last restart this step confirmed
|
|
2332
2339
|
* ({@link AGORA_ACTIVATED_ENV_SCOPE_KEY}) is not this exact env (an org
|
|
2333
2340
|
* applied before this existed, whose live env already matched desired
|
|
2334
|
-
* with nothing ever confirmed running it).
|
|
2335
|
-
*
|
|
2336
|
-
*
|
|
2337
|
-
*
|
|
2338
|
-
*
|
|
2341
|
+
* with nothing ever confirmed running it). The activation marker is
|
|
2342
|
+
* recorded last, after the restart (if any) returns without throwing, so a
|
|
2343
|
+
* failed restart leaves it stale and the next plan replans this step even
|
|
2344
|
+
* though Coolify's own env store already matches desired.
|
|
2345
|
+
*
|
|
2346
|
+
* #504: a brand new app is never restarted -- but it is deployed, once
|
|
2347
|
+
* ({@link AGORA_DEPLOYED_SCOPE_KEY}). `createGithubAppApplication` never
|
|
2348
|
+
* sets `instant_deploy`, and Coolify defaults it to false, so without an
|
|
2349
|
+
* explicit deploy the app sat created but never built or started.
|
|
2350
|
+
*
|
|
2351
|
+
* #506: `ports_exposes: AGORA_APP_PORTS_EXPOSES` goes on the create call
|
|
2352
|
+
* itself, so Traefik has a port to route to from the moment the app
|
|
2353
|
+
* exists. An app `findOrCreateCallsApp` instead *found* may predate this
|
|
2354
|
+
* fix and carry no `ports_exposes` at all (or the wrong one), so its live
|
|
2355
|
+
* value is read and, when it differs, patched -- and, since the container
|
|
2356
|
+
* listens on `ports_exposes` only at its next deploy (the same rule
|
|
2357
|
+
* `coolifyApp`'s own `settingsNeedDeploy` follows for a port change
|
|
2358
|
+
* there), redeployed even on a run that already has its one #504 deploy
|
|
2359
|
+
* behind it.
|
|
2339
2360
|
*/
|
|
2340
2361
|
private coolifyAgora;
|
|
2341
2362
|
/**
|
|
@@ -2360,6 +2381,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2360
2381
|
*
|
|
2361
2382
|
* `AUTH_APP_KEY` is minted once here; its broker entry is the next step
|
|
2362
2383
|
* (`bouleBrokerEntry`).
|
|
2384
|
+
*
|
|
2385
|
+
* #504: a brand new app is never restarted -- but it is deployed, once
|
|
2386
|
+
* ({@link BOULE_DEPLOYED_SCOPE_KEY}), the same reason and the same marker
|
|
2387
|
+
* shape {@link coolifyAgora}'s own doc comment explains.
|
|
2363
2388
|
*/
|
|
2364
2389
|
private coolifyBoule;
|
|
2365
2390
|
/**
|
|
@@ -2501,6 +2526,15 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2501
2526
|
* shared-hostname collision this ticket exists to refuse. An org re-running
|
|
2502
2527
|
* its own already-recorded zone is unaffected: the store then names this
|
|
2503
2528
|
* same org as the owner.
|
|
2529
|
+
*
|
|
2530
|
+
* #510: also records the zone's own id under the flat `cloudflare_zone`
|
|
2531
|
+
* scope key, alongside the `cloudflare:zone:<domain>` resource id `runSteps`
|
|
2532
|
+
* already records from this method's return value. Without it, nothing
|
|
2533
|
+
* ever gives `ProvidersCloudflareReader` a zone id to resolve this org's
|
|
2534
|
+
* scope with (`providers-readers.ts`'s `storeScopeResolver` doc comment),
|
|
2535
|
+
* so that reader's `listResources` always returned `[]` and `diff.ts`'s
|
|
2536
|
+
* `planZone` re-proposed "provision a Cloudflare zone" on every apply, even
|
|
2537
|
+
* once the zone was live.
|
|
2504
2538
|
*/
|
|
2505
2539
|
private cloudflareZone;
|
|
2506
2540
|
/**
|
package/dist/plan/executor.js
CHANGED
|
@@ -2,8 +2,8 @@ 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 { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
|
|
5
|
-
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
6
|
-
import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
5
|
+
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
6
|
+
import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
7
7
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
8
8
|
import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
9
9
|
import { CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, chatInternalNames, chatMasConfigYaml, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
|
|
@@ -14,7 +14,7 @@ import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScope
|
|
|
14
14
|
import { EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
|
|
15
15
|
import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
|
|
16
16
|
import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
17
|
-
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
17
|
+
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
18
18
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
19
19
|
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, } from './platform-diff.js';
|
|
20
20
|
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
@@ -240,10 +240,14 @@ const CMS_SITE_APP_DOCKERFILE = '/apps/site/Dockerfile';
|
|
|
240
240
|
* followed rather than reopened (#164 "Decide" 1). The agora repo's own
|
|
241
241
|
* Dockerfile (`apps/agora/Dockerfile`) already `EXPOSE`s 8080 and serves
|
|
242
242
|
* from a build built at the repository root, the same one-repo shape
|
|
243
|
-
* `CMS_REPO`'s own two apps use
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
243
|
+
* `CMS_REPO`'s own two apps use. This comment used to claim that needed no
|
|
244
|
+
* `ports_exposes` of its own, on the theory that Coolify's github-app build
|
|
245
|
+
* reads a Dockerfile's `EXPOSE` the same way it does for `cmsSite`'s two
|
|
246
|
+
* apps above -- wrong (#506): Coolify never reads `EXPOSE` for a build it
|
|
247
|
+
* runs itself, so without an explicit `agora.ts`'s `AGORA_APP_PORTS_EXPOSES`,
|
|
248
|
+
* Traefik is left routing to a port nothing listens on, and a company's
|
|
249
|
+
* Agora answers 502 behind the tunnel. Seen live on Company Included,
|
|
250
|
+
* 2026-10-05.
|
|
247
251
|
*/
|
|
248
252
|
const AGORA_REPO = 'wtfalch/agora';
|
|
249
253
|
const AGORA_APP_DOCKERFILE = '/apps/agora/Dockerfile';
|
|
@@ -665,7 +669,7 @@ export class FoundryStepExecutor {
|
|
|
665
669
|
case 'cloudflare:tunnel':
|
|
666
670
|
return this.cloudflareTunnel(action, org, context);
|
|
667
671
|
case 'cloudflare:zone':
|
|
668
|
-
return this.cloudflareZone(action, org);
|
|
672
|
+
return this.cloudflareZone(action, org, context);
|
|
669
673
|
case 'cloudflare:access':
|
|
670
674
|
return this.cloudflareAccess(action, org);
|
|
671
675
|
case 'cloudflare:ratelimit':
|
|
@@ -1232,11 +1236,16 @@ export class FoundryStepExecutor {
|
|
|
1232
1236
|
throw new Error(`zitadel:login-host for ${org.slug}: the loginHostBindings port is not wired`);
|
|
1233
1237
|
}
|
|
1234
1238
|
const host = action.desired.host;
|
|
1235
|
-
|
|
1236
|
-
//
|
|
1237
|
-
|
|
1239
|
+
// The platform's own shared sign-in host: already trusted and routed by
|
|
1240
|
+
// the shared instance, so it gets no trusted domain or binding here.
|
|
1241
|
+
const shared = action.desired.shared === true;
|
|
1238
1242
|
const clientIds = loginHostClientIds(recorded);
|
|
1239
|
-
|
|
1243
|
+
if (!shared) {
|
|
1244
|
+
await this.ports.zitadel.ensureTrustedDomain(host);
|
|
1245
|
+
// zitadel-login's own routing table, written before any app is repointed
|
|
1246
|
+
// so no app ever points at a host with no binding.
|
|
1247
|
+
await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
|
|
1248
|
+
}
|
|
1240
1249
|
// Only apps whose client id the binding now holds: any other app in the
|
|
1241
1250
|
// project would lose sign-in. The rest move on a later run, once the
|
|
1242
1251
|
// recorded client ids differ from the binding and replan this step.
|
|
@@ -1337,7 +1346,10 @@ export class FoundryStepExecutor {
|
|
|
1337
1346
|
// the binding and replans the step next run; a nonempty pending list
|
|
1338
1347
|
// does too (`planLoginHost`), independent of the host comparison that
|
|
1339
1348
|
// normally alone decides it.
|
|
1340
|
-
|
|
1349
|
+
if (shared)
|
|
1350
|
+
context.recordScopeId(LOGIN_HOST_SHARED_SCOPE_KEY, '1');
|
|
1351
|
+
else
|
|
1352
|
+
context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
|
|
1341
1353
|
context.recordScopeId(LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, pendingRemovalState(stillPending));
|
|
1342
1354
|
return { resourceId: host };
|
|
1343
1355
|
}
|
|
@@ -3603,11 +3615,25 @@ export class FoundryStepExecutor {
|
|
|
3603
3615
|
* actually differs, or whenever the last restart this step confirmed
|
|
3604
3616
|
* ({@link AGORA_ACTIVATED_ENV_SCOPE_KEY}) is not this exact env (an org
|
|
3605
3617
|
* applied before this existed, whose live env already matched desired
|
|
3606
|
-
* with nothing ever confirmed running it).
|
|
3607
|
-
*
|
|
3608
|
-
*
|
|
3609
|
-
*
|
|
3610
|
-
*
|
|
3618
|
+
* with nothing ever confirmed running it). The activation marker is
|
|
3619
|
+
* recorded last, after the restart (if any) returns without throwing, so a
|
|
3620
|
+
* failed restart leaves it stale and the next plan replans this step even
|
|
3621
|
+
* though Coolify's own env store already matches desired.
|
|
3622
|
+
*
|
|
3623
|
+
* #504: a brand new app is never restarted -- but it is deployed, once
|
|
3624
|
+
* ({@link AGORA_DEPLOYED_SCOPE_KEY}). `createGithubAppApplication` never
|
|
3625
|
+
* sets `instant_deploy`, and Coolify defaults it to false, so without an
|
|
3626
|
+
* explicit deploy the app sat created but never built or started.
|
|
3627
|
+
*
|
|
3628
|
+
* #506: `ports_exposes: AGORA_APP_PORTS_EXPOSES` goes on the create call
|
|
3629
|
+
* itself, so Traefik has a port to route to from the moment the app
|
|
3630
|
+
* exists. An app `findOrCreateCallsApp` instead *found* may predate this
|
|
3631
|
+
* fix and carry no `ports_exposes` at all (or the wrong one), so its live
|
|
3632
|
+
* value is read and, when it differs, patched -- and, since the container
|
|
3633
|
+
* listens on `ports_exposes` only at its next deploy (the same rule
|
|
3634
|
+
* `coolifyApp`'s own `settingsNeedDeploy` follows for a port change
|
|
3635
|
+
* there), redeployed even on a run that already has its one #504 deploy
|
|
3636
|
+
* behind it.
|
|
3611
3637
|
*/
|
|
3612
3638
|
async coolifyAgora(action, org, context) {
|
|
3613
3639
|
// The host values the plan read, carried in the step: never read again
|
|
@@ -3637,9 +3663,22 @@ export class FoundryStepExecutor {
|
|
|
3637
3663
|
name,
|
|
3638
3664
|
domains: coolifyDomains([agoraHostnameFor(org)]),
|
|
3639
3665
|
dockerfile_location: AGORA_APP_DOCKERFILE,
|
|
3666
|
+
ports_exposes: AGORA_APP_PORTS_EXPOSES,
|
|
3640
3667
|
}));
|
|
3641
3668
|
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
3642
3669
|
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
3670
|
+
// #506: an app found rather than just created may predate passing
|
|
3671
|
+
// `ports_exposes` at create -- Traefik then routes to a port nothing
|
|
3672
|
+
// listens on and Agora answers 502 behind the tunnel. A freshly created
|
|
3673
|
+
// app already got the right port above, so only a found one is checked.
|
|
3674
|
+
const portDiffers = app.existed &&
|
|
3675
|
+
(await this.ports.coolify.getApplication(app.appId)).ports_exposes !==
|
|
3676
|
+
AGORA_APP_PORTS_EXPOSES;
|
|
3677
|
+
if (portDiffers) {
|
|
3678
|
+
await this.ports.coolify.updateApplication(app.appId, {
|
|
3679
|
+
ports_exposes: AGORA_APP_PORTS_EXPOSES,
|
|
3680
|
+
});
|
|
3681
|
+
}
|
|
3643
3682
|
// #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
|
|
3644
3683
|
// Coolify's own env store matching `env`, which is why that store alone
|
|
3645
3684
|
// can no longer tell a changed value from one already live.
|
|
@@ -3650,7 +3689,25 @@ export class FoundryStepExecutor {
|
|
|
3650
3689
|
await this.ports.coolify.bulkUpsertEnvs(app.appId, Object.entries(env).map(([key, value]) => ({ key, value })));
|
|
3651
3690
|
const revision = agoraEnvRevision(env);
|
|
3652
3691
|
const alreadyActivated = context.recordedIds()[AGORA_ACTIVATED_ENV_SCOPE_KEY] === revision;
|
|
3653
|
-
|
|
3692
|
+
// #504: `createGithubAppApplication` never sets `instant_deploy`, and
|
|
3693
|
+
// Coolify defaults it to false -- a brand new app, and one found by name
|
|
3694
|
+
// that a previous apply created but crashed before deploying, both sat
|
|
3695
|
+
// built but never started. `restart` cannot fix either: it starts a
|
|
3696
|
+
// container that was never built. Deployed once this executor has
|
|
3697
|
+
// confirmed (the marker never changes value once set), never again -- a
|
|
3698
|
+
// live app is only ever restarted from here on, unless #506's port fix
|
|
3699
|
+
// below needs its own deploy.
|
|
3700
|
+
if (!context.recordedIds()[AGORA_DEPLOYED_SCOPE_KEY]) {
|
|
3701
|
+
await this.ports.coolify.deploy(app.appId);
|
|
3702
|
+
context.recordScopeId(AGORA_DEPLOYED_SCOPE_KEY, '1');
|
|
3703
|
+
}
|
|
3704
|
+
else if (portDiffers) {
|
|
3705
|
+
// #506: a port fix needs its own full deploy -- env drift alone only
|
|
3706
|
+
// needs the cheaper restart, and this deploy already carries this
|
|
3707
|
+
// run's env (pushed above), so the two never both run.
|
|
3708
|
+
await this.ports.coolify.deploy(app.appId);
|
|
3709
|
+
}
|
|
3710
|
+
else if (app.existed && (envChanged || !alreadyActivated)) {
|
|
3654
3711
|
await this.ports.coolify.restart(app.appId);
|
|
3655
3712
|
}
|
|
3656
3713
|
context.recordScopeId(AGORA_ACTIVATED_ENV_SCOPE_KEY, revision);
|
|
@@ -3679,6 +3736,10 @@ export class FoundryStepExecutor {
|
|
|
3679
3736
|
*
|
|
3680
3737
|
* `AUTH_APP_KEY` is minted once here; its broker entry is the next step
|
|
3681
3738
|
* (`bouleBrokerEntry`).
|
|
3739
|
+
*
|
|
3740
|
+
* #504: a brand new app is never restarted -- but it is deployed, once
|
|
3741
|
+
* ({@link BOULE_DEPLOYED_SCOPE_KEY}), the same reason and the same marker
|
|
3742
|
+
* shape {@link coolifyAgora}'s own doc comment explains.
|
|
3682
3743
|
*/
|
|
3683
3744
|
async coolifyBoule(action, org, context) {
|
|
3684
3745
|
// The issuer and Archon URL the plan read, carried in the step: the env
|
|
@@ -3730,7 +3791,17 @@ export class FoundryStepExecutor {
|
|
|
3730
3791
|
if (!dbPush.pushed) {
|
|
3731
3792
|
throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
3732
3793
|
}
|
|
3733
|
-
|
|
3794
|
+
// #504: see `coolifyAgora`'s own comment just above -- `createGithubAppApplication`
|
|
3795
|
+
// never sets `instant_deploy`, so a brand new app (or one found by name
|
|
3796
|
+
// that a previous apply created but crashed before deploying) sat built
|
|
3797
|
+
// but never started. Deployed once this executor has confirmed, never
|
|
3798
|
+
// again -- a live app is only ever restarted from here on.
|
|
3799
|
+
if (!context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]) {
|
|
3800
|
+
await this.ports.coolify.deploy(app.appId);
|
|
3801
|
+
context.recordScopeId(BOULE_DEPLOYED_SCOPE_KEY, '1');
|
|
3802
|
+
}
|
|
3803
|
+
else if (app.existed &&
|
|
3804
|
+
(envChanged || cookieSecret !== null || appKey !== null || dbPush.changed)) {
|
|
3734
3805
|
await this.ports.coolify.restart(app.appId);
|
|
3735
3806
|
}
|
|
3736
3807
|
return { resourceId: app.appId };
|
|
@@ -4219,8 +4290,17 @@ export class FoundryStepExecutor {
|
|
|
4219
4290
|
* shared-hostname collision this ticket exists to refuse. An org re-running
|
|
4220
4291
|
* its own already-recorded zone is unaffected: the store then names this
|
|
4221
4292
|
* same org as the owner.
|
|
4293
|
+
*
|
|
4294
|
+
* #510: also records the zone's own id under the flat `cloudflare_zone`
|
|
4295
|
+
* scope key, alongside the `cloudflare:zone:<domain>` resource id `runSteps`
|
|
4296
|
+
* already records from this method's return value. Without it, nothing
|
|
4297
|
+
* ever gives `ProvidersCloudflareReader` a zone id to resolve this org's
|
|
4298
|
+
* scope with (`providers-readers.ts`'s `storeScopeResolver` doc comment),
|
|
4299
|
+
* so that reader's `listResources` always returned `[]` and `diff.ts`'s
|
|
4300
|
+
* `planZone` re-proposed "provision a Cloudflare zone" on every apply, even
|
|
4301
|
+
* once the zone was live.
|
|
4222
4302
|
*/
|
|
4223
|
-
async cloudflareZone(action, org) {
|
|
4303
|
+
async cloudflareZone(action, org, context) {
|
|
4224
4304
|
const domain = action.key;
|
|
4225
4305
|
const cf = this.ports.cloudflare;
|
|
4226
4306
|
const existing = await cf.findZoneByApex(domain);
|
|
@@ -4229,9 +4309,11 @@ export class FoundryStepExecutor {
|
|
|
4229
4309
|
if (owner !== null && owner !== org.id) {
|
|
4230
4310
|
throw new Error(`cloudflare:zone for ${org.slug}: zone ${existing.id} (${domain}) is already recorded under org ${owner}; foundry does not adopt it`);
|
|
4231
4311
|
}
|
|
4312
|
+
context.recordScopeId('cloudflare_zone', existing.id);
|
|
4232
4313
|
return { resourceId: existing.id };
|
|
4233
4314
|
}
|
|
4234
4315
|
const created = await cf.createZone(domain);
|
|
4316
|
+
context.recordScopeId('cloudflare_zone', created.id);
|
|
4235
4317
|
return { resourceId: created.id };
|
|
4236
4318
|
}
|
|
4237
4319
|
// ---- mail -----------------------------------------------------------
|
|
@@ -5318,7 +5400,14 @@ export class FoundryStepExecutor {
|
|
|
5318
5400
|
// `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY` -- nothing else ever runs
|
|
5319
5401
|
// for this org again to finish removing it once destroy deletes
|
|
5320
5402
|
// the ZITADEL project and org below, so it has to go here too.
|
|
5321
|
-
|
|
5403
|
+
// A shared instance host's trusted domain is not this org's to remove.
|
|
5404
|
+
// The flag may be missing, so the recorded host is also compared
|
|
5405
|
+
// with the platform's shared auth host.
|
|
5406
|
+
const sharedHost = await this.ports.platformOrg?.authHostname?.();
|
|
5407
|
+
const isShared = recordedIds[LOGIN_HOST_SHARED_SCOPE_KEY] === '1' ||
|
|
5408
|
+
(sharedHost != null && target.id === sharedHost);
|
|
5409
|
+
const own = isShared ? [] : [target.id];
|
|
5410
|
+
for (const host of [...own, ...pendingRemovalHosts(recordedIds)]) {
|
|
5322
5411
|
try {
|
|
5323
5412
|
await this.ports.zitadel.deleteTrustedDomain(host);
|
|
5324
5413
|
}
|
|
@@ -22,18 +22,22 @@ import type { DeclaredOrg } from '../org.js';
|
|
|
22
22
|
* org's own `<slug>.oikos.host` can collide with this subdomain, the same
|
|
23
23
|
* way `AGORA_SUFFIX` reserves `-agora`.
|
|
24
24
|
*
|
|
25
|
-
* #488:
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* host
|
|
29
|
-
*
|
|
30
|
-
* duplicate host
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* is `null` when the caller has none to compare against (no platform
|
|
34
|
-
* declared, or sign-in not ready yet), in which case this never refuses.
|
|
25
|
+
* #488: the platform's own org is not handled here: it signs in on the shared
|
|
26
|
+
* ZITADEL instance's own host (`platformAuthHostname`), whatever
|
|
27
|
+
* `issuer.usesDomain` says (William, 2026-10-06), and `planLoginHost` picks
|
|
28
|
+
* that host itself. That host must never get a trusted
|
|
29
|
+
* domain or a login binding of its own:
|
|
30
|
+
* Archon's login-host write throws on a duplicate host, and `auth`'s own
|
|
31
|
+
* `company-host.ts` denies every request through one, the shared one
|
|
32
|
+
* included (`planLoginHost` and `zitadelLoginHost` skip both for it).
|
|
35
33
|
*/
|
|
36
|
-
export declare function loginHostnameFor(org: DeclaredOrg
|
|
34
|
+
export declare function loginHostnameFor(org: DeclaredOrg): string;
|
|
35
|
+
/**
|
|
36
|
+
* Scope id set to `'1'` once an org's recorded login host is the platform's
|
|
37
|
+
* shared instance host: that host's trusted domain belongs to the shared
|
|
38
|
+
* instance, so `destroy` must not remove it.
|
|
39
|
+
*/
|
|
40
|
+
export declare const LOGIN_HOST_SHARED_SCOPE_KEY = "zitadel_login_host_shared";
|
|
37
41
|
/**
|
|
38
42
|
* One entry in zitadel-login's own `LOGIN_HOST_BINDINGS`
|
|
39
43
|
* (`auth/docs/company-login-hosts.md`) -- a single-line JSON document naming
|
package/dist/plan/login-host.js
CHANGED
|
@@ -23,30 +23,30 @@ import { BOULE_CLIENT_ID_SCOPE_KEY } from './boule.js';
|
|
|
23
23
|
* org's own `<slug>.oikos.host` can collide with this subdomain, the same
|
|
24
24
|
* way `AGORA_SUFFIX` reserves `-agora`.
|
|
25
25
|
*
|
|
26
|
-
* #488:
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* host
|
|
30
|
-
*
|
|
31
|
-
* duplicate host
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* is `null` when the caller has none to compare against (no platform
|
|
35
|
-
* declared, or sign-in not ready yet), in which case this never refuses.
|
|
26
|
+
* #488: the platform's own org is not handled here: it signs in on the shared
|
|
27
|
+
* ZITADEL instance's own host (`platformAuthHostname`), whatever
|
|
28
|
+
* `issuer.usesDomain` says (William, 2026-10-06), and `planLoginHost` picks
|
|
29
|
+
* that host itself. That host must never get a trusted
|
|
30
|
+
* domain or a login binding of its own:
|
|
31
|
+
* Archon's login-host write throws on a duplicate host, and `auth`'s own
|
|
32
|
+
* `company-host.ts` denies every request through one, the shared one
|
|
33
|
+
* included (`planLoginHost` and `zitadelLoginHost` skip both for it).
|
|
36
34
|
*/
|
|
37
|
-
export function loginHostnameFor(org
|
|
35
|
+
export function loginHostnameFor(org) {
|
|
38
36
|
// `issuer.usesDomain` is the issuer's opt-in into the org's domain
|
|
39
37
|
// (`org.ts`'s `IssuerConfig`): a domain alone never moves it, so a domain
|
|
40
38
|
// with `usesDomain` false stays on the tier 1 host.
|
|
41
39
|
if (org.domain && org.issuer.usesDomain) {
|
|
42
|
-
|
|
43
|
-
if (platformAuthHostname && host === platformAuthHostname) {
|
|
44
|
-
return `${org.slug}-auth.oikos.host`;
|
|
45
|
-
}
|
|
46
|
-
return host;
|
|
40
|
+
return `auth.${org.domain}`;
|
|
47
41
|
}
|
|
48
42
|
return `${org.slug}-auth.oikos.host`;
|
|
49
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Scope id set to `'1'` once an org's recorded login host is the platform's
|
|
46
|
+
* shared instance host: that host's trusted domain belongs to the shared
|
|
47
|
+
* instance, so `destroy` must not remove it.
|
|
48
|
+
*/
|
|
49
|
+
export const LOGIN_HOST_SHARED_SCOPE_KEY = 'zitadel_login_host_shared';
|
|
50
50
|
/**
|
|
51
51
|
* Scope id holding what the binding was last written with
|
|
52
52
|
* ({@link loginBindingState}). Client ids are not secrets. The planner
|
|
@@ -47,6 +47,9 @@ export class PlatformOrgPorts {
|
|
|
47
47
|
const token = await this.held('jobs', JOBS_ADMIN_TOKEN_REF);
|
|
48
48
|
return new HttpJobsSchedulerClient(`https://${platform.jobs.hostname}`, token, this.options.fetch);
|
|
49
49
|
}
|
|
50
|
+
async authHostname() {
|
|
51
|
+
return (await this.options.store.get())?.auth.hostname ?? null;
|
|
52
|
+
}
|
|
50
53
|
async recorded(service, scopeKey) {
|
|
51
54
|
const value = (await this.options.store.getServiceIds())[scopeKey];
|
|
52
55
|
if (!value) {
|
|
@@ -640,8 +640,9 @@ export declare const PLATFORM_AI_MANAGEMENT_KEY_REF = "ai:platform:managementKey
|
|
|
640
640
|
* What Archon's management key may do in ai: exactly `ai`'s own
|
|
641
641
|
* `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
|
|
642
642
|
* configuration only (offerings, provisioning, offers, budgets, usage, cost)
|
|
643
|
-
* plus deleting (archiving) an organisation
|
|
644
|
-
*
|
|
643
|
+
* plus deleting (archiving) an organisation and setting another
|
|
644
|
+
* organisation's webhook address and budget, never connections, runs or
|
|
645
|
+
* webhook deliveries. ai's `/v1/platform/keys` route refuses any permission outside
|
|
645
646
|
* this same list, so a request for more than this is a 403, not a wider key.
|
|
646
647
|
*/
|
|
647
648
|
export declare const PLATFORM_AI_MANAGEMENT_PERMISSIONS: readonly string[];
|
|
@@ -2330,11 +2330,14 @@ const PLATFORM_ARCHON_PROJECT = 'platform';
|
|
|
2330
2330
|
const PLATFORM_ARCHON_APP_NAME = 'platform-archon';
|
|
2331
2331
|
const PLATFORM_ARCHON_DB_NAME = 'platform-archon-db';
|
|
2332
2332
|
/**
|
|
2333
|
-
* The database
|
|
2334
|
-
*
|
|
2335
|
-
* `
|
|
2333
|
+
* The database Archon runs on. Live platform-archon moved to `archon_v2` on
|
|
2334
|
+
* the same server (2026-10-02, the move onto `@wtfalch/db`), while its Coolify
|
|
2335
|
+
* resource still names `archon` as its first database. So both URLs built
|
|
2336
|
+
* here set this database themselves, never `internal_db_url`'s path.
|
|
2337
|
+
* Archon's own migrations grant to `current_database() || '_rt'`, so
|
|
2338
|
+
* {@link ARCHON_RUNTIME_ROLE} follows.
|
|
2336
2339
|
*/
|
|
2337
|
-
const ARCHON_POSTGRES_DB = '
|
|
2340
|
+
const ARCHON_POSTGRES_DB = 'archon_v2';
|
|
2338
2341
|
const ARCHON_RUNTIME_ROLE = `${ARCHON_POSTGRES_DB}_rt`;
|
|
2339
2342
|
/** Fixed so the tunnel addresses the container directly (see `provisionPlatformAuth`). */
|
|
2340
2343
|
const PLATFORM_ARCHON_INTERNAL_NAME = 'platform-archon';
|
|
@@ -2668,11 +2671,12 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2668
2671
|
// The owner URL must name the adopted database's host, or the app's two
|
|
2669
2672
|
// database URLs would point at different databases.
|
|
2670
2673
|
const liveOwnerUrl = await currentEnvValue(deps.coolify, appId, 'DATABASE_URL_OWNER');
|
|
2674
|
+
const ownerUrl = archonDatabaseUrl(db.internal_db_url);
|
|
2671
2675
|
if (liveOwnerUrl) {
|
|
2672
2676
|
assertSameDatabaseHost('DATABASE_URL_OWNER', liveOwnerUrl, db.internal_db_url);
|
|
2673
2677
|
}
|
|
2674
2678
|
else {
|
|
2675
|
-
envs.push({ key: 'DATABASE_URL_OWNER', value:
|
|
2679
|
+
envs.push({ key: 'DATABASE_URL_OWNER', value: ownerUrl });
|
|
2676
2680
|
}
|
|
2677
2681
|
const liveReportingSite = await currentEnvValue(deps.coolify, appId, 'REPORTING_SITE');
|
|
2678
2682
|
if (!liveReportingSite) {
|
|
@@ -2692,7 +2696,7 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2692
2696
|
// reflects the env Archon actually ends up running with, not just this
|
|
2693
2697
|
// run's delta.
|
|
2694
2698
|
const archonEnvHashOverrides = new Map([
|
|
2695
|
-
['DATABASE_URL_OWNER', liveOwnerUrl ??
|
|
2699
|
+
['DATABASE_URL_OWNER', liveOwnerUrl ?? ownerUrl],
|
|
2696
2700
|
['REPORTING_SITE', liveReportingSite ?? 'archon'],
|
|
2697
2701
|
]);
|
|
2698
2702
|
const effectiveCookieSecret = cookieSecret ?? (await currentEnvValue(deps.coolify, appId, 'AUTH_COOKIE_SECRET'));
|
|
@@ -3625,10 +3629,9 @@ async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
|
|
|
3625
3629
|
return null;
|
|
3626
3630
|
}
|
|
3627
3631
|
let host;
|
|
3628
|
-
let pathname;
|
|
3629
3632
|
let search;
|
|
3630
3633
|
try {
|
|
3631
|
-
({ host,
|
|
3634
|
+
({ host, search } = new URL(dbInternalUrl));
|
|
3632
3635
|
}
|
|
3633
3636
|
catch {
|
|
3634
3637
|
// Node's `ERR_INVALID_URL` carries the whole input, credential included, so
|
|
@@ -3636,7 +3639,19 @@ async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
|
|
|
3636
3639
|
throw new Error('platform:archon: the database internal_db_url could not be parsed; not generating DATABASE_URL. Check the database in Coolify and apply again');
|
|
3637
3640
|
}
|
|
3638
3641
|
const password = randomBytes(24).toString('base64url');
|
|
3639
|
-
return `postgres://${ARCHON_RUNTIME_ROLE}:${password}@${host}
|
|
3642
|
+
return `postgres://${ARCHON_RUNTIME_ROLE}:${password}@${host}/${ARCHON_POSTGRES_DB}${search}`;
|
|
3643
|
+
}
|
|
3644
|
+
/** `internalUrl` on {@link ARCHON_POSTGRES_DB}. The error never carries the URL: it holds a credential. */
|
|
3645
|
+
function archonDatabaseUrl(internalUrl) {
|
|
3646
|
+
let url;
|
|
3647
|
+
try {
|
|
3648
|
+
url = new URL(internalUrl);
|
|
3649
|
+
}
|
|
3650
|
+
catch {
|
|
3651
|
+
throw new Error('platform:archon: the database internal_db_url could not be parsed; not generating DATABASE_URL_OWNER. Check the database in Coolify and apply again');
|
|
3652
|
+
}
|
|
3653
|
+
url.pathname = `/${ARCHON_POSTGRES_DB}`;
|
|
3654
|
+
return url.toString();
|
|
3640
3655
|
}
|
|
3641
3656
|
/**
|
|
3642
3657
|
* Thrown by {@link createAiManagementKeyMinter} (never by a test's own
|
|
@@ -3720,8 +3735,9 @@ export const PLATFORM_AI_MANAGEMENT_KEY_REF = 'ai:platform:managementKey';
|
|
|
3720
3735
|
* What Archon's management key may do in ai: exactly `ai`'s own
|
|
3721
3736
|
* `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
|
|
3722
3737
|
* configuration only (offerings, provisioning, offers, budgets, usage, cost)
|
|
3723
|
-
* plus deleting (archiving) an organisation
|
|
3724
|
-
*
|
|
3738
|
+
* plus deleting (archiving) an organisation and setting another
|
|
3739
|
+
* organisation's webhook address and budget, never connections, runs or
|
|
3740
|
+
* webhook deliveries. ai's `/v1/platform/keys` route refuses any permission outside
|
|
3725
3741
|
* this same list, so a request for more than this is a 403, not a wider key.
|
|
3726
3742
|
*/
|
|
3727
3743
|
export const PLATFORM_AI_MANAGEMENT_PERMISSIONS = [
|
|
@@ -3732,6 +3748,8 @@ export const PLATFORM_AI_MANAGEMENT_PERMISSIONS = [
|
|
|
3732
3748
|
'ai.organisations:provision',
|
|
3733
3749
|
'ai.organisations:offer',
|
|
3734
3750
|
'ai.organisations:delete',
|
|
3751
|
+
'ai.organisations:webhook',
|
|
3752
|
+
'ai.organisations:budget',
|
|
3735
3753
|
'ai.keys:issue',
|
|
3736
3754
|
'ai.budgets:read',
|
|
3737
3755
|
'ai.budgets:update',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { CloudflareClient } from '@wtfalch/providers/cloudflare';
|
|
2
2
|
import type { CoolifyClient } from '@wtfalch/providers/coolify';
|
|
3
|
-
import type
|
|
3
|
+
import { type ZitadelClient } from '@wtfalch/providers/zitadel';
|
|
4
4
|
import type { OrgId } from '../org.js';
|
|
5
5
|
import type { DeclaredOrgStore } from '../store.js';
|
|
6
6
|
import type { CloudflareReader, CmsReader, CoolifyReader, LiveResource, ZitadelReader } from './live.js';
|
|
@@ -53,15 +53,17 @@ export type ScopeResolver = (orgId: OrgId) => Promise<string | null>;
|
|
|
53
53
|
* resource it creates, keyed `system:kind:key` ({@link storeRecordedIds}
|
|
54
54
|
* below reads those back), and #20's `FoundryStepExecutor` (`executor.ts`)
|
|
55
55
|
* now also calls `StepExecutionContext.recordScopeId` for `coolify_project`,
|
|
56
|
-
* `zitadel_org` and `
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
56
|
+
* `zitadel_org`, `zitadel_project` and (#510) `cloudflare_zone`, so a
|
|
57
|
+
* resolver built with one of those `serviceKey`s returns the real id once
|
|
58
|
+
* that step has run. Before #510, `cloudflareZone` recorded its result only
|
|
59
|
+
* under `cloudflare:zone:<domain>` (a resource id, read back by
|
|
60
|
+
* {@link storeRecordedIds}), never under the plain `cloudflare_zone` scope
|
|
61
|
+
* key — so a resolver built with that `serviceKey` always returned `null`,
|
|
62
|
+
* `ProvidersCloudflareReader.listResources` always returned `[]`, and
|
|
63
|
+
* `diff.ts`'s `planZone` re-proposed provisioning the zone on every apply
|
|
64
|
+
* even once it was live. A scope key that genuinely has nothing recorded yet
|
|
65
|
+
* still correctly resolves to `null`: a reader passed `null` returns `[]`,
|
|
66
|
+
* which `computePlanDetail` (`diff.ts`) already reads as "no live resource".
|
|
65
67
|
*/
|
|
66
68
|
export declare function storeScopeResolver(store: DeclaredOrgStore, serviceKey: string): ScopeResolver;
|
|
67
69
|
/**
|
|
@@ -112,7 +114,8 @@ export declare class ProvidersCloudflareReader implements CloudflareReader {
|
|
|
112
114
|
/**
|
|
113
115
|
* Backed by `@wtfalch/providers/zitadel`'s `ZitadelClient.listResources`: one
|
|
114
116
|
* resource per project and per project grant in a ZITADEL org (v1 scope —
|
|
115
|
-
* see that method's own doc comment)
|
|
117
|
+
* see that method's own doc comment), plus (#510) a synthesized `issuer`
|
|
118
|
+
* resource once the org's own ZITADEL org is confirmed live.
|
|
116
119
|
*/
|
|
117
120
|
export declare class ProvidersZitadelReader implements ZitadelReader {
|
|
118
121
|
private readonly client;
|
|
@@ -120,6 +123,26 @@ export declare class ProvidersZitadelReader implements ZitadelReader {
|
|
|
120
123
|
private readonly resolveRecordedIds;
|
|
121
124
|
constructor(client: ZitadelClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
|
|
122
125
|
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
126
|
+
/**
|
|
127
|
+
* #510: `client.listResources` reports only what is *inside* a ZITADEL org
|
|
128
|
+
* (its projects and grants), never the org record itself, so nothing ever
|
|
129
|
+
* matched `diff.ts`'s `planIssuer`'s own `zitadel:issuer:<orgId>` lookup —
|
|
130
|
+
* every plan re-proposed "provision the org's own ZITADEL org" after a
|
|
131
|
+
* clean apply, even once `zitadel_org` was recorded and live. `orgId` here
|
|
132
|
+
* is foundry's own id ({@link resourceKey}'s `key` for an issuer, `diff.ts`'s
|
|
133
|
+
* `planIssuer`), not `zitadelOrgId` — the resource foundry tracks is "this
|
|
134
|
+
* org's issuer", and ZITADEL's own org id is an implementation detail of
|
|
135
|
+
* where it lives, the same distinction {@link applyRecordedIds} draws for
|
|
136
|
+
* every other recorded resource.
|
|
137
|
+
*
|
|
138
|
+
* `resolveScope` only reads what was last recorded; it never confirms the
|
|
139
|
+
* org still exists in ZITADEL. A 404 here means the recorded id no longer
|
|
140
|
+
* resolves (the org was removed out of band) and reads as "not live" — the
|
|
141
|
+
* same `[]` answer a never-recorded scope already gives. Any other failure
|
|
142
|
+
* propagates, same as every other call in this reader. An org ZITADEL still
|
|
143
|
+
* returns but marks `ORG_STATE_REMOVED` is not live either.
|
|
144
|
+
*/
|
|
145
|
+
private liveIssuer;
|
|
123
146
|
}
|
|
124
147
|
/**
|
|
125
148
|
* Backed by the same `@wtfalch/providers/coolify`'s `CoolifyClient.listResources`
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ZitadelError } from '@wtfalch/providers/zitadel';
|
|
1
2
|
import { BOULE_MANAGED_ENV_KEYS } from './boule.js';
|
|
2
3
|
/**
|
|
3
4
|
* A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
|
|
@@ -8,15 +9,17 @@ import { BOULE_MANAGED_ENV_KEYS } from './boule.js';
|
|
|
8
9
|
* resource it creates, keyed `system:kind:key` ({@link storeRecordedIds}
|
|
9
10
|
* below reads those back), and #20's `FoundryStepExecutor` (`executor.ts`)
|
|
10
11
|
* now also calls `StepExecutionContext.recordScopeId` for `coolify_project`,
|
|
11
|
-
* `zitadel_org` and `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
12
|
+
* `zitadel_org`, `zitadel_project` and (#510) `cloudflare_zone`, so a
|
|
13
|
+
* resolver built with one of those `serviceKey`s returns the real id once
|
|
14
|
+
* that step has run. Before #510, `cloudflareZone` recorded its result only
|
|
15
|
+
* under `cloudflare:zone:<domain>` (a resource id, read back by
|
|
16
|
+
* {@link storeRecordedIds}), never under the plain `cloudflare_zone` scope
|
|
17
|
+
* key — so a resolver built with that `serviceKey` always returned `null`,
|
|
18
|
+
* `ProvidersCloudflareReader.listResources` always returned `[]`, and
|
|
19
|
+
* `diff.ts`'s `planZone` re-proposed provisioning the zone on every apply
|
|
20
|
+
* even once it was live. A scope key that genuinely has nothing recorded yet
|
|
21
|
+
* still correctly resolves to `null`: a reader passed `null` returns `[]`,
|
|
22
|
+
* which `computePlanDetail` (`diff.ts`) already reads as "no live resource".
|
|
20
23
|
*/
|
|
21
24
|
export function storeScopeResolver(store, serviceKey) {
|
|
22
25
|
return async (orgId) => {
|
|
@@ -179,18 +182,34 @@ async function withEnvNamesConfig(client, resource, recorded) {
|
|
|
179
182
|
* the `agora` kind, like {@link withLogVolumeConfig}. Unlike that read, a
|
|
180
183
|
* failure here is not swallowed: a plan that cannot see the live config must
|
|
181
184
|
* fail, not report it as missing.
|
|
185
|
+
*
|
|
186
|
+
* #506: also the live app's own `ports_exposes`, as `config.ports_exposes`,
|
|
187
|
+
* so `planAgora` can plan the fix for an app Coolify never got that setting
|
|
188
|
+
* on even when its env already matches -- the exact shape of the Company
|
|
189
|
+
* Included incident this issue names, where re-planning without this read
|
|
190
|
+
* would see no drift at all and never run `coolifyAgora` again.
|
|
182
191
|
*/
|
|
183
192
|
async function withAgoraEnvConfig(client, resource, recorded) {
|
|
184
193
|
if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('agora:')) {
|
|
185
194
|
return resource;
|
|
186
195
|
}
|
|
187
|
-
const envs = await
|
|
196
|
+
const [envs, app] = await Promise.all([
|
|
197
|
+
client.listEnvs(resource.key),
|
|
198
|
+
client.getApplication(resource.key),
|
|
199
|
+
]);
|
|
188
200
|
const env = {};
|
|
189
201
|
for (const e of envs) {
|
|
190
202
|
if (!e.is_preview && e.key.startsWith('AGORA_'))
|
|
191
203
|
env[e.key] = e.value;
|
|
192
204
|
}
|
|
193
|
-
|
|
205
|
+
// `@wtfalch/providers`' `CoolifyApplication` type does not declare
|
|
206
|
+
// `ports_exposes` (as of 0.7.3), but `getApplication` returns the raw
|
|
207
|
+
// Coolify API response (`coolify.ts`'s own `getApplication`, a plain
|
|
208
|
+
// `request` passthrough), which does carry it -- the same gap that
|
|
209
|
+
// repo's own CLAUDE.md names for other fields ("verified against a live
|
|
210
|
+
// instance, not the bundled openapi.yaml").
|
|
211
|
+
const portsExposes = app.ports_exposes;
|
|
212
|
+
return { ...resource, config: { ...resource.config, env, ports_exposes: portsExposes } };
|
|
194
213
|
}
|
|
195
214
|
/**
|
|
196
215
|
* #161: the org's own recorded Boule app's live sign-in env vars, as
|
|
@@ -273,13 +292,17 @@ export class ProvidersCloudflareReader {
|
|
|
273
292
|
// A zone needs no mapping: its key is already the zone name, which is
|
|
274
293
|
// foundry's own zone key (`diff.ts`'s `planZone`), so it never matches a
|
|
275
294
|
// recorded provider id and always passes through unchanged.
|
|
276
|
-
return applyRecordedIds(toFoundryResources('cloudflare', resources)
|
|
295
|
+
return applyRecordedIds(toFoundryResources('cloudflare', resources)
|
|
296
|
+
// Keyed by the zone id, so a recorded zone id would map it onto the
|
|
297
|
+
// zone's own key and replace the zone's status; nothing plans from it.
|
|
298
|
+
.filter((resource) => resource.kind !== 'email_routing'), recorded, CLOUDFLARE_DROP_UNRECORDED);
|
|
277
299
|
}
|
|
278
300
|
}
|
|
279
301
|
/**
|
|
280
302
|
* Backed by `@wtfalch/providers/zitadel`'s `ZitadelClient.listResources`: one
|
|
281
303
|
* resource per project and per project grant in a ZITADEL org (v1 scope —
|
|
282
|
-
* see that method's own doc comment)
|
|
304
|
+
* see that method's own doc comment), plus (#510) a synthesized `issuer`
|
|
305
|
+
* resource once the org's own ZITADEL org is confirmed live.
|
|
283
306
|
*/
|
|
284
307
|
export class ProvidersZitadelReader {
|
|
285
308
|
client;
|
|
@@ -294,11 +317,45 @@ export class ProvidersZitadelReader {
|
|
|
294
317
|
const zitadelOrgId = await this.resolveScope(orgId);
|
|
295
318
|
if (!zitadelOrgId)
|
|
296
319
|
return [];
|
|
297
|
-
const [resources, recorded] = await Promise.all([
|
|
320
|
+
const [resources, recorded, issuer] = await Promise.all([
|
|
298
321
|
this.client.listResources(zitadelOrgId),
|
|
299
322
|
this.resolveRecordedIds(orgId),
|
|
323
|
+
this.liveIssuer(orgId, zitadelOrgId),
|
|
300
324
|
]);
|
|
301
|
-
|
|
325
|
+
const mapped = applyRecordedIds(toFoundryResources('zitadel', resources), recorded);
|
|
326
|
+
return issuer ? [...mapped, issuer] : mapped;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* #510: `client.listResources` reports only what is *inside* a ZITADEL org
|
|
330
|
+
* (its projects and grants), never the org record itself, so nothing ever
|
|
331
|
+
* matched `diff.ts`'s `planIssuer`'s own `zitadel:issuer:<orgId>` lookup —
|
|
332
|
+
* every plan re-proposed "provision the org's own ZITADEL org" after a
|
|
333
|
+
* clean apply, even once `zitadel_org` was recorded and live. `orgId` here
|
|
334
|
+
* is foundry's own id ({@link resourceKey}'s `key` for an issuer, `diff.ts`'s
|
|
335
|
+
* `planIssuer`), not `zitadelOrgId` — the resource foundry tracks is "this
|
|
336
|
+
* org's issuer", and ZITADEL's own org id is an implementation detail of
|
|
337
|
+
* where it lives, the same distinction {@link applyRecordedIds} draws for
|
|
338
|
+
* every other recorded resource.
|
|
339
|
+
*
|
|
340
|
+
* `resolveScope` only reads what was last recorded; it never confirms the
|
|
341
|
+
* org still exists in ZITADEL. A 404 here means the recorded id no longer
|
|
342
|
+
* resolves (the org was removed out of band) and reads as "not live" — the
|
|
343
|
+
* same `[]` answer a never-recorded scope already gives. Any other failure
|
|
344
|
+
* propagates, same as every other call in this reader. An org ZITADEL still
|
|
345
|
+
* returns but marks `ORG_STATE_REMOVED` is not live either.
|
|
346
|
+
*/
|
|
347
|
+
async liveIssuer(orgId, zitadelOrgId) {
|
|
348
|
+
try {
|
|
349
|
+
const found = await this.client.getOrg(zitadelOrgId);
|
|
350
|
+
if (found.state === 'ORG_STATE_REMOVED')
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
if (err instanceof ZitadelError && err.status === 404)
|
|
355
|
+
return null;
|
|
356
|
+
throw err;
|
|
357
|
+
}
|
|
358
|
+
return { system: 'zitadel', kind: 'issuer', key: orgId, config: {} };
|
|
302
359
|
}
|
|
303
360
|
}
|
|
304
361
|
/**
|