Skip to content

Commit

Permalink
Merge pull request #1 from NickolasHKraus/improve-update-logic
Browse files Browse the repository at this point in the history
Improve update logic
  • Loading branch information
NickolasHKraus committed Sep 16, 2019
2 parents b11a8d9 + 20030f1 commit 731f62f
Show file tree
Hide file tree
Showing 6 changed files with 259 additions and 180 deletions.
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@
[![Releases](https://img.shields.io/github/v/release/Dwolla/certificate-validator?color=blue)](https://github.com/Dwolla/certificate-validator/releases)
[![MIT License](https://img.shields.io/github/license/Dwolla/certificate-validator?color=blue)](https://github.com/Dwolla/certificate-validator/blob/master/LICENSE)

Certificate Validator is an AWS CloudFormation custom resource which facilitates certificate validation via DNS.
Certificate Validator is an AWS CloudFormation custom resource which facilitates AWS Certificate Manager (ACM) certificate validation via DNS.

## Overview

Certificate Validator solves a common problem:

> *AWS CloudFormation does not provide a means for automatically validating certificates.*
>*AWS CloudFormation does not provide a means for automatically validating AWS Certificate Manager (ACM) certificates.*
From the [`AWS::CertificateManager::Certificate`](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-certificatemanager-certificate.html) documentation:

>**Important**
>
>When you use the `AWS::CertificateManager::Certificate` resource in an AWS CloudFormation stack, the stack will remain in the `CREATE_IN_PROGRESS` state. Further stack operations will be delayed until you validate the certificate request, either by acting upon the instructions in the validation email, or by adding a CNAME record to your DNS configuration.
## Validating a certificate with DNS

Expand Down
2 changes: 1 addition & 1 deletion certificate_validator/certificate_validator/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

class ValidationMethod(str, Enum):
"""
ValidationMethod of a Request Certificate request.
ValidationMethod of a RequestCertificate request.
The ValidationMethod is the method you want to use to validate that you own
or control the domain associated with a public certificate. You can
Expand Down
12 changes: 12 additions & 0 deletions certificate_validator/certificate_validator/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,18 @@ def update(self) -> None:
within the template. Therefore, custom resource code doesn't have to
detect changes because it knows that its properties have changed when
Update is being called.
*Replacing a Custom Resource During an Update*
You can update custom resources that require a replacement of the
underlying physical resource. When you update a custom resource in an
AWS CloudFormation template, AWS CloudFormation sends an update request
to that custom resource. If a custom resource requires a replacement,
the new custom resource must send a response with the new physical ID.
When AWS CloudFormation receives the response, it compares the
PhysicalResourceId between the old and new custom resources. If they
are different, AWS CloudFormation recognizes the update as a
replacement and sends a delete request to the old resource.
"""
raise NotImplementedError

Expand Down
192 changes: 98 additions & 94 deletions certificate_validator/certificate_validator/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@

class Action(str, Enum):
"""
Action of a Change Resource Record Sets request.
Action of a ChangeResourceRecordSets request.
A Change Resource Record Sets request can have the following actions:
A ChangeResourceRecordSets request can have the following actions:
* CREATE: Creates a resource record set that has the specified values.
* DELETE: Deletes an existing resource record set that has the specified
values.
Expand Down Expand Up @@ -103,7 +103,6 @@ def update(self) -> None:
:rtype: None
:return: None
"""
self.delete()
self.create()

def delete(self) -> None:
Expand Down Expand Up @@ -135,10 +134,10 @@ class CertificateValidator(CertificateMixin, Provider):
"""
A Custom::CertificateValidator Custom Resource.
The Custom::CertificateValidator resource creates, updates, and deletes a
AWS::Route53::RecordSetGroup resource.
The Custom::CertificateValidator resource creates, updates, and deletes
AWS::Route53::RecordSetGroup resources.
The Custom::CertificateValidator resource retrieves the record set used by
The Custom::CertificateValidator resource retrieves the record sets used by
AWS Certificate Manager (ACM) to validate a certificate.
"""
def __init__(self, *args, **kwargs):
Expand All @@ -152,118 +151,125 @@ def __init__(self, *args, **kwargs):
self.acm = ACM()
self.route53 = Route53()

def create(self) -> None:
def change_resource_record_sets(
self, certificate_arn: str, action: Action
) -> None:
"""
Create a AWS::Route53::RecordSetGroup resource.
Create, update, or delete AWS::Route53::RecordSetGroup resources.
Given the ARN of a ACM certificate, create, update, or delete the
AWS::Route53::RecordSetGroup resources used for initial validation.
:type certificate_arn: str
:param certificate_arn: ARN of the ACM certificate
:type action: Action
:param action: action of a ChangeResourceRecordSets request
:rtype: None
:return: None
"""
if not self.is_valid_arn(
self.request.resource_properties['CertificateArn']
):
if not self.is_valid_arn(certificate_arn):
self.response.set_status(success=False)
self.response.set_reason(reason='Certificate ARN is invalid.')
return
self.response.set_physical_resource_id(str(uuid.uuid4()))
try:
response = self.acm.describe_certificate(
certificate_arn=self.request.
resource_properties['CertificateArn']
)
domain_name = response['Certificate']['DomainName']
hosted_zone_id = self.get_hosted_zone_id(domain_name)
resource_records = self.get_resource_records(
self.request.resource_properties['CertificateArn']
)
change_batch = self.get_change_batch(
Action.CREATE.value, resource_records
)
self.route53.change_resource_record_sets(
hosted_zone_id=hosted_zone_id, change_batch=change_batch
domain_validation_options = self.get_domain_validation_options(
certificate_arn
)
for domain_validation_option in domain_validation_options:
# remove subdomains from DomainName
domain_name = '.'.join(
domain_validation_option['DomainName'].split('.')[-2:]
)
hosted_zone_id = self.get_hosted_zone_id(domain_name)
resource_record = domain_validation_option['ResourceRecord']
change_batch = self.get_change_batch(
action.value, resource_record
)
self.route53.change_resource_record_sets(
hosted_zone_id=hosted_zone_id, change_batch=change_batch
)
self.response.set_status(success=True)
except exceptions.ClientError as ex:
self.response.set_status(success=False)
self.response.set_reason(reason=str(ex))

def create(self) -> None:
"""
Create AWS::Route53::RecordSetGroup resources.
:rtype: None
:return: None
"""
self.response.set_physical_resource_id(str(uuid.uuid4()))
self.change_resource_record_sets(
self.request.resource_properties['CertificateArn'], Action.UPSERT
)

def update(self) -> None:
"""
Update the AWS::Route53::RecordSetGroup resource.
Update AWS::Route53::RecordSetGroup resources.
If either the DomainName or SubjectAlternativeNames for the
Custom::Certificate are changed, delete the
AWS::Route53::RecordSetGroup resources associated with the old
Custom::Certificate and create the AWS::Route53::RecordSetGroup
resources associated with the new Custom::Certificate.
:rtype: None
:return: None
"""
self.delete()
self.create()
self.change_resource_record_sets(
self.request.old_resource_properties['CertificateArn'],
Action.DELETE
)
self.change_resource_record_sets(
self.request.resource_properties['CertificateArn'], Action.UPSERT
)

def delete(self) -> None:
"""
Delete the AWS::Route53::RecordSetGroup resource.
Delete AWS::Route53::RecordSetGroup resources.
:rtype: None
:return: None
"""
if not self.is_valid_arn(
self.request.resource_properties['CertificateArn']
):
self.response.set_status(success=False)
self.response.set_reason(reason='Certificate ARN is invalid.')
return
try:
response = self.acm.describe_certificate(
certificate_arn=self.request.
resource_properties['CertificateArn']
)
domain_name = response['Certificate']['DomainName']
hosted_zone_id = self.get_hosted_zone_id(domain_name)
resource_records = self.get_resource_records(
self.request.resource_properties['CertificateArn']
)
change_batch = self.get_change_batch(
Action.DELETE.value, resource_records
)
self.route53.change_resource_record_sets(
hosted_zone_id=hosted_zone_id, change_batch=change_batch
)
self.response.set_status(success=True)
except exceptions.ClientError as ex:
self.response.set_status(success=False)
self.response.set_reason(reason=str(ex))
self.change_resource_record_sets(
self.request.resource_properties['CertificateArn'], Action.DELETE
)

def get_resource_records(self, certificate_arn: str) -> list:
def get_domain_validation_options(self, certificate_arn: str) -> str:
"""
Retrieve the resouce records for a given Certificate.
Retrieve the domain validation options for a given Certificate.
Polling of the DescribeCertificate API endpoint is used since there is
a latency period between when the certificate is created and when the
resource records used for domain validation are available.
:type certificate_arn: str
:param certificate_arn: ARN of the ACM certificate
:rtype: list
:return: list of resource records
:return: domain validation options for a given Certificate
"""
def resource_records_exist(response: dict) -> bool:
if response['Certificate']['DomainValidationOptions'][0].get(
'ResourceRecord'
):
return True
domain_validation_options = response['Certificate'
]['DomainValidationOptions']
for domain_validation_option in domain_validation_options:
if not domain_validation_option.get('ResourceRecord'):
return False
else:
return False
return True

response = polling.poll(
lambda: self.acm.describe_certificate(
certificate_arn=self.request.resource_properties[
'CertificateArn']
),
lambda: self.acm.
describe_certificate(certificate_arn=certificate_arn),
check_success=resource_records_exist,
step=5,
timeout=60
)

resource_records = [
x['ResourceRecord']
for x in response['Certificate']['DomainValidationOptions']
]
return resource_records
return response['Certificate']['DomainValidationOptions']

def get_hosted_zone_id(self, domain_name: str) -> str:
"""
Expand All @@ -279,40 +285,38 @@ def get_hosted_zone_id(self, domain_name: str) -> str:
hosted_zone = response['HostedZones'][0]
return hosted_zone['Id'].split('/hostedzone/')[1]

def get_change_batch(self, action: Action, resource_records: list) -> dict:
def get_change_batch(self, action: Action, resource_record: dict) -> dict:
"""
Create a change batch given a resource record set.
The `resource_records` parameter has the following form:
The `resource_record` parameter has the following form:
[
{
'Name': 'string',
'Type': 'CNAME',
'Value': 'string'
]
}
:type action: Action
:param action: action of a Change Resource Record Sets request
:type resource_records: list
:param resource_records: resource record sets to add for domain
validation
:param action: action of a ChangeResourceRecordSets request
:type resource_record: dict
:param resource_record: resource record set for domain validation
:rtype: dict
:return: a dict containing the resource record sets to add for domain
:return: a dict containing the resource record set for domain
validation
"""
changes = []
for resource_record in resource_records:
changes.append({
'Action': action,
'ResourceRecordSet': {
'Name': resource_record['Name'],
'Type': resource_record['Type'],
'TTL': 300,
'ResourceRecords': [{
'Value': resource_record['Value']
}]
}
})
changes.append({
'Action': action,
'ResourceRecordSet': {
'Name': resource_record['Name'],
'Type': resource_record['Type'],
'TTL': 300,
'ResourceRecords': [{
'Value': resource_record['Value']
}]
}
})
change_batch = {'Changes': changes}
return change_batch
25 changes: 15 additions & 10 deletions certificate_validator/tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,15 @@ def setUp(self):
'PhysicalResourceId': 'physical_resource_id',
'ResourceProperties': {
'ServiceToken': 'service_token',
'CertificateArn': 'arn:aws:acm:us-east-1:123:certificate/1337',
'CertificateArn': 'arn:aws:acm:us-east-1:123:certificate/1',
},
'OldResourceProperties': {
'ServiceToken': 'service_token'
'ServiceToken': 'service_token',
'CertificateArn': 'arn:aws:acm:us-east-1:123:certificate/0',
}
}
self.request = Request(**self.request_kwargs)
self.certificate_arn = 'arn:aws:acm:us-east-1:123:certificate/1337'
self.mock_request = patch.object(provider, 'Request').start()
self.mock_request.return_value = Mock()
self.mock_response = patch.object(provider, 'Response').start()
Expand All @@ -228,18 +230,21 @@ def setUp(self):
self.mock_change_resource_record_sets = patch.object(
resources.Route53, 'change_resource_record_sets'
).start()
self.mock_get_domain_validation_options = patch.object(
resources.CertificateValidator, 'get_domain_validation_options'
).start()
self.mock_get_domain_validation_options.return_value = [{
'DomainName': 'certificate-validator.com',
'ResourceRecord': {
'Name': '_x1.certificate-validator.com.',
'Type': 'CNAME',
'Value': '_x2.acm-validations.aws.'
}
}]
self.mock_get_hosted_zone_id = patch.object(
resources.CertificateValidator, 'get_hosted_zone_id'
).start()
self.mock_get_hosted_zone_id.return_value = 'Z23ABC4XYZL05B'
self.mock_get_resource_records = patch.object(
resources.CertificateValidator, 'get_resource_records'
).start()
self.mock_get_resource_records.return_value = [{
'Name': '_x1.certificate-validator.com.',
'Type': 'CNAME',
'Value': '_x2.acm-validations.aws.'
}]
self.mock_get_change_batch = patch.object(
resources.CertificateValidator, 'get_change_batch'
).start()
Expand Down

0 comments on commit 731f62f

Please sign in to comment.