CloudFormation custom macros let you inject arbitrary code into your templates at build time, long before CloudFormation even tries to create resources.
Here’s a quick example. Imagine you have a common pattern for defining IAM roles with specific managed policies and a trust policy that looks something like this:
MyCustomRole:
Type: AWS::IAM::Role
Properties:
RoleName: my-app-role
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
- arn:aws:iam::aws:policy/CloudWatchLogsReadOnlyAccess
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
This is repetitive if you define many such roles. With a macro, you can define a single, more abstract resource type and let the macro expand it into the full IAM role definition.
First, we define the macro itself. This is a Lambda function that takes a CloudFormation template fragment as input and returns a modified fragment.
{
"AWSTemplateFormatVersion": "2010-09-09",
"Description": "Macro to generate IAM roles with common policies",
"Resources": {
"IamRoleMacro": {
"Type": "AWS::Lambda::Function",
"Properties": {
"FunctionName": "CloudFormationIamRoleMacro",
"Handler": "index.handler",
"Runtime": "python3.9",
"Role": "arn:aws:iam::123456789012:role/LambdaExecutionRoleForCFNMacro",
"Code": {
"ZipFile": "import json\n\ndef handler(event, context):\n fragment = event['fragment']\n # Process the fragment here\n # For this example, we'll just return it as-is\n # In a real macro, you'd transform it\n return fragment"
}
}
}
}
}
The Role ARN arn:aws:iam::123456789012:role/LambdaExecutionRoleForCFNMacro needs to be a valid IAM role that the Lambda function can assume. This role must have permissions to execute Lambda functions and, crucially, permissions to interact with CloudFormation itself, specifically cloudformation:RegisterType and cloudformation:DeregisterType.
Once the Lambda function is deployed, you register it as a CloudFormation macro:
aws cloudformation register-type \
--type MACRO \
-- வழங்கு-name CloudFormationIamRoleMacro \
--execution-role-arn arn:aws:iam::123456789012:role/LambdaExecutionRoleForCFNMacro \
--region us-east-1
Now, in your main CloudFormation template, you can reference this macro. When CloudFormation processes your template, it invokes the macro, which in turn invokes your Lambda function.
Let’s refine our example. Instead of just returning the fragment, the macro will generate a specific IAM role.
Lambda Function Code (index.py):
import json
def handler(event, context):
fragment = event['fragment']
macros = fragment.get('Resources', {})
processed_resources = {}
for name, resource in macros.items():
if resource['Type'] == 'Custom::MyIAMRole':
properties = resource.get('Properties', {})
role_name = properties.get('RoleName', name)
managed_policies = properties.get('ManagedPolicies', [])
assume_role_principal_service = properties.get('AssumeRolePrincipalService', 'lambda.amazonaws.com')
# Construct the IAM Role resource
iam_role_resource = {
"Type": "AWS::IAM::Role",
"Properties": {
"RoleName": role_name,
"ManagedPolicyArns": managed_policies,
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": assume_role_principal_service
},
"Action": "sts:AssumeRole"
}
]
}
}
}
processed_resources[name] = iam_role_resource
else:
processed_resources[name] = resource # Keep other resources as-is
fragment['Resources'] = processed_resources
return fragment
Your CloudFormation Template (template.yaml):
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::LanguageExtensions
Resources:
MyApplicationRole:
Type: Custom::MyIAMRole
Properties:
RoleName: my-app-role-generated
ManagedPolicies:
- arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
- arn:aws:iam::aws:policy/CloudWatchLogsReadOnlyAccess
AssumeRolePrincipalService: lambda.amazonaws.com
AnotherServiceRole:
Type: Custom::MyIAMRole
Properties:
RoleName: another-service-role-generated
ManagedPolicies:
- arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess
AssumeRolePrincipalService: ec2.amazonaws.com
When you deploy template.yaml, CloudFormation sees the Transform directive. It then invokes the registered macro CloudFormationIamRoleMacro. The macro’s Lambda function receives the fragment containing MyApplicationRole and AnotherServiceRole. It processes these Custom::MyIAMRole resources, expands them into full AWS::IAM::Role definitions, and returns the modified fragment to CloudFormation.
This pattern is incredibly powerful for enforcing standards, reducing boilerplate, and creating domain-specific languages within CloudFormation. You can abstract away complex configurations, enforce security best practices, and make your templates much more concise and readable.
The key to understanding how this works is realizing that the Transform directive tells CloudFormation to preprocess your template before it starts provisioning resources. The Lambda function acts as a code generator, transforming your abstract definitions into concrete CloudFormation resources.
The most surprising thing about macros is that they operate on the entire template fragment passed to them, not just the resources they are defined on. This means a single macro can potentially transform multiple resource types or even add entirely new resources based on the input.
Consider a scenario where you want to automatically add a CloudWatch Alarm to any EC2 instance you define. Your macro would look for AWS::EC2::Instance resources, and if found, it would insert a new AWS::CloudWatch::Alarm resource that references the instance created by the AWS::EC2::Instance resource. This is all done within the event['fragment'] processing.
The next concept you’ll likely explore is CloudFormation custom resources, which are similar but execute after resources have been created or updated, allowing for more complex pre- or post-provisioning logic or integration with external systems.