@wtfalch/foundry 0.41.2 → 0.43.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 (71) hide show
  1. package/dist/bootstrap/versions.d.ts +2 -0
  2. package/dist/index.d.ts +7 -3
  3. package/dist/index.js +4 -2
  4. package/dist/migrations/0054_org_signin_in_app.sql +6 -0
  5. package/dist/migrations/0055_release_requests.sql +20 -0
  6. package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
  7. package/dist/org.d.ts +30 -0
  8. package/dist/org.js +52 -1
  9. package/dist/plan/agora.d.ts +4 -1
  10. package/dist/plan/agora.js +16 -2
  11. package/dist/plan/archon-blue-green.d.ts +54 -0
  12. package/dist/plan/archon-blue-green.js +72 -0
  13. package/dist/plan/blue-green-apply.d.ts +82 -0
  14. package/dist/plan/blue-green-apply.js +360 -0
  15. package/dist/plan/blue-green.d.ts +223 -0
  16. package/dist/plan/blue-green.js +444 -0
  17. package/dist/plan/boule.d.ts +5 -2
  18. package/dist/plan/boule.js +14 -2
  19. package/dist/plan/cloudflared-connector.d.ts +26 -0
  20. package/dist/plan/cloudflared-connector.js +37 -0
  21. package/dist/plan/coolify-previews.d.ts +10 -0
  22. package/dist/plan/coolify-previews.js +14 -0
  23. package/dist/plan/diff.d.ts +6 -0
  24. package/dist/plan/diff.js +123 -11
  25. package/dist/plan/edge-ratelimits.d.ts +5 -0
  26. package/dist/plan/edge-ratelimits.js +16 -6
  27. package/dist/plan/executor.d.ts +79 -0
  28. package/dist/plan/executor.js +527 -31
  29. package/dist/plan/login-host.d.ts +23 -2
  30. package/dist/plan/login-host.js +30 -5
  31. package/dist/plan/own-diff.js +6 -0
  32. package/dist/plan/plan-apply.d.ts +51 -0
  33. package/dist/plan/plan-apply.js +222 -0
  34. package/dist/plan/platform-diff.d.ts +9 -0
  35. package/dist/plan/platform-diff.js +28 -1
  36. package/dist/plan/platform-org.d.ts +6 -0
  37. package/dist/plan/platform-org.js +5 -0
  38. package/dist/plan/platform-providers.d.ts +31 -2
  39. package/dist/plan/platform-providers.js +345 -13
  40. package/dist/plan/providers-readers.js +6 -2
  41. package/dist/plan/proxy-labels.d.ts +2 -2
  42. package/dist/plan/proxy-labels.js +12 -4
  43. package/dist/plan/release-host.d.ts +59 -0
  44. package/dist/plan/release-host.js +239 -0
  45. package/dist/plan/remote-database.d.ts +124 -0
  46. package/dist/plan/remote-database.js +373 -0
  47. package/dist/plan/sign-in-archon.d.ts +46 -0
  48. package/dist/plan/sign-in-archon.js +71 -0
  49. package/dist/plan/sign-in-bindings.d.ts +16 -0
  50. package/dist/plan/sign-in-bindings.js +44 -0
  51. package/dist/plan/sign-in.d.ts +135 -0
  52. package/dist/plan/sign-in.js +172 -0
  53. package/dist/platform.d.ts +11 -1
  54. package/dist/platform.js +4 -1
  55. package/dist/release/decide.d.ts +95 -0
  56. package/dist/release/decide.js +242 -0
  57. package/dist/release/health.d.ts +48 -0
  58. package/dist/release/health.js +61 -0
  59. package/dist/release/run.d.ts +40 -0
  60. package/dist/release/run.js +56 -0
  61. package/dist/release/state.d.ts +64 -0
  62. package/dist/release/state.js +126 -0
  63. package/dist/release/types.d.ts +54 -0
  64. package/dist/release/types.js +4 -0
  65. package/dist/store/postgres.d.ts +8 -0
  66. package/dist/store/postgres.js +62 -3
  67. package/dist/store/schema.d.ts +171 -0
  68. package/dist/store/schema.js +14 -0
  69. package/package.json +1 -1
  70. package/versions.digests.json +2 -1
  71. 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
+ }
@@ -80,7 +80,7 @@ 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
82
  /** 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"];
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"];
84
84
  /**
85
85
  * The env vars an org's Boule app should carry, and every input still missing
86
86
  * to build them. Callers that write must refuse on a non-empty `missing`: a
@@ -124,5 +124,8 @@ export declare function wantedBouleBrokerEntry(org: DeclaredOrg, recorded: Reado
124
124
  } | {
125
125
  missing: BrokerEntryInput[];
126
126
  };
127
- /** The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored. */
127
+ /**
128
+ * The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored,
129
+ * except `SIGNIN_UPSTREAM`: the switch turned off must take it away, so a live one that is not desired is drift.
130
+ */
128
131
  export declare function bouleEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_NO_REPLY_LOCAL_PART } from '../org.js';
