Implementing multi-regional Stack deployments

Greetings,
We would like to have one Stack that’s initialised with one AWS account and a list of region names and then the idea is that whatever Constracts get scoped into it, they’d get regionally replicated on the cloud.

What I need help with is:

  • Resource constructs accept a provider parameter - singular. Does that mean that we can not achieve the pattern outlined above; with one-to-many mapping between the Constructs within a Stack and multiple regions? Is this the only way to dictate the provider to the Constructs for the purposes of deploying it regionally?
  • I tried to fetch the current actual region at deploy-time using a DataAwsRegion. I believe need to use the region name in deriving the resource id, that’s why I’m trying to look it up in interpolated form during deployment. But I get a RuntimeError: You cannot use a Token (e.g. a reference to an attribute) as the id of a construct. That leads me to thinking that perhaps I should reconsider my pattern.
  • In general, how would one achieve the pattern outlined above with DRY code?

The code for the relevant class is below. I would appreciate any help. Thanks in advance.

class AWSStack(TerraformStack):
    """
    Wraps TerraformStack, implementing most of the boilerplate,
    essential configuration and validations for a CDK stack on AWS
    """

    def __init__(self, scope: Construct, name: str,
                 environment: AWSEnvironment, spec: dict):
        stack_id = generate_stack_id(scope.name, name, environment.account)
        super().__init__(scope, id=stack_id)

        self.account = environment.account
        if self.account is None:
            Annotations.of(self).add_error(f"Invalid account name '{self.account}'. Expected one of: {ALL_AWS_ACCOUNTS}.")
        else:
            self.account_id = AWS_ACCOUNT_IDS[self.account]

        self.regions = environment.regions
        if self.regions is None:
            Annotations.of(self).add_error(f"Invalid list of regions given. Expected a subset of: {ALL_AWS_REGIONS}.")

        self.name = name
        self.env_suffix = environment.suffix
        self.spec = spec
        self.role_arn = f"arn:aws:iam::{self.account_id}:role/AdminAccessRole"
        self.state_bucket_name = STATE_BUCKETS[self.account]
        self.state_file_name = f"cdktf/{scope.name}/{self.name}.state"

        self.backend = S3Backend(
            self,
            bucket=self.state_bucket_name,
            region=DEFAULTS["aws-region"],
            key=self.state_file_name,
            role_arn=self.role_arn,
            encrypt=True,
            dynamodb_table="terraform-state-lock"
        )

        self.providers = {}
        for region in self.regions:
            provider_params = {
                "scope": self,
                "id": region,
                "region": region,
                "assume_role": {"role_arn": self.role_arn, "duration": "1h"}
            }
            if region != DEFAULTS["aws-region"]:
                provider_params["alias"] = region

            self.providers[region] = cdktf_aws.provider.AwsProvider(
                **provider_params
            )

        region_lookup = cdktf_aws.data_aws_region.DataAwsRegion(
            self,
            "data-source-aws-region-" + generate_random_string()
        )
        # the below line gets: RuntimeError: You cannot use a Token (e.g. a reference to an attribute) as the id of a construct
        # self.region = region_lookup.get_string_attribute("name")

edit: I should probably add that everything was working when I used to have single-region stacks with one (non-aliased) provider per Stack. The challenge is doing it in a way such that each Stack can be paired with multiple regions instead of creating many stacks per region.

Hi @alpozcan,

you seem to be on the right path already. While I would favor multiple stacks (more on that later), it is certainly a valid requirement to use multiple regions within the same stack.

Regarding the error you mentioned: while you can’t use a token (i.e. a value returned from a resource or provider) in the id of a construct, you probably don’t have to do so, if you have some other thing to iterate over. And it seems like you have a plain list of strings containing the regions, so you could use those for your ids.

Regarding a single provider supported for a resource: That is right, a single resource can only ever be at a single place at the same time. But you could create a loop and create multiple resources with different providers each.

The thing to know about using multiple providers: Every resource that should use a non-default provider (i.e. one that has an alias) needs a reference to this provider passed via provider – that is also true for the data source to lookup the region.

A possible structure in pseudo-code:

AWSStack {
  init() {
     regions = ["eu-central-1", "us-east-1"]
     
     // pretty much like you already do
     for region in regions {
       provider = new AwsProvider(self, "aws-"+region, {
         region: region,
         alias: "aws-"+region,
       })

       new DataAwsRegion(self, "region-lookup-"+region, {
         provider: provider, // you need to pass the provider it should do the lookup in
       })

       new S3Bucket(self, "bucket-"+region, {
         provider: provider,
         // ... other config
       })
     }
  }
}

But as it seems that you want to replicate each resource across each region (probably for fallback / availability reasons?), using multiple stacks would be preferred, as that would reduce the blast radius if something goes wrong. For example, this enables you to try to roll out the change in one region first and then follow with others. However, this is something the underlying application needs to support, so I can understand why deploying everything at once can be a requirement here.

1 Like