Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PR #1951/655a9dd1 backport][stable-7] Add simple decorator for handling common botocore/boto3 errors #1960

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelogs/fragments/1951-error-handler.yml
Original file line number Diff line number Diff line change
@@ -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).
104 changes: 104 additions & 0 deletions plugins/module_utils/errors.py
Original file line number Diff line number Diff line change
@@ -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
118 changes: 37 additions & 81 deletions plugins/module_utils/iam.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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.",
Expand All @@ -165,91 +172,50 @@ 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.
Profiles need to be converted to Ansible format using normalize_iam_instance_profile before being displayed.

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):
Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading