mandrel-platform 0.20.1 → 0.24.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/package.json +1 -1
- package/scripts/check-ci-required-aggregator.test.mjs +227 -0
- package/scripts/check-coverage-threshold.test.mjs +56 -51
- package/scripts/check-destructive-migration.mjs +68 -6
- package/scripts/check-destructive-migration.test.mjs +146 -0
- package/scripts/check-runner-health.mjs +469 -0
- package/scripts/check-runner-health.test.mjs +389 -0
- package/scripts/deploy-boot-smoke.mjs +364 -0
- package/scripts/deploy-boot-smoke.test.mjs +381 -0
- package/scripts/deploy-worker-secrets.mjs +190 -0
- package/scripts/deploy-worker-secrets.test.mjs +136 -0
- package/scripts/platform-sync.test.mjs +36 -9
- package/scripts/runner-fleet-consumers.json +20 -0
- package/templates/runbooks/README.md +13 -0
- package/templates/runbooks/runner-fleet-health.md +150 -0
- package/templates/runbooks/runner-provisioning.md +187 -0
- package/templates/runner/.env.example +45 -0
- package/templates/runner/job-cleanup.sh +97 -0
- package/templates/workflows/deploy-staging-run.yml +77 -0
- package/templates/workflows/deploy-staging.yml +67 -62
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-runner-health.test.mjs — node:test suite for the scheduled
|
|
4
|
+
* runner-fleet health monitor (Story #258).
|
|
5
|
+
*
|
|
6
|
+
* The checker exposes pure helpers plus an injectable `runGh` seam, so the
|
|
7
|
+
* whole pipeline is exercised offline with canned GitHub responses — no
|
|
8
|
+
* network, no `gh` auth.
|
|
9
|
+
*
|
|
10
|
+
* Run: node scripts/check-runner-health.test.mjs (or `node --test scripts/`)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { test } from "node:test";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
buildReport,
|
|
21
|
+
classifyRunners,
|
|
22
|
+
fetchQueuedRuns,
|
|
23
|
+
fetchRunners,
|
|
24
|
+
hasUnhealthy,
|
|
25
|
+
isRepoHealthy,
|
|
26
|
+
isStaleQueuedRun,
|
|
27
|
+
parseArgv,
|
|
28
|
+
renderReport,
|
|
29
|
+
runCli,
|
|
30
|
+
runnerMatchesLabels,
|
|
31
|
+
} from "./check-runner-health.mjs";
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// parseArgv
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
test("parseArgv defaults to the fleet consumer config", () => {
|
|
38
|
+
const opts = parseArgv([]);
|
|
39
|
+
assert.equal(opts.config, "scripts/runner-fleet-consumers.json");
|
|
40
|
+
assert.equal(opts.json, false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("parseArgv parses --config and --json", () => {
|
|
44
|
+
const opts = parseArgv(["--config", "custom.json", "--json"]);
|
|
45
|
+
assert.equal(opts.config, "custom.json");
|
|
46
|
+
assert.equal(opts.json, true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// runnerMatchesLabels
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
test("runnerMatchesLabels is case-insensitive and order-independent", () => {
|
|
54
|
+
assert.equal(
|
|
55
|
+
runnerMatchesLabels(["Self-Hosted", "macOS", "ARM64", "domio-runner"], [
|
|
56
|
+
"self-hosted",
|
|
57
|
+
"arm64",
|
|
58
|
+
]),
|
|
59
|
+
true,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("runnerMatchesLabels fails when an expected label is missing", () => {
|
|
64
|
+
assert.equal(
|
|
65
|
+
runnerMatchesLabels(["self-hosted", "macOS"], ["self-hosted", "ARM64"]),
|
|
66
|
+
false,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// classifyRunners
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
const EXPECTED = { expectedCount: 3, labels: ["self-hosted", "macOS", "ARM64", "domio-runner"] };
|
|
75
|
+
|
|
76
|
+
test("classifyRunners reports a fully healthy fleet", () => {
|
|
77
|
+
const runners = [1, 2, 3].map((n) => ({
|
|
78
|
+
id: n,
|
|
79
|
+
name: `domio-runner-${n}`,
|
|
80
|
+
status: "online",
|
|
81
|
+
labels: [{ name: "self-hosted" }, { name: "macOS" }, { name: "ARM64" }, { name: "domio-runner" }],
|
|
82
|
+
}));
|
|
83
|
+
const v = classifyRunners(runners, EXPECTED);
|
|
84
|
+
assert.equal(v.total, 3);
|
|
85
|
+
assert.equal(v.online, 3);
|
|
86
|
+
assert.equal(v.matchingOnline, 3);
|
|
87
|
+
assert.equal(v.shortfall, 0);
|
|
88
|
+
assert.equal(v.hasShortfall, false);
|
|
89
|
+
assert.equal(v.hasOffline, false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("classifyRunners flags an offline runner", () => {
|
|
93
|
+
const runners = [
|
|
94
|
+
{ id: 1, name: "domio-runner-1", status: "online", labels: [{ name: "self-hosted" }, { name: "macOS" }, { name: "ARM64" }, { name: "domio-runner" }] },
|
|
95
|
+
{ id: 2, name: "domio-runner-2", status: "offline", labels: [{ name: "self-hosted" }, { name: "macOS" }, { name: "ARM64" }, { name: "domio-runner" }] },
|
|
96
|
+
];
|
|
97
|
+
const v = classifyRunners(runners, EXPECTED);
|
|
98
|
+
assert.equal(v.hasOffline, true);
|
|
99
|
+
assert.equal(v.offline.length, 1);
|
|
100
|
+
assert.equal(v.offline[0].name, "domio-runner-2");
|
|
101
|
+
assert.equal(v.matchingOnline, 1);
|
|
102
|
+
assert.equal(v.hasShortfall, true);
|
|
103
|
+
assert.equal(v.shortfall, 2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("classifyRunners flags a count shortfall even with all-online but wrong labels", () => {
|
|
107
|
+
const runners = [
|
|
108
|
+
{ id: 1, name: "other-runner", status: "online", labels: [{ name: "self-hosted" }, { name: "linux" }] },
|
|
109
|
+
];
|
|
110
|
+
const v = classifyRunners(runners, EXPECTED);
|
|
111
|
+
assert.equal(v.hasOffline, false);
|
|
112
|
+
assert.equal(v.matchingOnline, 0);
|
|
113
|
+
assert.equal(v.hasShortfall, true);
|
|
114
|
+
assert.equal(v.shortfall, 3);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("classifyRunners handles an empty runner list", () => {
|
|
118
|
+
const v = classifyRunners([], EXPECTED);
|
|
119
|
+
assert.equal(v.total, 0);
|
|
120
|
+
assert.equal(v.hasShortfall, true);
|
|
121
|
+
assert.equal(v.shortfall, 3);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// isStaleQueuedRun
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
const NOW = Date.parse("2026-07-03T12:00:00Z");
|
|
129
|
+
|
|
130
|
+
test("isStaleQueuedRun flags an old queued run with no matching online runner", () => {
|
|
131
|
+
const run = {
|
|
132
|
+
status: "queued",
|
|
133
|
+
created_at: "2026-07-03T11:00:00Z", // 60 min ago
|
|
134
|
+
labels: ["self-hosted", "macOS", "ARM64", "domio-runner"],
|
|
135
|
+
};
|
|
136
|
+
assert.equal(isStaleQueuedRun(run, [], 20, NOW), true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("isStaleQueuedRun does not flag a recently queued run", () => {
|
|
140
|
+
const run = {
|
|
141
|
+
status: "queued",
|
|
142
|
+
created_at: "2026-07-03T11:55:00Z", // 5 min ago
|
|
143
|
+
labels: ["self-hosted", "macOS", "ARM64", "domio-runner"],
|
|
144
|
+
};
|
|
145
|
+
assert.equal(isStaleQueuedRun(run, [], 20, NOW), false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("isStaleQueuedRun does not flag when a matching online runner exists", () => {
|
|
149
|
+
const run = {
|
|
150
|
+
status: "queued",
|
|
151
|
+
created_at: "2026-07-03T11:00:00Z",
|
|
152
|
+
labels: ["self-hosted", "macOS", "ARM64", "domio-runner"],
|
|
153
|
+
};
|
|
154
|
+
const onlineLabelSets = [["self-hosted", "macOS", "ARM64", "domio-runner"]];
|
|
155
|
+
assert.equal(isStaleQueuedRun(run, onlineLabelSets, 20, NOW), false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("isStaleQueuedRun ignores non-queued/waiting runs", () => {
|
|
159
|
+
const run = { status: "completed", created_at: "2026-07-03T09:00:00Z" };
|
|
160
|
+
assert.equal(isStaleQueuedRun(run, [], 20, NOW), false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("isStaleQueuedRun does not flag when no label info is available", () => {
|
|
164
|
+
const run = { status: "queued", created_at: "2026-07-03T09:00:00Z", labels: [] };
|
|
165
|
+
assert.equal(isStaleQueuedRun(run, [], 20, NOW), false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// isRepoHealthy
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
test("isRepoHealthy is true only with no offline/shortfall/stale", () => {
|
|
173
|
+
const healthyVerdict = { hasOffline: false, hasShortfall: false };
|
|
174
|
+
assert.equal(isRepoHealthy(healthyVerdict, []), true);
|
|
175
|
+
assert.equal(isRepoHealthy({ ...healthyVerdict, hasOffline: true }, []), false);
|
|
176
|
+
assert.equal(isRepoHealthy({ ...healthyVerdict, hasShortfall: true }, []), false);
|
|
177
|
+
assert.equal(isRepoHealthy(healthyVerdict, [{ id: 1 }]), false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// renderReport
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
test("renderReport renders a healthy fleet with no Degraded section", () => {
|
|
185
|
+
const report = {
|
|
186
|
+
results: [
|
|
187
|
+
{
|
|
188
|
+
name: "domio",
|
|
189
|
+
repo: "dsj1984/domio",
|
|
190
|
+
verdict: { matchingOnline: 3, shortfall: 0, offline: [], hasOffline: false, hasShortfall: false },
|
|
191
|
+
staleRuns: [],
|
|
192
|
+
healthy: true,
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
const text = renderReport(report);
|
|
197
|
+
assert.match(text, /✅ healthy/);
|
|
198
|
+
assert.match(text, /✅ Fleet healthy/);
|
|
199
|
+
assert.doesNotMatch(text, /### Degraded/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("renderReport surfaces offline / shortfall / stale-run detail lines", () => {
|
|
203
|
+
const report = {
|
|
204
|
+
results: [
|
|
205
|
+
{
|
|
206
|
+
name: "domio",
|
|
207
|
+
repo: "dsj1984/domio",
|
|
208
|
+
verdict: {
|
|
209
|
+
matchingOnline: 1,
|
|
210
|
+
shortfall: 2,
|
|
211
|
+
offline: [{ id: 2, name: "domio-runner-2", status: "offline" }],
|
|
212
|
+
hasOffline: true,
|
|
213
|
+
hasShortfall: true,
|
|
214
|
+
},
|
|
215
|
+
staleRuns: [{ id: 42, html_url: "https://github.com/dsj1984/domio/actions/runs/42", created_at: "x" }],
|
|
216
|
+
healthy: false,
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
};
|
|
220
|
+
const text = renderReport(report);
|
|
221
|
+
assert.match(text, /❌ degraded/);
|
|
222
|
+
assert.match(text, /OFFLINE runner\(s\)/);
|
|
223
|
+
assert.match(text, /SHORTFALL/);
|
|
224
|
+
assert.match(text, /STALE QUEUED RUN/);
|
|
225
|
+
assert.match(text, /### Degraded/);
|
|
226
|
+
assert.match(text, /templates\/runbooks\/runner-fleet-health\.md/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("renderReport surfaces a fetch error row", () => {
|
|
230
|
+
const report = { results: [{ name: "domio", repo: "dsj1984/domio", error: "boom" }] };
|
|
231
|
+
const text = renderReport(report);
|
|
232
|
+
assert.match(text, /⚠️ error/);
|
|
233
|
+
assert.match(text, /error — boom/);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// fetchRunners / fetchQueuedRuns (injectable runGh)
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
test("fetchRunners returns the runners array from the API response", () => {
|
|
241
|
+
const runGh = () =>
|
|
242
|
+
JSON.stringify({ total_count: 1, runners: [{ id: 1, name: "r1", status: "online", labels: [] }] });
|
|
243
|
+
const runners = fetchRunners("dsj1984/domio", runGh);
|
|
244
|
+
assert.equal(runners.length, 1);
|
|
245
|
+
assert.equal(runners[0].name, "r1");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("fetchRunners returns [] on a 404", () => {
|
|
249
|
+
const runGh = () => {
|
|
250
|
+
const err = new Error("gh: Not Found (HTTP 404)");
|
|
251
|
+
throw err;
|
|
252
|
+
};
|
|
253
|
+
assert.deepEqual(fetchRunners("dsj1984/domio", runGh), []);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("fetchRunners propagates a non-404 error", () => {
|
|
257
|
+
const runGh = () => {
|
|
258
|
+
throw new Error("gh: Forbidden (HTTP 403)");
|
|
259
|
+
};
|
|
260
|
+
assert.throws(() => fetchRunners("dsj1984/domio", runGh));
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("fetchQueuedRuns merges queued and waiting runs", () => {
|
|
264
|
+
const runGh = (args) => {
|
|
265
|
+
const path = args[1];
|
|
266
|
+
if (path.includes("status=queued")) {
|
|
267
|
+
return JSON.stringify({ workflow_runs: [{ id: 1, status: "queued", created_at: "x" }] });
|
|
268
|
+
}
|
|
269
|
+
if (path.includes("status=waiting")) {
|
|
270
|
+
return JSON.stringify({ workflow_runs: [{ id: 2, status: "waiting", created_at: "y" }] });
|
|
271
|
+
}
|
|
272
|
+
throw new Error(`unexpected path ${path}`);
|
|
273
|
+
};
|
|
274
|
+
const runs = fetchQueuedRuns("dsj1984/domio", runGh);
|
|
275
|
+
assert.equal(runs.length, 2);
|
|
276
|
+
assert.deepEqual(runs.map((r) => r.id).sort(), [1, 2]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("fetchQueuedRuns returns [] on a 404", () => {
|
|
280
|
+
const runGh = () => {
|
|
281
|
+
throw new Error("gh: Not Found (HTTP 404)");
|
|
282
|
+
};
|
|
283
|
+
assert.deepEqual(fetchQueuedRuns("dsj1984/domio", runGh), []);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// buildReport / hasUnhealthy
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
const CONFIG = {
|
|
291
|
+
defaultStaleQueuedMinutes: 20,
|
|
292
|
+
repos: [
|
|
293
|
+
{ name: "domio", repo: "dsj1984/domio", expectedCount: 2, labels: ["self-hosted", "domio-runner"] },
|
|
294
|
+
],
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
test("buildReport marks a repo healthy when runners are all online and matching", () => {
|
|
298
|
+
const runGh = (args) => {
|
|
299
|
+
const path = args[1];
|
|
300
|
+
if (path.includes("actions/runners")) {
|
|
301
|
+
return JSON.stringify({
|
|
302
|
+
runners: [
|
|
303
|
+
{ id: 1, name: "r1", status: "online", labels: [{ name: "self-hosted" }, { name: "domio-runner" }] },
|
|
304
|
+
{ id: 2, name: "r2", status: "online", labels: [{ name: "self-hosted" }, { name: "domio-runner" }] },
|
|
305
|
+
],
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
if (path.includes("actions/runs")) {
|
|
309
|
+
return JSON.stringify({ workflow_runs: [] });
|
|
310
|
+
}
|
|
311
|
+
throw new Error(`unexpected ${path}`);
|
|
312
|
+
};
|
|
313
|
+
const report = buildReport(CONFIG, runGh, NOW);
|
|
314
|
+
assert.equal(report.results[0].healthy, true);
|
|
315
|
+
assert.equal(hasUnhealthy(report), false);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("buildReport marks a repo unhealthy and records a fetch error as unhealthy", () => {
|
|
319
|
+
const runGh = (args) => {
|
|
320
|
+
const path = args[1];
|
|
321
|
+
if (path.includes("actions/runners")) throw new Error("gh: Service Unavailable (HTTP 503)");
|
|
322
|
+
return "{}";
|
|
323
|
+
};
|
|
324
|
+
const report = buildReport(CONFIG, runGh, NOW);
|
|
325
|
+
assert.equal(report.results[0].error !== undefined, true);
|
|
326
|
+
assert.equal(hasUnhealthy(report), true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// runCli (end-to-end against a temp config + injected runGh)
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
test("runCli exits 0 for a healthy fleet and 1 for a degraded one", () => {
|
|
334
|
+
const dir = mkdtempSync(join(tmpdir(), "runner-health-cli-"));
|
|
335
|
+
try {
|
|
336
|
+
const configPath = join(dir, "runner-fleet-consumers.json");
|
|
337
|
+
writeFileSync(
|
|
338
|
+
configPath,
|
|
339
|
+
JSON.stringify({
|
|
340
|
+
defaultStaleQueuedMinutes: 20,
|
|
341
|
+
repos: [
|
|
342
|
+
{ name: "domio", repo: "dsj1984/domio", expectedCount: 2, labels: ["self-hosted", "domio-runner"] },
|
|
343
|
+
],
|
|
344
|
+
}),
|
|
345
|
+
);
|
|
346
|
+
const stdout = { buf: "", write(s) { this.buf += s; } };
|
|
347
|
+
const stderr = { buf: "", write(s) { this.buf += s; } };
|
|
348
|
+
const healthyRunGh = (args) => {
|
|
349
|
+
const path = args[1];
|
|
350
|
+
if (path.includes("actions/runners")) {
|
|
351
|
+
return JSON.stringify({
|
|
352
|
+
runners: [
|
|
353
|
+
{ id: 1, name: "r1", status: "online", labels: [{ name: "self-hosted" }, { name: "domio-runner" }] },
|
|
354
|
+
{ id: 2, name: "r2", status: "online", labels: [{ name: "self-hosted" }, { name: "domio-runner" }] },
|
|
355
|
+
],
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
if (path.includes("actions/runs")) return JSON.stringify({ workflow_runs: [] });
|
|
359
|
+
throw new Error(`unexpected ${path}`);
|
|
360
|
+
};
|
|
361
|
+
const code = runCli({
|
|
362
|
+
argv: ["--config", configPath],
|
|
363
|
+
cwd: process.cwd(),
|
|
364
|
+
stdout,
|
|
365
|
+
stderr,
|
|
366
|
+
runGh: healthyRunGh,
|
|
367
|
+
summaryPath: undefined,
|
|
368
|
+
nowMs: NOW,
|
|
369
|
+
});
|
|
370
|
+
assert.equal(code, 0);
|
|
371
|
+
assert.match(stdout.buf, /Fleet healthy|Runner-fleet health dashboard/);
|
|
372
|
+
} finally {
|
|
373
|
+
rmSync(dir, { recursive: true, force: true });
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("runCli returns 1 on a bad config path", () => {
|
|
378
|
+
const stdout = { buf: "", write(s) { this.buf += s; } };
|
|
379
|
+
const stderr = { buf: "", write(s) { this.buf += s; } };
|
|
380
|
+
const code = runCli({
|
|
381
|
+
argv: ["--config", "scripts/does-not-exist.json"],
|
|
382
|
+
cwd: process.cwd(),
|
|
383
|
+
stdout,
|
|
384
|
+
stderr,
|
|
385
|
+
runGh: () => "{}",
|
|
386
|
+
});
|
|
387
|
+
assert.equal(code, 1);
|
|
388
|
+
assert.match(stderr.buf, /failed to read config/);
|
|
389
|
+
});
|