From 04c6f9426973e2c62ead2469928be4d799fa5e58 Mon Sep 17 00:00:00 2001 From: Chris Archibald Date: Tue, 5 Mar 2019 08:22:31 -0800 Subject: [PATCH] New Module: na_ontap_quotas (#49783) * Revert "changes to clusteR" This reverts commit 33ee1b71e4bc8435fb315762a871f8c4cb6c5f80. * new module na_ontap_quotas * fix file location * Fix author * updates * Revert "Revert "changes to clusteR"" This reverts commit 1c82958764ea38a91d51d3cfe8a85b418df8f0b8. * fix author * Add types --- .../modules/storage/netapp/na_ontap_quotas.py | 344 ++++++++++++++++++ .../storage/netapp/test_na_ontap_quotas.py | 238 ++++++++++++ 2 files changed, 582 insertions(+) create mode 100644 lib/ansible/modules/storage/netapp/na_ontap_quotas.py create mode 100644 test/units/modules/storage/netapp/test_na_ontap_quotas.py diff --git a/lib/ansible/modules/storage/netapp/na_ontap_quotas.py b/lib/ansible/modules/storage/netapp/na_ontap_quotas.py new file mode 100644 index 00000000000..7ecb904ed9d --- /dev/null +++ b/lib/ansible/modules/storage/netapp/na_ontap_quotas.py @@ -0,0 +1,344 @@ +#!/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_quotas +short_description: NetApp ONTAP Quotas +extends_documentation_fragment: + - netapp.na_ontap +version_added: '2.8' +author: NetApp Ansible Team (@carchi8py) +description: +- Set/Modify/Delete quota on ONTAP +options: + state: + description: + - Whether the specified quota should exist or not. + choices: ['present', 'absent'] + default: present + type: str + vserver: + required: true + description: + - Name of the vserver to use. + type: str + volume: + description: + - The name of the volume that the quota resides on. + required: true + type: str + quota_target: + description: + - The quota target of the type specified. + required: true + type: str + qtree: + description: + - Name of the qtree for the quota. + - For user or group rules, it can be the qtree name or "" if no qtree. + - For tree type rules, this field must be "". + default: "" + type: str + type: + description: + - The type of quota rule + choices: ['user', 'group', 'tree'] + required: true + type: str + policy: + description: + - Name of the quota policy from which the quota rule should be obtained. + type: str + set_quota_status: + description: + - Whether the specified volume should have quota status on or off. + type: bool + file_limit: + description: + - The number of files that the target can have. + default: '-' + type: str + disk_limit: + description: + - The amount of disk space that is reserved for the target. + default: '-' + type: str + threshold: + description: + - The amount of disk space the target would have to exceed before a message is logged. + default: '-' + type: str +''' + +EXAMPLES = """ + - name: Add/Set quota + na_ontap_quotas: + state: present + vserver: ansible + volume: ansible + quota_target: /vol/ansible + type: user + policy: ansible + file_limit: 2 + disk_limit: 3 + set_quota_status: True + hostname: "{{ netapp_hostname }}" + username: "{{ netapp_username }}" + password: "{{ netapp_password }}" + - name: modify quota + na_ontap_quotas: + state: present + vserver: ansible + volume: ansible + quota_target: /vol/ansible + type: user + policy: ansible + file_limit: 2 + disk_limit: 3 + threshold: 3 + set_quota_status: False + hostname: "{{ netapp_hostname }}" + username: "{{ netapp_username }}" + password: "{{ netapp_password }}" + - name: Delete quota + na_ontap_quotas: + state: absent + vserver: ansible + volume: ansible + quota_target: /vol/ansible + type: user + policy: ansible + hostname: "{{ netapp_hostname }}" + username: "{{ netapp_username }}" + password: "{{ netapp_password }}" +""" + +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_module import NetAppModule + +HAS_NETAPP_LIB = netapp_utils.has_netapp_lib() + + +class NetAppONTAPQuotas(object): + '''Class with quotas methods''' + + def __init__(self): + + self.argument_spec = netapp_utils.na_ontap_host_argument_spec() + self.argument_spec.update(dict( + state=dict(required=False, choices=['present', 'absent'], default='present'), + vserver=dict(required=True, type='str'), + volume=dict(required=True, type='str'), + quota_target=dict(required=True, type='str'), + qtree=dict(required=False, type='str', default=""), + type=dict(required=True, type='str', choices=['user', 'group', 'tree']), + policy=dict(required=False, type='str'), + set_quota_status=dict(required=False, type='bool'), + file_limit=dict(required=False, type='str', default='-'), + disk_limit=dict(required=False, type='str', default='-'), + threshold=dict(required=False, type='str', default='-') + )) + + 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) + + 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_quota_status(self): + """ + Return details about the quota status + :param: + name : volume name + :return: status of the quota. None if not found. + :rtype: dict + """ + quota_status_get = netapp_utils.zapi.NaElement('quota-status') + quota_status_get.translate_struct({ + 'volume': self.parameters['volume'] + }) + try: + result = self.server.invoke_successfully(quota_status_get, enable_tunneling=True) + except netapp_utils.zapi.NaApiError as error: + self.module.fail_json(msg='Error fetching quotas status info: %s' % to_native(error), + exception=traceback.format_exc()) + if result: + return result['status'] + return None + + def get_quotas(self): + """ + Get quota details + :return: name of volume if quota exists, None otherwise + """ + quota_get = netapp_utils.zapi.NaElement('quota-list-entries-iter') + query = { + 'query': { + 'quota-entry': { + 'volume': self.parameters['volume'], + 'quota-target': self.parameters['quota_target'], + 'quota-type': self.parameters['type'] + } + } + } + quota_get.translate_struct(query) + if self.parameters.get('policy'): + quota_get['query']['quota-entry'].add_new_child('policy', self.parameters['policy']) + try: + result = self.server.invoke_successfully(quota_get, enable_tunneling=True) + except netapp_utils.zapi.NaApiError as error: + self.module.fail_json(msg='Error fetching quotas info: %s' % 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_values = {'volume': result['attributes-list']['quota-entry']['volume'], + 'file_limit': result['attributes-list']['quota-entry']['file-limit'], + 'disk_limit': result['attributes-list']['quota-entry']['disk-limit'], + 'threshold': result['attributes-list']['quota-entry']['threshold']} + return return_values + return None + + def quota_entry_set(self): + """ + Adds a quota entry + """ + options = {'volume': self.parameters['volume'], + 'quota-target': self.parameters['quota_target'], + 'quota-type': self.parameters['type'], + 'qtree': self.parameters['qtree'], + 'file-limit': self.parameters['file_limit'], + 'disk-limit': self.parameters['disk_limit'], + 'threshold': self.parameters['threshold']} + if self.parameters.get('policy'): + options['policy'] = self.parameters['policy'] + set_entry = netapp_utils.zapi.NaElement.create_node_with_children( + 'quota-set-entry', **options) + try: + self.server.invoke_successfully(set_entry, enable_tunneling=True) + except netapp_utils.zapi.NaApiError as error: + self.module.fail_json(msg='Error adding/modifying quota entry %s: %s' + % (self.parameters['volume'], to_native(error)), + exception=traceback.format_exc()) + + def quota_entry_delete(self): + """ + Deletes a quota entry + """ + options = {'volume': self.parameters['volume'], + 'quota-target': self.parameters['quota_target'], + 'quota-type': self.parameters['type'], + 'qtree': self.parameters['qtree']} + set_entry = netapp_utils.zapi.NaElement.create_node_with_children( + 'quota-delete-entry', **options) + if self.parameters.get('policy'): + set_entry.add_new_child('policy', self.parameters['policy']) + try: + self.server.invoke_successfully(set_entry, enable_tunneling=True) + except netapp_utils.zapi.NaApiError as error: + self.module.fail_json(msg='Error deleting quota entry %s: %s' + % (self.parameters['volume'], to_native(error)), + exception=traceback.format_exc()) + + def quota_entry_modify(self, modify_attrs): + """ + Modifies a quota entry + """ + options = {'volume': self.parameters['volume'], + 'quota-target': self.parameters['quota_target'], + 'quota-type': self.parameters['type'], + 'qtree': self.parameters['qtree']} + options.update(modify_attrs) + if self.parameters.get('policy'): + options['policy'] = str(self.parameters['policy']) + modify_entry = netapp_utils.zapi.NaElement.create_node_with_children( + 'quota-modify-entry', **options) + try: + self.server.invoke_successfully(modify_entry, enable_tunneling=True) + except netapp_utils.zapi.NaApiError as error: + self.module.fail_json(msg='Error modifying quota entry %s: %s' + % (self.parameters['volume'], to_native(error)), + exception=traceback.format_exc()) + + def on_or_off_quota(self, status): + """ + on or off quota + """ + quota = netapp_utils.zapi.NaElement.create_node_with_children( + status, **{'volume': self.parameters['volume']}) + try: + self.server.invoke_successfully(quota, + enable_tunneling=True) + except netapp_utils.zapi.NaApiError as error: + self.module.fail_json(msg='Error setting %s for %s: %s' + % (status, self.parameters['volume'], to_native(error)), + exception=traceback.format_exc()) + + def apply(self): + """ + Apply action to quotas + """ + netapp_utils.ems_log_event("na_ontap_quotas", self.server) + modify_quota_status = None + modify_quota = None + current = self.get_quotas() + if 'set_quota_status' in self.parameters: + quota_status = self.get_quota_status() + if quota_status is not None: + quota_status_action = self.na_helper.get_modified_attributes( + {'set_quota_status': True if quota_status == 'on' else False}, self.parameters) + if quota_status_action: + modify_quota_status = 'quota-on' if quota_status_action['set_quota_status'] else 'quota-off' + cd_action = self.na_helper.get_cd_action(current, self.parameters) + if cd_action is None: + modify_quota = self.na_helper.get_modified_attributes(current, self.parameters) + if self.na_helper.changed: + if self.module.check_mode: + pass + else: + if cd_action == 'create': + self.quota_entry_set() + elif cd_action == 'delete': + self.quota_entry_delete() + elif modify_quota is not None: + for key in modify_quota: + modify_quota[key.replace("_", "-")] = modify_quota.pop(key) + self.quota_entry_modify(modify_quota) + if modify_quota_status is not None: + self.on_or_off_quota(modify_quota_status) + self.module.exit_json(changed=self.na_helper.changed) + + +def main(): + '''Execute action''' + quota_obj = NetAppONTAPQuotas() + quota_obj.apply() + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/storage/netapp/test_na_ontap_quotas.py b/test/units/modules/storage/netapp/test_na_ontap_quotas.py new file mode 100644 index 00000000000..3791a5f4d3b --- /dev/null +++ b/test/units/modules/storage/netapp/test_na_ontap_quotas.py @@ -0,0 +1,238 @@ +''' unit tests ONTAP Ansible module: na_ontap_quotas ''' + +from __future__ import print_function +import json +import pytest + +from units.compat import unittest +from units.compat.mock import patch +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_quotas \ + import NetAppONTAPQuotas as my_module + +if not netapp_utils.has_netapp_lib(): + pytestmark = pytest.mark.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 == 'quotas': + xml = self.build_quota_info() + elif self.type == 'quota_fail': + raise netapp_utils.zapi.NaApiError(code='TEST', message="This exception is from the unit test") + self.xml_out = xml + return xml + + @staticmethod + def build_quota_info(): + ''' build xml data for quota-entry ''' + xml = netapp_utils.zapi.NaElement('xml') + data = {'num-records': 1, + 'attributes-list': {'quota-entry': {'volume': 'ansible', + 'file-limit': '-', 'disk-limit': '-', 'threshold': '-'}}, + 'status': 'true'} + xml.translate_struct(data) + 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.onbox = False + + def set_default_args(self): + if self.onbox: + hostname = '10.193.75.3' + username = 'admin' + password = 'netapp1!' + volume = 'ansible' + vserver = 'ansible' + policy = 'ansible' + quota_target = '/vol/ansible' + type = 'user' + else: + hostname = 'hostname' + username = 'username' + password = 'password' + volume = 'ansible' + vserver = 'ansible' + policy = 'ansible' + quota_target = '/vol/ansible' + type = 'user' + return dict({ + 'hostname': hostname, + 'username': username, + 'password': password, + 'volume': volume, + 'vserver': vserver, + 'policy': policy, + 'quota_target': quota_target, + 'type': type + }) + + 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_get_called(self): + ''' test get_quota for non-existent quota''' + set_module_args(self.set_default_args()) + my_obj = my_module() + my_obj.server = self.server + assert my_obj.get_quotas is not None + + def test_ensure_get_called_existing(self): + ''' test get_quota for existing quota''' + set_module_args(self.set_default_args()) + my_obj = my_module() + my_obj.server = MockONTAPConnection(kind='quotas') + assert my_obj.get_quotas() + + @patch('ansible.modules.storage.netapp.na_ontap_quotas.NetAppONTAPQuotas.quota_entry_set') + def test_successful_create(self, quota_entry_set): + ''' creating quota and testing idempotency ''' + data = self.set_default_args() + data.update({'file_limit': '3', + 'disk_limit': '4'}) + # data['file_limit'] = '3' + # data['disk_limit'] = '4' + # data['threshold'] = '4' + set_module_args(data) + my_obj = my_module() + if not self.onbox: + my_obj.server = self.server + with pytest.raises(AnsibleExitJson) as exc: + my_obj.apply() + assert exc.value.args[0]['changed'] + quota_entry_set.assert_called_with() + # to reset na_helper from remembering the previous 'changed' value + set_module_args(self.set_default_args()) + my_obj = my_module() + if not self.onbox: + my_obj.server = MockONTAPConnection('quotas') + with pytest.raises(AnsibleExitJson) as exc: + my_obj.apply() + assert not exc.value.args[0]['changed'] + + @patch('ansible.modules.storage.netapp.na_ontap_quotas.NetAppONTAPQuotas.quota_entry_delete') + def test_successful_delete(self, quota_entry_delete): + ''' deleting quota and testing idempotency ''' + data = self.set_default_args() + data['state'] = 'absent' + set_module_args(data) + my_obj = my_module() + if not self.onbox: + my_obj.server = MockONTAPConnection('quotas') + with pytest.raises(AnsibleExitJson) as exc: + my_obj.apply() + assert exc.value.args[0]['changed'] + quota_entry_delete.assert_called_with() + # to reset na_helper from remembering the previous 'changed' value + my_obj = my_module() + if not self.onbox: + my_obj.server = self.server + with pytest.raises(AnsibleExitJson) as exc: + my_obj.apply() + assert not exc.value.args[0]['changed'] + + def test_successful_modify(self): + ''' modifying quota and testing idempotency ''' + data = self.set_default_args() + data['file_limit'] = '3' + set_module_args(data) + my_obj = my_module() + if not self.onbox: + my_obj.server = MockONTAPConnection('quotas') + with pytest.raises(AnsibleExitJson) as exc: + my_obj.apply() + assert exc.value.args[0]['changed'] + + def test_quota_on_off(self): + ''' quota set on or off ''' + data = self.set_default_args() + data['set_quota_status'] = 'false' + set_module_args(data) + my_obj = my_module() + if not self.onbox: + my_obj.server = MockONTAPConnection('quotas') + with pytest.raises(AnsibleExitJson) as exc: + my_obj.apply() + assert not exc.value.args[0]['changed'] + + 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.onbox: + my_obj.server = MockONTAPConnection('quota_fail') + with pytest.raises(AnsibleFailJson) as exc: + my_obj.get_quota_status() + assert 'Error fetching quotas status info' in exc.value.args[0]['msg'] + with pytest.raises(AnsibleFailJson) as exc: + my_obj.get_quotas() + assert 'Error fetching quotas info' in exc.value.args[0]['msg'] + with pytest.raises(AnsibleFailJson) as exc: + my_obj.quota_entry_set() + assert 'Error adding/modifying quota entry' in exc.value.args[0]['msg'] + with pytest.raises(AnsibleFailJson) as exc: + my_obj.quota_entry_delete() + assert 'Error deleting quota entry' in exc.value.args[0]['msg'] + with pytest.raises(AnsibleFailJson) as exc: + my_obj.quota_entry_modify(module_args) + assert 'Error modifying quota entry' in exc.value.args[0]['msg'] + with pytest.raises(AnsibleFailJson) as exc: + my_obj.on_or_off_quota('quota-on') + assert 'Error setting quota-on for ansible' in exc.value.args[0]['msg']