UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

708 lines (635 loc) 22.8 kB
'use strict' /* eslint-disable no-console, eslint-rules/eslint-process-env */ const path = require('path') const { spawn } = require('child_process') const { cleanupCommandOutputs, prepareCommandOutputs } = require('./command-output-policy') const { getExecutableForSpawn, isEnvExecutable, parseArgv, } = require('./executable') const { sanitizeConsoleText, sanitizeString } = require('./redaction') const { ensureSafeDirectory, writeFileSafely } = require('./safe-files') const INIT_PATH = path.resolve(__dirname, '..', 'init.js') const REGISTER_PATH = path.resolve(__dirname, '..', '..', 'register.js') const VALIDATION_MODE_ENV = '_DD_TEST_OPTIMIZATION_VALIDATION_MODE' const VALIDATION_MANIFEST_ENV = '_DD_TEST_OPTIMIZATION_VALIDATION_MANIFEST_FILE' const VALIDATION_OUTPUT_ENV = '_DD_TEST_OPTIMIZATION_VALIDATION_OUTPUT_DIR' const VALIDATION_CAPTURE_MODE_ENV = '_DD_TEST_OPTIMIZATION_VALIDATION_CAPTURE_MODE' const APM_AGENTLESS_ENV = '_DD_APM_TRACING_AGENTLESS_ENABLED' const VALIDATION_RESERVED_ENV_NAMES = [ 'NODE_OPTIONS', VALIDATION_MANIFEST_ENV, VALIDATION_MODE_ENV, VALIDATION_OUTPUT_ENV, VALIDATION_CAPTURE_MODE_ENV, APM_AGENTLESS_ENV, ] const CLEAN_ENV_ALLOWLIST = new Set([ 'COMSPEC', 'ComSpec', 'HOME', 'LOGNAME', 'PATH', 'Path', 'PATHEXT', 'SHELL', 'SystemRoot', 'TEMP', 'TMP', 'TMPDIR', 'USER', 'USERNAME', 'VOLTA_HOME', 'WINDIR', 'windir', ]) const VALIDATION_SUPPRESSION_ENV = { DD_AGENTLESS_LOG_SUBMISSION_ENABLED: 'false', DD_APPSEC_ENABLED: 'false', DD_CRASHTRACKING_ENABLED: 'false', DD_DATA_STREAMS_ENABLED: 'false', DD_DYNAMIC_INSTRUMENTATION_ENABLED: 'false', DD_EXPERIMENTAL_APPSEC_STANDALONE_ENABLED: 'false', DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED: 'false', DD_HEAP_SNAPSHOT_COUNT: '0', DD_IAST_ENABLED: 'false', DD_INSTRUMENTATION_TELEMETRY_ENABLED: 'false', DD_LLMOBS_ENABLED: 'false', DD_LOGS_OTEL_ENABLED: 'false', DD_METRICS_OTEL_ENABLED: 'false', DD_PROFILING_ENABLED: 'false', DD_REMOTE_CONFIGURATION_ENABLED: 'false', DD_RUNTIME_METRICS_ENABLED: 'false', DD_TRACE_OTEL_ENABLED: 'false', DD_TRACE_SPAN_LEAK_DEBUG: '0', // Offline validation only needs test-cycle events and explicitly configured filesystem inputs. DD_CIVISIBILITY_GIT_UPLOAD_ENABLED: 'false', DD_CIVISIBILITY_GIT_UNSHALLOW_ENABLED: 'false', DD_CIVISIBILITY_IMPACTED_TESTS_DETECTION_ENABLED: 'false', DD_EXPERIMENTAL_TEST_REQUESTS_FS_CACHE: 'false', DD_TEST_FAILED_TEST_REPLAY_ENABLED: 'false', } const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024 const EARLY_STOP_KILL_GRACE_MS = 500 const TIMEOUT_KILL_GRACE_MS = 5000 const TIMEOUT_FINALIZE_GRACE_MS = 1000 function runCommand (command, options = {}) { const { env = {}, envMode = 'inherit', outDir, label, repositoryRoot, requireExecutableApproval = false, stopWhen, verbose = false, } = options const artifactRoot = options.artifactRoot || path.dirname(outDir) const startedAt = Date.now() const { maxOutputBytes, timeoutFinalizeGraceMs, timeoutKillGraceMs, timeoutMs, } = getCommandExecutionSettings(command) const result = { label, command: serializeCommand(command), displayCommand: serializeDisplayCommand(command), commandDetails: getCommandDetails(command), cwd: command.cwd, exitCode: null, signal: null, stoppedEarly: false, durationMs: 0, timedOut: false, stdout: '', stdoutTruncated: false, stderr: '', stderrTruncated: false, artifacts: {}, } ensureSafeDirectory(artifactRoot, outDir, 'command artifact directory') try { assertNoInlineValidationEnvOverrides(command, env) } catch (error) { return Promise.reject(error) } const outputStates = prepareCommandOutputs({ command, artifactRoot, outDir, repositoryRoot }) return new Promise((resolve) => { let finalized = false let processGroupCleanupPending = false let pendingCloseResult const childEnv = { ...getBaseEnv(envMode), ...command.env, ...env, } if (command.env?.NODE_OPTIONS && env.NODE_OPTIONS) { childEnv.NODE_OPTIONS = mergeNodeOptions(env.NODE_OPTIONS, command.env.NODE_OPTIONS) } for (const [name, value] of Object.entries(childEnv)) { if (value === undefined) delete childEnv[name] } const useProcessGroup = shouldUseProcessGroup() let child try { const executable = getExecutableForSpawn(command, { requireApproval: requireExecutableApproval }) const argv0 = process.platform === 'win32' ? {} : { argv0: executable.argv0 } child = command.usesShell ? spawn(command.shellCommand, { ...argv0, cwd: command.cwd, detached: useProcessGroup, env: childEnv, shell: executable.path, stdio: ['ignore', 'pipe', 'pipe'], }) : spawn(executable.path, command.argv.slice(1), { ...argv0, cwd: command.cwd, detached: useProcessGroup, env: childEnv, shell: false, stdio: ['ignore', 'pipe', 'pipe'], }) } catch (error) { result.stderr = `${error.stack || error}\n` result.durationMs = Date.now() - startedAt try { result.commandOutputPaths = cleanupCommandOutputs(outputStates) } catch (cleanupError) { result.outputCleanupError = cleanupError?.message || String(cleanupError) } resolve(result) return } if (verbose) { console.log(sanitizeConsoleText( `[test-optimization-validator] running ${label || serializeCommand(command)}` )) } let killTimer let finalizeTimer let stopTimer const timeout = setTimeout(() => { result.timedOut = true processGroupCleanupPending = useProcessGroup signalChild(child, 'SIGTERM', useProcessGroup) killTimer = setTimeout(() => { signalChild(child, 'SIGKILL', useProcessGroup) finishProcessGroupCleanup('SIGKILL') }, timeoutKillGraceMs) }, timeoutMs) if (stopWhen) { stopTimer = setInterval(() => { let shouldStop = false try { shouldStop = stopWhen() } catch {} if (!shouldStop) return clearInterval(stopTimer) result.stoppedEarly = true processGroupCleanupPending = useProcessGroup signalChild(child, 'SIGTERM', useProcessGroup) killTimer = setTimeout(() => { signalChild(child, 'SIGKILL', useProcessGroup) finishProcessGroupCleanup('SIGKILL') }, EARLY_STOP_KILL_GRACE_MS) }, 25) } child.stdout.on('data', chunk => { const capture = appendCapturedOutput(result.stdout, chunk, maxOutputBytes) result.stdout = capture.output result.stdoutTruncated = result.stdoutTruncated || capture.truncated }) child.stderr.on('data', chunk => { const capture = appendCapturedOutput(result.stderr, chunk, maxOutputBytes) result.stderr = capture.output result.stderrTruncated = result.stderrTruncated || capture.truncated }) child.on('error', err => { result.stderr += `${err.stack || err}\n` finalize(null, null) }) child.on('close', (code, signal) => { if (processGroupCleanupPending) { pendingCloseResult = { code, signal } return } finalize(code, signal) }) function finishProcessGroupCleanup (fallbackSignal) { processGroupCleanupPending = false if (pendingCloseResult) { finalize(pendingCloseResult.code, pendingCloseResult.signal || fallbackSignal) return } finalizeTimer = setTimeout(() => finalize(null, fallbackSignal), timeoutFinalizeGraceMs) } function finalize (code, signal) { if (finalized) return finalized = true clearTimeout(timeout) clearTimeout(killTimer) clearTimeout(finalizeTimer) clearInterval(stopTimer) result.exitCode = code result.signal = signal result.durationMs = Date.now() - startedAt try { result.commandOutputPaths = cleanupCommandOutputs(outputStates) } catch (err) { result.outputCleanupError = err && err.message ? err.message : String(err) result.stderr += '\n[test-optimization-validator] could not clean up command outputs: ' + `${result.outputCleanupError}\n` if (result.exitCode === 0) result.exitCode = 1 } result.artifacts.stdout = path.join(outDir, 'stdout.txt') result.artifacts.stderr = path.join(outDir, 'stderr.txt') result.artifacts.command = path.join(outDir, 'command.json') try { writeFileSafely( artifactRoot, result.artifacts.stdout, sanitizeString(formatCapturedOutput(result.stdout, result.stdoutTruncated, maxOutputBytes)), 'command stdout artifact' ) writeFileSafely( artifactRoot, result.artifacts.stderr, sanitizeString(formatCapturedOutput(result.stderr, result.stderrTruncated, maxOutputBytes)), 'command stderr artifact' ) writeFileSafely(artifactRoot, result.artifacts.command, `${JSON.stringify({ command: sanitizeString(result.command), displayCommand: sanitizeString(result.displayCommand), commandDetails: result.commandDetails, cwd: result.cwd, exitCode: result.exitCode, signal: result.signal, durationMs: result.durationMs, timedOut: result.timedOut, stoppedEarly: result.stoppedEarly, stdoutTruncated: result.stdoutTruncated, stderrTruncated: result.stderrTruncated, maxOutputBytes, commandOutputPaths: result.commandOutputPaths, outputCleanupError: result.outputCleanupError, }, null, 2)}\n`, 'command metadata artifact') } catch (error) { result.artifactWriteError = error?.message || String(error) result.stderr += '\n[test-optimization-validator] could not write command artifacts: ' + `${result.artifactWriteError}\n` if (!Number.isInteger(result.exitCode) || result.exitCode === 0) result.exitCode = 1 } resolve(result) } }) } /** * Returns the effective bounded execution settings used for one project command. * * @param {object} command structured command * @returns {{maxOutputBytes: number, timeoutFinalizeGraceMs: number, timeoutKillGraceMs: number, timeoutMs: number}} * execution settings */ function getCommandExecutionSettings (command) { return { maxOutputBytes: command.maxOutputBytes || DEFAULT_MAX_OUTPUT_BYTES, timeoutFinalizeGraceMs: command.timeoutFinalizeGraceMs || TIMEOUT_FINALIZE_GRACE_MS, timeoutKillGraceMs: command.timeoutKillGraceMs || TIMEOUT_KILL_GRACE_MS, timeoutMs: command.timeoutMs || 300_000, } } /** * Appends output while retaining only the latest bytes for diagnostic artifacts. * * @param {string} current currently captured output * @param {Buffer|string} chunk new output chunk * @param {number} maxBytes maximum retained bytes * @returns {{output: string, truncated: boolean}} retained output and truncation flag */ function appendCapturedOutput (current, chunk, maxBytes) { const next = Buffer.concat([ Buffer.from(current), Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)), ]) if (next.length <= maxBytes) { return { output: next.toString('utf8'), truncated: false, } } return { output: next.subarray(next.length - maxBytes).toString('utf8'), truncated: true, } } /** * Adds truncation context to a captured command output artifact. * * @param {string} output captured output * @param {boolean} truncated whether earlier output was omitted * @param {number} maxBytes maximum retained bytes * @returns {string} output artifact content */ function formatCapturedOutput (output, truncated, maxBytes) { if (!truncated) return output return `[test-optimization-validator] output truncated to last ${maxBytes} bytes\n${output}` } function shouldUseProcessGroup () { return process.platform !== 'win32' } function signalChild (child, signal, useProcessGroup) { try { if (useProcessGroup) { process.kill(-child.pid, signal) return } } catch {} child.kill(signal) } function getBaseEnv (envMode) { if (envMode !== 'clean') return process.env const cleanEnv = {} for (const name of CLEAN_ENV_ALLOWLIST) { if (process.env[name] !== undefined) cleanEnv[name] = process.env[name] } return cleanEnv } function buildDatadogEnv ({ fixture, outputRoot, scenario, framework }) { const offline = buildOfflineValidationEnv({ fixture, outputRoot }) return { ...offline, DD_CIVISIBILITY_AGENTLESS_ENABLED: '0', DD_CIVISIBILITY_ENABLED: '1', DD_TRACE_ENABLED: 'true', ...VALIDATION_SUPPRESSION_ENV, DD_SERVICE: 'dd-test-optimization-validation', DD_ENV: 'local-validation', DD_CIVISIBILITY_FLAKY_RETRY_COUNT: '2', DD_TAGS: `test_optimization.validation.scenario:${scenario}`, NODE_OPTIONS: withCiPreloads('', framework), } } function buildCiWiringEnv ({ fixture, outputRoot }) { return { ...buildOfflineValidationEnv({ fixture, outputRoot }), [VALIDATION_CAPTURE_MODE_ENV]: 'sample', DD_TRACE_DEBUG: '1', DD_TRACE_LOG_LEVEL: 'debug', ...VALIDATION_SUPPRESSION_ENV, } } /** * Builds the private environment used by the filesystem-only validation exporter. * * @param {object} input offline validation inputs * @param {{manifestPath: string}} input.fixture authoritative cache fixture * @param {string} input.outputRoot pre-created payload output root * @returns {NodeJS.ProcessEnv} validation transport environment */ function buildOfflineValidationEnv ({ fixture, outputRoot }) { return { DD_AGENT_HOST: undefined, DD_API_KEY: undefined, DD_APP_KEY: undefined, DATADOG_API_KEY: undefined, [APM_AGENTLESS_ENV]: undefined, DD_CIVISIBILITY_AGENTLESS_ENABLED: '0', DD_CIVISIBILITY_AGENTLESS_URL: undefined, DD_EXPERIMENTAL_TEST_OPT_SETTINGS_CACHE: undefined, DD_TRACE_AGENT_HOSTNAME: undefined, DD_TRACE_AGENT_PORT: undefined, DD_TRACE_AGENT_UNIX_DOMAIN_SOCKET: undefined, DD_TRACE_AGENT_URL: undefined, OTEL_LOGS_EXPORTER: undefined, OTEL_METRICS_EXPORTER: undefined, OTEL_TRACES_EXPORTER: undefined, [VALIDATION_MANIFEST_ENV]: fixture.manifestPath, [VALIDATION_MODE_ENV]: '1', [VALIDATION_OUTPUT_ENV]: outputRoot, } } /** * Rejects command-local assignments that can bypass validator-controlled offline routing. * * @param {object} command command to execute * @param {NodeJS.ProcessEnv} env validator environment overrides */ function assertNoInlineValidationEnvOverrides (command, env) { if (!env[VALIDATION_MODE_ENV]) return const reservedEnvNames = new Set([ ...VALIDATION_RESERVED_ENV_NAMES, ...Object.keys(env).filter(name => { return name.startsWith('DD_') || name.startsWith('_DD_') || name.startsWith('OTEL_') }), ]) if (command.usesShell) { rejectReservedShellAssignments(command.shellCommand, reservedEnvNames) return } const parsed = parseArgv(command.argv) rejectReservedEnvSplitStrings(command.argv, reservedEnvNames) if (parsed.ignoreEnvironment) throwEnvironmentReset() if (parsed.unsupportedEnvOption) throwUnsupportedEnvOption(parsed.unsupportedEnvOption) for (const name of Object.keys(parsed.prefixEnv)) { if (reservedEnvNames.has(name)) throwReservedEnvOverride(name) } for (const name of parsed.unsetEnvNames) { if (reservedEnvNames.has(name)) throwReservedEnvOverride(name) } if (isPosixShellExecutable(command.argv[parsed.commandIndex])) { for (let index = parsed.commandIndex + 1; index < command.argv.length - 1; index++) { const value = command.argv[index] if (isShellCommandFlag(value) && typeof command.argv[index + 1] === 'string') { rejectReservedShellAssignments(command.argv[index + 1], reservedEnvNames) } } } } /** * Rejects reserved variable assignments and removals in shell source. * * @param {string} shellCommand shell source * @param {Set<string>} reservedEnvNames validator-controlled environment names */ function rejectReservedShellAssignments (shellCommand, reservedEnvNames) { const source = String(shellCommand || '') const environmentReset = /\benv(?:\.exe)?\s+(?:(?![;&|()]).)*?(?:-(?=\s|$)|-i\b|--ignore-environment\b)/i if (environmentReset.test(source)) throwEnvironmentReset() for (const name of reservedEnvNames) { const escapedName = escapeRegExp(name) const assignment = new RegExp( String.raw`(?:^|[\s;&|()'"])(?:export\s+|set\s+)?(?:\$env:)?${escapedName}\s*\+?=`, 'i' ) const removal = new RegExp( String.raw`(?:\bunset(?:\s+(?:-[A-Za-z]+|[A-Za-z_][A-Za-z0-9_]*))*\s+|` + String.raw`\benv(?:\.exe)?\s+(?:(?![;&|()]).)*?(?:-u\s*|--unset(?:=|\s+))|` + String.raw`\bRemove-Item\s+(?:[^;&|]*\s)?env:)${escapedName}\b`, 'i' ) if (assignment.test(source) || removal.test(source)) throwReservedEnvOverride(name) } } /** * Rejects reserved environment changes hidden inside env --split-string arguments. * * @param {string[]} argv structured command arguments * @param {Set<string>} reservedEnvNames validator-controlled environment names * @returns {void} */ function rejectReservedEnvSplitStrings (argv, reservedEnvNames) { if (!Array.isArray(argv) || !isEnvExecutable(argv[0])) return for (let index = 1; index < argv.length; index++) { const argument = argv[index] if (argument === '-S' || argument === '--split-string') { if (typeof argv[index + 1] === 'string') { rejectReservedShellAssignments(`env ${argv[index + 1]}`, reservedEnvNames) } index++ continue } const splitString = /^(?:-S|--split-string=)(.+)$/.exec(argument)?.[1] if (splitString !== undefined) rejectReservedShellAssignments(`env ${splitString}`, reservedEnvNames) } } function isShellCommandFlag (value) { return /^-[A-Za-z]*c[A-Za-z]*$/.test(value) } function isPosixShellExecutable (value) { return /^(?:a|ba|da|k|z)?sh$/.test(path.basename(String(value || '')).toLowerCase()) } /** * Throws a customer-facing error for unsafe inline validation environment changes. * * @param {string} name reserved environment variable */ function throwReservedEnvOverride (name) { throw new Error( `Refusing inline ${name} changes during live validation because they can bypass the offline validation mode. ` + 'Record CI-provided values in command.env so the validator can apply its private diagnostic settings.' ) } function throwEnvironmentReset () { throw new Error( 'Refusing to clear the command environment during live validation because this would remove the offline ' + 'validation and Datadog initialization settings.' ) } function throwUnsupportedEnvOption (option) { throw new Error( `Refusing unsupported env option ${option} during live validation because its environment effects cannot be ` + 'verified safely.' ) } /** * Escapes a literal for use in a regular expression. * * @param {string} value literal value * @returns {string} escaped value */ function escapeRegExp (value) { return value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`) } function withCiPreloads (nodeOptions = '', framework) { let result = nodeOptions.trim() if (framework?.framework === 'vitest' && !hasRegister(result)) { result = `--import ${formatNodeRequire(REGISTER_PATH)}${result ? ` ${result}` : ''}` } if (!hasCiInit(result)) { result = `${result ? `${result} ` : ''}-r ${formatNodeRequire(INIT_PATH)}` } return result } function mergeNodeOptions (...nodeOptions) { return nodeOptions .map(value => String(value || '').trim()) .filter(Boolean) .join(' ') } function hasCiInit (nodeOptions) { return nodeOptions.includes('dd-trace/ci/init') || nodeOptions.includes(INIT_PATH) } function hasRegister (nodeOptions) { return nodeOptions.includes('dd-trace/register.js') || nodeOptions.includes(REGISTER_PATH) } function formatNodeRequire (filename) { if (!/\s/.test(filename)) return filename return JSON.stringify(filename) } function serializeCommand (command) { return command.usesShell ? command.shellCommand : command.argv.join(' ') } /** * Renders the command that will actually execute without trusting display-only manifest fields. * * @param {object} command command to render * @returns {string} unambiguous customer-facing command */ function serializeApprovalCommand (command) { if (command.usesShell) return command.shellCommand return command.argv.map(formatApprovalArgument).join(' ') } /** * Quotes arguments whose boundaries would otherwise be ambiguous in an approval plan. * * @param {string} value argument value * @returns {string} visible argument */ function formatApprovalArgument (value) { const argument = String(value) if (/^[A-Za-z0-9_@%+=:,./\\-]+$/.test(argument)) return argument if (process.platform === 'win32') return JSON.stringify(argument) return `'${argument.replaceAll('\'', '\'"\'"\'')}'` } function serializeDisplayCommand (command) { if (typeof command.displayCommand === 'string' && command.displayCommand.trim()) { return command.displayCommand.trim() } if (command.usesShell) return command.shellCommand return getDisplayArgv(command.argv).join(' ') } function getCommandDetails (command) { if (command.usesShell) return const details = getDisplayDetails(command.argv) if (!details.exactCommandCollapsed) return return details } function getDisplayArgv (argv) { const { prefixAssignments, commandIndex, corepackIndex } = parseArgv(argv) if (corepackIndex !== -1) return [...prefixAssignments, ...argv.slice(corepackIndex + 1)] return [...prefixAssignments, ...argv.slice(commandIndex)] } function getDisplayDetails (argv) { const { commandIndex, corepackIndex, pathAdjusted } = parseArgv(argv) const displayArgv = getDisplayArgv(argv) const details = { exactCommandCollapsed: displayArgv.join(' ') !== argv.join(' '), } if (pathAdjusted) details.pathAdjusted = true if (corepackIndex !== -1) { details.runtimeWrapper = 'node/corepack' details.packageManager = argv[corepackIndex + 1] } else if (commandIndex > 0) { details.runtimeWrapper = 'env' } return details } module.exports = { runCommand, buildCiWiringEnv, buildDatadogEnv, getBaseEnv, getCommandDetails, getCommandExecutionSettings, serializeApprovalCommand, serializeCommand, serializeDisplayCommand, withCiPreloads, mergeNodeOptions, }