Browse Source

Add lvm thin pool storage implementation

Bahtiar `kalkin-` Gadimov 7 years ago
parent
commit
3ae6530cd8
4 changed files with 618 additions and 0 deletions
  1. 341 0
      qubes/storage/lvm.py
  2. 272 0
      qubes/tools/qubes_lvm.py
  3. 4 0
      rpm_spec/core-dom0.spec
  4. 1 0
      setup.py

+ 341 - 0
qubes/storage/lvm.py

@@ -0,0 +1,341 @@
+# vim: fileencoding=utf-8
+# pylint: disable=abstract-method
+#
+# The Qubes OS Project, http://www.qubes-os.org
+#
+# Copyright (C) 2016 Bahtiar `kalkin-` Gadimov <bahtiar@gadimov.de>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+''' Driver for storing vm images in a LVM thin pool '''
+
+import logging
+import os
+import subprocess
+
+import qubes
+
+
+class ThinPool(qubes.storage.Pool):
+    ''' LVM Thin based pool implementation
+    '''  # pylint: disable=protected-access
+
+    driver = 'lvm_thin'
+
+    def __init__(self, volume_group, thin_pool, **kwargs):
+        super(ThinPool, self).__init__(**kwargs)
+        self.volume_group = volume_group
+        self.thin_pool = thin_pool
+        self._pool_id = "{!s}/{!s}".format(volume_group, thin_pool)
+        self.log = logging.getLogger('qube.storage.lvm.%s' % self._pool_id)
+
+    def backup(self, volume):
+        msg = "Expected volume_type 'snap' got {!s}"
+        msg = msg.format(volume.volume_type)
+        assert volume.volume_type == 'snap', msg
+        cmd = ['remove', volume.vid + "-back"]
+        qubes_lvm(cmd, self.log)
+        cmd = ['clone', volume.vid, volume.vid + "-back"]
+        qubes_lvm(cmd, self.log)
+        volume.backups = [volume.vid + "-back"]
+        return volume
+
+    def clone(self, source, target):
+        cmd = ['clone', source.vid, target.vid]
+        qubes_lvm(cmd, self.log)
+        return target
+
+    def commit(self, volume):
+        msg = "Expected rw:True & volume_type 'snap' got {!s} & rw:{!s}"
+        msg = msg.format(volume.volume_type, volume.rw)
+        assert volume.volume_type == 'snap' and volume.rw, msg
+        assert volume.path.endswith("-snap")
+        self.backup(volume)
+        cmd = ['remove', volume.vid]
+        qubes_lvm(cmd, self.log)
+        cmd = ['clone', volume.path, volume.vid]
+        qubes_lvm(cmd, self.log)
+
+    def _backup(self, volume):
+        msg = "Expected volume_type 'snap' got {!s}"
+        msg = msg.format(volume.volume_type)
+        assert volume.volume_type == 'snap', msg
+        cmd = ['remove', volume.vid + "-back"]
+        qubes_lvm(cmd, self.log)
+        cmd = ['clone', volume.vid, volume.vid + "-back"]
+        qubes_lvm(cmd, self.log)
+        volume.backups = [volume.vid + "-back"]
+        return 'XXX'
+
+    @property
+    def config(self):
+        return {
+            'name': self.name,
+            'volume_group': self.volume_group,
+            'thin_pool': self.thin_pool,
+            'driver': ThinPool.driver
+        }
+
+    def create(self, volume):
+        assert volume.vid
+        assert volume.size
+        if volume.source:
+            return self.clone(volume.source, volume)
+        else:
+            cmd = [
+                'create',
+                self._pool_id,
+                volume.vid.split('/', 1)[1],
+                str(volume.size)
+            ]
+            qubes_lvm(cmd, self.log)
+        return volume
+
+    def destroy(self):
+        pass  # TODO Should we remove an existing pool?
+
+    def export(self, volume):
+        ''' Returns an object that can be `open()`. '''
+        return '/dev/' + volume.vid
+
+    def init_volume(self, vm, volume_config):
+        ''' Initialize a :py:class:`qubes.storage.Volume` from `volume_config`.
+        '''
+
+        if 'vid' not in volume_config.keys():
+            if vm and hasattr(vm, 'name'):
+                vm_name = vm.name
+            else:
+                # for the future if we have volumes not belonging to a vm
+                vm_name = qubes.utils.random_string()
+
+            assert self.name
+
+            volume_config['vid'] = "{!s}/{!s}-{!s}".format(
+                self.volume_group, vm_name, volume_config['name'])
+
+        volume_config['volume_group'] = self.volume_group
+
+        return ThinVolume(**volume_config)
+
+    def import_volume(self, dst_pool, dst_volume, src_pool, src_volume):
+        src_path = src_pool.export(src_volume)
+
+        # HACK: neat trick to speed up testing if you have same physical thin
+        # pool assigned to two qubes-pools i.e: qubes_dom0 and test-lvm
+        # pylint: disable=line-too-long
+        if isinstance(src_pool, ThinPool) and src_pool.thin_pool == dst_pool.thin_pool:  # NOQA
+            return self.clone(src_volume, dst_volume)
+        else:
+            dst_volume = self.create(dst_volume)
+
+        cmd = ['sudo', 'qubes-lvm', 'import', dst_volume.vid]
+        blk_size = 4096
+        p = subprocess.Popen(cmd, stdin=subprocess.PIPE)
+        dst = p.stdin
+        with open(src_path, 'rb') as src:
+            while True:
+                tmp = src.read(blk_size)
+                if not tmp:
+                    break
+                else:
+                    dst.write(tmp)
+        p.stdin.close()
+        p.wait()
+        return dst_volume
+
+    def is_dirty(self, volume):
+        if volume.save_on_stop:
+            return os.path.exists(volume.path + '-snap')
+        return False
+
+    def remove(self, volume):
+        assert volume.vid
+        if self.is_dirty(volume):
+            cmd = ['remove', volume._vid_snap]
+            qubes_lvm(cmd, self.log)
+
+        cmd = ['remove', volume.vid]
+        qubes_lvm(cmd, self.log)
+
+    def rename(self, volume, old_name, new_name):
+        ''' Called when the domain changes its name '''
+        new_vid = "{!s}/{!s}-{!s}".format(self.volume_group, new_name,
+                                          volume.name)
+        if volume.save_on_stop:
+            cmd = ['clone', volume.vid, new_vid]
+            qubes_lvm(cmd, self.log)
+
+        if volume.save_on_stop or volume._is_volatile:
+            cmd = ['remove', volume.vid]
+            qubes_lvm(cmd, self.log)
+
+        volume.vid = new_vid
+
+        if not volume._is_volatile:
+            volume._vid_snap = volume.vid + '-snap'
+        return volume
+
+    def _reset(self, volume):
+        self.remove(volume)
+        self.create(volume)
+
+    def setup(self):
+        pass  # TODO Should we create a non existing pool?gt
+
+    def start(self, volume):
+        if volume._is_snapshot:
+            self._snapshot(volume)
+        elif volume._is_volatile:
+            self._reset(volume)
+        else:
+            if not self.is_dirty(volume):
+                self._snapshot(volume)
+
+        return volume
+
+    def stop(self, volume):
+        if volume.save_on_stop:
+            cmd = ['remove', volume.vid]
+            qubes_lvm(cmd, self.log)
+            cmd = ['clone', volume._vid_snap, volume.vid]
+            qubes_lvm(cmd, self.log)
+            cmd = ['remove', volume._vid_snap]
+            qubes_lvm(cmd, self.log)
+        elif volume._is_volatile:
+            cmd = ['remove', volume.vid]
+            qubes_lvm(cmd, self.log)
+        else:
+            cmd = ['remove', volume._vid_snap]
+            qubes_lvm(cmd, self.log)
+
+    def _snapshot(self, volume):
+        if volume.source is None:
+            cmd = ['clone', volume.vid, volume._vid_snap]
+        else:
+            cmd = ['clone', str(volume.source), volume._vid_snap]
+        qubes_lvm(cmd, self.log)
+
+    def verify(self, volume):
+        ''' Verifies the volume. '''
+        cmd = ['sudo', 'qubes-lvm', 'volumes',
+               self.volume_group + '/' + self.thin_pool]
+        p = subprocess.Popen(cmd, stdout=subprocess.PIPE)
+        result = p.communicate()[0]
+        for line in result.splitlines():
+            if not line.strip():
+                continue
+            vid, atr = line.strip().split(' ')
+            if vid == volume.vid:
+                return atr[4] == 'a'
+
+        return False
+
+    @property
+    def volumes(self):
+        ''' Return a list of volumes managed by this pool '''
+        cmd = ['sudo', 'qubes-lvm', 'volumes',
+               self.volume_group + '/' + self.thin_pool]
+        p = subprocess.Popen(cmd, stdout=subprocess.PIPE)
+        result = p.communicate()[0]
+        volumes = []
+        for line in result.splitlines():
+            if not line.strip():
+                continue
+            vid, atr = line.strip().split(' ')
+            config = {
+                'pool': self.name,
+                'vid': vid,
+                'name': vid,
+                'volume_group': self.volume_group,
+                'rw': atr[1] == 'w',
+            }
+            volumes += [ThinVolume(**config)]
+        return volumes
+
+    def _reset_volume(self, volume):
+        ''' Resets a volatile volume '''
+        assert volume.volume_type == 'volatile', \
+            'Expected a volatile volume, but got {!r}'.format(volume)
+        self.log.debug('Resetting volatile ' + volume.vid)
+        cmd = ['remove', volume.vid]
+        qubes_lvm(cmd, self.log)
+        cmd = ['create', self._pool_id, volume.vid.split('/')[1],
+               str(volume.size)]
+        qubes_lvm(cmd, self.log)
+
+class ThinVolume(qubes.storage.Volume):
+    ''' Default LVM thin volume implementation
+    '''  # pylint: disable=too-few-public-methods
+
+    def __init__(self, volume_group, **kwargs):
+        self.volume_group = volume_group
+        super(ThinVolume, self).__init__(**kwargs)
+
+        if self.snap_on_start and self.source is None:
+            msg = "snap_on_start specified on {!r} but no volume source set"
+            msg = msg.format(self.name)
+            raise qubes.storage.StoragePoolException(msg)
+        elif not self.snap_on_start and self.source is not None:
+            msg = "source specified on {!r} but no snap_on_start set"
+            msg = msg.format(self.name)
+            raise qubes.storage.StoragePoolException(msg)
+
+        self.path = '/dev/' + self.vid
+        if not self._is_volatile:
+            self._vid_snap = self.vid + '-snap'
+
+    @property
+    def revisions(self):
+        return {}
+
+    @property
+    def _is_origin(self):
+        return not self.snap_on_start and self.save_on_stop
+
+    @property
+    def _is_origin_snapshot(self):
+        return self.snap_on_start and self.save_on_stop
+
+    @property
+    def _is_snapshot(self):
+        return self.snap_on_start and not self.save_on_stop
+
+    @property
+    def _is_volatile(self):
+        return not self.snap_on_start and not self.save_on_stop
+
+def pool_exists(pool_id):
+    ''' Return true if pool exists '''
+    cmd = ['pool', pool_id]
+    return qubes_lvm(cmd)
+
+
+def qubes_lvm(cmd, log=logging.getLogger('qube.storage.lvm')):
+    ''' Call :program:`qubes-lvm` to execute an LVM operation '''
+    # TODO Refactor this ones the udev groups gets fixed and we don't need root
+    # for operations on lvm devices
+    cmd = ['sudo', 'qubes-lvm'] + cmd
+    p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+    out, err = p.communicate()
+    return_code = p.returncode
+    if out:
+        log.info(out)
+    if return_code == 0 and err:
+        log.warning(err)
+    elif return_code != 0:
+        assert err, "Command exited unsuccessful, but printed nothing to stderr"
+        raise qubes.storage.StoragePoolException(err)
+    return True

