borgmatic.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. from argparse import ArgumentParser
  2. import collections
  3. import json
  4. import logging
  5. import os
  6. from subprocess import CalledProcessError
  7. import sys
  8. import pkg_resources
  9. from borgmatic.borg import (
  10. check as borg_check,
  11. create as borg_create,
  12. environment as borg_environment,
  13. prune as borg_prune,
  14. extract as borg_extract,
  15. list as borg_list,
  16. info as borg_info,
  17. init as borg_init,
  18. )
  19. from borgmatic.commands import hook
  20. from borgmatic.config import checks, collect, convert, validate
  21. from borgmatic.signals import configure_signals
  22. from borgmatic.verbosity import verbosity_to_log_level
  23. logger = logging.getLogger(__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. create_group = parser.add_argument_group('options for --create')
  93. create_group.add_argument(
  94. '--progress',
  95. dest='progress',
  96. default=False,
  97. action='store_true',
  98. help='Display progress for each file as it is backed up',
  99. )
  100. extract_group = parser.add_argument_group('options for --extract')
  101. extract_group.add_argument(
  102. '--repository',
  103. help='Path of repository to restore from, defaults to the configured repository if there is only one',
  104. )
  105. extract_group.add_argument('--archive', help='Name of archive to restore')
  106. extract_group.add_argument(
  107. '--restore-path',
  108. nargs='+',
  109. dest='restore_paths',
  110. help='Paths to restore from archive, defaults to the entire archive',
  111. )
  112. common_group = parser.add_argument_group('common options')
  113. common_group.add_argument(
  114. '-c',
  115. '--config',
  116. nargs='+',
  117. dest='config_paths',
  118. default=config_paths,
  119. help='Configuration filenames or directories, defaults to: {}'.format(
  120. ' '.join(config_paths)
  121. ),
  122. )
  123. common_group.add_argument(
  124. '--excludes',
  125. dest='excludes_filename',
  126. help='Deprecated in favor of exclude_patterns within configuration',
  127. )
  128. common_group.add_argument(
  129. '--stats',
  130. dest='stats',
  131. default=False,
  132. action='store_true',
  133. help='Display statistics of archive with --create or --prune option',
  134. )
  135. common_group.add_argument(
  136. '--json',
  137. dest='json',
  138. default=False,
  139. action='store_true',
  140. help='Output results from the --create, --list, or --info options as json',
  141. )
  142. common_group.add_argument(
  143. '-n',
  144. '--dry-run',
  145. dest='dry_run',
  146. action='store_true',
  147. help='Go through the motions, but do not actually write to any repositories',
  148. )
  149. common_group.add_argument(
  150. '-v',
  151. '--verbosity',
  152. type=int,
  153. choices=range(0, 3),
  154. default=0,
  155. help='Display verbose progress (1 for some, 2 for lots)',
  156. )
  157. common_group.add_argument(
  158. '--version',
  159. dest='version',
  160. default=False,
  161. action='store_true',
  162. help='Display installed version number of borgmatic and exit',
  163. )
  164. common_group.add_argument('--help', action='help', help='Show this help information and exit')
  165. args = parser.parse_args(arguments)
  166. if args.excludes_filename:
  167. raise ValueError(
  168. 'The --excludes option has been replaced with exclude_patterns in configuration'
  169. )
  170. if (args.encryption_mode or args.append_only or args.storage_quota) and not args.init:
  171. raise ValueError(
  172. 'The --encryption, --append-only, and --storage-quota options can only be used with the --init option'
  173. )
  174. if args.init and args.dry_run:
  175. raise ValueError('The --init option cannot be used with the --dry-run option')
  176. if args.init and not args.encryption_mode:
  177. raise ValueError('The --encryption option is required with the --init option')
  178. if not args.extract:
  179. if args.repository:
  180. raise ValueError('The --repository option can only be used with the --extract option')
  181. if args.archive:
  182. raise ValueError('The --archive option can only be used with the --extract option')
  183. if args.restore_paths:
  184. raise ValueError('The --restore-path option can only be used with the --extract option')
  185. if args.extract and not args.archive:
  186. raise ValueError('The --archive option is required with the --extract option')
  187. if args.progress and not (args.create or args.extract):
  188. raise ValueError(
  189. 'The --progress option can only be used with the --create and --extract options'
  190. )
  191. if args.json and not (args.create or args.list or args.info):
  192. raise ValueError(
  193. 'The --json option can only be used with the --create, --list, or --info options'
  194. )
  195. if args.json and args.list and args.info:
  196. raise ValueError(
  197. 'With the --json option, options --list and --info cannot be used together'
  198. )
  199. # If any of the action flags are explicitly requested, leave them as-is. Otherwise, assume
  200. # defaults: Mutate the given arguments to enable the default actions.
  201. if (
  202. not args.init
  203. and not args.prune
  204. and not args.create
  205. and not args.check
  206. and not args.extract
  207. and not args.list
  208. and not args.info
  209. ):
  210. args.prune = True
  211. args.create = True
  212. args.check = True
  213. if args.stats and not (args.create or args.prune):
  214. raise ValueError('The --stats option can only be used when creating or pruning archives')
  215. return args
  216. def run_configuration(config_filename, config, args): # pragma: no cover
  217. '''
  218. Given a config filename and the corresponding parsed config dict, execute its defined pruning,
  219. backups, consistency checks, and/or other actions.
  220. '''
  221. (location, storage, retention, consistency, hooks) = (
  222. config.get(section_name, {})
  223. for section_name in ('location', 'storage', 'retention', 'consistency', 'hooks')
  224. )
  225. try:
  226. local_path = location.get('local_path', 'borg')
  227. remote_path = location.get('remote_path')
  228. borg_environment.initialize(storage)
  229. if args.create:
  230. hook.execute_hook(hooks.get('before_backup'), config_filename, 'pre-backup')
  231. _run_commands(
  232. args=args,
  233. consistency=consistency,
  234. local_path=local_path,
  235. location=location,
  236. remote_path=remote_path,
  237. retention=retention,
  238. storage=storage,
  239. )
  240. if args.create:
  241. hook.execute_hook(hooks.get('after_backup'), config_filename, 'post-backup')
  242. except (OSError, CalledProcessError):
  243. hook.execute_hook(hooks.get('on_error'), config_filename, 'on-error')
  244. raise
  245. def _run_commands(*, args, consistency, local_path, location, remote_path, retention, storage):
  246. json_results = []
  247. for unexpanded_repository in location['repositories']:
  248. _run_commands_on_repository(
  249. args=args,
  250. consistency=consistency,
  251. json_results=json_results,
  252. local_path=local_path,
  253. location=location,
  254. remote_path=remote_path,
  255. retention=retention,
  256. storage=storage,
  257. unexpanded_repository=unexpanded_repository,
  258. )
  259. if args.json:
  260. sys.stdout.write(json.dumps(json_results))
  261. def _run_commands_on_repository(
  262. *,
  263. args,
  264. consistency,
  265. json_results,
  266. local_path,
  267. location,
  268. remote_path,
  269. retention,
  270. storage,
  271. unexpanded_repository
  272. ): # pragma: no cover
  273. repository = os.path.expanduser(unexpanded_repository)
  274. dry_run_label = ' (dry run; not making any changes)' if args.dry_run else ''
  275. if args.init:
  276. logger.info('{}: Initializing repository'.format(repository))
  277. borg_init.initialize_repository(
  278. repository,
  279. args.encryption_mode,
  280. args.append_only,
  281. args.storage_quota,
  282. local_path=local_path,
  283. remote_path=remote_path,
  284. )
  285. if args.prune:
  286. logger.info('{}: Pruning archives{}'.format(repository, dry_run_label))
  287. borg_prune.prune_archives(
  288. args.dry_run,
  289. repository,
  290. storage,
  291. retention,
  292. local_path=local_path,
  293. remote_path=remote_path,
  294. stats=args.stats,
  295. )
  296. if args.create:
  297. logger.info('{}: Creating archive{}'.format(repository, dry_run_label))
  298. borg_create.create_archive(
  299. args.dry_run,
  300. repository,
  301. location,
  302. storage,
  303. local_path=local_path,
  304. remote_path=remote_path,
  305. progress=args.progress,
  306. stats=args.stats,
  307. )
  308. if args.check and checks.repository_enabled_for_checks(repository, consistency):
  309. logger.info('{}: Running consistency checks'.format(repository))
  310. borg_check.check_archives(
  311. repository, storage, consistency, local_path=local_path, remote_path=remote_path
  312. )
  313. if args.extract:
  314. if args.repository is None or repository == args.repository:
  315. logger.info('{}: Extracting archive {}'.format(repository, args.archive))
  316. borg_extract.extract_archive(
  317. args.dry_run,
  318. repository,
  319. args.archive,
  320. args.restore_paths,
  321. storage,
  322. local_path=local_path,
  323. remote_path=remote_path,
  324. progress=args.progress,
  325. )
  326. if args.list:
  327. logger.info('{}: Listing archives'.format(repository))
  328. output = borg_list.list_archives(
  329. repository, storage, local_path=local_path, remote_path=remote_path, json=args.json
  330. )
  331. if args.json:
  332. json_results.append(json.loads(output))
  333. else:
  334. sys.stdout.write(output)
  335. if args.info:
  336. logger.info('{}: Displaying summary info for archives'.format(repository))
  337. output = borg_info.display_archives_info(
  338. repository, storage, local_path=local_path, remote_path=remote_path, json=args.json
  339. )
  340. if args.json:
  341. json_results.append(json.loads(output))
  342. else:
  343. sys.stdout.write(output)
  344. def collect_configuration_run_summary_logs(config_filenames, args):
  345. '''
  346. Given a sequence of configuration filenames and parsed command-line arguments as an
  347. argparse.ArgumentParser instance, run each configuration file and yield a series of
  348. logging.LogRecord instances containing summary information about each run.
  349. '''
  350. # Dict mapping from config filename to corresponding parsed config dict.
  351. configs = collections.OrderedDict()
  352. for config_filename in config_filenames:
  353. try:
  354. logger.info('{}: Parsing configuration file'.format(config_filename))
  355. configs[config_filename] = validate.parse_configuration(
  356. config_filename, validate.schema_filename()
  357. )
  358. except (ValueError, OSError, validate.Validation_error) as error:
  359. yield logging.makeLogRecord(
  360. dict(
  361. levelno=logging.CRITICAL,
  362. msg='{}: Error parsing configuration file'.format(config_filename),
  363. )
  364. )
  365. yield logging.makeLogRecord(dict(levelno=logging.CRITICAL, msg=error))
  366. if args.extract:
  367. validate.guard_configuration_contains_repository(args.repository, configs)
  368. for config_filename, config in configs.items():
  369. try:
  370. run_configuration(config_filename, config, args)
  371. yield logging.makeLogRecord(
  372. dict(
  373. levelno=logging.INFO,
  374. msg='{}: Successfully ran configuration file'.format(config_filename),
  375. )
  376. )
  377. except (ValueError, OSError, CalledProcessError) as error:
  378. yield logging.makeLogRecord(
  379. dict(
  380. levelno=logging.CRITICAL,
  381. msg='{}: Error running configuration file'.format(config_filename),
  382. )
  383. )
  384. yield logging.makeLogRecord(dict(levelno=logging.CRITICAL, msg=error))
  385. if not config_filenames:
  386. yield logging.makeLogRecord(
  387. dict(
  388. levelno=logging.CRITICAL,
  389. msg='{}: No configuration files found'.format(' '.join(args.config_paths)),
  390. )
  391. )
  392. def exit_with_help_link(): # pragma: no cover
  393. '''
  394. Display a link to get help and exit with an error code.
  395. '''
  396. logger.critical('\nNeed some help? https://torsion.org/borgmatic/#issues')
  397. sys.exit(1)
  398. def main(): # pragma: no cover
  399. configure_signals()
  400. try:
  401. args = parse_arguments(*sys.argv[1:])
  402. except ValueError as error:
  403. logging.basicConfig(level=logging.CRITICAL, format='%(message)s')
  404. logger.critical(error)
  405. exit_with_help_link()
  406. logging.basicConfig(level=verbosity_to_log_level(args.verbosity), format='%(message)s')
  407. if args.version:
  408. print(pkg_resources.require('borgmatic')[0].version)
  409. sys.exit(0)
  410. config_filenames = tuple(collect.collect_config_filenames(args.config_paths))
  411. logger.debug('Ensuring legacy configuration is upgraded')
  412. convert.guard_configuration_upgraded(LEGACY_CONFIG_PATH, config_filenames)
  413. summary_logs = tuple(collect_configuration_run_summary_logs(config_filenames, args))
  414. logger.info('\nsummary:')
  415. [logger.handle(log) for log in summary_logs if log.levelno >= logger.getEffectiveLevel()]
  416. if any(log.levelno == logging.CRITICAL for log in summary_logs):
  417. exit_with_help_link()