From 2c17f9becda010188e57f1751bfcbdf64a600ff8 Mon Sep 17 00:00:00 2001 From: Ner'zhul Date: Tue, 3 Jan 2017 15:48:07 +0100 Subject: [PATCH] vmware_guest: module rewrite to add many features & fixes (#19228) * vmware_guest: various fixes, improvements & additions * Add template_flag attribute to define if the destination machine is a template * Add helper class to create: * SCSI controller * Disks * Network devices * New feature: create VM without using templates * New feature: multiple NIC * New feature: multiple disks * New feature: custom SCSI controller types (default: paravirtual) * New feature: NIC can now be E1000 or VMXNet3 (default) * New feature: customize NIC mac address * New feature: new disk option autoselect_datastore permit to select the less used datastore. If datastore field is provided, filter the datastore list before selection * New feature: Implement disk resizing + addition when state=present and VM exists * New feature: when state=present and vm exists, modify the current CPU, Memory and disk space * New feature: add guest_id support permitting to customize & change current VM guest ID in VMWare * New feature: resource pool support * New feature: change VM configuration without recreating it (CPU, memory, disks, network, guest ID, resource pool) * Add 'gatherfacts' state to gather facts on a VM instead of previous 'present' state ('present' ensure the VM configuration) * Add PyVmomiCache class to cache read only object * Various python code fixes * Various documentation fixes * esxi_hostname & cluster are now exclusive * Drop ips attribute & set ip directly into networks * Little performance fixes by removing some duplicate calls to VMWare API * Python 3 portability fixes * Create many functions to make the code maintainable * Cleanup some useless attributes * Add 'suspended' as desired state for VM * Make guest_id, memory & CPU number optional in reconfiguration mode * Note: guest_id is now mandatory to create a VM from scratch (not templating) * Bux fixes + Do network IP optinal + Add network vlan option --- .../modules/cloud/vmware/vmware_guest.py | 1224 +++++++++++------ 1 file changed, 799 insertions(+), 425 deletions(-) diff --git a/lib/ansible/modules/cloud/vmware/vmware_guest.py b/lib/ansible/modules/cloud/vmware/vmware_guest.py index 5a24704c369..82f51187355 100644 --- a/lib/ansible/modules/cloud/vmware/vmware_guest.py +++ b/lib/ansible/modules/cloud/vmware/vmware_guest.py @@ -1,5 +1,8 @@ #!/usr/bin/python # -*- coding: utf-8 -*- +# +# This module is also sponsored by E.T.A.I. (www.etai.fr) +# # This file is part of Ansible # # Ansible is free software: you can redistribute it and/or modify @@ -29,7 +32,9 @@ description: - poweron/poweroff/restart a virtualmachine - remove a virtualmachine version_added: 2.2 -author: James Tanner (@jctanner) +author: + - James Tanner (@jctanner) + - Loic Blot (@nerzhul) notes: - Tested on vSphere 6.0 requirements: @@ -39,6 +44,7 @@ options: state: description: - What state should the virtualmachine be in? + - if state is set to present and VM exists, ensure the VM configuration if conform to task arguments required: True choices: ['present', 'absent', 'poweredon', 'poweredoff', 'restarted', 'suspended'] name: @@ -47,7 +53,7 @@ options: required: True name_match: description: - - If multiple vms matching the name, use the first or last found + - If multiple vms matching the name, use the first or last found required: False default: 'first' choices: ['first', 'last'] @@ -58,25 +64,48 @@ options: required: False template: description: - - Name of the template to deploy, if needed to create the guest (state=present). + - Template used to create guest. + - If this value is not set, VM is created without using a template. - If the guest exists already this setting will be ignored. required: False + is_template: + description: + - Flag the instance as a template + required: False + default: False + version_added: "2.3" folder: description: - Destination folder path for the new guest required: False hardware: description: - - Attributes such as cpus, memory, osid, and disk controller + - "Manage some VM hardware attributes." + - "Valid attributes are: memory_mb, num_cpus and scsi" + - "scsi: Valid values are buslogic, lsilogic, lsilogicsas and paravirtual (default)" required: False + guest_id: + description: + - "Set the guest ID (Debian, RHEL, Windows...)" + - "This field is required when creating a VM" + - "Valid values are referenced here: https://www.vmware.com/support/developer/converter-sdk/conv55_apireference/vim.vm.GuestOsDescriptor.GuestOsIdentifier.html" + required: False + version_added: "2.3" disk: description: - - A list of disks to add + - "A list of disks to add" + - "Valid attributes are: size_[tb,gb,mb,kb], type, datastore and autoselect_datastore" + - "type: Valid value is thin (default: None)" + - "datastore: Datastore to use for the disk. If autoselect_datastore is True, filter datastore selection." + - "autoselect_datastore (bool): select the less used datastore." required: False - nic: + resource_pool: description: - - A list of nics to add + - Affect machine to the given resource pool + - Resource pool should be child of the selected host parent required: False + default: None + version_added: "2.3" wait_for_ip_address: description: - Wait until vcenter detects an IP address for the guest @@ -89,6 +118,11 @@ options: description: - Destination datacenter for the deploy operation required: True + cluster: + description: + - The cluster name where the VM will run. + required: False + version_added: "2.3" esxi_hostname: description: - The esxi hostname where the VM will run. @@ -100,17 +134,13 @@ options: version_added: "2.3" customize: description: - - Should customization spec be run - required: False - version_added: "2.3" - ips: - description: - - IP Addresses to set + - Should customization spec be applied. This is only used when deploying a template. required: False version_added: "2.3" - networks: + networks: description: - - Network to use should include VM network name and gateway + - Network to use should include VM network name or VLAN, ip and gateway + - "You can add 'mac' optional field to customize mac address" required: False version_added: "2.3" dns_servers: @@ -118,7 +148,7 @@ options: - DNS servers to use required: False version_added: "2.3" - domain: + domain: description: - Domain to use while customizing required: False @@ -128,7 +158,7 @@ options: - A key, value pair of snapshot operation types and their additional required parameters. required: False version_added: "2.3" -extends_documentation_fragment: vmware.documentation +extends_documentation_fragment: vmware.documentation ''' EXAMPLES = ''' @@ -145,18 +175,14 @@ Example from Ansible playbook name: testvm_2 state: poweredon folder: testvms + guest_id: centos64guest disk: - size_gb: 10 type: thin datastore: g73_datastore - nic: - - type: vmxnet3 - network: VM Network - network_type: standard hardware: memory_mb: 512 num_cpus: 1 - osid: centos64guest scsi: paravirtual datacenter: datacenter1 esxi_hostname: 192.0.2.117 @@ -164,6 +190,33 @@ Example from Ansible playbook wait_for_ip_address: yes register: deploy +# +# Create a VM and flag it as a template +# + - name: create VM template + vmware_guest: + validate_certs: False + hostname: 192.0.2.88 + username: administrator@vsphere.local + password: vmware + name: testvm_6 + folder: testvms + is_template: yes + guest_id: debian6_64Guest + resource_pool: highperformance_pool + disk: + - size_gb: 10 + type: thin + datastore: g73_datastore + hardware: + memory_mb: 512 + num_cpus: 1 + scsi: lsilogic + datacenter: datacenter1 + cluster: vmware_cluster_esx + wait_for_ip_address: yes + register: deploy + # # Clone Template and customize # @@ -181,11 +234,12 @@ Example from Ansible playbook customize: True domain: "example.com" dns_servers: ['192.168.1.1','192.168.1.2'] - ips: "192.168.1.100" networks: '192.168.1.0/24': network: 'VM Network' - gateway: '192.168.1.1' + gateway: '192.168.1.1' + ip: "192.168.1.100" + mac: "aa:bb:dd:aa:00:14" # # Gather facts only # @@ -282,6 +336,7 @@ HAS_PYVMOMI = False try: import pyVmomi from pyVmomi import vim + HAS_PYVMOMI = True except ImportError: pass @@ -292,30 +347,154 @@ from netaddr import IPNetwork, IPAddress from ansible.module_utils.urls import fetch_url -class PyVmomiHelper(object): + +class PyVmomiDeviceHelper(object): + """ This class is a helper to create easily VMWare Objects for PyVmomiHelper """ def __init__(self, module): + self.module = module + self.next_disk_unit_number = 0 + + @staticmethod + def create_scsi_controller(scsi_type): + scsi_ctl = vim.vm.device.VirtualDeviceSpec() + scsi_ctl.operation = vim.vm.device.VirtualDeviceSpec.Operation.add + if scsi_type == 'lsilogic': + scsi_ctl.device = vim.vm.device.VirtualLsiLogicController() + elif scsi_type == 'paravirtual': + scsi_ctl.device = vim.vm.device.ParaVirtualSCSIController() + elif scsi_type == 'buslogic': + scsi_ctl.device = vim.vm.device.VirtualBusLogicController() + elif scsi_type == 'lsilogicsas': + scsi_ctl.device = vim.vm.device.VirtualLsiLogicSASController() + + scsi_ctl.device.deviceInfo = vim.Description() + scsi_ctl.device.slotInfo = vim.vm.device.VirtualDevice.PciBusSlotInfo() + scsi_ctl.device.slotInfo.pciSlotNumber = 16 + scsi_ctl.device.controllerKey = 100 + scsi_ctl.device.unitNumber = 3 + scsi_ctl.device.busNumber = 0 + scsi_ctl.device.hotAddRemove = True + scsi_ctl.device.sharedBus = 'noSharing' + scsi_ctl.device.scsiCtlrUnitNumber = 7 + + return scsi_ctl + + @staticmethod + def is_scsi_controller(device): + return isinstance(device, vim.vm.device.VirtualLsiLogicController) or \ + isinstance(device, vim.vm.device.ParaVirtualSCSIController) or \ + isinstance(device, vim.vm.device.VirtualBusLogicController) or \ + isinstance(device, vim.vm.device.VirtualLsiLogicSASController) + + 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.backing.diskMode = 'persistent' + diskspec.device.controllerKey = scsi_ctl.device.key + + assert self.next_disk_unit_number != 7 + assert disk_index != 7 + """ + Configure disk unit number. + """ + if disk_index is not None: + diskspec.device.unitNumber = disk_index + self.next_disk_unit_number = disk_index + 1 + else: + diskspec.device.unitNumber = self.next_disk_unit_number + self.next_disk_unit_number += 1 + + # unit number 7 is reserved to SCSI controller, increase next index + if self.next_disk_unit_number == 7: + self.next_disk_unit_number += 1 + + return diskspec + + def create_nic(self, device_type, device_label, device_infos): + nic = vim.vm.device.VirtualDeviceSpec() + if device_type == 'vmxnet3': + nic.device = vim.vm.device.VirtualVmxnet3() + elif device_type == 'e1000': + nic.device = vim.vm.device.VirtualE1000() + else: + self.module.fail_json(msg="invalid device_type '%s' for network %s" % + (device_type, device_infos['network'])) + + nic.device.wakeOnLanEnabled = True + nic.device.addressType = 'assigned' + nic.device.deviceInfo = vim.Description() + nic.device.deviceInfo.label = device_label + nic.device.deviceInfo.summary = device_infos['network'] + nic.device.connectable = vim.vm.device.VirtualDevice.ConnectInfo() + nic.device.connectable.startConnected = True + nic.device.connectable.allowGuestControl = True + nic.device.connectable.connected = True + if 'mac' in device_infos: + nic.device.macAddress = device_infos['mac'] + + return nic + + +class PyVmomiCache(object): + """ This class caches references to objects which are requested multiples times but not modified """ + def __init__(self, content): + self.content = content + self.networks = {} + self.clusters = {} + self.esx_hosts = {} + def get_network(self, network): + if network not in self.networks: + self.networks[network] = get_obj(self.content, [vim.Network], network) + + return self.networks[network] + + def get_cluster(self, cluster): + if cluster not in self.clusters: + self.clusters[cluster] = get_obj(self.content, [vim.ClusterComputeResource], cluster) + + return self.clusters[cluster] + + def get_esx_host(self, host): + if host not in self.esx_hosts: + self.esx_hosts[host] = get_obj(self.content, [vim.HostSystem], host) + + return self.esx_hosts[host] + + +class PyVmomiHelper(object): + def __init__(self, module): if not HAS_PYVMOMI: module.fail_json(msg='pyvmomi module required') self.module = module + self.device_helper = PyVmomiDeviceHelper(self.module) self.params = module.params self.si = None - self.smartconnect() + self.content = connect_to_api(self.module) self.datacenter = None self.folders = None - self.foldermap = None + self.foldermap = {'fvim_by_path': {}, 'path_by_fvim': {}, 'path_by_vvim': {}, 'paths': {}, + 'uuids': {}} + self.configspec = None + self.change_detected = False + self.customspec = None + self.current_vm_obj = None + self.cache = PyVmomiCache(self.content) - def smartconnect(self): - self.content = connect_to_api(self.module) + def should_deploy_from_template(self): + return 'template' in self.params and self.params['template'] is not None - def _build_folder_tree(self, folder, tree={}, treepath=None): + def _build_folder_tree(self, folder): tree = {'virtualmachines': [], - 'subfolders': {}, - 'vimobj': folder, - 'name': folder.name} + 'subfolders': {}, + 'vimobj': folder, + 'name': folder.name} children = None if hasattr(folder, 'childEntity'): @@ -335,46 +514,23 @@ class PyVmomiHelper(object): return folder return tree + def _build_folder_map(self, folder, inpath='/'): - def _build_folder_map(self, folder, vmap={}, inpath='/'): - - ''' Build a searchable index for vms+uuids+folders ''' - + """ Build a searchable index for vms+uuids+folders """ if isinstance(folder, tuple): folder = folder[1] - if not 'names' in vmap: - vmap['names'] = {} - if not 'uuids' in vmap: - vmap['uuids'] = {} - if not 'paths' in vmap: - vmap['paths'] = {} - if inpath == '/': thispath = '/vm' else: thispath = os.path.join(inpath, folder['name']) - if thispath not in vmap['paths']: - vmap['paths'][thispath] = [] + if thispath not in self.foldermap['paths']: + self.foldermap['paths'][thispath] = [] - # helpful for isolating folder objects later on - if not 'path_by_fvim' in vmap: - vmap['path_by_fvim'] = {} - if not 'fvim_by_path' in vmap: - vmap['fvim_by_path'] = {} # store object by path and store path by object - vmap['fvim_by_path'][thispath] = folder['vimobj'] - vmap['path_by_fvim'][folder['vimobj']] = thispath - - # helpful for isolating vm objects later on - if not 'path_by_vvim' in vmap: - vmap['path_by_vvim'] = {} - if not 'vvim_by_path' in vmap: - vmap['vvim_by_path'] = {} - if thispath not in vmap['vvim_by_path']: - vmap['vvim_by_path'][thispath] = [] - + self.foldermap['fvim_by_path'][thispath] = folder['vimobj'] + self.foldermap['path_by_fvim'][folder['vimobj']] = thispath for item in folder.items(): k = item[0] @@ -384,31 +540,25 @@ class PyVmomiHelper(object): pass elif k == 'subfolders': for x in v.items(): - vmap = self._build_folder_map(x, vmap=vmap, inpath=thispath) + self._build_folder_map(x, inpath=thispath) elif k == 'virtualmachines': for x in v: - if not x.config.name in vmap['names']: - vmap['names'][x.config.name] = [] - vmap['names'][x.config.name].append(x.config.uuid) - vmap['uuids'][x.config.uuid] = x.config.name - vmap['paths'][thispath].append(x.config.uuid) - - if x not in vmap['vvim_by_path'][thispath]: - vmap['vvim_by_path'][thispath].append(x) - if x not in vmap['path_by_vvim']: - vmap['path_by_vvim'][x] = thispath - return vmap + self.foldermap['uuids'][x.config.uuid] = x.config.name + self.foldermap['paths'][thispath].append(x.config.uuid) - def getfolders(self): + if x not in self.foldermap['path_by_vvim']: + self.foldermap['path_by_vvim'][x] = thispath + def getfolders(self): if not self.datacenter: self.get_datacenter() self.folders = self._build_folder_tree(self.datacenter.vmFolder) - self.folder_map = self._build_folder_map(self.folders) - return (self.folders, self.folder_map) + self._build_folder_map(self.folders) + + @staticmethod + def compile_folder_path_for_object(vobj): + """ make a /vm/foo/bar/baz like folder path for an object """ - def compile_folder_path_for_object(self, vobj): - ''' make a /vm/foo/bar/baz like folder path for an object ''' paths = [] if isinstance(vobj, vim.Folder): paths.append(vobj.name) @@ -424,23 +574,20 @@ class PyVmomiHelper(object): return '/' + '/'.join(paths) def get_datacenter(self): - self.datacenter = get_obj(self.content, [vim.Datacenter], - self.params['datacenter']) + self.datacenter = get_obj(self.content, [vim.Datacenter], + self.params['datacenter']) - def getvm(self, name=None, uuid=None, folder=None, name_match=None): + def getvm(self, name=None, uuid=None, folder=None, name_match=None, cache=False): # https://www.vmware.com/support/developer/vc-sdk/visdk2xpubs/ReferenceGuide/vim.SearchIndex.html # self.si.content.searchIndex.FindByInventoryPath('DC1/vm/test_folder') vm = None - folder_path = None searchpath = None if uuid: vm = self.content.searchIndex.FindByUuid(uuid=uuid, vmSearch=True) - elif folder: - if self.params['folder'].endswith('/'): self.params['folder'] = self.params['folder'][0:-1] @@ -455,10 +602,12 @@ class PyVmomiHelper(object): # need to look for matching absolute path if not self.folders: self.getfolders() - paths = self.folder_map['paths'].keys() + paths = self.foldermap['paths'].keys() paths = [x for x in paths if x.endswith(self.params['folder'])] if len(paths) > 1: - self.module.fail_json(msg='%s matches more than one folder. Please use the absolute path starting with /vm/' % self.params['folder']) + self.module.fail_json( + msg='%s matches more than one folder. Please use the absolute path starting with /vm/' % + self.params['folder']) elif paths: searchpath = paths[0] @@ -476,21 +625,20 @@ class PyVmomiHelper(object): break if not vm: - # FIXME - this is unused if folder has a default value - vmList = get_all_objs(self.content, [vim.VirtualMachine]) - # narrow down by folder if folder: if not self.folders: self.getfolders() # compare the folder path of each VM against the search path + vmList = get_all_objs(self.content, [vim.VirtualMachine]) for item in vmList.items(): vobj = item[0] if not isinstance(vobj.parent, vim.Folder): continue if self.compile_folder_path_for_object(vobj) == searchpath: + self.current_vm_obj = vobj return vobj if name_match: @@ -498,26 +646,27 @@ class PyVmomiHelper(object): vm = get_obj(self.content, [vim.VirtualMachine], name) elif name_match == 'last': matches = [] - vmList = get_all_objs(self.content, [vim.VirtualMachine]) - for thisvm in vmList: + for thisvm in get_all_objs(self.content, [vim.VirtualMachine]): if thisvm.config.name == name: matches.append(thisvm) if matches: vm = matches[-1] - else: + else: matches = [] - vmList = get_all_objs(self.content, [vim.VirtualMachine]) - for thisvm in vmList: + for thisvm in get_all_objs(self.content, [vim.VirtualMachine]): if thisvm.config.name == name: matches.append(thisvm) if len(matches) > 1: - module.fail_json(msg='more than 1 vm exists by the name %s. Please specify a uuid, or a folder, or a datacenter or name_match' % name) + self.module.fail_json( + msg='more than 1 vm exists by the name %s. Please specify a uuid, or a folder, ' + 'or a datacenter or name_match' % name) if matches: vm = matches[0] + if cache and vm: + self.current_vm_obj = vm return vm - def set_powerstate(self, vm, state, force): """ Set the power status for a VM determined by the current and @@ -546,14 +695,20 @@ class PyVmomiHelper(object): task = vm.PowerOn() elif expected_state == 'restarted': - if current_state in ('poweredon', 'poweringon', 'resetting'): + if current_state in ('poweredon', 'poweringon', 'resetting', 'poweredoff'): task = vm.Reset() else: - result = {'changed': False, 'failed': True, + result = {'changed': False, 'failed': True, 'msg': "Cannot restart VM in the current state %s" % current_state} + elif expected_state == 'suspended': + if current_state in ('poweredon', 'poweringon'): + task = vm.Suspend() + else: + result = {'changed': False, 'failed': True, + 'msg': 'Cannot suspend VM in the current state %s' % current_state} except Exception: - result = {'changed': False, 'failed': True, + result = {'changed': False, 'failed': True, 'msg': get_exception()} if task: @@ -570,31 +725,30 @@ class PyVmomiHelper(object): result['instance'] = facts return result + @staticmethod + def gather_facts(vm): - def gather_facts(self, vm): - - ''' Gather facts from vim.VirtualMachine object. ''' + """ 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_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_memtotal_mb': vm.config.hardware.memoryMB, - 'hw_interfaces':[], + 'hw_interfaces': [], 'ipv4': None, 'ipv6': None, } netDict = {} for device in vm.guest.net: - mac = device.macAddress - ips = list(device.ipAddress) - netDict[mac] = ips - for k,v in netDict.items(): + netDict[device.macAddress] = list(device.ipAddress) + + for k, v in iteritems(netDict): for ipaddress in v: if ipaddress: if '::' in ipaddress: @@ -602,11 +756,12 @@ class PyVmomiHelper(object): else: facts['ipv4'] = ipaddress - for idx,entry in enumerate(vm.config.hardware.device): + ethernet_idx = 0 + for idx, entry in enumerate(vm.config.hardware.device): if not hasattr(entry, 'macAddress'): continue - factname = 'hw_eth' + str(idx) + factname = 'hw_eth' + str(ethernet_idx) facts[factname] = { 'addresstype': entry.addressType, 'label': entry.deviceInfo.label, @@ -615,303 +770,482 @@ class PyVmomiHelper(object): 'macaddress_dash': entry.macAddress.replace(':', '-'), 'summary': entry.deviceInfo.summary, } - facts['hw_interfaces'].append('eth'+str(idx)) + facts['hw_interfaces'].append('eth' + str(ethernet_idx)) + ethernet_idx += 1 return facts - def remove_vm(self, vm): # https://www.vmware.com/support/developer/converter-sdk/conv60_apireference/vim.ManagedEntity.html#destroy task = vm.Destroy() self.wait_for_task(task) if task.info.state == 'error': - return ({'changed': False, 'failed': True, 'msg': task.info.error.msg}) + return {'changed': False, 'failed': True, 'msg': task.info.error.msg} else: - return ({'changed': True, 'failed': False}) - + return {'changed': True, 'failed': False} - def deploy_template(self, poweron=False, wait_for_ip=False): + def configure_guestid(self, vm_obj, vm_creation=False): + # guest_id is only mandatory on VM creation + if vm_creation and self.params['guest_id'] is None: + self.module.fail_json(msg="guest_id attribute is mandatory for VM creation") - # https://github.com/vmware/pyvmomi-community-samples/blob/master/samples/clone_vm.py - # https://www.vmware.com/support/developer/vc-sdk/visdk25pubs/ReferenceGuide/vim.vm.CloneSpec.html - # https://www.vmware.com/support/developer/vc-sdk/visdk25pubs/ReferenceGuide/vim.vm.ConfigSpec.html - # https://www.vmware.com/support/developer/vc-sdk/visdk41pubs/ApiReference/vim.vm.RelocateSpec.html + if vm_obj is None or self.configspec.guestId != vm_obj.summary.guest.guestId: + self.change_detected = True - # FIXME: - # - clusters - # - multiple datacenters - # - resource pools - # - multiple templates by the same name - # - multiple disks - # - changing the esx host is ignored? - # - static IPs + self.configspec.guestId = self.params['guest_id'] - # FIXME: need to search for this in the same way as guests to ensure accuracy - template = get_obj(self.content, [vim.VirtualMachine], self.params['template']) - if not template: - self.module.fail_json(msg="Could not find a template named %s" % self.params['template']) + def configure_cpu_and_memory(self, vm_obj, vm_creation=False): + # set cpu/memory/etc + if 'hardware' in self.params: + if 'num_cpus' in self.params['hardware']: + self.configspec.numCPUs = int(self.params['hardware']['num_cpus']) + if vm_obj is None or self.configspec.numCPUs != vm_obj.config.hardware.numCPU: + self.change_detected = True + # num_cpu is mandatory for VM creation + elif vm_creation and not self.should_deploy_from_template(): + self.module.fail_json(msg="hardware.num_cpus attribute is mandatory for VM creation") - datacenters = get_all_objs(self.content, [vim.Datacenter]) - datacenter = get_obj(self.content, [vim.Datacenter], - self.params['datacenter']) - if not datacenter: - self.module.fail_json(msg='No datacenter named %s was found' % self.params['datacenter']) + if 'memory_mb' in self.params['hardware']: + self.configspec.memoryMB = int(self.params['hardware']['memory_mb']) + if vm_obj is None or self.configspec.memoryMB != vm_obj.config.hardware.memoryMB: + self.change_detected = True + # memory_mb is mandatory for VM creation + elif vm_creation and not self.should_deploy_from_template(): + self.module.fail_json(msg="hardware.memory_mb attribute is mandatory for VM creation") + + def get_vm_network_interfaces(self, vm=None): + if vm is None: + return [] + + device_list = [] + for device in vm.config.hardware.device: + if isinstance(device, vim.vm.device.VirtualVmxnet3) or isinstance(device, vim.vm.device.VirtualE1000): + device_list.append(device) + + return device_list + + def configure_network(self, vm_obj): + # Ignore empty networks, this permits to keep networks when deploying a template/cloning a VM + if len(self.params['networks']) == 0: + return + + network_devices = list() + for network in self.params['networks']: + if network: + if 'ip' in self.params['networks'][network]: + ip = self.params['networks'][network]['ip'] + if ip not in IPNetwork(network): + self.module.fail_json(msg="ip '%s' not in network %s" % (ip, network)) + + ipnet = IPNetwork(network) + self.params['networks'][network]['subnet_mask'] = str(ipnet.netmask) + + if 'network' in self.params['networks'][network]: + if get_obj(self.content, [vim.Network], self.params['networks'][network]['network']) is None: + self.module.fail_json(msg="Network %s doesn't exists" % network) + elif 'vlan' in self.params['networks'][network]: + network_name = None + dvps = get_all_objs(self.content, [vim.dvs.DistributedVirtualPortgroup]) + for dvp in dvps: + if dvp.config.defaultPortConfig.vlan.vlanId == self.params['networks'][network]['vlan']: + network_name = dvp.config.name + break + if network_name: + self.params['networks'][network]['network'] = network_name + else: + self.module.fail_json(msg="VLAN %s doesn't exists" % self.params['networks'][network]['vlan']) + else: + self.module.fail_json(msg="You need to define a network or a vlan") + + network_devices.append(self.params['networks'][network]) + + adaptermaps = [] + + # List current device for Clone or Idempotency + current_net_devices = self.get_vm_network_interfaces(vm=vm_obj) + if len(network_devices) < len(current_net_devices): + self.module.fail_json(msg="given network device list is lesser than current VM device list (%d < %d). " + "Removing interfaces is not allowed" + % (len(network_devices), len(current_net_devices))) + + for key in range(0, len(network_devices)): + # Default device type is vmxnet3, VMWare best practice + device_type = network_devices[key]['device_type'] \ + if 'device_type' in network_devices[key] else 'vmxnet3' + + nic = self.device_helper.create_nic(device_type, + 'Network Adapter %s' % (key + 1), + network_devices[key]) + + nic_change_detected = False + if key < len(current_net_devices) and (vm_obj or self.should_deploy_from_template()): + nic.operation = vim.vm.device.VirtualDeviceSpec.Operation.edit + # Changing mac address has no effect when editing interface + if 'mac' in network_devices[key] and nic.device.macAddress != current_net_devices[key].macAddress: + self.module.fail_json(msg="Changing MAC address has not effect when interface is already present. " + "The failing new MAC address is %s" % nic.device.macAddress) + + nic.device = current_net_devices[key] + nic.device.deviceInfo = vim.Description() + else: + nic.operation = vim.vm.device.VirtualDeviceSpec.Operation.add + nic_change_detected = True - if not self.foldermap: - self.folders, self.foldermap = self.getfolders() + if hasattr(self.cache.get_network(network_devices[key]['network']), 'portKeys'): + # VDS switch + pg_obj = get_obj(self.content, [vim.dvs.DistributedVirtualPortgroup], network_devices[key]['network']) + dvs_port_connection = vim.dvs.PortConnection() + dvs_port_connection.portgroupKey = pg_obj.key + dvs_port_connection.switchUuid = pg_obj.config.distributedVirtualSwitch.uuid + nic.device.backing = vim.vm.device.VirtualEthernetCard.DistributedVirtualPortBackingInfo() + nic.device.backing.port = dvs_port_connection + else: + # vSwitch + if not isinstance(nic.device.backing, vim.vm.device.VirtualEthernetCard.NetworkBackingInfo): + nic.device.backing = vim.vm.device.VirtualEthernetCard.NetworkBackingInfo() + nic_change_detected = True - # find matching folders - if self.params['folder'].startswith('/'): - folders = [x for x in self.foldermap['fvim_by_path'].items() if x[0] == self.params['folder']] - else: - folders = [x for x in self.foldermap['fvim_by_path'].items() if x[0].endswith(self.params['folder'])] + net_obj = self.cache.get_network(network_devices[key]['network']) + if nic.device.backing.network != net_obj: + nic.device.backing.network = net_obj + nic_change_detected = True - # throw error if more than one match or no matches - if len(folders) == 0: - self.module.fail_json(msg='no folder matched the path: %s' % self.params['folder']) - elif len(folders) > 1: - self.module.fail_json(msg='too many folders matched "%s", please give the full path starting with /vm/' % self.params['folder']) + if nic.device.backing.deviceName != network_devices[key]['network']: + nic.device.backing.deviceName = network_devices[key]['network'] + nic_change_detected = True - # grab the folder vim object - destfolder = folders[0][1] + if nic_change_detected: + self.configspec.deviceChange.append(nic) + self.change_detected = True + + if vm_obj is None or self.should_deploy_from_template(): + if 'ip' in self.params['networks'][network]: + guest_map = vim.vm.customization.AdapterMapping() + guest_map.adapter = vim.vm.customization.IPSettings() + guest_map.adapter.ip = vim.vm.customization.FixedIp() + guest_map.adapter.ip.ipAddress = str(network_devices[key]['ip']) + guest_map.adapter.subnetMask = str(network_devices[key]['subnet_mask']) + + if 'gateway' in network_devices[key]: + guest_map.adapter.gateway = network_devices[key]['gateway'] + + if self.params.get('domain'): + guest_map.adapter.dnsDomain = self.params['domain'] + + adaptermaps.append(guest_map) + + if vm_obj is None or self.should_deploy_from_template(): + # DNS settings + globalip = vim.vm.customization.GlobalIPSettings() + globalip.dnsServerList = self.params['dns_servers'] + globalip.dnsSuffixList = str(self.params['domain']) + + # Hostname settings + ident = vim.vm.customization.LinuxPrep() + ident.domain = str(self.params['domain']) + ident.hostName = vim.vm.customization.FixedName() + ident.hostName.name = self.params['name'] + + self.customspec = vim.vm.customization.Specification() + self.customspec.nicSettingMap = adaptermaps + self.customspec.globalIPSettings = globalip + self.customspec.identity = ident + + def get_vm_scsi_controller(self, vm_obj): + # If vm_obj doesn't exists no SCSI controller to find + if vm_obj is None: + return None + + for device in vm_obj.config.hardware.device: + if self.device_helper.is_scsi_controller(device): + scsi_ctl = vim.vm.device.VirtualDeviceSpec() + scsi_ctl.device = device + return scsi_ctl + + return None + + def get_configured_disk_size(self, expected_disk_spec): + # what size is it? + if [x for x in expected_disk_spec.keys() if x.startswith('size_') or x == 'size']: + # size_tb, size_gb, size_mb, size_kb, size_b ...? + if 'size' in expected_disk_spec: + expected = ''.join(c for c in expected_disk_spec['size'] if c.isdigit()) + unit = expected_disk_spec['size'].replace(expected, '').lower() + expected = int(expected) + else: + param = [x for x in expected_disk_spec.keys() if x.startswith('size_')][0] + unit = param.split('_')[-1].lower() + expected = [x[1] for x in expected_disk_spec.items() if x[0].startswith('size_')][0] + expected = int(expected) + + if unit == 'tb': + return expected * 1024 * 1024 * 1024 + elif unit == 'gb': + return expected * 1024 * 1024 + elif unit == ' mb': + return expected * 1024 + elif unit == 'kb': + return expected + + self.module.fail_json( + msg='%s is not a supported unit for disk size. Supported units are kb, mb, gb or tb' % unit) + + # No size found but disk, fail + self.module.fail_json( + msg="no size, size_kb, size_mb, size_gb or size_tb attribute found into disk configuration") + + 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: + return + + scsi_ctl = self.get_vm_scsi_controller(vm_obj) + + # Create scsi controller only if we are deploying a new VM, not a template or reconfiguring + if vm_obj is None or scsi_ctl is None: + scsi_ctl = self.device_helper.create_scsi_controller(self.get_scsi_type()) + self.change_detected = True + self.configspec.deviceChange.append(scsi_ctl) + + disks = [x for x in vm_obj.config.hardware.device if isinstance(x, vim.vm.device.VirtualDisk)] \ + if vm_obj is not None else None + + if disks is not None and self.params.get('disk') and len(self.params.get('disk')) < len(disks): + self.module.fail_json(msg="Provided disks configuration has less disks than " + "the target object (%d vs %d)" % (len(self.params.get('disk')), len(disks))) + + disk_index = 0 + for expected_disk_spec in self.params.get('disk'): + disk_modified = False + # If we are manipulating and existing objects which has disks and disk_index is in disks + if vm_obj is not None and disks is not None and disk_index < len(disks): + diskspec = vim.vm.device.VirtualDeviceSpec() + # set the operation to edit so that it knows to keep other settings + diskspec.operation = vim.vm.device.VirtualDeviceSpec.Operation.edit + diskspec.device = disks[disk_index] + else: + diskspec = self.device_helper.create_scsi_disk(scsi_ctl, disk_index) + disk_modified = True + # is it thin? + if 'type' in expected_disk_spec: + if expected_disk_spec.get('type', '').lower() == 'thin': + diskspec.device.backing.thinProvisioned = True + + # which datastore? + if expected_disk_spec.get('datastore'): + # TODO: This is already handled by the relocation spec, + # but it needs to eventually be handled for all the + # 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: + self.module.fail_json( + msg="given disk size is lesser than found (%d < %d). Reducing disks is not allowed." % + (kb, diskspec.device.capacityInKB)) + + if kb != diskspec.device.capacityInKB or disk_modified: + diskspec.device.capacityInKB = kb + self.configspec.deviceChange.append(diskspec) + + self.change_detected = True + + def select_host(self): # if the user wants a cluster, get the list of hosts for the cluster and use the first one if self.params['cluster']: - cluster = get_obj(self.content, [vim.ClusterComputeResource], self.params['cluster']) + cluster = self.cache.get_cluster(self.params['cluster']) if not cluster: self.module.fail_json(msg="Failed to find a cluster named %s" % self.params['cluster']) - #resource_pool = cluster.resourcePool hostsystems = [x for x in cluster.host] + # TODO: add a policy to select host hostsystem = hostsystems[0] else: - hostsystem = get_obj(self.content, [vim.HostSystem], self.params['esxi_hostname']) + hostsystem = self.cache.get_esx_host(self.params['esxi_hostname']) if not hostsystem: self.module.fail_json(msg="Failed to find a host named %s" % self.params['esxi_hostname']) - # set the destination datastore in the relocation spec - datastore_name = None + return hostsystem + + def select_datastore(self, vm_obj=None): datastore = None - if self.params['disk']: - if 'datastore' in self.params['disk'][0]: + datastore_name = None + if len(self.params['disk']) != 0: + # TODO: really use the datastore for newly created disks + if 'autoselect_datastore' in self.params['disk'][0] and self.params['disk'][0]['autoselect_datastore']: + datastores = get_all_objs(self.content, [vim.Datastore]) + if datastores is None or len(datastores) == 0: + self.module.fail_json(msg="Unable to find a datastore list when autoselecting") + + datastore_freespace = 0 + for ds in datastores: + if ds.summary.freeSpace > datastore_freespace: + # If datastore field is provided, filter destination datastores + if 'datastore' in self.params['disk'][0] and \ + isinstance(self.params['disk'][0]['datastore'], str) and \ + ds.name.find(self.params['disk'][0]['datastore']) < 0: + continue + + datastore = ds + datastore_name = datastore.name + datastore_freespace = ds.summary.freeSpace + + elif 'datastore' in self.params['disk'][0]: datastore_name = self.params['disk'][0]['datastore'] datastore = get_obj(self.content, [vim.Datastore], datastore_name) - if not datastore: + else: + self.module.fail_json(msg="Either datastore or autoselect_datastore " + "should be provided to select datastore") + if not datastore and self.should_deploy_from_template(): # use the template's existing DS - disks = [x for x in template.config.hardware.device if isinstance(x, vim.vm.device.VirtualDisk)] + disks = [x for x in vm_obj.config.hardware.device if isinstance(x, vim.vm.device.VirtualDisk)] datastore = disks[0].backing.datastore datastore_name = datastore.name if not datastore: self.module.fail_json(msg="Failed to find a matching datastore") - # create the relocation spec - relospec = vim.vm.RelocateSpec() - relospec.host = hostsystem - relospec.datastore = datastore + return datastore, datastore_name - # Find the associated resourcepool for the host system - # * FIXME: find resourcepool for clusters too - resource_pool = None + def obj_has_parent(self, obj, parent): + assert obj is not None and parent is not None + current_parent = obj + + while True: + if current_parent.name == parent.name: + return True + + current_parent = current_parent.parent + if current_parent is None: + return False + + def select_resource_pool(self, host): resource_pools = get_all_objs(self.content, [vim.ResourcePool]) for rp in resource_pools.items(): if not rp[0]: continue + if not hasattr(rp[0], 'parent'): continue - if rp[0].parent == hostsystem.parent: - resource_pool = rp[0] - break - if resource_pool: - relospec.pool = resource_pool - else: - self.module.fail_json(msg="Failed to find a resource group for %s" \ - % hostsystem.name) - - clonespec_kwargs = {} - clonespec_kwargs['location'] = relospec - - # create disk spec if not default - if self.params['disk']: - # grab the template's first disk and modify it for this customization - disks = [x for x in template.config.hardware.device if isinstance(x, vim.vm.device.VirtualDisk)] - diskspec = vim.vm.device.VirtualDeviceSpec() - # set the operation to edit so that it knows to keep other settings - diskspec.operation = vim.vm.device.VirtualDeviceSpec.Operation.edit - diskspec.device = disks[0] - # get the first disk attributes - pspec = self.params.get('disk')[0] + # Find resource pool on host + if self.obj_has_parent(rp[0].parent, host.parent): + # If no resource_pool selected or it's the selected pool, return it + if self.module.params['resource_pool'] is None or rp[0].name == self.module.params['resource_pool']: + return rp[0] - # is it thin? - if pspec.get('type', '').lower() == 'thin': - diskspec.device.backing.thinProvisioned = True - - # which datastore? - if pspec.get('datastore'): - # This is already handled by the relocation spec, - # but it needs to eventually be handled for all the - # other disks defined - pass - - # what size is it? - if [x for x in pspec.keys() if x.startswith('size_') or x == 'size']: - # size_tb, size_gb, size_mb, size_kb, size_b ...? - if 'size' in pspec: - expected = ''.join(c for c in pspec['size'] if c.isdigit()) - unit = pspec['size'].replace(expected, '').lower() - expected = int(expected) - else: - param = [x for x in pspec.keys() if x.startswith('size_')][0] - unit = param.split('_')[-1].lower() - expected = [x[1] for x in pspec.items() if x[0].startswith('size_')][0] - expected = int(expected) - - kb = None - if unit == 'tb': - kb = expected * 1024 * 1024 * 1024 - elif unit == 'gb': - kb = expected * 1024 * 1024 - elif unit ==' mb': - kb = expected * 1024 - elif unit == 'kb': - kb = expected - else: - self.module.fail_json(msg='%s is not a supported unit for disk size' % unit) - diskspec.device.capacityInKB = kb - - # tell the configspec that the disk device needs to change - configspec = vim.vm.ConfigSpec(deviceChange=[diskspec]) - clonespec_kwargs['config'] = configspec + if self.module.params['resource_pool'] is not None: + self.module.fail_json(msg="Could not find resource_pool %s for selected host %s" + % (self.module.params['resource_pool'], host.name)) + else: + self.module.fail_json(msg="Failed to find a resource group for %s" % host.name) + def get_scsi_type(self): + disk_controller_type = "paravirtual" # set cpu/memory/etc if 'hardware' in self.params: - if not 'config' in clonespec_kwargs: - clonespec_kwargs['config'] = vim.vm.ConfigSpec() - if 'num_cpus' in self.params['hardware']: - clonespec_kwargs['config'].numCPUs = \ - int(self.params['hardware']['num_cpus']) - if 'memory_mb' in self.params['hardware']: - clonespec_kwargs['config'].memoryMB = \ - int(self.params['hardware']['memory_mb']) - - # lets try and assign a static ip address - if self.params['customize'] is True: - ip_settings = list() - if self.params['ips']: - for ip_string in self.params['ips']: - ip = IPAddress(self.params['ips']) - for network in self.params['networks']: - if network: - if ip in IPNetwork(network): - self.params['networks'][network]['ip'] = str(ip) - ipnet = IPNetwork(network) - self.params['networks'][network]['subnet_mask'] = str( - ipnet.netmask - ) - ip_settings.append(self.params['networks'][network]) - - key = 0 - network = get_obj(self.content, [vim.Network], ip_settings[key]['network']) - datacenter = get_obj(self.content, [vim.Datacenter], self.params['datacenter']) - # get the folder where VMs are kept for this datacenter - destfolder = datacenter.vmFolder - - cluster = get_obj(self.content, [vim.ClusterComputeResource],self.params['cluster']) - - devices = [] - adaptermaps = [] + if 'scsi' in self.params['hardware']: + if self.params['hardware']['scsi'] in ['buslogic', 'paravirtual', 'lsilogic', 'lsilogicsas']: + disk_controller_type = self.params['hardware']['scsi'] + else: + self.module.fail_json(msg="hardware.scsi attribute should be 'paravirtual' or 'lsilogic'") + return disk_controller_type - try: - for device in template.config.hardware.device: - if hasattr(device, 'addressType'): - nic = vim.vm.device.VirtualDeviceSpec() - nic.operation = vim.vm.device.VirtualDeviceSpec.Operation.remove - nic.device = device - devices.append(nic) - except: - pass + def deploy_vm(self): + # https://github.com/vmware/pyvmomi-community-samples/blob/master/samples/clone_vm.py + # https://www.vmware.com/support/developer/vc-sdk/visdk25pubs/ReferenceGuide/vim.vm.CloneSpec.html + # https://www.vmware.com/support/developer/vc-sdk/visdk25pubs/ReferenceGuide/vim.vm.ConfigSpec.html + # https://www.vmware.com/support/developer/vc-sdk/visdk41pubs/ApiReference/vim.vm.RelocateSpec.html - # single device support - nic = vim.vm.device.VirtualDeviceSpec() - nic.operation = vim.vm.device.VirtualDeviceSpec.Operation.add - nic.device = vim.vm.device.VirtualVmxnet3() - nic.device.wakeOnLanEnabled = True - nic.device.addressType = 'assigned' - nic.device.deviceInfo = vim.Description() - nic.device.deviceInfo.label = 'Network Adapter %s' % (key + 1) - nic.device.deviceInfo.summary = ip_settings[key]['network'] - - if hasattr(get_obj(self.content, [vim.Network], ip_settings[key]['network']), 'portKeys'): - # VDS switch - pg_obj = get_obj(self.content, [vim.dvs.DistributedVirtualPortgroup], ip_settings[key]['network']) - dvs_port_connection = vim.dvs.PortConnection() - dvs_port_connection.portgroupKey= pg_obj.key - dvs_port_connection.switchUuid= pg_obj.config.distributedVirtualSwitch.uuid - nic.device.backing = vim.vm.device.VirtualEthernetCard.DistributedVirtualPortBackingInfo() - nic.device.backing.port = dvs_port_connection - - else: - # vSwitch - nic.device.backing = vim.vm.device.VirtualEthernetCard.NetworkBackingInfo() - nic.device.backing.network = get_obj(self.content, [vim.Network], ip_settings[key]['network']) - nic.device.backing.deviceName = ip_settings[key]['network'] - - nic.device.connectable = vim.vm.device.VirtualDevice.ConnectInfo() - nic.device.connectable.startConnected = True - nic.device.connectable.allowGuestControl = True - nic.device.connectable.connected = True - nic.device.connectable.allowGuestControl = True - devices.append(nic) - - # Update the spec with the added NIC - clonespec_kwargs['config'].deviceChange = devices - - guest_map = vim.vm.customization.AdapterMapping() - guest_map.adapter = vim.vm.customization.IPSettings() - guest_map.adapter.ip = vim.vm.customization.FixedIp() - guest_map.adapter.ip.ipAddress = str(ip_settings[key]['ip']) - guest_map.adapter.subnetMask = str(ip_settings[key]['subnet_mask']) + # FIXME: + # - multiple datacenters + # - multiple templates by the same name + # - static IPs - try: - guest_map.adapter.gateway = ip_settings[key]['gateway'] - except: - pass + datacenters = get_all_objs(self.content, [vim.Datacenter]) + datacenter = get_obj(self.content, [vim.Datacenter], self.params['datacenter']) + if not datacenter: + self.module.fail_json(msg='No datacenter named %s was found' % self.params['datacenter']) - try: - guest_map.adapter.dnsDomain = self.params['domain'] - except: - pass + # find matching folders + if self.params['folder'].startswith('/'): + if not self.params['folder'].startswith('/vm'): + self.params['folder'] = '/vm' + self.params['folder'] + folders = [x for x in self.foldermap['fvim_by_path'].items() if x[0] == self.params['folder']] + else: + folders = [x for x in self.foldermap['fvim_by_path'].items() if x[0].endswith(self.params['folder'])] - adaptermaps.append(guest_map) + # throw error if more than one match or no matches + if len(folders) == 0: + self.module.fail_json(msg='no folder matched the path: %s' % self.params['folder']) + elif len(folders) > 1: + self.module.fail_json( + msg='too many folders matched "%s", please give the full path starting with /vm/' % self.params[ + 'folder']) - # DNS settings - globalip = vim.vm.customization.GlobalIPSettings() - globalip.dnsServerList = self.params['dns_servers'] - globalip.dnsSuffixList = str(self.params['domain']) + # grab the folder vim object + destfolder = folders[0][1] + hostsystem = self.select_host() - # Hostname settings - ident = vim.vm.customization.LinuxPrep() - ident.domain = str(self.params['domain']) - ident.hostName = vim.vm.customization.FixedName() - ident.hostName.name = self.params['name'] + if self.should_deploy_from_template(): + # FIXME: need to search for this in the same way as guests to ensure accuracy + vm_obj = get_obj(self.content, [vim.VirtualMachine], self.params['template']) + if not vm_obj: + self.module.fail_json(msg="Could not find a template named %s" % self.params['template']) + else: + vm_obj = None - customspec = vim.vm.customization.Specification() - clonespec_kwargs['customization'] = customspec + # set the destination datastore for VM & disks + (datastore, datastore_name) = self.select_datastore(vm_obj) + resource_pool = self.select_resource_pool(hostsystem) - clonespec_kwargs['customization'].nicSettingMap = adaptermaps - clonespec_kwargs['customization'].globalIPSettings = globalip - clonespec_kwargs['customization'].identity = ident + self.configspec = vim.vm.ConfigSpec(cpuHotAddEnabled=True, memoryHotAddEnabled=True) + self.configspec.deviceChange = [] + self.configure_guestid(vm_obj=vm_obj, vm_creation=True) + self.configure_cpu_and_memory(vm_obj=vm_obj, vm_creation=True) + self.configure_disks(vm_obj=vm_obj) + self.configure_network(vm_obj=vm_obj) - clonespec = vim.vm.CloneSpec(**clonespec_kwargs) - task = template.Clone(folder=destfolder, name=self.params['name'], spec=clonespec) - self.wait_for_task(task) + try: + if self.should_deploy_from_template(): + # create the relocation spec + relospec = vim.vm.RelocateSpec() + relospec.host = hostsystem + relospec.datastore = datastore + relospec.pool = resource_pool + + clonespec = vim.vm.CloneSpec(template=self.params['is_template'], + location=relospec) + if self.params['customize'] is True: + clonespec.customization = self.customspec + + clonespec.config = self.configspec + task = vm_obj.Clone(folder=destfolder, name=self.params['name'], spec=clonespec) + else: + # ConfigSpec require name for VM creation + self.configspec.name = self.params['name'] + self.configspec.files = vim.vm.FileInfo(logDirectory=None, + snapshotDirectory=None, + suspendDirectory=None, + vmPathName="[" + datastore_name + "] " + self.params["name"]) + + task = destfolder.CreateVM_Task(config=self.configspec, pool=resource_pool) + self.wait_for_task(task) + except TypeError: + self.module.fail_json(msg="TypeError was returned, please ensure to give correct inputs.") if task.info.state == 'error': # https://kb.vmware.com/selfservice/microsites/search.do?language=en_US&cmd=displayKC&externalId=2021361 # https://kb.vmware.com/selfservice/microsites/search.do?language=en_US&cmd=displayKC&externalId=2173 - return ({'changed': False, 'failed': True, 'msg': task.info.error.msg}) + return {'changed': False, 'failed': True, 'msg': task.info.error.msg} else: - # set annotation vm = task.info.result if self.params['annotation']: @@ -919,20 +1253,59 @@ class PyVmomiHelper(object): annotation_spec.annotation = str(self.params['annotation']) task = vm.ReconfigVM_Task(annotation_spec) self.wait_for_task(task) - if wait_for_ip: + + if self.params['wait_for_ip_address'] or self.params['state'] in ['poweredon', 'restarted']: self.set_powerstate(vm, 'poweredon', force=False) - self.wait_for_vm_ip(vm) + + if self.params['wait_for_ip_address']: + self.wait_for_vm_ip(vm) + vm_facts = self.gather_facts(vm) - return ({'changed': True, 'failed': False, 'instance': vm_facts}) + return {'changed': self.change_detected, 'failed': False, 'instance': vm_facts} + + def reconfigure_vm(self): + self.configspec = vim.vm.ConfigSpec() + self.configspec.deviceChange = [] + + self.configure_guestid(vm_obj=self.current_vm_obj) + self.configure_cpu_and_memory(vm_obj=self.current_vm_obj) + self.configure_disks(vm_obj=self.current_vm_obj) + self.configure_network(vm_obj=self.current_vm_obj) + + relospec = vim.vm.RelocateSpec() + hostsystem = self.select_host() + relospec.pool = self.select_resource_pool(hostsystem) - def wait_for_task(self, task): + change_applied = False + if relospec.pool != self.current_vm_obj.resourcePool: + task = self.current_vm_obj.RelocateVM_Task(spec=relospec) + self.wait_for_task(task) + change_applied = True + + # Only send VMWare task if we see a modification + if self.change_detected: + task = self.current_vm_obj.ReconfigVM_Task(spec=self.configspec) + self.wait_for_task(task) + + if task.info.state == 'error': + # https://kb.vmware.com/selfservice/microsites/search.do?language=en_US&cmd=displayKC&externalId=2021361 + # https://kb.vmware.com/selfservice/microsites/search.do?language=en_US&cmd=displayKC&externalId=2173 + return {'changed': False, 'failed': True, 'msg': task.info.error.msg} + + change_applied = True + + vm_facts = self.gather_facts(self.current_vm_obj) + return {'changed': change_applied, 'failed': False, 'instance': vm_facts} + + @staticmethod + def wait_for_task(task): # https://www.vmware.com/support/developer/vc-sdk/visdk25pubs/ReferenceGuide/vim.Task.html # https://www.vmware.com/support/developer/vc-sdk/visdk25pubs/ReferenceGuide/vim.TaskInfo.html # https://github.com/virtdevninja/pyvmomi-community-samples/blob/master/samples/tools/tasks.py while task.info.state not in ['success', 'error']: time.sleep(1) - def wait_for_vm_ip(self, vm, poll=100, sleep=5): + def wait_for_vm_ip(self, vm, poll=100, sleep=5): ips = None facts = {} thispoll = 0 @@ -947,16 +1320,14 @@ class PyVmomiHelper(object): return facts - def fetch_file_from_guest(self, vm, username, password, src, dest): - ''' Use VMWare's filemanager api to fetch a file over http ''' + """ Use VMWare's filemanager api to fetch a file over http """ result = {'failed': False} tools_status = vm.guest.toolsStatus - if (tools_status == 'toolsNotInstalled' or - tools_status == 'toolsNotRunning'): + 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 @@ -968,18 +1339,18 @@ class PyVmomiHelper(object): # https://github.com/vmware/pyvmomi/blob/master/docs/vim/vm/guest/FileManager/FileTransferInformation.rst fti = self.content.guestOperationsManager.fileManager. \ - InitiateFileTransferFromGuest(vm, creds, src) + InitiateFileTransferFromGuest(vm, creds, src) result['size'] = fti.size result['url'] = fti.url # Use module_utils to fetch the remote url returned from the api - rsp, info = fetch_url(self.module, fti.url, use_proxy=False, - force=True, last_mod_time=None, - timeout=10, headers=None) + rsp, info = fetch_url(self.module, fti.url, use_proxy=False, + force=True, last_mod_time=None, + timeout=10, headers=None) # save all of the transfer data - for k,v in info.items(): + for k, v in iteritems(info): result[k] = v # exit early if xfer failed @@ -990,23 +1361,21 @@ class PyVmomiHelper(object): # attempt to read the content and write it try: with open(dest, 'wb') as f: - f.write(rsp.read()) + f.write(rsp.read()) except Exception as e: result['failed'] = True result['msg'] = str(e) return result - def push_file_to_guest(self, vm, username, password, src, dest, overwrite=True): - ''' Use VMWare's filemanager api to push a file over http ''' + """ Use VMWare's filemanager api to fetch a file over http """ result = {'failed': False} tools_status = vm.guest.toolsStatus - if (tools_status == 'toolsNotInstalled' or - tools_status == 'toolsNotRunning'): + 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 @@ -1017,12 +1386,10 @@ class PyVmomiHelper(object): ) # the api requires a filesize in bytes - filesize = None fdata = None try: - #filesize = os.path.getsize(src) + # filesize = os.path.getsize(src) filesize = os.stat(src).st_size - fdata = None with open(src, 'rb') as f: fdata = f.read() result['local_filesize'] = filesize @@ -1034,30 +1401,29 @@ class PyVmomiHelper(object): # https://www.vmware.com/support/developer/converter-sdk/conv60_apireference/vim.vm.guest.FileManager.html#initiateFileTransferToGuest file_attribute = vim.vm.guest.FileManager.FileAttributes() url = self.content.guestOperationsManager.fileManager. \ - InitiateFileTransferToGuest(vm, creds, dest, file_attribute, - filesize, overwrite) + InitiateFileTransferToGuest(vm, creds, dest, file_attribute, + filesize, overwrite) # PUT the filedata to the url ... rsp, info = fetch_url(self.module, url, method="put", data=fdata, - use_proxy=False, force=True, last_mod_time=None, - timeout=10, headers=None) + use_proxy=False, force=True, last_mod_time=None, + timeout=10, headers=None) result['msg'] = str(rsp.read()) # save all of the transfer data - for k,v in info.items(): + for k, v in iteritems(info): result[k] = v return result - def run_command_in_guest(self, 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'): + tools_status == 'toolsNotRunning'): result['failed'] = True result['msg'] = "VMwareTools is not installed or is not running in the guest" return result @@ -1067,19 +1433,18 @@ class PyVmomiHelper(object): username=username, password=password ) - res = None - pdata = None try: # https://github.com/vmware/pyvmomi/blob/master/docs/vim/vm/guest/ProcessManager.rst pm = self.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, + # 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]) @@ -1088,6 +1453,7 @@ class PyVmomiHelper(object): 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() @@ -1106,15 +1472,15 @@ class PyVmomiHelper(object): def list_snapshots_recursively(self, snapshots): snapshot_data = [] - snap_text = '' for snapshot in snapshots: - snap_text = 'Id: %s; Name: %s; Description: %s; CreateTime: %s; State: %s'%(snapshot.id, snapshot.name, - snapshot.description, snapshot.createTime, snapshot.state) + snap_text = 'Id: %s; Name: %s; Description: %s; CreateTime: %s; State: %s' % (snapshot.id, snapshot.name, + snapshot.description, + snapshot.createTime, + snapshot.state) snapshot_data.append(snap_text) snapshot_data = snapshot_data + self.list_snapshots_recursively(snapshot.childSnapshotList) return snapshot_data - def get_snapshots_by_name_recursively(self, snapshots, snapname): snap_obj = [] for snapshot in snapshots: @@ -1133,8 +1499,9 @@ class PyVmomiHelper(object): return snap_obj def snapshot_vm(self, vm, guest, snapshot_op): - ''' To perform snapshot operations create/remove/revert/list_all/list_current/remove_all ''' + """ To perform snapshot operations create/remove/revert/list_all/list_current/remove_all """ + snapshot_op_name = None try: snapshot_op_name = snapshot_op['op_type'] except KeyError: @@ -1147,7 +1514,7 @@ class PyVmomiHelper(object): self.module.fail_json(msg="Specify op_type - create/remove/revert/list_all/list_current/remove_all") if snapshot_op_name != 'create' and vm.snapshot is None: - self.module.exit_json(msg="VM - %s doesn't have any snapshots"%guest) + self.module.exit_json(msg="VM - %s doesn't have any snapshots" % guest) if snapshot_op_name == 'create': try: @@ -1172,7 +1539,7 @@ class PyVmomiHelper(object): snap_obj = self.get_snapshots_by_name_recursively(vm.snapshot.rootSnapshotList, snapname) - #if len(snap_obj) is 0; then no snapshots with specified name + # if len(snap_obj) is 0; then no snapshots with specified name if len(snap_obj) == 1: snap_obj = snap_obj[0].snapshot if snapshot_op_name == 'remove': @@ -1180,7 +1547,8 @@ class PyVmomiHelper(object): else: task = snap_obj.RevertToSnapshot_Task() else: - self.module.exit_json(msg="Couldn't find any snapshots with specified name: %s on VM: %s"%(snapname, guest)) + self.module.exit_json( + msg="Couldn't find any snapshots with specified name: %s on VM: %s" % (snapname, guest)) elif snapshot_op_name == 'list_all': snapshot_data = self.list_snapshots_recursively(vm.snapshot.rootSnapshotList) @@ -1189,9 +1557,10 @@ class PyVmomiHelper(object): elif snapshot_op_name == 'list_current': current_snapref = vm.snapshot.currentSnapshot current_snap_obj = self.get_current_snap_obj(vm.snapshot.rootSnapshotList, current_snapref) - result['current_snapshot'] = 'Id: %s; Name: %s; Description: %s; CreateTime: %s; State: %s'%(current_snap_obj[0].id, - current_snap_obj[0].name, current_snap_obj[0].description, current_snap_obj[0].createTime, - current_snap_obj[0].state) + result['current_snapshot'] = 'Id: %s; Name: %s; Description: %s; CreateTime: %s; State: %s' % ( + current_snap_obj[0].id, + current_snap_obj[0].name, current_snap_obj[0].description, current_snap_obj[0].createTime, + current_snap_obj[0].state) elif snapshot_op_name == 'remove_all': task = vm.RemoveAllSnapshots() @@ -1205,6 +1574,7 @@ class PyVmomiHelper(object): return result + def get_obj(content, vimtype, name): """ Return an object by name, if name is None the @@ -1227,9 +1597,6 @@ def get_obj(content, vimtype, name): def main(): - - vm = None - module = AnsibleModule( argument_spec=dict( hostname=dict( @@ -1252,19 +1619,21 @@ def main(): 'present', 'absent', 'restarted', - 'reconfigured' + 'suspended', + 'gatherfacts', ], default='present'), validate_certs=dict(required=False, type='bool', default=True), - template_src=dict(required=False, type='str', aliases=['template']), + template_src=dict(required=False, type='str', aliases=['template'], default=None), + is_template=dict(required=False, type='bool', default=False), annotation=dict(required=False, type='str', aliases=['notes']), name=dict(required=True, type='str'), name_match=dict(required=False, type='str', default='first'), snapshot_op=dict(required=False, type='dict', default={}), uuid=dict(required=False, type='str'), folder=dict(required=False, type='str', default='/vm', aliases=['folder']), - disk=dict(required=False, type='list'), - nic=dict(required=False, type='list'), + guest_id=dict(required=False, type='str', default=None), + disk=dict(required=False, type='list', default=[]), hardware=dict(required=False, type='dict', default={}), force=dict(required=False, type='bool', default=False), datacenter=dict(required=False, type='str', default=None), @@ -1272,68 +1641,73 @@ def main(): cluster=dict(required=False, type='str', default=None), wait_for_ip_address=dict(required=False, type='bool', default=True), customize=dict(required=False, type='bool', default=False), - ips=dict(required=False, type='str', default=None), dns_servers=dict(required=False, type='list', default=None), domain=dict(required=False, type='str', default=None), - networks=dict(required=False, type='dict', default={}) + networks=dict(required=False, type='dict', default={}), + resource_pool=dict(required=False, type='str', default=None) ), supports_check_mode=True, - mutually_exclusive=[], + mutually_exclusive=[ + ['esxi_hostname', 'cluster'], + ], required_together=[ ['state', 'force'], ['template'], ], ) - pyv = PyVmomiHelper(module) + result = {'failed': False, 'changed': False} + + # Fail check, customize require template to be defined + if module.params["customize"] and not module.params['template']: + module.fail_json(msg="customize option is only valid when template option is defined") + pyv = PyVmomiHelper(module) # Check if the VM exists before continuing - vm = pyv.getvm(name=module.params['name'], - folder=module.params['folder'], - uuid=module.params['uuid'], - name_match=module.params['name_match']) + vm = pyv.getvm(name=module.params['name'], + folder=module.params['folder'], + uuid=module.params['uuid'], + name_match=module.params['name_match'], + cache=True) # VM already exists if vm: - if module.params['state'] == 'absent': # destroy it if module.params['force']: # has to be poweredoff first - result = pyv.set_powerstate(vm, 'poweredoff', module.params['force']) + pyv.set_powerstate(vm, 'poweredoff', module.params['force']) result = pyv.remove_vm(vm) - elif module.params['state'] in ['poweredon', 'poweredoff', 'restarted']: + elif module.params['state'] == 'present': + result = pyv.reconfigure_vm() + elif module.params['state'] in ['poweredon', 'poweredoff', 'restarted', 'suspended']: # set powerstate - result = pyv.set_powerstate(vm, module.params['state'], module.params['force']) - elif module.params['snapshot_op']: - result = pyv.snapshot_vm(vm, module.params['name'], module.params['snapshot_op']) - else: + tmp_result = pyv.set_powerstate(vm, module.params['state'], module.params['force']) + if tmp_result['changed']: + result["changed"] = True + if not tmp_result["failed"]: + result["failed"] = False + elif module.params['state'] == 'gatherfacts': # Run for facts only try: module.exit_json(instance=pyv.gather_facts(vm)) except Exception: e = get_exception() - module.fail_json( - msg="Fact gather failed with exception %s" % e) - + module.fail_json(msg="Fact gather failed with exception %s" % e) + elif module.params['snapshot_op']: + result = pyv.snapshot_vm(vm, module.params['name'], module.params['snapshot_op']) + else: + # This should not happen + assert False # VM doesn't exist else: - create_states = ['poweredon', 'poweredoff', 'present', 'restarted'] - if module.params['state'] in create_states: - poweron = (module.params['state'] != 'poweredoff') + if module.params['state'] in ['poweredon', 'poweredoff', 'present', 'restarted', 'suspended']: # Create it ... - result = pyv.deploy_template( - poweron=poweron, - wait_for_ip=module.params['wait_for_ip_address'] - ) - result['changed'] = True - elif module.params['state'] == 'absent': - result = {'changed': False, 'failed': False} - else: - result = {'changed': False, 'failed': False} + result = pyv.deploy_vm() + elif module.params['state'] == 'gatherfacts': + module.fail_json(msg="Unable to gather facts for inexistant VM %s" % module.params['name']) - # FIXME - if not 'failed' in result: + if 'failed' not in result: result['failed'] = False if result['failed']: