@wtfalch/foundry 0.42.1 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/bootstrap/versions.d.ts +2 -0
  2. package/dist/index.d.ts +8 -4
  3. package/dist/index.js +4 -2
  4. package/dist/migrations/0055_release_requests.sql +20 -0
  5. package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
  6. package/dist/migrations/0057_org_customers.sql +29 -0
  7. package/dist/org.d.ts +55 -0
  8. package/dist/org.js +90 -27
  9. package/dist/plan/archon-blue-green.d.ts +54 -0
  10. package/dist/plan/archon-blue-green.js +72 -0
  11. package/dist/plan/blue-green-apply.d.ts +82 -0
  12. package/dist/plan/blue-green-apply.js +360 -0
  13. package/dist/plan/blue-green.d.ts +223 -0
  14. package/dist/plan/blue-green.js +444 -0
  15. package/dist/plan/boule.d.ts +43 -2
  16. package/dist/plan/boule.js +48 -1
  17. package/dist/plan/broker-entries.d.ts +28 -0
  18. package/dist/plan/broker-entries.js +46 -1
  19. package/dist/plan/cloudflared-connector.d.ts +26 -0
  20. package/dist/plan/cloudflared-connector.js +37 -0
  21. package/dist/plan/diff.d.ts +20 -0
  22. package/dist/plan/diff.js +207 -20
  23. package/dist/plan/executor.d.ts +134 -2
  24. package/dist/plan/executor.js +517 -25
  25. package/dist/plan/hostnames.d.ts +7 -0
  26. package/dist/plan/hostnames.js +17 -2
  27. package/dist/plan/locked-hostnames.d.ts +11 -0
  28. package/dist/plan/locked-hostnames.js +13 -0
  29. package/dist/plan/mcp.d.ts +57 -0
  30. package/dist/plan/mcp.js +131 -0
  31. package/dist/plan/org-names.d.ts +2 -2
  32. package/dist/plan/org-names.js +16 -1
  33. package/dist/plan/plan-apply.d.ts +77 -0
  34. package/dist/plan/plan-apply.js +267 -5
  35. package/dist/plan/platform-diff.d.ts +9 -0
  36. package/dist/plan/platform-diff.js +24 -1
  37. package/dist/plan/platform-providers.d.ts +15 -1
  38. package/dist/plan/platform-providers.js +90 -5
  39. package/dist/plan/proxy-labels.d.ts +2 -2
  40. package/dist/plan/proxy-labels.js +14 -4
  41. package/dist/plan/release-host.d.ts +59 -0
  42. package/dist/plan/release-host.js +239 -0
  43. package/dist/plan/remote-database.d.ts +124 -0
  44. package/dist/plan/remote-database.js +373 -0
  45. package/dist/plan/worker-app-database.d.ts +148 -0
  46. package/dist/plan/worker-app-database.js +424 -0
  47. package/dist/plan/worker-app.d.ts +320 -0
  48. package/dist/plan/worker-app.js +792 -0
  49. package/dist/plan/zitadel-machine-user.d.ts +13 -1
  50. package/dist/plan/zitadel-machine-user.js +80 -0
  51. package/dist/platform.d.ts +11 -1
  52. package/dist/platform.js +4 -1
  53. package/dist/release/decide.d.ts +95 -0
  54. package/dist/release/decide.js +242 -0
  55. package/dist/release/health.d.ts +48 -0
  56. package/dist/release/health.js +61 -0
  57. package/dist/release/run.d.ts +40 -0
  58. package/dist/release/run.js +56 -0
  59. package/dist/release/state.d.ts +64 -0
  60. package/dist/release/state.js +126 -0
  61. package/dist/release/types.d.ts +54 -0
  62. package/dist/release/types.js +4 -0
  63. package/dist/requested-changes.d.ts +12 -3
  64. package/dist/requested-changes.js +22 -0
  65. package/dist/store/postgres.d.ts +8 -0
  66. package/dist/store/postgres.js +67 -8
  67. package/dist/store/schema.d.ts +188 -0
  68. package/dist/store/schema.js +15 -0
  69. package/dist/worker-app-config.d.ts +151 -0
  70. package/dist/worker-app-config.js +438 -0
  71. package/package.json +1 -1
  72. package/versions.digests.json +2 -1
  73. package/versions.json +2 -1
