duplitab 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. #!/usr/bin/env python3
  2. # PYTHON_ARGCOMPLETE_OK
  3. import collections
  4. import hashlib
  5. import os
  6. import re
  7. import shlex
  8. import subprocess
  9. import tabulate
  10. import tempfile
  11. import time
  12. import urllib.parse
  13. import yaml
  14. def command_join(args):
  15. return ' '.join([shlex.quote(a) for a in args])
  16. def sshfs_mount(url, path, print_trace = False):
  17. """
  18. > Duplicity uses the URL format [...]. The generic format for a URL is:
  19. > scheme://[user[:password]@]host[:port]/[/]path
  20. > [...]
  21. > In protocols that support it, the path may be preceded by a single slash, '/path', to
  22. > represent a relative path to the target home directory, or preceded by a double slash,
  23. > '//path', to represent an absolute filesystem path.
  24. """
  25. url_attr = urllib.parse.urlparse(url)
  26. assert url_attr.scheme in ['sftp']
  27. mount_command = [
  28. 'sshfs',
  29. '{}:{}'.format(url_attr.netloc, url_attr.path[1:]),
  30. path,
  31. ]
  32. if print_trace:
  33. print('+ {}'.format(command_join(mount_command)))
  34. subprocess.check_call(mount_command)
  35. def sshfs_unmount(path, retry_delay_seconds = 1.0, retry_count = 2, print_trace = False):
  36. unmount_command = [
  37. 'fusermount',
  38. '-u',
  39. path,
  40. ]
  41. if print_trace:
  42. print('+ {}'.format(command_join(unmount_command)))
  43. try:
  44. subprocess.check_call(unmount_command)
  45. except subprocess.CalledProcessError as ex:
  46. if retry_count > 0:
  47. time.sleep(retry_delay_seconds)
  48. sshfs_unmount(
  49. path = path,
  50. retry_delay_seconds = retry_delay_seconds,
  51. retry_count = retry_count - 1,
  52. print_trace = print_trace,
  53. )
  54. else:
  55. raise ex
  56. def backup(config, duplicity_verbosity, no_print_config, no_print_statistics, tab_dry,
  57. print_trace = False):
  58. for backup in config:
  59. if not no_print_config:
  60. print('\n{}'.format(yaml.dump({'backup': backup}, default_flow_style = False).strip()))
  61. backup_command = ['duplicity']
  62. # encryption
  63. try:
  64. encryption = backup['encryption']
  65. except KeyError:
  66. encryption = True
  67. if 'encryption' in backup and not backup['encryption']:
  68. backup_command += ['--no-encryption']
  69. else:
  70. if 'encrypt_key' in backup:
  71. backup_command += ['--encrypt-key', backup['encrypt_key']]
  72. # determine source
  73. source_mount_path = None
  74. try:
  75. if backup['source_type'] == 'local':
  76. local_source_path = backup['source_path']
  77. elif backup['source_type'] == 'sshfs':
  78. source_mount_path = tempfile.mkdtemp(prefix = 'duplitab-source-sshfs-')
  79. sshfs_mount(
  80. url = 'sftp://{}/{}'.format(backup['source_host'], backup['source_path']),
  81. path = source_mount_path,
  82. print_trace = print_trace,
  83. )
  84. local_source_path = source_mount_path
  85. backup_command.append('--allow-source-mismatch')
  86. else:
  87. raise Exception("unsupported source type '{}'".format(backup['source_type']))
  88. # selectors
  89. try:
  90. selectors = backup['selectors']
  91. except KeyError:
  92. selectors = []
  93. for selector in selectors:
  94. if selector['option'] in ['include', 'exclude']:
  95. shell_pattern = selector['shell_pattern']
  96. if shell_pattern.startswith(backup['source_path']):
  97. shell_pattern = shell_pattern.replace(
  98. backup['source_path'],
  99. local_source_path,
  100. 1,
  101. )
  102. backup_command += ['--{}'.format(selector['option']), shell_pattern]
  103. else:
  104. raise Exception("unsupported selector option '{}'".format(selector['option']))
  105. # duplicity verbosity
  106. if duplicity_verbosity:
  107. backup_command += ['--verbosity', duplicity_verbosity]
  108. # statistics
  109. if no_print_statistics:
  110. backup_command.append('--no-print-statistics')
  111. # source path
  112. backup_command.append(local_source_path)
  113. # target
  114. target_mount_path = None
  115. try:
  116. if 'target_via_sshfs' in backup and backup['target_via_sshfs']:
  117. target_mount_path = tempfile.mkdtemp(prefix = 'duplitab-target-sshfs-')
  118. backup_command += ['file://' + target_mount_path]
  119. sshfs_mount(
  120. backup['target_url'],
  121. target_mount_path,
  122. print_trace = print_trace,
  123. )
  124. # set backup name to make archive dir persistent
  125. # (default name: hash of target url)
  126. backup_command += ['--name', hashlib.sha1(backup['target_url'].encode('utf-8')).hexdigest()]
  127. else:
  128. backup_command += [backup['target_url']]
  129. try:
  130. if print_trace:
  131. print('{} {}'.format(
  132. '*' if tab_dry else '+',
  133. command_join(backup_command),
  134. ))
  135. if not tab_dry:
  136. subprocess.check_call(backup_command)
  137. finally:
  138. if target_mount_path:
  139. sshfs_unmount(target_mount_path, print_trace = print_trace)
  140. finally:
  141. if target_mount_path:
  142. os.rmdir(target_mount_path)
  143. if source_mount_path:
  144. sshfs_unmount(source_mount_path, print_trace = print_trace)
  145. finally:
  146. if source_mount_path:
  147. os.rmdir(source_mount_path)
  148. def run(command, config_path, quiet, duplicity_verbosity,
  149. target_url_filter_regex = None,
  150. table_style = 'plain',
  151. no_print_config = False,
  152. no_print_trace = False, no_print_statistics = False, tab_dry = False):
  153. if quiet:
  154. if not duplicity_verbosity:
  155. duplicity_verbosity = 'warning'
  156. no_print_trace = True
  157. no_print_statistics = True
  158. no_print_config = True
  159. with open(config_path) as config_file:
  160. config = yaml.load(config_file.read())
  161. for backup_attr in config:
  162. if not 'source_type' in backup_attr:
  163. backup_attr['source_type'] = 'local'
  164. filtered_config = []
  165. for backup_attr in config:
  166. if (not target_url_filter_regex
  167. or re.search('^{}$'.format(target_url_filter_regex), backup_attr['target_url'])):
  168. filtered_config.append(backup_attr)
  169. if not command or command == 'list':
  170. columns = collections.OrderedDict([
  171. ('source type', 'source_type'),
  172. ('source host', 'source_host'),
  173. ('source path', 'source_path'),
  174. ('target url', 'target_url'),
  175. ('encrypt key', 'encrypt_key'),
  176. ])
  177. table = [[b[c] for c in columns.values()] for b in filtered_config]
  178. print(tabulate.tabulate(
  179. table,
  180. columns.keys(),
  181. tablefmt = table_style,
  182. ))
  183. elif command == 'backup':
  184. backup(
  185. config = filtered_config,
  186. duplicity_verbosity = duplicity_verbosity,
  187. no_print_config = no_print_config,
  188. no_print_statistics = no_print_statistics,
  189. tab_dry = tab_dry,
  190. print_trace = not no_print_trace,
  191. )
  192. def _init_argparser():
  193. import argparse
  194. argparser = argparse.ArgumentParser(description = None)
  195. argparser.add_argument(
  196. '-c',
  197. '--config',
  198. dest = 'config_path',
  199. default = '/etc/duplitab',
  200. )
  201. argparser.add_argument(
  202. '--filter-target-url',
  203. dest = 'target_url_filter_regex',
  204. metavar = 'REGEXP',
  205. default = None,
  206. )
  207. argparser.add_argument(
  208. '-q',
  209. '--quiet',
  210. '--silent',
  211. action = 'store_true',
  212. dest = 'quiet',
  213. )
  214. argparser.add_argument(
  215. '--duplicity-verbosity',
  216. type = str,
  217. )
  218. argparser.add_argument(
  219. '--no-print-trace',
  220. action = 'store_true',
  221. )
  222. subparsers = argparser.add_subparsers(
  223. dest = 'command',
  224. )
  225. subparser_list = subparsers.add_parser('list')
  226. subparser_list.add_argument(
  227. '--table-style',
  228. default = 'plain',
  229. )
  230. subparser_backup = subparsers.add_parser('backup')
  231. subparser_backup.add_argument(
  232. '--no-print-config',
  233. action = 'store_true',
  234. )
  235. subparser_backup.add_argument(
  236. '--no-print-statistics',
  237. action = 'store_true',
  238. )
  239. subparser_backup.add_argument(
  240. '--tab-dry',
  241. action = 'store_true',
  242. )
  243. return argparser
  244. def main(argv):
  245. argparser = _init_argparser()
  246. try:
  247. import argcomplete
  248. argcomplete.autocomplete(argparser)
  249. except ImportError:
  250. pass
  251. args = argparser.parse_args(argv)
  252. run(**vars(args))
  253. return 0
  254. if __name__ == "__main__":
  255. import sys
  256. sys.exit(main(sys.argv[1:]))