A wrapper script for Borg backup software that creates and prunes backups
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

borgmatic.py 11KB

  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 (
  8. check as borg_check,
  9. create as borg_create,
  10. environment as borg_environment,
  11. prune as borg_prune,
  12. list as borg_list,
  13. info as borg_info,
  14. init as borg_init,
  15. )
  16. from borgmatic.commands import hook
  17. from borgmatic.config import checks, collect, convert, validate
  18. from borgmatic.signals import configure_signals
  19. from borgmatic.verbosity import verbosity_to_log_level
  20. logger = logging.getLogger(__name__)
  21. LEGACY_CONFIG_PATH = '/etc/borgmatic/config'
  22. def parse_arguments(*arguments):
  23. '''
  24. Given command-line arguments with which this script was invoked, parse the arguments and return
  25. them as an ArgumentParser instance.
  26. '''
  27. config_paths = collect.get_default_config_paths()
  28. parser = ArgumentParser(
  29. description='''
  30. A simple wrapper script for the Borg backup software that creates and prunes backups.
  31. If none of the --prune, --create, or --check options are given, then borgmatic defaults
  32. to all three: prune, create, and check archives.
  33. '''
  34. )
  35. parser.add_argument(
  36. '-c',
  37. '--config',
  38. nargs='+',
  39. dest='config_paths',
  40. default=config_paths,
  41. help='Configuration filenames or directories, defaults to: {}'.format(
  42. ' '.join(config_paths)
  43. ),
  44. )
  45. parser.add_argument(
  46. '--excludes',
  47. dest='excludes_filename',
  48. help='Deprecated in favor of exclude_patterns within configuration',
  49. )
  50. parser.add_argument(
  51. '-I', '--init', dest='init', action='store_true', help='Initialize an empty Borg repository'
  52. )
  53. parser.add_argument(
  54. '-e',
  55. '--encryption',
  56. dest='encryption_mode',
  57. help='Borg repository encryption mode (for use with --init)',
  58. )
  59. parser.add_argument(
  60. '--append-only',
  61. dest='append_only',
  62. action='store_true',
  63. help='Create an append-only repository (for use with --init)',
  64. )
  65. parser.add_argument(
  66. '--storage-quota',
  67. dest='storage_quota',
  68. help='Create a repository with a fixed storage quota (for use with --init)',
  69. )
  70. parser.add_argument(
  71. '-p',
  72. '--prune',
  73. dest='prune',
  74. action='store_true',
  75. help='Prune archives according to the retention policy',
  76. )
  77. parser.add_argument(
  78. '-C',
  79. '--create',
  80. dest='create',
  81. action='store_true',
  82. help='Create archives (actually perform backups)',
  83. )
  84. parser.add_argument(
  85. '-k', '--check', dest='check', action='store_true', help='Check archives for consistency'
  86. )
  87. parser.add_argument('-l', '--list', dest='list', action='store_true', help='List archives')
  88. parser.add_argument(
  89. '-i',
  90. '--info',
  91. dest='info',
  92. action='store_true',
  93. help='Display summary information on archives',
  94. )
  95. parser.add_argument(
  96. '--progress',
  97. dest='progress',
  98. default=False,
  99. action='store_true',
  100. help='Display progress with --create option for each file as it is backed up',
  101. )
  102. parser.add_argument(
  103. '--json',
  104. dest='json',
  105. default=False,
  106. action='store_true',
  107. help='Output results from the --create, --list, or --info options as json',
  108. )
  109. parser.add_argument(
  110. '-n',
  111. '--dry-run',
  112. dest='dry_run',
  113. action='store_true',
  114. help='Go through the motions, but do not actually write to any repositories',
  115. )
  116. parser.add_argument(
  117. '-v',
  118. '--verbosity',
  119. type=int,
  120. choices=range(0, 3),
  121. default=0,
  122. help='Display verbose progress (1 for some, 2 for lots)',
  123. )
  124. args = parser.parse_args(arguments)
  125. if args.excludes_filename:
  126. raise ValueError(
  127. 'The --excludes option has been replaced with exclude_patterns in configuration'
  128. )
  129. if (args.encryption_mode or args.append_only or args.storage_quota) and not args.init:
  130. raise ValueError(
  131. 'The --encryption, --append-only, and --storage-quota options can only be used with the --init option'
  132. )
  133. if args.init and (args.prune or args.create or args.dry_run):
  134. raise ValueError(
  135. 'The --init option cannot be used with the --prune, --create, or --dry-run options'
  136. )
  137. if args.init and not args.encryption_mode:
  138. raise ValueError('The --encryption option is required with the --init option')
  139. if args.progress and not args.create:
  140. raise ValueError('The --progress option can only be used with the --create option')
  141. if args.json and not (args.create or args.list or args.info):
  142. raise ValueError(
  143. 'The --json option can only be used with the --create, --list, or --info options'
  144. )
  145. if args.json and args.list and args.info:
  146. raise ValueError(
  147. 'With the --json option, options --list and --info cannot be used together'
  148. )
  149. # If any of the action flags are explicitly requested, leave them as-is. Otherwise, assume
  150. # defaults: Mutate the given arguments to enable the default actions.
  151. if args.init or args.prune or args.create or args.check or args.list or args.info:
  152. return args
  153. args.prune = True
  154. args.create = True
  155. args.check = True
  156. return args
  157. def run_configuration(config_filename, args): # pragma: no cover
  158. '''
  159. Parse a single configuration file, and execute its defined pruning, backups, and/or consistency
  160. checks.
  161. '''
  162. logger.info('{}: Parsing configuration file'.format(config_filename))
  163. config = validate.parse_configuration(config_filename, validate.schema_filename())
  164. (location, storage, retention, consistency, hooks) = (
  165. config.get(section_name, {})
  166. for section_name in ('location', 'storage', 'retention', 'consistency', 'hooks')
  167. )
  168. try:
  169. local_path = location.get('local_path', 'borg')
  170. remote_path = location.get('remote_path')
  171. borg_environment.initialize(storage)
  172. if args.create:
  173. hook.execute_hook(hooks.get('before_backup'), config_filename, 'pre-backup')
  174. _run_commands(
  175. args=args,
  176. consistency=consistency,
  177. local_path=local_path,
  178. location=location,
  179. remote_path=remote_path,
  180. retention=retention,
  181. storage=storage,
  182. )
  183. if args.create:
  184. hook.execute_hook(hooks.get('after_backup'), config_filename, 'post-backup')
  185. except (OSError, CalledProcessError):
  186. hook.execute_hook(hooks.get('on_error'), config_filename, 'on-error')
  187. raise
  188. def _run_commands(*, args, consistency, local_path, location, remote_path, retention, storage):
  189. json_results = []
  190. for unexpanded_repository in location['repositories']:
  191. _run_commands_on_repository(
  192. args=args,
  193. consistency=consistency,
  194. json_results=json_results,
  195. local_path=local_path,
  196. location=location,
  197. remote_path=remote_path,
  198. retention=retention,
  199. storage=storage,
  200. unexpanded_repository=unexpanded_repository,
  201. )
  202. if args.json:
  203. sys.stdout.write(json.dumps(json_results))
  204. def _run_commands_on_repository(
  205. *,
  206. args,
  207. consistency,
  208. json_results,
  209. local_path,
  210. location,
  211. remote_path,
  212. retention,
  213. storage,
  214. unexpanded_repository
  215. ): # pragma: no cover
  216. repository = os.path.expanduser(unexpanded_repository)
  217. dry_run_label = ' (dry run; not making any changes)' if args.dry_run else ''
  218. if args.init:
  219. logger.info('{}: Initializing repository'.format(repository))
  220. borg_init.initialize_repository(
  221. repository,
  222. args.encryption_mode,
  223. args.append_only,
  224. args.storage_quota,
  225. local_path=local_path,
  226. remote_path=remote_path,
  227. )
  228. if args.prune:
  229. logger.info('{}: Pruning archives{}'.format(repository, dry_run_label))
  230. borg_prune.prune_archives(
  231. args.dry_run,
  232. repository,
  233. storage,
  234. retention,
  235. local_path=local_path,
  236. remote_path=remote_path,
  237. )
  238. if args.create:
  239. logger.info('{}: Creating archive{}'.format(repository, dry_run_label))
  240. borg_create.create_archive(
  241. args.dry_run,
  242. repository,
  243. location,
  244. storage,
  245. local_path=local_path,
  246. remote_path=remote_path,
  247. progress=args.progress,
  248. )
  249. if args.check and checks.repository_enabled_for_checks(repository, consistency):
  250. logger.info('{}: Running consistency checks'.format(repository))
  251. borg_check.check_archives(
  252. repository, storage, consistency, local_path=local_path, remote_path=remote_path
  253. )
  254. if args.list:
  255. logger.info('{}: Listing archives'.format(repository))
  256. output = borg_list.list_archives(
  257. repository, storage, local_path=local_path, remote_path=remote_path, json=args.json
  258. )
  259. if args.json:
  260. json_results.append(json.loads(output))
  261. else:
  262. sys.stdout.write(output)
  263. if args.info:
  264. logger.info('{}: Displaying summary info for archives'.format(repository))
  265. output = borg_info.display_archives_info(
  266. repository, storage, local_path=local_path, remote_path=remote_path, json=args.json
  267. )
  268. if args.json:
  269. json_results.append(json.loads(output))
  270. else:
  271. sys.stdout.write(output)
  272. def main(): # pragma: no cover
  273. try:
  274. configure_signals()
  275. args = parse_arguments(*sys.argv[1:])
  276. logging.basicConfig(level=verbosity_to_log_level(args.verbosity), format='%(message)s')
  277. config_filenames = tuple(collect.collect_config_filenames(args.config_paths))
  278. logger.debug('Ensuring legacy configuration is upgraded')
  279. convert.guard_configuration_upgraded(LEGACY_CONFIG_PATH, config_filenames)
  280. if len(config_filenames) == 0:
  281. raise ValueError(
  282. 'Error: No configuration files found in: {}'.format(' '.join(args.config_paths))
  283. )
  284. for config_filename in config_filenames:
  285. run_configuration(config_filename, args)
  286. except (ValueError, OSError, CalledProcessError) as error:
  287. print(error, file=sys.stderr)
  288. print(file=sys.stderr)
  289. print('Need some help? https://torsion.org/borgmatic/#issues', file=sys.stderr)
  290. sys.exit(1)