2
0

test_command.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
  1. import logging
  2. import subprocess
  3. import pytest
  4. from flexmock import flexmock
  5. from borgmatic.hooks import command as module
  6. def test_interpolate_context_passes_through_command_without_variable():
  7. assert module.interpolate_context('pre-backup', 'ls', {'foo': 'bar'}) == 'ls'
  8. def test_interpolate_context_warns_and_passes_through_command_with_unknown_variable():
  9. command = 'ls {baz}'
  10. flexmock(module.logger).should_receive('warning').once()
  11. assert module.interpolate_context('pre-backup', command, {'foo': 'bar'}) == command
  12. def test_interpolate_context_does_not_warn_and_passes_through_command_with_unknown_variable_matching_borg_placeholder():
  13. command = 'ls {hostname}'
  14. flexmock(module.logger).should_receive('warning').never()
  15. assert module.interpolate_context('pre-backup', command, {'foo': 'bar'}) == command
  16. def test_interpolate_context_interpolates_variables():
  17. command = 'ls {foo}{baz} {baz}'
  18. context = {'foo': 'bar', 'baz': 'quux'}
  19. assert module.interpolate_context('pre-backup', command, context) == 'ls barquux quux'
  20. def test_interpolate_context_escapes_interpolated_variables():
  21. command = 'ls {foo} {inject}'
  22. context = {'foo': 'bar', 'inject': 'hi; naughty-command'}
  23. assert (
  24. module.interpolate_context('pre-backup', command, context) == "ls bar 'hi; naughty-command'"
  25. )
  26. def test_make_environment_without_pyinstaller_does_not_touch_environment():
  27. assert module.make_environment({}, sys_module=flexmock()) == {}
  28. def test_make_environment_with_pyinstaller_clears_LD_LIBRARY_PATH():
  29. assert module.make_environment({}, sys_module=flexmock(frozen=True, _MEIPASS='yup')) == {
  30. 'LD_LIBRARY_PATH': '',
  31. }
  32. def test_make_environment_with_pyinstaller_and_LD_LIBRARY_PATH_ORIG_copies_it_into_LD_LIBRARY_PATH():
  33. assert module.make_environment(
  34. {'LD_LIBRARY_PATH_ORIG': '/lib/lib/lib'},
  35. sys_module=flexmock(frozen=True, _MEIPASS='yup'),
  36. ) == {'LD_LIBRARY_PATH_ORIG': '/lib/lib/lib', 'LD_LIBRARY_PATH': '/lib/lib/lib'}
  37. @pytest.mark.parametrize(
  38. 'hooks,filters,expected_hooks',
  39. (
  40. (
  41. (
  42. {
  43. 'before': 'action',
  44. 'run': ['foo'],
  45. },
  46. {
  47. 'after': 'action',
  48. 'run': ['bar'],
  49. },
  50. {
  51. 'before': 'repository',
  52. 'run': ['baz'],
  53. },
  54. ),
  55. {},
  56. (
  57. {
  58. 'before': 'action',
  59. 'run': ['foo'],
  60. },
  61. {
  62. 'after': 'action',
  63. 'run': ['bar'],
  64. },
  65. {
  66. 'before': 'repository',
  67. 'run': ['baz'],
  68. },
  69. ),
  70. ),
  71. (
  72. (
  73. {
  74. 'before': 'action',
  75. 'run': ['foo'],
  76. },
  77. {
  78. 'after': 'action',
  79. 'run': ['bar'],
  80. },
  81. {
  82. 'before': 'repository',
  83. 'run': ['baz'],
  84. },
  85. ),
  86. {
  87. 'before': 'action',
  88. },
  89. (
  90. {
  91. 'before': 'action',
  92. 'run': ['foo'],
  93. },
  94. ),
  95. ),
  96. (
  97. (
  98. {
  99. 'after': 'action',
  100. 'run': ['foo'],
  101. },
  102. {
  103. 'before': 'action',
  104. 'run': ['bar'],
  105. },
  106. {
  107. 'after': 'repository',
  108. 'run': ['baz'],
  109. },
  110. ),
  111. {
  112. 'after': 'action',
  113. },
  114. (
  115. {
  116. 'after': 'action',
  117. 'run': ['foo'],
  118. },
  119. ),
  120. ),
  121. (
  122. (
  123. {
  124. 'before': 'action',
  125. 'run': ['foo'],
  126. },
  127. {
  128. 'before': 'action',
  129. 'run': ['bar'],
  130. },
  131. {
  132. 'before': 'action',
  133. 'run': ['baz'],
  134. },
  135. ),
  136. {
  137. 'before': 'action',
  138. 'action_names': ['create', 'compact', 'extract'],
  139. },
  140. (
  141. {
  142. 'before': 'action',
  143. 'run': ['foo'],
  144. },
  145. {
  146. 'before': 'action',
  147. 'run': ['bar'],
  148. },
  149. {
  150. 'before': 'action',
  151. 'run': ['baz'],
  152. },
  153. ),
  154. ),
  155. (
  156. (
  157. {
  158. 'before': 'action',
  159. 'states': ['finish'], # Not actually valid; only valid for "after".
  160. 'run': ['foo'],
  161. },
  162. {
  163. 'after': 'action',
  164. 'run': ['bar'],
  165. },
  166. {
  167. 'after': 'action',
  168. 'states': ['finish'],
  169. 'run': ['baz'],
  170. },
  171. {
  172. 'after': 'action',
  173. 'states': ['fail'],
  174. 'run': ['quux'],
  175. },
  176. ),
  177. {
  178. 'after': 'action',
  179. 'state_names': ['finish'],
  180. },
  181. (
  182. {
  183. 'after': 'action',
  184. 'run': ['bar'],
  185. },
  186. {
  187. 'after': 'action',
  188. 'states': ['finish'],
  189. 'run': ['baz'],
  190. },
  191. ),
  192. ),
  193. ),
  194. )
  195. def test_filter_hooks(hooks, filters, expected_hooks):
  196. assert module.filter_hooks(hooks, **filters) == expected_hooks
  197. LOGGING_ANSWER = flexmock()
  198. def test_execute_hooks_invokes_each_hook_and_command():
  199. flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
  200. flexmock(module.logging).ANSWER = LOGGING_ANSWER
  201. flexmock(module).should_receive('interpolate_context').replace_with(
  202. lambda hook_description, command, context: command,
  203. )
  204. flexmock(module).should_receive('make_environment').and_return({})
  205. for command in ('foo', 'bar', 'baz'):
  206. flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
  207. [command],
  208. output_log_level=LOGGING_ANSWER,
  209. shell=True,
  210. environment={},
  211. working_directory=None,
  212. ).once()
  213. module.execute_hooks(
  214. [{'before': 'create', 'run': ['foo']}, {'before': 'create', 'run': ['bar', 'baz']}],
  215. umask=None,
  216. working_directory=None,
  217. dry_run=False,
  218. )
  219. def test_execute_hooks_with_umask_sets_that_umask():
  220. flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
  221. flexmock(module.logging).ANSWER = LOGGING_ANSWER
  222. flexmock(module).should_receive('interpolate_context').replace_with(
  223. lambda hook_description, command, context: command,
  224. )
  225. flexmock(module.os).should_receive('umask').with_args(0o77).and_return(0o22).once()
  226. flexmock(module.os).should_receive('umask').with_args(0o22).once()
  227. flexmock(module).should_receive('make_environment').and_return({})
  228. flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
  229. ['foo'],
  230. output_log_level=logging.ANSWER,
  231. shell=True,
  232. environment={},
  233. working_directory=None,
  234. )
  235. module.execute_hooks(
  236. [{'before': 'create', 'run': ['foo']}],
  237. umask=77,
  238. working_directory=None,
  239. dry_run=False,
  240. )
  241. def test_execute_hooks_with_working_directory_executes_command_with_it():
  242. flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
  243. flexmock(module.logging).ANSWER = LOGGING_ANSWER
  244. flexmock(module).should_receive('interpolate_context').replace_with(
  245. lambda hook_description, command, context: command,
  246. )
  247. flexmock(module).should_receive('make_environment').and_return({})
  248. flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
  249. ['foo'],
  250. output_log_level=logging.ANSWER,
  251. shell=True,
  252. environment={},
  253. working_directory='/working',
  254. )
  255. module.execute_hooks(
  256. [{'before': 'create', 'run': ['foo']}],
  257. umask=None,
  258. working_directory='/working',
  259. dry_run=False,
  260. )
  261. def test_execute_hooks_with_dry_run_skips_commands():
  262. flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
  263. flexmock(module.logging).ANSWER = LOGGING_ANSWER
  264. flexmock(module).should_receive('interpolate_context').replace_with(
  265. lambda hook_description, command, context: command,
  266. )
  267. flexmock(module).should_receive('make_environment').and_return({})
  268. flexmock(module.borgmatic.execute).should_receive('execute_command').never()
  269. module.execute_hooks(
  270. [{'before': 'create', 'run': ['foo']}],
  271. umask=None,
  272. working_directory=None,
  273. dry_run=True,
  274. )
  275. def test_execute_hooks_with_empty_commands_does_not_raise():
  276. module.execute_hooks([], umask=None, working_directory=None, dry_run=True)
  277. def test_execute_hooks_with_error_logs_as_error():
  278. flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
  279. flexmock(module.logging).ANSWER = LOGGING_ANSWER
  280. flexmock(module).should_receive('interpolate_context').replace_with(
  281. lambda hook_description, command, context: command,
  282. )
  283. flexmock(module).should_receive('make_environment').and_return({})
  284. flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
  285. ['foo'],
  286. output_log_level=logging.ERROR,
  287. shell=True,
  288. environment={},
  289. working_directory=None,
  290. ).once()
  291. module.execute_hooks(
  292. [{'after': 'error', 'run': ['foo']}],
  293. umask=None,
  294. working_directory=None,
  295. dry_run=False,
  296. )
  297. def test_execute_hooks_with_before_or_after_raises():
  298. flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
  299. flexmock(module.logging).ANSWER = LOGGING_ANSWER
  300. flexmock(module).should_receive('interpolate_context').never()
  301. flexmock(module).should_receive('make_environment').never()
  302. flexmock(module.borgmatic.execute).should_receive('execute_command').never()
  303. with pytest.raises(ValueError):
  304. module.execute_hooks(
  305. [
  306. {'erstwhile': 'create', 'run': ['foo']},
  307. {'erstwhile': 'create', 'run': ['bar', 'baz']},
  308. ],
  309. umask=None,
  310. working_directory=None,
  311. dry_run=False,
  312. )
  313. def test_execute_hooks_without_commands_to_run_does_not_raise():
  314. flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
  315. flexmock(module.logging).ANSWER = LOGGING_ANSWER
  316. flexmock(module).should_receive('interpolate_context').replace_with(
  317. lambda hook_description, command, context: command,
  318. )
  319. flexmock(module).should_receive('make_environment').and_return({})
  320. for command in ('foo', 'bar'):
  321. flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
  322. [command],
  323. output_log_level=LOGGING_ANSWER,
  324. shell=True,
  325. environment={},
  326. working_directory=None,
  327. ).once()
  328. module.execute_hooks(
  329. [{'before': 'create', 'run': []}, {'before': 'create', 'run': ['foo', 'bar']}],
  330. umask=None,
  331. working_directory=None,
  332. dry_run=False,
  333. )
  334. def test_before_after_hooks_calls_command_hooks():
  335. commands = [
  336. {'before': 'repository', 'run': ['foo', 'bar']},
  337. {'after': 'repository', 'run': ['baz']},
  338. ]
  339. flexmock(module).should_receive('filter_hooks').with_args(
  340. commands,
  341. before='action',
  342. action_names=['create'],
  343. ).and_return(flexmock()).once()
  344. flexmock(module).should_receive('filter_hooks').with_args(
  345. commands,
  346. after='action',
  347. action_names=['create'],
  348. state_names=['finish'],
  349. ).and_return(flexmock()).once()
  350. flexmock(module).should_receive('execute_hooks').twice()
  351. with module.Before_after_hooks(
  352. command_hooks=commands,
  353. before_after='action',
  354. umask=1234,
  355. working_directory='/working',
  356. dry_run=False,
  357. action_names=['create'],
  358. context1='stuff',
  359. context2='such',
  360. ):
  361. pass
  362. def test_before_after_hooks_with_before_error_runs_after_hook_and_raises():
  363. commands = [
  364. {'before': 'repository', 'run': ['foo', 'bar']},
  365. {'after': 'repository', 'run': ['baz']},
  366. ]
  367. flexmock(module).should_receive('filter_hooks').with_args(
  368. commands,
  369. before='action',
  370. action_names=['create'],
  371. ).and_return(flexmock()).once()
  372. flexmock(module).should_receive('filter_hooks').with_args(
  373. commands,
  374. after='action',
  375. action_names=['create'],
  376. state_names=['fail'],
  377. ).and_return(flexmock()).once()
  378. flexmock(module).should_receive('execute_hooks').and_raise(OSError).and_return(None)
  379. flexmock(module).should_receive('considered_soft_failure').and_return(False)
  380. with (
  381. pytest.raises(ValueError),
  382. module.Before_after_hooks(
  383. command_hooks=commands,
  384. before_after='action',
  385. umask=1234,
  386. working_directory='/working',
  387. dry_run=False,
  388. action_names=['create'],
  389. context1='stuff',
  390. context2='such',
  391. ),
  392. ):
  393. raise AssertionError() # This should never get called.
  394. def test_before_after_hooks_with_before_soft_failure_raises():
  395. commands = [
  396. {'before': 'repository', 'run': ['foo', 'bar']},
  397. {'after': 'repository', 'run': ['baz']},
  398. ]
  399. flexmock(module).should_receive('filter_hooks').with_args(
  400. commands,
  401. before='action',
  402. action_names=['create'],
  403. ).and_return(flexmock()).once()
  404. flexmock(module).should_receive('filter_hooks').with_args(
  405. commands,
  406. after='action',
  407. action_names=['create'],
  408. state_names=['finish'],
  409. ).never()
  410. flexmock(module).should_receive('execute_hooks').and_raise(OSError)
  411. flexmock(module).should_receive('considered_soft_failure').and_return(True)
  412. with (
  413. pytest.raises(OSError),
  414. module.Before_after_hooks(
  415. command_hooks=commands,
  416. before_after='action',
  417. umask=1234,
  418. working_directory='/working',
  419. dry_run=False,
  420. action_names=['create'],
  421. context1='stuff',
  422. context2='such',
  423. ),
  424. ):
  425. pass
  426. def test_before_after_hooks_with_wrapped_code_error_runs_after_hook_and_raises():
  427. commands = [
  428. {'before': 'repository', 'run': ['foo', 'bar']},
  429. {'after': 'repository', 'run': ['baz']},
  430. ]
  431. flexmock(module).should_receive('filter_hooks').with_args(
  432. commands,
  433. before='action',
  434. action_names=['create'],
  435. ).and_return(flexmock()).once()
  436. flexmock(module).should_receive('filter_hooks').with_args(
  437. commands,
  438. after='action',
  439. action_names=['create'],
  440. state_names=['fail'],
  441. ).and_return(flexmock()).once()
  442. flexmock(module).should_receive('execute_hooks').twice()
  443. with (
  444. pytest.raises(ValueError),
  445. module.Before_after_hooks(
  446. command_hooks=commands,
  447. before_after='action',
  448. umask=1234,
  449. working_directory='/working',
  450. dry_run=False,
  451. action_names=['create'],
  452. context1='stuff',
  453. context2='such',
  454. ),
  455. ):
  456. raise ValueError()
  457. def test_before_after_hooks_with_after_error_raises():
  458. commands = [
  459. {'before': 'repository', 'run': ['foo', 'bar']},
  460. {'after': 'repository', 'run': ['baz']},
  461. ]
  462. flexmock(module).should_receive('filter_hooks').with_args(
  463. commands,
  464. before='action',
  465. action_names=['create'],
  466. ).and_return(flexmock()).once()
  467. flexmock(module).should_receive('filter_hooks').with_args(
  468. commands,
  469. after='action',
  470. action_names=['create'],
  471. state_names=['finish'],
  472. ).and_return(flexmock()).once()
  473. flexmock(module).should_receive('execute_hooks').and_return(None).and_raise(OSError)
  474. flexmock(module).should_receive('considered_soft_failure').and_return(False)
  475. with (
  476. pytest.raises(ValueError),
  477. module.Before_after_hooks(
  478. command_hooks=commands,
  479. before_after='action',
  480. umask=1234,
  481. working_directory='/working',
  482. dry_run=False,
  483. action_names=['create'],
  484. context1='stuff',
  485. context2='such',
  486. ),
  487. ):
  488. pass
  489. def test_before_after_hooks_with_after_soft_failure_raises():
  490. commands = [
  491. {'before': 'repository', 'run': ['foo', 'bar']},
  492. {'after': 'repository', 'run': ['baz']},
  493. ]
  494. flexmock(module).should_receive('filter_hooks').with_args(
  495. commands,
  496. before='action',
  497. action_names=['create'],
  498. ).and_return(flexmock()).once()
  499. flexmock(module).should_receive('filter_hooks').with_args(
  500. commands,
  501. after='action',
  502. action_names=['create'],
  503. state_names=['finish'],
  504. ).and_return(flexmock()).once()
  505. flexmock(module).should_receive('execute_hooks').and_return(None).and_raise(OSError)
  506. flexmock(module).should_receive('considered_soft_failure').and_return(True)
  507. with (
  508. pytest.raises(OSError),
  509. module.Before_after_hooks(
  510. command_hooks=commands,
  511. before_after='action',
  512. umask=1234,
  513. working_directory='/working',
  514. dry_run=False,
  515. action_names=['create'],
  516. context1='stuff',
  517. context2='such',
  518. ),
  519. ):
  520. pass
  521. def test_considered_soft_failure_treats_soft_fail_exit_code_as_soft_fail():
  522. error = subprocess.CalledProcessError(module.SOFT_FAIL_EXIT_CODE, 'try again')
  523. assert module.considered_soft_failure(error)
  524. def test_considered_soft_failure_does_not_treat_other_exit_code_as_soft_fail():
  525. error = subprocess.CalledProcessError(1, 'error')
  526. assert not module.considered_soft_failure(error)
  527. def test_considered_soft_failure_does_not_treat_other_exception_type_as_soft_fail():
  528. assert not module.considered_soft_failure(Exception())
  529. def test_considered_soft_failure_caches_results_and_only_logs_once():
  530. error = subprocess.CalledProcessError(module.SOFT_FAIL_EXIT_CODE, 'try again')
  531. flexmock(module.logger).should_receive('info').once()
  532. assert module.considered_soft_failure(error)
  533. assert module.considered_soft_failure(error)