From 655a9dd134810570f33a93513c979d5b84581e77 Mon Sep 17 00:00:00 2001 From: Mark Chappell Date: Wed, 31 Jan 2024 17:56:57 +0100 Subject: [PATCH] Add simple decorator for handling common botocore/boto3 errors (#1951) Add simple decorator for handling common botocore/boto3 errors SUMMARY Adds a handler for common IAM API errors ISSUE TYPE Feature Pull Request COMPONENT NAME plugins/module_utils/iam.py plugins/modules/iam_user.py ADDITIONAL INFORMATION TODO: Changelog Reviewed-by: Alina Buzachis Reviewed-by: Mark Chappell Reviewed-by: Bikouo Aubin Reviewed-by: Helen Bailey --- changelogs/fragments/1951-error-handler.yml | 3 + plugins/module_utils/errors.py | 104 +++++ plugins/module_utils/iam.py | 118 ++--- plugins/modules/iam_user.py | 407 ++++++++++-------- .../aws_error_handler/test_common_handler.py | 87 ++++ .../test_deletion_handler.py | 125 ++++++ .../aws_error_handler/test_list_handler.py | 128 ++++++ .../iam/test_iam_error_handler.py | 131 ++++++ .../iam/test_validate_iam_identifiers.py | 5 + 9 files changed, 846 insertions(+), 262 deletions(-) create mode 100644 changelogs/fragments/1951-error-handler.yml create mode 100644 plugins/module_utils/errors.py create mode 100644 tests/unit/module_utils/errors/aws_error_handler/test_common_handler.py create mode 100644 tests/unit/module_utils/errors/aws_error_handler/test_deletion_handler.py create mode 100644 tests/unit/module_utils/errors/aws_error_handler/test_list_handler.py create mode 100644 tests/unit/module_utils/iam/test_iam_error_handler.py diff --git a/changelogs/fragments/1951-error-handler.yml b/changelogs/fragments/1951-error-handler.yml new file mode 100644 index 00000000000..a8c958db5f3 --- /dev/null +++ b/changelogs/fragments/1951-error-handler.yml @@ -0,0 +1,3 @@ +minor_changes: +- module_utils.errors - added a basic error handler decorator (https://github.com/ansible-collections/amazon.aws/pull/1951). +- iam_user - refactored error handling to use a decorator (https://github.com/ansible-collections/amazon.aws/pull/1951). diff --git a/plugins/module_utils/errors.py b/plugins/module_utils/errors.py new file mode 100644 index 00000000000..38e9b380072 --- /dev/null +++ b/plugins/module_utils/errors.py @@ -0,0 +1,104 @@ +# -*- 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 functools + +try: + import botocore +except ImportError: + pass # Modules are responsible for handling this. + +from .exceptions import AnsibleAWSError + + +class AWSErrorHandler: + + """_CUSTOM_EXCEPTION can be overridden by subclasses to customize the exception raised""" + + _CUSTOM_EXCEPTION = AnsibleAWSError + + @classmethod + def _is_missing(cls): + """Should be overridden with a class method that returns the value from is_boto3_error_code (or similar)""" + return type("NeverEverRaisedException", (Exception,), {}) + + @classmethod + def common_error_handler(cls, description): + """A simple error handler that catches the standard Boto3 exceptions and raises + an AnsibleAWSError exception. + + param: description: a description of the action being taken. + Exception raised will include a message of + f"Timeout trying to {description}" or + f"Failed to {description}" + """ + + def wrapper(func): + @functools.wraps(func) + def handler(*args, **kwargs): + try: + return func(*args, **kwargs) + except botocore.exceptions.WaiterError as e: + raise cls._CUSTOM_EXCEPTION(message=f"Timeout trying to {description}", exception=e) from e + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + raise cls._CUSTOM_EXCEPTION(message=f"Failed to {description}", exception=e) from e + + return handler + + return wrapper + + @classmethod + def list_error_handler(cls, description, default_value=None): + """A simple error handler that catches the standard Boto3 exceptions and raises + an AnsibleAWSError exception. + Error codes representing a non-existent entity will result in None being returned + Generally used for Get/List calls where the exception just means the resource isn't there + + param: description: a description of the action being taken. + Exception raised will include a message of + f"Timeout trying to {description}" or + f"Failed to {description}" + param: default_value: the value to return if no matching + resources are returned. Defaults to None + """ + + def wrapper(func): + @functools.wraps(func) + @cls.common_error_handler(description) + def handler(*args, **kwargs): + try: + return func(*args, **kwargs) + except cls._is_missing(): + return default_value + + return handler + + return wrapper + + @classmethod + def deletion_error_handler(cls, description): + """A simple error handler that catches the standard Boto3 exceptions and raises + an AnsibleAWSError exception. + Error codes representing a non-existent entity will result in None being returned + Generally used in deletion calls where NoSuchEntity means it's already gone + + param: description: a description of the action being taken. + Exception raised will include a message of + f"Timeout trying to {description}" or + f"Failed to {description}" + """ + + def wrapper(func): + @functools.wraps(func) + @cls.common_error_handler(description) + def handler(*args, **kwargs): + try: + return func(*args, **kwargs) + except cls._is_missing(): + return False + + return handler + + return wrapper diff --git a/plugins/module_utils/iam.py b/plugins/module_utils/iam.py index 787588b43df..7793610a552 100644 --- a/plugins/module_utils/iam.py +++ b/plugins/module_utils/iam.py @@ -17,6 +17,7 @@ from .arn import parse_aws_arn from .arn import validate_aws_arn from .botocore import is_boto3_error_code +from .errors import AWSErrorHandler from .exceptions import AnsibleAWSError from .retries import AWSRetry from .tagging import ansible_dict_to_boto3_tag_list @@ -27,6 +28,14 @@ class AnsibleIAMError(AnsibleAWSError): pass +class IAMErrorHandler(AWSErrorHandler): + _CUSTOM_EXCEPTION = AnsibleIAMError + + @classmethod + def _is_missing(cls): + return is_boto3_error_code("NoSuchEntity") + + @AWSRetry.jittered_backoff() def _tag_iam_instance_profile(client, **kwargs): client.tag_instance_profile(**kwargs) @@ -80,11 +89,9 @@ def _list_managed_policies(client, **kwargs): return paginator.paginate(**kwargs).build_full_result() +@IAMErrorHandler.common_error_handler("list all managed policies") def list_managed_policies(client): - try: - return _list_managed_policies(client)["Policies"] - except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e: - raise AnsibleIAMError(message="Failed to list all managed policies", exception=e) + return _list_managed_policies(client)["Policies"] def convert_managed_policy_names_to_arns(client, policy_names): @@ -99,7 +106,7 @@ def convert_managed_policy_names_to_arns(client, policy_names): try: return [allpolicies[policy] for policy in policy_names if policy is not None] except KeyError as e: - raise AnsibleIAMError(message="Failed to find policy by name:" + str(e), exception=e) + raise AnsibleIAMError(message="Failed to find policy by name:" + str(e), exception=e) from e def get_aws_account_id(module): @@ -148,10 +155,10 @@ def get_aws_account_info(module): ) account_id = result.get("account_id") partition = result.get("partition") - except ( + except ( # pylint: disable=duplicate-except botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError, - ) as e: # pylint: disable=duplicate-except + ) as e: module.fail_json_aws( e, msg="Failed to get AWS account information, Try allowing sts:GetCallerIdentity or iam:GetUser permissions.", @@ -165,68 +172,35 @@ def get_aws_account_info(module): return (to_native(account_id), to_native(partition)) +@IAMErrorHandler.common_error_handler("create instance profile") def create_iam_instance_profile(client, name, path, tags): boto3_tags = ansible_dict_to_boto3_tag_list(tags or {}) path = path or "/" - try: - result = _create_instance_profile(client, InstanceProfileName=name, Path=path, Tags=boto3_tags) - except ( - botocore.exceptions.BotoCoreError, - botocore.exceptions.ClientError, - ) as e: # pylint: disable=duplicate-except - raise AnsibleIAMError(message="Unable to create instance profile", exception=e) + result = _create_instance_profile(client, InstanceProfileName=name, Path=path, Tags=boto3_tags) return result["InstanceProfile"] +@IAMErrorHandler.deletion_error_handler("delete instance profile") def delete_iam_instance_profile(client, name): - try: - _delete_instance_profile(client, InstanceProfileName=name) - except is_boto3_error_code("NoSuchEntity"): - # Deletion already happened. - return False - except ( - botocore.exceptions.BotoCoreError, - botocore.exceptions.ClientError, - ) as e: # pylint: disable=duplicate-except - raise AnsibleIAMError(message="Unable to delete instance profile", exception=e) + _delete_instance_profile(client, InstanceProfileName=name) + # Error Handler will return False if the resource didn't exist return True +@IAMErrorHandler.common_error_handler("add role to instance profile") def add_role_to_iam_instance_profile(client, profile_name, role_name): - try: - _add_role_to_instance_profile(client, InstanceProfileName=profile_name, RoleName=role_name) - except ( - botocore.exceptions.BotoCoreError, - botocore.exceptions.ClientError, - ) as e: # pylint: disable=duplicate-except - raise AnsibleIAMError( - message="Unable to add role to instance profile", - exception=e, - profile_name=profile_name, - role_name=role_name, - ) + _add_role_to_instance_profile(client, InstanceProfileName=profile_name, RoleName=role_name) return True +@IAMErrorHandler.deletion_error_handler("remove role from instance profile") def remove_role_from_iam_instance_profile(client, profile_name, role_name): - try: - _remove_role_from_instance_profile(client, InstanceProfileName=profile_name, RoleName=role_name) - except is_boto3_error_code("NoSuchEntity"): - # Deletion already happened. - return False - except ( - botocore.exceptions.BotoCoreError, - botocore.exceptions.ClientError, - ) as e: # pylint: disable=duplicate-except - raise AnsibleIAMError( - message="Unable to remove role from instance profile", - exception=e, - profile_name=profile_name, - role_name=role_name, - ) + _remove_role_from_instance_profile(client, InstanceProfileName=profile_name, RoleName=role_name) + # Error Handler will return False if the resource didn't exist return True +@IAMErrorHandler.list_error_handler("list instance profiles", []) def list_iam_instance_profiles(client, name=None, prefix=None, role=None): """ Returns a list of IAM instance profiles in boto3 format. @@ -234,22 +208,14 @@ def list_iam_instance_profiles(client, name=None, prefix=None, role=None): See also: normalize_iam_instance_profile """ - try: - if role: - return _list_iam_instance_profiles_for_role(client, RoleName=role) - if name: - # Unlike the others this returns a single result, make this a list with 1 element. - return [_get_iam_instance_profiles(client, InstanceProfileName=name)] - if prefix: - return _list_iam_instance_profiles(client, PathPrefix=prefix) - return _list_iam_instance_profiles(client) - except is_boto3_error_code("NoSuchEntity"): - return [] - except ( - botocore.exceptions.BotoCoreError, - botocore.exceptions.ClientError, - ) as e: # pylint: disable=duplicate-except - raise AnsibleIAMError(message="Unable to list instance profiles", exception=e) + if role: + return _list_iam_instance_profiles_for_role(client, RoleName=role) + if name: + # Unlike the others this returns a single result, make this a list with 1 element. + return [_get_iam_instance_profiles(client, InstanceProfileName=name)] + if prefix: + return _list_iam_instance_profiles(client, PathPrefix=prefix) + return _list_iam_instance_profiles(client) def normalize_iam_instance_profile(profile): @@ -288,29 +254,19 @@ def normalize_iam_role(role): return new_role +@IAMErrorHandler.common_error_handler("tag instance profile") def tag_iam_instance_profile(client, name, tags): if not tags: return boto3_tags = ansible_dict_to_boto3_tag_list(tags or {}) - try: - result = _tag_iam_instance_profile(client, InstanceProfileName=name, Tags=boto3_tags) - except ( - botocore.exceptions.BotoCoreError, - botocore.exceptions.ClientError, - ) as e: # pylint: disable=duplicate-except - raise AnsibleIAMError(message="Unable to tag instance profile", exception=e) + result = _tag_iam_instance_profile(client, InstanceProfileName=name, Tags=boto3_tags) +@IAMErrorHandler.common_error_handler("untag instance profile") def untag_iam_instance_profile(client, name, tags): if not tags: return - try: - result = _untag_iam_instance_profile(client, InstanceProfileName=name, TagKeys=tags) - except ( - botocore.exceptions.BotoCoreError, - botocore.exceptions.ClientError, - ) as e: # pylint: disable=duplicate-except - raise AnsibleIAMError(message="Unable to untag instance profile", exception=e) + result = _untag_iam_instance_profile(client, InstanceProfileName=name, TagKeys=tags) def _validate_iam_name(resource_type, name=None): diff --git a/plugins/modules/iam_user.py b/plugins/modules/iam_user.py index c123961b79e..8f21ae70ff0 100644 --- a/plugins/modules/iam_user.py +++ b/plugins/modules/iam_user.py @@ -227,6 +227,7 @@ from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code 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 convert_managed_policy_names_to_arns from ansible_collections.amazon.aws.plugins.module_utils.iam import validate_iam_identifiers from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule @@ -243,6 +244,12 @@ def normalize_user(user): return user +@IAMErrorHandler.common_error_handler("waiting for IAM user creation") +def _wait_user_exists(connection, **params): + waiter = connection.get_waiter("user_exists") + waiter.wait(**params) + + def wait_iam_exists(connection, module): if not module.params.get("wait"): return @@ -252,19 +259,12 @@ def wait_iam_exists(connection, module): delay = min(wait_timeout, 5) max_attempts = wait_timeout // delay + waiter_config = {"Delay": delay, "MaxAttempts": max_attempts} - try: - waiter = connection.get_waiter("user_exists") - waiter.wait( - WaiterConfig={"Delay": delay, "MaxAttempts": max_attempts}, - UserName=user_name, - ) - except botocore.exceptions.WaiterError as e: - module.fail_json_aws(e, msg="Timeout while waiting on IAM user creation") - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - module.fail_json_aws(e, msg="Failed while waiting on IAM user creation") + _wait_user_exists(connection, WaiterConfig=waiter_config, UserName=user_name) +@IAMErrorHandler.common_error_handler("create user") def create_user(connection, module, user_name, path, boundary, tags): params = {"UserName": user_name} if path: @@ -277,17 +277,23 @@ def create_user(connection, module, user_name, path, boundary, tags): if module.check_mode: module.exit_json(changed=True, create_params=params) - try: - user = connection.create_user(aws_retry=True, **params) - except ( - botocore.exceptions.ClientError, - botocore.exceptions.BotoCoreError, - ) as e: # pylint: disable=duplicate-except - raise AnsibleIAMError(message=f"Unable to create user {user_name}", exception=e) + user = connection.create_user(aws_retry=True, **params) return normalize_user(user) +@IAMErrorHandler.common_error_handler("create user login profile") +def _create_login_profile(connection, **params): + return connection.create_login_profile(aws_retry=True, **params) + + +# Uses the list error handler because we "update" as a quick test for existence +# when our next step would be update or create. +@IAMErrorHandler.list_error_handler("update user login profile") +def _update_login_profile(connection, **params): + return connection.update_login_profile(aws_retry=True, **params) + + def _create_or_update_login_profile(connection, name, password, reset): # Apply new password / update password for the user user_params = { @@ -296,21 +302,10 @@ def _create_or_update_login_profile(connection, name, password, reset): "PasswordResetRequired": reset, } - try: - retval = connection.update_login_profile(aws_retry=True, **user_params) - except is_boto3_error_code("NoSuchEntity"): - # Login profile does not yet exist - create it - try: - retval = connection.create_login_profile(aws_retry=True, **user_params) - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - raise AnsibleIAMError(message="Unable to create user login profile", exception=e) - except ( - botocore.exceptions.ClientError, - botocore.exceptions.BotoCoreError, - ) as e: # pylint: disable=duplicate-except - raise AnsibleIAMError(message="Unable to update user login profile", exception=e) - - return retval + retval = _update_login_profile(connection, **user_params) + if retval: + return retval + return _create_login_profile(connection, **user_params) def ensure_login_profile(connection, check_mode, user_name, password, update, reset, new_user): @@ -325,29 +320,14 @@ def ensure_login_profile(connection, check_mode, user_name, password, update, re return True, _create_or_update_login_profile(connection, user_name, password, reset) +@IAMErrorHandler.list_error_handler("get login profile") def _get_login_profile(connection, name): - try: - profile = connection.get_login_profile(aws_retry=True, UserName=name).get("LoginProfile") - except is_boto3_error_code("NoSuchEntity"): - return None - except ( - botocore.exceptions.ClientError, - botocore.exceptions.BotoCoreError, - ) as e: - raise AnsibleIAMError(message=f"Unable to get login profile for user {name}", exception=e) - return profile + return connection.get_login_profile(aws_retry=True, UserName=name).get("LoginProfile") +@IAMErrorHandler.deletion_error_handler("delete login profile") def _delete_login_profile(connection, name): - try: - connection.delete_login_profile(aws_retry=True, UserName=name) - except is_boto3_error_code("NoSuchEntityException"): - return - except ( - botocore.exceptions.ClientError, - botocore.exceptions.BotoCoreError, - ) as e: # pylint: disable=duplicate-except - raise AnsibleIAMError(message="Unable to delete user login profile", exception=e) + connection.delete_login_profile(aws_retry=True, UserName=name) def remove_login_profile(connection, check_mode, user_name, remove_password, new_user): @@ -368,38 +348,29 @@ def remove_login_profile(connection, check_mode, user_name, remove_password, new return True +@IAMErrorHandler.list_error_handler("get policies for user") def _list_attached_policies(connection, user_name): - try: - return connection.list_attached_user_policies(UserName=user_name)["AttachedPolicies"] - except is_boto3_error_code("NoSuchEntity"): - return [] - except ( - botocore.exceptions.ClientError, - botocore.exceptions.BotoCoreError, - ) as e: # pylint: disable=duplicate-except - raise AnsibleIAMError(message=f"Unable to get policies for user {user_name}", exception=e) + return connection.list_attached_user_policies(UserName=user_name)["AttachedPolicies"] -def attach_policies(connection, user_name, policies): +@IAMErrorHandler.common_error_handler("attach policy to user") +def attach_policies(connection, check_mode, user_name, policies): + if not policies: + return False + if check_mode: + return True for policy_arn in policies: - try: - connection.attach_user_policy(UserName=user_name, PolicyArn=policy_arn) - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - raise AnsibleIAMError( - message=f"Unable to attach policy {policy_arn} to user {user_name}", - exception=e, - ) + connection.attach_user_policy(UserName=user_name, PolicyArn=policy_arn) -def detach_policies(connection, user_name, policies): +@IAMErrorHandler.common_error_handler("detach policy from user") +def detach_policies(connection, check_mode, user_name, policies): + if not policies: + return False + if check_mode: + return True for policy_arn in policies: - try: - connection.detach_user_policy(UserName=user_name, PolicyArn=policy_arn) - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - raise AnsibleIAMError( - message=f"Unable to detach policy {policy_arn} from user {user_name}", - exception=e, - ) + connection.detach_user_policy(UserName=user_name, PolicyArn=policy_arn) def ensure_managed_policies(connection, check_mode, user_name, managed_policies, purge_policies): @@ -423,12 +394,13 @@ def ensure_managed_policies(connection, check_mode, user_name, managed_policies, if check_mode: return True - detach_policies(connection, user_name, policies_to_remove) - attach_policies(connection, user_name, policies_to_add) + detach_policies(connection, check_mode, user_name, policies_to_remove) + attach_policies(connection, check_mode, user_name, policies_to_add) return True +@IAMErrorHandler.common_error_handler("set tags for user") def ensure_user_tags(connection, check_mode, user, user_name, new_tags, purge_tags): if new_tags is None: return False @@ -443,20 +415,28 @@ def ensure_user_tags(connection, check_mode, user, user_name, new_tags, purge_ta if check_mode: return True - try: - if tags_to_remove: - connection.untag_user(UserName=user_name, TagKeys=tags_to_remove) - if tags_to_add: - connection.tag_user(UserName=user_name, Tags=ansible_dict_to_boto3_tag_list(tags_to_add)) - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - raise AnsibleIAMError( - message=f"Unable to set tags for user {user_name}", - exception=e, - ) + if tags_to_remove: + connection.untag_user(UserName=user_name, TagKeys=tags_to_remove) + if tags_to_add: + connection.tag_user(UserName=user_name, Tags=ansible_dict_to_boto3_tag_list(tags_to_add)) return True +@IAMErrorHandler.deletion_error_handler("remove permissions boundary for user") +def _delete_user_permissions_boundary(connection, check_mode, user_name): + if check_mode: + return True + connection.delete_user_permissions_boundary(aws_retry=True, UserName=user_name) + + +@IAMErrorHandler.common_error_handler("set permissions boundary for user") +def _put_user_permissions_boundary(connection, check_mode, user_name, boundary): + if check_mode: + return True + connection.put_user_permissions_boundary(aws_retry=True, UserName=user_name, PermissionsBoundary=boundary) + + def ensure_permissions_boundary(connection, check_mode, user, user_name, boundary): if boundary is None: return False @@ -472,32 +452,14 @@ def ensure_permissions_boundary(connection, check_mode, user, user_name, boundar return True if boundary == "": - try: - connection.delete_user_permissions_boundary( - aws_retry=True, - UserName=user_name, - ) - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - raise AnsibleIAMError( - message=f"Unable to remove permissions boundary for user {user_name}", - exception=e, - ) + _delete_user_permissions_boundary(connection, check_mode, user_name) else: - try: - connection.put_user_permissions_boundary( - aws_retry=True, - UserName=user_name, - PermissionsBoundary=boundary, - ) - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - raise AnsibleIAMError( - message=f"Unable to set permissions boundary for user {user_name}", - exception=e, - ) + _put_user_permissions_boundary(connection, check_mode, user_name, boundary) return True +@IAMErrorHandler.common_error_handler("set path for user") def ensure_path(connection, check_mode, user, user_name, path): if path is None: return False @@ -510,17 +472,7 @@ def ensure_path(connection, check_mode, user, user_name, path): if check_mode: return True - try: - connection.update_user( - aws_retry=True, - UserName=user_name, - NewPath=path, - ) - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - raise AnsibleIAMError( - message=f"Unable to set path for user {user_name}", - exception=e, - ) + connection.update_user(aws_retry=True, UserName=user_name, NewPath=path) return True @@ -627,6 +579,149 @@ def create_or_update_user(connection, module): module.exit_json(changed=changed, iam_user=user, user=user["user"]) +@IAMErrorHandler.deletion_error_handler("delete access key") +def delete_access_key(connection, check_mode, user_name, key_id): + if check_mode: + return True + connection.delete_access_key(aws_retry=True, UserName=user_name, AccessKeyId=key_id) + return True + + +@IAMErrorHandler.list_error_handler("list access keys") +def delete_access_keys(connection, check_mode, user_name): + access_keys = connection.list_access_keys(aws_retry=True, UserName=user_name)["AccessKeyMetadata"] + if not access_keys: + return False + for access_key in access_keys: + delete_access_key(connection, check_mode, user_name, access_key["AccessKeyId"]) + return True + + +@IAMErrorHandler.deletion_error_handler("delete SSH key") +def delete_ssh_key(connection, check_mode, user_name, key_id): + if check_mode: + return True + connection.delete_ssh_public_key(aws_retry=True, UserName=user_name, SSHPublicKeyId=key_id) + return True + + +@IAMErrorHandler.list_error_handler("list SSH keys") +def delete_ssh_public_keys(connection, check_mode, user_name): + public_keys = connection.list_ssh_public_keys(aws_retry=True, UserName=user_name)["SSHPublicKeys"] + if not public_keys: + return False + for public_key in public_keys: + delete_ssh_key(connection, check_mode, user_name, public_key["SSHPublicKeyId"]) + return True + + +@IAMErrorHandler.deletion_error_handler("delete service credential") +def delete_service_credential(connection, check_mode, user_name, cred_id): + if check_mode: + return True + connection.delete_ssh_public_key(aws_retry=True, UserName=user_name, SSHPublicKeyId=cred_id) + return True + + +@IAMErrorHandler.list_error_handler("list service credentials") +def delete_service_credentials(connection, check_mode, user_name): + credentials = connection.list_service_specific_credentials(aws_retry=True, UserName=user_name)[ + "ServiceSpecificCredentials" + ] + if not credentials: + return False + for credential in credentials: + delete_service_credential(connection, check_mode, user_name, credential["ServiceSpecificCredentialId"]) + return True + + +@IAMErrorHandler.deletion_error_handler("delete signing certificate") +def delete_signing_certificate(connection, check_mode, user_name, cert_id): + if check_mode: + return True + connection.delete_signing_certificate(aws_retry=True, UserName=user_name, CertificateId=cert_id) + return True + + +@IAMErrorHandler.list_error_handler("list signing certificates") +def delete_signing_certificates(connection, check_mode, user_name): + certificates = connection.list_signing_certificates(aws_retry=True, UserName=user_name)["Certificates"] + if not certificates: + return False + for certificate in certificates: + delete_signing_certificate(connection, check_mode, user_name, certificate["CertificateId"]) + return True + + +@IAMErrorHandler.deletion_error_handler("delete MFA device") +def delete_mfa_device(connection, check_mode, user_name, device_id): + if check_mode: + return True + connection.deactivate_mfa_device(aws_retry=True, UserName=user_name, SerialNumber=device_id) + return True + + +@IAMErrorHandler.list_error_handler("list MFA devices") +def delete_mfa_devices(connection, check_mode, user_name): + devices = connection.list_mfa_devices(aws_retry=True, UserName=user_name)["MFADevices"] + if not devices: + return False + for device in devices: + delete_mfa_device(connection, check_mode, user_name, device["SerialNumber"]) + return True + + +def detach_all_policies(connection, check_mode, user_name): + # Remove any attached policies + attached_policies_desc = _list_attached_policies(connection, user_name) + current_attached_policies = [policy["PolicyArn"] for policy in attached_policies_desc] + detach_policies(connection, check_mode, user_name, current_attached_policies) + + +@IAMErrorHandler.deletion_error_handler("delete inline policy") +def delete_inline_policy(connection, check_mode, user_name, policy): + if check_mode: + return True + connection.delete_user_policy(aws_retry=True, UserName=user_name, PolicyName=policy) + return True + + +@IAMErrorHandler.list_error_handler("list inline policies") +def delete_inline_policies(connection, check_mode, user_name): + inline_policies = connection.list_user_policies(aws_retry=True, UserName=user_name)["PolicyNames"] + if not inline_policies: + return False + for policy_name in inline_policies: + delete_inline_policy(connection, check_mode, user_name, policy_name) + return True + + +@IAMErrorHandler.deletion_error_handler("remove user from group") +def remove_from_group(connection, check_mode, user_name, group_name): + if check_mode: + return True + connection.remove_user_from_group(aws_retry=True, UserName=user_name, GroupName=group_name) + return True + + +@IAMErrorHandler.list_error_handler("list groups containing user") +def remove_from_all_groups(connection, check_mode, user_name): + user_groups = connection.list_groups_for_user(aws_retry=True, UserName=user_name)["Groups"] + if not user_groups: + return False + for group in user_groups: + remove_from_group(connection, check_mode, user_name, group["GroupName"]) + return True + + +@IAMErrorHandler.deletion_error_handler("delete user") +def delete_user(connection, check_mode, user_name): + if check_mode: + return True + connection.delete_user(aws_retry=True, UserName=user_name) + return True + + def destroy_user(connection, module): user_name = module.params.get("name") @@ -655,67 +750,17 @@ def destroy_user(connection, module): # - Inline policies # - Group membership - try: - # Remove user's login profile (console password) - remove_login_profile(connection, module.check_mode, user_name, True, False) - - # Remove user's access keys - access_keys = connection.list_access_keys(aws_retry=True, UserName=user_name)["AccessKeyMetadata"] - for access_key in access_keys: - connection.delete_access_key(aws_retry=True, UserName=user_name, AccessKeyId=access_key["AccessKeyId"]) - - # Remove user's ssh public keys - ssh_public_keys = connection.list_ssh_public_keys(UserName=user_name)["SSHPublicKeys"] - for ssh_public_key in ssh_public_keys: - connection.delete_ssh_public_key( - aws_retry=True, UserName=user_name, SSHPublicKeyId=ssh_public_key["SSHPublicKeyId"] - ) - - # Remove user's service specific credentials - service_credentials = connection.list_service_specific_credentials(aws_retry=True, UserName=user_name)[ - "ServiceSpecificCredentials" - ] - for service_specific_credential in service_credentials: - connection.delete_service_specific_credential( - aws_retry=True, - UserName=user_name, - ServiceSpecificCredentialId=service_specific_credential["ServiceSpecificCredentialId"], - ) - - # Remove user's signing certificates - signing_certificates = connection.list_signing_certificates(UserName=user_name)["Certificates"] - for signing_certificate in signing_certificates: - connection.delete_signing_certificate( - aws_retry=True, UserName=user_name, CertificateId=signing_certificate["CertificateId"] - ) - - # Remove user's MFA devices - mfa_devices = connection.list_mfa_devices(aws_retry=True, UserName=user_name)["MFADevices"] - for mfa_device in mfa_devices: - connection.deactivate_mfa_device( - aws_retry=True, UserName=user_name, SerialNumber=mfa_device["SerialNumber"] - ) - - # Remove any attached policies - attached_policies_desc = _list_attached_policies(connection, user_name) - current_attached_policies = [policy["PolicyArn"] for policy in attached_policies_desc] - detach_policies(connection, user_name, current_attached_policies) - - # Remove user's inline policies - inline_policies = connection.list_user_policies(aws_retry=True, UserName=user_name)["PolicyNames"] - for policy_name in inline_policies: - connection.delete_user_policy(aws_retry=True, UserName=user_name, PolicyName=policy_name) - - # Remove user's group membership - user_groups = connection.list_groups_for_user(aws_retry=True, UserName=user_name)["Groups"] - for group in user_groups: - connection.remove_user_from_group(aws_retry=True, UserName=user_name, GroupName=group["GroupName"]) - - connection.delete_user(aws_retry=True, UserName=user_name) - except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: - module.fail_json_aws(e, msg=f"Unable to delete user {user_name}") - - module.exit_json(changed=True) + remove_login_profile(connection, module.check_mode, user_name, True, False) + delete_access_keys(connection, module.check_mode, user_name) + delete_ssh_public_keys(connection, module.check_mode, user_name) + delete_service_credentials(connection, module.check_mode, user_name) + delete_signing_certificates(connection, module.check_mode, user_name) + delete_mfa_devices(connection, module.check_mode, user_name) + detach_all_policies(connection, module.check_mode, user_name) + delete_inline_policies(connection, module.check_mode, user_name) + remove_from_all_groups(connection, module.check_mode, user_name) + changed = delete_user(connection, module.check_mode, user_name) + module.exit_json(changed=changed) def get_user(connection, name): diff --git a/tests/unit/module_utils/errors/aws_error_handler/test_common_handler.py b/tests/unit/module_utils/errors/aws_error_handler/test_common_handler.py new file mode 100644 index 00000000000..3a3cc41b959 --- /dev/null +++ b/tests/unit/module_utils/errors/aws_error_handler/test_common_handler.py @@ -0,0 +1,87 @@ +# -*- 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) + +try: + import botocore +except ImportError: + pass + +import pytest + +from ansible_collections.amazon.aws.plugins.module_utils.botocore import HAS_BOTO3 +from ansible_collections.amazon.aws.plugins.module_utils.errors import AWSErrorHandler +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleAWSError + +if not HAS_BOTO3: + pytestmark = pytest.mark.skip("test_common_handler.py requires the python modules 'boto3' and 'botocore'") + + +class AnsibleAWSExampleError(AnsibleAWSError): + pass + + +class AWSExampleErrorHandler(AWSErrorHandler): + _CUSTOM_EXCEPTION = AnsibleAWSExampleError + + @classmethod + def _is_missing(cls): + # Shouldn't be called by the 'common' handler + assert False, "_is_missing() should not be called by common_error_handler" + + +class TestAwsCommonHandler: + def test_no_failures(self): + self.counter = 0 + + @AWSErrorHandler.common_error_handler("no error") + def no_failures(): + self.counter += 1 + + no_failures() + assert self.counter == 1 + + def test_no_failures_no_missing(self): + self.counter = 0 + + @AWSExampleErrorHandler.common_error_handler("no error") + def no_failures(): + self.counter += 1 + + no_failures() + assert self.counter == 1 + + def test_client_error(self): + self.counter = 0 + err_response = {"Error": {"Code": "MalformedPolicyDocument"}} + + @AWSErrorHandler.common_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "Something bad") + + with pytest.raises(AnsibleAWSError) as e_info: + raise_client_error() + assert self.counter == 1 + raised = e_info.value + assert isinstance(raised.exception, botocore.exceptions.ClientError) + assert "do something" in raised.message + assert "Something bad" in str(raised.exception) + + def test_custom_error(self): + self.counter = 0 + err_response = {"Error": {"Code": "MalformedPolicyDocument"}} + + @AWSExampleErrorHandler.common_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "Something bad") + + with pytest.raises(AnsibleAWSExampleError) as e_info: + raise_client_error() + assert self.counter == 1 + raised = e_info.value + assert isinstance(raised.exception, botocore.exceptions.ClientError) + assert "do something" in raised.message + assert "Something bad" in str(raised.exception) diff --git a/tests/unit/module_utils/errors/aws_error_handler/test_deletion_handler.py b/tests/unit/module_utils/errors/aws_error_handler/test_deletion_handler.py new file mode 100644 index 00000000000..adc08f6c1ec --- /dev/null +++ b/tests/unit/module_utils/errors/aws_error_handler/test_deletion_handler.py @@ -0,0 +1,125 @@ +# -*- 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) + +try: + import botocore +except ImportError: + pass + +import pytest + +from ansible_collections.amazon.aws.plugins.module_utils.botocore import HAS_BOTO3 +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.errors import AWSErrorHandler +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleAWSError + +if not HAS_BOTO3: + pytestmark = pytest.mark.skip("test_deletion_handler.py requires the python modules 'boto3' and 'botocore'") + + +class AnsibleAWSExampleError(AnsibleAWSError): + pass + + +class AWSExampleErrorHandler(AWSErrorHandler): + _CUSTOM_EXCEPTION = AnsibleAWSExampleError + + @classmethod + def _is_missing(cls): + return is_boto3_error_code("NoSuchEntity") + + +class AWSCleanErrorHandler(AWSErrorHandler): + @classmethod + def _is_missing(cls): + # Shouldn't be called if there's no error + assert False, "_is_missing() should not be called when no errors occurred" + + +class TestAWSDeletionHandler: + def test_no_failures(self): + self.counter = 0 + + @AWSErrorHandler.deletion_error_handler("no error") + def no_failures(): + self.counter += 1 + + no_failures() + assert self.counter == 1 + + def test_no_failures_no_missing(self): + self.counter = 0 + + @AWSCleanErrorHandler.deletion_error_handler("no error") + def no_failures(): + self.counter += 1 + + no_failures() + assert self.counter == 1 + + def test_client_error(self): + self.counter = 0 + err_response = {"Error": {"Code": "MalformedPolicyDocument"}} + + @AWSErrorHandler.deletion_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "Something bad") + + with pytest.raises(AnsibleAWSError) as e_info: + raise_client_error() + assert self.counter == 1 + raised = e_info.value + assert isinstance(raised.exception, botocore.exceptions.ClientError) + assert "do something" in raised.message + assert "Something bad" in str(raised.exception) + + def test_no_missing_client_error(self): + # If _is_missing() hasn't been overridden we do nothing interesting + self.counter = 0 + err_response = {"Error": {"Code": "NoSuchEntity"}} + + @AWSErrorHandler.deletion_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "I couldn't find it") + + with pytest.raises(AnsibleAWSError) as e_info: + raise_client_error() + assert self.counter == 1 + raised = e_info.value + assert isinstance(raised.exception, botocore.exceptions.ClientError) + assert "do something" in raised.message + assert "I couldn't find it" in str(raised.exception) + + def test_ignore_error(self): + self.counter = 0 + err_response = {"Error": {"Code": "NoSuchEntity"}} + + @AWSExampleErrorHandler.deletion_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "I couldn't find it") + + ret_val = raise_client_error() + assert self.counter == 1 + assert ret_val is False + + def test_custom_error(self): + self.counter = 0 + err_response = {"Error": {"Code": "MalformedPolicyDocument"}} + + @AWSExampleErrorHandler.deletion_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "Something bad") + + with pytest.raises(AnsibleAWSExampleError) as e_info: + raise_client_error() + assert self.counter == 1 + raised = e_info.value + assert isinstance(raised.exception, botocore.exceptions.ClientError) + assert "do something" in raised.message + assert "Something bad" in str(raised.exception) diff --git a/tests/unit/module_utils/errors/aws_error_handler/test_list_handler.py b/tests/unit/module_utils/errors/aws_error_handler/test_list_handler.py new file mode 100644 index 00000000000..4f9d276f62d --- /dev/null +++ b/tests/unit/module_utils/errors/aws_error_handler/test_list_handler.py @@ -0,0 +1,128 @@ +# -*- 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) + +try: + import botocore +except ImportError: + pass + +import pytest + +from ansible_collections.amazon.aws.plugins.module_utils.botocore import HAS_BOTO3 +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.errors import AWSErrorHandler +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleAWSError + +if not HAS_BOTO3: + pytestmark = pytest.mark.skip("test_list_handler.py requires the python modules 'boto3' and 'botocore'") + + +class AnsibleAWSExampleError(AnsibleAWSError): + pass + + +class AWSExampleErrorHandler(AWSErrorHandler): + _CUSTOM_EXCEPTION = AnsibleAWSExampleError + + @classmethod + def _is_missing(cls): + return is_boto3_error_code("NoSuchEntity") + + +class AWSCleanErrorHandler(AWSErrorHandler): + @classmethod + def _is_missing(cls): + # Shouldn't be called if there's no error + assert False, "_is_missing() should not be called when no errors occurred" + + +class TestAWSListHandler: + def test_no_failures(self): + self.counter = 0 + + @AWSErrorHandler.list_error_handler("no error") + def no_failures(): + self.counter += 1 + + no_failures() + assert self.counter == 1 + + def test_client_error(self): + self.counter = 0 + err_response = {"Error": {"Code": "MalformedPolicyDocument"}} + + @AWSErrorHandler.list_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "Something bad") + + with pytest.raises(AnsibleAWSError) as e_info: + raise_client_error() + assert self.counter == 1 + raised = e_info.value + assert isinstance(raised.exception, botocore.exceptions.ClientError) + assert "do something" in raised.message + assert "Something bad" in str(raised.exception) + + def test_no_missing_client_error(self): + # If _is_missing() hasn't been overridden we do nothing interesting + self.counter = 0 + err_response = {"Error": {"Code": "NoSuchEntity"}} + + @AWSErrorHandler.list_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "Something bad") + + with pytest.raises(AnsibleAWSError) as e_info: + raise_client_error() + assert self.counter == 1 + raised = e_info.value + assert isinstance(raised.exception, botocore.exceptions.ClientError) + assert "do something" in raised.message + assert "Something bad" in str(raised.exception) + + def test_list_error(self): + self.counter = 0 + err_response = {"Error": {"Code": "NoSuchEntity"}} + + @AWSExampleErrorHandler.list_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "I couldn't find it") + + ret_val = raise_client_error() + assert self.counter == 1 + assert ret_val is None + + def test_list_error_custom_return(self): + self.counter = 0 + err_response = {"Error": {"Code": "NoSuchEntity"}} + + @AWSExampleErrorHandler.list_error_handler("do something", []) + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "I couldn't find it") + + ret_val = raise_client_error() + assert self.counter == 1 + assert ret_val == [] + + def test_custom_error(self): + self.counter = 0 + err_response = {"Error": {"Code": "MalformedPolicyDocument"}} + + @AWSExampleErrorHandler.list_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "Something bad") + + with pytest.raises(AnsibleAWSExampleError) as e_info: + raise_client_error() + assert self.counter == 1 + raised = e_info.value + assert isinstance(raised.exception, botocore.exceptions.ClientError) + assert "do something" in raised.message + assert "Something bad" in str(raised.exception) diff --git a/tests/unit/module_utils/iam/test_iam_error_handler.py b/tests/unit/module_utils/iam/test_iam_error_handler.py new file mode 100644 index 00000000000..7da8f6e0df2 --- /dev/null +++ b/tests/unit/module_utils/iam/test_iam_error_handler.py @@ -0,0 +1,131 @@ +# -*- 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) + +try: + import botocore +except ImportError: + pass + +import pytest + +from ansible_collections.amazon.aws.plugins.module_utils.botocore import HAS_BOTO3 +from ansible_collections.amazon.aws.plugins.module_utils.iam import AnsibleIAMError +from ansible_collections.amazon.aws.plugins.module_utils.iam import IAMErrorHandler + +if not HAS_BOTO3: + pytestmark = pytest.mark.skip("test_iam_error_handler.py requires the python modules 'boto3' and 'botocore'") + + +class TestIamDeletionHandler: + def test_no_failures(self): + self.counter = 0 + + @IAMErrorHandler.deletion_error_handler("no error") + def no_failures(): + self.counter += 1 + + no_failures() + assert self.counter == 1 + + def test_client_error(self): + self.counter = 0 + err_response = {"Error": {"Code": "MalformedPolicyDocument"}} + + @IAMErrorHandler.deletion_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "Something bad") + + with pytest.raises(AnsibleIAMError) as e_info: + raise_client_error() + assert self.counter == 1 + raised = e_info.value + assert isinstance(raised.exception, botocore.exceptions.ClientError) + assert "do something" in raised.message + assert "Something bad" in str(raised.exception) + + def test_ignore_error(self): + self.counter = 0 + err_response = {"Error": {"Code": "NoSuchEntity"}} + + @IAMErrorHandler.deletion_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "I couldn't find it") + + ret_val = raise_client_error() + assert self.counter == 1 + assert ret_val is False + + +class TestIamListHandler: + def test_no_failures(self): + self.counter = 0 + + @IAMErrorHandler.list_error_handler("no error") + def no_failures(): + self.counter += 1 + + no_failures() + assert self.counter == 1 + + def test_client_error(self): + self.counter = 0 + err_response = {"Error": {"Code": "MalformedPolicyDocument"}} + + @IAMErrorHandler.list_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "Something bad") + + with pytest.raises(AnsibleIAMError) as e_info: + raise_client_error() + assert self.counter == 1 + raised = e_info.value + assert isinstance(raised.exception, botocore.exceptions.ClientError) + assert "do something" in raised.message + assert "Something bad" in str(raised.exception) + + def test_list_error(self): + self.counter = 0 + err_response = {"Error": {"Code": "NoSuchEntity"}} + + @IAMErrorHandler.list_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "I couldn't find it") + + ret_val = raise_client_error() + assert self.counter == 1 + assert ret_val is None + + +class TestIamCommonHandler: + def test_no_failures(self): + self.counter = 0 + + @IAMErrorHandler.common_error_handler("no error") + def no_failures(): + self.counter += 1 + + no_failures() + assert self.counter == 1 + + def test_client_error(self): + self.counter = 0 + err_response = {"Error": {"Code": "MalformedPolicyDocument"}} + + @IAMErrorHandler.common_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "Something bad") + + with pytest.raises(AnsibleIAMError) as e_info: + raise_client_error() + assert self.counter == 1 + raised = e_info.value + assert isinstance(raised.exception, botocore.exceptions.ClientError) + assert "do something" in raised.message + assert "Something bad" in str(raised.exception) diff --git a/tests/unit/module_utils/iam/test_validate_iam_identifiers.py b/tests/unit/module_utils/iam/test_validate_iam_identifiers.py index 98de2f16f6d..d5a0436f9f6 100644 --- a/tests/unit/module_utils/iam/test_validate_iam_identifiers.py +++ b/tests/unit/module_utils/iam/test_validate_iam_identifiers.py @@ -1,3 +1,8 @@ +# -*- 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.amazon.aws.plugins.module_utils.iam import validate_iam_identifiers