duplicity-team team mailing list archive
-
duplicity-team team
-
Mailing list archive
-
Message #03278
[Merge] lp:~matthew-t-bentley/duplicity/b2 into lp:duplicity
Matthew Bentley has proposed merging lp:~matthew-t-bentley/duplicity/b2 into lp:duplicity.
Requested reviews:
duplicity-team (duplicity-team)
For more details, see:
https://code.launchpad.net/~matthew-t-bentley/duplicity/b2/+merge/279363
Adds a backed for BackBlaze's (currently beta) B2 backup service.
This adds backends/b2backend.py, modifies log.py to add an error code and modifies commandline.py to add the b2:// example to the help text.
--
Your team duplicity-team is requested to review the proposed merge of lp:~matthew-t-bentley/duplicity/b2 into lp:duplicity.
=== added file 'duplicity/backends/b2backend.py'
--- duplicity/backends/b2backend.py 1970-01-01 00:00:00 +0000
+++ duplicity/backends/b2backend.py 2015-12-02 21:59:48 +0000
@@ -0,0 +1,340 @@
+#
+# Copyright (c) 2015 Matthew Bentley
+#
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+import os
+import hashlib
+
+import duplicity.backend
+from duplicity.errors import BackendException, FatalBackendException
+
+import json
+import urllib2
+import base64
+
+
+class B2Backend(duplicity.backend.Backend):
+ """
+ Backend for BackBlaze's B2 storage service
+ """
+
+ def __init__(self, parsed_url):
+ """
+ Authorize to B2 api and set up needed variables
+ """
+ duplicity.backend.Backend.__init__(self, parsed_url)
+
+ self.account_id = parsed_url.username
+ account_key = self.get_password()
+
+ self.url_parts = [
+ x for x in parsed_url.path.replace("@", "/").split('/') if x != ''
+ ]
+ if self.url_parts:
+ self.username = self.url_parts.pop(0)
+ self.bucket_name = self.url_parts.pop(0)
+ else:
+ raise BackendException("B2 requires a bucket name")
+ self.path = "/".join(self.url_parts)
+
+ id_and_key = self.account_id + ":" + account_key
+ basic_auth_string = 'Basic ' + base64.b64encode(id_and_key)
+ headers = {'Authorization': basic_auth_string}
+
+ request = urllib2.Request(
+ 'https://api.backblaze.com/b2api/v1/b2_authorize_account',
+ headers=headers
+ )
+
+ response = urllib2.urlopen(request)
+ response_data = json.loads(response.read())
+ response.close()
+
+ self.auth_token = response_data['authorizationToken']
+ self.api_url = response_data['apiUrl']
+ self.download_url = response_data['downloadUrl']
+
+ try:
+ self.find_or_create_bucket(self.bucket_name)
+ except urllib2.HTTPError:
+ raise FatalBackendException("Bucket cannot be created")
+
+ def _get(self, remote_filename, local_path):
+ """
+ Download remote_filename to local_path
+ """
+ remote_filename = self.full_filename(remote_filename)
+ url = self.download_url + \
+ '/file/' + self.bucket_name + '/' + \
+ remote_filename
+ resp = self.get_or_post(url, None)
+
+ to_file = open(local_path.name, 'wb')
+ to_file.write(resp)
+ to_file.close()
+
+ def _put(self, source_path, remote_filename):
+ """
+ Copy source_path to remote_filename
+ """
+ self._delete(remote_filename)
+ digest = self.hex_sha1_of_file(source_path)
+ content_type = 'application/pgp-encrypted'
+ remote_filename = self.full_filename(remote_filename)
+
+ info = self.get_upload_info(self.bucket_id)
+ url = info['uploadUrl']
+
+ headers = {
+ 'Authorization': info['authorizationToken'],
+ 'X-Bz-File-Name': remote_filename,
+ 'Content-Type': content_type,
+ 'X-Bz-Content-Sha1': digest,
+ 'Content-Length': str(os.path.getsize(source_path.name)),
+ }
+ data_file = source_path.open()
+ self.get_or_post(url, None, headers, data_file=data_file)
+
+ def _list(self):
+ """
+ List files on remote server
+ """
+ endpoint = 'b2_list_file_names'
+ url = self.formatted_url(endpoint)
+ params = {
+ 'bucketId': self.bucket_id,
+ 'maxFileCount': 1000,
+ }
+ try:
+ resp = self.get_or_post(url, params)
+ except urllib2.HTTPError:
+ return []
+
+ files = [x['fileName'].split('/')[-1] for x in resp['files']]
+
+ next_file = resp['nextFileName']
+ while next_file:
+ params['startFileName'] = next_file
+ try:
+ resp = self.get_or_post(url, params)
+ except urllib2.HTTPError:
+ return files
+
+ files += [x['fileName'].split('/')[-1] for x in resp['files']]
+ next_file = resp['nextFileName']
+
+ return files
+
+ def _delete(self, filename):
+ """
+ Delete filename from remote server
+ """
+ endpoint = 'b2_delete_file_version'
+ url = self.formatted_url(endpoint)
+ fileid = self.get_file_id(filename)
+ if fileid is None:
+ return
+ filename = self.full_filename(filename)
+ params = {'fileName': filename, 'fileId': fileid}
+ try:
+ self.get_or_post(url, params)
+ except urllib2.HTTPError as e:
+ if e.code == 400:
+ return
+ else:
+ raise e
+
+ def _query(self, filename):
+ """
+ Get size info of filename
+ """
+ info = self.get_file_info(filename)
+ if not info:
+ return {'size': -1}
+
+ return {'size': info['size']}
+
+ def _error_code(self, operation, e):
+ if isinstance(e, urllib2.HTTPError):
+ if e.code == 400:
+ return log.ErrorCode.bad_request
+ if e.code == 500:
+ return log.ErrorCode.backed_error
+ if e.code == 403:
+ return log.ErrorCode.backed_permission_denied
+
+ def find_or_create_bucket(self, bucket_name):
+ """
+ Find a bucket with name bucket_name and save its id.
+ If it doesn't exist, create it
+ """
+ endpoint = 'b2_list_buckets'
+ url = self.formatted_url(endpoint)
+
+ params = {'accountId': self.account_id}
+ resp = self.get_or_post(url, params)
+
+ bucket_names = [x['bucketName'] for x in resp['buckets']]
+
+ if bucket_name not in bucket_names:
+ self.create_bucket(bucket_name)
+ else:
+ self.bucket_id = {
+ x[
+ 'bucketName'
+ ]: x['bucketId'] for x in resp['buckets']
+ }[bucket_name]
+
+ def create_bucket(self, bucket_name):
+ """
+ Create a bucket with name bucket_name and save its id
+ """
+ endpoint = 'b2_create_bucket'
+ url = self.formatted_url(endpoint)
+ params = {
+ 'accountId': self.account_id,
+ 'bucketName': bucket_name,
+ 'bucketType': 'allPrivate'
+ }
+ resp = self.get_or_post(url, params)
+
+ self.bucket_id = resp['bucketId']
+
+ def formatted_url(self, endpoint):
+ """
+ Return the full api endpoint from just the last part
+ """
+ return '%s/b2api/v1/%s' % (self.api_url, endpoint)
+
+ def get_upload_info(self, bucket_id):
+ """
+ Get an upload url for a bucket
+ """
+ endpoint = 'b2_get_upload_url'
+ url = self.formatted_url(endpoint)
+ return self.get_or_post(url, {'bucketId': bucket_id})
+
+ def get_or_post(self, url, data, headers=None, data_file=None):
+ """
+ Sends the request, either get or post.
+ If data and data_file are None, send a get request.
+ data_file takes precedence over data.
+ If headers are not supplied, just send with an auth key
+ """
+ if headers is None:
+ headers = {'Authorization': self.auth_token}
+ if data_file is not None:
+ data = data_file
+ else:
+ data = json.dumps(data) if data else None
+
+ encoded_headers = dict(
+ (k, urllib2.quote(v.encode('utf-8')))
+ for (k, v) in headers.iteritems()
+ )
+
+ with OpenUrl(url, data, encoded_headers) as resp:
+ out = resp.read()
+ try:
+ return json.loads(out)
+ except ValueError:
+ return out
+
+ def get_file_info(self, filename):
+ """
+ Get a file info from filename
+ """
+ endpoint = 'b2_list_file_names'
+ url = self.formatted_url(endpoint)
+ filename = self.full_filename(filename)
+ params = {
+ 'bucketId': self.bucket_id,
+ 'maxFileCount': 1,
+ 'startFileName': filename,
+ }
+ resp = self.get_or_post(url, params)
+
+ try:
+ return resp['files'][0]
+ except IndexError:
+ return None
+ except TypeError:
+ return None
+
+ def get_file_id(self, filename):
+ """
+ Get a file id form filename
+ """
+ try:
+ return self.get_file_info(filename)['fileId']
+ except IndexError:
+ return None
+ except TypeError:
+ return None
+
+ def full_filename(self, filename):
+ if self.path:
+ return self.path + '/' + filename
+ else:
+ return filename
+
+ @staticmethod
+ def hex_sha1_of_file(path):
+ """
+ Calculate the sha1 of a file to upload
+ """
+ f = path.open()
+ block_size = 1024 * 1024
+ digest = hashlib.sha1()
+ while True:
+ data = f.read(block_size)
+ if len(data) == 0:
+ break
+ digest.update(data)
+ f.close()
+ return digest.hexdigest()
+
+
+class OpenUrl(object):
+ """
+ Context manager that handles an open urllib2.Request, and provides
+ the file-like object that is the response.
+ """
+
+ def __init__(self, url, data, headers):
+ self.url = url
+ self.data = data
+ self.headers = headers
+ self.file = None
+
+ def __enter__(self):
+ request = urllib2.Request(self.url, self.data, self.headers)
+ self.file = urllib2.urlopen(request)
+ return self.file
+
+ def __exit__(self, exception_type, exception, traceback):
+ if self.file is not None:
+ self.file.close()
+
+
+duplicity.backend.register_backend("b2", B2Backend)
=== modified file 'duplicity/commandline.py'
--- duplicity/commandline.py 2015-10-10 00:02:35 +0000
+++ duplicity/commandline.py 2015-12-02 21:59:48 +0000
@@ -910,6 +910,7 @@
dpbx:///%(some_dir)s
onedrive://%(some_dir)s
azure://%(container_name)s
+ b2://%(user)s@%(bucket_name)s/[%(some_dir)s/]
""" % dict
=== modified file 'duplicity/log.py'
--- duplicity/log.py 2015-05-08 12:28:47 +0000
+++ duplicity/log.py 2015-12-02 21:59:48 +0000
@@ -307,6 +307,8 @@
dpbx_nologin = 47
+ bad_request = 48
+
# 50->69 reserved for backend errors
backend_error = 50
backend_permission_denied = 51
Follow ups