diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 12:04:41 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 12:04:41 +0000 |
commit | 975f66f2eebe9dadba04f275774d4ab83f74cf25 (patch) | |
tree | 89bd26a93aaae6a25749145b7e4bca4a1e75b2be /ansible_collections/netapp/ontap/plugins/module_utils | |
parent | Initial commit. (diff) | |
download | ansible-975f66f2eebe9dadba04f275774d4ab83f74cf25.tar.xz ansible-975f66f2eebe9dadba04f275774d4ab83f74cf25.zip |
Adding upstream version 7.7.0+dfsg.upstream/7.7.0+dfsg
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'ansible_collections/netapp/ontap/plugins/module_utils')
12 files changed, 2676 insertions, 0 deletions
diff --git a/ansible_collections/netapp/ontap/plugins/module_utils/netapp.py b/ansible_collections/netapp/ontap/plugins/module_utils/netapp.py new file mode 100644 index 000000000..28d9428a2 --- /dev/null +++ b/ansible_collections/netapp/ontap/plugins/module_utils/netapp.py @@ -0,0 +1,1134 @@ +# 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 <sumit4@netapp.com> +# Copyright (c) 2017, Michael Price <michael.price@netapp.com> +# Copyright (c) 2017-2023, NetApp, Inc +# 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 = "22.7.0" +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' +ZAPI_DEPRECATION_MESSAGE = "With version 22.0.0 ONTAPI (ZAPI) has been deprecated. The final ONTAP version to support ZAPI is ONTAP 9.13.1. "\ + "ZAPI calls in these modules will continue to work for ONTAP versions that supports ZAPI. "\ + "You can update your playbook to use REST by adding use_rest: always to your playbook. "\ + "More information can be found at: https://github.com/ansible-collections/netapp.ontap" + +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_zapi_only_spec(): + # This is used for Zapi only Modules. + + 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='never'), + feature_flags=dict(required=False, type='dict'), + cert_filepath=dict(required=False, type='str'), + key_filepath=dict(required=False, type='str', no_log=False), + ) + + +def na_ontap_host_argument_spec(): + # This is used for Zapi + REST, and REST only Modules. + + 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'), + cert_filepath=dict(required=False, type='str'), + key_filepath=dict(required=False, type='str', no_log=False), + force_ontap_version=dict(required=False, type='str') + ) + + +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, # when true, fail if response.content in not empty and is not valid json + trace_apis=False, # when true, append ZAPI and REST requests/responses to /tmp/ontap_zapi.txt + trace_headers=False, # when true, headers are not redacted in send requests + trace_auth_args=False, # when true, auth_args are not redacted in send requests + 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', 'ndmp'], + 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, host_options=None): + if not HAS_SF_SDK: + module.fail_json(msg="the python SolidFire SDK module is required") + + if host_options is None: + host_options = module.params + msg, msg2 = None, None + missing_options = [option for option in ('hostname', 'username', 'password') if not host_options.get(option)] + if missing_options: + verb = 'are' if len(missing_options) > 1 else 'is' + msg = "%s %s required for ElementSW connection." % (', '.join(missing_options), verb) + extra_options = [option for option in ('cert_filepath', 'key_filepath') if host_options.get(option)] + if extra_options: + verb = 'are' if len(extra_options) > 1 else 'is' + msg2 = "%s %s not supported for ElementSW connection." % (', '.join(extra_options), verb) + msg = "%s %s" % (msg, msg2) if msg and msg2 else msg or msg2 + if msg: + module.fail_json(msg=msg) + hostname = host_options.get('hostname') + username = host_options.get('username') + password = host_options.get('password') + + try: + return ElementFactory.create(hostname, username, password, port=port) + except Exception as exc: + raise Exception("Unable to create SF connection: %s" % exc) + + +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): + module.warn(ZAPI_DEPRECATION_MESSAGE) + 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 in ('Vserver API missing vserver parameter.', 'Specified vserver not found') + + +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 + + +def generate_result(changed, actions=None, modify=None, response=None, extra_responses=None): + result = dict(changed=changed) + if response is not None: + result['response'] = response + if modify: + result['modify'] = modify + if actions: + result['actions'] = actions + if extra_responses: + result.update(extra_responses) + return result + + +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 + # either username/password or a certifcate with/without a key are used for authentication + self.username = self.host_options.get('username') + self.password = self.host_options.get('password') + self.hostname = self.host_options['hostname'] + self.use_rest = self.host_options['use_rest'].lower() + self.cert_filepath = self.host_options.get('cert_filepath') + self.key_filepath = self.host_options.get('key_filepath') + self.verify = self.host_options['validate_certs'] + self.timeout = timeout + port = self.host_options['http_port'] + self.force_ontap_version = self.host_options.get('force_ontap_version') + 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.fallback_to_zapi_reason = 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') + self.log_headers = has_feature(module, 'trace_headers') + self.log_auth_args = has_feature(module, 'trace_auth_args') + + 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) + # The module only supports REST, so make it required + self.use_rest = 'always' + 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, files=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, files, get_auth_args()) + + return status_code, json_dict, error_details + + def _send_request(self, method, url, params, json, headers, files, 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 if self.log_headers else 'redacted', + auth_args=auth_args if self.log_auth_args else 'redacted'))) + try: + response = requests.request(method, url, verify=self.verify, params=params, + timeout=self.timeout, json=json, headers=headers, files=files, **auth_args) + status_code = response.status_code + self.log_debug(status_code, response.content) + # 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: + if json_dict is None: + json_dict = {} + if method == 'OPTIONS': + # OPTIONS provides the list of supported verbs + json_dict['Allow'] = response.headers.get('Allow') + if response.headers.get('Content-Type', '').startswith("multipart/form-data"): + json_dict['text'] = response.text + return status_code, json_dict, error_details + + def _is_job_done(self, job_json, job_state, job_error, timed_out): + """ return (done, message, error) + done is True to indicate that the job is complete, or failed, or timed out + done is False when the job is still running + """ + # 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" + # } + # } + # } + done, error = False, None + message = job_json.get('message', '') if job_json else None + if job_state == 'failure': + # if the job has failed, return message as error + error = message + message = None + done = True + elif job_state not in ('queued', 'running', None): + error = job_error + done = True + elif timed_out: + # Would like to post a message to user (not sure how) + self.log_error(0, 'Timeout error: Process still running') + error = 'Timeout error: Process still running' + if job_error is not None: + error += ' - %s' % job_error + done = True + return done, message, error + + 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 + errors = [] + message = None + runtime = 0 + retries = 0 + max_retries = 3 + done = False + while not done: + # Will run every <increment> seconds for <timeout> 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: + errors.append(str(job_error)) + retries += 1 + if retries > max_retries: + error = " - ".join(errors) + self.log_error(0, 'Job error: Reached max retries.') + done = True + else: + retries = 0 + done, message, error = self._is_job_done(job_json, job_state, job_error, runtime >= timeout) + if not done: + 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, json=None, headers=headers) + return message, error + + def post(self, api, body, params=None, headers=None, files=None): + method = 'POST' + retry = 3 + while retry > 0: + dummy, message, error = self.send_request(method, api, params, json=body, headers=headers, files=files) + if error and type(error) is dict and 'temporarily locked' in error.get('message', ''): + time.sleep(30) + retry = retry - 1 + continue + break + return message, error + + def patch(self, api, body, params=None, headers=None, files=None): + method = 'PATCH' + retry = 3 + while retry > 0: + dummy, message, error = self.send_request(method, api, params, json=body, headers=headers, files=files) + if error and type(error) is dict and 'temporarily locked' in error.get('message', ''): + time.sleep(30) + retry = retry - 1 + continue + break + 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, json=None, 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_from_params(self): + """ Provide a way to override the current version + This is required when running a custom vsadmin role as ONTAP does not currently allow access to /api/cluster. + This may also be interesting for testing :) + Report a warning if API call failed to report version. + Report a warning if current version could be fetched and is different. + """ + try: + version = [int(x) for x in self.force_ontap_version.split('.')] + if len(version) == 2: + version.append(0) + gen, major, minor = version + except (TypeError, ValueError) as exc: + self.module.fail_json( + msg='Error: unexpected format in force_ontap_version, expecting G.M.m or G.M, as in 9.10.1, got: %s, error: %s' + % (self.force_ontap_version, exc)) + + warning = '' + read_version = self.get_ontap_version() + if read_version == (-1, -1, -1): + warning = ', unable to read current version:' + elif read_version != (gen, major, minor): + warning = ' but current version is %s' % self.ontap_version['full'] + if warning: + warning = 'Forcing ONTAP version to %s%s' % (self.force_ontap_version, warning) + self.set_version({'version': { + 'generation': gen, + 'major': major, + 'minor': minor, + 'full': 'set by user to %s' % self.force_ontap_version, + }}) + return warning + + 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) + if error: + self.log_error(status_code, str(error)) + if self.force_ontap_version: + warning = self.get_ontap_version_from_params() + if error: + warning += ' error: %s, status_code: %s' % (error, status_code) + if warning: + self.module.warn(warning) + msg = 'Forcing ONTAP version to %s' % self.force_ontap_version + if error: + self.log_error('INFO', msg) + else: + self.log_debug('INFO', msg) + error = None + status_code = 200 + self.is_rest_error = str(error) if error else None + return status_code + + def convert_parameter_keys_to_dot_notation(self, parameters): + """ Get all variable set in a list and add them to a dict so that partially_supported_rest_properties works correctly """ + if isinstance(parameters, dict): + temp = {} + for parameter in parameters: + if isinstance(parameters[parameter], list): + if parameter not in temp: + temp[parameter] = {} + for adict in parameters[parameter]: + if isinstance(adict, dict): + for key in adict: + temp[parameter + '.' + key] = 0 + parameters.update(temp) + return parameters + + 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 + # don't send a new request if we already know the version + status_code = self.get_ontap_version_using_rest() if self.get_ontap_version() == (-1, -1, -1) else 200 + if self.use_rest == "always" and partially_supported_rest_properties: + # If a variable is on a list we need to move it to a dict for this check to work correctly. + temp_parameters = parameters.copy() + temp_parameters = self.convert_parameter_keys_to_dot_notation(temp_parameters) + error = '\n'.join( + "Minimum version of ONTAP for %s is %s." % (property[0], str(property[1])) + for property in partially_supported_rest_properties + if self.get_ontap_version()[:3] < property[1] and property[0] in temp_parameters + ) + if error != '': + return True, 'Error: %s Current version: %s.' % (error, self.get_ontap_version()) + 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.fallback_to_zapi_reason =\ + 'because of unsupported option(s) or option value(s) in REST: %s' % used_unsupported_rest_properties + self.module.warn('Falling back to ZAPI %s' % self.fallback_to_zapi_reason) + 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 + # If a variable is on a list we need to move it to a dict for this check to work correctly. + temp_parameters = parameters.copy() + temp_parameters = self.convert_parameter_keys_to_dot_notation(temp_parameters) + for property in partially_supported_rest_properties: + if self.get_ontap_version()[:3] < property[1] and property[0] in temp_parameters: + self.fallback_to_zapi_reason =\ + 'because of unsupported option(s) or option value(s) "%s" in REST require %s' % (property[0], str(property[1])) + self.module.warn('Falling back to ZAPI %s' % self.fallback_to_zapi_reason) + 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 + return (True, None) if status_code == 200 else (False, None) + + def is_rest_supported_properties(self, parameters, unsupported_rest_properties=None, partially_supported_rest_properties=None, report_error=False): + 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 report_error: + return use_rest, error + 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 and partially_supported_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) diff --git a/ansible_collections/netapp/ontap/plugins/module_utils/netapp_elementsw_module.py b/ansible_collections/netapp/ontap/plugins/module_utils/netapp_elementsw_module.py new file mode 100644 index 000000000..d16c992ec --- /dev/null +++ b/ansible_collections/netapp/ontap/plugins/module_utils/netapp_elementsw_module.py @@ -0,0 +1,41 @@ +# This code is part of Ansible, but is an independent component. +# This particular file snippet, and this file snippet only, is BSD licensed. + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +HAS_SF_SDK = False +try: + # pylint: disable=unused-import + import solidfire.common + + HAS_SF_SDK = True +except Exception: + HAS_SF_SDK = False + + +def has_sf_sdk(): + return HAS_SF_SDK + + +class NaElementSWModule(object): + + def __init__(self, elem): + self.elem_connect = elem + self.parameters = dict() + + def volume_id_exists(self, volume_id): + """ + Return volume_id if volume exists for given volume_id + + :param volume_id: volume ID + :type volume_id: int + :return: Volume ID if found, None if not found + :rtype: int + """ + volume_list = self.elem_connect.list_volumes(volume_ids=[volume_id]) + for volume in volume_list.volumes: + if volume.volume_id == volume_id: + if str(volume.delete_time) == "": + return volume.volume_id + return None diff --git a/ansible_collections/netapp/ontap/plugins/module_utils/netapp_ipaddress.py b/ansible_collections/netapp/ontap/plugins/module_utils/netapp_ipaddress.py new file mode 100644 index 000000000..a936071ca --- /dev/null +++ b/ansible_collections/netapp/ontap/plugins/module_utils/netapp_ipaddress.py @@ -0,0 +1,134 @@ +# 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) 2020-2022, Laurent Nicolas <laurentn@netapp.com> +# 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. + +""" Support class for NetApp ansible modules + + Provides accesss to ipaddress - mediating unicode issues with python2.7 +""" + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible.module_utils._text import to_native + +try: + import ipaddress + HAS_IPADDRESS_LIB = True + IMPORT_ERROR = None +except ImportError as exc: + HAS_IPADDRESS_LIB = False + IMPORT_ERROR = to_native(exc) + + +def _check_ipaddress_is_present(module): + ''' + report error at runtime rather than when attempting to load the module + ''' + if HAS_IPADDRESS_LIB: + return None + module.fail_json(msg="Error: the python ipaddress package is required for this module. Import error: %s" % IMPORT_ERROR) + + +def _get_ipv4orv6_address(ip_address, module): + ''' + return IPV4Adress or IPV6Address object + ''' + _check_ipaddress_is_present(module) + # python 2.7 requires unicode format + ip_addr = u'%s' % ip_address + try: + return ipaddress.ip_address(ip_addr) + except ValueError as exc: + error = 'Error: Invalid IP address value %s - %s' % (ip_address, to_native(exc)) + module.fail_json(msg=error) + + +def _get_ipv4orv6_network(ip_address, netmask, strict, module): + ''' + return IPV4Network or IPV6Network object + ''' + _check_ipaddress_is_present(module) + # python 2.7 requires unicode format + ip_addr = u'%s/%s' % (ip_address, netmask) if netmask is not None else u'%s' % ip_address + try: + return ipaddress.ip_network(ip_addr, strict) + except ValueError as exc: + error = 'Error: Invalid IP network value %s' % ip_addr + if 'has host bits set' in to_native(exc): + error += '. Please specify a network address without host bits set' + elif netmask is not None: + error += '. Check address and netmask values' + error += ': %s.' % to_native(exc) + module.fail_json(msg=error) + + +def _check_ipv6_has_prefix_length(ip_address, netmask, module): + ip_address = _get_ipv4orv6_address(ip_address, module) + if not isinstance(ip_address, ipaddress.IPv6Address) or isinstance(netmask, int): + return + if ':' in netmask: + module.fail_json(msg='Error: only prefix_len is supported for IPv6 addresses, got %s' % netmask) + + +def validate_ip_address_is_network_address(ip_address, module): + ''' + Validate if the given IP address is a network address (i.e. it's host bits are set to 0) + ONTAP doesn't validate if the host bits are set, + and hence doesn't add a new address unless the IP is from a different network. + So this validation allows the module to be idempotent. + :return: None + ''' + dummy = _get_ipv4orv6_network(ip_address, None, True, module) + + +def validate_and_compress_ip_address(ip_address, module): + ''' + 0's in IPv6 addresses can be compressed to save space + This will be a noop for IPv4 address + In addition, it makes sure the address is in a valid format + ''' + # return compressed value for IPv6 and value in . notation for IPv4 + return str(_get_ipv4orv6_address(ip_address, module)) + + +def netmask_length_to_netmask(ip_address, length, module): + ''' + input: ip_address and netmask length + output: netmask in dot notation + ''' + return str(_get_ipv4orv6_network(ip_address, length, False, module).netmask) + + +def netmask_to_netmask_length(ip_address, netmask, module): + ''' + input: ip_address and netmask in dot notation for IPv4, expanded netmask is not supported for IPv6 + netmask as int or a str representaiton of int is also accepted + output: netmask length as int + ''' + _check_ipv6_has_prefix_length(ip_address, netmask, module) + return _get_ipv4orv6_network(ip_address, netmask, False, module).prefixlen diff --git a/ansible_collections/netapp/ontap/plugins/module_utils/netapp_module.py b/ansible_collections/netapp/ontap/plugins/module_utils/netapp_module.py new file mode 100644 index 000000000..91acd3933 --- /dev/null +++ b/ansible_collections/netapp/ontap/plugins/module_utils/netapp_module.py @@ -0,0 +1,619 @@ +# 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) 2018, Laurent Nicolas <laurentn@netapp.com> +# 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. + +''' Support class for NetApp ansible modules ''' + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from copy import deepcopy +import re +import traceback +import ansible_collections.netapp.ontap.plugins.module_utils.netapp as netapp_utils + +ZAPI_ONLY_DEPRECATION_MESSAGE = "This module only supports ZAPI and is deprecated. "\ + "It will no longer work with newer versions of ONTAP. "\ + "The final ONTAP version to support ZAPI is ONTAP 9.12.1." + + +def cmp(obj1, obj2): + """ + Python 3 does not have a cmp function, this will do the cmp. + :param obj1: first object to check + :param obj2: second object to check + :return: + """ + # convert to lower case for string comparison. + if obj1 is None: + return -1 + if isinstance(obj1, str) and isinstance(obj2, str): + obj1 = obj1.lower() + obj2 = obj2.lower() + # if list has string element, convert string to lower case. + if isinstance(obj1, list) and isinstance(obj2, list): + obj1 = [x.lower() if isinstance(x, str) else x for x in obj1] + obj2 = [x.lower() if isinstance(x, str) else x for x in obj2] + obj1.sort() + obj2.sort() + return (obj1 > obj2) - (obj1 < obj2) + + +class NetAppModule(object): + ''' + Common class for NetApp modules + set of support functions to derive actions based + on the current state of the system, and a desired state + ''' + + def __init__(self, module=None): + # we can call this with module set to self or self.module + # self is a NetApp module, while self.module is the AnsibleModule object + self.netapp_module = None + self.ansible_module = module + if module and getattr(module, 'module', None) is not None: + self.netapp_module = module + self.ansible_module = module.module + # When using self or self.module, this gives access to: + # self.ansible_module.fail_json + # When using self, this gives access to: + # self.netapp_module.rest_api.log_debug + self.log = [] + self.changed = False + self.parameters = {'name': 'not initialized'} + self.zapi_string_keys = {} + self.zapi_bool_keys = {} + self.zapi_list_keys = {} + self.zapi_int_keys = {} + self.zapi_required = {} + self.params_to_rest_api_keys = {} + + def module_deprecated(self, module): + module.warn(ZAPI_ONLY_DEPRECATION_MESSAGE) + + def module_replaces(self, new_module, module): + self.module_deprecated(module) + module.warn('netapp.ontap.%s should be used instead.' % new_module) + + def set_parameters(self, ansible_params): + self.parameters = {} + for param in ansible_params: + if ansible_params[param] is not None: + self.parameters[param] = ansible_params[param] + return self.parameters + + def fall_back_to_zapi(self, module, msg, parameters): + if parameters['use_rest'].lower() == 'always': + module.fail_json(msg='Error: %s' % msg) + if parameters['use_rest'].lower() == 'auto': + module.warn('Falling back to ZAPI: %s' % msg) + return False + + def check_and_set_parameters(self, module): + self.parameters = {} + check_for_none = netapp_utils.has_feature(module, 'check_required_params_for_none') + if check_for_none: + required_keys = [key for key, value in module.argument_spec.items() if value.get('required')] + for param in module.params: + if module.params[param] is not None: + self.parameters[param] = module.params[param] + elif check_for_none and param in required_keys: + module.fail_json(msg="%s requires a value, got: None" % param) + return self.parameters + + @staticmethod + def type_error_message(type_str, key, value): + return "expecting '%s' type for %s: %s, got: %s" % (type_str, repr(key), repr(value), type(value)) + + def get_value_for_bool(self, from_zapi, value, key=None): + """ + Convert boolean values to string or vice-versa + If from_zapi = True, value is converted from string (as it appears in ZAPI) to boolean + If from_zapi = False, value is converted from boolean to string + For get() method, from_zapi = True + For modify(), create(), from_zapi = False + :param from_zapi: convert the value from ZAPI or to ZAPI acceptable type + :param value: value of the boolean attribute + :param key: if present, force error checking to validate type, and accepted values + :return: string or boolean + """ + if value is None: + return None + if from_zapi: + if key is not None and not isinstance(value, str): + raise TypeError(self.type_error_message('str', key, value)) + if key is not None and value not in ('true', 'false'): + raise ValueError('Unexpected value: %s received from ZAPI for boolean attribute: %s' % (repr(value), repr(key))) + return value == 'true' + if key is not None and not isinstance(value, bool): + raise TypeError(self.type_error_message('bool', key, value)) + return 'true' if value else 'false' + + def get_value_for_int(self, from_zapi, value, key=None): + """ + Convert integer values to string or vice-versa + If from_zapi = True, value is converted from string (as it appears in ZAPI) to integer + If from_zapi = False, value is converted from integer to string + For get() method, from_zapi = True + For modify(), create(), from_zapi = False + :param from_zapi: convert the value from ZAPI or to ZAPI acceptable type + :param value: value of the integer attribute + :param key: if present, force error checking to validate type + :return: string or integer + """ + if value is None: + return None + if from_zapi: + if key is not None and not isinstance(value, str): + raise TypeError(self.type_error_message('str', key, value)) + return int(value) + if key is not None and not isinstance(value, int): + raise TypeError(self.type_error_message('int', key, value)) + return str(value) + + def get_value_for_list(self, from_zapi, zapi_parent, zapi_child=None, data=None): + """ + Convert a python list() to NaElement or vice-versa + If from_zapi = True, value is converted from NaElement (parent-children structure) to list() + If from_zapi = False, value is converted from list() to NaElement + :param zapi_parent: ZAPI parent key or the ZAPI parent NaElement + :param zapi_child: ZAPI child key + :param data: list() to be converted to NaElement parent-children object + :param from_zapi: convert the value from ZAPI or to ZAPI acceptable type + :return: list() or NaElement + """ + if from_zapi: + if zapi_parent is None: + return [] + return [zapi_child.get_content() for zapi_child in zapi_parent.get_children()] + + zapi_parent = netapp_utils.zapi.NaElement(zapi_parent) + for item in data: + zapi_parent.add_new_child(zapi_child, item) + return zapi_parent + + def get_cd_action(self, current, desired): + ''' takes a desired state and a current state, and return an action: + create, delete, None + eg: + is_present = 'absent' + some_object = self.get_object(source) + if some_object is not None: + is_present = 'present' + action = cd_action(current=is_present, desired = self.desired.state()) + ''' + desired_state = desired['state'] if 'state' in desired else 'present' + if current is None and desired_state == 'absent': + return None + if current is not None and desired_state == 'present': + return None + # change in state + self.changed = True + return 'create' if current is None else 'delete' + + @staticmethod + def check_keys(current, desired): + ''' TODO: raise an error if keys do not match + with the exception of: + new_name, state in desired + ''' + + @staticmethod + def compare_lists(current, desired, get_list_diff): + ''' compares two lists and return a list of elements that are either the desired elements or elements that are + modified from the current state depending on the get_list_diff flag + :param: current: current item attribute in ONTAP + :param: desired: attributes from playbook + :param: get_list_diff: specifies whether to have a diff of desired list w.r.t current list for an attribute + :return: list of attributes to be modified + :rtype: list + ''' + current_copy = deepcopy(current) + desired_copy = deepcopy(desired) + + # get what in desired and not in current + desired_diff_list = [] + for item in desired: + if item in current_copy: + current_copy.remove(item) + else: + desired_diff_list.append(item) + + # get what in current but not in desired + current_diff_list = [] + for item in current: + if item in desired_copy: + desired_copy.remove(item) + else: + current_diff_list.append(item) + + if desired_diff_list or current_diff_list: + # there are changes + return desired_diff_list if get_list_diff else desired + else: + return None + + def get_modified_attributes(self, current, desired, get_list_diff=False): + ''' takes two dicts of attributes and return a dict of attributes that are + not in the current state + It is expected that all attributes of interest are listed in current and + desired. + :param: current: current attributes in ONTAP + :param: desired: attributes from playbook + :param: get_list_diff: specifies whether to have a diff of desired list w.r.t current list for an attribute + :return: dict of attributes to be modified + :rtype: dict + + NOTE: depending on the attribute, the caller may need to do a modify or a + different operation (eg move volume if the modified attribute is an + aggregate name) + ''' + # if the object does not exist, we can't modify it + modified = {} + if current is None: + return modified + + if not isinstance(desired, dict): + raise TypeError("Expecting dict, got: %s with current: %s" % (desired, current)) + # error out if keys do not match + self.check_keys(current, desired) + + # collect changed attributes + for key, value in current.items(): + # if self.netapp_module: + # self.netapp_module.rest_api.log_debug('KDV', "%s:%s:%s" % (key, desired.get(key), value)) + if desired.get(key) is not None: + modified_value = None + if isinstance(value, list): + modified_value = self.compare_lists(value, desired[key], get_list_diff) # get modified list from current and desired + elif isinstance(value, dict): + modified_value = self.get_modified_attributes(value, desired[key]) or None + else: + try: + result = cmp(value, desired[key]) + except TypeError as exc: + raise TypeError("%s, key: %s, value: %s, desired: %s" % (repr(exc), key, repr(value), repr(desired[key]))) + # if self.netapp_module: + # self.netapp_module.rest_api.log_debug('RESULT', result) + if result != 0: + modified_value = desired[key] + if modified_value is not None: + modified[key] = modified_value + + if modified: + self.changed = True + return modified + + def is_rename_action(self, source, target): + ''' takes a source and target object, and returns True + if a rename is required + eg: + source = self.get_object(source_name) + target = self.get_object(target_name) + action = is_rename_action(source, target) + :return: None for error, True for rename action, False otherwise + + I'm not sure we need this function any more. + I think a better way to do it is to: + 1. look if a create is required (eg the target resource does not exist and state==present) + 2. consider that a create can be fullfilled by different actions: rename, create from scratch, move, ... + So for rename: + cd_action = self.na_helper.get_cd_action(current, self.parameters) + if cd_action == 'create' and self.parameters.get('from_name'): + # creating new subnet by renaming + current = self.get_subnet(self.parameters['from_name']) + if current is None: + self.module.fail_json(msg="Error renaming: subnet %s does not exist" % + self.parameters['from_name']) + rename = True + cd_action = None + ''' + if source is None and target is None: + # error, do nothing + # cannot rename a non existent resource + return None + if target is None: + # source is not None and target is None: + # rename is in order + self.changed = True + return True + # target is not None, so do nothing as the destination exists + # if source is None, maybe we already renamed + # if source is not None, maybe a new resource was created after being renamed + return False + + @staticmethod + def sanitize_wwn(initiator): + ''' igroup initiator may or may not be using WWN format: eg 20:00:00:25:B5:00:20:01 + if format is matched, convert initiator to lowercase, as this is what ONTAP is using ''' + wwn_format = r'[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){7}' + initiator = initiator.strip() + if re.match(wwn_format, initiator): + initiator = initiator.lower() + return initiator + + def safe_get(self, an_object, key_list, allow_sparse_dict=True): + ''' recursively traverse a dictionary or a any object supporting get_item or indexing + (in our case, python dicts and NAElement responses, and lists) + It is expected that some keys can be missing, this is controlled with allow_sparse_dict + + return value if the key chain is exhausted + return None if a key is not found and allow_sparse_dict is True + raise KeyError is a key is not found and allow_sparse_dict is False (looking for exact match) + raise TypeError if an intermediate element cannot be indexed, + unless the element is None and allow_sparse_dict is True + ''' + if not key_list: + # we've exhausted the keys, good! + return an_object + key_list = list(key_list) # preserve original values + key = key_list.pop(0) + try: + return self.safe_get(an_object[key], key_list, allow_sparse_dict=allow_sparse_dict) + except (KeyError, IndexError) as exc: + # error, key or index not found + if allow_sparse_dict: + return None + raise exc + except TypeError as exc: + # error, we were expecting a dict or NAElement + if allow_sparse_dict and an_object is None: + return None + raise exc + + def convert_value(self, value, convert_to): + if convert_to is None: + return value, None + if not isinstance(value, str): + return None, ('Unexpected type: %s for %s' % (type(value), str(value))) + if convert_to == str: + return value, None + if convert_to == int: + try: + return int(value), None + except ValueError as exc: + return None, ('Unexpected value for int: %s, %s' % (str(value), str(exc))) + if convert_to == bool: + if value not in ('true', 'false'): + return None, 'Unexpected value: %s received from ZAPI for boolean attribute' % value + return value == 'true', None + if convert_to == 'bool_online': + return value == 'online', None + self.ansible_module.fail_json(msg='Error: Unexpected value for convert_to: %s' % convert_to) + + def zapi_get_value(self, na_element, key_list, required=False, default=None, convert_to=None): + """ read a value from na_element using key_list + + If required is True, an error is reported if a key in key_list is not found. + If required is False and the value is not found, uses default as the value. + If convert_to is set to str, bool, int, the ZAPI value is converted from str to the desired type. + suported values: None, the python types int, str, bool, special 'bool_online' + + Errors: fail_json is called for: + - a key is not found and required=True, + - a format conversion error + """ + + # keep a copy, as the list is mutated + saved_key_list = list(key_list) + try: + value = self.safe_get(na_element, key_list, allow_sparse_dict=not required) + except (KeyError, TypeError) as exc: + error = exc + else: + value, error = self.convert_value(value, convert_to) if value is not None else (default, None) + if error: + self.ansible_module.fail_json(msg='Error reading %s from %s: %s' % (saved_key_list, na_element.to_string(), error)) + return value + + def zapi_get_attrs(self, na_element, attr_dict, result): + """ Retrieve a list of attributes from na_elements + see na_ontap_volume for an example. + na_element: xml element as returned by ZAPI. + attr_dict: + A dict of dict, with format: + key: dict(key_list, required=False, default=None, convert_to=None, omitnone=False) + The keys are used to index a result dictionary, values are read from a ZAPI object indexed by key_list. + If required is True, an error is reported if a key in key_list is not found. + If required is False and the value is not found, uses default as the value. + If convert_to is set to str, bool, int, the ZAPI value is converted from str to the desired type. + I'm not sure there is much value in omitnone, but it preserves backward compatibility. + When the value is None, if omitnone is False, a None value is recorded, if True, the key is not set. + result: an existing dictionary. keys are added or updated based on attrs. + + Errors: fail_json is called for: + - a key is not found and required=True, + - a format conversion error + """ + for key, kwargs in attr_dict.items(): + omitnone = kwargs.pop('omitnone', False) + value = self.zapi_get_value(na_element, **kwargs) + if value is not None or not omitnone: + result[key] = value + + def _filter_out_none_entries_from_dict(self, adict, allow_empty_list_or_dict): + """take a dict as input and return a dict without keys whose values are None + return empty dicts or lists if allow_empty_list_or_dict otherwise skip empty dicts or lists. + """ + result = {} + for key, value in adict.items(): + if isinstance(value, (list, dict)): + sub = self.filter_out_none_entries(value, allow_empty_list_or_dict) + if sub or allow_empty_list_or_dict: + # allow empty dict or list if allow_empty_list_or_dict is set. + # skip empty dict or list otherwise + result[key] = sub + elif value is not None: + # skip None value + result[key] = value + return result + + def _filter_out_none_entries_from_list(self, alist, allow_empty_list_or_dict): + """take a list as input and return a list without elements whose values are None + return empty dicts or lists if allow_empty_list_or_dict otherwise skip empty dicts or lists. + """ + result = [] + for item in alist: + if isinstance(item, (list, dict)): + sub = self.filter_out_none_entries(item, allow_empty_list_or_dict) + if sub or allow_empty_list_or_dict: + # allow empty dict or list if allow_empty_list_or_dict is set. + # skip empty dict or list otherwise + result.append(sub) + elif item is not None: + # skip None value + result.append(item) + return result + + def filter_out_none_entries(self, list_or_dict, allow_empty_list_or_dict=False): + """take a dict or list as input and return a dict/list without keys/elements whose values are None + return empty dicts or lists if allow_empty_list_or_dict otherwise skip empty dicts or lists. + """ + + if isinstance(list_or_dict, dict): + return self._filter_out_none_entries_from_dict(list_or_dict, allow_empty_list_or_dict) + + if isinstance(list_or_dict, list): + return self._filter_out_none_entries_from_list(list_or_dict, allow_empty_list_or_dict) + + raise TypeError('unexpected type %s' % type(list_or_dict)) + + @staticmethod + def get_caller(depth): + '''return the name of: + our caller if depth is 1 + the caller of our caller if depth is 2 + the caller of the caller of our caller if depth is 3 + ... + ''' + # one more caller in the stack + depth += 1 + frames = traceback.extract_stack(limit=depth) + try: + function_name = frames[0].name + except AttributeError: + # python 2.7 does not have named attributes for frames + try: + function_name = frames[0][2] + except Exception as exc: # pylint: disable=broad-except + function_name = 'Error retrieving function name: %s - %s' % (str(exc), repr(frames)) + return function_name + + def fail_on_error(self, error, api=None, stack=False, depth=1, previous_errors=None): + '''depth identifies how far is the caller in the call stack''' + if error is None: + return + # one more caller to account for this function + depth += 1 + if api is not None: + error = 'calling api: %s: %s' % (api, error) + results = dict(msg='Error in %s: %s' % (self.get_caller(depth), error)) + if stack: + results['stack'] = traceback.format_stack() + if previous_errors: + results['previous_errors'] = ' - '.join(previous_errors) + if getattr(self, 'ansible_module', None) is not None: + self.ansible_module.fail_json(**results) + raise AttributeError('Expecting self.ansible_module to be set when reporting %s' % repr(results)) + + def compare_chmod_value(self, current_permissions, desired_permissions): + """ + compare current unix_permissions to desired unix_permissions. + :return: True if the same, False it not the same or desired unix_permissions is not valid. + """ + if current_permissions is None: + return False + if desired_permissions.isdigit(): + return int(current_permissions) == int(desired_permissions) + # ONTAP will throw error as invalid field if the length is not 9 or 12. + if len(desired_permissions) not in [12, 9]: + return False + desired_octal_value = '' + # if the length is 12, first three character sets userid('s'), groupid('s') and sticky('t') attributes + if len(desired_permissions) == 12: + if desired_permissions[0] not in ['s', '-'] or desired_permissions[1] not in ['s', '-']\ + or desired_permissions[2] not in ['t', '-']: + return False + desired_octal_value += str(self.char_to_octal(desired_permissions[:3])) + # if the len is 9, start from 0 else start from 3. + start_range = len(desired_permissions) - 9 + for i in range(start_range, len(desired_permissions), 3): + if desired_permissions[i] not in ['r', '-'] or desired_permissions[i + 1] not in ['w', '-']\ + or desired_permissions[i + 2] not in ['x', '-']: + return False + group_permission = self.char_to_octal(desired_permissions[i:i + 3]) + desired_octal_value += str(group_permission) + return int(current_permissions) == int(desired_octal_value) + + def char_to_octal(self, chars): + """ + :param chars: Characters to be converted into octal values. + :return: octal value of the individual group permission. + """ + total = 0 + if chars[0] in ['r', 's']: + total += 4 + if chars[1] in ['w', 's']: + total += 2 + if chars[2] in ['x', 't']: + total += 1 + return total + + def ignore_missing_vserver_on_delete(self, error, vserver_name=None): + """ When a resource is expected to be absent, it's OK if the containing vserver is also absent. + This function expects self.parameters('vserver') to be set or the vserver_name argument to be passed. + error is an error returned by rest_generic.get_xxxx. + """ + if self.parameters.get('state') != 'absent': + return False + if vserver_name is None: + if self.parameters.get('vserver') is None: + self.ansible_module.fail_json( + msg='Internal error, vserver name is required, when processing error: %s' % error, exception=traceback.format_exc()) + vserver_name = self.parameters['vserver'] + if isinstance(error, str): + pass + elif isinstance(error, dict): + if 'message' in error: + error = error['message'] + else: + self.ansible_module.fail_json( + msg='Internal error, error should contain "message" key, found: %s' % error, exception=traceback.format_exc()) + else: + self.ansible_module.fail_json( + msg='Internal error, error should be str or dict, found: %s, %s' % (type(error), error), exception=traceback.format_exc()) + return 'SVM "%s" does not exist.' % vserver_name in error + + def remove_hal_links(self, records): + """ Remove all _links entries """ + if isinstance(records, dict): + records.pop('_links', None) + for record in records.values(): + self.remove_hal_links(record) + if isinstance(records, list): + for record in records: + self.remove_hal_links(record) diff --git a/ansible_collections/netapp/ontap/plugins/module_utils/rest_application.py b/ansible_collections/netapp/ontap/plugins/module_utils/rest_application.py new file mode 100644 index 000000000..d96f23031 --- /dev/null +++ b/ansible_collections/netapp/ontap/plugins/module_utils/rest_application.py @@ -0,0 +1,180 @@ +# 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) 2020-2022, Laurent Nicolas <laurentn@netapp.com> +# 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. + +""" Support class for NetApp ansible modules + + Provides accesss to application resources using REST calls +""" + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible_collections.netapp.ontap.plugins.module_utils import rest_generic + + +class RestApplication(): + """Helper methods to manage application and application components""" + def __init__(self, rest_api, svm_name, app_name): + self.svm_name = svm_name + self.app_name = app_name + self.app_uuid = None + self.rest_api = rest_api + + def _set_application_uuid(self): + """Use REST application/applications to get application uuid""" + api = 'application/applications' + query = {'svm.name': self.svm_name, 'name': self.app_name} + record, error = rest_generic.get_one_record(self.rest_api, api, query) + if error is None and record is not None: + self.app_uuid = record['uuid'] + return None, error + + def get_application_uuid(self): + """Use REST application/applications to get application uuid""" + error = None + if self.app_uuid is None: + dummy, error = self._set_application_uuid() + return self.app_uuid, error + + def get_application_details(self, template=None): + """Use REST application/applications to get application details""" + uuid, error = self.get_application_uuid() + if error: + return uuid, error + if uuid is None: # not found + return None, None + query = dict(fields='name,%s,statistics' % template) if template else None + api = 'application/applications/%s' % uuid + return rest_generic.get_one_record(self.rest_api, api, query) + + def create_application(self, body): + """Use REST application/applications san template to create one or more LUNs""" + dummy, error = self.fail_if_uuid('create_application') + if error is not None: + return dummy, error + api = 'application/applications' + query = {'return_records': 'true'} + response, error = rest_generic.post_async(self.rest_api, api, body, query) + if error and 'Unexpected argument' in error and 'exclude_aggregates' in error: + error += ' "exclude_aggregates" requires ONTAP 9.9.1 GA or later.' + return response, error + + def patch_application(self, body): + """Use REST application/applications san template to add one or more LUNs""" + dummy, error = self.fail_if_no_uuid() + if error is not None: + return dummy, error + api = 'application/applications' + query = {'return_records': 'true'} + return rest_generic.patch_async(self.rest_api, api, self.app_uuid, body, query) + + def create_application_body(self, template_name, template_body, smart_container=True): + if not isinstance(smart_container, bool): + error = "expecting bool value for smart_container, got: %s" % smart_container + return None, error + body = { + 'name': self.app_name, + 'svm': {'name': self.svm_name}, + 'smart_container': smart_container, + template_name: template_body + } + return body, None + + def delete_application(self): + """Use REST application/applications to delete app""" + dummy, error = self.fail_if_no_uuid() + if error is not None: + return dummy, error + api = 'application/applications' + response, error = rest_generic.delete_async(self.rest_api, api, self.app_uuid) + self.app_uuid = None + return response, error + + def get_application_components(self): + """Use REST application/applications to get application components""" + dummy, error = self.fail_if_no_uuid() + if error is not None: + return dummy, error + api = 'application/applications/%s/components' % self.app_uuid + return rest_generic.get_0_or_more_records(self.rest_api, api) + + def get_application_component_uuid(self): + """Use REST application/applications to get component uuid + Assume a single component per application + """ + dummy, error = self.fail_if_no_uuid() + if error is not None: + return dummy, error + api = 'application/applications/%s/components' % self.app_uuid + record, error = rest_generic.get_one_record(self.rest_api, api, fields='uuid') + if error is None and record is not None: + return record['uuid'], None + return None, error + + def get_application_component_details(self, comp_uuid=None): + """Use REST application/applications to get application components""" + dummy, error = self.fail_if_no_uuid() + if error is not None: + return dummy, error + if comp_uuid is None: + # assume a single component + comp_uuid, error = self.get_application_component_uuid() + if error: + return comp_uuid, error + if comp_uuid is None: + error = 'no component for application %s' % self.app_name + return None, error + api = 'application/applications/%s/components/%s' % (self.app_uuid, comp_uuid) + return rest_generic.get_one_record(self.rest_api, api) + + def get_application_component_backing_storage(self): + """Use REST application/applications to get component uuid. + + Assume a single component per application + """ + dummy, error = self.fail_if_no_uuid() + if error is not None: + return dummy, error + response, error = self.get_application_component_details() + if error or response is None: + return response, error + return response['backing_storage'], None + + def fail_if_no_uuid(self): + """Prevent a logic error.""" + if self.app_uuid is None: + msg = 'function should not be called before application uuid is set.' + return None, msg + return None, None + + def fail_if_uuid(self, fname): + """Prevent a logic error.""" + if self.app_uuid is not None: + msg = 'function %s should not be called when application uuid is set: %s.' % (fname, self.app_uuid) + return None, msg + return None, None diff --git a/ansible_collections/netapp/ontap/plugins/module_utils/rest_generic.py b/ansible_collections/netapp/ontap/plugins/module_utils/rest_generic.py new file mode 100644 index 000000000..4c570f8d8 --- /dev/null +++ b/ansible_collections/netapp/ontap/plugins/module_utils/rest_generic.py @@ -0,0 +1,101 @@ +# 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) 2021, Laurent Nicolas <laurentn@netapp.com> +# 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. + +""" Support functions for NetApp ansible modules + + Provides common processing for responses and errors from REST calls +""" + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import ansible_collections.netapp.ontap.plugins.module_utils.rest_response_helpers as rrh + + +def build_query_with_fields(query, fields): + ''' for GET requests''' + if fields is not None and query is None: + query = {} + if fields is not None: + query['fields'] = fields + return query + + +def build_query_with_timeout(query, timeout): + ''' for POST, PATCH, DELETE requests''' + params = {} if query else None + if timeout > 0: + # without return_timeout, REST returns immediately with a 202 and a job link + # but the job status is 'running' + # with return_timeout, REST returns quickly with a 200 and a job link + # and the job status is 'success' + params = dict(return_timeout=timeout) + if query is not None: + params.update(query) + return params + + +def get_one_record(rest_api, api, query=None, fields=None): + query = build_query_with_fields(query, fields) + response, error = rest_api.get(api, query) + record, error = rrh.check_for_0_or_1_records(api, response, error, query) + return record, error + + +def get_0_or_more_records(rest_api, api, query=None, fields=None): + query = build_query_with_fields(query, fields) + response, error = rest_api.get(api, query) + records, error = rrh.check_for_0_or_more_records(api, response, error) + return records, error + + +def post_async(rest_api, api, body, query=None, timeout=30, job_timeout=30, headers=None, raw_error=False, files=None): + # see delete_async for async and sync operations and status codes + response, error = rest_api.post(api, body=body, params=build_query_with_timeout(query, timeout), headers=headers, files=files) + # limit the polling interval to something between 5 seconds and 60 seconds + increment = min(max(job_timeout / 6, 5), 60) + response, error = rrh.check_for_error_and_job_results(api, response, error, rest_api, increment=increment, timeout=job_timeout, raw_error=raw_error) + return response, error + + +def patch_async(rest_api, api, uuid_or_name, body, query=None, timeout=30, job_timeout=30, headers=None, raw_error=False, files=None): + # cluster does not use uuid or name, and query based PATCH does not use UUID (for restit) + api = '%s/%s' % (api, uuid_or_name) if uuid_or_name is not None else api + response, error = rest_api.patch(api, body=body, params=build_query_with_timeout(query, timeout), headers=headers, files=files) + increment = min(max(job_timeout / 6, 5), 60) + response, error = rrh.check_for_error_and_job_results(api, response, error, rest_api, increment=increment, timeout=job_timeout, raw_error=raw_error) + return response, error + + +def delete_async(rest_api, api, uuid, query=None, body=None, timeout=30, job_timeout=30, headers=None, raw_error=False): + # query based DELETE does not use UUID (for restit) + api = '%s/%s' % (api, uuid) if uuid is not None else api + response, error = rest_api.delete(api, body=body, params=build_query_with_timeout(query, timeout), headers=headers) + increment = min(max(job_timeout / 6, 5), 60) + response, error = rrh.check_for_error_and_job_results(api, response, error, rest_api, increment=increment, timeout=job_timeout, raw_error=raw_error) + return response, error diff --git a/ansible_collections/netapp/ontap/plugins/module_utils/rest_owning_resource.py b/ansible_collections/netapp/ontap/plugins/module_utils/rest_owning_resource.py new file mode 100644 index 000000000..597c02d50 --- /dev/null +++ b/ansible_collections/netapp/ontap/plugins/module_utils/rest_owning_resource.py @@ -0,0 +1,26 @@ +""" Support functions for NetApp ansible modules + Provides common processing for responses and errors from REST calls +""" + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible_collections.netapp.ontap.plugins.module_utils import rest_generic + + +def get_export_policy_id(rest_api, policy_name, svm_name, module): + api = 'protocols/nfs/export-policies' + query = {'name': policy_name, 'svm.name': svm_name} + record, error = rest_generic.get_one_record(rest_api, api, query) + if error: + module.fail_json(msg='Could not find export policy %s on SVM %s' % (policy_name, svm_name)) + return record['id'] if record else None + + +def get_volume_uuid(rest_api, volume_name, svm_name, module): + api = 'storage/volumes' + query = {'name': volume_name, 'svm.name': svm_name} + record, error = rest_generic.get_one_record(rest_api, api, query) + if error: + module.fail_json(msg='Could not find volume %s on SVM %s' % (volume_name, svm_name)) + return record['uuid'] if record else None diff --git a/ansible_collections/netapp/ontap/plugins/module_utils/rest_response_helpers.py b/ansible_collections/netapp/ontap/plugins/module_utils/rest_response_helpers.py new file mode 100644 index 000000000..59e04b3b3 --- /dev/null +++ b/ansible_collections/netapp/ontap/plugins/module_utils/rest_response_helpers.py @@ -0,0 +1,137 @@ +# 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) 2020, Laurent Nicolas <laurentn@netapp.com> +# 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. + +""" Support functions for NetApp ansible modules + + Provides common processing for responses and errors from REST calls +""" + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +def api_error(api, error): + """format error message for api error, if error is present""" + return "calling: %s: got %s." % (api, error) if error is not None else None + + +def no_response_error(api, response): + """format error message for empty response""" + return "calling: %s: no response %s." % (api, repr(response)) + + +def job_error(response, error): + """format error message for job error""" + return "job reported error: %s, received %s." % (error, repr(response)) + + +def unexpected_response_error(api, response, query=None): + """format error message for reponse not matching expectations""" + msg = "calling: %s: unexpected response %s." % (api, repr(response)) + if query: + msg += " for query: %s" % repr(query) + return response, msg + + +def get_num_records(response): + """ num_records is not always present + if absent, count the records or assume 1 + """ + if 'num_records' in response: + return response['num_records'] + return len(response['records']) if 'records' in response else 1 + + +def check_for_0_or_1_records(api, response, error, query=None): + """return None if no record was returned by the API + return record if one record was returned by the API + return error otherwise (error, no response, more than 1 record) + """ + if error: + return (None, api_error(api, error)) if api else (None, error) + if not response: + return None, no_response_error(api, response) + num_records = get_num_records(response) + if num_records == 0: + return None, None # not found + if num_records != 1: + return unexpected_response_error(api, response, query) + if 'records' in response: + return response['records'][0], None + return response, None + + +def check_for_0_or_more_records(api, response, error): + """return None if no record was returned by the API + return records if one or more records was returned by the API + return error otherwise (error, no response) + """ + if error: + return (None, api_error(api, error)) if api else (None, error) + if not response: + return None, no_response_error(api, response) + if get_num_records(response) == 0: + return None, None # not found + if 'records' in response: + return response['records'], None + error = 'No "records" key in %s' % response + return (None, api_error(api, error)) if api else (None, error) + + +def check_for_error_and_job_results(api, response, error, rest_api, **kwargs): + """report first error if present + otherwise call wait_on_job and retrieve job response or error + """ + format_error = not kwargs.pop('raw_error', False) + if error: + if format_error: + error = api_error(api, error) + # we expect two types of response + # a plain response, for synchronous calls + # or a job response, for asynchronous calls + # and it's possible to expect both when 'return_timeout' > 0 + # + # when using a query instead of UUID, REST return jobs (a list of jobs) rather than a single job + # only restit can send a query, all other calls are using a UUID. + elif isinstance(response, dict): + job = None + if 'job' in response: + job = response['job'] + elif 'jobs' in response: + if response['num_records'] > 1: + error = "multiple jobs in progress, can't check status" + else: + job = response['jobs'][0] + if job: + job_response, error = rest_api.wait_on_job(job, **kwargs) + if error: + if format_error: + error = job_error(response, error) + else: + response['job_response'] = job_response + return response, error diff --git a/ansible_collections/netapp/ontap/plugins/module_utils/rest_user.py b/ansible_collections/netapp/ontap/plugins/module_utils/rest_user.py new file mode 100644 index 000000000..b7c2e66a2 --- /dev/null +++ b/ansible_collections/netapp/ontap/plugins/module_utils/rest_user.py @@ -0,0 +1,49 @@ +# 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) 2021, Laurent Nicolas <laurentn@netapp.com> +# 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. + +""" Support functions for NetApp ansible modules + + Provides common processing for responses and errors from REST calls +""" + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import ansible_collections.netapp.ontap.plugins.module_utils.rest_response_helpers as rrh + + +def get_users(rest_api, parameters, fields=None): + api = 'security/accounts' + query = dict() + for field in parameters: + query[field] = parameters[field] + if fields is not None: + query['fields'] = fields + response, error = rest_api.get(api, query) + users, error = rrh.check_for_0_or_more_records(api, response, error) + return users, error diff --git a/ansible_collections/netapp/ontap/plugins/module_utils/rest_volume.py b/ansible_collections/netapp/ontap/plugins/module_utils/rest_volume.py new file mode 100644 index 000000000..10af36754 --- /dev/null +++ b/ansible_collections/netapp/ontap/plugins/module_utils/rest_volume.py @@ -0,0 +1,61 @@ +# 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) 2020, Laurent Nicolas <laurentn@netapp.com> +# 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. + +""" Support functions for NetApp ansible modules + + Provides common processing for responses and errors from REST calls +""" + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible_collections.netapp.ontap.plugins.module_utils import rest_generic + + +def get_volumes(rest_api, vserver=None, name=None): + api = 'storage/volumes' + query = {} + if vserver is not None: + query['svm.name'] = vserver + if name is not None: + query['name'] = name + if not query: + query = None + return rest_generic.get_0_or_more_records(rest_api, api, query) + + +def get_volume(rest_api, vserver, name, fields=None): + api = 'storage/volumes' + query = dict(name=name) + query['svm.name'] = vserver + return rest_generic.get_one_record(rest_api, api, query, fields=fields) + + +def patch_volume(rest_api, uuid, body, query=None, job_timeout=120): + api = 'storage/volumes' + return rest_generic.patch_async(rest_api, api, uuid, body, query=query, job_timeout=job_timeout) diff --git a/ansible_collections/netapp/ontap/plugins/module_utils/rest_vserver.py b/ansible_collections/netapp/ontap/plugins/module_utils/rest_vserver.py new file mode 100644 index 000000000..cbdfdaef9 --- /dev/null +++ b/ansible_collections/netapp/ontap/plugins/module_utils/rest_vserver.py @@ -0,0 +1,61 @@ +# 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) 2021, Laurent Nicolas <laurentn@netapp.com> +# 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. + +""" Support functions for NetApp ansible modules + + Provides common processing for responses and errors from REST calls +""" + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible_collections.netapp.ontap.plugins.module_utils.rest_generic import get_one_record + + +def get_vserver(rest_api, name, fields=None): + api = 'svm/svms' + query = {'name': name} + if fields is not None: + query['fields'] = fields + vserver, error = get_one_record(rest_api, api, query) + return vserver, error + + +def get_vserver_uuid(rest_api, name, module=None, error_on_none=False): + """ returns a tuple (uuid, error) + when module is set and an error is found, fails the module and exit + when error_on_none IS SET, force an error if vserver is not found + """ + record, error = get_vserver(rest_api, name, 'uuid') + if error and module: + module.fail_json(msg="Error fetching vserver %s: %s" % (name, error)) + if not error and record is None and error_on_none: + error = "vserver %s does not exist or is not a data vserver." % name + if module: + module.fail_json(msg="Error %s" % error) + return record['uuid'] if not error and record else None, error diff --git a/ansible_collections/netapp/ontap/plugins/module_utils/zapis_svm.py b/ansible_collections/netapp/ontap/plugins/module_utils/zapis_svm.py new file mode 100644 index 000000000..71a3f2496 --- /dev/null +++ b/ansible_collections/netapp/ontap/plugins/module_utils/zapis_svm.py @@ -0,0 +1,133 @@ +# 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) 2020, Laurent Nicolas <laurentn@netapp.com> +# 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. + +''' Support class for NetApp ansible modules + + Provides accesss to SVM (vserver) resources using ZAPI calls +''' + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import traceback + +from ansible.module_utils._text import to_native +import ansible_collections.netapp.ontap.plugins.module_utils.netapp as netapp_utils + + +def get_vserver(svm_cx, vserver_name): + """ + Return vserver information. + + :return: + vserver object if vserver found + None if vserver is not found + :rtype: object/None + """ + vserver_info = netapp_utils.zapi.NaElement('vserver-get-iter') + query_details = netapp_utils.zapi.NaElement.create_node_with_children( + 'vserver-info', **{'vserver-name': vserver_name}) + + query = netapp_utils.zapi.NaElement('query') + query.add_child_elem(query_details) + vserver_info.add_child_elem(query) + + result = svm_cx.invoke_successfully(vserver_info, enable_tunneling=False) + vserver_details = None + if result.get_child_by_name('num-records') and int(result.get_child_content('num-records')) >= 1: + attributes_list = result.get_child_by_name('attributes-list') + vserver_info = attributes_list.get_child_by_name('vserver-info') + aggr_list = [] + # vserver aggr-list can be empty by default + get_list = vserver_info.get_child_by_name('aggr-list') + if get_list is not None: + aggregates = get_list.get_children() + aggr_list.extend(aggr.get_content() for aggr in aggregates) + protocols = [] + # allowed-protocols is not empty for data SVM, but is for node SVM + allowed_protocols = vserver_info.get_child_by_name('allowed-protocols') + if allowed_protocols is not None: + get_protocols = allowed_protocols.get_children() + protocols.extend(protocol.get_content() for protocol in get_protocols) + vserver_details = {'name': vserver_info.get_child_content('vserver-name'), + 'root_volume': vserver_info.get_child_content('root-volume'), + 'root_volume_aggregate': vserver_info.get_child_content('root-volume-aggregate'), + 'root_volume_security_style': vserver_info.get_child_content('root-volume-security-style'), + 'subtype': vserver_info.get_child_content('vserver-subtype'), + 'aggr_list': aggr_list, + 'language': vserver_info.get_child_content('language'), + 'quota_policy': vserver_info.get_child_content('quota-policy'), + 'snapshot_policy': vserver_info.get_child_content('snapshot-policy'), + 'allowed_protocols': protocols, + 'ipspace': vserver_info.get_child_content('ipspace'), + 'comment': vserver_info.get_child_content('comment'), + 'max_volumes': vserver_info.get_child_content('max-volumes')} + + return vserver_details + + +def modify_vserver(svm_cx, module, name, modify, parameters=None): + ''' + Modify vserver. + :param name: vserver name + :param modify: list of modify attributes + :param parameters: customer original inputs + modify only contains the difference between the customer inputs and current + for some attributes, it may be safer to apply the original inputs + ''' + if parameters is None: + parameters = modify + + vserver_modify = netapp_utils.zapi.NaElement('vserver-modify') + vserver_modify.add_new_child('vserver-name', name) + for attribute in modify: + if attribute == 'comment': + vserver_modify.add_new_child('comment', parameters['comment']) + if attribute == 'language': + vserver_modify.add_new_child('language', parameters['language']) + if attribute == 'quota_policy': + vserver_modify.add_new_child('quota-policy', parameters['quota_policy']) + if attribute == 'snapshot_policy': + vserver_modify.add_new_child('snapshot-policy', parameters['snapshot_policy']) + if attribute == 'max_volumes': + vserver_modify.add_new_child('max-volumes', parameters['max_volumes']) + if attribute == 'allowed_protocols': + allowed_protocols = netapp_utils.zapi.NaElement('allowed-protocols') + for protocol in parameters['allowed_protocols']: + allowed_protocols.add_new_child('protocol', protocol) + vserver_modify.add_child_elem(allowed_protocols) + if attribute == 'aggr_list': + aggregates = netapp_utils.zapi.NaElement('aggr-list') + for aggr in parameters['aggr_list']: + aggregates.add_new_child('aggr-name', aggr) + vserver_modify.add_child_elem(aggregates) + try: + svm_cx.invoke_successfully(vserver_modify, enable_tunneling=False) + except netapp_utils.zapi.NaApiError as exc: + module.fail_json(msg='Error modifying SVM %s: %s' % (name, to_native(exc)), + exception=traceback.format_exc()) |