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

rds module_util - fix check_mode and idempotence bugs and support adding/removing IAM roles #714

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
minor_changes:
- module_utils.rds - Add support and unit tests for addition/removal of IAM roles to/from a db instance in module_utils.rds with waiters (https://github.com/ansible-collections/amazon.aws/pull/714).
bugfixes:
- module.utils.rds - Add waiter for promoting read replica to fix idempotency issue (https://github.com/ansible-collections/amazon.aws/pull/714).
68 changes: 56 additions & 12 deletions plugins/module_utils/rds.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
'create_db_instance', 'restore_db_instance_to_point_in_time', 'restore_db_instance_from_s3',
'restore_db_instance_from_db_snapshot', 'create_db_instance_read_replica', 'modify_db_instance',
'delete_db_instance', 'add_tags_to_resource', 'remove_tags_from_resource', 'list_tags_for_resource',
'promote_read_replica', 'stop_db_instance', 'start_db_instance', 'reboot_db_instance'
'promote_read_replica', 'stop_db_instance', 'start_db_instance', 'reboot_db_instance', 'add_role_to_db_instance',
'remove_role_from_db_instance'
]

snapshot_cluster_method_names = [
Expand Down Expand Up @@ -65,6 +66,12 @@ def get_rds_method_attribute(method_name, module):
waiter = 'db_instance_deleted'
elif method_name == 'stop_db_instance':
waiter = 'db_instance_stopped'
elif method_name == 'add_role_to_db_instance':
waiter = 'role_associated'
elif method_name == 'remove_role_from_db_instance':
waiter = 'role_disassociated'
elif method_name == 'promote_read_replica':
waiter = 'read_replica_promoted'
else:
waiter = 'db_instance_available'
elif method_name in snapshot_cluster_method_names or method_name in snapshot_instance_method_names:
Expand Down Expand Up @@ -130,15 +137,6 @@ def handle_errors(module, exception, method_name, parameters):
changed = False
else:
module.fail_json_aws(exception, msg='Unable to {0}'.format(get_rds_method_attribute(method_name, module).operation_description))
elif method_name == 'create_db_instance' and error_code == 'InvalidParameterValue':
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed and moved engine validation to community.aws.rds_instance (added choices for valid engines so this code will never be reached)

accepted_engines = [
'aurora', 'aurora-mysql', 'aurora-postgresql', 'mariadb', 'mysql', 'oracle-ee', 'oracle-se',
'oracle-se1', 'oracle-se2', 'postgres', 'sqlserver-ee', 'sqlserver-ex', 'sqlserver-se', 'sqlserver-web'
]
if parameters.get('Engine') not in accepted_engines:
module.fail_json_aws(exception, msg='DB engine {0} should be one of {1}'.format(parameters.get('Engine'), accepted_engines))
else:
module.fail_json_aws(exception, msg='Unable to {0}'.format(get_rds_method_attribute(method_name, module).operation_description))
elif method_name == 'create_db_cluster' and error_code == 'InvalidParameterValue':
accepted_engines = [
'aurora', 'aurora-mysql', 'aurora-postgresql'
Expand All @@ -161,10 +159,10 @@ def call_method(client, module, method_name, parameters):
# TODO: stabilize by adding get_rds_method_attribute(method_name).extra_retry_codes
method = getattr(client, method_name)
try:
if method_name == 'modify_db_instance':
if method_name in ['modify_db_instance', 'promote_read_replica']:
# check if instance is in an available state first, if possible
if wait:
wait_for_status(client, module, module.params['db_instance_identifier'], method_name)
wait_for_status(client, module, module.params['db_instance_identifier'], 'modify_db_instance')
result = AWSRetry.jittered_backoff(catch_extra_error_codes=['InvalidDBInstanceState'])(method)(**parameters)
elif method_name == 'modify_db_cluster':
# check if cluster is in an available state first, if possible
Expand Down Expand Up @@ -305,3 +303,49 @@ def ensure_tags(client, module, resource_arn, existing_tags, tags, purge_tags):
parameters={'ResourceName': resource_arn, 'TagKeys': tags_to_remove}
)
return changed


def compare_iam_roles(existing_roles, target_roles, purge_roles):
jatorcasso marked this conversation as resolved.
Show resolved Hide resolved
'''
Returns differences between target and existing IAM roles

Parameters:
existing_roles (list): Existing IAM roles
target_roles (list): Target IAM roles
purge_roles (bool): Remove roles not in target_roles if True

Returns:
roles_to_add (list): List of IAM roles to add
roles_to_delete (list): List of IAM roles to delete
'''
existing_roles = [dict((k, v) for k, v in role.items() if k != 'status') for role in existing_roles]
roles_to_add = [role for role in target_roles if role not in existing_roles]
roles_to_remove = [role for role in existing_roles if role not in target_roles] if purge_roles else []
return roles_to_add, roles_to_remove


def update_iam_roles(client, module, instance_id, roles_to_add, roles_to_remove):
'''
Update a DB instance's associated IAM roles

Parameters:
client: RDS client
module: AnsibleAWSModule
instance_id (str): DB's instance ID
roles_to_add (list): List of IAM roles to add
roles_to_delete (list): List of IAM roles to delete

Returns:
changed (bool): True if changes were successfully made to DB instance's IAM roles; False if not
'''
for role in roles_to_remove:
jillr marked this conversation as resolved.
Show resolved Hide resolved
params = {'DBInstanceIdentifier': instance_id,
'RoleArn': role['role_arn'],
'FeatureName': role['feature_name']}
result, changed = call_method(client, module, method_name='remove_role_from_db_instance', parameters=params)
for role in roles_to_add:
params = {'DBInstanceIdentifier': instance_id,
'RoleArn': role['role_arn'],
'FeatureName': role['feature_name']}
result, changed = call_method(client, module, method_name='add_role_to_db_instance', parameters=params)
return changed
81 changes: 81 additions & 0 deletions plugins/module_utils/waiters.py
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,69 @@
}
]
},
"ReadReplicaPromoted": {
"delay": 5,
"maxAttempts": 40,
"operation": "DescribeDBInstances",
"acceptors": [
{
"state": "success",
"matcher": "path",
"argument": "length(DBInstances[].StatusInfos) == `0`",
"expected": True
},
{
"state": "retry",
"matcher": "pathAny",
"argument": "DBInstances[].StatusInfos[].Status",
"expected": "replicating"
}
]
},
"RoleAssociated": {
"delay": 5,
"maxAttempts": 40,
"operation": "DescribeDBInstances",
"acceptors": [
{
"state": "success",
"matcher": "pathAll",
"argument": "DBInstances[].AssociatedRoles[].Status",
"expected": "ACTIVE"
},
{
"state": "retry",
"matcher": "pathAny",
"argument": "DBInstances[].AssociatedRoles[].Status",
"expected": "PENDING"
}
]
},
"RoleDisassociated": {
"delay": 5,
"maxAttempts": 40,
"operation": "DescribeDBInstances",
"acceptors": [
{
"state": "success",
"matcher": "pathAll",
"argument": "DBInstances[].AssociatedRoles[].Status",
"expected": "ACTIVE"
},
{
"state": "retry",
"matcher": "pathAny",
"argument": "DBInstances[].AssociatedRoles[].Status",
"expected": "PENDING"
},
{
"state": "success",
"matcher": "path",
"argument": "length(DBInstances[].AssociatedRoles[]) == `0`",
"expected": True
},
]
}
}
}

