@wtfalch/foundry 0.28.0 → 0.30.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/calls-sfu-ssh-client.d.ts +11 -0
- package/dist/calls-sfu-ssh-client.js +65 -11
- package/dist/index.d.ts +7 -5
- package/dist/index.js +4 -3
- package/dist/org.d.ts +88 -0
- package/dist/org.js +68 -0
- package/dist/plan/agora.d.ts +13 -0
- package/dist/plan/agora.js +15 -0
- package/dist/plan/boule.d.ts +27 -3
- package/dist/plan/boule.js +39 -1
- package/dist/plan/chat-matrix.d.ts +31 -2
- package/dist/plan/chat-matrix.js +40 -6
- package/dist/plan/diff.d.ts +45 -2
- package/dist/plan/diff.js +211 -16
- package/dist/plan/executor.d.ts +132 -0
- package/dist/plan/executor.js +510 -31
- package/dist/plan/index.d.ts +5 -2
- package/dist/plan/index.js +3 -1
- package/dist/plan/plan-apply.js +1 -0
- package/dist/plan/platform-diff.d.ts +9 -0
- package/dist/plan/platform-diff.js +15 -3
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-org.js +4 -1
- package/dist/plan/platform-providers.d.ts +1 -1
- package/dist/plan/platform-providers.js +79 -22
- package/dist/plan/providers-readers.d.ts +15 -2
- package/dist/plan/providers-readers.js +44 -6
- package/dist/plan/step-system.js +1 -0
- package/dist/plan/version-status.d.ts +200 -0
- package/dist/plan/version-status.js +213 -0
- package/dist/plan/zitadel-login-policy.d.ts +23 -0
- package/dist/plan/zitadel-login-policy.js +103 -0
- package/dist/plan/zitadel-machine-user.d.ts +1 -1
- package/dist/plan/zitadel-machine-user.js +34 -0
- package/dist/platform.d.ts +1 -1
- package/package.json +3 -2
package/dist/plan/chat-matrix.js
CHANGED
|
@@ -29,6 +29,22 @@ export function chatUpstreams(orgSlug) {
|
|
|
29
29
|
mas: `http://${names.mas}:${CHAT_MAS_PORT}`,
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Internal names for the org's re-keyed homeserver pair (#463, design note
|
|
34
|
+
* `docs/chat-rekey-design.md` Option 1): a second Synapse and MAS,
|
|
35
|
+
* provisioned beside the original pair under `chatInternalNames` so both
|
|
36
|
+
* ends of the copy are live at once. The chat service and its own database
|
|
37
|
+
* are never re-created — same app, repointed once the copy is done and the
|
|
38
|
+
* original pair is parked.
|
|
39
|
+
*/
|
|
40
|
+
export function chatRekeyedInternalNames(orgSlug) {
|
|
41
|
+
return {
|
|
42
|
+
synapse: `${orgSlug}-chat-synapse-2`,
|
|
43
|
+
mas: `${orgSlug}-chat-mas-2`,
|
|
44
|
+
synapseDb: `${orgSlug}-chat-synapse-2-db`,
|
|
45
|
+
masDb: `${orgSlug}-chat-mas-2-db`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
32
48
|
/**
|
|
33
49
|
* Never resolved by DNS and never federates; the identifier Synapse, MAS and
|
|
34
50
|
* the chat service agree on. A level-1 org's chat lives on this name until a
|
|
@@ -38,18 +54,33 @@ export function chatUpstreams(orgSlug) {
|
|
|
38
54
|
export function chatServerName(orgSlug) {
|
|
39
55
|
return `chat-${orgSlug}.internal`;
|
|
40
56
|
}
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
57
|
+
/**
|
|
58
|
+
* The Matrix user the chat service acts as
|
|
59
|
+
* (`chat/apps/chat-service/src/workspace.ts`). `serverName` defaults to the
|
|
60
|
+
* org's level-1 `chatServerName`; a re-key (#463) passes the new one so the
|
|
61
|
+
* gate module's `admin_user` matches the homeserver it is loaded into.
|
|
62
|
+
*/
|
|
63
|
+
export function chatAdminUser(orgSlug, serverName = chatServerName(orgSlug)) {
|
|
64
|
+
return `@chat-admin:${serverName}`;
|
|
44
65
|
}
|
|
45
66
|
const q = (value) => JSON.stringify(value);
|
|
67
|
+
const SERVER_NAME_PATTERN = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
|
68
|
+
/** A re-key's `server_name` is a lowercase DNS name with at least two labels; anything else would be written into both configs. */
|
|
69
|
+
export function assertChatServerName(serverName) {
|
|
70
|
+
if (serverName.length > 253 || !SERVER_NAME_PATTERN.test(serverName)) {
|
|
71
|
+
throw new Error('chat server_name must be a lowercase domain name');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
46
74
|
/** `homeserver.yaml`: federation off, presence on (stable Synapse features only -- no MSC3026 `busy`, foundry#41), MAS for auth, the chat gate module on. */
|
|
47
75
|
export function chatSynapseHomeserverYaml(input) {
|
|
76
|
+
const serverName = input.serverName ?? chatServerName(input.orgSlug);
|
|
77
|
+
if (input.serverName !== undefined)
|
|
78
|
+
assertChatServerName(serverName);
|
|
48
79
|
return [
|
|
49
80
|
'# Generated by @wtfalch/foundry (executor.ts, coolify:chat). Do not edit',
|
|
50
81
|
'# by hand; re-run apply instead -- it will not rotate secrets that exist.',
|
|
51
82
|
'',
|
|
52
|
-
`server_name: ${q(
|
|
83
|
+
`server_name: ${q(serverName)}`,
|
|
53
84
|
'pid_file: /data/homeserver.pid',
|
|
54
85
|
'report_stats: false',
|
|
55
86
|
'',
|
|
@@ -106,13 +137,16 @@ export function chatSynapseHomeserverYaml(input) {
|
|
|
106
137
|
'modules:',
|
|
107
138
|
' - module: wtfalch_chat_gate.ChatGate',
|
|
108
139
|
' config:',
|
|
109
|
-
` admin_user: ${q(chatAdminUser(input.orgSlug))}`,
|
|
140
|
+
` admin_user: ${q(chatAdminUser(input.orgSlug, serverName))}`,
|
|
110
141
|
' allow_encrypted_channels: false',
|
|
111
142
|
'',
|
|
112
143
|
].join('\n');
|
|
113
144
|
}
|
|
114
145
|
/** MAS `config.yaml`: password login and registration off; the chat service is the one client, with admin. */
|
|
115
146
|
export function chatMasConfigYaml(input) {
|
|
147
|
+
const serverName = input.serverName ?? chatServerName(input.orgSlug);
|
|
148
|
+
if (input.serverName !== undefined)
|
|
149
|
+
assertChatServerName(serverName);
|
|
116
150
|
const indentKey = (pem, spaces) => pem
|
|
117
151
|
.trimEnd()
|
|
118
152
|
.split('\n')
|
|
@@ -150,7 +184,7 @@ export function chatMasConfigYaml(input) {
|
|
|
150
184
|
'',
|
|
151
185
|
'matrix:',
|
|
152
186
|
' kind: synapse',
|
|
153
|
-
` homeserver: ${q(
|
|
187
|
+
` homeserver: ${q(serverName)}`,
|
|
154
188
|
` endpoint: ${q(`http://${input.synapseHost}:${CHAT_SYNAPSE_PORT}`)}`,
|
|
155
189
|
` secret: ${q(input.sharedSecret)}`,
|
|
156
190
|
'',
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DeclaredOrg, OrgId } from '../org.js';
|
|
1
|
+
import type { DeclaredOrg, OrgId, OrgServiceSchema } from '../org.js';
|
|
2
2
|
import type { Plan, PlanStep, UnmanagedItem, VersionChangeItem } from '../plan.js';
|
|
3
3
|
import { type AgoraHostReading } from './agora.js';
|
|
4
4
|
import type { LiveResource } from './live.js';
|
|
@@ -64,6 +64,8 @@ export interface PlanDetail {
|
|
|
64
64
|
unmanaged: UnmanagedItem[];
|
|
65
65
|
/** #251: see `VersionChangeItem` (`../plan.ts`). Never folded into `plannedSteps`. */
|
|
66
66
|
versionChanges: VersionChangeItem[];
|
|
67
|
+
/** #444a: see {@link OrgPostgresPlan}. Never folded into `plannedSteps`, for the same reason `versionChanges` above is not. */
|
|
68
|
+
orgPostgres: OrgPostgresPlan;
|
|
67
69
|
}
|
|
68
70
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
69
71
|
export declare function toPlan(detail: PlanDetail): Plan;
|
|
@@ -110,7 +112,12 @@ export declare function toPlan(detail: PlanDetail): Plan;
|
|
|
110
112
|
* app-creating step below: {@link planLoginHost}, the org's own company
|
|
111
113
|
* login host -- also idempotent on the *recorded* resource id, since
|
|
112
114
|
* `live` has no notion of a ZITADEL trusted domain or a login binding
|
|
113
|
-
* either.
|
|
115
|
+
* either. #444a adds one more fact, in a different shape from every other
|
|
116
|
+
* addition here: {@link planOrgPostgres}, the org's one shared Postgres
|
|
117
|
+
* server plus whichever of `org.ts`'s `ORG_SERVICE_SCHEMAS` (nine today)
|
|
118
|
+
* this org actually runs and has not yet recorded -- idempotent on the
|
|
119
|
+
* *recorded* resource id like {@link planOwners}, but never folded into
|
|
120
|
+
* `plannedSteps`: its own {@link OrgPostgresPlan} field says why. Everything
|
|
114
121
|
* else in `live` that none of these eighteen singletons and however many
|
|
115
122
|
* declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
|
|
116
123
|
* guests, calls on the org's own domain, adopted/bespoke apps, the actual
|
|
@@ -128,6 +135,42 @@ export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[]
|
|
|
128
135
|
* its login host equal to the shared ZITADEL instance's own.
|
|
129
136
|
*/
|
|
130
137
|
export declare function isPlatformOwnOrg(org: DeclaredOrg, platformApex: string | null): boolean;
|
|
138
|
+
/**
|
|
139
|
+
* What {@link planOrgPostgres} reports about the org's one shared Postgres
|
|
140
|
+
* server and its fixed schema topology (#444a, "manifest-and-plan-only"):
|
|
141
|
+
* missing only, nothing applied. Deliberately **not** folded into
|
|
142
|
+
* `plannedSteps` -- the same reason {@link VersionChangeItem} (`../plan.ts`)
|
|
143
|
+
* never is, restated in `computePlanDetail`'s own doc comment below, but
|
|
144
|
+
* sharper here: `plan-apply.ts`'s `runSteps` executes every
|
|
145
|
+
* `detail.plannedSteps` entry through `StepExecutor.execute`, which
|
|
146
|
+
* switches on `` `${system}:${kind}` `` and throws on anything it has no
|
|
147
|
+
* case for. `plan/executor.ts` is untouched this slice (444a's own
|
|
148
|
+
* acceptance line), so a `'coolify:postgres-server'`/`'coolify:postgres-
|
|
149
|
+
* schema'` entry in `plannedSteps` would not sit there harmlessly the way
|
|
150
|
+
* an unapplied field does -- the next real `apply()` would reach it and
|
|
151
|
+
* throw `FoundryStepExecutor has no dispatch for coolify:postgres-server`,
|
|
152
|
+
* for every org, immediately. This field is how 444a reports the same
|
|
153
|
+
* "missing" fact to a caller of `computePlanDetail` directly without that
|
|
154
|
+
* happening; wiring it into the public `Plan` (`toPlan` below) and into
|
|
155
|
+
* real `plannedSteps` entries is 444b's job, once `plan/executor.ts` has a
|
|
156
|
+
* dispatch for them.
|
|
157
|
+
*/
|
|
158
|
+
export interface OrgPostgresPlan {
|
|
159
|
+
/** True until the org's one shared Postgres server is recorded under {@link ORG_POSTGRES_SERVER_SCOPE_KEY}. */
|
|
160
|
+
serverMissing: boolean;
|
|
161
|
+
/**
|
|
162
|
+
* Every `org.ts` {@link ORG_SERVICE_SCHEMAS} entry this org actually runs
|
|
163
|
+
* and has not yet recorded under its own `scopeKey`, in that constant's
|
|
164
|
+
* own order -- see {@link orgServiceSchemaEnabled} for which entries that
|
|
165
|
+
* is. Boule's is always included, the same way `ORG_SERVICE_SCHEMAS`
|
|
166
|
+
* itself always carries it; CMS's, chat's and calls' sit behind their own
|
|
167
|
+
* feature switch, and the app schema behind whether any hosting entry
|
|
168
|
+
* declares a database. A schema this org does not run yet is never
|
|
169
|
+
* reported missing, even though {@link ORG_SERVICE_SCHEMAS} still names
|
|
170
|
+
* it as part of the full topology.
|
|
171
|
+
*/
|
|
172
|
+
missingSchemas: readonly OrgServiceSchema[];
|
|
173
|
+
}
|
|
131
174
|
/** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
|
|
132
175
|
export declare const AI_KEY_UNDELIVERED_SCOPE_KEY = "ai_key_undelivered";
|
|
133
176
|
/** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
|
package/dist/plan/diff.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
|
-
import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
|
|
3
|
-
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
4
|
-
import { BOULE_BROKER_ENTRY_KEY, bouleEnvDrift, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
2
|
+
import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, ownerMailboxRecorded, ownerMailboxes, } from '../org.js';
|
|
3
|
+
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
4
|
+
import { BOULE_BROKER_ENTRY_KEY, BOULE_MAIL_TOKEN_ENV, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
5
5
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
|
|
6
|
-
import { chatInternalNames } from './chat-matrix.js';
|
|
6
|
+
import { chatInternalNames, chatUpstreams } from './chat-matrix.js';
|
|
7
7
|
import { planEntryAiKeys } from './entry-ai-key.js';
|
|
8
8
|
import { planEdgeReservedLabels, planEntryEdgeKeys } from './entry-edge-key.js';
|
|
9
9
|
import { planFilesKeys } from './entry-files-key.js';
|
|
@@ -145,7 +145,12 @@ function liveResourceName(r) {
|
|
|
145
145
|
* app-creating step below: {@link planLoginHost}, the org's own company
|
|
146
146
|
* login host -- also idempotent on the *recorded* resource id, since
|
|
147
147
|
* `live` has no notion of a ZITADEL trusted domain or a login binding
|
|
148
|
-
* either.
|
|
148
|
+
* either. #444a adds one more fact, in a different shape from every other
|
|
149
|
+
* addition here: {@link planOrgPostgres}, the org's one shared Postgres
|
|
150
|
+
* server plus whichever of `org.ts`'s `ORG_SERVICE_SCHEMAS` (nine today)
|
|
151
|
+
* this org actually runs and has not yet recorded -- idempotent on the
|
|
152
|
+
* *recorded* resource id like {@link planOwners}, but never folded into
|
|
153
|
+
* `plannedSteps`: its own {@link OrgPostgresPlan} field says why. Everything
|
|
149
154
|
* else in `live` that none of these eighteen singletons and however many
|
|
150
155
|
* declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
|
|
151
156
|
* guests, calls on the org's own domain, adopted/bespoke apps, the actual
|
|
@@ -164,7 +169,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
164
169
|
const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady, isPlatformOrg);
|
|
165
170
|
if (issuer.step)
|
|
166
171
|
plannedSteps.push(issuer.step);
|
|
167
|
-
plannedSteps.push(...planOwners(org, recordedIds, platformReadiness.authReady,
|
|
172
|
+
plannedSteps.push(...planOwners(org, recordedIds, platformReadiness.authReady, platformReadiness.authIssuer));
|
|
168
173
|
// #267: after the ZITADEL org step, before any app's own issuer
|
|
169
174
|
// application. Apps created later in this run are not in the binding yet;
|
|
170
175
|
// the next run replans this step and repoints them.
|
|
@@ -176,6 +181,11 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
176
181
|
platformAuthHostnameFrom('error' in agoraHost ? null : agoraHost.issuer), isPlatformOrg);
|
|
177
182
|
if (loginHost.step)
|
|
178
183
|
plannedSteps.push(loginHost.step);
|
|
184
|
+
// #444a: the org's one shared Postgres server and its fixed schema
|
|
185
|
+
// topology -- dependent on nothing else planned here, but its own
|
|
186
|
+
// `OrgPostgresPlan`, never `plannedSteps` ({@link OrgPostgresPlan}'s own
|
|
187
|
+
// doc comment says why).
|
|
188
|
+
const orgPostgres = planOrgPostgres(org, recordedIds);
|
|
179
189
|
// Planned ahead of everything that needs the zone live (#238); its own step
|
|
180
190
|
// is pushed just below, before mail.
|
|
181
191
|
const zone = planZone(org, byKey, claimed);
|
|
@@ -230,6 +240,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
230
240
|
: callsTokenHostFrom(withRecordedMessagingProject(agoraHost, recordedIds)));
|
|
231
241
|
if (callsService.step)
|
|
232
242
|
plannedSteps.push(callsService.step);
|
|
243
|
+
// After both apps it writes to: chat's token and the calls service's chat address.
|
|
244
|
+
const callsChat = planCallsChat(org, recordedIds, chat.step !== null || callsService.step !== null, chat.blocked !== null || calls.blocked !== null || callsService.blocked !== null);
|
|
245
|
+
if (callsChat.step)
|
|
246
|
+
plannedSteps.push(callsChat.step);
|
|
233
247
|
// #251: same mechanism as the platform's own version-change lines -- see
|
|
234
248
|
// `planPlatform`'s own block in `platform-diff.ts`. Synapse and MAS never
|
|
235
249
|
// patch a running app's image tag once created, so this is
|
|
@@ -398,7 +412,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
398
412
|
const bouleGuarded = guardApp(bouleClient);
|
|
399
413
|
if (bouleGuarded)
|
|
400
414
|
plannedSteps.push(bouleGuarded);
|
|
401
|
-
const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null);
|
|
415
|
+
const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null, platformReadiness);
|
|
402
416
|
if (boule.step)
|
|
403
417
|
plannedSteps.push(boule.step);
|
|
404
418
|
// After the apps and Boule -- an edge key needs its app recorded or planned,
|
|
@@ -468,6 +482,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
468
482
|
unmanaged.push({ description: agora.blocked, key: 'coolify:agora' });
|
|
469
483
|
if (boule.blocked)
|
|
470
484
|
unmanaged.push({ description: boule.blocked, key: 'coolify:boule' });
|
|
485
|
+
if (boule.mailBlocked) {
|
|
486
|
+
unmanaged.push({ description: boule.mailBlocked, key: 'coolify:boule:mail' });
|
|
487
|
+
}
|
|
471
488
|
// Same for an ai key minted but never delivered: planning a new mint would
|
|
472
489
|
// orphan a second live key.
|
|
473
490
|
if (aiKey.blocked)
|
|
@@ -504,6 +521,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
504
521
|
unmanaged.push({ description: calls.blocked, key: 'coolify:calls' });
|
|
505
522
|
if (chat.blocked)
|
|
506
523
|
unmanaged.push({ description: chat.blocked, key: 'coolify:chat' });
|
|
524
|
+
if (callsChat.blocked) {
|
|
525
|
+
unmanaged.push({ description: callsChat.blocked, key: 'coolify:calls-chat' });
|
|
526
|
+
}
|
|
507
527
|
if (callsService.blocked) {
|
|
508
528
|
unmanaged.push({ description: callsService.blocked, key: 'coolify:calls-service' });
|
|
509
529
|
}
|
|
@@ -538,6 +558,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
538
558
|
plannedSteps: plannedSteps.map(withStepSystem),
|
|
539
559
|
unmanaged,
|
|
540
560
|
versionChanges,
|
|
561
|
+
orgPostgres,
|
|
541
562
|
};
|
|
542
563
|
}
|
|
543
564
|
/**
|
|
@@ -638,12 +659,20 @@ function planIssuer(org, byKey, claimed, authReady, isPlatformOrg = false) {
|
|
|
638
659
|
};
|
|
639
660
|
}
|
|
640
661
|
claimed.add(key);
|
|
662
|
+
// #517: both sides on the domain says nothing about which domain. The live
|
|
663
|
+
// issuer's recorded host differing from the declared domain's login host is
|
|
664
|
+
// a domain change, and moves the issuer too.
|
|
665
|
+
const liveHost = typeof live.config.host === 'string' ? live.config.host : null;
|
|
666
|
+
const domainMoved = Boolean(live.config.usesDomain) &&
|
|
667
|
+
desiredUsesDomain &&
|
|
668
|
+
Boolean(org.domain) &&
|
|
669
|
+
liveHost !== null &&
|
|
670
|
+
liveHost !== loginHostnameFor(org);
|
|
641
671
|
// The platform's own org already signs in on the shared host: no move. A
|
|
642
|
-
// live issuer that does not say
|
|
643
|
-
// moves one) is not read as a different one either.
|
|
672
|
+
// live issuer that does not say is not read as a different one either.
|
|
644
673
|
if (isPlatformOrg ||
|
|
645
674
|
live.config.usesDomain === undefined ||
|
|
646
|
-
Boolean(live.config.usesDomain) === desiredUsesDomain) {
|
|
675
|
+
(Boolean(live.config.usesDomain) === desiredUsesDomain && !domainMoved)) {
|
|
647
676
|
return { step: null, blocked: null };
|
|
648
677
|
}
|
|
649
678
|
const domain = org.domain ?? 'the org domain';
|
|
@@ -922,6 +951,50 @@ function loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned)
|
|
|
922
951
|
}
|
|
923
952
|
return null;
|
|
924
953
|
}
|
|
954
|
+
/**
|
|
955
|
+
* Whether `org` runs the service {@link schema} belongs to, so
|
|
956
|
+
* {@link planOrgPostgres} reports a schema missing only once the org would
|
|
957
|
+
* actually need it -- CMS's, chat's and calls' entries follow their own
|
|
958
|
+
* on/off switch, the same ones this file already gates their own
|
|
959
|
+
* `coolify:*` steps on (`org.ts`'s `SiteConfig.enabled`, `ChatConfig.enabled`,
|
|
960
|
+
* `CallsConfig.enabled`); Boule's is unconditional, matching `org.ts`'s own
|
|
961
|
+
* "every org gets one" rule; the app schema follows whether any
|
|
962
|
+
* `hosting.entries` entry declares `database: true` (`coolifyDatabase`'s own
|
|
963
|
+
* gate) rather than `hosting.enabled` alone, since a hosting org with no
|
|
964
|
+
* database-bearing entry has nothing for this schema to hold.
|
|
965
|
+
*/
|
|
966
|
+
function orgServiceSchemaEnabled(org, schema) {
|
|
967
|
+
if (schema.schema === 'cms')
|
|
968
|
+
return org.site.enabled;
|
|
969
|
+
if (schema.schema.startsWith('chat_'))
|
|
970
|
+
return org.chat.enabled;
|
|
971
|
+
if (schema.schema.startsWith('calls_'))
|
|
972
|
+
return org.calls.enabled;
|
|
973
|
+
if (schema.schema === 'app')
|
|
974
|
+
return org.hosting.entries.some((entry) => entry.database);
|
|
975
|
+
return true;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* #444a: reports the org's one shared Postgres server as missing until
|
|
979
|
+
* `recordedIds` says otherwise, and its fixed schema topology as missing for
|
|
980
|
+
* whichever schemas this org actually runs and has not yet recorded (see
|
|
981
|
+
* {@link orgServiceSchemaEnabled}) -- see {@link OrgPostgresPlan}'s own doc
|
|
982
|
+
* comment for why this is a `PlanDetail` field rather than `plannedSteps`
|
|
983
|
+
* entries. Nothing here is ever blocked (rule 2 does not apply: unlike chat
|
|
984
|
+
* or calls, the server and its schemas need no issuer, no ZITADEL org and no
|
|
985
|
+
* platform readiness to exist), and the server being missing never gates a
|
|
986
|
+
* schema being reported missing too -- a fresh org with every feature on
|
|
987
|
+
* reports both the same way #444a's own acceptance line asks for ("a fresh
|
|
988
|
+
* org plans one server and N schemas"), since nothing here actually depends
|
|
989
|
+
* on the server existing yet; that ordering is 444b's concern, once
|
|
990
|
+
* `plan/executor.ts` has something to run.
|
|
991
|
+
*/
|
|
992
|
+
function planOrgPostgres(org, recordedIds) {
|
|
993
|
+
return {
|
|
994
|
+
serverMissing: !recordedIds[ORG_POSTGRES_SERVER_SCOPE_KEY],
|
|
995
|
+
missingSchemas: ORG_SERVICE_SCHEMAS.filter((schema) => orgServiceSchemaEnabled(org, schema) && !recordedIds[schema.scopeKey]),
|
|
996
|
+
};
|
|
997
|
+
}
|
|
925
998
|
/** #230: restated from `executor.ts`'s `MAIL_ROLE_MAILBOXES_SCOPE_KEY`, same rule: comma-joined local parts already created. */
|
|
926
999
|
const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
|
|
927
1000
|
/** Restated from `executor.ts`, the same rule: the app id of the org tunnel's cloudflared connector. */
|
|
@@ -1257,13 +1330,22 @@ function planChat(org, byKey, claimed, recordedIds, reading, issuerPlanned) {
|
|
|
1257
1330
|
}
|
|
1258
1331
|
return none;
|
|
1259
1332
|
}
|
|
1260
|
-
const domain = org.domain ?? 'its domain';
|
|
1261
1333
|
if (!liveUsesDomain && desiredUsesDomain) {
|
|
1334
|
+
// #463: `usesDomain` is never fired by `domain` alone (#7 amendment 1),
|
|
1335
|
+
// but it can still be turned on with no domain recorded -- the step
|
|
1336
|
+
// itself has nowhere to re-key onto, so this is blocked rather than
|
|
1337
|
+
// planned (rule 2), not left for `coolifyChatRekey` to throw on.
|
|
1338
|
+
if (!org.domain) {
|
|
1339
|
+
return {
|
|
1340
|
+
step: null,
|
|
1341
|
+
blocked: `${org.slug}'s chat is blocked, not planned: usesDomain is on but the org has no domain to re-key onto`,
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1262
1344
|
return {
|
|
1263
1345
|
step: {
|
|
1264
1346
|
action: { system: 'coolify', kind: 'chat', key: org.id, desired: { usesDomain: true } },
|
|
1265
1347
|
step: {
|
|
1266
|
-
description: `re-key ${org.slug}'s chat onto ${domain}, copying rooms and messages so history reads continuous`,
|
|
1348
|
+
description: `re-key ${org.slug}'s chat onto ${org.domain}, copying rooms and messages so history reads continuous`,
|
|
1267
1349
|
destroys: true,
|
|
1268
1350
|
migrates: true,
|
|
1269
1351
|
},
|
|
@@ -1428,6 +1510,79 @@ function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlo
|
|
|
1428
1510
|
}
|
|
1429
1511
|
return { step: null, blocked: null };
|
|
1430
1512
|
}
|
|
1513
|
+
/**
|
|
1514
|
+
* The token that lets the org's calls service ask its chat who is in a room
|
|
1515
|
+
* (`executor.ts`'s `coolifyCallsChat`). Chat off: nothing, unless the calls
|
|
1516
|
+
* service still carries the address, which is then removed so it does not
|
|
1517
|
+
* point at a chat that is gone. Both on: planned until the token is delivered
|
|
1518
|
+
* and both apps run with it, also for an org whose chat and calls service
|
|
1519
|
+
* already exist. An undelivered token is reported, never minted again (rule 1).
|
|
1520
|
+
* Silent while chat or the calls service is itself planned or blocked: they
|
|
1521
|
+
* report their own.
|
|
1522
|
+
*/
|
|
1523
|
+
function planCallsChat(org, recordedIds, upstreamPlanned, upstreamBlocked) {
|
|
1524
|
+
const none = { step: null, blocked: null };
|
|
1525
|
+
if (!org.calls.enabled)
|
|
1526
|
+
return none;
|
|
1527
|
+
const action = (desired) => ({
|
|
1528
|
+
system: 'coolify',
|
|
1529
|
+
kind: 'calls-chat',
|
|
1530
|
+
key: org.id,
|
|
1531
|
+
desired,
|
|
1532
|
+
});
|
|
1533
|
+
const callsServiceId = recordedIds[`coolify:calls-service:${org.id}`];
|
|
1534
|
+
if (!org.chat.enabled) {
|
|
1535
|
+
if (!callsServiceId || !recordedIds[CHAT_CALLS_APPLIED_SCOPE_KEY])
|
|
1536
|
+
return none;
|
|
1537
|
+
return {
|
|
1538
|
+
step: {
|
|
1539
|
+
action: action({ remove: true }),
|
|
1540
|
+
step: {
|
|
1541
|
+
description: `remove the chat address and token from ${org.slug}'s calls service (chat is off) and restart it`,
|
|
1542
|
+
destroys: false,
|
|
1543
|
+
migrates: false,
|
|
1544
|
+
},
|
|
1545
|
+
},
|
|
1546
|
+
blocked: null,
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
const tokenId = recordedIds[CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY];
|
|
1550
|
+
if (tokenId && !recordedIds[CHAT_CALLS_TOKEN_SCOPE_KEY]) {
|
|
1551
|
+
return {
|
|
1552
|
+
step: null,
|
|
1553
|
+
blocked: `${org.slug}'s calls-to-chat token is blocked, not planned: token ${tokenId} was minted but never written to both apps; remove the recorded key ${CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY} to write it again (chat-service's copy is reused, none is minted)`,
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
const guard = recordedIds[CHAT_CALLS_TOKEN_SCOPE_KEY];
|
|
1557
|
+
if (guard &&
|
|
1558
|
+
recordedIds[CHAT_CALLS_APPLIED_SCOPE_KEY] ===
|
|
1559
|
+
chatCallsApplied(guard, chatUpstreams(org.slug).chat)) {
|
|
1560
|
+
return none;
|
|
1561
|
+
}
|
|
1562
|
+
const ready = chatHasUpstreams(org, recordedIds) &&
|
|
1563
|
+
callsServiceId &&
|
|
1564
|
+
callsServiceHasName(org, recordedIds) &&
|
|
1565
|
+
recordedIds[CALLS_SERVICE_DEPLOYED_SCOPE_KEY];
|
|
1566
|
+
if (!ready && upstreamBlocked)
|
|
1567
|
+
return none;
|
|
1568
|
+
if (!ready && !upstreamPlanned) {
|
|
1569
|
+
return {
|
|
1570
|
+
step: null,
|
|
1571
|
+
blocked: `${org.slug}'s calls-to-chat token is blocked, not planned: chat or the calls service is not provisioned yet (apply them first)`,
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
return {
|
|
1575
|
+
step: {
|
|
1576
|
+
action: action({}),
|
|
1577
|
+
step: {
|
|
1578
|
+
description: `give ${org.slug}'s chat service and calls service one internal token, point the calls service at chat, and redeploy both`,
|
|
1579
|
+
destroys: false,
|
|
1580
|
+
migrates: false,
|
|
1581
|
+
},
|
|
1582
|
+
},
|
|
1583
|
+
blocked: null,
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1431
1586
|
/**
|
|
1432
1587
|
* Deduped `ai.*` permissions offered to every organisation by default
|
|
1433
1588
|
* (Decision, this campaign's report): the minimum set that lets an org
|
|
@@ -2027,7 +2182,7 @@ function planBouleClient(org, byKey, claimed) {
|
|
|
2027
2182
|
* address (the one Agora signs in at); without it the Boule is blocked, not
|
|
2028
2183
|
* planned, so every other step still applies.
|
|
2029
2184
|
*/
|
|
2030
|
-
function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl) {
|
|
2185
|
+
function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl, readiness) {
|
|
2031
2186
|
const key = resourceKey('coolify', 'boule', org.id);
|
|
2032
2187
|
const live = byKey.get(key);
|
|
2033
2188
|
if (live)
|
|
@@ -2042,26 +2197,59 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
|
|
|
2042
2197
|
if (tooLong.length > 0) {
|
|
2043
2198
|
return {
|
|
2044
2199
|
step: null,
|
|
2200
|
+
mailBlocked: null,
|
|
2045
2201
|
blocked: `${org.slug}'s Boule is blocked, not planned: ${tooLong.map(([n, what]) => `${what} '${n}' is over 63 characters`).join(', ')}; shorten the slug`,
|
|
2046
2202
|
};
|
|
2047
2203
|
}
|
|
2048
2204
|
if (otherSlugs.includes(`${org.slug}-boule`)) {
|
|
2049
2205
|
return {
|
|
2050
2206
|
step: null,
|
|
2207
|
+
mailBlocked: null,
|
|
2051
2208
|
blocked: `${org.slug}'s Boule is blocked, not planned: another declared org has the slug '${org.slug}-boule', which is this Boule's hostname`,
|
|
2052
2209
|
};
|
|
2053
2210
|
}
|
|
2054
2211
|
if (!issuer) {
|
|
2055
2212
|
return {
|
|
2056
2213
|
step: null,
|
|
2214
|
+
mailBlocked: null,
|
|
2057
2215
|
blocked: `${org.slug}'s Boule is blocked, not planned: the host supplies no issuer`,
|
|
2058
2216
|
};
|
|
2059
2217
|
}
|
|
2218
|
+
// Mail env, only for a hosted-mail org whose domain is recorded. Blocked,
|
|
2219
|
+
// not failing: while the platform cannot yet supply the token, Boule is
|
|
2220
|
+
// planned without mail and keeps showing the invite link.
|
|
2221
|
+
const mailWanted = bouleMailWanted(org, recordedIds);
|
|
2222
|
+
const mailDomain = recordedIds.mail_domain?.toLowerCase();
|
|
2223
|
+
const isApexOrg = Boolean(mailDomain && readiness.platformApex && mailDomain === readiness.platformApex.toLowerCase());
|
|
2224
|
+
let mailBlockedReason = null;
|
|
2225
|
+
if (mailWanted && isApexOrg) {
|
|
2226
|
+
if (!readiness.platformLettermintProjectId) {
|
|
2227
|
+
mailBlockedReason = 'platform.mail.lettermintProjectId is not declared yet';
|
|
2228
|
+
}
|
|
2229
|
+
else if (readiness.mailKeyHeld !== true) {
|
|
2230
|
+
mailBlockedReason = `the platform's Lettermint key is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`;
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
else if (mailWanted) {
|
|
2234
|
+
mailBlockedReason = edgeNeeded(readiness, ['zones', 'dns']);
|
|
2235
|
+
}
|
|
2236
|
+
const mailBlocked = mailBlockedReason
|
|
2237
|
+
? `${org.slug}'s Boule mail is blocked, not planned: ${mailBlockedReason}`
|
|
2238
|
+
: null;
|
|
2239
|
+
const mailOn = mailWanted && !mailBlocked;
|
|
2060
2240
|
const action = {
|
|
2061
2241
|
system: 'coolify',
|
|
2062
2242
|
kind: 'boule',
|
|
2063
2243
|
key: org.id,
|
|
2064
|
-
|
|
2244
|
+
// The platform values go in like the app step's: the executor writes
|
|
2245
|
+
// what the plan showed and refuses a plan that carries no `platformApex`.
|
|
2246
|
+
desired: {
|
|
2247
|
+
issuer,
|
|
2248
|
+
archonUrl,
|
|
2249
|
+
platformApex: readiness.platformApex,
|
|
2250
|
+
platformLettermintProjectId: readiness.platformLettermintProjectId,
|
|
2251
|
+
mailBlocked: mailBlocked !== null,
|
|
2252
|
+
},
|
|
2065
2253
|
};
|
|
2066
2254
|
if (!live) {
|
|
2067
2255
|
return {
|
|
@@ -2070,13 +2258,19 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
|
|
|
2070
2258
|
step: { description: `provision ${org.slug}'s Boule`, destroys: false, migrates: false },
|
|
2071
2259
|
},
|
|
2072
2260
|
blocked: null,
|
|
2261
|
+
mailBlocked,
|
|
2073
2262
|
};
|
|
2074
2263
|
}
|
|
2075
2264
|
const liveEnv = (live.config.env ?? {});
|
|
2076
|
-
const { env } = desiredBouleEnv(org, recordedIds, issuer, archonUrl);
|
|
2265
|
+
const { env } = desiredBouleEnv(org, recordedIds, issuer, archonUrl, mailOn);
|
|
2077
2266
|
const drift = bouleEnvDrift(env, liveEnv);
|
|
2267
|
+
// The token is compared, never planned with its value.
|
|
2268
|
+
if (mailOn &&
|
|
2269
|
+
bouleMailTokenDrift(liveEnv[BOULE_MAIL_TOKEN_ENV], live.config.appMailToken)) {
|
|
2270
|
+
drift.push(BOULE_MAIL_TOKEN_ENV);
|
|
2271
|
+
}
|
|
2078
2272
|
if (drift.length === 0)
|
|
2079
|
-
return { step: null, blocked: null };
|
|
2273
|
+
return { step: null, blocked: null, mailBlocked };
|
|
2080
2274
|
return {
|
|
2081
2275
|
step: {
|
|
2082
2276
|
action,
|
|
@@ -2087,6 +2281,7 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
|
|
|
2087
2281
|
},
|
|
2088
2282
|
},
|
|
2089
2283
|
blocked: null,
|
|
2284
|
+
mailBlocked,
|
|
2090
2285
|
};
|
|
2091
2286
|
}
|
|
2092
2287
|
/**
|