+ 272 - 0
qubes/tools/qubes_lvm.py

@@ -0,0 +1,272 @@
+#!/usr/bin/python2
+#
+# The Qubes OS Project, http://www.qubes-os.org
+#
+# Copyright (C) 2016 Bahtiar `kalkin-` Gadimov <bahtiar@gadimov.de>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+''' Manage pools and volumes managed by the 'lvm_thin' driver. '''
+
+from __future__ import print_function
+
+import datetime
+import logging
+import os
+import subprocess
+import sys
+import time
+import lvm  # pylint: disable=import-error
+
+import qubes
+
+log = logging.getLogger('qubes.storage.lvm')
+
+
+def lvm_image_changed(vm):
+    ''' Returns true if source image changed '''
+    # TODO: reimplement lvm_image_changed
+    vm_root = vm.root_img
+    tp_root = vm.template.root_img
+    if not os.path.exists(vm_root):
+        return False
+    cmd = 'date +"%%s" -d "' + \
+        '`sudo tune2fs %s -l|grep "Last write time"|cut -d":" -f2,3,4`"'
+    result1 = subprocess.check_output(cmd % vm_root, shell=True).strip()
+    result2 = subprocess.check_output(cmd % tp_root, shell=True).strip()
+
+    result1 = datetime.datetime.strptime(result1, '%c')
+    result2 = datetime.datetime.strptime(result2, '%c')
+    return result2 > result1
+
+
+def pool_exists(args):
+    """ Check if given name is an lvm thin volume. """
+    # TODO Implement a faster and proper working version pool_exists
+    vg_name, thin_pool_name = args.pool_id.split('/', 1)
+    volume_group = lvm.vgOpen(vg_name)
+    for p in volume_group.listLVs():
+        if p.getAttr()[0] == 't' and p.getName() == thin_pool_name:
+            volume_group.close()
+            return True
+
+    volume_group.close()
+    return False
+
+
+def thin_volume_exists(volume):
+    """ Check if the given volume exists and is a thin volume """
+    log.debug("Checking if the %s thin volume exists", volume)
+    assert volume is not None
+
+    cmd = ['sudo', 'lvs', '-o', 'lv_modules', '--rows', volume]
+    try:
+        output = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+        log.debug(output)
+        # Just because the above command succeded it does not mean that we
+        # really have a volume managed by a thin pool. It could be just any
+        # volume. Below we check that the volume uses the thin-pool module.
+        if "thin-pool,thin" in output:
+            return True
+    except subprocess.CalledProcessError:
+        return False
+
+
+def remove_volume(args):
+    """ Tries to remove the specified logical volume.
+
+        If the removal fails it will try up to 3 times waiting 1, 2 and 3
+        seconds between tries. Most of the time this function fails if some
+        process still has the volume locked.
+    """
+    img = args.name
+    if not thin_volume_exists(img):
+        log.info("Expected to remove %s, but volume does not exist", img)
+        return
+
+    tries = 1
+    successful = False
+    cmd = ['sudo', 'lvremove', '-f', img]
+
+    while tries <= 3 and not successful:
+        log.info("Trying to remove LVM %s", img)
+        try:
+            output = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+            log.debug(output)
+            successful = True
+        except subprocess.CalledProcessError:
+            successful = False
+
+        if successful:
+            break
+        else:
+            time.sleep(tries)
+            tries += 1
+
+    if not successful:
+        log.error('Could not remove volume ' + img)
+
+
+def clone_volume(args):
+    """ Calls lvcreate and creates new snapshot. """
+    old = args.source
+    new_name = args.destination
+    cmd = ["sudo", "lvcreate", "-kn", "-ay", "-s", old, "-n", new_name]
+    return subprocess.call(cmd)
+
+
+def new_volume(args):
+    ''' Creates a new volume in the specified thin pool, formated with ext4 '''
+
+    thin_pool = args.pool_id
+    name = args.name
+    size = args.size
+    log.info('Creating new Thin LVM %s in %s VG %s bytes', name, thin_pool,
+             size)
+    cmd = ['sudo', 'lvcreate', '-T', thin_pool, '-kn', '-ay', '-n', name, '-V',
+           str(size) + 'B']
+
+    return subprocess.call(cmd)
+
+
+def rename_volume(old_name, new_name):
+    ''' Rename volume '''
+    log.debug("Renaming LVM  %s to %s ", old_name, new_name)
+    retcode = subprocess.call(["sudo", "lvrename", old_name, new_name])
+    if retcode != 0:
+        raise IOError("Error renaming LVM  %s to %s " % (old_name, new_name))
+    return new_name
+
+
+def init_pool_parser(sub_parsers):
+    ''' Initialize pool subparser '''
+    pool_parser = sub_parsers.add_parser(
+        'pool', aliases=('p', 'pl'),
+        help="Exit with exit code 0 if pool exists")
+    pool_parser.add_argument('pool_id', metavar='VG/POOL',
+                             help="volume_group/pool_name")
+    pool_parser.set_defaults(func=pool_exists)
+
+
+def init_new_parser(sub_parsers):
+    ''' Initialize the 'new' subparser '''
+    new_parser = sub_parsers.add_parser(
+        'new', aliases=('n', 'create'),
+        help='Creates a new thin ThinPoolLogicalVolume')
+    new_parser.add_argument('pool_id', metavar='VG/POOL',
+                            help="volume_group/pool_name")
+
+    new_parser.add_argument('name',
+                            help='name of the new ThinPoolLogicalVolume')
+    new_parser.add_argument(
+        'size', help='size in bytes of the new ThinPoolLogicalVolume')
+
+    new_parser.set_defaults(func=new_volume)
+
+
+def init_import_parser(sub_parsers):
+    ''' Initialize import subparser '''
+    import_parser = sub_parsers.add_parser(
+        'import', aliases=('imp', 'i'),
+        help='sparse copy data from stdin to a thin volume')
+    import_parser.add_argument('name', metavar='VG/VID',
+                               help='volume_group/volume_name')
+    import_parser.set_defaults(func=import_volume)
+
+def init_clone_parser(sub_parsers):
+    ''' Initialize clone subparser '''
+    clone_parser = sub_parsers.add_parser(
+        'clone', aliases=('cln', 'c'),
+        help='sparse copy data from stdin to a thin volume')
+    clone_parser.add_argument('source', metavar='VG/VID',
+                               help='volume_group/volume_name')
+    clone_parser.add_argument('destination', metavar='VG/VID',
+                               help='volume_group/volume_name')
+    clone_parser.set_defaults(func=clone_volume)
+
+def import_volume(args):
+    ''' Imports from stdin to a thin volume '''
+    name = args.name
+    src = sys.stdin
+    blk_size = 4096
+    zeros = '\x00' * blk_size
+    dst_path = '/dev/%s' % name
+    with open(dst_path, 'wb') as dst:
+        while True:
+            tmp = src.read(blk_size)
+            if not tmp:
+                break
+            elif tmp == zeros:
+                dst.seek(blk_size, 1)
+            else:
+                dst.write(tmp)
+
+
+def list_volumes(args):
+    ''' lists volumes '''
+    vg_name, _ = args.name.split('/')
+    volume_group = lvm.vgOpen(vg_name)
+    for p in volume_group.listLVs():
+        if p.getAttr()[0] == 'V':
+            print(vg_name + "/" + p.getName() + ' ' + p.getAttr())
+    volume_group.close()
+
+
+def init_volumes_parser(sub_parsers):
+    ''' Initialize volumes subparser '''
+    parser = sub_parsers.add_parser('volumes', aliases=('v', 'vol'),
+                                    help='list volumes in a pool')
+    parser.add_argument('name', metavar='VG/THIN_POOL',
+                        help='volume_group/thin_pool_name')
+    parser.set_defaults(func=list_volumes)
+
+
+def init_remove_parser(sub_parsers):
+    ''' Initialize remove subparser '''
+    remove_parser = sub_parsers.add_parser('remove', aliases=('rm', 'r'),
+                                           help='Removes a LogicalVolume')
+    remove_parser.add_argument('name', metavar='VG/VID',
+                               help='volume_group/volume_name')
+    remove_parser.set_defaults(func=remove_volume)
+
+
+def get_parser():
+    '''Create :py:class:`argparse.ArgumentParser` suitable for
+    :program:`qubes-lvm`.
+    '''
+    parser = qubes.tools.QubesArgumentParser(description=__doc__, want_app=True)
+    parser.register('action', 'parsers', qubes.tools.AliasedSubParsersAction)
+    sub_parsers = parser.add_subparsers(
+        title='commands',
+        description="For more information see qubes-lvm command -h",
+        dest='command')
+    init_pool_parser(sub_parsers)
+    init_import_parser(sub_parsers)
+    init_new_parser(sub_parsers)
+    init_volumes_parser(sub_parsers)
+    init_remove_parser(sub_parsers)
+    init_clone_parser(sub_parsers)
+
+    return parser
+
+
+def main(args=None):
+    '''Main routine of :program:`qubes-lvm`.'''
+    args = get_parser().parse_args(args)
+    return args.func(args)
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 4 - 0
rpm_spec/core-dom0.spec

