diff --git a/lib/ansible/modules/storage/netapp/netapp_e_iscsi_interface.py b/lib/ansible/modules/storage/netapp/netapp_e_iscsi_interface.py new file mode 100644 index 00000000000..54939c75c9e --- /dev/null +++ b/lib/ansible/modules/storage/netapp/netapp_e_iscsi_interface.py @@ -0,0 +1,398 @@ +#!/usr/bin/python + +# (c) 2018, 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: netapp_e_iscsi_interface +short_description: NetApp E-Series manage iSCSI interface configuration +description: + - Configure settings of an E-Series iSCSI interface +version_added: '2.7' +author: Michael Price (@lmprice) +extends_documentation_fragment: + - netapp.eseries +options: + controller: + description: + - The controller that owns the port you want to configure. + - Controller names are presented alphabetically, with the first controller as A, + the second as B, and so on. + - Current hardware models have either 1 or 2 available controllers, but that is not a guaranteed hard + limitation and could change in the future. + required: yes + choices: + - A + - B + name: + description: + - The channel of the port to modify the configuration of. + - The list of choices is not necessarily comprehensive. It depends on the number of ports + that are available in the system. + - The numerical value represents the number of the channel (typically from left to right on the HIC), + beginning with a value of 1. + required: yes + aliases: + - channel + state: + description: + - When enabled, the provided configuration will be utilized. + - When disabled, the IPv4 configuration will be cleared and IPv4 connectivity disabled. + choices: + - enabled + - disabled + default: enabled + address: + description: + - The IPv4 address to assign to the interface. + - Should be specified in xx.xx.xx.xx form. + - Mutually exclusive with I(config_method=dhcp) + subnet_mask: + description: + - The subnet mask to utilize for the interface. + - Should be specified in xx.xx.xx.xx form. + - Mutually exclusive with I(config_method=dhcp) + gateway: + description: + - The IPv4 gateway address to utilize for the interface. + - Should be specified in xx.xx.xx.xx form. + - Mutually exclusive with I(config_method=dhcp) + config_method: + description: + - The configuration method type to use for this interface. + - dhcp is mutually exclusive with I(address), I(subnet_mask), and I(gateway). + choices: + - dhcp + - static + default: dhcp + mtu: + description: + - The maximum transmission units (MTU), in bytes. + - This allows you to configure a larger value for the MTU, in order to enable jumbo frames + (any value > 1500). + - Generally, it is necessary to have your host, switches, and other components not only support jumbo + frames, but also have it configured properly. Therefore, unless you know what you're doing, it's best to + leave this at the default. + default: 1500 + aliases: + - max_frame_size + log_path: + description: + - A local path to a file to be used for debug logging + required: no +notes: + - Check mode is supported. + - The interface settings are applied synchronously, but changes to the interface itself (receiving a new IP address + via dhcp, etc), can take seconds or minutes longer to take effect. + - This module will not be useful/usable on an E-Series system without any iSCSI interfaces. + - This module requires a Web Services API version of >= 1.3. +""" + +EXAMPLES = """ + - name: Configure the first port on the A controller with a static IPv4 address + netapp_e_iscsi_interface: + name: "1" + controller: "A" + config_method: static + address: "192.168.1.100" + subnet_mask: "255.255.255.0" + gateway: "192.168.1.1" + ssid: "1" + api_url: "10.1.1.1:8443" + api_username: "admin" + api_password: "myPass" + + - name: Disable ipv4 connectivity for the second port on the B controller + netapp_e_iscsi_interface: + name: "2" + controller: "B" + state: disabled + ssid: "{{ ssid }}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + + - name: Enable jumbo frames for the first 4 ports on controller A + netapp_e_iscsi_interface: + name: "{{ item | int }}" + controller: "A" + state: enabled + mtu: 9000 + config_method: dhcp + ssid: "{{ ssid }}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + loop: + - 1 + - 2 + - 3 + - 4 +""" + +RETURN = """ +msg: + description: Success message + returned: on success + type: string + sample: The interface settings have been updated. +enabled: + description: + - Indicates whether IPv4 connectivity has been enabled or disabled. + - This does not necessarily indicate connectivity. If dhcp was enabled without a dhcp server, for instance, + it is unlikely that the configuration will actually be valid. + returned: on success + sample: True + type: bool +""" +import json +import logging +from pprint import pformat +import re + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.netapp import request, eseries_host_argument_spec +from ansible.module_utils._text import to_native + +HEADERS = { + "Content-Type": "application/json", + "Accept": "application/json", +} + + +class IscsiInterface(object): + def __init__(self): + argument_spec = eseries_host_argument_spec() + argument_spec.update(dict( + controller=dict(type='str', required=True, choices=['A', 'B']), + name=dict(type='int', aliases=['channel']), + state=dict(type='str', required=False, default='enabled', choices=['enabled', 'disabled']), + address=dict(type='str', required=False), + subnet_mask=dict(type='str', required=False), + gateway=dict(type='str', required=False), + config_method=dict(type='str', required=False, default='dhcp', choices=['dhcp', 'static']), + mtu=dict(type='int', default=1500, required=False, aliases=['max_frame_size']), + log_path=dict(type='str', required=False), + )) + + required_if = [ + ["config_method", "static", ["address", "subnet_mask"]], + ] + + self.module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True, required_if=required_if, ) + args = self.module.params + self.controller = args['controller'] + self.name = args['name'] + self.mtu = args['mtu'] + self.state = args['state'] + self.address = args['address'] + self.subnet_mask = args['subnet_mask'] + self.gateway = args['gateway'] + self.config_method = args['config_method'] + + self.ssid = args['ssid'] + self.url = args['api_url'] + self.creds = dict(url_password=args['api_password'], + validate_certs=args['validate_certs'], + url_username=args['api_username'], ) + + self.check_mode = self.module.check_mode + self.post_body = dict() + self.controllers = list() + + log_path = args['log_path'] + + # logging setup + self._logger = logging.getLogger(self.__class__.__name__) + + if log_path: + logging.basicConfig( + level=logging.DEBUG, filename=log_path, filemode='w', + format='%(relativeCreated)dms %(levelname)s %(module)s.%(funcName)s:%(lineno)d\n %(message)s') + + if not self.url.endswith('/'): + self.url += '/' + + if self.mtu < 1500 or self.mtu > 9000: + self.module.fail_json(msg="The provided mtu is invalid, it must be > 1500 and < 9000 bytes.") + + if self.config_method == 'dhcp' and any([self.address, self.subnet_mask, self.gateway]): + self.module.fail_json( + 'A config_method of dhcp is mutually exclusive with the address, subnet_mask, and gateway options.') + + # A relatively primitive regex to validate that the input is formatted like a valid ip address + address_regex = re.compile(r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}') + + if self.address and not address_regex.match(self.address): + self.module.fail_json("An invalid ip address was provided for address.") + + if self.subnet_mask and not address_regex.match(self.subnet_mask): + self.module.fail_json("An invalid ip address was provided for subnet_mask.") + + if self.gateway and not address_regex.match(self.gateway): + self.module.fail_json("An invalid ip address was provided for gateway.") + + @property + def interfaces(self): + ifaces = list() + try: + (rc, ifaces) = request(self.url + 'storage-systems/%s/graph/xpath-filter?query=/controller/hostInterfaces' + % self.ssid, headers=HEADERS, **self.creds) + except Exception as err: + self.module.fail_json( + msg="Failed to retrieve defined host interfaces. Array Id [%s]. Error [%s]." + % (self.ssid, to_native(err))) + + # Filter out non-iSCSI interfaces + ifaces = [iface['iscsi'] for iface in ifaces if iface['interfaceType'] == 'iscsi'] + + return ifaces + + def get_controllers(self): + """Retrieve a mapping of controller labels to their references + { + 'A': '070000000000000000000001', + 'B': '070000000000000000000002', + } + :return: the controllers defined on the system + """ + controllers = list() + try: + (rc, controllers) = request(self.url + 'storage-systems/%s/graph/xpath-filter?query=/controller/id' + % self.ssid, headers=HEADERS, **self.creds) + except Exception as err: + self.module.fail_json( + msg="Failed to retrieve controller list! Array Id [%s]. Error [%s]." + % (self.ssid, to_native(err))) + + controllers.sort() + + controllers_dict = {} + i = ord('A') + for controller in controllers: + label = chr(i) + controllers_dict[label] = controller + i += 1 + + return controllers_dict + + def fetch_target_interface(self): + interfaces = self.interfaces + + for iface in interfaces: + if iface['channel'] == self.name and self.controllers[self.controller] == iface['controllerId']: + return iface + + channels = sorted(set((str(iface['channel'])) for iface in interfaces + if self.controllers[self.controller] == iface['controllerId'])) + + self.module.fail_json(msg="The requested channel of %s is not valid. Valid channels include: %s." + % (self.name, ", ".join(channels))) + + def make_update_body(self, target_iface): + body = dict(iscsiInterface=target_iface['id']) + update_required = False + + self._logger.info("Requested state=%s.", self.state) + self._logger.info("config_method: current=%s, requested=%s", + target_iface['ipv4Data']['ipv4AddressConfigMethod'], self.config_method) + + if self.state == 'enabled': + settings = dict() + if not target_iface['ipv4Enabled']: + update_required = True + settings['ipv4Enabled'] = [True] + if self.mtu != target_iface['interfaceData']['ethernetData']['maximumFramePayloadSize']: + update_required = True + settings['maximumFramePayloadSize'] = [self.mtu] + if self.config_method == 'static': + ipv4Data = target_iface['ipv4Data']['ipv4AddressData'] + + if ipv4Data['ipv4Address'] != self.address: + update_required = True + settings['ipv4Address'] = [self.address] + if ipv4Data['ipv4SubnetMask'] != self.subnet_mask: + update_required = True + settings['ipv4SubnetMask'] = [self.subnet_mask] + if self.gateway is not None and ipv4Data['ipv4GatewayAddress'] != self.gateway: + update_required = True + settings['ipv4GatewayAddress'] = [self.gateway] + + if target_iface['ipv4Data']['ipv4AddressConfigMethod'] != 'configStatic': + update_required = True + settings['ipv4AddressConfigMethod'] = ['configStatic'] + + elif (target_iface['ipv4Data']['ipv4AddressConfigMethod'] != 'configDhcp'): + update_required = True + settings.update(dict(ipv4Enabled=[True], + ipv4AddressConfigMethod=['configDhcp'])) + body['settings'] = settings + + else: + if target_iface['ipv4Enabled']: + update_required = True + body['settings'] = dict(ipv4Enabled=[False]) + + self._logger.info("Update required ?=%s", update_required) + self._logger.info("Update body: %s", pformat(body)) + + return update_required, body + + def update(self): + self.controllers = self.get_controllers() + if self.controller not in self.controllers: + self.module.fail_json(msg="The provided controller name is invalid. Valid controllers: %s." + % ", ".join(self.controllers.keys())) + + iface_before = self.fetch_target_interface() + update_required, body = self.make_update_body(iface_before) + if update_required and not self.check_mode: + try: + url = (self.url + + 'storage-systems/%s/symbol/setIscsiInterfaceProperties' % self.ssid) + (rc, result) = request(url, method='POST', data=json.dumps(body), headers=HEADERS, timeout=300, + ignore_errors=True, **self.creds) + # We could potentially retry this a few times, but it's probably a rare enough case (unless a playbook + # is cancelled mid-flight), that it isn't worth the complexity. + if rc == 422 and result['retcode'] in ['busy', '3']: + self.module.fail_json( + msg="The interface is currently busy (probably processing a previously requested modification" + " request). This operation cannot currently be completed. Array Id [%s]. Error [%s]." + % (self.ssid, result)) + # Handle authentication issues, etc. + elif rc != 200: + self.module.fail_json( + msg="Failed to modify the interface! Array Id [%s]. Error [%s]." + % (self.ssid, to_native(result))) + self._logger.debug("Update request completed successfully.") + # This is going to catch cases like a connection failure + except Exception as err: + self.module.fail_json( + msg="Connection failure: we failed to modify the interface! Array Id [%s]. Error [%s]." + % (self.ssid, to_native(err))) + + iface_after = self.fetch_target_interface() + + self.module.exit_json(msg="The interface settings have been updated.", changed=update_required, + enabled=iface_after['ipv4Enabled']) + + def __call__(self, *args, **kwargs): + self.update() + + +def main(): + iface = IscsiInterface() + iface() + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/netapp_eseries_iscsi_interface/aliases b/test/integration/targets/netapp_eseries_iscsi_interface/aliases new file mode 100644 index 00000000000..d314d14a748 --- /dev/null +++ b/test/integration/targets/netapp_eseries_iscsi_interface/aliases @@ -0,0 +1,10 @@ +# This test is not enabled by default, but can be utilized by defining required variables in integration_config.yml +# Example integration_config.yml: +# --- +#netapp_e_api_host: 10.113.1.111:8443 +#netapp_e_api_username: admin +#netapp_e_api_password: myPass +#netapp_e_ssid: 1 + +unsupported +netapp/eseries diff --git a/test/integration/targets/netapp_eseries_iscsi_interface/tasks/main.yml b/test/integration/targets/netapp_eseries_iscsi_interface/tasks/main.yml new file mode 100644 index 00000000000..996354c8860 --- /dev/null +++ b/test/integration/targets/netapp_eseries_iscsi_interface/tasks/main.yml @@ -0,0 +1 @@ +- include_tasks: run.yml diff --git a/test/integration/targets/netapp_eseries_iscsi_interface/tasks/run.yml b/test/integration/targets/netapp_eseries_iscsi_interface/tasks/run.yml new file mode 100644 index 00000000000..05cf39a5fb7 --- /dev/null +++ b/test/integration/targets/netapp_eseries_iscsi_interface/tasks/run.yml @@ -0,0 +1,448 @@ +--- +# Test code for the netapp_e_iscsi_interface module +# (c) 2018, NetApp, Inc +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +# *********************** +# *** Local test data *** +# *********************** +- name: NetApp Test iSCSI Interface module + fail: + msg: 'Please define netapp_e_api_username, netapp_e_api_password, netapp_e_api_host, and netapp_e_ssid.' + when: netapp_e_api_username is undefined or netapp_e_api_password is undefined + or netapp_e_api_host is undefined or netapp_e_ssid is undefined + vars: + credentials: &creds + api_url: "https://{{ netapp_e_api_host }}/devmgr/v2" + api_username: "{{ netapp_e_api_username }}" + api_password: "{{ netapp_e_api_password }}" + ssid: "{{ netapp_e_ssid }}" + validate_certs: no + array: &array + subnet: 255.255.255.0 + gateway: 10.10.10.1 + A: + - channel: 1 + max_frame_size: 1500 + - channel: 2 + max_frame_size: 2000 + - channel: 3 + max_frame_size: 9000 + - channel: 4 + max_frame_size: 1500 + - channel: 5 + max_frame_size: 2000 + - channel: 6 + max_frame_size: 9000 + B: + - channel: 1 + max_frame_size: 9000 + - channel: 2 + max_frame_size: 1500 + - channel: 3 + max_frame_size: 2000 + - channel: 4 + max_frame_size: 9000 + - channel: 5 + max_frame_size: 1500 + - channel: 6 + max_frame_size: 2000 + + +# *************************************************** +# *** Ensure python jmespath package is installed *** +# *************************************************** +- name: Ensure that jmespath is installed + pip: + name: jmespath + state: enabled + register: jmespath +- fail: + msg: "Restart playbook, the jmespath package was installed and is need for the playbook's execution." + when: jmespath.changed + + +# ************************************ +# *** Set local playbook test data *** +# ************************************ +- name: set credentials + set_fact: + credentials: *creds +- name: set array + set_fact: + array: *array + +- name: Show some debug information + debug: + msg: "Using user={{ credentials.api_username }} on server={{ credentials.api_url }}." + + +# ***************************************** +# *** Disable all controller A channels *** +# ***************************************** +- name: Disable all controller A ports + netapp_e_iscsi_interface: + <<: *creds + controller: "A" + channel: "{{ item.channel }}" + state: disabled + loop: "{{ lookup('list', array.A) }}" + +# Delay to give time for the asynchronous symbol call has complete +- pause: + seconds: 30 + +# Request all controller's iscsi host interface information +- name: Collect iscsi port information + uri: + url: "{{ xpath_filter_url }}?query=controller/hostInterfaces//iscsi" + user: "{{ credentials.api_username }}" + password: "{{ credentials.api_password }}" + body_format: json + validate_certs: no + register: result + vars: + xpath_filter_url: "{{ credentials.api_url }}/storage-systems/{{ credentials.ssid }}/graph/xpath-filter" + +# Extract controller A's port information from the iscsi host interfaces list +# Note: min filter is used because there are only two controller ids and the smaller corresponds with controller A +- name: Get controller A's controllerId + set_fact: + controller_a_id: "{{ result | json_query('json[*].controllerId') | min }}" + +# Collect all port information associated with controller A +- name: Get controller A's port information + set_fact: + controller_a: "{{ result | json_query(controller_a_query) }}" + vars: + controller_a_query: "json[?controllerId=='{{ controller_a_id }}']" + +# Confirm controller A's ports are disabled +- name: Verify all controller A ports are disabled + assert: + that: "{{ item.ipv4Enabled == false }}" + msg: "Controller A, channel {{ item.channel }} is not disabled" + loop: "{{ controller_a }}" + + +# ***************************************** +# *** Disable all controller B channels *** +# ***************************************** +- name: Disable all controller B ports + netapp_e_iscsi_interface: + <<: *creds + controller: "B" + channel: "{{ item.channel }}" + state: disabled + loop: "{{ lookup('list', array.B) }}" + +# Delay to give time for the asynchronous symbol call has complete +- pause: + seconds: 30 + +# Request all controller's iscsi host interface information +- name: Collect iscsi port information + uri: + url: "{{ xpath_filter_url }}?query=controller/hostInterfaces//iscsi" + user: "{{ credentials.api_username }}" + password: "{{ credentials.api_password }}" + body_format: json + validate_certs: no + register: result + vars: + xpath_filter_url: "{{ credentials.api_url }}/storage-systems/{{ credentials.ssid }}/graph/xpath-filter" + +# Extract controller B's port information from the iscsi host interfaces list +# Note: min filter is used because there are only two controller ids and the smaller corresponds with controller B +- name: Get controller B's controllerId + set_fact: + controller_b_id: "{{ result | json_query('json[*].controllerId') | max }}" + +# Collect all port information associated with controller B +- name: Get controller B's port information + set_fact: + controller_b: "{{ result | json_query(controller_b_query) }}" + vars: + controller_b_query: "json[?controllerId=='{{ controller_b_id }}']" + +# Confirm controller B's ports are disabled +- name: Verify all controller B ports are disabled + assert: + that: "{{ item.ipv4Enabled == false }}" + msg: "Controller B, channel {{ item.channel }} is not disabled" + loop: "{{ controller_b }}" + + +# ***************************************************** +# *** Configure all controller A's ports statically *** +# ***************************************************** +- name: Configure controller A's port to use a static configuration method + netapp_e_iscsi_interface: + <<: *creds + controller: "A" + channel: "{{ item.channel }}" + state: enabled + config_method: static + address: "{{ array.gateway.split('.')[:3] | join('.') }}.{{ item.channel }}" + subnet_mask: "{{ array.subnet }}" + gateway: "{{ array.gateway }}" + max_frame_size: "{{ item.max_frame_size }}" + loop: "{{ lookup('list', array.A) }}" + +# Delay to give time for the asynchronous symbol call has complete +- pause: + seconds: 30 + +# Request a list of iscsi host interfaces +- name: Collect array information + uri: + url: "{{ xpath_filter }}?query=controller/hostInterfaces//iscsi" + user: "{{ credentials.api_username }}" + password: "{{ credentials.api_password }}" + body_format: json + validate_certs: no + register: result + vars: + xpath_filter: "{{ credentials.api_url }}/storage-systems/{{ credentials.ssid }}/graph/xpath-filter" + +# Extract controller A's port information from the iscsi host interfaces list +# Note: min filter is used because there are only two controller ids and the smaller corresponds with controller A +- name: Get controller A's controllerId + set_fact: + controller_a_id: "{{ result | json_query('json[*].controllerId') | min }}" + +# Compile any iscsi port information associated with controller A +- name: Get controller A's port information + set_fact: + controller_a: "{{ result | json_query(controller_a_query) }}" + vars: + controller_a_query: "json[?controllerId=='{{ controller_a_id }}']" + +# Confirm that controller A ports are statically defined with the expected MTU, gateway, subnet and ipv4 address +- name: Verify expected controller A's port configuration + assert: + that: "{{ item[0].channel != item[1].channel or + ( item[0].ipv4Data.ipv4AddressConfigMethod == 'configStatic' and + item[0].interfaceData.ethernetData.maximumFramePayloadSize == item[1].max_frame_size and + item[0].ipv4Data.ipv4AddressData.ipv4GatewayAddress == array.gateway and + item[0].ipv4Data.ipv4AddressData.ipv4SubnetMask == array.subnet and + item[0].ipv4Data.ipv4AddressData.ipv4Address == partial_address + item[1].channel | string ) }}" + msg: "Failed to configure controller A, channel {{ item[0].channel }}" + loop: "{{ query('nested', lookup('list', controller_a), lookup('list', array.A) ) }}" + vars: + partial_address: "{{ array.gateway.split('.')[:3] | join('.') + '.' }}" + + +# ******************************************************************************************* +# *** Configure controller B's channels for dhcp and specific frame maximum payload sizes *** +# ******************************************************************************************* +- name: Configure controller B's ports to use dhcp with different MTU + netapp_e_iscsi_interface: + <<: *creds + controller: "B" + channel: "{{ item.channel }}" + state: enabled + config_method: dhcp + max_frame_size: "{{ item.max_frame_size }}" + loop: "{{ lookup('list', array.B) }}" + +# Delay to give time for the asynchronous symbol call has complete +- pause: + seconds: 30 + +# request a list of iscsi host interfaces +- name: Collect array information + uri: + url: "{{ xpath_filter_url }}?query=controller/hostInterfaces//iscsi" + user: "{{ credentials.api_username }}" + password: "{{ credentials.api_password }}" + body_format: json + validate_certs: no + register: result + vars: + xpath_filter_url: "{{ credentials.api_url }}/storage-systems/{{ credentials.ssid }}/graph/xpath-filter" + +# Extract controller B's port information from the iscsi host interfaces list +# Note: max filter is used because there are only two controller ids and the larger corresponds with controller B +- name: Get controller B's controllerId + set_fact: + controller_b_id: "{{ result | json_query('json[*].controllerId') | max }}" +- name: Get controller B port information list + set_fact: + controller_b: "{{ result | json_query(controller_b_query) }}" + vars: + controller_b_query: "json[?controllerId=='{{ controller_b_id }}']" + +# Using a nested loop of array information and expected information, verify that each channel has the appropriate max +# frame payload size and is configured for dhcp +- name: Verify expected controller B's port configuration + assert: + that: "{{ item[0].channel != item[1].channel or + ( item[0].ipv4Data.ipv4AddressConfigMethod == 'configDhcp' and + item[0].interfaceData.ethernetData.maximumFramePayloadSize == item[1].max_frame_size ) }}" + msg: > + Failed to configure controller channel {{ item[0].channel }} for dhcp + and/or maximum frame size of {{ item[1].max_frame_size }}! + loop: "{{ query('nested', lookup('list', controller_b), lookup('list', array.B)) }}" + + +# ******************************************************************************************* +# *** Configure controller A's channels for dhcp and specific frame maximum payload sizes *** +# ******************************************************************************************* +- name: Configure controller A's ports to use dhcp with different MTU + netapp_e_iscsi_interface: + <<: *creds + controller: "A" + channel: "{{ item.channel }}" + state: enabled + config_method: dhcp + max_frame_size: "{{ item.max_frame_size }}" + loop: "{{ lookup('list', array.A) }}" + +# Delay to give time for the asynchronous symbol call has complete +- pause: + seconds: 30 + +# Request a list of iscsi host interfaces +- name: Collect array information + uri: + url: "{{ xpath_filter_url }}?query=controller/hostInterfaces//iscsi" + user: "{{ credentials.api_username }}" + password: "{{ credentials.api_password }}" + body_format: json + validate_certs: no + register: result + vars: + xpath_filter_url: "{{ credentials.api_url }}/storage-systems/{{ credentials.ssid }}/graph/xpath-filter" + +# Extract controller A's port information from the iscsi host interfaces list +# Note: min filter is used because there are only two controller ids and the larger corresponds with controller A +- name: Get controller A's controllerId + set_fact: + controller_a_id: "{{ result | json_query('json[*].controllerId') | min }}" +- name: Get controller A port information list + set_fact: + controller_a: "{{ result | json_query(controller_a_query) }}" + vars: + controller_a_query: "json[?controllerId=='{{ controller_a_id }}']" + +# Using a nested loop of array information and expected information, verify that each channel has the appropriate max +# frame payload size and is configured for dhcp +- name: Verify expected controller A's port configuration + assert: + that: "{{ item[0].channel != item[1].channel or + ( item[0].ipv4Data.ipv4AddressConfigMethod == 'configDhcp' and + item[0].interfaceData.ethernetData.maximumFramePayloadSize == item[1].max_frame_size ) }}" + msg: > + Failed to configure controller channel {{ item[0].channel }} for dhcp + and/or maximum frame size of {{ item[1].max_frame_size }}! + loop: "{{ query('nested', lookup('list', controller_a), lookup('list', array.A)) }}" + + +# ***************************************************** +# *** Configure all controller B's ports statically *** +# ***************************************************** +- name: Configure controller B's ports to use a static configuration method + netapp_e_iscsi_interface: + <<: *creds + controller: "B" + channel: "{{ item.channel }}" + state: enabled + config_method: static + address: "{{ array.gateway.split('.')[:3] | join('.') }}.{{ item.channel }}" + subnet_mask: "{{ array.subnet }}" + gateway: "{{ array.gateway }}" + max_frame_size: "{{ item.max_frame_size }}" + loop: "{{ lookup('list', array.B) }}" + +# Delay to give time for the asynchronous symbol call has complete +- pause: + seconds: 30 + +# request a list of iscsi host interfaces +- name: Collect array information + uri: + url: "{{ xpath_filter }}?query=controller/hostInterfaces//iscsi" + user: "{{ credentials.api_username }}" + password: "{{ credentials.api_password }}" + body_format: json + validate_certs: no + register: result + vars: + xpath_filter: "{{ credentials.api_url }}/storage-systems/{{ credentials.ssid }}/graph/xpath-filter" + +# Extract controller B's port information from the iscsi host interfaces list +# Note: min filter is used because there are only two controller ids and the smaller corresponds with controller B +- name: Get controller B's controllerId + set_fact: + controller_b_id: "{{ result | json_query('json[*].controllerId') | max }}" + +# Compile any iscsi port information associated with controller B +- name: Get controller B's port information + set_fact: + controller_b: "{{ result | json_query(controller_b_query) }}" + vars: + controller_b_query: "json[?controllerId=='{{ controller_b_id }}']" + +# Confirm that controller B ports are statically defined with the expected MTU, gateway, subnet and ipv4 address +- name: Verify expected controller B's port configuration + assert: + that: "{{ item[0].channel != item[1].channel or + ( item[0].ipv4Data.ipv4AddressConfigMethod == 'configStatic' and + item[0].interfaceData.ethernetData.maximumFramePayloadSize == item[1].max_frame_size and + item[0].ipv4Data.ipv4AddressData.ipv4GatewayAddress == array.gateway and + item[0].ipv4Data.ipv4AddressData.ipv4SubnetMask == array.subnet and + item[0].ipv4Data.ipv4AddressData.ipv4Address == partial_address + item[1].channel | string ) }}" + msg: "Failed to configure controller B, channel {{ item[0].channel }}" + loop: "{{ query('nested', lookup('list', controller_b), lookup('list', array.B) ) }}" + vars: + partial_address: "{{ array.gateway.split('.')[:3] | join('.') + '.' }}" + + +# ************************************** +# *** Disable all controller B ports *** +# ************************************** +- name: Disable all controller B's ports + netapp_e_iscsi_interface: + <<: *creds + controller: "B" + channel: "{{ item.channel }}" + state: disabled + loop: "{{ lookup('list', array.B) }}" + +# Delay to give time for the asynchronous symbol call has complete +- pause: + seconds: 30 + +# Request controller iscsi host interface information +- name: Collect iscsi port information + uri: + url: "{{ xpath_filter_url }}?query=controller/hostInterfaces//iscsi" + user: "{{ credentials.api_username }}" + password: "{{ credentials.api_password }}" + body_format: json + validate_certs: no + register: result + vars: + xpath_filter_url: "{{ credentials.api_url }}/storage-systems/{{ credentials.ssid }}/graph/xpath-filter" + +# Extract controller A's port information from the iscsi host interfaces list +# Note: min filter is used because there are only two controller ids and the smaller corresponds with controller B +- name: Get controller B's controllerId + set_fact: + controller_b_id: "{{ result | json_query('json[*].controllerId') | max }}" + +# Compile any iscsi port information associated with controller B +- name: Get controller B's port information + set_fact: + controller_b: "{{ result | json_query(controller_b_query) }}" + vars: + controller_b_query: "json[?controllerId=='{{ controller_b_id }}']" + +# Confirm that all of controller B's ports are disabled +- name: Verify all controller B ports are disabled + assert: + that: "{{ item.ipv4Enabled == false }}" + msg: "Controller B, channel {{ item.channel }} is not disabled" + loop: "{{ controller_b }}" diff --git a/test/units/modules/storage/netapp/test_netapp_e_iscsi_interface.py b/test/units/modules/storage/netapp/test_netapp_e_iscsi_interface.py new file mode 100644 index 00000000000..93dd6f718a4 --- /dev/null +++ b/test/units/modules/storage/netapp/test_netapp_e_iscsi_interface.py @@ -0,0 +1,255 @@ +# (c) 2018, NetApp Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from mock import MagicMock + +from ansible.module_utils import basic, netapp +from ansible.modules.storage.netapp import netapp_e_host +from ansible.modules.storage.netapp.netapp_e_host import Host +from ansible.modules.storage.netapp.netapp_e_iscsi_interface import IscsiInterface +from units.modules.utils import AnsibleExitJson, AnsibleFailJson, ModuleTestCase, set_module_args + +__metaclass__ = type +import unittest +import mock +import pytest +import json +from ansible.compat.tests.mock import patch +from ansible.module_utils._text import to_bytes + + +class IscsiInterfaceTest(ModuleTestCase): + REQUIRED_PARAMS = { + 'api_username': 'rw', + 'api_password': 'password', + 'api_url': 'http://localhost', + 'ssid': '1', + 'state': 'disabled', + 'name': 1, + 'controller': 'A', + } + REQ_FUNC = 'ansible.modules.storage.netapp.netapp_e_iscsi_interface.request' + + def _set_args(self, args=None): + module_args = self.REQUIRED_PARAMS.copy() + if args is not None: + module_args.update(args) + set_module_args(module_args) + + def test_validate_params(self): + """Ensure we can pass valid parameters to the module""" + # Provide a range of valid values for each + for controller in ['A', 'B']: + for i in range(1, 10): + for mtu in [1500, 2500, 9000]: + self._set_args(dict( + state='disabled', + name=i, + controller=controller, + mtu=mtu, + )) + iface = IscsiInterface() + + def test_invalid_params(self): + """Ensure that our input validation catches invalid parameters""" + + # Currently a 'C' controller is invalid + self._set_args(dict( + state='disabled', + name=1, + controller="C", + )) + with self.assertRaises(AnsibleFailJson) as result: + iface = IscsiInterface() + + # Each of these mtu values are invalid + for mtu in [500, 1499, 9001]: + self._set_args({ + 'state': 'disabled', + 'name': 1, + 'controller': 'A', + 'mtu': mtu + }) + with self.assertRaises(AnsibleFailJson) as result: + iface = IscsiInterface() + + def test_interfaces(self): + """Validate that we are processing the interface list properly""" + self._set_args() + + interfaces = [ + dict(interfaceType='iscsi', + iscsi=dict()), + dict(interfaceType='iscsi', + iscsi=dict()), + dict(interfaceType='fc', ) + ] + + # Ensure we filter out anything without an interfaceType of iscsi + expected = [iface['iscsi'] for iface in interfaces if iface['interfaceType'] == 'iscsi'] + + # We expect a single call to the API: retrieve the list of interfaces from the objectGraph. + with mock.patch(self.REQ_FUNC, return_value=(200, interfaces)): + iface = IscsiInterface() + interfaces = iface.interfaces + self.assertEquals(interfaces, expected) + + def test_interfaces_fail(self): + """Ensure we fail gracefully on an error to retrieve the interfaces""" + self._set_args() + + with self.assertRaises(AnsibleFailJson) as result: + # Simulate a failed call to the API + with mock.patch(self.REQ_FUNC, side_effect=Exception("Failure")): + iface = IscsiInterface() + interfaces = iface.interfaces + + def test_fetch_target_interface_bad_channel(self): + """Ensure we fail correctly when a bad channel is provided""" + self._set_args() + + interfaces = list(dict(channel=1, controllerId='1')) + + with self.assertRaisesRegexp(AnsibleFailJson, r".*?channels include.*"): + with mock.patch.object(IscsiInterface, 'interfaces', return_value=interfaces): + iface = IscsiInterface() + interfaces = iface.fetch_target_interface() + + def test_make_update_body_dhcp(self): + """Ensure the update body generates correctly for a transition from static to dhcp""" + self._set_args(dict(state='enabled', + config_method='dhcp') + ) + + iface = dict(id='1', + ipv4Enabled=False, + ipv4Data=dict(ipv4AddressData=dict(ipv4Address="0.0.0.0", + ipv4SubnetMask="0.0.0.0", + ipv4GatewayAddress="0.0.0.0", ), + ipv4AddressConfigMethod='configStatic', ), + interfaceData=dict(ethernetData=dict(maximumFramePayloadSize=1500, ), ), + ) + + # Test a transition from static to dhcp + inst = IscsiInterface() + update, body = inst.make_update_body(iface) + self.assertTrue(update, msg="An update was expected!") + self.assertEquals(body['settings']['ipv4Enabled'][0], True) + self.assertEquals(body['settings']['ipv4AddressConfigMethod'][0], 'configDhcp') + + def test_make_update_body_static(self): + """Ensure the update body generates correctly for a transition from dhcp to static""" + iface = dict(id='1', + ipv4Enabled=False, + ipv4Data=dict(ipv4AddressConfigMethod='configDhcp', + ipv4AddressData=dict(ipv4Address="0.0.0.0", + ipv4SubnetMask="0.0.0.0", + ipv4GatewayAddress="0.0.0.0", ), ), + interfaceData=dict(ethernetData=dict(maximumFramePayloadSize=1500, ), ), ) + + self._set_args(dict(state='enabled', + config_method='static', + address='10.10.10.10', + subnet_mask='255.255.255.0', + gateway='1.1.1.1')) + + inst = IscsiInterface() + update, body = inst.make_update_body(iface) + self.assertTrue(update, msg="An update was expected!") + self.assertEquals(body['settings']['ipv4Enabled'][0], True) + self.assertEquals(body['settings']['ipv4AddressConfigMethod'][0], 'configStatic') + self.assertEquals(body['settings']['ipv4Address'][0], '10.10.10.10') + self.assertEquals(body['settings']['ipv4SubnetMask'][0], '255.255.255.0') + self.assertEquals(body['settings']['ipv4GatewayAddress'][0], '1.1.1.1') + + CONTROLLERS = dict(A='1', B='2') + + def test_update_bad_controller(self): + """Ensure a bad controller fails gracefully""" + self._set_args(dict(controller='B')) + + inst = IscsiInterface() + with self.assertRaises(AnsibleFailJson) as result: + with mock.patch.object(inst, 'get_controllers', return_value=dict(A='1')) as get_controllers: + inst() + + @mock.patch.object(IscsiInterface, 'get_controllers', return_value=CONTROLLERS) + def test_update(self, get_controllers): + """Validate the good path""" + self._set_args() + + inst = IscsiInterface() + with self.assertRaises(AnsibleExitJson): + with mock.patch(self.REQ_FUNC, return_value=(200, "")) as request: + with mock.patch.object(inst, 'fetch_target_interface', side_effect=[{}, mock.MagicMock()]): + with mock.patch.object(inst, 'make_update_body', return_value=(True, {})): + inst() + request.assert_called_once() + + @mock.patch.object(IscsiInterface, 'get_controllers', return_value=CONTROLLERS) + def test_update_not_required(self, get_controllers): + """Ensure we don't trigger the update if one isn't required or if check mode is enabled""" + self._set_args() + + # make_update_body will report that no change is required, so we should see no call to the API. + inst = IscsiInterface() + with self.assertRaises(AnsibleExitJson) as result: + with mock.patch(self.REQ_FUNC, return_value=(200, "")) as request: + with mock.patch.object(inst, 'fetch_target_interface', side_effect=[{}, mock.MagicMock()]): + with mock.patch.object(inst, 'make_update_body', return_value=(False, {})): + inst() + request.assert_not_called() + self.assertFalse(result.exception.args[0]['changed'], msg="No change was expected.") + + # Since check_mode is enabled, we will run everything normally, but not make a request to the API + # to perform the actual change. + inst = IscsiInterface() + inst.check_mode = True + with self.assertRaises(AnsibleExitJson) as result: + with mock.patch(self.REQ_FUNC, return_value=(200, "")) as request: + with mock.patch.object(inst, 'fetch_target_interface', side_effect=[{}, mock.MagicMock()]): + with mock.patch.object(inst, 'make_update_body', return_value=(True, {})): + inst() + request.assert_not_called() + self.assertTrue(result.exception.args[0]['changed'], msg="A change was expected.") + + @mock.patch.object(IscsiInterface, 'get_controllers', return_value=CONTROLLERS) + def test_update_fail_busy(self, get_controllers): + """Ensure we fail correctly on receiving a busy response from the API.""" + self._set_args() + + inst = IscsiInterface() + with self.assertRaisesRegexp(AnsibleFailJson, r".*?busy.*") as result: + with mock.patch(self.REQ_FUNC, return_value=(422, dict(retcode="3"))) as request: + with mock.patch.object(inst, 'fetch_target_interface', side_effect=[{}, mock.MagicMock()]): + with mock.patch.object(inst, 'make_update_body', return_value=(True, {})): + inst() + request.assert_called_once() + + @mock.patch.object(IscsiInterface, 'get_controllers', return_value=CONTROLLERS) + @mock.patch.object(IscsiInterface, 'make_update_body', return_value=(True, {})) + def test_update_fail(self, get_controllers, make_body): + """Ensure we fail correctly on receiving a normal failure from the API.""" + self._set_args() + + inst = IscsiInterface() + # Test a 422 error with a non-busy status + with self.assertRaisesRegexp(AnsibleFailJson, r".*?Failed to modify.*") as result: + with mock.patch(self.REQ_FUNC, return_value=(422, mock.MagicMock())) as request: + with mock.patch.object(inst, 'fetch_target_interface', side_effect=[{}, mock.MagicMock()]): + inst() + request.assert_called_once() + + # Test a 401 (authentication) error + with self.assertRaisesRegexp(AnsibleFailJson, r".*?Failed to modify.*") as result: + with mock.patch(self.REQ_FUNC, return_value=(401, mock.MagicMock())) as request: + with mock.patch.object(inst, 'fetch_target_interface', side_effect=[{}, mock.MagicMock()]): + inst() + request.assert_called_once() + + # Test with a connection failure + with self.assertRaisesRegexp(AnsibleFailJson, r".*?Connection failure.*") as result: + with mock.patch(self.REQ_FUNC, side_effect=Exception()) as request: + with mock.patch.object(inst, 'fetch_target_interface', side_effect=[{}, mock.MagicMock()]): + inst() + request.assert_called_once()