Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

sns: support cross-region subscriptions #13707

Closed
dillon-odonovan opened this issue Mar 19, 2021 · 7 comments
Closed

sns: support cross-region subscriptions #13707

dillon-odonovan opened this issue Mar 19, 2021 · 7 comments
Labels
@aws-cdk/aws-sns-subscriptions closed-for-staleness This issue was automatically closed because it hadn't received any attention in a while. effort/small Small work item – less than a day of effort feature-request A feature should be added or improved. p1

Comments

@dillon-odonovan
Copy link
Contributor

When creating an SNS subscription in a region different from the SNS topic's region, there is a Region property in the CloudFormation resource that is not being set.

Reproduction Steps

sealed class Program
{
  public static void Main(string[] args)
  {
    var app = new App();

    var snsStack = new SnsStack(app, "Sns", new StackProps
    {
        Env = new Environment
        {
            Account = "XXXXXXXXXXXX",
            Region = "us-east-1",
        }
    });
    
    var lambdaStack = new LambdaStack(app, "Lambda", new StackProps
    {
        Env = new Environment
        {
            Account = "XXXXXXXXXXXX",
            Region = "us-west-2",
        }
    }, snsStack);
}

public class SnsStack : Stack
{
    public readonly ITopic Topic;

    public SnsStack(Construct scope, string id, StackProps props) : base(scope, id, props)
    {
        Topic = new Topic(this, "Topic", new TopicProps { TopicName = PhysicalName.GENERATE_IF_NEEDED, });
    }
}

public class LambdaStack : Stack
{
    public readonly IFunction Function;

    internal LambdaStack(Construct scope, string id, StackProps props, SnsStack snsStack) : base(scope, id, props)
    {
        Function = new PythonFunction(this, "Function", new PythonFunctionProps
        {
            Entry = "src/CdkSnsCrossRegion/Function",
        });

        snsStack.Topic.AddSubscription(new LambdaSubscription(Function));
    }
}

What did you expect to happen?

Expected Region property to be set in AWS::SNS::Subscription resource.

{
  "FunctionTopicD77BEC18": {
    "Type": "AWS::SNS::Subscription",
    "Properties": {
      "Protocol": "lambda",
      "TopicArn": {
        "Fn::Join": [
          "",
          [
            "arn:",
            {
              "Ref": "AWS::Partition"
            },
            ":sns:us-east-1:XXXXXXXXXXXX:snssnstopic2f96c0ab8390dbda1164"
          ]
        ]
      },
      "Endpoint": {
        "Fn::GetAtt": [
          "Function76856677",
          "Arn"
        ]
      },
      **"Region": "us-east-1"**
    },
  },
}

What actually happened?

Region property of AWS::SNS::Subscription is not set.

{
  "FunctionTopicD77BEC18": {
    "Type": "AWS::SNS::Subscription",
    "Properties": {
      "Protocol": "lambda",
      "TopicArn": {
        "Fn::Join": [
          "",
          [
            "arn:",
            {
              "Ref": "AWS::Partition"
            },
            ":sns:us-east-1:XXXXXXXXXXXX:snssnstopic2f96c0ab8390dbda1164"
          ]
        ]
      },
      "Endpoint": {
        "Fn::GetAtt": [
          "Function76856677",
          "Arn"
        ]
      }
    },
  },
}

Environment

  • CDK CLI Version : 1.94.1 (build 60d8f91)
  • Framework Version: 1.94.1
  • Node.js Version: v12.16.3
  • OS : macOS 10.13.6
  • Language (Version): net5.0

Other

Possibly related:

#3842
#7044

I looked at the source and it seemed fine. Perhaps I'm missing something really obvious or the usage is incorrect?


This is 🐛 Bug Report

@dillon-odonovan dillon-odonovan added bug This issue is a bug. needs-triage This issue or PR still needs to be triaged. labels Mar 19, 2021
@github-actions github-actions bot added @aws-cdk/aws-cloudformation Related to AWS CloudFormation @aws-cdk/aws-lambda Related to AWS Lambda @aws-cdk/aws-sns Related to Amazon Simple Notification Service @aws-cdk/aws-sns-subscriptions labels Mar 19, 2021
@dillon-odonovan dillon-odonovan changed the title (aws-sns-subscriptions): Cross-region SNS Lambda subscription not adding Region property in synthesized CloudFormation for resources AWS::SNS::Subscription (aws-sns-subscriptions): Cross-region SNS Lambda subscription not adding Region property in synthesized AWS::SNS::Subscription CloudFormation resource Mar 19, 2021
@rix0rrr rix0rrr changed the title (aws-sns-subscriptions): Cross-region SNS Lambda subscription not adding Region property in synthesized AWS::SNS::Subscription CloudFormation resource sns: support cross-region subscriptions Mar 22, 2021
@rix0rrr rix0rrr added effort/small Small work item – less than a day of effort feature-request A feature should be added or improved. p2 p1 and removed bug This issue is a bug. p2 labels Mar 22, 2021
@dillon-odonovan
Copy link
Contributor Author

dillon-odonovan commented Mar 25, 2021

Is this truly a feature request? This functionality is already supported, albeit only through Topic.FromTopicArn.

If I use the workaround of

public class LambdaStack : Stack
{
    public readonly IFunction Function;

