test_database.py 22 KB

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