mirror of
https://github.com/ansible-collections/community.general.git
synced 2025-05-23 09:29:08 -07:00
Currently, Ansible interprets variables with a True|False value as boolean. This causes the vmware_deploy_ovf module to break, because it can only accept string values as properties. This fix checks if a value is boolean, and converts it to a string if it is. Since integers do not seem to be causing the same error, this is the only check we appear to need. After completion, OVF properties that are boolean can be specified as yes|no or true|false. Closes: #45528
603 lines
19 KiB
Python
603 lines
19 KiB
Python
#!/usr/bin/python
|
|
# -*- coding: utf-8 -*-
|
|
|
|
# Copyright: (c) 2017, Matt Martz <matt@sivel.net>
|
|
#
|
|
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
|
|
|
from __future__ import absolute_import, division, print_function
|
|
__metaclass__ = type
|
|
|
|
ANSIBLE_METADATA = {
|
|
'metadata_version': '1.1',
|
|
'status': ['preview'],
|
|
'supported_by': 'community'
|
|
}
|
|
|
|
DOCUMENTATION = '''
|
|
author: 'Matt Martz (@sivel)'
|
|
short_description: 'Deploys a VMware virtual machine from an OVF or OVA file'
|
|
description:
|
|
- 'This module can be used to deploy a VMware VM from an OVF or OVA file'
|
|
module: vmware_deploy_ovf
|
|
notes: []
|
|
options:
|
|
allow_duplicates:
|
|
default: "yes"
|
|
description:
|
|
- Whether or not to allow duplicate VM names. ESXi allows duplicates, vCenter may not.
|
|
type: bool
|
|
datacenter:
|
|
default: ha-datacenter
|
|
description:
|
|
- Datacenter to deploy to.
|
|
cluster:
|
|
description:
|
|
- Cluster to deploy to.
|
|
datastore:
|
|
default: datastore1
|
|
description:
|
|
- Datastore to deploy to.
|
|
deployment_option:
|
|
description:
|
|
- The key of the chosen deployment option.
|
|
disk_provisioning:
|
|
choices:
|
|
- flat
|
|
- eagerZeroedThick
|
|
- monolithicSparse
|
|
- twoGbMaxExtentSparse
|
|
- twoGbMaxExtentFlat
|
|
- thin
|
|
- sparse
|
|
- thick
|
|
- seSparse
|
|
- monolithicFlat
|
|
default: thin
|
|
description:
|
|
- Disk provisioning type.
|
|
fail_on_spec_warnings:
|
|
description:
|
|
- Cause the module to treat OVF Import Spec warnings as errors.
|
|
default: "no"
|
|
type: bool
|
|
folder:
|
|
description:
|
|
- Absolute path of folder to place the virtual machine.
|
|
- If not specified, defaults to the value of C(datacenter.vmFolder).
|
|
name:
|
|
description:
|
|
- Name of the VM to work with.
|
|
- Virtual machine names in vCenter are not necessarily unique, which may be problematic.
|
|
networks:
|
|
default:
|
|
VM Network: VM Network
|
|
description:
|
|
- 'C(key: value) mapping of OVF network name, to the vCenter network name.'
|
|
ovf:
|
|
description:
|
|
- 'Path to OVF or OVA file to deploy.'
|
|
aliases:
|
|
- ova
|
|
power_on:
|
|
default: true
|
|
description:
|
|
- 'Whether or not to power on the virtual machine after creation.'
|
|
type: bool
|
|
properties:
|
|
description:
|
|
- The assignment of values to the properties found in the OVF as key value pairs.
|
|
resource_pool:
|
|
default: Resources
|
|
description:
|
|
- 'Resource Pool to deploy to.'
|
|
wait:
|
|
default: true
|
|
description:
|
|
- 'Wait for the host to power on.'
|
|
type: bool
|
|
wait_for_ip_address:
|
|
default: false
|
|
description:
|
|
- Wait until vCenter detects an IP address for the VM.
|
|
- This requires vmware-tools (vmtoolsd) to properly work after creation.
|
|
type: bool
|
|
requirements:
|
|
- pyvmomi
|
|
version_added: "2.7"
|
|
extends_documentation_fragment: vmware.documentation
|
|
'''
|
|
|
|
EXAMPLES = r'''
|
|
- vmware_deploy_ovf:
|
|
hostname: '{{ vcenter_hostname }}'
|
|
username: '{{ vcenter_username }}'
|
|
password: '{{ vcenter_password }}'
|
|
ovf: /path/to/ubuntu-16.04-amd64.ovf
|
|
wait_for_ip_address: true
|
|
delegate_to: localhost
|
|
'''
|
|
|
|
|
|
RETURN = r'''
|
|
instance:
|
|
description: metadata about the new virtual machine
|
|
returned: always
|
|
type: dict
|
|
sample: None
|
|
'''
|
|
|
|
import io
|
|
import os
|
|
import sys
|
|
import tarfile
|
|
import time
|
|
import traceback
|
|
|
|
from threading import Thread
|
|
|
|
from ansible.module_utils._text import to_native
|
|
from ansible.module_utils.basic import AnsibleModule
|
|
from ansible.module_utils.six import string_types
|
|
from ansible.module_utils.urls import generic_urlparse, open_url, urlparse, urlunparse
|
|
from ansible.module_utils.vmware import (HAS_PYVMOMI, connect_to_api, find_datacenter_by_name, find_datastore_by_name,
|
|
find_network_by_name, find_resource_pool_by_name, find_vm_by_name, find_cluster_by_name,
|
|
gather_vm_facts, vmware_argument_spec, wait_for_task, wait_for_vm_ip)
|
|
try:
|
|
from ansible.module_utils.vmware import vim
|
|
from pyVmomi import vmodl
|
|
except ImportError:
|
|
pass
|
|
|
|
|
|
def path_exists(value):
|
|
if not isinstance(value, string_types):
|
|
value = str(value)
|
|
value = os.path.expanduser(os.path.expandvars(value))
|
|
if not os.path.exists(value):
|
|
raise ValueError('%s is not a valid path' % value)
|
|
return value
|
|
|
|
|
|
class ProgressReader(io.FileIO):
|
|
def __init__(self, name, mode='r', closefd=True):
|
|
self.bytes_read = 0
|
|
io.FileIO.__init__(self, name, mode=mode, closefd=closefd)
|
|
|
|
def read(self, size=10240):
|
|
chunk = io.FileIO.read(self, size)
|
|
self.bytes_read += len(chunk)
|
|
return chunk
|
|
|
|
|
|
class TarFileProgressReader(tarfile.ExFileObject):
|
|
def __init__(self, *args):
|
|
self.bytes_read = 0
|
|
tarfile.ExFileObject.__init__(self, *args)
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_value, traceback):
|
|
try:
|
|
self.close()
|
|
except:
|
|
pass
|
|
|
|
def read(self, size=10240):
|
|
chunk = tarfile.ExFileObject.read(self, size)
|
|
self.bytes_read += len(chunk)
|
|
return chunk
|
|
|
|
|
|
class VMDKUploader(Thread):
|
|
def __init__(self, vmdk, url, validate_certs=True, tarinfo=None, create=False):
|
|
Thread.__init__(self)
|
|
|
|
self.vmdk = vmdk
|
|
|
|
if tarinfo:
|
|
self.size = tarinfo.size
|
|
else:
|
|
self.size = os.stat(vmdk).st_size
|
|
|
|
self.url = url
|
|
self.validate_certs = validate_certs
|
|
self.tarinfo = tarinfo
|
|
|
|
self.f = None
|
|
self.e = None
|
|
|
|
self._create = create
|
|
|
|
@property
|
|
def bytes_read(self):
|
|
try:
|
|
return self.f.bytes_read
|
|
except AttributeError:
|
|
return 0
|
|
|
|
def _request_opts(self):
|
|
'''
|
|
Requests for vmdk files differ from other file types. Build the request options here to handle that
|
|
'''
|
|
headers = {
|
|
'Content-Length': self.size,
|
|
'Content-Type': 'application/octet-stream',
|
|
}
|
|
|
|
if self._create:
|
|
# Non-VMDK
|
|
method = 'PUT'
|
|
headers['Overwrite'] = 't'
|
|
else:
|
|
# VMDK
|
|
method = 'POST'
|
|
headers['Content-Type'] = 'application/x-vnd.vmware-streamVmdk'
|
|
|
|
return {
|
|
'method': method,
|
|
'headers': headers,
|
|
}
|
|
|
|
def _open_url(self):
|
|
open_url(self.url, data=self.f, validate_certs=self.validate_certs, **self._request_opts())
|
|
|
|
def run(self):
|
|
if self.tarinfo:
|
|
try:
|
|
with TarFileProgressReader(self.vmdk, self.tarinfo) as self.f:
|
|
self._open_url()
|
|
except Exception:
|
|
self.e = sys.exc_info()
|
|
else:
|
|
try:
|
|
with ProgressReader(self.vmdk, 'rb') as self.f:
|
|
self._open_url()
|
|
except Exception:
|
|
self.e = sys.exc_info()
|
|
|
|
|
|
class VMwareDeployOvf:
|
|
def __init__(self, module):
|
|
self.si = connect_to_api(module)
|
|
self.module = module
|
|
self.params = module.params
|
|
|
|
self.datastore = None
|
|
self.datacenter = None
|
|
self.resource_pool = None
|
|
self.network_mappings = []
|
|
|
|
self.ovf_descriptor = None
|
|
self.tar = None
|
|
|
|
self.lease = None
|
|
self.import_spec = None
|
|
self.entity = None
|
|
|
|
def get_objects(self):
|
|
self.datastore = find_datastore_by_name(self.si, self.params['datastore'])
|
|
if not self.datastore:
|
|
self.module.fail_json(msg='%(datastore)s could not be located' % self.params)
|
|
|
|
self.datacenter = find_datacenter_by_name(self.si, self.params['datacenter'])
|
|
if not self.datacenter:
|
|
self.module.fail_json(msg='%(datacenter)s could not be located' % self.params)
|
|
|
|
if self.params['cluster']:
|
|
cluster = find_cluster_by_name(self.si, self.params['cluster'])
|
|
if cluster is None:
|
|
self.module.fail_json(msg="Unable to find cluster '%(cluster)s'" % self.params)
|
|
else:
|
|
self.resource_pool = cluster.resourcePool
|
|
else:
|
|
self.resource_pool = find_resource_pool_by_name(self.si, self.params['resource_pool'])
|
|
if not self.resource_pool:
|
|
self.module.fail_json(msg='%(resource_pool)s could not be located' % self.params)
|
|
|
|
for key, value in self.params['networks'].items():
|
|
network = find_network_by_name(self.si, value)
|
|
if not network:
|
|
self.module.fail_json(msg='%(network)s could not be located' % self.params)
|
|
network_mapping = vim.OvfManager.NetworkMapping()
|
|
network_mapping.name = key
|
|
network_mapping.network = network
|
|
self.network_mappings.append(network_mapping)
|
|
|
|
return self.datastore, self.datacenter, self.resource_pool, self.network_mappings
|
|
|
|
def get_ovf_descriptor(self):
|
|
if tarfile.is_tarfile(self.params['ovf']):
|
|
self.tar = tarfile.open(self.params['ovf'])
|
|
ovf = None
|
|
for candidate in self.tar.getmembers():
|
|
dummy, ext = os.path.splitext(candidate.name)
|
|
if ext.lower() == '.ovf':
|
|
ovf = candidate
|
|
break
|
|
if not ovf:
|
|
self.module.fail_json(msg='Could not locate OVF file in %(ovf)s' % self.params)
|
|
|
|
self.ovf_descriptor = to_native(self.tar.extractfile(ovf).read())
|
|
else:
|
|
with open(self.params['ovf']) as f:
|
|
self.ovf_descriptor = f.read()
|
|
|
|
return self.ovf_descriptor
|
|
|
|
def get_lease(self):
|
|
datastore, datacenter, resource_pool, network_mappings = self.get_objects()
|
|
|
|
params = {
|
|
'diskProvisioning': self.params['disk_provisioning'],
|
|
}
|
|
if self.params['name']:
|
|
params['entityName'] = self.params['name']
|
|
if network_mappings:
|
|
params['networkMapping'] = network_mappings
|
|
if self.params['deployment_option']:
|
|
params['deploymentOption'] = self.params['deployment_option']
|
|
if self.params['properties']:
|
|
params['propertyMapping'] = []
|
|
for key, value in self.params['properties'].items():
|
|
property_mapping = vim.KeyValue()
|
|
property_mapping.key = key
|
|
property_mapping.value = str(value) if isinstance(value, bool) else value
|
|
params['propertyMapping'].append(property_mapping)
|
|
|
|
if self.params['folder']:
|
|
folder = self.si.searchIndex.FindByInventoryPath(self.params['folder'])
|
|
else:
|
|
folder = datacenter.vmFolder
|
|
|
|
spec_params = vim.OvfManager.CreateImportSpecParams(**params)
|
|
|
|
ovf_descriptor = self.get_ovf_descriptor()
|
|
|
|
self.import_spec = self.si.ovfManager.CreateImportSpec(
|
|
ovf_descriptor,
|
|
resource_pool,
|
|
datastore,
|
|
spec_params
|
|
)
|
|
|
|
errors = [to_native(e.msg) for e in getattr(self.import_spec, 'error', [])]
|
|
if self.params['fail_on_spec_warnings']:
|
|
errors.extend(
|
|
(to_native(w.msg) for w in getattr(self.import_spec, 'warning', []))
|
|
)
|
|
if errors:
|
|
self.module.fail_json(
|
|
msg='Failure validating OVF import spec: %s' % '. '.join(errors)
|
|
)
|
|
|
|
for warning in getattr(self.import_spec, 'warning', []):
|
|
self.module.warn('Problem validating OVF import spec: %s' % to_native(warning.msg))
|
|
|
|
if not self.params['allow_duplicates']:
|
|
name = self.import_spec.importSpec.configSpec.name
|
|
match = find_vm_by_name(self.si, name, folder=folder)
|
|
if match:
|
|
self.module.exit_json(instance=gather_vm_facts(self.si, match), changed=False)
|
|
|
|
if self.module.check_mode:
|
|
self.module.exit_json(changed=True, instance={'hw_name': name})
|
|
|
|
try:
|
|
self.lease = resource_pool.ImportVApp(
|
|
self.import_spec.importSpec,
|
|
folder
|
|
)
|
|
except vmodl.fault.SystemError as e:
|
|
self.module.fail_json(
|
|
msg='Failed to start import: %s' % to_native(e.msg)
|
|
)
|
|
|
|
while self.lease.state != vim.HttpNfcLease.State.ready:
|
|
time.sleep(0.1)
|
|
|
|
self.entity = self.lease.info.entity
|
|
|
|
return self.lease, self.import_spec
|
|
|
|
def _normalize_url(self, url):
|
|
'''
|
|
The hostname in URLs from vmware may be ``*`` update it accordingly
|
|
'''
|
|
url_parts = generic_urlparse(urlparse(url))
|
|
if url_parts.hostname == '*':
|
|
if url_parts.port:
|
|
url_parts.netloc = '%s:%d' % (self.params['hostname'], url_parts.port)
|
|
else:
|
|
url_parts.netloc = self.params['hostname']
|
|
|
|
return urlunparse(url_parts.as_list())
|
|
|
|
def upload(self):
|
|
if self.params['ovf'] is None:
|
|
self.module.fail_json(msg="OVF path is required for upload operation.")
|
|
|
|
ovf_dir = os.path.dirname(self.params['ovf'])
|
|
|
|
lease, import_spec = self.get_lease()
|
|
|
|
uploaders = []
|
|
|
|
for file_item in import_spec.fileItem:
|
|
device_upload_url = None
|
|
for device_url in lease.info.deviceUrl:
|
|
if file_item.deviceId == device_url.importKey:
|
|
device_upload_url = self._normalize_url(device_url.url)
|
|
break
|
|
|
|
if not device_upload_url:
|
|
lease.HttpNfcLeaseAbort(
|
|
vmodl.fault.SystemError(reason='Failed to find deviceUrl for file %s' % file_item.path)
|
|
)
|
|
self.module.fail_json(
|
|
msg='Failed to find deviceUrl for file %s' % file_item.path
|
|
)
|
|
|
|
vmdk_tarinfo = None
|
|
if self.tar:
|
|
vmdk = self.tar
|
|
try:
|
|
vmdk_tarinfo = self.tar.getmember(file_item.path)
|
|
except KeyError:
|
|
lease.HttpNfcLeaseAbort(
|
|
vmodl.fault.SystemError(reason='Failed to find VMDK file %s in OVA' % file_item.path)
|
|
)
|
|
self.module.fail_json(
|
|
msg='Failed to find VMDK file %s in OVA' % file_item.path
|
|
)
|
|
else:
|
|
vmdk = os.path.join(ovf_dir, file_item.path)
|
|
try:
|
|
path_exists(vmdk)
|
|
except ValueError:
|
|
lease.HttpNfcLeaseAbort(
|
|
vmodl.fault.SystemError(reason='Failed to find VMDK file at %s' % vmdk)
|
|
)
|
|
self.module.fail_json(
|
|
msg='Failed to find VMDK file at %s' % vmdk
|
|
)
|
|
|
|
uploaders.append(
|
|
VMDKUploader(
|
|
vmdk,
|
|
device_upload_url,
|
|
self.params['validate_certs'],
|
|
tarinfo=vmdk_tarinfo,
|
|
create=file_item.create
|
|
)
|
|
)
|
|
|
|
total_size = sum(u.size for u in uploaders)
|
|
total_bytes_read = [0] * len(uploaders)
|
|
for i, uploader in enumerate(uploaders):
|
|
uploader.start()
|
|
while uploader.is_alive():
|
|
time.sleep(0.1)
|
|
total_bytes_read[i] = uploader.bytes_read
|
|
lease.HttpNfcLeaseProgress(int(100.0 * sum(total_bytes_read) / total_size))
|
|
|
|
if uploader.e:
|
|
lease.HttpNfcLeaseAbort(
|
|
vmodl.fault.SystemError(reason='%s' % to_native(uploader.e[1]))
|
|
)
|
|
self.module.fail_json(
|
|
msg='%s' % to_native(uploader.e[1]),
|
|
exception=''.join(traceback.format_tb(uploader.e[2]))
|
|
)
|
|
|
|
def complete(self):
|
|
self.lease.HttpNfcLeaseComplete()
|
|
|
|
def power_on(self):
|
|
facts = {}
|
|
if self.params['power_on']:
|
|
task = self.entity.PowerOn()
|
|
if self.params['wait']:
|
|
wait_for_task(task)
|
|
if self.params['wait_for_ip_address']:
|
|
_facts = wait_for_vm_ip(self.si, self.entity)
|
|
if not _facts:
|
|
self.module.fail_json(msg='Waiting for IP address timed out')
|
|
facts.update(_facts)
|
|
|
|
if not facts:
|
|
facts.update(gather_vm_facts(self.si, self.entity))
|
|
|
|
return facts
|
|
|
|
|
|
def main():
|
|
argument_spec = vmware_argument_spec()
|
|
argument_spec.update({
|
|
'name': {},
|
|
'datastore': {
|
|
'default': 'datastore1',
|
|
},
|
|
'datacenter': {
|
|
'default': 'ha-datacenter',
|
|
},
|
|
'cluster': {
|
|
'default': None,
|
|
},
|
|
'deployment_option': {
|
|
'default': None,
|
|
},
|
|
'folder': {
|
|
'default': None,
|
|
},
|
|
'resource_pool': {
|
|
'default': 'Resources',
|
|
},
|
|
'networks': {
|
|
'default': {
|
|
'VM Network': 'VM Network',
|
|
},
|
|
'type': 'dict',
|
|
},
|
|
'ovf': {
|
|
'type': path_exists,
|
|
'aliases': ['ova'],
|
|
},
|
|
'disk_provisioning': {
|
|
'choices': [
|
|
'flat',
|
|
'eagerZeroedThick',
|
|
'monolithicSparse',
|
|
'twoGbMaxExtentSparse',
|
|
'twoGbMaxExtentFlat',
|
|
'thin',
|
|
'sparse',
|
|
'thick',
|
|
'seSparse',
|
|
'monolithicFlat'
|
|
],
|
|
'default': 'thin',
|
|
},
|
|
'power_on': {
|
|
'type': 'bool',
|
|
'default': True,
|
|
},
|
|
'properties': {
|
|
'type': 'dict',
|
|
},
|
|
'wait': {
|
|
'type': 'bool',
|
|
'default': True,
|
|
},
|
|
'wait_for_ip_address': {
|
|
'type': 'bool',
|
|
'default': False,
|
|
},
|
|
'allow_duplicates': {
|
|
'type': 'bool',
|
|
'default': True,
|
|
},
|
|
'fail_on_spec_warnings': {
|
|
'type': 'bool',
|
|
'default': False,
|
|
},
|
|
})
|
|
module = AnsibleModule(
|
|
argument_spec=argument_spec,
|
|
supports_check_mode=True,
|
|
)
|
|
|
|
if not HAS_PYVMOMI:
|
|
module.fail_json(msg='pyvmomi python library not found')
|
|
|
|
deploy_ovf = VMwareDeployOvf(module)
|
|
deploy_ovf.upload()
|
|
deploy_ovf.complete()
|
|
facts = deploy_ovf.power_on()
|
|
|
|
module.exit_json(instance=facts, changed=True)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|