Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

vmware: add the vmware_guest_instantclone module #61014

Closed
wants to merge 13 commits into from
6 changes: 6 additions & 0 deletions lib/ansible/module_utils/vmware.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,12 @@ def gather_vm_facts(content, vm):
pass
if vm.summary.runtime.dasVmProtection:
facts['hw_guest_ha_state'] = vm.summary.runtime.dasVmProtection.dasProtected
if vm.summary.runtime.instantCloneFrozen is not None:
# This property is only available to vSphere 6.7 and later
# This value will only affect instant clone operations
# which are not available to all ESXi and vSphere installations.
facts['instant_clone_frozen'] = vm.summary.runtime.instantCloneFrozen


datastores = vm.datastore
for ds in datastores:
Expand Down
265 changes: 265 additions & 0 deletions lib/ansible/modules/cloud/vmware/vmware_guest_instantclone.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
#
# 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 = r'''
---
module: vmware_guest_instantclone
short_description: Manages virtual machines instant clones in vCenter
description:
- This module can be used to create instant clones of a given virtual machine.
- All parameters and VMware object names are case sensitive.
version_added: 2.10
author:
- Dakota Clark (@PDQDakota) <dakota.clark@pdq.com>
notes:
- Tested on vSphere 6.7
- For best results, freeze the source VM before use
requirements:
- "python >= 2.6"
- PyVmomi
options:
name:
description:
- Name of the VM to create an instant clone from.
- This is required if C(uuid) or C(moid) is not supplied.
required: True
type: str
name_match:
description:
- If multiple virtual machines matching the name, use the first or last found.
default: 'first'
choices: [ first, last ]
type: str
uuid:
description:
- UUID of the virtual machine to clone from, if known; this is VMware's unique identifier.
- This is required if C(name) or C(moid) is not supplied.
type: str
moid:
description:
- Managed Object ID of the base VM, if known. This is a unique identifier only within a single vCenter instance.
- This is required if C(name) or C(uuid) is not supplied.
type: str
use_instance_uuid:
description:
- Whether to use the VMware instance UUID rather than the BIOS UUID.
default: no
type: bool
datacenter:
description:
- Datacenter for the clone operation.
- This parameter is case sensitive.
type: str
clone_name:
description:
- Name of the new instant clone VM
type: str
customvalues:
description:
- A Key / Value list of custom configuration parameters.
required: False
type: list
extends_documentation_fragment: vmware.documentation
'''

EXAMPLES = r'''
- name: Create an instant clone of a running VM
vmware_guest_instantclone:
hostname: "{{ vcenter_hostname }}"
username: "{{ vcenter_username }}"
password: "{{ vcenter_password }}"
validate_certs: no
name: source-vm
datacenter: MYVMWDC
clone_name: new-instantclone
delegate_to: localhost

- name: Create an instant clone with custom config values
vmware_guest_instantclone:
hostname: "{{ vcenter_hostname }}"
username: "{{ vcenter_username }}"
password: "{{ vcenter_password }}"
validate_certs: no
name: source-vm
datacenter: MYVMWDC
clone_name: new-instantclone

