@wtfalch/foundry 0.42.1 → 0.43.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 (50) hide show
  1. package/dist/bootstrap/versions.d.ts +2 -0
  2. package/dist/index.d.ts +5 -1
  3. package/dist/index.js +3 -1
  4. package/dist/migrations/0055_release_requests.sql +20 -0
  5. package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
  6. package/dist/org.d.ts +13 -0
  7. package/dist/org.js +38 -1
  8. package/dist/plan/archon-blue-green.d.ts +54 -0
  9. package/dist/plan/archon-blue-green.js +72 -0
  10. package/dist/plan/blue-green-apply.d.ts +82 -0
  11. package/dist/plan/blue-green-apply.js +360 -0
  12. package/dist/plan/blue-green.d.ts +223 -0
  13. package/dist/plan/blue-green.js +444 -0
  14. package/dist/plan/cloudflared-connector.d.ts +26 -0
  15. package/dist/plan/cloudflared-connector.js +37 -0
  16. package/dist/plan/diff.d.ts +6 -0
  17. package/dist/plan/diff.js +31 -2
  18. package/dist/plan/executor.d.ts +34 -0
  19. package/dist/plan/executor.js +244 -9
  20. package/dist/plan/plan-apply.d.ts +51 -0
  21. package/dist/plan/plan-apply.js +222 -0
  22. package/dist/plan/platform-diff.d.ts +9 -0
  23. package/dist/plan/platform-diff.js +24 -1
  24. package/dist/plan/platform-providers.d.ts +15 -1
  25. package/dist/plan/platform-providers.js +90 -5
  26. package/dist/plan/proxy-labels.d.ts +2 -2
  27. package/dist/plan/proxy-labels.js +12 -4
  28. package/dist/plan/release-host.d.ts +59 -0
  29. package/dist/plan/release-host.js +239 -0
  30. package/dist/plan/remote-database.d.ts +124 -0
  31. package/dist/plan/remote-database.js +373 -0
  32. package/dist/platform.d.ts +11 -1
  33. package/dist/platform.js +4 -1
  34. package/dist/release/decide.d.ts +95 -0
  35. package/dist/release/decide.js +242 -0
  36. package/dist/release/health.d.ts +48 -0
  37. package/dist/release/health.js +61 -0
  38. package/dist/release/run.d.ts +40 -0
  39. package/dist/release/run.js +56 -0
  40. package/dist/release/state.d.ts +64 -0
  41. package/dist/release/state.js +126 -0
  42. package/dist/release/types.d.ts +54 -0
  43. package/dist/release/types.js +4 -0
  44. package/dist/store/postgres.d.ts +8 -0
  45. package/dist/store/postgres.js +56 -3
  46. package/dist/store/schema.d.ts +154 -0
  47. package/dist/store/schema.js +12 -0
  48. package/package.json +1 -1
  49. package/versions.digests.json +2 -1
  50. package/versions.json +2 -1
