From 13571bb8d504577e5aaf3fef667e65951648b294 Mon Sep 17 00:00:00 2001 From: Danielle Barda Date: Wed, 7 Feb 2024 12:58:44 +0200 Subject: [PATCH] Adding the targets for ansible-test Signed-off-by: Danielle Barda --- Makefile | 16 + .../modules/{vmware_cluster.py => cluster.py} | 21 +- .../{vmware_cluster_drs.py => cluster_drs.py} | 20 +- .../{vmware_cluster_ha.py => cluster_ha.py} | 14 +- plugins/modules/{vmware_host.py => host.py} | 16 +- plugins/modules_utils/vmware.py | 1851 ++++++++++++++++- .../integration/targets/cluster/playbook.yml | 8 + .../targets/cluster/tasks/add_host.yml | 0 .../targets/cluster/tasks/create_cluster.yml | 0 .../targets/cluster/tasks/delete_cluster.yml | 0 .../targets/cluster/tasks/enable_drs.yml | 0 .../targets/cluster/tasks/main.yml | 5 + .../targets/prepare_vcsim/tasks/main.yml | 7 + tests/{units => unit}/.gitkeep | 0 tests/unit/plugins/modules/common/utils.py | 61 + tests/unit/plugins/modules/test_cluster_ha.py | 54 + tests/unit/requirements.txt | 3 + 17 files changed, 2037 insertions(+), 39 deletions(-) create mode 100644 Makefile rename plugins/modules/{vmware_cluster.py => cluster.py} (95%) rename plugins/modules/{vmware_cluster_drs.py => cluster_drs.py} (96%) rename plugins/modules/{vmware_cluster_ha.py => cluster_ha.py} (98%) rename plugins/modules/{vmware_host.py => host.py} (99%) create mode 100644 tests/integration/targets/cluster/playbook.yml create mode 100644 tests/integration/targets/cluster/tasks/add_host.yml create mode 100644 tests/integration/targets/cluster/tasks/create_cluster.yml create mode 100644 tests/integration/targets/cluster/tasks/delete_cluster.yml create mode 100644 tests/integration/targets/cluster/tasks/enable_drs.yml create mode 100644 tests/integration/targets/cluster/tasks/main.yml create mode 100644 tests/integration/targets/prepare_vcsim/tasks/main.yml rename tests/{units => unit}/.gitkeep (100%) create mode 100644 tests/unit/plugins/modules/common/utils.py create mode 100644 tests/unit/plugins/modules/test_cluster_ha.py create mode 100644 tests/unit/requirements.txt diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..4a152d0e --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +RELEASE?=0.0.2 + +lint: + ansible-lint + +clean: + rm -rf *tar.gz +# build: +# ansible-galaxy collection build +# +# publish: +# ansible-galaxy collection publish machacekondra-openshift_install-$(RELEASE).tar.gz --token=$(GALAXY_TOKEN) + +integration: + ansible-test sanity -v --color --coverage --junit --docker + diff --git a/plugins/modules/vmware_cluster.py b/plugins/modules/cluster.py similarity index 95% rename from plugins/modules/vmware_cluster.py rename to plugins/modules/cluster.py index 479d3152..e350b5ad 100644 --- a/plugins/modules/vmware_cluster.py +++ b/plugins/modules/cluster.py @@ -6,13 +6,15 @@ # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later +# the target test "cluster" covering the testing for that module + from __future__ import absolute_import, division, print_function __metaclass__ = type DOCUMENTATION = r''' --- -module: vmware_cluster +module: cluster short_description: Manage VMware vSphere clusters description: - Adds or removes VMware vSphere clusters. @@ -40,17 +42,16 @@ default: present type: str seealso: -- module: community.vmware.vmware_cluster_drs -- module: community.vmware.vmware_cluster_ha -- module: community.vmware.vmware_cluster_vsan -extends_documentation_fragment: -- community.vmware.vmware.documentation +- module: vmware.vmware.vmware_cluster_drs +- module: vmware.vmware.vmware_cluster_ha +- module: vmware.vmware.vmware_cluster_vsan + ''' EXAMPLES = r''' - name: Create Cluster - community.vmware.vmware_cluster: + vmware.vmware.cluster: hostname: '{{ vcenter_hostname }}' username: '{{ vcenter_username }}' password: '{{ vcenter_password }}' @@ -59,7 +60,7 @@ delegate_to: localhost - name: Delete Cluster - community.vmware.vmware_cluster: + vmware.vmware.cluster: hostname: "{{ vcenter_hostname }}" username: "{{ vcenter_username }}" password: "{{ vcenter_password }}" @@ -78,10 +79,10 @@ pass from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.vmware.plugins.module_utils.vmware import ( +from ansible_collections.vmware.vmware.plugins.module_utils.vmware import ( + vmware_argument_spec, PyVmomi, find_datacenter_by_name, - vmware_argument_spec, wait_for_task) from ansible.module_utils._text import to_native diff --git a/plugins/modules/vmware_cluster_drs.py b/plugins/modules/cluster_drs.py similarity index 96% rename from plugins/modules/vmware_cluster_drs.py rename to plugins/modules/cluster_drs.py index 3027c291..d8a71e53 100644 --- a/plugins/modules/vmware_cluster_drs.py +++ b/plugins/modules/cluster_drs.py @@ -12,7 +12,7 @@ DOCUMENTATION = r''' --- -module: vmware_cluster_drs +module: cluster_drs short_description: Manage Distributed Resource Scheduler (DRS) on VMware vSphere clusters description: - Manages DRS on VMware vSphere clusters. @@ -74,12 +74,12 @@ type: bool default: false extends_documentation_fragment: -- community.vmware.vmware.documentation +- vmware.vmware.vmware.documentation ''' EXAMPLES = r''' - name: Enable DRS - community.vmware.vmware_cluster_drs: + vmware.vmware.cluster_drs: hostname: '{{ vcenter_hostname }}' username: '{{ vcenter_username }}' password: '{{ vcenter_password }}' @@ -88,7 +88,7 @@ enable: true delegate_to: localhost - name: Enable DRS and distribute a more even number of virtual machines across hosts for availability - community.vmware.vmware_cluster_drs: + vmware.vmware.cluster_drs: hostname: '{{ vcenter_hostname }}' username: '{{ vcenter_username }}' password: '{{ vcenter_password }}' @@ -99,7 +99,7 @@ 'TryBalanceVmsPerHost': '1' delegate_to: localhost - name: Enable DRS and set default VM behavior to partially automated - community.vmware.vmware_cluster_drs: + vmware.vmware.cluster_drs: hostname: "{{ vcenter_hostname }}" username: "{{ vcenter_username }}" password: "{{ vcenter_password }}" @@ -119,14 +119,14 @@ pass from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.vmware.plugins.module_utils.vmware import ( +from ansible_collections.vmware.vmware.plugins.module_utils.vmware import ( + vmware_argument_spec, PyVmomi, - TaskError, find_datacenter_by_name, - vmware_argument_spec, - wait_for_task, + TaskError, option_diff, -) + wait_for_task) + from ansible.module_utils._text import to_native diff --git a/plugins/modules/vmware_cluster_ha.py b/plugins/modules/cluster_ha.py similarity index 98% rename from plugins/modules/vmware_cluster_ha.py rename to plugins/modules/cluster_ha.py index 1ad532d4..179e9042 100644 --- a/plugins/modules/vmware_cluster_ha.py +++ b/plugins/modules/cluster_ha.py @@ -13,7 +13,7 @@ DOCUMENTATION = r''' --- -module: vmware_cluster_ha +module: cluster_ha short_description: Manage High Availability (HA) on VMware vSphere clusters description: - Manages HA configuration on VMware vSphere clusters. @@ -198,13 +198,13 @@ default: 'warning' choices: [ 'disabled', 'warning', 'restartAggressive' ] extends_documentation_fragment: -- community.vmware.vmware.documentation +- vmware.vmware.vmware.documentation ''' EXAMPLES = r''' - name: Enable HA without admission control - community.vmware.vmware_cluster_ha: + vmware.vmware.cluster_ha: hostname: '{{ vcenter_hostname }}' username: '{{ vcenter_username }}' password: '{{ vcenter_password }}' @@ -214,7 +214,7 @@ delegate_to: localhost - name: Enable HA and VM monitoring without admission control - community.vmware.vmware_cluster_ha: + vmware.vmware.cluster_ha: hostname: "{{ vcenter_hostname }}" username: "{{ vcenter_username }}" password: "{{ vcenter_password }}" @@ -225,7 +225,7 @@ delegate_to: localhost - name: Enable HA with admission control reserving 50% of resources for HA - community.vmware.vmware_cluster_ha: + vmware.vmware.cluster_ha: hostname: '{{ vcenter_hostname }}' username: '{{ vcenter_username }}' password: '{{ vcenter_password }}' @@ -249,11 +249,11 @@ pass from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.vmware.plugins.module_utils.vmware import ( +from ansible_collections.vmware.vmware.plugins.module_utils.vmware import ( + vmware_argument_spec, PyVmomi, TaskError, find_datacenter_by_name, - vmware_argument_spec, wait_for_task, option_diff, ) diff --git a/plugins/modules/vmware_host.py b/plugins/modules/host.py similarity index 99% rename from plugins/modules/vmware_host.py rename to plugins/modules/host.py index d5b586b3..c681a52c 100644 --- a/plugins/modules/vmware_host.py +++ b/plugins/modules/host.py @@ -13,7 +13,7 @@ DOCUMENTATION = r''' --- -module: vmware_host +module: host short_description: Add, remove, or move an ESXi host to, from, or within vCenter description: - This module can be used to add, reconnect, or remove an ESXi host to or from vCenter. @@ -117,13 +117,13 @@ type: bool default: true extends_documentation_fragment: -- community.vmware.vmware.documentation +- vmware.vmware.vmware.documentation ''' EXAMPLES = r''' - name: Add ESXi Host to vCenter - community.vmware.vmware_host: + vmware.vmware.host: hostname: '{{ vcenter_hostname }}' username: '{{ vcenter_username }}' password: '{{ vcenter_password }}' @@ -136,7 +136,7 @@ delegate_to: localhost - name: Add ESXi Host to vCenter under a specific folder - community.vmware.vmware_host: + vmware.vmware.host: hostname: '{{ vcenter_hostname }}' username: '{{ vcenter_username }}' password: '{{ vcenter_password }}' @@ -150,7 +150,7 @@ delegate_to: localhost - name: Reconnect ESXi Host (with username/password set) - community.vmware.vmware_host: + vmware.vmware.host: hostname: '{{ vcenter_hostname }}' username: '{{ vcenter_username }}' password: '{{ vcenter_password }}' @@ -163,7 +163,7 @@ delegate_to: localhost - name: Reconnect ESXi Host (with default username/password) - community.vmware.vmware_host: + vmware.vmware.host: hostname: '{{ vcenter_hostname }}' username: '{{ vcenter_username }}' password: '{{ vcenter_password }}' @@ -174,7 +174,7 @@ delegate_to: localhost - name: Add ESXi Host with SSL Thumbprint to vCenter - community.vmware.vmware_host: + vmware.vmware.host: hostname: '{{ vcenter_hostname }}' username: '{{ vcenter_username }}' password: '{{ vcenter_password }}' @@ -203,7 +203,7 @@ from ansible.module_utils.basic import AnsibleModule from ansible.module_utils._text import to_native -from ansible_collections.community.vmware.plugins.module_utils.vmware import ( +from ansible_collections.vmware.vmware.plugins.module_utils.vmware import ( PyVmomi, TaskError, vmware_argument_spec, wait_for_task, find_host_by_cluster_datacenter, find_hostsystem_by_name ) diff --git a/plugins/modules_utils/vmware.py b/plugins/modules_utils/vmware.py index 8a314880..91c3842e 100644 --- a/plugins/modules_utils/vmware.py +++ b/plugins/modules_utils/vmware.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -# This code based and copied from https://github.com/ansible-collections/community.vmware/blob/main/plugins/module_utils/vmware.py # Copyright: (c) 2015, Joseph Callen # Copyright: (c) 2018, Ansible Project @@ -11,8 +10,20 @@ __metaclass__ = type import atexit +import ansible.module_utils.common._collections_compat as collections_compat +import json +import os +import re +import socket import ssl +import hashlib +import time import traceback +import datetime +from collections import OrderedDict +from ansible.module_utils.compat.version import StrictVersion +from random import randint + REQUESTS_IMP_ERR = None try: @@ -34,7 +45,15 @@ HAS_PYVMOMI = False HAS_PYVMOMIJSON = False -from ansible.module_utils.basic import env_fallback +from ansible.module_utils._text import to_text, to_native +from ansible.module_utils.six import integer_types, iteritems, string_types, raise_from +from ansible.module_utils.basic import env_fallback, missing_required_lib +from ansible.module_utils.six.moves.urllib.parse import unquote + + +class TaskError(Exception): + def __init__(self, *args, **kwargs): + super(TaskError, self).__init__(*args, **kwargs) class ApiAccessError(Exception): @@ -42,18 +61,633 @@ def __init__(self, *args, **kwargs): super(ApiAccessError, self).__init__(*args, **kwargs) +def check_answer_question_status(vm): + """Check whether locked a virtual machine. + + Args: + vm: Virtual machine management object + + Returns: bool + """ + if hasattr(vm, "runtime") and vm.runtime.question: + return True + + return False + + +def make_answer_response(vm, answers): + """Make the response contents to answer against locked a virtual machine. + + Args: + vm: Virtual machine management object + answers: Answer contents + + Returns: Dict with answer id and number + Raises: TaskError on failure + """ + response_list = {} + for message in vm.runtime.question.message: + response_list[message.id] = {} + for choice in vm.runtime.question.choice.choiceInfo: + response_list[message.id].update({ + choice.label: choice.key + }) + + responses = [] + try: + for answer in answers: + responses.append({ + "id": vm.runtime.question.id, + "response_num": response_list[answer["question"]][answer["response"]] + }) + except Exception: + raise TaskError("not found %s or %s or both in the response list" % (answer["question"], answer["response"])) + + return responses + + +def answer_question(vm, responses): + """Answer against the question for unlocking a virtual machine. + + Args: + vm: Virtual machine management object + responses: Answer contents to unlock a virtual machine + """ + for response in responses: + try: + vm.AnswerVM(response["id"], response["response_num"]) + except Exception as e: + raise TaskError("answer failed: %s" % to_text(e)) + + +def wait_for_task(task, max_backoff=64, timeout=3600, vm=None, answers=None): + """Wait for given task using exponential back-off algorithm. + + Args: + task: VMware task object + max_backoff: Maximum amount of sleep time in seconds + timeout: Timeout for the given task in seconds + + Returns: Tuple with True and result for successful task + Raises: TaskError on failure + """ + failure_counter = 0 + start_time = time.time() + + while True: + if check_answer_question_status(vm): + if answers: + responses = make_answer_response(vm, answers) + answer_question(vm, responses) + else: + raise TaskError("%s" % to_text(vm.runtime.question.text)) + if time.time() - start_time >= timeout: + raise TaskError("Timeout") + if task.info.state == vim.TaskInfo.State.success: + return True, task.info.result + if task.info.state == vim.TaskInfo.State.error: + error_msg = task.info.error + hovmware_argument_specst_thumbprint = None + try: + error_msg = error_msg.msg + if hasattr(task.info.error, 'thumbprint'): + host_thumbprint = task.info.error.thumbprint + except AttributeError: + pass + finally: + raise_from(TaskError(error_msg, host_thumbprint), task.info.error) + if task.info.state in [vim.TaskInfo.State.running, vim.TaskInfo.State.queued]: + sleep_time = min(2 ** failure_counter + randint(1, 1000) / 1000, max_backoff) + time.sleep(sleep_time) + failure_counter += 1 + + +def wait_for_vm_ip(content, vm, timeout=300): + facts = dict() + interval = 15 + while timeout > 0: + _facts = gather_vm_facts(content, vm) + if _facts['ipv4'] or _facts['ipv6']: + facts = _facts + break + time.sleep(interval) + timeout -= interval + + return facts + + +def find_obj(content, vimtype, name, first=True, folder=None): + container = content.viewManager.CreateContainerView(folder or content.rootFolder, recursive=True, type=vimtype) + # Get all objects matching type (and name if given) + obj_list = [obj for obj in container.view if not name or to_text(unquote(obj.name)) == to_text(unquote(name))] + container.Destroy() + + # Return first match or None + if first: + if obj_list: + return obj_list[0] + return None + + # Return all matching objects or empty list + return obj_list + + +def find_dvspg_by_name(dv_switch, portgroup_name): + portgroup_name = quote_obj_name(portgroup_name) + portgroups = dv_switch.portgroup + + for pg in portgroups: + if pg.name == portgroup_name: + return pg + + return None + + +def find_object_by_name(content, name, obj_type, folder=None, recurse=True): + if not isinstance(obj_type, list): + obj_type = [obj_type] + + name = name.strip() + + objects = get_all_objs(content, obj_type, folder=folder, recurse=recurse) + for obj in objects: + try: + if unquote(obj.name) == name: + return obj + except vmodl.fault.ManagedObjectNotFound: + pass + + return None + + +def find_all_objects_by_name(content, name, obj_type, folder=None, recurse=True): + if not isinstance(obj_type, list): + obj_type = [obj_type] + + name = name.strip() + result = [] + + objects = get_all_objs(content, obj_type, folder=folder, recurse=recurse) + for obj in objects: + try: + if unquote(obj.name) == name: + result.append(obj) + except vmodl.fault.ManagedObjectNotFound: + pass + return result + + +def find_cluster_by_name(content, cluster_name, datacenter=None): + if datacenter and hasattr(datacenter, 'hostFolder'): + folder = datacenter.hostFolder + else: + folder = content.rootFolder + + return find_object_by_name(content, cluster_name, [vim.ClusterComputeResource], folder=folder) + + +def find_datacenter_by_name(content, datacenter_name): + return find_object_by_name(content, datacenter_name, [vim.Datacenter]) + + +def get_parent_datacenter(obj): + """ Walk the parent tree to find the objects datacenter """ + if isinstance(obj, vim.Datacenter): + return obj + datacenter = None + while True: + if not hasattr(obj, 'parent'): + break + obj = obj.parent or obj.parentVApp + if isinstance(obj, vim.Datacenter): + datacenter = obj + break + return datacenter + + +def find_datastore_by_name(content, datastore_name, datacenter_name=None): + return find_object_by_name(content, datastore_name, [vim.Datastore], datacenter_name) + + +def find_folder_by_name(content, folder_name): + return find_object_by_name(content, folder_name, [vim.Folder]) + + +def find_folder_by_fqpn(content, folder_name, datacenter_name=None, folder_type=None): + """ + Find the folder by its given fully qualified path name. + The Fully Qualified Path Name is I(datacenter)/I(folder type)/folder name/ + for example - Lab/vm/someparent/myfolder is a vm folder in the Lab datacenter. + """ + # Remove leading/trailing slashes and create list of subfolders + folder = folder_name.strip('/') + folder_parts = folder.strip('/').split('/') + + # Process datacenter + if len(folder_parts) > 0: + if not datacenter_name: + datacenter_name = folder_parts[0] + if datacenter_name == folder_parts[0]: + folder_parts.pop(0) + datacenter = find_datacenter_by_name(content, datacenter_name) + if not datacenter: + return None + + # Process folder type + if len(folder_parts) > 0: + if not folder_type: + folder_type = folder_parts[0] + if folder_type == folder_parts[0]: + folder_parts.pop(0) + if folder_type in ['vm', 'host', 'datastore', 'network']: + parent_obj = getattr(datacenter, "%sFolder" % folder_type.lower()) + else: + return None + + # Process remaining subfolders + if len(folder_parts) > 0: + for part in folder_parts: + folder_obj = None + for part_obj in parent_obj.childEntity: + if part_obj.name == part and ('Folder' in part_obj.childType or vim.Folder in part_obj.childType): + folder_obj = part_obj + parent_obj = part_obj + break + if not folder_obj: + return None + else: + folder_obj = parent_obj + return folder_obj + + +def find_dvs_by_name(content, switch_name, folder=None): + return find_object_by_name(content, switch_name, [vim.DistributedVirtualSwitch], folder=folder) + + +def find_hostsystem_by_name(content, hostname, datacenter=None): + if datacenter and hasattr(datacenter, 'hostFolder'): + folder = datacenter.hostFolder + else: + folder = content.rootFolder + return find_object_by_name(content, hostname, [vim.HostSystem], folder=folder) + + +def find_resource_pool_by_name(content, resource_pool_name): + return find_object_by_name(content, resource_pool_name, [vim.ResourcePool]) + + +def find_resource_pool_by_cluster(content, resource_pool_name='Resources', cluster=None): + return find_object_by_name(content, resource_pool_name, [vim.ResourcePool], folder=cluster) + + +def find_network_by_name(content, network_name, datacenter_name=None): + return find_object_by_name(content, network_name, [vim.Network], datacenter_name) + + +def find_all_networks_by_name(content, network_name, datacenter_name=None): + return find_all_objects_by_name(content, network_name, [vim.Network], datacenter_name) + + +def find_vm_by_id(content, vm_id, vm_id_type="vm_name", datacenter=None, + cluster=None, folder=None, match_first=False): + """ UUID is unique to a VM, every other id returns the first match. """ + si = content.searchIndex + vm = None + + if vm_id_type == 'dns_name': + vm = si.FindByDnsName(datacenter=datacenter, dnsName=vm_id, vmSearch=True) + elif vm_id_type == 'uuid': + # Search By BIOS UUID rather than instance UUID + vm = si.FindByUuid(datacenter=datacenter, instanceUuid=False, uuid=vm_id, vmSearch=True) + elif vm_id_type == 'instance_uuid': + vm = si.FindByUuid(datacenter=datacenter, instanceUuid=True, uuid=vm_id, vmSearch=True) + elif vm_id_type == 'ip': + vm = si.FindByIp(datacenter=datacenter, ip=vm_id, vmSearch=True) + elif vm_id_type == 'vm_name': + folder = None + if cluster: + folder = cluster + elif datacenter: + folder = datacenter.hostFolder + vm = find_vm_by_name(content, vm_id, folder) + elif vm_id_type == 'inventory_path': + searchpath = folder + # get all objects for this path + f_obj = si.FindByInventoryPath(searchpath) + if f_obj: + if isinstance(f_obj, vim.Datacenter): + f_obj = f_obj.vmFolder + for c_obj in f_obj.childEntity: + if not isinstance(c_obj, vim.VirtualMachine): + continue + if c_obj.name == vm_id: + vm = c_obj + if match_first: + break + return vm + + +def find_vm_by_name(content, vm_name, folder=None, recurse=True): + return find_object_by_name(content, vm_name, [vim.VirtualMachine], folder=folder, recurse=recurse) + + +def find_host_portgroup_by_name(host, portgroup_name): + + for portgroup in host.config.network.portgroup: + if portgroup.spec.name == portgroup_name: + return portgroup + return None + + +def compile_folder_path_for_object(vobj): + """ make a /vm/foo/bar/baz like folder path for an object """ + + paths = [] + if isinstance(vobj, vim.Folder): + paths.append(vobj.name) + + thisobj = vobj + while hasattr(thisobj, 'parent'): + thisobj = thisobj.parent + try: + moid = thisobj._moId + except AttributeError: + moid = None + if moid in ['group-d1', 'ha-folder-root']: + break + if isinstance(thisobj, vim.Folder): + paths.append(thisobj.name) + paths.reverse() + return '/' + '/'.join(paths) + + +def _get_vm_prop(vm, attributes): + """Safely get a property or return None""" + result = vm + for attribute in attributes: + try: + result = getattr(result, attribute) + except (AttributeError, IndexError): + return None + return result + + +def gather_vm_facts(content, vm): + """ Gather facts from vim.VirtualMachine object. """ + facts = { + 'module_hw': True, + 'hw_name': vm.config.name, + 'hw_power_status': vm.summary.runtime.powerState, + 'hw_guest_full_name': vm.summary.guest.guestFullName, + 'hw_guest_id': vm.summary.guest.guestId, + 'hw_product_uuid': vm.config.uuid, + 'hw_processor_count': vm.config.hardware.numCPU, + 'hw_cores_per_socket': vm.config.hardware.numCoresPerSocket, + 'hw_memtotal_mb': vm.config.hardware.memoryMB, + 'hw_interfaces': [], + 'hw_datastores': [], + 'hw_files': [], + 'hw_esxi_host': None, + 'hw_guest_ha_state': None, + 'hw_is_template': vm.config.template, + 'hw_folder': None, + 'hw_version': vm.config.version, + 'instance_uuid': vm.config.instanceUuid, + 'guest_tools_status': _get_vm_prop(vm, ('guest', 'toolsRunningStatus')), + 'guest_tools_version': _get_vm_prop(vm, ('guest', 'toolsVersion')), + 'guest_question': json.loads(json.dumps(vm.summary.runtime.question, cls=VmomiSupport.VmomiJSONEncoder, + sort_keys=True, strip_dynamic=True)), + 'guest_consolidation_needed': vm.summary.runtime.consolidationNeeded, + 'ipv4': None, + 'ipv6': None, + 'annotation': vm.config.annotation, + 'customvalues': {}, + 'snapshots': [], + 'current_snapshot': None, + 'vnc': {}, + 'moid': vm._moId, + 'vimref': "vim.VirtualMachine:%s" % vm._moId, + 'advanced_settings': {}, + } + + # facts that may or may not exist + if vm.summary.runtime.host: + try: + host = vm.summary.runtime.host + facts['hw_esxi_host'] = host.summary.config.name + facts['hw_cluster'] = host.parent.name if host.parent and isinstance(host.parent, vim.ClusterComputeResource) else None + + except vim.fault.NoPermission: + # User does not have read permission for the host system, + # proceed without this value. This value does not contribute or hamper + # provisioning or power management operations. + pass + if vm.summary.runtime.dasVmProtection: + facts['hw_guest_ha_state'] = vm.summary.runtime.dasVmProtection.dasProtected + + datastores = vm.datastore + for ds in datastores: + facts['hw_datastores'].append(ds.info.name) + + try: + files = vm.config.files + layout = vm.layout + if files: + facts['hw_files'] = [files.vmPathName] + for item in layout.snapshot: + for snap in item.snapshotFile: + if 'vmsn' in snap: + facts['hw_files'].append(snap) + for item in layout.configFile: + facts['hw_files'].append(os.path.join(os.path.dirname(files.vmPathName), item)) + for item in vm.layout.logFile: + facts['hw_files'].append(os.path.join(files.logDirectory, item)) + for item in vm.layout.disk: + for disk in item.diskFile: + facts['hw_files'].append(disk) + except Exception: + pass + + facts['hw_folder'] = PyVmomi.get_vm_path(content, vm) + + cfm = content.customFieldsManager + # Resolve custom values + for value_obj in vm.summary.customValue: + kn = value_obj.key + if cfm is not None and cfm.field: + for f in cfm.field: + if f.key == value_obj.key: + kn = f.name + # Exit the loop immediately, we found it + break + + facts['customvalues'][kn] = value_obj.value + + # Resolve advanced settings + for advanced_setting in vm.config.extraConfig: + facts['advanced_settings'][advanced_setting.key] = advanced_setting.value + + net_dict = {} + vmnet = _get_vm_prop(vm, ('guest', 'net')) + if vmnet: + for device in vmnet: + if device.deviceConfigId > 0: + net_dict[device.macAddress] = list(device.ipAddress) + + if vm.guest.ipAddress: + if ':' in vm.guest.ipAddress: + facts['ipv6'] = vm.guest.ipAddress + else: + facts['ipv4'] = vm.guest.ipAddress + + ethernet_idx = 0 + for entry in vm.config.hardware.device: + if not hasattr(entry, 'macAddress'): + continue + + if entry.macAddress: + mac_addr = entry.macAddress + mac_addr_dash = mac_addr.replace(':', '-') + else: + mac_addr = mac_addr_dash = None + + if ( + hasattr(entry, "backing") + and hasattr(entry.backing, "port") + and hasattr(entry.backing.port, "portKey") + and hasattr(entry.backing.port, "portgroupKey") + ): + port_group_key = entry.backing.port.portgroupKey + port_key = entry.backing.port.portKey + else: + port_group_key = None + port_key = None + + factname = 'hw_eth' + str(ethernet_idx) + facts[factname] = { + 'addresstype': entry.addressType, + 'label': entry.deviceInfo.label, + 'macaddress': mac_addr, + 'ipaddresses': net_dict.get(entry.macAddress, None), + 'macaddress_dash': mac_addr_dash, + 'summary': entry.deviceInfo.summary, + 'portgroup_portkey': port_key, + 'portgroup_key': port_group_key, + } + facts['hw_interfaces'].append('eth' + str(ethernet_idx)) + ethernet_idx += 1 + + snapshot_facts = list_snapshots(vm) + if 'snapshots' in snapshot_facts: + facts['snapshots'] = snapshot_facts['snapshots'] + facts['current_snapshot'] = snapshot_facts['current_snapshot'] + + facts['vnc'] = get_vnc_extraconfig(vm) + + # Gather vTPM information + facts['tpm_info'] = { + 'tpm_present': vm.summary.config.tpmPresent if hasattr(vm.summary.config, 'tpmPresent') else None, + 'provider_id': vm.config.keyId.providerId.id if vm.config.keyId else None + } + return facts + + +def ansible_date_time_facts(timestamp): + # timestamp is a datetime.datetime object + date_time_facts = {} + if timestamp is None: + return date_time_facts + + utctimestamp = timestamp.astimezone(datetime.timezone.utc) + + date_time_facts['year'] = timestamp.strftime('%Y') + date_time_facts['month'] = timestamp.strftime('%m') + date_time_facts['weekday'] = timestamp.strftime('%A') + date_time_facts['weekday_number'] = timestamp.strftime('%w') + date_time_facts['weeknumber'] = timestamp.strftime('%W') + date_time_facts['day'] = timestamp.strftime('%d') + date_time_facts['hour'] = timestamp.strftime('%H') + date_time_facts['minute'] = timestamp.strftime('%M') + date_time_facts['second'] = timestamp.strftime('%S') + date_time_facts['epoch'] = timestamp.strftime('%s') + date_time_facts['date'] = timestamp.strftime('%Y-%m-%d') + date_time_facts['time'] = timestamp.strftime('%H:%M:%S') + date_time_facts['iso8601_micro'] = utctimestamp.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + date_time_facts['iso8601'] = utctimestamp.strftime("%Y-%m-%dT%H:%M:%SZ") + date_time_facts['iso8601_basic'] = timestamp.strftime("%Y%m%dT%H%M%S%f") + date_time_facts['iso8601_basic_short'] = timestamp.strftime("%Y%m%dT%H%M%S") + date_time_facts['tz'] = timestamp.strftime("%Z") + date_time_facts['tz_offset'] = timestamp.strftime("%z") + + return date_time_facts + + +def deserialize_snapshot_obj(obj): + return {'id': obj.id, + 'name': obj.name, + 'description': obj.description, + 'creation_time': obj.createTime, + 'state': obj.state, + 'quiesced': obj.quiesced} + + +def list_snapshots_recursively(snapshots): + snapshot_data = [] + for snapshot in snapshots: + snapshot_data.append(deserialize_snapshot_obj(snapshot)) + snapshot_data = snapshot_data + list_snapshots_recursively(snapshot.childSnapshotList) + return snapshot_data + + +def get_current_snap_obj(snapshots, snapob): + snap_obj = [] + for snapshot in snapshots: + if snapshot.snapshot == snapob: + snap_obj.append(snapshot) + snap_obj = snap_obj + get_current_snap_obj(snapshot.childSnapshotList, snapob) + return snap_obj + + +def list_snapshots(vm): + result = {} + snapshot = _get_vm_prop(vm, ('snapshot',)) + if not snapshot: + return result + if vm.snapshot is None: + return result + + result['snapshots'] = list_snapshots_recursively(vm.snapshot.rootSnapshotList) + current_snapref = vm.snapshot.currentSnapshot + current_snap_obj = get_current_snap_obj(vm.snapshot.rootSnapshotList, current_snapref) + if current_snap_obj: + result['current_snapshot'] = deserialize_snapshot_obj(current_snap_obj[0]) + else: + result['current_snapshot'] = dict() + return result + + +def get_vnc_extraconfig(vm): + result = {} + for opts in vm.config.extraConfig: + for optkeyname in ['enabled', 'ip', 'port', 'password']: + if opts.key.lower() == "remotedisplay.vnc." + optkeyname: + result[optkeyname] = opts.value + return result + + def vmware_argument_spec(): return dict( hostname=dict(type='str', + aliases=['vcenter_hostname'], required=False, fallback=(env_fallback, ['VMWARE_HOST']), ), username=dict(type='str', - aliases=['user', 'admin'], + aliases=['vcenter_username', 'user', 'admin'], required=False, fallback=(env_fallback, ['VMWARE_USER'])), password=dict(type='str', - aliases=['pass', 'pwd'], + aliases=['vcenter_password', 'pass', 'pwd'], required=False, no_log=True, fallback=(env_fallback, ['VMWARE_PASSWORD'])), @@ -61,6 +695,7 @@ def vmware_argument_spec(): default=443, fallback=(env_fallback, ['VMWARE_PORT'])), validate_certs=dict(type='bool', + aliases=['vcenter_validate_certs'], required=False, default=True, fallback=(env_fallback, ['VMWARE_VALIDATE_CERTS']) @@ -177,3 +812,1211 @@ def _raise_or_fail(msg): if return_si: return service_instance, service_instance.RetrieveContent() return service_instance.RetrieveContent() + + +def get_all_objs(content, vimtype, folder=None, recurse=True): + if not folder: + folder = content.rootFolder + + obj = {} + container = content.viewManager.CreateContainerView(folder, vimtype, recurse) + for managed_object_ref in container.view: + try: + obj.update({managed_object_ref: managed_object_ref.name}) + except vmodl.fault.ManagedObjectNotFound: + pass + return obj + + +def run_command_in_guest(content, vm, username, password, program_path, program_args, program_cwd, program_env): + + result = {'failed': False} + + tools_status = vm.guest.toolsStatus + if (tools_status == 'toolsNotInstalled' or tools_status == 'toolsNotRunning'): + result['failed'] = True + result['msg'] = "VMwareTools is not installed or is not running in the guest" + return result + + # https://github.com/vmware/pyvmomi/blob/master/docs/vim/vm/guest/NamePasswordAuthentication.rst + creds = vim.vm.guest.NamePasswordAuthentication( + username=username, password=password + ) + + try: + # https://github.com/vmware/pyvmomi/blob/master/docs/vim/vm/guest/ProcessManager.rst + pm = content.guestOperationsManager.processManager + # https://www.vmware.com/support/developer/converter-sdk/conv51_apireference/vim.vm.guest.ProcessManager.ProgramSpec.html + ps = vim.vm.guest.ProcessManager.ProgramSpec( + # programPath=program, + # arguments=args + programPath=program_path, + arguments=program_args, + workingDirectory=program_cwd, + ) + + res = pm.StartProgramInGuest(vm, creds, ps) + result['pid'] = res + pdata = pm.ListProcessesInGuest(vm, creds, [res]) + + # wait for pid to finish + while not pdata[0].endTime: + time.sleep(1) + pdata = pm.ListProcessesInGuest(vm, creds, [res]) + + result['owner'] = pdata[0].owner + result['startTime'] = pdata[0].startTime.isoformat() + result['endTime'] = pdata[0].endTime.isoformat() + result['exitCode'] = pdata[0].exitCode + if result['exitCode'] != 0: + result['failed'] = True + result['msg'] = "program exited non-zero" + else: + result['msg'] = "program completed successfully" + + except Exception as e: + result['msg'] = str(e) + result['failed'] = True + + return result + + +def serialize_spec(clonespec): + """Serialize a clonespec or a relocation spec""" + data = {} + attrs = dir(clonespec) + attrs = [x for x in attrs if not x.startswith('_')] + for x in attrs: + xo = getattr(clonespec, x) + if callable(xo): + continue + xt = type(xo) + if xo is None: + data[x] = None + elif isinstance(xo, vim.vm.ConfigSpec): + data[x] = serialize_spec(xo) + elif isinstance(xo, vim.vm.RelocateSpec): + data[x] = serialize_spec(xo) + elif isinstance(xo, vim.vm.device.VirtualDisk): + data[x] = serialize_spec(xo) + elif isinstance(xo, vim.vm.device.VirtualDeviceSpec.FileOperation): + data[x] = to_text(xo) + elif isinstance(xo, vim.Description): + data[x] = { + 'dynamicProperty': serialize_spec(xo.dynamicProperty), + 'dynamicType': serialize_spec(xo.dynamicType), + 'label': serialize_spec(xo.label), + 'summary': serialize_spec(xo.summary), + } + elif hasattr(xo, 'name'): + data[x] = to_text(xo) + ':' + to_text(xo.name) + elif isinstance(xo, vim.vm.ProfileSpec): + pass + elif issubclass(xt, list): + data[x] = [] + for xe in xo: + data[x].append(serialize_spec(xe)) + elif issubclass(xt, string_types + integer_types + (float, bool)): + if issubclass(xt, integer_types): + data[x] = int(xo) + else: + data[x] = to_text(xo) + elif issubclass(xt, bool): + data[x] = xo + elif issubclass(xt, dict): + data[to_text(x)] = {} + for k, v in xo.items(): + k = to_text(k) + data[x][k] = serialize_spec(v) + else: + data[x] = str(xt) + + return data + + +def find_host_by_cluster_datacenter(module, content, datacenter_name, cluster_name, host_name): + dc = find_datacenter_by_name(content, datacenter_name) + if dc is None: + module.fail_json(msg="Unable to find datacenter with name %s" % datacenter_name) + cluster = find_cluster_by_name(content, cluster_name, datacenter=dc) + if cluster is None: + module.fail_json(msg="Unable to find cluster with name %s" % cluster_name) + + for host in cluster.host: + if host.name == host_name: + return host, cluster + + return None, cluster + + +def set_vm_power_state(content, vm, state, force, timeout=0, answers=None): + """ + Set the power status for a VM determined by the current and + requested states. force is forceful + """ + facts = gather_vm_facts(content, vm) + if state == 'present': + state = 'poweredon' + expected_state = state.replace('_', '').replace('-', '').lower() + current_state = facts['hw_power_status'].lower() + result = dict( + changed=False, + failed=False, + ) + + # Need Force + if not force and current_state not in ['poweredon', 'poweredoff']: + result['failed'] = True + result['msg'] = "Virtual Machine is in %s power state. Force is required!" % current_state + result['instance'] = gather_vm_facts(content, vm) + return result + + # State is not already true + if current_state != expected_state: + task = None + try: + if expected_state == 'poweredoff': + task = vm.PowerOff() + + elif expected_state == 'poweredon': + task = vm.PowerOn() + + elif expected_state == 'restarted': + if current_state in ('poweredon', 'poweringon', 'resetting', 'poweredoff'): + task = vm.Reset() + else: + result['failed'] = True + result['msg'] = "Cannot restart virtual machine in the current state %s" % current_state + + elif expected_state == 'suspended': + if current_state in ('poweredon', 'poweringon'): + task = vm.Suspend() + else: + result['failed'] = True + result['msg'] = 'Cannot suspend virtual machine in the current state %s' % current_state + + elif expected_state in ['shutdownguest', 'rebootguest']: + if current_state == 'poweredon': + if vm.guest.toolsRunningStatus == 'guestToolsRunning': + if expected_state == 'shutdownguest': + task = vm.ShutdownGuest() + if timeout > 0: + result.update(wait_for_poweroff(vm, timeout)) + else: + task = vm.RebootGuest() + # Set result['changed'] immediately because + # shutdown and reboot return None. + result['changed'] = True + else: + result['failed'] = True + result['msg'] = "VMware tools should be installed for guest shutdown/reboot" + elif current_state == 'poweredoff': + result['changed'] = False + else: + result['failed'] = True + result['msg'] = "Virtual machine %s must be in poweredon state for guest reboot" % vm.name + + else: + result['failed'] = True + result['msg'] = "Unsupported expected state provided: %s" % expected_state + + except Exception as e: + result['failed'] = True + result['msg'] = to_text(e) + + if task: + try: + wait_for_task(task, vm=vm, answers=answers) + except TaskError as e: + result['failed'] = True + result['msg'] = to_text(e) + finally: + if task.info.state == 'error': + result['failed'] = True + result['msg'] = task.info.error.msg + else: + result['changed'] = True + + # need to get new metadata if changed + result['instance'] = gather_vm_facts(content, vm) + + return result + + +def wait_for_poweroff(vm, timeout=300): + result = dict() + interval = 15 + while timeout > 0: + if vm.runtime.powerState.lower() == 'poweredoff': + break + time.sleep(interval) + timeout -= interval + else: + result['failed'] = True + result['msg'] = 'Timeout while waiting for VM power off.' + return result + + +def is_integer(value, type_of='int'): + try: + VmomiSupport.vmodlTypes[type_of](value) + return True + except (TypeError, ValueError): + return False + + +def is_boolean(value): + if str(value).lower() in ['true', 'on', 'yes', 'false', 'off', 'no']: + return True + return False + + +def is_truthy(value): + if str(value).lower() in ['true', 'on', 'yes']: + return True + return False + + +# options is the dict as defined in the module parameters, current_options is +# the list of the currently set options as returned by the vSphere API. +# When truthy_strings_as_bool is True, strings like 'true', 'off' or 'yes' +# are converted to booleans. +def option_diff(options, current_options, truthy_strings_as_bool=True): + current_options_dict = {} + for option in current_options: + current_options_dict[option.key] = option.value + + change_option_list = [] + for option_key, option_value in options.items(): + if truthy_strings_as_bool and is_boolean(option_value): + option_value = VmomiSupport.vmodlTypes['bool'](is_truthy(option_value)) + elif type(option_value) is int: + option_value = VmomiSupport.vmodlTypes['int'](option_value) + elif type(option_value) is float: + option_value = VmomiSupport.vmodlTypes['float'](option_value) + elif type(option_value) is str: + option_value = VmomiSupport.vmodlTypes['string'](option_value) + + if option_key not in current_options_dict or current_options_dict[option_key] != option_value: + change_option_list.append(vim.option.OptionValue(key=option_key, value=option_value)) + + return change_option_list + + +def quote_obj_name(object_name=None): + """ + Replace special characters in object name + with urllib quote equivalent + + """ + if not object_name: + return None + + SPECIAL_CHARS = OrderedDict({ + '%': '%25', + '/': '%2f', + '\\': '%5c' + }) + for key in SPECIAL_CHARS.keys(): + if key in object_name: + object_name = object_name.replace(key, SPECIAL_CHARS[key]) + + return object_name + + +class PyVmomi(object): + def __init__(self, module): + """ + Constructor + """ + if not HAS_REQUESTS: + module.fail_json(msg=missing_required_lib('requests'), + exception=REQUESTS_IMP_ERR) + + if not HAS_PYVMOMI: + module.fail_json(msg=missing_required_lib('PyVmomi'), + exception=PYVMOMI_IMP_ERR) + + self.module = module + self.params = module.params + self.current_vm_obj = None + self.si, self.content = connect_to_api(self.module, return_si=True) + self.custom_field_mgr = [] + if self.content.customFieldsManager: # not an ESXi + self.custom_field_mgr = self.content.customFieldsManager.field + + def is_vcenter(self): + """ + Check if given hostname is vCenter or ESXi host + Returns: True if given connection is with vCenter server + False if given connection is with ESXi server + + """ + api_type = None + try: + api_type = self.content.about.apiType + except (vmodl.RuntimeFault, vim.fault.VimFault) as exc: + self.module.fail_json(msg="Failed to get status of vCenter server : %s" % exc.msg) + + if api_type == 'VirtualCenter': + return True + elif api_type == 'HostAgent': + return False + + def vcenter_version_at_least(self, version=None): + """ + Check that the vCenter server is at least a specific version number + Args: + version (tuple): a version tuple, for example (6, 7, 0) + Returns: bool + """ + if version: + vc_version = self.content.about.version + return StrictVersion(vc_version) >= StrictVersion('.'.join(map(str, version))) + self.module.fail_json(msg='The passed vCenter version: %s is None.' % version) + + def get_cert_fingerprint(self, fqdn, port, proxy_host=None, proxy_port=None): + if proxy_host: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(1) + sock.connect(( + proxy_host, + proxy_port)) + command = "CONNECT %s:%d HTTP/1.0\r\n\r\n" % (fqdn, port) + sock.send(command.encode()) + buf = sock.recv(8192).decode() + if buf.split()[1] != '200': + self.module.fail_json(msg="Failed to connect to the proxy") + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + der_cert_bin = ctx.wrap_socket(sock, server_hostname=fqdn).getpeercert(True) + sock.close() + else: + try: + pem = ssl.get_server_certificate((fqdn, port)) + except Exception: + self.module.fail_json(msg=f"Cannot connect to host: {fqdn}") + der_cert_bin = ssl.PEM_cert_to_DER_cert(pem) + if der_cert_bin: + string = str(hashlib.sha1(der_cert_bin).hexdigest()) + return ':'.join(a + b for a, b in zip(string[::2], string[1::2])) + else: + self.module.fail_json(msg=f"Unable to obtain certificate fingerprint for host: {fqdn}") + + def get_managed_objects_properties(self, vim_type, properties=None): + """ + Look up a Managed Object Reference in vCenter / ESXi Environment + :param vim_type: Type of vim object e.g, for datacenter - vim.Datacenter + :param properties: List of properties related to vim object e.g. Name + :return: local content object + """ + # Get Root Folder + root_folder = self.content.rootFolder + + if properties is None: + properties = ['name'] + + # Create Container View with default root folder + mor = self.content.viewManager.CreateContainerView(root_folder, [vim_type], True) + + # Create Traversal spec + traversal_spec = vmodl.query.PropertyCollector.TraversalSpec( + name="traversal_spec", + path='view', + skip=False, + type=vim.view.ContainerView + ) + + # Create Property Spec + property_spec = vmodl.query.PropertyCollector.PropertySpec( + type=vim_type, # Type of object to retrieved + all=False, + pathSet=properties + ) + + # Create Object Spec + object_spec = vmodl.query.PropertyCollector.ObjectSpec( + obj=mor, + skip=True, + selectSet=[traversal_spec] + ) + + # Create Filter Spec + filter_spec = vmodl.query.PropertyCollector.FilterSpec( + objectSet=[object_spec], + propSet=[property_spec], + reportMissingObjectsInResults=False + ) + + return self.content.propertyCollector.RetrieveContents([filter_spec]) + + # Virtual Machine related functions + def get_vm(self): + """ + Find unique virtual machine either by UUID, MoID or Name. + Returns: virtual machine object if found, else None. + + """ + vm_obj = None + user_desired_path = None + use_instance_uuid = self.params.get('use_instance_uuid') or False + if 'uuid' in self.params and self.params['uuid']: + if not use_instance_uuid: + vm_obj = find_vm_by_id(self.content, vm_id=self.params['uuid'], vm_id_type="uuid") + elif use_instance_uuid: + vm_obj = find_vm_by_id(self.content, + vm_id=self.params['uuid'], + vm_id_type="instance_uuid") + elif 'name' in self.params and self.params['name']: + objects = self.get_managed_objects_properties(vim_type=vim.VirtualMachine, properties=['name']) + vms = [] + + for temp_vm_object in objects: + if ( + len(temp_vm_object.propSet) == 1 + and unquote(temp_vm_object.propSet[0].val) == self.params["name"] + ): + vms.append(temp_vm_object.obj) + + # get_managed_objects_properties may return multiple virtual machine, + # following code tries to find user desired one depending upon the folder specified. + if len(vms) > 1: + # We have found multiple virtual machines, decide depending upon folder value + if self.params['folder'] is None: + self.module.fail_json(msg="Multiple virtual machines with same name [%s] found, " + "Folder value is a required parameter to find uniqueness " + "of the virtual machine" % self.params['name'], + details="Please see documentation of the vmware_guest module " + "for folder parameter.") + + # Get folder path where virtual machine is located + # User provided folder where user thinks virtual machine is present + user_folder = self.params['folder'] + # User defined datacenter + user_defined_dc = self.params['datacenter'] + # User defined datacenter's object + datacenter_obj = find_datacenter_by_name(self.content, self.params['datacenter']) + # Get Path for Datacenter + dcpath = compile_folder_path_for_object(vobj=datacenter_obj) + + # Nested folder does not return trailing / + if not dcpath.endswith('/'): + dcpath += '/' + + if user_folder in [None, '', '/']: + # User provided blank value or + # User provided only root value, we fail + self.module.fail_json(msg="vmware_guest found multiple virtual machines with same " + "name [%s], please specify folder path other than blank " + "or '/'" % self.params['name']) + elif user_folder.startswith('/vm/'): + # User provided nested folder under VMware default vm folder i.e. folder = /vm/india/finance + user_desired_path = "%s%s%s" % (dcpath, user_defined_dc, user_folder) + else: + # User defined datacenter is not nested i.e. dcpath = '/' , or + # User defined datacenter is nested i.e. dcpath = '/F0/DC0' or + # User provided folder starts with / and datacenter i.e. folder = /ha-datacenter/ or + # User defined folder starts with datacenter without '/' i.e. + # folder = DC0/vm/india/finance or + # folder = DC0/vm + user_desired_path = user_folder + + for vm in vms: + # Check if user has provided same path as virtual machine + actual_vm_folder_path = self.get_vm_path(content=self.content, vm_name=vm) + if not actual_vm_folder_path.startswith("%s%s" % (dcpath, user_defined_dc)): + continue + if user_desired_path in actual_vm_folder_path: + vm_obj = vm + break + elif vms: + # Unique virtual machine found. + actual_vm_folder_path = self.get_vm_path(content=self.content, vm_name=vms[0]) + if self.params.get('folder') is None: + vm_obj = vms[0] + elif self.params['folder'] in actual_vm_folder_path: + vm_obj = vms[0] + elif 'moid' in self.params and self.params['moid']: + vm_obj = VmomiSupport.templateOf('VirtualMachine')(self.params['moid'], self.si._stub) + try: + getattr(vm_obj, 'name') + except vmodl.fault.ManagedObjectNotFound: + vm_obj = None + + if vm_obj: + self.current_vm_obj = vm_obj + + return vm_obj + + def gather_facts(self, vm): + """ + Gather facts of virtual machine. + Args: + vm: Name of virtual machine. + + Returns: Facts dictionary of the given virtual machine. + + """ + return gather_vm_facts(self.content, vm) + + @staticmethod + def get_vm_path(content, vm_name): + """ + Find the path of virtual machine. + Args: + content: VMware content object + vm_name: virtual machine managed object + + Returns: Folder of virtual machine if exists, else None + + """ + folder_name = None + folder = vm_name.parent + if folder: + folder_name = folder.name + fp = folder.parent + # climb back up the tree to find our path, stop before the root folder + while fp is not None and fp.name is not None and fp != content.rootFolder: + folder_name = fp.name + '/' + folder_name + try: + fp = fp.parent + except Exception: + break + folder_name = '/' + folder_name + return folder_name + + def get_vm_or_template(self, template_name=None): + """ + Find the virtual machine or virtual machine template using name + used for cloning purpose. + Args: + template_name: Name of virtual machine or virtual machine template + + Returns: virtual machine or virtual machine template object + + """ + template_obj = None + if not template_name: + return template_obj + + if "/" in template_name: + vm_obj_path = os.path.dirname(template_name) + vm_obj_name = os.path.basename(template_name) + template_obj = find_vm_by_id(self.content, vm_obj_name, vm_id_type="inventory_path", folder=vm_obj_path) + if template_obj: + return template_obj + else: + template_obj = find_vm_by_id(self.content, vm_id=template_name, vm_id_type="uuid") + if template_obj: + return template_obj + + objects = self.get_managed_objects_properties(vim_type=vim.VirtualMachine, properties=['name']) + templates = [] + + for temp_vm_object in objects: + if len(temp_vm_object.propSet) != 1: + continue + for temp_vm_object_property in temp_vm_object.propSet: + if temp_vm_object_property.val == template_name: + templates.append(temp_vm_object.obj) + break + + if len(templates) > 1: + # We have found multiple virtual machine templates + self.module.fail_json(msg="Multiple virtual machines or templates with same name [%s] found." % template_name) + elif templates: + template_obj = templates[0] + + return template_obj + + # Cluster related functions + def find_cluster_by_name(self, cluster_name, datacenter_name=None): + """ + Find Cluster by name in given datacenter + Args: + cluster_name: Name of cluster name to find + datacenter_name: (optional) Name of datacenter + + Returns: True if found + + """ + return find_cluster_by_name(self.content, cluster_name, datacenter=datacenter_name) + + def get_all_hosts_by_cluster(self, cluster_name): + """ + Get all hosts from cluster by cluster name + Args: + cluster_name: Name of cluster + + Returns: List of hosts + + """ + cluster_obj = self.find_cluster_by_name(cluster_name=cluster_name) + if cluster_obj: + return list(cluster_obj.host) + else: + return [] + + # Hosts related functions + def find_hostsystem_by_name(self, host_name, datacenter=None): + """ + Find Host by name + Args: + host_name: Name of ESXi host + datacenter: (optional) Datacenter of ESXi resides + + Returns: True if found + + """ + return find_hostsystem_by_name(self.content, hostname=host_name, datacenter=datacenter) + + def get_all_host_objs(self, cluster_name=None, esxi_host_name=None): + """ + Get all host system managed object + + Args: + cluster_name: Name of Cluster + esxi_host_name: Name of ESXi server + + Returns: A list of all host system managed objects, else empty list + + """ + host_obj_list = [] + if not self.is_vcenter(): + hosts = get_all_objs(self.content, [vim.HostSystem]).keys() + if hosts: + host_obj_list.append(list(hosts)[0]) + else: + if cluster_name: + cluster_obj = self.find_cluster_by_name(cluster_name=cluster_name) + if cluster_obj: + host_obj_list = list(cluster_obj.host) + else: + self.module.fail_json(changed=False, msg="Cluster '%s' not found" % cluster_name) + elif esxi_host_name: + if isinstance(esxi_host_name, str): + esxi_host_name = [esxi_host_name] + + for host in esxi_host_name: + esxi_host_obj = self.find_hostsystem_by_name(host_name=host) + if esxi_host_obj: + host_obj_list.append(esxi_host_obj) + else: + self.module.fail_json(changed=False, msg="ESXi '%s' not found" % host) + + return host_obj_list + + def host_version_at_least(self, version=None, vm_obj=None, host_name=None): + """ + Check that the ESXi Host is at least a specific version number + Args: + vm_obj: virtual machine object, required one of vm_obj, host_name + host_name (string): ESXi host name + version (tuple): a version tuple, for example (6, 7, 0) + Returns: bool + """ + if vm_obj: + host_system = vm_obj.summary.runtime.host + elif host_name: + host_system = self.find_hostsystem_by_name(host_name=host_name) + else: + self.module.fail_json(msg='VM object or ESXi host name must be set one.') + if host_system and version: + host_version = host_system.summary.config.product.version + return StrictVersion(host_version) >= StrictVersion('.'.join(map(str, version))) + else: + self.module.fail_json(msg='Unable to get the ESXi host from vm: %s, or hostname %s,' + 'or the passed ESXi version: %s is None.' % (vm_obj, host_name, version)) + + # Network related functions + @staticmethod + def find_host_portgroup_by_name(host, portgroup_name): + """ + Find Portgroup on given host + Args: + host: Host config object + portgroup_name: Name of portgroup + + Returns: True if found else False + + """ + for portgroup in host.config.network.portgroup: + if portgroup.spec.name == portgroup_name: + return portgroup + return False + + def get_all_port_groups_by_host(self, host_system): + """ + Get all Port Group by host + Args: + host_system: Name of Host System + + Returns: List of Port Group Spec + """ + pgs_list = [] + for pg in host_system.config.network.portgroup: + pgs_list.append(pg) + return pgs_list + + def find_network_by_name(self, network_name=None): + """ + Get network specified by name + Args: + network_name: Name of network + + Returns: List of network managed objects + """ + networks = [] + + if not network_name: + return networks + + objects = self.get_managed_objects_properties(vim_type=vim.Network, properties=['name']) + + for temp_vm_object in objects: + if len(temp_vm_object.propSet) != 1: + continue + for temp_vm_object_property in temp_vm_object.propSet: + if temp_vm_object_property.val == network_name: + networks.append(temp_vm_object.obj) + break + return networks + + def network_exists_by_name(self, network_name=None): + """ + Check if network with a specified name exists or not + Args: + network_name: Name of network + + Returns: True if network exists else False + """ + ret = False + if not network_name: + return ret + ret = True if self.find_network_by_name(network_name=network_name) else False + return ret + + # Datacenter + def find_datacenter_by_name(self, datacenter_name): + """ + Get datacenter managed object by name + + Args: + datacenter_name: Name of datacenter + + Returns: datacenter managed object if found else None + + """ + return find_datacenter_by_name(self.content, datacenter_name=datacenter_name) + + def is_datastore_valid(self, datastore_obj=None): + """ + Check if datastore selected is valid or not + Args: + datastore_obj: datastore managed object + + Returns: True if datastore is valid, False if not + """ + if not datastore_obj \ + or datastore_obj.summary.maintenanceMode != 'normal' \ + or not datastore_obj.summary.accessible: + return False + return True + + def find_datastore_by_name(self, datastore_name, datacenter_name=None): + """ + Get datastore managed object by name + Args: + datastore_name: Name of datastore + datacenter_name: Name of datacenter where the datastore resides. This is needed because Datastores can be + shared across Datacenters, so we need to specify the datacenter to assure we get the correct Managed Object Reference + + Returns: datastore managed object if found else None + + """ + return find_datastore_by_name(self.content, datastore_name=datastore_name, datacenter_name=datacenter_name) + + def find_folder_by_name(self, folder_name): + """ + Get vm folder managed object by name + Args: + folder_name: Name of the vm folder + + Returns: vm folder managed object if found else None + + """ + return find_folder_by_name(self.content, folder_name=folder_name) + + def find_folder_by_fqpn(self, folder_name, datacenter_name=None, folder_type=None): + """ + Get a unique folder managed object by specifying its Fully Qualified Path Name + as datacenter/folder_type/sub1/sub2 + Args: + folder_name: Fully Qualified Path Name folder name + datacenter_name: Name of the datacenter, taken from Fully Qualified Path Name if not defined + folder_type: Type of folder, vm, host, datastore or network, + taken from Fully Qualified Path Name if not defined + + Returns: folder managed object if found, else None + + """ + return find_folder_by_fqpn(self.content, folder_name=folder_name, datacenter_name=datacenter_name, folder_type=folder_type) + + # Datastore cluster + def find_datastore_cluster_by_name(self, datastore_cluster_name, datacenter=None, folder=None): + """ + Get datastore cluster managed object by name + Args: + datastore_cluster_name: Name of datastore cluster + datacenter: Managed object of the datacenter + folder: Managed object of the folder which holds datastore + + Returns: Datastore cluster managed object if found else None + + """ + if datacenter and hasattr(datacenter, 'datastoreFolder'): + folder = datacenter.datastoreFolder + if not folder: + folder = self.content.rootFolder + + data_store_clusters = get_all_objs(self.content, [vim.StoragePod], folder=folder) + for dsc in data_store_clusters: + if dsc.name == datastore_cluster_name: + return dsc + return None + + def get_recommended_datastore(self, datastore_cluster_obj=None): + """ + Return Storage DRS recommended datastore from datastore cluster + Args: + datastore_cluster_obj: datastore cluster managed object + + Returns: Name of recommended datastore from the given datastore cluster + + """ + if datastore_cluster_obj is None: + return None + # Check if Datastore Cluster provided by user is SDRS ready + sdrs_status = datastore_cluster_obj.podStorageDrsEntry.storageDrsConfig.podConfig.enabled + if sdrs_status: + # We can get storage recommendation only if SDRS is enabled on given datastorage cluster + pod_sel_spec = vim.storageDrs.PodSelectionSpec() + pod_sel_spec.storagePod = datastore_cluster_obj + storage_spec = vim.storageDrs.StoragePlacementSpec() + storage_spec.podSelectionSpec = pod_sel_spec + storage_spec.type = 'create' + + try: + rec = self.content.storageResourceManager.RecommendDatastores(storageSpec=storage_spec) + rec_action = rec.recommendations[0].action[0] + return rec_action.destination.name + except Exception: + # There is some error so we fall back to general workflow + pass + datastore = None + datastore_freespace = 0 + for ds in datastore_cluster_obj.childEntity: + if isinstance(ds, vim.Datastore) and ds.summary.freeSpace > datastore_freespace: + # If datastore field is provided, filter destination datastores + if not self.is_datastore_valid(datastore_obj=ds): + continue + + datastore = ds + datastore_freespace = ds.summary.freeSpace + if datastore: + return datastore.name + return None + + # Resource pool + def find_resource_pool_by_name(self, resource_pool_name='Resources', folder=None): + """ + Get resource pool managed object by name + Args: + resource_pool_name: Name of resource pool + + Returns: Resource pool managed object if found else None + + """ + if not folder: + folder = self.content.rootFolder + + resource_pools = get_all_objs(self.content, [vim.ResourcePool], folder=folder) + for rp in resource_pools: + if rp.name == resource_pool_name: + return rp + return None + + def find_resource_pool_by_cluster(self, resource_pool_name='Resources', cluster=None): + """ + Get resource pool managed object by cluster object + Args: + resource_pool_name: Name of resource pool + cluster: Managed object of cluster + + Returns: Resource pool managed object if found else None + + """ + desired_rp = None + if not cluster: + return desired_rp + + if resource_pool_name != 'Resources': + # Resource pool name is different than default 'Resources' + resource_pools = cluster.resourcePool.resourcePool + if resource_pools: + for rp in resource_pools: + if rp.name == resource_pool_name: + desired_rp = rp + break + else: + desired_rp = cluster.resourcePool + + return desired_rp + + # VMDK stuff + def vmdk_disk_path_split(self, vmdk_path): + """ + Takes a string in the format + + [datastore_name] path/to/vm_name.vmdk + + Returns a tuple with multiple strings: + + 1. datastore_name: The name of the datastore (without brackets) + 2. vmdk_fullpath: The "path/to/vm_name.vmdk" portion + 3. vmdk_filename: The "vm_name.vmdk" portion of the string (os.path.basename equivalent) + 4. vmdk_folder: The "path/to/" portion of the string (os.path.dirname equivalent) + """ + try: + datastore_name = re.match(r'^\[(.*?)\]', vmdk_path, re.DOTALL).groups()[0] + vmdk_fullpath = re.match(r'\[.*?\] (.*)$', vmdk_path).groups()[0] + vmdk_filename = os.path.basename(vmdk_fullpath) + vmdk_folder = os.path.dirname(vmdk_fullpath) + return datastore_name, vmdk_fullpath, vmdk_filename, vmdk_folder + except (IndexError, AttributeError) as e: + self.module.fail_json(msg="Bad path '%s' for filename disk vmdk image: %s" % (vmdk_path, to_native(e))) + + def find_vmdk_file(self, datastore_obj, vmdk_fullpath, vmdk_filename, vmdk_folder): + """ + Return vSphere file object or fail_json + Args: + datastore_obj: Managed object of datastore + vmdk_fullpath: Path of VMDK file e.g., path/to/vm/vmdk_filename.vmdk + vmdk_filename: Name of vmdk e.g., VM0001_1.vmdk + vmdk_folder: Base dir of VMDK e.g, path/to/vm + + """ + + browser = datastore_obj.browser + datastore_name = datastore_obj.name + datastore_name_sq = "[" + datastore_name + "]" + if browser is None: + self.module.fail_json(msg="Unable to access browser for datastore %s" % datastore_name) + + detail_query = vim.host.DatastoreBrowser.FileInfo.Details( + fileOwner=True, + fileSize=True, + fileType=True, + modification=True + ) + search_spec = vim.host.DatastoreBrowser.SearchSpec( + details=detail_query, + matchPattern=[vmdk_filename], + searchCaseInsensitive=True, + ) + search_res = browser.SearchSubFolders( + datastorePath=datastore_name_sq, + searchSpec=search_spec + ) + + changed = False + vmdk_path = datastore_name_sq + " " + vmdk_fullpath + try: + changed, result = wait_for_task(search_res) + except TaskError as task_e: + self.module.fail_json(msg=to_native(task_e)) + + if not changed: + self.module.fail_json(msg="No valid disk vmdk image found for path %s" % vmdk_path) + + target_folder_paths = [ + datastore_name_sq + " " + vmdk_folder + '/', + datastore_name_sq + " " + vmdk_folder, + ] + + for file_result in search_res.info.result: + for f in getattr(file_result, 'file'): + if f.path == vmdk_filename and file_result.folderPath in target_folder_paths: + return f + + self.module.fail_json(msg="No vmdk file found for path specified [%s]" % vmdk_path) + + def find_first_class_disk_by_name(self, disk_name, datastore_obj): + """ + Get first-class disk managed object by name + Args: + disk_name: Name of the first-class disk + datastore_obj: Managed object of datastore + + Returns: First-class disk managed object if found else None + + """ + + if self.is_vcenter(): + for id in self.content.vStorageObjectManager.ListVStorageObject(datastore_obj): + disk = self.content.vStorageObjectManager.RetrieveVStorageObject(id, datastore_obj) + if disk.config.name == disk_name: + return disk + else: + for id in self.content.vStorageObjectManager.HostListVStorageObject(datastore_obj): + disk = self.content.vStorageObjectManager.HostRetrieveVStorageObject(id, datastore_obj) + if disk.config.name == disk_name: + return disk + + return None + + # + # Conversion to JSON + # + + def _deepmerge(self, d, u): + """ + Deep merges u into d. + + Credit: + https://bit.ly/2EDOs1B (stackoverflow question 3232943) + License: + cc-by-sa 3.0 (https://creativecommons.org/licenses/by-sa/3.0/) + Changes: + using collections_compat for compatibility + + Args: + - d (dict): dict to merge into + - u (dict): dict to merge into d + + Returns: + dict, with u merged into d + """ + for k, v in iteritems(u): + if isinstance(v, collections_compat.Mapping): + d[k] = self._deepmerge(d.get(k, {}), v) + else: + d[k] = v + return d + + def _extract(self, data, remainder): + """ + This is used to break down dotted properties for extraction. + + Args: + - data (dict): result of _jsonify on a property + - remainder: the remainder of the dotted property to select + + Return: + dict + """ + result = dict() + if '.' not in remainder: + result[remainder] = data[remainder] + return result + key, remainder = remainder.split('.', 1) + if isinstance(data, list): + temp_ds = [] + for i in range(len(data)): + temp_ds.append(self._extract(data[i][key], remainder)) + result[key] = temp_ds + else: + result[key] = self._extract(data[key], remainder) + return result + + def _jsonify(self, obj): + """ + Convert an object from pyVmomi into JSON. + + Args: + - obj (object): vim object + + Return: + dict + """ + return json.loads(json.dumps(obj, cls=VmomiSupport.VmomiJSONEncoder, + sort_keys=True, strip_dynamic=True)) + + def to_json(self, obj, properties=None): + """ + Convert a vSphere (pyVmomi) Object into JSON. This is a deep + transformation. The list of properties is optional - if not + provided then all properties are deeply converted. The resulting + JSON is sorted to improve human readability. + + Requires upstream support from pyVmomi > 6.7.1 + (https://github.com/vmware/pyvmomi/pull/732) + + Args: + - obj (object): vim object + - properties (list, optional): list of properties following + the property collector specification, for example: + ["config.hardware.memoryMB", "name", "overallStatus"] + default is a complete object dump, which can be large + + Return: + dict + """ + if not HAS_PYVMOMIJSON: + self.module.fail_json(msg='The installed version of pyvmomi lacks JSON output support; need pyvmomi>6.7.1') + + result = dict() + if properties: + for prop in properties: + try: + if '.' in prop: + key, remainder = prop.split('.', 1) + tmp = dict() + tmp[key] = self._extract(self._jsonify(getattr(obj, key)), remainder) + self._deepmerge(result, tmp) + else: + result[prop] = self._jsonify(getattr(obj, prop)) + # To match gather_vm_facts output + prop_name = prop + if prop.lower() == '_moid': + prop_name = 'moid' + elif prop.lower() == '_vimref': + prop_name = 'vimref' + result[prop_name] = result[prop] + except (AttributeError, KeyError): + self.module.fail_json(msg="Property '{0}' not found.".format(prop)) + else: + result = self._jsonify(obj) + return result + + def get_folder_path(self, cur): + full_path = '/' + cur.name + while hasattr(cur, 'parent') and cur.parent: + if cur.parent == self.content.rootFolder: + break + cur = cur.parent + full_path = '/' + cur.name + full_path + return full_path + + def find_obj_by_moid(self, object_type, moid): + """ + Get Managed Object based on an object type and moid. + If you'd like to search for a virtual machine, recommended you use get_vm method. + + Args: + - object_type: Managed Object type + It is possible to specify types the following. + ["Datacenter", "ClusterComputeResource", "ResourcePool", "Folder", "HostSystem", + "VirtualMachine", "DistributedVirtualSwitch", "DistributedVirtualPortgroup", "Datastore"] + - moid: moid of Managed Object + :return: Managed Object if it exists else None + """ + + obj = VmomiSupport.templateOf(object_type)(moid, self.si._stub) + try: + getattr(obj, 'name') + except vmodl.fault.ManagedObjectNotFound: + obj = None + + return obj diff --git a/tests/integration/targets/cluster/playbook.yml b/tests/integration/targets/cluster/playbook.yml new file mode 100644 index 00000000..01177b3f --- /dev/null +++ b/tests/integration/targets/cluster/playbook.yml @@ -0,0 +1,8 @@ +- hosts: localhost + gather_facts: no + tasks: + - import_role: + name: prepare_lab + - import_role: + name: cluster + diff --git a/tests/integration/targets/cluster/tasks/add_host.yml b/tests/integration/targets/cluster/tasks/add_host.yml new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/targets/cluster/tasks/create_cluster.yml b/tests/integration/targets/cluster/tasks/create_cluster.yml new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/targets/cluster/tasks/delete_cluster.yml b/tests/integration/targets/cluster/tasks/delete_cluster.yml new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/targets/cluster/tasks/enable_drs.yml b/tests/integration/targets/cluster/tasks/enable_drs.yml new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/targets/cluster/tasks/main.yml b/tests/integration/targets/cluster/tasks/main.yml new file mode 100644 index 00000000..dc22240b --- /dev/null +++ b/tests/integration/targets/cluster/tasks/main.yml @@ -0,0 +1,5 @@ +- include_tasks: create_cluster.yml +- include_tasks: enable_drs.yml +- include_tasks: disable_drs.yml +- include_tasks: add_host.yml +- include_tasks: delete_cluster.yml diff --git a/tests/integration/targets/prepare_vcsim/tasks/main.yml b/tests/integration/targets/prepare_vcsim/tasks/main.yml new file mode 100644 index 00000000..3758618c --- /dev/null +++ b/tests/integration/targets/prepare_vcsim/tasks/main.yml @@ -0,0 +1,7 @@ +--- + # Running the vcsim for each test inside container +- name: Run vcsim + ansible.vmware.vmware_cluster_drs: + datacenter_name: my_dc + cluster_name: my_cluster + enable: yes \ No newline at end of file diff --git a/tests/units/.gitkeep b/tests/unit/.gitkeep similarity index 100% rename from tests/units/.gitkeep rename to tests/unit/.gitkeep diff --git a/tests/unit/plugins/modules/common/utils.py b/tests/unit/plugins/modules/common/utils.py new file mode 100644 index 00000000..bdf5a090 --- /dev/null +++ b/tests/unit/plugins/modules/common/utils.py @@ -0,0 +1,61 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import json + +from ansible.module_utils import basic +from ansible.module_utils._text import to_bytes + +from mock import patch + + +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 + if 'cluster_name' not in args: + args["cluster_name"] = "mycluster" + if 'hostname' not in args: + args["hostname"] = "127.0.0.1" + if 'username' not in args: + args["username"] = "test" + if 'password' not in args: + args["password"] = "test" + + 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: + def setup_method(self): + self.mock_module = patch.multiple( + basic.AnsibleModule, exit_json=exit_json, fail_json=fail_json, + ) + self.mock_module.start() + + def teardown_method(self): + self.mock_module.stop() + + +def generate_name(test_case): + return test_case['name'] diff --git a/tests/unit/plugins/modules/test_cluster_ha.py b/tests/unit/plugins/modules/test_cluster_ha.py new file mode 100644 index 00000000..06af9acd --- /dev/null +++ b/tests/unit/plugins/modules/test_cluster_ha.py @@ -0,0 +1,54 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import sys +import pytest + +from ansible_collections.vmware.vmware.plugins.modules import cluster_ha +from pyVmomi import vim + +from .common.utils import ( + AnsibleExitJson, ModuleTestCase, set_module_args, +) + +pytestmark = pytest.mark.skipif( + sys.version_info < (2, 7), reason="requires python2.7 or higher" +) + + +class TestAffinity(ModuleTestCase): + + def __prepare(self, mocker): + init_mock = mocker.patch.object(cluster_ha.PyVmomi, "__init__") + init_mock.return_value = None + + find_datacenter_by_name = mocker.patch.object(cluster_ha.VMwareCluster, "find_datacenter_by_name") + find_datacenter_by_name.return_value = {} + find_cluster_by_name = mocker.patch.object(cluster_ha.VMwareCluster, "find_cluster_by_name") + find_cluster_by_name.return_value = {} + isolation_response = mocker.patch.object(vim.cluster.DasVmSettings, "IsolationResponse") + isolation_response.return_value = {'clusterIsolationResponse': ""} + + def test_configure_ha(self, mocker): + self.__prepare(mocker) + configure_ha_mock = mocker.patch.object(cluster_ha.VMwareCluster, "configure_ha") + configure_ha_mock.return_value = True, None + + set_module_args( + enable=True, + datacenter="test", + ha_host_monitoring="enabled", + ha_vm_monitoring="vmAndAppMonitoring", + host_isolation_response="powerOff", + ) + cluster_ha.VMwareCluster.params = { + 'slot_based_admission_control': "", + 'failover_host_admission_control': "", + 'reservation_based_admission_control': "" + } + + with pytest.raises(AnsibleExitJson) as c: + cluster_ha.main() + + assert c.value.args[0]["changed"], True diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt new file mode 100644 index 00000000..88ba3ba0 --- /dev/null +++ b/tests/unit/requirements.txt @@ -0,0 +1,3 @@ +requests +pyVim +pyVmomi \ No newline at end of file