Merge lp:~matthew-t-bentley/duplicity/b2 into lp:~duplicity-team/duplicity/0.7-series
- b2
- Merge into 0.7-series
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 |
Related bugs: |
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.
Commit message
Description of the change
Adds a backed for BackBlaze's (currently beta) B2 backup service.
This adds backends/
edso (ed.so) wrote : | # |
- 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
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 |
please add backend documentation to the manpage (sections Requirements, Url Formats etc.)
bin/duplicity.1
..ede/duply.net