@wtfalch/foundry 0.61.0 → 0.63.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/bootstrap/declare.js +4 -1
- package/dist/bootstrap/versions.d.ts +2 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +3 -0
- package/dist/migrations/0065_native_build_runs.sql +32 -0
- package/dist/migrations/0066_org_kosmos.sql +10 -0
- package/dist/migrations/0067_requested_changes_kosmos.sql +6 -0
- package/dist/migrations/0068_platform_registry.sql +17 -0
- package/dist/modules/sign-in.js +1 -1
- package/dist/native-build.d.ts +50 -0
- package/dist/native-build.js +72 -0
- package/dist/org.d.ts +22 -3
- package/dist/org.js +25 -2
- package/dist/plan/agora.d.ts +1 -1
- package/dist/plan/agora.js +4 -0
- package/dist/plan/archon-blue-green.d.ts +5 -0
- package/dist/plan/archon-blue-green.js +2 -0
- package/dist/plan/blue-green-apply.d.ts +5 -0
- package/dist/plan/blue-green-apply.js +197 -22
- package/dist/plan/blue-green.d.ts +80 -9
- package/dist/plan/blue-green.js +115 -14
- package/dist/plan/boule.d.ts +1 -1
- package/dist/plan/boule.js +2 -0
- package/dist/plan/broker-entries.d.ts +9 -0
- package/dist/plan/broker-entries.js +29 -0
- package/dist/plan/deploy-health.js +9 -2
- package/dist/plan/desktop-callback.js +4 -1
- package/dist/plan/diff.d.ts +5 -0
- package/dist/plan/diff.js +184 -63
- package/dist/plan/edge-access.d.ts +2 -3
- package/dist/plan/edge-access.js +8 -0
- package/dist/plan/executor.d.ts +111 -19
- package/dist/plan/executor.js +578 -72
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/jobs-scheduler-http.d.ts +2 -0
- package/dist/plan/jobs-scheduler-http.js +11 -0
- package/dist/plan/kosmos.d.ts +56 -0
- package/dist/plan/kosmos.js +84 -0
- package/dist/plan/locked-hostnames.js +5 -2
- package/dist/plan/org-names.d.ts +3 -3
- package/dist/plan/org-names.js +3 -1
- package/dist/plan/org-postgres-migrate.js +1 -0
- package/dist/plan/own-limits.d.ts +12 -0
- package/dist/plan/own-limits.js +2 -0
- package/dist/plan/plan-apply.d.ts +51 -1
- package/dist/plan/plan-apply.js +298 -25
- package/dist/plan/platform-diff.d.ts +6 -0
- package/dist/plan/platform-diff.js +74 -0
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-providers.d.ts +46 -1
- package/dist/plan/platform-providers.js +162 -1
- package/dist/plan/platform-registry-keys.d.ts +58 -0
- package/dist/plan/platform-registry-keys.js +69 -0
- package/dist/plan/platform-registry.d.ts +67 -0
- package/dist/plan/platform-registry.js +298 -0
- package/dist/plan/post-logout-uris.d.ts +1 -1
- package/dist/plan/post-logout-uris.js +5 -2
- package/dist/plan/providers-readers.js +20 -1
- package/dist/plan/release-host.d.ts +31 -2
- package/dist/plan/release-host.js +114 -6
- package/dist/plan/stage-copy-host.d.ts +104 -0
- package/dist/plan/stage-copy-host.js +259 -0
- package/dist/plan/stage-copy.js +44 -0
- package/dist/plan/stage-env.d.ts +9 -0
- package/dist/plan/stage-env.js +38 -0
- package/dist/plan/stage-guards.d.ts +57 -1
- package/dist/plan/stage-guards.js +106 -0
- package/dist/plan/stage-isolation.js +56 -0
- package/dist/plan/stage-services.d.ts +93 -0
- package/dist/plan/stage-services.js +153 -0
- package/dist/plan/stage-signin.d.ts +152 -0
- package/dist/plan/stage-signin.js +133 -0
- package/dist/plan/stage-sink.d.ts +2 -0
- package/dist/plan/stage-sink.js +6 -1
- package/dist/platform.d.ts +7 -1
- package/dist/platform.js +1 -0
- package/dist/release/decide.d.ts +12 -0
- package/dist/release/decide.js +37 -2
- package/dist/release/migration-compatibility.d.ts +66 -0
- package/dist/release/migration-compatibility.js +340 -0
- package/dist/release/run.d.ts +71 -1
- package/dist/release/run.js +92 -8
- package/dist/release/state.d.ts +14 -1
- package/dist/release/state.js +43 -0
- package/dist/release/types.d.ts +20 -0
- package/dist/release/types.js +2 -0
- package/dist/requested-changes.d.ts +11 -2
- package/dist/requested-changes.js +13 -1
- package/dist/slug.d.ts +1 -1
- package/dist/slug.js +11 -2
- package/dist/store/postgres.js +21 -0
- package/dist/store/schema.d.ts +87 -0
- package/dist/store/schema.js +10 -0
- package/package.json +3 -2
- package/versions.digests.json +1 -0
- package/versions.json +1 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { foldMigrationClasses } from '../worker-app-config.js';
|
|
2
|
+
import { parseMigrations, unappliedDatabaseMigrations, } from '../worker-app-migrations.js';
|
|
3
|
+
const REMOVAL_MARKER = /^--\s*removal-since:\s*(\S.*)$/i;
|
|
4
|
+
const STATEMENT_MAX_CHARS = 160;
|
|
5
|
+
/** Collapses whitespace and cuts a statement down to a readable, one-line length for a message. */
|
|
6
|
+
function oneLine(text) {
|
|
7
|
+
const collapsed = text.replace(/\s+/g, ' ').trim();
|
|
8
|
+
return collapsed.length > STATEMENT_MAX_CHARS
|
|
9
|
+
? `${collapsed.slice(0, STATEMENT_MAX_CHARS - 1)}…`
|
|
10
|
+
: collapsed;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Splits `sql` into standalone statements at a semicolon that sits outside a line comment, a
|
|
14
|
+
* block comment, a single- or double-quoted literal, and a dollar-quoted body (a function's
|
|
15
|
+
* `$$ ... $$`, which may itself hold semicolons). Each statement keeps any comment lines directly
|
|
16
|
+
* above it, so a `-- removal-since:` marker travels with the statement it excuses.
|
|
17
|
+
*/
|
|
18
|
+
function splitStatements(sql) {
|
|
19
|
+
const statements = [];
|
|
20
|
+
let current = '';
|
|
21
|
+
let i = 0;
|
|
22
|
+
const n = sql.length;
|
|
23
|
+
while (i < n) {
|
|
24
|
+
const ch = sql[i];
|
|
25
|
+
if (ch === '-' && sql[i + 1] === '-') {
|
|
26
|
+
const end = sql.indexOf('\n', i);
|
|
27
|
+
const end2 = end === -1 ? n : end + 1;
|
|
28
|
+
current += sql.slice(i, end2);
|
|
29
|
+
i = end2;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (ch === '/' && sql[i + 1] === '*') {
|
|
33
|
+
const end = sql.indexOf('*/', i + 2);
|
|
34
|
+
const end2 = end === -1 ? n : end + 2;
|
|
35
|
+
current += sql.slice(i, end2);
|
|
36
|
+
i = end2;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (ch === "'" || ch === '"') {
|
|
40
|
+
let j = i + 1;
|
|
41
|
+
while (j < n) {
|
|
42
|
+
if (sql[j] === ch) {
|
|
43
|
+
if (sql[j + 1] === ch) {
|
|
44
|
+
j += 2;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
j += 1;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
j += 1;
|
|
51
|
+
}
|
|
52
|
+
current += sql.slice(i, j);
|
|
53
|
+
i = j;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (ch === '$') {
|
|
57
|
+
const tagMatch = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/.exec(sql.slice(i));
|
|
58
|
+
if (tagMatch) {
|
|
59
|
+
const tag = tagMatch[0];
|
|
60
|
+
const end = sql.indexOf(tag, i + tag.length);
|
|
61
|
+
const end2 = end === -1 ? n : end + tag.length;
|
|
62
|
+
current += sql.slice(i, end2);
|
|
63
|
+
i = end2;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (ch === ';') {
|
|
68
|
+
statements.push(current);
|
|
69
|
+
current = '';
|
|
70
|
+
i += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
current += ch;
|
|
74
|
+
i += 1;
|
|
75
|
+
}
|
|
76
|
+
if (current.trim() !== '')
|
|
77
|
+
statements.push(current);
|
|
78
|
+
return statements;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Splits `code` at a top-level comma (outside a quoted literal or parentheses), so each element
|
|
82
|
+
* of an `ALTER TABLE`'s comma-separated action list -- `ADD COLUMN a text DEFAULT 'x', ADD COLUMN
|
|
83
|
+
* b integer NOT NULL` -- can be judged on its own. Without this, a `DEFAULT` on one added column
|
|
84
|
+
* hid a `NOT NULL` with no default on a different one, because the two regexes ran against the
|
|
85
|
+
* whole statement (foundry#941 review).
|
|
86
|
+
*/
|
|
87
|
+
function splitTopLevelCommas(code) {
|
|
88
|
+
const parts = [];
|
|
89
|
+
let current = '';
|
|
90
|
+
let depth = 0;
|
|
91
|
+
let i = 0;
|
|
92
|
+
const n = code.length;
|
|
93
|
+
while (i < n) {
|
|
94
|
+
const ch = code[i];
|
|
95
|
+
if (ch === "'" || ch === '"') {
|
|
96
|
+
let j = i + 1;
|
|
97
|
+
while (j < n) {
|
|
98
|
+
if (code[j] === ch) {
|
|
99
|
+
if (code[j + 1] === ch) {
|
|
100
|
+
j += 2;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
j += 1;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
j += 1;
|
|
107
|
+
}
|
|
108
|
+
current += code.slice(i, j);
|
|
109
|
+
i = j;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (ch === '(') {
|
|
113
|
+
depth += 1;
|
|
114
|
+
current += ch;
|
|
115
|
+
i += 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (ch === ')') {
|
|
119
|
+
depth = Math.max(0, depth - 1);
|
|
120
|
+
current += ch;
|
|
121
|
+
i += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (ch === ',' && depth === 0) {
|
|
125
|
+
parts.push(current);
|
|
126
|
+
current = '';
|
|
127
|
+
i += 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
current += ch;
|
|
131
|
+
i += 1;
|
|
132
|
+
}
|
|
133
|
+
parts.push(current);
|
|
134
|
+
return parts;
|
|
135
|
+
}
|
|
136
|
+
/** The statement with its leading blank and comment lines removed, so classification reads code, not a marker. */
|
|
137
|
+
function withoutLeadingComments(statement) {
|
|
138
|
+
const lines = statement.split('\n');
|
|
139
|
+
let i = 0;
|
|
140
|
+
while (i < lines.length) {
|
|
141
|
+
const trimmed = lines[i].trim();
|
|
142
|
+
if (trimmed === '' || trimmed.startsWith('--')) {
|
|
143
|
+
i += 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
return lines.slice(i).join('\n').trim();
|
|
149
|
+
}
|
|
150
|
+
/** The release named in a `-- removal-since: <release>` line directly above the statement, or `null`. */
|
|
151
|
+
function declaredSince(statement) {
|
|
152
|
+
for (const line of statement.split('\n')) {
|
|
153
|
+
const trimmed = line.trim();
|
|
154
|
+
if (trimmed === '')
|
|
155
|
+
continue;
|
|
156
|
+
const match = REMOVAL_MARKER.exec(trimmed);
|
|
157
|
+
if (match)
|
|
158
|
+
return match[1].trim();
|
|
159
|
+
if (!trimmed.startsWith('--'))
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Classifies one statement's code (comments already stripped) as additive or destructive, or
|
|
166
|
+
* `null` when it cannot tell: an unrecognised statement is never assumed to be additive.
|
|
167
|
+
*/
|
|
168
|
+
function classifyStatement(code) {
|
|
169
|
+
if (code === '')
|
|
170
|
+
return null;
|
|
171
|
+
if (/^CREATE\s+(OR\s+REPLACE\s+)?(TABLE|SCHEMA|EXTENSION|SEQUENCE|VIEW)\b/i.test(code)) {
|
|
172
|
+
return { destructive: false, reason: 'creates something new' };
|
|
173
|
+
}
|
|
174
|
+
if (/^CREATE\s+(UNIQUE\s+)?INDEX(\s+CONCURRENTLY)?\b/i.test(code)) {
|
|
175
|
+
return { destructive: false, reason: 'adds an index' };
|
|
176
|
+
}
|
|
177
|
+
if (/^CREATE\s+(OR\s+REPLACE\s+)?(FUNCTION|PROCEDURE)\b/i.test(code)) {
|
|
178
|
+
return { destructive: false, reason: 'adds or replaces a function' };
|
|
179
|
+
}
|
|
180
|
+
if (/^CREATE\s+(OR\s+REPLACE\s+)?TRIGGER\b/i.test(code)) {
|
|
181
|
+
return { destructive: false, reason: 'adds a trigger' };
|
|
182
|
+
}
|
|
183
|
+
if (/^(COMMENT\s+ON|GRANT)\b/i.test(code)) {
|
|
184
|
+
return {
|
|
185
|
+
destructive: false,
|
|
186
|
+
reason: 'changes neither data nor structure a running release reads',
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (/^ALTER\s+TABLE\b/i.test(code)) {
|
|
190
|
+
if (/\bDROP\s+COLUMN\b/i.test(code))
|
|
191
|
+
return { destructive: true, reason: 'drops a column' };
|
|
192
|
+
if (/\bDROP\s+CONSTRAINT\b/i.test(code))
|
|
193
|
+
return { destructive: true, reason: 'drops a constraint' };
|
|
194
|
+
if (/\bRENAME\b/i.test(code))
|
|
195
|
+
return { destructive: true, reason: 'renames a table or column' };
|
|
196
|
+
if (/\bALTER\s+COLUMN\b[\s\S]*\b(SET\s+DATA\s+)?TYPE\b/i.test(code)) {
|
|
197
|
+
return { destructive: true, reason: "changes a column's type" };
|
|
198
|
+
}
|
|
199
|
+
if (/\bSET\s+NOT\s+NULL\b/i.test(code)) {
|
|
200
|
+
return { destructive: true, reason: 'adds a NOT NULL constraint to an existing column' };
|
|
201
|
+
}
|
|
202
|
+
if (/\bADD\s+COLUMN\b/i.test(code)) {
|
|
203
|
+
for (const clause of splitTopLevelCommas(code)) {
|
|
204
|
+
if (!/\bADD\s+COLUMN\b/i.test(clause))
|
|
205
|
+
continue;
|
|
206
|
+
if (/\bNOT\s+NULL\b/i.test(clause) && !/\bDEFAULT\b/i.test(clause)) {
|
|
207
|
+
return { destructive: true, reason: 'adds a NOT NULL column with no default' };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { destructive: false, reason: 'adds a column' };
|
|
211
|
+
}
|
|
212
|
+
if (/\bADD\s+CONSTRAINT\b/i.test(code))
|
|
213
|
+
return { destructive: false, reason: 'adds a constraint' };
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
if (/^DROP\s+(TABLE|INDEX|TYPE|SCHEMA|FUNCTION|PROCEDURE|TRIGGER|VIEW|MATERIALIZED\s+VIEW|EXTENSION|SEQUENCE)\b/i.test(code)) {
|
|
217
|
+
return { destructive: true, reason: 'drops something the live release may still read' };
|
|
218
|
+
}
|
|
219
|
+
if (/^(TRUNCATE|DELETE\s+FROM|UPDATE\s+\S+\s+SET)\b/i.test(code)) {
|
|
220
|
+
return { destructive: true, reason: 'removes or rewrites existing rows' };
|
|
221
|
+
}
|
|
222
|
+
if (/^ALTER\s+(INDEX|SEQUENCE|VIEW|TYPE|SCHEMA)\b[\s\S]*\bRENAME\b/i.test(code)) {
|
|
223
|
+
return { destructive: true, reason: 'renames something' };
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
const splitHelp = (what) => `${what}; split this into two releases: stop using it in one, remove it in a later release once that one is no longer on live, idle or draining`;
|
|
228
|
+
/** A destructive statement or step named `since` is drained once that release deploys nowhere. */
|
|
229
|
+
function hasDrained(since, deployedReleases) {
|
|
230
|
+
return !deployedReleases.includes(since);
|
|
231
|
+
}
|
|
232
|
+
function sqlProblems(migrationsJson, liveMigrationIds, deployedReleases) {
|
|
233
|
+
let declared;
|
|
234
|
+
try {
|
|
235
|
+
declared = parseMigrations(migrationsJson);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
return [{ migration: '', statement: '', reason: error.message }];
|
|
239
|
+
}
|
|
240
|
+
let pending;
|
|
241
|
+
try {
|
|
242
|
+
pending = unappliedDatabaseMigrations(declared, liveMigrationIds);
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
return [{ migration: '', statement: '', reason: error.message }];
|
|
246
|
+
}
|
|
247
|
+
const problems = [];
|
|
248
|
+
for (const migration of pending) {
|
|
249
|
+
for (const statement of splitStatements(migration.sql)) {
|
|
250
|
+
const code = withoutLeadingComments(statement);
|
|
251
|
+
const classified = classifyStatement(code);
|
|
252
|
+
if (classified === null) {
|
|
253
|
+
problems.push({
|
|
254
|
+
migration: migration.id,
|
|
255
|
+
statement: oneLine(code),
|
|
256
|
+
reason: 'this statement is not one foundry recognises as additive; split it into its own release and ask a person',
|
|
257
|
+
});
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (!classified.destructive)
|
|
261
|
+
continue;
|
|
262
|
+
const since = declaredSince(statement);
|
|
263
|
+
if (since === null) {
|
|
264
|
+
problems.push({
|
|
265
|
+
migration: migration.id,
|
|
266
|
+
statement: oneLine(code),
|
|
267
|
+
reason: splitHelp(classified.reason),
|
|
268
|
+
});
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (!hasDrained(since, deployedReleases)) {
|
|
272
|
+
problems.push({
|
|
273
|
+
migration: migration.id,
|
|
274
|
+
statement: oneLine(code),
|
|
275
|
+
reason: `${classified.reason}; declared as the second step after release ${since}, but ${since} is still on live, idle or draining -- wait for it to fully drain, then release this one`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return problems;
|
|
281
|
+
}
|
|
282
|
+
function doProblems(doMigrations, liveDoTag, declaredRemovals, deployedReleases) {
|
|
283
|
+
if (doMigrations.length === 0)
|
|
284
|
+
return [];
|
|
285
|
+
let appliedCount = 0;
|
|
286
|
+
if (liveDoTag !== undefined) {
|
|
287
|
+
const index = doMigrations.findIndex((m) => m.tag === liveDoTag);
|
|
288
|
+
if (index < 0) {
|
|
289
|
+
return [
|
|
290
|
+
{
|
|
291
|
+
migration: liveDoTag,
|
|
292
|
+
statement: '',
|
|
293
|
+
reason: "the live release holds a Durable Object migration that is not in this release's declared list; migrations are append-only",
|
|
294
|
+
},
|
|
295
|
+
];
|
|
296
|
+
}
|
|
297
|
+
appliedCount = index + 1;
|
|
298
|
+
}
|
|
299
|
+
const folded = foldMigrationClasses(doMigrations.slice(0, appliedCount));
|
|
300
|
+
if ('error' in folded)
|
|
301
|
+
return [{ migration: '', statement: '', reason: folded.error }];
|
|
302
|
+
const problems = [];
|
|
303
|
+
for (const migration of doMigrations.slice(appliedCount)) {
|
|
304
|
+
for (const className of migration.deletedClasses ?? []) {
|
|
305
|
+
if (!folded.classes.has(className))
|
|
306
|
+
continue; // never reached live: nothing running still uses it
|
|
307
|
+
const removal = declaredRemovals.find((r) => r.className === className);
|
|
308
|
+
const statement = `deleted_classes: ${className}`;
|
|
309
|
+
if (!removal) {
|
|
310
|
+
problems.push({
|
|
311
|
+
migration: migration.tag,
|
|
312
|
+
statement,
|
|
313
|
+
reason: splitHelp(`deletes the Durable Object class '${className}', which the live release still uses`),
|
|
314
|
+
});
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (!hasDrained(removal.sinceRelease, deployedReleases)) {
|
|
318
|
+
problems.push({
|
|
319
|
+
migration: migration.tag,
|
|
320
|
+
statement,
|
|
321
|
+
reason: `deletes the Durable Object class '${className}', declared as the second step after release ${removal.sinceRelease}, but ${removal.sinceRelease} is still on live, idle or draining -- wait for it to fully drain, then release this one`,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return problems;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Whether a release's migrations (SQL and Durable Object) may go to idle, given what the
|
|
330
|
+
* previous live release already applied and which releases are deployed anywhere right now. See
|
|
331
|
+
* the module docstring for the rule and the removal convention.
|
|
332
|
+
*/
|
|
333
|
+
export function checkMigrationCompatibility(input) {
|
|
334
|
+
const deployedReleases = input.deployedReleases ?? [];
|
|
335
|
+
const problems = [
|
|
336
|
+
...sqlProblems(input.migrationsJson, input.liveMigrationIds ?? [], deployedReleases),
|
|
337
|
+
...doProblems(input.doMigrations ?? [], input.liveDoTag, input.declaredRemovals ?? [], deployedReleases),
|
|
338
|
+
];
|
|
339
|
+
return problems.length === 0 ? { ok: true } : { ok: false, problems };
|
|
340
|
+
}
|
package/dist/release/run.d.ts
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import { type Decision, type Observation, type ReleaseEffect, type ReleaseEntryConfig } from './decide.js';
|
|
2
2
|
import { type ReleaseStore } from './state.js';
|
|
3
|
-
import type
|
|
3
|
+
import { type Color } from './types.js';
|
|
4
|
+
/** What a stage copy request came to. Fixed text only: a reason, a step name or an id. */
|
|
5
|
+
export type StageCopyOutcome = {
|
|
6
|
+
outcome: 'ok';
|
|
7
|
+
detail: string;
|
|
8
|
+
recorded: boolean;
|
|
9
|
+
} | {
|
|
10
|
+
outcome: 'refused';
|
|
11
|
+
reason: string;
|
|
12
|
+
} | {
|
|
13
|
+
outcome: 'failed';
|
|
14
|
+
step: string;
|
|
15
|
+
}
|
|
16
|
+
/** Another run holds the copy lock: leave the request open and write nothing. */
|
|
17
|
+
| {
|
|
18
|
+
outcome: 'busy';
|
|
19
|
+
};
|
|
4
20
|
/** What the runner needs from the outside. Each call is bounded by its own timeout. */
|
|
5
21
|
export interface ReleaseDeps {
|
|
6
22
|
/** The reporting health read for the range, or null when it failed. */
|
|
@@ -11,6 +27,17 @@ export interface ReleaseDeps {
|
|
|
11
27
|
/** The Coolify status and the own-address check of a color. */
|
|
12
28
|
observeColor(color: Color): Promise<Pick<Observation, 'status' | 'address'>>;
|
|
13
29
|
effect(effect: ReleaseEffect): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Runs one stage copy request (the copy can take ~45 minutes); {@link runStageCopy} calls it after the run
|
|
32
|
+
* that decided on the copy is done. It answers with the outcome and throws only for a lost lock. Absent: the
|
|
33
|
+
* request is refused with `no-copy-port`.
|
|
34
|
+
*/
|
|
35
|
+
stageCopy?(request: {
|
|
36
|
+
mode: 'copy' | 'undo';
|
|
37
|
+
requestId: number;
|
|
38
|
+
actor: string;
|
|
39
|
+
runId?: string;
|
|
40
|
+
}): Promise<StageCopyOutcome>;
|
|
14
41
|
}
|
|
15
42
|
export interface ReleaseRunResult {
|
|
16
43
|
/** Scope keys to record (null removes one). The caller persists them with the rest of the run. */
|
|
@@ -20,6 +47,22 @@ export interface ReleaseRunResult {
|
|
|
20
47
|
decision: Decision | null;
|
|
21
48
|
/** The requests this run acted on. The caller consumes them with `store.consumeRequests` after it persists `writes`. */
|
|
22
49
|
requestIds: number[];
|
|
50
|
+
/** A stage copy request was left open because this run does not run copies. */
|
|
51
|
+
deferredCopy: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* The stage copy this run decided on, not yet run. The caller runs it (`runStageCopy`) after it has persisted
|
|
54
|
+
* `writes` and written the weights, so a rollback decided in the same run never waits for the copy. Its
|
|
55
|
+
* request is not in `requestIds`: {@link settleStageCopy} says when it is consumed.
|
|
56
|
+
*/
|
|
57
|
+
copy: StageCopyJob | null;
|
|
58
|
+
}
|
|
59
|
+
/** One stage copy request a run decided to act on. */
|
|
60
|
+
export interface StageCopyJob {
|
|
61
|
+
mode: 'copy' | 'undo';
|
|
62
|
+
requestId: number;
|
|
63
|
+
actor: string;
|
|
64
|
+
/** The token of the run that took the copy lock. Set by the caller that claims the lock. */
|
|
65
|
+
runId?: string;
|
|
23
66
|
}
|
|
24
67
|
/**
|
|
25
68
|
* One run for one entry. Reads the recorded keys fresh and the open
|
|
@@ -36,4 +79,31 @@ export declare function runRelease(input: {
|
|
|
36
79
|
deps: ReleaseDeps;
|
|
37
80
|
/** No reporting health read: only hard fails count (see `decide`). */
|
|
38
81
|
hardOnly?: boolean;
|
|
82
|
+
/** Leave stage copy requests open and untouched (an apply, and the tick's first phase). */
|
|
83
|
+
deferCopies?: boolean;
|
|
39
84
|
}): Promise<ReleaseRunResult>;
|
|
85
|
+
/** What a finished stage copy writes and whether its request is done. */
|
|
86
|
+
export interface StageCopySettled {
|
|
87
|
+
writes: Record<string, string | null>;
|
|
88
|
+
/** Fixed texts for the operator. */
|
|
89
|
+
refused: string[];
|
|
90
|
+
/** The request is consumed. False: it stays open (busy, or the first failure). */
|
|
91
|
+
consume: boolean;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Runs the copy `job` with `deps.stageCopy` (absent: refused with `no-copy-port`). Call it after the run that
|
|
95
|
+
* decided on it has persisted its writes and written the weights: the copy takes up to ~45 minutes.
|
|
96
|
+
*/
|
|
97
|
+
export declare function runStageCopy(deps: ReleaseDeps, job: StageCopyJob): Promise<StageCopyOutcome>;
|
|
98
|
+
/**
|
|
99
|
+
* What a copy outcome records. `ids` are the recorded keys now (the retry mark is read from them). A failure is
|
|
100
|
+
* retried once, and only a failure before the switch can come back as `failed`: the host reports a failure after
|
|
101
|
+
* the switch as `ok`, so a retry never sweeps the database stage runs on or its undo target.
|
|
102
|
+
*/
|
|
103
|
+
export declare function settleStageCopy(input: {
|
|
104
|
+
ids: Readonly<Record<string, string>>;
|
|
105
|
+
entryKey: string;
|
|
106
|
+
now: Date;
|
|
107
|
+
job: StageCopyJob;
|
|
108
|
+
outcome: StageCopyOutcome;
|
|
109
|
+
}): StageCopySettled;
|
package/dist/release/run.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { decide, healthRange, } from './decide.js';
|
|
2
2
|
import { formatWeights, formatWindow, recordedFrom, releaseKeys, } from './state.js';
|
|
3
|
+
import { isStageCopyAction } from './types.js';
|
|
4
|
+
const REFUSAL_TEXT = {
|
|
5
|
+
'no-copy-port': 'copy refused: this host cannot restore',
|
|
6
|
+
};
|
|
3
7
|
/**
|
|
4
8
|
* One run for one entry. Reads the recorded keys fresh and the open
|
|
5
9
|
* requests (it consumes none), decides and runs the effects. A failure, or a
|
|
@@ -10,9 +14,25 @@ export async function runRelease(input) {
|
|
|
10
14
|
const { store, orgId, entryKey, deps, now } = input;
|
|
11
15
|
const ids = await store.getScopeIds(orgId);
|
|
12
16
|
const recorded = recordedFrom(ids, entryKey);
|
|
13
|
-
if (!recorded)
|
|
14
|
-
return {
|
|
15
|
-
|
|
17
|
+
if (!recorded) {
|
|
18
|
+
return {
|
|
19
|
+
writes: {},
|
|
20
|
+
blocked: [],
|
|
21
|
+
refused: [],
|
|
22
|
+
decision: null,
|
|
23
|
+
requestIds: [],
|
|
24
|
+
deferredCopy: false,
|
|
25
|
+
copy: null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const k = releaseKeys(entryKey);
|
|
29
|
+
const all = await store.openRequests({ orgId, entryKey });
|
|
30
|
+
// A stage request whose result was persisted before a crash is consumed, never run a second time.
|
|
31
|
+
const handledId = ids[k.stageCopyHandled];
|
|
32
|
+
const replayed = all.filter((r) => isStageCopyAction(r.action) && String(r.id) === handledId);
|
|
33
|
+
const fresh = all.filter((r) => !replayed.includes(r));
|
|
34
|
+
const deferred = input.deferCopies ? fresh.filter((r) => isStageCopyAction(r.action)) : [];
|
|
35
|
+
const open = fresh.filter((r) => !deferred.includes(r));
|
|
16
36
|
const actions = open.map((r) => r.action);
|
|
17
37
|
const range = healthRange(recorded, now);
|
|
18
38
|
const target = recorded.release?.to ?? null;
|
|
@@ -33,11 +53,25 @@ export async function runRelease(input) {
|
|
|
33
53
|
observation: { health, ...seen },
|
|
34
54
|
...(input.hardOnly ? { hardOnly: true } : {}),
|
|
35
55
|
});
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
56
|
+
const extra = {};
|
|
57
|
+
const refused = [...decision.refused];
|
|
58
|
+
const stamp = now.toISOString();
|
|
59
|
+
let copy = null;
|
|
60
|
+
const requestOf = (kind) => open.find((r) => r.action.kind === kind);
|
|
61
|
+
for (const effect of decision.effects) {
|
|
62
|
+
if (effect.kind !== 'stage_copy') {
|
|
63
|
+
await deps.effect(effect);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const request = requestOf(effect.mode === 'copy' ? 'copy_prod_to_stage' : 'undo_stage_copy');
|
|
67
|
+
if (request)
|
|
68
|
+
copy = { mode: effect.mode, requestId: request.id, actor: request.actor };
|
|
69
|
+
}
|
|
70
|
+
if (decision.stageRefusal)
|
|
71
|
+
extra[k.stageCopy] = `${stamp}|refused|${decision.stageRefusal}`;
|
|
39
72
|
const next = decision.recorded;
|
|
40
73
|
const writes = {
|
|
74
|
+
...extra,
|
|
41
75
|
[k.live]: next.live,
|
|
42
76
|
[k.weights]: formatWeights(next.weights),
|
|
43
77
|
[k.digest('blue')]: next.digests.blue,
|
|
@@ -54,8 +88,58 @@ export async function runRelease(input) {
|
|
|
54
88
|
return {
|
|
55
89
|
writes,
|
|
56
90
|
blocked: decision.blocked,
|
|
57
|
-
refused
|
|
91
|
+
refused,
|
|
58
92
|
decision,
|
|
59
|
-
requestIds: open.map((r) => r.id),
|
|
93
|
+
requestIds: [...replayed, ...open.filter((r) => r.id !== copy?.requestId)].map((r) => r.id),
|
|
94
|
+
deferredCopy: deferred.length > 0,
|
|
95
|
+
copy,
|
|
60
96
|
};
|
|
61
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Runs the copy `job` with `deps.stageCopy` (absent: refused with `no-copy-port`). Call it after the run that
|
|
100
|
+
* decided on it has persisted its writes and written the weights: the copy takes up to ~45 minutes.
|
|
101
|
+
*/
|
|
102
|
+
export async function runStageCopy(deps, job) {
|
|
103
|
+
return deps.stageCopy
|
|
104
|
+
? deps.stageCopy({
|
|
105
|
+
mode: job.mode,
|
|
106
|
+
requestId: job.requestId,
|
|
107
|
+
actor: job.actor,
|
|
108
|
+
...(job.runId ? { runId: job.runId } : {}),
|
|
109
|
+
})
|
|
110
|
+
: { outcome: 'refused', reason: 'no-copy-port' };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* What a copy outcome records. `ids` are the recorded keys now (the retry mark is read from them). A failure is
|
|
114
|
+
* retried once, and only a failure before the switch can come back as `failed`: the host reports a failure after
|
|
115
|
+
* the switch as `ok`, so a retry never sweeps the database stage runs on or its undo target.
|
|
116
|
+
*/
|
|
117
|
+
export function settleStageCopy(input) {
|
|
118
|
+
const { ids, outcome: result, job } = input;
|
|
119
|
+
const k = releaseKeys(input.entryKey);
|
|
120
|
+
const stamp = input.now.toISOString();
|
|
121
|
+
const writes = {};
|
|
122
|
+
const refused = [];
|
|
123
|
+
if (result.outcome === 'busy')
|
|
124
|
+
return { writes, refused, consume: false };
|
|
125
|
+
if (result.outcome === 'ok') {
|
|
126
|
+
if (!result.recorded)
|
|
127
|
+
writes[k.stageCopy] = `${stamp}|${result.detail}|ok`;
|
|
128
|
+
writes[k.stageCopyRetry] = null;
|
|
129
|
+
return { writes, refused, consume: true };
|
|
130
|
+
}
|
|
131
|
+
if (result.outcome === 'refused') {
|
|
132
|
+
writes[k.stageCopy] = `${stamp}|refused|${result.reason}`;
|
|
133
|
+
writes[k.stageCopyRetry] = null;
|
|
134
|
+
refused.push(REFUSAL_TEXT[result.reason] ?? `copy refused: ${result.reason}`);
|
|
135
|
+
return { writes, refused, consume: true };
|
|
136
|
+
}
|
|
137
|
+
writes[k.stageCopy] = `${stamp}|failed|${result.step}`;
|
|
138
|
+
if (ids[k.stageCopyRetry] === String(job.requestId)) {
|
|
139
|
+
writes[k.stageCopyRetry] = null;
|
|
140
|
+
return { writes, refused, consume: true };
|
|
141
|
+
}
|
|
142
|
+
// The first failure leaves the request open for the next run; the second consumes it.
|
|
143
|
+
writes[k.stageCopyRetry] = String(job.requestId);
|
|
144
|
+
return { writes, refused, consume: false };
|
|
145
|
+
}
|
package/dist/release/state.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { type Recorded, resolveReleaseConfig } from './decide.js';
|
|
3
|
-
import type { Color, ReleaseAction, ReleaseSignal, ReleaseState, WindowState } from './types.js';
|
|
3
|
+
import type { Color, ReleaseAction, ReleaseSignal, ReleaseState, StageCopyState, WindowState } from './types.js';
|
|
4
4
|
/**
|
|
5
5
|
* The persistence the release runner and Archon share. `getScopeIds` is the
|
|
6
6
|
* recorded `bg.*` keys of an org's applied state (or of the platform, for the
|
|
@@ -33,10 +33,21 @@ export declare function releaseKeys(entry: string): {
|
|
|
33
33
|
digest: (c: Color) => string;
|
|
34
34
|
weights: string;
|
|
35
35
|
stage: string;
|
|
36
|
+
stageDeploy: string;
|
|
36
37
|
window: string;
|
|
37
38
|
signal: string;
|
|
38
39
|
heldPrefix: string;
|
|
39
40
|
held: (digest: string) => string;
|
|
41
|
+
/** Set once stage holds the sink env (`bg.<id>.stagesink`, written by the structure apply). */
|
|
42
|
+
stageSink: string;
|
|
43
|
+
/** `<ISO time>|<execution id>|ok`, `<ISO time>|undo|ok`, `<ISO time>|refused|<reason>` or `<ISO time>|failed|<step>`. */
|
|
44
|
+
stageCopy: string;
|
|
45
|
+
/** `<expiry ms>|<request id>` while a copy runs. An unexpired lock makes another run leave the request open. */
|
|
46
|
+
stageCopyLock: string;
|
|
47
|
+
/** The id of the last stage request whose result was persisted: a replay of it is consumed, not run again. */
|
|
48
|
+
stageCopyHandled: string;
|
|
49
|
+
/** The id of a request whose copy failed once; a second failure consumes it. */
|
|
50
|
+
stageCopyRetry: string;
|
|
40
51
|
};
|
|
41
52
|
export declare function formatWeights(w: Record<Color, number>): string;
|
|
42
53
|
export declare function parseWeights(value: string | undefined): Record<Color, number> | null;
|
|
@@ -47,6 +58,8 @@ export declare function parseWindow(value: string | undefined): {
|
|
|
47
58
|
};
|
|
48
59
|
export declare function formatWindow(window: WindowState, release: Recorded['release']): string;
|
|
49
60
|
export declare function recordedFrom(ids: Record<string, string>, entryKey: string): Recorded | null;
|
|
61
|
+
/** Reads `bg.<id>.stagecopy`; a damaged value reads as absent. */
|
|
62
|
+
export declare function parseStageCopy(value: string | undefined): StageCopyState | undefined;
|
|
50
63
|
export declare function readSignal(ids: Record<string, string>, entryKey: string): ReleaseSignal | null;
|
|
51
64
|
/** C2: the release state of one hosting entry, or null before blue/green recorded anything. */
|
|
52
65
|
export declare function readReleaseState(store: ReleaseStore, input: {
|
package/dist/release/state.js
CHANGED
|
@@ -10,10 +10,21 @@ export function releaseKeys(entry) {
|
|
|
10
10
|
digest: (c) => `${p}.digest.${c}`,
|
|
11
11
|
weights: `${p}.weights`,
|
|
12
12
|
stage: `${p}.stage`,
|
|
13
|
+
stageDeploy: `${p}.stagedeploy`,
|
|
13
14
|
window: `${p}.window`,
|
|
14
15
|
signal: `${p}.signal`,
|
|
15
16
|
heldPrefix: `${p}.held.`,
|
|
16
17
|
held: (digest) => `${p}.held.${digest}`,
|
|
18
|
+
/** Set once stage holds the sink env (`bg.<id>.stagesink`, written by the structure apply). */
|
|
19
|
+
stageSink: `${p}.stagesink`,
|
|
20
|
+
/** `<ISO time>|<execution id>|ok`, `<ISO time>|undo|ok`, `<ISO time>|refused|<reason>` or `<ISO time>|failed|<step>`. */
|
|
21
|
+
stageCopy: `${p}.stagecopy`,
|
|
22
|
+
/** `<expiry ms>|<request id>` while a copy runs. An unexpired lock makes another run leave the request open. */
|
|
23
|
+
stageCopyLock: `${p}.stagecopylock`,
|
|
24
|
+
/** The id of the last stage request whose result was persisted: a replay of it is consumed, not run again. */
|
|
25
|
+
stageCopyHandled: `${p}.stagecopyhandled`,
|
|
26
|
+
/** The id of a request whose copy failed once; a second failure consumes it. */
|
|
27
|
+
stageCopyRetry: `${p}.stagecopyretry`,
|
|
17
28
|
};
|
|
18
29
|
}
|
|
19
30
|
export function formatWeights(w) {
|
|
@@ -87,12 +98,30 @@ export function recordedFrom(ids, entryKey) {
|
|
|
87
98
|
weights: parseWeights(ids[k.weights]) ?? { ...{ blue: 0, green: 0 }, [live.data]: 100 },
|
|
88
99
|
window,
|
|
89
100
|
release,
|
|
101
|
+
stageSink: Boolean(ids[k.stageSink]),
|
|
90
102
|
held: Object.keys(ids)
|
|
91
103
|
.filter((key) => key.startsWith(k.heldPrefix))
|
|
92
104
|
.map((key) => key.slice(k.heldPrefix.length))
|
|
93
105
|
.sort(),
|
|
94
106
|
};
|
|
95
107
|
}
|
|
108
|
+
const STAGE_COPY_DETAIL = /^[A-Za-z0-9_-]{1,64}$/;
|
|
109
|
+
/** Reads `bg.<id>.stagecopy`; a damaged value reads as absent. */
|
|
110
|
+
export function parseStageCopy(value) {
|
|
111
|
+
const parts = (value ?? '').split('|');
|
|
112
|
+
if (parts.length !== 3)
|
|
113
|
+
return undefined;
|
|
114
|
+
const [at, middle, last] = parts;
|
|
115
|
+
if (Number.isNaN(Date.parse(at)))
|
|
116
|
+
return undefined;
|
|
117
|
+
if (last === 'ok') {
|
|
118
|
+
return STAGE_COPY_DETAIL.test(middle) ? { at, outcome: 'ok', detail: middle } : undefined;
|
|
119
|
+
}
|
|
120
|
+
if ((middle === 'refused' || middle === 'failed') && STAGE_COPY_DETAIL.test(last)) {
|
|
121
|
+
return { at, outcome: middle, detail: last };
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
96
125
|
export function readSignal(ids, entryKey) {
|
|
97
126
|
const parsed = signalSchema.safeParse(parseJson(ids[releaseKeys(entryKey).signal]));
|
|
98
127
|
return parsed.success ? parsed.data : null;
|
|
@@ -103,6 +132,7 @@ export async function readReleaseState(store, input) {
|
|
|
103
132
|
const r = recordedFrom(ids, input.entryKey);
|
|
104
133
|
if (!r)
|
|
105
134
|
return null;
|
|
135
|
+
const stageCopy = parseStageCopy(ids[releaseKeys(input.entryKey).stageCopy]);
|
|
106
136
|
return {
|
|
107
137
|
entryKey: input.entryKey,
|
|
108
138
|
live: r.live,
|
|
@@ -113,6 +143,17 @@ export async function readReleaseState(store, input) {
|
|
|
113
143
|
signal: readSignal(ids, input.entryKey),
|
|
114
144
|
held: r.held,
|
|
115
145
|
config: resolveReleaseConfig(input.entry),
|
|
146
|
+
...(stageCopy ? { stageCopy } : {}),
|
|
147
|
+
...stageDeployOf(ids[releaseKeys(input.entryKey).stageDeploy]),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** The last stage deploy from `<ISO time>|ok|<commit>` or `<ISO time>|failed|<step>`; nothing for a damaged value. */
|
|
151
|
+
function stageDeployOf(value) {
|
|
152
|
+
const m = /^([^|]+)\|(ok|failed)\|([^|]*)$/.exec(value ?? '');
|
|
153
|
+
if (!m || Number.isNaN(Date.parse(m[1])))
|
|
154
|
+
return {};
|
|
155
|
+
return {
|
|
156
|
+
stageDeploy: { at: m[1], outcome: m[2], detail: m[3] },
|
|
116
157
|
};
|
|
117
158
|
}
|
|
118
159
|
const actionSchema = z.discriminatedUnion('kind', [
|
|
@@ -123,6 +164,8 @@ const actionSchema = z.discriminatedUnion('kind', [
|
|
|
123
164
|
z.object({ kind: z.literal('approve_step') }),
|
|
124
165
|
z.object({ kind: z.literal('open') }),
|
|
125
166
|
z.object({ kind: z.literal('deploy_stage'), ref: z.string().regex(STAGE_REF_PATTERN) }),
|
|
167
|
+
z.object({ kind: z.literal('copy_prod_to_stage') }),
|
|
168
|
+
z.object({ kind: z.literal('undo_stage_copy') }),
|
|
126
169
|
]);
|
|
127
170
|
/** C2: ask the runner to apply an action on its next run. Archon audits it. */
|
|
128
171
|
export async function requestReleaseAction(store, input) {
|