@wtfalch/foundry 0.46.0 → 0.48.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 (59) hide show
  1. package/dist/build-json.d.ts +28 -0
  2. package/dist/build-json.js +95 -0
  3. package/dist/index.d.ts +10 -4
  4. package/dist/index.js +6 -2
  5. package/dist/migrations/0058_app_requests.sql +32 -0
  6. package/dist/migrations/0059_org_builtin_blue_green.sql +9 -0
  7. package/dist/org.d.ts +47 -5
  8. package/dist/org.js +176 -128
  9. package/dist/plan/archon-blue-green.js +15 -1
  10. package/dist/plan/blue-green-apply.d.ts +35 -0
  11. package/dist/plan/blue-green-apply.js +167 -16
  12. package/dist/plan/blue-green.d.ts +97 -2
  13. package/dist/plan/blue-green.js +214 -13
  14. package/dist/plan/database-dump.d.ts +37 -0
  15. package/dist/plan/database-dump.js +101 -0
  16. package/dist/plan/diff.js +9 -5
  17. package/dist/plan/executor.d.ts +48 -0
  18. package/dist/plan/executor.js +87 -28
  19. package/dist/plan/health-targets.js +1 -2
  20. package/dist/plan/hostnames.d.ts +2 -2
  21. package/dist/plan/hostnames.js +6 -6
  22. package/dist/plan/index.d.ts +3 -1
  23. package/dist/plan/index.js +5 -1
  24. package/dist/plan/locked-hostnames.d.ts +1 -0
  25. package/dist/plan/locked-hostnames.js +1 -0
  26. package/dist/plan/plan-apply.d.ts +30 -1
  27. package/dist/plan/plan-apply.js +86 -8
  28. package/dist/plan/platform-diff.js +2 -1
  29. package/dist/plan/platform-providers.js +5 -0
  30. package/dist/plan/providers-readers.js +15 -4
  31. package/dist/plan/proxy-labels.d.ts +13 -1
  32. package/dist/plan/proxy-labels.js +26 -3
  33. package/dist/plan/release-host.d.ts +1 -1
  34. package/dist/plan/release-host.js +36 -14
  35. package/dist/plan/stage-env.d.ts +38 -0
  36. package/dist/plan/stage-env.js +52 -0
  37. package/dist/plan/stage-guards.d.ts +104 -0
  38. package/dist/plan/stage-guards.js +149 -0
  39. package/dist/plan/stage-isolation.d.ts +57 -0
  40. package/dist/plan/stage-isolation.js +248 -0
  41. package/dist/plan/worker-app.js +3 -1
  42. package/dist/refuse-org.d.ts +7 -0
  43. package/dist/refuse-org.js +12 -0
  44. package/dist/release/decide.d.ts +5 -0
  45. package/dist/release/decide.js +10 -6
  46. package/dist/release/health.d.ts +6 -1
  47. package/dist/release/health.js +10 -2
  48. package/dist/release/run.d.ts +2 -0
  49. package/dist/release/run.js +2 -1
  50. package/dist/requested-changes.d.ts +50 -4
  51. package/dist/requested-changes.js +9 -1
  52. package/dist/store/postgres.d.ts +5 -1
  53. package/dist/store/postgres.js +86 -8
  54. package/dist/store/schema.d.ts +111 -1
  55. package/dist/store/schema.js +7 -0
  56. package/dist/store.d.ts +24 -1
  57. package/dist/worker-app-config.d.ts +8 -2
  58. package/dist/worker-app-config.js +20 -6
  59. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { sharedSecretRef } from '../org.js';
3
3
  import { applyBlueGreenServer } from './blue-green-apply.js';
4
- import { ARCHON_BG_ORIGIN_SCOPE_KEY, DEFAULT_WEIGHTS, PRIMARY_SERVER_SCOPE_KEY, bgKey, healthTokenName, lbOrigin, splitServers, } from './blue-green.js';
4
+ import { ARCHON_BG_ORIGIN_SCOPE_KEY, DEFAULT_WEIGHTS, PRIMARY_SERVER_SCOPE_KEY, actionsKeyName, bgKey, healthTokenName, lbOrigin, mintActionsKey, splitServers, } from './blue-green.js';
5
5
  /**
6
6
  * platform-archon on the blue/green path (`platform.archon.blueGreen`): the archon app keeps its name and is
7
7
  * blue; green, the balancer and stage are new apps beside it on the primary server. The tunnel moves to the
@@ -58,6 +58,20 @@ export async function applyArchonBlueGreen(run) {
58
58
  primaryBlueAppId: run.blueAppId,
59
59
  logDir: run.logDir,
60
60
  isRunning: run.isRunning,
61
+ adoptRunningBlue: true,
62
+ actionsKey: async () => {
63
+ const key = await mintActionsKey({
64
+ id,
65
+ ref: sharedSecretRef(run.ownerOrgId, actionsKeyName(id)),
66
+ recorded: run.recorded,
67
+ record: run.record,
68
+ persist: run.persist,
69
+ heldKeys: run.heldKeys,
70
+ generate: () => randomBytes(32).toString('base64'),
71
+ secret: run.secret,
72
+ });
73
+ return key;
74
+ },
61
75
  };
62
76
  await applyBlueGreenServer(deps, subject, { uuid: primary, primaryUuid: primary });
63
77
  // The structure is applied and every part ran: now, and only now, the tunnel moves.
@@ -3,6 +3,7 @@ import { ensureConnectorsOnServers } from './cloudflared-connector.js';
3
3
  import { type DeployHealthRuntime } from './deploy-health.js';
4
4
  import type { TunnelPort } from './edge-tunnels.js';
5
5
  import type { CoolifyPort } from './executor.js';
6
+ import { type StageGuardDeps } from './stage-guards.js';
6
7
  /** The Coolify calls this module makes. */
