file.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. #!/usr/bin/python2 -O
  2. # vim: fileencoding=utf-8
  3. #
  4. # The Qubes OS Project, https://www.qubes-os.org/
  5. #
  6. # Copyright (C) 2015 Joanna Rutkowska <joanna@invisiblethingslab.com>
  7. # Copyright (C) 2013-2015 Marek Marczykowski-Górecki
  8. # <marmarek@invisiblethingslab.com>
  9. # Copyright (C) 2015 Wojtek Porczyk <woju@invisiblethingslab.com>
  10. #
  11. # This program is free software; you can redistribute it and/or modify
  12. # it under the terms of the GNU General Public License as published by
  13. # the Free Software Foundation; either version 2 of the License, or
  14. # (at your option) any later version.
  15. #
  16. # This program is distributed in the hope that it will be useful,
  17. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  18. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  19. # GNU General Public License for more details.
  20. #
  21. # You should have received a copy of the GNU General Public License along
  22. # with this program; if not, write to the Free Software Foundation, Inc.,
  23. # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
  24. #
  25. ''' This module contains pool implementations backed by file images'''
  26. from __future__ import absolute_import
  27. import os
  28. import os.path
  29. import re
  30. import subprocess
  31. from qubes.storage import Pool, StoragePoolException, Volume
  32. BLKSIZE = 512
  33. class FilePool(Pool):
  34. ''' File based 'original' disk implementation '''
  35. driver = 'file'
  36. def __init__(self, name=None, dir_path=None):
  37. super(FilePool, self).__init__(name=name)
  38. assert dir_path, "No pool dir_path specified"
  39. self.dir_path = os.path.normpath(dir_path)
  40. self._volumes = []
  41. def clone(self, source, target):
  42. ''' Clones the volume if the `source.pool` if the source is a
  43. :py:class:`FileVolume`.
  44. '''
  45. if issubclass(FileVolume, source.__class__):
  46. raise StoragePoolException('Volumes %s and %s use different pools'
  47. % (source.__class__, target.__class__))
  48. if source.volume_type not in ['origin', 'read-write']:
  49. return target
  50. copy_file(source.vid, target.vid)
  51. return target
  52. def create(self, volume, source_volume=None):
  53. _type = volume.volume_type
  54. size = volume.size
  55. if _type == 'origin':
  56. create_sparse_file(volume.path_origin, size)
  57. create_sparse_file(volume.path_cow, size)
  58. elif _type in ['read-write'] and source_volume:
  59. copy_file(source_volume.path, volume.path)
  60. elif _type in ['read-write', 'volatile']:
  61. create_sparse_file(volume.path, size)
  62. return volume
  63. @property
  64. def config(self):
  65. return {
  66. 'name': self.name,
  67. 'dir_path': self.dir_path,
  68. 'driver': FilePool.driver,
  69. }
  70. def is_outdated(self, volume):
  71. # FIX: Implement or remove this at all?
  72. raise NotImplementedError
  73. def resize(self, volume, size):
  74. ''' Expands volume, throws
  75. :py:class:`qubst.storage.StoragePoolException` if given size is
  76. less than current_size
  77. '''
  78. _type = volume.volume_type
  79. if _type not in ['origin', 'read-write', 'volatile']:
  80. raise StoragePoolException('Can not resize a %s volume %s' %
  81. (_type, volume.vid))
  82. if size <= volume.size:
  83. raise StoragePoolException(
  84. 'For your own safety, shrinking of %s is'
  85. ' disabled. If you really know what you'
  86. ' are doing, use `truncate` on %s manually.' %
  87. (volume.name, volume.vid))
  88. if _type == 'origin':
  89. path = volume.path_origin
  90. elif _type in ['read-write', 'volatile']:
  91. path = volume.path
  92. with open(path, 'a+b') as fd:
  93. fd.truncate(size)
  94. self._resize_loop_device(path)
  95. def remove(self, volume):
  96. if volume.volume_type in ['read-write', 'volatile']:
  97. _remove_if_exists(volume.path)
  98. elif volume.volume_type == 'origin':
  99. _remove_if_exists(volume.path)
  100. _remove_if_exists(volume.path_cow)
  101. def rename(self, volume, old_name, new_name):
  102. assert issubclass(volume.__class__, FileVolume)
  103. old_dir = os.path.dirname(volume.path)
  104. new_dir = os.path.join(os.path.dirname(old_dir), new_name)
  105. if not os.path.exists(new_dir):
  106. os.makedirs(new_dir)
  107. volume.rename_target_dir(old_name, new_name)
  108. return volume
  109. @staticmethod
  110. def _resize_loop_device(path):
  111. ''' Sets the loop device capacity '''
  112. # find loop device if any
  113. p = subprocess.Popen(
  114. ['sudo', 'losetup', '--associated', path],
  115. stdout=subprocess.PIPE)
  116. result = p.communicate()
  117. m = re.match(r'^(/dev/loop\d+):\s', result[0])
  118. if m is not None:
  119. loop_dev = m.group(1)
  120. # resize loop device
  121. subprocess.check_call(['sudo', 'losetup', '--set-capacity',
  122. loop_dev])
  123. def commit_template_changes(self, volume):
  124. if volume.volume_type != 'origin':
  125. return volume
  126. if os.path.exists(volume.path_cow):
  127. os.rename(volume.path_cow, volume.path_cow + '.old')
  128. old_umask = os.umask(002)
  129. with open(volume.path_cow, 'w') as f_cow:
  130. f_cow.truncate(volume.size)
  131. os.umask(old_umask)
  132. return volume
  133. def destroy(self):
  134. pass
  135. def setup(self):
  136. create_dir_if_not_exists(self.dir_path)
  137. appvms_path = os.path.join(self.dir_path, 'appvms')
  138. create_dir_if_not_exists(appvms_path)
  139. vm_templates_path = os.path.join(self.dir_path, 'vm-templates')
  140. create_dir_if_not_exists(vm_templates_path)
  141. def start(self, volume):
  142. if volume.volume_type == 'volatile':
  143. self._reset_volume(volume)
  144. if volume.volume_type in ['origin', 'snapshot']:
  145. _check_path(volume.path_origin)
  146. _check_path(volume.path_cow)
  147. else:
  148. _check_path(volume.path)
  149. return volume
  150. def stop(self, volume):
  151. pass
  152. @staticmethod
  153. def _reset_volume(volume):
  154. ''' Remove and recreate a volatile volume '''
  155. assert volume.volume_type == 'volatile', "Not a volatile volume"
  156. assert volume.size
  157. _remove_if_exists(volume.path)
  158. with open(volume.path, "w") as f_volatile:
  159. f_volatile.truncate(volume.size)
  160. return volume
  161. def target_dir(self, vm):
  162. """ Returns the path to vmdir depending on the type of the VM.
  163. The default QubesOS file storage saves the vm images in three
  164. different directories depending on the ``QubesVM`` type:
  165. * ``appvms`` for ``QubesAppVm`` or ``QubesHvm``
  166. * ``vm-templates`` for ``QubesTemplateVm`` or ``QubesTemplateHvm``
  167. Args:
  168. vm: a QubesVM
  169. pool_dir: the root directory of the pool
  170. Returns:
  171. string (str) absolute path to the directory where the vm files
  172. are stored
  173. """
  174. # FIX Remove this if we drop the file backend
  175. import qubes.vm.templatevm # nopep8
  176. import qubes.vm.dispvm # nopep8
  177. if isinstance(vm, qubes.vm.templatevm.TemplateVM):
  178. subdir = 'vm-templates'
  179. elif isinstance(vm, qubes.vm.dispvm.DispVM):
  180. subdir = 'appvms'
  181. return os.path.join(self.dir_path, subdir,
  182. vm.template.name + '-dvm')
  183. else:
  184. subdir = 'appvms'
  185. return os.path.join(self.dir_path, subdir, vm.name)
  186. def init_volume(self, vm, volume_config):
  187. assert 'volume_type' in volume_config, "Volume type missing " \
  188. + str(volume_config)
  189. volume_type = volume_config['volume_type']
  190. known_types = {
  191. 'read-write': ReadWriteFile,
  192. 'read-only': ReadOnlyFile,
  193. 'origin': OriginFile,
  194. 'snapshot': SnapshotFile,
  195. 'volatile': VolatileFile,
  196. }
  197. if volume_type not in known_types:
  198. raise StoragePoolException("Unknown volume type " + volume_type)
  199. if volume_type in ['snapshot', 'read-only']:
  200. name = volume_config['name']
  201. origin_vm = vm.template
  202. while origin_vm.volume_config[name]['volume_type'] == volume_type:
  203. origin_vm = origin_vm.template
  204. expected_origin_type = {
  205. 'snapshot': 'origin',
  206. 'read-only': 'read-write', # FIXME: really?
  207. }[volume_type]
  208. assert origin_vm.volume_config[name]['volume_type'] == \
  209. expected_origin_type
  210. origin_pool = vm.app.get_pool(origin_vm.volume_config[name]['pool'])
  211. assert isinstance(origin_pool,
  212. FilePool), 'Origin volume not a file volume'
  213. volume_config['target_dir'] = origin_pool.target_dir(origin_vm)
  214. volume_config['size'] = origin_vm.volume_config[name]['size']
  215. else:
  216. volume_config['target_dir'] = self.target_dir(vm)
  217. volume = known_types[volume_type](**volume_config)
  218. self._volumes += [volume]
  219. return volume
  220. def verify(self, volume):
  221. return volume.verify()
  222. @property
  223. def volumes(self):
  224. return self._volumes
  225. class FileVolume(Volume):
  226. ''' Parent class for the xen volumes implementation which expects a
  227. `target_dir` param on initialization.
  228. '''
  229. def __init__(self, target_dir, **kwargs):
  230. self.target_dir = target_dir
  231. assert self.target_dir, "target_dir not specified"
  232. super(FileVolume, self).__init__(**kwargs)
  233. def _new_dir(self, new_name):
  234. ''' Returns a new directory path based on the new_name. This is a helper
  235. method for moving file images during vm renaming.
  236. '''
  237. old_dir = os.path.dirname(self.path)
  238. return os.path.join(os.path.dirname(old_dir), new_name)
  239. class SizeMixIn(FileVolume):
  240. ''' A mix in which expects a `size` param to be > 0 on initialization and
  241. provides a usage property wrapper.
  242. '''
  243. def __init__(self, size=0, **kwargs):
  244. assert size, 'Empty size provided'
  245. assert size > 0, 'Size for volume ' + kwargs['name'] + ' is <=0'
  246. super(SizeMixIn, self).__init__(size=int(size), **kwargs)
  247. @property
  248. def usage(self):
  249. ''' Returns the actualy used space '''
  250. return get_disk_usage(self.vid)
  251. @property
  252. def config(self):
  253. ''' return config data for serialization to qubes.xml '''
  254. return {'name': self.name,
  255. 'pool': self.pool,
  256. 'size': str(self.size),
  257. 'volume_type': self.volume_type}
  258. class ReadWriteFile(SizeMixIn):
  259. ''' Represents a readable & writable file image based volume '''
  260. def __init__(self, **kwargs):
  261. super(ReadWriteFile, self).__init__(**kwargs)
  262. self.path = os.path.join(self.target_dir, self.name + '.img')
  263. self.vid = self.path
  264. def rename_target_dir(self, new_name, new_dir):
  265. ''' Called by :py:class:`FilePool` when a domain changes it's name '''
  266. # pylint: disable=unused-argument
  267. old_path = self.path
  268. file_name = os.path.basename(self.path)
  269. new_path = os.path.join(new_dir, file_name)
  270. os.rename(old_path, new_path)
  271. self.target_dir = new_dir
  272. self.path = new_path
  273. self.vid = self.path
  274. def verify(self):
  275. ''' Verifies the volume. '''
  276. if not os.path.exists(self.path):
  277. raise StoragePoolException('Missing image file: %s' % self.path)
  278. class ReadOnlyFile(FileVolume):
  279. ''' Represents a readonly file image based volume '''
  280. usage = 0
  281. def __init__(self, size=0, **kwargs):
  282. super(ReadOnlyFile, self).__init__(size=int(size), **kwargs)
  283. self.path = self.vid
  284. def rename_target_dir(self, old_name, new_name):
  285. """ Called by :py:class:`FilePool` when a domain changes it's name.
  286. Only copies the volume if it belongs to the domain being renamed.
  287. Currently if a volume is in a directory named the same as the domain,
  288. it's ”owned” by the domain.
  289. """
  290. new_dir = self._new_dir(new_name)
  291. if os.path.basename(self.target_dir) == old_name:
  292. file_name = os.path.basename(self.path)
  293. new_path = os.path.join(new_dir, file_name)
  294. old_path = self.path
  295. os.rename(old_path, new_path)
  296. self.target_dir = new_dir
  297. self.path = new_path
  298. self.vid = self.path
  299. def verify(self):
  300. ''' Verifies the volume. '''
  301. if not os.path.exists(self.path):
  302. raise StoragePoolException('Missing image file: %s' % self.path)
  303. class OriginFile(SizeMixIn):
  304. ''' Represents a readable, writeable & snapshotable file image based volume.
  305. This is used for TemplateVM's
  306. '''
  307. script = 'block-origin'
  308. def __init__(self, **kwargs):
  309. super(OriginFile, self).__init__(**kwargs)
  310. self.path_origin = os.path.join(self.target_dir, self.name + '.img')
  311. self.path_cow = os.path.join(self.target_dir, self.name + '-cow.img')
  312. self.path = '%s:%s' % (self.path_origin, self.path_cow)
  313. self.vid = self.path_origin
  314. def commit(self):
  315. ''' Commit Template changes '''
  316. raise NotImplementedError
  317. def rename_target_dir(self, old_name, new_name):
  318. ''' Called by :py:class:`FilePool` when a domain changes it's name.
  319. ''' # pylint: disable=unused-argument
  320. new_dir = self._new_dir(new_name)
  321. old_path_origin = self.path_origin
  322. old_path_cow = self.path_cow
  323. new_path_origin = os.path.join(new_dir, self.name + '.img')
  324. new_path_cow = os.path.join(new_dir, self.name + '-cow.img')
  325. os.rename(old_path_origin, new_path_origin)
  326. os.rename(old_path_cow, new_path_cow)
  327. self.target_dir = new_dir
  328. self.path_origin = new_path_origin
  329. self.path_cow = new_path_cow
  330. self.path = '%s:%s' % (self.path_origin, self.path_cow)
  331. self.vid = self.path_origin
  332. @property
  333. def usage(self):
  334. result = 0
  335. if os.path.exists(self.path_origin):
  336. result += get_disk_usage(self.path_origin)
  337. if os.path.exists(self.path_cow):
  338. result += get_disk_usage(self.path_cow)
  339. return result
  340. def verify(self):
  341. ''' Verifies the volume. '''
  342. if not os.path.exists(self.path_origin):
  343. raise StoragePoolException('Missing image file: %s' %
  344. self.path_origin)
  345. class SnapshotFile(FileVolume):
  346. ''' Represents a readonly snapshot of an :py:class:`OriginFile` volume '''
  347. script = 'block-snapshot'
  348. rw = False
  349. usage = 0
  350. def __init__(self, name=None, size=None, **kwargs):
  351. assert size
  352. super(SnapshotFile, self).__init__(name=name, size=int(size), **kwargs)
  353. self.path_origin = os.path.join(self.target_dir, name + '.img')
  354. self.path_cow = os.path.join(self.target_dir, name + '-cow.img')
  355. self.path = '%s:%s' % (self.path_origin, self.path_cow)
  356. self.vid = self.path_origin
  357. def verify(self):
  358. ''' Verifies the volume. '''
  359. if not os.path.exists(self.path_origin):
  360. raise StoragePoolException('Missing image file: %s' %
  361. self.path_origin)
  362. class VolatileFile(SizeMixIn):
  363. ''' Represents a readable & writeable file based volume, which will be
  364. discarded and recreated at each startup.
  365. '''
  366. def __init__(self, **kwargs):
  367. super(VolatileFile, self).__init__(**kwargs)
  368. self.path = os.path.join(self.target_dir, self.name + '.img')
  369. self.vid = self.path
  370. def rename_target_dir(self, old_name, new_name):
  371. ''' Called by :py:class:`FilePool` when a domain changes it's name.
  372. ''' # pylint: disable=unused-argument
  373. new_dir = self._new_dir(new_name)
  374. _remove_if_exists(self.path)
  375. file_name = os.path.basename(self.path)
  376. self.target_dir = new_dir
  377. new_path = os.path.join(new_dir, file_name)
  378. self.path = new_path
  379. self.vid = self.path
  380. def verify(self):
  381. ''' Verifies the volume. '''
  382. pass
  383. def create_sparse_file(path, size):
  384. ''' Create an empty sparse file '''
  385. if os.path.exists(path):
  386. raise IOError("Volume %s already exists", path)
  387. parent_dir = os.path.dirname(path)
  388. if not os.path.exists(parent_dir):
  389. os.makedirs(parent_dir)
  390. with open(path, 'a+b') as fh:
  391. fh.truncate(size)
  392. def get_disk_usage_one(st):
  393. '''Extract disk usage of one inode from its stat_result struct.
  394. If known, get real disk usage, as written to device by filesystem, not
  395. logical file size. Those values may be different for sparse files.
  396. :param os.stat_result st: stat result
  397. :returns: disk usage
  398. '''
  399. try:
  400. return st.st_blocks * BLKSIZE
  401. except AttributeError:
  402. return st.st_size
  403. def get_disk_usage(path):
  404. '''Get real disk usage of given path (file or directory).
  405. When *path* points to directory, then it is evaluated recursively.
  406. This function tries estiate real disk usage. See documentation of
  407. :py:func:`get_disk_usage_one`.
  408. :param str path: path to evaluate
  409. :returns: disk usage
  410. '''
  411. try:
  412. st = os.lstat(path)
  413. except OSError:
  414. return 0
  415. ret = get_disk_usage_one(st)
  416. # if path is not a directory, this is skipped
  417. for dirpath, dirnames, filenames in os.walk(path):
  418. for name in dirnames + filenames:
  419. ret += get_disk_usage_one(os.lstat(os.path.join(dirpath, name)))
  420. return ret
  421. def create_dir_if_not_exists(path):
  422. """ Check if a directory exists in if not create it.
  423. This method does not create any parent directories.
  424. """
  425. if not os.path.exists(path):
  426. os.mkdir(path)
  427. def copy_file(source, destination):
  428. '''Effective file copy, preserving sparse files etc.'''
  429. # We prefer to use Linux's cp, because it nicely handles sparse files
  430. assert os.path.exists(source), \
  431. "Missing the source %s to copy from" % source
  432. assert not os.path.exists(destination), \
  433. "Destination %s already exists" % destination
  434. parent_dir = os.path.dirname(destination)
  435. if not os.path.exists(parent_dir):
  436. os.makedirs(parent_dir)
  437. try:
  438. subprocess.check_call(['cp', '--reflink=auto', source, destination])
  439. except subprocess.CalledProcessError:
  440. raise IOError('Error while copying {!r} to {!r}'.format(source,
  441. destination))
  442. def _remove_if_exists(path):
  443. ''' Removes a path if it exist, silently succeeds if file does not exist '''
  444. if os.path.exists(path):
  445. os.remove(path)
  446. def _check_path(path):
  447. ''' Raise an StoragePoolException if ``path`` does not exist'''
  448. if not os.path.exists(path):
  449. raise StoragePoolException('Missing image file: %s' % path)