]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
ceph-volume: rearrange api/lvm.py
authorRishabh Dave <ridave@redhat.com>
Sat, 12 Oct 2019 11:14:39 +0000 (16:44 +0530)
committerNathan Cutler <ncutler@suse.com>
Tue, 5 Nov 2019 11:36:24 +0000 (12:36 +0100)
Move code for PVs, VGs and LVs, individually, next to each other.

Fixes: https://tracker.ceph.com/issues/42368
Signed-off-by: Rishabh Dave <ridave@redhat.com>
(cherry picked from commit f4b764b55fab611a075ad8e597f1e978e2752b7d)

src/ceph-volume/ceph_volume/api/lvm.py

index 47ccc1068d4bf05ff8277e2c39d1b6f10bf19422..86c81618cf8918da7bf5c0b8916af497d3ac25a4 100644 (file)
@@ -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