delegate_to: localhost
'''

RETURN = r'''
instance:
description: metadata about the new instant clone
returned: always
type: dict
sample: None
'''

HAS_PYVMOMI = False
try:
from pyVmomi import vim, vmodl, VmomiSupport
HAS_PYVMOMI = True
except ImportError:
pass

from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.common.network import is_mac
from ansible.module_utils._text import to_text, to_native
from ansible.module_utils.vmware import (find_obj, gather_vm_facts, vmware_argument_spec,
set_vm_power_state, PyVmomi, wait_for_task, TaskError)


class PyVmomiHelper(PyVmomi):
def __init__(self, module):
super(PyVmomiHelper, self).__init__(module)

def deploy_instantclone(self, vm):
vm_state = gather_vm_facts(self.content, vm)
# An instant clone requires the base VM to be running, fail if it is not
if vm_state['hw_power_status'] != 'poweredOn':
self.module.fail_json(msg='Unable to instant clone a VM in a "%s" state. It must be powered on.' % vm_state['hw_power_status'])
if vm_state['instant_clone_frozen'] is None:
self.module.fail_json(msg='Unable to determine if VM is in a frozen state. Is vSphere running 6.7 or later?')

# Build VM config dict
config = []
# If customvalues is not empty, fill config
if len(self.module.params['customvalues']) != 0:
for kv in self.module.params['customvalues']:
if 'key' not in kv or 'value' not in kv:
self.module.exit_json(msg="The parameter customvalues items required both 'key' and 'value' fields.")
ov = vim.OptionValue()
ov.key = kv['key']
ov.value = kv['value']
config.append(ov)

# Begin building the spec
instantclone_spec = vim.VirtualMachineInstantCloneSpec()
location_spec = vim.VirtualMachineRelocateSpec()
instantclone_spec.config = config
instantclone_spec.name = self.module.params['clone_name']

if vm_state['instant_clone_frozen'] is False:
# VM is not frozen, need to do prep work for instant clone
vm_network_adapters = []
devices = vm.config.hardware.device
for device in devices:
if isinstance(device, vim.VirtualEthernetCard):
vm_network_adapters.append(device)

for vm_network_adapter in vm_network_adapters:
# Standard network switch
if isinstance(vm_network_adapter.backing, vim.VirtualEthernetCardNetworkBackingInfo):
network_id = vm_network_adapter.backing.network
device_spec = vim.VirtualDeviceConfigSpec()
device_spec.operation = 'edit'
device_spec.device = vm_network_adapter
device_spec.device.backing = vim.VirtualEthernetCardNetworkBackingInfo()
device_spec.device.backing.deviceName = network_id
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

network_id is a vim.Network instance, so you should actually set network_id.name in deviceName.

connectable = vim.VirtualDeviceConnectInfo()
connectable.migrateConnect = 'disconnect'
device_spec.device.connectable = connectable
location_spec.deviceChange.append(device_spec)
# Distributed network switch
elif isinstance(vm_network_adapter.backing, vim.VirtualEthernetCardDistributedVirtualPortBackingInfo):
network_id = vm_network_adapter.backing.port
# If the port key isn't cleared the VM clone will fail as the port is in use by the running source VM.
network_id.portKey = None
device_spec = vim.VirtualDeviceConfigSpec()
device_spec.operation = 'edit'
device_spec.device = vm_network_adapter
device_spec.device.backing = vim.VirtualEthernetCardDistributedVirtualPortBackingInfo()
device_spec.device.backing.port = network_id
connectable = vim.VirtualDeviceConnectInfo()
connectable.migrateConnect = 'disconnect'
device_spec.device.connectable = connectable
location_spec.deviceChange.append(device_spec)
else:
self.module.module.exit_json(msg='Unknown network backing type of %s only Virtual Distributed switches and Standard swtiches are supported.'
% vm_network_adapter.__class__.__name__.split('.')[-1])

# Finalize the spec for a non-frozen VM
instantclone_spec.location = location_spec

else:
# VM is frozen, can clone without prep work
# Finalize the spec for a frozen VM
instantclone_spec.location = location_spec

task = vm.InstantClone_Task(instantclone_spec)
wait_for_task(task)

if task.info.state == 'error':
kwargs = {
'changed': False,
'failed': True,
'msg': task.info.error.msg,
'clone_method': 'InstantClone_Task'
}
return kwargs

clone = task.info.result
vm_facts = gather_vm_facts(self.content, clone)
return {
'changed': True,
'failed': False,
'instance': vm_facts,
'clone_method': 'InstantClone_Task'
}


def main():
argument_spec = vmware_argument_spec()
argument_spec.update(
name=dict(type='str'),
name_match=dict(type='str', choices=['first', 'last'], default='first'),
uuid=dict(type='str'),
moid=dict(type='str'),
use_instance_uuid=dict(type='bool', default=False),
datacenter=dict(required=True, type='str'),
clone_name=dict(required=True, type='str'),
customvalues=dict(type='list', default=[]),
)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
required_one_of=[
['name', 'uuid', 'moid']
],
)

result = {'failed': False, 'changed': False}

pyv = PyVmomiHelper(module)
vm = pyv.get_vm()

if not vm:
vm_id = (module.params.get('uuid') or module.params.get('name') or module.params.get('moid'))
module.fail_json(msg="Unable to find any VM with the identifier: %s" % vm_id)

if module.check_mode:
result.update(
changed=True,
desired_operation='instantclone_vm',
)
module.exit_json(**result)

result = pyv.deploy_instantclone(vm)

if result['failed']:
module.fail_json(**result)
else:
module.exit_json(**result)


if __name__ == '__main__':
main()
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Test0001: Create an instant clone
- name: 0001 - Create an instant clone
validate_certs: False
hostname: '{{ vcenter_hostname }}'
username: '{{ vcenter_username }}'
password: '{{ vcenter_password }}'
datacenter: '{{ dc1 }}'
name: '{{ source_vm }}'
clone_name: '{{ name }}'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to initialize the environment with prepare_vmware_test. I used this playbook to test your module:

- import_role:
    name: prepare_vmware_tests
  vars:
    setup_attach_host: true
    setup_datastore: true
    setup_virtualmachines: true

- name: set state to poweron the first VM
  vmware_guest_powerstate:
    validate_certs: no
    hostname: "{{ vcenter_hostname }}"
    username: "{{ vcenter_username }}"
    password: "{{ vcenter_password }}"
    name: "{{ virtual_machines[0].name }}"
    folder: '{{ f0 }}'
    state: powered-on

# Test0001: Create an instant clone
- name: 0001 - Create an instant clone
  vmware_guest_instantclone:
    validate_certs: False
    hostname: '{{ vcenter_hostname }}'
    username: '{{ vcenter_username }}'
    password: '{{ vcenter_password }}'
    datacenter: '{{ dc1 }}'
    name: "{{ virtual_machines[0].name }}"
    clone_name: test_vm1

Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
[
[
{
"name": "sample_vm_001",
"hostname": "esxi01.example.com",
"username": "administrator@vsphere.local",
"password": "Secret@123$%",
"validate_certs": "True",
"datacenter": "test-datacenter",
"clone_name": "clone-test"
},
{
"failed": "True",
"msg": "Unknown error while connecting to vCenter or ESXi API at esxi01.example.com:443 :"
}
],
[
{
"hostname": "esxi01.example.com",
"username": "administrator@vsphere.local",
"password": "Secret@123$%",
"validate_certs": "False",
"datacenter": "test-datacenter",
"clone_name": "clone-test"
},
{
"failed": "True",
"msg": "one of the following is required: name, uuid, moid"
}
]
]
51 changes: 51 additions & 0 deletions test/units/modules/cloud/vmware/test_vmware_guest_instantclone.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
#
# Copyright: (c) 2019, Ansible Project
# 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

import os
import sys
import pytest
import json

pyvmomi = pytest.importorskip('pyVmomi')

if sys.version_info < (2, 7):
pytestmark = pytest.mark.skip("vmware_guest_instantclone Ansible modules require Python >= 2.7")


from ansible.modules.cloud.vmware import vmware_guest_instantclone

curr_dir = os.path.dirname(__file__)
test_data_file = open(os.path.join(curr_dir, 'test_data', 'test_vmware_guest_instantclone_with_parameters.json'), 'r')
TEST_CASES = json.loads(test_data_file.read())
test_data_file.close()


@pytest.mark.parametrize('patch_ansible_module', [{}], indirect=['patch_ansible_module'])
@pytest.mark.usefixtures('patch_ansible_module')
def test_vmware_guest_instantclone_wo_parameters(capfd):
with pytest.raises(SystemExit):
vmware_guest_instantclone.main()
out, err = capfd.readouterr()
results = json.loads(out)
assert results['failed']
assert "missing required arguments:" in results['msg']


@pytest.mark.parametrize('patch_ansible_module, testcase', TEST_CASES, indirect=['patch_ansible_module'])
@pytest.mark.usefixtures('patch_ansible_module')
def test_vmware_guest_instantclone_with_parameters(mocker, capfd, testcase):
if testcase.get('test_ssl_context', None):
class mocked_ssl:
pass
mocker.patch('ansible.module_utils.vmware.ssl', new=mocked_ssl)

with pytest.raises(SystemExit):
vmware_guest_instantclone.main()
out, err = capfd.readouterr()
results = json.loads(out)
assert str(results['failed']) == testcase['failed']
assert testcase['msg'] in results['msg']