@checkstack/healthcheck-script-backend 0.3.17 → 0.4.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/CHANGELOG.md +116 -0
- package/package.json +2 -2
- package/src/concurrency.test.ts +147 -0
- package/src/execute-collector.test.ts +47 -9
- package/src/execute-collector.ts +80 -16
- package/src/inline-script-collector.test.ts +165 -58
- package/src/inline-script-collector.ts +152 -229
- package/src/security.test.ts +63 -7
- package/src/strategy.test.ts +7 -14
- package/src/strategy.ts +17 -72
- package/src/transport-client.ts +10 -3
|
@@ -2,7 +2,9 @@ import {
|
|
|
2
2
|
Versioned,
|
|
3
3
|
z,
|
|
4
4
|
configString,
|
|
5
|
-
|
|
5
|
+
requestTimeoutMs,
|
|
6
|
+
defaultEsmScriptRunner,
|
|
7
|
+
type EsmScriptRunResult,
|
|
6
8
|
type HealthCheckRunForAggregation,
|
|
7
9
|
type CollectorResult,
|
|
8
10
|
type CollectorStrategy,
|
|
@@ -24,166 +26,57 @@ import type { ScriptTransportClient } from "./transport-client";
|
|
|
24
26
|
import { extractErrorMessage } from "@checkstack/common";
|
|
25
27
|
|
|
26
28
|
// ============================================================================
|
|
27
|
-
//
|
|
29
|
+
// EXECUTOR ADAPTER
|
|
28
30
|
// ============================================================================
|
|
31
|
+
//
|
|
32
|
+
// The collector keeps its own injectable `InlineScriptExecutor`
|
|
33
|
+
// interface for backwards-compatible test mocks. In production it
|
|
34
|
+
// wraps the shared `EsmScriptRunner` from `@checkstack/backend-api` —
|
|
35
|
+
// the canonical subprocess sandbox shared with the integration-script
|
|
36
|
+
// provider. See `core/backend-api/src/esm-script-runner.ts` for the
|
|
37
|
+
// isolation + cleanup model.
|
|
29
38
|
|
|
30
39
|
/**
|
|
31
|
-
*
|
|
40
|
+
* Shape returned by an inline-script executor. Mirrors
|
|
41
|
+
* `EsmScriptRunResult` from the shared runner, kept as a separate
|
|
42
|
+
* interface so the test surface of this collector doesn't have to
|
|
43
|
+
* import from backend-api just to construct a mock.
|
|
32
44
|
*/
|
|
33
|
-
interface
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Safe console interface for scripts.
|
|
42
|
-
*/
|
|
43
|
-
interface SafeConsole {
|
|
44
|
-
log: (...args: unknown[]) => void;
|
|
45
|
-
warn: (...args: unknown[]) => void;
|
|
46
|
-
error: (...args: unknown[]) => void;
|
|
47
|
-
info: (...args: unknown[]) => void;
|
|
45
|
+
export interface InlineScriptExecutionResult {
|
|
46
|
+
result?: unknown;
|
|
47
|
+
error?: string;
|
|
48
|
+
stack?: string;
|
|
49
|
+
stdout: string;
|
|
50
|
+
stderr: string;
|
|
51
|
+
timedOut: boolean;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
/** Optional message describing the result */
|
|
57
|
-
message?: string;
|
|
58
|
-
/** Optional numeric value for metrics */
|
|
59
|
-
value?: number;
|
|
54
|
+
export interface InlineScriptExecutor {
|
|
55
|
+
execute(input: {
|
|
56
|
+
script: string;
|
|
57
|
+
config: Record<string, unknown>;
|
|
58
|
+
timeoutMs: number;
|
|
59
|
+
}): Promise<InlineScriptExecutionResult>;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
*
|
|
63
|
+
* Default executor — delegates to the shared `EsmScriptRunner`. Wires
|
|
64
|
+
* `globalThis.context = { config }` (the inline-health-check runtime
|
|
65
|
+
* surface) and the virtual `@checkstack/healthcheck` module / global
|
|
66
|
+
* `defineHealthCheck` helper.
|
|
64
67
|
*/
|
|
65
|
-
|
|
66
|
-
script,
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
error?: string;
|
|
78
|
-
timedOut: boolean;
|
|
79
|
-
}> {
|
|
80
|
-
try {
|
|
81
|
-
// SECURITY: Execute in isolated Worker to prevent access to process, require, Bun, etc.
|
|
82
|
-
const workerCode = `
|
|
83
|
-
self.onmessage = async (event) => {
|
|
84
|
-
const { script, config } = event.data;
|
|
85
|
-
const logs = [];
|
|
86
|
-
const safeConsole = {
|
|
87
|
-
log: (...args) => logs.push(args.map(a => typeof a === "object" ? JSON.stringify(a) : String(a)).join(" ")),
|
|
88
|
-
warn: (...args) => logs.push("[WARN] " + args.map(a => typeof a === "object" ? JSON.stringify(a) : String(a)).join(" ")),
|
|
89
|
-
error: (...args) => logs.push("[ERROR] " + args.map(a => typeof a === "object" ? JSON.stringify(a) : String(a)).join(" ")),
|
|
90
|
-
info: (...args) => logs.push("[INFO] " + args.map(a => typeof a === "object" ? JSON.stringify(a) : String(a)).join(" ")),
|
|
91
|
-
};
|
|
92
|
-
const context = { config, fetch: globalThis.fetch };
|
|
93
|
-
try {
|
|
94
|
-
const fn = new Function("context", "console", "fetch",
|
|
95
|
-
"return (async () => { " + script + " })();"
|
|
96
|
-
);
|
|
97
|
-
const result = await fn(context, safeConsole, globalThis.fetch);
|
|
98
|
-
self.postMessage({ result, logs });
|
|
99
|
-
} catch (error) {
|
|
100
|
-
self.postMessage({ error: error.message, logs });
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
`;
|
|
104
|
-
const blob = new Blob([workerCode], { type: "application/javascript" });
|
|
105
|
-
const workerUrl = URL.createObjectURL(blob);
|
|
106
|
-
const worker = new Worker(workerUrl);
|
|
107
|
-
|
|
108
|
-
const workerResult = await Promise.race([
|
|
109
|
-
new Promise<{ result?: unknown; error?: string; logs: string[] }>(
|
|
110
|
-
(resolve) => {
|
|
111
|
-
worker.addEventListener("message", (event: MessageEvent) =>
|
|
112
|
-
resolve(event.data),
|
|
113
|
-
);
|
|
114
|
-
worker.postMessage({ script, config: context.config });
|
|
115
|
-
},
|
|
116
|
-
),
|
|
117
|
-
new Promise<never>((_, reject) =>
|
|
118
|
-
setTimeout(() => reject(new Error("__TIMEOUT__")), timeoutMs),
|
|
119
|
-
),
|
|
120
|
-
]);
|
|
121
|
-
|
|
122
|
-
worker.terminate();
|
|
123
|
-
URL.revokeObjectURL(workerUrl);
|
|
124
|
-
|
|
125
|
-
// Replay logs into the outer safeConsole
|
|
126
|
-
if (workerResult.logs && Array.isArray(workerResult.logs)) {
|
|
127
|
-
for (const logLine of workerResult.logs) {
|
|
128
|
-
if (logLine.startsWith("[WARN] ")) {
|
|
129
|
-
safeConsole.warn(logLine.slice(7));
|
|
130
|
-
} else if (logLine.startsWith("[ERROR] ")) {
|
|
131
|
-
safeConsole.error(logLine.slice(8));
|
|
132
|
-
} else if (logLine.startsWith("[INFO] ")) {
|
|
133
|
-
safeConsole.info(logLine.slice(7));
|
|
134
|
-
} else {
|
|
135
|
-
safeConsole.log(logLine);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (workerResult.error) {
|
|
141
|
-
throw new Error(workerResult.error);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const { result } = workerResult;
|
|
145
|
-
|
|
146
|
-
// Normalize result
|
|
147
|
-
if (result === undefined || result === null) {
|
|
148
|
-
return { result: { success: true }, timedOut: false };
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (typeof result === "boolean") {
|
|
152
|
-
return { result: { success: result }, timedOut: false };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (typeof result === "object") {
|
|
156
|
-
const resultObj = result as Record<string, unknown>;
|
|
157
|
-
return {
|
|
158
|
-
result: {
|
|
159
|
-
success: Boolean(resultObj.success ?? true),
|
|
160
|
-
message:
|
|
161
|
-
typeof resultObj.message === "string"
|
|
162
|
-
? resultObj.message
|
|
163
|
-
: undefined,
|
|
164
|
-
value:
|
|
165
|
-
typeof resultObj.value === "number" ? resultObj.value : undefined,
|
|
166
|
-
},
|
|
167
|
-
timedOut: false,
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
return {
|
|
172
|
-
result: { success: true, message: String(result) },
|
|
173
|
-
timedOut: false,
|
|
174
|
-
};
|
|
175
|
-
} catch (error) {
|
|
176
|
-
const message = extractErrorMessage(error);
|
|
177
|
-
if (message === "__TIMEOUT__") {
|
|
178
|
-
return {
|
|
179
|
-
result: undefined,
|
|
180
|
-
error: "Script execution timed out",
|
|
181
|
-
timedOut: true,
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
return { result: undefined, error: message, timedOut: false };
|
|
185
|
-
}
|
|
186
|
-
}
|
|
68
|
+
const defaultInlineScriptExecutor: InlineScriptExecutor = {
|
|
69
|
+
async execute({ script, config, timeoutMs }) {
|
|
70
|
+
const res: EsmScriptRunResult = await defaultEsmScriptRunner.run({
|
|
71
|
+
script,
|
|
72
|
+
context: { config },
|
|
73
|
+
timeoutMs,
|
|
74
|
+
helperModuleName: "@checkstack/healthcheck",
|
|
75
|
+
helperFunctionName: "defineHealthCheck",
|
|
76
|
+
});
|
|
77
|
+
return res;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
187
80
|
|
|
188
81
|
// ============================================================================
|
|
189
82
|
// CONFIGURATION SCHEMA
|
|
@@ -193,13 +86,9 @@ const inlineScriptConfigSchema = z.object({
|
|
|
193
86
|
script: configString({
|
|
194
87
|
"x-editor-types": ["typescript"],
|
|
195
88
|
}).describe(
|
|
196
|
-
"TypeScript/JavaScript
|
|
89
|
+
"TypeScript/JavaScript module. Use `import { ... } from \"node:os\"` to pull in Node built-ins. The recommended pattern is `export default defineHealthCheck({ success, message?, value? })` — `defineHealthCheck` is provided by `@checkstack/healthcheck` and asserts the return shape at the type level. Throwing also signals failure.",
|
|
197
90
|
),
|
|
198
|
-
timeout:
|
|
199
|
-
.min(1000)
|
|
200
|
-
.max(60_000)
|
|
201
|
-
.default(10_000)
|
|
202
|
-
.describe("Maximum execution time in milliseconds"),
|
|
91
|
+
timeout: requestTimeoutMs().describe("Maximum execution time in milliseconds"),
|
|
203
92
|
});
|
|
204
93
|
|
|
205
94
|
export type InlineScriptConfig = z.infer<typeof inlineScriptConfigSchema>;
|
|
@@ -270,37 +159,71 @@ export type InlineScriptAggregatedResult = InferAggregatedResult<
|
|
|
270
159
|
typeof inlineScriptAggregatedFields
|
|
271
160
|
>;
|
|
272
161
|
|
|
162
|
+
// ============================================================================
|
|
163
|
+
// RESULT NORMALISATION
|
|
164
|
+
// ============================================================================
|
|
165
|
+
|
|
166
|
+
interface NormalisedScriptResult {
|
|
167
|
+
success: boolean;
|
|
168
|
+
message?: string;
|
|
169
|
+
value?: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function normaliseScriptReturn(raw: unknown): NormalisedScriptResult {
|
|
173
|
+
if (raw === undefined || raw === null) {
|
|
174
|
+
return { success: true };
|
|
175
|
+
}
|
|
176
|
+
if (typeof raw === "boolean") {
|
|
177
|
+
return { success: raw };
|
|
178
|
+
}
|
|
179
|
+
if (typeof raw === "number") {
|
|
180
|
+
return { success: true, value: raw };
|
|
181
|
+
}
|
|
182
|
+
if (typeof raw === "object") {
|
|
183
|
+
const obj = raw as Record<string, unknown>;
|
|
184
|
+
return {
|
|
185
|
+
success: typeof obj.success === "boolean" ? obj.success : true,
|
|
186
|
+
message: typeof obj.message === "string" ? obj.message : undefined,
|
|
187
|
+
value: typeof obj.value === "number" ? obj.value : undefined,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return { success: true, message: String(raw) };
|
|
191
|
+
}
|
|
192
|
+
|
|
273
193
|
// ============================================================================
|
|
274
194
|
// INLINE SCRIPT COLLECTOR
|
|
275
195
|
// ============================================================================
|
|
276
196
|
|
|
277
197
|
/**
|
|
278
198
|
* Inline Script collector for health checks.
|
|
279
|
-
* Executes TypeScript/JavaScript code directly and checks the result.
|
|
280
|
-
*
|
|
281
|
-
* Scripts should return an object with:
|
|
282
|
-
* - success: boolean - Whether the check passed
|
|
283
|
-
* - message?: string - Optional status message
|
|
284
|
-
* - value?: number - Optional numeric value for metrics
|
|
285
199
|
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
200
|
+
* The user writes a TypeScript/JavaScript ES module. It can `import` from
|
|
201
|
+
* Node built-ins (`node:os`, `node:fs/promises`, `node:child_process`,
|
|
202
|
+
* `node:crypto`, ...), use top-level `await`, and signal its result either
|
|
203
|
+
* via `export default` or — for backwards compatibility — a top-level
|
|
204
|
+
* `return X;`. `globalThis.context` exposes `{ config }` for access to the
|
|
205
|
+
* collector configuration.
|
|
290
206
|
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
207
|
+
* Subprocess isolation, env scrubbing, temp-dir lifecycle and result
|
|
208
|
+
* marshalling all live in the shared `EsmScriptRunner` in
|
|
209
|
+
* `@checkstack/backend-api`. This collector is just the schema +
|
|
210
|
+
* result-shape glue.
|
|
295
211
|
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
212
|
+
* @example Modern (ES module)
|
|
213
|
+
* ```ts
|
|
214
|
+
* import { loadavg } from "node:os";
|
|
215
|
+
* const load = loadavg()[0];
|
|
216
|
+
* export default {
|
|
217
|
+
* success: load < 0.6,
|
|
218
|
+
* message: `Load: ${load.toFixed(2)}`,
|
|
219
|
+
* value: load,
|
|
302
220
|
* };
|
|
303
221
|
* ```
|
|
222
|
+
*
|
|
223
|
+
* @example Legacy (still works)
|
|
224
|
+
* ```ts
|
|
225
|
+
* return { success: true, message: "All good!" };
|
|
226
|
+
* ```
|
|
304
227
|
*/
|
|
305
228
|
export class InlineScriptCollector implements CollectorStrategy<
|
|
306
229
|
ScriptTransportClient,
|
|
@@ -323,6 +246,12 @@ export class InlineScriptCollector implements CollectorStrategy<
|
|
|
323
246
|
fields: inlineScriptAggregatedFields,
|
|
324
247
|
});
|
|
325
248
|
|
|
249
|
+
private executor: InlineScriptExecutor;
|
|
250
|
+
|
|
251
|
+
constructor(executor: InlineScriptExecutor = defaultInlineScriptExecutor) {
|
|
252
|
+
this.executor = executor;
|
|
253
|
+
}
|
|
254
|
+
|
|
326
255
|
async execute({
|
|
327
256
|
config,
|
|
328
257
|
}: {
|
|
@@ -332,74 +261,68 @@ export class InlineScriptCollector implements CollectorStrategy<
|
|
|
332
261
|
}): Promise<CollectorResult<InlineScriptResult>> {
|
|
333
262
|
const startTime = Date.now();
|
|
334
263
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
},
|
|
356
|
-
error: (...args) => {
|
|
357
|
-
logs.push(
|
|
358
|
-
`[ERROR] ${args.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ")}`,
|
|
359
|
-
);
|
|
360
|
-
},
|
|
361
|
-
info: (...args) => {
|
|
362
|
-
logs.push(
|
|
363
|
-
`[INFO] ${args.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ")}`,
|
|
364
|
-
);
|
|
365
|
-
},
|
|
366
|
-
};
|
|
367
|
-
|
|
368
|
-
// Execute the script
|
|
369
|
-
const { result, error, timedOut } = await executeInlineScript({
|
|
370
|
-
script: config.script,
|
|
371
|
-
context: scriptContext,
|
|
372
|
-
safeConsole,
|
|
373
|
-
timeoutMs: config.timeout,
|
|
374
|
-
});
|
|
264
|
+
let exec: InlineScriptExecutionResult;
|
|
265
|
+
try {
|
|
266
|
+
exec = await this.executor.execute({
|
|
267
|
+
script: config.script,
|
|
268
|
+
config: config as unknown as Record<string, unknown>,
|
|
269
|
+
timeoutMs: config.timeout,
|
|
270
|
+
});
|
|
271
|
+
} catch (error) {
|
|
272
|
+
const executionTimeMs = Date.now() - startTime;
|
|
273
|
+
const message = extractErrorMessage(error);
|
|
274
|
+
return {
|
|
275
|
+
result: {
|
|
276
|
+
success: false,
|
|
277
|
+
message,
|
|
278
|
+
executionTimeMs,
|
|
279
|
+
timedOut: false,
|
|
280
|
+
},
|
|
281
|
+
error: message,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
375
284
|
|
|
376
285
|
const executionTimeMs = Date.now() - startTime;
|
|
377
286
|
|
|
378
|
-
if (
|
|
287
|
+
if (exec.timedOut) {
|
|
379
288
|
return {
|
|
380
289
|
result: {
|
|
381
290
|
success: false,
|
|
382
|
-
message: error,
|
|
291
|
+
message: exec.error ?? "Script execution timed out",
|
|
383
292
|
executionTimeMs,
|
|
384
|
-
timedOut,
|
|
293
|
+
timedOut: true,
|
|
385
294
|
},
|
|
386
|
-
error,
|
|
295
|
+
error: exec.error ?? "Script execution timed out",
|
|
387
296
|
};
|
|
388
297
|
}
|
|
389
298
|
|
|
299
|
+
if (exec.error !== undefined) {
|
|
300
|
+
return {
|
|
301
|
+
result: {
|
|
302
|
+
success: false,
|
|
303
|
+
message: exec.error,
|
|
304
|
+
executionTimeMs,
|
|
305
|
+
timedOut: false,
|
|
306
|
+
},
|
|
307
|
+
error: exec.error,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const normalised = normaliseScriptReturn(exec.result);
|
|
312
|
+
const fallbackMessage =
|
|
313
|
+
exec.stdout.length > 0 ? exec.stdout : undefined;
|
|
314
|
+
|
|
390
315
|
return {
|
|
391
316
|
result: {
|
|
392
|
-
success:
|
|
393
|
-
message:
|
|
394
|
-
|
|
395
|
-
value: result?.value,
|
|
317
|
+
success: normalised.success,
|
|
318
|
+
message: normalised.message ?? fallbackMessage,
|
|
319
|
+
value: normalised.value,
|
|
396
320
|
executionTimeMs,
|
|
397
321
|
timedOut: false,
|
|
398
322
|
},
|
|
399
|
-
error:
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
: undefined,
|
|
323
|
+
error: normalised.success
|
|
324
|
+
? undefined
|
|
325
|
+
: (normalised.message ?? "Check failed"),
|
|
403
326
|
};
|
|
404
327
|
}
|
|
405
328
|
|
package/src/security.test.ts
CHANGED
|
@@ -3,28 +3,84 @@ import { ScriptHealthCheckStrategy } from "./strategy";
|
|
|
3
3
|
|
|
4
4
|
describe("ScriptHealthCheckStrategy Security", () => {
|
|
5
5
|
it("should not leak sensitive environment variables to child process", async () => {
|
|
6
|
-
// Set a secret in the current process
|
|
7
6
|
process.env.TEST_SECRET_KEY = "SUPER_SECRET_KEY_DO_NOT_LEAK";
|
|
8
7
|
|
|
9
|
-
// Use the default strategy which uses the real Bun.spawn
|
|
10
8
|
const strategy = new ScriptHealthCheckStrategy();
|
|
11
9
|
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
12
10
|
|
|
13
11
|
const result = await connectedClient.client.exec({
|
|
14
|
-
|
|
15
|
-
args: [],
|
|
12
|
+
script: "env",
|
|
16
13
|
timeout: 5000,
|
|
17
14
|
});
|
|
18
15
|
|
|
19
16
|
connectedClient.close();
|
|
20
17
|
|
|
21
|
-
// Cleanup before assertions to be safe
|
|
22
18
|
delete process.env.TEST_SECRET_KEY;
|
|
23
19
|
|
|
24
20
|
expect(result.exitCode).toBe(0);
|
|
25
21
|
expect(result.stdout).not.toContain("SUPER_SECRET_KEY_DO_NOT_LEAK");
|
|
26
|
-
//
|
|
27
|
-
// PATH is usually present, but let's check for something else generic or just ensure output is not empty
|
|
22
|
+
// Sanity: at least PATH/HOME should still be forwarded from the whitelist.
|
|
28
23
|
expect(result.stdout.length).toBeGreaterThan(0);
|
|
29
24
|
});
|
|
25
|
+
|
|
26
|
+
it("runs a real shell script with pipes, conditionals, and awk", async () => {
|
|
27
|
+
// This was the original regression that motivated switching to `sh -c`:
|
|
28
|
+
// previously the entire shell expression was passed to spawn() as the
|
|
29
|
+
// literal argv[0] and failed with ENOENT. With `sh -c`, the shell parses
|
|
30
|
+
// the whole thing — pipes and awk included.
|
|
31
|
+
const strategy = new ScriptHealthCheckStrategy();
|
|
32
|
+
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
33
|
+
|
|
34
|
+
const result = await connectedClient.client.exec({
|
|
35
|
+
script:
|
|
36
|
+
"printf '0.42 0.50 0.60\\n' | awk '{ print $1 }' | awk -v t=0.60 '{ if ($1+0 > t) exit 1; else print \"load_ok=\"$1 }'",
|
|
37
|
+
timeout: 5000,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
connectedClient.close();
|
|
41
|
+
|
|
42
|
+
expect(result.exitCode).toBe(0);
|
|
43
|
+
expect(result.stdout).toContain("load_ok=0.42");
|
|
44
|
+
expect(result.timedOut).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("the starter template's load-average extraction runs on the current OS", async () => {
|
|
48
|
+
// Regression guard: the previous starter used `awk '{print $1}'
|
|
49
|
+
// /proc/loadavg`, which only exists on Linux. macOS satellites
|
|
50
|
+
// failed with "awk: can't open file /proc/loadavg". The current
|
|
51
|
+
// starter uses `uptime` instead so it runs everywhere we deploy.
|
|
52
|
+
//
|
|
53
|
+
// This test executes the same pipeline the starter uses and just
|
|
54
|
+
// asserts a numeric float comes out. Don't compare to a specific
|
|
55
|
+
// value — load on the test runner is whatever it is.
|
|
56
|
+
const strategy = new ScriptHealthCheckStrategy();
|
|
57
|
+
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
58
|
+
|
|
59
|
+
const result = await connectedClient.client.exec({
|
|
60
|
+
script:
|
|
61
|
+
"uptime | sed 's/.*load average[s]*: //' | awk '{ print $1 }' | tr -d ','",
|
|
62
|
+
timeout: 5000,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
connectedClient.close();
|
|
66
|
+
|
|
67
|
+
expect(result.exitCode).toBe(0);
|
|
68
|
+
expect(result.stdout).toMatch(/^\d+(\.\d+)?$/);
|
|
69
|
+
expect(result.timedOut).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("propagates non-zero exit codes from the user's script", async () => {
|
|
73
|
+
const strategy = new ScriptHealthCheckStrategy();
|
|
74
|
+
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
75
|
+
|
|
76
|
+
const result = await connectedClient.client.exec({
|
|
77
|
+
script: "echo 'load too high' && exit 1",
|
|
78
|
+
timeout: 5000,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
connectedClient.close();
|
|
82
|
+
|
|
83
|
+
expect(result.exitCode).toBe(1);
|
|
84
|
+
expect(result.stdout).toContain("load too high");
|
|
85
|
+
});
|
|
30
86
|
});
|
package/src/strategy.test.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { describe, expect, it, mock } from "bun:test";
|
|
|
2
2
|
import { ScriptHealthCheckStrategy, ScriptExecutor } from "./strategy";
|
|
3
3
|
|
|
4
4
|
describe("ScriptHealthCheckStrategy", () => {
|
|
5
|
-
// Helper to create mock Script executor
|
|
6
5
|
const createMockExecutor = (
|
|
7
6
|
config: {
|
|
8
7
|
exitCode?: number;
|
|
@@ -50,8 +49,7 @@ describe("ScriptHealthCheckStrategy", () => {
|
|
|
50
49
|
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
51
50
|
|
|
52
51
|
const result = await connectedClient.client.exec({
|
|
53
|
-
|
|
54
|
-
args: [],
|
|
52
|
+
script: "true",
|
|
55
53
|
timeout: 5000,
|
|
56
54
|
});
|
|
57
55
|
|
|
@@ -68,8 +66,7 @@ describe("ScriptHealthCheckStrategy", () => {
|
|
|
68
66
|
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
69
67
|
|
|
70
68
|
const result = await connectedClient.client.exec({
|
|
71
|
-
|
|
72
|
-
args: [],
|
|
69
|
+
script: "false",
|
|
73
70
|
timeout: 5000,
|
|
74
71
|
});
|
|
75
72
|
|
|
@@ -85,8 +82,7 @@ describe("ScriptHealthCheckStrategy", () => {
|
|
|
85
82
|
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
86
83
|
|
|
87
84
|
const result = await connectedClient.client.exec({
|
|
88
|
-
|
|
89
|
-
args: ["60"],
|
|
85
|
+
script: "sleep 60",
|
|
90
86
|
timeout: 1000,
|
|
91
87
|
});
|
|
92
88
|
|
|
@@ -102,8 +98,7 @@ describe("ScriptHealthCheckStrategy", () => {
|
|
|
102
98
|
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
103
99
|
|
|
104
100
|
const result = await connectedClient.client.exec({
|
|
105
|
-
|
|
106
|
-
args: [],
|
|
101
|
+
script: "no-such-binary-anywhere",
|
|
107
102
|
timeout: 5000,
|
|
108
103
|
});
|
|
109
104
|
|
|
@@ -112,14 +107,13 @@ describe("ScriptHealthCheckStrategy", () => {
|
|
|
112
107
|
connectedClient.close();
|
|
113
108
|
});
|
|
114
109
|
|
|
115
|
-
it("should pass
|
|
110
|
+
it("should pass script, cwd and env through to executor", async () => {
|
|
116
111
|
const mockExecutor = createMockExecutor({ exitCode: 0 });
|
|
117
112
|
const strategy = new ScriptHealthCheckStrategy(mockExecutor);
|
|
118
113
|
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
119
114
|
|
|
120
115
|
await connectedClient.client.exec({
|
|
121
|
-
|
|
122
|
-
args: ["--verbose", "--env=prod"],
|
|
116
|
+
script: "./check.sh --verbose --env=prod",
|
|
123
117
|
cwd: "/opt/scripts",
|
|
124
118
|
env: { API_KEY: "secret" },
|
|
125
119
|
timeout: 5000,
|
|
@@ -127,8 +121,7 @@ describe("ScriptHealthCheckStrategy", () => {
|
|
|
127
121
|
|
|
128
122
|
expect(mockExecutor.execute).toHaveBeenCalledWith(
|
|
129
123
|
expect.objectContaining({
|
|
130
|
-
|
|
131
|
-
args: ["--verbose", "--env=prod"],
|
|
124
|
+
script: "./check.sh --verbose --env=prod",
|
|
132
125
|
cwd: "/opt/scripts",
|
|
133
126
|
env: { API_KEY: "secret" },
|
|
134
127
|
}),
|