%PDF- %PDF-
| Direktori : /lib/python3/dist-packages/duplicity/backends/ |
| Current File : //lib/python3/dist-packages/duplicity/backends/multibackend.py |
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4; encoding:utf-8 -*-
#
# 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 json
import os
import os.path
import urllib.error
import urllib.parse
import urllib.request
import duplicity.backend
from duplicity import config
from duplicity import log
from duplicity import util
from duplicity.errors import BackendException
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 = []
__affinities = {}
# Set of known query paramaters
__knownQueryParameters = frozenset(
[
"mode",
"onfail",
"subpath",
]
)
# 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",
]
)
# sub path to dynamically add sub directories to backends
# will be appended to the url value
__subpath = ""
# 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
def get_query_params(parsed_url):
# Reparse so the query string is available
reparsed_url = urllib.parse.urlparse(parsed_url.geturl())
if len(reparsed_url.query) == 0:
return dict()
try:
queryMultiDict = urllib.parse.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 list(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")
if "subpath" in queryParams:
self.__subpath = queryParams["subpath"]
try:
with open(parsed_url.path) as f:
configs = json.load(f)
except IOError as e:
log.Log(_("MultiBackend: Url %s") % (parsed_url.strip_auth()), 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"] + self.__subpath
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)
# Prefix affinity
if "prefixes" in config:
if self.__mode == "stripe":
raise BackendException("Multibackend: stripe mode not supported with prefix affinity.")
for prefix in config["prefixes"]:
log.Log(
_("Multibackend: register affinity for prefix %s") % prefix,
log.INFO,
)
if prefix in self.__affinities:
self.__affinities[prefix].append(store)
else:
self.__affinities[prefix] = [store]
# store_list = store.list()
# log.Log(_("MultiBackend: at init, store %s has %s files")
# % (url, len(store_list)),
# log.INFO)
def _eligible_stores(self, filename):
if self.__affinities:
matching_prefixes = [k for k in list(self.__affinities.keys()) if os.fsdecode(filename).startswith(k)]
matching_stores = {store for prefix in matching_prefixes for store in self.__affinities[prefix]}
if matching_stores:
# Distinct stores with matching prefix
return list(matching_stores)
# No affinity rule or no matching store for that prefix
return self.__stores
def _put(self, source_path, remote_filename):
# Store an indication of whether any of these passed
passed = False
# Eligibile stores for this action
stores = self._eligible_stores(remote_filename)
# Mirror mode always starts at zero
if self.__mode == "mirror":
self.__write_cursor = 0
first = self.__write_cursor
while True:
store = stores[self.__write_cursor]
try:
next = self.__write_cursor + 1 # pylint: disable=redefined-builtin
if next > len(stores) - 1:
next = 0
log.Log(
_("MultiBackend: _put: write to store #%s (%s)")
% (self.__write_cursor, store.backend.parsed_url.strip_auth()),
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.strip_auth(),
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
stores = self._eligible_stores(remote_filename)
for s in stores:
flist = s.list()
if remote_filename in flist:
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.strip_auth()),
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:
config.are_errors_fatal["list"] = (False, [])
l = s.list()
log.Notice(_("MultiBackend: %s: %d files") % (s.backend.parsed_url.strip_auth(), len(l)))
if len(l) == 0 and duplicity.backend._last_exception:
log.Warn(
_(
f"Exception during list of {s.backend.parsed_url.strip_auth()}: "
f"{util.uexc(duplicity.backend._last_exception)}"
)
)
duplicity.backend._last_exception = None
lists.append(l)
# 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
stores = self._eligible_stores(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 stores:
flist = s.list()
if filename in flist:
if hasattr(s.backend, "_delete_list"):
s._do_delete_list(
[
filename,
]
)
elif hasattr(s.backend, "_delete"):
s._do_delete(filename)
passed = True
# In stripe mode, only one item will have the file
if self.__mode == "stripe":
return
if not passed:
log.Log(
_("MultiBackend: failed to delete %s. Tried all backing stores and none succeeded") % filename,
log.ERROR,
)
def _delete_list(self, filenames):
# Store an indication on whether any passed
passed = False
stores = self.__stores
# 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 stores:
flist = s.list()
cleaned = [f for f in filenames if f in flist]
if hasattr(s.backend, "_delete_list"):
s._do_delete_list(cleaned)
elif hasattr(s.backend, "_delete"):
for filename in cleaned:
s._do_delete(filename)
passed = True
# In stripe mode, only one item will have the file
if self.__mode == "stripe":
return
if not passed:
log.Log(
_("MultiBackend: failed to delete %s. Tried all backing stores and none succeeded") % filenames,
log.ERROR,
)
def pre_process_download(self, filename):
for store in self.__stores:
if hasattr(store.backend, "pre_process_download"):
store.backend.pre_process_download(filename)
def pre_process_download_batch(self, filenames):
set_files = set(filenames)
for store in self.__stores:
if hasattr(store.backend, "pre_process_download_batch"):
store_files_to_download = set_files.intersection(store.list())
if len(store_files_to_download) > 0:
store.backend.pre_process_download_batch(store_files_to_download)
duplicity.backend.register_backend("multi", MultiBackend)