Capture the details of fatal errors

This commit is contained in:
Henry Mercer 2023-07-20 18:31:37 +01:00
parent 76b2afaa4a
commit 21c926745f
7 changed files with 63 additions and 371 deletions

34
lib/codeql.js generated
View file

@ -31,11 +31,9 @@ const toolrunner = __importStar(require("@actions/exec/lib/toolrunner"));
const yaml = __importStar(require("js-yaml"));
const actions_util_1 = require("./actions-util");
const environment_1 = require("./environment");
const error_matcher_1 = require("./error-matcher");
const feature_flags_1 = require("./feature-flags");
const languages_1 = require("./languages");
const setupCodeql = __importStar(require("./setup-codeql"));
const toolrunner_error_catcher_1 = require("./toolrunner-error-catcher");
const util = __importStar(require("./util"));
const util_1 = require("./util");
class CommandInvocationError extends Error {
@ -318,7 +316,7 @@ async function getCodeQLForCmd(cmd, checkVersion) {
const ext = process.platform === "win32" ? ".cmd" : ".sh";
const traceCommand = path.resolve(await this.resolveExtractor(language), "tools", `autobuild${ext}`);
// Run trace command
await (0, toolrunner_error_catcher_1.toolrunnerErrorCatcher)(cmd, [
await runTool(cmd, [
"database",
"trace-command",
...(await getTrapCachingExtractorConfigArgsForLang(config, language)),
@ -326,7 +324,7 @@ async function getCodeQLForCmd(cmd, checkVersion) {
databasePath,
"--",
traceCommand,
], error_matcher_1.errorMatchers);
]);
},
async finalizeDatabase(databasePath, threadsFlag, memoryFlag) {
const args = [
@ -439,7 +437,7 @@ async function getCodeQLForCmd(cmd, checkVersion) {
if (querySuitePath) {
codeqlArgs.push(querySuitePath);
}
await (0, toolrunner_error_catcher_1.toolrunnerErrorCatcher)(cmd, codeqlArgs, error_matcher_1.errorMatchers);
await runTool(cmd, codeqlArgs);
},
async databaseInterpretResults(databasePath, querySuitePaths, sarifFile, addSnippetsFlag, threadsFlag, verbosityFlag, automationDetailsId, config, features, logger) {
const shouldExportDiagnostics = await features.getValue(feature_flags_1.Feature.ExportDiagnosticsEnabled, this);
@ -486,11 +484,11 @@ async function getCodeQLForCmd(cmd, checkVersion) {
codeqlArgs.push(...querySuitePaths);
}
// capture stdout, which contains analysis summaries
const returnState = await (0, toolrunner_error_catcher_1.toolrunnerErrorCatcher)(cmd, codeqlArgs, error_matcher_1.errorMatchers);
const returnState = await runTool(cmd, codeqlArgs);
if (shouldWorkaroundInvalidNotifications) {
util.fixInvalidNotificationsInFile(codeqlOutputFile, sarifFile, logger);
}
return returnState.stdout;
return returnState;
},
async databasePrintBaseline(databasePath) {
const codeqlArgs = [
@ -735,10 +733,30 @@ async function runTool(cmd, args = [], opts = {}) {
ignoreReturnCode: true,
...(opts.stdin ? { input: Buffer.from(opts.stdin || "") } : {}),
}).exec();
if (exitCode !== 0)
if (exitCode !== 0) {
error = extractFatalErrors(error) || error;
throw new CommandInvocationError(cmd, args, exitCode, error, output);
}
return output;
}
function extractFatalErrors(error) {
const fatalErrors = [];
const fatalErrorRegex = /.*fatal error occurred:/gi;
let lastFatalErrorIndex;
let match;
while ((match = fatalErrorRegex.exec(error)) !== null) {
if (lastFatalErrorIndex !== undefined) {
fatalErrors.push(error.slice(lastFatalErrorIndex, match.index));
}
lastFatalErrorIndex = match.index;
}
if (lastFatalErrorIndex !== undefined) {
const lastError = error.slice(lastFatalErrorIndex);
return (lastError +
(fatalErrors.length > 0 ? `\nContext:\n${fatalErrors.join("\n")}` : ""));
}
return undefined;
}
/**
* If appropriate, generates a code scanning configuration that is to be used for a scan.
* If the configuration is not to be generated, returns undefined.

File diff suppressed because one or more lines are too long

View file

@ -9,7 +9,6 @@ import { getOptionalInput, isAnalyzingDefaultBranch } from "./actions-util";
import * as api from "./api-client";
import type { Config } from "./config-utils";
import { EnvVar } from "./environment";
import { errorMatchers } from "./error-matcher";
import {
CODEQL_VERSION_NEW_ANALYSIS_SUMMARY,
CodeQLDefaultVersionInfo,
@ -20,7 +19,6 @@ import {
import { isTracedLanguage, Language } from "./languages";
import { Logger } from "./logging";
import * as setupCodeql from "./setup-codeql";
import { toolrunnerErrorCatcher } from "./toolrunner-error-catcher";
import * as util from "./util";
import { wrapError } from "./util";
@ -627,19 +625,15 @@ export async function getCodeQLForCmd(
`autobuild${ext}`
);
// Run trace command
await toolrunnerErrorCatcher(
cmd,
[
"database",
"trace-command",
...(await getTrapCachingExtractorConfigArgsForLang(config, language)),
...getExtraOptionsFromEnv(["database", "trace-command"]),
databasePath,
"--",
traceCommand,
],
errorMatchers
);
await runTool(cmd, [
"database",
"trace-command",
...(await getTrapCachingExtractorConfigArgsForLang(config, language)),
...getExtraOptionsFromEnv(["database", "trace-command"]),
databasePath,
"--",
traceCommand,
]);
},
async finalizeDatabase(
databasePath: string,
@ -782,7 +776,7 @@ export async function getCodeQLForCmd(
if (querySuitePath) {
codeqlArgs.push(querySuitePath);
}
await toolrunnerErrorCatcher(cmd, codeqlArgs, errorMatchers);
await runTool(cmd, codeqlArgs);
},
async databaseInterpretResults(
databasePath: string,
@ -848,17 +842,13 @@ export async function getCodeQLForCmd(
codeqlArgs.push(...querySuitePaths);
}
// capture stdout, which contains analysis summaries
const returnState = await toolrunnerErrorCatcher(
cmd,
codeqlArgs,
errorMatchers
);
const returnState = await runTool(cmd, codeqlArgs);
if (shouldWorkaroundInvalidNotifications) {
util.fixInvalidNotificationsInFile(codeqlOutputFile, sarifFile, logger);
}
return returnState.stdout;
return returnState;
},
async databasePrintBaseline(databasePath: string): Promise<string> {
const codeqlArgs = [
@ -1161,11 +1151,34 @@ async function runTool(
ignoreReturnCode: true,
...(opts.stdin ? { input: Buffer.from(opts.stdin || "") } : {}),
}).exec();
if (exitCode !== 0)
if (exitCode !== 0) {
error = extractFatalErrors(error) || error;
throw new CommandInvocationError(cmd, args, exitCode, error, output);
}
return output;
}
function extractFatalErrors(error: string): string | undefined {
const fatalErrors: string[] = [];
const fatalErrorRegex = /.*fatal error occurred:/gi;
let lastFatalErrorIndex: number | undefined;
let match: RegExpMatchArray | null;
while ((match = fatalErrorRegex.exec(error)) !== null) {
if (lastFatalErrorIndex !== undefined) {
fatalErrors.push(error.slice(lastFatalErrorIndex, match.index));
}
lastFatalErrorIndex = match.index;
}
if (lastFatalErrorIndex !== undefined) {
const lastError = error.slice(lastFatalErrorIndex);
return (
lastError +
(fatalErrors.length > 0 ? `\nContext:\n${fatalErrors.join("\n")}` : "")
);
}
return undefined;
}
/**
* If appropriate, generates a code scanning configuration that is to be used for a scan.
* If the configuration is not to be generated, returns undefined.

View file

@ -1,27 +0,0 @@
import test from "ava";
import { namedMatchersForTesting } from "./error-matcher";
/*
NB We test the regexes for all the matchers against example log output snippets.
*/
test("fatalError matches against example log output", async (t) => {
t.assert(
testErrorMatcher(
"fatalError",
"A fatal error occurred: Could not process query metadata for test-query.ql"
)
);
});
function testErrorMatcher(matcherName: string, logSample: string): boolean {
if (!(matcherName in namedMatchersForTesting)) {
throw new Error(`Unknown matcher ${matcherName}`);
}
const regex = namedMatchersForTesting[matcherName].outputRegex;
if (regex === undefined) {
throw new Error(`Cannot test matcher ${matcherName} with null regex`);
}
return regex.test(logSample);
}

View file

@ -1,18 +0,0 @@
// defines properties to match against the result of executed commands,
// and a custom error to return when a match is found
export interface ErrorMatcher {
exitCode?: number; // exit code of the run process
outputRegex?: RegExp; // pattern to match against either stdout or stderr
message: string; // the error message that will be thrown for a matching process
}
// exported only for testing purposes
export const namedMatchersForTesting: { [key: string]: ErrorMatcher } = {
fatalError: {
outputRegex: new RegExp("A fatal error occurred"),
message: "A fatal error occurred.",
},
};
// we collapse the matches into an array for use in execErrorCatcher
export const errorMatchers = Object.values(namedMatchersForTesting);

View file

@ -1,207 +0,0 @@
import * as exec from "@actions/exec";
import test from "ava";
import { ErrorMatcher } from "./error-matcher";
import { setupTests } from "./testing-utils";
import { toolrunnerErrorCatcher } from "./toolrunner-error-catcher";
setupTests(test);
test("matchers are never applied if non-error exit", async (t) => {
const testArgs = buildDummyArgs(
"foo bar\\nblort qux",
"foo bar\\nblort qux",
"",
0
);
const matchers: ErrorMatcher[] = [
{ exitCode: 123, outputRegex: new RegExp("foo bar"), message: "error!!!" },
];
t.deepEqual(await exec.exec("node", testArgs), 0);
const returnState = await toolrunnerErrorCatcher("node", testArgs, matchers);
t.deepEqual(returnState.exitCode, 0);
});
test("regex matchers are applied to stdout for non-zero exit code", async (t) => {
const testArgs = buildDummyArgs("foo bar\\nblort qux", "", "", 1);
const matchers: ErrorMatcher[] = [
{ exitCode: 123, outputRegex: new RegExp("foo bar"), message: "🦄" },
];
await t.throwsAsync(exec.exec("node", testArgs), {
instanceOf: Error,
message: /failed with exit code 1/,
});
await t.throwsAsync(toolrunnerErrorCatcher("node", testArgs, matchers), {
instanceOf: Error,
message: "🦄",
});
});
test("regex matchers are applied to stderr for non-zero exit code", async (t) => {
const testArgs = buildDummyArgs(
"non matching string",
"foo bar\\nblort qux",
"",
1
);
const matchers: ErrorMatcher[] = [
{ exitCode: 123, outputRegex: new RegExp("foo bar"), message: "🦄" },
];
await t.throwsAsync(exec.exec("node", testArgs), {
instanceOf: Error,
message: /failed with exit code 1/,
});
await t.throwsAsync(toolrunnerErrorCatcher("node", testArgs, matchers), {
instanceOf: Error,
message: "🦄",
});
});
test("matcher returns correct error message when multiple matchers defined", async (t) => {
const testArgs = buildDummyArgs(
"non matching string",
"foo bar\\nblort qux",
"",
1
);
const matchers: ErrorMatcher[] = [
{ exitCode: 456, outputRegex: new RegExp("lorem ipsum"), message: "😩" },
{ exitCode: 123, outputRegex: new RegExp("foo bar"), message: "🦄" },
{ exitCode: 789, outputRegex: new RegExp("blah blah"), message: "🤦‍♂️" },
];
await t.throwsAsync(exec.exec("node", testArgs), {
instanceOf: Error,
message: /failed with exit code 1/,
});
await t.throwsAsync(toolrunnerErrorCatcher("node", testArgs, matchers), {
instanceOf: Error,
message: "🦄",
});
});
test("matcher returns first match to regex when multiple matches", async (t) => {
const testArgs = buildDummyArgs(
"non matching string",
"foo bar\\nblort qux",
"",
1
);
const matchers: ErrorMatcher[] = [
{ exitCode: 123, outputRegex: new RegExp("foo bar"), message: "🦄" },
{ exitCode: 789, outputRegex: new RegExp("blah blah"), message: "🤦‍♂️" },
{ exitCode: 987, outputRegex: new RegExp("foo bar"), message: "🚫" },
];
await t.throwsAsync(exec.exec("node", testArgs), {
instanceOf: Error,
message: /failed with exit code 1/,
});
await t.throwsAsync(toolrunnerErrorCatcher("node", testArgs, matchers), {
instanceOf: Error,
message: "🦄",
});
});
test("exit code matchers are applied", async (t) => {
const testArgs = buildDummyArgs(
"non matching string",
"foo bar\\nblort qux",
"",
123
);
const matchers: ErrorMatcher[] = [
{
exitCode: 123,
outputRegex: new RegExp("this will not match"),
message: "🦄",
},
];
await t.throwsAsync(exec.exec("node", testArgs), {
instanceOf: Error,
message: /failed with exit code 123/,
});
await t.throwsAsync(toolrunnerErrorCatcher("node", testArgs, matchers), {
instanceOf: Error,
message: "🦄",
});
});
test("execErrorCatcher respects the ignoreReturnValue option", async (t) => {
const testArgs = buildDummyArgs("standard output", "error output", "", 199);
await t.throwsAsync(
toolrunnerErrorCatcher("node", testArgs, [], { ignoreReturnCode: false }),
{ instanceOf: Error }
);
const returnState = await toolrunnerErrorCatcher("node", testArgs, [], {
ignoreReturnCode: true,
});
t.deepEqual(returnState.exitCode, 199);
});
test("execErrorCatcher preserves behavior of provided listeners", async (t) => {
const stdoutExpected = "standard output";
const stderrExpected = "error output";
let stdoutActual = "";
let stderrActual = "";
const listeners = {
stdout: (data: Buffer) => {
stdoutActual += data.toString();
},
stderr: (data: Buffer) => {
stderrActual += data.toString();
},
};
const testArgs = buildDummyArgs(stdoutExpected, stderrExpected, "", 0);
const returnState = await toolrunnerErrorCatcher("node", testArgs, [], {
listeners,
});
t.deepEqual(returnState.exitCode, 0);
t.deepEqual(stdoutActual, `${stdoutExpected}\n`);
t.deepEqual(stderrActual, `${stderrExpected}\n`);
});
function buildDummyArgs(
stdoutContents: string,
stderrContents: string,
desiredErrorMessage?: string,
desiredExitCode?: number
): string[] {
let command = "";
if (stdoutContents) command += `console.log("${stdoutContents}");`;
if (stderrContents) command += `console.error("${stderrContents}");`;
if (command.length === 0)
throw new Error("Must provide contents for either stdout or stderr");
if (desiredErrorMessage)
command += `throw new Error("${desiredErrorMessage}");`;
if (desiredExitCode) command += `process.exitCode = ${desiredExitCode};`;
return ["-e", command];
}

View file

@ -1,87 +0,0 @@
import * as im from "@actions/exec/lib/interfaces";
import * as toolrunner from "@actions/exec/lib/toolrunner";
import * as safeWhich from "@chrisgavin/safe-which";
import { ErrorMatcher } from "./error-matcher";
import { wrapError } from "./util";
export interface ReturnState {
exitCode: number;
stdout: string;
}
/**
* Wrapper for toolrunner.Toolrunner which checks for specific return code and/or regex matches in console output.
* Output will be streamed to the live console as well as captured for subsequent processing.
* Returns promise with return code
*
* @param commandLine command to execute
* @param args optional arguments for tool. Escaping is handled by the lib.
* @param matchers defines specific codes and/or regexes that should lead to return of a custom error
* @param options optional exec options. See ExecOptions
* @returns ReturnState exit code and stdout output, if applicable
*/
export async function toolrunnerErrorCatcher(
commandLine: string,
args?: string[],
matchers?: ErrorMatcher[],
options?: im.ExecOptions
): Promise<ReturnState> {
let stdout = "";
let stderr = "";
const listeners = {
stdout: (data: Buffer) => {
stdout += data.toString();
if (options?.listeners?.stdout !== undefined) {
options.listeners.stdout(data);
}
},
stderr: (data: Buffer) => {
stderr += data.toString();
if (options?.listeners?.stderr !== undefined) {
options.listeners.stderr(data);
}
},
};
// we capture the original return code or error so that if no match is found we can duplicate the behavior
let exitCode: number;
try {
exitCode = await new toolrunner.ToolRunner(
await safeWhich.safeWhich(commandLine),
args,
{
...options, // we want to override the original options, so include them first
listeners,
ignoreReturnCode: true, // so we can check for specific codes using the matchers
}
).exec();
// if there is a zero return code then we do not apply the matchers
if (exitCode === 0) return { exitCode, stdout };
if (matchers) {
for (const matcher of matchers) {
if (
matcher.exitCode === exitCode ||
matcher.outputRegex?.test(stderr) ||
matcher.outputRegex?.test(stdout)
) {
throw new Error(matcher.message);
}
}
}
// only if we were instructed to ignore the return code do we ever return it non-zero
if (options?.ignoreReturnCode) {
return { exitCode, stdout };
} else {
throw new Error(
`The process '${commandLine}' failed with exit code ${exitCode}`
);
}
} catch (e) {
throw wrapError(e);
}
}