# (c) 2020, NetApp, Inc # BSD-3 Clause (see COPYING or https://opensource.org/licenses/BSD-3-Clause) from __future__ import absolute_import, division, print_function __metaclass__ = type import json import random import mimetypes from pprint import pformat from ansible.module_utils import six from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.six.moves.urllib.error import HTTPError, URLError from ansible.module_utils.urls import open_url from ansible.module_utils.api import basic_auth_argument_spec 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' try: from urlparse import urlparse, urlunparse except ImportError: from urllib.parse import urlparse, urlunparse def eseries_host_argument_spec(): """Retrieve a base argument specification common to all NetApp E-Series modules""" argument_spec = basic_auth_argument_spec() argument_spec.update(dict( api_username=dict(type="str", required=True), api_password=dict(type="str", required=True, no_log=True), api_url=dict(type="str", required=True), ssid=dict(type="str", required=False, default="1"), validate_certs=dict(type="bool", required=False, default=True) )) return argument_spec def eseries_proxy_argument_spec(): """Retrieve a base argument specification common to all NetApp E-Series modules for proxy specific tasks""" argument_spec = basic_auth_argument_spec() argument_spec.update(dict( api_username=dict(type="str", required=True), api_password=dict(type="str", required=True, no_log=True), api_url=dict(type="str", required=True), validate_certs=dict(type="bool", required=False, default=True) )) return argument_spec class NetAppESeriesModule(object): """Base class for all NetApp E-Series modules. Provides a set of common methods for NetApp E-Series modules, including version checking, mode (proxy, embedded) verification, http requests, secure http redirection for embedded web services, and logging setup. Be sure to add the following lines in the module's documentation section: extends_documentation_fragment: - santricity :param dict(dict) ansible_options: dictionary of ansible option definitions :param str web_services_version: minimally required web services rest api version (default value: "02.00.0000.0000") :param bool supports_check_mode: whether the module will support the check_mode capabilities (default=False) :param list(list) mutually_exclusive: list containing list(s) of mutually exclusive options (optional) :param list(list) required_if: list containing list(s) containing the option, the option value, and then a list of required options. (optional) :param list(list) required_one_of: list containing list(s) of options for which at least one is required. (optional) :param list(list) required_together: list containing list(s) of options that are required together. (optional) :param bool log_requests: controls whether to log each request (default: True) :param bool proxy_specific_task: controls whether ssid is a default option (default: False) """ DEFAULT_TIMEOUT = 300 DEFAULT_SECURE_PORT = "8443" DEFAULT_BASE_PATH = "devmgr/" DEFAULT_REST_API_PATH = "devmgr/v2/" DEFAULT_REST_API_ABOUT_PATH = "devmgr/utils/about" DEFAULT_HEADERS = {"Content-Type": "application/json", "Accept": "application/json", "netapp-client-type": "Ansible-%s" % ansible_version} HTTP_AGENT = "Ansible / %s" % ansible_version SIZE_UNIT_MAP = dict(bytes=1, b=1, kb=1024, mb=1024**2, gb=1024**3, tb=1024**4, pb=1024**5, eb=1024**6, zb=1024**7, yb=1024**8) HOST_TYPE_INDEXES = {"aix mpio": 9, "avt 4m": 5, "hp-ux": 15, "linux atto": 24, "linux dm-mp": 28, "linux pathmanager": 25, "solaris 10 or earlier": 2, "solaris 11 or later": 17, "svc": 18, "ontap": 26, "mac": 22, "vmware": 10, "windows": 1, "windows atto": 23, "windows clustered": 8} def __init__(self, ansible_options, web_services_version=None, supports_check_mode=False, mutually_exclusive=None, required_if=None, required_one_of=None, required_together=None, log_requests=True, proxy_specific_task=False): if proxy_specific_task: argument_spec = eseries_proxy_argument_spec() else: argument_spec = eseries_host_argument_spec() argument_spec.update(ansible_options) self.module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=supports_check_mode, mutually_exclusive=mutually_exclusive, required_if=required_if, required_one_of=required_one_of, required_together=required_together) args = self.module.params self.web_services_version = web_services_version if web_services_version else "02.00.0000.0000" if proxy_specific_task: self.ssid = "0" else: self.ssid = args["ssid"] self.url = args["api_url"] self.log_requests = log_requests self.creds = dict(url_username=args["api_username"], url_password=args["api_password"], validate_certs=args["validate_certs"]) if not self.url.endswith("/"): self.url += "/" self.is_proxy_used_cache = None self.is_embedded_available_cache = None self.is_web_services_valid_cache = None def _check_ssid(self): """Verify storage system identifier exist on the proxy and, if not, then update to match storage system name.""" try: rc, data = self._request(url=self.url + self.DEFAULT_REST_API_ABOUT_PATH, **self.creds) if data["runningAsProxy"]: if self.ssid.lower() not in ["proxy", "0"]: try: rc, systems = self._request(url=self.url + self.DEFAULT_REST_API_PATH + "storage-systems", **self.creds) alternates = [] for system in systems: if system["id"] == self.ssid: break elif system["name"] == self.ssid: alternates.append(system["id"]) else: if len(alternates) == 1: self.module.warn("Array Id does not exist on Web Services Proxy Instance! However, there is a storage system with a" " matching name. Updating Identifier. Array Name: [%s], Array Id [%s]." % (self.ssid, alternates[0])) self.ssid = alternates[0] else: self.module.fail_json(msg="Array identifier does not exist on Web Services Proxy Instance! Array ID [%s]." % self.ssid) except Exception as error: self.module.fail_json(msg="Failed to determine Web Services Proxy storage systems! Array [%s]. Error [%s]" % (self.ssid, to_native(error))) except Exception as error: # Don't fail here, if the ssid is wrong the it will fail on the next request. Causes issues for na_santricity_auth module. pass def _check_web_services_version(self): """Verify proxy or embedded web services meets minimum version required for module. The minimum required web services version is evaluated against version supplied through the web services rest api. AnsibleFailJson exception will be raised when the minimum is not met or exceeded. This helper function will update the supplied api url if secure http is not used for embedded web services :raise AnsibleFailJson: raised when the contacted api service does not meet the minimum required version. """ if not self.is_web_services_valid_cache: url_parts = urlparse(self.url) if not url_parts.scheme or not url_parts.netloc: self.module.fail_json(msg="Failed to provide valid API URL. Example: https://192.168.1.100:8443/devmgr/v2. URL [%s]." % self.url) if url_parts.scheme not in ["http", "https"]: self.module.fail_json(msg="Protocol must be http or https. URL [%s]." % self.url) self.url = "%s://%s/" % (url_parts.scheme, url_parts.netloc) about_url = self.url + self.DEFAULT_REST_API_ABOUT_PATH rc, data = request(about_url, timeout=self.DEFAULT_TIMEOUT, headers=self.DEFAULT_HEADERS, ignore_errors=True, force_basic_auth=False, **self.creds) if rc != 200: self.module.warn("Failed to retrieve web services about information! Retrying with secure ports. Array Id [%s]." % self.ssid) self.url = "https://%s:8443/" % url_parts.netloc.split(":")[0] about_url = self.url + self.DEFAULT_REST_API_ABOUT_PATH try: rc, data = request(about_url, timeout=self.DEFAULT_TIMEOUT, headers=self.DEFAULT_HEADERS, **self.creds) except Exception as error: self.module.fail_json(msg="Failed to retrieve the webservices about information! Array Id [%s]. Error [%s]." % (self.ssid, to_native(error))) if len(data["version"].split(".")) == 4: major, minor, other, revision = data["version"].split(".") minimum_major, minimum_minor, other, minimum_revision = self.web_services_version.split(".") if not (major > minimum_major or (major == minimum_major and minor > minimum_minor) or (major == minimum_major and minor == minimum_minor and revision >= minimum_revision)): self.module.fail_json(msg="Web services version does not meet minimum version required. Current version: [%s]." " Version required: [%s]." % (data["version"], self.web_services_version)) self.module.log("Web services rest api version met the minimum required version.") else: self.module.warn("Web services rest api version unknown!") self._check_ssid() self.is_web_services_valid_cache = True def is_web_services_version_met(self, version): """Determines whether a particular web services version has been satisfied.""" split_version = version.split(".") if len(split_version) != 4 or not split_version[0].isdigit() or not split_version[1].isdigit() or not split_version[3].isdigit(): self.module.fail_json(msg="Version is not a valid Web Services version. Version [%s]." % version) url_parts = urlparse(self.url) if not url_parts.scheme or not url_parts.netloc: self.module.fail_json(msg="Failed to provide valid API URL. Example: https://192.168.1.100:8443/devmgr/v2. URL [%s]." % self.url) if url_parts.scheme not in ["http", "https"]: self.module.fail_json(msg="Protocol must be http or https. URL [%s]." % self.url) self.url = "%s://%s/" % (url_parts.scheme, url_parts.netloc) about_url = self.url + self.DEFAULT_REST_API_ABOUT_PATH rc, data = request(about_url, timeout=self.DEFAULT_TIMEOUT, headers=self.DEFAULT_HEADERS, ignore_errors=True, **self.creds) if rc != 200: self.module.warn("Failed to retrieve web services about information! Retrying with secure ports. Array Id [%s]." % self.ssid) self.url = "https://%s:8443/" % url_parts.netloc.split(":")[0] about_url = self.url + self.DEFAULT_REST_API_ABOUT_PATH try: rc, data = request(about_url, timeout=self.DEFAULT_TIMEOUT, headers=self.DEFAULT_HEADERS, **self.creds) except Exception as error: self.module.fail_json(msg="Failed to retrieve the webservices about information! Array Id [%s]. Error [%s]." % (self.ssid, to_native(error))) if len(data["version"].split(".")) == 4: major, minor, other, revision = data["version"].split(".") minimum_major, minimum_minor, other, minimum_revision = split_version if not (major > minimum_major or (major == minimum_major and minor > minimum_minor) or (major == minimum_major and minor == minimum_minor and revision >= minimum_revision)): return False else: return False return True def is_embedded_available(self): """Determine whether the storage array has embedded services available.""" self._check_web_services_version() if self.is_embedded_available_cache is None: if self.is_proxy(): if self.ssid == "0" or self.ssid.lower() == "proxy": self.is_embedded_available_cache = False else: try: rc, bundle = self.request("storage-systems/%s/graph/xpath-filter?query=/sa/saData/extendedSAData/codeVersions[codeModule='bundle']" % self.ssid) self.is_embedded_available_cache = False if bundle: self.is_embedded_available_cache = True except Exception as error: self.module.fail_json(msg="Failed to retrieve information about storage system [%s]. Error [%s]." % (self.ssid, to_native(error))) else: # Contacted using embedded web services self.is_embedded_available_cache = True self.module.log("embedded_available: [%s]" % ("True" if self.is_embedded_available_cache else "False")) return self.is_embedded_available_cache def is_embedded(self): """Determine whether web services server is the embedded web services.""" return not self.is_proxy() def is_proxy(self): """Determine whether web services server is the proxy web services. :raise AnsibleFailJson: raised when web services about endpoint failed to be contacted. :return bool: whether contacted web services is running from storage array (embedded) or from a proxy. """ self._check_web_services_version() if self.is_proxy_used_cache is None: about_url = self.url + self.DEFAULT_REST_API_ABOUT_PATH try: rc, data = request(about_url, timeout=self.DEFAULT_TIMEOUT, headers=self.DEFAULT_HEADERS, force_basic_auth=False, **self.creds) self.is_proxy_used_cache = data["runningAsProxy"] self.module.log("proxy: [%s]" % ("True" if self.is_proxy_used_cache else "False")) except Exception as error: self.module.fail_json(msg="Failed to retrieve the webservices about information! Array Id [%s]. Error [%s]." % (self.ssid, to_native(error))) return self.is_proxy_used_cache def request(self, path, rest_api_path=DEFAULT_REST_API_PATH, rest_api_url=None, data=None, method='GET', headers=None, ignore_errors=False, timeout=None, force_basic_auth=True, log_request=None, json_response=True): """Issue an HTTP request to a url, retrieving an optional JSON response. :param str path: web services rest api endpoint path (Example: storage-systems/1/graph). Note that when the full url path is specified then that will be used without supplying the protocol, hostname, port and rest path. :param str rest_api_path: override the class DEFAULT_REST_API_PATH which is used to build the request URL. :param str rest_api_url: override the class url member which contains the base url for web services. :param data: data required for the request (data may be json or any python structured data) :param str method: request method such as GET, POST, DELETE. :param dict headers: dictionary containing request headers. :param bool ignore_errors: forces the request to ignore any raised exceptions. :param int timeout: duration of seconds before request finally times out. :param bool force_basic_auth: Ensure that basic authentication is being used. :param bool log_request: Log the request and response :param bool json_response: Whether the response should be loaded as JSON, otherwise the response is return raw. """ self._check_web_services_version() if rest_api_url is None: rest_api_url = self.url if headers is None: headers = self.DEFAULT_HEADERS if timeout is None: timeout = self.DEFAULT_TIMEOUT if log_request is None: log_request = self.log_requests if not isinstance(data, str) and "Content-Type" in headers and headers["Content-Type"] == "application/json": data = json.dumps(data) if path.startswith("/"): path = path[1:] request_url = rest_api_url + rest_api_path + path if log_request: self.module.log(pformat(dict(url=request_url, data=data, method=method, headers=headers))) response = self._request(url=request_url, data=data, method=method, headers=headers, last_mod_time=None, timeout=timeout, http_agent=self.HTTP_AGENT, force_basic_auth=force_basic_auth, ignore_errors=ignore_errors, json_response=json_response, **self.creds) if log_request: self.module.log(pformat(response)) return response @staticmethod def _request(url, data=None, headers=None, method='GET', use_proxy=True, force=False, last_mod_time=None, timeout=10, validate_certs=True, url_username=None, url_password=None, http_agent=None, force_basic_auth=True, ignore_errors=False, json_response=True): """Issue an HTTP request to a url, retrieving an optional JSON response.""" if headers is None: headers = {"Content-Type": "application/json", "Accept": "application/json"} headers.update({"netapp-client-type": "Ansible-%s" % ansible_version}) if not http_agent: http_agent = "Ansible / %s" % ansible_version try: r = open_url(url=url, data=data, headers=headers, method=method, use_proxy=use_proxy, force=force, last_mod_time=last_mod_time, timeout=timeout, validate_certs=validate_certs, url_username=url_username, url_password=url_password, http_agent=http_agent, force_basic_auth=force_basic_auth) rc = r.getcode() response = r.read() if json_response and response: response = json.loads(response) except HTTPError as error: rc = error.code response = error.fp.read() try: if json_response: response = json.loads(response) except Exception: pass if not ignore_errors: raise Exception(rc, response) except ValueError as error: pass return rc, response def create_multipart_formdata(files, fields=None, send_8kb=False): """Create the data for a multipart/form request. :param list(list) files: list of lists each containing (name, filename, path). :param list(list) fields: list of lists each containing (key, value). :param bool send_8kb: only sends the first 8kb of the files (default: False). """ boundary = "---------------------------" + "".join([str(random.randint(0, 9)) for x in range(27)]) data_parts = list() data = None if six.PY2: # Generate payload for Python 2 newline = "\r\n" if fields is not None: for key, value in fields: data_parts.extend(["--%s" % boundary, 'Content-Disposition: form-data; name="%s"' % key, "", value]) for name, filename, path in files: with open(path, "rb") as fh: value = fh.read(8192) if send_8kb else fh.read() data_parts.extend(["--%s" % boundary, 'Content-Disposition: form-data; name="%s"; filename="%s"' % (name, filename), "Content-Type: %s" % (mimetypes.guess_type(path)[0] or "application/octet-stream"), "", value]) data_parts.extend(["--%s--" % boundary, ""]) data = newline.join(data_parts) else: newline = six.b("\r\n") if fields is not None: for key, value in fields: data_parts.extend([six.b("--%s" % boundary), six.b('Content-Disposition: form-data; name="%s"' % key), six.b(""), six.b(value)]) for name, filename, path in files: with open(path, "rb") as fh: value = fh.read(8192) if send_8kb else fh.read() data_parts.extend([six.b("--%s" % boundary), six.b('Content-Disposition: form-data; name="%s"; filename="%s"' % (name, filename)), six.b("Content-Type: %s" % (mimetypes.guess_type(path)[0] or "application/octet-stream")), six.b(""), value]) data_parts.extend([six.b("--%s--" % boundary), b""]) data = newline.join(data_parts) headers = { "Content-Type": "multipart/form-data; boundary=%s" % boundary, "Content-Length": str(len(data))} return headers, data def request(url, data=None, headers=None, method='GET', use_proxy=True, force=False, last_mod_time=None, timeout=10, validate_certs=True, url_username=None, url_password=None, http_agent=None, force_basic_auth=True, ignore_errors=False): """Issue an HTTP request to a url, retrieving an optional JSON response.""" if headers is None: headers = {"Content-Type": "application/json", "Accept": "application/json"} headers.update({"netapp-client-type": "Ansible-%s" % ansible_version}) if not http_agent: http_agent = "Ansible / %s" % ansible_version try: r = open_url(url=url, data=data, headers=headers, method=method, use_proxy=use_proxy, force=force, last_mod_time=last_mod_time, timeout=timeout, validate_certs=validate_certs, url_username=url_username, url_password=url_password, http_agent=http_agent, force_basic_auth=force_basic_auth) except HTTPError as err: r = err.fp try: raw_data = r.read() if raw_data: data = json.loads(raw_data) else: raw_data = None except Exception: if ignore_errors: pass else: raise Exception(raw_data) resp_code = r.getcode() if resp_code >= 400 and not ignore_errors: raise Exception(resp_code, data) else: return resp_code, data