duplicity-team team mailing list archive
-
duplicity-team team
-
Mailing list archive
-
Message #02885
[Merge] lp:~stynor/duplicity/multi-backend into lp:duplicity
Steve Tynor has proposed merging lp:~stynor/duplicity/multi-backend into lp:duplicity.
Requested reviews:
duplicity-team (duplicity-team)
For more details, see:
https://code.launchpad.net/~stynor/duplicity/multi-backend/+merge/255911
A new backend that allows use of more than one backend stores (e.g. to combine the available space from more than one cloud provider to make a larger store available to duplicity).
Changed file:
bin/duplicity.1
New file:
duplicity/backends/multibackend.py
I've tested this with the local, gdocs and pydrive backends. Usage instructions are in the manpage.
--
Your team duplicity-team is requested to review the proposed merge of lp:~stynor/duplicity/multi-backend into lp:duplicity.
=== modified file 'bin/duplicity.1' (properties changed: -x to +x)
--- bin/duplicity.1 2015-03-12 21:43:25 +0000
+++ bin/duplicity.1 2015-04-11 22:06:20 +0000
@@ -157,6 +157,13 @@
(also see
.BR "A NOTE ON PYDRIVE BACKEND"
) below.
+.TP
+.BR "multi backend"
+.B Multi -- store to more than one backend
+.br
+(also see
+.BR "A NOTE ON MULTI BACKEND"
+) below.
.br
.SH DESCRIPTION
Duplicity incrementally backs up files and folders into
@@ -1272,6 +1279,15 @@
.B "A NOTE ON PYDRIVE BACKEND"
below.
.RE
+.BR "multi"
+.PP
+.RS
+multi:///path/to/config.json
+.PP
+See also
+.B "A NOTE ON MULTI BACKEND"
+below.
+.RE
.SH TIME FORMATS
duplicity uses time strings in two places. Firstly, many of the files
@@ -1862,6 +1878,43 @@
.BR GOOGLE_DRIVE_ACCOUNT_KEY
environment variable for authentification.
+.SH A NOTE ON MULTI BACKEND
+
+The multi backend allows duplicity to combine the storage available in
+more than one backend store (e.g., you can store across a google drive
+account and a onedrive account to get effectively the combined storage
+available in both). The URL path specifies a JSON formated config
+file containing a list of the backends it will use. Multibackend then
+round-robins across the given backends. Each element of the list must
+have a "url" element, and may also contain an optional "description"
+and an optional "env" list of environment variables used to configure
+that backend.
+.PP
+For example:
+.nf
+.RS
+[
+ {
+ "description": "a comment about the backend
+ "url": "abackend://myuser@xxxxxxxxxx/backup",
+ "env": [
+ {
+ "name" : "MYENV",
+ "value" : "xyz"
+ },
+ {
+ "name" : "FOO",
+ "value" : "bar"
+ }
+ ]
+ },
+ {
+ "url": "file:///path/to/dir"
+ }
+]
+.RE
+.fi
+
.SH A NOTE ON SYMMETRIC ENCRYPTION AND SIGNING
Signing and symmetrically encrypt at the same time with the gpg binary on the
command line, as used within duplicity, is a specifically challenging issue.
=== added file 'duplicity/backends/multibackend.py'
--- duplicity/backends/multibackend.py 1970-01-01 00:00:00 +0000
+++ duplicity/backends/multibackend.py 2015-04-11 22:06:20 +0000
@@ -0,0 +1,190 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright 2015 Steve Tynor <steve.tynor@xxxxxxxxx>
+#
+# This file is part of duplicity.
+#
+# Duplicity is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the
+# Free Software Foundation; either version 2 of the License, or (at your
+# option) any later version.
+#
+# Duplicity is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with duplicity; if not, write to the Free Software Foundation,
+# Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+
+#
+
+import os
+import os.path
+import string
+import urllib
+import json
+
+import duplicity.backend
+from duplicity.errors import BackendException
+from duplicity import log
+
+class MultiBackend(duplicity.backend.Backend):
+ """Store files across multiple remote stores. URL is a path to a local file containing URLs/other config defining the remote store"""
+
+
+ # the stores we are managing
+ __stores = []
+
+ # when we write, we "stripe" via a simple round-robin across
+ # remote stores. It's hard to get too much more sophisticated
+ # since we can't rely on the backend to give us any useful meta
+ # data (e.g. sizes of files, capacity of the store (quotas)) to do
+ # a better job of balancing load across stores.
+ __write_cursor = 0
+
+ def __init__(self, parsed_url):
+ duplicity.backend.Backend.__init__(self, parsed_url)
+
+ # Init each of the wrapped stores
+ #
+ # config file is a json formatted collection of values, one for
+ # each backend. We will 'stripe' data across all the given stores:
+ #
+ # 'url' - the URL used for the backend store
+ # 'env' - an optional list of enviroment variable values to set
+ # during the intialization of the backend
+ #
+ # Example:
+ #
+ # [
+ # {
+ # "url": "abackend://myuser@xxxxxxxxxx/backup",
+ # "env": [
+ # {
+ # "name" : "MYENV",
+ # "value" : "xyz"
+ # },
+ # {
+ # "name" : "FOO",
+ # "value" : "bar"
+ # }
+ # ]
+ # },
+ # {
+ # "url": "file:///path/to/dir"
+ # }
+ # ]
+
+ try:
+ with open(parsed_url.path) as f:
+ configs = json.load(f)
+ except IOError as e:
+ log.Log(_("MutliBackend: Could not load config file %s: %s ")
+ % (parsed_url.path, e),
+ log.ERROR)
+ raise BackendException('Could not load config file')
+
+ for config in configs:
+ url = config['url']
+ log.Log(_("MultiBackend: use store %s")
+ % (url),
+ log.INFO)
+ if 'env' in config:
+ for env in config['env']:
+ log.Log(_("MultiBackend: set env %s = %s")
+ % (env['name'],env['value']),
+ log.INFO)
+ os.environ[env['name']] = env['value'];
+
+ store = duplicity.backend.get_backend(url)
+ self.__stores.append(store)
+ store_list = store.list()
+ log.Log(_("MultiBackend: at init, store %s has %s files")
+ % (url, len(store_list)),
+ log.INFO)
+
+ def _put(self, source_path, remote_filename):
+ first = self.__write_cursor
+ while True:
+ store = self.__stores[self.__write_cursor]
+ try:
+ next = self.__write_cursor + 1;
+ if (next > len(self.__stores) -1):
+ next = 0
+ log.Log(_("MultiBackend: _put: write to store #%s (%s)")
+ % (self.__write_cursor, store.backend.parsed_url.url_string),
+ log.DEBUG)
+ store.put(source_path, remote_filename)
+ self.__write_cursor = next
+ break
+ except Exception, e:
+ log.Log(_("MultiBackend: failed to write to store #%s (%s), try #%s, Exception: %s")
+ % (self.__write_cursor, store.backend.parsed_url.url_string, next, e),
+ log.INFO)
+ self.__write_cursor = next
+
+ if (self.__write_cursor == first):
+ log.Log(_("MultiBackend: failed to write %s. Tried all backing stores and none succeeded")
+ % (source_path),
+ log.ERROR)
+ raise BackendException("failed to write");
+
+ def _get(self, remote_filename, local_path):
+ # since the backend operations will be retried, we can't
+ # simply try to get from the store, if not found, move to the
+ # next store (since each failure will be retried n times
+ # before finally giving up). So we need to get the list first
+ # before we try to fetch
+ # ENHANCEME: maintain a cached list for each store
+ for s in self.__stores:
+ list = s.list()
+ if remote_filename in list:
+ s.get(remote_filename, local_path)
+ return
+ log.Log(_("MultiBackend: failed to get %s to %s from %s")
+ % (remote_filename, local_path, s.backend.parsed_url.url_string),
+ log.INFO)
+ log.Log(_("MultiBackend: failed to get %s. Tried all backing stores and none succeeded")
+ % (remote_filename),
+ log.ERROR)
+ raise BackendException("failed to get")
+
+ def _list(self):
+ lists = []
+ for s in self.__stores:
+ l = s.list()
+ log.Log(_("MultiBackend: list from %s: %s")
+ % (s.backend.parsed_url.url_string, l),
+ log.DEBUG)
+ lists.append(s.list())
+ # combine the lists into a single flat list:
+ result = [item for sublist in lists for item in sublist]
+ log.Log(_("MultiBackend: combined list: %s")
+ % (result),
+ log.DEBUG)
+ return result
+
+ def _delete(self, filename):
+ # since the backend operations will be retried, we can't
+ # simply try to get from the store, if not found, move to the
+ # next store (since each failure will be retried n times
+ # before finally giving up). So we need to get the list first
+ # before we try to delete
+ # ENHANCEME: maintain a cached list for each store
+ for s in self.__stores:
+ list = s.list()
+ if filename in list:
+ s._do_delete(filename)
+ return
+ log.Log(_("MultiBackend: failed to delete %s from %s")
+ % (filename, s.backend.parsed_url.url_string),
+ log.INFO)
+ log.Log(_("MultiBackend: failed to delete %s. Tried all backing stores and none succeeded")
+ % (filename),
+ log.ERROR)
+# raise BackendException("failed to delete")
+
+duplicity.backend.register_backend('multi', MultiBackend)
+
Follow ups