@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.
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/migrations/0059_org_builtin_blue_green.sql +9 -0
- package/dist/org.d.ts +17 -1
- package/dist/org.js +24 -3
- package/dist/plan/archon-blue-green.js +15 -1
- package/dist/plan/blue-green-apply.d.ts +35 -0
- package/dist/plan/blue-green-apply.js +167 -16
- package/dist/plan/blue-green.d.ts +97 -2
- package/dist/plan/blue-green.js +214 -13
- package/dist/plan/database-dump.d.ts +37 -0
- package/dist/plan/database-dump.js +101 -0
- package/dist/plan/diff.js +3 -0
- package/dist/plan/executor.d.ts +48 -0
- package/dist/plan/executor.js +79 -20
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +4 -0
- package/dist/plan/locked-hostnames.d.ts +1 -0
- package/dist/plan/locked-hostnames.js +1 -0
- package/dist/plan/plan-apply.js +16 -7
- package/dist/plan/platform-diff.js +2 -1
- package/dist/plan/platform-providers.js +2 -0
- package/dist/plan/proxy-labels.d.ts +13 -1
- package/dist/plan/proxy-labels.js +26 -3
- package/dist/plan/release-host.d.ts +1 -1
- package/dist/plan/release-host.js +36 -14
- package/dist/plan/stage-env.d.ts +38 -0
- package/dist/plan/stage-env.js +52 -0
- package/dist/plan/stage-guards.d.ts +104 -0
- package/dist/plan/stage-guards.js +149 -0
- package/dist/plan/stage-isolation.d.ts +57 -0
- package/dist/plan/stage-isolation.js +248 -0
- package/dist/refuse-org.d.ts +1 -1
- package/dist/refuse-org.js +5 -3
- package/dist/release/decide.d.ts +5 -0
- package/dist/release/decide.js +10 -6
- package/dist/release/health.d.ts +6 -1
- package/dist/release/health.js +10 -2
- package/dist/release/run.d.ts +2 -0
- package/dist/release/run.js +2 -1
- package/dist/store/postgres.js +20 -1
- package/dist/store/schema.d.ts +39 -1
- package/dist/store/schema.js +3 -0
- 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
|
-
/**
|
|
151
|
-
|
|
227
|
+
/**
|
|
228
|
+
* `before`: ahead of the tunnel step, so its route finds a running balancer. `after`: needs the tunnel.
|
|
229
|
+
* `last`: after Boule's and Agora's own steps, which make the blue app a built-in subject copies.
|
|
230
|
+
*/
|
|
231
|
+
phase: 'before' | 'after' | 'last';
|
|
152
232
|
key: string;
|
|
153
233
|
description: string;
|
|
154
234
|
desired: Record<string, unknown>;
|
|
@@ -157,6 +237,19 @@ export interface BlueGreenPlanStep {
|
|
|
157
237
|
export declare function structureHash(subject: BlueGreenSubject, input: {
|
|
158
238
|
primary: boolean;
|
|
159
239
|
}): string;
|
|
240
|
+
/** The kind of stage app a subject has: platform-archon, else a customer app. */
|
|
241
|
+
export declare const stageKindOf: (subject: Pick<BlueGreenSubject, "id">) => StageKind;
|
|
242
|
+
/** The names stage can collide on. */
|
|
243
|
+
export declare function stageResources(subject: BlueGreenSubject): Record<string, string>;
|
|
244
|
+
/** The names of the subject's live parts: stage must equal none of them. */
|
|
245
|
+
export declare function prodResources(subject: BlueGreenSubject): Record<string, string>;
|
|
246
|
+
/** The address of a stage app inside its network: it has no hostname. */
|
|
247
|
+
export declare const stageAppUrl: (subject: Pick<BlueGreenSubject, "base" | "port">) => string;
|
|
248
|
+
/**
|
|
249
|
+
* The violations of stage isolation that need no live values: the stage env names the builder gives, and the
|
|
250
|
+
* names stage and the org's other apps hold. Names only.
|
|
251
|
+
*/
|
|
252
|
+
export declare function stagePlanViolations(subject: BlueGreenSubject, otherApps: readonly string[]): string[];
|
|
160
253
|
/** Hostnames of the tunnel that no blue/green entry serves; a connector on a second server cannot answer them. */
|
|
161
254
|
export declare function namesNotBlueGreen(org: DeclaredOrg): string[];
|
|
162
255
|
export interface PlanBlueGreenInput {
|
|
@@ -168,6 +261,8 @@ export interface PlanBlueGreenInput {
|
|
|
168
261
|
canHoldSecrets: boolean;
|
|
169
262
|
/** The entry's app step is planned in this run (it makes the blue copy this step copies from). */
|
|
170
263
|
appPlanned: (entryKey: string) => boolean;
|
|
264
|
+
/** The platform's recorded Access provider, which the stage gate requires. Absent: no gate is made. */
|
|
265
|
+
accessProviderId?: string | null;
|
|
171
266
|
}
|
|
172
267
|
/**
|
|
173
268
|
* Plans the blue/green parts of an org: per usable server, one structure step (the apps, the balancer, the
|
package/dist/plan/blue-green.js
CHANGED
|
@@ -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
|
|
250
|
-
const
|
|
251
|
-
|
|
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
|
|
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 (
|
|
278
|
-
blocked('its
|
|
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
|
-
|
|
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: {
|
|
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) {
|