254 lines
6.0 KiB
JavaScript
Executable File
254 lines
6.0 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
import os from "node:os";
|
|
import process from "node:process";
|
|
import { spawn } from "node:child_process";
|
|
|
|
const TIMEOUT_EXIT_CODE = 124;
|
|
const SPAWN_ERROR_EXIT_CODE = 127;
|
|
const INVALID_ARGUMENT_EXIT_CODE = 2;
|
|
const DEFAULT_GRACE_SECONDS = 2;
|
|
const MAX_TIMEOUT_SECONDS = 86_400;
|
|
const MAX_GRACE_SECONDS = 60;
|
|
const STAGE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/u;
|
|
|
|
function failArguments() {
|
|
process.stderr.write(`[docker-smoke] FAIL stage=timeout-runner reason=invalid_arguments exit_code=${INVALID_ARGUMENT_EXIT_CODE}\n`);
|
|
process.exit(INVALID_ARGUMENT_EXIT_CODE);
|
|
}
|
|
|
|
function parseSeconds(value, { allowZero, maximum }) {
|
|
if (typeof value !== "string" || !/^(?:0|[1-9]\d*)(?:\.\d+)?$/u.test(value)) {
|
|
failArguments();
|
|
}
|
|
|
|
const parsed = Number(value);
|
|
const validLowerBound = allowZero ? parsed >= 0 : parsed > 0;
|
|
if (!Number.isFinite(parsed) || !validLowerBound || parsed > maximum) {
|
|
failArguments();
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function parseArguments(argv) {
|
|
let stage;
|
|
let timeoutSeconds;
|
|
let graceSeconds = DEFAULT_GRACE_SECONDS;
|
|
let sawGraceSeconds = false;
|
|
let command;
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const argument = argv[index];
|
|
|
|
if (argument === "--") {
|
|
command = argv.slice(index + 1);
|
|
break;
|
|
}
|
|
|
|
if (argument === "--stage" && stage === undefined) {
|
|
stage = argv[index + 1];
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (argument === "--timeout-seconds" && timeoutSeconds === undefined) {
|
|
timeoutSeconds = argv[index + 1];
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (argument === "--grace-seconds" && !sawGraceSeconds) {
|
|
graceSeconds = argv[index + 1];
|
|
sawGraceSeconds = true;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
failArguments();
|
|
}
|
|
|
|
if (!STAGE_PATTERN.test(stage ?? "") || timeoutSeconds === undefined || !command?.length) {
|
|
failArguments();
|
|
}
|
|
|
|
return {
|
|
stage,
|
|
timeoutSeconds: parseSeconds(timeoutSeconds, { allowZero: false, maximum: MAX_TIMEOUT_SECONDS }),
|
|
graceSeconds: parseSeconds(String(graceSeconds), { allowZero: true, maximum: MAX_GRACE_SECONDS }),
|
|
command,
|
|
};
|
|
}
|
|
|
|
function signalExitCode(signal) {
|
|
const signalNumber = os.constants.signals[signal];
|
|
return typeof signalNumber === "number" ? 128 + signalNumber : 1;
|
|
}
|
|
|
|
const { stage, timeoutSeconds, graceSeconds, command } = parseArguments(process.argv.slice(2));
|
|
const startedAt = performance.now();
|
|
const timeoutMilliseconds = timeoutSeconds * 1_000;
|
|
const graceMilliseconds = graceSeconds * 1_000;
|
|
|
|
process.stderr.write(`[docker-smoke] START stage=${stage} timeout_seconds=${timeoutSeconds} grace_seconds=${graceSeconds}\n`);
|
|
|
|
const child = spawn(command[0], command.slice(1), {
|
|
detached: true,
|
|
env: process.env,
|
|
shell: false,
|
|
stdio: "inherit",
|
|
});
|
|
|
|
let childClosed = false;
|
|
let finished = false;
|
|
let termination;
|
|
let timeoutTimer;
|
|
let forceKillTimer;
|
|
|
|
function durationMilliseconds() {
|
|
return Math.max(0, Math.round(performance.now() - startedAt));
|
|
}
|
|
|
|
function processGroupExists() {
|
|
if (!child.pid) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
process.kill(-child.pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
return error?.code === "EPERM";
|
|
}
|
|
}
|
|
|
|
function signalProcessGroup(signal) {
|
|
if (!child.pid) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
process.kill(-child.pid, signal);
|
|
} catch (error) {
|
|
if (error?.code !== "ESRCH") {
|
|
try {
|
|
child.kill(signal);
|
|
} catch {
|
|
// The child may have exited between the process-group and direct kill.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function clearTimers() {
|
|
if (timeoutTimer) {
|
|
clearTimeout(timeoutTimer);
|
|
}
|
|
if (forceKillTimer) {
|
|
clearTimeout(forceKillTimer);
|
|
}
|
|
}
|
|
|
|
function finish(exitCode, outcome, extraMetadata = "") {
|
|
if (finished) {
|
|
return;
|
|
}
|
|
|
|
finished = true;
|
|
clearTimers();
|
|
process.removeListener("SIGINT", handleInterrupt);
|
|
process.removeListener("SIGTERM", handleTerminate);
|
|
const suffix = extraMetadata ? ` ${extraMetadata}` : "";
|
|
process.stderr.write(`[docker-smoke] ${outcome} stage=${stage} duration_ms=${durationMilliseconds()}${suffix} exit_code=${exitCode}\n`);
|
|
process.exitCode = exitCode;
|
|
}
|
|
|
|
function finishTerminationIfReady() {
|
|
if (!termination || (!childClosed && !termination.forceKillSent)) {
|
|
return;
|
|
}
|
|
|
|
if (processGroupExists() && !termination.forceKillSent) {
|
|
return;
|
|
}
|
|
|
|
if (termination.kind === "timeout") {
|
|
finish(TIMEOUT_EXIT_CODE, "TIMEOUT");
|
|
return;
|
|
}
|
|
|
|
const exitCode = signalExitCode(termination.signal);
|
|
finish(exitCode, "FAIL", `signal=${termination.signal}`);
|
|
}
|
|
|
|
function forceKillTermination() {
|
|
if (!termination || finished) {
|
|
return;
|
|
}
|
|
|
|
termination.forceKillSent = true;
|
|
signalProcessGroup("SIGKILL");
|
|
child.unref();
|
|
finishTerminationIfReady();
|
|
}
|
|
|
|
function beginTermination(nextTermination) {
|
|
if (finished) {
|
|
return;
|
|
}
|
|
|
|
if (termination) {
|
|
forceKillTermination();
|
|
return;
|
|
}
|
|
|
|
termination = { ...nextTermination, forceKillSent: false };
|
|
if (timeoutTimer) {
|
|
clearTimeout(timeoutTimer);
|
|
}
|
|
signalProcessGroup("SIGTERM");
|
|
forceKillTimer = setTimeout(forceKillTermination, graceMilliseconds);
|
|
finishTerminationIfReady();
|
|
}
|
|
|
|
function handleInterrupt() {
|
|
beginTermination({ kind: "signal", signal: "SIGINT" });
|
|
}
|
|
|
|
function handleTerminate() {
|
|
beginTermination({ kind: "signal", signal: "SIGTERM" });
|
|
}
|
|
|
|
process.on("SIGINT", handleInterrupt);
|
|
process.on("SIGTERM", handleTerminate);
|
|
|
|
child.once("error", () => {
|
|
childClosed = true;
|
|
if (termination) {
|
|
finishTerminationIfReady();
|
|
return;
|
|
}
|
|
finish(SPAWN_ERROR_EXIT_CODE, "FAIL", "reason=spawn_error");
|
|
});
|
|
|
|
child.once("close", (code, signal) => {
|
|
childClosed = true;
|
|
if (termination) {
|
|
finishTerminationIfReady();
|
|
return;
|
|
}
|
|
|
|
if (signal) {
|
|
const exitCode = signalExitCode(signal);
|
|
finish(exitCode, "FAIL", `signal=${signal}`);
|
|
return;
|
|
}
|
|
|
|
const exitCode = typeof code === "number" ? code : 1;
|
|
finish(exitCode, exitCode === 0 ? "PASS" : "FAIL");
|
|
});
|
|
|
|
timeoutTimer = setTimeout(() => {
|
|
beginTermination({ kind: "timeout" });
|
|
}, timeoutMilliseconds);
|