Mitch Garnaat

Another WIP commit. Major changes in the CLI. Also much better detection of ch…

…anges (or no changes) in the code, configuration, policies, etc. when deploying.  An attempt to incorporate a test runner that will run unit tests associated with the Lambda function.
1 -# Copyright (c) 2014,2015 Mitch Garnaat http://garnaat.org/
2 -#
3 -# Licensed under the Apache License, Version 2.0 (the "License"). You
4 -# may not use this file except in compliance with the License. A copy of
5 -# the License is located at
6 -#
7 -# http://aws.amazon.com/apache2.0/
8 -#
9 -# or in the "license" file accompanying this file. This file is
10 -# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11 -# ANY KIND, either express or implied. See the License for the specific
12 -# language governing permissions and limitations under the License.
13 -
14 -import boto3
15 -
16 -
17 -class __AWS(object):
18 -
19 - def __init__(self, profile_name=None, region_name=None):
20 - self._client_cache = {}
21 - self._session = boto3.session.Session(
22 - region_name=region_name, profile_name=profile_name)
23 -
24 - def create_client(self, client_name):
25 - if client_name not in self._client_cache:
26 - self._client_cache[client_name] = self._session.client(
27 - client_name)
28 - return self._client_cache[client_name]
29 -
30 -
31 -__Singleton_AWS = None
32 -
33 -
34 -def get_aws(context):
35 - global __Singleton_AWS
36 - if __Singleton_AWS is None:
37 - __Singleton_AWS = __AWS(context.profile, context.region)
38 - return __Singleton_AWS
...@@ -13,33 +13,22 @@ ...@@ -13,33 +13,22 @@
13 # limitations under the License. 13 # limitations under the License.
14 14
15 import logging 15 import logging
16 -import json
17 import os 16 import os
18 17
19 -import datetime
20 import jmespath 18 import jmespath
21 import boto3 19 import boto3
20 +import placebo
22 21
23 22
24 LOG = logging.getLogger(__name__) 23 LOG = logging.getLogger(__name__)
25 24
26 25
27 -def json_encoder(obj):
28 - """JSON encoder that formats datetimes as ISO8601 format."""
29 - if isinstance(obj, datetime.datetime):
30 - return obj.isoformat()
31 - else:
32 - return obj
33 -
34 -
35 class AWSClient(object): 26 class AWSClient(object):
36 27
37 - def __init__(self, service_name, region_name, profile_name, 28 + def __init__(self, service_name, region_name, profile_name):
38 - record_path=None):
39 self._service_name = service_name 29 self._service_name = service_name
40 self._region_name = region_name 30 self._region_name = region_name
41 self._profile_name = profile_name 31 self._profile_name = profile_name
42 - self._record_path = record_path
43 self._client = self._create_client() 32 self._client = self._create_client()
44 33
45 @property 34 @property
...@@ -54,43 +43,12 @@ class AWSClient(object): ...@@ -54,43 +43,12 @@ class AWSClient(object):
54 def profile_name(self): 43 def profile_name(self):
55 return self._profile_name 44 return self._profile_name
56 45
57 - def _record(self, op_name, kwargs, data):
58 - """
59 - This is a little hack to enable easier unit testing of the code.
60 - Since botocore/boto3 has its own set of tests, I'm not interested in
61 - trying to test it again here. So, this recording capability allows
62 - us to save the data coming back from botocore as JSON files which
63 - can then be used by the mocked awsclient in the unit test directory.
64 - To enable this, pass in a record_path to the contructor and the JSON
65 - data files will get stored in this path.
66 - """
67 - if self._record_path:
68 - path = os.path.expanduser(self._record_path)
69 - path = os.path.expandvars(path)
70 - path = os.path.join(path, self.service_name)
71 - if not os.path.isdir(path):
72 - os.mkdir(path)
73 - path = os.path.join(path, self.region_name)
74 - if not os.path.isdir(path):
75 - os.mkdir(path)
76 - path = os.path.join(path, self.account_id)
77 - if not os.path.isdir(path):
78 - os.mkdir(path)
79 - filename = op_name
80 - if kwargs:
81 - for k, v in kwargs.items():
82 - if k != 'query':
83 - filename += '_{}_{}'.format(k, v)
84 - filename += '.json'
85 - path = os.path.join(path, filename)
86 - with open(path, 'wb') as fp:
87 - json.dump(data, fp, indent=4, default=json_encoder,
88 - ensure_ascii=False)
89 -
90 def _create_client(self): 46 def _create_client(self):
91 session = boto3.session.Session( 47 session = boto3.session.Session(
92 region_name=self._region_name, profile_name=self._profile_name) 48 region_name=self._region_name, profile_name=self._profile_name)
93 - return session.client(self._service_name) 49 + placebo.attach(session)
50 + client = session.client(self._service_name)
51 + return client
94 52
95 def call(self, op_name, query=None, **kwargs): 53 def call(self, op_name, query=None, **kwargs):
96 """ 54 """
...@@ -131,20 +89,36 @@ class AWSClient(object): ...@@ -131,20 +89,36 @@ class AWSClient(object):
131 data = op(**kwargs) 89 data = op(**kwargs)
132 if query: 90 if query:
133 data = query.search(data) 91 data = query.search(data)
134 - self._record(op_name, kwargs, data)
135 return data 92 return data
136 93
137 94
138 _client_cache = {} 95 _client_cache = {}
139 96
140 97
98 +def save_recordings(recording_path):
99 + for key in _client_cache:
100 + client = _client_cache[key]
101 + full_path = os.path.join(recording_path, '{}.json'.format(key))
102 + client._client.meta.placebo.save(full_path)
103 +
104 +
141 def create_client(service_name, context): 105 def create_client(service_name, context):
142 global _client_cache 106 global _client_cache
143 client_key = '{}:{}:{}'.format(service_name, context.region, 107 client_key = '{}:{}:{}'.format(service_name, context.region,
144 context.profile) 108 context.profile)
145 if client_key not in _client_cache: 109 if client_key not in _client_cache:
146 - _client_cache[client_key] = AWSClient(service_name, 110 + client = AWSClient(service_name, context.region,
147 - context.region, 111 + context.profile)
148 - context.profile, 112 + if 'placebo' in context.config:
149 - context.record_path) 113 + placebo_cfg = context.config['placebo']
114 + if placebo_cfg.get('mode') == 'play':
115 + full_path = os.path.join(
116 + placebo_cfg['recording_path'],
117 + '{}.json'.format(client_key))
118 + if os.path.exists(full_path):
119 + client._client.meta.placebo.load(full_path)
120 + client._client.meta.placebo.start()
121 + elif placebo_cfg['mode'] == 'record':
122 + client._client.meta.placebo.record()
123 + _client_cache[client_key] = client
150 return _client_cache[client_key] 124 return _client_cache[client_key]
......
...@@ -16,6 +16,7 @@ import logging ...@@ -16,6 +16,7 @@ import logging
16 import yaml 16 import yaml
17 import time 17 import time
18 import os 18 import os
19 +import shutil
19 20
20 import kappa.function 21 import kappa.function
21 import kappa.event_source 22 import kappa.event_source
...@@ -57,26 +58,28 @@ class Context(object): ...@@ -57,26 +58,28 @@ class Context(object):
57 with open(cache_file, 'rb') as fp: 58 with open(cache_file, 'rb') as fp:
58 self.cache = yaml.load(fp) 59 self.cache = yaml.load(fp)
59 60
60 - def save_cache(self): 61 + def _delete_cache(self):
62 + if os.path.isdir('.kappa'):
63 + shutil.rmtree('.kappa')
64 + self.cache = {}
65 +
66 + def _save_cache(self):
61 if not os.path.isdir('.kappa'): 67 if not os.path.isdir('.kappa'):
62 os.mkdir('.kappa') 68 os.mkdir('.kappa')
63 cache_file = os.path.join('.kappa', 'cache') 69 cache_file = os.path.join('.kappa', 'cache')
64 with open(cache_file, 'wb') as fp: 70 with open(cache_file, 'wb') as fp:
65 yaml.dump(self.cache, fp) 71 yaml.dump(self.cache, fp)
66 72
67 - @property 73 + def get_cache_value(self, key):
68 - def name(self): 74 + return self.cache.setdefault(self.environment, dict()).get(key)
69 - return '{}-{}-v{}'.format(self.base_name,
70 - self.environment,
71 - self.version)
72 75
73 - @property 76 + def set_cache_value(self, key, value):
74 - def base_name(self): 77 + self.cache.setdefault(self.environment, dict())[key] = value
75 - return self.config.get('base_name') 78 + self._save_cache()
76 79
77 @property 80 @property
78 - def version(self): 81 + def name(self):
79 - return self.config.get('version') 82 + return '{}-{}'.format(self.config['name'], self.environment)
80 83
81 @property 84 @property
82 def profile(self): 85 def profile(self):
...@@ -87,14 +90,23 @@ class Context(object): ...@@ -87,14 +90,23 @@ class Context(object):
87 return self.config['environments'][self.environment]['region'] 90 return self.config['environments'][self.environment]['region']
88 91
89 @property 92 @property
90 - def record_path(self): 93 + def record(self):
91 - return self.config.get('record_path') 94 + return self.config.get('record', False)
92 95
93 @property 96 @property
94 def lambda_config(self): 97 def lambda_config(self):
95 return self.config.get('lambda') 98 return self.config.get('lambda')
96 99
97 @property 100 @property
101 + def test_dir(self):
102 + return self.config.get('tests', '_tests')
103 +
104 + @property
105 + def unit_test_runner(self):
106 + return self.config.get('unit_test_runner',
107 + 'nosetests . ../_tests/unit/')
108 +
109 + @property
98 def exec_role_arn(self): 110 def exec_role_arn(self):
99 return self.role.arn 111 return self.role.arn
100 112
...@@ -179,14 +191,20 @@ class Context(object): ...@@ -179,14 +191,20 @@ class Context(object):
179 time.sleep(5) 191 time.sleep(5)
180 self.function.deploy() 192 self.function.deploy()
181 193
182 - def update_code(self): 194 + def invoke(self, data):
183 - self.function.update() 195 + return self.function.invoke(data)
184 196
185 - def invoke(self): 197 + def unit_tests(self):
186 - return self.function.invoke() 198 + # run any unit tests
199 + unit_test_path = os.path.join(self.test_dir, 'unit')
200 + if os.path.exists(unit_test_path):
201 + os.chdir(self.function.path)
202 + print('running unit tests')
203 + pipe = os.popen(self.unit_test_runner, 'r')
204 + print(pipe.read())
187 205
188 def test(self): 206 def test(self):
189 - return self.function.invoke() 207 + return self.unit_tests()
190 208
191 def dryrun(self): 209 def dryrun(self):
192 return self.function.dryrun() 210 return self.function.dryrun()
...@@ -197,6 +215,9 @@ class Context(object): ...@@ -197,6 +215,9 @@ class Context(object):
197 def tail(self): 215 def tail(self):
198 return self.function.tail() 216 return self.function.tail()
199 217
218 + def tag(self, name, description):
219 + return self.function.tag(name, description)
220 +
200 def delete(self): 221 def delete(self):
201 for event_source in self.event_sources: 222 for event_source in self.event_sources:
202 event_source.remove(self.function) 223 event_source.remove(self.function)
...@@ -208,6 +229,7 @@ class Context(object): ...@@ -208,6 +229,7 @@ class Context(object):
208 time.sleep(5) 229 time.sleep(5)
209 if self.policy: 230 if self.policy:
210 self.policy.delete() 231 self.policy.delete()
232 + self._delete_cache()
211 233
212 def status(self): 234 def status(self):
213 status = {} 235 status = {}
......
...@@ -17,6 +17,7 @@ import os ...@@ -17,6 +17,7 @@ import os
17 import zipfile 17 import zipfile
18 import time 18 import time
19 import shutil 19 import shutil
20 +import hashlib
20 21
21 from botocore.exceptions import ClientError 22 from botocore.exceptions import ClientError
22 23
...@@ -66,11 +67,11 @@ class Function(object): ...@@ -66,11 +67,11 @@ class Function(object):
66 67
67 @property 68 @property
68 def path(self): 69 def path(self):
69 - return self._config['path'] 70 + return self._config.get('path', '_src')
70 71
71 @property 72 @property
72 - def test_data(self): 73 + def tests(self):
73 - return self._config['test_data'] 74 + return self._config.get('tests', '_tests')
74 75
75 @property 76 @property
76 def permissions(self): 77 def permissions(self):
...@@ -87,34 +88,79 @@ class Function(object): ...@@ -87,34 +88,79 @@ class Function(object):
87 LOG.debug('Unable to find ARN for function: %s', self.name) 88 LOG.debug('Unable to find ARN for function: %s', self.name)
88 return self._response 89 return self._response
89 90
90 - @property 91 + def _get_response_configuration(self, key, default=None):
91 - def code_sha_256(self): 92 + value = None
92 response = self._get_response() 93 response = self._get_response()
93 - return response['Configuration']['CodeSha256'] 94 + if response:
95 + if 'Configuration' in response:
96 + value = response['Configuration'].get(key, default)
97 + return value
98 +
99 + def _get_response_code(self, key, default=None):
100 + value = None
101 + response = self._get_response
102 + if response:
103 + if 'Configuration' in response:
104 + value = response['Configuration'].get(key, default)
105 + return value
94 106
95 @property 107 @property
96 - def arn(self): 108 + def code_sha_256(self):
97 - response = self._get_response() 109 + return self._get_response_configuration('CodeSha256')
98 - return response['Configuration']['FunctionArn']
99 110
100 @property 111 @property
101 - def version(self): 112 + def arn(self):
102 - response = self._get_response() 113 + return self._get_response_configuration('FunctionArn')
103 - return response['Configuration']['Version']
104 114
105 @property 115 @property
106 def repository_type(self): 116 def repository_type(self):
107 - response = self._get_response() 117 + return self._get_response_code('RepositoryType')
108 - return response['Code']['RepositoryType']
109 118
110 @property 119 @property
111 def location(self): 120 def location(self):
112 - response = self._get_response() 121 + return self._get_response_code('Location')
113 - return response['Code']['Location'] 122 +
123 + @property
124 + def version(self):
125 + return self._get_response_configuration('Version')
114 126
115 def exists(self): 127 def exists(self):
116 return self._get_response() 128 return self._get_response()
117 129
130 + def _check_function_md5(self):
131 + changed = True
132 + self._copy_config_file()
133 + self.zip_lambda_function(self.zipfile_name, self.path)
134 + m = hashlib.md5()
135 + with open(self.zipfile_name, 'rb') as fp:
136 + m.update(fp.read())
137 + zip_md5 = m.hexdigest()
138 + cached_md5 = self._context.get_cache_value('zip_md5')
139 + LOG.debug('zip_md5: %s', zip_md5)
140 + LOG.debug('cached md5: %s', cached_md5)
141 + if zip_md5 != cached_md5:
142 + self._context.set_cache_value('zip_md5', zip_md5)
143 + else:
144 + changed = False
145 + LOG.info('function unchanged')
146 + return changed
147 +
148 + def _check_config_md5(self):
149 + m = hashlib.md5()
150 + m.update(self.description)
151 + m.update(self.handler)
152 + m.update(str(self.memory_size))
153 + m.update(self._context.exec_role_arn)
154 + m.update(str(self.timeout))
155 + config_md5 = m.hexdigest()
156 + cached_md5 = self._context.get_cache_value('config_md5')
157 + if config_md5 != cached_md5:
158 + self._context.set_cache_value('config_md5', config_md5)
159 + changed = True
160 + else:
161 + changed = False
162 + return changed
163 +
118 @property 164 @property
119 def log(self): 165 def log(self):
120 if self._log is None: 166 if self._log is None:
...@@ -181,13 +227,13 @@ class Function(object): ...@@ -181,13 +227,13 @@ class Function(object):
181 config_path = os.path.join(self.path, config_name) 227 config_path = os.path.join(self.path, config_name)
182 if os.path.exists(config_path): 228 if os.path.exists(config_path):
183 dest_path = os.path.join(self.path, 'config.json') 229 dest_path = os.path.join(self.path, 'config.json')
184 - LOG.info('copy %s to %s', config_path, dest_path) 230 + LOG.debug('copy %s to %s', config_path, dest_path)
185 - shutil.copyfile(config_path, dest_path) 231 + shutil.copy2(config_path, dest_path)
186 232
187 def create(self): 233 def create(self):
188 LOG.info('creating function %s', self.name) 234 LOG.info('creating function %s', self.name)
189 - self._copy_config_file() 235 + self._check_function_md5()
190 - self.zip_lambda_function(self.zipfile_name, self.path) 236 + self._check_config_md5()
191 with open(self.zipfile_name, 'rb') as fp: 237 with open(self.zipfile_name, 'rb') as fp:
192 exec_role = self._context.exec_role_arn 238 exec_role = self._context.exec_role_arn
193 LOG.debug('exec_role=%s', exec_role) 239 LOG.debug('exec_role=%s', exec_role)
...@@ -202,29 +248,17 @@ class Function(object): ...@@ -202,29 +248,17 @@ class Function(object):
202 Handler=self.handler, 248 Handler=self.handler,
203 Description=self.description, 249 Description=self.description,
204 Timeout=self.timeout, 250 Timeout=self.timeout,
205 - MemorySize=self.memory_size) 251 + MemorySize=self.memory_size,
252 + Publish=True)
206 LOG.debug(response) 253 LOG.debug(response)
207 except Exception: 254 except Exception:
208 LOG.exception('Unable to upload zip file') 255 LOG.exception('Unable to upload zip file')
209 self.add_permissions() 256 self.add_permissions()
210 257
211 - def _do_update(self):
212 - do_update = False
213 - if self._context.force:
214 - do_update = True
215 - else:
216 - stats = os.stat(self.zipfile_name)
217 - if self._context.cache.get('zipfile_size') != stats.st_size:
218 - self._context.cache['zipfile_size'] = stats.st_size
219 - do_update = True
220 - return do_update
221 -
222 def update(self): 258 def update(self):
223 LOG.info('updating %s', self.name) 259 LOG.info('updating %s', self.name)
224 - self._copy_config_file() 260 + if self._check_function_md5():
225 - self.zip_lambda_function(self.zipfile_name, self.path) 261 + self._response = None
226 - if self._do_update():
227 - self._context.save_cache()
228 with open(self.zipfile_name, 'rb') as fp: 262 with open(self.zipfile_name, 'rb') as fp:
229 try: 263 try:
230 LOG.info('uploading new function zipfile %s', 264 LOG.info('uploading new function zipfile %s',
...@@ -233,33 +267,40 @@ class Function(object): ...@@ -233,33 +267,40 @@ class Function(object):
233 response = self._lambda_client.call( 267 response = self._lambda_client.call(
234 'update_function_code', 268 'update_function_code',
235 FunctionName=self.name, 269 FunctionName=self.name,
236 - ZipFile=zipdata) 270 + ZipFile=zipdata,
271 + Publish=True)
237 LOG.debug(response) 272 LOG.debug(response)
238 except Exception: 273 except Exception:
239 LOG.exception('unable to update zip file') 274 LOG.exception('unable to update zip file')
240 - else:
241 - LOG.info('function has not changed')
242 -
243 - def deploy(self):
244 - if self.exists():
245 - return self.update()
246 - return self.create()
247 275
248 - def publish_version(self, description): 276 + def update_configuration(self):
249 - LOG.info('publishing version of %s', self.name) 277 + if self._check_config_md5():
278 + self._response = None
279 + LOG.info('updating configuration for %s', self.name)
280 + exec_role = self._context.exec_role_arn
281 + LOG.debug('exec_role=%s', exec_role)
250 try: 282 try:
251 response = self._lambda_client.call( 283 response = self._lambda_client.call(
252 - 'publish_version', 284 + 'update_function_configuration',
253 FunctionName=self.name, 285 FunctionName=self.name,
254 - CodeSha256=self.code_sha_256, 286 + Role=exec_role,
255 - Description=description) 287 + Handler=self.handler,
288 + Description=self.description,
289 + Timeout=self.timeout,
290 + MemorySize=self.memory_size)
256 LOG.debug(response) 291 LOG.debug(response)
257 except Exception: 292 except Exception:
258 - LOG.exception('Unable to publish version') 293 + LOG.exception('unable to update function configuration')
259 - return response['Version'] 294 + else:
295 + LOG.info('function configuration has not changed')
296 +
297 + def deploy(self):
298 + if self.exists():
299 + self.update_configuration()
300 + return self.update()
301 + return self.create()
260 302
261 def list_versions(self): 303 def list_versions(self):
262 - LOG.info('listing versions of %s', self.name)
263 try: 304 try:
264 response = self._lambda_client.call( 305 response = self._lambda_client.call(
265 'list_versions_by_function', 306 'list_versions_by_function',
...@@ -270,15 +311,26 @@ class Function(object): ...@@ -270,15 +311,26 @@ class Function(object):
270 return response['Versions'] 311 return response['Versions']
271 312
272 def create_alias(self, name, description, version=None): 313 def create_alias(self, name, description, version=None):
273 - LOG.info('creating alias of %s', self.name) 314 + # Find the current (latest) version by version number
274 - if version is None: 315 + # First find the SHA256 of $LATEST
275 - version = self.version 316 + if not version:
317 + versions = self.list_versions()
318 + for version in versions:
319 + if version['Version'] == '$LATEST':
320 + latest_sha256 = version['CodeSha256']
321 + break
322 + for version in versions:
323 + if version['Version'] != '$LATEST':
324 + if version['CodeSha256'] == latest_sha256:
325 + version = version['Version']
326 + break
276 try: 327 try:
328 + LOG.info('creating alias %s=%s', name, version)
277 response = self._lambda_client.call( 329 response = self._lambda_client.call(
278 'create_alias', 330 'create_alias',
279 FunctionName=self.name, 331 FunctionName=self.name,
280 Description=description, 332 Description=description,
281 - FunctionVersion=self.version, 333 + FunctionVersion=version,
282 Name=name) 334 Name=name)
283 LOG.debug(response) 335 LOG.debug(response)
284 except Exception: 336 except Exception:
...@@ -297,8 +349,7 @@ class Function(object): ...@@ -297,8 +349,7 @@ class Function(object):
297 return response['Versions'] 349 return response['Versions']
298 350
299 def tag(self, name, description): 351 def tag(self, name, description):
300 - version = self.publish_version(description) 352 + self.create_alias(name, description)
301 - self.create_alias(name, description, version)
302 353
303 def delete(self): 354 def delete(self):
304 LOG.info('deleting function %s', self.name) 355 LOG.info('deleting function %s', self.name)
...@@ -332,17 +383,14 @@ class Function(object): ...@@ -332,17 +383,14 @@ class Function(object):
332 InvokeArgs=fp) 383 InvokeArgs=fp)
333 LOG.debug(response) 384 LOG.debug(response)
334 385
335 - def _invoke(self, test_data, invocation_type): 386 + def _invoke(self, data, invocation_type):
336 - if test_data is None: 387 + LOG.debug('invoke %s as %s', self.name, invocation_type)
337 - test_data = self.test_data
338 - LOG.debug('invoke %s', test_data)
339 - with open(test_data) as fp:
340 response = self._lambda_client.call( 388 response = self._lambda_client.call(
341 'invoke', 389 'invoke',
342 FunctionName=self.name, 390 FunctionName=self.name,
343 InvocationType=invocation_type, 391 InvocationType=invocation_type,
344 LogType='Tail', 392 LogType='Tail',
345 - Payload=fp.read()) 393 + Payload=data)
346 LOG.debug(response) 394 LOG.debug(response)
347 return response 395 return response
348 396
......
...@@ -131,11 +131,11 @@ class Policy(object): ...@@ -131,11 +131,11 @@ class Policy(object):
131 m = hashlib.md5() 131 m = hashlib.md5()
132 m.update(document) 132 m.update(document)
133 policy_md5 = m.hexdigest() 133 policy_md5 = m.hexdigest()
134 + cached_md5 = self._context.get_cache_value('policy_md5')
134 LOG.debug('policy_md5: %s', policy_md5) 135 LOG.debug('policy_md5: %s', policy_md5)
135 - LOG.debug('cache md5: %s', self._context.cache.get('policy_md5')) 136 + LOG.debug('cached md5: %s', cached_md5)
136 - if policy_md5 != self._context.cache.get('policy_md5'): 137 + if policy_md5 != cached_md5:
137 - self._context.cache['policy_md5'] = policy_md5 138 + self._context.set_cache_value('policy_md5', policy_md5)
138 - self._context.save_cache()
139 self._add_policy_version() 139 self._add_policy_version()
140 else: 140 else:
141 LOG.info('policy unchanged') 141 LOG.info('policy unchanged')
...@@ -158,6 +158,20 @@ class Policy(object): ...@@ -158,6 +158,20 @@ class Policy(object):
158 document = self.document() 158 document = self.document()
159 if self.arn and document: 159 if self.arn and document:
160 LOG.info('deleting policy %s', self.name) 160 LOG.info('deleting policy %s', self.name)
161 + LOG.info('deleting all policy versions for %s', self.name)
162 + versions = self._list_versions()
163 + for version in versions:
164 + LOG.debug('deleting version %s', version['VersionId'])
165 + if not version['IsDefaultVersion']:
166 + try:
167 + response = self._iam_client.call(
168 + 'delete_policy_version',
169 + PolicyArn=self.arn,
170 + VersionId=version['VersionId'])
171 + except Exception:
172 + LOG.exception('Unable to delete policy version %s',
173 + version['VersionId'])
174 + LOG.debug('now delete policy')
161 response = self._iam_client.call( 175 response = self._iam_client.call(
162 'delete_policy', PolicyArn=self.arn) 176 'delete_policy', PolicyArn=self.arn)
163 LOG.debug(response) 177 LOG.debug(response)
......
...@@ -19,12 +19,16 @@ import click ...@@ -19,12 +19,16 @@ import click
19 19
20 from kappa.context import Context 20 from kappa.context import Context
21 21
22 +pass_ctx = click.make_pass_decorator(Context)
23 +
22 24
23 @click.group() 25 @click.group()
24 -@click.argument( 26 +@click.option(
25 - 'config', 27 + '--config',
28 + default='kappa.yml',
26 type=click.File('rb'), 29 type=click.File('rb'),
27 envvar='KAPPA_CONFIG', 30 envvar='KAPPA_CONFIG',
31 + help='Name of config file (default is kappa.yml)'
28 ) 32 )
29 @click.option( 33 @click.option(
30 '--debug/--no-debug', 34 '--debug/--no-debug',
...@@ -32,117 +36,63 @@ from kappa.context import Context ...@@ -32,117 +36,63 @@ from kappa.context import Context
32 help='Turn on debugging output' 36 help='Turn on debugging output'
33 ) 37 )
34 @click.option( 38 @click.option(
35 - '--environment', 39 + '--env',
36 - help='Specify which environment to work with' 40 + default='dev',
37 -) 41 + help='Specify which environment to work with (default dev)'
38 -@click.option(
39 - '--force/--no-force',
40 - default=False,
41 - help='Force an update of the Lambda function'
42 ) 42 )
43 @click.pass_context 43 @click.pass_context
44 -def cli(ctx, config=None, debug=False, environment=None, force=None): 44 +def cli(ctx, config=None, debug=False, env=None):
45 - config = config 45 + ctx.obj = Context(config, env, debug)
46 - ctx.obj['debug'] = debug
47 - ctx.obj['config'] = config
48 - ctx.obj['environment'] = environment
49 - ctx.obj['force'] = force
50 46
51 47
52 @cli.command() 48 @cli.command()
53 -@click.pass_context 49 +@pass_ctx
54 def deploy(ctx): 50 def deploy(ctx):
55 """Deploy the Lambda function and any policies and roles required""" 51 """Deploy the Lambda function and any policies and roles required"""
56 - context = Context(ctx.obj['config'], ctx.obj['environment'],
57 - ctx.obj['debug'], ctx.obj['force'])
58 click.echo('deploying') 52 click.echo('deploying')
59 - context.deploy() 53 + ctx.deploy()
60 - click.echo('done')
61 -
62 -
63 -@cli.command()
64 -@click.pass_context
65 -def tag(ctx):
66 - """Deploy the Lambda function and any policies and roles required"""
67 - context = Context(ctx.obj['config'], ctx.obj['environment'],
68 - ctx.obj['debug'], ctx.obj['force'])
69 - click.echo('tagging')
70 - context.deploy()
71 click.echo('done') 54 click.echo('done')
72 55
73 56
74 @cli.command() 57 @cli.command()
75 -@click.pass_context 58 +@click.argument('data_file', type=click.File('r'))
76 -def invoke(ctx): 59 +@pass_ctx
60 +def invoke(ctx, data_file):
77 """Invoke the command synchronously""" 61 """Invoke the command synchronously"""
78 - context = Context(ctx.obj['config'], ctx.obj['environment'],
79 - ctx.obj['debug'], ctx.obj['force'])
80 click.echo('invoking') 62 click.echo('invoking')
81 - response = context.invoke() 63 + response = ctx.invoke(data_file.read())
82 log_data = base64.b64decode(response['LogResult']) 64 log_data = base64.b64decode(response['LogResult'])
83 click.echo(log_data) 65 click.echo(log_data)
66 + click.echo('Response:')
84 click.echo(response['Payload'].read()) 67 click.echo(response['Payload'].read())
85 click.echo('done') 68 click.echo('done')
86 69
87 70
88 @cli.command() 71 @cli.command()
89 -@click.pass_context 72 +@pass_ctx
90 def test(ctx): 73 def test(ctx):
91 """Test the command synchronously""" 74 """Test the command synchronously"""
92 - context = Context(ctx.obj['config'], ctx.obj['environment'],
93 - ctx.obj['debug'], ctx.obj['force'])
94 click.echo('testing') 75 click.echo('testing')
95 - response = context.test() 76 + ctx.test()
96 - log_data = base64.b64decode(response['LogResult'])
97 - click.echo(log_data)
98 - click.echo(response['Payload'].read())
99 - click.echo('done')
100 -
101 -
102 -@cli.command()
103 -@click.pass_context
104 -def dryrun(ctx):
105 - """Show you what would happen but don't actually do anything"""
106 - context = Context(ctx.obj['config'], ctx.obj['environment'],
107 - ctx.obj['debug'], ctx.obj['force'])
108 - click.echo('invoking dryrun')
109 - response = context.dryrun()
110 - click.echo(response)
111 - click.echo('done')
112 -
113 -
114 -@cli.command()
115 -@click.pass_context
116 -def invoke_async(ctx):
117 - """Invoke the Lambda function asynchronously"""
118 - context = Context(ctx.obj['config'], ctx.obj['environment'],
119 - ctx.obj['debug'], ctx.obj['force'])
120 - click.echo('invoking async')
121 - response = context.invoke_async()
122 - click.echo(response)
123 click.echo('done') 77 click.echo('done')
124 78
125 79
126 @cli.command() 80 @cli.command()
127 -@click.pass_context 81 +@pass_ctx
128 def tail(ctx): 82 def tail(ctx):
129 """Show the last 10 lines of the log file""" 83 """Show the last 10 lines of the log file"""
130 - context = Context(ctx.obj['config'], ctx.obj['environment'],
131 - ctx.obj['debug'], ctx.obj['force'])
132 click.echo('tailing logs') 84 click.echo('tailing logs')
133 - for e in context.tail()[-10:]: 85 + for e in ctx.tail()[-10:]:
134 ts = datetime.utcfromtimestamp(e['timestamp']//1000).isoformat() 86 ts = datetime.utcfromtimestamp(e['timestamp']//1000).isoformat()
135 click.echo("{}: {}".format(ts, e['message'])) 87 click.echo("{}: {}".format(ts, e['message']))
136 click.echo('done') 88 click.echo('done')
137 89
138 90
139 @cli.command() 91 @cli.command()
140 -@click.pass_context 92 +@pass_ctx
141 def status(ctx): 93 def status(ctx):
142 """Print a status of this Lambda function""" 94 """Print a status of this Lambda function"""
143 - context = Context(ctx.obj['config'], ctx.obj['environment'], 95 + status = ctx.status()
144 - ctx.obj['debug'])
145 - status = context.status()
146 click.echo(click.style('Policy', bold=True)) 96 click.echo(click.style('Policy', bold=True))
147 if status['policy']: 97 if status['policy']:
148 line = ' {} ({})'.format( 98 line = ' {} ({})'.format(
...@@ -175,58 +125,46 @@ def status(ctx): ...@@ -175,58 +125,46 @@ def status(ctx):
175 125
176 126
177 @cli.command() 127 @cli.command()
178 -@click.pass_context 128 +@pass_ctx
179 def delete(ctx): 129 def delete(ctx):
180 """Delete the Lambda function and related policies and roles""" 130 """Delete the Lambda function and related policies and roles"""
181 - context = Context(ctx.obj['config'], ctx.obj['environment'],
182 - ctx.obj['debug'], ctx.obj['force'])
183 click.echo('deleting') 131 click.echo('deleting')
184 - context.delete() 132 + ctx.delete()
185 click.echo('done') 133 click.echo('done')
186 134
187 135
188 @cli.command() 136 @cli.command()
189 -@click.pass_context 137 +@click.option(
190 -def add_event_sources(ctx): 138 + '--command',
139 + type=click.Choice(['add', 'update', 'enable', 'disable']),
140 + help='Operation to perform on event sources')
141 +@pass_ctx
142 +def event_sources(ctx, command):
191 """Add any event sources specified in the config file""" 143 """Add any event sources specified in the config file"""
192 - context = Context(ctx.obj['config'], ctx.obj['environment'], 144 + if command == 'add':
193 - ctx.obj['debug'], ctx.obj['force'])
194 click.echo('adding event sources') 145 click.echo('adding event sources')
195 - context.add_event_sources() 146 + ctx.add_event_sources()
196 click.echo('done') 147 click.echo('done')
197 - 148 + elif command == 'update':
198 -
199 -@cli.command()
200 -@click.pass_context
201 -def update_event_sources(ctx):
202 - """Update event sources specified in the config file"""
203 - context = Context(ctx.obj['config'], ctx.obj['environment'],
204 - ctx.obj['debug'], ctx.obj['force'])
205 click.echo('updating event sources') 149 click.echo('updating event sources')
206 - context.update_event_sources() 150 + ctx.update_event_sources()
207 click.echo('done') 151 click.echo('done')
208 - 152 + elif command == 'enable':
209 -
210 -@cli.command()
211 -@click.pass_context
212 -def enable_event_sources(ctx):
213 - """Enable event sources specified in the config file"""
214 - context = Context(ctx.obj['config'], ctx.obj['environment'],
215 - ctx.obj['debug'], ctx.obj['force'])
216 click.echo('enabling event sources') 153 click.echo('enabling event sources')
217 - context.enable_event_sources() 154 + ctx.enable_event_sources()
218 click.echo('done') 155 click.echo('done')
219 - 156 + elif command == 'disable':
220 -
221 -@cli.command()
222 -@click.pass_context
223 -def disable_event_sources(ctx):
224 - """Disable event sources specified in the config file"""
225 - context = Context(ctx.obj['config'], ctx.obj['environment'],
226 - ctx.obj['debug'], ctx.obj['force'])
227 click.echo('enabling event sources') 157 click.echo('enabling event sources')
228 - context.disable_event_sources() 158 + ctx.disable_event_sources()
229 click.echo('done') 159 click.echo('done')
230 160
231 161
232 -cli(obj={}) 162 +@cli.command()
163 +@click.argument('name')
164 +@click.argument('description')
165 +@pass_ctx
166 +def tag(ctx, name, description):
167 + """Tag the current function version with a symbolic name"""
168 + click.echo('creating tag for function')
169 + ctx.tag(name, description)
170 + click.echo('done')
......
1 -boto3>=1.2.0 1 +boto3>=1.2.2
2 click==5.1 2 click==5.1
3 PyYAML>=3.11 3 PyYAML>=3.11
4 mock>=1.0.1 4 mock>=1.0.1
......
1 +A Simple Python Example
2 +=======================
3 +
4 +In this Python example, we will build a Lambda function that can be hooked up
5 +to methods in API Gateway to provide a simple CRUD REST API that persists JSON
6 +objects in DynamoDB.
7 +
8 +To implement this, we will create a single Lambda function that will be
9 +associated with the GET, POST, PUT, and DELETE HTTP methods of a single API
10 +Gateway resource. We will show the API Gateway connections later. For now, we
11 +will focus on our Lambda function.
12 +
13 +
14 +
15 +Installing Dependencies
16 +-----------------------
17 +
18 +Put all dependencies in the `requirements.txt` file in this directory and then
19 +run the following command to install them in this directory prior to uploading
20 +the code.
21 +
22 + $ pip install -r requirements.txt -t /full/path/to/this/code
23 +
24 +This will install all of the dependencies inside the code directory so they can
25 +be bundled with your own code and deployed to Lambda.
26 +
27 +The ``setup.cfg`` file in this directory is required if you are running on
28 +MacOS and are using brew. It may not be needed on other platforms.
29 +
1 +The Code Is Here!
2 +=================
3 +
4 +At the moment, the contents of this directory are created by hand but when
5 +LambdaPI is complete, the basic framework would be created for you. You would
6 +have a Python source file that works but doesn't actually do anything. And the
7 +config.json file here would be created on the fly at deployment time. The
8 +correct resource names and other variables would be written into the config
9 +file and then then config file would get bundled up with the code. You can
10 +then load the config file at run time in the Lambda Python code so you don't
11 +have to hardcode resource names in your code.
12 +
13 +
14 +Installing Dependencies
15 +-----------------------
16 +
17 +Put all dependencies in the `requirements.txt` file in this directory and then
18 +run the following command to install them in this directory prior to uploading
19 +the code.
20 +
21 + $ pip install -r requirements.txt -t /full/path/to/this/code
22 +
23 +This will install all of the dependencies inside the code directory so they can
24 +be bundled with your own code and deployed to Lambda.
25 +
26 +The ``setup.cfg`` file in this directory is required if you are running on
27 +MacOS and are using brew. It may not be needed on other platforms.
28 +
1 +{
2 + "region_name": "us-west-2",
3 + "sample_table": "kappa-python-sample"
4 +}
5 +
1 +{
2 + "region_name": "us-west-2",
3 + "sample_table": "kappa-python-sample"
4 +}
5 +
1 +git+ssh://git@github.com/garnaat/petard.git
2 +
1 +import logging
2 +import json
3 +import uuid
4 +
5 +import boto3
6 +
7 +LOG = logging.getLogger()
8 +LOG.setLevel(logging.INFO)
9 +
10 +# The kappa deploy command will make sure that the right config file
11 +# for this environment is available in the local directory.
12 +config = json.load(open('config.json'))
13 +
14 +session = boto3.Session(region_name=config['region_name'])
15 +ddb_client = session.resource('dynamodb')
16 +table = ddb_client.Table(config['sample_table'])
17 +
18 +
19 +def foobar():
20 + return 42
21 +
22 +
23 +def _get(event, context):
24 + customer_id = event.get('id')
25 + if customer_id is None:
26 + raise Exception('No id provided for GET operation')
27 + response = table.get_item(Key={'id': customer_id})
28 + item = response.get('Item')
29 + if item is None:
30 + raise Exception('id: {} not found'.format(customer_id))
31 + return response['Item']
32 +
33 +
34 +def _post(event, context):
35 + item = event['json_body']
36 + if item is None:
37 + raise Exception('No json_body found in event')
38 + item['id'] = str(uuid.uuid4())
39 + table.put_item(Item=item)
40 + return item
41 +
42 +
43 +def _put(event, context):
44 + data = _get(event, context)
45 + id = data.get('id')
46 + data.update(event['json_body'])
47 + # don't allow the id to be changed
48 + data['id'] = id
49 + table.put_item(Item=data)
50 + return data
51 +
52 +
53 +def handler(event, context):
54 + LOG.info(event)
55 + http_method = event.get('http_method')
56 + if not http_method:
57 + return 'NoHttpMethodSupplied'
58 + if http_method == 'GET':
59 + return _get(event, context)
60 + elif http_method == 'POST':
61 + return _post(event, context)
62 + elif http_method == 'PUT':
63 + return _put(event, context)
64 + elif http_method == 'DELETE':
65 + return _put(event, context)
66 + else:
67 + raise Exception('UnsupportedMethod: {}'.format(http_method))
1 +{
2 + "http_method": "GET",
3 + "id": "4a407fc2-da7a-41e9-8dc6-8a057b6b767a"
4 +}
1 +{
2 + "http_method": "POST",
3 + "json_body": {
4 + "foo": "This is the foo value",
5 + "bar": "This is the bar value"
6 + }
7 +}
1 +import unittest
2 +
3 +import simple
4 +
5 +
6 +class TestSimple(unittest.TestCase):
7 +
8 + def test_foobar(self):
9 + self.assertEqual(simple.foobar(), 42)
1 +---
2 +name: kappa-python-sample
3 +environments:
4 + dev:
5 + profile: <your dev profile>
6 + region: <your dev region e.g. us-west-2>
7 + policy:
8 + resources:
9 + - arn: arn:aws:dynamodb:us-west-2:123456789012:table/kappa-python-sample
10 +
11 + actions:
12 + - "*"
13 + - arn: arn:aws:logs:*:*:*
14 + actions:
15 + - "*"
16 + prod:
17 + profile: <your prod profile>
18 + region: <your prod region e.g. us-west-2>
19 + policy_resources:
20 + - arn: arn:aws:dynamodb:us-west-2:234567890123:table/kappa-python-sample
21 + actions:
22 + - "*"
23 + - arn: arn:aws:logs:*:*:*
24 + actions:
25 + - "*"
26 +lambda:
27 + description: A simple Python sample
28 + handler: simple.handler
29 + runtime: python2.7
30 + memory_size: 256
31 + timeout: 3
32 +
...\ No newline at end of file ...\ No newline at end of file
...@@ -5,7 +5,7 @@ from setuptools import setup, find_packages ...@@ -5,7 +5,7 @@ from setuptools import setup, find_packages
5 import os 5 import os
6 6
7 requires = [ 7 requires = [
8 - 'boto3>=1.2.0', 8 + 'boto3>=1.2.2',
9 'click>=5.0', 9 'click>=5.0',
10 'PyYAML>=3.11' 10 'PyYAML>=3.11'
11 ] 11 ]
......