artillery
Version:
Cloud-scale load testing. https://www.artillery.io
655 lines (568 loc) • 19.3 kB
JavaScript
// Copyright (c) Artillery Software Inc.
// SPDX-License-Identifier: BUSL-1.1
//
// Non-evaluation use of Artillery on Azure requires a commercial license
const { QueueConsumer } = require('./aqs-queue-consumer');
const { SQS_QUEUES_NAME_PREFIX } = require('../aws/constants');
const { DefaultAzureCredential } = require('@azure/identity');
const { QueueClient } = require('@azure/storage-queue');
const {
ContainerInstanceManagementClient
} = require('@azure/arm-containerinstance');
const { BlobServiceClient } = require('@azure/storage-blob');
const { createTest } = require('../aws-ecs/legacy/create-test');
const util = require('../aws-ecs/legacy/util');
const generateId = require('../../util/generate-id');
const EventEmitter = require('eventemitter3');
const debug = require('debug')('platform:azure-aci');
const { IMAGE_VERSION, WAIT_TIMEOUT } = require('../aws-ecs/legacy/constants');
const { regionNames } = require('./regions');
const path = require('node:path');
const { Timeout, sleep } = require('../aws-ecs/legacy/time');
const dotenv = require('dotenv');
const fs = require('node:fs');
const request = require('got');
class PlatformAzureACI {
constructor(script, variablePayload, opts, platformOpts) {
this.script = script;
this.variablePayload = variablePayload;
this.opts = opts;
this.platformOpts = platformOpts;
this.cloudKey =
this.platformOpts.cliArgs.key || process.env.ARTILLERY_CLOUD_API_KEY;
this.events = new EventEmitter();
this.testRunId = platformOpts.testRunId;
this.workers = {};
this.count = 0;
this.waitingReadyCount = 0;
this.artilleryArgs = [];
this.azureTenantId =
process.env.AZURE_TENANT_ID || platformOpts.platformConfig['tenant-id'];
this.azureSubscriptionId =
process.env.AZURE_SUBSCRIPTION_ID ||
platformOpts.platformConfig['subscription-id'];
this.azureClientId = process.env.AZURE_CLIENT_ID;
this.azureClientSecret = process.env.AZURE_CLIENT_SECRET;
this.storageAccount =
process.env.AZURE_STORAGE_ACCOUNT ||
platformOpts.platformConfig['storage-account'];
this.blobContainerName =
process.env.AZURE_STORAGE_BLOB_CONTAINER ||
platformOpts.platformConfig['blob-container'];
this.resourceGroupName =
process.env.AZURE_RESOURCE_GROUP ||
platformOpts.platformConfig['resource-group'];
this.cpu = parseInt(platformOpts.platformConfig.cpu, 10) || 4;
this.memory = parseInt(platformOpts.platformConfig.memory, 10) || 8;
this.region = platformOpts.platformConfig.region || 'eastus';
this.extraEnvVars = {};
if (!regionNames.includes(this.region)) {
const err = new Error(`Invalid region: ${this.region}`);
err.code = 'INVALID_REGION';
err.url = 'https://docs.art/az/regions';
throw err;
}
if (
!this.azureTenantId ||
!this.azureSubscriptionId ||
!this.azureClientId ||
!this.azureClientSecret
) {
const err = new Error('Azure credentials not found');
err.code = 'AZURE_CREDENTIALS_NOT_FOUND';
err.url = 'https://docs.art/az/credentials';
throw err;
}
if (
!this.storageAccount ||
!this.blobContainerName ||
!this.resourceGroupName
) {
const err = new Error('Azure configuration not found');
err.code = 'AZURE_CONFIG_NOT_FOUND';
err.url = 'https://docs.art/az/configuration';
throw err;
}
this.containerInstances = [];
}
async init() {
const credential = new DefaultAzureCredential();
artillery.log('Tenant ID:', this.azureTenantId);
artillery.log('Subscription ID:', this.azureSubscriptionId);
artillery.log('Storage account:', this.storageAccount);
artillery.log('Blob container:', this.blobContainerName);
artillery.log('Resource group:', this.resourceGroupName);
if (this.platformOpts.count > 5) {
const ok = await this.checkLicense();
if (!ok) {
console.log();
console.log(`
+--------------------------------------------------+
| License for Azure integration not found |
| |
| Load tests on Azure are limited to a maximum of |
| 5 workers without a valid license. |
| See https://docs.art/az/license for more details |
+--------------------------------------------------+
`);
throw new Error('ERR_LICENSE_REQUIRED');
}
}
//
// Upload test bundle
//
this.blobServiceClient = new BlobServiceClient(
`https://${this.storageAccount}.blob.core.windows.net`,
credential
);
this.blobContainerClient = this.blobServiceClient.getContainerClient(
this.blobContainerName
);
const customSyncClient = {
send: async (command) => {
// command is always an instance of PutObjectCommand() from @aws-sdk/client-s3
const { Key, Body } = command.input;
const blockBlobClient =
this.blobContainerClient.getBlockBlobClient(Key);
await blockBlobClient.upload(Body, Body.length);
}
};
const { manifest } = await createTest(this.opts.absoluteScriptPath, {
name: this.testRunId,
config: this.platformOpts.cliArgs.config,
flags: this.platformOpts.cliArgs,
customSyncClient
});
//
// Create the queue
//
this.queueName = `${SQS_QUEUES_NAME_PREFIX}_${this.testRunId}.`
.replaceAll('_', '-')
.slice(0, 63);
this.queueUrl =
process.env.AZURE_STORAGE_QUEUE_URL ||
`https://${this.storageAccount}.queue.core.windows.net/${this.queueName}`;
const queueClient = new QueueClient(this.queueUrl, credential);
await queueClient.create();
this.aqsClient = queueClient;
// Construct CLI args for the container
this.artilleryArgs = [];
this.artilleryArgs.push('run');
if (this.platformOpts.cliArgs.environment) {
this.artilleryArgs.push('-e');
this.artilleryArgs.push(this.platformOpts.cliArgs.environment);
}
if (this.platformOpts.cliArgs.solo) {
this.artilleryArgs.push('--solo');
}
if (this.platformOpts.cliArgs.target) {
this.artilleryArgs.push('--target');
this.artilleryArgs.push(this.platformOpts.cliArgs.target);
}
if (this.platformOpts.cliArgs.variables) {
this.artilleryArgs.push('-v');
this.artilleryArgs.push(this.platformOpts.cliArgs.variables);
}
if (this.platformOpts.cliArgs.overrides) {
this.artilleryArgs.push('--overrides');
this.artilleryArgs.push(this.platformOpts.cliArgs.overrides);
}
if (this.platformOpts.cliArgs.dotenv) {
const dotEnvPath = path.resolve(
process.cwd(),
this.platformOpts.cliArgs.dotenv
);
const contents = fs.readFileSync(dotEnvPath);
const envVars = dotenv.parse(contents);
this.extraEnvVars = Object.assign({}, this.extraEnvVars, envVars);
}
if (this.platformOpts.cliArgs['scenario-name']) {
this.artilleryArgs.push('--scenario-name');
this.artilleryArgs.push(this.platformOpts.cliArgs['scenario-name']);
}
if (this.platformOpts.cliArgs.config) {
this.artilleryArgs.push('--config');
const p = manifest.files.filter(
(x) => x.orig === this.opts.absoluteConfigPath
)[0];
this.artilleryArgs.push(p.noPrefixPosix);
}
if (this.platformOpts.cliArgs.quiet) {
this.artilleryArgs.push('--quiet');
}
// This needs to be the last argument for now:
const p = manifest.files.filter(
(x) => x.orig === this.opts.absoluteScriptPath
)[0];
this.artilleryArgs.push(p.noPrefixPosix);
const poolSize =
typeof process.env.CONSUMER_POOL_SIZE !== 'undefined'
? parseInt(process.env.CONSUMER_POOL_SIZE, 10)
: Math.max(Math.ceil(this.count / 25), 5);
const consumer = new QueueConsumer(
{ poolSize },
{
queueUrl: process.env.AZURE_STORAGE_QUEUE_URL || this.queueUrl,
handleMessage: async (message) => {
let payload = null;
let attributes = null;
try {
const result = JSON.parse(message.Body);
payload = result.payload;
attributes = result.attributes;
} catch (parseErr) {
console.error(parseErr);
console.error(message.Body);
}
if (process.env.LOG_QUEUE_MESSAGES) {
console.log(message);
}
if (!payload) {
throw new Error('AQS message with an empty body');
}
if (!attributes || !attributes.testId || !attributes.workerId) {
throw new Error('AQS message with no testId or workerId');
}
if (this.testRunId !== attributes.testId) {
throw new Error('AQS message for an unknown testId');
}
const workerId = attributes.workerId;
if (payload.event === 'workerStats') {
this.events.emit('stats', workerId, payload);
} else if (payload.event === 'artillery.log') {
console.log(payload.log);
} else if (payload.event === 'done') {
// 'done' handler in Launcher exects the message argument to have an "id" and "report" fields
payload.id = workerId;
payload.report = payload.stats;
this.events.emit('done', workerId, payload);
} else if (
payload.event === 'phaseStarted' ||
payload.event === 'phaseCompleted'
) {
payload.id = workerId;
this.events.emit(payload.event, workerId, { phase: payload.phase });
} else if (payload.event === 'workerError') {
global.artillery.suggestedExitCode = payload.exitCode || 1;
if (payload.exitCode !== 21) {
this.events.emit(payload.event, workerId, {
id: workerId,
error: new Error(
`A worker has exited with an error. Reason: ${payload.reason}`
),
level: 'error',
aggregatable: false,
logs: payload.logs
});
}
} else if (payload.event === 'workerReady') {
this.events.emit(payload.event, workerId);
this.waitingReadyCount++;
// TODO: Do this only for batches of workers with "wait" option set
if (this.waitingReadyCount === this.count) {
await this.sendGoSignal();
}
} else {
debug(payload);
}
}
}
);
consumer.on('error', (err) => {
console.error(err);
});
this.queueConsumer = consumer;
const metadata = {
region: this.region,
platformConfig: {
memory: this.memory,
cpu: this.cpu
}
};
global.artillery.globalEvents.emit('metadata', metadata);
}
getDesiredWorkerCount() {
return this.platformOpts.count;
}
async startJob() {
await this.init();
console.log('Creating container instances...');
// Create & run the leader:
const { workerId } = await this.createWorker();
this.workers[workerId] = { workerId };
await this.runWorker(workerId, { isLeader: true });
// Run the rest of the containers we need:
for (let i = 0; i < this.platformOpts.count - 1; i++) {
const { workerId } = await this.createWorker();
this.workers[workerId] = { workerId };
await this.runWorker(workerId);
if (i > 0 && i % 10 === 0) {
const delayMs =
Math.floor(
Math.random() *
parseInt(process.env.AZURE_LAUNCH_STAGGER_SEC || '5', 10)
) * 1000;
await sleep(delayMs);
}
}
let instancesCreated = false;
console.log('Waiting for Azure ACI to create container instances...');
const containerInstanceClient = new ContainerInstanceManagementClient(
new DefaultAzureCredential(),
this.azureSubscriptionId
);
const provisioningWaitTimeout = new Timeout(WAIT_TIMEOUT * 1000).start();
let containerGroupsInTestRun = [];
while (true) {
const containerGroupListResult =
containerInstanceClient.containerGroups.listByResourceGroup(
this.resourceGroupName
);
containerGroupsInTestRun = [];
for await (const containerGroup of containerGroupListResult) {
if (containerGroup.name.indexOf(this.testRunId) > 0) {
containerGroupsInTestRun.push(containerGroup);
}
}
const byStatus = containerGroupsInTestRun.reduce((acc, cg) => {
if (!acc[cg.provisioningState]) {
acc[cg.provisioningState] = 0;
}
acc[cg.provisioningState]++;
return acc;
}, {});
if (
(byStatus.Succeeded || 0) + (byStatus.Running || 0) ===
this.count
) {
instancesCreated = true;
break;
}
if (provisioningWaitTimeout.timedout()) {
break;
}
await sleep(10000);
}
if (instancesCreated) {
console.log(
'Container instances have been created. Waiting for workers to start...'
);
await this.queueConsumer.start();
} else {
console.log('Some containers instances failed to provision');
console.log('Please see the Azure console for details');
console.log(
'https://portal.azure.com/#view/HubsExtension/BrowseResource/resourceType/Microsoft.ContainerInstance%2FcontainerGroups'
);
await global.artillery.shutdown();
}
}
async shutdown() {
this.queueConsumer.stop();
try {
await this.aqsClient.delete();
} catch (_err) {}
const credential = new DefaultAzureCredential();
if (process.env.RETAIN_CONTAINER_INSTANCES !== 'true') {
const containerInstanceClient = new ContainerInstanceManagementClient(
credential,
this.azureSubscriptionId
);
const containerGroupListResult =
containerInstanceClient.containerGroups.listByResourceGroup(
this.resourceGroupName
);
for await (const containerGroup of containerGroupListResult) {
if (containerGroup.name.indexOf(this.testRunId) > 0) {
try {
await containerInstanceClient.containerGroups.beginDeleteAndWait(
this.resourceGroupName,
containerGroup.name
);
} catch (err) {
console.log(err);
}
}
}
}
}
async sendGoSignal() {
const Key = `tests/${this.testRunId}/go.json`;
const blockBlobClient = this.blobContainerClient.getBlockBlobClient(Key);
const _res = await blockBlobClient.upload('', 0);
}
async createWorker() {
const workerId = generateId('worker');
return { workerId };
}
async runWorker(workerId, opts = { isLeader: false }) {
const credential = new DefaultAzureCredential();
const imageVersion =
process.env.ARTILLERY_WORKER_IMAGE_VERSION || IMAGE_VERSION;
const defaultArchitecture = 'x86_64';
const containerImageURL =
process.env.WORKER_IMAGE_URL ||
`public.ecr.aws/d8a4z9o5/artillery-worker:${imageVersion}-${defaultArchitecture}`;
const client = new ContainerInstanceManagementClient(
credential,
this.azureSubscriptionId
);
const environmentVariables = [
{
name: 'WORKER_ID_OVERRIDE',
value: workerId
},
{
name: 'ARTILLERY_TEST_RUN_ID',
value: this.testRunId
},
// {
// name: 'DEBUGX',
// value: 'true',
// },
{
name: 'DEBUG',
value: 'cloud'
},
{
name: 'IS_LEADER',
value: String(opts.isLeader)
},
{
name: 'AQS_QUEUE_NAME',
value: this.queueName
},
{
name: 'AZURE_STORAGE_ACCOUNT',
value: this.storageAccount
},
{
name: 'AZURE_SUBSCRIPTION_ID',
secureValue: this.azureSubscriptionId
},
{
name: 'AZURE_TENANT_ID',
secureValue: this.azureTenantId
},
{
name: 'AZURE_CLIENT_ID',
secureValue: this.azureClientId
},
{
name: 'AZURE_CLIENT_SECRET',
secureValue: this.azureClientSecret
},
{
name: 'AZURE_STORAGE_AUTH_MODE',
value: 'login'
}
];
if (this.cloudKey) {
environmentVariables.push({
name: 'ARTILLERY_CLOUD_API_KEY',
secureValue: this.cloudKey
});
}
const cloudEndpoint = process.env.ARTILLERY_CLOUD_ENDPOINT;
if (cloudEndpoint) {
environmentVariables.push({
name: 'ARTILLERY_CLOUD_ENDPOINT',
secureValue: cloudEndpoint
});
}
for (const [name, value] of Object.entries(this.extraEnvVars)) {
environmentVariables.push({ name, value });
}
const containerGroup = {
location: this.region,
containers: [
{
name: 'artillery-worker',
image: containerImageURL,
resources: {
requests: {
cpu: this.cpu,
memoryInGB: this.memory
}
},
command: [
'/artillery/loadgen-worker',
'-z',
'yes', // yes for Azure
'-q',
this.queueUrl,
'-p',
this.blobContainerName,
'-a',
util.btoa(JSON.stringify(this.artilleryArgs)),
'-i',
this.testRunId,
'-t',
String(WAIT_TIMEOUT),
'-d',
'NOT_USED_ON_AZURE',
'-r',
'NOT_USED_ON_AZURE'
],
environmentVariables
}
],
osType: 'Linux',
restartPolicy: 'Never'
};
if (!this.ts) {
this.ts = Date.now();
}
const containerGroupName = `artillery-test-${this.ts}-${this.testRunId}-${this.count}`;
try {
const containerInstance =
await client.containerGroups.beginCreateOrUpdate(
this.resourceGroupName,
containerGroupName,
containerGroup
);
this.containerInstances.push(containerInstance);
this.count++;
} catch (err) {
// TODO: Make this better
console.log(err.code);
console.log(err.details?.error?.message);
throw err;
}
}
async stopWorker(_workerId) {}
async checkLicense() {
const baseUrl =
process.env.ARTILLERY_CLOUD_ENDPOINT || 'https://app.artillery.io';
const res = await request.get(`${baseUrl}/api/user/whoami`, {
headers: {
'x-auth-token': this.cloudKey
},
throwHttpErrors: false,
retry: {
limit: 3
}
});
try {
const body = JSON.parse(res.body);
const activeOrg = body.activeOrg;
if (!activeOrg) {
return false;
}
if (!Array.isArray(body.memberships)) {
return false;
}
const activeMembership = body.memberships.find(
(membership) => membership.id === activeOrg
);
if (!activeMembership) {
return false;
}
const plan = activeMembership.plan;
return plan !== 'developer';
} catch (_err) {
return false;
}
}
}
module.exports = PlatformAzureACI;