borgmatic.py 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146
  1. import collections
  2. import importlib.metadata
  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 ruamel.yaml
  11. import borgmatic.actions.borg
  12. import borgmatic.actions.break_lock
  13. import borgmatic.actions.change_passphrase
  14. import borgmatic.actions.check
  15. import borgmatic.actions.compact
  16. import borgmatic.actions.config.bootstrap
  17. import borgmatic.actions.config.generate
  18. import borgmatic.actions.config.validate
  19. import borgmatic.actions.create
  20. import borgmatic.actions.delete
  21. import borgmatic.actions.export_key
  22. import borgmatic.actions.export_tar
  23. import borgmatic.actions.extract
  24. import borgmatic.actions.import_key
  25. import borgmatic.actions.info
  26. import borgmatic.actions.list
  27. import borgmatic.actions.mount
  28. import borgmatic.actions.prune
  29. import borgmatic.actions.recreate
  30. import borgmatic.actions.repo_create
  31. import borgmatic.actions.repo_delete
  32. import borgmatic.actions.repo_info
  33. import borgmatic.actions.repo_list
  34. import borgmatic.actions.restore
  35. import borgmatic.actions.transfer
  36. import borgmatic.commands.completion.bash
  37. import borgmatic.commands.completion.fish
  38. import borgmatic.config.load
  39. import borgmatic.config.paths
  40. from borgmatic.borg import umount as borg_umount
  41. from borgmatic.borg import version as borg_version
  42. from borgmatic.commands.arguments import parse_arguments
  43. from borgmatic.config import checks, collect, validate
  44. from borgmatic.hooks import command, dispatch
  45. from borgmatic.hooks.monitoring import monitor
  46. from borgmatic.logger import (
  47. DISABLED,
  48. Log_prefix,
  49. add_custom_log_levels,
  50. configure_delayed_logging,
  51. configure_logging,
  52. should_do_markup,
  53. )
  54. from borgmatic.signals import configure_signals
  55. from borgmatic.verbosity import get_verbosity, verbosity_to_log_level
  56. logger = logging.getLogger(__name__)
  57. def get_skip_actions(config, arguments):
  58. '''
  59. Given a configuration dict and command-line arguments as an argparse.Namespace, return a list of
  60. the configured action names to skip. Omit "check" from this list though if "check --force" is
  61. part of the command-like arguments.
  62. '''
  63. skip_actions = config.get('skip_actions', [])
  64. if 'check' in arguments and arguments['check'].force:
  65. return [action for action in skip_actions if action != 'check']
  66. return skip_actions
  67. class Monitoring_hooks:
  68. '''
  69. A Python context manager for pinging monitoring hooks for the start state before the wrapped
  70. code and log and finish (or failure) after the wrapped code. Also responsible for
  71. initializing/destroying the monitoring hooks.
  72. Example use as a context manager:
  73. with Monitoring_hooks(config_filename, config, arguments, global_arguments):
  74. do_stuff()
  75. '''
  76. def __init__(self, config_filename, config, arguments, global_arguments):
  77. '''
  78. Given a configuration filename, a configuration dict, command-line arguments as an
  79. argparse.Namespace, and global arguments as an argparse.Namespace, save relevant data points
  80. for use below.
  81. '''
  82. using_primary_action = {'create', 'prune', 'compact', 'check'}.intersection(arguments)
  83. self.config_filename = config_filename
  84. self.config = config
  85. self.dry_run = global_arguments.dry_run
  86. self.monitoring_log_level = verbosity_to_log_level(
  87. get_verbosity({config_filename: config}, 'monitoring_verbosity')
  88. )
  89. self.monitoring_hooks_are_activated = (
  90. using_primary_action and self.monitoring_log_level != DISABLED
  91. )
  92. def __enter__(self):
  93. '''
  94. If monitoring hooks are enabled and a primary action is in use, initialize monitoring hooks
  95. and ping them for the "start" state.
  96. '''
  97. if not self.monitoring_hooks_are_activated:
  98. return
  99. dispatch.call_hooks(
  100. 'initialize_monitor',
  101. self.config,
  102. dispatch.Hook_type.MONITORING,
  103. self.config_filename,
  104. self.monitoring_log_level,
  105. self.dry_run,
  106. )
  107. try:
  108. dispatch.call_hooks(
  109. 'ping_monitor',
  110. self.config,
  111. dispatch.Hook_type.MONITORING,
  112. self.config_filename,
  113. monitor.State.START,
  114. self.monitoring_log_level,
  115. self.dry_run,
  116. )
  117. except (OSError, CalledProcessError) as error:
  118. raise ValueError(f'Error pinging monitor: {error}')
  119. def __exit__(self, exception_type, exception, traceback):
  120. '''
  121. If monitoring hooks are enabled and a primary action is in use, ping monitoring hooks for
  122. the "log" state and also the "finish" or "fail" states (depending on whether there's an
  123. exception). Lastly, destroy monitoring hooks.
  124. '''
  125. if not self.monitoring_hooks_are_activated:
  126. return
  127. # Send logs irrespective of error.
  128. try:
  129. dispatch.call_hooks(
  130. 'ping_monitor',
  131. self.config,
  132. dispatch.Hook_type.MONITORING,
  133. self.config_filename,
  134. monitor.State.LOG,
  135. self.monitoring_log_level,
  136. self.dry_run,
  137. )
  138. except (OSError, CalledProcessError) as error:
  139. raise ValueError(f'Error pinging monitor: {error}')
  140. try:
  141. dispatch.call_hooks(
  142. 'ping_monitor',
  143. self.config,
  144. dispatch.Hook_type.MONITORING,
  145. self.config_filename,
  146. monitor.State.FAIL if exception else monitor.State.FINISH,
  147. self.monitoring_log_level,
  148. self.dry_run,
  149. )
  150. except (OSError, CalledProcessError) as error:
  151. # If the wrapped code errored, prefer raising that exception, as it's probably more
  152. # important than a monitor failing to ping.
  153. if exception:
  154. return
  155. raise ValueError(f'Error pinging monitor: {error}')
  156. dispatch.call_hooks(
  157. 'destroy_monitor',
  158. self.config,
  159. dispatch.Hook_type.MONITORING,
  160. self.monitoring_log_level,
  161. self.dry_run,
  162. )
  163. def run_configuration(config_filename, config, config_paths, arguments):
  164. '''
  165. Given a config filename, the corresponding parsed config dict, a sequence of loaded
  166. configuration paths, and command-line arguments as a dict from subparser name to a namespace of
  167. parsed arguments, execute the defined create, prune, compact, check, and/or other actions.
  168. Yield a combination of:
  169. * JSON output strings from successfully executing any actions that produce JSON
  170. * logging.LogRecord instances containing errors from any actions or backup hooks that fail
  171. '''
  172. global_arguments = arguments['global']
  173. local_path = config.get('local_path', 'borg')
  174. remote_path = config.get('remote_path')
  175. retries = config.get('retries', 0)
  176. retry_wait = config.get('retry_wait', 0)
  177. repo_queue = Queue()
  178. encountered_error = None
  179. error_repository = None
  180. skip_actions = get_skip_actions(config, arguments)
  181. if skip_actions:
  182. logger.debug(
  183. f"Skipping {'/'.join(skip_actions)} action{'s' if len(skip_actions) > 1 else ''} due to configured skip_actions"
  184. )
  185. try:
  186. with Monitoring_hooks(config_filename, config, arguments, global_arguments):
  187. with borgmatic.hooks.command.Before_after_hooks(
  188. command_hooks=config.get('commands'),
  189. before_after='configuration',
  190. umask=config.get('umask'),
  191. working_directory=borgmatic.config.paths.get_working_directory(config),
  192. dry_run=global_arguments.dry_run,
  193. action_names=arguments.keys(),
  194. configuration_filename=config_filename,
  195. log_file=config.get('log_file', ''),
  196. ):
  197. try:
  198. local_borg_version = borg_version.local_borg_version(config, local_path)
  199. logger.debug(f'Borg {local_borg_version}')
  200. except (OSError, CalledProcessError, ValueError) as error:
  201. yield from log_error_records(
  202. f'{config_filename}: Error getting local Borg version', error
  203. )
  204. return
  205. for repo in config['repositories']:
  206. repo_queue.put(
  207. (repo, 0),
  208. )
  209. while not repo_queue.empty():
  210. repository, retry_num = repo_queue.get()
  211. with Log_prefix(repository.get('label', repository['path'])):
  212. logger.debug('Running actions for repository')
  213. timeout = retry_num * retry_wait
  214. if timeout:
  215. logger.warning(f'Sleeping {timeout}s before next retry')
  216. time.sleep(timeout)
  217. try:
  218. yield from run_actions(
  219. arguments=arguments,
  220. config_filename=config_filename,
  221. config=config,
  222. config_paths=config_paths,
  223. local_path=local_path,
  224. remote_path=remote_path,
  225. local_borg_version=local_borg_version,
  226. repository=repository,
  227. )
  228. except (OSError, CalledProcessError, ValueError) as error:
  229. if retry_num < retries:
  230. repo_queue.put(
  231. (repository, retry_num + 1),
  232. )
  233. tuple( # Consume the generator so as to trigger logging.
  234. log_error_records(
  235. 'Error running actions for repository',
  236. error,
  237. levelno=logging.WARNING,
  238. log_command_error_output=True,
  239. )
  240. )
  241. logger.warning(f'Retrying... attempt {retry_num + 1}/{retries}')
  242. continue
  243. if command.considered_soft_failure(error):
  244. continue
  245. yield from log_error_records(
  246. 'Error running actions for repository',
  247. error,
  248. )
  249. encountered_error = error
  250. error_repository = repository
  251. # Re-raise any error, so that the Monitoring_hooks context manager wrapping this
  252. # code can see the error and act accordingly. Do this here rather than as soon as
  253. # the error is encountered so that an error with one repository doesn't prevent
  254. # other repositories from running.
  255. if encountered_error:
  256. raise encountered_error
  257. except (OSError, CalledProcessError, ValueError) as error:
  258. # No need to repeat logging of the error if it was already logged above.
  259. if error_repository:
  260. yield from log_error_records('Error running configuration')
  261. else:
  262. yield from log_error_records('Error running configuration', error)
  263. encountered_error = error
  264. if not encountered_error:
  265. return
  266. try:
  267. command.execute_hooks(
  268. command.filter_hooks(
  269. config.get('commands'),
  270. after='error',
  271. action_names=arguments.keys(),
  272. state_names=['fail'],
  273. ),
  274. config.get('umask'),
  275. borgmatic.config.paths.get_working_directory(config),
  276. global_arguments.dry_run,
  277. configuration_filename=config_filename,
  278. log_file=config.get('log_file', ''),
  279. repository=error_repository.get('path', '') if error_repository else '',
  280. repository_label=error_repository.get('label', '') if error_repository else '',
  281. error=encountered_error,
  282. output=getattr(encountered_error, 'output', ''),
  283. )
  284. except (OSError, CalledProcessError) as error:
  285. if command.considered_soft_failure(error):
  286. return
  287. yield from log_error_records(f'{config_filename}: Error running after error hook', error)
  288. def run_actions(
  289. *,
  290. arguments,
  291. config_filename,
  292. config,
  293. config_paths,
  294. local_path,
  295. remote_path,
  296. local_borg_version,
  297. repository,
  298. ):
  299. '''
  300. Given parsed command-line arguments as an argparse.ArgumentParser instance, the configuration
  301. filename, a configuration dict, a sequence of loaded configuration paths, local and remote paths
  302. to Borg, a local Borg version string, and a repository name, run all actions from the
  303. command-line arguments on the given repository.
  304. Yield JSON output strings from executing any actions that produce JSON.
  305. Raise OSError or subprocess.CalledProcessError if an error occurs running a command for an
  306. action or a hook. Raise ValueError if the arguments or configuration passed to action are
  307. invalid.
  308. '''
  309. add_custom_log_levels()
  310. repository_path = os.path.expanduser(repository['path'])
  311. global_arguments = arguments['global']
  312. dry_run_label = ' (dry run; not making any changes)' if global_arguments.dry_run else ''
  313. hook_context = {
  314. 'configuration_filename': config_filename,
  315. 'repository_label': repository.get('label', ''),
  316. 'log_file': config.get('log_file', ''),
  317. # Deprecated: For backwards compatibility with borgmatic < 1.6.0.
  318. 'repositories': ','.join([repo['path'] for repo in config['repositories']]),
  319. 'repository': repository_path,
  320. }
  321. skip_actions = set(get_skip_actions(config, arguments))
  322. with borgmatic.hooks.command.Before_after_hooks(
  323. command_hooks=config.get('commands'),
  324. before_after='repository',
  325. umask=config.get('umask'),
  326. working_directory=borgmatic.config.paths.get_working_directory(config),
  327. dry_run=global_arguments.dry_run,
  328. action_names=arguments.keys(),
  329. **hook_context,
  330. ):
  331. for action_name, action_arguments in arguments.items():
  332. if action_name == 'global' or action_name in skip_actions:
  333. continue
  334. with borgmatic.hooks.command.Before_after_hooks(
  335. command_hooks=config.get('commands'),
  336. before_after='action',
  337. umask=config.get('umask'),
  338. working_directory=borgmatic.config.paths.get_working_directory(config),
  339. dry_run=global_arguments.dry_run,
  340. action_names=(action_name,),
  341. **hook_context,
  342. ):
  343. if action_name == 'repo-create':
  344. borgmatic.actions.repo_create.run_repo_create(
  345. repository,
  346. config,
  347. local_borg_version,
  348. action_arguments,
  349. global_arguments,
  350. local_path,
  351. remote_path,
  352. )
  353. elif action_name == 'transfer':
  354. borgmatic.actions.transfer.run_transfer(
  355. repository,
  356. config,
  357. local_borg_version,
  358. action_arguments,
  359. global_arguments,
  360. local_path,
  361. remote_path,
  362. )
  363. elif action_name == 'create':
  364. yield from borgmatic.actions.create.run_create(
  365. config_filename,
  366. repository,
  367. config,
  368. config_paths,
  369. local_borg_version,
  370. action_arguments,
  371. global_arguments,
  372. dry_run_label,
  373. local_path,
  374. remote_path,
  375. )
  376. elif action_name == 'recreate':
  377. borgmatic.actions.recreate.run_recreate(
  378. repository,
  379. config,
  380. local_borg_version,
  381. action_arguments,
  382. global_arguments,
  383. local_path,
  384. remote_path,
  385. )
  386. elif action_name == 'prune':
  387. borgmatic.actions.prune.run_prune(
  388. config_filename,
  389. repository,
  390. config,
  391. local_borg_version,
  392. action_arguments,
  393. global_arguments,
  394. dry_run_label,
  395. local_path,
  396. remote_path,
  397. )
  398. elif action_name == 'compact':
  399. borgmatic.actions.compact.run_compact(
  400. config_filename,
  401. repository,
  402. config,
  403. local_borg_version,
  404. action_arguments,
  405. global_arguments,
  406. dry_run_label,
  407. local_path,
  408. remote_path,
  409. )
  410. elif action_name == 'check':
  411. if checks.repository_enabled_for_checks(repository, config):
  412. borgmatic.actions.check.run_check(
  413. config_filename,
  414. repository,
  415. config,
  416. local_borg_version,
  417. action_arguments,
  418. global_arguments,
  419. local_path,
  420. remote_path,
  421. )
  422. elif action_name == 'extract':
  423. borgmatic.actions.extract.run_extract(
  424. config_filename,
  425. repository,
  426. config,
  427. local_borg_version,
  428. action_arguments,
  429. global_arguments,
  430. local_path,
  431. remote_path,
  432. )
  433. elif action_name == 'export-tar':
  434. borgmatic.actions.export_tar.run_export_tar(
  435. repository,
  436. config,
  437. local_borg_version,
  438. action_arguments,
  439. global_arguments,
  440. local_path,
  441. remote_path,
  442. )
  443. elif action_name == 'mount':
  444. borgmatic.actions.mount.run_mount(
  445. repository,
  446. config,
  447. local_borg_version,
  448. action_arguments,
  449. global_arguments,
  450. local_path,
  451. remote_path,
  452. )
  453. elif action_name == 'restore':
  454. borgmatic.actions.restore.run_restore(
  455. repository,
  456. config,
  457. local_borg_version,
  458. action_arguments,
  459. global_arguments,
  460. local_path,
  461. remote_path,
  462. )
  463. elif action_name == 'repo-list':
  464. yield from borgmatic.actions.repo_list.run_repo_list(
  465. repository,
  466. config,
  467. local_borg_version,
  468. action_arguments,
  469. global_arguments,
  470. local_path,
  471. remote_path,
  472. )
  473. elif action_name == 'list':
  474. yield from borgmatic.actions.list.run_list(
  475. repository,
  476. config,
  477. local_borg_version,
  478. action_arguments,
  479. global_arguments,
  480. local_path,
  481. remote_path,
  482. )
  483. elif action_name == 'repo-info':
  484. yield from borgmatic.actions.repo_info.run_repo_info(
  485. repository,
  486. config,
  487. local_borg_version,
  488. action_arguments,
  489. global_arguments,
  490. local_path,
  491. remote_path,
  492. )
  493. elif action_name == 'info':
  494. yield from borgmatic.actions.info.run_info(
  495. repository,
  496. config,
  497. local_borg_version,
  498. action_arguments,
  499. global_arguments,
  500. local_path,
  501. remote_path,
  502. )
  503. elif action_name == 'break-lock':
  504. borgmatic.actions.break_lock.run_break_lock(
  505. repository,
  506. config,
  507. local_borg_version,
  508. action_arguments,
  509. global_arguments,
  510. local_path,
  511. remote_path,
  512. )
  513. elif action_name == 'export':
  514. borgmatic.actions.export_key.run_export_key(
  515. repository,
  516. config,
  517. local_borg_version,
  518. action_arguments,
  519. global_arguments,
  520. local_path,
  521. remote_path,
  522. )
  523. elif action_name == 'import':
  524. borgmatic.actions.import_key.run_import_key(
  525. repository,
  526. config,
  527. local_borg_version,
  528. action_arguments,
  529. global_arguments,
  530. local_path,
  531. remote_path,
  532. )
  533. elif action_name == 'change-passphrase':
  534. borgmatic.actions.change_passphrase.run_change_passphrase(
  535. repository,
  536. config,
  537. local_borg_version,
  538. action_arguments,
  539. global_arguments,
  540. local_path,
  541. remote_path,
  542. )
  543. elif action_name == 'delete':
  544. borgmatic.actions.delete.run_delete(
  545. repository,
  546. config,
  547. local_borg_version,
  548. action_arguments,
  549. global_arguments,
  550. local_path,
  551. remote_path,
  552. )
  553. elif action_name == 'repo-delete':
  554. borgmatic.actions.repo_delete.run_repo_delete(
  555. repository,
  556. config,
  557. local_borg_version,
  558. action_arguments,
  559. global_arguments,
  560. local_path,
  561. remote_path,
  562. )
  563. elif action_name == 'borg':
  564. borgmatic.actions.borg.run_borg(
  565. repository,
  566. config,
  567. local_borg_version,
  568. action_arguments,
  569. global_arguments,
  570. local_path,
  571. remote_path,
  572. )
  573. def load_configurations(config_filenames, arguments, overrides=None, resolve_env=True):
  574. '''
  575. Given a sequence of configuration filenames, arguments as a dict from action name to
  576. argparse.Namespace, a sequence of configuration file override strings in the form of
  577. "option.suboption=value", and whether to resolve environment variables, load and validate each
  578. configuration file. Return the results as a tuple of: dict of configuration filename to
  579. corresponding parsed configuration, a sequence of paths for all loaded configuration files
  580. (including includes), and a sequence of logging.LogRecord instances containing any parse errors.
  581. Log records are returned here instead of being logged directly because logging isn't yet
  582. initialized at this point! (Although with the Delayed_logging_handler now in place, maybe this
  583. approach could change.)
  584. '''
  585. # Dict mapping from config filename to corresponding parsed config dict.
  586. configs = collections.OrderedDict()
  587. config_paths = set()
  588. logs = []
  589. # Parse and load each configuration file.
  590. for config_filename in config_filenames:
  591. logs.extend(
  592. [
  593. logging.makeLogRecord(
  594. dict(
  595. levelno=logging.DEBUG,
  596. levelname='DEBUG',
  597. msg=f'{config_filename}: Loading configuration file',
  598. )
  599. ),
  600. ]
  601. )
  602. try:
  603. configs[config_filename], paths, parse_logs = validate.parse_configuration(
  604. config_filename,
  605. validate.schema_filename(),
  606. arguments,
  607. overrides,
  608. resolve_env,
  609. )
  610. config_paths.update(paths)
  611. logs.extend(parse_logs)
  612. except PermissionError:
  613. logs.extend(
  614. [
  615. logging.makeLogRecord(
  616. dict(
  617. levelno=logging.WARNING,
  618. levelname='WARNING',
  619. msg=f'{config_filename}: Insufficient permissions to read configuration file',
  620. )
  621. ),
  622. ]
  623. )
  624. except (ValueError, OSError, validate.Validation_error) as error:
  625. logs.extend(
  626. [
  627. logging.makeLogRecord(
  628. dict(
  629. levelno=logging.CRITICAL,
  630. levelname='CRITICAL',
  631. msg=f'{config_filename}: Error parsing configuration file',
  632. )
  633. ),
  634. logging.makeLogRecord(
  635. dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=str(error))
  636. ),
  637. ]
  638. )
  639. return (configs, sorted(config_paths), logs)
  640. def log_record(suppress_log=False, **kwargs):
  641. '''
  642. Create a log record based on the given makeLogRecord() arguments, one of which must be
  643. named "levelno". Log the record (unless suppress log is set) and return it.
  644. '''
  645. record = logging.makeLogRecord(kwargs)
  646. if suppress_log:
  647. return record
  648. logger.handle(record)
  649. return record
  650. BORG_REPOSITORY_ACCESS_ABORTED_EXIT_CODE = 62
  651. def log_error_records(
  652. message, error=None, levelno=logging.CRITICAL, log_command_error_output=False
  653. ):
  654. '''
  655. Given error message text, an optional exception object, an optional log level, and whether to
  656. log the error output of a CalledProcessError (if any), log error summary information and also
  657. yield it as a series of logging.LogRecord instances.
  658. Note that because the logs are yielded as a generator, logs won't get logged unless you consume
  659. the generator output.
  660. '''
  661. level_name = logging._levelToName[levelno]
  662. if not error:
  663. yield log_record(levelno=levelno, levelname=level_name, msg=str(message))
  664. return
  665. try:
  666. raise error
  667. except CalledProcessError as error:
  668. yield log_record(levelno=levelno, levelname=level_name, msg=str(message))
  669. if error.output:
  670. try:
  671. output = error.output.decode('utf-8')
  672. except (UnicodeDecodeError, AttributeError):
  673. output = error.output
  674. # Suppress these logs for now and save the error output for the log summary at the end.
  675. # Log a separate record per line, as some errors can be really verbose and overflow the
  676. # per-record size limits imposed by some logging backends.
  677. for output_line in output.splitlines():
  678. yield log_record(
  679. levelno=levelno,
  680. levelname=level_name,
  681. msg=output_line,
  682. suppress_log=True,
  683. )
  684. yield log_record(levelno=levelno, levelname=level_name, msg=str(error))
  685. if error.returncode == BORG_REPOSITORY_ACCESS_ABORTED_EXIT_CODE:
  686. yield log_record(
  687. levelno=levelno,
  688. levelname=level_name,
  689. msg='\nTo work around this, set either the "relocated_repo_access_is_ok" or "unknown_unencrypted_repo_access_is_ok" option to "true", as appropriate.',
  690. )
  691. except (ValueError, OSError) as error:
  692. yield log_record(levelno=levelno, levelname=level_name, msg=str(message))
  693. yield log_record(levelno=levelno, levelname=level_name, msg=str(error))
  694. except: # noqa: E722
  695. # Raising above only as a means of determining the error type. Swallow the exception here
  696. # because we don't want the exception to propagate out of this function.
  697. pass
  698. def get_local_path(configs):
  699. '''
  700. Arbitrarily return the local path from the first configuration dict. Default to "borg" if not
  701. set.
  702. '''
  703. return next(iter(configs.values())).get('local_path', 'borg')
  704. def collect_highlander_action_summary_logs(configs, arguments, configuration_parse_errors):
  705. '''
  706. Given a dict of configuration filename to corresponding parsed configuration, parsed
  707. command-line arguments as a dict from subparser name to a parsed namespace of arguments, and
  708. whether any configuration files encountered errors during parsing, run a highlander action
  709. specified in the arguments, if any, and yield a series of logging.LogRecord instances containing
  710. summary information.
  711. A highlander action is an action that cannot coexist with other actions on the borgmatic
  712. command-line, and borgmatic exits after processing such an action.
  713. '''
  714. add_custom_log_levels()
  715. if 'bootstrap' in arguments:
  716. try:
  717. # No configuration file is needed for bootstrap.
  718. local_borg_version = borg_version.local_borg_version(
  719. {}, arguments['bootstrap'].local_path
  720. )
  721. except (OSError, CalledProcessError, ValueError) as error:
  722. yield from log_error_records('Error getting local Borg version', error)
  723. return
  724. try:
  725. borgmatic.actions.config.bootstrap.run_bootstrap(
  726. arguments['bootstrap'], arguments['global'], local_borg_version
  727. )
  728. yield logging.makeLogRecord(
  729. dict(
  730. levelno=logging.ANSWER,
  731. levelname='ANSWER',
  732. msg='Bootstrap successful',
  733. )
  734. )
  735. except (
  736. CalledProcessError,
  737. ValueError,
  738. OSError,
  739. ) as error:
  740. yield from log_error_records(error)
  741. return
  742. if 'generate' in arguments:
  743. try:
  744. borgmatic.actions.config.generate.run_generate(
  745. arguments['generate'], arguments['global']
  746. )
  747. yield logging.makeLogRecord(
  748. dict(
  749. levelno=logging.ANSWER,
  750. levelname='ANSWER',
  751. msg='Generate successful',
  752. )
  753. )
  754. except (
  755. CalledProcessError,
  756. ValueError,
  757. OSError,
  758. ) as error:
  759. yield from log_error_records(error)
  760. return
  761. if 'validate' in arguments:
  762. if configuration_parse_errors:
  763. yield logging.makeLogRecord(
  764. dict(
  765. levelno=logging.CRITICAL,
  766. levelname='CRITICAL',
  767. msg='Configuration validation failed',
  768. )
  769. )
  770. return
  771. try:
  772. borgmatic.actions.config.validate.run_validate(arguments['validate'], configs)
  773. yield logging.makeLogRecord(
  774. dict(
  775. levelno=logging.ANSWER,
  776. levelname='ANSWER',
  777. msg='All configuration files are valid',
  778. )
  779. )
  780. except (
  781. CalledProcessError,
  782. ValueError,
  783. OSError,
  784. ) as error:
  785. yield from log_error_records(error)
  786. return
  787. def collect_configuration_run_summary_logs(configs, config_paths, arguments, log_file_path):
  788. '''
  789. Given a dict of configuration filename to corresponding parsed configuration, a sequence of
  790. loaded configuration paths, parsed command-line arguments as a dict from subparser name to a
  791. parsed namespace of arguments, and the path of a log file (if any), run each configuration file
  792. and yield a series of logging.LogRecord instances containing summary information about each run.
  793. As a side effect of running through these configuration files, output their JSON results, if
  794. any, to stdout.
  795. '''
  796. # Run cross-file validation checks.
  797. repository = None
  798. for action_name, action_arguments in arguments.items():
  799. if hasattr(action_arguments, 'repository'):
  800. repository = getattr(action_arguments, 'repository')
  801. break
  802. try:
  803. validate.guard_configuration_contains_repository(repository, configs)
  804. except ValueError as error:
  805. yield from log_error_records(str(error))
  806. return
  807. if not configs:
  808. yield from log_error_records(
  809. f"{' '.join(arguments['global'].config_paths)}: No valid configuration files found",
  810. )
  811. return
  812. try:
  813. seen_command_hooks = []
  814. for config_filename, config in configs.items():
  815. command_hooks = command.filter_hooks(
  816. tuple(
  817. command_hook
  818. for command_hook in config.get('commands', ())
  819. if command_hook not in seen_command_hooks
  820. ),
  821. before='everything',
  822. action_names=arguments.keys(),
  823. )
  824. if command_hooks:
  825. command.execute_hooks(
  826. command_hooks,
  827. config.get('umask'),
  828. borgmatic.config.paths.get_working_directory(config),
  829. arguments['global'].dry_run,
  830. configuration_filename=config_filename,
  831. log_file=log_file_path or '',
  832. )
  833. seen_command_hooks += list(command_hooks)
  834. except (CalledProcessError, ValueError, OSError) as error:
  835. yield from log_error_records('Error running before everything hook', error)
  836. return
  837. # Execute the actions corresponding to each configuration file.
  838. json_results = []
  839. encountered_error = False
  840. for config_filename, config in configs.items():
  841. with Log_prefix(config_filename):
  842. results = list(run_configuration(config_filename, config, config_paths, arguments))
  843. error_logs = tuple(
  844. result for result in results if isinstance(result, logging.LogRecord)
  845. )
  846. if error_logs:
  847. encountered_error = True
  848. yield from log_error_records('An error occurred')
  849. yield from error_logs
  850. else:
  851. yield logging.makeLogRecord(
  852. dict(
  853. levelno=logging.INFO,
  854. levelname='INFO',
  855. msg=f'{config_filename}: Successfully ran configuration file',
  856. )
  857. )
  858. if results:
  859. json_results.extend(results)
  860. if 'umount' in arguments:
  861. logger.info(f"Unmounting mount point {arguments['umount'].mount_point}")
  862. try:
  863. borg_umount.unmount_archive(
  864. config,
  865. mount_point=arguments['umount'].mount_point,
  866. local_path=get_local_path(configs),
  867. )
  868. except (CalledProcessError, OSError) as error:
  869. encountered_error = True
  870. yield from log_error_records('Error unmounting mount point', error)
  871. if json_results:
  872. sys.stdout.write(json.dumps(json_results))
  873. try:
  874. seen_command_hooks = []
  875. for config_filename, config in configs.items():
  876. command_hooks = command.filter_hooks(
  877. tuple(
  878. command_hook
  879. for command_hook in config.get('commands', ())
  880. if command_hook not in seen_command_hooks
  881. ),
  882. after='everything',
  883. action_names=arguments.keys(),
  884. state_names=['fail' if encountered_error else 'finish'],
  885. )
  886. if command_hooks:
  887. command.execute_hooks(
  888. command_hooks,
  889. config.get('umask'),
  890. borgmatic.config.paths.get_working_directory(config),
  891. arguments['global'].dry_run,
  892. configuration_filename=config_filename,
  893. log_file=log_file_path or '',
  894. )
  895. seen_command_hooks += list(command_hooks)
  896. except (CalledProcessError, ValueError, OSError) as error:
  897. yield from log_error_records('Error running after everything hook', error)
  898. def exit_with_help_link(): # pragma: no cover
  899. '''
  900. Display a link to get help and exit with an error code.
  901. '''
  902. logger.critical('')
  903. logger.critical('Need some help? https://torsion.org/borgmatic/#issues')
  904. sys.exit(1)
  905. def check_and_show_help_on_no_args(configs):
  906. '''
  907. Given a dict of configuration filename to corresponding parsed configuration, check if the
  908. borgmatic command is run without any arguments. If the configuration option "default_actions" is
  909. set to False, show the help message. Otherwise, trigger the default backup behavior.
  910. '''
  911. if len(sys.argv) == 1: # No arguments provided
  912. default_actions = any(config.get('default_actions', True) for config in configs.values())
  913. if not default_actions:
  914. parse_arguments('--help')
  915. sys.exit(0)
  916. def get_singular_option_value(configs, option_name):
  917. '''
  918. Given a dict of configuration filename to corresponding parsed configuration, return the value
  919. of the given option from the configuration files or None if it's not set.
  920. Log and exit if there are conflicting values for the option across the configuration files.
  921. '''
  922. distinct_values = {
  923. value
  924. for config in configs.values()
  925. for value in (config.get(option_name),)
  926. if value is not None
  927. }
  928. if len(distinct_values) > 1:
  929. configure_logging(logging.CRITICAL)
  930. joined_values = ', '.join(str(value) for value in distinct_values)
  931. logger.critical(
  932. f'The {option_name} option has conflicting values across configuration files: {joined_values}'
  933. )
  934. exit_with_help_link()
  935. try:
  936. return tuple(distinct_values)[0]
  937. except IndexError:
  938. return None
  939. def main(extra_summary_logs=[]): # pragma: no cover
  940. configure_signals()
  941. configure_delayed_logging()
  942. schema_filename = validate.schema_filename()
  943. try:
  944. schema = borgmatic.config.load.load_configuration(schema_filename)
  945. except (ruamel.yaml.error.YAMLError, RecursionError) as error:
  946. configure_logging(logging.CRITICAL)
  947. logger.critical(error)
  948. exit_with_help_link()
  949. try:
  950. arguments = parse_arguments(schema, *sys.argv[1:])
  951. except ValueError as error:
  952. configure_logging(logging.CRITICAL)
  953. logger.critical(error)
  954. exit_with_help_link()
  955. except SystemExit as error:
  956. if error.code == 0:
  957. raise error
  958. configure_logging(logging.CRITICAL)
  959. logger.critical(f"Error parsing arguments: {' '.join(sys.argv)}")
  960. exit_with_help_link()
  961. global_arguments = arguments['global']
  962. if global_arguments.version:
  963. print(importlib.metadata.version('borgmatic'))
  964. sys.exit(0)
  965. if global_arguments.bash_completion:
  966. print(borgmatic.commands.completion.bash.bash_completion())
  967. sys.exit(0)
  968. if global_arguments.fish_completion:
  969. print(borgmatic.commands.completion.fish.fish_completion())
  970. sys.exit(0)
  971. config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths))
  972. configs, config_paths, parse_logs = load_configurations(
  973. config_filenames,
  974. arguments,
  975. global_arguments.overrides,
  976. resolve_env=global_arguments.resolve_env and not arguments.get('validate'),
  977. )
  978. # Use the helper function to check and show help on no arguments, passing the preloaded configs
  979. check_and_show_help_on_no_args(configs)
  980. configuration_parse_errors = (
  981. (max(log.levelno for log in parse_logs) >= logging.CRITICAL) if parse_logs else False
  982. )
  983. any_json_flags = any(
  984. getattr(sub_arguments, 'json', False) for sub_arguments in arguments.values()
  985. )
  986. log_file_path = get_singular_option_value(configs, 'log_file')
  987. try:
  988. configure_logging(
  989. verbosity_to_log_level(get_verbosity(configs, 'verbosity')),
  990. verbosity_to_log_level(get_verbosity(configs, 'syslog_verbosity')),
  991. verbosity_to_log_level(get_verbosity(configs, 'log_file_verbosity')),
  992. verbosity_to_log_level(get_verbosity(configs, 'monitoring_verbosity')),
  993. log_file_path,
  994. get_singular_option_value(configs, 'log_file_format'),
  995. color_enabled=should_do_markup(configs, any_json_flags),
  996. )
  997. except (FileNotFoundError, PermissionError) as error:
  998. configure_logging(logging.CRITICAL)
  999. logger.critical(f'Error configuring logging: {error}')
  1000. exit_with_help_link()
  1001. summary_logs = (
  1002. extra_summary_logs
  1003. + parse_logs
  1004. + (
  1005. list(
  1006. collect_highlander_action_summary_logs(
  1007. configs, arguments, configuration_parse_errors
  1008. )
  1009. )
  1010. or list(
  1011. collect_configuration_run_summary_logs(
  1012. configs, config_paths, arguments, log_file_path
  1013. )
  1014. )
  1015. )
  1016. )
  1017. summary_logs_max_level = max(log.levelno for log in summary_logs)
  1018. for message in ('', 'summary:'):
  1019. log_record(
  1020. levelno=summary_logs_max_level,
  1021. levelname=logging.getLevelName(summary_logs_max_level),
  1022. msg=message,
  1023. )
  1024. for log in summary_logs:
  1025. logger.handle(log)
  1026. if summary_logs_max_level >= logging.CRITICAL:
  1027. exit_with_help_link()