borgmatic.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963
  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 borgmatic.actions.borg
  11. import borgmatic.actions.break_lock
  12. import borgmatic.actions.change_passphrase
  13. import borgmatic.actions.check
  14. import borgmatic.actions.compact
  15. import borgmatic.actions.config.bootstrap
  16. import borgmatic.actions.config.generate
  17. import borgmatic.actions.config.validate
  18. import borgmatic.actions.create
  19. import borgmatic.actions.delete
  20. import borgmatic.actions.export_key
  21. import borgmatic.actions.export_tar
  22. import borgmatic.actions.extract
  23. import borgmatic.actions.info
  24. import borgmatic.actions.list
  25. import borgmatic.actions.mount
  26. import borgmatic.actions.prune
  27. import borgmatic.actions.repo_create
  28. import borgmatic.actions.repo_delete
  29. import borgmatic.actions.repo_info
  30. import borgmatic.actions.repo_list
  31. import borgmatic.actions.restore
  32. import borgmatic.actions.transfer
  33. import borgmatic.commands.completion.bash
  34. import borgmatic.commands.completion.fish
  35. from borgmatic.borg import umount as borg_umount
  36. from borgmatic.borg import version as borg_version
  37. from borgmatic.commands.arguments import parse_arguments
  38. from borgmatic.config import checks, collect, validate
  39. from borgmatic.hooks import command, dispatch
  40. from borgmatic.hooks.monitoring import monitor
  41. from borgmatic.logger import (
  42. DISABLED,
  43. Log_prefix,
  44. add_custom_log_levels,
  45. configure_delayed_logging,
  46. configure_logging,
  47. should_do_markup,
  48. )
  49. from borgmatic.signals import configure_signals
  50. from borgmatic.verbosity import verbosity_to_log_level
  51. logger = logging.getLogger(__name__)
  52. def get_skip_actions(config, arguments):
  53. '''
  54. Given a configuration dict and command-line arguments as an argparse.Namespace, return a list of
  55. the configured action names to skip. Omit "check" from this list though if "check --force" is
  56. part of the command-like arguments.
  57. '''
  58. skip_actions = config.get('skip_actions', [])
  59. if 'check' in arguments and arguments['check'].force:
  60. return [action for action in skip_actions if action != 'check']
  61. return skip_actions
  62. def run_configuration(config_filename, config, config_paths, arguments):
  63. '''
  64. Given a config filename, the corresponding parsed config dict, a sequence of loaded
  65. configuration paths, and command-line arguments as a dict from subparser name to a namespace of
  66. parsed arguments, execute the defined create, prune, compact, check, and/or other actions.
  67. Yield a combination of:
  68. * JSON output strings from successfully executing any actions that produce JSON
  69. * logging.LogRecord instances containing errors from any actions or backup hooks that fail
  70. '''
  71. global_arguments = arguments['global']
  72. local_path = config.get('local_path', 'borg')
  73. remote_path = config.get('remote_path')
  74. retries = config.get('retries', 0)
  75. retry_wait = config.get('retry_wait', 0)
  76. encountered_error = None
  77. error_repository = ''
  78. using_primary_action = {'create', 'prune', 'compact', 'check'}.intersection(arguments)
  79. monitoring_log_level = verbosity_to_log_level(global_arguments.monitoring_verbosity)
  80. monitoring_hooks_are_activated = using_primary_action and monitoring_log_level != DISABLED
  81. skip_actions = get_skip_actions(config, arguments)
  82. if skip_actions:
  83. logger.debug(
  84. f"Skipping {'/'.join(skip_actions)} action{'s' if len(skip_actions) > 1 else ''} due to configured skip_actions"
  85. )
  86. try:
  87. local_borg_version = borg_version.local_borg_version(config, local_path)
  88. logger.debug(f'Borg {local_borg_version}')
  89. except (OSError, CalledProcessError, ValueError) as error:
  90. yield from log_error_records(f'{config_filename}: Error getting local Borg version', error)
  91. return
  92. try:
  93. if monitoring_hooks_are_activated:
  94. dispatch.call_hooks(
  95. 'initialize_monitor',
  96. config,
  97. dispatch.Hook_type.MONITORING,
  98. config_filename,
  99. monitoring_log_level,
  100. global_arguments.dry_run,
  101. )
  102. dispatch.call_hooks(
  103. 'ping_monitor',
  104. config,
  105. dispatch.Hook_type.MONITORING,
  106. config_filename,
  107. monitor.State.START,
  108. monitoring_log_level,
  109. global_arguments.dry_run,
  110. )
  111. except (OSError, CalledProcessError) as error:
  112. if command.considered_soft_failure(error):
  113. return
  114. encountered_error = error
  115. yield from log_error_records(f'{config_filename}: Error pinging monitor', error)
  116. if not encountered_error:
  117. repo_queue = Queue()
  118. for repo in config['repositories']:
  119. repo_queue.put(
  120. (repo, 0),
  121. )
  122. while not repo_queue.empty():
  123. repository, retry_num = repo_queue.get()
  124. with Log_prefix(repository.get('label', repository['path'])):
  125. logger.debug('Running actions for repository')
  126. timeout = retry_num * retry_wait
  127. if timeout:
  128. logger.warning(f'Sleeping {timeout}s before next retry')
  129. time.sleep(timeout)
  130. try:
  131. yield from run_actions(
  132. arguments=arguments,
  133. config_filename=config_filename,
  134. config=config,
  135. config_paths=config_paths,
  136. local_path=local_path,
  137. remote_path=remote_path,
  138. local_borg_version=local_borg_version,
  139. repository=repository,
  140. )
  141. except (OSError, CalledProcessError, ValueError) as error:
  142. if retry_num < retries:
  143. repo_queue.put(
  144. (repository, retry_num + 1),
  145. )
  146. tuple( # Consume the generator so as to trigger logging.
  147. log_error_records(
  148. 'Error running actions for repository',
  149. error,
  150. levelno=logging.WARNING,
  151. log_command_error_output=True,
  152. )
  153. )
  154. logger.warning(f'Retrying... attempt {retry_num + 1}/{retries}')
  155. continue
  156. if command.considered_soft_failure(error):
  157. continue
  158. yield from log_error_records(
  159. 'Error running actions for repository',
  160. error,
  161. )
  162. encountered_error = error
  163. error_repository = repository['path']
  164. try:
  165. if monitoring_hooks_are_activated:
  166. # Send logs irrespective of error.
  167. dispatch.call_hooks(
  168. 'ping_monitor',
  169. config,
  170. dispatch.Hook_type.MONITORING,
  171. config_filename,
  172. monitor.State.LOG,
  173. monitoring_log_level,
  174. global_arguments.dry_run,
  175. )
  176. except (OSError, CalledProcessError) as error:
  177. if not command.considered_soft_failure(error):
  178. encountered_error = error
  179. yield from log_error_records('Error pinging monitor', error)
  180. if not encountered_error:
  181. try:
  182. if monitoring_hooks_are_activated:
  183. dispatch.call_hooks(
  184. 'ping_monitor',
  185. config,
  186. dispatch.Hook_type.MONITORING,
  187. config_filename,
  188. monitor.State.FINISH,
  189. monitoring_log_level,
  190. global_arguments.dry_run,
  191. )
  192. dispatch.call_hooks(
  193. 'destroy_monitor',
  194. config,
  195. dispatch.Hook_type.MONITORING,
  196. monitoring_log_level,
  197. global_arguments.dry_run,
  198. )
  199. except (OSError, CalledProcessError) as error:
  200. if command.considered_soft_failure(error):
  201. return
  202. encountered_error = error
  203. yield from log_error_records(f'{config_filename}: Error pinging monitor', error)
  204. if encountered_error and using_primary_action:
  205. try:
  206. command.execute_hook(
  207. config.get('on_error'),
  208. config.get('umask'),
  209. config_filename,
  210. 'on-error',
  211. global_arguments.dry_run,
  212. repository=error_repository,
  213. error=encountered_error,
  214. output=getattr(encountered_error, 'output', ''),
  215. )
  216. dispatch.call_hooks(
  217. 'ping_monitor',
  218. config,
  219. dispatch.Hook_type.MONITORING,
  220. config_filename,
  221. monitor.State.FAIL,
  222. monitoring_log_level,
  223. global_arguments.dry_run,
  224. )
  225. dispatch.call_hooks(
  226. 'destroy_monitor',
  227. config,
  228. dispatch.Hook_type.MONITORING,
  229. monitoring_log_level,
  230. global_arguments.dry_run,
  231. )
  232. except (OSError, CalledProcessError) as error:
  233. if command.considered_soft_failure(error):
  234. return
  235. yield from log_error_records(f'{config_filename}: Error running on-error hook', error)
  236. def run_actions(
  237. *,
  238. arguments,
  239. config_filename,
  240. config,
  241. config_paths,
  242. local_path,
  243. remote_path,
  244. local_borg_version,
  245. repository,
  246. ):
  247. '''
  248. Given parsed command-line arguments as an argparse.ArgumentParser instance, the configuration
  249. filename, a configuration dict, a sequence of loaded configuration paths, local and remote paths
  250. to Borg, a local Borg version string, and a repository name, run all actions from the
  251. command-line arguments on the given repository.
  252. Yield JSON output strings from executing any actions that produce JSON.
  253. Raise OSError or subprocess.CalledProcessError if an error occurs running a command for an
  254. action or a hook. Raise ValueError if the arguments or configuration passed to action are
  255. invalid.
  256. '''
  257. add_custom_log_levels()
  258. repository_path = os.path.expanduser(repository['path'])
  259. global_arguments = arguments['global']
  260. dry_run_label = ' (dry run; not making any changes)' if global_arguments.dry_run else ''
  261. hook_context = {
  262. 'repository_label': repository.get('label', ''),
  263. 'log_file': global_arguments.log_file if global_arguments.log_file else '',
  264. # Deprecated: For backwards compatibility with borgmatic < 1.6.0.
  265. 'repositories': ','.join([repo['path'] for repo in config['repositories']]),
  266. 'repository': repository_path,
  267. }
  268. skip_actions = set(get_skip_actions(config, arguments))
  269. command.execute_hook(
  270. config.get('before_actions'),
  271. config.get('umask'),
  272. config_filename,
  273. 'pre-actions',
  274. global_arguments.dry_run,
  275. **hook_context,
  276. )
  277. for action_name, action_arguments in arguments.items():
  278. if action_name == 'repo-create' and action_name not in skip_actions:
  279. borgmatic.actions.repo_create.run_repo_create(
  280. repository,
  281. config,
  282. local_borg_version,
  283. action_arguments,
  284. global_arguments,
  285. local_path,
  286. remote_path,
  287. )
  288. elif action_name == 'transfer' and action_name not in skip_actions:
  289. borgmatic.actions.transfer.run_transfer(
  290. repository,
  291. config,
  292. local_borg_version,
  293. action_arguments,
  294. global_arguments,
  295. local_path,
  296. remote_path,
  297. )
  298. elif action_name == 'create' and action_name not in skip_actions:
  299. yield from borgmatic.actions.create.run_create(
  300. config_filename,
  301. repository,
  302. config,
  303. config_paths,
  304. hook_context,
  305. local_borg_version,
  306. action_arguments,
  307. global_arguments,
  308. dry_run_label,
  309. local_path,
  310. remote_path,
  311. )
  312. elif action_name == 'prune' and action_name not in skip_actions:
  313. borgmatic.actions.prune.run_prune(
  314. config_filename,
  315. repository,
  316. config,
  317. hook_context,
  318. local_borg_version,
  319. action_arguments,
  320. global_arguments,
  321. dry_run_label,
  322. local_path,
  323. remote_path,
  324. )
  325. elif action_name == 'compact' and action_name not in skip_actions:
  326. borgmatic.actions.compact.run_compact(
  327. config_filename,
  328. repository,
  329. config,
  330. hook_context,
  331. local_borg_version,
  332. action_arguments,
  333. global_arguments,
  334. dry_run_label,
  335. local_path,
  336. remote_path,
  337. )
  338. elif action_name == 'check' and action_name not in skip_actions:
  339. if checks.repository_enabled_for_checks(repository, config):
  340. borgmatic.actions.check.run_check(
  341. config_filename,
  342. repository,
  343. config,
  344. hook_context,
  345. local_borg_version,
  346. action_arguments,
  347. global_arguments,
  348. local_path,
  349. remote_path,
  350. )
  351. elif action_name == 'extract' and action_name not in skip_actions:
  352. borgmatic.actions.extract.run_extract(
  353. config_filename,
  354. repository,
  355. config,
  356. hook_context,
  357. local_borg_version,
  358. action_arguments,
  359. global_arguments,
  360. local_path,
  361. remote_path,
  362. )
  363. elif action_name == 'export-tar' and action_name not in skip_actions:
  364. borgmatic.actions.export_tar.run_export_tar(
  365. repository,
  366. config,
  367. local_borg_version,
  368. action_arguments,
  369. global_arguments,
  370. local_path,
  371. remote_path,
  372. )
  373. elif action_name == 'mount' and action_name not in skip_actions:
  374. borgmatic.actions.mount.run_mount(
  375. repository,
  376. config,
  377. local_borg_version,
  378. action_arguments,
  379. global_arguments,
  380. local_path,
  381. remote_path,
  382. )
  383. elif action_name == 'restore' and action_name not in skip_actions:
  384. borgmatic.actions.restore.run_restore(
  385. repository,
  386. config,
  387. local_borg_version,
  388. action_arguments,
  389. global_arguments,
  390. local_path,
  391. remote_path,
  392. )
  393. elif action_name == 'repo-list' and action_name not in skip_actions:
  394. yield from borgmatic.actions.repo_list.run_repo_list(
  395. repository,
  396. config,
  397. local_borg_version,
  398. action_arguments,
  399. global_arguments,
  400. local_path,
  401. remote_path,
  402. )
  403. elif action_name == 'list' and action_name not in skip_actions:
  404. yield from borgmatic.actions.list.run_list(
  405. repository,
  406. config,
  407. local_borg_version,
  408. action_arguments,
  409. global_arguments,
  410. local_path,
  411. remote_path,
  412. )
  413. elif action_name == 'repo-info' and action_name not in skip_actions:
  414. yield from borgmatic.actions.repo_info.run_repo_info(
  415. repository,
  416. config,
  417. local_borg_version,
  418. action_arguments,
  419. global_arguments,
  420. local_path,
  421. remote_path,
  422. )
  423. elif action_name == 'info' and action_name not in skip_actions:
  424. yield from borgmatic.actions.info.run_info(
  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 == 'break-lock' and action_name not in skip_actions:
  434. borgmatic.actions.break_lock.run_break_lock(
  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 == 'export' and action_name not in skip_actions:
  444. borgmatic.actions.export_key.run_export_key(
  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 == 'change-passphrase' and action_name not in skip_actions:
  454. borgmatic.actions.change_passphrase.run_change_passphrase(
  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 == 'delete' and action_name not in skip_actions:
  464. borgmatic.actions.delete.run_delete(
  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 == 'repo-delete' and action_name not in skip_actions:
  474. borgmatic.actions.repo_delete.run_repo_delete(
  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 == 'borg' and action_name not in skip_actions:
  484. borgmatic.actions.borg.run_borg(
  485. repository,
  486. config,
  487. local_borg_version,
  488. action_arguments,
  489. global_arguments,
  490. local_path,
  491. remote_path,
  492. )
  493. command.execute_hook(
  494. config.get('after_actions'),
  495. config.get('umask'),
  496. config_filename,
  497. 'post-actions',
  498. global_arguments.dry_run,
  499. **hook_context,
  500. )
  501. def load_configurations(config_filenames, overrides=None, resolve_env=True):
  502. '''
  503. Given a sequence of configuration filenames, load and validate each configuration file. Return
  504. the results as a tuple of: dict of configuration filename to corresponding parsed configuration,
  505. a sequence of paths for all loaded configuration files (including includes), and a sequence of
  506. logging.LogRecord instances containing any parse errors.
  507. Log records are returned here instead of being logged directly because logging isn't yet
  508. initialized at this point!
  509. '''
  510. # Dict mapping from config filename to corresponding parsed config dict.
  511. configs = collections.OrderedDict()
  512. config_paths = set()
  513. logs = []
  514. # Parse and load each configuration file.
  515. for config_filename in config_filenames:
  516. logs.extend(
  517. [
  518. logging.makeLogRecord(
  519. dict(
  520. levelno=logging.DEBUG,
  521. levelname='DEBUG',
  522. msg=f'{config_filename}: Loading configuration file',
  523. )
  524. ),
  525. ]
  526. )
  527. try:
  528. configs[config_filename], paths, parse_logs = validate.parse_configuration(
  529. config_filename, validate.schema_filename(), overrides, resolve_env
  530. )
  531. config_paths.update(paths)
  532. logs.extend(parse_logs)
  533. except PermissionError:
  534. logs.extend(
  535. [
  536. logging.makeLogRecord(
  537. dict(
  538. levelno=logging.WARNING,
  539. levelname='WARNING',
  540. msg=f'{config_filename}: Insufficient permissions to read configuration file',
  541. )
  542. ),
  543. ]
  544. )
  545. except (ValueError, OSError, validate.Validation_error) as error:
  546. logs.extend(
  547. [
  548. logging.makeLogRecord(
  549. dict(
  550. levelno=logging.CRITICAL,
  551. levelname='CRITICAL',
  552. msg=f'{config_filename}: Error parsing configuration file',
  553. )
  554. ),
  555. logging.makeLogRecord(
  556. dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=str(error))
  557. ),
  558. ]
  559. )
  560. return (configs, sorted(config_paths), logs)
  561. def log_record(suppress_log=False, **kwargs):
  562. '''
  563. Create a log record based on the given makeLogRecord() arguments, one of which must be
  564. named "levelno". Log the record (unless suppress log is set) and return it.
  565. '''
  566. record = logging.makeLogRecord(kwargs)
  567. if suppress_log:
  568. return record
  569. logger.handle(record)
  570. return record
  571. BORG_REPOSITORY_ACCESS_ABORTED_EXIT_CODE = 62
  572. def log_error_records(
  573. message, error=None, levelno=logging.CRITICAL, log_command_error_output=False
  574. ):
  575. '''
  576. Given error message text, an optional exception object, an optional log level, and whether to
  577. log the error output of a CalledProcessError (if any), log error summary information and also
  578. yield it as a series of logging.LogRecord instances.
  579. Note that because the logs are yielded as a generator, logs won't get logged unless you consume
  580. the generator output.
  581. '''
  582. level_name = logging._levelToName[levelno]
  583. if not error:
  584. yield log_record(levelno=levelno, levelname=level_name, msg=str(message))
  585. return
  586. try:
  587. raise error
  588. except CalledProcessError as error:
  589. yield log_record(levelno=levelno, levelname=level_name, msg=str(message))
  590. if error.output:
  591. try:
  592. output = error.output.decode('utf-8')
  593. except (UnicodeDecodeError, AttributeError):
  594. output = error.output
  595. # Suppress these logs for now and save the error output for the log summary at the end.
  596. # Log a separate record per line, as some errors can be really verbose and overflow the
  597. # per-record size limits imposed by some logging backends.
  598. for output_line in output.splitlines():
  599. yield log_record(
  600. levelno=levelno,
  601. levelname=level_name,
  602. msg=output_line,
  603. suppress_log=True,
  604. )
  605. yield log_record(levelno=levelno, levelname=level_name, msg=str(error))
  606. if error.returncode == BORG_REPOSITORY_ACCESS_ABORTED_EXIT_CODE:
  607. yield log_record(
  608. levelno=levelno,
  609. levelname=level_name,
  610. 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.',
  611. )
  612. except (ValueError, OSError) as error:
  613. yield log_record(levelno=levelno, levelname=level_name, msg=str(message))
  614. yield log_record(levelno=levelno, levelname=level_name, msg=str(error))
  615. except: # noqa: E722
  616. # Raising above only as a means of determining the error type. Swallow the exception here
  617. # because we don't want the exception to propagate out of this function.
  618. pass
  619. def get_local_path(configs):
  620. '''
  621. Arbitrarily return the local path from the first configuration dict. Default to "borg" if not
  622. set.
  623. '''
  624. return next(iter(configs.values())).get('local_path', 'borg')
  625. def collect_highlander_action_summary_logs(configs, arguments, configuration_parse_errors):
  626. '''
  627. Given a dict of configuration filename to corresponding parsed configuration, parsed
  628. command-line arguments as a dict from subparser name to a parsed namespace of arguments, and
  629. whether any configuration files encountered errors during parsing, run a highlander action
  630. specified in the arguments, if any, and yield a series of logging.LogRecord instances containing
  631. summary information.
  632. A highlander action is an action that cannot coexist with other actions on the borgmatic
  633. command-line, and borgmatic exits after processing such an action.
  634. '''
  635. add_custom_log_levels()
  636. if 'bootstrap' in arguments:
  637. try:
  638. # No configuration file is needed for bootstrap.
  639. local_borg_version = borg_version.local_borg_version(
  640. {}, arguments['bootstrap'].local_path
  641. )
  642. except (OSError, CalledProcessError, ValueError) as error:
  643. yield from log_error_records('Error getting local Borg version', error)
  644. return
  645. try:
  646. borgmatic.actions.config.bootstrap.run_bootstrap(
  647. arguments['bootstrap'], arguments['global'], local_borg_version
  648. )
  649. yield logging.makeLogRecord(
  650. dict(
  651. levelno=logging.ANSWER,
  652. levelname='ANSWER',
  653. msg='Bootstrap successful',
  654. )
  655. )
  656. except (
  657. CalledProcessError,
  658. ValueError,
  659. OSError,
  660. ) as error:
  661. yield from log_error_records(error)
  662. return
  663. if 'generate' in arguments:
  664. try:
  665. borgmatic.actions.config.generate.run_generate(
  666. arguments['generate'], arguments['global']
  667. )
  668. yield logging.makeLogRecord(
  669. dict(
  670. levelno=logging.ANSWER,
  671. levelname='ANSWER',
  672. msg='Generate successful',
  673. )
  674. )
  675. except (
  676. CalledProcessError,
  677. ValueError,
  678. OSError,
  679. ) as error:
  680. yield from log_error_records(error)
  681. return
  682. if 'validate' in arguments:
  683. if configuration_parse_errors:
  684. yield logging.makeLogRecord(
  685. dict(
  686. levelno=logging.CRITICAL,
  687. levelname='CRITICAL',
  688. msg='Configuration validation failed',
  689. )
  690. )
  691. return
  692. try:
  693. borgmatic.actions.config.validate.run_validate(arguments['validate'], configs)
  694. yield logging.makeLogRecord(
  695. dict(
  696. levelno=logging.ANSWER,
  697. levelname='ANSWER',
  698. msg='All configuration files are valid',
  699. )
  700. )
  701. except (
  702. CalledProcessError,
  703. ValueError,
  704. OSError,
  705. ) as error:
  706. yield from log_error_records(error)
  707. return
  708. def collect_configuration_run_summary_logs(configs, config_paths, arguments):
  709. '''
  710. Given a dict of configuration filename to corresponding parsed configuration, a sequence of
  711. loaded configuration paths, and parsed command-line arguments as a dict from subparser name to a
  712. parsed namespace of arguments, run each configuration file and yield a series of
  713. logging.LogRecord instances containing summary information about each run.
  714. As a side effect of running through these configuration files, output their JSON results, if
  715. any, to stdout.
  716. '''
  717. # Run cross-file validation checks.
  718. repository = None
  719. for action_name, action_arguments in arguments.items():
  720. if hasattr(action_arguments, 'repository'):
  721. repository = getattr(action_arguments, 'repository')
  722. break
  723. try:
  724. validate.guard_configuration_contains_repository(repository, configs)
  725. except ValueError as error:
  726. yield from log_error_records(str(error))
  727. return
  728. if not configs:
  729. yield from log_error_records(
  730. f"{' '.join(arguments['global'].config_paths)}: No valid configuration files found",
  731. )
  732. return
  733. if 'create' in arguments:
  734. try:
  735. for config_filename, config in configs.items():
  736. command.execute_hook(
  737. config.get('before_everything'),
  738. config.get('umask'),
  739. config_filename,
  740. 'pre-everything',
  741. arguments['global'].dry_run,
  742. )
  743. except (CalledProcessError, ValueError, OSError) as error:
  744. yield from log_error_records('Error running pre-everything hook', error)
  745. return
  746. # Execute the actions corresponding to each configuration file.
  747. json_results = []
  748. for config_filename, config in configs.items():
  749. with Log_prefix(config_filename):
  750. results = list(run_configuration(config_filename, config, config_paths, arguments))
  751. error_logs = tuple(
  752. result for result in results if isinstance(result, logging.LogRecord)
  753. )
  754. if error_logs:
  755. yield from log_error_records('An error occurred')
  756. yield from error_logs
  757. else:
  758. yield logging.makeLogRecord(
  759. dict(
  760. levelno=logging.INFO,
  761. levelname='INFO',
  762. msg='Successfully ran configuration file',
  763. )
  764. )
  765. if results:
  766. json_results.extend(results)
  767. if 'umount' in arguments:
  768. logger.info(f"Unmounting mount point {arguments['umount'].mount_point}")
  769. try:
  770. borg_umount.unmount_archive(
  771. config,
  772. mount_point=arguments['umount'].mount_point,
  773. local_path=get_local_path(configs),
  774. )
  775. except (CalledProcessError, OSError) as error:
  776. yield from log_error_records('Error unmounting mount point', error)
  777. if json_results:
  778. sys.stdout.write(json.dumps(json_results))
  779. if 'create' in arguments:
  780. try:
  781. for config_filename, config in configs.items():
  782. command.execute_hook(
  783. config.get('after_everything'),
  784. config.get('umask'),
  785. config_filename,
  786. 'post-everything',
  787. arguments['global'].dry_run,
  788. )
  789. except (CalledProcessError, ValueError, OSError) as error:
  790. yield from log_error_records('Error running post-everything hook', error)
  791. def exit_with_help_link(): # pragma: no cover
  792. '''
  793. Display a link to get help and exit with an error code.
  794. '''
  795. logger.critical('')
  796. logger.critical('Need some help? https://torsion.org/borgmatic/#issues')
  797. sys.exit(1)
  798. def main(extra_summary_logs=[]): # pragma: no cover
  799. configure_signals()
  800. configure_delayed_logging()
  801. try:
  802. arguments = parse_arguments(*sys.argv[1:])
  803. except ValueError as error:
  804. configure_logging(logging.CRITICAL)
  805. logger.critical(error)
  806. exit_with_help_link()
  807. except SystemExit as error:
  808. if error.code == 0:
  809. raise error
  810. configure_logging(logging.CRITICAL)
  811. logger.critical(f"Error parsing arguments: {' '.join(sys.argv)}")
  812. exit_with_help_link()
  813. global_arguments = arguments['global']
  814. if global_arguments.version:
  815. print(importlib.metadata.version('borgmatic'))
  816. sys.exit(0)
  817. if global_arguments.bash_completion:
  818. print(borgmatic.commands.completion.bash.bash_completion())
  819. sys.exit(0)
  820. if global_arguments.fish_completion:
  821. print(borgmatic.commands.completion.fish.fish_completion())
  822. sys.exit(0)
  823. config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths))
  824. configs, config_paths, parse_logs = load_configurations(
  825. config_filenames, global_arguments.overrides, global_arguments.resolve_env
  826. )
  827. configuration_parse_errors = (
  828. (max(log.levelno for log in parse_logs) >= logging.CRITICAL) if parse_logs else False
  829. )
  830. any_json_flags = any(
  831. getattr(sub_arguments, 'json', False) for sub_arguments in arguments.values()
  832. )
  833. color_enabled = should_do_markup(global_arguments.no_color or any_json_flags, configs)
  834. try:
  835. configure_logging(
  836. verbosity_to_log_level(global_arguments.verbosity),
  837. verbosity_to_log_level(global_arguments.syslog_verbosity),
  838. verbosity_to_log_level(global_arguments.log_file_verbosity),
  839. verbosity_to_log_level(global_arguments.monitoring_verbosity),
  840. global_arguments.log_file,
  841. global_arguments.log_file_format,
  842. color_enabled=color_enabled,
  843. )
  844. except (FileNotFoundError, PermissionError) as error:
  845. configure_logging(logging.CRITICAL)
  846. logger.critical(f'Error configuring logging: {error}')
  847. exit_with_help_link()
  848. summary_logs = (
  849. extra_summary_logs
  850. + parse_logs
  851. + (
  852. list(
  853. collect_highlander_action_summary_logs(
  854. configs, arguments, configuration_parse_errors
  855. )
  856. )
  857. or list(collect_configuration_run_summary_logs(configs, config_paths, arguments))
  858. )
  859. )
  860. summary_logs_max_level = max(log.levelno for log in summary_logs)
  861. for message in ('', 'summary:'):
  862. log_record(
  863. levelno=summary_logs_max_level,
  864. levelname=logging.getLevelName(summary_logs_max_level),
  865. msg=message,
  866. )
  867. for log in summary_logs:
  868. logger.handle(log)
  869. if summary_logs_max_level >= logging.CRITICAL:
  870. exit_with_help_link()