Implement disk modification on the CLI

Adds functions for listing, adding, and removing disks from the CLI,
without editing the XML directly.

References #101
This commit is contained in:
Joshua Boniface 2020-11-08 00:48:50 -05:00
parent a770b65f6b
commit 569dcd84a4
2 changed files with 366 additions and 9 deletions

View File

@ -633,22 +633,23 @@ def format_vm_networks(config, name, networks):
output_list = []
name_length = 5
vni_length = 8
macaddr_length = 12
model_length = 6
_name_length = len(name) + 1
if _name_length > name_length:
name_length = _name_length
for network in networks:
vni_length = 8
_vni_length = len(network[0]) + 1
if _vni_length > vni_length:
vni_length = _vni_length
macaddr_length = 12
_macaddr_length = len(network[1]) + 1
if _macaddr_length > macaddr_length:
macaddr_length = _macaddr_length
model_length = 6
_model_length = len(network[2]) + 1
if _model_length > model_length:
model_length = _model_length
@ -695,6 +696,267 @@ def format_vm_networks(config, name, networks):
return '\n'.join(output_list)
def vm_volumes_add(config, vm, volume, disk_id, bus, disk_type, restart):
"""
Add a new volume to the VM
Calls vm_info to get the VM XML.
Calls vm_modify to set the VM XML.
"""
from lxml.objectify import fromstring
from lxml.etree import tostring
from copy import deepcopy
import cli_lib.ceph as pvc_ceph
if disk_type == 'rbd':
# Verify that the provided volume is valid
vpool = volume.split('/')[0]
vname = volume.split('/')[1]
retcode, retdata = pvc_ceph.ceph_volume_info(config, vpool, vname)
if not retcode:
return False, "Volume {} is not present in the cluster.".format(volume)
status, domain_information = vm_info(config, vm)
if not status:
return status, domain_information
xml = domain_information.get('xml', None)
if xml is None:
return False, "VM does not have a valid XML doccument."
try:
parsed_xml = fromstring(xml)
except Exception:
return False, 'ERROR: Failed to parse XML data.'
last_disk = None
id_list = list()
for disk in parsed_xml.devices.find('disk'):
id_list.append(disk.target.attrib.get('dev'))
if disk.source.attrib.get('protocol') == disk_type:
if disk_type == 'rbd':
last_disk = disk.source.attrib.get('name')
elif disk_type == 'file':
last_disk = disk.source.attrib.get('file')
if last_disk == volume:
return False, 'Volume {} is already configured for VM {}.'.format(volume, vm)
last_disk_details = deepcopy(disk)
if disk_id is not None:
if disk_id in id_list:
return False, 'Manually specified disk ID {} is already in use for VM {}.'.format(disk_id, vm)
else:
# Find the next free disk ID
first_dev_prefix = id_list[0][0:-1]
for char in range(ord('a'), ord('z')):
char = chr(char)
next_id = "{}{}".format(first_dev_prefix, char)
if next_id not in id_list:
break
else:
next_id = None
if next_id is None:
return False, 'Failed to find a valid disk_id and none specified; too many disks for VM {}?'.format(vm)
disk_id = next_id
if last_disk is None:
if disk_type == 'rbd':
# RBD volumes need an example to be based on
return False, "There are no existing RBD volumes attached to this VM. Autoconfiguration failed; use the 'vm modify' command to manually configure this volume with the required details for authentication, hosts, etc.."
elif disk_type == 'file':
# File types can be added ad-hoc
disk_template = '<disk type="file" device="disk"><driver name="qemu" type="raw"/><source file="{source}"/><target dev="{dev}" bus="{bus}"/></disk>'.format(
source=volume,
dev=disk_id,
bus=bus
)
last_disk_details = fromstring(disk_template)
new_disk_details = last_disk_details
new_disk_details.target.set('dev', disk_id)
new_disk_details.target.set('bus', bus)
if disk_type == 'rbd':
new_disk_details.source.set('name', volume)
elif disk_type == 'file':
new_disk_details.source.set('file', volume)
for disk in parsed_xml.devices.find('disk'):
last_disk = disk
last_disk.addnext(new_disk_details)
try:
new_xml = tostring(parsed_xml, pretty_print=True)
except Exception:
return False, 'ERROR: Failed to dump XML data.'
return vm_modify(config, vm, new_xml, restart)
def vm_volumes_remove(config, vm, volume, restart):
"""
Remove a volume to the VM
Calls vm_info to get the VM XML.
Calls vm_modify to set the VM XML.
"""
from lxml.objectify import fromstring
from lxml.etree import tostring
status, domain_information = vm_info(config, vm)
if not status:
return status, domain_information
xml = domain_information.get('xml', None)
if xml is None:
return False, "VM does not have a valid XML doccument."
try:
parsed_xml = fromstring(xml)
except Exception:
return False, 'ERROR: Failed to parse XML data.'
for disk in parsed_xml.devices.find('disk'):
disk_name = disk.source.attrib.get('name')
if not disk_name:
disk_name = disk.source.attrib.get('file')
if volume == disk_name:
disk.getparent().remove(disk)
try:
new_xml = tostring(parsed_xml, pretty_print=True)
except Exception:
return False, 'ERROR: Failed to dump XML data.'
return vm_modify(config, vm, new_xml, restart)
def vm_volumes_get(config, vm):
"""
Get the volumes of the VM
Calls vm_info to get VM XML.
Returns a list of tuples of (volume, disk_id, type, bus)
"""
from lxml.objectify import fromstring
status, domain_information = vm_info(config, vm)
if not status:
return status, domain_information
xml = domain_information.get('xml', None)
if xml is None:
return False, "VM does not have a valid XML doccument."
try:
parsed_xml = fromstring(xml)
except Exception:
return False, 'ERROR: Failed to parse XML data.'
volume_data = list()
for disk in parsed_xml.devices.find('disk'):
protocol = disk.attrib.get('type')
disk_id = disk.target.attrib.get('dev')
bus = disk.target.attrib.get('bus')
if protocol == 'network':
protocol = disk.source.attrib.get('protocol')
source = disk.source.attrib.get('name')
elif protocol == 'file':
protocol = 'file'
source = disk.source.attrib.get('file')
else:
protocol = 'unknown'
source = 'unknown'
volume_data.append((source, disk_id, protocol, bus))
return True, volume_data
def format_vm_volumes(config, name, volumes):
"""
Format the output of a volume value in a nice table
"""
output_list = []
name_length = 5
volume_length = 7
disk_id_length = 4
protocol_length = 5
bus_length = 4
_name_length = len(name) + 1
if _name_length > name_length:
name_length = _name_length
for volume in volumes:
_volume_length = len(volume[0]) + 1
if _volume_length > volume_length:
volume_length = _volume_length
_disk_id_length = len(volume[1]) + 1
if _disk_id_length > disk_id_length:
disk_id_length = _disk_id_length
_protocol_length = len(volume[2]) + 1
if _protocol_length > protocol_length:
protocol_length = _protocol_length
_bus_length = len(volume[3]) + 1
if _bus_length > bus_length:
bus_length = _bus_length
output_list.append(
'{bold}{name: <{name_length}} \
{volume: <{volume_length}} \
{disk_id: <{disk_id_length}} \
{protocol: <{protocol_length}} \
{bus: <{bus_length}}{end_bold}'.format(
name_length=name_length,
volume_length=volume_length,
disk_id_length=disk_id_length,
protocol_length=protocol_length,
bus_length=bus_length,
bold=ansiprint.bold(),
end_bold=ansiprint.end(),
name='Name',
volume='Volume',
disk_id='Dev',
protocol='Type',
bus='Bus'
)
)
count = 0
for volume in volumes:
if count > 0:
name = ''
count += 1
output_list.append(
'{bold}{name: <{name_length}} \
{volume: <{volume_length}} \
{disk_id: <{disk_id_length}} \
{protocol: <{protocol_length}} \
{bus: <{bus_length}}{end_bold}'.format(
name_length=name_length,
volume_length=volume_length,
disk_id_length=disk_id_length,
protocol_length=protocol_length,
bus_length=bus_length,
bold='',
end_bold='',
name=name,
volume=volume[0],
disk_id=volume[1],
protocol=volume[2],
bus=volume[3]
)
)
return '\n'.join(output_list)
def view_console_log(config, vm, lines=100):
"""
Return console log lines from the API (and display them in a pager in the main CLI)
@ -925,7 +1187,7 @@ def format_info(config, domain_information, long_output):
ainformation.append('')
ainformation.append('{}Controllers:{} {}ID Type Model{}'.format(ansiprint.purple(), ansiprint.end(), ansiprint.bold(), ansiprint.end()))
for controller in domain_information['controllers']:
ainformation.append(' {0: <3} {1: <14} {2: <8}'.format(domain_information['controllers'].index(controller), controller['type'], controller['model']))
ainformation.append(' {0: <3} {1: <14} {2: <8}'.format(domain_information['controllers'].index(controller), controller['type'], str(controller['model'])))
# Join it all together
ainformation.append('')

View File

@ -1167,6 +1167,8 @@ def vm_memory_set(domain, memory, restart):
def vm_network():
"""
Manage the attached networks of a virtual machine in the PVC cluster.
Network details cannot be modified here. To modify a network, first remove it, then readd it with the correct settings. Unless the '-r'/'--reboot' flag is provided, this will not affect the running VM until it is restarted.
"""
pass
@ -1261,11 +1263,105 @@ def vm_network_remove(domain, vni, restart):
@click.group(name='volume', short_help='Manage attached volumes of a virtual machine.', context_settings=CONTEXT_SETTINGS)
def vm_volume():
"""
Manage the attached volumes of a virtual machine in the PVC cluster."
Manage the attached volumes of a virtual machine in the PVC cluster.
Volume details cannot be modified here. To modify a volume, first remove it, then readd it with the correct settings. Unless the '-r'/'--reboot' flag is provided, this will not affect the running VM until it is restarted.
"""
pass
###############################################################################
# pvc vm volume get
###############################################################################
@click.command(name='get', short_help='Get the volumes of a virtual machine.')
@click.argument(
'domain'
)
@click.option(
'-r', '--raw', 'raw', is_flag=True, default=False,
help='Display the raw values only without formatting.'
)
@cluster_req
def vm_volume_get(domain, raw):
"""
Get the volumes of the virtual machine DOMAIN.
"""
retcode, retdata = pvc_vm.vm_volumes_get(config, domain)
if not raw:
retmsg = pvc_vm.format_vm_volumes(config, domain, retdata)
else:
volume_paths = list()
for volume in retdata:
volume_paths.append("{}:{}".format(volume[2], volume[0]))
retmsg = ','.join(volume_paths)
cleanup(retcode, retmsg)
###############################################################################
# pvc vm volume add
###############################################################################
@click.command(name='add', short_help='Add volume to a virtual machine.')
@click.argument(
'domain'
)
@click.argument(
'volume'
)
@click.option(
'-d', '--disk-id', 'disk_id', default=None,
help='The disk ID in sdX/vdX/hdX format; if not specified, the next available will be used.'
)
@click.option(
'-b', '--bus', 'bus', default='scsi', show_default=True,
type=click.Choice(['scsi', 'ide', 'usb', 'virtio']),
help='The bus to attach the disk to; must be present in the VM.'
)
@click.option(
'-t', '--type', 'disk_type', default='rbd', show_default=True,
type=click.Choice(['rbd', 'file']),
help='The type of volume to add.'
)
@click.option(
'-r', '--restart', 'restart', is_flag=True, default=False,
help='Immediately restart VM to apply new config.'
)
@cluster_req
def vm_volume_add(domain, volume, disk_id, bus, disk_type, restart):
"""
Add the volume VOLUME to the virtual machine DOMAIN.
VOLUME may be either an absolute file path (for type 'file') or an RBD volume in the form "pool/volume" (for type 'rbd'). RBD volumes are verified against the cluster before adding and must exist.
"""
retcode, retmsg = pvc_vm.vm_volumes_add(config, domain, volume, disk_id, bus, disk_type, restart)
cleanup(retcode, retmsg)
###############################################################################
# pvc vm volume remove
###############################################################################
@click.command(name='remove', short_help='Remove volume from a virtual machine.')
@click.argument(
'domain'
)
@click.argument(
'vni'
)
@click.option(
'-r', '--restart', 'restart', is_flag=True, default=False,
help='Immediately restart VM to apply new config.'
)
@cluster_req
def vm_volume_remove(domain, vni, restart):
"""
Remove the volume VNI to the virtual machine DOMAIN.
"""
retcode, retmsg = pvc_vm.vm_volumes_remove(config, domain, vni, restart)
cleanup(retcode, retmsg)
###############################################################################
# pvc vm log
###############################################################################
@ -4083,10 +4179,9 @@ vm_network.add_command(vm_network_get)
vm_network.add_command(vm_network_add)
vm_network.add_command(vm_network_remove)
# vm_volume.add_command(vm_volume_list)
# vm_volume.add_command(vm_volume_add)
# vm_volume.add_command(vm_volume_modify)
# vm_volume_add_command(vm_volume_remove)
vm_volume.add_command(vm_volume_get)
vm_volume.add_command(vm_volume_add)
vm_volume.add_command(vm_volume_remove)
cli_vm.add_command(vm_define)
cli_vm.add_command(vm_meta)