A WIP commit on the new refactor for support of Python and other features.
Showing
12 changed files
with
627 additions
and
210 deletions
1 | -# Copyright (c) 2014 Mitch Garnaat http://garnaat.org/ | 1 | +# Copyright (c) 2014, 2015 Mitch Garnaat |
2 | # | 2 | # |
3 | -# Licensed under the Apache License, Version 2.0 (the "License"). You | 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
4 | -# may not use this file except in compliance with the License. A copy of | 4 | +# you may not use this file except in compliance with the License. |
5 | -# the License is located at | 5 | +# You may obtain a copy of the License at |
6 | # | 6 | # |
7 | -# http://aws.amazon.com/apache2.0/ | 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
8 | # | 8 | # |
9 | -# or in the "license" file accompanying this file. This file is | 9 | +# Unless required by applicable law or agreed to in writing, software |
10 | -# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF | 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
11 | -# ANY KIND, either express or implied. See the License for the specific | 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | -# language governing permissions and limitations under the License. | 12 | +# See the License for the specific language governing permissions and |
13 | +# limitations under the License. | ||
13 | 14 | ||
14 | import os | 15 | import os |
15 | 16 | ... | ... |
kappa/awsclient.py
0 → 100644
1 | +# Copyright (c) 2015 Mitch Garnaat | ||
2 | +# | ||
3 | +# Licensed under the Apache License, Version 2.0 (the "License"); | ||
4 | +# you may not use this file except in compliance with the License. | ||
5 | +# You may obtain a copy of the License at | ||
6 | +# | ||
7 | +# http://www.apache.org/licenses/LICENSE-2.0 | ||
8 | +# | ||
9 | +# Unless required by applicable law or agreed to in writing, software | ||
10 | +# distributed under the License is distributed on an "AS IS" BASIS, | ||
11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
12 | +# See the License for the specific language governing permissions and | ||
13 | +# limitations under the License. | ||
14 | + | ||
15 | +import logging | ||
16 | +import json | ||
17 | +import os | ||
18 | + | ||
19 | +import datetime | ||
20 | +import jmespath | ||
21 | +import boto3 | ||
22 | + | ||
23 | + | ||
24 | +LOG = logging.getLogger(__name__) | ||
25 | + | ||
26 | + | ||
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): | ||
36 | + | ||
37 | + def __init__(self, service_name, region_name, profile_name, | ||
38 | + record_path=None): | ||
39 | + self._service_name = service_name | ||
40 | + self._region_name = region_name | ||
41 | + self._profile_name = profile_name | ||
42 | + self._record_path = record_path | ||
43 | + self._client = self._create_client() | ||
44 | + | ||
45 | + @property | ||
46 | + def service_name(self): | ||
47 | + return self._service_name | ||
48 | + | ||
49 | + @property | ||
50 | + def region_name(self): | ||
51 | + return self._region_name | ||
52 | + | ||
53 | + @property | ||
54 | + def profile_name(self): | ||
55 | + return self._profile_name | ||
56 | + | ||
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): | ||
91 | + session = boto3.session.Session( | ||
92 | + region_name=self._region_name, profile_name=self._profile_name) | ||
93 | + return session.client(self._service_name) | ||
94 | + | ||
95 | + def call(self, op_name, query=None, **kwargs): | ||
96 | + """ | ||
97 | + Make a request to a method in this client. The response data is | ||
98 | + returned from this call as native Python data structures. | ||
99 | + | ||
100 | + This method differs from just calling the client method directly | ||
101 | + in the following ways: | ||
102 | + | ||
103 | + * It automatically handles the pagination rather than | ||
104 | + relying on a separate pagination method call. | ||
105 | + * You can pass an optional jmespath query and this query | ||
106 | + will be applied to the data returned from the low-level | ||
107 | + call. This allows you to tailor the returned data to be | ||
108 | + exactly what you want. | ||
109 | + | ||
110 | + :type op_name: str | ||
111 | + :param op_name: The name of the request you wish to make. | ||
112 | + | ||
113 | + :type query: str | ||
114 | + :param query: A jmespath query that will be applied to the | ||
115 | + data returned by the operation prior to returning | ||
116 | + it to the user. | ||
117 | + | ||
118 | + :type kwargs: keyword arguments | ||
119 | + :param kwargs: Additional keyword arguments you want to pass | ||
120 | + to the method when making the request. | ||
121 | + """ | ||
122 | + LOG.debug(kwargs) | ||
123 | + if query: | ||
124 | + query = jmespath.compile(query) | ||
125 | + if self._client.can_paginate(op_name): | ||
126 | + paginator = self._client.get_paginator(op_name) | ||
127 | + results = paginator.paginate(**kwargs) | ||
128 | + data = results.build_full_result() | ||
129 | + else: | ||
130 | + op = getattr(self._client, op_name) | ||
131 | + data = op(**kwargs) | ||
132 | + if query: | ||
133 | + data = query.search(data) | ||
134 | + self._record(op_name, kwargs, data) | ||
135 | + return data | ||
136 | + | ||
137 | + | ||
138 | +_client_cache = {} | ||
139 | + | ||
140 | + | ||
141 | +def create_client(service_name, context): | ||
142 | + global _client_cache | ||
143 | + client_key = '{}:{}:{}'.format(service_name, context.region, | ||
144 | + context.profile) | ||
145 | + if client_key not in _client_cache: | ||
146 | + _client_cache[client_key] = AWSClient(service_name, | ||
147 | + context.region, | ||
148 | + context.profile, | ||
149 | + context.record_path) | ||
150 | + return _client_cache[client_key] |
1 | -# Copyright (c) 2014 Mitch Garnaat http://garnaat.org/ | 1 | +# Copyright (c) 2014, 2015 Mitch Garnaat |
2 | # | 2 | # |
3 | -# Licensed under the Apache License, Version 2.0 (the "License"). You | 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
4 | -# may not use this file except in compliance with the License. A copy of | 4 | +# you may not use this file except in compliance with the License. |
5 | -# the License is located at | 5 | +# You may obtain a copy of the License at |
6 | # | 6 | # |
7 | -# http://aws.amazon.com/apache2.0/ | 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
8 | # | 8 | # |
9 | -# or in the "license" file accompanying this file. This file is | 9 | +# Unless required by applicable law or agreed to in writing, software |
10 | -# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF | 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
11 | -# ANY KIND, either express or implied. See the License for the specific | 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | -# language governing permissions and limitations under the License. | 12 | +# See the License for the specific language governing permissions and |
13 | +# limitations under the License. | ||
13 | 14 | ||
14 | import logging | 15 | import logging |
15 | import yaml | 16 | import yaml |
16 | import time | 17 | import time |
18 | +import os | ||
17 | 19 | ||
18 | import kappa.function | 20 | import kappa.function |
19 | import kappa.event_source | 21 | import kappa.event_source |
... | @@ -28,34 +30,53 @@ InfoFmtString = '\t%(message)s' | ... | @@ -28,34 +30,53 @@ InfoFmtString = '\t%(message)s' |
28 | 30 | ||
29 | class Context(object): | 31 | class Context(object): |
30 | 32 | ||
31 | - def __init__(self, config_file, debug=False): | 33 | + def __init__(self, config_file, environment=None, debug=False): |
32 | if debug: | 34 | if debug: |
33 | self.set_logger('kappa', logging.DEBUG) | 35 | self.set_logger('kappa', logging.DEBUG) |
34 | else: | 36 | else: |
35 | self.set_logger('kappa', logging.INFO) | 37 | self.set_logger('kappa', logging.INFO) |
38 | + self._load_cache() | ||
36 | self.config = yaml.load(config_file) | 39 | self.config = yaml.load(config_file) |
37 | - if 'policy' in self.config.get('iam', ''): | 40 | + self.environment = environment |
38 | - self.policy = kappa.policy.Policy( | 41 | + self.policy = kappa.policy.Policy( |
39 | - self, self.config['iam']['policy']) | 42 | + self, self.config['environments'][self.environment]) |
40 | - else: | 43 | + self.role = kappa.role.Role( |
41 | - self.policy = None | 44 | + self, self.config['environments'][self.environment]) |
42 | - if 'role' in self.config.get('iam', ''): | ||
43 | - self.role = kappa.role.Role( | ||
44 | - self, self.config['iam']['role']) | ||
45 | - else: | ||
46 | - self.role = None | ||
47 | self.function = kappa.function.Function( | 45 | self.function = kappa.function.Function( |
48 | self, self.config['lambda']) | 46 | self, self.config['lambda']) |
49 | self.event_sources = [] | 47 | self.event_sources = [] |
50 | self._create_event_sources() | 48 | self._create_event_sources() |
51 | 49 | ||
50 | + def _load_cache(self): | ||
51 | + self.cache = {} | ||
52 | + if os.path.isdir('.kappa'): | ||
53 | + cache_file = os.path.join('.kappa', 'cache') | ||
54 | + if os.path.isfile(cache_file): | ||
55 | + with open(cache_file, 'rb') as fp: | ||
56 | + self.cache = yaml.load(fp) | ||
57 | + | ||
58 | + def save_cache(self): | ||
59 | + if not os.path.isdir('.kappa'): | ||
60 | + os.mkdir('.kappa') | ||
61 | + cache_file = os.path.join('.kappa', 'cache') | ||
62 | + with open(cache_file, 'wb') as fp: | ||
63 | + yaml.dump(self.cache, fp) | ||
64 | + | ||
65 | + @property | ||
66 | + def name(self): | ||
67 | + return self.config.get('name', None) | ||
68 | + | ||
52 | @property | 69 | @property |
53 | def profile(self): | 70 | def profile(self): |
54 | - return self.config.get('profile', None) | 71 | + return self.config['environments'][self.environment]['profile'] |
55 | 72 | ||
56 | @property | 73 | @property |
57 | def region(self): | 74 | def region(self): |
58 | - return self.config.get('region', None) | 75 | + return self.config['environments'][self.environment]['region'] |
76 | + | ||
77 | + @property | ||
78 | + def record_path(self): | ||
79 | + return self.config.get('record_path', None) | ||
59 | 80 | ||
60 | @property | 81 | @property |
61 | def lambda_config(self): | 82 | def lambda_config(self): |
... | @@ -134,6 +155,18 @@ class Context(object): | ... | @@ -134,6 +155,18 @@ class Context(object): |
134 | time.sleep(5) | 155 | time.sleep(5) |
135 | self.function.create() | 156 | self.function.create() |
136 | 157 | ||
158 | + def deploy(self): | ||
159 | + if self.policy: | ||
160 | + self.policy.deploy() | ||
161 | + if self.role: | ||
162 | + self.role.create() | ||
163 | + # There is a consistency problem here. | ||
164 | + # If you don't wait for a bit, the function.create call | ||
165 | + # will fail because the policy has not been attached to the role. | ||
166 | + LOG.debug('Waiting for policy/role propogation') | ||
167 | + time.sleep(5) | ||
168 | + self.function.deploy() | ||
169 | + | ||
137 | def update_code(self): | 170 | def update_code(self): |
138 | self.function.update() | 171 | self.function.update() |
139 | 172 | ... | ... |
1 | -# Copyright (c) 2014 Mitch Garnaat http://garnaat.org/ | 1 | +# Copyright (c) 2014, 2015 Mitch Garnaat |
2 | # | 2 | # |
3 | -# Licensed under the Apache License, Version 2.0 (the "License"). You | 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
4 | -# may not use this file except in compliance with the License. A copy of | 4 | +# you may not use this file except in compliance with the License. |
5 | -# the License is located at | 5 | +# You may obtain a copy of the License at |
6 | # | 6 | # |
7 | -# http://aws.amazon.com/apache2.0/ | 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
8 | # | 8 | # |
9 | -# or in the "license" file accompanying this file. This file is | 9 | +# Unless required by applicable law or agreed to in writing, software |
10 | -# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF | 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
11 | -# ANY KIND, either express or implied. See the License for the specific | 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | -# language governing permissions and limitations under the License. | 12 | +# See the License for the specific language governing permissions and |
13 | +# limitations under the License. | ||
13 | 14 | ||
14 | import logging | 15 | import logging |
15 | 16 | ||
16 | from botocore.exceptions import ClientError | 17 | from botocore.exceptions import ClientError |
17 | 18 | ||
18 | -import kappa.aws | 19 | +import kappa.awsclient |
19 | 20 | ||
20 | LOG = logging.getLogger(__name__) | 21 | LOG = logging.getLogger(__name__) |
21 | 22 | ||
... | @@ -47,12 +48,12 @@ class KinesisEventSource(EventSource): | ... | @@ -47,12 +48,12 @@ class KinesisEventSource(EventSource): |
47 | 48 | ||
48 | def __init__(self, context, config): | 49 | def __init__(self, context, config): |
49 | super(KinesisEventSource, self).__init__(context, config) | 50 | super(KinesisEventSource, self).__init__(context, config) |
50 | - aws = kappa.aws.get_aws(context) | 51 | + self._lambda = kappa.awsclient.create_client('kinesis', context) |
51 | - self._lambda = aws.create_client('lambda') | ||
52 | 52 | ||
53 | def _get_uuid(self, function): | 53 | def _get_uuid(self, function): |
54 | uuid = None | 54 | uuid = None |
55 | - response = self._lambda.list_event_source_mappings( | 55 | + response = self._lambda.call( |
56 | + 'list_event_source_mappings', | ||
56 | FunctionName=function.name, | 57 | FunctionName=function.name, |
57 | EventSourceArn=self.arn) | 58 | EventSourceArn=self.arn) |
58 | LOG.debug(response) | 59 | LOG.debug(response) |
... | @@ -62,7 +63,8 @@ class KinesisEventSource(EventSource): | ... | @@ -62,7 +63,8 @@ class KinesisEventSource(EventSource): |
62 | 63 | ||
63 | def add(self, function): | 64 | def add(self, function): |
64 | try: | 65 | try: |
65 | - response = self._lambda.create_event_source_mapping( | 66 | + response = self._lambda.call( |
67 | + 'create_event_source_mapping', | ||
66 | FunctionName=function.name, | 68 | FunctionName=function.name, |
67 | EventSourceArn=self.arn, | 69 | EventSourceArn=self.arn, |
68 | BatchSize=self.batch_size, | 70 | BatchSize=self.batch_size, |
... | @@ -78,7 +80,8 @@ class KinesisEventSource(EventSource): | ... | @@ -78,7 +80,8 @@ class KinesisEventSource(EventSource): |
78 | uuid = self._get_uuid(function) | 80 | uuid = self._get_uuid(function) |
79 | if uuid: | 81 | if uuid: |
80 | try: | 82 | try: |
81 | - response = self._lambda.update_event_source_mapping( | 83 | + response = self._lambda.call( |
84 | + 'update_event_source_mapping', | ||
82 | BatchSize=self.batch_size, | 85 | BatchSize=self.batch_size, |
83 | Enabled=self.enabled, | 86 | Enabled=self.enabled, |
84 | FunctionName=function.arn) | 87 | FunctionName=function.arn) |
... | @@ -90,7 +93,8 @@ class KinesisEventSource(EventSource): | ... | @@ -90,7 +93,8 @@ class KinesisEventSource(EventSource): |
90 | response = None | 93 | response = None |
91 | uuid = self._get_uuid(function) | 94 | uuid = self._get_uuid(function) |
92 | if uuid: | 95 | if uuid: |
93 | - response = self._lambda.delete_event_source_mapping( | 96 | + response = self._lambda.call( |
97 | + 'delete_event_source_mapping', | ||
94 | UUID=uuid) | 98 | UUID=uuid) |
95 | LOG.debug(response) | 99 | LOG.debug(response) |
96 | return response | 100 | return response |
... | @@ -101,7 +105,8 @@ class KinesisEventSource(EventSource): | ... | @@ -101,7 +105,8 @@ class KinesisEventSource(EventSource): |
101 | uuid = self._get_uuid(function) | 105 | uuid = self._get_uuid(function) |
102 | if uuid: | 106 | if uuid: |
103 | try: | 107 | try: |
104 | - response = self._lambda.get_event_source_mapping( | 108 | + response = self._lambda.call( |
109 | + 'get_event_source_mapping', | ||
105 | UUID=self._get_uuid(function)) | 110 | UUID=self._get_uuid(function)) |
106 | LOG.debug(response) | 111 | LOG.debug(response) |
107 | except ClientError: | 112 | except ClientError: |
... | @@ -121,8 +126,7 @@ class S3EventSource(EventSource): | ... | @@ -121,8 +126,7 @@ class S3EventSource(EventSource): |
121 | 126 | ||
122 | def __init__(self, context, config): | 127 | def __init__(self, context, config): |
123 | super(S3EventSource, self).__init__(context, config) | 128 | super(S3EventSource, self).__init__(context, config) |
124 | - aws = kappa.aws.get_aws(context) | 129 | + self._s3 = kappa.awsclient.create_client('s3', config) |
125 | - self._s3 = aws.create_client('s3') | ||
126 | 130 | ||
127 | def _make_notification_id(self, function_name): | 131 | def _make_notification_id(self, function_name): |
128 | return 'Kappa-%s-notification' % function_name | 132 | return 'Kappa-%s-notification' % function_name |
... | @@ -141,7 +145,8 @@ class S3EventSource(EventSource): | ... | @@ -141,7 +145,8 @@ class S3EventSource(EventSource): |
141 | ] | 145 | ] |
142 | } | 146 | } |
143 | try: | 147 | try: |
144 | - response = self._s3.put_bucket_notification_configuration( | 148 | + response = self._s3.call( |
149 | + 'put_bucket_notification_configuration', | ||
145 | Bucket=self._get_bucket_name(), | 150 | Bucket=self._get_bucket_name(), |
146 | NotificationConfiguration=notification_spec) | 151 | NotificationConfiguration=notification_spec) |
147 | LOG.debug(response) | 152 | LOG.debug(response) |
... | @@ -154,7 +159,8 @@ class S3EventSource(EventSource): | ... | @@ -154,7 +159,8 @@ class S3EventSource(EventSource): |
154 | 159 | ||
155 | def remove(self, function): | 160 | def remove(self, function): |
156 | LOG.debug('removing s3 notification') | 161 | LOG.debug('removing s3 notification') |
157 | - response = self._s3.get_bucket_notification( | 162 | + response = self._s3.call( |
163 | + 'get_bucket_notification', | ||
158 | Bucket=self._get_bucket_name()) | 164 | Bucket=self._get_bucket_name()) |
159 | LOG.debug(response) | 165 | LOG.debug(response) |
160 | if 'CloudFunctionConfiguration' in response: | 166 | if 'CloudFunctionConfiguration' in response: |
... | @@ -162,14 +168,16 @@ class S3EventSource(EventSource): | ... | @@ -162,14 +168,16 @@ class S3EventSource(EventSource): |
162 | if fn_arn == function.arn: | 168 | if fn_arn == function.arn: |
163 | del response['CloudFunctionConfiguration'] | 169 | del response['CloudFunctionConfiguration'] |
164 | del response['ResponseMetadata'] | 170 | del response['ResponseMetadata'] |
165 | - response = self._s3.put_bucket_notification( | 171 | + response = self._s3.call( |
172 | + 'put_bucket_notification', | ||
166 | Bucket=self._get_bucket_name(), | 173 | Bucket=self._get_bucket_name(), |
167 | NotificationConfiguration=response) | 174 | NotificationConfiguration=response) |
168 | LOG.debug(response) | 175 | LOG.debug(response) |
169 | 176 | ||
170 | def status(self, function): | 177 | def status(self, function): |
171 | LOG.debug('status for s3 notification for %s', function.name) | 178 | LOG.debug('status for s3 notification for %s', function.name) |
172 | - response = self._s3.get_bucket_notification( | 179 | + response = self._s3.call( |
180 | + 'get_bucket_notification', | ||
173 | Bucket=self._get_bucket_name()) | 181 | Bucket=self._get_bucket_name()) |
174 | LOG.debug(response) | 182 | LOG.debug(response) |
175 | if 'CloudFunctionConfiguration' not in response: | 183 | if 'CloudFunctionConfiguration' not in response: |
... | @@ -181,15 +189,15 @@ class SNSEventSource(EventSource): | ... | @@ -181,15 +189,15 @@ class SNSEventSource(EventSource): |
181 | 189 | ||
182 | def __init__(self, context, config): | 190 | def __init__(self, context, config): |
183 | super(SNSEventSource, self).__init__(context, config) | 191 | super(SNSEventSource, self).__init__(context, config) |
184 | - aws = kappa.aws.get_aws(context) | 192 | + self._sns = kappa.awsclient.create_client('sns', context) |
185 | - self._sns = aws.create_client('sns') | ||
186 | 193 | ||
187 | def _make_notification_id(self, function_name): | 194 | def _make_notification_id(self, function_name): |
188 | return 'Kappa-%s-notification' % function_name | 195 | return 'Kappa-%s-notification' % function_name |
189 | 196 | ||
190 | def exists(self, function): | 197 | def exists(self, function): |
191 | try: | 198 | try: |
192 | - response = self._sns.list_subscriptions_by_topic( | 199 | + response = self._sns.call( |
200 | + 'list_subscriptions_by_topic', | ||
193 | TopicArn=self.arn) | 201 | TopicArn=self.arn) |
194 | LOG.debug(response) | 202 | LOG.debug(response) |
195 | for subscription in response['Subscriptions']: | 203 | for subscription in response['Subscriptions']: |
... | @@ -201,7 +209,8 @@ class SNSEventSource(EventSource): | ... | @@ -201,7 +209,8 @@ class SNSEventSource(EventSource): |
201 | 209 | ||
202 | def add(self, function): | 210 | def add(self, function): |
203 | try: | 211 | try: |
204 | - response = self._sns.subscribe( | 212 | + response = self._sns.call( |
213 | + 'subscribe', | ||
205 | TopicArn=self.arn, Protocol='lambda', | 214 | TopicArn=self.arn, Protocol='lambda', |
206 | Endpoint=function.arn) | 215 | Endpoint=function.arn) |
207 | LOG.debug(response) | 216 | LOG.debug(response) |
... | @@ -216,7 +225,8 @@ class SNSEventSource(EventSource): | ... | @@ -216,7 +225,8 @@ class SNSEventSource(EventSource): |
216 | try: | 225 | try: |
217 | subscription = self.exists(function) | 226 | subscription = self.exists(function) |
218 | if subscription: | 227 | if subscription: |
219 | - response = self._sns.unsubscribe( | 228 | + response = self._sns.call( |
229 | + 'unsubscribe', | ||
220 | SubscriptionArn=subscription['SubscriptionArn']) | 230 | SubscriptionArn=subscription['SubscriptionArn']) |
221 | LOG.debug(response) | 231 | LOG.debug(response) |
222 | except Exception: | 232 | except Exception: | ... | ... |
1 | -# Copyright (c) 2014 Mitch Garnaat http://garnaat.org/ | 1 | +# Copyright (c) 2014, 2015 Mitch Garnaat |
2 | # | 2 | # |
3 | -# Licensed under the Apache License, Version 2.0 (the "License"). You | 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
4 | -# may not use this file except in compliance with the License. A copy of | 4 | +# you may not use this file except in compliance with the License. |
5 | -# the License is located at | 5 | +# You may obtain a copy of the License at |
6 | # | 6 | # |
7 | -# http://aws.amazon.com/apache2.0/ | 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
8 | # | 8 | # |
9 | -# or in the "license" file accompanying this file. This file is | 9 | +# Unless required by applicable law or agreed to in writing, software |
10 | -# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF | 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
11 | -# ANY KIND, either express or implied. See the License for the specific | 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | -# language governing permissions and limitations under the License. | 12 | +# See the License for the specific language governing permissions and |
13 | +# limitations under the License. | ||
13 | 14 | ||
14 | import logging | 15 | import logging |
15 | import os | 16 | import os |
16 | import zipfile | 17 | import zipfile |
18 | +import time | ||
17 | 19 | ||
18 | from botocore.exceptions import ClientError | 20 | from botocore.exceptions import ClientError |
19 | 21 | ||
20 | -import kappa.aws | 22 | +import kappa.awsclient |
21 | import kappa.log | 23 | import kappa.log |
22 | 24 | ||
23 | LOG = logging.getLogger(__name__) | 25 | LOG = logging.getLogger(__name__) |
... | @@ -28,14 +30,14 @@ class Function(object): | ... | @@ -28,14 +30,14 @@ class Function(object): |
28 | def __init__(self, context, config): | 30 | def __init__(self, context, config): |
29 | self._context = context | 31 | self._context = context |
30 | self._config = config | 32 | self._config = config |
31 | - aws = kappa.aws.get_aws(context) | 33 | + self._lambda_client = kappa.awsclient.create_client( |
32 | - self._lambda_svc = aws.create_client('lambda') | 34 | + 'lambda', context) |
33 | - self._arn = None | 35 | + self._response = None |
34 | self._log = None | 36 | self._log = None |
35 | 37 | ||
36 | @property | 38 | @property |
37 | def name(self): | 39 | def name(self): |
38 | - return self._config['name'] | 40 | + return self._context.name |
39 | 41 | ||
40 | @property | 42 | @property |
41 | def runtime(self): | 43 | def runtime(self): |
... | @@ -73,17 +75,44 @@ class Function(object): | ... | @@ -73,17 +75,44 @@ class Function(object): |
73 | def permissions(self): | 75 | def permissions(self): |
74 | return self._config.get('permissions', list()) | 76 | return self._config.get('permissions', list()) |
75 | 77 | ||
76 | - @property | 78 | + def _get_response(self): |
77 | - def arn(self): | 79 | + if self._response is None: |
78 | - if self._arn is None: | ||
79 | try: | 80 | try: |
80 | - response = self._lambda_svc.get_function( | 81 | + self._response = self._lambda_client.call( |
82 | + 'get_function', | ||
81 | FunctionName=self.name) | 83 | FunctionName=self.name) |
82 | - LOG.debug(response) | 84 | + LOG.debug(self._response) |
83 | - self._arn = response['Configuration']['FunctionArn'] | ||
84 | except Exception: | 85 | except Exception: |
85 | LOG.debug('Unable to find ARN for function: %s', self.name) | 86 | LOG.debug('Unable to find ARN for function: %s', self.name) |
86 | - return self._arn | 87 | + return self._response |
88 | + | ||
89 | + @property | ||
90 | + def code_sha_256(self): | ||
91 | + response = self._get_response() | ||
92 | + return response['Configuration']['CodeSha256'] | ||
93 | + | ||
94 | + @property | ||
95 | + def arn(self): | ||
96 | + response = self._get_response() | ||
97 | + return response['Configuration']['FunctionArn'] | ||
98 | + | ||
99 | + @property | ||
100 | + def version(self): | ||
101 | + response = self._get_response() | ||
102 | + return response['Configuration']['Version'] | ||
103 | + | ||
104 | + @property | ||
105 | + def repository_type(self): | ||
106 | + response = self._get_response() | ||
107 | + return response['Code']['RepositoryType'] | ||
108 | + | ||
109 | + @property | ||
110 | + def location(self): | ||
111 | + response = self._get_response() | ||
112 | + return response['Code']['Location'] | ||
113 | + | ||
114 | + def exists(self): | ||
115 | + return self._get_response() | ||
87 | 116 | ||
88 | @property | 117 | @property |
89 | def log(self): | 118 | def log(self): |
... | @@ -125,6 +154,8 @@ class Function(object): | ... | @@ -125,6 +154,8 @@ class Function(object): |
125 | self._zip_lambda_file(zipfile_name, lambda_fn) | 154 | self._zip_lambda_file(zipfile_name, lambda_fn) |
126 | 155 | ||
127 | def add_permissions(self): | 156 | def add_permissions(self): |
157 | + if self.permissions: | ||
158 | + time.sleep(5) | ||
128 | for permission in self.permissions: | 159 | for permission in self.permissions: |
129 | try: | 160 | try: |
130 | kwargs = { | 161 | kwargs = { |
... | @@ -138,7 +169,8 @@ class Function(object): | ... | @@ -138,7 +169,8 @@ class Function(object): |
138 | source_account = permission.get('source_account', None) | 169 | source_account = permission.get('source_account', None) |
139 | if source_account: | 170 | if source_account: |
140 | kwargs['SourceAccount'] = source_account | 171 | kwargs['SourceAccount'] = source_account |
141 | - response = self._lambda_svc.add_permission(**kwargs) | 172 | + response = self._lambda_client.call( |
173 | + 'add_permission', **kwargs) | ||
142 | LOG.debug(response) | 174 | LOG.debug(response) |
143 | except Exception: | 175 | except Exception: |
144 | LOG.exception('Unable to add permission') | 176 | LOG.exception('Unable to add permission') |
... | @@ -151,7 +183,8 @@ class Function(object): | ... | @@ -151,7 +183,8 @@ class Function(object): |
151 | LOG.debug('exec_role=%s', exec_role) | 183 | LOG.debug('exec_role=%s', exec_role) |
152 | try: | 184 | try: |
153 | zipdata = fp.read() | 185 | zipdata = fp.read() |
154 | - response = self._lambda_svc.create_function( | 186 | + response = self._lambda_client.call( |
187 | + 'create_function', | ||
155 | FunctionName=self.name, | 188 | FunctionName=self.name, |
156 | Code={'ZipFile': zipdata}, | 189 | Code={'ZipFile': zipdata}, |
157 | Runtime=self.runtime, | 190 | Runtime=self.runtime, |
... | @@ -168,21 +201,90 @@ class Function(object): | ... | @@ -168,21 +201,90 @@ class Function(object): |
168 | def update(self): | 201 | def update(self): |
169 | LOG.debug('updating %s', self.zipfile_name) | 202 | LOG.debug('updating %s', self.zipfile_name) |
170 | self.zip_lambda_function(self.zipfile_name, self.path) | 203 | self.zip_lambda_function(self.zipfile_name, self.path) |
171 | - with open(self.zipfile_name, 'rb') as fp: | 204 | + stats = os.stat(self.zipfile_name) |
172 | - try: | 205 | + if self._context.cache.get('zipfile_size') != stats.st_size: |
173 | - zipdata = fp.read() | 206 | + self._context.cache['zipfile_size'] = stats.st_size |
174 | - response = self._lambda_svc.update_function_code( | 207 | + self._context.save_cache() |
175 | - FunctionName=self.name, | 208 | + with open(self.zipfile_name, 'rb') as fp: |
176 | - ZipFile=zipdata) | 209 | + try: |
177 | - LOG.debug(response) | 210 | + zipdata = fp.read() |
178 | - except Exception: | 211 | + response = self._lambda_client.call( |
179 | - LOG.exception('Unable to update zip file') | 212 | + 'update_function_code', |
213 | + FunctionName=self.name, | ||
214 | + ZipFile=zipdata) | ||
215 | + LOG.debug(response) | ||
216 | + except Exception: | ||
217 | + LOG.exception('Unable to update zip file') | ||
218 | + else: | ||
219 | + LOG.info('Code has not changed') | ||
220 | + | ||
221 | + def deploy(self): | ||
222 | + if self.exists(): | ||
223 | + return self.update() | ||
224 | + return self.create() | ||
225 | + | ||
226 | + def publish_version(self, description): | ||
227 | + LOG.debug('publishing version of ', self.name) | ||
228 | + try: | ||
229 | + response = self._lambda_client.call( | ||
230 | + 'publish_version', | ||
231 | + FunctionName=self.name, | ||
232 | + CodeSha256=self.code_sha_256, | ||
233 | + Description=description) | ||
234 | + LOG.debug(response) | ||
235 | + except Exception: | ||
236 | + LOG.exception('Unable to publish version') | ||
237 | + return response['Version'] | ||
238 | + | ||
239 | + def list_versions(self): | ||
240 | + LOG.debug('listing versions of ', self.name) | ||
241 | + try: | ||
242 | + response = self._lambda_client.call( | ||
243 | + 'list_versions_by_function', | ||
244 | + FunctionName=self.name) | ||
245 | + LOG.debug(response) | ||
246 | + except Exception: | ||
247 | + LOG.exception('Unable to list versions') | ||
248 | + return response['Versions'] | ||
249 | + | ||
250 | + def create_alias(self, name, description, version=None): | ||
251 | + LOG.debug('creating alias of ', self.name) | ||
252 | + if version is None: | ||
253 | + version = self.version | ||
254 | + try: | ||
255 | + response = self._lambda_client.call( | ||
256 | + 'create_alias', | ||
257 | + FunctionName=self.name, | ||
258 | + Description=description, | ||
259 | + FunctionVersion=self.version, | ||
260 | + Name=name) | ||
261 | + LOG.debug(response) | ||
262 | + except Exception: | ||
263 | + LOG.exception('Unable to create alias') | ||
264 | + | ||
265 | + def list_aliases(self): | ||
266 | + LOG.debug('listing aliases of ', self.name) | ||
267 | + try: | ||
268 | + response = self._lambda_client.call( | ||
269 | + 'list_aliases', | ||
270 | + FunctionName=self.name, | ||
271 | + FunctionVersion=self.version) | ||
272 | + LOG.debug(response) | ||
273 | + except Exception: | ||
274 | + LOG.exception('Unable to list aliases') | ||
275 | + return response['Versions'] | ||
276 | + | ||
277 | + def tag(self, name, description): | ||
278 | + version = self.publish_version(description) | ||
279 | + self.create_alias(name, description, version) | ||
180 | 280 | ||
181 | def delete(self): | 281 | def delete(self): |
182 | LOG.debug('deleting function %s', self.name) | 282 | LOG.debug('deleting function %s', self.name) |
183 | response = None | 283 | response = None |
184 | try: | 284 | try: |
185 | - response = self._lambda_svc.delete_function(FunctionName=self.name) | 285 | + response = self._lambda_client.call( |
286 | + 'delete_function', | ||
287 | + FunctionName=self.name) | ||
186 | LOG.debug(response) | 288 | LOG.debug(response) |
187 | except ClientError: | 289 | except ClientError: |
188 | LOG.debug('function %s: not found', self.name) | 290 | LOG.debug('function %s: not found', self.name) |
... | @@ -191,7 +293,8 @@ class Function(object): | ... | @@ -191,7 +293,8 @@ class Function(object): |
191 | def status(self): | 293 | def status(self): |
192 | LOG.debug('getting status for function %s', self.name) | 294 | LOG.debug('getting status for function %s', self.name) |
193 | try: | 295 | try: |
194 | - response = self._lambda_svc.get_function( | 296 | + response = self._lambda_client.call( |
297 | + 'get_function', | ||
195 | FunctionName=self.name) | 298 | FunctionName=self.name) |
196 | LOG.debug(response) | 299 | LOG.debug(response) |
197 | except ClientError: | 300 | except ClientError: |
... | @@ -202,7 +305,8 @@ class Function(object): | ... | @@ -202,7 +305,8 @@ class Function(object): |
202 | def invoke_asynch(self, data_file): | 305 | def invoke_asynch(self, data_file): |
203 | LOG.debug('_invoke_async %s', data_file) | 306 | LOG.debug('_invoke_async %s', data_file) |
204 | with open(data_file) as fp: | 307 | with open(data_file) as fp: |
205 | - response = self._lambda_svc.invoke_async( | 308 | + response = self._lambda_client.call( |
309 | + 'invoke_async', | ||
206 | FunctionName=self.name, | 310 | FunctionName=self.name, |
207 | InvokeArgs=fp) | 311 | InvokeArgs=fp) |
208 | LOG.debug(response) | 312 | LOG.debug(response) |
... | @@ -212,7 +316,8 @@ class Function(object): | ... | @@ -212,7 +316,8 @@ class Function(object): |
212 | test_data = self.test_data | 316 | test_data = self.test_data |
213 | LOG.debug('invoke %s', test_data) | 317 | LOG.debug('invoke %s', test_data) |
214 | with open(test_data) as fp: | 318 | with open(test_data) as fp: |
215 | - response = self._lambda_svc.invoke( | 319 | + response = self._lambda_client.call( |
320 | + 'invoke', | ||
216 | FunctionName=self.name, | 321 | FunctionName=self.name, |
217 | InvocationType=invocation_type, | 322 | InvocationType=invocation_type, |
218 | LogType='Tail', | 323 | LogType='Tail', | ... | ... |
1 | -# Copyright (c) 2014 Mitch Garnaat http://garnaat.org/ | 1 | +# Copyright (c) 2014, 2015 Mitch Garnaat |
2 | # | 2 | # |
3 | -# Licensed under the Apache License, Version 2.0 (the "License"). You | 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
4 | -# may not use this file except in compliance with the License. A copy of | 4 | +# you may not use this file except in compliance with the License. |
5 | -# the License is located at | 5 | +# You may obtain a copy of the License at |
6 | # | 6 | # |
7 | -# http://aws.amazon.com/apache2.0/ | 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
8 | # | 8 | # |
9 | -# or in the "license" file accompanying this file. This file is | 9 | +# Unless required by applicable law or agreed to in writing, software |
10 | -# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF | 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
11 | -# ANY KIND, either express or implied. See the License for the specific | 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | -# language governing permissions and limitations under the License. | 12 | +# See the License for the specific language governing permissions and |
13 | +# limitations under the License. | ||
13 | 14 | ||
14 | import logging | 15 | import logging |
15 | 16 | ||
16 | from botocore.exceptions import ClientError | 17 | from botocore.exceptions import ClientError |
17 | 18 | ||
18 | -import kappa.aws | 19 | +import kappa.awsclient |
19 | 20 | ||
20 | LOG = logging.getLogger(__name__) | 21 | LOG = logging.getLogger(__name__) |
21 | 22 | ||
... | @@ -25,12 +26,11 @@ class Log(object): | ... | @@ -25,12 +26,11 @@ class Log(object): |
25 | def __init__(self, context, log_group_name): | 26 | def __init__(self, context, log_group_name): |
26 | self._context = context | 27 | self._context = context |
27 | self.log_group_name = log_group_name | 28 | self.log_group_name = log_group_name |
28 | - aws = kappa.aws.get_aws(self._context) | 29 | + self._log_client = kappa.awsclient.create_client('logs', context) |
29 | - self._log_svc = aws.create_client('logs') | ||
30 | 30 | ||
31 | def _check_for_log_group(self): | 31 | def _check_for_log_group(self): |
32 | LOG.debug('checking for log group') | 32 | LOG.debug('checking for log group') |
33 | - response = self._log_svc.describe_log_groups() | 33 | + response = self._log_client.call('describe_log_groups') |
34 | log_group_names = [lg['logGroupName'] for lg in response['logGroups']] | 34 | log_group_names = [lg['logGroupName'] for lg in response['logGroups']] |
35 | return self.log_group_name in log_group_names | 35 | return self.log_group_name in log_group_names |
36 | 36 | ||
... | @@ -40,7 +40,8 @@ class Log(object): | ... | @@ -40,7 +40,8 @@ class Log(object): |
40 | LOG.info( | 40 | LOG.info( |
41 | 'log group %s has not been created yet', self.log_group_name) | 41 | 'log group %s has not been created yet', self.log_group_name) |
42 | return [] | 42 | return [] |
43 | - response = self._log_svc.describe_log_streams( | 43 | + response = self._log_client.call( |
44 | + 'describe_log_streams', | ||
44 | logGroupName=self.log_group_name) | 45 | logGroupName=self.log_group_name) |
45 | LOG.debug(response) | 46 | LOG.debug(response) |
46 | return response['logStreams'] | 47 | return response['logStreams'] |
... | @@ -58,7 +59,8 @@ class Log(object): | ... | @@ -58,7 +59,8 @@ class Log(object): |
58 | latest_stream = stream | 59 | latest_stream = stream |
59 | elif stream['lastEventTimestamp'] > latest_stream['lastEventTimestamp']: | 60 | elif stream['lastEventTimestamp'] > latest_stream['lastEventTimestamp']: |
60 | latest_stream = stream | 61 | latest_stream = stream |
61 | - response = self._log_svc.get_log_events( | 62 | + response = self._log_client.call( |
63 | + 'get_log_events', | ||
62 | logGroupName=self.log_group_name, | 64 | logGroupName=self.log_group_name, |
63 | logStreamName=latest_stream['logStreamName']) | 65 | logStreamName=latest_stream['logStreamName']) |
64 | LOG.debug(response) | 66 | LOG.debug(response) |
... | @@ -66,7 +68,8 @@ class Log(object): | ... | @@ -66,7 +68,8 @@ class Log(object): |
66 | 68 | ||
67 | def delete(self): | 69 | def delete(self): |
68 | try: | 70 | try: |
69 | - response = self._log_svc.delete_log_group( | 71 | + response = self._log_client.call( |
72 | + 'delete_log_group', | ||
70 | logGroupName=self.log_group_name) | 73 | logGroupName=self.log_group_name) |
71 | LOG.debug(response) | 74 | LOG.debug(response) |
72 | except ClientError: | 75 | except ClientError: | ... | ... |
1 | -# Copyright (c) 2015 Mitch Garnaat http://garnaat.org/ | 1 | +# Copyright (c) 2014, 2015 Mitch Garnaat |
2 | # | 2 | # |
3 | -# Licensed under the Apache License, Version 2.0 (the "License"). You | 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
4 | -# may not use this file except in compliance with the License. A copy of | 4 | +# you may not use this file except in compliance with the License. |
5 | -# the License is located at | 5 | +# You may obtain a copy of the License at |
6 | # | 6 | # |
7 | -# http://aws.amazon.com/apache2.0/ | 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
8 | # | 8 | # |
9 | -# or in the "license" file accompanying this file. This file is | 9 | +# Unless required by applicable law or agreed to in writing, software |
10 | -# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF | 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
11 | -# ANY KIND, either express or implied. See the License for the specific | 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | -# language governing permissions and limitations under the License. | 12 | +# See the License for the specific language governing permissions and |
13 | +# limitations under the License. | ||
13 | 14 | ||
14 | import logging | 15 | import logging |
16 | +import json | ||
17 | +import hashlib | ||
15 | 18 | ||
16 | -import kappa.aws | 19 | +import kappa.awsclient |
17 | 20 | ||
18 | LOG = logging.getLogger(__name__) | 21 | LOG = logging.getLogger(__name__) |
19 | 22 | ||
... | @@ -23,21 +26,39 @@ class Policy(object): | ... | @@ -23,21 +26,39 @@ class Policy(object): |
23 | def __init__(self, context, config): | 26 | def __init__(self, context, config): |
24 | self._context = context | 27 | self._context = context |
25 | self._config = config | 28 | self._config = config |
26 | - aws = kappa.aws.get_aws(context) | 29 | + self._iam_client = kappa.awsclient.create_client('iam', self._context) |
27 | - self._iam_svc = aws.create_client('iam') | 30 | + self._arn = self._config['policy'].get('arn', None) |
28 | - self._arn = None | 31 | + |
32 | + @property | ||
33 | + def environment(self): | ||
34 | + return self._context.environment | ||
29 | 35 | ||
30 | @property | 36 | @property |
31 | def name(self): | 37 | def name(self): |
32 | - return self._config['name'] | 38 | + return '{}-{}-policy'.format(self._context.name, self.environment) |
33 | 39 | ||
34 | @property | 40 | @property |
35 | def description(self): | 41 | def description(self): |
36 | - return self._config.get('description', None) | 42 | + return 'A kappa policy to control access to {} resources'.format( |
43 | + self.environment) | ||
37 | 44 | ||
38 | - @property | ||
39 | def document(self): | 45 | def document(self): |
40 | - return self._config.get('document', None) | 46 | + if 'resources' not in self._config['policy']: |
47 | + return None | ||
48 | + document = {"Version": "2012-10-17"} | ||
49 | + statements = [] | ||
50 | + document['Statement'] = statements | ||
51 | + for resource in self._config['policy']['resources']: | ||
52 | + arn = resource['arn'] | ||
53 | + _, _, service, _ = arn.split(':', 3) | ||
54 | + statement = {"Effect": "Allow", | ||
55 | + "Resource": resource['arn']} | ||
56 | + actions = [] | ||
57 | + for action in resource['actions']: | ||
58 | + actions.append("{}:{}".format(service, action)) | ||
59 | + statement['Action'] = actions | ||
60 | + statements.append(statement) | ||
61 | + return json.dumps(document, indent=2, sort_keys=True) | ||
41 | 62 | ||
42 | @property | 63 | @property |
43 | def path(self): | 64 | def path(self): |
... | @@ -52,20 +73,21 @@ class Policy(object): | ... | @@ -52,20 +73,21 @@ class Policy(object): |
52 | return self._arn | 73 | return self._arn |
53 | 74 | ||
54 | def _find_all_policies(self): | 75 | def _find_all_policies(self): |
55 | - # boto3 does not currently do pagination | ||
56 | - # so we have to do it ourselves | ||
57 | - policies = [] | ||
58 | try: | 76 | try: |
59 | - response = self._iam_svc.list_policies() | 77 | + response = self._iam_client.call( |
60 | - policies += response['Policies'] | 78 | + 'list_policies') |
61 | - while response['IsTruncated']: | ||
62 | - LOG.debug('getting another page of policies') | ||
63 | - response = self._iam_svc.list_policies( | ||
64 | - Marker=response['Marker']) | ||
65 | - policies += response['Policies'] | ||
66 | except Exception: | 79 | except Exception: |
67 | LOG.exception('Error listing policies') | 80 | LOG.exception('Error listing policies') |
68 | - return policies | 81 | + return response['Policies'] |
82 | + | ||
83 | + def _list_versions(self): | ||
84 | + try: | ||
85 | + response = self._iam_client.call( | ||
86 | + 'list_policy_versions', | ||
87 | + PolicyArn=self.arn) | ||
88 | + except Exception: | ||
89 | + LOG.exception('Error listing policy versions') | ||
90 | + return response['Versions'] | ||
69 | 91 | ||
70 | def exists(self): | 92 | def exists(self): |
71 | for policy in self._find_all_policies(): | 93 | for policy in self._find_all_policies(): |
... | @@ -73,27 +95,72 @@ class Policy(object): | ... | @@ -73,27 +95,72 @@ class Policy(object): |
73 | return policy | 95 | return policy |
74 | return None | 96 | return None |
75 | 97 | ||
76 | - def create(self): | 98 | + def _add_policy_version(self): |
77 | - LOG.debug('creating policy %s', self.name) | 99 | + document = self.document() |
100 | + if not document: | ||
101 | + LOG.debug('not a custom policy, no need to version it') | ||
102 | + return | ||
103 | + versions = self._list_versions() | ||
104 | + if len(versions) == 5: | ||
105 | + try: | ||
106 | + response = self._iam_client.call( | ||
107 | + 'delete_policy_version', | ||
108 | + PolicyArn=self.arn, | ||
109 | + VersionId=versions[-1]['VersionId']) | ||
110 | + except Exception: | ||
111 | + LOG.exception('Unable to delete policy version') | ||
112 | + # update policy with a new version here | ||
113 | + try: | ||
114 | + response = self._iam_client.call( | ||
115 | + 'create_policy_version', | ||
116 | + PolicyArn=self.arn, | ||
117 | + PolicyDocument=document, | ||
118 | + SetAsDefault=True) | ||
119 | + LOG.debug(response) | ||
120 | + except Exception: | ||
121 | + LOG.exception('Error creating new Policy version') | ||
122 | + | ||
123 | + def deploy(self): | ||
124 | + LOG.debug('deploying policy %s', self.name) | ||
125 | + document = self.document() | ||
126 | + if not document: | ||
127 | + LOG.debug('not a custom policy, no need to create it') | ||
128 | + return | ||
78 | policy = self.exists() | 129 | policy = self.exists() |
79 | - if not policy and self.document: | 130 | + if policy: |
80 | - with open(self.document, 'rb') as fp: | 131 | + m = hashlib.md5() |
81 | - try: | 132 | + m.update(document) |
82 | - response = self._iam_svc.create_policy( | 133 | + policy_md5 = m.hexdigest() |
83 | - Path=self.path, PolicyName=self.name, | 134 | + LOG.debug('policy_md5: {}'.format(policy_md5)) |
84 | - PolicyDocument=fp.read(), | 135 | + LOG.debug('cache md5: {}'.format( |
85 | - Description=self.description) | 136 | + self._context.cache.get('policy_md5'))) |
86 | - LOG.debug(response) | 137 | + if policy_md5 != self._context.cache.get('policy_md5'): |
87 | - except Exception: | 138 | + self._context.cache['policy_md5'] = policy_md5 |
88 | - LOG.exception('Error creating Policy') | 139 | + self._context.save_cache() |
140 | + self._add_policy_version() | ||
141 | + else: | ||
142 | + LOG.info('Policy unchanged') | ||
143 | + else: | ||
144 | + # create a new policy | ||
145 | + try: | ||
146 | + response = self._iam_client.call( | ||
147 | + 'create_policy', | ||
148 | + Path=self.path, PolicyName=self.name, | ||
149 | + PolicyDocument=document, | ||
150 | + Description=self.description) | ||
151 | + LOG.debug(response) | ||
152 | + except Exception: | ||
153 | + LOG.exception('Error creating Policy') | ||
89 | 154 | ||
90 | def delete(self): | 155 | def delete(self): |
91 | response = None | 156 | response = None |
92 | # Only delete the policy if it has a document associated with it. | 157 | # Only delete the policy if it has a document associated with it. |
93 | # This indicates that it was a custom policy created by kappa. | 158 | # This indicates that it was a custom policy created by kappa. |
94 | - if self.arn and self.document: | 159 | + document = self.document() |
160 | + if self.arn and document: | ||
95 | LOG.debug('deleting policy %s', self.name) | 161 | LOG.debug('deleting policy %s', self.name) |
96 | - response = self._iam_svc.delete_policy(PolicyArn=self.arn) | 162 | + response = self._iam_client.call( |
163 | + 'delete_policy', PolicyArn=self.arn) | ||
97 | LOG.debug(response) | 164 | LOG.debug(response) |
98 | return response | 165 | return response |
99 | 166 | ... | ... |
1 | -# Copyright (c) 2015 Mitch Garnaat http://garnaat.org/ | 1 | +# Copyright (c) 2014, 2015 Mitch Garnaat |
2 | # | 2 | # |
3 | -# Licensed under the Apache License, Version 2.0 (the "License"). You | 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
4 | -# may not use this file except in compliance with the License. A copy of | 4 | +# you may not use this file except in compliance with the License. |
5 | -# the License is located at | 5 | +# You may obtain a copy of the License at |
6 | # | 6 | # |
7 | -# http://aws.amazon.com/apache2.0/ | 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
8 | # | 8 | # |
9 | -# or in the "license" file accompanying this file. This file is | 9 | +# Unless required by applicable law or agreed to in writing, software |
10 | -# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF | 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
11 | -# ANY KIND, either express or implied. See the License for the specific | 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | -# language governing permissions and limitations under the License. | 12 | +# See the License for the specific language governing permissions and |
13 | +# limitations under the License. | ||
13 | 14 | ||
14 | import logging | 15 | import logging |
15 | 16 | ||
16 | from botocore.exceptions import ClientError | 17 | from botocore.exceptions import ClientError |
17 | 18 | ||
18 | -import kappa.aws | 19 | +import kappa.awsclient |
19 | 20 | ||
20 | LOG = logging.getLogger(__name__) | 21 | LOG = logging.getLogger(__name__) |
21 | 22 | ||
... | @@ -39,20 +40,20 @@ class Role(object): | ... | @@ -39,20 +40,20 @@ class Role(object): |
39 | def __init__(self, context, config): | 40 | def __init__(self, context, config): |
40 | self._context = context | 41 | self._context = context |
41 | self._config = config | 42 | self._config = config |
42 | - aws = kappa.aws.get_aws(context) | 43 | + self._iam_client = kappa.awsclient.create_client('iam', context) |
43 | - self._iam_svc = aws.create_client('iam') | ||
44 | self._arn = None | 44 | self._arn = None |
45 | 45 | ||
46 | @property | 46 | @property |
47 | def name(self): | 47 | def name(self): |
48 | - return self._config['name'] | 48 | + return '{}-{}-role'.format( |
49 | + self._context.name, self._context.environment) | ||
49 | 50 | ||
50 | @property | 51 | @property |
51 | def arn(self): | 52 | def arn(self): |
52 | if self._arn is None: | 53 | if self._arn is None: |
53 | try: | 54 | try: |
54 | - response = self._iam_svc.get_role( | 55 | + response = self._iam_client.call( |
55 | - RoleName=self.name) | 56 | + 'get_role', RoleName=self.name) |
56 | LOG.debug(response) | 57 | LOG.debug(response) |
57 | self._arn = response['Role']['Arn'] | 58 | self._arn = response['Role']['Arn'] |
58 | except Exception: | 59 | except Exception: |
... | @@ -60,20 +61,11 @@ class Role(object): | ... | @@ -60,20 +61,11 @@ class Role(object): |
60 | return self._arn | 61 | return self._arn |
61 | 62 | ||
62 | def _find_all_roles(self): | 63 | def _find_all_roles(self): |
63 | - # boto3 does not currently do pagination | ||
64 | - # so we have to do it ourselves | ||
65 | - roles = [] | ||
66 | try: | 64 | try: |
67 | - response = self._iam_svc.list_roles() | 65 | + response = self._iam_client.call('list_roles') |
68 | - roles += response['Roles'] | ||
69 | - while response['IsTruncated']: | ||
70 | - LOG.debug('getting another page of roles') | ||
71 | - response = self._iam_svc.list_roles( | ||
72 | - Marker=response['Marker']) | ||
73 | - roles += response['Roles'] | ||
74 | except Exception: | 66 | except Exception: |
75 | LOG.exception('Error listing roles') | 67 | LOG.exception('Error listing roles') |
76 | - return roles | 68 | + return response['Roles'] |
77 | 69 | ||
78 | def exists(self): | 70 | def exists(self): |
79 | for role in self._find_all_roles(): | 71 | for role in self._find_all_roles(): |
... | @@ -86,13 +78,15 @@ class Role(object): | ... | @@ -86,13 +78,15 @@ class Role(object): |
86 | role = self.exists() | 78 | role = self.exists() |
87 | if not role: | 79 | if not role: |
88 | try: | 80 | try: |
89 | - response = self._iam_svc.create_role( | 81 | + response = self._iam_client.call( |
82 | + 'create_role', | ||
90 | Path=self.Path, RoleName=self.name, | 83 | Path=self.Path, RoleName=self.name, |
91 | AssumeRolePolicyDocument=AssumeRolePolicyDocument) | 84 | AssumeRolePolicyDocument=AssumeRolePolicyDocument) |
92 | LOG.debug(response) | 85 | LOG.debug(response) |
93 | if self._context.policy: | 86 | if self._context.policy: |
94 | LOG.debug('attaching policy %s', self._context.policy.arn) | 87 | LOG.debug('attaching policy %s', self._context.policy.arn) |
95 | - response = self._iam_svc.attach_role_policy( | 88 | + response = self._iam_client.call( |
89 | + 'attach_role_policy', | ||
96 | RoleName=self.name, | 90 | RoleName=self.name, |
97 | PolicyArn=self._context.policy.arn) | 91 | PolicyArn=self._context.policy.arn) |
98 | LOG.debug(response) | 92 | LOG.debug(response) |
... | @@ -106,10 +100,12 @@ class Role(object): | ... | @@ -106,10 +100,12 @@ class Role(object): |
106 | LOG.debug('First detach the policy from the role') | 100 | LOG.debug('First detach the policy from the role') |
107 | policy_arn = self._context.policy.arn | 101 | policy_arn = self._context.policy.arn |
108 | if policy_arn: | 102 | if policy_arn: |
109 | - response = self._iam_svc.detach_role_policy( | 103 | + response = self._iam_client.call( |
104 | + 'detach_role_policy', | ||
110 | RoleName=self.name, PolicyArn=policy_arn) | 105 | RoleName=self.name, PolicyArn=policy_arn) |
111 | LOG.debug(response) | 106 | LOG.debug(response) |
112 | - response = self._iam_svc.delete_role(RoleName=self.name) | 107 | + response = self._iam_client.call( |
108 | + 'delete_role', RoleName=self.name) | ||
113 | LOG.debug(response) | 109 | LOG.debug(response) |
114 | except ClientError: | 110 | except ClientError: |
115 | LOG.exception('role %s not found', self.name) | 111 | LOG.exception('role %s not found', self.name) |
... | @@ -118,7 +114,8 @@ class Role(object): | ... | @@ -118,7 +114,8 @@ class Role(object): |
118 | def status(self): | 114 | def status(self): |
119 | LOG.debug('getting status for role %s', self.name) | 115 | LOG.debug('getting status for role %s', self.name) |
120 | try: | 116 | try: |
121 | - response = self._iam_svc.get_role(RoleName=self.name) | 117 | + response = self._iam_client.call( |
118 | + 'get_role', RoleName=self.name) | ||
122 | LOG.debug(response) | 119 | LOG.debug(response) |
123 | except ClientError: | 120 | except ClientError: |
124 | LOG.debug('role %s not found', self.name) | 121 | LOG.debug('role %s not found', self.name) | ... | ... |
kappa/scripts/__init__.py
0 → 100644
1 | +# Copyright (c) 2014, 2015 Mitch Garnaat | ||
2 | +# | ||
3 | +# Licensed under the Apache License, Version 2.0 (the "License"); | ||
4 | +# you may not use this file except in compliance with the License. | ||
5 | +# You may obtain a copy of the License at | ||
6 | +# | ||
7 | +# http://www.apache.org/licenses/LICENSE-2.0 | ||
8 | +# | ||
9 | +# Unless required by applicable law or agreed to in writing, software | ||
10 | +# distributed under the License is distributed on an "AS IS" BASIS, | ||
11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
12 | +# See the License for the specific language governing permissions and | ||
13 | +# limitations under the License. |
1 | #!/usr/bin/env python | 1 | #!/usr/bin/env python |
2 | -# Copyright (c) 2014 Mitch Garnaat http://garnaat.org/ | 2 | +# Copyright (c) 2014, 2015 Mitch Garnaat http://garnaat.org/ |
3 | # | 3 | # |
4 | # Licensed under the Apache License, Version 2.0 (the "License"). You | 4 | # Licensed under the Apache License, Version 2.0 (the "License"). You |
5 | # may not use this file except in compliance with the License. A copy of | 5 | # may not use this file except in compliance with the License. A copy of |
... | @@ -11,8 +11,8 @@ | ... | @@ -11,8 +11,8 @@ |
11 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF | 11 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF |
12 | # ANY KIND, either express or implied. See the License for the specific | 12 | # ANY KIND, either express or implied. See the License for the specific |
13 | # language governing permissions and limitations under the License. | 13 | # language governing permissions and limitations under the License. |
14 | + | ||
14 | from datetime import datetime | 15 | from datetime import datetime |
15 | -import logging | ||
16 | import base64 | 16 | import base64 |
17 | 17 | ||
18 | import click | 18 | import click |
... | @@ -31,70 +31,97 @@ from kappa.context import Context | ... | @@ -31,70 +31,97 @@ from kappa.context import Context |
31 | default=False, | 31 | default=False, |
32 | help='Turn on debugging output' | 32 | help='Turn on debugging output' |
33 | ) | 33 | ) |
34 | +@click.option( | ||
35 | + '--environment', | ||
36 | + help='Specify which environment to work with' | ||
37 | +) | ||
34 | @click.pass_context | 38 | @click.pass_context |
35 | -def cli(ctx, config=None, debug=False): | 39 | +def cli(ctx, config=None, debug=False, environment=None): |
36 | config = config | 40 | config = config |
37 | ctx.obj['debug'] = debug | 41 | ctx.obj['debug'] = debug |
38 | ctx.obj['config'] = config | 42 | ctx.obj['config'] = config |
43 | + ctx.obj['environment'] = environment | ||
44 | + | ||
39 | 45 | ||
40 | @cli.command() | 46 | @cli.command() |
41 | @click.pass_context | 47 | @click.pass_context |
42 | -def create(ctx): | 48 | +def deploy(ctx): |
43 | - context = Context(ctx.obj['config'], ctx.obj['debug']) | 49 | + """Deploy the Lambda function and any policies and roles required""" |
44 | - click.echo('creating...') | 50 | + context = Context(ctx.obj['config'], ctx.obj['environment'], |
45 | - context.create() | 51 | + ctx.obj['debug']) |
52 | + click.echo('deploying...') | ||
53 | + context.deploy() | ||
46 | click.echo('...done') | 54 | click.echo('...done') |
47 | 55 | ||
56 | + | ||
48 | @cli.command() | 57 | @cli.command() |
49 | @click.pass_context | 58 | @click.pass_context |
50 | -def update_code(ctx): | 59 | +def tag(ctx): |
51 | - context = Context(ctx.obj['config'], ctx.obj['debug']) | 60 | + """Deploy the Lambda function and any policies and roles required""" |
52 | - click.echo('updating code...') | 61 | + context = Context(ctx.obj['config'], ctx.obj['environment'], |
53 | - context.update_code() | 62 | + ctx.obj['debug']) |
63 | + click.echo('deploying...') | ||
64 | + context.deploy() | ||
54 | click.echo('...done') | 65 | click.echo('...done') |
55 | 66 | ||
67 | + | ||
56 | @cli.command() | 68 | @cli.command() |
57 | @click.pass_context | 69 | @click.pass_context |
58 | def invoke(ctx): | 70 | def invoke(ctx): |
59 | - context = Context(ctx.obj['config'], ctx.obj['debug']) | 71 | + """Invoke the command synchronously""" |
72 | + context = Context(ctx.obj['config'], ctx.obj['environment'], | ||
73 | + ctx.obj['debug']) | ||
60 | click.echo('invoking...') | 74 | click.echo('invoking...') |
61 | response = context.invoke() | 75 | response = context.invoke() |
62 | log_data = base64.b64decode(response['LogResult']) | 76 | log_data = base64.b64decode(response['LogResult']) |
63 | click.echo(log_data) | 77 | click.echo(log_data) |
78 | + click.echo(response['Payload'].read()) | ||
64 | click.echo('...done') | 79 | click.echo('...done') |
65 | 80 | ||
81 | + | ||
66 | @cli.command() | 82 | @cli.command() |
67 | @click.pass_context | 83 | @click.pass_context |
68 | def dryrun(ctx): | 84 | def dryrun(ctx): |
69 | - context = Context(ctx.obj['config'], ctx.obj['debug']) | 85 | + """Show you what would happen but don't actually do anything""" |
86 | + context = Context(ctx.obj['config'], ctx.obj['environment'], | ||
87 | + ctx.obj['debug']) | ||
70 | click.echo('invoking dryrun...') | 88 | click.echo('invoking dryrun...') |
71 | response = context.dryrun() | 89 | response = context.dryrun() |
72 | click.echo(response) | 90 | click.echo(response) |
73 | click.echo('...done') | 91 | click.echo('...done') |
74 | 92 | ||
93 | + | ||
75 | @cli.command() | 94 | @cli.command() |
76 | @click.pass_context | 95 | @click.pass_context |
77 | def invoke_async(ctx): | 96 | def invoke_async(ctx): |
78 | - context = Context(ctx.obj['config'], ctx.obj['debug']) | 97 | + """Invoke the Lambda function asynchronously""" |
98 | + context = Context(ctx.obj['config'], ctx.obj['environment'], | ||
99 | + ctx.obj['debug']) | ||
79 | click.echo('invoking async...') | 100 | click.echo('invoking async...') |
80 | response = context.invoke_async() | 101 | response = context.invoke_async() |
81 | click.echo(response) | 102 | click.echo(response) |
82 | click.echo('...done') | 103 | click.echo('...done') |
83 | 104 | ||
105 | + | ||
84 | @cli.command() | 106 | @cli.command() |
85 | @click.pass_context | 107 | @click.pass_context |
86 | def tail(ctx): | 108 | def tail(ctx): |
87 | - context = Context(ctx.obj['config'], ctx.obj['debug']) | 109 | + """Show the last 10 lines of the log file""" |
110 | + context = Context(ctx.obj['config'], ctx.obj['environment'], | ||
111 | + ctx.obj['debug']) | ||
88 | click.echo('tailing logs...') | 112 | click.echo('tailing logs...') |
89 | for e in context.tail()[-10:]: | 113 | for e in context.tail()[-10:]: |
90 | ts = datetime.utcfromtimestamp(e['timestamp']//1000).isoformat() | 114 | ts = datetime.utcfromtimestamp(e['timestamp']//1000).isoformat() |
91 | click.echo("{}: {}".format(ts, e['message'])) | 115 | click.echo("{}: {}".format(ts, e['message'])) |
92 | click.echo('...done') | 116 | click.echo('...done') |
93 | 117 | ||
118 | + | ||
94 | @cli.command() | 119 | @cli.command() |
95 | @click.pass_context | 120 | @click.pass_context |
96 | def status(ctx): | 121 | def status(ctx): |
97 | - context = Context(ctx.obj['config'], ctx.obj['debug']) | 122 | + """Print a status of this Lambda function""" |
123 | + context = Context(ctx.obj['config'], ctx.obj['environment'], | ||
124 | + ctx.obj['debug']) | ||
98 | status = context.status() | 125 | status = context.status() |
99 | click.echo(click.style('Policy', bold=True)) | 126 | click.echo(click.style('Policy', bold=True)) |
100 | if status['policy']: | 127 | if status['policy']: |
... | @@ -126,30 +153,38 @@ def status(ctx): | ... | @@ -126,30 +153,38 @@ def status(ctx): |
126 | else: | 153 | else: |
127 | click.echo(click.style(' None', fg='green')) | 154 | click.echo(click.style(' None', fg='green')) |
128 | 155 | ||
156 | + | ||
129 | @cli.command() | 157 | @cli.command() |
130 | @click.pass_context | 158 | @click.pass_context |
131 | def delete(ctx): | 159 | def delete(ctx): |
132 | - context = Context(ctx.obj['config'], ctx.obj['debug']) | 160 | + """Delete the Lambda function and related policies and roles""" |
161 | + context = Context(ctx.obj['config'], ctx.obj['environment'], | ||
162 | + ctx.obj['debug']) | ||
133 | click.echo('deleting...') | 163 | click.echo('deleting...') |
134 | context.delete() | 164 | context.delete() |
135 | click.echo('...done') | 165 | click.echo('...done') |
136 | 166 | ||
167 | + | ||
137 | @cli.command() | 168 | @cli.command() |
138 | @click.pass_context | 169 | @click.pass_context |
139 | def add_event_sources(ctx): | 170 | def add_event_sources(ctx): |
140 | - context = Context(ctx.obj['config'], ctx.obj['debug']) | 171 | + """Add any event sources specified in the config file""" |
172 | + context = Context(ctx.obj['config'], ctx.obj['environment'], | ||
173 | + ctx.obj['debug']) | ||
141 | click.echo('adding event sources...') | 174 | click.echo('adding event sources...') |
142 | context.add_event_sources() | 175 | context.add_event_sources() |
143 | click.echo('...done') | 176 | click.echo('...done') |
144 | 177 | ||
178 | + | ||
145 | @cli.command() | 179 | @cli.command() |
146 | @click.pass_context | 180 | @click.pass_context |
147 | def update_event_sources(ctx): | 181 | def update_event_sources(ctx): |
148 | - context = Context(ctx.obj['config'], ctx.obj['debug']) | 182 | + """Update event sources specified in the config file""" |
183 | + context = Context(ctx.obj['config'], ctx.obj['environment'], | ||
184 | + ctx.obj['debug']) | ||
149 | click.echo('updating event sources...') | 185 | click.echo('updating event sources...') |
150 | context.update_event_sources() | 186 | context.update_event_sources() |
151 | click.echo('...done') | 187 | click.echo('...done') |
152 | 188 | ||
153 | 189 | ||
154 | -if __name__ == '__main__': | 190 | +cli(obj={}) |
155 | - cli(obj={}) | ... | ... |
... | @@ -5,8 +5,8 @@ from setuptools import setup, find_packages | ... | @@ -5,8 +5,8 @@ from setuptools import setup, find_packages |
5 | import os | 5 | import os |
6 | 6 | ||
7 | requires = [ | 7 | requires = [ |
8 | - 'boto3==1.1.1', | 8 | + 'boto3==1.2.0', |
9 | - 'click==4.0', | 9 | + 'click>=5.0', |
10 | 'PyYAML>=3.11' | 10 | 'PyYAML>=3.11' |
11 | ] | 11 | ] |
12 | 12 | ||
... | @@ -22,7 +22,10 @@ setup( | ... | @@ -22,7 +22,10 @@ setup( |
22 | packages=find_packages(exclude=['tests*']), | 22 | packages=find_packages(exclude=['tests*']), |
23 | package_data={'kappa': ['_version']}, | 23 | package_data={'kappa': ['_version']}, |
24 | package_dir={'kappa': 'kappa'}, | 24 | package_dir={'kappa': 'kappa'}, |
25 | - scripts=['bin/kappa'], | 25 | + entry_points=""" |
26 | + [console_scripts] | ||
27 | + kappa=kappa.scripts.cli:cli | ||
28 | + """, | ||
26 | install_requires=requires, | 29 | install_requires=requires, |
27 | license=open("LICENSE").read(), | 30 | license=open("LICENSE").read(), |
28 | classifiers=( | 31 | classifiers=( | ... | ... |
-
Please register or login to post a comment