123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484 |
- #
- # The Qubes OS Project, https://www.qubes-os.org/
- #
- # Copyright (C) 2018 Rusty Bird <rustybird@net-c.com>
- #
- # This library is free software; you can redistribute it and/or
- # modify it under the terms of the GNU Lesser General Public
- # License as published by the Free Software Foundation; either
- # version 2.1 of the License, or (at your option) any later version.
- #
- # This library 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
- # Lesser General Public License for more details.
- #
- # You should have received a copy of the GNU Lesser General Public
- # License along with this library; if not, see <https://www.gnu.org/licenses/>.
- #
- ''' Driver for handling VM images as files, without any device-mapper
- involvement. A reflink-capable filesystem is strongly recommended,
- but not required.
- '''
- import asyncio
- import collections
- import errno
- import fcntl
- import functools
- import glob
- import logging
- import os
- import subprocess
- import tempfile
- import threading
- from contextlib import contextmanager, suppress
- import qubes.storage
- FICLONE = 1074041865 # defined in <linux/fs.h>, assuming sizeof(int)==4
- LOOP_SET_CAPACITY = 0x4C07 # defined in <linux/loop.h>
- LOGGER = logging.getLogger('qubes.storage.reflink')
- def _coroutinized(function):
- ''' Decorator transforming a synchronous function into a coroutine
- that runs the function in the event loop's thread-based
- default executor.
- '''
- @asyncio.coroutine
- @functools.wraps(function)
- def wrapper(*args, **kwargs):
- return (yield from asyncio.get_event_loop().run_in_executor(
- None, functools.partial(function, *args, **kwargs)))
- return wrapper
- class ReflinkPool(qubes.storage.Pool):
- driver = 'file-reflink'
- _known_dir_path_prefixes = ['appvms', 'vm-templates']
- def __init__(self, *, name, revisions_to_keep=1,
- dir_path, setup_check=True):
- super().__init__(name=name, revisions_to_keep=revisions_to_keep)
- self._setup_check = qubes.property.bool(None, None, setup_check)
- self._volumes = {}
- self.dir_path = os.path.abspath(dir_path)
- @_coroutinized
- def setup(self):
- created = _make_dir(self.dir_path)
- if self._setup_check and not is_supported(self.dir_path):
- if created:
- _remove_empty_dir(self.dir_path)
- raise qubes.storage.StoragePoolException(
- 'The filesystem for {!r} does not support reflinks. If you'
- ' can live with VM startup delays and wasted disk space, pass'
- ' the "setup_check=False" option.'.format(self.dir_path))
- for dir_path_prefix in self._known_dir_path_prefixes:
- _make_dir(os.path.join(self.dir_path, dir_path_prefix))
- return self
- def init_volume(self, vm, volume_config):
- # Fail closed on any strange VM dir_path_prefix, just in case
- # /etc/udev/rules.d/00-qubes-ignore-devices.rules needs update
- assert vm.dir_path_prefix in self._known_dir_path_prefixes, \
- 'Unknown dir_path_prefix {!r}'.format(vm.dir_path_prefix)
- volume_config['pool'] = self
- if 'revisions_to_keep' not in volume_config:
- volume_config['revisions_to_keep'] = self.revisions_to_keep
- if 'vid' not in volume_config:
- volume_config['vid'] = os.path.join(vm.dir_path_prefix, vm.name,
- volume_config['name'])
- volume = ReflinkVolume(**volume_config)
- self._volumes[volume_config['vid']] = volume
- return volume
- def list_volumes(self):
- return list(self._volumes.values())
- def get_volume(self, vid):
- return self._volumes[vid]
- def destroy(self):
- pass
- @property
- def config(self):
- return {
- 'name': self.name,
- 'dir_path': self.dir_path,
- 'driver': ReflinkPool.driver,
- 'revisions_to_keep': self.revisions_to_keep
- }
- @property
- def size(self):
- statvfs = os.statvfs(self.dir_path)
- return statvfs.f_frsize * statvfs.f_blocks
- @property
- def usage(self):
- statvfs = os.statvfs(self.dir_path)
- return statvfs.f_frsize * (statvfs.f_blocks - statvfs.f_bfree)
- def included_in(self, app):
- ''' Check if there is pool containing this one - either as a
- filesystem or its LVM volume'''
- return qubes.storage.search_pool_containing_dir(
- [pool for pool in app.pools.values() if pool is not self],
- self.dir_path)
- def _locked(method):
- ''' Decorator transforming a synchronous volume method to run
- under the volume lock.
- '''
- @functools.wraps(method)
- def wrapper(self, *args, **kwargs):
- with self._lock: # pylint: disable=protected-access
- return method(self, *args, **kwargs)
- return wrapper
- class ReflinkVolume(qubes.storage.Volume):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self._lock = threading.Lock()
- self._path_vid = os.path.join(self.pool.dir_path, self.vid)
- self._path_clean = self._path_vid + '.img'
- self._path_dirty = self._path_vid + '-dirty.img'
- self._path_import = self._path_vid + '-import.img'
- self.path = self._path_dirty
- @_coroutinized
- @_locked
- def create(self):
- self._remove_all_images()
- if self.save_on_stop and not self.snap_on_start:
- _create_sparse_file(self._path_clean, self._size)
- return self
- @_coroutinized
- def verify(self):
- if self.snap_on_start:
- img = self.source._path_clean # pylint: disable=protected-access
- elif self.save_on_stop:
- img = self._path_clean
- else:
- img = None
- if img is None or os.path.exists(img):
- return True
- raise qubes.storage.StoragePoolException(
- 'Missing image file {!r} for volume {}'.format(img, self.vid))
- @_coroutinized
- @_locked
- def remove(self):
- self.pool._volumes.pop(self, None) # pylint: disable=protected-access
- self._remove_all_images()
- _remove_empty_dir(os.path.dirname(self._path_vid))
- return self
- def _remove_all_images(self):
- self._remove_incomplete_images()
- self._prune_revisions(keep=0)
- _remove_file(self._path_clean)
- _remove_file(self._path_dirty)
- def _remove_incomplete_images(self):
- for tmp in glob.iglob(glob.escape(self._path_vid) + '*.img*~*'):
- _remove_file(tmp)
- _remove_file(self._path_import)
- def is_outdated(self):
- if self.snap_on_start:
- with suppress(FileNotFoundError):
- # pylint: disable=protected-access
- return (os.path.getmtime(self.source._path_clean) >
- os.path.getmtime(self._path_clean))
- return False
- def is_dirty(self):
- return self.save_on_stop and os.path.exists(self._path_dirty)
- @_coroutinized
- @_locked
- def start(self):
- self._remove_incomplete_images()
- if not self.is_dirty():
- if self.snap_on_start:
- # pylint: disable=protected-access
- _copy_file(self.source._path_clean, self._path_clean)
- if self.snap_on_start or self.save_on_stop:
- _copy_file(self._path_clean, self._path_dirty)
- else:
- # Preferably use the size of a leftover image, in case
- # the volume was previously resized - but then a crash
- # prevented qubes.xml serialization of the new size.
- _create_sparse_file(self._path_dirty, self._get_size())
- return self
- @_coroutinized
- @_locked
- def stop(self):
- if self.save_on_stop:
- self._commit(self._path_dirty)
- else:
- if not self.snap_on_start:
- self._get_size() # preserve manual resize of image
- _remove_file(self._path_dirty)
- _remove_file(self._path_clean)
- return self
- def _commit(self, path_from):
- self._add_revision()
- self._prune_revisions()
- _fsync_path(path_from)
- _rename_file(path_from, self._path_clean)
- def _add_revision(self):
- if self.revisions_to_keep == 0:
- return
- ctime = os.path.getctime(self._path_clean)
- timestamp = qubes.storage.isodate(int(ctime))
- _copy_file(self._path_clean,
- self._path_revision(self._next_revision_number, timestamp))
- def _prune_revisions(self, keep=None):
- if keep is None:
- keep = self.revisions_to_keep
- # pylint: disable=invalid-unary-operand-type
- for number, timestamp in list(self.revisions.items())[:-keep or None]:
- _remove_file(self._path_revision(number, timestamp))
- @_coroutinized
- @_locked
- def revert(self, revision=None):
- if self.is_dirty():
- raise qubes.storage.StoragePoolException(
- 'Cannot revert: {} is not cleanly stopped'.format(self.vid))
- if revision is None:
- number, timestamp = list(self.revisions.items())[-1]
- else:
- number, timestamp = revision, None
- path_revision = self._path_revision(number, timestamp)
- self._add_revision()
- _rename_file(path_revision, self._path_clean)
- return self
- @_coroutinized
- @_locked
- def resize(self, size):
- ''' Resize a read-write volume; notify any corresponding loop
- devices of the size change.
- '''
- if not self.rw:
- raise qubes.storage.StoragePoolException(
- 'Cannot resize: {} is read-only'.format(self.vid))
- for path in (self._path_dirty, self._path_clean):
- with suppress(FileNotFoundError):
- _resize_file(path, size)
- break
- self._size = size
- if path == self._path_dirty:
- _update_loopdev_sizes(self._path_dirty)
- return self
- def export(self):
- if not self.save_on_stop:
- raise NotImplementedError(
- 'Cannot export: {} is not save_on_stop'.format(self.vid))
- return self._path_clean
- @_coroutinized
- @_locked
- def import_data(self, size):
- if not self.save_on_stop:
- raise NotImplementedError(
- 'Cannot import_data: {} is not save_on_stop'.format(self.vid))
- _create_sparse_file(self._path_import, size)
- return self._path_import
- def _import_data_end(self, success):
- (self._commit if success else _remove_file)(self._path_import)
- return self
- import_data_end = _coroutinized(_locked(_import_data_end))
- @_coroutinized
- @_locked
- def import_volume(self, src_volume):
- if self.save_on_stop:
- try:
- success = False
- _copy_file(src_volume.export(), self._path_import)
- success = True
- finally:
- self._import_data_end(success)
- return self
- def _path_revision(self, number, timestamp=None):
- if timestamp is None:
- timestamp = self.revisions[number]
- return self._path_clean + '.' + number + '@' + timestamp + 'Z'
- @property
- def _next_revision_number(self):
- numbers = self.revisions.keys()
- if numbers:
- return str(int(list(numbers)[-1]) + 1)
- return '1'
- @property
- def revisions(self):
- prefix = self._path_clean + '.'
- paths = glob.iglob(glob.escape(prefix) + '*@*Z')
- items = (path[len(prefix):-1].split('@') for path in paths)
- return collections.OrderedDict(sorted(items,
- key=lambda item: int(item[0])))
- def _get_size(self):
- for path in (self._path_dirty, self._path_clean):
- with suppress(FileNotFoundError):
- self._size = os.path.getsize(path)
- break
- return self._size
- size = property(_locked(_get_size))
- @property
- def usage(self):
- ''' Return volume disk usage from the VM's perspective. It is
- usually much lower from the host's perspective due to CoW.
- '''
- for path in (self._path_dirty, self._path_clean):
- with suppress(FileNotFoundError):
- return os.stat(path).st_blocks * 512
- return 0
- @contextmanager
- def _replace_file(dst):
- ''' Yield a tempfile whose name starts with dst, creating the last
- directory component if necessary. If the block does not raise
- an exception, safely rename the tempfile to dst.
- '''
- tmp_dir, prefix = os.path.split(dst + '~')
- _make_dir(tmp_dir)
- tmp = tempfile.NamedTemporaryFile(dir=tmp_dir, prefix=prefix, delete=False)
- try:
- yield tmp
- tmp.flush()
- os.fsync(tmp.fileno())
- tmp.close()
- _rename_file(tmp.name, dst)
- except:
- tmp.close()
- _remove_file(tmp.name)
- raise
- def _fsync_path(path):
- fd = os.open(path, os.O_RDONLY) # works for a file or a directory
- try:
- os.fsync(fd)
- finally:
- os.close(fd)
- def _make_dir(path):
- ''' mkdir path, ignoring FileExistsError; return whether we
- created it.
- '''
- with suppress(FileExistsError):
- os.mkdir(path)
- _fsync_path(os.path.dirname(path))
- LOGGER.info('Created directory: %s', path)
- return True
- return False
- def _remove_file(path):
- with suppress(FileNotFoundError):
- os.remove(path)
- _fsync_path(os.path.dirname(path))
- LOGGER.info('Removed file: %s', path)
- def _remove_empty_dir(path):
- try:
- os.rmdir(path)
- _fsync_path(os.path.dirname(path))
- LOGGER.info('Removed empty directory: %s', path)
- except OSError as ex:
- if ex.errno not in (errno.ENOENT, errno.ENOTEMPTY):
- raise
- def _rename_file(src, dst):
- os.rename(src, dst)
- dst_dir = os.path.dirname(dst)
- src_dir = os.path.dirname(src)
- _fsync_path(dst_dir)
- if src_dir != dst_dir:
- _fsync_path(src_dir)
- LOGGER.info('Renamed file: %s -> %s', src, dst)
- def _resize_file(path, size):
- ''' Resize an existing file. '''
- with open(path, 'rb+') as file:
- file.truncate(size)
- os.fsync(file.fileno())
- def _create_sparse_file(path, size):
- ''' Create an empty sparse file. '''
- with _replace_file(path) as tmp:
- tmp.truncate(size)
- LOGGER.info('Created sparse file: %s', tmp.name)
- def _update_loopdev_sizes(img):
- ''' Resolve img; update the size of loop devices backed by it. '''
- needle = os.fsencode(os.path.realpath(img)) + b'\n'
- for sys_path in glob.iglob('/sys/block/loop[0-9]*/loop/backing_file'):
- try:
- with open(sys_path, 'rb') as sys_io:
- if sys_io.read() != needle:
- continue
- except FileNotFoundError:
- continue
- with open('/dev/' + sys_path.split('/')[3], 'rb') as dev_io:
- fcntl.ioctl(dev_io.fileno(), LOOP_SET_CAPACITY)
- def _attempt_ficlone(src, dst):
- try:
- fcntl.ioctl(dst.fileno(), FICLONE, src.fileno())
- return True
- except OSError as ex:
- if ex.errno not in (errno.EBADF, errno.EINVAL,
- errno.EOPNOTSUPP, errno.EXDEV):
- raise
- return False
- def _copy_file(src, dst):
- ''' Copy src to dst as a reflink if possible, sparse if not. '''
- with _replace_file(dst) as tmp_io:
- with open(src, 'rb') as src_io:
- if _attempt_ficlone(src_io, tmp_io):
- LOGGER.info('Reflinked file: %s -> %s', src, tmp_io.name)
- return True
- LOGGER.info('Copying file: %s -> %s', src, tmp_io.name)
- cmd = 'cp', '--sparse=always', src, tmp_io.name
- p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
- check=False)
- if p.returncode != 0:
- raise qubes.storage.StoragePoolException(str(p))
- return False
- def is_supported(dst_dir, src_dir=None):
- ''' Return whether destination directory supports reflink copies
- from source directory. (A temporary file is created in each
- directory, using O_TMPFILE if possible.)
- '''
- if src_dir is None:
- src_dir = dst_dir
- with tempfile.TemporaryFile(dir=src_dir) as src, \
- tempfile.TemporaryFile(dir=dst_dir) as dst:
- src.write(b'foo') # don't let any fs get clever with empty files
- return _attempt_ficlone(src, dst)
|