duplitab 12 KB

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