Skip to content

Commit

Permalink
Add simple decorator for handling common botocore/boto3 errors (#1951)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
tremble authored Jan 31, 2024
1 parent 05c262a commit 655a9dd
Show file tree
Hide file tree
Showing 9 changed files with 846 additions and 262 deletions.
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

0 comments on commit 655a9dd

Please sign in to comment.