2
2
  import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
3
+ import { SIGN_IN_UPSTREAM_ENV, inAppHostApplied, signInUpstream, staleSignInUpstream, } from './sign-in.js';
3
4
  /**
4
5
  * #161 (Boule part): what both the planner (`diff.ts`) and the executor
5
6
  * (`executor.ts`) need to agree on for an org's Boule -- the hostname, the
@@ -103,6 +104,7 @@ export const BOULE_MANAGED_ENV_KEYS = [
103
104
  BOULE_MAIL_TOKEN_ENV,
104
105
  BOULE_PEER_PASSWORD_ENV,
105
106
  BOULE_BRIDGE_SECRET_ENV,
107
+ SIGN_IN_UPSTREAM_ENV,
106
108
  ];
107
109
  /**
108
110
  * The env vars an org's Boule app should carry, and every input still missing
@@ -162,6 +164,10 @@ export function desiredBouleEnv(org, recorded, issuer, archonUrl, mailWanted = f
162
164
  const mailDomain = recorded[MAIL_DOMAIN_RECORDED_KEY];
163
165
  if (mailWanted && mailDomain)
164
166
  env.MAIL_FROM = bouleMailFrom(org, mailDomain);
167
+ // In-app sign-in: the app proxies `/sign-in/*` to the login only after its host is applied there.
168
+ if (inAppHostApplied(org, recorded, bouleHostnameFor(org))) {
169
+ env[SIGN_IN_UPSTREAM_ENV] = signInUpstream(org);
170
+ }
165
171
  return { env, missing };
166
172
  }
167
173
  /** The `AUTH_APPS` name of an org's Boule broker entry, and the scope key suffix it records under. */
@@ -201,9 +207,15 @@ export function wantedBouleBrokerEntry(org, recorded) {
201
207
  },
202
208
  };
203
209
  }
204
- /** The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored. */
210
+ /**
211
+ * The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored,
212
+ * except `SIGNIN_UPSTREAM`: the switch turned off must take it away, so a live one that is not desired is drift.
213
+ */
205
214
  export function bouleEnvDrift(desired, live) {
206
- return Object.entries(desired)
215
+ const drift = Object.entries(desired)
207
216
  .filter(([key, value]) => live[key] !== value)
208
217
  .map(([key]) => key);
218
+ if (staleSignInUpstream(desired, live))
219
+ drift.push(SIGN_IN_UPSTREAM_ENV);
220
+ return drift;
209
221
  }
@@ -76,3 +76,29 @@ export declare function ensureCloudflaredConnector(input: EnsureCloudflaredConne
76
76
  appId: string;
77
77
  deployed: boolean;
78
78
  }>;
79
+ /** The scope key of a non-primary server's connector: the tunnel's own key plus the server uuid. */
80
+ export declare function serverConnectorScopeKey(baseScopeKey: string, serverUuid: string): string;
81
+ /** A non-primary server's connector app name: the tunnel's own name plus the first eight characters of the uuid. */
82
+ export declare function serverConnectorAppName(baseAppName: string, serverUuid: string): string;
83
+ export interface EnsureConnectorsOnServersInput extends Omit<EnsureCloudflaredConnectorInput, 'resolveServer' | 'recordedConnectorAppId' | 'recordAppId' | 'appName'> {
84
+ /** The primary's connector keeps its name and scope key, so a record made before servers were many is still its record. */
85
+ baseAppName: string;
86
+ baseScopeKey: string;
87
+ primaryUuid: string;
88
+ serverUuids: readonly string[];
89
+ recorded: Readonly<Record<string, string>>;
90
+ /** Records the app id as soon as it exists; the caller persists it. */
91
+ record: (key: string, appId: string) => Promise<void>;
92
+ }
93
+ /**
94
+ * One connector app for the tunnel on each server, all with the tunnel's one token. Each server's connector
95
+ * reaches that server's own containers, so a request lands on a server with the app on it. The primary's
96
+ * connector is the existing one (found by its recorded id, never created again); every other server gets its
97
+ * own name and scope key. A failure on one server stops the loop there: the ones before it are recorded,
98
+ * and the next run repeats from the failed one.
99
+ */
100
+ export declare function ensureConnectorsOnServers(input: EnsureConnectorsOnServersInput): Promise<Array<{
101
+ serverUuid: string;
102
+ appId: string;
103
+ deployed: boolean;
104
+ }>>;
@@ -119,3 +119,40 @@ async function tunnelTokenDiffers(coolify, appId, token) {
119
119
  const live = (await coolify.listEnvs(appId)).find((e) => e.key === 'TUNNEL_TOKEN' && !e.is_preview);
120
120
  return !live || live.value !== token;
121
121
  }
