From 8bf069114f3ba116688be0c4ec036ea5d1d4fe27 Mon Sep 17 00:00:00 2001 From: Chris Archibald Date: Tue, 16 Jul 2019 10:40:56 -0700 Subject: [PATCH] New Module: Vscan Enable (#57953) * new module * fix netapp.py * updates * fixes --- lib/ansible/module_utils/netapp.py | 125 +++++++++- .../modules/storage/netapp/na_ontap_vscan.py | 178 ++++++++++++++ .../storage/netapp/test_na_ontap_vscan.py | 231 ++++++++++++++++++ 3 files changed, 532 insertions(+), 2 deletions(-) create mode 100644 lib/ansible/modules/storage/netapp/na_ontap_vscan.py create mode 100644 test/units/modules/storage/netapp/test_na_ontap_vscan.py diff --git a/lib/ansible/module_utils/netapp.py b/lib/ansible/module_utils/netapp.py index ddf2bb8a432..6826a2047bc 100644 --- a/lib/ansible/module_utils/netapp.py +++ b/lib/ansible/module_utils/netapp.py @@ -31,7 +31,7 @@ import json import os from pprint import pformat -from ansible.module_utils.basic import AnsibleModule +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 @@ -48,6 +48,12 @@ try: except ImportError: HAS_NETAPP_LIB = False +try: + import requests + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + import ssl try: from urlparse import urlparse, urlunparse @@ -496,7 +502,7 @@ def ems_log_event(source, server, name="Ansible", id="12345", version=ansible_ve server.invoke_successfully(ems_log, True) -def get_cserver(server): +def get_cserver_zapi(server): vserver_info = zapi.NaElement('vserver-get-iter') query_details = zapi.NaElement.create_node_with_children('vserver-info', **{'vserver-type': 'admin'}) query = zapi.NaElement('query') @@ -507,3 +513,118 @@ def get_cserver(server): attribute_list = result.get_child_by_name('attributes-list') vserver_list = attribute_list.get_child_by_name('vserver-info') return vserver_list.get_child_content('vserver-name') + + +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 + + +class OntapRestAPI(object): + def __init__(self, module, timeout=60): + self.module = module + self.username = self.module.params['username'] + self.password = self.module.params['password'] + self.hostname = self.module.params['hostname'] + self.verify = self.module.params['validate_certs'] + self.timeout = timeout + self.url = 'https://' + self.hostname + '/api/' + self.errors = list() + self.debug_logs = list() + self.check_required_library() + + def check_required_library(self): + if not HAS_REQUESTS: + self.module.fail_json(msg=missing_required_lib('requests')) + + def send_request(self, method, api, params, json=None, return_status_code=False): + ''' send http request and process reponse, including error conditions ''' + url = self.url + api + status_code = None + content = None + json_dict = None + json_error = None + error_details = None + + def get_json(response): + ''' extract json, and error message if present ''' + try: + json = response.json() + except ValueError: + return None, None + error = json.get('error') + return json, error + + try: + response = requests.request(method, url, verify=self.verify, auth=(self.username, self.password), params=params, timeout=self.timeout, json=json) + content = response.content # for debug purposes + status_code = response.status_code + # If the response was successful, no Exception will be raised + response.raise_for_status() + json_dict, json_error = get_json(response) + except requests.exceptions.HTTPError as err: + junk, json_error = get_json(response) + 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 + self.log_debug(status_code, content) + if return_status_code: + return status_code, error_details + return json_dict, error_details + + def get(self, api, params): + method = 'GET' + return self.send_request(method, api, params) + + def post(self, api, data, params=None): + method = 'POST' + return self.send_request(method, api, params, json=data) + + def patch(self, api, data, params=None): + method = 'PATCH' + return self.send_request(method, api, params, json=data) + + def delete(self, api, data, params=None): + method = 'DELETE' + return self.send_request(method, api, params, json=data) + + def is_rest(self): + method = 'HEAD' + api = 'cluster/software' + status_code, junk = self.send_request(method, api, params=None, return_status_code=True) + if status_code == 200: + return True + return False + + def log_error(self, status_code, message): + self.errors.append(message) + self.debug_logs.append((status_code, message)) + + def log_debug(self, status_code, content): + self.debug_logs.append((status_code, content)) diff --git a/lib/ansible/modules/storage/netapp/na_ontap_vscan.py b/lib/ansible/modules/storage/netapp/na_ontap_vscan.py new file mode 100644 index 00000000000..4eee6329ec2 --- /dev/null +++ b/lib/ansible/modules/storage/netapp/na_ontap_vscan.py @@ -0,0 +1,178 @@ +#!/usr/bin/python + +# (c) 2018-2019, NetApp Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'certified'} + +DOCUMENTATION = ''' +module: na_ontap_vscan +short_description: NetApp ONTAP Vscan enable/disable. +extends_documentation_fragment: + - netapp.na_ontap +version_added: '2.9' +author: NetApp Ansible Team (@carchi8py) +notes: +- on demand task, on_access_policy and scanner_pools must be set up before running this module +description: +- Enable and Disable Vscan +options: + enable: + description: + - Whether to enable to disable a Vscan + type: bool + default: True + + vserver: + description: + - the name of the data vserver to use. + required: true + type: str +''' + +EXAMPLES = """ + - name: Enable Vscan + na_ontap_vscan: + enable: True + username: '{{ netapp_username }}' + password: '{{ netapp_password }}' + hostname: '{{ netapp_hostname }}' + vserver: trident_svm + + - name: Disable Vscan + na_ontap_vscan: + enable: False + username: '{{ netapp_username }}' + password: '{{ netapp_password }}' + hostname: '{{ netapp_hostname }}' + vserver: trident_svm +""" + +RETURN = """ + +""" + +import traceback + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +import ansible.module_utils.netapp as netapp_utils +from ansible.module_utils.netapp import OntapRestAPI +from ansible.module_utils.netapp_module import NetAppModule + +HAS_NETAPP_LIB = netapp_utils.has_netapp_lib() + + +class NetAppOntapVscan(object): + def __init__(self): + self.use_rest = False + self.argument_spec = netapp_utils.na_ontap_host_argument_spec() + self.argument_spec.update(dict( + enable=dict(type='bool', default=True), + vserver=dict(required=True, type='str'), + )) + self.module = AnsibleModule( + argument_spec=self.argument_spec, + supports_check_mode=True + ) + self.na_helper = NetAppModule() + self.parameters = self.na_helper.set_parameters(self.module.params) + + # API should be used for ONTAP 9.6 or higher, Zapi for lower version + self.restApi = OntapRestAPI(self.module) + if self.restApi.is_rest(): + self.use_rest = True + else: + if HAS_NETAPP_LIB is False: + self.module.fail_json(msg="the python NetApp-Lib module is required") + else: + self.server = netapp_utils.setup_na_ontap_zapi(module=self.module, vserver=self.parameters['vserver']) + + def get_vscan(self): + if self.use_rest: + params = {'fields': 'svm,enabled', + "svm.name": self.parameters['vserver']} + api = "protocols/vscan" + message, error = self.restApi.get(api, params) + if error: + self.module.fail_json(msg=error) + return message['records'][0] + else: + vscan_status_iter = netapp_utils.zapi.NaElement('vscan-status-get-iter') + vscan_status_info = netapp_utils.zapi.NaElement('vscan-status-info') + vscan_status_info.add_new_child('vserver', self.parameters['vserver']) + query = netapp_utils.zapi.NaElement('query') + query.add_child_elem(vscan_status_info) + vscan_status_iter.add_child_elem(query) + try: + result = self.server.invoke_successfully(vscan_status_iter, True) + except netapp_utils.zapi.NaApiError as error: + self.module.fail_json(msg='Error getting Vscan info for Vserver %s: %s' % + (self.parameters['vserver'], to_native(error)), + exception=traceback.format_exc()) + if result.get_child_by_name('num-records') and int(result.get_child_content('num-records')) >= 1: + return result.get_child_by_name('attributes-list').get_child_by_name('vscan-status-info') + + def enable_vscan(self, uuid=None): + if self.use_rest: + params = {"svm.name": self.parameters['vserver']} + data = {"enabled": self.parameters['enable']} + api = "protocols/vscan/" + uuid + message, error = self.restApi.patch(api, data, params) + if error is not None: + self.module.fail_json(msg=error) + # self.module.fail_json(msg=repr(self.restApi.errors), log=repr(self.restApi.debug_logs)) + else: + vscan_status_obj = netapp_utils.zapi.NaElement("vscan-status-modify") + vscan_status_obj.add_new_child('is-vscan-enabled', str(self.parameters['enable'])) + try: + self.server.invoke_successfully(vscan_status_obj, True) + except netapp_utils.zapi.NaApiError as error: + self.module.fail_json(msg="Error Enable/Disabling Vscan: %s" % to_native(error), exception=traceback.format_exc()) + + def asup_log(self): + if self.use_rest: + # TODO: logging for Rest + return + else: + # Either we are using ZAPI, or REST failed when it should not + try: + netapp_utils.ems_log_event("na_ontap_vscan", self.server) + except Exception: + # TODO: we may fail to connect to REST or ZAPI, the line below shows REST issues only + # self.module.fail_json(msg=repr(self.restApi.errors), log=repr(self.restApi.debug_logs)) + pass + + def apply(self): + changed = False + self.asup_log() + current = self.get_vscan() + if self.use_rest: + if current['enabled'] != self.parameters['enable']: + if not self.module.check_mode: + self.enable_vscan(current['svm']['uuid']) + changed = True + else: + if current.get_child_content('is-vscan-enabled') != str(self.parameters['enable']).lower(): + if not self.module.check_mode: + self.enable_vscan() + changed = True + self.module.exit_json(changed=changed) + + +def main(): + """ + Execute action from playbook + """ + command = NetAppOntapVscan() + command.apply() + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/storage/netapp/test_na_ontap_vscan.py b/test/units/modules/storage/netapp/test_na_ontap_vscan.py new file mode 100644 index 00000000000..ba2fb2e73a1 --- /dev/null +++ b/test/units/modules/storage/netapp/test_na_ontap_vscan.py @@ -0,0 +1,231 @@ +# (c) 2018, NetApp, Inc +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +''' unit tests for Ansible module: na_ontap_vscan''' + +from __future__ import print_function +import json +import pytest + +from units.compat import unittest +from units.compat.mock import patch, Mock +from ansible.module_utils import basic +from ansible.module_utils._text import to_bytes +import ansible.module_utils.netapp as netapp_utils + +from ansible.modules.storage.netapp.na_ontap_vscan \ + import NetAppOntapVscan as vscan_module # module under test + +if not netapp_utils.has_netapp_lib(): + pytestmark = pytest.mark.skip('skipping as missing required netapp_lib') +HAS_NETAPP_ZAPI_MSG = "pip install netapp_lib is required" + + +# REST API canned responses when mocking send_request +SRR = { + # common responses + 'is_rest': (200, None), + 'is_zapi': (400, "Unreachable"), + 'empty_good': ({}, None), + 'end_of_sequence': (None, "Ooops, the UT needs one more SRR response"), + 'generic_error': (None, "Expected error"), + # module specific responses + 'enabled': ({'records': [{'enabled': True, 'svm': {'uuid': 'testuuid'}}]}, None), + 'disabled': ({'records': [{'enabled': False, 'svm': {'uuid': 'testuuid'}}]}, None), +} + + +def set_module_args(args): + """prepare arguments so that they will be picked up during module creation""" + args = json.dumps({'ANSIBLE_MODULE_ARGS': args}) + basic._ANSIBLE_ARGS = to_bytes(args) # pylint: disable=protected-access + + +class AnsibleExitJson(Exception): + """Exception class to be raised by module.exit_json and caught by the test case""" + pass + + +class AnsibleFailJson(Exception): + """Exception class to be raised by module.fail_json and caught by the test case""" + pass + + +def exit_json(*args, **kwargs): # pylint: disable=unused-argument + """function to patch over exit_json; package return data into an exception""" + if 'changed' not in kwargs: + kwargs['changed'] = False + raise AnsibleExitJson(kwargs) + + +def fail_json(*args, **kwargs): # pylint: disable=unused-argument + """function to patch over fail_json; package return data into an exception""" + kwargs['failed'] = True + raise AnsibleFailJson(kwargs) + + +class MockONTAPConnection(object): + ''' mock server connection to ONTAP host ''' + + def __init__(self, kind=None, data=None): + ''' save arguments ''' + self.kind = kind + self.params = data + self.xml_in = None + self.xml_out = None + + def invoke_successfully(self, xml, enable_tunneling): # pylint: disable=unused-argument + ''' mock invoke_successfully returning xml data ''' + self.xml_in = xml + if self.kind == 'enable': + xml = self.build_vscan_status_info(self.params) + self.xml_out = xml + return xml + + @staticmethod + def build_vscan_status_info(status): + xml = netapp_utils.zapi.NaElement('xml') + attributes = {'num-records': 1, + 'attributes-list': {'vscan-status-info': {'is-vscan-enabled': status}}} + xml.translate_struct(attributes) + return xml + + +class TestMyModule(unittest.TestCase): + ''' Unit tests for na_ontap_job_schedule ''' + + def setUp(self): + self.mock_module_helper = patch.multiple(basic.AnsibleModule, + exit_json=exit_json, + fail_json=fail_json) + self.mock_module_helper.start() + self.addCleanup(self.mock_module_helper.stop) + + def mock_args(self): + return { + 'enable': False, + 'vserver': 'vserver', + 'hostname': 'test', + 'username': 'test_user', + 'password': 'test_pass!' + } + + def get_vscan_mock_object(self, type='zapi', kind=None, status=None): + vscan_obj = vscan_module() + if type == 'zapi': + if kind is None: + vscan_obj.server = MockONTAPConnection() + else: + vscan_obj.server = MockONTAPConnection(kind=kind, data=status) + # For rest, mocking is achieved through side_effect + return vscan_obj + + def test_module_fail_when_required_args_missing(self): + ''' required arguments are reported as errors ''' + with pytest.raises(AnsibleFailJson) as exc: + set_module_args({}) + vscan_module() + print('Info: %s' % exc.value.args[0]['msg']) + + def test_successfully_enable(self): + data = self.mock_args() + data['enable'] = True + set_module_args(data) + with pytest.raises(AnsibleExitJson) as exc: + self.get_vscan_mock_object('zapi', 'enable', 'false').apply() + assert exc.value.args[0]['changed'] + + def test_idempotently_enable(self): + data = self.mock_args() + data['enable'] = True + set_module_args(data) + with pytest.raises(AnsibleExitJson) as exc: + self.get_vscan_mock_object('zapi', 'enable', 'true').apply() + assert not exc.value.args[0]['changed'] + + def test_successfully_disable(self): + data = self.mock_args() + data['enable'] = False + set_module_args(data) + with pytest.raises(AnsibleExitJson) as exc: + self.get_vscan_mock_object('zapi', 'enable', 'true').apply() + assert exc.value.args[0]['changed'] + + def test_idempotently_disable(self): + data = self.mock_args() + data['enable'] = False + set_module_args(data) + with pytest.raises(AnsibleExitJson) as exc: + self.get_vscan_mock_object('zapi', 'enable', 'false').apply() + assert not exc.value.args[0]['changed'] + + @patch('ansible.module_utils.netapp.OntapRestAPI.send_request') + def test_rest_error(self, mock_request): + data = self.mock_args() + set_module_args(data) + mock_request.side_effect = [ + SRR['is_rest'], + SRR['generic_error'], + SRR['end_of_sequence'] + ] + with pytest.raises(AnsibleFailJson) as exc: + self.get_vscan_mock_object(type='rest').apply() + assert exc.value.args[0]['msg'] == SRR['generic_error'][1] + + @patch('ansible.module_utils.netapp.OntapRestAPI.send_request') + def test_rest_successly_enable(self, mock_request): + data = self.mock_args() + data['enable'] = True + set_module_args(data) + mock_request.side_effect = [ + SRR['is_rest'], + SRR['disabled'], + SRR['empty_good'], + SRR['end_of_sequence'] + ] + with pytest.raises(AnsibleExitJson) as exc: + self.get_vscan_mock_object(type='rest').apply() + assert exc.value.args[0]['changed'] + + @patch('ansible.module_utils.netapp.OntapRestAPI.send_request') + def test_rest_idempotently_enable(self, mock_request): + data = self.mock_args() + data['enable'] = True + set_module_args(data) + mock_request.side_effect = [ + SRR['is_rest'], + SRR['enabled'], + SRR['end_of_sequence'] + ] + with pytest.raises(AnsibleExitJson) as exc: + self.get_vscan_mock_object(type='rest').apply() + assert not exc.value.args[0]['changed'] + + @patch('ansible.module_utils.netapp.OntapRestAPI.send_request') + def test_rest_successly_disable(self, mock_request): + data = self.mock_args() + data['enable'] = False + set_module_args(data) + mock_request.side_effect = [ + SRR['is_rest'], + SRR['enabled'], + SRR['empty_good'], + SRR['end_of_sequence'] + ] + with pytest.raises(AnsibleExitJson) as exc: + self.get_vscan_mock_object(type='rest').apply() + assert exc.value.args[0]['changed'] + + @patch('ansible.module_utils.netapp.OntapRestAPI.send_request') + def test_rest_idempotently_disable(self, mock_request): + data = self.mock_args() + data['enable'] = False + set_module_args(data) + mock_request.side_effect = [ + SRR['is_rest'], + SRR['disabled'], + SRR['end_of_sequence'] + ] + with pytest.raises(AnsibleExitJson) as exc: + self.get_vscan_mock_object(type='rest').apply() + assert not exc.value.args[0]['changed']