duplitab 6.8 KB

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