file.py 17 KB

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