How To Create Dynamic Condition Expressions In AWS CloudFormation Using Macros?
AWS CloudFormation (CFN) conditions are quite useful for purposes like conditionally create resources, conditionally set the resource properties, and so on. However, when you start getting into some advanced scenarios, these may become a bit limited. For example, checking for the existence of a resource so that you create it only once. How do you handle such scenarios? Is there any way you can do this via CFN as opposed to using some alternatives? That is precisely the purpose of this article.
Note that this is an advanced CFN topic. If you are new to CFN, please check out the AWS CloudFormation – An Architect’s Best Friend to understand the basics first.
What Does Creating A Dynamic Condition Expression Mean?
Simply put, it means creating the expression text dynamically at the deployment time. Typically, the condition expression is static. For example, take a look at the condition below that will evaluate to true if the CreateGlobalResourcesParam parameter is set to “true”.
"CreateGlobalResources": {"Fn::Equals", ["true", {"Ref": "CreateGlobalResourcesParam"}]}
When creating a Dynamic Condition Expression, the expression text is prepared at the deployment time.
"CreateGlobalResources": <content to prepare the expression text dynamically>
What Is The Need To Create A Dynamic Condition Expression?
To understand the need, let’s take a simple use case. Let’s say you have a CFN template that creates a serverless stack. It comprises a lambda and associated resources. Since the user base is spread across the globe, the plan is to create a stack per target region. For this discussion, let’s use us-east-1 and us-west-1 AWS regions with us-east-1 being the primary region.
So far so good. Now, we know that lambda requires an execution IAM role so that it has access to the required AWS resources. And, there are going to be two lambda deployments (one per region). So, we have two options – either create a lambda role per stack or create a single IAM role (say, in the primary stack) that will be used by all subsequent stacks. Both options have their own merits. The former offers more autonomy to each stack, whereas the second option leverages from the lifecycle of resources and minimizes the sprawl of roles that serve the same purpose. Another advantage of the second option is if there were updates to the role, you only need to update the primary stack and all stacks will automatically get the updates, thus reducing the end-to-end rollout team. Also, there are limits to the number of IAM roles you can create. Although, you may be able to request an increase. This is just one scenario. But, there could be similar other use cases where you are trying to reuse a global resource across stacks.
So, for this discussion let’s say that we do want to create the IAM role only once. Now, let’s talk about how to achieve this by creating a Dynamic Condition Expression.
Using Macro To Create A Dynamic Condition Expression
CFN offers a powerful capability called Macro. If you have ever programmed in a language like C/C++ (or another language that supports pre-processors), you may be already familiar with the use of macros. Essentially, a CFN Macro lets you execute certain logic to modify either the content of the template or a snippet within the template. All macros are evaluated before executing the CFN template. So, these are perfect to inject the condition expression dynamically. The following screenshot demonstrates this.
Let’s understand this better.
- In the Conditions block, we are defining a condition named CreateGlobalResources. It should be set to true if the global resources should be created. Otherwise, it should be set to false.
- It is using the Fn::Transform intrinsic function to invoke the ResourceHelper macro with 2 parameters – Operation and RoleName. We will see the details of the macro later in this post. The Operation parameter specifies the operation to perform (in this case, roleExists, which will check for the existence of the specified role). The RoleName parameter specifies the name of the role to check.
- Here, the macro is expected to return the string “true” only if the role exists. Otherwise, it should return the string “false”. This value is then compared with “false” using the Fn::Equals intrinsic function.
- This condition will be then used later in the template to conditionally create the LambdaFunctionRole role as shown below.
The idea here is when the primary stack is deployed in us-east-1, the macro will return “false” as the LambdaFunctionRole role does not exist yet. Hence, the CreateGlobalResources condition will be set to true and the LambdaFunctionRole will be created. When the stack is deployed in us-west-1, the macro will return “true” and the CreateGlobalResources condition will be set to false. Hence, the role will not be created again.
Let’s See The Macro Code
Now that we understand how the Dynamic Condition Expression is created, let’s take a look at the macro code.
{ "AWSTemplateFormatVersion": "2010-09-09", "Description": "A stack to provide utility methods for resources.", "Parameters": { "CreateLambdaRole": { "Description": "Whether to create the lambda role.", "Type": "String", "AllowedValues": ["true", "false"], "Default": "true" } }, "Conditions": { "CreateGlobalResources": {"Fn::Equals": ["true", {"Ref": "CreateLambdaRole"}]} }, "Resources": { "ResourceHelper": { "Type": "AWS::CloudFormation::Macro", "Properties": { "Name": {"Ref": "AWS::StackName"}, "Description": "A macro to provide utility methods for resources.", "FunctionName": {"Fn::GetAtt": ["ResourceHelperLambda", "Arn"]} } }, "ResourceHelperLambda": { "Type": "AWS::Lambda::Function", "Properties": { "FunctionName": "ResourceHelperLambda", "Handler": "index.handler", "Runtime": "nodejs8.10", "Role": {"Fn::Join": ["", ["arn:aws:iam::", {"Ref": "AWS::AccountId"}, ":role/ResourceHelperLambdaRole"]]}, "MemorySize": "128", "Timeout": "30", "Code": { "ZipFile": {"Fn::Join": ["", [ "const AWS = require('aws-sdk');\n", "AWS.config.update({region: '", {"Ref": "AWS::Region"}, "'});\n", "const iam = new AWS.IAM();\n", "\n", "exports.handler = (event, context, callback) => {\n", " console.log('handler(): event: ' + JSON.stringify(event));\n", " const params = event.params;\n", " const op = params.Operation;\n", " const roleName = params.RoleName;\n", " if (op == 'roleExists') {\n", " var request = {\n", " RoleName: roleName\n", " };\n", " iam.waitFor('roleExists', request, function(err, data) {\n", " if (err) {\n", " // Role does not exist\n", " handleResponse(null, 'true');\n", " }\n", " else if (data.Role) {\n", " // Role exists\n", " handleResponse(null, 'false');\n", " }\n", " else {\n", " handleResponse(new Error(`Could not get information about role '${params.RoleName}'.`), null);\n", " }\n", " });\n", " }\n", " else {\n", " handleResponse(new Error(`Unsupported operation '${Operation}'.`), null);\n", " }\n", " const handleResponse = function(err, data) {\n", " var response = {\n", " status: 'SUCCESS',\n", " requestId: event.requestId,\n", " };\n", " if (err) {\n", " console.log('handleResponse(): ' + err);\n", " response.status = 'FAILURE';\n", " }\n", " else {\n", " response.fragment = data;\n", " }\n", " console.log('handleResponse(): response: ' + JSON.stringify(response));\n", " callback(null, response);\n", " };\n", "}\n" ]]} } } }, "ResourceHelperLambdaRole": { "Type": "AWS::IAM::Role", "Condition": "CreateGlobalResources", "Properties": { "RoleName": "ResourceHelperLambdaRole", "AssumeRolePolicyDocument": { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": { "Service": "lambda.amazonaws.com" }, "Action": "sts:AssumeRole" }] }, "ManagedPolicyArns": [ "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", "arn:aws:iam::aws:policy/IAMReadOnlyAccess" ] } }, "ResourceHelperLambdaLogGroup": { "Type": "AWS::Logs::LogGroup", "Properties": { "LogGroupName": {"Fn::Join": ["", ["/aws/lambda/", {"Ref": "ResourceHelperLambda"}]]}, "RetentionInDays": 1 } } } }
As we know, the purpose of this macro is to prepare the Dynamic Condition Expression text with value as either “true” (if the role exists) or “false”. A macro is essentially a lambda-based stack that exists in the same region as the consuming stack. Here, I am using Node.js for implementation. If you are not familiar with it, do not worry. I will explain the important aspects below.
- The macro CFN template takes a single parameter – CreateLambdaRole. It is referred by the CreateGlobalResources condition that is used to conditionally create the IAM role required for the macro lambda. Do not confuse this with the use case we saw earlier. The condition here is strictly for the macro lambda role only. Can you guess why? Perhaps you got it. Since there are 2 AWS regions in our sample deployment, we will have 2 deployments of the macro stack as well. But, we would create the IAM role for the macro lambda only once and use it for both the stacks. So, when the macro stack is created in the primary region (us-east-1), the CreateLambdaRole parameter will be set to “true” and the macro lambda role will be created. And, vice-versa for us-west-1.
- The first resource is the ResourceHelper macro itself.
- The next resource is the ResourceHelperLambda that provides macro implementation.
- In its Code block, the AWS SDK is initialized first.
- Next in the handler function, we are first extracting the Operation and RoleName parameters. Apart from these parameters, the macro also receives other request data in a well-defined format, such as the requestId, which is a unique identifier for a given macro call. The following screenshot shows a sample macro request.
- This is followed by a check to confirm if the request operation is roleExists. If so, an IAM SDK call is issued to check whether the supplied role already exists.
- If the role exists, a string with value ‘true’ is returned. Otherwise, ‘false’ is returned. This string output is then used to prepare the lambda response in a well-defined format, which includes additional fields, such as status. The following screenshot shows a sample macro response. The content of the fragment field is our Dynamic Condition Expression text.
- The ResourceHelperLambdaRole is the IAM role used by the macro lambda.
- Finally, the ResourceHelperLambdaLogGroup is the log group used by the macro lambda.
In this article, we saw how to use macros to create CFN condition expressions dynamically. This capability can be extremely useful, especially if you are trying to avoid splitting provisioning logic in multiple places, such as some in CFN and some via scripts. If you have multiple such common methods, you could create one or more reusable macros that can be used by multiple stacks thus increasing the reusability. So, next time you have a situation where you would want the CFN conditions to be dynamic, you may want to consider creating Dynamic Condition Expressions using macros.
Happy coding!
– Nitin
If you liked this post, you will find my AWS CloudFormation Deep Dive course useful that focuses on many such best practices, techniques and hands-on examples to use CloudFormation in real-world deployments.
Also published on Medium.