0471453773
The wrapper doesn't do anything else than translating command parameters, but it's load time is significant (because of python imports mostly). Since we can't use python lvm API from non-root user anyway, lets drop the wrapper and call `lvm` directly (or through sudo when necessary). This makes VM startup much faster - storage preparation is down from over 10s to about 3s. QubesOS/qubes-issues#2256
455 lines
14 KiB
Python
455 lines
14 KiB
Python
# 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
|
|
|
|
size_cache = None
|
|
|
|
driver = 'lvm_thin'
|
|
|
|
def __init__(self, volume_group, thin_pool, revisions_to_keep=1, **kwargs):
|
|
super(ThinPool, self).__init__(revisions_to_keep=revisions_to_keep,
|
|
**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 clone(self, source, target):
|
|
cmd = ['clone', str(source), str(target)]
|
|
qubes_lvm(cmd, self.log)
|
|
return target
|
|
|
|
def _commit(self, volume):
|
|
msg = "Trying to commit {!s}, but it has save_on_stop == False"
|
|
msg = msg.format(volume)
|
|
assert volume.save_on_stop, msg
|
|
|
|
msg = "Trying to commit {!s}, but it has rw == False"
|
|
msg = msg.format(volume)
|
|
assert volume.rw, msg
|
|
assert hasattr(volume, '_vid_snap')
|
|
|
|
cmd = ['remove', volume.vid + "-back"]
|
|
qubes_lvm(cmd, self.log)
|
|
cmd = ['clone', volume._vid_snap, volume.vid + "-back"]
|
|
qubes_lvm(cmd, self.log)
|
|
|
|
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]
|
|
|
|
@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)
|
|
reset_cache()
|
|
return volume
|
|
|
|
def destroy(self):
|
|
pass # TODO Should we remove an existing pool?
|
|
|
|
def export(self, volume):
|
|
''' Returns an object that can be `open()`. '''
|
|
devpath = '/dev/' + volume.vid
|
|
if not os.access(devpath, os.R_OK):
|
|
# FIXME: convert to udev rules, and drop after introducing qubesd
|
|
subprocess.check_call(['sudo', 'chgrp', 'qubes', devpath])
|
|
subprocess.check_call(['sudo', 'chmod', 'g+rw', devpath])
|
|
return devpath
|
|
|
|
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):
|
|
if not src_volume.save_on_stop:
|
|
return dst_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', 'dd', 'if=' + src_path, 'of=/dev/' + dst_volume.vid,
|
|
'conv=sparse']
|
|
subprocess.check_call(cmd)
|
|
reset_cache()
|
|
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)
|
|
reset_cache()
|
|
|
|
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'
|
|
reset_cache()
|
|
return volume
|
|
|
|
def revert(self, volume, revision=None):
|
|
old_path = volume.path + '-back'
|
|
if not os.path.exists(old_path):
|
|
msg = "Volume {!s} has no {!s}".format(volume, old_path)
|
|
raise qubes.storage.StoragePoolException(msg)
|
|
|
|
cmd = ['remove', volume.vid]
|
|
qubes_lvm(cmd, self.log)
|
|
cmd = ['clone', volume.vid + '-back', volume.vid]
|
|
qubes_lvm(cmd, self.log)
|
|
reset_cache()
|
|
return volume
|
|
|
|
def resize(self, volume, size):
|
|
''' Expands volume, throws
|
|
:py:class:`qubst.storage.qubes.storage.StoragePoolException` if
|
|
given size is less than current_size
|
|
'''
|
|
if not volume.rw:
|
|
msg = 'Can not resize reađonly volume {!s}'.format(volume)
|
|
raise qubes.storage.StoragePoolException(msg)
|
|
|
|
if size <= volume.size:
|
|
raise qubes.storage.StoragePoolException(
|
|
'For your own safety, shrinking of %s is'
|
|
' disabled. If you really know what you'
|
|
' are doing, use `lvresize` on %s manually.' %
|
|
(volume.name, volume.vid))
|
|
|
|
cmd = ['extend', volume.vid, str(size)]
|
|
qubes_lvm(cmd, self.log)
|
|
reset_cache()
|
|
|
|
def _reset(self, volume):
|
|
try:
|
|
self.remove(volume)
|
|
except qubes.storage.StoragePoolException:
|
|
pass
|
|
|
|
self.create(volume)
|
|
|
|
def setup(self):
|
|
pass # TODO Should we create a non existing pool?
|
|
|
|
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)
|
|
|
|
reset_cache()
|
|
return volume
|
|
|
|
def stop(self, volume):
|
|
if volume.save_on_stop:
|
|
self._commit(volume)
|
|
if volume._is_snapshot:
|
|
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)
|
|
reset_cache()
|
|
return volume
|
|
|
|
def _snapshot(self, volume):
|
|
try:
|
|
cmd = ['remove', volume._vid_snap]
|
|
qubes_lvm(cmd, self.log)
|
|
except: # pylint: disable=bare-except
|
|
pass
|
|
|
|
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. '''
|
|
try:
|
|
vol_info = size_cache[volume.vid]
|
|
return vol_info['attr'][4] == 'a'
|
|
except KeyError:
|
|
return False
|
|
|
|
@property
|
|
def volumes(self):
|
|
''' Return a list of volumes managed by this pool '''
|
|
volumes = []
|
|
for vid, vol_info in size_cache.items():
|
|
if not vid.startswith(self.volume_group + '/'):
|
|
continue
|
|
if vol_info['pool_lv'] != self.thin_pool:
|
|
continue
|
|
config = {
|
|
'pool': self.name,
|
|
'vid': vid,
|
|
'name': vid,
|
|
'volume_group': self.volume_group,
|
|
'rw': vol_info['attr'][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)
|
|
|
|
|
|
def init_cache(log=logging.getLogger('qube.storage.lvm')):
|
|
cmd = ['lvs', '--noheadings', '-o',
|
|
'vg_name,pool_lv,name,lv_size,data_percent,lv_attr',
|
|
'--units', 'b', '--separator', ',']
|
|
if os.getuid() != 0:
|
|
cmd.insert(0, 'sudo')
|
|
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
close_fds=True)
|
|
out, err = p.communicate()
|
|
return_code = p.returncode
|
|
if return_code == 0 and err:
|
|
log.warning(err)
|
|
elif return_code != 0:
|
|
raise qubes.storage.StoragePoolException(err)
|
|
|
|
result = {}
|
|
|
|
for line in out.splitlines():
|
|
line = line.strip()
|
|
pool_name, pool_lv, name, size, usage_percent, attr = line.split(',', 5)
|
|
if '' in [pool_name, pool_lv, name, size, usage_percent]:
|
|
continue
|
|
name = pool_name + "/" + name
|
|
size = int(size[:-1])
|
|
usage = int(size / 100 * float(usage_percent))
|
|
result[name] = {'size': size, 'usage': usage, 'pool_lv': pool_lv,
|
|
'attr': attr}
|
|
|
|
return result
|
|
|
|
|
|
size_cache = init_cache()
|
|
|
|
class ThinVolume(qubes.storage.Volume):
|
|
''' Default LVM thin volume implementation
|
|
''' # pylint: disable=too-few-public-methods
|
|
|
|
|
|
def __init__(self, volume_group, size=0, **kwargs):
|
|
self.volume_group = volume_group
|
|
super(ThinVolume, self).__init__(size=size, **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'
|
|
|
|
self._size = size
|
|
|
|
@property
|
|
def revisions(self):
|
|
path = self.path + '-back'
|
|
if os.path.exists(path):
|
|
seconds = os.path.getctime(path)
|
|
iso_date = qubes.storage.isodate(seconds).split('.', 1)[0]
|
|
return {iso_date: path}
|
|
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
|
|
|
|
@property
|
|
def size(self):
|
|
try:
|
|
return qubes.storage.lvm.size_cache[self.vid]['size']
|
|
except KeyError:
|
|
return self._size
|
|
|
|
@size.setter
|
|
def size(self, _):
|
|
raise qubes.storage.StoragePoolException(
|
|
"You shouldn't use lvm size setter")
|
|
|
|
|
|
@property
|
|
def usage(self): # lvm thin usage always returns at least the same usage as
|
|
# the parent
|
|
try:
|
|
return qubes.storage.lvm.size_cache[self.vid]['usage']
|
|
except KeyError:
|
|
return 0
|
|
|
|
|
|
def pool_exists(pool_id):
|
|
''' Return true if pool exists '''
|
|
try:
|
|
vol_info = size_cache[pool_id]
|
|
return vol_info['attr'][0] == 't'
|
|
except KeyError:
|
|
return False
|
|
|
|
|
|
def qubes_lvm(cmd, log=logging.getLogger('qube.storage.lvm')):
|
|
''' Call :program:`lvm` to execute an LVM operation '''
|
|
action = cmd[0]
|
|
if action == 'remove':
|
|
lvm_cmd = ['lvremove', '-f', cmd[1]]
|
|
elif action == 'clone':
|
|
lvm_cmd = ['lvcreate', '-kn', '-ay', '-s', cmd[1], '-n', cmd[2]]
|
|
elif action == 'create':
|
|
lvm_cmd = ['lvcreate', '-T', cmd[1], '-kn', '-ay', '-n', cmd[2], '-V',
|
|
str(cmd[3]) + 'B']
|
|
elif action == 'extend':
|
|
size = int(cmd[2]) / (1000 * 1000)
|
|
lvm_cmd = ["lvextend", "-L%s" % size, cmd[1]]
|
|
else:
|
|
raise NotImplementedError('unsupported action: ' + action)
|
|
if os.getuid() != 0:
|
|
cmd = ['sudo', 'lvm'] + lvm_cmd
|
|
else:
|
|
cmd = ['lvm'] + lvm_cmd
|
|
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
close_fds=True)
|
|
out, err = p.communicate()
|
|
return_code = p.returncode
|
|
if out:
|
|
log.debug(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
|
|
|
|
|
|
def reset_cache():
|
|
qubes.storage.lvm.size_cache = init_cache()
|