From 742f87b98d8152319d0fc5b329c263e0c3564152 Mon Sep 17 00:00:00 2001 From: Rishabh Dave Date: Sat, 12 Oct 2019 16:44:39 +0530 Subject: [PATCH] ceph-volume: rearrange api/lvm.py Move code for PVs, VGs and LVs, individually, next to each other. Fixes: https://tracker.ceph.com/issues/42368 Signed-off-by: Rishabh Dave (cherry picked from commit f4b764b55fab611a075ad8e597f1e978e2752b7d) --- src/ceph-volume/ceph_volume/api/lvm.py | 1363 ++++++++++++------------ 1 file changed, 692 insertions(+), 671 deletions(-) diff --git a/src/ceph-volume/ceph_volume/api/lvm.py b/src/ceph-volume/ceph_volume/api/lvm.py index 47ccc1068d4bf..86c81618cf891 100644 --- a/src/ceph-volume/ceph_volume/api/lvm.py +++ b/src/ceph-volume/ceph_volume/api/lvm.py @@ -267,63 +267,11 @@ def dmsetup_splitname(dev): return _splitname_parser(out) -def is_lv(dev, lvs=None): - """ - Boolean to detect if a device is an LV or not. - """ - splitname = dmsetup_splitname(dev) - # Allowing to optionally pass `lvs` can help reduce repetitive checks for - # multiple devices at once. - if lvs is None or len(lvs) == 0: - lvs = Volumes() - - if splitname.get('LV_NAME'): - lvs.filter(lv_name=splitname['LV_NAME'], vg_name=splitname['VG_NAME']) - return len(lvs) > 0 - return False - - -def get_api_vgs(): - """ - Return the list of group volumes available in the system using flags to - include common metadata associated with them - - Command and sample delimited output should look like:: - - $ vgs --noheadings --units=g --readonly --separator=';' \ - -o vg_name,pv_count,lv_count,snap_count,vg_attr,vg_size,vg_free - ubuntubox-vg;1;2;0;wz--n-;299.52g;12.00m - osd_vg;3;1;0;wz--n-;29.21g;9.21g - - To normalize sizing, the units are forced in 'g' which is equivalent to - gigabytes, which uses multiples of 1024 (as opposed to 1000) - """ - fields = 'vg_name,pv_count,lv_count,snap_count,vg_attr,vg_size,vg_free,vg_free_count' - stdout, stderr, returncode = process.call( - ['vgs', '--noheadings', '--readonly', '--units=g', '--separator=";"', '-o', fields], - verbose_on_failure=False - ) - return _output_parser(stdout, fields) - - -def get_api_lvs(): - """ - Return the list of logical volumes available in the system using flags to include common - metadata associated with them - - Command and delimited output should look like:: - - $ lvs --noheadings --readonly --separator=';' -a -o lv_tags,lv_path,lv_name,vg_name - ;/dev/ubuntubox-vg/root;root;ubuntubox-vg - ;/dev/ubuntubox-vg/swap_1;swap_1;ubuntubox-vg - - """ - fields = 'lv_tags,lv_path,lv_name,vg_name,lv_uuid,lv_size' - stdout, stderr, returncode = process.call( - ['lvs', '--noheadings', '--readonly', '--separator=";"', '-a', '-o', fields], - verbose_on_failure=False - ) - return _output_parser(stdout, fields) +#################################### +# +# Code for LVM Physical Volumes +# +################################ def get_api_pvs(): @@ -350,52 +298,161 @@ def get_api_pvs(): return _output_parser(stdout, fields) -def get_lv_from_argument(argument): +class PVolume(object): """ - Helper proxy function that consumes a possible logical volume passed in from the CLI - in the form of `vg/lv`, but with some validation so that an argument that is a full - path to a device can be ignored + Represents a Physical Volume from LVM, with some top-level attributes like + ``pv_name`` and parsed tags as a dictionary of key/value pairs. """ - if argument.startswith('/'): - lv = get_lv(lv_path=argument) - return lv - try: - vg_name, lv_name = argument.split('/') - except (ValueError, AttributeError): - return None - return get_lv(lv_name=lv_name, vg_name=vg_name) + def __init__(self, **kw): + for k, v in kw.items(): + setattr(self, k, v) + self.pv_api = kw + self.name = kw['pv_name'] + self.tags = parse_tags(kw['pv_tags']) -def get_lv(lv_name=None, vg_name=None, lv_path=None, lv_uuid=None, lv_tags=None, lvs=None): - """ - Return a matching lv for the current system, requiring ``lv_name``, - ``vg_name``, ``lv_path`` or ``tags``. Raises an error if more than one lv - is found. + def __str__(self): + return '<%s>' % self.pv_api['pv_name'] - It is useful to use ``tags`` when trying to find a specific logical volume, - but it can also lead to multiple lvs being found, since a lot of metadata - is shared between lvs of a distinct OSD. - """ - if not any([lv_name, vg_name, lv_path, lv_uuid, lv_tags]): - return None - if lvs is None: - lvs = Volumes() - return lvs.get( - lv_name=lv_name, vg_name=vg_name, lv_path=lv_path, lv_uuid=lv_uuid, - lv_tags=lv_tags - ) + def __repr__(self): + return self.__str__() + def set_tags(self, tags): + """ + :param tags: A dictionary of tag names and values, like:: -def get_pv(pv_name=None, pv_uuid=None, pv_tags=None): + { + "ceph.osd_fsid": "aaa-fff-bbbb", + "ceph.osd_id": "0" + } + + At the end of all modifications, the tags are refreshed to reflect + LVM's most current view. + """ + for k, v in tags.items(): + self.set_tag(k, v) + # after setting all the tags, refresh them for the current object, use the + # pv_* identifiers to filter because those shouldn't change + pv_object = get_pv(pv_name=self.pv_name, pv_uuid=self.pv_uuid) + self.tags = pv_object.tags + + def set_tag(self, key, value): + """ + Set the key/value pair as an LVM tag. Does not "refresh" the values of + the current object for its tags. Meant to be a "fire and forget" type + of modification. + + **warning**: Altering tags on a PV has to be done ensuring that the + device is actually the one intended. ``pv_name`` is *not* a persistent + value, only ``pv_uuid`` is. Using ``pv_uuid`` is the best way to make + sure the device getting changed is the one needed. + """ + # remove it first if it exists + if self.tags.get(key): + current_value = self.tags[key] + tag = "%s=%s" % (key, current_value) + process.call(['pvchange', '--deltag', tag, self.pv_name]) + + process.call( + [ + 'pvchange', + '--addtag', '%s=%s' % (key, value), self.pv_name + ] + ) + + +class PVolumes(list): """ - Return a matching pv (physical volume) for the current system, requiring - ``pv_name``, ``pv_uuid``, or ``pv_tags``. Raises an error if more than one - pv is found. + A list of all known (physical) volumes for the current system, with the ability + to filter them via keyword arguments. """ - if not any([pv_name, pv_uuid, pv_tags]): - return None - pvs = PVolumes() - return pvs.get(pv_name=pv_name, pv_uuid=pv_uuid, pv_tags=pv_tags) + + def __init__(self, populate=True): + if populate: + self._populate() + + def _populate(self): + # get all the pvs in the current system + for pv_item in get_api_pvs(): + self.append(PVolume(**pv_item)) + + def _purge(self): + """ + Deplete all the items in the list, used internally only so that we can + dynamically allocate the items when filtering without the concern of + messing up the contents + """ + self[:] = [] + + def _filter(self, pv_name=None, pv_uuid=None, pv_tags=None): + """ + The actual method that filters using a new list. Useful so that other + methods that do not want to alter the contents of the list (e.g. + ``self.find``) can operate safely. + """ + filtered = [i for i in self] + if pv_name: + filtered = [i for i in filtered if i.pv_name == pv_name] + + if pv_uuid: + filtered = [i for i in filtered if i.pv_uuid == pv_uuid] + + # at this point, `filtered` has either all the physical volumes in self + # or is an actual filtered list if any filters were applied + if pv_tags: + tag_filtered = [] + for pvolume in filtered: + matches = all(pvolume.tags.get(k) == str(v) for k, v in pv_tags.items()) + if matches: + tag_filtered.append(pvolume) + # return the tag_filtered pvolumes here, the `filtered` list is no + # longer usable + return tag_filtered + + return filtered + + def filter(self, pv_name=None, pv_uuid=None, pv_tags=None): + """ + Filter out volumes on top level attributes like ``pv_name`` or by + ``pv_tags`` where a dict is required. For example, to find a physical + volume that has an OSD ID of 0, the filter would look like:: + + pv_tags={'ceph.osd_id': '0'} + + """ + if not any([pv_name, pv_uuid, pv_tags]): + raise TypeError('.filter() requires pv_name, pv_uuid, or pv_tags' + '(none given)') + + filtered_pvs = PVolumes(populate=False) + filtered_pvs.extend(self._filter(pv_name, pv_uuid, pv_tags)) + return filtered_pvs + + def get(self, pv_name=None, pv_uuid=None, pv_tags=None): + """ + This is a bit expensive, since it will try to filter out all the + matching items in the list, filter them out applying anything that was + added and return the matching item. + + This method does *not* alter the list, and it will raise an error if + multiple pvs are matched + + It is useful to use ``tags`` when trying to find a specific logical volume, + but it can also lead to multiple pvs being found, since a lot of metadata + is shared between pvs of a distinct OSD. + """ + if not any([pv_name, pv_uuid, pv_tags]): + return None + pvs = self._filter( + pv_name=pv_name, + pv_uuid=pv_uuid, + pv_tags=pv_tags + ) + if not pvs: + return None + if len(pvs) > 1 and pv_tags: + raise MultiplePVsError(pv_name) + return pvs[0] def create_pv(device): @@ -412,299 +469,185 @@ def create_pv(device): ]) -def create_vg(devices, name=None, name_prefix=None): +def remove_pv(pv_name): """ - Create a Volume Group. Command looks like:: + Removes a physical volume using a double `-f` to prevent prompts and fully + remove anything related to LVM. This is tremendously destructive, but so is all other actions + when zapping a device. - vgcreate --force --yes group_name device + In the case where multiple PVs are found, it will ignore that fact and + continue with the removal, specifically in the case of messages like:: - Once created the volume group is returned as a ``VolumeGroup`` object + WARNING: PV $UUID /dev/DEV-1 was already found on /dev/DEV-2 - :param devices: A list of devices to create a VG. Optionally, a single - device (as a string) can be used. - :param name: Optionally set the name of the VG, defaults to 'ceph-{uuid}' - :param name_prefix: Optionally prefix the name of the VG, which will get combined - with a UUID string + These situations can be avoided with custom filtering rules, which this API + cannot handle while accommodating custom user filters. """ - if isinstance(devices, set): - devices = list(devices) - if not isinstance(devices, list): - devices = [devices] - if name_prefix: - name = "%s-%s" % (name_prefix, str(uuid.uuid4())) - elif name is None: - name = "ceph-%s" % str(uuid.uuid4()) - process.run([ - 'vgcreate', - '-s', - '1G', - '--force', - '--yes', - name] + devices + fail_msg = "Unable to remove vg %s" % pv_name + process.run( + [ + 'pvremove', + '-v', # verbose + '-f', # force it + '-f', # force it + pv_name + ], + fail_msg=fail_msg, ) - vg = get_vg(vg_name=name) - return vg - -def extend_vg(vg, devices): +def get_pv(pv_name=None, pv_uuid=None, pv_tags=None): """ - Extend a Volume Group. Command looks like:: + Return a matching pv (physical volume) for the current system, requiring + ``pv_name``, ``pv_uuid``, or ``pv_tags``. Raises an error if more than one + pv is found. + """ + if not any([pv_name, pv_uuid, pv_tags]): + return None + pvs = PVolumes() + return pvs.get(pv_name=pv_name, pv_uuid=pv_uuid, pv_tags=pv_tags) - vgextend --force --yes group_name [device, ...] - Once created the volume group is extended and returned as a ``VolumeGroup`` object +################################ +# +# Code for LVM Volume Groups +# +############################# - :param vg: A VolumeGroup object - :param devices: A list of devices to extend the VG. Optionally, a single - device (as a string) can be used. + +def get_api_vgs(): """ - if not isinstance(devices, list): - devices = [devices] - process.run([ - 'vgextend', - '--force', - '--yes', - vg.name] + devices - ) + Return the list of group volumes available in the system using flags to + include common metadata associated with them - vg = get_vg(vg_name=vg.name) - return vg + Command and sample delimited output should look like:: + $ vgs --noheadings --units=g --readonly --separator=';' \ + -o vg_name,pv_count,lv_count,snap_count,vg_attr,vg_size,vg_free + ubuntubox-vg;1;2;0;wz--n-;299.52g;12.00m + osd_vg;3;1;0;wz--n-;29.21g;9.21g -def reduce_vg(vg, devices): + To normalize sizing, the units are forced in 'g' which is equivalent to + gigabytes, which uses multiples of 1024 (as opposed to 1000) """ - Reduce a Volume Group. Command looks like:: + fields = 'vg_name,pv_count,lv_count,snap_count,vg_attr,vg_size,vg_free,vg_free_count' + stdout, stderr, returncode = process.call( + ['vgs', '--noheadings', '--readonly', '--units=g', '--separator=";"', '-o', fields], + verbose_on_failure=False + ) + return _output_parser(stdout, fields) - vgreduce --force --yes group_name [device, ...] - :param vg: A VolumeGroup object - :param devices: A list of devices to remove from the VG. Optionally, a - single device (as a string) can be used. +class VolumeGroup(object): + """ + Represents an LVM group, with some top-level attributes like ``vg_name`` """ - if not isinstance(devices, list): - devices = [devices] - process.run([ - 'vgreduce', - '--force', - '--yes', - vg.name] + devices - ) - vg = get_vg(vg_name=vg.name) - return vg + def __init__(self, **kw): + for k, v in kw.items(): + setattr(self, k, v) + self.name = kw['vg_name'] + self.tags = parse_tags(kw.get('vg_tags', '')) + def __str__(self): + return '<%s>' % self.name -def remove_vg(vg_name): - """ - Removes a volume group. - """ - if not vg_name: - logger.warning('Skipping removal of invalid VG name: "%s"', vg_name) - return - fail_msg = "Unable to remove vg %s" % vg_name - process.run( - [ - 'vgremove', - '-v', # verbose - '-f', # force it - vg_name - ], - fail_msg=fail_msg, - ) + def __repr__(self): + return self.__str__() + def _parse_size(self, size): + error_msg = "Unable to convert vg size to integer: '%s'" % str(size) + try: + integer, _ = size.split('g') + except ValueError: + logger.exception(error_msg) + raise RuntimeError(error_msg) -def remove_pv(pv_name): - """ - Removes a physical volume using a double `-f` to prevent prompts and fully - remove anything related to LVM. This is tremendously destructive, but so is all other actions - when zapping a device. + return util.str_to_int(integer) - In the case where multiple PVs are found, it will ignore that fact and - continue with the removal, specifically in the case of messages like:: + @property + def free(self): + """ + Parse the available size in gigabytes from the ``vg_free`` attribute, that + will be a string with a character ('g') to indicate gigabytes in size. + Returns a rounded down integer to ease internal operations:: - WARNING: PV $UUID /dev/DEV-1 was already found on /dev/DEV-2 + >>> data_vg.vg_free + '0.01g' + >>> data_vg.size + 0 + """ + return self._parse_size(self.vg_free) - These situations can be avoided with custom filtering rules, which this API - cannot handle while accommodating custom user filters. - """ - fail_msg = "Unable to remove vg %s" % pv_name - process.run( - [ - 'pvremove', - '-v', # verbose - '-f', # force it - '-f', # force it - pv_name - ], - fail_msg=fail_msg, - ) + @property + def size(self): + """ + Parse the size in gigabytes from the ``vg_size`` attribute, that + will be a string with a character ('g') to indicate gigabytes in size. + Returns a rounded down integer to ease internal operations:: + >>> data_vg.vg_size + '1024.9g' + >>> data_vg.size + 1024 + """ + return self._parse_size(self.vg_size) -def remove_lv(lv): - """ - Removes a logical volume given it's absolute path. + def sizing(self, parts=None, size=None): + """ + Calculate proper sizing to fully utilize the volume group in the most + efficient way possible. To prevent situations where LVM might accept + a percentage that is beyond the vg's capabilities, it will refuse with + an error when requesting a larger-than-possible parameter, in addition + to rounding down calculations. - Will return True if the lv is successfully removed or - raises a RuntimeError if the removal fails. + A dictionary with different sizing parameters is returned, to make it + easier for others to choose what they need in order to create logical + volumes:: - :param lv: A ``Volume`` object or the path for an LV - """ - if isinstance(lv, Volume): - path = lv.lv_path - else: - path = lv + >>> data_vg.free + 1024 + >>> data_vg.sizing(parts=4) + {'parts': 4, 'sizes': 256, 'percentages': 25} + >>> data_vg.sizing(size=512) + {'parts': 2, 'sizes': 512, 'percentages': 50} - stdout, stderr, returncode = process.call( - [ - 'lvremove', - '-v', # verbose - '-f', # force it - path - ], - show_command=True, - terminal_verbose=True, - ) - if returncode != 0: - raise RuntimeError("Unable to remove %s" % path) - return True + :param parts: Number of parts to create LVs from + :param size: Size in gigabytes to divide the VG into -def create_lv(name, group, extents=None, size=None, tags=None, uuid_name=False, pv=None): - """ - Create a Logical Volume in a Volume Group. Command looks like:: + :raises SizeAllocationError: When requested size cannot be allocated with + :raises ValueError: If both ``parts`` and ``size`` are given + """ + if parts is not None and size is not None: + raise ValueError( + "Cannot process sizing with both parts (%s) and size (%s)" % (parts, size) + ) - lvcreate -L 50G -n gfslv vg0 + # if size is given we need to map that to extents so that we avoid + # issues when trying to get this right with a size in gigabytes find + # the percentage first, cheating, because these values are thrown out + vg_free_count = util.str_to_int(self.vg_free_count) - ``name``, ``group``, are required. If ``size`` is provided it must follow - lvm's size notation (like 1G, or 20M). Tags are an optional dictionary and is expected to - conform to the convention of prefixing them with "ceph." like:: + if size: + extents = int(size * vg_free_count / self.free) + disk_sizing = sizing(self.free, size=size, parts=parts) + else: + if parts is not None: + # Prevent parts being 0, falling back to 1 (100% usage) + parts = parts or 1 + size = int(self.free / parts) + extents = size * vg_free_count / self.free + disk_sizing = sizing(self.free, parts=parts) - {"ceph.block_device": "/dev/ceph/osd-1"} + extent_sizing = sizing(vg_free_count, size=extents) - :param uuid_name: Optionally combine the ``name`` with UUID to ensure uniqueness - """ - if uuid_name: - name = '%s-%s' % (name, uuid.uuid4()) - if tags is None: - tags = { - "ceph.osd_id": "null", - "ceph.type": "null", - "ceph.cluster_fsid": "null", - "ceph.osd_fsid": "null", - } - - # XXX add CEPH_VOLUME_LVM_DEBUG to enable -vvvv on lv operations - type_path_tag = { - 'journal': 'ceph.journal_device', - 'data': 'ceph.data_device', - 'block': 'ceph.block_device', - 'wal': 'ceph.wal_device', - 'db': 'ceph.db_device', - 'lockbox': 'ceph.lockbox_device', # XXX might not ever need this lockbox sorcery - } - if size: - command = [ - 'lvcreate', - '--yes', - '-L', - '%s' % size, - '-n', name, group - ] - elif extents: - command = [ - 'lvcreate', - '--yes', - '-l', - '%s' % extents, - '-n', name, group - ] - # create the lv with all the space available, this is needed because the - # system call is different for LVM - else: - command = [ - 'lvcreate', - '--yes', - '-l', - '100%FREE', - '-n', name, group - ] - if pv: - command.append(pv) - process.run(command) - - lv = get_lv(lv_name=name, vg_name=group) - lv.set_tags(tags) - - # when creating a distinct type, the caller doesn't know what the path will - # be so this function will set it after creation using the mapping - path_tag = type_path_tag.get(tags.get('ceph.type')) - if path_tag: - lv.set_tags( - {path_tag: lv.lv_path} - ) - return lv - - -def create_lvs(volume_group, parts=None, size=None, name_prefix='ceph-lv'): - """ - Create multiple Logical Volumes from a Volume Group by calculating the - proper extents from ``parts`` or ``size``. A custom prefix can be used - (defaults to ``ceph-lv``), these names are always suffixed with a uuid. - - LV creation in ceph-volume will require tags, this is expected to be - pre-computed by callers who know Ceph metadata like OSD IDs and FSIDs. It - will probably not be the case when mass-creating LVs, so common/default - tags will be set to ``"null"``. - - .. note:: LVs that are not in use can be detected by querying LVM for tags that are - set to ``"null"``. - - :param volume_group: The volume group (vg) to use for LV creation - :type group: ``VolumeGroup()`` object - :param parts: Number of LVs to create *instead of* ``size``. - :type parts: int - :param size: Size (in gigabytes) of LVs to create, e.g. "as many 10gb LVs as possible" - :type size: int - :param extents: The number of LVM extents to use to create the LV. Useful if looking to have - accurate LV sizes (LVM rounds sizes otherwise) - """ - if parts is None and size is None: - # fallback to just one part (using 100% of the vg) - parts = 1 - lvs = [] - tags = { - "ceph.osd_id": "null", - "ceph.type": "null", - "ceph.cluster_fsid": "null", - "ceph.osd_fsid": "null", - } - sizing = volume_group.sizing(parts=parts, size=size) - for part in range(0, sizing['parts']): - size = sizing['sizes'] - extents = sizing['extents'] - lv_name = '%s-%s' % (name_prefix, uuid.uuid4()) - lvs.append( - create_lv(lv_name, volume_group.name, extents=extents, tags=tags) - ) - return lvs - - -def get_vg(vg_name=None, vg_tags=None): - """ - Return a matching vg for the current system, requires ``vg_name`` or - ``tags``. Raises an error if more than one vg is found. - - It is useful to use ``tags`` when trying to find a specific volume group, - but it can also lead to multiple vgs being found. - """ - if not any([vg_name, vg_tags]): - return None - vgs = VolumeGroups() - return vgs.get(vg_name=vg_name, vg_tags=vg_tags) - - -class VolumeGroups(list): + disk_sizing['extents'] = int(extents) + disk_sizing['percentages'] = extent_sizing['percentages'] + return disk_sizing + + +class VolumeGroups(list): """ A list of all known volume groups for the current system, with the ability to filter them via keyword arguments. @@ -796,314 +739,147 @@ class VolumeGroups(list): return vgs[0] -class Volumes(list): - """ - A list of all known (logical) volumes for the current system, with the ability - to filter them via keyword arguments. +def create_vg(devices, name=None, name_prefix=None): """ + Create a Volume Group. Command looks like:: - def __init__(self): - self._populate() + vgcreate --force --yes group_name device - def _populate(self): - # get all the lvs in the current system - for lv_item in get_api_lvs(): - self.append(Volume(**lv_item)) + Once created the volume group is returned as a ``VolumeGroup`` object - def _purge(self): - """ - Delete all the items in the list, used internally only so that we can - dynamically allocate the items when filtering without the concern of - messing up the contents - """ - self[:] = [] + :param devices: A list of devices to create a VG. Optionally, a single + device (as a string) can be used. + :param name: Optionally set the name of the VG, defaults to 'ceph-{uuid}' + :param name_prefix: Optionally prefix the name of the VG, which will get combined + with a UUID string + """ + if isinstance(devices, set): + devices = list(devices) + if not isinstance(devices, list): + devices = [devices] + if name_prefix: + name = "%s-%s" % (name_prefix, str(uuid.uuid4())) + elif name is None: + name = "ceph-%s" % str(uuid.uuid4()) + process.run([ + 'vgcreate', + '-s', + '1G', + '--force', + '--yes', + name] + devices + ) - def _filter(self, lv_name=None, vg_name=None, lv_path=None, lv_uuid=None, lv_tags=None): - """ - The actual method that filters using a new list. Useful so that other - methods that do not want to alter the contents of the list (e.g. - ``self.find``) can operate safely. - """ - filtered = [i for i in self] - if lv_name: - filtered = [i for i in filtered if i.lv_name == lv_name] + vg = get_vg(vg_name=name) + return vg - if vg_name: - filtered = [i for i in filtered if i.vg_name == vg_name] - if lv_uuid: - filtered = [i for i in filtered if i.lv_uuid == lv_uuid] +def extend_vg(vg, devices): + """ + Extend a Volume Group. Command looks like:: - if lv_path: - filtered = [i for i in filtered if i.lv_path == lv_path] + vgextend --force --yes group_name [device, ...] - # at this point, `filtered` has either all the volumes in self or is an - # actual filtered list if any filters were applied - if lv_tags: - tag_filtered = [] - for volume in filtered: - # all the tags we got need to match on the volume - matches = all(volume.tags.get(k) == str(v) for k, v in lv_tags.items()) - if matches: - tag_filtered.append(volume) - return tag_filtered + Once created the volume group is extended and returned as a ``VolumeGroup`` object - return filtered + :param vg: A VolumeGroup object + :param devices: A list of devices to extend the VG. Optionally, a single + device (as a string) can be used. + """ + if not isinstance(devices, list): + devices = [devices] + process.run([ + 'vgextend', + '--force', + '--yes', + vg.name] + devices + ) - def filter(self, lv_name=None, vg_name=None, lv_path=None, lv_uuid=None, lv_tags=None): - """ - Filter out volumes on top level attributes like ``lv_name`` or by - ``lv_tags`` where a dict is required. For example, to find a volume - that has an OSD ID of 0, the filter would look like:: + vg = get_vg(vg_name=vg.name) + return vg - lv_tags={'ceph.osd_id': '0'} - """ - if not any([lv_name, vg_name, lv_path, lv_uuid, lv_tags]): - raise TypeError('.filter() requires lv_name, vg_name, lv_path, lv_uuid, or tags (none given)') - # first find the filtered volumes with the values in self - filtered_volumes = self._filter( - lv_name=lv_name, - vg_name=vg_name, - lv_path=lv_path, - lv_uuid=lv_uuid, - lv_tags=lv_tags - ) - # then purge everything - self._purge() - # and add the filtered items - self.extend(filtered_volumes) +def reduce_vg(vg, devices): + """ + Reduce a Volume Group. Command looks like:: - def get(self, lv_name=None, vg_name=None, lv_path=None, lv_uuid=None, lv_tags=None): - """ - This is a bit expensive, since it will try to filter out all the - matching items in the list, filter them out applying anything that was - added and return the matching item. + vgreduce --force --yes group_name [device, ...] - This method does *not* alter the list, and it will raise an error if - multiple LVs are matched + :param vg: A VolumeGroup object + :param devices: A list of devices to remove from the VG. Optionally, a + single device (as a string) can be used. + """ + if not isinstance(devices, list): + devices = [devices] + process.run([ + 'vgreduce', + '--force', + '--yes', + vg.name] + devices + ) - It is useful to use ``tags`` when trying to find a specific logical volume, - but it can also lead to multiple lvs being found, since a lot of metadata - is shared between lvs of a distinct OSD. - """ - if not any([lv_name, vg_name, lv_path, lv_uuid, lv_tags]): - return None - lvs = self._filter( - lv_name=lv_name, - vg_name=vg_name, - lv_path=lv_path, - lv_uuid=lv_uuid, - lv_tags=lv_tags - ) - if not lvs: - return None - if len(lvs) > 1: - raise MultipleLVsError(lv_name, lv_path) - return lvs[0] + vg = get_vg(vg_name=vg.name) + return vg -class PVolumes(list): +def remove_vg(vg_name): """ - A list of all known (physical) volumes for the current system, with the ability - to filter them via keyword arguments. + Removes a volume group. """ - - def __init__(self, populate=True): - if populate: - self._populate() - - def _populate(self): - # get all the pvs in the current system - for pv_item in get_api_pvs(): - self.append(PVolume(**pv_item)) - - def _purge(self): - """ - Deplete all the items in the list, used internally only so that we can - dynamically allocate the items when filtering without the concern of - messing up the contents - """ - self[:] = [] - - def _filter(self, pv_name=None, pv_uuid=None, pv_tags=None): - """ - The actual method that filters using a new list. Useful so that other - methods that do not want to alter the contents of the list (e.g. - ``self.find``) can operate safely. - """ - filtered = [i for i in self] - if pv_name: - filtered = [i for i in filtered if i.pv_name == pv_name] - - if pv_uuid: - filtered = [i for i in filtered if i.pv_uuid == pv_uuid] - - # at this point, `filtered` has either all the physical volumes in self - # or is an actual filtered list if any filters were applied - if pv_tags: - tag_filtered = [] - for pvolume in filtered: - matches = all(pvolume.tags.get(k) == str(v) for k, v in pv_tags.items()) - if matches: - tag_filtered.append(pvolume) - # return the tag_filtered pvolumes here, the `filtered` list is no - # longer usable - return tag_filtered - - return filtered - - def filter(self, pv_name=None, pv_uuid=None, pv_tags=None): - """ - Filter out volumes on top level attributes like ``pv_name`` or by - ``pv_tags`` where a dict is required. For example, to find a physical - volume that has an OSD ID of 0, the filter would look like:: - - pv_tags={'ceph.osd_id': '0'} - - """ - if not any([pv_name, pv_uuid, pv_tags]): - raise TypeError('.filter() requires pv_name, pv_uuid, or pv_tags' - '(none given)') - - filtered_pvs = PVolumes(populate=False) - filtered_pvs.extend(self._filter(pv_name, pv_uuid, pv_tags)) - return filtered_pvs - - def get(self, pv_name=None, pv_uuid=None, pv_tags=None): - """ - This is a bit expensive, since it will try to filter out all the - matching items in the list, filter them out applying anything that was - added and return the matching item. - - This method does *not* alter the list, and it will raise an error if - multiple pvs are matched - - It is useful to use ``tags`` when trying to find a specific logical volume, - but it can also lead to multiple pvs being found, since a lot of metadata - is shared between pvs of a distinct OSD. - """ - if not any([pv_name, pv_uuid, pv_tags]): - return None - pvs = self._filter( - pv_name=pv_name, - pv_uuid=pv_uuid, - pv_tags=pv_tags - ) - if not pvs: - return None - if len(pvs) > 1 and pv_tags: - raise MultiplePVsError(pv_name) - return pvs[0] + if not vg_name: + logger.warning('Skipping removal of invalid VG name: "%s"', vg_name) + return + fail_msg = "Unable to remove vg %s" % vg_name + process.run( + [ + 'vgremove', + '-v', # verbose + '-f', # force it + vg_name + ], + fail_msg=fail_msg, + ) -class VolumeGroup(object): - """ - Represents an LVM group, with some top-level attributes like ``vg_name`` +def get_vg(vg_name=None, vg_tags=None): """ + Return a matching vg for the current system, requires ``vg_name`` or + ``tags``. Raises an error if more than one vg is found. - def __init__(self, **kw): - for k, v in kw.items(): - setattr(self, k, v) - self.name = kw['vg_name'] - self.tags = parse_tags(kw.get('vg_tags', '')) - - def __str__(self): - return '<%s>' % self.name - - def __repr__(self): - return self.__str__() - - def _parse_size(self, size): - error_msg = "Unable to convert vg size to integer: '%s'" % str(size) - try: - integer, _ = size.split('g') - except ValueError: - logger.exception(error_msg) - raise RuntimeError(error_msg) - - return util.str_to_int(integer) - - @property - def free(self): - """ - Parse the available size in gigabytes from the ``vg_free`` attribute, that - will be a string with a character ('g') to indicate gigabytes in size. - Returns a rounded down integer to ease internal operations:: - - >>> data_vg.vg_free - '0.01g' - >>> data_vg.size - 0 - """ - return self._parse_size(self.vg_free) - - @property - def size(self): - """ - Parse the size in gigabytes from the ``vg_size`` attribute, that - will be a string with a character ('g') to indicate gigabytes in size. - Returns a rounded down integer to ease internal operations:: - - >>> data_vg.vg_size - '1024.9g' - >>> data_vg.size - 1024 - """ - return self._parse_size(self.vg_size) - - def sizing(self, parts=None, size=None): - """ - Calculate proper sizing to fully utilize the volume group in the most - efficient way possible. To prevent situations where LVM might accept - a percentage that is beyond the vg's capabilities, it will refuse with - an error when requesting a larger-than-possible parameter, in addition - to rounding down calculations. - - A dictionary with different sizing parameters is returned, to make it - easier for others to choose what they need in order to create logical - volumes:: - - >>> data_vg.free - 1024 - >>> data_vg.sizing(parts=4) - {'parts': 4, 'sizes': 256, 'percentages': 25} - >>> data_vg.sizing(size=512) - {'parts': 2, 'sizes': 512, 'percentages': 50} + It is useful to use ``tags`` when trying to find a specific volume group, + but it can also lead to multiple vgs being found. + """ + if not any([vg_name, vg_tags]): + return None + vgs = VolumeGroups() + return vgs.get(vg_name=vg_name, vg_tags=vg_tags) - :param parts: Number of parts to create LVs from - :param size: Size in gigabytes to divide the VG into +################################# +# +# Code for LVM Logical Volumes +# +############################### - :raises SizeAllocationError: When requested size cannot be allocated with - :raises ValueError: If both ``parts`` and ``size`` are given - """ - if parts is not None and size is not None: - raise ValueError( - "Cannot process sizing with both parts (%s) and size (%s)" % (parts, size) - ) - # if size is given we need to map that to extents so that we avoid - # issues when trying to get this right with a size in gigabytes find - # the percentage first, cheating, because these values are thrown out - vg_free_count = util.str_to_int(self.vg_free_count) +def get_api_lvs(): + """ + Return the list of logical volumes available in the system using flags to include common + metadata associated with them - if size: - extents = int(size * vg_free_count / self.free) - disk_sizing = sizing(self.free, size=size, parts=parts) - else: - if parts is not None: - # Prevent parts being 0, falling back to 1 (100% usage) - parts = parts or 1 - size = int(self.free / parts) - extents = size * vg_free_count / self.free - disk_sizing = sizing(self.free, parts=parts) + Command and delimited output should look like:: - extent_sizing = sizing(vg_free_count, size=extents) + $ lvs --noheadings --readonly --separator=';' -a -o lv_tags,lv_path,lv_name,vg_name + ;/dev/ubuntubox-vg/root;root;ubuntubox-vg + ;/dev/ubuntubox-vg/swap_1;swap_1;ubuntubox-vg - disk_sizing['extents'] = int(extents) - disk_sizing['percentages'] = extent_sizing['percentages'] - return disk_sizing + """ + fields = 'lv_tags,lv_path,lv_name,vg_name,lv_uuid,lv_size' + stdout, stderr, returncode = process.call( + ['lvs', '--noheadings', '--readonly', '--separator=";"', '-a', '-o', fields], + verbose_on_failure=False + ) + return _output_parser(stdout, fields) class Volume(object): @@ -1204,64 +980,309 @@ class Volume(object): self.tags[key] = value -class PVolume(object): +class Volumes(list): """ - Represents a Physical Volume from LVM, with some top-level attributes like - ``pv_name`` and parsed tags as a dictionary of key/value pairs. + A list of all known (logical) volumes for the current system, with the ability + to filter them via keyword arguments. """ - def __init__(self, **kw): - for k, v in kw.items(): - setattr(self, k, v) - self.pv_api = kw - self.name = kw['pv_name'] - self.tags = parse_tags(kw['pv_tags']) - - def __str__(self): - return '<%s>' % self.pv_api['pv_name'] + def __init__(self): + self._populate() - def __repr__(self): - return self.__str__() + def _populate(self): + # get all the lvs in the current system + for lv_item in get_api_lvs(): + self.append(Volume(**lv_item)) - def set_tags(self, tags): + def _purge(self): """ - :param tags: A dictionary of tag names and values, like:: + Delete all the items in the list, used internally only so that we can + dynamically allocate the items when filtering without the concern of + messing up the contents + """ + self[:] = [] - { - "ceph.osd_fsid": "aaa-fff-bbbb", - "ceph.osd_id": "0" - } + def _filter(self, lv_name=None, vg_name=None, lv_path=None, lv_uuid=None, lv_tags=None): + """ + The actual method that filters using a new list. Useful so that other + methods that do not want to alter the contents of the list (e.g. + ``self.find``) can operate safely. + """ + filtered = [i for i in self] + if lv_name: + filtered = [i for i in filtered if i.lv_name == lv_name] - At the end of all modifications, the tags are refreshed to reflect - LVM's most current view. + if vg_name: + filtered = [i for i in filtered if i.vg_name == vg_name] + + if lv_uuid: + filtered = [i for i in filtered if i.lv_uuid == lv_uuid] + + if lv_path: + filtered = [i for i in filtered if i.lv_path == lv_path] + + # at this point, `filtered` has either all the volumes in self or is an + # actual filtered list if any filters were applied + if lv_tags: + tag_filtered = [] + for volume in filtered: + # all the tags we got need to match on the volume + matches = all(volume.tags.get(k) == str(v) for k, v in lv_tags.items()) + if matches: + tag_filtered.append(volume) + return tag_filtered + + return filtered + + def filter(self, lv_name=None, vg_name=None, lv_path=None, lv_uuid=None, lv_tags=None): """ - for k, v in tags.items(): - self.set_tag(k, v) - # after setting all the tags, refresh them for the current object, use the - # pv_* identifiers to filter because those shouldn't change - pv_object = get_pv(pv_name=self.pv_name, pv_uuid=self.pv_uuid) - self.tags = pv_object.tags + Filter out volumes on top level attributes like ``lv_name`` or by + ``lv_tags`` where a dict is required. For example, to find a volume + that has an OSD ID of 0, the filter would look like:: + + lv_tags={'ceph.osd_id': '0'} - def set_tag(self, key, value): """ - Set the key/value pair as an LVM tag. Does not "refresh" the values of - the current object for its tags. Meant to be a "fire and forget" type - of modification. + if not any([lv_name, vg_name, lv_path, lv_uuid, lv_tags]): + raise TypeError('.filter() requires lv_name, vg_name, lv_path, lv_uuid, or tags (none given)') + # first find the filtered volumes with the values in self + filtered_volumes = self._filter( + lv_name=lv_name, + vg_name=vg_name, + lv_path=lv_path, + lv_uuid=lv_uuid, + lv_tags=lv_tags + ) + # then purge everything + self._purge() + # and add the filtered items + self.extend(filtered_volumes) - **warning**: Altering tags on a PV has to be done ensuring that the - device is actually the one intended. ``pv_name`` is *not* a persistent - value, only ``pv_uuid`` is. Using ``pv_uuid`` is the best way to make - sure the device getting changed is the one needed. + def get(self, lv_name=None, vg_name=None, lv_path=None, lv_uuid=None, lv_tags=None): """ - # remove it first if it exists - if self.tags.get(key): - current_value = self.tags[key] - tag = "%s=%s" % (key, current_value) - process.call(['pvchange', '--deltag', tag, self.pv_name]) + This is a bit expensive, since it will try to filter out all the + matching items in the list, filter them out applying anything that was + added and return the matching item. - process.call( - [ - 'pvchange', - '--addtag', '%s=%s' % (key, value), self.pv_name - ] + This method does *not* alter the list, and it will raise an error if + multiple LVs are matched + + It is useful to use ``tags`` when trying to find a specific logical volume, + but it can also lead to multiple lvs being found, since a lot of metadata + is shared between lvs of a distinct OSD. + """ + if not any([lv_name, vg_name, lv_path, lv_uuid, lv_tags]): + return None + lvs = self._filter( + lv_name=lv_name, + vg_name=vg_name, + lv_path=lv_path, + lv_uuid=lv_uuid, + lv_tags=lv_tags + ) + if not lvs: + return None + if len(lvs) > 1: + raise MultipleLVsError(lv_name, lv_path) + return lvs[0] + + +def create_lv(name, group, extents=None, size=None, tags=None, uuid_name=False, pv=None): + """ + Create a Logical Volume in a Volume Group. Command looks like:: + + lvcreate -L 50G -n gfslv vg0 + + ``name``, ``group``, are required. If ``size`` is provided it must follow + lvm's size notation (like 1G, or 20M). Tags are an optional dictionary and is expected to + conform to the convention of prefixing them with "ceph." like:: + + {"ceph.block_device": "/dev/ceph/osd-1"} + + :param uuid_name: Optionally combine the ``name`` with UUID to ensure uniqueness + """ + if uuid_name: + name = '%s-%s' % (name, uuid.uuid4()) + if tags is None: + tags = { + "ceph.osd_id": "null", + "ceph.type": "null", + "ceph.cluster_fsid": "null", + "ceph.osd_fsid": "null", + } + + # XXX add CEPH_VOLUME_LVM_DEBUG to enable -vvvv on lv operations + type_path_tag = { + 'journal': 'ceph.journal_device', + 'data': 'ceph.data_device', + 'block': 'ceph.block_device', + 'wal': 'ceph.wal_device', + 'db': 'ceph.db_device', + 'lockbox': 'ceph.lockbox_device', # XXX might not ever need this lockbox sorcery + } + if size: + command = [ + 'lvcreate', + '--yes', + '-L', + '%s' % size, + '-n', name, group + ] + elif extents: + command = [ + 'lvcreate', + '--yes', + '-l', + '%s' % extents, + '-n', name, group + ] + # create the lv with all the space available, this is needed because the + # system call is different for LVM + else: + command = [ + 'lvcreate', + '--yes', + '-l', + '100%FREE', + '-n', name, group + ] + if pv: + command.append(pv) + process.run(command) + + lv = get_lv(lv_name=name, vg_name=group) + lv.set_tags(tags) + + # when creating a distinct type, the caller doesn't know what the path will + # be so this function will set it after creation using the mapping + path_tag = type_path_tag.get(tags.get('ceph.type')) + if path_tag: + lv.set_tags( + {path_tag: lv.lv_path} + ) + return lv + + +def remove_lv(lv): + """ + Removes a logical volume given it's absolute path. + + Will return True if the lv is successfully removed or + raises a RuntimeError if the removal fails. + + :param lv: A ``Volume`` object or the path for an LV + """ + if isinstance(lv, Volume): + path = lv.lv_path + else: + path = lv + + stdout, stderr, returncode = process.call( + [ + 'lvremove', + '-v', # verbose + '-f', # force it + path + ], + show_command=True, + terminal_verbose=True, + ) + if returncode != 0: + raise RuntimeError("Unable to remove %s" % path) + return True + + +def is_lv(dev, lvs=None): + """ + Boolean to detect if a device is an LV or not. + """ + splitname = dmsetup_splitname(dev) + # Allowing to optionally pass `lvs` can help reduce repetitive checks for + # multiple devices at once. + if lvs is None or len(lvs) == 0: + lvs = Volumes() + + if splitname.get('LV_NAME'): + lvs.filter(lv_name=splitname['LV_NAME'], vg_name=splitname['VG_NAME']) + return len(lvs) > 0 + return False + + +def get_lv(lv_name=None, vg_name=None, lv_path=None, lv_uuid=None, lv_tags=None, lvs=None): + """ + Return a matching lv for the current system, requiring ``lv_name``, + ``vg_name``, ``lv_path`` or ``tags``. Raises an error if more than one lv + is found. + + It is useful to use ``tags`` when trying to find a specific logical volume, + but it can also lead to multiple lvs being found, since a lot of metadata + is shared between lvs of a distinct OSD. + """ + if not any([lv_name, vg_name, lv_path, lv_uuid, lv_tags]): + return None + if lvs is None: + lvs = Volumes() + return lvs.get( + lv_name=lv_name, vg_name=vg_name, lv_path=lv_path, lv_uuid=lv_uuid, + lv_tags=lv_tags + ) + + +def get_lv_from_argument(argument): + """ + Helper proxy function that consumes a possible logical volume passed in from the CLI + in the form of `vg/lv`, but with some validation so that an argument that is a full + path to a device can be ignored + """ + if argument.startswith('/'): + lv = get_lv(lv_path=argument) + return lv + try: + vg_name, lv_name = argument.split('/') + except (ValueError, AttributeError): + return None + return get_lv(lv_name=lv_name, vg_name=vg_name) + + +def create_lvs(volume_group, parts=None, size=None, name_prefix='ceph-lv'): + """ + Create multiple Logical Volumes from a Volume Group by calculating the + proper extents from ``parts`` or ``size``. A custom prefix can be used + (defaults to ``ceph-lv``), these names are always suffixed with a uuid. + + LV creation in ceph-volume will require tags, this is expected to be + pre-computed by callers who know Ceph metadata like OSD IDs and FSIDs. It + will probably not be the case when mass-creating LVs, so common/default + tags will be set to ``"null"``. + + .. note:: LVs that are not in use can be detected by querying LVM for tags that are + set to ``"null"``. + + :param volume_group: The volume group (vg) to use for LV creation + :type group: ``VolumeGroup()`` object + :param parts: Number of LVs to create *instead of* ``size``. + :type parts: int + :param size: Size (in gigabytes) of LVs to create, e.g. "as many 10gb LVs as possible" + :type size: int + :param extents: The number of LVM extents to use to create the LV. Useful if looking to have + accurate LV sizes (LVM rounds sizes otherwise) + """ + if parts is None and size is None: + # fallback to just one part (using 100% of the vg) + parts = 1 + lvs = [] + tags = { + "ceph.osd_id": "null", + "ceph.type": "null", + "ceph.cluster_fsid": "null", + "ceph.osd_fsid": "null", + } + sizing = volume_group.sizing(parts=parts, size=size) + for part in range(0, sizing['parts']): + size = sizing['sizes'] + extents = sizing['extents'] + lv_name = '%s-%s' % (name_prefix, uuid.uuid4()) + lvs.append( + create_lv(lv_name, volume_group.name, extents=extents, tags=tags) ) + return lvs -- 2.39.5