@@ -0,0 +1,444 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { VERSIONS, pinnedRef } from '../bootstrap/versions.js';
3
+ import { serverConnectorScopeKey } from './cloudflared-connector.js';
4
+ import { entryHostnames, tunnelIngressHostnames, wildcardHostname } from './hostnames.js';
5
+ import { appNameFor, appStepKey } from './platform-diff.js';
6
+ import { remoteDatabaseAppliedFor } from './remote-database.js';
7
+ export const COLORS = ['blue', 'green'];
8
+ /** The name of `base`'s copy in `color`: `<base>-blue`. */
9
+ export function colorAppName(base, color) {
10
+ return `${base}-${color}`;
11
+ }
12
+ export const lbAppName = (base) => `${base}-lb`;
13
+ export const stageAppName = (base) => `${base}-stage`;
14
+ /** The color a name carries, or `null`. */
15
+ export function colorOfName(name) {
16
+ return COLORS.find((color) => name.endsWith(`-${color}`)) ?? null;
17
+ }
18
+ /** Live blue until a release moves it. */
19
+ export const DEFAULT_WEIGHTS = 'blue:100,green:0';
20
+ /** `blue:95,green:5`. Whole percentages that sum to 100, so a typo cannot send traffic nowhere. */
21
+ export function parseWeights(raw) {
22
+ const text = raw ?? DEFAULT_WEIGHTS;
23
+ const match = /^blue:(\d{1,3}),green:(\d{1,3})$/.exec(text);
24
+ if (!match)
25
+ return null;
26
+ const weights = { blue: Number(match[1]), green: Number(match[2]) };
27
+ return weights.blue + weights.green === 100 ? weights : null;
28
+ }
29
+ export const formatWeights = (w) => `blue:${w.blue},green:${w.green}`;
30
+ /** The scope keys of a blue/green subject (contract C3). `.` separated, no `:`. */
31
+ export const bgKey = {
32
+ live: (id) => `bg.${id}.live`,
33
+ digest: (id, color) => `bg.${id}.digest.${color}`,
34
+ weights: (id) => `bg.${id}.weights`,
35
+ stage: (id) => `bg.${id}.stage`,
36
+ window: (id) => `bg.${id}.window`,
37
+ held: (id, digest) => `bg.${id}.held.${digest}`,
38
+ /** The weights the load balancer on `server` was last written with. */
39
+ lb: (id, server) => `bg.${id}.lb.${server}`,
40
+ /** The hash of the structure (apps, balancer, stage) last applied on `server`. */
41
+ applied: (id, server) => `bg.${id}.applied.${server}`,
42
+ /** The Coolify app of one part (`blue`, `green`, `lb`, `stage`) on `server`. */
43
+ app: (id, server, part) => `bg.${id}.app.${server}.${part}`,
44
+ /** The cloudflared connector app of the tunnel on a non-primary `server`. */
45
+ connector: (id, server) => `bg.${id}.connector.${server}`,
46
+ stageDatabase: (id) => `bg.${id}.stagedb`,
47
+ /** The Coolify name of the blue app on the primary: the adopted single app's name, or `<base>-blue`. */
48
+ blueName: (id) => `bg.${id}.bluename`,
49
+ /** The pin key of the balancer's header routers. Not a credential: it only pins a color. */
50
+ pinKey: (id) => `bg.${id}.pinkey`,
51
+ };
52
+ /** The three env names the reporting health read uses (contract C1). */
53
+ export const REPORTING_ENV = {
54
+ color: 'REPORTING_COLOR',
55
+ release: 'REPORTING_RELEASE',
56
+ healthToken: 'REPORTING_HEALTH_TOKEN',
57
+ };
58
+ /** The held-key name of an entry's health token (`sharedSecretRef(org.id, name)`): one mint per entry. */
59
+ export const healthTokenName = (id) => `reporting-health-${id}`;
60
+ /** What a color's container gets for the reporting health read. The release is left out until a digest is known. */
61
+ export function reportingEnv(input) {
62
+ return [
63
+ { key: REPORTING_ENV.color, value: input.color },
64
+ ...(input.release ? [{ key: REPORTING_ENV.release, value: input.release }] : []),
65
+ { key: REPORTING_ENV.healthToken, value: input.healthToken },
66
+ ];
67
+ }
68
+ /**
69
+ * A hosting entry that declares `blueGreen`, as a subject; `null` when it does not or cannot run.
70
+ * `recorded` names the blue app: an app that ran before blue/green keeps its own name (see {@link liveAppNameFor}).
71
+ */
72
+ export function entrySubject(org, entry, recorded) {
73
+ if (!entry.blueGreen || entry.repo === null || !org.hosting.enabled)
74
+ return null;
75
+ const base = appNameFor(org, entry);
76
+ const isStatic = entry.buildPack === 'static';
77
+ return {
78
+ id: entry.key,
79
+ base,
80
+ primaryBlueName: liveAppNameFor(org, entry, recorded),
81
+ port: isStatic ? 80 : (entry.port ?? 80),
82
+ repo: entry.repo,
83
+ folder: entry.folder,
84
+ branch: entry.branch,
85
+ buildPack: entry.buildPack,
86
+ healthPath: entry.healthPath ?? (isStatic ? '/' : '/api/health'),
87
+ database: entry.database,
88
+ hostnames: entryHostnames(org, entry),
89
+ config: entry.blueGreen,
90
+ };
91
+ }
92
+ /**
93
+ * The Coolify name of the entry's app on the primary. A blue/green entry's app is its blue copy: the recorded
94
+ * name; else the entry's own name when its app is already recorded (it ran before blue/green and is adopted as
95
+ * blue, never renamed); else `<base>-blue` for an entry with no app yet.
96
+ */
97
+ export function liveAppNameFor(org, entry, recorded = {}) {
98
+ const base = appNameFor(org, entry);
99
+ if (!entry.blueGreen)
100
+ return base;
101
+ const named = recorded[bgKey.blueName(entry.key)];
102
+ if (named)
103
+ return named;
104
+ return recorded[`coolify:app:${appStepKey(org, entry.key)}`] ? base : colorAppName(base, 'blue');
105
+ }
106
+ /** Whether the balancer on the primary is written and the structure applied: the tunnel may move to it. */
107
+ export function balancerApplied(id, recorded, primaryUuid) {
108
+ return Boolean(primaryUuid && recorded[bgKey.lb(id, primaryUuid)] && recorded[bgKey.applied(id, primaryUuid)]);
109
+ }
110
+ /** The origin the tunnel routes a subject's names to: its balancer, reached on each server's own network. */
111
+ export function lbOrigin(subject) {
112
+ return `http://${lbAppName(subject.base)}:${subject.port}`;
113
+ }
114
+ /** Usable and reachable servers in the order Coolify lists them, and the uuids of the rest. */
115
+ export function splitServers(all) {
116
+ const up = [];
117
+ const down = [];
118
+ for (const server of all)
119
+ (server.usable && server.reachable ? up : down).push(server.uuid);
120
+ return { up, down };
121
+ }
122
+ /** The scope key of the org's primary server (`COOLIFY_SERVER_SCOPE_KEY` in `executor.ts`). */
123
+ export const PRIMARY_SERVER_SCOPE_KEY = 'coolify_server';
124
+ // ---- the load balancer -----------------------------------------------------
125
+ /** The sticky cookie that keeps a user on one color. */
126
+ export const LB_COOKIE_NAME = 'bg_color';
127
+ /** A request with this header goes to the named color whatever the weights; the release runner checks a color with it. */
128
+ export const LB_COLOR_HEADER = 'X-Foundry-Color';
129
+ /** A pinned request also carries this header with the entry's pin key, so a stranger cannot pin a color. */
130
+ export const LB_PIN_HEADER = 'X-Foundry-Pin';
131
+ const PIN_KEY = /^[0-9a-f]{64}$/;
132
+ /** Above the default priority of `app` (its rule length), so a pinned request never reaches the weighted service. */
133
+ const LB_PIN_PRIORITY = 10000;
134
+ export const LB_CONFIG_PATH = '/etc/traefik/dynamic/lb.yml';
135
+ /** Where the balancer's config lives inside the container, mounted as one file. */
136
+ const LB_ENTRYPOINT = 'web';
137
+ /**
138
+ * The balancer's inline Dockerfile: a pinned Traefik with a file provider watching one directory.
139
+ * Weights are never in here, so a weight change is a config write and never a redeploy.
140
+ */
141
+ export function lbDockerfile(port) {
142
+ return [
143
+ `FROM traefik:${pinnedRef('traefik')}`,
144
+ `CMD ["--providers.file.directory=/etc/traefik/dynamic", "--providers.file.watch=true", "--entrypoints.${LB_ENTRYPOINT}.address=:${port}", "--entrypoints.traefik.address=:8080", "--ping=true", "--log.level=WARN", "--accesslog=false", "--api=false"]`,
145
+ 'HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 CMD ["traefik", "healthcheck", "--ping"]',
146
+ '',
147
+ ].join('\n');
148
+ }
149
+ /** The traefik tag the balancer runs, for a test that keeps the pin and the Dockerfile equal. */
150
+ export const LB_IMAGE_REF = `traefik:${VERSIONS.traefik}`;
151
+ /**
152
+ * The balancer's dynamic config: two weighted services and a sticky cookie. A color at weight 0 is left out
153
+ * of the weighted list, because Traefik sends a request that carries a sticky cookie to the named service
154
+ * whatever its weight, which would keep a rolled-back user on the failed color. The weights are in the first
155
+ * line, so a reader sees both numbers. `backends` are the URLs of each color's containers, one per server.
156
+ */
157
+ export function renderLbConfig(input) {
158
+ const { weights, backends, pinKey } = input;
159
+ if (!PIN_KEY.test(pinKey))
160
+ throw new Error('the load balancer pin key is not 64 hex characters');
161
+ const listed = COLORS.filter((color) => weights[color] > 0);
162
+ const lines = [
163
+ `# weights ${formatWeights(weights)}`,
164
+ 'http:',
165
+ ' routers:',
166
+ ...COLORS.flatMap((color) => [
167
+ ` pin-${color}:`,
168
+ ` rule: Header(\`${LB_COLOR_HEADER}\`, \`${color}\`) && Header(\`${LB_PIN_HEADER}\`, \`${pinKey}\`)`,
169
+ ` priority: ${LB_PIN_PRIORITY}`,
170
+ ` entryPoints: [${LB_ENTRYPOINT}]`,
171
+ ` service: ${color}`,
172
+ ]),
173
+ ' app:',
174
+ ' rule: PathPrefix(`/`)',
175
+ ` entryPoints: [${LB_ENTRYPOINT}]`,
176
+ ' service: weighted',
177
+ ' services:',
178
+ ' weighted:',
179
+ ' weighted:',
180
+ ' sticky:',
181
+ ' cookie:',
182
+ ` name: ${LB_COOKIE_NAME}`,
183
+ ' httpOnly: true',
184
+ ' secure: true',
185
+ ' sameSite: lax',
186
+ ' services:',
187
+ ...listed.flatMap((color) => [
188
+ ` - name: ${color}`,
189
+ ` weight: ${weights[color]}`,
190
+ ]),
191
+ ];
192
+ for (const color of COLORS) {
193
+ lines.push(` ${color}:`, ' loadBalancer:', ' servers:', ...backends[color].map((url) => ` - url: ${url}`));
194
+ }
195
+ lines.push('');
196
+ return lines.join('\n');
197
+ }
198
+ /**
199
+ * The URL of one color's container on its own server's network. On the primary, blue is the app that was
200
+ * already there, under its own name; every other color is `<base>-<color>`.
201
+ */
202
+ export const colorBackend = (subject, color, onPrimary) => `http://${onPrimary && color === 'blue' ? subject.primaryBlueName : colorAppName(subject.base, color)}:${subject.port}`;
203
+ /** A short hash of what decides the shape of a server's apps, so a change replans the server. */
204
+ export function structureHash(subject, input) {
205
+ return createHash('sha256')
206
+ .update(JSON.stringify({
207
+ base: subject.base,
208
+ port: subject.port,
209
+ repo: subject.repo,
210
+ folder: subject.folder,
211
+ branch: subject.branch,
212
+ buildPack: subject.buildPack,
213
+ healthPath: subject.healthPath,
214
+ database: subject.database,
215
+ primary: input.primary,
216
+ lb: VERSIONS.traefik,
217
+ }))
218
+ .digest('hex')
219
+ .slice(0, 16);
220
+ }
221
+ /** Hostnames of the tunnel that no blue/green entry serves; a connector on a second server cannot answer them. */
222
+ export function namesNotBlueGreen(org) {
223
+ const served = new Set();
224
+ for (const entry of org.hosting.entries) {
225
+ if (!entry.blueGreen)
226
+ continue;
227
+ for (const name of entryHostnames(org, entry))
228
+ served.add(name);
229
+ if (entry.hostnames.includes('*')) {
230
+ const wildcard = wildcardHostname(org);
231
+ if (wildcard)
232
+ served.add(wildcard);
233
+ }
234
+ }
235
+ return tunnelIngressHostnames(org).filter((name) => !served.has(name));
236
+ }
237
+ const MAX_NAME = 63;
238
+ /** Restated from `executor.ts`, the same rule as `diff.ts` does: the app id of the org tunnel's cloudflared connector. */
239
+ const TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_app';
240
+ /**
241
+ * Plans the blue/green parts of an org: per usable server, one structure step (the apps, the balancer, the
242
+ * stage on the primary) when the recorded shape differs, else one balancer-config step when only the weights
243
+ * differ. A server that is down is skipped and reported in `unmanaged`. Pure: reads nothing but its input.
244
+ */
245
+ export function planBlueGreen(input) {
246
+ const { org, recordedIds } = input;
247
+ const steps = [];
248
+ const unmanaged = [];
249
+ for (const entry of org.hosting.entries) {
250
+ const subject = entrySubject(org, entry, recordedIds);
251
+ if (!subject)
252
+ continue;
253
+ const id = subject.id;
254
+ const blocked = (why, part = 'blocked') => unmanaged.push({
255
+ description: `${org.slug}'s ${id} app blue/green is blocked, not planned: ${why}`,
256
+ key: `bg:${id}:${part}`,
257
+ });
258
+ if (entry.buildPack === 'dockercompose')
259
+ continue;
260
+ if (colorAppName(subject.base, 'green').length > MAX_NAME) {
261
+ blocked('its app name is too long for the green copy; shorten the slug or the entry key');
262
+ continue;
263
+ }
264
+ if (!input.servers) {
265
+ blocked('the host cannot list Coolify servers');
266
+ continue;
267
+ }
268
+ if (!input.canHoldSecrets) {
269
+ blocked('the host cannot hold the health token (its held keys cannot put)');
270
+ continue;
271
+ }
272
+ const weights = parseWeights(recordedIds[bgKey.weights(id)]);
273
+ if (!weights) {
274
+ blocked(`the recorded weights are not 'blue:<n>,green:<n>' summing to 100`, 'weights');
275
+ continue;
276
+ }
277
+ if (!recordedIds[`coolify:app:${appStepKey(org, id)}`] && !input.appPlanned(id)) {
278
+ blocked('its blue copy is not recorded and no app step is planned to make it');
279
+ continue;
280
+ }
281
+ const { up, down } = splitServers(input.servers);
282
+ const recordedPrimary = recordedIds[PRIMARY_SERVER_SCOPE_KEY];
283
+ const primary = recordedPrimary ?? up[0];
284
+ if (!primary || !up.includes(primary)) {
285
+ blocked(`the primary Coolify server ${primary ?? '(none)'} is not usable and reachable`);
286
+ continue;
287
+ }
288
+ for (const uuid of down) {
289
+ unmanaged.push({
290
+ description: `${org.slug}'s ${id} app has no blue/green copies on Coolify server ${uuid}: the server is not usable or not reachable right now`,
291
+ key: `bg:${id}:down:${uuid}`,
292
+ });
293
+ }
294
+ const others = up.filter((uuid) => uuid !== primary);
295
+ const unrouted = namesNotBlueGreen(org);
296
+ const useOthers = others.length > 0 && unrouted.length === 0;
297
+ if (others.length > 0 && !useOthers) {
298
+ blocked('a second server needs every name on the tunnel to be blue/green, because its connector answers all of them', 'servers');
299
+ }
300
+ const targets = [primary, ...(useOthers ? others : [])];
301
+ if (useOthers && recordedIds[`cloudflare:tunnel:${org.id}`]) {
302
+ for (const server of others) {
303
+ const key = `${org.id}:${server}`;
304
+ if (!recordedIds[serverConnectorScopeKey(TUNNEL_CONNECTOR_APP_SCOPE_KEY, server)] &&
305
+ !steps.some((s) => s.kind === 'bluegreen-connector' && s.key === key)) {
306
+ steps.push({
307
+ kind: 'bluegreen-connector',
308
+ phase: 'after',
309
+ key,
310
+ description: `run a cloudflared connector for ${org.slug}'s Cloudflare tunnel on Coolify server ${server}`,
311
+ desired: { server, primary },
312
+ });
313
+ }
314
+ }
315
+ }
316
+ for (const server of targets) {
317
+ const isPrimary = server === primary;
318
+ const key = `${org.id}:${id}:${server}`;
319
+ const hash = structureHash(subject, { primary: isPrimary });
320
+ if (!isPrimary && subject.database && !remoteDatabaseAppliedFor(recordedIds, id, server)) {
321
+ unmanaged.push({
322
+ description: `${org.slug}'s ${id} app has no blue/green copies on Coolify server ${server} yet: its database route to that server is not applied`,
323
+ key: `bg:${id}:dbroute:${server}`,
324
+ });
325
+ continue;
326
+ }
327
+ if (recordedIds[bgKey.applied(id, server)] !== hash) {
328
+ steps.push({
329
+ kind: 'bluegreen',
330
+ phase: 'before',
331
+ key,
332
+ description: `run ${org.slug}'s ${id} app as blue and green behind a load balancer on Coolify server ${server}${isPrimary ? ', with a stage copy' : ''}`,
333
+ desired: { entry: id, server, primary, weights: formatWeights(weights) },
334
+ });
335
+ }
336
+ else if (recordedIds[bgKey.lb(id, server)] !== formatWeights(weights)) {
337
+ steps.push({
338
+ kind: 'bluegreen-lb',
339
+ phase: 'before',
340
+ key,
341
+ description: `set ${org.slug}'s ${id} app traffic to ${formatWeights(weights)} on Coolify server ${server}`,
342
+ desired: { entry: id, server, primary, weights: formatWeights(weights) },
343
+ });
344
+ }
345
+ }
346
+ }
347
+ return { steps, unmanaged };
348
+ }
349
+ // ---- platform-archon -------------------------------------------------------
350
+ /** The scope-key id, base name and port platform-archon runs under. */
351
+ export const ARCHON_SUBJECT_ID = 'platform-archon';
352
+ /** https://github.com/wtfalch/archon: one Dockerfile at its root, `runner` stage on port 3000. */
353
+ export const ARCHON_REPO = 'wtfalch/archon';
354
+ const ARCHON_PORT_NUMBER = 3000;
355
+ /**
356
+ * platform-archon as a blue/green subject: the same type a hosting entry makes. Its running app keeps its
357
+ * name and serves as the blue copy, because renaming its container would take its sign-in and tunnel down
358
+ * mid-apply; green and the balancer are new apps beside it.
359
+ */
360
+ export function archonSubject(platform, repo) {
361
+ const { archon } = platform;
362
+ if (!archon.enabled || !archon.blueGreen)
363
+ return null;
364
+ return {
365
+ id: ARCHON_SUBJECT_ID,
366
+ base: ARCHON_SUBJECT_ID,
367
+ primaryBlueName: ARCHON_SUBJECT_ID,
368
+ port: ARCHON_PORT_NUMBER,
369
+ repo,
370
+ folder: null,
371
+ branch: archon.gitRef,
372
+ buildPack: 'dockerfile',
373
+ healthPath: '/api/health',
374
+ database: true,
375
+ hostnames: [archon.hostname],
376
+ config: archon.blueGreen,
377
+ };
378
+ }
379
+ /**
380
+ * Where the tunnel sends a subject's names: its balancer once the structure on the primary is applied (the
381
+ * balancer's config written and every part running), else the app as before. Pointing the tunnel at a balancer
382
+ * that does not exist or does not answer yet would take the site down.
383
+ */
384
+ export function tunnelOriginFor(subject, recorded, primaryUuid, fallback) {
385
+ if (!subject || !balancerApplied(subject.id, recorded, primaryUuid))
386
+ return fallback;
387
+ return lbOrigin(subject);
388
+ }
389
+ /** The origin the archon step last wrote to the tunnel through {@link tunnelOriginFor}, recorded by the platform step. */
390
+ export const ARCHON_BG_ORIGIN_SCOPE_KEY = 'archon_bluegreen_origin';
391
+ /** The platform scope keys the archon plan reads (restated from `platform-diff.ts`, which imports this module). */
392
+ const ARCHON_APP_RECORD = 'coolify:app:platform:archon';
393
+ const ARCHON_TUNNEL_RECORD = 'cloudflare_platform_archon_tunnel';
394
+ /**
395
+ * Plans the one platform step that runs platform-archon on the blue/green path: the structure on the primary
396
+ * server, the balancer's weights, and the tunnel's move to the balancer. It plans nothing until archon's app and
397
+ * tunnel are recorded, because the step copies the app's env and moves that tunnel. Archon runs on the primary
398
+ * only: the platform has no database route to another server.
399
+ */
400
+ export function planArchonBlueGreen(input) {
401
+ const subject = archonSubject(input.platform, input.repo);
402
+ const none = { step: null, unmanaged: [] };
403
+ if (!subject)
404
+ return none;
405
+ const { recorded } = input;
406
+ if (!recorded[ARCHON_APP_RECORD] || !recorded[ARCHON_TUNNEL_RECORD])
407
+ return none;
408
+ const blocked = (why) => ({
409
+ step: null,
410
+ unmanaged: [
411
+ {
412
+ description: `platform-archon blue/green is blocked, not planned: ${why}`,
413
+ key: `bg:${subject.id}:blocked`,
414
+ },
415
+ ],
416
+ });
417
+ if (input.executorBlocked)
418
+ return blocked(input.executorBlocked);
419
+ if (!input.servers)
420
+ return blocked('the host cannot list Coolify servers');
421
+ const weights = parseWeights(recorded[bgKey.weights(subject.id)]);
422
+ if (!weights)
423
+ return blocked(`the recorded weights are not 'blue:<n>,green:<n>' summing to 100`);
424
+ const { up } = splitServers(input.servers);
425
+ const primary = recorded[PRIMARY_SERVER_SCOPE_KEY] ?? up[0];
426
+ if (!primary || !up.includes(primary)) {
427
+ return blocked(`the primary Coolify server ${primary ?? '(none)'} is not usable and reachable`);
428
+ }
429
+ const done = recorded[bgKey.applied(subject.id, primary)] === structureHash(subject, { primary: true }) &&
430
+ recorded[bgKey.lb(subject.id, primary)] === formatWeights(weights) &&
431
+ recorded[ARCHON_BG_ORIGIN_SCOPE_KEY] === lbOrigin(subject);
432
+ if (done)
433
+ return none;
434
+ return {
435
+ unmanaged: [],
436
+ step: {
437
+ kind: 'bluegreen',
438
+ phase: 'after',
439
+ key: subject.id,
440
+ description: `run platform-archon as blue and green behind a load balancer on Coolify server ${primary}, then send its tunnel to the balancer`,
441
+ desired: { subject: subject.id, primary, weights: formatWeights(weights) },
442
+ },
443
+ };
444
+ }
@@ -79,8 +79,44 @@ export declare const BOULE_PEER_PASSWORD_ENV = "AUTHZ_PEER_PASSWORD_OPERATOR";
79
79
  export declare const BOULE_BRIDGE_SECRET_ENV = "ARCHON_BRIDGE_SECRET";
