diff options
Diffstat (limited to 'ansible_collections/purestorage/fusion/plugins')
29 files changed, 6448 insertions, 0 deletions
diff --git a/ansible_collections/purestorage/fusion/plugins/doc_fragments/purestorage.py b/ansible_collections/purestorage/fusion/plugins/doc_fragments/purestorage.py new file mode 100644 index 000000000..a2f933161 --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/doc_fragments/purestorage.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2021, Simon Dodsley <simon@purestorage.com> +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +class ModuleDocFragment(object): + # Standard Pure Storage documentation fragment + DOCUMENTATION = r""" +options: + - See separate platform section for more details +requirements: + - See separate platform section for more details +notes: + - Ansible modules are available for the following Pure Storage products: FlashArray, FlashBlade, Pure1, Fusion +""" + + # Documentation fragment for Fusion + FUSION = r""" +options: + private_key_file: + aliases: [ key_file ] + description: + - Path to the private key file + - Defaults to the set environment variable under FUSION_PRIVATE_KEY_FILE. + type: str + private_key_password: + description: + - Password of the encrypted private key file + type: str + issuer_id: + aliases: [ app_id ] + description: + - Application ID from Pure1 Registration page + - eg. pure1:apikey:dssf2331sd + - Defaults to the set environment variable under FUSION_ISSUER_ID + type: str + access_token: + description: + - Access token for Fusion Service + - Defaults to the set environment variable under FUSION_ACCESS_TOKEN + type: str +notes: + - This module requires the I(purefusion) Python library + - You must set C(FUSION_ISSUER_ID) and C(FUSION_PRIVATE_KEY_FILE) environment variables + if I(issuer_id) and I(private_key_file) arguments are not passed to the module directly + - If you want to use access token for authentication, you must use C(FUSION_ACCESS_TOKEN) environment variable + if I(access_token) argument is not passed to the module directly +requirements: + - python >= 3.8 + - purefusion +""" diff --git a/ansible_collections/purestorage/fusion/plugins/inventory/__init__.py b/ansible_collections/purestorage/fusion/plugins/inventory/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/inventory/__init__.py diff --git a/ansible_collections/purestorage/fusion/plugins/module_utils/errors.py b/ansible_collections/purestorage/fusion/plugins/module_utils/errors.py new file mode 100644 index 000000000..0edf364cf --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/module_utils/errors.py @@ -0,0 +1,291 @@ +# -*- coding: utf-8 -*- + +# (c) 2023, Jan Kodera (jkodera@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +try: + import fusion as purefusion + import urllib3 +except ImportError: + pass + +import sys +import json +import re +import traceback as trace + + +class OperationException(Exception): + """Raised if an asynchronous Operation fails.""" + + def __init__(self, op, http_error=None): + self._op = op + self._http_error = http_error + + @property + def op(self): + return self._op + + @property + def http_error(self): + return self._http_error + + +def _get_verbosity(module): + # verbosity is a private member and Ansible does not really allow + # providing extra information only if the user wants it due to ideological + # reasons, so extract it as carefully as possible and assume non-verbose + # if something fails + try: + if module._verbosity is not None and isinstance(module._verbosity, int): + return module._verbosity + except Exception: + pass + return 0 + + +def _extract_rest_call_site(traceback): + # extracts first function in traceback that comes from 'fusion.api.*_api*', + # converts its name from something like 'get_volume' to 'Get volume' and returns + while traceback: + try: + frame = traceback.tb_frame + func_name = ( + frame.f_code.co_name + ) # contains function name, e.g. 'get_volume' + mod_path = frame.f_globals[ + "__name__" + ] # contains module path, e.g. 'fusion.api.volumes_api' + path_segments = mod_path.split(".") + if ( + path_segments[0] == "fusion" + and path_segments[1] == "api" + and "_api" in path_segments[2] + ): + call_site = func_name.replace("_", " ").capitalize() + return call_site + except Exception: + pass + traceback = traceback.tb_next + return None + + +class DetailsPrinter: + def __init__(self, target): + self._target = target + self._parenthesed = False + + def append(self, what): + if not self._parenthesed: + self._target += " (" + self._parenthesed = True + else: + self._target += ", " + + self._target += what + + def finish(self): + if self._parenthesed: + self._target += ")" + return self._target + + +def format_fusion_api_exception(exception, traceback=None): + """Formats `fusion.rest.ApiException` into a simple short form, suitable + for Ansible error output. Returns a (message: str, body: dict) tuple.""" + message = None + code = None + resource_name = None + request_id = None + body = None + call_site = _extract_rest_call_site(traceback) + try: + body = json.loads(exception.body) + request_id = body.get("request_id", None) + error = body["error"] + message = error.get("message") + code = error.get("pure_code") + if not code: + code = exception.status + if not code: + code = error.get("http_code") + resource_name = error["details"]["name"] + except Exception: + pass + + output = "" + if call_site: + output += "'{0}' failed".format(call_site) + else: + output += "request failed" + + if message: + output += ", {0}".format(message.replace('"', "'")) + + details = DetailsPrinter(output) + if resource_name: + details.append("resource: '{0}'".format(resource_name)) + if code: + details.append("code: '{0}'".format(code)) + if request_id: + details.append("request id: '{0}'".format(request_id)) + output = details.finish() + + return (output, body) + + +def format_failed_fusion_operation_exception(exception): + """Formats failed `fusion.Operation` into a simple short form, suitable + for Ansible error output. Returns a (message: str, body: dict) tuple.""" + op = exception.op + http_error = exception.http_error + if op.status != "Failed" and not http_error: + raise ValueError( + "BUG: can only format Operation exception with .status == Failed or http_error != None" + ) + + message = None + code = None + operation_name = None + operation_id = None + + try: + if op.status == "Failed": + operation_id = op.id + error = op.error + message = error.message + code = error.pure_code + if not code: + code = error.http_code + operation_name = op.request_type + except Exception as e: + pass + + output = "" + if operation_name: + # converts e.g. 'CreateVolume' to 'Create volume' + operation_name = re.sub("(.)([A-Z][a-z]+)", r"\1 \2", operation_name) + operation_name = re.sub( + "([a-z0-9])([A-Z])", r"\1 \2", operation_name + ).capitalize() + output += "{0}: ".format(operation_name) + output += "operation failed" + + if message: + output += ", {0}".format(message.replace('"', "'")) + + details = DetailsPrinter(output) + if code: + details.append("code: '{0}'".format(code)) + if operation_id: + details.append("operation id: '{0}'".format(operation_id)) + if http_error: + details.append("HTTP error: '{0}'".format(str(http_error).replace('"', "'"))) + + output = details.finish() + + return output + + +def format_http_exception(exception, traceback): + """Formats failed `urllib3.exceptions` exceptions into a simple short form, + suitable for Ansible error output. Returns a `str`.""" + # urllib3 exceptions hide all details in a formatted message so all we + # can do is append the REST call that caused this + output = "" + call_site = _extract_rest_call_site(traceback) + if call_site: + output += "'{0}': ".format(call_site) + output += "HTTP request failed via " + + inner = exception + while True: + try: + e = inner.reason + if e and isinstance(e, urllib3.exceptions.HTTPError): + inner = e + continue + break + except Exception: + break + + if inner != exception: + output += "'{0}'/'{1}'".format(type(inner).__name__, type(exception).__name__) + else: + output += "'{0}'".format(type(exception).__name__) + + output += " - {0}".format(str(exception).replace('"', "'")) + + return output + + +def _handle_api_exception( + module, + exception, + traceback, + verbosity, +): + (error_message, body) = format_fusion_api_exception(exception, traceback) + + if verbosity > 1: + module.fail_json(msg=error_message, call_details=body, traceback=str(traceback)) + elif verbosity > 0: + module.fail_json(msg=error_message, call_details=body) + else: + module.fail_json(msg=error_message) + + +def _handle_operation_exception(module, exception, traceback, verbosity): + op = exception.op + + error_message = format_failed_fusion_operation_exception(exception) + + if verbosity > 1: + module.fail_json( + msg=error_message, op_details=op.to_dict(), traceback=str(traceback) + ) + elif verbosity > 0: + module.fail_json(msg=error_message, op_details=op.to_dict()) + else: + module.fail_json(msg=error_message) + + +def _handle_http_exception(module, exception, traceback, verbosity): + error_message = format_http_exception(exception, traceback) + + if verbosity > 1: + module.fail_json(msg=error_message, traceback=trace.format_exception(exception)) + else: + module.fail_json(msg=error_message) + + +def _except_hook_callback(module, original_hook, type, value, traceback): + verbosity = _get_verbosity(module) + if type == purefusion.rest.ApiException: + _handle_api_exception( + module, + value, + traceback, + verbosity, + ) + elif type == OperationException: + _handle_operation_exception(module, value, traceback, verbosity) + elif issubclass(type, urllib3.exceptions.HTTPError): + _handle_http_exception(module, value, traceback, verbosity) + + # if we bubbled here the handlers were not able to process the exception + original_hook(type, value, traceback) + + +def install_fusion_exception_hook(module): + """Installs a hook that catches `purefusion.rest.ApiException` and + `OperationException` and produces simpler and nicer error messages + for Ansible output.""" + original_hook = sys.excepthook + sys.excepthook = lambda type, value, traceback: _except_hook_callback( + module, original_hook, type, value, traceback + ) diff --git a/ansible_collections/purestorage/fusion/plugins/module_utils/fusion.py b/ansible_collections/purestorage/fusion/plugins/module_utils/fusion.py new file mode 100644 index 000000000..74b5f0e91 --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/module_utils/fusion.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- + +# This code is part of Ansible, but is an independent component. +# This particular file snippet, and this file snippet only, is BSD licensed. +# Modules you write using this snippet, which is embedded dynamically by Ansible +# still belong to the author of the module, and may assign their own license +# to the complete work. +# +# Copyright (c), Simon Dodsley <simon@purestorage.com>,2021 +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +try: + import fusion +except ImportError: + pass + +from os import environ +from urllib.parse import urljoin +import platform + +TOKEN_EXCHANGE_URL = "https://api.pure1.purestorage.com/oauth2/1.0/token" +VERSION = 1.0 +USER_AGENT_BASE = "Ansible" + +PARAM_ISSUER_ID = "issuer_id" +PARAM_PRIVATE_KEY_FILE = "private_key_file" +PARAM_PRIVATE_KEY_PASSWORD = "private_key_password" +PARAM_ACCESS_TOKEN = "access_token" +ENV_ISSUER_ID = "FUSION_ISSUER_ID" +ENV_API_HOST = "FUSION_API_HOST" +ENV_PRIVATE_KEY_FILE = "FUSION_PRIVATE_KEY_FILE" +ENV_TOKEN_ENDPOINT = "FUSION_TOKEN_ENDPOINT" +ENV_ACCESS_TOKEN = "FUSION_ACCESS_TOKEN" + +# will be deprecated in 2.0.0 +PARAM_APP_ID = "app_id" # replaced by PARAM_ISSUER_ID +PARAM_KEY_FILE = "key_file" # replaced by PARAM_PRIVATE_KEY_FILE +ENV_APP_ID = "FUSION_APP_ID" # replaced by ENV_ISSUER_ID +ENV_HOST = "FUSION_HOST" # replaced by ENV_API_HOST +DEP_VER = "2.0.0" +BASE_PATH = "/api/1.1" + + +def _env_deprecation_warning(module, old_env, new_env, vers): + if old_env in environ: + if new_env in environ: + module.warn( + f"{old_env} env variable is ignored because {new_env} is specified." + f" {old_env} env variable is deprecated and will be removed in version {vers}" + f" Please use {new_env} env variable only." + ) + else: + module.warn( + f"{old_env} env variable is deprecated and will be removed in version {vers}" + f" Please use {new_env} env variable instead." + ) + + +def _param_deprecation_warning(module, old_param, new_param, vers): + if old_param in module.params: + module.warn( + f"{old_param} parameter is deprecated and will be removed in version {vers}" + f" Please use {new_param} parameter instead." + f" Don't use both parameters simultaneously." + ) + + +def get_fusion(module): + """Return System Object or Fail""" + # deprecation warnings + _param_deprecation_warning(module, PARAM_APP_ID, PARAM_ISSUER_ID, DEP_VER) + _param_deprecation_warning(module, PARAM_KEY_FILE, PARAM_PRIVATE_KEY_FILE, DEP_VER) + _env_deprecation_warning(module, ENV_APP_ID, ENV_ISSUER_ID, DEP_VER) + _env_deprecation_warning(module, ENV_HOST, ENV_API_HOST, DEP_VER) + + user_agent = "%(base)s %(class)s/%(version)s (%(platform)s)" % { + "base": USER_AGENT_BASE, + "class": __name__, + "version": VERSION, + "platform": platform.platform(), + } + + issuer_id = module.params[PARAM_ISSUER_ID] + access_token = module.params[PARAM_ACCESS_TOKEN] + private_key_file = module.params[PARAM_PRIVATE_KEY_FILE] + private_key_password = module.params[PARAM_PRIVATE_KEY_PASSWORD] + + if private_key_password is not None: + module.fail_on_missing_params([PARAM_PRIVATE_KEY_FILE]) + + config = fusion.Configuration() + if ENV_API_HOST in environ or ENV_HOST in environ: + host_url = environ.get(ENV_API_HOST, environ.get(ENV_HOST)) + config.host = urljoin(host_url, BASE_PATH) + config.token_endpoint = environ.get(ENV_TOKEN_ENDPOINT, config.token_endpoint) + + if access_token is not None: + config.access_token = access_token + elif issuer_id is not None and private_key_file is not None: + config.issuer_id = issuer_id + config.private_key_file = private_key_file + if private_key_password is not None: + config.private_key_password = private_key_password + elif ENV_ACCESS_TOKEN in environ: + config.access_token = environ.get(ENV_ACCESS_TOKEN) + elif ( + ENV_ISSUER_ID in environ or ENV_APP_ID in environ + ) and ENV_PRIVATE_KEY_FILE in environ: + config.issuer_id = environ.get(ENV_ISSUER_ID, environ.get(ENV_APP_ID)) + config.private_key_file = environ.get(ENV_PRIVATE_KEY_FILE) + else: + module.fail_json( + msg=f"You must set either {ENV_ISSUER_ID} and {ENV_PRIVATE_KEY_FILE} or {ENV_ACCESS_TOKEN} environment variables. " + f"Or module arguments either {PARAM_ISSUER_ID} and {PARAM_PRIVATE_KEY_FILE} or {PARAM_ACCESS_TOKEN}" + ) + + try: + client = fusion.ApiClient(config) + client.set_default_header("User-Agent", user_agent) + api_instance = fusion.DefaultApi(client) + api_instance.get_version() + except Exception as err: + module.fail_json(msg="Fusion authentication failed: {0}".format(err)) + + return client + + +def fusion_argument_spec(): + """Return standard base dictionary used for the argument_spec argument in AnsibleModule""" + + return { + PARAM_ISSUER_ID: { + "no_log": True, + "aliases": [PARAM_APP_ID], + "deprecated_aliases": [ + { + "name": PARAM_APP_ID, + "version": DEP_VER, + "collection_name": "purefusion.fusion", + } + ], + }, + PARAM_PRIVATE_KEY_FILE: { + "no_log": False, + "aliases": [PARAM_KEY_FILE], + "deprecated_aliases": [ + { + "name": PARAM_KEY_FILE, + "version": DEP_VER, + "collection_name": "purefusion.fusion", + } + ], + }, + PARAM_PRIVATE_KEY_PASSWORD: { + "no_log": True, + }, + PARAM_ACCESS_TOKEN: { + "no_log": True, + }, + } diff --git a/ansible_collections/purestorage/fusion/plugins/module_utils/getters.py b/ansible_collections/purestorage/fusion/plugins/module_utils/getters.py new file mode 100644 index 000000000..535de76ba --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/module_utils/getters.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- + +# (c) 2023, Daniel Turecek (dturecek@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +try: + import fusion as purefusion +except ImportError: + pass + + +def get_array(module, fusion, array_name=None): + """Return Array or None""" + array_api_instance = purefusion.ArraysApi(fusion) + try: + if array_name is None: + array_name = module.params["array"] + + return array_api_instance.get_array( + array_name=array_name, + availability_zone_name=module.params["availability_zone"], + region_name=module.params["region"], + ) + except purefusion.rest.ApiException: + return None + + +def get_az(module, fusion, availability_zone_name=None): + """Get Availability Zone or None""" + az_api_instance = purefusion.AvailabilityZonesApi(fusion) + try: + if availability_zone_name is None: + availability_zone_name = module.params["availability_zone"] + + return az_api_instance.get_availability_zone( + region_name=module.params["region"], + availability_zone_name=availability_zone_name, + ) + except purefusion.rest.ApiException: + return None + + +def get_region(module, fusion, region_name=None): + """Get Region or None""" + region_api_instance = purefusion.RegionsApi(fusion) + try: + if region_name is None: + region_name = module.params["region"] + + return region_api_instance.get_region( + region_name=region_name, + ) + except purefusion.rest.ApiException: + return None + + +def get_ss(module, fusion, storage_service_name=None): + """Return Storage Service or None""" + ss_api_instance = purefusion.StorageServicesApi(fusion) + try: + if storage_service_name is None: + storage_service_name = module.params["storage_service"] + + return ss_api_instance.get_storage_service( + storage_service_name=storage_service_name + ) + except purefusion.rest.ApiException: + return None + + +def get_tenant(module, fusion, tenant_name=None): + """Return Tenant or None""" + api_instance = purefusion.TenantsApi(fusion) + try: + if tenant_name is None: + tenant_name = module.params["tenant"] + + return api_instance.get_tenant(tenant_name=tenant_name) + except purefusion.rest.ApiException: + return None + + +def get_ts(module, fusion, tenant_space_name=None): + """Tenant Space or None""" + ts_api_instance = purefusion.TenantSpacesApi(fusion) + try: + if tenant_space_name is None: + tenant_space_name = module.params["tenant_space"] + + return ts_api_instance.get_tenant_space( + tenant_name=module.params["tenant"], + tenant_space_name=tenant_space_name, + ) + except purefusion.rest.ApiException: + return None diff --git a/ansible_collections/purestorage/fusion/plugins/module_utils/networking.py b/ansible_collections/purestorage/fusion/plugins/module_utils/networking.py new file mode 100644 index 000000000..a00d8200a --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/module_utils/networking.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- + +# (c) 2023, Jan Kodera (jkodera@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import ipaddress + +# while regexes are hard to maintain, they are used anyways for few reasons: +# a) REST backend accepts fairly restricted input and we need to match that input instead of all +# the esoteric extra forms various packages are usually capable of parsing (like dotted-decimal +# subnet masks, octal octets, hexadecimal octets, zero-extended addresses etc.) +# b) manually written parsing routines are usually complex to write, verify and think about +import re + +# IPv4 octet regex part, matches only simple decimal 0-255 without leading zeroes +_octet = ( + "((?:[0-9])|" # matches 0-9 + "(?:[1-9][0-9])|" # matches 10-99 + "(?:1[0-9][0-9])|" # matches 100-199 + "(?:2[0-4][0-9])|" # matches 200-249 + "(?:25[0-5]))" # matches 250-255 +) + +# IPv4 subnet mask regex part, matches decimal 8-32 +_subnet_mask = ( + "((?:[8-9])|" # matches 8-9 + "(?:[1-2][0-9])|" # matches 10-29 + "(?:3[0-2]))" # matches 30-32 +) + +# matches IPv4 addresses +_addr_pattern = re.compile(r"^{octet}\.{octet}\.{octet}\.{octet}$".format(octet=_octet)) +# matches IPv4 networks in CIDR format, i.e. addresses in the form 'a.b.c.d/e' +_cidr_pattern = re.compile( + r"^{octet}\.{octet}\.{octet}\.{octet}\/{0}$".format(_subnet_mask, octet=_octet) +) + + +def is_valid_network(addr): + """Returns True if `addr` is IPv4 address/submask in bit CIDR notation, False otherwise.""" + match = re.match(_cidr_pattern, addr) + if match is None: + return False + for i in range(4): + if int(match.group(i + 1)) > 255: + return False + mask = int(match.group(5)) + if mask < 8 or mask > 32: + return False + return True + + +def is_valid_address(addr): + """Returns True if `addr` is a valid IPv4 address, False otherwise. Does not support + octal/hex notations.""" + match = re.match(_addr_pattern, addr) + if match is None: + return False + for i in range(4): + if int(match.group(i + 1)) > 255: + return False + return True + + +def is_address_in_network(addr, network): + """Returns True if `addr` and `network` are a valid IPv4 address and + IPv4 network respectively and if `addr` is in `network`, False otherwise.""" + if not is_valid_address(addr) or not is_valid_network(network): + return False + parsed_addr = ipaddress.ip_address(addr) + parsed_net = ipaddress.ip_network(network) + return parsed_addr in parsed_net diff --git a/ansible_collections/purestorage/fusion/plugins/module_utils/operations.py b/ansible_collections/purestorage/fusion/plugins/module_utils/operations.py new file mode 100644 index 000000000..dc80aefe3 --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/module_utils/operations.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +# (c) 2023, Jan Kodera (jkodera@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import time +import math + +try: + import fusion as purefusion + from urllib3.exceptions import HTTPError +except ImportError: + pass + +from ansible_collections.purestorage.fusion.plugins.module_utils.errors import ( + OperationException, +) + + +def await_operation(fusion, operation, fail_playbook_if_operation_fails=True): + """ + Waits for given operation to finish. + Throws an exception by default if the operation fails. + """ + op_api = purefusion.OperationsApi(fusion) + operation_get = None + while True: + try: + operation_get = op_api.get_operation(operation.id) + if operation_get.status == "Succeeded": + return operation_get + if operation_get.status == "Failed": + if fail_playbook_if_operation_fails: + raise OperationException(operation_get) + return operation_get + except HTTPError as err: + raise OperationException(operation, http_error=err) + time.sleep(int(math.ceil(operation_get.retry_in / 1000))) diff --git a/ansible_collections/purestorage/fusion/plugins/module_utils/parsing.py b/ansible_collections/purestorage/fusion/plugins/module_utils/parsing.py new file mode 100644 index 000000000..a2cd75245 --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/module_utils/parsing.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- + +# (c) 2023, Jan Kodera (jkodera@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +import re + +__metaclass__ = type + +METRIC_SUFFIXES = ["K", "M", "G", "T", "P"] + +duration_pattern = re.compile( + r"^((?P<Y>[1-9]\d*)Y)?((?P<W>[1-9]\d*)W)?((?P<D>[1-9]\d*)D)?(((?P<H>[1-9]\d*)H)?((?P<M>[1-9]\d*)M)?)?$" +) +duration_transformation = { + "Y": 365 * 24 * 60, + "W": 7 * 24 * 60, + "D": 24 * 60, + "H": 60, + "M": 1, +} + + +def parse_number_with_metric_suffix(module, number, factor=1024): + """Given a human-readable string (e.g. 2G, 30M, 400), + return the resolved integer. + Will call `module.fail_json()` for invalid inputs. + """ + try: + stripped_num = number.strip() + if stripped_num[-1].isdigit(): + return int(stripped_num) + # has unit prefix + result = float(stripped_num[:-1]) + suffix = stripped_num[-1].upper() + factor_count = METRIC_SUFFIXES.index(suffix) + 1 + for _i in range(0, factor_count): + result = result * float(factor) + return int(result) + except Exception: + module.fail_json( + msg="'{0}' is not a valid number, use '400', '1K', '2M', ...".format(number) + ) + return 0 + + +def parse_duration(period): + if period.isdigit(): + return int(period) + + match = duration_pattern.match(period.upper()) + if not match or period == "": + raise ValueError("Invalid format") + + durations = { + "Y": int(match.group("Y")) if match.group("Y") else 0, + "W": int(match.group("W")) if match.group("W") else 0, + "D": int(match.group("D")) if match.group("D") else 0, + "H": int(match.group("H")) if match.group("H") else 0, + "M": int(match.group("M")) if match.group("M") else 0, + } + return sum(value * duration_transformation[key] for key, value in durations.items()) + + +def parse_minutes(module, period): + try: + return parse_duration(period) + except ValueError: + module.fail_json( + msg=( + "'{0}' is not a valid time period, use combination of data units (Y,W,D,H,M)" + "e.g. 4W3D5H, 5D8H5M, 3D, 5W, 1Y5W..." + ).format(period) + ) diff --git a/ansible_collections/purestorage/fusion/plugins/module_utils/prerequisites.py b/ansible_collections/purestorage/fusion/plugins/module_utils/prerequisites.py new file mode 100644 index 000000000..a4edaf341 --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/module_utils/prerequisites.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- + +# (c) 2023, Jan Kodera (jkodera@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import re +import importlib +import importlib.metadata + +# This file exists because Ansible currently cannot declare dependencies on Python modules. +# see https://github.com/ansible/ansible/issues/62733 for more info about lack of req support + +############################# + +# 'module_name, package_name, version_requirements' triplets +DEPENDENCIES = [ + ("fusion", "purefusion", ">=1.0.11,<2.0"), + ("urllib3", "urllib3", None), +] + +############################# + + +def _parse_version(val): + """ + Parse a package version. + Takes in either MAJOR.MINOR or MAJOR.MINOR.PATCH form. PATCH + can have additional suffixes, e.g. '-prerelease', 'a1', ... + + :param val: a string representation of the package version + :returns: tuple of ints (MAJOR, MINOR, PATCH) or None if not parsed + """ + # regexes for this were really ugly + try: + parts = val.split(".") + if len(parts) < 2 or len(parts) > 3: + return None + major = int(parts[0]) + minor = int(parts[1]) + if len(parts) > 2: + patch = re.match(r"^\d+", parts[2]) + patch = int(patch.group(0)) + else: + patch = None + return (major, minor, patch) + except Exception: + return None + + +# returns list of tuples [(COMPARATOR, (MAJOR, MINOR, PATCH)),...] +def _parse_version_requirements(val): + """ + Parse package requirements. + + :param val: a string in the form ">=1.0.11,<2.0" + :returns: list of tuples in the form [(">=", (1, 0, 11)), ("<", (2, 0, None))] or None if not parsed + """ + reqs = [] + try: + parts = val.split(",") + for part in parts: + match = re.match(r"\s*(>=|<=|==|=|<|>|!=)\s*([^\s]+)", part) + op = match.group(1) + ver = match.group(2) + ver_tuple = _parse_version(ver) + if not ver_tuple: + raise ValueError("invalid version {0}".format(ver)) + reqs.append((op, ver_tuple)) + return reqs + except Exception as e: + raise ValueError("invalid version requirement '{0}' {1}".format(val, e)) + + +def _compare_version(op, ver, req): + """ + Compare two versions. + + :param op: a string, one of comparators ">=", "<=", "=", "==", ">" or "<" + :param ver: version tuple in _parse_version() return form + :param req: version tuple in _parse_version() return form + :returns: True if ver 'op' req; False otherwise + """ + + def _cmp(a, b): + return (a > b) - (a < b) + + major = _cmp(ver[0], req[0]) + minor = _cmp(ver[1], req[1]) + patch = None + if req[2] is not None: + patch = _cmp(ver[2] or 0, req[2]) + result = { + ">=": major > 0 or (major == 0 and (minor > 0 or patch is None or patch >= 0)), + "<=": major < 0 or (major == 0 and (minor < 0 or patch is None or patch <= 0)), + ">": major > 0 + or (major == 0 and (minor > 0 or patch is not None and patch > 0)), + "<": major < 0 + or (major == 0 and (minor < 0 or patch is not None and patch < 0)), + "=": major == 0 and minor == 0 and (patch is None or patch == 0), + "==": major == 0 and minor == 0 and (patch is None or patch == 0), + "!=": major != 0 or minor != 0 or (patch is not None and patch != 0), + }.get(op) + return result + + +def _version_satisfied(version, requirements): + """ + Checks whether version matches given version requirements. + + :param version: a string, in input form to _parse_version() + :param requirements: as string, in input form to _parse_version_requirements() + :returns: True if 'version' matches 'requirements'; False otherwise + """ + + version = _parse_version(version) + requirements = _parse_version_requirements(requirements) + for req in requirements: + if not _compare_version(req[0], version, req[1]): + return False + return True + + +# poor helper to work around the fact Ansible is unable to manage python dependencies +def _check_import(ansible_module, module, package=None, version_requirements=None): + """ + Tries to import a module and optionally validates its package version. + Calls AnsibleModule.fail_json() if not satisfied. + + :param ansible_module: an AnsibleModule instance + :param module: a string with module name to try to import + :param package: a string, package to check version for; must be specified with 'version_requirements' + :param version_requirements: a string, version requirements for 'package' + """ + try: + mod = importlib.import_module(module) + except ImportError: + ansible_module.fail_json( + msg="Error: Python package '{0}' required and missing".format(module) + ) + + if package and version_requirements: + # silently ignore version checks and hope for the best if we can't fetch + # the package version since we can't know how the user installs packages + try: + version = importlib.metadata.version(package) + if version and not _version_satisfied(version, version_requirements): + ansible_module.fail_json( + msg="Error: Python package '{0}' version '{1}' does not satisfy requirements '{2}'".format( + module, version, version_requirements + ) + ) + except Exception: + pass # ignore package loads + + +def check_dependencies(ansible_module): + for module, package, version_requirements in DEPENDENCIES: + _check_import(ansible_module, module, package, version_requirements) diff --git a/ansible_collections/purestorage/fusion/plugins/module_utils/startup.py b/ansible_collections/purestorage/fusion/plugins/module_utils/startup.py new file mode 100644 index 000000000..55d7f11a2 --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/module_utils/startup.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- + +# (c) 2023, Jan Kodera (jkodera@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from ansible_collections.purestorage.fusion.plugins.module_utils.errors import ( + install_fusion_exception_hook, +) + +from ansible_collections.purestorage.fusion.plugins.module_utils.prerequisites import ( + check_dependencies, +) + +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + get_fusion, +) + + +def setup_fusion(module): + check_dependencies(module) + install_fusion_exception_hook(module) + return get_fusion(module) diff --git a/ansible_collections/purestorage/fusion/plugins/modules/fusion_api_client.py b/ansible_collections/purestorage/fusion/plugins/modules/fusion_api_client.py new file mode 100644 index 000000000..39860449d --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/modules/fusion_api_client.py @@ -0,0 +1,139 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2022, Simon Dodsley (simon@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: fusion_api_client +version_added: '1.0.0' +short_description: Manage API clients in Pure Storage Fusion +description: +- Create or delete an API Client in Pure Storage Fusion. +author: +- Pure Storage Ansible Team (@sdodsley) <pure-ansible-team@purestorage.com> +notes: +- Supports C(check mode). +options: + name: + description: + - The name of the client. + type: str + required: true + state: + description: + - Define whether the client should exist or not. + default: present + choices: [ present, absent ] + type: str + public_key: + description: + - The API clients PEM formatted (Base64 encoded) RSA public key. + - Include the C(—–BEGIN PUBLIC KEY—–) and C(—–END PUBLIC KEY—–) lines. + type: str + required: true +extends_documentation_fragment: +- purestorage.fusion.purestorage.fusion +""" + +EXAMPLES = r""" +- name: Create new API client foo + purestorage.fusion.fusion_api_client: + name: "foo client" + public_key: "{{lookup('file', 'public_pem_file') }}" + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" +""" + +RETURN = r""" +""" + +try: + import fusion as purefusion +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + fusion_argument_spec, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.startup import ( + setup_fusion, +) + + +def get_client_id(module, fusion): + """Get API Client ID, or None if not available""" + id_api_instance = purefusion.IdentityManagerApi(fusion) + try: + clients = id_api_instance.list_api_clients() + for client in clients: + if ( + client.public_key == module.params["public_key"] + and client.display_name == module.params["name"] + ): + return client.id + return None + except purefusion.rest.ApiException: + return None + + +def delete_client(module, fusion, client_id): + """Delete API Client""" + id_api_instance = purefusion.IdentityManagerApi(fusion) + + changed = True + if not module.check_mode: + id_api_instance.delete_api_client(api_client_id=client_id) + module.exit_json(changed=changed) + + +def create_client(module, fusion): + """Create API Client""" + + id_api_instance = purefusion.IdentityManagerApi(fusion) + + changed = True + if not module.check_mode: + client = purefusion.APIClientPost( + public_key=module.params["public_key"], + display_name=module.params["name"], + ) + id_api_instance.create_api_client(client) + + module.exit_json(changed=changed) + + +def main(): + """Main code""" + argument_spec = fusion_argument_spec() + argument_spec.update( + dict( + name=dict(type="str", required=True), + public_key=dict(type="str", required=True), + state=dict(type="str", default="present", choices=["present", "absent"]), + ) + ) + + module = AnsibleModule(argument_spec, supports_check_mode=True) + fusion = setup_fusion(module) + + state = module.params["state"] + client_id = get_client_id(module, fusion) + if client_id is None and state == "present": + create_client(module, fusion) + elif client_id is not None and state == "absent": + delete_client(module, fusion, client_id) + else: + module.exit_json(changed=False) + + module.exit_json(changed=False) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/purestorage/fusion/plugins/modules/fusion_array.py b/ansible_collections/purestorage/fusion/plugins/modules/fusion_array.py new file mode 100644 index 000000000..f7933eabe --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/modules/fusion_array.py @@ -0,0 +1,265 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2022, Simon Dodsley (simon@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: fusion_array +version_added: '1.0.0' +short_description: Manage arrays in Pure Storage Fusion +description: +- Create or delete an array in Pure Storage Fusion. +author: +- Pure Storage Ansible Team (@sdodsley) <pure-ansible-team@purestorage.com> +notes: +- Supports C(check mode). +options: + name: + description: + - The name of the array. + type: str + required: true + state: + description: + - Define whether the array should exist or not. + default: present + choices: [ present, absent ] + type: str + display_name: + description: + - The human name of the array. + - If not provided, defaults to I(name). + type: str + region: + description: + - The region the AZ is in. + type: str + required: true + availability_zone: + aliases: [ az ] + description: + - The availability zone the array is located in. + type: str + required: true + hardware_type: + description: + - Hardware type to which the storage class applies. + choices: [ flash-array-x, flash-array-c, flash-array-x-optane, flash-array-xl ] + type: str + host_name: + description: + - Management IP address of the array, or FQDN. + type: str + appliance_id: + description: + - Appliance ID of the array. + type: str + maintenance_mode: + description: + - "Switch the array into maintenance mode or back. + Array in maintenance mode can have placement groups migrated out but not in. + Intended use cases are for example safe decommissioning or to prevent use + of an array that has not yet been fully configured." + type: bool + unavailable_mode: + description: + - "Switch the array into unavailable mode or back. + Fusion tries to exclude unavailable arrays from virtually any operation it + can. This is to prevent stalling operations in case of e.g. a networking + failure. As of the moment arrays have to be marked unavailable manually." + type: bool +extends_documentation_fragment: +- purestorage.fusion.purestorage.fusion +""" + +EXAMPLES = r""" +- name: Create new array foo + purestorage.fusion.fusion_array: + name: foo + az: zone_1 + region: region1 + hardware_type: flash-array-x + host_name: foo_array + display_name: "foo array" + appliance_id: 1227571-198887878-35016350232000707 + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" +""" + +RETURN = r""" +""" + +try: + import fusion as purefusion +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + fusion_argument_spec, +) + +from ansible_collections.purestorage.fusion.plugins.module_utils import getters +from ansible_collections.purestorage.fusion.plugins.module_utils.operations import ( + await_operation, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.startup import ( + setup_fusion, +) + + +def get_array(module, fusion): + """Return Array or None""" + return getters.get_array(module, fusion, array_name=module.params["name"]) + + +def create_array(module, fusion): + """Create Array""" + + array_api_instance = purefusion.ArraysApi(fusion) + + if not module.check_mode: + if not module.params["display_name"]: + display_name = module.params["name"] + else: + display_name = module.params["display_name"] + array = purefusion.ArrayPost( + hardware_type=module.params["hardware_type"], + display_name=display_name, + host_name=module.params["host_name"], + name=module.params["name"], + appliance_id=module.params["appliance_id"], + ) + res = array_api_instance.create_array( + array, + availability_zone_name=module.params["availability_zone"], + region_name=module.params["region"], + ) + await_operation(fusion, res) + return True + + +def update_array(module, fusion): + """Update Array""" + array = get_array(module, fusion) + patches = [] + if ( + module.params["display_name"] + and module.params["display_name"] != array.display_name + ): + patch = purefusion.ArrayPatch( + display_name=purefusion.NullableString(module.params["display_name"]), + ) + patches.append(patch) + + if module.params["host_name"] and module.params["host_name"] != array.host_name: + patch = purefusion.ArrayPatch( + host_name=purefusion.NullableString(module.params["host_name"]) + ) + patches.append(patch) + + if ( + module.params["maintenance_mode"] is not None + and module.params["maintenance_mode"] != array.maintenance_mode + ): + patch = purefusion.ArrayPatch( + maintenance_mode=purefusion.NullableBoolean( + module.params["maintenance_mode"] + ) + ) + patches.append(patch) + if ( + module.params["unavailable_mode"] is not None + and module.params["unavailable_mode"] != array.unavailable_mode + ): + patch = purefusion.ArrayPatch( + unavailable_mode=purefusion.NullableBoolean( + module.params["unavailable_mode"] + ) + ) + patches.append(patch) + + if not module.check_mode: + array_api_instance = purefusion.ArraysApi(fusion) + for patch in patches: + op = array_api_instance.update_array( + patch, + availability_zone_name=module.params["availability_zone"], + region_name=module.params["region"], + array_name=module.params["name"], + ) + await_operation(fusion, op) + + changed = len(patches) != 0 + return changed + + +def delete_array(module, fusion): + """Delete Array - not currently available""" + array_api_instance = purefusion.ArraysApi(fusion) + if not module.check_mode: + res = array_api_instance.delete_array( + region_name=module.params["region"], + availability_zone_name=module.params["availability_zone"], + array_name=module.params["name"], + ) + await_operation(fusion, res) + return True + + +def main(): + """Main code""" + argument_spec = fusion_argument_spec() + argument_spec.update( + dict( + name=dict(type="str", required=True), + availability_zone=dict(type="str", required=True, aliases=["az"]), + display_name=dict(type="str"), + region=dict(type="str", required=True), + appliance_id=dict(type="str"), + host_name=dict(type="str"), + hardware_type=dict( + type="str", + choices=[ + "flash-array-x", + "flash-array-c", + "flash-array-x-optane", + "flash-array-xl", + ], + ), + maintenance_mode=dict(type="bool"), + unavailable_mode=dict(type="bool"), + state=dict(type="str", default="present", choices=["present", "absent"]), + ) + ) + + module = AnsibleModule(argument_spec, supports_check_mode=True) + fusion = setup_fusion(module) + + state = module.params["state"] + array = get_array(module, fusion) + + changed = False + if not array and state == "present": + module.fail_on_missing_params(["hardware_type", "host_name", "appliance_id"]) + changed = create_array(module, fusion) | update_array( + module, fusion + ) # update is run to set properties which cannot be set on creation and instead use defaults + elif array and state == "present": + changed = changed | update_array(module, fusion) + elif array and state == "absent": + changed = changed | delete_array(module, fusion) + else: + module.exit_json(changed=False) + + module.exit_json(changed=changed) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/purestorage/fusion/plugins/modules/fusion_az.py b/ansible_collections/purestorage/fusion/plugins/modules/fusion_az.py new file mode 100644 index 000000000..02647d397 --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/modules/fusion_az.py @@ -0,0 +1,162 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2022, Simon Dodsley (simon@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: fusion_az +version_added: '1.0.0' +short_description: Create Availability Zones in Pure Storage Fusion +description: +- Manage an Availability Zone in Pure Storage Fusion. +author: +- Pure Storage Ansible Team (@sdodsley) <pure-ansible-team@purestorage.com> +notes: +- Supports C(check mode). +options: + name: + description: + - The name of the Availability Zone. + type: str + required: true + state: + description: + - Define whether the Availability Zone should exist or not. + default: present + choices: [ present, absent ] + type: str + display_name: + description: + - The human name of the Availability Zone. + - If not provided, defaults to I(name). + type: str + region: + description: + - Region within which the AZ is created. + type: str + required: true +extends_documentation_fragment: +- purestorage.fusion.purestorage.fusion +""" + +EXAMPLES = r""" +- name: Create new AZ foo + purestorage.fusion.fusion_az: + name: foo + display_name: "foo AZ" + region: region1 + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + +- name: Delete AZ foo + purestorage.fusion.fusion_az: + name: foo + state: absent + region: region1 + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" +""" + +RETURN = r""" +""" + +try: + import fusion as purefusion +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + fusion_argument_spec, +) + +from ansible_collections.purestorage.fusion.plugins.module_utils import getters +from ansible_collections.purestorage.fusion.plugins.module_utils.operations import ( + await_operation, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.startup import ( + setup_fusion, +) + + +def get_az(module, fusion): + """Get Availability Zone or None""" + return getters.get_az(module, fusion, availability_zone_name=module.params["name"]) + + +def delete_az(module, fusion): + """Delete Availability Zone""" + + az_api_instance = purefusion.AvailabilityZonesApi(fusion) + + changed = True + if not module.check_mode: + op = az_api_instance.delete_availability_zone( + region_name=module.params["region"], + availability_zone_name=module.params["name"], + ) + await_operation(fusion, op) + + module.exit_json(changed=changed) + + +def create_az(module, fusion): + """Create Availability Zone""" + + az_api_instance = purefusion.AvailabilityZonesApi(fusion) + + changed = True + if not module.check_mode: + if not module.params["display_name"]: + display_name = module.params["name"] + else: + display_name = module.params["display_name"] + + azone = purefusion.AvailabilityZonePost( + name=module.params["name"], + display_name=display_name, + ) + op = az_api_instance.create_availability_zone( + azone, region_name=module.params["region"] + ) + await_operation(fusion, op) + + module.exit_json(changed=changed) + + +def main(): + """Main code""" + argument_spec = fusion_argument_spec() + argument_spec.update( + dict( + name=dict(type="str", required=True), + display_name=dict(type="str"), + region=dict(type="str", required=True), + state=dict(type="str", default="present", choices=["present", "absent"]), + ) + ) + + module = AnsibleModule(argument_spec, supports_check_mode=True) + fusion = setup_fusion(module) + + state = module.params["state"] + azone = get_az(module, fusion) + + if not azone and state == "present": + create_az(module, fusion) + elif azone and state == "absent": + delete_az(module, fusion) + else: + module.exit_json(changed=False) + + module.exit_json(changed=False) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/purestorage/fusion/plugins/modules/fusion_hap.py b/ansible_collections/purestorage/fusion/plugins/modules/fusion_hap.py new file mode 100644 index 000000000..3f45ea2dd --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/modules/fusion_hap.py @@ -0,0 +1,312 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2022, Simon Dodsley (simon@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: fusion_hap +version_added: '1.0.0' +short_description: Manage host access policies in Pure Storage Fusion +description: +- Create or delete host access policies in Pure Storage Fusion. +author: +- Pure Storage Ansible Team (@sdodsley) <pure-ansible-team@purestorage.com> +notes: +- Supports C(check mode). +- Setting passwords is not an idempotent action. +- Only iSCSI transport is currently supported. +- iSCSI CHAP is not yet supported. +options: + name: + description: + - The name of the host access policy. + type: str + required: true + display_name: + description: + - The human name of the host access policy. + type: str + state: + description: + - Define whether the host access policy should exist or not. + - When removing host access policy all connected volumes must + have been previously disconnected. + type: str + default: present + choices: [ absent, present ] + wwns: + type: list + elements: str + description: + - CURRENTLY NOT SUPPORTED. + - List of wwns for the host access policy. + iqn: + type: str + description: + - IQN for the host access policy. + nqn: + type: str + description: + - CURRENTLY NOT SUPPORTED. + - NQN for the host access policy. + personality: + type: str + description: + - Define which operating system the host is. + default: linux + choices: ['linux', 'windows', 'hpux', 'vms', 'aix', 'esxi', 'solaris', 'hitachi-vsp', 'oracle-vm-server'] + target_user: + type: str + description: + - CURRENTLY NOT SUPPORTED. + - Sets the target user name for CHAP authentication. + - Required with I(target_password). + - To clear the username/password pair use C(clear) as the password. + target_password: + type: str + description: + - CURRENTLY NOT SUPPORTED. + - Sets the target password for CHAP authentication. + - Password length between 12 and 255 characters. + - To clear the username/password pair use C(clear) as the password. + host_user: + type: str + description: + - CURRENTLY NOT SUPPORTED. + - Sets the host user name for CHAP authentication. + - Required with I(host_password). + - To clear the username/password pair use C(clear) as the password. + host_password: + type: str + description: + - CURRENTLY NOT SUPPORTED. + - Sets the host password for CHAP authentication. + - Password length between 12 and 255 characters. + - To clear the username/password pair use C(clear) as the password. +extends_documentation_fragment: +- purestorage.fusion.purestorage.fusion +""" + +EXAMPLES = r""" +- name: Create new AIX host access policy + purestorage.fusion.fusion_hap: + name: foo + personality: aix + iqn: "iqn.2005-03.com.RedHat:linux-host1" + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + +- name: Delete host access policy + purestorage.fusion.fusion_hap: + name: foo + state: absent + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" +""" + +RETURN = r""" +""" + +try: + import fusion as purefusion +except ImportError: + pass + +import re +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + fusion_argument_spec, +) + +from ansible_collections.purestorage.fusion.plugins.module_utils.startup import ( + setup_fusion, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.operations import ( + await_operation, +) + + +def _check_iqn(module, fusion): + hap_api_instance = purefusion.HostAccessPoliciesApi(fusion) + hosts = hap_api_instance.list_host_access_policies().items + for host in hosts: + if host.iqn == module.params["iqn"] and host.name != module.params["name"]: + module.fail_json( + msg="Supplied IQN {0} already used by host access policy {1}".format( + module.params["iqn"], host.name + ) + ) + + +def get_host(module, fusion): + """Return host or None""" + hap_api_instance = purefusion.HostAccessPoliciesApi(fusion) + try: + return hap_api_instance.get_host_access_policy( + host_access_policy_name=module.params["name"] + ) + except purefusion.rest.ApiException: + return None + + +def create_hap(module, fusion): + """Create a new host access policy""" + hap_api_instance = purefusion.HostAccessPoliciesApi(fusion) + changed = True + if not module.check_mode: + display_name = module.params["display_name"] or module.params["name"] + + op = hap_api_instance.create_host_access_policy( + purefusion.HostAccessPoliciesPost( + iqn=module.params["iqn"], + personality=module.params["personality"], + name=module.params["name"], + display_name=display_name, + ) + ) + await_operation(fusion, op) + module.exit_json(changed=changed) + + +def delete_hap(module, fusion): + """Delete a Host Access Policy""" + hap_api_instance = purefusion.HostAccessPoliciesApi(fusion) + changed = True + if not module.check_mode: + op = hap_api_instance.delete_host_access_policy( + host_access_policy_name=module.params["name"] + ) + await_operation(fusion, op) + module.exit_json(changed=changed) + + +def main(): + argument_spec = fusion_argument_spec() + argument_spec.update( + dict( + name=dict(type="str", required=True), + state=dict(type="str", default="present", choices=["absent", "present"]), + nqn=dict( + type="str", + removed_in_version="2.0.0", + removed_from_collection="purestorage.fusion", + ), + iqn=dict(type="str"), + wwns=dict( + type="list", + elements="str", + removed_in_version="2.0.0", + removed_from_collection="purestorage.fusion", + ), + host_password=dict( + type="str", + removed_in_version="2.0.0", + removed_from_collection="purestorage.fusion", + no_log=True, + ), + host_user=dict( + type="str", + removed_in_version="2.0.0", + removed_from_collection="purestorage.fusion", + ), + target_password=dict( + type="str", + removed_in_version="2.0.0", + removed_from_collection="purestorage.fusion", + no_log=True, + ), + target_user=dict( + type="str", + removed_in_version="2.0.0", + removed_from_collection="purestorage.fusion", + ), + display_name=dict(type="str"), + personality=dict( + type="str", + default="linux", + choices=[ + "linux", + "windows", + "hpux", + "vms", + "aix", + "esxi", + "solaris", + "hitachi-vsp", + "oracle-vm-server", + ], + ), + ) + ) + + required_if = [["state", "present", ["personality", "iqn"]]] + + module = AnsibleModule( + argument_spec, + supports_check_mode=True, + required_if=required_if, + ) + fusion = setup_fusion(module) + + if module.params["nqn"]: + module.warn( + "`nqn` parameter is deprecated and will be removed in version 2.0.0" + ) + if module.params["wwns"]: + module.warn( + "`wwns` parameter is deprecated and will be removed in version 2.0.0" + ) + if module.params["host_password"]: + module.warn( + "`host_password` parameter is deprecated and will be removed in version 2.0.0" + ) + if module.params["host_user"]: + module.warn( + "`host_user` parameter is deprecated and will be removed in version 2.0.0" + ) + if module.params["target_password"]: + module.warn( + "`target_password` parameter is deprecated and will be removed in version 2.0.0" + ) + if module.params["target_user"]: + module.warn( + "`target_user` parameter is deprecated and will be removed in version 2.0.0" + ) + + hap_pattern = re.compile("^[a-zA-Z0-9]([a-zA-Z0-9-_]{0,61}[a-zA-Z0-9])?$") + iqn_pattern = re.compile( + r"^iqn\.\d{4}-\d{2}((?<!-)\.(?!-)[a-zA-Z0-9\-]+){1,63}(?<!-)(?<!\.)(:(?!:)[^,\s'\"]+)?$" + ) + + if not hap_pattern.match(module.params["name"]): + module.fail_json( + msg="Host Access Policy {0} does not conform to naming convention".format( + module.params["name"] + ) + ) + + if module.params["iqn"] is not None and not iqn_pattern.match(module.params["iqn"]): + module.fail_json( + msg="IQN {0} is not a valid iSCSI IQN".format(module.params["name"]) + ) + + state = module.params["state"] + host = get_host(module, fusion) + _check_iqn(module, fusion) + + if host is None and state == "present": + create_hap(module, fusion) + elif host is not None and state == "absent": + delete_hap(module, fusion) + + module.exit_json(changed=False) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/purestorage/fusion/plugins/modules/fusion_hw.py b/ansible_collections/purestorage/fusion/plugins/modules/fusion_hw.py new file mode 100644 index 000000000..31d313e9d --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/modules/fusion_hw.py @@ -0,0 +1,88 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2022, Simon Dodsley (simon@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: fusion_hw +version_added: '1.0.0' +deprecated: + removed_at_date: "2023-08-09" + why: Hardware type cannot be modified in Pure Storage Fusion + alternative: there's no alternative as this functionality has never worked before +short_description: Create hardware types in Pure Storage Fusion +description: +- Create a hardware type in Pure Storage Fusion. +author: +- Pure Storage Ansible Team (@sdodsley) <pure-ansible-team@purestorage.com> +notes: +- Supports C(check mode). +options: + name: + description: + - The name of the hardware type. + type: str + state: + description: + - Define whether the hardware type should exist or not. + - Currently there is no mechanism to delete a hardware type. + default: present + choices: [ present ] + type: str + display_name: + description: + - The human name of the hardware type. + - If not provided, defaults to I(name). + type: str + media_type: + description: + - Volume size limit in M, G, T or P units. + type: str + array_type: + description: + - The array type for the hardware type. + choices: [ FA//X, FA//C ] + type: str +extends_documentation_fragment: +- purestorage.fusion.purestorage.fusion +""" + +# this module does nothing, thus no example is provided +EXAMPLES = r""" +""" + +RETURN = r""" +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + fusion_argument_spec, +) + + +def main(): + """Main code""" + argument_spec = fusion_argument_spec() + argument_spec.update( + dict( + name=dict(type="str"), + display_name=dict(type="str"), + array_type=dict(type="str", choices=["FA//X", "FA//C"]), + media_type=dict(type="str"), + state=dict(type="str", default="present", choices=["present"]), + ) + ) + + module = AnsibleModule(argument_spec, supports_check_mode=True) + + module.exit_json(changed=False) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/purestorage/fusion/plugins/modules/fusion_info.py b/ansible_collections/purestorage/fusion/plugins/modules/fusion_info.py new file mode 100644 index 000000000..be019d3d2 --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/modules/fusion_info.py @@ -0,0 +1,1130 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2021, Simon Dodsley (simon@purestorage.com), Andrej Pajtas (apajtas@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: fusion_info +version_added: '1.0.0' +short_description: Collect information from Pure Fusion +description: + - Collect information from a Pure Fusion environment. + - By default, the module will collect basic + information including counts for arrays, availability_zones, volumes, snapshots + . Fleet capacity and data reduction rates are also provided. + - Additional information can be collected based on the configured set of arguments. +author: + - Pure Storage ansible Team (@sdodsley) <pure-ansible-team@purestorage.com> +notes: +- Supports C(check mode). +options: + gather_subset: + description: + - When supplied, this argument will define the information to be collected. + Possible values for this include all, minimum, roles, users, arrays, hardware_types, + volumes, host_access_policies, storage_classes, protection_policies, placement_groups, + network_interfaces, availability_zones, network_interface_groups, storage_endpoints, + snapshots, regions, storage_services, tenants, tenant_spaces, network_interface_groups and api_clients. + type: list + elements: str + required: false + default: minimum +extends_documentation_fragment: + - purestorage.fusion.purestorage.fusion +""" + +EXAMPLES = r""" +- name: Collect default set of information + purestorage.fusion.fusion_info: + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + register: fusion_info + +- name: Show default information + ansible.builtin.debug: + msg: "{{ fusion_info['fusion_info']['default'] }}" + +- name: Collect all information + purestorage.fusion.fusion_info: + gather_subset: + - all + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + +- name: Show all information + ansible.builtin.debug: + msg: "{{ fusion_info['fusion_info'] }}" +""" + +RETURN = r""" +fusion_info: + description: Returns the information collected from Fusion + returned: always + type: dict +""" + +try: + import fusion as purefusion +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + fusion_argument_spec, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.startup import ( + setup_fusion, +) +import time +import http + + +def _convert_microseconds(micros): + seconds = (micros / 1000) % 60 + minutes = (micros / (1000 * 60)) % 60 + hours = (micros / (1000 * 60 * 60)) % 24 + return seconds, minutes, hours + + +def _api_permission_denied_handler(name): + """Return decorator which catches #403 errors""" + + def inner(func): + def wrapper(module, fusion, *args, **kwargs): + try: + return func(module, fusion, *args, **kwargs) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + module.warn(f"Cannot get [{name} dict], reason: Permission denied") + return None + else: + # other exceptions will be handled by our exception hook + raise exc + + return wrapper + + return inner + + +def generate_default_dict(module, fusion): + def warning_api_exception(name): + module.warn(f"Cannot get {name} in [default dict], reason: Permission denied") + + def warning_argument_none(name, requirement): + module.warn( + f"Cannot get {name} in [default dict], reason: Required argument `{requirement}` not available." + ) + + # All values are independent on each other - if getting one value fails, we will show warning and continue. + # That's also the reason why there's so many nested for loops repeating all over again. + version = None + users_num = None + protection_policies_num = None + host_access_policies_num = None + hardware_types_num = None + storage_services = None + storage_services_num = None + tenants = None + tenants_num = None + regions = None + regions_num = None + roles = None + roles_num = None + storage_classes_num = None + role_assignments_num = None + tenant_spaces_num = None + volumes_num = None + placement_groups_num = None + snapshots_num = None + availability_zones_num = None + arrays_num = None + network_interfaces_num = None + network_interface_groups_num = None + storage_endpoints_num = None + + try: + version = purefusion.DefaultApi(fusion).get_version().version + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("API version") + else: + # other exceptions will be handled by our exception hook + raise exc + + try: + users_num = len(purefusion.IdentityManagerApi(fusion).list_users()) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("Users") + else: + # other exceptions will be handled by our exception hook + raise exc + + try: + protection_policies_num = len( + purefusion.ProtectionPoliciesApi(fusion).list_protection_policies().items + ) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("Protection Policies") + else: + # other exceptions will be handled by our exception hook + raise exc + + try: + host_access_policies_num = len( + purefusion.HostAccessPoliciesApi(fusion).list_host_access_policies().items + ) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("Host Access Policies") + else: + # other exceptions will be handled by our exception hook + raise exc + + try: + hardware_types_num = len( + purefusion.HardwareTypesApi(fusion).list_hardware_types().items + ) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("Hardware Types") + else: + # other exceptions will be handled by our exception hook + raise exc + + try: + storage_services = purefusion.StorageServicesApi(fusion).list_storage_services() + storage_services_num = len(storage_services.items) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("Storage Services") + else: + # other exceptions will be handled by our exception hook + raise exc + + try: + tenants = purefusion.TenantsApi(fusion).list_tenants() + tenants_num = len(tenants.items) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("Tenants") + else: + # other exceptions will be handled by our exception hook + raise exc + + try: + regions = purefusion.RegionsApi(fusion).list_regions() + regions_num = len(regions.items) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("Regions") + else: + # other exceptions will be handled by our exception hook + raise exc + + try: + roles = purefusion.RolesApi(fusion).list_roles() + roles_num = len(roles) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("Roles") + else: + # other exceptions will be handled by our exception hook + raise exc + + if storage_services is not None: + try: + storage_class_api_instance = purefusion.StorageClassesApi(fusion) + storage_classes_num = sum( + len( + storage_class_api_instance.list_storage_classes( + storage_service_name=storage_service.name + ).items + ) + for storage_service in storage_services.items + ) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("Storage Classes") + else: + # other exceptions will be handled by our exception hook + raise exc + else: + warning_argument_none("Storage Classes", "storage_services") + + if roles is not None: + try: + role_assign_api_instance = purefusion.RoleAssignmentsApi(fusion) + role_assignments_num = sum( + len(role_assign_api_instance.list_role_assignments(role_name=role.name)) + for role in roles + ) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("Role Assignments") + else: + # other exceptions will be handled by our exception hook + raise exc + else: + warning_argument_none("Role Assignments", "roles") + + if tenants is not None: + tenantspace_api_instance = purefusion.TenantSpacesApi(fusion) + + try: + tenant_spaces_num = sum( + len( + tenantspace_api_instance.list_tenant_spaces( + tenant_name=tenant.name + ).items + ) + for tenant in tenants.items + ) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("Tenant Spaces") + else: + # other exceptions will be handled by our exception hook + raise exc + + try: + vol_api_instance = purefusion.VolumesApi(fusion) + volumes_num = sum( + len( + vol_api_instance.list_volumes( + tenant_name=tenant.name, + tenant_space_name=tenant_space.name, + ).items + ) + for tenant in tenants.items + for tenant_space in tenantspace_api_instance.list_tenant_spaces( + tenant_name=tenant.name + ).items + ) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("Volumes") + else: + # other exceptions will be handled by our exception hook + raise exc + + try: + plgrp_api_instance = purefusion.PlacementGroupsApi(fusion) + placement_groups_num = sum( + len( + plgrp_api_instance.list_placement_groups( + tenant_name=tenant.name, + tenant_space_name=tenant_space.name, + ).items + ) + for tenant in tenants.items + for tenant_space in tenantspace_api_instance.list_tenant_spaces( + tenant_name=tenant.name + ).items + ) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("Placement Groups") + else: + # other exceptions will be handled by our exception hook + raise exc + + try: + snapshot_api_instance = purefusion.SnapshotsApi(fusion) + snapshots_num = sum( + len( + snapshot_api_instance.list_snapshots( + tenant_name=tenant.name, + tenant_space_name=tenant_space.name, + ).items + ) + for tenant in tenants.items + for tenant_space in tenantspace_api_instance.list_tenant_spaces( + tenant_name=tenant.name + ).items + ) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("Snapshots") + else: + # other exceptions will be handled by our exception hook + raise exc + else: + warning_argument_none("Tenant Spaces", "tenants") + warning_argument_none("Volumes", "tenants") + warning_argument_none("Placement Groups", "tenants") + warning_argument_none("Snapshots", "tenants") + + if regions is not None: + az_api_instance = purefusion.AvailabilityZonesApi(fusion) + + try: + availability_zones_num = sum( + len( + az_api_instance.list_availability_zones( + region_name=region.name + ).items + ) + for region in regions.items + ) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("Availability Zones") + else: + # other exceptions will be handled by our exception hook + raise exc + + try: + arrays_api_instance = purefusion.ArraysApi(fusion) + arrays_num = sum( + len( + arrays_api_instance.list_arrays( + availability_zone_name=availability_zone.name, + region_name=region.name, + ).items + ) + for region in regions.items + for availability_zone in az_api_instance.list_availability_zones( + region_name=region.name + ).items + ) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("Arrays") + else: + # other exceptions will be handled by our exception hook + raise exc + + try: + nig_api_instance = purefusion.NetworkInterfaceGroupsApi(fusion) + network_interface_groups_num = sum( + len( + nig_api_instance.list_network_interface_groups( + availability_zone_name=availability_zone.name, + region_name=region.name, + ).items + ) + for region in regions.items + for availability_zone in az_api_instance.list_availability_zones( + region_name=region.name + ).items + ) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("Network Interface Groups") + else: + # other exceptions will be handled by our exception hook + raise exc + + try: + send_api_instance = purefusion.StorageEndpointsApi(fusion) + storage_endpoints_num = sum( + len( + send_api_instance.list_storage_endpoints( + availability_zone_name=availability_zone.name, + region_name=region.name, + ).items + ) + for region in regions.items + for availability_zone in az_api_instance.list_availability_zones( + region_name=region.name + ).items + ) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("Storage Endpoints") + else: + # other exceptions will be handled by our exception hook + raise exc + + try: + nic_api_instance = purefusion.NetworkInterfacesApi(fusion) + network_interfaces_num = sum( + len( + nic_api_instance.list_network_interfaces( + availability_zone_name=availability_zone.name, + region_name=region.name, + array_name=array_detail.name, + ).items + ) + for region in regions.items + for availability_zone in az_api_instance.list_availability_zones( + region_name=region.name + ).items + for array_detail in arrays_api_instance.list_arrays( + availability_zone_name=availability_zone.name, + region_name=region.name, + ).items + ) + except purefusion.rest.ApiException as exc: + if exc.status == http.HTTPStatus.FORBIDDEN: + warning_api_exception("Network Interfaces") + else: + # other exceptions will be handled by our exception hook + raise exc + else: + warning_argument_none("Availability Zones", "regions") + warning_argument_none("Network Interfaces", "regions") + warning_argument_none("Network Interface Groups", "regions") + warning_argument_none("Storage Endpoints", "regions") + warning_argument_none("Arrays", "regions") + + return { + "version": version, + "users": users_num, + "protection_policies": protection_policies_num, + "host_access_policies": host_access_policies_num, + "hardware_types": hardware_types_num, + "storage_services": storage_services_num, + "tenants": tenants_num, + "regions": regions_num, + "storage_classes": storage_classes_num, + "roles": roles_num, + "role_assignments": role_assignments_num, + "tenant_spaces": tenant_spaces_num, + "volumes": volumes_num, + "placement_groups": placement_groups_num, + "snapshots": snapshots_num, + "availability_zones": availability_zones_num, + "arrays": arrays_num, + "network_interfaces": network_interfaces_num, + "network_interface_groups": network_interface_groups_num, + "storage_endpoints": storage_endpoints_num, + } + + +@_api_permission_denied_handler("network_interfaces") +def generate_nics_dict(module, fusion): + nics_info = {} + nic_api_instance = purefusion.NetworkInterfacesApi(fusion) + arrays_api_instance = purefusion.ArraysApi(fusion) + az_api_instance = purefusion.AvailabilityZonesApi(fusion) + regions_api_instance = purefusion.RegionsApi(fusion) + regions = regions_api_instance.list_regions() + for region in regions.items: + azs = az_api_instance.list_availability_zones(region_name=region.name) + for az in azs.items: + array_details = arrays_api_instance.list_arrays( + availability_zone_name=az.name, + region_name=region.name, + ) + for array_detail in array_details.items: + array_name = az.name + "/" + array_detail.name + nics_info[array_name] = {} + nics = nic_api_instance.list_network_interfaces( + availability_zone_name=az.name, + region_name=region.name, + array_name=array_detail.name, + ) + + for nic in nics.items: + nics_info[array_name][nic.name] = { + "enabled": nic.enabled, + "display_name": nic.display_name, + "interface_type": nic.interface_type, + "services": nic.services, + "max_speed": nic.max_speed, + "vlan": nic.eth.vlan, + "address": nic.eth.address, + "mac_address": nic.eth.mac_address, + "gateway": nic.eth.gateway, + "mtu": nic.eth.mtu, + "network_interface_group": nic.network_interface_group.name, + "availability_zone": nic.availability_zone.name, + } + return nics_info + + +@_api_permission_denied_handler("host_access_policies") +def generate_hap_dict(module, fusion): + hap_info = {} + api_instance = purefusion.HostAccessPoliciesApi(fusion) + hosts = api_instance.list_host_access_policies() + for host in hosts.items: + name = host.name + hap_info[name] = { + "personality": host.personality, + "display_name": host.display_name, + "iqn": host.iqn, + } + return hap_info + + +@_api_permission_denied_handler("arrays") +def generate_array_dict(module, fusion): + array_info = {} + array_api_instance = purefusion.ArraysApi(fusion) + az_api_instance = purefusion.AvailabilityZonesApi(fusion) + regions_api_instance = purefusion.RegionsApi(fusion) + regions = regions_api_instance.list_regions() + for region in regions.items: + azs = az_api_instance.list_availability_zones(region_name=region.name) + for az in azs.items: + arrays = array_api_instance.list_arrays( + availability_zone_name=az.name, + region_name=region.name, + ) + for array in arrays.items: + array_name = array.name + array_space = array_api_instance.get_array_space( + availability_zone_name=az.name, + array_name=array_name, + region_name=region.name, + ) + array_perf = array_api_instance.get_array_performance( + availability_zone_name=az.name, + array_name=array_name, + region_name=region.name, + ) + array_info[array_name] = { + "region": region.name, + "availability_zone": az.name, + "host_name": array.host_name, + "maintenance_mode": array.maintenance_mode, + "unavailable_mode": array.unavailable_mode, + "display_name": array.display_name, + "hardware_type": array.hardware_type.name, + "appliance_id": array.appliance_id, + "apartment_id": getattr(array, "apartment_id", None), + "space": { + "total_physical_space": array_space.total_physical_space, + }, + "performance": { + "read_bandwidth": array_perf.read_bandwidth, + "read_latency_us": array_perf.read_latency_us, + "reads_per_sec": array_perf.reads_per_sec, + "write_bandwidth": array_perf.write_bandwidth, + "write_latency_us": array_perf.write_latency_us, + "writes_per_sec": array_perf.writes_per_sec, + }, + } + return array_info + + +@_api_permission_denied_handler("placement_groups") +def generate_pg_dict(module, fusion): + pg_info = {} + tenant_api_instance = purefusion.TenantsApi(fusion) + tenantspace_api_instance = purefusion.TenantSpacesApi(fusion) + pg_api_instance = purefusion.PlacementGroupsApi(fusion) + tenants = tenant_api_instance.list_tenants() + for tenant in tenants.items: + tenant_spaces = tenantspace_api_instance.list_tenant_spaces( + tenant_name=tenant.name + ).items + for tenant_space in tenant_spaces: + groups = pg_api_instance.list_placement_groups( + tenant_name=tenant.name, + tenant_space_name=tenant_space.name, + ) + for group in groups.items: + group_name = tenant.name + "/" + tenant_space.name + "/" + group.name + pg_info[group_name] = { + "tenant": group.tenant.name, + "display_name": group.display_name, + "placement_engine": group.placement_engine, + "tenant_space": group.tenant_space.name, + "az": group.availability_zone.name, + "array": getattr(group.array, "name", None), + } + return pg_info + + +@_api_permission_denied_handler("tenant_spaces") +def generate_ts_dict(module, fusion): + ts_info = {} + tenant_api_instance = purefusion.TenantsApi(fusion) + tenantspace_api_instance = purefusion.TenantSpacesApi(fusion) + tenants = tenant_api_instance.list_tenants() + for tenant in tenants.items: + tenant_spaces = tenantspace_api_instance.list_tenant_spaces( + tenant_name=tenant.name + ).items + for tenant_space in tenant_spaces: + ts_name = tenant.name + "/" + tenant_space.name + ts_info[ts_name] = { + "tenant": tenant.name, + "display_name": tenant_space.display_name, + } + return ts_info + + +@_api_permission_denied_handler("protection_policies") +def generate_pp_dict(module, fusion): + pp_info = {} + api_instance = purefusion.ProtectionPoliciesApi(fusion) + policies = api_instance.list_protection_policies() + for policy in policies.items: + policy_name = policy.name + pp_info[policy_name] = { + "objectives": policy.objectives, + } + return pp_info + + +@_api_permission_denied_handler("tenants") +def generate_tenant_dict(module, fusion): + tenants_api_instance = purefusion.TenantsApi(fusion) + return { + tenant.name: { + "display_name": tenant.display_name, + } + for tenant in tenants_api_instance.list_tenants().items + } + + +@_api_permission_denied_handler("regions") +def generate_regions_dict(module, fusion): + regions_api_instance = purefusion.RegionsApi(fusion) + return { + region.name: { + "display_name": region.display_name, + } + for region in regions_api_instance.list_regions().items + } + + +@_api_permission_denied_handler("availability_zones") +def generate_zones_dict(module, fusion): + zones_info = {} + az_api_instance = purefusion.AvailabilityZonesApi(fusion) + regions_api_instance = purefusion.RegionsApi(fusion) + regions = regions_api_instance.list_regions() + for region in regions.items: + zones = az_api_instance.list_availability_zones(region_name=region.name) + for zone in zones.items: + az_name = zone.name + zones_info[az_name] = { + "display_name": zone.display_name, + "region": zone.region.name, + } + return zones_info + + +@_api_permission_denied_handler("role_assignments") +def generate_ras_dict(module, fusion): + ras_info = {} + ras_api_instance = purefusion.RoleAssignmentsApi(fusion) + role_api_instance = purefusion.RolesApi(fusion) + roles = role_api_instance.list_roles() + for role in roles: + ras = ras_api_instance.list_role_assignments(role_name=role.name) + for assignment in ras: + name = assignment.name + ras_info[name] = { + "display_name": assignment.display_name, + "role": assignment.role.name, + "scope": assignment.scope.name, + } + return ras_info + + +@_api_permission_denied_handler("roles") +def generate_roles_dict(module, fusion): + roles_info = {} + api_instance = purefusion.RolesApi(fusion) + roles = api_instance.list_roles() + for role in roles: + name = role.name + roles_info[name] = { + "display_name": role.display_name, + "scopes": role.assignable_scopes, + } + return roles_info + + +@_api_permission_denied_handler("api_clients") +def generate_api_client_dict(module, fusion): + client_info = {} + api_instance = purefusion.IdentityManagerApi(fusion) + clients = api_instance.list_api_clients() + for client in clients: + client_info[client.name] = { + "display_name": client.display_name, + "issuer": client.issuer, + "public_key": client.public_key, + "creator_id": client.creator_id, + "last_key_update": time.strftime( + "%a, %d %b %Y %H:%M:%S %Z", + time.localtime(client.last_key_update / 1000), + ), + "last_used": time.strftime( + "%a, %d %b %Y %H:%M:%S %Z", + time.localtime(client.last_used / 1000), + ), + } + return client_info + + +@_api_permission_denied_handler("users") +def generate_users_dict(module, fusion): + users_info = {} + api_instance = purefusion.IdentityManagerApi(fusion) + users = api_instance.list_users() + for user in users: + users_info[user.name] = { + "display_name": user.display_name, + "email": user.email, + "id": user.id, + } + return users_info + + +@_api_permission_denied_handler("hardware_types") +def generate_hardware_types_dict(module, fusion): + hardware_info = {} + api_instance = purefusion.HardwareTypesApi(fusion) + hw_types = api_instance.list_hardware_types() + for hw_type in hw_types.items: + hardware_info[hw_type.name] = { + "array_type": hw_type.array_type, + "display_name": hw_type.display_name, + "media_type": hw_type.media_type, + } + return hardware_info + + +@_api_permission_denied_handler("storage_classes") +def generate_sc_dict(module, fusion): + sc_info = {} + ss_api_instance = purefusion.StorageServicesApi(fusion) + sc_api_instance = purefusion.StorageClassesApi(fusion) + services = ss_api_instance.list_storage_services() + for service in services.items: + classes = sc_api_instance.list_storage_classes( + storage_service_name=service.name, + ) + for s_class in classes.items: + sc_info[s_class.name] = { + "bandwidth_limit": getattr(s_class, "bandwidth_limit", None), + "iops_limit": getattr(s_class, "iops_limit", None), + "size_limit": getattr(s_class, "size_limit", None), + "display_name": s_class.display_name, + "storage_service": service.name, + } + return sc_info + + +@_api_permission_denied_handler("storage_services") +def generate_storserv_dict(module, fusion): + ss_dict = {} + ss_api_instance = purefusion.StorageServicesApi(fusion) + services = ss_api_instance.list_storage_services() + for service in services.items: + ss_dict[service.name] = { + "display_name": service.display_name, + "hardware_types": None, + } + # can be None if we don't have permission to see this + if service.hardware_types is not None: + ss_dict[service.name]["hardware_types"] = [] + for hwtype in service.hardware_types: + ss_dict[service.name]["hardware_types"].append(hwtype.name) + return ss_dict + + +@_api_permission_denied_handler("storage_endpoints") +def generate_se_dict(module, fusion): + se_dict = {} + se_api_instance = purefusion.StorageEndpointsApi(fusion) + az_api_instance = purefusion.AvailabilityZonesApi(fusion) + regions_api_instance = purefusion.RegionsApi(fusion) + regions = regions_api_instance.list_regions() + for region in regions.items: + azs = az_api_instance.list_availability_zones(region_name=region.name) + for az in azs.items: + endpoints = se_api_instance.list_storage_endpoints( + region_name=region.name, + availability_zone_name=az.name, + ) + for endpoint in endpoints.items: + name = region.name + "/" + az.name + "/" + endpoint.name + se_dict[name] = { + "display_name": endpoint.display_name, + "endpoint_type": endpoint.endpoint_type, + "iscsi_interfaces": [], + } + for iface in endpoint.iscsi.discovery_interfaces: + dct = { + "address": iface.address, + "gateway": iface.gateway, + "mtu": iface.mtu, + "network_interface_groups": None, + } + if iface.network_interface_groups is not None: + dct["network_interface_groups"] = [ + nig.name for nig in iface.network_interface_groups + ] + se_dict[name]["iscsi_interfaces"].append(dct) + return se_dict + + +@_api_permission_denied_handler("network_interface_groups") +def generate_nigs_dict(module, fusion): + nigs_dict = {} + nig_api_instance = purefusion.NetworkInterfaceGroupsApi(fusion) + az_api_instance = purefusion.AvailabilityZonesApi(fusion) + regions_api_instance = purefusion.RegionsApi(fusion) + regions = regions_api_instance.list_regions() + for region in regions.items: + azs = az_api_instance.list_availability_zones(region_name=region.name) + for az in azs.items: + nigs = nig_api_instance.list_network_interface_groups( + region_name=region.name, + availability_zone_name=az.name, + ) + for nig in nigs.items: + name = region.name + "/" + az.name + "/" + nig.name + nigs_dict[name] = { + "display_name": nig.display_name, + "gateway": nig.eth.gateway, + "prefix": nig.eth.prefix, + "mtu": nig.eth.mtu, + } + return nigs_dict + + +@_api_permission_denied_handler("snapshots") +def generate_snap_dicts(module, fusion): + snap_dict = {} + vsnap_dict = {} + tenant_api_instance = purefusion.TenantsApi(fusion) + tenantspace_api_instance = purefusion.TenantSpacesApi(fusion) + snap_api_instance = purefusion.SnapshotsApi(fusion) + vsnap_api_instance = purefusion.VolumeSnapshotsApi(fusion) + tenants = tenant_api_instance.list_tenants() + for tenant in tenants.items: + tenant_spaces = tenantspace_api_instance.list_tenant_spaces( + tenant_name=tenant.name + ).items + for tenant_space in tenant_spaces: + snaps = snap_api_instance.list_snapshots( + tenant_name=tenant.name, + tenant_space_name=tenant_space.name, + ) + for snap in snaps.items: + snap_name = tenant.name + "/" + tenant_space.name + "/" + snap.name + secs, mins, hours = _convert_microseconds(snap.time_remaining) + snap_dict[snap_name] = { + "display_name": snap.display_name, + "protection_policy": snap.protection_policy, + "time_remaining": "{0} hours, {1} mins, {2} secs".format( + int(hours), int(mins), int(secs) + ), + "volume_snapshots_link": snap.volume_snapshots_link, + } + vsnaps = vsnap_api_instance.list_volume_snapshots( + tenant_name=tenant.name, + tenant_space_name=tenant_space.name, + snapshot_name=snap.name, + ) + for vsnap in vsnaps.items: + vsnap_name = ( + tenant.name + + "/" + + tenant_space.name + + "/" + + snap.name + + "/" + + vsnap.name + ) + secs, mins, hours = _convert_microseconds(vsnap.time_remaining) + vsnap_dict[vsnap_name] = { + "size": vsnap.size, + "display_name": vsnap.display_name, + "protection_policy": vsnap.protection_policy, + "serial_number": vsnap.serial_number, + "created_at": time.strftime( + "%a, %d %b %Y %H:%M:%S %Z", + time.localtime(vsnap.created_at / 1000), + ), + "time_remaining": "{0} hours, {1} mins, {2} secs".format( + int(hours), int(mins), int(secs) + ), + "placement_group": vsnap.placement_group.name, + } + return snap_dict, vsnap_dict + + +@_api_permission_denied_handler("volumes") +def generate_volumes_dict(module, fusion): + volume_info = {} + + tenant_api_instance = purefusion.TenantsApi(fusion) + vol_api_instance = purefusion.VolumesApi(fusion) + tenant_space_api_instance = purefusion.TenantSpacesApi(fusion) + + tenants = tenant_api_instance.list_tenants() + for tenant in tenants.items: + tenant_spaces = tenant_space_api_instance.list_tenant_spaces( + tenant_name=tenant.name + ).items + for tenant_space in tenant_spaces: + volumes = vol_api_instance.list_volumes( + tenant_name=tenant.name, + tenant_space_name=tenant_space.name, + ) + for volume in volumes.items: + vol_name = tenant.name + "/" + tenant_space.name + "/" + volume.name + volume_info[vol_name] = { + "tenant": tenant.name, + "tenant_space": tenant_space.name, + "name": volume.name, + "size": volume.size, + "display_name": volume.display_name, + "placement_group": volume.placement_group.name, + "source_volume_snapshot": getattr( + volume.source_volume_snapshot, "name", None + ), + "protection_policy": getattr( + volume.protection_policy, "name", None + ), + "storage_class": volume.storage_class.name, + "serial_number": volume.serial_number, + "target": {}, + "array": getattr(volume.array, "name", None), + } + + volume_info[vol_name]["target"] = { + "iscsi": { + "addresses": volume.target.iscsi.addresses, + "iqn": volume.target.iscsi.iqn, + }, + "nvme": { + "addresses": None, + "nqn": None, + }, + "fc": { + "addresses": None, + "wwns": None, + }, + } + return volume_info + + +def main(): + argument_spec = fusion_argument_spec() + argument_spec.update( + dict(gather_subset=dict(default="minimum", type="list", elements="str")) + ) + + module = AnsibleModule(argument_spec, supports_check_mode=True) + + # will handle all errors (except #403 which should be handled in code) + fusion = setup_fusion(module) + + subset = [test.lower() for test in module.params["gather_subset"]] + valid_subsets = ( + "all", + "minimum", + "roles", + "users", + "placements", + "arrays", + "hardware_types", + "volumes", + "hosts", + "storage_classes", + "protection_policies", + "placement_groups", + "interfaces", + "zones", + "nigs", + "storage_endpoints", + "snapshots", + "storage_services", + "tenants", + "tenant_spaces", + "network_interface_groups", + "api_clients", + "availability_zones", + "host_access_policies", + "network_interfaces", + "regions", + ) + for option in subset: + if option not in valid_subsets: + module.fail_json( + msg=f"value gather_subset must be one or more of: {','.join(valid_subsets)}, got: {','.join(subset)}\nvalue {option} is not allowed" + ) + + info = {} + + if "minimum" in subset or "all" in subset: + info["default"] = generate_default_dict(module, fusion) + if "hardware_types" in subset or "all" in subset: + info["hardware_types"] = generate_hardware_types_dict(module, fusion) + if "users" in subset or "all" in subset: + info["users"] = generate_users_dict(module, fusion) + if "regions" in subset or "all" in subset: + info["regions"] = generate_regions_dict(module, fusion) + if "availability_zones" in subset or "all" in subset or "zones" in subset: + info["availability_zones"] = generate_zones_dict(module, fusion) + if "zones" in subset: + module.warn( + "The 'zones' subset is deprecated and will be removed in the version 2.0.0\nUse 'availability_zones' subset instead." + ) + if "roles" in subset or "all" in subset: + info["roles"] = generate_roles_dict(module, fusion) + info["role_assignments"] = generate_ras_dict(module, fusion) + if "storage_services" in subset or "all" in subset: + info["storage_services"] = generate_storserv_dict(module, fusion) + if "volumes" in subset or "all" in subset: + info["volumes"] = generate_volumes_dict(module, fusion) + if "protection_policies" in subset or "all" in subset: + info["protection_policies"] = generate_pp_dict(module, fusion) + if "placement_groups" in subset or "all" in subset or "placements" in subset: + info["placement_groups"] = generate_pg_dict(module, fusion) + if "placements" in subset: + module.warn( + "The 'placements' subset is deprecated and will be removed in the version 1.7.0" + ) + if "storage_classes" in subset or "all" in subset: + info["storage_classes"] = generate_sc_dict(module, fusion) + if "network_interfaces" in subset or "all" in subset or "interfaces" in subset: + info["network_interfaces"] = generate_nics_dict(module, fusion) + if "interfaces" in subset: + module.warn( + "The 'interfaces' subset is deprecated and will be removed in the version 2.0.0\nUse 'network_interfaces' subset instead." + ) + if "host_access_policies" in subset or "all" in subset or "hosts" in subset: + info["host_access_policies"] = generate_hap_dict(module, fusion) + if "hosts" in subset: + module.warn( + "The 'hosts' subset is deprecated and will be removed in the version 2.0.0\nUse 'host_access_policies' subset instead." + ) + if "arrays" in subset or "all" in subset: + info["arrays"] = generate_array_dict(module, fusion) + if "tenants" in subset or "all" in subset: + info["tenants"] = generate_tenant_dict(module, fusion) + if "tenant_spaces" in subset or "all" in subset: + info["tenant_spaces"] = generate_ts_dict(module, fusion) + if "storage_endpoints" in subset or "all" in subset: + info["storage_endpoints"] = generate_se_dict(module, fusion) + if "api_clients" in subset or "all" in subset: + info["api_clients"] = generate_api_client_dict(module, fusion) + if "network_interface_groups" in subset or "all" in subset or "nigs" in subset: + info["network_interface_groups"] = generate_nigs_dict(module, fusion) + if "nigs" in subset: + module.warn( + "The 'nigs' subset is deprecated and will be removed in the version 1.7.0" + ) + if "snapshots" in subset or "all" in subset: + snap_dicts = generate_snap_dicts(module, fusion) + if snap_dicts is not None: + info["snapshots"], info["volume_snapshots"] = snap_dicts + else: + info["snapshots"], info["volume_snapshots"] = None, None + + module.exit_json(changed=False, fusion_info=info) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/purestorage/fusion/plugins/modules/fusion_ni.py b/ansible_collections/purestorage/fusion/plugins/modules/fusion_ni.py new file mode 100644 index 000000000..6816ed841 --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/modules/fusion_ni.py @@ -0,0 +1,244 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2023, Andrej Pajtas (apajtas@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: fusion_ni +version_added: '1.0.0' +short_description: Manage network interfaces in Pure Storage Fusion +description: +- Update parameters of network interfaces in Pure Storage Fusion. +notes: +- Supports C(check_mode). +author: +- Pure Storage Ansible Team (@sdodsley) <pure-ansible-team@purestorage.com> +options: + name: + description: + - The name of the network interface. + type: str + required: true + display_name: + description: + - The human name of the network interface. + - If not provided, defaults to I(name). + type: str + region: + description: + - The name of the region the availability zone is in. + type: str + required: true + availability_zone: + aliases: [ az ] + description: + - The name of the availability zone for the network interface. + type: str + required: true + array: + description: + - The name of the array the network interface belongs to. + type: str + required: true + eth: + description: + - The IP address associated with the network interface. + - IP address must include a CIDR notation. + - Only IPv4 is supported at the moment. + - Required together with `network_interface_group` parameter. + type: str + enabled: + description: + - True if network interface is in use. + type: bool + network_interface_group: + description: + - The name of the network interface group this network interface belongs to. + type: str +extends_documentation_fragment: +- purestorage.fusion.purestorage.fusion +""" + +EXAMPLES = r""" +- name: Patch network interface + purestorage.fusion.fusion_ni: + name: foo + region: us-west + availability_zone: bar + array: array0 + eth: 10.21.200.124/24 + enabled: true + network_interface_group: subnet-0 + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" +""" + +RETURN = r""" +""" + +try: + import fusion as purefusion +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + fusion_argument_spec, +) + +from ansible_collections.purestorage.fusion.plugins.module_utils.getters import ( + get_array, + get_az, + get_region, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.networking import ( + is_valid_network, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.startup import ( + setup_fusion, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.operations import ( + await_operation, +) + + +def get_ni(module, fusion): + """Get Network Interface or None""" + ni_api_instance = purefusion.NetworkInterfacesApi(fusion) + try: + return ni_api_instance.get_network_interface( + region_name=module.params["region"], + availability_zone_name=module.params["availability_zone"], + array_name=module.params["array"], + net_intf_name=module.params["name"], + ) + except purefusion.rest.ApiException: + return None + + +def update_ni(module, fusion, ni): + """Update Network Interface""" + ni_api_instance = purefusion.NetworkInterfacesApi(fusion) + + patches = [] + if ( + module.params["display_name"] + and module.params["display_name"] != ni.display_name + ): + patch = purefusion.NetworkInterfacePatch( + display_name=purefusion.NullableString(module.params["display_name"]), + ) + patches.append(patch) + + if module.params["enabled"] is not None and module.params["enabled"] != ni.enabled: + patch = purefusion.NetworkInterfacePatch( + enabled=purefusion.NullableBoolean(module.params["enabled"]), + ) + patches.append(patch) + + if ( + module.params["network_interface_group"] + and module.params["network_interface_group"] != ni.network_interface_group + ): + if module.params["eth"] and module.params["eth"] != ni.eth: + patch = purefusion.NetworkInterfacePatch( + eth=purefusion.NetworkInterfacePatchEth( + purefusion.NullableString(module.params["eth"]) + ), + network_interface_group=purefusion.NullableString( + module.params["network_interface_group"] + ), + ) + else: + patch = purefusion.NetworkInterfacePatch( + network_interface_group=purefusion.NullableString( + module.params["network_interface_group"] + ), + ) + patches.append(patch) + + if not module.check_mode: + for patch in patches: + op = ni_api_instance.update_network_interface( + patch, + region_name=module.params["region"], + availability_zone_name=module.params["availability_zone"], + array_name=module.params["array"], + net_intf_name=module.params["name"], + ) + await_operation(fusion, op) + + changed = len(patches) != 0 + + module.exit_json(changed=changed) + + +def main(): + """Main code""" + argument_spec = fusion_argument_spec() + argument_spec.update( + dict( + name=dict(type="str", required=True), + display_name=dict(type="str"), + region=dict(type="str", required=True), + availability_zone=dict(type="str", required=True, aliases=["az"]), + array=dict(type="str", required=True), + eth=dict(type="str"), + enabled=dict(type="bool"), + network_interface_group=dict(type="str"), + ) + ) + + required_by = { + "eth": "network_interface_group", + } + + module = AnsibleModule( + argument_spec, + supports_check_mode=True, + required_by=required_by, + ) + + fusion = setup_fusion(module) + + if module.params["eth"] and not is_valid_network(module.params["eth"]): + module.fail_json( + msg="`eth` '{0}' is not a valid address in CIDR notation".format( + module.params["eth"] + ) + ) + + if not get_region(module, fusion): + module.fail_json( + msg="Region {0} does not exist.".format(module.params["region"]) + ) + + if not get_az(module, fusion): + module.fail_json( + msg="Availability Zone {0} does not exist.".format( + module.params["availability_zone"] + ) + ) + + if not get_array(module, fusion): + module.fail_json(msg="Array {0} does not exist.".format(module.params["array"])) + + ni = get_ni(module, fusion) + if not ni: + module.fail_json( + msg="Network Interface {0} does not exist".format(module.params["name"]) + ) + + update_ni(module, fusion, ni) + + module.exit_json(changed=False) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/purestorage/fusion/plugins/modules/fusion_nig.py b/ansible_collections/purestorage/fusion/plugins/modules/fusion_nig.py new file mode 100644 index 000000000..d6056fd5a --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/modules/fusion_nig.py @@ -0,0 +1,274 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2022, Simon Dodsley (simon@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: fusion_nig +version_added: '1.0.0' +short_description: Manage Network Interface Groups in Pure Storage Fusion +description: +- Create, delete and modify network interface groups in Pure Storage Fusion. +- Currently this only supports a single tenant subnet per tenant network +author: +- Pure Storage Ansible Team (@sdodsley) <pure-ansible-team@purestorage.com> +notes: +- Supports C(check mode). +options: + name: + description: + - The name of the network interface group. + type: str + required: true + display_name: + description: + - The human name of the network interface group. + - If not provided, defaults to I(name). + type: str + state: + description: + - Define whether the network interface group should exist or not. + type: str + default: present + choices: [ absent, present ] + availability_zone: + aliases: [ az ] + description: + - The name of the availability zone for the network interface group. + type: str + required: true + region: + description: + - Region for the network interface group. + type: str + required: true + gateway: + description: + - "Address of the subnet gateway. + Currently must be a valid IPv4 address." + type: str + mtu: + description: + - MTU setting for the subnet. + default: 1500 + type: int + group_type: + description: + - The type of network interface group. + type: str + default: eth + choices: [ eth ] + prefix: + description: + - "Network prefix in CIDR notation. + Required to create a new network interface group. + Currently only IPv4 addresses with subnet mask are supported." + type: str +extends_documentation_fragment: +- purestorage.fusion.purestorage.fusion +""" + +EXAMPLES = r""" +- name: Create new network interface group foo in AZ bar + purestorage.fusion.fusion_nig: + name: foo + availability_zone: bar + region: region1 + mtu: 9000 + gateway: 10.21.200.1 + prefix: 10.21.200.0/24 + state: present + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + +- name: Delete network interface group foo in AZ bar + purestorage.fusion.fusion_nig: + name: foo + availability_zone: bar + region: region1 + state: absent + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" +""" + +RETURN = r""" +""" + +try: + import fusion as purefusion +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + fusion_argument_spec, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.networking import ( + is_valid_address, + is_valid_network, + is_address_in_network, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.startup import ( + setup_fusion, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.operations import ( + await_operation, +) + + +def get_nig(module, fusion): + """Check Network Interface Group""" + nig_api_instance = purefusion.NetworkInterfaceGroupsApi(fusion) + try: + return nig_api_instance.get_network_interface_group( + availability_zone_name=module.params["availability_zone"], + region_name=module.params["region"], + network_interface_group_name=module.params["name"], + ) + except purefusion.rest.ApiException: + return None + + +def create_nig(module, fusion): + """Create Network Interface Group""" + + nig_api_instance = purefusion.NetworkInterfaceGroupsApi(fusion) + + changed = False + if module.params["gateway"] and not is_address_in_network( + module.params["gateway"], module.params["prefix"] + ): + module.fail_json(msg="`gateway` must be an address in subnet `prefix`") + + if not module.check_mode: + display_name = module.params["display_name"] or module.params["name"] + if module.params["group_type"] == "eth": + if module.params["gateway"]: + eth = purefusion.NetworkInterfaceGroupEthPost( + prefix=module.params["prefix"], + gateway=module.params["gateway"], + mtu=module.params["mtu"], + ) + else: + eth = purefusion.NetworkInterfaceGroupEthPost( + prefix=module.params["prefix"], + mtu=module.params["mtu"], + ) + nig = purefusion.NetworkInterfaceGroupPost( + group_type="eth", + eth=eth, + name=module.params["name"], + display_name=display_name, + ) + op = nig_api_instance.create_network_interface_group( + nig, + availability_zone_name=module.params["availability_zone"], + region_name=module.params["region"], + ) + await_operation(fusion, op) + changed = True + else: + # to prevent future unintended error + module.warn(f"group_type={module.params['group_type']} is not implemented") + + module.exit_json(changed=changed) + + +def delete_nig(module, fusion): + """Delete Network Interface Group""" + changed = True + nig_api_instance = purefusion.NetworkInterfaceGroupsApi(fusion) + if not module.check_mode: + op = nig_api_instance.delete_network_interface_group( + availability_zone_name=module.params["availability_zone"], + region_name=module.params["region"], + network_interface_group_name=module.params["name"], + ) + await_operation(fusion, op) + module.exit_json(changed=changed) + + +def update_nig(module, fusion, nig): + """Update Network Interface Group""" + + nifg_api_instance = purefusion.NetworkInterfaceGroupsApi(fusion) + patches = [] + if ( + module.params["display_name"] + and module.params["display_name"] != nig.display_name + ): + patch = purefusion.NetworkInterfaceGroupPatch( + display_name=purefusion.NullableString(module.params["display_name"]), + ) + patches.append(patch) + + if not module.check_mode: + for patch in patches: + op = nifg_api_instance.update_network_interface_group( + patch, + availability_zone_name=module.params["availability_zone"], + region_name=module.params["region"], + network_interface_group_name=module.params["name"], + ) + await_operation(fusion, op) + + changed = len(patches) != 0 + + module.exit_json(changed=changed) + + +def main(): + """Main code""" + argument_spec = fusion_argument_spec() + argument_spec.update( + dict( + name=dict(type="str", required=True), + display_name=dict(type="str"), + availability_zone=dict(type="str", required=True, aliases=["az"]), + region=dict(type="str", required=True), + prefix=dict(type="str"), + gateway=dict(type="str"), + mtu=dict(type="int", default=1500), + group_type=dict(type="str", default="eth", choices=["eth"]), + state=dict(type="str", default="present", choices=["absent", "present"]), + ) + ) + + module = AnsibleModule(argument_spec, supports_check_mode=True) + fusion = setup_fusion(module) + + state = module.params["state"] + if module.params["prefix"] and not is_valid_network(module.params["prefix"]): + module.fail_json( + msg="`prefix` '{0}' is not a valid address in CIDR notation".format( + module.params["prefix"] + ) + ) + if module.params["gateway"] and not is_valid_address(module.params["gateway"]): + module.fail_json( + msg="`gateway` '{0}' is not a valid address".format( + module.params["gateway"] + ) + ) + + nig = get_nig(module, fusion) + + if state == "present" and not nig: + module.fail_on_missing_params(["prefix"]) + create_nig(module, fusion) + elif state == "present" and nig: + update_nig(module, fusion, nig) + elif state == "absent" and nig: + delete_nig(module, fusion) + + module.exit_json(changed=False) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/purestorage/fusion/plugins/modules/fusion_pg.py b/ansible_collections/purestorage/fusion/plugins/modules/fusion_pg.py new file mode 100644 index 000000000..57843d896 --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/modules/fusion_pg.py @@ -0,0 +1,278 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2022, Simon Dodsley (simon@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: fusion_pg +version_added: '1.0.0' +short_description: Manage placement groups in Pure Storage Fusion +description: +- Create, update or delete a placement groups in Pure Storage Fusion. +author: +- Pure Storage Ansible Team (@sdodsley) <pure-ansible-team@purestorage.com> +notes: +- Supports C(check mode). +options: + name: + description: + - The name of the placement group. + type: str + required: true + display_name: + description: + - The human name of the placement group. + - If not provided, defaults to I(name). + type: str + state: + description: + - Define whether the placement group should exist or not. + type: str + default: present + choices: [ absent, present ] + tenant: + description: + - The name of the tenant. + type: str + required: true + tenant_space: + description: + - The name of the tenant space. + type: str + required: true + region: + description: + - The name of the region the availability zone is in. + type: str + availability_zone: + aliases: [ az ] + description: + - The name of the availability zone the placement group is in. + type: str + storage_service: + description: + - The name of the storage service to create the placement group for. + type: str + array: + description: + - "Array to place the placement group to. Changing it (i.e. manual migration) + is an elevated operation." + type: str + placement_engine: + description: + - For workload placement recommendations from Pure1 Meta, use C(pure1meta). + - Please note that this might increase volume creation time. + type: str + choices: [ heuristics, pure1meta ] +extends_documentation_fragment: +- purestorage.fusion.purestorage.fusion +""" + +EXAMPLES = r""" +- name: Create new placement group named foo + purestorage.fusion.fusion_pg: + name: foo + tenant: test + tenant_space: space_1 + availability_zone: az1 + region: region1 + storage_service: storage_service_1 + state: present + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + +- name: Delete placement group foo + purestorage.fusion.fusion_pg: + name: foo + tenant: test + tenant_space: space_1 + state: absent + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" +""" + +RETURN = r""" +""" + +try: + import fusion as purefusion +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + fusion_argument_spec, +) + +from ansible_collections.purestorage.fusion.plugins.module_utils.startup import ( + setup_fusion, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.operations import ( + await_operation, +) + + +def get_pg(module, fusion): + """Return Placement Group or None""" + pg_api_instance = purefusion.PlacementGroupsApi(fusion) + try: + return pg_api_instance.get_placement_group( + tenant_name=module.params["tenant"], + tenant_space_name=module.params["tenant_space"], + placement_group_name=module.params["name"], + ) + except purefusion.rest.ApiException: + return None + + +def create_pg(module, fusion): + """Create Placement Group""" + + pg_api_instance = purefusion.PlacementGroupsApi(fusion) + + if not module.check_mode: + if not module.params["display_name"]: + display_name = module.params["name"] + else: + display_name = module.params["display_name"] + group = purefusion.PlacementGroupPost( + availability_zone=module.params["availability_zone"], + name=module.params["name"], + display_name=display_name, + region=module.params["region"], + storage_service=module.params["storage_service"], + ) + op = pg_api_instance.create_placement_group( + group, + tenant_name=module.params["tenant"], + tenant_space_name=module.params["tenant_space"], + ) + await_operation(fusion, op) + + return True + + +def update_display_name(module, fusion, patches, pg): + if not module.params["display_name"]: + return + if module.params["display_name"] == pg.display_name: + return + patch = purefusion.PlacementGroupPatch( + display_name=purefusion.NullableString(module.params["display_name"]), + ) + patches.append(patch) + + +def update_array(module, fusion, patches, pg): + if not module.params["array"]: + return + if not pg.array: + module.warn( + "cannot see placement group array, probably missing required permissions to change it" + ) + return + if pg.array.name == module.params["array"]: + return + + patch = purefusion.PlacementGroupPatch( + array=purefusion.NullableString(module.params["array"]), + ) + patches.append(patch) + + +def update_pg(module, fusion, pg): + """Update Placement Group""" + + pg_api_instance = purefusion.PlacementGroupsApi(fusion) + patches = [] + + update_display_name(module, fusion, patches, pg) + update_array(module, fusion, patches, pg) + + if not module.check_mode: + for patch in patches: + op = pg_api_instance.update_placement_group( + patch, + tenant_name=module.params["tenant"], + tenant_space_name=module.params["tenant_space"], + placement_group_name=module.params["name"], + ) + await_operation(fusion, op) + + changed = len(patches) != 0 + return changed + + +def delete_pg(module, fusion): + """Delete Placement Group""" + pg_api_instance = purefusion.PlacementGroupsApi(fusion) + if not module.check_mode: + op = pg_api_instance.delete_placement_group( + placement_group_name=module.params["name"], + tenant_name=module.params["tenant"], + tenant_space_name=module.params["tenant_space"], + ) + await_operation(fusion, op) + + return True + + +def main(): + """Main code""" + argument_spec = fusion_argument_spec() + argument_spec.update( + dict( + name=dict(type="str", required=True), + display_name=dict(type="str"), + tenant=dict(type="str", required=True), + tenant_space=dict(type="str", required=True), + region=dict(type="str"), + availability_zone=dict(type="str", aliases=["az"]), + storage_service=dict(type="str"), + state=dict(type="str", default="present", choices=["absent", "present"]), + array=dict(type="str"), + placement_engine=dict( + type="str", + choices=["heuristics", "pure1meta"], + removed_in_version="2.0.0", + removed_from_collection="purestorage.fusion", + ), + ) + ) + + module = AnsibleModule(argument_spec, supports_check_mode=True) + fusion = setup_fusion(module) + + if module.params["placement_engine"]: + module.warn("placement_engine parameter will be deprecated in version 2.0.0") + + changed = False + + state = module.params["state"] + pgroup = get_pg(module, fusion) + + if state == "present" and not pgroup: + module.fail_on_missing_params( + ["region", "availability_zone", "storage_service"] + ) + changed = create_pg(module, fusion) or changed + if module.params["array"]: + # changing placement requires additional update + pgroup = get_pg(module, fusion) + changed = update_pg(module, fusion, pgroup) or changed + elif state == "present" and pgroup: + changed = update_pg(module, fusion, pgroup) or changed + elif state == "absent" and pgroup: + changed = delete_pg(module, fusion) or changed + + module.exit_json(changed=changed) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/purestorage/fusion/plugins/modules/fusion_pp.py b/ansible_collections/purestorage/fusion/plugins/modules/fusion_pp.py new file mode 100644 index 000000000..abce9195c --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/modules/fusion_pp.py @@ -0,0 +1,187 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2022, Simon Dodsley (simon@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: fusion_pp +version_added: '1.0.0' +short_description: Manage protection policies in Pure Storage Fusion +description: +- Manage protection policies in Pure Storage Fusion. +author: +- Pure Storage Ansible Team (@sdodsley) <pure-ansible-team@purestorage.com> +notes: +- Supports C(check mode). +options: + name: + description: + - The name of the protection policy. + type: str + required: true + state: + description: + - Define whether the protection policy should exist or not. + default: present + choices: [ present, absent ] + type: str + display_name: + description: + - The human name of the protection policy. + - If not provided, defaults to I(name). + type: str + local_rpo: + description: + - Recovery Point Objective for snapshots. + - Value should be specified in minutes. + - Minimum value is 10 minutes. + type: str + local_retention: + description: + - Retention Duration for periodic snapshots. + - Minimum value is 10 minutes. + - Value can be provided as m(inutes), h(ours), + d(ays), w(eeks), or y(ears). + - If no unit is provided, minutes are assumed. + type: str +extends_documentation_fragment: +- purestorage.fusion.purestorage.fusion +""" + +EXAMPLES = r""" +- name: Create new protection policy foo + purestorage.fusion.fusion_pp: + name: foo + local_rpo: 10 + local_retention: 4d + display_name: "foo pp" + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + +- name: Delete protection policy foo + purestorage.fusion.fusion_pp: + name: foo + state: absent + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" +""" + +RETURN = r""" +""" + +try: + import fusion as purefusion +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + fusion_argument_spec, +) + +from ansible_collections.purestorage.fusion.plugins.module_utils.parsing import ( + parse_minutes, +) + +from ansible_collections.purestorage.fusion.plugins.module_utils.startup import ( + setup_fusion, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.operations import ( + await_operation, +) + + +def get_pp(module, fusion): + """Return Protection Policy or None""" + pp_api_instance = purefusion.ProtectionPoliciesApi(fusion) + try: + return pp_api_instance.get_protection_policy( + protection_policy_name=module.params["name"] + ) + except purefusion.rest.ApiException: + return None + + +def create_pp(module, fusion): + """Create Protection Policy""" + + pp_api_instance = purefusion.ProtectionPoliciesApi(fusion) + local_rpo = parse_minutes(module, module.params["local_rpo"]) + local_retention = parse_minutes(module, module.params["local_retention"]) + if local_retention < 1: + module.fail_json(msg="Local Retention must be a minimum of 1 minutes") + if local_rpo < 10: + module.fail_json(msg="Local RPO must be a minimum of 10 minutes") + changed = True + if not module.check_mode: + if not module.params["display_name"]: + display_name = module.params["name"] + else: + display_name = module.params["display_name"] + op = pp_api_instance.create_protection_policy( + purefusion.ProtectionPolicyPost( + name=module.params["name"], + display_name=display_name, + objectives=[ + purefusion.RPO(type="RPO", rpo="PT" + str(local_rpo) + "M"), + purefusion.Retention( + type="Retention", after="PT" + str(local_retention) + "M" + ), + ], + ) + ) + await_operation(fusion, op) + + module.exit_json(changed=changed) + + +def delete_pp(module, fusion): + """Delete Protection Policy""" + pp_api_instance = purefusion.ProtectionPoliciesApi(fusion) + changed = True + if not module.check_mode: + op = pp_api_instance.delete_protection_policy( + protection_policy_name=module.params["name"], + ) + await_operation(fusion, op) + + module.exit_json(changed=changed) + + +def main(): + """Main code""" + argument_spec = fusion_argument_spec() + argument_spec.update( + dict( + name=dict(type="str", required=True), + display_name=dict(type="str"), + local_rpo=dict(type="str"), + local_retention=dict(type="str"), + state=dict(type="str", default="present", choices=["present", "absent"]), + ) + ) + module = AnsibleModule(argument_spec, supports_check_mode=True) + fusion = setup_fusion(module) + + state = module.params["state"] + policy = get_pp(module, fusion) + + if not policy and state == "present": + module.fail_on_missing_params(["local_rpo", "local_retention"]) + create_pp(module, fusion) + elif policy and state == "absent": + delete_pp(module, fusion) + else: + module.exit_json(changed=False) + + module.exit_json(changed=False) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/purestorage/fusion/plugins/modules/fusion_ra.py b/ansible_collections/purestorage/fusion/plugins/modules/fusion_ra.py new file mode 100644 index 000000000..7cfc7d866 --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/modules/fusion_ra.py @@ -0,0 +1,281 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2022, Simon Dodsley (simon@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: fusion_ra +version_added: '1.0.0' +short_description: Manage role assignments in Pure Storage Fusion +description: +- Create or delete a storage class in Pure Storage Fusion. +author: +- Pure Storage Ansible Team (@sdodsley) <pure-ansible-team@purestorage.com> +notes: +- Supports C(check mode). +options: + role: + description: + - The name of the role to be assigned/unassigned. + type: str + required: true + state: + description: + - Define whether the role assingment should exist or not. + type: str + default: present + choices: [ absent, present ] + user: + description: + - The username to assign the role to. + - Currently this only supports the Pure1 App ID. + - This should be provide in the same format as I(issuer_id). + type: str + principal: + description: + - The unique ID of the principal (User or API Client) to assign to the role. + type: str + api_client_key: + description: + - The key of API client to assign the role to. + type: str + scope: + description: + - The level to which the role is assigned. + choices: [ organization, tenant, tenant_space ] + default: organization + type: str + tenant: + description: + - The name of the tenant the user has the role applied to. + - Must be provided if I(scope) is set to either C(tenant) or C(tenant_space). + type: str + tenant_space: + description: + - The name of the tenant_space the user has the role applied to. + - Must be provided if I(scope) is set to C(tenant_space). + type: str +extends_documentation_fragment: +- purestorage.fusion.purestorage.fusion +""" + +EXAMPLES = r""" +- name: Assign role foo to user in tenant bar + purestorage.fusion.fusion_ra: + name: foo + user: key_name + tenant: bar + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + +- name: Delete role foo from user in tenant bar + purestorage.fusion.fusion_ra: + name: foo + user: key_name + tenant: bar + state: absent + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" +""" + +RETURN = r""" +""" + +try: + import fusion as purefusion +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + fusion_argument_spec, +) + +from ansible_collections.purestorage.fusion.plugins.module_utils.operations import ( + await_operation, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.startup import ( + setup_fusion, +) + + +def get_principal(module, fusion): + if module.params["principal"]: + return module.params["principal"] + if module.params["user"]: + principal = user_to_principal(fusion, module.params["user"]) + if not principal: + module.fail_json( + msg="User {0} does not exist".format(module.params["user"]) + ) + return principal + if module.params["api_client_key"]: + principal = apiclient_to_principal(fusion, module.params["api_client_key"]) + if not principal: + module.fail_json( + msg="API Client with key {0} does not exist".format( + module.params["api_client_key"] + ) + ) + return principal + + +def user_to_principal(fusion, user_id): + """Given a human readable Fusion user, such as a Pure 1 App ID + return the associated principal + """ + id_api_instance = purefusion.IdentityManagerApi(fusion) + users = id_api_instance.list_users() + for user in users: + if user.name == user_id: + return user.id + return None + + +def apiclient_to_principal(fusion, api_client_key): + """Given an API client key, such as "pure1:apikey:123xXxyYyzYzASDF" (also known as issuer_id), + return the associated principal + """ + id_api_instance = purefusion.IdentityManagerApi(fusion) + api_clients = id_api_instance.list_users(name=api_client_key) + if len(api_clients) > 0: + return api_clients[0].id + return None + + +def get_scope(params): + """Given a scope type and associated tenant + and tenant_space, return the scope_link + """ + scope_link = None + if params["scope"] == "organization": + scope_link = "/" + elif params["scope"] == "tenant": + scope_link = "/tenants/" + params["tenant"] + elif params["scope"] == "tenant_space": + scope_link = ( + "/tenants/" + params["tenant"] + "/tenant-spaces/" + params["tenant_space"] + ) + return scope_link + + +def get_ra(module, fusion): + """Return Role Assignment or None""" + ra_api_instance = purefusion.RoleAssignmentsApi(fusion) + try: + principal = get_principal(module, fusion) + assignments = ra_api_instance.list_role_assignments( + role_name=module.params["role"], + principal=principal, + ) + for assign in assignments: + scope = get_scope(module.params) + if assign.scope.self_link == scope: + return assign + return None + except purefusion.rest.ApiException: + return None + + +def create_ra(module, fusion): + """Create Role Assignment""" + + ra_api_instance = purefusion.RoleAssignmentsApi(fusion) + + changed = True + if not module.check_mode: + principal = get_principal(module, fusion) + scope = get_scope(module.params) + assignment = purefusion.RoleAssignmentPost(scope=scope, principal=principal) + op = ra_api_instance.create_role_assignment( + assignment, role_name=module.params["role"] + ) + await_operation(fusion, op) + module.exit_json(changed=changed) + + +def delete_ra(module, fusion): + """Delete Role Assignment""" + changed = True + ra_api_instance = purefusion.RoleAssignmentsApi(fusion) + if not module.check_mode: + ra_name = get_ra(module, fusion).name + op = ra_api_instance.delete_role_assignment( + role_name=module.params["role"], role_assignment_name=ra_name + ) + await_operation(fusion, op) + + module.exit_json(changed=changed) + + +def main(): + """Main code""" + argument_spec = fusion_argument_spec() + argument_spec.update( + dict( + api_client_key=dict(type="str", no_log=True), + principal=dict(type="str"), + role=dict( + type="str", + required=True, + deprecated_aliases=[ + dict( + name="name", + date="2023-07-26", + collection_name="purefusion.fusion", + ) + ], + ), + scope=dict( + type="str", + default="organization", + choices=["organization", "tenant", "tenant_space"], + ), + state=dict(type="str", default="present", choices=["present", "absent"]), + tenant=dict(type="str"), + tenant_space=dict(type="str"), + user=dict(type="str"), + ) + ) + + required_if = [ + ["scope", "tenant", ["tenant"]], + ["scope", "tenant_space", ["tenant", "tenant_space"]], + ] + mutually_exclusive = [ + ("user", "principal", "api_client_key"), + ] + required_one_of = [ + ("user", "principal", "api_client_key"), + ] + + module = AnsibleModule( + argument_spec, + required_if=required_if, + supports_check_mode=True, + mutually_exclusive=mutually_exclusive, + required_one_of=required_one_of, + ) + fusion = setup_fusion(module) + + state = module.params["state"] + role_assignment = get_ra(module, fusion) + + if not role_assignment and state == "present": + create_ra(module, fusion) + elif role_assignment and state == "absent": + delete_ra(module, fusion) + else: + module.exit_json(changed=False) + + module.exit_json(changed=False) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/purestorage/fusion/plugins/modules/fusion_region.py b/ansible_collections/purestorage/fusion/plugins/modules/fusion_region.py new file mode 100644 index 000000000..fbcbff4b0 --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/modules/fusion_region.py @@ -0,0 +1,180 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2022, Simon Dodsley (simon@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: fusion_region +version_added: '1.1.0' +short_description: Manage Regions in Pure Storage Fusion +description: +- Manage regions in Pure Storage Fusion. +author: +- Pure Storage Ansible Team (@sdodsley) <pure-ansible-team@purestorage.com> +notes: +- Supports C(check mode). +options: + name: + description: + - The name of the Region. + type: str + required: true + state: + description: + - Define whether the Region should exist or not. + default: present + choices: [ present, absent ] + type: str + display_name: + description: + - The human name of the Region. + - If not provided, defaults to I(name). + type: str +extends_documentation_fragment: +- purestorage.fusion.purestorage.fusion +""" + +EXAMPLES = r""" +- name: Create new region foo + purestorage.fusion.fusion_region: + name: foo + display_name: "foo Region" + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + +- name: Update region foo + purestorage.fusion.fusion_region: + name: foo + display_name: "new foo Region" + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + +- name: Delete region foo + purestorage.fusion.fusion_region: + name: foo + state: absent + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" +""" + +RETURN = r""" +""" + +try: + import fusion as purefusion +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + fusion_argument_spec, +) + +from ansible_collections.purestorage.fusion.plugins.module_utils.operations import ( + await_operation, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.startup import ( + setup_fusion, +) +from ansible_collections.purestorage.fusion.plugins.module_utils import getters + + +def get_region(module, fusion): + """Get Region or None""" + return getters.get_region(module, fusion, module.params["name"]) + + +def create_region(module, fusion): + """Create Region""" + + reg_api_instance = purefusion.RegionsApi(fusion) + + changed = True + if not module.check_mode: + if not module.params["display_name"]: + display_name = module.params["name"] + else: + display_name = module.params["display_name"] + region = purefusion.RegionPost( + name=module.params["name"], + display_name=display_name, + ) + op = reg_api_instance.create_region(region) + await_operation(fusion, op) + + module.exit_json(changed=changed) + + +def delete_region(module, fusion): + """Delete Region""" + + reg_api_instance = purefusion.RegionsApi(fusion) + + changed = True + if not module.check_mode: + op = reg_api_instance.delete_region(region_name=module.params["name"]) + await_operation(fusion, op) + + module.exit_json(changed=changed) + + +def update_region(module, fusion, region): + """Update Region settings""" + changed = False + reg_api_instance = purefusion.RegionsApi(fusion) + + if ( + module.params["display_name"] + and module.params["display_name"] != region.display_name + ): + changed = True + if not module.check_mode: + reg = purefusion.RegionPatch( + display_name=purefusion.NullableString(module.params["display_name"]) + ) + op = reg_api_instance.update_region( + reg, + region_name=module.params["name"], + ) + await_operation(fusion, op) + + module.exit_json(changed=changed) + + +def main(): + """Main code""" + argument_spec = fusion_argument_spec() + argument_spec.update( + dict( + name=dict(type="str", required=True), + display_name=dict(type="str"), + state=dict(type="str", default="present", choices=["present", "absent"]), + ) + ) + + module = AnsibleModule(argument_spec, supports_check_mode=True) + fusion = setup_fusion(module) + + state = module.params["state"] + region = get_region(module, fusion) + + if not region and state == "present": + create_region(module, fusion) + elif region and state == "present": + update_region(module, fusion, region) + elif region and state == "absent": + delete_region(module, fusion) + else: + module.exit_json(changed=False) + + module.exit_json(changed=False) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/purestorage/fusion/plugins/modules/fusion_sc.py b/ansible_collections/purestorage/fusion/plugins/modules/fusion_sc.py new file mode 100644 index 000000000..2327b8d48 --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/modules/fusion_sc.py @@ -0,0 +1,255 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2022, Simon Dodsley (simon@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: fusion_sc +version_added: '1.0.0' +short_description: Manage storage classes in Pure Storage Fusion +description: +- Manage a storage class in Pure Storage Fusion. +notes: +- Supports C(check_mode). +- It is not currently possible to update bw_limit or + iops_limit after a storage class has been created. +author: +- Pure Storage Ansible Team (@sdodsley) <pure-ansible-team@purestorage.com> +options: + name: + description: + - The name of the storage class. + type: str + required: true + state: + description: + - Define whether the storage class should exist or not. + default: present + choices: [ present, absent ] + type: str + display_name: + description: + - The human name of the storage class. + - If not provided, defaults to I(name). + type: str + size_limit: + description: + - Volume size limit in M, G, T or P units. + - Must be between 1MB and 4PB. + - If not provided at creation, this will default to 4PB. + type: str + bw_limit: + description: + - The bandwidth limit in M or G units. + M will set MB/s. + G will set GB/s. + - Must be between 1MB/s and 512GB/s. + - If not provided at creation, this will default to 512GB/s. + type: str + iops_limit: + description: + - The IOPs limit - use value or K or M. + K will mean 1000. + M will mean 1000000. + - Must be between 100 and 100000000. + - If not provided at creation, this will default to 100000000. + type: str + storage_service: + description: + - Storage service to which the storage class belongs. + type: str + required: true +extends_documentation_fragment: +- purestorage.fusion.purestorage.fusion +""" + +EXAMPLES = r""" +- name: Create new storage class foo + purestorage.fusion.fusion_sc: + name: foo + size_limit: 100G + iops_limit: 100000 + bw_limit: 25M + storage_service: service1 + display_name: "test class" + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + +- name: Update storage class (only display_name change is supported) + purestorage.fusion.fusion_sc: + name: foo + display_name: "main class" + storage_service: service1 + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + +- name: Delete storage class + purestorage.fusion.fusion_sc: + name: foo + storage_service: service1 + state: absent + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" +""" + +RETURN = r""" +""" + +try: + import fusion as purefusion +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + fusion_argument_spec, +) + +from ansible_collections.purestorage.fusion.plugins.module_utils.parsing import ( + parse_number_with_metric_suffix, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.startup import ( + setup_fusion, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.operations import ( + await_operation, +) + + +def get_sc(module, fusion): + """Return Storage Class or None""" + sc_api_instance = purefusion.StorageClassesApi(fusion) + try: + return sc_api_instance.get_storage_class( + storage_class_name=module.params["name"], + storage_service_name=module.params["storage_service"], + ) + except purefusion.rest.ApiException: + return None + + +def create_sc(module, fusion): + """Create Storage Class""" + + sc_api_instance = purefusion.StorageClassesApi(fusion) + + if not module.params["size_limit"]: + module.params["size_limit"] = "4P" + if not module.params["iops_limit"]: + module.params["iops_limit"] = "100000000" + if not module.params["bw_limit"]: + module.params["bw_limit"] = "512G" + size_limit = parse_number_with_metric_suffix(module, module.params["size_limit"]) + iops_limit = int( + parse_number_with_metric_suffix( + module, module.params["iops_limit"], factor=1000 + ) + ) + bw_limit = parse_number_with_metric_suffix(module, module.params["bw_limit"]) + if bw_limit < 1048576 or bw_limit > 549755813888: # 1MB/s to 512GB/s + module.fail_json(msg="Bandwidth limit is not within the required range") + if iops_limit < 100 or iops_limit > 100_000_000: + module.fail_json(msg="IOPs limit is not within the required range") + if size_limit < 1048576 or size_limit > 4503599627370496: # 1MB to 4PB + module.fail_json(msg="Size limit is not within the required range") + + changed = True + if not module.check_mode: + if not module.params["display_name"]: + display_name = module.params["name"] + else: + display_name = module.params["display_name"] + s_class = purefusion.StorageClassPost( + name=module.params["name"], + size_limit=size_limit, + iops_limit=iops_limit, + bandwidth_limit=bw_limit, + display_name=display_name, + ) + op = sc_api_instance.create_storage_class( + s_class, storage_service_name=module.params["storage_service"] + ) + await_operation(fusion, op) + + module.exit_json(changed=changed) + + +def update_sc(module, fusion, s_class): + """Update Storage Class settings""" + changed = False + sc_api_instance = purefusion.StorageClassesApi(fusion) + + if ( + module.params["display_name"] + and module.params["display_name"] != s_class.display_name + ): + changed = True + if not module.check_mode: + sclass = purefusion.StorageClassPatch( + display_name=purefusion.NullableString(module.params["display_name"]) + ) + op = sc_api_instance.update_storage_class( + sclass, + storage_service_name=module.params["storage_service"], + storage_class_name=module.params["name"], + ) + await_operation(fusion, op) + + module.exit_json(changed=changed) + + +def delete_sc(module, fusion): + """Delete Storage Class""" + sc_api_instance = purefusion.StorageClassesApi(fusion) + changed = True + if not module.check_mode: + op = sc_api_instance.delete_storage_class( + storage_class_name=module.params["name"], + storage_service_name=module.params["storage_service"], + ) + await_operation(fusion, op) + + module.exit_json(changed=changed) + + +def main(): + """Main code""" + argument_spec = fusion_argument_spec() + argument_spec.update( + dict( + name=dict(type="str", required=True), + display_name=dict(type="str"), + iops_limit=dict(type="str"), + bw_limit=dict(type="str"), + size_limit=dict(type="str"), + storage_service=dict(type="str", required=True), + state=dict(type="str", default="present", choices=["present", "absent"]), + ) + ) + + module = AnsibleModule(argument_spec, supports_check_mode=True) + fusion = setup_fusion(module) + + state = module.params["state"] + s_class = get_sc(module, fusion) + + if not s_class and state == "present": + create_sc(module, fusion) + elif s_class and state == "present": + update_sc(module, fusion, s_class) + elif s_class and state == "absent": + delete_sc(module, fusion) + else: + module.exit_json(changed=False) + + module.exit_json(changed=False) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/purestorage/fusion/plugins/modules/fusion_se.py b/ansible_collections/purestorage/fusion/plugins/modules/fusion_se.py new file mode 100644 index 000000000..9eed4bea0 --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/modules/fusion_se.py @@ -0,0 +1,507 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2023, Simon Dodsley (simon@purestorage.com), Jan Kodera (jkodera@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: fusion_se +version_added: '1.0.0' +short_description: Manage storage endpoints in Pure Storage Fusion +description: +- Create or delete storage endpoints in Pure Storage Fusion. +notes: +- Supports C(check_mode). +author: +- Pure Storage Ansible Team (@sdodsley) <pure-ansible-team@purestorage.com> +options: + name: + description: + - The name of the storage endpoint. + type: str + required: true + display_name: + description: + - The human name of the storage endpoint. + - If not provided, defaults to I(name). + type: str + state: + description: + - Define whether the storage endpoint should exist or not. + type: str + default: present + choices: [ absent, present ] + region: + description: + - The name of the region the availability zone is in + type: str + required: true + availability_zone: + aliases: [ az ] + description: + - The name of the availability zone for the storage endpoint. + type: str + required: true + endpoint_type: + description: + - "DEPRECATED: Will be removed in version 2.0.0" + - Type of the storage endpoint. Only iSCSI is available at the moment. + type: str + iscsi: + description: + - List of discovery interfaces. + type: list + elements: dict + suboptions: + address: + description: + - IP address to be used in the subnet of the storage endpoint. + - IP address must include a CIDR notation. + - Only IPv4 is supported at the moment. + type: str + gateway: + description: + - Address of the subnet gateway. + type: str + network_interface_groups: + description: + - List of network interface groups to assign to the address. + type: list + elements: str + cbs_azure_iscsi: + description: + - CBS Azure iSCSI + type: dict + suboptions: + storage_endpoint_collection_identity: + description: + - The Storage Endpoint Collection Identity which belongs to the Azure entities. + type: str + load_balancer: + description: + - The Load Balancer id which gives permissions to CBS array applications to modify the Load Balancer. + type: str + load_balancer_addresses: + description: + - The IPv4 addresses of the Load Balancer. + type: list + elements: str + network_interface_groups: + description: + - "DEPRECATED: Will be removed in version 2.0.0" + - List of network interface groups to assign to the storage endpoints. + type: list + elements: str + addresses: + description: + - "DEPRECATED: Will be removed in version 2.0.0" + - List of IP addresses to be used in the subnet of the storage endpoint. + - IP addresses must include a CIDR notation. + - Only IPv4 is supported at the moment. + type: list + elements: str + gateway: + description: + - "DEPRECATED: Will be removed in version 2.0.0" + - Address of the subnet gateway. + - Currently this must be provided. + type: str + +extends_documentation_fragment: +- purestorage.fusion.purestorage.fusion +""" + +EXAMPLES = r""" +- name: Create new storage endpoint foo in AZ bar + purestorage.fusion.fusion_se: + name: foo + availability_zone: bar + region: us-west + iscsi: + - address: 10.21.200.124/24 + gateway: 10.21.200.1 + network_interface_groups: + - subnet-0 + - address: 10.21.200.36/24 + gateway: 10.21.200.2 + network_interface_groups: + - subnet-0 + - subnet-1 + state: present + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + +- name: Create new CBS storage endpoint foo in AZ bar + purestorage.fusion.fusion_se: + name: foo + availability_zone: bar + region: us-west + cbs_azure_iscsi: + storage_endpoint_collection_identity: "/subscriptions/sub/resourcegroups/sec/providers/ms/userAssignedIdentities/secId" + load_balancer: "/subscriptions/sub/resourcegroups/sec/providers/ms/loadBalancers/sec-lb" + load_balancer_addresses: + - 10.21.200.1 + - 10.21.200.2 + state: present + app_id: key_name + key_file: "az-admin-private-key.pem" + +- name: Delete storage endpoint foo in AZ bar + purestorage.fusion.fusion_se: + name: foo + availability_zone: bar + region: us-west + state: absent + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + +- name: (DEPRECATED) Create new storage endpoint foo in AZ bar + purestorage.fusion.fusion_se: + name: foo + availability_zone: bar + gateway: 10.21.200.1 + region: us-west + addresses: + - 10.21.200.124/24 + - 10.21.200.36/24 + network_interface_groups: + - subnet-0 + state: present + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" +""" + +RETURN = r""" +""" + +try: + import fusion as purefusion +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + fusion_argument_spec, +) + +from ansible_collections.purestorage.fusion.plugins.module_utils.networking import ( + is_valid_network, + is_valid_address, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.startup import ( + setup_fusion, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.operations import ( + await_operation, +) + + +####################################################################### +# DEPRECATED CODE SECTION STARTS + + +def create_se_old(module, fusion): + """Create Storage Endpoint""" + + se_api_instance = purefusion.StorageEndpointsApi(fusion) + + changed = True + + if not module.check_mode: + if not module.params["display_name"]: + display_name = module.params["name"] + else: + display_name = module.params["display_name"] + ifaces = [] + for address in module.params["addresses"]: + if module.params["gateway"]: + iface = purefusion.StorageEndpointIscsiDiscoveryInterfacePost( + address=address, + gateway=module.params["gateway"], + network_interface_groups=module.params["network_interface_groups"], + ) + else: + iface = purefusion.StorageEndpointIscsiDiscoveryInterfacePost( + address=address, + network_interface_groups=module.params["network_interface_groups"], + ) + ifaces.append(iface) + op = purefusion.StorageEndpointPost( + endpoint_type="iscsi", + iscsi=purefusion.StorageEndpointIscsiPost( + discovery_interfaces=ifaces, + ), + name=module.params["name"], + display_name=display_name, + ) + op = se_api_instance.create_storage_endpoint( + op, + region_name=module.params["region"], + availability_zone_name=module.params["availability_zone"], + ) + await_operation(fusion, op) + + module.exit_json(changed=changed) + + +# DEPRECATED CODE SECTION ENDS +####################################################################### + + +def get_se(module, fusion): + """Storage Endpoint or None""" + se_api_instance = purefusion.StorageEndpointsApi(fusion) + try: + return se_api_instance.get_storage_endpoint( + region_name=module.params["region"], + storage_endpoint_name=module.params["name"], + availability_zone_name=module.params["availability_zone"], + ) + except purefusion.rest.ApiException: + return None + + +def create_se(module, fusion): + """Create Storage Endpoint""" + se_api_instance = purefusion.StorageEndpointsApi(fusion) + + if not module.check_mode: + endpoint_type = None + + iscsi = None + if module.params["iscsi"] is not None: + iscsi = purefusion.StorageEndpointIscsiPost( + discovery_interfaces=[ + purefusion.StorageEndpointIscsiDiscoveryInterfacePost(**endpoint) + for endpoint in module.params["iscsi"] + ] + ) + endpoint_type = "iscsi" + + cbs_azure_iscsi = None + if module.params["cbs_azure_iscsi"] is not None: + cbs_azure_iscsi = purefusion.StorageEndpointCbsAzureIscsiPost( + storage_endpoint_collection_identity=module.params["cbs_azure_iscsi"][ + "storage_endpoint_collection_identity" + ], + load_balancer=module.params["cbs_azure_iscsi"]["load_balancer"], + load_balancer_addresses=module.params["cbs_azure_iscsi"][ + "load_balancer_addresses" + ], + ) + endpoint_type = "cbs-azure-iscsi" + + op = se_api_instance.create_storage_endpoint( + purefusion.StorageEndpointPost( + name=module.params["name"], + display_name=module.params["display_name"] or module.params["name"], + endpoint_type=endpoint_type, + iscsi=iscsi, + cbs_azure_iscsi=cbs_azure_iscsi, + ), + region_name=module.params["region"], + availability_zone_name=module.params["availability_zone"], + ) + await_operation(fusion, op) + + module.exit_json(changed=True) + + +def delete_se(module, fusion): + """Delete Storage Endpoint""" + se_api_instance = purefusion.StorageEndpointsApi(fusion) + if not module.check_mode: + op = se_api_instance.delete_storage_endpoint( + region_name=module.params["region"], + availability_zone_name=module.params["availability_zone"], + storage_endpoint_name=module.params["name"], + ) + await_operation(fusion, op) + module.exit_json(changed=True) + + +def update_se(module, fusion, se): + """Update Storage Endpoint""" + + se_api_instance = purefusion.StorageEndpointsApi(fusion) + patches = [] + if ( + module.params["display_name"] + and module.params["display_name"] != se.display_name + ): + patch = purefusion.StorageEndpointPatch( + display_name=purefusion.NullableString(module.params["display_name"]), + ) + patches.append(patch) + + if not module.check_mode: + for patch in patches: + op = se_api_instance.update_storage_endpoint( + patch, + region_name=module.params["region"], + availability_zone_name=module.params["availability_zone"], + storage_endpoint_name=module.params["name"], + ) + await_operation(fusion, op) + + changed = len(patches) != 0 + + module.exit_json(changed=changed) + + +def main(): + """Main code""" + argument_spec = fusion_argument_spec() + argument_spec.update( + dict( + name=dict(type="str", required=True), + display_name=dict(type="str"), + region=dict(type="str", required=True), + availability_zone=dict(type="str", required=True, aliases=["az"]), + iscsi=dict( + type="list", + elements="dict", + options=dict( + address=dict(type="str"), + gateway=dict(type="str"), + network_interface_groups=dict(type="list", elements="str"), + ), + ), + cbs_azure_iscsi=dict( + type="dict", + options=dict( + storage_endpoint_collection_identity=dict(type="str"), + load_balancer=dict(type="str"), + load_balancer_addresses=dict(type="list", elements="str"), + ), + ), + state=dict(type="str", default="present", choices=["absent", "present"]), + # deprecated, will be removed in 2.0.0 + endpoint_type=dict( + type="str", + removed_in_version="2.0.0", + removed_from_collection="purestorage.fusion", + ), + addresses=dict( + type="list", + elements="str", + removed_in_version="2.0.0", + removed_from_collection="purestorage.fusion", + ), + gateway=dict( + type="str", + removed_in_version="2.0.0", + removed_from_collection="purestorage.fusion", + ), + network_interface_groups=dict( + type="list", + elements="str", + removed_in_version="2.0.0", + removed_from_collection="purestorage.fusion", + ), + ) + ) + + mutually_exclusive = [ + ("iscsi", "cbs_azure_iscsi"), + # can not use both deprecated and new fields at the same time + ("iscsi", "cbs_azure_iscsi", "addresses"), + ("iscsi", "cbs_azure_iscsi", "gateway"), + ("iscsi", "cbs_azure_iscsi", "network_interface_groups"), + ] + + module = AnsibleModule( + argument_spec, + mutually_exclusive=mutually_exclusive, + supports_check_mode=True, + ) + fusion = setup_fusion(module) + + state = module.params["state"] + + if module.params["endpoint_type"] is not None: + module.warn( + "'endpoint_type' parameter is deprecated and will be removed in the version 2.0" + ) + + deprecated_parameters = {"addresses", "gateway", "network_interface_groups"} + used_deprecated_parameters = [ + key + for key in list(deprecated_parameters & module.params.keys()) + if module.params[key] is not None + ] + + if len(used_deprecated_parameters) > 0: + # user uses deprecated module interface + for param_name in used_deprecated_parameters: + module.warn( + f"'{param_name}' parameter is deprecated and will be removed in the version 2.0" + ) + + if module.params["addresses"]: + for address in module.params["addresses"]: + if not is_valid_network(address): + module.fail_json( + msg=f"'{address}' is not a valid address in CIDR notation" + ) + + sendp = get_se(module, fusion) + + if state == "present" and not sendp: + module.fail_on_missing_params(["addresses"]) + if not (module.params["addresses"]): + module.fail_json( + msg="At least one entry in 'addresses' is required to create new storage endpoint" + ) + create_se_old(module, fusion) + elif state == "present" and sendp: + update_se(module, fusion, sendp) + elif state == "absent" and sendp: + delete_se(module, fusion) + else: + # user uses new module interface + if module.params["iscsi"] is not None: + for endpoint in module.params["iscsi"]: + address = endpoint["address"] + if not is_valid_network(address): + module.fail_json( + msg=f"'{address}' is not a valid address in CIDR notation" + ) + gateway = endpoint["gateway"] + if not is_valid_address(gateway): + module.fail_json( + msg=f"'{gateway}' is not a valid IPv4 address notation" + ) + if module.params["cbs_azure_iscsi"] is not None: + for address in module.params["cbs_azure_iscsi"]["load_balancer_addresses"]: + if not is_valid_address(address): + module.fail_json( + msg=f"'{address}' is not a valid IPv4 address notation" + ) + + sendp = get_se(module, fusion) + + if state == "present" and not sendp: + if ( + module.params["iscsi"] is None + and module.params["cbs_azure_iscsi"] is None + ): + module.fail_json( + msg="either 'iscsi' or `cbs_azure_iscsi` parameter is required when creating storage endpoint" + ) + create_se(module, fusion) + elif state == "present" and sendp: + update_se(module, fusion, sendp) + elif state == "absent" and sendp: + delete_se(module, fusion) + + module.exit_json(changed=False) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/purestorage/fusion/plugins/modules/fusion_ss.py b/ansible_collections/purestorage/fusion/plugins/modules/fusion_ss.py new file mode 100644 index 000000000..3fdbb07dd --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/modules/fusion_ss.py @@ -0,0 +1,208 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2022, Simon Dodsley (simon@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: fusion_ss +version_added: '1.0.0' +short_description: Manage storage services in Pure Storage Fusion +description: +- Manage a storage services in Pure Storage Fusion. +author: +- Pure Storage Ansible Team (@sdodsley) <pure-ansible-team@purestorage.com> +notes: +- Supports C(check mode). +options: + name: + description: + - The name of the storage service. + type: str + required: true + state: + description: + - Define whether the storage service should exist or not. + default: present + choices: [ present, absent ] + type: str + display_name: + description: + - The human name of the storage service. + - If not provided, defaults to I(name). + type: str + hardware_types: + description: + - Hardware types to which the storage service applies. + type: list + elements: str + choices: [ flash-array-x, flash-array-c, flash-array-x-optane, flash-array-xl ] +extends_documentation_fragment: +- purestorage.fusion.purestorage.fusion +""" + +EXAMPLES = r""" +- name: Create new storage service foo + purestorage.fusion.fusion_ss: + name: foo + hardware_types: + - flash-array-x + - flash-array-x-optane + display_name: "test class" + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + +- name: Update storage service + purestorage.fusion.fusion_ss: + name: foo + display_name: "main class" + hardware_types: + - flash-array-c + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + +- name: Delete storage service + purestorage.fusion.fusion_ss: + name: foo + state: absent + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" +""" + +RETURN = r""" +""" + +try: + import fusion as purefusion +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + fusion_argument_spec, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.startup import ( + setup_fusion, +) +from ansible_collections.purestorage.fusion.plugins.module_utils import getters +from ansible_collections.purestorage.fusion.plugins.module_utils.operations import ( + await_operation, +) + + +def get_ss(module, fusion): + """Return Storage Service or None""" + return getters.get_ss(module, fusion, storage_service_name=module.params["name"]) + + +def create_ss(module, fusion): + """Create Storage Service""" + + ss_api_instance = purefusion.StorageServicesApi(fusion) + + changed = True + if not module.check_mode: + if not module.params["display_name"]: + display_name = module.params["name"] + else: + display_name = module.params["display_name"] + s_service = purefusion.StorageServicePost( + name=module.params["name"], + display_name=display_name, + hardware_types=module.params["hardware_types"], + ) + op = ss_api_instance.create_storage_service(s_service) + await_operation(fusion, op) + + module.exit_json(changed=changed) + + +def delete_ss(module, fusion): + """Delete Storage Service""" + + ss_api_instance = purefusion.StorageServicesApi(fusion) + + changed = True + if not module.check_mode: + op = ss_api_instance.delete_storage_service( + storage_service_name=module.params["name"] + ) + await_operation(fusion, op) + + module.exit_json(changed=changed) + + +def update_ss(module, fusion, ss): + """Update Storage Service""" + + ss_api_instance = purefusion.StorageServicesApi(fusion) + patches = [] + if ( + module.params["display_name"] + and module.params["display_name"] != ss.display_name + ): + patch = purefusion.StorageServicePatch( + display_name=purefusion.NullableString(module.params["display_name"]), + ) + patches.append(patch) + + if not module.check_mode: + for patch in patches: + op = ss_api_instance.update_storage_service( + patch, + storage_service_name=module.params["name"], + ) + await_operation(fusion, op) + + changed = len(patches) != 0 + + module.exit_json(changed=changed) + + +def main(): + """Main code""" + argument_spec = fusion_argument_spec() + argument_spec.update( + dict( + name=dict(type="str", required=True), + display_name=dict(type="str"), + hardware_types=dict( + type="list", + elements="str", + choices=[ + "flash-array-x", + "flash-array-c", + "flash-array-x-optane", + "flash-array-xl", + ], + ), + state=dict(type="str", default="present", choices=["present", "absent"]), + ) + ) + + module = AnsibleModule(argument_spec, supports_check_mode=True) + fusion = setup_fusion(module) + + state = module.params["state"] + s_service = get_ss(module, fusion) + + if not s_service and state == "present": + module.fail_on_missing_params(["hardware_types"]) + create_ss(module, fusion) + elif s_service and state == "present": + update_ss(module, fusion, s_service) + elif s_service and state == "absent": + delete_ss(module, fusion) + else: + module.exit_json(changed=False) + + module.exit_json(changed=False) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/purestorage/fusion/plugins/modules/fusion_tenant.py b/ansible_collections/purestorage/fusion/plugins/modules/fusion_tenant.py new file mode 100644 index 000000000..96e890a6b --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/modules/fusion_tenant.py @@ -0,0 +1,169 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2022, Simon Dodsley (simon@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: fusion_tenant +version_added: '1.0.0' +short_description: Manage tenants in Pure Storage Fusion +description: +- Create,delete or update a tenant in Pure Storage Fusion. +author: +- Pure Storage Ansible Team (@sdodsley) <pure-ansible-team@purestorage.com> +notes: +- Supports C(check mode). +options: + name: + description: + - The name of the tenant. + type: str + required: true + state: + description: + - Define whether the tenant should exist or not. + default: present + choices: [ present, absent ] + type: str + display_name: + description: + - The human name of the tenant. + - If not provided, defaults to I(name). + type: str +extends_documentation_fragment: +- purestorage.fusion.purestorage.fusion +""" + +EXAMPLES = r""" +- name: Create new tenat foo + purestorage.fusion.fusion_tenant: + name: foo + display_name: "tenant foo" + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + +- name: Delete tenat foo + purestorage.fusion.fusion_tenant: + name: foo + state: absent + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" +""" + +RETURN = r""" +""" + +try: + import fusion as purefusion +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + fusion_argument_spec, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.startup import ( + setup_fusion, +) +from ansible_collections.purestorage.fusion.plugins.module_utils import getters +from ansible_collections.purestorage.fusion.plugins.module_utils.operations import ( + await_operation, +) + + +def get_tenant(module, fusion): + """Return Tenant or None""" + return getters.get_tenant(module, fusion, tenant_name=module.params["name"]) + + +def create_tenant(module, fusion): + """Create Tenant""" + + api_instance = purefusion.TenantsApi(fusion) + changed = True + if not module.check_mode: + if not module.params["display_name"]: + display_name = module.params["name"] + else: + display_name = module.params["display_name"] + tenant = purefusion.TenantPost( + name=module.params["name"], + display_name=display_name, + ) + op = api_instance.create_tenant(tenant) + await_operation(fusion, op) + + module.exit_json(changed=changed) + + +def update_tenant(module, fusion, tenant): + """Update Tenant settings""" + changed = False + api_instance = purefusion.TenantsApi(fusion) + + if ( + module.params["display_name"] + and module.params["display_name"] != tenant.display_name + ): + changed = True + if not module.check_mode: + new_tenant = purefusion.TenantPatch( + display_name=purefusion.NullableString(module.params["display_name"]), + ) + op = api_instance.update_tenant( + new_tenant, + tenant_name=module.params["name"], + ) + await_operation(fusion, op) + + module.exit_json(changed=changed) + + +def delete_tenant(module, fusion): + """Delete Tenant""" + changed = True + api_instance = purefusion.TenantsApi(fusion) + if not module.check_mode: + op = api_instance.delete_tenant(tenant_name=module.params["name"]) + await_operation(fusion, op) + + module.exit_json(changed=changed) + + +def main(): + """Main code""" + argument_spec = fusion_argument_spec() + argument_spec.update( + dict( + name=dict(type="str", required=True), + display_name=dict(type="str"), + state=dict(type="str", default="present", choices=["present", "absent"]), + ) + ) + + module = AnsibleModule(argument_spec, supports_check_mode=True) + fusion = setup_fusion(module) + + state = module.params["state"] + tenant = get_tenant(module, fusion) + + if not tenant and state == "present": + create_tenant(module, fusion) + elif tenant and state == "present": + update_tenant(module, fusion, tenant) + elif tenant and state == "absent": + delete_tenant(module, fusion) + else: + module.exit_json(changed=False) + + module.exit_json(changed=False) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/purestorage/fusion/plugins/modules/fusion_tn.py b/ansible_collections/purestorage/fusion/plugins/modules/fusion_tn.py new file mode 100644 index 000000000..717b1e46f --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/modules/fusion_tn.py @@ -0,0 +1,122 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2022, Simon Dodsley (simon@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: fusion_tn +version_added: '1.0.0' +deprecated: + removed_at_date: "2023-07-26" + why: Tenant Networks were removed as a concept in Pure Storage Fusion + alternative: most of the functionality can be replicated using M(purestorage.fusion.fusion_se) and M(purestorage.fusion.fusion_nig) +short_description: Manage tenant networks in Pure Storage Fusion +description: +- Create or delete tenant networks in Pure Storage Fusion. +notes: +- Supports C(check_mode). +- Currently this only supports a single tenant subnet per tenant network. +author: +- Pure Storage Ansible Team (@sdodsley) <pure-ansible-team@purestorage.com> +options: + name: + description: + - The name of the tenant network. + type: str + display_name: + description: + - The human name of the tenant network. + - If not provided, defaults to I(name). + type: str + state: + description: + - Define whether the tenant network should exist or not. + type: str + default: present + choices: [ absent, present ] + region: + description: + - The name of the region the availability zone is in + type: str + availability_zone: + aliases: [ az ] + description: + - The name of the availability zone for the tenant network. + type: str + provider_subnets: + description: + - List of provider subnets to assign to the tenant networks subnet. + type: list + elements: str + addresses: + description: + - List of IP addresses to be used in the subnet of the tenant network. + - IP addresses must include a CIDR notation. + - IPv4 and IPv6 are fully supported. + type: list + elements: str + gateway: + description: + - Address of the subnet gateway. + - Currently this must be provided. + type: str + mtu: + description: + - MTU setting for the subnet. + default: 1500 + type: int + prefix: + description: + - Network prefix in CIDR format. + - This will be deprecated soon. + type: str +extends_documentation_fragment: +- purestorage.fusion.purestorage.fusion +""" + +# this module does nothing, thus no example is provided +EXAMPLES = r""" +""" + +RETURN = r""" +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + fusion_argument_spec, +) + + +def main(): + """Main code""" + argument_spec = fusion_argument_spec() + argument_spec.update( + dict( + name=dict(type="str"), + region=dict(type="str"), + display_name=dict(type="str"), + availability_zone=dict(type="str", aliases=["az"]), + prefix=dict(type="str"), + gateway=dict(type="str"), + mtu=dict(type="int", default=1500), + provider_subnets=dict(type="list", elements="str"), + addresses=dict(type="list", elements="str"), + state=dict(type="str", default="present", choices=["absent", "present"]), + ) + ) + module = AnsibleModule(argument_spec, supports_check_mode=True) + module.warn( + "This module is deprecated, doesn't work, and will be removed in the version 2.0." + " Please, use purestorage.fusion.fusion_se and purestorage.fusion.fusion_nig instead." + ) + module.exit_json(changed=False) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/purestorage/fusion/plugins/modules/fusion_ts.py b/ansible_collections/purestorage/fusion/plugins/modules/fusion_ts.py new file mode 100644 index 000000000..33fb0187a --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/modules/fusion_ts.py @@ -0,0 +1,187 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2022, Simon Dodsley (simon@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: fusion_ts +version_added: '1.0.0' +short_description: Manage tenant spaces in Pure Storage Fusion +description: +- Create, update or delete a tenant spaces in Pure Storage Fusion. +author: +- Pure Storage Ansible Team (@sdodsley) <pure-ansible-team@purestorage.com> +notes: +- Supports C(check mode). +options: + name: + description: + - The name of the tenant space. + type: str + required: true + display_name: + description: + - The human name of the tenant space. + - If not provided, defaults to I(name). + type: str + state: + description: + - Define whether the tenant space should exist or not. + type: str + default: present + choices: [ absent, present ] + tenant: + description: + - The name of the tenant. + type: str + required: true +extends_documentation_fragment: +- purestorage.fusion.purestorage.fusion +""" + +EXAMPLES = r""" +- name: Create new tenant space foo for tenant bar + purestorage.fusion.fusion_ts: + name: foo + tenant: bar + state: present + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + +- name: Delete tenant space foo in tenant bar + purestorage.fusion.fusion_ts: + name: foo + tenant: bar + state: absent + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" +""" + +RETURN = r""" +""" + +try: + import fusion as purefusion +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + fusion_argument_spec, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.startup import ( + setup_fusion, +) +from ansible_collections.purestorage.fusion.plugins.module_utils import getters +from ansible_collections.purestorage.fusion.plugins.module_utils.operations import ( + await_operation, +) + + +def get_ts(module, fusion): + """Tenant Space or None""" + return getters.get_ts(module, fusion, tenant_space_name=module.params["name"]) + + +def create_ts(module, fusion): + """Create Tenant Space""" + + ts_api_instance = purefusion.TenantSpacesApi(fusion) + + changed = True + if not module.check_mode: + if not module.params["display_name"]: + display_name = module.params["name"] + else: + display_name = module.params["display_name"] + tspace = purefusion.TenantSpacePost( + name=module.params["name"], + display_name=display_name, + ) + op = ts_api_instance.create_tenant_space( + tspace, + tenant_name=module.params["tenant"], + ) + await_operation(fusion, op) + + module.exit_json(changed=changed) + + +def update_ts(module, fusion, ts): + """Update Tenant Space""" + + ts_api_instance = purefusion.TenantSpacesApi(fusion) + patches = [] + if ( + module.params["display_name"] + and module.params["display_name"] != ts.display_name + ): + patch = purefusion.TenantSpacePatch( + display_name=purefusion.NullableString(module.params["display_name"]), + ) + patches.append(patch) + + if not module.check_mode: + for patch in patches: + op = ts_api_instance.update_tenant_space( + patch, + tenant_name=module.params["tenant"], + tenant_space_name=module.params["name"], + ) + await_operation(fusion, op) + + changed = len(patches) != 0 + + module.exit_json(changed=changed) + + +def delete_ts(module, fusion): + """Delete Tenant Space""" + changed = True + ts_api_instance = purefusion.TenantSpacesApi(fusion) + if not module.check_mode: + op = ts_api_instance.delete_tenant_space( + tenant_name=module.params["tenant"], + tenant_space_name=module.params["name"], + ) + await_operation(fusion, op) + + module.exit_json(changed=changed) + + +def main(): + """Main code""" + argument_spec = fusion_argument_spec() + argument_spec.update( + dict( + name=dict(type="str", required=True), + display_name=dict(type="str"), + tenant=dict(type="str", required=True), + state=dict(type="str", default="present", choices=["absent", "present"]), + ) + ) + + module = AnsibleModule(argument_spec, supports_check_mode=True) + fusion = setup_fusion(module) + + state = module.params["state"] + tspace = get_ts(module, fusion) + + if state == "present" and not tspace: + create_ts(module, fusion) + elif state == "present" and tspace: + update_ts(module, fusion, tspace) + elif state == "absent" and tspace: + delete_ts(module, fusion) + + module.exit_json(changed=False) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/purestorage/fusion/plugins/modules/fusion_volume.py b/ansible_collections/purestorage/fusion/plugins/modules/fusion_volume.py new file mode 100644 index 000000000..5b19064f5 --- /dev/null +++ b/ansible_collections/purestorage/fusion/plugins/modules/fusion_volume.py @@ -0,0 +1,450 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2023, Simon Dodsley (simon@purestorage.com), Jan Kodera (jkodera@purestorage.com) +# GNU General Public License v3.0+ (see COPYING.GPLv3 or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: fusion_volume +version_added: '1.0.0' +short_description: Manage volumes in Pure Storage Fusion +description: +- Create, update or delete a volume in Pure Storage Fusion. +author: +- Pure Storage Ansible Team (@sdodsley) <pure-ansible-team@purestorage.com> +notes: +- Supports C(check mode). +options: + name: + description: + - The name of the volume. + type: str + required: true + display_name: + description: + - The human name of the volume. + - If not provided, defaults to I(name). + type: str + state: + description: + - Define whether the volume should exist or not. + type: str + default: present + choices: [ absent, present ] + tenant: + description: + - The name of the tenant. + type: str + required: true + tenant_space: + description: + - The name of the tenant space. + type: str + required: true + eradicate: + description: + - "Wipes the volume instead of a soft delete if true. Must be used with `state: absent`." + type: bool + default: false + size: + description: + - Volume size in M, G, T or P units. + type: str + storage_class: + description: + - The name of the storage class. + type: str + placement_group: + description: + - The name of the placement group. + type: str + protection_policy: + description: + - The name of the protection policy. + type: str + host_access_policies: + description: + - 'A list of host access policies to connect the volume to. + To clear, assign empty list: host_access_policies: []' + type: list + elements: str + rename: + description: + - New name for volume. + type: str +extends_documentation_fragment: +- purestorage.fusion.purestorage.fusion +""" + +EXAMPLES = r""" +- name: Create new volume named foo in storage_class fred + purestorage.fusion.fusion_volume: + name: foo + storage_class: fred + size: 1T + tenant: test + tenant_space: space_1 + state: present + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + +- name: Extend the size of an existing volume named foo + purestorage.fusion.fusion_volume: + name: foo + size: 2T + tenant: test + tenant_space: space_1 + state: present + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" + +- name: Delete volume named foo + purestorage.fusion.fusion_volume: + name: foo + tenant: test + tenant_space: space_1 + state: absent + issuer_id: key_name + private_key_file: "az-admin-private-key.pem" +""" + +RETURN = r""" +""" + +try: + import fusion as purefusion +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.fusion.plugins.module_utils.fusion import ( + fusion_argument_spec, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.parsing import ( + parse_number_with_metric_suffix, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.startup import ( + setup_fusion, +) +from ansible_collections.purestorage.fusion.plugins.module_utils.operations import ( + await_operation, +) + + +def get_volume(module, fusion): + """Return Volume or None""" + volume_api_instance = purefusion.VolumesApi(fusion) + try: + return volume_api_instance.get_volume( + tenant_name=module.params["tenant"], + tenant_space_name=module.params["tenant_space"], + volume_name=module.params["name"], + ) + except purefusion.rest.ApiException: + return None + + +def get_wanted_haps(module): + """Return set of host access policies to assign""" + if not module.params["host_access_policies"]: + return set() + # looks like yaml parsing can leave in some spaces if coma-delimited .so strip() the names + return set([hap.strip() for hap in module.params["host_access_policies"]]) + + +def extract_current_haps(volume): + """Return set of host access policies that volume currently has""" + if not volume.host_access_policies: + return set() + return set([hap.name for hap in volume.host_access_policies]) + + +def create_volume(module, fusion): + """Create Volume""" + + size = parse_number_with_metric_suffix(module, module.params["size"]) + + if not module.check_mode: + display_name = module.params["display_name"] or module.params["name"] + volume_api_instance = purefusion.VolumesApi(fusion) + volume = purefusion.VolumePost( + size=size, + storage_class=module.params["storage_class"], + placement_group=module.params["placement_group"], + name=module.params["name"], + display_name=display_name, + protection_policy=module.params["protection_policy"], + ) + op = volume_api_instance.create_volume( + volume, + tenant_name=module.params["tenant"], + tenant_space_name=module.params["tenant_space"], + ) + await_operation(fusion, op) + + return True + + +def update_host_access_policies(module, current, patches): + wanted = module.params + # 'wanted[...] is not None' to differentiate between empty list and no list + if wanted["host_access_policies"] is not None: + current_haps = extract_current_haps(current) + wanted_haps = get_wanted_haps(module) + if wanted_haps != current_haps: + patch = purefusion.VolumePatch( + host_access_policies=purefusion.NullableString(",".join(wanted_haps)) + ) + patches.append(patch) + + +def update_destroyed(module, current, patches): + wanted = module.params + destroyed = wanted["state"] != "present" + if destroyed != current.destroyed: + patch = purefusion.VolumePatch(destroyed=purefusion.NullableBoolean(destroyed)) + patches.append(patch) + if destroyed and not module.params["eradicate"]: + module.warn( + ( + "Volume '{0}' is being soft deleted to prevent data loss, " + "if you want to wipe it immediately to reclaim used space, add 'eradicate: true'" + ).format(current.name) + ) + + +def update_display_name(module, current, patches): + wanted = module.params + if wanted["display_name"] and wanted["display_name"] != current.display_name: + patch = purefusion.VolumePatch( + display_name=purefusion.NullableString(wanted["display_name"]) + ) + patches.append(patch) + + +def update_storage_class(module, current, patches): + wanted = module.params + if ( + wanted["storage_class"] + and wanted["storage_class"] != current.storage_class.name + ): + patch = purefusion.VolumePatch( + storage_class=purefusion.NullableString(wanted["storage_class"]) + ) + patches.append(patch) + + +def update_placement_group(module, current, patches): + wanted = module.params + if ( + wanted["placement_group"] + and wanted["placement_group"] != current.placement_group.name + ): + patch = purefusion.VolumePatch( + placement_group=purefusion.NullableString(wanted["placement_group"]) + ) + patches.append(patch) + + +def update_size(module, current, patches): + wanted = module.params + if wanted["size"]: + wanted_size = parse_number_with_metric_suffix(module, wanted["size"]) + if wanted_size != current.size: + patch = purefusion.VolumePatch(size=purefusion.NullableSize(wanted_size)) + patches.append(patch) + + +def update_protection_policy(module, current, patches): + wanted = module.params + current_policy = current.protection_policy.name if current.protection_policy else "" + if ( + wanted["protection_policy"] is not None + and wanted["protection_policy"] != current_policy + ): + patch = purefusion.VolumePatch( + protection_policy=purefusion.NullableString(wanted["protection_policy"]) + ) + patches.append(patch) + + +def apply_patches(module, fusion, patches): + volume_api_instance = purefusion.VolumesApi(fusion) + for patch in patches: + op = volume_api_instance.update_volume( + patch, + volume_name=module.params["name"], + tenant_name=module.params["tenant"], + tenant_space_name=module.params["tenant_space"], + ) + await_operation(fusion, op) + + +def update_volume(module, fusion): + """Update Volume size, placement group, protection policy, storage class, HAPs""" + current = get_volume(module, fusion) + patches = [] + + if not current: + # cannot update nonexistent volume + # Note for check mode: the reasons this codepath is ran in check mode + # is to catch any argument errors and to compute 'changed'. Basically + # all argument checks are kept in validate_arguments() to filter the + # first part. The second part MAY diverge flow from the real run here if + # create_volume() created the volume and update was then run to update + # its properties. HOWEVER we don't really care in that case because + # create_volume() already sets 'changed' to true, so any 'changed' + # result from update_volume() would not change it. + return False + + # volumes with 'destroyed' flag are kinda special because we can't change + # most of their properties while in this state, so we need to set it last + # and unset it first if changed, respectively + if module.params["state"] == "present": + update_destroyed(module, current, patches) + update_size(module, current, patches) + update_protection_policy(module, current, patches) + update_display_name(module, current, patches) + update_storage_class(module, current, patches) + update_placement_group(module, current, patches) + update_host_access_policies(module, current, patches) + elif module.params["state"] == "absent" and not current.destroyed: + update_size(module, current, patches) + update_protection_policy(module, current, patches) + update_display_name(module, current, patches) + update_storage_class(module, current, patches) + update_placement_group(module, current, patches) + update_host_access_policies(module, current, patches) + update_destroyed(module, current, patches) + + if not module.check_mode: + apply_patches(module, fusion, patches) + + changed = len(patches) != 0 + return changed + + +def eradicate_volume(module, fusion): + """Eradicate Volume""" + current = get_volume(module, fusion) + if module.check_mode: + return current or module.params["state"] == "present" + if not current: + return False + + # update_volume() should be called before eradicate=True and it should + # ensure the volume is destroyed and HAPs are unassigned + if not current.destroyed or current.host_access_policies: + module.fail_json( + msg="BUG: inconsistent state, eradicate_volume() cannot be called with current.destroyed=False or any host_access_policies" + ) + + volume_api_instance = purefusion.VolumesApi(fusion) + op = volume_api_instance.delete_volume( + volume_name=module.params["name"], + tenant_name=module.params["tenant"], + tenant_space_name=module.params["tenant_space"], + ) + await_operation(fusion, op) + + return True + + +def validate_arguments(module, volume): + """Validates most argument conditions and possible unacceptable argument combinations""" + state = module.params["state"] + + if state == "present" and not volume: + module.fail_on_missing_params(["placement_group", "storage_class", "size"]) + + if module.params["state"] == "absent" and ( + module.params["host_access_policies"] + or (volume and volume.host_access_policies) + ): + module.fail_json( + msg=( + "Volume must have no host access policies when destroyed, either revert the delete " + "by setting 'state: present' or remove all HAPs by 'host_access_policies: []'" + ) + ) + + if state == "present" and module.params["eradicate"]: + module.fail_json( + msg="'eradicate: true' cannot be used together with 'state: present'" + ) + + if module.params["size"]: + size = parse_number_with_metric_suffix(module, module.params["size"]) + if size < 1048576 or size > 4503599627370496: # 1MB to 4PB + module.fail_json( + msg="Size is not within the required range, size must be between 1MB and 4PB" + ) + + +def main(): + """Main code""" + argument_spec = fusion_argument_spec() + deprecated_hosts = dict( + name="hosts", date="2023-07-26", collection_name="purefusion.fusion" + ) + argument_spec.update( + dict( + name=dict(type="str", required=True), + display_name=dict(type="str"), + rename=dict( + type="str", + removed_at_date="2023-07-26", + removed_from_collection="purestorage.fusion", + ), + tenant=dict(type="str", required=True), + tenant_space=dict(type="str", required=True), + placement_group=dict(type="str"), + storage_class=dict(type="str"), + protection_policy=dict(type="str"), + host_access_policies=dict( + type="list", elements="str", deprecated_aliases=[deprecated_hosts] + ), + eradicate=dict(type="bool", default=False), + state=dict(type="str", default="present", choices=["absent", "present"]), + size=dict(type="str"), + ) + ) + + required_by = { + "placement_group": "storage_class", + } + + module = AnsibleModule( + argument_spec, + required_by=required_by, + supports_check_mode=True, + ) + fusion = setup_fusion(module) + + state = module.params["state"] + + volume = get_volume(module, fusion) + + validate_arguments(module, volume) + + if state == "absent" and not volume: + module.exit_json(changed=False) + + changed = False + if state == "present" and not volume: + changed = changed | create_volume(module, fusion) + # volume might exist even if soft-deleted, so we still have to update it + changed = changed | update_volume(module, fusion) + if module.params["eradicate"]: + changed = changed | eradicate_volume(module, fusion) + + module.exit_json(changed=changed) + + +if __name__ == "__main__": + main() |