From c6a9534d6199b9499fa1331c1ef890400f8c3fd7 Mon Sep 17 00:00:00 2001 From: Chris Archibald Date: Thu, 31 Jan 2019 10:43:54 -0800 Subject: [PATCH] Rewrite of na_ontap_cluster and unit tests. (#49780) * changes to clusteR * Revert "changes to clusteR" This reverts commit 33ee1b71e4bc8435fb315762a871f8c4cb6c5f80. * ontap cluster changes --- .../storage/netapp/na_ontap_cluster.py | 148 ++++++++----- .../storage/netapp/test_na_ontap_cluster.py | 198 ++++++++++++++++++ 2 files changed, 298 insertions(+), 48 deletions(-) create mode 100644 test/units/modules/storage/netapp/test_na_ontap_cluster.py diff --git a/lib/ansible/modules/storage/netapp/na_ontap_cluster.py b/lib/ansible/modules/storage/netapp/na_ontap_cluster.py index 8b4d65ba3c..027b693e32 100644 --- a/lib/ansible/modules/storage/netapp/na_ontap_cluster.py +++ b/lib/ansible/modules/storage/netapp/na_ontap_cluster.py @@ -20,6 +20,7 @@ version_added: '2.6' author: NetApp Ansible Team (@carchi8py) description: - Create or join or apply licenses to ONTAP clusters +- Cluster join can be performed using only one of the parameters, either cluster_name or cluster_ip_address options: state: description: @@ -63,11 +64,17 @@ EXAMPLES = """ - name: Join cluster na_ontap_cluster: state: present - cluster_name: FPaaS-A300 cluster_ip_address: 10.61.184.181 hostname: "{{ netapp_hostname }}" username: "{{ netapp_username }}" password: "{{ netapp_password }}" + - name: Join cluster + na_ontap_cluster: + state: present + cluster_name: FPaaS-A300-01 + hostname: "{{ netapp_hostname }}" + username: "{{ netapp_username }}" + password: "{{ netapp_password }}" """ RETURN = """ @@ -78,10 +85,22 @@ 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_module import NetAppModule HAS_NETAPP_LIB = netapp_utils.has_netapp_lib() +def local_cmp(a, b): + """ + compares with only values and not keys, keys should be the same for both dicts + :param a: dict 1 + :param b: dict 2 + :return: difference of values in both dicts + """ + diff = [key for key in a if a[key] != b[key]] + return len(diff) + + class NetAppONTAPCluster(object): """ object initialize and class methods @@ -102,78 +121,101 @@ class NetAppONTAPCluster(object): supports_check_mode=True, required_together=[ ['license_package', 'node_serial_number'] - ], - mutually_exclusive=[ - ['cluster_name', 'cluster_ip_address'], ] ) - parameters = self.module.params - - # set up state variables - self.state = parameters['state'] - self.cluster_ip_address = parameters['cluster_ip_address'] - self.cluster_name = parameters['cluster_name'] - self.license_code = parameters['license_code'] - self.license_package = parameters['license_package'] - self.node_serial_number = parameters['node_serial_number'] + self.na_helper = NetAppModule() + self.parameters = self.na_helper.set_parameters(self.module.params) 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) + def get_licensing_status(self): + """ + Check licensing status + + :return: package (key) and licensing status (value) + :rtype: dict + """ + license_status = netapp_utils.zapi.NaElement( + 'license-v2-status-list-info') + try: + result = self.server.invoke_successfully(license_status, + enable_tunneling=True) + except netapp_utils.zapi.NaApiError as error: + self.module.fail_json(msg="Error checking license status: %s" % + to_native(error), exception=traceback.format_exc()) + + return_dictionary = {} + license_v2_status = result.get_child_by_name('license-v2-status') + if license_v2_status: + for license_v2_status_info in license_v2_status.get_children(): + package = license_v2_status_info.get_child_content('package') + status = license_v2_status_info.get_child_content('method') + return_dictionary[package] = status + + return return_dictionary + def create_cluster(self): """ Create a cluster """ cluster_create = netapp_utils.zapi.NaElement.create_node_with_children( - 'cluster-create', **{'cluster-name': self.cluster_name}) + 'cluster-create', **{'cluster-name': self.parameters['cluster_name']}) try: self.server.invoke_successfully(cluster_create, enable_tunneling=True) - return True except netapp_utils.zapi.NaApiError as error: # Error 36503 denotes node already being used. if to_native(error.code) == "36503": return False else: self.module.fail_json(msg='Error creating cluster %s: %s' - % (self.cluster_name, to_native(error)), + % (self.parameters['cluster_name'], to_native(error)), exception=traceback.format_exc()) + return True def cluster_join(self): """ Add a node to an existing cluster """ - cluster_add_node = netapp_utils.zapi.NaElement.create_node_with_children( - 'cluster-join', **{'cluster-ip-address': self.cluster_ip_address}) - + if self.parameters.get('cluster_ip_address') is not None: + cluster_add_node = netapp_utils.zapi.NaElement.create_node_with_children( + 'cluster-join', **{'cluster-ip-address': self.parameters['cluster_ip_address']}) + for_fail_attribute = self.parameters.get('cluster_ip_address') + elif self.parameters.get('cluster_name') is not None: + cluster_add_node = netapp_utils.zapi.NaElement.create_node_with_children( + 'cluster-join', **{'cluster-name': self.parameters['cluster_name']}) + for_fail_attribute = self.parameters.get('cluster_name') + else: + return False try: self.server.invoke_successfully(cluster_add_node, enable_tunneling=True) - return True except netapp_utils.zapi.NaApiError as error: # Error 36503 denotes node already being used. if to_native(error.code) == "36503": return False else: self.module.fail_json(msg='Error adding node to cluster %s: %s' - % (self.cluster_name, to_native(error)), + % (for_fail_attribute, to_native(error)), exception=traceback.format_exc()) + return True def license_v2_add(self): """ Apply a license to cluster """ license_add = netapp_utils.zapi.NaElement.create_node_with_children('license-v2-add') - license_add.add_node_with_children('codes', **{'license-code-v2': self.license_code}) + license_add.add_node_with_children('codes', **{'license-code-v2': self.parameters['license_code']}) try: self.server.invoke_successfully(license_add, enable_tunneling=True) except netapp_utils.zapi.NaApiError as error: - self.module.fail_json(msg='Error adding license to the cluster %s: %s' - % (self.cluster_name, to_native(error)), + self.module.fail_json(msg='Error adding license %s: %s' + % (self.parameters['license_code'], to_native(error)), exception=traceback.format_exc()) def license_v2_delete(self): @@ -181,15 +223,23 @@ class NetAppONTAPCluster(object): Delete license from cluster """ license_delete = netapp_utils.zapi.NaElement.create_node_with_children( - 'license-v2-delete', **{'package': self.license_package, - 'serial-number': self.node_serial_number}) + 'license-v2-delete', **{'package': self.parameters['license_package'], + 'serial-number': self.parameters['node_serial_number']}) try: self.server.invoke_successfully(license_delete, enable_tunneling=True) except netapp_utils.zapi.NaApiError as error: - self.module.fail_json(msg='Error deleting license from cluster %s : %s' - % (self.cluster_name, to_native(error)), + self.module.fail_json(msg='Error deleting license : %s' % (to_native(error)), exception=traceback.format_exc()) + def autosupport_log(self): + """ + Autosupport log for cluster + :return: + """ + results = netapp_utils.get_cserver(self.server) + cserver = netapp_utils.setup_na_ontap_zapi(module=self.module, vserver=results) + netapp_utils.ems_log_event("na_ontap_cluster", cserver) + def apply(self): """ Apply action to cluster @@ -197,28 +247,30 @@ class NetAppONTAPCluster(object): property_changed = False create_flag = False join_flag = False - changed = False - if self.state == 'absent': + self.autosupport_log() + license_status = self.get_licensing_status() + + if self.module.check_mode: pass - elif self.state == 'present': # license add, delete - changed = True - - if changed: - if self.module.check_mode: - pass - else: - if self.state == 'present': - if self.cluster_name is not None: - create_flag = self.create_cluster() - if self.cluster_ip_address is not None: - join_flag = self.cluster_join() - if self.license_code is not None: - self.license_v2_add() - property_changed = True - if self.license_package is not None and self.node_serial_number is not None: + else: + if self.parameters.get('state') == 'present': + if self.parameters.get('cluster_name') is not None: + create_flag = self.create_cluster() + if not create_flag: + join_flag = self.cluster_join() + if self.parameters.get('license_code') is not None: + self.license_v2_add() + property_changed = True + if self.parameters.get('license_package') is not None and\ + self.parameters.get('node_serial_number') is not None: + if license_status.get(str(self.parameters.get('license_package')).lower()) != 'none': self.license_v2_delete() property_changed = True + if property_changed: + new_license_status = self.get_licensing_status() + if local_cmp(license_status, new_license_status) == 0: + property_changed = False changed = property_changed or create_flag or join_flag self.module.exit_json(changed=changed) @@ -227,8 +279,8 @@ def main(): """ Create object and call apply """ - rule_obj = NetAppONTAPCluster() - rule_obj.apply() + cluster_obj = NetAppONTAPCluster() + cluster_obj.apply() if __name__ == '__main__': diff --git a/test/units/modules/storage/netapp/test_na_ontap_cluster.py b/test/units/modules/storage/netapp/test_na_ontap_cluster.py new file mode 100644 index 0000000000..d365b38915 --- /dev/null +++ b/test/units/modules/storage/netapp/test_na_ontap_cluster.py @@ -0,0 +1,198 @@ +# (c) 2018, NetApp, Inc +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +''' unit tests ONTAP Ansible module: na_ontap_cluster ''' + +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_cluster \ + import NetAppONTAPCluster as my_module # module under test + +if not netapp_utils.has_netapp_lib(): + pytestmark = pytest.skip('skipping as missing required netapp_lib') + + +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): + ''' save arguments ''' + self.type = kind + 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.type == 'cluster': + xml = self.build_cluster_info() + elif self.type == 'cluster_fail': + raise netapp_utils.zapi.NaApiError(code='TEST', message="This exception is from the unit test") + self.xml_out = xml + return xml + + def autosupport_log(self): + ''' mock autosupport log''' + return None + + @staticmethod + def build_cluster_info(): + ''' build xml data for cluster-info ''' + xml = netapp_utils.zapi.NaElement('xml') + data = {'license-v2-status': {'package': 'cifs', 'method': 'site'}} + xml.translate_struct(data) + print(xml.to_string()) + return xml + + +class TestMyModule(unittest.TestCase): + ''' a group of related Unit Tests ''' + + 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) + self.server = MockONTAPConnection() + self.use_vsim = False + + def set_default_args(self): + if self.use_vsim: + hostname = '10.193.77.37' + username = 'admin' + password = 'netapp1!' + license_package = 'CIFS' + node_serial_number = '123' + license_code = 'AAA' + cluster_name = 'abc' + else: + hostname = '10.193.77.37' + username = 'admin' + password = 'netapp1!' + license_package = 'CIFS' + node_serial_number = '123' + cluster_ip_address = '0.0.0.0' + license_code = 'AAA' + cluster_name = 'abc' + return dict({ + 'hostname': hostname, + 'username': username, + 'password': password, + 'license_package': license_package, + 'node_serial_number': node_serial_number, + 'license_code': license_code, + 'cluster_name': cluster_name, + 'cluster_ip_address': cluster_ip_address + }) + + def test_module_fail_when_required_args_missing(self): + ''' required arguments are reported as errors ''' + with pytest.raises(AnsibleFailJson) as exc: + set_module_args({}) + my_module() + print('Info: %s' % exc.value.args[0]['msg']) + + def test_ensure_license_get_called(self): + ''' fetching details of license ''' + set_module_args(self.set_default_args()) + my_obj = my_module() + my_obj.server = self.server + license_get = my_obj.get_licensing_status() + print('Info: test_license_get: %s' % repr(license_get)) + assert not bool(license_get) + + def test_ensure_apply_for_cluster_called(self): + ''' creating license and checking idempotency ''' + module_args = {} + module_args.update(self.set_default_args()) + set_module_args(module_args) + my_obj = my_module() + my_obj.autosupport_log = Mock(return_value=None) + if not self.use_vsim: + my_obj.server = self.server + with pytest.raises(AnsibleExitJson) as exc: + my_obj.apply() + print('Info: test_cluster_apply: %s' % repr(exc.value)) + assert exc.value.args[0]['changed'] + if not self.use_vsim: + my_obj.server = MockONTAPConnection('cluster') + with pytest.raises(AnsibleExitJson) as exc: + my_obj.apply() + print('Info: test_cluster_apply: %s' % repr(exc.value)) + assert exc.value.args[0]['changed'] + + @patch('ansible.modules.storage.netapp.na_ontap_cluster.NetAppONTAPCluster.create_cluster') + def test_cluster_create_called(self, cluster_create): + ''' creating cluster''' + module_args = {} + module_args.update(self.set_default_args()) + set_module_args(module_args) + my_obj = my_module() + my_obj.autosupport_log = Mock(return_value=None) + if not self.use_vsim: + my_obj.server = MockONTAPConnection('cluster') + with pytest.raises(AnsibleExitJson) as exc: + my_obj.apply() + print('Info: test_cluster_apply: %s' % repr(exc.value)) + cluster_create.assert_called_with() + + def test_if_all_methods_catch_exception(self): + module_args = {} + module_args.update(self.set_default_args()) + set_module_args(module_args) + my_obj = my_module() + if not self.use_vsim: + my_obj.server = MockONTAPConnection('cluster_fail') + with pytest.raises(AnsibleFailJson) as exc: + my_obj.get_licensing_status() + assert 'Error checking license status' in exc.value.args[0]['msg'] + with pytest.raises(AnsibleFailJson) as exc: + my_obj.create_cluster() + assert 'Error creating cluster' in exc.value.args[0]['msg'] + with pytest.raises(AnsibleFailJson) as exc: + my_obj.cluster_join() + assert 'Error adding node to cluster' in exc.value.args[0]['msg'] + with pytest.raises(AnsibleFailJson) as exc: + my_obj.license_v2_add() + assert 'Error adding license' in exc.value.args[0]['msg'] + with pytest.raises(AnsibleFailJson) as exc: + my_obj.license_v2_delete() + assert 'Error deleting license' in exc.value.args[0]['msg']