@wtfalch/foundry 0.47.0 → 0.49.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.
- package/dist/bootstrap/declare.js +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +3 -2
- package/dist/migrations/0059_org_builtin_blue_green.sql +9 -0
- package/dist/migrations/0060_hosting_repos.sql +7 -0
- package/dist/org.d.ts +25 -1
- package/dist/org.js +51 -3
- package/dist/plan/archon-blue-green.js +15 -1
- package/dist/plan/blue-green-apply.d.ts +35 -0
- package/dist/plan/blue-green-apply.js +167 -16
- package/dist/plan/blue-green.d.ts +97 -2
- package/dist/plan/blue-green.js +214 -13
- package/dist/plan/database-dump.d.ts +37 -0
- package/dist/plan/database-dump.js +101 -0
- package/dist/plan/diff.js +3 -0
- package/dist/plan/executor.d.ts +48 -0
- package/dist/plan/executor.js +79 -20
- package/dist/plan/github-release-hook.d.ts +81 -0
- package/dist/plan/github-release-hook.js +159 -0
- package/dist/plan/index.d.ts +4 -1
- package/dist/plan/index.js +5 -1
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +1 -0
- package/dist/plan/locked-hostnames.js +1 -0
- package/dist/plan/plan-apply.js +16 -7
- package/dist/plan/platform-diff.d.ts +59 -0
- package/dist/plan/platform-diff.js +100 -2
- package/dist/plan/platform-providers.d.ts +10 -0
- package/dist/plan/platform-providers.js +73 -1
- package/dist/plan/proxy-labels.d.ts +13 -1
- package/dist/plan/proxy-labels.js +26 -3
- package/dist/plan/release-host.d.ts +1 -1
- package/dist/plan/release-host.js +36 -14
- package/dist/plan/stage-env.d.ts +38 -0
- package/dist/plan/stage-env.js +52 -0
- package/dist/plan/stage-guards.d.ts +104 -0
- package/dist/plan/stage-guards.js +149 -0
- package/dist/plan/stage-isolation.d.ts +57 -0
- package/dist/plan/stage-isolation.js +248 -0
- package/dist/refuse-org.d.ts +1 -1
- package/dist/refuse-org.js +5 -3
- package/dist/release/decide.d.ts +5 -0
- package/dist/release/decide.js +10 -6
- package/dist/release/health.d.ts +6 -1
- package/dist/release/health.js +10 -2
- package/dist/release/run.d.ts +2 -0
- package/dist/release/run.js +2 -1
- package/dist/store/postgres.js +32 -2
- package/dist/store/schema.d.ts +58 -1
- package/dist/store/schema.js +5 -0
- package/package.json +1 -1
package/dist/plan/blue-green.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { VERSIONS, pinnedRef } from '../bootstrap/versions.js';
|
|
3
|
+
import { agoraHostnameFor } from './agora.js';
|
|
4
|
+
import { bouleHostnameFor } from './boule.js';
|
|
3
5
|
import { serverConnectorScopeKey } from './cloudflared-connector.js';
|
|
4
6
|
import { entryHostnames, tunnelIngressHostnames, wildcardHostname } from './hostnames.js';
|
|
5
7
|
import { appNameFor, appStepKey } from './platform-diff.js';
|
|
6
8
|
import { remoteDatabaseAppliedFor } from './remote-database.js';
|
|
9
|
+
import { buildStageEnv } from './stage-env.js';
|
|
10
|
+
import { assertStageIsolated, envClass } from './stage-isolation.js';
|
|
7
11
|
export const COLORS = ['blue', 'green'];
|
|
8
12
|
/** The name of `base`'s copy in `color`: `<base>-blue`. */
|
|
9
13
|
export function colorAppName(base, color) {
|
|
@@ -43,11 +47,21 @@ export const bgKey = {
|
|
|
43
47
|
app: (id, server, part) => `bg.${id}.app.${server}.${part}`,
|
|
44
48
|
/** The cloudflared connector app of the tunnel on a non-primary `server`. */
|
|
45
49
|
connector: (id, server) => `bg.${id}.connector.${server}`,
|
|
50
|
+
/** The held ref of the server actions key, recorded and persisted as soon as it is minted (no `:`: undelivered). */
|
|
51
|
+
actionsKey: (id) => `bg.${id}.actionskey`,
|
|
46
52
|
stageDatabase: (id) => `bg.${id}.stagedb`,
|
|
53
|
+
/** `ok`, `unhealthy` (build or health check failed) or `blocked:<reason names>` (a guard refused; stage was not written to). */
|
|
54
|
+
stageState: (id) => `bg.${id}.stagestate`,
|
|
47
55
|
/** The Coolify name of the blue app on the primary: the adopted single app's name, or `<base>-blue`. */
|
|
48
56
|
blueName: (id) => `bg.${id}.bluename`,
|
|
49
57
|
/** The pin key of the balancer's header routers. Not a credential: it only pins a color. */
|
|
50
58
|
pinKey: (id) => `bg.${id}.pinkey`,
|
|
59
|
+
/** The hash of the stage env name set and resource set that passed the isolation check. */
|
|
60
|
+
stageIsolation: (id) => `bg.${id}.stageisolation`,
|
|
61
|
+
/** The id of the Access gate over the stage host: staff only, no bypass, no service-token rule. */
|
|
62
|
+
stageGate: (id) => `bg.${id}.stagegate`,
|
|
63
|
+
/** `ok:<hash>` once every apply-time probe was rejected; `notrun:<names>` or `failed:<names>` otherwise. */
|
|
64
|
+
stageProbes: (id) => `bg.${id}.stageprobes`,
|
|
51
65
|
};
|
|
52
66
|
/** The three env names the reporting health read uses (contract C1). */
|
|
53
67
|
export const REPORTING_ENV = {
|
|
@@ -55,6 +69,32 @@ export const REPORTING_ENV = {
|
|
|
55
69
|
release: 'REPORTING_RELEASE',
|
|
56
70
|
healthToken: 'REPORTING_HEALTH_TOKEN',
|
|
57
71
|
};
|
|
72
|
+
/** Next.js server action ids differ between builds unless every color is built with the same key. */
|
|
73
|
+
export const ACTIONS_KEY_ENV = 'NEXT_SERVER_ACTIONS_ENCRYPTION_KEY';
|
|
74
|
+
/** The held-key name of a subject's server actions key: one mint per subject, shared by blue and green only. */
|
|
75
|
+
export const actionsKeyName = (id) => `server-actions-key-${id}`;
|
|
76
|
+
/**
|
|
77
|
+
* One mint of the server actions key (CLAUDE.md, Platform steps, rule 1). The key goes to the held store, then its
|
|
78
|
+
* ref is recorded under {@link bgKey.actionsKey} and persisted, and only then is it returned for any env. A later
|
|
79
|
+
* apply reuses the held key; when the ref is recorded but the key is no longer held it stops, never mints again.
|
|
80
|
+
* The error names the ref only.
|
|
81
|
+
*/
|
|
82
|
+
export async function mintActionsKey(input) {
|
|
83
|
+
const held = await input.heldKeys.resolve(input.ref);
|
|
84
|
+
if (held) {
|
|
85
|
+
input.secret(held);
|
|
86
|
+
return held;
|
|
87
|
+
}
|
|
88
|
+
if (input.recorded()[bgKey.actionsKey(input.id)]) {
|
|
89
|
+
throw new Error(`server actions key ${input.ref} was minted but is no longer held; restore the held value`);
|
|
90
|
+
}
|
|
91
|
+
const key = input.generate();
|
|
92
|
+
input.secret(key);
|
|
93
|
+
await input.heldKeys.put(input.ref, key);
|
|
94
|
+
input.record(bgKey.actionsKey(input.id), input.ref);
|
|
95
|
+
await input.persist();
|
|
96
|
+
return key;
|
|
97
|
+
}
|
|
58
98
|
/** The held-key name of an entry's health token (`sharedSecretRef(org.id, name)`): one mint per entry. */
|
|
59
99
|
export const healthTokenName = (id) => `reporting-health-${id}`;
|
|
60
100
|
/** What a color's container gets for the reporting health read. The release is left out until a digest is known. */
|
|
@@ -65,6 +105,27 @@ export function reportingEnv(input) {
|
|
|
65
105
|
{ key: REPORTING_ENV.healthToken, value: input.healthToken },
|
|
66
106
|
];
|
|
67
107
|
}
|
|
108
|
+
export const BUILTIN_APPS = ['boule', 'agora'];
|
|
109
|
+
/** What foundry builds each built-in app from; `executor.ts` creates the app from the same values. */
|
|
110
|
+
export const BUILTIN_APP = {
|
|
111
|
+
boule: {
|
|
112
|
+
repo: 'wtfalch/boule',
|
|
113
|
+
dockerfile: '/Dockerfile',
|
|
114
|
+
port: 3000,
|
|
115
|
+
healthPath: '/api/health',
|
|
116
|
+
database: true,
|
|
117
|
+
reporting: true,
|
|
118
|
+
},
|
|
119
|
+
agora: {
|
|
120
|
+
repo: 'wtfalch/agora',
|
|
121
|
+
dockerfile: '/apps/agora/Dockerfile',
|
|
122
|
+
// Equal to `AGORA_APP_PORTS_EXPOSES` (a test holds them together); importing it here would be a cycle.
|
|
123
|
+
port: 8080,
|
|
124
|
+
healthPath: '/',
|
|
125
|
+
database: false,
|
|
126
|
+
reporting: false,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
68
129
|
/**
|
|
69
130
|
* A hosting entry that declares `blueGreen`, as a subject; `null` when it does not or cannot run.
|
|
70
131
|
* `recorded` names the blue app: an app that ran before blue/green keeps its own name (see {@link liveAppNameFor}).
|
|
@@ -87,8 +148,57 @@ export function entrySubject(org, entry, recorded) {
|
|
|
87
148
|
database: entry.database,
|
|
88
149
|
hostnames: entryHostnames(org, entry),
|
|
89
150
|
config: entry.blueGreen,
|
|
151
|
+
blueAppKey: `coolify:app:${appStepKey(org, entry.key)}`,
|
|
152
|
+
reporting: true,
|
|
90
153
|
};
|
|
91
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* The company's Boule or Agora as a subject; `null` when it does not declare blue/green. The running app keeps
|
|
157
|
+
* its name `<slug>-<app>` and is blue, like platform-archon. Both run on the primary server only.
|
|
158
|
+
*/
|
|
159
|
+
export function builtinSubject(org, app, recorded = {}) {
|
|
160
|
+
const config = org[app]?.blueGreen;
|
|
161
|
+
if (!config)
|
|
162
|
+
return null;
|
|
163
|
+
const spec = BUILTIN_APP[app];
|
|
164
|
+
const base = `${org.slug}-${app}`;
|
|
165
|
+
return {
|
|
166
|
+
id: app,
|
|
167
|
+
base,
|
|
168
|
+
primaryBlueName: recorded[bgKey.blueName(app)] ?? base,
|
|
169
|
+
port: spec.port,
|
|
170
|
+
repo: spec.repo,
|
|
171
|
+
folder: null,
|
|
172
|
+
branch: 'main',
|
|
173
|
+
buildPack: 'dockerfile',
|
|
174
|
+
healthPath: spec.healthPath,
|
|
175
|
+
database: spec.database,
|
|
176
|
+
hostnames: [app === 'boule' ? bouleHostnameFor(org) : agoraHostnameFor(org)],
|
|
177
|
+
config,
|
|
178
|
+
blueAppKey: `coolify:${app}:${org.id}`,
|
|
179
|
+
dockerfileLocation: spec.dockerfile,
|
|
180
|
+
reporting: spec.reporting,
|
|
181
|
+
builtin: app,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/** Every blue/green subject of an org: the hosting entries that declare it, then Boule and Agora. */
|
|
185
|
+
export function blueGreenSubjects(org, recorded) {
|
|
186
|
+
const subjects = [];
|
|
187
|
+
for (const entry of org.hosting.entries) {
|
|
188
|
+
const subject = entrySubject(org, entry, recorded);
|
|
189
|
+
if (subject)
|
|
190
|
+
subjects.push(subject);
|
|
191
|
+
}
|
|
192
|
+
for (const app of BUILTIN_APPS) {
|
|
193
|
+
const subject = builtinSubject(org, app, recorded);
|
|
194
|
+
if (subject)
|
|
195
|
+
subjects.push(subject);
|
|
196
|
+
}
|
|
197
|
+
return subjects;
|
|
198
|
+
}
|
|
199
|
+
/** Whether the org declares blue/green for any of its apps. */
|
|
200
|
+
export const declaresBlueGreen = (org) => org.hosting.entries.some((entry) => entry.blueGreen) ||
|
|
201
|
+
BUILTIN_APPS.some((app) => org[app]?.blueGreen);
|
|
92
202
|
/**
|
|
93
203
|
* The Coolify name of the entry's app on the primary. A blue/green entry's app is its blue copy: the recorded
|
|
94
204
|
* name; else the entry's own name when its app is already recorded (it ran before blue/green and is adopted as
|
|
@@ -218,6 +328,59 @@ export function structureHash(subject, input) {
|
|
|
218
328
|
.digest('hex')
|
|
219
329
|
.slice(0, 16);
|
|
220
330
|
}
|
|
331
|
+
/** The kind of stage app a subject has: platform-archon, else a customer app. */
|
|
332
|
+
export const stageKindOf = (subject) => subject.id === ARCHON_SUBJECT_ID ? 'archon' : 'customer';
|
|
333
|
+
/** The names stage can collide on. */
|
|
334
|
+
export function stageResources(subject) {
|
|
335
|
+
const database = `${subject.base}-stage-db`;
|
|
336
|
+
return {
|
|
337
|
+
app: stageAppName(subject.base),
|
|
338
|
+
...(subject.database ? { database, databaseName: database.replace(/-/g, '_') } : {}),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/** The names of the subject's live parts: stage must equal none of them. */
|
|
342
|
+
export function prodResources(subject) {
|
|
343
|
+
return {
|
|
344
|
+
blue: subject.primaryBlueName,
|
|
345
|
+
green: colorAppName(subject.base, 'green'),
|
|
346
|
+
lb: lbAppName(subject.base),
|
|
347
|
+
base: subject.base,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
/** The address of a stage app inside its network: it has no hostname. */
|
|
351
|
+
export const stageAppUrl = (subject) => `http://${stageAppName(subject.base)}:${subject.port}`;
|
|
352
|
+
/**
|
|
353
|
+
* The violations of stage isolation that need no live values: the stage env names the builder gives, and the
|
|
354
|
+
* names stage and the org's other apps hold. Names only.
|
|
355
|
+
*/
|
|
356
|
+
export function stagePlanViolations(subject, otherApps) {
|
|
357
|
+
const { env } = buildStageEnv({
|
|
358
|
+
kind: stageKindOf(subject),
|
|
359
|
+
appUrl: stageAppUrl(subject),
|
|
360
|
+
logDir: '',
|
|
361
|
+
database: subject.database
|
|
362
|
+
? [
|
|
363
|
+
{ key: 'DATABASE_URL_OWNER', value: '' },
|
|
364
|
+
{ key: 'DATABASE_URL', value: '' },
|
|
365
|
+
]
|
|
366
|
+
: [],
|
|
367
|
+
prod: [],
|
|
368
|
+
existing: [],
|
|
369
|
+
mint: () => '',
|
|
370
|
+
});
|
|
371
|
+
const stage = {
|
|
372
|
+
env: env.map((e) => ({ name: e.key, class: envClass(e.key) })),
|
|
373
|
+
resources: stageResources(subject),
|
|
374
|
+
};
|
|
375
|
+
const prod = {
|
|
376
|
+
env: [],
|
|
377
|
+
resources: {
|
|
378
|
+
...prodResources(subject),
|
|
379
|
+
...Object.fromEntries(otherApps.map((name, i) => [`other${i}`, name])),
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
return assertStageIsolated({ kind: stageKindOf(subject), prod, stage });
|
|
383
|
+
}
|
|
221
384
|
/** Hostnames of the tunnel that no blue/green entry serves; a connector on a second server cannot answer them. */
|
|
222
385
|
export function namesNotBlueGreen(org) {
|
|
223
386
|
const served = new Set();
|
|
@@ -232,6 +395,11 @@ export function namesNotBlueGreen(org) {
|
|
|
232
395
|
served.add(wildcard);
|
|
233
396
|
}
|
|
234
397
|
}
|
|
398
|
+
for (const subject of blueGreenSubjects(org)) {
|
|
399
|
+
if (subject.builtin)
|
|
400
|
+
for (const name of subject.hostnames)
|
|
401
|
+
served.add(name);
|
|
402
|
+
}
|
|
235
403
|
return tunnelIngressHostnames(org).filter((name) => !served.has(name));
|
|
236
404
|
}
|
|
237
405
|
const MAX_NAME = 63;
|
|
@@ -246,21 +414,25 @@ export function planBlueGreen(input) {
|
|
|
246
414
|
const { org, recordedIds } = input;
|
|
247
415
|
const steps = [];
|
|
248
416
|
const unmanaged = [];
|
|
249
|
-
for (const
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
continue;
|
|
417
|
+
for (const subject of blueGreenSubjects(org, recordedIds)) {
|
|
418
|
+
const entry = org.hosting.entries.find((e) => e.key === subject.id);
|
|
419
|
+
const builtin = subject.builtin !== undefined;
|
|
253
420
|
const id = subject.id;
|
|
254
421
|
const blocked = (why, part = 'blocked') => unmanaged.push({
|
|
255
422
|
description: `${org.slug}'s ${id} app blue/green is blocked, not planned: ${why}`,
|
|
256
423
|
key: `bg:${id}:${part}`,
|
|
257
424
|
});
|
|
258
|
-
if (entry
|
|
425
|
+
if (entry?.buildPack === 'dockercompose')
|
|
259
426
|
continue;
|
|
260
427
|
if (colorAppName(subject.base, 'green').length > MAX_NAME) {
|
|
261
428
|
blocked('its app name is too long for the green copy; shorten the slug or the entry key');
|
|
262
429
|
continue;
|
|
263
430
|
}
|
|
431
|
+
const stageViolations = stagePlanViolations(subject, org.hosting.entries.filter((e) => e.key !== subject.id).map((e) => appNameFor(org, e)));
|
|
432
|
+
if (stageViolations.length > 0) {
|
|
433
|
+
blocked(`its stage would not be isolated from production (${stageViolations.join(', ')})`, 'stageisolation');
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
264
436
|
if (!input.servers) {
|
|
265
437
|
blocked('the host cannot list Coolify servers');
|
|
266
438
|
continue;
|
|
@@ -274,8 +446,14 @@ export function planBlueGreen(input) {
|
|
|
274
446
|
blocked(`the recorded weights are not 'blue:<n>,green:<n>' summing to 100`, 'weights');
|
|
275
447
|
continue;
|
|
276
448
|
}
|
|
277
|
-
if (
|
|
278
|
-
blocked('its
|
|
449
|
+
if (builtin && !org.domain) {
|
|
450
|
+
blocked('its address is on the shared tunnel, which cannot route to a balancer; give the company a domain');
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (!recordedIds[subject.blueAppKey] && (builtin || !input.appPlanned(id))) {
|
|
454
|
+
blocked(builtin
|
|
455
|
+
? 'its app is not recorded yet; apply the company once first'
|
|
456
|
+
: 'its blue copy is not recorded and no app step is planned to make it');
|
|
279
457
|
continue;
|
|
280
458
|
}
|
|
281
459
|
const { up, down } = splitServers(input.servers);
|
|
@@ -285,13 +463,13 @@ export function planBlueGreen(input) {
|
|
|
285
463
|
blocked(`the primary Coolify server ${primary ?? '(none)'} is not usable and reachable`);
|
|
286
464
|
continue;
|
|
287
465
|
}
|
|
288
|
-
for (const uuid of down) {
|
|
466
|
+
for (const uuid of builtin ? [] : down) {
|
|
289
467
|
unmanaged.push({
|
|
290
468
|
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
469
|
key: `bg:${id}:down:${uuid}`,
|
|
292
470
|
});
|
|
293
471
|
}
|
|
294
|
-
const others = up.filter((uuid) => uuid !== primary);
|
|
472
|
+
const others = builtin ? [] : up.filter((uuid) => uuid !== primary);
|
|
295
473
|
const unrouted = namesNotBlueGreen(org);
|
|
296
474
|
const useOthers = others.length > 0 && unrouted.length === 0;
|
|
297
475
|
if (others.length > 0 && !useOthers) {
|
|
@@ -313,6 +491,19 @@ export function planBlueGreen(input) {
|
|
|
313
491
|
}
|
|
314
492
|
}
|
|
315
493
|
}
|
|
494
|
+
const stageState = recordedIds[bgKey.stageState(id)];
|
|
495
|
+
if (stageState === 'unhealthy') {
|
|
496
|
+
unmanaged.push({
|
|
497
|
+
description: `${org.slug}'s ${id} app: stage is not healthy yet`,
|
|
498
|
+
key: `bg:${id}:stage`,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
else if (stageState?.startsWith('blocked:')) {
|
|
502
|
+
unmanaged.push({
|
|
503
|
+
description: `${org.slug}'s ${id} app: stage is blocked and was not written to (${stageState.slice('blocked:'.length)})`,
|
|
504
|
+
key: `bg:${id}:stage`,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
316
507
|
for (const server of targets) {
|
|
317
508
|
const isPrimary = server === primary;
|
|
318
509
|
const key = `${org.id}:${id}:${server}`;
|
|
@@ -324,19 +515,27 @@ export function planBlueGreen(input) {
|
|
|
324
515
|
});
|
|
325
516
|
continue;
|
|
326
517
|
}
|
|
327
|
-
|
|
518
|
+
// A stage that was not healthy is retried by running the structure again.
|
|
519
|
+
const stageRetry = isPrimary && (stageState === 'unhealthy' || stageState?.startsWith('blocked:') === true);
|
|
520
|
+
if (recordedIds[bgKey.applied(id, server)] !== hash || stageRetry) {
|
|
328
521
|
steps.push({
|
|
329
522
|
kind: 'bluegreen',
|
|
330
|
-
phase: 'before',
|
|
523
|
+
phase: builtin ? 'last' : 'before',
|
|
331
524
|
key,
|
|
332
525
|
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: {
|
|
526
|
+
desired: {
|
|
527
|
+
entry: id,
|
|
528
|
+
server,
|
|
529
|
+
primary,
|
|
530
|
+
weights: formatWeights(weights),
|
|
531
|
+
...(input.accessProviderId ? { accessProviderId: input.accessProviderId } : {}),
|
|
532
|
+
},
|
|
334
533
|
});
|
|
335
534
|
}
|
|
336
535
|
else if (recordedIds[bgKey.lb(id, server)] !== formatWeights(weights)) {
|
|
337
536
|
steps.push({
|
|
338
537
|
kind: 'bluegreen-lb',
|
|
339
|
-
phase: 'before',
|
|
538
|
+
phase: builtin ? 'last' : 'before',
|
|
340
539
|
key,
|
|
341
540
|
description: `set ${org.slug}'s ${id} app traffic to ${formatWeights(weights)} on Coolify server ${server}`,
|
|
342
541
|
desired: { entry: id, server, primary, weights: formatWeights(weights) },
|
|
@@ -374,6 +573,8 @@ export function archonSubject(platform, repo) {
|
|
|
374
573
|
database: true,
|
|
375
574
|
hostnames: [archon.hostname],
|
|
376
575
|
config: archon.blueGreen,
|
|
576
|
+
blueAppKey: ARCHON_APP_RECORD,
|
|
577
|
+
reporting: true,
|
|
377
578
|
};
|
|
378
579
|
}
|
|
379
580
|
/**
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CoolifyPort } from './executor.js';
|
|
2
|
+
/** The dump {@link dumpNow} started, and where it is saved. */
|
|
3
|
+
export interface DatabaseDump {
|
|
4
|
+
/** Coolify's uuid of the execution this call started. */
|
|
5
|
+
executionId: string;
|
|
6
|
+
location: {
|
|
7
|
+
/** The S3 storage (`platform-backups`) the dump was saved to. */
|
|
8
|
+
storageUuid: string;
|
|
9
|
+
/** The dump's file as Coolify records it: its path in that storage's bucket. */
|
|
10
|
+
filename: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** An error of {@link dumpNow}: fixed text and the database's key, never a Coolify message, uuid or body. */
|
|
14
|
+
export declare class DumpError extends Error {
|
|
15
|
+
constructor(message: string);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Takes a fresh dump of one database now and returns where it is saved. Coolify has no
|
|
19
|
+
* run-now route: `PATCH /databases/{uuid}/backups/{backup}` with `backup_now: true` queues the
|
|
20
|
+
* schedule's job, so the dump goes through the database's existing schedule. The execution it
|
|
21
|
+
* started is the first one not listed before the call; an older one is never returned. Every
|
|
22
|
+
* failure is an error naming `databaseKey`, never a uuid, a Coolify message or a body. Nothing is
|
|
23
|
+
* retried. The dump is subject to the schedule's own retention.
|
|
24
|
+
*/
|
|
25
|
+
export declare function dumpNow(coolify: Pick<CoolifyPort, 'listDatabaseBackups' | 'raw'> & Partial<Pick<CoolifyPort, 'listDatabaseBackupExecutions'>>, input: {
|
|
26
|
+
/** The database's name in `companyDatabaseKeys`, e.g. `app.shop`. Goes into every error. */
|
|
27
|
+
databaseKey: string;
|
|
28
|
+
databaseUuid: string;
|
|
29
|
+
/** The uuid of the database's schedule. */
|
|
30
|
+
backupUuid: string;
|
|
31
|
+
/** The registered `platform-backups` storage the schedule saves to. */
|
|
32
|
+
storageUuid: string;
|
|
33
|
+
/** Milliseconds to wait for the dump to finish. Default: 10 minutes. */
|
|
34
|
+
timeoutMs?: number;
|
|
35
|
+
/** Milliseconds between two reads of the executions. Default: 3 seconds. */
|
|
36
|
+
intervalMs?: number;
|
|
37
|
+
}): Promise<DatabaseDump>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const DUMP_NOW_TIMEOUT_MS = 600_000;
|
|
2
|
+
const DUMP_NOW_INTERVAL_MS = 3_000;
|
|
3
|
+
const DUMP_CALL_TIMEOUT_MS = 30_000;
|
|
4
|
+
/** An error of {@link dumpNow}: fixed text and the database's key, never a Coolify message, uuid or body. */
|
|
5
|
+
export class DumpError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'DumpError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
async function dumpCall(label, call) {
|
|
12
|
+
let timer;
|
|
13
|
+
try {
|
|
14
|
+
return await Promise.race([
|
|
15
|
+
call(),
|
|
16
|
+
new Promise((_, reject) => {
|
|
17
|
+
timer = setTimeout(() => reject(new DumpError(`dump of ${label}: Coolify did not answer within ${DUMP_CALL_TIMEOUT_MS / 1000} seconds`)), DUMP_CALL_TIMEOUT_MS);
|
|
18
|
+
}),
|
|
19
|
+
]);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error instanceof DumpError)
|
|
23
|
+
throw error;
|
|
24
|
+
throw new DumpError(`dump of ${label}: a Coolify call failed`);
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function dumpExecutions(coolify, label, databaseUuid, backupUuid) {
|
|
31
|
+
const body = await dumpCall(label, () => coolify.listDatabaseBackupExecutions
|
|
32
|
+
? coolify.listDatabaseBackupExecutions(databaseUuid, backupUuid)
|
|
33
|
+
: coolify.raw('GET', `/databases/${databaseUuid}/backups/${backupUuid}/executions`));
|
|
34
|
+
const rows = Array.isArray(body) ? body : body?.executions;
|
|
35
|
+
if (!Array.isArray(rows)) {
|
|
36
|
+
throw new DumpError(`dump of ${label}: Coolify's executions had an unexpected shape`);
|
|
37
|
+
}
|
|
38
|
+
return rows.map((row) => {
|
|
39
|
+
const r = row;
|
|
40
|
+
if (!r || typeof r.uuid !== 'string' || typeof r.status !== 'string') {
|
|
41
|
+
throw new DumpError(`dump of ${label}: Coolify's executions had an unexpected shape`);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
uuid: r.uuid,
|
|
45
|
+
status: r.status,
|
|
46
|
+
...(typeof r.filename === 'string' ? { filename: r.filename } : {}),
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Takes a fresh dump of one database now and returns where it is saved. Coolify has no
|
|
52
|
+
* run-now route: `PATCH /databases/{uuid}/backups/{backup}` with `backup_now: true` queues the
|
|
53
|
+
* schedule's job, so the dump goes through the database's existing schedule. The execution it
|
|
54
|
+
* started is the first one not listed before the call; an older one is never returned. Every
|
|
55
|
+
* failure is an error naming `databaseKey`, never a uuid, a Coolify message or a body. Nothing is
|
|
56
|
+
* retried. The dump is subject to the schedule's own retention.
|
|
57
|
+
*/
|
|
58
|
+
export async function dumpNow(coolify, input) {
|
|
59
|
+
const { databaseKey: label, databaseUuid, backupUuid, storageUuid } = input;
|
|
60
|
+
for (const [name, value] of Object.entries({
|
|
61
|
+
databaseKey: label,
|
|
62
|
+
databaseUuid,
|
|
63
|
+
backupUuid,
|
|
64
|
+
storageUuid,
|
|
65
|
+
})) {
|
|
66
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
67
|
+
throw new DumpError(`dump: ${name} must be a non-empty string`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const timeoutMs = input.timeoutMs ?? DUMP_NOW_TIMEOUT_MS;
|
|
71
|
+
const intervalMs = input.intervalMs ?? DUMP_NOW_INTERVAL_MS;
|
|
72
|
+
if (!(timeoutMs > 0) || !(intervalMs > 0)) {
|
|
73
|
+
throw new DumpError(`dump of ${label}: timeoutMs and intervalMs must be positive`);
|
|
74
|
+
}
|
|
75
|
+
const schedule = (await dumpCall(label, () => coolify.listDatabaseBackups(databaseUuid))).find((s) => s.uuid === backupUuid);
|
|
76
|
+
if (!schedule || schedule.enabled === false || !schedule.save_s3) {
|
|
77
|
+
throw new DumpError(`dump of ${label}: its schedule is missing, disabled or does not save to S3`);
|
|
78
|
+
}
|
|
79
|
+
if (schedule.s3_storage_uuid !== undefined && schedule.s3_storage_uuid !== storageUuid) {
|
|
80
|
+
throw new DumpError(`dump of ${label}: its schedule saves to another storage than the backup bucket`);
|
|
81
|
+
}
|
|
82
|
+
const before = new Set((await dumpExecutions(coolify, label, databaseUuid, backupUuid)).map((e) => e.uuid));
|
|
83
|
+
await dumpCall(label, () => coolify.raw('PATCH', `/databases/${databaseUuid}/backups/${backupUuid}`, { backup_now: true }));
|
|
84
|
+
const deadline = Date.now() + timeoutMs;
|
|
85
|
+
for (;;) {
|
|
86
|
+
const started = (await dumpExecutions(coolify, label, databaseUuid, backupUuid)).find((e) => !before.has(e.uuid));
|
|
87
|
+
if (started?.status === 'success') {
|
|
88
|
+
if (!started.filename) {
|
|
89
|
+
throw new DumpError(`dump of ${label}: Coolify recorded no file for the finished dump`);
|
|
90
|
+
}
|
|
91
|
+
return { executionId: started.uuid, location: { storageUuid, filename: started.filename } };
|
|
92
|
+
}
|
|
93
|
+
if (started && started.status !== 'running') {
|
|
94
|
+
throw new DumpError(`dump of ${label} failed`);
|
|
95
|
+
}
|
|
96
|
+
if (Date.now() >= deadline) {
|
|
97
|
+
throw new DumpError(`dump of ${label}: no finished dump within ${Math.round(timeoutMs / 1000)} seconds`);
|
|
98
|
+
}
|
|
99
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
100
|
+
}
|
|
101
|
+
}
|
package/dist/plan/diff.js
CHANGED
|
@@ -490,6 +490,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
490
490
|
recordedIds,
|
|
491
491
|
servers: options.blueGreenServers,
|
|
492
492
|
canHoldSecrets: platformReadiness.canHoldSecrets === true,
|
|
493
|
+
accessProviderId: platformReadiness.accessProviderId ?? null,
|
|
493
494
|
appPlanned: (entryKey) => plannedSteps.some((p) => p.action.system === 'coolify' &&
|
|
494
495
|
p.action.kind === 'app' &&
|
|
495
496
|
p.action.key === appStepKey(org, entryKey)),
|
|
@@ -566,6 +567,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
566
567
|
const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null, platformReadiness, bouleCredentials.wants);
|
|
567
568
|
if (boule.step)
|
|
568
569
|
plannedSteps.push(boule.step);
|
|
570
|
+
// Boule's and Agora's blue/green: after their own steps, which make the app it copies.
|
|
571
|
+
plannedSteps.push(...blueGreen.steps.filter((x) => x.phase === 'last').map(blueGreenDraft()));
|
|
569
572
|
// Boule owners, after the Boule step: needs the app deployed and the seeding secret in its env.
|
|
570
573
|
const bouleOwners = planBouleOwners(org, recordedIds, (byKey.get(resourceKey('coolify', 'boule', org.id))?.config.env ?? null));
|
|
571
574
|
if (bouleOwners.seed.length > 0) {
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor }
|
|
|
19
19
|
import type { EdgePortName } from './platform-org.js';
|
|
20
20
|
import type { RequestHeaderPort } from './platform-providers.js';
|
|
21
21
|
import { type ReleaseContext, type ReleaseHost } from './release-host.js';
|
|
22
|
+
import { type StageProbePort } from './stage-guards.js';
|
|
22
23
|
import type { WorkerAppDatabasePort } from './worker-app-database.js';
|
|
23
24
|
import { type KeysCustodyPort, type WorkerAppPort } from './worker-app.js';
|
|
24
25
|
export declare function coolifyProjectMark(org: Pick<DeclaredOrg, 'id'>): string;
|
|
@@ -1731,6 +1732,11 @@ export interface FoundryStepExecutorPorts {
|
|
|
1731
1732
|
* `edge.access` (see {@link EdgePort.access}). Kept for a host or a test that supplies its own.
|
|
1732
1733
|
*/
|
|
1733
1734
|
access?: AccessPort;
|
|
1735
|
+
/**
|
|
1736
|
+
* The calls that prove a stage credential does not work against production (`plan/stage-guards.ts`). Without
|
|
1737
|
+
* one the probes are reported as not run, and the stage copy stays blocked.
|
|
1738
|
+
*/
|
|
1739
|
+
stageProbes?: StageProbePort;
|
|
1734
1740
|
/**
|
|
1735
1741
|
* F4.ratelimits: overrides the rate limiting rule calls, which otherwise go through
|
|
1736
1742
|
* `edge.ratelimits` (see {@link EdgePort.ratelimits}). Kept for a host or a test that supplies its own.
|
|
@@ -1909,6 +1915,41 @@ export declare function healthUrls(org: DeclaredOrg): string[];
|
|
|
1909
1915
|
export declare const COOLIFY_PROXY_ORIGIN = "http://coolify-proxy:80";
|
|
1910
1916
|
/** The one Access policy foundry puts on a platform Access app (#84). */
|
|
1911
1917
|
export declare const ACCESS_POLICY_NAME = "foundry: allowed emails";
|
|
1918
|
+
/**
|
|
1919
|
+
* #164: the repo `coolifyAgora` builds the org's Agora from
|
|
1920
|
+
* (https://github.com/wtfalch/agora) -- the issue's own "How" names this
|
|
1921
|
+
* exact Dockerfile, and it is the only build strategy any app this file
|
|
1922
|
+
* provisions ever uses ({@link CMS_APP_DOCKERFILE}/
|
|
1923
|
+
* `CALLS_SERVICE_DOCKERFILE` below are all `dockerfile_location` into a
|
|
1924
|
+
* `createGithubAppApplication` build-from-repo call, never a registry
|
|
1925
|
+
* image): "Coolify builds each org's Agora from the agora repo" is not a
|
|
1926
|
+
* fresh product choice here, it is this file's one established convention,
|
|
1927
|
+
* followed rather than reopened (#164 "Decide" 1). The agora repo's own
|
|
1928
|
+
* Dockerfile (`apps/agora/Dockerfile`) already `EXPOSE`s 8080 and serves
|
|
1929
|
+
* from a build built at the repository root, the same one-repo shape
|
|
1930
|
+
* `CMS_REPO`'s own two apps use. This comment used to claim that needed no
|
|
1931
|
+
* `ports_exposes` of its own, on the theory that Coolify's github-app build
|
|
1932
|
+
* reads a Dockerfile's `EXPOSE` the same way it does for `cmsSite`'s two
|
|
1933
|
+
* apps above -- wrong (#506): Coolify never reads `EXPOSE` for a build it
|
|
1934
|
+
* runs itself, so without an explicit `agora.ts`'s `AGORA_APP_PORTS_EXPOSES`,
|
|
1935
|
+
* Traefik is left routing to a port nothing listens on, and a company's
|
|
1936
|
+
* Agora answers 502 behind the tunnel. Seen live on Company Included,
|
|
1937
|
+
* 2026-10-05.
|
|
1938
|
+
*/
|
|
1939
|
+
export declare const AGORA_REPO = "wtfalch/agora";
|
|
1940
|
+
export declare const AGORA_APP_DOCKERFILE = "/apps/agora/Dockerfile";
|
|
1941
|
+
/**
|
|
1942
|
+
* #161 (Boule part): the repo `coolifyBoule` builds the org's Boule from
|
|
1943
|
+
* (https://github.com/wtfalch/boule), by the same convention as
|
|
1944
|
+
* {@link AGORA_REPO}: Coolify's github-app build from the repo, never a
|
|
1945
|
+
* registry image. Boule's own Dockerfile is at the repository root, serves on
|
|
1946
|
+
* 3000 (`EXPOSE 3000`) and runs `scripts/migrate-and-start.sh`, which applies
|
|
1947
|
+
* its migrations as the owner and then serves as the `<database>_rt` role.
|
|
1948
|
+
*/
|
|
1949
|
+
export declare const BOULE_REPO = "wtfalch/boule";
|
|
1950
|
+
export declare const BOULE_APP_DOCKERFILE = "/Dockerfile";
|
|
1951
|
+
/** Boule's own `EXPOSE 3000` (its doc comment just above) -- the port its health check (#543) probes. */
|
|
1952
|
+
export declare const BOULE_PORT = 3000;
|
|
1912
1953
|
export declare const LOG_DIR = "/app/logs";
|
|
1913
1954
|
/** The company's Coolify destination (its Docker network `co-<slug>`), recorded when its first resource is created. */
|
|
1914
1955
|
export declare const COOLIFY_DESTINATION_SCOPE_KEY = "coolify_destination";
|
|
@@ -2342,6 +2383,13 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2342
2383
|
* a step for it fails, which the next plan reports instead.
|
|
2343
2384
|
*/
|
|
2344
2385
|
private coolifyBlueGreen;
|
|
2386
|
+
/**
|
|
2387
|
+
* Stage's guards (`plan/stage-guards.ts`): the probe port the host supplied, and the Access gate over the stage
|
|
2388
|
+
* host. The gate is offered only when everything it needs is in hand (a domain, the platform's Access provider,
|
|
2389
|
+
* the company's sign-in organisation, an owner, an Access port) and no `hosting.access` rule names the host: a
|
|
2390
|
+
* bypass or service-token rule there would open stage, so no gate is made and the guard stays unrecorded.
|
|
2391
|
+
*/
|
|
2392
|
+
private stageGuards;
|
|
2345
2393
|
private coolifyApp;
|
|
2346
2394
|
/**
|
|
2347
2395
|
* #75: mounts a persistent volume at {@link LOG_DIR} on the app, unless one
|