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