UNPKG

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
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