borgmatic.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. import collections
  2. import json
  3. import logging
  4. import os
  5. import sys
  6. from argparse import ArgumentParser
  7. from subprocess import CalledProcessError
  8. import colorama
  9. import pkg_resources
  10. from borgmatic.borg import check as borg_check
  11. from borgmatic.borg import create as borg_create
  12. from borgmatic.borg import environment as borg_environment
  13. from borgmatic.borg import extract as borg_extract
  14. from borgmatic.borg import info as borg_info
  15. from borgmatic.borg import init as borg_init
  16. from borgmatic.borg import list as borg_list
  17. from borgmatic.borg import prune as borg_prune
  18. from borgmatic.commands import hook
  19. from borgmatic.config import checks, collect, convert, validate
  20. from borgmatic.logger import get_logger, should_do_markup
  21. from borgmatic.signals import configure_signals
  22. from borgmatic.verbosity import verbosity_to_log_level
  23. logger = get_logger(__name__)
  24. LEGACY_CONFIG_PATH = '/etc/borgmatic/config'
  25. def parse_arguments(*arguments):
  26. '''
  27. Given command-line arguments with which this script was invoked, parse the arguments and return
  28. them as an argparse.ArgumentParser instance.
  29. '''
  30. config_paths = collect.get_default_config_paths()
  31. parser = ArgumentParser(
  32. description='''
  33. A simple wrapper script for the Borg backup software that creates and prunes backups.
  34. If none of the action options are given, then borgmatic defaults to: prune, create, and
  35. check archives.
  36. ''',
  37. add_help=False,
  38. )
  39. actions_group = parser.add_argument_group('actions')
  40. actions_group.add_argument(
  41. '-I', '--init', dest='init', action='store_true', help='Initialize an empty Borg repository'
  42. )
  43. actions_group.add_argument(
  44. '-p',
  45. '--prune',
  46. dest='prune',
  47. action='store_true',
  48. help='Prune archives according to the retention policy',
  49. )
  50. actions_group.add_argument(
  51. '-C',
  52. '--create',
  53. dest='create',
  54. action='store_true',
  55. help='Create archives (actually perform backups)',
  56. )
  57. actions_group.add_argument(
  58. '-k', '--check', dest='check', action='store_true', help='Check archives for consistency'
  59. )
  60. actions_group.add_argument(
  61. '-x',
  62. '--extract',
  63. dest='extract',
  64. action='store_true',
  65. help='Extract a named archive to the current directory',
  66. )
  67. actions_group.add_argument(
  68. '-l', '--list', dest='list', action='store_true', help='List archives'
  69. )
  70. actions_group.add_argument(
  71. '-i',
  72. '--info',
  73. dest='info',
  74. action='store_true',
  75. help='Display summary information on archives',
  76. )
  77. init_group = parser.add_argument_group('options for --init')
  78. init_group.add_argument(
  79. '-e', '--encryption', dest='encryption_mode', help='Borg repository encryption mode'
  80. )
  81. init_group.add_argument(
  82. '--append-only',
  83. dest='append_only',
  84. action='store_true',
  85. help='Create an append-only repository',
  86. )
  87. init_group.add_argument(
  88. '--storage-quota',
  89. dest='storage_quota',
  90. help='Create a repository with a fixed storage quota',
  91. )
  92. prune_group = parser.add_argument_group('options for --prune')
  93. stats_argument = prune_group.add_argument(
  94. '--stats',
  95. dest='stats',
  96. default=False,
  97. action='store_true',
  98. help='Display statistics of archive',
  99. )
  100. create_group = parser.add_argument_group('options for --create')
  101. progress_argument = create_group.add_argument(
  102. '--progress',
  103. dest='progress',
  104. default=False,
  105. action='store_true',
  106. help='Display progress for each file as it is processed',
  107. )
  108. create_group._group_actions.append(stats_argument)
  109. json_argument = create_group.add_argument(
  110. '--json', dest='json', default=False, action='store_true', help='Output results as JSON'
  111. )
  112. extract_group = parser.add_argument_group('options for --extract')
  113. repository_argument = extract_group.add_argument(
  114. '--repository',
  115. help='Path of repository to use, defaults to the configured repository if there is only one',
  116. )
  117. archive_argument = extract_group.add_argument('--archive', help='Name of archive to operate on')
  118. extract_group.add_argument(
  119. '--restore-path',
  120. nargs='+',
  121. dest='restore_paths',
  122. help='Paths to restore from archive, defaults to the entire archive',
  123. )
  124. extract_group._group_actions.append(progress_argument)
  125. list_group = parser.add_argument_group('options for --list')
  126. list_group._group_actions.append(repository_argument)
  127. list_group._group_actions.append(archive_argument)
  128. list_group._group_actions.append(json_argument)
  129. info_group = parser.add_argument_group('options for --info')
  130. info_group._group_actions.append(json_argument)
  131. common_group = parser.add_argument_group('common options')
  132. common_group.add_argument(
  133. '-c',
  134. '--config',
  135. nargs='+',
  136. dest='config_paths',
  137. default=config_paths,
  138. help='Configuration filenames or directories, defaults to: {}'.format(
  139. ' '.join(config_paths)
  140. ),
  141. )
  142. common_group.add_argument(
  143. '--excludes',
  144. dest='excludes_filename',
  145. help='Deprecated in favor of exclude_patterns within configuration',
  146. )
  147. common_group.add_argument(
  148. '-n',
  149. '--dry-run',
  150. dest='dry_run',
  151. action='store_true',
  152. help='Go through the motions, but do not actually write to any repositories',
  153. )
  154. common_group.add_argument(
  155. '-nc', '--no-color', dest='no_color', action='store_true', help='Disable colored output'
  156. )
  157. common_group.add_argument(
  158. '-v',
  159. '--verbosity',
  160. type=int,
  161. choices=range(0, 3),
  162. default=0,
  163. help='Display verbose progress (1 for some, 2 for lots)',
  164. )
  165. common_group.add_argument(
  166. '--version',
  167. dest='version',
  168. default=False,
  169. action='store_true',
  170. help='Display installed version number of borgmatic and exit',
  171. )
  172. common_group.add_argument('--help', action='help', help='Show this help information and exit')
  173. args = parser.parse_args(arguments)
  174. if args.excludes_filename:
  175. raise ValueError(
  176. 'The --excludes option has been replaced with exclude_patterns in configuration'
  177. )
  178. if (args.encryption_mode or args.append_only or args.storage_quota) and not args.init:
  179. raise ValueError(
  180. 'The --encryption, --append-only, and --storage-quota options can only be used with the --init option'
  181. )
  182. if args.init and args.dry_run:
  183. raise ValueError('The --init option cannot be used with the --dry-run option')
  184. if args.init and not args.encryption_mode:
  185. raise ValueError('The --encryption option is required with the --init option')
  186. if not args.extract:
  187. if not args.list:
  188. if args.repository:
  189. raise ValueError(
  190. 'The --repository option can only be used with the --extract and --list options'
  191. )
  192. if args.archive:
  193. raise ValueError(
  194. 'The --archive option can only be used with the --extract and --list options'
  195. )
  196. if args.restore_paths:
  197. raise ValueError('The --restore-path option can only be used with the --extract option')
  198. if args.extract and not args.archive:
  199. raise ValueError('The --archive option is required with the --extract option')
  200. if args.progress and not (args.create or args.extract):
  201. raise ValueError(
  202. 'The --progress option can only be used with the --create and --extract options'
  203. )
  204. if args.json and not (args.create or args.list or args.info):
  205. raise ValueError(
  206. 'The --json option can only be used with the --create, --list, or --info options'
  207. )
  208. if args.json and args.list and args.info:
  209. raise ValueError(
  210. 'With the --json option, options --list and --info cannot be used together'
  211. )
  212. # If any of the action flags are explicitly requested, leave them as-is. Otherwise, assume
  213. # defaults: Mutate the given arguments to enable the default actions.
  214. if (
  215. not args.init
  216. and not args.prune
  217. and not args.create
  218. and not args.check
  219. and not args.extract
  220. and not args.list
  221. and not args.info
  222. ):
  223. args.prune = True
  224. args.create = True
  225. args.check = True
  226. if args.stats and not (args.create or args.prune):
  227. raise ValueError('The --stats option can only be used when creating or pruning archives')
  228. return args
  229. def run_configuration(config_filename, config, args): # pragma: no cover
  230. '''
  231. Given a config filename and the corresponding parsed config dict, execute its defined pruning,
  232. backups, consistency checks, and/or other actions.
  233. Yield JSON output strings from executing any actions that produce JSON.
  234. '''
  235. (location, storage, retention, consistency, hooks) = (
  236. config.get(section_name, {})
  237. for section_name in ('location', 'storage', 'retention', 'consistency', 'hooks')
  238. )
  239. try:
  240. local_path = location.get('local_path', 'borg')
  241. remote_path = location.get('remote_path')
  242. borg_environment.initialize(storage)
  243. if args.create:
  244. hook.execute_hook(
  245. hooks.get('before_backup'), config_filename, 'pre-backup', args.dry_run
  246. )
  247. for repository_path in location['repositories']:
  248. yield from run_actions(
  249. args=args,
  250. location=location,
  251. storage=storage,
  252. retention=retention,
  253. consistency=consistency,
  254. local_path=local_path,
  255. remote_path=remote_path,
  256. repository_path=repository_path,
  257. )
  258. if args.create:
  259. hook.execute_hook(
  260. hooks.get('after_backup'), config_filename, 'post-backup', args.dry_run
  261. )
  262. except (OSError, CalledProcessError):
  263. hook.execute_hook(hooks.get('on_error'), config_filename, 'on-error', args.dry_run)
  264. raise
  265. def run_actions(
  266. *, args, location, storage, retention, consistency, local_path, remote_path, repository_path
  267. ): # pragma: no cover
  268. '''
  269. Given parsed command-line arguments as an argparse.ArgumentParser instance, several different
  270. configuration dicts, local and remote paths to Borg, and a repository name, run all actions
  271. from the command-line arguments on the given repository.
  272. Yield JSON output strings from executing any actions that produce JSON.
  273. '''
  274. repository = os.path.expanduser(repository_path)
  275. dry_run_label = ' (dry run; not making any changes)' if args.dry_run else ''
  276. if args.init:
  277. logger.info('{}: Initializing repository'.format(repository))
  278. borg_init.initialize_repository(
  279. repository,
  280. args.encryption_mode,
  281. args.append_only,
  282. args.storage_quota,
  283. local_path=local_path,
  284. remote_path=remote_path,
  285. )
  286. if args.prune:
  287. logger.info('{}: Pruning archives{}'.format(repository, dry_run_label))
  288. borg_prune.prune_archives(
  289. args.dry_run,
  290. repository,
  291. storage,
  292. retention,
  293. local_path=local_path,
  294. remote_path=remote_path,
  295. stats=args.stats,
  296. )
  297. if args.create:
  298. logger.info('{}: Creating archive{}'.format(repository, dry_run_label))
  299. json_output = borg_create.create_archive(
  300. args.dry_run,
  301. repository,
  302. location,
  303. storage,
  304. local_path=local_path,
  305. remote_path=remote_path,
  306. progress=args.progress,
  307. stats=args.stats,
  308. json=args.json,
  309. )
  310. if json_output:
  311. yield json.loads(json_output)
  312. if args.check and checks.repository_enabled_for_checks(repository, consistency):
  313. logger.info('{}: Running consistency checks'.format(repository))
  314. borg_check.check_archives(
  315. repository, storage, consistency, local_path=local_path, remote_path=remote_path
  316. )
  317. if args.extract:
  318. if args.repository is None or repository == args.repository:
  319. logger.info('{}: Extracting archive {}'.format(repository, args.archive))
  320. borg_extract.extract_archive(
  321. args.dry_run,
  322. repository,
  323. args.archive,
  324. args.restore_paths,
  325. location,
  326. storage,
  327. local_path=local_path,
  328. remote_path=remote_path,
  329. progress=args.progress,
  330. )
  331. if args.list:
  332. if args.repository is None or repository == args.repository:
  333. logger.info('{}: Listing archives'.format(repository))
  334. json_output = borg_list.list_archives(
  335. repository,
  336. storage,
  337. args.archive,
  338. local_path=local_path,
  339. remote_path=remote_path,
  340. json=args.json,
  341. )
  342. if json_output:
  343. yield json.loads(json_output)
  344. if args.info:
  345. logger.info('{}: Displaying summary info for archives'.format(repository))
  346. json_output = borg_info.display_archives_info(
  347. repository, storage, local_path=local_path, remote_path=remote_path, json=args.json
  348. )
  349. if json_output:
  350. yield json.loads(json_output)
  351. def collect_configuration_run_summary_logs(config_filenames, args):
  352. '''
  353. Given a sequence of configuration filenames and parsed command-line arguments as an
  354. argparse.ArgumentParser instance, run each configuration file and yield a series of
  355. logging.LogRecord instances containing summary information about each run.
  356. As a side effect of running through these configuration files, output their JSON results, if
  357. any, to stdout.
  358. '''
  359. # Dict mapping from config filename to corresponding parsed config dict.
  360. configs = collections.OrderedDict()
  361. # Parse and load each configuration file.
  362. for config_filename in config_filenames:
  363. try:
  364. logger.info('{}: Parsing configuration file'.format(config_filename))
  365. configs[config_filename] = validate.parse_configuration(
  366. config_filename, validate.schema_filename()
  367. )
  368. except (ValueError, OSError, validate.Validation_error) as error:
  369. yield logging.makeLogRecord(
  370. dict(
  371. levelno=logging.CRITICAL,
  372. msg='{}: Error parsing configuration file'.format(config_filename),
  373. )
  374. )
  375. yield logging.makeLogRecord(dict(levelno=logging.CRITICAL, msg=error))
  376. # Run cross-file validation checks.
  377. if args.extract or (args.list and args.archive):
  378. try:
  379. validate.guard_configuration_contains_repository(args.repository, configs)
  380. except ValueError as error:
  381. yield logging.makeLogRecord(dict(levelno=logging.CRITICAL, msg=error))
  382. return
  383. # Execute the actions corresponding to each configuration file.
  384. json_results = []
  385. for config_filename, config in configs.items():
  386. try:
  387. json_results.extend(list(run_configuration(config_filename, config, args)))
  388. yield logging.makeLogRecord(
  389. dict(
  390. levelno=logging.INFO,
  391. msg='{}: Successfully ran configuration file'.format(config_filename),
  392. )
  393. )
  394. except (ValueError, OSError, CalledProcessError) as error:
  395. yield logging.makeLogRecord(
  396. dict(
  397. levelno=logging.CRITICAL,
  398. msg='{}: Error running configuration file'.format(config_filename),
  399. )
  400. )
  401. yield logging.makeLogRecord(dict(levelno=logging.CRITICAL, msg=error))
  402. if json_results:
  403. sys.stdout.write(json.dumps(json_results))
  404. if not config_filenames:
  405. yield logging.makeLogRecord(
  406. dict(
  407. levelno=logging.CRITICAL,
  408. msg='{}: No configuration files found'.format(' '.join(args.config_paths)),
  409. )
  410. )
  411. def exit_with_help_link(): # pragma: no cover
  412. '''
  413. Display a link to get help and exit with an error code.
  414. '''
  415. logger.critical('\nNeed some help? https://torsion.org/borgmatic/#issues')
  416. sys.exit(1)
  417. def main(): # pragma: no cover
  418. configure_signals()
  419. try:
  420. args = parse_arguments(*sys.argv[1:])
  421. except ValueError as error:
  422. logging.basicConfig(level=logging.CRITICAL, format='%(message)s')
  423. logger.critical(error)
  424. exit_with_help_link()
  425. colorama.init(autoreset=True, strip=not should_do_markup(args.no_color))
  426. logging.basicConfig(level=verbosity_to_log_level(args.verbosity), format='%(message)s')
  427. if args.version:
  428. print(pkg_resources.require('borgmatic')[0].version)
  429. sys.exit(0)
  430. config_filenames = tuple(collect.collect_config_filenames(args.config_paths))
  431. logger.debug('Ensuring legacy configuration is upgraded')
  432. convert.guard_configuration_upgraded(LEGACY_CONFIG_PATH, config_filenames)
  433. summary_logs = tuple(collect_configuration_run_summary_logs(config_filenames, args))
  434. logger.info('\nsummary:')
  435. [logger.handle(log) for log in summary_logs if log.levelno >= logger.getEffectiveLevel()]
  436. if any(log.levelno == logging.CRITICAL for log in summary_logs):
  437. exit_with_help_link()