duplitab 8.8 KB

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