Merge lp:~matthew-t-bentley/duplicity/b2 into lp:~duplicity-team/duplicity/0.7-series

Proposed by Matthew Bentley
Status: Superseded
Proposed branch: lp:~matthew-t-bentley/duplicity/b2
Merge into: lp:~duplicity-team/duplicity/0.7-series
Diff against target: 369 lines (+343/-0)
3 files modified
duplicity/backends/b2backend.py (+340/-0)
duplicity/commandline.py (+1/-0)
duplicity/log.py (+2/-0)
To merge this branch: bzr merge lp:~matthew-t-bentley/duplicity/b2
Reviewer Review Type Date Requested Status
edso Needs Fixing
Review via email: mp+279363@code.launchpad.net

This proposal has been superseded by a proposal from 2015-12-03.

Description of the change

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.

To post a comment you must log in.
Revision history for this message
edso (ed.so) wrote :

please add backend documentation to the manpage (sections Requirements, Url Formats etc.)
 bin/duplicity.1

..ede/duply.net

Revision history for this message
edso (ed.so) wrote :

manpage

review: Needs Fixing
lp:~matthew-t-bentley/duplicity/b2 updated
1157. By Matthew Bentley

Documentation

1158. By Matthew Bentley

Remove unnecessary requirements section in manpage

1159. By Matthew Bentley

Undo changes to po files

1160. By Matthew Bentley

Merge from upstream

1161. By Matthew Bentley

Fix missing import and typos

1162. By Matthew Bentley

Merge and re-add missing error

1163. By Matthew Bentley

Allow multiple backups in the same bucket

1164. By Matthew Bentley

Add debugging for b2backend

1165. By Matthew Bentley

Set fake (otherwise unused) hostname for prettier password prompt
Thanks to Andreas Knab <email address hidden> for the patch

1166. By Matthew Bentley

Make sure listed files are exactly in the requested path
Thanks to Andreas Knab <email address hidden> for the pull request