@@ -90,6 +90,8 @@ Requires:       PyQt4
 # for property's docstrings
 Requires:	python-docutils
 
+# for lvm support
+Requires: lvm2-python-libs
 
 # Prevent preupgrade from installation (it pretend to provide distribution upgrade)
 Obsoletes:	preupgrade < 2.0
@@ -237,6 +239,7 @@ fi
 %{python_sitelib}/qubes/storage/file.py*
 %{python_sitelib}/qubes/storage/domain.py*
 %{python_sitelib}/qubes/storage/kernels.py*
+%{python_sitelib}/qubes/storage/lvm.py*
 
 %dir %{python_sitelib}/qubes/tools
 %{python_sitelib}/qubes/tools/__init__.py*
@@ -245,6 +248,7 @@ fi
 %{python_sitelib}/qubes/tools/qubes_monitor_layout_notify.py*
 %{python_sitelib}/qubes/tools/qubes_prefs.py*
 %{python_sitelib}/qubes/tools/qvm_block.py*
+%{python_sitelib}/qubes/tools/qubes_lvm.py*
 %{python_sitelib}/qubes/tools/qvm_create.py*
 %{python_sitelib}/qubes/tools/qvm_features.py*
 %{python_sitelib}/qubes/tools/qvm_check.py*

+ 1 - 0
setup.py

@@ -47,5 +47,6 @@ if __name__ == '__main__':
             'qubes.storage': [
                 'file = qubes.storage.file:FilePool',
                 'linux-kernel = qubes.storage.kernels:LinuxKernel',
+                'lvm_thin = qubes.storage.lvm:ThinPool',
             ]
         })