diff --git a/nutanix/__init__.py b/nutanix/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nutanix/ncp/README.md b/nutanix/ncp/README.md new file mode 100644 index 000000000..41bcf48cd --- /dev/null +++ b/nutanix/ncp/README.md @@ -0,0 +1,48 @@ +# Nutanix Ansible Collection - nutanix.ncp +Ansible collections to automate Nutanix Cloud Platform (ncp). + +# Building and installing the collection locally +``` +ansible-galaxy collection build +ansible-galaxy collection install nutanix.ncp-1.0.0.tar.gz +``` + +##or + +### Installing the collection from GitHub repository +``` +ansible-galaxy collection install git+https://github.com/nutanix/nutanix.ansible.git#nutanix, +``` +_Add `--force` option for rebuilding or reinstalling to overwrite existing data_ + +# Included modules +``` +nutanix_vms +nutanix_images +nutanix_subnets +``` + +# Inventory plugin +`ncp_prism_vm_inventory` + +# Module documentation and examples +``` +ansible-doc nutanix.ncp. +``` + +# Examples +## Playbook to print name of vms in PC +``` +- hosts: localhost + collections: + - nutanix.ncp + tasks: + - ncp_prism_vm_info: + pc_hostname: {{ pc_hostname }} + pc_username: {{ pc_username }} + pc_password: {{ pc_password }} + validate_certs: False + register: result + - debug: + msg: "{{ result.vms }}" +``` \ No newline at end of file diff --git a/nutanix/ncp/__init__.py b/nutanix/ncp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nutanix/ncp/galaxy.yml b/nutanix/ncp/galaxy.yml new file mode 100644 index 000000000..24f3eb21d --- /dev/null +++ b/nutanix/ncp/galaxy.yml @@ -0,0 +1,17 @@ +namespace: "nutanix" +name: "ncp" +version: "1.0.0" +readme: "README.md" +authors: + - "Balu George (@balugeorge)" + - "Sarath Kumar K (@kumarsarath588)" + - "Prem Karat (@premkarat)" + - "Gevorg Khachatryan (@Gevorg-Khachatryan-97)" +description: Ansible collection for v3 Nutanix APIs https://www.nutanix.dev/api-reference-v3/ +license_file: 'LICENSE' +tags: [nutanix, prism, ahv] +repository: https://github.com/nutanix/nutanix.ansible +documentation: https://github.com/nutanix/nutanix.ansible/nutanix/prism/docs +homepage: https://github.com/nutanix/nutanix.ansible/nutanix/prism +issues: https://github.com/nutanix/nutanix.ansible/issues +build_ignore: [] \ No newline at end of file diff --git a/nutanix/ncp/plugins/README.md b/nutanix/ncp/plugins/README.md new file mode 100644 index 000000000..97642535d --- /dev/null +++ b/nutanix/ncp/plugins/README.md @@ -0,0 +1,31 @@ +# Collections Plugins Directory + +This directory can be used to ship various plugins inside an Ansible collection. Each plugin is placed in a folder that +is named after the type of plugin it is in. It can also include the `module_utils` and `modules` directory that +would contain module utils and modules respectively. + +Here is an example directory of the majority of plugins currently supported by Ansible: + +``` +└── plugins + ├── action + ├── become + ├── cache + ├── callback + ├── cliconf + ├── connection + ├── filter + ├── httpapi + ├── inventory + ├── lookup + ├── module_utils + ├── modules + ├── netconf + ├── shell + ├── strategy + ├── terminal + ├── test + └── vars +``` + +A full list of plugin types can be found at [Working With Plugins](https://docs.ansible.com/ansible-core/devel/plugins/plugins.html). diff --git a/nutanix/ncp/plugins/module_utils/__int__.py b/nutanix/ncp/plugins/module_utils/__int__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nutanix/ncp/plugins/module_utils/base_module.py b/nutanix/ncp/plugins/module_utils/base_module.py new file mode 100644 index 000000000..b5e0c011f --- /dev/null +++ b/nutanix/ncp/plugins/module_utils/base_module.py @@ -0,0 +1,25 @@ +# Copyright: 2021, Ansible Project +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause ) +from ansible.module_utils.basic import AnsibleModule + + +class BaseModule(AnsibleModule): + """Basic module with common arguments""" + + argument_spec = dict( + action=dict(type="str", required=True), + auth=dict(type="dict", required=True), + data=dict(type="dict", required=False), + operations=dict(type="list", required=False), + wait=dict(type="bool", required=False, default=True), + wait_timeout=dict(type="int", required=False, default=300), + validate_certs=dict(type="bool", required=False, default=False), + ) + + def __init__(self, **kwargs): + if not kwargs.get("argument_spec"): + kwargs["argument_spec"] = self.argument_spec + else: + kwargs["argument_spec"].update(self.argument_spec) + kwargs["supports_check_mode"] = True + super(BaseModule, self).__init__(**kwargs) diff --git a/nutanix/ncp/plugins/module_utils/entity.py b/nutanix/ncp/plugins/module_utils/entity.py new file mode 100644 index 000000000..0e185f645 --- /dev/null +++ b/nutanix/ncp/plugins/module_utils/entity.py @@ -0,0 +1,212 @@ +# Copyright: 2021, Ansible Project +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause ) +from base64 import b64encode +import time +import uuid + +from ansible.module_utils.common.text.converters import to_text +from ansible.module_utils.urls import fetch_url +import json + +try: + from urllib.parse import urlparse +except ImportError: + from urlparse import urlparse + + +class Entity: + """Basic functionality for Nutanix modules""" + + result = dict( + changed=False, + original_message="", + message="" + ) + + methods_of_actions = { + "create": "post", + "list": "post", + "update": "put", + "delete": "delete" + } + kind = "" + api_version = "3.1.0" + __BASEURL__ = "" + + def __init__(self, module): + self.run_module(module) + + def parse_data(self): + if self.action != "list": + if self.data.get("metadata"): + if not self.data["metadata"].get("kind"): + self.data["metadata"].update({"kind": self.kind}) + else: + self.data.update({"metadata": {"kind": self.kind}}) + if not self.data.get("api_version"): + self.data["api_version"] = self.api_version + + def check_response(self): + + if self.response.get("task_uuid") and self.wait: + task = self.validate_request(self.module, self.response.get("task_uuid"), self.netloc, self.wait_timeout) + self.result["task_information"] = task + + if not self.response.get("status"): + if self.response.get("api_response_list"): + self.response["status"] = self.response.get("api_response_list")[0].get("api_response").get("status") + elif "entities" in self.response: + if self.response["entities"]: + self.response["status"] = self.response.get("entities")[0].get("status") + else: + self.response["status"] = {"state": "complete"} + + if self.response.get("status") and self.wait: + state = self.response.get("status").get("state") + if "pending" in state.lower() or "running" in state.lower(): + task = self.validate_request(self.module, + self.response.get("status").get("execution_context").get("task_uuid"), + self.netloc, + self.wait_timeout) + self.response["status"]["state"] = task.get("status") + self.result["task_information"] = task + + self.result["changed"] = True + status = self.response.get("state") or self.response.get("status").get("state") + if status and status.lower() != "succeeded" or self.action == "list": + self.result["changed"] = False + if status.lower() != "complete": + self.result["failed"] = True + + self.result["response"] = self.response + + def create(self): + pass + + def update(self): + item_uuid = self.data["metadata"]["uuid"] + if not self.data.get("operations"): + self.url += "/" + str(uuid.UUID(item_uuid)) + else: + self.url += "/" + str(uuid.UUID(item_uuid)) + "/file" + response = self.send_request(self.module, "get", self.url, self.data, self.username, self.password) + + if response.get("state") and response.get("state").lower() == "error": + self.result["changed"] = False + self.result["failed"] = True + self.result["message"] = response["message_list"] + else: + self.data["metadata"]["spec_version"] = response["metadata"]["spec_version"] + + def delete(self): + item_uuid = self.data["metadata"]["uuid"] + self.url += "/" + str(uuid.UUID(item_uuid)) + + def list(self): + self.url += "/" + self.action + if not self.data: + self.data = {"kind": self.kind} + + @staticmethod + def send_request(module, method, req_url, req_data, username, password, timeout=30): + try: + credentials = bytes(username + ":" + password, encoding="ascii") + except: + credentials = bytes(username + ":" + password).encode("ascii") + + encoded_credentials = b64encode(credentials).decode("ascii") + authorization = "Basic " + encoded_credentials + headers = {"Accept": "application/json", "Content-Type": "application/json", + "Authorization": authorization, "cache-control": "no-cache"} + if req_data is None: + payload = {} + else: + payload = req_data + resp, info = fetch_url(module=module, url=req_url, headers=headers, + method=method, data=module.jsonify(payload), timeout=timeout) + if not 300 > info['status'] > 199: + module.fail_json(msg="Fail: %s" % ("Status: " + str(info['msg']) + ", Message: " + str(info.get('body')))) + + body = resp.read() if resp else info.get("body") + try: + resp_json = json.loads(to_text(body)) if body else None + except ValueError: + resp_json = None + return resp_json + + def validate_request(self, module, task_uuid, netloc, wait_timeout): + timer = 5 + retries = wait_timeout // timer + response = None + succeeded = False + task_uuid = str(uuid.UUID(task_uuid)) + url = self.generate_url_from_operations("tasks", netloc, [task_uuid]) + while retries > 0 or not succeeded: + response = self.send_request(module, "get", url, None, self.username, self.password) + if response.get("status"): + status = response.get("status") + if "running" not in status.lower(): + succeeded = True + return response + time.sleep(timer) + retries -= 1 + return response + + def generate_url_from_operations(self, name, netloc=None, ops=None): + name = name.split("_")[-1] + url = "https://" + netloc + path = self.__BASEURL__ + '/' + name + if ops: + for each in ops: + if type(each) is str: + path += "/" + each + elif type(each) is dict: + key = list(each.keys())[0] + val = each[key] + path += "/{0}/{1}".format(key, val) + url += path + return self.validate_url(url, netloc, path) + + @staticmethod + def validate_url(url, netloc, path=""): + parsed_url = urlparse(url) + if url and netloc and "http" in parsed_url.scheme and netloc == parsed_url.netloc and path == parsed_url.path: + return url + raise ValueError("Incorrect URL :", url) + + def build(self): + + self.username = self.credentials["username"] + self.password = self.credentials["password"] + + self.parse_data() + + self.url = self.generate_url_from_operations(self.module_name, self.netloc, self.operations) + + getattr(self, self.action)() + + self.response = self.send_request(self.module, self.methods_of_actions[self.action], + self.url, self.data, self.username, self.password) + + self.check_response() + + def run_module(self, module): + + if module.check_mode: + module.exit_json(**self.result) + + for key, value in module.params.items(): + setattr(self, key, value) + + self.url = self.auth.get("url") + self.credentials = self.auth.get("credentials") + + if not self.url: + self.url = str(self.auth.get("ip_address")) + ":" + str(self.auth.get("port")) + + self.netloc = self.url + self.module_name = module._name + self.module = module + self.build() + + module.exit_json(**self.result) diff --git a/nutanix/ncp/plugins/module_utils/prism/__int__.py b/nutanix/ncp/plugins/module_utils/prism/__int__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nutanix/ncp/plugins/module_utils/prism/images.py b/nutanix/ncp/plugins/module_utils/prism/images.py new file mode 100644 index 000000000..583ed2b24 --- /dev/null +++ b/nutanix/ncp/plugins/module_utils/prism/images.py @@ -0,0 +1,8 @@ +# This file is part of Ansible +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from .prism import Prism + + +class Image(Prism): + kind = 'image' diff --git a/nutanix/ncp/plugins/module_utils/prism/prism.py b/nutanix/ncp/plugins/module_utils/prism/prism.py new file mode 100644 index 000000000..3521a68ea --- /dev/null +++ b/nutanix/ncp/plugins/module_utils/prism/prism.py @@ -0,0 +1,8 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +from ..entity import Entity + + +class Prism(Entity): + __BASEURL__ = "/api/nutanix/v3" diff --git a/nutanix/ncp/plugins/module_utils/prism/subnets.py b/nutanix/ncp/plugins/module_utils/prism/subnets.py new file mode 100644 index 000000000..075886058 --- /dev/null +++ b/nutanix/ncp/plugins/module_utils/prism/subnets.py @@ -0,0 +1,9 @@ +# This file is part of Ansible +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from .prism import Prism + + +class Subnet(Prism): + kind = 'subnet' + diff --git a/nutanix/ncp/plugins/module_utils/prism/vms.py b/nutanix/ncp/plugins/module_utils/prism/vms.py new file mode 100644 index 000000000..78fd95bd0 --- /dev/null +++ b/nutanix/ncp/plugins/module_utils/prism/vms.py @@ -0,0 +1,14 @@ +# This file is part of Ansible +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from .prism import Prism + + +class VM(Prism): + kind = 'vm' + + + data_tmp = { + + } + diff --git a/nutanix/ncp/plugins/modules/__int__.py b/nutanix/ncp/plugins/modules/__int__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nutanix/ncp/plugins/modules/nutanix_images.py b/nutanix/ncp/plugins/modules/nutanix_images.py new file mode 100644 index 000000000..3e37a9c9f --- /dev/null +++ b/nutanix/ncp/plugins/modules/nutanix_images.py @@ -0,0 +1,156 @@ +#!/usr/bin/python + +# Copyright: (c) 2021 +# 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 + + +DOCUMENTATION = r''' +--- +module: nutanix_images + +short_description: This module allows to communicate with the resource /images + +version_added: "1.0.0" + +description: This module allows to perform the following tasks on /images + +options: + action: + description: This is the action used to indicate the type of request + required: true + type: str + credentials: + description: Credentials needed for authenticating to the subnet + required: true + type: dict (Variable from file) + data: + description: This acts as either the params or the body payload depending on the HTTP action + required: false + type: dict + operation: + description: This acts as the sub_url in the requested url + required: false + type: str + ip_address: + description: This acts as the ip_address of the subnet. It can be passed as a list in ansible using with_items + required: True + type: str (Variable from file) + port: + description: This acts as the port of the subnet. It can be passed as a list in ansible using with_items + required: True + type: str (Variable from file) + +author: + - Gevorg Khachatryan (@gevorg_khachatryan) +''' + +EXAMPLES = r''' + +#CREATE action, request to /images +- hosts: [hosts_group] + tasks: + - name: create Image + nutanix_images: + action: create + credentials: str (Variable from file) + ip_address: str (Variable from file) + port: str (Variable from file) + data: + spec: + name: string + resources: + image_type: string + source_uri: string + +#UPDATE action, request to /images/{uuid} +- hosts: [hosts_group] + tasks: + - name: update Image + nutanix_images: + action: update + credentials: str (Variable from file) + ip_address: str (Variable from file) + port: str (Variable from file) + data: + metadata: + uuid: string + spec: + name: string + resources: + image_type: string + source_uri: string + +#LIST action, request to /images/list +- hosts: [hosts_group] + tasks: + - name: List Images + nutanix_images: + action: list + credentials: str (Variable from file) + ip_address: str (Variable from file) + port: str (Variable from file) + data: + - kind: string + - sort_attribute: string + - filter: string + - length: integer + - sort_order: string + - offset: integer + +#DELETE action, request to /images/{uuid} +- hosts: [hosts_group] + tasks: + - name: delete Image + nutanix_images: + action: delete + credentials: str (Variable from file) + ip_address: str (Variable from file) + port: str (Variable from file) + data: + metadata: + uuid: string +''' + +RETURN = r''' + +# CREATE /images +responses: +- default: Internal Error +- 202: Request Accepted + +# UPDATE /images/{uuid} +responses: +- default: Internal Error +- 404: Invalid UUID provided +- 202: Request Accepted + +# LIST /images/list +responses: +- default: Internal Error +- 200: Success + +# DELETE /images/{uuid} +responses: +- default: Internal Error +- 404: Invalid UUID provided +- 202: Request Accepted +''' + +from ..module_utils.base_module import BaseModule +from ..module_utils.prism.images import Image + + +def run_module(): + module = BaseModule() + Image(module) + + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/nutanix/ncp/plugins/modules/nutanix_subnets.py b/nutanix/ncp/plugins/modules/nutanix_subnets.py new file mode 100644 index 000000000..75f8585e1 --- /dev/null +++ b/nutanix/ncp/plugins/modules/nutanix_subnets.py @@ -0,0 +1,139 @@ +#!/usr/bin/python + +# Copyright: (c) 2021 +# 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 + + +DOCUMENTATION = r''' +--- +module: nutanix_subnets + +short_description: This module allows to communicate with the resource /subnets + +version_added: "1.0.0" + +description: This module allows to perform the following tasks on /subnets + +options: + action: + description: This is the HTTP action used to indicate the type of request + required: true + type: str + credentials: + description: Credentials needed for authenticating to the subnet + required: true + type: dict (Variable from file) + data: + description: This acts as either the params or the body payload depending on the HTTP action + required: false + type: dict + operation: + description: This acts as the sub_url in the requested url + required: false + type: str + ip_address: + description: This acts as the ip_address of the subnet. It can be passed as a list in ansible using with_items + required: True + type: str (Variable from file) + port: + description: This acts as the port of the subnet. It can be passed as a list in ansible using with_items + required: True + type: str (Variable from file) + +author: + - Gevorg Khachatryan (@gevorg_khachatryan-97) +''' + +EXAMPLES = r''' + +#CREATE action, request to /subnets +- hosts: [hosts_group] + tasks: + - nutanix_subnets: + action: create + credentials: str (Variable from file) + ip_address: str (Variable from file) + port: str (Variable from file) + data: + - spec: object + - metadata: object + +#UPDATE action, request to /subnets/{uuid} +- hosts: [hosts_group] + tasks: + - nutanix_subnets: + action: update + credentials: str (Variable from file) + ip_address: str (Variable from file) + port: str (Variable from file) + data: + metadata: + uuid: string + spec: object + +#LIST action, request to /subnets/list +- hosts: [hosts_group] + tasks: + - nutanix_subnets: + action: list + data: + - kind: string + - sort_attribute: string + - filter: string + - length: integer + - sort_order: string + - offset: integer + +#DELETE action, request to /subnets/{uuid} +- hosts: [hosts_group] + tasks: + - nutanix_subnets: + action: delete + data: + metadata: + uuid: string + +''' + +RETURN = r''' + +# CREATE /subnets +responses: +- default: Internal Error +- 202: Request Accepted + +# UPDATE /subnets/{uuid} +responses: +- default: Internal Error +- 404: Invalid UUID provided +- 202: Request Accepted + +# LIST /subnets/list +responses: +- default: Internal Error +- 200: Success + +# DELETE /subnets/{uuid} +responses: +- default: Internal Error +- 404: Invalid UUID provided +- 202: Request Accepted +''' + +from ..module_utils.base_module import BaseModule +from ..module_utils.prism.subnets import Subnet + + +def run_module(): + module = BaseModule() + Subnet(module) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/nutanix/ncp/plugins/modules/nutanix_vms.py b/nutanix/ncp/plugins/modules/nutanix_vms.py new file mode 100644 index 000000000..b537232c8 --- /dev/null +++ b/nutanix/ncp/plugins/modules/nutanix_vms.py @@ -0,0 +1,64 @@ +#!/usr/bin/python + +# Copyright: (c) 2021 +# 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 + +DOCUMENTATION = r''' +--- +module: nutanix_vms + +short_description: This module allows to communicate with the resource /vms + +version_added: "1.0.0" + +description: This module allows to perform the following tasks on /vms + +options: + action: + description: This is the HTTP action used to indicate the type of request + required: true + type: str + credentials: + description: Credentials needed for authenticating to the subnet + required: true + type: dict (Variable from file) + data: + description: This acts as either the params or the body payload depending on the HTTP action + required: false + type: dict + operation: + description: This acts as the sub_url in the requested url + required: false + type: str + ip_address: + description: This acts as the ip_address of the subnet. It can be passed as a list in ansible using with_items + required: True + type: str + +author: + - Gevorg Khachatryan (@gevorg_khachatryan) +''' + +EXAMPLES = r''' +''' + +RETURN = r''' +''' + +from ..module_utils.base_module import BaseModule +from ..module_utils.prism.vms import VM + +def run_module(): + module = BaseModule() + VM(module) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/nutanix/ncp/templates/__init__.py b/nutanix/ncp/templates/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nutanix/ncp/tests/__init__.py b/nutanix/ncp/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nutanix/ncp/tests/unit/__init__.py b/nutanix/ncp/tests/unit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nutanix/ncp/tests/unit/compat/__init__.py b/nutanix/ncp/tests/unit/compat/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nutanix/ncp/tests/unit/compat/builtins.py b/nutanix/ncp/tests/unit/compat/builtins.py new file mode 100644 index 000000000..f60ee6782 --- /dev/null +++ b/nutanix/ncp/tests/unit/compat/builtins.py @@ -0,0 +1,33 @@ +# (c) 2014, Toshio Kuratomi +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +# Make coding more python3-ish +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +# +# Compat for python2.7 +# + +# One unittest needs to import builtins via __import__() so we need to have +# the string that represents it +try: + import __builtin__ +except ImportError: + BUILTINS = 'builtins' +else: + BUILTINS = '__builtin__' diff --git a/nutanix/ncp/tests/unit/compat/mock.py b/nutanix/ncp/tests/unit/compat/mock.py new file mode 100644 index 000000000..0972cd2e8 --- /dev/null +++ b/nutanix/ncp/tests/unit/compat/mock.py @@ -0,0 +1,122 @@ +# (c) 2014, Toshio Kuratomi +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +# Make coding more python3-ish +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +''' +Compat module for Python3.x's unittest.mock module +''' +import sys + +# Python 2.7 + +# Note: Could use the pypi mock library on python3.x as well as python2.x. It +# is the same as the python3 stdlib mock library + +try: + # Allow wildcard import because we really do want to import all of mock's + # symbols into this compat shim + # pylint: disable=wildcard-import,unused-wildcard-import + from unittest.mock import * +except ImportError: + # Python 2 + # pylint: disable=wildcard-import,unused-wildcard-import + try: + from mock import * + except ImportError: + print('You need the mock library installed on python2.x to run tests') + + +# Prior to 3.4.4, mock_open cannot handle binary read_data +if sys.version_info >= (3,) and sys.version_info < (3, 4, 4): + file_spec = None + + def _iterate_read_data(read_data): + # Helper for mock_open: + # Retrieve lines from read_data via a generator so that separate calls to + # readline, read, and readlines are properly interleaved + sep = b'\n' if isinstance(read_data, bytes) else '\n' + data_as_list = [l + sep for l in read_data.split(sep)] + + if data_as_list[-1] == sep: + # If the last line ended in a newline, the list comprehension will have an + # extra entry that's just a newline. Remove this. + data_as_list = data_as_list[:-1] + else: + # If there wasn't an extra newline by itself, then the file being + # emulated doesn't have a newline to end the last line remove the + # newline that our naive format() added + data_as_list[-1] = data_as_list[-1][:-1] + + for line in data_as_list: + yield line + + def mock_open(mock=None, read_data=''): + """ + A helper function to create a mock to replace the use of `open`. It works + for `open` called directly or used as a context manager. + + The `mock` argument is the mock object to configure. If `None` (the + default) then a `MagicMock` will be created for you, with the API limited + to methods or attributes available on standard file handles. + + `read_data` is a string for the `read` methoddline`, and `readlines` of the + file handle to return. This is an empty string by default. + """ + def _readlines_side_effect(*args, **kwargs): + if handle.readlines.return_value is not None: + return handle.readlines.return_value + return list(_data) + + def _read_side_effect(*args, **kwargs): + if handle.read.return_value is not None: + return handle.read.return_value + return type(read_data)().join(_data) + + def _readline_side_effect(): + if handle.readline.return_value is not None: + while True: + yield handle.readline.return_value + for line in _data: + yield line + + global file_spec + if file_spec is None: + import _io + file_spec = list(set(dir(_io.TextIOWrapper)).union(set(dir(_io.BytesIO)))) + + if mock is None: + mock = MagicMock(name='open', spec=open) + + handle = MagicMock(spec=file_spec) + handle.__enter__.return_value = handle + + _data = _iterate_read_data(read_data) + + handle.write.return_value = None + handle.read.return_value = None + handle.readline.return_value = None + handle.readlines.return_value = None + + handle.read.side_effect = _read_side_effect + handle.readline.side_effect = _readline_side_effect() + handle.readlines.side_effect = _readlines_side_effect + + mock.return_value = handle + return mock diff --git a/nutanix/ncp/tests/unit/compat/unittest.py b/nutanix/ncp/tests/unit/compat/unittest.py new file mode 100644 index 000000000..98f08ad6a --- /dev/null +++ b/nutanix/ncp/tests/unit/compat/unittest.py @@ -0,0 +1,38 @@ +# (c) 2014, Toshio Kuratomi +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +# Make coding more python3-ish +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +''' +Compat module for Python2.7's unittest module +''' + +import sys + +# Allow wildcard import because we really do want to import all of +# unittests's symbols into this compat shim +# pylint: disable=wildcard-import,unused-wildcard-import +if sys.version_info < (2, 7): + try: + # Need unittest2 on python2.6 + from unittest2 import * + except ImportError: + print('You need unittest2 installed on python2.6.x to run tests') +else: + from unittest import * diff --git a/nutanix/ncp/tests/unit/plugins/__init__.py b/nutanix/ncp/tests/unit/plugins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nutanix/ncp/tests/unit/plugins/module_utils/__init__.py b/nutanix/ncp/tests/unit/plugins/module_utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nutanix/ncp/tests/unit/plugins/module_utils/test_entity.py b/nutanix/ncp/tests/unit/plugins/module_utils/test_entity.py new file mode 100644 index 000000000..e387dbdef --- /dev/null +++ b/nutanix/ncp/tests/unit/plugins/module_utils/test_entity.py @@ -0,0 +1,243 @@ +from ansible.module_utils import basic +from ansible_collections.nutanix.ncp.tests.unit.plugins.modules.utils import AnsibleExitJson, AnsibleFailJson, ModuleTestCase, set_module_args +from ansible.module_utils.six.moves.urllib.parse import urlparse +from ansible_collections.nutanix.ncp.plugins.module_utils.entity import Entity + +try: + from unittest.mock import MagicMock +except Exception: + from mock import MagicMock + + +def send_request(module, req_verb, req_url, req_data, username, password, timeout=30): + """ Mock send_request """ + kwargs = locals() + status = {'state': 'succeeded'} + spec_version = None + if req_verb == 'get': + status = 'succeeded' + spec_version = '1' + response = {'status': status, + 'status_code': 200, + 'request': { + 'req_verb': req_verb, + 'req_url': req_url, + 'req_data': req_data, + 'username': username, + 'password': password + + }, + 'metadata': { + 'spec_version': spec_version + } + } + + return response + + +def exit_json(*args, **kwargs): + if 'changed' not in kwargs: + kwargs['changed'] = False + raise AnsibleExitJson(kwargs) + + +class TestEntity(ModuleTestCase): + + def setUp(self): + module = object() + Entity.__init__ = MagicMock(side_effect=lambda *args: None) + self.builder = Entity(module) + self.builder.username = "username" + self.builder.password = "password" + self.builder.credentials = {} + self.builder.ip_address = "99.99.99.99" + self.builder.port = "9999" + self.builder.netloc = 'test.com' + self.builder.url = 'https://test.com/test' + self.builder.operations = '' + self.builder.wait = True + self.builder.module_name = 'test' + self.builder.module = module + self.builder.send_request = MagicMock(side_effect=send_request) + basic.AnsibleModule.exit_json = MagicMock(side_effect=exit_json) + + def test_create_action(self): + action = 'create' + + self.builder.data = {} + self.builder.credentials = { + 'username': self.builder.username, + 'password': self.builder.password + } + req = { + 'req_verb': self.builder.methods_of_actions[action], + 'req_url': self.builder.url, + 'req_data': self.builder.data, + } + req.update(self.builder.credentials) + self.builder.action = action + self.builder.build() + self.assertEqual(self.builder.result['response']['request'], req) + self.assertEqual(self.builder.result['changed'], True) + + def test_negative_create_action(self): + action = 'create' + + self.builder.data = {} + self.builder.credentials = { + 'username': self.builder.username, + # 'password': self.builder.password + } + exception = None + try: + self.builder.action = action + self.builder.build() + except Exception as e: + exception = e + self.assertEqual(type(exception), KeyError) + self.assertEqual(self.builder.result['changed'], False) + + def test_update_action(self): + action = 'update' + + self.builder.data = { + 'metadata': { + 'uuid': "a218f559-0ec0-46d8-a876-38f7d8950098", + } + } + self.builder.credentials = { + 'username': self.builder.username, + 'password': self.builder.password + } + req = { + 'req_verb': self.builder.methods_of_actions[action], + 'req_url': self.builder.url + '/' + self.builder.data['metadata']['uuid'], + 'req_data': self.builder.data, + } + req.update(self.builder.credentials) + self.builder.action = action + self.builder.build() + self.assertEqual(self.builder.result['response']['request'], req) + self.assertEqual(self.builder.result['changed'], True) + + def test_negative_update_action(self): + action = 'update' + + self.builder.data = {} + self.builder.credentials = { + 'username': self.builder.username, + 'password': self.builder.password + } + exception = None + try: + self.builder.action = action + self.builder.build() + except Exception as e: + exception = e + self.assertEqual(type(exception), KeyError) + self.assertEqual(self.builder.result['changed'], False) + + def test_list_action(self): + action = 'list' + + self.builder.data = {'kind': ''} + self.builder.credentials = { + 'username': self.builder.username, + 'password': self.builder.password + } + req = { + 'req_verb': self.builder.methods_of_actions[action], + 'req_url': self.builder.url + '/list', + 'req_data': self.builder.data, + } + req.update(self.builder.credentials) + self.builder.action = action + self.builder.build() + self.assertEqual(self.builder.result['response']['request'], req) + self.assertEqual(self.builder.result['changed'], False) + + def test_delete_action(self): + action = 'delete' + + self.builder.data = { + 'metadata': { + 'uuid': "a218f559-0ec0-46d8-a876-38f7d8950098", + } + } + self.builder.credentials = { + 'username': self.builder.username, + 'password': self.builder.password + } + req = { + 'req_verb': self.builder.methods_of_actions[action], + 'req_url': self.builder.url + '/' + self.builder.data['metadata']['uuid'], + 'req_data': self.builder.data, + } + req.update(self.builder.credentials) + self.builder.action = action + self.builder.build() + self.assertEqual(self.builder.result['response']['request'], req) + self.assertEqual(self.builder.result['changed'], True) + + def test_negative_delete_action(self): + action = 'delete' + + self.builder.data = {} # testing without uuid + self.builder.credentials = { + 'username': self.builder.username, + 'password': self.builder.password + } + exception = None + try: + self.builder.action = action + self.builder.build() + except Exception as e: + exception = e + self.assertEqual(type(exception), KeyError) + self.assertEqual(self.builder.result['changed'], False) + + def test_generate_url(self): + module_name = 'test' + operations = ['update', {'clone': 'test'}] + ip = str(self.builder.ip_address) + port = str(self.builder.port) + netloc = self.builder.netloc or ip + ':' + port + actual = self.builder.generate_url_from_operations(module_name, netloc, operations) + actual = urlparse(actual) + path = "/" + module_name + for each in operations: + if isinstance(each, str): + path += "/" + each + elif isinstance(each, dict): + key = list(each.keys())[0] + val = each[key] + path += "/{0}/{1}".format(key, val) + self.assertTrue("http" in actual.scheme) + self.assertEqual(netloc, actual.netloc) + self.assertEqual(path, actual.path) + + def test_negative_generate_url(self): + operations = ['update', {'clone': 'test'}] + exception = None + try: + self.builder.generate_url_from_operations('', '', operations) + + except Exception as e: + exception = e + self.assertEqual(type(exception), ValueError) + + def test_validate_request(self): + response = self.builder.validate_request(self.builder.module, + 'a218f559-0ec0-46d8-a876-38f7d8950098', + self.builder.netloc, 1) + self.assertEqual(response.get("status"), 'succeeded') + + def test_negative_validate_request(self): + exception = None + try: + self.builder.validate_request(self.builder.module, + 'wrong_uuid', + self.builder.netloc, 1) + except Exception as e: + exception = e + self.assertEqual(type(exception), ValueError) diff --git a/nutanix/ncp/tests/unit/plugins/modules/__init__.py b/nutanix/ncp/tests/unit/plugins/modules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nutanix/ncp/tests/unit/plugins/modules/utils.py b/nutanix/ncp/tests/unit/plugins/modules/utils.py new file mode 100644 index 000000000..6f2baa6c4 --- /dev/null +++ b/nutanix/ncp/tests/unit/plugins/modules/utils.py @@ -0,0 +1,50 @@ +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import json + +from ansible_collections.nutanix.ncp.tests.unit.compat import unittest +from ansible_collections.nutanix.ncp.tests.unit.compat.mock import patch +from ansible.module_utils import basic +from ansible.module_utils._text import to_bytes + + +def set_module_args(args): + if '_ansible_remote_tmp' not in args: + args['_ansible_remote_tmp'] = '/tmp' + if '_ansible_keep_remote_files' not in args: + args['_ansible_keep_remote_files'] = False + + args = json.dumps({'ANSIBLE_MODULE_ARGS': args}) + basic._ANSIBLE_ARGS = to_bytes(args) + + +class AnsibleExitJson(Exception): + pass + + +class AnsibleFailJson(Exception): + pass + + +def exit_json(*args, **kwargs): + if 'changed' not in kwargs: + kwargs['changed'] = False + raise AnsibleExitJson(kwargs) + + +def fail_json(*args, **kwargs): + kwargs['failed'] = True + raise AnsibleFailJson(kwargs) + + +class ModuleTestCase(unittest.TestCase): + + def setUp(self): + self.mock_module = patch.multiple(basic.AnsibleModule, exit_json=exit_json, fail_json=fail_json) + self.mock_module.start() + self.mock_sleep = patch('time.sleep') + self.mock_sleep.start() + set_module_args({}) + self.addCleanup(self.mock_module.stop) + self.addCleanup(self.mock_sleep.stop) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..c847e8c6f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +pip~=20.3.4 +ipaddress~=1.0.23 +setuptools~=44.1.1 +ansible~=4.6.0 +requests~=2.26.0 \ No newline at end of file