UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

601 lines (552 loc) 18.9 kB
'use strict' const cp = require('child_process') const os = require('os') const path = require('path') const fs = require('fs') const log = require('../../log') const { GIT_COMMIT_SHA, GIT_BRANCH, GIT_REPOSITORY_URL, GIT_TAG, GIT_COMMIT_MESSAGE, GIT_COMMIT_COMMITTER_DATE, GIT_COMMIT_COMMITTER_EMAIL, GIT_COMMIT_COMMITTER_NAME, GIT_COMMIT_AUTHOR_DATE, GIT_COMMIT_AUTHOR_EMAIL, GIT_COMMIT_AUTHOR_NAME, CI_WORKSPACE_PATH, GIT_COMMIT_HEAD_AUTHOR_DATE, GIT_COMMIT_HEAD_AUTHOR_EMAIL, GIT_COMMIT_HEAD_AUTHOR_NAME, GIT_COMMIT_HEAD_COMMITTER_DATE, GIT_COMMIT_HEAD_COMMITTER_EMAIL, GIT_COMMIT_HEAD_COMMITTER_NAME, GIT_COMMIT_HEAD_MESSAGE } = require('./tags') const { incrementCountMetric, distributionMetric, TELEMETRY_GIT_COMMAND, TELEMETRY_GIT_COMMAND_MS, TELEMETRY_GIT_COMMAND_ERRORS } = require('../../ci-visibility/telemetry') const { filterSensitiveInfoFromRepository } = require('./url') const { storage } = require('../../../../datadog-core') const GIT_REV_LIST_MAX_BUFFER = 12 * 1024 * 1024 // 12MB function sanitizedExec ( cmd, flags, operationMetric, durationMetric, errorMetric, shouldTrim = true ) { const store = storage('legacy').getStore() storage('legacy').enterWith({ noop: true }) let startTime if (operationMetric) { incrementCountMetric(operationMetric.name, operationMetric.tags) } if (durationMetric) { startTime = Date.now() } try { let result = cp.execFileSync(cmd, flags, { stdio: 'pipe' }).toString() if (shouldTrim) { result = result.replaceAll(/(\r\n|\n|\r)/gm, '') } if (durationMetric) { distributionMetric(durationMetric.name, durationMetric.tags, Date.now() - startTime) } return result } catch (err) { if (errorMetric) { incrementCountMetric(errorMetric.name, { ...errorMetric.tags, errorType: err.code, exitCode: err.status || err.errno }) } log.error('Git plugin error executing command', err) return '' } finally { storage('legacy').enterWith(store) } } function isDirectory (path) { try { const stats = fs.statSync(path) return stats.isDirectory() } catch { return false } } function isGitAvailable () { const isWindows = os.platform() === 'win32' const command = isWindows ? 'where' : 'which' try { cp.execFileSync(command, ['git'], { stdio: 'pipe' }) return true } catch { incrementCountMetric(TELEMETRY_GIT_COMMAND_ERRORS, { command: 'check_git', exitCode: 'missing' }) return false } } function isShallowRepository () { return sanitizedExec( 'git', ['rev-parse', '--is-shallow-repository'], { name: TELEMETRY_GIT_COMMAND, tags: { command: 'check_shallow' } }, { name: TELEMETRY_GIT_COMMAND_MS, tags: { command: 'check_shallow' } }, { name: TELEMETRY_GIT_COMMAND_ERRORS, tags: { command: 'check_shallow' } } ) === 'true' } function getGitVersion () { const gitVersionString = sanitizedExec('git', ['version']) const gitVersionMatches = gitVersionString.match(/git version (\d+)\.(\d+)\.(\d+)/) try { return { major: Number.parseInt(gitVersionMatches[1]), minor: Number.parseInt(gitVersionMatches[2]), patch: Number.parseInt(gitVersionMatches[3]) } } catch { return null } } function unshallowRepository (parentOnly = false) { const gitVersion = getGitVersion() if (!gitVersion) { log.warn('Git version could not be extracted, so git unshallow will not proceed') return } if (gitVersion.major < 2 || (gitVersion.major === 2 && gitVersion.minor < 27)) { log.warn('Git version is <2.27, so git unshallow will not proceed') return } const defaultRemoteName = sanitizedExec('git', ['config', '--default', 'origin', '--get', 'clone.defaultRemoteName']) const revParseHead = sanitizedExec('git', ['rev-parse', 'HEAD']) const baseGitOptions = [ 'fetch', parentOnly ? '--deepen=1' : '--shallow-since="1 month ago"', '--update-shallow', '--filter=blob:none', '--recurse-submodules=no', defaultRemoteName ] incrementCountMetric(TELEMETRY_GIT_COMMAND, { command: 'unshallow' }) const start = Date.now() try { cp.execFileSync('git', [ ...baseGitOptions, revParseHead ], { stdio: 'pipe' }) } catch (err) { // If the local HEAD is a commit that has not been pushed to the remote, the above command will fail. log.error('Git plugin error executing git command', err) incrementCountMetric( TELEMETRY_GIT_COMMAND_ERRORS, { command: 'unshallow', errorType: err.code, exitCode: err.status || err.errno } ) const upstreamRemote = sanitizedExec('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']) try { cp.execFileSync('git', [ ...baseGitOptions, upstreamRemote ], { stdio: 'pipe' }) } catch (err) { // If the CI is working on a detached HEAD or branch tracking hasn’t been set up, the above command will fail. log.error('Git plugin error executing fallback git command', err) incrementCountMetric( TELEMETRY_GIT_COMMAND_ERRORS, { command: 'unshallow', errorType: err.code, exitCode: err.status || err.errno } ) // We use sanitizedExec here because if this last option fails, we'll give up. sanitizedExec( 'git', baseGitOptions, null, null, { name: TELEMETRY_GIT_COMMAND_ERRORS, tags: { command: 'unshallow' } } // we log the error in sanitizedExec ) } } distributionMetric(TELEMETRY_GIT_COMMAND_MS, { command: 'unshallow' }, Date.now() - start) } function getRepositoryUrl () { return sanitizedExec( 'git', ['config', '--get', 'remote.origin.url'], { name: TELEMETRY_GIT_COMMAND, tags: { command: 'get_repository' } }, { name: TELEMETRY_GIT_COMMAND_MS, tags: { command: 'get_repository' } }, { name: TELEMETRY_GIT_COMMAND_ERRORS, tags: { command: 'get_repository' } } ) } function getLatestCommits () { incrementCountMetric(TELEMETRY_GIT_COMMAND, { command: 'get_local_commits' }) const startTime = Date.now() try { const result = cp.execFileSync('git', ['log', '--format=%H', '-n 1000', '--since="1 month ago"'], { stdio: 'pipe' }) .toString() .split('\n') .filter(Boolean) distributionMetric(TELEMETRY_GIT_COMMAND_MS, { command: 'get_local_commits' }, Date.now() - startTime) return result } catch (err) { log.error('Get latest commits failed: %s', err.message) incrementCountMetric( TELEMETRY_GIT_COMMAND_ERRORS, { command: 'get_local_commits', errorType: err.status } ) return [] } } function getGitDiff (baseCommit, targetCommit) { const flags = ['diff', '-U0', '--word-diff=porcelain', baseCommit] if (targetCommit) { flags.push(targetCommit) } return sanitizedExec( 'git', flags, { name: TELEMETRY_GIT_COMMAND, tags: { command: 'diff' } }, { name: TELEMETRY_GIT_COMMAND_MS, tags: { command: 'diff' } }, { name: TELEMETRY_GIT_COMMAND_ERRORS, tags: { command: 'diff' } }, false // important not to trim or we'll lose the line breaks which we need to detect impacted tests ) } function getGitRemoteName () { const upstreamRemote = sanitizedExec( 'git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { name: TELEMETRY_GIT_COMMAND, tags: { command: 'get_remote_name' } }, { name: TELEMETRY_GIT_COMMAND_MS, tags: { command: 'get_remote_name' } }, { name: TELEMETRY_GIT_COMMAND_ERRORS, tags: { command: 'get_remote_name' } } ) if (upstreamRemote) { return upstreamRemote.split('/')[0] } const remotes = sanitizedExec( 'git', ['remote'], { name: TELEMETRY_GIT_COMMAND, tags: { command: 'get_remote_name' } }, { name: TELEMETRY_GIT_COMMAND_MS, tags: { command: 'get_remote_name' } }, { name: TELEMETRY_GIT_COMMAND_ERRORS, tags: { command: 'get_remote_name' } }, false ) return remotes.split('\n')[0] || 'origin' } function getSourceBranch () { return sanitizedExec( 'git', ['rev-parse', '--abbrev-ref', 'HEAD'], { name: TELEMETRY_GIT_COMMAND, tags: { command: 'get_source_branch' } }, { name: TELEMETRY_GIT_COMMAND_MS, tags: { command: 'get_source_branch' } }, { name: TELEMETRY_GIT_COMMAND_ERRORS, tags: { command: 'get_source_branch' } } ) } function checkAndFetchBranch (branch, remoteName) { try { // `git show-ref --verify --quiet refs/remotes/${remoteName}/${branch}` will exit 0 if the branch exists // Otherwise it will exit 1 cp.execFileSync( 'git', ['show-ref', '--verify', '--quiet', `refs/remotes/${remoteName}/${branch}`], { stdio: 'pipe' } ) // branch exists locally, so we finish } catch { // branch does not exist locally, so we will check the remote try { // IMPORTANT: we use timeouts because these commands hang if the branch can't be found // `git ls-remote --heads origin my-branch` will exit 0 even if the branch doesn't exist. // The piece of information we need is whether the command outputs anything. // `git ls-remote --heads origin my-branch` could exit an error code if the remote does not exist. const remoteHeads = cp.execFileSync( 'git', ['ls-remote', '--heads', remoteName, branch], { stdio: 'pipe', timeout: 2000 } ) if (remoteHeads) { // branch exists, so we'll fetch it cp.execFileSync( 'git', ['fetch', '--depth', '1', remoteName, branch], { stdio: 'pipe', timeout: 5000 } ) } } catch (e) { // branch does not exist or couldn't be fetched, so we can't do anything log.error('Git plugin error checking and fetching branch', e) } } } function getLocalBranches (remoteName) { const localBranches = sanitizedExec( 'git', ['for-each-ref', '--format=%(refname:short)', `refs/remotes/${remoteName}`], { name: TELEMETRY_GIT_COMMAND, tags: { command: 'get_local_branches' } }, { name: TELEMETRY_GIT_COMMAND_MS, tags: { command: 'get_local_branches' } }, { name: TELEMETRY_GIT_COMMAND_ERRORS, tags: { command: 'get_local_branches' } }, false ) try { return localBranches.split('\n').filter(Boolean) } catch { return [] } } function getMergeBase (baseBranch, sourceBranch) { return sanitizedExec( 'git', ['merge-base', baseBranch, sourceBranch], { name: TELEMETRY_GIT_COMMAND, tags: { command: 'get_merge_base' } }, { name: TELEMETRY_GIT_COMMAND_MS, tags: { command: 'get_merge_base' } }, { name: TELEMETRY_GIT_COMMAND_ERRORS, tags: { command: 'get_merge_base' } } ) } function getCounts (sourceBranch, candidateBranch) { const counts = sanitizedExec( 'git', ['rev-list', '--left-right', '--count', `${candidateBranch}...${sourceBranch}`], { name: TELEMETRY_GIT_COMMAND, tags: { command: 'get_counts' } }, { name: TELEMETRY_GIT_COMMAND_MS, tags: { command: 'get_counts' } }, { name: TELEMETRY_GIT_COMMAND_ERRORS, tags: { command: 'get_counts' } } ) try { if (!counts) { return { behind: null, ahead: null } } const [behind, ahead] = counts.split(/\s+/).map(Number) return { behind, ahead } } catch { return { behind: null, ahead: null } } } function getCommitsRevList (commitsToExclude, commitsToInclude) { let result = null const commitsToExcludeString = commitsToExclude.map(commit => `^${commit}`) incrementCountMetric(TELEMETRY_GIT_COMMAND, { command: 'get_objects' }) const startTime = Date.now() try { result = cp.execFileSync( 'git', [ 'rev-list', '--objects', '--no-object-names', '--filter=blob:none', '--since="1 month ago"', ...commitsToExcludeString, ...commitsToInclude ], { stdio: 'pipe', maxBuffer: GIT_REV_LIST_MAX_BUFFER }) .toString() .split('\n') .filter(Boolean) } catch (err) { log.error('Get commits to upload failed: %s', err.message) incrementCountMetric( TELEMETRY_GIT_COMMAND_ERRORS, { command: 'get_objects', errorType: err.code, exitCode: err.status || err.errno } // err.status might be null ) } distributionMetric(TELEMETRY_GIT_COMMAND_MS, { command: 'get_objects' }, Date.now() - startTime) return result } function generatePackFilesForCommits (commitsToUpload) { let result = [] const tmpFolder = os.tmpdir() if (!isDirectory(tmpFolder)) { // TODO: Do we need the stack trace for this error? If not, just log the string log.error(new Error('Provided path to generate packfiles is not a directory')) return [] } const randomPrefix = String(Math.floor(Math.random() * 10_000)) const temporaryPath = path.join(tmpFolder, randomPrefix) const cwdPath = path.join(process.cwd(), randomPrefix) incrementCountMetric(TELEMETRY_GIT_COMMAND, { command: 'pack_objects' }) const startTime = Date.now() // Generates pack files to upload and // returns the ordered list of packfiles' paths function execGitPackObjects (targetPath) { return cp.execFileSync( 'git', [ 'pack-objects', '--compression=9', '--max-pack-size=3m', targetPath ], { stdio: 'pipe', input: commitsToUpload.join('\n') } ).toString().split('\n').filter(Boolean).map(commit => `${targetPath}-${commit}.pack`) } try { result = execGitPackObjects(temporaryPath) } catch (err) { log.error('Git plugin error executing git pack-objects command', err) incrementCountMetric( TELEMETRY_GIT_COMMAND_ERRORS, { command: 'pack_objects', exitCode: err.status || err.errno, errorType: err.code } ) /** * The generation of pack files in the temporary folder (from `os.tmpdir()`) * sometimes fails in certain CI setups with the error message * `unable to rename temporary pack file: Invalid cross-device link`. * The reason why is unclear. * * A workaround is to attempt to generate the pack files in `process.cwd()`. * While this works most of the times, it's not ideal since it affects the git status. * This workaround is intended to be temporary. * * TODO: fix issue and remove workaround. */ try { result = execGitPackObjects(cwdPath) } catch (err) { log.error('Git plugin error executing fallback git pack-objects command', err) incrementCountMetric( TELEMETRY_GIT_COMMAND_ERRORS, { command: 'pack_objects', exitCode: err.status || err.errno, errorType: err.code } ) } } distributionMetric(TELEMETRY_GIT_COMMAND_MS, { command: 'pack_objects' }, Date.now() - startTime) return result } // If there is ciMetadata, it takes precedence. function getGitMetadata (ciMetadata) { const { commitSHA, branch, repositoryUrl, tag, commitMessage, authorName: ciAuthorName, authorEmail: ciAuthorEmail, ciWorkspacePath, headCommitSha } = ciMetadata // With stdio: 'pipe', errors in this command will not be output to the parent process, // so if `git` is not present in the env, we won't show a warning to the user. const [ authorName, authorEmail, authorDate, committerName, committerEmail, committerDate ] = sanitizedExec('git', ['show', '-s', '--format=%an,%ae,%aI,%cn,%ce,%cI']).split(',') const tags = { [GIT_COMMIT_MESSAGE]: commitMessage || sanitizedExec('git', ['show', '-s', '--format=%B'], null, null, null, false), [GIT_BRANCH]: branch || sanitizedExec('git', ['rev-parse', '--abbrev-ref', 'HEAD']), [GIT_COMMIT_SHA]: commitSHA || sanitizedExec('git', ['rev-parse', 'HEAD']), [CI_WORKSPACE_PATH]: ciWorkspacePath || sanitizedExec('git', ['rev-parse', '--show-toplevel']), } if (headCommitSha) { if (isShallowRepository()) { fetchHeadCommitSha(headCommitSha) } const [ gitHeadCommitSha, headAuthorDate, headAuthorName, headAuthorEmail, headCommitterDate, headCommitterName, headCommitterEmail, headCommitMessage ] = sanitizedExec( 'git', [ 'show', '-s', '--format=\'%H","%aI","%an","%ae","%cI","%cn","%ce","%B\'', headCommitSha ], null, null, null, false ).split('","') if (gitHeadCommitSha) { tags[GIT_COMMIT_HEAD_AUTHOR_DATE] = headAuthorDate tags[GIT_COMMIT_HEAD_AUTHOR_EMAIL] = headAuthorEmail tags[GIT_COMMIT_HEAD_AUTHOR_NAME] = headAuthorName tags[GIT_COMMIT_HEAD_COMMITTER_DATE] = headCommitterDate tags[GIT_COMMIT_HEAD_COMMITTER_EMAIL] = headCommitterEmail tags[GIT_COMMIT_HEAD_COMMITTER_NAME] = headCommitterName tags[GIT_COMMIT_HEAD_MESSAGE] = headCommitMessage } } const entries = [ GIT_REPOSITORY_URL, filterSensitiveInfoFromRepository(repositoryUrl || sanitizedExec('git', ['ls-remote', '--get-url'])), GIT_COMMIT_AUTHOR_DATE, authorDate, GIT_COMMIT_AUTHOR_NAME, ciAuthorName || authorName, GIT_COMMIT_AUTHOR_EMAIL, ciAuthorEmail || authorEmail, GIT_COMMIT_COMMITTER_DATE, committerDate, GIT_COMMIT_COMMITTER_NAME, committerName, GIT_COMMIT_COMMITTER_EMAIL, committerEmail, GIT_TAG, tag ] for (let i = 0; i < entries.length; i += 2) { const value = entries[i + 1] if (value) { tags[entries[i]] = value } } return tags } function getGitInformationDiscrepancy () { const gitRepositoryUrl = getRepositoryUrl() const gitCommitSHA = sanitizedExec( 'git', ['rev-parse', 'HEAD'], { name: TELEMETRY_GIT_COMMAND, tags: { command: 'get_commit_sha' } }, { name: TELEMETRY_GIT_COMMAND_MS, tags: { command: 'get_commit_sha' } }, { name: TELEMETRY_GIT_COMMAND_ERRORS, tags: { command: 'get_commit_sha' } } ) return { gitRepositoryUrl, gitCommitSHA } } function fetchHeadCommitSha (headSha) { const remoteName = getGitRemoteName() sanitizedExec( 'git', [ 'fetch', '--update-shallow', '--filter=blob:none', '--recurse-submodules=no', '--no-write-fetch-head', remoteName, headSha ], { name: TELEMETRY_GIT_COMMAND, tags: { command: 'fetch_head_commit_sha' } }, { name: TELEMETRY_GIT_COMMAND_MS, tags: { command: 'fetch_head_commit_sha' } }, { name: TELEMETRY_GIT_COMMAND_ERRORS, tags: { command: 'fetch_head_commit_sha' } } ) } module.exports = { getGitMetadata, getLatestCommits, getRepositoryUrl, generatePackFilesForCommits, getCommitsRevList, GIT_REV_LIST_MAX_BUFFER, isShallowRepository, unshallowRepository, isGitAvailable, getGitInformationDiscrepancy, getGitDiff, getGitRemoteName, getSourceBranch, checkAndFetchBranch, getLocalBranches, getMergeBase, getCounts, fetchHeadCommitSha }