├── .gitignore ├── MANIFEST.in ├── README.rst ├── cfnlambda.py ├── cfnlambda_util.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | .*project 2 | *.pyc 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Note: this repo has been migrated to https://github.com/iRobotCorporation/cfn-custom-resource 2 | 3 | cfnlambda 4 | ========= 5 | 6 | :code:`cfnlambda` provides an abstract base class to make it easier to implement 7 | `AWS CloudFormation custom resources`_. It was developed from Gene Wood's 8 | `library of the same name`_, which provides lower-level functions and 9 | decorators for the same purpose. 10 | 11 | The :code:`CloudFormationCustomResource` class requires a child class to implement 12 | three methods: :code:`create`, :code:`update`, and :code:`delete`, for each of the respective 13 | CloudFormation actions. Failure can be indicated by raising an exception. 14 | Logging to CloudWatch is provided by the :code:`logger` field. 15 | 16 | More details are available in the docs in the :code:`cfnlambda` module. 17 | 18 | Quickstart 19 | ---------- 20 | 21 | :: 22 | 23 | from cfnlambda import CloudFormationCustomResource 24 | 25 | class Adder(CloudFormationCustomResource): 26 | def create(self): 27 | sum = (float(self.resource_properties['key1']) + 28 | float(self.resource_properties['key2'])) 29 | return {'sum': sum} 30 | 31 | update = create 32 | 33 | def delete(self): 34 | pass 35 | 36 | handler = Adder.get_handler() 37 | 38 | :: 39 | 40 | from cfnlambda import CloudFormationCustomResource 41 | 42 | class AWSServiceUnsupportedByCF(CloudFormationCustomResource): 43 | def create(self): 44 | client = self.get_boto3_client('service-name') 45 | # use client 46 | 47 | def update(self): 48 | # create and use client 49 | 50 | def delete(self): 51 | # create and use client 52 | 53 | handler = AWSServiceUnsupportedByCF.get_handler() 54 | 55 | :: 56 | 57 | from cfnlambda import CloudFormationCustomResource 58 | 59 | class ExternalServer(CloudFormationCustomResource): 60 | DISABLE_PHYSICAL_RESOURCE_ID_GENERATION = True # get this from server 61 | 62 | # create_server, update_server, terminate_server methods implemented here 63 | 64 | def create(self): 65 | properties: 66 | response = self.create_server(properties=self.resource_properties) 67 | if response.status != 200: 68 | raise Exception('server creation failed') 69 | self.physical_resource_id = response.hostname 70 | return {'IP': response.ip} 71 | 72 | def update(self): 73 | response = self.update_server(hostname=self.physical_resource_id, properties=self.resource_properties) 74 | if response.status != 200: 75 | raise Exception('server update failed') 76 | return {'IP': response.ip} 77 | 78 | def delete(self): 79 | response = self.terminate_server(hostname=self.physical_resource_id) 80 | if response.status != 200: 81 | raise Exception('server termination failed') 82 | 83 | handler = ExternalServer.get_handler() 84 | 85 | The :code:`handle` method on :code:`CloudFormationCustomResource` does a few things. It logs 86 | the event and context, populates the class fields, generates a physical resource id 87 | for the resource, and calls the :code:`validate` and :code:`populate` methods that the child class 88 | can override. Then, it calls the :code:`create`, :code:`update`, or :code:`delete` method as 89 | appropriate, adds any returned dictionary to the :code:`resource_outputs` dict, or, in 90 | case of an exception, sets the status to FAILED. It then cleans up and returns the 91 | result to CloudFormation. 92 | 93 | The :code:`resource_outputs` dict is then available in CloudFormation for use with the 94 | :code:`Fn::GetAtt` function. 95 | 96 | :: 97 | 98 | { "Fn::GetAtt": [ "MyCustomResource", "ip" ] } 99 | 100 | If the return value from the :code:`create`/:code:`update`/:code:`delete` method 101 | is not a dict, it is placed into the :code:`resource_outputs` dict with key 'result'. 102 | 103 | If the :code:`DELETE_LOGS_ON_STACK_DELETION` class field is set to True, all 104 | CloudWatch logs generated while the stack was created, updated and deleted will 105 | be deleted upon a successful stack deletion. If an exception is thrown during 106 | stack deletion, the logs will always be retained to facilitate troubleshooting. 107 | NOTE: this is not intended for use when multiple stacks access the same function. 108 | 109 | Finally, the custom resource will not report a status of FAILED when a stack 110 | DELETE is attempted. This will prevent a CloudFormation stack from getting stuck 111 | in a DELETE_FAILED state. One side effect of this is that if your AWS Lambda 112 | function throws an exception while trying to process a stack deletion, though 113 | the stack will show a status of DELETE_COMPLETE, there could still be resources 114 | which your AWS Lambda function created which have not been deleted. This will be 115 | noted in the logs. To disable this feature, set HIDE_STACK_DELETE_FAILURE 116 | class field to False. 117 | 118 | To deploy MyCustomResource.py, simply run 119 | 120 | :: 121 | 122 | python cfnlambda.py path/to/MyCustomResource.py IAMRoleName 123 | 124 | This will create a Lambda function named MyCustomResource and print the ARN. 125 | Note that the role is only required if the function doesn't yet exist, so 126 | subsequent deploys can omit it. The timeout is 5 minutes and the memory size 127 | is 128 MB. If these are changed manually, redeploying will not overwrite your 128 | changes. 129 | 130 | How to contribute 131 | ----------------- 132 | Feel free to open issues or fork and submit PRs. 133 | 134 | * Issue Tracker: https://github.com/iRobotCorporation/cfnlambda/issues 135 | * Source Code: https://github.com/iRobotCorporation/cfnlambda 136 | 137 | .. _library of the same name: https://github.com/gene1wood/cfnlambda 138 | .. _AWS CloudFormation custom resources: http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-custom-resources.html 139 | .. _cfn-response: http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html#cfn-lambda-function-code-cfnresponsemodule 140 | -------------------------------------------------------------------------------- /cfnlambda.py: -------------------------------------------------------------------------------- 1 | """Base class for implementing Lambda functions backing custom CloudFormation resources. 2 | 3 | The class, CloudFormationCustomResource, has methods that child classes 4 | implement to create, update, or delete the resource, while taking care of the 5 | parsing of the input, exception handling, and response sending. The class does 6 | all of its importing inside its methods, so it can be copied over to, for 7 | example, write the Lambda function in the browser-based editor, or inline in 8 | CloudFormation once it supports that for Python. 9 | 10 | This Source Code Form is subject to the terms of the Mozilla Public 11 | License, v. 2.0. If a copy of the MPL was not distributed with this 12 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 13 | """ 14 | 15 | class CloudFormationCustomResource(object): 16 | """Base class for CloudFormation custom resource classes. 17 | 18 | To create a handler for a custom resource in CloudFormation, simply create a 19 | child class (say, MyCustomResource), implement the methods specified below, 20 | and create the handler function: 21 | 22 | handler = MyCustomResource.get_handler() 23 | 24 | The child class does not need to have a constructor. In this case, the resource 25 | type name, which is validated by handle() method, is 'Custom::' + the child 26 | class name. The logger also uses the child class name. If either of these need 27 | to be different, they can be provided to the parent constructor. The resource 28 | type passed to the parent constructor can be a string or a list of strings, if 29 | the child class is capable of processing multiple resource types. 30 | 31 | Child classes must implement the create(), update(), and delete() methods. 32 | Each of these methods can indicate success or failure in one of two ways: 33 | * Simply return or raise an exception 34 | * Set self.status to self.STATUS_SUCCESS or self.STATUS_FAILED 35 | In the case of failure, self.failure_reason can be set to a string to 36 | provide an explanation in the response. 37 | These methods can also populate the self.resource_outputs dictionary with fields 38 | that then will be available in CloudFormation. If the return value of the function 39 | is a dict, that is merged into resource_outputs. If it is not a dict, the value 40 | is stored under the 'result' key. 41 | 42 | Child classes may implement validate() and/or populate(). validate() should return 43 | True if self.resource_properties is valid. populate() can transfer the contents of 44 | self.resource_properties into object fields, if this is not done by validate(). 45 | 46 | The class provides methods get_boto3_client() and get_boto3_resource() that cache 47 | the clients/resources in the class, reducing overhead in the Lambda invocations. 48 | These also rely on the get_boto3_session() method, which in turn uses 49 | BOTO3_SESSION_FACTORY if it is set, allowing overriding with mock sessions for 50 | testing. Similarly, BOTO3_CLIENT_FACTORY and BOTO3_RESOURCE_FACTORY, both of which 51 | can be set to callables that take a session and a name, can be set to override 52 | client and resource creation. 53 | 54 | Some hooks are provided to override behavior. The first four are instance fields, 55 | since they may be set to functions that rely on instance fields. The last 56 | is a class field, since it is called by a class method. 57 | * finish_function, normally set to CloudFormationCustomResource.cfn_response, takes 58 | as input the custom resource object and deals with sending the response and 59 | cleaning up. 60 | * send_function, used within CloudFormationCustomResource.cfn_response, takes as 61 | input the custom resource object, a url, and the response_content dictionary. 62 | Normally this is set to CloudFormationCustomResource.send_response, which uses 63 | requests to send the content to its destination. requests is loaded either 64 | directly if available, falling back to the vendored version in botocore. 65 | * generate_unique_id_prefix_function can be set to put a prefix on the id returned 66 | by generate_unique_id, for example if the physical resource 67 | id needs to be an ARN. 68 | * generate_physical_resource_id_function is used to get a physical resource id 69 | on a create call unless DISABLE_PHYSICAL_RESOURCE_ID_GENERATION is True. 70 | It takes the custom resource object as input.This is normally 71 | set to CloudFormationCustomResource.generate_unique_id, which 72 | generates a physical resource id like CloudFormation: 73 | {stack_id}-{logical resource id}-{random string} 74 | It also provides two keyword arguments: 75 | * prefix: if for example the physical resource id must be an arn 76 | * separator: defaulting to '-'. 77 | * BOTO3_SESSION_FACTORY takes no input and returns an object that acts like a boto3 session. 78 | If this class field is not None, it is used by get_boto3_session() instead of creating 79 | a regular boto3 session. This could be made to use placebo for testing 80 | https://github.com/garnaat/placebo 81 | 82 | The class provides four configuration options that can be overridden in child 83 | classes: 84 | * DELETE_LOGS_ON_STACK_DELETION: A boolean which, when True, will cause a successful 85 | stack deletion to trigger the deletion of the CloudWatch log group on stack 86 | deletion. If there is a problem during stack deletion, the logs are left in place. 87 | NOTE: this is not intended for use when the Lambda function is used by multiple 88 | stacks. 89 | * HIDE_STACK_DELETE_FAILURE: A boolean which, when True, will report 90 | SUCCESS to CloudFormation when a stack deletion is requested 91 | regardless of the success of the AWS Lambda function. This will 92 | prevent stacks from being stuck in DELETE_FAILED states but will 93 | potentially result in resources created by the AWS Lambda function 94 | to remain in existence after stack deletion. If 95 | HIDE_STACK_DELETE_FAILURE is False, an exception in the AWS Lambda 96 | function will result in DELETE_FAILED upon an attempt to delete 97 | the stack. 98 | * DISABLE_PHYSICAL_RESOURCE_ID_GENERATION: If True, skips the automatic generation 99 | of a unique physical resource id if the custom resource has a source for that 100 | itself. 101 | * PHYSICAL_RESOURCE_ID_MAX_LEN: An int used by generate_unique_id 102 | when generating a physical resource id. 103 | """ 104 | DELETE_LOGS_ON_STACK_DELETION = False 105 | HIDE_STACK_DELETE_FAILURE = True 106 | 107 | DISABLE_PHYSICAL_RESOURCE_ID_GENERATION = False 108 | PHYSICAL_RESOURCE_ID_MAX_LEN = 128 109 | 110 | STATUS_SUCCESS = 'SUCCESS' 111 | STATUS_FAILED = 'FAILED' 112 | 113 | REQUEST_CREATE = 'Create' 114 | REQUEST_DELETE = 'Delete' 115 | REQUEST_UPDATE = 'Update' 116 | 117 | BASE_LOGGER_LEVEL = None 118 | 119 | def __init__(self, resource_type=None, logger=None): 120 | import logging 121 | if logger: 122 | self.logger = logger 123 | else: 124 | self.logger = logging.getLogger(self.__class__.__name__) 125 | 126 | self._base_logger = logging.getLogger('CFCustomResource') 127 | if self.BASE_LOGGER_LEVEL: 128 | self._base_logger.setLevel(self.BASE_LOGGER_LEVEL) 129 | 130 | if not resource_type: 131 | resource_type = self.__class__.__name__ 132 | 133 | def process_resource_type(resource_type): 134 | if not (resource_type.startswith('Custom::') or resource_type == 'AWS::CloudFormation::CustomResource'): 135 | resource_type = 'Custom::' + resource_type 136 | return resource_type 137 | 138 | if isinstance(resource_type, (list, tuple)): 139 | resource_type = [process_resource_type(rt) for rt in resource_type] 140 | elif isinstance(resource_type, basestring): 141 | resource_type = process_resource_type(resource_type) 142 | 143 | self.resource_type = resource_type 144 | 145 | self.event = None 146 | self.context = None 147 | 148 | self.request_resource_type = None 149 | self.request_type = None 150 | self.response_url = None 151 | self.stack_id = None 152 | self.request_id = None 153 | 154 | self.logical_resource_id = None 155 | self.physical_resource_id = None 156 | self.resource_properties = None 157 | self.old_resource_properties = None 158 | 159 | self.status = None 160 | self.failure_reason = None 161 | self.resource_outputs = {} 162 | 163 | self.finish_function = self.cfn_response 164 | self.send_response_function = self.send_response 165 | 166 | self.generate_unique_id_prefix_function = None 167 | self.generate_physical_resource_id_function = self.generate_unique_id 168 | 169 | def validate_resource_type(self, resource_type): 170 | """Return True if resource_type is valid""" 171 | if isinstance(self.resource_type, (list, tuple)): 172 | return resource_type in self.resource_type 173 | return resource_type == self.resource_type 174 | 175 | def validate(self): 176 | """Return True if self.resource_properties is valid.""" 177 | return True 178 | 179 | def populate(self): 180 | """Populate fields from self.resource_properties and self.old_resource_properties, 181 | if this is not done in validate()""" 182 | pass 183 | 184 | def create(self): 185 | raise NotImplementedError 186 | 187 | def update(self): 188 | raise NotImplementedError 189 | 190 | def delete(self): 191 | raise NotImplementedError 192 | 193 | BOTO3_SESSION_FACTORY = None 194 | BOTO3_CLIENT_FACTORY = None 195 | BOTO3_RESOURCE_FACTORY = None 196 | 197 | BOTO3_SESSION = None 198 | BOTO3_CLIENTS = {} 199 | BOTO3_RESOURCES = {} 200 | 201 | @classmethod 202 | def get_boto3_session(cls): 203 | if cls.BOTO3_SESSION is None: 204 | if cls.BOTO3_SESSION_FACTORY: 205 | cls.BOTO3_SESSION = cls.BOTO3_SESSION_FACTORY() 206 | else: 207 | import boto3 208 | cls.BOTO3_SESSION = boto3.session.Session() 209 | return cls.BOTO3_SESSION 210 | 211 | @classmethod 212 | def get_boto3_client(cls, name): 213 | if name not in cls.BOTO3_CLIENTS: 214 | if cls.BOTO3_CLIENT_FACTORY: 215 | client = cls.BOTO3_CLIENT_FACTORY(cls.get_boto3_session(), name) 216 | else: 217 | client = cls.get_boto3_session().client(name) 218 | cls.BOTO3_CLIENTS[name] = client 219 | return cls.BOTO3_CLIENTS[name] 220 | 221 | @classmethod 222 | def get_boto3_resource(cls, name): 223 | if name not in cls.BOTO3_RESOURCES: 224 | if cls.BOTO3_RESOURCE_FACTORY: 225 | resource = cls.BOTO3_RESOURCE_FACTORY(cls.get_boto3_session(), name) 226 | else: 227 | resource = cls.get_boto3_session().resource(name) 228 | cls.BOTO3_RESOURCES[name] = resource 229 | return cls.BOTO3_RESOURCES[name] 230 | 231 | @classmethod 232 | def get_handler(cls, *args, **kwargs): 233 | """Returns a handler suitable for Lambda to call. The handler creates an 234 | instance of the class in every call, passing any arguments given to 235 | get_handler. 236 | 237 | Use like: 238 | handler = MyCustomResource.get_handler()""" 239 | def handler(event, context): 240 | return cls(*args, **kwargs).handle(event, context) 241 | return handler 242 | 243 | def handle(self, event, context): 244 | """Use the get_handler class method to get a handler that calls this method.""" 245 | import json 246 | self._base_logger.info('REQUEST RECEIVED: %s' % json.dumps(event)) 247 | def plainify(obj): 248 | d = {} 249 | for field, value in vars(obj).iteritems(): 250 | if isinstance(value, 251 | (str, unicode, 252 | int, float, bool, type(None))): 253 | d[field] = value 254 | elif isinstance(value, (list, tuple)): 255 | d[field] = [plainify(v) for v in value] 256 | elif isinstance(value, dict): 257 | d[field] = dict((k, plainify(v)) for k, v in value.iteritems()) 258 | else: 259 | d[field] = repr(value) 260 | self._base_logger.info('LambdaContext: %s' % json.dumps(plainify(context))) 261 | 262 | self.event = event 263 | self.context = context 264 | 265 | self.request_resource_type = event['ResourceType'] 266 | self.request_type = event['RequestType'] 267 | self.response_url = event['ResponseURL'] 268 | self.stack_id = event['StackId'] 269 | self.request_id = event['RequestId'] 270 | 271 | self.logical_resource_id = event['LogicalResourceId'] 272 | self.physical_resource_id = event.get('PhysicalResourceId') 273 | self.resource_properties = event.get('ResourceProperties', {}) 274 | self.old_resource_properties = event.get('OldResourceProperties') 275 | 276 | self.status = None 277 | self.failure_reason = None 278 | self.resource_outputs = {} 279 | 280 | try: 281 | if not self.validate_resource_type(self.request_resource_type): 282 | raise Exception('invalid resource type') 283 | 284 | if not self.validate(): 285 | pass 286 | 287 | if not self.physical_resource_id and not self.DISABLE_PHYSICAL_RESOURCE_ID_GENERATION: 288 | self.physical_resource_id = self.generate_physical_resource_id_function(max_len=self.PHYSICAL_RESOURCE_ID_MAX_LEN) 289 | 290 | self.populate() 291 | 292 | outputs = getattr(self, self.request_type.lower())() 293 | 294 | if outputs: 295 | if not isinstance(outputs, dict): 296 | outputs = {'result': outputs} 297 | self.resource_outputs.update(outputs) 298 | 299 | if not self.status: 300 | self.status = self.STATUS_SUCCESS 301 | except Exception as e: 302 | import traceback 303 | if not self.status: 304 | self.status = self.STATUS_FAILED 305 | self.failure_reason = 'Custom resource %s failed due to exception "%s".' % (self.__class__.__name__, e.message) 306 | if self.failure_reason: 307 | self._base_logger.error(str(self.failure_reason)) 308 | self._base_logger.debug(traceback.format_exc()) 309 | 310 | if self.request_type == self.REQUEST_DELETE: 311 | if self.status == self.STATUS_FAILED and self.HIDE_STACK_DELETE_FAILURE: 312 | message = ( 313 | 'There may be resources created by the AWS ' 314 | 'Lambda that have not been deleted and cleaned up ' 315 | 'despite the fact that the stack status may be ' 316 | 'DELETE_COMPLETE.') 317 | self._base_logger.error(message) 318 | if self.failure_reason: 319 | self._base_logger.error('Reason for failure: ' + str(self.failure_reason)) 320 | self.status = self.STATUS_SUCCESS 321 | 322 | if self.status == self.STATUS_SUCCESS and self.DELETE_LOGS_ON_STACK_DELETION: 323 | import logging 324 | logging.disable(logging.CRITICAL) 325 | logs_client = self.get_boto3_client('logs') 326 | logs_client.delete_log_group( 327 | logGroupName=context.log_group_name) 328 | 329 | self.finish_function(self) 330 | 331 | def generate_unique_id(self, prefix=None, separator='-', max_len=None): 332 | """Generate a unique id similar to how CloudFormation generates 333 | physical resource ids""" 334 | import random 335 | import string 336 | 337 | if prefix is None: 338 | if self.generate_unique_id_prefix_function: 339 | prefix = self.generate_unique_id_prefix_function() 340 | else: 341 | prefix = '' 342 | 343 | stack_id = self.stack_id.split(':')[-1] 344 | if '/' in stack_id: 345 | stack_id = stack_id.split('/')[1] 346 | stack_id = stack_id.replace('-', '') 347 | 348 | logical_resource_id = self.logical_resource_id 349 | 350 | len_of_rand = 12 351 | 352 | rand = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(len_of_rand)) 353 | 354 | if max_len: 355 | max_len = max_len - len(prefix) 356 | len_of_parts = max_len - len_of_rand - 2 * len(separator) 357 | len_of_parts_diff = (len(stack_id) + len(logical_resource_id)) - len_of_parts 358 | if len_of_parts_diff > 0: 359 | len_of_stack_id = min(len(stack_id), len(stack_id) - len_of_parts_diff // 2) 360 | len_of_resource = len_of_parts - len_of_stack_id 361 | stack_id = stack_id[:len_of_stack_id] 362 | logical_resource_id = logical_resource_id[:len_of_resource] 363 | return '{prefix}{stack_id}{separator}{logical_id}{separator}{rand}'.format( 364 | prefix=prefix, 365 | separator=separator, 366 | stack_id=stack_id, 367 | logical_id=logical_resource_id, 368 | rand=rand, 369 | ) 370 | 371 | @classmethod 372 | def send_response(cls, resource, url, response_content): 373 | import httplib, json 374 | try: 375 | import requests 376 | except: 377 | from botocore.vendored import requests 378 | 379 | put_response = requests.put(resource.response_url, 380 | data=json.dumps(response_content)) 381 | body_text = "" 382 | if put_response.status_code // 100 != 2: 383 | body_text = "\n" + put_response.text 384 | resource._base_logger.debug("Status code: %s %s%s" % (put_response.status_code, httplib.responses[put_response.status_code], body_text)) 385 | 386 | return put_response 387 | 388 | @classmethod 389 | def cfn_response(cls, resource): 390 | import json, traceback 391 | 392 | physical_resource_id = resource.physical_resource_id 393 | if physical_resource_id is None: 394 | physical_resource_id = resource.context.log_stream_name 395 | default_reason = ("See the details in CloudWatch Log Stream: %s" % 396 | resource.context.log_stream_name) 397 | outputs = {} 398 | for key, value in resource.resource_outputs.iteritems(): 399 | if not isinstance(value, basestring): 400 | value = json.dumps(value) 401 | outputs[key] = value 402 | response_content = { 403 | "Status": resource.status, 404 | "Reason": resource.failure_reason or default_reason, 405 | "PhysicalResourceId": physical_resource_id, 406 | "StackId": resource.event['StackId'], 407 | "RequestId": resource.event['RequestId'], 408 | "LogicalResourceId": resource.event['LogicalResourceId'], 409 | "Data": outputs 410 | } 411 | resource._base_logger.debug("Response body: %s", json.dumps(response_content)) 412 | try: 413 | return resource.send_response_function(resource, resource.response_url, response_content) 414 | except Exception as e: 415 | resource._base_logger.error("send response failed: %s" % e.message) 416 | resource._base_logger.debug(traceback.format_exc()) 417 | 418 | if __name__ == '__main__': 419 | import argparse, zipfile, sys, os.path, StringIO 420 | try: 421 | import boto3, botocore 422 | parser = argparse.ArgumentParser(description='Deploy a CloudFormation custom resource handler') 423 | parser.add_argument('file', help='the python file to use') 424 | parser.add_argument('role', nargs='?', 425 | help='the role to use (only required when function has not been previously deployed)') 426 | parser.add_argument('--name', '-n', 427 | help='set the Lambda function name, defaults to the module name') 428 | parser.add_argument('--zip-output', '-o', help='path to save the zip file') 429 | parser.add_argument('--handler', default='handler', help='set the handler function name, defaults to \'handler\'') 430 | parser.add_argument('--publish', action='store_true', help='publish a version for this code') 431 | parser.add_argument('--verbose', '-v', action='store_true', help='print debugging information') 432 | 433 | args = parser.parse_args() 434 | 435 | timeout = 300 436 | memory = 128 437 | 438 | module = os.path.basename(args.file).split('.')[0] 439 | name = args.name or module 440 | role = args.role 441 | if '.' in args.handler: 442 | handler = args.handler 443 | else: 444 | handler = module + '.' + args.handler 445 | 446 | if args.verbose: print 'reading files' 447 | with open(args.file, 'r') as fp: 448 | f = fp.read() 449 | with open(__file__, 'r') as fp: 450 | me = fp.read() 451 | 452 | if args.verbose: print 'creating zip file' 453 | if args.zip_output: 454 | z = zipfile.ZipFile(args.zip_output, 'w', zipfile.ZIP_DEFLATED) 455 | else: 456 | sio = StringIO.StringIO() 457 | z = zipfile.ZipFile(sio, 'w', zipfile.ZIP_DEFLATED) 458 | 459 | info = zipfile.ZipInfo(os.path.basename(args.file)) 460 | info.external_attr = 0444 << 16L 461 | z.writestr(info, f) 462 | 463 | info = zipfile.ZipInfo(os.path.basename(__file__)) 464 | info.external_attr = 0444 << 16L 465 | z.writestr(info, me) 466 | 467 | z.close() 468 | 469 | if args.verbose: print 'loading zip file' 470 | if args.zip_output: 471 | with open(args.zip_output, 'rb') as fp: 472 | zip_data = fp.read() 473 | else: 474 | zip_data = sio.getvalue() 475 | 476 | client = boto3.client('lambda') 477 | 478 | exists = False 479 | try: 480 | if args.verbose: print 'testing if function exists' 481 | response = client.get_function_configuration(FunctionName=name) 482 | current_role = response['Role'] 483 | current_handler = response['Handler'] 484 | timeout = response['Timeout'] 485 | memory = response['MemorySize'] 486 | if args.verbose: print 'function exists' 487 | exists = True 488 | except botocore.exceptions.ClientError as e: 489 | if not e.response['ResponseMetadata']['HTTPStatusCode'] == 404: 490 | raise 491 | if args.verbose: print 'function does not exist' 492 | 493 | if role and not role.startswith('arn:'): 494 | response = boto3.client('iam').get_role(RoleName=role) 495 | role = response['Role']['Arn'] 496 | 497 | if not exists: 498 | if args.verbose: print 'creating function' 499 | if not role: 500 | sys.stderr.write('[ERROR] role not given\n') 501 | sys.exit(1) 502 | response = client.create_function( 503 | FunctionName=name, 504 | Runtime='python2.7', 505 | Role=role, 506 | Handler=handler, 507 | Code={'ZipFile': zip_data}, 508 | Timeout=timeout, 509 | MemorySize=memory, 510 | Publish=args.publish) 511 | arn = response['FunctionArn'] 512 | else: 513 | role_changed = (role and role != current_role) 514 | if role_changed and args.verbose: print 'role changed' 515 | handler_changed = (current_handler != handler) 516 | if handler_changed and args.verbose: print 'handler changed' 517 | if role_changed or handler_changed: 518 | if args.verbose: print 'updating function configuration' 519 | client.update_function_configuration( 520 | FunctionName=name, 521 | Role=role or current_role, 522 | Handler=handler, 523 | Timeout=timeout, 524 | MemorySize=memory) 525 | 526 | if args.verbose: print 'updating function code' 527 | response = client.update_function_code( 528 | FunctionName=name, 529 | ZipFile=zip_data, 530 | Publish=args.publish) 531 | arn = response['FunctionArn'] 532 | 533 | print arn 534 | except Exception as e: 535 | sys.stderr.write('[ERROR] an exception occurred: {} {}\n'.format(type(e).__name__, e)) 536 | sys.exit(1) 537 | -------------------------------------------------------------------------------- /cfnlambda_util.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module provides a utility function, generate_request, to create events 3 | for using in testing. 4 | """ 5 | 6 | EXAMPLE_REQUEST = { 7 | "RequestType" : "Create", 8 | "ResponseURL" : "http://pre-signed-S3-url-for-response", 9 | "StackId" : "arn:aws:cloudformation:us-west-2:EXAMPLE/stack-name/guid", 10 | "RequestId" : "7bfe2d54710d48dcbc6f0b26fb68c9d1", 11 | "ResourceType" : "Custom::ResourceTypeName", 12 | "LogicalResourceId" : "MyLogicalResourceId", 13 | "ResourceProperties" : { 14 | "Key" : "Value", 15 | "List" : [ "1", "2", "3" ] 16 | } 17 | } 18 | 19 | def generate_request(request_type, resource_type, properties, response_url, 20 | stack_id=None, 21 | request_id=None, 22 | logical_resource_id=None, 23 | physical_resource_id=None, 24 | old_properties=None): 25 | """Generate a request for testing. 26 | 27 | Args: 28 | request_type: One of 'Create', 'Update', or 'Delete'. 29 | resource_type: The CloudFormation resource type. If it does not begin 30 | with 'Custom::', this is prepended. 31 | properties: A dictionary of the fields for the resource. 32 | response_url: A url or a tuple of (bucket, key) for S3. If key ends with 33 | 'RANDOM', a random string replaces that. 34 | """ 35 | 36 | import uuid 37 | import boto3 38 | 39 | request_type = request_type.lower() 40 | if request_type not in ['create', 'update', 'delete']: 41 | raise ValueError('unknown request type') 42 | request_type = request_type[0].upper() + request_type[1:] 43 | 44 | if not resource_type.startswith('Custom::'): 45 | resource_type = 'Custom::' + resource_type 46 | 47 | if not isinstance(properties, dict): 48 | raise TypeError('properties must be a dict') 49 | 50 | if isinstance(response_url, (list, tuple)): 51 | bucket, key = response_url 52 | if key.endswith('RANDOM'): 53 | key = key[:-6] + str(uuid.uuid4()) 54 | response_url = boto3.client('s3').generate_presigned_url( 55 | ClientMethod='put_object', 56 | HttpMethod='PUT', 57 | Params={ 58 | 'Bucket': bucket, 59 | 'Key': key}) 60 | 61 | stack_id = stack_id or "arn:aws:cloudformation:us-west-2:EXAMPLE/stack-name/guid" 62 | 63 | request_id = request_id or str(uuid.uuid4()) 64 | 65 | logical_resource_id = logical_resource_id or "MyLogicalResourceId" 66 | 67 | physical_resource_id = physical_resource_id or logical_resource_id 68 | 69 | event = { 70 | "RequestType" : request_type, 71 | "ResponseURL" : response_url, 72 | "StackId" : stack_id, 73 | "RequestId" : request_id, 74 | "ResourceType" : resource_type, 75 | "LogicalResourceId" : logical_resource_id, 76 | "ResourceProperties" : properties 77 | } 78 | 79 | if request_type in ['Update', 'Delete']: 80 | if not physical_resource_id: 81 | raise RuntimeError('physical resource id not set for {}'.format(request_type)) 82 | event['PhysicalResourceId'] = physical_resource_id 83 | 84 | if request_type == 'Update': 85 | if not old_properties: 86 | raise RuntimeError('old properties not set for {}'.format(request_type)) 87 | event['OldResourceProperties'] = old_properties 88 | 89 | return event 90 | 91 | class MockLambdaContext(object): 92 | def __init__(self, 93 | function_name='FunctionName', 94 | function_version='$LATEST', 95 | memory_size=128, 96 | timeout=3, 97 | start=None, 98 | region='us-east-1', 99 | account_id='123456789012'): 100 | import time, uuid 101 | 102 | if start is None: 103 | start = time.time() 104 | 105 | self._timeout = timeout 106 | self._start = start 107 | self._get_time = time.time 108 | 109 | self.function_name = function_name 110 | self.function_version = function_version 111 | self.invoked_function_arn = 'arn:aws:lambda:{region}:{account_id}:function:{name}:{version}'.format( 112 | name=self.function_name, 113 | version=self.function_version, 114 | region=region, 115 | account_id=account_id) 116 | self.memory_limit_in_mb = memory_size 117 | self.aws_request_id = str(uuid.uuid4()) 118 | self.log_group_name = '/aws/lambda/{}'.format(self.function_name) 119 | self.log_stream_name = '{}/[{}]{}'.format( 120 | time.strftime('%Y/%m/%d', time.gmtime(self._start)), 121 | self.function_version, 122 | self.aws_request_id) 123 | self.identity = None 124 | self.client_context = None 125 | 126 | def get_remaining_time_in_millis(self): 127 | time_used = self._get_time() - self._start 128 | time_left = self._timeout * 1000 - time_used 129 | return int(round(time_left * 1000)) 130 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """cfnlambda setup module 2 | 3 | This Source Code Form is subject to the terms of the Mozilla Public 4 | License, v. 2.0. If a copy of the MPL was not distributed with this 5 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | """ 7 | 8 | from setuptools import setup 9 | from codecs import open 10 | from os import path 11 | 12 | here = path.abspath(path.dirname(__file__)) 13 | 14 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 15 | long_description = f.read() 16 | 17 | setup( 18 | name='cfnlambda', 19 | 20 | # Versions should comply with PEP440. For a discussion on single-sourcing 21 | # the version across setup.py and the project code, see 22 | # https://packaging.python.org/en/latest/single_source_version.html 23 | version='1.0.0', 24 | 25 | description='Collection of tools to enable use of AWS Lambda with ' 26 | 'CloudFormation.', 27 | long_description=long_description, 28 | url='https://github.com/iRobotCorporation/cfnlambda', 29 | author='Ben Kehoe', 30 | author_email='bkehoe@irobot.com', 31 | license='MPL-2.0', 32 | 33 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 34 | classifiers=[ 35 | 'Development Status :: 4 - Beta', 36 | 'Intended Audience :: Developers', 37 | 'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)', 38 | 'Programming Language :: Python :: 2.7', 39 | ], 40 | keywords='aws lambda cloudformation', 41 | py_modules=['cfnlambda'], 42 | install_requires=['boto3', 43 | 'botocore'], 44 | ) 45 | --------------------------------------------------------------------------------