UNPKG

artillery

Version:

Cloud-scale load testing. https://www.artillery.io

1,761 lines (1,541 loc) 59 kB
/* eslint-disable no-warning-comments */ const AWS = require('aws-sdk'); // Normal debugging for messages, summaries, and errors: const debug = require('debug')('commands:run-test'); // Verbose debugging for responses from AWS API calls, large objects etc: const debugVerbose = require('debug')('commands:run-test:v'); const debugErr = require('debug')('commands:run-test:errors'); const A = require('async'); const path = require('path'); const fs = require('fs'); const chalk = require('chalk'); const defaultOptions = require('rc')('artillery'); const moment = require('moment'); const EnsurePlugin = require('artillery-plugin-ensure'); const SlackPlugin = require('artillery-plugin-slack'); const { getADOTRelevantReporterConfigs, resolveADOTConfigSettings } = require('artillery-plugin-publish-metrics'); const EventEmitter = require('events'); const _ = require('lodash'); const pkg = require('../../../../package.json'); const { parseTags } = require('./tags'); const { Timeout, sleep, timeStringToMs } = require('./time'); const { SqsReporter } = require('./sqs-reporter'); const awaitOnEE = require('../../../../lib/util/await-on-ee'); const { VPCSubnetFinder } = require('./find-public-subnets'); const awsUtil = require('./aws-util'); const { createTest } = require('./create-test'); const { TestBundle } = require('./test-object'); const createS3Client = require('./create-s3-client'); const { getBucketName } = require('./util'); const getAccountId = require('../../aws/aws-get-account-id'); const { setCloudwatchRetention } = require('../../aws/aws-cloudwatch'); const dotenv = require('dotenv'); const util = require('./util'); const setDefaultAWSCredentials = require('../../aws/aws-set-default-credentials'); module.exports = runCluster; let consoleReporter = { toggleSpinner: () => {} }; const { TASK_NAME, SQS_QUEUES_NAME_PREFIX, LOGGROUP_NAME, LOGGROUP_RETENTION_DAYS, IMAGE_VERSION, WAIT_TIMEOUT, ARTILLERY_CLUSTER_NAME, TEST_RUNS_MAX_TAGS } = require('./constants'); const { TestNotFoundError, NoAvailableQueueError, ClientServerVersionMismatchError } = require('./errors'); let IS_FARGATE = false; const TEST_RUN_STATUS = require('./test-run-status'); const prepareTestExecutionPlan = require('../../../util/prepare-test-execution-plan'); function setupConsoleReporter(quiet) { const reporterOpts = { outputFormat: 'classic', printPeriod: false, quiet: quiet }; if ( global.artillery && global.artillery.version && global.artillery.version.startsWith('2') ) { delete reporterOpts.outputFormat; delete reporterOpts.printPeriod; } const reporterEvents = new EventEmitter(); consoleReporter = global.artillery.__createReporter( reporterEvents, reporterOpts ); // // Disable spinner on v1 if ( global.artillery && global.artillery.version && !global.artillery.version.startsWith('2') ) { consoleReporter.spinner.stop(); consoleReporter.spinner.clear(); consoleReporter.spinner = { start: () => {}, stop: () => {}, clear: () => {} }; } return { reporterEvents }; } function runCluster(scriptPath, options) { if (process.env.DEBUG) { AWS.config.logger = console; } const artilleryReporter = setupConsoleReporter(options.quiet); // camelCase all flag names, e.g. `launch-config` becomes launchConfig const options2 = {}; for (const [k, v] of Object.entries(options)) { options2[_.camelCase(k)] = v; } tryRunCluster(scriptPath, options2, artilleryReporter); } function logProgress(msg, opts = {}) { if (typeof opts.showTimestamp === 'undefined') { opts.showTimestamp = true; } if (global.artillery && global.artillery.log) { artillery.logger(opts).log(msg); } else { consoleReporter.toggleSpinner(); artillery.log( `${msg} ${chalk.gray('[' + moment().format('HH:mm:ss') + ']')}` ); consoleReporter.toggleSpinner(); } } async function tryRunCluster(scriptPath, options, artilleryReporter) { const MAX_RETAINED_LOG_SIZE_MB = Number( process.env.MAX_RETAINED_LOG_SIZE_MB || '50' ); const MAX_RETAINED_LOG_SIZE = MAX_RETAINED_LOG_SIZE_MB * 1024 * 1024; let currentSize = 0; // Override console.log so as not to interfere with the spinner let outputLines = []; let truncated = false; console.log = (function () { let orig = console.log; return function () { try { orig.apply(console, arguments); if (currentSize < MAX_RETAINED_LOG_SIZE) { outputLines = outputLines.concat(arguments); for (const x of arguments) { currentSize += String(x).length; } } else { if (!truncated) { truncated = true; const msg = `[WARNING] Artillery: maximum retained log size exceeded, max size: ${MAX_RETAINED_LOG_SIZE_MB}MB. Further logs won't be retained.\n\n`; process.stdout.write(msg); outputLines = outputLines.concat([msg]); } } } catch (err) { debug(err); } }; })(); console.error = (function () { let orig = console.error; return function () { try { orig.apply(console, arguments); if (currentSize < MAX_RETAINED_LOG_SIZE) { outputLines = outputLines.concat(arguments); for (const x of arguments) { currentSize += String(x).length; } } else { if (!truncated) { truncated = true; const msg = `[WARNING] Artillery: maximum retained log size exceeded, max size: ${MAX_RETAINED_LOG_SIZE_MB}MB. Further logs won't be retained.\n\n`; process.stdout.write(msg); outputLines = outputLines.concat([msg]); } } } catch (err) { debug(err); } }; })(); try { await setDefaultAWSCredentials(AWS); } catch (err) { console.error(err); process.exit(1); } let context = {}; const inputFiles = [].concat(scriptPath, options.config || []); const runnableScript = await prepareTestExecutionPlan(inputFiles, options); context.runnableScript = runnableScript; let absoluteScriptPath; if (typeof scriptPath !== 'undefined') { absoluteScriptPath = path.resolve(process.cwd(), scriptPath); context.namedTest = false; try { fs.statSync(absoluteScriptPath); } catch (statErr) { artillery.log('Could not read file:', scriptPath); process.exit(1); } } if (options.dotenv) { const dotEnvPath = path.resolve(process.cwd(), options.dotenv); const contents = fs.readFileSync(dotEnvPath); context.dotenv = dotenv.parse(contents); } if (options.record) { const cloudKey = options.key || process.env.ARTILLERY_CLOUD_API_KEY; const cloudEndpoint = process.env.ARTILLERY_CLOUD_ENDPOINT; // Explicitly make Artillery Cloud API key available to workers (if it's set) // Relying on the fact that contents of context.dotenv gets passed onto workers // for it if (cloudKey) { context.dotenv = { ...context.dotenv, ARTILLERY_CLOUD_API_KEY: cloudKey }; } // Explicitly make Artillery Cloud endpoint available to workers (if it's set) if (cloudEndpoint) { context.dotenv = { ...context.dotenv, ARTILLERY_CLOUD_ENDPOINT: cloudEndpoint }; } } if (options.bundle) { context.namedTest = true; } if (options.maxDuration) { try { const maxDurationMs = timeStringToMs(options.maxDuration); context.maxDurationMs = maxDurationMs; } catch (err) { throw err; } } context.tags = parseTags(options.tags); if (context.tags.length > TEST_RUNS_MAX_TAGS) { console.error( chalk.red( `A maximum of ${TEST_RUNS_MAX_TAGS} tags is allowed per test run` ) ); process.exit(1); } // Set name tag if not already set: if (context.tags.filter((t) => t.name === 'name').length === 0) { if (typeof scriptPath !== 'undefined') { context.tags.push({ name: 'name', value: path.basename(scriptPath) }); } else { context.tags.push({ name: 'name', value: options.bundle }); } } if (options.name) { for (const t of context.tags) { if (t.name === 'name') { t.value = options.name; } } } context.extraSecrets = options.secret || []; context.testId = global.artillery.testRunId; if (context.namedTest) { context.s3Prefix = options.bundle; debug(`Trying to run a named test: ${context.s3Prefix}`); } if (!context.namedTest) { const contextPath = options.context ? path.resolve(options.context) : path.dirname(absoluteScriptPath); debugVerbose('script:', absoluteScriptPath); debugVerbose('root:', contextPath); const containerScriptPath = path.join( path.relative(contextPath, path.dirname(absoluteScriptPath)), path.basename(absoluteScriptPath) ); if (containerScriptPath.indexOf('..') !== -1) { artillery.log( chalk.red( 'Test script must reside inside the context dir. See Artillery docs for more details.' ) ); process.exit(1); } // FIXME: These need clearer names. dir vs path and local vs container. context.contextDir = contextPath; context.newScriptPath = containerScriptPath; debug('container script path:', containerScriptPath); } const count = Number(options.count) || 1; if (typeof options.taskRoleName !== 'undefined') { let customRoleName = options.taskRoleName; // Allow ARNs for convenience // https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_identifiers.html // We split by :role because role names may contain slash characters (subpaths) if (customRoleName.startsWith('arn:aws:iam')) { customRoleName = customRoleName.split(':role/')[1]; } context.customTaskRoleName = customRoleName; } const clusterName = options.cluster || ARTILLERY_CLUSTER_NAME; if (options.launchConfig) { let launchConfig; try { launchConfig = JSON.parse(options.launchConfig); } catch (parseErr) { debug(parseErr); } if (!launchConfig) { artillery.log( chalk.red( "Launch config could not be parsed. Please check that it's valid JSON." ) ); process.exit(1); } if (launchConfig.ulimits && !Array.isArray(launchConfig.ulimits)) { // TODO: Proper schema validation for the object artillery.log(chalk.red('ulimits must be an array of objects')); artillery.log( 'Please see AWS documentation for more information:\nhttps://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_Ulimit.html' ); process.exit(1); } options.launchConfig = launchConfig; } else { options.launchConfig = {}; } if (options.cpu) { const n = Number(options.cpu); if (isNaN(n)) { artillery.log('The value of --cpu must be a number'); process.exit(1); } // Allow specifying 16 vCPU as either "16" or "16384". The actual value is // validated later. const MAX_VCPUS = 16; if (n <= MAX_VCPUS) { options.launchConfig.cpu = n * 1024; } else { options.launchConfig.cpu = n; } } if (options.memory) { const n = Number(options.memory); if (isNaN(n)) { artillery.log('The value of --memory must be a number'); process.exit(1); } const MAX_MEMORY_IN_GB = 120; if (n <= MAX_MEMORY_IN_GB) { options.launchConfig.memory = String(parseInt(options.memory, 10) * 1024); } else { options.launchConfig.memory = options.memory; } } // check launch type is valid: if (typeof options.launchType !== 'undefined') { if ( options.launchType !== 'ecs:fargate' && options.launchType !== 'ecs:ec2' ) { artillery.log( 'Invalid launch type - the value of --launch-type needs to be ecs:fargate or ecs:ec2' ); process.exit(1); } } if (typeof options.fargate !== 'undefined') { console.error( 'The --fargate flag is deprecated, use --launch-type ecs:fargate instead' ); } if (options.fargate && options.launchType) { console.error( 'Either --fargate or --launch-type flag should be set, not both' ); process.exit(1); } if ( typeof options.fargate === 'undefined' && typeof options.launchType === 'undefined' ) { options.launchType = 'ecs:fargate'; } IS_FARGATE = typeof options.fargate !== 'undefined' || // --fargate set typeof options.publicSubnetIds !== 'undefined' || // --public-subnet-ids set (typeof options.launchType !== 'undefined' && options.launchType === 'ecs:fargate') || // --launch-type ecs:fargate typeof options.launchType === 'undefined'; global.artillery.globalEvents.emit('test:init', { flags: options, testRunId: context.testId, tags: context.tags, metadata: { testId: context.testId, startedAt: Date.now(), count, tags: context.tags, launchType: options.launchType } }); let packageJsonPath; if (options.packages) { packageJsonPath = path.resolve(process.cwd(), options.packages); try { // TODO: Check that filename is package.json JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); } catch (err) { console.error('Could not load package dependency list'); console.error('Trying to read from:', packageJsonPath); console.error(err); } } context = Object.assign(context, { scriptPath: absoluteScriptPath, originalScriptPath: scriptPath, count: count, region: options.region, taskName: `${TASK_NAME}_${ IS_FARGATE ? 'fargate' : '' }_${clusterName}_${IMAGE_VERSION.replace(/\./g, '-')}_${Math.floor( Math.random() * 1e6 )}`, clusterName: clusterName, logGroupName: LOGGROUP_NAME, cliOptions: options, isFargate: IS_FARGATE, isCapacitySpot: typeof options.spot !== 'undefined', configTableName: '', status: TEST_RUN_STATUS.INITIALIZING, packageJsonPath, taskArns: [] }); let subnetIds = []; if (options.publicSubnetIds) { console.error( `${chalk.yellow( 'Warning' )}: --public-subnet-ids will be deprecated. Use --subnet-ids instead.` ); subnetIds = options.publicSubnetIds.split(','); } if (options.subnetIds) { subnetIds = options.subnetIds.split(','); } if (IS_FARGATE) { context.fargatePublicSubnetIds = subnetIds; context.fargateSecurityGroupIds = typeof options.securityGroupIds !== 'undefined' ? options.securityGroupIds.split(',') : []; } if (global.artillery && global.artillery.telemetry) { global.artillery.telemetry.capture('run-test', { version: global.artillery.version, proVersion: pkg.version, count: count, launchPlatform: IS_FARGATE ? 'ecs:fargate' : 'ecs:ec2', usesTags: context.tags.length > 0, region: context.region, crossRegion: context.region !== context.backendRegion }); } async function newWaterfall(artilleryReporter) { let testRunCompletedSuccessfully = true; let shuttingDown = false; async function gracefulShutdown(opts = { earlyStop: false, exitCode: 0 }) { if (shuttingDown) { return; } shuttingDown = true; if (opts.earlyStop) { if (context.status !== TEST_RUN_STATUS.ERROR) { // Retain ERROR status if already set elsewhere context.status = TEST_RUN_STATUS.EARLY_STOP; } } await cleanupResources(context); global.artillery.globalEvents.emit('shutdown:start', { exitCode: opts.exitCode, earlyStop: opts.earlyStop }); const ps = []; for (const e of global.artillery.extensionEvents) { const testInfo = { endTime: Date.now() }; if (e.ext === 'beforeExit') { ps.push( e.method({ report: context.aggregateReport, flags: context.cliOptions, runnerOpts: { environment: context.cliOptions?.environment, scriptPath: '', absoluteScriptPath: '' }, testInfo }) ); } } await Promise.allSettled(ps); const ps2 = []; const shutdownOpts = { earlyStop: opts.earlyStop, exitCode: opts.exitCode }; for (const e of global.artillery.extensionEvents) { if (e.ext === 'onShutdown') { ps2.push(e.method(shutdownOpts)); } } await Promise.allSettled(ps2); await global.artillery.telemetry?.shutdown(); process.exit(global.artillery.suggestedExitCode || opts.exitCode); } global.artillery.shutdown = gracefulShutdown; process.on('SIGINT', async () => { if (shuttingDown) { return; } console.log('Stopping test run (SIGINT received)...'); await gracefulShutdown({ exitCode: 1, earlyStop: true }); }); process.on('SIGTERM', async () => { if (shuttingDown) { return; } console.log('Stopping test run (SIGTERM received)...'); await gracefulShutdown({ exitCode: 1, earlyStop: true }); }); // Messages from SQS reporter created later will be relayed via this EE context.reporterEvents = artilleryReporter.reporterEvents; try { logProgress('Checking AWS connectivity...'); context.accountId = await getAccountId(); await Promise.all([ (async function (context) { const bucketName = await getBucketName(); context.s3Bucket = bucketName; return context; })(context) ]); logProgress('Checking cluster...'); const clusterExists = await checkTargetCluster(context); if (!clusterExists) { if (typeof context.cliOptions.cluster === 'undefined') { // User did not specify a cluster with --cluster, and ARTILLERY_CLUSTER_NAME // does not exist, so create it await createArtilleryCluster(context); } else { // User specified a cluster, but it's not there throw new Error( `Could not find cluster ${context.clusterName} in ${context.region}` ); } } if (context.tags.length > 0) { logProgress( 'Tags: ' + context.tags.map((t) => t.name + ':' + t.value).join(', ') ); } logProgress(`Test run ID: ${context.testId}`); logProgress('Preparing launch platform...'); await maybeGetSubnetIdsForFargate(context); logProgress( `Environment: Account: ${context.accountId} Region: ${context.region} Count: ${context.count} Cluster: ${context.clusterName} Launch type: ${context.cliOptions.launchType} ${ context.isFargate && context.isCapacitySpot ? '(Spot)' : '(On-demand)' } `, { showTimestamp: false } ); await createQueue(context); await checkCustomTaskRole(context); logProgress('Preparing test bundle...'); await createTestBundle(context); await createADOTDefinitionIfNeeded(context); await ensureTaskExists(context); await getManifest(context); await generateTaskOverrides(context); logProgress('Launching workers...'); await setupDefaultECSParams(context); if ( context.status !== TEST_RUN_STATUS.EARLY_STOP && context.status !== TEST_RUN_STATUS.TERMINATING ) { // Set up SQS listener: listen(context, artilleryReporter.reporterEvents); await launchLeadTask(context); } setCloudwatchRetention( `${LOGGROUP_NAME}/${context.clusterName}`, LOGGROUP_RETENTION_DAYS, { maxRetries: 10, waitPerRetry: 2 * 1000 } ); if ( context.status !== TEST_RUN_STATUS.EARLY_STOP && context.status !== TEST_RUN_STATUS.TERMINATING ) { logProgress( context.isFargate ? 'Waiting for Fargate...' : 'Waiting for ECS...' ); await ecsRunTask(context); } if ( context.status !== TEST_RUN_STATUS.EARLY_STOP && context.status !== TEST_RUN_STATUS.TERMINATING ) { await waitForTasks2(context); } if ( context.status !== TEST_RUN_STATUS.EARLY_STOP && context.status !== TEST_RUN_STATUS.TERMINATING ) { logProgress('Waiting for workers to come online...'); await waitForWorkerSync(context); await sendGoSignal(context); logProgress('Workers are running, waiting for reports...'); if (context.maxDurationMs && context.maxDurationMs > 0) { logProgress( `Max duration for test run is set to: ${context.cliOptions.maxDuration}` ); const testDurationTimeout = new Timeout(context.maxDurationMs); testDurationTimeout.start(); testDurationTimeout.on('timeout', async () => { artillery.log( `Max duration of test run exceeded: ${context.cliOptions.maxDuration}\n` ); await gracefulShutdown({ earlyStop: true }); }); } context.status = TEST_RUN_STATUS.RECEIVING_REPORTS; } // Need to wait for all reports to be over here, not exit const workerState = await awaitOnEE( artilleryReporter.reporterEvents, 'workersDone' ); debug(workerState); logProgress(`Test run completed: ${context.testId}`); context.status = TEST_RUN_STATUS.COMPLETED; let checks = []; global.artillery.globalEvents.once('checks', async (results) => { checks = results; }); if (context.ensureSpec) { new EnsurePlugin.Plugin({ config: { ensure: context.ensureSpec } }); } if (context.fullyResolvedConfig?.plugins?.slack) { new SlackPlugin.Plugin({ config: context.fullyResolvedConfig }); } if (context.cliOptions.output) { let logfile = getLogFilename( context.cliOptions.output, defaultOptions.logFilenameFormat ); for (const ix of context.intermediateReports) { delete ix.histograms; ix.histograms = ix.summaries; } delete context.aggregateReport.histograms; context.aggregateReport.histograms = context.aggregateReport.summaries; const jsonReport = { intermediate: context.intermediateReports, aggregate: context.aggregateReport, testId: context.testId, metadata: { tags: context.tags, count: context.count, region: context.region, cluster: context.clusterName, artilleryVersion: { core: global.artillery.version, pro: pkg.version } }, ensure: checks.map((c) => { return { condition: c.original, success: c.result === 1, strict: c.strict }; }) }; fs.writeFileSync(logfile, JSON.stringify(jsonReport, null, 2), { flag: 'w' }); } debug(context.testId, 'done'); } catch (err) { debug(err); if (err.code === 'InvalidParameterException') { if ( err.message .toLowerCase() .indexOf('no container instances were found') !== -1 ) { artillery.log( chalk.yellow('The ECS cluster has no active EC2 instances') ); } else { artillery.log(err); } } else if (err instanceof TestNotFoundError) { artillery.log(`Test ${context.s3Prefix} not found`); } else if ( err instanceof NoAvailableQueueError || err instanceof ClientServerVersionMismatchError ) { artillery.log(chalk.red('Error:', err.message)); } else { artillery.log(util.formatError(err)); artillery.log(err); artillery.log(err.stack); } testRunCompletedSuccessfully = false; global.artillery.suggestedExitCode = 1; } finally { if (!testRunCompletedSuccessfully) { logProgress('Cleaning up...'); context.status = TEST_RUN_STATUS.ERROR; await gracefulShutdown({ earlyStop: true, exitCode: 1 }); } else { context.status = TEST_RUN_STATUS.COMPLETED; await gracefulShutdown({ earlyStop: false, exitCode: 0 }); } } } await newWaterfall(artilleryReporter); } async function cleanupResources(context) { try { if (context.sqsReporter) { context.sqsReporter.stop(); } if (context.adot?.SSMParameterPath) { await awsUtil.deleteParameter( context.adot.SSMParameterPath, context.region ); } if (context.taskArns && context.taskArns.length > 0) { for (const taskArn of context.taskArns) { try { const ecs = new AWS.ECS({ apiVersion: '2014-11-13', region: context.region }); await ecs .stopTask({ task: taskArn, cluster: context.clusterName, reason: 'Test cleanup' }) .promise(); } catch (err) { // TODO: Retry if appropriate, give the user more information // to be able to fall back to manual intervention if possible. // TODO: Consumer has no idea if this succeeded or not debug(err); } } } // TODO: Should either retry, or not throw in any of these await Promise.all([ deleteQueue(context), deregisterTaskDefinition(context), gcQueues(context) ]); } catch (err) { artillery.log(err); } } function checkFargateResourceConfig(cpu, memory) { function generateListOfOptionsMiB(minGB, maxGB, incrementGB) { const result = []; for (let i = 0; i <= (maxGB - minGB) / incrementGB; i++) { result.push((minGB + incrementGB * i) * 1024); } return result; } // Based on https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-cpu-memory-error.html const FARGATE_VALID_CONFIGS = { 256: [512, 1024, 2048], 512: [1024, 2048, 3072, 4096], 1024: [2048, 3072, 4096, 5120, 6144, 7168, 8192], 2048: generateListOfOptionsMiB(4, 16, 1), 4096: generateListOfOptionsMiB(8, 30, 1), 8192: generateListOfOptionsMiB(16, 60, 4), 16384: generateListOfOptionsMiB(32, 120, 8) }; if (!FARGATE_VALID_CONFIGS[cpu]) { return new Error( `Unsupported cpu override for Fargate. Must be one of: ${Object.keys( FARGATE_VALID_CONFIGS ).join(', ')}` ); } if (FARGATE_VALID_CONFIGS[cpu].indexOf(memory) < 0) { return new Error( `Fargate memory override for cpu = ${cpu} must be one of: ${FARGATE_VALID_CONFIGS[ cpu ].join(', ')}` ); } return null; } async function createArtilleryCluster(context) { const ecs = new AWS.ECS({ apiVersion: '2014-11-13', region: context.region }); try { await ecs .createCluster({ clusterName: ARTILLERY_CLUSTER_NAME, capacityProviders: ['FARGATE_SPOT'] }) .promise(); let retries = 0; while (retries < 12) { const clusterActive = await checkTargetCluster(context); if (clusterActive) { break; } retries++; await sleep(10 * 1000); } } catch (err) { throw err; } } // // Check that ECS cluster exists: // async function checkTargetCluster(context) { const ecs = new AWS.ECS({ apiVersion: '2014-11-13', region: context.region }); try { const response = await ecs .describeClusters({ clusters: [context.clusterName] }) .promise(); debug(response); if (response.clusters.length === 0 || response.failures.length > 0) { debugVerbose(response); return false; } else { const activeClusters = response.clusters.filter( (c) => c.status === 'ACTIVE' ); return activeClusters.length > 0; } } catch (err) { debugVerbose(err); return false; } } async function maybeGetSubnetIdsForFargate(context) { if (!context.isFargate) { return context; } // TODO: Sanity check that subnets actually exist before trying to use them in test definitions if (context.fargatePublicSubnetIds.length > 0) { return context; } debug('Subnet IDs not provided, looking up default VPC'); const f = new VPCSubnetFinder({ region: context.region }); const publicSubnets = await f.findPublicSubnets(); if (publicSubnets.length === 0) { throw new Error('Could not find public subnets in default VPC'); } context.fargatePublicSubnetIds = publicSubnets.map((s) => s.SubnetId); debug('Found public subnets:', context.fargatePublicSubnetIds.join(', ')); return context; } async function createTestBundle(context) { return new Promise((resolve, reject) => { createTest( context.scriptPath, { name: context.testId, config: context.cliOptions.config, packageJsonPath: context.packageJsonPath, flags: context.cliOptions }, function (err, result) { if (err) { return reject(err); } else { context.fullyResolvedConfig = result.manifest.fullyResolvedConfig; return resolve(context); } } ); }); } async function createADOTDefinitionIfNeeded(context) { const publishMetricsConfig = context.fullyResolvedConfig.plugins?.['publish-metrics']; if (!publishMetricsConfig) { debug('No publish-metrics plugin set, skipping ADOT configuration'); return context; } const adotRelevantConfigs = getADOTRelevantReporterConfigs(publishMetricsConfig); if (adotRelevantConfigs.length === 0) { debug('No ADOT relevant reporter configs set, skipping ADOT configuration'); return context; } try { const { adotEnvVars, adotConfig } = resolveADOTConfigSettings({ configList: adotRelevantConfigs, dotenv: { ...context.dotenv } }); context.dotenv = Object.assign(context.dotenv || {}, adotEnvVars); context.adot = { SSMParameterPath: `/artilleryio/OTEL_CONFIG_${context.testId}` }; await awsUtil.putParameter( context.adot.SSMParameterPath, JSON.stringify(adotConfig), 'String', context.region ); context.adot.taskDefinition = { name: 'adot-collector', image: 'amazon/aws-otel-collector:v0.39.0', command: [ '--config=/etc/ecs/container-insights/otel-task-metrics-config.yaml' ], secrets: [ { name: 'AOT_CONFIG_CONTENT', valueFrom: `arn:aws:ssm:${context.region}:${context.accountId}:parameter${context.adot.SSMParameterPath}` } ], logConfiguration: { logDriver: 'awslogs', options: { 'awslogs-group': `${context.logGroupName}/${context.clusterName}`, 'awslogs-region': context.region, 'awslogs-stream-prefix': `artilleryio/${context.testId}`, 'awslogs-create-group': 'true' } } }; } catch (err) { throw new Error(err); } return context; } async function ensureTaskExists(context) { return new Promise((resolve, reject) => { const ecs = new AWS.ECS({ apiVersion: '2014-11-13', region: context.region }); // Note: these are integers for container definitions, and strings for task definitions (on Fargate) // Defaults have to be Fargate-compatible // https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html#task_size let cpu = 4096; let memory = 8192; const defaultUlimits = { nofile: { softLimit: 8192, hardLimit: 8192 } }; let ulimits = []; if (context.cliOptions.launchConfig) { const lc = context.cliOptions.launchConfig; if (lc.cpu) { cpu = parseInt(lc.cpu, 10); } if (lc.memory) { memory = parseInt(lc.memory, 10); } if (lc.ulimits) { lc.ulimits.forEach((u) => { if (!defaultUlimits[u.name]) { defaultUlimits[u.name] = {}; } defaultUlimits[u.name] = { softLimit: u.softLimit, hardLimit: typeof u.hardLimit == 'number' ? u.hardLimit : u.softLimit }; }); } // TODO: Check this earlier to return an error faster. if (context.isFargate) { const configErr = checkFargateResourceConfig(cpu, memory); if (configErr) { return reject(configErr); } } } ulimits = Object.keys(defaultUlimits).map((name) => { return { name: name, softLimit: defaultUlimits[name].softLimit, hardLimit: defaultUlimits[name].hardLimit }; }); const defaultArchitecture = 'x86_64'; const imageUrl = process.env.WORKER_IMAGE_URL || `public.ecr.aws/d8a4z9o5/artillery-worker:${IMAGE_VERSION}-${defaultArchitecture}`; const secrets = [ 'NPM_TOKEN', 'NPM_REGISTRY', 'NPM_SCOPE', 'NPM_SCOPE_REGISTRY', 'NPMRC', 'ARTIFACTORY_AUTH', 'ARTIFACTORY_EMAIL' ] .concat(context.extraSecrets) .map((secretName) => { return { name: secretName, valueFrom: `arn:aws:ssm:${context.region}:${context.accountId}:parameter/artilleryio/${secretName}` }; }); const artilleryContainerDefinition = { name: 'artillery', image: imageUrl, cpu: cpu, command: [], entryPoint: ['/artillery/loadgen-worker'], memory: memory, secrets: secrets, ulimits: ulimits, essential: true, logConfiguration: { logDriver: 'awslogs', options: { 'awslogs-group': `${context.logGroupName}/${context.clusterName}`, 'awslogs-region': context.region, 'awslogs-stream-prefix': `artilleryio/${context.testId}`, 'awslogs-create-group': 'true', mode: 'non-blocking' } } }; if (context.cliOptions.containerDnsServers) { artilleryContainerDefinition.dnsServers = context.cliOptions.containerDnsServers.split(','); } let taskDefinition = { family: context.taskName, containerDefinitions: [artilleryContainerDefinition], executionRoleArn: context.taskRoleArn }; if (typeof context.adot !== 'undefined') { taskDefinition.containerDefinitions.push(context.adot.taskDefinition); } context.taskDefinition = taskDefinition; if (!context.isFargate && taskDefinition.containerDefinitions.length > 1) { // Limits for sidecar have to be set explicitly on ECS EC2 taskDefinition.containerDefinitions[1].memory = 1024; taskDefinition.containerDefinitions[1].cpu = 1024; } if (context.isFargate) { taskDefinition.networkMode = 'awsvpc'; taskDefinition.requiresCompatibilities = ['FARGATE']; taskDefinition.cpu = String(cpu); taskDefinition.memory = String(memory); // NOTE: This role must exist. // This value cannot be an override, meaning it's hardcoded into the task definition. // That in turn means that if the role is updated then the task definition needs to be // recreated too taskDefinition.executionRoleArn = context.taskRoleArn; // TODO: A separate role for Fargate } const params = { taskDefinition: context.taskName }; debug('Task definition\n', JSON.stringify(taskDefinition, null, 4)); ecs.describeTaskDefinition(params, function (err, _data) { if (err) { ecs.registerTaskDefinition(taskDefinition, function (err, response) { if (err) { artillery.log(err); artillery.log('Could not create ECS task, please try again'); return reject(err); } else { debug('OK: ECS task registered'); debugVerbose(JSON.stringify(response, null, 4)); context.taskDefinitionArn = response.taskDefinition.taskDefinitionArn; debug(`Task definition ARN: ${context.taskDefinitionArn}`); return resolve(context); } }); } else { debug('OK: ECS task exists'); if (process.env.ECR_IMAGE_VERSION) { debug( 'ECR_IMAGE_VERSION is set, but the task definition was already in place.' ); } return resolve(context); } }); }); } async function checkCustomTaskRole(context) { if (!context.customTaskRoleName) { return; } const iam = new AWS.IAM(); try { const roleData = await iam .getRole({ RoleName: context.customTaskRoleName }) .promise(); context.customRoleArn = roleData.Role.Arn; context.taskRoleArn = roleData.Role.Arn; debug(roleData); } catch (err) { throw err; } } async function gcQueues(context) { const sqs = new AWS.SQS({ region: context.region }); let data; try { data = await sqs .listQueues({ QueueNamePrefix: SQS_QUEUES_NAME_PREFIX, MaxResults: 1000 }) .promise(); } catch (err) { debug(err); } if (data && data.QueueUrls && data.QueueUrls.length > 0) { for (const qu of data.QueueUrls) { try { const data = await sqs .getQueueAttributes({ QueueUrl: qu, AttributeNames: ['CreatedTimestamp'] }) .promise(); const ts = Number(data.Attributes['CreatedTimestamp']) * 1000; // Delete after 96 hours if (Date.now() - ts > 96 * 60 * 60 * 1000) { await sqs.deleteQueue({ QueueUrl: qu }).promise(); } } catch (err) { // TODO: Filter on errors which may be ignored, e.g.: // AWS.SimpleQueueService.NonExistentQueue: The specified queue does not exist // which can happen if another test ends between calls to listQueues and getQueueAttributes. // Sometimes SQS returns recently deleted queues to ListQueues too. debug(err); } } } } async function deleteQueue(context) { if (!context.sqsQueueUrl) { return; } const sqs = new AWS.SQS({ region: context.region }); try { await sqs.deleteQueue({ QueueUrl: context.sqsQueueUrl }).promise(); } catch (err) { console.error(`Unable to clean up SQS queue. URL: ${context.sqsQueueUrl}`); debug(err); } } async function createQueue(context) { const sqs = new AWS.SQS({ region: context.region }); const queueName = `${SQS_QUEUES_NAME_PREFIX}_${context.testId.slice( 0, 30 )}.fifo`; const params = { QueueName: queueName, Attributes: { FifoQueue: 'true', ContentBasedDeduplication: 'false', MessageRetentionPeriod: '1800', VisibilityTimeout: '600' // 10 minutes } }; try { const result = await sqs.createQueue(params).promise(); context.sqsQueueUrl = result.QueueUrl; } catch (err) { throw err; } // Wait for the queue to be available: let waited = 0; let ok = false; while (waited < 120 * 1000) { try { const results = await sqs .listQueues({ QueueNamePrefix: queueName }) .promise(); if (results.QueueUrls && results.QueueUrls.length === 1) { debug('SQS queue created:', queueName); ok = true; break; } else { await sleep(10 * 1000); waited += 10 * 1000; } } catch (err) { await sleep(10 * 1000); waited += 10 * 1000; } } if (!ok) { debug('Time out waiting for SQS queue:', queueName); throw new Error('SQS queue could not be created'); } } async function getManifest(context) { try { const testBundle = new TestBundle( context.namedTest ? context.s3Prefix : context.testId ); const metadata = await testBundle.getManifest(); context.newScriptPath = metadata.scriptPath; if (metadata.configPath) { context.configPath = metadata.configPath; } return context; } catch (err) { if (err.code === 'NoSuchKey') { throw new TestNotFoundError(); } else { throw err; } } } async function generateTaskOverrides(context) { const cliArgs = ['run'].concat( context.cliOptions.environment ? ['--environment', context.cliOptions.environment] : [], context.cliOptions['scenario-name'] ? ['--scenario-name', context.cliOptions['scenario-name']] : [], context.cliOptions.insecure ? ['-k'] : [], context.cliOptions.target ? ['-t', context.cliOptions.target] : [], context.cliOptions.overrides ? ['--overrides', context.cliOptions.overrides] : [], context.cliOptions.variables ? ['--variables', context.cliOptions.variables] : [], context.configPath ? ['--config', context.configPath] : [] ); // NOTE: This MUST come last: cliArgs.push(context.newScriptPath); debug('cliArgs', cliArgs, cliArgs.join(' ')); const s3path = `s3://${context.s3Bucket}/tests/${ context.namedTest ? context.s3Prefix : context.testId }`; const adotOverride = [ { name: 'adot-collector', environment: [] } ]; const overrides = { containerOverrides: [ { name: 'artillery', command: [ '-p', s3path, '-a', util.btoa(JSON.stringify(cliArgs)), '-r', context.region, '-q', process.env.SQS_QUEUE_URL || context.sqsQueueUrl, '-i', context.testId, '-d', `s3://${context.s3Bucket}/test-runs`, '-t', String(WAIT_TIMEOUT) ], environment: [ { name: 'AWS_SDK_JS_SUPPRESS_MAINTENANCE_MODE_MESSAGE', value: '1' }, { name: 'ARTILLERY_TEST_RUN_ID', value: global.artillery.testRunId } ] }, ...(context.adot ? adotOverride : []) ], taskRoleArn: context.taskRoleArn }; if (context.customRoleArn) { overrides.taskRoleArn = context.customRoleArn; } if (context.cliOptions.taskEphemeralStorage) { overrides.ephemeralStorage = { sizeInGiB: context.cliOptions.taskEphemeralStorage }; } overrides.containerOverrides[0].environment.push({ name: 'USE_V2', value: 'true' }); if (context.dotenv) { let extraEnv = []; for (const [name, value] of Object.entries(context.dotenv)) { extraEnv.push({ name, value }); } overrides.containerOverrides[0].environment = overrides.containerOverrides[0].environment.concat(extraEnv); if (overrides.containerOverrides[1]) { overrides.containerOverrides[1].environment = overrides.containerOverrides[1].environment.concat(extraEnv); } } if (context.cliOptions.launchConfig) { const lc = context.cliOptions.launchConfig; if (lc.environment) { overrides.containerOverrides[0].environment = overrides.containerOverrides[0].environment.concat(lc.environment); if (overrides.containerOverrides[1]) { overrides.containerOverrides[1].environment = overrides.containerOverrides[1].environment.concat(lc.environment); } } // // Not officially supported: // if (lc.taskRoleArn) { overrides.taskRoleArn = lc.taskRoleArn; } if (lc.command) { overrides.containerOverrides[0].command = lc.command; } } debug('OK: Overrides generated'); debugVerbose(JSON.stringify(overrides, null, 4)); context.taskOverrides = overrides; return context; } async function setupDefaultECSParams(context) { const defaultParams = { taskDefinition: context.taskName, cluster: context.clusterName, overrides: context.taskOverrides }; if (context.isFargate) { if (context.isCapacitySpot) { defaultParams.capacityProviderStrategy = [ { capacityProvider: 'FARGATE_SPOT', weight: 1, base: 0 } ]; } else { // On-demand capacity defaultParams.launchType = 'FARGATE'; } // Networking config: private subnets of the VPC that the ECS cluster // is in. Don't need public subnets. defaultParams.networkConfiguration = { awsvpcConfiguration: { // https://github.com/aws/amazon-ecs-agent/issues/1128 assignPublicIp: 'ENABLED', securityGroups: context.fargateSecurityGroupIds, subnets: context.fargatePublicSubnetIds } }; } else { defaultParams.launchType = 'EC2'; } context.defaultECSParams = defaultParams; return context; } async function launchLeadTask(context) { const metadata = { testId: context.testId, startedAt: Date.now(), cluster: context.clusterName, region: context.region, launchType: context.cliOptions.launchType, isFargateSpot: context.isCapacitySpot, count: context.count, sqsQueueUrl: context.sqsQueueUrl, tags: context.tags, secrets: JSON.stringify( Array.isArray(context.extraSecrets) ? context.extraSecrets : [context.extraSecrets] ), platformConfig: JSON.stringify({ memory: context.taskDefinition.containerDefinitions[0].memory, cpu: context.taskDefinition.containerDefinitions[0].cpu }), artilleryVersion: JSON.stringify({ core: global.artillery.version }), // Properties from the runnable script object: testConfig: { target: context.runnableScript.config.target, phases: context.runnableScript.config.phases, plugins: context.runnableScript.config.plugins, environment: context.runnableScript._environment, scriptPath: context.runnableScript._scriptPath, configPath: context.runnableScript._configPath } }; artillery.globalEvents.emit('metadata', metadata); context.status = TEST_RUN_STATUS.LAUNCHING_WORKERS; const ecs = new AWS.ECS({ apiVersion: '2014-11-13', region: context.region }); const leaderParams = Object.assign( { count: 1 }, JSON.parse(JSON.stringify(context.defaultECSParams)) ); leaderParams.overrides.containerOverrides[0].environment.push({ name: 'IS_LEADER', value: 'true' }); try { const runData = await ecs.runTask(leaderParams).promise(); if (runData.failures.length > 0) { if (runData.failures.length === context.count) { artillery.log('ERROR: Worker start failure'); const uniqueReasons = [ ...new Set(runData.failures.map((f) => f.reason)) ]; artillery.log('Reason:', uniqueReasons); throw new Error('Could not start workers'); } else { artillery.log('WARNING: Some workers failed to start'); artillery.log(chalk.red(JSON.stringify(runData.failures, null, 4))); throw new Error('Not enough capacity - terminating'); } } context.taskArns = context.taskArns.concat( runData.tasks.map((task) => task.taskArn) ); artillery.globalEvents.emit('metadata', { platformMetadata: { taskArns: context.taskArns } }); } catch (runErr) { throw runErr; } return context; } // TODO: When launching >20 containers on Fargate, adjust WAIT_TIMEOUT dynamically to // add extra time spent in waiting between runTask calls: WAIT_TIMEOUT + worker_count. async function ecsRunTask(context) { const ecs = new AWS.ECS({ apiVersion: '2014-11-13', region: context.region }); let tasksRemaining = context.count - 1; let retries = 0; while ( tasksRemaining > 0 && context.status !== TEST_RUN_STATUS.TERMINATING && context.status !== TEST_RUN_STATUS.EARLY_STOP ) { if (retries >= 10) { artillery.log('Max retries for ECS (10) exceeded'); throw new Error('Max retries exceeded'); } let launchCount = tasksRemaining <= 10 ? tasksRemaining : 10; let params = Object.assign( { count: launchCount }, JSON.parse(JSON.stringify(context.defaultECSParams)) ); params.overrides.containerOverrides[0].environment.push({ name: 'IS_LEADER', value: 'false' }); try { const runData = await ecs.runTask(params).promise(); const launchedTasksCount = runData.tasks?.length || 0; tasksRemaining -= launchedTasksCount; if (launchedTasksCount > 0) { const newTaskArns = runData.tasks.map((task) => task.taskArn); context.taskArns = context.taskArns.concat(newTaskArns); artillery.globalEvents.emit('metadata', { platformMetadata: { taskArns: newTaskArns } }); debug(`Launched ${launchedTasksCount} tasks`); } if (runData.failures.length > 0) { artillery.log('Some workers failed to start'); const uniqueReasons = [ ...new Set(runData.failures.map((f) => f.reason)) ]; artillery.log(chalk.red(uniqueReasons)); artillery.log('Retrying...'); await sleep(10 * 1000); throw new Error('Not enough ECS capacity'); } } catch (runErr) { if (runErr.code === 'ThrottlingException') { artillery.log('ThrottlingException returned from ECS, retrying'); await sleep(2000 * retries); debug('runTask throttled, retrying'); debug(runErr); } else if (runErr.message.match(/Not enough ECS capacity/gi)) { // Do nothing } else { artillery.log(runErr); } retries++; if (retries >= 10) { artillery.log('Max retries for ECS (10) exceeded'); throw runErr; } } } return context; } async function waitForTa