diff --git a/.gitignore b/.gitignore index 9ad24cfa7..08cf1070a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,34 @@ +Skip to content +Search or jump to… +Pull requests +Issues +Marketplace +Explore + +@Gevorg-Khachatryan-97 +nutanix +/ +nutanix.ansible +Internal +Code +Issues +12 +Pull requests +Actions +Projects +1 +Wiki +Security +Insights +Settings +nutanix.ansible/.gitignore +@kumarsarath588 +kumarsarath588 Add CICD github actions +Latest commit 0765ecc 6 days ago + History + 1 contributor +136 lines (109 sloc) 1.8 KB + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -133,4 +164,17 @@ dmypy.json *~ # vs code configs -.vscode \ No newline at end of file +.vscode +© 2022 GitHub, Inc. +Terms +Privacy +Security +Status +Docs +Contact GitHub +Pricing +API +Training +Blog +About +Loading complete \ No newline at end of file diff --git a/README.md b/README.md index f7252271d..6c7d535cf 100644 --- a/README.md +++ b/README.md @@ -6,21 +6,15 @@ Ansible collections to automate Nutanix Cloud Platform (ncp). 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 +ntnx_vms ``` - +# Inventory plugin +`ncp_prism_vm_inventory` # Module documentation and examples ``` @@ -28,147 +22,18 @@ ansible-doc nutanix.ncp. ``` # Examples -Make sure to use the correct values that matches your environment for all the variables. - -Create playbook.yml with the content as below, and run it using -``` -ansible-playbook playbook.yml +## Playbook to print name of vms in PC ``` -## Playbook to Create Different Vm's -``` ---- - hosts: localhost collections: - - nutanix.ncp - vars: - credentials: - username: UserName - password: Password - config: - ip_address: XXX.XXX.XXX.XXX - port: 9440 - cluster: - uuid: "0005d578-2faf-9fb6-3c07-ac1f6b6f9780" - networks: - static: - name: "static_subnet" - uuid: "72c5057d-93f7-4389-a01a-2c2f42eae3ef" - ip: "10.30.30.72" - storage_config: - uuid: "4446ca0b-7846-4a6f-b00a-386736432121" + - nutanix.ncp tasks: - - name: Create vm from image - nutanix.ncp.nutanix_vms: - state: present - name: image_vm - timezone: "UTC" - auth: - credentials: "{{credentials}}" - url: "{{config.ip_address}}:{{config.port}}" - cluster: - cluster_uuid: "{{cluster.uuid}}" - disks: - - type: "DISK" - clone_image: "CentOS-7-cloudinit" - bus: "SCSI" - register: result - #ignore_errors: True - - name: VM with Cluster , Network, UTC time zone, one Disk - nutanix_vms: - state: present - name: "Cluster Network and Disk" - timezone: "UTC" - auth: - credentials: "{{credentials}}" - url: "{{config.ip_address}}:{{config.port}}" - cluster: - cluster_uuid: "{{cluster.uuid}}" - networks: - - connected: True - subnet_name: "{{networks.static.name}}" - disks: - - type: "DISK" - size_gb: 5 - bus: "PCI" - register: result - ignore_errors: True - - name: VM with Cluster, different Disks, memory size - nutanix_vms: - state: present - name: "Different disks" - auth: - credentials: "{{credentials}}" - url: "{{config.ip_address}}:{{config.port}}" - cluster: - cluster_uuid: "{{cluster.uuid}}" - disks: - - type: "DISK" - size_gb: 4 - bus: "SATA" - - type: "DISK" - size_gb: 3 - bus: "SCSI" - memory_gb: 20 - register: result - ignore_errors: True - - name: VM with Cluster, different CDROMS - nutanix_vms: - state: present - name: "CDROM" - auth: - credentials: "{{credentials}}" - url: "{{config.ip_address}}:{{config.port}}" - cluster: - cluster_uuid: "{{cluster.uuid}}" - disks: - - type: "CDROM" - bus: "SATA" - - type: "CDROM" - bus: "IDE" - cores_per_vcpu: 1 - register: result - ignore_errors: True - - name: delete recently created vm - nutanix_vms: - uuid: '{{ result["response"]["metadata"]["uuid"] }}' - state: absent - auth: - credentials: "{{credentials}}" - url: "{{config.ip_address}}:{{config.port}}" - register: result - - name: VM with all specification - nutanix_vms: - state: present - name: "All specification" - timezone: "GMT" - auth: - credentials: "{{credentials}}" - url: "{{config.ip_address}}:{{config.port}}" - cluster: - cluster_uuid: "{{cluster.uuid}}" - disks: - - type: "DISK" - size_gb: 1 - bus: "SCSI" - - type: "DISK" - size_gb: 4 - bus: "PCI" - - type: "DISK" - size_gb: 16 - bus: "SATA" - - type: "DISK" - size_gb: 16 - bus: "SCSI" - - type: "CDROM" - size_gb: 4 - bus: "IDE" - boot_device_order_list: - - "DISK" - - "CDROM" - - "NETWORK" - vcpus: 20 - cores_per_vcpu: 4 - memory_gb: 6 - register: result - ignore_errors: True -``` \ No newline at end of file + - ntnx_vms: + nutanix_host: '{{config.ip_address}}' + nutanix_username: '{{credentials.username}}' + nutanix_password: '{{credentials.password}}' + validate_certs: False + register: result + - debug: + msg: "{{ result.vms }}" +``` diff --git a/examples/ansible.cfg b/examples/ansible.cfg new file mode 100644 index 000000000..0dd8c2275 --- /dev/null +++ b/examples/ansible.cfg @@ -0,0 +1,5 @@ +[defaults] +inventory = nutanix.yaml + +[inventory] +enable_plugins = nutanix.ncp.ntnx_prism_vm_inventory diff --git a/examples/create_vm.yml b/examples/create_vm.yml new file mode 100644 index 000000000..9699c4d0e --- /dev/null +++ b/examples/create_vm.yml @@ -0,0 +1,43 @@ +--- +- name: VM Creation using all poissible options + hosts: localhost + gather_facts: false + collections: + - nutanix.ncp + tasks: + - name: create Vm + ntnx_vms: + state: present + nutanix_host: '' + nutanix_username: '' + nutanix_password: '' + name: "ansible_automation_demo" + desc: "ansible_vm_description" + categories: + AppType: + - "Apache_Spark" + cluster: + #uuid: "0005d578-2faf-9fb6-3c07-ac1f6b6f9780" + name: "auto_cluster_prod_1aa888141361" + networks: + - is_connected: True + subnet: + name: "vlan.800" + disks: + - type: "DISK" + size_gb: 30 + bus: "SATA" + clone_image: + name: "CentOS-7-cloudinit" + vcpus: 1 + cores_per_vcpu: 1 + memory_gb: 1 + guest_customization: + type: "cloud_init" + script_path: "test.json" + is_overridable: True + register: output + + - name: output of list Subnets + debug: + msg: '{{ output }}' diff --git a/examples/delete_vm.yml b/examples/delete_vm.yml new file mode 100644 index 000000000..e464edd8a --- /dev/null +++ b/examples/delete_vm.yml @@ -0,0 +1,23 @@ +--- +- name: Auto Generated Playbook + hosts: localhost + vars_files: credentials.yml + gather_facts: false + collections: + - nutanix.ncp + tasks: + - name: delete Vm + ntnx_vms: + state: absent + auth: + credentials: '{{credentials}}' + url: '{{config.ip_address}}:{{config.port}}' + nutanix_host: '{{config.ip_address}}' + nutanix_username: '{{credentials.username}}' + nutanix_password: '{{credentials.password}}' + uuid: "63f7115e-e071-4dc3-b417-69fe6d6ff37f" + register: output + + - name: output of delete vm + debug: + msg: '{{ output }}' diff --git a/examples/nutanix.yml b/examples/nutanix.yml new file mode 100644 index 000000000..714160841 --- /dev/null +++ b/examples/nutanix.yml @@ -0,0 +1,6 @@ +--- +plugin: nutanix.ncp.ntnx_prism_vm_inventory +validate_certs: False +data: + offset: 0 + length: 100 \ No newline at end of file diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 000000000..97642535d --- /dev/null +++ b/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/plugins/inventory/ntnx_prism_vm_inventory.py b/plugins/inventory/ntnx_prism_vm_inventory.py new file mode 100644 index 000000000..11a0a57b1 --- /dev/null +++ b/plugins/inventory/ntnx_prism_vm_inventory.py @@ -0,0 +1,174 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2021 [Balu George, Prem Karat] +# 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""" + name: ntnx_prism_vm_inventory + plugin_type: inventory + short_description: Get a list of Nutanix VMs for ansible dynamic inventory. + description: + - Get a list of Nutanix VMs for ansible dynamic inventory. + version_added: "1.0.0" + author: + - "Balu George (@balugeorge)" + - "Prem Karat (@premkarat)" + inventory: ntnx_prism_vm_inventory + options: + plugin: + description: Name of the plugin + required: true + choices: ['ntnx_prism_vm_inventory', 'nutanix.ncp.ntnx_prism_vm_inventory'] + nutanix_hostname: + description: Prism central hostname or IP address + required: true + type: str + env: + - name: NUTANIX_HOSTNAME + nutanix_username: + description: Prism central username + required: true + type: str + env: + - name: NUTANIX_USERNAME + nutanix_password: + description: Prism central password + required: true + type: str + env: + - name: NUTANIX_PASSWORD + nutanix_port: + description: Prism central port + default: 9440 + type: str + env: + - name: NUTANIX_PORT + data: + description: + - Pagination support for listing VMs + - Default length(number of records to retrieve) has been set to 500 + default: {"offset": 0, "length": 500} + type: dict + validate_certs: + description: + - Set value to C(False) to skip validation for self signed certificates + - This is not recommended for production setup + default: True + type: boolean + env: + - name: VALIDATE_CERTS + notes: "null" + requirements: "null" +""" + +import json +import tempfile +from ansible.errors import AnsibleError +from ansible.plugins.inventory import BaseInventoryPlugin + +from ..module_utils.prism import vms + + +class Mock_Module: + def __init__(self, host, port, username, password, validate_certs=False): + self.tmpdir = tempfile.gettempdir() + self.params = { + "nutanix_host": host, + "nutanix_port": port, + "nutanix_username": username, + "nutanix_password": password, + "validate_certs": validate_certs, + } + + def jsonify(self, data): + return json.dumps(data) + + +class InventoryModule(BaseInventoryPlugin): + """Nutanix VM dynamic invetory module for ansible""" + + NAME = "nutanix.ncp.ntnx_prism_vm_inventory" + + def verify_file(self, path): + """Verify inventory configuration file""" + if not super().verify_file(path): + return False + + inventory_file_fmts = ( + "nutanix.yaml", + "nutanix.yml", + "nutanix_host_inventory.yaml", + "nutanix_host_inventory.yml", + ) + return path.endswith(inventory_file_fmts) + + def parse(self, inventory, loader, path, cache=True): + super().parse(inventory, loader, path, cache=cache) + self._read_config_data(path) + + self.nutanix_hostname = self.get_option("nutanix_hostname") + self.nutanix_username = self.get_option("nutanix_username") + self.nutanix_password = self.get_option("nutanix_password") + self.nutanix_port = self.get_option("nutanix_port") + self.data = self.get_option("data") + self.validate_certs = self.get_option("validate_certs") + + module = Mock_Module( + self.nutanix_hostname, + self.nutanix_port, + self.nutanix_username, + self.nutanix_password, + self.validate_certs, + ) + vm = vms.VM(module) + resp, status_code = vm.list(self.data) + keys_to_strip_from_resp = [ + "disk_list", + "vnuma_config", + "nic_list", + "power_state_mechanism", + "host_reference", + "serial_port_list", + "gpu_list", + "storage_config", + "boot_config", + "guest_customization", + ] + + for entity in resp["entities"]: + cluster = entity["status"]["cluster_reference"]["name"] + vm_name = entity["status"]["name"] + vm_uuid = entity["metadata"]["uuid"] + vm_ip = None + + # Get VM IP + nic_count = 0 + for nics in entity["status"]["resources"]["nic_list"]: + if nics["nic_type"] == "NORMAL_NIC" and nic_count == 0: + for endpoint in nics["ip_endpoint_list"]: + if endpoint["type"] == "ASSIGNED": + vm_ip = endpoint["ip"] + nic_count += 1 + continue + + # Add inventory groups and hosts to inventory groups + self.inventory.add_group(cluster) + self.inventory.add_child("all", cluster) + self.inventory.add_host(vm_name, group=cluster) + self.inventory.set_variable(vm_name, "ansible_host", vm_ip) + self.inventory.set_variable(vm_name, "uuid", vm_uuid) + + # Add hostvars + for key in keys_to_strip_from_resp: + try: + del entity["status"]["resources"][key] + except KeyError: + pass + + for key, value in entity["status"]["resources"].items(): + self.inventory.set_variable(vm_name, key, value) diff --git a/plugins/module_utils/base_module.py b/plugins/module_utils/base_module.py index c29ec60e9..e0bae4b8b 100644 --- a/plugins/module_utils/base_module.py +++ b/plugins/module_utils/base_module.py @@ -3,6 +3,7 @@ from __future__ import absolute_import, division, print_function from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import env_fallback __metaclass__ = type @@ -11,17 +12,33 @@ class BaseModule(AnsibleModule): """Basic module with common arguments""" argument_spec = dict( - action=dict(type="str", required=True, aliases=["state"]), - auth=dict(type="dict", required=True), - 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), + nutanix_host=dict( + type="str", required=True, fallback=(env_fallback, ["NUTANIX_HOST"]) + ), + nutanix_port=dict(default="9440", type="str"), + nutanix_username=dict( + type="str", required=True, fallback=(env_fallback, ["NUTANIX_USERNAME"]) + ), + nutanix_password=dict( + type="str", + required=True, + no_log=True, + fallback=(env_fallback, ["NUTANIX_PASSWORD"]), + ), + validate_certs=dict( + type="bool", default=True, fallback=(env_fallback, ["VALIDATE_CERTS"]) + ), + state=dict(type="str", choices=["present", "absent"], default="present"), + wait=dict(type="bool", default=True), ) def __init__(self, **kwargs): - if not kwargs.get("argument_spec"): - kwargs["argument_spec"] = self.argument_spec - else: + if kwargs.get("argument_spec"): kwargs["argument_spec"].update(self.argument_spec) - kwargs["supports_check_mode"] = True + else: + kwargs["argument_spec"] = self.argument_spec + + if not kwargs.get("supports_check_mode"): + kwargs["supports_check_mode"] = True + super(BaseModule, self).__init__(**kwargs) diff --git a/plugins/module_utils/entity.py b/plugins/module_utils/entity.py index 4a9a00248..3f850bd15 100644 --- a/plugins/module_utils/entity.py +++ b/plugins/module_utils/entity.py @@ -1,332 +1,135 @@ -# Copyright: 2021, Ansible Project -# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause ) -from __future__ import absolute_import, division, print_function +# This file is part of Ansible +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) -import json -import time -import uuid -from base64 import b64encode - -from ansible.module_utils.common.text.converters import to_text -from ansible.module_utils.urls import fetch_url +from __future__ import absolute_import, division, print_function __metaclass__ = type -try: - from urllib.parse import urlparse -except ImportError: - from urlparse import urlparse - - -class Entity: - """Basic functionality for Nutanix modules""" - - entity_type = "Base" - spec_file = "" - result = dict(changed=False, original_message="", message="") - - methods_of_actions = { - "create": "post", - "list": "post", - "update": "put", - "delete": "delete", - "absent": "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 BaseException: - 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 = {} +import json +from ansible.module_utils.urls import fetch_url +from ansible.module_utils._text import to_text +from base64 import b64encode +from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse + + +class Entity(object): + def __init__( + self, + module, + resource_type, + scheme="https", + cookies=None, + additional_headers=None, + ): + self.module = module + self.base_url = self._build_url(module, scheme, resource_type) + self.headers = self._build_headers(module, additional_headers) + self.cookies = cookies + + def create(self, data=None, endpoint=None, query=None, timeout=30): + url = self.base_url + "/{0}".format(endpoint) if endpoint else self.base_url + if query: + url = self._build_url_with_query(url, query) + return self._fetch_url(url, method="POST", data=data, timeout=timeout) + + def read(self, uuid=None, endpoint=None, query=None, timeout=30): + url = self.base_url + "/{0}".format(uuid) if uuid else self.base_url + if endpoint: + url = url + "/{}".format(endpoint) + if query: + url = self._build_url_with_query(url, query) + return self._fetch_url(url, method="GET", timeout=timeout) + + def update(self, data=None, uuid=None, endpoint=None, query=None, timeout=30): + url = self.base_url + "/{0}".format(uuid) if uuid else self.base_url + if endpoint: + url = url + "/{0}".format(endpoint) + if query: + url = self._build_url_with_query(url, query) + return self._fetch_url(url, method="PUT", data=data, timeout=timeout) + + def delete(self, uuid=None, endpoint=None, query=None, timeout=30): + url = self.base_url + "/{0}".format(uuid) if uuid else self.base_url + if endpoint: + url = url + "/{0}".format(endpoint) + if query: + url = self._build_url_with_query(url, query) + return self._fetch_url(url, method="DELETE", timeout=timeout) + + def list(self, data=None, endpoint=None, use_base_url=False, timeout=30): + url = self.base_url if use_base_url else self.base_url + "/list" + if endpoint: + url = url + "/{0}".format(endpoint) + return self._fetch_url(url, method="POST", data=data, timeout=timeout) + + def get_uuid(self, name): + data = {"filter": f"name=={name}", "length": 1} + resp, _ = self.list(data) + if resp and resp.get("entities"): + return resp["entities"][0]["metadata"]["uuid"] + return None + + def _build_url(self, module, scheme, resource_type): + host = module.params.get("nutanix_host") + url = "{proto}://{host}".format(proto=scheme, host=host) + port = module.params.get("nutanix_port") + if port: + url += ":{port}".format(port=port) + if resource_type.startswith("/"): + url += resource_type else: - payload = req_data + url += "/{resource_type}".format(resource_type=resource_type) + return url + + def _build_headers(self, module, additional_headers): + headers = {"Content-Type": "application/json", "Accept": "application/json"} + if additional_headers: + headers.update(additional_headers) + usr = module.params.get("nutanix_username") + pas = module.params.get("nutanix_password") + if usr and pas: + cred = f"{usr}:{pas}".format(usr=usr, pas=pas) + encoded_cred = b64encode(bytes(cred, encoding="ascii")).decode("ascii") + auth_header = "Basic " + encoded_cred + headers.update({"Authorization": auth_header}) + return headers + + def _build_url_with_query(self, url, query): + url = urlparse(url) + query_ = dict(parse_qsl(url.query)) + query_.update(query) + query_ = urlencode(query_) + url = url._replace(query=query_) + return urlunparse(url) + + def _fetch_url(self, url, method, data=None, timeout=30): + data = self.module.jsonify(data) if data else None resp, info = fetch_url( - module=module, - url=req_url, - headers=headers, + self.module, + url, + data=data, method=method, - data=module.jsonify(payload), + headers=self.headers, + cookies=self.cookies, timeout=timeout, ) - if not 300 > info["status"] > 199: - module.fail_json( - msg="Fail: " - + "Status: " - + "{0}".format(str(info["msg"])) - + ", Message: " - + "{0}".format(str(info.get("body"))) - ) + status_code = info.get("status") 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() and "queued" 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 get_action(self): - if self.action == "present": - self.action = "update" if self.data["metadata"].get("uuid") else "create" - elif self.action == "absent": - self.action = self.methods_of_actions[self.action] - elif self.action not in self.methods_of_actions.keys(): - raise ValueError("Wrong action: " + self.action) - - def get_spec(self): - from os.path import join - - import yaml - - # Get the current working directory - - ncp_dir = ( - self.module.tmpdir.split("/tmp")[0] - + "/collections/ansible_collections/nutanix/ncp/" - ) - - file_path = join(ncp_dir, self.spec_file) - with open(file_path, "rb") as f: - # spec = json.loads(str(f.read())) - spec = yaml.safe_load(f.read()) - return spec - - def clean_spec(self, spec): - for key, value in spec.copy().items(): - if isinstance(value, str): - if value.startswith("{{") and value.endswith("}}"): - value = value[2:-2] - - value = getattr(self, value, None) - if value: - if key == "guest_customization": - value = self.get_attr_spec(key, value) - spec[key] = value - else: - spec.pop(key) - - elif isinstance(value, dict): - value = self.clean_spec(value) - if value: - spec[key] = value - else: - spec.pop(key) - elif isinstance(value, list) and key != "required": - obj = value.pop(0) - list_key = obj.pop("list_key") - sub_spec_key = list_key.split("__")[-1] - if list_key: - value = self.get_attr_spec( - sub_spec_key, getattr(self, list_key, None) - ) - if value: - spec[key] = value - else: - spec.pop(key) - return spec - - 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.get_action() - - 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(): - if isinstance(value, dict): - for k, v in value.items(): - setattr(self, k, v) - setattr(self, key, value) - self.module = module - - spec = self.get_spec() - self.clean_spec(spec) - self.data = spec - - 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")) + if 199 < status_code < 300: + err = None + else: + err = info.get("msg", "Status code != 2xx") + self.module.fail_json( + msg="Failed fetching URL: {}".format(url), + status_code=status_code, + error=err, + response=resp_json, ) - - self.netloc = self.url - self.module_name = module._name - self.build() - - module.exit_json(**self.result) + status = {"error": err, "code": status_code} + return resp_json, status diff --git a/plugins/module_utils/prism/clusters.py b/plugins/module_utils/prism/clusters.py new file mode 100644 index 000000000..4ad573e14 --- /dev/null +++ b/plugins/module_utils/prism/clusters.py @@ -0,0 +1,10 @@ +# 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 Cluster(Prism): + def __init__(self, module): + resource_type = "/clusters" + super().__init__(module, resource_type=resource_type) diff --git a/plugins/module_utils/prism/groups.py b/plugins/module_utils/prism/groups.py new file mode 100644 index 000000000..382f93440 --- /dev/null +++ b/plugins/module_utils/prism/groups.py @@ -0,0 +1,17 @@ +# 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 Groups(Prism): + def __init__(self, module): + resource_type = "/groups" + super().__init__(module, resource_type=resource_type) + + def get_uuid(self, entity_type, filter): + data = {"entity_type": entity_type, "filter_criteria": filter} + resp, _ = self.list(data, use_base_url=True) + if resp.get("group_results"): + return resp["group_results"][0]["entity_results"][0]["entity_id"] + return None diff --git a/plugins/module_utils/prism/images.py b/plugins/module_utils/prism/images.py new file mode 100644 index 000000000..b616fa3e5 --- /dev/null +++ b/plugins/module_utils/prism/images.py @@ -0,0 +1,10 @@ +# 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): + def __init__(self, module): + resource_type = "/images" + super().__init__(module, resource_type=resource_type) diff --git a/plugins/module_utils/prism/prism.py b/plugins/module_utils/prism/prism.py index 356e490b6..177dff054 100644 --- a/plugins/module_utils/prism/prism.py +++ b/plugins/module_utils/prism/prism.py @@ -1,9 +1,13 @@ from __future__ import absolute_import, division, print_function -from ..entity import Entity - __metaclass__ = type +from ..entity import Entity + class Prism(Entity): __BASEURL__ = "/api/nutanix/v3" + + def __init__(self, module, resource_type): + resource_type = self.__BASEURL__ + resource_type + super().__init__(module, resource_type) diff --git a/plugins/module_utils/prism/projects.py b/plugins/module_utils/prism/projects.py new file mode 100644 index 000000000..546c8c040 --- /dev/null +++ b/plugins/module_utils/prism/projects.py @@ -0,0 +1,10 @@ +# 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 Project(Prism): + def __init__(self, module): + resource_type = "/projects" + super().__init__(module, resource_type=resource_type) diff --git a/plugins/module_utils/prism/subnets.py b/plugins/module_utils/prism/subnets.py new file mode 100644 index 000000000..25e8da896 --- /dev/null +++ b/plugins/module_utils/prism/subnets.py @@ -0,0 +1,10 @@ +# 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): + def __init__(self, module): + resource_type = "/subnets" + super().__init__(module, resource_type=resource_type) diff --git a/plugins/module_utils/prism/tasks.py b/plugins/module_utils/prism/tasks.py new file mode 100644 index 000000000..ddcc8a0ad --- /dev/null +++ b/plugins/module_utils/prism/tasks.py @@ -0,0 +1,46 @@ +# 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 os import stat +import time + +from .prism import Prism + + +class Task(Prism): + def __init__(self, module): + resource_type = "/tasks" + super().__init__(module, resource_type=resource_type) + + def create(self, data=None, endpoint=None, query=None, timeout=30): + raise NotImplementedError("Create not permitted") + + def update(self, data=None, uuid=None, endpoint=None, query=None, timeout=30): + raise NotImplementedError("Update not permitted") + + def delete(self, uuid=None, endpoint=None, query=None, timeout=30): + raise NotImplementedError("Delete not permitted") + + def list(self, data=None, endpoint=None, use_base_url=False, timeout=30): + raise NotImplementedError("List not permitted") + + def get_uuid(self, name): + raise NotImplementedError("get_uuid not permitted") + + def wait_for_completion(self, uuid): + state = "" + while state != "SUCCEEDED": + time.sleep(2) + response, status = self.read(uuid) + if status["error"]: + return response, status + + state = response.get("status") + if state == "FAILED": + status = { + "error": response["error_detail"], + "code": response["error_code"], + } + return response, status + + return response, status diff --git a/plugins/module_utils/prism/vms.py b/plugins/module_utils/prism/vms.py index 508de1226..ae53d0aa2 100644 --- a/plugins/module_utils/prism/vms.py +++ b/plugins/module_utils/prism/vms.py @@ -1,271 +1,311 @@ # 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 __future__ import absolute_import, division, print_function -from base64 import b64encode from copy import deepcopy -from .prism import Prism +import base64 +import os -__metaclass__ = type +from .clusters import Cluster +from .prism import Prism +from .projects import Project +from .subnets import Subnet +from .groups import Groups +from .images import Image class VM(Prism): - kind = "vm" - spec_file = "plugins/module_utils/prism/specs/vm_spec.json" - entity_type = "NutanixVm" - - def get_attr_spec(self, param, param_spec, **kwargs): - param_method_spec = { - "disk_list": VMDisk, - "nic_list": VMNetwork, - "guest_customization": GuestCustomizationSpec, + def __init__(self, module): + resource_type = "/vms" + super().__init__(module, resource_type=resource_type) + self.build_spec_methods = { + "name": self._build_spec_name, + "desc": self._build_spec_desc, + "project": self._build_spec_project, + "cluster": self._build_spec_cluster, + "vcpus": self._build_spec_vcpus, + "cores_per_vcpu": self._build_spec_cores, + "memory_gb": self._build_spec_mem, + "networks": self._build_spec_networks, + "disks": self._build_spec_disks, + "boot_config": self._build_spec_boot_config, + "guest_customization": self._build_spec_gc, + "timezone": self._build_spec_timezone, + "categories": self._build_spec_categories, } - if param in param_method_spec: - handler = param_method_spec[param]() - return handler(param_spec, get_ref=self.get_entity_by_name) - return param_spec - - def get_entity_by_name(self, payload=None): - url = self.generate_url_from_operations("groups", netloc=self.url) - resp = self.send_request( - self.module, - "post", - url, - payload, - self.credentials["username"], - self.credentials["password"], - ) - - try: - return resp["group_results"][0]["entity_results"][0]["entity_id"] - - except IndexError: - - self.result["message"] = "Entity does not exist. payload - {0}".format( - payload - ) - self.result["failed"] = True - - self.module.exit_json(**self.result) - - -class VMSpec: - def get_default_spec(self): - raise NotImplementedError( - "Get Default Spec helper not implemented for {0}".format(self.entity_type) + def get_spec(self): + spec = self._get_default_spec() + for ansible_param, ansible_value in self.module.params.items(): + build_spec_method = self.build_spec_methods.get(ansible_param) + if build_spec_method and ansible_value: + _, error = build_spec_method(spec, ansible_value) + if error: + return None, error + return spec, None + + def _get_default_spec(self): + return deepcopy( + { + "api_version": "3.1.0", + "metadata": {"kind": "vm"}, + "spec": { + "cluster_reference": {"kind": "cluster", "uuid": None}, + "name": None, + "resources": { + "num_sockets": 1, + "num_vcpus_per_socket": 1, + "memory_size_mib": 4096, + "power_state": "ON", + "disk_list": [], + "nic_list": [], + "gpu_list": [], + "boot_config": { + "boot_type": "LEGACY", + "boot_device_order_list": ["CDROM", "DISK", "NETWORK"], + }, + "hardware_clock_timezone": "UTC", + }, + }, + } ) - def _get_api_spec(self, param_spec, **kwargs): - raise NotImplementedError( - "Get Api Spec helper not implemented for {0}".format(self.entity_type) + def _get_default_network_spec(self): + return deepcopy( + { + "ip_endpoint_list": [], + "subnet_reference": {"kind": "subnet", "uuid": None}, + "is_connected": True, + } ) - def remove_null_references(self, spec, parent_spec=None, spec_key=None): - - if isinstance(spec, list): - for _i in spec: - self.remove_null_references(_i) - - elif isinstance(spec, dict): - for _k, _v in spec.copy().items(): - if _v in [None, "", []]: - spec.pop(_k) - self.remove_null_references(_v, spec, _k) - - if not bool(spec) and parent_spec and spec_key: - parent_spec.pop(spec_key) - - -class VMDisk(VMSpec): - entity_type = "VMDisk" - - @staticmethod - def get_default_spec(): + def _get_default_disk_spec(self): return deepcopy( { - "uuid": "", - "storage_config": { - "flash_mode": "", - "storage_container_reference": { - "url": "", - "kind": "", - "uuid": "", - "name": "", - }, - }, "device_properties": { - "device_type": "", - "disk_address": {"device_index": 0, "adapter_type": ""}, + "device_type": "DISK", + "disk_address": {"adapter_type": None, "device_index": None}, }, - "data_source_reference": { - "url": "", - "kind": "", - "uuid": "", - "name": "", - }, - "disk_size_mib": "", - } - ) - - def __get_image_ref(self, name, **kwargs): - get_entity_by_name = kwargs["get_ref"] - payload = {"entity_type": "image", "filter_criteria": "name=={0}".format(name)} - entity_uuid = get_entity_by_name(payload=payload) - return {"kind": "image", "uuid": entity_uuid} - - def __get_storage_container_ref(self, name, **kwargs): - get_entity_by_name = kwargs["get_ref"] - payload = { - "entity_type": "storage_container", - "filter_criteria": "container_name=={0}".format(name), - } - entity_uuid = get_entity_by_name(payload=payload) - return {"kind": "storage_container", "uuid": entity_uuid} - - def _get_api_spec(self, param_spec, **kwargs): - - final_disk_list = [] - _di_map = {} - - for disk_param in param_spec: - disk_final = self.get_default_spec() - if disk_param.get("clone_image"): - disk_final["data_source_reference"] = self.__get_image_ref( - disk_param["clone_image"], **kwargs - ) - - disk_final["device_properties"]["device_type"] = disk_param["type"] - disk_final["device_properties"]["disk_address"][ - "adapter_type" - ] = disk_param["bus"] - - # Calculating device_index for the DISK - - if disk_param["bus"] not in _di_map: - _di_map[disk_param["bus"]] = 0 - - disk_final["device_properties"]["disk_address"]["device_index"] = _di_map[ - disk_param["bus"] - ] - _di_map[disk_param["bus"]] += 1 - # Size of disk - if disk_param.get("size_gb"): - disk_final["disk_size_mib"] = disk_param["size_gb"] * 1024 - if disk_param["storage_config"] and disk_param["storage_config"].get( - "storage_container_name" - ): - disk_final["storage_config"] = { - "storage_container_reference": self.__get_storage_container_ref( - disk_param["storage_config"]["storage_container_name"], **kwargs - ) - } - elif disk_param.get("storage_container_uuid"): - disk_final["storage_config"] = { + "disk_size_bytes": None, + "storage_config": { "storage_container_reference": { "kind": "storage_container", - "uuid": disk_param["storage_container_uuid"], + "uuid": None, } - } - final_disk_list.append(disk_final) - self.remove_null_references(final_disk_list) + }, + "data_source_reference": {"kind": "image", "uuid": None}, + } + ) - return final_disk_list + def _build_spec_name(self, payload, value): + payload["spec"]["name"] = value + return payload, None - def __call__(self, param_spec, **kwargs): - return self._get_api_spec(param_spec, **kwargs) + def _build_spec_desc(self, payload, value): + payload["spec"]["description"] = value + return payload, None + def _build_spec_project(self, payload, param): + if "name" in param: + project = Project(self.module) + name = param["name"] + uuid = project.get_uuid(name) + if not uuid: + error = "Failed to get UUID for project name: {}".format(name) + return None, error -class VMNetwork(VMSpec): - entity_type = "VMNetwork" + elif "uuid" in param: + uuid = param["uuid"] - @staticmethod - def get_default_spec(): - return deepcopy( - { # fill it with default value if any - "uuid": "", - "is_connected": False, - "network_function_nic_type": "INGRESS", - "nic_type": "", - "subnet_reference": {"kind": "", "name": "", "uuid": ""}, - "network_function_chain_reference": "", - "mac_address": "", - "ip_endpoint_list": [], - } + payload["metadata"].update( + {"project_reference": {"uuid": uuid, "kind": "project"}} ) + return payload, None + + def _build_spec_cluster(self, payload, param): + if "name" in param: + cluster = Cluster(self.module) + name = param["name"] + uuid = cluster.get_uuid(name) + if not uuid: + error = "Failed to get UUID for cluster name: {}".format(name) + return None, error + + elif "uuid" in param: + uuid = param["uuid"] + + payload["spec"]["cluster_reference"]["uuid"] = uuid + return payload, None + + def _build_spec_vcpus(self, payload, value): + payload["spec"]["resources"]["num_sockets"] = value + return payload, None + + def _build_spec_cores(self, payload, value): + payload["spec"]["resources"]["num_vcpus_per_socket"] = value + return payload, None + + def _build_spec_mem(self, payload, value): + payload["spec"]["resources"]["memory_size_mib"] = value * 1024 + return payload, None + + def _build_spec_networks(self, payload, networks): + nics = [] + for network in networks: + nic = self._get_default_network_spec() + if network.get("private_ip"): + nic["ip_endpoint_list"].append({"ip": network["private_ip"]}) + + nic["is_connected"] = network["is_connected"] + + if network.get("subnet", {}).get("name"): + subnet = Subnet(self.module) + name = network["subnet"]["name"] + uuid = subnet.get_uuid(name) + if not uuid: + error = "Failed to get UUID for subnet name: {}".format(name) + return None, error + + elif network.get("subnet", {}).get("uuid"): + uuid = network["subnet"]["uuid"] + + nic["subnet_reference"]["uuid"] = uuid + + nics.append(nic) + + payload["spec"]["resources"]["nic_list"] = nics + return payload, None + + def _build_spec_disks(self, payload, vdisks): + disks = [] + scsi_index = sata_index = pci_index = ide_index = 0 + + for vdisk in vdisks: + disk = self._get_default_disk_spec() + + if "type" in vdisk: + disk["device_properties"]["device_type"] = vdisk["type"] + + if "bus" in vdisk: + if vdisk["bus"] == "SCSI": + device_index = scsi_index + scsi_index += 1 + elif vdisk["bus"] == "SATA": + device_index = sata_index + sata_index += 1 + elif vdisk["bus"] == "PCI": + device_index = pci_index + pci_index += 1 + elif vdisk["bus"] == "IDE": + device_index = ide_index + ide_index += 1 + + disk["device_properties"]["disk_address"]["adapter_type"] = vdisk["bus"] + disk["device_properties"]["disk_address"]["device_index"] = device_index + + if vdisk.get("empty_cdrom"): + disk.pop("disk_size_bytes") + disk.pop("data_source_reference") + disk.pop("storage_config") + + else: + disk["disk_size_bytes"] = vdisk["size_gb"] * 1024 * 1024 * 1024 + + if vdisk.get("storage_container"): + disk.pop("data_source_reference") + if "name" in vdisk["storage_container"]: + groups = Groups(self.module) + name = vdisk["storage_container"]["name"] + uuid = groups.get_uuid( + entity_type="storage_container", + filter=f"container_name=={name}", + ) + if not uuid: + error = "Failed to get UUID for storgae container: {}".format( + name + ) + return None, error + + elif "uuid" in vdisk["storage_container"]: + uuid = vdisk["storage_container"]["uuid"] + + disk["storage_config"]["storage_container_reference"]["uuid"] = uuid + + elif vdisk.get("clone_image"): + if "name" in vdisk["clone_image"]: + image = Image(self.module) + name = vdisk["clone_image"]["name"] + uuid = image.get_uuid(name) + if not uuid: + error = "Failed to get UUID for image: {}".format(name) + return None, error + + elif "uuid" in vdisk["clone_image"]: + uuid = vdisk["clone_image"]["uuid"] + + disk["data_source_reference"]["uuid"] = uuid + + if ( + not disk.get("storage_config", {}) + .get("storage_container_reference", {}) + .get("uuid") + ): + disk.pop("storage_config", None) - def __get_subnet_ref(self, name, **kwargs): - get_entity_by_name = kwargs["get_ref"] - payload = {"entity_type": "subnet", "filter_criteria": "name=={0}".format(name)} - entity_uuid = get_entity_by_name(payload=payload) - return {"kind": "subnet", "uuid": entity_uuid} - - def _get_api_spec(self, param_spec, **kwargs): + if not disk.get("data_source_reference", {}).get("uuid"): + disk.pop("data_source_reference", None) - final_nic_list = [] - for nic_param in param_spec: - nic_final = self.get_default_spec() - for k, v in nic_param.items(): - if k in nic_final.keys() and not isinstance(v, list): - nic_final[k] = v + disks.append(disk) - # elif 'subnet_' in k and k.split('_')[-1] in nic_final['subnet_reference']: - # nic_final['subnet_reference'][k.split('_')[-1]] = v + payload["spec"]["resources"]["disk_list"] = disks + return payload, None - elif k == "subnet_uuid" and v: - nic_final["subnet_reference"] = {"kind": "subnet", "uuid": v} - elif k == "subnet_name" and v and not nic_param.get("subnet_uuid"): - nic_final["subnet_reference"] = self.__get_subnet_ref(v, **kwargs) + def _build_spec_boot_config(self, payload, param): + boot_config = payload["spec"]["resources"]["boot_config"] + if "LEGACY" == param["boot_type"] and "boot_order" in param: + boot_config["boot_device_order_list"] = param["boot_order"] - elif k == "ip_endpoint_list" and bool(v): - nic_final[k] = [{"ip": v[0]}] + elif "UEFI" == param["boot_type"]: + boot_config.pop("boot_device_order_list") + boot_config["boot_type"] = "UEFI" - final_nic_list.append(nic_final) - self.remove_null_references(final_nic_list) + elif "SECURE_BOOT" == param["boot_type"]: + boot_config.pop("boot_device_order_list") + boot_config["boot_type"] = "SECURE_BOOT" + payload["spec"]["resources"]["machine_type"] = "Q35" + return payload, None - return final_nic_list + def _build_spec_gc(self, payload, param): + fpath = param["script_path"] - def __call__(self, param_spec, **kwargs): - return self._get_api_spec(param_spec, **kwargs) + if not os.path.exists(fpath): + error = "File not found: {}".format(fpath) + return None, error + with open(fpath, "rb") as f: + content = base64.b64encode(f.read()) + gc_spec = {"guest_customization": {}} -class GuestCustomizationSpec(VMSpec): - @staticmethod - def get_default_spec(): - return deepcopy( - { - "sysprep": { - "install_type": "", - "unattend_xml": "", - "custom_key_values": {}, - }, - "cloud_init": { - "meta_data": "", - "user_data": "", - "custom_key_values": {}, - }, - "is_overridable": "", + if "sysprep" in param["type"]: + gc_spec["guest_customization"] = { + "sysprep": {"install_type": "PREPARED", "unattend_xml": content} } - ) - - def _get_api_spec(self, param_spec, **kwargs): - gc_spec = self.get_default_spec() - script_file_path = param_spec["script_path"] - with open(script_file_path, "rb") as f: - content = f.read() - content = b64encode(content) - type = param_spec["type"] - if type == "sysprep": - gc_spec[type]["unattend_xml"] = content - elif type == "cloud_init": - gc_spec[type]["user_data"] = content - gc_spec["is_overridable"] = param_spec.get("is_overridable") + elif "cloud_init" in param["type"]: + gc_spec["guest_customization"] = {"cloud_init": {"user_data": content}} - self.remove_null_references(gc_spec) + if "is_overridable" in param: + gc_spec["guest_customization"]["is_overridable"] = param["is_overridable"] + payload["spec"]["resources"].update(gc_spec) + return payload, None - return gc_spec + def _build_spec_timezone(self, payload, value): + payload["spec"]["resources"]["hardware_clock_timezone"] = value + return payload, None - def __call__(self, param_spec, **kwargs): - return self._get_api_spec(param_spec, **kwargs) + def _build_spec_categories(self, payload, value): + payload["metadata"]["categories_mapping"] = value + payload["metadata"]["use_categories_mapping"] = True + return payload, None diff --git a/plugins/module_utils/utils.py b/plugins/module_utils/utils.py new file mode 100644 index 000000000..d9b301c1d --- /dev/null +++ b/plugins/module_utils/utils.py @@ -0,0 +1,19 @@ +# 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 __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +def remove_param_with_none_value(d): + for k, v in d.copy().items(): + if v is None: + d.pop(k) + elif isinstance(v, dict): + remove_param_with_none_value(v) + elif isinstance(v, list): + for e in v: + if isinstance(e, dict): + remove_param_with_none_value(e) + break diff --git a/plugins/modules/ntnx_vms.py b/plugins/modules/ntnx_vms.py new file mode 100644 index 000000000..19e58c33a --- /dev/null +++ b/plugins/modules/ntnx_vms.py @@ -0,0 +1,447 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2021, Prem Karat +# 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 +import re +from urllib import response + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: ntnx_vm + +short_description: VM module which suports VM CRUD operations + +version_added: "1.0.0" + +description: Create, Update, Delete, Power-on, Power-off Nutanix VM's + +options: + nutanix_host: + description: + - PC hostname or IP address + type: str + required: true + nutanix_port: + description: + - PC port + type: str + default: 9440 + required: false + nutanix_username: + description: + - PC username + type: str + required: true + nutanix_password: + description: + - PC password; + required: true + type: str + validate_certs: + description: + - Set value to C(False) to skip validation for self signed certificates + - This is not recommended for production setup + type: bool + default: true + state: + description: + - Specify state of Virtual Machine + - If C(state) is set to C(present) the VM is created. + - If C(state) is set to C(absent) and the VM exists in the cluster, VM with specified name is removed. + choices: + - present + - absent + type: str + default: present + wait: + description: This is the wait description. + required: false + default: false + name: + description: VM Name + required: true + type: str + vm_uuid: + description: + - VM UUID + - Required for VM deletion + required: false + desc: + description: A description for VM. + required: false + type: str + project: + description: Name or UUID of the project. + required: false + type: dict + suboptions: + name: + description: + - Project Name + - Mutually exclusive with C(uuid) + type: str + required: true + uuid: + description: + - Project UUID + - Mutually exclusive with C(name) + type: str + required: true + cluster: + description: + - Name or UUID of the cluster on which the VM will be placed. + required: false + suboptions: + name: + description: + - Cluster Name + - Mutually exclusive with C(uuid) + type: str + required: true + uuid: + description: + - Cluster UUID + - Mutually exclusive with C(name) + type: str + required: true + vcpus: + description: + - Number number of sockets. + required: false + type: int + default: 1 + cores_per_vcpu: + description: + - This is the number of vcpus per socket. + required: false + type: int + default: 1 + memory_gb: + description: + - Memory size in GB + required: false + type: int + default: 1 + networks: + description: + - list of subnets to which the VM needs to connect to. + type: list + elements: dict + required: false + suboptions: + subnet: + description: + - Name or UUID of the subnet to which the VM should be connnected. + suboptions: + name: + description: + - Subnet Name + - Mutually exclusive with C(uuid) + type: str + required: true + uuid: + description: + - Subnet UUID + - Mutually exclusive with C(name) + type: str + required: true + private_ip: + description: + - Optionally assign static IP to the VM. + type: str + required: False + is_connected: + description: + - connect or disconnect the VM to the subnet. + type: bool + required: False + default: True + disks: + description: + - List of disks attached to the VM + type: list + elements: dict + suboptions: + type: + description: + - 'CDROM or DISK' + choices: [ 'CDROM', 'DISK' ] + default: DISK + type: str + size_gb: + description: + - The Disk Size in GB. + - This option is applicable for only DISK type above. + type: int + bus: + description: 'Bus type of the device' + choices: [ 'SCSI', 'PCI', 'SATA', 'IDE' ] for DISK type. + choices: [ 'SATA', 'IDE' ] for CDROM type. + type: str + storage_container: + description: + - Mutually exclusive with C(clone_image) and C(empty_cdrom) + suboptions: + name: + description: + - Storage containter Name + - Mutually exclusive with C(uuid) + type: str + required: true + uuid: + description: + - Storage container UUID + - Mutually exclusive with C(name) + type: str + required: true + clone_image: + description: + - Mutually exclusive with C(storage_container) and C(empty_cdrom) + suboptions: + name: + description: + - Image Name + - Mutually exclusive with C(uuid) + type: str + required: true + uuid: + description: + - Image UUID + - Mutually exclusive with C(name) + type: str + required: true + empty_cdrom: + type: bool + description: + - Mutually exclusive with C(clone_image) and C(storage_container) + boot_config: + description: + - Indicates whether the VM should use Secure boot, UEFI boot or Legacy boot. + required: False + suboptions: + boot_type: + description: + - Boot type of VM. + choices: [ "LEGACY", "UEFI", "SECURE_BOOT" ] + default: "LEGACY" + type: str + boot_order: + description: + - Applicable only for LEGACY boot_type + - Boot device order list + type: list + default: ["CDROM", "DISK", "NETWORK"] + guest_customization: + description: + - cloud_init or sysprep guest customization + type: dict + required: false + suboptions: + type: + description: + - cloud_init or sysprep type + type: str + choices: [sysprep, cloud_init] + default: sysprep + required: true + script_path: + description: + - Absolute file path to the script. + type: path + required: true + is_overridable: + description: + - Flag to allow override of customization during deployment. + type: bool + default: false + required: false + timezone: + description: + - VM's hardware clock timezone in IANA TZDB format (America/Los_Angeles). + type: str + default: UTC + required: false + categories: + description: + - categories to be attached to the VM. + type: dict + required: false +""" + +EXAMPLES = r""" +# TODO +""" + +RETURN = r""" +# TODO +""" + +from ..module_utils.base_module import BaseModule +from ..module_utils.prism.vms import VM +from ..module_utils.prism.tasks import Task +from ..module_utils.utils import remove_param_with_none_value + + +def get_module_spec(): + mutually_exclusive = [("name", "uuid")] + + entity_by_spec = dict(name=dict(type="str"), uuid=dict(type="str")) + + network_spec = dict( + subnet=dict( + type="dict", options=entity_by_spec, mutually_exclusive=mutually_exclusive + ), + private_ip=dict(type="str", required=False), + is_connected=dict(type="bool", default=True), + ) + + disk_spec = dict( + type=dict(type="str", choices=["CDROM", "DISK"], default="DISK"), + size_gb=dict(type="int"), + bus=dict(type="str", choices=["SCSI", "PCI", "SATA", "IDE"], default="SCSI"), + storage_container=dict( + type="dict", options=entity_by_spec, mutually_exclusive=mutually_exclusive + ), + clone_image=dict( + type="dict", options=entity_by_spec, mutually_exclusive=mutually_exclusive + ), + empty_cdrom=dict(type="bool"), + ) + + boot_config_spec = dict( + boot_type=dict(type="str", choices=["LEGACY", "UEFI", "SECURE_BOOT"]), + boot_order=dict( + type="list", elements=str, default=["CDROM", "DISK", "NETWORK"] + ), + ) + + gc_spec = dict( + type=dict(type="str", choices=["cloud_init", "sysprep"], required=True), + script_path=dict(type="path", required=True), + is_overridable=dict(type="bool", default=False), + ) + + module_args = dict( + name=dict(type="str", required=True), + vm_uuid=dict(type="str"), + desc=dict(type="str"), + project=dict( + type="dict", options=entity_by_spec, mutually_exclusive=mutually_exclusive + ), + cluster=dict( + type="dict", options=entity_by_spec, mutually_exclusive=mutually_exclusive + ), + vcpus=dict(type="int", default=1), + cores_per_vcpu=dict(type="int", default=1), + memory_gb=dict(type="int", default=1), + networks=dict(type="list", elements="dict", options=network_spec), + disks=dict( + type="list", + elements="dict", + options=disk_spec, + mutually_exclusive=[ + ("storage_container", "clone_image", "empty_cdrom"), + ("size_gb", "empty_cdrom"), + ], + ), + boot_config=dict(type="dict", options=boot_config_spec), + guest_customization=dict(type="dict", options=gc_spec), + timezone=dict(type="str", default="UTC"), + categories=dict(type="dict"), + ) + + return module_args + + +def create_vm(module, result): + vm = VM(module) + spec, error = vm.get_spec() + if error: + result["error"] = error + module.fail_json(msg="Failed generating VM Spec", **result) + + if module.check_mode: + result["response"] = spec + return + + resp, status = vm.create(spec) + if status["error"]: + result["error"] = status["error"] + result["response"] = resp + module.fail_json(msg="Failed creating VM", **result) + + vm_uuid = resp["metadata"]["uuid"] + result["changed"] = True + result["response"] = resp + result["vm_uuid"] = vm_uuid + result["task_uuid"] = resp["status"]["execution_context"]["task_uuid"] + + if module.params.get("wait"): + wait_for_task_completion(module, result) + resp, _ = vm.read(vm_uuid) + result["response"] = resp + + +def delete_vm(module, result): + vm_uuid = module.params["vm_uuid"] + if not vm_uuid: + result["error"] = "Missing parameter vm_uuid in playbook" + module.fail_json(msg="Failed deleting VM", **result) + + vm = VM(module) + resp, status = vm.delete(vm_uuid) + if status["error"]: + result["error"] = status["error"] + result["response"] = resp + module.fail_json(msg="Failed deleting VM", **result) + + result["changed"] = True + result["response"] = resp + result["vm_uuid"] = vm_uuid + result["task_uuid"] = resp["status"]["execution_context"]["task_uuid"] + + if module.params.get("wait"): + wait_for_task_completion(module, result) + + +def wait_for_task_completion(module, result): + task = Task(module) + task_uuid = result["task_uuid"] + resp, status = task.wait_for_completion(task_uuid) + result["response"] = resp + if status["error"]: + result["error"] = status["error"] + result["response"] = resp + module.fail_json(msg="Failed creating VM", **result) + + +def run_module(): + module = BaseModule(argument_spec=get_module_spec(), supports_check_mode=True) + remove_param_with_none_value(module.params) + result = { + "changed": False, + "error": None, + "response": None, + "vm_uuid": None, + "task_uuid": None, + } + state = module.params["state"] + if state == "present": + create_vm(module, result) + elif state == "absent": + delete_vm(module, result) + + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == "__main__": + main() diff --git a/tests/test_entity.py b/tests/test_entity.py new file mode 100644 index 000000000..1535ad0fd --- /dev/null +++ b/tests/test_entity.py @@ -0,0 +1,30 @@ +from plugins.module_utils.prism.vms import VM +from plugins.module_utils.prism.images import Image + +import json +from ansible.module_utils.basic import AnsibleModule + + +class Module: + def __init__(self): + self.params = { + "nutanix_host": "10.46.34.230", + "nutanix_port": 9440, + "nutanix_username": "admin", + "nutanix_password": "Nutanix.123", + } + self.tmpdir = "/tmp" + + def jsonify(self, data): + return json.dumps(data) + + +def main(): + module = Module() + vm = VM(module) + data = {"kind": "vm", "offset": 0, "length": 500} + print(vm.list(data)) + + +if __name__ == "__main__": + main()