Skip to content
This repository has been archived by the owner on Jan 31, 2024. It is now read-only.

Commit

Permalink
feat: Lambda subscription on SNS topic (#974)
Browse files Browse the repository at this point in the history
* feat: Lambda subscription on SNS topic

* fix: typo

* test: update snapshot

* fix: accept alias as lambda input

* pass lambda reference via arn

* add permissions to invoke lambda

* fix: target lambda base rather than alias

add additional docs

* feat: add DLQ as public instance property
  • Loading branch information
kschelonka authored Jul 21, 2022
1 parent e867673 commit 5abb010
Show file tree
Hide file tree
Showing 4 changed files with 350 additions and 2 deletions.
38 changes: 38 additions & 0 deletions src/base/ApplicationLambdaSnsTopicSubscription.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Testing } from 'cdktf';
import { lambdafunction } from '@cdktf/provider-aws';
import { ApplicationLambdaSnsTopicSubscription } from './ApplicationLambdaSnsTopicSubscription';

describe('ApplicationSqsSnsTopicSubscription', () => {
const getConfig = (stack) => ({
name: 'test-sns-subscription',
snsTopicArn: 'arn:aws:sns:TopicName',
lambda: new lambdafunction.DataAwsLambdaFunction(stack, 'lambda', {
functionName: 'test-lambda',
}),
});

it('renders an Lambda <> SNS subscription without tags', () => {
const synthed = Testing.synthScope((stack) => {
new ApplicationLambdaSnsTopicSubscription(
stack,
'lambda-sns-subscription',
getConfig(stack)
);
});
expect(synthed).toMatchSnapshot();
});

it('renders an SQS SNS subscription with tags', () => {
const synthed = Testing.synthScope((stack) => {
new ApplicationLambdaSnsTopicSubscription(
stack,
'lambda-sns-subscription',
{
...getConfig(stack),
tags: { hello: 'there' },
}
);
});
expect(synthed).toMatchSnapshot();
});
});
144 changes: 144 additions & 0 deletions src/base/ApplicationLambdaSnsTopicSubscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { Resource, TerraformResource } from 'cdktf';
import { Construct } from 'constructs';
import { sqs, sns, iam, lambdafunction } from '@cdktf/provider-aws';
import { SnsTopicSubscriptionConfig } from '@cdktf/provider-aws/lib/sns';

/** The config props type of [[`ApplicationLambdaSnsTopicSubscription]] */
export interface ApplicationLambdaSnsTopicSubscriptionProps {
/** The prefix used to help identify related resources */
name: string;
/** The SNS topic to subscribe the Lambda to */
snsTopicArn: string;
/** The Lambda that should be invoked by incoming messages to the SNS topic */
lambda: lambdafunction.DataAwsLambdaFunction | lambdafunction.LambdaFunction;
/** Tags to apply to the resource(s), where applicable (in this case only the DLQ for the SNS) */
tags?: { [key: string]: string };
/** Optional list of resource dependencies */
dependsOn?: TerraformResource[];
}