7
8
  type Coolify = Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy' | 'deployAndWait' | 'listStorages' | 'createPersistentStorage' | 'createFileStorage' | 'updateFileStorage' | 'requireGithubApp' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw'> & {
8
9
  listApplicationDeployments?: CoolifyPort['listApplicationDeployments'];
@@ -31,6 +32,21 @@ export interface BlueGreenApplyDeps {
31
32
  primaryBlueAppId: string;
32
33
  logDir: string;
33
34
  isRunning: (status: string | undefined) => boolean;
35
+ /**
36
+ * The primary blue is the process running this apply (platform-archon). Its first apply writes the reporting
37
+ * env but does not redeploy it: a restart would cut the apply, and the env is read at its next start.
38
+ */
39
+ adoptRunningBlue?: boolean;
40
+ /**
41
+ * The subject's server actions key (held, minted once). Blue and green are built with it; stage gets a key of
42
+ * its own. Absent: no key is set.
43
+ */
44
+ actionsKey?: () => Promise<string>;
45
+ /**
46
+ * What stage's guards need from the host: the probe port and the Access gate. A missing part leaves that
47
+ * guard unrecorded ("not run", never passed), so `stageGuardsReady` stays false and the copy does not start.
48
+ */
49
+ stageGuards?: StageGuardDeps;
34
50
  }
35
51
  /** What writing a balancer's config needs; {@link BlueGreenApplyDeps} and the release runner both have it. */
36
52
  export interface LbConfigDeps {
@@ -60,6 +76,25 @@ export declare function applyBlueGreenWeights(deps: LbConfigDeps, subject: BlueG
60
76
  uuid: string;
61
77
  primaryUuid: string;
62
78
  }): Promise<void>;
79
+ /**
80
+ * The primary blue's env for another color: the database host swapped when the server is not the primary, and
81
+ * never the reporting names or the actions key, which differ by design. The structure apply and a deploy both
82
+ * copy through this, so a color is never deployed with an older copy than the structure apply wrote.
83
+ */
84
+ export declare function copiedEnv(deps: {
85
+ label: string;
86
+ secret: (value: string) => void;
87
+ }, subject: Pick<BlueGreenSubject, 'base'>, source: Array<{
88
+ key: string;
89
+ value?: string;
90
+ is_preview?: boolean;
91
+ }>, server: {
92
+ uuid: string;
93
+ primaryUuid: string;
94
+ }): Array<{
95
+ key: string;
96
+ value: string;
97
+ }>;
63
98
  /** A connector for the org's tunnel on each given server; the primary's is the existing one. */
64
99
  export declare function applyBlueGreenConnectors(input: {
65
100
  coolify: Parameters<typeof ensureConnectorsOnServers>[0]['coolify'];
@@ -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, [...copied, ...reporting]);
61
- await ensureDeployed(deps, appId, name, reportingChanged);
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
- /** The primary blue's env for another color: the database host swapped when the server is not the primary. */
107
- function copiedEnv(deps, subject, source, server) {
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 || env.value === undefined || env.key.startsWith('REPORTING_'))
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. Holds no env from the live app. */
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 envs = [{ key: 'LOG_DIR', value: deps.logDir }];
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
- envs.push({ key: 'DATABASE_URL_OWNER', value: db.internal_db_url });
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
- envs.push({ key: 'DATABASE_URL', value: runtime });
341
+ database.push({ key: 'DATABASE_URL', value: runtime });
314
342
  }
315
343
  }
316
344
  }
317
345
  }
318
- // Stage never holds the live app's keys or secrets: its env is the log directory and its own database.
319
- await upsertEnv(deps, appId, envs);
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
- const built = await waitForNewDeployment(coolify, appId, noDeploy, deps.runtime, deps.sleep, `${deps.label} ${name}`, `${name} stays as it was`);
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
- /** `before`: ahead of the tunnel step, so its route finds a running balancer. `after`: needs the tunnel. */
151
- phase: 'before' | 'after';
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