reflink.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. #
  2. # The Qubes OS Project, https://www.qubes-os.org/
  3. #
  4. # Copyright (C) 2018 Rusty Bird <rustybird@net-c.com>
  5. #
  6. # This library is free software; you can redistribute it and/or
  7. # modify it under the terms of the GNU Lesser General Public
  8. # License as published by the Free Software Foundation; either
  9. # version 2.1 of the License, or (at your option) any later version.
  10. #
  11. # This library is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. # Lesser General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU Lesser General Public
  17. # License along with this library; if not, see <https://www.gnu.org/licenses/>.
  18. #
  19. ''' Driver for handling VM images as files, without any device-mapper
  20. involvement. A reflink-capable filesystem is strongly recommended,
  21. but not required.
  22. '''
  23. import asyncio
  24. import collections
  25. import errno
  26. import fcntl
  27. import functools
  28. import glob
  29. import logging
  30. import os
  31. import subprocess
  32. import tempfile
  33. import platform
  34. import sys
  35. from contextlib import contextmanager, suppress
  36. import qubes.storage
  37. import qubes.utils
  38. HOST_MACHINE = platform.machine()
  39. if HOST_MACHINE == "x86_64":
  40. FICLONE = 0x40049409 # defined in <linux/fs.h>
  41. elif HOST_MACHINE == "ppc64le":
  42. FICLONE = 0x80049409
  43. else:
  44. print("Missing IOCTL definitions for platform {}".format(HOST_MACHINE))
  45. sys.exit(1)
  46. LOOP_SET_CAPACITY = 0x4C07 # defined in <linux/loop.h>
  47. LOGGER = logging.getLogger('qubes.storage.reflink')
  48. def _coroutinized(function):
  49. ''' Wrap a synchronous function in a coroutine that runs the
  50. function via the event loop's ThreadPool-based default
  51. executor.
  52. '''
  53. @asyncio.coroutine
  54. @functools.wraps(function)
  55. def wrapper(*args, **kwargs):
  56. return (yield from asyncio.get_event_loop().run_in_executor(
  57. None, functools.partial(function, *args, **kwargs)))
  58. return wrapper
  59. class ReflinkPool(qubes.storage.Pool):
  60. driver = 'file-reflink'
  61. _known_dir_path_prefixes = ['appvms', 'vm-templates']
  62. def __init__(self, *, name, revisions_to_keep=1,
  63. dir_path, setup_check=True):
  64. super().__init__(name=name, revisions_to_keep=revisions_to_keep)
  65. self._setup_check = qubes.property.bool(None, None, setup_check)
  66. self._volumes = {}
  67. self.dir_path = os.path.abspath(dir_path)
  68. @_coroutinized
  69. def setup(self):
  70. created = _make_dir(self.dir_path)
  71. if self._setup_check and not is_supported(self.dir_path):
  72. if created:
  73. _remove_empty_dir(self.dir_path)
  74. raise qubes.storage.StoragePoolException(
  75. 'The filesystem for {!r} does not support reflinks. If you'
  76. ' can live with VM startup delays and wasted disk space, pass'
  77. ' the "setup_check=False" option.'.format(self.dir_path))
  78. for dir_path_prefix in self._known_dir_path_prefixes:
  79. _make_dir(os.path.join(self.dir_path, dir_path_prefix))
  80. return self
  81. def init_volume(self, vm, volume_config):
  82. # Fail closed on any strange VM dir_path_prefix, just in case
  83. # /etc/udev/rules.d/00-qubes-ignore-devices.rules needs update
  84. assert vm.dir_path_prefix in self._known_dir_path_prefixes, \
  85. 'Unknown dir_path_prefix {!r}'.format(vm.dir_path_prefix)
  86. volume_config['pool'] = self
  87. if 'revisions_to_keep' not in volume_config:
  88. volume_config['revisions_to_keep'] = self.revisions_to_keep
  89. if 'vid' not in volume_config:
  90. volume_config['vid'] = os.path.join(vm.dir_path_prefix, vm.name,
  91. volume_config['name'])
  92. volume = ReflinkVolume(**volume_config)
  93. self._volumes[volume_config['vid']] = volume
  94. return volume
  95. def list_volumes(self):
  96. return list(self._volumes.values())
  97. def get_volume(self, vid):
  98. return self._volumes[vid]
  99. def destroy(self):
  100. pass
  101. @property
  102. def config(self):
  103. return {
  104. 'name': self.name,
  105. 'dir_path': self.dir_path,
  106. 'driver': ReflinkPool.driver,
  107. 'revisions_to_keep': self.revisions_to_keep
  108. }
  109. @property
  110. def size(self):
  111. statvfs = os.statvfs(self.dir_path)
  112. return statvfs.f_frsize * statvfs.f_blocks
  113. @property
  114. def usage(self):
  115. statvfs = os.statvfs(self.dir_path)
  116. return statvfs.f_frsize * (statvfs.f_blocks - statvfs.f_bfree)
  117. def included_in(self, app):
  118. ''' Check if there is pool containing this one - either as a
  119. filesystem or its LVM volume'''
  120. return qubes.storage.search_pool_containing_dir(
  121. [pool for pool in app.pools.values() if pool is not self],
  122. self.dir_path)
  123. class ReflinkVolume(qubes.storage.Volume):
  124. def __init__(self, *args, **kwargs):
  125. super().__init__(*args, **kwargs)
  126. self._path_vid = os.path.join(self.pool.dir_path, self.vid)
  127. self._path_clean = self._path_vid + '.img'
  128. self._path_dirty = self._path_vid + '-dirty.img'
  129. self._path_import = self._path_vid + '-import.img'
  130. self.path = self._path_dirty
  131. @qubes.storage.Volume.locked
  132. @_coroutinized
  133. def create(self):
  134. self._remove_all_images()
  135. if self.save_on_stop and not self.snap_on_start:
  136. _create_sparse_file(self._path_clean, self._size)
  137. return self
  138. @_coroutinized
  139. def verify(self):
  140. if self.snap_on_start:
  141. img = self.source._path_clean # pylint: disable=protected-access
  142. elif self.save_on_stop:
  143. img = self._path_clean
  144. else:
  145. img = None
  146. if img is None or os.path.exists(img):
  147. return True
  148. raise qubes.storage.StoragePoolException(
  149. 'Missing image file {!r} for volume {}'.format(img, self.vid))
  150. @qubes.storage.Volume.locked
  151. @_coroutinized
  152. def remove(self):
  153. self.pool._volumes.pop(self, None) # pylint: disable=protected-access
  154. self._remove_all_images()
  155. _remove_empty_dir(os.path.dirname(self._path_vid))
  156. return self
  157. def _remove_all_images(self):
  158. self._remove_incomplete_images()
  159. self._prune_revisions(keep=0)
  160. _remove_file(self._path_clean)
  161. _remove_file(self._path_dirty)
  162. def _remove_incomplete_images(self):
  163. for tmp in glob.iglob(glob.escape(self._path_vid) + '*.img*~*'):
  164. _remove_file(tmp)
  165. _remove_file(self._path_import)
  166. def is_outdated(self):
  167. if self.snap_on_start:
  168. with suppress(FileNotFoundError):
  169. # pylint: disable=protected-access
  170. return (os.path.getmtime(self.source._path_clean) >
  171. os.path.getmtime(self._path_clean))
  172. return False
  173. def is_dirty(self):
  174. return self.save_on_stop and os.path.exists(self._path_dirty)
  175. @qubes.storage.Volume.locked
  176. @_coroutinized
  177. def start(self):
  178. self._remove_incomplete_images()
  179. if not self.is_dirty():
  180. if self.snap_on_start:
  181. # pylint: disable=protected-access
  182. _copy_file(self.source._path_clean, self._path_clean)
  183. if self.snap_on_start or self.save_on_stop:
  184. _copy_file(self._path_clean, self._path_dirty)
  185. else:
  186. # Preferably use the size of a leftover image, in case
  187. # the volume was previously resized - but then a crash
  188. # prevented qubes.xml serialization of the new size.
  189. _create_sparse_file(self._path_dirty, self.size)
  190. return self
  191. @qubes.storage.Volume.locked
  192. @_coroutinized
  193. def stop(self):
  194. if self.save_on_stop:
  195. self._commit(self._path_dirty)
  196. else:
  197. if not self.snap_on_start:
  198. self._size = self.size # preserve manual resize of image
  199. _remove_file(self._path_dirty)
  200. _remove_file(self._path_clean)
  201. return self
  202. def _commit(self, path_from):
  203. self._add_revision()
  204. self._prune_revisions()
  205. _fsync_path(path_from)
  206. _rename_file(path_from, self._path_clean)
  207. def _add_revision(self):
  208. if self.revisions_to_keep == 0:
  209. return
  210. ctime = os.path.getctime(self._path_clean)
  211. timestamp = qubes.storage.isodate(int(ctime))
  212. _copy_file(self._path_clean,
  213. self._path_revision(self._next_revision_number, timestamp))
  214. def _prune_revisions(self, keep=None):
  215. if keep is None:
  216. keep = self.revisions_to_keep
  217. # pylint: disable=invalid-unary-operand-type
  218. for number, timestamp in list(self.revisions.items())[:-keep or None]:
  219. _remove_file(self._path_revision(number, timestamp))
  220. @qubes.storage.Volume.locked
  221. @_coroutinized
  222. def revert(self, revision=None):
  223. if self.is_dirty():
  224. raise qubes.storage.StoragePoolException(
  225. 'Cannot revert: {} is not cleanly stopped'.format(self.vid))
  226. if revision is None:
  227. number, timestamp = list(self.revisions.items())[-1]
  228. else:
  229. number, timestamp = revision, None
  230. path_revision = self._path_revision(number, timestamp)
  231. self._add_revision()
  232. _rename_file(path_revision, self._path_clean)
  233. return self
  234. @qubes.storage.Volume.locked
  235. @_coroutinized
  236. def resize(self, size):
  237. ''' Resize a read-write volume; notify any corresponding loop
  238. devices of the size change.
  239. '''
  240. if not self.rw:
  241. raise qubes.storage.StoragePoolException(
  242. 'Cannot resize: {} is read-only'.format(self.vid))
  243. for path in (self._path_dirty, self._path_clean):
  244. with suppress(FileNotFoundError):
  245. _resize_file(path, size)
  246. break
  247. self._size = size
  248. if path == self._path_dirty:
  249. _update_loopdev_sizes(self._path_dirty)
  250. return self
  251. def export(self):
  252. if not self.save_on_stop:
  253. raise NotImplementedError(
  254. 'Cannot export: {} is not save_on_stop'.format(self.vid))
  255. return self._path_clean
  256. @qubes.storage.Volume.locked
  257. @_coroutinized
  258. def import_data(self, size):
  259. if not self.save_on_stop:
  260. raise NotImplementedError(
  261. 'Cannot import_data: {} is not save_on_stop'.format(self.vid))
  262. _create_sparse_file(self._path_import, size)
  263. return self._path_import
  264. def _import_data_end(self, success):
  265. (self._commit if success else _remove_file)(self._path_import)
  266. return self
  267. import_data_end = qubes.storage.Volume.locked(_coroutinized(
  268. _import_data_end))
  269. @qubes.storage.Volume.locked
  270. @asyncio.coroutine
  271. def import_volume(self, src_volume):
  272. if self.save_on_stop:
  273. try:
  274. success = False
  275. src_path = yield from qubes.utils.coro_maybe(
  276. src_volume.export())
  277. try:
  278. yield from _coroutinized(_copy_file)(
  279. src_path, self._path_import)
  280. finally:
  281. yield from qubes.utils.coro_maybe(
  282. src_volume.export_end(src_path))
  283. success = True
  284. finally:
  285. yield from _coroutinized(self._import_data_end)(success)
  286. return self
  287. def _path_revision(self, number, timestamp=None):
  288. if timestamp is None:
  289. timestamp = self.revisions[number]
  290. return self._path_clean + '.' + number + '@' + timestamp + 'Z'
  291. @property
  292. def _next_revision_number(self):
  293. numbers = self.revisions.keys()
  294. if numbers:
  295. return str(int(list(numbers)[-1]) + 1)
  296. return '1'
  297. @property
  298. def revisions(self):
  299. prefix = self._path_clean + '.'
  300. paths = glob.iglob(glob.escape(prefix) + '*@*Z')
  301. items = (path[len(prefix):-1].split('@') for path in paths)
  302. return collections.OrderedDict(sorted(items,
  303. key=lambda item: int(item[0])))
  304. @property
  305. def size(self):
  306. for path in (self._path_dirty, self._path_clean):
  307. with suppress(FileNotFoundError):
  308. return os.path.getsize(path)
  309. return self._size
  310. @property
  311. def usage(self):
  312. ''' Return volume disk usage from the VM's perspective. It is
  313. usually much lower from the host's perspective due to CoW.
  314. '''
  315. for path in (self._path_dirty, self._path_clean):
  316. with suppress(FileNotFoundError):
  317. return os.stat(path).st_blocks * 512
  318. return 0
  319. @contextmanager
  320. def _replace_file(dst):
  321. ''' Yield a tempfile whose name starts with dst, creating the last
  322. directory component if necessary. If the block does not raise
  323. an exception, safely rename the tempfile to dst.
  324. '''
  325. tmp_dir, prefix = os.path.split(dst + '~')
  326. _make_dir(tmp_dir)
  327. tmp = tempfile.NamedTemporaryFile(dir=tmp_dir, prefix=prefix, delete=False)
  328. try:
  329. yield tmp
  330. tmp.flush()
  331. os.fsync(tmp.fileno())
  332. tmp.close()
  333. _rename_file(tmp.name, dst)
  334. except:
  335. tmp.close()
  336. _remove_file(tmp.name)
  337. raise
  338. def _fsync_path(path):
  339. fd = os.open(path, os.O_RDONLY) # works for a file or a directory
  340. try:
  341. os.fsync(fd)
  342. finally:
  343. os.close(fd)
  344. def _make_dir(path):
  345. ''' mkdir path, ignoring FileExistsError; return whether we
  346. created it.
  347. '''
  348. with suppress(FileExistsError):
  349. os.mkdir(path)
  350. _fsync_path(os.path.dirname(path))
  351. LOGGER.info('Created directory: %s', path)
  352. return True
  353. return False
  354. def _remove_file(path):
  355. with suppress(FileNotFoundError):
  356. os.remove(path)
  357. _fsync_path(os.path.dirname(path))
  358. LOGGER.info('Removed file: %s', path)
  359. def _remove_empty_dir(path):
  360. try:
  361. os.rmdir(path)
  362. _fsync_path(os.path.dirname(path))
  363. LOGGER.info('Removed empty directory: %s', path)
  364. except OSError as ex:
  365. if ex.errno not in (errno.ENOENT, errno.ENOTEMPTY):
  366. raise
  367. def _rename_file(src, dst):
  368. os.rename(src, dst)
  369. dst_dir = os.path.dirname(dst)
  370. src_dir = os.path.dirname(src)
  371. _fsync_path(dst_dir)
  372. if src_dir != dst_dir:
  373. _fsync_path(src_dir)
  374. LOGGER.info('Renamed file: %s -> %s', src, dst)
  375. def _resize_file(path, size):
  376. ''' Resize an existing file. '''
  377. with open(path, 'rb+') as file:
  378. file.truncate(size)
  379. os.fsync(file.fileno())
  380. def _create_sparse_file(path, size):
  381. ''' Create an empty sparse file. '''
  382. with _replace_file(path) as tmp:
  383. tmp.truncate(size)
  384. LOGGER.info('Created sparse file: %s', tmp.name)
  385. def _update_loopdev_sizes(img):
  386. ''' Resolve img; update the size of loop devices backed by it. '''
  387. needle = os.fsencode(os.path.realpath(img)) + b'\n'
  388. for sys_path in glob.iglob('/sys/block/loop[0-9]*/loop/backing_file'):
  389. try:
  390. with open(sys_path, 'rb') as sys_io:
  391. if sys_io.read() != needle:
  392. continue
  393. except FileNotFoundError:
  394. continue
  395. with open('/dev/' + sys_path.split('/')[3], 'rb') as dev_io:
  396. fcntl.ioctl(dev_io.fileno(), LOOP_SET_CAPACITY)
  397. def _attempt_ficlone(src, dst):
  398. try:
  399. fcntl.ioctl(dst.fileno(), FICLONE, src.fileno())
  400. return True
  401. except OSError as ex:
  402. if ex.errno not in (errno.EBADF, errno.EINVAL,
  403. errno.EOPNOTSUPP, errno.EXDEV):
  404. raise
  405. return False
  406. def _copy_file(src, dst):
  407. ''' Copy src to dst as a reflink if possible, sparse if not. '''
  408. with _replace_file(dst) as tmp_io:
  409. with open(src, 'rb') as src_io:
  410. if _attempt_ficlone(src_io, tmp_io):
  411. LOGGER.info('Reflinked file: %s -> %s', src, tmp_io.name)
  412. return True
  413. LOGGER.info('Copying file: %s -> %s', src, tmp_io.name)
  414. cmd = 'cp', '--sparse=always', src, tmp_io.name
  415. p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
  416. check=False)
  417. if p.returncode != 0:
  418. raise qubes.storage.StoragePoolException(str(p))
  419. return False
  420. def is_supported(dst_dir, src_dir=None):
  421. ''' Return whether destination directory supports reflink copies
  422. from source directory. (A temporary file is created in each
  423. directory, using O_TMPFILE if possible.)
  424. '''
  425. if src_dir is None:
  426. src_dir = dst_dir
  427. with tempfile.TemporaryFile(dir=src_dir) as src, \
  428. tempfile.TemporaryFile(dir=dst_dir) as dst:
  429. src.write(b'foo') # don't let any fs get clever with empty files
  430. return _attempt_ficlone(src, dst)