serverless-spy
Version:
CDK-based library for writing elegant integration tests on AWS serverless architecture and an additional web console to monitor events in real time.
445 lines (443 loc) • 20.7 kB
JavaScript
const __dirname = import.meta.dirname;
import { envVariableNames, init_envVariableNames } from "./common/envVariableNames.mjs";
import * as fs from "fs";
import * as path from "path";
import { PythonLayerVersion } from "@aws-cdk/aws-lambda-python-alpha";
import { BundlingFileAccess, CfnOutput, Duration, Stack, aws_iam, custom_resources } from "aws-cdk-lib";
import * as dynamoDb from "aws-cdk-lib/aws-dynamodb";
import * as events from "aws-cdk-lib/aws-events";
import * as targets from "aws-cdk-lib/aws-events-targets";
import { Effect } from "aws-cdk-lib/aws-iam";
import * as lambda from "aws-cdk-lib/aws-lambda";
import { Architecture, SingletonFunction } from "aws-cdk-lib/aws-lambda";
import * as dynamoDbStream from "aws-cdk-lib/aws-lambda-event-sources";
import { SqsEventSource } from "aws-cdk-lib/aws-lambda-event-sources";
import * as lambdaNode from "aws-cdk-lib/aws-lambda-nodejs";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as s3notif from "aws-cdk-lib/aws-s3-notifications";
import * as sns from "aws-cdk-lib/aws-sns";
import * as snsSubs from "aws-cdk-lib/aws-sns-subscriptions";
import * as sqs from "aws-cdk-lib/aws-sqs";
import { Construct } from "constructs";
//#region src/ServerlessSpy.ts
init_envVariableNames();
const isLambdaFunction = (node) => "functionName" in node && "functionArn" in node && "runtime" in node;
const serverlessSpyIotEndpointCrNamePrefix = "ServerlessSpyIotEndpoint";
var ServerlessSpy = class extends Construct {
constructor(scope, id, props) {
super(scope, id);
this.props = props;
this.createdResourcesBySSpy = [];
this.lambdaSubscriptionPool = [];
this.lambdasSpied = [];
this.serviceKeys = [];
this.spiedNodes = [];
this.layerMap = {};
const rootStack = this.cleanName(this.findRootStack(Stack.of(this)).node.id);
const getIoTEndpoint = new custom_resources.AwsCustomResource(this, serverlessSpyIotEndpointCrNamePrefix, {
onCreate: {
service: "Iot",
action: "describeEndpoint",
physicalResourceId: custom_resources.PhysicalResourceId.fromResponse("endpointAddress"),
parameters: { endpointType: "iot:Data-ATS" }
},
onUpdate: {
service: "Iot",
action: "describeEndpoint",
physicalResourceId: custom_resources.PhysicalResourceId.fromResponse("endpointAddress"),
parameters: { endpointType: "iot:Data-ATS" }
},
installLatestAwsSdk: false,
policy: custom_resources.AwsCustomResourcePolicy.fromSdkCalls({ resources: custom_resources.AwsCustomResourcePolicy.ANY_RESOURCE }),
functionName: serverlessSpyIotEndpointCrNamePrefix + rootStack
});
this.iotEndpoint = getIoTEndpoint.getResponseField("endpointAddress");
this.createdResourcesBySSpy.push(getIoTEndpoint);
new CfnOutput(this, "ServerlessSpyIoTEndpoint", {
key: "ServerlessSpyWsUrl",
value: `${this.iotEndpoint}/${rootStack}`
});
this.lambdaSubscriptionMain = this.provideFunctionForSubscription();
}
getDefaultLambdaEnvironmentVariables() {
return { NODE_OPTIONS: "--enable-source-maps" };
}
/**
* Initalize spying on resources given as parameter.
* @param nodes Which reources and their children to spy on.
*/
spyNodes(nodes) {
for (const node of nodes) {
let ns = this.getAllNodes(node);
this.internalSpyNodes(ns);
}
this.finalizeSpy();
}
/**
* Initalize spying on resources.
* @param filter Limit which resources to spy on.
*/
spy(filter) {
let nodes = this.getAllNodes(Stack.of(this));
const filterWithDefaults = {
spyLambda: true,
spySqs: true,
spySnsTopic: true,
spySnsSubsription: true,
spyEventBridge: true,
spyEventBridgeRule: true,
spyS3: true,
spyDynamoDB: true,
...filter
};
const CRID = "AWS" + custom_resources.AwsCustomResource.PROVIDER_FUNCTION_UUID.replace(/-/gi, "").substring(0, 16);
nodes = nodes.filter((node) => {
if (node.node.id.startsWith(CRID) || node.node.id === "Provider" || node instanceof SingletonFunction) {
if (this.props?.debugMode) console.info(`Skipping ${node.node.id}`);
return false;
} else if (filterWithDefaults.spyLambda && (node instanceof lambda.Function || node instanceof NodejsFunction || isLambdaFunction(node))) return true;
else if (filterWithDefaults.spySnsTopic && node instanceof sns.Topic) return true;
else if (filterWithDefaults.spySnsSubsription && node instanceof sns.Subscription) return true;
else if (filterWithDefaults.spyS3 && node instanceof s3.Bucket) return true;
else if (filterWithDefaults.spyDynamoDB && node instanceof dynamoDb.Table) return true;
else if (filterWithDefaults.spyDynamoDB && node instanceof dynamoDb.TableV2) return true;
else if (filterWithDefaults.spyEventBridge && node instanceof events.EventBus) return true;
else if (filterWithDefaults.spyEventBridgeRule && node instanceof events.Rule) return true;
else if (filterWithDefaults.spySqs && node instanceof lambda.CfnEventSourceMapping) return true;
else if (filterWithDefaults.spySqs && this.props?.spySqsWithNoSubscriptionAndDropAllMessages && node instanceof sqs.Queue) return true;
return false;
});
this.internalSpyNodes(nodes);
this.finalizeSpy();
}
internalSpyNodes(nodes) {
for (const node of nodes) this.internalSpyNode(node);
}
finalizeSpy() {
for (const func of this.lambdaSubscriptionPool) func.function.addEnvironment(envVariableNames.SSPY_INFRA_MAPPING, JSON.stringify(func.mapping));
for (const func of this.lambdasSpied) func.function.addEnvironment(envVariableNames.SSPY_INFRA_MAPPING, JSON.stringify(func.mapping));
if (this.props?.generateSpyEventsFileLocation) this.writeSpyEventsClass(this.props?.generateSpyEventsFileLocation);
}
getExtensionAssetLocation() {
let extensionAssetLocation = path.join(__dirname, "../extension/dist/layer");
const extensionAssetLocationAlt = path.join(__dirname, "../lib/extension/dist/layer");
if (!fs.existsSync(extensionAssetLocation)) if (!fs.existsSync(extensionAssetLocationAlt)) throw new Error(`Folder with assets for extension does not exists at ${extensionAssetLocation} or at ${extensionAssetLocationAlt} `);
else extensionAssetLocation = extensionAssetLocationAlt;
const extensionAssetLocationWrapper = path.join(extensionAssetLocation, "spy-wrapper");
if (!fs.existsSync(extensionAssetLocationWrapper)) throw new Error(`Wrapper script for extension does not exists ${extensionAssetLocation}`);
const extensionAssetLocationCode = path.join(extensionAssetLocation, `nodejs/node_modules/interceptor.js`);
if (!fs.existsSync(extensionAssetLocationCode)) throw new Error(`Code for extension does not exists ${extensionAssetLocationCode}`);
return extensionAssetLocation;
}
getLanguageExtensionAssetLocation(language) {
const rootDir = path.join(__dirname, "..");
let extensionAssetLocation = path.join(rootDir, `extensions/${language}`);
const extensionAssetLocationAlt = path.join(rootDir, `lib/extensions/${language}`);
if (!fs.existsSync(extensionAssetLocation)) if (!fs.existsSync(extensionAssetLocationAlt)) throw new Error(`Folder with assets for extension for ${language} does not exists at ${extensionAssetLocation} or at ${extensionAssetLocationAlt} `);
else extensionAssetLocation = extensionAssetLocationAlt;
const extensionAssetLocationWrapper = path.join(extensionAssetLocation, "spy-wrapper");
if (!fs.existsSync(extensionAssetLocationWrapper)) throw new Error(`Wrapper script for extension does not exists at ${extensionAssetLocationWrapper}`);
return extensionAssetLocation;
}
/**
* Write SpyEvents class, which helps with writing the code for tests.
* @param fileLocation
*/
writeSpyEventsClass(fileLocation) {
fs.mkdirSync(path.dirname(fileLocation), { recursive: true });
const code = `/* eslint-disable */\nexport class ServerlessSpyEvents {\n${this.serviceKeys.map((sk) => ` ${sk.replace(/#/g, "")}: '${sk}' = '${sk}';\n`).join("")}}\n`;
fs.writeFileSync(fileLocation, code);
}
getAllNodes(parent) {
const nodes = [];
nodes.push(parent);
this.getAllNodesRecursive(parent, nodes);
return nodes;
}
getAllNodesRecursive(parent, nodes) {
for (const node of parent.node.children) {
nodes.push(node);
this.getAllNodesRecursive(node, nodes);
}
}
internalSpyNode(node) {
if (this.spiedNodes.includes(node)) return;
this.spiedNodes.push(node);
if (this.createdResourcesBySSpy.includes(node)) return;
if (this.lambdaSubscriptionPool.find((s) => s.function === node)) return;
if (this.props?.debugMode) console.info("Spy on node", this.getConstructName(node));
if (node instanceof lambda.Function || node instanceof NodejsFunction || isLambdaFunction(node)) this.internalSpyLambda(node);
else if (node instanceof sns.Topic) this.internalSpySnsTopic(node);
else if (node instanceof sns.Subscription) this.internalSpySnsSubscription(node);
else if (node instanceof s3.Bucket) this.internalSpyS3(node);
else if (node instanceof dynamoDb.Table) this.internalSpyDynamodb(node);
else if (node instanceof dynamoDb.TableV2) this.internalSpyDynamodb(node);
else if (node instanceof events.EventBus) this.internalSpyEventBus(node);
else if (node instanceof events.Rule) this.internalSpyEventBusRule(node);
else if (node instanceof lambda.CfnEventSourceMapping) this.internalSpySqs(node);
else if (node instanceof sqs.Queue) {
if (this.props?.spySqsWithNoSubscriptionAndDropAllMessages) this.internalSpySpySqsWithNoSubscription(node);
}
}
getExtensionForRuntime(runtime, architecture) {
const layerKey = `sspy_extension_${runtime.toString()}_${architecture.name.toString()}`.replace(/\./g, "_");
let layer = this.layerMap[layerKey];
let spyWrapperPath = "/opt/spy-wrapper";
switch (runtime.name) {
case lambda.Runtime.PYTHON_3_8.name:
case lambda.Runtime.PYTHON_3_9.name:
case lambda.Runtime.PYTHON_3_10.name:
case lambda.Runtime.PYTHON_3_11.name:
case lambda.Runtime.PYTHON_3_12.name:
spyWrapperPath = "/opt/python/spy-wrapper";
layer = layer || new PythonLayerVersion(this, layerKey, {
compatibleRuntimes: [runtime],
compatibleArchitectures: [architecture],
entry: this.getLanguageExtensionAssetLocation("python"),
bundling: { bundlingFileAccess: BundlingFileAccess.VOLUME_COPY }
});
break;
case lambda.Runtime.NODEJS_12_X.name:
case lambda.Runtime.NODEJS_14_X.name:
case lambda.Runtime.NODEJS_16_X.name:
case lambda.Runtime.NODEJS_18_X.name:
case lambda.Runtime.NODEJS_20_X.name:
case lambda.Runtime.NODEJS_22_X.name:
layer = layer || new lambda.LayerVersion(this, layerKey, {
compatibleRuntimes: [runtime],
compatibleArchitectures: [architecture],
code: lambda.Code.fromAsset(this.getExtensionAssetLocation())
});
break;
default:
console.log(`No extensions available for ${runtime.toString()}`);
return;
}
this.layerMap[layerKey] = layer;
this.createdResourcesBySSpy.push(layer);
return {
layer,
spyWrapperPath
};
}
internalSpySpySqsWithNoSubscription(queue) {
if (this.findElement((n) => n instanceof lambda.CfnEventSourceMapping && n.eventSourceArn === queue.queueArn)) return;
const queueName = this.getConstructName(queue);
const func = new NodejsFunction(this, `${queueName}SqsSubscriptionAndDropAllMessages`, {
memorySize: 512,
timeout: Duration.seconds(5),
runtime: lambda.Runtime.NODEJS_22_X,
handler: "handler",
entry: this.getAssetLocation("functions/sqsSubscriptionAndDropAllMessages.js"),
environment: this.getDefaultLambdaEnvironmentVariables()
});
func.addEventSource(new SqsEventSource(queue));
this.setupForIoT(func);
const { layer, spyWrapperPath } = this.getExtensionForRuntime(func.runtime, func.architecture);
func.addLayers(layer);
func.addEnvironment("AWS_LAMBDA_EXEC_WRAPPER", spyWrapperPath);
if (this.props?.debugMode) func.addEnvironment(envVariableNames.SSPY_DEBUG, "true");
this.createdResourcesBySSpy.push(func);
const serviceKey = `Sqs#${queueName}`;
this.addMappingToFunction(func, {
key: queue.queueArn,
value: serviceKey
});
this.serviceKeys.push(serviceKey);
func.addEnvironment(envVariableNames.SSPY_SUBSCRIBED_TO_SQS, "true");
}
internalSpySqs(node) {
const queue = this.findElement((n) => n instanceof sqs.Queue && n.queueArn === node.eventSourceArn);
const func = this.findElement((n) => n instanceof lambda.Function && n.functionName === node.functionName);
if (queue && func) {
const serviceKey = `Sqs#${this.getConstructName(queue)}`;
this.addMappingToFunction(func, {
key: queue.queueArn,
value: serviceKey
});
this.serviceKeys.push(serviceKey);
func.addEnvironment(envVariableNames.SSPY_SUBSCRIBED_TO_SQS, "true");
}
}
createFunctionForSubscription(index) {
const func = new lambdaNode.NodejsFunction(this, `Subscription${index}`, {
memorySize: 512,
timeout: Duration.seconds(5),
runtime: lambda.Runtime.NODEJS_22_X,
handler: "handler",
entry: this.getAssetLocation("functions/sendMessage.js"),
environment: { NODE_OPTIONS: "--enable-source-maps" }
});
this.setupForIoT(func);
return func;
}
internalSpyS3(s3Bucket) {
s3Bucket.addEventNotification(s3.EventType.OBJECT_CREATED_PUT, new s3notif.LambdaDestination(this.lambdaSubscriptionMain.function));
const serviceKey = `S3#${this.getConstructName(s3Bucket)}`;
this.lambdaSubscriptionMain.mapping[s3Bucket.bucketArn] = serviceKey;
this.serviceKeys.push(serviceKey);
}
internalSpyDynamodb(table) {
table.node.defaultChild.streamSpecification = { streamViewType: dynamoDb.StreamViewType.NEW_AND_OLD_IMAGES };
try {
table.tableStreamArn = table.node.defaultChild.attrStreamArn;
} catch (e) {
if (!(e instanceof TypeError && e.message.includes("only a getter"))) throw e;
}
this.lambdaSubscriptionMain.function.addEventSource(new dynamoDbStream.DynamoEventSource(table, {
startingPosition: lambda.StartingPosition.LATEST,
batchSize: 1,
retryAttempts: 0
}));
const serviceKey = `DynamoDB#${this.getConstructName(table)}`;
this.lambdaSubscriptionMain.mapping[table.tableArn] = serviceKey;
this.serviceKeys.push(serviceKey);
}
internalSpyEventBusRule(rule) {
const { eventBusName } = rule.node.defaultChild;
let bridgeName = "Default";
if (!!eventBusName) {
const eventBridge = this.getEventBridge(eventBusName);
if (!eventBridge) throw new Error(`Can not find EventBridge with name "${eventBusName}"`);
bridgeName = this.getConstructName(eventBridge);
}
const functionSubscription = this.provideFunctionForSubscription((s) => !s.usedForEventBridge);
functionSubscription.usedForEventBridge = true;
rule.addTarget(new targets.LambdaFunction(functionSubscription.function));
const ruleName = this.getConstructName(rule);
const serviceKey = `EventBridgeRule#${bridgeName}#${ruleName}`;
functionSubscription.mapping.eventBridge = serviceKey;
this.serviceKeys.push(serviceKey);
}
internalSpyEventBus(eventBus) {
const functionSubscription = this.provideFunctionForSubscription((s) => !s.usedForEventBridge);
functionSubscription.usedForEventBridge = true;
const bridgeName = this.getConstructName(eventBus);
const rule = new events.Rule(this, `RuleAll${bridgeName}`, {
eventBus,
eventPattern: { version: ["0"] },
targets: [new targets.LambdaFunction(functionSubscription.function)]
});
this.createdResourcesBySSpy.push(rule);
const serviceKey = `EventBridge#${bridgeName}`;
functionSubscription.mapping.eventBridge = serviceKey;
this.serviceKeys.push(serviceKey);
}
internalSpySnsTopic(topic) {
const functionSubscription = this.provideFunctionForSubscription((s) => !s.subsribedTopics.includes(topic));
const subscription = topic.addSubscription(new snsSubs.LambdaSubscription(functionSubscription.function));
this.createdResourcesBySSpy.push(subscription);
const serviceKey = `SnsTopic#${this.getConstructName(topic)}`;
functionSubscription.mapping[topic.topicArn] = serviceKey;
this.serviceKeys.push(serviceKey);
functionSubscription.subsribedTopics.push(topic);
}
internalSpySnsSubscription(subscription) {
if (!subscription.node.scope) return;
const topic = this.getTopic(subscription.node.defaultChild.topicArn);
if (!topic) throw new Error("Can not find Topic");
const functionSubscription = this.provideFunctionForSubscription((s) => !s.subsribedTopics.includes(topic));
const { filterPolicy } = subscription.node.defaultChild;
const subscriptionClone = topic.addSubscription(new snsSubs.LambdaSubscription(functionSubscription.function));
subscriptionClone.node.defaultChild.filterPolicy = filterPolicy;
this.createdResourcesBySSpy.push(subscriptionClone);
const topicName = this.getConstructName(topic);
const targetName = this.getConstructName(subscription.node.scope);
functionSubscription.subsribedTopics.push(topic);
const serviceKey = `SnsSubscription#${topicName}#${targetName}`;
functionSubscription.mapping[topic.topicArn] = serviceKey;
this.serviceKeys.push(serviceKey);
}
provideFunctionForSubscription(filterFunction) {
let functionSubscription;
if (filterFunction) functionSubscription = this.lambdaSubscriptionPool.find(filterFunction);
else if (this.lambdaSubscriptionPool.length > 0) functionSubscription = this.lambdaSubscriptionPool[0];
if (!functionSubscription) {
functionSubscription = {
subsribedTopics: [],
usedForEventBridge: false,
mapping: {},
function: this.createFunctionForSubscription(this.lambdaSubscriptionPool.length)
};
this.lambdaSubscriptionPool.push(functionSubscription);
}
return functionSubscription;
}
setupForIoT(func) {
func.addEnvironment(envVariableNames.SSPY_ROOT_STACK, this.cleanName(this.findRootStack(Stack.of(this)).node.id));
func.addEnvironment(envVariableNames.SSPY_IOT_ENDPOINT, this.iotEndpoint);
func.addToRolePolicy(new aws_iam.PolicyStatement({
actions: ["iot:*"],
effect: Effect.ALLOW,
resources: ["*"]
}));
}
internalSpyLambda(func) {
const { layer, spyWrapperPath } = this.getExtensionForRuntime(func.runtime, func.architecture || Architecture.X86_64);
if (!layer) return;
func.addLayers(layer);
const functionName = this.getConstructName(func);
func.addEnvironment(envVariableNames.SSPY_FUNCTION_NAME, functionName);
func.addEnvironment("AWS_LAMBDA_EXEC_WRAPPER", spyWrapperPath);
if (this.props?.debugMode) func.addEnvironment(envVariableNames.SSPY_DEBUG, "true");
this.setupForIoT(func);
this.serviceKeys.push(`Function#${functionName}#Request`);
this.serviceKeys.push(`Function#${functionName}#Error`);
this.serviceKeys.push(`Function#${functionName}#Console`);
this.serviceKeys.push(`Function#${functionName}#Response`);
this.addMappingToFunction(func);
}
getConstructName(construct) {
let constructName = construct.node.path;
const { node } = Stack.of(this);
if (constructName.startsWith(node.id)) constructName = constructName.substring(node.id.length + 1);
return this.cleanName(constructName);
}
cleanName(name) {
return name.replace(/[-_]+/g, " ").replace(/[^\w\s]/g, "").replace(/\s(.)/g, ($1) => $1.toUpperCase()).replace(/\s/g, "").replace(/^(.)/, ($1) => $1.toUpperCase());
}
getTopic(topicArn) {
return this.findElement((node) => node instanceof sns.Topic && node.topicArn === topicArn);
}
getEventBridge(eventBusName) {
return this.findElement((node) => (node instanceof events.EventBus || node.constructor.name === "ImportedEventBus") && node.eventBusName === eventBusName);
}
findRootStack(stack) {
if (stack.nested) {
const parentStack = stack.nestedStackParent;
if (parentStack) return this.findRootStack(parentStack);
return stack;
} else return stack;
}
findElement(filterFunc, parent) {
if (!parent) parent = this.findRootStack(Stack.of(this));
for (const node of parent.node.children) {
if (filterFunc(node)) return node;
const elementFoundInChild = this.findElement(filterFunc, node);
if (elementFoundInChild) return elementFoundInChild;
}
}
addMappingToFunction(func, keyValue) {
for (const fs$2 of this.lambdasSpied) if (fs$2.function === func) {
if (keyValue) fs$2.mapping[keyValue.key] = keyValue.value;
return;
}
const fs$1 = {
function: func,
mapping: {}
};
if (keyValue) fs$1.mapping[keyValue.key] = keyValue.value;
this.lambdasSpied.push(fs$1);
}
getAssetLocation(location) {
const loc = path.join(__dirname, "../lib/" + location);
if (fs.existsSync(loc)) return loc;
const loc2 = path.join(__dirname, "../../lib/" + location);
if (fs.existsSync(loc2)) return loc2;
throw new Error(`Location ${loc} and ${loc2} does not exists.`);
}
};
//#endregion
export { ServerlessSpy };
//# sourceMappingURL=ServerlessSpy.mjs.map