2
0

test_database.py 19 KB

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