Customizing CloudFormation With Python

Issue ACM Certificates From CloudFormation With Python

Posted by Ryan S. Brown on Mon, Feb 1, 2016
In General
Tags: python, lambda, cloudformation

Last week, I made a set of CloudFormation custom resources to issue AWS Certificate Manager certificates. It’s pretty simple to add support to CloudFormation for just about any resource. This post walks through making a custom resource in Python. For more on using the resources for ACM certificates, check out this post. The full resource code is also on Github.

Designing the Resource

The first step to making a custom resource is outlining its dependencies. What information does it need to do its job? Let’s peek at the ACM certificate request API and see.

{
    "DomainName": "string",
    "DomainValidationOptions": [
        {
            "DomainName": "string",
            "ValidationDomain": "string"
        }
    ],
    "IdempotencyToken": "string",
    "SubjectAlternativeNames": [
        "string"
    ]
}

A CloudFormation user probably doesn’t want to specify domains separately. They’d rather use a single list of domains. For subdomains, they’re going to need to be able to specify domain validation options, so we’ll pass those through.

With that, let’s see what our resource will look like:

"ACMCertificate": {
    "Type": "Custom::AcmCertificateRequest",
    "Properties": {
        "Domains": ["test.serverlesscode.com"],
        "ValidationOptions": {
            "ValidationDomain": "serverlesscode.com",
            "DomainName": "test.serverlesscode.com"
        },
        "Await": true,

        "ServiceToken": "arn:aws:lambda:us-east-1:REDACTED:function:CfnAcmCertificate"
    }
}

You can use any string after the Custom:: type name, I chose AcmCertificateRequest but that’s not mandatory.

I added the Await parameter so that users can specify whether they want to wait for the certificate to be approved by the domain owner, or to just issue the request.

Now that we know what parameters to take, it’s time to get down to writing the code. To make this part easier, I wrote a library to handle communicating with CloudFormation.

Writing the Resource

To start, let’s take a brief look at my cfn_resource library. The code is on Github at ryansb/cfn-wrapper-python. It provides a few Flask-like decorators to make it easy to handle the different custom resource requests. To use it, you instantiate a Resource, then use it to decorate your handlers for the different request types.

import cfn_resource

handler = cfn_resource.Resource()

@handler.create
def create_thing(event, context):
    # do some stuff
    return {"PhysicalResourceId": "arn:aws:fake:myID"}

This is a lot like Flask’s route decorators, the Resource object is called by Lambda with the event, and it routes the event to the function you specify. It also handles sending a response back to CloudFormation about the success/failure of the resource. If you’re interested, that code is here.

Now let’s walk through creating the ACM certificate, given the CloudFormation resource in the last section.

@handler.create
def create_cert(event, context):
    # get the properties set in the CloudFormation resource
    props = event['ResourceProperties']
    domains = props['Domains'] # list of domain names

    # ... there's more code here but I cut it out, see github for full code

    kwargs = {
        'DomainName': domains[0],
        # the idempotency token length limit is 31 characters
        'IdempotencyToken': id_token[:30]
    }

    if len(domains) > 1:
        # add alternative names if the user wants more names
        # wildcards are allowed
        kwargs['SubjectAlternativeNames'] = domains[1:]

    if props.get('ValidationOptions'):
        """ List of domain validation options. Looks like:
        {
            "DomainName": "test.foo.com",
            "ValidationDomain": "foo.com",
        }
        """
        kwargs['DomainValidationOptions'] = props.get('ValidationOptions')

    # here we actually make the request
    response = acm.request_certificate(**kwargs)
    if props.get('Await', False):
        await_validation(domains[0], context)

    return {
        'Status': 'SUCCESS',
        'Reason': 'Cert request created successfully',
        'PhysicalResourceId': response['CertificateArn'],
        'Data': {},
    }

Full code here

Deletion is even simpler, because there’s nothing to check. When creating the certificate we tell CloudFormation the ARN of the certificate, and it sends it with the DELETE request. We just need to use the PhysicalResourceId where the ARN is saved to delete the certificate.