Expand Down Expand Up @@ -993,6 +1056,24 @@ def route53_model(name):
core_waiter.NormalizedOperationMethod(
rds.describe_db_clusters
)),
('RDS', 'read_replica_promoted'): lambda rds: core_waiter.Waiter(
'read_replica_promoted',
rds_model('ReadReplicaPromoted'),
core_waiter.NormalizedOperationMethod(
rds.describe_db_instances
)),
('RDS', 'role_associated'): lambda rds: core_waiter.Waiter(
'role_associated',
rds_model('RoleAssociated'),
core_waiter.NormalizedOperationMethod(
rds.describe_db_instances
)),
('RDS', 'role_disassociated'): lambda rds: core_waiter.Waiter(
'role_disassociated',
rds_model('RoleDisassociated'),
core_waiter.NormalizedOperationMethod(
rds.describe_db_instances
)),
('Route53', 'resource_record_sets_changed'): lambda route53: core_waiter.Waiter(
'resource_record_sets_changed',
route53_model('ResourceRecordSetsChanged'),
Expand Down
90 changes: 80 additions & 10 deletions tests/unit/module_utils/test_rds.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@

__metaclass__ = type

from ansible_collections.amazon.aws.tests.unit.compat.mock import MagicMock
from ansible_collections.amazon.aws.plugins.module_utils import rds

from ansible_collections.amazon.aws.tests.unit.compat import unittest
from ansible_collections.amazon.aws.tests.unit.compat.mock import MagicMock

from contextlib import nullcontext
import pytest
Expand Down Expand Up @@ -284,14 +284,6 @@ def test__handle_errors(method_name, exception, expected):
match="method promote_read_replica_db_cluster hasn't been added to the list of accepted methods to use a waiter in module_utils/rds.py",
),
),
(
"create_db_instance",
build_exception("create_db_instance", code="InvalidParameterValue"),
*expected(
"DB engine fake_engine should be one of aurora, aurora-mysql, aurora-postgresql, mariadb, mysql, oracle-ee, oracle-se, oracle-se1, "
+ "oracle-se2, postgres, sqlserver-ee, sqlserver-ex, sqlserver-se, sqlserver-web"
),
),
(
"create_db_cluster",
build_exception("create_db_cluster", code="InvalidParameterValue"),
Expand All @@ -308,3 +300,81 @@ def test__handle_errors_failed(method_name, exception, expected, error):
rds.handle_errors(module, exception, method_name, {"Engine": "fake_engine"})
module.fail_json_aws.assert_called_once
module.fail_json_aws.call_args[1]["msg"] == expected


class RdsUtils(unittest.TestCase):

# ========================================================
# Setup some initial data that we can use within our tests
# ========================================================
def setUp(self):
self.target_role_list = [
{
'role_arn': 'role_won',
'feature_name': 's3Export'
},
{
'role_arn': 'role_too',
'feature_name': 'Lambda'
},
{
'role_arn': 'role_thrie',
'feature_name': 's3Import'
}
]

# ========================================================
# rds.compare_iam_roles
# ========================================================

def test_compare_iam_roles_equal(self):
existing_list = self.target_role_list
roles_to_add, roles_to_delete = rds.compare_iam_roles(existing_list, self.target_role_list, purge_roles=False)
self.assertEqual([], roles_to_add)
self.assertEqual([], roles_to_delete)
roles_to_add, roles_to_delete = rds.compare_iam_roles(existing_list, self.target_role_list, purge_roles=True)
self.assertEqual([], roles_to_add)
self.assertEqual([], roles_to_delete)

def test_compare_iam_roles_empty_arr_existing(self):
roles_to_add, roles_to_delete = rds.compare_iam_roles([], self.target_role_list, purge_roles=False)
self.assertEqual(self.target_role_list, roles_to_add)
self.assertEqual([], roles_to_delete)
roles_to_add, roles_to_delete = rds.compare_iam_roles([], self.target_role_list, purge_roles=True)
self.assertEqual(self.target_role_list, roles_to_add)
self.assertEqual([], roles_to_delete)

def test_compare_iam_roles_empty_arr_target(self):
existing_list = self.target_role_list
roles_to_add, roles_to_delete = rds.compare_iam_roles(existing_list, [], purge_roles=False)
self.assertEqual([], roles_to_add)
self.assertEqual([], roles_to_delete)
roles_to_add, roles_to_delete = rds.compare_iam_roles(existing_list, [], purge_roles=True)
self.assertEqual([], roles_to_add)
self.assertEqual(self.target_role_list, roles_to_delete)

def test_compare_iam_roles_different(self):
existing_list = [
{
'role_arn': 'role_wonn',
'feature_name': 's3Export'
}]
roles_to_add, roles_to_delete = rds.compare_iam_roles(existing_list, self.target_role_list, purge_roles=False)
self.assertEqual(self.target_role_list, roles_to_add)
self.assertEqual([], roles_to_delete)
roles_to_add, roles_to_delete = rds.compare_iam_roles(existing_list, self.target_role_list, purge_roles=True)
self.assertEqual(self.target_role_list, roles_to_add)
self.assertEqual(existing_list, roles_to_delete)

existing_list = self.target_role_list.copy()
self.target_role_list = [
{
'role_arn': 'role_wonn',
'feature_name': 's3Export'
}]
roles_to_add, roles_to_delete = rds.compare_iam_roles(existing_list, self.target_role_list, purge_roles=False)
self.assertEqual(self.target_role_list, roles_to_add)
self.assertEqual([], roles_to_delete)
roles_to_add, roles_to_delete = rds.compare_iam_roles(existing_list, self.target_role_list, purge_roles=True)
self.assertEqual(self.target_role_list, roles_to_add)
self.assertEqual(existing_list, roles_to_delete)