122
+ /** The scope key of a non-primary server's connector: the tunnel's own key plus the server uuid. */
123
+ export function serverConnectorScopeKey(baseScopeKey, serverUuid) {
124
+ return `${baseScopeKey}.${serverUuid}`;
125
+ }
126
+ /** A non-primary server's connector app name: the tunnel's own name plus the first eight characters of the uuid. */
127
+ export function serverConnectorAppName(baseAppName, serverUuid) {
128
+ return `${baseAppName}-${serverUuid.slice(0, 8)}`;
129
+ }
130
+ /**
131
+ * One connector app for the tunnel on each server, all with the tunnel's one token. Each server's connector
132
+ * reaches that server's own containers, so a request lands on a server with the app on it. The primary's
133
+ * connector is the existing one (found by its recorded id, never created again); every other server gets its
134
+ * own name and scope key. A failure on one server stops the loop there: the ones before it are recorded,
135
+ * and the next run repeats from the failed one.
136
+ */
137
+ export async function ensureConnectorsOnServers(input) {
138
+ const results = [];
139
+ for (const serverUuid of input.serverUuids) {
140
+ const primary = serverUuid === input.primaryUuid;
141
+ const scopeKey = primary
142
+ ? input.baseScopeKey
143
+ : serverConnectorScopeKey(input.baseScopeKey, serverUuid);
144
+ const { appId, deployed } = await ensureCloudflaredConnector({
145
+ coolify: input.coolify,
146
+ tunnels: input.tunnels,
147
+ tunnelId: input.tunnelId,
148
+ projectUuid: input.projectUuid,
149
+ environment: input.environment,
150
+ appName: primary ? input.baseAppName : serverConnectorAppName(input.baseAppName, serverUuid),
151
+ resolveServer: async () => ({ uuid: serverUuid }),
152
+ recordedConnectorAppId: input.recorded[scopeKey] ?? null,
153
+ recordAppId: (appId) => input.record(scopeKey, appId),
154
+ });
155
+ results.push({ serverUuid, appId, deployed });
156
+ }
157
+ return results;
158
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Foundry deploys from its own pipeline, so Coolify's pull-request previews
3
+ * stay off on every app it manages (#788).
4
+ *
5
+ * Whether a live app has them on. `@wtfalch/providers` types the field on
6
+ * create and update only, and the read location (top level, or under
7
+ * `settings` as `is_consistent_container_name_enabled` is) is not documented,
8
+ * so both are read. Absent in both reads as off.
9
+ */
10
+ export declare function previewsOn(app: unknown): boolean;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Foundry deploys from its own pipeline, so Coolify's pull-request previews
3
+ * stay off on every app it manages (#788).
4
+ *
5
+ * Whether a live app has them on. `@wtfalch/providers` types the field on
6
+ * create and update only, and the read location (top level, or under
7
+ * `settings` as `is_consistent_container_name_enabled` is) is not documented,
8
+ * so both are read. Absent in both reads as off.
9
+ */
10
+ export function previewsOn(app) {
11
+ const a = app;
12
+ return (a?.is_preview_deployments_enabled === true ||
13
+ a?.settings?.is_preview_deployments_enabled === true);
14
+ }
@@ -1,8 +1,10 @@
1
1
  import type { DeclaredOrg, OrgId, OrgServiceSchema } from '../org.js';
2
2
  import type { Plan, PlanStep, UnmanagedItem, VersionChangeItem } from '../plan.js';
3
3
  import { type AgoraHostReading } from './agora.js';
4
+ import { type BlueGreenServer } from './blue-green.js';
4
5
  import type { LiveResource } from './live.js';
5
6
  import { type OrgPlatformReadiness } from './platform-org.js';
7
+ import { type RemoteDatabaseFacts } from './remote-database.js';
6
8
  /**
7
9
  * What a {@link StepExecutor} (see `plan-apply.ts`) needs to actually make a
8
10
  * step true, alongside the public {@link PlanStep} an operator reads.
@@ -41,6 +43,8 @@ export interface DraftStep {
41
43
  * New ones go here, not in new positional arguments (rule 7).
42
44
  */
43
45
  export interface PlanOrgOptions {
46
+ /** What the blue/green database route needs (`plan/remote-database.ts`). Absent: nothing is planned. */
47
+ remoteDatabase?: RemoteDatabaseFacts;
44
48
  /** The platform's edge has the `oikos.host` zone: a company without a domain waits on it for its own services. */
45
49
  oikosZoneReady?: boolean;
46
50
  /** #267: the executor was built with a `loginHostBindings` port. */
@@ -108,6 +112,8 @@ export interface PlanOrgOptions {
108
112
  * but the storage is not registered, so each database is reported blocked.
109
113
  */
110
114
  backupStorageUuid?: string | null;
115
+ /** Every Coolify server the host lists, for blue/green (`plan/blue-green.ts`). Absent: the host cannot list them. */
116
+ blueGreenServers?: readonly BlueGreenServer[];
111
117
  }
112
118
  export interface PlanDetail {
113
119
  orgId: OrgId;