"""Base class for implementing Lambda functions backing custom CloudFormation resources. The class, CloudFormationCustomResource, has methods that child classes implement to create, update, or delete the resource, while taking care of the parsing of the input, exception handling, and response sending. # For resources of type Custom::MyCustomResource class MyCustomResource(CloudFormationCustomResource): def create(self): # Implement # For AWS SDKs, use: # self.get_boto3_client(service_name) # self.get_boto3_resource(service_name) # Set the name of what you are creating to the value in # self.physical_resource_id # This id is autogenerated for you, but you can set it if you want or need # This id is what CloudFormation uses for Ref # The resource properties defined in the template are in # self.resource_properties # Attributes can be set by returning a dictionary def update(self): # Implement # The name of what you previously created is # self.physical_resource_id # The updated properties are in # self.resource_properties # To check what's changed, compare with # self.old_resource_properties # If you set attributes in create(), you need to set them here too def delete(self): # implement This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. """ import logging import json import traceback import random import string import six from six.moves import http_client import boto3 import requests class CloudFormationCustomResource(object): """Base class for CloudFormation custom resource classes. To create a handler for a custom resource in CloudFormation, simply create a child class (say, MyCustomResource), implement the methods specified below, and create the handler function: handler = MyCustomResource.get_handler() By default, the class name is assumed to be the expected resource type name (i.e., Custom:: + the class name), and incoming requests are validated against this. To disable this, set a class field RESOURCE_TYPE_SPEC=None. To validate incoming requests against types other than the class name, set RESOURCE_TYPE_SPEC to be a string or a list of strings. Child classes must implement the create(), update(), and delete() methods. Each of these methods can indicate success or failure in one of two ways: * Simply return or raise an exception * Set self.status to self.STATUS_SUCCESS or self.STATUS_FAILED In the case of failure, self.failure_reason can be set to a string to provide an explanation in the response. These methods can also populate the self.resource_outputs dictionary with fields that then will be available in CloudFormation. If the return value of the function is a dict, that is merged into resource_outputs. If it is not a dict, the value is stored under the 'result' key. By default, before create is called, self.physical_resource_id is set to a value that is generated similar to how CloudFormation does it: {stack_id}-{logical resource id}-{random string}. This value should be used to name the resource being created. If instead, the physical resource id is generated by the resource itself (for example, Cognito pools have a unique id generated by Cognito), then you should set the class field DISABLE_PHYSICAL_RESOURCE_ID_GENERATION=True, and set self.physical_resource_id yourself within the create method. Child classes may implement validate() and/or populate(). validate() should return True if self.resource_properties is valid. populate() can transfer the contents of self.resource_properties into object fields, if this is not done by validate(). The class provides methods get_boto3_client() and get_boto3_resource() that cache the clients/resources in the class, reducing overhead in the Lambda invocations. These also rely on the get_boto3_session() method, which in turn uses BOTO3_SESSION_FACTORY if it is set, allowing overriding with mock sessions for testing. Similarly, BOTO3_CLIENT_FACTORY and BOTO3_RESOURCE_FACTORY, both of which can be set to callables that take a session and a name, can be set to override client and resource creation. Some hooks are provided to override behavior. The first four are instance fields, since they may be set to functions that rely on instance fields. The last is a class field, since it is called by a class method. * finish_function, normally set to CloudFormationCustomResource.cfn_response, takes as input the custom resource object and deals with sending the response and cleaning up. * send_function, used within CloudFormationCustomResource.cfn_response, takes as input the custom resource object, a url, and the response_content dictionary. Normally this is set to CloudFormationCustomResource.send_response, which uses requests to send the content to its destination. * generate_unique_id_prefix_function can be set to put a prefix on the id returned by generate_unique_id, for example if the physical resource id needs to be an ARN. * generate_physical_resource_id_function is used to get a physical resource id on a create call unless DISABLE_PHYSICAL_RESOURCE_ID_GENERATION is True. It takes the custom resource object as input.This is normally set to CloudFormationCustomResource.generate_unique_id, which generates a physical resource id like CloudFormation: {stack_id}-{logical resource id}-{random string} It also provides two keyword arguments: * prefix: if for example the physical resource id must be an arn * separator: defaulting to '-'. * BOTO3_SESSION_FACTORY takes no input and returns an object that acts like a boto3 session. If this class field is not None, it is used by get_boto3_session() instead of creating a regular boto3 session. This could be made to use placebo for testing https://github.com/garnaat/placebo The class provides four configuration options that can be overridden in child classes: * DELETE_LOGS_ON_STACK_DELETION: A boolean which, when True, will cause a successful stack deletion to trigger the deletion of the CloudWatch log group on stack deletion. If there is a problem during stack deletion, the logs are left in place. NOTE: this is not intended for use when the Lambda function is used by multiple stacks. * DISABLE_PHYSICAL_RESOURCE_ID_GENERATION: If True, skips the automatic generation of a unique physical resource id if the custom resource has a source for that itself, for example if it is specified in the properties, or returned by the resource creation API. Using this option, the class must set self.physical_resource_id in the create() method. * PHYSICAL_RESOURCE_ID_MAX_LEN: An int used by generate_unique_id when generating a physical resource id. """ DELETE_LOGS_ON_STACK_DELETION = False DISABLE_PHYSICAL_RESOURCE_ID_GENERATION = False PHYSICAL_RESOURCE_ID_MAX_LEN = 128 STATUS_SUCCESS = 'SUCCESS' STATUS_FAILED = 'FAILED' REQUEST_CREATE = 'Create' REQUEST_DELETE = 'Delete' REQUEST_UPDATE = 'Update' BASE_LOGGER_LEVEL = None DUMMY_RESPONSE_URL_SILENT = 'dummy:silent' DUMMY_RESPONSE_URL_PRINT = 'dummy:print' RAISE_ON_FAILURE = False STRINGIFY_OUTPUT = True def __init__(self, logger=None): if logger: self.logger = logger else: self.logger = logging.getLogger(self.__class__.__name__) self._base_logger = logging.getLogger('CloudFormationCustomResource') if self.BASE_LOGGER_LEVEL: self._base_logger.setLevel(self.BASE_LOGGER_LEVEL) resource_type_spec = getattr(self, 'RESOURCE_TYPE_SPEC', self.__class__.__name__) def process_resource_type_spec(resource_type_spec): if not (resource_type_spec.startswith('Custom::') or resource_type_spec == 'AWS::CloudFormation::CustomResource'): resource_type_spec = 'Custom::' + resource_type_spec return resource_type_spec if isinstance(resource_type_spec, (list, tuple)): resource_type_spec = [process_resource_type_spec(rt) for rt in resource_type_spec] elif isinstance(resource_type_spec, six.string_types): resource_type_spec = process_resource_type_spec(resource_type_spec) self.resource_type_spec = resource_type_spec self.event = None self.context = None self.resource_type = None self.request_type = None self.response_url = None self.stack_id = None self.request_id = None self.logical_resource_id = None self.physical_resource_id = None self.resource_properties = None self.old_resource_properties = None self.status = None self.failure_reason = None self.resource_outputs = {} self.finish_function = self.cfn_response self.send_response_function = self.send_response self.generate_unique_id_prefix_function = None self.generate_physical_resource_id_function = self.generate_unique_id def validate_resource_type(self, resource_type): """Return True if resource_type is valid""" if not self.resource_type_spec: return True if isinstance(self.resource_type_spec, (list, tuple)): return resource_type in self.resource_type_spec return resource_type == self.resource_type_spec def validate(self): """Return True if self.resource_properties is valid.""" return True def populate(self): """Populate subclass fields from self.resource_properties and self.old_resource_properties, if this is not done in validate()""" pass def create(self): raise NotImplementedError def update(self): raise NotImplementedError def delete(self): raise NotImplementedError def has_property_changed(self, property_name): """Test if a property has changed. Will return true during create.""" return (self.old_resource_properties is None or self.old_resource_properties.get(property_name) != self.resource_properties.get(property_name)) BOTO3_SESSION_FACTORY = None BOTO3_CLIENT_FACTORY = None BOTO3_RESOURCE_FACTORY = None BOTO3_SESSION = None BOTO3_CLIENTS = {} BOTO3_RESOURCES = {} @classmethod def get_boto3_session(cls): if cls.BOTO3_SESSION is None: if cls.BOTO3_SESSION_FACTORY: cls.BOTO3_SESSION = cls.BOTO3_SESSION_FACTORY() else: cls.BOTO3_SESSION = boto3.session.Session() return cls.BOTO3_SESSION @classmethod def get_boto3_client(cls, name): if name not in cls.BOTO3_CLIENTS: if cls.BOTO3_CLIENT_FACTORY: client = cls.BOTO3_CLIENT_FACTORY(cls.get_boto3_session(), name) else: client = cls.get_boto3_session().client(name) cls.BOTO3_CLIENTS[name] = client return cls.BOTO3_CLIENTS[name] @classmethod def get_boto3_resource(cls, name): if name not in cls.BOTO3_RESOURCES: if cls.BOTO3_RESOURCE_FACTORY: resource = cls.BOTO3_RESOURCE_FACTORY(cls.get_boto3_session(), name) else: resource = cls.get_boto3_session().resource(name) cls.BOTO3_RESOURCES[name] = resource return cls.BOTO3_RESOURCES[name] @classmethod def get_handler(cls, *args, **kwargs): """Returns a handler suitable for Lambda to call. The handler creates an instance of the class in every call, passing any arguments given to get_handler. Use like: handler = MyCustomResource.get_handler()""" def handler(event, context): return cls(*args, **kwargs).handle(event, context) return handler def handle(self, event, context): """Use the get_handler class method to get a handler that calls this method.""" self._base_logger.info('REQUEST RECEIVED: {}'.format(json.dumps(event))) def plainify(obj): d = {} for field, value in six.iteritems(vars(obj)): if field.startswith('_'): continue if isinstance(value, (float, bool, type(None)) + six.integer_types + six.string_types): d[field] = value elif isinstance(value, (list, tuple)): d[field] = [plainify(v) for v in value] elif isinstance(value, dict): d[field] = dict((k, plainify(v)) for k, v in six.iteritems(value)) else: d[field] = repr(value) return d self._base_logger.info('LambdaContext: %s' % json.dumps(plainify(context))) # handle an event nested inside of an SNS event if 'Records' in event and len(event['Records']) == 1: event = json.loads(event['Records'][0]['Sns']['Message']) self.event = event self.context = context self.resource_type = event['ResourceType'] self.request_type = event['RequestType'] self.response_url = event['ResponseURL'] self.stack_id = event['StackId'] self.request_id = event['RequestId'] self.logical_resource_id = event['LogicalResourceId'] self.physical_resource_id = event.get('PhysicalResourceId') self.resource_properties = event.get('ResourceProperties', {}) self.old_resource_properties = event.get('OldResourceProperties') self.status = None self.failure_reason = None self.resource_outputs = {} try: if not self.validate_resource_type(self.resource_type): raise Exception('invalid resource type') if not self.validate(): pass if not self.physical_resource_id and not self.DISABLE_PHYSICAL_RESOURCE_ID_GENERATION: self.physical_resource_id = self.generate_physical_resource_id_function(max_len=self.PHYSICAL_RESOURCE_ID_MAX_LEN) self.populate() method_name = self.request_type.lower() self._base_logger.debug("Dispatching to subclass: {}".format(method_name)) outputs = getattr(self, method_name)() if outputs: if not isinstance(outputs, dict): outputs = {'Value': outputs} self.resource_outputs.update(outputs) if not self.status: self.status = self.STATUS_SUCCESS except Exception as e: if not self.status: self.status = self.STATUS_FAILED self.failure_reason = 'Custom resource {} failed due to exception "{}".'.format(self.__class__.__name__, e) if self.failure_reason: self._base_logger.error(str(self.failure_reason)) self._base_logger.debug(traceback.format_exc()) if self.request_type == self.REQUEST_DELETE: if self.status == self.STATUS_SUCCESS and self.DELETE_LOGS_ON_STACK_DELETION: logging.disable(logging.CRITICAL) logs_client = self.get_boto3_client('logs') logs_client.delete_log_group( logGroupName=context.log_group_name) self.finish_function(self) def generate_unique_id(self, prefix=None, separator='-', max_len=None): """Generate a unique id similar to how CloudFormation generates physical resource ids""" if prefix is None: if self.generate_unique_id_prefix_function: prefix = self.generate_unique_id_prefix_function() else: prefix = '' stack_id = self.stack_id.split(':')[-1] if '/' in stack_id: stack_id = stack_id.split('/')[1] stack_id = stack_id.replace('-', '') logical_resource_id = self.logical_resource_id len_of_rand = 12 rand = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(len_of_rand)) if max_len: max_len = max_len - len(prefix) len_of_parts = max_len - len_of_rand - 2 * len(separator) len_of_parts_diff = (len(stack_id) + len(logical_resource_id)) - len_of_parts if len_of_parts_diff > 0: len_of_stack_id = min(len(stack_id), len(stack_id) - len_of_parts_diff // 2) len_of_resource = len_of_parts - len_of_stack_id stack_id = stack_id[:len_of_stack_id] logical_resource_id = logical_resource_id[:len_of_resource] return '{prefix}{stack_id}{separator}{logical_id}{separator}{rand}'.format( prefix=prefix, separator=separator, stack_id=stack_id, logical_id=logical_resource_id, rand=rand, ) @classmethod def send_response(cls, resource, url, response_content): if url == cls.DUMMY_RESPONSE_URL_SILENT: return elif url == cls.DUMMY_RESPONSE_URL_PRINT: six.print_(json.dumps(response_content, indent=2)) else: put_response = requests.put(url, data=json.dumps(response_content)) status_code = put_response.status_code response_text = put_response.text body_text = "" if status_code // 100 != 2: body_text = "\n" + response_text resource._base_logger.debug("Status code: {} {}{}".format(put_response.status_code, http_client.responses[put_response.status_code], body_text)) return put_response @classmethod def cfn_response(cls, resource): physical_resource_id = resource.physical_resource_id if physical_resource_id is None: physical_resource_id = resource.context.log_stream_name default_reason = ("See the details in CloudWatch Log Stream: {}".format(resource.context.log_stream_name)) outputs = {} for key, value in six.iteritems(resource.resource_outputs): if resource.STRINGIFY_OUTPUT and not isinstance(value, six.string_types): value = json.dumps(value) outputs[key] = value response_content = { "Status": resource.status, "Reason": resource.failure_reason or default_reason, "PhysicalResourceId": physical_resource_id, "StackId": resource.event['StackId'], "RequestId": resource.event['RequestId'], "LogicalResourceId": resource.event['LogicalResourceId'], "Data": outputs } resource._base_logger.debug("Response body: {}".format(json.dumps(response_content))) if cls.RAISE_ON_FAILURE and resource.status == cls.STATUS_FAILED: raise Exception(resource.failure_reason) try: return resource.send_response_function(resource, resource.response_url, response_content) except Exception as e: resource._base_logger.error("send response failed: {}".format(e)) resource._base_logger.debug(traceback.format_exc())