file.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  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. from __future__ import absolute_import
  26. import os
  27. import os.path
  28. import re
  29. import subprocess
  30. from qubes.storage import Pool, StoragePoolException, Volume
  31. BLKSIZE = 512
  32. class FilePool(Pool):
  33. ''' File based 'original' disk implementation '''
  34. driver = 'file'
  35. def __init__(self, name=None, dir_path=None):
  36. super(FilePool, self).__init__(name=name)
  37. assert dir_path, "No pool dir_path specified"
  38. self.dir_path = os.path.normpath(dir_path)
  39. def clone(self, source, target):
  40. ''' Clones the volume if the `source.pool` if the source is a
  41. :py:class:`FileVolume`.
  42. '''
  43. if issubclass(FileVolume, source.__class__):
  44. raise StoragePoolException('Volumes %s and %s use different pools'
  45. % (source.__class__, target.__class__))
  46. if source.volume_type not in ['origin', 'read-write']:
  47. return target
  48. copy_file(source.vid, target.vid)
  49. return target
  50. def create(self, volume, source_volume=None):
  51. _type = volume.volume_type
  52. size = volume.size
  53. if _type == 'origin':
  54. create_sparse_file(volume.path_origin, size)
  55. create_sparse_file(volume.path_cow, size)
  56. elif _type in ['read-write'] and source_volume:
  57. copy_file(source_volume.path, volume.path)
  58. elif _type in ['read-write', 'volatile']:
  59. create_sparse_file(volume.path, size)
  60. return volume
  61. @property
  62. def config(self):
  63. return {
  64. 'name': self.name,
  65. 'dir_path': self.dir_path,
  66. 'driver': FilePool.driver,
  67. }
  68. def resize(self, volume, size):
  69. ''' Expands volume, throws
  70. :py:class:`qubst.storage.StoragePoolException` if given size is
  71. less than current_size
  72. '''
  73. _type = volume.volume_type
  74. if _type not in ['origin', 'read-write', 'volatile']:
  75. raise StoragePoolException('Can not resize a %s volume %s' %
  76. (_type, volume.vid))
  77. if size <= volume.size:
  78. raise StoragePoolException(
  79. 'For your own safety, shrinking of %s is'
  80. ' disabled. If you really know what you'
  81. ' are doing, use `truncate` on %s manually.' %
  82. (volume.name, volume.vid))
  83. if _type == 'origin':
  84. path = volume.path_origin
  85. elif _type in ['read-write', 'volatile']:
  86. path = volume.path
  87. with open(path, 'a+b') as fd:
  88. fd.truncate(size)
  89. self._resize_loop_device(path)
  90. def remove(self, volume):
  91. if volume.volume_type in ['read-write', 'volatile']:
  92. _remove_if_exists(volume.path)
  93. elif volume.volume_type == 'origin':
  94. _remove_if_exists(volume.path)
  95. _remove_if_exists(volume.path_cow)
  96. def rename(self, volume, old_name, new_name):
  97. assert issubclass(volume.__class__, FileVolume)
  98. old_dir = os.path.dirname(volume.path)
  99. new_dir = os.path.join(os.path.dirname(old_dir), new_name)
  100. if not os.path.exists(new_dir):
  101. os.makedirs(new_dir)
  102. # FIXME: proper polymorphism
  103. if volume.volume_type == 'read-write':
  104. volume.rename_target_dir(new_name, new_dir)
  105. elif volume.volume_type == 'read-only':
  106. volume.rename_target_dir(old_name, new_dir)
  107. elif volume.volume_type in ['origin', 'volatile']:
  108. volume.rename_target_dir(new_dir)
  109. return volume
  110. @staticmethod
  111. def _resize_loop_device(path):
  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. if vm.is_template():
  175. subdir = 'vm-templates'
  176. elif vm.is_disposablevm():
  177. subdir = 'appvms'
  178. return os.path.join(self.dir_path, subdir,
  179. vm.template.name + '-dvm')
  180. else:
  181. subdir = 'appvms'
  182. return os.path.join(self.dir_path, subdir, vm.name)
  183. def init_volume(self, vm, volume_config):
  184. assert 'volume_type' in volume_config, "Volume type missing " \
  185. + str(volume_config)
  186. volume_type = volume_config['volume_type']
  187. known_types = {
  188. 'read-write': ReadWriteFile,
  189. 'read-only': ReadOnlyFile,
  190. 'origin': OriginFile,
  191. 'snapshot': SnapshotFile,
  192. 'volatile': VolatileFile,
  193. }
  194. if volume_type not in known_types:
  195. raise StoragePoolException("Unknown volume type " + volume_type)
  196. if volume_type in ['snapshot', 'read-only']:
  197. name = volume_config['name']
  198. origin_vm = vm.template
  199. while origin_vm.volume_config[name]['volume_type'] == volume_type:
  200. origin_vm = origin_vm.template
  201. expected_origin_type = {
  202. 'snapshot': 'origin',
  203. 'read-only': 'read-write', # FIXME: really?
  204. }[volume_type]
  205. assert origin_vm.volume_config[name]['volume_type'] == \
  206. expected_origin_type
  207. origin_pool = vm.app.get_pool(origin_vm.volume_config[name]['pool'])
  208. assert isinstance(origin_pool,
  209. FilePool), 'Origin volume not a file volume'
  210. volume_config['target_dir'] = origin_pool.target_dir(origin_vm)
  211. volume_config['size'] = origin_vm.volume_config[name]['size']
  212. else:
  213. volume_config['target_dir'] = self.target_dir(vm)
  214. return known_types[volume_type](**volume_config)
  215. class FileVolume(Volume):
  216. ''' Parent class for the xen volumes implementation which expects a
  217. `target_dir` param on initialization.
  218. '''
  219. def __init__(self, target_dir, **kwargs):
  220. self.target_dir = target_dir
  221. assert self.target_dir, "target_dir not specified"
  222. super(FileVolume, self).__init__(**kwargs)
  223. class SizeMixIn(FileVolume):
  224. ''' A mix in which expects a `size` param to be > 0 on initialization and
  225. provides a usage property wrapper.
  226. '''
  227. def __init__(self, size=0, **kwargs):
  228. assert size, 'Empty size provided'
  229. assert size > 0, 'Size for volume ' + kwargs['name'] + ' is <=0'
  230. super(SizeMixIn, self).__init__(size=int(size), **kwargs)
  231. @property
  232. def usage(self):
  233. ''' Returns the actualy used space '''
  234. return get_disk_usage(self.vid)
  235. @property
  236. def config(self):
  237. ''' return config data for serialization to qubes.xml '''
  238. return {'name': self.name,
  239. 'pool': self.pool,
  240. 'size': str(self.size),
  241. 'volume_type': self.volume_type}
  242. class ReadWriteFile(SizeMixIn):
  243. ''' Represents a readable & writable file image based volume '''
  244. def __init__(self, **kwargs):
  245. super(ReadWriteFile, self).__init__(**kwargs)
  246. self.path = os.path.join(self.target_dir, self.name + '.img')
  247. self.vid = self.path
  248. def rename_target_dir(self, new_name, new_dir):
  249. ''' Called by :py:class:`FilePool` when a domain changes it's name '''
  250. # pylint: disable=unused-argument
  251. old_path = self.path
  252. file_name = os.path.basename(self.path)
  253. new_path = os.path.join(new_dir, file_name)
  254. os.rename(old_path, new_path)
  255. self.target_dir = new_dir
  256. self.path = new_path
  257. self.vid = self.path
  258. class ReadOnlyFile(FileVolume):
  259. ''' Represents a readonly file image based volume '''
  260. usage = 0
  261. def __init__(self, size=0, **kwargs):
  262. super(ReadOnlyFile, self).__init__(size=int(size), **kwargs)
  263. self.path = self.vid
  264. def rename_target_dir(self, old_name, new_dir):
  265. """ Called by :py:class:`FilePool` when a domain changes it's name.
  266. Only copies the volume if it belongs to the domain being renamed.
  267. Currently if a volume is in a directory named the same as the domain,
  268. it's ”owned” by the domain.
  269. """
  270. if os.path.basename(self.target_dir) == old_name:
  271. file_name = os.path.basename(self.path)
  272. new_path = os.path.join(new_dir, file_name)
  273. old_path = self.path
  274. os.rename(old_path, new_path)
  275. self.target_dir = new_dir
  276. self.path = new_path
  277. self.vid = self.path
  278. class OriginFile(SizeMixIn):
  279. ''' Represents a readable, writeable & snapshotable file image based volume.
  280. This is used for TemplateVM's
  281. '''
  282. script = 'block-origin'
  283. def __init__(self, **kwargs):
  284. super(OriginFile, self).__init__(**kwargs)
  285. self.path_origin = os.path.join(self.target_dir, self.name + '.img')
  286. self.path_cow = os.path.join(self.target_dir, self.name + '-cow.img')
  287. self.path = '%s:%s' % (self.path_origin, self.path_cow)
  288. self.vid = self.path_origin
  289. def commit(self):
  290. ''' Commit Template changes '''
  291. raise NotImplementedError
  292. def rename_target_dir(self, new_dir):
  293. ''' Called by :py:class:`FilePool` when a domain changes it's name '''
  294. old_path_origin = self.path_origin
  295. old_path_cow = self.path_cow
  296. new_path_origin = os.path.join(new_dir, self.name + '.img')
  297. new_path_cow = os.path.join(new_dir, self.name + '-cow.img')
  298. os.rename(old_path_origin, new_path_origin)
  299. os.rename(old_path_cow, new_path_cow)
  300. self.target_dir = new_dir
  301. self.path_origin = new_path_origin
  302. self.path_cow = new_path_cow
  303. self.path = '%s:%s' % (self.path_origin, self.path_cow)
  304. self.vid = self.path_origin
  305. @property
  306. def usage(self):
  307. result = 0
  308. if os.path.exists(self.path_origin):
  309. result += get_disk_usage(self.path_origin)
  310. if os.path.exists(self.path_cow):
  311. result += get_disk_usage(self.path_cow)
  312. return result
  313. class SnapshotFile(FileVolume):
  314. ''' Represents a readonly snapshot of an :py:class:`OriginFile` volume '''
  315. script = 'block-snapshot'
  316. rw = False
  317. usage = 0
  318. def __init__(self, name=None, size=None, **kwargs):
  319. assert size
  320. super(SnapshotFile, self).__init__(name=name, size=int(size), **kwargs)
  321. self.path_origin = os.path.join(self.target_dir, name + '.img')
  322. self.path_cow = os.path.join(self.target_dir, name + '-cow.img')
  323. self.path = '%s:%s' % (self.path_origin, self.path_cow)
  324. self.vid = self.path_origin
  325. class VolatileFile(SizeMixIn):
  326. ''' Represents a readable & writeable file based volume, which will be
  327. discarded and recreated at each startup.
  328. '''
  329. def __init__(self, **kwargs):
  330. super(VolatileFile, self).__init__(**kwargs)
  331. self.path = os.path.join(self.target_dir, self.name + '.img')
  332. self.vid = self.path
  333. def rename_target_dir(self, new_dir):
  334. ''' Called by :py:class:`FilePool` when a domain changes it's name '''
  335. _remove_if_exists(self.path)
  336. file_name = os.path.basename(self.path)
  337. self.target_dir = new_dir
  338. new_path = os.path.join(new_dir, file_name)
  339. self.path = new_path
  340. self.vid = self.path
  341. def create_sparse_file(path, size):
  342. ''' Create an empty sparse file '''
  343. if os.path.exists(path):
  344. raise IOError("Volume %s already exists", path)
  345. parent_dir = os.path.dirname(path)
  346. if not os.path.exists(parent_dir):
  347. os.makedirs(parent_dir)
  348. with open(path, 'a+b') as fh:
  349. fh.truncate(size)
  350. def get_disk_usage_one(st):
  351. '''Extract disk usage of one inode from its stat_result struct.
  352. If known, get real disk usage, as written to device by filesystem, not
  353. logical file size. Those values may be different for sparse files.
  354. :param os.stat_result st: stat result
  355. :returns: disk usage
  356. '''
  357. try:
  358. return st.st_blocks * BLKSIZE
  359. except AttributeError:
  360. return st.st_size
  361. def get_disk_usage(path):
  362. '''Get real disk usage of given path (file or directory).
  363. When *path* points to directory, then it is evaluated recursively.
  364. This function tries estiate real disk usage. See documentation of
  365. :py:func:`get_disk_usage_one`.
  366. :param str path: path to evaluate
  367. :returns: disk usage
  368. '''
  369. try:
  370. st = os.lstat(path)
  371. except OSError:
  372. return 0
  373. ret = get_disk_usage_one(st)
  374. # if path is not a directory, this is skipped
  375. for dirpath, dirnames, filenames in os.walk(path):
  376. for name in dirnames + filenames:
  377. ret += get_disk_usage_one(os.lstat(os.path.join(dirpath, name)))
  378. return ret
  379. def create_dir_if_not_exists(path):
  380. """ Check if a directory exists in if not create it.
  381. This method does not create any parent directories.
  382. """
  383. if not os.path.exists(path):
  384. os.mkdir(path)
  385. def copy_file(source, destination):
  386. '''Effective file copy, preserving sparse files etc.
  387. '''
  388. # TODO: Windows support
  389. # We prefer to use Linux's cp, because it nicely handles sparse files
  390. assert os.path.exists(source), \
  391. "Missing the source %s to copy from" % source
  392. assert not os.path.exists(destination), \
  393. "Destination %s already exists" % destination
  394. parent_dir = os.path.dirname(destination)
  395. if not os.path.exists(parent_dir):
  396. os.makedirs(parent_dir)
  397. try:
  398. subprocess.check_call(['cp', '--reflink=auto', source, destination])
  399. except subprocess.CalledProcessError:
  400. raise IOError('Error while copying {!r} to {!r}'.format(source,
  401. destination))
  402. def _remove_if_exists(path):
  403. if os.path.exists(path):
  404. os.remove(path)
  405. def _check_path(path):
  406. ''' Raise an StoragePoolException if ``path`` does not exist'''
  407. if not os.path.exists(path):
  408. raise StoragePoolException('Missing image file: %s' % path)