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). 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/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 new file mode 100644 index 00000000000..3f65d5a6151 --- /dev/null +++ b/plugins/module_utils/_s3/transformations.py @@ -0,0 +1,217 @@ +# -*- 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, + 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): + """ + 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..d8b12c95619 --- /dev/null +++ b/plugins/module_utils/s3.py @@ -0,0 +1,267 @@ +# -*- 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_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.community.aws.plugins.module_utils._s3.common 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 + """ + return client.get_bucket_website(Bucket=bucket_name) + + +@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: + List of CORS rules, 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 + """ + response = client.get_bucket_metrics_configuration(Bucket=bucket_name, Id=metrics_id) + return response.get("MetricsConfiguration", {}) + + +@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 + """ + return client.delete_bucket_metrics_configuration(Bucket=bucket_name, Id=metrics_id) + + +@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 + """ + result = client.get_bucket_lifecycle_configuration(Bucket=bucket_name) + return normalize_boto3_result(result) + + +@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 61% rename from plugins/modules/s3_cors.py rename to plugins/modules/s3_bucket_cors.py index c9cf2d0546a..5191d296d13 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,52 @@ ] """ -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 + + 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(): @@ -152,16 +151,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..7f86a454c10 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 False, configuration didn't exist (race condition - already deleted) + return result is not False 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..a5d4e103223 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,83 @@ 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 normalize_website_configuration +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) + # 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 + needs_update = True + + if not needs_update: + return False + + if module.check_mode: + return 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") + 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)) + + # Wait 5 secs before returning to give it time to update + time.sleep(5) + 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") - module.exit_json(changed=changed) +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 + + 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(): @@ -306,6 +249,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 +257,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_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 78% rename from tests/integration/targets/s3_lifecycle/tasks/main.yml rename to tests/integration/targets/s3_bucket_lifecycle/tasks/main.yml index 947c356ef47..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: @@ -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 @@ -60,9 +60,22 @@ - 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 - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' expiration_days: 300 prefix: '' @@ -71,9 +84,33 @@ - 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) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' expiration_days: 300 register: output @@ -83,7 +120,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 +131,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 @@ -103,9 +140,23 @@ - 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 - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' status: disabled transition_days: 30 @@ -115,9 +166,35 @@ - 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) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' status: disabled transition_days: 30 @@ -129,7 +206,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 +218,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 @@ -151,9 +228,22 @@ - 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 - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' state: absent prefix: /something @@ -162,9 +252,33 @@ - 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) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' state: absent prefix: /something @@ -175,7 +289,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 +301,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 +313,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 +324,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 +335,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 +347,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 +359,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 +367,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 +375,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 +388,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 +400,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 +411,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 +422,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 +433,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 +444,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 +456,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 +468,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 +485,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 +503,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 +515,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 +527,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 +538,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 +550,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 +561,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 +573,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 +585,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 +600,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 +612,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 +625,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 +638,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 +654,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 +667,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 +681,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 +692,7 @@ - output is changed - name: example 4 (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' prefix: /logs/ status: disabled @@ -590,7 +704,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 +714,7 @@ - output is changed - name: example 5 (idempotency) - s3_lifecycle: + community.aws.s3_bucket_lifecycle: name: '{{ bucket_name }}' prefix: /logs/ state: absent @@ -611,7 +725,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 +738,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 +752,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 +768,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 +785,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 +797,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 +810,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 +825,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 +840,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 +856,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 +870,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 +884,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_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_bucket_metrics_configuration/tasks/main.yml b/tests/integration/targets/s3_bucket_metrics_configuration/tasks/main.yml new file mode 100644 index 00000000000..f9a68958daf --- /dev/null +++ b/tests/integration/targets/s3_bucket_metrics_configuration/tasks/main.yml @@ -0,0 +1,418 @@ +--- +# Integration tests for s3_bucket_metrics_configuration +# +# Notes: +# - The module only outputs 'changed' since its very simple +# +- 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: + + + # ============================================================ + - name: Try to create metrics configuration for non-existing bucket + community.aws.s3_bucket_metrics_configuration: + bucket_name: '{{ test_bucket }}' + id: 'EntireBucket' + state: present + register: result + ignore_errors: yes + + - assert: + that: + - result is failed + + # ============================================================ + - name: Create simple s3_bucket to act upon it + s3_bucket: + name: '{{ test_bucket }}' + state: present + register: output + + - assert: + that: + - output is changed + - output.name == test_bucket + + # ============================================================ + - name: Create a metrics configuration under check mode + community.aws.s3_bucket_metrics_configuration: + bucket_name: '{{ test_bucket }}' + id: 'EntireBucket' + state: present + check_mode: yes + register: result + + - include_tasks: './s3_metrics_info.yml' + + - assert: + that: + - result is changed + - metrics_info | selectattr('id', 'search', 'EntireBucket') | list | length == 0 + + # ============================================================ + - name: Create a metrics configuration that enables metrics for an entire bucket + community.aws.s3_bucket_metrics_configuration: + bucket_name: '{{ test_bucket }}' + id: 'EntireBucket' + state: present + register: result + + - include_tasks: './s3_metrics_info.yml' + + - assert: + that: + - 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: + bucket_name: '{{ test_bucket }}' + id: 'EntireBucket' + state: present + check_mode: yes + register: result + + - assert: + that: + - result is not changed + + # ============================================================ + - name: Create a metrics configuration idempotency + community.aws.s3_bucket_metrics_configuration: + bucket_name: '{{ test_bucket }}' + id: 'EntireBucket' + state: present + register: result + + - assert: + 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: + bucket_name: "{{ test_bucket }}" + id: Assets + filter_prefix: assets + state: present + register: result + + - include_tasks: './s3_metrics_info.yml' + + - assert: + that: + - 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: + bucket_name: "{{ test_bucket }}" + id: Assets + filter_tag: + kind: Asset + state: present + check_mode: yes + register: result + + - include_tasks: './s3_metrics_info.yml' + + - 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.tags is not defined + + # ============================================================ + - name: Update existing metrics configuration and enable metrics for objects with specific tag + community.aws.s3_bucket_metrics_configuration: + bucket_name: "{{ test_bucket }}" + id: Assets + filter_tag: + kind: Asset + state: present + register: result + + - include_tasks: './s3_metrics_info.yml' + + - 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.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: + bucket_name: "{{ test_bucket }}" + id: ImportantBlueDocuments + filter_prefix: documents + filter_tags: + priority: High + class: Blue + state: present + register: result + + - include_tasks: './s3_metrics_info.yml' + + - assert: + that: + - result is changed + - (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: 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: + bucket_name: '{{ test_bucket }}' + id: 'EntireBucket' + state: absent + check_mode: yes + register: result + + - include_tasks: './s3_metrics_info.yml' + + - assert: + that: + - result is changed + - metrics_info | selectattr('id', 'search', 'EntireBucket') | list | length == 1 # still present + + # ============================================================ + - name: Delete metrics configuration + community.aws.s3_bucket_metrics_configuration: + bucket_name: '{{ test_bucket }}' + id: 'EntireBucket' + state: absent + register: result + + - include_tasks: './s3_metrics_info.yml' + + - assert: + that: + - 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: Delete metrics configuration idempotency + community.aws.s3_bucket_metrics_configuration: + bucket_name: '{{ test_bucket }}' + id: 'EntireBucket' + state: absent + register: result + + - assert: + that: + - result is not changed + + # ============================================================ + - name: Try to delete metrics configuration for non-existing bucket + community.aws.s3_bucket_metrics_configuration: + bucket_name: '{{ test_bucket }}-non-existing' + id: 'EntireBucket' + state: absent + register: result + + - assert: + that: + - result is not changed + + # ============================================================ + always: + - name: Delete test bucket + s3_bucket: + name: '{{ test_bucket }}' + state: absent + ignore_errors: yes diff --git a/tests/integration/targets/s3_bucket_metrics_configuration/tasks/s3_metrics_info.yml b/tests/integration/targets/s3_bucket_metrics_configuration/tasks/s3_metrics_info.yml new file mode 100644 index 00000000000..4fe6a86b02f --- /dev/null +++ b/tests/integration/targets/s3_bucket_metrics_configuration/tasks/s3_metrics_info.yml @@ -0,0 +1,10 @@ +--- +# Utility tasks to list bucket metrics configurations +- name: List s3 bucket metrics configurations + community.aws.s3_bucket_metrics_configuration_info: + name: "{{ test_bucket }}" + register: metrics_info_result + +- name: Set metrics_info fact + set_fact: + metrics_info: "{{ metrics_info_result.metrics_configurations }}" 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_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_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/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' diff --git a/tests/integration/targets/s3_metrics_configuration/tasks/main.yml b/tests/integration/targets/s3_metrics_configuration/tasks/main.yml deleted file mode 100644 index 9e9f1133aaf..00000000000 --- a/tests/integration/targets/s3_metrics_configuration/tasks/main.yml +++ /dev/null @@ -1,243 +0,0 @@ ---- -# Integration tests for s3_metrics_configuration -# -# Notes: -# - The module only outputs 'changed' since its very simple -# -- 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: - - # 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: - bucket_name: '{{ test_bucket }}' - id: 'EntireBucket' - state: present - register: result - ignore_errors: yes - - - assert: - that: - - result is failed - - # ============================================================ - - name: Create simple s3_bucket to act upon it - s3_bucket: - name: '{{ test_bucket }}' - state: present - register: output - - - assert: - that: - - output is changed - - output.name == test_bucket - - # ============================================================ - - name: Create a metrics configuration under check mode - s3_metrics_configuration: - bucket_name: '{{ test_bucket }}' - id: 'EntireBucket' - state: present - check_mode: yes - register: result - - - include_tasks: './s3_metrics_info.yml' - - - assert: - that: - - result is changed - - metrics_info | selectattr('Id', 'search', 'EntireBucket') | list | length == 0 - - # ============================================================ - - name: Create a metrics configuration that enables metrics for an entire bucket - s3_metrics_configuration: - bucket_name: '{{ test_bucket }}' - id: 'EntireBucket' - state: present - register: result - - - include_tasks: './s3_metrics_info.yml' - - - assert: - that: - - result is changed - - metrics_info | selectattr('Id', 'search', 'EntireBucket') | list | length == 1 - - # ============================================================ - - name: Create a metrics configuration idempotency under check mode - s3_metrics_configuration: - bucket_name: '{{ test_bucket }}' - id: 'EntireBucket' - state: present - check_mode: yes - register: result - - - assert: - that: - - result is not changed - - # ============================================================ - - name: Create a metrics configuration idempotency - s3_metrics_configuration: - bucket_name: '{{ test_bucket }}' - id: 'EntireBucket' - state: present - register: result - - - assert: - that: - - result is not changed - - # ============================================================ - - name: Put a metrics configuration that enables metrics for objects starting with a prefix - s3_metrics_configuration: - bucket_name: "{{ test_bucket }}" - id: Assets - filter_prefix: assets - state: present - register: result - - - include_tasks: './s3_metrics_info.yml' - - - assert: - that: - - result is changed - - (metrics_info | selectattr('Id', 'search', 'Assets') | list | first).Filter.Prefix == 'assets' - - # ============================================================ - - name: Update existing metrics configuration under check mode - s3_metrics_configuration: - bucket_name: "{{ test_bucket }}" - id: Assets - filter_tag: - kind: Asset - state: present - check_mode: yes - register: result - - - include_tasks: './s3_metrics_info.yml' - - - 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 - - # ============================================================ - - name: Update existing metrics configuration and enable metrics for objects with specific tag - s3_metrics_configuration: - bucket_name: "{{ test_bucket }}" - id: Assets - filter_tag: - kind: Asset - state: present - register: result - - - include_tasks: './s3_metrics_info.yml' - - - 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' - - # ============================================================ - - name: Put a metrics configuration that enables metrics for objects that start with a particular prefix and have specific tags applied - s3_metrics_configuration: - bucket_name: "{{ test_bucket }}" - id: ImportantBlueDocuments - filter_prefix: documents - filter_tags: - priority: High - class: Blue - state: present - register: result - - - include_tasks: './s3_metrics_info.yml' - - - 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' - - # ============================================================ - - name: Delete metrics configuration in check mode - s3_metrics_configuration: - bucket_name: '{{ test_bucket }}' - id: 'EntireBucket' - state: absent - check_mode: yes - register: result - - - include_tasks: './s3_metrics_info.yml' - - - assert: - that: - - result is changed - - metrics_info | selectattr('Id', 'search', 'EntireBucket') | list | length == 1 # still present - - # ============================================================ - - name: Delete metrics configuration - s3_metrics_configuration: - bucket_name: '{{ test_bucket }}' - id: 'EntireBucket' - state: absent - register: result - - - include_tasks: './s3_metrics_info.yml' - - - assert: - that: - - result is changed - - metrics_info | selectattr('Id', 'search', 'EntireBucket') | list | length == 0 - - # ============================================================ - - name: Try to delete non-existing metrics configuration - s3_metrics_configuration: - bucket_name: '{{ test_bucket }}' - id: 'EntireBucket' - state: absent - register: result - - - assert: - that: - - result is not changed - - # ============================================================ - - name: Try to delete metrics configuration for non-existing bucket - s3_metrics_configuration: - bucket_name: '{{ test_bucket }}-non-existing' - id: 'EntireBucket' - state: absent - register: result - ignore_errors: yes - - - assert: - that: - - result is failed - - # ============================================================ - always: - - name: Delete test bucket - s3_bucket: - name: '{{ test_bucket }}' - state: absent - ignore_errors: yes 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 deleted file mode 100644 index fdbc8cbfc92..00000000000 --- a/tests/integration/targets/s3_metrics_configuration/tasks/s3_metrics_info.yml +++ /dev/null @@ -1,16 +0,0 @@ ---- -# 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 - -- set_fact: - metrics_info: "{{ (list_comand_result.stdout | from_json)['MetricsConfigurationList'] | default([]) }}" 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(