test_command.py 18 KB

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