@wtfalch/foundry 0.42.1 → 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 (50) hide show
  1. package/dist/bootstrap/versions.d.ts +2 -0
  2. package/dist/index.d.ts +5 -1
  3. package/dist/index.js +3 -1
  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/org.d.ts +13 -0
  7. package/dist/org.js +38 -1
  8. package/dist/plan/archon-blue-green.d.ts +54 -0
  9. package/dist/plan/archon-blue-green.js +72 -0
  10. package/dist/plan/blue-green-apply.d.ts +82 -0
  11. package/dist/plan/blue-green-apply.js +360 -0
  12. package/dist/plan/blue-green.d.ts +223 -0
  13. package/dist/plan/blue-green.js +444 -0
  14. package/dist/plan/cloudflared-connector.d.ts +26 -0
  15. package/dist/plan/cloudflared-connector.js +37 -0
  16. package/dist/plan/diff.d.ts +6 -0
  17. package/dist/plan/diff.js +31 -2
  18. package/dist/plan/executor.d.ts +34 -0
  19. package/dist/plan/executor.js +244 -9
  20. package/dist/plan/plan-apply.d.ts +51 -0
  21. package/dist/plan/plan-apply.js +222 -0
  22. package/dist/plan/platform-diff.d.ts +9 -0
  23. package/dist/plan/platform-diff.js +24 -1
  24. package/dist/plan/platform-providers.d.ts +15 -1
  25. package/dist/plan/platform-providers.js +90 -5
  26. package/dist/plan/proxy-labels.d.ts +2 -2
  27. package/dist/plan/proxy-labels.js +12 -4
  28. package/dist/plan/release-host.d.ts +59 -0
  29. package/dist/plan/release-host.js +239 -0
  30. package/dist/plan/remote-database.d.ts +124 -0
  31. package/dist/plan/remote-database.js +373 -0
  32. package/dist/platform.d.ts +11 -1
  33. package/dist/platform.js +4 -1
  34. package/dist/release/decide.d.ts +95 -0
  35. package/dist/release/decide.js +242 -0
  36. package/dist/release/health.d.ts +48 -0
  37. package/dist/release/health.js +61 -0
  38. package/dist/release/run.d.ts +40 -0
  39. package/dist/release/run.js +56 -0
  40. package/dist/release/state.d.ts +64 -0
  41. package/dist/release/state.js +126 -0
  42. package/dist/release/types.d.ts +54 -0
  43. package/dist/release/types.js +4 -0
  44. package/dist/store/postgres.d.ts +8 -0
  45. package/dist/store/postgres.js +56 -3
  46. package/dist/store/schema.d.ts +154 -0
  47. package/dist/store/schema.js +12 -0
  48. package/package.json +1 -1
  49. package/versions.digests.json +2 -1
  50. 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
+ }
@@ -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
+ }
@@ -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;
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, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, heldEnvRef, ownerMailboxRecorded, ownerMailboxes, ownsServices, } from '../org.js';
3
3
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
4
+ import { planBlueGreen } from './blue-green.js';
4
5
  import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_MAIL_TOKEN_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
5
6
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
6
7
  import { chatInternalNames, chatUpstreams } from './chat-matrix.js';
@@ -23,6 +24,7 @@ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_
23
24
  import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
24
25
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
25
26
  import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryRoutes, entryRoutesMarker, } from './proxy-labels.js';
27
+ import { planRemoteDatabase } from './remote-database.js';
26
28
  import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, signInDomainBlockedText, signInOrgDomain, } from './sign-in-org.js';
27
29
  import { SIGN_IN_BASE_PATH, SIGN_IN_IMAGE_TAG_SUFFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_LOGIN_INTERNAL_NAME, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, inAppSignIn, inAppStatePairs, inAppTargets, } from './sign-in.js';
28
30
  import { withStepSystem } from './step-system.js';
@@ -456,6 +458,18 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
456
458
  const edgeReservedLabels = planEdgeReservedLabels(org, recordedIds, platformReadiness, options.platformHostnames ?? []);
457
459
  if (edgeReservedLabels.step)
458
460
  plannedSteps.push(edgeReservedLabels.step);
461
+ // Blue/green (`blue-green.ts`): the apps and balancers before the tunnel routes to them, a second
462
+ // server's connector after the tunnel exists.
463
+ const blueGreen = planBlueGreen({
464
+ org,
465
+ recordedIds,
466
+ servers: options.blueGreenServers,
467
+ canHoldSecrets: platformReadiness.canHoldSecrets === true,
468
+ appPlanned: (entryKey) => plannedSteps.some((p) => p.action.system === 'coolify' &&
469
+ p.action.kind === 'app' &&
470
+ p.action.key === appStepKey(org, entryKey)),
471
+ });
472
+ plannedSteps.push(...blueGreen.steps.filter((x) => x.phase === 'before').map(blueGreenDraft()));
459
473
  const tunnel = planTunnel(org, byKey, claimed, recordedIds, platformReadiness.platformApex);
