@artilleryio/platform-fargate
Version:
Fargate support for Artillery
504 lines (415 loc) • 11.6 kB
JavaScript
const { ObjectStore } = require('../cloud/object-store');
const { getBucketName, getAccountId } = require('../util');
const getrc = require('../utils/get-rc');
const util = require('util');
const AWS = require('aws-sdk');
const debug = require('debug')('store');
const uuidv4 = require('uuid/v4');
const { createDynamoDocumentClient } = require('../utils/create-dynamo-document-client');
const getBackendId = require('../utils/get-backend-id');
const { writeInBatches } = require('./write-in-batches');
const { ConsoleOutputSerializeError } = require('../errors');
const { StorageBackendAuroraV1 } = require('./aurora-serverless');
//
// DynamoDB-backed storage for TestRuns
//
class StorageBackendDynamo {
constructor(id, opts) {
this.id = id;
this.tags = [];
this.tagsSearchPatterns = [];
}
static properties = {
type: 'aws:dynamodb',
description: 'AWS DynamoDB backend',
};
async init(id, opts) {
debug('testRunObject init');
if (!this.tableName) {
const accountId = await getAccountId();
this.tableName = process.env.ARTILLERY_TEST_RUNS_TABLE || `artilleryio-test-runs-${accountId}`;
this.commonObjectsTable = `artilleryio-common-objects-${accountId}`;
}
const { backendId } = await getBackendId();
this.region = backendId;
AWS.config.update({region: this.region});
this.ddb = createDynamoDocumentClient();
// Large objects such as console output live in S3:
this.objectPrefix = `data-${this.region}`;
const bucketName = await getBucketName();
this.objectStore = new ObjectStore({ backend: 'aws', bucket: bucketName });
return this;
}
async setChecks() {
return this; // not implemented
}
async setEndedAt(ts) {
debug('testRunObject setEndedAt');
// Don't need this on Dynamo
return this;
}
async setTagsSearchStrings(tags = [], additionalAttributes = {}) {
const attrs = Object.entries(additionalAttributes).length > 0 ?
{ ...additionalAttributes, createdTime: this.createdTime } : additionalAttributes
if (this.tagsSearchPatterns.length === 0) {
this.tagsSearchPatterns = this._generateTagCombinations(
tags
.map((x) => x.name + ':' + x.value)
.sort()
)
}
await this._writeInBatches(this.tagsSearchPatterns.map((x) => {
const r = {
Item: {
testRunId: this.id,
kind: `tags#${x}`,
valueType: typeof x,
...attrs,
},
};
r.Item[`${typeof x.value}Value`] = x.value;
return r;
}));
}
async setTags(testRunTags) {
debug('testRunObject setTags');
this.tags = testRunTags;
//
// Set tags for test for this test run:
//
// This writes all tags as one field:
const params = {
TableName: this.tableName,
Item: {
testRunId: this.id,
kind: 'tags',
tags: this.tags.map(x => x.name + ':' + x.value).sort().join(',')
}
};
await this.ddb.put(params).promise();
await this.setTagsSearchStrings(this.tags);
//
// Update global list of tags:
//
const tagData = this.tags.map((x) => {
const r = {
Item: {
testRunId: `__artilleryio:virtual:key`,
kind: `tag#${x.name}:${x.value}`,
name: x.name,
valueType: typeof x.value,
}
};
r.Item[`${(typeof x.value)}Value`] = x.value;
return r;
});
debug(tagData);
await this._writeInBatches(tagData);
return this;
}
async setReport({aggregate}) {
debug('testRunObject setReport');
if (aggregate) {
const aggregateJson = JSON.stringify(aggregate);
debug('writing aggregate, item size:', aggregateJson.length);
// Aggregate report:
const objectPath = `${this.objectPrefix}/${this.id}/aggregate.json`;
await this.objectStore.put(objectPath, aggregateJson);
const params = {
TableName: this.tableName,
Item: {
testRunId: this.id,
kind: 'report#aggregate',
data: `s3://${objectPath}`,
}
};
await this.ddb.put(params).promise();
}
return this;
}
async setTextLog(lines) {
debug('testRunObject setTextLog');
let data;
try {
JSON.stringify(lines);
} catch (stringifyErr) {
throw new ConsoleOutputSerializeError('Failed trying to serialize console output');
}
let textLog = '';
for(const args of lines) {
textLog += util.format( ...Object.keys(args).map(k => args[k]))+'\n';
}
const objectPath = `${this.objectPrefix}/${this.id}/console-log.json`;
await this.objectStore.put(objectPath, textLog);
const params = {
TableName: this.tableName,
Item: {
testRunId: this.id,
kind: 'consoleOutput#0',
data: `s3://${objectPath}`,
}
};
await this.ddb.put(params).promise();
return this;
}
async setStatus(statusString, endTime) {
debug('testRunObject setStatus:', statusString);
const endTimeISOString = new Date(endTime).toISOString();
await this.ddb.put({
TableName: this.tableName,
Item: {
testRunId: this.id,
kind: 'lastStatus',
statusName: statusString,
createdTime: this.createdTime,
endTime: endTimeISOString // TODO: endTime -> updatedTime
}
}).promise();
// every time the status is updated, update it in the search facets too
await this.setTagsSearchStrings(this.tags, {
tagStatus: statusString,
endTime: endTimeISOString
});
return this;
}
async setMetadata(metadata) {
debug('testRunObject setMetadata');
const items = [];
// FIXME: this means metadata has to be a FLAT object, nothing
// nested inside
const metadata2 = JSON.parse(JSON.stringify(metadata));
delete metadata2.tags;
for (const [key, val] of Object.entries(metadata2)) {
const r = {
Item: {
testRunId: this.id,
kind: `metadata#${key}`,
key: key,
valueType: typeof val
}
};
r.Item[`${typeof val}Value`] = val;
items.push(r);
}
debug(items);
await this._writeInBatches(items);
this.createdTime = new Date(metadata.startedAt || Date.now()).toISOString();
// Set createdTime attribute from metadata
await this.ddb.put({
TableName: this.tableName,
Item: {
testRunId: this.id,
kind: 'createdTime',
createdTime: this.createdTime,
}
}).promise();
return this;
}
async setTasks(taskArns) {
debug('testRunObject setTasks');
const items = taskArns.map((arn) => {
return {
Item: {
testRunId: this.id,
kind: `metadata#task#${arn}`,
value: arn
}
}
});
await this._writeInBatches(items);
return this;
}
// TODO: Refactor this + setReport()
// ts is an epoch timestamp
async recordIntermediateReport(report, ts) {
debug('testRunObject recordIntermediateReport');
await this.ddb.put({
TableName: this.tableName,
Item: {
testRunId: this.id,
kind: `report#intermediate#${ts}`,
data: JSON.stringify(report),
}
}).promise();
return this;
}
// TODO: Add size checks
async addNote(note) {
debug('testRunObject addNote');
// Add note to test:
const ts = Date.now();
await this.ddb.put({
TableName: this.tableName,
Item: {
testRunId: this.id,
kind: `note#${ts}`,
noteId: uuidv4(),
createdTime: new Date(ts).toISOString(),
data: note // FIXME: XSS
},
}).promise();
return this;
}
_makeBatchWriteRequest(tableName, items) {
const batchWriteRequest = {
RequestItems: {}
};
batchWriteRequest.RequestItems[tableName] = items.map(i => {
return { PutRequest: i };
});
return batchWriteRequest;
}
_generateTagCombinations(tags) {
const combinations = new Set();
const slent = Math.pow(2, tags.length);
let temp = [];
for (var i = 0; i < slent; i++) {
temp = [];
for (var j = 0; j < tags.length; j++) {
if (i & Math.pow(2, j)) {
temp.push(tags[j]);
}
}
if (temp.length > 0) {
combinations.add(temp.sort().join(","));
}
}
return [...combinations];
}
async _writeInBatches(items) {
return writeInBatches(this.ddb, items, (batch) =>
this._makeBatchWriteRequest(this.tableName, batch)
);
}
}
class StorageBackendNoop {
constructor() {
return this;
}
async init() {
return this;
}
async setChecks() {
return this;
}
async setEndedAt() {
return this;
}
async setTags() {
return this;
}
async bulkInsertTags() {
return this;
}
async setTags() {
return this;
}
async setReport() {
return this;
}
async setTextLog() {
return this;
}
async setStatus() {
return this;
}
async setMetadata() {
return this;
}
async setTasks() {
return this;
}
async recordIntermediateReport() {
return this;
}
async addNote() {
return this;
}
}
class TestRunObject {
constructor(id, opts = {}) {
this.id = id;
if (opts && opts.type === 'aws:aurora-v1') {
this.backend = new StorageBackendAuroraV1(id, opts);
} else if (opts && opts.type == 'cloud') {
this.backend = new StorageBackendCloud(id, opts);
}else if (opts && opts.type == 'noop') {
this.backend = new StorageBackendNoop(id, opts);
} else {
this.backend = new StorageBackendDynamo(id, opts);
}
this.opts = opts;
return this;
}
async init(id, opts) {
await this.backend.init(this.id, opts); // TODO: id is unnecessary
return this;
}
static async get(id) {
}
static async list(attrs, limit, offset) {
// supported attrs: id, tags
}
async setChecks(checks) {
await this.backend.setChecks(checks);
return this;
}
async setEndedAt(ts) {
this.endedAt = ts;
await this.backend.setEndedAt(ts);
return this;
}
async bulkInsertTags(tags) {
await this.backend.bulkInsertTags(tags);
return this;
}
async setTags(tags) {
this.tags = tags;
await this.backend.setTags(tags);
return this;
}
// TODO: This updates in one go, we want to do partial updates for intermediate
async setReport(intermediateReports, aggregateReport) {
this.report = {
intermediate: intermediateReports,
aggregate: aggregateReport
};
await this.backend.setReport(this.report);
return this;
}
async setTextLog(lines) {
this.textLog = lines;
await this.backend.setTextLog(lines);
return this;
}
async setStatus(statusString, ts) {
if(typeof ts === 'undefined') {
ts = Date.now();
}
await this.backend.setStatus(statusString, ts);
return this;
}
async setMetadata(metadata) {
await this.backend.setMetadata(metadata);
return this;
}
async setTasks(taskArns) {
await this.backend.setTasks(taskArns);
return this;
}
async setTagsSearchStrings(tags, additionalAttributes) {
await this.backend.setTagsSearchStrings(tags, additionalAttributes);
return this;
}
async recordIntermediateReport(report, ts) {
await this.backend.recordIntermediateReport(report, ts);
return this;
}
async addNote(note, userId) {
await this.backend.addNote(note, userId);
return this;
}
}
module.exports = {
TestRunObject,
};