storage_callback.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. #
  2. # The Qubes OS Project, http://www.qubes-os.org
  3. #
  4. # Copyright (C) 2020 David Hobach <david@hobach.de>
  5. #
  6. # This library is free software; you can redistribute it and/or
  7. # modify it under the terms of the GNU Lesser General Public
  8. # License as published by the Free Software Foundation; either
  9. # version 2.1 of the License, or (at your option) any later version.
  10. #
  11. # This library is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. # Lesser General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU Lesser General Public
  17. # License along with this library; if not, see <https://www.gnu.org/licenses/>.
  18. #
  19. ''' Tests for the callback storage driver.
  20. They are mostly based upon the lvm storage driver tests.
  21. '''
  22. # pylint: disable=line-too-long
  23. import os
  24. import json
  25. import subprocess
  26. import qubes.tests
  27. import qubes.tests.storage
  28. import qubes.tests.storage_lvm
  29. from qubes.tests.storage_lvm import skipUnlessLvmPoolExists
  30. from qubes.storage.callback import CallbackPool, CallbackVolume
  31. CB_CONF = '/etc/qubes_callback.json'
  32. LOG_BIN = '/tmp/testCbLogArgs'
  33. CB_DATA = {'utest-callback-01': {
  34. 'bdriver': 'lvm_thin',
  35. 'bdriver_args': {
  36. 'volume_group': qubes.tests.storage_lvm.DEFAULT_LVM_POOL.split('/')[0],
  37. 'thin_pool': qubes.tests.storage_lvm.DEFAULT_LVM_POOL.split('/')[1]
  38. },
  39. 'description': 'For unit testing of the callback pool driver.'
  40. },
  41. 'utest-callback-02': {
  42. 'bdriver': 'lvm_thin',
  43. 'bdriver_args': {
  44. 'volume_group': qubes.tests.storage_lvm.DEFAULT_LVM_POOL.split('/')[0],
  45. 'thin_pool': qubes.tests.storage_lvm.DEFAULT_LVM_POOL.split('/')[1]
  46. },
  47. 'cmd': LOG_BIN,
  48. 'description': 'For unit testing of the callback pool driver.'
  49. },
  50. 'utest-callback-03': {
  51. 'bdriver': 'lvm_thin',
  52. 'bdriver_args': {
  53. 'volume_group': qubes.tests.storage_lvm.DEFAULT_LVM_POOL.split('/')[0],
  54. 'thin_pool': qubes.tests.storage_lvm.DEFAULT_LVM_POOL.split('/')[1]
  55. },
  56. 'cmd': 'exit 1',
  57. 'pre_sinit': LOG_BIN + ' pre_sinit',
  58. 'pre_setup': LOG_BIN + ' pre_setup',
  59. 'pre_volume_create': LOG_BIN + ' pre_volume_create',
  60. 'post_volume_create': LOG_BIN + ' post_volume_create',
  61. 'pre_volume_import_data': LOG_BIN + ' pre_volume_import_data',
  62. 'post_volume_import_data_end': LOG_BIN + ' post_volume_import_data_end',
  63. 'post_volume_remove': LOG_BIN + ' post_volume_remove',
  64. 'post_destroy': '-',
  65. 'description': 'For unit testing of the callback pool driver.'
  66. },
  67. 'testing-fail-missing-all': {
  68. },
  69. 'testing-fail-missing-bdriver-args': {
  70. 'bdriver': 'file',
  71. 'description': 'For unit testing of the callback pool driver.'
  72. },
  73. 'testing-fail-incorrect-bdriver': {
  74. 'bdriver': 'nonexisting-bdriver',
  75. 'bdriver_args': {
  76. 'foo': 'bar',
  77. 'bla': 'blub'
  78. },
  79. 'cmd': 'echo foo',
  80. 'description': 'For unit testing of the callback pool driver.'
  81. },
  82. }
  83. class CallbackBase:
  84. ''' Mixin base class for callback tests. Has no base class. '''
  85. conf_id = None
  86. pool_name = 'test-callback'
  87. @classmethod
  88. def setUpClass(cls, conf_id='utest-callback-01'):
  89. conf = {'name': cls.pool_name,
  90. 'driver': 'callback',
  91. 'conf_id': conf_id}
  92. cls.conf_id = conf_id
  93. assert not(os.path.exists(CB_CONF)), '%s must NOT exist. Please delete it, if you do not need it.' % CB_CONF
  94. sudo = [] if os.getuid() == 0 else ['sudo']
  95. subprocess.run(sudo + ['install', '-m', '666', '/dev/null', CB_CONF], check=True)
  96. with open(CB_CONF, 'w') as outfile:
  97. json.dump(CB_DATA, outfile)
  98. super().setUpClass(pool_class=CallbackPool, volume_class=CallbackVolume, pool_conf=conf)
  99. @classmethod
  100. def tearDownClass(cls):
  101. super().tearDownClass()
  102. sudo = [] if os.getuid() == 0 else ['sudo']
  103. subprocess.run(sudo + ['rm', '-f', CB_CONF], check=True)
  104. def setUp(self, init_pool=True):
  105. super().setUp(init_pool=init_pool)
  106. if init_pool:
  107. #tests from other pools will assume that they're fully initialized after calling __init__()
  108. self.loop.run_until_complete(self.pool._assert_initialized())
  109. def test_000_000_callback_test_init(self):
  110. ''' Check whether the test init did work. '''
  111. if hasattr(self, 'pool'):
  112. self.assertIsInstance(self.pool, CallbackPool)
  113. self.assertEqual(self.pool.backend_class, qubes.storage.lvm.ThinPool)
  114. self.assertTrue(os.path.isfile(CB_CONF))
  115. @skipUnlessLvmPoolExists
  116. class TC_00_CallbackPool(CallbackBase, qubes.tests.storage_lvm.TC_00_ThinPool):
  117. pass
  118. @skipUnlessLvmPoolExists
  119. class TC_01_CallbackPool(CallbackBase, qubes.tests.storage_lvm.TC_01_ThinPool):
  120. pass
  121. @skipUnlessLvmPoolExists
  122. class TC_02_cb_StorageHelpers(CallbackBase, qubes.tests.storage_lvm.TC_02_StorageHelpers):
  123. pass
  124. class LoggingCallbackBase(CallbackBase):
  125. ''' Mixin base class that sets up LOG_BIN and removes `LoggingCallbackBase.test_log`, if needed. '''
  126. test_log = '/tmp/cb_tests.log'
  127. test_log_expected = None #dict: class + test name --> test index (int, 0..x) --> expected _additional_ log content
  128. volume_name = 'volume_name'
  129. xml_path = '/tmp/qubes-test-callback.xml'
  130. @classmethod
  131. def setUpClass(cls, conf_id=None, log_expected=None):
  132. script = """#!/bin/bash
  133. i=1
  134. for arg in "$@" ; do
  135. echo "$i: $arg" >> "LOG_OUT"
  136. (( i++))
  137. done
  138. exit 0
  139. """
  140. script = script.replace('LOG_OUT', cls.test_log)
  141. with open(LOG_BIN, 'w') as f:
  142. f.write(script)
  143. os.chmod(LOG_BIN, 0o775)
  144. cls.test_log_expected = log_expected
  145. super().setUpClass(conf_id=conf_id)
  146. @classmethod
  147. def tearDownClass(cls):
  148. super().tearDownClass()
  149. os.remove(LOG_BIN)
  150. def setUp(self, init_pool=False):
  151. assert not(os.path.exists(self.test_log)), '%s must NOT exist. Please delete it, if you do not need it.' % self.test_log
  152. self.maxDiff = None
  153. xml = """
  154. <qubes>
  155. <labels>
  156. <label color="0x000000" id="label-8">black</label>
  157. </labels>
  158. <pools>
  159. <pool dir_path="/var/lib/qubes" driver="file" name="varlibqubes" revisions_to_keep="1"/>
  160. <pool dir_path="/var/lib/qubes/vm-kernels" driver="linux-kernel" name="linux-kernel"/>
  161. <pool conf_id="CONF_ID" driver="callback" name="POOL_NAME"/>
  162. </pools>
  163. <properties>
  164. <property name="clockvm"></property>
  165. <property name="default_pool_kernel">linux-kernel</property>
  166. <property name="default_template"></property>
  167. <property name="updatevm"></property>
  168. </properties>
  169. <domains>
  170. <domain id="domain-0" class="AdminVM">
  171. <properties>
  172. <property name="label">black</property>
  173. </properties>
  174. <features/>
  175. <tags/>
  176. </domain>
  177. </domains>
  178. </qubes>
  179. """
  180. xml = xml.replace('CONF_ID', self.conf_id)
  181. xml = xml.replace('POOL_NAME', self.pool_name)
  182. with open(self.xml_path, 'w') as f:
  183. f.write(xml)
  184. self.app = qubes.Qubes(self.xml_path,
  185. clockvm=None,
  186. updatevm=None,
  187. offline_mode=True,
  188. )
  189. os.environ['QUBES_XML_PATH'] = self.xml_path
  190. super().setUp(init_pool=init_pool)
  191. def tearDown(self):
  192. super().tearDown()
  193. os.unlink(self.app.store)
  194. self.app.close()
  195. del self.app
  196. for attr in dir(self):
  197. if isinstance(getattr(self, attr), qubes.vm.BaseVM):
  198. delattr(self, attr)
  199. if os.path.exists(self.test_log):
  200. os.remove(self.test_log)
  201. if os.path.exists(self.xml_path):
  202. os.remove(self.xml_path)
  203. def assertLogContent(self, expected):
  204. ''' Assert that the log matches the given string.
  205. :param expected: Expected content of the log file (String).
  206. '''
  207. try:
  208. with open(self.test_log, 'r') as f:
  209. found = f.read()
  210. except FileNotFoundError:
  211. found = ''
  212. if expected != '':
  213. expected = expected + '\n'
  214. self.assertEqual(found, expected)
  215. def assertLog(self, test_name, ind=0):
  216. ''' Assert that the log matches the expected status.
  217. :param test_name: Name of the test.
  218. :param ind: Index inside `test_log_expected` to check against (Integer starting at 0).
  219. '''
  220. d = self.test_log_expected[str(self.__class__) + test_name]
  221. expected = []
  222. for i in range(ind+1):
  223. expected = expected + [d[i]]
  224. expected = filter(None, expected)
  225. self.assertLogContent('\n'.join(expected))
  226. def test_001_callbacks(self):
  227. ''' create a lvm pool with additional callbacks '''
  228. config = {
  229. 'name': self.volume_name,
  230. 'pool': self.pool_name,
  231. 'save_on_stop': True,
  232. 'rw': True,
  233. 'revisions_to_keep': 2,
  234. 'size': qubes.config.defaults['root_img_size'],
  235. }
  236. new_size = 2 * qubes.config.defaults['root_img_size']
  237. test_name = 'test_001_callbacks'
  238. self.assertLog(test_name, 0)
  239. self.init_pool()
  240. self.assertFalse(self.created_pool)
  241. self.assertIsInstance(self.pool, CallbackPool)
  242. self.assertLog(test_name, 1)
  243. vm = qubes.tests.storage.TestVM(self)
  244. volume = self.app.get_pool(self.pool.name).init_volume(vm, config)
  245. self.assertLog(test_name, 2)
  246. self.loop.run_until_complete(volume.create())
  247. self.assertLog(test_name, 3)
  248. self.loop.run_until_complete(volume.import_data(new_size))
  249. self.assertLog(test_name, 4)
  250. self.loop.run_until_complete(volume.import_data_end(True))
  251. self.assertLog(test_name, 5)
  252. self.assertEqual(volume.size, new_size)
  253. self.loop.run_until_complete(volume.remove())
  254. self.assertLog(test_name, 6)
  255. @skipUnlessLvmPoolExists
  256. class TC_91_CallbackPool(LoggingCallbackBase, qubes.tests.storage_lvm.ThinPoolBase):
  257. ''' Tests for the actual callback functionality.
  258. conf_id = utest-callback-02
  259. '''
  260. @classmethod
  261. def setUpClass(cls):
  262. conf_id = 'utest-callback-02'
  263. name = cls.pool_name
  264. bdriver = (CB_DATA[conf_id])['bdriver']
  265. ctor_params = json.dumps(CB_DATA[conf_id], sort_keys=True, indent=2)
  266. vname = cls.volume_name
  267. vid = '{0}/vm-test-inst-appvm-{1}'.format(qubes.tests.storage_lvm.DEFAULT_LVM_POOL.split('/')[0], vname)
  268. vsize = 2 * qubes.config.defaults['root_img_size']
  269. log_expected = \
  270. {str(cls) + 'test_001_callbacks':
  271. {0: '',
  272. 1: '',
  273. 2: '',
  274. 3: '1: {0}\n2: {1}\n3: pre_sinit\n4: {2}\n5: {3}\n1: {0}\n2: {1}\n3: pre_volume_create\n4: {2}\n5: {3}\n6: {4}\n7: {5}\n8: None\n1: {0}\n2: {1}\n3: post_volume_create\n4: {2}\n5: {3}\n6: {4}\n7: {5}\n8: None'.format(name, bdriver, conf_id, ctor_params, vname, vid),
  275. 4: '1: {0}\n2: {1}\n3: pre_volume_import_data\n4: {2}\n5: {3}\n6: {4}\n7: {5}\n8: None\n9: {6}'.format(name, bdriver, conf_id, ctor_params, vname, vid, vsize),
  276. 5: '1: {0}\n2: {1}\n3: post_volume_import_data_end\n4: {2}\n5: {3}\n6: {4}\n7: {5}\n8: None\n9: {6}'.format(name, bdriver, conf_id, ctor_params, vname, vid, True),
  277. 6: '1: {0}\n2: {1}\n3: post_volume_remove\n4: {2}\n5: {3}\n6: {4}\n7: {5}\n8: None'.format(name, bdriver, conf_id, ctor_params, vname, vid),
  278. }
  279. }
  280. super().setUpClass(conf_id=conf_id, log_expected=log_expected)
  281. @skipUnlessLvmPoolExists
  282. class TC_92_CallbackPool(LoggingCallbackBase, qubes.tests.storage_lvm.ThinPoolBase):
  283. ''' Tests for the actual callback functionality.
  284. conf_id = utest-callback-03
  285. '''
  286. @classmethod
  287. def setUpClass(cls):
  288. log_expected = \
  289. {str(cls) + 'test_001_callbacks':
  290. {0: '',
  291. 1: '',
  292. 2: '',
  293. 3: '1: pre_sinit\n1: pre_volume_create\n1: post_volume_create',
  294. 4: '1: pre_volume_import_data',
  295. 5: '1: post_volume_import_data_end',
  296. 6: '1: post_volume_remove',
  297. }
  298. }
  299. super().setUpClass(conf_id='utest-callback-03', log_expected=log_expected)
  300. def test_002_failing_callback(self):
  301. ''' Make sure that we check the exit code of executed callbacks. '''
  302. config = {
  303. 'name': self.volume_name,
  304. 'pool': self.pool_name,
  305. 'save_on_stop': True,
  306. 'rw': True,
  307. 'revisions_to_keep': 2,
  308. 'size': qubes.config.defaults['root_img_size'],
  309. }
  310. self.init_pool()
  311. vm = qubes.tests.storage.TestVM(self)
  312. volume = self.app.get_pool(self.pool.name).init_volume(vm, config)
  313. with self.assertRaises(subprocess.CalledProcessError) as cm:
  314. #should trigger the `exit 1` of `cmd`
  315. self.loop.run_until_complete(volume.start())
  316. self.assertTrue('exit status 1' in str(cm.exception))
  317. def test_003_errors(self):
  318. ''' Make sure we error out on common user & dev mistakes. '''
  319. #missing conf_id
  320. with self.assertRaises(qubes.storage.StoragePoolException):
  321. cb = CallbackPool(name='some-name', conf_id='')
  322. #invalid conf_id
  323. with self.assertRaises(qubes.storage.StoragePoolException):
  324. cb = CallbackPool(name='some-name', conf_id='nonexisting-id')
  325. #incorrect backend driver
  326. with self.assertRaises(qubes.storage.StoragePoolException):
  327. cb = CallbackPool(name='some-name', conf_id='testing-fail-incorrect-bdriver')
  328. #missing config entries
  329. with self.assertRaises(qubes.storage.StoragePoolException):
  330. cb = CallbackPool(name='some-name', conf_id='testing-fail-missing-all')
  331. #missing bdriver args
  332. with self.assertRaises(TypeError):
  333. cb = CallbackPool(name='some-name', conf_id='testing-fail-missing-bdriver-args')
  334. class TC_93_CallbackPool(qubes.tests.QubesTestCase):
  335. def test_001_missing_conf(self):
  336. ''' A missing config file must cause errors. '''
  337. with self.assertRaises(FileNotFoundError):
  338. cb = CallbackPool(name='some-name', conf_id='nonexisting-id')