Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: resolveAndTestFacts with async polling #2025

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ snyk_report.html
cert.pem
key.pem
help/commands-md
help/commands-txt
help/commands-docs
help/commands-man
**/tsconfig.tsbuildinfo
__outputs__
Expand Down
2 changes: 1 addition & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ src/cli/commands/test/iac-local-execution/parsers/hcl-to-json/parser.js

# Generated Docs
help/commands-md
help/commands-txt
help/commands-docs
help/commands-man

# Has empty lines for templating convenience
Expand Down
Empty file added help/commands-txt/snyk.txt
Empty file.
2 changes: 1 addition & 1 deletion help/generator/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const COMMANDS: Record<string, { optionsFile?: string }> = {

const GENERATED_MARKDOWN_FOLDER = './help/commands-md';
const GENERATED_MAN_FOLDER = './help/commands-man';
const GENERATED_TXT_FOLDER = './help/commands-txt';
const GENERATED_TXT_FOLDER = './help/commands-docs';

function execShellCommand(cmd): Promise<string> {
return new Promise((resolve) => {
Expand Down
4 changes: 2 additions & 2 deletions src/cli/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ export = async function help(item: string | boolean) {
try {
const filename = path.resolve(
__dirname,
'../../../help/commands-txt',
'../../../help/commands-docs',
item === DEFAULT_HELP ? DEFAULT_HELP + '.txt' : `snyk-${item}.txt`,
);
return readHelpFile(filename);
} catch (error) {
const filename = path.resolve(
__dirname,
'../../../help/commands-txt',
'../../../help/commands-docs',
DEFAULT_HELP + '.txt',
);
return readHelpFile(filename);
Expand Down
120 changes: 120 additions & 0 deletions src/lib/ecosystems/polling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import * as config from '../config';
import { isCI } from '../is-ci';
import { makeRequest } from '../request/promise';
import { Options } from '../types';

import { ResolveAndTestFactsResponse } from '../snyk-test/legacy';
import { assembleQueryString } from '../snyk-test/common';
import { getAuthHeader } from '../api-token';
import { ScanResult } from './types';

export async function requestPollingToken(
options: Options,
isAsync: boolean,
scanResult: ScanResult,
): Promise<ResolveAndTestFactsResponse> {
const payload = {
method: 'POST',
url: `${config.API}/test-dependencies`,
json: true,
headers: {
'x-is-ci': isCI(),
authorization: getAuthHeader(),
},
body: {
isAsync,
scanResult,
},
qs: assembleQueryString(options),
};
return await makeRequest<ResolveAndTestFactsResponse>(payload);
}

export async function pollingWithTokenUntilDone(
token: string,
type: string,
options: Options,
pollInterval: number,
attemptsCount: number,
maxAttempts = Infinity,
): Promise<ResolveAndTestFactsResponse> {
const payload = {
method: 'GET',
url: `${config.API}/test-dependencies/${token}`,
json: true,
headers: {
'x-is-ci': isCI(),
authorization: getAuthHeader(),
},
qs: { ...assembleQueryString(options), type },
};

const response = await makeRequest<ResolveAndTestFactsResponse>(payload);

if (response.result && response.meta) {
return response;
}

if (pollingRequestHasFailed(response)) {
throw new Error('polling request has failed.');
}

//TODO: (@snyk/tundra): move to backend
checkPollingAttempts(maxAttempts)(attemptsCount);

return await retryWithPollInterval(
token,
type,
options,
pollInterval,
attemptsCount,
maxAttempts,
);
}

async function retryWithPollInterval(
token: string,
type: string,
options: Options,
pollInterval: number,
attemptsCount: number,
maxAttempts: number,
): Promise<ResolveAndTestFactsResponse> {
return new Promise((resolve, reject) =>
setTimeout(async () => {
const res = await pollingWithTokenUntilDone(
token,
type,
options,
pollInterval,
attemptsCount,
maxAttempts,
);

if (pollingRequestHasFailed(res)) {
return reject(res);
}

if (res.result && res.meta) {
return resolve(res);
}
}, pollInterval),
);
}

function checkPollingAttempts(maxAttempts: number) {
return (attemptsCount: number) => {
if (attemptsCount > maxAttempts) {
throw new Error('Exceeded Polling maxAttempts');
}
};
}
function pollingRequestHasFailed(
response: ResolveAndTestFactsResponse,
): boolean {
const { token, result, meta, status, error, code, message } = response;
const hasError = !!error && !!code && !!message;
const pollingContextIsMissing = !token && !result && !meta && !status;

return !!pollingContextIsMissing || hasError;
}
95 changes: 94 additions & 1 deletion src/lib/ecosystems/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import { makeRequest } from '../request/promise';
import { Options } from '../types';
import { TestCommandResult } from '../../cli/commands/types';
import * as spinner from '../../lib/spinner';
import { Ecosystem, ScanResult, TestResult } from './types';
import { Ecosystem, EcosystemPlugin, ScanResult, TestResult } from './types';
import { getPlugin } from './plugins';
import { TestDependenciesResponse } from '../snyk-test/legacy';
import { assembleQueryString } from '../snyk-test/common';
import { getAuthHeader } from '../api-token';
import { requestPollingToken, pollingWithTokenUntilDone } from './polling';

export async function testEcosystem(
ecosystem: Ecosystem,
Expand All @@ -30,10 +31,43 @@ export async function testEcosystem(
scanResultsByPath[path] = pluginResponse.scanResults;
}
spinner.clearAll();

if (ecosystem === 'cpp') {
const [testResults, errors] = await resolveAndTestFacts(
ecosystem,
scanResultsByPath,
options,
);
return await getTestResultsOutput(
errors,
options,
testResults,
plugin,
scanResultsByPath,
);
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the current stage I preferred to keep this logic as part of testDependencies instead of create a completely new flow for resolveAndTestFacts, have time to change it when required


const [testResults, errors] = await testDependencies(
scanResultsByPath,
options,
);

return await getTestResultsOutput(
errors,
options,
testResults,
plugin,
scanResultsByPath,
);
}

async function getTestResultsOutput(
errors: string[],
options: Options,
testResults: TestResult[],
plugin: EcosystemPlugin,
scanResultsByPath: { [dir: string]: ScanResult[] },
) {
const stringifiedData = JSON.stringify(testResults, null, 2);
if (options.json) {
return TestCommandResult.createJsonTestCommandResult(stringifiedData);
Expand All @@ -53,6 +87,65 @@ export async function testEcosystem(
);
}

export async function resolveAndTestFacts(
ecosystem: Ecosystem,
scans: {
[dir: string]: ScanResult[];
},
options: Options,
): Promise<[TestResult[], string[]]> {
const results: any[] = [];
const errors: string[] = [];
for (const [path, scanResults] of Object.entries(scans)) {
await spinner(`Resolving and Testing C/C++ fileSignatures in ${path}`);
for (const scanResult of scanResults) {
try {
const res = await requestPollingToken(options, true, scanResult);
if (!res.token && !res.status) {
throw 'Something went wrong, token and status n/a';
}
//TODO (@snyk/tundra): poll interval could be based on amount of fileSignatures to be resolved?
const pollInterval = 30000;
const attemptsCount = 0;
const maxAttempts = 250;

const response = await pollingWithTokenUntilDone(
res.token,
ecosystem,
options,
pollInterval,
attemptsCount,
maxAttempts,
);

if (response.meta && response.result) {
results.push({
issues: response.result.issues,
issuesData: response.result.issuesData,
depGraphData: response.result.depGraphData,
});
}
} catch (error) {
const hasStatusCodeError = error.code >= 400 && error.code < 500;
const pollingMaxAttemptsExceeded =
error.message === 'Exceeded Polling maxAttempts';
if (hasStatusCodeError || pollingMaxAttemptsExceeded) {
throw new Error(error.message);
}

if (error.code >= 500) {
const errorMsg = `Could not test dependencies in ${path} \n${error.message}`;
errors.push(errorMsg);
continue;
}
errors.push(`Could not test dependencies in ${path}`);
}
}
}
spinner.clearAll();
return [results, errors];
}

async function testDependencies(
scans: {
[dir: string]: ScanResult[];
Expand Down
11 changes: 11 additions & 0 deletions src/lib/snyk-test/legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,17 @@ export interface TestDependenciesResponse {
meta: TestDepGraphMeta;
}

export type ResolveAndTestFactsStatus = 'CANCELLED' | 'ERROR' | 'PENDING' | 'RUNNING' | 'OK';
export interface ResolveAndTestFactsResponse {
token: string;
result: TestDependenciesResult;
meta: TestDepGraphMeta;
status?: ResolveAndTestFactsStatus;
code?: number;
error?: string;
message?: string;
userMessage?: string;
}
export interface Ignores {
[path: string]: {
paths: string[][];
Expand Down