storage.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. # -*- encoding: utf-8 -*-
  2. #
  3. # The Qubes OS Project, http://www.qubes-os.org
  4. #
  5. # Copyright (C) 2017 Marek Marczykowski-Górecki
  6. # <marmarek@invisiblethingslab.com>
  7. #
  8. # This program is free software; you can redistribute it and/or modify
  9. # it under the terms of the GNU Lesser General Public License as published by
  10. # the Free Software Foundation; either version 2.1 of the License, or
  11. # (at your option) any later version.
  12. #
  13. # This program is distributed in the hope that it will be useful,
  14. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. # GNU Lesser General Public License for more details.
  17. #
  18. # You should have received a copy of the GNU Lesser General Public License along
  19. # with this program; if not, see <http://www.gnu.org/licenses/>.
  20. '''Storage subsystem.'''
  21. import qubesadmin.exc
  22. class Volume(object):
  23. '''Storage volume.'''
  24. def __init__(self, app, pool=None, vid=None, vm=None, vm_name=None):
  25. '''Construct a Volume object.
  26. Volume may be identified using pool+vid, or vm+vm_name. Either of
  27. those argument pairs must be given.
  28. :param Qubes app: application instance
  29. :param str pool: pool name
  30. :param str vid: volume id (within pool)
  31. :param str vm: owner VM name
  32. :param str vm_name: name within owning VM (like 'private', 'root' etc)
  33. '''
  34. self.app = app
  35. if pool is None and vm is None:
  36. raise ValueError('Either pool or vm must be given')
  37. if pool is not None and vid is None:
  38. raise ValueError('If pool is given, vid must be too.')
  39. if vm is not None and vm_name is None:
  40. raise ValueError('If vm is given, vm_name must be too.')
  41. self._pool = pool
  42. self._vid = vid
  43. self._vm = vm
  44. self._vm_name = vm_name
  45. self._info = None
  46. def _qubesd_call(self, func_name, payload=None, payload_stream=None):
  47. '''Make a call to qubesd regarding this volume
  48. :param str func_name: API function name, like `Info` or `Resize`
  49. :param bytes payload: Payload to send.
  50. :param file payload_stream: Stream to pipe payload from. Only one of
  51. `payload` and `payload_stream` can be used.
  52. '''
  53. if self._vm is not None:
  54. method = 'admin.vm.volume.' + func_name
  55. dest = self._vm
  56. arg = self._vm_name
  57. else:
  58. if payload_stream:
  59. raise NotImplementedError(
  60. 'payload_stream not implemented for '
  61. 'admin.pool.volume.* calls')
  62. method = 'admin.pool.volume.' + func_name
  63. dest = 'dom0'
  64. arg = self._pool
  65. if payload is not None:
  66. payload = self._vid.encode('ascii') + b' ' + payload
  67. else:
  68. payload = self._vid.encode('ascii')
  69. return self.app.qubesd_call(dest, method, arg, payload=payload,
  70. payload_stream=payload_stream)
  71. def _fetch_info(self, force=True):
  72. '''Fetch volume properties
  73. Populate self._info dict
  74. :param bool force: refresh self._info, even if already populated.
  75. '''
  76. if not force and self._info is not None:
  77. return
  78. info = self._qubesd_call('Info')
  79. info = info.decode('ascii')
  80. self._info = dict([line.split('=', 1) for line in info.splitlines()])
  81. def __eq__(self, other):
  82. if isinstance(other, Volume):
  83. return self.pool == other.pool and self.vid == other.vid
  84. return NotImplemented
  85. def __lt__(self, other):
  86. # pylint: disable=protected-access
  87. if isinstance(other, Volume):
  88. if self._vm and other._vm:
  89. return (self._vm, self._vm_name) < (other._vm, other._vm_name)
  90. if self._vid and other._vid:
  91. return (self._pool, self._vid) < (other._pool, other._vid)
  92. return NotImplemented
  93. @property
  94. def name(self):
  95. '''per-VM volume name, if available'''
  96. return self._vm_name
  97. @property
  98. def pool(self):
  99. '''Storage volume pool name.'''
  100. if self._pool is not None:
  101. return self._pool
  102. try:
  103. self._fetch_info()
  104. except qubesadmin.exc.QubesDaemonAccessError:
  105. raise qubesadmin.exc.QubesPropertyAccessError('pool')
  106. return str(self._info['pool'])
  107. @property
  108. def vid(self):
  109. '''Storage volume id, unique within given pool.'''
  110. if self._vid is not None:
  111. return self._vid
  112. try:
  113. self._fetch_info()
  114. except qubesadmin.exc.QubesDaemonAccessError:
  115. raise qubesadmin.exc.QubesPropertyAccessError('vid')
  116. return str(self._info['vid'])
  117. @property
  118. def size(self):
  119. '''Size of volume, in bytes.'''
  120. try:
  121. self._fetch_info()
  122. except qubesadmin.exc.QubesDaemonAccessError:
  123. raise qubesadmin.exc.QubesPropertyAccessError('size')
  124. return int(self._info['size'])
  125. @property
  126. def usage(self):
  127. '''Used volume space, in bytes.'''
  128. try:
  129. self._fetch_info()
  130. except qubesadmin.exc.QubesDaemonAccessError:
  131. raise qubesadmin.exc.QubesPropertyAccessError('usage')
  132. return int(self._info['usage'])
  133. @property
  134. def rw(self):
  135. '''True if volume is read-write.'''
  136. try:
  137. self._fetch_info()
  138. except qubesadmin.exc.QubesDaemonAccessError:
  139. raise qubesadmin.exc.QubesPropertyAccessError('rw')
  140. return self._info['rw'] == 'True'
  141. @rw.setter
  142. def rw(self, value):
  143. '''Set rw property'''
  144. self._qubesd_call('Set.rw', str(value).encode('ascii'))
  145. self._info = None
  146. @property
  147. def snap_on_start(self):
  148. '''Create a snapshot from source on VM start.'''
  149. try:
  150. self._fetch_info()
  151. except qubesadmin.exc.QubesDaemonAccessError:
  152. raise qubesadmin.exc.QubesPropertyAccessError('snap_on_start')
  153. return self._info['snap_on_start'] == 'True'
  154. @property
  155. def save_on_stop(self):
  156. '''Commit changes to original volume on VM stop.'''
  157. try:
  158. self._fetch_info()
  159. except qubesadmin.exc.QubesDaemonAccessError:
  160. raise qubesadmin.exc.QubesPropertyAccessError('save_on_stop')
  161. return self._info['save_on_stop'] == 'True'
  162. @property
  163. def source(self):
  164. '''Volume ID of source volume (for :py:attr:`snap_on_start`).
  165. If None, this volume itself will be used.
  166. '''
  167. try:
  168. self._fetch_info()
  169. except qubesadmin.exc.QubesDaemonAccessError:
  170. raise qubesadmin.exc.QubesPropertyAccessError('source')
  171. if self._info['source']:
  172. return self._info['source']
  173. return None
  174. @property
  175. def revisions_to_keep(self):
  176. '''Number of revisions to keep around'''
  177. try:
  178. self._fetch_info()
  179. except qubesadmin.exc.QubesDaemonAccessError:
  180. raise qubesadmin.exc.QubesPropertyAccessError('revisions_to_keep')
  181. return int(self._info['revisions_to_keep'])
  182. @revisions_to_keep.setter
  183. def revisions_to_keep(self, value):
  184. '''Set revisions_to_keep property'''
  185. self._qubesd_call('Set.revisions_to_keep', str(value).encode('ascii'))
  186. self._info = None
  187. def is_outdated(self):
  188. '''Returns `True` if this snapshot of a source volume (for
  189. `snap_on_start`=True) is outdated.
  190. '''
  191. try:
  192. self._fetch_info()
  193. except qubesadmin.exc.QubesDaemonAccessError:
  194. raise qubesadmin.exc.QubesPropertyAccessError('is_outdated')
  195. return self._info.get('is_outdated', False) == 'True'
  196. def resize(self, size):
  197. '''Resize volume.
  198. Currently only extending is supported.
  199. :param int size: new size in bytes.
  200. '''
  201. self._qubesd_call('Resize', str(size).encode('ascii'))
  202. @property
  203. def revisions(self):
  204. ''' Returns iterable containing revision identifiers'''
  205. revisions = self._qubesd_call('ListSnapshots')
  206. return revisions.decode('ascii').splitlines()
  207. def revert(self, revision):
  208. ''' Revert volume to previous revision
  209. :param str revision: Revision identifier to revert to
  210. '''
  211. if not isinstance(revision, str):
  212. raise TypeError('revision must be a str')
  213. self._qubesd_call('Revert', revision.encode('ascii'))
  214. def import_data(self, stream):
  215. ''' Import volume data from a given file-like object.
  216. This function overrides existing volume content.
  217. :param stream: file-like object, must support fileno()
  218. '''
  219. self._qubesd_call('Import', payload_stream=stream)
  220. def import_data_with_size(self, stream, size):
  221. ''' Import volume data from a given file-like object, informing qubesd
  222. that data has a specific size.
  223. This function overrides existing volume content.
  224. :param stream: file-like object, must support fileno()
  225. :param size: size of data in bytes
  226. '''
  227. size_line = str(size) + '\n'
  228. self._qubesd_call(
  229. 'ImportWithSize', payload=size_line.encode(),
  230. payload_stream=stream)
  231. def clear_data(self):
  232. ''' Clear existing volume content. '''
  233. self._qubesd_call('Clear')
  234. def clone(self, source):
  235. ''' Clone data from sane volume of another VM.
  236. This function override existing volume content.
  237. This operation is implemented for VM volumes - those in vm.volumes
  238. collection (not pool.volumes).
  239. :param source: source volume object
  240. '''
  241. # pylint: disable=protected-access
  242. # get a token from source volume
  243. token = source._qubesd_call('CloneFrom')
  244. # and use it to actually clone volume data
  245. self._qubesd_call('CloneTo', payload=token)
  246. class Pool(object):
  247. ''' A Pool is used to manage different kind of volumes (File
  248. based/LVM/Btrfs/...).
  249. '''
  250. def __init__(self, app, name=None):
  251. ''' Initialize storage pool wrapper
  252. :param app: Qubes() object
  253. :param name: name of the pool
  254. '''
  255. self.app = app
  256. self.name = name
  257. self._config = None
  258. def __str__(self):
  259. return self.name
  260. def __eq__(self, other):
  261. if isinstance(other, Pool):
  262. return self.name == other.name
  263. if isinstance(other, str):
  264. return self.name == other
  265. return NotImplemented
  266. def __lt__(self, other):
  267. if isinstance(other, Pool):
  268. return self.name < other.name
  269. return NotImplemented
  270. @property
  271. def usage_details(self):
  272. ''' Storage pool usage details (current - not cached) '''
  273. try:
  274. pool_usage_data = self.app.qubesd_call(
  275. 'dom0', 'admin.pool.UsageDetails', self.name, None)
  276. except qubesadmin.exc.QubesDaemonAccessError:
  277. raise qubesadmin.exc.QubesPropertyAccessError('usage_details')
  278. pool_usage_data = pool_usage_data.decode('utf-8')
  279. assert pool_usage_data.endswith('\n') or pool_usage_data == ''
  280. pool_usage_data = pool_usage_data[:-1]
  281. def _int_split(text): # pylint: disable=missing-docstring
  282. key, value = text.split("=", 1)
  283. return key, int(value)
  284. return dict(_int_split(l) for l in pool_usage_data.splitlines())
  285. @property
  286. def config(self):
  287. ''' Storage pool config '''
  288. if self._config is None:
  289. try:
  290. pool_info_data = self.app.qubesd_call(
  291. 'dom0', 'admin.pool.Info', self.name, None)
  292. except qubesadmin.exc.QubesDaemonAccessError:
  293. raise qubesadmin.exc.QubesPropertyAccessError('config')
  294. pool_info_data = pool_info_data.decode('utf-8')
  295. assert pool_info_data.endswith('\n')
  296. pool_info_data = pool_info_data[:-1]
  297. self._config = dict(
  298. l.split('=', 1) for l in pool_info_data.splitlines())
  299. return self._config
  300. @property
  301. def size(self):
  302. ''' Storage pool size, in bytes'''
  303. try:
  304. return int(self.usage_details['data_size'])
  305. except KeyError:
  306. # pool driver does not provide size information
  307. return None
  308. @property
  309. def usage(self):
  310. ''' Space used in the pool, in bytes '''
  311. try:
  312. return int(self.usage_details['data_usage'])
  313. except KeyError:
  314. # pool driver does not provide usage information
  315. return None
  316. @property
  317. def driver(self):
  318. ''' Storage pool driver '''
  319. return self.config['driver']
  320. @property
  321. def revisions_to_keep(self):
  322. '''Number of revisions to keep around'''
  323. return int(self.config['revisions_to_keep'])
  324. @revisions_to_keep.setter
  325. def revisions_to_keep(self, value):
  326. '''Set revisions_to_keep property'''
  327. self.app.qubesd_call('dom0',
  328. 'admin.pool.Set.revisions_to_keep',
  329. self.name,
  330. str(value).encode('ascii'))
  331. self._config = None
  332. @property
  333. def volumes(self):
  334. ''' Volumes managed by this pool '''
  335. try:
  336. volumes_data = self.app.qubesd_call(
  337. 'dom0', 'admin.pool.volume.List', self.name, None)
  338. except qubesadmin.exc.QubesDaemonAccessError:
  339. raise qubesadmin.exc.QubesPropertyAccessError('volumes')
  340. assert volumes_data.endswith(b'\n')
  341. volumes_data = volumes_data[:-1].decode('ascii')
  342. for vid in volumes_data.splitlines():
  343. yield Volume(self.app, self.name, vid)