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)
  93. elif volume.volume_type == 'origin':
  94. _remove_if_exists(volume)
  95. def rename(self, volume, old_name, new_name):
  96. assert issubclass(volume.__class__, FileVolume)
  97. old_dir = os.path.dirname(volume.path)
  98. new_dir = os.path.join(os.path.dirname(old_dir), new_name)
  99. if not os.path.exists(new_dir):
  100. os.makedirs(new_dir)
  101. if volume.volume_type == 'read-write':
  102. volume.rename_target_dir(new_name, new_dir)
  103. elif volume.volume_type == 'read-only':
  104. volume.rename_target_dir(old_name, new_dir)
  105. elif volume.volume_type in ['origin', 'volatile']:
  106. volume.rename_target_dir(new_dir)
  107. return volume
  108. def _resize_loop_device(self, path):
  109. # find loop device if any
  110. p = subprocess.Popen(
  111. ['sudo', 'losetup', '--associated', path],
  112. stdout=subprocess.PIPE)
  113. result = p.communicate()
  114. m = re.match(r'^(/dev/loop\d+):\s', result[0])
  115. if m is not None:
  116. loop_dev = m.group(1)
  117. # resize loop device
  118. subprocess.check_call(['sudo', 'losetup', '--set-capacity',
  119. loop_dev])
  120. def commit_template_changes(self, volume):
  121. if volume.volume_type != 'origin':
  122. return volume
  123. if os.path.exists(volume.path_cow):
  124. os.rename(volume.path_cow, volume.path_cow + '.old')
  125. old_umask = os.umask(002)
  126. with open(volume.path_cow, 'w') as f_cow:
  127. f_cow.truncate(volume.size)
  128. os.umask(old_umask)
  129. return volume
  130. def destroy(self):
  131. pass
  132. def setup(self):
  133. create_dir_if_not_exists(self.dir_path)
  134. appvms_path = os.path.join(self.dir_path, 'appvms')
  135. create_dir_if_not_exists(appvms_path)
  136. vm_templates_path = os.path.join(self.dir_path, 'vm-templates')
  137. create_dir_if_not_exists(vm_templates_path)
  138. def start(self, volume):
  139. if volume.volume_type == 'volatile':
  140. self._reset_volume(volume)
  141. if volume.volume_type in ['origin', 'snapshot']:
  142. _check_path(volume.path_origin)
  143. _check_path(volume.path_cow)
  144. else:
  145. _check_path(volume.path)
  146. return volume
  147. def stop(self, volume):
  148. pass
  149. def _reset_volume(self, volume):
  150. ''' Remove and recreate a volatile volume '''
  151. assert volume.volume_type == 'volatile', "Not a volatile volume"
  152. assert volume.size
  153. _remove_if_exists(volume)
  154. with open(volume.path, "w") as f_volatile:
  155. f_volatile.truncate(volume.size)
  156. return volume
  157. def target_dir(self, vm):
  158. """ Returns the path to vmdir depending on the type of the VM.
  159. The default QubesOS file storage saves the vm images in three
  160. different directories depending on the ``QubesVM`` type:
  161. * ``appvms`` for ``QubesAppVm`` or ``QubesHvm``
  162. * ``vm-templates`` for ``QubesTemplateVm`` or ``QubesTemplateHvm``
  163. Args:
  164. vm: a QubesVM
  165. pool_dir: the root directory of the pool
  166. Returns:
  167. string (str) absolute path to the directory where the vm files
  168. are stored
  169. """
  170. if vm.is_template():
  171. subdir = 'vm-templates'
  172. elif vm.is_disposablevm():
  173. subdir = 'appvms'
  174. return os.path.join(self.dir_path, subdir,
  175. vm.template.name + '-dvm')
  176. else:
  177. subdir = 'appvms'
  178. return os.path.join(self.dir_path, subdir, vm.name)
  179. def init_volume(self, vm, volume_config):
  180. assert 'volume_type' in volume_config, "Volume type missing " \
  181. + str(volume_config)
  182. volume_type = volume_config['volume_type']
  183. known_types = {
  184. 'read-write': ReadWriteFile,
  185. 'read-only': ReadOnlyFile,
  186. 'origin': OriginFile,
  187. 'snapshot': SnapshotFile,
  188. 'volatile': VolatileFile,
  189. }
  190. if volume_type not in known_types:
  191. raise StoragePoolException("Unknown volume type " + volume_type)
  192. if volume_type in ['snapshot', 'read-only']:
  193. name = volume_config['name']
  194. origin_vm = vm
  195. while hasattr(origin_vm, 'template') and \
  196. origin_vm.volume_config[name]['volume_type'] == \
  197. volume_type:
  198. origin_vm = origin_vm.template
  199. expected_origin_type = {
  200. 'snapshot': 'origin',
  201. 'read-only': 'read-write', # FIXME: really?
  202. }[volume_type]
  203. assert origin_vm.volume_config[name]['volume_type'] == \
  204. expected_origin_type
  205. origin_pool = vm.app.get_pool(origin_vm.volume_config[name]['pool'])
  206. assert isinstance(origin_pool,
  207. FilePool), 'Origin volume not a file volume'
  208. volume_config['target_dir'] = origin_pool.target_dir(origin_vm)
  209. volume_config['size'] = origin_vm.volume_config[name]['size']
  210. else:
  211. volume_config['target_dir'] = self.target_dir(vm)
  212. return known_types[volume_type](**volume_config)
  213. class FileVolume(Volume):
  214. ''' Parent class for the xen volumes implementation which expects a
  215. `target_dir` param on initialization.
  216. '''
  217. def __init__(self, target_dir, **kwargs):
  218. self.target_dir = target_dir
  219. assert self.target_dir, "target_dir not specified"
  220. super(FileVolume, self).__init__(**kwargs)
  221. class SizeMixIn(FileVolume):
  222. ''' A mix in which expects a `size` param to be > 0 on initialization and
  223. provides a usage property wrapper.
  224. '''
  225. def __init__(self, size=0, **kwargs):
  226. assert size, 'Empty size provided'
  227. assert size > 0, 'Size for volume ' + kwargs['name'] + ' is <=0'
  228. super(SizeMixIn, self).__init__(size=int(size), **kwargs)
  229. @property
  230. def usage(self):
  231. ''' Returns the actualy used space '''
  232. return get_disk_usage(self.vid)
  233. @property
  234. def config(self):
  235. ''' return config data for serialization to qubes.xml '''
  236. return {'name': self.name,
  237. 'pool': self.pool,
  238. 'size': str(self.size),
  239. 'volume_type': self.volume_type}
  240. class ReadWriteFile(SizeMixIn):
  241. ''' Represents a readable & writable file image based volume '''
  242. def __init__(self, **kwargs):
  243. super(ReadWriteFile, self).__init__(**kwargs)
  244. self.path = os.path.join(self.target_dir, self.name + '.img')
  245. self.vid = self.path
  246. def rename_target_dir(self, new_name, new_dir):
  247. ''' Called by :py:class:`FilePool` when a domain changes it's name '''
  248. old_path = self.path
  249. file_name = os.path.basename(self.path)
  250. new_path = os.path.join(new_dir, file_name)
  251. os.rename(old_path, new_path)
  252. self.target_dir = new_dir
  253. self.path = new_path
  254. self.vid = self.path
  255. class ReadOnlyFile(FileVolume):
  256. ''' Represents a readonly file image based volume '''
  257. usage = 0
  258. def __init__(self, size=0, **kwargs):
  259. super(ReadOnlyFile, self).__init__(size=int(size), **kwargs)
  260. self.path = self.vid
  261. def rename_target_dir(self, old_name, new_dir):
  262. """ Called by :py:class:`FilePool` when a domain changes it's name.
  263. Only copies the volume if it belongs to the domain being renamed.
  264. Currently if a volume is in a directory named the same as the domain,
  265. it's ”owned” by the domain.
  266. """
  267. if os.path.basename(self.target_dir) == old_name:
  268. file_name = os.path.basename(self.path)
  269. new_path = os.path.join(new_dir, file_name)
  270. old_path = self.path
  271. os.rename(old_path, new_path)
  272. self.target_dir = new_dir
  273. self.path = new_path
  274. self.vid = self.path
  275. class OriginFile(SizeMixIn):
  276. ''' Represents a readable, writeable & snapshotable file image based volume.
  277. This is used for TemplateVM's
  278. '''
  279. script = 'block-origin'
  280. def __init__(self, **kwargs):
  281. super(OriginFile, self).__init__(**kwargs)
  282. self.path_origin = os.path.join(self.target_dir, self.name + '.img')
  283. self.path_cow = os.path.join(self.target_dir, self.name + '-cow.img')
  284. self.path = '%s:%s' % (self.path_origin, self.path_cow)
  285. self.vid = self.path_origin
  286. def commit(self):
  287. ''' Commit Template changes '''
  288. raise NotImplementedError
  289. def rename_target_dir(self, new_dir):
  290. ''' Called by :py:class:`FilePool` when a domain changes it's name '''
  291. old_path_origin = self.path_origin
  292. old_path_cow = self.path_cow
  293. new_path_origin = os.path.join(new_dir, self.name + '.img')
  294. new_path_cow = os.path.join(new_dir, self.name + '-cow.img')
  295. os.rename(old_path_origin, new_path_origin)
  296. os.rename(old_path_cow, new_path_cow)
  297. self.target_dir = new_dir
  298. self.path_origin = new_path_origin
  299. self.path_cow = new_path_cow
  300. self.path = '%s:%s' % (self.path_origin, self.path_cow)
  301. self.vid = self.path_origin
  302. @property
  303. def usage(self):
  304. result = 0
  305. if os.path.exists(self.path_origin):
  306. result += get_disk_usage(self.path_origin)
  307. if os.path.exists(self.path_cow):
  308. result += get_disk_usage(self.path_cow)
  309. return result
  310. class SnapshotFile(FileVolume):
  311. ''' Represents a readonly snapshot of an :py:class:`OriginFile` volume '''
  312. script = 'block-snapshot'
  313. rw = False
  314. usage = 0
  315. def __init__(self, name=None, size=None, **kwargs):
  316. assert size
  317. super(SnapshotFile, self).__init__(name=name, size=int(size), **kwargs)
  318. self.path_origin = os.path.join(self.target_dir, name + '.img')
  319. self.path_cow = os.path.join(self.target_dir, name + '-cow.img')
  320. self.path = '%s:%s' % (self.path_origin, self.path_cow)
  321. self.vid = self.path_origin
  322. class VolatileFile(SizeMixIn):
  323. ''' Represents a readable & writeable file based volume, which will be
  324. discarded and recreated at each startup.
  325. '''
  326. def __init__(self, **kwargs):
  327. super(VolatileFile, self).__init__(**kwargs)
  328. self.path = os.path.join(self.target_dir, self.name + '.img')
  329. self.vid = self.path
  330. def rename_target_dir(self, new_dir):
  331. ''' Called by :py:class:`FilePool` when a domain changes it's name '''
  332. _remove_if_exists(self)
  333. file_name = os.path.basename(self.path)
  334. self.target_dir = new_dir
  335. new_path = os.path.join(new_dir, file_name)
  336. self.path = new_path
  337. self.vid = self.path
  338. def create_sparse_file(path, size):
  339. ''' Create an empty sparse file '''
  340. if os.path.exists(path):
  341. raise IOError("Volume %s already exists", path)
  342. parent_dir = os.path.dirname(path)
  343. if not os.path.exists(parent_dir):
  344. os.makedirs(parent_dir)
  345. with open(path, 'a+b') as fh:
  346. fh.truncate(size)
  347. def get_disk_usage_one(st):
  348. '''Extract disk usage of one inode from its stat_result struct.
  349. If known, get real disk usage, as written to device by filesystem, not
  350. logical file size. Those values may be different for sparse files.
  351. :param os.stat_result st: stat result
  352. :returns: disk usage
  353. '''
  354. try:
  355. return st.st_blocks * BLKSIZE
  356. except AttributeError:
  357. return st.st_size
  358. def get_disk_usage(path):
  359. '''Get real disk usage of given path (file or directory).
  360. When *path* points to directory, then it is evaluated recursively.
  361. This function tries estiate real disk usage. See documentation of
  362. :py:func:`get_disk_usage_one`.
  363. :param str path: path to evaluate
  364. :returns: disk usage
  365. '''
  366. try:
  367. st = os.lstat(path)
  368. except OSError:
  369. return 0
  370. ret = get_disk_usage_one(st)
  371. # if path is not a directory, this is skipped
  372. for dirpath, dirnames, filenames in os.walk(path):
  373. for name in dirnames + filenames:
  374. ret += get_disk_usage_one(os.lstat(os.path.join(dirpath, name)))
  375. return ret
  376. def create_dir_if_not_exists(path):
  377. """ Check if a directory exists in if not create it.
  378. This method does not create any parent directories.
  379. """
  380. if not os.path.exists(path):
  381. os.mkdir(path)
  382. def copy_file(source, destination):
  383. '''Effective file copy, preserving sparse files etc.
  384. '''
  385. # TODO: Windows support
  386. # We prefer to use Linux's cp, because it nicely handles sparse files
  387. assert os.path.exists(source), \
  388. "Missing the source %s to copy from" % source
  389. assert not os.path.exists(destination), \
  390. "Destination %s already exists" % destination
  391. parent_dir = os.path.dirname(destination)
  392. if not os.path.exists(parent_dir):
  393. os.makedirs(parent_dir)
  394. try:
  395. subprocess.check_call(['cp', '--reflink=auto', source, destination])
  396. except subprocess.CalledProcessError:
  397. raise IOError('Error while copying {!r} to {!r}'.format(source,
  398. destination))
  399. def _remove_if_exists(volume):
  400. if os.path.exists(volume.path):
  401. os.remove(volume.path)
  402. if volume.volume_type == 'origin':
  403. os.remove(volume.path_cow)
  404. def _check_path(path):
  405. ''' Raise an StoragePoolException if ``path`` does not exist'''
  406. if not os.path.exists(path):
  407. raise StoragePoolException('Missing image file: %s' % path)