# This code is part of Ansible, but is an independent component. # This particular file snippet, and this file snippet only, is BSD licensed. # Modules you write using this snippet, which is embedded dynamically by Ansible # still belong to the author of the module, and may assign their own license # to the complete work. # # Copyright (c) 2017, Sumit Kumar # Copyright (c) 2017, Michael Price # All rights reserved. # # Redistribution and use in source and binary forms, with or without modification, # are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. # IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE # USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ''' netapp.py ''' from __future__ import (absolute_import, division, print_function) __metaclass__ = type import base64 import logging import os import ssl import time from ansible.module_utils.basic import missing_required_lib from ansible.module_utils._text import to_native try: from ansible.module_utils.ansible_release import __version__ as ansible_version except ImportError: ansible_version = 'unknown' COLLECTION_VERSION = "21.18.1" CLIENT_APP_VERSION = "%s/%s" % ("%s", COLLECTION_VERSION) IMPORT_EXCEPTION = None try: from netapp_lib.api.zapi import zapi HAS_NETAPP_LIB = True except ImportError as exc: HAS_NETAPP_LIB = False IMPORT_EXCEPTION = exc try: import requests HAS_REQUESTS = True except ImportError: HAS_REQUESTS = False HAS_SF_SDK = False SF_BYTE_MAP = dict( # Management GUI displays 1024 ** 3 as 1.1 GB, thus use 1000. bytes=1, b=1, kb=1000, mb=1000 ** 2, gb=1000 ** 3, tb=1000 ** 4, pb=1000 ** 5, eb=1000 ** 6, zb=1000 ** 7, yb=1000 ** 8 ) POW2_BYTE_MAP = dict( # Here, 1 kb = 1024 bytes=1, b=1, k=1024, m=1024 ** 2, g=1024 ** 3, t=1024 ** 4, p=1024 ** 5, e=1024 ** 6, z=1024 ** 7, y=1024 ** 8, kb=1024, mb=1024 ** 2, gb=1024 ** 3, tb=1024 ** 4, pb=1024 ** 5, eb=1024 ** 6, zb=1024 ** 7, yb=1024 ** 8, ) ERROR_MSG = dict( no_cserver='This module is expected to run as cluster admin' ) LOG = logging.getLogger(__name__) LOG_FILE = '/tmp/ontap_apis.log' try: from solidfire.factory import ElementFactory HAS_SF_SDK = True except ImportError: HAS_SF_SDK = False def has_netapp_lib(): return HAS_NETAPP_LIB def netapp_lib_is_required(): return "Error: the python NetApp-Lib module is required. Import error: %s" % str(IMPORT_EXCEPTION) def has_sf_sdk(): return HAS_SF_SDK def na_ontap_host_argument_spec(): return dict( hostname=dict(required=True, type='str'), username=dict(required=False, type='str', aliases=['user']), password=dict(required=False, type='str', aliases=['pass'], no_log=True), https=dict(required=False, type='bool', default=False), validate_certs=dict(required=False, type='bool', default=True), http_port=dict(required=False, type='int'), ontapi=dict(required=False, type='int'), use_rest=dict(required=False, type='str', default='auto'), feature_flags=dict(required=False, type='dict', default=dict()), cert_filepath=dict(required=False, type='str'), key_filepath=dict(required=False, type='str', no_log=False), ) def na_ontap_host_argument_spec_peer(): spec = na_ontap_host_argument_spec() spec.pop('feature_flags') # get rid of default values, as we'll use source values for value in spec.values(): if 'default' in value: value.pop('default') return spec def has_feature(module, feature_name): feature = get_feature(module, feature_name) if isinstance(feature, bool): return feature module.fail_json(msg="Error: expected bool type for feature flag: %s" % feature_name) def get_feature(module, feature_name): ''' if the user has configured the feature, use it otherwise, use our default ''' default_flags = dict( strict_json_check=True, # if true, fail if response.content in not empty and is not valid json trace_apis=False, # if true, append ZAPI and REST requests/responses to /tmp/ontap_zapi.txt check_required_params_for_none=True, classic_basic_authorization=False, # use ZAPI wrapper to send Authorization header deprecation_warning=True, sanitize_xml=True, sanitize_code_points=[8], # unicode values, 8 is backspace show_modified=True, always_wrap_zapi=True, # for better error reporting flexcache_delete_return_timeout=5, # ONTAP bug if too big? # for SVM, whch protocols can be allowed svm_allowable_protocols_rest=['cifs', 'fcp', 'iscsi', 'nvme', 'nfs'], svm_allowable_protocols_zapi=['cifs', 'fcp', 'iscsi', 'nvme', 'nfs', 'ndmp', 'http'], max_files_change_threshold=1, # percentage of increase/decrease required to trigger a modify action warn_or_fail_on_fabricpool_backend_change='fail', no_cserver_ems=False # when True, don't attempt to find cserver and don't send cserver EMS ) if module.params['feature_flags'] is not None and feature_name in module.params['feature_flags']: return module.params['feature_flags'][feature_name] if feature_name in default_flags: return default_flags[feature_name] module.fail_json(msg="Internal error: unexpected feature flag: %s" % feature_name) def create_sf_connection(module, port=None): hostname = module.params['hostname'] username = module.params['username'] password = module.params['password'] if HAS_SF_SDK and hostname and username and password: try: return ElementFactory.create(hostname, username, password, port=port) except Exception as exc: raise Exception("Unable to create SF connection: %s" % exc) module.fail_json(msg="the python SolidFire SDK module is required") def set_auth_method(module, username, password, cert_filepath, key_filepath): error = None if password is None and username is None: if cert_filepath is None: error = ('Error: cannot have a key file without a cert file' if key_filepath is not None else 'Error: ONTAP module requires username/password or SSL certificate file(s)') else: auth_method = 'single_cert' if key_filepath is None else 'cert_key' elif password is not None and username is not None: if cert_filepath is not None or key_filepath is not None: error = 'Error: cannot have both basic authentication (username/password) ' +\ 'and certificate authentication (cert/key files)' else: auth_method = 'basic_auth' if has_feature(module, 'classic_basic_authorization') else 'speedy_basic_auth' else: error = 'Error: username and password have to be provided together' if cert_filepath is not None or key_filepath is not None: error += ' and cannot be used with cert or key files' if error: module.fail_json(msg=error) return auth_method def setup_host_options_from_module_params(host_options, module, keys): '''if an option is not set, use primary value. but don't mix up basic and certificate authentication methods host_options is updated in place option values are read from module.params keys is a list of keys that need to be added/updated/left alone in host_options ''' password_keys = ['username', 'password'] certificate_keys = ['cert_filepath', 'key_filepath'] use_password = any(host_options.get(x) is not None for x in password_keys) use_certificate = any(host_options.get(x) is not None for x in certificate_keys) if use_password and use_certificate: module.fail_json( msg='Error: host cannot have both basic authentication (username/password) and certificate authentication (cert/key files).') if use_password: exclude_keys = certificate_keys elif use_certificate: exclude_keys = password_keys else: exclude_keys = [] for key in keys: if host_options.get(key) is None and key not in exclude_keys: # use same value as source if no value is given for dest host_options[key] = module.params[key] def set_zapi_port_and_transport(server, https, port, validate_certs): # default is HTTP if https: if port is None: port = 443 transport_type = 'HTTPS' # HACK to bypass certificate verification if validate_certs is False and not os.environ.get('PYTHONHTTPSVERIFY', '') and getattr(ssl, '_create_unverified_context', None): ssl._create_default_https_context = ssl._create_unverified_context else: if port is None: port = 80 transport_type = 'HTTP' server.set_transport_type(transport_type) server.set_port(port) def setup_na_ontap_zapi(module, vserver=None, wrap_zapi=False, host_options=None): if host_options is None: host_options = module.params hostname = host_options.get('hostname') username = host_options.get('username') password = host_options.get('password') cert_filepath = host_options.get('cert_filepath') key_filepath = host_options.get('key_filepath') https = host_options.get('https') validate_certs = host_options.get('validate_certs') port = host_options.get('http_port') version = host_options.get('ontapi') trace = has_feature(module, 'trace_apis') if trace: logging.basicConfig(filename=LOG_FILE, level=logging.DEBUG, format='%(asctime)s %(levelname)-8s %(message)s') wrap_zapi |= has_feature(module, 'always_wrap_zapi') auth_method = set_auth_method(module, username, password, cert_filepath, key_filepath) if not HAS_NETAPP_LIB: module.fail_json(msg=netapp_lib_is_required()) # set up zapi if auth_method in ('single_cert', 'cert_key'): # override NaServer in netapp-lib to enable certificate authentication server = OntapZAPICx(hostname, module=module, username=username, password=password, validate_certs=validate_certs, cert_filepath=cert_filepath, key_filepath=key_filepath, style=zapi.NaServer.STYLE_CERTIFICATE, auth_method=auth_method, trace=trace) # SSL certificate authentication requires SSL https = True elif auth_method == 'speedy_basic_auth' or wrap_zapi: # override NaServer in netapp-lib to add Authorization header preemptively # use wrapper to handle parse error (mostly for na_ontap_command) server = OntapZAPICx(hostname, module=module, username=username, password=password, validate_certs=validate_certs, auth_method=auth_method, trace=trace) else: # legacy netapp-lib server = zapi.NaServer(hostname, username=username, password=password, trace=trace) if vserver: server.set_vserver(vserver) if host_options.get('use_rest') == 'always': note = '' if https else ' Note: https is set to false.' module.warn("Using ZAPI for %s, ignoring 'use_rest: always'.%s" % (module._name, note)) set_zapi_port_and_transport(server, https, port, validate_certs) server.set_api_version(major=1, minor=(version or 110)) server.set_server_type('FILER') return server def is_zapi_connection_error(message): ''' return True if it is a connection issue ''' # netapp-lib message may contain a tuple or a str! try: if isinstance(message, tuple) and isinstance(message[0], ConnectionError): return True except NameError: # python 2.7 does not know about ConnectionError pass return isinstance(message, str) and message.startswith(('URLError', 'Unauthorized')) def is_zapi_write_access_error(message): ''' return True if it is a write access error ''' # netapp-lib message may contain a tuple or a str! if isinstance(message, str) and message.startswith('Insufficient privileges:'): return 'does not have write access' in message return False def is_zapi_missing_vserver_error(message): ''' return True if it is a missing vserver error ''' # netapp-lib message may contain a tuple or a str! return isinstance(message, str) and message == 'Vserver API missing vserver parameter.' def ems_log_event_cserver(source, server, module): if has_feature(module, 'no_cserver_ems'): return results = get_cserver(server) cserver = setup_na_ontap_zapi(module=module, vserver=results) ems_log_event(source, cserver) def ems_log_event(source, server, name="Ansible", ident="12345", version=COLLECTION_VERSION, category="Information", event="setup", autosupport="false"): ems_log = zapi.NaElement('ems-autosupport-log') # Host name invoking the API. ems_log.add_new_child("computer-name", name) # ID of event. A user defined event-id, range [0..2^32-2]. ems_log.add_new_child("event-id", ident) # Name of the application invoking the API. ems_log.add_new_child("event-source", source) # Version of application invoking the API. ems_log.add_new_child("app-version", version) # Application defined category of the event. ems_log.add_new_child("category", category) # Description of event to log. An application defined message to log. ems_log.add_new_child("event-description", event) ems_log.add_new_child("log-level", "6") ems_log.add_new_child("auto-support", autosupport) try: server.invoke_successfully(ems_log, True) except zapi.NaApiError as exc: # Do not fail if we can't connect to the server. # The module will report a better error when trying to get some data from ONTAP. # Do not fail if we don't have write privileges. # Do not fail if there is no cserver, as on FSx. if not is_zapi_connection_error(exc.message) \ and not is_zapi_write_access_error(exc.message) \ and not is_zapi_missing_vserver_error(exc.message): # raise on other errors, as it may be a bug in calling the ZAPI raise exc def get_cserver_zapi(server): ''' returns None if not run on the management or cluster IP ''' vserver_info = zapi.NaElement('vserver-get-iter') query_details = zapi.NaElement.create_node_with_children('vserver-info', **{'vserver-type': 'admin'}) query = zapi.NaElement('query') query.add_child_elem(query_details) vserver_info.add_child_elem(query) try: result = server.invoke_successfully(vserver_info, enable_tunneling=False) except zapi.NaApiError as exc: # Do not fail if we can't connect to the server. # The module will report a better error when trying to get some data from ONTAP. if is_zapi_connection_error(exc.message): return None # raise on other errors, as it may be a bug in calling the ZAPI raise exc attribute_list = result.get_child_by_name('attributes-list') if attribute_list is not None: vserver_list = attribute_list.get_child_by_name('vserver-info') if vserver_list is not None: return vserver_list.get_child_content('vserver-name') return None def classify_zapi_exception(error): ''' return type of error ''' try: # very unlikely to fail, but don't take any chance err_code = int(error.code) except (AttributeError, ValueError): err_code = 0 try: # very unlikely to fail, but don't take any chance err_msg = error.message except AttributeError: err_msg = "" if err_code == 13005 and err_msg.startswith('Unable to find API:') and 'data vserver' in err_msg: return 'missing_vserver_api_error', 'Most likely running a cluster level API as vserver: %s' % to_native(error) if err_code == 13001 and err_msg.startswith("RPC: Couldn't make connection"): return 'rpc_error', to_native(error) return "other_error", to_native(error) def get_cserver(connection, is_rest=False): if not is_rest: return get_cserver_zapi(connection) params = {'fields': 'type'} api = "private/cli/vserver" json, error = connection.get(api, params) if json is None or error is not None: # exit if there is an error or no data return None vservers = json.get('records') if vservers is not None: for vserver in vservers: if vserver['type'] == 'admin': # cluster admin return vserver['vserver'] if len(vservers) == 1: # assume vserver admin return vservers[0]['vserver'] return None if HAS_NETAPP_LIB: class OntapZAPICx(zapi.NaServer): ''' override zapi NaServer class to: - enable SSL certificate authentication - ignore invalid XML characters in ONTAP output (when using CLI module) - add Authorization header when using basic authentication ''' def __init__(self, hostname=None, server_type=zapi.NaServer.SERVER_TYPE_FILER, transport_type=zapi.NaServer.TRANSPORT_TYPE_HTTP, style=zapi.NaServer.STYLE_LOGIN_PASSWORD, username=None, password=None, port=None, trace=False, module=None, cert_filepath=None, key_filepath=None, validate_certs=None, auth_method=None): # python 2.x syntax, but works for python 3 as well super(OntapZAPICx, self).__init__(hostname, server_type=server_type, transport_type=transport_type, style=style, username=username, password=password, port=port, trace=trace) self.cert_filepath = cert_filepath self.key_filepath = key_filepath self.validate_certs = validate_certs self.module = module self.base64_creds = None if auth_method == 'speedy_basic_auth': auth = '%s:%s' % (username, password) self.base64_creds = base64.b64encode(auth.encode()).decode() def _create_certificate_auth_handler(self): try: context = ssl.create_default_context() except AttributeError as exc: self._fail_with_exc_info('SSL certificate authentication requires python 2.7 or later.', exc) if not self.validate_certs: context.check_hostname = False context.verify_mode = ssl.CERT_NONE try: context.load_cert_chain(self.cert_filepath, keyfile=self.key_filepath) except IOError as exc: self._fail_with_exc_info('Cannot load SSL certificate, check files exist.', exc) return zapi.urllib.request.HTTPSHandler(context=context) def _fail_with_exc_info(self, arg0, exc): msg = arg0 msg += ' More info: %s' % repr(exc) self.module.fail_json(msg=msg) def sanitize_xml(self, response): # some ONTAP CLI commands return BEL on error new_response = response.replace(b'\x07\n', b'') # And 9.1 uses \r\n rather than \n ! new_response = new_response.replace(b'\x07\r\n', b'') # And 9.7 may send backspaces for code_point in get_feature(self.module, 'sanitize_code_points'): if bytes([8]) == b'\x08': # python 3 byte = bytes([code_point]) elif chr(8) == b'\x08': # python 2 byte = chr(code_point) else: # very unlikely, noop byte = b'.' new_response = new_response.replace(byte, b'.') return new_response def _parse_response(self, response): ''' handling XML parsing exception ''' try: return super(OntapZAPICx, self)._parse_response(response) except zapi.etree.XMLSyntaxError as exc: if has_feature(self.module, 'sanitize_xml'): try: return super(OntapZAPICx, self)._parse_response(self.sanitize_xml(response)) except Exception: # ignore a second exception, we'll report the first one pass try: # report first exception, but include full response exc.msg += ". Received: %s" % response except Exception: # in case the response is very badly formatted, ignore it pass raise exc def _create_request(self, na_element, enable_tunneling=False): ''' intercept newly created request to add Authorization header ''' request, netapp_element = super(OntapZAPICx, self)._create_request(na_element, enable_tunneling=enable_tunneling) request.add_header('X-Dot-Client-App', CLIENT_APP_VERSION % self.module._name) if self.base64_creds is not None: request.add_header('Authorization', 'Basic %s' % self.base64_creds) return request, netapp_element # as is from latest version of netapp-lib def invoke_elem(self, na_element, enable_tunneling=False): """Invoke the API on the server.""" if not na_element or not isinstance(na_element, zapi.NaElement): raise ValueError('NaElement must be supplied to invoke API') request, request_element = self._create_request(na_element, enable_tunneling) if self._trace: zapi.LOG.debug("Request: %s", request_element.to_string(pretty=True)) if not hasattr(self, '_opener') or not self._opener \ or self._refresh_conn: self._build_opener() try: if hasattr(self, '_timeout'): response = self._opener.open(request, timeout=self._timeout) else: response = self._opener.open(request) except zapi.urllib.error.HTTPError as exc: raise zapi.NaApiError(exc.code, exc.reason) except zapi.urllib.error.URLError as exc: msg = 'URL error' error = repr(exc) try: # ConnectionRefusedError is not defined in python 2.7 if isinstance(exc.reason, ConnectionRefusedError): msg = 'Unable to connect' error = exc.args except Exception: pass raise zapi.NaApiError(msg, error) except Exception as exc: raise zapi.NaApiError('Unexpected error', repr(exc)) response_xml = response.read() response_element = self._get_result(response_xml) if self._trace: zapi.LOG.debug("Response: %s", response_element.to_string(pretty=True)) return response_element class OntapRestAPI(object): ''' wrapper to send requests to ONTAP REST APIs ''' def __init__(self, module, timeout=60, host_options=None): self.host_options = module.params if host_options is None else host_options self.module = module self.username = self.host_options['username'] self.password = self.host_options['password'] self.hostname = self.host_options['hostname'] self.use_rest = self.host_options['use_rest'].lower() self.cert_filepath = self.host_options['cert_filepath'] self.key_filepath = self.host_options['key_filepath'] self.verify = self.host_options['validate_certs'] self.timeout = timeout port = self.host_options['http_port'] if port is None: self.url = 'https://%s/api/' % self.hostname else: self.url = 'https://%s:%d/api/' % (self.hostname, port) self.is_rest_error = None self.ontap_version = dict( full='unknown', generation=-1, major=-1, minor=-1, valid=False ) self.errors = [] self.debug_logs = [] self.auth_method = set_auth_method(self.module, self.username, self.password, self.cert_filepath, self.key_filepath) self.check_required_library() if has_feature(module, 'trace_apis'): logging.basicConfig(filename=LOG_FILE, level=logging.DEBUG, format='%(asctime)s %(levelname)-8s %(message)s') def requires_ontap_9_6(self, module_name): return self.requires_ontap_version(module_name) def requires_ontap_version(self, module_name, version='9.6'): suffix = " - %s" % self.is_rest_error if self.is_rest_error is not None else "" return "%s only supports REST, and requires ONTAP %s or later.%s" % (module_name, version, suffix) def options_require_ontap_version(self, options, version='9.6', use_rest=None): current_version = self.get_ontap_version() suffix = " - %s" % self.is_rest_error if self.is_rest_error is not None else "" if current_version != (-1, -1, -1): suffix += " - ONTAP version: %s.%s.%s" % current_version if use_rest is not None: suffix += " - using %s" % ('REST' if use_rest else 'ZAPI') if isinstance(options, list) and len(options) > 1: tag = "any of %s" % options elif isinstance(options, list) and len(options) == 1: tag = str(options[0]) else: tag = str(options) return 'using %s requires ONTAP %s or later and REST must be enabled%s.' % (tag, version, suffix) def meets_rest_minimum_version(self, use_rest, minimum_generation, minimum_major, minimum_minor=0): return use_rest and self.get_ontap_version() >= (minimum_generation, minimum_major, minimum_minor) def fail_if_not_rest_minimum_version(self, module_name, minimum_generation, minimum_major, minimum_minor=0): status_code = self.get_ontap_version_using_rest() msgs = [] if self.use_rest == 'never': msgs.append('Error: REST is required for this module, found: "use_rest: %s".' % self.use_rest) if self.is_rest_error: msgs.append('Error using REST for version, error: %s.' % self.is_rest_error) if status_code != 200: msgs.append('Error using REST for version, status_code: %s.' % status_code) if msgs: self.module.fail_json(msg=' '.join(msgs)) version = self.get_ontap_version() if version < (minimum_generation, minimum_major, minimum_minor): msg = 'Error: ' + self.requires_ontap_version(module_name, '%d.%d.%d' % (minimum_generation, minimum_major, minimum_minor)) msg += ' Found: %s.%s.%s.' % version self.module.fail_json(msg=msg) def check_required_library(self): if not HAS_REQUESTS: self.module.fail_json(msg=missing_required_lib('requests')) def build_headers(self, accept=None, vserver_name=None, vserver_uuid=None): headers = {'X-Dot-Client-App': CLIENT_APP_VERSION % self.module._name} # accept is used to turn on/off HAL linking if accept is not None: headers['accept'] = accept # vserver tunneling using vserver name and/or UUID if vserver_name is not None: headers['X-Dot-SVM-Name'] = vserver_name if vserver_uuid is not None: headers['X-Dot-SVM-UUID'] = vserver_uuid return headers def send_request(self, method, api, params, json=None, headers=None): ''' send http request and process reponse, including error conditions ''' url = self.url + api def get_auth_args(): if self.auth_method == 'single_cert': kwargs = dict(cert=self.cert_filepath) elif self.auth_method == 'cert_key': kwargs = dict(cert=(self.cert_filepath, self.key_filepath)) elif self.auth_method in ('basic_auth', 'speedy_basic_auth'): # with requests, there is no challenge, eg no 401. kwargs = dict(auth=(self.username, self.password)) else: raise KeyError(self.auth_method) return kwargs status_code, json_dict, error_details = self._send_request(method, url, params, json, headers, get_auth_args()) return status_code, json_dict, error_details def _send_request(self, method, url, params, json, headers, auth_args): status_code = None json_dict = None json_error = None error_details = None if headers is None: headers = self.build_headers() def fail_on_non_empty_value(response): '''json() may fail on an empty value, but it's OK if no response is expected. To avoid false positives, only report an issue when we expect to read a value. The first get will see it. ''' if method == 'GET' and has_feature(self.module, 'strict_json_check'): contents = response.content if len(contents) > 0: raise ValueError("Expecting json, got: %s" % contents) def get_json(response): ''' extract json, and error message if present ''' try: json = response.json() except ValueError: fail_on_non_empty_value(response) return None, None return json, json.get('error') self.log_debug('sending', repr(dict(method=method, url=url, verify=self.verify, params=params, timeout=self.timeout, json=json, headers=headers, auth_args=auth_args))) try: response = requests.request(method, url, verify=self.verify, params=params, timeout=self.timeout, json=json, headers=headers, **auth_args) self.log_debug(status_code, response.content) status_code = response.status_code # If the response was successful, no Exception will be raised response.raise_for_status() json_dict, json_error = get_json(response) except requests.exceptions.HTTPError as err: try: __, json_error = get_json(response) except (AttributeError, ValueError): json_error = None if json_error is None: self.log_error(status_code, 'HTTP error: %s' % err) error_details = str(err) # If an error was reported in the json payload, it is handled below except requests.exceptions.ConnectionError as err: self.log_error(status_code, 'Connection error: %s' % err) error_details = str(err) except Exception as err: self.log_error(status_code, 'Other error: %s' % err) error_details = str(err) if json_error is not None: self.log_error(status_code, 'Endpoint error: %d: %s' % (status_code, json_error)) error_details = json_error if not error_details and not json_dict and method == 'OPTIONS': # OPTIONS provides the list of supported verbs json_dict['Allow'] = response.headers.get('Allow') return status_code, json_dict, error_details def wait_on_job(self, job, timeout=600, increment=60): try: url = job['_links']['self']['href'].split('api/')[1] except Exception as err: self.log_error(0, 'URL Incorrect format: %s - Job: %s' % (err, job)) return None, 'URL Incorrect format: %s - Job: %s' % (err, job) # Expecting job to be in the following format # {'job': # {'uuid': 'fde79888-692a-11ea-80c2-005056b39fe7', # '_links': # {'self': # {'href': '/api/cluster/jobs/fde79888-692a-11ea-80c2-005056b39fe7'} # } # } # } error = None message = None runtime = 0 retries = 0 max_retries = 3 while True: # Will run every every seconds for seconds job_json, job_error = self.get(url, None) job_state = job_json.get('state', None) if job_json else None # ignore error if status is provided in the job if job_error and job_state is None: error = job_error retries += 1 if retries > max_retries: self.log_error(0, 'Job error: Reach max retries.') break else: retries = 0 # a job looks like this # { # "uuid": "cca3d070-58c6-11ea-8c0c-005056826c14", # "description": "POST /api/cluster/metrocluster", # "state": "failure", # "message": "There are not enough disks in Pool1.", **OPTIONAL** # "code": 2432836, # "start_time": "2020-02-26T10:35:44-08:00", # "end_time": "2020-02-26T10:47:38-08:00", # "_links": { # "self": { # "href": "/api/cluster/jobs/cca3d070-58c6-11ea-8c0c-005056826c14" # } # } # } message = job_json.get('message', '') if job_json else None if job_state == 'failure': # if the job has failed, return message as error return None, message if job_state not in ('queued', 'running', None): if error is None: error = job_error break elif runtime >= timeout: # Would like to post a message to user (not sure how) if job_state != 'success': self.log_error(0, 'Timeout error: Process still running') if error is None: error = 'Timeout error: Process still running' if job_error is not None: error += ' - %s' % job_error break time.sleep(increment) runtime += increment return message, error def get(self, api, params=None, headers=None): method = 'GET' dummy, message, error = self.send_request(method, api, params, headers=headers) return message, error def post(self, api, body, params=None, headers=None): method = 'POST' dummy, message, error = self.send_request(method, api, params, json=body, headers=headers) return message, error def patch(self, api, body, params=None, headers=None): method = 'PATCH' dummy, message, error = self.send_request(method, api, params, json=body, headers=headers) return message, error def delete(self, api, body=None, params=None, headers=None): method = 'DELETE' dummy, message, error = self.send_request(method, api, params, json=body, headers=headers) return message, error def options(self, api, params=None, headers=None): method = 'OPTIONS' dummy, message, error = self.send_request(method, api, params, headers=headers) return message, error def set_version(self, message): try: version = message.get('version', 'not found') except AttributeError: self.ontap_version['valid'] = False self.ontap_version['full'] = 'unreadable message' return for key in self.ontap_version: try: self.ontap_version[key] = version.get(key, -1) except AttributeError: self.ontap_version[key] = -1 self.ontap_version['valid'] = all( self.ontap_version[key] != -1 for key in self.ontap_version if key != 'valid' ) def get_ontap_version(self): if self.ontap_version['valid']: return self.ontap_version['generation'], self.ontap_version['major'], self.ontap_version['minor'] return -1, -1, -1 def get_node_version_using_rest(self): # using GET rather than HEAD because the error messages are different, # and we need the version as some REST options are not available in earlier versions method = 'GET' api = 'cluster/nodes' params = {'fields': ['version']} status_code, message, error = self.send_request(method, api, params=params) if message and 'records' in message and len(message['records']) > 0: message = message['records'][0] return status_code, message, error def get_ontap_version_using_rest(self): # using GET rather than HEAD because the error messages are different, # and we need the version as some REST options are not available in earlier versions method = 'GET' api = 'cluster' params = {'fields': ['version']} status_code, message, error = self.send_request(method, api, params=params) try: if error and 'are available in precluster.' in error.get('message', ''): # in precluster mode, version is not available :( status_code, message, error = self.get_node_version_using_rest() except AttributeError: pass self.set_version(message) self.is_rest_error = str(error) if error else None if error: self.log_error(status_code, str(error)) return status_code def _is_rest(self, used_unsupported_rest_properties=None, partially_supported_rest_properties=None, parameters=None): if self.use_rest not in ['always', 'auto', 'never']: error = "use_rest must be one of: never, always, auto. Got: '%s'" % self.use_rest return False, error if self.use_rest == "always" and used_unsupported_rest_properties: error = "REST API currently does not support '%s'" % \ ', '.join(used_unsupported_rest_properties) return True, error if self.use_rest == 'never': # force ZAPI if requested return False, None status_code = self.get_ontap_version_using_rest() if self.use_rest == "always" and partially_supported_rest_properties: error = ''.join( "Minimum version of ONTAP for %s is %s\n" % (property[0], str(property[1])) for property in partially_supported_rest_properties if self.get_ontap_version()[0:3] < property[1] and property[0] in parameters ) if error != '': return True, error if self.use_rest == 'always': # ignore error, it will show up later when calling another REST API return True, None # we're now using 'auto' if used_unsupported_rest_properties: # force ZAPI if some parameter requires it if self.get_ontap_version()[:2] > (9, 5): self.module.warn('Falling back to ZAPI because of unsupported option(s) or option value(s) in REST: %s' % used_unsupported_rest_properties) return False, None if partially_supported_rest_properties: # if ontap version is lower than partially_supported_rest_properties version, force ZAPI, only if the paramater is used for property in partially_supported_rest_properties: if self.get_ontap_version()[0:2] < property[1] and property[0] in parameters: self.module.warn( 'Falling back to ZAPI because of unsupported option(s) or option value(s) "%s" in REST require %s' % (property[0], str(property[1]))) return False, None if self.get_ontap_version()[:2] in ((9, 4), (9, 5)): # we can't trust REST support on 9.5, and not at all on 9.4 return False, None if status_code == 200: return True, None return False, None def is_rest_supported_properties(self, parameters, unsupported_rest_properties=None, partially_supported_rest_properties=None): used_unsupported_rest_properties = None if unsupported_rest_properties: used_unsupported_rest_properties = [x for x in unsupported_rest_properties if x in parameters] use_rest, error = self.is_rest(used_unsupported_rest_properties, partially_supported_rest_properties, parameters) if error: self.module.fail_json(msg=error) return use_rest def is_rest(self, used_unsupported_rest_properties=None, partially_supported_rest_properties=None, parameters=None): ''' only return error if there is a reason to ''' use_rest, error = self._is_rest(used_unsupported_rest_properties, partially_supported_rest_properties, parameters) if used_unsupported_rest_properties is None: return use_rest return use_rest, error def log_error(self, status_code, message): LOG.error("%s: %s", status_code, message) self.errors.append(message) self.debug_logs.append((status_code, message)) def log_debug(self, status_code, content): LOG.debug("%s: %s", status_code, content) self.debug_logs.append((status_code, content)) def write_to_file(self, tag, data=None, filepath=None, append=True): ''' This function is only for debug purposes, all calls to write_to_file should be removed before submitting. If data is None, tag is considered as data else tag is a label, and data is data. ''' if filepath is None: filepath = '/tmp/ontap_log' mode = 'a' if append else 'w' with open(filepath, mode) as afile: if data is not None: afile.write("%s: %s\n" % (str(tag), str(data))) else: afile.write(str(tag)) afile.write('\n') def write_errors_to_file(self, tag=None, filepath=None, append=True): if tag is None: tag = 'Error' for error in self.errors: self.write_to_file(tag, error, filepath, append) if not append: append = True def write_debug_log_to_file(self, tag=None, filepath=None, append=True): if tag is None: tag = 'Debug' for status_code, message in self.debug_logs: self.write_to_file(tag, status_code, filepath, append) if not append: append = True self.write_to_file(tag, message, filepath, append)