# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright 2015 Steve Tynor <steve.tynor@gmail.com>
# Copyright 2016 Thomas Harning Jr <harningt@gmail.com>
# - mirror/stripe modes
# - write error modes
#
# 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 urlparse
import json
import duplicity.backend
from duplicity.errors import BackendException
from duplicity import log
[docs]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 = []
# Set of known query paramaters
__knownQueryParameters = frozenset([
'mode',
'onfail',
])
# the mode of operation to follow
# can be one of 'stripe' or 'mirror' currently
__mode = 'stripe'
__mode_allowedSet = frozenset([
'mirror',
'stripe',
])
# the write error handling logic
# can be one of the following:
# * continue - default, on failure continues to next source
# * abort - stop all further operations
__onfail_mode = 'continue'
__onfail_mode_allowedSet = frozenset([
'abort',
'continue',
])
# when we write in stripe mode, 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
@staticmethod
[docs] def get_query_params(parsed_url):
# Reparse so the query string is available
reparsed_url = urlparse.urlparse(parsed_url.geturl())
if len(reparsed_url.query) == 0:
return dict()
try:
queryMultiDict = urlparse.parse_qs(reparsed_url.query, strict_parsing=True)
except ValueError as e:
log.Log(_("MultiBackend: Could not parse query string %s: %s ")
% (reparsed_url.query, e),
log.ERROR)
raise BackendException('Could not parse query string')
queryDict = dict()
# Convert the multi-dict to a single dictionary
# while checking to make sure that no unrecognized values are found
for name, valueList in queryMultiDict.items():
if len(valueList) != 1:
log.Log(_("MultiBackend: Invalid query string %s: more than one value for %s")
% (reparsed_url.query, name),
log.ERROR)
raise BackendException('Invalid query string')
if name not in MultiBackend.__knownQueryParameters:
log.Log(_("MultiBackend: Invalid query string %s: unknown parameter %s")
% (reparsed_url.query, name),
log.ERROR)
raise BackendException('Invalid query string')
queryDict[name] = valueList[0]
return queryDict
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@domain.com/backup",
# "env": [
# {
# "name" : "MYENV",
# "value" : "xyz"
# },
# {
# "name" : "FOO",
# "value" : "bar"
# }
# ]
# },
# {
# "url": "file:///path/to/dir"
# }
# ]
queryParams = MultiBackend.get_query_params(parsed_url)
if 'mode' in queryParams:
self.__mode = queryParams['mode']
if 'onfail' in queryParams:
self.__onfail_mode = queryParams['onfail']
if self.__mode not in MultiBackend.__mode_allowedSet:
log.Log(_("MultiBackend: illegal value for %s: %s")
% ('mode', self.__mode), log.ERROR)
raise BackendException("MultiBackend: invalid mode value")
if self.__onfail_mode not in MultiBackend.__onfail_mode_allowedSet:
log.Log(_("MultiBackend: illegal value for %s: %s")
% ('onfail', self.__onfail_mode), log.ERROR)
raise BackendException("MultiBackend: invalid onfail value")
try:
with open(parsed_url.path) as f:
configs = json.load(f)
except IOError as e:
log.Log(_("MultiBackend: Url %s")
% (parsed_url.geturl()),
log.ERROR)
log.Log(_("MultiBackend: 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']
# Fix advised in bug #1471795
url = url.encode('utf-8')
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):
# Store an indication of whether any of these passed
passed = False
# Mirror mode always starts at zero
if self.__mode == 'mirror':
self.__write_cursor = 0
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)
passed = True
self.__write_cursor = next
# No matter what, if we loop around, break this loop
if next == 0:
break
# If in stripe mode, don't continue to the next
if self.__mode == 'stripe':
break
except Exception as 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 we consider write failure as abort, abort
if self.__onfail_mode == 'abort':
log.Log(_("MultiBackend: failed to write %s. Aborting process.")
% (source_path),
log.ERROR)
raise BackendException("failed to write")
# If we've looped around, and none of them passed, fail
if (self.__write_cursor == first) and not passed:
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 w/o duplicates via set:
result = list({item for sublist in lists for item in sublist})
log.Log(_("MultiBackend: combined list: %s")
% (result),
log.DEBUG)
return result
def _delete(self, filename):
# Store an indication on whether any passed
passed = False
# 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)
passed = True
# In stripe mode, only one item will have the file
if self.__mode == 'stripe':
return
log.Log(_("MultiBackend: failed to delete %s from %s")
% (filename, s.backend.parsed_url.url_string),
log.INFO)
if not passed:
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)