1#!/usr/bin/python
2# This file is part of Ansible
3#
4# Ansible is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8#
9# Ansible is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
16
17
18ANSIBLE_METADATA = {'metadata_version': '1.1',
19                    'status': ['preview'],
20                    'supported_by': 'community'}
21
22
23DOCUMENTATION = '''
24---
25module: lambda
26short_description: Manage AWS Lambda functions
27description:
28     - Allows for the management of Lambda functions.
29version_added: '2.2'
30requirements: [ boto3 ]
31options:
32  name:
33    description:
34      - The name you want to assign to the function you are uploading. Cannot be changed.
35    required: true
36  state:
37    description:
38      - Create or delete Lambda function.
39    default: present
40    choices: [ 'present', 'absent' ]
41  runtime:
42    description:
43      - The runtime environment for the Lambda function you are uploading.
44      - Required when creating a function. Uses parameters as described in boto3 docs.
45      - Required when C(state=present).
46      - For supported list of runtimes, see U(https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html).
47  role:
48    description:
49      - The Amazon Resource Name (ARN) of the IAM role that Lambda assumes when it executes your function to access any other Amazon Web Services (AWS)
50        resources. You may use the bare ARN if the role belongs to the same AWS account.
51      - Required when C(state=present).
52  handler:
53    description:
54      - The function within your code that Lambda calls to begin execution.
55  zip_file:
56    description:
57      - A .zip file containing your deployment package
58      - If C(state=present) then either zip_file or s3_bucket must be present.
59    aliases: [ 'src' ]
60  s3_bucket:
61    description:
62      - Amazon S3 bucket name where the .zip file containing your deployment package is stored.
63      - If C(state=present) then either zip_file or s3_bucket must be present.
64      - C(s3_bucket) and C(s3_key) are required together.
65  s3_key:
66    description:
67      - The Amazon S3 object (the deployment package) key name you want to upload.
68      - C(s3_bucket) and C(s3_key) are required together.
69  s3_object_version:
70    description:
71      - The Amazon S3 object (the deployment package) version you want to upload.
72  description:
73    description:
74      - A short, user-defined function description. Lambda does not use this value. Assign a meaningful description as you see fit.
75  timeout:
76    description:
77      - The function maximum execution time in seconds after which Lambda should terminate the function.
78    default: 3
79  memory_size:
80    description:
81      - The amount of memory, in MB, your Lambda function is given.
82    default: 128
83  vpc_subnet_ids:
84    description:
85      - List of subnet IDs to run Lambda function in. Use this option if you need to access resources in your VPC. Leave empty if you don't want to run
86        the function in a VPC.
87  vpc_security_group_ids:
88    description:
89      - List of VPC security group IDs to associate with the Lambda function. Required when vpc_subnet_ids is used.
90  environment_variables:
91    description:
92      - A dictionary of environment variables the Lambda function is given.
93    aliases: [ 'environment' ]
94    version_added: "2.3"
95  dead_letter_arn:
96    description:
97      - The parent object that contains the target Amazon Resource Name (ARN) of an Amazon SQS queue or Amazon SNS topic.
98    version_added: "2.3"
99  tags:
100    description:
101      - tag dict to apply to the function (requires botocore 1.5.40 or above).
102    version_added: "2.5"
103author:
104    - 'Steyn Huizinga (@steynovich)'
105extends_documentation_fragment:
106    - aws
107    - ec2
108'''
109
110EXAMPLES = '''
111# Create Lambda functions
112- name: looped creation
113  lambda:
114    name: '{{ item.name }}'
115    state: present
116    zip_file: '{{ item.zip_file }}'
117    runtime: 'python2.7'
118    role: 'arn:aws:iam::987654321012:role/lambda_basic_execution'
119    handler: 'hello_python.my_handler'
120    vpc_subnet_ids:
121    - subnet-123abcde
122    - subnet-edcba321
123    vpc_security_group_ids:
124    - sg-123abcde
125    - sg-edcba321
126    environment_variables: '{{ item.env_vars }}'
127    tags:
128      key1: 'value1'
129  loop:
130    - name: HelloWorld
131      zip_file: hello-code.zip
132      env_vars:
133        key1: "first"
134        key2: "second"
135    - name: ByeBye
136      zip_file: bye-code.zip
137      env_vars:
138        key1: "1"
139        key2: "2"
140
141# To remove previously added tags pass an empty dict
142- name: remove tags
143  lambda:
144    name: 'Lambda function'
145    state: present
146    zip_file: 'code.zip'
147    runtime: 'python2.7'
148    role: 'arn:aws:iam::987654321012:role/lambda_basic_execution'
149    handler: 'hello_python.my_handler'
150    tags: {}
151
152# Basic Lambda function deletion
153- name: Delete Lambda functions HelloWorld and ByeBye
154  lambda:
155    name: '{{ item }}'
156    state: absent
157  loop:
158    - HelloWorld
159    - ByeBye
160'''
161
162RETURN = '''
163code:
164    description: the lambda function location returned by get_function in boto3
165    returned: success
166    type: dict
167    sample:
168      {
169        'location': 'a presigned S3 URL',
170        'repository_type': 'S3',
171      }
172configuration:
173    description: the lambda function metadata returned by get_function in boto3
174    returned: success
175    type: dict
176    sample:
177      {
178        'code_sha256': 'SHA256 hash',
179        'code_size': 123,
180        'description': 'My function',
181        'environment': {
182          'variables': {
183            'key': 'value'
184          }
185        },
186        'function_arn': 'arn:aws:lambda:us-east-1:123456789012:function:myFunction:1',
187        'function_name': 'myFunction',
188        'handler': 'index.handler',
189        'last_modified': '2017-08-01T00:00:00.000+0000',
190        'memory_size': 128,
191        'role': 'arn:aws:iam::123456789012:role/lambda_basic_execution',
192        'runtime': 'nodejs6.10',
193        'timeout': 3,
194        'version': '1',
195        'vpc_config': {
196          'security_group_ids': [],
197          'subnet_ids': []
198        }
199      }
200'''
201
202from ansible.module_utils._text import to_native
203from ansible.module_utils.aws.core import AnsibleAWSModule
204from ansible.module_utils.ec2 import get_aws_connection_info, boto3_conn, camel_dict_to_snake_dict
205from ansible.module_utils.ec2 import compare_aws_tags
206import base64
207import hashlib
208import traceback
209import re
210
211try:
212    from botocore.exceptions import ClientError, BotoCoreError, ValidationError, ParamValidationError
213except ImportError:
214    pass  # protected by AnsibleAWSModule
215
216
217def get_account_info(module, region=None, endpoint=None, **aws_connect_kwargs):
218    """return the account information (account id and partition) we are currently working on
219
220    get_account_info tries too find out the account that we are working
221    on.  It's not guaranteed that this will be easy so we try in
222    several different ways.  Giving either IAM or STS privileges to
223    the account should be enough to permit this.
224    """
225    account_id = None
226    partition = None
227    try:
228        sts_client = boto3_conn(module, conn_type='client', resource='sts',
229                                region=region, endpoint=endpoint, **aws_connect_kwargs)
230        caller_id = sts_client.get_caller_identity()
231        account_id = caller_id.get('Account')
232        partition = caller_id.get('Arn').split(':')[1]
233    except ClientError:
234        try:
235            iam_client = boto3_conn(module, conn_type='client', resource='iam',
236                                    region=region, endpoint=endpoint, **aws_connect_kwargs)
237            arn, partition, service, reg, account_id, resource = iam_client.get_user()['User']['Arn'].split(':')
238        except ClientError as e:
239            if (e.response['Error']['Code'] == 'AccessDenied'):
240                except_msg = to_native(e.message)
241                m = except_msg.search(r"arn:(aws(-([a-z\-]+))?):iam::([0-9]{12,32}):\w+/")
242                account_id = m.group(4)
243                partition = m.group(1)
244            if account_id is None:
245                module.fail_json_aws(e, msg="getting account information")
246            if partition is None:
247                module.fail_json_aws(e, msg="getting account information: partition")
248        except Exception as e:
249            module.fail_json_aws(e, msg="getting account information")
250
251    return account_id, partition
252
253
254def get_current_function(connection, function_name, qualifier=None):
255    try:
256        if qualifier is not None:
257            return connection.get_function(FunctionName=function_name, Qualifier=qualifier)
258        return connection.get_function(FunctionName=function_name)
259    except ClientError as e:
260        try:
261            if e.response['Error']['Code'] == 'ResourceNotFoundException':
262                return None
263        except (KeyError, AttributeError):
264            pass
265        raise e
266
267
268def sha256sum(filename):
269    hasher = hashlib.sha256()
270    with open(filename, 'rb') as f:
271        hasher.update(f.read())
272
273    code_hash = hasher.digest()
274    code_b64 = base64.b64encode(code_hash)
275    hex_digest = code_b64.decode('utf-8')
276
277    return hex_digest
278
279
280def set_tag(client, module, tags, function):
281    if not hasattr(client, "list_tags"):
282        module.fail_json(msg="Using tags requires botocore 1.5.40 or above")
283
284    changed = False
285    arn = function['Configuration']['FunctionArn']
286
287    try:
288        current_tags = client.list_tags(Resource=arn).get('Tags', {})
289    except ClientError as e:
290        module.fail_json(msg="Unable to list tags: {0}".format(to_native(e)),
291                         exception=traceback.format_exc())
292
293    tags_to_add, tags_to_remove = compare_aws_tags(current_tags, tags, purge_tags=True)
294
295    try:
296        if tags_to_remove:
297            client.untag_resource(
298                Resource=arn,
299                TagKeys=tags_to_remove
300            )
301            changed = True
302
303        if tags_to_add:
304            client.tag_resource(
305                Resource=arn,
306                Tags=tags_to_add
307            )
308            changed = True
309
310    except ClientError as e:
311        module.fail_json(msg="Unable to tag resource {0}: {1}".format(arn,
312                         to_native(e)), exception=traceback.format_exc(),
313                         **camel_dict_to_snake_dict(e.response))
314    except BotoCoreError as e:
315        module.fail_json(msg="Unable to tag resource {0}: {1}".format(arn,
316                         to_native(e)), exception=traceback.format_exc())
317
318    return changed
319
320
321def main():
322    argument_spec = dict(
323        name=dict(required=True),
324        state=dict(default='present', choices=['present', 'absent']),
325        runtime=dict(),
326        role=dict(),
327        handler=dict(),
328        zip_file=dict(aliases=['src']),
329        s3_bucket=dict(),
330        s3_key=dict(),
331        s3_object_version=dict(),
332        description=dict(default=''),
333        timeout=dict(type='int', default=3),
334        memory_size=dict(type='int', default=128),
335        vpc_subnet_ids=dict(type='list'),
336        vpc_security_group_ids=dict(type='list'),
337        environment_variables=dict(type='dict'),
338        dead_letter_arn=dict(),
339        tags=dict(type='dict'),
340    )
341
342    mutually_exclusive = [['zip_file', 's3_key'],
343                          ['zip_file', 's3_bucket'],
344                          ['zip_file', 's3_object_version']]
345
346    required_together = [['s3_key', 's3_bucket'],
347                         ['vpc_subnet_ids', 'vpc_security_group_ids']]
348
349    required_if = [['state', 'present', ['runtime', 'handler', 'role']]]
350
351    module = AnsibleAWSModule(argument_spec=argument_spec,
352                              supports_check_mode=True,
353                              mutually_exclusive=mutually_exclusive,
354                              required_together=required_together,
355                              required_if=required_if)
356
357    name = module.params.get('name')
358    state = module.params.get('state').lower()
359    runtime = module.params.get('runtime')
360    role = module.params.get('role')
361    handler = module.params.get('handler')
362    s3_bucket = module.params.get('s3_bucket')
363    s3_key = module.params.get('s3_key')
364    s3_object_version = module.params.get('s3_object_version')
365    zip_file = module.params.get('zip_file')
366    description = module.params.get('description')
367    timeout = module.params.get('timeout')
368    memory_size = module.params.get('memory_size')
369    vpc_subnet_ids = module.params.get('vpc_subnet_ids')
370    vpc_security_group_ids = module.params.get('vpc_security_group_ids')
371    environment_variables = module.params.get('environment_variables')
372    dead_letter_arn = module.params.get('dead_letter_arn')
373    tags = module.params.get('tags')
374
375    check_mode = module.check_mode
376    changed = False
377
378    region, ec2_url, aws_connect_kwargs = get_aws_connection_info(module, boto3=True)
379    if not region:
380        module.fail_json(msg='region must be specified')
381
382    try:
383        client = boto3_conn(module, conn_type='client', resource='lambda',
384                            region=region, endpoint=ec2_url, **aws_connect_kwargs)
385    except (ClientError, ValidationError) as e:
386        module.fail_json_aws(e, msg="Trying to connect to AWS")
387
388    if state == 'present':
389        if re.match(r'^arn:aws(-([a-z\-]+))?:iam', role):
390            role_arn = role
391        else:
392            # get account ID and assemble ARN
393            account_id, partition = get_account_info(module, region=region, endpoint=ec2_url, **aws_connect_kwargs)
394            role_arn = 'arn:{0}:iam::{1}:role/{2}'.format(partition, account_id, role)
395
396    # Get function configuration if present, False otherwise
397    current_function = get_current_function(client, name)
398
399    # Update existing Lambda function
400    if state == 'present' and current_function:
401
402        # Get current state
403        current_config = current_function['Configuration']
404        current_version = None
405
406        # Update function configuration
407        func_kwargs = {'FunctionName': name}
408
409        # Update configuration if needed
410        if role_arn and current_config['Role'] != role_arn:
411            func_kwargs.update({'Role': role_arn})
412        if handler and current_config['Handler'] != handler:
413            func_kwargs.update({'Handler': handler})
414        if description and current_config['Description'] != description:
415            func_kwargs.update({'Description': description})
416        if timeout and current_config['Timeout'] != timeout:
417            func_kwargs.update({'Timeout': timeout})
418        if memory_size and current_config['MemorySize'] != memory_size:
419            func_kwargs.update({'MemorySize': memory_size})
420        if (environment_variables is not None) and (current_config.get(
421                'Environment', {}).get('Variables', {}) != environment_variables):
422            func_kwargs.update({'Environment': {'Variables': environment_variables}})
423        if dead_letter_arn is not None:
424            if current_config.get('DeadLetterConfig'):
425                if current_config['DeadLetterConfig']['TargetArn'] != dead_letter_arn:
426                    func_kwargs.update({'DeadLetterConfig': {'TargetArn': dead_letter_arn}})
427            else:
428                if dead_letter_arn != "":
429                    func_kwargs.update({'DeadLetterConfig': {'TargetArn': dead_letter_arn}})
430
431        # Check for unsupported mutation
432        if current_config['Runtime'] != runtime:
433            module.fail_json(msg='Cannot change runtime. Please recreate the function')
434
435        # If VPC configuration is desired
436        if vpc_subnet_ids or vpc_security_group_ids:
437            if not vpc_subnet_ids or not vpc_security_group_ids:
438                module.fail_json(msg='vpc connectivity requires at least one security group and one subnet')
439
440            if 'VpcConfig' in current_config:
441                # Compare VPC config with current config
442                current_vpc_subnet_ids = current_config['VpcConfig']['SubnetIds']
443                current_vpc_security_group_ids = current_config['VpcConfig']['SecurityGroupIds']
444
445                subnet_net_id_changed = sorted(vpc_subnet_ids) != sorted(current_vpc_subnet_ids)
446                vpc_security_group_ids_changed = sorted(vpc_security_group_ids) != sorted(current_vpc_security_group_ids)
447
448            if 'VpcConfig' not in current_config or subnet_net_id_changed or vpc_security_group_ids_changed:
449                new_vpc_config = {'SubnetIds': vpc_subnet_ids,
450                                  'SecurityGroupIds': vpc_security_group_ids}
451                func_kwargs.update({'VpcConfig': new_vpc_config})
452        else:
453            # No VPC configuration is desired, assure VPC config is empty when present in current config
454            if 'VpcConfig' in current_config and current_config['VpcConfig'].get('VpcId'):
455                func_kwargs.update({'VpcConfig': {'SubnetIds': [], 'SecurityGroupIds': []}})
456
457        # Upload new configuration if configuration has changed
458        if len(func_kwargs) > 1:
459            try:
460                if not check_mode:
461                    response = client.update_function_configuration(**func_kwargs)
462                    current_version = response['Version']
463                changed = True
464            except (ParamValidationError, ClientError) as e:
465                module.fail_json_aws(e, msg="Trying to update lambda configuration")
466
467        # Update code configuration
468        code_kwargs = {'FunctionName': name, 'Publish': True}
469
470        # Update S3 location
471        if s3_bucket and s3_key:
472            # If function is stored on S3 always update
473            code_kwargs.update({'S3Bucket': s3_bucket, 'S3Key': s3_key})
474
475            # If S3 Object Version is given
476            if s3_object_version:
477                code_kwargs.update({'S3ObjectVersion': s3_object_version})
478
479        # Compare local checksum, update remote code when different
480        elif zip_file:
481            local_checksum = sha256sum(zip_file)
482            remote_checksum = current_config['CodeSha256']
483
484            # Only upload new code when local code is different compared to the remote code
485            if local_checksum != remote_checksum:
486                try:
487                    with open(zip_file, 'rb') as f:
488                        encoded_zip = f.read()
489                    code_kwargs.update({'ZipFile': encoded_zip})
490                except IOError as e:
491                    module.fail_json(msg=str(e), exception=traceback.format_exc())
492
493        # Tag Function
494        if tags is not None:
495            if set_tag(client, module, tags, current_function):
496                changed = True
497
498        # Upload new code if needed (e.g. code checksum has changed)
499        if len(code_kwargs) > 2:
500            try:
501                if not check_mode:
502                    response = client.update_function_code(**code_kwargs)
503                    current_version = response['Version']
504                changed = True
505            except (ParamValidationError, ClientError) as e:
506                module.fail_json_aws(e, msg="Trying to upload new code")
507
508        # Describe function code and configuration
509        response = get_current_function(client, name, qualifier=current_version)
510        if not response:
511            module.fail_json(msg='Unable to get function information after updating')
512
513        # We're done
514        module.exit_json(changed=changed, **camel_dict_to_snake_dict(response))
515
516    # Function doesn't exists, create new Lambda function
517    elif state == 'present':
518        if s3_bucket and s3_key:
519            # If function is stored on S3
520            code = {'S3Bucket': s3_bucket,
521                    'S3Key': s3_key}
522            if s3_object_version:
523                code.update({'S3ObjectVersion': s3_object_version})
524        elif zip_file:
525            # If function is stored in local zipfile
526            try:
527                with open(zip_file, 'rb') as f:
528                    zip_content = f.read()
529
530                code = {'ZipFile': zip_content}
531            except IOError as e:
532                module.fail_json(msg=str(e), exception=traceback.format_exc())
533
534        else:
535            module.fail_json(msg='Either S3 object or path to zipfile required')
536
537        func_kwargs = {'FunctionName': name,
538                       'Publish': True,
539                       'Runtime': runtime,
540                       'Role': role_arn,
541                       'Code': code,
542                       'Timeout': timeout,
543                       'MemorySize': memory_size,
544                       }
545
546        if description is not None:
547            func_kwargs.update({'Description': description})
548
549        if handler is not None:
550            func_kwargs.update({'Handler': handler})
551
552        if environment_variables:
553            func_kwargs.update({'Environment': {'Variables': environment_variables}})
554
555        if dead_letter_arn:
556            func_kwargs.update({'DeadLetterConfig': {'TargetArn': dead_letter_arn}})
557
558        # If VPC configuration is given
559        if vpc_subnet_ids or vpc_security_group_ids:
560            if not vpc_subnet_ids or not vpc_security_group_ids:
561                module.fail_json(msg='vpc connectivity requires at least one security group and one subnet')
562
563            func_kwargs.update({'VpcConfig': {'SubnetIds': vpc_subnet_ids,
564                                              'SecurityGroupIds': vpc_security_group_ids}})
565
566        # Finally try to create function
567        current_version = None
568        try:
569            if not check_mode:
570                response = client.create_function(**func_kwargs)
571                current_version = response['Version']
572            changed = True
573        except (ParamValidationError, ClientError) as e:
574            module.fail_json_aws(e, msg="Trying to create function")
575
576        # Tag Function
577        if tags is not None:
578            if set_tag(client, module, tags, get_current_function(client, name)):
579                changed = True
580
581        response = get_current_function(client, name, qualifier=current_version)
582        if not response:
583            module.fail_json(msg='Unable to get function information after creating')
584        module.exit_json(changed=changed, **camel_dict_to_snake_dict(response))
585
586    # Delete existing Lambda function
587    if state == 'absent' and current_function:
588        try:
589            if not check_mode:
590                client.delete_function(FunctionName=name)
591            changed = True
592        except (ParamValidationError, ClientError) as e:
593            module.fail_json_aws(e, msg="Trying to delete Lambda function")
594
595        module.exit_json(changed=changed)
596
597    # Function already absent, do nothing
598    elif state == 'absent':
599        module.exit_json(changed=changed)
600
601
602if __name__ == '__main__':
603    main()
604