From 7444b3252722ade1c71548a0a988dcc9d91908c4 Mon Sep 17 00:00:00 2001 From: ghe Date: Thu, 10 Nov 2022 10:12:18 +0000 Subject: [PATCH] feat: sync all Github projects for a given organization in Snyk --- README.md | 2 + docs/sync.md | 65 +++ src/cmds/sync.ts | 99 ++++ src/common.ts | 2 + src/lib/api/project/index.ts | 3 +- src/lib/project/update-branch.ts | 2 +- .../github/get-default-branch.ts | 2 +- src/lib/source-handlers/github/index.ts | 1 + .../source-handlers/github/is-configured.ts | 6 + src/loggers/log-failed-projects.ts | 2 +- src/loggers/log-failed-sync.ts | 32 ++ src/loggers/log-failed-to-update-projects.ts | 46 ++ src/loggers/log-updated-project.ts | 34 +- .../generate-imported-targets-from-snyk.ts | 8 +- src/scripts/sync/sync-org-projects.ts | 164 ++++--- src/scripts/sync/sync-projects-per-target.ts | 139 +++++- ...branches.test.ts => update-branch.test.ts} | 10 +- .../project/update-project-per-target.test.ts | 101 ---- .../lib/source-handlers/github/github.test.ts | 18 + ...enerate-imported-targets-from-snyk.test.ts | 13 + test/scripts/sync/sync-org-projects.test.ts | 440 +++++++----------- test/system/__snapshots__/sync.test.ts.snap | 22 + test/system/help.test.ts | 49 +- test/system/sync.test.ts | 165 +++++++ 24 files changed, 927 insertions(+), 498 deletions(-) create mode 100644 docs/sync.md create mode 100644 src/cmds/sync.ts create mode 100644 src/lib/source-handlers/github/is-configured.ts create mode 100644 src/loggers/log-failed-sync.ts create mode 100644 src/loggers/log-failed-to-update-projects.ts rename test/lib/project/{compare-branches.test.ts => update-branch.test.ts} (92%) delete mode 100644 test/lib/project/update-project-per-target.test.ts create mode 100644 test/system/__snapshots__/sync.test.ts.snap create mode 100644 test/system/sync.test.ts diff --git a/README.md b/README.md index 2335391b..591622aa 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ If you need to adjust concurrency you can stop the script, change the concurrenc - [Contributing](.github/CONTRIBUTING.md) - [Kicking off an import](docs/import.md) +- [Sync: detecting changes in monitored repos and updating Snyk projects](docs/sync.md) + - Example workflows - [AWS automation example](docs/example-workflows/aws-automation-example.md) diff --git a/docs/sync.md b/docs/sync.md new file mode 100644 index 00000000..295788a1 --- /dev/null +++ b/docs/sync.md @@ -0,0 +1,65 @@ +# Sync +## Table of Contents +- [Sync](#sync) + - [Table of Contents](#table-of-contents) + - [Prerequisites](#prerequisites) +- [What will change?](#what-will-change) + - [Branches](#branches) +- [Kick off sync](#kick-off-sync) + - [1. Set the env vars](#1-set-the-env-vars) + - [2. Download & run](#2-download--run) + - [Examples](#examples) + - [Github.com](#githubcom) +- [Known limitations](#known-limitations) + +## Prerequisites +You will need to have setup in advance: +- your [Snyk organizations](docs/orgs.md) should exist and have projects +- your Snyk organizations configured with some connection to SCM (Github/Gitlab/Bitbucket etc) as you will need the provide which integration sync should use to update projects. +- you will need your Snyk API token, with correct scope & [admin access for all Organizations](https://snyk.docs.apiary.io/#reference/import-projects/import/import-targets). This command will perform project changes on users behalf (import, update project branch, deactivate projects). **Github Integration Note**: As Github is both an auth & integration, how the integration is done has an effect on usage: + - For users importing via [Github Snyk integration](https://docs.snyk.io/integrations/git-repository-scm-integrations/github-integration#setting-up-a-github-integration) use your **personal Snyk API token** (Service Accounts are not supported for Github integration imports via API as this is a personal auth token only accessible to the user) + - For Github Enterprise Snyk integration with a url & token (for Github.com, Github Enterprise Cloud & Github Enterprise hosted) use a **Snyk API service account token** + + +Any logs will be generated at `SNYK_LOG_PATH` directory. + +# What will change? + +## Branches +Updating the project branch in Snyk to match the default branch of the repo in the SCM. The drift can happen for several reasons: +- branch was renamed in Github/Gitlab etc on a repo from e.g. from `master` > `main` +- a new default branch was chosen from existing branches e.g. both `main` and `develop` exist as branches and default branch switched from `main` to `develop` + + +# Kick off sync +`sync` command will analyze existing projects & targets (repos) in Snyk organization and determine if any changes are needed. + +`--dryRun=true` - run the command first in dry-run mode to see what changes will be made in Snyk before running this again without if everything looks good. In this mode the last call to Snyk APIs to make the changes will be skipped but the logs will pretend as if it succeeded, the log entry will indicate this was generate in `dryRun` mode. + +The command will produce detailed logs for projects that were `updated` and those that needed an update but `failed`. If no changes are needed these will not be logged. + + +## 1. Set the env vars +- `SNYK_TOKEN` - your [Snyk api token](https://app.snyk.io/account) +- `SNYK_LOG_PATH` - the path to folder where all logs should be saved,it is recommended creating a dedicated logs folder per import you have running. (Note: all logs will append) +- `SNYK_API` (optional) defaults to `https://snyk.io/api/v1` +- `GITHUB_TOKEN` - SCM token that has read level or similar permissions to see information about repos like default branch & can list files in a repo + +## 2. Download & run + +Grab a binary from the [releases page](https://github.com/snyk-tech-services/snyk-api-import/releases) and run with `DEBUG=snyk* snyk-api-import-macos import --file=path/to/imported-targets.json` + + +## Examples + +### Github.com + +In dry-run mode: +`DEBUG=*snyk* SNYK_TOKEN=xxxx snyk-api-import sync --orgPublicId= --source=github --dryRun=true` + +Live mode: +`DEBUG=*snyk* SNYK_TOKEN=xxxx snyk-api-import sync --orgPublicId= --source=github` + +# Known limitations +- Any organizations using a custom branch feature are currently not supported, `sync` will not continue. +- ANy organizations that previously used the custom feature flag should ideally delete all existing projects & re-import to restore the project names to standard format (do not include a branch in the project name). `sync` will work regardless but may cause confusion as the project name will reference a branch that is not likely to be the actual branch being tested. diff --git a/src/cmds/sync.ts b/src/cmds/sync.ts new file mode 100644 index 00000000..0b83c825 --- /dev/null +++ b/src/cmds/sync.ts @@ -0,0 +1,99 @@ +import * as debugLib from 'debug'; +import * as yargs from 'yargs'; +const debug = debugLib('snyk:orgs-data-script'); + +import { getLoggingPath } from '../lib/get-logging-path'; +import type { CommandResult } from '../lib/types'; +import { SupportedIntegrationTypesUpdateProject } from '../lib/types'; + +import { updateOrgTargets } from '../scripts/sync/sync-org-projects'; + +export const command = ['sync']; +export const desc = + 'Sync targets (e.g. repos) and their projects between Snyk and SCM for a given organization. Actions include:\n - updating monitored branch in Snyk to match the default branch from SCM'; +export const builder = { + orgPublicId: { + required: true, + default: undefined, + desc: 'Public id of the organization in Snyk that will be updated', + }, + sourceUrl: { + required: false, + default: undefined, + desc: 'Custom base url for the source API that can list organizations (e.g. Github Enterprise url)', + }, + // TODO: needs integration Type for GHE<> Github setup + source: { + required: true, + default: SupportedIntegrationTypesUpdateProject.GITHUB, + choices: [...Object.values(SupportedIntegrationTypesUpdateProject)], + desc: 'List of sources to be synced e.g. Github, Github Enterprise, Gitlab, Bitbucket Server, Bitbucket Cloud', + }, + dryRun: { + required: false, + default: false, + desc: 'Dry run option. Will create a log file listing the potential updates', + }, +}; + +export async function syncOrg( + source: SupportedIntegrationTypesUpdateProject[], + orgPublicId: string, + sourceUrl?: string, + dryRun?: boolean, +): Promise { + try { + getLoggingPath(); + + const res = await updateOrgTargets(orgPublicId, source, dryRun, sourceUrl); + + const nothingToUpdate = + res.processedTargets == 0 && + res.meta.projects.updated.length == 0 && + res.meta.projects.failed.length == 0; + const orgMessage = nothingToUpdate + ? `Did not detect any changes to apply` + : `Processed ${res.processedTargets} targets\nUpdated ${res.meta.projects.updated.length} projects\n${res.meta.projects.failed.length} projects failed to update\nFind more information in ${res.fileName} and ${res.failedFileName}`; + + return { + fileName: res.fileName, + exitCode: 0, + message: + `Finished syncing all ${source} targets for Snyk organization ${orgPublicId}\n` + + orgMessage, + }; + } catch (e) { + const errorMessage = `ERROR! Failed to sync organization. Try running with \`DEBUG=snyk* for more info\`.\nERROR: ${e.message}`; + return { + fileName: undefined, + exitCode: 1, + message: errorMessage, + }; + } +} + +export async function handler(argv: { + source: SupportedIntegrationTypesUpdateProject; + orgPublicId: string; + sourceUrl?: string; + dryRun?: boolean; +}): Promise { + const { source, orgPublicId, sourceUrl, dryRun } = argv; + debug('ℹ️ Options: ' + JSON.stringify(argv)); + + const sourceList: SupportedIntegrationTypesUpdateProject[] = []; + sourceList.push(source); + + // when the input will be a file we will need to + // add a function to read and parse the file + const res = await syncOrg(sourceList, orgPublicId, sourceUrl, dryRun); + + if (res.exitCode === 1) { + debug('Failed to sync organizations.\n' + res.message); + + console.error(res.message); + setTimeout(() => yargs.exit(1, new Error(res.message)), 3000); + } else { + console.log(res.message); + } +} diff --git a/src/common.ts b/src/common.ts index e4beba90..3b200b1d 100644 --- a/src/common.ts +++ b/src/common.ts @@ -2,6 +2,7 @@ export const IMPORT_LOG_NAME = 'imported-targets.log'; export const FAILED_LOG_NAME = 'failed-imports.log'; export const FAILED_PROJECTS_LOG_NAME = 'failed-projects.log'; export const UPDATED_PROJECTS_LOG_NAME = 'updated-projects.log'; +export const FAILED_UPDATE_PROJECTS_LOG_NAME = 'failed-to-update-projects.log'; export const FAILED_POLLS_LOG_NAME = 'failed-polls.log'; export const IMPORT_JOBS_LOG_NAME = 'import-jobs.log'; export const IMPORTED_PROJECTS_LOG_NAME = 'imported-projects.log'; @@ -9,6 +10,7 @@ export const IMPORTED_BATCHES_LOG_NAME = 'imported-batches.log'; export const IMPORT_JOB_RESULTS = 'import-job-results.log'; export const CREATED_ORG_LOG_NAME = 'created-orgs.log' export const FAILED_ORG_LOG_NAME = 'failed-to-create-orgs.log' +export const FAILED_SYNC_LOG_NAME = 'failed-to-sync-target.log' export const targetProps = [ 'name', 'appId', diff --git a/src/lib/api/project/index.ts b/src/lib/api/project/index.ts index e4b7cde4..c89379ba 100644 --- a/src/lib/api/project/index.ts +++ b/src/lib/api/project/index.ts @@ -5,7 +5,7 @@ import { getApiToken } from '../../get-api-token'; import { getSnykHost } from '../../get-snyk-host'; import type { requestsManager } from 'snyk-request-manager'; import type { SnykProject } from '../../types'; -const debug = debugLib('snyk:api-import'); +const debug = debugLib('snyk:api-project'); export async function deleteProjects( orgId: string, @@ -88,6 +88,7 @@ export async function updateProject( const statusCode = res.statusCode || res.status; if (!statusCode || statusCode !== 200) { + debug('Failed updating project: ' + projectId); throw new Error( 'Expected a 200 response, instead received: ' + JSON.stringify({ data: res.data, status: statusCode }), diff --git a/src/lib/project/update-branch.ts b/src/lib/project/update-branch.ts index 24ce697e..6fe55216 100644 --- a/src/lib/project/update-branch.ts +++ b/src/lib/project/update-branch.ts @@ -31,7 +31,7 @@ export async function updateBranch( return { updated }; } catch (e) { throw new Error( - `Failed to update project ${projectPublicId}. ERROR: ${e.message}`, + `Failed to update project ${projectPublicId} via Snyk API. ERROR: ${e.message}`, ); } } diff --git a/src/lib/source-handlers/github/get-default-branch.ts b/src/lib/source-handlers/github/get-default-branch.ts index cd27a924..62316cd6 100644 --- a/src/lib/source-handlers/github/get-default-branch.ts +++ b/src/lib/source-handlers/github/get-default-branch.ts @@ -14,7 +14,7 @@ export async function getGithubReposDefaultBranch( const baseUrl = getGithubBaseUrl(host); const octokit: Octokit = new Octokit({ baseUrl, auth: githubToken }); - debug(`Fetch default branch for repo: ${target.owner}/${target.name}`); + debug(`Fetching default branch for repo: ${target.owner}/${target.name}`); const response = await octokit.repos.get({ owner: target.owner!, diff --git a/src/lib/source-handlers/github/index.ts b/src/lib/source-handlers/github/index.ts index 8f7df787..a8bee527 100644 --- a/src/lib/source-handlers/github/index.ts +++ b/src/lib/source-handlers/github/index.ts @@ -3,3 +3,4 @@ export * from './list-repos'; export * from './organization-is-empty'; export * from './get-default-branch'; export * from './types'; +export * from './is-configured'; diff --git a/src/lib/source-handlers/github/is-configured.ts b/src/lib/source-handlers/github/is-configured.ts new file mode 100644 index 00000000..854d2ebe --- /dev/null +++ b/src/lib/source-handlers/github/is-configured.ts @@ -0,0 +1,6 @@ +import { getGithubToken } from './get-github-token'; + +export function isGithubConfigured(): boolean { + getGithubToken(); + return true; +} diff --git a/src/loggers/log-failed-projects.ts b/src/loggers/log-failed-projects.ts index afff1d93..cae28f50 100644 --- a/src/loggers/log-failed-projects.ts +++ b/src/loggers/log-failed-projects.ts @@ -2,7 +2,7 @@ import * as bunyan from 'bunyan'; import * as debugLib from 'debug'; import { FAILED_PROJECTS_LOG_NAME } from './../common'; -import { Project } from './../lib/types'; +import type { Project } from './../lib/types'; import { getLoggingPath } from './../lib'; const debug = debugLib('snyk:import-projects-script'); diff --git a/src/loggers/log-failed-sync.ts b/src/loggers/log-failed-sync.ts new file mode 100644 index 00000000..ef909c19 --- /dev/null +++ b/src/loggers/log-failed-sync.ts @@ -0,0 +1,32 @@ +import * as debugLib from 'debug'; +import * as bunyan from 'bunyan'; + +import { getLoggingPath } from '../lib'; +import { FAILED_SYNC_LOG_NAME } from '../common'; +import type { SnykTarget } from '../lib/types'; + +const debug = debugLib('snyk:failed-to-sync-orgs'); + +export async function logFailedSync( + orgId: string, + target: SnykTarget, + errorMessage: string, + loggingPath = getLoggingPath(), +): Promise { + const log = bunyan.createLogger({ + name: 'sync', + level: 'error', + streams: [ + { + level: 'error', + path: `${loggingPath}/${orgId}.${FAILED_SYNC_LOG_NAME}`, + }, + ], + }); + try { + log.error({ orgId, target, errorMessage }, `Failed to sync target`); + } catch (e) { + debug('Failed to log failed sync', { orgId, target, errorMessage }); + // do nothing + } +} diff --git a/src/loggers/log-failed-to-update-projects.ts b/src/loggers/log-failed-to-update-projects.ts new file mode 100644 index 00000000..fe870972 --- /dev/null +++ b/src/loggers/log-failed-to-update-projects.ts @@ -0,0 +1,46 @@ +import * as bunyan from 'bunyan'; + +import { FAILED_UPDATE_PROJECTS_LOG_NAME } from './../common'; +import { getLoggingPath } from './../lib'; +import type { ProjectUpdateFailure } from '../scripts/sync/sync-projects-per-target'; + +export async function logFailedToUpdateProjects( + orgId: string, + updated: ProjectUpdateFailure[], + loggingPath: string = getLoggingPath(), +): Promise { + try { + const log = bunyan.createLogger({ + name: 'snyk:sync-org-projects', + level: 'info', + streams: [ + { + level: 'info', + path: `${loggingPath}/${orgId}.${FAILED_UPDATE_PROJECTS_LOG_NAME}`, + }, + ], + }); + updated.forEach((update) => { + log.info( + { + orgId, + projectPublicId: update.projectPublicId, + from: update.from, + to: update.to, + update: update.type, + dryRun: String(update.dryRun), + target: { + id: update.target?.id, + origin: update.target?.attributes.origin, + displayName: update.target?.attributes.displayName, + remoteUrl: update.target?.attributes.remoteUrl ?? undefined, + }, + error: update.errorMessage, + }, + `Snyk project ${update.type} update failed`, + ); + }); + } catch (e) { + // do nothing + } +} diff --git a/src/loggers/log-updated-project.ts b/src/loggers/log-updated-project.ts index fa71fa0b..94f7579b 100644 --- a/src/loggers/log-updated-project.ts +++ b/src/loggers/log-updated-project.ts @@ -1,19 +1,12 @@ import * as bunyan from 'bunyan'; -import * as debugLib from 'debug'; import { UPDATED_PROJECTS_LOG_NAME } from './../common'; -import { Project } from './../lib/types'; import { getLoggingPath } from './../lib'; - -const debug = debugLib('snyk:sync-org-projects'); - -export interface FailedProject extends Project { - locationUrl: string; -} +import type { ProjectUpdate } from '../scripts/sync/sync-projects-per-target'; export async function logUpdatedProjects( orgId: string, - branchesUpdated: string[], + updated: ProjectUpdate[], loggingPath: string = getLoggingPath(), ): Promise { try { @@ -27,14 +20,23 @@ export async function logUpdatedProjects( }, ], }); - branchesUpdated.forEach((projectPublicId) => { - debug( - { orgId, projectPublicId }, - 'Snyk project branch updated to point to default', - ); + updated.forEach((update) => { log.info( - { orgId, projectPublicId }, - 'Snyk project branch updated to point to default', + { + orgId, + projectPublicId: update.projectPublicId, + from: update.from, + to: update.to, + update: update.type, + dryRun: String(update.dryRun), + target: { + id: update.target?.id, + origin: update.target?.attributes.origin, + displayName: update.target?.attributes.displayName, + remoteUrl: update.target?.attributes.remoteUrl ?? undefined, + }, + }, + `Snyk project ${update.type} updated`, ); }); } catch (e) { diff --git a/src/scripts/generate-imported-targets-from-snyk.ts b/src/scripts/generate-imported-targets-from-snyk.ts index b1274f1d..7256d3ff 100644 --- a/src/scripts/generate-imported-targets-from-snyk.ts +++ b/src/scripts/generate-imported-targets-from-snyk.ts @@ -33,7 +33,7 @@ export function projectToTarget( return { owner, branch: project.branch || undefined, // TODO: make it not optional - name, + name: name.split('(')[0], }; } export function bitbucketServerProjectToTarget( @@ -42,7 +42,7 @@ export function bitbucketServerProjectToTarget( const [projectKey, repoSlug] = project.name.split(':')[0].split('/'); return { projectKey, - repoSlug, + repoSlug: repoSlug.split('(')[0], }; } @@ -51,6 +51,7 @@ export function gitlabProjectToImportLogTarget( ): Target { // Gitlab target is only `id` & branch and the Snyk API does not return the id. // However we are already logging `name` which for Gitlab is "owner/repo", branch & id so if we use the same name we can match on it + // TODO: add support for customBranch projects const name = project.name.split(':')[0]; return { branch: project.branch || undefined, // TODO: make it not optional @@ -93,8 +94,6 @@ export async function generateSnykImportedTargets( fileName: string; failedOrgs: SnykOrg[]; }> { - const timeLabel = 'Generated imported Snyk targets'; - console.time(timeLabel); const { groupId, orgId } = id; if (!(groupId || orgId)) { throw new Error( @@ -198,7 +197,6 @@ export async function generateSnykImportedTargets( : `Could the organization ${orgId} be empty?`; console.warn(`No targets could be generated. ${message}`); } - console.timeEnd(timeLabel); return { targets: targetsData, fileName: path.resolve(getLoggingPath(), IMPORT_LOG_NAME), diff --git a/src/scripts/sync/sync-org-projects.ts b/src/scripts/sync/sync-org-projects.ts index 71f161e9..78c62620 100644 --- a/src/scripts/sync/sync-org-projects.ts +++ b/src/scripts/sync/sync-org-projects.ts @@ -2,38 +2,64 @@ import pMap = require('p-map'); import * as debugLib from 'debug'; import * as path from 'path'; import { requestsManager } from 'snyk-request-manager'; -import { UPDATED_PROJECTS_LOG_NAME } from '../../common'; +import { + FAILED_UPDATE_PROJECTS_LOG_NAME, + UPDATED_PROJECTS_LOG_NAME, +} from '../../common'; import type { TargetFilters } from '../../lib'; -import { getLoggingPath, listProjects, listTargets } from '../../lib'; +import { isGithubConfigured } from '../../lib'; +import { getLoggingPath, listTargets } from '../../lib'; import { getFeatureFlag } from '../../lib/api/feature-flags'; -import type { SnykProject, SnykTarget } from '../../lib/types'; +import type { SnykTarget } from '../../lib/types'; import { SupportedIntegrationTypesUpdateProject } from '../../lib/types'; import { logUpdatedProjects } from '../../loggers/log-updated-project'; -import { updateProjectForTarget as updateProjectForTarget } from './sync-projects-per-target'; +import type { ProjectUpdateFailure } from './sync-projects-per-target'; +import { syncProjectsForTarget } from './sync-projects-per-target'; +import type { ProjectUpdate } from './sync-projects-per-target'; +import { logFailedSync } from '../../loggers/log-failed-sync'; +import { logFailedToUpdateProjects } from '../../loggers/log-failed-to-update-projects'; const debug = debugLib('snyk:sync-org-projects'); +export function isSourceConfigured( + origin: SupportedIntegrationTypesUpdateProject, +): () => void { + const getDefaultBranchGenerators = { + [SupportedIntegrationTypesUpdateProject.GITHUB]: isGithubConfigured, + }; + return getDefaultBranchGenerators[origin]; +} + export async function updateOrgTargets( publicOrgId: string, sources: SupportedIntegrationTypesUpdateProject[], dryRun = false, + host?: string, ): Promise<{ fileName: string; + failedFileName: string; processedTargets: number; meta: { projects: { - branchUpdated: string[]; + updated: ProjectUpdate[]; + failed: ProjectUpdateFailure[]; }; }; }> { - const branchUpdated: string[] = []; - const logFile = path.resolve(getLoggingPath(), UPDATED_PROJECTS_LOG_NAME); - const res = { - fileName: logFile, + const res: { + processedTargets: number; + meta: { + projects: { + updated: ProjectUpdate[]; + failed: ProjectUpdateFailure[]; + }; + }; + } = { processedTargets: 0, meta: { projects: { - branchUpdated, + updated: [], + failed: [], }, }, }; @@ -43,12 +69,11 @@ export async function updateOrgTargets( Object.values(SupportedIntegrationTypesUpdateProject).includes(source), ); if (!allowedSources.length) { - console.warn( - `The organization (${publicOrgId}) does not have any projects that are supported for sync. Currently supported projects are ${Object.values( + throw new Error( + `Nothing to sync, stopping. Sync command currently only supports the following sources: ${Object.values( SupportedIntegrationTypesUpdateProject, ).join(',')}`, ); - return res; } const requestManager = new requestsManager({ @@ -57,16 +82,31 @@ export async function updateOrgTargets( maxRetryCount: 3, }); - if (await getFeatureFlag(requestManager, 'customBranch', publicOrgId)) { - console.warn( - 'Detected custom branches are used in this organization. Skipping syncing organization ${publicOrgId}', + let hasCustomBranchFlag = true; + + try { + hasCustomBranchFlag = await getFeatureFlag( + requestManager, + 'customBranch', + publicOrgId, + ); + } catch (e) { + throw new Error( + `Org ${publicOrgId} was not found or you may not have the correct permissions to access the org`, + ); + } + + // TODO: move this into sync project per target and skip only whats needed + if (hasCustomBranchFlag) { + throw new Error( + `Detected custom branches feature. Skipping syncing organization ${publicOrgId} because it is not possible to determine which should be the default branch.`, ); - return res; } await pMap( allowedSources, async (source: SupportedIntegrationTypesUpdateProject) => { + isSourceConfigured(source)(); const filters: TargetFilters = { limit: 100, origin: source, @@ -79,23 +119,36 @@ export async function updateOrgTargets( filters, ); debug(`Syncing targets for source ${source}`); - const updated = await updateTargets( + const response = await updateTargets( requestManager, publicOrgId, targets, dryRun, + host, ); - res.processedTargets += updated.processedTargets; - res.meta.projects.branchUpdated.push( - ...updated.meta.projects.branchUpdated, - ); - debug(`Logging updated targets for source ${source}`); - // TODO: add a test to ensure a log was created & is the expected format - await logUpdatedProjects(publicOrgId, branchUpdated, logFile); + res.processedTargets += response.processedTargets; + res.meta.projects.updated.push(...response.meta.projects.updated); + res.meta.projects.failed.push(...response.meta.projects.failed); }, { concurrency: 3 }, ); - return res; + + let logFile = UPDATED_PROJECTS_LOG_NAME; + try { + logFile = path.resolve(getLoggingPath(), UPDATED_PROJECTS_LOG_NAME); + } catch (e) { + console.warn(e.message); + } + let failedLogFile = FAILED_UPDATE_PROJECTS_LOG_NAME; + try { + failedLogFile = path.resolve( + getLoggingPath(), + FAILED_UPDATE_PROJECTS_LOG_NAME, + ); + } catch (e) { + console.warn(e.message); + } + return { ...res, fileName: logFile, failedFileName: failedLogFile }; } export async function updateTargets( @@ -103,71 +156,62 @@ export async function updateTargets( orgId: string, targets: SnykTarget[], dryRun = false, + host?: string, ): Promise<{ processedTargets: number; meta: { projects: { - branchUpdated: string[]; + updated: ProjectUpdate[]; + failed: ProjectUpdateFailure[]; }; }; }> { let processedTargets = 0; - const updated: string[] = []; + const updatedProjects: ProjectUpdate[] = []; + const failedProjects: ProjectUpdateFailure[] = []; + + const loggingPath = getLoggingPath(); await pMap( targets, async (target: SnykTarget) => { try { - const filters = { targetId: target.id, limit: 100 }; - debug(`Listing projects for target ${target.attributes.displayName}`); - const { projects } = await listProjects(requestManager, orgId, filters); - debug(`Syncing projects for target ${target.attributes.displayName}`); - const { updatedProjects } = await syncAllProjects( + const { updated, failed } = await syncProjectsForTarget( requestManager, orgId, - projects, + target, dryRun, + host, ); - updated.push(...updatedProjects); + updatedProjects.push(...updated); + failedProjects.push(...failed); processedTargets += 1; + + if (updated.length) { + await logUpdatedProjects(orgId, updated); + } + if (failed.length) { + await logFailedToUpdateProjects(orgId, failed); + } } catch (e) { debug(e); + const errorMessage: string = e.message; console.warn( - `Failed to sync target ${target.attributes.displayName}. ERROR: ${e.message}`, + `Failed to sync target ${target.attributes.displayName}. ERROR: ${errorMessage}`, ); + await logFailedSync(orgId, target, errorMessage, loggingPath); } }, - { concurrency: 10 }, + { concurrency: 20 }, ); return { processedTargets, // TODO: collect failed targets & log them with reason? meta: { projects: { - branchUpdated: updated, + updated: updatedProjects, + failed: failedProjects, }, }, }; } - -async function syncAllProjects( - requestManager: requestsManager, - orgId: string, - projects: SnykProject[], - dryRun = false, -): Promise<{ updatedProjects: string[] }> { - const updatedProjects: string[] = []; - await pMap(projects, async (project) => { - const { updated } = await updateProjectForTarget( - requestManager, - orgId, - project, - dryRun, - ); - if (updated) { - updatedProjects.push(project.id); - } - }); - - return { updatedProjects }; -} diff --git a/src/scripts/sync/sync-projects-per-target.ts b/src/scripts/sync/sync-projects-per-target.ts index a49bbe07..d2a6359c 100644 --- a/src/scripts/sync/sync-projects-per-target.ts +++ b/src/scripts/sync/sync-projects-per-target.ts @@ -3,9 +3,11 @@ import * as debugLib from 'debug'; import { getGithubReposDefaultBranch } from '../../lib/source-handlers/github'; import { updateBranch } from '../../lib/project/update-branch'; -import type { SnykProject, Target } from '../../lib/types'; +import type { SnykProject, SnykTarget, Target } from '../../lib/types'; import { SupportedIntegrationTypesUpdateProject } from '../../lib/types'; import { targetGenerators } from '../generate-imported-targets-from-snyk'; +import { listProjects } from '../../lib'; +import pMap = require('p-map'); const debug = debugLib('snyk:sync-projects-per-target'); export function getBranchGenerator( @@ -18,36 +20,127 @@ export function getBranchGenerator( return getDefaultBranchGenerators[origin]; } -export async function updateProjectForTarget( +export async function syncProjectsForTarget( requestManager: requestsManager, orgId: string, - project: SnykProject, - dryRun = false, // TODO: add a test for this function + this param -): Promise<{ updated: boolean }> { - let defaultBranch; - const origin = project.origin as SupportedIntegrationTypesUpdateProject; + target: SnykTarget, + dryRun = false, + host?: string, +): Promise<{ updated: ProjectUpdate[]; failed: ProjectUpdateFailure[] }> { + debug(`Listing projects for target ${target.attributes.displayName}`); + const { projects } = await listProjects(requestManager, orgId, { + targetId: target.id, + limit: 100, + }); + if (projects.length < 1) { + throw new Error( + `No projects returned to process for target: ${target.attributes.displayName} orgId: ${orgId}`, + ); + } + debug(`Syncing projects for target ${target.attributes.displayName}`); + + // update branches + const { updated, failed } = await bulkUpdateProjectsBranch( + requestManager, + orgId, + projects, + dryRun, + host, + ); + // add target info for logs + return { + updated: updated.map((t) => ({ ...t, target })), + failed: failed.map((t) => ({ ...t, target })), + }; +} + +export interface ProjectUpdate { + projectPublicId: string; + type: 'branch'; + from: string; + to: string; + dryRun: boolean; + target?: SnykTarget; +} +export interface ProjectUpdateFailure extends ProjectUpdate { + errorMessage: string; +} + +export async function bulkUpdateProjectsBranch( + requestManager: requestsManager, + orgId: string, + projects: SnykProject[], + dryRun = false, + host?: string, +): Promise<{ updated: ProjectUpdate[]; failed: ProjectUpdateFailure[] }> { + const updatedProjects: ProjectUpdate[] = []; + const failedProjects: ProjectUpdateFailure[] = []; + + let defaultBranch: string; + const origin = projects[0].origin as SupportedIntegrationTypesUpdateProject; try { - const target = targetGenerators[origin](project); - defaultBranch = await getBranchGenerator(origin)(target); + const target = targetGenerators[origin](projects[0]); + debug(`Getting default branch via ${origin} for ${projects[0].name}`); + defaultBranch = await getBranchGenerator(origin)(target, host); } catch (e) { - debug(`Getting default branch failed with error: ${e}`); + debug(e); + const error = `Getting default branch via ${origin} API failed with error: ${e.message}`; + console.error(error); + projects.map((project) => { + failedProjects.push({ + errorMessage: error, + projectPublicId: project.id, + type: 'branch', + from: project.branch!, + to: defaultBranch, + dryRun, + }); + }); + return { updated: updatedProjects, failed: failedProjects }; } - if (!defaultBranch) { - return { updated: false }; - } - - const { updated } = await updateBranch( - requestManager, - { - branch: project.branch!, - projectPublicId: project.id, + await pMap( + projects, + async (project: SnykProject) => { + try { + const { updated } = await updateBranch( + requestManager, + { + branch: project.branch!, + projectPublicId: project.id, + }, + defaultBranch, + orgId, + dryRun, + ); + if (updated) { + updatedProjects.push({ + projectPublicId: project.id, + type: 'branch', + from: project.branch!, + to: defaultBranch, + dryRun, + }); + debug( + `Default branch updated from ${project.branch!} to ${defaultBranch} for ${ + project.id + }`, + ); + } + } catch (e) { + failedProjects.push({ + errorMessage: e.message, + projectPublicId: project.id, + type: 'branch', + from: project.branch!, + to: defaultBranch, + dryRun, + }); + } }, - defaultBranch, - orgId, - dryRun, + { concurrency: 30 }, ); - return { updated }; + return { updated: updatedProjects, failed: failedProjects }; } diff --git a/test/lib/project/compare-branches.test.ts b/test/lib/project/update-branch.test.ts similarity index 92% rename from test/lib/project/compare-branches.test.ts rename to test/lib/project/update-branch.test.ts index b0a52c85..18a86e84 100644 --- a/test/lib/project/compare-branches.test.ts +++ b/test/lib/project/update-branch.test.ts @@ -1,5 +1,5 @@ import { requestsManager } from 'snyk-request-manager'; -import * as compareProject from '../../../src/lib/project/update-branch'; +import * as updateBranch from '../../../src/lib/project/update-branch'; describe('updateBranch', () => { const OLD_ENV = process.env; @@ -51,7 +51,7 @@ describe('updateBranch', () => { status: 200, }); - const res = await compareProject.updateBranch( + const res = await updateBranch.updateBranch( requestManager, { branch: 'main', @@ -69,7 +69,7 @@ describe('updateBranch', () => { status: 200, }); - const res = await compareProject.updateBranch( + const res = await updateBranch.updateBranch( requestManager, { branch: 'main', @@ -84,7 +84,7 @@ describe('updateBranch', () => { }, 5000); it('does not update the project if the branches are the same', async () => { - const res = await compareProject.updateBranch( + const res = await updateBranch.updateBranch( requestManager, { branch: 'main', @@ -101,7 +101,7 @@ describe('updateBranch', () => { .spyOn(requestManager, 'request') .mockResolvedValue({ statusCode: 500, data: {} }); expect(async () => { - await compareProject.updateBranch( + await updateBranch.updateBranch( requestManager, { branch: 'main', diff --git a/test/lib/project/update-project-per-target.test.ts b/test/lib/project/update-project-per-target.test.ts deleted file mode 100644 index 2623ecae..00000000 --- a/test/lib/project/update-project-per-target.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { requestsManager } from 'snyk-request-manager'; -import { updateProjectForTarget } from '../../../src/scripts/sync/sync-projects-per-target'; -import * as github from '../../../src/lib/source-handlers/github'; -import * as projects from '../../../src/lib/api/project'; - -describe('UpdateProject (Github)', () => { - const requestManager = new requestsManager({ - userAgentPrefix: 'snyk-api-import:tests', - }); - let githubSpy: jest.SpyInstance; - let projectsSpy: jest.SpyInstance; - - beforeAll(() => { - githubSpy = jest.spyOn(github, 'getGithubReposDefaultBranch'); - projectsSpy = jest.spyOn(projects, 'updateProject'); - }); - - afterAll(async () => { - jest.restoreAllMocks(); - }, 1000); - - beforeEach(async () => { - jest.clearAllMocks(); - }, 1000); - - it('updates project when default branch changed', async () => { - const defaultBranch = 'newBranch'; - githubSpy.mockImplementation(() => Promise.resolve(defaultBranch)); - projectsSpy.mockImplementation(() => - Promise.resolve({ ...testProject, branch: defaultBranch }), - ); - const testProject = { - name: 'testProject', - id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', - created: '2018-10-29T09:50:54.014Z', - origin: 'github', - type: 'npm', - branch: 'master', - }; - - const res = await updateProjectForTarget( - requestManager, - 'af137b96-6966-46c1-826b-2e79ac49bbxx', - testProject, - ); - expect(githubSpy).toHaveBeenCalledTimes(1); - expect(projectsSpy).toHaveBeenCalledTimes(1); - - expect(res.updated).toBeTruthy(); - }, 5000); - - it('fails to update project, if API call to get default branch fails', async () => { - const defaultBranch = 'newBranch'; - githubSpy.mockImplementation(() => - Promise.reject({ statusCode: 500, message: 'Error' }), - ); - projectsSpy.mockImplementation(() => - Promise.resolve({ ...testProject, branch: defaultBranch }), - ); - const testProject = { - name: 'testProject', - id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', - created: '2018-10-29T09:50:54.014Z', - origin: 'github', - type: 'npm', - branch: 'master', - }; - expect( - updateProjectForTarget( - requestManager, - 'af137b96-6966-46c1-826b-2e79ac49bbxx', - testProject, - ), - ).rejects.toThrow(); - expect(githubSpy).toHaveBeenCalledTimes(1); - expect(projectsSpy).not.toHaveBeenCalled(); - }, 5000); - - it('does nothing if the default branch did no change', async () => { - jest - .spyOn(github, 'getGithubReposDefaultBranch') - .mockResolvedValue('master'); - - const testProject = { - name: 'testProject', - id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', - created: '2018-10-29T09:50:54.014Z', - origin: 'github', - type: 'npm', - branch: 'master', - }; - - const res = await updateProjectForTarget( - requestManager, - 'af137b96-6966-46c1-826b-2e79ac49bbxx', - testProject, - ); - expect(res.updated).toBeFalsy(); - expect(projectsSpy).not.toHaveBeenCalled(); - }, 5000); -}); diff --git a/test/lib/source-handlers/github/github.test.ts b/test/lib/source-handlers/github/github.test.ts index 4639008a..515249b4 100644 --- a/test/lib/source-handlers/github/github.test.ts +++ b/test/lib/source-handlers/github/github.test.ts @@ -62,3 +62,21 @@ describe('listGithubRepos script', () => { }); }, 30000); }); + +describe('isGithubConfigured', () => { + const OLD_ENV = process.env; + + afterEach(async () => { + process.env = { ...OLD_ENV }; + }); + it('correctly configured', async () => { + process.env.GITHUB_TOKEN = process.env.GH_TOKEN; + + const configured = github.isGithubConfigured(); + expect(configured).toBeTruthy(); + }); + it('not configured should throw', async () => { + delete process.env.GITHUB_TOKEN; + expect(() => github.isGithubConfigured()).toThrow(); + }); +}); diff --git a/test/scripts/generate-imported-targets-from-snyk.test.ts b/test/scripts/generate-imported-targets-from-snyk.test.ts index 2e8da76a..7a85ace0 100644 --- a/test/scripts/generate-imported-targets-from-snyk.test.ts +++ b/test/scripts/generate-imported-targets-from-snyk.test.ts @@ -205,6 +205,19 @@ describe('projectToTarget', () => { }); }); + it('succeed to convert Github / Gitlab project name to target with branch in the name', async () => { + const project = { + name: 'lili-snyk/huge-monorepo(main):cockroach/build/builder/Dockerfile', + branch: 'main', + }; + const target = projectToTarget(project); + expect(target).toEqual({ + branch: 'main', + name: 'huge-monorepo', + owner: 'lili-snyk', + }); + }); + it('succeed to convert GCR project name to target', async () => { const project = { name: 'snyk-main/bundle-lock-job:prod', diff --git a/test/scripts/sync/sync-org-projects.test.ts b/test/scripts/sync/sync-org-projects.test.ts index 5d00656b..f8486e29 100644 --- a/test/scripts/sync/sync-org-projects.test.ts +++ b/test/scripts/sync/sync-org-projects.test.ts @@ -5,7 +5,7 @@ import { updateTargets, } from '../../../src/scripts/sync/sync-org-projects'; import type { ProjectsResponse } from '../../../src/lib/api/org'; -import * as updateProjectForTarget from '../../../src/scripts/sync/sync-projects-per-target'; +import type * as syncProjectsForTarget from '../../../src/scripts/sync/sync-projects-per-target'; import type { SnykProject, SnykTarget, @@ -19,16 +19,37 @@ import * as featureFlags from '../../../src/lib/api/feature-flags'; import * as updateProjectsLog from '../../../src/loggers/log-updated-project'; describe('updateTargets', () => { + const OLD_ENV = process.env; + process.env.SNYK_LOG_PATH = './'; + process.env.SNYK_TOKEN = 'dummy'; + process.env.GITHUB_TOKEN = 'dummy'; + const requestManager = new requestsManager({ userAgentPrefix: 'snyk-api-import:tests', }); + let githubSpy: jest.SpyInstance; + let projectsSpy: jest.SpyInstance; - afterEach(() => { + beforeAll(() => { + githubSpy = jest.spyOn(github, 'getGithubReposDefaultBranch'); + projectsSpy = jest.spyOn(projectApi, 'updateProject'); + }, 1000); + + afterAll(async () => { jest.restoreAllMocks(); + }, 1000); + + beforeEach(async () => { + jest.clearAllMocks(); + }, 1000); + + afterEach(() => { + process.env = { ...OLD_ENV }; }); describe('Github', () => { it('updates a projects branch if default branch changed', async () => { + // Arrange const testTarget = [ { attributes: { @@ -52,13 +73,13 @@ describe('updateTargets', () => { }, ]; - const testProjects: ProjectsResponse = { + const projectsAPIResponse: ProjectsResponse = { org: { id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', }, projects: [ { - name: 'testProject', + name: 'snyk/goof:package.json', id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', created: '2018-10-29T09:50:54.014Z', origin: 'github', @@ -69,23 +90,43 @@ describe('updateTargets', () => { }; const orgId = 'af137b96-6966-46c1-826b-2e79ac49bbxx'; + const defaultBranch = 'develop'; + + const updated: syncProjectsForTarget.ProjectUpdate[] = [ + { + projectPublicId: 'af137b96-6966-46c1-826b-2e79ac49bbxx', + from: projectsAPIResponse.projects[0].branch!, + to: defaultBranch, + type: 'branch', + dryRun: false, + }, + ]; + const failed: syncProjectsForTarget.ProjectUpdateFailure[] = []; jest .spyOn(lib, 'listProjects') - .mockImplementation(() => Promise.resolve(testProjects)); - jest - .spyOn(updateProjectForTarget, 'updateProjectForTarget') - .mockImplementation(() => Promise.resolve({ updated: true })); - + .mockImplementation(() => Promise.resolve(projectsAPIResponse)); + githubSpy.mockImplementation(() => Promise.resolve(defaultBranch)); + projectsSpy.mockImplementation(() => + Promise.resolve({ ...projectsAPIResponse, branch: defaultBranch }), + ); + // Act const res = await updateTargets(requestManager, orgId, testTarget); - expect(res.processedTargets).toEqual(1); - expect(res.meta.projects.branchUpdated).toEqual([ - 'af137b96-6966-46c1-826b-2e79ac49bbxx', - ]); - }, 5000); + // Assert + expect(res).toStrictEqual({ + processedTargets: 1, + meta: { + projects: { + failed: failed.map((f) => ({ ...f, target: testTarget[0] })), + updated: updated.map((u) => ({ ...u, target: testTarget[0] })), + }, + }, + }); + }, 10000); it('did not need to update a projects branch', async () => { + // Arrange const testTarget = [ { attributes: { @@ -109,13 +150,13 @@ describe('updateTargets', () => { }, ]; - const testProjects: ProjectsResponse = { + const projectsAPIResponse: ProjectsResponse = { org: { id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', }, projects: [ { - name: 'testProject', + name: 'snyk/goof:package.json', id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', created: '2018-10-29T09:50:54.014Z', origin: 'github', @@ -127,24 +168,37 @@ describe('updateTargets', () => { const orgId = 'af137b96-6966-46c1-826b-2e79ac49bbxx'; + const defaultBranch = projectsAPIResponse.projects[0].branch; + jest .spyOn(lib, 'listProjects') - .mockImplementation(() => Promise.resolve(testProjects)); - jest - .spyOn(updateProjectForTarget, 'updateProjectForTarget') - .mockImplementation(() => Promise.resolve({ updated: false })); + .mockImplementation(() => Promise.resolve(projectsAPIResponse)); + githubSpy.mockImplementation(() => Promise.resolve(defaultBranch)); + projectsSpy.mockImplementation(() => + Promise.resolve({ ...projectsAPIResponse, branch: defaultBranch }), + ); + // Act const res = await updateTargets(requestManager, orgId, testTarget); - expect(res.processedTargets).toEqual(1); - expect(res.meta.projects.branchUpdated).toEqual([]); + // Assert + expect(res).toStrictEqual({ + processedTargets: 1, + meta: { + projects: { + failed: [], + updated: [], + }, + }, + }); }, 5000); it('updates several projects from the same target 1 failed 1 success', async () => { + // Arrange const testTargets = [ { attributes: { - displayName: 'test', + displayName: 'snyk/monorepo', isPrivate: false, origin: 'github', remoteUrl: null, @@ -163,23 +217,23 @@ describe('updateTargets', () => { type: 'target', }, ]; - - const testProjects: ProjectsResponse = { + const orgId = 'af137b96-6966-46c1-826b-2e79ac49bbxx'; + const projectsAPIResponse: ProjectsResponse = { org: { - id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', + id: orgId, }, projects: [ { - name: 'testProject', - id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', + name: 'snyk/monorepo:build.gradle', + id: '3626066d-21a7-424f-b6fc-dc0d222d8e4a', created: '2018-10-29T09:50:54.014Z', origin: 'github', type: 'npm', branch: 'master', }, { - name: 'testProject2', - id: 'af137b96-6966-46c1-826b-2e79ac49aaxx', + name: 'snyk/monorepo(main):package.json', + id: 'f57afea5-8fed-41d8-a8fd-d374c0944b07', created: '2018-10-29T09:50:54.014Z', origin: 'github', type: 'maven', @@ -188,22 +242,52 @@ describe('updateTargets', () => { ], }; - const orgId = 'af137b96-6966-46c1-826b-2e79ac49bbxx'; + const defaultBranch = 'develop'; + const updated: syncProjectsForTarget.ProjectUpdate[] = [ + { + projectPublicId: projectsAPIResponse.projects[0].id, + from: projectsAPIResponse.projects[0].branch!, + to: defaultBranch, + type: 'branch', + dryRun: false, + }, + ]; + const failed: syncProjectsForTarget.ProjectUpdateFailure[] = [ + { + errorMessage: + 'Failed to update project f57afea5-8fed-41d8-a8fd-d374c0944b07 via Snyk API. ERROR: Error', + projectPublicId: projectsAPIResponse.projects[1].id, + from: projectsAPIResponse.projects[1].branch!, + to: defaultBranch, + type: 'branch', + dryRun: false, + }, + ]; jest .spyOn(lib, 'listProjects') - .mockImplementationOnce(() => Promise.resolve(testProjects)); - jest - .spyOn(updateProjectForTarget, 'updateProjectForTarget') - .mockImplementationOnce(() => Promise.resolve({ updated: true })) - .mockImplementationOnce(() => Promise.resolve({ updated: false })); - + .mockImplementation(() => Promise.resolve(projectsAPIResponse)); + githubSpy.mockImplementation(() => Promise.resolve(defaultBranch)); + projectsSpy + .mockImplementationOnce(() => + Promise.resolve({ ...projectsAPIResponse, branch: defaultBranch }), + ) + .mockImplementationOnce(() => + Promise.reject({ statusCode: '404', message: 'Error' }), + ); + // Act const res = await updateTargets(requestManager, orgId, testTargets); - expect(res.processedTargets).toEqual(1); - expect(res.meta.projects.branchUpdated).toEqual([ - 'af137b96-6966-46c1-826b-2e79ac49bbxx', - ]); + // Assert + expect(res).toStrictEqual({ + processedTargets: 1, + meta: { + projects: { + updated: updated.map((u) => ({ ...u, target: testTargets[0] })), + failed: failed.map((f) => ({ ...f, target: testTargets[0] })), + }, + }, + }); }, 5000); }); }); @@ -240,45 +324,24 @@ describe('updateOrgTargets', () => { }); describe('Github', () => { - it('skip unsupported origins', async () => { - const res = await updateOrgTargets('xxx', ['unsupported' as any]); - expect(res).toEqual({ - fileName: expect.stringMatching('/updated-projects.log'), - meta: { - projects: { - branchUpdated: [], - }, - }, - processedTargets: 0, - }); + it('throws if only unsupported origins requested', async () => { + await expect( + updateOrgTargets('xxx', ['unsupported' as any]), + ).rejects.toThrowError( + 'Nothing to sync, stopping. Sync command currently only supports the following sources: github', + ); }); - it('skip an org that uses the customBranch FF', async () => { + it('throws if the organization uses the customBranch FF', async () => { featureFlagsSpy.mockResolvedValue(true); - logUpdatedProjectsSpy.mockResolvedValue(null); - const res = await updateOrgTargets('xxx', ['unsupported' as any]); - expect(res).toEqual({ - fileName: expect.stringMatching('/updated-projects.log'), - meta: { - projects: { - branchUpdated: [], - }, - }, - processedTargets: 0, - }); - }); - - it('throws error if listTargets has API error', async () => { - featureFlagsSpy.mockResolvedValue(false); - listTargetsSpy.mockRejectedValue( - 'Expected a 200 response, instead received:' + - JSON.stringify({ statusCode: 500, message: 'Something went wrong' }), - ); - expect( + await expect( updateOrgTargets('xxx', [ SupportedIntegrationTypesUpdateProject.GITHUB, ]), - ).rejects.toThrowError('Expected a 200 response, instead received'); + ).rejects.toThrowError( + 'Detected custom branches feature. Skipping syncing organization xxx because it is not possible to determine which should be the default branch.', + ); }); + it('skips target if listingProjects has API error', async () => { const targets: SnykTarget[] = [ { @@ -304,16 +367,20 @@ describe('updateOrgTargets', () => { const res = await updateOrgTargets('xxx', [ SupportedIntegrationTypesUpdateProject.GITHUB, ]); - expect(res).toEqual({ + expect(res).toStrictEqual({ + failedFileName: expect.stringMatching('failed-to-update-projects.log'), fileName: expect.stringMatching('/updated-projects.log'), meta: { projects: { - branchUpdated: [], + failed: [], + updated: [], }, }, processedTargets: 0, }); }); + it.todo('github is not configured'); + it.todo('skips extra unsupported source, but finishes supported'); it('skips target & projects error if getting default branch fails', async () => { const targets: SnykTarget[] = [ { @@ -346,132 +413,20 @@ describe('updateOrgTargets', () => { const res = await updateOrgTargets('xxx', [ SupportedIntegrationTypesUpdateProject.GITHUB, ]); - expect(res).toEqual({ + expect(res).toStrictEqual({ + failedFileName: expect.stringMatching('/failed-to-update-projects.log'), fileName: expect.stringMatching('/updated-projects.log'), meta: { projects: { - branchUpdated: [], + updated: [], + failed: [], }, }, processedTargets: 0, }); }); - // TODO: needs more work, updateProject spy is calling real function still. - it('Successfully updated several targets (1 supported, 1 unsupported)', async () => { - const targets: SnykTarget[] = [ - { - attributes: { - displayName: 'snyk/bar', - isPrivate: true, - origin: 'github', - remoteUrl: null, - }, - id: uuid.v4(), - relationships: {} as unknown as SnykTargetRelationships, - type: 'target', - }, - { - attributes: { - displayName: 'snyk/cli', - isPrivate: true, - origin: 'github-enterprise', - remoteUrl: null, - }, - id: uuid.v4(), - relationships: {} as unknown as SnykTargetRelationships, - type: 'target', - }, - ]; - const updatedProjectId = uuid.v4(); - const projects: SnykProject[] = [ - { - name: 'example', - id: updatedProjectId, - created: 'date', - origin: 'github', - type: 'npm', - branch: 'main', - }, - ]; - featureFlagsSpy.mockResolvedValue(false); - listTargetsSpy.mockResolvedValue({ targets }); - listProjectsSpy.mockResolvedValueOnce({ projects }); - listProjectsSpy.mockResolvedValue({ projects: [] }); - logUpdatedProjectsSpy.mockResolvedValue(null); - githubSpy.mockResolvedValue('develop'); - updateProjectSpy.mockResolvedValue(''); - - const res = await updateOrgTargets('xxx', [ - SupportedIntegrationTypesUpdateProject.GITHUB, - ]); - expect(res).toEqual({ - fileName: expect.stringMatching('/updated-projects.log'), - meta: { - projects: { - branchUpdated: [updatedProjectId], - }, - }, - processedTargets: 2, - }); - }); - it('Some projects fail to update in a target', async () => { - const targets: SnykTarget[] = [ - { - attributes: { - displayName: 'snyk/bar', - isPrivate: true, - origin: 'github', - remoteUrl: null, - }, - id: uuid.v4(), - relationships: {} as unknown as SnykTargetRelationships, - type: 'target', - }, - ]; - const updatedProjectId = uuid.v4(); - const projects: SnykProject[] = [ - { - name: 'snyk/bar', - id: updatedProjectId, - created: 'date', - origin: 'github', - type: 'npm', - branch: 'main', - }, - { - name: 'snyk/foo', - id: uuid.v4(), - created: 'date', - origin: 'github', - type: 'yarn', - branch: 'develop', - }, - ]; - featureFlagsSpy.mockResolvedValueOnce(false); - listTargetsSpy.mockResolvedValueOnce({ targets }); - listProjectsSpy.mockResolvedValueOnce({ projects }); - logUpdatedProjectsSpy.mockResolvedValueOnce(null); - githubSpy.mockResolvedValueOnce('develop'); - githubSpy.mockRejectedValueOnce( - 'Failed to get default branch from Github', - ); - updateProjectSpy.mockResolvedValue(''); - - const res = await updateOrgTargets('xxx', [ - SupportedIntegrationTypesUpdateProject.GITHUB, - ]); - expect(res).toEqual({ - fileName: expect.stringMatching('/updated-projects.log'), - meta: { - projects: { - branchUpdated: [updatedProjectId], - }, - }, - processedTargets: 1, - }); - }); - it('Successfully updated several targets', async () => { + it('Successfully updated several targets (dryRun mode)', async () => { const targets: SnykTarget[] = [ { attributes: { @@ -520,96 +475,57 @@ describe('updateOrgTargets', () => { ]; featureFlagsSpy.mockResolvedValueOnce(false); listTargetsSpy.mockResolvedValueOnce({ targets }); - listProjectsSpy.mockResolvedValueOnce({ projects: projectsTarget1 }); - listProjectsSpy.mockResolvedValueOnce({ projects: projectsTarget2 }); + listProjectsSpy + .mockResolvedValueOnce({ projects: projectsTarget1 }) + .mockResolvedValueOnce({ projects: projectsTarget2 }); logUpdatedProjectsSpy.mockResolvedValueOnce(null); - githubSpy.mockResolvedValue('new-branch'); - updateProjectSpy.mockResolvedValue(''); - - const res = await updateOrgTargets('xxx', [ - SupportedIntegrationTypesUpdateProject.GITHUB, - ]); - expect(res).toEqual({ - fileName: expect.stringMatching('/updated-projects.log'), - meta: { - projects: { - branchUpdated: [updatedProjectId1, updatedProjectId2], - }, - }, - processedTargets: 2, - }); - }); - it('Successfully updated several targets (dryRun mode)', async () => { - const targets: SnykTarget[] = [ + const defaultBranch = 'new-branch'; + githubSpy.mockResolvedValue(defaultBranch); + const updated: syncProjectsForTarget.ProjectUpdate[] = [ { - attributes: { - displayName: 'snyk/bar', - isPrivate: true, - origin: 'github', - remoteUrl: null, - }, - id: uuid.v4(), - relationships: {} as unknown as SnykTargetRelationships, - type: 'target', + projectPublicId: updatedProjectId1, + from: projectsTarget1[0].branch!, + to: defaultBranch, + type: 'branch', + dryRun: true, + target: targets[0], }, { - attributes: { - displayName: 'snyk/foo', - isPrivate: false, - origin: 'github', - remoteUrl: null, - }, - id: uuid.v4(), - relationships: {} as unknown as SnykTargetRelationships, - type: 'target', - }, - ]; - const updatedProjectId1 = uuid.v4(); - const updatedProjectId2 = uuid.v4(); - const projectsTarget1: SnykProject[] = [ - { - name: 'snyk/bar', - id: updatedProjectId1, - created: 'date', - origin: 'github', - type: 'npm', - branch: 'main', + projectPublicId: updatedProjectId2, + from: projectsTarget2[0].branch!, + to: defaultBranch, + type: 'branch', + dryRun: true, + target: targets[1], }, ]; - const projectsTarget2: SnykProject[] = [ - { - name: 'snyk/foo', - id: updatedProjectId2, - created: 'date', - origin: 'github', - type: 'yarn', - branch: 'develop', - }, - ]; - featureFlagsSpy.mockResolvedValueOnce(false); - listTargetsSpy.mockResolvedValueOnce({ targets }); - listProjectsSpy.mockResolvedValueOnce({ projects: projectsTarget1 }); - listProjectsSpy.mockResolvedValueOnce({ projects: projectsTarget2 }); - - logUpdatedProjectsSpy.mockResolvedValueOnce(null); - githubSpy.mockResolvedValue('new-branch'); + const failed: syncProjectsForTarget.ProjectUpdateFailure[] = []; + // Act const res = await updateOrgTargets( 'xxx', [SupportedIntegrationTypesUpdateProject.GITHUB], true, ); - expect(res).toEqual({ + // Assert + expect(res).toStrictEqual({ + failedFileName: expect.stringMatching('/failed-to-update-projects.log'), fileName: expect.stringMatching('/updated-projects.log'), meta: { projects: { - branchUpdated: [updatedProjectId1, updatedProjectId2], + updated, + failed, }, }, processedTargets: 2, }); + expect(featureFlagsSpy).toHaveBeenCalledTimes(1); + expect(listTargetsSpy).toHaveBeenCalledTimes(1); + expect(listProjectsSpy).toHaveBeenCalledTimes(2); + expect(githubSpy).toBeCalledTimes(2); expect(updateProjectSpy).not.toHaveBeenCalled(); + expect(logUpdatedProjectsSpy).toHaveBeenCalledTimes(2); }); }); }); diff --git a/test/system/__snapshots__/sync.test.ts.snap b/test/system/__snapshots__/sync.test.ts.snap new file mode 100644 index 00000000..4017e3c4 --- /dev/null +++ b/test/system/__snapshots__/sync.test.ts.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`\`snyk-api-import help <...>\` Shows help text as expected 1`] = ` +"index.js sync + +Sync targets (e.g. repos) and their projects between Snyk and SCM for a given +organization. Actions include: +- updating monitored branch in Snyk to match the default branch from SCM + +Options: + --version Show version number [boolean] + --help Show help [boolean] + --orgPublicId Public id of the organization in Snyk that will be updated + [required] + --sourceUrl Custom base url for the source API that can list organizations + (e.g. Github Enterprise url) + --source List of sources to be synced e.g. Github, Github Enterprise, + Gitlab, Bitbucket Server, Bitbucket Cloud + [required] [choices: \\"github\\"] [default: \\"github\\"] + --dryRun Dry run option. Will create a log file listing the potential + updates [default: false]" +`; diff --git a/test/system/help.test.ts b/test/system/help.test.ts index 77d78e5d..a2419c66 100644 --- a/test/system/help.test.ts +++ b/test/system/help.test.ts @@ -18,33 +18,38 @@ describe('`snyk-api-import help <...>`', () => { expect(err).toBeNull(); expect(stderr).toEqual(''); expect(stdout.trim()).toMatchInlineSnapshot(` -"index.js + "index.js -Kick off API powered import + Kick off API powered import -Commands: - index.js import Kick off API powered import [default] [aliases: i] - index.js import:data Generate data required for targets to be imported via - API to create Snyk projects. + Commands: + index.js import Kick off API powered import [default] [aliases: i] + index.js import:data Generate data required for targets to be imported via + API to create Snyk projects. - index.js list:imported List all targets imported in Snyk for a given group & - source type. An analysis is performed on all current - organizations and their projects to generate this. The - generated file can be used to skip previously imported - targets when running the \`import\` command - index.js orgs:create Create the organizations in Snyk based on data file - generated with \`orgs:data\` command. Output generates - key data for created and existing organizations for - use to generate project import data. - index.js orgs:data Generate data required for Orgs to be created via API - by mirroring a given source. + index.js list:imported List all targets imported in Snyk for a given group & + source type. An analysis is performed on all current + organizations and their projects to generate this. The + generated file can be used to skip previously imported + targets when running the \`import\` command + index.js orgs:create Create the organizations in Snyk based on data file + generated with \`orgs:data\` command. Output generates + key data for created and existing organizations for + use to generate project import data. + index.js orgs:data Generate data required for Orgs to be created via API + by mirroring a given source. + index.js sync Sync targets (e.g. repos) and their projects between + Snyk and SCM for a given organization. Actions + include: + - updating monitored branch in Snyk to match the + default branch from SCM -Options: - --version Show version number [boolean] - --help Show help [boolean] - --file Path to json file that contains the targets to be imported" -`); + Options: + --version Show version number [boolean] + --help Show help [boolean] + --file Path to json file that contains the targets to be imported" + `); }).on('exit', (code) => { expect(code).toEqual(0); done(); diff --git a/test/system/sync.test.ts b/test/system/sync.test.ts new file mode 100644 index 00000000..54650fd2 --- /dev/null +++ b/test/system/sync.test.ts @@ -0,0 +1,165 @@ +import { exec } from 'child_process'; +import * as path from 'path'; +const main = './dist/index.js'.replace(/\//g, path.sep); + +jest.unmock('snyk-request-manager'); +jest.requireActual('snyk-request-manager'); + +describe('`snyk-api-import help <...>`', () => { + const OLD_ENV = process.env; + const ORG_ID = process.env.TEST_ORG_ID as string; + + afterAll(() => { + process.env = { ...OLD_ENV }; + }); + it('Shows help text as expected', (done) => { + exec(`node ${main} sync help`, (err, stdout) => { + if (err) { + throw err; + } + expect(err).toBeNull(); + expect(stdout.trim()).toMatchSnapshot(); + }).on('exit', (code) => { + expect(code).toEqual(0); + done(); + }); + }); + + it('Fails when given a bad/non-existent org ID `123456789`', (done) => { + const logPath = path.resolve(__dirname + '/fixtures'); + + exec( + `node ${main} sync --orgPublicId=123456789 --source=github`, + { + env: { + PATH: process.env.PATH, + SNYK_TOKEN: process.env.SNYK_TOKEN_TEST, + SNYK_API: process.env.SNYK_API_TEST, + SNYK_LOG_PATH: logPath, + }, + }, + (err, stdout, stderr) => { + expect(stderr).toMatchInlineSnapshot(` + "ERROR! Failed to sync organization. Try running with \`DEBUG=snyk* for more info\`. + ERROR: Org 123456789 was not found or you may not have the correct permissions to access the org + " + `); + expect(err!.message).toMatchInlineSnapshot(` + "Command failed: node ./dist/index.js sync --orgPublicId=123456789 --source=github + ERROR! Failed to sync organization. Try running with \`DEBUG=snyk* for more info\`. + ERROR: Org 123456789 was not found or you may not have the correct permissions to access the org + " + `); + expect(stdout).toEqual(''); + }, + ).on('exit', (code) => { + expect(code).toEqual(1); + done(); + }); + }, 40000); + + it('Throws an error for an unsupported SCM like Bitbucket Server', (done) => { + const logPath = path.resolve(__dirname); + + exec( + `node ${main} sync --orgPublicId=123456789 --source=bitbucket-server --sourceUrl=somewhere.com`, + { + env: { + PATH: process.env.PATH, + SNYK_TOKEN: process.env.SNYK_TOKEN_TEST, + SNYK_API: process.env.SNYK_API_TEST, + SNYK_LOG_PATH: logPath, + }, + }, + (err, stdout, stderr) => { + expect(stderr).toMatchInlineSnapshot(` + "index.js sync + + Sync targets (e.g. repos) and their projects between Snyk and SCM for a given + organization. Actions include: + - updating monitored branch in Snyk to match the default branch from SCM + + Options: + --version Show version number [boolean] + --help Show help [boolean] + --orgPublicId Public id of the organization in Snyk that will be updated + [required] + --sourceUrl Custom base url for the source API that can list organizations + (e.g. Github Enterprise url) + --source List of sources to be synced e.g. Github, Github Enterprise, + Gitlab, Bitbucket Server, Bitbucket Cloud + [required] [choices: \\"github\\"] [default: \\"github\\"] + --dryRun Dry run option. Will create a log file listing the potential + updates [default: false] + + Invalid values: + Argument: source, Given: \\"bitbucket-server\\", Choices: \\"github\\" + " + `); + expect(err!.message).toMatchInlineSnapshot(` + "Command failed: node ./dist/index.js sync --orgPublicId=123456789 --source=bitbucket-server --sourceUrl=somewhere.com + index.js sync + + Sync targets (e.g. repos) and their projects between Snyk and SCM for a given + organization. Actions include: + - updating monitored branch in Snyk to match the default branch from SCM + + Options: + --version Show version number [boolean] + --help Show help [boolean] + --orgPublicId Public id of the organization in Snyk that will be updated + [required] + --sourceUrl Custom base url for the source API that can list organizations + (e.g. Github Enterprise url) + --source List of sources to be synced e.g. Github, Github Enterprise, + Gitlab, Bitbucket Server, Bitbucket Cloud + [required] [choices: \\"github\\"] [default: \\"github\\"] + --dryRun Dry run option. Will create a log file listing the potential + updates [default: false] + + Invalid values: + Argument: source, Given: \\"bitbucket-server\\", Choices: \\"github\\" + " + `); + expect(stdout).toEqual(''); + }, + ).on('exit', (code) => { + expect(code).toEqual(1); + done(); + }); + }, 40000); + + it('Throws an error for if source auth details are not provided', (done) => { + const logPath = path.resolve(__dirname); + delete process.env.GITHUB_TOKEN; + exec( + `node ${main} sync --orgPublicId=${ORG_ID} --source=github`, + { + env: { + PATH: process.env.PATH, + SNYK_TOKEN: process.env.SNYK_TOKEN_TEST, + SNYK_API: process.env.SNYK_API_TEST, + SNYK_LOG_PATH: logPath, + }, + }, + (err, stdout, stderr) => { + expect(stderr).toMatchInlineSnapshot(` + "ERROR! Failed to sync organization. Try running with \`DEBUG=snyk* for more info\`. + ERROR: Please set the GITHUB_TOKEN e.g. export GITHUB_TOKEN='mypersonalaccesstoken123' + " + `); + expect(err!.message).toMatchInlineSnapshot(` + "Command failed: node ./dist/index.js sync --orgPublicId=74e2f385-a54f-491e-9034-76c53e72927a --source=github + ERROR! Failed to sync organization. Try running with \`DEBUG=snyk* for more info\`. + ERROR: Please set the GITHUB_TOKEN e.g. export GITHUB_TOKEN='mypersonalaccesstoken123' + " + `); + expect(stdout).toEqual(''); + }, + ).on('exit', (code) => { + expect(code).toEqual(1); + done(); + }); + }, 40000); + it.todo('Success synced'); +});