@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,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 entry of org.hosting.entries) {
250
- const subject = entrySubject(org, entry, recordedIds);
251
- if (!subject)
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.buildPack === 'dockercompose')
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 (!recordedIds[`coolify:app:${appStepKey(org, id)}`] && !input.appPlanned(id)) {
278
- blocked('its blue copy is not recorded and no app step is planned to make it');
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
- if (recordedIds[bgKey.applied(id, server)] !== hash) {
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: { entry: id, server, primary, weights: formatWeights(weights) },
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
@@ -1,5 +1,5 @@
1
1
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
- import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, heldEnvRef, ownerMailboxRecorded, ownerMailboxes, ownerNameProblem, ownsServices, } from '../org.js';
2
+ import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, heldEnvRef, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownerNameProblem, ownsServices, } from '../org.js';
3
3
  import { entryHasDatabase } from '../worker-app-config.js';
4
4
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
5
5
  import { planBlueGreen } from './blue-green.js';
@@ -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) {
@@ -3161,7 +3164,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
3161
3164
  // right now, and gating this on `mailHost` let a platform-apex org with an
3162
3165
  // already-recorded domain take the unprotected path the moment platform
3163
3166
  // mail went back to off.
3164
- if (entryKey === APP_ENTRY_KEY) {
3167
+ if (entryKey === mainEntry(org.hosting)?.key) {
3165
3168
  const mailDomain = recordedIds[MAIL_DOMAIN_SCOPE_KEY];
3166
3169
  const platformApex = readiness.platformApex;
3167
3170
  const platformLettermintProjectId = readiness.platformLettermintProjectId;
@@ -3473,6 +3476,7 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
3473
3476
  .map((id) => id.slice(`coolify:app:${org.id}:`.length));
3474
3477
  const declared = new Set([APP_ENTRY_KEY, ...org.hosting.entries.map((e) => e.key)]);
3475
3478
  const keys = new Set([...declared, ...recordedKeys]);
3479
+ const mainKey = mainEntry(org.hosting)?.key;
3476
3480
  for (const key of keys) {
3477
3481
  if (!declared.has(key)) {
3478
3482
  // The reader gives a parked app no `enabled` field, so only a running
@@ -3508,14 +3512,14 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
3508
3512
  app.step.action.desired = { ...app.step.action.desired, authIssuer };
3509
3513
  }
3510
3514
  // #491: only the `app` entry's own step runs `ensureAppLettermintMail`
3511
- // (`executor.ts`'s own `entry.key === APP_ENTRY_KEY` gate). Unlike
3515
+ // (`executor.ts`'s own `isMainEntry(entry)` gate). Unlike
3512
3516
  // `secretSources`/`authIssuer` above, these two are set unconditionally
3513
3517
  // -- `null` when no platform is declared, never omitted -- because
3514
3518
  // `executor.ts` treats the key being *absent* as a stale plan predating
3515
3519
  // this field and refuses to run (review round 2, finding 3): an omitted
3516
3520
  // key here would read identically to a stale plan and silently fall
3517
3521
  // back to the unprotected pre-#491 path instead.
3518
- if (key === APP_ENTRY_KEY && app.step && app.step.action.desired.enabled !== false) {
3522
+ if (key === mainKey && app.step && app.step.action.desired.enabled !== false) {
3519
3523
  app.step.action.desired = {
3520
3524
  ...app.step.action.desired,
3521
3525
  platformApex: readiness.platformApex,
@@ -3527,7 +3531,7 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
3527
3531
  // edge: blocked, not planned, while edge is not applied.
3528
3532
  const appReason = edgeNeeded(readiness, ['zones', 'dns']);
3529
3533
  if (appReason &&
3530
- key === APP_ENTRY_KEY &&
3534
+ key === mainKey &&
3531
3535
  app.step &&
3532
3536
  app.step.action.desired.enabled !== false &&
3533
3537
  recordedIds[MAIL_DOMAIN_SCOPE_KEY]) {
@@ -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