diff --git a/storage/netapp/README.md b/storage/netapp/README.md new file mode 100644 index 00000000000..8d5ab2fd4cf --- /dev/null +++ b/storage/netapp/README.md @@ -0,0 +1,454 @@ +#NetApp Storage Modules +This directory contains modules that support the storage platforms in the NetApp portfolio. + +##SANtricity Modules +The modules prefixed with *netapp\_e* are built to support the SANtricity storage platform. They require the SANtricity +WebServices Proxy. The WebServices Proxy is free software available at the [NetApp Software Download site](http://mysupport.netapp.com/NOW/download/software/eseries_webservices/1.40.X000.0009/). +Starting with the E2800 platform (11.30 OS), the modules will work directly with the storage array. Starting with this +platform, REST API requests are handled directly on the box. This array can still be managed by proxy for large scale deployments. +The modules provide idempotent provisioning for volume groups, disk pools, standard volumes, thin volumes, LUN mapping, +hosts, host groups (clusters), volume snapshots, consistency groups, and asynchronous mirroring. +### Prerequisites +| Software | Version | +| -------- |:-------:| +| SANtricity Web Services Proxy*|1.4 or 2.0| +| Ansible | 2.2** | +\* Not required for *E2800 with 11.30 OS*
+\*\*The modules where developed with this version. Ansible forward and backward compatibility applies. + +###Questions and Contribution +Please feel free to submit pull requests with improvements. Issues for these modules should be routed to @hulquest but +we also try to keep an eye on the list for issues specific to these modules. General questions can be made to our [development team](mailto:ng-hsg-engcustomer-esolutions-support@netapp.com) + +### Examples +These examples are not comprehensive but are intended to help you get started when integrating storage provisioning into +your playbooks. +```yml +- name: NetApp Test All Modules + hosts: proxy20 + gather_facts: yes + connection: local + vars: + storage_systems: + ansible1: + address1: "10.251.230.41" + address2: "10.251.230.42" + ansible2: + address1: "10.251.230.43" + address2: "10.251.230.44" + ansible3: + address1: "10.251.230.45" + address2: "10.251.230.46" + ansible4: + address1: "10.251.230.47" + address2: "10.251.230.48" + storage_pools: + Disk_Pool_1: + raid_level: raidDiskPool + criteria_drive_count: 11 + Disk_Pool_2: + raid_level: raidDiskPool + criteria_drive_count: 11 + Disk_Pool_3: + raid_level: raid0 + criteria_drive_count: 2 + volumes: + vol_1: + storage_pool_name: Disk_Pool_1 + size: 10 + thin_provision: false + thin_volume_repo_size: 7 + vol_2: + storage_pool_name: Disk_Pool_2 + size: 10 + thin_provision: false + thin_volume_repo_size: 7 + vol_3: + storage_pool_name: Disk_Pool_3 + size: 10 + thin_provision: false + thin_volume_repo_size: 7 + thin_vol_1: + storage_pool_name: Disk_Pool_1 + size: 10 + thin_provision: true + thin_volume_repo_size: 7 + hosts: + ANSIBLE-1: + host_type: 1 + index: 1 + ports: + - type: 'fc' + label: 'fpPort1' + port: '2100000E1E191B01' + + netapp_api_host: 10.251.230.29 + netapp_api_url: http://{{ netapp_api_host }}/devmgr/v2 + netapp_api_username: rw + netapp_api_password: rw + ssid: ansible1 + auth: no + lun_mapping: no + netapp_api_validate_certs: False + snapshot: no + gather_facts: no + amg_create: no + remove_volume: no + make_volume: no + check_thins: no + remove_storage_pool: yes + check_storage_pool: yes + remove_storage_system: no + check_storage_system: yes + change_role: no + flash_cache: False + configure_hostgroup: no + configure_async_mirror: False + configure_snapshot: no + copy_volume: False + volume_copy_source_volume_id: + volume_destination_source_volume_id: + snapshot_volume_storage_pool_name: Disk_Pool_3 + snapshot_volume_image_id: 3400000060080E5000299B640063074057BC5C5E + snapshot_volume: no + snapshot_volume_name: vol_1_snap_vol + host_type_index: 1 + host_name: ANSIBLE-1 + set_host: no + remove_host: no + amg_member_target_array: + amg_member_primary_pool: + amg_member_secondary_pool: + amg_member_primary_volume: + amg_member_secondary_volume: + set_amg_member: False + amg_array_name: foo + amg_name: amg_made_by_ansible + amg_secondaryArrayId: ansible2 + amg_sync_name: foo + amg_sync: no + + tasks: + + - name: Get array facts + netapp_e_facts: + ssid: "{{ item.key }}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" + with_dict: "{{ storage_systems }}" + when: gather_facts + + - name: Presence of storage system + netapp_e_storage_system: + ssid: "{{ item.key }}" + state: present + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" + controller_addresses: + - "{{ item.value.address1 }}" + - "{{ item.value.address2 }}" + with_dict: "{{ storage_systems }}" + when: check_storage_system + + - name: Create Snapshot + netapp_e_snapshot_images: + ssid: "{{ ssid }}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" + snapshot_group: "ansible_snapshot_group" + state: 'create' + when: snapshot + + - name: Auth Module Example + netapp_e_auth: + ssid: "{{ ssid }}" + current_password: 'Infinit2' + new_password: 'Infinit1' + set_admin: yes + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + when: auth + + - name: No disk groups + netapp_e_storagepool: + ssid: "{{ ssid }}" + name: "{{ item }}" + state: absent + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" + remove_volumes: yes + with_items: + - Disk_Pool_1 + - Disk_Pool_2 + - Disk_Pool_3 + when: remove_storage_pool + + - name: Make disk groups + netapp_e_storagepool: + ssid: "{{ ssid }}" + name: "{{ item.key }}" + state: present + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" + raid_level: "{{ item.value.raid_level }}" + criteria_drive_count: "{{ item.value.criteria_drive_count }}" + with_dict: " {{ storage_pools }}" + when: check_storage_pool + + - name: No thin volume + netapp_e_volume: + ssid: "{{ ssid }}" + name: NewThinVolumeByAnsible + state: absent + thin_provision: yes + log_path: /tmp/volume.log + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" + when: check_thins + + - name: Make a thin volume + netapp_e_volume: + ssid: "{{ ssid }}" + name: NewThinVolumeByAnsible + state: present + thin_provision: yes + thin_volume_repo_size: 7 + size: 10 + log_path: /tmp/volume.log + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" + storage_pool_name: Disk_Pool_1 + when: check_thins + + - name: Remove standard/thick volumes + netapp_e_volume: + ssid: "{{ ssid }}" + name: "{{ item.key }}" + state: absent + log_path: /tmp/volume.log + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" + with_dict: "{{ volumes }}" + when: remove_volume + + - name: Make a volume + netapp_e_volume: + ssid: "{{ ssid }}" + name: "{{ item.key }}" + state: present + storage_pool_name: "{{ item.value.storage_pool_name }}" + size: "{{ item.value.size }}" + thin_provision: "{{ item.value.thin_provision }}" + thin_volume_repo_size: "{{ item.value.thin_volume_repo_size }}" + log_path: /tmp/volume.log + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" + with_dict: "{{ volumes }}" + when: make_volume + + - name: No storage system + netapp_e_storage_system: + ssid: "{{ item.key }}" + state: absent + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" + with_dict: "{{ storage_systems }}" + when: remove_storage_system + + - name: Update the role of a storage array + netapp_e_amg_role: + name: "{{ amg_name }}" + role: primary + force: true + noSync: true + ssid: "{{ ssid }}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" + when: change_role + + - name: Flash Cache + netapp_e_flashcache: + ssid: "{{ ssid }}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" + name: SSDCacheBuiltByAnsible + when: flash_cache + + - name: Configure Hostgroup + netapp_e_hostgroup: + ssid: "{{ ssid }}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" + state: absent + name: "ansible-host-group" + when: configure_hostgroup + + - name: Configure Snapshot group + netapp_e_snapshot_group: + ssid: "{{ ssid }}" + state: present + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" + base_volume_name: vol_3 + name: ansible_snapshot_group + repo_pct: 20 + warning_threshold: 85 + delete_limit: 30 + full_policy: purgepit + storage_pool_name: Disk_Pool_3 + rollback_priority: medium + when: configure_snapshot + + - name: Copy volume + netapp_e_volume_copy: + ssid: "{{ ssid }}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + status: present + source_volume_id: "{{ volume_copy_source_volume_id }}" + destination_volume_id: "{{ volume_destination_source_volume_id }}" + when: copy_volume + + - name: Snapshot volume + netapp_e_snapshot_volume: + ssid: "{{ ssid }}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + state: present + storage_pool_name: "{{ snapshot_volume_storage_pool_name }}" + snapshot_image_id: "{{ snapshot_volume_image_id }}" + name: "{{ snapshot_volume_name }}" + when: snapshot_volume + + - name: Remove hosts + netapp_e_host: + ssid: "{{ ssid }}" + state: absent + name: "{{ item.key }}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + host_type_index: "{{ host_type_index }}" + with_dict: "{{hosts}}" + when: remove_host + + - name: Ensure/add hosts + netapp_e_host: + ssid: "{{ ssid }}" + state: present + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + name: "{{ item.key }}" + host_type_index: "{{ item.value.index }}" + ports: + - type: 'fc' + label: 'fpPort1' + port: '2100000E1E191B01' + with_dict: "{{hosts}}" + when: set_host + + - name: Unmap a volume + netapp_e_lun_mapping: + state: absent + ssid: "{{ ssid }}" + lun: 2 + target: "{{ host_name }}" + volume_name: "thin_vol_1" + target_type: host + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + when: lun_mapping + + - name: Map a volume + netapp_e_lun_mapping: + state: present + ssid: "{{ ssid }}" + lun: 16 + target: "{{ host_name }}" + volume_name: "thin_vol_1" + target_type: host + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + when: lun_mapping + + - name: Update LUN Id + netapp_e_lun_mapping: + state: present + ssid: "{{ ssid }}" + lun: 2 + target: "{{ host_name }}" + volume_name: "thin_vol_1" + target_type: host + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + when: lun_mapping + + - name: AMG removal + netapp_e_amg: + state: absent + ssid: "{{ ssid }}" + secondaryArrayId: "{{amg_secondaryArrayId}}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + new_name: "{{amg_array_name}}" + name: "{{amg_name}}" + when: amg_create + + - name: AMG create + netapp_e_amg: + state: present + ssid: "{{ ssid }}" + secondaryArrayId: "{{amg_secondaryArrayId}}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + new_name: "{{amg_array_name}}" + name: "{{amg_name}}" + when: amg_create + + - name: start AMG async + netapp_e_amg_sync: + name: "{{ amg_name }}" + state: running + ssid: "{{ ssid }}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + when: amg_sync +``` diff --git a/storage/netapp/netapp_e_amg.py b/storage/netapp/netapp_e_amg.py new file mode 100644 index 00000000000..44189988be4 --- /dev/null +++ b/storage/netapp/netapp_e_amg.py @@ -0,0 +1,328 @@ +#!/usr/bin/python +# (c) 2016, NetApp, Inc +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# +DOCUMENTATION = """ +--- +module: netapp_e_amg +short_description: Create, Remove, and Update Asynchronous Mirror Groups +description: + - Allows for the creation, removal and updating of Asynchronous Mirror Groups for NetApp E-series storage arrays +version_added: '2.2' +author: Kevin Hulquest (@hulquest) +options: + api_username: + required: true + description: + - The username to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_password: + required: true + description: + - The password to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_url: + required: true + description: + - The url to the SANtricity WebServices Proxy or embedded REST API. + example: + - https://prod-1.wahoo.acme.com/devmgr/v2 + validate_certs: + required: false + default: true + description: + - Should https certificates be validated? + name: + description: + - The name of the async array you wish to target, or create. + - If C(state) is present and the name isn't found, it will attempt to create. + required: yes + secondaryArrayId: + description: + - The ID of the secondary array to be used in mirroing process + required: yes + syncIntervalMinutes: + description: + - The synchronization interval in minutes + required: no + default: 10 + manualSync: + description: + - Setting this to true will cause other synchronization values to be ignored + required: no + default: no + recoveryWarnThresholdMinutes: + description: + - Recovery point warning threshold (minutes). The user will be warned when the age of the last good failures point exceeds this value + required: no + default: 20 + repoUtilizationWarnThreshold: + description: + - Recovery point warning threshold + required: no + default: 80 + interfaceType: + description: + - The intended protocol to use if both Fibre and iSCSI are available. + choices: + - iscsi + - fibre + required: no + default: null + syncWarnThresholdMinutes: + description: + - The threshold (in minutes) for notifying the user that periodic synchronization has taken too long to complete. + required: no + default: 10 + ssid: + description: + - The ID of the primary storage array for the async mirror action + required: yes + state: + description: + - A C(state) of present will either create or update the async mirror group. + - A C(state) of absent will remove the async mirror group. + required: yes +""" + +EXAMPLES = """ + - name: AMG removal + na_eseries_amg: + state: absent + ssid: "{{ ssid }}" + secondaryArrayId: "{{amg_secondaryArrayId}}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + new_name: "{{amg_array_name}}" + name: "{{amg_name}}" + when: amg_create + + - name: AMG create + netapp_e_amg: + state: present + ssid: "{{ ssid }}" + secondaryArrayId: "{{amg_secondaryArrayId}}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + new_name: "{{amg_array_name}}" + name: "{{amg_name}}" + when: amg_create +""" + +RETURN = """ +msg: + description: Successful removal + returned: success + type: string + sample: "Async mirror group removed." + +msg: + description: Successful creation + returned: success + type: string + sample: '{"changed": true, "connectionType": "fc", "groupRef": "3700000060080E5000299C24000006E857AC7EEC", "groupState": "optimal", "id": "3700000060080E5000299C24000006E857AC7EEC", "label": "amg_made_by_ansible", "localRole": "primary", "mirrorChannelRemoteTarget": "9000000060080E5000299C24005B06E557AC7EEC", "orphanGroup": false, "recoveryPointAgeAlertThresholdMinutes": 20, "remoteRole": "secondary", "remoteTarget": {"nodeName": {"ioInterfaceType": "fc", "iscsiNodeName": null, "remoteNodeWWN": "20040080E5299F1C"}, "remoteRef": "9000000060080E5000299C24005B06E557AC7EEC", "scsiinitiatorTargetBaseProperties": {"ioInterfaceType": "fc", "iscsiinitiatorTargetBaseParameters": null}}, "remoteTargetId": "ansible2", "remoteTargetName": "Ansible2", "remoteTargetWwn": "60080E5000299F880000000056A25D56", "repositoryUtilizationWarnThreshold": 80, "roleChangeProgress": "none", "syncActivity": "idle", "syncCompletionTimeAlertThresholdMinutes": 10, "syncIntervalMinutes": 10, "worldWideName": "60080E5000299C24000006E857AC7EEC"}' +""" + +import json + +from ansible.module_utils.api import basic_auth_argument_spec +from ansible.module_utils.basic import AnsibleModule, get_exception +from ansible.module_utils.urls import open_url +from ansible.module_utils.six.moves.urllib.error import HTTPError + +HEADERS = { + "Content-Type": "application/json", + "Accept": "application/json", +} + + +def request(url, data=None, headers=None, method='GET', use_proxy=True, + force=False, last_mod_time=None, timeout=10, validate_certs=True, + url_username=None, url_password=None, http_agent=None, force_basic_auth=False, ignore_errors=False): + try: + r = open_url(url=url, data=data, headers=headers, method=method, use_proxy=use_proxy, + force=force, last_mod_time=last_mod_time, timeout=timeout, validate_certs=validate_certs, + url_username=url_username, url_password=url_password, http_agent=http_agent, + force_basic_auth=force_basic_auth) + except HTTPError: + err = get_exception() + r = err.fp + + try: + raw_data = r.read() + if raw_data: + data = json.loads(raw_data) + else: + data = None + except: + if ignore_errors: + pass + else: + raise Exception(raw_data) + + resp_code = r.getcode() + + if resp_code >= 400 and not ignore_errors: + raise Exception(resp_code, data) + else: + return resp_code, data + + +def has_match(module, ssid, api_url, api_pwd, api_usr, body): + compare_keys = ['syncIntervalMinutes', 'syncWarnThresholdMinutes', + 'recoveryWarnThresholdMinutes', 'repoUtilizationWarnThreshold'] + desired_state = dict((x, (body.get(x))) for x in compare_keys) + label_exists = False + matches_spec = False + current_state = None + async_id = None + api_data = None + desired_name = body.get('name') + endpoint = 'storage-systems/%s/async-mirrors' % ssid + url = api_url + endpoint + try: + rc, data = request(url, url_username=api_usr, url_password=api_pwd, headers=HEADERS) + except Exception: + error = get_exception() + module.exit_json(exception="Error finding a match. Message: %s" % str(error)) + + for async_group in data: + if async_group['label'] == desired_name: + label_exists = True + api_data = async_group + async_id = async_group['groupRef'] + current_state = dict( + syncIntervalMinutes=async_group['syncIntervalMinutes'], + syncWarnThresholdMinutes=async_group['syncCompletionTimeAlertThresholdMinutes'], + recoveryWarnThresholdMinutes=async_group['recoveryPointAgeAlertThresholdMinutes'], + repoUtilizationWarnThreshold=async_group['repositoryUtilizationWarnThreshold'], + ) + + if current_state == desired_state: + matches_spec = True + + return label_exists, matches_spec, api_data, async_id + + +def create_async(module, ssid, api_url, api_pwd, api_usr, body): + endpoint = 'storage-systems/%s/async-mirrors' % ssid + url = api_url + endpoint + post_data = json.dumps(body) + try: + rc, data = request(url, data=post_data, method='POST', url_username=api_usr, url_password=api_pwd, + headers=HEADERS) + except Exception: + error = get_exception() + module.exit_json(exception="Exception while creating aysnc mirror group. Message: %s" % str(error)) + return data + + +def update_async(module, ssid, api_url, pwd, user, body, new_name, async_id): + endpoint = 'storage-systems/%s/async-mirrors/%s' % (ssid, async_id) + url = api_url + endpoint + compare_keys = ['syncIntervalMinutes', 'syncWarnThresholdMinutes', + 'recoveryWarnThresholdMinutes', 'repoUtilizationWarnThreshold'] + desired_state = dict((x, (body.get(x))) for x in compare_keys) + + if new_name: + desired_state['new_name'] = new_name + + post_data = json.dumps(desired_state) + + try: + rc, data = request(url, data=post_data, method='POST', headers=HEADERS, + url_username=user, url_password=pwd) + except Exception: + error = get_exception() + module.exit_json(exception="Exception while updating async mirror group. Message: %s" % str(error)) + + return data + + +def remove_amg(module, ssid, api_url, pwd, user, async_id): + endpoint = 'storage-systems/%s/async-mirrors/%s' % (ssid, async_id) + url = api_url + endpoint + try: + rc, data = request(url, method='DELETE', url_username=user, url_password=pwd, + headers=HEADERS) + except Exception: + error = get_exception() + module.exit_json(exception="Exception while removing async mirror group. Message: %s" % str(error)) + + return + + +def main(): + argument_spec = basic_auth_argument_spec() + argument_spec.update(dict( + api_username=dict(type='str', required=True), + api_password=dict(type='str', required=True, no_log=True), + api_url=dict(type='str', required=True), + name=dict(required=True, type='str'), + new_name=dict(required=False, type='str'), + secondaryArrayId=dict(required=True, type='str'), + syncIntervalMinutes=dict(required=False, default=10, type='int'), + manualSync=dict(required=False, default=False, type='bool'), + recoveryWarnThresholdMinutes=dict(required=False, default=20, type='int'), + repoUtilizationWarnThreshold=dict(required=False, default=80, type='int'), + interfaceType=dict(required=False, choices=['fibre', 'iscsi'], type='str'), + ssid=dict(required=True, type='str'), + state=dict(required=True, choices=['present', 'absent']), + syncWarnThresholdMinutes=dict(required=False, default=10, type='int') + )) + + module = AnsibleModule(argument_spec=argument_spec) + + p = module.params + + ssid = p.pop('ssid') + api_url = p.pop('api_url') + user = p.pop('api_username') + pwd = p.pop('api_password') + new_name = p.pop('new_name') + state = p.pop('state') + + if not api_url.endswith('/'): + api_url += '/' + + name_exists, spec_matches, api_data, async_id = has_match(module, ssid, api_url, pwd, user, p) + + if state == 'present': + if name_exists and spec_matches: + module.exit_json(changed=False, msg="Desired state met", **api_data) + elif name_exists and not spec_matches: + results = update_async(module, ssid, api_url, pwd, user, + p, new_name, async_id) + module.exit_json(changed=True, + msg="Async mirror group updated", async_id=async_id, + **results) + elif not name_exists: + results = create_async(module, ssid, api_url, user, pwd, p) + module.exit_json(changed=True, **results) + + elif state == 'absent': + if name_exists: + remove_amg(module, ssid, api_url, pwd, user, async_id) + module.exit_json(changed=True, msg="Async mirror group removed.", + async_id=async_id) + else: + module.exit_json(changed=False, + msg="Async Mirror group: %s already absent" % p['name']) + + +if __name__ == '__main__': + main() diff --git a/storage/netapp/netapp_e_amg_role.py b/storage/netapp/netapp_e_amg_role.py new file mode 100644 index 00000000000..7a2f1bdf18b --- /dev/null +++ b/storage/netapp/netapp_e_amg_role.py @@ -0,0 +1,239 @@ +#!/usr/bin/python + +# (c) 2016, NetApp, Inc +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# +DOCUMENTATION = """ +--- +module: netapp_e_amg_role +short_description: Update the role of a storage array within an Asynchronous Mirror Group (AMG). +description: + - Update a storage array to become the primary or secondary instance in an asynchronous mirror group +version_added: '2.2' +author: Kevin Hulquest (@hulquest) +options: + api_username: + required: true + description: + - The username to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_password: + required: true + description: + - The password to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_url: + required: true + description: + - The url to the SANtricity WebServices Proxy or embedded REST API. + example: + - https://prod-1.wahoo.acme.com/devmgr/v2 + validate_certs: + required: false + default: true + description: + - Should https certificates be validated? + ssid: + description: + - The ID of the primary storage array for the async mirror action + required: yes + role: + description: + - Whether the array should be the primary or secondary array for the AMG + required: yes + choices: ['primary', 'secondary'] + noSync: + description: + - Whether to avoid synchronization prior to role reversal + required: no + default: no + choices: [yes, no] + force: + description: + - Whether to force the role reversal regardless of the online-state of the primary + required: no + default: no +""" + +EXAMPLES = """ + - name: Update the role of a storage array + netapp_e_amg_role: + name: updating amg role + role: primary + ssid: "{{ ssid }}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" +""" + +RETURN = """ +msg: + description: Failure message + returned: failure + type: string + sample: "No Async Mirror Group with the name." +""" +import json + +from ansible.module_utils.api import basic_auth_argument_spec +from ansible.module_utils.basic import AnsibleModule + +from ansible.module_utils.pycompat24 import get_exception +from ansible.module_utils.urls import open_url +from ansible.module_utils.six.moves.urllib.error import HTTPError + +HEADERS = { + "Content-Type": "application/json", + "Accept": "application/json", +} + + +def request(url, data=None, headers=None, method='GET', use_proxy=True, + force=False, last_mod_time=None, timeout=10, validate_certs=True, + url_username=None, url_password=None, http_agent=None, force_basic_auth=True, ignore_errors=False): + try: + r = open_url(url=url, data=data, headers=headers, method=method, use_proxy=use_proxy, + force=force, last_mod_time=last_mod_time, timeout=timeout, validate_certs=validate_certs, + url_username=url_username, url_password=url_password, http_agent=http_agent, + force_basic_auth=force_basic_auth) + except HTTPError: + err = get_exception() + r = err.fp + + try: + raw_data = r.read() + if raw_data: + data = json.loads(raw_data) + else: + raw_data = None + except: + if ignore_errors: + pass + else: + raise Exception(raw_data) + + resp_code = r.getcode() + + if resp_code >= 400 and not ignore_errors: + raise Exception(resp_code, data) + else: + return resp_code, data + + +def has_match(module, ssid, api_url, api_pwd, api_usr, body, name): + amg_exists = False + has_desired_role = False + amg_id = None + amg_data = None + get_amgs = 'storage-systems/%s/async-mirrors' % ssid + url = api_url + get_amgs + try: + amg_rc, amgs = request(url, url_username=api_usr, url_password=api_pwd, + headers=HEADERS) + except: + module.fail_json(msg="Failed to find AMGs on storage array. Id [%s]" % (ssid)) + + for amg in amgs: + if amg['label'] == name: + amg_exists = True + amg_id = amg['id'] + amg_data = amg + if amg['localRole'] == body.get('role'): + has_desired_role = True + + return amg_exists, has_desired_role, amg_id, amg_data + + +def update_amg(module, ssid, api_url, api_usr, api_pwd, body, amg_id): + endpoint = 'storage-systems/%s/async-mirrors/%s/role' % (ssid, amg_id) + url = api_url + endpoint + post_data = json.dumps(body) + try: + request(url, data=post_data, method='POST', url_username=api_usr, + url_password=api_pwd, headers=HEADERS) + except: + err = get_exception() + module.fail_json( + msg="Failed to change role of AMG. Id [%s]. AMG Id [%s]. Error [%s]" % (ssid, amg_id, str(err))) + + status_endpoint = 'storage-systems/%s/async-mirrors/%s' % (ssid, amg_id) + status_url = api_url + status_endpoint + try: + rc, status = request(status_url, method='GET', url_username=api_usr, + url_password=api_pwd, headers=HEADERS) + except: + err = get_exception() + module.fail_json( + msg="Failed to check status of AMG after role reversal. " + + "Id [%s]. AMG Id [%s]. Error [%s]" % (ssid, amg_id, str(err))) + + # Here we wait for the role reversal to complete + if 'roleChangeProgress' in status: + while status['roleChangeProgress'] != "none": + try: + rc, status = request(status_url, method='GET', + url_username=api_usr, url_password=api_pwd, headers=HEADERS) + except: + err = get_exception() + module.fail_json( + msg="Failed to check status of AMG after role reversal. " + + "Id [%s]. AMG Id [%s]. Error [%s]" % (ssid, amg_id, str(err))) + return status + + +def main(): + argument_spec = basic_auth_argument_spec() + argument_spec.update(dict( + name=dict(required=True, type='str'), + role=dict(required=True, choices=['primary', 'secondary']), + noSync=dict(required=False, type='bool', default=False), + force=dict(required=False, type='bool', default=False), + ssid=dict(required=True, type='str'), + api_url=dict(required=True), + api_username=dict(required=False), + api_password=dict(required=False, no_log=True), + )) + + module = AnsibleModule(argument_spec=argument_spec) + + p = module.params + + ssid = p.pop('ssid') + api_url = p.pop('api_url') + user = p.pop('api_username') + pwd = p.pop('api_password') + name = p.pop('name') + + if not api_url.endswith('/'): + api_url += '/' + + agm_exists, has_desired_role, async_id, amg_data = has_match(module, ssid, api_url, pwd, user, p, name) + + if not agm_exists: + module.fail_json(msg="No Async Mirror Group with the name: '%s' was found" % name) + elif has_desired_role: + module.exit_json(changed=False, **amg_data) + + else: + amg_data = update_amg(module, ssid, api_url, user, pwd, p, async_id) + if amg_data: + module.exit_json(changed=True, **amg_data) + else: + module.exit_json(changed=True, msg="AMG role changed.") + + +if __name__ == '__main__': + main() diff --git a/storage/netapp/netapp_e_amg_sync.py b/storage/netapp/netapp_e_amg_sync.py new file mode 100644 index 00000000000..a86b594f3b0 --- /dev/null +++ b/storage/netapp/netapp_e_amg_sync.py @@ -0,0 +1,269 @@ +#!/usr/bin/python + +# (c) 2016, NetApp, Inc +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# +DOCUMENTATION = """ +--- +module: netapp_e_amg_sync +short_description: Conduct synchronization actions on asynchronous member groups. +description: + - Allows for the initialization, suspension and resumption of an asynchronous mirror group's synchronization for NetApp E-series storage arrays. +version_added: '2.2' +author: Kevin Hulquest (@hulquest) +options: + api_username: + required: true + description: + - The username to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_password: + required: true + description: + - The password to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_url: + required: true + description: + - The url to the SANtricity WebServices Proxy or embedded REST API. + example: + - https://prod-1.wahoo.acme.com/devmgr/v2 + validate_certs: + required: false + default: true + description: + - Should https certificates be validated? + ssid: + description: + - The ID of the storage array containing the AMG you wish to target + name: + description: + - The name of the async mirror group you wish to target + required: yes + state: + description: + - The synchronization action you'd like to take. + - If C(running) then it will begin syncing if there is no active sync or will resume a suspended sync. If there is already a sync in progress, it will return with an OK status. + - If C(suspended) it will suspend any ongoing sync action, but return OK if there is no active sync or if the sync is already suspended + choices: + - running + - suspended + required: yes + delete_recovery_point: + description: + - Indicates whether the failures point can be deleted on the secondary if necessary to achieve the synchronization. + - If true, and if the amount of unsynchronized data exceeds the CoW repository capacity on the secondary for any member volume, the last failures point will be deleted and synchronization will continue. + - If false, the synchronization will be suspended if the amount of unsynchronized data exceeds the CoW Repository capacity on the secondary and the failures point will be preserved. + - "NOTE: This only has impact for newly launched syncs." + choices: + - yes + - no + default: no +""" +EXAMPLES = """ + - name: start AMG async + netapp_e_amg_sync: + name: "{{ amg_sync_name }}" + state: running + ssid: "{{ ssid }}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" +""" +RETURN = """ +json: + description: The object attributes of the AMG. + returned: success + type: string + example: + { + "changed": false, + "connectionType": "fc", + "groupRef": "3700000060080E5000299C24000006EF57ACAC70", + "groupState": "optimal", + "id": "3700000060080E5000299C24000006EF57ACAC70", + "label": "made_with_ansible", + "localRole": "primary", + "mirrorChannelRemoteTarget": "9000000060080E5000299C24005B06E557AC7EEC", + "orphanGroup": false, + "recoveryPointAgeAlertThresholdMinutes": 20, + "remoteRole": "secondary", + "remoteTarget": { + "nodeName": { + "ioInterfaceType": "fc", + "iscsiNodeName": null, + "remoteNodeWWN": "20040080E5299F1C" + }, + "remoteRef": "9000000060080E5000299C24005B06E557AC7EEC", + "scsiinitiatorTargetBaseProperties": { + "ioInterfaceType": "fc", + "iscsiinitiatorTargetBaseParameters": null + } + }, + "remoteTargetId": "ansible2", + "remoteTargetName": "Ansible2", + "remoteTargetWwn": "60080E5000299F880000000056A25D56", + "repositoryUtilizationWarnThreshold": 80, + "roleChangeProgress": "none", + "syncActivity": "idle", + "syncCompletionTimeAlertThresholdMinutes": 10, + "syncIntervalMinutes": 10, + "worldWideName": "60080E5000299C24000006EF57ACAC70" + } +""" +import json + +from ansible.module_utils.api import basic_auth_argument_spec +from ansible.module_utils.basic import AnsibleModule + +from ansible.module_utils.pycompat24 import get_exception +from ansible.module_utils.urls import open_url +from ansible.module_utils.six.moves.urllib.error import HTTPError + + +def request(url, data=None, headers=None, method='GET', use_proxy=True, + force=False, last_mod_time=None, timeout=10, validate_certs=True, + url_username=None, url_password=None, http_agent=None, force_basic_auth=True, ignore_errors=False): + try: + r = open_url(url=url, data=data, headers=headers, method=method, use_proxy=use_proxy, + force=force, last_mod_time=last_mod_time, timeout=timeout, validate_certs=validate_certs, + url_username=url_username, url_password=url_password, http_agent=http_agent, + force_basic_auth=force_basic_auth) + except HTTPError: + err = get_exception() + r = err.fp + + try: + raw_data = r.read() + if raw_data: + data = json.loads(raw_data) + else: + raw_data = None + except: + if ignore_errors: + pass + else: + raise Exception(raw_data) + + resp_code = r.getcode() + + if resp_code >= 400 and not ignore_errors: + raise Exception(resp_code, data) + else: + return resp_code, data + + +class AMGsync(object): + def __init__(self): + argument_spec = basic_auth_argument_spec() + argument_spec.update(dict( + api_username=dict(type='str', required=True), + api_password=dict(type='str', required=True, no_log=True), + api_url=dict(type='str', required=True), + name=dict(required=True, type='str'), + ssid=dict(required=True, type='str'), + state=dict(required=True, type='str', choices=['running', 'suspended']), + delete_recovery_point=dict(required=False, type='bool', default=False) + )) + self.module = AnsibleModule(argument_spec=argument_spec) + args = self.module.params + self.name = args['name'] + self.ssid = args['ssid'] + self.state = args['state'] + self.delete_recovery_point = args['delete_recovery_point'] + try: + self.user = args['api_username'] + self.pwd = args['api_password'] + self.url = args['api_url'] + except KeyError: + self.module.fail_json(msg="You must pass in api_username" + "and api_password and api_url to the module.") + self.certs = args['validate_certs'] + + self.post_headers = { + "Accept": "application/json", + "Content-Type": "application/json" + } + self.amg_id, self.amg_obj = self.get_amg() + + def get_amg(self): + endpoint = self.url + '/storage-systems/%s/async-mirrors' % self.ssid + (rc, amg_objs) = request(endpoint, url_username=self.user, url_password=self.pwd, validate_certs=self.certs, + headers=self.post_headers) + try: + amg_id = filter(lambda d: d['label'] == self.name, amg_objs)[0]['id'] + amg_obj = filter(lambda d: d['label'] == self.name, amg_objs)[0] + except IndexError: + self.module.fail_json( + msg="There is no async mirror group %s associated with storage array %s" % (self.name, self.ssid)) + return amg_id, amg_obj + + @property + def current_state(self): + amg_id, amg_obj = self.get_amg() + return amg_obj['syncActivity'] + + def run_sync_action(self): + # If we get to this point we know that the states differ, and there is no 'err' state, + # so no need to revalidate + + post_body = dict() + if self.state == 'running': + if self.current_state == 'idle': + if self.delete_recovery_point: + post_body.update(dict(deleteRecoveryPointIfNecessary=self.delete_recovery_point)) + suffix = 'sync' + else: + # In a suspended state + suffix = 'resume' + else: + suffix = 'suspend' + + endpoint = self.url + "/storage-systems/%s/async-mirrors/%s/%s" % (self.ssid, self.amg_id, suffix) + + (rc, resp) = request(endpoint, method='POST', url_username=self.user, url_password=self.pwd, + validate_certs=self.certs, data=json.dumps(post_body), headers=self.post_headers, + ignore_errors=True) + + if not str(rc).startswith('2'): + self.module.fail_json(msg=str(resp['errorMessage'])) + + return resp + + def apply(self): + state_map = dict( + running=['active'], + suspended=['userSuspended', 'internallySuspended', 'paused'], + err=['unkown', '_UNDEFINED']) + + if self.current_state not in state_map[self.state]: + if self.current_state in state_map['err']: + self.module.fail_json( + msg="The sync is a state of '%s', this requires manual intervention. " + + "Please investigate and try again" % self.current_state) + else: + self.amg_obj = self.run_sync_action() + + (ret, amg) = self.get_amg() + self.module.exit_json(changed=False, **amg) + + +def main(): + sync = AMGsync() + sync.apply() + + +if __name__ == '__main__': + main() diff --git a/storage/netapp/netapp_e_auth.py b/storage/netapp/netapp_e_auth.py new file mode 100644 index 00000000000..a9f54257a3d --- /dev/null +++ b/storage/netapp/netapp_e_auth.py @@ -0,0 +1,283 @@ +#!/usr/bin/python + +# (c) 2016, NetApp, Inc +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# +DOCUMENTATION = ''' +--- +module: netapp_e_auth +short_description: Sets or updates the password for a storage array. +description: + - Sets or updates the password for a storage array. When the password is updated on the storage array, it must be updated on the SANtricity Web Services proxy. Note, all storage arrays do not have a Monitor or RO role. +version_added: "2.2" +author: Kevin Hulquest (@hulquest) +options: + api_username: + required: true + description: + - The username to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_password: + required: true + description: + - The password to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_url: + required: true + description: + - The url to the SANtricity WebServices Proxy or embedded REST API. + example: + - https://prod-1.wahoo.acme.com/devmgr/v2 + validate_certs: + required: false + default: true + description: + - Should https certificates be validated? + name: + description: + - The name of the storage array. Note that if more than one storage array with this name is detected, the task will fail and you'll have to use the ID instead. + required: False + ssid: + description: + - the identifier of the storage array in the Web Services Proxy. + required: False + set_admin: + description: + - Boolean value on whether to update the admin password. If set to false then the RO account is updated. + default: False + current_password: + description: + - The current admin password. This is not required if the password hasn't been set before. + required: False + new_password: + description: + - The password you would like to set. Cannot be more than 30 characters. + required: True + api_url: + description: + - The full API url. + - "Example: http://ENDPOINT:8080/devmgr/v2" + - This can optionally be set via an environment variable, API_URL + required: False + api_username: + description: + - The username used to authenticate against the API + - This can optionally be set via an environment variable, API_USERNAME + required: False + api_password: + description: + - The password used to authenticate against the API + - This can optionally be set via an environment variable, API_PASSWORD + required: False +''' + +EXAMPLES = ''' +- name: Test module + netapp_e_auth: + name: trex + current_password: 'B4Dpwd' + new_password: 'W0rs3P4sswd' + set_admin: yes + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" +''' + +RETURN = ''' +msg: + description: Success message + returned: success + type: string + sample: "Password Updated Successfully" +''' +import json + +from ansible.module_utils.api import basic_auth_argument_spec +from ansible.module_utils.basic import AnsibleModule + +from ansible.module_utils.pycompat24 import get_exception +from ansible.module_utils.urls import open_url +from ansible.module_utils.six.moves.urllib.error import HTTPError + + +HEADERS = { + "Content-Type": "application/json", + "Accept": "application/json" +} + + +def request(url, data=None, headers=None, method='GET', use_proxy=True, + force=False, last_mod_time=None, timeout=10, validate_certs=True, + url_username=None, url_password=None, http_agent=None, force_basic_auth=True, ignore_errors=False): + try: + r = open_url(url=url, data=data, headers=headers, method=method, use_proxy=use_proxy, + force=force, last_mod_time=last_mod_time, timeout=timeout, validate_certs=validate_certs, + url_username=url_username, url_password=url_password, http_agent=http_agent, + force_basic_auth=force_basic_auth) + except HTTPError: + err = get_exception() + r = err.fp + + try: + raw_data = r.read() + if raw_data: + data = json.loads(raw_data) + else: + raw_data = None + except: + if ignore_errors: + pass + else: + raise Exception(raw_data) + + resp_code = r.getcode() + + if resp_code >= 400 and not ignore_errors: + raise Exception(resp_code, data) + else: + return resp_code, data + + +def get_ssid(module, name, api_url, user, pwd): + count = 0 + all_systems = 'storage-systems' + systems_url = api_url + all_systems + rc, data = request(systems_url, headers=HEADERS, url_username=user, url_password=pwd) + for system in data: + if system['name'] == name: + count += 1 + if count > 1: + module.fail_json( + msg="You supplied a name for the Storage Array but more than 1 array was found with that name. " + + "Use the id instead") + else: + ssid = system['id'] + else: + continue + + if count == 0: + module.fail_json(msg="No storage array with the name %s was found" % name) + + else: + return ssid + + +def get_pwd_status(module, ssid, api_url, user, pwd): + pwd_status = "storage-systems/%s/passwords" % ssid + url = api_url + pwd_status + try: + rc, data = request(url, headers=HEADERS, url_username=user, url_password=pwd) + return data['readOnlyPasswordSet'], data['adminPasswordSet'] + except HTTPError: + error = get_exception() + module.fail_json(msg="There was an issue with connecting, please check that your " + "endpoint is properly defined and your credentials are correct: %s" % str(error)) + + +def update_storage_system_pwd(module, ssid, pwd, api_url, api_usr, api_pwd): + update_pwd = 'storage-systems/%s' % ssid + url = api_url + update_pwd + post_body = json.dumps(dict(storedPassword=pwd)) + try: + rc, data = request(url, data=post_body, method='POST', headers=HEADERS, url_username=api_usr, + url_password=api_pwd) + except: + err = get_exception() + module.fail_json(msg="Failed to update system password. Id [%s]. Error [%s]" % (ssid, str(err))) + return data + + +def set_password(module, ssid, api_url, user, pwd, current_password=None, new_password=None, set_admin=False): + set_pass = "storage-systems/%s/passwords" % ssid + url = api_url + set_pass + + if not current_password: + current_password = "" + + post_body = json.dumps( + dict(currentAdminPassword=current_password, adminPassword=set_admin, newPassword=new_password)) + + try: + rc, data = request(url, method='POST', data=post_body, headers=HEADERS, url_username=user, url_password=pwd, + ignore_errors=True) + except: + err = get_exception() + module.fail_json(msg="Failed to set system password. Id [%s]. Error [%s]" % (ssid, str(err))) + + if rc == 422: + post_body = json.dumps(dict(currentAdminPassword='', adminPassword=set_admin, newPassword=new_password)) + try: + rc, data = request(url, method='POST', data=post_body, headers=HEADERS, url_username=user, url_password=pwd) + except Exception: + module.fail_json(msg="Wrong or no admin password supplied. Please update your playbook and try again") + + update_data = update_storage_system_pwd(module, ssid, new_password, api_url, user, pwd) + + if int(rc) == 204: + return update_data + else: + module.fail_json(msg="%s:%s" % (rc, data)) + + +def main(): + argument_spec = basic_auth_argument_spec() + argument_spec.update(dict( + name=dict(required=False, type='str'), + ssid=dict(required=False, type='str'), + current_password=dict(required=False, no_log=True), + new_password=dict(required=True, no_log=True), + set_admin=dict(required=True, type='bool'), + api_url=dict(required=True), + api_username=dict(required=False), + api_password=dict(required=False, no_log=True) + ) + ) + module = AnsibleModule(argument_spec=argument_spec, mutually_exclusive=[['name', 'ssid']], + required_one_of=[['name', 'ssid']]) + + name = module.params['name'] + ssid = module.params['ssid'] + current_password = module.params['current_password'] + new_password = module.params['new_password'] + set_admin = module.params['set_admin'] + user = module.params['api_username'] + pwd = module.params['api_password'] + api_url = module.params['api_url'] + + if not api_url.endswith('/'): + api_url += '/' + + if name: + ssid = get_ssid(module, name, api_url, user, pwd) + + ro_pwd, admin_pwd = get_pwd_status(module, ssid, api_url, user, pwd) + + if admin_pwd and not current_password: + module.fail_json( + msg="Admin account has a password set. " + + "You must supply current_password in order to update the RO or Admin passwords") + + if len(new_password) > 30: + module.fail_json(msg="Passwords must not be greater than 30 characters in length") + + success = set_password(module, ssid, api_url, user, pwd, current_password=current_password, + new_password=new_password, + set_admin=set_admin) + + module.exit_json(changed=True, msg="Password Updated Successfully", **success) + + +if __name__ == '__main__': + main() diff --git a/storage/netapp/netapp_e_facts.py b/storage/netapp/netapp_e_facts.py index 514002b9d38..37e3f827627 100644 --- a/storage/netapp/netapp_e_facts.py +++ b/storage/netapp/netapp_e_facts.py @@ -18,7 +18,7 @@ # along with Ansible. If not, see . # DOCUMENTATION = ''' -module: na_eseries_facts +module: netapp_e_facts version_added: '2.2' short_description: Get facts about NetApp E-Series arrays options: @@ -55,7 +55,7 @@ author: Kevin Hulquest (@hulquest) EXAMPLES = """ --- - name: Get array facts - na_eseries_facts: + netapp_e_facts: array_id: "{{ netapp_array_id }}" api_url: "{{ netapp_api_url }}" api_username: "{{ netapp_api_username }}" @@ -68,8 +68,6 @@ msg: Gathered facts for . """ import json -import os - from ansible.module_utils.api import basic_auth_argument_spec from ansible.module_utils.basic import AnsibleModule, get_exception from ansible.module_utils.urls import open_url @@ -173,8 +171,7 @@ def main(): available_capacity=sp['freeSpace'], total_capacity=sp['totalRaidedSpace'], used_capacity=sp['usedSpace'] - ) for sp in resp['volumeGroup'] - ] + ) for sp in resp['volumeGroup']] all_volumes = list(resp['volume']) # all_volumes.extend(resp['thinVolume']) @@ -187,8 +184,7 @@ def main(): parent_storage_pool_id=v['volumeGroupRef'], capacity=v['capacity'], is_thin_provisioned=v['thinProvisioned'] - ) for v in all_volumes - ] + ) for v in all_volumes] features = [f for f in resp['sa']['capabilities']] features.extend([f['capability'] for f in resp['sa']['premiumFeatures'] if f['isEnabled']]) diff --git a/storage/netapp/netapp_e_flashcache.py b/storage/netapp/netapp_e_flashcache.py new file mode 100644 index 00000000000..5fa4a669747 --- /dev/null +++ b/storage/netapp/netapp_e_flashcache.py @@ -0,0 +1,420 @@ +#!/usr/bin/python + +# (c) 2016, NetApp, Inc +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# +DOCUMENTATION = ''' +module: netapp_e_flashcache +author: Kevin Hulquest (@hulquest) +version_added: '2.2' +short_description: Manage NetApp SSD caches +description: +- Create or remove SSD caches on a NetApp E-Series storage array. +options: + api_username: + required: true + description: + - The username to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_password: + required: true + description: + - The password to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_url: + required: true + description: + - The url to the SANtricity WebServices Proxy or embedded REST API. + example: + - https://prod-1.wahoo.acme.com/devmgr/v2 + validate_certs: + required: false + default: true + description: + - Should https certificates be validated? + ssid: + required: true + description: + - The ID of the array to manage (as configured on the web services proxy). + state: + required: true + description: + - Whether the specified SSD cache should exist or not. + choices: ['present', 'absent'] + default: present + name: + required: true + description: + - The name of the SSD cache to manage + io_type: + description: + - The type of workload to optimize the cache for. + choices: ['filesystem','database','media'] + default: filesystem + disk_count: + description: + - The minimum number of disks to use for building the cache. The cache will be expanded if this number exceeds the number of disks already in place + size_unit: + description: + - The unit to be applied to size arguments + choices: ['bytes', 'b', 'kb', 'mb', 'gb', 'tb', 'pb', 'eb', 'zb', 'yb'] + default: gb + cache_size_min: + description: + - The minimum size (in size_units) of the ssd cache. The cache will be expanded if this exceeds the current size of the cache. +''' + +EXAMPLES = """ + - name: Flash Cache + netapp_e_flashcache: + ssid: "{{ ssid }}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" + name: SSDCacheBuiltByAnsible +""" + +RETURN = """ +msg: + description: Success message + returned: success + type: string + sample: json for newly created flash cache +""" +import json +import logging +import sys + +from ansible.module_utils.api import basic_auth_argument_spec +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.pycompat24 import get_exception +from ansible.module_utils.urls import open_url + +from ansible.module_utils.six.moves.urllib.error import HTTPError + + +def request(url, data=None, headers=None, method='GET', use_proxy=True, + force=False, last_mod_time=None, timeout=10, validate_certs=True, + url_username=None, url_password=None, http_agent=None, force_basic_auth=True, ignore_errors=False): + try: + r = open_url(url=url, data=data, headers=headers, method=method, use_proxy=use_proxy, + force=force, last_mod_time=last_mod_time, timeout=timeout, validate_certs=validate_certs, + url_username=url_username, url_password=url_password, http_agent=http_agent, + force_basic_auth=force_basic_auth) + except HTTPError: + err = get_exception() + r = err.fp + + try: + raw_data = r.read() + if raw_data: + data = json.loads(raw_data) + else: + raw_data = None + except: + if ignore_errors: + pass + else: + raise Exception(raw_data) + + resp_code = r.getcode() + + if resp_code >= 400 and not ignore_errors: + raise Exception(resp_code, data) + else: + return resp_code, data + + +class NetAppESeriesFlashCache(object): + def __init__(self): + self.name = None + self.log_mode = None + self.log_path = None + self.api_url = None + self.api_username = None + self.api_password = None + self.ssid = None + self.validate_certs = None + self.disk_count = None + self.size_unit = None + self.cache_size_min = None + self.io_type = None + self.driveRefs = None + self.state = None + self._size_unit_map = dict( + bytes=1, + b=1, + kb=1024, + mb=1024 ** 2, + gb=1024 ** 3, + tb=1024 ** 4, + pb=1024 ** 5, + eb=1024 ** 6, + zb=1024 ** 7, + yb=1024 ** 8 + ) + + argument_spec = basic_auth_argument_spec() + argument_spec.update(dict( + api_username=dict(type='str', required=True), + api_password=dict(type='str', required=True, no_log=True), + api_url=dict(type='str', required=True), + state=dict(default='present', choices=['present', 'absent'], type='str'), + ssid=dict(required=True, type='str'), + name=dict(required=True, type='str'), + disk_count=dict(type='int'), + disk_refs=dict(type='list'), + cache_size_min=dict(type='int'), + io_type=dict(default='filesystem', choices=['filesystem', 'database', 'media']), + size_unit=dict(default='gb', choices=['bytes', 'b', 'kb', 'mb', 'gb', 'tb', 'pb', 'eb', 'zb', 'yb'], + type='str'), + criteria_disk_phy_type=dict(choices=['sas', 'sas4k', 'fibre', 'fibre520b', 'scsi', 'sata', 'pata'], + type='str'), + log_mode=dict(type='str'), + log_path=dict(type='str'), + )) + self.module = AnsibleModule( + argument_spec=argument_spec, + required_if=[ + + ], + mutually_exclusive=[ + + ], + # TODO: update validation for various selection criteria + supports_check_mode=True + ) + + self.__dict__.update(self.module.params) + + # logging setup + self._logger = logging.getLogger(self.__class__.__name__) + self.debug = self._logger.debug + + if self.log_mode == 'file' and self.log_path: + logging.basicConfig(level=logging.DEBUG, filename=self.log_path) + elif self.log_mode == 'stderr': + logging.basicConfig(level=logging.DEBUG, stream=sys.stderr) + + self.post_headers = dict(Accept="application/json") + self.post_headers['Content-Type'] = 'application/json' + + def get_candidate_disks(self, disk_count, size_unit='gb', capacity=None): + self.debug("getting candidate disks...") + + drives_req = dict( + driveCount=disk_count, + sizeUnit=size_unit, + driveType='ssd', + ) + + if capacity: + drives_req['targetUsableCapacity'] = capacity + + (rc, drives_resp) = request(self.api_url + "/storage-systems/%s/drives" % (self.ssid), + data=json.dumps(drives_req), headers=self.post_headers, method='POST', + url_username=self.api_username, url_password=self.api_password, + validate_certs=self.validate_certs) + + if rc == 204: + self.module.fail_json(msg='Cannot find disks to match requested criteria for ssd cache') + + disk_ids = [d['id'] for d in drives_resp] + bytes = reduce(lambda s, d: s + int(d['usableCapacity']), drives_resp, 0) + + return (disk_ids, bytes) + + def create_cache(self): + (disk_ids, bytes) = self.get_candidate_disks(disk_count=self.disk_count, size_unit=self.size_unit, + capacity=self.cache_size_min) + + self.debug("creating ssd cache...") + + create_fc_req = dict( + driveRefs=disk_ids, + name=self.name + ) + + (rc, self.resp) = request(self.api_url + "/storage-systems/%s/flash-cache" % (self.ssid), + data=json.dumps(create_fc_req), headers=self.post_headers, method='POST', + url_username=self.api_username, url_password=self.api_password, + validate_certs=self.validate_certs) + + def update_cache(self): + self.debug('updating flash cache config...') + update_fc_req = dict( + name=self.name, + configType=self.io_type + ) + + (rc, self.resp) = request(self.api_url + "/storage-systems/%s/flash-cache/configure" % (self.ssid), + data=json.dumps(update_fc_req), headers=self.post_headers, method='POST', + url_username=self.api_username, url_password=self.api_password, + validate_certs=self.validate_certs) + + def delete_cache(self): + self.debug('deleting flash cache...') + (rc, self.resp) = request(self.api_url + "/storage-systems/%s/flash-cache" % (self.ssid), method='DELETE', + url_username=self.api_username, url_password=self.api_password, + validate_certs=self.validate_certs, ignore_errors=True) + + @property + def needs_more_disks(self): + if len(self.cache_detail['driveRefs']) < self.disk_count: + self.debug("needs resize: current disk count %s < requested requested count %s" % ( + len(self.cache_detail['driveRefs']), self.disk_count)) + return True + + @property + def needs_less_disks(self): + if len(self.cache_detail['driveRefs']) > self.disk_count: + self.debug("needs resize: current disk count %s < requested requested count %s" % ( + len(self.cache_detail['driveRefs']), self.disk_count)) + return True + + @property + def current_size_bytes(self): + return int(self.cache_detail['fcDriveInfo']['fcWithDrives']['usedCapacity']) + + @property + def requested_size_bytes(self): + if self.cache_size_min: + return self.cache_size_min * self._size_unit_map[self.size_unit] + else: + return 0 + + @property + def needs_more_capacity(self): + if self.current_size_bytes < self.requested_size_bytes: + self.debug("needs resize: current capacity %sb is less than requested minimum %sb" % ( + self.current_size_bytes, self.requested_size_bytes)) + return True + + @property + def needs_resize(self): + return self.needs_more_disks or self.needs_more_capacity or self.needs_less_disks + + def resize_cache(self): + # increase up to disk count first, then iteratively add disks until we meet requested capacity + + # TODO: perform this calculation in check mode + current_disk_count = len(self.cache_detail['driveRefs']) + proposed_new_disks = 0 + + proposed_additional_bytes = 0 + proposed_disk_ids = [] + + if self.needs_more_disks: + proposed_disk_count = self.disk_count - current_disk_count + + (disk_ids, bytes) = self.get_candidate_disks(disk_count=proposed_disk_count) + proposed_additional_bytes = bytes + proposed_disk_ids = disk_ids + + while self.current_size_bytes + proposed_additional_bytes < self.requested_size_bytes: + proposed_new_disks += 1 + (disk_ids, bytes) = self.get_candidate_disks(disk_count=proposed_new_disks) + proposed_disk_ids = disk_ids + proposed_additional_bytes = bytes + + add_drives_req = dict( + driveRef=proposed_disk_ids + ) + + self.debug("adding drives to flash-cache...") + (rc, self.resp) = request(self.api_url + "/storage-systems/%s/flash-cache/addDrives" % (self.ssid), + data=json.dumps(add_drives_req), headers=self.post_headers, method='POST', + url_username=self.api_username, url_password=self.api_password, + validate_certs=self.validate_certs) + + elif self.needs_less_disks and self.driveRefs: + rm_drives = dict(driveRef=self.driveRefs) + (rc, self.resp) = request(self.api_url + "/storage-systems/%s/flash-cache/removeDrives" % (self.ssid), + data=json.dumps(rm_drives), headers=self.post_headers, method='POST', + url_username=self.api_username, url_password=self.api_password, + validate_certs=self.validate_certs) + + def apply(self): + result = dict(changed=False) + (rc, cache_resp) = request(self.api_url + "/storage-systems/%s/flash-cache" % (self.ssid), + url_username=self.api_username, url_password=self.api_password, + validate_certs=self.validate_certs, ignore_errors=True) + + if rc == 200: + self.cache_detail = cache_resp + else: + self.cache_detail = None + + if rc not in [200, 404]: + raise Exception( + "Unexpected error code %s fetching flash cache detail. Response data was %s" % (rc, cache_resp)) + + if self.state == 'present': + if self.cache_detail: + # TODO: verify parameters against detail for changes + if self.cache_detail['name'] != self.name: + self.debug("CHANGED: name differs") + result['changed'] = True + if self.cache_detail['flashCacheBase']['configType'] != self.io_type: + self.debug("CHANGED: io_type differs") + result['changed'] = True + if self.needs_resize: + self.debug("CHANGED: resize required") + result['changed'] = True + else: + self.debug("CHANGED: requested state is 'present' but cache does not exist") + result['changed'] = True + else: # requested state is absent + if self.cache_detail: + self.debug("CHANGED: requested state is 'absent' but cache exists") + result['changed'] = True + + if not result['changed']: + self.debug("no changes, exiting...") + self.module.exit_json(**result) + + if self.module.check_mode: + self.debug("changes pending in check mode, exiting early...") + self.module.exit_json(**result) + + if self.state == 'present': + if not self.cache_detail: + self.create_cache() + else: + if self.needs_resize: + self.resize_cache() + + # run update here as well, since io_type can't be set on creation + self.update_cache() + + elif self.state == 'absent': + self.delete_cache() + + # TODO: include other details about the storage pool (size, type, id, etc) + self.module.exit_json(changed=result['changed'], **self.resp) + + +def main(): + sp = NetAppESeriesFlashCache() + try: + sp.apply() + except Exception: + e = get_exception() + sp.debug("Exception in apply(): \n%s" % str(e)) + sp.module.fail_json(msg="Failed to create flash cache. Error[%s]" % str(e)) + +if __name__ == '__main__': + main() diff --git a/storage/netapp/netapp_e_host.py b/storage/netapp/netapp_e_host.py new file mode 100644 index 00000000000..2261d8264de --- /dev/null +++ b/storage/netapp/netapp_e_host.py @@ -0,0 +1,425 @@ +#!/usr/bin/python + +# (c) 2016, NetApp, Inc +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# +DOCUMENTATION = """ +--- +module: netapp_e_host +short_description: manage eseries hosts +description: + - Create, update, remove hosts on NetApp E-series storage arrays +version_added: '2.2' +author: Kevin Hulquest (@hulquest) +options: + api_username: + required: true + description: + - The username to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_password: + required: true + description: + - The password to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_url: + required: true + description: + - The url to the SANtricity WebServices Proxy or embedded REST API. + example: + - https://prod-1.wahoo.acme.com/devmgr/v2 + validate_certs: + required: false + default: true + description: + - Should https certificates be validated? + ssid: + description: + - the id of the storage array you wish to act against + required: True + name: + description: + - If the host doesnt yet exist, the label to assign at creation time. + - If the hosts already exists, this is what is used to identify the host to apply any desired changes + required: True + host_type_index: + description: + - The index that maps to host type you wish to create. It is recommended to use the M(netapp_e_facts) module to gather this information. Alternatively you can use the WSP portal to retrieve the information. + required: True + ports: + description: + - a list of of dictionaries of host ports you wish to associate with the newly created host + required: False + group: + description: + - the group you want the host to be a member of + required: False + +""" + +EXAMPLES = """ + - name: Set Host Info + netapp_e_host: + ssid: "{{ ssid }}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + name: "{{ host_name }}" + host_type_index: "{{ host_type_index }}" +""" + +RETURN = """ +msg: + description: Success message + returned: success + type: string + sample: The host has been created. +""" +import json + +from ansible.module_utils.api import basic_auth_argument_spec +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.pycompat24 import get_exception +from ansible.module_utils.urls import open_url +from ansible.module_utils.six.moves.urllib.error import HTTPError + +HEADERS = { + "Content-Type": "application/json", + "Accept": "application/json", +} + + +def request(url, data=None, headers=None, method='GET', use_proxy=True, + force=False, last_mod_time=None, timeout=10, validate_certs=True, + url_username=None, url_password=None, http_agent=None, force_basic_auth=True, ignore_errors=False): + try: + r = open_url(url=url, data=data, headers=headers, method=method, use_proxy=use_proxy, + force=force, last_mod_time=last_mod_time, timeout=timeout, validate_certs=validate_certs, + url_username=url_username, url_password=url_password, http_agent=http_agent, + force_basic_auth=force_basic_auth) + except HTTPError: + err = get_exception() + r = err.fp + + try: + raw_data = r.read() + if raw_data: + data = json.loads(raw_data) + else: + raw_data is None + except: + if ignore_errors: + pass + else: + raise Exception(raw_data) + + resp_code = r.getcode() + + if resp_code >= 400 and not ignore_errors: + raise Exception(resp_code, data) + else: + return resp_code, data + + +class Host(object): + def __init__(self): + argument_spec = basic_auth_argument_spec() + argument_spec.update(dict( + api_username=dict(type='str', required=True), + api_password=dict(type='str', required=True, no_log=True), + api_url=dict(type='str', required=True), + ssid=dict(type='str', required=True), + state=dict(type='str', required=True, choices=['absent', 'present']), + group=dict(type='str', required=False), + ports=dict(type='list', required=False), + force_port=dict(type='bool', default=False), + name=dict(type='str', required=True), + host_type_index=dict(type='int', required=True) + )) + + self.module = AnsibleModule(argument_spec=argument_spec) + args = self.module.params + self.group = args['group'] + self.ports = args['ports'] + self.force_port = args['force_port'] + self.name = args['name'] + self.host_type_index = args['host_type_index'] + self.state = args['state'] + self.ssid = args['ssid'] + self.url = args['api_url'] + self.user = args['api_username'] + self.pwd = args['api_password'] + self.certs = args['validate_certs'] + self.ports = args['ports'] + self.post_body = dict() + + if not self.url.endswith('/'): + self.url += '/' + + @property + def valid_host_type(self): + try: + (rc, host_types) = request(self.url + 'storage-systems/%s/host-types' % self.ssid, url_password=self.pwd, + url_username=self.user, validate_certs=self.certs, headers=HEADERS) + except Exception: + err = get_exception() + self.module.fail_json( + msg="Failed to get host types. Array Id [%s]. Error [%s]." % (self.ssid, str(err))) + + try: + match = filter(lambda host_type: host_type['index'] == self.host_type_index, host_types)[0] + return True + except IndexError: + self.module.fail_json(msg="There is no host type with index %s" % self.host_type_index) + + @property + def hostports_available(self): + used_ids = list() + try: + (rc, self.available_ports) = request(self.url + 'storage-systems/%s/unassociated-host-ports' % self.ssid, + url_password=self.pwd, url_username=self.user, + validate_certs=self.certs, + headers=HEADERS) + except: + err = get_exception() + self.module.fail_json( + msg="Failed to get unassociated host ports. Array Id [%s]. Error [%s]." % (self.ssid, str(err))) + + if len(self.available_ports) > 0 and len(self.ports) <= len(self.available_ports): + for port in self.ports: + for free_port in self.available_ports: + # Desired Type matches but also make sure we havent already used the ID + if not free_port['id'] in used_ids: + # update the port arg to have an id attribute + used_ids.append(free_port['id']) + break + + if len(used_ids) != len(self.ports) and not self.force_port: + self.module.fail_json( + msg="There are not enough free host ports with the specified port types to proceed") + else: + return True + + else: + self.module.fail_json(msg="There are no host ports available OR there are not enough unassigned host ports") + + @property + def group_id(self): + if self.group: + try: + (rc, all_groups) = request(self.url + 'storage-systems/%s/host-groups' % self.ssid, + url_password=self.pwd, + url_username=self.user, validate_certs=self.certs, headers=HEADERS) + except: + err = get_exception() + self.module.fail_json( + msg="Failed to get host groups. Array Id [%s]. Error [%s]." % (self.ssid, str(err))) + + try: + group_obj = filter(lambda group: group['name'] == self.group, all_groups)[0] + return group_obj['id'] + except IndexError: + self.module.fail_json(msg="No group with the name: %s exists" % self.group) + else: + # Return the value equivalent of no group + return "0000000000000000000000000000000000000000" + + @property + def host_exists(self): + try: + (rc, all_hosts) = request(self.url + 'storage-systems/%s/hosts' % self.ssid, url_password=self.pwd, + url_username=self.user, validate_certs=self.certs, headers=HEADERS) + except: + err = get_exception() + self.module.fail_json( + msg="Failed to determine host existence. Array Id [%s]. Error [%s]." % (self.ssid, str(err))) + + self.all_hosts = all_hosts + try: # Try to grab the host object + self.host_obj = filter(lambda host: host['label'] == self.name, all_hosts)[0] + return True + except IndexError: + # Host with the name passed in does not exist + return False + + @property + def needs_update(self): + needs_update = False + self.force_port_update = False + + if self.host_obj['clusterRef'] != self.group_id or \ + self.host_obj['hostTypeIndex'] != self.host_type_index: + needs_update = True + + if self.ports: + if not self.host_obj['ports']: + needs_update = True + for arg_port in self.ports: + # First a quick check to see if the port is mapped to a different host + if not self.port_on_diff_host(arg_port): + for obj_port in self.host_obj['ports']: + if arg_port['label'] == obj_port['label']: + # Confirmed that port arg passed in exists on the host + # port_id = self.get_port_id(obj_port['label']) + if arg_port['type'] != obj_port['portId']['ioInterfaceType']: + needs_update = True + if 'iscsiChapSecret' in arg_port: + # No way to know the current secret attr, so always return True just in case + needs_update = True + else: + # If the user wants the ports to be reassigned, do it + if self.force_port: + self.force_port_update = True + needs_update = True + else: + self.module.fail_json( + msg="The port you specified:\n%s\n is associated with a different host. Specify force_port as True or try a different port spec" % arg_port) + + return needs_update + + def port_on_diff_host(self, arg_port): + """ Checks to see if a passed in port arg is present on a different host """ + for host in self.all_hosts: + # Only check 'other' hosts + if self.host_obj['name'] != self.name: + for port in host['ports']: + # Check if the port label is found in the port dict list of each host + if arg_port['label'] == port['label']: + self.other_host = host + return True + return False + + def reassign_ports(self, apply=True): + if not self.post_body: + self.post_body = dict( + portsToUpdate=dict() + ) + + for port in self.ports: + if self.port_on_diff_host(port): + self.post_body['portsToUpdate'].update(dict( + portRef=self.other_host['hostPortRef'], + hostRef=self.host_obj['id'], + # Doesnt yet address port identifier or chap secret + )) + + if apply: + try: + (rc, self.host_obj) = request( + self.url + 'storage-systems/%s/hosts/%s' % (self.ssid, self.host_obj['id']), + url_username=self.user, url_password=self.pwd, headers=HEADERS, + validate_certs=self.certs, method='POST', data=json.dumps(self.post_body)) + except: + err = get_exception() + self.module.fail_json( + msg="Failed to reassign host port. Host Id [%s]. Array Id [%s]. Error [%s]." % ( + self.host_obj['id'], self.ssid, str(err))) + + def update_host(self): + if self.ports: + if self.hostports_available: + if self.force_port_update is True: + self.reassign_ports(apply=False) + # Make sure that only ports that arent being reassigned are passed into the ports attr + self.ports = [port for port in self.ports if not self.port_on_diff_host(port)] + + self.post_body['ports'] = self.ports + + if self.group: + self.post_body['groupId'] = self.group_id + + self.post_body['hostType'] = dict(index=self.host_type_index) + + try: + (rc, self.host_obj) = request(self.url + 'storage-systems/%s/hosts/%s' % (self.ssid, self.host_obj['id']), + url_username=self.user, url_password=self.pwd, headers=HEADERS, + validate_certs=self.certs, method='POST', data=json.dumps(self.post_body)) + except: + err = get_exception() + self.module.fail_json(msg="Failed to update host. Array Id [%s]. Error [%s]." % (self.ssid, str(err))) + + self.module.exit_json(changed=True, **self.host_obj) + + def create_host(self): + post_body = dict( + name=self.name, + host_type=dict(index=self.host_type_index), + groupId=self.group_id, + ports=self.ports + ) + if self.ports: + # Check that all supplied port args are valid + if self.hostports_available: + post_body.update(ports=self.ports) + elif not self.force_port: + self.module.fail_json( + msg="You supplied ports that are already in use. Supply force_port to True if you wish to reassign the ports") + + if not self.host_exists: + try: + (rc, create_resp) = request(self.url + "storage-systems/%s/hosts" % self.ssid, method='POST', + url_username=self.user, url_password=self.pwd, validate_certs=self.certs, + data=json.dumps(post_body), headers=HEADERS) + except: + err = get_exception() + self.module.fail_json( + msg="Failed to create host. Array Id [%s]. Error [%s]." % (self.ssid, str(err))) + else: + self.module.exit_json(changed=False, + msg="Host already exists. Id [%s]. Host [%s]." % (self.ssid, self.name)) + + self.host_obj = create_resp + + if self.ports and self.force_port: + self.reassign_ports() + + self.module.exit_json(changed=True, **self.host_obj) + + def remove_host(self): + try: + (rc, resp) = request(self.url + "storage-systems/%s/hosts/%s" % (self.ssid, self.host_obj['id']), + method='DELETE', + url_username=self.user, url_password=self.pwd, validate_certs=self.certs) + except: + err = get_exception() + self.module.fail_json( + msg="Failed to remote host. Host[%s]. Array Id [%s]. Error [%s]." % (self.host_obj['id'], + self.ssid, + str(err))) + + def apply(self): + if self.state == 'present': + if self.host_exists: + if self.needs_update and self.valid_host_type: + self.update_host() + else: + self.module.exit_json(changed=False, msg="Host already present.", id=self.ssid, label=self.name) + elif self.valid_host_type: + self.create_host() + else: + if self.host_exists: + self.remove_host() + self.module.exit_json(changed=True, msg="Host removed.") + else: + self.module.exit_json(changed=False, msg="Host already absent.", id=self.ssid, label=self.name) + + +def main(): + host = Host() + host.apply() + + +if __name__ == '__main__': + main() diff --git a/storage/netapp/netapp_e_hostgroup.py b/storage/netapp/netapp_e_hostgroup.py new file mode 100644 index 00000000000..5248c1d9531 --- /dev/null +++ b/storage/netapp/netapp_e_hostgroup.py @@ -0,0 +1,413 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2016, NetApp, Inc +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# +# +DOCUMENTATION = ''' +--- +module: netapp_e_hostgroup +version_added: "2.2" +short_description: Manage NetApp Storage Array Host Groups +author: Kevin Hulquest (@hulquest) +description: +- Create, update or destroy host groups on a NetApp E-Series storage array. +options: + api_username: + required: true + description: + - The username to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_password: + required: true + description: + - The password to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_url: + required: true + description: + - The url to the SANtricity WebServices Proxy or embedded REST API. + example: + - https://prod-1.wahoo.acme.com/devmgr/v2 + validate_certs: + required: false + default: true + description: + - Should https certificates be validated? + ssid: + required: true + description: + - The ID of the array to manage (as configured on the web services proxy). + state: + required: true + description: + - Whether the specified host group should exist or not. + choices: ['present', 'absent'] + name: + required: false + description: + - The name of the host group to manage. Either this or C(id_num) must be supplied. + new_name: + required: false + description: + - specify this when you need to update the name of a host group + id: + required: false + description: + - The id number of the host group to manage. Either this or C(name) must be supplied. + hosts:: + required: false + description: + - a list of host names/labels to add to the group +''' +EXAMPLES = ''' + - name: Configure Hostgroup + netapp_e_hostgroup: + ssid: "{{ ssid }}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" + state: present +''' +RETURN = ''' +clusterRef: + description: The unique identification value for this object. Other objects may use this reference value to refer to the cluster. + returned: always except when state is absent + type: string + sample: "3233343536373839303132333100000000000000" +confirmLUNMappingCreation: + description: If true, indicates that creation of LUN-to-volume mappings should require careful confirmation from the end-user, since such a mapping will alter the volume access rights of other clusters, in addition to this one. + returned: always + type: boolean + sample: false +hosts: + description: A list of the hosts that are part of the host group after all operations. + returned: always except when state is absent + type: list + sample: ["HostA","HostB"] +id: + description: The id number of the hostgroup + returned: always except when state is absent + type: string + sample: "3233343536373839303132333100000000000000" +isSAControlled: + description: If true, indicates that I/O accesses from this cluster are subject to the storage array's default LUN-to-volume mappings. If false, indicates that I/O accesses from the cluster are subject to cluster-specific LUN-to-volume mappings. + returned: always except when state is absent + type: boolean + sample: false +label: + description: The user-assigned, descriptive label string for the cluster. + returned: always + type: string + sample: "MyHostGroup" +name: + description: same as label + returned: always except when state is absent + type: string + sample: "MyHostGroup" +protectionInformationCapableAccessMethod: + description: This field is true if the host has a PI capable access method. + returned: always except when state is absent + type: boolean + sample: true +''' + +HEADERS = { + "Content-Type": "application/json", + "Accept": "application/json" +} + +import json + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.pycompat24 import get_exception + +from ansible.module_utils.urls import open_url +from ansible.module_utils.six.moves.urllib.error import HTTPError + + +def request(url, data=None, headers=None, method='GET', use_proxy=True, + force=False, last_mod_time=None, timeout=10, validate_certs=True, + url_username=None, url_password=None, http_agent=None, force_basic_auth=True, ignore_errors=False): + try: + r = open_url(url=url, data=data, headers=headers, method=method, use_proxy=use_proxy, + force=force, last_mod_time=last_mod_time, timeout=timeout, validate_certs=validate_certs, + url_username=url_username, url_password=url_password, http_agent=http_agent, + force_basic_auth=force_basic_auth) + except HTTPError: + err = get_exception() + r = err.fp + + try: + raw_data = r.read() + if raw_data: + data = json.loads(raw_data) + else: + raw_data = None + except: + if ignore_errors: + pass + else: + raise Exception(raw_data) + + resp_code = r.getcode() + + if resp_code >= 400 and not ignore_errors: + raise Exception(resp_code, data) + else: + return resp_code, data + + +def group_exists(module, id_type, ident, ssid, api_url, user, pwd): + rc, data = get_hostgroups(module, ssid, api_url, user, pwd) + for group in data: + if group[id_type] == ident: + return True, data + else: + continue + + return False, data + + +def get_hostgroups(module, ssid, api_url, user, pwd): + groups = "storage-systems/%s/host-groups" % ssid + url = api_url + groups + try: + rc, data = request(url, headers=HEADERS, url_username=user, url_password=pwd) + return rc, data + except HTTPError: + err = get_exception() + module.fail_json(msg="Failed to get host groups. Id [%s]. Error [%s]." % (ssid, str(err))) + + +def get_hostref(module, ssid, name, api_url, user, pwd): + all_hosts = 'storage-systems/%s/hosts' % ssid + url = api_url + all_hosts + try: + rc, data = request(url, method='GET', headers=HEADERS, url_username=user, url_password=pwd) + except Exception: + err = get_exception() + module.fail_json(msg="Failed to get hosts. Id [%s]. Error [%s]." % (ssid, str(err))) + + for host in data: + if host['name'] == name: + return host['hostRef'] + else: + continue + + module.fail_json(msg="No host with the name %s could be found" % name) + + +def create_hostgroup(module, ssid, name, api_url, user, pwd, hosts=None): + groups = "storage-systems/%s/host-groups" % ssid + url = api_url + groups + hostrefs = [] + + if hosts: + for host in hosts: + href = get_hostref(module, ssid, host, api_url, user, pwd) + hostrefs.append(href) + + post_data = json.dumps(dict(name=name, hosts=hostrefs)) + try: + rc, data = request(url, method='POST', data=post_data, headers=HEADERS, url_username=user, url_password=pwd) + except Exception: + err = get_exception() + module.fail_json(msg="Failed to create host group. Id [%s]. Error [%s]." % (ssid, str(err))) + + return rc, data + + +def update_hostgroup(module, ssid, name, api_url, user, pwd, hosts=None, new_name=None): + gid = get_hostgroup_id(module, ssid, name, api_url, user, pwd) + groups = "storage-systems/%s/host-groups/%s" % (ssid, gid) + url = api_url + groups + hostrefs = [] + + if hosts: + for host in hosts: + href = get_hostref(module, ssid, host, api_url, user, pwd) + hostrefs.append(href) + + if new_name: + post_data = json.dumps(dict(name=new_name, hosts=hostrefs)) + else: + post_data = json.dumps(dict(hosts=hostrefs)) + + try: + rc, data = request(url, method='POST', data=post_data, headers=HEADERS, url_username=user, url_password=pwd) + except Exception: + err = get_exception() + module.fail_json(msg="Failed to update host group. Group [%s]. Id [%s]. Error [%s]." % (gid, ssid, + str(err))) + + return rc, data + + +def delete_hostgroup(module, ssid, group_id, api_url, user, pwd): + groups = "storage-systems/%s/host-groups/%s" % (ssid, group_id) + url = api_url + groups + # TODO: Loop through hosts, do mapping to href, make new list to pass to data + try: + rc, data = request(url, method='DELETE', headers=HEADERS, url_username=user, url_password=pwd) + except Exception: + err = get_exception() + module.fail_json(msg="Failed to delete host group. Group [%s]. Id [%s]. Error [%s]." % (group_id, ssid, str(err))) + + return rc, data + + +def get_hostgroup_id(module, ssid, name, api_url, user, pwd): + all_groups = 'storage-systems/%s/host-groups' % ssid + url = api_url + all_groups + rc, data = request(url, method='GET', headers=HEADERS, url_username=user, url_password=pwd) + for hg in data: + if hg['name'] == name: + return hg['id'] + else: + continue + + module.fail_json(msg="A hostgroup with the name %s could not be found" % name) + + +def get_hosts_in_group(module, ssid, group_name, api_url, user, pwd): + all_groups = 'storage-systems/%s/host-groups' % ssid + g_url = api_url + all_groups + try: + g_rc, g_data = request(g_url, method='GET', headers=HEADERS, url_username=user, url_password=pwd) + except Exception: + err = get_exception() + module.fail_json( + msg="Failed in first step getting hosts from group. Group: [%s]. Id [%s]. Error [%s]." % (group_name, + ssid, + str(err))) + + all_hosts = 'storage-systems/%s/hosts' % ssid + h_url = api_url + all_hosts + try: + h_rc, h_data = request(h_url, method='GET', headers=HEADERS, url_username=user, url_password=pwd) + except Exception: + err = get_exception() + module.fail_json( + msg="Failed in second step getting hosts from group. Group: [%s]. Id [%s]. Error [%s]." % ( + group_name, + ssid, + str(err))) + + hosts_in_group = [] + + for hg in g_data: + if hg['name'] == group_name: + clusterRef = hg['clusterRef'] + + for host in h_data: + if host['clusterRef'] == clusterRef: + hosts_in_group.append(host['name']) + + return hosts_in_group + + +def main(): + module = AnsibleModule( + argument_spec=dict( + name=dict(required=False), + new_name=dict(required=False), + ssid=dict(required=True), + id=dict(required=False), + state=dict(required=True, choices=['present', 'absent']), + hosts=dict(required=False, type='list'), + api_url=dict(required=True), + api_username=dict(required=True), + validate_certs=dict(required=False, default=True), + api_password=dict(required=True, no_log=True) + ), + supports_check_mode=False, + mutually_exclusive=[['name', 'id']], + required_one_of=[['name', 'id']] + ) + + name = module.params['name'] + new_name = module.params['new_name'] + ssid = module.params['ssid'] + id_num = module.params['id'] + state = module.params['state'] + hosts = module.params['hosts'] + user = module.params['api_username'] + pwd = module.params['api_password'] + api_url = module.params['api_url'] + + if not api_url.endswith('/'): + api_url += '/' + + if name: + id_type = 'name' + id_key = name + elif id_num: + id_type = 'id' + id_key = id_num + + exists, group_data = group_exists(module, id_type, id_key, ssid, api_url, user, pwd) + + if state == 'present': + if not exists: + try: + rc, data = create_hostgroup(module, ssid, name, api_url, user, pwd, hosts) + except Exception: + err = get_exception() + module.fail_json(msg="Failed to create a host group. Id [%s]. Error [%s]." % (ssid, str(err))) + + hosts = get_hosts_in_group(module, ssid, name, api_url, user, pwd) + module.exit_json(changed=True, hosts=hosts, **data) + else: + current_hosts = get_hosts_in_group(module, ssid, name, api_url, user, pwd) + + if not current_hosts: + current_hosts = [] + + if not hosts: + hosts = [] + + if set(current_hosts) != set(hosts): + try: + rc, data = update_hostgroup(module, ssid, name, api_url, user, pwd, hosts, new_name) + except Exception: + err = get_exception() + module.fail_json( + msg="Failed to update host group. Group: [%s]. Id [%s]. Error [%s]." % (name, ssid, str(err))) + module.exit_json(changed=True, hosts=hosts, **data) + else: + for group in group_data: + if group['name'] == name: + module.exit_json(changed=False, hosts=current_hosts, **group) + + elif state == 'absent': + if exists: + hg_id = get_hostgroup_id(module, ssid, name, api_url, user, pwd) + try: + rc, data = delete_hostgroup(module, ssid, hg_id, api_url, user, pwd) + except Exception: + err = get_exception() + module.fail_json( + msg="Failed to delete host group. Group: [%s]. Id [%s]. Error [%s]." % (name, ssid, str(err))) + + module.exit_json(changed=True, msg="Host Group deleted") + else: + module.exit_json(changed=False, msg="Host Group is already absent") + + +if __name__ == '__main__': + main() diff --git a/storage/netapp/netapp_e_lun_mapping.py b/storage/netapp/netapp_e_lun_mapping.py new file mode 100644 index 00000000000..439a7e4f5e5 --- /dev/null +++ b/storage/netapp/netapp_e_lun_mapping.py @@ -0,0 +1,365 @@ +#!/usr/bin/python + +# (c) 2016, NetApp, Inc +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# +DOCUMENTATION = ''' +--- +module: netapp_e_lun_mapping +author: Kevin Hulquest (@hulquest) +short_description: + - Create or Remove LUN Mappings +description: + - Allows for the creation and removal of volume to host mappings for NetApp E-series storage arrays. +version_added: "2.2" +options: + api_username: + required: true + description: + - The username to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_password: + required: true + description: + - The password to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_url: + required: true + description: + - The url to the SANtricity WebServices Proxy or embedded REST API. + example: + - https://prod-1.wahoo.acme.com/devmgr/v2 + validate_certs: + required: false + default: true + description: + - Should https certificates be validated? + ssid: + description: + - "The storage system array identifier." + required: False + lun: + description: + - The LUN number you wish to give the mapping + - If the supplied I(volume_name) is associated with a different LUN, it will be updated to what is supplied here. + required: False + default: 0 + target: + description: + - The name of host or hostgroup you wish to assign to the mapping + - If omitted, the default hostgroup is used. + - If the supplied I(volume_name) is associated with a different target, it will be updated to what is supplied here. + required: False + volume_name: + description: + - The name of the volume you wish to include in the mapping. + required: True + target_type: + description: + - Whether the target is a host or group. + - Required if supplying an explicit target. + required: False + choices: ["host", "group"] + state: + description: + - Present will ensure the mapping exists, absent will remove the mapping. + - All parameters I(lun), I(target), I(target_type) and I(volume_name) must still be supplied. + required: True + choices: ["present", "absent"] + api_url: + description: + - "The full API url. Example: http://ENDPOINT:8080/devmgr/v2" + - This can optionally be set via an environment variable, API_URL + required: False + api_username: + description: + - The username used to authenticate against the API. This can optionally be set via an environment variable, API_USERNAME + required: False + api_password: + description: + - The password used to authenticate against the API. This can optionally be set via an environment variable, API_PASSWORD + required: False +''' + +EXAMPLES = ''' +--- + - name: Lun Mapping Example + netapp_e_lun_mapping: + state: present + ssid: 1 + lun: 12 + target: Wilson + volume_name: Colby1 + target_type: group + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" +''' +RETURN = ''' +msg: Mapping exists. +msg: Mapping removed. +''' +import json + +from ansible.module_utils.api import basic_auth_argument_spec +from ansible.module_utils.pycompat24 import get_exception +from ansible.module_utils.urls import open_url + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six.moves.urllib.error import HTTPError + +HEADERS = { + "Content-Type": "application/json", + "Accept": "application/json" +} + + +def request(url, data=None, headers=None, method='GET', use_proxy=True, + force=False, last_mod_time=None, timeout=10, validate_certs=True, + url_username=None, url_password=None, http_agent=None, force_basic_auth=True, ignore_errors=False): + try: + r = open_url(url=url, data=data, headers=headers, method=method, use_proxy=use_proxy, + force=force, last_mod_time=last_mod_time, timeout=timeout, validate_certs=validate_certs, + url_username=url_username, url_password=url_password, http_agent=http_agent, + force_basic_auth=force_basic_auth) + except HTTPError: + err = get_exception() + r = err.fp + + try: + raw_data = r.read() + if raw_data: + data = json.loads(raw_data) + else: + raw_data = None + except: + if ignore_errors: + pass + else: + raise Exception(raw_data) + + resp_code = r.getcode() + + if resp_code >= 400 and not ignore_errors: + raise Exception(resp_code, data) + else: + return resp_code, data + + +def get_host_and_group_map(module, ssid, api_url, user, pwd): + mapping = dict(host=dict(), group=dict()) + + hostgroups = 'storage-systems/%s/host-groups' % ssid + groups_url = api_url + hostgroups + try: + hg_rc, hg_data = request(groups_url, headers=HEADERS, url_username=user, url_password=pwd) + except: + err = get_exception() + module.fail_json(msg="Failed to get host groups. Id [%s]. Error [%s]" % (ssid, str(err))) + + for group in hg_data: + mapping['group'][group['name']] = group['id'] + + hosts = 'storage-systems/%s/hosts' % ssid + hosts_url = api_url + hosts + try: + h_rc, h_data = request(hosts_url, headers=HEADERS, url_username=user, url_password=pwd) + except: + err = get_exception() + module.fail_json(msg="Failed to get hosts. Id [%s]. Error [%s]" % (ssid, str(err))) + + for host in h_data: + mapping['host'][host['name']] = host['id'] + + return mapping + + +def get_volume_id(module, data, ssid, name, api_url, user, pwd): + qty = 0 + for volume in data: + if volume['name'] == name: + qty += 1 + + if qty > 1: + module.fail_json(msg="More than one volume with the name: %s was found, " + "please use the volume WWN instead" % name) + else: + wwn = volume['wwn'] + + try: + return wwn + except NameError: + module.fail_json(msg="No volume with the name: %s, was found" % (name)) + + +def get_hostgroups(module, ssid, api_url, user, pwd): + groups = "storage-systems/%s/host-groups" % ssid + url = api_url + groups + try: + rc, data = request(url, headers=HEADERS, url_username=user, url_password=pwd) + return data + except Exception: + module.fail_json(msg="There was an issue with connecting, please check that your" + "endpoint is properly defined and your credentials are correct") + + +def get_volumes(module, ssid, api_url, user, pwd, mappable): + volumes = 'storage-systems/%s/%s' % (ssid, mappable) + url = api_url + volumes + try: + rc, data = request(url, url_username=user, url_password=pwd) + except Exception: + err = get_exception() + module.fail_json( + msg="Failed to mappable objects. Type[%s. Id [%s]. Error [%s]." % (mappable, ssid, str(err))) + return data + + +def get_lun_mappings(ssid, api_url, user, pwd, get_all=None): + mappings = 'storage-systems/%s/volume-mappings' % ssid + url = api_url + mappings + rc, data = request(url, url_username=user, url_password=pwd) + + if not get_all: + remove_keys = ('ssid', 'perms', 'lunMappingRef', 'type', 'id') + + for key in remove_keys: + for mapping in data: + del mapping[key] + + return data + + +def create_mapping(module, ssid, lun_map, vol_name, api_url, user, pwd): + mappings = 'storage-systems/%s/volume-mappings' % ssid + url = api_url + mappings + post_body = json.dumps(dict( + mappableObjectId=lun_map['volumeRef'], + targetId=lun_map['mapRef'], + lun=lun_map['lun'] + )) + + rc, data = request(url, data=post_body, method='POST', url_username=user, url_password=pwd, headers=HEADERS, + ignore_errors=True) + + if rc == 422: + data = move_lun(module, ssid, lun_map, vol_name, api_url, user, pwd) + # module.fail_json(msg="The volume you specified '%s' is already " + # "part of a different LUN mapping. If you " + # "want to move it to a different host or " + # "hostgroup, then please use the " + # "netapp_e_move_lun module" % vol_name) + return data + + +def move_lun(module, ssid, lun_map, vol_name, api_url, user, pwd): + lun_id = get_lun_id(module, ssid, lun_map, api_url, user, pwd) + move_lun = "storage-systems/%s/volume-mappings/%s/move" % (ssid, lun_id) + url = api_url + move_lun + post_body = json.dumps(dict(targetId=lun_map['mapRef'], lun=lun_map['lun'])) + rc, data = request(url, data=post_body, method='POST', url_username=user, url_password=pwd, headers=HEADERS) + return data + + +def get_lun_id(module, ssid, lun_mapping, api_url, user, pwd): + data = get_lun_mappings(ssid, api_url, user, pwd, get_all=True) + + for lun_map in data: + if lun_map['volumeRef'] == lun_mapping['volumeRef']: + return lun_map['id'] + # This shouldn't ever get called + module.fail_json(msg="No LUN map found.") + + +def remove_mapping(module, ssid, lun_mapping, api_url, user, pwd): + lun_id = get_lun_id(module, ssid, lun_mapping, api_url, user, pwd) + lun_del = "storage-systems/%s/volume-mappings/%s" % (ssid, lun_id) + url = api_url + lun_del + rc, data = request(url, method='DELETE', url_username=user, url_password=pwd, headers=HEADERS) + return data + + +def main(): + argument_spec = basic_auth_argument_spec() + argument_spec.update(dict( + api_username=dict(type='str', required=True), + api_password=dict(type='str', required=True, no_log=True), + api_url=dict(type='str', required=True), + state=dict(required=True, choices=['present', 'absent']), + target=dict(required=False, default=None), + target_type=dict(required=False, choices=['host', 'group']), + lun=dict(required=False, type='int', default=0), + ssid=dict(required=False), + volume_name=dict(required=True), + )) + + module = AnsibleModule(argument_spec=argument_spec) + + state = module.params['state'] + target = module.params['target'] + target_type = module.params['target_type'] + lun = module.params['lun'] + ssid = module.params['ssid'] + vol_name = module.params['volume_name'] + user = module.params['api_username'] + pwd = module.params['api_password'] + api_url = module.params['api_url'] + + if not api_url.endswith('/'): + api_url += '/' + + volume_map = get_volumes(module, ssid, api_url, user, pwd, "volumes") + thin_volume_map = get_volumes(module, ssid, api_url, user, pwd, "thin-volumes") + volref = None + + for vol in volume_map: + if vol['label'] == vol_name: + volref = vol['volumeRef'] + + if not volref: + for vol in thin_volume_map: + if vol['label'] == vol_name: + volref = vol['volumeRef'] + + if not volref: + module.fail_json(changed=False, msg="No volume with the name %s was found" % vol_name) + + host_and_group_mapping = get_host_and_group_map(module, ssid, api_url, user, pwd) + + desired_lun_mapping = dict( + mapRef=host_and_group_mapping[target_type][target], + lun=lun, + volumeRef=volref + ) + + lun_mappings = get_lun_mappings(ssid, api_url, user, pwd) + + if state == 'present': + if desired_lun_mapping in lun_mappings: + module.exit_json(changed=False, msg="Mapping exists") + else: + result = create_mapping(module, ssid, desired_lun_mapping, vol_name, api_url, user, pwd) + module.exit_json(changed=True, **result) + + elif state == 'absent': + if desired_lun_mapping in lun_mappings: + result = remove_mapping(module, ssid, desired_lun_mapping, api_url, user, pwd) + module.exit_json(changed=True, msg="Mapping removed") + else: + module.exit_json(changed=False, msg="Mapping absent") + + +if __name__ == '__main__': + main() diff --git a/storage/netapp/netapp_e_snapshot_group.py b/storage/netapp/netapp_e_snapshot_group.py new file mode 100644 index 00000000000..90c6e8471bb --- /dev/null +++ b/storage/netapp/netapp_e_snapshot_group.py @@ -0,0 +1,382 @@ +#!/usr/bin/python + +# (c) 2016, NetApp, Inc +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# +DOCUMENTATION = """ +--- +module: netapp_e_snapshot_group +short_description: Manage snapshot groups +description: + - Create, update, delete snapshot groups for NetApp E-series storage arrays +version_added: '2.2' +author: Kevin Hulquest (@hulquest) +options: + api_username: + required: true + description: + - The username to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_password: + required: true + description: + - The password to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_url: + required: true + description: + - The url to the SANtricity WebServices Proxy or embedded REST API. + example: + - https://prod-1.wahoo.acme.com/devmgr/v2 + validate_certs: + required: false + default: true + description: + - Should https certificates be validated? + state: + description: + - Whether to ensure the group is present or absent. + required: True + choices: + - present + - absent + name: + description: + - The name to give the snapshot group + required: True + base_volume_name: + description: + - The name of the base volume or thin volume to use as the base for the new snapshot group. + - If a snapshot group with an identical C(name) already exists but with a different base volume + an error will be returned. + required: True + repo_pct: + description: + - The size of the repository in relation to the size of the base volume + required: False + default: 20 + warning_threshold: + description: + - The repository utilization warning threshold, as a percentage of the repository volume capacity. + required: False + default: 80 + delete_limit: + description: + - The automatic deletion indicator. + - If non-zero, the oldest snapshot image will be automatically deleted when creating a new snapshot image to keep the total number of snapshot images limited to the number specified. + - This value is overridden by the consistency group setting if this snapshot group is associated with a consistency group. + required: False + default: 30 + full_policy: + description: + - The behavior on when the data repository becomes full. + - This value is overridden by consistency group setting if this snapshot group is associated with a consistency group + required: False + default: purgepit + choices: + - purgepit + - unknown + - failbasewrites + - __UNDEFINED + storage_pool_name: + required: True + description: + - The name of the storage pool on which to allocate the repository volume. + rollback_priority: + required: False + description: + - The importance of the rollback operation. + - This value is overridden by consistency group setting if this snapshot group is associated with a consistency group + choices: + - highest + - high + - medium + - low + - lowest + - __UNDEFINED + default: medium +""" + +EXAMPLES = """ + - name: Configure Snapshot group + netapp_e_snapshot_group: + ssid: "{{ ssid }}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" + base_volume_name: SSGroup_test + name=: OOSS_Group + repo_pct: 20 + warning_threshold: 85 + delete_limit: 30 + full_policy: purgepit + storage_pool_name: Disk_Pool_1 + rollback_priority: medium +""" +RETURN = """ +msg: + description: Success message + returned: success + type: string + sample: json facts for newly created snapshot group. +""" +HEADERS = { + "Content-Type": "application/json", + "Accept": "application/json", +} +import json + +from ansible.module_utils.api import basic_auth_argument_spec +from ansible.module_utils.basic import AnsibleModule + +from ansible.module_utils.pycompat24 import get_exception +from ansible.module_utils.urls import open_url +from ansible.module_utils.six.moves.urllib.error import HTTPError + + +def request(url, data=None, headers=None, method='GET', use_proxy=True, + force=False, last_mod_time=None, timeout=10, validate_certs=True, + url_username=None, url_password=None, http_agent=None, force_basic_auth=True, ignore_errors=False): + try: + r = open_url(url=url, data=data, headers=headers, method=method, use_proxy=use_proxy, + force=force, last_mod_time=last_mod_time, timeout=timeout, validate_certs=validate_certs, + url_username=url_username, url_password=url_password, http_agent=http_agent, + force_basic_auth=force_basic_auth) + except HTTPError: + err = get_exception() + r = err.fp + + try: + raw_data = r.read() + if raw_data: + data = json.loads(raw_data) + else: + raw_data = None + except: + if ignore_errors: + pass + else: + raise Exception(raw_data) + + resp_code = r.getcode() + + if resp_code >= 400 and not ignore_errors: + raise Exception(resp_code, data) + else: + return resp_code, data + + +class SnapshotGroup(object): + def __init__(self): + + argument_spec = basic_auth_argument_spec() + argument_spec.update( + api_username=dict(type='str', required=True), + api_password=dict(type='str', required=True, no_log=True), + api_url=dict(type='str', required=True), + state=dict(required=True, choices=['present', 'absent']), + base_volume_name=dict(required=True), + name=dict(required=True), + repo_pct=dict(default=20, type='int'), + warning_threshold=dict(default=80, type='int'), + delete_limit=dict(default=30, type='int'), + full_policy=dict(default='purgepit', choices=['unknown', 'failbasewrites', 'purgepit']), + rollback_priority=dict(default='medium', choices=['highest', 'high', 'medium', 'low', 'lowest']), + storage_pool_name=dict(type='str'), + ssid=dict(required=True), + ) + + self.module = AnsibleModule(argument_spec=argument_spec) + + self.post_data = dict() + self.warning_threshold = self.module.params['warning_threshold'] + self.base_volume_name = self.module.params['base_volume_name'] + self.name = self.module.params['name'] + self.repo_pct = self.module.params['repo_pct'] + self.delete_limit = self.module.params['delete_limit'] + self.full_policy = self.module.params['full_policy'] + self.rollback_priority = self.module.params['rollback_priority'] + self.storage_pool_name = self.module.params['storage_pool_name'] + self.state = self.module.params['state'] + + self.url = self.module.params['api_url'] + self.user = self.module.params['api_username'] + self.pwd = self.module.params['api_password'] + self.certs = self.module.params['validate_certs'] + self.ssid = self.module.params['ssid'] + + if not self.url.endswith('/'): + self.url += '/' + + self.changed = False + + @property + def pool_id(self): + pools = 'storage-systems/%s/storage-pools' % self.ssid + url = self.url + pools + try: + (rc, data) = request(url, headers=HEADERS, url_username=self.user, url_password=self.pwd) + except: + err = get_exception() + self.module.fail_json(msg="Snapshot group module - Failed to fetch storage pools. " + + "Id [%s]. Error [%s]." % (self.ssid, str(err))) + + for pool in data: + if pool['name'] == self.storage_pool_name: + self.pool_data = pool + return pool['id'] + + self.module.fail_json(msg="No storage pool with the name: '%s' was found" % self.name) + + @property + def volume_id(self): + volumes = 'storage-systems/%s/volumes' % self.ssid + url = self.url + volumes + try: + rc, data = request(url, headers=HEADERS, url_username=self.user, url_password=self.pwd, + validate_certs=self.certs) + except: + err = get_exception() + self.module.fail_json(msg="Snapshot group module - Failed to fetch volumes. " + + "Id [%s]. Error [%s]." % (self.ssid, str(err))) + qty = 0 + for volume in data: + if volume['name'] == self.base_volume_name: + qty += 1 + + if qty > 1: + self.module.fail_json(msg="More than one volume with the name: %s was found, " + "please ensure your volume has a unique name" % self.base_volume_name) + else: + Id = volume['id'] + self.volume = volume + + try: + return Id + except NameError: + self.module.fail_json(msg="No volume with the name: %s, was found" % self.base_volume_name) + + @property + def snapshot_group_id(self): + url = self.url + 'storage-systems/%s/snapshot-groups' % self.ssid + try: + rc, data = request(url, headers=HEADERS, url_username=self.user, url_password=self.pwd, + validate_certs=self.certs) + except: + err = get_exception() + self.module.fail_json(msg="Failed to fetch snapshot groups. " + + "Id [%s]. Error [%s]." % (self.ssid, str(err))) + for ssg in data: + if ssg['name'] == self.name: + self.ssg_data = ssg + return ssg['id'] + + return None + + @property + def ssg_needs_update(self): + if self.ssg_data['fullWarnThreshold'] != self.warning_threshold or \ + self.ssg_data['autoDeleteLimit'] != self.delete_limit or \ + self.ssg_data['repFullPolicy'] != self.full_policy or \ + self.ssg_data['rollbackPriority'] != self.rollback_priority: + return True + else: + return False + + def create_snapshot_group(self): + self.post_data = dict( + baseMappableObjectId=self.volume_id, + name=self.name, + repositoryPercentage=self.repo_pct, + warningThreshold=self.warning_threshold, + autoDeleteLimit=self.delete_limit, + fullPolicy=self.full_policy, + storagePoolId=self.pool_id, + ) + snapshot = 'storage-systems/%s/snapshot-groups' % self.ssid + url = self.url + snapshot + try: + rc, self.ssg_data = request(url, data=json.dumps(self.post_data), method='POST', headers=HEADERS, + url_username=self.user, url_password=self.pwd, validate_certs=self.certs) + except: + err = get_exception() + self.module.fail_json(msg="Failed to create snapshot group. " + + "Snapshot group [%s]. Id [%s]. Error [%s]." % (self.name, + self.ssid, + str(err))) + + if not self.snapshot_group_id: + self.snapshot_group_id = self.ssg_data['id'] + + if self.ssg_needs_update: + self.update_ssg() + else: + self.module.exit_json(changed=True, **self.ssg_data) + + def update_ssg(self): + self.post_data = dict( + warningThreshold=self.warning_threshold, + autoDeleteLimit=self.delete_limit, + fullPolicy=self.full_policy, + rollbackPriority=self.rollback_priority + ) + + url = self.url + "storage-systems/%s/snapshot-groups/%s" % (self.ssid, self.snapshot_group_id) + try: + rc, self.ssg_data = request(url, data=json.dumps(self.post_data), method='POST', headers=HEADERS, + url_username=self.user, url_password=self.pwd, validate_certs=self.certs) + except: + err = get_exception() + self.module.fail_json(msg="Failed to update snapshot group. " + + "Snapshot group [%s]. Id [%s]. Error [%s]." % (self.name, + self.ssid, + str(err))) + + def apply(self): + if self.state == 'absent': + if self.snapshot_group_id: + try: + rc, resp = request( + self.url + 'storage-systems/%s/snapshot-groups/%s' % (self.ssid, self.snapshot_group_id), + method='DELETE', headers=HEADERS, url_password=self.pwd, url_username=self.user, + validate_certs=self.certs) + except: + err = get_exception() + self.module.fail_json(msg="Failed to delete snapshot group. " + + "Snapshot group [%s]. Id [%s]. Error [%s]." % (self.name, + self.ssid, + str(err))) + self.module.exit_json(changed=True, msg="Snapshot group removed", **self.ssg_data) + else: + self.module.exit_json(changed=False, msg="Snapshot group absent") + + elif self.snapshot_group_id: + if self.ssg_needs_update: + self.update_ssg() + self.module.exit_json(changed=True, **self.ssg_data) + else: + self.module.exit_json(changed=False, **self.ssg_data) + else: + self.create_snapshot_group() + + +def main(): + vg = SnapshotGroup() + vg.apply() + + +if __name__ == '__main__': + main() diff --git a/storage/netapp/netapp_e_snapshot_images.py b/storage/netapp/netapp_e_snapshot_images.py new file mode 100644 index 00000000000..8c81af84535 --- /dev/null +++ b/storage/netapp/netapp_e_snapshot_images.py @@ -0,0 +1,250 @@ +#!/usr/bin/python + +# (c) 2016, NetApp, Inc +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# +DOCUMENTATION = """ +--- +module: netapp_e_snapshot_images +short_description: Create and delete snapshot images +description: + - Create and delete snapshots images on snapshot groups for NetApp E-series storage arrays. + - Only the oldest snapshot image can be deleted so consistency is preserved. + - "Related: Snapshot volumes are created from snapshot images." +version_added: '2.2' +author: Kevin Hulquest (@hulquest) +options: + api_username: + required: true + description: + - The username to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_password: + required: true + description: + - The password to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_url: + required: true + description: + - The url to the SANtricity WebServices Proxy or embedded REST API. + example: + - https://prod-1.wahoo.acme.com/devmgr/v2 + validate_certs: + required: false + default: true + description: + - Should https certificates be validated? + snapshot_group: + description: + - The name of the snapshot group in which you want to create a snapshot image. + required: True + state: + description: + - Whether a new snapshot image should be created or oldest be deleted. + required: True + choices: ['create', 'remove'] +""" +EXAMPLES = """ + - name: Create Snapshot + netapp_e_snapshot_images: + ssid: "{{ ssid }}" + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ validate_certs }}" + snapshot_group: "3300000060080E5000299C24000005B656D9F394" + state: 'create' +""" +RETURN = """ +--- + changed: true + msg: "Created snapshot image" + image_id: "3400000060080E5000299B640063074057BC5C5E " +""" + +HEADERS = { + "Content-Type": "application/json", + "Accept": "application/json", +} +import json + +from ansible.module_utils.api import basic_auth_argument_spec +from ansible.module_utils.basic import AnsibleModule + +from ansible.module_utils.pycompat24 import get_exception +from ansible.module_utils.urls import open_url +from ansible.module_utils.six.moves.urllib.error import HTTPError + + +def request(url, data=None, headers=None, method='GET', use_proxy=True, + force=False, last_mod_time=None, timeout=10, validate_certs=True, + url_username=None, url_password=None, http_agent=None, force_basic_auth=True, ignore_errors=False): + try: + r = open_url(url=url, data=data, headers=headers, method=method, use_proxy=use_proxy, + force=force, last_mod_time=last_mod_time, timeout=timeout, validate_certs=validate_certs, + url_username=url_username, url_password=url_password, http_agent=http_agent, + force_basic_auth=force_basic_auth) + except HTTPError: + err = get_exception() + r = err.fp + + try: + raw_data = r.read() + if raw_data: + data = json.loads(raw_data) + else: + raw_data = None + except: + if ignore_errors: + pass + else: + raise Exception(raw_data) + + resp_code = r.getcode() + + if resp_code >= 400 and not ignore_errors: + raise Exception(resp_code, data) + else: + return resp_code, data + + +def snapshot_group_from_name(module, ssid, api_url, api_pwd, api_usr, name): + snap_groups = 'storage-systems/%s/snapshot-groups' % ssid + snap_groups_url = api_url + snap_groups + (ret, snapshot_groups) = request(snap_groups_url, url_username=api_usr, url_password=api_pwd, headers=HEADERS, + validate_certs=module.params['validate_certs']) + + snapshot_group_id = None + for snapshot_group in snapshot_groups: + if name == snapshot_group['label']: + snapshot_group_id = snapshot_group['pitGroupRef'] + break + if snapshot_group_id is None: + module.fail_json(msg="Failed to lookup snapshot group. Group [%s]. Id [%s]." % (name, ssid)) + + return snapshot_group + + +def oldest_image(module, ssid, api_url, api_pwd, api_usr, name): + get_status = 'storage-systems/%s/snapshot-images' % ssid + url = api_url + get_status + + try: + (ret, images) = request(url, url_username=api_usr, url_password=api_pwd, headers=HEADERS, + validate_certs=module.params['validate_certs']) + except: + err = get_exception() + module.fail_json(msg="Failed to get snapshot images for group. Group [%s]. Id [%s]. Error [%s]" % + (name, ssid, str(err))) + if not images: + module.exit_json(msg="There are no snapshot images to remove. Group [%s]. Id [%s]." % (name, ssid)) + + oldest = min(images, key=lambda x: x['pitSequenceNumber']) + if oldest is None or "pitRef" not in oldest: + module.fail_json(msg="Failed to lookup oldest snapshot group. Group [%s]. Id [%s]." % (name, ssid)) + + return oldest + + +def create_image(module, ssid, api_url, pwd, user, p, snapshot_group): + snapshot_group_obj = snapshot_group_from_name(module, ssid, api_url, pwd, user, snapshot_group) + snapshot_group_id = snapshot_group_obj['pitGroupRef'] + endpoint = 'storage-systems/%s/snapshot-images' % ssid + url = api_url + endpoint + post_data = json.dumps({'groupId': snapshot_group_id}) + + image_data = request(url, data=post_data, method='POST', url_username=user, url_password=pwd, headers=HEADERS, + validate_certs=module.params['validate_certs']) + + if image_data[1]['status'] == 'optimal': + status = True + id = image_data[1]['id'] + else: + status = False + id = '' + + return status, id + + +def delete_image(module, ssid, api_url, pwd, user, snapshot_group): + image = oldest_image(module, ssid, api_url, pwd, user, snapshot_group) + image_id = image['pitRef'] + endpoint = 'storage-systems/%s/snapshot-images/%s' % (ssid, image_id) + url = api_url + endpoint + + try: + (ret, image_data) = request(url, method='DELETE', url_username=user, url_password=pwd, headers=HEADERS, + validate_certs=module.params['validate_certs']) + except Exception: + e = get_exception() + image_data = (e[0], e[1]) + + if ret == 204: + deleted_status = True + error_message = '' + else: + deleted_status = False + error_message = image_data[1]['errorMessage'] + + return deleted_status, error_message + + +def main(): + argument_spec = basic_auth_argument_spec() + argument_spec.update(dict( + snapshot_group=dict(required=True, type='str'), + ssid=dict(required=True, type='str'), + api_url=dict(required=True), + api_username=dict(required=False), + api_password=dict(required=False, no_log=True), + validate_certs=dict(required=False, default=True), + state=dict(required=True, choices=['create', 'remove'], type='str'), + )) + module = AnsibleModule(argument_spec) + + p = module.params + + ssid = p.pop('ssid') + api_url = p.pop('api_url') + user = p.pop('api_username') + pwd = p.pop('api_password') + snapshot_group = p.pop('snapshot_group') + desired_state = p.pop('state') + + if not api_url.endswith('/'): + api_url += '/' + + if desired_state == 'create': + created_status, snapshot_id = create_image(module, ssid, api_url, pwd, user, p, snapshot_group) + + if created_status: + module.exit_json(changed=True, msg='Created snapshot image', image_id=snapshot_id) + else: + module.fail_json( + msg="Could not create snapshot image on system %s, in snapshot group %s" % (ssid, snapshot_group)) + else: + deleted, error_msg = delete_image(module, ssid, api_url, pwd, user, snapshot_group) + + if deleted: + module.exit_json(changed=True, msg='Deleted snapshot image for snapshot group [%s]' % (snapshot_group)) + else: + module.fail_json( + msg="Could not create snapshot image on system %s, in snapshot group %s --- %s" % ( + ssid, snapshot_group, error_msg)) + + +if __name__ == '__main__': + main() diff --git a/storage/netapp/netapp_e_snapshot_volume.py b/storage/netapp/netapp_e_snapshot_volume.py new file mode 100644 index 00000000000..9a143bd4125 --- /dev/null +++ b/storage/netapp/netapp_e_snapshot_volume.py @@ -0,0 +1,287 @@ +#!/usr/bin/python + +# (c) 2016, NetApp, Inc +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# +DOCUMENTATION = """ +--- +module: netapp_e_snapshot_volume +short_description: Manage E/EF-Series snapshot volumes. +description: + - Create, update, remove snapshot volumes for NetApp E/EF-Series storage arrays. +version_added: '2.2' +author: Kevin Hulquest (@hulquest) +note: Only I(full_threshold) is supported for update operations. If the snapshot volume already exists and the threshold matches, then an C(ok) status will be returned, no other changes can be made to a pre-existing snapshot volume. +options: + api_username: + required: true + description: + - The username to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_password: + required: true + description: + - The password to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_url: + required: true + description: + - The url to the SANtricity WebServices Proxy or embedded REST API. + example: + - https://prod-1.wahoo.acme.com/devmgr/v2 + validate_certs: + required: false + default: true + description: + - Should https certificates be validated? + ssid: + description: + - storage array ID + required: True + snapshot_image_id: + required: True + description: + - The identifier of the snapshot image used to create the new snapshot volume. + - "Note: You'll likely want to use the M(netapp_e_facts) module to find the ID of the image you want." + full_threshold: + description: + - The repository utilization warning threshold percentage + default: 85 + name: + required: True + description: + - The name you wish to give the snapshot volume + view_mode: + required: True + description: + - The snapshot volume access mode + choices: + - modeUnknown + - readWrite + - readOnly + - __UNDEFINED + repo_percentage: + description: + - The size of the view in relation to the size of the base volume + default: 20 + storage_pool_name: + description: + - Name of the storage pool on which to allocate the repository volume. + required: True + state: + description: + - Whether to create or remove the snapshot volume + required: True + choices: + - absent + - present +""" +EXAMPLES = """ + - name: Snapshot volume + netapp_e_snapshot_volume: + ssid: "{{ ssid }}" + api_url: "{{ netapp_api_url }}"/ + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + state: present + storage_pool_name: "{{ snapshot_volume_storage_pool_name }}" + snapshot_image_id: "{{ snapshot_volume_image_id }}" + name: "{{ snapshot_volume_name }}" +""" +RETURN = """ +msg: + description: Success message + returned: success + type: string + sample: Json facts for the volume that was created. +""" +HEADERS = { + "Content-Type": "application/json", + "Accept": "application/json", +} +import json + +from ansible.module_utils.api import basic_auth_argument_spec +from ansible.module_utils.basic import AnsibleModule + +from ansible.module_utils.pycompat24 import get_exception +from ansible.module_utils.urls import open_url +from ansible.module_utils.six.moves.urllib.error import HTTPError + + +def request(url, data=None, headers=None, method='GET', use_proxy=True, + force=False, last_mod_time=None, timeout=10, validate_certs=True, + url_username=None, url_password=None, http_agent=None, force_basic_auth=True, ignore_errors=False): + try: + r = open_url(url=url, data=data, headers=headers, method=method, use_proxy=use_proxy, + force=force, last_mod_time=last_mod_time, timeout=timeout, validate_certs=validate_certs, + url_username=url_username, url_password=url_password, http_agent=http_agent, + force_basic_auth=force_basic_auth) + except HTTPError: + err = get_exception() + r = err.fp + + try: + raw_data = r.read() + if raw_data: + data = json.loads(raw_data) + else: + raw_data = None + except: + if ignore_errors: + pass + else: + raise Exception(raw_data) + + resp_code = r.getcode() + + if resp_code >= 400 and not ignore_errors: + raise Exception(resp_code, data) + else: + return resp_code, data + + +class SnapshotVolume(object): + def __init__(self): + argument_spec = basic_auth_argument_spec() + argument_spec.update(dict( + api_username=dict(type='str', required=True), + api_password=dict(type='str', required=True, no_log=True), + api_url=dict(type='str', required=True), + ssid=dict(type='str', required=True), + snapshot_image_id=dict(type='str', required=True), + full_threshold=dict(type='int', default=85), + name=dict(type='str', required=True), + view_mode=dict(type='str', default='readOnly', + choices=['readOnly', 'readWrite', 'modeUnknown', '__Undefined']), + repo_percentage=dict(type='int', default=20), + storage_pool_name=dict(type='str', required=True), + state=dict(type='str', required=True, choices=['absent', 'present']) + )) + + self.module = AnsibleModule(argument_spec=argument_spec) + args = self.module.params + self.state = args['state'] + self.ssid = args['ssid'] + self.snapshot_image_id = args['snapshot_image_id'] + self.full_threshold = args['full_threshold'] + self.name = args['name'] + self.view_mode = args['view_mode'] + self.repo_percentage = args['repo_percentage'] + self.storage_pool_name = args['storage_pool_name'] + self.url = args['api_url'] + self.user = args['api_username'] + self.pwd = args['api_password'] + self.certs = args['validate_certs'] + + if not self.url.endswith('/'): + self.url += '/' + + @property + def pool_id(self): + pools = 'storage-systems/%s/storage-pools' % self.ssid + url = self.url + pools + (rc, data) = request(url, headers=HEADERS, url_username=self.user, url_password=self.pwd, + validate_certs=self.certs) + + for pool in data: + if pool['name'] == self.storage_pool_name: + self.pool_data = pool + return pool['id'] + + self.module.fail_json(msg="No storage pool with the name: '%s' was found" % self.name) + + @property + def ss_vol_exists(self): + rc, ss_vols = request(self.url + 'storage-systems/%s/snapshot-volumes' % self.ssid, headers=HEADERS, + url_username=self.user, url_password=self.pwd, validate_certs=self.certs) + if ss_vols: + for ss_vol in ss_vols: + if ss_vol['name'] == self.name: + self.ss_vol = ss_vol + return True + else: + return False + + return False + + @property + def ss_vol_needs_update(self): + if self.ss_vol['fullWarnThreshold'] != self.full_threshold: + return True + else: + return False + + def create_ss_vol(self): + post_data = dict( + snapshotImageId=self.snapshot_image_id, + fullThreshold=self.full_threshold, + name=self.name, + viewMode=self.view_mode, + repositoryPercentage=self.repo_percentage, + repositoryPoolId=self.pool_id + ) + + rc, create_resp = request(self.url + 'storage-systems/%s/snapshot-volumes' % self.ssid, + data=json.dumps(post_data), headers=HEADERS, url_username=self.user, + url_password=self.pwd, validate_certs=self.certs, method='POST') + + self.ss_vol = create_resp + # Doing a check after creation because the creation call fails to set the specified warning threshold + if self.ss_vol_needs_update: + self.update_ss_vol() + else: + self.module.exit_json(changed=True, **create_resp) + + def update_ss_vol(self): + post_data = dict( + fullThreshold=self.full_threshold, + ) + + rc, resp = request(self.url + 'storage-systems/%s/snapshot-volumes/%s' % (self.ssid, self.ss_vol['id']), + data=json.dumps(post_data), headers=HEADERS, url_username=self.user, url_password=self.pwd, + method='POST', validate_certs=self.certs) + + self.module.exit_json(changed=True, **resp) + + def remove_ss_vol(self): + rc, resp = request(self.url + 'storage-systems/%s/snapshot-volumes/%s' % (self.ssid, self.ss_vol['id']), + headers=HEADERS, url_username=self.user, url_password=self.pwd, validate_certs=self.certs, + method='DELETE') + self.module.exit_json(changed=True, msg="Volume successfully deleted") + + def apply(self): + if self.state == 'present': + if self.ss_vol_exists: + if self.ss_vol_needs_update: + self.update_ss_vol() + else: + self.module.exit_json(changed=False, **self.ss_vol) + else: + self.create_ss_vol() + else: + if self.ss_vol_exists: + self.remove_ss_vol() + else: + self.module.exit_json(changed=False, msg="Volume already absent") + + +def main(): + sv = SnapshotVolume() + sv.apply() + + +if __name__ == '__main__': + main() diff --git a/storage/netapp/netapp_e_storage_system.py b/storage/netapp/netapp_e_storage_system.py index 13ef7c9fbc5..40ef893ad9b 100644 --- a/storage/netapp/netapp_e_storage_system.py +++ b/storage/netapp/netapp_e_storage_system.py @@ -18,7 +18,7 @@ # along with Ansible. If not, see . # DOCUMENTATION = ''' -module: na_eseries_storage_system +module: netapp_e_storage_system version_added: "2.2" short_description: Add/remove arrays from the Web Services Proxy description: @@ -80,7 +80,7 @@ author: Kevin Hulquest (@hulquest) EXAMPLES = ''' --- - name: Presence of storage system - na_eseries_storage_system: + netapp_e_storage_system: ssid: "{{ item.key }}" state: present api_url: "{{ netapp_api_url }}" @@ -99,8 +99,7 @@ msg: Storage system removed. msg: Storage system added. ''' import json -import os -from datetime import datetime as dt, timedelta, time +from datetime import datetime as dt, timedelta from time import sleep from ansible.module_utils.api import basic_auth_argument_spec @@ -267,7 +266,6 @@ def main(): module.fail_json(msg="Failed to add storage system. Id[%s]. Request body [%s]. Error[%s]." % (ssid, request_data, str(err))) - else: # array exists, modify... post_headers = dict(Accept="application/json") post_headers['Content-Type'] = 'application/json' @@ -286,7 +284,6 @@ def main(): module.fail_json(msg="Failed to update storage system. Id[%s]. Request body [%s]. Error[%s]." % (ssid, post_body, str(err))) - elif state == 'absent': # delete the array try: diff --git a/storage/netapp/netapp_e_storagepool.py b/storage/netapp/netapp_e_storagepool.py new file mode 100644 index 00000000000..1d86ef46f6b --- /dev/null +++ b/storage/netapp/netapp_e_storagepool.py @@ -0,0 +1,884 @@ +#!/usr/bin/python + +# (c) 2016, NetApp, Inc +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +DOCUMENTATION = ''' +--- +module: netapp_e_storagepool +short_description: Manage disk groups and disk pools +version_added: '2.2' +description: + - Create or remove disk groups and disk pools for NetApp E-series storage arrays. +options: + api_username: + required: true + description: + - The username to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_password: + required: true + description: + - The password to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_url: + required: true + description: + - The url to the SANtricity WebServices Proxy or embedded REST API. + example: + - https://prod-1.wahoo.acme.com/devmgr/v2 + validate_certs: + required: false + default: true + description: + - Should https certificates be validated? + ssid: + required: true + description: + - The ID of the array to manage (as configured on the web services proxy). + state: + required: true + description: + - Whether the specified storage pool should exist or not. + - Note that removing a storage pool currently requires the removal of all defined volumes first. + choices: ['present', 'absent'] + name: + required: true + description: + - The name of the storage pool to manage + criteria_drive_count: + description: + - The number of disks to use for building the storage pool. The pool will be expanded if this number exceeds the number of disks already in place + criteria_drive_type: + description: + - The type of disk (hdd or ssd) to use when searching for candidates to use. + choices: ['hdd','ssd'] + criteria_size_unit: + description: + - The unit used to interpret size parameters + choices: ['bytes', 'b', 'kb', 'mb', 'gb', 'tb', 'pb', 'eb', 'zb', 'yb'] + default: 'gb' + criteria_drive_min_size: + description: + - The minimum individual drive size (in size_unit) to consider when choosing drives for the storage pool. + criteria_min_usable_capacity: + description: + - The minimum size of the storage pool (in size_unit). The pool will be expanded if this value exceeds itscurrent size. + criteria_drive_interface_type: + description: + - The interface type to use when selecting drives for the storage pool (no value means all interface types will be considered) + choices: ['sas', 'sas4k', 'fibre', 'fibre520b', 'scsi', 'sata', 'pata'] + criteria_drive_require_fde: + description: + - Whether full disk encryption ability is required for drives to be added to the storage pool + raid_level: + required: true + choices: ['raidAll', 'raid0', 'raid1', 'raid3', 'raid5', 'raid6', 'raidDiskPool'] + description: + - "Only required when the requested state is 'present'. The RAID level of the storage pool to be created." + erase_secured_drives: + required: false + choices: ['true', 'false'] + description: + - Whether to erase secured disks before adding to storage pool + secure_pool: + required: false + choices: ['true', 'false'] + description: + - Whether to convert to a secure storage pool. Will only work if all drives in the pool are security capable. + reserve_drive_count: + required: false + description: + - Set the number of drives reserved by the storage pool for reconstruction operations. Only valide on raid disk pools. + remove_volumes: + required: false + default: False + description: + - Prior to removing a storage pool, delete all volumes in the pool. +author: Kevin Hulquest (@hulquest) + +''' +EXAMPLES = ''' + - name: No disk groups + netapp_e_storagepool: + ssid: "{{ ssid }}" + name: "{{ item }}" + state: absent + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" +''' +RETURN = ''' +msg: + description: Success message + returned: success + type: string + sample: Json facts for the pool that was created. +''' + +import json +import logging +from traceback import format_exc + +from ansible.module_utils.api import basic_auth_argument_spec +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.pycompat24 import get_exception +from ansible.module_utils.urls import open_url +from ansible.module_utils.six.moves.urllib.error import HTTPError + + +def request(url, data=None, headers=None, method='GET', use_proxy=True, + force=False, last_mod_time=None, timeout=10, validate_certs=True, + url_username=None, url_password=None, http_agent=None, force_basic_auth=True, ignore_errors=False): + try: + r = open_url(url=url, data=data, headers=headers, method=method, use_proxy=use_proxy, + force=force, last_mod_time=last_mod_time, timeout=timeout, validate_certs=validate_certs, + url_username=url_username, url_password=url_password, http_agent=http_agent, + force_basic_auth=force_basic_auth) + except HTTPError: + err = get_exception() + r = err.fp + + try: + raw_data = r.read() + if raw_data: + data = json.loads(raw_data) + else: + raw_data = None + except: + if ignore_errors: + pass + else: + raise Exception(raw_data) + + resp_code = r.getcode() + + if resp_code >= 400 and not ignore_errors: + raise Exception(resp_code, data) + else: + return resp_code, data + + +def select(predicate, iterable): + # python 2, 3 generic filtering. + if predicate is None: + predicate = bool + for x in iterable: + if predicate(x): + yield x + + +class groupby(object): + # python 2, 3 generic grouping. + def __init__(self, iterable, key=None): + if key is None: + key = lambda x: x + self.keyfunc = key + self.it = iter(iterable) + self.tgtkey = self.currkey = self.currvalue = object() + + def __iter__(self): + return self + + def next(self): + while self.currkey == self.tgtkey: + self.currvalue = next(self.it) # Exit on StopIteration + self.currkey = self.keyfunc(self.currvalue) + self.tgtkey = self.currkey + return (self.currkey, self._grouper(self.tgtkey)) + + def _grouper(self, tgtkey): + while self.currkey == tgtkey: + yield self.currvalue + self.currvalue = next(self.it) # Exit on StopIteration + self.currkey = self.keyfunc(self.currvalue) + + +class NetAppESeriesStoragePool(object): + def __init__(self): + self._sp_drives_cached = None + + self._size_unit_map = dict( + bytes=1, + b=1, + kb=1024, + mb=1024 ** 2, + gb=1024 ** 3, + tb=1024 ** 4, + pb=1024 ** 5, + eb=1024 ** 6, + zb=1024 ** 7, + yb=1024 ** 8 + ) + + argument_spec = basic_auth_argument_spec() + argument_spec.update(dict( + api_username=dict(type='str', required=True), + api_password=dict(type='str', required=True, no_log=True), + api_url=dict(type='str', required=True), + state=dict(required=True, choices=['present', 'absent'], type='str'), + ssid=dict(required=True, type='str'), + name=dict(required=True, type='str'), + criteria_size_unit=dict(default='gb', type='str'), + criteria_drive_count=dict(type='int'), + criteria_drive_interface_type=dict(choices=['sas', 'sas4k', 'fibre', 'fibre520b', 'scsi', 'sata', 'pata'], + type='str'), + criteria_drive_type=dict(choices=['ssd', 'hdd'], type='str'), + criteria_drive_min_size=dict(type='int'), + criteria_drive_require_fde=dict(type='bool'), + criteria_min_usable_capacity=dict(type='int'), + raid_level=dict( + choices=['raidUnsupported', 'raidAll', 'raid0', 'raid1', 'raid3', 'raid5', 'raid6', 'raidDiskPool']), + erase_secured_drives=dict(type='bool'), + log_path=dict(type='str'), + remove_drives=dict(type='list'), + secure_pool=dict(type='bool', default=False), + reserve_drive_count=dict(type='int'), + remove_volumes=dict(type='bool', default=False) + )) + + self.module = AnsibleModule( + argument_spec=argument_spec, + required_if=[ + ('state', 'present', ['raid_level']) + ], + mutually_exclusive=[ + + ], + # TODO: update validation for various selection criteria + supports_check_mode=True + ) + + p = self.module.params + + log_path = p['log_path'] + + # logging setup + self._logger = logging.getLogger(self.__class__.__name__) + self.debug = self._logger.debug + + if log_path: + logging.basicConfig(level=logging.DEBUG, filename=log_path) + + self.state = p['state'] + self.ssid = p['ssid'] + self.name = p['name'] + self.validate_certs = p['validate_certs'] + + self.criteria_drive_count = p['criteria_drive_count'] + self.criteria_drive_type = p['criteria_drive_type'] + self.criteria_size_unit = p['criteria_size_unit'] + self.criteria_drive_min_size = p['criteria_drive_min_size'] + self.criteria_min_usable_capacity = p['criteria_min_usable_capacity'] + self.criteria_drive_interface_type = p['criteria_drive_interface_type'] + self.criteria_drive_require_fde = p['criteria_drive_require_fde'] + + self.raid_level = p['raid_level'] + self.erase_secured_drives = p['erase_secured_drives'] + self.remove_drives = p['remove_drives'] + self.secure_pool = p['secure_pool'] + self.reserve_drive_count = p['reserve_drive_count'] + self.remove_volumes = p['remove_volumes'] + + try: + self.api_usr = p['api_username'] + self.api_pwd = p['api_password'] + self.api_url = p['api_url'] + except KeyError: + self.module.fail_json(msg="You must pass in api_username " + "and api_password and api_url to the module.") + + self.post_headers = dict(Accept="application/json") + self.post_headers['Content-Type'] = 'application/json' + + # Quick and dirty drive selector, since the one provided by web service proxy is broken for min_disk_size as of 2016-03-12. + # Doesn't really need to be a class once this is in module_utils or retired- just groups everything together so we + # can copy/paste to other modules more easily. + # Filters all disks by specified criteria, then groups remaining disks by capacity, interface and disk type, and selects + # the first set that matches the specified count and/or aggregate capacity. + # class DriveSelector(object): + def filter_drives( + self, + drives, # raw drives resp + interface_type=None, # sas, sata, fibre, etc + drive_type=None, # ssd/hdd + spindle_speed=None, # 7200, 10000, 15000, ssd (=0) + min_drive_size=None, + max_drive_size=None, + fde_required=None, + size_unit='gb', + min_total_capacity=None, + min_drive_count=None, + exact_drive_count=None, + raid_level=None + ): + if min_total_capacity is None and exact_drive_count is None: + raise Exception("One of criteria_min_total_capacity or criteria_drive_count must be specified.") + + if min_total_capacity: + min_total_capacity = min_total_capacity * self._size_unit_map[size_unit] + + # filter clearly invalid/unavailable drives first + drives = select(lambda d: self._is_valid_drive(d), drives) + + if interface_type: + drives = select(lambda d: d['phyDriveType'] == interface_type, drives) + + if drive_type: + drives = select(lambda d: d['driveMediaType'] == drive_type, drives) + + if spindle_speed is not None: # 0 is valid for ssds + drives = select(lambda d: d['spindleSpeed'] == spindle_speed, drives) + + if min_drive_size: + min_drive_size_bytes = min_drive_size * self._size_unit_map[size_unit] + drives = select(lambda d: int(d['rawCapacity']) >= min_drive_size_bytes, drives) + + if max_drive_size: + max_drive_size_bytes = max_drive_size * self._size_unit_map[size_unit] + drives = select(lambda d: int(d['rawCapacity']) <= max_drive_size_bytes, drives) + + if fde_required: + drives = select(lambda d: d['fdeCapable'], drives) + + # initial implementation doesn't have a preference for any of these values... + # just return the first set we find that matches the requested disk count and/or minimum total capacity + for (cur_capacity, drives_by_capacity) in groupby(drives, lambda d: int(d['rawCapacity'])): + for (cur_interface_type, drives_by_interface_type) in groupby(drives_by_capacity, + lambda d: d['phyDriveType']): + for (cur_drive_type, drives_by_drive_type) in groupby(drives_by_interface_type, + lambda d: d['driveMediaType']): + # listify so we can consume more than once + drives_by_drive_type = list(drives_by_drive_type) + candidate_set = list() # reset candidate list on each iteration of the innermost loop + + if exact_drive_count: + if len(drives_by_drive_type) < exact_drive_count: + continue # we know this set is too small, move on + + for drive in drives_by_drive_type: + candidate_set.append(drive) + if self._candidate_set_passes(candidate_set, min_capacity_bytes=min_total_capacity, + min_drive_count=min_drive_count, + exact_drive_count=exact_drive_count, raid_level=raid_level): + return candidate_set + + raise Exception("couldn't find an available set of disks to match specified criteria") + + def _is_valid_drive(self, d): + is_valid = d['available'] \ + and d['status'] == 'optimal' \ + and not d['pfa'] \ + and not d['removed'] \ + and not d['uncertified'] \ + and not d['invalidDriveData'] \ + and not d['nonRedundantAccess'] + + return is_valid + + def _candidate_set_passes(self, candidate_set, min_capacity_bytes=None, min_drive_count=None, + exact_drive_count=None, raid_level=None): + if not self._is_drive_count_valid(len(candidate_set), min_drive_count=min_drive_count, + exact_drive_count=exact_drive_count, raid_level=raid_level): + return False + # TODO: this assumes candidate_set is all the same size- if we want to allow wastage, need to update to use min size of set + if min_capacity_bytes is not None and self._calculate_usable_capacity(int(candidate_set[0]['rawCapacity']), + len(candidate_set), + raid_level=raid_level) < min_capacity_bytes: + return False + + return True + + def _calculate_usable_capacity(self, disk_size_bytes, disk_count, raid_level=None): + if raid_level in [None, 'raid0']: + return disk_size_bytes * disk_count + if raid_level == 'raid1': + return (disk_size_bytes * disk_count) / 2 + if raid_level in ['raid3', 'raid5']: + return (disk_size_bytes * disk_count) - disk_size_bytes + if raid_level in ['raid6', 'raidDiskPool']: + return (disk_size_bytes * disk_count) - (disk_size_bytes * 2) + raise Exception("unsupported raid_level: %s" % raid_level) + + def _is_drive_count_valid(self, drive_count, min_drive_count=0, exact_drive_count=None, raid_level=None): + if exact_drive_count and exact_drive_count != drive_count: + return False + if raid_level == 'raidDiskPool': + if drive_count < 11: + return False + if raid_level == 'raid1': + if drive_count % 2 != 0: + return False + if raid_level in ['raid3', 'raid5']: + if drive_count < 3: + return False + if raid_level == 'raid6': + if drive_count < 4: + return False + if min_drive_count and drive_count < min_drive_count: + return False + + return True + + def get_storage_pool(self, storage_pool_name): + # global ifilter + self.debug("fetching storage pools") + # map the storage pool name to its id + try: + (rc, resp) = request(self.api_url + "/storage-systems/%s/storage-pools" % (self.ssid), + headers=dict(Accept="application/json"), url_username=self.api_usr, + url_password=self.api_pwd, validate_certs=self.validate_certs) + except Exception: + err = get_exception() + rc = err.args[0] + if rc == 404 and self.state == 'absent': + self.module.exit_json( + msg="Storage pool [%s] did not exist." % (self.name)) + else: + err = get_exception() + self.module.exit_json( + msg="Failed to get storage pools. Array id [%s]. Error[%s]. State[%s]. RC[%s]." % + (self.ssid, str(err), self.state, rc)) + + self.debug("searching for storage pool '%s'" % storage_pool_name) + + pool_detail = next(select(lambda a: a['name'] == storage_pool_name, resp), None) + + if pool_detail: + found = 'found' + else: + found = 'not found' + self.debug(found) + + return pool_detail + + def get_candidate_disks(self): + self.debug("getting candidate disks...") + + # driveCapacityMin is broken on /drives POST. Per NetApp request we built our own + # switch back to commented code below if it gets fixed + # drives_req = dict( + # driveCount = self.criteria_drive_count, + # sizeUnit = 'mb', + # raidLevel = self.raid_level + # ) + # + # if self.criteria_drive_type: + # drives_req['driveType'] = self.criteria_drive_type + # if self.criteria_disk_min_aggregate_size_mb: + # drives_req['targetUsableCapacity'] = self.criteria_disk_min_aggregate_size_mb + # + # # TODO: this arg appears to be ignored, uncomment if it isn't + # #if self.criteria_disk_min_size_gb: + # # drives_req['driveCapacityMin'] = self.criteria_disk_min_size_gb * 1024 + # (rc,drives_resp) = request(self.api_url + "/storage-systems/%s/drives" % (self.ssid), data=json.dumps(drives_req), headers=self.post_headers, method='POST', url_username=self.api_usr, url_password=self.api_pwd, validate_certs=self.validate_certs) + # + # if rc == 204: + # self.module.fail_json(msg='Cannot find disks to match requested criteria for storage pool') + + # disk_ids = [d['id'] for d in drives_resp] + + try: + (rc, drives_resp) = request(self.api_url + "/storage-systems/%s/drives" % (self.ssid), method='GET', + url_username=self.api_usr, url_password=self.api_pwd, + validate_certs=self.validate_certs) + except: + err = get_exception() + self.module.exit_json( + msg="Failed to fetch disk drives. Array id [%s]. Error[%s]." % (self.ssid, str(err))) + + try: + candidate_set = self.filter_drives(drives_resp, + exact_drive_count=self.criteria_drive_count, + drive_type=self.criteria_drive_type, + min_drive_size=self.criteria_drive_min_size, + raid_level=self.raid_level, + size_unit=self.criteria_size_unit, + min_total_capacity=self.criteria_min_usable_capacity, + interface_type=self.criteria_drive_interface_type, + fde_required=self.criteria_drive_require_fde + ) + except: + err = get_exception() + self.module.fail_json( + msg="Failed to allocate adequate drive count. Id [%s]. Error [%s]." % (self.ssid, str(err))) + + disk_ids = [d['id'] for d in candidate_set] + + return disk_ids + + def create_storage_pool(self): + self.debug("creating storage pool...") + + sp_add_req = dict( + raidLevel=self.raid_level, + diskDriveIds=self.disk_ids, + name=self.name + ) + + if self.erase_secured_drives: + sp_add_req['eraseSecuredDrives'] = self.erase_secured_drives + + try: + (rc, resp) = request(self.api_url + "/storage-systems/%s/storage-pools" % (self.ssid), + data=json.dumps(sp_add_req), headers=self.post_headers, method='POST', + url_username=self.api_usr, url_password=self.api_pwd, + validate_certs=self.validate_certs, + timeout=120) + except: + err = get_exception() + pool_id = self.pool_detail['id'] + self.module.exit_json( + msg="Failed to create storage pool. Pool id [%s]. Array id [%s]. Error[%s]." % (pool_id, + self.ssid, + str(err))) + + self.pool_detail = self.get_storage_pool(self.name) + + if self.secure_pool: + secure_pool_data = dict(securePool=True) + try: + (retc, r) = request( + self.api_url + "/storage-systems/%s/storage-pools/%s" % (self.ssid, self.pool_detail['id']), + data=json.dumps(secure_pool_data), headers=self.post_headers, method='POST', + url_username=self.api_usr, + url_password=self.api_pwd, validate_certs=self.validate_certs, timeout=120, ignore_errors=True) + except: + err = get_exception() + pool_id = self.pool_detail['id'] + self.module.exit_json( + msg="Failed to update storage pool. Pool id [%s]. Array id [%s]. Error[%s]." % (pool_id, + self.ssid, + str(err))) + + @property + def needs_raid_level_migration(self): + current_raid_level = self.pool_detail['raidLevel'] + needs_migration = self.raid_level != current_raid_level + + if needs_migration: # sanity check some things so we can fail early/check-mode + if current_raid_level == 'raidDiskPool': + self.module.fail_json(msg="raid level cannot be changed for disk pools") + + return needs_migration + + def migrate_raid_level(self): + self.debug("migrating storage pool to raid level '%s'..." % self.raid_level) + sp_raid_migrate_req = dict( + raidLevel=self.raid_level + ) + try: + (rc, resp) = request( + self.api_url + "/storage-systems/%s/storage-pools/%s/raid-type-migration" % (self.ssid, + self.name), + data=json.dumps(sp_raid_migrate_req), headers=self.post_headers, method='POST', + url_username=self.api_usr, + url_password=self.api_pwd, validate_certs=self.validate_certs, timeout=120) + except: + err = get_exception() + pool_id = self.pool_detail['id'] + self.module.exit_json( + msg="Failed to change the raid level of storage pool. Pool id [%s]. Array id [%s]. Error[%s]." % ( + pool_id, self.ssid, str(err))) + + @property + def sp_drives(self, exclude_hotspares=True): + if not self._sp_drives_cached: + + self.debug("fetching drive list...") + try: + (rc, resp) = request(self.api_url + "/storage-systems/%s/drives" % (self.ssid), method='GET', + url_username=self.api_usr, url_password=self.api_pwd, + validate_certs=self.validate_certs) + except: + err = get_exception() + pool_id = self.pool_detail['id'] + self.module.exit_json( + msg="Failed to fetch disk drives. Pool id [%s]. Array id [%s]. Error[%s]." % (pool_id, self.ssid, str(err))) + + sp_id = self.pool_detail['id'] + if exclude_hotspares: + self._sp_drives_cached = [d for d in resp if d['currentVolumeGroupRef'] == sp_id and not d['hotSpare']] + else: + self._sp_drives_cached = [d for d in resp if d['currentVolumeGroupRef'] == sp_id] + + return self._sp_drives_cached + + @property + def reserved_drive_count_differs(self): + if int(self.pool_detail['volumeGroupData']['diskPoolData'][ + 'reconstructionReservedDriveCount']) != self.reserve_drive_count: + return True + return False + + @property + def needs_expansion(self): + if self.criteria_drive_count > len(self.sp_drives): + return True + # TODO: is totalRaidedSpace the best attribute for "how big is this SP"? + if self.criteria_min_usable_capacity and \ + (self.criteria_min_usable_capacity * self._size_unit_map[self.criteria_size_unit]) > int(self.pool_detail['totalRaidedSpace']): + return True + + return False + + def get_expansion_candidate_drives(self): + # sanity checks; don't call this if we can't/don't need to expand + if not self.needs_expansion: + self.module.fail_json(msg="can't get expansion candidates when pool doesn't need expansion") + + self.debug("fetching expansion candidate drives...") + try: + (rc, resp) = request( + self.api_url + "/storage-systems/%s/storage-pools/%s/expand" % (self.ssid, + self.pool_detail['id']), + method='GET', url_username=self.api_usr, url_password=self.api_pwd, validate_certs=self.validate_certs, + timeout=120) + except: + err = get_exception() + pool_id = self.pool_detail['id'] + self.module.exit_json( + msg="Failed to fetch candidate drives for storage pool. Pool id [%s]. Array id [%s]. Error[%s]." % ( + pool_id, self.ssid, str(err))) + + current_drive_count = len(self.sp_drives) + current_capacity_bytes = int(self.pool_detail['totalRaidedSpace']) # TODO: is this the right attribute to use? + + if self.criteria_min_usable_capacity: + requested_capacity_bytes = self.criteria_min_usable_capacity * self._size_unit_map[self.criteria_size_unit] + else: + requested_capacity_bytes = current_capacity_bytes + + if self.criteria_drive_count: + minimum_disks_to_add = max((self.criteria_drive_count - current_drive_count), 1) + else: + minimum_disks_to_add = 1 + + minimum_bytes_to_add = max(requested_capacity_bytes - current_capacity_bytes, 0) + + # FUTURE: allow more control over expansion candidate selection? + # loop over candidate disk sets and add until we've met both criteria + + added_drive_count = 0 + added_capacity_bytes = 0 + + drives_to_add = set() + + for s in resp: + # don't trust the API not to give us duplicate drives across candidate sets, especially in multi-drive sets + candidate_drives = s['drives'] + if len(drives_to_add.intersection(candidate_drives)) != 0: + # duplicate, skip + continue + drives_to_add.update(candidate_drives) + added_drive_count += len(candidate_drives) + added_capacity_bytes += int(s['usableCapacity']) + + if added_drive_count >= minimum_disks_to_add and added_capacity_bytes >= minimum_bytes_to_add: + break + + if (added_drive_count < minimum_disks_to_add) or (added_capacity_bytes < minimum_bytes_to_add): + self.module.fail_json( + msg="unable to find at least %s drives to add that would add at least %s bytes of capacity" % ( + minimum_disks_to_add, minimum_bytes_to_add)) + + return list(drives_to_add) + + def expand_storage_pool(self): + drives_to_add = self.get_expansion_candidate_drives() + + self.debug("adding %s drives to storage pool..." % len(drives_to_add)) + sp_expand_req = dict( + drives=drives_to_add + ) + try: + request( + self.api_url + "/storage-systems/%s/storage-pools/%s/expand" % (self.ssid, + self.pool_detail['id']), + data=json.dumps(sp_expand_req), headers=self.post_headers, method='POST', url_username=self.api_usr, + url_password=self.api_pwd, validate_certs=self.validate_certs, timeout=120) + except: + err = get_exception() + pool_id = self.pool_detail['id'] + self.module.exit_json( + msg="Failed to add drives to storage pool. Pool id [%s]. Array id [%s]. Error[%s]." % (pool_id, + self.ssid, + str( + err))) + + # TODO: check response + # TODO: support blocking wait? + + def reduce_drives(self, drive_list): + if all(drive in drive_list for drive in self.sp_drives): + # all the drives passed in are present in the system + pass + else: + self.module.fail_json( + msg="One of the drives you wish to remove does not currently exist in the storage pool you specified") + + try: + (rc, resp) = request( + self.api_url + "/storage-systems/%s/storage-pools/%s/reduction" % (self.ssid, + self.pool_detail['id']), + data=json.dumps(drive_list), headers=self.post_headers, method='POST', url_username=self.api_usr, + url_password=self.api_pwd, validate_certs=self.validate_certs, timeout=120) + except: + err = get_exception() + pool_id = self.pool_detail['id'] + self.module.exit_json( + msg="Failed to remove drives from storage pool. Pool id [%s]. Array id [%s]. Error[%s]." % ( + pool_id, self.ssid, str(err))) + + def update_reserve_drive_count(self, qty): + data = dict(reservedDriveCount=qty) + try: + (rc, resp) = request( + self.api_url + "/storage-systems/%s/storage-pools/%s" % (self.ssid, self.pool_detail['id']), + data=json.dumps(data), headers=self.post_headers, method='POST', url_username=self.api_usr, + url_password=self.api_pwd, validate_certs=self.validate_certs, timeout=120) + except: + err = get_exception() + pool_id = self.pool_detail['id'] + self.module.exit_json( + msg="Failed to update reserve drive count. Pool id [%s]. Array id [%s]. Error[%s]." % (pool_id, + self.ssid, + str( + err))) + + def apply(self): + changed = False + pool_exists = False + + self.pool_detail = self.get_storage_pool(self.name) + + if self.pool_detail: + pool_exists = True + pool_id = self.pool_detail['id'] + + if self.state == 'absent': + self.debug("CHANGED: storage pool exists, but requested state is 'absent'") + changed = True + elif self.state == 'present': + # sanity checks first- we can't change these, so we'll bomb if they're specified + if self.criteria_drive_type and self.criteria_drive_type != self.pool_detail['driveMediaType']: + self.module.fail_json( + msg="drive media type %s cannot be changed to %s" % (self.pool_detail['driveMediaType'], + self.criteria_drive_type)) + + # now the things we can change... + if self.needs_expansion: + self.debug("CHANGED: storage pool needs expansion") + changed = True + + if self.needs_raid_level_migration: + self.debug( + "CHANGED: raid level migration required; storage pool uses '%s', requested is '%s'" % ( + self.pool_detail['raidLevel'], self.raid_level)) + changed = True + + # if self.reserved_drive_count_differs: + # changed = True + + # TODO: validate other state details? (pool priority, alert threshold) + + # per FPoole and others, pool reduce operations will not be supported. Automatic "smart" reduction + # presents a difficult parameter issue, as the disk count can increase due to expansion, so we + # can't just use disk count > criteria_drive_count. + + else: # pool does not exist + if self.state == 'present': + self.debug("CHANGED: storage pool does not exist, but requested state is 'present'") + changed = True + + # ensure we can get back a workable set of disks + # (doing this early so candidate selection runs under check mode) + self.disk_ids = self.get_candidate_disks() + else: + self.module.exit_json(msg="Storage pool [%s] did not exist." % (self.name)) + + if changed and not self.module.check_mode: + # apply changes + if self.state == 'present': + if not pool_exists: + self.create_storage_pool() + else: # pool exists but differs, modify... + if self.needs_expansion: + self.expand_storage_pool() + + if self.remove_drives: + self.reduce_drives(self.remove_drives) + + if self.needs_raid_level_migration: + self.migrate_raid_level() + + # if self.reserved_drive_count_differs: + # self.update_reserve_drive_count(self.reserve_drive_count) + + if self.secure_pool: + secure_pool_data = dict(securePool=True) + try: + (retc, r) = request( + self.api_url + "/storage-systems/%s/storage-pools/%s" % (self.ssid, + self.pool_detail[ + 'id']), + data=json.dumps(secure_pool_data), headers=self.post_headers, method='POST', + url_username=self.api_usr, url_password=self.api_pwd, + validate_certs=self.validate_certs, timeout=120, ignore_errors=True) + except: + err = get_exception() + self.module.exit_json( + msg="Failed to delete storage pool. Pool id [%s]. Array id [%s]. Error[%s]." % ( + pool_id, self.ssid, str(err))) + + if int(retc) == 422: + self.module.fail_json( + msg="Error in enabling secure pool. One of the drives in the specified storage pool is likely not security capable") + + elif self.state == 'absent': + # delete the storage pool + try: + remove_vol_opt = '' + if self.remove_volumes: + remove_vol_opt = '?delete-volumes=true' + (rc, resp) = request( + self.api_url + "/storage-systems/%s/storage-pools/%s%s" % (self.ssid, pool_id, + remove_vol_opt), + method='DELETE', + url_username=self.api_usr, url_password=self.api_pwd, validate_certs=self.validate_certs, + timeout=120) + except: + err = get_exception() + self.module.exit_json( + msg="Failed to delete storage pool. Pool id [%s]. Array id [%s]. Error[%s]." % (pool_id, + self.ssid, + str(err))) + + self.module.exit_json(changed=changed, **self.pool_detail) + + +def main(): + sp = NetAppESeriesStoragePool() + try: + sp.apply() + except Exception: + e = get_exception() + sp.debug("Exception in apply(): \n%s" % format_exc(e)) + raise + + +if __name__ == '__main__': + main() diff --git a/storage/netapp/netapp_e_volume.py b/storage/netapp/netapp_e_volume.py new file mode 100644 index 00000000000..09825c5201e --- /dev/null +++ b/storage/netapp/netapp_e_volume.py @@ -0,0 +1,618 @@ +#!/usr/bin/python + +# (c) 2016, NetApp, Inc +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# + +from ansible.module_utils.api import basic_auth_argument_spec + +DOCUMENTATION = ''' +--- +module: netapp_e_volume +version_added: "2.2" +short_description: Manage storage volumes (standard and thin) +description: + - Create or remove volumes (standard and thin) for NetApp E/EF-series storage arrays. +options: + api_username: + required: true + description: + - The username to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_password: + required: true + description: + - The password to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_url: + required: true + description: + - The url to the SANtricity WebServices Proxy or embedded REST API. + example: + - https://prod-1.wahoo.acme.com/devmgr/v2 + validate_certs: + required: false + default: true + description: + - Should https certificates be validated? + ssid: + required: true + description: + - The ID of the array to manage (as configured on the web services proxy). + state: + required: true + description: + - Whether the specified volume should exist or not. + choices: ['present', 'absent'] + name: + required: true + description: + - The name of the volume to manage + storage_pool_name: + required: true + description: + - "Required only when requested state is 'present'. The name of the storage pool the volume should exist on." + size_unit: + description: + - The unit used to interpret the size parameter + choices: ['bytes', 'b', 'kb', 'mb', 'gb', 'tb', 'pb', 'eb', 'zb', 'yb'] + default: 'gb' + size: + required: true + description: + - "Required only when state = 'present'. The size of the volume in (size_unit)." + segment_size_kb: + description: + - The segment size of the new volume + default: 512 + thin_provision: + description: + - Whether the volume should be thin provisioned. Thin volumes can only be created on disk pools (raidDiskPool). + default: False + choices: ['yes','no','true','false'] + thin_volume_repo_size: + description: + - Initial size of the thin volume repository volume (in size_unit) + required: True + thin_volume_max_repo_size: + description: + - Maximum size that the thin volume repository volume will automatically expand to + default: same as size (in size_unit) + ssd_cache_enabled: + description: + - Whether an existing SSD cache should be enabled on the volume (fails if no SSD cache defined) + default: None (ignores existing SSD cache setting) + choices: ['yes','no','true','false'] + data_assurance_enabled: + description: + - If data assurance should be enabled for the volume + default: false + +# TODO: doc thin volume parameters + +author: Kevin Hulquest (@hulquest) + +''' +EXAMPLES = ''' + - name: No thin volume + netapp_e_volume: + ssid: "{{ ssid }}" + name: NewThinVolumeByAnsible + state: absent + log_path: /tmp/volume.log + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" + when: check_volume + + + - name: No fat volume + netapp_e_volume: + ssid: "{{ ssid }}" + name: NewVolumeByAnsible + state: absent + log_path: /tmp/volume.log + api_url: "{{ netapp_api_url }}" + api_username: "{{ netapp_api_username }}" + api_password: "{{ netapp_api_password }}" + validate_certs: "{{ netapp_api_validate_certs }}" + when: check_volume +''' +RETURN = ''' +--- +msg: "Standard volume [workload_vol_1] has been created." +msg: "Thin volume [workload_thin_vol] has been created." +msg: "Volume [workload_vol_1] has been expanded." +msg: "Volume [workload_vol_1] has been deleted." +msg: "Volume [workload_vol_1] did not exist." +msg: "Volume [workload_vol_1] already exists." +''' + +import json +import logging +import time +from traceback import format_exc + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.pycompat24 import get_exception +from ansible.module_utils.urls import open_url +from ansible.module_utils.six.moves.urllib.error import HTTPError + + +def request(url, data=None, headers=None, method='GET', use_proxy=True, + force=False, last_mod_time=None, timeout=10, validate_certs=True, + url_username=None, url_password=None, http_agent=None, force_basic_auth=True, ignore_errors=False): + try: + r = open_url(url=url, data=data, headers=headers, method=method, use_proxy=use_proxy, + force=force, last_mod_time=last_mod_time, timeout=timeout, validate_certs=validate_certs, + url_username=url_username, url_password=url_password, http_agent=http_agent, + force_basic_auth=force_basic_auth) + except HTTPError: + err = get_exception() + r = err.fp + + try: + raw_data = r.read() + if raw_data: + data = json.loads(raw_data) + else: + raw_data is None + except: + if ignore_errors: + pass + else: + raise Exception(raw_data) + + resp_code = r.getcode() + + if resp_code >= 400 and not ignore_errors: + raise Exception(resp_code, data) + else: + return resp_code, data + + +def ifilter(predicate, iterable): + # python 2, 3 generic filtering. + if predicate is None: + predicate = bool + for x in iterable: + if predicate(x): + yield x + + +class NetAppESeriesVolume(object): + def __init__(self): + self._size_unit_map = dict( + bytes=1, + b=1, + kb=1024, + mb=1024 ** 2, + gb=1024 ** 3, + tb=1024 ** 4, + pb=1024 ** 5, + eb=1024 ** 6, + zb=1024 ** 7, + yb=1024 ** 8 + ) + + self._post_headers = dict(Accept="application/json") + self._post_headers['Content-Type'] = 'application/json' + + argument_spec = basic_auth_argument_spec() + argument_spec.update(dict( + state=dict(required=True, choices=['present', 'absent']), + ssid=dict(required=True, type='str'), + name=dict(required=True, type='str'), + storage_pool_name=dict(type='str'), + size_unit=dict(default='gb', choices=['bytes', 'b', 'kb', 'mb', 'gb', 'tb', 'pb', 'eb', 'zb', 'yb'], + type='str'), + size=dict(type='int'), + segment_size_kb=dict(default=128, choices=[8, 16, 32, 64, 128, 256, 512], type='int'), + ssd_cache_enabled=dict(type='bool'), # no default, leave existing setting alone + data_assurance_enabled=dict(default=False, type='bool'), + thin_provision=dict(default=False, type='bool'), + thin_volume_repo_size=dict(type='int'), + thin_volume_max_repo_size=dict(type='int'), + # TODO: add cache, owning controller support, thin expansion policy, etc + log_path=dict(type='str'), + api_url=dict(type='str'), + api_username=dict(type='str'), + api_password=dict(type='str'), + validate_certs=dict(type='bool'), + )) + + self.module = AnsibleModule(argument_spec=argument_spec, + required_if=[ + ('state', 'present', ['storage_pool_name', 'size']), + ('thin_provision', 'true', ['thin_volume_repo_size']) + ], + supports_check_mode=True) + p = self.module.params + + log_path = p['log_path'] + + # logging setup + self._logger = logging.getLogger(self.__class__.__name__) + self.debug = self._logger.debug + + if log_path: + logging.basicConfig(level=logging.DEBUG, filename=log_path) + + self.state = p['state'] + self.ssid = p['ssid'] + self.name = p['name'] + self.storage_pool_name = p['storage_pool_name'] + self.size_unit = p['size_unit'] + self.size = p['size'] + self.segment_size_kb = p['segment_size_kb'] + self.ssd_cache_enabled = p['ssd_cache_enabled'] + self.data_assurance_enabled = p['data_assurance_enabled'] + self.thin_provision = p['thin_provision'] + self.thin_volume_repo_size = p['thin_volume_repo_size'] + self.thin_volume_max_repo_size = p['thin_volume_max_repo_size'] + + if not self.thin_volume_max_repo_size: + self.thin_volume_max_repo_size = self.size + + self.validate_certs = p['validate_certs'] + + try: + self.api_usr = p['api_username'] + self.api_pwd = p['api_password'] + self.api_url = p['api_url'] + except KeyError: + self.module.fail_json(msg="You must pass in api_username " + "and api_password and api_url to the module.") + + def get_volume(self, volume_name): + self.debug('fetching volumes') + # fetch the list of volume objects and look for one with a matching name (we'll need to merge volumes and thin-volumes) + try: + (rc, volumes) = request(self.api_url + "/storage-systems/%s/volumes" % (self.ssid), + headers=dict(Accept="application/json"), url_username=self.api_usr, + url_password=self.api_pwd, validate_certs=self.validate_certs) + except Exception: + err = get_exception() + self.module.fail_json( + msg="Failed to obtain list of standard/thick volumes. Array Id [%s]. Error[%s]." % (self.ssid, + str(err))) + + try: + self.debug('fetching thin-volumes') + (rc, thinvols) = request(self.api_url + "/storage-systems/%s/thin-volumes" % (self.ssid), + headers=dict(Accept="application/json"), url_username=self.api_usr, + url_password=self.api_pwd, validate_certs=self.validate_certs) + except Exception: + err = get_exception() + self.module.fail_json( + msg="Failed to obtain list of thin volumes. Array Id [%s]. Error[%s]." % (self.ssid, str(err))) + + volumes.extend(thinvols) + + self.debug("searching for volume '%s'" % volume_name) + volume_detail = next(ifilter(lambda a: a['name'] == volume_name, volumes), None) + + if volume_detail: + self.debug('found') + else: + self.debug('not found') + + return volume_detail + + def get_storage_pool(self, storage_pool_name): + self.debug("fetching storage pools") + # map the storage pool name to its id + try: + (rc, resp) = request(self.api_url + "/storage-systems/%s/storage-pools" % (self.ssid), + headers=dict(Accept="application/json"), url_username=self.api_usr, + url_password=self.api_pwd, validate_certs=self.validate_certs) + except Exception: + err = get_exception() + self.module.fail_json( + msg="Failed to obtain list of storage pools. Array Id [%s]. Error[%s]." % (self.ssid, str(err))) + + self.debug("searching for storage pool '%s'" % storage_pool_name) + pool_detail = next(ifilter(lambda a: a['name'] == storage_pool_name, resp), None) + + if pool_detail: + self.debug('found') + else: + self.debug('not found') + + return pool_detail + + def create_volume(self, pool_id, name, size_unit, size, segment_size_kb, data_assurance_enabled): + volume_add_req = dict( + name=name, + poolId=pool_id, + sizeUnit=size_unit, + size=size, + segSize=segment_size_kb, + dataAssuranceEnabled=data_assurance_enabled, + ) + + self.debug("creating volume '%s'" % name) + try: + (rc, resp) = request(self.api_url + "/storage-systems/%s/volumes" % (self.ssid), + data=json.dumps(volume_add_req), headers=self._post_headers, method='POST', + url_username=self.api_usr, url_password=self.api_pwd, + validate_certs=self.validate_certs, + timeout=120) + except Exception: + err = get_exception() + self.module.fail_json( + msg="Failed to create volume. Volume [%s]. Array Id [%s]. Error[%s]." % (self.name, self.ssid, + str(err))) + + def create_thin_volume(self, pool_id, name, size_unit, size, thin_volume_repo_size, + thin_volume_max_repo_size, data_assurance_enabled): + thin_volume_add_req = dict( + name=name, + poolId=pool_id, + sizeUnit=size_unit, + virtualSize=size, + repositorySize=thin_volume_repo_size, + maximumRepositorySize=thin_volume_max_repo_size, + dataAssuranceEnabled=data_assurance_enabled, + ) + + self.debug("creating thin-volume '%s'" % name) + try: + (rc, resp) = request(self.api_url + "/storage-systems/%s/thin-volumes" % (self.ssid), + data=json.dumps(thin_volume_add_req), headers=self._post_headers, method='POST', + url_username=self.api_usr, url_password=self.api_pwd, + validate_certs=self.validate_certs, + timeout=120) + except Exception: + err = get_exception() + self.module.fail_json( + msg="Failed to create thin volume. Volume [%s]. Array Id [%s]. Error[%s]." % (self.name, + self.ssid, + str(err))) + + def delete_volume(self): + # delete the volume + self.debug("deleting volume '%s'" % self.volume_detail['name']) + try: + (rc, resp) = request( + self.api_url + "/storage-systems/%s/%s/%s" % (self.ssid, self.volume_resource_name, + self.volume_detail['id']), + method='DELETE', url_username=self.api_usr, url_password=self.api_pwd, + validate_certs=self.validate_certs, timeout=120) + except Exception: + err = get_exception() + self.module.fail_json( + msg="Failed to delete volume. Volume [%s]. Array Id [%s]. Error[%s]." % (self.name, self.ssid, + str(err))) + + @property + def volume_resource_name(self): + if self.volume_detail['thinProvisioned']: + return 'thin-volumes' + else: + return 'volumes' + + @property + def volume_properties_changed(self): + return self.volume_ssdcache_setting_changed # or with other props here when extended + + # TODO: add support for r/w cache settings, owning controller, scan settings, expansion policy, growth alert threshold + + @property + def volume_ssdcache_setting_changed(self): + # None means ignore existing setting + if self.ssd_cache_enabled is not None and self.ssd_cache_enabled != self.volume_detail['flashCached']: + self.debug("flash cache setting changed") + return True + + def update_volume_properties(self): + update_volume_req = dict() + + # conditionally add values so we ignore unspecified props + if self.volume_ssdcache_setting_changed: + update_volume_req['flashCache'] = self.ssd_cache_enabled + + self.debug("updating volume properties...") + try: + (rc, resp) = request( + self.api_url + "/storage-systems/%s/%s/%s/" % (self.ssid, self.volume_resource_name, + self.volume_detail['id']), + data=json.dumps(update_volume_req), headers=self._post_headers, method='POST', + url_username=self.api_usr, url_password=self.api_pwd, validate_certs=self.validate_certs, + timeout=120) + except Exception: + err = get_exception() + self.module.fail_json( + msg="Failed to update volume properties. Volume [%s]. Array Id [%s]. Error[%s]." % (self.name, + self.ssid, + str(err))) + + @property + def volume_needs_expansion(self): + current_size_bytes = int(self.volume_detail['capacity']) + requested_size_bytes = self.size * self._size_unit_map[self.size_unit] + + # TODO: check requested/current repo volume size for thin-volumes as well + + # TODO: do we need to build any kind of slop factor in here? + return requested_size_bytes > current_size_bytes + + def expand_volume(self): + is_thin = self.volume_detail['thinProvisioned'] + if is_thin: + # TODO: support manual repo expansion as well + self.debug('expanding thin volume') + thin_volume_expand_req = dict( + newVirtualSize=self.size, + sizeUnit=self.size_unit + ) + try: + (rc, resp) = request(self.api_url + "/storage-systems/%s/thin-volumes/%s/expand" % (self.ssid, + self.volume_detail[ + 'id']), + data=json.dumps(thin_volume_expand_req), headers=self._post_headers, method='POST', + url_username=self.api_usr, url_password=self.api_pwd, + validate_certs=self.validate_certs, timeout=120) + except Exception: + err = get_exception() + self.module.fail_json( + msg="Failed to expand thin volume. Volume [%s]. Array Id [%s]. Error[%s]." % (self.name, + self.ssid, + str(err))) + + # TODO: check return code + else: + self.debug('expanding volume') + volume_expand_req = dict( + expansionSize=self.size, + sizeUnit=self.size_unit + ) + try: + (rc, resp) = request( + self.api_url + "/storage-systems/%s/volumes/%s/expand" % (self.ssid, + self.volume_detail['id']), + data=json.dumps(volume_expand_req), headers=self._post_headers, method='POST', + url_username=self.api_usr, url_password=self.api_pwd, validate_certs=self.validate_certs, + timeout=120) + except Exception: + err = get_exception() + self.module.fail_json( + msg="Failed to expand volume. Volume [%s]. Array Id [%s]. Error[%s]." % (self.name, + self.ssid, + str(err))) + + self.debug('polling for completion...') + + while True: + try: + (rc, resp) = request(self.api_url + "/storage-systems/%s/volumes/%s/expand" % (self.ssid, + self.volume_detail[ + 'id']), + method='GET', url_username=self.api_usr, url_password=self.api_pwd, + validate_certs=self.validate_certs) + except Exception: + err = get_exception() + self.module.fail_json( + msg="Failed to get volume expansion progress. Volume [%s]. Array Id [%s]. Error[%s]." % ( + self.name, self.ssid, str(err))) + + action = resp['action'] + percent_complete = resp['percentComplete'] + + self.debug('expand action %s, %s complete...' % (action, percent_complete)) + + if action == 'none': + self.debug('expand complete') + break + else: + time.sleep(5) + + def apply(self): + changed = False + volume_exists = False + msg = None + + self.volume_detail = self.get_volume(self.name) + + if self.volume_detail: + volume_exists = True + + if self.state == 'absent': + self.debug("CHANGED: volume exists, but requested state is 'absent'") + changed = True + elif self.state == 'present': + # check requested volume size, see if expansion is necessary + if self.volume_needs_expansion: + self.debug( + "CHANGED: requested volume size %s%s is larger than current size %sb" % (self.size, + self.size_unit, + self.volume_detail[ + 'capacity'])) + changed = True + + if self.volume_properties_changed: + self.debug("CHANGED: one or more volume properties have changed") + changed = True + + else: + if self.state == 'present': + self.debug("CHANGED: volume does not exist, but requested state is 'present'") + changed = True + + if changed: + if self.module.check_mode: + self.debug('skipping changes due to check mode') + else: + if self.state == 'present': + if not volume_exists: + pool_detail = self.get_storage_pool(self.storage_pool_name) + + if not pool_detail: + self.module.fail_json(msg='Requested storage pool (%s) not found' % self.storage_pool_name) + + if self.thin_provision and not pool_detail['diskPool']: + self.module.fail_json( + msg='Thin provisioned volumes can only be located on disk pools (not volume groups)') + + pool_id = pool_detail['id'] + + if not self.thin_provision: + self.create_volume(pool_id, self.name, self.size_unit, self.size, self.segment_size_kb, + self.data_assurance_enabled) + msg = "Standard volume [%s] has been created." % (self.name) + + else: + self.create_thin_volume(pool_id, self.name, self.size_unit, self.size, + self.thin_volume_repo_size, self.thin_volume_max_repo_size, + self.data_assurance_enabled) + msg = "Thin volume [%s] has been created." % (self.name) + + else: # volume exists but differs, modify... + if self.volume_needs_expansion: + self.expand_volume() + msg = "Volume [%s] has been expanded." % (self.name) + + # this stuff always needs to run on present (since props can't be set on creation) + if self.volume_properties_changed: + self.update_volume_properties() + msg = "Properties of volume [%s] has been updated." % (self.name) + + elif self.state == 'absent': + self.delete_volume() + msg = "Volume [%s] has been deleted." % (self.name) + else: + self.debug("exiting with no changes") + if self.state == 'absent': + msg = "Volume [%s] did not exist." % (self.name) + else: + msg = "Volume [%s] already exists." % (self.name) + + self.module.exit_json(msg=msg, changed=changed) + + +def main(): + v = NetAppESeriesVolume() + + try: + v.apply() + except Exception: + e = get_exception() + v.debug("Exception in apply(): \n%s" % format_exc(e)) + v.module.fail_json(msg="Module failed. Error [%s]." % (str(e))) + + +if __name__ == '__main__': + main() diff --git a/storage/netapp/netapp_e_volume_copy.py b/storage/netapp/netapp_e_volume_copy.py new file mode 100644 index 00000000000..f715c84088f --- /dev/null +++ b/storage/netapp/netapp_e_volume_copy.py @@ -0,0 +1,439 @@ +#!/usr/bin/python + +# (c) 2016, NetApp, Inc +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# +DOCUMENTATION = """ +--- +module: netapp_e_volume_copy +short_description: Create volume copy pairs +description: + - Create and delete snapshots images on volume groups for NetApp E-series storage arrays. +version_added: '2.2' +author: Kevin Hulquest (@hulquest) +options: + api_username: + required: true + description: + - The username to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_password: + required: true + description: + - The password to authenticate with the SANtricity WebServices Proxy or embedded REST API. + api_url: + required: true + description: + - The url to the SANtricity WebServices Proxy or embedded REST API. + example: + - https://prod-1.wahoo.acme.com/devmgr/v2 + validate_certs: + required: false + default: true + description: + - Should https certificates be validated? + source_volume_id: + description: + - The the id of the volume copy source. + - If used, must be paired with destination_volume_id + - Mutually exclusive with volume_copy_pair_id, and search_volume_id + destination_volume_id: + description: + - The the id of the volume copy destination. + - If used, must be paired with source_volume_id + - Mutually exclusive with volume_copy_pair_id, and search_volume_id + volume_copy_pair_id: + description: + - The the id of a given volume copy pair + - Mutually exclusive with destination_volume_id, source_volume_id, and search_volume_id + - Can use to delete or check presence of volume pairs + - Must specify this or (destination_volume_id and source_volume_id) + state: + description: + - Whether the specified volume copy pair should exist or not. + required: True + choices: ['present', 'absent'] + create_copy_pair_if_does_not_exist: + description: + - Defines if a copy pair will be created if it does not exist. + - If set to True destination_volume_id and source_volume_id are required. + choices: [True, False] + default: True + start_stop_copy: + description: + - starts a re-copy or stops a copy in progress + - "Note: If you stop the initial file copy before it it done the copy pair will be destroyed" + - Requires volume_copy_pair_id + search_volume_id: + description: + - Searches for all valid potential target and source volumes that could be used in a copy_pair + - Mutually exclusive with volume_copy_pair_id, destination_volume_id and source_volume_id +""" +RESULTS = """ +""" +EXAMPLES = """ +--- +msg: + description: Success message + returned: success + type: string + sample: Json facts for the volume copy that was created. +""" +RETURN = """ +msg: + description: Success message + returned: success + type: string + sample: Created Volume Copy Pair with ID +""" + +import json + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.pycompat24 import get_exception +from ansible.module_utils.urls import open_url +from ansible.module_utils.six.moves.urllib.error import HTTPError + +HEADERS = { + "Content-Type": "application/json", + "Accept": "application/json", +} + + +def request(url, data=None, headers=None, method='GET', use_proxy=True, + force=False, last_mod_time=None, timeout=10, validate_certs=True, + url_username=None, url_password=None, http_agent=None, force_basic_auth=True, ignore_errors=False): + try: + r = open_url(url=url, data=data, headers=headers, method=method, use_proxy=use_proxy, + force=force, last_mod_time=last_mod_time, timeout=timeout, validate_certs=validate_certs, + url_username=url_username, url_password=url_password, http_agent=http_agent, + force_basic_auth=force_basic_auth) + except HTTPError: + err = get_exception() + r = err.fp + + try: + raw_data = r.read() + if raw_data: + data = json.loads(raw_data) + else: + raw_data = None + except: + if ignore_errors: + pass + else: + raise Exception(raw_data) + + resp_code = r.getcode() + + if resp_code >= 400 and not ignore_errors: + raise Exception(resp_code, data) + else: + return resp_code, data + + +def find_volume_copy_pair_id_from_source_volume_id_and_destination_volume_id(params): + get_status = 'storage-systems/%s/volume-copy-jobs' % params['ssid'] + url = params['api_url'] + get_status + + (rc, resp) = request(url, method='GET', url_username=params['api_username'], + url_password=params['api_password'], headers=HEADERS, + validate_certs=params['validate_certs']) + + volume_copy_pair_id = None + for potential_copy_pair in resp: + if potential_copy_pair['sourceVolume'] == params['source_volume_id']: + if potential_copy_pair['sourceVolume'] == params['source_volume_id']: + volume_copy_pair_id = potential_copy_pair['id'] + + return volume_copy_pair_id + + +def create_copy_pair(params): + get_status = 'storage-systems/%s/volume-copy-jobs' % params['ssid'] + url = params['api_url'] + get_status + + rData = { + "sourceId": params['source_volume_id'], + "targetId": params['destination_volume_id'] + } + + (rc, resp) = request(url, data=json.dumps(rData), ignore_errors=True, method='POST', + url_username=params['api_username'], url_password=params['api_password'], headers=HEADERS, + validate_certs=params['validate_certs']) + if rc != 200: + return False, (rc, resp) + else: + return True, (rc, resp) + + +def delete_copy_pair_by_copy_pair_id(params): + get_status = 'storage-systems/%s/volume-copy-jobs/%s?retainRepositories=false' % ( + params['ssid'], params['volume_copy_pair_id']) + url = params['api_url'] + get_status + + (rc, resp) = request(url, ignore_errors=True, method='DELETE', + url_username=params['api_username'], url_password=params['api_password'], headers=HEADERS, + validate_certs=params['validate_certs']) + if rc != 204: + return False, (rc, resp) + else: + return True, (rc, resp) + + +def find_volume_copy_pair_id_by_volume_copy_pair_id(params): + get_status = 'storage-systems/%s/volume-copy-jobs/%s?retainRepositories=false' % ( + params['ssid'], params['volume_copy_pair_id']) + url = params['api_url'] + get_status + + (rc, resp) = request(url, ignore_errors=True, method='DELETE', + url_username=params['api_username'], url_password=params['api_password'], headers=HEADERS, + validate_certs=params['validate_certs']) + if rc != 200: + return False, (rc, resp) + else: + return True, (rc, resp) + + +def start_stop_copy(params): + get_status = 'storage-systems/%s/volume-copy-jobs-control/%s?control=%s' % ( + params['ssid'], params['volume_copy_pair_id'], params['start_stop_copy']) + url = params['api_url'] + get_status + + (response_code, response_data) = request(url, ignore_errors=True, method='POST', + url_username=params['api_username'], url_password=params['api_password'], + headers=HEADERS, + validate_certs=params['validate_certs']) + + if response_code == 200: + return True, response_data[0]['percentComplete'] + else: + return False, response_data + + +def check_copy_status(params): + get_status = 'storage-systems/%s/volume-copy-jobs-control/%s' % ( + params['ssid'], params['volume_copy_pair_id']) + url = params['api_url'] + get_status + + (response_code, response_data) = request(url, ignore_errors=True, method='GET', + url_username=params['api_username'], url_password=params['api_password'], + headers=HEADERS, + validate_certs=params['validate_certs']) + + if response_code == 200: + if response_data['percentComplete'] != -1: + + return True, response_data['percentComplete'] + else: + return False, response_data['percentComplete'] + else: + return False, response_data + + +def find_valid_copy_pair_targets_and_sources(params): + get_status = 'storage-systems/%s/volumes' % params['ssid'] + url = params['api_url'] + get_status + + (response_code, response_data) = request(url, ignore_errors=True, method='GET', + url_username=params['api_username'], url_password=params['api_password'], + headers=HEADERS, + validate_certs=params['validate_certs']) + + if response_code == 200: + source_capacity = None + candidates = [] + for volume in response_data: + if volume['id'] == params['search_volume_id']: + source_capacity = volume['capacity'] + else: + candidates.append(volume) + + potential_sources = [] + potential_targets = [] + + for volume in candidates: + if volume['capacity'] > source_capacity: + if volume['volumeCopyTarget'] is False: + if volume['volumeCopySource'] is False: + potential_targets.append(volume['id']) + else: + if volume['volumeCopyTarget'] is False: + if volume['volumeCopySource'] is False: + potential_sources.append(volume['id']) + + return potential_targets, potential_sources + + else: + raise Exception("Response [%s]" % response_code) + + +def main(): + module = AnsibleModule(argument_spec=dict( + source_volume_id=dict(type='str'), + destination_volume_id=dict(type='str'), + copy_priority=dict(required=False, default=0, type='int'), + ssid=dict(required=True, type='str'), + api_url=dict(required=True), + api_username=dict(required=False), + api_password=dict(required=False, no_log=True), + validate_certs=dict(required=False, default=True), + targetWriteProtected=dict(required=False, default=True, type='bool'), + onlineCopy=dict(required=False, default=False, type='bool'), + volume_copy_pair_id=dict(type='str'), + status=dict(required=True, choices=['present', 'absent'], type='str'), + create_copy_pair_if_does_not_exist=dict(required=False, default=True, type='bool'), + start_stop_copy=dict(required=False, choices=['start', 'stop'], type='str'), + search_volume_id=dict(type='str'), + ), + mutually_exclusive=[['volume_copy_pair_id', 'destination_volume_id'], + ['volume_copy_pair_id', 'source_volume_id'], + ['volume_copy_pair_id', 'search_volume_id'], + ['search_volume_id', 'destination_volume_id'], + ['search_volume_id', 'source_volume_id'], + ], + required_together=[['source_volume_id', 'destination_volume_id'], + ], + required_if=[["create_copy_pair_if_does_not_exist", True, ['source_volume_id', 'destination_volume_id'], ], + ["start_stop_copy", 'stop', ['volume_copy_pair_id'], ], + ["start_stop_copy", 'start', ['volume_copy_pair_id'], ], + ] + + ) + params = module.params + + if not params['api_url'].endswith('/'): + params['api_url'] += '/' + + # Check if we want to search + if params['search_volume_id'] is not None: + try: + potential_targets, potential_sources = find_valid_copy_pair_targets_and_sources(params) + except: + e = get_exception() + module.fail_json(msg="Failed to find valid copy pair candidates. Error [%s]" % str(e)) + + module.exit_json(changed=False, + msg=' Valid source devices found: %s Valid target devices found: %s' % (len(potential_sources), len(potential_targets)), + search_volume_id=params['search_volume_id'], + valid_targets=potential_targets, + valid_sources=potential_sources) + + # Check if we want to start or stop a copy operation + if params['start_stop_copy'] == 'start' or params['start_stop_copy'] == 'stop': + + # Get the current status info + currenty_running, status_info = check_copy_status(params) + + # If we want to start + if params['start_stop_copy'] == 'start': + + # If we have already started + if currenty_running is True: + module.exit_json(changed=False, msg='Volume Copy Pair copy has started.', + volume_copy_pair_id=params['volume_copy_pair_id'], percent_done=status_info) + # If we need to start + else: + + start_status, info = start_stop_copy(params) + + if start_status is True: + module.exit_json(changed=True, msg='Volume Copy Pair copy has started.', + volume_copy_pair_id=params['volume_copy_pair_id'], percent_done=info) + else: + module.fail_json(msg="Could not start volume copy pair Error: %s" % info) + + # If we want to stop + else: + # If it has already stopped + if currenty_running is False: + module.exit_json(changed=False, msg='Volume Copy Pair copy is stopped.', + volume_copy_pair_id=params['volume_copy_pair_id']) + + # If we need to stop it + else: + start_status, info = start_stop_copy(params) + + if start_status is True: + module.exit_json(changed=True, msg='Volume Copy Pair copy has been stopped.', + volume_copy_pair_id=params['volume_copy_pair_id']) + else: + module.fail_json(msg="Could not stop volume copy pair Error: %s" % info) + + # If we want the copy pair to exist we do this stuff + if params['status'] == 'present': + + # We need to check if it exists first + if params['volume_copy_pair_id'] is None: + params['volume_copy_pair_id'] = find_volume_copy_pair_id_from_source_volume_id_and_destination_volume_id( + params) + + # If no volume copy pair is found we need need to make it. + if params['volume_copy_pair_id'] is None: + + # In order to create we can not do so with just a volume_copy_pair_id + + copy_began_status, (rc, resp) = create_copy_pair(params) + + if copy_began_status is True: + module.exit_json(changed=True, msg='Created Volume Copy Pair with ID: %s' % resp['id']) + else: + module.fail_json(msg="Could not create volume copy pair Code: %s Error: %s" % (rc, resp)) + + # If it does exist we do nothing + else: + # We verify that it exists + exist_status, (exist_status_code, exist_status_data) = find_volume_copy_pair_id_by_volume_copy_pair_id( + params) + + if exist_status: + module.exit_json(changed=False, + msg=' Volume Copy Pair with ID: %s exists' % params['volume_copy_pair_id']) + else: + if exist_status_code == 404: + module.fail_json( + msg=' Volume Copy Pair with ID: %s does not exist. Can not create without source_volume_id and destination_volume_id' % + params['volume_copy_pair_id']) + else: + module.fail_json(msg="Could not find volume copy pair Code: %s Error: %s" % ( + exist_status_code, exist_status_data)) + + module.fail_json(msg="Done") + + # If we want it to not exist we do this + else: + + if params['volume_copy_pair_id'] is None: + params['volume_copy_pair_id'] = find_volume_copy_pair_id_from_source_volume_id_and_destination_volume_id( + params) + + # We delete it by the volume_copy_pair_id + delete_status, (delete_status_code, delete_status_data) = delete_copy_pair_by_copy_pair_id(params) + + if delete_status is True: + module.exit_json(changed=True, + msg=' Volume Copy Pair with ID: %s was deleted' % params['volume_copy_pair_id']) + else: + if delete_status_code == 404: + module.exit_json(changed=False, + msg=' Volume Copy Pair with ID: %s does not exist' % params['volume_copy_pair_id']) + else: + module.fail_json(msg="Could not delete volume copy pair Code: %s Error: %s" % ( + delete_status_code, delete_status_data)) + + +if __name__ == '__main__': + main()