Merge lp:~matthew-t-bentley/duplicity/b2 into lp:~duplicity-team/duplicity/0.7-series
- b2
- Merge into 0.7-series
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 |
Related bugs: |
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.
Commit message
Description of the change
Adds a backed for BackBlaze's (currently beta) B2 backup service.
This adds backends/
edso (ed.so) wrote : Posted in a previous version of this proposal | # |
edso (ed.so) wrote : Posted in a previous version of this proposal | # |
manpage
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.
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
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.
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:/
> You are subscribed to branch lp:duplicity.
>
Matthew Bentley (matthew-t-bentley) wrote : | # |
Fixed changes to po files.
Kenneth Loafman (kenneth-loafman) wrote : | # |
@edso, is this ready to go?
edso (ed.so) : | # |
edso (ed.so) wrote : | # |
no objections from my side.
https:/
..ede
Preview Diff
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 |
please add backend documentation to the manpage (sections Requirements, Url Formats etc.)
bin/duplicity.1
..ede/duply.net