@handler.delete
def delete_certificate(event, context):
    resp = {'Status': 'SUCCESS',
            'PhysicalResourceId': event['PhysicalResourceId'],
            'Data': {},
            }
    try:
        acm.delete_certificate(CertificateArn=event['PhysicalResourceId'])
    except:
        log.exception('Failure deleting cert with arn %s' % event['PhysicalResourceId'])
        resp['Reason'] = 'Some exception was raised while deleting the cert'
    return resp

That’s about it for the Python to handle the resource. Now let’s use it!

Using the Resource

Of course, we’ll need a template that issues the certificate. First, you’ll need to create the Lambda function for the template to call. In the ACM-CloudFormation repository I’ve got a template to create the IAM role for the function.

{
    "AWSTemplateFormatVersion": "2010-09-09",
    "Parameters": {
        "Domains": {
            "Type": "CommaDelimitedList",
            "Description": "Comma-separated list of domain names"
        }
    },
    "Resources": {
        "ACMCertificate": {
            "Type": "Custom::AcmCertificateRequest",
            "Properties": {
                "Domains": {
                    "Ref": "Domains"
                },
                "Await": true,
                "ServiceToken": "arn:aws:lambda:us-east-1:REDACTED:function:CfnAcmCertificate"
            }
        }
    }
}

Note that you need to add the ServiceToken property with the ARN of the Lambda function. This property lets CloudFormation know what function to invoke with the resource info.

Deploying the Resource

Fortunately, we can use CloudFormation to deploy custom resource functions in the same template where they will be used - excellent!

The template needs to create the IAM role and policy for the function. This one needs full access to all things ACM because granular IAM permissions don’t yet exist for this service.

"ExecRolePolicies": {
  "Type": "AWS::IAM::Policy",
  "Properties": {
    "PolicyName": "ExecRolePolicy",
    "PolicyDocument": {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Action": [
            "acm:*",
            "cloudfront:List*",
            "cloudfront:Get*",
            "cloudfront:UpdateDistribution"
          ],
          "Resource": [ "*" ],
          "Effect": "Allow"
        },
        {
          "Action": [ "logs:*" ],
          "Resource": "arn:aws:logs:*:*:*",
          "Effect": "Allow"
        }
      ]
    },
    "Roles": [{"Ref": "ExecRole"}]
  }
},
"ExecRole": {
  "Type": "AWS::IAM::Role",
  "Properties": {
    "AssumeRolePolicyDocument": {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Action": ["sts:AssumeRole"],
          "Effect": "Allow",
          "Principal": {"Service": ["lambda.amazonaws.com"]}
        }
      ]
    }
  }
},

Now that we have the permissions, we can create the Lambda function that provides the custom resource.

"AcmRegistrationFunction": {
  "Type": "AWS::Lambda::Function",
  "Properties": {
    "Handler": "acm_handler.handler",
    "MemorySize": 128,
    "Runtime": "python2.7",
    "Code": {
      "S3Bucket": "examples.serverlesscode.com",
      "S3Key": "acm-certificate-resource-functions.zip"
    },
    "Role": {"Fn::GetAtt": ["ExecRole", "Arn"]},
    "Timeout": 300
  }
},

Finally, we can use that function for our ACM resource. Note the use of Fn::GetAtt to dynamically find the ARN of the Lambda function, so we can use it in the same template it’s created in.

"AcmCertificate": {
  "Type": "Custom::AcmCertificate",
  "Properties": {
    "Domains": {
      "Ref": "Domains"
    },
    "ServiceToken": {
      "Fn::GetAtt": [
        "AcmRegistrationFunction",
        "Arn"
      ]
    },
    "Await": true
  }
}

CloudFormation handles the dependencies here, so it waits for the Lambda function to be available before calling it to provide the SSL resource.

To launch this right now click Launch stack TestStack and provide a comma-separated list of domains, with the root first. For me, that’s serverlesscode.com,www.serverlesscode.com. You’ll get emails for each domain that you have to click the “Confirm” link on before the certificate is issued.

Wrapping Up

For more about this resource, check out this post that covers using it to provide a custom certificate for a CloudFormation distribution. You can also get the template for this post here.

Keep up with future posts via RSS. If you have suggestions, questions, or comments my email is ryan@serverlesscode.com.


Tweet this, send to Hackernews, or post on Reddit