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

Proposed by Matthew Bentley
Status: Merged
Merged at revision: 1157
Proposed branch: lp:~matthew-t-bentley/duplicity/b2
Merge into: lp:~duplicity-team/duplicity/0.7-series
Diff against target: 402 lines (+357/-1)
4 files modified
bin/duplicity.1 (+6/-0)
duplicity/backends/b2backend.py (+340/-0)
duplicity/commandline.py (+9/-1)
duplicity/log.py (+2/-0)
To merge this branch: bzr merge lp:~matthew-t-bentley/duplicity/b2
Reviewer Review Type Date Requested Status
edso Approve
Review via email: mp+279453@code.launchpad.net

This proposal supersedes 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, modifies commandline.py to add the b2:// example to the help text, and updates the manpage to reflect these changes.

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

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 : Posted in a previous version of this proposal

manpage

review: Needs Fixing
Revision history for this message
Matthew Bentley (matthew-t-bentley) wrote : Posted in a previous version of this proposal

I added the necessary pieces to the manpage and updated en_GB.po for the additions to commandline.py.

Revision history for this message
edso (ed.so) wrote : Posted in a previous version of this proposal

isn't urllib2 a standard python library being distributed by default?

Requirements usually lists components needed in addition to python2.6/2.7.

..ede

Revision history for this message
Matthew Bentley (matthew-t-bentley) wrote : Posted in a previous version of this proposal

That is true. There are no other dependencies outside of python, so I removed B2 from the requirements section.

Revision history for this message
Kenneth Loafman (kenneth-loafman) wrote : Posted in a previous version of this proposal

One thing to note, po/duplicity.pot and po/en_GB.po are all handled
automatically with translation tools. Please remove these changes from the
merge. They will be generated automatically by the tools and Launchpad
Translation Services.

On Thu, Dec 3, 2015 at 8:33 AM, Matthew Bentley <email address hidden>
wrote:

> That is true. There are no other dependencies outside of python, so I
> removed B2 from the requirements section.
> --
> https://code.launchpad.net/~matthew-t-bentley/duplicity/b2/+merge/279445
> You are subscribed to branch lp:duplicity.
>

Revision history for this message
Matthew Bentley (matthew-t-bentley) wrote :

Fixed changes to po files.

Revision history for this message
Kenneth Loafman (kenneth-loafman) wrote :

@edso, is this ready to go?

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

no objections from my side.
 https://www.youtube.com/watch?v=3jd1Ih8EUmw#t=8 :))

..ede

Preview Diff

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

Subscribers

People subscribed via source and target branches