diff --git a/changelogs/fragments/lambda-add-support-for-layers.yml b/changelogs/fragments/lambda-add-support-for-layers.yml new file mode 100644 index 00000000000..970456e1abc --- /dev/null +++ b/changelogs/fragments/lambda-add-support-for-layers.yml @@ -0,0 +1,3 @@ +--- +minor_changes: +- lambda - add support for function layers when creating or updating lambda function. diff --git a/plugins/modules/lambda.py b/plugins/modules/lambda.py index da947f6901d..7f8536cf3d1 100644 --- a/plugins/modules/lambda.py +++ b/plugins/modules/lambda.py @@ -119,6 +119,31 @@ choices: ['x86_64', 'arm64'] aliases: ['architectures'] version_added: 5.0.0 + layers: + description: + - A list of function layers to add to the function's execution environment. + - Specify each layer by its ARN, including the version. + suboptions: + layer_version_arn: + description: + - The ARN of the layer version. + - Mutually exclusive with I(layer_version_arn). + type: str + layer_name: + description: + - The name or Amazon Resource Name (ARN) of the layer. + - Mutually exclusive with I(layer_version_arn). + type: str + aliases: ['layer_arn'] + version: + description: + - The version number. + - Required when I(layer_name) is provided, ignored if not. + type: int + aliases: ['layer_version'] + type: list + elements: dict + version_added: 5.1.0 author: - 'Steyn Huizinga (@steynovich)' extends_documentation_fragment: @@ -178,6 +203,18 @@ loop: - HelloWorld - ByeBye + +# Create Lambda functions with function layers +- name: looped creation + amazon.aws.lambda: + name: 'HelloWorld' + state: present + zip_file: 'hello-code.zip' + runtime: 'python2.7' + role: 'arn:aws:iam::123456789012:role/lambda_basic_execution' + handler: 'hello_python.my_handler' + layers: + - layer_version_arn: 'arn:aws:lambda:us-east-1:123456789012:layer:python27-env:7' ''' RETURN = r''' @@ -328,12 +365,36 @@ 'subnet_ids': [], 'vpc_id': '123' } + layers: + description: The function's layers. + returned: on success + version_added: 5.1.0 + type: complex + contains: + arn: + description: The Amazon Resource Name (ARN) of the function layer. + returned: always + type: str + sample: active + code_size: + description: The size of the layer archive in bytes. + returned: always + type: str + signing_profile_version_arn: + description: The Amazon Resource Name (ARN) for a signing profile version. + returned: always + type: str + signing_job_arn: + description: The Amazon Resource Name (ARN) of a signing job. + returned: always + type: str ''' import base64 import hashlib import traceback import re +from collections import Counter try: from botocore.exceptions import ClientError, BotoCoreError, WaiterError @@ -393,6 +454,17 @@ def get_current_function(connection, function_name, qualifier=None): return None +def get_layer_version_arn(module, connection, layer_name, version_number): + try: + layer_versions = connection.list_layer_versions(LayerName=layer_name, aws_retry=True)['LayerVersions'] + for v in layer_versions: + if v["Version"] == version_number: + return v["LayerVersionArn"] + module.fail_json(msg='Unable to find version {0} from Lambda layer {1}'.format(version_number, layer_name)) + except is_boto3_error_code('ResourceNotFoundException'): + module.fail_json(msg='Lambda layer {0} not found'.format(layer_name)) + + def sha256sum(filename): hasher = hashlib.sha256() with open(filename, 'rb') as f: @@ -552,6 +624,21 @@ def main(): architecture=dict(choices=['x86_64', 'arm64'], type='str', aliases=['architectures']), tags=dict(type='dict', aliases=['resource_tags']), purge_tags=dict(type='bool', default=True), + layers=dict( + type='list', + elements='dict', + options=dict( + layer_version_arn=dict(type='str'), + layer_name=dict(type='str', aliases=['layer_arn']), + version=dict(type='int', aliases=['layer_version']), + ), + required_together=[['layer_name', 'version']], + required_one_of=[['layer_version_arn', 'layer_name']], + mutually_exclusive=[ + ['layer_name', 'layer_version_arn'], + ['version', 'layer_version_arn'] + ], + ), ) mutually_exclusive = [['zip_file', 's3_key'], @@ -594,6 +681,7 @@ def main(): purge_tags = module.params.get('purge_tags') kms_key_arn = module.params.get('kms_key_arn') architectures = module.params.get('architecture') + layers = [] check_mode = module.check_mode changed = False @@ -615,6 +703,14 @@ def main(): account_id, partition = get_account_info(module) role_arn = 'arn:{0}:iam::{1}:role/{2}'.format(partition, account_id, role) + # create list of layer version arn + if module.params.get("layers"): + for layer in module.params.get("layers"): + layer_version_arn = layer.get("layer_version_arn") + if layer_version_arn is None: + layer_version_arn = get_layer_version_arn(module, client, layer.get("layer_name"), layer.get("version")) + layers.append(layer_version_arn) + # Get function configuration if present, False otherwise current_function = get_current_function(client, name) @@ -676,6 +772,13 @@ def main(): if 'VpcConfig' in current_config and current_config['VpcConfig'].get('VpcId'): func_kwargs.update({'VpcConfig': {'SubnetIds': [], 'SecurityGroupIds': []}}) + # Check layers + if layers: + # compare two lists to see if the target layers are equal to the current + current_layers = current_config.get('Layers', []) + if Counter(layers) != Counter((f['Arn'] for f in current_layers)): + func_kwargs.update({'Layers': layers}) + # Upload new configuration if configuration has changed if len(func_kwargs) > 1: if not check_mode: @@ -760,6 +863,10 @@ def main(): func_kwargs.update({'VpcConfig': {'SubnetIds': vpc_subnet_ids, 'SecurityGroupIds': vpc_security_group_ids}}) + # Layers + if layers: + func_kwargs.update({'Layers': layers}) + # Tag Function if tags: func_kwargs.update({'Tags': tags}) diff --git a/tests/integration/targets/lambda/defaults/main.yml b/tests/integration/targets/lambda/defaults/main.yml index 3d6130161ad..63414fbfd92 100644 --- a/tests/integration/targets/lambda/defaults/main.yml +++ b/tests/integration/targets/lambda/defaults/main.yml @@ -6,3 +6,8 @@ lambda_role_name: ansible-test-{{ tiny_prefix }}-lambda lambda_python_runtime: python3.9 lambda_python_handler: mini_lambda.handler +lambda_python_layers_names: + - "{{ tiny_prefix }}-layer-01" + - "{{ tiny_prefix }}-layer-02" +lambda_function_name_with_layer: '{{ tiny_prefix }}-func-with-layer' +lambda_function_name_with_multiple_layer: '{{ tiny_prefix }}-func-with-mutiplelayer' diff --git a/tests/integration/targets/lambda/meta/main.yml b/tests/integration/targets/lambda/meta/main.yml index 7ac58d94183..409583a2c09 100644 --- a/tests/integration/targets/lambda/meta/main.yml +++ b/tests/integration/targets/lambda/meta/main.yml @@ -2,3 +2,4 @@ dependencies: - role: setup_botocore_pip vars: botocore_version: 1.21.51 +- role: setup_remote_tmp_dir diff --git a/tests/integration/targets/lambda/tasks/main.yml b/tests/integration/targets/lambda/tasks/main.yml index d6ca80acfee..d07790bda79 100644 --- a/tests/integration/targets/lambda/tasks/main.yml +++ b/tests/integration/targets/lambda/tasks/main.yml @@ -104,6 +104,45 @@ - result.changed == False - '"parameters are required together" in result.msg' + - name: test state=present with incomplete layers + lambda: + name: '{{ lambda_function_name }}' + runtime: '{{ lambda_python_runtime }}' + role: '{{ lambda_role_name }}' + handler: mini_lambda.handler + zip_file: '{{ zip_res.dest }}' + layers: + - layer_name: test-layer + check_mode: true + register: result + ignore_errors: true + - name: assert lambda fails with proper message + assert: + that: + - result is failed + - result is not changed + - '"parameters are required together: layer_name, version found in layers" in result.msg' + + - name: test state=present with incomplete layers + lambda: + name: '{{ lambda_function_name }}' + runtime: '{{ lambda_python_runtime }}' + role: '{{ lambda_role_name }}' + handler: mini_lambda.handler + zip_file: '{{ zip_res.dest }}' + layers: + - layer_version_arn: 'arn:aws:lambda:us-east-2:123456789012:layer:blank-java-lib:7' + version: 9 + check_mode: true + register: result + ignore_errors: true + - name: assert lambda fails with proper message + assert: + that: + - result is failed + - result is not changed + - '"parameters are mutually exclusive: version|layer_version_arn found in layers" in result.msg' + # Prepare minimal Lambda - name: test state=present - upload the lambda (check mode) lambda: @@ -600,9 +639,132 @@ assert: that: - result is not failed + + # Test creation with layers + - name: Create temporary directory for testing + tempfile: + suffix: lambda + state: directory + register: test_dir + + - name: Create python directory for lambda layer + file: + path: "{{ remote_tmp_dir }}/python" + state: directory + + - name: Create lambda layer library + copy: + content: | + def hello(): + print("Hello from the ansible amazon.aws lambda layer") + return 1 + dest: "{{ remote_tmp_dir }}/python/lambda_layer.py" + + - name: Create lambda layer archive + archive: + format: zip + path: "{{ remote_tmp_dir }}" + dest: "{{ remote_tmp_dir }}/lambda_layer.zip" + + - name: Create lambda layer + lambda_layer: + name: "{{ lambda_python_layers_names[0] }}" + description: '{{ lambda_python_layers_names[0] }} lambda layer' + content: + zip_file: "{{ remote_tmp_dir }}/lambda_layer.zip" + register: first_layer + + - name: Create another lambda layer + lambda_layer: + name: "{{ lambda_python_layers_names[1] }}" + description: '{{ lambda_python_layers_names[1] }} lambda layer' + content: + zip_file: "{{ remote_tmp_dir }}/lambda_layer.zip" + register: second_layer + + - name: Create lambda function with layers + lambda: + name: '{{ lambda_function_name_with_layer }}' + runtime: '{{ lambda_python_runtime }}' + handler: '{{ lambda_python_handler }}' + role: '{{ lambda_role_name }}' + zip_file: '{{ zip_res.dest }}' + layers: + - layer_version_arn: "{{ first_layer.layer_versions.0.layer_version_arn }}" + register: result + - name: Validate that lambda function was created with expected property + assert: + that: + - result is changed + - '"layers" in result.configuration' + - result.configuration.layers | length == 1 + - result.configuration.layers.0.arn == first_layer.layer_versions.0.layer_version_arn + + - name: Create lambda function with layers once again (validate idempotency) + lambda: + name: '{{ lambda_function_name_with_layer }}' + runtime: '{{ lambda_python_runtime }}' + handler: '{{ lambda_python_handler }}' + role: '{{ lambda_role_name }}' + zip_file: '{{ zip_res.dest }}' + layers: + - layer_version_arn: "{{ first_layer.layer_versions.0.layer_version_arn }}" + register: result + - name: Validate that no change were made + assert: + that: + - result is not changed + + - name: Create lambda function with mutiple layers + lambda: + name: '{{ lambda_function_name_with_multiple_layer }}' + runtime: '{{ lambda_python_runtime }}' + handler: '{{ lambda_python_handler }}' + role: '{{ lambda_role_name }}' + zip_file: '{{ zip_res.dest }}' + layers: + - layer_version_arn: "{{ first_layer.layer_versions.0.layer_version_arn }}" + - layer_name: "{{ second_layer.layer_versions.0.layer_arn }}" + version: "{{ second_layer.layer_versions.0.version }}" + register: result + - name: Validate that lambda function was created with expected property + assert: + that: + - result is changed + - '"layers" in result.configuration' + - result.configuration.layers | length == 2 + - first_layer.layer_versions.0.layer_version_arn in lambda_layer_versions + - second_layer.layer_versions.0.layer_version_arn in lambda_layer_versions + vars: + lambda_layer_versions: "{{ result.configuration.layers | map(attribute='arn') | list }}" + + - name: Create lambda function with mutiple layers and changing layers order (idempotency) + lambda: + name: '{{ lambda_function_name_with_multiple_layer }}' + runtime: '{{ lambda_python_runtime }}' + handler: '{{ lambda_python_handler }}' + role: '{{ lambda_role_name }}' + zip_file: '{{ zip_res.dest }}' + layers: + - layer_version_arn: "{{ second_layer.layer_versions.0.layer_version_arn }}" + - layer_name: "{{ first_layer.layer_versions.0.layer_arn }}" + version: "{{ first_layer.layer_versions.0.version }}" + register: result + - name: Validate that lambda function was created with expected property + assert: + that: + - result is not changed always: + - name: Delete lambda layers + lambda_layer: + name: "{{ item }}" + version: -1 + state: absent + ignore_errors: true + with_items: "{{ lambda_python_layers_names }}" + - name: ensure functions are absent at end of test lambda: name: '{{ item }}'