diff options
Diffstat (limited to 'ansible_collections/amazon/aws/plugins')
7 files changed, 242 insertions, 128 deletions
diff --git a/ansible_collections/amazon/aws/plugins/module_utils/common.py b/ansible_collections/amazon/aws/plugins/module_utils/common.py index 673915725..41ba80231 100644 --- a/ansible_collections/amazon/aws/plugins/module_utils/common.py +++ b/ansible_collections/amazon/aws/plugins/module_utils/common.py @@ -4,7 +4,7 @@ # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) AMAZON_AWS_COLLECTION_NAME = "amazon.aws" -AMAZON_AWS_COLLECTION_VERSION = "7.4.0" +AMAZON_AWS_COLLECTION_VERSION = "7.5.0" _collection_info_context = { diff --git a/ansible_collections/amazon/aws/plugins/module_utils/iam.py b/ansible_collections/amazon/aws/plugins/module_utils/iam.py index 430823f3b..56920d53e 100644 --- a/ansible_collections/amazon/aws/plugins/module_utils/iam.py +++ b/ansible_collections/amazon/aws/plugins/module_utils/iam.py @@ -4,7 +4,6 @@ # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) import re -from copy import deepcopy try: import botocore @@ -12,17 +11,20 @@ except ImportError: pass # Modules are responsible for handling this. from ansible.module_utils._text import to_native -from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict from .arn import parse_aws_arn from .arn import validate_aws_arn from .botocore import is_boto3_error_code -from .botocore import normalize_boto3_result from .errors import AWSErrorHandler from .exceptions import AnsibleAWSError from .retries import AWSRetry from .tagging import ansible_dict_to_boto3_tag_list -from .tagging import boto3_tag_list_to_ansible_dict +from .transformation import AnsibleAWSResource +from .transformation import AnsibleAWSResourceList +from .transformation import BotoResource +from .transformation import BotoResourceList +from .transformation import boto3_resource_list_to_ansible_dict +from .transformation import boto3_resource_to_ansible_dict class AnsibleIAMError(AnsibleAWSError): @@ -198,66 +200,6 @@ def get_iam_managed_policy_version(client, arn, version): return client.get_policy_version(PolicyArn=arn, VersionId=version)["PolicyVersion"] -def normalize_iam_mfa_device(device): - """Converts IAM MFA Device from the CamelCase boto3 format to the snake_case Ansible format""" - if not device: - return device - camel_device = camel_dict_to_snake_dict(device) - camel_device["tags"] = boto3_tag_list_to_ansible_dict(device.pop("Tags", [])) - return camel_device - - -def normalize_iam_mfa_devices(devices): - """Converts a list of IAM MFA Devices from the CamelCase boto3 format to the snake_case Ansible format""" - if not devices: - return [] - devices = [normalize_iam_mfa_device(d) for d in devices] - return devices - - -def normalize_iam_user(user): - """Converts IAM users from the CamelCase boto3 format to the snake_case Ansible format""" - if not user: - return user - camel_user = camel_dict_to_snake_dict(user) - camel_user["tags"] = boto3_tag_list_to_ansible_dict(user.pop("Tags", [])) - return camel_user - - -def normalize_iam_policy(policy): - """Converts IAM policies from the CamelCase boto3 format to the snake_case Ansible format""" - if not policy: - return policy - camel_policy = camel_dict_to_snake_dict(policy) - camel_policy["tags"] = boto3_tag_list_to_ansible_dict(policy.get("Tags", [])) - return camel_policy - - -def normalize_iam_group(group): - """Converts IAM Groups from the CamelCase boto3 format to the snake_case Ansible format""" - if not group: - return group - camel_group = camel_dict_to_snake_dict(normalize_boto3_result(group)) - return camel_group - - -def normalize_iam_access_key(access_key): - """Converts IAM access keys from the CamelCase boto3 format to the snake_case Ansible format""" - if not access_key: - return access_key - camel_key = camel_dict_to_snake_dict(normalize_boto3_result(access_key)) - return camel_key - - -def normalize_iam_access_keys(access_keys): - """Converts a list of IAM access keys from the CamelCase boto3 format to the snake_case Ansible format""" - if not access_keys: - return [] - access_keys = [normalize_iam_access_key(k) for k in access_keys] - sorted_keys = sorted(access_keys, key=lambda d: d.get("create_date", None)) - return sorted_keys - - def convert_managed_policy_names_to_arns(client, policy_names): if all(validate_aws_arn(policy, service="iam") for policy in policy_names if policy is not None): return policy_names @@ -386,47 +328,6 @@ def list_iam_instance_profiles(client, name=None, prefix=None, role=None): return _list_iam_instance_profiles(client) -def normalize_iam_instance_profile(profile, _v7_compat=False): - """ - Converts a boto3 format IAM instance profile into "Ansible" format - - _v7_compat is deprecated and will be removed in release after 2025-05-01 DO NOT USE. - """ - - new_profile = camel_dict_to_snake_dict(deepcopy(profile)) - if profile.get("Roles"): - new_profile["roles"] = [normalize_iam_role(role, _v7_compat=_v7_compat) for role in profile.get("Roles")] - if profile.get("Tags"): - new_profile["tags"] = boto3_tag_list_to_ansible_dict(profile.get("Tags")) - else: - new_profile["tags"] = {} - new_profile["original"] = profile - return new_profile - - -def normalize_iam_role(role, _v7_compat=False): - """ - Converts a boto3 format IAM instance role into "Ansible" format - - _v7_compat is deprecated and will be removed in release after 2025-05-01 DO NOT USE. - """ - - new_role = camel_dict_to_snake_dict(deepcopy(role)) - if role.get("InstanceProfiles"): - new_role["instance_profiles"] = [ - normalize_iam_instance_profile(profile, _v7_compat=_v7_compat) for profile in role.get("InstanceProfiles") - ] - if role.get("AssumeRolePolicyDocument"): - if _v7_compat: - # new_role["assume_role_policy_document"] = role.get("AssumeRolePolicyDocument") - new_role["assume_role_policy_document_raw"] = role.get("AssumeRolePolicyDocument") - else: - new_role["assume_role_policy_document"] = role.get("AssumeRolePolicyDocument") - - new_role["tags"] = boto3_tag_list_to_ansible_dict(role.get("Tags", [])) - return new_role - - @IAMErrorHandler.common_error_handler("tag instance profile") @AWSRetry.jittered_backoff() def tag_iam_instance_profile(client, name, tags): @@ -497,3 +398,83 @@ def validate_iam_identifiers(resource_type, name=None, path=None): return path_problem return None + + +def normalize_iam_mfa_device(device: BotoResource) -> AnsibleAWSResource: + """Converts IAM MFA Device from the CamelCase boto3 format to the snake_case Ansible format""" + # MFA Devices don't support Tags (as of 1.34.52) + return boto3_resource_to_ansible_dict(device) + + +def normalize_iam_mfa_devices(devices: BotoResourceList) -> AnsibleAWSResourceList: + """Converts a list of IAM MFA Devices from the CamelCase boto3 format to the snake_case Ansible format""" + # MFA Devices don't support Tags (as of 1.34.52) + return boto3_resource_list_to_ansible_dict(devices) + + +def normalize_iam_user(user: BotoResource) -> AnsibleAWSResource: + """Converts IAM users from the CamelCase boto3 format to the snake_case Ansible format""" + return boto3_resource_to_ansible_dict(user) + + +def normalize_iam_policy(policy: BotoResource) -> AnsibleAWSResource: + """Converts IAM policies from the CamelCase boto3 format to the snake_case Ansible format""" + return boto3_resource_to_ansible_dict(policy) + + +def normalize_iam_group(group: BotoResource) -> AnsibleAWSResource: + """Converts IAM Groups from the CamelCase boto3 format to the snake_case Ansible format""" + # Groups don't support Tags (as of 1.34.52) + return boto3_resource_to_ansible_dict(group, force_tags=False) + + +def normalize_iam_access_key(access_key: BotoResource) -> AnsibleAWSResource: + """Converts IAM access keys from the CamelCase boto3 format to the snake_case Ansible format""" + # Access Keys don't support Tags (as of 1.34.52) + return boto3_resource_to_ansible_dict(access_key, force_tags=False) + + +def normalize_iam_access_keys(access_keys: BotoResourceList) -> AnsibleAWSResourceList: + """Converts a list of IAM access keys from the CamelCase boto3 format to the snake_case Ansible format""" + # Access Keys don't support Tags (as of 1.34.52) + if not access_keys: + return access_keys + access_keys = boto3_resource_list_to_ansible_dict(access_keys, force_tags=False) + return sorted(access_keys, key=lambda d: d.get("create_date", None)) + + +def normalize_iam_instance_profile(profile: BotoResource) -> AnsibleAWSResource: + """ + Converts a boto3 format IAM instance profile into "Ansible" format + + _v7_compat is deprecated and will be removed in release after 2025-05-01 DO NOT USE. + """ + transforms = {"Roles": _normalize_iam_roles} + transformed_profile = boto3_resource_to_ansible_dict(profile, nested_transforms=transforms) + return transformed_profile + + +def normalize_iam_role(role: BotoResource, _v7_compat: bool = False) -> AnsibleAWSResource: + """ + Converts a boto3 format IAM instance role into "Ansible" format + + _v7_compat is deprecated and will be removed in release after 2025-05-01 DO NOT USE. + """ + transforms = {"InstanceProfiles": _normalize_iam_instance_profiles} + ignore_list = [] if _v7_compat else ["AssumeRolePolicyDocument"] + transformed_role = boto3_resource_to_ansible_dict(role, nested_transforms=transforms, ignore_list=ignore_list) + if _v7_compat and role.get("AssumeRolePolicyDocument"): + transformed_role["assume_role_policy_document_raw"] = role["AssumeRolePolicyDocument"] + return transformed_role + + +def _normalize_iam_instance_profiles(profiles: BotoResourceList) -> AnsibleAWSResourceList: + if not profiles: + return profiles + return [normalize_iam_instance_profile(p) for p in profiles] + + +def _normalize_iam_roles(roles: BotoResourceList) -> AnsibleAWSResourceList: + if not roles: + return roles + return [normalize_iam_role(r) for r in roles] diff --git a/ansible_collections/amazon/aws/plugins/module_utils/transformation.py b/ansible_collections/amazon/aws/plugins/module_utils/transformation.py index 708736fc0..a5bc23607 100644 --- a/ansible_collections/amazon/aws/plugins/module_utils/transformation.py +++ b/ansible_collections/amazon/aws/plugins/module_utils/transformation.py @@ -28,9 +28,26 @@ # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE # USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from copy import deepcopy +from typing import Any +from typing import Callable +from typing import Mapping +from typing import Optional +from typing import Sequence +from typing import Union + +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict from ansible.module_utils.six import integer_types from ansible.module_utils.six import string_types +from .botocore import normalize_boto3_result +from .tagging import boto3_tag_list_to_ansible_dict + +BotoResource = Union[None, Mapping[str, Any]] +BotoResourceList = Union[None, Sequence[Mapping[str, Any]]] +AnsibleAWSResource = Union[None, Mapping[str, Any]] +AnsibleAWSResourceList = Union[None, Sequence[Mapping[str, Any]]] + def ansible_dict_to_boto3_filter_list(filters_dict): """Convert an Ansible dict of filters to list of dicts that boto3 can use @@ -133,3 +150,82 @@ def scrub_none_parameters(parameters, descend_into_lists=True): clean_parameters[k] = v return clean_parameters + + +def _perform_nested_transforms( + resource: Mapping[str, Any], + nested_transforms: Optional[Mapping[str, Callable]], +) -> Mapping[str, Any]: + if not nested_transforms: + return resource + + for k, transform in nested_transforms.items(): + if k in resource: + resource[k] = transform(resource[k]) + + return resource + + +def boto3_resource_to_ansible_dict( + resource: BotoResource, + transform_tags: bool = True, + force_tags: bool = True, + normalize: bool = True, + ignore_list: Optional[Sequence[str]] = None, + nested_transforms: Optional[Mapping[str, Callable]] = None, +) -> AnsibleAWSResource: + """ + Transforms boto3-style (CamelCase) resource to the ansible-style (snake_case). + + :param resource: a dictionary representing the resource + :param transform_tags: whether or not to perform "tag list" to "dictionary" conversion on the "Tags" key + :param normalize: whether resources should be passed through .botocore.normalize_boto3_result + :param ignore_list: a list of keys, the contents of which should not be transformed + :param nested_transforms: a mapping of keys to Callable, the Callable will only be passed the value for the key + in the resource dictionary + :return: dictionary representing the transformed resource + """ + if not resource: + return resource + ignore_list = ignore_list or [] + nested_transforms = nested_transforms or {} + + transformed_resource = deepcopy(resource) + if normalize: + transformed_resource = normalize_boto3_result(transformed_resource) + transformed_resource = _perform_nested_transforms(transformed_resource, nested_transforms) + ignore_list = [*ignore_list, *nested_transforms] + camel_resource = camel_dict_to_snake_dict(transformed_resource, ignore_list=ignore_list) + if transform_tags and "Tags" in resource: + camel_resource["tags"] = boto3_tag_list_to_ansible_dict(resource["Tags"]) + if force_tags and "Tags" not in resource: + camel_resource["tags"] = {} + + return camel_resource + + +def boto3_resource_list_to_ansible_dict( + resource_list: BotoResourceList, + transform_tags: bool = True, + force_tags: bool = True, + normalize: bool = True, + ignore_list: Optional[Sequence[str]] = None, + nested_transforms: Optional[Mapping[str, Callable]] = None, +) -> AnsibleAWSResourceList: + """ + Transforms a list of boto3-style (CamelCase) resources to the ansible-style (snake_case). + + :param resource_list: a list of dictionaries representing the resources + :param transform_tags: whether or not to perform "tag list" to "dictionary" conversion on the "Tags" key + :param normalize: whether resources should be passed through .botocore.normalize_boto3_result() + :param ignore_list: a list of keys, the contents of which should not be transformed + :param nested_transforms: a mapping of keys to Callable, the Callable will only be passed the value for the key + in the resource dictionary + :return: list of dictionaries representing the transformed resources + """ + if not resource_list: + return resource_list + return [ + boto3_resource_to_ansible_dict(resource, transform_tags, force_tags, normalize, ignore_list, nested_transforms) + for resource in resource_list + ] diff --git a/ansible_collections/amazon/aws/plugins/modules/cloudwatchlogs_log_group_info.py b/ansible_collections/amazon/aws/plugins/modules/cloudwatchlogs_log_group_info.py index 0cfe22e22..453d268d5 100644 --- a/ansible_collections/amazon/aws/plugins/modules/cloudwatchlogs_log_group_info.py +++ b/ansible_collections/amazon/aws/plugins/modules/cloudwatchlogs_log_group_info.py @@ -82,6 +82,18 @@ from ansible.module_utils.common.dict_transformations import camel_dict_to_snake from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry + + +@AWSRetry.exponential_backoff() +def list_tags_log_group_with_backoff(client, log_group_name): + return client.list_tags_log_group(logGroupName=log_group_name) + + +@AWSRetry.exponential_backoff() +def describe_log_groups_with_backoff(client, **kwargs): + paginator = client.get_paginator("describe_log_groups") + return paginator.paginate(**kwargs).build_full_result() def describe_log_group(client, log_group_name, module): @@ -89,15 +101,14 @@ def describe_log_group(client, log_group_name, module): if log_group_name: params["logGroupNamePrefix"] = log_group_name try: - paginator = client.get_paginator("describe_log_groups") - desc_log_group = paginator.paginate(**params).build_full_result() + desc_log_group = describe_log_groups_with_backoff(client, **params) except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: module.fail_json_aws(e, msg=f"Unable to describe log group {log_group_name}") for log_group in desc_log_group["logGroups"]: log_group_name = log_group["logGroupName"] try: - tags = client.list_tags_log_group(logGroupName=log_group_name) + tags = list_tags_log_group_with_backoff(client, log_group_name) except is_boto3_error_code("AccessDeniedException"): tags = {} module.warn(f"Permission denied listing tags for log group {log_group_name}") diff --git a/ansible_collections/amazon/aws/plugins/modules/iam_user_info.py b/ansible_collections/amazon/aws/plugins/modules/iam_user_info.py index 259d26803..2ddbe1d5a 100644 --- a/ansible_collections/amazon/aws/plugins/modules/iam_user_info.py +++ b/ansible_collections/amazon/aws/plugins/modules/iam_user_info.py @@ -103,14 +103,27 @@ iam_users: type: dict returned: if user exists sample: '{"Env": "Prod"}' + login_profile: + description: Detailed login profile information if the user has access to log in from AWS default console. Returns an empty object {} if no access. + returned: always + type: dict + sample: {"create_date": "2024-03-20T12:50:56+00:00", "password_reset_required": false, "user_name": "i_am_a_user"} """ from ansible_collections.amazon.aws.plugins.module_utils.iam import AnsibleIAMError +from ansible_collections.amazon.aws.plugins.module_utils.iam import IAMErrorHandler from ansible_collections.amazon.aws.plugins.module_utils.iam import get_iam_group from ansible_collections.amazon.aws.plugins.module_utils.iam import get_iam_user from ansible_collections.amazon.aws.plugins.module_utils.iam import list_iam_users from ansible_collections.amazon.aws.plugins.module_utils.iam import normalize_iam_user from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry + + +@IAMErrorHandler.list_error_handler("get login profile", {}) +@AWSRetry.jittered_backoff() +def check_console_access(connection, user_name): + return connection.get_login_profile(UserName=user_name)["LoginProfile"] def _list_users(connection, name, group, path): @@ -136,6 +149,8 @@ def _list_users(connection, name, group, path): def list_users(connection, name, group, path): users = _list_users(connection, name, group, path) users = [u for u in users if u is not None] + for user in users: + user["LoginProfile"] = check_console_access(connection, user["UserName"]) return [normalize_iam_user(user) for user in users] @@ -147,7 +162,9 @@ def main(): ) module = AnsibleAWSModule( - argument_spec=argument_spec, mutually_exclusive=[["group", "path_prefix"]], supports_check_mode=True + argument_spec=argument_spec, + mutually_exclusive=[["group", "path_prefix"]], + supports_check_mode=True, ) name = module.params.get("name") diff --git a/ansible_collections/amazon/aws/plugins/modules/s3_object.py b/ansible_collections/amazon/aws/plugins/modules/s3_object.py index 2c4ebe9c3..2cd897c89 100644 --- a/ansible_collections/amazon/aws/plugins/modules/s3_object.py +++ b/ansible_collections/amazon/aws/plugins/modules/s3_object.py @@ -315,7 +315,9 @@ EXAMPLES = r""" object: /my/desired/key.txt src: /usr/local/myfile.txt mode: put - metadata: 'Content-Encoding=gzip,Cache-Control=no-cache' + metadata: + Content-Encoding: gzip + Cache-Control: no-cache - name: PUT/upload with custom headers amazon.aws.s3_object: @@ -1314,6 +1316,11 @@ def copy_object_to_bucket(module, s3, bucket, obj, encrypt, metadata, validate, metadata, ) ) + if metadata: + # 'MetadataDirective' Specifies whether the metadata is copied from the source object or replaced + # with metadata that's provided in the request. The default value is 'COPY', therefore when user + # specifies a metadata we should set it to 'REPLACE' + params.update({"MetadataDirective": "REPLACE"}) s3.copy_object(aws_retry=True, **params) put_object_acl(module, s3, bucket, obj) # Tags diff --git a/ansible_collections/amazon/aws/plugins/plugin_utils/inventory.py b/ansible_collections/amazon/aws/plugins/plugin_utils/inventory.py index 144f77a7a..b0e47f7ef 100644 --- a/ansible_collections/amazon/aws/plugins/plugin_utils/inventory.py +++ b/ansible_collections/amazon/aws/plugins/plugin_utils/inventory.py @@ -33,7 +33,10 @@ class AWSInventoryBase(BaseInventoryPlugin, Constructable, Cacheable, AWSPluginB "secret_key", "session_token", "profile", - "iam_role_name", + "endpoint_url", + "assume_role_arn", + "region", + "regions", ) def __init__(self, templar, options): @@ -48,20 +51,21 @@ class AWSInventoryBase(BaseInventoryPlugin, Constructable, Cacheable, AWSPluginB def get(self, *args): value = self.original_options.get(*args) - if not value: - return value - if args[0] not in self.TEMPLATABLE_OPTIONS: - return value - if not self.templar.is_template(value): + if ( + not value + or not self.templar + or args[0] not in self.TEMPLATABLE_OPTIONS + or not self.templar.is_template(value) + ): return value return self.templar.template(variable=value, disable_lookups=False) def get_options(self, *args): - original_options = super().get_options(*args) - if not self.templar: - return original_options - return self.TemplatedOptions(self.templar, original_options) + return self.TemplatedOptions(self.templar, super().get_options(*args)) + + def get_option(self, option, hostvars=None): + return self.TemplatedOptions(self.templar, {option: super().get_option(option, hostvars)}).get(option) def __init__(self): super().__init__() @@ -109,8 +113,7 @@ class AWSInventoryBase(BaseInventoryPlugin, Constructable, Cacheable, AWSPluginB } def _set_frozen_credentials(self): - options = self.get_options() - iam_role_arn = options.get("assume_role_arn") + iam_role_arn = self.get_option("assume_role_arn") if iam_role_arn: self._freeze_iam_role(iam_role_arn) @@ -136,10 +139,9 @@ class AWSInventoryBase(BaseInventoryPlugin, Constructable, Cacheable, AWSPluginB return None def _boto3_regions(self, service): - options = self.get_options() - - if options.get("regions"): - return options.get("regions") + regions = self.get_option("regions") + if regions: + return regions # boto3 has hard coded lists of available regions for resources, however this does bit-rot # As such we try to query the service, and fall back to ec2 for a list of regions @@ -149,7 +151,7 @@ class AWSInventoryBase(BaseInventoryPlugin, Constructable, Cacheable, AWSPluginB return regions # fallback to local list hardcoded in boto3 if still no regions - session = _boto3_session(options.get("profile")) + session = _boto3_session(self.get_option("profile")) regions = session.get_available_regions(service) if not regions: |