    internal LambdaStack(Construct scope, string id, StackProps props, SnsStack snsStack) : base(scope, id, props)
    {
        Function = new PythonFunction(this, "Function", new PythonFunctionProps
        {
            Entry = "src/CdkSnsCrossRegion/Function",
        });

        Topic.FromTopicArn(snsStack.Topic).AddSubscription(new LambdaSubscription(Function));
    }
}

I get the expected output

{
  "Type": "AWS::SNS::Subscription",
  "Properties": {
    "Protocol": "lambda",
    "TopicArn": {
      "Fn::Join": [
        "",
        [
          "arn:",
          {
            "Ref": "AWS::Partition"
          },
          ":sns:us-east-1:XXXXXXXXXXXX:snssnstopic2f96c0ab8390dbda1164"
        ]
      ]
    },
    "Endpoint": {
      "Fn::GetAtt": [
        "Function76856677",
        "Arn"
      ]
    },
    "Region": {
      "Fn::Select": [
        3,
        {
          "Fn::Split": [
            ":",
            {
              "Fn::Join": [
                "",
                [
                  "arn:",
                  {
                    "Ref": "AWS::Partition"
                  },
                  ":sns:us-east-1:XXXXXXXXXXXX:snssnstopic2f96c0ab8390dbda1164"
                ]
              ]
            }
          ]
        }
      ]
    }
  }
}

Looking at the offending method:

private regionFromArn(topic: sns.ITopic): string | undefined {
// no need to specify `region` for topics defined within the same stack.
if (topic instanceof sns.Topic) {
return undefined;
}
return Stack.of(topic).parseArn(topic.topicArn).region;
}

checking for topic instanceof sns.Topic assumes that the topic was created in the same stack. This seems to be an incorrect assumption to me.

The topic must be of type ITopic, which is implemented by abstract type TopicBase which is extended by concrete types Topic and Import. The method above seems to assume that if you want to create a cross-region subscription, you will always have a topic of concrete type Import via Topic.FromTopicArn().

I'm fairly new to the CDK codebase, but an implementation such as below, seems to be more functionally correct.

private regionFromArn(topic: sns.ITopic): string | undefined {
  if (topic instanceof sns.Topic) {
    // we know topic is not imported

    // get the region of the stack that the topic was created in
    const topicRegion = topic.stack.region;
    // get the region of the stack that the function is being created in
    const functionRegion = this.fn.stack.region;

    // if the regions are not the same, we must provide the region of the topic
    if (topicRegion !== functionRegion) {
      return topicRegion;
    }

    // no need to specify region in subscription if topic is defined within the same region as the function.
    return undefined;
  }
  // if topic is not an instanceof sns.Topic, assume it's an instance of sns.Import
  // and get the region that it was defined/deployed in
  return Stack.of(topic).parseArn(topic.topicArn).region;
}

This way, even if we have a concrete Topic, we check its region to see if it is different from the region of the function. This supports multiple use-case patterns such as importing both the function and the topic, importing either, or neither. It will then appropriately return either the region of the topic, or undefined if the regions are the same.

I don't fully understand how tokens in the CDK work yet, so there may be a preferred alternative implementation, but I did write some unit tests to validate that this change did not break other functionality and does include the Region property for my use case.

I haven't created a PR because there is already one open for this issue, but am willing to do so.

I believe this change would also apply to the other subscription types such as SQS, as I believe this implementation originally came from a feature request for cross-region SNS subscriptions with SQS endpoints as a result of #4917

@MrArnoldPalmer
Copy link
Contributor

@robertd how do you believe this compares to your PR?

The issue that I can see here is the use of the construct in environment-agnostic stacks, where the region will not be known at synth time and will be a tokenized value that can't be compared via '==='. We may be able to use intrinsics to do a bunch of template conditional logic but I'm not sure if that is doable for this case and would require some experimentation.

@dillon-odonovan do you any details about use cases that may not be supported by the fix in the existing PR?

@dillon-odonovan
Copy link
Contributor Author

Unless I'm missing something, this appears to be the only fix so far in the current PR (where 114 is the only line that changed):

new CfnSubscription(this, 'Resource', {
endpoint: props.endpoint,
protocol: props.protocol,
topicArn: props.topic.topicArn,
rawMessageDelivery: props.rawMessageDelivery,
filterPolicy: this.filterPolicy,
region: props.region ?? Stack.of(this).region,
redrivePolicy: this.buildDeadLetterConfig(this.deadLetterQueue),
});

To me, this fix doesn't appear to change the behavior of the CfnSubscription. If no Region property is provided, CloudFormation will use the current region that the stack is being deployed in as the default. This appears to just make that CloudFormation default behavior explicit. It doesn't seem to address the use-case I was going after.

@MrArnoldPalmer
I imagine that if the region of the stack is not known at the time of synth, it's not reasonable to expect the subscription to point to the correct region if creating the subscription by passing the object reference of the Topic. In that case, I think it would be expected to use Topic.fromTopicArn and import the topic.

So, with that being said, perhaps the guidance is that whenever a cross-region subscription is desired, the expectation is to import the resource via Topic.fromTopicArn. I think that outcome is fine, it would just be a matter of how to communicate that to the user if they try to create a subscription like in the use-case above.

I looked into what the current behavior would be if given 2 environment-agnostic stacks a subscription was tried to be created in this manner:

Test Case:

test('subscription region property - two stacks where regions are not yet defined', () => {
  const snsStack = new Stack();
  const myTopic = new sns.Topic(snsStack, 'mytopic', {
    topicName: 'MyTopic',
  });

  const subscriptionStack = new Stack();
  const func = new lambda.Function(subscriptionStack, 'MyFunction', {
    code: lambda.Code.fromInline('exports.handler = function(e, c, cb) { return cb() }'),
    handler: 'index.handler',
    runtime: lambda.Runtime.NODEJS_10_X,
  });

  myTopic.addSubscription(new subs.LambdaSubscription(func));

  // note - I only had this be the expectation so that it would fail and I could see the generated CloudFormation
  expect(subscriptionStack).toMatchTemplate({});
});

Result:

● subscription region property - two stacks where regions are not yet defined

