1# This code is part of Ansible, but is an independent component.
2# This particular file snippet, and this file snippet only, is BSD licensed.
3# Modules you write using this snippet, which is embedded dynamically by Ansible
4# still belong to the author of the module, and may assign their own license
5# to the complete work.
6#
7# Copyright (c) 2017, Sumit Kumar <sumit4@netapp.com>
8# Copyright (c) 2017, Michael Price <michael.price@netapp.com>
9# All rights reserved.
10#
11# Redistribution and use in source and binary forms, with or without modification,
12# are permitted provided that the following conditions are met:
13#
14#    * Redistributions of source code must retain the above copyright
15#      notice, this list of conditions and the following disclaimer.
16#    * Redistributions in binary form must reproduce the above copyright notice,
17#      this list of conditions and the following disclaimer in the documentation
18#      and/or other materials provided with the distribution.
19#
20# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
21# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
22# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
23# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
24# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
25# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
26# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
27# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
28# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29
30'''
31common routines for um_info
32'''
33
34from __future__ import (absolute_import, division, print_function)
35__metaclass__ = type
36
37import logging
38from ansible.module_utils.basic import missing_required_lib
39from ansible.module_utils._text import to_native
40
41try:
42    from ansible.module_utils.ansible_release import __version__ as ansible_version
43except ImportError:
44    ansible_version = 'unknown'
45
46COLLECTION_VERSION = "21.7.0"
47
48try:
49    import requests
50    HAS_REQUESTS = True
51except ImportError:
52    HAS_REQUESTS = False
53
54ERROR_MSG = dict(
55    no_cserver='This module is expected to run as cluster admin'
56)
57
58LOG = logging.getLogger(__name__)
59LOG_FILE = '/tmp/um_apis.log'
60
61
62def na_um_host_argument_spec():
63
64    return dict(
65        hostname=dict(required=True, type='str'),
66        username=dict(required=True, type='str'),
67        password=dict(required=True, type='str', no_log=True),
68        validate_certs=dict(required=False, type='bool', default=True),
69        http_port=dict(required=False, type='int'),
70        feature_flags=dict(required=False, type='dict', default=dict()),
71        max_records=dict(required=False, type='int')
72    )
73
74
75def has_feature(module, feature_name):
76    feature = get_feature(module, feature_name)
77    if isinstance(feature, bool):
78        return feature
79    module.fail_json(msg="Error: expected bool type for feature flag: %s" % feature_name)
80
81
82def get_feature(module, feature_name):
83    ''' if the user has configured the feature, use it
84        otherwise, use our default
85    '''
86    default_flags = dict(
87        strict_json_check=True,                 # if true, fail if response.content in not empty and is not valid json
88        trace_apis=False,                       # if true, append REST requests/responses to LOG_FILE
89
90    )
91
92    if module.params['feature_flags'] is not None and feature_name in module.params['feature_flags']:
93        return module.params['feature_flags'][feature_name]
94    if feature_name in default_flags:
95        return default_flags[feature_name]
96    module.fail_json(msg="Internal error: unexpected feature flag: %s" % feature_name)
97
98
99class UMRestAPI(object):
100    ''' send REST request and process response '''
101    def __init__(self, module, timeout=60):
102        self.module = module
103        self.username = self.module.params['username']
104        self.password = self.module.params['password']
105        self.hostname = self.module.params['hostname']
106        self.verify = self.module.params['validate_certs']
107        self.max_records = self.module.params['max_records']
108        self.timeout = timeout
109        if self.module.params.get('http_port') is not None:
110            self.url = 'https://%s:%d' % (self.hostname, self.module.params['http_port'])
111        else:
112            self.url = 'https://%s' % self.hostname
113        self.errors = list()
114        self.debug_logs = list()
115        self.check_required_library()
116        if has_feature(module, 'trace_apis'):
117            logging.basicConfig(filename=LOG_FILE, level=logging.DEBUG, format='%(asctime)s %(levelname)-8s %(message)s')
118
119    def check_required_library(self):
120        if not HAS_REQUESTS:
121            self.module.fail_json(msg=missing_required_lib('requests'))
122
123    def get_records(self, message, api):
124        records = list()
125        try:
126            if message['total_records'] > 0:
127                records = message['records']
128                if message['total_records'] != len(records):
129                    self.module.warn('Mismatch between received: %d and expected: %d records.' % (len(records), message['total_records']))
130        except KeyError as exc:
131            self.module.fail_json(msg='Error: unexpected response from %s: %s - expecting key: %s'
132                                  % (api, message, to_native(exc)))
133        return records
134
135    def send_request(self, method, api, params, json=None, accept=None):
136        ''' send http request and process response, including error conditions '''
137        url = self.url + api
138        status_code = None
139        content = None
140        json_dict = None
141        json_error = None
142        error_details = None
143        headers = None
144        if accept is not None:
145            headers = dict()
146            # accept is used to turn on/off HAL linking
147            if accept is not None:
148                headers['accept'] = accept
149
150        def check_contents(response):
151            '''json() may fail on an empty value, but it's OK if no response is expected.
152               To avoid false positives, only report an issue when we expect to read a value.
153               The first get will see it.
154            '''
155            if method == 'GET' and has_feature(self.module, 'strict_json_check'):
156                contents = response.content
157                if len(contents) > 0:
158                    raise ValueError("Expecting json, got: %s" % contents)
159
160        def get_json(response):
161            ''' extract json, and error message if present '''
162            try:
163                json = response.json()
164            except ValueError:
165                check_contents(response)
166                return None, None
167            error = json.get('error')
168            return json, error
169
170        self.log_debug('sending', repr(dict(method=method, url=url, verify=self.verify, params=params,
171                                            timeout=self.timeout, json=json, headers=headers)))
172        try:
173            response = requests.request(method, url, verify=self.verify, auth=(self.username, self.password),
174                                        params=params, timeout=self.timeout, json=json, headers=headers)
175            content = response.content  # for debug purposes
176            status_code = response.status_code
177            # If the response was successful, no Exception will be raised
178            response.raise_for_status()
179            json_dict, json_error = get_json(response)
180        except requests.exceptions.HTTPError as err:
181            __, json_error = get_json(response)
182            if json_error is None:
183                self.log_error(status_code, 'HTTP error: %s' % err)
184                error_details = str(err)
185            # If an error was reported in the json payload, it is handled below
186        except requests.exceptions.ConnectionError as err:
187            self.log_error(status_code, 'Connection error: %s' % err)
188            error_details = str(err)
189        except Exception as err:
190            self.log_error(status_code, 'Other error: %s' % err)
191            error_details = str(err)
192        if json_error is not None:
193            self.log_error(status_code, 'Endpoint error: %d: %s' % (status_code, json_error))
194            error_details = json_error
195        self.log_debug(status_code, content)
196        return json_dict, error_details
197
198    def get(self, api, params):
199
200        def get_next_api(message):
201            '''make sure _links is present, and href is present if next is present
202               return api if next is present, None otherwise
203               return error if _links or href are missing
204            '''
205            api, error = None, None
206            if message is None or '_links' not in message:
207                error = 'Expecting _links key in %s' % message
208            elif 'next' in message['_links']:
209                if 'href' in message['_links']['next']:
210                    api = message['_links']['next']['href']
211                else:
212                    error = 'Expecting href key in %s' % message['_links']['next']
213            return api, error
214
215        method = 'GET'
216        records = list()
217        if self.max_records is not None:
218            if params and 'max_records' not in params:
219                params['max_records'] = self.max_records
220            else:
221                params = dict(max_records=self.max_records)
222        api = '/api/%s' % api
223
224        while api:
225            message, error = self.send_request(method, api, params)
226            if error:
227                return message, error
228            api, error = get_next_api(message)
229            if error:
230                return message, error
231            if 'records' in message:
232                records.extend(message['records'])
233            params = None       # already included in the next link
234
235        if records:
236            message['records'] = records
237        return message, error
238
239    def log_error(self, status_code, message):
240        LOG.error("%s: %s", status_code, message)
241        self.errors.append(message)
242        self.debug_logs.append((status_code, message))
243
244    def log_debug(self, status_code, content):
245        LOG.debug("%s: %s", status_code, content)
246        self.debug_logs.append((status_code, content))
247