@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
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { COLORS, LB_CONFIG_PATH, bgKey, colorAppName, colorBackend, formatWeights, lbAppName, lbDockerfile, parseWeights, renderLbConfig, reportingEnv, stageAppName, structureHash, } from './blue-green.js';
|
|
2
|
+
import { ACTIONS_KEY_ENV, COLORS, LB_CONFIG_PATH, bgKey, colorAppName, colorBackend, formatWeights, lbAppName, lbDockerfile, parseWeights, prodResources, renderLbConfig, reportingEnv, stageAppName, stageAppUrl, stageKindOf, stageResources, structureHash, } from './blue-green.js';
|
|
3
3
|
import { recordBuiltCommit } from './build-record.js';
|
|
4
4
|
import { ensureConnectorsOnServers } from './cloudflared-connector.js';
|
|
5
5
|
import { waitForNewDeployment } from './deploy-health.js';
|
|
6
6
|
import { ENTRY_CONTAINER_LABELS } from './proxy-labels.js';
|
|
7
7
|
import { remoteDatabaseAddress, remoteDatabaseHost } from './remote-database.js';
|
|
8
|
+
import { buildStageEnv } from './stage-env.js';
|
|
9
|
+
import { buildStageProbes, runStageProbes, stageProbesRecord, } from './stage-guards.js';
|
|
10
|
+
import { assertStageIsolated, envClass, stageIsolationHash, } from './stage-isolation.js';
|
|
8
11
|
/** Env names whose value is a credential: registered before they are copied. */
|
|
9
12
|
const SECRETISH = /(KEY|SECRET|TOKEN|PASSWORD|DATABASE_URL)/;
|
|
10
13
|
/** The env names that name the database. */
|
|
@@ -35,10 +38,16 @@ export async function applyBlueGreenServer(deps, subject, server) {
|
|
|
35
38
|
if (!weights)
|
|
36
39
|
throw new Error(`${deps.label}: the recorded weights are not valid`);
|
|
37
40
|
const ids = {};
|
|
38
|
-
if (isPrimary)
|
|
41
|
+
if (isPrimary) {
|
|
39
42
|
ids.blue = deps.primaryBlueAppId;
|
|
43
|
+
// A merge must not redeploy blue past the release runner.
|
|
44
|
+
await coolify.updateApplication(deps.primaryBlueAppId, { is_auto_deploy_enabled: false });
|
|
45
|
+
}
|
|
40
46
|
const sourceEnv = await coolify.listEnvs(deps.primaryBlueAppId);
|
|
41
|
-
const token = await deps.healthToken();
|
|
47
|
+
const token = subject.reporting ? await deps.healthToken() : null;
|
|
48
|
+
const actionsKey = subject.reporting && deps.actionsKey ? await deps.actionsKey() : null;
|
|
49
|
+
if (actionsKey)
|
|
50
|
+
deps.secret(actionsKey);
|
|
42
51
|
for (const color of COLORS) {
|
|
43
52
|
const name = isPrimary && color === 'blue' ? subject.primaryBlueName : colorAppName(subject.base, color);
|
|
44
53
|
if (!ids[color]) {
|
|
@@ -53,12 +62,20 @@ export async function applyBlueGreenServer(deps, subject, server) {
|
|
|
53
62
|
const appId = ids[color];
|
|
54
63
|
const copied = isPrimary && color === 'blue' ? [] : copiedEnv(deps, subject, sourceEnv, server);
|
|
55
64
|
const release = deps.recorded()[bgKey.digest(id, color)];
|
|
56
|
-
const reporting = reportingEnv({ color, release, healthToken: token });
|
|
65
|
+
const reporting = token === null ? [] : reportingEnv({ color, release, healthToken: token });
|
|
57
66
|
// A running color reads its reporting env at start: when it changed, the color restarts before the
|
|
58
67
|
// tunnel can move to it.
|
|
59
68
|
const reportingChanged = await reportingEnvDiffers(deps, appId, reporting);
|
|
60
|
-
await upsertEnv(deps, appId, [
|
|
61
|
-
|
|
69
|
+
await upsertEnv(deps, appId, [
|
|
70
|
+
...copied,
|
|
71
|
+
...reporting,
|
|
72
|
+
...(actionsKey ? [{ key: ACTIONS_KEY_ENV, value: actionsKey, buildTime: true }] : []),
|
|
73
|
+
]);
|
|
74
|
+
const adoptingBlue = deps.adoptRunningBlue === true &&
|
|
75
|
+
isPrimary &&
|
|
76
|
+
color === 'blue' &&
|
|
77
|
+
!deps.recorded()[bgKey.applied(id, server.uuid)];
|
|
78
|
+
await ensureDeployed(deps, appId, name, reportingChanged && !adoptingBlue);
|
|
62
79
|
}
|
|
63
80
|
const lbName = lbAppName(subject.base);
|
|
64
81
|
const lbId = await ensureLoadBalancer(deps, subject, server.uuid, lbName, isPrimary);
|
|
@@ -103,16 +120,24 @@ async function writeLbConfig(deps, subject, server, lbId, weights) {
|
|
|
103
120
|
deps.record(bgKey.lb(subject.id, server.uuid), formatWeights(weights));
|
|
104
121
|
await deps.persist();
|
|
105
122
|
}
|
|
106
|
-
/**
|
|
107
|
-
|
|
123
|
+
/**
|
|
124
|
+
* The primary blue's env for another color: the database host swapped when the server is not the primary, and
|
|
125
|
+
* never the reporting names or the actions key, which differ by design. The structure apply and a deploy both
|
|
126
|
+
* copy through this, so a color is never deployed with an older copy than the structure apply wrote.
|
|
127
|
+
*/
|
|
128
|
+
export function copiedEnv(deps, subject, source, server) {
|
|
108
129
|
const sidecar = server.uuid === server.primaryUuid
|
|
109
130
|
? null
|
|
110
131
|
: remoteDatabaseHost(subject.base, server.uuid, server.primaryUuid);
|
|
111
132
|
const dbHost = sidecar ? remoteDatabaseAddress(sidecar) : null;
|
|
112
133
|
const out = [];
|
|
113
134
|
for (const env of source) {
|
|
114
|
-
if (env.is_preview ||
|
|
135
|
+
if (env.is_preview ||
|
|
136
|
+
env.value === undefined ||
|
|
137
|
+
env.key.startsWith('REPORTING_') ||
|
|
138
|
+
env.key === ACTIONS_KEY_ENV) {
|
|
115
139
|
continue;
|
|
140
|
+
}
|
|
116
141
|
let value = env.value;
|
|
117
142
|
if (dbHost && DATABASE_ENV.has(env.key)) {
|
|
118
143
|
const swapped = withDatabaseHost(value, dbHost);
|
|
@@ -149,6 +174,8 @@ async function ensureGitApp(deps, subject, input) {
|
|
|
149
174
|
ports_exposes: String(subject.port),
|
|
150
175
|
git_branch: input.branch,
|
|
151
176
|
is_preview_deployments_enabled: false,
|
|
177
|
+
is_auto_deploy_enabled: input.autoDeploy,
|
|
178
|
+
...(subject.dockerfileLocation ? { dockerfile_location: subject.dockerfileLocation } : {}),
|
|
152
179
|
...(subject.folder === null ? {} : { base_directory: `/${subject.folder}` }),
|
|
153
180
|
};
|
|
154
181
|
const recorded = deps.recorded()[input.key];
|
|
@@ -188,6 +215,7 @@ async function ensureGitApp(deps, subject, input) {
|
|
|
188
215
|
ports_exposes: isStatic ? '80' : String(subject.port),
|
|
189
216
|
is_auto_deploy_enabled: input.autoDeploy,
|
|
190
217
|
base_directory: subject.folder === null ? undefined : `/${subject.folder}`,
|
|
218
|
+
...(subject.dockerfileLocation ? { dockerfile_location: subject.dockerfileLocation } : {}),
|
|
191
219
|
});
|
|
192
220
|
// Recorded before anything else is written, so a later failure never leaves an app nothing points at.
|
|
193
221
|
deps.record(input.key, created.uuid);
|
|
@@ -273,12 +301,12 @@ async function ensureLoadBalancer(deps, subject, serverUuid, name, isPrimary) {
|
|
|
273
301
|
}
|
|
274
302
|
return lbId;
|
|
275
303
|
}
|
|
276
|
-
/** The stage app and its own empty database, on the primary.
|
|
304
|
+
/** The stage app and its own empty database, on the primary. Its env comes from the allowlist, never from a copy of the live app's. */
|
|
277
305
|
async function ensureStage(deps, subject, serverUuid) {
|
|
278
306
|
const { coolify } = deps;
|
|
279
307
|
const name = stageAppName(subject.base);
|
|
280
308
|
const appId = await stageAppId(deps, subject, name, serverUuid);
|
|
281
|
-
const
|
|
309
|
+
const database = [];
|
|
282
310
|
if (subject.database) {
|
|
283
311
|
const dbName = `${subject.base}-stage-db`;
|
|
284
312
|
const pgName = dbName.replace(/-/g, '_');
|
|
@@ -302,7 +330,7 @@ async function ensureStage(deps, subject, serverUuid) {
|
|
|
302
330
|
const db = await coolify.getDatabase(dbId);
|
|
303
331
|
if (db.internal_db_url) {
|
|
304
332
|
deps.secret(db.internal_db_url);
|
|
305
|
-
|
|
333
|
+
database.push({ key: 'DATABASE_URL_OWNER', value: db.internal_db_url });
|
|
306
334
|
const have = (await coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL' && !e.is_preview);
|
|
307
335
|
if (!have) {
|
|
308
336
|
const password = randomBytes(24).toString('base64url');
|
|
@@ -310,21 +338,144 @@ async function ensureStage(deps, subject, serverUuid) {
|
|
|
310
338
|
const runtime = runtimeDatabaseUrl(db.internal_db_url, `${pgName}_rt`, password);
|
|
311
339
|
if (runtime) {
|
|
312
340
|
deps.secret(runtime);
|
|
313
|
-
|
|
341
|
+
database.push({ key: 'DATABASE_URL', value: runtime });
|
|
314
342
|
}
|
|
315
343
|
}
|
|
316
344
|
}
|
|
317
345
|
}
|
|
318
|
-
|
|
319
|
-
await
|
|
346
|
+
const kind = stageKindOf(subject);
|
|
347
|
+
const prodEnv = await coolify.listEnvs(deps.primaryBlueAppId);
|
|
348
|
+
const greenId = deps.recorded()[bgKey.app(subject.id, serverUuid, 'green')];
|
|
349
|
+
const greenEnv = greenId ? await coolify.listEnvs(greenId) : [];
|
|
350
|
+
const { env } = buildStageEnv({
|
|
351
|
+
kind,
|
|
352
|
+
appUrl: stageAppUrl(subject),
|
|
353
|
+
logDir: deps.logDir,
|
|
354
|
+
database,
|
|
355
|
+
prod: prodEnv,
|
|
356
|
+
existing: await coolify.listEnvs(appId),
|
|
357
|
+
mint: () => {
|
|
358
|
+
const value = randomBytes(32).toString('base64url');
|
|
359
|
+
deps.secret(value);
|
|
360
|
+
return value;
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
const stageEnv = [...env];
|
|
364
|
+
if (subject.reporting && deps.actionsKey) {
|
|
365
|
+
// Stage never shares a production secret: its key is its own, minted once and kept.
|
|
366
|
+
const have = (await coolify.listEnvs(appId)).find((e) => e.key === ACTIONS_KEY_ENV && !e.is_preview);
|
|
367
|
+
const key = have?.value || randomBytes(32).toString('base64');
|
|
368
|
+
deps.secret(key);
|
|
369
|
+
stageEnv.push({ key: ACTIONS_KEY_ENV, value: key, buildTime: true });
|
|
370
|
+
}
|
|
371
|
+
const stageSurface = {
|
|
372
|
+
env: stageEnv.map((e) => ({ name: e.key, class: envClass(e.key), value: e.value })),
|
|
373
|
+
resources: stageResources(subject),
|
|
374
|
+
};
|
|
375
|
+
const violations = assertStageIsolated({
|
|
376
|
+
kind,
|
|
377
|
+
prod: {
|
|
378
|
+
env: [...prodEnv, ...greenEnv]
|
|
379
|
+
.filter((e) => !e.is_preview && e.value !== undefined)
|
|
380
|
+
.map((e) => ({ name: e.key, class: envClass(e.key), value: e.value })),
|
|
381
|
+
resources: prodResources(subject),
|
|
382
|
+
internalHosts: Object.values(prodResources(subject)),
|
|
383
|
+
},
|
|
384
|
+
stage: stageSurface,
|
|
385
|
+
});
|
|
386
|
+
if (violations.length > 0) {
|
|
387
|
+
// Stage fails closed for itself: nothing is written to it, production's structure goes on.
|
|
388
|
+
await stageFailed(deps, subject, `blocked:${violations.join(',')}`);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
// Stage's credentials are tried against production before they are written; one that production accepts
|
|
392
|
+
// blocks stage (nothing is written to it), and what was tried is recorded by name so the copy slice sees it.
|
|
393
|
+
const stageValues = new Map((await coolify.listEnvs(appId))
|
|
394
|
+
.filter((e) => !e.is_preview && e.value !== undefined)
|
|
395
|
+
.map((e) => [e.key, e.value]));
|
|
396
|
+
for (const e of stageEnv)
|
|
397
|
+
stageValues.set(e.key, e.value);
|
|
398
|
+
const isolationHash = stageIsolationHash(stageSurface);
|
|
399
|
+
let probeResult;
|
|
400
|
+
try {
|
|
401
|
+
probeResult = await runStageProbes(buildStageProbes({
|
|
402
|
+
port: deps.stageGuards?.probes,
|
|
403
|
+
stage: stageValues,
|
|
404
|
+
database: subject.database,
|
|
405
|
+
prod: new Map(prodEnv
|
|
406
|
+
.filter((e) => !e.is_preview && e.value !== undefined)
|
|
407
|
+
.map((e) => [e.key, e.value])),
|
|
408
|
+
}));
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
// A connection URL that cannot be read: stage is blocked, production goes on.
|
|
412
|
+
await stageFailed(deps, subject, 'blocked:database', error);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const probes = stageProbesRecord(probeResult, isolationHash, stageValues);
|
|
416
|
+
if (probeResult.failed.length > 0) {
|
|
417
|
+
deps.record(bgKey.stageProbes(subject.id), probes.value);
|
|
418
|
+
await stageFailed(deps, subject, `blocked:${probeResult.failed.join(',')}`);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (!(await ensureStageGate(deps, subject))) {
|
|
422
|
+
await stageFailed(deps, subject, 'blocked:gate');
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
await upsertEnv(deps, appId, stageEnv);
|
|
426
|
+
deps.record(bgKey.stageIsolation(subject.id), isolationHash);
|
|
427
|
+
deps.record(bgKey.stageProbes(subject.id), probes.value);
|
|
428
|
+
await deps.persist();
|
|
320
429
|
const live = await coolify.getApplication(appId);
|
|
321
430
|
if (live.status === undefined || !deps.isRunning(live.status)) {
|
|
322
|
-
|
|
431
|
+
// Stage is a test copy: a failed build or health check must not fail production's structure apply.
|
|
432
|
+
// The next apply retries it (the plan keeps the step while the state is `unhealthy`).
|
|
433
|
+
let built;
|
|
434
|
+
try {
|
|
435
|
+
built = await waitForNewDeployment(coolify, appId, noDeploy, deps.runtime, deps.sleep, `${deps.label} ${name}`, `${name} stays as it was`);
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
await stageFailed(deps, subject, 'unhealthy', error);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
323
441
|
await recordBuiltCommit(coolify, { appId, deploymentUuid: built.deploymentUuid, name: subject.id }, (key, value) => {
|
|
324
442
|
if (key.startsWith('built_commit_'))
|
|
325
443
|
deps.record(bgKey.stage(subject.id), value);
|
|
326
444
|
});
|
|
327
445
|
}
|
|
446
|
+
deps.record(bgKey.stageState(subject.id), 'ok');
|
|
447
|
+
await deps.persist();
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Stage fails closed for itself only: its state is recorded and the apply of production goes on. The log line is
|
|
451
|
+
* fixed text and the error name; the cause can echo a credential, so it is never logged.
|
|
452
|
+
*/
|
|
453
|
+
async function stageFailed(deps, subject, state, error) {
|
|
454
|
+
const name = error === undefined ? 'refused' : error instanceof Error ? error.name : 'unknown';
|
|
455
|
+
console.error(`${deps.label}: stage is ${state.split(':')[0]} (${name}); production goes on`);
|
|
456
|
+
deps.record(bgKey.stageState(subject.id), state);
|
|
457
|
+
await deps.persist();
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* The Access gate over the stage host, made once. `false` when making it failed: stage is then blocked and not
|
|
461
|
+
* written to. No gate dependency: nothing is recorded (not run) and stage goes on.
|
|
462
|
+
*/
|
|
463
|
+
async function ensureStageGate(deps, subject) {
|
|
464
|
+
const gate = deps.stageGuards?.gate;
|
|
465
|
+
if (!gate || deps.recorded()[bgKey.stageGate(subject.id)])
|
|
466
|
+
return true;
|
|
467
|
+
let id;
|
|
468
|
+
try {
|
|
469
|
+
id = (await gate.create()).id;
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
// An Access error can echo the request, which names the people allowed in: only its name is logged.
|
|
473
|
+
console.error(`${deps.label}: making the Access gate failed (${error instanceof Error ? error.name : 'unknown'})`);
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
deps.record(bgKey.stageGate(subject.id), id);
|
|
477
|
+
await deps.persist();
|
|
478
|
+
return true;
|
|
328
479
|
}
|
|
329
480
|
async function stageAppId(deps, subject, name, serverUuid) {
|
|
330
481
|
return ensureGitApp(deps, subject, {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BlueGreenConfig, DeclaredOrg, HostingEntry } from '../org.js';
|
|
2
|
+
import { type StageKind } from './stage-isolation.js';
|
|
2
3
|
/**
|
|
3
4
|
* Blue/green releases (docs/adr/0001-blue-green-releases.md). A color is a property any app can have
|
|
4
5
|
* ({@link Colored}), not something only a hosting entry has: platform-archon uses the same code path
|
|
@@ -38,11 +39,21 @@ export declare const bgKey: {
|
|
|
38
39
|
app: (id: string, server: string, part: string) => string;
|
|
39
40
|
/** The cloudflared connector app of the tunnel on a non-primary `server`. */
|
|
40
41
|
connector: (id: string, server: string) => string;
|
|
42
|
+
/** The held ref of the server actions key, recorded and persisted as soon as it is minted (no `:`: undelivered). */
|
|
43
|
+
actionsKey: (id: string) => string;
|
|
41
44
|
stageDatabase: (id: string) => string;
|
|
45
|
+
/** `ok`, `unhealthy` (build or health check failed) or `blocked:<reason names>` (a guard refused; stage was not written to). */
|
|
46
|
+
stageState: (id: string) => string;
|
|
42
47
|
/** The Coolify name of the blue app on the primary: the adopted single app's name, or `<base>-blue`. */
|
|
43
48
|
blueName: (id: string) => string;
|
|
44
49
|
/** The pin key of the balancer's header routers. Not a credential: it only pins a color. */
|
|
45
50
|
pinKey: (id: string) => string;
|
|
51
|
+
/** The hash of the stage env name set and resource set that passed the isolation check. */
|
|
52
|
+
stageIsolation: (id: string) => string;
|
|
53
|
+
/** The id of the Access gate over the stage host: staff only, no bypass, no service-token rule. */
|
|
54
|
+
stageGate: (id: string) => string;
|
|
55
|
+
/** `ok:<hash>` once every apply-time probe was rejected; `notrun:<names>` or `failed:<names>` otherwise. */
|
|
56
|
+
stageProbes: (id: string) => string;
|
|
46
57
|
};
|
|
47
58
|
/** The three env names the reporting health read uses (contract C1). */
|
|
48
59
|
export declare const REPORTING_ENV: {
|
|
@@ -50,6 +61,30 @@ export declare const REPORTING_ENV: {
|
|
|
50
61
|
readonly release: "REPORTING_RELEASE";
|
|
51
62
|
readonly healthToken: "REPORTING_HEALTH_TOKEN";
|
|
52
63
|
};
|
|
64
|
+
/** Next.js server action ids differ between builds unless every color is built with the same key. */
|
|
65
|
+
export declare const ACTIONS_KEY_ENV = "NEXT_SERVER_ACTIONS_ENCRYPTION_KEY";
|
|
66
|
+
/** The held-key name of a subject's server actions key: one mint per subject, shared by blue and green only. */
|
|
67
|
+
export declare const actionsKeyName: (id: string) => string;
|
|
68
|
+
/**
|
|
69
|
+
* One mint of the server actions key (CLAUDE.md, Platform steps, rule 1). The key goes to the held store, then its
|
|
70
|
+
* ref is recorded under {@link bgKey.actionsKey} and persisted, and only then is it returned for any env. A later
|
|
71
|
+
* apply reuses the held key; when the ref is recorded but the key is no longer held it stops, never mints again.
|
|
72
|
+
* The error names the ref only.
|
|
73
|
+
*/
|
|
74
|
+
export declare function mintActionsKey(input: {
|
|
75
|
+
id: string;
|
|
76
|
+
ref: string;
|
|
77
|
+
recorded: () => Readonly<Record<string, string>>;
|
|
78
|
+
record: (key: string, value: string) => void;
|
|
79
|
+
persist: () => Promise<void>;
|
|
80
|
+
heldKeys: {
|
|
81
|
+
resolve(ref: string): Promise<string | null>;
|
|
82
|
+
put(ref: string, value: string): Promise<void>;
|
|
83
|
+
};
|
|
84
|
+
generate: () => string;
|
|
85
|
+
/** Registers the value for redaction before it is written anywhere. */
|
|
86
|
+
secret: (value: string) => void;
|
|
87
|
+
}): Promise<string>;
|
|
53
88
|
/** The held-key name of an entry's health token (`sharedSecretRef(org.id, name)`): one mint per entry. */
|
|
54
89
|
export declare const healthTokenName: (id: string) => string;
|
|
55
90
|
/** What a color's container gets for the reporting health read. The release is left out until a digest is known. */
|
|
@@ -83,12 +118,54 @@ export interface BlueGreenSubject {
|
|
|
83
118
|
/** The hostnames the tunnel routes to the balancer. */
|
|
84
119
|
hostnames: string[];
|
|
85
120
|
config: BlueGreenConfig;
|
|
121
|
+
/** The scope key that records the Coolify app of the blue copy (the app the subject runs as today). */
|
|
122
|
+
blueAppKey: string;
|
|
123
|
+
/** The Dockerfile in the repo, when it is not the root one. */
|
|
124
|
+
dockerfileLocation?: string;
|
|
125
|
+
/**
|
|
126
|
+
* Whether the app serves the reporting health read. A static app does not: its release uses hard fails only
|
|
127
|
+
* (address check, crash loop) and moves up by time.
|
|
128
|
+
*/
|
|
129
|
+
reporting: boolean;
|
|
130
|
+
/** Set for Boule and Agora, which are not hosting entries. */
|
|
131
|
+
builtin?: BuiltinApp;
|
|
86
132
|
}
|
|
133
|
+
/** The apps foundry runs for every company, which can declare blue/green (`org.boule`, `org.agora`). */
|
|
134
|
+
export type BuiltinApp = 'boule' | 'agora';
|
|
135
|
+
export declare const BUILTIN_APPS: readonly BuiltinApp[];
|
|
136
|
+
/** What foundry builds each built-in app from; `executor.ts` creates the app from the same values. */
|
|
137
|
+
export declare const BUILTIN_APP: {
|
|
138
|
+
readonly boule: {
|
|
139
|
+
readonly repo: "wtfalch/boule";
|
|
140
|
+
readonly dockerfile: "/Dockerfile";
|
|
141
|
+
readonly port: 3000;
|
|
142
|
+
readonly healthPath: "/api/health";
|
|
143
|
+
readonly database: true;
|
|
144
|
+
readonly reporting: true;
|
|
145
|
+
};
|
|
146
|
+
readonly agora: {
|
|
147
|
+
readonly repo: "wtfalch/agora";
|
|
148
|
+
readonly dockerfile: "/apps/agora/Dockerfile";
|
|
149
|
+
readonly port: 8080;
|
|
150
|
+
readonly healthPath: "/";
|
|
151
|
+
readonly database: false;
|
|
152
|
+
readonly reporting: false;
|
|
153
|
+
};
|
|
154
|
+
};
|
|
87
155
|
/**
|
|
88
156
|
* A hosting entry that declares `blueGreen`, as a subject; `null` when it does not or cannot run.
|
|
89
157
|
* `recorded` names the blue app: an app that ran before blue/green keeps its own name (see {@link liveAppNameFor}).
|
|
90
158
|
*/
|
|
91
159
|
export declare function entrySubject(org: DeclaredOrg, entry: HostingEntry, recorded?: Readonly<Record<string, string>>): BlueGreenSubject | null;
|
|
160
|
+
/**
|
|
161
|
+
* The company's Boule or Agora as a subject; `null` when it does not declare blue/green. The running app keeps
|
|
162
|
+
* its name `<slug>-<app>` and is blue, like platform-archon. Both run on the primary server only.
|
|
163
|
+
*/
|
|
164
|
+
export declare function builtinSubject(org: DeclaredOrg, app: BuiltinApp, recorded?: Readonly<Record<string, string>>): BlueGreenSubject | null;
|
|
165
|
+
/** Every blue/green subject of an org: the hosting entries that declare it, then Boule and Agora. */
|
|
166
|
+
export declare function blueGreenSubjects(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): BlueGreenSubject[];
|
|
167
|
+
/** Whether the org declares blue/green for any of its apps. */
|
|
168
|
+
export declare const declaresBlueGreen: (org: DeclaredOrg) => boolean;
|
|
92
169
|
/**
|
|
93
170
|
* The Coolify name of the entry's app on the primary. A blue/green entry's app is its blue copy: the recorded
|
|
94
171
|
* name; else the entry's own name when its app is already recorded (it ran before blue/green and is adopted as
|
|
@@ -147,8 +224,11 @@ export interface UnmanagedLine {
|
|
|
147
224
|
}
|
|
148
225
|
export interface BlueGreenPlanStep {
|
|
149
226
|
kind: 'bluegreen' | 'bluegreen-lb' | 'bluegreen-connector';
|
|
150
|
-
/**
|
|
151
|
-
|
|
227
|
+
/**
|
|
228
|
+
* `before`: ahead of the tunnel step, so its route finds a running balancer. `after`: needs the tunnel.
|
|
229
|
+
* `last`: after Boule's and Agora's own steps, which make the blue app a built-in subject copies.
|
|
230
|
+
*/
|
|
231
|
+
phase: 'before' | 'after' | 'last';
|
|
152
232
|
key: string;
|
|
153
233
|
description: string;
|
|
154
234
|
desired: Record<string, unknown>;
|
|
@@ -157,6 +237,19 @@ export interface BlueGreenPlanStep {
|
|
|
157
237
|
export declare function structureHash(subject: BlueGreenSubject, input: {
|
|
158
238
|
primary: boolean;
|
|
159
239
|
}): string;
|
|
240
|
+
/** The kind of stage app a subject has: platform-archon, else a customer app. */
|
|
241
|
+
export declare const stageKindOf: (subject: Pick<BlueGreenSubject, "id">) => StageKind;
|
|
242
|
+
/** The names stage can collide on. */
|
|
243
|
+
export declare function stageResources(subject: BlueGreenSubject): Record<string, string>;
|
|
244
|
+
/** The names of the subject's live parts: stage must equal none of them. */
|
|
245
|
+
export declare function prodResources(subject: BlueGreenSubject): Record<string, string>;
|
|
246
|
+
/** The address of a stage app inside its network: it has no hostname. */
|
|
247
|
+
export declare const stageAppUrl: (subject: Pick<BlueGreenSubject, "base" | "port">) => string;
|
|
248
|
+
/**
|
|
249
|
+
* The violations of stage isolation that need no live values: the stage env names the builder gives, and the
|
|
250
|
+
* names stage and the org's other apps hold. Names only.
|
|
251
|
+
*/
|
|
252
|
+
export declare function stagePlanViolations(subject: BlueGreenSubject, otherApps: readonly string[]): string[];
|
|
160
253
|
/** Hostnames of the tunnel that no blue/green entry serves; a connector on a second server cannot answer them. */
|
|
161
254
|
export declare function namesNotBlueGreen(org: DeclaredOrg): string[];
|
|
162
255
|
export interface PlanBlueGreenInput {
|
|
@@ -168,6 +261,8 @@ export interface PlanBlueGreenInput {
|
|
|
168
261
|
canHoldSecrets: boolean;
|
|
169
262
|
/** The entry's app step is planned in this run (it makes the blue copy this step copies from). */
|
|
170
263
|
appPlanned: (entryKey: string) => boolean;
|
|
264
|
+
/** The platform's recorded Access provider, which the stage gate requires. Absent: no gate is made. */
|
|
265
|
+
accessProviderId?: string | null;
|
|
171
266
|
}
|
|
172
267
|
/**
|
|
173
268
|
* Plans the blue/green parts of an org: per usable server, one structure step (the apps, the balancer, the
|