460
474
  // #238: a tunnel that is being provisioned waits for an active zone; parking
461
475
  // one never does.
@@ -482,6 +496,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
482
496
  else if (tunnel) {
483
497
  plannedSteps.push(tunnel);
484
498
  }
499
+ plannedSteps.push(...blueGreen.steps.filter((x) => x.phase === 'after').map(blueGreenDraft()));
485
500
  // Right after the zone (#43): `cloudflareRateLimit` resolves the zone by
486
501
  // apex domain, so the zone step above must already have run (or the zone
487
502
  // must already exist live) by the time this one's action executes.
@@ -549,6 +564,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
549
564
  (jobsAvailable && !ownsServices(org)) || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader, platformReadiness.jobsUrlMissing ?? false);
550
565
  if (jobsRegistration.step)
551
566
  plannedSteps.push(jobsRegistration.step);
567
+ const remoteDatabase = planRemoteDatabase(org, options.remoteDatabase, recordedIds);
568
+ if (remoteDatabase.step)
569
+ plannedSteps.push(remoteDatabase.step);
552
570
  // F8 (#318), after Agora and Boule, not right after the zone: a route
553
571
  // already bound by a hand-made entry, another entry's route, a taken
554
572
  // script name, or no Workers token all throw at apply, and `runSteps`
@@ -615,6 +633,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
615
633
  // orphan a second live key.
616
634
  if (aiKey.blocked)
617
635
  unmanaged.push({ description: aiKey.blocked, key: 'ai:key' });
636
+ unmanaged.push(...blueGreen.unmanaged);
618
637
  unmanaged.push(...filesKeys.blocked);
619
638
  unmanaged.push(...entryAiKeys.blocked);
620
639
  unmanaged.push(...edgeWorkers.blocked);
@@ -644,6 +663,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
644
663
  if (jobsRegistration.blocked) {
645
664
  unmanaged.push({ description: jobsRegistration.blocked, key: 'jobs:registration' });
646
665
  }
666
+ if (remoteDatabase.blocked)
667
+ unmanaged.push(remoteDatabase.blocked);
647
668
  // #177: an org step whose own shared platform service is off or not yet
648
669
  // applied is blocked here, not planned -- the same rule, now driven by
649
670
  // `platformReadiness` instead of a host-supplied port. Each is reported at
@@ -740,6 +761,13 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
740
761
  orgPostgres,
741
762
  };
742
763
  }
764
+ /** A blue/green plan step as the draft step `computePlanDetail` collects. */
765
+ function blueGreenDraft() {
766
+ return (step) => ({
767
+ action: { system: 'coolify', kind: step.kind, key: step.key, desired: step.desired },
768
+ step: { description: step.description, destroys: false, migrates: false },
769
+ });
770
+ }
743
771
  /** Steps that run against the org's own ZITADEL instance (its projects, apps and broker entries). */
744
772
  const NEEDS_OWN_ZITADEL = new Set([
745
773
  'zitadel:issuer',
@@ -3336,7 +3364,7 @@ function planTunnel(org, byKey, claimed, recordedIds, platformApex) {
3336
3364
  (org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
3337
3365
  Boolean(org.domain) ||
3338
3366
  // A company without a domain still gets a tunnel of its own for an entry at `<slug>.oikos.host`.
3339
- entryRoutes(org).length > 0;
3367
+ entryRoutes(org, recordedIds).length > 0;
3340
3368
  const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`, org.id,
3341
3369
  // Cloudflare's status, read against what is declared: a park cannot change
3342
3370
  // it, so judging on/off by status alone would never let the plan empty.
@@ -3393,7 +3421,8 @@ function planTunnel(org, byKey, claimed, recordedIds, platformApex) {
3393
3421
  // An entry's hostname goes straight to its container, so a changed port, key or name, or an ingress
3394
3422
  // written before entries were routed that way, needs the ingress written again. Nothing recorded
3395
3423
  // equals no routes.
3396
- const routesStale = (recordedIds[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY] ?? '[]') !== entryRoutesMarker(org);
3424
+ const routesStale = (recordedIds[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY] ?? '[]') !==
3425
+ entryRoutesMarker(org, recordedIds);
3397
3426
  if (!connectorMissing && !dnsStale && !routesStale)
3398
3427
  return null;
3399
3428
  return {