borgmatic.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. from argparse import ArgumentParser
  2. import json
  3. import logging
  4. import os
  5. from subprocess import CalledProcessError
  6. import sys
  7. from borgmatic.borg import check as borg_check, create as borg_create, prune as borg_prune, \
  8. list as borg_list, info as borg_info
  9. from borgmatic.commands import hook
  10. from borgmatic.config import collect, convert, validate
  11. from borgmatic.signals import configure_signals
  12. from borgmatic.verbosity import verbosity_to_log_level
  13. logger = logging.getLogger(__name__)
  14. LEGACY_CONFIG_PATH = '/etc/borgmatic/config'
  15. def parse_arguments(*arguments):
  16. '''
  17. Given command-line arguments with which this script was invoked, parse the arguments and return
  18. them as an ArgumentParser instance.
  19. '''
  20. config_paths = collect.get_default_config_paths()
  21. parser = ArgumentParser(
  22. description=
  23. '''
  24. A simple wrapper script for the Borg backup software that creates and prunes backups.
  25. If none of the --prune, --create, or --check options are given, then borgmatic defaults
  26. to all three: prune, create, and check archives.
  27. '''
  28. )
  29. parser.add_argument(
  30. '-c', '--config',
  31. nargs='+',
  32. dest='config_paths',
  33. default=config_paths,
  34. help='Configuration filenames or directories, defaults to: {}'.format(' '.join(config_paths)),
  35. )
  36. parser.add_argument(
  37. '--excludes',
  38. dest='excludes_filename',
  39. help='Deprecated in favor of exclude_patterns within configuration',
  40. )
  41. parser.add_argument(
  42. '-p', '--prune',
  43. dest='prune',
  44. action='store_true',
  45. help='Prune archives according to the retention policy',
  46. )
  47. parser.add_argument(
  48. '-C', '--create',
  49. dest='create',
  50. action='store_true',
  51. help='Create archives (actually perform backups)',
  52. )
  53. parser.add_argument(
  54. '-k', '--check',
  55. dest='check',
  56. action='store_true',
  57. help='Check archives for consistency',
  58. )
  59. parser.add_argument(
  60. '-l', '--list',
  61. dest='list',
  62. action='store_true',
  63. help='List archives',
  64. )
  65. parser.add_argument(
  66. '-i', '--info',
  67. dest='info',
  68. action='store_true',
  69. help='Display summary information on archives',
  70. )
  71. parser.add_argument(
  72. '--json',
  73. dest='json',
  74. default=False,
  75. action='store_true',
  76. help='Output results from the --create, --list, or --info options as json',
  77. )
  78. parser.add_argument(
  79. '-n', '--dry-run',
  80. dest='dry_run',
  81. action='store_true',
  82. help='Go through the motions, but do not actually write to any repositories',
  83. )
  84. parser.add_argument(
  85. '-v', '--verbosity',
  86. type=int,
  87. choices=range(0,3),
  88. default=0,
  89. help='Display verbose progress (1 for some, 2 for lots)',
  90. )
  91. args = parser.parse_args(arguments)
  92. if args.json and not (args.create or args.list or args.info):
  93. raise ValueError('The --json option can only be used with the --create, --list, or --info options')
  94. if args.json and args.list and args.info:
  95. raise ValueError(
  96. 'With the --json option, options --list and --info cannot be used together'
  97. )
  98. # If any of the action flags are explicitly requested, leave them as-is. Otherwise, assume
  99. # defaults: Mutate the given arguments to enable the default actions.
  100. if args.prune or args.create or args.check or args.list or args.info:
  101. return args
  102. args.prune = True
  103. args.create = True
  104. args.check = True
  105. return args
  106. def run_configuration(config_filename, args): # pragma: no cover
  107. '''
  108. Parse a single configuration file, and execute its defined pruning, backups, and/or consistency
  109. checks.
  110. '''
  111. logger.info('{}: Parsing configuration file'.format(config_filename))
  112. config = validate.parse_configuration(config_filename, validate.schema_filename())
  113. (location, storage, retention, consistency, hooks) = (
  114. config.get(section_name, {})
  115. for section_name in ('location', 'storage', 'retention', 'consistency', 'hooks')
  116. )
  117. try:
  118. local_path = location.get('local_path', 'borg')
  119. remote_path = location.get('remote_path')
  120. borg_create.initialize_environment(storage)
  121. if args.create:
  122. hook.execute_hook(hooks.get('before_backup'), config_filename, 'pre-backup')
  123. _run_commands(args, consistency, local_path, location, remote_path, retention, storage)
  124. if args.create:
  125. hook.execute_hook(hooks.get('after_backup'), config_filename, 'post-backup')
  126. except (OSError, CalledProcessError):
  127. hook.execute_hook(hooks.get('on_error'), config_filename, 'on-error')
  128. raise
  129. def _run_commands(args, consistency, local_path, location, remote_path, retention, storage):
  130. json_results = []
  131. for unexpanded_repository in location['repositories']:
  132. _run_commands_on_repository(
  133. args, consistency, json_results, local_path, location, remote_path, retention, storage,
  134. unexpanded_repository,
  135. )
  136. if args.json:
  137. sys.stdout.write(json.dumps(json_results))
  138. def _run_commands_on_repository(
  139. args, consistency, json_results, local_path, location, remote_path,
  140. retention, storage, unexpanded_repository,
  141. ): # pragma: no cover
  142. repository = os.path.expanduser(unexpanded_repository)
  143. dry_run_label = ' (dry run; not making any changes)' if args.dry_run else ''
  144. if args.prune:
  145. logger.info('{}: Pruning archives{}'.format(repository, dry_run_label))
  146. borg_prune.prune_archives(
  147. args.dry_run,
  148. repository,
  149. storage,
  150. retention,
  151. local_path=local_path,
  152. remote_path=remote_path,
  153. )
  154. if args.create:
  155. logger.info('{}: Creating archive{}'.format(repository, dry_run_label))
  156. borg_create.create_archive(
  157. args.dry_run,
  158. repository,
  159. location,
  160. storage,
  161. local_path=local_path,
  162. remote_path=remote_path,
  163. )
  164. if args.check:
  165. logger.info('{}: Running consistency checks'.format(repository))
  166. borg_check.check_archives(
  167. repository,
  168. storage,
  169. consistency,
  170. local_path=local_path,
  171. remote_path=remote_path
  172. )
  173. if args.list:
  174. logger.info('{}: Listing archives'.format(repository))
  175. output = borg_list.list_archives(
  176. repository,
  177. storage,
  178. local_path=local_path,
  179. remote_path=remote_path,
  180. json=args.json,
  181. )
  182. if args.json:
  183. json_results.append(json.loads(output))
  184. else:
  185. sys.stdout.write(output)
  186. if args.info:
  187. logger.info('{}: Displaying summary info for archives'.format(repository))
  188. output = borg_info.display_archives_info(
  189. repository,
  190. storage,
  191. local_path=local_path,
  192. remote_path=remote_path,
  193. json=args.json,
  194. )
  195. if args.json:
  196. json_results.append(json.loads(output))
  197. else:
  198. sys.stdout.write(output)
  199. def main(): # pragma: no cover
  200. try:
  201. configure_signals()
  202. args = parse_arguments(*sys.argv[1:])
  203. logging.basicConfig(level=verbosity_to_log_level(args.verbosity), format='%(message)s')
  204. config_filenames = tuple(collect.collect_config_filenames(args.config_paths))
  205. logger.debug('Ensuring legacy configuration is upgraded')
  206. convert.guard_configuration_upgraded(LEGACY_CONFIG_PATH, config_filenames)
  207. if len(config_filenames) == 0:
  208. raise ValueError('Error: No configuration files found in: {}'.format(' '.join(args.config_paths)))
  209. for config_filename in config_filenames:
  210. run_configuration(config_filename, args)
  211. except (ValueError, OSError, CalledProcessError) as error:
  212. print(error, file=sys.stderr)
  213. sys.exit(1)