borg.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. from datetime import datetime
  2. import glob
  3. import itertools
  4. import os
  5. import platform
  6. import sys
  7. import re
  8. import subprocess
  9. import tempfile
  10. from borgmatic.verbosity import VERBOSITY_SOME, VERBOSITY_LOTS
  11. # Integration with Borg for actually handling backups.
  12. COMMAND = 'borg'
  13. def initialize(storage_config, command=COMMAND):
  14. passphrase = storage_config.get('encryption_passphrase')
  15. if passphrase:
  16. os.environ['{}_PASSPHRASE'.format(command.upper())] = passphrase
  17. def _write_exclude_file(exclude_patterns=None):
  18. '''
  19. Given a sequence of exclude patterns, write them to a named temporary file and return it. Return
  20. None if no patterns are provided.
  21. '''
  22. if not exclude_patterns:
  23. return None
  24. exclude_file = tempfile.NamedTemporaryFile('w')
  25. exclude_file.write('\n'.join(exclude_patterns))
  26. exclude_file.flush()
  27. return exclude_file
  28. def create_archive(
  29. verbosity, repository, location_config, storage_config, command=COMMAND,
  30. ):
  31. '''
  32. Given a vebosity flag, a storage config dict, a list of source directories, a local or remote
  33. repository path, a list of exclude patterns, and a command to run, create a Borg archive.
  34. '''
  35. sources = tuple(
  36. itertools.chain.from_iterable(
  37. glob.glob(directory) or [directory]
  38. for directory in location_config['source_directories']
  39. )
  40. )
  41. exclude_file = _write_exclude_file(location_config.get('exclude_patterns'))
  42. exclude_flags = ('--exclude-from', exclude_file.name) if exclude_file else ()
  43. compression = storage_config.get('compression', None)
  44. compression_flags = ('--compression', compression) if compression else ()
  45. umask = storage_config.get('umask', None)
  46. umask_flags = ('--umask', str(umask)) if umask else ()
  47. one_file_system_flags = ('--one-file-system',) if location_config.get('one_file_system') else ()
  48. remote_path = location_config.get('remote_path')
  49. remote_path_flags = ('--remote-path', remote_path) if remote_path else ()
  50. verbosity_flags = {
  51. VERBOSITY_SOME: ('--info', '--stats',),
  52. VERBOSITY_LOTS: ('--debug', '--list', '--stats'),
  53. }.get(verbosity, ())
  54. full_command = (
  55. command, 'create',
  56. '{repository}::{hostname}-{timestamp}'.format(
  57. repository=repository,
  58. hostname=platform.node(),
  59. timestamp=datetime.now().isoformat(),
  60. ),
  61. ) + sources + exclude_flags + compression_flags + one_file_system_flags + \
  62. remote_path_flags + umask_flags + verbosity_flags
  63. subprocess.check_call(full_command)
  64. def _make_prune_flags(retention_config):
  65. '''
  66. Given a retention config dict mapping from option name to value, tranform it into an iterable of
  67. command-line name-value flag pairs.
  68. For example, given a retention config of:
  69. {'keep_weekly': 4, 'keep_monthly': 6}
  70. This will be returned as an iterable of:
  71. (
  72. ('--keep-weekly', '4'),
  73. ('--keep-monthly', '6'),
  74. )
  75. '''
  76. return (
  77. ('--' + option_name.replace('_', '-'), str(retention_config[option_name]))
  78. for option_name, value in retention_config.items()
  79. )
  80. def prune_archives(verbosity, repository, retention_config, command=COMMAND, remote_path=None):
  81. '''
  82. Given a verbosity flag, a local or remote repository path, a retention config dict, and a
  83. command to run, prune Borg archives according the the retention policy specified in that
  84. configuration.
  85. '''
  86. remote_path_flags = ('--remote-path', remote_path) if remote_path else ()
  87. verbosity_flags = {
  88. VERBOSITY_SOME: ('--info', '--stats',),
  89. VERBOSITY_LOTS: ('--debug', '--stats'),
  90. }.get(verbosity, ())
  91. full_command = (
  92. command, 'prune',
  93. repository,
  94. ) + tuple(
  95. element
  96. for pair in _make_prune_flags(retention_config)
  97. for element in pair
  98. ) + remote_path_flags + verbosity_flags
  99. subprocess.check_call(full_command)
  100. DEFAULT_CHECKS = ('repository', 'archives')
  101. def _parse_checks(consistency_config):
  102. '''
  103. Given a consistency config with a "checks" list, transform it to a tuple of named checks to run.
  104. For example, given a retention config of:
  105. {'checks': ['repository', 'archives']}
  106. This will be returned as:
  107. ('repository', 'archives')
  108. If no "checks" option is present, return the DEFAULT_CHECKS. If the checks value is the string
  109. "disabled", return an empty tuple, meaning that no checks should be run.
  110. '''
  111. checks = consistency_config.get('checks', [])
  112. if checks == ['disabled']:
  113. return ()
  114. return tuple(check for check in checks if check.lower() not in ('disabled', '')) or DEFAULT_CHECKS
  115. def _make_check_flags(checks, check_last=None):
  116. '''
  117. Given a parsed sequence of checks, transform it into tuple of command-line flags.
  118. For example, given parsed checks of:
  119. ('repository',)
  120. This will be returned as:
  121. ('--repository-only',)
  122. Additionally, if a check_last value is given, a "--last" flag will be added.
  123. '''
  124. last_flag = ('--last', str(check_last)) if check_last else ()
  125. if checks == DEFAULT_CHECKS:
  126. return last_flag
  127. return tuple(
  128. '--{}-only'.format(check) for check in checks
  129. if check in DEFAULT_CHECKS
  130. ) + last_flag
  131. def check_archives(verbosity, repository, consistency_config, command=COMMAND, remote_path=None):
  132. '''
  133. Given a verbosity flag, a local or remote repository path, a consistency config dict, and a
  134. command to run, check the contained Borg archives for consistency.
  135. If there are no consistency checks to run, skip running them.
  136. '''
  137. checks = _parse_checks(consistency_config)
  138. check_last = consistency_config.get('check_last', None)
  139. if set(checks).intersection(set(DEFAULT_CHECKS)):
  140. remote_path_flags = ('--remote-path', remote_path) if remote_path else ()
  141. verbosity_flags = {
  142. VERBOSITY_SOME: ('--info',),
  143. VERBOSITY_LOTS: ('--debug',),
  144. }.get(verbosity, ())
  145. full_command = (
  146. command, 'check',
  147. repository,
  148. ) + _make_check_flags(checks, check_last) + remote_path_flags + verbosity_flags
  149. # The check command spews to stdout/stderr even without the verbose flag. Suppress it.
  150. stdout = None if verbosity_flags else open(os.devnull, 'w')
  151. subprocess.check_call(full_command, stdout=stdout, stderr=subprocess.STDOUT)
  152. if 'extract' in checks:
  153. extract_last_archive_dry_run(verbosity, repository, command, remote_path)
  154. def extract_last_archive_dry_run(verbosity, repository, command=COMMAND, remote_path=None):
  155. '''
  156. Perform an extraction dry-run of just the most recent archive. If there are no archives, skip
  157. the dry-run.
  158. '''
  159. remote_path_flags = ('--remote-path', remote_path) if remote_path else ()
  160. verbosity_flags = {
  161. VERBOSITY_SOME: ('--info',),
  162. VERBOSITY_LOTS: ('--debug',),
  163. }.get(verbosity, ())
  164. full_list_command = (
  165. command, 'list',
  166. '--short',
  167. repository,
  168. ) + remote_path_flags + verbosity_flags
  169. list_output = subprocess.check_output(full_list_command).decode(sys.stdout.encoding)
  170. last_archive_name = list_output.strip().split('\n')[-1]
  171. if not last_archive_name:
  172. return
  173. list_flag = ('--list',) if verbosity == VERBOSITY_LOTS else ()
  174. full_extract_command = (
  175. command, 'extract',
  176. '--dry-run',
  177. '{repository}::{last_archive_name}'.format(
  178. repository=repository,
  179. last_archive_name=last_archive_name,
  180. ),
  181. ) + remote_path_flags + verbosity_flags + list_flag
  182. subprocess.check_call(full_extract_command)