Unmerged revisions

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'duplicity/backends/b2backend.py'
2--- duplicity/backends/b2backend.py 1970-01-01 00:00:00 +0000
3+++ duplicity/backends/b2backend.py 2015-12-02 21:59:48 +0000
4@@ -0,0 +1,340 @@
5+#
6+# Copyright (c) 2015 Matthew Bentley
7+#
8+#
9+# Permission is hereby granted, free of charge, to any person obtaining a copy
10+# of this software and associated documentation files (the "Software"), to deal
11+# in the Software without restriction, including without limitation the rights
12+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13+# copies of the Software, and to permit persons to whom the Software is
14+# furnished to do so, subject to the following conditions:
15+#
16+#
17+# The above copyright notice and this permission notice shall be included in
18+# all copies or substantial portions of the Software.
19+#
20+#
21+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
27+# THE SOFTWARE.
28+
29+import os
30+import hashlib
31+
32+import duplicity.backend
33+from duplicity.errors import BackendException, FatalBackendException
34+
35+import json
36+import urllib2
37+import base64
38+
39+
40+class B2Backend(duplicity.backend.Backend):
41+ """
42+ Backend for BackBlaze's B2 storage service
43+ """
44+
45+ def __init__(self, parsed_url):
46+ """
47+ Authorize to B2 api and set up needed variables
48+ """
49+ duplicity.backend.Backend.__init__(self, parsed_url)
50+
51+ self.account_id = parsed_url.username
52+ account_key = self.get_password()
53+
54+ self.url_parts = [
55+ x for x in parsed_url.path.replace("@", "/").split('/') if x != ''
56+ ]
57+ if self.url_parts:
58+ self.username = self.url_parts.pop(0)
59+ self.bucket_name = self.url_parts.pop(0)
60+ else:
61+ raise BackendException("B2 requires a bucket name")
62+ self.path = "/".join(self.url_parts)
63+
64+ id_and_key = self.account_id + ":" + account_key
65+ basic_auth_string = 'Basic ' + base64.b64encode(id_and_key)
66+ headers = {'Authorization': basic_auth_string}
67+
68+ request = urllib2.Request(
69+ 'https://api.backblaze.com/b2api/v1/b2_authorize_account',
70+ headers=headers
71+ )
72+
73+ response = urllib2.urlopen(request)
74+ response_data = json.loads(response.read())
75+ response.close()
76+
77+ self.auth_token = response_data['authorizationToken']
78+ self.api_url = response_data['apiUrl']
79+ self.download_url = response_data['downloadUrl']
80+
81+ try:
82+ self.find_or_create_bucket(self.bucket_name)
83+ except urllib2.HTTPError:
84+ raise FatalBackendException("Bucket cannot be created")
85+
86+ def _get(self, remote_filename, local_path):
87+ """
88+ Download remote_filename to local_path
89+ """
90+ remote_filename = self.full_filename(remote_filename)
91+ url = self.download_url + \
92+ '/file/' + self.bucket_name + '/' + \
93+ remote_filename
94+ resp = self.get_or_post(url, None)
95+
96+ to_file = open(local_path.name, 'wb')
97+ to_file.write(resp)
98+ to_file.close()
99+
100+ def _put(self, source_path, remote_filename):
101+ """
102+ Copy source_path to remote_filename
103+ """
104+ self._delete(remote_filename)
105+ digest = self.hex_sha1_of_file(source_path)
106+ content_type = 'application/pgp-encrypted'
107+ remote_filename = self.full_filename(remote_filename)
108+
109+ info = self.get_upload_info(self.bucket_id)
110+ url = info['uploadUrl']
111+
112+ headers = {
113+ 'Authorization': info['authorizationToken'],
114+ 'X-Bz-File-Name': remote_filename,
115+ 'Content-Type': content_type,
116+ 'X-Bz-Content-Sha1': digest,
117+ 'Content-Length': str(os.path.getsize(source_path.name)),
118+ }
119+ data_file = source_path.open()
120+ self.get_or_post(url, None, headers, data_file=data_file)
121+
122+ def _list(self):
123+ """
124+ List files on remote server
125+ """
126+ endpoint = 'b2_list_file_names'
127+ url = self.formatted_url(endpoint)
128+ params = {
129+ 'bucketId': self.bucket_id,
130+ 'maxFileCount': 1000,
131+ }
132+ try:
133+ resp = self.get_or_post(url, params)
134+ except urllib2.HTTPError:
135+ return []
136+
137+ files = [x['fileName'].split('/')[-1] for x in resp['files']]
138+
139+ next_file = resp['nextFileName']
140+ while next_file:
141+ params['startFileName'] = next_file
142+ try:
143+ resp = self.get_or_post(url, params)
144+ except urllib2.HTTPError:
145+ return files
146+
147+ files += [x['fileName'].split('/')[-1] for x in resp['files']]
148+ next_file = resp['nextFileName']
149+
150+ return files
151+
152+ def _delete(self, filename):
153+ """
154+ Delete filename from remote server
155+ """
156+ endpoint = 'b2_delete_file_version'
157+ url = self.formatted_url(endpoint)
158+ fileid = self.get_file_id(filename)
159+ if fileid is None:
160+ return
161+ filename = self.full_filename(filename)
162+ params = {'fileName': filename, 'fileId': fileid}
163+ try:
164+ self.get_or_post(url, params)
165+ except urllib2.HTTPError as e:
166+ if e.code == 400:
167+ return
168+ else:
169+ raise e
170+
171+ def _query(self, filename):
172+ """
173+ Get size info of filename
174+ """
175+ info = self.get_file_info(filename)
176+ if not info:
177+ return {'size': -1}
178+
179+ return {'size': info['size']}
180+
181+ def _error_code(self, operation, e):
182+ if isinstance(e, urllib2.HTTPError):
183+ if e.code == 400:
184+ return log.ErrorCode.bad_request
185+ if e.code == 500:
186+ return log.ErrorCode.backed_error
187+ if e.code == 403:
188+ return log.ErrorCode.backed_permission_denied
189+
190+ def find_or_create_bucket(self, bucket_name):
191+ """
192+ Find a bucket with name bucket_name and save its id.
193+ If it doesn't exist, create it
194+ """
195+ endpoint = 'b2_list_buckets'
196+ url = self.formatted_url(endpoint)
197+
198+ params = {'accountId': self.account_id}
199+ resp = self.get_or_post(url, params)
200+
201+ bucket_names = [x['bucketName'] for x in resp['buckets']]
202+
203+ if bucket_name not in bucket_names:
204+ self.create_bucket(bucket_name)
205+ else:
206+ self.bucket_id = {
207+ x[
208+ 'bucketName'
209+ ]: x['bucketId'] for x in resp['buckets']
210+ }[bucket_name]
211+
212+ def create_bucket(self, bucket_name):
213+ """
214+ Create a bucket with name bucket_name and save its id
215+ """
216+ endpoint = 'b2_create_bucket'
217+ url = self.formatted_url(endpoint)
218+ params = {
219+ 'accountId': self.account_id,
220+ 'bucketName': bucket_name,
221+ 'bucketType': 'allPrivate'
222+ }
223+ resp = self.get_or_post(url, params)
224+
225+ self.bucket_id = resp['bucketId']
226+
227+ def formatted_url(self, endpoint):
228+ """
229+ Return the full api endpoint from just the last part
230+ """
231+ return '%s/b2api/v1/%s' % (self.api_url, endpoint)
232+
233+ def get_upload_info(self, bucket_id):
234+ """
235+ Get an upload url for a bucket
236+ """
237+ endpoint = 'b2_get_upload_url'
238+ url = self.formatted_url(endpoint)
239+ return self.get_or_post(url, {'bucketId': bucket_id})
240+
241+ def get_or_post(self, url, data, headers=None, data_file=None):
242+ """
243+ Sends the request, either get or post.
244+ If data and data_file are None, send a get request.
245+ data_file takes precedence over data.
246+ If headers are not supplied, just send with an auth key
247+ """
248+ if headers is None:
249+ headers = {'Authorization': self.auth_token}
250+ if data_file is not None:
251+ data = data_file
252+ else:
253+ data = json.dumps(data) if data else None
254+
255+ encoded_headers = dict(
256+ (k, urllib2.quote(v.encode('utf-8')))
257+ for (k, v) in headers.iteritems()
258+ )
259+
260+ with OpenUrl(url, data, encoded_headers) as resp:
261+ out = resp.read()
262+ try:
263+ return json.loads(out)
264+ except ValueError:
265+ return out
266+
267+ def get_file_info(self, filename):
268+ """
269+ Get a file info from filename
270+ """
271+ endpoint = 'b2_list_file_names'
272+ url = self.formatted_url(endpoint)
273+ filename = self.full_filename(filename)
274+ params = {
275+ 'bucketId': self.bucket_id,
276+ 'maxFileCount': 1,
277+ 'startFileName': filename,
278+ }
279+ resp = self.get_or_post(url, params)
280+
281+ try:
282+ return resp['files'][0]
283+ except IndexError:
284+ return None
285+ except TypeError:
286+ return None
287+
288+ def get_file_id(self, filename):
289+ """
290+ Get a file id form filename
291+ """
292+ try:
293+ return self.get_file_info(filename)['fileId']
294+ except IndexError:
295+ return None
296+ except TypeError:
297+ return None
298+
299+ def full_filename(self, filename):
300+ if self.path:
301+ return self.path + '/' + filename
302+ else:
303+ return filename
304+
305+ @staticmethod
306+ def hex_sha1_of_file(path):
307+ """
308+ Calculate the sha1 of a file to upload
309+ """
310+ f = path.open()
311+ block_size = 1024 * 1024
312+ digest = hashlib.sha1()
313+ while True:
314+ data = f.read(block_size)
315+ if len(data) == 0:
316+ break
317+ digest.update(data)
318+ f.close()
319+ return digest.hexdigest()
320+
321+
322+class OpenUrl(object):
323+ """
324+ Context manager that handles an open urllib2.Request, and provides
325+ the file-like object that is the response.
326+ """
327+
328+ def __init__(self, url, data, headers):
329+ self.url = url
330+ self.data = data
331+ self.headers = headers
332+ self.file = None
333+
334+ def __enter__(self):
335+ request = urllib2.Request(self.url, self.data, self.headers)
336+ self.file = urllib2.urlopen(request)
337+ return self.file
338+
339+ def __exit__(self, exception_type, exception, traceback):
340+ if self.file is not None:
341+ self.file.close()
342+
343+
344+duplicity.backend.register_backend("b2", B2Backend)
345
346=== modified file 'duplicity/commandline.py'
347--- duplicity/commandline.py 2015-10-10 00:02:35 +0000
348+++ duplicity/commandline.py 2015-12-02 21:59:48 +0000
349@@ -910,6 +910,7 @@
350 dpbx:///%(some_dir)s
351 onedrive://%(some_dir)s
352 azure://%(container_name)s
353+ b2://%(user)s@%(bucket_name)s/[%(some_dir)s/]
354
355 """ % dict
356
357
358=== modified file 'duplicity/log.py'
359--- duplicity/log.py 2015-05-08 12:28:47 +0000
360+++ duplicity/log.py 2015-12-02 21:59:48 +0000
361@@ -307,6 +307,8 @@
362
363 dpbx_nologin = 47
364
365+ bad_request = 48
366+
367 # 50->69 reserved for backend errors
368 backend_error = 50
369 backend_permission_denied = 51

Subscribers

People subscribed via source and target branches