borgmatic.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  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. '--stats',
  104. dest='stats',
  105. default=False,
  106. action='store_true',
  107. help='Display statistics of archive with --create or --prune option',
  108. )
  109. parser.add_argument(
  110. '--json',
  111. dest='json',
  112. default=False,
  113. action='store_true',
  114. help='Output results from the --create, --list, or --info options as json',
  115. )
  116. parser.add_argument(
  117. '-n',
  118. '--dry-run',
  119. dest='dry_run',
  120. action='store_true',
  121. help='Go through the motions, but do not actually write to any repositories',
  122. )
  123. parser.add_argument(
  124. '-v',
  125. '--verbosity',
  126. type=int,
  127. choices=range(0, 3),
  128. default=0,
  129. help='Display verbose progress (1 for some, 2 for lots)',
  130. )
  131. args = parser.parse_args(arguments)
  132. if args.excludes_filename:
  133. raise ValueError(
  134. 'The --excludes option has been replaced with exclude_patterns in configuration'
  135. )
  136. if (args.encryption_mode or args.append_only or args.storage_quota) and not args.init:
  137. raise ValueError(
  138. 'The --encryption, --append-only, and --storage-quota options can only be used with the --init option'
  139. )
  140. if args.init and args.dry_run:
  141. raise ValueError('The --init option cannot be used with the --dry-run option')
  142. if args.init and not args.encryption_mode:
  143. raise ValueError('The --encryption option is required with the --init option')
  144. if args.progress and not args.create:
  145. raise ValueError('The --progress option can only be used with the --create option')
  146. if args.stats and not (args.create or args.prune):
  147. raise ValueError('The --stats option can only be used with the --create or --prune options')
  148. if args.json and not (args.create or args.list or args.info):
  149. raise ValueError(
  150. 'The --json option can only be used with the --create, --list, or --info options'
  151. )
  152. if args.json and args.list and args.info:
  153. raise ValueError(
  154. 'With the --json option, options --list and --info cannot be used together'
  155. )
  156. # If any of the action flags are explicitly requested, leave them as-is. Otherwise, assume
  157. # defaults: Mutate the given arguments to enable the default actions.
  158. if args.init or args.prune or args.create or args.check or args.list or args.info:
  159. return args
  160. args.prune = True
  161. args.create = True
  162. args.check = True
  163. return args
  164. def run_configuration(config_filename, args): # pragma: no cover
  165. '''
  166. Parse a single configuration file, and execute its defined pruning, backups, and/or consistency
  167. checks.
  168. '''
  169. logger.info('{}: Parsing configuration file'.format(config_filename))
  170. config = validate.parse_configuration(config_filename, validate.schema_filename())
  171. (location, storage, retention, consistency, hooks) = (
  172. config.get(section_name, {})
  173. for section_name in ('location', 'storage', 'retention', 'consistency', 'hooks')
  174. )
  175. try:
  176. local_path = location.get('local_path', 'borg')
  177. remote_path = location.get('remote_path')
  178. borg_environment.initialize(storage)
  179. if args.create:
  180. hook.execute_hook(hooks.get('before_backup'), config_filename, 'pre-backup')
  181. _run_commands(
  182. args=args,
  183. consistency=consistency,
  184. local_path=local_path,
  185. location=location,
  186. remote_path=remote_path,
  187. retention=retention,
  188. storage=storage,
  189. )
  190. if args.create:
  191. hook.execute_hook(hooks.get('after_backup'), config_filename, 'post-backup')
  192. except (OSError, CalledProcessError):
  193. hook.execute_hook(hooks.get('on_error'), config_filename, 'on-error')
  194. raise
  195. def _run_commands(*, args, consistency, local_path, location, remote_path, retention, storage):
  196. json_results = []
  197. for unexpanded_repository in location['repositories']:
  198. _run_commands_on_repository(
  199. args=args,
  200. consistency=consistency,
  201. json_results=json_results,
  202. local_path=local_path,
  203. location=location,
  204. remote_path=remote_path,
  205. retention=retention,
  206. storage=storage,
  207. unexpanded_repository=unexpanded_repository,
  208. )
  209. if args.json:
  210. sys.stdout.write(json.dumps(json_results))
  211. def _run_commands_on_repository(
  212. *,
  213. args,
  214. consistency,
  215. json_results,
  216. local_path,
  217. location,
  218. remote_path,
  219. retention,
  220. storage,
  221. unexpanded_repository
  222. ): # pragma: no cover
  223. repository = os.path.expanduser(unexpanded_repository)
  224. dry_run_label = ' (dry run; not making any changes)' if args.dry_run else ''
  225. if args.init:
  226. logger.info('{}: Initializing repository'.format(repository))
  227. borg_init.initialize_repository(
  228. repository,
  229. args.encryption_mode,
  230. args.append_only,
  231. args.storage_quota,
  232. local_path=local_path,
  233. remote_path=remote_path,
  234. )
  235. if args.prune:
  236. logger.info('{}: Pruning archives{}'.format(repository, dry_run_label))
  237. borg_prune.prune_archives(
  238. args.dry_run,
  239. repository,
  240. storage,
  241. retention,
  242. local_path=local_path,
  243. remote_path=remote_path,
  244. stats=args.stats,
  245. )
  246. if args.create:
  247. logger.info('{}: Creating archive{}'.format(repository, dry_run_label))
  248. borg_create.create_archive(
  249. args.dry_run,
  250. repository,
  251. location,
  252. storage,
  253. local_path=local_path,
  254. remote_path=remote_path,
  255. progress=args.progress,
  256. stats=args.stats,
  257. )
  258. if args.check and checks.repository_enabled_for_checks(repository, consistency):
  259. logger.info('{}: Running consistency checks'.format(repository))
  260. borg_check.check_archives(
  261. repository, storage, consistency, local_path=local_path, remote_path=remote_path
  262. )
  263. if args.list:
  264. logger.info('{}: Listing archives'.format(repository))
  265. output = borg_list.list_archives(
  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. if args.info:
  273. logger.info('{}: Displaying summary info for archives'.format(repository))
  274. output = borg_info.display_archives_info(
  275. repository, storage, local_path=local_path, remote_path=remote_path, json=args.json
  276. )
  277. if args.json:
  278. json_results.append(json.loads(output))
  279. else:
  280. sys.stdout.write(output)
  281. def main(): # pragma: no cover
  282. try:
  283. configure_signals()
  284. args = parse_arguments(*sys.argv[1:])
  285. logging.basicConfig(level=verbosity_to_log_level(args.verbosity), format='%(message)s')
  286. config_filenames = tuple(collect.collect_config_filenames(args.config_paths))
  287. logger.debug('Ensuring legacy configuration is upgraded')
  288. convert.guard_configuration_upgraded(LEGACY_CONFIG_PATH, config_filenames)
  289. if len(config_filenames) == 0:
  290. raise ValueError(
  291. 'Error: No configuration files found in: {}'.format(' '.join(args.config_paths))
  292. )
  293. for config_filename in config_filenames:
  294. run_configuration(config_filename, args)
  295. except (ValueError, OSError, CalledProcessError) as error:
  296. print(error, file=sys.stderr)
  297. print(file=sys.stderr)
  298. print('Need some help? https://torsion.org/borgmatic/#issues', file=sys.stderr)
  299. sys.exit(1)