test_command.py 19 KB

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