Skip to content

Commit

Permalink
feat: resolveAndTestFacts w/ async polling(c/c++)
Browse files Browse the repository at this point in the history
Introduces async request for C/C++ with polling, where its Facts are resolved/computed in our platform
  • Loading branch information
anthogez committed Jul 7, 2021
1 parent 5150231 commit 4fd0858
Show file tree
Hide file tree
Showing 4 changed files with 348 additions and 1 deletion.
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 hasPollingContext = token && result && meta && status;

return !hasPollingContext || 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,
);
}

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 = 10000;
const attemptsCount = 0;
const maxAttempts = 250000;

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
123 changes: 123 additions & 0 deletions test/jest/unit/lib/ecosystems/test.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { Options } from '../../../../../src/lib/types';
import * as polling from '../../../../../src/lib/ecosystems/polling';
import { resolveAndTestFacts } from '../../../../../src/lib/ecosystems/test';

describe('test ecosystems', () => {
it('resolveAndTestFacts', async () => {
/* eslint-disable @typescript-eslint/camelcase */
const scanResults = {
path: [
{
name: 'my-unmanaged-c-project',
facts: [
{
type: 'fileSignatures',
data: [
{
path: 'fastlz_example/fastlz.h',
hashes_ffm: [
{
format: 1,
data: 'ucMc383nMM/wkFRM4iOo5Q',
},
{
format: 1,
data: 'k+DxEmslFQWuJsZFXvSoYw',
},
],
},
],
},
],
identity: {
type: 'cpp',
},
target: {
remoteUrl: 'https://github.com/some-org/some-unmanaged-project.git',
branch: 'master',
},
},
],
};

const depGraphData = {
schemaVersion: '1.2.0',
pkgManager: { name: 'cpp' },
pkgs: [
{ id: '_root@0.0.0', info: { name: '_root', version: '0.0.0' } },
{
id: 'fastlz|https://github.com/ariya/fastlz/archive/0.5.0.zip@0.5.0',
info: {
name: 'fastlz|https://github.com/ariya/fastlz/archive/0.5.0.zip',
version: '0.5.0',
},
},
],
graph: {
rootNodeId: 'root-node',
nodes: [
{
nodeId: 'root-node',
pkgId: '_root@0.0.0',
deps: [
{
nodeId:
'fastlz|https://github.com/ariya/fastlz/archive/0.5.0.zip@0.5.0',
},
],
},
{
nodeId:
'fastlz|https://github.com/ariya/fastlz/archive/0.5.0.zip@0.5.0',
pkgId:
'fastlz|https://github.com/ariya/fastlz/archive/0.5.0.zip@0.5.0',
deps: [],
},
],
},
};

// temporary values, we trully want depGraph & issuesData/affectedPkgs
const pollingWithTokenUntilDoneSpy = jest.spyOn(
polling,
'pollingWithTokenUntilDone',
);

pollingWithTokenUntilDoneSpy.mockResolvedValueOnce({
result: {
issuesData: {},
issues: [],
depGraphData,
meta: {
isPrivate: true,
isLicensesEnabled: false,
ignoreSettings: null,
org: expect.any(String),
},
},
});

const [testResults, errors] = await resolveAndTestFacts(
'cpp',
scanResults,
{} as Options,
);

expect(testResults).toEqual([
{
result: {
issuesData: {},
issues: [],
depGraphData,
meta: {
isPrivate: true,
isLicensesEnabled: false,
ignoreSettings: null,
org: expect.any(String),
},
},
},
]);
expect(errors).toEqual([]);
});
});

0 comments on commit 4fd0858

Please sign in to comment.