borgmatic.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936
  1. import collections
  2. import copy
  3. import json
  4. import logging
  5. import os
  6. import sys
  7. import time
  8. from queue import Queue
  9. from subprocess import CalledProcessError
  10. import colorama
  11. import pkg_resources
  12. import borgmatic.commands.completion
  13. from borgmatic.borg import borg as borg_borg
  14. from borgmatic.borg import check as borg_check
  15. from borgmatic.borg import compact as borg_compact
  16. from borgmatic.borg import create as borg_create
  17. from borgmatic.borg import environment as borg_environment
  18. from borgmatic.borg import export_tar as borg_export_tar
  19. from borgmatic.borg import extract as borg_extract
  20. from borgmatic.borg import feature as borg_feature
  21. from borgmatic.borg import info as borg_info
  22. from borgmatic.borg import init as borg_init
  23. from borgmatic.borg import list as borg_list
  24. from borgmatic.borg import mount as borg_mount
  25. from borgmatic.borg import prune as borg_prune
  26. from borgmatic.borg import umount as borg_umount
  27. from borgmatic.borg import version as borg_version
  28. from borgmatic.commands.arguments import parse_arguments
  29. from borgmatic.config import checks, collect, convert, validate
  30. from borgmatic.hooks import command, dispatch, dump, monitor
  31. from borgmatic.logger import configure_logging, should_do_markup
  32. from borgmatic.signals import configure_signals
  33. from borgmatic.verbosity import verbosity_to_log_level
  34. logger = logging.getLogger(__name__)
  35. LEGACY_CONFIG_PATH = '/etc/borgmatic/config'
  36. def run_configuration(config_filename, config, arguments):
  37. '''
  38. Given a config filename, the corresponding parsed config dict, and command-line arguments as a
  39. dict from subparser name to a namespace of parsed arguments, execute the defined prune, compact,
  40. create, check, and/or other actions.
  41. Yield a combination of:
  42. * JSON output strings from successfully executing any actions that produce JSON
  43. * logging.LogRecord instances containing errors from any actions or backup hooks that fail
  44. '''
  45. (location, storage, retention, consistency, hooks) = (
  46. config.get(section_name, {})
  47. for section_name in ('location', 'storage', 'retention', 'consistency', 'hooks')
  48. )
  49. global_arguments = arguments['global']
  50. local_path = location.get('local_path', 'borg')
  51. remote_path = location.get('remote_path')
  52. retries = storage.get('retries', 0)
  53. retry_wait = storage.get('retry_wait', 0)
  54. borg_environment.initialize(storage)
  55. encountered_error = None
  56. error_repository = ''
  57. using_primary_action = {'prune', 'compact', 'create', 'check'}.intersection(arguments)
  58. monitoring_log_level = verbosity_to_log_level(global_arguments.monitoring_verbosity)
  59. try:
  60. local_borg_version = borg_version.local_borg_version(local_path)
  61. except (OSError, CalledProcessError, ValueError) as error:
  62. yield from log_error_records(
  63. '{}: Error getting local Borg version'.format(config_filename), error
  64. )
  65. return
  66. try:
  67. if using_primary_action:
  68. dispatch.call_hooks(
  69. 'initialize_monitor',
  70. hooks,
  71. config_filename,
  72. monitor.MONITOR_HOOK_NAMES,
  73. monitoring_log_level,
  74. global_arguments.dry_run,
  75. )
  76. if using_primary_action:
  77. dispatch.call_hooks(
  78. 'ping_monitor',
  79. hooks,
  80. config_filename,
  81. monitor.MONITOR_HOOK_NAMES,
  82. monitor.State.START,
  83. monitoring_log_level,
  84. global_arguments.dry_run,
  85. )
  86. except (OSError, CalledProcessError) as error:
  87. if command.considered_soft_failure(config_filename, error):
  88. return
  89. encountered_error = error
  90. yield from log_error_records('{}: Error pinging monitor'.format(config_filename), error)
  91. if not encountered_error:
  92. repo_queue = Queue()
  93. for repo in location['repositories']:
  94. repo_queue.put((repo, 0),)
  95. while not repo_queue.empty():
  96. repository_path, retry_num = repo_queue.get()
  97. timeout = retry_num * retry_wait
  98. if timeout:
  99. logger.warning(f'{config_filename}: Sleeping {timeout}s before next retry')
  100. time.sleep(timeout)
  101. try:
  102. yield from run_actions(
  103. arguments=arguments,
  104. config_filename=config_filename,
  105. location=location,
  106. storage=storage,
  107. retention=retention,
  108. consistency=consistency,
  109. hooks=hooks,
  110. local_path=local_path,
  111. remote_path=remote_path,
  112. local_borg_version=local_borg_version,
  113. repository_path=repository_path,
  114. )
  115. except (OSError, CalledProcessError, ValueError) as error:
  116. if retry_num < retries:
  117. repo_queue.put((repository_path, retry_num + 1),)
  118. tuple( # Consume the generator so as to trigger logging.
  119. log_error_records(
  120. '{}: Error running actions for repository'.format(repository_path),
  121. error,
  122. levelno=logging.WARNING,
  123. log_command_error_output=True,
  124. )
  125. )
  126. logger.warning(
  127. f'{config_filename}: Retrying... attempt {retry_num + 1}/{retries}'
  128. )
  129. continue
  130. if command.considered_soft_failure(config_filename, error):
  131. return
  132. yield from log_error_records(
  133. '{}: Error running actions for repository'.format(repository_path), error
  134. )
  135. encountered_error = error
  136. error_repository = repository_path
  137. if not encountered_error:
  138. try:
  139. if using_primary_action:
  140. dispatch.call_hooks(
  141. 'ping_monitor',
  142. hooks,
  143. config_filename,
  144. monitor.MONITOR_HOOK_NAMES,
  145. monitor.State.FINISH,
  146. monitoring_log_level,
  147. global_arguments.dry_run,
  148. )
  149. dispatch.call_hooks(
  150. 'destroy_monitor',
  151. hooks,
  152. config_filename,
  153. monitor.MONITOR_HOOK_NAMES,
  154. monitoring_log_level,
  155. global_arguments.dry_run,
  156. )
  157. except (OSError, CalledProcessError) as error:
  158. if command.considered_soft_failure(config_filename, error):
  159. return
  160. encountered_error = error
  161. yield from log_error_records('{}: Error pinging monitor'.format(config_filename), error)
  162. if encountered_error and using_primary_action:
  163. try:
  164. command.execute_hook(
  165. hooks.get('on_error'),
  166. hooks.get('umask'),
  167. config_filename,
  168. 'on-error',
  169. global_arguments.dry_run,
  170. repository=error_repository,
  171. error=encountered_error,
  172. output=getattr(encountered_error, 'output', ''),
  173. )
  174. dispatch.call_hooks(
  175. 'ping_monitor',
  176. hooks,
  177. config_filename,
  178. monitor.MONITOR_HOOK_NAMES,
  179. monitor.State.FAIL,
  180. monitoring_log_level,
  181. global_arguments.dry_run,
  182. )
  183. dispatch.call_hooks(
  184. 'destroy_monitor',
  185. hooks,
  186. config_filename,
  187. monitor.MONITOR_HOOK_NAMES,
  188. monitoring_log_level,
  189. global_arguments.dry_run,
  190. )
  191. except (OSError, CalledProcessError) as error:
  192. if command.considered_soft_failure(config_filename, error):
  193. return
  194. yield from log_error_records(
  195. '{}: Error running on-error hook'.format(config_filename), error
  196. )
  197. def run_actions(
  198. *,
  199. arguments,
  200. config_filename,
  201. location,
  202. storage,
  203. retention,
  204. consistency,
  205. hooks,
  206. local_path,
  207. remote_path,
  208. local_borg_version,
  209. repository_path,
  210. ):
  211. '''
  212. Given parsed command-line arguments as an argparse.ArgumentParser instance, the configuration
  213. filename, several different configuration dicts, local and remote paths to Borg, a local Borg
  214. version string, and a repository name, run all actions from the command-line arguments on the
  215. given repository.
  216. Yield JSON output strings from executing any actions that produce JSON.
  217. Raise OSError or subprocess.CalledProcessError if an error occurs running a command for an
  218. action or a hook. Raise ValueError if the arguments or configuration passed to action are
  219. invalid.
  220. '''
  221. repository = os.path.expanduser(repository_path)
  222. global_arguments = arguments['global']
  223. dry_run_label = ' (dry run; not making any changes)' if global_arguments.dry_run else ''
  224. hook_context = {
  225. 'repository': repository_path,
  226. # Deprecated: For backwards compatibility with borgmatic < 1.6.0.
  227. 'repositories': ','.join(location['repositories']),
  228. }
  229. if 'init' in arguments:
  230. logger.info('{}: Initializing repository'.format(repository))
  231. borg_init.initialize_repository(
  232. repository,
  233. storage,
  234. arguments['init'].encryption_mode,
  235. arguments['init'].append_only,
  236. arguments['init'].storage_quota,
  237. local_path=local_path,
  238. remote_path=remote_path,
  239. )
  240. if 'prune' in arguments:
  241. command.execute_hook(
  242. hooks.get('before_prune'),
  243. hooks.get('umask'),
  244. config_filename,
  245. 'pre-prune',
  246. global_arguments.dry_run,
  247. **hook_context,
  248. )
  249. logger.info('{}: Pruning archives{}'.format(repository, dry_run_label))
  250. borg_prune.prune_archives(
  251. global_arguments.dry_run,
  252. repository,
  253. storage,
  254. retention,
  255. local_path=local_path,
  256. remote_path=remote_path,
  257. stats=arguments['prune'].stats,
  258. files=arguments['prune'].files,
  259. )
  260. command.execute_hook(
  261. hooks.get('after_prune'),
  262. hooks.get('umask'),
  263. config_filename,
  264. 'post-prune',
  265. global_arguments.dry_run,
  266. **hook_context,
  267. )
  268. if 'compact' in arguments:
  269. command.execute_hook(
  270. hooks.get('before_compact'),
  271. hooks.get('umask'),
  272. config_filename,
  273. 'pre-compact',
  274. global_arguments.dry_run,
  275. )
  276. if borg_feature.available(borg_feature.Feature.COMPACT, local_borg_version):
  277. logger.info('{}: Compacting segments{}'.format(repository, dry_run_label))
  278. borg_compact.compact_segments(
  279. global_arguments.dry_run,
  280. repository,
  281. storage,
  282. local_path=local_path,
  283. remote_path=remote_path,
  284. progress=arguments['compact'].progress,
  285. cleanup_commits=arguments['compact'].cleanup_commits,
  286. threshold=arguments['compact'].threshold,
  287. )
  288. else: # pragma: nocover
  289. logger.info(
  290. '{}: Skipping compact (only available/needed in Borg 1.2+)'.format(repository)
  291. )
  292. command.execute_hook(
  293. hooks.get('after_compact'),
  294. hooks.get('umask'),
  295. config_filename,
  296. 'post-compact',
  297. global_arguments.dry_run,
  298. )
  299. if 'create' in arguments:
  300. command.execute_hook(
  301. hooks.get('before_backup'),
  302. hooks.get('umask'),
  303. config_filename,
  304. 'pre-backup',
  305. global_arguments.dry_run,
  306. **hook_context,
  307. )
  308. logger.info('{}: Creating archive{}'.format(repository, dry_run_label))
  309. dispatch.call_hooks(
  310. 'remove_database_dumps',
  311. hooks,
  312. repository,
  313. dump.DATABASE_HOOK_NAMES,
  314. location,
  315. global_arguments.dry_run,
  316. )
  317. active_dumps = dispatch.call_hooks(
  318. 'dump_databases',
  319. hooks,
  320. repository,
  321. dump.DATABASE_HOOK_NAMES,
  322. location,
  323. global_arguments.dry_run,
  324. )
  325. stream_processes = [process for processes in active_dumps.values() for process in processes]
  326. json_output = borg_create.create_archive(
  327. global_arguments.dry_run,
  328. repository,
  329. location,
  330. storage,
  331. local_borg_version,
  332. local_path=local_path,
  333. remote_path=remote_path,
  334. progress=arguments['create'].progress,
  335. stats=arguments['create'].stats,
  336. json=arguments['create'].json,
  337. files=arguments['create'].files,
  338. stream_processes=stream_processes,
  339. )
  340. if json_output: # pragma: nocover
  341. yield json.loads(json_output)
  342. dispatch.call_hooks(
  343. 'remove_database_dumps',
  344. hooks,
  345. config_filename,
  346. dump.DATABASE_HOOK_NAMES,
  347. location,
  348. global_arguments.dry_run,
  349. )
  350. command.execute_hook(
  351. hooks.get('after_backup'),
  352. hooks.get('umask'),
  353. config_filename,
  354. 'post-backup',
  355. global_arguments.dry_run,
  356. **hook_context,
  357. )
  358. if 'check' in arguments and checks.repository_enabled_for_checks(repository, consistency):
  359. command.execute_hook(
  360. hooks.get('before_check'),
  361. hooks.get('umask'),
  362. config_filename,
  363. 'pre-check',
  364. global_arguments.dry_run,
  365. **hook_context,
  366. )
  367. logger.info('{}: Running consistency checks'.format(repository))
  368. borg_check.check_archives(
  369. repository,
  370. location,
  371. storage,
  372. consistency,
  373. local_path=local_path,
  374. remote_path=remote_path,
  375. progress=arguments['check'].progress,
  376. repair=arguments['check'].repair,
  377. only_checks=arguments['check'].only,
  378. force=arguments['check'].force,
  379. )
  380. command.execute_hook(
  381. hooks.get('after_check'),
  382. hooks.get('umask'),
  383. config_filename,
  384. 'post-check',
  385. global_arguments.dry_run,
  386. **hook_context,
  387. )
  388. if 'extract' in arguments:
  389. command.execute_hook(
  390. hooks.get('before_extract'),
  391. hooks.get('umask'),
  392. config_filename,
  393. 'pre-extract',
  394. global_arguments.dry_run,
  395. **hook_context,
  396. )
  397. if arguments['extract'].repository is None or validate.repositories_match(
  398. repository, arguments['extract'].repository
  399. ):
  400. logger.info(
  401. '{}: Extracting archive {}'.format(repository, arguments['extract'].archive)
  402. )
  403. borg_extract.extract_archive(
  404. global_arguments.dry_run,
  405. repository,
  406. borg_list.resolve_archive_name(
  407. repository, arguments['extract'].archive, storage, local_path, remote_path
  408. ),
  409. arguments['extract'].paths,
  410. location,
  411. storage,
  412. local_borg_version,
  413. local_path=local_path,
  414. remote_path=remote_path,
  415. destination_path=arguments['extract'].destination,
  416. strip_components=arguments['extract'].strip_components,
  417. progress=arguments['extract'].progress,
  418. )
  419. command.execute_hook(
  420. hooks.get('after_extract'),
  421. hooks.get('umask'),
  422. config_filename,
  423. 'post-extract',
  424. global_arguments.dry_run,
  425. **hook_context,
  426. )
  427. if 'export-tar' in arguments:
  428. if arguments['export-tar'].repository is None or validate.repositories_match(
  429. repository, arguments['export-tar'].repository
  430. ):
  431. logger.info(
  432. '{}: Exporting archive {} as tar file'.format(
  433. repository, arguments['export-tar'].archive
  434. )
  435. )
  436. borg_export_tar.export_tar_archive(
  437. global_arguments.dry_run,
  438. repository,
  439. borg_list.resolve_archive_name(
  440. repository, arguments['export-tar'].archive, storage, local_path, remote_path
  441. ),
  442. arguments['export-tar'].paths,
  443. arguments['export-tar'].destination,
  444. storage,
  445. local_path=local_path,
  446. remote_path=remote_path,
  447. tar_filter=arguments['export-tar'].tar_filter,
  448. files=arguments['export-tar'].files,
  449. strip_components=arguments['export-tar'].strip_components,
  450. )
  451. if 'mount' in arguments:
  452. if arguments['mount'].repository is None or validate.repositories_match(
  453. repository, arguments['mount'].repository
  454. ):
  455. if arguments['mount'].archive:
  456. logger.info(
  457. '{}: Mounting archive {}'.format(repository, arguments['mount'].archive)
  458. )
  459. else: # pragma: nocover
  460. logger.info('{}: Mounting repository'.format(repository))
  461. borg_mount.mount_archive(
  462. repository,
  463. borg_list.resolve_archive_name(
  464. repository, arguments['mount'].archive, storage, local_path, remote_path
  465. ),
  466. arguments['mount'].mount_point,
  467. arguments['mount'].paths,
  468. arguments['mount'].foreground,
  469. arguments['mount'].options,
  470. storage,
  471. local_path=local_path,
  472. remote_path=remote_path,
  473. )
  474. if 'restore' in arguments: # pragma: nocover
  475. if arguments['restore'].repository is None or validate.repositories_match(
  476. repository, arguments['restore'].repository
  477. ):
  478. logger.info(
  479. '{}: Restoring databases from archive {}'.format(
  480. repository, arguments['restore'].archive
  481. )
  482. )
  483. dispatch.call_hooks(
  484. 'remove_database_dumps',
  485. hooks,
  486. repository,
  487. dump.DATABASE_HOOK_NAMES,
  488. location,
  489. global_arguments.dry_run,
  490. )
  491. restore_names = arguments['restore'].databases or []
  492. if 'all' in restore_names:
  493. restore_names = []
  494. archive_name = borg_list.resolve_archive_name(
  495. repository, arguments['restore'].archive, storage, local_path, remote_path
  496. )
  497. found_names = set()
  498. for hook_name, per_hook_restore_databases in hooks.items():
  499. if hook_name not in dump.DATABASE_HOOK_NAMES:
  500. continue
  501. for restore_database in per_hook_restore_databases:
  502. database_name = restore_database['name']
  503. if restore_names and database_name not in restore_names:
  504. continue
  505. found_names.add(database_name)
  506. dump_pattern = dispatch.call_hooks(
  507. 'make_database_dump_pattern',
  508. hooks,
  509. repository,
  510. dump.DATABASE_HOOK_NAMES,
  511. location,
  512. database_name,
  513. )[hook_name]
  514. # Kick off a single database extract to stdout.
  515. extract_process = borg_extract.extract_archive(
  516. dry_run=global_arguments.dry_run,
  517. repository=repository,
  518. archive=archive_name,
  519. paths=dump.convert_glob_patterns_to_borg_patterns([dump_pattern]),
  520. location_config=location,
  521. storage_config=storage,
  522. local_borg_version=local_borg_version,
  523. local_path=local_path,
  524. remote_path=remote_path,
  525. destination_path='/',
  526. # A directory format dump isn't a single file, and therefore can't extract
  527. # to stdout. In this case, the extract_process return value is None.
  528. extract_to_stdout=bool(restore_database.get('format') != 'directory'),
  529. )
  530. # Run a single database restore, consuming the extract stdout (if any).
  531. dispatch.call_hooks(
  532. 'restore_database_dump',
  533. {hook_name: [restore_database]},
  534. repository,
  535. dump.DATABASE_HOOK_NAMES,
  536. location,
  537. global_arguments.dry_run,
  538. extract_process,
  539. )
  540. dispatch.call_hooks(
  541. 'remove_database_dumps',
  542. hooks,
  543. repository,
  544. dump.DATABASE_HOOK_NAMES,
  545. location,
  546. global_arguments.dry_run,
  547. )
  548. if not restore_names and not found_names:
  549. raise ValueError('No databases were found to restore')
  550. missing_names = sorted(set(restore_names) - found_names)
  551. if missing_names:
  552. raise ValueError(
  553. 'Cannot restore database(s) {} missing from borgmatic\'s configuration'.format(
  554. ', '.join(missing_names)
  555. )
  556. )
  557. if 'list' in arguments:
  558. if arguments['list'].repository is None or validate.repositories_match(
  559. repository, arguments['list'].repository
  560. ):
  561. list_arguments = copy.copy(arguments['list'])
  562. if not list_arguments.json: # pragma: nocover
  563. logger.warning('{}: Listing archives'.format(repository))
  564. list_arguments.archive = borg_list.resolve_archive_name(
  565. repository, list_arguments.archive, storage, local_path, remote_path
  566. )
  567. json_output = borg_list.list_archives(
  568. repository,
  569. storage,
  570. list_arguments=list_arguments,
  571. local_path=local_path,
  572. remote_path=remote_path,
  573. )
  574. if json_output: # pragma: nocover
  575. yield json.loads(json_output)
  576. if 'info' in arguments:
  577. if arguments['info'].repository is None or validate.repositories_match(
  578. repository, arguments['info'].repository
  579. ):
  580. info_arguments = copy.copy(arguments['info'])
  581. if not info_arguments.json: # pragma: nocover
  582. logger.warning('{}: Displaying summary info for archives'.format(repository))
  583. info_arguments.archive = borg_list.resolve_archive_name(
  584. repository, info_arguments.archive, storage, local_path, remote_path
  585. )
  586. json_output = borg_info.display_archives_info(
  587. repository,
  588. storage,
  589. info_arguments=info_arguments,
  590. local_path=local_path,
  591. remote_path=remote_path,
  592. )
  593. if json_output: # pragma: nocover
  594. yield json.loads(json_output)
  595. if 'borg' in arguments:
  596. if arguments['borg'].repository is None or validate.repositories_match(
  597. repository, arguments['borg'].repository
  598. ):
  599. logger.warning('{}: Running arbitrary Borg command'.format(repository))
  600. archive_name = borg_list.resolve_archive_name(
  601. repository, arguments['borg'].archive, storage, local_path, remote_path
  602. )
  603. borg_borg.run_arbitrary_borg(
  604. repository,
  605. storage,
  606. options=arguments['borg'].options,
  607. archive=archive_name,
  608. local_path=local_path,
  609. remote_path=remote_path,
  610. )
  611. def load_configurations(config_filenames, overrides=None, resolve_env=True):
  612. '''
  613. Given a sequence of configuration filenames, load and validate each configuration file. Return
  614. the results as a tuple of: dict of configuration filename to corresponding parsed configuration,
  615. and sequence of logging.LogRecord instances containing any parse errors.
  616. '''
  617. # Dict mapping from config filename to corresponding parsed config dict.
  618. configs = collections.OrderedDict()
  619. logs = []
  620. # Parse and load each configuration file.
  621. for config_filename in config_filenames:
  622. try:
  623. configs[config_filename] = validate.parse_configuration(
  624. config_filename, validate.schema_filename(), overrides, resolve_env
  625. )
  626. except PermissionError:
  627. logs.extend(
  628. [
  629. logging.makeLogRecord(
  630. dict(
  631. levelno=logging.WARNING,
  632. levelname='WARNING',
  633. msg='{}: Insufficient permissions to read configuration file'.format(
  634. config_filename
  635. ),
  636. )
  637. ),
  638. ]
  639. )
  640. except (ValueError, OSError, validate.Validation_error) as error:
  641. logs.extend(
  642. [
  643. logging.makeLogRecord(
  644. dict(
  645. levelno=logging.CRITICAL,
  646. levelname='CRITICAL',
  647. msg='{}: Error parsing configuration file'.format(config_filename),
  648. )
  649. ),
  650. logging.makeLogRecord(
  651. dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error)
  652. ),
  653. ]
  654. )
  655. return (configs, logs)
  656. def log_record(suppress_log=False, **kwargs):
  657. '''
  658. Create a log record based on the given makeLogRecord() arguments, one of which must be
  659. named "levelno". Log the record (unless suppress log is set) and return it.
  660. '''
  661. record = logging.makeLogRecord(kwargs)
  662. if suppress_log:
  663. return record
  664. logger.handle(record)
  665. return record
  666. def log_error_records(
  667. message, error=None, levelno=logging.CRITICAL, log_command_error_output=False
  668. ):
  669. '''
  670. Given error message text, an optional exception object, an optional log level, and whether to
  671. log the error output of a CalledProcessError (if any), log error summary information and also
  672. yield it as a series of logging.LogRecord instances.
  673. Note that because the logs are yielded as a generator, logs won't get logged unless you consume
  674. the generator output.
  675. '''
  676. level_name = logging._levelToName[levelno]
  677. if not error:
  678. yield log_record(levelno=levelno, levelname=level_name, msg=message)
  679. return
  680. try:
  681. raise error
  682. except CalledProcessError as error:
  683. yield log_record(levelno=levelno, levelname=level_name, msg=message)
  684. if error.output:
  685. # Suppress these logs for now and save full error output for the log summary at the end.
  686. yield log_record(
  687. levelno=levelno,
  688. levelname=level_name,
  689. msg=error.output,
  690. suppress_log=not log_command_error_output,
  691. )
  692. yield log_record(levelno=levelno, levelname=level_name, msg=error)
  693. except (ValueError, OSError) as error:
  694. yield log_record(levelno=levelno, levelname=level_name, msg=message)
  695. yield log_record(levelno=levelno, levelname=level_name, msg=error)
  696. except: # noqa: E722
  697. # Raising above only as a means of determining the error type. Swallow the exception here
  698. # because we don't want the exception to propagate out of this function.
  699. pass
  700. def get_local_path(configs):
  701. '''
  702. Arbitrarily return the local path from the first configuration dict. Default to "borg" if not
  703. set.
  704. '''
  705. return next(iter(configs.values())).get('location', {}).get('local_path', 'borg')
  706. def collect_configuration_run_summary_logs(configs, arguments):
  707. '''
  708. Given a dict of configuration filename to corresponding parsed configuration, and parsed
  709. command-line arguments as a dict from subparser name to a parsed namespace of arguments, run
  710. each configuration file and yield a series of logging.LogRecord instances containing summary
  711. information about each run.
  712. As a side effect of running through these configuration files, output their JSON results, if
  713. any, to stdout.
  714. '''
  715. # Run cross-file validation checks.
  716. if 'extract' in arguments:
  717. repository = arguments['extract'].repository
  718. elif 'list' in arguments and arguments['list'].archive:
  719. repository = arguments['list'].repository
  720. elif 'mount' in arguments:
  721. repository = arguments['mount'].repository
  722. else:
  723. repository = None
  724. if repository:
  725. try:
  726. validate.guard_configuration_contains_repository(repository, configs)
  727. except ValueError as error:
  728. yield from log_error_records(str(error))
  729. return
  730. if not configs:
  731. yield from log_error_records(
  732. '{}: No valid configuration files found'.format(
  733. ' '.join(arguments['global'].config_paths)
  734. )
  735. )
  736. return
  737. if 'create' in arguments:
  738. try:
  739. for config_filename, config in configs.items():
  740. hooks = config.get('hooks', {})
  741. command.execute_hook(
  742. hooks.get('before_everything'),
  743. hooks.get('umask'),
  744. config_filename,
  745. 'pre-everything',
  746. arguments['global'].dry_run,
  747. )
  748. except (CalledProcessError, ValueError, OSError) as error:
  749. yield from log_error_records('Error running pre-everything hook', error)
  750. return
  751. # Execute the actions corresponding to each configuration file.
  752. json_results = []
  753. for config_filename, config in configs.items():
  754. results = list(run_configuration(config_filename, config, arguments))
  755. error_logs = tuple(result for result in results if isinstance(result, logging.LogRecord))
  756. if error_logs:
  757. yield from log_error_records(
  758. '{}: Error running configuration file'.format(config_filename)
  759. )
  760. yield from error_logs
  761. else:
  762. yield logging.makeLogRecord(
  763. dict(
  764. levelno=logging.INFO,
  765. levelname='INFO',
  766. msg='{}: Successfully ran configuration file'.format(config_filename),
  767. )
  768. )
  769. if results:
  770. json_results.extend(results)
  771. if 'umount' in arguments:
  772. logger.info('Unmounting mount point {}'.format(arguments['umount'].mount_point))
  773. try:
  774. borg_umount.unmount_archive(
  775. mount_point=arguments['umount'].mount_point, local_path=get_local_path(configs)
  776. )
  777. except (CalledProcessError, OSError) as error:
  778. yield from log_error_records('Error unmounting mount point', error)
  779. if json_results:
  780. sys.stdout.write(json.dumps(json_results))
  781. if 'create' in arguments:
  782. try:
  783. for config_filename, config in configs.items():
  784. hooks = config.get('hooks', {})
  785. command.execute_hook(
  786. hooks.get('after_everything'),
  787. hooks.get('umask'),
  788. config_filename,
  789. 'post-everything',
  790. arguments['global'].dry_run,
  791. )
  792. except (CalledProcessError, ValueError, OSError) as error:
  793. yield from log_error_records('Error running post-everything hook', error)
  794. def exit_with_help_link(): # pragma: no cover
  795. '''
  796. Display a link to get help and exit with an error code.
  797. '''
  798. logger.critical('')
  799. logger.critical('Need some help? https://torsion.org/borgmatic/#issues')
  800. sys.exit(1)
  801. def main(): # pragma: no cover
  802. configure_signals()
  803. try:
  804. arguments = parse_arguments(*sys.argv[1:])
  805. except ValueError as error:
  806. configure_logging(logging.CRITICAL)
  807. logger.critical(error)
  808. exit_with_help_link()
  809. except SystemExit as error:
  810. if error.code == 0:
  811. raise error
  812. configure_logging(logging.CRITICAL)
  813. logger.critical('Error parsing arguments: {}'.format(' '.join(sys.argv)))
  814. exit_with_help_link()
  815. global_arguments = arguments['global']
  816. if global_arguments.version:
  817. print(pkg_resources.require('borgmatic')[0].version)
  818. sys.exit(0)
  819. if global_arguments.bash_completion:
  820. print(borgmatic.commands.completion.bash_completion())
  821. sys.exit(0)
  822. config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths))
  823. configs, parse_logs = load_configurations(
  824. config_filenames, global_arguments.overrides, global_arguments.resolve_env
  825. )
  826. any_json_flags = any(
  827. getattr(sub_arguments, 'json', False) for sub_arguments in arguments.values()
  828. )
  829. colorama.init(
  830. autoreset=True,
  831. strip=not should_do_markup(global_arguments.no_color or any_json_flags, configs),
  832. )
  833. try:
  834. configure_logging(
  835. verbosity_to_log_level(global_arguments.verbosity),
  836. verbosity_to_log_level(global_arguments.syslog_verbosity),
  837. verbosity_to_log_level(global_arguments.log_file_verbosity),
  838. verbosity_to_log_level(global_arguments.monitoring_verbosity),
  839. global_arguments.log_file,
  840. )
  841. except (FileNotFoundError, PermissionError) as error:
  842. configure_logging(logging.CRITICAL)
  843. logger.critical('Error configuring logging: {}'.format(error))
  844. exit_with_help_link()
  845. logger.debug('Ensuring legacy configuration is upgraded')
  846. convert.guard_configuration_upgraded(LEGACY_CONFIG_PATH, config_filenames)
  847. summary_logs = parse_logs + list(collect_configuration_run_summary_logs(configs, arguments))
  848. summary_logs_max_level = max(log.levelno for log in summary_logs)
  849. for message in ('', 'summary:'):
  850. log_record(
  851. levelno=summary_logs_max_level,
  852. levelname=logging.getLevelName(summary_logs_max_level),
  853. msg=message,
  854. )
  855. for log in summary_logs:
  856. logger.handle(log)
  857. if summary_logs_max_level >= logging.CRITICAL:
  858. exit_with_help_link()