duplitab 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  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. # name
  63. if backup['name']:
  64. backup_command += ['--name', backup['name']]
  65. # encryption
  66. try:
  67. encryption = backup['encryption']
  68. except KeyError:
  69. encryption = True
  70. if 'encryption' in backup and not backup['encryption']:
  71. backup_command += ['--no-encryption']
  72. else:
  73. if 'encrypt_key' in backup:
  74. backup_command += ['--encrypt-key', backup['encrypt_key']]
  75. # determine source
  76. source_mount_path = None
  77. try:
  78. if backup['source_type'] == 'local':
  79. local_source_path = backup['source_path']
  80. elif backup['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(backup['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. if not backup['name']:
  130. backup_command += ['--name', hashlib.sha1(backup['target_url'].encode('utf-8')).hexdigest()]
  131. else:
  132. backup_command += [backup['target_url']]
  133. try:
  134. if print_trace:
  135. print('{} {}'.format(
  136. '*' if tab_dry else '+',
  137. command_join(backup_command),
  138. ))
  139. if not tab_dry:
  140. subprocess.check_call(backup_command)
  141. finally:
  142. if target_mount_path:
  143. sshfs_unmount(target_mount_path, print_trace = print_trace)
  144. finally:
  145. if target_mount_path:
  146. os.rmdir(target_mount_path)
  147. if source_mount_path:
  148. sshfs_unmount(source_mount_path, print_trace = print_trace)
  149. finally:
  150. if source_mount_path:
  151. os.rmdir(source_mount_path)
  152. def run(command, config_path, quiet, duplicity_verbosity,
  153. target_url_filter_regex = None,
  154. table_style = 'plain',
  155. no_print_config = False,
  156. no_print_trace = False, no_print_statistics = False, tab_dry = False):
  157. if quiet:
  158. if not duplicity_verbosity:
  159. duplicity_verbosity = 'warning'
  160. no_print_trace = True
  161. no_print_statistics = True
  162. no_print_config = True
  163. with open(config_path) as config_file:
  164. config = yaml.load(config_file.read())
  165. for backup_attr in config:
  166. if not 'name' in backup_attr:
  167. backup_attr['name'] = None
  168. if not 'source_type' in backup_attr:
  169. backup_attr['source_type'] = 'local'
  170. if not 'source_host' in backup_attr:
  171. backup_attr['source_host'] = None
  172. if not 'encrypt_key' in backup_attr:
  173. backup_attr['encrypt_key'] = None
  174. filtered_config = []
  175. for backup_attr in config:
  176. if (not target_url_filter_regex
  177. or re.search('^{}$'.format(target_url_filter_regex), backup_attr['target_url'])):
  178. filtered_config.append(backup_attr)
  179. if not command or command == 'list':
  180. columns = collections.OrderedDict([
  181. ('name', 'name'),
  182. ('source type', 'source_type'),
  183. ('source host', 'source_host'),
  184. ('source path', 'source_path'),
  185. ('target url', 'target_url'),
  186. ('encrypt key', 'encrypt_key'),
  187. ])
  188. table = [[b[c] for c in columns.values()] for b in filtered_config]
  189. print(tabulate.tabulate(
  190. table,
  191. columns.keys(),
  192. tablefmt = table_style,
  193. ))
  194. elif command == 'backup':
  195. backup(
  196. config = filtered_config,
  197. duplicity_verbosity = duplicity_verbosity,
  198. no_print_config = no_print_config,
  199. no_print_statistics = no_print_statistics,
  200. tab_dry = tab_dry,
  201. print_trace = not no_print_trace,
  202. )
  203. def _init_argparser():
  204. import argparse
  205. argparser = argparse.ArgumentParser(description = None)
  206. argparser.add_argument(
  207. '-c',
  208. '--config',
  209. dest = 'config_path',
  210. default = '/etc/duplitab',
  211. )
  212. argparser.add_argument(
  213. '--filter-target-url',
  214. dest = 'target_url_filter_regex',
  215. metavar = 'REGEXP',
  216. default = None,
  217. )
  218. argparser.add_argument(
  219. '-q',
  220. '--quiet',
  221. '--silent',
  222. action = 'store_true',
  223. dest = 'quiet',
  224. )
  225. argparser.add_argument(
  226. '--duplicity-verbosity',
  227. type = str,
  228. )
  229. argparser.add_argument(
  230. '--no-print-trace',
  231. action = 'store_true',
  232. )
  233. subparsers = argparser.add_subparsers(
  234. dest = 'command',
  235. )
  236. subparser_list = subparsers.add_parser('list')
  237. subparser_list.add_argument(
  238. '--table-style',
  239. default = 'plain',
  240. )
  241. subparser_backup = subparsers.add_parser('backup')
  242. subparser_backup.add_argument(
  243. '--no-print-config',
  244. action = 'store_true',
  245. )
  246. subparser_backup.add_argument(
  247. '--no-print-statistics',
  248. action = 'store_true',
  249. )
  250. subparser_backup.add_argument(
  251. '--tab-dry',
  252. action = 'store_true',
  253. )
  254. return argparser
  255. def main(argv):
  256. argparser = _init_argparser()
  257. try:
  258. import argcomplete
  259. argcomplete.autocomplete(argparser)
  260. except ImportError:
  261. pass
  262. args = argparser.parse_args(argv)
  263. run(**vars(args))
  264. return 0
  265. if __name__ == "__main__":
  266. import sys
  267. sys.exit(main(sys.argv[1:]))