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