AWS Lambda-backed Custom Resources for Stack Outputs, Resources, and Parameters
At Stelligent we are continually improving our methods for developing immutable infrastructure. One pattern we have adopted is to use a layered approach when provisioning AWS resources through CloudFormation. This leads to the break down of a single-purpose monolithic CloudFormation template, into multiple, reusable templates.
While we find this approach to be more scalable and maintainable, it does introduce a new problem to solve. In the monolithic template — where we create every resource necessary to run our stack — we can cross reference resources created within the template. For example, if we create a security group resource which allows inbound HTTPS access, we can refer to its logical resource name when associating it with an Elastic Load Balancer we create in the same template.
"LoadBalancerSecurityGroup" : {
"Type" : "AWS::EC2::SecurityGroup"
"Properties" : {
"SecurityGroupIngress" : [
{
"IpProtocol" : "tcp",
"ToPort" : "443",
"CidrIp" : "0.0.0.0/0",
"FromPort" : "443"
}
],
"GroupDescription" : "Allow inbound HTTPS traffic"
},
},
"LoadBalancer" : {
"Type" : "AWS::ElasticLoadBalancing::LoadBalancer",
"Properties" : {
"SecurityGroups" : [
{
"Ref" : "LoadBalancerSecurityGroup"
}
]
}
},
If however, we abstract the security group into a separate “network stack” — because we want to create a single security group which can be utilized by any resource which needs to allow inbound HTTPS access — we now need a way to reference the security group’s physical resource id in the “downstream” stack where we create our ELB. Since we are creating the ELB in a separate stack, an internal reference is not available to us.
There are a few available options for making this association. One could place the Security Group’s resource id into a mapping declaration in the template. This of course would require replacing the resource id should it ever change. Not ideal. A better way would be to look up the resource id and pass it in as a stack parameter by using code similar to this:
require 'aws-sdk'
def get_physical_id(stack,logical_id)
stack.resource_summaries.find{|rs| rs.logical_id == logical_id}.physical_resource_id
end
cfn = Aws::CloudFormation::Resource.new(region: 'us-east-1')
network_stack = Aws::CloudFormation::Stack.new(name: 'myNetworkStack', region: 'us-east-1')
parameters = [
{
parameter_key: "publicHttpsSgId",
parameter_value: get_physical_id(network_stack, 'myPublicHttpsSg')
},
...
]
cfn.create_stack({
stack_name: 'myDownstreamStack',
template_body: 'someApplication.template',
parameters: parameters
})
And then creating a parameter for each upstream resource in the downstream CloudFormation template:
"Parameters" : {
"publicHttpsSgId" : {
"Description": "...",
"Type" : "String"
},
The code above works well, and has proved useful on many engagements; but as I mentioned at the onset of this post, we are always looking to improve. Thus we come to the topic of this post.
AWS Lambda-backed Custom Resources
Not long ago, AWS published a great walkthrough demonstrating how one can create an AWS Lambda function which will return a mapping of all of the Outputs key-value pairs of an upstream CloudFormation stack. By combining this function with a CloudFormation custom resource, one is able to cross reference Resource IDs created in other stacks directly within the downstream template —bypassing the need to write external code and pass in Resource IDs through parameters. If you are unfamiliar with the walkthrough, you may find it worthwhile to review it before moving forward.
While this solution is an improvement over supporting separate code to lookup and then pass in parameters, I still find it slightly annoying that I need to explicitly create a stack Output to declare every resource id I might want to use downstream. What if I don’t know yet if I will use a particular resource, or if I am just feeling lazy? For these reasons, I have modified the example in the walkthrough to support looking up stack Resources directly, eliminating the need to declare them as stack Outputs.
Stack Resource Lookup Lambda Function
To take advantage of this functionality one simply needs to
- Create an IAM Role with a Policy which allows the Lambda to execute.
- Create the Lambda itself
- Create a Custom Resource and use the FnGetAtt intrinsic function to return a resource id
The IAM Role/Policy is straightforward. In this case we need to create a policy which will allow our Lambda specific rights to the CloudFormation service. We also need to allow it to create and write out AWS logs. Below is the snippet of code from a CloudFormation template to create the Role.
"Resources": {
"lambdaExecutionRole": {
"Type": "AWS::IAM::Role",
"Properties": {
"AssumeRolePolicyDocument": {
"Statement": [{
"Action": ["sts:AssumeRole"],
"Effect": "Allow",
"Principal": {
"Service": ["lambda.amazonaws.com"]
}
}],
"Version": "2012-10-17"
},
"Path": "/",
"Policies": [{
"PolicyDocument": {
"Statement": [
{
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Effect": "Allow",
"Resource": "arn:aws:logs:*:*:*"
},
{
"Action": [
"cloudformation:DescribeStacks",
"cloudformation:ListStackResources"
],
"Effect": "Allow",
"Resource": "*"
}
],
"Version": "2012-10-17"
},
"PolicyName": "root"
}]
}
},
Next we have the AWS Lambda function itself. Here we use the javascript SDK to return a mapping of Logical Resource IDs to Physical Resources IDs for any given stack. We also associate this Lambda with the role we created above.
"lookupStackResources" : {
"Type" : "AWS::Lambda::Function",
"Properties" : {
"Code" : {
"ZipFile" : {
"Fn::Join" : [
"",[
"var response = require('cfn-response');",
"exports.handler = function(event, context) {",
" console.log('REQUEST RECEIVED:\n', JSON.stringify(event));",
" if (event.RequestType == 'Delete') {",
" response.send(event, context, response.SUCCESS);",
" return;",
" }",
" var stackName = event.ResourceProperties.StackName;",
" var responseData = {};",
" if (stackName) {",
" var aws = require('aws-sdk');",
" var cfn = new aws.CloudFormation();",
" cfn.listStackResources({StackName: stackName}, function(err, data) {",
" if (err) {",
" responseData = {Error: 'listStackResources call failed'};",
" console.log(responseData.Error + ':\n', err);",
" response.send(event, context, response.FAILED, responseData);",
" }",
" else {",
" data.StackResourceSummaries.forEach(function(output) {",
" responseData[output.LogicalResourceId] = output.PhysicalResourceId;",
" });",
" response.send(event, context, response.SUCCESS, responseData);",
" }",
" });",
" } else {",
" responseData = {Error: 'Stack name not specified'};",
" console.log(responseData.Error);",
" response.send(event, context, response.FAILED, responseData);",
" }",
"};"
]
]
}
},
"Runtime" : "nodejs",
"Role" : {
"Fn::GetAtt" : ["lambdaExecutionRole","Arn"]
},
"Timeout" : "30",
"Handler" : "index.handler"
}
},
Finally we create the Custom Resource (you will need to replace the ServiceToken and StackName values with your own) and perform the lookup.
"Resources" : {
"NetworkResources" : {
"Type" : "Custom::NetworkResources",
"Properties" : {
"ServiceToken" : "arn:aws:lambda:us-east-1:012345678901:function:myLookupStack-lookupStackResources-ABCD1E2FGH3I"
"StackName" : "myUpstreamNetworkStack"
}
},
"RdsSubnetGroup" : {
"Type" : "AWS::RDS::DBSubnetGroup",
"Properties" : {
"DBSubnetGroupDescription" : "Subnets were created by myUpstreamNetworkStack"
"SubnetIds" : [
{
"Fn::GetAtt" : ["NetworkResources","DbSubnetA"]
},
{
"Fn::GetAtt" : ["NetworkResources","DbSubnetB"]
}
],
}
},
Here we have created two subnet resources, DbSubnetA and DbSubnetB, in our upstream network stack. We have created a second, downstream stack where we will create the resources for an RDS Database Instance. When we create our RDS Subnet Group resource we need to provide the physical resource IDs for our SubnetIds property. Here we rely on the NetworkResources Custom Resource to return a mapping of logical resource IDs to physical resource IDs — think of it as a hash if you come from a Ruby background — and we use the Fn::GetAtt intrinsic function to retrieve the physical resource ID for a given logical resource ID (e.g. DbSubnetA or DbSubnetB).
Stack Parameters Lookup Lambda Function
CloudFormation stacks persist three types of key/value mappings: Outputs, Resources, and Parameters. Outputs lookups were covered by the AWS Walkthrough referenced earlier, Resource lookups are covered in the section above, so that just leaves Parameters. Since all of the same principles apply, I am only going to include the code to create the Lambda. The following CloudFormation snippet will create an AWS Lambda function returning a mapping of a given Stack’s Parameter Keys to Parameter Values.
"lookupStackParameters" : {
"Type" : "AWS::Lambda::Function",
"Properties" : {
"Code" : {
"ZipFile" : {
"Fn::Join" : [
"",[
"var response = require('cfn-response');",
"exports.handler = function(event, context) {",
" console.log('REQUEST RECEIVED:\n', JSON.stringify(event));",
" if (event.RequestType == 'Delete') {",
" response.send(event, context, response.SUCCESS);",
" return;",
" }",
" var stackName = event.ResourceProperties.StackName;",
" var responseData = {};",
" if (stackName) {",
" var aws = require('aws-sdk');",
" var cfn = new aws.CloudFormation();",
" cfn.describeStacks({StackName: stackName}, function(err, data) {",
" if (err) {",
" responseData = {Error: 'DescribeStacks call failed'};",
" console.log(responseData.Error + ':\n', err);",
" response.send(event, context, response.FAILED, responseData);",
" }",
" else {",
" data.Stacks[0].Parameters.forEach(function(param) {",
" responseData[param.ParameterKey] = param.ParameterValue;",
" });",
" response.send(event, context, response.SUCCESS, responseData);",
" }",
" });",
" } else {",
" responseData = {Error: 'Stack name not specified'};",
" console.log(responseData.Error);",
" response.send(event, context, response.FAILED, responseData);",
" }",
"};"
]
]
}
},
"Runtime" : "nodejs",
"Role" : {
"Fn::GetAtt" : ["lambdaExecutionRole","Arn"]
},
"Timeout" : "30",
"Handler" : "index.handler"
}
}
Summary
Hopefully after reading this you are encouraged to utilize a layered approach when authoring your CloudFormation templates. The benefits of reusable, loosely coupled code apply just the same to infrastructure provisioning as any other code you maintain. Lambda backed Custom Resources provide you just enough functionality to solve the problem presented by inter-stack resource references. We hope you find this approach useful, and welcome any feedback you have.