diff --git a/ansible_collections/netapp/azure/netapp-azure-20.8.0.tar.gz b/ansible_collections/netapp/azure/netapp-azure-20.8.0.tar.gz index 18983528..a8da82d0 100644 Binary files a/ansible_collections/netapp/azure/netapp-azure-20.8.0.tar.gz and b/ansible_collections/netapp/azure/netapp-azure-20.8.0.tar.gz differ diff --git a/ansible_collections/netapp/ontap/README.md b/ansible_collections/netapp/ontap/README.md index af263cce..a3b67bd5 100644 --- a/ansible_collections/netapp/ontap/README.md +++ b/ansible_collections/netapp/ontap/README.md @@ -24,6 +24,10 @@ Join our Slack Channel at [Netapp.io](http://netapp.io/slack) ## 20.11.0 + +### New Modules + - na_ontap_metrocluster_dr_group: Configure a Metrocluster DR group (Supports ONTAP 9.8+) + ### Minor changes - na_ontap_cifs - output `modified` if a modify action is taken. - na_ontap_cluster_peer: optional parameter 'ipspace' added for cluster peer. @@ -42,6 +46,7 @@ Join our Slack Channel at [Netapp.io](http://netapp.io/slack) - na_ontap_info - Use `node-id` as key rather than `current-version`. - na_ontap_ipspace - invalid call in error reporting (double error). - na_ontap_lun - `use_exact_size` to create a lun with the exact given size so that the lun is not rounded up. + - na_ontap_metrocluster: Fix issue where module would fail on waiting for rest api job - na_ontap_software_update - module is not idempotent. ## 20.10.0 diff --git a/ansible_collections/netapp/ontap/plugins/module_utils/netapp.py b/ansible_collections/netapp/ontap/plugins/module_utils/netapp.py index e310b0bb..f58a6efd 100644 --- a/ansible_collections/netapp/ontap/plugins/module_utils/netapp.py +++ b/ansible_collections/netapp/ontap/plugins/module_utils/netapp.py @@ -462,8 +462,11 @@ def __init__(self, module, timeout=60): self.check_required_library() def requires_ontap_9_6(self, module_name): + 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 support REST, and requires ONTAP 9.6 or later.%s" % (module_name, suffix) + return "%s only support REST, and requires ONTAP %s or later.%s" % (module_name, version, suffix) def check_required_library(self): if not HAS_REQUESTS: diff --git a/ansible_collections/netapp/ontap/plugins/modules/na_ontap_metrocluster_dr_group.py b/ansible_collections/netapp/ontap/plugins/modules/na_ontap_metrocluster_dr_group.py new file mode 100644 index 00000000..d8345c3d --- /dev/null +++ b/ansible_collections/netapp/ontap/plugins/modules/na_ontap_metrocluster_dr_group.py @@ -0,0 +1,222 @@ +#!/usr/bin/python +""" +(c) 2020, 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': 'community' +} + +DOCUMENTATION = ''' +module: na_ontap_metrocluster_dr_group +short_description: NetApp ONTAP manage MetroCluster DR Group +extends_documentation_fragment: + - netapp.ontap.netapp.na_ontap +version_added: 20.11.0 +author: NetApp Ansible Team (@carchi8py) +requirements: + - ONTAP >= 9.8 +description: + - Create/Delete MetroCluster DR Group + - Create only supports MCC IP + - Delete supports both MCC IP and MCC FC +options: + state: + choices: ['present', 'absent'] + description: + add or remove DR groups + default: present + type: str + dr_pairs: + description: disaster recovery pairs + type: list + required: true + elements: dict + suboptions: + node_name: + description: + - the name of the main node + required: true + type: str + partner_node_name: + description: + - the name of the main partner node + required: true + type: str + partner_cluster_name: + description: + - The name of the partner cluster + required: true + type: str +''' + +EXAMPLES = ''' +- + name: Manage MetroCluster DR group + hosts: localhost + collections: + - netapp.ontap + vars: + login: &login + hostname: "{{ hostname }}" + username: "{{ username }}" + password: "{{ password }}" + https: True + validate_certs: False + tasks: + - name: Create MetroCluster DR group + na_ontap_metrocluster_dr_group: + <<: *login + dr_pairs: + - partner_name: carchi_cluster3_01 + node_name: carchi_cluster1_01 + partner_cluster_name: carchi_cluster3 + - name: Delete MetroCluster DR group + na_ontap_metrocluster_dr_group: + <<: *login + dr_pairs: + - partner_name: carchi_cluster3_01 + node_name: carchi_cluster1_01 + state: absent + partner_cluster_name: carchi_cluster3 +''' + +RETURN = ''' +''' + +from ansible.module_utils.basic import AnsibleModule +import ansible_collections.netapp.ontap.plugins.module_utils.netapp as netapp_utils +from ansible_collections.netapp.ontap.plugins.module_utils.netapp_module import NetAppModule +from ansible_collections.netapp.ontap.plugins.module_utils.netapp import OntapRestAPI + + +class NetAppONTAPMetroClusterDRGroup(object): + def __init__(self): + self.argument_spec = netapp_utils.na_ontap_host_argument_spec() + self.argument_spec.update(dict( + state=dict(choices=['present', 'absent'], default='present'), + dr_pairs=dict(required=True, type='list', elements='dict', options=dict( + node_name=dict(required=True, type='str'), + partner_node_name=dict(required=True, type='str') + )), + partner_cluster_name=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) + self.rest_api = OntapRestAPI(self.module) + self.use_rest = self.rest_api.is_rest() + + if not self.use_rest: + self.module.fail_json(msg=self.rest_api.requires_ontap_version('na_ontap_metrocluster_dr_group', + version='9.8')) + + def get_dr_group(self): + return_attrs = None + for pair in self.parameters['dr_pairs']: + api = 'cluster/metrocluster/dr-groups' + options = {'fields': '*', + 'dr_pairs.node.name': pair['node_name'], + 'dr_pairs.partner.name': pair['partner_node_name'], + 'partner_cluster.name': self.parameters['partner_cluster_name']} + message, error = self.rest_api.get(api, options) + if error: + self.module.fail_json(msg=error) + if 'records' in message and message['num_records'] == 0: + continue + elif 'records' not in message or message['num_records'] != 1: + error = "Unexpected response from %s: %s" % (api, repr(message)) + self.module.fail_json(msg=error) + record = message['records'][0] + return_attrs = { + 'partner_cluster_name': record['partner_cluster']['name'], + 'dr_pairs': [], + 'id': record['id'] + } + for dr_pair in record['dr_pairs']: + return_attrs['dr_pairs'].append({'node_name': dr_pair['node']['name'], 'partner_node_name': dr_pair['partner']['name']}) + # if we have an return_dr_id we don't need to loop anymore + break + return return_attrs + + def get_dr_group_ids_from_nodes(self): + delete_ids = [] + for pair in self.parameters['dr_pairs']: + api = 'cluster/metrocluster/nodes' + options = {'fields': '*', + 'node.name': pair['node_name']} + message, error = self.rest_api.get(api, options) + if error: + self.module.fail_json(msg=error) + if 'records' in message and message['num_records'] == 0: + continue + elif 'records' not in message or message['num_records'] != 1: + error = "Unexpected response from %s: %s" % (api, repr(message)) + self.module.fail_json(msg=error) + record = message['records'][0] + if int(record['dr_group_id']) not in delete_ids: + delete_ids.append(int(record['dr_group_id'])) + return delete_ids + + def create_dr_group(self): + api = 'cluster/metrocluster/dr-groups' + dr_pairs = [] + for pair in self.parameters['dr_pairs']: + dr_pairs.append({'node': {'name': pair['node_name']}, + 'partner': {'name': pair['partner_node_name']}}) + partner_cluster = {'name': self.parameters['partner_cluster_name']} + data = {'dr_pairs': dr_pairs, 'partner_cluster': partner_cluster} + message, error = self.rest_api.post(api, data) + if error is not None: + self.module.fail_json(msg="%s" % error) + message, error = self.rest_api.wait_on_job(message['job']) + if error: + self.module.fail_json(msg="%s" % error) + + def delete_dr_groups(self, dr_ids): + for dr_id in dr_ids: + api = 'cluster/metrocluster/dr-groups/' + str(dr_id) + message, error = self.rest_api.delete(api) + if error: + self.module.fail_json(msg=error) + message, error = self.rest_api.wait_on_job(message['job']) + if error: + self.module.fail_json(msg="%s" % error) + + def apply(self): + current = self.get_dr_group() + delete_ids = None + cd_action = self.na_helper.get_cd_action(current, self.parameters) + if cd_action is None and current is None and self.parameters['state'] == 'absent': + # check if there is some FC group to delete + delete_ids = self.get_dr_group_ids_from_nodes() + if delete_ids: + cd_action = 'delete' + self.na_helper.changed = True + elif cd_action == 'delete': + delete_ids = [current['id']] + if cd_action and not self.module.check_mode: + if cd_action == 'create': + self.create_dr_group() + if cd_action == 'delete': + self.delete_dr_groups(delete_ids) + self.module.exit_json(changed=self.na_helper.changed) + + +def main(): + obj = NetAppONTAPMetroClusterDRGroup() + obj.apply() + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/netapp/ontap/tests/unit/plugins/modules/test_na_ontap_metrocluster_dr_group.py b/ansible_collections/netapp/ontap/tests/unit/plugins/modules/test_na_ontap_metrocluster_dr_group.py new file mode 100644 index 00000000..df349da4 --- /dev/null +++ b/ansible_collections/netapp/ontap/tests/unit/plugins/modules/test_na_ontap_metrocluster_dr_group.py @@ -0,0 +1,196 @@ +# (c) 2020, 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_metrocluster ''' + +from __future__ import (absolute_import, division, print_function) + +__metaclass__ = type + +import json +import pytest + +from ansible_collections.netapp.ontap.tests.unit.compat import unittest +from ansible_collections.netapp.ontap.tests.unit.compat.mock import patch, Mock +from ansible.module_utils import basic +from ansible.module_utils._text import to_bytes + +from ansible_collections.netapp.ontap.plugins.modules.na_ontap_metrocluster_dr_group \ + import NetAppONTAPMetroClusterDRGroup as mcc_dr_pairs_module # module under test + +# REST API canned responses when mocking send_request +SRR = { + # common responses + 'is_rest': (200, {}, None), + 'is_zapi': (400, {}, "Unreachable"), + 'empty_good': (200, {}, None), + 'end_of_sequence': (500, None, "Unexpected call to send_request"), + 'generic_error': (400, None, "Expected error"), + # module specific responses + 'get_mcc_dr_pair_with_no_results': (200, {'records': [], 'num_records': 0}, None), + 'get_mcc_dr_pair_with_results': (200, {'records': [{'partner_cluster': {'name': 'rha2-b2b1_siteB'}, + 'dr_pairs': [{'node': {'name': 'rha17-a2'}, + 'partner': {'name': 'rha17-b2'}}, + {'node': {'name': 'rha17-b2'}, + 'partner': {'name': 'rha17-b1'}}], + 'id': '2'}], + 'num_records': 1}, None), + 'mcc_dr_pair_post': (200, {'job': { + 'uuid': 'fde79888-692a-11ea-80c2-005056b39fe7', + '_links': { + 'self': { + 'href': '/api/cluster/jobs/fde79888-692a-11ea-80c2-005056b39fe7'}}} + }, None), + 'get_mcc_dr_node': (200, {'records': [{'dr_group_id': '1'}], 'num_records': 1}, None), + 'get_mcc_dr_node_none': (200, {'records': [], 'num_records': 0}, None), + 'job': (200, { + "uuid": "cca3d070-58c6-11ea-8c0c-005056826c14", + "description": "POST /api/cluster/metrocluster", + "state": "success", + "message": "There are not enough disks in Pool1.", + "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" + } + } + }, 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""" + + +class AnsibleFailJson(Exception): + """Exception class to be raised by module.fail_json and caught by the test case""" + + +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 TestMyModule(unittest.TestCase): + """ Unit tests for na_ontap_metrocluster """ + + 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.mock_mcc_dr_pair = { + 'partner_cluster_name': 'rha2-b2b1_siteB', + 'node_name': 'rha17-a2', + 'partner_node_name': 'rha17-b2', + 'node_name2': 'rha17-b2', + 'partner_node_name2': 'rha17-b1' + + } + + def mock_args(self): + return { + 'dr_pairs': [{ + 'node_name': self.mock_mcc_dr_pair['node_name'], + 'partner_node_name': self.mock_mcc_dr_pair['partner_node_name'], + }, { + 'node_name': self.mock_mcc_dr_pair['node_name2'], + 'partner_node_name': self.mock_mcc_dr_pair['partner_node_name2'], + }], + 'partner_cluster_name': self.mock_mcc_dr_pair['partner_cluster_name'], + 'hostname': 'test_host', + 'username': 'test_user', + 'password': 'test_pass!' + } + + def get_alias_mock_object(self): + alias_obj = mcc_dr_pairs_module() + return alias_obj + + @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request') + def test_rest_successful_create(self, mock_request): + """Test successful rest create""" + data = self.mock_args() + set_module_args(data) + mock_request.side_effect = [ + SRR['is_rest'], + SRR['get_mcc_dr_pair_with_no_results'], + SRR['get_mcc_dr_pair_with_no_results'], + SRR['mcc_dr_pair_post'], + SRR['job'], + SRR['empty_good'], + SRR['end_of_sequence'] + ] + with pytest.raises(AnsibleExitJson) as exc: + self.get_alias_mock_object().apply() + assert exc.value.args[0]['changed'] + + @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request') + def test_rest_create_idempotency(self, mock_request): + """Test rest create idempotency""" + data = self.mock_args() + set_module_args(data) + mock_request.side_effect = [ + SRR['is_rest'], + SRR['get_mcc_dr_pair_with_results'], + SRR['empty_good'], + SRR['end_of_sequence'] + ] + with pytest.raises(AnsibleExitJson) as exc: + self.get_alias_mock_object().apply() + assert not exc.value.args[0]['changed'] + + @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request') + def test_rest_successful_delete(self, mock_request): + """Test successful rest delete""" + data = self.mock_args() + data['state'] = 'absent' + set_module_args(data) + mock_request.side_effect = [ + SRR['is_rest'], + SRR['get_mcc_dr_pair_with_results'], + SRR['mcc_dr_pair_post'], + SRR['job'], + SRR['empty_good'], + SRR['end_of_sequence'] + ] + with pytest.raises(AnsibleExitJson) as exc: + self.get_alias_mock_object().apply() + assert exc.value.args[0]['changed'] + + @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request') + def test_rest_delete_idempotency(self, mock_request): + """Test rest delete idempotency""" + data = self.mock_args() + data['state'] = 'absent' + set_module_args(data) + mock_request.side_effect = [ + SRR['is_rest'], + SRR['get_mcc_dr_pair_with_no_results'], + SRR['get_mcc_dr_pair_with_no_results'], + SRR['get_mcc_dr_node_none'], + SRR['get_mcc_dr_node_none'], + SRR['job'], + SRR['empty_good'], + SRR['end_of_sequence'] + ] + with pytest.raises(AnsibleExitJson) as exc: + self.get_alias_mock_object().apply() + assert not exc.value.args[0]['changed']