    Cannot reference across apps. Consuming and producing stacks must be defined within the same CDK app.

      47 |   // unsupported: stacks from different apps
      48 |   if (producer.node.root !== consumer.node.root) {
    > 49 |     throw new Error('Cannot reference across apps. Consuming and producing stacks must be defined within the same CDK app.');
         |           ^
      50 |   }
      51 |
      52 |   // unsupported: stacks are not in the same environment

      at resolveValue (../core/lib/private/refs.ts:49:11)
      at Object.resolveReferences (../core/lib/private/refs.ts:30:24)
      at Object.prepareApp (../core/lib/private/prepare-app.ts:31:3)
      at Object.synthesize (../core/lib/private/synthesis.ts:24:3)
      at App.synth (../core/lib/stage.ts:188:23)
      at synthesizeApp (../assert/lib/synth-utils.ts:76:15)
      at Function._synthesizeWithNested (../assert/lib/synth-utils.ts:53:22)
      at Object.expect (../assert/lib/expect.ts:9:45)
      at Object.toMatchTemplate (../assert/jest.ts:42:23)
      at __EXTERNAL_MATCHER_TRAP__ (../../../node_modules/expect/build/index.js:342:30)

So I think this matches with what I think expected behavior would be if the stacks are both environment-agnostic and they are used in a cross-reference manner.

To me, we could either raise an error similar to above if the subscription is tried to be created in a cross-reference manner (without importing via fromTopicArn), or we may be able to implement a similar fix to what I outlined above. If we are able to detect the former, then at that point it seems like we have all information to know how to correctly configure the subscription, and perhaps we should just do that.

@MrArnoldPalmer
Copy link
Contributor

So this error is because the two stacks in the test have different App parents. The Stack construct creates its own App if you don't pass one explicitly in unit tests so you can get around that by building and passing an App yourself.

I think I understand why the current change isn't what you were looking for but tell me this is correct.

The current fix is defaulting to the region of the stack that the subscription is in when it should be the region of the topic being subscribed to.

@dillon-odonovan
Copy link
Contributor Author

Yes, that's correct. The region needs to be relative to the topic not the subscription.

@MrArnoldPalmer
Copy link
Contributor

So I wonder if we can just parse the region out of the topic arn, which we have access to here and always pass it. Or instead of Stack.of(this).region we just Stack.of(props.topic).region. We will have to test with various combinations of environment-agnostic stacks (both, either, none etc) and identify which break, because I have a feeling they will whether silently or with some unrelated errors, and then see if we can catch those early and provide users with appropriate messaging.

@ryparker ryparker removed the needs-triage This issue or PR still needs to be triaged. label Jun 1, 2021
mergify bot pushed a commit that referenced this issue Nov 11, 2021
…lambda (#17273)

fixing cross region subscriptions to SQS and Lambda. This PR handles a
couple different scenarios. This only applies to SNS topics that are
instanceof sns.Topic, it does not apply to imported topics (sns.ITopic).
The current behavior for imported topics will remain the same.

1. SNS Topic and subscriber (sqs or lambda) are created in separate
   stacks with explicit environment.

In this case if the `region` is different between the two stacks then
the topic region will be provided in the subscription.

2. SNS Topic and subscriber (sqs or lambda) are created in separate
   stacks, and _both_ are env agnostic (no explicit region is provided)

In this case a region is not specified in the subscription resource,
which means it is assumed that they are both created in the same region.
The alternatives are to either throw an error telling the user to
specify a region, or to create a CfnOutput with the topic region. Since
it should be a rare occurrence for a user to be deploying a CDK app with
multiple env agnostic stacks that are meant for different environments,
I think the best option is to assume same region.

3. SNS Topic and subscriber (sqs or lambda) are created in separate
   stacks, and _one_ of them are env agnostic (no explicit region is provided)

In this case if the SNS stack has an explicit environment then we will
provide that in the subscription resource (assume that it is cross
region). If the SNS stack is env agnostic then we will do nothing
(assume they are created in the same region).

fixes #7044,#13707


----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
@nija-at nija-at removed @aws-cdk/aws-cloudformation Related to AWS CloudFormation @aws-cdk/aws-lambda Related to AWS Lambda @aws-cdk/aws-sns Related to Amazon Simple Notification Service labels Nov 24, 2021
TikiTDO pushed a commit to TikiTDO/aws-cdk that referenced this issue Feb 21, 2022
…lambda (aws#17273)

fixing cross region subscriptions to SQS and Lambda. This PR handles a
couple different scenarios. This only applies to SNS topics that are
instanceof sns.Topic, it does not apply to imported topics (sns.ITopic).
The current behavior for imported topics will remain the same.

1. SNS Topic and subscriber (sqs or lambda) are created in separate
   stacks with explicit environment.

In this case if the `region` is different between the two stacks then
the topic region will be provided in the subscription.

2. SNS Topic and subscriber (sqs or lambda) are created in separate
   stacks, and _both_ are env agnostic (no explicit region is provided)

In this case a region is not specified in the subscription resource,
which means it is assumed that they are both created in the same region.
The alternatives are to either throw an error telling the user to
specify a region, or to create a CfnOutput with the topic region. Since
it should be a rare occurrence for a user to be deploying a CDK app with
multiple env agnostic stacks that are meant for different environments,
I think the best option is to assume same region.

3. SNS Topic and subscriber (sqs or lambda) are created in separate
   stacks, and _one_ of them are env agnostic (no explicit region is provided)

In this case if the SNS stack has an explicit environment then we will
provide that in the subscription resource (assume that it is cross
region). If the SNS stack is env agnostic then we will do nothing
(assume they are created in the same region).

fixes aws#7044,aws#13707


----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
@github-actions
Copy link

This issue has not received any attention in 1 year. If you want to keep this issue open, please leave a comment below and auto-close will be canceled.

@github-actions github-actions bot added the closing-soon This issue will automatically close in 4 days unless further comments are made. label Nov 24, 2022
@github-actions github-actions bot added closed-for-staleness This issue was automatically closed because it hadn't received any attention in a while. and removed closing-soon This issue will automatically close in 4 days unless further comments are made. labels Dec 2, 2022
@github-actions github-actions bot closed this as completed Dec 2, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
@aws-cdk/aws-sns-subscriptions closed-for-staleness This issue was automatically closed because it hadn't received any attention in a while. effort/small Small work item – less than a day of effort feature-request A feature should be added or improved. p1
Projects
None yet
5 participants