test_database.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. import json
  2. import os
  3. import shutil
  4. import subprocess
  5. import sys
  6. import tempfile
  7. import pymongo
  8. import pytest
  9. import ruamel.yaml
  10. def write_configuration(
  11. source_directory,
  12. config_path,
  13. repository_path,
  14. user_runtime_directory,
  15. postgresql_dump_format='custom',
  16. postgresql_all_dump_format=None,
  17. mariadb_mysql_all_dump_format=None,
  18. mongodb_dump_format='archive',
  19. ):
  20. '''
  21. Write out borgmatic configuration into a file at the config path. Set the options so as to work
  22. for testing. This includes injecting the given repository path, borgmatic source directory for
  23. storing database dumps, dump format (for PostgreSQL), and encryption passphrase.
  24. '''
  25. postgresql_all_format_option = f'format: {postgresql_all_dump_format}' if postgresql_all_dump_format else ''
  26. mariadb_mysql_dump_format_option = f'format: {mariadb_mysql_all_dump_format}' if mariadb_mysql_all_dump_format else ''
  27. config_yaml = f'''
  28. source_directories:
  29. - {source_directory}
  30. repositories:
  31. - path: {repository_path}
  32. user_runtime_directory: {user_runtime_directory}
  33. encryption_passphrase: "test"
  34. postgresql_databases:
  35. - name: test
  36. hostname: postgresql
  37. username: postgres
  38. password: test
  39. format: {postgresql_dump_format}
  40. - name: all
  41. {postgresql_all_format_option}
  42. hostname: postgresql
  43. username: postgres
  44. password: test
  45. mariadb_databases:
  46. - name: test
  47. hostname: mariadb
  48. username: root
  49. password: test
  50. - name: all
  51. {mariadb_mysql_dump_format_option}
  52. hostname: mariadb
  53. username: root
  54. password: test
  55. mysql_databases:
  56. - name: test
  57. hostname: not-actually-mysql
  58. username: root
  59. password: test
  60. - name: all
  61. {mariadb_mysql_dump_format_option}
  62. hostname: not-actually-mysql
  63. username: root
  64. password: test
  65. mongodb_databases:
  66. - name: test
  67. hostname: mongodb
  68. username: root
  69. password: test
  70. authentication_database: admin
  71. format: {mongodb_dump_format}
  72. - name: all
  73. hostname: mongodb
  74. username: root
  75. password: test
  76. sqlite_databases:
  77. - name: sqlite_test
  78. path: /tmp/sqlite_test.db
  79. '''
  80. with open(config_path, 'w') as config_file:
  81. config_file.write(config_yaml)
  82. return ruamel.yaml.YAML(typ='safe').load(config_yaml)
  83. @pytest.mark.parametrize(
  84. 'postgresql_all_dump_format,mariadb_mysql_all_dump_format',
  85. (
  86. (None, None),
  87. ('custom', 'sql'),
  88. )
  89. )
  90. def write_custom_restore_configuration(
  91. source_directory,
  92. config_path,
  93. repository_path,
  94. user_runtime_directory,
  95. postgresql_dump_format='custom',
  96. postgresql_all_dump_format=None,
  97. mariadb_mysql_all_dump_format=None,
  98. mongodb_dump_format='archive',
  99. ):
  100. '''
  101. Write out borgmatic configuration into a file at the config path. Set the options so as to work
  102. for testing with custom restore options. This includes a custom restore_hostname, restore_port,
  103. restore_username, restore_password and restore_path.
  104. '''
  105. config_yaml = f'''
  106. source_directories:
  107. - {source_directory}
  108. repositories:
  109. - path: {repository_path}
  110. user_runtime_directory: {user_runtime_directory}
  111. encryption_passphrase: "test"
  112. postgresql_databases:
  113. - name: test
  114. hostname: postgresql
  115. username: postgres
  116. password: test
  117. format: {postgresql_dump_format}
  118. restore_hostname: postgresql2
  119. restore_port: 5433
  120. restore_password: test2
  121. mariadb_databases:
  122. - name: test
  123. hostname: mariadb
  124. username: root
  125. password: test
  126. restore_hostname: mariadb2
  127. restore_port: 3307
  128. restore_username: root
  129. restore_password: test2
  130. mysql_databases:
  131. - name: test
  132. hostname: not-actually-mysql
  133. username: root
  134. password: test
  135. restore_hostname: not-actually-mysql2
  136. restore_port: 3307
  137. restore_username: root
  138. restore_password: test2
  139. mongodb_databases:
  140. - name: test
  141. hostname: mongodb
  142. username: root
  143. password: test
  144. authentication_database: admin
  145. format: {mongodb_dump_format}
  146. restore_hostname: mongodb2
  147. restore_port: 27018
  148. restore_username: root2
  149. restore_password: test2
  150. sqlite_databases:
  151. - name: sqlite_test
  152. path: /tmp/sqlite_test.db
  153. restore_path: /tmp/sqlite_test2.db
  154. '''
  155. with open(config_path, 'w') as config_file:
  156. config_file.write(config_yaml)
  157. return ruamel.yaml.YAML(typ='safe').load(config_yaml)
  158. def write_simple_custom_restore_configuration(
  159. source_directory,
  160. config_path,
  161. repository_path,
  162. user_runtime_directory,
  163. postgresql_dump_format='custom',
  164. ):
  165. '''
  166. Write out borgmatic configuration into a file at the config path. Set the options so as to work
  167. for testing with custom restore options, but this time using CLI arguments. This includes a
  168. custom restore_hostname, restore_port, restore_username and restore_password as we only test
  169. these options for PostgreSQL.
  170. '''
  171. config_yaml = f'''
  172. source_directories:
  173. - {source_directory}
  174. repositories:
  175. - path: {repository_path}
  176. user_runtime_directory: {user_runtime_directory}
  177. encryption_passphrase: "test"
  178. postgresql_databases:
  179. - name: test
  180. hostname: postgresql
  181. username: postgres
  182. password: test
  183. format: {postgresql_dump_format}
  184. '''
  185. with open(config_path, 'w') as config_file:
  186. config_file.write(config_yaml)
  187. return ruamel.yaml.YAML(typ='safe').load(config_yaml)
  188. def get_connection_params(database, use_restore_options=False):
  189. hostname = (database.get('restore_hostname') if use_restore_options else None) or database.get(
  190. 'hostname'
  191. )
  192. port = (database.get('restore_port') if use_restore_options else None) or database.get('port')
  193. username = (database.get('restore_username') if use_restore_options else None) or database.get(
  194. 'username'
  195. )
  196. password = (database.get('restore_password') if use_restore_options else None) or database.get(
  197. 'password'
  198. )
  199. return (hostname, port, username, password)
  200. def run_postgresql_command(command, config, use_restore_options=False):
  201. (hostname, port, username, password) = get_connection_params(
  202. config['postgresql_databases'][0], use_restore_options
  203. )
  204. subprocess.check_call(
  205. [
  206. '/usr/bin/psql',
  207. f'--host={hostname}',
  208. f'--port={port or 5432}',
  209. f"--username={username or 'root'}",
  210. f'--command={command}',
  211. 'test',
  212. ],
  213. env={'PGPASSWORD': password},
  214. )
  215. def run_mariadb_command(command, config, use_restore_options=False, binary_name='mariadb'):
  216. (hostname, port, username, password) = get_connection_params(
  217. config[f'{binary_name}_databases'][0], use_restore_options
  218. )
  219. subprocess.check_call(
  220. [
  221. f'/usr/bin/{binary_name}',
  222. f'--host={hostname}',
  223. f'--port={port or 3306}',
  224. f'--user={username}',
  225. f'--execute={command}',
  226. 'test',
  227. ],
  228. env={'MYSQL_PWD': password},
  229. )
  230. def get_mongodb_database_client(config, use_restore_options=False):
  231. (hostname, port, username, password) = get_connection_params(
  232. config['mongodb_databases'][0], use_restore_options
  233. )
  234. return pymongo.MongoClient(f'mongodb://{username}:{password}@{hostname}:{port or 27017}').test
  235. def run_sqlite_command(command, config, use_restore_options=False):
  236. database = config['sqlite_databases'][0]
  237. path = (database.get('restore_path') if use_restore_options else None) or database.get('path')
  238. subprocess.check_call(
  239. [
  240. '/usr/bin/sqlite3',
  241. path,
  242. command,
  243. '.exit',
  244. ],
  245. )
  246. DEFAULT_HOOK_NAMES = {'postgresql', 'mariadb', 'mysql', 'mongodb', 'sqlite'}
  247. def create_test_tables(config, use_restore_options=False):
  248. '''
  249. Create test tables for borgmatic to dump and backup.
  250. '''
  251. command = 'create table test{id} (thing int); insert into test{id} values (1);'
  252. if 'postgresql_databases' in config:
  253. run_postgresql_command(command.format(id=1), config, use_restore_options)
  254. if 'mariadb_databases' in config:
  255. run_mariadb_command(command.format(id=2), config, use_restore_options)
  256. if 'mysql_databases' in config:
  257. run_mariadb_command(command.format(id=3), config, use_restore_options, binary_name='mysql')
  258. if 'mongodb_databases' in config:
  259. get_mongodb_database_client(config, use_restore_options)['test4'].insert_one({'thing': 1})
  260. if 'sqlite_databases' in config:
  261. run_sqlite_command(command.format(id=5), config, use_restore_options)
  262. def drop_test_tables(config, use_restore_options=False):
  263. '''
  264. Drop the test tables in preparation for borgmatic restoring them.
  265. '''
  266. command = 'drop table if exists test{id};'
  267. if 'postgresql_databases' in config:
  268. run_postgresql_command(command.format(id=1), config, use_restore_options)
  269. if 'mariadb_databases' in config:
  270. run_mariadb_command(command.format(id=2), config, use_restore_options)
  271. if 'mysql_databases' in config:
  272. run_mariadb_command(command.format(id=3), config, use_restore_options, binary_name='mysql')
  273. if 'mongodb_databases' in config:
  274. get_mongodb_database_client(config, use_restore_options)['test4'].drop()
  275. if 'sqlite_databases' in config:
  276. run_sqlite_command(command.format(id=5), config, use_restore_options)
  277. def select_test_tables(config, use_restore_options=False):
  278. '''
  279. Select the test tables to make sure they exist.
  280. Raise if the expected tables cannot be selected, for instance if a restore hasn't worked as
  281. expected.
  282. '''
  283. command = 'select count(*) from test{id};'
  284. if 'postgresql_databases' in config:
  285. run_postgresql_command(command.format(id=1), config, use_restore_options)
  286. if 'mariadb_databases' in config:
  287. run_mariadb_command(command.format(id=2), config, use_restore_options)
  288. if 'mysql_databases' in config:
  289. run_mariadb_command(command.format(id=3), config, use_restore_options, binary_name='mysql')
  290. if 'mongodb_databases' in config:
  291. assert (
  292. get_mongodb_database_client(config, use_restore_options)['test4'].count_documents(
  293. filter={}
  294. )
  295. > 0
  296. )
  297. if 'sqlite_databases' in config:
  298. run_sqlite_command(command.format(id=5), config, use_restore_options)
  299. def test_database_dump_and_restore():
  300. # Create a Borg repository.
  301. temporary_directory = tempfile.mkdtemp()
  302. repository_path = os.path.join(temporary_directory, 'test.borg')
  303. # Write out a special file to ensure that it gets properly excluded and Borg doesn't hang on it.
  304. os.mkfifo(os.path.join(temporary_directory, 'special_file'))
  305. original_working_directory = os.getcwd()
  306. try:
  307. config_path = os.path.join(temporary_directory, 'test.yaml')
  308. config = write_configuration(
  309. temporary_directory, config_path, repository_path, temporary_directory
  310. )
  311. create_test_tables(config)
  312. select_test_tables(config)
  313. subprocess.check_call(
  314. [
  315. 'borgmatic',
  316. '-v',
  317. '2',
  318. '--config',
  319. config_path,
  320. 'repo-create',
  321. '--encryption',
  322. 'repokey',
  323. ]
  324. )
  325. # Run borgmatic to generate a backup archive including database dumps.
  326. subprocess.check_call(['borgmatic', 'create', '--config', config_path, '-v', '2'])
  327. # Get the created archive name.
  328. output = subprocess.check_output(
  329. ['borgmatic', '--config', config_path, 'list', '--json']
  330. ).decode(sys.stdout.encoding)
  331. parsed_output = json.loads(output)
  332. assert len(parsed_output) == 1
  333. assert len(parsed_output[0]['archives']) == 1
  334. archive_name = parsed_output[0]['archives'][0]['archive']
  335. # Restore the databases from the archive.
  336. drop_test_tables(config)
  337. subprocess.check_call(
  338. ['borgmatic', '-v', '2', '--config', config_path, 'restore', '--archive', archive_name]
  339. )
  340. # Ensure the test tables have actually been restored.
  341. select_test_tables(config)
  342. finally:
  343. os.chdir(original_working_directory)
  344. shutil.rmtree(temporary_directory)
  345. drop_test_tables(config)
  346. def test_database_dump_and_restore_with_restore_cli_flags():
  347. # Create a Borg repository.
  348. temporary_directory = tempfile.mkdtemp()
  349. repository_path = os.path.join(temporary_directory, 'test.borg')
  350. original_working_directory = os.getcwd()
  351. try:
  352. config_path = os.path.join(temporary_directory, 'test.yaml')
  353. config = write_simple_custom_restore_configuration(
  354. temporary_directory, config_path, repository_path, temporary_directory
  355. )
  356. create_test_tables(config)
  357. select_test_tables(config)
  358. subprocess.check_call(
  359. [
  360. 'borgmatic',
  361. '-v',
  362. '2',
  363. '--config',
  364. config_path,
  365. 'repo-create',
  366. '--encryption',
  367. 'repokey',
  368. ]
  369. )
  370. # Run borgmatic to generate a backup archive including a database dump.
  371. subprocess.check_call(['borgmatic', 'create', '--config', config_path, '-v', '2'])
  372. # Get the created archive name.
  373. output = subprocess.check_output(
  374. ['borgmatic', '--config', config_path, 'list', '--json']
  375. ).decode(sys.stdout.encoding)
  376. parsed_output = json.loads(output)
  377. assert len(parsed_output) == 1
  378. assert len(parsed_output[0]['archives']) == 1
  379. archive_name = parsed_output[0]['archives'][0]['archive']
  380. # Restore the database from the archive.
  381. drop_test_tables(config)
  382. subprocess.check_call(
  383. [
  384. 'borgmatic',
  385. '-v',
  386. '2',
  387. '--config',
  388. config_path,
  389. 'restore',
  390. '--archive',
  391. archive_name,
  392. '--hostname',
  393. 'postgresql2',
  394. '--port',
  395. '5433',
  396. '--password',
  397. 'test2',
  398. ]
  399. )
  400. # Ensure the test tables have actually been restored. But first modify the config to contain
  401. # the altered restore values from the borgmatic command above. This ensures that the test
  402. # tables are selected from the correct database.
  403. database = config['postgresql_databases'][0]
  404. database['restore_hostname'] = 'postgresql2'
  405. database['restore_port'] = '5433'
  406. database['restore_password'] = 'test2'
  407. select_test_tables(config, use_restore_options=True)
  408. finally:
  409. os.chdir(original_working_directory)
  410. shutil.rmtree(temporary_directory)
  411. drop_test_tables(config)
  412. drop_test_tables(config, use_restore_options=True)
  413. def test_database_dump_and_restore_with_restore_configuration_options():
  414. # Create a Borg repository.
  415. temporary_directory = tempfile.mkdtemp()
  416. repository_path = os.path.join(temporary_directory, 'test.borg')
  417. original_working_directory = os.getcwd()
  418. try:
  419. config_path = os.path.join(temporary_directory, 'test.yaml')
  420. config = write_custom_restore_configuration(
  421. temporary_directory, config_path, repository_path, temporary_directory
  422. )
  423. create_test_tables(config)
  424. select_test_tables(config)
  425. subprocess.check_call(
  426. [
  427. 'borgmatic',
  428. '-v',
  429. '2',
  430. '--config',
  431. config_path,
  432. 'repo-create',
  433. '--encryption',
  434. 'repokey',
  435. ]
  436. )
  437. # Run borgmatic to generate a backup archive including a database dump.
  438. subprocess.check_call(['borgmatic', 'create', '--config', config_path, '-v', '2'])
  439. # Get the created archive name.
  440. output = subprocess.check_output(
  441. ['borgmatic', '--config', config_path, 'list', '--json']
  442. ).decode(sys.stdout.encoding)
  443. parsed_output = json.loads(output)
  444. assert len(parsed_output) == 1
  445. assert len(parsed_output[0]['archives']) == 1
  446. archive_name = parsed_output[0]['archives'][0]['archive']
  447. # Restore the database from the archive.
  448. drop_test_tables(config)
  449. subprocess.check_call(
  450. ['borgmatic', '-v', '2', '--config', config_path, 'restore', '--archive', archive_name]
  451. )
  452. # Ensure the test tables have actually been restored.
  453. select_test_tables(config, use_restore_options=True)
  454. finally:
  455. os.chdir(original_working_directory)
  456. shutil.rmtree(temporary_directory)
  457. drop_test_tables(config)
  458. drop_test_tables(config, use_restore_options=True)
  459. def test_database_dump_and_restore_with_directory_format():
  460. # Create a Borg repository.
  461. temporary_directory = tempfile.mkdtemp()
  462. repository_path = os.path.join(temporary_directory, 'test.borg')
  463. original_working_directory = os.getcwd()
  464. try:
  465. config_path = os.path.join(temporary_directory, 'test.yaml')
  466. config = write_configuration(
  467. temporary_directory,
  468. config_path,
  469. repository_path,
  470. temporary_directory,
  471. postgresql_dump_format='directory',
  472. mongodb_dump_format='directory',
  473. )
  474. create_test_tables(config)
  475. select_test_tables(config)
  476. subprocess.check_call(
  477. [
  478. 'borgmatic',
  479. '-v',
  480. '2',
  481. '--config',
  482. config_path,
  483. 'repo-create',
  484. '--encryption',
  485. 'repokey',
  486. ]
  487. )
  488. # Run borgmatic to generate a backup archive including a database dump.
  489. subprocess.check_call(['borgmatic', 'create', '--config', config_path, '-v', '2'])
  490. # Restore the database from the archive.
  491. drop_test_tables(config)
  492. subprocess.check_call(
  493. ['borgmatic', '--config', config_path, 'restore', '--archive', 'latest']
  494. )
  495. # Ensure the test tables have actually been restored.
  496. select_test_tables(config)
  497. finally:
  498. os.chdir(original_working_directory)
  499. shutil.rmtree(temporary_directory)
  500. drop_test_tables(config)
  501. def test_database_dump_with_error_causes_borgmatic_to_exit():
  502. # Create a Borg repository.
  503. temporary_directory = tempfile.mkdtemp()
  504. repository_path = os.path.join(temporary_directory, 'test.borg')
  505. original_working_directory = os.getcwd()
  506. try:
  507. config_path = os.path.join(temporary_directory, 'test.yaml')
  508. write_configuration(temporary_directory, config_path, repository_path, temporary_directory)
  509. subprocess.check_call(
  510. [
  511. 'borgmatic',
  512. '-v',
  513. '2',
  514. '--config',
  515. config_path,
  516. 'repo-create',
  517. '--encryption',
  518. 'repokey',
  519. ]
  520. )
  521. # Run borgmatic with a config override such that the database dump fails.
  522. with pytest.raises(subprocess.CalledProcessError):
  523. subprocess.check_call(
  524. [
  525. 'borgmatic',
  526. 'create',
  527. '--config',
  528. config_path,
  529. '-v',
  530. '2',
  531. '--override',
  532. "hooks.postgresql_databases=[{'name': 'nope'}]", # noqa: FS003
  533. ]
  534. )
  535. finally:
  536. os.chdir(original_working_directory)
  537. shutil.rmtree(temporary_directory)