From 14d96fdb9f225d3908875eab6e85e09a04033e35 Mon Sep 17 00:00:00 2001 From: Mark Chappell Date: Thu, 9 Jul 2026 10:09:21 +0200 Subject: [PATCH 1/4] s3_bucket_*: Refactor S3 modules to use consistent naming and shared utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename S3 configuration modules to follow s3_bucket_* naming convention: - s3_cors → s3_bucket_cors - s3_lifecycle → s3_bucket_lifecycle - s3_metrics_configuration → s3_bucket_metrics_configuration - s3_website → s3_bucket_website Add new _info modules for retrieving configuration: - s3_bucket_cors_info - s3_bucket_lifecycle_info - s3_bucket_metrics_configuration_info - s3_bucket_notification_info - s3_bucket_website_info Create shared module_utils infrastructure: - plugins/module_utils/s3.py: Common S3 utilities and helpers - plugins/module_utils/_s3/: Specialised S3 functionality modules Update meta/runtime.yml with redirects from old module names to new names for backwards compatibility, and add new modules to action groups. Update all integration and unit tests to use new module names. Co-Authored-By: Claude Sonnet 4.5 --- meta/runtime.yml | 17 + plugins/module_utils/_s3/__init__.py | 0 plugins/module_utils/_s3/transformations.py | 211 +++++++ plugins/module_utils/s3.py | 279 +++++++++ .../modules/{s3_cors.py => s3_bucket_cors.py} | 76 +-- plugins/modules/s3_bucket_cors_info.py | 113 ++++ ...s3_lifecycle.py => s3_bucket_lifecycle.py} | 209 +++---- plugins/modules/s3_bucket_lifecycle_info.py | 154 +++++ ....py => s3_bucket_metrics_configuration.py} | 113 ++-- .../s3_bucket_metrics_configuration_info.py | 161 ++++++ plugins/modules/s3_bucket_notification.py | 89 +-- .../modules/s3_bucket_notification_info.py | 161 ++++++ .../{s3_website.py => s3_bucket_website.py} | 191 +++--- plugins/modules/s3_bucket_website_info.py | 169 ++++++ plugins/modules/s3_sync.py | 63 +- pyproject.toml | 108 +++- .../tasks/test_lambda_notifications.yml | 24 +- .../tasks/test_sns_sqs_notifications.yml | 139 ++++- .../targets/s3_lifecycle/tasks/main.yml | 122 ++-- .../targets/s3_logging/tasks/main.yml | 42 +- .../s3_metrics_configuration/tasks/main.yml | 61 +- .../tasks/s3_metrics_info.yml | 18 +- .../targets/s3_sync/tasks/main.yml | 12 +- .../module_utils/_s3/test_transformations.py | 543 ++++++++++++++++++ .../s3_lifecycle/test_filters_are_equal.py | 2 +- 25 files changed, 2484 insertions(+), 593 deletions(-) create mode 100644 plugins/module_utils/_s3/__init__.py create mode 100644 plugins/module_utils/_s3/transformations.py create mode 100644 plugins/module_utils/s3.py rename plugins/modules/{s3_cors.py => s3_bucket_cors.py} (63%) create mode 100644 plugins/modules/s3_bucket_cors_info.py rename plugins/modules/{s3_lifecycle.py => s3_bucket_lifecycle.py} (84%) create mode 100644 plugins/modules/s3_bucket_lifecycle_info.py rename plugins/modules/{s3_metrics_configuration.py => s3_bucket_metrics_configuration.py} (51%) create mode 100644 plugins/modules/s3_bucket_metrics_configuration_info.py create mode 100644 plugins/modules/s3_bucket_notification_info.py rename plugins/modules/{s3_website.py => s3_bucket_website.py} (56%) create mode 100644 plugins/modules/s3_bucket_website_info.py create mode 100644 tests/unit/plugins/module_utils/_s3/test_transformations.py diff --git a/meta/runtime.yml b/meta/runtime.yml index 2097ecda400..ea3a8516efe 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -159,7 +159,16 @@ action_groups: - redshift_info - redshift_subnet_group - route53_wait + - s3_bucket_cors + - s3_bucket_cors_info + - s3_bucket_lifecycle + - s3_bucket_lifecycle_info + - s3_bucket_metrics_configuration + - s3_bucket_metrics_configuration_info - s3_bucket_notification + - s3_bucket_notification_info + - s3_bucket_website + - s3_bucket_website_info - s3_cors - s3_lifecycle - s3_logging @@ -606,6 +615,14 @@ plugin_routing: redirect: amazon.aws.route53_zone s3_bucket_info: redirect: amazon.aws.s3_bucket_info + s3_cors: + redirect: community.aws.s3_bucket_cors + s3_lifecycle: + redirect: community.aws.s3_bucket_lifecycle + s3_metrics_configuration: + redirect: community.aws.s3_bucket_metrics_configuration + s3_website: + redirect: community.aws.s3_bucket_website sts_assume_role: redirect: amazon.aws.sts_assume_role ec2_vpc_egress_igw: diff --git a/plugins/module_utils/_s3/__init__.py b/plugins/module_utils/_s3/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/plugins/module_utils/_s3/transformations.py b/plugins/module_utils/_s3/transformations.py new file mode 100644 index 00000000000..132d7f7ef7b --- /dev/null +++ b/plugins/module_utils/_s3/transformations.py @@ -0,0 +1,211 @@ +# -*- coding: utf-8 -*- + +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +S3 transformation and normalization functions. + +This module contains functions for transforming S3 API responses into +Ansible-friendly formats and vice versa. +""" + +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict + +from ansible_collections.amazon.aws.plugins.module_utils.tagging import ansible_dict_to_boto3_tag_list +from ansible_collections.amazon.aws.plugins.module_utils.transformation import boto3_resource_list_to_ansible_dict +from ansible_collections.amazon.aws.plugins.module_utils.transformation import boto3_resource_to_ansible_dict + + +def build_notification_configuration(bucket_config): + """ + Build notification configuration dict for return value. + + Args: + bucket_config: Dictionary with QueueConfigurations, TopicConfigurations, + LambdaFunctionConfigurations keys containing Config objects + + Returns: + Dictionary with snake_case keys suitable for module return + """ + notification_configs = dict(QueueConfigurations=[], TopicConfigurations=[], LambdaFunctionConfigurations=[]) + for target_configs in bucket_config: + for cfg in bucket_config[target_configs]: + notification_configs[target_configs].append(camel_dict_to_snake_dict(cfg.raw)) + return camel_dict_to_snake_dict(notification_configs) + + +def create_website_configuration(suffix, error_key, redirect_all_requests): + """ + Create website configuration payload for S3 API. + + Args: + suffix: Index document suffix (e.g., 'index.html') + error_key: Error document key + redirect_all_requests: Redirect URL (format: 'protocol://hostname' or 'hostname') + + Returns: + Dictionary suitable for put_bucket_website API call + + Raises: + ValueError: If redirect_all_requests URL format is invalid + """ + website_configuration = {} + + if error_key is not None: + website_configuration["ErrorDocument"] = {"Key": error_key} + + if suffix is not None: + website_configuration["IndexDocument"] = {"Suffix": suffix} + + if redirect_all_requests is not None: + website_configuration["RedirectAllRequestsTo"] = _create_redirect_dict(redirect_all_requests) + + return website_configuration + + +def _create_redirect_dict(url): + """ + Parse redirect URL into protocol and hostname components. + + Args: + url: Redirect URL (format: 'protocol://hostname' or 'hostname') + + Returns: + Dictionary with Protocol and/or HostName keys + + Raises: + ValueError: If URL format is invalid + """ + redirect_dict = {} + url_split = url.split(":") + + # Did we split anything? + if len(url_split) == 2: + redirect_dict["Protocol"] = url_split[0] + redirect_dict["HostName"] = url_split[1].replace("//", "") + elif len(url_split) == 1: + redirect_dict["HostName"] = url_split[0] + else: + raise ValueError("Redirect URL appears invalid") + + return redirect_dict + + +def normalize_cors_rules(cors_rules): + """ + Normalize CORS rules to snake_case. + + Args: + cors_rules: List of CORS rule dictionaries from AWS API (CamelCase) + + Returns: + List of normalized CORS rule dictionaries with snake_case keys + """ + return boto3_resource_list_to_ansible_dict(cors_rules, transform_tags=False) + + +def normalize_lifecycle_rules(lifecycle_config): + """ + Normalize lifecycle configuration to snake_case. + + Args: + lifecycle_config: Lifecycle configuration dictionary from AWS API (CamelCase) + + Returns: + List of normalized lifecycle rule dictionaries with snake_case keys + """ + rules = lifecycle_config.get("Rules", []) + return boto3_resource_list_to_ansible_dict(rules, transform_tags=False) + + +def normalize_notification_configuration(notification_config): + """ + Normalize notification configuration to snake_case. + + Args: + notification_config: Notification configuration dictionary from AWS API (CamelCase) + + Returns: + Normalized notification configuration dictionary with snake_case keys + """ + return boto3_resource_to_ansible_dict(notification_config, transform_tags=False) + + +def normalize_website_configuration(website_config): + """ + Normalize website configuration to snake_case. + + Args: + website_config: Website configuration dictionary from AWS API (CamelCase) + + Returns: + Normalized website configuration dictionary with snake_case keys + """ + return boto3_resource_to_ansible_dict(website_config, transform_tags=False) + + +def normalize_metrics_configuration(config): + """ + Normalize a metrics configuration to snake_case. + + Args: + config: Raw metrics configuration from AWS API (CamelCase) + + Returns: + Normalized configuration dictionary with snake_case keys + """ + if not config: + return None + + normalized = {"id": config.get("Id")} + + if "Filter" in config: + filter_config = config["Filter"] + normalized_filter = {} + + if "Prefix" in filter_config: + normalized_filter["prefix"] = filter_config["Prefix"] + + if "Tag" in filter_config: + tag = filter_config["Tag"] + normalized_filter["tags"] = {tag["Key"]: tag["Value"]} + + if "And" in filter_config: + and_filter = filter_config["And"] + if "Prefix" in and_filter: + normalized_filter["prefix"] = and_filter["Prefix"] + if "Tags" in and_filter: + normalized_filter["tags"] = {tag["Key"]: tag["Value"] for tag in and_filter["Tags"]} + + if normalized_filter: + normalized["filter"] = normalized_filter + + return normalized + + +def create_metrics_configuration(mc_id, filter_prefix, filter_tags): + """ + Create metrics configuration payload for S3 API. + + Args: + mc_id: Metrics configuration ID + filter_prefix: Prefix filter for metrics + filter_tags: Tag filters as Ansible dictionary + + Returns: + Dictionary suitable for put_bucket_metrics_configuration API call + """ + payload = {"Id": mc_id} + # Just a filter_prefix or just a single tag filter is a special case + if filter_prefix and not filter_tags: + payload["Filter"] = {"Prefix": filter_prefix} + elif not filter_prefix and len(filter_tags) == 1: + payload["Filter"] = {"Tag": ansible_dict_to_boto3_tag_list(filter_tags)[0]} + # Otherwise we need to use 'And' + elif filter_tags: + payload["Filter"] = {"And": {"Tags": ansible_dict_to_boto3_tag_list(filter_tags)}} + if filter_prefix: + payload["Filter"]["And"]["Prefix"] = filter_prefix + + return payload diff --git a/plugins/module_utils/s3.py b/plugins/module_utils/s3.py new file mode 100644 index 00000000000..9345aa6109f --- /dev/null +++ b/plugins/module_utils/s3.py @@ -0,0 +1,279 @@ +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +S3 client wrappers for community.aws modules. + +These wrappers will eventually be contributed to amazon.aws. +""" + +from __future__ import annotations + +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_message +from ansible_collections.amazon.aws.plugins.module_utils.botocore import normalize_boto3_result +from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry +from ansible_collections.amazon.aws.plugins.module_utils.s3 import S3ErrorHandler + +# Intended for general use / re-import +# pylint: disable=unused-import,useless-import-alias +from ansible_collections.community.aws.plugins.module_utils._s3.transformations import ( + build_notification_configuration as build_notification_configuration, +) +from ansible_collections.community.aws.plugins.module_utils._s3.transformations import ( + create_metrics_configuration as create_metrics_configuration, +) +from ansible_collections.community.aws.plugins.module_utils._s3.transformations import ( + create_website_configuration as create_website_configuration, +) +from ansible_collections.community.aws.plugins.module_utils._s3.transformations import ( + normalize_cors_rules as normalize_cors_rules, +) +from ansible_collections.community.aws.plugins.module_utils._s3.transformations import ( + normalize_lifecycle_rules as normalize_lifecycle_rules, +) +from ansible_collections.community.aws.plugins.module_utils._s3.transformations import ( + normalize_metrics_configuration as normalize_metrics_configuration, +) +from ansible_collections.community.aws.plugins.module_utils._s3.transformations import ( + normalize_notification_configuration as normalize_notification_configuration, +) +from ansible_collections.community.aws.plugins.module_utils._s3.transformations import ( + normalize_website_configuration as normalize_website_configuration, +) + +# ======================================== +# S3 Bucket Configuration Wrappers +# ======================================== + + +@S3ErrorHandler.list_error_handler("get bucket notification configuration", {}) +@AWSRetry.jittered_backoff(max_delay=120, catch_extra_error_codes=["OperationAborted"]) +def get_bucket_notification_configuration(client, bucket_name): + """ + Get the notification configuration for an S3 bucket. + + Parameters: + client: boto3 S3 client + bucket_name: Name of the S3 bucket + + Returns: + Notification configuration dictionary + """ + return client.get_bucket_notification_configuration(Bucket=bucket_name) + + +@S3ErrorHandler.common_error_handler("put bucket notification configuration") +@AWSRetry.jittered_backoff(max_delay=120, catch_extra_error_codes=["OperationAborted"]) +def put_bucket_notification_configuration(client, bucket_name, notification_configuration): + """ + Set the notification configuration for an S3 bucket. + + Parameters: + client: boto3 S3 client + bucket_name: Name of the S3 bucket + notification_configuration: Notification configuration dictionary + """ + return client.put_bucket_notification_configuration( + Bucket=bucket_name, NotificationConfiguration=notification_configuration + ) + + +@S3ErrorHandler.list_error_handler("get bucket website configuration", {}) +@AWSRetry.jittered_backoff(max_delay=120, catch_extra_error_codes=["OperationAborted"]) +def get_bucket_website(client, bucket_name): + """ + Get the website configuration for an S3 bucket. + + Parameters: + client: boto3 S3 client + bucket_name: Name of the S3 bucket + + Returns: + Website configuration dictionary, or {} if no configuration exists + """ + try: + return client.get_bucket_website(Bucket=bucket_name) + except is_boto3_error_code("NoSuchWebsiteConfiguration"): + return {} + + +@S3ErrorHandler.common_error_handler("put bucket website configuration") +@AWSRetry.jittered_backoff(max_delay=120, catch_extra_error_codes=["OperationAborted"]) +def put_bucket_website(client, bucket_name, website_configuration): + """ + Set the website configuration for an S3 bucket. + + Parameters: + client: boto3 S3 client + bucket_name: Name of the S3 bucket + website_configuration: Website configuration dictionary + """ + return client.put_bucket_website(Bucket=bucket_name, WebsiteConfiguration=website_configuration) + + +@S3ErrorHandler.deletion_error_handler("delete bucket website configuration") +@AWSRetry.jittered_backoff(max_delay=120, catch_extra_error_codes=["OperationAborted"]) +def delete_bucket_website(client, bucket_name): + """ + Delete the website configuration for an S3 bucket. + + Parameters: + client: boto3 S3 client + bucket_name: Name of the S3 bucket + """ + return client.delete_bucket_website(Bucket=bucket_name) + + +@S3ErrorHandler.list_error_handler("get bucket CORS configuration", {}) +@AWSRetry.jittered_backoff(max_delay=120, catch_extra_error_codes=["OperationAborted"]) +def get_bucket_cors(client, bucket_name): + """ + Get the CORS configuration for an S3 bucket. + + Parameters: + client: boto3 S3 client + bucket_name: Name of the S3 bucket + + Returns: + CORS configuration dictionary, or {} if no configuration exists + """ + result = client.get_bucket_cors(Bucket=bucket_name) + return result.get("CORSRules", []) + + +@S3ErrorHandler.common_error_handler("put bucket CORS configuration") +@AWSRetry.jittered_backoff(max_delay=120, catch_extra_error_codes=["OperationAborted"]) +def put_bucket_cors(client, bucket_name, cors_rules): + """ + Set the CORS configuration for an S3 bucket. + + Parameters: + client: boto3 S3 client + bucket_name: Name of the S3 bucket + cors_rules: List of CORS rules + """ + return client.put_bucket_cors(Bucket=bucket_name, CORSConfiguration={"CORSRules": cors_rules}) + + +@S3ErrorHandler.deletion_error_handler("delete bucket CORS configuration") +@AWSRetry.jittered_backoff(max_delay=120, catch_extra_error_codes=["OperationAborted"]) +def delete_bucket_cors(client, bucket_name): + """ + Delete the CORS configuration for an S3 bucket. + + Parameters: + client: boto3 S3 client + bucket_name: Name of the S3 bucket + """ + return client.delete_bucket_cors(Bucket=bucket_name) + + +@S3ErrorHandler.list_error_handler("get bucket metrics configuration", {}) +@AWSRetry.jittered_backoff(max_delay=120, catch_extra_error_codes=["OperationAborted"]) +def get_bucket_metrics_configuration(client, bucket_name, metrics_id): + """ + Get a specific metrics configuration for an S3 bucket. + + Parameters: + client: boto3 S3 client + bucket_name: Name of the S3 bucket + metrics_id: ID of the metrics configuration + + Returns: + Metrics configuration dictionary, or {} if not found + """ + try: + response = client.get_bucket_metrics_configuration(Bucket=bucket_name, Id=metrics_id) + return response.get("MetricsConfiguration", {}) + except is_boto3_error_code("NoSuchConfiguration"): + return {} + + +@S3ErrorHandler.common_error_handler("put bucket metrics configuration") +@AWSRetry.jittered_backoff(max_delay=120, catch_extra_error_codes=["OperationAborted"]) +def put_bucket_metrics_configuration(client, bucket_name, metrics_id, metrics_configuration): + """ + Set a metrics configuration for an S3 bucket. + + Parameters: + client: boto3 S3 client + bucket_name: Name of the S3 bucket + metrics_id: ID of the metrics configuration + metrics_configuration: Metrics configuration dictionary + """ + return client.put_bucket_metrics_configuration( + Bucket=bucket_name, Id=metrics_id, MetricsConfiguration=metrics_configuration + ) + + +@S3ErrorHandler.deletion_error_handler("delete bucket metrics configuration") +@AWSRetry.jittered_backoff(max_delay=120, catch_extra_error_codes=["OperationAborted"]) +def delete_bucket_metrics_configuration(client, bucket_name, metrics_id): + """ + Delete a metrics configuration for an S3 bucket. + + Parameters: + client: boto3 S3 client + bucket_name: Name of the S3 bucket + metrics_id: ID of the metrics configuration + """ + try: + return client.delete_bucket_metrics_configuration(Bucket=bucket_name, Id=metrics_id) + except is_boto3_error_code("NoSuchConfiguration"): + return None + + +@S3ErrorHandler.list_error_handler("get bucket lifecycle configuration", {"Rules": []}) +@AWSRetry.jittered_backoff(max_delay=120, catch_extra_error_codes=["OperationAborted"]) +def get_bucket_lifecycle_configuration(client, bucket_name): + """ + Get the lifecycle configuration for an S3 bucket. + + Parameters: + client: boto3 S3 client + bucket_name: Name of the S3 bucket + + Returns: + Lifecycle configuration dictionary, or {"Rules": []} if not configured + """ + try: + result = client.get_bucket_lifecycle_configuration(Bucket=bucket_name) + return normalize_boto3_result(result) + except is_boto3_error_code("NoSuchLifecycleConfiguration"): + return {"Rules": []} + + +@S3ErrorHandler.common_error_handler("put bucket lifecycle configuration") +@AWSRetry.jittered_backoff(max_delay=120, catch_extra_error_codes=["OperationAborted"]) +def put_bucket_lifecycle_configuration(client, bucket_name, lifecycle_configuration): + """ + Set the lifecycle configuration for an S3 bucket. + + Parameters: + client: boto3 S3 client + bucket_name: Name of the S3 bucket + lifecycle_configuration: Lifecycle configuration dictionary + """ + try: + return client.put_bucket_lifecycle_configuration( + Bucket=bucket_name, LifecycleConfiguration=lifecycle_configuration + ) + except is_boto3_error_message("At least one action needs to be specified in a rule"): + # Amazon interpreted this as not changing anything + return None + + +@S3ErrorHandler.deletion_error_handler("delete bucket lifecycle configuration") +@AWSRetry.jittered_backoff(max_delay=120, catch_extra_error_codes=["OperationAborted"]) +def delete_bucket_lifecycle(client, bucket_name): + """ + Delete the lifecycle configuration for an S3 bucket. + + Parameters: + client: boto3 S3 client + bucket_name: Name of the S3 bucket + """ + return client.delete_bucket_lifecycle(Bucket=bucket_name) diff --git a/plugins/modules/s3_cors.py b/plugins/modules/s3_bucket_cors.py similarity index 63% rename from plugins/modules/s3_cors.py rename to plugins/modules/s3_bucket_cors.py index c9cf2d0546a..dd4ff7057a9 100644 --- a/plugins/modules/s3_cors.py +++ b/plugins/modules/s3_bucket_cors.py @@ -6,12 +6,13 @@ DOCUMENTATION = r""" --- -module: s3_cors +module: s3_bucket_cors version_added: 1.0.0 short_description: Manage CORS for S3 buckets in AWS description: - Manage CORS for S3 buckets in AWS. - Prior to release 5.0.0 this module was called C(community.aws.aws_s3_cors). + - Since release 11.1.0 the preferred name is C(community.aws.s3_bucket_cors), C(community.aws.s3_cors) remains as an alias. The usage did not change. author: - "Oyvind Saltvik (@fivethreeo)" @@ -95,54 +96,51 @@ ] """ -try: - from botocore.exceptions import BotoCoreError - from botocore.exceptions import ClientError -except ImportError: - pass # Handled by AnsibleAWSModule - from ansible.module_utils.common.dict_transformations import snake_dict_to_camel_dict from ansible_collections.amazon.aws.plugins.module_utils.policy import compare_policies +from ansible_collections.amazon.aws.plugins.module_utils.s3 import AnsibleS3Error from ansible_collections.community.aws.plugins.module_utils.modules import AnsibleCommunityAWSModule as AnsibleAWSModule +from ansible_collections.community.aws.plugins.module_utils.s3 import delete_bucket_cors +from ansible_collections.community.aws.plugins.module_utils.s3 import get_bucket_cors +from ansible_collections.community.aws.plugins.module_utils.s3 import put_bucket_cors -def create_or_update_bucket_cors(connection, module): +def create_or_update_bucket_cors(module, client): + """Create or update bucket CORS configuration.""" name = module.params.get("name") rules = module.params.get("rules", []) - changed = False - - try: - current_camel_rules = connection.get_bucket_cors(Bucket=name)["CORSRules"] - except ClientError: - current_camel_rules = [] + current_camel_rules = get_bucket_cors(client, name) new_camel_rules = snake_dict_to_camel_dict(rules, capitalize_first=True) + # compare_policies() takes two dicts and makes them hashable for comparison - if compare_policies(new_camel_rules, current_camel_rules): - changed = True + if not compare_policies(new_camel_rules, current_camel_rules): + return False - if changed: - try: - connection.put_bucket_cors(Bucket=name, CORSConfiguration={"CORSRules": new_camel_rules}) - except (BotoCoreError, ClientError) as e: - module.fail_json_aws(e, msg=f"Unable to update CORS for bucket {name}") + if module.check_mode: + return True - module.exit_json(changed=changed, name=name, rules=rules) + put_bucket_cors(client, name, new_camel_rules) + return True -def destroy_bucket_cors(connection, module): +def delete_bucket_cors_configuration(module, client): + """Delete bucket CORS configuration.""" name = module.params.get("name") - changed = False - try: - connection.delete_bucket_cors(Bucket=name) - changed = True - except (BotoCoreError, ClientError) as e: - module.fail_json_aws(e, msg=f"Unable to delete CORS for bucket {name}") + current_camel_rules = get_bucket_cors(client, name) + + # Check if already absent + if not current_camel_rules: + return False - module.exit_json(changed=changed) + if module.check_mode: + return True + + delete_bucket_cors(client, name) + return True def main(): @@ -152,16 +150,20 @@ def main(): state=dict(type="str", choices=["present", "absent"], required=True), ) - module = AnsibleAWSModule(argument_spec=argument_spec) + module = AnsibleAWSModule(argument_spec=argument_spec, supports_check_mode=True) - client = module.client("s3") + try: + client = module.client("s3") + state = module.params.get("state") - state = module.params.get("state") + if state == "present": + changed = create_or_update_bucket_cors(module, client) + else: # absent + changed = delete_bucket_cors_configuration(module, client) - if state == "present": - create_or_update_bucket_cors(client, module) - elif state == "absent": - destroy_bucket_cors(client, module) + module.exit_json(changed=changed, name=module.params["name"], rules=module.params.get("rules", [])) + except AnsibleS3Error as e: + module.fail_json_aws_error(e) if __name__ == "__main__": diff --git a/plugins/modules/s3_bucket_cors_info.py b/plugins/modules/s3_bucket_cors_info.py new file mode 100644 index 00000000000..7dbad3a12f5 --- /dev/null +++ b/plugins/modules/s3_bucket_cors_info.py @@ -0,0 +1,113 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: s3_bucket_cors_info +version_added: 11.1.0 +short_description: Retrieve CORS configuration for S3 buckets +description: + - Retrieve CORS configuration for S3 buckets. +author: + - Mark Chappell (@tremble) +options: + name: + description: + - Name of the S3 bucket. + required: true + type: str + aliases: ['bucket_name'] +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Note: These examples do not set authentication details, see the AWS Guide for details. + +- name: Retrieve CORS configuration for a bucket + community.aws.s3_bucket_cors_info: + name: my-bucket + register: result + +- name: Display CORS rules + ansible.builtin.debug: + var: result.cors_rules +""" + +RETURN = r""" +cors_rules: + description: List of CORS rules for the bucket + returned: always + type: list + elements: dict + contains: + allowed_headers: + description: Headers that are specified in the Access-Control-Request-Headers header + returned: when configured + type: list + elements: str + sample: ["Authorization"] + allowed_methods: + description: HTTP methods that the origin is allowed to execute + returned: always + type: list + elements: str + sample: ["GET", "PUT"] + allowed_origins: + description: Origins that are allowed to access the bucket + returned: always + type: list + elements: str + sample: ["https://example.com"] + expose_headers: + description: Headers in the response that customers are able to access from their applications + returned: when configured + type: list + elements: str + sample: ["ETag"] + max_age_seconds: + description: Time in seconds that browser can cache the response for a preflight request + returned: when configured + type: int + sample: 3000 +""" + +from ansible_collections.amazon.aws.plugins.module_utils.s3 import AnsibleS3Error + +from ansible_collections.community.aws.plugins.module_utils.modules import AnsibleCommunityAWSModule as AnsibleAWSModule +from ansible_collections.community.aws.plugins.module_utils.s3 import get_bucket_cors +from ansible_collections.community.aws.plugins.module_utils.s3 import normalize_cors_rules + + +def main(): + argument_spec = dict( + name=dict(required=True, type="str", aliases=["bucket_name"]), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + bucket_name = module.params.get("name") + + try: + client = module.client("s3") + cors_rules = get_bucket_cors(client, bucket_name) + + # Convert to snake_case + result = normalize_cors_rules(cors_rules) + + module.exit_json(changed=False, cors_rules=result) + + except AnsibleS3Error as e: + module.fail_json_aws_error(e) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/s3_lifecycle.py b/plugins/modules/s3_bucket_lifecycle.py similarity index 84% rename from plugins/modules/s3_lifecycle.py rename to plugins/modules/s3_bucket_lifecycle.py index e22c0b4624f..11104ae18cc 100644 --- a/plugins/modules/s3_lifecycle.py +++ b/plugins/modules/s3_bucket_lifecycle.py @@ -6,11 +6,12 @@ DOCUMENTATION = r""" --- -module: s3_lifecycle +module: s3_bucket_lifecycle version_added: 1.0.0 short_description: Manage S3 bucket lifecycle rules in AWS description: - Manage S3 bucket lifecycle rules in AWS. + - Since release 11.1.0 the preferred name is C(community.aws.s3_bucket_lifecycle), C(community.aws.s3_lifecycle) remains as an alias. author: - "Rob White (@wimnat)" notes: @@ -243,17 +244,12 @@ except ImportError: HAS_DATEUTIL = False -try: - import botocore -except ImportError: - pass # handled by AnsibleAwsModule - -from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code -from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_message -from ansible_collections.amazon.aws.plugins.module_utils.botocore import normalize_boto3_result -from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry +from ansible_collections.amazon.aws.plugins.module_utils.s3 import AnsibleS3Error from ansible_collections.community.aws.plugins.module_utils.modules import AnsibleCommunityAWSModule as AnsibleAWSModule +from ansible_collections.community.aws.plugins.module_utils.s3 import delete_bucket_lifecycle +from ansible_collections.community.aws.plugins.module_utils.s3 import get_bucket_lifecycle_configuration +from ansible_collections.community.aws.plugins.module_utils.s3 import put_bucket_lifecycle_configuration def parse_date(date): @@ -271,17 +267,8 @@ def parse_date(date): def fetch_rules(client, module, name): # Get the bucket's current lifecycle rules - try: - current_lifecycle = client.get_bucket_lifecycle_configuration(aws_retry=True, Bucket=name) - current_lifecycle_rules = normalize_boto3_result(current_lifecycle["Rules"]) - except is_boto3_error_code("NoSuchLifecycleConfiguration"): - current_lifecycle_rules = [] - except ( - botocore.exceptions.ClientError, - botocore.exceptions.BotoCoreError, - ) as e: # pylint: disable=duplicate-except - module.fail_json_aws(e) - return current_lifecycle_rules + current_lifecycle = get_bucket_lifecycle_configuration(client, name) + return current_lifecycle.get("Rules", []) # Helper function to deeply compare filters @@ -519,58 +506,69 @@ def merge_transitions(updated_rule, updating_rule): def create_lifecycle_rule(client, module): name = module.params.get("name") wait = module.params.get("wait") - changed = False old_lifecycle_rules = fetch_rules(client, module, name) new_rule = build_rule(client, module) (changed, lifecycle_configuration) = compare_and_update_configuration(client, module, old_lifecycle_rules, new_rule) - if changed: - # Write lifecycle to bucket - try: - client.put_bucket_lifecycle_configuration( - aws_retry=True, Bucket=name, LifecycleConfiguration=lifecycle_configuration - ) - except is_boto3_error_message("At least one action needs to be specified in a rule"): - # Amazon interpreted this as not changing anything - changed = False - except ( - botocore.exceptions.ClientError, - botocore.exceptions.BotoCoreError, - ) as e: # pylint: disable=duplicate-except - module.fail_json_aws( - e, lifecycle_configuration=lifecycle_configuration, name=name, old_lifecycle_rules=old_lifecycle_rules - ) - _changed = changed - _retries = 10 - _not_changed_cnt = 6 - while wait and _changed and _retries and _not_changed_cnt: - # We've seen examples where get_bucket_lifecycle_configuration returns - # the updated rules, then the old rules, then the updated rules again and - # again couple of times. - # Thus try to read the rule few times in a row to check if it has changed. - time.sleep(5) - _retries -= 1 - new_rules = fetch_rules(client, module, name) - (_changed, lifecycle_configuration) = compare_and_update_configuration(client, module, new_rules, new_rule) - if not _changed: - _not_changed_cnt -= 1 - _changed = True - else: - _not_changed_cnt = 6 - else: + if not changed: + new_rules = fetch_rules(client, module, name) + return { + "changed": False, + "new_rule": new_rule, + "rules": new_rules, + "old_rules": old_lifecycle_rules, + "_retries": 0, + "_config": lifecycle_configuration, + } + + if module.check_mode: + return { + "changed": True, + "new_rule": new_rule, + "rules": old_lifecycle_rules, + "old_rules": old_lifecycle_rules, + "_retries": 0, + "_config": lifecycle_configuration, + } + + # Write lifecycle to bucket + result = put_bucket_lifecycle_configuration(client, name, lifecycle_configuration) + if result is None: + # Amazon interpreted this as not changing anything + changed = False + + _changed = changed + _retries = 10 + _not_changed_cnt = 6 + while wait and _changed and _retries and _not_changed_cnt: + # We've seen examples where get_bucket_lifecycle_configuration returns + # the updated rules, then the old rules, then the updated rules again and + # again couple of times. + # Thus try to read the rule few times in a row to check if it has changed. + time.sleep(5) + _retries -= 1 + new_rules = fetch_rules(client, module, name) + (_changed, lifecycle_configuration) = compare_and_update_configuration(client, module, new_rules, new_rule) + if not _changed: + _not_changed_cnt -= 1 + _changed = True + else: + _not_changed_cnt = 6 + + if not wait: _retries = 0 new_rules = fetch_rules(client, module, name) - module.exit_json( - changed=changed, - new_rule=new_rule, - rules=new_rules, - old_rules=old_lifecycle_rules, - _retries=_retries, - _config=lifecycle_configuration, - ) + return { + "changed": changed, + "new_rule": new_rule, + "rules": new_rules, + "old_rules": old_lifecycle_rules, + "_retries": _retries, + "_config": lifecycle_configuration, + } def destroy_lifecycle_rule(client, module): @@ -578,7 +576,6 @@ def destroy_lifecycle_rule(client, module): prefix = module.params.get("prefix") rule_id = module.params.get("rule_id") wait = module.params.get("wait") - changed = False if prefix is None: prefix = "" @@ -586,42 +583,42 @@ def destroy_lifecycle_rule(client, module): current_lifecycle_rules = fetch_rules(client, module, name) changed, lifecycle_obj = compare_and_remove_rule(current_lifecycle_rules, rule_id, prefix) - if changed: - # Write lifecycle to bucket or, if there no rules left, delete lifecycle configuration - try: - if lifecycle_obj["Rules"]: - client.put_bucket_lifecycle_configuration( - aws_retry=True, Bucket=name, LifecycleConfiguration=lifecycle_obj - ) - elif current_lifecycle_rules: - changed = True - client.delete_bucket_lifecycle(aws_retry=True, Bucket=name) - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - module.fail_json_aws(e) - - _changed = changed - _retries = 10 - _not_changed_cnt = 6 - while wait and _changed and _retries and _not_changed_cnt: - # We've seen examples where get_bucket_lifecycle_configuration returns - # the updated rules, then the old rules, then the updated rules again and - # again couple of times. - # Thus try to read the rule few times in a row to check if it has changed. - time.sleep(5) - _retries -= 1 - new_rules = fetch_rules(client, module, name) - (_changed, lifecycle_configuration) = compare_and_remove_rule(new_rules, rule_id, prefix) - if not _changed: - _not_changed_cnt -= 1 - _changed = True - else: - _not_changed_cnt = 6 - else: + if not changed: + new_rules = fetch_rules(client, module, name) + return {"changed": False, "rules": new_rules, "_retries": 0} + + if module.check_mode: + return {"changed": True, "rules": current_lifecycle_rules, "_retries": 0} + + # Write lifecycle to bucket or, if there no rules left, delete lifecycle configuration + if lifecycle_obj["Rules"]: + put_bucket_lifecycle_configuration(client, name, lifecycle_obj) + elif current_lifecycle_rules: + delete_bucket_lifecycle(client, name) + + _changed = changed + _retries = 10 + _not_changed_cnt = 6 + while wait and _changed and _retries and _not_changed_cnt: + # We've seen examples where get_bucket_lifecycle_configuration returns + # the updated rules, then the old rules, then the updated rules again and + # again couple of times. + # Thus try to read the rule few times in a row to check if it has changed. + time.sleep(5) + _retries -= 1 + new_rules = fetch_rules(client, module, name) + (_changed, _lifecycle_configuration) = compare_and_remove_rule(new_rules, rule_id, prefix) + if not _changed: + _not_changed_cnt -= 1 + _changed = True + else: + _not_changed_cnt = 6 + + if not wait: _retries = 0 new_rules = fetch_rules(client, module, name) - - module.exit_json(changed=changed, rules=new_rules, old_rules=current_lifecycle_rules, _retries=_retries) + return {"changed": changed, "rules": new_rules, "old_rules": current_lifecycle_rules, "_retries": _retries} def main(): @@ -653,6 +650,7 @@ def main(): module = AnsibleAWSModule( argument_spec=argument_spec, + supports_check_mode=True, mutually_exclusive=[ ["expiration_days", "expiration_date", "expire_object_delete_marker"], ["expiration_days", "transition_date"], @@ -667,7 +665,7 @@ def main(): }, ) - client = module.client("s3", retry_decorator=AWSRetry.jittered_backoff()) + client = module.client("s3") expiration_date = module.params.get("expiration_date") transition_date = module.params.get("transition_date") @@ -710,10 +708,15 @@ def main(): " The time must be midnight and a timezone of GMT must be included" ) - if state == "present": - create_lifecycle_rule(client, module) - elif state == "absent": - destroy_lifecycle_rule(client, module) + try: + if state == "present": + result = create_lifecycle_rule(client, module) + elif state == "absent": + result = destroy_lifecycle_rule(client, module) + + module.exit_json(**result) + except AnsibleS3Error as e: + module.fail_json_aws_error(e) if __name__ == "__main__": diff --git a/plugins/modules/s3_bucket_lifecycle_info.py b/plugins/modules/s3_bucket_lifecycle_info.py new file mode 100644 index 00000000000..387c5767dce --- /dev/null +++ b/plugins/modules/s3_bucket_lifecycle_info.py @@ -0,0 +1,154 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: s3_bucket_lifecycle_info +version_added: 11.1.0 +short_description: Retrieve lifecycle configuration for S3 buckets +description: + - Retrieve lifecycle configuration for S3 buckets. +author: + - Mark Chappell (@tremble) +options: + name: + description: + - Name of the S3 bucket. + required: true + type: str + aliases: ['bucket_name'] +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Note: These examples do not set authentication details, see the AWS Guide for details. + +- name: Retrieve lifecycle configuration for a bucket + community.aws.s3_bucket_lifecycle_info: + name: my-bucket + register: result + +- name: Display lifecycle rules + ansible.builtin.debug: + var: result.lifecycle_rules +""" + +RETURN = r""" +lifecycle_rules: + description: List of lifecycle rules for the bucket + returned: always + type: list + elements: dict + contains: + id: + description: Unique identifier for the rule + returned: when configured + type: str + sample: "Delete old logs" + status: + description: Whether the rule is currently being applied + returned: always + type: str + sample: "Enabled" + prefix: + description: Object key prefix identifying objects to which the rule applies + returned: when configured + type: str + sample: "logs/" + filter: + description: Filter identifying objects to which the rule applies + returned: when configured + type: dict + expiration: + description: Expiration configuration + returned: when configured + type: dict + contains: + days: + description: Number of days after which to expire objects + returned: when configured + type: int + sample: 30 + date: + description: Date when objects will expire + returned: when configured + type: str + expired_object_delete_marker: + description: Remove delete markers with no noncurrent versions + returned: when configured + type: bool + transitions: + description: Transition configurations + returned: when configured + type: list + elements: dict + contains: + days: + description: Number of days after which to transition objects + returned: when configured + type: int + sample: 30 + date: + description: Date when objects will transition + returned: when configured + type: str + storage_class: + description: Storage class to transition to + returned: always + type: str + sample: "GLACIER" + noncurrent_version_expiration: + description: Noncurrent version expiration configuration + returned: when configured + type: dict + noncurrent_version_transitions: + description: Noncurrent version transition configurations + returned: when configured + type: list + elements: dict + abort_incomplete_multipart_upload: + description: Abort incomplete multipart upload configuration + returned: when configured + type: dict +""" + +from ansible_collections.amazon.aws.plugins.module_utils.s3 import AnsibleS3Error + +from ansible_collections.community.aws.plugins.module_utils.modules import AnsibleCommunityAWSModule as AnsibleAWSModule +from ansible_collections.community.aws.plugins.module_utils.s3 import get_bucket_lifecycle_configuration +from ansible_collections.community.aws.plugins.module_utils.s3 import normalize_lifecycle_rules + + +def main(): + argument_spec = dict( + name=dict(required=True, type="str", aliases=["bucket_name"]), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + bucket_name = module.params.get("name") + + try: + client = module.client("s3") + lifecycle_config = get_bucket_lifecycle_configuration(client, bucket_name) + + # Convert to snake_case + result = normalize_lifecycle_rules(lifecycle_config) + + module.exit_json(changed=False, lifecycle_rules=result) + + except AnsibleS3Error as e: + module.fail_json_aws_error(e) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/s3_metrics_configuration.py b/plugins/modules/s3_bucket_metrics_configuration.py similarity index 51% rename from plugins/modules/s3_metrics_configuration.py rename to plugins/modules/s3_bucket_metrics_configuration.py index 4e62b7bf8e4..47a8d74b5bd 100644 --- a/plugins/modules/s3_metrics_configuration.py +++ b/plugins/modules/s3_bucket_metrics_configuration.py @@ -6,11 +6,12 @@ DOCUMENTATION = r""" --- -module: s3_metrics_configuration +module: s3_bucket_metrics_configuration version_added: 1.3.0 short_description: Manage s3 bucket metrics configuration in AWS description: - Manage s3 bucket metrics configuration in AWS which allows to get the CloudWatch request metrics for the objects in a bucket + - Since release 11.1.0 the preferred name is C(community.aws.s3_bucket_metrics_configuration), C(community.aws.s3_metrics_configuration) remains as an alias. author: - Dmytro Vorotyntsev (@vorotech) notes: @@ -95,90 +96,53 @@ state: absent """ -try: - from botocore.exceptions import BotoCoreError - from botocore.exceptions import ClientError -except ImportError: - pass # Handled by AnsibleAWSModule - -from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code -from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry -from ansible_collections.amazon.aws.plugins.module_utils.tagging import ansible_dict_to_boto3_tag_list +from ansible_collections.amazon.aws.plugins.module_utils.s3 import AnsibleS3Error from ansible_collections.community.aws.plugins.module_utils.modules import AnsibleCommunityAWSModule as AnsibleAWSModule +from ansible_collections.community.aws.plugins.module_utils.s3 import create_metrics_configuration +from ansible_collections.community.aws.plugins.module_utils.s3 import delete_bucket_metrics_configuration +from ansible_collections.community.aws.plugins.module_utils.s3 import get_bucket_metrics_configuration +from ansible_collections.community.aws.plugins.module_utils.s3 import put_bucket_metrics_configuration -def _create_metrics_configuration(mc_id, filter_prefix, filter_tags): - payload = {"Id": mc_id} - # Just a filter_prefix or just a single tag filter is a special case - if filter_prefix and not filter_tags: - payload["Filter"] = {"Prefix": filter_prefix} - elif not filter_prefix and len(filter_tags) == 1: - payload["Filter"] = {"Tag": ansible_dict_to_boto3_tag_list(filter_tags)[0]} - # Otherwise we need to use 'And' - elif filter_tags: - payload["Filter"] = {"And": {"Tags": ansible_dict_to_boto3_tag_list(filter_tags)}} - if filter_prefix: - payload["Filter"]["And"]["Prefix"] = filter_prefix - - return payload - - -def create_or_update_metrics_configuration(client, module): +def create_or_update_metrics_configuration(module, client): + """Create or update bucket metrics configuration.""" bucket_name = module.params.get("bucket_name") mc_id = module.params.get("id") filter_prefix = module.params.get("filter_prefix") filter_tags = module.params.get("filter_tags") - try: - response = client.get_bucket_metrics_configuration(aws_retry=True, Bucket=bucket_name, Id=mc_id) - metrics_configuration = response["MetricsConfiguration"] - except is_boto3_error_code("NoSuchConfiguration"): - metrics_configuration = None - except (BotoCoreError, ClientError) as e: # pylint: disable=duplicate-except - module.fail_json_aws(e, msg="Failed to get bucket metrics configuration") - - new_configuration = _create_metrics_configuration(mc_id, filter_prefix, filter_tags) + metrics_configuration = get_bucket_metrics_configuration(client, bucket_name, mc_id) + new_configuration = create_metrics_configuration(mc_id, filter_prefix, filter_tags) - if metrics_configuration: - if metrics_configuration == new_configuration: - module.exit_json(changed=False) + # Check if already in desired state + if metrics_configuration and metrics_configuration == new_configuration: + return False if module.check_mode: - module.exit_json(changed=True) - - try: - client.put_bucket_metrics_configuration( - aws_retry=True, Bucket=bucket_name, Id=mc_id, MetricsConfiguration=new_configuration - ) - except (BotoCoreError, ClientError) as e: # pylint: disable=duplicate-except - module.fail_json_aws(e, msg=f"Failed to put bucket metrics configuration '{mc_id}'") + return True - module.exit_json(changed=True) + put_bucket_metrics_configuration(client, bucket_name, mc_id, new_configuration) + return True -def delete_metrics_configuration(client, module): +def delete_metrics_configuration(module, client): + """Delete bucket metrics configuration.""" bucket_name = module.params.get("bucket_name") mc_id = module.params.get("id") - try: - client.get_bucket_metrics_configuration(aws_retry=True, Bucket=bucket_name, Id=mc_id) - except is_boto3_error_code("NoSuchConfiguration"): - module.exit_json(changed=False) - except (BotoCoreError, ClientError) as e: # pylint: disable=duplicate-except - module.fail_json_aws(e, msg="Failed to get bucket metrics configuration") + metrics_configuration = get_bucket_metrics_configuration(client, bucket_name, mc_id) - if module.check_mode: - module.exit_json(changed=True) + # Check if already absent + if not metrics_configuration: + return False - try: - client.delete_bucket_metrics_configuration(aws_retry=True, Bucket=bucket_name, Id=mc_id) - except is_boto3_error_code("NoSuchConfiguration"): - module.exit_json(changed=False) - except (BotoCoreError, ClientError) as e: # pylint: disable=duplicate-except - module.fail_json_aws(e, msg=f"Failed to delete bucket metrics configuration '{mc_id}'") + if module.check_mode: + return True - module.exit_json(changed=True) + result = delete_bucket_metrics_configuration(client, bucket_name, mc_id) + # If result is None, configuration didn't exist (race condition) + return result is not None def main(): @@ -191,17 +155,18 @@ def main(): ) module = AnsibleAWSModule(argument_spec=argument_spec, supports_check_mode=True) - state = module.params.get("state") - try: - client = module.client("s3", retry_decorator=AWSRetry.exponential_backoff(retries=10, delay=3)) - except (BotoCoreError, ClientError) as e: - module.fail_json_aws(e, msg="Failed to connect to AWS") - - if state == "present": - create_or_update_metrics_configuration(client, module) - elif state == "absent": - delete_metrics_configuration(client, module) + client = module.client("s3") + state = module.params.get("state") + + if state == "present": + changed = create_or_update_metrics_configuration(module, client) + else: # absent + changed = delete_metrics_configuration(module, client) + + module.exit_json(changed=changed) + except AnsibleS3Error as e: + module.fail_json_aws_error(e) if __name__ == "__main__": diff --git a/plugins/modules/s3_bucket_metrics_configuration_info.py b/plugins/modules/s3_bucket_metrics_configuration_info.py new file mode 100644 index 00000000000..9dedeb4ea75 --- /dev/null +++ b/plugins/modules/s3_bucket_metrics_configuration_info.py @@ -0,0 +1,161 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: s3_bucket_metrics_configuration_info +version_added: 11.1.0 +short_description: Retrieve information about S3 bucket metrics configurations +description: + - Retrieve information about S3 bucket metrics configurations. + - Metrics configurations allow CloudWatch request metrics for objects in a bucket. +author: + - Mark Chappell (@tremble) +options: + name: + description: + - Name of the S3 bucket. + required: true + type: str + aliases: ['bucket_name'] + id: + description: + - The ID of a specific metrics configuration to retrieve. + - If not specified, all metrics configurations for the bucket will be returned. + required: false + type: str +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Note: These examples do not set authentication details, see the AWS Guide for details. + +- name: Retrieve all metrics configurations for a bucket + community.aws.s3_metrics_configuration_info: + name: my-bucket + register: result + +- name: Retrieve a specific metrics configuration + community.aws.s3_metrics_configuration_info: + name: my-bucket + id: EntireBucket + register: result +""" + +RETURN = r""" +metrics_configurations: + description: List of metrics configurations for the bucket + returned: always + type: list + elements: dict + contains: + id: + description: The ID used to identify the metrics configuration + returned: always + type: str + sample: "EntireBucket" + filter: + description: Filter criteria for the metrics configuration + returned: when filter is configured + type: dict + contains: + prefix: + description: Prefix filter + returned: when configured + type: str + sample: "documents/" + tags: + description: Tag filters + returned: when configured + type: dict + sample: { "priority": "high", "class": "blue" } +""" + +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry +from ansible_collections.amazon.aws.plugins.module_utils.s3 import AnsibleS3Error +from ansible_collections.amazon.aws.plugins.module_utils.s3 import S3ErrorHandler + +from ansible_collections.community.aws.plugins.module_utils.modules import AnsibleCommunityAWSModule as AnsibleAWSModule +from ansible_collections.community.aws.plugins.module_utils.s3 import normalize_metrics_configuration + + +@AWSRetry.jittered_backoff(max_delay=120, catch_extra_error_codes=["OperationAborted"]) +def _list_bucket_metrics_configurations_with_token(client, bucket_name, continuation_token=None): + """List bucket metrics configurations with optional continuation token.""" + params = {"Bucket": bucket_name} + if continuation_token: + params["ContinuationToken"] = continuation_token + return client.list_bucket_metrics_configurations(**params) + + +@S3ErrorHandler.list_error_handler("list bucket metrics configurations", []) +def list_bucket_metrics_configurations(client, bucket_name): + """List all metrics configurations for a bucket.""" + configurations = [] + continuation_token = None + + while True: + response = _list_bucket_metrics_configurations_with_token(client, bucket_name, continuation_token) + configurations.extend(response.get("MetricsConfigurationList", [])) + + if not response.get("IsTruncated"): + break + continuation_token = response.get("NextContinuationToken") + + return configurations + + +@S3ErrorHandler.list_error_handler("get bucket metrics configuration", {}) +@AWSRetry.jittered_backoff(max_delay=120, catch_extra_error_codes=["OperationAborted"]) +def get_bucket_metrics_configuration(client, bucket_name, metrics_id): + """Get a specific metrics configuration.""" + try: + response = client.get_bucket_metrics_configuration(Bucket=bucket_name, Id=metrics_id) + return response.get("MetricsConfiguration", {}) + except is_boto3_error_code("NoSuchConfiguration"): + return {} + + +def main(): + argument_spec = dict( + name=dict(required=True, type="str", aliases=["bucket_name"]), + id=dict(required=False, type="str"), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + bucket_name = module.params.get("name") + metrics_id = module.params.get("id") + + try: + client = module.client("s3") + + if metrics_id: + config = get_bucket_metrics_configuration(client, bucket_name, metrics_id) + if config: + result = [normalize_metrics_configuration(config)] + else: + result = [] + else: + configs = list_bucket_metrics_configurations(client, bucket_name) + result = [normalize_metrics_configuration(config) for config in configs] + result = [r for r in result if r is not None] + + module.exit_json(changed=False, metrics_configurations=result) + + except AnsibleS3Error as e: + module.fail_json_aws_error(e) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/s3_bucket_notification.py b/plugins/modules/s3_bucket_notification.py index 1045164dce3..4f26e1a2a23 100644 --- a/plugins/modules/s3_bucket_notification.py +++ b/plugins/modules/s3_bucket_notification.py @@ -156,15 +156,12 @@ type: list """ -try: - from botocore.exceptions import BotoCoreError - from botocore.exceptions import ClientError -except ImportError: - pass # will be protected by AnsibleAWSModule - -from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict +from ansible_collections.amazon.aws.plugins.module_utils.s3 import AnsibleS3Error from ansible_collections.community.aws.plugins.module_utils.modules import AnsibleCommunityAWSModule as AnsibleAWSModule +from ansible_collections.community.aws.plugins.module_utils.s3 import build_notification_configuration +from ansible_collections.community.aws.plugins.module_utils.s3 import get_bucket_notification_configuration +from ansible_collections.community.aws.plugins.module_utils.s3 import put_bucket_notification_configuration class AmazonBucket: @@ -181,10 +178,7 @@ def full_config(self): QueueConfigurations=[], TopicConfigurations=[], LambdaFunctionConfigurations=[] ) - try: - config_lookup = self.client.get_bucket_notification_configuration(Bucket=self.bucket_name) - except (ClientError, BotoCoreError) as e: - self.module.fail_json(msg=f"{e}") + config_lookup = get_bucket_notification_configuration(self.client, self.bucket_name) # Handle different event targets if config_lookup.get("QueueConfigurations"): @@ -240,18 +234,17 @@ def delete_config(self, desired): return configs def _upload_bucket_config(self, configs): - api_params = dict(Bucket=self.bucket_name, NotificationConfiguration=dict()) + notification_config = dict() # Iterate through available configs for target_configs in configs: if len(configs[target_configs]) > 0: - api_params["NotificationConfiguration"][target_configs] = configs[target_configs] + notification_config[target_configs] = configs[target_configs] if not self.check_mode: - try: - self.client.put_bucket_notification_configuration(**api_params) - except (ClientError, BotoCoreError) as e: - self.module.fail_json(msg=f"{e}") + put_bucket_notification_configuration(self.client, self.bucket_name, notification_config) + # Invalidate cache so next call to full_config() retrieves updated configuration + self._full_config_cache = None class Config: @@ -352,39 +345,55 @@ def setup_module_object(): ) -def main(): - module = setup_module_object() +def create_or_update_bucket_notification(module, client): + """Create or update bucket notification configuration.""" + bucket = AmazonBucket(module, client) + current = bucket.current_config(module.params["event_name"]) + desired = Config.from_params(**module.params) + + # Check if already in desired state + if current == desired: + return False, build_notification_configuration(bucket.full_config()) - client = module.client("s3") + if module.check_mode: + return True, build_notification_configuration(bucket.full_config()) + + bucket.apply_config(desired) + return True, build_notification_configuration(bucket.full_config()) + + +def delete_bucket_notification(module, client): + """Delete bucket notification configuration.""" bucket = AmazonBucket(module, client) current = bucket.current_config(module.params["event_name"]) desired = Config.from_params(**module.params) - notification_configs = dict(QueueConfigurations=[], TopicConfigurations=[], LambdaFunctionConfigurations=[]) + # Check if already absent + if not current: + return False, build_notification_configuration(bucket.full_config()) + + if module.check_mode: + return True, build_notification_configuration(bucket.full_config()) - for target_configs in bucket.full_config(): - for cfg in bucket.full_config()[target_configs]: - notification_configs[target_configs].append(camel_dict_to_snake_dict(cfg.raw)) + bucket.delete_config(desired) + return True, build_notification_configuration(bucket.full_config()) - state = module.params["state"] - updated_configuration = dict() - changed = False - if state == "present": - if current != desired: - updated_configuration = bucket.apply_config(desired) - changed = True - elif state == "absent": - if current: - updated_configuration = bucket.delete_config(desired) - changed = True +def main(): + module = setup_module_object() + + try: + client = module.client("s3") + state = module.params["state"] - for target_configs in updated_configuration: - notification_configs[target_configs] = [] - for cfg in updated_configuration.get(target_configs, list()): - notification_configs[target_configs].append(camel_dict_to_snake_dict(cfg)) + if state == "present": + changed, notification_configuration = create_or_update_bucket_notification(module, client) + else: # absent + changed, notification_configuration = delete_bucket_notification(module, client) - module.exit_json(changed=changed, notification_configuration=camel_dict_to_snake_dict(notification_configs)) + module.exit_json(changed=changed, notification_configuration=notification_configuration) + except AnsibleS3Error as e: + module.fail_json_aws_error(e) if __name__ == "__main__": diff --git a/plugins/modules/s3_bucket_notification_info.py b/plugins/modules/s3_bucket_notification_info.py new file mode 100644 index 00000000000..1cf0008a85e --- /dev/null +++ b/plugins/modules/s3_bucket_notification_info.py @@ -0,0 +1,161 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: s3_bucket_notification_info +version_added: 11.1.0 +short_description: Retrieve notification configuration for S3 buckets +description: + - Retrieve notification configuration for S3 buckets. +author: + - Mark Chappell (@tremble) +options: + name: + description: + - Name of the S3 bucket. + required: true + type: str + aliases: ['bucket_name'] +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Note: These examples do not set authentication details, see the AWS Guide for details. + +- name: Retrieve notification configuration for a bucket + community.aws.s3_bucket_notification_info: + name: my-bucket + register: result + +- name: Display notification configuration + ansible.builtin.debug: + var: result.notification_configuration +""" + +RETURN = r""" +notification_configuration: + description: Notification configuration for the bucket + returned: always + type: dict + contains: + lambda_function_configurations: + description: List of Lambda function notification configurations + returned: when configured + type: list + elements: dict + contains: + id: + description: Unique identifier for the configuration + returned: when configured + type: str + sample: "lambda-notification-1" + lambda_function_arn: + description: ARN of the Lambda function + returned: always + type: str + sample: "arn:aws:lambda:us-east-1:123456789012:function:my-function" + events: + description: S3 bucket events for which to send notifications + returned: always + type: list + elements: str + sample: ["s3:ObjectCreated:*"] + filter: + description: Filtering rules to determine which objects trigger notifications + returned: when configured + type: dict + queue_configurations: + description: List of SQS queue notification configurations + returned: when configured + type: list + elements: dict + contains: + id: + description: Unique identifier for the configuration + returned: when configured + type: str + sample: "sqs-notification-1" + queue_arn: + description: ARN of the SQS queue + returned: always + type: str + sample: "arn:aws:sqs:us-east-1:123456789012:my-queue" + events: + description: S3 bucket events for which to send notifications + returned: always + type: list + elements: str + sample: ["s3:ObjectCreated:*"] + filter: + description: Filtering rules to determine which objects trigger notifications + returned: when configured + type: dict + topic_configurations: + description: List of SNS topic notification configurations + returned: when configured + type: list + elements: dict + contains: + id: + description: Unique identifier for the configuration + returned: when configured + type: str + sample: "sns-notification-1" + topic_arn: + description: ARN of the SNS topic + returned: always + type: str + sample: "arn:aws:sns:us-east-1:123456789012:my-topic" + events: + description: S3 bucket events for which to send notifications + returned: always + type: list + elements: str + sample: ["s3:ObjectCreated:*"] + filter: + description: Filtering rules to determine which objects trigger notifications + returned: when configured + type: dict +""" + +from ansible_collections.amazon.aws.plugins.module_utils.s3 import AnsibleS3Error + +from ansible_collections.community.aws.plugins.module_utils.modules import AnsibleCommunityAWSModule as AnsibleAWSModule +from ansible_collections.community.aws.plugins.module_utils.s3 import get_bucket_notification_configuration +from ansible_collections.community.aws.plugins.module_utils.s3 import normalize_notification_configuration + + +def main(): + argument_spec = dict( + name=dict(required=True, type="str", aliases=["bucket_name"]), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + bucket_name = module.params.get("name") + + try: + client = module.client("s3") + notification_config = get_bucket_notification_configuration(client, bucket_name) + + # Convert to snake_case + result = normalize_notification_configuration(notification_config) + + module.exit_json(changed=False, notification_configuration=result) + + except AnsibleS3Error as e: + module.fail_json_aws_error(e) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/s3_website.py b/plugins/modules/s3_bucket_website.py similarity index 56% rename from plugins/modules/s3_website.py rename to plugins/modules/s3_bucket_website.py index fb590b658ea..74becf695e3 100644 --- a/plugins/modules/s3_website.py +++ b/plugins/modules/s3_bucket_website.py @@ -6,11 +6,12 @@ DOCUMENTATION = r""" --- -module: s3_website +module: s3_bucket_website version_added: 1.0.0 short_description: Configure an s3 bucket as a website description: - Configure an s3 bucket as a website + - Since release 11.1.0 the preferred name is C(community.aws.s3_bucket_website), C(community.aws.s3_website) remains as an alias. author: - Rob White (@wimnat) options: @@ -158,141 +159,78 @@ import time -try: - import botocore -except ImportError: - pass # Handled by AnsibleAWSModule - from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict -from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.s3 import AnsibleS3Error from ansible_collections.community.aws.plugins.module_utils.modules import AnsibleCommunityAWSModule as AnsibleAWSModule +from ansible_collections.community.aws.plugins.module_utils.s3 import create_website_configuration +from ansible_collections.community.aws.plugins.module_utils.s3 import delete_bucket_website +from ansible_collections.community.aws.plugins.module_utils.s3 import get_bucket_website +from ansible_collections.community.aws.plugins.module_utils.s3 import put_bucket_website -def _create_redirect_dict(url): - redirect_dict = {} - url_split = url.split(":") - - # Did we split anything? - if len(url_split) == 2: - redirect_dict["Protocol"] = url_split[0] - redirect_dict["HostName"] = url_split[1].replace("//", "") - elif len(url_split) == 1: - redirect_dict["HostName"] = url_split[0] - else: - raise ValueError("Redirect URL appears invalid") - - return redirect_dict - - -def _create_website_configuration(suffix, error_key, redirect_all_requests): - website_configuration = {} - - if error_key is not None: - website_configuration["ErrorDocument"] = {"Key": error_key} - - if suffix is not None: - website_configuration["IndexDocument"] = {"Suffix": suffix} - - if redirect_all_requests is not None: - website_configuration["RedirectAllRequestsTo"] = _create_redirect_dict(redirect_all_requests) - - return website_configuration - - -def enable_or_update_bucket_as_website(client_connection, resource_connection, module): +def create_or_update_bucket_website(module, client): + """Create or update bucket website configuration.""" bucket_name = module.params.get("name") redirect_all_requests = module.params.get("redirect_all_requests") + # If redirect_all_requests is set then don't use the default suffix that has been set if redirect_all_requests is not None: suffix = None else: suffix = module.params.get("suffix") error_key = module.params.get("error_key") - changed = False - try: - bucket_website = resource_connection.BucketWebsite(bucket_name) - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - module.fail_json_aws(e, msg="Failed to get bucket") + website_config = get_bucket_website(client, bucket_name) - try: - website_config = client_connection.get_bucket_website(Bucket=bucket_name) - except is_boto3_error_code("NoSuchWebsiteConfiguration"): - website_config = None - except ( - botocore.exceptions.ClientError, - botocore.exceptions.BotoCoreError, - ) as e: # pylint: disable=duplicate-except - module.fail_json_aws(e, msg="Failed to get website configuration") - - if website_config is None: - try: - bucket_website.put( - WebsiteConfiguration=_create_website_configuration(suffix, error_key, redirect_all_requests) - ) - changed = True - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - module.fail_json_aws(e, msg="Failed to set bucket website configuration") - except ValueError as e: - module.fail_json(msg=str(e)) + # Check if update needed + needs_update = False + + if not website_config: + needs_update = True else: try: - if ( - (suffix is not None and website_config["IndexDocument"]["Suffix"] != suffix) - or (error_key is not None and website_config["ErrorDocument"]["Key"] != error_key) - or ( - redirect_all_requests is not None - and website_config["RedirectAllRequestsTo"] != _create_redirect_dict(redirect_all_requests) - ) - ): - try: - bucket_website.put( - WebsiteConfiguration=_create_website_configuration(suffix, error_key, redirect_all_requests) - ) - changed = True - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - module.fail_json_aws(e, msg="Failed to update bucket website configuration") - except KeyError: - try: - bucket_website.put( - WebsiteConfiguration=_create_website_configuration(suffix, error_key, redirect_all_requests) - ) - changed = True - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - module.fail_json_aws(e, msg="Failed to update bucket website configuration") - except ValueError as e: - module.fail_json(msg=str(e)) - - # Wait 5 secs before getting the website_config again to give it time to update - time.sleep(5) - - website_config = client_connection.get_bucket_website(Bucket=bucket_name) - module.exit_json(changed=changed, **camel_dict_to_snake_dict(website_config)) - - -def disable_bucket_as_website(client_connection, module): - changed = False - bucket_name = module.params.get("name") + # Compare against what the new configuration would be + new_configuration = create_website_configuration(suffix, error_key, redirect_all_requests) + if website_config != new_configuration: + needs_update = True + except (KeyError, ValueError): + # If config is malformed, update it + needs_update = True - try: - client_connection.get_bucket_website(Bucket=bucket_name) - except is_boto3_error_code("NoSuchWebsiteConfiguration"): - module.exit_json(changed=changed) - except ( - botocore.exceptions.ClientError, - botocore.exceptions.BotoCoreError, - ) as e: # pylint: disable=duplicate-except - module.fail_json_aws(e, msg="Failed to get bucket website") + if not needs_update: + return False + + if module.check_mode: + return True try: - client_connection.delete_bucket_website(Bucket=bucket_name) - changed = True - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - module.fail_json_aws(e, msg="Failed to delete bucket website") + new_config = create_website_configuration(suffix, error_key, redirect_all_requests) + put_bucket_website(client, bucket_name, new_config) + except ValueError as e: + module.fail_json(msg=str(e)) - module.exit_json(changed=changed) + # Wait 5 secs before returning to give it time to update + time.sleep(5) + return True + + +def delete_bucket_website_configuration(module, client): + """Delete bucket website configuration.""" + bucket_name = module.params.get("name") + + website_config = get_bucket_website(client, bucket_name) + + # Check if already absent + if not website_config: + return False + + if module.check_mode: + return True + + delete_bucket_website(client, bucket_name) + return True def main(): @@ -306,6 +244,7 @@ def main(): module = AnsibleAWSModule( argument_spec=argument_spec, + supports_check_mode=True, mutually_exclusive=[ ["redirect_all_requests", "suffix"], ["redirect_all_requests", "error_key"], @@ -313,17 +252,19 @@ def main(): ) try: - client_connection = module.client("s3") - resource_connection = module.resource("s3") - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - module.fail_json_aws(e, msg="Failed to connect to AWS") - - state = module.params.get("state") - - if state == "present": - enable_or_update_bucket_as_website(client_connection, resource_connection, module) - elif state == "absent": - disable_bucket_as_website(client_connection, module) + client = module.client("s3") + state = module.params.get("state") + + if state == "present": + changed = create_or_update_bucket_website(module, client) + else: # absent + changed = delete_bucket_website_configuration(module, client) + + # Get final config for return value + website_config = get_bucket_website(client, module.params["name"]) + module.exit_json(changed=changed, **camel_dict_to_snake_dict(website_config)) + except AnsibleS3Error as e: + module.fail_json_aws_error(e) if __name__ == "__main__": diff --git a/plugins/modules/s3_bucket_website_info.py b/plugins/modules/s3_bucket_website_info.py new file mode 100644 index 00000000000..8ce67473a67 --- /dev/null +++ b/plugins/modules/s3_bucket_website_info.py @@ -0,0 +1,169 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: s3_bucket_website_info +version_added: 11.1.0 +short_description: Retrieve website configuration for S3 buckets +description: + - Retrieve website configuration for S3 buckets. +author: + - Mark Chappell (@tremble) +options: + name: + description: + - Name of the S3 bucket. + required: true + type: str + aliases: ['bucket_name'] +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Note: These examples do not set authentication details, see the AWS Guide for details. + +- name: Retrieve website configuration for a bucket + community.aws.s3_bucket_website_info: + name: my-bucket + register: result + +- name: Display website configuration + ansible.builtin.debug: + var: result.website_configuration +""" + +RETURN = r""" +website_configuration: + description: Website configuration for the bucket (empty dict if not configured) + returned: always + type: dict + contains: + error_document: + description: Error document configuration + returned: when configured + type: dict + contains: + key: + description: Object key name to use when a 4XX class error occurs + returned: always + type: str + sample: "error.html" + index_document: + description: Index document configuration + returned: when configured + type: dict + contains: + suffix: + description: Suffix that is appended to a request for a directory + returned: always + type: str + sample: "index.html" + redirect_all_requests_to: + description: Redirect all requests to another host + returned: when configured + type: dict + contains: + host_name: + description: Name of the host where requests will be redirected + returned: always + type: str + sample: "example.com" + protocol: + description: Protocol to use when redirecting requests + returned: when configured + type: str + sample: "https" + routing_rules: + description: Routing rules + returned: when configured + type: list + elements: dict + contains: + condition: + description: Condition that must be met for the redirect to apply + returned: when configured + type: dict + contains: + http_error_code_returned_equals: + description: HTTP error code when the redirect is applied + returned: when configured + type: str + sample: "404" + key_prefix_equals: + description: Object key name prefix when the redirect is applied + returned: when configured + type: str + sample: "docs/" + redirect: + description: Redirect information + returned: always + type: dict + contains: + host_name: + description: Name of the host where requests will be redirected + returned: when configured + type: str + sample: "example.com" + http_redirect_code: + description: HTTP redirect code to use on the response + returned: when configured + type: str + sample: "301" + protocol: + description: Protocol to use when redirecting requests + returned: when configured + type: str + sample: "https" + replace_key_prefix_with: + description: Object key prefix to use in the redirect request + returned: when configured + type: str + sample: "documents/" + replace_key_with: + description: Specific object key to use in the redirect request + returned: when configured + type: str + sample: "error.html" +""" + +from ansible_collections.amazon.aws.plugins.module_utils.s3 import AnsibleS3Error + +from ansible_collections.community.aws.plugins.module_utils.modules import AnsibleCommunityAWSModule as AnsibleAWSModule +from ansible_collections.community.aws.plugins.module_utils.s3 import get_bucket_website +from ansible_collections.community.aws.plugins.module_utils.s3 import normalize_website_configuration + + +def main(): + argument_spec = dict( + name=dict(required=True, type="str", aliases=["bucket_name"]), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + bucket_name = module.params.get("name") + + try: + client = module.client("s3") + website_config = get_bucket_website(client, bucket_name) + + # Convert to snake_case + result = normalize_website_configuration(website_config) + + module.exit_json(changed=False, website_configuration=result) + + except AnsibleS3Error as e: + module.fail_json_aws_error(e) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/s3_sync.py b/plugins/modules/s3_sync.py index 36809ed2f75..bb0f0df6b8c 100644 --- a/plugins/modules/s3_sync.py +++ b/plugins/modules/s3_sync.py @@ -242,11 +242,6 @@ except ImportError: HAS_DATEUTIL = False -try: - import botocore -except ImportError: - pass # Handled by AnsibleAWSModule - from ansible.module_utils._text import to_text from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code @@ -512,42 +507,34 @@ def main(): result = {} mode = module.params["mode"] - - try: - s3 = module.client("s3") - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - module.fail_json_aws(e, msg="Failed to connect to AWS") + s3 = module.client("s3") if mode == "push": + result["filelist_initial"] = gather_files( + module.params["file_root"], exclude=module.params["exclude"], include=module.params["include"] + ) + result["filelist_typed"] = determine_mimetypes(result["filelist_initial"], module.params.get("mime_map")) + result["filelist_s3"] = calculate_s3_path(result["filelist_typed"], module.params["key_prefix"]) try: - result["filelist_initial"] = gather_files( - module.params["file_root"], exclude=module.params["exclude"], include=module.params["include"] - ) - result["filelist_typed"] = determine_mimetypes(result["filelist_initial"], module.params.get("mime_map")) - result["filelist_s3"] = calculate_s3_path(result["filelist_typed"], module.params["key_prefix"]) - try: - result["filelist_local_etag"] = calculate_local_etag(result["filelist_s3"]) - except ValueError as e: - if module.params["file_change_strategy"] == "checksum": - module.fail_json_aws( - e, - "Unable to calculate checksum. If running in FIPS mode, you may need to use another file_change_strategy", - ) - result["filelist_local_etag"] = result["filelist_s3"].copy() - result["filelist_actionable"] = filter_list( - s3, module.params["bucket"], result["filelist_local_etag"], module.params["file_change_strategy"] - ) - result["uploads"] = upload_files(s3, module.params["bucket"], result["filelist_actionable"], module.params) - - if module.params["delete"]: - result["removed"] = remove_files(s3, result["filelist_local_etag"], module.params) - - # mark changed if we actually upload something. - if result.get("uploads") or result.get("removed"): - result["changed"] = True - # result.update(filelist=actionable_filelist) - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - module.fail_json_aws(e, msg="Failed to push file") + result["filelist_local_etag"] = calculate_local_etag(result["filelist_s3"]) + except ValueError as e: + if module.params["file_change_strategy"] == "checksum": + module.fail_json_aws( + e, + "Unable to calculate checksum. If running in FIPS mode, you may need to use another file_change_strategy", + ) + result["filelist_local_etag"] = result["filelist_s3"].copy() + result["filelist_actionable"] = filter_list( + s3, module.params["bucket"], result["filelist_local_etag"], module.params["file_change_strategy"] + ) + result["uploads"] = upload_files(s3, module.params["bucket"], result["filelist_actionable"], module.params) + + if module.params["delete"]: + result["removed"] = remove_files(s3, result["filelist_local_etag"], module.params) + + # mark changed if we actually upload something. + if result.get("uploads") or result.get("removed"): + result["changed"] = True module.exit_json(**result) diff --git a/pyproject.toml b/pyproject.toml index 6c835cbedce..1ca49f0c26e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,8 @@ line-length = 120 target-version = ['py38', 'py39'] extend-exclude = ''' /( - | plugins/module_utils/_version.py + | \.tox + | tests/output )/ ''' @@ -21,6 +22,7 @@ src = [ profile = "black" force_single_line = true line_length = 120 +skip = [".tox", "tests/output"] src_paths = [ "plugins", @@ -36,6 +38,10 @@ known_ansible_community_aws = ["ansible_collections.community.aws"] [tool.flynt] transform-joins = true +exclude = [ + ".tox", + "tests/output", +] [tool.flake8] # E123, E125 skipped as they are invalid PEP-8. @@ -43,18 +49,110 @@ show-source = true ignore = ["E123", "E125", "E203", "E402", "E501", "E741", "F401", "F811", "F841", "W503"] max-line-length = 160 builtins = "_" +exclude = [".tox", "tests/output"] [tool.mypy] disable_error_code = ["import-untyped"] +[tool.pylint.main] +ignore = [".tox", "tests/output"] + +[tool.pylint.imports] +allow-reexport-from-package = true + [tool.ruff] line-length = 120 +target-version = "py39" +exclude = [ + ".tox", + "tests/output", +] [tool.ruff.lint] -# "F401" - unused-imports - We use these imports to maintain historic Interfaces -# "E402" - import not at top of file - General Ansible style puts the documentation at the top. -unfixable = ["F401"] -ignore = ["F401", "E402"] +# Enable rule categories +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "C90", # mccabe complexity + "N", # pep8-naming + "RUF", # ruff-specific rules +] + +ignore = [ + # Ansible-specific patterns + "E402", # module-level-import-not-at-top-of-file - Ansible puts DOCUMENTATION first + "UP009", # utf-8-encoding-declaration - Ansible modules require this for compatibility + + # Python 3.10+ syntax (we support 3.9+) + "UP007", # non-pep604-annotation-union - Use Union[X, Y] not X | Y (requires 3.10+) + "UP045", # non-pep604-annotation-optional - Use Optional[X] not X | None (requires 3.10+) + + # TODO: Enable before 12.0.0 release (Sept/Oct 2026) + # These modernizations are valid for Python 3.9+ but complicate backporting + "UP006", # non-pep585-annotation - Use list[str] not List[str] + "UP035", # deprecated-import - Import from collections.abc, use builtin types + + # TODO: Refactor complex functions (technical debt) + "C901", # complex-structure - 29 functions exceed complexity threshold + + # Flake8 compatibility (from existing config) + # E123, E125, W503 - Not implemented in ruff (deprecated/invalid PEP-8 rules) + "E203", # whitespace before ':' (conflicts with formatter) + "E501", # line too long (handled by formatter at 120 chars) + "E741", # ambiguous variable name (too strict for AWS API names) + "F811", # redefinition of unused name + "F841", # local variable assigned but never used + + # Naming exceptions for Ansible/AWS patterns + "N802", # function name should be lowercase (AWS API methods use CamelCase) + "N803", # argument name should be lowercase (AWS API parameters) + "N806", # variable in function should be lowercase (AWS API responses) + "N818", # exception name should end with Error - too disruptive to change existing exceptions +] + +# Rules that cannot be auto-fixed (require manual intervention) +unfixable = [ + "F401", # unused-import - Don't auto-remove due to historic interfaces + "F841", # unused-variable - May break compatibility +] + +[tool.ruff.lint.isort] +# Match existing isort config +force-single-line = true +known-third-party = ["botocore", "boto3"] +section-order = [ + "future", + "standard-library", + "third-party", + "first-party", + "ansible-core", + "ansible-amazon-aws", + "ansible-community-aws", + "local-folder", +] + +[tool.ruff.lint.isort.sections] +"ansible-core" = ["ansible"] +"ansible-amazon-aws" = ["ansible_collections.amazon.aws"] +"ansible-community-aws" = ["ansible_collections.community.aws"] + +[tool.ruff.lint.mccabe] +# Match existing pylint cyclomatic complexity setting +max-complexity = 15 + +[tool.ruff.format] +# Match black config +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.ruff.lint.per-file-ignores] +# Pytest fixtures must be imported to be available +"tests/unit/plugins/conftest.py" = ["F401"] [tool.pytest.ini_options] xfail_strict = true diff --git a/tests/integration/targets/s3_bucket_notification/tasks/test_lambda_notifications.yml b/tests/integration/targets/s3_bucket_notification/tasks/test_lambda_notifications.yml index b4cc8a6e037..ac2e6b947f5 100644 --- a/tests/integration/targets/s3_bucket_notification/tasks/test_lambda_notifications.yml +++ b/tests/integration/targets/s3_bucket_notification/tasks/test_lambda_notifications.yml @@ -50,7 +50,7 @@ ## Start lambda notification target tests - name: register notification without invoke permissions - s3_bucket_notification: + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -82,7 +82,7 @@ seconds: 10 - name: create s3 bucket notification - check_mode - s3_bucket_notification: + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -101,7 +101,7 @@ - result is changed - name: create s3 bucket notification - s3_bucket_notification: + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -123,7 +123,7 @@ - result.notification_configuration.lambda_function_configurations[0].lambda_function_arn == lambda_info.configuration.function_arn - name: create s3 bucket notification - idempotency - s3_bucket_notification: + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -141,7 +141,7 @@ - result is not changed - name: test mutually exclusive parameters - s3_bucket_notification: + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -162,7 +162,7 @@ - "result.msg == 'parameters are mutually exclusive: lambda_alias|lambda_version'" - name: test configuration change on suffix - s3_bucket_notification: + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -180,7 +180,7 @@ - result is changed - name: test configuration change on prefix - s3_bucket_notification: + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -198,7 +198,7 @@ - result is changed - name: test configuration change on new events added - s3_bucket_notification: + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -218,7 +218,7 @@ - result.notification_configuration.lambda_function_configurations[0].events | length == 3 - name: test that event order does not result in change - s3_bucket_notification: + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -237,7 +237,7 @@ - result is not changed - name: test configuration change on events removed - s3_bucket_notification: + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -256,7 +256,7 @@ ## Test removal of event notification - name: test remove notification - s3_bucket_notification: + community.aws.s3_bucket_notification: state: absent event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -268,7 +268,7 @@ - result is changed - name: test that events is already removed - s3_bucket_notification: + community.aws.s3_bucket_notification: state: absent event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" diff --git a/tests/integration/targets/s3_bucket_notification/tasks/test_sns_sqs_notifications.yml b/tests/integration/targets/s3_bucket_notification/tasks/test_sns_sqs_notifications.yml index c5d6d6c1a94..8edb6e7b021 100644 --- a/tests/integration/targets/s3_bucket_notification/tasks/test_sns_sqs_notifications.yml +++ b/tests/integration/targets/s3_bucket_notification/tasks/test_sns_sqs_notifications.yml @@ -76,8 +76,24 @@ - queue_creation is successful ## Start tests + # Test initial state with info module + - name: get bucket notification info (empty) + community.aws.s3_bucket_notification_info: + name: "{{ bucket_name }}" + register: info_result + + - name: assert no notifications configured + assert: + that: + - info_result is successful + - info_result is not changed + - info_result.notification_configuration.lambda_function_configurations | default([]) | length == 0 + - info_result.notification_configuration.queue_configurations | default([]) | length == 0 + - info_result.notification_configuration.topic_configurations | default([]) | length == 0 + + # Create notification: check_mode → change → check_mode idempotency → idempotency - name: create bucket notification - check_mode - s3_bucket_notification: + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -89,13 +105,13 @@ register: result check_mode: true - - name: create bucket notification - check_mode + - name: assert create bucket notification - check_mode assert: that: - result is changed - name: create bucket notification - s3_bucket_notification: + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -106,7 +122,7 @@ suffix: .jpg register: result - - name: create bucket notification + - name: assert create bucket notification assert: that: - result is successful @@ -115,8 +131,25 @@ - result.notification_configuration.topic_configurations[0].id == event_name - result.notification_configuration.topic_configurations[0].topic_arn == topic_creation.sns_arn - - name: create bucket notification - idempotency - s3_bucket_notification: + - name: get bucket notification info (with configuration) + community.aws.s3_bucket_notification_info: + name: "{{ bucket_name }}" + register: info_result + + - name: assert notification is returned in snake_case + assert: + that: + - info_result is successful + - info_result is not changed + - info_result.notification_configuration.topic_configurations | length == 1 + - info_result.notification_configuration.topic_configurations[0].id == event_name + - info_result.notification_configuration.topic_configurations[0].topic_arn == topic_creation.sns_arn + # Verify snake_case format + - "'topic_arn' in info_result.notification_configuration.topic_configurations[0]" + - "'TopicArn' not in info_result.notification_configuration.topic_configurations[0]" + + - name: create bucket notification - idempotency - check_mode + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -126,14 +159,32 @@ prefix: images/ suffix: .jpg register: result + check_mode: true + + - name: assert create bucket notification - idempotency - check_mode + assert: + that: + - result is not changed - name: create bucket notification - idempotency + community.aws.s3_bucket_notification: + state: present + event_name: "{{ event_name }}" + bucket_name: "{{ bucket_name }}" + topic_arn: "{{ topic_creation.sns_arn }}" + events: + - s3:ObjectCreated:* + prefix: images/ + suffix: .jpg + register: result + + - name: assert create bucket notification - idempotency assert: that: - result is not changed - name: test mutually exclusive parameters - s3_bucket_notification: + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -153,7 +204,7 @@ - "result.msg == 'parameters are mutually exclusive: queue_arn|topic_arn|lambda_function_arn'" - name: test configuration change on suffix - s3_bucket_notification: + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -172,7 +223,7 @@ - result.notification_configuration.topic_configurations[0].filter.key.filter_rules[1].value == '.gif' - name: test configuration change on prefix - s3_bucket_notification: + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -191,7 +242,7 @@ - result.notification_configuration.topic_configurations[0].filter.key.filter_rules[0].value == 'photos/' - name: test configuration change on new events added - s3_bucket_notification: + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -211,7 +262,7 @@ - result.notification_configuration.topic_configurations[0].events | length == 3 - name: test configuration change on events removed - s3_bucket_notification: + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -230,7 +281,7 @@ ## Test change of event notification target - name: test changing from sns to sqs target - check_mode - s3_bucket_notification: + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -242,13 +293,13 @@ register: result check_mode: yes - - name: test changing from sns to sqs target - check_mode + - name: assert changing from sns to sqs target - check_mode assert: that: - result is changed - name: test changing from sns to sqs target - s3_bucket_notification: + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -259,15 +310,15 @@ suffix: .gif register: result - - name: test changing from sns to sqs target + - name: assert changing from sns to sqs target assert: that: - result is changed - result.notification_configuration.queue_configurations | length == 1 - result.notification_configuration.queue_configurations[0].queue_arn == queue_creation.queue_arn - - name: test changing from sns to sqs target - idempotency - s3_bucket_notification: + - name: test changing from sns to sqs target - idempotency - check_mode + community.aws.s3_bucket_notification: state: present event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" @@ -277,47 +328,89 @@ prefix: photos/ suffix: .gif register: result + check_mode: true + + - name: assert changing from sns to sqs target - idempotency - check_mode + assert: + that: + - result is not changed - name: test changing from sns to sqs target - idempotency + community.aws.s3_bucket_notification: + state: present + event_name: "{{ event_name }}" + bucket_name: "{{ bucket_name }}" + queue_arn: "{{ queue_creation.queue_arn }}" + events: + - s3:ObjectCreated:Post + prefix: photos/ + suffix: .gif + register: result + + - name: assert changing from sns to sqs target - idempotency assert: that: - result is not changed ## Test removal of event notification - name: remove notification - check_mode - s3_bucket_notification: + community.aws.s3_bucket_notification: state: absent event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" register: result check_mode: yes - - name: remove notification - check_mode + - name: assert remove notification - check_mode assert: that: - result is changed - name: remove notification - s3_bucket_notification: + community.aws.s3_bucket_notification: state: absent event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" register: result - - name: remove notification + - name: assert remove notification assert: that: - result is changed - result.notification_configuration.queue_configurations | length < 1 - - name: remove notification - idempotency - s3_bucket_notification: + - name: get bucket notification info (after removal) + community.aws.s3_bucket_notification_info: + name: "{{ bucket_name }}" + register: info_result + + - name: assert notifications removed + assert: + that: + - info_result is successful + - info_result.notification_configuration.queue_configurations | default([]) | length == 0 + + - name: remove notification - idempotency - check_mode + community.aws.s3_bucket_notification: state: absent event_name: "{{ event_name }}" bucket_name: "{{ bucket_name }}" register: result + check_mode: true + + - name: assert remove notification - idempotency - check_mode + assert: + that: + - result is not changed - name: remove notification - idempotency + community.aws.s3_bucket_notification: + state: absent + event_name: "{{ event_name }}" + bucket_name: "{{ bucket_name }}" + register: result + + - name: assert remove notification - idempotency assert: that: - result is not changed diff --git a/tests/integration/targets/s3_lifecycle/tasks/main.yml b/tests/integration/targets/s3_lifecycle/tasks/main.yml index 947c356ef47..db419f5aa51 100644 --- a/tests/integration/targets/s3_lifecycle/tasks/main.yml +++ b/tests/integration/targets/s3_lifecycle/tasks/main.yml @@ -8,7 +8,7 @@ secret_key: '{{ aws_secret_key }}' session_token: '{{ security_token | default(omit) }}' region: '{{ aws_region }}' - s3_lifecycle: + community.aws.s3_bucket_lifecycle: wait: true block: @@ -26,7 +26,7 @@ - not output.requester_pays # ============================================================ - name: Create a number of policies and check if all of them are created - community.aws.s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: "{{ bucket_name }}" rule_id: "{{ item }}" expiration_days: 10 @@ -44,7 +44,7 @@ - (output.results | last).rules | length == 3 # ============================================================ - name: Remove previously created policies and check if all of them are removed - community.aws.s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: "{{ bucket_name }}" rule_id: "{{ item }}" expiration_days: 10 @@ -62,7 +62,7 @@ - (output.results | last).rules | length == 0 # ============================================================ - name: Create a lifecycle policy - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' expiration_days: 300 prefix: '' @@ -73,7 +73,7 @@ - output is changed # ============================================================ - name: Create a lifecycle policy (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' expiration_days: 300 register: output @@ -83,7 +83,7 @@ - output is not changed # ============================================================ - name: Create a second lifecycle policy - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' transition_days: 30 prefix: /something @@ -94,7 +94,7 @@ - output is changed # ============================================================ - name: Create a second lifecycle policy (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' transition_days: 30 prefix: /something @@ -105,7 +105,7 @@ - output is not changed # ============================================================ - name: Disable the second lifecycle policy - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' status: disabled transition_days: 30 @@ -117,7 +117,7 @@ - output is changed # ============================================================ - name: Disable the second lifecycle policy (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' status: disabled transition_days: 30 @@ -129,7 +129,7 @@ - output is not changed # ============================================================ - name: Re-enable the second lifecycle policy - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' status: enabled transition_days: 300 @@ -141,7 +141,7 @@ - output is changed # ============================================================ - name: Re-enable the second lifecycle policy (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' status: enabled transition_days: 300 @@ -153,7 +153,7 @@ - output is not changed # ============================================================ - name: Delete the second lifecycle policy - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' state: absent prefix: /something @@ -164,7 +164,7 @@ - output is changed # ============================================================ - name: Delete the second lifecycle policy (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' state: absent prefix: /something @@ -175,7 +175,7 @@ - output is not changed # ============================================================ - name: Create a second lifecycle policy, with infrequent access - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' transition_days: 30 storage_class: standard_ia @@ -187,7 +187,7 @@ - output is changed # ============================================================ - name: Create a second lifecycle policy, with infrequent access (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' storage_class: standard_ia transition_days: 30 @@ -199,7 +199,7 @@ - output is not changed # ============================================================ - name: Create a second lifecycle policy, with glacier - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' transition_days: 300 prefix: /something @@ -210,7 +210,7 @@ - output is changed # ============================================================ - name: Create a second lifecycle policy, with glacier (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' transition_days: 300 prefix: /something @@ -221,7 +221,7 @@ - output is not changed # ============================================================ - name: Create a lifecycle policy, with glacier and transition_days to 0 - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' transition_days: 0 storage_class: glacier @@ -233,7 +233,7 @@ - output is changed # ============================================================ - name: Create a lifecycle policy, with glacier and transition_days to 0 (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' transition_days: 0 storage_class: glacier @@ -245,7 +245,7 @@ - output is not changed # ============================================================ - name: Create a lifecycle policy with infrequent access - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' transition_days: 30 storage_class: standard_ia @@ -253,7 +253,7 @@ register: output - name: Create a second lifecycle policy, with glacier - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' transition_days: 300 prefix: /something @@ -261,7 +261,7 @@ register: output - name: Create a lifecycle policy with infrequent access (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' storage_class: standard_ia transition_days: 30 @@ -274,7 +274,7 @@ - output is not changed - name: Create a second lifecycle policy, with glacier (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' transition_days: 300 prefix: /something @@ -286,7 +286,7 @@ - output is not changed # ============================================================ - name: Create a lifecycle policy, with noncurrent expiration - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' noncurrent_version_expiration_days: 300 prefix: /something @@ -297,7 +297,7 @@ - output is changed # ============================================================ - name: Create a lifecycle policy, with noncurrent expiration - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' noncurrent_version_expiration_days: 300 prefix: /something @@ -308,7 +308,7 @@ - output is not changed # ============================================================ - name: Create a lifecycle policy, with noncurrent transition - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' noncurrent_version_transition_days: 300 prefix: /something @@ -319,7 +319,7 @@ - output is changed # ============================================================ - name: Create a lifecycle policy, with noncurrent transitions and expirations - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' noncurrent_version_transition_days: 300 prefix: /something @@ -330,7 +330,7 @@ - output is not changed # ============================================================ - name: Create a lifecycle policy, with noncurrent transition - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' noncurrent_version_transition_days: 300 noncurrent_version_storage_class: standard_ia @@ -342,7 +342,7 @@ - output is changed # ============================================================ - name: Create a lifecycle policy, with noncurrent transitions and expirations - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' noncurrent_version_storage_class: standard_ia noncurrent_version_transition_days: 300 @@ -354,7 +354,7 @@ - output is not changed # ============================================================ - name: Create a lifecycle policy, with noncurrent transitions - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' noncurrent_version_transitions: - transition_days: 30 @@ -371,7 +371,7 @@ - output is changed # ============================================================ - name: Create a lifecycle policy, with noncurrent transitions - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' noncurrent_version_transitions: - transition_days: 30 @@ -389,7 +389,7 @@ # ============================================================ - name: Create a lifecycle policy, with abort_incomplete_multipart_upload - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' abort_incomplete_multipart_upload_days: 1 prefix: /something @@ -401,7 +401,7 @@ # ============================================================ - name: Create a lifecycle policy, with abort_incomplete_multipart_upload (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' abort_incomplete_multipart_upload_days: 1 prefix: /something @@ -413,7 +413,7 @@ # ============================================================ - name: Create a lifecycle policy, with expired_object_delete_marker - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' expire_object_delete_marker: True prefix: /something @@ -424,7 +424,7 @@ - output is changed - name: Create a lifecycle policy, with expired_object_delete_marker (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' expire_object_delete_marker: True prefix: /something @@ -436,7 +436,7 @@ # ============================================================ - name: Update lifecycle policy, with noncurrent_version_expiration_days - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' noncurrent_version_expiration_days: 5 prefix: /something @@ -447,7 +447,7 @@ - output is changed - name: Update lifecycle policy, with noncurrent_version_expiration_days (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' noncurrent_version_expiration_days: 5 prefix: /something @@ -459,7 +459,7 @@ # ============================================================ - name: Update lifecycle policy, with noncurrent_version_keep_newer - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' noncurrent_version_expiration_days: 10 noncurrent_version_keep_newer: 6 @@ -471,7 +471,7 @@ - output is changed - name: Update lifecycle policy, with noncurrent_version_keep_newer (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' noncurrent_version_expiration_days: 10 noncurrent_version_keep_newer: 6 @@ -486,7 +486,7 @@ # test all the examples # Configure a lifecycle rule on a bucket to expire (delete) items with a prefix of /logs/ after 30 days - name: example 1 - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' expiration_days: 30 prefix: /logs/ @@ -498,7 +498,7 @@ - output is changed - name: example 1 (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' expiration_days: 30 prefix: /logs/ @@ -511,7 +511,7 @@ # Configure a lifecycle rule to transition all items with a prefix of /logs/ to glacier after 7 days and then delete after 90 days - name: example 2 - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' transition_days: 7 expiration_days: 90 @@ -524,7 +524,7 @@ - output is changed - name: example 2 (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' transition_days: 7 expiration_days: 90 @@ -540,7 +540,7 @@ # Note that midnight GMT must be specified. # Be sure to quote your date strings - name: example 3 - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' transition_date: "2020-12-30T00:00:00.000Z" expiration_date: "2030-12-30T00:00:00.000Z" @@ -553,7 +553,7 @@ - output is changed - name: example 3 (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' transition_date: "2020-12-30T00:00:00.000Z" expiration_date: "2030-12-30T00:00:00.000Z" @@ -567,7 +567,7 @@ # Disable the rule created above - name: example 4 - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' prefix: /logs/ status: disabled @@ -578,7 +578,7 @@ - output is changed - name: example 4 (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' prefix: /logs/ status: disabled @@ -590,7 +590,7 @@ # Delete the lifecycle rule created above - name: example 5 - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' prefix: /logs/ state: absent @@ -600,7 +600,7 @@ - output is changed - name: example 5 (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' prefix: /logs/ state: absent @@ -611,7 +611,7 @@ # Configure a lifecycle rule to transition all backup files older than 31 days in /backups/ to standard infrequent access class. - name: example 6 - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' prefix: /backups/ storage_class: standard_ia @@ -624,7 +624,7 @@ - output is changed - name: example 6 (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' prefix: /backups/ storage_class: standard_ia @@ -638,7 +638,7 @@ # Configure a lifecycle rule to transition files to infrequent access after 30 days and glacier after 90 - name: example 7 - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' prefix: /other_logs/ state: present @@ -654,7 +654,7 @@ - output is changed - name: example 7 (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' prefix: /other_logs/ state: present @@ -671,7 +671,7 @@ # Check create and delete lifecycle policy with an empty prefix - name: Create rule with no prefix - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: "{{ bucket_name }}" rule_id: empty-prefix state: present @@ -683,7 +683,7 @@ - output is changed - name: Delete rule with no prefix - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: "{{ bucket_name }}" rule_id: empty-prefix state: absent @@ -696,7 +696,7 @@ # Check create and delete lifecycle policy with minimum and maximum object size (with prefix) - name: Create rule with minimum and maximum object size - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: "{{ bucket_name }}" rule_id: minimum-object-size-prefix prefix: /something/ @@ -711,7 +711,7 @@ - output is changed - name: Create rule with minimum object size (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: "{{ bucket_name }}" rule_id: minimum-object-size-prefix prefix: /something/ @@ -726,7 +726,7 @@ - output is not changed - name: Delete rule with minimum and maximum object size - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: "{{ bucket_name }}" rule_id: minimum-object-size-prefix prefix: /something/ @@ -742,7 +742,7 @@ # Check create and delete lifecycle policy with minimum and maximum object size (no prefix) - name: Create rule with minimum and maximum object size - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: "{{ bucket_name }}" rule_id: minimum-object-size-noprefix minimum_object_size: 100 @@ -756,7 +756,7 @@ - output is changed - name: Create rule with minimum object size (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: "{{ bucket_name }}" rule_id: minimum-object-size-noprefix minimum_object_size: 100 @@ -770,7 +770,7 @@ - output is not changed - name: Delete rule with minimum and maximum object size - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: "{{ bucket_name }}" rule_id: minimum-object-size-noprefix minimum_object_size: 100 diff --git a/tests/integration/targets/s3_logging/tasks/main.yml b/tests/integration/targets/s3_logging/tasks/main.yml index 05a73845d02..30facdbb3f7 100644 --- a/tests/integration/targets/s3_logging/tasks/main.yml +++ b/tests/integration/targets/s3_logging/tasks/main.yml @@ -22,7 +22,7 @@ # ============================================================ - name: Try to enable logging before creating target_bucket - s3_logging: + community.aws.s3_logging: state: present name: '{{ test_bucket }}' register: result @@ -67,7 +67,7 @@ # ============================================================ - name: Enable logging (check_mode) - s3_logging: + community.aws.s3_logging: state: present name: '{{ test_bucket }}' target_bucket: '{{ log_bucket_1 }}' @@ -78,7 +78,7 @@ - result is changed - name: Enable logging - s3_logging: + community.aws.s3_logging: state: present name: '{{ test_bucket }}' target_bucket: '{{ log_bucket_1 }}' @@ -88,7 +88,7 @@ - result is changed - name: Enable logging idempotency (check_mode) - s3_logging: + community.aws.s3_logging: state: present name: '{{ test_bucket }}' target_bucket: '{{ log_bucket_1 }}' @@ -99,7 +99,7 @@ - result is not changed - name: Enable logging idempotency - s3_logging: + community.aws.s3_logging: state: present name: '{{ test_bucket }}' target_bucket: '{{ log_bucket_1 }}' @@ -111,7 +111,7 @@ # ============================================================ - name: Change logging bucket (check_mode) - s3_logging: + community.aws.s3_logging: state: present name: '{{ test_bucket }}' target_bucket: '{{ log_bucket_2 }}' @@ -122,7 +122,7 @@ - result is changed - name: Change logging bucket - s3_logging: + community.aws.s3_logging: state: present name: '{{ test_bucket }}' target_bucket: '{{ log_bucket_2 }}' @@ -132,7 +132,7 @@ - result is changed - name: Change logging bucket idempotency (check_mode) - s3_logging: + community.aws.s3_logging: state: present name: '{{ test_bucket }}' target_bucket: '{{ log_bucket_2 }}' @@ -143,7 +143,7 @@ - result is not changed - name: Change logging bucket idempotency - s3_logging: + community.aws.s3_logging: state: present name: '{{ test_bucket }}' target_bucket: '{{ log_bucket_2 }}' @@ -155,7 +155,7 @@ # ============================================================ - name: Change logging prefix (check_mode) - s3_logging: + community.aws.s3_logging: state: present name: '{{ test_bucket }}' target_bucket: '{{ log_bucket_2 }}' @@ -167,7 +167,7 @@ - result is changed - name: Change logging prefix - s3_logging: + community.aws.s3_logging: state: present name: '{{ test_bucket }}' target_bucket: '{{ log_bucket_2 }}' @@ -178,7 +178,7 @@ - result is changed - name: Change logging prefix idempotency (check_mode) - s3_logging: + community.aws.s3_logging: state: present name: '{{ test_bucket }}' target_bucket: '{{ log_bucket_2 }}' @@ -190,7 +190,7 @@ - result is not changed - name: Change logging prefix idempotency - s3_logging: + community.aws.s3_logging: state: present name: '{{ test_bucket }}' target_bucket: '{{ log_bucket_2 }}' @@ -203,7 +203,7 @@ # ============================================================ - name: Remove logging prefix (check_mode) - s3_logging: + community.aws.s3_logging: state: present name: '{{ test_bucket }}' target_bucket: '{{ log_bucket_2 }}' @@ -214,7 +214,7 @@ - result is changed - name: Remove logging prefix - s3_logging: + community.aws.s3_logging: state: present name: '{{ test_bucket }}' target_bucket: '{{ log_bucket_2 }}' @@ -224,7 +224,7 @@ - result is changed - name: Remove logging prefix idempotency (check_mode) - s3_logging: + community.aws.s3_logging: state: present name: '{{ test_bucket }}' target_bucket: '{{ log_bucket_2 }}' @@ -235,7 +235,7 @@ - result is not changed - name: Remove logging prefix idempotency - s3_logging: + community.aws.s3_logging: state: present name: '{{ test_bucket }}' target_bucket: '{{ log_bucket_2 }}' @@ -247,7 +247,7 @@ # ============================================================ - name: Disable logging (check_mode) - s3_logging: + community.aws.s3_logging: state: absent name: '{{ test_bucket }}' register: result @@ -257,7 +257,7 @@ - result is changed - name: Disable logging - s3_logging: + community.aws.s3_logging: state: absent name: '{{ test_bucket }}' register: result @@ -266,7 +266,7 @@ - result is changed - name: Disable logging idempotency (check_mode) - s3_logging: + community.aws.s3_logging: state: absent name: '{{ test_bucket }}' register: result @@ -276,7 +276,7 @@ - result is not changed - name: Disable logging idempotency - s3_logging: + community.aws.s3_logging: state: absent name: '{{ test_bucket }}' register: result diff --git a/tests/integration/targets/s3_metrics_configuration/tasks/main.yml b/tests/integration/targets/s3_metrics_configuration/tasks/main.yml index 9e9f1133aaf..afce67f929b 100644 --- a/tests/integration/targets/s3_metrics_configuration/tasks/main.yml +++ b/tests/integration/targets/s3_metrics_configuration/tasks/main.yml @@ -14,15 +14,10 @@ - amazon.aws block: - # TODO: Until there's a module to get info s3 metrics configuration, awscli is needed - - name: Install awscli - pip: - state: present - name: awscli # ============================================================ - name: Try to create metrics configuration for non-existing bucket - s3_metrics_configuration: + community.aws.s3_bucket_metrics_configuration: bucket_name: '{{ test_bucket }}' id: 'EntireBucket' state: present @@ -47,7 +42,7 @@ # ============================================================ - name: Create a metrics configuration under check mode - s3_metrics_configuration: + community.aws.s3_bucket_metrics_configuration: bucket_name: '{{ test_bucket }}' id: 'EntireBucket' state: present @@ -59,11 +54,11 @@ - assert: that: - result is changed - - metrics_info | selectattr('Id', 'search', 'EntireBucket') | list | length == 0 + - metrics_info | selectattr('id', 'search', 'EntireBucket') | list | length == 0 # ============================================================ - name: Create a metrics configuration that enables metrics for an entire bucket - s3_metrics_configuration: + community.aws.s3_bucket_metrics_configuration: bucket_name: '{{ test_bucket }}' id: 'EntireBucket' state: present @@ -74,11 +69,11 @@ - assert: that: - result is changed - - metrics_info | selectattr('Id', 'search', 'EntireBucket') | list | length == 1 + - metrics_info | selectattr('id', 'search', 'EntireBucket') | list | length == 1 # ============================================================ - name: Create a metrics configuration idempotency under check mode - s3_metrics_configuration: + community.aws.s3_bucket_metrics_configuration: bucket_name: '{{ test_bucket }}' id: 'EntireBucket' state: present @@ -91,7 +86,7 @@ # ============================================================ - name: Create a metrics configuration idempotency - s3_metrics_configuration: + community.aws.s3_bucket_metrics_configuration: bucket_name: '{{ test_bucket }}' id: 'EntireBucket' state: present @@ -103,7 +98,7 @@ # ============================================================ - name: Put a metrics configuration that enables metrics for objects starting with a prefix - s3_metrics_configuration: + community.aws.s3_bucket_metrics_configuration: bucket_name: "{{ test_bucket }}" id: Assets filter_prefix: assets @@ -115,11 +110,11 @@ - assert: that: - result is changed - - (metrics_info | selectattr('Id', 'search', 'Assets') | list | first).Filter.Prefix == 'assets' + - (metrics_info | selectattr('id', 'search', 'Assets') | list | first).filter.prefix == 'assets' # ============================================================ - name: Update existing metrics configuration under check mode - s3_metrics_configuration: + community.aws.s3_bucket_metrics_configuration: bucket_name: "{{ test_bucket }}" id: Assets filter_tag: @@ -133,12 +128,12 @@ - assert: that: - result is changed - - (metrics_info | selectattr('Id', 'search', 'Assets') | list | first).Filter.Prefix == 'assets' - - (metrics_info | selectattr('Id', 'search', 'Assets') | list | first).Filter.Tag is not defined + - (metrics_info | selectattr('id', 'search', 'Assets') | list | first).filter.prefix == 'assets' + - (metrics_info | selectattr('id', 'search', 'Assets') | list | first).filter.tags is not defined # ============================================================ - name: Update existing metrics configuration and enable metrics for objects with specific tag - s3_metrics_configuration: + community.aws.s3_bucket_metrics_configuration: bucket_name: "{{ test_bucket }}" id: Assets filter_tag: @@ -151,13 +146,12 @@ - assert: that: - result is changed - - (metrics_info | selectattr('Id', 'search', 'Assets') | list | first).Filter.Prefix is not defined - - (metrics_info | selectattr('Id', 'search', 'Assets') | list | first).Filter.Tag.Key == 'kind' - - (metrics_info | selectattr('Id', 'search', 'Assets') | list | first).Filter.Tag.Value == 'Asset' + - (metrics_info | selectattr('id', 'search', 'Assets') | list | first).filter.prefix is not defined + - (metrics_info | selectattr('id', 'search', 'Assets') | list | first).filter.tags.kind == 'Asset' # ============================================================ - name: Put a metrics configuration that enables metrics for objects that start with a particular prefix and have specific tags applied - s3_metrics_configuration: + community.aws.s3_bucket_metrics_configuration: bucket_name: "{{ test_bucket }}" id: ImportantBlueDocuments filter_prefix: documents @@ -172,15 +166,13 @@ - assert: that: - result is changed - - (metrics_info | selectattr('Id', 'search', 'ImportantBlueDocuments') | list | first).Filter.And.Prefix == 'documents' - - (metrics_info | selectattr('Id', 'search', 'ImportantBlueDocuments') | list | first).Filter.And.Tags[0].Key == 'priority' - - (metrics_info | selectattr('Id', 'search', 'ImportantBlueDocuments') | list | first).Filter.And.Tags[0].Value == 'High' - - (metrics_info | selectattr('Id', 'search', 'ImportantBlueDocuments') | list | first).Filter.And.Tags[1].Key == 'class' - - (metrics_info | selectattr('Id', 'search', 'ImportantBlueDocuments') | list | first).Filter.And.Tags[1].Value == 'Blue' + - (metrics_info | selectattr('id', 'search', 'ImportantBlueDocuments') | list | first).filter.prefix == 'documents' + - (metrics_info | selectattr('id', 'search', 'ImportantBlueDocuments') | list | first).filter.tags.priority == 'High' + - (metrics_info | selectattr('id', 'search', 'ImportantBlueDocuments') | list | first).filter.tags.class == 'Blue' # ============================================================ - name: Delete metrics configuration in check mode - s3_metrics_configuration: + community.aws.s3_bucket_metrics_configuration: bucket_name: '{{ test_bucket }}' id: 'EntireBucket' state: absent @@ -192,11 +184,11 @@ - assert: that: - result is changed - - metrics_info | selectattr('Id', 'search', 'EntireBucket') | list | length == 1 # still present + - metrics_info | selectattr('id', 'search', 'EntireBucket') | list | length == 1 # still present # ============================================================ - name: Delete metrics configuration - s3_metrics_configuration: + community.aws.s3_bucket_metrics_configuration: bucket_name: '{{ test_bucket }}' id: 'EntireBucket' state: absent @@ -207,11 +199,11 @@ - assert: that: - result is changed - - metrics_info | selectattr('Id', 'search', 'EntireBucket') | list | length == 0 + - metrics_info | selectattr('id', 'search', 'EntireBucket') | list | length == 0 # ============================================================ - name: Try to delete non-existing metrics configuration - s3_metrics_configuration: + community.aws.s3_bucket_metrics_configuration: bucket_name: '{{ test_bucket }}' id: 'EntireBucket' state: absent @@ -223,16 +215,15 @@ # ============================================================ - name: Try to delete metrics configuration for non-existing bucket - s3_metrics_configuration: + community.aws.s3_bucket_metrics_configuration: bucket_name: '{{ test_bucket }}-non-existing' id: 'EntireBucket' state: absent register: result - ignore_errors: yes - assert: that: - - result is failed + - result is not changed # ============================================================ always: diff --git a/tests/integration/targets/s3_metrics_configuration/tasks/s3_metrics_info.yml b/tests/integration/targets/s3_metrics_configuration/tasks/s3_metrics_info.yml index fdbc8cbfc92..4fe6a86b02f 100644 --- a/tests/integration/targets/s3_metrics_configuration/tasks/s3_metrics_info.yml +++ b/tests/integration/targets/s3_metrics_configuration/tasks/s3_metrics_info.yml @@ -1,16 +1,10 @@ --- # Utility tasks to list bucket metrics configurations -# TODO: Update this when an s3_metrics_configuration_info module exists - name: List s3 bucket metrics configurations - command: > - aws s3api list-bucket-metrics-configurations - --bucket {{ test_bucket }} - environment: - AWS_ACCESS_KEY_ID: "{{ aws_access_key }}" - AWS_SECRET_ACCESS_KEY: "{{ aws_secret_key }}" - AWS_SESSION_TOKEN: "{{ security_token | default(omit) }}" - AWS_DEFAULT_REGION: "{{ aws_region }}" - register: list_comand_result + community.aws.s3_bucket_metrics_configuration_info: + name: "{{ test_bucket }}" + register: metrics_info_result -- set_fact: - metrics_info: "{{ (list_comand_result.stdout | from_json)['MetricsConfigurationList'] | default([]) }}" +- name: Set metrics_info fact + set_fact: + metrics_info: "{{ metrics_info_result.metrics_configurations }}" diff --git a/tests/integration/targets/s3_sync/tasks/main.yml b/tests/integration/targets/s3_sync/tasks/main.yml index 600490706a4..519d6d70352 100644 --- a/tests/integration/targets/s3_sync/tasks/main.yml +++ b/tests/integration/targets/s3_sync/tasks/main.yml @@ -49,7 +49,7 @@ chdir: "{{ output_dir }}/s3_sync" - name: Sync files with remote bucket - s3_sync: + community.aws.s3_sync: bucket: "{{ test_bucket }}" file_root: "{{ output_dir }}/s3_sync" register: output @@ -71,7 +71,7 @@ - not output.requester_pays - name: Sync files with remote bucket using glacier storage class - s3_sync: + community.aws.s3_sync: bucket: "{{ test_bucket_2 }}" file_root: "{{ output_dir }}/s3_sync" storage_class: "GLACIER" @@ -84,7 +84,7 @@ # ============================================================ - name: Sync files already present - s3_sync: + community.aws.s3_sync: bucket: "{{ test_bucket }}" file_root: "{{ output_dir }}/s3_sync" register: output @@ -94,7 +94,7 @@ # ============================================================ - name: Sync files with etag calculation - s3_sync: + community.aws.s3_sync: bucket: "{{ test_bucket }}" file_root: "{{ output_dir }}/s3_sync" file_change_strategy: checksum @@ -117,7 +117,7 @@ - not output.requester_pays - name: Sync individual file with remote bucket - s3_sync: + community.aws.s3_sync: bucket: "{{ test_bucket_3 }}" file_root: "{{ output_dir }}/s3_sync/test1.txt" register: output @@ -129,7 +129,7 @@ # DOCUMENTATION EXAMPLES # ============================================================ - name: all the options - s3_sync: + community.aws.s3_sync: bucket: "{{ test_bucket }}" file_root: "{{ output_dir }}/s3_sync" mime_map: diff --git a/tests/unit/plugins/module_utils/_s3/test_transformations.py b/tests/unit/plugins/module_utils/_s3/test_transformations.py new file mode 100644 index 00000000000..19573a56356 --- /dev/null +++ b/tests/unit/plugins/module_utils/_s3/test_transformations.py @@ -0,0 +1,543 @@ +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +import pytest + +from ansible_collections.community.aws.plugins.module_utils.s3 import build_notification_configuration +from ansible_collections.community.aws.plugins.module_utils.s3 import create_metrics_configuration +from ansible_collections.community.aws.plugins.module_utils.s3 import create_website_configuration +from ansible_collections.community.aws.plugins.module_utils.s3 import normalize_cors_rules +from ansible_collections.community.aws.plugins.module_utils.s3 import normalize_lifecycle_rules +from ansible_collections.community.aws.plugins.module_utils.s3 import normalize_metrics_configuration +from ansible_collections.community.aws.plugins.module_utils.s3 import normalize_notification_configuration +from ansible_collections.community.aws.plugins.module_utils.s3 import normalize_website_configuration + + +class MockConfig: + """Mock Config object for testing notification configuration.""" + + def __init__(self, raw_dict): + self.raw = raw_dict + self.name = raw_dict.get("Id") + + +class TestBuildNotificationConfiguration: + """Test the build_notification_configuration function.""" + + def test_builds_empty_notification_config(self): + """Test that empty configuration returns proper structure.""" + bucket_config = { + "QueueConfigurations": [], + "TopicConfigurations": [], + "LambdaFunctionConfigurations": [], + } + + result = build_notification_configuration(bucket_config) + + assert result == { + "queue_configurations": [], + "topic_configurations": [], + "lambda_function_configurations": [], + } + + def test_builds_queue_notification_config(self): + """Test that queue configuration is properly transformed.""" + bucket_config = { + "QueueConfigurations": [ + MockConfig( + { + "Id": "test-queue-event", + "QueueArn": "arn:aws:sqs:us-east-1:123456789012:test-queue", + "Events": ["s3:ObjectCreated:*"], + "Filter": { + "Key": { + "FilterRules": [ + {"Name": "Prefix", "Value": "images/"}, + {"Name": "Suffix", "Value": ".jpg"}, + ] + } + }, + } + ) + ], + "TopicConfigurations": [], + "LambdaFunctionConfigurations": [], + } + + result = build_notification_configuration(bucket_config) + + assert len(result["queue_configurations"]) == 1 + assert result["queue_configurations"][0]["id"] == "test-queue-event" + assert result["queue_configurations"][0]["queue_arn"] == "arn:aws:sqs:us-east-1:123456789012:test-queue" + assert result["queue_configurations"][0]["events"] == ["s3:ObjectCreated:*"] + + def test_builds_topic_notification_config(self): + """Test that topic configuration is properly transformed.""" + bucket_config = { + "QueueConfigurations": [], + "TopicConfigurations": [ + MockConfig( + { + "Id": "test-topic-event", + "TopicArn": "arn:aws:sns:us-east-1:123456789012:test-topic", + "Events": ["s3:ObjectRemoved:*"], + } + ) + ], + "LambdaFunctionConfigurations": [], + } + + result = build_notification_configuration(bucket_config) + + assert len(result["topic_configurations"]) == 1 + assert result["topic_configurations"][0]["id"] == "test-topic-event" + assert result["topic_configurations"][0]["topic_arn"] == "arn:aws:sns:us-east-1:123456789012:test-topic" + + def test_builds_lambda_notification_config(self): + """Test that lambda configuration is properly transformed.""" + bucket_config = { + "QueueConfigurations": [], + "TopicConfigurations": [], + "LambdaFunctionConfigurations": [ + MockConfig( + { + "Id": "test-lambda-event", + "LambdaFunctionArn": "arn:aws:lambda:us-east-1:123456789012:function:test-function", + "Events": ["s3:ObjectCreated:Put", "s3:ObjectCreated:Post"], + } + ) + ], + } + + result = build_notification_configuration(bucket_config) + + assert len(result["lambda_function_configurations"]) == 1 + assert result["lambda_function_configurations"][0]["id"] == "test-lambda-event" + assert ( + result["lambda_function_configurations"][0]["lambda_function_arn"] + == "arn:aws:lambda:us-east-1:123456789012:function:test-function" + ) + + def test_builds_mixed_notification_config(self): + """Test that mixed configuration with multiple targets is properly transformed.""" + bucket_config = { + "QueueConfigurations": [ + MockConfig( + { + "Id": "queue-event", + "QueueArn": "arn:aws:sqs:us-east-1:123456789012:queue", + "Events": ["s3:ObjectCreated:*"], + } + ) + ], + "TopicConfigurations": [ + MockConfig( + { + "Id": "topic-event", + "TopicArn": "arn:aws:sns:us-east-1:123456789012:topic", + "Events": ["s3:ObjectRemoved:*"], + } + ) + ], + "LambdaFunctionConfigurations": [ + MockConfig( + { + "Id": "lambda-event", + "LambdaFunctionArn": "arn:aws:lambda:us-east-1:123456789012:function:func", + "Events": ["s3:ObjectRestore:*"], + } + ) + ], + } + + result = build_notification_configuration(bucket_config) + + assert len(result["queue_configurations"]) == 1 + assert len(result["topic_configurations"]) == 1 + assert len(result["lambda_function_configurations"]) == 1 + + def test_transforms_camel_case_to_snake_case(self): + """Test that all keys are properly transformed from CamelCase to snake_case.""" + bucket_config = { + "QueueConfigurations": [ + MockConfig( + { + "Id": "test-event", + "QueueArn": "arn:aws:sqs:us-east-1:123456789012:queue", + "Events": ["s3:ObjectCreated:*"], + "Filter": { + "Key": {"FilterRules": [{"Name": "Prefix", "Value": "test/"}]}, + }, + } + ) + ], + "TopicConfigurations": [], + "LambdaFunctionConfigurations": [], + } + + result = build_notification_configuration(bucket_config) + + # Check all keys are snake_case + assert "queue_configurations" in result + config = result["queue_configurations"][0] + assert "id" in config + assert "queue_arn" in config + assert "events" in config + assert "filter" in config + assert "filter_rules" in config["filter"]["key"] + + +class TestCreateWebsiteConfiguration: + """Test the create_website_configuration function.""" + + def test_creates_config_with_suffix_only(self): + """Test creating configuration with only index document suffix.""" + result = create_website_configuration(suffix="index.html", error_key=None, redirect_all_requests=None) + + assert result == {"IndexDocument": {"Suffix": "index.html"}} + + def test_creates_config_with_error_key(self): + """Test creating configuration with error document.""" + result = create_website_configuration(suffix="index.html", error_key="error.html", redirect_all_requests=None) + + assert result == { + "IndexDocument": {"Suffix": "index.html"}, + "ErrorDocument": {"Key": "error.html"}, + } + + def test_creates_config_with_redirect_hostname_only(self): + """Test creating configuration with redirect to hostname only.""" + result = create_website_configuration(suffix=None, error_key=None, redirect_all_requests="example.com") + + assert result == {"RedirectAllRequestsTo": {"HostName": "example.com"}} + + def test_creates_config_with_redirect_protocol_and_hostname(self): + """Test creating configuration with redirect including protocol.""" + result = create_website_configuration(suffix=None, error_key=None, redirect_all_requests="https://example.com") + + assert result == { + "RedirectAllRequestsTo": { + "Protocol": "https", + "HostName": "example.com", + } + } + + def test_creates_config_with_http_redirect(self): + """Test creating configuration with HTTP redirect.""" + result = create_website_configuration(suffix=None, error_key=None, redirect_all_requests="http://example.com") + + assert result == { + "RedirectAllRequestsTo": { + "Protocol": "http", + "HostName": "example.com", + } + } + + def test_handles_redirect_with_multiple_slashes(self): + """Test that double slashes in redirect URL are properly handled.""" + result = create_website_configuration( + suffix=None, error_key=None, redirect_all_requests="https://www.example.com" + ) + + assert result["RedirectAllRequestsTo"]["HostName"] == "www.example.com" + assert result["RedirectAllRequestsTo"]["Protocol"] == "https" + + def test_raises_error_on_invalid_redirect_url(self): + """Test that invalid redirect URL raises ValueError.""" + with pytest.raises(ValueError, match="Redirect URL appears invalid"): + create_website_configuration(suffix=None, error_key=None, redirect_all_requests="invalid:url:with:colons") + + def test_creates_empty_config(self): + """Test creating empty configuration when all parameters are None.""" + result = create_website_configuration(suffix=None, error_key=None, redirect_all_requests=None) + + assert result == {} + + def test_creates_config_with_custom_suffix(self): + """Test creating configuration with custom index document suffix.""" + result = create_website_configuration(suffix="home.htm", error_key=None, redirect_all_requests=None) + + assert result == {"IndexDocument": {"Suffix": "home.htm"}} + + def test_creates_config_with_nested_error_path(self): + """Test creating configuration with nested error document path.""" + result = create_website_configuration( + suffix="index.html", error_key="errors/404.html", redirect_all_requests=None + ) + + assert result["ErrorDocument"]["Key"] == "errors/404.html" + + +class TestCreateMetricsConfiguration: + """Test the create_metrics_configuration function.""" + + def test_creates_config_with_id_only(self): + """Test creating configuration with ID only (entire bucket metrics).""" + result = create_metrics_configuration(mc_id="EntireBucket", filter_prefix=None, filter_tags={}) + + assert result == {"Id": "EntireBucket"} + + def test_creates_config_with_prefix_filter(self): + """Test creating configuration with prefix filter.""" + result = create_metrics_configuration(mc_id="Assets", filter_prefix="assets/", filter_tags={}) + + assert result == { + "Id": "Assets", + "Filter": {"Prefix": "assets/"}, + } + + def test_creates_config_with_single_tag_filter(self): + """Test creating configuration with single tag filter.""" + result = create_metrics_configuration(mc_id="Tagged", filter_prefix=None, filter_tags={"kind": "asset"}) + + assert result == { + "Id": "Tagged", + "Filter": {"Tag": {"Key": "kind", "Value": "asset"}}, + } + + def test_creates_config_with_multiple_tag_filters(self): + """Test creating configuration with multiple tag filters.""" + result = create_metrics_configuration( + mc_id="MultiTag", filter_prefix=None, filter_tags={"priority": "high", "class": "blue"} + ) + + assert result["Id"] == "MultiTag" + assert "Filter" in result + assert "And" in result["Filter"] + assert "Tags" in result["Filter"]["And"] + assert len(result["Filter"]["And"]["Tags"]) == 2 + + # Check tags are present (order may vary) + tags = result["Filter"]["And"]["Tags"] + tag_dict = {tag["Key"]: tag["Value"] for tag in tags} + assert tag_dict == {"priority": "high", "class": "blue"} + + def test_creates_config_with_prefix_and_multiple_tags(self): + """Test creating configuration with both prefix and multiple tags.""" + result = create_metrics_configuration( + mc_id="PrefixAndTags", filter_prefix="documents/", filter_tags={"priority": "high", "class": "blue"} + ) + + assert result["Id"] == "PrefixAndTags" + assert "Filter" in result + assert "And" in result["Filter"] + assert result["Filter"]["And"]["Prefix"] == "documents/" + assert len(result["Filter"]["And"]["Tags"]) == 2 + + def test_creates_config_with_empty_prefix(self): + """Test that empty prefix is treated as no prefix.""" + result = create_metrics_configuration(mc_id="Test", filter_prefix="", filter_tags={}) + + # Empty string is falsy, so no Filter should be added + assert result == {"Id": "Test"} + + def test_prefix_not_added_with_single_tag(self): + """Test that prefix alone with single tag doesn't use And structure.""" + result = create_metrics_configuration(mc_id="Test", filter_prefix=None, filter_tags={"env": "prod"}) + + assert "Filter" in result + assert "Tag" in result["Filter"] + assert "And" not in result["Filter"] + + def test_tag_keys_and_values_preserved(self): + """Test that tag keys and values are correctly preserved.""" + result = create_metrics_configuration( + mc_id="Test", filter_prefix=None, filter_tags={"Environment": "Production", "Application": "WebServer"} + ) + + tags = result["Filter"]["And"]["Tags"] + tag_dict = {tag["Key"]: tag["Value"] for tag in tags} + assert tag_dict["Environment"] == "Production" + assert tag_dict["Application"] == "WebServer" + + def test_creates_config_with_nested_prefix(self): + """Test creating configuration with nested prefix path.""" + result = create_metrics_configuration(mc_id="Nested", filter_prefix="path/to/files/", filter_tags={}) + + assert result["Filter"]["Prefix"] == "path/to/files/" + + def test_creates_config_with_special_characters_in_tags(self): + """Test creating configuration with special characters in tag values.""" + result = create_metrics_configuration( + mc_id="Special", filter_prefix=None, filter_tags={"owner": "user@example.com"} + ) + + assert result["Filter"]["Tag"]["Value"] == "user@example.com" + + +class TestNormalizeMetricsConfiguration: + """Test the normalize_metrics_configuration function.""" + + def test_normalizes_simple_config(self): + """Test normalizing configuration with ID only.""" + config = {"Id": "EntireBucket"} + result = normalize_metrics_configuration(config) + + assert result == {"id": "EntireBucket"} + + def test_normalizes_config_with_prefix_filter(self): + """Test normalizing configuration with prefix filter.""" + config = { + "Id": "Assets", + "Filter": {"Prefix": "assets/"}, + } + result = normalize_metrics_configuration(config) + + assert result["id"] == "Assets" + assert result["filter"]["prefix"] == "assets/" + + def test_normalizes_config_with_single_tag(self): + """Test normalizing configuration with single tag.""" + config = { + "Id": "Tagged", + "Filter": {"Tag": {"Key": "kind", "Value": "asset"}}, + } + result = normalize_metrics_configuration(config) + + assert result["id"] == "Tagged" + assert result["filter"]["tags"] == {"kind": "asset"} + + def test_normalizes_config_with_and_prefix_and_tags(self): + """Test normalizing configuration with And filter (prefix + tags).""" + config = { + "Id": "Complex", + "Filter": { + "And": { + "Prefix": "documents/", + "Tags": [ + {"Key": "priority", "Value": "high"}, + {"Key": "class", "Value": "blue"}, + ], + } + }, + } + result = normalize_metrics_configuration(config) + + assert result["id"] == "Complex" + assert result["filter"]["prefix"] == "documents/" + assert result["filter"]["tags"] == {"priority": "high", "class": "blue"} + + def test_normalizes_empty_config(self): + """Test normalizing None/empty configuration.""" + assert normalize_metrics_configuration(None) is None + assert normalize_metrics_configuration({}) is None + + +class TestNormalizeCorsRules: + """Test the normalize_cors_rules function.""" + + def test_normalizes_cors_rules(self): + """Test normalizing CORS rules.""" + cors_rules = [ + { + "AllowedHeaders": ["Authorization"], + "AllowedMethods": ["GET", "PUT"], + "AllowedOrigins": ["https://example.com"], + "MaxAgeSeconds": 3000, + } + ] + result = normalize_cors_rules(cors_rules) + + assert len(result) == 1 + assert result[0]["allowed_headers"] == ["Authorization"] + assert result[0]["allowed_methods"] == ["GET", "PUT"] + assert result[0]["allowed_origins"] == ["https://example.com"] + assert result[0]["max_age_seconds"] == 3000 + + def test_normalizes_empty_cors_rules(self): + """Test normalizing empty CORS rules list.""" + assert normalize_cors_rules([]) == [] + assert normalize_cors_rules(None) is None + + +class TestNormalizeLifecycleRules: + """Test the normalize_lifecycle_rules function.""" + + def test_normalizes_lifecycle_rules(self): + """Test normalizing lifecycle rules.""" + lifecycle_config = { + "Rules": [ + { + "ID": "DeleteOldLogs", + "Status": "Enabled", + "Prefix": "logs/", + "Expiration": {"Days": 30}, + } + ] + } + result = normalize_lifecycle_rules(lifecycle_config) + + assert len(result) == 1 + assert result[0]["id"] == "DeleteOldLogs" + assert result[0]["status"] == "Enabled" + assert result[0]["prefix"] == "logs/" + assert result[0]["expiration"]["days"] == 30 + + def test_normalizes_empty_lifecycle_config(self): + """Test normalizing empty lifecycle configuration.""" + assert normalize_lifecycle_rules({"Rules": []}) == [] + assert normalize_lifecycle_rules({}) == [] + + +class TestNormalizeNotificationConfiguration: + """Test the normalize_notification_configuration function.""" + + def test_normalizes_notification_config(self): + """Test normalizing notification configuration.""" + notification_config = { + "TopicConfigurations": [ + { + "Id": "test-event", + "TopicArn": "arn:aws:sns:us-east-1:123456789012:topic", + "Events": ["s3:ObjectCreated:*"], + } + ], + "QueueConfigurations": [], + "LambdaFunctionConfigurations": [], + } + result = normalize_notification_configuration(notification_config) + + assert "topic_configurations" in result + assert len(result["topic_configurations"]) == 1 + assert result["topic_configurations"][0]["id"] == "test-event" + assert result["topic_configurations"][0]["topic_arn"] == "arn:aws:sns:us-east-1:123456789012:topic" + + def test_normalizes_empty_notification_config(self): + """Test normalizing empty notification configuration.""" + assert normalize_notification_configuration({}) == {} + assert normalize_notification_configuration(None) is None + + +class TestNormalizeWebsiteConfiguration: + """Test the normalize_website_configuration function.""" + + def test_normalizes_website_config(self): + """Test normalizing website configuration.""" + website_config = { + "IndexDocument": {"Suffix": "index.html"}, + "ErrorDocument": {"Key": "error.html"}, + } + result = normalize_website_configuration(website_config) + + assert result["index_document"]["suffix"] == "index.html" + assert result["error_document"]["key"] == "error.html" + + def test_normalizes_redirect_config(self): + """Test normalizing redirect configuration.""" + website_config = { + "RedirectAllRequestsTo": { + "HostName": "example.com", + "Protocol": "https", + } + } + result = normalize_website_configuration(website_config) + + assert result["redirect_all_requests_to"]["host_name"] == "example.com" + assert result["redirect_all_requests_to"]["protocol"] == "https" + + def test_normalizes_empty_website_config(self): + """Test normalizing empty website configuration.""" + assert normalize_website_configuration({}) == {} + assert normalize_website_configuration(None) is None diff --git a/tests/unit/plugins/modules/s3_lifecycle/test_filters_are_equal.py b/tests/unit/plugins/modules/s3_lifecycle/test_filters_are_equal.py index 4f295646300..b6f91dc4b89 100644 --- a/tests/unit/plugins/modules/s3_lifecycle/test_filters_are_equal.py +++ b/tests/unit/plugins/modules/s3_lifecycle/test_filters_are_equal.py @@ -5,7 +5,7 @@ import pytest -from ansible_collections.community.aws.plugins.modules.s3_lifecycle import filters_are_equal +from ansible_collections.community.aws.plugins.modules.s3_bucket_lifecycle import filters_are_equal @pytest.mark.parametrize( From 0b59acfe298612d9d86d42df0b0f150f4c0e01d1 Mon Sep 17 00:00:00 2001 From: Mark Chappell Date: Mon, 20 Jul 2026 13:24:14 +0200 Subject: [PATCH 2/4] s3_bucket_*: Add changelog fragment for module renames Add changelog fragment documenting the rename of s3_cors, s3_lifecycle, s3_metrics_configuration, and s3_website modules to s3_bucket_* naming convention for consistency. Co-Authored-By: Claude Sonnet 4.5 --- changelogs/fragments/s3-bucket-refactor.yml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelogs/fragments/s3-bucket-refactor.yml diff --git a/changelogs/fragments/s3-bucket-refactor.yml b/changelogs/fragments/s3-bucket-refactor.yml new file mode 100644 index 00000000000..5a7ee2b761a --- /dev/null +++ b/changelogs/fragments/s3-bucket-refactor.yml @@ -0,0 +1,6 @@ +--- +minor_changes: +- s3_bucket_cors - The s3_cors module has been renamed to s3_bucket_cors for consistency with other S3 bucket configuration modules. The usage of the module has not changed and the s3_cors alias will continue to work (https://github.com/ansible-collections/community.aws/pull/2472). +- s3_bucket_lifecycle - The s3_lifecycle module has been renamed to s3_bucket_lifecycle for consistency with other S3 bucket configuration modules. The usage of the module has not changed and the s3_lifecycle alias will continue to work (https://github.com/ansible-collections/community.aws/pull/2472). +- s3_bucket_metrics_configuration - The s3_metrics_configuration module has been renamed to s3_bucket_metrics_configuration for consistency with other S3 bucket configuration modules. The usage of the module has not changed and the s3_metrics_configuration alias will continue to work (https://github.com/ansible-collections/community.aws/pull/2472). +- s3_bucket_website - The s3_website module has been renamed to s3_bucket_website for consistency with other S3 bucket configuration modules. The usage of the module has not changed and the s3_website alias will continue to work (https://github.com/ansible-collections/community.aws/pull/2472). From a8c084493d1879eddb55596a5bc4373bff031c83 Mon Sep 17 00:00:00 2001 From: Mark Chappell Date: Wed, 22 Jul 2026 18:11:56 +0200 Subject: [PATCH 3/4] Update integration tests --- .../targets/s3_bucket_cors/aliases | 2 + .../targets/s3_bucket_cors/defaults/main.yml | 2 + .../meta/main.yml | 0 .../targets/s3_bucket_cors/tasks/main.yml | 526 ++++++++++++++++ .../targets/s3_bucket_lifecycle/aliases | 4 + .../s3_bucket_lifecycle/defaults/main.yml | 1 + .../meta/main.yml | 0 .../tasks/main.yml | 116 +++- .../s3_bucket_metrics_configuration/aliases | 2 + .../defaults/main.yml | 2 + .../meta/main.yml | 2 + .../tasks/main.yml | 188 +++++- .../tasks/s3_metrics_info.yml | 0 .../targets/s3_bucket_website/aliases | 2 + .../s3_bucket_website/defaults/main.yml | 2 + .../targets/s3_bucket_website/meta/main.yml | 2 + .../targets/s3_bucket_website/tasks/main.yml | 565 ++++++++++++++++++ .../integration/targets/s3_lifecycle/aliases | 3 - .../targets/s3_lifecycle/defaults/main.yml | 1 - .../targets/s3_metrics_configuration/aliases | 1 - .../defaults/main.yml | 2 - 21 files changed, 1413 insertions(+), 10 deletions(-) create mode 100644 tests/integration/targets/s3_bucket_cors/aliases create mode 100644 tests/integration/targets/s3_bucket_cors/defaults/main.yml rename tests/integration/targets/{s3_lifecycle => s3_bucket_cors}/meta/main.yml (100%) create mode 100644 tests/integration/targets/s3_bucket_cors/tasks/main.yml create mode 100644 tests/integration/targets/s3_bucket_lifecycle/aliases create mode 100644 tests/integration/targets/s3_bucket_lifecycle/defaults/main.yml rename tests/integration/targets/{s3_metrics_configuration => s3_bucket_lifecycle}/meta/main.yml (100%) rename tests/integration/targets/{s3_lifecycle => s3_bucket_lifecycle}/tasks/main.yml (87%) create mode 100644 tests/integration/targets/s3_bucket_metrics_configuration/aliases create mode 100644 tests/integration/targets/s3_bucket_metrics_configuration/defaults/main.yml create mode 100644 tests/integration/targets/s3_bucket_metrics_configuration/meta/main.yml rename tests/integration/targets/{s3_metrics_configuration => s3_bucket_metrics_configuration}/tasks/main.yml (55%) rename tests/integration/targets/{s3_metrics_configuration => s3_bucket_metrics_configuration}/tasks/s3_metrics_info.yml (100%) create mode 100644 tests/integration/targets/s3_bucket_website/aliases create mode 100644 tests/integration/targets/s3_bucket_website/defaults/main.yml create mode 100644 tests/integration/targets/s3_bucket_website/meta/main.yml create mode 100644 tests/integration/targets/s3_bucket_website/tasks/main.yml delete mode 100644 tests/integration/targets/s3_lifecycle/aliases delete mode 100644 tests/integration/targets/s3_lifecycle/defaults/main.yml delete mode 100644 tests/integration/targets/s3_metrics_configuration/aliases delete mode 100644 tests/integration/targets/s3_metrics_configuration/defaults/main.yml diff --git a/tests/integration/targets/s3_bucket_cors/aliases b/tests/integration/targets/s3_bucket_cors/aliases new file mode 100644 index 00000000000..4b3c9063a0e --- /dev/null +++ b/tests/integration/targets/s3_bucket_cors/aliases @@ -0,0 +1,2 @@ +cloud/aws +s3_bucket_cors_info diff --git a/tests/integration/targets/s3_bucket_cors/defaults/main.yml b/tests/integration/targets/s3_bucket_cors/defaults/main.yml new file mode 100644 index 00000000000..ef012cc812d --- /dev/null +++ b/tests/integration/targets/s3_bucket_cors/defaults/main.yml @@ -0,0 +1,2 @@ +--- +test_bucket: '{{ tiny_prefix }}-s3-bucket-cors' diff --git a/tests/integration/targets/s3_lifecycle/meta/main.yml b/tests/integration/targets/s3_bucket_cors/meta/main.yml similarity index 100% rename from tests/integration/targets/s3_lifecycle/meta/main.yml rename to tests/integration/targets/s3_bucket_cors/meta/main.yml diff --git a/tests/integration/targets/s3_bucket_cors/tasks/main.yml b/tests/integration/targets/s3_bucket_cors/tasks/main.yml new file mode 100644 index 00000000000..eef5402af55 --- /dev/null +++ b/tests/integration/targets/s3_bucket_cors/tasks/main.yml @@ -0,0 +1,526 @@ +--- +# Integration tests for s3_bucket_cors +# +# Notes: +# - s3_bucket_cors manages CORS configuration for S3 buckets +# - Tests cover state=present (create/update) and state=absent (remove) +# - Check mode and idempotency are tested for all operations +# +- module_defaults: + group/aws: + access_key: '{{ aws_access_key }}' + secret_key: '{{ aws_secret_key }}' + session_token: '{{ security_token | default(omit) }}' + region: '{{ aws_region }}' + collections: + - amazon.aws + block: + + # ============================================================ + # Setup - Create test bucket + # ============================================================ + + - name: Create simple s3_bucket for CORS testing + amazon.aws.s3_bucket: + state: present + name: '{{ test_bucket }}' + register: output + + - name: Assert bucket was created properly + assert: + that: + - output is changed + - output.name == test_bucket + + # ============================================================ + # Test Create CORS rules (state = present) + # ============================================================ + + - name: Create CORS rules (check_mode) + community.aws.s3_bucket_cors: + state: present + name: '{{ test_bucket }}' + rules: + - allowed_origins: + - http://www.example.com + allowed_methods: + - GET + - POST + allowed_headers: + - Authorization + expose_headers: + - x-amz-server-side-encryption + - x-amz-request-id + max_age_seconds: 30000 + register: result + check_mode: true + + - name: Assert check_mode shows changed + assert: + that: + - result is changed + + - name: Create CORS rules + community.aws.s3_bucket_cors: + state: present + name: '{{ test_bucket }}' + rules: + - allowed_origins: + - http://www.example.com + allowed_methods: + - GET + - POST + allowed_headers: + - Authorization + expose_headers: + - x-amz-server-side-encryption + - x-amz-request-id + max_age_seconds: 30000 + register: result + + - name: Assert CORS rules were created + assert: + that: + - result is changed + - result.name == test_bucket + - result.rules | length == 1 + - result.rules[0].allowed_origins == ['http://www.example.com'] + - result.rules[0].allowed_methods == ['GET', 'POST'] + + - name: Get CORS configuration with s3_bucket_cors_info + community.aws.s3_bucket_cors_info: + name: '{{ test_bucket }}' + register: cors_info + + - name: Assert CORS info matches configured state + assert: + that: + - cors_info.cors_rules | length == 1 + - cors_info.cors_rules[0].allowed_origins == ['http://www.example.com'] + - cors_info.cors_rules[0].allowed_methods == ['GET', 'POST'] + - cors_info.cors_rules[0].allowed_headers == ['Authorization'] + - cors_info.cors_rules[0].expose_headers == ['x-amz-server-side-encryption', 'x-amz-request-id'] + - cors_info.cors_rules[0].max_age_seconds == 30000 + + - name: Create CORS rules idempotency (check_mode) + community.aws.s3_bucket_cors: + state: present + name: '{{ test_bucket }}' + rules: + - allowed_origins: + - http://www.example.com + allowed_methods: + - GET + - POST + allowed_headers: + - Authorization + expose_headers: + - x-amz-server-side-encryption + - x-amz-request-id + max_age_seconds: 30000 + register: result + check_mode: true + + - name: Assert check_mode shows no change + assert: + that: + - result is not changed + + - name: Create CORS rules idempotency + community.aws.s3_bucket_cors: + state: present + name: '{{ test_bucket }}' + rules: + - allowed_origins: + - http://www.example.com + allowed_methods: + - GET + - POST + allowed_headers: + - Authorization + expose_headers: + - x-amz-server-side-encryption + - x-amz-request-id + max_age_seconds: 30000 + register: result + + - name: Assert idempotency - no change + assert: + that: + - result is not changed + + # ============================================================ + # Test state=present - Update CORS rules + # ============================================================ + + - name: Update CORS rules - change max_age_seconds (check_mode) + community.aws.s3_bucket_cors: + state: present + name: '{{ test_bucket }}' + rules: + - allowed_origins: + - http://www.example.com + allowed_methods: + - GET + - POST + allowed_headers: + - Authorization + expose_headers: + - x-amz-server-side-encryption + - x-amz-request-id + max_age_seconds: 60000 + register: result + check_mode: true + + - name: Assert check_mode shows changed + assert: + that: + - result is changed + + - name: Update CORS rules - change max_age_seconds + community.aws.s3_bucket_cors: + state: present + name: '{{ test_bucket }}' + rules: + - allowed_origins: + - http://www.example.com + allowed_methods: + - GET + - POST + allowed_headers: + - Authorization + expose_headers: + - x-amz-server-side-encryption + - x-amz-request-id + max_age_seconds: 60000 + register: result + + - name: Assert CORS rules were updated + assert: + that: + - result is changed + - result.rules[0].max_age_seconds == 60000 + + - name: Update CORS rules idempotency (check_mode) + community.aws.s3_bucket_cors: + state: present + name: '{{ test_bucket }}' + rules: + - allowed_origins: + - http://www.example.com + allowed_methods: + - GET + - POST + allowed_headers: + - Authorization + expose_headers: + - x-amz-server-side-encryption + - x-amz-request-id + max_age_seconds: 60000 + register: result + check_mode: true + + - name: Assert check_mode shows no change + assert: + that: + - result is not changed + + - name: Update CORS rules idempotency + community.aws.s3_bucket_cors: + state: present + name: '{{ test_bucket }}' + rules: + - allowed_origins: + - http://www.example.com + allowed_methods: + - GET + - POST + allowed_headers: + - Authorization + expose_headers: + - x-amz-server-side-encryption + - x-amz-request-id + max_age_seconds: 60000 + register: result + + - name: Assert idempotency - no change + assert: + that: + - result is not changed + + # ============================================================ + # Test state=present - Multiple CORS rules + # ============================================================ + + - name: Add multiple CORS rules (check_mode) + community.aws.s3_bucket_cors: + state: present + name: '{{ test_bucket }}' + rules: + - allowed_origins: + - http://www.example.com + allowed_methods: + - GET + - POST + allowed_headers: + - Authorization + max_age_seconds: 60000 + - allowed_origins: + - https://www.example.org + allowed_methods: + - GET + allowed_headers: + - '*' + max_age_seconds: 3000 + register: result + check_mode: true + + - name: Assert check_mode shows changed + assert: + that: + - result is changed + + - name: Add multiple CORS rules + community.aws.s3_bucket_cors: + state: present + name: '{{ test_bucket }}' + rules: + - allowed_origins: + - http://www.example.com + allowed_methods: + - GET + - POST + allowed_headers: + - Authorization + max_age_seconds: 60000 + - allowed_origins: + - https://www.example.org + allowed_methods: + - GET + allowed_headers: + - '*' + max_age_seconds: 3000 + register: result + + - name: Assert multiple CORS rules were created + assert: + that: + - result is changed + - result.rules | length == 2 + + - name: Add multiple CORS rules idempotency (check_mode) + community.aws.s3_bucket_cors: + state: present + name: '{{ test_bucket }}' + rules: + - allowed_origins: + - http://www.example.com + allowed_methods: + - GET + - POST + allowed_headers: + - Authorization + max_age_seconds: 60000 + - allowed_origins: + - https://www.example.org + allowed_methods: + - GET + allowed_headers: + - '*' + max_age_seconds: 3000 + register: result + check_mode: true + + - name: Assert check_mode shows no change + assert: + that: + - result is not changed + + - name: Add multiple CORS rules idempotency + community.aws.s3_bucket_cors: + state: present + name: '{{ test_bucket }}' + rules: + - allowed_origins: + - http://www.example.com + allowed_methods: + - GET + - POST + allowed_headers: + - Authorization + max_age_seconds: 60000 + - allowed_origins: + - https://www.example.org + allowed_methods: + - GET + allowed_headers: + - '*' + max_age_seconds: 3000 + register: result + + - name: Assert idempotency - no change + assert: + that: + - result is not changed + + # ============================================================ + # Test state=present - Wildcard origins + # ============================================================ + + - name: Set CORS with wildcard origin (check_mode) + community.aws.s3_bucket_cors: + state: present + name: '{{ test_bucket }}' + rules: + - allowed_origins: + - '*' + allowed_methods: + - GET + allowed_headers: + - '*' + max_age_seconds: 3000 + register: result + check_mode: true + + - name: Assert check_mode shows changed + assert: + that: + - result is changed + + - name: Set CORS with wildcard origin + community.aws.s3_bucket_cors: + state: present + name: '{{ test_bucket }}' + rules: + - allowed_origins: + - '*' + allowed_methods: + - GET + allowed_headers: + - '*' + max_age_seconds: 3000 + register: result + + - name: Assert wildcard CORS rule was created + assert: + that: + - result is changed + - result.rules | length == 1 + - result.rules[0].allowed_origins == ['*'] + + - name: Set CORS with wildcard origin idempotency (check_mode) + community.aws.s3_bucket_cors: + state: present + name: '{{ test_bucket }}' + rules: + - allowed_origins: + - '*' + allowed_methods: + - GET + allowed_headers: + - '*' + max_age_seconds: 3000 + register: result + check_mode: true + + - name: Assert check_mode shows no change + assert: + that: + - result is not changed + + - name: Set CORS with wildcard origin idempotency + community.aws.s3_bucket_cors: + state: present + name: '{{ test_bucket }}' + rules: + - allowed_origins: + - '*' + allowed_methods: + - GET + allowed_headers: + - '*' + max_age_seconds: 3000 + register: result + + - name: Assert idempotency - no change + assert: + that: + - result is not changed + + # ============================================================ + # Test state=absent - Remove CORS rules + # ============================================================ + + - name: Remove CORS rules (check_mode) + community.aws.s3_bucket_cors: + state: absent + name: '{{ test_bucket }}' + register: result + check_mode: true + + - name: Assert check_mode shows changed + assert: + that: + - result is changed + + - name: Remove CORS rules + community.aws.s3_bucket_cors: + state: absent + name: '{{ test_bucket }}' + register: result + + - name: Assert CORS rules were removed + assert: + that: + - result is changed + + - name: Get CORS configuration after deletion + community.aws.s3_bucket_cors_info: + name: '{{ test_bucket }}' + register: cors_info + + - name: Assert CORS info shows no configuration + assert: + that: + - cors_info.cors_rules | length == 0 + + - name: Remove CORS rules idempotency (check_mode) + community.aws.s3_bucket_cors: + state: absent + name: '{{ test_bucket }}' + register: result + check_mode: true + + - name: Assert check_mode shows no change + assert: + that: + - result is not changed + + - name: Remove CORS rules idempotency + community.aws.s3_bucket_cors: + state: absent + name: '{{ test_bucket }}' + register: result + + - name: Assert idempotency - no change (bucket has no CORS) + assert: + that: + - result is not changed + + # ============================================================ + # Cleanup + # ============================================================ + + always: + - name: Remove CORS configuration + community.aws.s3_bucket_cors: + name: '{{ test_bucket }}' + state: absent + ignore_errors: true + + - name: Delete test bucket + amazon.aws.s3_bucket: + name: '{{ test_bucket }}' + state: absent + force: true + ignore_errors: true diff --git a/tests/integration/targets/s3_bucket_lifecycle/aliases b/tests/integration/targets/s3_bucket_lifecycle/aliases new file mode 100644 index 00000000000..123207ac395 --- /dev/null +++ b/tests/integration/targets/s3_bucket_lifecycle/aliases @@ -0,0 +1,4 @@ +time=17m + +cloud/aws +s3_bucket_lifecycle_info diff --git a/tests/integration/targets/s3_bucket_lifecycle/defaults/main.yml b/tests/integration/targets/s3_bucket_lifecycle/defaults/main.yml new file mode 100644 index 00000000000..96e148ddaa5 --- /dev/null +++ b/tests/integration/targets/s3_bucket_lifecycle/defaults/main.yml @@ -0,0 +1 @@ +bucket_name: '{{ tiny_prefix }}-s3-bucket-lifecycle' diff --git a/tests/integration/targets/s3_metrics_configuration/meta/main.yml b/tests/integration/targets/s3_bucket_lifecycle/meta/main.yml similarity index 100% rename from tests/integration/targets/s3_metrics_configuration/meta/main.yml rename to tests/integration/targets/s3_bucket_lifecycle/meta/main.yml diff --git a/tests/integration/targets/s3_lifecycle/tasks/main.yml b/tests/integration/targets/s3_bucket_lifecycle/tasks/main.yml similarity index 87% rename from tests/integration/targets/s3_lifecycle/tasks/main.yml rename to tests/integration/targets/s3_bucket_lifecycle/tasks/main.yml index db419f5aa51..f0e5b5eb0b1 100644 --- a/tests/integration/targets/s3_lifecycle/tasks/main.yml +++ b/tests/integration/targets/s3_bucket_lifecycle/tasks/main.yml @@ -1,5 +1,5 @@ --- -- name: 's3_lifecycle integration tests' +- name: 's3_bucket_lifecycle integration tests' collections: - amazon.aws module_defaults: @@ -60,6 +60,19 @@ - assert: that: - (output.results | last).rules | length == 0 + # ============================================================ + - name: Create a lifecycle policy (check_mode) + community.aws.s3_bucket_lifecycle: + name: '{{ bucket_name }}' + expiration_days: 300 + prefix: '' + check_mode: yes + register: output + + - assert: + that: + - output is changed + # ============================================================ - name: Create a lifecycle policy community.aws.s3_bucket_lifecycle: @@ -71,6 +84,30 @@ - assert: that: - output is changed + + - name: Get lifecycle configuration with s3_bucket_lifecycle_info + community.aws.s3_bucket_lifecycle_info: + name: '{{ bucket_name }}' + register: lifecycle_info + + - name: Assert lifecycle info matches configured state + assert: + that: + - lifecycle_info.lifecycle_rules | length >= 1 + - lifecycle_info.lifecycle_rules | selectattr('expiration', 'defined') | list | length >= 1 + + # ============================================================ + - name: Create a lifecycle policy idempotency (check_mode) + community.aws.s3_bucket_lifecycle: + name: '{{ bucket_name }}' + expiration_days: 300 + check_mode: yes + register: output + + - assert: + that: + - output is not changed + # ============================================================ - name: Create a lifecycle policy (idempotency) community.aws.s3_bucket_lifecycle: @@ -103,6 +140,20 @@ - assert: that: - output is not changed + # ============================================================ + - name: Disable the second lifecycle policy (check_mode) + community.aws.s3_bucket_lifecycle: + name: '{{ bucket_name }}' + status: disabled + transition_days: 30 + prefix: /something + check_mode: yes + register: output + + - assert: + that: + - output is changed + # ============================================================ - name: Disable the second lifecycle policy community.aws.s3_bucket_lifecycle: @@ -115,6 +166,32 @@ - assert: that: - output is changed + + - name: Get lifecycle configuration after disabling policy + community.aws.s3_bucket_lifecycle_info: + name: '{{ bucket_name }}' + register: lifecycle_info + + - name: Assert lifecycle info shows disabled policy + assert: + that: + - lifecycle_info.lifecycle_rules | length >= 1 + - lifecycle_info.lifecycle_rules | selectattr('status', 'equalto', 'Disabled') | list | length >= 1 + + # ============================================================ + - name: Disable the second lifecycle policy idempotency (check_mode) + community.aws.s3_bucket_lifecycle: + name: '{{ bucket_name }}' + status: disabled + transition_days: 30 + prefix: /something + check_mode: yes + register: output + + - assert: + that: + - output is not changed + # ============================================================ - name: Disable the second lifecycle policy (idempotency) community.aws.s3_bucket_lifecycle: @@ -151,6 +228,19 @@ - assert: that: - output is not changed + # ============================================================ + - name: Delete the second lifecycle policy (check_mode) + community.aws.s3_bucket_lifecycle: + name: '{{ bucket_name }}' + state: absent + prefix: /something + check_mode: yes + register: output + + - assert: + that: + - output is changed + # ============================================================ - name: Delete the second lifecycle policy community.aws.s3_bucket_lifecycle: @@ -162,6 +252,30 @@ - assert: that: - output is changed + + - name: Get lifecycle configuration after deleting policy + community.aws.s3_bucket_lifecycle_info: + name: '{{ bucket_name }}' + register: lifecycle_info + + - name: Assert lifecycle info reflects deletion + assert: + that: + - lifecycle_info.lifecycle_rules | selectattr('filter.prefix', 'defined') | selectattr('filter.prefix', 'equalto', '/something') | list | length == 0 + + # ============================================================ + - name: Delete the second lifecycle policy idempotency (check_mode) + community.aws.s3_bucket_lifecycle: + name: '{{ bucket_name }}' + state: absent + prefix: /something + check_mode: yes + register: output + + - assert: + that: + - output is not changed + # ============================================================ - name: Delete the second lifecycle policy (idempotency) community.aws.s3_bucket_lifecycle: diff --git a/tests/integration/targets/s3_bucket_metrics_configuration/aliases b/tests/integration/targets/s3_bucket_metrics_configuration/aliases new file mode 100644 index 00000000000..59a66190a8d --- /dev/null +++ b/tests/integration/targets/s3_bucket_metrics_configuration/aliases @@ -0,0 +1,2 @@ +cloud/aws +s3_bucket_metrics_configuration_info diff --git a/tests/integration/targets/s3_bucket_metrics_configuration/defaults/main.yml b/tests/integration/targets/s3_bucket_metrics_configuration/defaults/main.yml new file mode 100644 index 00000000000..429a18c9078 --- /dev/null +++ b/tests/integration/targets/s3_bucket_metrics_configuration/defaults/main.yml @@ -0,0 +1,2 @@ +--- +test_bucket: '{{ tiny_prefix }}-s3-bucket-metrics' diff --git a/tests/integration/targets/s3_bucket_metrics_configuration/meta/main.yml b/tests/integration/targets/s3_bucket_metrics_configuration/meta/main.yml new file mode 100644 index 00000000000..23d65c7ef45 --- /dev/null +++ b/tests/integration/targets/s3_bucket_metrics_configuration/meta/main.yml @@ -0,0 +1,2 @@ +--- +dependencies: [] diff --git a/tests/integration/targets/s3_metrics_configuration/tasks/main.yml b/tests/integration/targets/s3_bucket_metrics_configuration/tasks/main.yml similarity index 55% rename from tests/integration/targets/s3_metrics_configuration/tasks/main.yml rename to tests/integration/targets/s3_bucket_metrics_configuration/tasks/main.yml index afce67f929b..f9a68958daf 100644 --- a/tests/integration/targets/s3_metrics_configuration/tasks/main.yml +++ b/tests/integration/targets/s3_bucket_metrics_configuration/tasks/main.yml @@ -1,5 +1,5 @@ --- -# Integration tests for s3_metrics_configuration +# Integration tests for s3_bucket_metrics_configuration # # Notes: # - The module only outputs 'changed' since its very simple @@ -71,6 +71,19 @@ - result is changed - metrics_info | selectattr('id', 'search', 'EntireBucket') | list | length == 1 + - name: Get metrics configuration with s3_bucket_metrics_configuration_info + community.aws.s3_bucket_metrics_configuration_info: + bucket_name: '{{ test_bucket }}' + id: 'EntireBucket' + register: metrics_config_info + + - name: Assert metrics info matches configured state + assert: + that: + - metrics_config_info.metrics_configurations | length == 1 + - metrics_config_info.metrics_configurations[0].id == 'EntireBucket' + - metrics_config_info.metrics_configurations[0].filter is not defined + # ============================================================ - name: Create a metrics configuration idempotency under check mode community.aws.s3_bucket_metrics_configuration: @@ -96,6 +109,20 @@ that: - result is not changed + # ============================================================ + - name: Put a metrics configuration with prefix filter (check_mode) + community.aws.s3_bucket_metrics_configuration: + bucket_name: "{{ test_bucket }}" + id: Assets + filter_prefix: assets + state: present + check_mode: yes + register: result + + - assert: + that: + - result is changed + # ============================================================ - name: Put a metrics configuration that enables metrics for objects starting with a prefix community.aws.s3_bucket_metrics_configuration: @@ -112,6 +139,46 @@ - result is changed - (metrics_info | selectattr('id', 'search', 'Assets') | list | first).filter.prefix == 'assets' + - name: Get metrics configuration with prefix filter + community.aws.s3_bucket_metrics_configuration_info: + bucket_name: "{{ test_bucket }}" + id: Assets + register: metrics_config_info + + - name: Assert metrics info shows prefix filter + assert: + that: + - metrics_config_info.metrics_configurations | length == 1 + - metrics_config_info.metrics_configurations[0].id == 'Assets' + - metrics_config_info.metrics_configurations[0].filter.prefix == 'assets' + + # ============================================================ + - name: Put a metrics configuration with prefix filter idempotency (check_mode) + community.aws.s3_bucket_metrics_configuration: + bucket_name: "{{ test_bucket }}" + id: Assets + filter_prefix: assets + state: present + check_mode: yes + register: result + + - assert: + that: + - result is not changed + + # ============================================================ + - name: Put a metrics configuration with prefix filter idempotency + community.aws.s3_bucket_metrics_configuration: + bucket_name: "{{ test_bucket }}" + id: Assets + filter_prefix: assets + state: present + register: result + + - assert: + that: + - result is not changed + # ============================================================ - name: Update existing metrics configuration under check mode community.aws.s3_bucket_metrics_configuration: @@ -149,6 +216,66 @@ - (metrics_info | selectattr('id', 'search', 'Assets') | list | first).filter.prefix is not defined - (metrics_info | selectattr('id', 'search', 'Assets') | list | first).filter.tags.kind == 'Asset' + - name: Get metrics configuration with tag filter + community.aws.s3_bucket_metrics_configuration_info: + bucket_name: "{{ test_bucket }}" + id: Assets + register: metrics_config_info + + - name: Assert metrics info shows tag filter + assert: + that: + - metrics_config_info.metrics_configurations | length == 1 + - metrics_config_info.metrics_configurations[0].id == 'Assets' + - metrics_config_info.metrics_configurations[0].filter.prefix is not defined + - metrics_config_info.metrics_configurations[0].filter.tags.kind == 'Asset' + + # ============================================================ + - name: Update metrics configuration with tag filter idempotency (check_mode) + community.aws.s3_bucket_metrics_configuration: + bucket_name: "{{ test_bucket }}" + id: Assets + filter_tag: + kind: Asset + state: present + check_mode: yes + register: result + + - assert: + that: + - result is not changed + + # ============================================================ + - name: Update metrics configuration with tag filter idempotency + community.aws.s3_bucket_metrics_configuration: + bucket_name: "{{ test_bucket }}" + id: Assets + filter_tag: + kind: Asset + state: present + register: result + + - assert: + that: + - result is not changed + + # ============================================================ + - name: Put a metrics configuration with prefix and tags (check_mode) + community.aws.s3_bucket_metrics_configuration: + bucket_name: "{{ test_bucket }}" + id: ImportantBlueDocuments + filter_prefix: documents + filter_tags: + priority: High + class: Blue + state: present + check_mode: yes + register: result + + - assert: + that: + - result is changed + # ============================================================ - name: Put a metrics configuration that enables metrics for objects that start with a particular prefix and have specific tags applied community.aws.s3_bucket_metrics_configuration: @@ -170,6 +297,39 @@ - (metrics_info | selectattr('id', 'search', 'ImportantBlueDocuments') | list | first).filter.tags.priority == 'High' - (metrics_info | selectattr('id', 'search', 'ImportantBlueDocuments') | list | first).filter.tags.class == 'Blue' + # ============================================================ + - name: Put a metrics configuration with prefix and tags idempotency (check_mode) + community.aws.s3_bucket_metrics_configuration: + bucket_name: "{{ test_bucket }}" + id: ImportantBlueDocuments + filter_prefix: documents + filter_tags: + priority: High + class: Blue + state: present + check_mode: yes + register: result + + - assert: + that: + - result is not changed + + # ============================================================ + - name: Put a metrics configuration with prefix and tags idempotency + community.aws.s3_bucket_metrics_configuration: + bucket_name: "{{ test_bucket }}" + id: ImportantBlueDocuments + filter_prefix: documents + filter_tags: + priority: High + class: Blue + state: present + register: result + + - assert: + that: + - result is not changed + # ============================================================ - name: Delete metrics configuration in check mode community.aws.s3_bucket_metrics_configuration: @@ -201,8 +361,32 @@ - result is changed - metrics_info | selectattr('id', 'search', 'EntireBucket') | list | length == 0 + - name: Try to get deleted metrics configuration + community.aws.s3_bucket_metrics_configuration_info: + bucket_name: '{{ test_bucket }}' + id: 'EntireBucket' + register: metrics_config_info + + - name: Assert metrics info returns empty for deleted config + assert: + that: + - metrics_config_info.metrics_configurations | length == 0 + + # ============================================================ + - name: Delete metrics configuration idempotency (check_mode) + community.aws.s3_bucket_metrics_configuration: + bucket_name: '{{ test_bucket }}' + id: 'EntireBucket' + state: absent + check_mode: yes + register: result + + - assert: + that: + - result is not changed + # ============================================================ - - name: Try to delete non-existing metrics configuration + - name: Delete metrics configuration idempotency community.aws.s3_bucket_metrics_configuration: bucket_name: '{{ test_bucket }}' id: 'EntireBucket' diff --git a/tests/integration/targets/s3_metrics_configuration/tasks/s3_metrics_info.yml b/tests/integration/targets/s3_bucket_metrics_configuration/tasks/s3_metrics_info.yml similarity index 100% rename from tests/integration/targets/s3_metrics_configuration/tasks/s3_metrics_info.yml rename to tests/integration/targets/s3_bucket_metrics_configuration/tasks/s3_metrics_info.yml diff --git a/tests/integration/targets/s3_bucket_website/aliases b/tests/integration/targets/s3_bucket_website/aliases new file mode 100644 index 00000000000..a1e608287b9 --- /dev/null +++ b/tests/integration/targets/s3_bucket_website/aliases @@ -0,0 +1,2 @@ +cloud/aws +s3_bucket_website_info diff --git a/tests/integration/targets/s3_bucket_website/defaults/main.yml b/tests/integration/targets/s3_bucket_website/defaults/main.yml new file mode 100644 index 00000000000..9d9933bc4d7 --- /dev/null +++ b/tests/integration/targets/s3_bucket_website/defaults/main.yml @@ -0,0 +1,2 @@ +--- +test_bucket: '{{ tiny_prefix }}-s3-bucket-website' diff --git a/tests/integration/targets/s3_bucket_website/meta/main.yml b/tests/integration/targets/s3_bucket_website/meta/main.yml new file mode 100644 index 00000000000..23d65c7ef45 --- /dev/null +++ b/tests/integration/targets/s3_bucket_website/meta/main.yml @@ -0,0 +1,2 @@ +--- +dependencies: [] diff --git a/tests/integration/targets/s3_bucket_website/tasks/main.yml b/tests/integration/targets/s3_bucket_website/tasks/main.yml new file mode 100644 index 00000000000..3924e1f46fa --- /dev/null +++ b/tests/integration/targets/s3_bucket_website/tasks/main.yml @@ -0,0 +1,565 @@ +--- +# Integration tests for s3_bucket_website +# +# Notes: +# - s3_bucket_website manages website hosting configuration for S3 buckets +# - Tests cover state=present (create/update) and state=absent (remove) +# - Check mode and idempotency are tested for all operations +# +- module_defaults: + group/aws: + access_key: '{{ aws_access_key }}' + secret_key: '{{ aws_secret_key }}' + session_token: '{{ security_token | default(omit) }}' + region: '{{ aws_region }}' + collections: + - amazon.aws + block: + + # ============================================================ + # Setup - Create test bucket + # ============================================================ + + - name: Create simple s3_bucket for website testing + amazon.aws.s3_bucket: + state: present + name: '{{ test_bucket }}' + register: output + + - name: Assert bucket was created properly + assert: + that: + - output is changed + - output.name == test_bucket + + # ============================================================ + # Test Create website configuration (state = present) + # ============================================================ + + - name: Create website configuration (check_mode) + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + suffix: index.html + register: result + check_mode: true + + - name: Assert check_mode shows changed + assert: + that: + - result is changed + + - name: Create website configuration + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + suffix: index.html + register: result + + - name: Assert website configuration was created + assert: + that: + - result is changed + - result.index_document.suffix == 'index.html' + + - name: Get website configuration with s3_bucket_website_info + community.aws.s3_bucket_website_info: + name: '{{ test_bucket }}' + register: website_info + + - name: Assert website info matches configured state + assert: + that: + - website_info.website_configuration.index_document.suffix == 'index.html' + - website_info.website_configuration.error_document is not defined or website_info.website_configuration.error_document.key is not defined + + - name: Create website configuration idempotency (check_mode) + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + suffix: index.html + register: result + check_mode: true + + - name: Assert check_mode shows no change + assert: + that: + - result is not changed + + - name: Create website configuration idempotency + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + suffix: index.html + register: result + + - name: Assert idempotency - no change + assert: + that: + - result is not changed + + # ============================================================ + # Test state=present - Update website configuration + # ============================================================ + + - name: Update website configuration - add error document (check_mode) + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + suffix: index.html + error_key: error.html + register: result + check_mode: true + + - name: Assert check_mode shows changed + assert: + that: + - result is changed + + - name: Update website configuration - add error document + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + suffix: index.html + error_key: error.html + register: result + + - name: Assert website configuration was updated + assert: + that: + - result is changed + - result.index_document.suffix == 'index.html' + - result.error_document.key == 'error.html' + + - name: Get website configuration after adding error document + community.aws.s3_bucket_website_info: + name: '{{ test_bucket }}' + register: website_info + + - name: Assert website info shows error document + assert: + that: + - website_info.website_configuration.index_document.suffix == 'index.html' + - website_info.website_configuration.error_document.key == 'error.html' + + - name: Update website configuration idempotency (check_mode) + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + suffix: index.html + error_key: error.html + register: result + check_mode: true + + - name: Assert check_mode shows no change + assert: + that: + - result is not changed + + - name: Update website configuration idempotency + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + suffix: index.html + error_key: error.html + register: result + + - name: Assert idempotency - no change + assert: + that: + - result is not changed + + # ============================================================ + # Test state=present - Change suffix + # ============================================================ + + - name: Change suffix value (check_mode) + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + suffix: home.html + error_key: error.html + register: result + check_mode: true + + - name: Assert check_mode shows changed + assert: + that: + - result is changed + + - name: Change suffix value + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + suffix: home.html + error_key: error.html + register: result + + - name: Assert suffix was changed + assert: + that: + - result is changed + - result.index_document.suffix == 'home.html' + + - name: Change suffix value idempotency (check_mode) + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + suffix: home.html + error_key: error.html + register: result + check_mode: true + + - name: Assert check_mode shows no change + assert: + that: + - result is not changed + + - name: Change suffix value idempotency + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + suffix: home.html + error_key: error.html + register: result + + - name: Assert idempotency - no change + assert: + that: + - result is not changed + + # ============================================================ + # Test state=present - Remove error_key + # ============================================================ + + - name: Remove error document (check_mode) + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + suffix: home.html + register: result + check_mode: true + + - name: Assert check_mode shows changed + assert: + that: + - result is changed + + - name: Remove error document + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + suffix: home.html + register: result + + - name: Assert error document was removed + assert: + that: + - result is changed + - result.error_document is not defined or result.error_document.key is not defined + + - name: Remove error document idempotency (check_mode) + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + suffix: home.html + register: result + check_mode: true + + - name: Assert check_mode shows no change + assert: + that: + - result is not changed + + - name: Remove error document idempotency + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + suffix: home.html + register: result + + - name: Assert idempotency - no change + assert: + that: + - result is not changed + + # ============================================================ + # Test state=present - Redirect all requests + # ============================================================ + + - name: Configure redirect all requests (check_mode) + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + redirect_all_requests: example.com + register: result + check_mode: true + + - name: Assert check_mode shows changed + assert: + that: + - result is changed + + - name: Configure redirect all requests + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + redirect_all_requests: example.com + register: result + + - name: Assert redirect configuration was created + assert: + that: + - result is changed + - result.redirect_all_requests_to.host_name == 'example.com' + + - name: Get website configuration after redirect setup + community.aws.s3_bucket_website_info: + name: '{{ test_bucket }}' + register: website_info + + - name: Assert website info shows redirect + assert: + that: + - website_info.website_configuration.redirect_all_requests_to.host_name == 'example.com' + - website_info.website_configuration.index_document is not defined or website_info.website_configuration.index_document.suffix is not defined + + - name: Configure redirect all requests idempotency (check_mode) + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + redirect_all_requests: example.com + register: result + check_mode: true + + - name: Assert check_mode shows no change + assert: + that: + - result is not changed + + - name: Configure redirect all requests idempotency + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + redirect_all_requests: example.com + register: result + + - name: Assert idempotency - no change + assert: + that: + - result is not changed + + # ============================================================ + # Test state=present - Redirect with protocol + # ============================================================ + + - name: Configure redirect with protocol (check_mode) + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + redirect_all_requests: https://example.org + register: result + check_mode: true + + - name: Assert check_mode shows changed + assert: + that: + - result is changed + + - name: Configure redirect with protocol + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + redirect_all_requests: https://example.org + register: result + + - name: Assert redirect with protocol was configured + assert: + that: + - result is changed + - result.redirect_all_requests_to.host_name == 'example.org' + - result.redirect_all_requests_to.protocol == 'https' + + - name: Configure redirect with protocol idempotency (check_mode) + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + redirect_all_requests: https://example.org + register: result + check_mode: true + + - name: Assert check_mode shows no change + assert: + that: + - result is not changed + + - name: Configure redirect with protocol idempotency + community.aws.s3_bucket_website: + state: present + name: '{{ test_bucket }}' + redirect_all_requests: https://example.org + register: result + + - name: Assert idempotency - no change + assert: + that: + - result is not changed + + # ============================================================ + # Test state=present - Routing rules + # TODO: Add support for routing_rules parameter to s3_bucket_website module + # ============================================================ + + # - name: Configure website with routing rules (check_mode) + # community.aws.s3_bucket_website: + # state: present + # name: '{{ test_bucket }}' + # suffix: index.html + # error_key: error.html + # routing_rules: + # - condition: + # key_prefix_equals: docs/ + # redirect: + # replace_key_prefix_with: documents/ + # register: result + # check_mode: true + + # - name: Assert check_mode shows changed + # assert: + # that: + # - result is changed + + # - name: Configure website with routing rules + # community.aws.s3_bucket_website: + # state: present + # name: '{{ test_bucket }}' + # suffix: index.html + # error_key: error.html + # routing_rules: + # - condition: + # key_prefix_equals: docs/ + # redirect: + # replace_key_prefix_with: documents/ + # register: result + + # - name: Assert routing rules were configured + # assert: + # that: + # - result is changed + # - result.routing_rules is defined + # - result.routing_rules | length == 1 + + # - name: Configure website with routing rules idempotency (check_mode) + # community.aws.s3_bucket_website: + # state: present + # name: '{{ test_bucket }}' + # suffix: index.html + # error_key: error.html + # routing_rules: + # - condition: + # key_prefix_equals: docs/ + # redirect: + # replace_key_prefix_with: documents/ + # register: result + # check_mode: true + + # - name: Assert check_mode shows no change + # assert: + # that: + # - result is not changed + + # - name: Configure website with routing rules idempotency + # community.aws.s3_bucket_website: + # state: present + # name: '{{ test_bucket }}' + # suffix: index.html + # error_key: error.html + # routing_rules: + # - condition: + # key_prefix_equals: docs/ + # redirect: + # replace_key_prefix_with: documents/ + # register: result + + # - name: Assert idempotency - no change + # assert: + # that: + # - result is not changed + + # ============================================================ + # Test state=absent - Remove website configuration + # ============================================================ + + - name: Remove website configuration (check_mode) + community.aws.s3_bucket_website: + state: absent + name: '{{ test_bucket }}' + register: result + check_mode: true + + - name: Assert check_mode shows changed + assert: + that: + - result is changed + + - name: Remove website configuration + community.aws.s3_bucket_website: + state: absent + name: '{{ test_bucket }}' + register: result + + - name: Assert website configuration was removed + assert: + that: + - result is changed + + - name: Get website configuration after deletion + community.aws.s3_bucket_website_info: + name: '{{ test_bucket }}' + register: website_info + + - name: Assert website info shows no configuration + assert: + that: + - website_info.website_configuration is not defined or website_info.website_configuration == {} or website_info.website_configuration.index_document is not defined + + - name: Remove website configuration idempotency (check_mode) + community.aws.s3_bucket_website: + state: absent + name: '{{ test_bucket }}' + register: result + check_mode: true + + - name: Assert check_mode shows no change + assert: + that: + - result is not changed + + - name: Remove website configuration idempotency + community.aws.s3_bucket_website: + state: absent + name: '{{ test_bucket }}' + register: result + + - name: Assert idempotency - no change (bucket has no website configuration) + assert: + that: + - result is not changed + + # ============================================================ + # Cleanup + # ============================================================ + + always: + - name: Remove website configuration + community.aws.s3_bucket_website: + name: '{{ test_bucket }}' + state: absent + ignore_errors: true + + - name: Delete test bucket + amazon.aws.s3_bucket: + name: '{{ test_bucket }}' + state: absent + force: true + ignore_errors: true diff --git a/tests/integration/targets/s3_lifecycle/aliases b/tests/integration/targets/s3_lifecycle/aliases deleted file mode 100644 index c36c7f6cd1e..00000000000 --- a/tests/integration/targets/s3_lifecycle/aliases +++ /dev/null @@ -1,3 +0,0 @@ -time=17m - -cloud/aws diff --git a/tests/integration/targets/s3_lifecycle/defaults/main.yml b/tests/integration/targets/s3_lifecycle/defaults/main.yml deleted file mode 100644 index 3015292c6cb..00000000000 --- a/tests/integration/targets/s3_lifecycle/defaults/main.yml +++ /dev/null @@ -1 +0,0 @@ -bucket_name: '{{ tiny_prefix }}-s3-lifecycle' diff --git a/tests/integration/targets/s3_metrics_configuration/aliases b/tests/integration/targets/s3_metrics_configuration/aliases deleted file mode 100644 index 4ef4b2067d0..00000000000 --- a/tests/integration/targets/s3_metrics_configuration/aliases +++ /dev/null @@ -1 +0,0 @@ -cloud/aws diff --git a/tests/integration/targets/s3_metrics_configuration/defaults/main.yml b/tests/integration/targets/s3_metrics_configuration/defaults/main.yml deleted file mode 100644 index 85f5435bd8b..00000000000 --- a/tests/integration/targets/s3_metrics_configuration/defaults/main.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -test_bucket: '{{ tiny_prefix }}-testbucket' From 0bc2b6299c0d3d58645a604dbf319485812f70a7 Mon Sep 17 00:00:00 2001 From: Mark Chappell Date: Wed, 22 Jul 2026 18:16:16 +0200 Subject: [PATCH 4/4] s3_bucket_*: Extend S3ErrorHandler and fix race conditions in delete operations - Add community.aws S3ErrorHandler extending amazon.aws version - Include configuration-specific error codes (NoSuchConfiguration, etc.) - Remove manual try/except blocks from wrapper functions - Fix race condition handling in delete operations (check result is not False) - Fix get_bucket_cors default return value from {} to [] - Add normalize_website_configuration to strip ResponseMetadata after normalisation - Fix s3_bucket_website comparison to normalise both current and new configs Co-Authored-By: Claude Sonnet 4.5 --- plugins/module_utils/_s3/common.py | 68 +++++++++++++++++++ plugins/module_utils/_s3/transformations.py | 12 +++- plugins/module_utils/s3.py | 32 +++------ plugins/modules/s3_bucket_cors.py | 5 +- .../s3_bucket_metrics_configuration.py | 4 +- plugins/modules/s3_bucket_website.py | 11 ++- 6 files changed, 100 insertions(+), 32 deletions(-) create mode 100644 plugins/module_utils/_s3/common.py diff --git a/plugins/module_utils/_s3/common.py b/plugins/module_utils/_s3/common.py new file mode 100644 index 00000000000..6d703d1d914 --- /dev/null +++ b/plugins/module_utils/_s3/common.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +S3-specific error handling extensions for community.aws modules. + +Extends amazon.aws S3ErrorHandler to include additional error codes +specific to S3 bucket configuration operations. +""" + +from __future__ import annotations + +import typing + +if typing.TYPE_CHECKING: + from typing import Callable + +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.s3 import S3ErrorHandler as BaseS3ErrorHandler + + +class S3ErrorHandler(BaseS3ErrorHandler): + """ + Extended S3 error handler with support for additional configuration error codes. + + Extends amazon.aws.S3ErrorHandler to include error codes specific to + S3 bucket configuration operations (CORS, lifecycle, metrics, website, etc.). + """ + + @classmethod + def _is_missing(cls) -> Callable: + """ + Check if a boto3 exception indicates a missing/not found resource. + + Extends the base implementation to include additional S3 configuration + error codes: + - NoSuchConfiguration: Metrics configuration not found + - NoSuchWebsiteConfiguration: Website configuration not found + - NoSuchLifecycleConfiguration: Lifecycle configuration not found + - NoSuchCORSConfiguration: CORS configuration not found + + Returns: + A matcher function for boto3 error codes indicating missing resources. + """ + # Get the base error codes by calling the parent implementation + # We need to extract the codes and add our additional ones + return is_boto3_error_code( + [ + # Base error codes from amazon.aws.S3ErrorHandler + "404", + "NoSuchTagSet", + "NoSuchTagSetError", + "ObjectLockConfigurationNotFoundError", + "NoSuchBucketPolicy", + "ServerSideEncryptionConfigurationNotFoundError", + "NoSuchBucket", + "NoSuchPublicAccessBlockConfiguration", + "OwnershipControlsNotFoundError", + "NoSuchOwnershipControls", + # Additional configuration error codes for community.aws + "NoSuchConfiguration", + "NoSuchWebsiteConfiguration", + "NoSuchLifecycleConfiguration", + "NoSuchCORSConfiguration", + ] + ) diff --git a/plugins/module_utils/_s3/transformations.py b/plugins/module_utils/_s3/transformations.py index 132d7f7ef7b..3f65d5a6151 100644 --- a/plugins/module_utils/_s3/transformations.py +++ b/plugins/module_utils/_s3/transformations.py @@ -140,9 +140,15 @@ def normalize_website_configuration(website_config): website_config: Website configuration dictionary from AWS API (CamelCase) Returns: - Normalized website configuration dictionary with snake_case keys - """ - return boto3_resource_to_ansible_dict(website_config, transform_tags=False) + Normalized website configuration dictionary with snake_case keys, + with ResponseMetadata stripped + """ + if not website_config: + return website_config + normalized = boto3_resource_to_ansible_dict(website_config, transform_tags=False) + # Strip response_metadata after normalization (it's now snake_case) + normalized.pop("response_metadata", None) + return normalized def normalize_metrics_configuration(config): diff --git a/plugins/module_utils/s3.py b/plugins/module_utils/s3.py index 9345aa6109f..d8b12c95619 100644 --- a/plugins/module_utils/s3.py +++ b/plugins/module_utils/s3.py @@ -11,11 +11,11 @@ from __future__ import annotations -from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_message from ansible_collections.amazon.aws.plugins.module_utils.botocore import normalize_boto3_result from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry -from ansible_collections.amazon.aws.plugins.module_utils.s3 import S3ErrorHandler + +from ansible_collections.community.aws.plugins.module_utils._s3.common import S3ErrorHandler # Intended for general use / re-import # pylint: disable=unused-import,useless-import-alias @@ -94,10 +94,7 @@ def get_bucket_website(client, bucket_name): Returns: Website configuration dictionary, or {} if no configuration exists """ - try: - return client.get_bucket_website(Bucket=bucket_name) - except is_boto3_error_code("NoSuchWebsiteConfiguration"): - return {} + return client.get_bucket_website(Bucket=bucket_name) @S3ErrorHandler.common_error_handler("put bucket website configuration") @@ -127,7 +124,7 @@ def delete_bucket_website(client, bucket_name): return client.delete_bucket_website(Bucket=bucket_name) -@S3ErrorHandler.list_error_handler("get bucket CORS configuration", {}) +@S3ErrorHandler.list_error_handler("get bucket CORS configuration", []) @AWSRetry.jittered_backoff(max_delay=120, catch_extra_error_codes=["OperationAborted"]) def get_bucket_cors(client, bucket_name): """ @@ -138,7 +135,7 @@ def get_bucket_cors(client, bucket_name): bucket_name: Name of the S3 bucket Returns: - CORS configuration dictionary, or {} if no configuration exists + List of CORS rules, or [] if no configuration exists """ result = client.get_bucket_cors(Bucket=bucket_name) return result.get("CORSRules", []) @@ -185,11 +182,8 @@ def get_bucket_metrics_configuration(client, bucket_name, metrics_id): Returns: Metrics configuration dictionary, or {} if not found """ - try: - response = client.get_bucket_metrics_configuration(Bucket=bucket_name, Id=metrics_id) - return response.get("MetricsConfiguration", {}) - except is_boto3_error_code("NoSuchConfiguration"): - return {} + response = client.get_bucket_metrics_configuration(Bucket=bucket_name, Id=metrics_id) + return response.get("MetricsConfiguration", {}) @S3ErrorHandler.common_error_handler("put bucket metrics configuration") @@ -220,10 +214,7 @@ def delete_bucket_metrics_configuration(client, bucket_name, metrics_id): bucket_name: Name of the S3 bucket metrics_id: ID of the metrics configuration """ - try: - return client.delete_bucket_metrics_configuration(Bucket=bucket_name, Id=metrics_id) - except is_boto3_error_code("NoSuchConfiguration"): - return None + return client.delete_bucket_metrics_configuration(Bucket=bucket_name, Id=metrics_id) @S3ErrorHandler.list_error_handler("get bucket lifecycle configuration", {"Rules": []}) @@ -239,11 +230,8 @@ def get_bucket_lifecycle_configuration(client, bucket_name): Returns: Lifecycle configuration dictionary, or {"Rules": []} if not configured """ - try: - result = client.get_bucket_lifecycle_configuration(Bucket=bucket_name) - return normalize_boto3_result(result) - except is_boto3_error_code("NoSuchLifecycleConfiguration"): - return {"Rules": []} + result = client.get_bucket_lifecycle_configuration(Bucket=bucket_name) + return normalize_boto3_result(result) @S3ErrorHandler.common_error_handler("put bucket lifecycle configuration") diff --git a/plugins/modules/s3_bucket_cors.py b/plugins/modules/s3_bucket_cors.py index dd4ff7057a9..5191d296d13 100644 --- a/plugins/modules/s3_bucket_cors.py +++ b/plugins/modules/s3_bucket_cors.py @@ -139,8 +139,9 @@ def delete_bucket_cors_configuration(module, client): if module.check_mode: return True - delete_bucket_cors(client, name) - return True + result = delete_bucket_cors(client, name) + # If result is False, configuration didn't exist (race condition - already deleted) + return result is not False def main(): diff --git a/plugins/modules/s3_bucket_metrics_configuration.py b/plugins/modules/s3_bucket_metrics_configuration.py index 47a8d74b5bd..7f86a454c10 100644 --- a/plugins/modules/s3_bucket_metrics_configuration.py +++ b/plugins/modules/s3_bucket_metrics_configuration.py @@ -141,8 +141,8 @@ def delete_metrics_configuration(module, client): return True result = delete_bucket_metrics_configuration(client, bucket_name, mc_id) - # If result is None, configuration didn't exist (race condition) - return result is not None + # If result is False, configuration didn't exist (race condition - already deleted) + return result is not False def main(): diff --git a/plugins/modules/s3_bucket_website.py b/plugins/modules/s3_bucket_website.py index 74becf695e3..a5d4e103223 100644 --- a/plugins/modules/s3_bucket_website.py +++ b/plugins/modules/s3_bucket_website.py @@ -167,6 +167,7 @@ from ansible_collections.community.aws.plugins.module_utils.s3 import create_website_configuration from ansible_collections.community.aws.plugins.module_utils.s3 import delete_bucket_website from ansible_collections.community.aws.plugins.module_utils.s3 import get_bucket_website +from ansible_collections.community.aws.plugins.module_utils.s3 import normalize_website_configuration from ansible_collections.community.aws.plugins.module_utils.s3 import put_bucket_website @@ -193,7 +194,10 @@ def create_or_update_bucket_website(module, client): try: # Compare against what the new configuration would be new_configuration = create_website_configuration(suffix, error_key, redirect_all_requests) - if website_config != new_configuration: + # Normalize both to snake_case for comparison, and strip ResponseMetadata + current_normalized = normalize_website_configuration(website_config) + new_normalized = normalize_website_configuration(new_configuration) + if current_normalized != new_normalized: needs_update = True except (KeyError, ValueError): # If config is malformed, update it @@ -229,8 +233,9 @@ def delete_bucket_website_configuration(module, client): if module.check_mode: return True - delete_bucket_website(client, bucket_name) - return True + result = delete_bucket_website(client, bucket_name) + # If result is False, configuration didn't exist (race condition - already deleted) + return result is not False def main():