@@ -0,0 +1,360 @@
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';
3
+ import { recordBuiltCommit } from './build-record.js';
4
+ import { ensureConnectorsOnServers } from './cloudflared-connector.js';
5
+ import { waitForNewDeployment } from './deploy-health.js';
6
+ import { ENTRY_CONTAINER_LABELS } from './proxy-labels.js';
7
+ import { remoteDatabaseAddress, remoteDatabaseHost } from './remote-database.js';
8
+ /** Env names whose value is a credential: registered before they are copied. */
9
+ const SECRETISH = /(KEY|SECRET|TOKEN|PASSWORD|DATABASE_URL)/;
10
+ /** The env names that name the database. */
11
+ const DATABASE_ENV = new Set(['DATABASE_URL', 'DATABASE_URL_OWNER']);
12
+ /** Replaces the host in a connection URL; `null` when it is not one. Never throws a value. */
13
+ export function withDatabaseHost(url, host) {
14
+ const match = /^([a-z][a-z0-9+.-]*:\/\/[^@/?#]*@)([^/?#]*)(.*)$/i.exec(url);
15
+ return match ? `${match[1]}${host}${match[3]}` : null;
16
+ }
17
+ /** `postgres://user:password@host/db` for a stage runtime role: the password is minted by the caller. */
18
+ export function runtimeDatabaseUrl(ownerUrl, role, password) {
19
+ const match = /^([a-z][a-z0-9+.-]*:\/\/)[^:@/]*:[^@/]*@(.*)$/i.exec(ownerUrl);
20
+ return match ? `${match[1]}${role}:${password}@${match[2]}` : null;
21
+ }
22
+ const noDeploy = { kind: 'deployment-only' };
23
+ /**
24
+ * Applies one server's blue/green structure for `subject`: green (and blue, off the primary) beside the
25
+ * primary's blue, the balancer with the recorded weights, and on the primary the stage app. Every color
26
+ * gets the primary blue's env (the database host rewritten off the primary) and its three reporting
27
+ * values. A failure halfway leaves the apps already made, recorded by id; the next run finds them and
28
+ * does the rest. Nothing is recorded as applied until every part is done.
29
+ */
30
+ export async function applyBlueGreenServer(deps, subject, server) {
31
+ const { coolify } = deps;
32
+ const id = subject.id;
33
+ const isPrimary = server.uuid === server.primaryUuid;
34
+ const weights = parseWeights(deps.recorded()[bgKey.weights(id)]);
35
+ if (!weights)
36
+ throw new Error(`${deps.label}: the recorded weights are not valid`);
37
+ const ids = {};
38
+ if (isPrimary)
39
+ ids.blue = deps.primaryBlueAppId;
40
+ const sourceEnv = await coolify.listEnvs(deps.primaryBlueAppId);
41
+ const token = await deps.healthToken();
42
+ for (const color of COLORS) {
43
+ const name = isPrimary && color === 'blue' ? subject.primaryBlueName : colorAppName(subject.base, color);
44
+ if (!ids[color]) {
45
+ ids[color] = await ensureGitApp(deps, subject, {
46
+ key: bgKey.app(id, server.uuid, color),
47
+ name,
48
+ server: server.uuid,
49
+ branch: subject.branch,
50
+ autoDeploy: false,
51
+ });
52
+ }
53
+ const appId = ids[color];
54
+ const copied = isPrimary && color === 'blue' ? [] : copiedEnv(deps, subject, sourceEnv, server);
55
+ const release = deps.recorded()[bgKey.digest(id, color)];
56
+ const reporting = reportingEnv({ color, release, healthToken: token });
57
+ // A running color reads its reporting env at start: when it changed, the color restarts before the
58
+ // tunnel can move to it.
59
+ const reportingChanged = await reportingEnvDiffers(deps, appId, reporting);
60
+ await upsertEnv(deps, appId, [...copied, ...reporting]);
61
+ await ensureDeployed(deps, appId, name, reportingChanged);
62
+ }
63
+ const lbName = lbAppName(subject.base);
64
+ const lbId = await ensureLoadBalancer(deps, subject, server.uuid, lbName, isPrimary);
65
+ await writeLbConfig(deps, subject, { uuid: server.uuid, isPrimary }, lbId, weights);
66
+ if (isPrimary)
67
+ await ensureStage(deps, subject, server.uuid);
68
+ deps.record(bgKey.applied(id, server.uuid), structureHash(subject, { primary: isPrimary }));
69
+ await deps.persist();
70
+ }
71
+ /** Writes the balancer's config with the recorded weights and records them for this server. */
72
+ export async function applyBlueGreenWeights(deps, subject, server) {
73
+ const weights = parseWeights(deps.recorded()[bgKey.weights(subject.id)]);
74
+ if (!weights)
75
+ throw new Error(`${deps.label}: the recorded weights are not valid`);
76
+ const lbId = deps.recorded()[bgKey.app(subject.id, server.uuid, 'lb')];
77
+ if (!lbId)
78
+ throw new Error(`${deps.label}: the load balancer on this server is not recorded`);
79
+ await writeLbConfig(deps, subject, { uuid: server.uuid, isPrimary: server.uuid === server.primaryUuid }, lbId, weights);
80
+ }
81
+ async function writeLbConfig(deps, subject, server, lbId, weights) {
82
+ const onPrimary = server.isPrimary;
83
+ let pinKey = deps.recorded()[bgKey.pinKey(subject.id)];
84
+ if (!pinKey) {
85
+ pinKey = randomBytes(32).toString('hex');
86
+ deps.record(bgKey.pinKey(subject.id), pinKey);
87
+ await deps.persist();
88
+ }
89
+ const content = renderLbConfig({
90
+ weights,
91
+ pinKey,
92
+ backends: {
93
+ blue: [colorBackend(subject, 'blue', onPrimary)],
94
+ green: [colorBackend(subject, 'green', onPrimary)],
95
+ },
96
+ });
97
+ const storages = await deps.coolify.listStorages(lbId);
98
+ const found = storages.file_storages?.find((s) => s.mount_path === LB_CONFIG_PATH);
99
+ if (found)
100
+ await deps.coolify.updateFileStorage(lbId, found.uuid, content);
101
+ else
102
+ await deps.coolify.createFileStorage(lbId, { mount_path: LB_CONFIG_PATH, content });
103
+ deps.record(bgKey.lb(subject.id, server.uuid), formatWeights(weights));
104
+ await deps.persist();
105
+ }
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) {
108
+ const sidecar = server.uuid === server.primaryUuid
109
+ ? null
110
+ : remoteDatabaseHost(subject.base, server.uuid, server.primaryUuid);
111
+ const dbHost = sidecar ? remoteDatabaseAddress(sidecar) : null;
112
+ const out = [];
113
+ for (const env of source) {
114
+ if (env.is_preview || env.value === undefined || env.key.startsWith('REPORTING_'))
115
+ continue;
116
+ let value = env.value;
117
+ if (dbHost && DATABASE_ENV.has(env.key)) {
118
+ const swapped = withDatabaseHost(value, dbHost);
119
+ if (swapped === null) {
120
+ throw new Error(`${deps.label}: ${env.key} is not a connection URL, so its host was not changed`);
121
+ }
122
+ value = swapped;
123
+ }
124
+ if (SECRETISH.test(env.key))
125
+ deps.secret(value);
126
+ out.push({ key: env.key, value });
127
+ }
128
+ return out;
129
+ }
130
+ /** Whether the app's env holds a different value for any of the reporting names. */
131
+ async function reportingEnvDiffers(deps, appId, wanted) {
132
+ const have = await deps.coolify.listEnvs(appId);
133
+ return wanted.some((w) => have.find((e) => e.key === w.key && !e.is_preview)?.value !== w.value);
134
+ }
135
+ async function upsertEnv(deps, appId, envs) {
136
+ try {
137
+ await deps.coolify.bulkUpsertEnvs(appId, envs.map((e) => ({ ...e, literal: true })));
138
+ }
139
+ catch {
140
+ // A Coolify error can echo the request body, which holds credentials.
141
+ throw new Error(`${deps.label}: writing env to app ${appId} failed`);
142
+ }
143
+ }
144
+ /** The git app for one color or the stage: recorded id first, then by name, then created. */
145
+ async function ensureGitApp(deps, subject, input) {
146
+ const { coolify } = deps;
147
+ const isStatic = subject.buildPack === 'static';
148
+ const settings = {
149
+ ports_exposes: String(subject.port),
150
+ git_branch: input.branch,
151
+ is_preview_deployments_enabled: false,
152
+ ...(subject.folder === null ? {} : { base_directory: `/${subject.folder}` }),
153
+ };
154
+ const recorded = deps.recorded()[input.key];
155
+ if (recorded) {
156
+ try {
157
+ await coolify.getApplication(recorded);
158
+ await coolify.updateApplication(recorded, settings);
159
+ return recorded;
160
+ }
161
+ catch (error) {
162
+ if (!isNotFound(error))
163
+ throw error;
164
+ }
165
+ }
166
+ const found = await coolify.findApplicationByName(input.name, {
167
+ environmentIds: [deps.environment.id],
168
+ });
169
+ if (found) {
170
+ await coolify.updateApplication(found.uuid, settings);
171
+ deps.record(input.key, found.uuid);
172
+ await deps.persist();
173
+ return found.uuid;
174
+ }
175
+ const githubApp = await coolify.requireGithubApp();
176
+ const created = await coolify.createGithubAppApplication({
177
+ is_preview_deployments_enabled: false,
178
+ project_uuid: deps.projectUuid,
179
+ server_uuid: input.server,
180
+ environment_name: deps.environment.name,
181
+ environment_uuid: deps.environment.uuid,
182
+ github_app_uuid: githubApp.uuid,
183
+ git_repository: subject.repo,
184
+ git_branch: input.branch,
185
+ build_pack: subject.buildPack,
186
+ name: input.name,
187
+ autogenerate_domain: false,
188
+ ports_exposes: isStatic ? '80' : String(subject.port),
189
+ is_auto_deploy_enabled: input.autoDeploy,
190
+ base_directory: subject.folder === null ? undefined : `/${subject.folder}`,
191
+ });
192
+ // Recorded before anything else is written, so a later failure never leaves an app nothing points at.
193
+ deps.record(input.key, created.uuid);
194
+ await deps.persist();
195
+ await coolify.updateApplication(created.uuid, {
196
+ custom_internal_name: input.name,
197
+ custom_labels: Buffer.from(ENTRY_CONTAINER_LABELS).toString('base64'),
198
+ health_check_enabled: true,
199
+ health_check_path: subject.healthPath,
200
+ health_check_port: String(subject.port),
201
+ });
202
+ await mountLogVolume(deps, created.uuid, input.name);
203
+ return created.uuid;
204
+ }
205
+ async function mountLogVolume(deps, appId, name) {
206
+ const storages = await deps.coolify.listStorages(appId);
207
+ if ((storages.persistent_storages ?? []).some((s) => s.mount_path === deps.logDir))
208
+ return;
209
+ await deps.coolify.createPersistentStorage(appId, {
210
+ name: `${name}-logs`,
211
+ mount_path: deps.logDir,
212
+ });
213
+ }
214
+ /** Deploys a color that is new or not running, and waits for Coolify's deployment to finish. */
215
+ async function ensureDeployed(deps, appId, name, redeploy = false) {
216
+ const live = await deps.coolify.getApplication(appId);
217
+ if (!redeploy && live.status !== undefined && deps.isRunning(live.status))
218
+ return null;
219
+ return waitForNewDeployment(deps.coolify, appId, noDeploy, deps.runtime, deps.sleep, `${deps.label} ${name}`, `${name} stays as it was`);
220
+ }
221
+ async function ensureLoadBalancer(deps, subject, serverUuid, name, isPrimary) {
222
+ const { coolify } = deps;
223
+ const key = bgKey.app(subject.id, serverUuid, 'lb');
224
+ let lbId = deps.recorded()[key];
225
+ if (lbId) {
226
+ try {
227
+ await coolify.getApplication(lbId);
228
+ }
229
+ catch (error) {
230
+ if (!isNotFound(error))
231
+ throw error;
232
+ lbId = undefined;
233
+ }
234
+ }
235
+ if (!lbId) {
236
+ const found = await coolify.findApplicationByName(name, {
237
+ environmentIds: [deps.environment.id],
238
+ });
239
+ lbId = found?.uuid;
240
+ }
241
+ let created = false;
242
+ if (!lbId) {
243
+ const made = await coolify.createDockerfileApplication({
244
+ project_uuid: deps.projectUuid,
245
+ server_uuid: serverUuid,
246
+ environment_name: deps.environment.name,
247
+ environment_uuid: deps.environment.uuid,
248
+ dockerfile: lbDockerfile(subject.port),
249
+ name,
250
+ description: `load balancer for ${subject.base}`,
251
+ instant_deploy: false,
252
+ autogenerate_domain: false,
253
+ });
254
+ lbId = made.uuid;
255
+ created = true;
256
+ }
257
+ deps.record(key, lbId);
258
+ await deps.persist();
259
+ if (created) {
260
+ await coolify.updateApplication(lbId, {
261
+ custom_internal_name: name,
262
+ custom_labels: Buffer.from(ENTRY_CONTAINER_LABELS).toString('base64'),
263
+ ports_exposes: String(subject.port),
264
+ // The Dockerfile's own HEALTHCHECK, as the cloudflared connector does.
265
+ health_check_enabled: false,
266
+ });
267
+ // The config is mounted before the first start, so Traefik never starts without a file.
268
+ await writeLbConfig(deps, subject, { uuid: serverUuid, isPrimary }, lbId, parseWeights(deps.recorded()[bgKey.weights(subject.id)]));
269
+ }
270
+ const live = await coolify.getApplication(lbId);
271
+ if (created || live.status === undefined || !deps.isRunning(live.status)) {
272
+ await waitForNewDeployment(coolify, lbId, noDeploy, deps.runtime, deps.sleep, `${deps.label} ${name}`, `${name} stays as it was`);
273
+ }
274
+ return lbId;
275
+ }
276
+ /** The stage app and its own empty database, on the primary. Holds no env from the live app. */
277
+ async function ensureStage(deps, subject, serverUuid) {
278
+ const { coolify } = deps;
279
+ const name = stageAppName(subject.base);
280
+ const appId = await stageAppId(deps, subject, name, serverUuid);
281
+ const envs = [{ key: 'LOG_DIR', value: deps.logDir }];
282
+ if (subject.database) {
283
+ const dbName = `${subject.base}-stage-db`;
284
+ const pgName = dbName.replace(/-/g, '_');
285
+ let dbId = deps.recorded()[bgKey.stageDatabase(subject.id)];
286
+ if (!dbId) {
287
+ const existing = (await coolify.listDatabases()).find((d) => d.name === dbName && d.environment_id === deps.environment.id);
288
+ dbId =
289
+ existing?.uuid ??
290
+ (await coolify.createPostgresDatabase({
291
+ project_uuid: deps.projectUuid,
292
+ server_uuid: serverUuid,
293
+ environment_name: deps.environment.name,
294
+ environment_uuid: deps.environment.uuid,
295
+ name: dbName,
296
+ postgres_db: pgName,
297
+ postgres_user: pgName,
298
+ })).uuid;
299
+ deps.record(bgKey.stageDatabase(subject.id), dbId);
300
+ await deps.persist();
301
+ }
302
+ const db = await coolify.getDatabase(dbId);
303
+ if (db.internal_db_url) {
304
+ deps.secret(db.internal_db_url);
305
+ envs.push({ key: 'DATABASE_URL_OWNER', value: db.internal_db_url });
306
+ const have = (await coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL' && !e.is_preview);
307
+ if (!have) {
308
+ const password = randomBytes(24).toString('base64url');
309
+ deps.secret(password);
310
+ const runtime = runtimeDatabaseUrl(db.internal_db_url, `${pgName}_rt`, password);
311
+ if (runtime) {
312
+ deps.secret(runtime);
313
+ envs.push({ key: 'DATABASE_URL', value: runtime });
314
+ }
315
+ }
316
+ }
317
+ }
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);
320
+ const live = await coolify.getApplication(appId);
321
+ 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`);
323
+ await recordBuiltCommit(coolify, { appId, deploymentUuid: built.deploymentUuid, name: subject.id }, (key, value) => {
324
+ if (key.startsWith('built_commit_'))
325
+ deps.record(bgKey.stage(subject.id), value);
326
+ });
327
+ }
328
+ }
329
+ async function stageAppId(deps, subject, name, serverUuid) {
330
+ return ensureGitApp(deps, subject, {
331
+ key: bgKey.app(subject.id, serverUuid, 'stage'),
332
+ name,
333
+ server: serverUuid,
334
+ branch: subject.branch,
335
+ autoDeploy: true,
336
+ });
337
+ }
338
+ function isNotFound(error) {
339
+ const status = error?.status;
340
+ return status === 404;
341
+ }
342
+ /** A connector for the org's tunnel on each given server; the primary's is the existing one. */
343
+ export async function applyBlueGreenConnectors(input) {
344
+ await ensureConnectorsOnServers({
345
+ coolify: input.coolify,
346
+ tunnels: input.tunnels,
347
+ tunnelId: input.tunnelId,
348
+ baseAppName: input.baseAppName,
349
+ baseScopeKey: input.baseScopeKey,
350
+ primaryUuid: input.primaryUuid,
351
+ serverUuids: input.serverUuids,
352
+ projectUuid: input.projectUuid,
353
+ environment: input.environment,
354
+ recorded: input.recorded(),
355
+ record: async (key, appId) => {
356
+ input.record(key, appId);
357
+ await input.persist();
358
+ },
359
+ });
360
+ }
@@ -0,0 +1,223 @@
1
+ import type { BlueGreenConfig, DeclaredOrg, HostingEntry } from '../org.js';
2
+ /**
3
+ * Blue/green releases (docs/adr/0001-blue-green-releases.md). A color is a property any app can have
4
+ * ({@link Colored}), not something only a hosting entry has: platform-archon uses the same code path
5
+ * ({@link BlueGreenSubject}).
6
+ */
7
+ export type Color = 'blue' | 'green';
8
+ export declare const COLORS: readonly Color[];
9
+ /** Anything that runs as one color. */
10
+ export interface Colored {
11
+ color: Color;
12
+ }
13
+ /** The name of `base`'s copy in `color`: `<base>-blue`. */
14
+ export declare function colorAppName(base: string, color: Color): string;
15
+ export declare const lbAppName: (base: string) => string;
16
+ export declare const stageAppName: (base: string) => string;
17
+ /** The color a name carries, or `null`. */
18
+ export declare function colorOfName(name: string): Color | null;
19
+ export type Weights = Record<Color, number>;
20
+ /** Live blue until a release moves it. */
21
+ export declare const DEFAULT_WEIGHTS = "blue:100,green:0";
22
+ /** `blue:95,green:5`. Whole percentages that sum to 100, so a typo cannot send traffic nowhere. */
23
+ export declare function parseWeights(raw: string | undefined): Weights | null;
24
+ export declare const formatWeights: (w: Weights) => string;
25
+ /** The scope keys of a blue/green subject (contract C3). `.` separated, no `:`. */
26
+ export declare const bgKey: {
27
+ live: (id: string) => string;
28
+ digest: (id: string, color: Color) => string;
29
+ weights: (id: string) => string;
30
+ stage: (id: string) => string;
31
+ window: (id: string) => string;
32
+ held: (id: string, digest: string) => string;
33
+ /** The weights the load balancer on `server` was last written with. */
34
+ lb: (id: string, server: string) => string;
35
+ /** The hash of the structure (apps, balancer, stage) last applied on `server`. */
36
+ applied: (id: string, server: string) => string;
37
+ /** The Coolify app of one part (`blue`, `green`, `lb`, `stage`) on `server`. */
38
+ app: (id: string, server: string, part: string) => string;
39
+ /** The cloudflared connector app of the tunnel on a non-primary `server`. */
40
+ connector: (id: string, server: string) => string;
41
+ stageDatabase: (id: string) => string;
42
+ /** The Coolify name of the blue app on the primary: the adopted single app's name, or `<base>-blue`. */
43
+ blueName: (id: string) => string;
44
+ /** The pin key of the balancer's header routers. Not a credential: it only pins a color. */
45
+ pinKey: (id: string) => string;
46
+ };
47
+ /** The three env names the reporting health read uses (contract C1). */
48
+ export declare const REPORTING_ENV: {
49
+ readonly color: "REPORTING_COLOR";
50
+ readonly release: "REPORTING_RELEASE";
51
+ readonly healthToken: "REPORTING_HEALTH_TOKEN";
52
+ };
53
+ /** The held-key name of an entry's health token (`sharedSecretRef(org.id, name)`): one mint per entry. */
54
+ export declare const healthTokenName: (id: string) => string;
55
+ /** What a color's container gets for the reporting health read. The release is left out until a digest is known. */
56
+ export declare function reportingEnv(input: {
57
+ color: Color;
58
+ release: string | undefined;
59
+ healthToken: string;
60
+ }): Array<{
61
+ key: string;
62
+ value: string;
63
+ }>;
64
+ /**
65
+ * Everything the blue/green code needs to know about the app it runs. A hosting entry makes one
66
+ * ({@link entrySubject}); platform-archon makes one with {@link archonSubject} in `platform-providers.ts`.
67
+ */
68
+ export interface BlueGreenSubject {
69
+ /** The scope-key id: the entry key, or `platform-archon`. No `:` or `.`. */
70
+ id: string;
71
+ /** The base name: colors are `<base>-blue`, the balancer `<base>-lb`. */
72
+ base: string;
73
+ /** The Coolify name of the blue copy on the primary server. Entries: `<base>-blue`. */
74
+ primaryBlueName: string;
75
+ port: number;
76
+ repo: string;
77
+ folder: string | null;
78
+ branch: string;
79
+ buildPack: string;
80
+ healthPath: string;
81
+ /** Whether the app has a database (its URLs are copied to the other colors). */
82
+ database: boolean;
83
+ /** The hostnames the tunnel routes to the balancer. */
84
+ hostnames: string[];
85
+ config: BlueGreenConfig;
86
+ }
87
+ /**
88
+ * A hosting entry that declares `blueGreen`, as a subject; `null` when it does not or cannot run.
89
+ * `recorded` names the blue app: an app that ran before blue/green keeps its own name (see {@link liveAppNameFor}).
90
+ */
91
+ export declare function entrySubject(org: DeclaredOrg, entry: HostingEntry, recorded?: Readonly<Record<string, string>>): BlueGreenSubject | null;
92
+ /**
93
+ * The Coolify name of the entry's app on the primary. A blue/green entry's app is its blue copy: the recorded
94
+ * name; else the entry's own name when its app is already recorded (it ran before blue/green and is adopted as
95
+ * blue, never renamed); else `<base>-blue` for an entry with no app yet.
96
+ */
97
+ export declare function liveAppNameFor(org: DeclaredOrg, entry: HostingEntry, recorded?: Readonly<Record<string, string>>): string;
98
+ /** Whether the balancer on the primary is written and the structure applied: the tunnel may move to it. */
99
+ export declare function balancerApplied(id: string, recorded: Readonly<Record<string, string>>, primaryUuid: string | undefined): boolean;
100
+ /** The origin the tunnel routes a subject's names to: its balancer, reached on each server's own network. */
101
+ export declare function lbOrigin(subject: Pick<BlueGreenSubject, 'base' | 'port'>): string;
102
+ export interface BlueGreenServer {
103
+ uuid: string;
104
+ usable: boolean;
105
+ reachable: boolean;
106
+ }
107
+ /** Usable and reachable servers in the order Coolify lists them, and the uuids of the rest. */
108
+ export declare function splitServers(all: readonly BlueGreenServer[]): {
109
+ up: string[];
110
+ down: string[];
111
+ };
112
+ /** The scope key of the org's primary server (`COOLIFY_SERVER_SCOPE_KEY` in `executor.ts`). */
113
+ export declare const PRIMARY_SERVER_SCOPE_KEY = "coolify_server";
114
+ /** The sticky cookie that keeps a user on one color. */
115
+ export declare const LB_COOKIE_NAME = "bg_color";
116
+ /** A request with this header goes to the named color whatever the weights; the release runner checks a color with it. */
117
+ export declare const LB_COLOR_HEADER = "X-Foundry-Color";
118
+ /** A pinned request also carries this header with the entry's pin key, so a stranger cannot pin a color. */
119
+ export declare const LB_PIN_HEADER = "X-Foundry-Pin";
120
+ export declare const LB_CONFIG_PATH = "/etc/traefik/dynamic/lb.yml";
121
+ /**
122
+ * The balancer's inline Dockerfile: a pinned Traefik with a file provider watching one directory.
123
+ * Weights are never in here, so a weight change is a config write and never a redeploy.
124
+ */
125
+ export declare function lbDockerfile(port: number): string;
126
+ /** The traefik tag the balancer runs, for a test that keeps the pin and the Dockerfile equal. */
127
+ export declare const LB_IMAGE_REF: string;
128
+ /**
129
+ * The balancer's dynamic config: two weighted services and a sticky cookie. A color at weight 0 is left out
130
+ * of the weighted list, because Traefik sends a request that carries a sticky cookie to the named service
131
+ * whatever its weight, which would keep a rolled-back user on the failed color. The weights are in the first
132
+ * line, so a reader sees both numbers. `backends` are the URLs of each color's containers, one per server.
133
+ */
134
+ export declare function renderLbConfig(input: {
135
+ weights: Weights;
136
+ backends: Record<Color, string[]>;
137
+ pinKey: string;
138
+ }): string;
139
+ /**
140
+ * The URL of one color's container on its own server's network. On the primary, blue is the app that was
141
+ * already there, under its own name; every other color is `<base>-<color>`.
142
+ */
143
+ export declare const colorBackend: (subject: Pick<BlueGreenSubject, "base" | "port" | "primaryBlueName">, color: Color, onPrimary: boolean) => string;
144
+ export interface UnmanagedLine {
145
+ description: string;
146
+ key: string;
147
+ }
148
+ export interface BlueGreenPlanStep {
149
+ 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';
152
+ key: string;
153
+ description: string;
154
+ desired: Record<string, unknown>;
155
+ }
156
+ /** A short hash of what decides the shape of a server's apps, so a change replans the server. */
157
+ export declare function structureHash(subject: BlueGreenSubject, input: {
158
+ primary: boolean;
159
+ }): string;
160
+ /** Hostnames of the tunnel that no blue/green entry serves; a connector on a second server cannot answer them. */
161
+ export declare function namesNotBlueGreen(org: DeclaredOrg): string[];
162
+ export interface PlanBlueGreenInput {
163
+ org: DeclaredOrg;
164
+ recordedIds: Readonly<Record<string, string>>;
165
+ /** Every Coolify server the host reports. `undefined`: the host cannot list them. */
166
+ servers: readonly BlueGreenServer[] | undefined;
167
+ /** The host's executor can hold a secret (the health token). */
168
+ canHoldSecrets: boolean;
169
+ /** The entry's app step is planned in this run (it makes the blue copy this step copies from). */
170
+ appPlanned: (entryKey: string) => boolean;
171
+ }
172
+ /**
173
+ * Plans the blue/green parts of an org: per usable server, one structure step (the apps, the balancer, the
174
+ * stage on the primary) when the recorded shape differs, else one balancer-config step when only the weights
175
+ * differ. A server that is down is skipped and reported in `unmanaged`. Pure: reads nothing but its input.
176
+ */
177
+ export declare function planBlueGreen(input: PlanBlueGreenInput): {
178
+ steps: BlueGreenPlanStep[];
179
+ unmanaged: UnmanagedLine[];
180
+ };
181
+ /** The scope-key id, base name and port platform-archon runs under. */
182
+ export declare const ARCHON_SUBJECT_ID = "platform-archon";
183
+ /** https://github.com/wtfalch/archon: one Dockerfile at its root, `runner` stage on port 3000. */
184
+ export declare const ARCHON_REPO = "wtfalch/archon";
185
+ /**
186
+ * platform-archon as a blue/green subject: the same type a hosting entry makes. Its running app keeps its
187
+ * name and serves as the blue copy, because renaming its container would take its sign-in and tunnel down
188
+ * mid-apply; green and the balancer are new apps beside it.
189
+ */
190
+ export declare function archonSubject(platform: {
191
+ archon: {
192
+ enabled: boolean;
193
+ hostname: string;
194
+ gitRef: string;
195
+ blueGreen?: BlueGreenConfig;
196
+ };
197
+ }, repo: string): BlueGreenSubject | null;
198
+ /**
199
+ * Where the tunnel sends a subject's names: its balancer once the structure on the primary is applied (the
200
+ * balancer's config written and every part running), else the app as before. Pointing the tunnel at a balancer
201
+ * that does not exist or does not answer yet would take the site down.
202
+ */
203
+ export declare function tunnelOriginFor(subject: Pick<BlueGreenSubject, 'id' | 'base' | 'port'> | null, recorded: Readonly<Record<string, string>>, primaryUuid: string | undefined, fallback: string): string;
204
+ /** The origin the archon step last wrote to the tunnel through {@link tunnelOriginFor}, recorded by the platform step. */
205
+ export declare const ARCHON_BG_ORIGIN_SCOPE_KEY = "archon_bluegreen_origin";
206
+ /**
207
+ * Plans the one platform step that runs platform-archon on the blue/green path: the structure on the primary
208
+ * server, the balancer's weights, and the tunnel's move to the balancer. It plans nothing until archon's app and
209
+ * tunnel are recorded, because the step copies the app's env and moves that tunnel. Archon runs on the primary
210
+ * only: the platform has no database route to another server.
211
+ */
212
+ export declare function planArchonBlueGreen(input: {
213
+ platform: Parameters<typeof archonSubject>[0];
214
+ repo: string;
215
+ recorded: Readonly<Record<string, string>>;
216
+ /** Every Coolify server; `undefined`: the host cannot list them. */
217
+ servers: readonly BlueGreenServer[] | undefined;
218
+ /** Why the executor cannot run it (no way to hold the health token), or absent. */
219
+ executorBlocked?: string | undefined;
220
+ }): {
221
+ step: BlueGreenPlanStep | null;
222
+ unmanaged: UnmanagedLine[];
223
+ };