Ansible module using libvirt to manage KVM virtual machines.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

441 lines
16 KiB

#!/usr/bin/python3
# This code is released under the WTFPL(http://www.wtfpl.net/) by MCO System (https://www.mcos.nc)
import os
import shutil
import libvirt
import subprocess
from lxml import etree
from xmldiff import main as xd_main
from ansible.module_utils.basic import AnsibleModule
# ------------------------------------------------------------------------------
# VM
# ------------------------------------------------------------------------------
class VM:
def __init__(self, params):
self.xml_base_filename = "kvm_base.xml"
self.vir_connection = "qemu:///system"
self.params = params
self.xml_actual_domain_root = None
self.xml_tmp_domain_root = None
self.vir_domain = None
def create_disks(self):
"""Create supplementary disk files according to self.params['disks']
"""
out = []
err = ''
for disk in self.params['disks']:
disk_size = disk['size']
disk_filename = '{}.{}.img'.format(self.params['name'], disk['name'])
disk_filepath = os.path.join(self.params['libvirt_img_path'], disk_filename)
try:
if not os.path.isfile(disk_filepath):
# Ugly way to create disk
# FIXME: Use libvirt to create storage pool and add disks to it
cmd = '/usr/bin/qemu-img create -f qcow2 {} {}'.format(disk_filepath, disk_size)
process = subprocess.run(cmd,
shell=True,
stderr=subprocess.PIPE)
stderr = process.stderr
if len(stderr) != 0:
return (out, err)
out.append(disk['name'])
except Exception as e:
err = str(e)
return (out, err)
def _add_disks(self, element):
"""Add disks to element
"""
address = 4
for disk in self.params['disks']:
address += 1
if address < 10:
address = '0{}'.format(address)
disk_filename = '{}.{}.img'.format(self.params['name'], disk['name'])
disk_filepath = os.path.join(self.params['libvirt_img_path'], disk_filename)
xml_disk = "<disk type='file' device='disk'></disk>"
xml_driver = "<driver name='qemu' type='qcow2'/>"
xml_source = "<source file='{}'/>".format(disk_filepath)
xml_target = "<target dev='{}' bus='virtio'/>".format(disk['name'])
xml_address = "<address type='pci' domain='0x0000' bus='0x00' slot='0x{}' function='0x0'/>".format(address)
disk = etree.fromstring(xml_disk)
disk.append(etree.fromstring(xml_driver))
disk.append(etree.fromstring(xml_source))
disk.append(etree.fromstring(xml_target))
disk.append(etree.fromstring(xml_address))
element.append(disk)
def _set_xml_uuid(self, element):
"""Set VM UUID
"""
element.text = self.params['uuid']
def _set_xml_name(self, element):
"""Set VM name
"""
element.text = self.params['name']
def _set_xml_description(self, element):
"""Set VM description
"""
element.text = self.params['description']
def _set_xml_memory(self, element):
"""Set VM memory
"""
element.text = str(self.params['max_memory'])
def _set_xml_current_memory(self, element):
"""Set VM current memory.
"""
element.text = str(self.params['memory'])
def _set_xml_vcpu(self, element):
"""Set VM vcpu number and cpu set
"""
element.set('cpuset', str(self.params['cpu_set']))
element.text = str(self.params['vcpus'])
def _set_xml_os(self, element):
"""Set vM machine type
"""
element.find('type').set('machine', self.params['machine_type'])
def _set_xml_devices(self, element):
"""Set VM devices parameters
- Disk image file name
- Network interface MAC address
- Spice listening iP address
- Spice listening port
- Spice password
"""
img_filename = '{}.{}'.format(self.params['name'], 'img')
img_filename = os.path.join(self.params['libvirt_img_path'], img_filename)
element.find('disk').find('source').set('file', img_filename)
element.find('interface').find('mac').set('address',
self.params['mac_addr'])
element.find('graphics').set('port',
str(self.params['spice_port']))
element.find('graphics').set('listen',
self.params['spice_interface'])
element.find('graphics').set('passwd',
self.params['spice_password'])
element.find('graphics').find('listen').set('address',
self.params['spice_interface'])
self._add_disks(element)
def _set_autostart(self):
"""Set domain autostart parameter.
:return: True if no error encountered. False otherwise
:rtype: bool
"""
result = 'unchanged'
try:
autostart = self.vir_domain.autostart()
if self.params['autostart'] != autostart:
self.vir_domain.setAutostart(self.params['autostart'])
result = 'changed'
except Exception:
result = 'error'
return result
def _set_state(self):
"""Set domain state (Running or destroyed).
:return: True if no error encountered. False otherwise
:rtype: bool
"""
result = 'unchanged'
active = self.vir_domain.isActive()
xml = self.vir_domain.XMLDesc(flags=libvirt.VIR_DOMAIN_XML_SECURE)
try:
if self.params['active'] != active and self.params['active']:
with libvirt.open(self.vir_connection) as connect:
connect.createXML(xml)
result = 'changed'
elif self.params['active'] != active and not self.params['active']:
self.vir_domain.destroy()
result = 'changed'
except Exception:
result = 'error'
return result
def _set_vcpus_map(self):
"""Set vcpus map for domain
:return: True if no error encountered. False otherwise
:rtype: bool
"""
result = 'unchanged'
flags = libvirt.VIR_DOMAIN_AFFECT_LIVE
state = libvirt.VIR_VCPU_RUNNING
if self.vir_domain.isActive():
try:
vpcus = self.vir_domain.vcpusFlags()
if vpcus != self.params['vcpus']:
self.vir_domain.setGuestVcpus(self.params['vcpus'], state, flags)
result = 'changed'
except Exception:
result = 'error'
return result
def _set_vcpus(self):
"""Set number of vcpus for domain
:rtype: bool
"""
result = 'unchanged'
flags = libvirt.VIR_DOMAIN_AFFECT_LIVE
if self.vir_domain.isActive():
try:
vpcus = self.vir_domain.vcpusFlags()
if vpcus != self.params['vcpus']:
self.vir_domain.setVcpusFlags(self.params['vcpus'], flags)
result = 'changed'
except Exception:
result = 'error'
return result
def _set_memory(self):
"""Set available memory amount for domain
:rtype: bool
"""
result = 'unchanged'
flags = libvirt.VIR_DOMAIN_AFFECT_LIVE
if self.vir_domain.isActive():
try:
stats = self.vir_domain.memoryStats()
if stats['actual'] != self.params['memory']:
self.vir_domain.setMemoryFlags(self.params['memory'], flags)
result = 'changed'
except Exception:
result = 'error'
return result
def get_virt_domain(self):
"""Get libvirt.virDomain object from UUID and keep a reference to it in self.vir_domain. Also get XML from domain XML file.
:return: True if operation succeeded, False otherwise, meaning that domain does not exist on system.
:rtype: bool
"""
xml_domain_filename = '{}.xml'.format(self.params['name'])
xml_domain_filename = os.path.join(self.params['libvirt_qemu_conf_path'],
xml_domain_filename)
try:
with libvirt.open(self.vir_connection) as connect:
self.vir_domain = connect.lookupByUUIDString(self.params['uuid'])
with open(xml_domain_filename) as actual_xml_file:
self.xml_actual_domain_root = etree.parse(actual_xml_file).getroot()
return True
except Exception:
return False
def copy_disk(self):
"""Get libvirt.virDomain object from UUID and keep a reference to it in self.vir_domain. Also get XML from domain XML file.
:return: A tuple containing a boolean (True if operation was successful) and a string representing potential error message
:rtype: tuple
"""
out = False
err = ''
src = os.path.join(self.params['libvirt_img_path'],
self.params['src_img'])
dst = os.path.join(self.params['libvirt_img_path'],
'{}.img'.format(self.params['name']))
if self.vir_domain is None or \
not os.path.isfile(dst) or \
(self.vir_domain is not None and not self.vir_domain.isActive()):
if self.params['force_copy']:
try:
shutil.copyfile(src, dst)
out = True
except Exception:
return (out, err)
return (out, err)
def create_xml_file(self):
"""Create temporary XML domain file
:return: A tuple containing out (wich is a tuple containing path to tmp files) and a string representing potential error message
:rtype: tuple
"""
err = ''
xml_base_filename = os.path.join(self.params['libvirt_tmp_path'],
self.xml_base_filename)
self.xml_tmp_domain_root = etree.parse(xml_base_filename).getroot()
try:
elements = {'uuid': self._set_xml_uuid,
'name': self._set_xml_name,
'description': self._set_xml_description,
'memory': self._set_xml_memory,
'currentMemory': self._set_xml_current_memory,
'vcpu': self._set_xml_vcpu,
'os': self._set_xml_os,
'devices': self._set_xml_devices}
for element in self.xml_tmp_domain_root.getchildren():
if element.tag in elements:
elements[element.tag](element)
except Exception as e:
err = '[CREATE] [{}] {}'.format(e.__class__.__name__, str(e))
return err
def compare_xml_file(self):
"""Compare generated temporary XML domain file with actual XML domain file.
:return: A tuple with an integer representing the number of different lines and a string representing potential error message
:rtype: tuple
"""
out = 1
err = ''
# Make module idempotent
try:
xml_tmp_string = etree.tostring(self.xml_tmp_domain_root,
encoding="utf-8",
xml_declaration=False,
pretty_print=True)
xml_actual_string = etree.tostring(self.xml_actual_domain_root,
encoding="utf-8",
xml_declaration=False,
pretty_print=True)
diff = xd_main.diff_texts(xml_tmp_string.decode("utf-8"),
xml_actual_string.decode("utf-8"))
out = len(diff)
except Exception as e:
err = '[COMPARE] {}'.format(str(e))
return (out, err)
def define_domain(self):
"""Define the generated domain from its XML file
:return: A string representing potential error message (Empty string if no error)
:rtype: str
"""
err = ''
try:
xml_string = etree.tostring(self.xml_tmp_domain_root,
encoding="utf-8",
xml_declaration=False,
pretty_print=True)
with libvirt.open(self.vir_connection) as connect:
connect.defineXML(xml_string.decode("utf-8"))
except Exception as e:
err = '[DEFINE] {}'.format(str(e))
return err
def apply_live_settings(self):
"""Apply settings to domain
:return: A dict representing status of each operations.
Status can be on of `changed`, `unchanged`, `error`
:rtype: dict
"""
results = {}
if self.get_virt_domain():
settings = {'autostart': self._set_autostart,
'state': self._set_state,
'vcpus_map': self._set_vcpus_map,
'vcpus': self._set_vcpus,
'memory': self._set_memory, }
for setting in settings:
results[setting] = settings[setting]()
return results
# ------------------------------------------------------------------------------
# Main
# ------------------------------------------------------------------------------
def main():
module = AnsibleModule(supports_check_mode=True,
argument_spec=dict(uuid=dict(required=True),
name=dict(required=True),
description=dict(required=True),
src_img=dict(required=True),
disks=dict(required=True, type='list'),
force_copy=dict(required=True, type='bool'),
machine_type=dict(required=True),
vcpus=dict(required=True, type='int'),
cpu_set=dict(required=True),
max_memory=dict(required=True, type='int'),
memory=dict(required=True, type='int'),
mac_addr=dict(required=True, type='str'),
ip_addr=dict(required=True, type='str'),
spice_password=dict(required=True, no_log=True),
spice_interface=dict(required=True),
spice_port=dict(required=True, type='int'),
active=dict(default=True, type='bool'),
autostart=dict(Default=True, type='bool'),
present=dict(Default=True, type='bool'),
libvirt_img_path=dict(required=True),
libvirt_tmp_path=dict(required=True),
libvirt_qemu_conf_path=dict(required=True)))
virt_vm = VM(module.params)
result = {'name': virt_vm.params['name'],
'compare': 0,
'already_exists': False}
# Disk creation
out, err = virt_vm.create_disks()
if len(err) != 0:
module.fail_json(msg=err)
if len(out) > 0:
result['changed'] = True
# XML domain file generation
result['create_disks'] = True
err = virt_vm.create_xml_file()
if len(err) != 0:
module.fail_json(msg=err)
if virt_vm.get_virt_domain():
result['already_exists'] = True
# First thing first, shall we copy a disk ?
out, err = virt_vm.copy_disk()
if len(err) > 0:
module.fail_json(msg=err)
result['changed'] = out
result['disk_copy'] = out
# Make module idempotent by comparison with existing file
out, err = virt_vm.compare_xml_file()
if len(err) > 0:
module.fail_json(msg=err)
if out > 0:
# Differences spotted, this a new file
# Let's validate that new domain XML file
result['compare'] = out
err = virt_vm.define_domain()
if len(err) > 0:
# Some error has been encountered
module.fail_json(msg=err)
result['defined'] = True
result['changed'] = True
else:
# File does not exists yet
# First thing first, shall we copy a disk ?
out, err = virt_vm.copy_disk()
if len(err) > 0:
module.fail_json(msg=err)
result['changed'] = out
result['disk_copy'] = out
# Try to install XML file
err = virt_vm.define_domain()
if len(err) > 0:
# Some error has been encountered
module.fail_json(msg=err)
result['defined'] = True
result['changed'] = True
# Apply settings to domain
live_settings = virt_vm.apply_live_settings()
result['live_settings'] = live_settings
# Eventually, we have succeed !
module.exit_json(**result)
if __name__ == '__main__':
main()