80
80
  /** The role Boule creates from {@link BOULE_PEER_PASSWORD_ENV}, with grants on schema `authz` only. */
81
81
  export declare const BOULE_PEER_ROLE = "authz_operator_rt";
82
+ /** The org's Archon id and the credential Boule reaches Archon with (C3). The id is the org's own; the credential is a secret. */
83
+ export declare const BOULE_ARCHON_ORG_ID_ENV = "ARCHON_ORG_ID";
84
+ export declare const BOULE_ARCHON_CREDENTIAL_ENV = "ARCHON_CREDENTIAL";
85
+ /** The company's customers organisation in the sign-in service, and the read-only credential that lists its people (C3). */
86
+ export declare const BOULE_CUSTOMERS_ORG_ENV = "CUSTOMERS_ORGANISATION_ID";
87
+ export declare const BOULE_CUSTOMERS_CREDENTIAL_ENV = "CUSTOMERS_DIRECTORY_CREDENTIAL";
88
+ /**
89
+ * When the Archon credential was last minted and held (ISO date-time). No secret. Archon's mint rotates
90
+ * on every call and the credential lives 365 days, so foundry mints only when none was pushed or the
91
+ * push is {@link ARCHON_CREDENTIAL_RENEW_AFTER_MS} old.
92
+ */
93
+ export declare const BOULE_ARCHON_PUSHED_AT_SCOPE_KEY = "boule_archon_pushed_at";
94
+ export declare const ARCHON_CREDENTIAL_RENEW_AFTER_MS: number;
95
+ /** True when no push is recorded, it is unreadable, or it is at least 335 days old. */
96
+ export declare function archonCredentialRenewDue(recorded: Readonly<Record<string, string>>, nowMs: number): boolean;
97
+ /** The ZITADEL machine user that reads the customers organisation. No secret. */
98
+ export declare const CUSTOMERS_DIRECTORY_USER_SCOPE_KEY = "customers_directory_user";
99
+ /** `1` or `0`: the sign-up switch last written to the customers organisation's login policy. */
100
+ export declare const CUSTOMERS_SIGN_UP_SCOPE_KEY = "zitadel_customers_signup";
101
+ /** Where the held-key store keeps the Archon credential of an org's Boule. */
102
+ export declare const bouleArchonCredentialRef: (orgId: string) => string;
103
+ /** Where the held-key store keeps the directory credential of an org's customers. */
104
+ export declare const customersDirectoryRef: (orgId: string) => string;
105
+ /** The customers organisation id `zitadel:customers` recorded. */
106
+ export declare const customersOrgIdOf: (org: Pick<DeclaredOrg, "id">, recorded: Readonly<Record<string, string>>) => string | undefined;
107
+ /**
108
+ * What the host can deliver to an org's Boule beyond its sign-in env: the Archon credential
109
+ * (`mintBouleArchonCredential`) and the customers directory credential (`mintOrgDirectoryReader`).
110
+ * Each needs a held-key store that can put. The plan decides once and carries the answer in the step.
111
+ */
112
+ export interface BouleCredentialWants {
113
+ archonCredential?: boolean;
114
+ customersDirectory?: boolean;
115
+ /** The recorded Archon credential is missing or 335 days old: the step mints it again. */
116
+ archonRenewDue?: boolean;
117
+ }
82
118
  /** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
83
- export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "AUTHZ_FIRST_OWNER", "ARCHON_URL", "MAIL_FROM", "LETTERMINT_API_TOKEN", "AUTHZ_PEER_PASSWORD_OPERATOR", "ARCHON_BRIDGE_SECRET", "SIGNIN_UPSTREAM"];
119
+ export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "AUTHZ_FIRST_OWNER", "ARCHON_URL", "MAIL_FROM", "LETTERMINT_API_TOKEN", "AUTHZ_PEER_PASSWORD_OPERATOR", "ARCHON_BRIDGE_SECRET", "ARCHON_ORG_ID", "ARCHON_CREDENTIAL", "CUSTOMERS_ORGANISATION_ID", "CUSTOMERS_DIRECTORY_CREDENTIAL", "SIGNIN_UPSTREAM"];
84
120
  /**
85
121
  * The env vars an org's Boule app should carry, and every input still missing
86
122
  * to build them. Callers that write must refuse on a non-empty `missing`: a
@@ -106,7 +142,7 @@ export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER",
106
142
  * (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
107
143
  * (generated once, never rotated).
108
144
  */
