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
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.