diff --git a/lib/ansible/modules/cloud/xenserver/xenserver_guest_powerstate.py b/lib/ansible/modules/cloud/xenserver/xenserver_guest_powerstate.py new file mode 100644 index 0000000000..a385c6dacd --- /dev/null +++ b/lib/ansible/modules/cloud/xenserver/xenserver_guest_powerstate.py @@ -0,0 +1,264 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2018, Bojan Vitnik +# 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: xenserver_guest_powerstate +short_description: Manages power states of virtual machines running on Citrix XenServer host or pool +description: > + This module can be used to power on, power off, restart or suspend virtual machine and grecefully reboot or shutdown guest OS of virtual machine. +version_added: '2.8' +author: +- Bojan Vitnik (@bvitnik) +notes: +- Minimal supported version of XenServer is 5.6 +- Module was tested with XenServer 6.5, 7.1 and 7.2 +- 'If no scheme is specified in C(hostname), module defaults to C(http://) because C(https://) is problematic in most setups. Make sure you are + accessing XenServer host in trusted environment or use C(https://) scheme explicitly.' +- 'To use C(https://) scheme for C(hostname) you have to either import host certificate to your OS certificate store or use C(validate_certs: no) + which requires XenAPI.py from XenServer 7.2 SDK or newer and Python 2.7.9 or newer.' +requirements: +- python >= 2.6 +- XenAPI +options: + state: + description: + - Specify the state VM should be in. + - If C(state) is set to value other than C(present), then VM is transitioned into required state and facts are returned. + - If C(state) is set to C(present), then VM is just checked for existance and facts are returned. + default: present + choices: [ powered-on, powered-off, restarted, shutdown-guest, reboot-guest, suspended, present ] + name: + description: + - Name of the VM to work with. + - VMs running on XenServer do not necessarily have unique names. The module will fail if multiple VMs with same name are found. + - In case of multiple VMs with same name, use C(uuid) to uniquely specify VM to manage. + - This parameter is case sensitive. + required: yes + aliases: [ 'name_label' ] + uuid: + description: + - UUID of the VM to manage if known, this is XenServer's unique identifier. + - It is required if name is not unique. + wait_for_ip_address: + description: + - Wait until XenServer detects an IP address for the VM. + - This requires XenServer Tools preinstaled on VM to properly work. + default: 'no' + type: bool + state_change_timeout: + description: + - 'By default, module will wait indefinitely for VM to change state or accquire an IP address if C(wait_for_ip_address: yes).' + - If this parameter is set to positive value, the module will instead wait specified number of seconds for the state change. + - In case of timeout, module will generate an error message. + default: 0 +extends_documentation_fragment: xenserver.documentation +''' + +EXAMPLES = r''' +- name: Power on VM + xenserver_guest_powerstate: + hostname: 192.168.1.209 + username: root + password: xenserver + name: testvm_11 + state: powered-on + delegate_to: localhost + register: facts +''' + +RETURN = r''' +instance: + description: Metadata about the VM + returned: always + type: dict + sample: { + "cdrom": { + "type": "none" + }, + "customization_agent": "native", + "disks": [ + { + "name": "windows-template-testing-0", + "name_desc": "", + "os_device": "xvda", + "size": 42949672960, + "sr": "Local storage", + "sr_uuid": "0af1245e-bdb0-ba33-1446-57a962ec4075", + "vbd_userdevice": "0" + }, + { + "name": "windows-template-testing-1", + "name_desc": "", + "os_device": "xvdb", + "size": 42949672960, + "sr": "Local storage", + "sr_uuid": "0af1245e-bdb0-ba33-1446-57a962ec4075", + "vbd_userdevice": "1" + } + ], + "domid": "56", + "folder": "", + "hardware": { + "memory_mb": 8192, + "num_cpu_cores_per_socket": 2, + "num_cpus": 4 + }, + "home_server": "", + "is_template": false, + "name": "windows-template-testing", + "name_desc": "", + "networks": [ + { + "gateway": "192.168.0.254", + "gateway6": "fc00::fffe", + "ip": "192.168.0.200", + "ip6": [ + "fe80:0000:0000:0000:e9cb:625a:32c5:c291", + "fc00:0000:0000:0000:0000:0000:0000:0001" + ], + "mac": "ba:91:3a:48:20:76", + "mtu": "1500", + "name": "Pool-wide network associated with eth1", + "netmask": "255.255.255.128", + "prefix": "25", + "prefix6": "64", + "vif_device": "0" + } + ], + "other_config": { + "base_template_name": "Windows Server 2016 (64-bit)", + "import_task": "OpaqueRef:e43eb71c-45d6-5351-09ff-96e4fb7d0fa5", + "install-methods": "cdrom", + "instant": "true", + "mac_seed": "f83e8d8a-cfdc-b105-b054-ef5cb416b77e" + }, + "platform": { + "acpi": "1", + "apic": "true", + "cores-per-socket": "2", + "device_id": "0002", + "hpet": "true", + "nx": "true", + "pae": "true", + "timeoffset": "-25200", + "vga": "std", + "videoram": "8", + "viridian": "true", + "viridian_reference_tsc": "true", + "viridian_time_ref_count": "true" + }, + "state": "poweredon", + "uuid": "e3c0b2d5-5f05-424e-479c-d3df8b3e7cda", + "xenstore_data": { + "vm-data": "" + } + } +''' + +import re + +HAS_XENAPI = False +try: + import XenAPI + HAS_XENAPI = True +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.xenserver import (xenserver_common_argument_spec, XAPI, XenServerObject, get_object_ref, + gather_vm_params, gather_vm_facts, set_vm_power_state, wait_for_vm_ip_address) + + +class XenServerVM(XenServerObject): + """Class for managing XenServer VM. + + Attributes: + vm_ref (str): XAPI reference to VM. + vm_params (dict): A dictionary with VM parameters as returned + by gather_vm_params() function. + """ + + def __init__(self, module): + """Inits XenServerVM using module parameters. + + Args: + module: Reference to Ansible module object. + """ + super(XenServerVM, self).__init__(module) + + self.vm_ref = get_object_ref(self.module, self.module.params['name'], self.module.params['uuid'], obj_type="VM", fail=True, msg_prefix="VM search: ") + self.gather_params() + + def gather_params(self): + """Gathers all VM parameters available in XAPI database.""" + self.vm_params = gather_vm_params(self.module, self.vm_ref) + + def gather_facts(self): + """Gathers and returns VM facts.""" + return gather_vm_facts(self.module, self.vm_params) + + def set_power_state(self, power_state): + """Controls VM power state.""" + state_changed, current_state = set_vm_power_state(self.module, self.vm_ref, power_state, self.module.params['state_change_timeout']) + + # If state has changed, update vm_params. + if state_changed: + self.vm_params['power_state'] = current_state.capitalize() + + return state_changed + + def wait_for_ip_address(self): + """Waits for VM to acquire an IP address.""" + self.vm_params['guest_metrics'] = wait_for_vm_ip_address(self.module, self.vm_ref, self.module.params['state_change_timeout']) + + +def main(): + argument_spec = xenserver_common_argument_spec() + argument_spec.update( + state=dict(type='str', default='present', + choices=['powered-on', 'powered-off', 'restarted', 'shutdown-guest', 'reboot-guest', 'suspended', 'present']), + name=dict(type='str', aliases=['name_label']), + uuid=dict(type='str'), + wait_for_ip_address=dict(type='bool', default=False), + state_change_timeout=dict(type='int', default=0), + ) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[ + ['name', 'uuid'], + ], + ) + + result = {'failed': False, 'changed': False} + + vm = XenServerVM(module) + + # Set VM power state. + if module.params['state'] != "present": + result['changed'] = vm.set_power_state(module.params['state']) + + if module.params['wait_for_ip_address']: + vm.wait_for_ip_address() + + result['instance'] = vm.gather_facts() + + if result['failed']: + module.fail_json(**result) + else: + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/cloud/xenserver/FakeAnsibleModule.py b/test/units/modules/cloud/xenserver/FakeAnsibleModule.py new file mode 100644 index 0000000000..b02ad4a136 --- /dev/null +++ b/test/units/modules/cloud/xenserver/FakeAnsibleModule.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2019, Bojan Vitnik +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +class AnsibleModuleException(Exception): + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + + +class ExitJsonException(AnsibleModuleException): + pass + + +class FailJsonException(AnsibleModuleException): + pass + + +class FakeAnsibleModule: + def __init__(self, params=None, check_mode=False): + self.params = params + self.check_mode = check_mode + + def exit_json(self, *args, **kwargs): + raise ExitJsonException(*args, **kwargs) + + def fail_json(self, *args, **kwargs): + raise FailJsonException(*args, **kwargs) diff --git a/test/units/modules/cloud/xenserver/conftest.py b/test/units/modules/cloud/xenserver/conftest.py index 4b4d1e7c59..f83befb82a 100644 --- a/test/units/modules/cloud/xenserver/conftest.py +++ b/test/units/modules/cloud/xenserver/conftest.py @@ -11,6 +11,24 @@ import sys import importlib import pytest +from .FakeAnsibleModule import FakeAnsibleModule + + +@pytest.fixture +def fake_ansible_module(request): + """Returns fake AnsibleModule with fake module params.""" + if hasattr(request, 'param'): + return FakeAnsibleModule(request.param) + else: + params = { + "hostname": "somehost", + "username": "someuser", + "password": "somepwd", + "validate_certs": True, + } + + return FakeAnsibleModule(params) + @pytest.fixture(autouse=True) def XenAPI(): @@ -42,3 +60,16 @@ def xenserver_guest_facts(XenAPI): from ansible.modules.cloud.xenserver import xenserver_guest_facts return xenserver_guest_facts + + +@pytest.fixture +def xenserver_guest_powerstate(XenAPI): + """Imports and returns xenserver_guest_powerstate module.""" + + # Since we are wrapping fake XenAPI module inside a fixture, all modules + # that depend on it have to be imported inside a test function. To make + # this easier to handle and remove some code repetition, we wrap the import + # of xenserver_guest_powerstate module with a fixture. + from ansible.modules.cloud.xenserver import xenserver_guest_powerstate + + return xenserver_guest_powerstate diff --git a/test/units/modules/cloud/xenserver/test_xenserver_guest_powerstate.py b/test/units/modules/cloud/xenserver/test_xenserver_guest_powerstate.py new file mode 100644 index 0000000000..f346ee61f1 --- /dev/null +++ b/test/units/modules/cloud/xenserver/test_xenserver_guest_powerstate.py @@ -0,0 +1,289 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2019, Bojan Vitnik +# 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 json +import pytest + +from .common import fake_xenapi_ref + + +testcase_set_powerstate = { + "params": [ + (False, "someoldstate"), + (True, "somenewstate"), + ], + "ids": [ + "state-same", + "state-changed", + ], +} + +testcase_module_params_state_present = { + "params": [ + { + "hostname": "somehost", + "username": "someuser", + "password": "somepwd", + "name": "somevmname", + }, + { + "hostname": "somehost", + "username": "someuser", + "password": "somepwd", + "name": "somevmname", + "state": "present", + }, + ], + "ids": [ + "present-implicit", + "present-explicit", + ], +} + +testcase_module_params_state_other = { + "params": [ + { + "hostname": "somehost", + "username": "someuser", + "password": "somepwd", + "name": "somevmname", + "state": "powered-on", + }, + { + "hostname": "somehost", + "username": "someuser", + "password": "somepwd", + "name": "somevmname", + "state": "powered-off", + }, + { + "hostname": "somehost", + "username": "someuser", + "password": "somepwd", + "name": "somevmname", + "state": "restarted", + }, + { + "hostname": "somehost", + "username": "someuser", + "password": "somepwd", + "name": "somevmname", + "state": "shutdown-guest", + }, + { + "hostname": "somehost", + "username": "someuser", + "password": "somepwd", + "name": "somevmname", + "state": "reboot-guest", + }, + { + "hostname": "somehost", + "username": "someuser", + "password": "somepwd", + "name": "somevmname", + "state": "suspended", + }, + ], + "ids": [ + "powered-on", + "powered-off", + "restarted", + "shutdown-guest", + "reboot-guest", + "suspended", + ], +} + +testcase_module_params_wait = { + "params": [ + { + "hostname": "somehost", + "username": "someuser", + "password": "somepwd", + "name": "somevmname", + "state": "present", + "wait_for_ip_address": "yes", + }, + { + "hostname": "somehost", + "username": "someuser", + "password": "somepwd", + "name": "somevmname", + "state": "powered-on", + "wait_for_ip_address": "yes", + }, + ], + "ids": [ + "wait-present", + "wait-other", + ], +} + + +@pytest.mark.parametrize('power_state', testcase_set_powerstate['params'], ids=testcase_set_powerstate['ids']) +def test_xenserver_guest_powerstate_set_power_state(mocker, fake_ansible_module, XenAPI, xenserver_guest_powerstate, power_state): + """Tests power state change handling.""" + mocker.patch('ansible.modules.cloud.xenserver.xenserver_guest_powerstate.get_object_ref', return_value=fake_xenapi_ref('VM')) + mocker.patch('ansible.modules.cloud.xenserver.xenserver_guest_powerstate.gather_vm_params', return_value={"power_state": "Someoldstate"}) + mocked_set_vm_power_state = mocker.patch('ansible.modules.cloud.xenserver.xenserver_guest_powerstate.set_vm_power_state', + return_value=power_state) + + mocked_xenapi = mocker.patch.object(XenAPI.Session, 'xenapi', create=True) + + mocked_returns = { + "pool.get_all.return_value": [fake_xenapi_ref('pool')], + "pool.get_default_SR.return_value": fake_xenapi_ref('SR'), + } + + mocked_xenapi.configure_mock(**mocked_returns) + + mocker.patch('ansible.module_utils.xenserver.get_xenserver_version', return_value=[7, 2, 0]) + + fake_ansible_module.params.update({ + "name": "somename", + "uuid": "someuuid", + "state_change_timeout": 1, + }) + + vm = xenserver_guest_powerstate.XenServerVM(fake_ansible_module) + state_changed = vm.set_power_state(None) + + mocked_set_vm_power_state.assert_called_once_with(fake_ansible_module, fake_xenapi_ref('VM'), None, 1) + assert state_changed == power_state[0] + assert vm.vm_params['power_state'] == power_state[1].capitalize() + + +@pytest.mark.parametrize('patch_ansible_module', + testcase_module_params_state_present['params'], + ids=testcase_module_params_state_present['ids'], + indirect=True) +def test_xenserver_guest_powerstate_present(mocker, patch_ansible_module, capfd, XenAPI, xenserver_guest_powerstate): + """ + Tests regular module invocation including parsing and propagation of + module params and module output when state is set to present. + """ + fake_vm_facts = {"fake-vm-fact": True} + + mocker.patch('ansible.modules.cloud.xenserver.xenserver_guest_powerstate.get_object_ref', return_value=fake_xenapi_ref('VM')) + mocker.patch('ansible.modules.cloud.xenserver.xenserver_guest_powerstate.gather_vm_params', return_value={}) + mocker.patch('ansible.modules.cloud.xenserver.xenserver_guest_powerstate.gather_vm_facts', return_value=fake_vm_facts) + mocked_set_vm_power_state = mocker.patch('ansible.modules.cloud.xenserver.xenserver_guest_powerstate.set_vm_power_state', + return_value=(True, "somenewstate")) + mocked_wait_for_vm_ip_address = mocker.patch('ansible.modules.cloud.xenserver.xenserver_guest_powerstate.wait_for_vm_ip_address', + return_value={}) + + mocked_xenapi = mocker.patch.object(XenAPI.Session, 'xenapi', create=True) + + mocked_returns = { + "pool.get_all.return_value": [fake_xenapi_ref('pool')], + "pool.get_default_SR.return_value": fake_xenapi_ref('SR'), + } + + mocked_xenapi.configure_mock(**mocked_returns) + + mocker.patch('ansible.module_utils.xenserver.get_xenserver_version', return_value=[7, 2, 0]) + + with pytest.raises(SystemExit): + xenserver_guest_powerstate.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + mocked_set_vm_power_state.assert_not_called() + mocked_wait_for_vm_ip_address.assert_not_called() + assert result['changed'] is False + assert result['instance'] == fake_vm_facts + + +@pytest.mark.parametrize('patch_ansible_module', + testcase_module_params_state_other['params'], + ids=testcase_module_params_state_other['ids'], + indirect=True) +def test_xenserver_guest_powerstate_other(mocker, patch_ansible_module, capfd, XenAPI, xenserver_guest_powerstate): + """ + Tests regular module invocation including parsing and propagation of + module params and module output when state is set to other value than + present. + """ + fake_vm_facts = {"fake-vm-fact": True} + + mocker.patch('ansible.modules.cloud.xenserver.xenserver_guest_powerstate.get_object_ref', return_value=fake_xenapi_ref('VM')) + mocker.patch('ansible.modules.cloud.xenserver.xenserver_guest_powerstate.gather_vm_params', return_value={}) + mocker.patch('ansible.modules.cloud.xenserver.xenserver_guest_powerstate.gather_vm_facts', return_value=fake_vm_facts) + mocked_set_vm_power_state = mocker.patch( + 'ansible.modules.cloud.xenserver.xenserver_guest_powerstate.set_vm_power_state', + return_value=(True, "somenewstate")) + mocked_wait_for_vm_ip_address = mocker.patch( + 'ansible.modules.cloud.xenserver.xenserver_guest_powerstate.wait_for_vm_ip_address', + return_value={}) + + mocked_xenapi = mocker.patch.object(XenAPI.Session, 'xenapi', create=True) + + mocked_returns = { + "pool.get_all.return_value": [fake_xenapi_ref('pool')], + "pool.get_default_SR.return_value": fake_xenapi_ref('SR'), + } + + mocked_xenapi.configure_mock(**mocked_returns) + + mocker.patch('ansible.module_utils.xenserver.get_xenserver_version', return_value=[7, 2, 0]) + + with pytest.raises(SystemExit): + xenserver_guest_powerstate.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + mocked_set_vm_power_state.assert_called_once() + mocked_wait_for_vm_ip_address.assert_not_called() + assert result['changed'] is True + assert result['instance'] == fake_vm_facts + + +@pytest.mark.parametrize('patch_ansible_module', + testcase_module_params_wait['params'], + ids=testcase_module_params_wait['ids'], + indirect=True) +def test_xenserver_guest_powerstate_wait(mocker, patch_ansible_module, capfd, XenAPI, xenserver_guest_powerstate): + """ + Tests regular module invocation including parsing and propagation of + module params and module output when wait_for_ip_address option is used. + """ + fake_vm_facts = {"fake-vm-fact": True} + + mocker.patch('ansible.modules.cloud.xenserver.xenserver_guest_powerstate.get_object_ref', return_value=fake_xenapi_ref('VM')) + mocker.patch('ansible.modules.cloud.xenserver.xenserver_guest_powerstate.gather_vm_params', return_value={}) + mocker.patch('ansible.modules.cloud.xenserver.xenserver_guest_powerstate.gather_vm_facts', return_value=fake_vm_facts) + mocked_set_vm_power_state = mocker.patch( + 'ansible.modules.cloud.xenserver.xenserver_guest_powerstate.set_vm_power_state', + return_value=(True, "somenewstate")) + mocked_wait_for_vm_ip_address = mocker.patch( + 'ansible.modules.cloud.xenserver.xenserver_guest_powerstate.wait_for_vm_ip_address', + return_value={}) + + mocked_xenapi = mocker.patch.object(XenAPI.Session, 'xenapi', create=True) + + mocked_returns = { + "pool.get_all.return_value": [fake_xenapi_ref('pool')], + "pool.get_default_SR.return_value": fake_xenapi_ref('SR'), + } + + mocked_xenapi.configure_mock(**mocked_returns) + + mocker.patch('ansible.module_utils.xenserver.get_xenserver_version', return_value=[7, 2, 0]) + + with pytest.raises(SystemExit): + xenserver_guest_powerstate.main() + + out, err = capfd.readouterr() + result = json.loads(out) + + mocked_wait_for_vm_ip_address.assert_called_once() + assert result['instance'] == fake_vm_facts