/**
* Creates an SNS to Lambda subscription with a DLQ for any messages
* that failed to send to the Lambda (e.g. due to permissions error).
* Automatically adds policies/permissions for the SNS topic to send
* messages to the DLQ and invoke the Lambda function.
*
* Artifacts:
* * {@link https://www.terraform.io/docs/providers/aws/r/sns_topic_subscription aws_sns_topic_subscription} Resource to subscribe Lambda to SNS topic
* * {@link https://www.terraform.io/docs/providers/aws/r/sqs_queue aws_sqs_queue} Resource (DLQ for the SNS topic)
* * {@link https://www.terraform.io/docs/providers/aws/r/sqs_queue_policy aws_sqs_queue_policy} Resource policy for SNS to send messages to DLQ
* * {@link https://www.terraform.io/docs/providers/aws/r/lambda_permission aws_lambda_permission} Resource permission for SNS to invoke Lambda
*/
export class ApplicationLambdaSnsTopicSubscription extends Resource {
/** the {@link https://www.terraform.io/docs/providers/aws/r/sns_topic_subscription aws_sns_topic_subscription} resource */
public readonly snsTopicSubscription: sns.SnsTopicSubscription;
/** the {@link https://www.terraform.io/docs/providers/aws/r/sqs_queue aws_sqs_queue} (DLQ) resource */
public readonly snsTopicDlq: sqs.SqsQueue;

constructor(
scope: Construct,
private name: string,
private config: ApplicationLambdaSnsTopicSubscriptionProps
) {
super(scope, name);

this.snsTopicDlq = this.createSqsSubscriptionDlq();
this.snsTopicSubscription = this.createSnsTopicSubscription(
this.snsTopicDlq
);
this.createDlqPolicy(this.snsTopicDlq);
this.createLambdaPolicy();
}

/**
* Create a dead-letter queue for failed SNS messages
* @private
*/
private createSqsSubscriptionDlq(): sqs.SqsQueue {
return new sqs.SqsQueue(this, 'sns-topic-dlq', {
name: `${this.config.name}-SNS-Topic-DLQ`,
tags: this.config.tags,
});
}

/**
* Create an SNS subscription for Lambda
* @param snsTopicDlq the DLQ for messages that failed to be processed
* @private
*/
private createSnsTopicSubscription(
snsTopicDlq: sqs.SqsQueue
): sns.SnsTopicSubscription {
return new sns.SnsTopicSubscription(this, 'sns-subscription', {
topicArn: this.config.snsTopicArn,
protocol: 'lambda',
endpoint: this.config.lambda.arn,
redrivePolicy: JSON.stringify({
deadLetterTargetArn: snsTopicDlq.arn,
}),
dependsOn: [
snsTopicDlq,
this.config.lambda.arn,
...(this.config.dependsOn ? this.config.dependsOn : []),
],
} as SnsTopicSubscriptionConfig);
}

/**
* Grant permissions for SNS topic to invoke lambda
* Cannot be applied to an alias; must use the base lambda function
*/
private createLambdaPolicy(): void {
new lambdafunction.LambdaPermission(
this,
`${this.name}-lambda-permission`,
{
principal: 'sns.amazonaws.com',
action: 'lambda:InvokeFunction',
functionName: this.config.lambda.functionName,
sourceArn: this.config.snsTopicArn,
}
);
}

/**
* Create IAM policies to allow SNS write to the dead-letter queue
* @param snsTopicDlq the SQS resource (used as DLQ) to grant permissions on
* @private
*/
private createDlqPolicy(snsTopicDlq: sqs.SqsQueue): void {
const queue = { name: 'sns-dlq', resource: snsTopicDlq };
const policy = new iam.DataAwsIamPolicyDocument(
this,
`${queue.name}-policy-document`,
{
statement: [
{
effect: 'Allow',
actions: ['sqs:SendMessage'],
resources: [queue.resource.arn],
principals: [
{
identifiers: ['sns.amazonaws.com'],
type: 'Service',
},
],
condition: [
{
test: 'ArnEquals',
variable: 'aws:SourceArn',
values: [this.config.snsTopicArn],
},
],
},
],
dependsOn: [queue.resource] as TerraformResource[],
}
).json;

new sqs.SqsQueuePolicy(this, `${queue.name}-policy`, {
queueUrl: queue.resource.url,
policy: policy,
});
}
}
8 changes: 6 additions & 2 deletions src/base/ApplicationVersionedLambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const DEFAULT_MEMORY_SIZE = 128;

