Exploring Fn::ForEach and Fn::FindInMap enhancements in AWS CloudFormation
August 1, 2023AWS CloudFormation, an Infrastructure as Code (IaC) service that lets you model, provision, and manage AWS and third-party resources, recently released a new language transform that enhances the core CloudFormation language. Today, we’ll be covering two more enhancements we’ve added since our initial release: Fn::FindInMap enhancements and a new looping function – Fn::ForEach.
These new language extensions are the result of open discussions with the larger CloudFormation community via our Request For Comments (RFC) proposals for new language features at our Language Discussion GitHub repository. We want to collaborate with the community to better align features and incorporate early feedback into the development cycle to meet the community’s needs. We invite you to participate in new RFCs to help shape the future of the CloudFormation language.
In this post, I’ll dive deeper into the new enhancements for Fn::FindInMap
as well as explore the new Fn::ForEach
looping mechanism and provide some examples.
Prerequisites
To use these new language features, you must add AWS::LanguageExtensions to the transform section of your template.
---
AWSTemplateFormatVersion: 2010-09-09
Transform: 'AWS::LanguageExtensions'
If you have a list of transforms, then we recommend having AWS managed transforms at the end, and AWS::LanguageExtensions must be listed before AWS::Serverless.
---
AWSTemplateFormatVersion: 2010-09-09
Transform: - 'AWS::LanguageExtensions' - 'AWS::Serverless-2016-10-31'
This transform will cover all of the existing and future language extensions.
FindInMap enhancements
We have updated the language extension transform for CloudFormation to support Fn::FindInMap enhancements, that extend the existing functionality of the Fn::FindInMap
intrinsic function so that now you can:
- use an optional, default value in Fn::FindInMap parameters, if a given key in a
Mappings
section is not found, and - use a number of additional intrinsic functions in the parameters of
Fn::FindInMap
; for more information, see Supported functions.
Let’s see an example use case where Fn::FindInMap
enhancements can help you simplify the business logic of your template, and make it more readable and easier to maintain. Let’s suppose you create a CloudFormation template that describes an Amazon Elastic Compute Cloud (Amazon EC2) instance, and you need to use smaller EC2 instance types for pre-production environments, and a larger EC2 instance type for production for cost savings. In this example, you choose a t2.micro instance type for the dev environment, t2.medium for the qa environment, and t2.large for the prod environment, that you start to describe as follows:
---
AWSTemplateFormatVersion: "2010-09-09" Description: 'Sample template that describes usage for `Fn::FindInMap` enhancements' Parameters: Environment: Description: Lifecycle environment. Type: String AllowedValues: - sandbox - dev - qa - prod Default: dev LatestAmiId: Description: Region-specific image to use. Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id> Default: /aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2 Mappings: LifecycleEnvToInstanceType: dev: InstanceType: t2.micro qa: InstanceType: t2.medium prod: InstanceType: t2.large
You described instance types for each of your 3 lifecycle environments in the Mappings section, and you engineered your template to read environment names as input data from Environment
in the Parameters
section. Looking closer at Environment
, you define another allowed value: sandbox
, that in this example is an environment for developers to use for prototype testing only: you choose not to include this environment in the mapping you created, with the intent to do the same for any other non-formal environment (for example, a contributor’s personal environment). Next, powered by the new enhancements toFn::FindInMap
, you assign a default value for environment names that are different than dev
, qa
, and prod
; this way, the only change you’ll need to make in this context is a new value(s) to AllowedValues
in Environment
. You describe this business logic in your template, to which you add the sample code shown next:
Transform: AWS::LanguageExtensions Resources: Ec2Instance: Type: AWS::EC2::Instance Properties: ImageId: !Ref 'LatestAmiId' InstanceType: !FindInMap - LifecycleEnvToInstanceType - !Ref 'Environment' - InstanceType - DefaultValue: t2.micro Tags: - Key: test Value: test
In the snippet above, you have declared the AWS::LanguageExtensions
transform, and described your configuration for an EC2 instance in the Resources
section. For InstanceType
, you chose to use Fn::FindInMap
enhancements, and pass DefaultValue
as an additional parameter with t2.micro
as its value. When the user uses this template to create a stack, and chooses sandbox for Environment
, the !Ref 'Environment'
reference to the value for Environment will evaluate to sandbox, which is not present in the mapping you created: in this case, t2.micro
will be used as a value for InstanceType.
These new enhancements also allow you to use more intrinsic functions inside of Fn::FindInMap
. Let’s say you have received requirements to use -env
as a suffix to environment names. You choose to make a minimal set of changes to your template, and start with the Parameters section as follows:
Parameters: Environment: Description: Lifecycle environment. Type: String AllowedValues: - sandbox-env - dev-env - qa-env - prod-env Default: dev-env
Next, instead of modifying all of your keys in the Mappings
section, you choose to only change the second parameter to Fn::FindInMap
enhancements as follows: first, you use the Fn::Split
intrinsic function to split the user-selected environment value string (for example, dev-env) into a list of values using the ‘-
‘ character as a delimiter, and next you use the Fn::Select
intrinsic function to choose the first element (that is, 0) of that list:
Resources: Ec2Instance: Type: AWS::EC2::Instance Properties: ImageId: !Ref 'LatestAmiId' InstanceType: !FindInMap - LifecycleEnvToInstanceType - !Select - 0 - !Split - '-' - !Ref 'Environment' - InstanceType - DefaultValue: t2.micro Tags: - Key: test Value: test
With the updated code above, if the user selects the dev-env
value for Environment
, Fn::FindInMap
enhancements will use dev
as the second parameter when looking up values in the Mappings
section.
Fn::ForEach intrinsic function
Another enhancement to the language extensions is the addition of native looping inside of CloudFormation with Fn::ForEach
. Imagine you have a situation where you need three EC2 instances that look exactly the same. Currently, you would have to copy and paste each instance as a separate resource with CloudFormation:
---
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::LanguageExtensions Resources: FirstInstance: Type: AWS::EC2::Instance Properties: # ..removed for brevity.. SecondInstance: Type: AWS::EC2::Instance Properties: # ..removed for brevity.. ThirdInstance: Type: AWS::EC2::Instance Properties: # ..removed for brevity..
If you encounter the need to update one property (the AMI ID, for example), you will have to update all three separately. While this is trivially easy for our example, templates that extend into the hundreds of resources quickly becomes difficult to maintain.
With the Fn::ForEach
language extension, we’re able to group all of these items together into an easy-to-manage snippet:
---
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::LanguageExtensions Resources: Fn::ForEach::Instances: - InstanceLogicalId - [FirstInstance, SecondInstance, ThirdInstance] - ${InstanceLogicalId}: Type: AWS::EC2::Instance Properties: # ..removed for brevity..
This results in the following output YAML, which is identical to our previous example:
---
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::LanguageExtensions Resources: FirstInstance: Type: AWS::EC2::Instance Properties: # ..removed for brevity.. SecondInstance: Type: AWS::EC2::Instance Properties: # ..removed for brevity.. ThirdInstance: Type: AWS::EC2::Instance Properties: # ..removed for brevity..
To break down the syntax, the Fn::ForEach function requires:
- A Logical ID for the looping function directly following the Fn::ForEach call. In our case, we named it
Instances
- The variable name we’ll be referencing in our snippet below
- The collection of strings we’ll be iterating over. You can write these inline, or pass them as parameters or mappings.
- A section of the template we’ll be iterating over using the variable name above. This is standard CloudFormation JSON/YAML.
These must be listed in an array immediately following the Fn::ForEach
intrinsic function and in this exact order.
We can use our key to reference values found elsewhere in the template. This adds additional flexibility when combined with the aforementioned FindInMap enhancements. Imagine a similar scenario where each instance needs a specific instance type, dictated by which instance it is and which environment we’re in. As described before, we would add our parameters and mappings to our template:
---
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::LanguageExtensions Parameters: Environment: Description: Lifecycle environment. Type: String AllowedValues: - sandbox - dev - qa - prod Default: dev LatestAmiId: Description: Region-specific image to use. Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id> Default: /aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2 Mappings: dev: FirstInstance: InstanceType: t2.micro SecondInstance: InstanceType: t2.micro ThirdInstance: InstanceType: t2.micro qa: FirstInstance: InstanceType: t2.medium SecondInstance: InstanceType: t2.medium ThirdInstance: InstanceType: t2.large prod: FirstInstance: InstanceType: t2.large SecondInstance: InstanceType: t2.xlarge ThirdInstance: InstanceType: t2.2xlarge
Given this configuration, we have different environment values as Parameters
and a Mapping
section that details our sizing requirements for our instance. With this, we can then use our new Fn::ForEach
functionality and FindInMap
enhancements:
Resources: Fn::ForEach::Instances: - InstanceLogicalId - [FirstInstance, SecondInstance, ThirdInstance] - ${InstanceLogicalId}: Type: AWS::EC2::Instance Properties: ImageId: !Ref LatestAmiId InstanceType: !FindInMap - !Ref Environment - !Ref InstanceLogicalId - InstanceType - DefaultValue: t2.micro
This results in the following output:
Resources: FirstInstance: Type: AWS::EC2::Instance Properties: ImageId: !Ref LatestAmiId InstanceType: !FindInMap - !Ref Environment - FirstInstance - InstanceType - DefaultValue: t2.micro SecondInstance: Type: AWS::EC2::Instance Properties: ImageId: !Ref LatestAmiId InstanceType: !FindInMap - !Ref Environment - SecondInstance - InstanceType - DefaultValue: t2.micro ThirdInstance: Type: AWS::EC2::Instance Properties: ImageId: !Ref LatestAmiId InstanceType: !FindInMap - !Ref Environment - ThirdInstance - InstanceType - DefaultValue: t2.micro
This looping feature can be used to create more than just resources – say we want to reference outputs from these EC2 instances as we create them. We can modify our above template to add an Output
section and iterate over it in the same way, as well as exporting the instance ID. We can even express more than one Output
per iteration. We’ll also move the instances list to a parameter for increased clarity.
Parameters: InstancesToManage: Type: CommaDelimitedList Description: Instances to be managed Default: FirstInstance,SecondInstance,ThirdInstance Outputs: Fn::ForEach::InstanceOutputs: - InstanceLogicalId - !Ref InstancesToManage - "${InstanceLogicalId}Id": Export: Name: !Sub ${AWS::AccountId}-${InstanceLogicalId}Id Value: !Ref Ref: InstanceLogicalId "${InstanceLogicalId}AvailabilityZone": Value: Fn::GetAtt: - !Ref InstanceLogicalId - AvailabilityZone
This outputs to:
Outputs: FirstInstanceId: Export: Name: !Sub ${AWS::AccountId}-FirstInstanceId Value: !Ref FirstInstance FirstInstanceAvailabilityZone: Value: Fn::GetAtt: - FirstInstance - AvailabilityZone SecondInstanceId: Export: Name: !Sub ${AWS::AccountId}-SecondInstanceId Value: !Ref SecondInstance SecondInstanceAvailabilityZone: Value: Fn::GetAtt: - SecondInstance - AvailabilityZone ThirdInstanceId: Export: Name: !Sub ${AWS::AccountId}-ThirdInstanceId Value: !Ref ThirdInstance ThirdInstanceAvailabilityZone: Value: Fn::GetAtt: - ThirdInstance - AvailabilityZone
In this snippet, we iterated over our collection and created multiple outputs. For each output, we concatenated our key with some other string. In this case, both Id
and AvailabilityZone
were concatenated with the key to create a unique output name based on the stack name and the logical ID of the resource.
Finally, loops can be nested inside other loops. Combined with the ability to concatenate values and do lookups inside of the Mapping
section, we’re able to significantly simplify complex CloudFormation templates. Imagine an example where we are tasked with creating a Virtual Private Cloud (VPC) with three private subnets and three public subnets. This is a common configuration our customers have and we can configure it simply with looping.
---
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::LanguageExtensions Parameters: AvailabilityTypes: Type: CommaDelimitedList Description: Types of subnets availability - public, private, or both AllowedValues: - Public - Private Default: Public,Private Mappings: SubnetOne: Public: Cidr: 10.215.0.0/24 Private: Cidr: 10.215.1.0/24 SubnetTwo: Public: Cidr: 10.215.2.0/24 Private: Cidr: 10.215.3.0/24 SubnetThree: Public: Cidr: 10.215.4.0/24 Private: Cidr: 10.215.5.0/24 Resources: VPC: Type: AWS::EC2::VPC Properties: CidrBlock: 10.215.0.0/16 EnableDnsSupport: true EnableDnsHostnames: true Fn::ForEach::Subnets: - SubnetIdentifier - - SubnetOne - SubnetTwo - SubnetThree - Fn::ForEach::SubnetAvailabilityType: - AvailabilityType - !Ref AvailabilityTypes - "${SubnetIdentifier}${AvailabilityType}": Type: AWS::EC2::Subnet Properties: VpcId: !Ref VPC CidrBlock: !FindInMap - !Ref SubnetIdentifier - !Ref AvailabilityType - Cidr
which outputs the following resource section:
Resources: VPC: Type: AWS::EC2::VPC Properties: CidrBlock: !Ref VpcCidr EnableDnsSupport: true EnableDnsHostnames: true SubnetOnePublic: Properties: VpcId: !Ref VPC CidrBlock: !FindInMap - SubnetOne - Public - Cidr SubnetOnePrivate: Properties: VpcId: !Ref VPC CidrBlock: !FindInMap - SubnetOne - Private - Cidr SubnetTwoPublic: Properties: VpcId: !Ref VPC CidrBlock: !FindInMap - SubnetTwo - Public - Cidr SubnetTwoPrivate: Properties: VpcId: !Ref VPC CidrBlock: !FindInMap - SubnetTwo - Private - Cidr SubnetThreePublic: Properties: VpcId: !Ref VPC CidrBlock: !FindInMap - SubnetThree - Public - Cidr SubnetThreePrivate: Properties: VpcId: !Ref VPC CidrBlock: !FindInMap - SubnetThree - Private - Cidr
Combining everything we’ve learned so far, this created six subnets total, three public, three private, and attached them to the respective VPC with a relevant CIDR block.
We’re excited to share this functionality with our community, and we invite you to share feedback on future enhancements to the looping functionality here. A few enhancements we’re discussing are:
- Iterating over a key/value pair
- Iterating over a list of lists
- Support in other template sections
- And more!
Please head over and let us know what you think!
Conclusion
In this post, we walked through the new CloudFormation additions to the language extensions transform, how to enable them in your templates, and how to engage in future language extensions via our open language discussion repository. Leave us your feedback at our Language Discussion GitHub repository to help shape the future of the CloudFormation language. We look forward to hearing from you!
About the Author: