From 8eff4cae10f7661a8fb9942e99fb0e251f485f3a Mon Sep 17 00:00:00 2001 From: mrmagooey Date: Thu, 31 Jan 2019 01:35:24 +1100 Subject: [PATCH] VMware: vmware_guest - allow existing vmdk files to be attached to guest (#45953) --- lib/ansible/module_utils/vmware.py | 78 ++++++++++++++++++- .../modules/cloud/vmware/vmware_guest.py | 58 +++++++++++--- test/units/module_utils/test_vmware.py | 35 +++++++-- 3 files changed, 154 insertions(+), 17 deletions(-) diff --git a/lib/ansible/module_utils/vmware.py b/lib/ansible/module_utils/vmware.py index 6b4f96260e7..34f37a064e9 100644 --- a/lib/ansible/module_utils/vmware.py +++ b/lib/ansible/module_utils/vmware.py @@ -8,6 +8,7 @@ __metaclass__ = type import atexit import os +import re import ssl import time from random import randint @@ -26,7 +27,7 @@ try: except ImportError: HAS_PYVMOMI = False -from ansible.module_utils._text import to_text +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 @@ -1162,3 +1163,78 @@ class PyVmomi(object): if dsc.name == datastore_cluster_name: return dsc return None + + # 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_path = 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 == target_folder_path: + return f + + self.module.fail_json(msg="No vmdk file found for path specified [%s]" % vmdk_path) diff --git a/lib/ansible/modules/cloud/vmware/vmware_guest.py b/lib/ansible/modules/cloud/vmware/vmware_guest.py index 5db4c4cfb99..2c1a78ab1df 100644 --- a/lib/ansible/modules/cloud/vmware/vmware_guest.py +++ b/lib/ansible/modules/cloud/vmware/vmware_guest.py @@ -178,6 +178,8 @@ options: - ' - C(eagerzeroedthick) eagerzeroedthick disk, added in version 2.5' - ' Default: C(None) thick disk, no eagerzero.' - ' - C(datastore) (string): Datastore to use for the disk. If C(autoselect_datastore) is enabled, filter datastore selection.' + - ' - C(filename) (string): Existing disk image to be used. Filename must be already exists on the datastore.' + - ' Specify filename string in C([datastore_name] path/to/file.vmdk) format. Added in version 2.8.' - ' - C(autoselect_datastore) (bool): select the less used datastore. Specify only if C(datastore) is not specified.' - ' - C(disk_mode) (string): Type of disk mode. Added in version 2.6' - ' - Available options are :' @@ -564,7 +566,7 @@ import time HAS_PYVMOMI = False try: - from pyVmomi import vim, vmodl + from pyVmomi import vim, vmodl, VmomiSupport HAS_PYVMOMI = True except ImportError: pass @@ -575,7 +577,8 @@ from ansible.module_utils._text import to_text, to_native from ansible.module_utils.vmware import (find_obj, gather_vm_facts, get_all_objs, compile_folder_path_for_object, serialize_spec, vmware_argument_spec, set_vm_power_state, PyVmomi, - find_dvs_by_name, find_dvspg_by_name, wait_for_vm_ip) + find_dvs_by_name, find_dvspg_by_name, wait_for_vm_ip, + wait_for_task, TaskError) class PyVmomiDeviceHelper(object): @@ -661,7 +664,6 @@ class PyVmomiDeviceHelper(object): def create_scsi_disk(self, scsi_ctl, disk_index=None): diskspec = vim.vm.device.VirtualDeviceSpec() diskspec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add - diskspec.fileOperation = vim.vm.device.VirtualDeviceSpec.FileOperation.create diskspec.device = vim.vm.device.VirtualDisk() diskspec.device.backing = vim.vm.device.VirtualDisk.FlatVer2BackingInfo() diskspec.device.controllerKey = scsi_ctl.device.key @@ -1708,6 +1710,38 @@ class PyVmomiHelper(PyVmomi): self.module.fail_json( msg="No size, size_kb, size_mb, size_gb or size_tb attribute found into disk configuration") + def find_vmdk(self, vmdk_path): + """ + Takes a vsphere datastore path in the format + + [datastore_name] path/to/file.vmdk + + Returns vsphere file object or raises RuntimeError + """ + datastore_name, vmdk_fullpath, vmdk_filename, vmdk_folder = self.vmdk_disk_path_split(vmdk_path) + + datastore = self.cache.find_obj(self.content, [vim.Datastore], datastore_name) + + if datastore is None: + self.module.fail_json(msg="Failed to find the datastore %s" % datastore_name) + + return self.find_vmdk_file(datastore, vmdk_fullpath, vmdk_filename, vmdk_folder) + + def add_existing_vmdk(self, vm_obj, expected_disk_spec, diskspec, scsi_ctl): + """ + Adds vmdk file described by expected_disk_spec['filename'], retrieves the file + information and adds the correct spec to self.configspec.deviceChange. + """ + filename = expected_disk_spec['filename'] + # if this is a new disk, or the disk file names are different + if (vm_obj and diskspec.device.backing.fileName != filename) or vm_obj is None: + vmdk_file = self.find_vmdk(expected_disk_spec['filename']) + diskspec.device.backing.fileName = expected_disk_spec['filename'] + diskspec.device.capacityInKB = VmomiSupport.vmodlTypes['long'](vmdk_file.fileSize / 1024) + diskspec.device.key = -1 + self.change_detected = True + self.configspec.deviceChange.append(diskspec) + def configure_disks(self, vm_obj): # Ignore empty disk list, this permits to keep disks when deploying a template/cloning a VM if len(self.params['disk']) == 0: @@ -1741,6 +1775,12 @@ class PyVmomiHelper(PyVmomi): diskspec = self.device_helper.create_scsi_disk(scsi_ctl, disk_index) disk_modified = True + # increment index for next disk search + disk_index += 1 + # index 7 is reserved to SCSI controller + if disk_index == 7: + disk_index += 1 + if 'disk_mode' in expected_disk_spec: disk_mode = expected_disk_spec.get('disk_mode', 'persistent').lower() valid_disk_mode = ['persistent', 'independent_persistent', 'independent_nonpersistent'] @@ -1762,6 +1802,12 @@ class PyVmomiHelper(PyVmomi): elif disk_type == 'eagerzeroedthick': diskspec.device.backing.eagerlyScrub = True + if 'filename' in expected_disk_spec and expected_disk_spec['filename'] is not None: + self.add_existing_vmdk(vm_obj, expected_disk_spec, diskspec, scsi_ctl) + continue + else: + diskspec.fileOperation = vim.vm.device.VirtualDeviceSpec.FileOperation.create + # which datastore? if expected_disk_spec.get('datastore'): # TODO: This is already handled by the relocation spec, @@ -1769,12 +1815,6 @@ class PyVmomiHelper(PyVmomi): # other disks defined pass - # increment index for next disk search - disk_index += 1 - # index 7 is reserved to SCSI controller - if disk_index == 7: - disk_index += 1 - kb = self.get_configured_disk_size(expected_disk_spec) # VMWare doesn't allow to reduce disk sizes if kb < diskspec.device.capacityInKB: diff --git a/test/units/module_utils/test_vmware.py b/test/units/module_utils/test_vmware.py index 56b327f7de8..98d3f7610aa 100644 --- a/test/units/module_utils/test_vmware.py +++ b/test/units/module_utils/test_vmware.py @@ -9,7 +9,7 @@ __metaclass__ = type import sys import pytest -pyvmomi = pytest.importorskip('PyVmomi') +pyvmomi = pytest.importorskip('pyVmomi') from ansible.module_utils.vmware import connect_to_api, PyVmomi @@ -88,6 +88,10 @@ class FakeAnsibleModule: raise FailJson(*args, **kwargs) +def fake_connect_to_api(module): + pass + + def test_pyvmomi_lib_exists(mocker, fake_ansible_module): """ Test if Pyvmomi is present or not""" mocker.patch('ansible.module_utils.vmware.HAS_PYVMOMI', new=False) @@ -119,12 +123,7 @@ def test_required_params(request, params, msg, fake_ansible_module): def test_validate_certs(mocker, fake_ansible_module): """ Test if SSL is required or not""" - fake_ansible_module.params = dict( - username='Administrator@vsphere.local', - password='Esxi@123$%', - hostname='esxi1', - validate_certs=True, - ) + fake_ansible_module.params = test_data[3][0] mocker.patch('ansible.module_utils.vmware.ssl', new=None) with pytest.raises(FailJson) as exec_info: @@ -132,3 +131,25 @@ def test_validate_certs(mocker, fake_ansible_module): msg = 'pyVim does not support changing verification mode with python < 2.7.9.' \ ' Either update python or use validate_certs=false.' assert msg == exec_info.value.kwargs['msg'] + + +def test_vmdk_disk_path_split(mocker, fake_ansible_module): + """ Test vmdk_disk_path_split function""" + fake_ansible_module.params = test_data[0][0] + + mocker.patch('ansible.module_utils.vmware.connect_to_api', new=fake_connect_to_api) + pyv = PyVmomi(fake_ansible_module) + v = pyv.vmdk_disk_path_split('[ds1] VM_0001/VM0001_0.vmdk') + assert v == ('ds1', 'VM_0001/VM0001_0.vmdk', 'VM0001_0.vmdk', 'VM_0001') + + +def test_vmdk_disk_path_split_negative(mocker, fake_ansible_module): + """ Test vmdk_disk_path_split function""" + fake_ansible_module.params = test_data[0][0] + + mocker.patch('ansible.module_utils.vmware.connect_to_api', new=fake_connect_to_api) + with pytest.raises(FailJson) as exec_info: + pyv = PyVmomi(fake_ansible_module) + pyv.vmdk_disk_path_split('[ds1]') + + assert 'Bad path' in exec_info.value.kwargs['msg']