109
- export declare function desiredBouleEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, issuer: string | null, archonUrl: string | null, mailWanted?: boolean): {
145
+ export declare function desiredBouleEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, issuer: string | null, archonUrl: string | null, mailWanted?: boolean, wants?: BouleCredentialWants): {
110
146
  env: Record<string, string>;
111
147
  missing: string[];
112
148
  };
@@ -129,3 +165,8 @@ export declare function wantedBouleBrokerEntry(org: DeclaredOrg, recorded: Reado
129
165
  * except `SIGNIN_UPSTREAM`: the switch turned off must take it away, so a live one that is not desired is drift.
130
166
  */
131
167
  export declare function bouleEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];
168
+ /**
169
+ * The customers env a live Boule still holds after the company switched customers off. A host that merely
170
+ * cannot deliver the credential keeps what it has: only an explicit off removes it.
171
+ */
172
+ export declare function staleCustomersEnv(org: Pick<DeclaredOrg, 'customers'>, live: Readonly<Record<string, string>>): string[];
@@ -90,6 +90,34 @@ export const BOULE_PEER_PASSWORD_ENV = 'AUTHZ_PEER_PASSWORD_OPERATOR';
90
90
  export const BOULE_BRIDGE_SECRET_ENV = 'ARCHON_BRIDGE_SECRET';
