reflink.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  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 collections
  24. import errno
  25. import fcntl
  26. import glob
  27. import logging
  28. import os
  29. import re
  30. import subprocess
  31. import tempfile
  32. from contextlib import contextmanager, suppress
  33. import qubes.storage
  34. BLKSIZE = 512
  35. FICLONE = 1074041865 # see ioctl_ficlone manpage
  36. LOGGER = logging.getLogger('qubes.storage.reflink')
  37. class ReflinkPool(qubes.storage.Pool):
  38. driver = 'file-reflink'
  39. _known_dir_path_prefixes = ['appvms', 'vm-templates']
  40. def __init__(self, dir_path, setup_check='yes', revisions_to_keep=1,
  41. **kwargs):
  42. super().__init__(revisions_to_keep=revisions_to_keep, **kwargs)
  43. self._volumes = {}
  44. self.dir_path = os.path.abspath(dir_path)
  45. self.setup_check = qubes.property.bool(None, None, setup_check)
  46. def setup(self):
  47. created = _make_dir(self.dir_path)
  48. if self.setup_check and not is_reflink_supported(self.dir_path):
  49. if created:
  50. _remove_empty_dir(self.dir_path)
  51. raise qubes.storage.StoragePoolException(
  52. 'The filesystem for {!r} does not support reflinks. If you'
  53. ' can live with VM startup delays and wasted disk space, pass'
  54. ' the "setup_check=no" option.'.format(self.dir_path))
  55. for dir_path_prefix in self._known_dir_path_prefixes:
  56. _make_dir(os.path.join(self.dir_path, dir_path_prefix))
  57. return self
  58. def init_volume(self, vm, volume_config):
  59. # Fail closed on any strange VM dir_path_prefix, just in case
  60. # /etc/udev/rules/00-qubes-ignore-devices.rules needs updating
  61. assert vm.dir_path_prefix in self._known_dir_path_prefixes, \
  62. 'Unknown dir_path_prefix {!r}'.format(vm.dir_path_prefix)
  63. volume_config['pool'] = self
  64. if 'revisions_to_keep' not in volume_config:
  65. volume_config['revisions_to_keep'] = self.revisions_to_keep
  66. if 'vid' not in volume_config:
  67. volume_config['vid'] = os.path.join(vm.dir_path_prefix, vm.name,
  68. volume_config['name'])
  69. volume = ReflinkVolume(**volume_config)
  70. self._volumes[volume_config['vid']] = volume
  71. return volume
  72. def list_volumes(self):
  73. return list(self._volumes.values())
  74. def get_volume(self, vid):
  75. return self._volumes[vid]
  76. def destroy(self):
  77. pass
  78. @property
  79. def config(self):
  80. return {
  81. 'name': self.name,
  82. 'dir_path': self.dir_path,
  83. 'driver': ReflinkPool.driver,
  84. 'revisions_to_keep': self.revisions_to_keep
  85. }
  86. @property
  87. def size(self):
  88. statvfs = os.statvfs(self.dir_path)
  89. return statvfs.f_frsize * statvfs.f_blocks
  90. @property
  91. def usage(self):
  92. statvfs = os.statvfs(self.dir_path)
  93. return statvfs.f_frsize * (statvfs.f_blocks - statvfs.f_bfree)
  94. def included_in(self, app):
  95. ''' Check if there is pool containing this one - either as a
  96. filesystem or its LVM volume'''
  97. return qubes.storage.search_pool_containing_dir(
  98. [pool for pool in app.pools.values() if pool is not self],
  99. self.dir_path)
  100. class ReflinkVolume(qubes.storage.Volume):
  101. def create(self):
  102. if self.save_on_stop and not self.snap_on_start:
  103. _create_sparse_file(self._path_clean, self.size)
  104. return self
  105. def verify(self):
  106. if self.snap_on_start:
  107. img = self.source._path_clean # pylint: disable=protected-access
  108. elif self.save_on_stop:
  109. img = self._path_clean
  110. else:
  111. img = None
  112. if img is None or os.path.exists(img):
  113. return True
  114. raise qubes.storage.StoragePoolException(
  115. 'Missing image file {!r} for volume {!s}'.format(img, self.vid))
  116. def remove(self):
  117. ''' Drop volume object from pool; remove volume images from
  118. oldest to newest; remove empty VM directory.
  119. '''
  120. self.pool._volumes.pop(self, None) # pylint: disable=protected-access
  121. self._prune_revisions(keep=0)
  122. _remove_file(self._path_clean)
  123. _remove_file(self._path_dirty)
  124. _remove_empty_dir(os.path.dirname(self._path_dirty))
  125. return self
  126. def is_outdated(self):
  127. if self.snap_on_start:
  128. with suppress(FileNotFoundError):
  129. # pylint: disable=protected-access
  130. return (os.path.getmtime(self.source._path_clean) >
  131. os.path.getmtime(self._path_clean))
  132. return False
  133. def is_dirty(self):
  134. return self.save_on_stop and os.path.exists(self._path_dirty)
  135. def start(self):
  136. if self.is_dirty(): # implies self.save_on_stop
  137. return self
  138. if self.snap_on_start:
  139. # pylint: disable=protected-access
  140. _copy_file(self.source._path_clean, self._path_clean)
  141. if self.snap_on_start or self.save_on_stop:
  142. _copy_file(self._path_clean, self._path_dirty)
  143. else:
  144. _create_sparse_file(self._path_dirty, self.size)
  145. return self
  146. def stop(self):
  147. if self.save_on_stop:
  148. self._commit()
  149. else:
  150. _remove_file(self._path_dirty)
  151. _remove_file(self._path_clean)
  152. return self
  153. def _commit(self):
  154. self._add_revision()
  155. self._prune_revisions()
  156. _rename_file(self._path_dirty, self._path_clean)
  157. def _add_revision(self):
  158. if self.revisions_to_keep == 0:
  159. return
  160. if _get_file_disk_usage(self._path_clean) == 0:
  161. return
  162. ctime = os.path.getctime(self._path_clean)
  163. timestamp = qubes.storage.isodate(int(ctime))
  164. _copy_file(self._path_clean,
  165. self._path_revision(self._next_revision_number, timestamp))
  166. def _prune_revisions(self, keep=None):
  167. if keep is None:
  168. keep = self.revisions_to_keep
  169. # pylint: disable=invalid-unary-operand-type
  170. for number, timestamp in list(self.revisions.items())[:-keep or None]:
  171. _remove_file(self._path_revision(number, timestamp))
  172. def revert(self, revision=None):
  173. if revision is None:
  174. number, timestamp = list(self.revisions.items())[-1]
  175. else:
  176. number, timestamp = revision, None
  177. path_revision = self._path_revision(number, timestamp)
  178. self._add_revision()
  179. _rename_file(path_revision, self._path_clean)
  180. return self
  181. def resize(self, size):
  182. ''' Expand a read-write volume image; notify any corresponding
  183. loop devices of the size change.
  184. '''
  185. if not self.rw:
  186. raise qubes.storage.StoragePoolException(
  187. 'Cannot resize: {!s} is read-only'.format(self.vid))
  188. if size < self.size:
  189. raise qubes.storage.StoragePoolException(
  190. 'For your own safety, shrinking of {!s} is disabled'
  191. ' ({:d} < {:d}). If you really know what you are doing,'
  192. ' use "truncate" manually.'.format(self.vid, size, self.size))
  193. try: # assume volume is not (cleanly) stopped ...
  194. _resize_file(self._path_dirty, size)
  195. except FileNotFoundError: # ... but it actually is.
  196. _resize_file(self._path_clean, size)
  197. self.size = size
  198. # resize any corresponding loop devices
  199. out = _cmd('losetup', '--associated', self._path_dirty)
  200. for match in re.finditer(br'^(/dev/loop[0-9]+): ', out, re.MULTILINE):
  201. loop_dev = match.group(1).decode('ascii')
  202. _cmd('losetup', '--set-capacity', loop_dev)
  203. return self
  204. def _require_save_on_stop(self, method_name):
  205. if not self.save_on_stop:
  206. raise NotImplementedError(
  207. 'Cannot {!s}: {!s} is not save_on_stop'.format(
  208. method_name, self.vid))
  209. def export(self):
  210. self._require_save_on_stop('export')
  211. return self._path_clean
  212. def import_data(self):
  213. self._require_save_on_stop('import_data')
  214. _create_sparse_file(self._path_dirty, self.size)
  215. return self._path_dirty
  216. def import_data_end(self, success):
  217. if success:
  218. self._commit()
  219. else:
  220. _remove_file(self._path_dirty)
  221. return self
  222. def import_volume(self, src_volume):
  223. self._require_save_on_stop('import_volume')
  224. try:
  225. _copy_file(src_volume.export(), self._path_dirty)
  226. except:
  227. self.import_data_end(False)
  228. raise
  229. self.import_data_end(True)
  230. return self
  231. def _path_revision(self, number, timestamp=None):
  232. if timestamp is None:
  233. timestamp = self.revisions[number]
  234. return self._path_clean + '.' + number + '@' + timestamp + 'Z'
  235. @property
  236. def _path_clean(self):
  237. return os.path.join(self.pool.dir_path, self.vid + '.img')
  238. @property
  239. def _path_dirty(self):
  240. return os.path.join(self.pool.dir_path, self.vid + '-dirty.img')
  241. @property
  242. def path(self):
  243. return self._path_dirty
  244. @property
  245. def _next_revision_number(self):
  246. numbers = self.revisions.keys()
  247. if numbers:
  248. return str(int(list(numbers)[-1]) + 1)
  249. return '1'
  250. @property
  251. def revisions(self):
  252. prefix = self._path_clean + '.'
  253. paths = glob.glob(glob.escape(prefix) + '*@*Z')
  254. items = sorted((path[len(prefix):-1].split('@') for path in paths),
  255. key=lambda item: int(item[0]))
  256. return collections.OrderedDict(items)
  257. @property
  258. def usage(self):
  259. ''' Return volume disk usage from the VM's perspective. It is
  260. usually much lower from the host's perspective due to CoW.
  261. '''
  262. with suppress(FileNotFoundError):
  263. return _get_file_disk_usage(self._path_dirty)
  264. with suppress(FileNotFoundError):
  265. return _get_file_disk_usage(self._path_clean)
  266. return 0
  267. @contextmanager
  268. def _replace_file(dst):
  269. ''' Yield a tempfile whose name starts with dst, creating the last
  270. directory component if necessary. If the block does not raise
  271. an exception, flush+fsync the tempfile and rename it to dst.
  272. '''
  273. tmp_dir, prefix = os.path.split(dst + '~')
  274. _make_dir(tmp_dir)
  275. tmp = tempfile.NamedTemporaryFile(dir=tmp_dir, prefix=prefix, delete=False)
  276. try:
  277. yield tmp
  278. tmp.flush()
  279. os.fsync(tmp.fileno())
  280. tmp.close()
  281. _rename_file(tmp.name, dst)
  282. except:
  283. tmp.close()
  284. _remove_file(tmp.name)
  285. raise
  286. def _get_file_disk_usage(path):
  287. ''' Return real disk usage (not logical file size) of a file. '''
  288. return os.stat(path).st_blocks * BLKSIZE
  289. def _fsync_dir(path):
  290. dir_fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
  291. try:
  292. os.fsync(dir_fd)
  293. finally:
  294. os.close(dir_fd)
  295. def _make_dir(path):
  296. ''' mkdir path, ignoring FileExistsError; return whether we
  297. created it.
  298. '''
  299. with suppress(FileExistsError):
  300. os.mkdir(path)
  301. _fsync_dir(os.path.dirname(path))
  302. LOGGER.info('Created directory: %s', path)
  303. return True
  304. return False
  305. def _remove_file(path):
  306. with suppress(FileNotFoundError):
  307. os.remove(path)
  308. _fsync_dir(os.path.dirname(path))
  309. LOGGER.info('Removed file: %s', path)
  310. def _remove_empty_dir(path):
  311. try:
  312. os.rmdir(path)
  313. _fsync_dir(os.path.dirname(path))
  314. LOGGER.info('Removed empty directory: %s', path)
  315. except OSError as ex:
  316. if ex.errno not in (errno.ENOENT, errno.ENOTEMPTY):
  317. raise
  318. def _rename_file(src, dst):
  319. os.rename(src, dst)
  320. dst_dir = os.path.dirname(dst)
  321. src_dir = os.path.dirname(src)
  322. _fsync_dir(dst_dir)
  323. if src_dir != dst_dir:
  324. _fsync_dir(src_dir)
  325. LOGGER.info('Renamed file: %s -> %s', src, dst)
  326. def _resize_file(path, size):
  327. ''' Resize an existing file. '''
  328. with open(path, 'rb+') as file:
  329. file.truncate(size)
  330. os.fsync(file.fileno())
  331. def _create_sparse_file(path, size):
  332. ''' Create an empty sparse file. '''
  333. with _replace_file(path) as tmp:
  334. tmp.truncate(size)
  335. LOGGER.info('Created sparse file: %s', tmp.name)
  336. def _copy_file(src, dst):
  337. ''' Copy src to dst as a reflink if possible, sparse if not. '''
  338. if not os.path.exists(src):
  339. raise FileNotFoundError(src)
  340. with _replace_file(dst) as tmp:
  341. LOGGER.info('Copying file: %s -> %s', src, tmp.name)
  342. _cmd('cp', '--sparse=always', '--reflink=auto', src, tmp.name)
  343. def _cmd(*args):
  344. ''' Run command until finished; return stdout (as bytes) if it
  345. exited 0. Otherwise, raise a detailed StoragePoolException.
  346. '''
  347. try:
  348. return subprocess.run(args, check=True,
  349. stdout=subprocess.PIPE,
  350. stderr=subprocess.PIPE).stdout
  351. except subprocess.CalledProcessError as ex:
  352. msg = '{!s} err={!r} out={!r}'.format(ex, ex.stderr, ex.stdout)
  353. raise qubes.storage.StoragePoolException(msg) from ex
  354. def is_reflink_supported(dst_dir, src_dir=None):
  355. ''' Return whether destination directory supports reflink copies
  356. from source directory. (A temporary file is created in each
  357. directory, using O_TMPFILE if possible.)
  358. '''
  359. if src_dir is None:
  360. src_dir = dst_dir
  361. dst = tempfile.TemporaryFile(dir=dst_dir)
  362. src = tempfile.TemporaryFile(dir=src_dir)
  363. src.write(b'foo') # don't let any filesystem get clever with empty files
  364. try:
  365. fcntl.ioctl(dst.fileno(), FICLONE, src.fileno())
  366. return True
  367. except OSError:
  368. return False