Kova/support/run-soak-loop.mjs
2026-05-01 09:15:22 +01:00

277 lines
8.3 KiB
JavaScript

#!/usr/bin/env node
import { spawn } from "node:child_process";
const SCHEMA_VERSION = "kova.soakLoop.v1";
const args = parseArgs(process.argv.slice(2));
const envName = requiredArg(args, "env");
assertKovaEnvName(envName);
const durationMs = positiveInt(args["duration-ms"] ?? 60000, "duration-ms");
const intervalMs = nonNegativeInt(args["interval-ms"] ?? 1000, "interval-ms");
const commandTimeoutMs = positiveInt(args["timeout-ms"] ?? 30000, "timeout-ms");
const startedAtEpochMs = Date.now();
const startedAt = new Date(startedAtEpochMs).toISOString();
const deadline = startedAtEpochMs + durationMs;
const commandRuns = [];
const healthSamples = [];
const errors = [];
const commandPlan = [
{ id: "status", command: "ocm", args: [`@${envName}`, "--", "status"] },
{ id: "plugins-list", command: "ocm", args: [`@${envName}`, "--", "plugins", "list"] },
{ id: "models-list", command: "ocm", args: [`@${envName}`, "--", "models", "list"] }
];
let iteration = 0;
do {
iteration += 1;
for (const planned of commandPlan) {
commandRuns.push(await runTimedCommand(planned, commandTimeoutMs, iteration));
}
healthSamples.push(await collectGatewayHealth(envName, commandTimeoutMs, iteration));
if (Date.now() < deadline && intervalMs > 0) {
await sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
}
} while (Date.now() < deadline);
const finishedAtEpochMs = Date.now();
const result = {
schemaVersion: SCHEMA_VERSION,
env: envName,
startedAt,
finishedAt: new Date(finishedAtEpochMs).toISOString(),
durationMs: finishedAtEpochMs - startedAtEpochMs,
requestedDurationMs: durationMs,
intervalMs,
iterations: iteration,
commandSummary: summarizeCommands(commandRuns),
healthSummary: summarizeHealth(healthSamples),
commandRuns,
healthSamples,
errors
};
console.log(JSON.stringify(result, null, 2));
process.exit(commandRuns.every((run) => run.status === 0 && run.timedOut !== true) ? 0 : 1);
async function collectGatewayHealth(env, timeoutMs, iterationIndex) {
const status = await runProcess("ocm", ["service", "status", env, "--json"], timeoutMs);
const base = {
iteration: iterationIndex,
serviceStatus: status.status,
durationMs: null,
ok: false,
status: null,
error: null
};
if (status.status !== 0) {
return {
...base,
error: firstLine(status.stderr) || firstLine(status.stdout) || "ocm service status failed"
};
}
let service;
try {
service = JSON.parse(status.stdout);
} catch (error) {
return {
...base,
error: `service status JSON parse failed: ${error.message}`
};
}
if (!service.gatewayPort) {
return {
...base,
error: "gateway port missing from service status"
};
}
const started = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), Math.min(timeoutMs, 5000));
try {
const response = await fetch(`http://127.0.0.1:${Number(service.gatewayPort)}/health`, {
signal: controller.signal
});
await response.text();
return {
...base,
gatewayPort: Number(service.gatewayPort),
ok: response.ok,
status: response.status,
durationMs: Date.now() - started
};
} catch (error) {
return {
...base,
gatewayPort: Number(service.gatewayPort),
durationMs: Date.now() - started,
error: error.name === "AbortError" ? "health request timed out" : error.message
};
} finally {
clearTimeout(timer);
}
}
async function runTimedCommand(planned, timeoutMs, iterationIndex) {
const startedAtEpochMs = Date.now();
const result = await runProcess(planned.command, planned.args, timeoutMs);
const finishedAtEpochMs = Date.now();
return {
id: planned.id,
iteration: iterationIndex,
command: [planned.command, ...planned.args].join(" "),
startedAt: new Date(startedAtEpochMs).toISOString(),
finishedAt: new Date(finishedAtEpochMs).toISOString(),
durationMs: finishedAtEpochMs - startedAtEpochMs,
status: result.status,
signal: result.signal,
timedOut: result.timedOut,
stdoutSnippet: result.stdout.slice(-500),
stderrSnippet: result.stderr.slice(-500)
};
}
function runProcess(command, args, timeoutMs) {
return new Promise((resolve) => {
const child = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
env: process.env
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => child.kill("SIGKILL"), 3000).unref();
}, timeoutMs);
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", (error) => {
clearTimeout(timer);
resolve({ status: 127, signal: null, timedOut, stdout, stderr: error.message });
});
child.on("close", (status, signal) => {
clearTimeout(timer);
resolve({ status: timedOut ? 124 : (status ?? 1), signal, timedOut, stdout, stderr });
});
});
}
function summarizeCommands(runs) {
const byId = {};
for (const id of [...new Set(runs.map((run) => run.id))].sort()) {
const matching = runs.filter((run) => run.id === id);
const durations = matching.map((run) => run.durationMs).filter(isNumber).sort((a, b) => a - b);
byId[id] = {
count: matching.length,
okCount: matching.filter((run) => run.status === 0 && run.timedOut !== true).length,
failureCount: matching.filter((run) => run.status !== 0 || run.timedOut === true).length,
p50Ms: percentile(durations, 0.5),
p95Ms: percentile(durations, 0.95),
maxMs: durations.at(-1) ?? null
};
}
const durations = runs.map((run) => run.durationMs).filter(isNumber).sort((a, b) => a - b);
return {
count: runs.length,
okCount: runs.filter((run) => run.status === 0 && run.timedOut !== true).length,
failureCount: runs.filter((run) => run.status !== 0 || run.timedOut === true).length,
p50Ms: percentile(durations, 0.5),
p95Ms: percentile(durations, 0.95),
maxMs: durations.at(-1) ?? null,
byId
};
}
function summarizeHealth(samples) {
const durations = samples.map((sample) => sample.durationMs).filter(isNumber).sort((a, b) => a - b);
return {
count: samples.length,
okCount: samples.filter((sample) => sample.ok === true).length,
failureCount: samples.filter((sample) => sample.ok !== true).length,
p50Ms: percentile(durations, 0.5),
p95Ms: percentile(durations, 0.95),
maxMs: durations.at(-1) ?? null
};
}
function percentile(values, percentileValue) {
if (values.length === 0) {
return null;
}
const index = Math.ceil(values.length * percentileValue) - 1;
return values[Math.min(Math.max(index, 0), values.length - 1)];
}
function parseArgs(values) {
const parsed = {};
for (let index = 0; index < values.length; index += 1) {
const value = values[index];
if (!value.startsWith("--")) {
throw new Error(`unexpected argument ${value}`);
}
const key = value.slice(2);
const next = values[index + 1];
if (!next || next.startsWith("--")) {
parsed[key] = true;
} else {
parsed[key] = next;
index += 1;
}
}
return parsed;
}
function requiredArg(argsObject, name) {
const value = argsObject[name];
if (typeof value !== "string" || value.length === 0) {
throw new Error(`--${name} is required`);
}
return value;
}
function positiveInt(value, name) {
const number = Number(value);
if (!Number.isInteger(number) || number <= 0) {
throw new Error(`--${name} must be a positive integer`);
}
return number;
}
function nonNegativeInt(value, name) {
const number = Number(value);
if (!Number.isInteger(number) || number < 0) {
throw new Error(`--${name} must be a non-negative integer`);
}
return number;
}
function assertKovaEnvName(value) {
if (!/^kova-[a-z0-9][a-z0-9-]*$/i.test(value)) {
throw new Error(`refusing to run soak loop against non-Kova env '${value}'`);
}
}
function isNumber(value) {
return typeof value === "number" && Number.isFinite(value);
}
function firstLine(value) {
return String(value ?? "").trim().split(/\r?\n/).find(Boolean);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}