91
91
  /** The role Boule creates from {@link BOULE_PEER_PASSWORD_ENV}, with grants on schema `authz` only. */
92
92
  export const BOULE_PEER_ROLE = 'authz_operator_rt';
93
+ /** The org's Archon id and the credential Boule reaches Archon with (C3). The id is the org's own; the credential is a secret. */
94
+ export const BOULE_ARCHON_ORG_ID_ENV = 'ARCHON_ORG_ID';
95
+ export const BOULE_ARCHON_CREDENTIAL_ENV = 'ARCHON_CREDENTIAL';
96
+ /** The company's customers organisation in the sign-in service, and the read-only credential that lists its people (C3). */
97
+ export const BOULE_CUSTOMERS_ORG_ENV = 'CUSTOMERS_ORGANISATION_ID';
98
+ export const BOULE_CUSTOMERS_CREDENTIAL_ENV = 'CUSTOMERS_DIRECTORY_CREDENTIAL';
99
+ /**
100
+ * When the Archon credential was last minted and held (ISO date-time). No secret. Archon's mint rotates
101
+ * on every call and the credential lives 365 days, so foundry mints only when none was pushed or the
102
+ * push is {@link ARCHON_CREDENTIAL_RENEW_AFTER_MS} old.
103
+ */
104
+ export const BOULE_ARCHON_PUSHED_AT_SCOPE_KEY = 'boule_archon_pushed_at';
105
+ export const ARCHON_CREDENTIAL_RENEW_AFTER_MS = 335 * 24 * 60 * 60 * 1000;
106
+ /** True when no push is recorded, it is unreadable, or it is at least 335 days old. */
107
+ export function archonCredentialRenewDue(recorded, nowMs) {
108
+ const at = Date.parse(recorded[BOULE_ARCHON_PUSHED_AT_SCOPE_KEY] ?? '');
109
+ return Number.isNaN(at) || nowMs - at >= ARCHON_CREDENTIAL_RENEW_AFTER_MS;
110
+ }
111
+ /** The ZITADEL machine user that reads the customers organisation. No secret. */
112
+ export const CUSTOMERS_DIRECTORY_USER_SCOPE_KEY = 'customers_directory_user';
113
+ /** `1` or `0`: the sign-up switch last written to the customers organisation's login policy. */
114
+ export const CUSTOMERS_SIGN_UP_SCOPE_KEY = 'zitadel_customers_signup';
115
+ /** Where the held-key store keeps the Archon credential of an org's Boule. */
116
+ export const bouleArchonCredentialRef = (orgId) => `foundry:boule-archon:${orgId}`;
117
+ /** Where the held-key store keeps the directory credential of an org's customers. */
118
+ export const customersDirectoryRef = (orgId) => `foundry:customers-directory:${orgId}`;
119
+ /** The customers organisation id `zitadel:customers` recorded. */
120
+ export const customersOrgIdOf = (org, recorded) => recorded[`zitadel:customers:${org.id}`] || undefined;
93
121
  /** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
94
122
  export const BOULE_MANAGED_ENV_KEYS = [
95
123
  'APP_URL',
@@ -104,6 +132,10 @@ export const BOULE_MANAGED_ENV_KEYS = [
104
132
  BOULE_MAIL_TOKEN_ENV,
105
133
  BOULE_PEER_PASSWORD_ENV,
106
134
  BOULE_BRIDGE_SECRET_ENV,
135
+ BOULE_ARCHON_ORG_ID_ENV,
136
+ BOULE_ARCHON_CREDENTIAL_ENV,
137
+ BOULE_CUSTOMERS_ORG_ENV,
138
+ BOULE_CUSTOMERS_CREDENTIAL_ENV,
107
139
  SIGN_IN_UPSTREAM_ENV,
108
140
  ];
109
141
  /**
@@ -131,7 +163,7 @@ export const BOULE_MANAGED_ENV_KEYS = [
131
163
  * (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
132
164
  * (generated once, never rotated).
133
165
  */
