@wtfalch/foundry 0.16.3 → 0.16.5
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 +42 -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 +9 -0
- package/dist/plan/platform-diff.js +22 -3
- package/dist/plan/platform-providers.d.ts +15 -1
- package/dist/plan/platform-providers.js +54 -6
- 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
|
@@ -237,6 +237,8 @@ export interface CoolifyPort {
|
|
|
237
237
|
name?: string;
|
|
238
238
|
description?: string;
|
|
239
239
|
instant_deploy?: boolean;
|
|
240
|
+
/** Extra `docker run` options (`--entrypoint "..."` becomes the compose entrypoint). */
|
|
241
|
+
custom_docker_run_options?: string;
|
|
240
242
|
/**
|
|
241
243
|
* `host:container`, comma separated -- publishes host ports directly
|
|
242
244
|
* (`@wtfalch/providers`' `CoolifyClient.createDockerImageApplication`
|
|
@@ -1377,6 +1379,14 @@ export declare function getDatabaseStarted(coolify: {
|
|
|
1377
1379
|
internal_db_url?: string;
|
|
1378
1380
|
status?: string;
|
|
1379
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
|
+
};
|
|
1380
1390
|
export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer {
|
|
1381
1391
|
private readonly ports;
|
|
1382
1392
|
constructor(ports: FoundryStepExecutorPorts);
|
|
@@ -1603,6 +1613,14 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1603
1613
|
* broker would drop.
|
|
1604
1614
|
*/
|
|
1605
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;
|
|
1606
1624
|
/**
|
|
1607
1625
|
* `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
|
|
1608
1626
|
* only the first time (task brief), never regenerated or overwritten, so a
|
|
@@ -1996,6 +2014,30 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1996
2014
|
* not the host values).
|
|
1997
2015
|
*/
|
|
1998
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;
|
|
1999
2041
|
/**
|
|
2000
2042
|
* `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with
|
|
2001
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);
|
|
@@ -46,6 +46,15 @@ export declare const PLATFORM_AUTH_KEY = "platform:auth";
|
|
|
46
46
|
* that was never actually applied.
|
|
47
47
|
*/
|
|
48
48
|
export declare const PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY = "auth_zitadel_applied_image_tag";
|
|
49
|
+
/**
|
|
50
|
+
* The image has `Entrypoint /app/zitadel` and no `Cmd`, so without this it
|
|
51
|
+
* runs zitadel with no subcommand, exits, and restarts forever. Coolify turns
|
|
52
|
+
* `--entrypoint` in `custom_docker_run_options` into the compose `entrypoint`
|
|
53
|
+
* (its PATCH takes this field; it refuses `dockerfile`).
|
|
54
|
+
*/
|
|
55
|
+
export declare const AUTH_ZITADEL_RUN_OPTIONS = "--entrypoint \"/app/zitadel start-from-init --masterkeyFromEnv --tlsMode external\"";
|
|
56
|
+
/** The run options last applied to the ZITADEL app (value: the options string), so a changed value forces one redeploy. */
|
|
57
|
+
export declare const PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY = "zitadel_platform_auth_run_options_applied";
|
|
49
58
|
/**
|
|
50
59
|
* #251: the Synapse/MAS image tags an org's calls Matrix stack last
|
|
51
60
|
* successful apply actually set live. `coolifyCalls` (`executor.ts`)
|
|
@@ -41,6 +41,15 @@ export const PLATFORM_AUTH_KEY = 'platform:auth';
|
|
|
41
41
|
* that was never actually applied.
|
|
42
42
|
*/
|
|
43
43
|
export const PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY = 'auth_zitadel_applied_image_tag';
|
|
44
|
+
/**
|
|
45
|
+
* The image has `Entrypoint /app/zitadel` and no `Cmd`, so without this it
|
|
46
|
+
* runs zitadel with no subcommand, exits, and restarts forever. Coolify turns
|
|
47
|
+
* `--entrypoint` in `custom_docker_run_options` into the compose `entrypoint`
|
|
48
|
+
* (its PATCH takes this field; it refuses `dockerfile`).
|
|
49
|
+
*/
|
|
50
|
+
export const AUTH_ZITADEL_RUN_OPTIONS = '--entrypoint "/app/zitadel start-from-init --masterkeyFromEnv --tlsMode external"';
|
|
51
|
+
/** The run options last applied to the ZITADEL app (value: the options string), so a changed value forces one redeploy. */
|
|
52
|
+
export const PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY = 'zitadel_platform_auth_run_options_applied';
|
|
44
53
|
/**
|
|
45
54
|
* #251: the Synapse/MAS image tags an org's calls Matrix stack last
|
|
46
55
|
* successful apply actually set live. `coolifyCalls` (`executor.ts`)
|
|
@@ -753,8 +762,15 @@ function planJobs(platform, byKey, claimed, observable) {
|
|
|
753
762
|
claimed.add(key);
|
|
754
763
|
if (!platform.jobs.enabled)
|
|
755
764
|
return { step: null, blocked: null };
|
|
756
|
-
if (live)
|
|
757
|
-
|
|
765
|
+
if (live) {
|
|
766
|
+
// The step also starts the scheduler's database (0.16.3), so a live app
|
|
767
|
+
// whose database the reader saw stopped plans it again. A status the
|
|
768
|
+
// reader could not read is absent, and plans nothing.
|
|
769
|
+
const databaseStatus = live.config.databaseStatus;
|
|
770
|
+
if (typeof databaseStatus !== 'string' || databaseStatus.startsWith('running')) {
|
|
771
|
+
return { step: null, blocked: null };
|
|
772
|
+
}
|
|
773
|
+
}
|
|
758
774
|
// No Coolify reader means the host wired no Coolify at all for the
|
|
759
775
|
// platform: the step's executor would throw every apply, and the scheduler
|
|
760
776
|
// could never be seen live, so it would re-plan forever. A fixed text: it
|
|
@@ -1103,7 +1119,10 @@ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authD
|
|
|
1103
1119
|
connectorApplied &&
|
|
1104
1120
|
brokerApplied &&
|
|
1105
1121
|
brokerDeployed &&
|
|
1106
|
-
loginClientApplied
|
|
1122
|
+
loginClientApplied &&
|
|
1123
|
+
// The ZITADEL run options reach the container only through the auth
|
|
1124
|
+
// step's redeploy, so a platform recorded before them still plans it.
|
|
1125
|
+
recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS) {
|
|
1107
1126
|
return { step: null, blocked: null };
|
|
1108
1127
|
}
|
|
1109
1128
|
// The admin key was minted but never reached the host. ZITADEL shows a
|
|
@@ -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`. */
|
|
@@ -770,7 +784,7 @@ export declare class ProvidersPlatformCloudflareReader implements PlatformCloudf
|
|
|
770
784
|
export declare class ProvidersPlatformCoolifyReader implements PlatformCoolifyReader {
|
|
771
785
|
private readonly client;
|
|
772
786
|
private readonly platformStore;
|
|
773
|
-
constructor(client: Pick<CoolifyPort, 'getApplication' | 'listDatabaseBackups'>, platformStore: Pick<PlatformStore, 'getServiceIds'>);
|
|
787
|
+
constructor(client: Pick<CoolifyPort, 'getApplication' | 'getDatabase' | 'listDatabaseBackups'>, platformStore: Pick<PlatformStore, 'getServiceIds'>);
|
|
774
788
|
listResources(): Promise<LiveResource[]>;
|
|
775
789
|
}
|
|
776
790
|
export {};
|
|
@@ -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 { 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_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_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 &&
|
|
@@ -871,6 +875,7 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
871
875
|
docker_registry_image_name: AUTH_ZITADEL_IMAGE,
|
|
872
876
|
docker_registry_image_tag: AUTH_ZITADEL_IMAGE_TAG,
|
|
873
877
|
ports_exposes: AUTH_ZITADEL_PORT,
|
|
878
|
+
custom_docker_run_options: AUTH_ZITADEL_RUN_OPTIONS,
|
|
874
879
|
name: PLATFORM_AUTH_ZITADEL_APP_NAME,
|
|
875
880
|
description: `ZITADEL for the platform's own sign-in (#168, ${AUTH_ZITADEL_IMAGE}:${AUTH_ZITADEL_IMAGE_TAG})`,
|
|
876
881
|
instant_deploy: false,
|
|
@@ -882,9 +887,14 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
882
887
|
// create must not leave the app unnamed for good, and the tunnel addresses
|
|
883
888
|
// the app by this name. (The port cannot read the current name back, and
|
|
884
889
|
// setting the same value again is harmless.)
|
|
890
|
+
// The run options go in the same patch, so an app made before they existed
|
|
891
|
+
// gets them too.
|
|
885
892
|
await deps.coolify.updateApplication(zitadelAppId, {
|
|
886
893
|
custom_internal_name: PLATFORM_AUTH_ZITADEL_INTERNAL_NAME,
|
|
894
|
+
custom_docker_run_options: AUTH_ZITADEL_RUN_OPTIONS,
|
|
887
895
|
});
|
|
896
|
+
// The container only takes new run options at its next deploy.
|
|
897
|
+
const zitadelRunOptionsNew = recorded[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] !== AUTH_ZITADEL_RUN_OPTIONS;
|
|
888
898
|
// #251: only on create, never unconditionally -- nothing here patches
|
|
889
899
|
// `docker_registry_image_tag` on an app `findOrCreatePlatformApp` found
|
|
890
900
|
// already running, so that is the only moment this tag is actually live.
|
|
@@ -1012,9 +1022,12 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
1012
1022
|
await deps.coolify.bulkUpsertEnvs(brokerAppId, brokerEnvs);
|
|
1013
1023
|
// Deploy an app that was just created, is not running (a throw after the
|
|
1014
1024
|
// create left it never deployed, or it stopped), or whose envs changed.
|
|
1015
|
-
if (zitadelApp.justCreated || !zitadelApp.running || zitadelEnvsChanged) {
|
|
1025
|
+
if (zitadelApp.justCreated || !zitadelApp.running || zitadelEnvsChanged || zitadelRunOptionsNew) {
|
|
1016
1026
|
await deps.coolify.deploy(zitadelAppId);
|
|
1017
1027
|
await record(PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY, zitadelAppId);
|
|
1028
|
+
// Only now: the options are live once the deploy went out. A throw between
|
|
1029
|
+
// the PATCH and here leaves the marker unset, so the next run deploys.
|
|
1030
|
+
await record(PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, AUTH_ZITADEL_RUN_OPTIONS);
|
|
1018
1031
|
}
|
|
1019
1032
|
if (loginApp.justCreated || !loginApp.running || loginEnvsChanged) {
|
|
1020
1033
|
await deps.coolify.deploy(loginAppId);
|
|
@@ -3219,9 +3232,13 @@ const PLATFORM_AI_WORKER_DEFAULTS = [
|
|
|
3219
3232
|
* rotates it yet, so the platform loses its AI management key after 90 days.
|
|
3220
3233
|
* - Deploys are decided from the recorded "pending" mark and the app's own
|
|
3221
3234
|
* status, not from a deployment's age: the port cannot read one.
|
|
3222
|
-
* -
|
|
3223
|
-
*
|
|
3224
|
-
*
|
|
3235
|
+
* - Sign-in (`AUTH_*`) is not set: until an operator supplies it the host
|
|
3236
|
+
* answers `503 not_configured` for sign-in, as its README says.
|
|
3237
|
+
* - `KEYS_WORKER_URL`, `KEYS_CREDENTIAL` and `KEYS_VERIFY_KEYS` come from
|
|
3238
|
+
* {@link PlatformAiDeps.keys} and are set on the host and the worker. They
|
|
3239
|
+
* gate more than provider work: ai builds the issuer behind
|
|
3240
|
+
* `POST /v1/platform/keys` only when all three are set, so without them
|
|
3241
|
+
* foundry's own management key can never be minted.
|
|
3225
3242
|
*/
|
|
3226
3243
|
async function provisionPlatformAi(context, deps) {
|
|
3227
3244
|
const { platform } = context;
|
|
@@ -3231,6 +3248,15 @@ async function provisionPlatformAi(context, deps) {
|
|
|
3231
3248
|
const runtime = deps.runtime ?? {};
|
|
3232
3249
|
const sleep = runtime.sleep ?? defaultSleep;
|
|
3233
3250
|
const { coolify } = deps;
|
|
3251
|
+
const keys = aiKeys(deps);
|
|
3252
|
+
if (!keys) {
|
|
3253
|
+
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");
|
|
3254
|
+
}
|
|
3255
|
+
const keysEnvs = [
|
|
3256
|
+
{ key: 'KEYS_WORKER_URL', value: keys.workerUrl },
|
|
3257
|
+
{ key: 'KEYS_CREDENTIAL', value: keys.credential },
|
|
3258
|
+
{ key: 'KEYS_VERIFY_KEYS', value: keys.verifyKeys },
|
|
3259
|
+
];
|
|
3234
3260
|
const record = async (key, id) => {
|
|
3235
3261
|
context.recordScopeId(key, id);
|
|
3236
3262
|
await context.persist?.();
|
|
@@ -3315,10 +3341,12 @@ async function provisionPlatformAi(context, deps) {
|
|
|
3315
3341
|
const runnerEnvs = [
|
|
3316
3342
|
{ key: 'DATABASE_URL', value: runtimeUrl },
|
|
3317
3343
|
{ key: 'APP_URL', value: `https://${hostname}` },
|
|
3344
|
+
...keysEnvs,
|
|
3318
3345
|
...(await missingEnvs(coolify, runner.appId, PLATFORM_AI_RUNNER_DEFAULTS)),
|
|
3319
3346
|
];
|
|
3320
3347
|
const workerEnvs = [
|
|
3321
3348
|
{ key: 'DATABASE_URL', value: runtimeUrl },
|
|
3349
|
+
...keysEnvs,
|
|
3322
3350
|
...(await missingEnvs(coolify, worker.appId, PLATFORM_AI_WORKER_DEFAULTS)),
|
|
3323
3351
|
];
|
|
3324
3352
|
// Whether anything the containers run on differs from what they last
|
|
@@ -4118,6 +4146,10 @@ async function provisionPlatformFiles(context, deps) {
|
|
|
4118
4146
|
await record(PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
4119
4147
|
return { resourceId: runnerApp.appId };
|
|
4120
4148
|
}
|
|
4149
|
+
function aiKeys(deps) {
|
|
4150
|
+
const keys = deps?.keys;
|
|
4151
|
+
return keys?.workerUrl && keys.credential && keys.verifyKeys ? keys : null;
|
|
4152
|
+
}
|
|
4121
4153
|
function filesKeys(deps) {
|
|
4122
4154
|
const keys = deps?.keys;
|
|
4123
4155
|
return keys?.workerUrl && keys.credential ? keys : null;
|
|
@@ -5029,11 +5061,27 @@ export class ProvidersPlatformCoolifyReader {
|
|
|
5029
5061
|
if (recordedAppId) {
|
|
5030
5062
|
try {
|
|
5031
5063
|
const app = await this.client.getApplication(recordedAppId);
|
|
5064
|
+
// The scheduler's database status, so `planJobs` can see a stopped
|
|
5065
|
+
// one. Left out when it cannot be read: unknown is not "stopped".
|
|
5066
|
+
let databaseStatus;
|
|
5067
|
+
const databaseId = recorded[JOBS_DATABASE_SCOPE_KEY];
|
|
5068
|
+
if (databaseId) {
|
|
5069
|
+
try {
|
|
5070
|
+
databaseStatus = (await this.client.getDatabase(databaseId)).status;
|
|
5071
|
+
}
|
|
5072
|
+
catch {
|
|
5073
|
+
databaseStatus = undefined;
|
|
5074
|
+
}
|
|
5075
|
+
}
|
|
5032
5076
|
resources.push({
|
|
5033
5077
|
system: 'coolify',
|
|
5034
5078
|
kind: 'jobs',
|
|
5035
5079
|
key: JOBS_SCHEDULER_KEY,
|
|
5036
|
-
config: {
|
|
5080
|
+
config: {
|
|
5081
|
+
uuid: app.uuid,
|
|
5082
|
+
status: app.status,
|
|
5083
|
+
...(databaseStatus !== undefined && { databaseStatus }),
|
|
5084
|
+
},
|
|
5037
5085
|
});
|
|
5038
5086
|
}
|
|
5039
5087
|
catch {
|
|
@@ -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;
|