@wtfalch/foundry 0.47.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 (44) hide show
  1. package/dist/index.d.ts +3 -1
  2. package/dist/index.js +1 -0
  3. package/dist/migrations/0059_org_builtin_blue_green.sql +9 -0
  4. package/dist/org.d.ts +17 -1
  5. package/dist/org.js +24 -3
  6. package/dist/plan/archon-blue-green.js +15 -1
  7. package/dist/plan/blue-green-apply.d.ts +35 -0
  8. package/dist/plan/blue-green-apply.js +167 -16
  9. package/dist/plan/blue-green.d.ts +97 -2
  10. package/dist/plan/blue-green.js +214 -13
  11. package/dist/plan/database-dump.d.ts +37 -0
  12. package/dist/plan/database-dump.js +101 -0
  13. package/dist/plan/diff.js +3 -0
  14. package/dist/plan/executor.d.ts +48 -0
  15. package/dist/plan/executor.js +79 -20
  16. package/dist/plan/index.d.ts +2 -0
  17. package/dist/plan/index.js +4 -0
  18. package/dist/plan/locked-hostnames.d.ts +1 -0
  19. package/dist/plan/locked-hostnames.js +1 -0
  20. package/dist/plan/plan-apply.js +16 -7
  21. package/dist/plan/platform-diff.js +2 -1
  22. package/dist/plan/platform-providers.js +2 -0
  23. package/dist/plan/proxy-labels.d.ts +13 -1
  24. package/dist/plan/proxy-labels.js +26 -3
  25. package/dist/plan/release-host.d.ts +1 -1
  26. package/dist/plan/release-host.js +36 -14
  27. package/dist/plan/stage-env.d.ts +38 -0
  28. package/dist/plan/stage-env.js +52 -0
  29. package/dist/plan/stage-guards.d.ts +104 -0
  30. package/dist/plan/stage-guards.js +149 -0
  31. package/dist/plan/stage-isolation.d.ts +57 -0
  32. package/dist/plan/stage-isolation.js +248 -0
  33. package/dist/refuse-org.d.ts +1 -1
  34. package/dist/refuse-org.js +5 -3
  35. package/dist/release/decide.d.ts +5 -0
  36. package/dist/release/decide.js +10 -6
  37. package/dist/release/health.d.ts +6 -1
  38. package/dist/release/health.js +10 -2
  39. package/dist/release/run.d.ts +2 -0
  40. package/dist/release/run.js +2 -1
  41. package/dist/store/postgres.js +20 -1
  42. package/dist/store/schema.d.ts +39 -1
  43. package/dist/store/schema.js +3 -0
  44. package/package.json +1 -1
@@ -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
@@ -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
@@ -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) {