1# Copyright: (c) 2018, Ansible Project 2# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 3 4from __future__ import (absolute_import, division, print_function) 5__metaclass__ = type 6 7from collections import namedtuple 8from time import sleep 9 10try: 11 from botocore.exceptions import BotoCoreError, ClientError, WaiterError 12except ImportError: 13 pass 14 15from ansible.module_utils._text import to_text 16from ansible.module_utils.common.dict_transformations import snake_dict_to_camel_dict 17 18from .ec2 import AWSRetry 19from .ec2 import ansible_dict_to_boto3_tag_list 20from .ec2 import boto3_tag_list_to_ansible_dict 21from .ec2 import compare_aws_tags 22from .waiters import get_waiter 23 24Boto3ClientMethod = namedtuple('Boto3ClientMethod', ['name', 'waiter', 'operation_description', 'cluster', 'instance']) 25# Whitelist boto3 client methods for cluster and instance resources 26cluster_method_names = [ 27 'create_db_cluster', 'restore_db_cluster_from_db_snapshot', 'restore_db_cluster_from_s3', 28 'restore_db_cluster_to_point_in_time', 'modify_db_cluster', 'delete_db_cluster', 'add_tags_to_resource', 29 'remove_tags_from_resource', 'list_tags_for_resource', 'promote_read_replica_db_cluster' 30] 31instance_method_names = [ 32 'create_db_instance', 'restore_db_instance_to_point_in_time', 'restore_db_instance_from_s3', 33 'restore_db_instance_from_db_snapshot', 'create_db_instance_read_replica', 'modify_db_instance', 34 'delete_db_instance', 'add_tags_to_resource', 'remove_tags_from_resource', 'list_tags_for_resource', 35 'promote_read_replica', 'stop_db_instance', 'start_db_instance', 'reboot_db_instance' 36] 37 38 39def get_rds_method_attribute(method_name, module): 40 readable_op = method_name.replace('_', ' ').replace('db', 'DB') 41 if method_name in cluster_method_names and 'new_db_cluster_identifier' in module.params: 42 cluster = True 43 instance = False 44 if method_name == 'delete_db_cluster': 45 waiter = 'cluster_deleted' 46 else: 47 waiter = 'cluster_available' 48 elif method_name in instance_method_names and 'new_db_instance_identifier' in module.params: 49 cluster = False 50 instance = True 51 if method_name == 'delete_db_instance': 52 waiter = 'db_instance_deleted' 53 elif method_name == 'stop_db_instance': 54 waiter = 'db_instance_stopped' 55 else: 56 waiter = 'db_instance_available' 57 else: 58 raise NotImplementedError("method {0} hasn't been added to the list of accepted methods to use a waiter in module_utils/rds.py".format(method_name)) 59 60 return Boto3ClientMethod(name=method_name, waiter=waiter, operation_description=readable_op, cluster=cluster, instance=instance) 61 62 63def get_final_identifier(method_name, module): 64 apply_immediately = module.params['apply_immediately'] 65 if get_rds_method_attribute(method_name, module).cluster: 66 identifier = module.params['db_cluster_identifier'] 67 updated_identifier = module.params['new_db_cluster_identifier'] 68 elif get_rds_method_attribute(method_name, module).instance: 69 identifier = module.params['db_instance_identifier'] 70 updated_identifier = module.params['new_db_instance_identifier'] 71 else: 72 raise NotImplementedError("method {0} hasn't been added to the list of accepted methods in module_utils/rds.py".format(method_name)) 73 if not module.check_mode and updated_identifier and apply_immediately: 74 identifier = updated_identifier 75 return identifier 76 77 78def handle_errors(module, exception, method_name, parameters): 79 80 if not isinstance(exception, ClientError): 81 module.fail_json_aws(exception, msg="Unexpected failure for method {0} with parameters {1}".format(method_name, parameters)) 82 83 changed = True 84 error_code = exception.response['Error']['Code'] 85 if method_name == 'modify_db_instance' and error_code == 'InvalidParameterCombination': 86 if 'No modifications were requested' in to_text(exception): 87 changed = False 88 elif 'ModifyDbCluster API' in to_text(exception): 89 module.fail_json_aws(exception, msg='It appears you are trying to modify attributes that are managed at the cluster level. Please see rds_cluster') 90 else: 91 module.fail_json_aws(exception, msg='Unable to {0}'.format(get_rds_method_attribute(method_name, module).operation_description)) 92 elif method_name == 'promote_read_replica' and error_code == 'InvalidDBInstanceState': 93 if 'DB Instance is not a read replica' in to_text(exception): 94 changed = False 95 else: 96 module.fail_json_aws(exception, msg='Unable to {0}'.format(get_rds_method_attribute(method_name, module).operation_description)) 97 elif method_name == 'create_db_instance' and exception.response['Error']['Code'] == 'InvalidParameterValue': 98 accepted_engines = [ 99 'aurora', 'aurora-mysql', 'aurora-postgresql', 'mariadb', 'mysql', 'oracle-ee', 'oracle-se', 100 'oracle-se1', 'oracle-se2', 'postgres', 'sqlserver-ee', 'sqlserver-ex', 'sqlserver-se', 'sqlserver-web' 101 ] 102 if parameters.get('Engine') not in accepted_engines: 103 module.fail_json_aws(exception, msg='DB engine {0} should be one of {1}'.format(parameters.get('Engine'), accepted_engines)) 104 else: 105 module.fail_json_aws(exception, msg='Unable to {0}'.format(get_rds_method_attribute(method_name, module).operation_description)) 106 else: 107 module.fail_json_aws(exception, msg='Unable to {0}'.format(get_rds_method_attribute(method_name, module).operation_description)) 108 109 return changed 110 111 112def call_method(client, module, method_name, parameters): 113 result = {} 114 changed = True 115 if not module.check_mode: 116 wait = module.params['wait'] 117 # TODO: stabilize by adding get_rds_method_attribute(method_name).extra_retry_codes 118 method = getattr(client, method_name) 119 try: 120 if method_name == 'modify_db_instance': 121 # check if instance is in an available state first, if possible 122 if wait: 123 wait_for_status(client, module, module.params['db_instance_identifier'], method_name) 124 result = AWSRetry.jittered_backoff(catch_extra_error_codes=['InvalidDBInstanceState'])(method)(**parameters) 125 else: 126 result = AWSRetry.jittered_backoff()(method)(**parameters) 127 except (BotoCoreError, ClientError) as e: 128 changed = handle_errors(module, e, method_name, parameters) 129 130 if wait and changed: 131 identifier = get_final_identifier(method_name, module) 132 wait_for_status(client, module, identifier, method_name) 133 return result, changed 134 135 136def wait_for_instance_status(client, module, db_instance_id, waiter_name): 137 def wait(client, db_instance_id, waiter_name, extra_retry_codes): 138 retry = AWSRetry.jittered_backoff(catch_extra_error_codes=extra_retry_codes) 139 try: 140 waiter = client.get_waiter(waiter_name) 141 except ValueError: 142 # using a waiter in module_utils/waiters.py 143 waiter = get_waiter(client, waiter_name) 144 waiter.wait(WaiterConfig={'Delay': 60, 'MaxAttempts': 60}, DBInstanceIdentifier=db_instance_id) 145 146 waiter_expected_status = { 147 'db_instance_deleted': 'deleted', 148 'db_instance_stopped': 'stopped', 149 } 150 expected_status = waiter_expected_status.get(waiter_name, 'available') 151 if expected_status == 'available': 152 extra_retry_codes = ['DBInstanceNotFound'] 153 else: 154 extra_retry_codes = [] 155 for attempt_to_wait in range(0, 10): 156 try: 157 wait(client, db_instance_id, waiter_name, extra_retry_codes) 158 break 159 except WaiterError as e: 160 # Instance may be renamed and AWSRetry doesn't handle WaiterError 161 if e.last_response.get('Error', {}).get('Code') == 'DBInstanceNotFound': 162 sleep(10) 163 continue 164 module.fail_json_aws(e, msg='Error while waiting for DB instance {0} to be {1}'.format(db_instance_id, expected_status)) 165 except (BotoCoreError, ClientError) as e: 166 module.fail_json_aws(e, msg='Unexpected error while waiting for DB instance {0} to be {1}'.format( 167 db_instance_id, expected_status) 168 ) 169 170 171def wait_for_cluster_status(client, module, db_cluster_id, waiter_name): 172 try: 173 waiter = get_waiter(client, waiter_name).wait(DBClusterIdentifier=db_cluster_id) 174 except WaiterError as e: 175 if waiter_name == 'cluster_deleted': 176 msg = "Failed to wait for DB cluster {0} to be deleted".format(db_cluster_id) 177 else: 178 msg = "Failed to wait for DB cluster {0} to be available".format(db_cluster_id) 179 module.fail_json_aws(e, msg=msg) 180 except (BotoCoreError, ClientError) as e: 181 module.fail_json_aws(e, msg="Failed with an unexpected error while waiting for the DB cluster {0}".format(db_cluster_id)) 182 183 184def wait_for_status(client, module, identifier, method_name): 185 waiter_name = get_rds_method_attribute(method_name, module).waiter 186 if get_rds_method_attribute(method_name, module).cluster: 187 wait_for_cluster_status(client, module, identifier, waiter_name) 188 elif get_rds_method_attribute(method_name, module).instance: 189 wait_for_instance_status(client, module, identifier, waiter_name) 190 else: 191 raise NotImplementedError("method {0} hasn't been added to the whitelist of handled methods".format(method_name)) 192 193 194def get_tags(client, module, cluster_arn): 195 try: 196 return boto3_tag_list_to_ansible_dict( 197 client.list_tags_for_resource(ResourceName=cluster_arn)['TagList'] 198 ) 199 except (BotoCoreError, ClientError) as e: 200 module.fail_json_aws(e, msg="Unable to describe tags") 201 202 203def arg_spec_to_rds_params(options_dict): 204 tags = options_dict.pop('tags') 205 has_processor_features = False 206 if 'processor_features' in options_dict: 207 has_processor_features = True 208 processor_features = options_dict.pop('processor_features') 209 camel_options = snake_dict_to_camel_dict(options_dict, capitalize_first=True) 210 for key in list(camel_options.keys()): 211 for old, new in (('Db', 'DB'), ('Iam', 'IAM'), ('Az', 'AZ')): 212 if old in key: 213 camel_options[key.replace(old, new)] = camel_options.pop(key) 214 camel_options['Tags'] = tags 215 if has_processor_features: 216 camel_options['ProcessorFeatures'] = processor_features 217 return camel_options 218 219 220def ensure_tags(client, module, resource_arn, existing_tags, tags, purge_tags): 221 if tags is None: 222 return False 223 tags_to_add, tags_to_remove = compare_aws_tags(existing_tags, tags, purge_tags) 224 changed = bool(tags_to_add or tags_to_remove) 225 if tags_to_add: 226 call_method( 227 client, module, method_name='add_tags_to_resource', 228 parameters={'ResourceName': resource_arn, 'Tags': ansible_dict_to_boto3_tag_list(tags_to_add)} 229 ) 230 if tags_to_remove: 231 call_method( 232 client, module, method_name='remove_tags_from_resource', 233 parameters={'ResourceName': resource_arn, 'TagKeys': tags_to_remove} 234 ) 235 return changed 236