├── .coveragerc ├── pytest.ini ├── test-requirements.txt ├── MANIFEST.in ├── tox.ini ├── example.py ├── vcr_recordings ├── client_code_failure.yaml └── sends_put_request.yaml ├── LICENSE.txt ├── .gitignore ├── setup.py ├── README.rst ├── cfn_resource.py └── test_cfn_resource.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = .tox/* 3 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | looponfailroots = . 3 | #addopts = -f 4 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | mock 2 | vcrpy 3 | pytest 4 | coverage>=4.2 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Include the license file 2 | include LICENSE.txt 3 | include README.rst 4 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py36,coverage-report 3 | 4 | [testenv] 5 | deps = -rtest-requirements.txt 6 | commands = coverage run --parallel -m pytest {posargs} 7 | 8 | [testenv:coverage-report] 9 | deps = coverage 10 | skip_install = true 11 | commands = 12 | coverage combine 13 | coverage html 14 | coverage report 15 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | # MIT Licensed, Copyright (c) 2015 Ryan Scott Brown 2 | 3 | import cfn_resource 4 | 5 | # set `handler` as the entry point for Lambda 6 | handler = cfn_resource.Resource() 7 | 8 | @handler.create 9 | def create_thing(event, context): 10 | # do some stuff 11 | return {"PhysicalResourceId": "arn:aws:fake:myID"} 12 | 13 | @handler.update 14 | def update_thing(event, context): 15 | # do some stuff 16 | return {"PhysicalResourceId": "arn:aws:fake:myID"} 17 | -------------------------------------------------------------------------------- /vcr_recordings/client_code_failure.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | interactions: 3 | - request: 4 | body: > 5 | {"StackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SomeStackHere3/d50d1280-a454-11e5-bd51-50e2416294a8", 6 | "Status": "FAILED", "PhysicalResourceId": "SomeStackHere3-FakeThing-893YUKO12RFM", 7 | "RequestId": "79abbda7-092e-4534-9602-3ab4cc377807", "Reason": "Exception was 8 | raised while handling custom resource", "LogicalResourceId": "FakeThing"} 9 | headers: 10 | Content-Type: [''] 11 | method: PUT 12 | uri: https://cloudformation-custom-resource-response-useast1.s3.amazonaws.com/arn%3Aaws%3Acloudformation%3Aus-east-1%3A368950843917%3Astack/SomeStackHere3/d50d1280-a454-11e5-bd51-50e2416294a8%7CFakeThing%7C79abbda7-092e-4534-9602-3ab4cc377807?AWSAccessKeyId=AKIAJNXHFR7P7YGKLDPQ&Expires=1450321030&Signature=HOCkeEsxMHHQMgnj3kx5gqLyfTU%3D 13 | response: 14 | body: {string: ''} 15 | headers: 16 | content-type: [application/xml] 17 | status: {code: 200, message: OK} 18 | -------------------------------------------------------------------------------- /vcr_recordings/sends_put_request.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | interactions: 3 | - request: 4 | body: > 5 | {"StackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SomeStackHere3/d50d1280-a454-11e5-bd51-50e2416294a8", 6 | "Status": "SUCCESS", "Reason": "Life is good, man", "PhysicalResourceId": "SomeStackHere3-FakeThing-893YUKO12RFM", 7 | "RequestId": "79abbda7-092e-4534-9602-3ab4cc377807", "Data": {}, "LogicalResourceId": 8 | "FakeThing"} 9 | headers: 10 | Content-Length: [332] 11 | Content-Type: [''] 12 | method: PUT 13 | uri: https://cloudformation-custom-resource-response-useast1.s3.amazonaws.com/arn%3Aaws%3Acloudformation%3Aus-east-1%3A368950843917%3Astack/SomeStackHere3/d50d1280-a454-11e5-bd51-50e2416294a8%7CFakeThing%7C79abbda7-092e-4534-9602-3ab4cc377807?AWSAccessKeyId=AKIAJNXHFR7P7YGKLDPQ&Expires=1450321030&Signature=HOCkeEsxMHHQMgnj3kx5gqLyfTU%3D 14 | response: 15 | body: {string: ''} 16 | headers: 17 | content-type: [application/xml] 18 | status: {code: 200, message: OK} 19 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Ryan Scott Brown 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python,vim,linux 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | env/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *,cover 50 | .hypothesis/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | 59 | # Sphinx documentation 60 | docs/_build/ 61 | 62 | # PyBuilder 63 | target/ 64 | 65 | #Ipython Notebook 66 | .ipynb_checkpoints 67 | 68 | 69 | ### Vim ### 70 | [._]*.s[a-w][a-z] 71 | [._]s[a-w][a-z] 72 | *.un~ 73 | Session.vim 74 | .netrwhist 75 | *~ 76 | 77 | 78 | ### Linux ### 79 | *~ 80 | 81 | # temporary files which can be created if a process still has a handle open of a deleted file 82 | .fuse_hidden* 83 | 84 | # KDE directory preferences 85 | .directory 86 | 87 | # Linux trash folder which might appear on any partition or disk 88 | .Trash-* 89 | 90 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """A setuptools based setup module. 2 | 3 | See: 4 | https://packaging.python.org/en/latest/distributing.html 5 | https://github.com/pypa/sampleproject 6 | """ 7 | 8 | # Always prefer setuptools over distutils 9 | from setuptools import setup 10 | # To use a consistent encoding 11 | from codecs import open 12 | from os import path 13 | 14 | here = path.abspath(path.dirname(__file__)) 15 | 16 | test_deps = ['mock', 'pytest', 'pytest-cov', 'pytest-xdist'] 17 | 18 | # Get the long description from the README file 19 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 20 | long_description = f.read() 21 | 22 | setup( 23 | name='cfn_resource', 24 | 25 | version='0.2.3', 26 | 27 | description='Wrapper decorators for building CloudFormation custom resources', 28 | long_description=long_description, 29 | url='https://github.com/ryansb/cfn-wrapper-python', 30 | author='Ryan Scott Brown', 31 | author_email='sb@ryansb.com', 32 | license='MIT', 33 | 34 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 35 | classifiers=[ 36 | # How mature is this project? Common values are 37 | # 3 - Alpha 38 | # 4 - Beta 39 | # 5 - Production/Stable 40 | 'Development Status :: 4 - Beta', 41 | 42 | # Indicate who your project is intended for 43 | 'Intended Audience :: Developers', 44 | 45 | 'License :: OSI Approved :: MIT License', 46 | 'Programming Language :: Python :: 2', 47 | 'Programming Language :: Python :: 2.7', 48 | 'Programming Language :: Python :: 3', 49 | 'Programming Language :: Python :: 3.6', 50 | ], 51 | 52 | # What does your project relate to? 53 | keywords='cloudformation aws cloud custom resource amazon', 54 | 55 | py_modules=["cfn_resource"], 56 | 57 | # List run-time dependencies here. These will be installed by pip when 58 | # your project is installed. For an analysis of "install_requires" vs pip's 59 | # requirements files see: 60 | # https://packaging.python.org/en/latest/requirements.html 61 | install_requires=[], 62 | 63 | # List additional groups of dependencies here (e.g. development 64 | # dependencies). You can install these using the following syntax, 65 | # for example: 66 | # $ pip install -e .[dev,test] 67 | extras_require={ 68 | 'test': test_deps, 69 | }, 70 | package_data={}, 71 | data_files=[], 72 | entry_points={}, 73 | ) 74 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | cfn\_resource.py 2 | ---------------- 3 | 4 | This project is a decorator and validation system that takes the 5 | drudgery out of writing custom resources. You still have access to the 6 | context and event as normal, but the decorator handles serializing your 7 | response and communicating results to CloudFormation. 8 | 9 | See `cfn-lambda `__ from 10 | Andrew Templeton if you're looking to write your custom resources in 11 | Node.js. 12 | 13 | Usage 14 | ----- 15 | 16 | 1. Copy ``cfn_resource.py`` into the directory of your lambda function 17 | handler.py 18 | 2. Use the ``cfn_resource.Resource`` event decorators to decorate your 19 | handler like in ``example.py`` 20 | 3. Zip up the contents and upload to Lambda 21 | 22 | Once the function is up, copy its ARN and use it as the ServiceToken for 23 | your `custom 24 | resource `__. 25 | For more on the requests you may receive, see `this 26 | document `__ 27 | 28 | .. code-block:: json 29 | 30 | { 31 | "AWSTemplateFormatVersion": "2010-09-09", 32 | "Resources": { 33 | "FakeThing": { 34 | "Type": "Custom::MyResource", 35 | "Properties": { 36 | "ServiceToken": "arn:aws:lambda:SOME-REGION:ACCOUNT:function:FunctionName", 37 | "OtherThing": "foobar", 38 | "AnotherThing": 2 39 | } 40 | } 41 | } 42 | } 43 | 44 | For more on how custom resources work, see the `AWS 45 | docs `__ 46 | 47 | Code Sample 48 | ----------- 49 | 50 | For this example, you need to have your handler in Lambda set as 51 | ``filename.handler`` where filename has the below contents. 52 | 53 | .. code-block:: python 54 | 55 | import cfn_resource 56 | 57 | # set `handler` as the entry point for Lambda 58 | handler = cfn_resource.Resource() 59 | 60 | @handler.create 61 | def create_thing(event, context): 62 | # do some stuff 63 | return {"PhysicalResourceId": "arn:aws:fake:myID"} 64 | 65 | @handler.update 66 | def update_thing(event, context): 67 | # do some stuff 68 | return {"PhysicalResourceId": "arn:aws:fake:myID"} 69 | 70 | Running Tests 71 | ------------- 72 | 73 | To run the tests locally, you need Python 2.7 and ``pip``. Ideally, you 74 | should use a virtualenv. 75 | 76 | .. code-block:: sh 77 | 78 | $ pip install -r test-requirements.txt 79 | $ py.test 80 | 81 | The tests use ``mock`` and ``py.test`` and will give you a terminal 82 | coverage report. Currently the tests cover ~90% of the (very small) 83 | codebase. 84 | 85 | License 86 | ------- 87 | 88 | This code is released under the MIT software license, see LICENSE.txt 89 | for details. No warranty of any kind is included, and the copyright 90 | notice must be included in redistributions. 91 | -------------------------------------------------------------------------------- /cfn_resource.py: -------------------------------------------------------------------------------- 1 | # MIT Licensed, Copyright (c) 2015 Ryan Scott Brown 2 | 3 | import json 4 | import logging 5 | import sys 6 | if sys.version_info.major == 3: 7 | from urllib.request import urlopen, Request, HTTPError, URLError 8 | from urllib.parse import urlencode 9 | else: 10 | from urllib2 import urlopen, Request, HTTPError, URLError 11 | 12 | logger = logging.getLogger() 13 | logger.setLevel(logging.INFO) 14 | 15 | SUCCESS = 'SUCCESS' 16 | FAILED = 'FAILED' 17 | 18 | """ 19 | Event example 20 | { 21 | "Status": SUCCESS | FAILED, 22 | "Reason: mandatory on failure 23 | "PhysicalResourceId": string, 24 | "StackId": event["StackId"], 25 | "RequestId": event["RequestId"], 26 | "LogicalResourceId": event["LogicalResourceId"], 27 | "Data": {} 28 | } 29 | """ 30 | 31 | def wrap_user_handler(func, base_response=None): 32 | def wrapper_func(event, context): 33 | response = { 34 | "StackId": event["StackId"], 35 | "RequestId": event["RequestId"], 36 | "LogicalResourceId": event["LogicalResourceId"], 37 | "Status": SUCCESS, 38 | } 39 | if event.get("PhysicalResourceId", False): 40 | response["PhysicalResourceId"] = event["PhysicalResourceId"] 41 | 42 | if base_response is not None: 43 | response.update(base_response) 44 | 45 | logger.debug("Received %s request with event: %s" % (event['RequestType'], json.dumps(event))) 46 | 47 | try: 48 | response.update(func(event, context)) 49 | except: 50 | logger.exception("Failed to execute resource function") 51 | response.update({ 52 | "Status": FAILED, 53 | "Reason": "Exception was raised while handling custom resource" 54 | }) 55 | 56 | serialized = json.dumps(response) 57 | logger.info("Responding to '%s' request with: %s" % ( 58 | event['RequestType'], serialized)) 59 | 60 | if sys.version_info.major == 3: 61 | req_data = serialized.encode('utf-8') 62 | else: 63 | req_data = serialized 64 | req = Request( 65 | event['ResponseURL'], data=req_data, 66 | headers={'Content-Length': len(req_data), 67 | 'Content-Type': ''} 68 | ) 69 | req.get_method = lambda: 'PUT' 70 | 71 | try: 72 | urlopen(req) 73 | logger.debug("Request to CFN API succeeded, nothing to do here") 74 | except HTTPError as e: 75 | logger.error("Callback to CFN API failed with status %d" % e.code) 76 | logger.error("Response: %s" % e.reason) 77 | except URLError as e: 78 | logger.error("Failed to reach the server - %s" % e.reason) 79 | 80 | return wrapper_func 81 | 82 | class Resource(object): 83 | _dispatch = None 84 | 85 | def __init__(self, wrapper=wrap_user_handler): 86 | self._dispatch = {} 87 | self._wrapper = wrapper 88 | 89 | def __call__(self, event, context): 90 | request = event['RequestType'] 91 | logger.debug("Received {} type event. Full parameters: {}".format(request, json.dumps(event))) 92 | return self._dispatch.get(request, self._succeed())(event, context) 93 | 94 | def _succeed(self): 95 | @self._wrapper 96 | def success(event, context): 97 | return { 98 | 'Status': SUCCESS, 99 | 'PhysicalResourceId': event.get('PhysicalResourceId', 'mock-resource-id'), 100 | 'Reason': 'Life is good, man', 101 | 'Data': {}, 102 | } 103 | return success 104 | 105 | def create(self, wraps): 106 | self._dispatch['Create'] = self._wrapper(wraps) 107 | return wraps 108 | 109 | def update(self, wraps): 110 | self._dispatch['Update'] = self._wrapper(wraps) 111 | return wraps 112 | 113 | def delete(self, wraps): 114 | self._dispatch['Delete'] = self._wrapper(wraps) 115 | return wraps 116 | -------------------------------------------------------------------------------- /test_cfn_resource.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | import vcr 5 | import pytest 6 | 7 | import cfn_resource 8 | 9 | class FakeLambdaContext(object): 10 | def __init__(self, name='Fake', version='LATEST'): 11 | self.name = name 12 | self.version = version 13 | 14 | @property 15 | def get_remaining_time_in_millis(self): 16 | return 10000 17 | 18 | @property 19 | def function_name(self): 20 | return self.name 21 | 22 | @property 23 | def function_version(self): 24 | return self.version 25 | 26 | @property 27 | def invoked_function_arn(self): 28 | return 'arn:aws:lambda:123456789012:' + self.name 29 | 30 | @property 31 | def memory_limit_in_mb(self): 32 | return 1024 33 | 34 | @property 35 | def aws_request_id(self): 36 | return '1234567890' 37 | 38 | 39 | def wrap_with_nothing(func, base_response=None): 40 | def wrapper(*args): 41 | return func(*args) 42 | return wrapper 43 | 44 | 45 | base_event = { 46 | "StackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SomeStackHere3/d50d1280-a454-11e5-bd51-50e2416294a8", 47 | "ResponseURL": "https://cloudformation-custom-resource-response-useast1.s3.amazonaws.com/arn%3Aaws%3Acloudformation%3Aus-east-1%3A368950843917%3Astack/SomeStackHere3/d50d1280-a454-11e5-bd51-50e2416294a8%7CFakeThing%7C79abbda7-092e-4534-9602-3ab4cc377807?AWSAccessKeyId=AKIAJNXHFR7P7YGKLDPQ&Expires=1450321030&Signature=HOCkeEsxMHHQMgnj3kx5gqLyfTU%3D", 48 | "ResourceProperties": { 49 | "OtherThing": "foobar", 50 | "ServiceToken": "arn:aws:lambda:us-east-1:123456789012:function:PyRsrc", 51 | "AnotherThing": "2" 52 | }, 53 | "RequestType": "Delete", 54 | "ServiceToken": "arn:aws:lambda:us-east-1:123456789012:function:PyRsrc", 55 | "ResourceType": "Custom::MyResource", 56 | "PhysicalResourceId": "SomeStackHere3-FakeThing-893YUKO12RFM", 57 | "RequestId": "79abbda7-092e-4534-9602-3ab4cc377807", 58 | "LogicalResourceId": "FakeThing" 59 | } 60 | 61 | ### Tests for the wrapper function 62 | 63 | @pytest.fixture 64 | def cassette(request): 65 | recordings_file = os.path.join( 66 | request.fspath.dirname, 67 | 'vcr_recordings', 68 | request.function.__name__ + '.yaml' 69 | ).replace('test_', '') 70 | with vcr.use_cassette(recordings_file, record_mode='none') as cass: 71 | yield cass 72 | 73 | def test_client_code_failure(cassette): 74 | rsrc = cfn_resource.Resource() 75 | 76 | @rsrc.delete 77 | def flaky_function(*args): 78 | raise KeyError('Oopsie') 79 | 80 | resp = rsrc(base_event.copy(), FakeLambdaContext()) 81 | assert resp is None 82 | print(cassette) 83 | 84 | assert json.loads(cassette.requests[0].body) 85 | 86 | reply = json.loads(cassette.requests[0].body) 87 | 88 | assert cassette.requests[0].method == 'PUT' 89 | assert reply['Status'] == cfn_resource.FAILED 90 | assert reply['StackId'] == base_event['StackId'] 91 | assert reply['Reason'] == "Exception was raised while handling custom resource" 92 | 93 | 94 | def test_sends_put_request(cassette): 95 | rsrc = cfn_resource.Resource() 96 | 97 | resp = rsrc(base_event.copy(), FakeLambdaContext()) 98 | 99 | assert cassette.requests[0].method == 'PUT' 100 | 101 | ### Tests for the Resource object and its decorator for wrapping 102 | ### user handlers 103 | 104 | def test_wraps_func(): 105 | rsrc = cfn_resource.Resource(wrap_with_nothing) 106 | @rsrc.delete 107 | def delete(event, context): 108 | return {'Status': cfn_resource.FAILED} 109 | resp = rsrc(base_event.copy(), FakeLambdaContext()) 110 | assert resp['Status'] == 'FAILED' 111 | 112 | def test_succeeds_default(): 113 | event = base_event.copy() 114 | event['PhysicalResourceId'] = 'my-existing-thing' 115 | event['RequestType'] = 'Update' 116 | 117 | rsrc = cfn_resource.Resource(wrap_with_nothing) 118 | resp = rsrc(event, FakeLambdaContext()) 119 | assert resp == { 120 | 'Status': 'SUCCESS', 121 | 'PhysicalResourceId': 'my-existing-thing', 122 | 'Reason': 'Life is good, man', 123 | 'Data': {}, 124 | } 125 | 126 | def test_double_register(): 127 | rsrc = cfn_resource.Resource(wrap_with_nothing) 128 | 129 | event = base_event.copy() 130 | event['RequestType'] = 'Update' 131 | 132 | @rsrc.update 133 | def update(event, context): 134 | return {'Data': {'called-from': 1}} 135 | 136 | @rsrc.update 137 | def update_two(event, context): 138 | return {'Data': {'called-from': 2}} 139 | 140 | resp = rsrc(event, FakeLambdaContext()) 141 | assert resp['Data'] == {'called-from': 2} 142 | 143 | def test_no_override(): 144 | rsrc = cfn_resource.Resource(wrap_with_nothing) 145 | 146 | event = base_event.copy() 147 | event['RequestType'] = 'Create' 148 | 149 | @rsrc.create 150 | def create(event, context): 151 | return {'Data': {'called-from': 1}} 152 | 153 | assert create(event, FakeLambdaContext()) == rsrc(event, FakeLambdaContext()) 154 | --------------------------------------------------------------------------------