backup.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. #
  2. # The Qubes OS Project, https://www.qubes-os.org/
  3. #
  4. # Copyright (C) 2014-2015
  5. # Marek Marczykowski-Górecki <marmarek@invisiblethingslab.com>
  6. # Copyright (C) 2015 Wojtek Porczyk <woju@invisiblethingslab.com>
  7. #
  8. # This program is free software; you can redistribute it and/or modify
  9. # it under the terms of the GNU General Public License as published by
  10. # the Free Software Foundation; either version 2 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 General Public License for more details.
  17. #
  18. # You should have received a copy of the GNU General Public License along
  19. # with this program; if not, write to the Free Software Foundation, Inc.,
  20. # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
  21. #
  22. import hashlib
  23. import logging
  24. import multiprocessing
  25. import os
  26. import shutil
  27. import sys
  28. import qubes
  29. import qubes.backup
  30. import qubes.exc
  31. import qubes.storage.lvm
  32. import qubes.tests
  33. import qubes.tests.storage_lvm
  34. import qubes.vm
  35. import qubes.vm.appvm
  36. import qubes.vm.templatevm
  37. import qubes.vm.qubesvm
  38. # noinspection PyAttributeOutsideInit
  39. class BackupTestsMixin(object):
  40. class BackupErrorHandler(logging.Handler):
  41. def __init__(self, errors_queue, level=logging.NOTSET):
  42. super(BackupTestsMixin.BackupErrorHandler, self).__init__(level)
  43. self.errors_queue = errors_queue
  44. def emit(self, record):
  45. self.errors_queue.put(record.getMessage())
  46. def setUp(self):
  47. super(BackupTestsMixin, self).setUp()
  48. try:
  49. self.init_default_template(self.template)
  50. except AttributeError:
  51. self.init_default_template()
  52. self.error_detected = multiprocessing.Queue()
  53. self.log.debug("Creating backupvm")
  54. self.backupdir = os.path.join(os.environ["HOME"], "test-backup")
  55. if os.path.exists(self.backupdir):
  56. shutil.rmtree(self.backupdir)
  57. os.mkdir(self.backupdir)
  58. self.error_handler = self.BackupErrorHandler(self.error_detected,
  59. level=logging.WARNING)
  60. backup_log = logging.getLogger('qubes.backup')
  61. backup_log.addHandler(self.error_handler)
  62. def tearDown(self):
  63. super(BackupTestsMixin, self).tearDown()
  64. shutil.rmtree(self.backupdir)
  65. backup_log = logging.getLogger('qubes.backup')
  66. backup_log.removeHandler(self.error_handler)
  67. def fill_image(self, path, size=None, sparse=False):
  68. block_size = 4096
  69. self.log.debug("Filling %s" % path)
  70. f = open(path, 'wb+')
  71. if size is None:
  72. f.seek(0, 2)
  73. size = f.tell()
  74. f.seek(0)
  75. for block_num in range(int(size/block_size)):
  76. if sparse:
  77. f.seek(block_size, 1)
  78. f.write(b'a' * block_size)
  79. f.close()
  80. # NOTE: this was create_basic_vms
  81. def create_backup_vms(self, pool=None):
  82. template = self.app.default_template
  83. vms = []
  84. vmname = self.make_vm_name('test-net')
  85. self.log.debug("Creating %s" % vmname)
  86. testnet = self.app.add_new_vm(qubes.vm.appvm.AppVM,
  87. name=vmname, template=template, provides_network=True,
  88. label='red')
  89. self.loop.run_until_complete(
  90. testnet.create_on_disk(pool=pool))
  91. testnet.features['service.ntpd'] = True
  92. vms.append(testnet)
  93. self.fill_image(testnet.storage.export('private'), 20*1024*1024)
  94. vmname = self.make_vm_name('test1')
  95. self.log.debug("Creating %s" % vmname)
  96. testvm1 = self.app.add_new_vm(qubes.vm.appvm.AppVM,
  97. name=vmname, template=template, label='red')
  98. testvm1.uses_default_netvm = False
  99. testvm1.netvm = testnet
  100. self.loop.run_until_complete(
  101. testvm1.create_on_disk(pool=pool))
  102. vms.append(testvm1)
  103. self.fill_image(testvm1.storage.export('private'), 100 * 1024 * 1024)
  104. vmname = self.make_vm_name('testhvm1')
  105. self.log.debug("Creating %s" % vmname)
  106. testvm2 = self.app.add_new_vm(qubes.vm.standalonevm.StandaloneVM,
  107. name=vmname,
  108. virt_mode='hvm',
  109. label='red')
  110. self.loop.run_until_complete(
  111. testvm2.create_on_disk(pool=pool))
  112. self.fill_image(testvm2.storage.export('root'), 1024 * 1024 * 1024, \
  113. True)
  114. vms.append(testvm2)
  115. vmname = self.make_vm_name('template')
  116. self.log.debug("Creating %s" % vmname)
  117. testvm3 = self.app.add_new_vm(qubes.vm.templatevm.TemplateVM,
  118. name=vmname, label='red')
  119. self.loop.run_until_complete(
  120. testvm3.create_on_disk(pool=pool))
  121. self.fill_image(testvm3.storage.export('root'), 100 * 1024 * 1024, True)
  122. vms.append(testvm3)
  123. vmname = self.make_vm_name('custom')
  124. self.log.debug("Creating %s" % vmname)
  125. testvm4 = self.app.add_new_vm(qubes.vm.appvm.AppVM,
  126. name=vmname, template=testvm3, label='red')
  127. self.loop.run_until_complete(
  128. testvm4.create_on_disk(pool=pool))
  129. vms.append(testvm4)
  130. self.app.save()
  131. return vms
  132. def make_backup(self, vms, target=None, expect_failure=False, **kwargs):
  133. if target is None:
  134. target = self.backupdir
  135. try:
  136. backup = qubes.backup.Backup(self.app, vms, **kwargs)
  137. except qubes.exc.QubesException as e:
  138. if not expect_failure:
  139. self.fail("QubesException during backup_prepare: %s" % str(e))
  140. else:
  141. raise
  142. if 'passphrase' not in kwargs:
  143. backup.passphrase = 'qubes'
  144. backup.target_dir = target
  145. try:
  146. self.loop.run_until_complete(backup.backup_do())
  147. except qubes.exc.QubesException as e:
  148. if not expect_failure:
  149. self.fail("QubesException during backup_do: %s" % str(e))
  150. else:
  151. raise
  152. def restore_backup(self, source=None, appvm=None, options=None,
  153. expect_errors=None, manipulate_restore_info=None,
  154. passphrase='qubes'):
  155. self.skipTest('Test not converted to Qubes 4.0')
  156. if source is None:
  157. backupfile = os.path.join(self.backupdir,
  158. sorted(os.listdir(self.backupdir))[-1])
  159. else:
  160. backupfile = source
  161. with self.assertNotRaises(qubes.exc.QubesException):
  162. restore_op = qubes.backup.BackupRestore(
  163. self.app, backupfile, appvm, passphrase)
  164. if options:
  165. for key, value in options.items():
  166. setattr(restore_op.options, key, value)
  167. restore_info = restore_op.get_restore_info()
  168. if callable(manipulate_restore_info):
  169. restore_info = manipulate_restore_info(restore_info)
  170. self.log.debug(restore_op.get_restore_summary(restore_info))
  171. with self.assertNotRaises(qubes.exc.QubesException):
  172. restore_op.restore_do(restore_info)
  173. errors = []
  174. if expect_errors is None:
  175. expect_errors = []
  176. else:
  177. self.assertFalse(self.error_detected.empty(),
  178. "Restore errors expected, but none detected")
  179. while not self.error_detected.empty():
  180. current_error = self.error_detected.get()
  181. if any(map(current_error.startswith, expect_errors)):
  182. continue
  183. errors.append(current_error)
  184. self.assertTrue(len(errors) == 0,
  185. "Error(s) detected during backup_restore_do: %s" %
  186. '\n'.join(errors))
  187. if not appvm and not os.path.isdir(backupfile):
  188. os.unlink(backupfile)
  189. def create_sparse(self, path, size):
  190. f = open(path, "w")
  191. f.truncate(size)
  192. f.close()
  193. def vm_checksum(self, vms):
  194. hashes = {}
  195. for vm in vms:
  196. assert isinstance(vm, qubes.vm.qubesvm.QubesVM)
  197. hashes[vm.name] = {}
  198. for name, volume in vm.volumes.items():
  199. if not volume.rw or not volume.save_on_stop:
  200. continue
  201. vol_path = vm.storage.get_pool(volume).export(volume)
  202. hasher = hashlib.sha1()
  203. with open(vol_path, 'rb') as afile:
  204. for buf in iter(lambda: afile.read(4096000), b''):
  205. hasher.update(buf)
  206. hashes[vm.name][name] = hasher.hexdigest()
  207. return hashes
  208. def assertCorrectlyRestored(self, orig_vms, orig_hashes):
  209. ''' Verify if restored VMs are identical to those before backup.
  210. :param orig_vms: collection of original QubesVM objects
  211. :param orig_hashes: result of :py:meth:`vm_checksum` on original VMs,
  212. before backup
  213. :return:
  214. '''
  215. for vm in orig_vms:
  216. self.assertIn(vm.name, self.app.domains)
  217. restored_vm = self.app.domains[vm.name]
  218. for prop in ('name', 'kernel',
  219. 'memory', 'maxmem', 'kernelopts',
  220. 'services', 'vcpus', 'features'
  221. 'include_in_backups', 'default_user', 'qrexec_timeout',
  222. 'autostart', 'pci_strictreset', 'debug',
  223. 'internal'):
  224. if not hasattr(vm, prop):
  225. continue
  226. self.assertEqual(
  227. getattr(vm, prop), getattr(restored_vm, prop),
  228. "VM {} - property {} not properly restored".format(
  229. vm.name, prop))
  230. for prop in ('netvm', 'template', 'label'):
  231. if not hasattr(vm, prop):
  232. continue
  233. orig_value = getattr(vm, prop)
  234. restored_value = getattr(restored_vm, prop)
  235. if orig_value and restored_value:
  236. self.assertEqual(orig_value.name, restored_value.name,
  237. "VM {} - property {} not properly restored".format(
  238. vm.name, prop))
  239. else:
  240. self.assertEqual(orig_value, restored_value,
  241. "VM {} - property {} not properly restored".format(
  242. vm.name, prop))
  243. for dev_class in vm.devices.keys():
  244. for dev in vm.devices[dev_class]:
  245. self.assertIn(dev, restored_vm.devices[dev_class],
  246. "VM {} - {} device not restored".format(
  247. vm.name, dev_class))
  248. if orig_hashes:
  249. hashes = self.vm_checksum([restored_vm])[restored_vm.name]
  250. self.assertEqual(orig_hashes[vm.name], hashes,
  251. "VM {} - disk images are not properly restored".format(
  252. vm.name))
  253. class TC_00_Backup(BackupTestsMixin, qubes.tests.SystemTestCase):
  254. def test_000_basic_backup(self):
  255. vms = self.create_backup_vms()
  256. orig_hashes = self.vm_checksum(vms)
  257. self.make_backup(vms)
  258. self.remove_vms(reversed(vms))
  259. self.restore_backup()
  260. self.assertCorrectlyRestored(vms, orig_hashes)
  261. self.remove_vms(reversed(vms))
  262. def test_001_compressed_backup(self):
  263. vms = self.create_backup_vms()
  264. orig_hashes = self.vm_checksum(vms)
  265. self.make_backup(vms, compressed=True)
  266. self.remove_vms(reversed(vms))
  267. self.restore_backup()
  268. self.assertCorrectlyRestored(vms, orig_hashes)
  269. def test_002_encrypted_backup(self):
  270. vms = self.create_backup_vms()
  271. orig_hashes = self.vm_checksum(vms)
  272. self.make_backup(vms, encrypted=True)
  273. self.remove_vms(reversed(vms))
  274. self.restore_backup()
  275. self.assertCorrectlyRestored(vms, orig_hashes)
  276. def test_003_compressed_encrypted_backup(self):
  277. vms = self.create_backup_vms()
  278. orig_hashes = self.vm_checksum(vms)
  279. self.make_backup(vms, compressed=True, encrypted=True)
  280. self.remove_vms(reversed(vms))
  281. self.restore_backup()
  282. self.assertCorrectlyRestored(vms, orig_hashes)
  283. def test_004_sparse_multipart(self):
  284. vms = []
  285. vmname = self.make_vm_name('testhvm2')
  286. self.log.debug("Creating %s" % vmname)
  287. hvmtemplate = self.app.add_new_vm(
  288. qubes.vm.templatevm.TemplateVM, name=vmname, virt_mode='hvm', label='red')
  289. hvmtemplate.create_on_disk()
  290. self.fill_image(
  291. os.path.join(hvmtemplate.dir_path, '00file'),
  292. 195 * 1024 * 1024 - 4096 * 3)
  293. self.fill_image(hvmtemplate.storage.export('private'),
  294. 195 * 1024 * 1024 - 4096 * 3)
  295. self.fill_image(hvmtemplate.storage.export('root'), 1024 * 1024 * 1024,
  296. sparse=True)
  297. vms.append(hvmtemplate)
  298. self.app.save()
  299. orig_hashes = self.vm_checksum(vms)
  300. self.make_backup(vms)
  301. self.remove_vms(reversed(vms))
  302. self.restore_backup()
  303. self.assertCorrectlyRestored(vms, orig_hashes)
  304. # TODO check vm.backup_timestamp
  305. def test_005_compressed_custom(self):
  306. vms = self.create_backup_vms()
  307. orig_hashes = self.vm_checksum(vms)
  308. self.make_backup(vms, compression_filter="bzip2")
  309. self.remove_vms(reversed(vms))
  310. self.restore_backup()
  311. self.assertCorrectlyRestored(vms, orig_hashes)
  312. def test_010_selective_restore(self):
  313. # create backup with internal dependencies (template, netvm etc)
  314. # try restoring only AppVMs (but not templates, netvms) - should
  315. # handle according to options set
  316. exclude = [
  317. self.make_vm_name('test-net'),
  318. self.make_vm_name('template')
  319. ]
  320. def exclude_some(restore_info):
  321. for name in exclude:
  322. restore_info.pop(name)
  323. return restore_info
  324. vms = self.create_backup_vms()
  325. orig_hashes = self.vm_checksum(vms)
  326. self.make_backup(vms, compression_filter="bzip2")
  327. self.remove_vms(reversed(vms))
  328. self.restore_backup(manipulate_restore_info=exclude_some)
  329. for vm in vms:
  330. if vm.name == self.make_vm_name('test1'):
  331. # netvm was set to 'test-inst-test-net' - excluded
  332. vm.netvm = qubes.property.DEFAULT
  333. elif vm.name == self.make_vm_name('custom'):
  334. # template was set to 'test-inst-template' - excluded
  335. vm.template = self.app.default_template
  336. vms = [vm for vm in vms if vm.name not in exclude]
  337. self.assertCorrectlyRestored(vms, orig_hashes)
  338. def test_020_encrypted_backup_non_ascii(self):
  339. vms = self.create_backup_vms()
  340. orig_hashes = self.vm_checksum(vms)
  341. self.make_backup(vms, encrypted=True, passphrase=u'zażółć gęślą jaźń')
  342. self.remove_vms(reversed(vms))
  343. self.restore_backup(passphrase=u'zażółć gęślą jaźń')
  344. self.assertCorrectlyRestored(vms, orig_hashes)
  345. def test_100_backup_dom0_no_restore(self):
  346. # do not write it into dom0 home itself...
  347. os.mkdir('/var/tmp/test-backup')
  348. self.backupdir = '/var/tmp/test-backup'
  349. self.make_backup([self.app.domains[0]])
  350. # TODO: think of some safe way to test restore...
  351. def test_200_restore_over_existing_directory(self):
  352. """
  353. Regression test for #1386
  354. :return:
  355. """
  356. vms = self.create_backup_vms()
  357. orig_hashes = self.vm_checksum(vms)
  358. self.make_backup(vms)
  359. self.remove_vms(reversed(vms))
  360. test_dir = vms[0].dir_path
  361. os.mkdir(test_dir)
  362. with open(os.path.join(test_dir, 'some-file.txt'), 'w') as f:
  363. f.write('test file\n')
  364. self.restore_backup(
  365. expect_errors=[
  366. '*** Directory {} already exists! It has been moved'.format(
  367. test_dir)
  368. ])
  369. self.assertCorrectlyRestored(vms, orig_hashes)
  370. def test_210_auto_rename(self):
  371. """
  372. Test for #869
  373. :return:
  374. """
  375. vms = self.create_backup_vms()
  376. self.make_backup(vms)
  377. self.restore_backup(options={
  378. 'rename_conflicting': True
  379. })
  380. for vm in vms:
  381. with self.assertNotRaises(
  382. (qubes.exc.QubesVMNotFoundError, KeyError)):
  383. restored_vm = self.app.domains[vm.name + '1']
  384. if vm.netvm and not vm.property_is_default('netvm'):
  385. self.assertEqual(restored_vm.netvm.name, vm.netvm.name + '1')
  386. def _find_pool(self, volume_group, thin_pool):
  387. ''' Returns the pool matching the specified ``volume_group`` &
  388. ``thin_pool``, or None.
  389. '''
  390. pools = [p for p in self.app.pools
  391. if issubclass(p.__class__, qubes.storage.lvm.ThinPool)]
  392. for pool in pools:
  393. if pool.volume_group == volume_group \
  394. and pool.thin_pool == thin_pool:
  395. return pool
  396. return None
  397. @qubes.tests.storage_lvm.skipUnlessLvmPoolExists
  398. def test_300_backup_lvm(self):
  399. volume_group, thin_pool = \
  400. qubes.tests.storage_lvm.DEFAULT_LVM_POOL.split('/', 1)
  401. self.pool = self._find_pool(volume_group, thin_pool)
  402. if not self.pool:
  403. self.pool = self.app.add_pool(
  404. **qubes.tests.storage_lvm.POOL_CONF)
  405. self.created_pool = True
  406. vms = self.create_backup_vms(pool=self.pool)
  407. orig_hashes = self.vm_checksum(vms)
  408. self.make_backup(vms)
  409. self.remove_vms(reversed(vms))
  410. self.restore_backup()
  411. self.assertCorrectlyRestored(vms, orig_hashes)
  412. self.remove_vms(reversed(vms))
  413. @qubes.tests.storage_lvm.skipUnlessLvmPoolExists
  414. def test_301_restore_to_lvm(self):
  415. volume_group, thin_pool = \
  416. qubes.tests.storage_lvm.DEFAULT_LVM_POOL.split('/', 1)
  417. self.pool = self._find_pool(volume_group, thin_pool)
  418. if not self.pool:
  419. self.pool = self.app.add_pool(
  420. **qubes.tests.storage_lvm.POOL_CONF)
  421. self.created_pool = True
  422. vms = self.create_backup_vms()
  423. orig_hashes = self.vm_checksum(vms)
  424. self.make_backup(vms)
  425. self.remove_vms(reversed(vms))
  426. self.restore_backup(options={'override_pool': self.pool.name})
  427. self.assertCorrectlyRestored(vms, orig_hashes)
  428. for vm in vms:
  429. vm = self.app.domains[vm.name]
  430. for volume in vm.volumes.values():
  431. if volume.save_on_stop:
  432. self.assertEqual(volume.pool, self.pool.name)
  433. self.remove_vms(reversed(vms))
  434. class TC_10_BackupVMMixin(BackupTestsMixin):
  435. def setUp(self):
  436. super(TC_10_BackupVMMixin, self).setUp()
  437. self.backupvm = self.app.add_new_vm(
  438. qubes.vm.appvm.AppVM,
  439. label='red',
  440. name=self.make_vm_name('backupvm'),
  441. template=self.template
  442. )
  443. self.loop.run_until_complete(self.backupvm.create_on_disk())
  444. def test_100_send_to_vm_file_with_spaces(self):
  445. vms = self.create_backup_vms()
  446. self.loop.run_until_complete(self.backupvm.start())
  447. self.loop.run_until_complete(self.backupvm.run_for_stdio(
  448. "mkdir '/var/tmp/backup directory'"))
  449. self.make_backup(vms, target_vm=self.backupvm,
  450. compressed=True, encrypted=True,
  451. target='/var/tmp/backup directory')
  452. self.remove_vms(reversed(vms))
  453. (backup_path, _) = self.loop.run_until_complete(
  454. self.backupvm.run_for_stdio("ls /var/tmp/backup*/qubes-backup*"))
  455. backup_path = backup_path.decode().strip()
  456. self.restore_backup(source=backup_path,
  457. appvm=self.backupvm)
  458. def test_110_send_to_vm_command(self):
  459. vms = self.create_backup_vms()
  460. self.loop.run_until_complete(self.backupvm.start())
  461. self.make_backup(vms, target_vm=self.backupvm,
  462. compressed=True, encrypted=True,
  463. target='dd of=/var/tmp/backup-test')
  464. self.remove_vms(reversed(vms))
  465. self.restore_backup(source='dd if=/var/tmp/backup-test',
  466. appvm=self.backupvm)
  467. def test_110_send_to_vm_no_space(self):
  468. """
  469. Check whether backup properly report failure when no enough space is
  470. available
  471. :return:
  472. """
  473. vms = self.create_backup_vms()
  474. self.loop.run_until_complete(self.backupvm.start())
  475. self.loop.run_until_complete(self.backupvm.run_for_stdio(
  476. # Debian 7 has too old losetup to handle loop-control device
  477. "mknod /dev/loop0 b 7 0;"
  478. "truncate -s 50M /home/user/backup.img && "
  479. "mkfs.ext4 -F /home/user/backup.img && "
  480. "mkdir /home/user/backup && "
  481. "mount /home/user/backup.img /home/user/backup -o loop &&"
  482. "chmod 777 /home/user/backup",
  483. user="root"))
  484. with self.assertRaises(qubes.exc.QubesException):
  485. self.make_backup(vms, target_vm=self.backupvm,
  486. compressed=False, encrypted=True,
  487. target='/home/user/backup',
  488. expect_failure=True)
  489. def load_tests(loader, tests, pattern):
  490. for template in qubes.tests.list_templates():
  491. tests.addTests(loader.loadTestsFromTestCase(
  492. type(
  493. 'TC_10_BackupVM_' + template,
  494. (TC_10_BackupVMMixin, qubes.tests.QubesTestCase),
  495. {'template': template})))
  496. return tests