UNPKG

@davec-funvr/serverless-plugin-lambda-dead-letter

Version:

serverless plugin that can configure a lambda with a dead letter queue or topic

415 lines (352 loc) 12.8 kB
'use strict' const BbPromise = require('bluebird') class Plugin { constructor (serverless, options) { this.serverless = serverless this.options = options this.provider = serverless.getProvider('aws') this.deploy = options.noDeploy === undefined ? true : !options.noDeploy this.hooks = { 'deploy:deploy': () => BbPromise.bind(this).then(this.setLambdaDeadLetterConfig), 'deploy:compileEvents': () => BbPromise.bind(this).then(this.compileFunctionDeadLetterResources), 'package:compileEvents': () => BbPromise.bind(this).then(this.compileFunctionDeadLetterResources) } // Configuration Schema // Author: Harrison Bowers // Date: Thu 12 Jan 2023 // https://www.serverless.com/framework/docs/guides/plugins/custom-configuration this.serverless.configSchemaHandler.defineFunctionProperties('aws', { type: 'object', properties: { deadLetter: { type: 'object', oneOf: [ { required: ['sqs'], additionalProperties: true, properties: { sqs: { type: 'object', additionalProperties: true, required: ['queueName'], properties: { queueName: { type: 'string' }, messageRetentionPeriod: { type: 'number' }, visibilityTimeout: { type: 'number' } } } } }, { required: ['targetArn'], properties: { targetArn: { oneOf: [ { type: 'string' }, { type: 'object', required: ['GetResourceArn'], properties: { GetResourceArn: { type: 'string' } } } ] } } } ] } } }) } resolveTargetArn (functionName, deadLetter, resolveStackResources) { let targetDefCount = 0 if (deadLetter.sqs !== undefined) targetDefCount += 1 if (deadLetter.sns !== undefined) targetDefCount += 1 if (deadLetter.targetArn !== undefined) targetDefCount += 1 if (targetDefCount === 0) { throw new Error( `Function: ${functionName}.deadLetter is missing one of the following properties: [sqs, sns, targetArn]` ) } if (targetDefCount > 1) { throw new Error( `Function: ${functionName}.deadLetter can only have one of the following properties: [sqs, sns, targetArn]` ) } if (deadLetter.sqs !== undefined) { return this.resolveTargetArnFromObject( functionName, { GetResourceArn: this.getLogicalIdForDlQueue(functionName) }, resolveStackResources ) } if (deadLetter.sns !== undefined) { return this.resolveTargetArnFromObject( functionName, { GetResourceArn: this.getLogicalIdForDlTopic(functionName) }, resolveStackResources ) } const targetArn = deadLetter.targetArn if (targetArn === null) { return BbPromise.resolve('') } else if (typeof targetArn === 'string') { return this.resolveTargetArnFromString(functionName, targetArn) } else if (typeof targetArn === 'object') { return this.resolveTargetArnFromObject(functionName, targetArn, resolveStackResources) } throw new Error( `Function property ${functionName}.deadLetter.targetArn is an unexpected type. This must be an object or string.` ) } // eslint-disable-next-line class-methods-use-this resolveTargetArnFromString (functionName, targetArn) { if (targetArn.trim().length === 0) { return BbPromise.resolve('') } const rg = new RegExp( '^(arn:aws:sqs:[a-z]{2,}-[a-z]{2,}-[0-9]{1}:[0-9]{12}:[a-zA-z0-9\\-_.]{1,80}' + '|arn:aws:sns:[a-z]{2,}-[a-z]{2,}-[0-9]{1}:[0-9]{12}:[a-zA-z0-9\\-_]{1,256})$' ) if (!rg.test(targetArn)) { throw new Error( `Function property ${functionName}.deadLetter.targetArn = '${targetArn}'. This is not a valid sns or sqs arn. ` ) } return BbPromise.resolve(targetArn) } resolveTargetArnFromObject (functionName, targetArn, resolveStackResources) { if (!targetArn.GetResourceArn) { throw new Error( `Function property ${functionName}.deadLetter.targetArn object is missing GetResourceArn property.` ) } if (!resolveStackResources) { // If the stack has not been deployed this we cannot get the resource arn // from cloudformation yet so just return a display string (not a real arn). // This can be used when: // a) the --noDeploy option is set // b) before stack deployment when we want to run this for simple validations. return BbPromise.resolve(`\${GetResourceArn: ${targetArn.GetResourceArn}}`) } const stackName = this.provider.naming.getStackName() const params = { StackName: stackName, LogicalResourceId: targetArn.GetResourceArn } return this.provider .request( 'CloudFormation', 'describeStackResource', params, this.options.stage, this.options.region ) .then(response => { const resType = response.StackResourceDetail.ResourceType switch (resType) { case 'AWS::SNS::Topic': return BbPromise.resolve(response.StackResourceDetail.PhysicalResourceId) case 'AWS::SQS::Queue': { const queueUrl = response.StackResourceDetail.PhysicalResourceId return BbPromise.resolve(Plugin.convertQueueUrlToArn(queueUrl)) } default: throw new Error( `Function property ${functionName}.deadLetter.targetArn.GetResourceArn ` + `must be a queue or topic. Resource not supported: ${resType}` ) } }) } buildDeadLetterUpdateParams (functionName, resolveStackResources) { const functionObj = this.serverless.service.getFunction(functionName) if (!functionObj.deadLetter) { return BbPromise.resolve() } return BbPromise.bind(this) .then(() => this.resolveTargetArn(functionName, functionObj.deadLetter, resolveStackResources) ) .then(targetArnString => BbPromise.resolve({ FunctionName: functionObj.name, DeadLetterConfig: { TargetArn: targetArnString } }) ) } setLambdaDeadLetterConfig () { return BbPromise.mapSeries(this.serverless.service.getAllFunctions(), functionName => this.buildDeadLetterUpdateParams(functionName, this.deploy).then(deadLetterUpdateParams => { if (!deadLetterUpdateParams) { return BbPromise.resolve() } let logPrefix let updateStep if (this.deploy) { logPrefix = '(updated)' updateStep = this.provider.request( 'Lambda', 'updateFunctionConfiguration', deadLetterUpdateParams, this.options.stage, this.options.region ) } else { logPrefix = '(noDeploy)' updateStep = BbPromise.resolve() } return updateStep.then(() => { const arnDisplayStr = deadLetterUpdateParams.DeadLetterConfig.TargetArn || '{none}' this.serverless.cli.log( `${logPrefix} Function '${functionName}' ` + `DeadLetterConfig.TargetArn: ${arnDisplayStr}` ) }) }) ) } static convertQueueUrlToArn (queueUrl) { const tokens = queueUrl.slice(8).split('/') const region = tokens[0].split('.')[1] const account = tokens[1] const queueName = tokens[2] return ['arn', 'aws', 'sqs', region, account, queueName].join(':') } validate (functionName) { return this.buildDeadLetterUpdateParams(functionName, false) } compileFunctionDeadLetterResources () { return BbPromise.mapSeries(this.serverless.service.getAllFunctions(), functionName => BbPromise.resolve() .then(() => this.validate(functionName)) .then(() => this.compileFunctionDeadLetterResource(functionName)) ) } compileFunctionDeadLetterResource (functionName) { const functionObj = this.serverless.service.getFunction(functionName) if (functionObj.deadLetter === null) { return BbPromise.resolve() } if (functionObj.deadLetter === undefined) { return BbPromise.resolve() } if (functionObj.deadLetter.sqs !== undefined) { return this.compileFunctionDeadLetterQueue(functionName, functionObj.deadLetter.sqs) } if (functionObj.deadLetter.sns !== undefined) { return this.compileFunctionDeadLetterTopic(functionName, functionObj.deadLetter.sns) } return BbPromise.resolve() } normalizeFunctionName (functionName) { return this.provider.naming.getNormalizedFunctionName(functionName) } getLogicalIdForDlQueue (functionName) { return `${this.normalizeFunctionName(functionName)}DeadLetterQueue` } getLogicalIdForDlQueuePolicy (functionName) { return `${this.normalizeFunctionName(functionName)}DeadLetterQueuePolicy` } getLogicalIdForDlTopic (functionName) { return `${this.normalizeFunctionName(functionName)}DeadLetterTopic` } static capitalizeFirstLetter (string) { return string.charAt(0).toUpperCase() + string.slice(1) } compileFunctionDeadLetterQueue (functionName, queueConfig) { const queueProps = { QueueName: '' } if (typeof queueConfig === 'string' || queueConfig === null) { queueProps.QueueName = (queueConfig || '').trim() } else if (typeof queueConfig === 'object') { Object.keys(queueConfig).forEach(key => { if (Object.prototype.hasOwnProperty.call(queueConfig, key)) { queueProps[Plugin.capitalizeFirstLetter(key)] = queueConfig[key] } }) } else { throw new TypeError( `Function property ${functionName}.deadLetter.sqs is an unexpected type. This must be an object or a string.` ) } if (queueProps.QueueName.length < 1) { throw new Error( `Function property ${functionName}.deadLetter.sqs queueName must contain one or more characters.` ) } const functionLogicalId = this.provider.naming.getLambdaLogicalId(functionName) const queueLogicalId = this.getLogicalIdForDlQueue(functionName) const queuePolicyLogicalId = this.getLogicalIdForDlQueuePolicy(functionName) const resources = this.serverless.service.provider.compiledCloudFormationTemplate.Resources const queueResource = { Type: 'AWS::SQS::Queue', Properties: queueProps } const queuePolicyResource = { Type: 'AWS::SQS::QueuePolicy', Properties: { Queues: [ { Ref: queueLogicalId } ], PolicyDocument: { Id: { 'Fn::Join': ['', [{ 'Fn::GetAtt': [queueLogicalId, 'Arn'] }, '/SQSDefaultPolicy']] }, Version: '2012-10-17', Statement: [ { Sid: 'Allow-Lambda-SendMessage', Effect: 'Allow', Principal: { AWS: '*' }, Action: ['SQS:SendMessage'], Resource: { 'Fn::GetAtt': [queueLogicalId, 'Arn'] }, Condition: { ArnEquals: { 'aws:SourceArn': { 'Fn::GetAtt': [functionLogicalId, 'Arn'] } } } } ] } } } resources[queueLogicalId] = queueResource resources[queuePolicyLogicalId] = queuePolicyResource } compileFunctionDeadLetterTopic (functionName, topicConfig) { if (typeof topicConfig !== 'string' && topicConfig !== null) { throw new Error( `Function property ${functionName}.deadLetter.sns is an unexpected type. This must be a or string.` ) } const topicName = (topicConfig || '').trim() if (topicName.length < 1) { throw new Error( `Function property ${functionName}.deadLetter.sns must contain one or more characters.` ) } const topicLogicalId = this.getLogicalIdForDlTopic(functionName) const resources = this.serverless.service.provider.compiledCloudFormationTemplate.Resources const topicResource = { Type: 'AWS::SNS::Topic', Properties: { TopicName: topicName } } resources[topicLogicalId] = topicResource } } module.exports = Plugin