134
- export function desiredBouleEnv(org, recorded, issuer, archonUrl, mailWanted = false) {
166
+ export function desiredBouleEnv(org, recorded, issuer, archonUrl, mailWanted = false, wants = {}) {
135
167
  const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
136
168
  const clientId = recorded[BOULE_CLIENT_ID_SCOPE_KEY];
137
169
  const missing = [];
@@ -153,6 +185,12 @@ export function desiredBouleEnv(org, recorded, issuer, archonUrl, mailWanted = f
153
185
  env.AUTH_CLIENT_ID = clientId;
154
186
  if (archonUrl)
155
187
  env.ARCHON_URL = archonUrl;
188
+ if (wants.archonCredential)
189
+ env[BOULE_ARCHON_ORG_ID_ENV] = org.id;
190
+ // The id is not missing while unrecorded: the customers step records it, and the plan plans Boule again then.
191
+ const customersOrgId = wants.customersDirectory ? customersOrgIdOf(org, recorded) : undefined;
192
+ if (customersOrgId)
193
+ env[BOULE_CUSTOMERS_ORG_ENV] = customersOrgId;
156
194
  const owner = org.owners[0];
157
195
  const ownerUserId = owner
158
196
  ? recorded[`zitadel:owner:${org.id}:${owner.email.toLowerCase()}`]
@@ -219,3 +257,12 @@ export function bouleEnvDrift(desired, live) {
219
257
  drift.push(SIGN_IN_UPSTREAM_ENV);
220
258
  return drift;
221
259
  }
260
+ /**
261
+ * The customers env a live Boule still holds after the company switched customers off. A host that merely
262
+ * cannot deliver the credential keeps what it has: only an explicit off removes it.
263
+ */
264
+ export function staleCustomersEnv(org, live) {
265
+ if (org.customers?.enabled)
266
+ return [];
267
+ return [BOULE_CUSTOMERS_ORG_ENV, BOULE_CUSTOMERS_CREDENTIAL_ENV].filter((key) => live[key] !== undefined);
268
+ }
@@ -19,6 +19,28 @@ export declare function brokerEntryName(org: DeclaredOrg, entry: HostingEntry):
19
19
  export declare function brokerEntryAppliedScopeKey(entryKey: string): string;
20
20
  /** The foreign entries the last write found invalid, as a JSON array of names. */
21
21
  export declare function brokerEntryWarningsScopeKey(entryKey: string): string;
22
+ /** The ZITADEL project id that is an MCP entry's token audience, recorded by `zitadel:mcp-project`. */
23
+ export declare const entryMcpProjectScopeKey: (entryKey: string) => string;
24
+ /** The marker for the entry's app having JWT access tokens and the MCP callback (`zitadel:mcp-access`). */
25
+ export declare const entryMcpAccessAppliedScopeKey: (entryKey: string) => string;
26
+ /** The instance access-token lifetime the MCP flow wants, in seconds (auth ADR 0002, 4). */
27
+ export declare const MCP_ACCESS_TOKEN_LIFETIME_SECONDS = 3600;
28
+ /** Records the lifetime foundry last set; the value is {@link MCP_ACCESS_TOKEN_LIFETIME_SECONDS}. */
29
+ export declare const ACCESS_TOKEN_LIFETIME_APPLIED_SCOPE_KEY = "access_token_lifetime_applied";
30
+ /** The broker's callback on the org's login host. It joins the entry app's redirect URIs. */
31
+ export declare const mcpCallbackUri: (org: DeclaredOrg) => string;
32
+ /**
33
+ * What an entry's MCP resource is, from the one recorded project id. The
34
+ * broker entry (`wantedBrokerEntry`) and the app's env (`entryAuthEnv`) both
35
+ * read it here, so the audience the broker checks and the audience the app
36
+ * compares are the same value. `null` without `mcp`, or until the project
37
+ * exists.
38
+ */
39
+ export declare function entryMcp(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>): {
40
+ resource: string;
41
+ projectId: string;
42
+ loginHost: string;
43
+ } | null;
22
44
  /** What the app's env says about who signs in, as recorded so far. Missing values are left out. */
23
45
  export declare function entryAuthEnv(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>, issuer: string | null): Record<string, string>;
24
46
  /** The marker `pushAppEnv` records for an entry's sign-in env; the planner compares against it. */
@@ -32,6 +54,12 @@ export interface BrokerEntryWant {
32
54
  from: string;
33
55
  registration: boolean;
34
56
  replaces: string | null;
57
+ /** Present only for an entry with `uses.auth.mcp`: what the broker needs to be its authorization server. */
58
+ mcp?: {
59
+ resource: string;
60
+ projectId: string;
61
+ loginHost: string;
62
+ };
35
63
  }
36
64
  /**
37
65
  * Each value a broker entry needs and the step that records it, so the