diff --git a/changelogs/fragments/20251013-cloudfront_function.yml b/changelogs/fragments/20251013-cloudfront_function.yml new file mode 100644 index 00000000000..ca29bc5cf68 --- /dev/null +++ b/changelogs/fragments/20251013-cloudfront_function.yml @@ -0,0 +1,3 @@ +--- +minor_changes: + - cloudfront_function - new module to manage AWS CloudFront Functions (https://github.com/ansible-collections/community.aws/pull/2345). diff --git a/meta/runtime.yml b/meta/runtime.yml index 15743d53e91..064d9123233 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -66,6 +66,7 @@ action_groups: - cloudformation_stack_set - cloudfront_distribution - cloudfront_distribution_info + - cloudfront_function - cloudfront_info - cloudfront_invalidation - cloudfront_origin_access_identity diff --git a/plugins/modules/cloudfront_function.py b/plugins/modules/cloudfront_function.py new file mode 100644 index 00000000000..2b286539193 --- /dev/null +++ b/plugins/modules/cloudfront_function.py @@ -0,0 +1,442 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2025 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: cloudfront_function +version_added: 10.1.0 +short_description: Manage AWS CloudFront Functions +description: + - Create, update, publish, or delete AWS CloudFront Functions. + - Idempotent and supports check mode. + +author: + - Nicolas Boutet (@boutetnico) + +options: + name: + description: + - Name of the CloudFront Function. + required: true + type: str + state: + description: + - Desired state of the function. + - C(present) ensures the function exists but may be in DEVELOPMENT stage. + - C(published) ensures the function exists and is published to LIVE stage. + - C(absent) ensures the function is deleted. + choices: [present, published, absent] + default: present + type: str + comment: + description: + - Comment for the function. + - Required when creating a new function. + type: str + default: '' + runtime: + description: + - Runtime of the function. + type: str + default: cloudfront-js-2.0 + choices: ['cloudfront-js-1.0', 'cloudfront-js-2.0'] + code: + description: + - The JavaScript source code for the CloudFront function. + - Can be provided as inline code or loaded from a file using the C(lookup) plugin. + - Required when I(state=present) or I(state=published) and function does not exist. + - If not provided for existing functions, only metadata will be updated. + type: str + +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +- name: Create a CloudFront function with inline code + community.aws.cloudfront_function: + name: simple-redirect + state: present + comment: "Simple redirect function" + runtime: cloudfront-js-2.0 + code: | + function handler(event) { + var request = event.request; + var response = { + statusCode: 301, + statusDescription: 'Moved Permanently', + headers: { + 'location': { value: 'https://example.com/' } + } + }; + return response; + } + +- name: Create a CloudFront function from file + community.aws.cloudfront_function: + name: edge-logic + state: present + comment: "Edge logic for viewer requests" + runtime: cloudfront-js-2.0 + code: "{{ lookup('file', 'files/cloudfront_functions/edge-logic.js') }}" + +- name: Update and publish function code + community.aws.cloudfront_function: + name: edge-logic + state: published + comment: "Updated edge logic" + code: "{{ lookup('file', 'files/cloudfront_functions/edge-logic-v2.js') }}" + +- name: Publish an existing function to LIVE (without code update) + community.aws.cloudfront_function: + name: edge-logic + state: published + code: "{{ lookup('file', 'files/cloudfront_functions/edge-logic.js') }}" + +- name: Remove a function + community.aws.cloudfront_function: + name: edge-logic + state: absent +""" + +RETURN = r""" +changed: + description: Whether a change occurred. + type: bool + returned: always +msg: + description: Operation result message. + type: str + returned: always +function: + description: Details of the CloudFront Function. + type: dict + returned: when state != absent + contains: + name: + description: Name of the function. + type: str + returned: always + arn: + description: ARN of the function. + type: str + returned: always + status: + description: Status of the function (DEVELOPMENT or LIVE). + type: str + returned: always + stage: + description: Stage of the function (DEVELOPMENT or LIVE). + type: str + returned: always + comment: + description: Comment for the function. + type: str + returned: always + runtime: + description: Runtime of the function. + type: str + returned: always + etag: + description: ETag of the function. + type: str + returned: always +""" + +from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry + +from ansible_collections.community.aws.plugins.module_utils.modules import AnsibleCommunityAWSModule as AnsibleAWSModule + +try: + from botocore.exceptions import BotoCoreError + from botocore.exceptions import ClientError +except ImportError: + pass # Handled by AnsibleAWSModule + +import hashlib + + +def get_function(client, name, stage="DEVELOPMENT"): + """Get function description or None if it doesn't exist.""" + try: + return client.describe_function(Name=name, Stage=stage) + except ClientError as e: + if e.response["Error"]["Code"] == "NoSuchFunctionExists": + return None + raise + + +def is_function_published(client, name): + """Check if function exists in LIVE stage.""" + try: + client.describe_function(Name=name, Stage="LIVE") + return True + except ClientError as e: + if e.response["Error"]["Code"] == "NoSuchFunctionExists": + return False + raise + + +def get_function_code_hash(client, name, stage="DEVELOPMENT"): + """Get SHA256 hash of function code.""" + try: + resp = client.get_function(Name=name, Stage=stage) + # FunctionCode is a StreamingBody object, read it directly + code_data = resp["FunctionCode"].read() + return hashlib.sha256(code_data).hexdigest() + except ClientError as e: + if e.response["Error"]["Code"] == "NoSuchFunctionExists": + return None + raise + + +def create_function(client, module, name, comment, runtime, code_bytes): + """Create a new CloudFront function.""" + if module.check_mode: + return True, None + + try: + response = client.create_function( + Name=name, + FunctionConfig={ + "Comment": comment, + "Runtime": runtime, + }, + FunctionCode=code_bytes, + ) + return True, response + except (BotoCoreError, ClientError) as e: + module.fail_json_aws(e, msg=f"Failed to create function {name}") + + +def update_function(client, module, name, etag, comment, runtime, code_bytes): + """Update an existing CloudFront function.""" + if module.check_mode: + return True, None + + try: + response = client.update_function( + Name=name, + IfMatch=etag, + FunctionConfig={ + "Comment": comment, + "Runtime": runtime, + }, + FunctionCode=code_bytes, + ) + return True, response + except (BotoCoreError, ClientError) as e: + module.fail_json_aws(e, msg=f"Failed to update function {name}") + + +def publish_function(client, module, name, etag): + """Publish function to LIVE stage.""" + if module.check_mode: + return True, None + + try: + response = client.publish_function( + Name=name, + IfMatch=etag, + ) + return True, response + except (BotoCoreError, ClientError) as e: + module.fail_json_aws(e, msg=f"Failed to publish function {name}") + + +def delete_function(client, module, name, etag): + """Delete a CloudFront function.""" + if module.check_mode: + return True + + try: + client.delete_function( + Name=name, + IfMatch=etag, + ) + return True + except (BotoCoreError, ClientError) as e: + module.fail_json_aws(e, msg=f"Failed to delete function {name}") + + +def format_function_output(func_description): + """Format function details for output.""" + if not func_description: + return None + + summary = func_description.get("FunctionSummary", {}) + metadata = summary.get("FunctionMetadata", {}) + config = summary.get("FunctionConfig", {}) + + return { + "name": summary.get("Name"), + "arn": metadata.get("FunctionARN"), + "status": summary.get("Status"), + "stage": metadata.get("Stage"), + "comment": config.get("Comment", ""), + "runtime": config.get("Runtime"), + "etag": func_description.get("ETag"), + "last_modified_time": metadata.get("LastModifiedTime"), + } + + +def main(): + argument_spec = dict( + name=dict(required=True, type="str"), + state=dict(default="present", choices=["present", "published", "absent"]), + comment=dict(default="", type="str"), + runtime=dict( + default="cloudfront-js-2.0", + type="str", + choices=["cloudfront-js-1.0", "cloudfront-js-2.0"], + ), + code=dict(type="str"), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_if=[ + ["state", "present", ["code"], True], + ["state", "published", ["code"], True], + ], + ) + + name = module.params["name"] + state = module.params["state"] + comment = module.params["comment"] + runtime = module.params["runtime"] + code_string = module.params["code"] + + # Use module.client() to properly handle AWS credentials and config + try: + client = module.client("cloudfront", retry_decorator=AWSRetry.jittered_backoff()) + except (BotoCoreError, ClientError) as e: + module.fail_json_aws(e, msg="Failed to connect to AWS") + + changed = False + msg = "" + + # Get current function state + current = get_function(client, name) + + # Handle absent state + if state == "absent": + if not current: + module.exit_json(changed=False, msg="Function does not exist") + + etag = current["ETag"] + changed = delete_function(client, module, name, etag) + module.exit_json(changed=changed, msg="Function deleted") + + # Process code string if provided + code_bytes = None + local_hash = None + if code_string: + code_bytes = code_string.encode("utf-8") + local_hash = hashlib.sha256(code_bytes).hexdigest() + + # Handle function creation + if not current: + if not code_bytes: + module.fail_json(msg="code parameter is required when creating a new function") + + changed, create_response = create_function(client, module, name, comment, runtime, code_bytes) + + if not module.check_mode: + current = get_function(client, name) + + # Publish if state is published + if state == "published": + etag = current["ETag"] + publish_function(client, module, name, etag) + current = get_function(client, name) + + result = { + "changed": changed, + "msg": ("Function created and published" if state == "published" else "Function created"), + } + if not module.check_mode: + result["function"] = format_function_output(current) + module.exit_json(**result) + + # Function exists - check if update is needed + etag = current["ETag"] + summary = current.get("FunctionSummary", {}) + config = summary.get("FunctionConfig", {}) + + # Get remote code hash if we have local code to compare + remote_hash = None + if local_hash: + remote_hash = get_function_code_hash(client, name, stage="DEVELOPMENT") + + # Check what needs updating + needs_config_update = comment != config.get("Comment", "") or runtime != config.get("Runtime") + needs_code_update = local_hash and local_hash != remote_hash + needs_update = needs_config_update or needs_code_update + + # Update function if needed + if needs_update: + if not code_bytes and needs_code_update: + # This shouldn't happen but guard against it + module.fail_json(msg="code parameter is required to update function code") + + # If only config changed, we need to provide existing code + if needs_config_update and not needs_code_update: + if not code_bytes: + module.fail_json(msg="code parameter is required when updating function configuration") + + changed, update_response = update_function(client, module, name, etag, comment, runtime, code_bytes) + + if not module.check_mode: + # Refresh function details to get new ETag + current = get_function(client, name) + etag = current["ETag"] + + msg = "Function updated" + + # Handle publish state + if state == "published": + if not module.check_mode: + # Check if function is already published to LIVE stage + already_published = is_function_published(client, name) + + # Only publish if not already in LIVE stage + if not already_published: + publish_changed, publish_response = publish_function(client, module, name, etag) + changed = changed or publish_changed + current = get_function(client, name) + msg = msg + " and published" if msg else "Function published" + elif not msg: + msg = "Function already published" + else: + # In check mode, assume we would publish if not already live + if not changed: + already_published = is_function_published(client, name) + if not already_published: + changed = True + msg = "Function would be published" + else: + msg = "Function already published" + + if not msg: + msg = "Function is up to date" + + result = { + "changed": changed, + "msg": msg, + } + + if not module.check_mode: + result["function"] = format_function_output(current) + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/cloudfront_function/aliases b/tests/integration/targets/cloudfront_function/aliases new file mode 100644 index 00000000000..4ef4b2067d0 --- /dev/null +++ b/tests/integration/targets/cloudfront_function/aliases @@ -0,0 +1 @@ +cloud/aws diff --git a/tests/integration/targets/cloudfront_function/defaults/main.yml b/tests/integration/targets/cloudfront_function/defaults/main.yml new file mode 100644 index 00000000000..72cdcc77ba0 --- /dev/null +++ b/tests/integration/targets/cloudfront_function/defaults/main.yml @@ -0,0 +1,2 @@ +--- +function_name: "{{ resource_prefix }}-cf-func" diff --git a/tests/integration/targets/cloudfront_function/files/viewer-request.js b/tests/integration/targets/cloudfront_function/files/viewer-request.js new file mode 100644 index 00000000000..57a356de526 --- /dev/null +++ b/tests/integration/targets/cloudfront_function/files/viewer-request.js @@ -0,0 +1,19 @@ +function handler(event) { + var request = event.request; + var headers = request.headers; + var uri = request.uri; + + // Add custom security headers + headers['x-frame-options'] = { value: 'DENY' }; + headers['x-content-type-options'] = { value: 'nosniff' }; + headers['x-xss-protection'] = { value: '1; mode=block' }; + + // Normalize URI + if (uri.endsWith('/')) { + request.uri = uri + 'index.html'; + } else if (!uri.includes('.')) { + request.uri = uri + '/index.html'; + } + + return request; +} diff --git a/tests/integration/targets/cloudfront_function/tasks/main.yml b/tests/integration/targets/cloudfront_function/tasks/main.yml new file mode 100644 index 00000000000..c2691b51abd --- /dev/null +++ b/tests/integration/targets/cloudfront_function/tasks/main.yml @@ -0,0 +1,385 @@ +- module_defaults: + group/aws: + access_key: "{{ aws_access_key }}" + secret_key: "{{ aws_secret_key }}" + session_token: "{{ security_token | default(omit) }}" + + collections: + - amazon.aws + + block: + # Test 1: Create a CloudFront function with inline code (state=present) + - name: Create CloudFront function with inline code + cloudfront_function: + name: "{{ function_name }}-test1" + state: present + comment: "Test function for integration tests" + runtime: cloudfront-js-2.0 + code: | + function handler(event) { + var response = { + statusCode: 200, + statusDescription: 'OK' + }; + return response; + } + register: create_function + + - name: Ensure function was created + assert: + that: + - create_function is changed + - create_function.function.name == "{{ function_name }}-test1" + - create_function.function.status == "UNASSOCIATED" + - create_function.function.stage == "DEVELOPMENT" + - create_function.function.runtime == "cloudfront-js-2.0" + - create_function.function.comment == "Test function for integration tests" + - create_function.msg == "Function created" + + # Test 2: Idempotency - create same function again (should not change) + - name: Create same function again (idempotency check) + cloudfront_function: + name: "{{ function_name }}-test1" + state: present + comment: "Test function for integration tests" + runtime: cloudfront-js-2.0 + code: | + function handler(event) { + var response = { + statusCode: 200, + statusDescription: 'OK' + }; + return response; + } + register: create_function_idempotent + + - name: Ensure function was not changed (idempotency) + assert: + that: + - create_function_idempotent is not changed + - create_function_idempotent.msg == "Function is up to date" + + # Test 3: Update function code + - name: Update function code + cloudfront_function: + name: "{{ function_name }}-test1" + state: present + comment: "Test function for integration tests" + runtime: cloudfront-js-2.0 + code: | + function handler(event) { + var response = { + statusCode: 301, + statusDescription: 'Moved Permanently', + headers: { + 'location': { value: 'https://example.com/' } + } + }; + return response; + } + register: update_function_code + + - name: Ensure function code was updated + assert: + that: + - update_function_code is changed + - update_function_code.msg == "Function updated" + + # Test 4: Update function comment + - name: Update function comment + cloudfront_function: + name: "{{ function_name }}-test1" + state: present + comment: "Updated comment for test function" + runtime: cloudfront-js-2.0 + code: | + function handler(event) { + var response = { + statusCode: 301, + statusDescription: 'Moved Permanently', + headers: { + 'location': { value: 'https://example.com/' } + } + }; + return response; + } + register: update_function_comment + + - name: Ensure function comment was updated + assert: + that: + - update_function_comment is changed + - update_function_comment.function.comment == "Updated comment for test function" + - update_function_comment.msg == "Function updated" + + # Test 5: Create and publish function in one step + - name: Create and publish function + cloudfront_function: + name: "{{ function_name }}-test2" + state: published + comment: "Function to be published" + runtime: cloudfront-js-2.0 + code: | + function handler(event) { + var request = event.request; + request.headers['x-custom-header'] = { value: 'test-value' }; + return request; + } + register: create_and_publish + + - name: Ensure function was created and published + assert: + that: + - create_and_publish is changed + - create_and_publish.function.name == "{{ function_name }}-test2" + - create_and_publish.function.stage == "LIVE" + - create_and_publish.msg == "Function created and published" + + # Test 6: Idempotency - publish already published function + - name: Publish already published function (idempotency) + cloudfront_function: + name: "{{ function_name }}-test2" + state: published + comment: "Function to be published" + runtime: cloudfront-js-2.0 + code: | + function handler(event) { + var request = event.request; + request.headers['x-custom-header'] = { value: 'test-value' }; + return request; + } + register: publish_idempotent + + - name: Ensure no change for already published function + assert: + that: + - publish_idempotent is not changed + - publish_idempotent.msg == "Function already published" + + # Test 7: Update and publish function + - name: Update and publish function + cloudfront_function: + name: "{{ function_name }}-test2" + state: published + comment: "Updated and published function" + runtime: cloudfront-js-2.0 + code: | + function handler(event) { + var request = event.request; + request.headers['x-updated-header'] = { value: 'updated-value' }; + return request; + } + register: update_and_publish + + - name: Ensure function was updated and published + assert: + that: + - update_and_publish is changed + - update_and_publish.function.comment == "Updated and published function" + - update_and_publish.function.stage == "LIVE" + + # Test 8: Create function from file + - name: Create function from file + cloudfront_function: + name: "{{ function_name }}-test3" + state: present + comment: "Function loaded from file" + runtime: cloudfront-js-2.0 + code: "{{ lookup('file', 'viewer-request.js') }}" + register: create_from_file + + - name: Ensure function from file was created + assert: + that: + - create_from_file is changed + - create_from_file.function.name == "{{ function_name }}-test3" + - create_from_file.msg == "Function created" + + # Test 9: Test with cloudfront-js-1.0 runtime + - name: Create function with cloudfront-js-1.0 runtime + cloudfront_function: + name: "{{ function_name }}-test4" + state: present + comment: "Function with JS 1.0 runtime" + runtime: cloudfront-js-1.0 + code: | + function handler(event) { + var response = { + statusCode: 200, + statusDescription: 'OK' + }; + return response; + } + register: create_js10_runtime + + - name: Ensure function with JS 1.0 runtime was created + assert: + that: + - create_js10_runtime is changed + - create_js10_runtime.function.runtime == "cloudfront-js-1.0" + + # Test 10: Update runtime version + - name: Update runtime from 1.0 to 2.0 + cloudfront_function: + name: "{{ function_name }}-test4" + state: present + comment: "Function with JS 1.0 runtime" + runtime: cloudfront-js-2.0 + code: | + function handler(event) { + var response = { + statusCode: 200, + statusDescription: 'OK' + }; + return response; + } + register: update_runtime + + - name: Ensure runtime was updated + assert: + that: + - update_runtime is changed + - update_runtime.function.runtime == "cloudfront-js-2.0" + + # Test 11: Check mode - create function + - name: Create function in check mode + cloudfront_function: + name: "{{ function_name }}-checkmode" + state: present + comment: "Check mode test" + runtime: cloudfront-js-2.0 + code: | + function handler(event) { + return event.request; + } + check_mode: true + register: check_mode_create + + - name: Ensure check mode reports change but doesn't create + assert: + that: + - check_mode_create is changed + + - name: Verify function was not actually created + cloudfront_function: + name: "{{ function_name }}-checkmode" + state: absent + register: delete_checkmode + ignore_errors: true + + - name: Ensure function doesn't exist + assert: + that: + - delete_checkmode is not changed + + # Test 12: Check mode - update function + - name: Update function in check mode + cloudfront_function: + name: "{{ function_name }}-test1" + state: present + comment: "Check mode update" + runtime: cloudfront-js-2.0 + code: | + function handler(event) { + var response = { + statusCode: 404, + statusDescription: 'Not Found' + }; + return response; + } + check_mode: true + register: check_mode_update + + - name: Ensure check mode reports change + assert: + that: + - check_mode_update is changed + + - name: Verify function was not actually updated + cloudfront_function: + name: "{{ function_name }}-test1" + state: present + comment: "Updated comment for test function" + runtime: cloudfront-js-2.0 + code: | + function handler(event) { + var response = { + statusCode: 301, + statusDescription: 'Moved Permanently', + headers: { + 'location': { value: 'https://example.com/' } + } + }; + return response; + } + register: verify_not_updated + + - name: Ensure function was not changed by check mode + assert: + that: + - verify_not_updated is not changed + + # Test 13: Delete function + - name: Delete CloudFront function + cloudfront_function: + name: "{{ function_name }}-test1" + state: absent + register: delete_function + + - name: Ensure function was deleted + assert: + that: + - delete_function is changed + - delete_function.msg == "Function deleted" + + # Test 14: Idempotency - delete non-existent function + - name: Delete function again (idempotency) + cloudfront_function: + name: "{{ function_name }}-test1" + state: absent + register: delete_function_idempotent + + - name: Ensure no change when deleting non-existent function + assert: + that: + - delete_function_idempotent is not changed + - delete_function_idempotent.msg == "Function does not exist" + + # Test 15: Check mode - delete function + - name: Delete function in check mode + cloudfront_function: + name: "{{ function_name }}-test2" + state: absent + check_mode: true + register: check_mode_delete + + - name: Ensure check mode reports change + assert: + that: + - check_mode_delete is changed + + # Test 16: Verify function still exists after check mode delete + - name: Delete function for real + cloudfront_function: + name: "{{ function_name }}-test2" + state: absent + register: delete_for_real + + - name: Ensure function was actually deleted + assert: + that: + - delete_for_real is changed + + always: + # Cleanup - delete all test functions + - name: Cleanup - delete test functions + cloudfront_function: + name: "{{ item }}" + state: absent + ignore_errors: true + loop: + - "{{ function_name }}-test1" + - "{{ function_name }}-test2" + - "{{ function_name }}-test3" + - "{{ function_name }}-test4" + - "{{ function_name }}-checkmode"