export class ApplicationVersionedLambda extends Resource {
public readonly versionedLambda: lambdafunction.LambdaAlias;
public readonly defaultLambda: lambdafunction.LambdaFunction;
public lambdaExecutionRole: iam.IamRole;

constructor(
Expand All @@ -48,7 +49,9 @@ export class ApplicationVersionedLambda extends Resource {
super(scope, name);

this.createCodeBucket();
this.versionedLambda = this.createLambdaFunction();
const { versionedLambda, lambda } = this.createLambdaFunction();
this.versionedLambda = versionedLambda;
this.defaultLambda = lambda;
}

private createLambdaFunction() {
Expand Down Expand Up @@ -107,7 +110,7 @@ export class ApplicationVersionedLambda extends Resource {
dependsOn: [lambda],
});

return new lambdafunction.LambdaAlias(this, 'alias', {
const versionedLambda = new lambdafunction.LambdaAlias(this, 'alias', {
functionName: lambda.functionName,
functionVersion: Fn.element(Fn.split(':', lambda.qualifiedArn), 7),
name: 'DEPLOYED',
Expand All @@ -116,6 +119,7 @@ export class ApplicationVersionedLambda extends Resource {
},
dependsOn: [lambda],
});
return { versionedLambda, lambda };
}

private shouldIgnorePublish() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`ApplicationSqsSnsTopicSubscription renders an Lambda <> SNS subscription without tags 1`] = `
"{
\\"data\\": {
\\"aws_iam_policy_document\\": {
\\"lambda-sns-subscription_sns-dlq-policy-document_8DAB362F\\": {
\\"depends_on\\": [
\\"aws_sqs_queue.lambda-sns-subscription_sns-topic-dlq_C5D5F199\\"
],
\\"statement\\": [
{
\\"actions\\": [
\\"sqs:SendMessage\\"
],
\\"condition\\": [
{
\\"test\\": \\"ArnEquals\\",
\\"values\\": [
\\"arn:aws:sns:TopicName\\"
],
\\"variable\\": \\"aws:SourceArn\\"
}
],
\\"effect\\": \\"Allow\\",
\\"principals\\": [
{
\\"identifiers\\": [
\\"sns.amazonaws.com\\"
],
\\"type\\": \\"Service\\"
}
],
\\"resources\\": [
\\"\${aws_sqs_queue.lambda-sns-subscription_sns-topic-dlq_C5D5F199.arn}\\"
]
}
]
}
},
\\"aws_lambda_function\\": {
\\"lambda\\": {
\\"function_name\\": \\"test-lambda\\"
}
}
},
\\"resource\\": {
\\"aws_lambda_permission\\": {
\\"lambda-sns-subscription_lambda-sns-subscription-lambda-permission_03B5A953\\": {
\\"action\\": \\"lambda:InvokeFunction\\",
\\"function_name\\": \\"\${data.aws_lambda_function.lambda.function_name}\\",
\\"principal\\": \\"sns.amazonaws.com\\",
\\"source_arn\\": \\"arn:aws:sns:TopicName\\"
}
},
\\"aws_sns_topic_subscription\\": {
\\"lambda-sns-subscription_1ED18AE9\\": {
\\"depends_on\\": [
\\"aws_sqs_queue.lambda-sns-subscription_sns-topic-dlq_C5D5F199\\"
],
\\"endpoint\\": \\"\${data.aws_lambda_function.lambda.arn}\\",
\\"protocol\\": \\"lambda\\",
\\"redrive_policy\\": \\"{\\\\\\"deadLetterTargetArn\\\\\\":\\\\\\"\${aws_sqs_queue.lambda-sns-subscription_sns-topic-dlq_C5D5F199.arn}\\\\\\"}\\",
\\"topic_arn\\": \\"arn:aws:sns:TopicName\\"
}
},
\\"aws_sqs_queue\\": {
\\"lambda-sns-subscription_sns-topic-dlq_C5D5F199\\": {
\\"name\\": \\"test-sns-subscription-SNS-Topic-DLQ\\"
}
},
\\"aws_sqs_queue_policy\\": {
\\"lambda-sns-subscription_sns-dlq-policy_31243636\\": {
\\"policy\\": \\"\${data.aws_iam_policy_document.lambda-sns-subscription_sns-dlq-policy-document_8DAB362F.json}\\",
\\"queue_url\\": \\"\${aws_sqs_queue.lambda-sns-subscription_sns-topic-dlq_C5D5F199.url}\\"
}
}
}
}"
`;

exports[`ApplicationSqsSnsTopicSubscription renders an SQS SNS subscription with tags 1`] = `
"{
\\"data\\": {
\\"aws_iam_policy_document\\": {
\\"lambda-sns-subscription_sns-dlq-policy-document_8DAB362F\\": {
\\"depends_on\\": [
\\"aws_sqs_queue.lambda-sns-subscription_sns-topic-dlq_C5D5F199\\"
],
\\"statement\\": [
{
\\"actions\\": [
\\"sqs:SendMessage\\"
],
\\"condition\\": [
{
\\"test\\": \\"ArnEquals\\",
\\"values\\": [
\\"arn:aws:sns:TopicName\\"
],
\\"variable\\": \\"aws:SourceArn\\"
}
],
\\"effect\\": \\"Allow\\",
\\"principals\\": [
{
\\"identifiers\\": [
\\"sns.amazonaws.com\\"
],
\\"type\\": \\"Service\\"
}
],
\\"resources\\": [
\\"\${aws_sqs_queue.lambda-sns-subscription_sns-topic-dlq_C5D5F199.arn}\\"
]
}
]
}
},
\\"aws_lambda_function\\": {
\\"lambda\\": {
\\"function_name\\": \\"test-lambda\\"
}
}
},
\\"resource\\": {
\\"aws_lambda_permission\\": {
\\"lambda-sns-subscription_lambda-sns-subscription-lambda-permission_03B5A953\\": {
\\"action\\": \\"lambda:InvokeFunction\\",
\\"function_name\\": \\"\${data.aws_lambda_function.lambda.function_name}\\",
\\"principal\\": \\"sns.amazonaws.com\\",
\\"source_arn\\": \\"arn:aws:sns:TopicName\\"
}
},
\\"aws_sns_topic_subscription\\": {
\\"lambda-sns-subscription_1ED18AE9\\": {
\\"depends_on\\": [
\\"aws_sqs_queue.lambda-sns-subscription_sns-topic-dlq_C5D5F199\\"
],
\\"endpoint\\": \\"\${data.aws_lambda_function.lambda.arn}\\",
\\"protocol\\": \\"lambda\\",
\\"redrive_policy\\": \\"{\\\\\\"deadLetterTargetArn\\\\\\":\\\\\\"\${aws_sqs_queue.lambda-sns-subscription_sns-topic-dlq_C5D5F199.arn}\\\\\\"}\\",
\\"topic_arn\\": \\"arn:aws:sns:TopicName\\"
}
},
\\"aws_sqs_queue\\": {
\\"lambda-sns-subscription_sns-topic-dlq_C5D5F199\\": {
\\"name\\": \\"test-sns-subscription-SNS-Topic-DLQ\\",
\\"tags\\": {
\\"hello\\": \\"there\\"
}
}
},
\\"aws_sqs_queue_policy\\": {
\\"lambda-sns-subscription_sns-dlq-policy_31243636\\": {
\\"policy\\": \\"\${data.aws_iam_policy_document.lambda-sns-subscription_sns-dlq-policy-document_8DAB362F.json}\\",
\\"queue_url\\": \\"\${aws_sqs_queue.lambda-sns-subscription_sns-topic-dlq_C5D5F199.url}\\"
}
}
}
}"
`;

0 comments on commit 5abb010

Please sign in to comment.