@wtfalch/foundry 0.16.4 → 0.16.6
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/org.js +5 -0
- package/dist/plan/boule.d.ts +67 -0
- package/dist/plan/boule.js +126 -0
- package/dist/plan/diff.d.ts +2 -0
- package/dist/plan/diff.js +190 -0
- package/dist/plan/executor.d.ts +40 -0
- package/dist/plan/executor.js +162 -12
- package/dist/plan/hostnames.d.ts +2 -2
- package/dist/plan/hostnames.js +5 -2
- package/dist/plan/locked-hostnames.d.ts +1 -1
- package/dist/plan/locked-hostnames.js +3 -1
- package/dist/plan/plan-apply.js +2 -0
- package/dist/plan/platform-diff.d.ts +7 -0
- package/dist/plan/platform-diff.js +7 -0
- package/dist/plan/platform-providers.d.ts +14 -0
- package/dist/plan/platform-providers.js +123 -10
- package/dist/plan/providers-readers.js +21 -1
- package/dist/slug.js +10 -2
- package/package.json +1 -1
package/dist/org.js
CHANGED
|
@@ -82,6 +82,10 @@ export function validateHosting(hosting, names) {
|
|
|
82
82
|
if (app.name === 'agora') {
|
|
83
83
|
return `issuerApps '${app.name}' is reserved for the always-on Agora client every org gets`;
|
|
84
84
|
}
|
|
85
|
+
// #161: the same for the always-on Boule client.
|
|
86
|
+
if (app.name === 'boule') {
|
|
87
|
+
return `issuerApps '${app.name}' is reserved for the always-on Boule client every org gets`;
|
|
88
|
+
}
|
|
85
89
|
}
|
|
86
90
|
if (hosting.enabled && !appEntry(hosting)) {
|
|
87
91
|
return `hosting.enabled requires an entry keyed '${APP_ENTRY_KEY}'`;
|
|
@@ -256,6 +260,7 @@ function validateEntryAuth(entry, hosting, domain) {
|
|
|
256
260
|
return `${at} needs an exact hostname to sign in at`;
|
|
257
261
|
}
|
|
258
262
|
if (entry.key === 'agora' ||
|
|
263
|
+
entry.key === 'boule' ||
|
|
259
264
|
entry.key === 'cms' ||
|
|
260
265
|
hosting.issuerApps.some((app) => app.name === entry.key)) {
|
|
261
266
|
return `${at} client name '${entry.key}' is already held by another ZITADEL client`;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { BrokerEntryInput, BrokerEntryWant } from './broker-entries.js';
|
|
3
|
+
/**
|
|
4
|
+
* #161 (Boule part): what both the planner (`diff.ts`) and the executor
|
|
5
|
+
* (`executor.ts`) need to agree on for an org's Boule -- the hostname, the
|
|
6
|
+
* recorded ids and the env vars those produce -- so the plan can compare the
|
|
7
|
+
* live app with exactly what the executor would write. The same split as
|
|
8
|
+
* `agora.ts`, for the same reason.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The fixed `boule` ZITADEL web client's `clientId`, recorded by
|
|
12
|
+
* `executor.ts`'s `zitadelApp` and read by `coolifyBoule` for
|
|
13
|
+
* `AUTH_CLIENT_ID`. Public, not a secret, so a flat scope id like
|
|
14
|
+
* `agora_client_id`.
|
|
15
|
+
*/
|
|
16
|
+
export declare const BOULE_CLIENT_ID_SCOPE_KEY = "boule_client_id";
|
|
17
|
+
/**
|
|
18
|
+
* The Boule database's Coolify uuid. It has no `system:kind:key` slot of its
|
|
19
|
+
* own (the step's one auto-recorded `resourceId` is the app), so it is a flat
|
|
20
|
+
* scope id, like the cms and calls databases.
|
|
21
|
+
*/
|
|
22
|
+
export declare const BOULE_DATABASE_SCOPE_KEY = "coolify_boule_database";
|
|
23
|
+
/**
|
|
24
|
+
* `boule.<domain>` when the org has a domain, `<slug>-boule.oikos.host`
|
|
25
|
+
* otherwise (the tier rule of boule#128 and foundry#147; `../slug.ts`'s
|
|
26
|
+
* `BOULE_SUFFIX` reserves the collision). A subdomain, not a path: an org's
|
|
27
|
+
* Boule is its own deploy, a separate origin and a separate application at the
|
|
28
|
+
* issuer.
|
|
29
|
+
*/
|
|
30
|
+
export declare function bouleHostnameFor(org: DeclaredOrg): string;
|
|
31
|
+
/** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
|
|
32
|
+
export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR"];
|
|
33
|
+
/**
|
|
34
|
+
* The env vars an org's Boule app should carry, and every input still missing
|
|
35
|
+
* to build them. Callers that write must refuse on a non-empty `missing`: a
|
|
36
|
+
* Boule with a blank client id fails every sign-in. `issuer` is the host's
|
|
37
|
+
* issuer address (`AgoraHostValues.issuer`), the same one Agora signs in at.
|
|
38
|
+
*
|
|
39
|
+
* `AUTHZ_BOOTSTRAP_OPERATOR` is the declared first owner's ZITADEL user id
|
|
40
|
+
* (the subject Boule's `bootstrapOperator` compares with the signed-in user's
|
|
41
|
+
* id, never an email), present once `zitadel:owner` has recorded it. It is not
|
|
42
|
+
* `missing` while absent: Boule runs without an operator until then.
|
|
43
|
+
*
|
|
44
|
+
* Not here, and written by the executor instead: the database URLs
|
|
45
|
+
* (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
|
|
46
|
+
* (generated once, never rotated).
|
|
47
|
+
*/
|
|
48
|
+
export declare function desiredBouleEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, issuer: string | null): {
|
|
49
|
+
env: Record<string, string>;
|
|
50
|
+
missing: string[];
|
|
51
|
+
};
|
|
52
|
+
/** The `AUTH_APPS` name of an org's Boule broker entry, and the scope key suffix it records under. */
|
|
53
|
+
export declare const BOULE_BROKER_ENTRY_KEY = "boule";
|
|
54
|
+
export declare function bouleBrokerEntryName(org: DeclaredOrg): string;
|
|
55
|
+
/**
|
|
56
|
+
* What the org's Boule broker entry must say (the same shape a hosting entry
|
|
57
|
+
* with `uses.auth` gets, `broker-entries.ts`'s `wantedBrokerEntry`): staff
|
|
58
|
+
* sign in, no sign-up, the one origin of the Boule hostname. Or the inputs
|
|
59
|
+
* still missing, each with the step that records it.
|
|
60
|
+
*/
|
|
61
|
+
export declare function wantedBouleBrokerEntry(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): {
|
|
62
|
+
want: BrokerEntryWant;
|
|
63
|
+
} | {
|
|
64
|
+
missing: BrokerEntryInput[];
|
|
65
|
+
};
|
|
66
|
+
/** The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored. */
|
|
67
|
+
export declare function bouleEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
|
|
2
|
+
/**
|
|
3
|
+
* #161 (Boule part): what both the planner (`diff.ts`) and the executor
|
|
4
|
+
* (`executor.ts`) need to agree on for an org's Boule -- the hostname, the
|
|
5
|
+
* recorded ids and the env vars those produce -- so the plan can compare the
|
|
6
|
+
* live app with exactly what the executor would write. The same split as
|
|
7
|
+
* `agora.ts`, for the same reason.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The fixed `boule` ZITADEL web client's `clientId`, recorded by
|
|
11
|
+
* `executor.ts`'s `zitadelApp` and read by `coolifyBoule` for
|
|
12
|
+
* `AUTH_CLIENT_ID`. Public, not a secret, so a flat scope id like
|
|
13
|
+
* `agora_client_id`.
|
|
14
|
+
*/
|
|
15
|
+
export const BOULE_CLIENT_ID_SCOPE_KEY = 'boule_client_id';
|
|
16
|
+
/**
|
|
17
|
+
* The Boule database's Coolify uuid. It has no `system:kind:key` slot of its
|
|
18
|
+
* own (the step's one auto-recorded `resourceId` is the app), so it is a flat
|
|
19
|
+
* scope id, like the cms and calls databases.
|
|
20
|
+
*/
|
|
21
|
+
export const BOULE_DATABASE_SCOPE_KEY = 'coolify_boule_database';
|
|
22
|
+
/**
|
|
23
|
+
* `boule.<domain>` when the org has a domain, `<slug>-boule.oikos.host`
|
|
24
|
+
* otherwise (the tier rule of boule#128 and foundry#147; `../slug.ts`'s
|
|
25
|
+
* `BOULE_SUFFIX` reserves the collision). A subdomain, not a path: an org's
|
|
26
|
+
* Boule is its own deploy, a separate origin and a separate application at the
|
|
27
|
+
* issuer.
|
|
28
|
+
*/
|
|
29
|
+
export function bouleHostnameFor(org) {
|
|
30
|
+
return org.domain ? `boule.${org.domain}` : `${org.slug}-boule.oikos.host`;
|
|
31
|
+
}
|
|
32
|
+
/** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
|
|
33
|
+
export const BOULE_MANAGED_ENV_KEYS = [
|
|
34
|
+
'APP_URL',
|
|
35
|
+
'AUTH_ISSUER',
|
|
36
|
+
'AUTH_ORGANIZATION_ID',
|
|
37
|
+
'AUTH_CLIENT_ID',
|
|
38
|
+
'BOULE_ORG_NAME',
|
|
39
|
+
'AUTHZ_BOOTSTRAP_OPERATOR',
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* The env vars an org's Boule app should carry, and every input still missing
|
|
43
|
+
* to build them. Callers that write must refuse on a non-empty `missing`: a
|
|
44
|
+
* Boule with a blank client id fails every sign-in. `issuer` is the host's
|
|
45
|
+
* issuer address (`AgoraHostValues.issuer`), the same one Agora signs in at.
|
|
46
|
+
*
|
|
47
|
+
* `AUTHZ_BOOTSTRAP_OPERATOR` is the declared first owner's ZITADEL user id
|
|
48
|
+
* (the subject Boule's `bootstrapOperator` compares with the signed-in user's
|
|
49
|
+
* id, never an email), present once `zitadel:owner` has recorded it. It is not
|
|
50
|
+
* `missing` while absent: Boule runs without an operator until then.
|
|
51
|
+
*
|
|
52
|
+
* Not here, and written by the executor instead: the database URLs
|
|
53
|
+
* (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
|
|
54
|
+
* (generated once, never rotated).
|
|
55
|
+
*/
|
|
56
|
+
export function desiredBouleEnv(org, recorded, issuer) {
|
|
57
|
+
const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
58
|
+
const clientId = recorded[BOULE_CLIENT_ID_SCOPE_KEY];
|
|
59
|
+
const missing = [];
|
|
60
|
+
if (!issuer)
|
|
61
|
+
missing.push('issuer');
|
|
62
|
+
if (!zitadelOrgId)
|
|
63
|
+
missing.push('zitadelOrgId');
|
|
64
|
+
if (!clientId)
|
|
65
|
+
missing.push('clientId');
|
|
66
|
+
const env = {
|
|
67
|
+
APP_URL: `https://${bouleHostnameFor(org)}`,
|
|
68
|
+
BOULE_ORG_NAME: org.displayName,
|
|
69
|
+
};
|
|
70
|
+
if (issuer)
|
|
71
|
+
env.AUTH_ISSUER = issuer;
|
|
72
|
+
if (zitadelOrgId)
|
|
73
|
+
env.AUTH_ORGANIZATION_ID = zitadelOrgId;
|
|
74
|
+
if (clientId)
|
|
75
|
+
env.AUTH_CLIENT_ID = clientId;
|
|
76
|
+
const owner = org.owners[0];
|
|
77
|
+
const ownerUserId = owner
|
|
78
|
+
? recorded[`zitadel:owner:${org.id}:${owner.email.toLowerCase()}`]
|
|
79
|
+
: undefined;
|
|
80
|
+
if (ownerUserId)
|
|
81
|
+
env.AUTHZ_BOOTSTRAP_OPERATOR = ownerUserId;
|
|
82
|
+
return { env, missing };
|
|
83
|
+
}
|
|
84
|
+
/** The `AUTH_APPS` name of an org's Boule broker entry, and the scope key suffix it records under. */
|
|
85
|
+
export const BOULE_BROKER_ENTRY_KEY = 'boule';
|
|
86
|
+
export function bouleBrokerEntryName(org) {
|
|
87
|
+
return `${org.slug}-boule`;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* What the org's Boule broker entry must say (the same shape a hosting entry
|
|
91
|
+
* with `uses.auth` gets, `broker-entries.ts`'s `wantedBrokerEntry`): staff
|
|
92
|
+
* sign in, no sign-up, the one origin of the Boule hostname. Or the inputs
|
|
93
|
+
* still missing, each with the step that records it.
|
|
94
|
+
*/
|
|
95
|
+
export function wantedBouleBrokerEntry(org, recorded) {
|
|
96
|
+
const organizationId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
97
|
+
const clientId = recorded[BOULE_CLIENT_ID_SCOPE_KEY];
|
|
98
|
+
const mailDomain = recorded.mail_domain;
|
|
99
|
+
const missing = [];
|
|
100
|
+
if (!organizationId) {
|
|
101
|
+
missing.push({ what: "the org's ZITADEL org id", producedBy: ['zitadel', 'issuer'] });
|
|
102
|
+
}
|
|
103
|
+
if (!clientId)
|
|
104
|
+
missing.push({ what: 'its sign-in client id', producedBy: ['zitadel', 'app'] });
|
|
105
|
+
if (!mailDomain)
|
|
106
|
+
missing.push({ what: 'the org mail domain', producedBy: ['coolify', 'mail'] });
|
|
107
|
+
if (!organizationId || !clientId || !mailDomain)
|
|
108
|
+
return { missing };
|
|
109
|
+
return {
|
|
110
|
+
want: {
|
|
111
|
+
name: bouleBrokerEntryName(org),
|
|
112
|
+
organizationId,
|
|
113
|
+
clientId,
|
|
114
|
+
origins: [`https://${bouleHostnameFor(org)}`],
|
|
115
|
+
from: `${org.mail.noReplyLocalPart || 'noreply'}@${mailDomain}`,
|
|
116
|
+
registration: false,
|
|
117
|
+
replaces: null,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored. */
|
|
122
|
+
export function bouleEnvDrift(desired, live) {
|
|
123
|
+
return Object.entries(desired)
|
|
124
|
+
.filter(([key, value]) => live[key] !== value)
|
|
125
|
+
.map(([key]) => key);
|
|
126
|
+
}
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface PlanOrgOptions {
|
|
|
36
36
|
loginHostBindings?: boolean;
|
|
37
37
|
/** F5: the executor was built with a `broker` port. */
|
|
38
38
|
broker?: boolean;
|
|
39
|
+
/** #161: the slugs of the other declared orgs, so a Boule whose hostname another org's slug would take is blocked. */
|
|
40
|
+
otherSlugs?: readonly string[];
|
|
39
41
|
/** F6a, F6b: the clock `planEntryAiKeys` and `planFilesKeys` read for the renewal window. Default `Date.now`. */
|
|
40
42
|
now?: () => number;
|
|
41
43
|
}
|
package/dist/plan/diff.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
2
|
import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
|
|
3
3
|
import { CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
4
|
+
import { BOULE_BROKER_ENTRY_KEY, bouleEnvDrift, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
4
5
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
|
|
5
6
|
import { planEntryAiKeys } from './entry-ai-key.js';
|
|
6
7
|
import { planFilesKeys } from './entry-files-key.js';
|
|
@@ -235,6 +236,20 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
235
236
|
const agora = planAgora(org, byKey, claimed, recordedIds, agoraHost);
|
|
236
237
|
if (agora.step)
|
|
237
238
|
plannedSteps.push(agora.step);
|
|
239
|
+
// #161 (Boule part): every org's own Boule -- always on, no toggle, the same
|
|
240
|
+
// shape as Agora and planned right after it. Its fixed ZITADEL client first,
|
|
241
|
+
// for the same reason: `coolifyBoule` reads that step's recorded client id.
|
|
242
|
+
const bouleClient = planBouleClient(org, byKey, claimed);
|
|
243
|
+
if (bouleClient)
|
|
244
|
+
plannedSteps.push(bouleClient);
|
|
245
|
+
const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? []);
|
|
246
|
+
if (boule.step)
|
|
247
|
+
plannedSteps.push(boule.step);
|
|
248
|
+
// Boule's own broker entry, after its app: the entry needs the AUTH_APP_KEY
|
|
249
|
+
// that step puts in the app's env.
|
|
250
|
+
const bouleBroker = planBouleBrokerEntry(org, recordedIds, plannedSteps, options.broker ?? false);
|
|
251
|
+
if (bouleBroker.step)
|
|
252
|
+
plannedSteps.push(bouleBroker.step);
|
|
238
253
|
// #173: register the org's own app with the platform's jobs scheduler.
|
|
239
254
|
// After Agora, so a failing registration can never stop Agora from
|
|
240
255
|
// provisioning (`runSteps` stops at the first failing step).
|
|
@@ -253,6 +268,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
253
268
|
// item list, so it rides along with the unmanaged items.
|
|
254
269
|
if (agora.blocked)
|
|
255
270
|
unmanaged.push({ description: agora.blocked, key: 'coolify:agora' });
|
|
271
|
+
if (boule.blocked)
|
|
272
|
+
unmanaged.push({ description: boule.blocked, key: 'coolify:boule' });
|
|
256
273
|
// Same for an ai key minted but never delivered: planning a new mint would
|
|
257
274
|
// orphan a second live key.
|
|
258
275
|
if (aiKey.blocked)
|
|
@@ -307,6 +324,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
307
324
|
// #243: an appEnv source nothing will record is reported, not silently skipped.
|
|
308
325
|
unmanaged.push(...app.blocked);
|
|
309
326
|
unmanaged.push(...brokerEntries.blocked);
|
|
327
|
+
unmanaged.push(...bouleBroker.blocked);
|
|
310
328
|
return { orgId: org.id, plannedSteps, unmanaged, versionChanges };
|
|
311
329
|
}
|
|
312
330
|
/**
|
|
@@ -1434,6 +1452,114 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
|
|
|
1434
1452
|
blocked: null,
|
|
1435
1453
|
};
|
|
1436
1454
|
}
|
|
1455
|
+
/**
|
|
1456
|
+
* #161 (Boule part): every org gets one fixed ZITADEL web client named
|
|
1457
|
+
* `boule`, so its Boule (https://github.com/wtfalch/boule) can sign anyone in.
|
|
1458
|
+
* The same shape as {@link planAgoraClient}: provision when missing, recreate
|
|
1459
|
+
* when the live `appType` differs, nothing otherwise.
|
|
1460
|
+
*/
|
|
1461
|
+
function planBouleClient(org, byKey, claimed) {
|
|
1462
|
+
const key = resourceKey('zitadel', 'app', 'boule');
|
|
1463
|
+
const live = byKey.get(key);
|
|
1464
|
+
if (live)
|
|
1465
|
+
claimed.add(key);
|
|
1466
|
+
const desired = { appType: 'web' };
|
|
1467
|
+
if (!live) {
|
|
1468
|
+
return {
|
|
1469
|
+
action: { system: 'zitadel', kind: 'app', key: 'boule', desired },
|
|
1470
|
+
step: {
|
|
1471
|
+
description: `provision the web ZITADEL app 'boule' for ${org.slug}'s Boule sign-in`,
|
|
1472
|
+
destroys: false,
|
|
1473
|
+
migrates: false,
|
|
1474
|
+
},
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
if (live.config.appType === 'web')
|
|
1478
|
+
return null;
|
|
1479
|
+
return {
|
|
1480
|
+
action: { system: 'zitadel', kind: 'app', key: 'boule', desired },
|
|
1481
|
+
step: {
|
|
1482
|
+
description: `recreate ZITADEL app 'boule' for ${org.slug} as web (was ${String(live.config.appType)})`,
|
|
1483
|
+
destroys: true,
|
|
1484
|
+
migrates: false,
|
|
1485
|
+
},
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* #161 (Boule part): the org's own Boule, a per-org singleton keyed by the org
|
|
1490
|
+
* id under its own `boule` kind, a separate Coolify application from the org's
|
|
1491
|
+
* hosted app and from Agora. Built from https://github.com/wtfalch/boule's
|
|
1492
|
+
* root Dockerfile (`executor.ts`'s `coolifyBoule`), with a database of its
|
|
1493
|
+
* own, never toggled by any declared field and never parked or deleted.
|
|
1494
|
+
*
|
|
1495
|
+
* Once live, a step is planned again whenever the live app's sign-in env lacks
|
|
1496
|
+
* a desired key or holds a different value (`boule.ts`'s `desiredBouleEnv`,
|
|
1497
|
+
* the function the executor writes from). The only host value is the issuer
|
|
1498
|
+
* address (the one Agora signs in at); without it the Boule is blocked, not
|
|
1499
|
+
* planned, so every other step still applies.
|
|
1500
|
+
*/
|
|
1501
|
+
function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs) {
|
|
1502
|
+
const key = resourceKey('coolify', 'boule', org.id);
|
|
1503
|
+
const live = byKey.get(key);
|
|
1504
|
+
if (live)
|
|
1505
|
+
claimed.add(key);
|
|
1506
|
+
// Never truncated: Postgres cuts an identifier at 63 bytes and Boule finds
|
|
1507
|
+
// its runtime role as `<current_database()>_rt`, so a cut name breaks the
|
|
1508
|
+
// match; a DNS label over 63 is refused by Coolify and Cloudflare.
|
|
1509
|
+
const tooLong = [
|
|
1510
|
+
[`${org.slug}-boule`, 'the Boule host label and app name'],
|
|
1511
|
+
[`${org.slug}_boule_db_rt`, 'the Boule database role'],
|
|
1512
|
+
].filter(([name]) => name.length > 63);
|
|
1513
|
+
if (tooLong.length > 0) {
|
|
1514
|
+
return {
|
|
1515
|
+
step: null,
|
|
1516
|
+
blocked: `${org.slug}'s Boule is blocked, not planned: ${tooLong.map(([n, what]) => `${what} '${n}' is over 63 characters`).join(', ')}; shorten the slug`,
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
if (otherSlugs.includes(`${org.slug}-boule`)) {
|
|
1520
|
+
return {
|
|
1521
|
+
step: null,
|
|
1522
|
+
blocked: `${org.slug}'s Boule is blocked, not planned: another declared org has the slug '${org.slug}-boule', which is this Boule's hostname`,
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
if (!issuer) {
|
|
1526
|
+
return {
|
|
1527
|
+
step: null,
|
|
1528
|
+
blocked: `${org.slug}'s Boule is blocked, not planned: the host supplies no issuer`,
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
const action = {
|
|
1532
|
+
system: 'coolify',
|
|
1533
|
+
kind: 'boule',
|
|
1534
|
+
key: org.id,
|
|
1535
|
+
desired: { issuer },
|
|
1536
|
+
};
|
|
1537
|
+
if (!live) {
|
|
1538
|
+
return {
|
|
1539
|
+
step: {
|
|
1540
|
+
action,
|
|
1541
|
+
step: { description: `provision ${org.slug}'s Boule`, destroys: false, migrates: false },
|
|
1542
|
+
},
|
|
1543
|
+
blocked: null,
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
const liveEnv = (live.config.env ?? {});
|
|
1547
|
+
const { env } = desiredBouleEnv(org, recordedIds, issuer);
|
|
1548
|
+
const drift = bouleEnvDrift(env, liveEnv);
|
|
1549
|
+
if (drift.length === 0)
|
|
1550
|
+
return { step: null, blocked: null };
|
|
1551
|
+
return {
|
|
1552
|
+
step: {
|
|
1553
|
+
action,
|
|
1554
|
+
step: {
|
|
1555
|
+
description: `update ${org.slug}'s Boule config (${drift.join(', ')} missing or different)`,
|
|
1556
|
+
destroys: false,
|
|
1557
|
+
migrates: false,
|
|
1558
|
+
},
|
|
1559
|
+
},
|
|
1560
|
+
blocked: null,
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1437
1563
|
/**
|
|
1438
1564
|
* The shared on/off shape behind {@link planApp}, {@link planDatabase} and
|
|
1439
1565
|
* {@link planTunnel} (#71): a per-org singleton resource, keyed by the org
|
|
@@ -2094,6 +2220,70 @@ function planBrokerEntries(org, recordedIds, plannedSteps, brokerWired) {
|
|
|
2094
2220
|
}
|
|
2095
2221
|
return { steps, blocked };
|
|
2096
2222
|
}
|
|
2223
|
+
/**
|
|
2224
|
+
* #161: the org's Boule broker entry (`<slug>-boule`), planned like a hosting
|
|
2225
|
+
* entry's ({@link planBrokerEntries}): blocked while the host has not wired
|
|
2226
|
+
* the broker port, or while an input is neither recorded nor produced by a
|
|
2227
|
+
* step in this plan; nothing once the recorded marker matches what it would
|
|
2228
|
+
* write. Silent while no Boule app exists or is planned (a blocked Boule
|
|
2229
|
+
* already says why).
|
|
2230
|
+
*/
|
|
2231
|
+
function planBouleBrokerEntry(org, recordedIds, plannedSteps, brokerWired) {
|
|
2232
|
+
const at = `${org.slug}'s Boule broker entry`;
|
|
2233
|
+
const key = 'coolify:boule-broker-entry';
|
|
2234
|
+
const blocked = [];
|
|
2235
|
+
const warnings = recordedIds[brokerEntryWarningsScopeKey(BOULE_BROKER_ENTRY_KEY)];
|
|
2236
|
+
for (const name of warnings ? JSON.parse(warnings) : []) {
|
|
2237
|
+
blocked.push({
|
|
2238
|
+
description: `warning: the broker's AUTH_APPS entry '${name}' is invalid and the broker drops it; foundry leaves it as it is`,
|
|
2239
|
+
key: `${key}:invalid:${name}`,
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
const hasApp = Boolean(recordedIds[`coolify:boule:${org.id}`]) ||
|
|
2243
|
+
plannedSteps.some((p) => p.action.system === 'coolify' && p.action.kind === 'boule');
|
|
2244
|
+
if (!hasApp)
|
|
2245
|
+
return { step: null, blocked };
|
|
2246
|
+
if (!brokerWired) {
|
|
2247
|
+
blocked.push({
|
|
2248
|
+
description: `${at} is blocked, not planned: the host has not wired the broker port`,
|
|
2249
|
+
key,
|
|
2250
|
+
});
|
|
2251
|
+
return { step: null, blocked };
|
|
2252
|
+
}
|
|
2253
|
+
const wanted = wantedBouleBrokerEntry(org, recordedIds);
|
|
2254
|
+
if ('want' in wanted) {
|
|
2255
|
+
if (recordedIds[brokerEntryAppliedScopeKey(BOULE_BROKER_ENTRY_KEY)] ===
|
|
2256
|
+
JSON.stringify(wanted.want)) {
|
|
2257
|
+
return { step: null, blocked };
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
else {
|
|
2261
|
+
const unplanned = wanted.missing.filter(({ producedBy: [system, kind] }) => !plannedSteps.some((p) => p.action.system === system && p.action.kind === kind));
|
|
2262
|
+
if (unplanned.length > 0) {
|
|
2263
|
+
// With mail off nothing records a mail domain, so the Boule app is up
|
|
2264
|
+
// while nobody can sign in to it: say so rather than "not recorded yet".
|
|
2265
|
+
const noMail = org.mail.mode === 'off' && unplanned.some((m) => m.producedBy[1] === 'mail');
|
|
2266
|
+
blocked.push({
|
|
2267
|
+
description: noMail
|
|
2268
|
+
? `${at} is blocked, not planned: ${org.slug}'s Boule is deployed but sign-in is down until the org's mail records a domain`
|
|
2269
|
+
: `${at} is blocked, not planned: ${unplanned.map((m) => m.what).join(', ')} not recorded yet`,
|
|
2270
|
+
key,
|
|
2271
|
+
});
|
|
2272
|
+
return { step: null, blocked };
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
return {
|
|
2276
|
+
step: {
|
|
2277
|
+
action: { system: 'coolify', kind: 'boule-broker-entry', key: org.id, desired: {} },
|
|
2278
|
+
step: {
|
|
2279
|
+
description: `write ${at} beside the others`,
|
|
2280
|
+
destroys: false,
|
|
2281
|
+
migrates: false,
|
|
2282
|
+
},
|
|
2283
|
+
},
|
|
2284
|
+
blocked,
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2097
2287
|
/**
|
|
2098
2288
|
* Plan gap A9: an org's customers sign in through a second ZITADEL org with
|
|
2099
2289
|
* self-registration on, granted the org's app project. Planned once an entry
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -1379,6 +1379,14 @@ export declare function getDatabaseStarted(coolify: {
|
|
|
1379
1379
|
internal_db_url?: string;
|
|
1380
1380
|
status?: string;
|
|
1381
1381
|
}>;
|
|
1382
|
+
/** Timing for the wait in {@link getDatabaseStarted}; a test swaps `sleep` and `now` for a fake clock. */
|
|
1383
|
+
export declare const databaseStartWait: {
|
|
1384
|
+
sleep: (ms: number) => Promise<void>;
|
|
1385
|
+
now: () => number;
|
|
1386
|
+
pollMs: number;
|
|
1387
|
+
timeoutMs: number;
|
|
1388
|
+
graceMs: number;
|
|
1389
|
+
};
|
|
1382
1390
|
export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer {
|
|
1383
1391
|
private readonly ports;
|
|
1384
1392
|
constructor(ports: FoundryStepExecutorPorts);
|
|
@@ -1605,6 +1613,14 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1605
1613
|
* broker would drop.
|
|
1606
1614
|
*/
|
|
1607
1615
|
private coolifyBrokerEntry;
|
|
1616
|
+
/**
|
|
1617
|
+
* #161: the org's Boule broker entry, `<slug>-boule`, written the way a
|
|
1618
|
+
* hosting entry's is: with the `AUTH_APP_KEY` `coolifyBoule` put in the
|
|
1619
|
+
* Boule app's env.
|
|
1620
|
+
*/
|
|
1621
|
+
private bouleBrokerEntry;
|
|
1622
|
+
/** The shared tail of both broker-entry steps: read the app's key, write the entry, record what was written. */
|
|
1623
|
+
private writeAppBrokerEntry;
|
|
1608
1624
|
/**
|
|
1609
1625
|
* `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
|
|
1610
1626
|
* only the first time (task brief), never regenerated or overwritten, so a
|
|
@@ -1998,6 +2014,30 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1998
2014
|
* not the host values).
|
|
1999
2015
|
*/
|
|
2000
2016
|
private coolifyAgora;
|
|
2017
|
+
/**
|
|
2018
|
+
* `plan/diff.ts`'s `planBoule`: provisions the org's own Boule
|
|
2019
|
+
* (https://github.com/wtfalch/boule) as one more Coolify application, from
|
|
2020
|
+
* that repo's root Dockerfile ({@link BOULE_REPO}), with a database of its
|
|
2021
|
+
* own, and pushes its env. The same shape as {@link coolifyAgora}. There is
|
|
2022
|
+
* no park branch: Boule is always on.
|
|
2023
|
+
*
|
|
2024
|
+
* The sign-in env comes from `plan/boule.ts`'s `desiredBouleEnv`, the same
|
|
2025
|
+
* function the plan compares the live app against. The step never writes a
|
|
2026
|
+
* partial set: when the issuer (carried in the step), the org's ZITADEL org
|
|
2027
|
+
* id or the Boule client id is missing it throws naming each one, before it
|
|
2028
|
+
* creates anything.
|
|
2029
|
+
*
|
|
2030
|
+
* The database is `<slug>-boule-db`, split like the cms app's (#117, #239):
|
|
2031
|
+
* the superuser URL as `DATABASE_URL_OWNER` (Boule's boot script migrates
|
|
2032
|
+
* with it, then unsets it) and a `<dbName>_rt` role's URL as `DATABASE_URL`,
|
|
2033
|
+
* minted once and never rotated. `AUTH_COOKIE_SECRET` is generated once the
|
|
2034
|
+
* same way. A running app is restarted when its sign-in env or database URLs
|
|
2035
|
+
* changed, since a container reads its env only at start.
|
|
2036
|
+
*
|
|
2037
|
+
* `AUTH_APP_KEY` is minted once here; its broker entry is the next step
|
|
2038
|
+
* (`bouleBrokerEntry`).
|
|
2039
|
+
*/
|
|
2040
|
+
private coolifyBoule;
|
|
2001
2041
|
/**
|
|
2002
2042
|
* `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with
|
|
2003
2043
|
* the platform's jobs scheduler (`JobsSchedulerPort.registerProject`, the
|
package/dist/plan/executor.js
CHANGED
|
@@ -3,6 +3,7 @@ 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, } from '../org.js';
|
|
5
5
|
import { 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, 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';
|
|
6
7
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
7
8
|
import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
8
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';
|
|
@@ -226,6 +227,16 @@ const CMS_SITE_APP_DOCKERFILE = '/apps/site/Dockerfile';
|
|
|
226
227
|
*/
|
|
227
228
|
const AGORA_REPO = 'wtfalch/agora';
|
|
228
229
|
const AGORA_APP_DOCKERFILE = '/apps/agora/Dockerfile';
|
|
230
|
+
/**
|
|
231
|
+
* #161 (Boule part): the repo `coolifyBoule` builds the org's Boule from
|
|
232
|
+
* (https://github.com/wtfalch/boule), by the same convention as
|
|
233
|
+
* {@link AGORA_REPO}: Coolify's github-app build from the repo, never a
|
|
234
|
+
* registry image. Boule's own Dockerfile is at the repository root, serves on
|
|
235
|
+
* 3000 (`EXPOSE 3000`) and runs `scripts/migrate-and-start.sh`, which applies
|
|
236
|
+
* its migrations as the owner and then serves as the `<database>_rt` role.
|
|
237
|
+
*/
|
|
238
|
+
const BOULE_REPO = 'wtfalch/boule';
|
|
239
|
+
const BOULE_APP_DOCKERFILE = '/Dockerfile';
|
|
229
240
|
/**
|
|
230
241
|
* Every app foundry deploys keeps its own logs across a redeploy, so a
|
|
231
242
|
* database outage does not also erase the only other copy of the evidence
|
|
@@ -559,12 +570,35 @@ export function isRunningOrStarting(status) {
|
|
|
559
570
|
* instead of `getDatabase`. Returns what `getDatabase` returned.
|
|
560
571
|
*/
|
|
561
572
|
export async function getDatabaseStarted(coolify, dbId) {
|
|
562
|
-
|
|
563
|
-
if (db.status
|
|
564
|
-
|
|
573
|
+
let db = await coolify.getDatabase(dbId);
|
|
574
|
+
if (db.status === undefined || db.status.startsWith('running'))
|
|
575
|
+
return db;
|
|
576
|
+
await coolify.raw('POST', `/databases/${dbId}/start`);
|
|
577
|
+
// Coolify reports `running` before Postgres accepts connections, and the app
|
|
578
|
+
// deployed next (a migrate step) connects at once. Wait for `running*`, then
|
|
579
|
+
// a fixed grace. A database already running returned above and never waits.
|
|
580
|
+
const { sleep, now, pollMs, timeoutMs, graceMs } = databaseStartWait;
|
|
581
|
+
const deadline = now() + timeoutMs;
|
|
582
|
+
for (;;) {
|
|
583
|
+
await sleep(pollMs);
|
|
584
|
+
db = await coolify.getDatabase(dbId);
|
|
585
|
+
if (db.status?.startsWith('running'))
|
|
586
|
+
break;
|
|
587
|
+
if (now() >= deadline) {
|
|
588
|
+
throw new Error(`Database ${dbId} did not reach "running" within ${timeoutMs / 1000}s (last status: ${db.status ?? 'unknown'}). Retry the apply.`);
|
|
589
|
+
}
|
|
565
590
|
}
|
|
591
|
+
await sleep(graceMs);
|
|
566
592
|
return db;
|
|
567
593
|
}
|
|
594
|
+
/** Timing for the wait in {@link getDatabaseStarted}; a test swaps `sleep` and `now` for a fake clock. */
|
|
595
|
+
export const databaseStartWait = {
|
|
596
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
597
|
+
now: () => Date.now(),
|
|
598
|
+
pollMs: 2000,
|
|
599
|
+
timeoutMs: 120_000,
|
|
600
|
+
graceMs: 5000,
|
|
601
|
+
};
|
|
568
602
|
// ---------------------------------------------------------------------------
|
|
569
603
|
// Executor
|
|
570
604
|
// ---------------------------------------------------------------------------
|
|
@@ -632,6 +666,10 @@ export class FoundryStepExecutor {
|
|
|
632
666
|
return this.cmsSite(action, org, context);
|
|
633
667
|
case 'coolify:agora':
|
|
634
668
|
return this.coolifyAgora(action, org, context);
|
|
669
|
+
case 'coolify:boule':
|
|
670
|
+
return this.coolifyBoule(action, org, context);
|
|
671
|
+
case 'coolify:boule-broker-entry':
|
|
672
|
+
return this.bouleBrokerEntry(org, context);
|
|
635
673
|
case 'jobs:registration':
|
|
636
674
|
return this.jobsRegistration(action, org, context);
|
|
637
675
|
default:
|
|
@@ -1052,11 +1090,17 @@ export class FoundryStepExecutor {
|
|
|
1052
1090
|
// `OIDC_AUTH_METHOD_TYPE_NONE`, no secret), which is what Agora's browser
|
|
1053
1091
|
// SPA needs. Providers cannot create ZITADEL's `USER_AGENT` app kind.
|
|
1054
1092
|
const isAgoraClient = action.key === 'agora';
|
|
1093
|
+
// #161: the fixed `boule` client signs people into the org's Boule at its
|
|
1094
|
+
// own hostname, on the standard `/auth/callback` path (Boule is built on
|
|
1095
|
+
// `@wtfalch/auth` like every other web app here).
|
|
1096
|
+
const isBouleClient = action.key === 'boule';
|
|
1055
1097
|
const hostname = isCmsClient
|
|
1056
1098
|
? cmsHostnameFor(org)
|
|
1057
1099
|
: isAgoraClient
|
|
1058
1100
|
? agoraHostnameFor(org)
|
|
1059
|
-
:
|
|
1101
|
+
: isBouleClient
|
|
1102
|
+
? bouleHostnameFor(org)
|
|
1103
|
+
: appHostname(org);
|
|
1060
1104
|
// Native apps sign in via device code, with no redirect of their own
|
|
1061
1105
|
// (auth/scripts/provision.mjs:475-497, prior art cited in the decisions
|
|
1062
1106
|
// file) -- empty arrays rather than a web app's callback/logout URLs.
|
|
@@ -1099,9 +1143,11 @@ export class FoundryStepExecutor {
|
|
|
1099
1143
|
? CMS_CLIENT_ID_SCOPE_KEY
|
|
1100
1144
|
: isAgoraClient
|
|
1101
1145
|
? AGORA_CLIENT_ID_SCOPE_KEY
|
|
1102
|
-
:
|
|
1103
|
-
?
|
|
1104
|
-
:
|
|
1146
|
+
: isBouleClient
|
|
1147
|
+
? BOULE_CLIENT_ID_SCOPE_KEY
|
|
1148
|
+
: authEntry
|
|
1149
|
+
? entryClientIdScopeKey(action.key)
|
|
1150
|
+
: APP_CLIENT_ID_SCOPE_KEY, created.clientId);
|
|
1105
1151
|
}
|
|
1106
1152
|
return { resourceId: created.appId };
|
|
1107
1153
|
}
|
|
@@ -1635,20 +1681,46 @@ export class FoundryStepExecutor {
|
|
|
1635
1681
|
if (!appId) {
|
|
1636
1682
|
throw new Error(`coolify:broker-entry for ${org.slug}: app ${action.key} is not recorded yet`);
|
|
1637
1683
|
}
|
|
1684
|
+
await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, appId, context);
|
|
1685
|
+
return {};
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* #161: the org's Boule broker entry, `<slug>-boule`, written the way a
|
|
1689
|
+
* hosting entry's is: with the `AUTH_APP_KEY` `coolifyBoule` put in the
|
|
1690
|
+
* Boule app's env.
|
|
1691
|
+
*/
|
|
1692
|
+
async bouleBrokerEntry(org, context) {
|
|
1693
|
+
const broker = this.ports.broker;
|
|
1694
|
+
if (!broker) {
|
|
1695
|
+
throw new Error(`coolify:boule-broker-entry for ${org.slug}: the broker port is not wired`);
|
|
1696
|
+
}
|
|
1697
|
+
const recorded = context.recordedIds();
|
|
1698
|
+
const wanted = wantedBouleBrokerEntry(org, recorded);
|
|
1699
|
+
if ('missing' in wanted) {
|
|
1700
|
+
throw new Error(`coolify:boule-broker-entry for ${org.slug}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
|
|
1701
|
+
}
|
|
1702
|
+
const appId = recorded[`coolify:boule:${org.id}`];
|
|
1703
|
+
if (!appId) {
|
|
1704
|
+
throw new Error(`coolify:boule-broker-entry for ${org.slug}: the Boule app is not recorded yet`);
|
|
1705
|
+
}
|
|
1706
|
+
await this.writeAppBrokerEntry(org, broker, BOULE_BROKER_ENTRY_KEY, wanted.want, appId, context);
|
|
1707
|
+
return {};
|
|
1708
|
+
}
|
|
1709
|
+
/** The shared tail of both broker-entry steps: read the app's key, write the entry, record what was written. */
|
|
1710
|
+
async writeAppBrokerEntry(org, broker, entryKey, want, appId, context) {
|
|
1638
1711
|
const appKey = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview)?.value;
|
|
1639
1712
|
if (!appKey || appKey.length < 32) {
|
|
1640
1713
|
throw new Error(`coolify:broker-entry for ${org.slug}: app ${appId} has no AUTH_APP_KEY yet`);
|
|
1641
1714
|
}
|
|
1642
|
-
const previousRaw =
|
|
1715
|
+
const previousRaw = context.recordedIds()[brokerEntryAppliedScopeKey(entryKey)];
|
|
1643
1716
|
const result = await writeBrokerEntry(broker, {
|
|
1644
|
-
want
|
|
1717
|
+
want,
|
|
1645
1718
|
appKey,
|
|
1646
1719
|
previous: previousRaw ? JSON.parse(previousRaw) : null,
|
|
1647
1720
|
});
|
|
1648
|
-
context.recordScopeId(brokerEntryWarningsScopeKey(
|
|
1721
|
+
context.recordScopeId(brokerEntryWarningsScopeKey(entryKey), JSON.stringify(result.invalidForeign));
|
|
1649
1722
|
// Last: a run that failed above plans the step again.
|
|
1650
|
-
context.recordScopeId(brokerEntryAppliedScopeKey(
|
|
1651
|
-
return {};
|
|
1723
|
+
context.recordScopeId(brokerEntryAppliedScopeKey(entryKey), JSON.stringify(want));
|
|
1652
1724
|
}
|
|
1653
1725
|
/**
|
|
1654
1726
|
* `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
|
|
@@ -2958,6 +3030,84 @@ export class FoundryStepExecutor {
|
|
|
2958
3030
|
await this.ports.coolify.bulkUpsertEnvs(app.appId, Object.entries(env).map(([key, value]) => ({ key, value })));
|
|
2959
3031
|
return { resourceId: app.appId };
|
|
2960
3032
|
}
|
|
3033
|
+
// ---- boule (#161) --------------------------------------------------------
|
|
3034
|
+
/**
|
|
3035
|
+
* `plan/diff.ts`'s `planBoule`: provisions the org's own Boule
|
|
3036
|
+
* (https://github.com/wtfalch/boule) as one more Coolify application, from
|
|
3037
|
+
* that repo's root Dockerfile ({@link BOULE_REPO}), with a database of its
|
|
3038
|
+
* own, and pushes its env. The same shape as {@link coolifyAgora}. There is
|
|
3039
|
+
* no park branch: Boule is always on.
|
|
3040
|
+
*
|
|
3041
|
+
* The sign-in env comes from `plan/boule.ts`'s `desiredBouleEnv`, the same
|
|
3042
|
+
* function the plan compares the live app against. The step never writes a
|
|
3043
|
+
* partial set: when the issuer (carried in the step), the org's ZITADEL org
|
|
3044
|
+
* id or the Boule client id is missing it throws naming each one, before it
|
|
3045
|
+
* creates anything.
|
|
3046
|
+
*
|
|
3047
|
+
* The database is `<slug>-boule-db`, split like the cms app's (#117, #239):
|
|
3048
|
+
* the superuser URL as `DATABASE_URL_OWNER` (Boule's boot script migrates
|
|
3049
|
+
* with it, then unsets it) and a `<dbName>_rt` role's URL as `DATABASE_URL`,
|
|
3050
|
+
* minted once and never rotated. `AUTH_COOKIE_SECRET` is generated once the
|
|
3051
|
+
* same way. A running app is restarted when its sign-in env or database URLs
|
|
3052
|
+
* changed, since a container reads its env only at start.
|
|
3053
|
+
*
|
|
3054
|
+
* `AUTH_APP_KEY` is minted once here; its broker entry is the next step
|
|
3055
|
+
* (`bouleBrokerEntry`).
|
|
3056
|
+
*/
|
|
3057
|
+
async coolifyBoule(action, org, context) {
|
|
3058
|
+
// The issuer the plan read, carried in the step: the env written is the
|
|
3059
|
+
// env the plan showed.
|
|
3060
|
+
const issuer = action.desired.issuer ?? null;
|
|
3061
|
+
const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer);
|
|
3062
|
+
if (missing.length > 0) {
|
|
3063
|
+
throw new Error(`Boule for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
|
|
3064
|
+
}
|
|
3065
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
3066
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
3067
|
+
const name = `${org.slug}-boule`;
|
|
3068
|
+
const dbName = `${org.slug}-boule-db`.replace(/-/g, '_');
|
|
3069
|
+
const dbId = await this.ensureCallsDatabase(context, projectUuid, environment, `${org.slug}-boule-db`, dbName, BOULE_DATABASE_SCOPE_KEY);
|
|
3070
|
+
context.recordScopeId(BOULE_DATABASE_SCOPE_KEY, dbId);
|
|
3071
|
+
const server = await this.resolveServer();
|
|
3072
|
+
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
3073
|
+
const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
3074
|
+
project_uuid: projectUuid,
|
|
3075
|
+
server_uuid: server.uuid,
|
|
3076
|
+
environment_name: environment.name,
|
|
3077
|
+
environment_uuid: environment.uuid,
|
|
3078
|
+
github_app_uuid: githubApp.uuid,
|
|
3079
|
+
git_repository: BOULE_REPO,
|
|
3080
|
+
git_branch: 'main',
|
|
3081
|
+
build_pack: 'dockerfile',
|
|
3082
|
+
name,
|
|
3083
|
+
domains: coolifyDomains([bouleHostnameFor(org)]),
|
|
3084
|
+
dockerfile_location: BOULE_APP_DOCKERFILE,
|
|
3085
|
+
}));
|
|
3086
|
+
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
3087
|
+
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
3088
|
+
const liveEnv = new Map((await this.ports.coolify.listEnvs(app.appId))
|
|
3089
|
+
.filter((e) => !e.is_preview)
|
|
3090
|
+
.map((e) => [e.key, e.value]));
|
|
3091
|
+
const envChanged = bouleEnvDrift(env, Object.fromEntries(liveEnv)).length > 0;
|
|
3092
|
+
const envs = Object.entries(env).map(([key, value]) => ({ key, value }));
|
|
3093
|
+
const cookieSecret = await this.ensureAuthCookieSecret(app.appId);
|
|
3094
|
+
if (cookieSecret)
|
|
3095
|
+
envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
|
|
3096
|
+
// The key the sign-in service knows this app by: minted once, then it
|
|
3097
|
+
// lives in this env and in the broker entry `bouleBrokerEntry` writes.
|
|
3098
|
+
const appKey = await this.ensureAuthAppKey(app.appId);
|
|
3099
|
+
if (appKey)
|
|
3100
|
+
envs.push({ key: 'AUTH_APP_KEY', value: appKey });
|
|
3101
|
+
await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
|
|
3102
|
+
const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName);
|
|
3103
|
+
if (!dbPush.pushed) {
|
|
3104
|
+
throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
3105
|
+
}
|
|
3106
|
+
if (app.existed && (envChanged || cookieSecret !== null || appKey !== null || dbPush.changed)) {
|
|
3107
|
+
await this.ports.coolify.restart(app.appId);
|
|
3108
|
+
}
|
|
3109
|
+
return { resourceId: app.appId };
|
|
3110
|
+
}
|
|
2961
3111
|
// ---- jobs (#173) ---------------------------------------------------------
|
|
2962
3112
|
/**
|
|
2963
3113
|
* `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with
|
package/dist/plan/hostnames.d.ts
CHANGED
|
@@ -28,8 +28,8 @@ export declare function coolifyDomains(names: readonly string[]): string;
|
|
|
28
28
|
export declare function legacyAppHostname(org: DeclaredOrg): string;
|
|
29
29
|
/**
|
|
30
30
|
* The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
|
|
31
|
-
* #164): a site's two names, every entry's names, an own-domain calls name
|
|
32
|
-
* Agora's, then the wildcard last. An org that does not host keeps the single
|
|
31
|
+
* #164): a site's two names, every entry's names, an own-domain calls name,
|
|
32
|
+
* Agora's and Boule's, then the wildcard last. An org that does not host keeps the single
|
|
33
33
|
* name it always had.
|
|
34
34
|
*/
|
|
35
35
|
export declare function tunnelIngressHostnames(org: DeclaredOrg): string[];
|
package/dist/plan/hostnames.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { APP_ENTRY_KEY } from '../org.js';
|
|
2
2
|
import { agoraHostnameFor } from './agora.js';
|
|
3
|
+
import { bouleHostnameFor } from './boule.js';
|
|
3
4
|
/**
|
|
4
5
|
* The full names an entry is served at. With `hosting.usesDomain` off, or no
|
|
5
6
|
* domain, `hostnames` is not read: the `app` entry is at `<slug>.oikos.host`
|
|
@@ -48,8 +49,8 @@ export function legacyAppHostname(org) {
|
|
|
48
49
|
}
|
|
49
50
|
/**
|
|
50
51
|
* The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
|
|
51
|
-
* #164): a site's two names, every entry's names, an own-domain calls name
|
|
52
|
-
* Agora's, then the wildcard last. An org that does not host keeps the single
|
|
52
|
+
* #164): a site's two names, every entry's names, an own-domain calls name,
|
|
53
|
+
* Agora's and Boule's, then the wildcard last. An org that does not host keeps the single
|
|
53
54
|
* name it always had.
|
|
54
55
|
*/
|
|
55
56
|
export function tunnelIngressHostnames(org) {
|
|
@@ -67,6 +68,8 @@ export function tunnelIngressHostnames(org) {
|
|
|
67
68
|
// #164: Agora always moves onto `agora.<domain>` once `org.domain` is set.
|
|
68
69
|
if (org.domain) {
|
|
69
70
|
hostnames.push(agoraHostnameFor(org));
|
|
71
|
+
// #161: and Boule moves onto `boule.<domain>` the same way.
|
|
72
|
+
hostnames.push(bouleHostnameFor(org));
|
|
70
73
|
}
|
|
71
74
|
// The wildcard goes last: Cloudflare takes the first ingress rule that
|
|
72
75
|
// matches, so every exact name above must come before `*.<domain>`.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { DeclaredOrg } from '../org.js';
|
|
2
2
|
/**
|
|
3
3
|
* The names under the org's domain that foundry owns, so no hosting entry may
|
|
4
|
-
* take one: Agora, the cms studio when the site is on, calls when it uses the
|
|
4
|
+
* take one: Agora, Boule, the cms studio when the site is on, calls when it uses the
|
|
5
5
|
* domain, the sign-in host when the issuer does, and every platform service
|
|
6
6
|
* hostname that falls under the domain. Empty for an org with no domain.
|
|
7
7
|
*/
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { agoraHostnameFor } from './agora.js';
|
|
2
|
+
import { bouleHostnameFor } from './boule.js';
|
|
2
3
|
import { loginHostnameFor } from './login-host.js';
|
|
3
4
|
/**
|
|
4
5
|
* The names under the org's domain that foundry owns, so no hosting entry may
|
|
5
|
-
* take one: Agora, the cms studio when the site is on, calls when it uses the
|
|
6
|
+
* take one: Agora, Boule, the cms studio when the site is on, calls when it uses the
|
|
6
7
|
* domain, the sign-in host when the issuer does, and every platform service
|
|
7
8
|
* hostname that falls under the domain. Empty for an org with no domain.
|
|
8
9
|
*/
|
|
@@ -12,6 +13,7 @@ export function lockedHostnames(org, platformHostnames) {
|
|
|
12
13
|
if (!domain)
|
|
13
14
|
return locked;
|
|
14
15
|
locked.add(agoraHostnameFor(org));
|
|
16
|
+
locked.add(bouleHostnameFor(org));
|
|
15
17
|
if (org.site.enabled)
|
|
16
18
|
locked.add(`cms.${domain}`);
|
|
17
19
|
if (org.calls.enabled && org.calls.usesDomain)
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -371,9 +371,11 @@ export class FoundryPlanApply {
|
|
|
371
371
|
this.orgPlatformReadiness(),
|
|
372
372
|
this.orgAgoraHost(id),
|
|
373
373
|
]);
|
|
374
|
+
const otherSlugs = (await this.store.list()).filter((o) => o.id !== id).map((o) => o.slug);
|
|
374
375
|
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
|
|
375
376
|
loginHostBindings: this.loginHostBindings,
|
|
376
377
|
broker: this.broker,
|
|
378
|
+
otherSlugs,
|
|
377
379
|
now: () => this.now().getTime(),
|
|
378
380
|
});
|
|
379
381
|
const invalid = await this.invalidHosting(org);
|
|
@@ -466,6 +466,13 @@ export declare const PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY = "ai_platform_migrat
|
|
|
466
466
|
* that throws) is repaired by the next one.
|
|
467
467
|
*/
|
|
468
468
|
export declare const PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY = "ai_platform_deploy_pending";
|
|
469
|
+
/**
|
|
470
|
+
* The Coolify deployment queued for the host (its `deployment_uuid`, or
|
|
471
|
+
* `untracked` when the deploy answered none), set when that deploy is queued
|
|
472
|
+
* and cleared once it finished. The mint waits for it, so a retry after a
|
|
473
|
+
* failed wait still waits instead of minting against the old container.
|
|
474
|
+
*/
|
|
475
|
+
export declare const PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY = "ai_platform_runner_deployment";
|
|
469
476
|
/**
|
|
470
477
|
* #171: the resource key `planFiles` plans under and `FoundryPlatformStepExecutor`
|
|
471
478
|
* dispatches on, the {@link PLATFORM_AUTH_KEY} shape for the files host.
|
|
@@ -523,6 +523,13 @@ export const PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY = 'ai_platform_migrate_blocke
|
|
|
523
523
|
* that throws) is repaired by the next one.
|
|
524
524
|
*/
|
|
525
525
|
export const PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY = 'ai_platform_deploy_pending';
|
|
526
|
+
/**
|
|
527
|
+
* The Coolify deployment queued for the host (its `deployment_uuid`, or
|
|
528
|
+
* `untracked` when the deploy answered none), set when that deploy is queued
|
|
529
|
+
* and cleared once it finished. The mint waits for it, so a retry after a
|
|
530
|
+
* failed wait still waits instead of minting against the old container.
|
|
531
|
+
*/
|
|
532
|
+
export const PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY = 'ai_platform_runner_deployment';
|
|
526
533
|
/**
|
|
527
534
|
* #171: the resource key `planFiles` plans under and `FoundryPlatformStepExecutor`
|
|
528
535
|
* dispatches on, the {@link PLATFORM_AUTH_KEY} shape for the files host.
|
|
@@ -513,6 +513,20 @@ export interface PlatformAiDeps {
|
|
|
513
513
|
heldKeys?: {
|
|
514
514
|
resolve(ref: string): Promise<string | null>;
|
|
515
515
|
};
|
|
516
|
+
/**
|
|
517
|
+
* The `@wtfalch/keys` settings the AI host reads (`KEYS_WORKER_URL`,
|
|
518
|
+
* `KEYS_CREDENTIAL`, `KEYS_VERIFY_KEYS`: the worker's origin, this host's
|
|
519
|
+
* own worker-signed caller credential, and the worker's Ed25519 public
|
|
520
|
+
* key(s), comma-separated base64url, current first). ai's
|
|
521
|
+
* `POST /v1/platform/keys` refuses every call unless all three are set, so
|
|
522
|
+
* the step sets them on the host and the worker, and the executor reports
|
|
523
|
+
* the step blocked while this is unset. Never recorded or logged.
|
|
524
|
+
*/
|
|
525
|
+
keys?: {
|
|
526
|
+
workerUrl: string;
|
|
527
|
+
credential: string;
|
|
528
|
+
verifyKeys: string;
|
|
529
|
+
};
|
|
516
530
|
/** Injected for tests. Absent, {@link provisionPlatformAi} builds the real {@link createAiManagementKeyMinter} from the signing key it mints. */
|
|
517
531
|
managementKeyMinter?: AiManagementKeyMinter;
|
|
518
532
|
/** Injected for tests, e.g. against a fake ai host. Defaults to the global `fetch`. */
|
|
@@ -6,7 +6,7 @@ import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
|
6
6
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
7
7
|
import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
|
|
8
8
|
import { coolifyDomains } from './hostnames.js';
|
|
9
|
-
import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
|
|
9
|
+
import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
|
|
10
10
|
import { applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
11
11
|
import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
|
|
12
12
|
/** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
|
|
@@ -184,6 +184,10 @@ export class FoundryPlatformStepExecutor {
|
|
|
184
184
|
blocked.ai =
|
|
185
185
|
"the platform's AI host is blocked, not planned: the host built the platform executor without its ai dependencies";
|
|
186
186
|
}
|
|
187
|
+
else if (platform.ai.enabled && !aiKeys(this.ai)) {
|
|
188
|
+
blocked.ai =
|
|
189
|
+
"the platform's AI host is blocked, not planned: the host supplies no keys worker URL, credential and verify keys (KEYS_WORKER_URL, KEYS_CREDENTIAL, KEYS_VERIFY_KEYS), and without them ai's POST /v1/platform/keys refuses every call, so foundry's management key could never be minted";
|
|
190
|
+
}
|
|
187
191
|
else if (platform.ai.enabled &&
|
|
188
192
|
!this.ai?.managementKeyMinter &&
|
|
189
193
|
!this.ai?.heldKeys &&
|
|
@@ -3143,16 +3147,18 @@ const PLATFORM_AI_WORKER_SCOPE_KEY = 'coolify_platform_ai_worker_app';
|
|
|
3143
3147
|
* Written only when absent, never overwritten: an operator's own model list
|
|
3144
3148
|
* or worker bounds survive every apply. Values are `.env.example`'s, and
|
|
3145
3149
|
* `AI_MODELS=[]` is the README's "honest configuration for a host with no
|
|
3146
|
-
* approved text model".
|
|
3150
|
+
* approved text model". The runner needs `AI_WORKER_TIMEOUT_MS` too: ai's
|
|
3151
|
+
* `runtimeHost()` (`apps/host/src/lib/authz/ai-host.ts`) requires it, and
|
|
3152
|
+
* every route that checks readiness fails without it.
|
|
3147
3153
|
*/
|
|
3148
3154
|
const PLATFORM_AI_RUNNER_DEFAULTS = [
|
|
3149
3155
|
{ key: 'AI_MODELS', value: '[]' },
|
|
3150
3156
|
{ key: 'AI_FORGE_BINDINGS', value: '[]' },
|
|
3157
|
+
{ key: 'AI_WORKER_TIMEOUT_MS', value: '60000' },
|
|
3151
3158
|
];
|
|
3152
3159
|
const PLATFORM_AI_WORKER_DEFAULTS = [
|
|
3153
3160
|
...PLATFORM_AI_RUNNER_DEFAULTS,
|
|
3154
3161
|
{ key: 'AI_WORKER_BATCH', value: '10' },
|
|
3155
|
-
{ key: 'AI_WORKER_TIMEOUT_MS', value: '60000' },
|
|
3156
3162
|
];
|
|
3157
3163
|
/**
|
|
3158
3164
|
* #172: makes `coolify:app` keyed {@link PLATFORM_AI_KEY} true -- the ai host
|
|
@@ -3228,9 +3234,13 @@ const PLATFORM_AI_WORKER_DEFAULTS = [
|
|
|
3228
3234
|
* rotates it yet, so the platform loses its AI management key after 90 days.
|
|
3229
3235
|
* - Deploys are decided from the recorded "pending" mark and the app's own
|
|
3230
3236
|
* status, not from a deployment's age: the port cannot read one.
|
|
3231
|
-
* -
|
|
3232
|
-
*
|
|
3233
|
-
*
|
|
3237
|
+
* - Sign-in (`AUTH_*`) is not set: until an operator supplies it the host
|
|
3238
|
+
* answers `503 not_configured` for sign-in, as its README says.
|
|
3239
|
+
* - `KEYS_WORKER_URL`, `KEYS_CREDENTIAL` and `KEYS_VERIFY_KEYS` come from
|
|
3240
|
+
* {@link PlatformAiDeps.keys} and are set on the host and the worker. They
|
|
3241
|
+
* gate more than provider work: ai builds the issuer behind
|
|
3242
|
+
* `POST /v1/platform/keys` only when all three are set, so without them
|
|
3243
|
+
* foundry's own management key can never be minted.
|
|
3234
3244
|
*/
|
|
3235
3245
|
async function provisionPlatformAi(context, deps) {
|
|
3236
3246
|
const { platform } = context;
|
|
@@ -3240,6 +3250,15 @@ async function provisionPlatformAi(context, deps) {
|
|
|
3240
3250
|
const runtime = deps.runtime ?? {};
|
|
3241
3251
|
const sleep = runtime.sleep ?? defaultSleep;
|
|
3242
3252
|
const { coolify } = deps;
|
|
3253
|
+
const keys = aiKeys(deps);
|
|
3254
|
+
if (!keys) {
|
|
3255
|
+
throw new Error("platform:ai needs the keys worker URL, credential and verify keys in its ai dependencies (KEYS_WORKER_URL, KEYS_CREDENTIAL, KEYS_VERIFY_KEYS); ai refuses to mint foundry's management key without them");
|
|
3256
|
+
}
|
|
3257
|
+
const keysEnvs = [
|
|
3258
|
+
{ key: 'KEYS_WORKER_URL', value: keys.workerUrl },
|
|
3259
|
+
{ key: 'KEYS_CREDENTIAL', value: keys.credential },
|
|
3260
|
+
{ key: 'KEYS_VERIFY_KEYS', value: keys.verifyKeys },
|
|
3261
|
+
];
|
|
3243
3262
|
const record = async (key, id) => {
|
|
3244
3263
|
context.recordScopeId(key, id);
|
|
3245
3264
|
await context.persist?.();
|
|
@@ -3324,10 +3343,12 @@ async function provisionPlatformAi(context, deps) {
|
|
|
3324
3343
|
const runnerEnvs = [
|
|
3325
3344
|
{ key: 'DATABASE_URL', value: runtimeUrl },
|
|
3326
3345
|
{ key: 'APP_URL', value: `https://${hostname}` },
|
|
3346
|
+
...keysEnvs,
|
|
3327
3347
|
...(await missingEnvs(coolify, runner.appId, PLATFORM_AI_RUNNER_DEFAULTS)),
|
|
3328
3348
|
];
|
|
3329
3349
|
const workerEnvs = [
|
|
3330
3350
|
{ key: 'DATABASE_URL', value: runtimeUrl },
|
|
3351
|
+
...keysEnvs,
|
|
3331
3352
|
...(await missingEnvs(coolify, worker.appId, PLATFORM_AI_WORKER_DEFAULTS)),
|
|
3332
3353
|
];
|
|
3333
3354
|
// Whether anything the containers run on differs from what they last
|
|
@@ -3383,8 +3404,12 @@ async function provisionPlatformAi(context, deps) {
|
|
|
3383
3404
|
}
|
|
3384
3405
|
await coolify.raw('POST', `/applications/${migrate.appId}/stop`);
|
|
3385
3406
|
}
|
|
3386
|
-
|
|
3387
|
-
|
|
3407
|
+
// `deploy` only queues a build that takes minutes, so the id of the host's
|
|
3408
|
+
// deployment is kept for the mint below to wait on.
|
|
3409
|
+
const runnerDeployment = deployRunner ? await coolify.deploy(runner.appId) : undefined;
|
|
3410
|
+
if (deployRunner) {
|
|
3411
|
+
await record(PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY, queuedDeploymentUuid(runnerDeployment) ?? 'untracked');
|
|
3412
|
+
}
|
|
3388
3413
|
if (deployWorker)
|
|
3389
3414
|
await coolify.deploy(worker.appId);
|
|
3390
3415
|
await record(PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, '');
|
|
@@ -3403,6 +3428,25 @@ async function provisionPlatformAi(context, deps) {
|
|
|
3403
3428
|
if (lost) {
|
|
3404
3429
|
throw new Error(`platform:ai: AI management key ${lost} was minted but never delivered; revoke it and remove the recorded key ${PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
3405
3430
|
}
|
|
3431
|
+
// The host answers 503 (or nothing) until the deployment that carries its
|
|
3432
|
+
// management settings has finished.
|
|
3433
|
+
const queued = context.recordedIds()[PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY];
|
|
3434
|
+
if (queued) {
|
|
3435
|
+
try {
|
|
3436
|
+
await waitForAiDeployment(coolify, runner.appId, queued, runtime, sleep);
|
|
3437
|
+
}
|
|
3438
|
+
catch (error) {
|
|
3439
|
+
// Only a build still running is worth waiting on again. A deployment
|
|
3440
|
+
// that failed, was cancelled or cannot be read is not: mark the host
|
|
3441
|
+
// pending so the next apply queues a new one instead of polling it.
|
|
3442
|
+
if (!(error instanceof AiDeploymentTimeoutError)) {
|
|
3443
|
+
await record(PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, '1');
|
|
3444
|
+
await record(PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY, '');
|
|
3445
|
+
}
|
|
3446
|
+
throw error;
|
|
3447
|
+
}
|
|
3448
|
+
await record(PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY, '');
|
|
3449
|
+
}
|
|
3406
3450
|
// #201: a test injects its own minter; without one, build the real one
|
|
3407
3451
|
// over ai's own POST /v1/platform/keys, signed with the key pinned above.
|
|
3408
3452
|
const minter = deps.managementKeyMinter ??
|
|
@@ -3440,8 +3484,9 @@ async function provisionPlatformAi(context, deps) {
|
|
|
3440
3484
|
if (error instanceof AiManagementKeyUntrustedError) {
|
|
3441
3485
|
throw new Error(`platform:ai: ${error.message}`);
|
|
3442
3486
|
}
|
|
3443
|
-
// The cause is dropped in case it echoes a credential
|
|
3444
|
-
|
|
3487
|
+
// The cause is dropped in case it echoes a credential; only the HTTP
|
|
3488
|
+
// status, or a network error's name and code, say why.
|
|
3489
|
+
throw new Error(`platform:ai: the AI management key could not be minted (${describeMintFailure(error)}). Nothing was recorded; apply again`);
|
|
3445
3490
|
}
|
|
3446
3491
|
const { credentialId, organisationId, secret } = minted;
|
|
3447
3492
|
await record(PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, credentialId);
|
|
@@ -3530,6 +3575,70 @@ class MigrationNotHealthyError extends Error {
|
|
|
3530
3575
|
this.lastStatus = lastStatus;
|
|
3531
3576
|
}
|
|
3532
3577
|
}
|
|
3578
|
+
/** The `deployment_uuid` in the `{deployments:[...]}` / array answer of Coolify's deploy call, if any. */
|
|
3579
|
+
function queuedDeploymentUuid(result) {
|
|
3580
|
+
const rows = Array.isArray(result)
|
|
3581
|
+
? result
|
|
3582
|
+
: result?.deployments;
|
|
3583
|
+
const uuid = Array.isArray(rows)
|
|
3584
|
+
? rows[0]?.deployment_uuid
|
|
3585
|
+
: undefined;
|
|
3586
|
+
return typeof uuid === 'string' && uuid ? uuid : undefined;
|
|
3587
|
+
}
|
|
3588
|
+
/**
|
|
3589
|
+
* Waits until the host's queued deployment has `finished`; throws on
|
|
3590
|
+
* `failed`/`cancelled-by-user` or once `migrateTimeoutMs` has passed. With no
|
|
3591
|
+
* deployment id (`untracked`) the only signal left is the app's own status,
|
|
3592
|
+
* which an old container can already satisfy, so that is best effort.
|
|
3593
|
+
*/
|
|
3594
|
+
/** The host's deployment was still running when the wait gave up. */
|
|
3595
|
+
class AiDeploymentTimeoutError extends Error {
|
|
3596
|
+
}
|
|
3597
|
+
async function waitForAiDeployment(coolify, appId, deploymentUuid, runtime, sleep) {
|
|
3598
|
+
const now = runtime.now ?? Date.now;
|
|
3599
|
+
const timeout = runtime.migrateTimeoutMs ?? 600_000;
|
|
3600
|
+
const interval = runtime.migrateIntervalMs ?? 5_000;
|
|
3601
|
+
const deadline = now() + timeout;
|
|
3602
|
+
let status;
|
|
3603
|
+
for (;;) {
|
|
3604
|
+
await sleep(interval);
|
|
3605
|
+
if (deploymentUuid === 'untracked') {
|
|
3606
|
+
status = (await coolify.getApplication(appId)).status;
|
|
3607
|
+
if (status?.startsWith('running:healthy'))
|
|
3608
|
+
return;
|
|
3609
|
+
}
|
|
3610
|
+
else {
|
|
3611
|
+
const found = (await coolify.raw('GET', `/deployments/${deploymentUuid}`));
|
|
3612
|
+
status = typeof found?.status === 'string' ? found.status : undefined;
|
|
3613
|
+
if (status === 'finished')
|
|
3614
|
+
return;
|
|
3615
|
+
if (status === 'failed' || status === 'cancelled-by-user') {
|
|
3616
|
+
throw new Error(`platform:ai: the host's deployment for ${appId} ended ${status}; the AI management key was not minted. Check its build log in Coolify, then apply again`);
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
if (now() >= deadline) {
|
|
3620
|
+
throw new AiDeploymentTimeoutError(`platform:ai: the host's deployment for ${appId} did not finish within ${Math.round(timeout / 1000)}s (last status ${status ?? 'unknown'}); the AI management key was not minted. Apply again`);
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
/**
|
|
3625
|
+
* Why a mint failed, safe to show: the status ai answered (the minter's own
|
|
3626
|
+
* fixed message), or a network error's `name` and `cause.code`. Never a
|
|
3627
|
+
* `.message` or body, which could echo a credential.
|
|
3628
|
+
*/
|
|
3629
|
+
function describeMintFailure(error) {
|
|
3630
|
+
const status = /^ai's platform\/keys route answered (\d{3})$/.exec(error instanceof Error ? error.message : '')?.[1];
|
|
3631
|
+
if (status)
|
|
3632
|
+
return `ai answered HTTP ${status}`;
|
|
3633
|
+
if (error instanceof Error) {
|
|
3634
|
+
const code = error.cause?.code;
|
|
3635
|
+
// A plain `Error` is the minter's own (a malformed body), not the network.
|
|
3636
|
+
if (typeof code === 'string' || error.name !== 'Error') {
|
|
3637
|
+
return `network error ${error.name}${typeof code === 'string' ? ` ${code}` : ''}`;
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
return 'the minter failed; its error is not shown';
|
|
3641
|
+
}
|
|
3533
3642
|
/**
|
|
3534
3643
|
* Polls the app until Coolify reports it stopped (`exited`/`stopped`), so a
|
|
3535
3644
|
* healthy status read afterwards belongs to a deployment queued after this.
|
|
@@ -4127,6 +4236,10 @@ async function provisionPlatformFiles(context, deps) {
|
|
|
4127
4236
|
await record(PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
4128
4237
|
return { resourceId: runnerApp.appId };
|
|
4129
4238
|
}
|
|
4239
|
+
function aiKeys(deps) {
|
|
4240
|
+
const keys = deps?.keys;
|
|
4241
|
+
return keys?.workerUrl && keys.credential && keys.verifyKeys ? keys : null;
|
|
4242
|
+
}
|
|
4130
4243
|
function filesKeys(deps) {
|
|
4131
4244
|
const keys = deps?.keys;
|
|
4132
4245
|
return keys?.workerUrl && keys.credential ? keys : null;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BOULE_MANAGED_ENV_KEYS } from './boule.js';
|
|
1
2
|
/**
|
|
2
3
|
* A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
|
|
3
4
|
* — the one place foundry records a provider's own id for an org today,
|
|
@@ -191,6 +192,24 @@ async function withAgoraEnvConfig(client, resource, recorded) {
|
|
|
191
192
|
}
|
|
192
193
|
return { ...resource, config: { ...resource.config, env } };
|
|
193
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* #161: the org's own recorded Boule app's live sign-in env vars, as
|
|
197
|
+
* `config.env`, so `diff.ts`'s `planBoule` can compare them with what
|
|
198
|
+
* `executor.ts` would write. Gated and failing exactly like
|
|
199
|
+
* {@link withAgoraEnvConfig}.
|
|
200
|
+
*/
|
|
201
|
+
async function withBouleEnvConfig(client, resource, recorded) {
|
|
202
|
+
if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('boule:')) {
|
|
203
|
+
return resource;
|
|
204
|
+
}
|
|
205
|
+
const managed = BOULE_MANAGED_ENV_KEYS;
|
|
206
|
+
const env = {};
|
|
207
|
+
for (const e of await client.listEnvs(resource.key)) {
|
|
208
|
+
if (!e.is_preview && managed.includes(e.key))
|
|
209
|
+
env[e.key] = e.value;
|
|
210
|
+
}
|
|
211
|
+
return { ...resource, config: { ...resource.config, env } };
|
|
212
|
+
}
|
|
194
213
|
/** `@wtfalch/providers`' own `LiveResource` (`resource.ts`) has no `system` field — foundry's adds it at this boundary. */
|
|
195
214
|
function toFoundryResources(system, resources) {
|
|
196
215
|
return resources.map((r) => ({ system, kind: r.kind, key: r.key, config: r.config }));
|
|
@@ -221,7 +240,8 @@ export class ProvidersCoolifyReader {
|
|
|
221
240
|
const withEnvNames = await Promise.all(withLogConfig.map((r) => withEnvNamesConfig(this.client, r, recorded)));
|
|
222
241
|
const withDbUrl = await Promise.all(withEnvNames.map((r) => withDatabaseUrlConfig(this.client, r, recorded)));
|
|
223
242
|
const withAgoraConfig = await Promise.all(withDbUrl.map((r) => withAgoraEnvConfig(this.client, r, recorded)));
|
|
224
|
-
|
|
243
|
+
const withBouleConfig = await Promise.all(withAgoraConfig.map((r) => withBouleEnvConfig(this.client, r, recorded)));
|
|
244
|
+
return applyRecordedIds(withBouleConfig, recorded);
|
|
225
245
|
}
|
|
226
246
|
}
|
|
227
247
|
/**
|
package/dist/slug.js
CHANGED
|
@@ -128,6 +128,13 @@ const CALLS_SUFFIX = /-calls$/;
|
|
|
128
128
|
* taken, not just one that turns a feature on.
|
|
129
129
|
*/
|
|
130
130
|
const AGORA_SUFFIX = /-agora$/;
|
|
131
|
+
/**
|
|
132
|
+
* `<something>-boule` — a level-1 org's own Boule hostname
|
|
133
|
+
* (`<slug>-boule.oikos.host`, foundry#161). Every org gets a Boule, so like
|
|
134
|
+
* {@link AGORA_SUFFIX} this collides with every org's own hostname, not only
|
|
135
|
+
* one that opts in.
|
|
136
|
+
*/
|
|
137
|
+
const BOULE_SUFFIX = /-boule$/;
|
|
131
138
|
/**
|
|
132
139
|
* Validates a candidate org slug against every rule #28 decided. Returns
|
|
133
140
|
* `null` when the slug is acceptable, or a {@link SlugRejection} naming
|
|
@@ -151,10 +158,11 @@ export function validateSlug(slug) {
|
|
|
151
158
|
if (STAGE_SUFFIX.test(slug) ||
|
|
152
159
|
PR_SUFFIX.test(slug) ||
|
|
153
160
|
CALLS_SUFFIX.test(slug) ||
|
|
154
|
-
AGORA_SUFFIX.test(slug)
|
|
161
|
+
AGORA_SUFFIX.test(slug) ||
|
|
162
|
+
BOULE_SUFFIX.test(slug)) {
|
|
155
163
|
return {
|
|
156
164
|
reason: 'environment-suffix',
|
|
157
|
-
message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls' or '-
|
|
165
|
+
message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls', '-agora' or '-boule') reserved for another org's own hostname (#28, #145, #164, #161) — 'acme-calls' is only legal as acme's calls hostname, 'acme-agora' only as acme's Agora hostname, 'acme-boule' only as acme's Boule hostname, never as an org slug in its own right`,
|
|
158
166
|
};
|
|
159
167
|
}
|
|
160
168
|
return null;
|