Merge lp:~ed.so/duplicity/webdav.fix-retry into lp:duplicity/0.6
- webdav.fix-retry
- Merge into 0.6-series
Status: | Merged |
---|---|
Merged at revision: | 904 |
Proposed branch: | lp:~ed.so/duplicity/webdav.fix-retry |
Merge into: | lp:duplicity/0.6 |
Diff against target: |
442 lines (+184/-48) 5 files modified
duplicity/backend.py (+25/-15) duplicity/backends/webdavbackend.py (+144/-33) duplicity/commandline.py (+5/-0) duplicity/errors.py (+6/-0) duplicity/globals.py (+4/-0) |
To merge this branch: | bzr merge lp:~ed.so/duplicity/webdav.fix-retry |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
edso | Approve | ||
Review via email: mp+142759@code.launchpad.net |
Commit message
webdav
- added ssl certificate verification (see man page)
- more robust retry routine to survive ssl errors, broken pipe errors
- added http redirect support
Description of the change
webdav
- added ssl certificate verification (see man page)
- more robust retry routine to survive ssl errors, broken pipe errors
- added http redirect support
Kenneth Loafman (kenneth-loafman) wrote : | # |
edso (ed.so) wrote : | # |
would simply merging interim manpage changes in this branch solve this?
..ede
Kenneth Loafman (kenneth-loafman) wrote : | # |
Possibly, bzr is fairly fussy about merges.
...Ken
On Fri, Jan 11, 2013 at 4:51 AM, edso <email address hidden> wrote:
> would simply merging interim manpage changes in this branch solve this?
>
> ..ede
> --
> https:/
> You are subscribed to branch lp:duplicity.
>
- 904. By ede
-
move man page changes to a separate uptodate merge branch
edso (ed.so) wrote : | # |
merge problem should be removed
please merge manpage branch as well
Preview Diff
1 | === modified file 'duplicity/backend.py' |
2 | --- duplicity/backend.py 2012-12-12 17:45:38 +0000 |
3 | +++ duplicity/backend.py 2013-01-11 18:46:21 +0000 |
4 | @@ -42,7 +42,7 @@ |
5 | |
6 | from duplicity.util import exception_traceback |
7 | |
8 | -from duplicity.errors import BackendException |
9 | +from duplicity.errors import BackendException, FatalBackendError |
10 | from duplicity.errors import TemporaryLoadException |
11 | from duplicity.errors import ConflictingScheme |
12 | from duplicity.errors import InvalidBackendURL |
13 | @@ -333,26 +333,35 @@ |
14 | # as we don't know what the underlying code comes up with and we really *do* |
15 | # want to retry globals.num_retries times under all circumstances |
16 | def retry_fatal(fn): |
17 | - def iterate(*args): |
18 | - for n in range(1, globals.num_retries): |
19 | - try: |
20 | - return fn(*args) |
21 | - except Exception, e: |
22 | - log.Warn("Attempt %s failed. %s: %s" |
23 | - % (n, e.__class__.__name__, str(e))) |
24 | - log.Debug("Backtrace of previous error: %s" |
25 | - % exception_traceback()) |
26 | - time.sleep(10) # wait a bit before trying again |
27 | + def _retry_fatal(self, *args): |
28 | + try: |
29 | + n = 0 |
30 | + for n in range(1, globals.num_retries): |
31 | + try: |
32 | + self.retry_count = n |
33 | + return fn(self, *args) |
34 | + except FatalBackendError, e: |
35 | + # die on fatal errors |
36 | + raise e |
37 | + except Exception, e: |
38 | + # retry on anything else |
39 | + log.Warn("Attempt %s failed. %s: %s" |
40 | + % (n, e.__class__.__name__, str(e))) |
41 | + log.Debug("Backtrace of previous error: %s" |
42 | + % exception_traceback()) |
43 | + time.sleep(10) # wait a bit before trying again |
44 | # final trial, die on exception |
45 | - try: |
46 | - return fn(*args) |
47 | + self.retry_count = n+1 |
48 | + return fn(self, *args) |
49 | except Exception, e: |
50 | log.FatalError("Giving up after %s attempts. %s: %s" |
51 | - % (globals.num_retries, e.__class__.__name__, str(e)), |
52 | + % (self.retry_count, e.__class__.__name__, str(e)), |
53 | log.ErrorCode.backend_error) |
54 | log.Debug("Backtrace of previous error: %s" |
55 | % exception_traceback()) |
56 | - return iterate |
57 | + self.retry_count = 0 |
58 | + |
59 | + return _retry_fatal |
60 | |
61 | class Backend: |
62 | """ |
63 | @@ -371,6 +380,7 @@ |
64 | |
65 | - move |
66 | """ |
67 | + |
68 | def __init__(self, parsed_url): |
69 | self.parsed_url = parsed_url |
70 | |
71 | |
72 | === modified file 'duplicity/backends/webdavbackend.py' |
73 | --- duplicity/backends/webdavbackend.py 2012-12-09 14:36:19 +0000 |
74 | +++ duplicity/backends/webdavbackend.py 2013-01-11 18:46:21 +0000 |
75 | @@ -2,6 +2,8 @@ |
76 | # |
77 | # Copyright 2002 Ben Escoto <ben@emerose.org> |
78 | # Copyright 2007 Kenneth Loafman <kenneth@loafman.com> |
79 | +# Copyright 2013 Edgar Soldin |
80 | +# - ssl cert verification, some robustness enhancements |
81 | # |
82 | # This file is part of duplicity. |
83 | # |
84 | @@ -20,7 +22,7 @@ |
85 | # Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA |
86 | |
87 | import base64 |
88 | -import httplib |
89 | +import httplib, os |
90 | import re |
91 | import urllib |
92 | import urllib2 |
93 | @@ -46,6 +48,58 @@ |
94 | def get_method(self): |
95 | return self.method |
96 | |
97 | +class VerifiedHTTPSConnection(httplib.HTTPSConnection): |
98 | + def __init__(self, *args, **kwargs): |
99 | + try: |
100 | + global socket, ssl |
101 | + import socket, ssl |
102 | + except ImportError: |
103 | + raise FatalBackendError("Missing socket or ssl libraries.") |
104 | + |
105 | + httplib.HTTPSConnection.__init__(self, *args, **kwargs) |
106 | + |
107 | + self.cacert_file = globals.ssl_cacert_file |
108 | + cacert_candidates = [ "~/.duplicity/cacert.pem", \ |
109 | + "~/duplicity_cacert.pem", \ |
110 | + "/etc/duplicity/cacert.pem" ] |
111 | + # |
112 | + if not self.cacert_file: |
113 | + for path in cacert_candidates : |
114 | + path = os.path.expanduser(path) |
115 | + if (os.path.isfile(path)): |
116 | + self.cacert_file = path |
117 | + break |
118 | + # still no cacert file, inform user |
119 | + if not self.cacert_file: |
120 | + raise FatalBackendError("""For certificate verification a cacert database file is needed in one of these locations: %s |
121 | +Hints: |
122 | + Consult the man page, chapter 'SSL Certificate Verification'. |
123 | + Consider using the options --ssl-cacert-file, --ssl-no-check-certificate .""" % ", ".join(cacert_candidates) ) |
124 | + # check if file is accessible (libssl errors are not very detailed) |
125 | + if not os.access(self.cacert_file, os.R_OK): |
126 | + raise FatalBackendError("Cacert database file '%s' is not readable." % cacert_file) |
127 | + |
128 | + def connect(self): |
129 | + # create new socket |
130 | + sock = socket.create_connection((self.host, self.port), |
131 | + self.timeout) |
132 | + if self._tunnel_host: |
133 | + self.sock = sock |
134 | + self._tunnel() |
135 | + |
136 | + # wrap the socket in ssl using verification |
137 | + self.sock = ssl.wrap_socket(sock, |
138 | + cert_reqs=ssl.CERT_REQUIRED, |
139 | + ca_certs=self.cacert_file, |
140 | + ) |
141 | + |
142 | + def request(self, *args, **kwargs): |
143 | + try: |
144 | + return httplib.HTTPSConnection.request(self, *args, **kwargs) |
145 | + except ssl.SSLError, e: |
146 | + # encapsulate ssl errors |
147 | + raise BackendException("SSL failed: %s" % str(e),log.ErrorCode.backend_error) |
148 | + |
149 | |
150 | class WebDAVBackend(duplicity.backend.Backend): |
151 | """Backend for accessing a WebDAV repository. |
152 | @@ -70,23 +124,22 @@ |
153 | self.digest_challenge = None |
154 | self.digest_auth_handler = None |
155 | |
156 | - if parsed_url.path: |
157 | + self.username = parsed_url.username |
158 | + self.password = self.get_password() |
159 | + self.directory = self._sanitize_path(parsed_url.path) |
160 | + |
161 | + log.Info("Using WebDAV protocol %s" % (globals.webdav_proto,)) |
162 | + log.Info("Using WebDAV host %s port %s" % (parsed_url.hostname, parsed_url.port)) |
163 | + log.Info("Using WebDAV directory %s" % (self.directory,)) |
164 | + |
165 | + self.conn = None |
166 | + |
167 | + def _sanitize_path(self,path): |
168 | + if path: |
169 | foldpath = re.compile('/+') |
170 | - self.directory = foldpath.sub('/', parsed_url.path + '/' ) |
171 | - else: |
172 | - self.directory = '/' |
173 | - |
174 | - log.Info("Using WebDAV host %s" % (parsed_url.hostname,)) |
175 | - log.Info("Using WebDAV port %s" % (parsed_url.port,)) |
176 | - log.Info("Using WebDAV directory %s" % (self.directory,)) |
177 | - log.Info("Using WebDAV protocol %s" % (globals.webdav_proto,)) |
178 | - |
179 | - if parsed_url.scheme == 'webdav': |
180 | - self.conn = httplib.HTTPConnection(parsed_url.hostname, parsed_url.port) |
181 | - elif parsed_url.scheme == 'webdavs': |
182 | - self.conn = httplib.HTTPSConnection(parsed_url.hostname, parsed_url.port) |
183 | - else: |
184 | - raise BackendException("Unknown URI scheme: %s" % (parsed_url.scheme)) |
185 | + return foldpath.sub('/', path + '/' ) |
186 | + else: |
187 | + return '/' |
188 | |
189 | def _getText(self,nodelist): |
190 | rc = "" |
191 | @@ -94,29 +147,76 @@ |
192 | if node.nodeType == node.TEXT_NODE: |
193 | rc = rc + node.data |
194 | return rc |
195 | + |
196 | + def _connect(self, forced=False): |
197 | + """ |
198 | + Connect or re-connect to the server, updates self.conn |
199 | + # reconnect on errors as a precaution, there are errors e.g. |
200 | + # "[Errno 32] Broken pipe" or SSl errors that render the connection unusable |
201 | + """ |
202 | + if self.retry_count<=1 and self.conn \ |
203 | + and self.conn.host == self.parsed_url.hostname: return |
204 | + |
205 | + log.Info("WebDAV create connection on '%s' (retry %s) " % (self.parsed_url.hostname,self.retry_count) ) |
206 | + if self.conn: self.conn.close() |
207 | + # http schemes needed for redirect urls from servers |
208 | + if self.parsed_url.scheme in ['webdav','http']: |
209 | + self.conn = httplib.HTTPConnection(self.parsed_url.hostname, self.parsed_url.port) |
210 | + elif self.parsed_url.scheme in ['webdavs','https']: |
211 | + if globals.ssl_no_check_certificate: |
212 | + self.conn = httplib.HTTPSConnection(self.parsed_url.hostname, self.parsed_url.port) |
213 | + else: |
214 | + self.conn = VerifiedHTTPSConnection(self.parsed_url.hostname, self.parsed_url.port) |
215 | + else: |
216 | + raise FatalBackendError("WebDAV Unknown URI scheme: %s" % (self.parsed_url.scheme)) |
217 | |
218 | def close(self): |
219 | self.conn.close() |
220 | |
221 | - def request(self, method, path, data=None): |
222 | + def request(self, method, path, data=None, redirected=0): |
223 | """ |
224 | Wraps the connection.request method to retry once if authentication is |
225 | required |
226 | """ |
227 | - quoted_path = urllib.quote(path) |
228 | + self._connect() |
229 | + |
230 | + quoted_path = urllib.quote(path,"/:~") |
231 | |
232 | if self.digest_challenge is not None: |
233 | self.headers['Authorization'] = self.get_digest_authorization(path) |
234 | + |
235 | + log.Info("WebDAV %s %s request with headers: %s " % (method,quoted_path,self.headers)) |
236 | + log.Info("WebDAV data length: %s " % len(str(data)) ) |
237 | self.conn.request(method, quoted_path, data, self.headers) |
238 | response = self.conn.getresponse() |
239 | - if response.status == 401: |
240 | + log.Info("WebDAV response status %s with reason '%s'." % (response.status,response.reason)) |
241 | + # resolve redirects and reset url on listing requests (they usually come before everything else) |
242 | + if response.status in [301,302] and method == 'PROPFIND': |
243 | + redirect_url = response.getheader('location',None) |
244 | + response.close() |
245 | + if redirect_url: |
246 | + log.Notice("WebDAV redirect to: %s " % urllib.unquote(redirect_url) ) |
247 | + if redirected > 10: |
248 | + raise FatalBackendError("WebDAV redirected 10 times. Giving up.") |
249 | + self.parsed_url = duplicity.backend.ParsedUrl(redirect_url) |
250 | + self.directory = self._sanitize_path(self.parsed_url.path) |
251 | + return self.request(method,self.directory,data,redirected+1) |
252 | + else: |
253 | + raise FatalBackendError("WebDAV missing location header in redirect response.") |
254 | + elif response.status == 401: |
255 | response.close() |
256 | self.headers['Authorization'] = self.get_authorization(response, quoted_path) |
257 | + log.Info("WebDAV retry request with authentification headers.") |
258 | + log.Info("WebDAV %s %s request2 with headers: %s " % (method,quoted_path,self.headers)) |
259 | + log.Info("WebDAV data length: %s " % len(str(data)) ) |
260 | self.conn.request(method, quoted_path, data, self.headers) |
261 | response = self.conn.getresponse() |
262 | - |
263 | + log.Info("WebDAV response2 status %s with reason '%s'." % (response.status,response.reason)) |
264 | + |
265 | return response |
266 | |
267 | + |
268 | + |
269 | def get_authorization(self, response, path): |
270 | """ |
271 | Fetches the auth header based on the requested method (basic or digest) |
272 | @@ -139,7 +239,7 @@ |
273 | """ |
274 | Returns the basic auth header |
275 | """ |
276 | - auth_string = '%s:%s' % (self.parsed_url.username, self.get_password()) |
277 | + auth_string = '%s:%s' % (self.username, self.password) |
278 | return 'Basic %s' % base64.encodestring(auth_string).strip() |
279 | |
280 | def get_digest_authorization(self, path): |
281 | @@ -149,7 +249,7 @@ |
282 | u = self.parsed_url |
283 | if self.digest_auth_handler is None: |
284 | pw_manager = urllib2.HTTPPasswordMgrWithDefaultRealm() |
285 | - pw_manager.add_password(None, self.conn.host, u.username, self.get_password()) |
286 | + pw_manager.add_password(None, self.conn.host, self.username, self.password) |
287 | self.digest_auth_handler = urllib2.HTTPDigestAuthHandler(pw_manager) |
288 | |
289 | # building a dummy request that gets never sent, |
290 | @@ -165,6 +265,7 @@ |
291 | def list(self): |
292 | """List files in directory""" |
293 | log.Info("Listing directory %s on WebDAV server" % (self.directory,)) |
294 | + response = None |
295 | try: |
296 | self.headers['Depth'] = "1" |
297 | response = self.request("PROPFIND", self.directory, self.listbody) |
298 | @@ -178,7 +279,7 @@ |
299 | response.close() |
300 | # just created folder is so return empty |
301 | return [] |
302 | - elif response.status == 207: |
303 | + elif response.status in [200, 207]: |
304 | document = response.read() |
305 | response.close() |
306 | else: |
307 | @@ -195,9 +296,10 @@ |
308 | if filename: |
309 | result.append(filename) |
310 | return result |
311 | - except Exception, cause: |
312 | - e = BackendException("Listing directory %s on WebDAV server failed. %s" % (self.directory,cause)) |
313 | + except Exception, e: |
314 | raise e |
315 | + finally: |
316 | + if response: response.close() |
317 | |
318 | def __taste_href(self, href): |
319 | """ |
320 | @@ -241,11 +343,15 @@ |
321 | """Get remote filename, saving it to local_path""" |
322 | url = self.directory + remote_filename |
323 | log.Info("Retrieving %s from WebDAV server" % (url ,)) |
324 | + response = None |
325 | try: |
326 | target_file = local_path.open("wb") |
327 | response = self.request("GET", url) |
328 | if response.status == 200: |
329 | + #data=response.read() |
330 | target_file.write(response.read()) |
331 | + #import hashlib |
332 | + #log.Info("WebDAV GOT %s bytes with md5=%s" % (len(data),hashlib.md5(data).hexdigest()) ) |
333 | assert not target_file.close() |
334 | local_path.setdata() |
335 | response.close() |
336 | @@ -254,9 +360,10 @@ |
337 | reason = response.reason |
338 | response.close() |
339 | raise BackendException("Bad status code %s reason %s." % (status,reason)) |
340 | - except Exception, cause: |
341 | - e = BackendException("Getting %s from WebDAV server failed. %s" % (url,cause)) |
342 | + except Exception, e: |
343 | raise e |
344 | + finally: |
345 | + if response: response.close() |
346 | |
347 | @retry_fatal |
348 | def put(self, source_path, remote_filename = None): |
349 | @@ -265,10 +372,11 @@ |
350 | remote_filename = source_path.get_filename() |
351 | url = self.directory + remote_filename |
352 | log.Info("Saving %s on WebDAV server" % (url ,)) |
353 | + response = None |
354 | try: |
355 | source_file = source_path.open("rb") |
356 | response = self.request("PUT", url, source_file.read()) |
357 | - if response.status == 201: |
358 | + if response.status in [201, 204]: |
359 | response.read() |
360 | response.close() |
361 | else: |
362 | @@ -276,9 +384,10 @@ |
363 | reason = response.reason |
364 | response.close() |
365 | raise BackendException("Bad status code %s reason %s." % (status,reason)) |
366 | - except Exception, cause: |
367 | - e = BackendException("Putting %s on WebDAV server failed. %s" % (url,cause)) |
368 | + except Exception, e: |
369 | raise e |
370 | + finally: |
371 | + if response: response.close() |
372 | |
373 | @retry_fatal |
374 | def delete(self, filename_list): |
375 | @@ -286,6 +395,7 @@ |
376 | for filename in filename_list: |
377 | url = self.directory + filename |
378 | log.Info("Deleting %s from WebDAV server" % (url ,)) |
379 | + response = None |
380 | try: |
381 | response = self.request("DELETE", url) |
382 | if response.status == 204: |
383 | @@ -296,9 +406,10 @@ |
384 | reason = response.reason |
385 | response.close() |
386 | raise BackendException("Bad status code %s reason %s." % (status,reason)) |
387 | - except Exception, cause: |
388 | - e = BackendException("Deleting %s on WebDAV server failed. %s" % (url,cause)) |
389 | + except Exception, e: |
390 | raise e |
391 | + finally: |
392 | + if response: response.close() |
393 | |
394 | duplicity.backend.register_backend("webdav", WebDAVBackend) |
395 | duplicity.backend.register_backend("webdavs", WebDAVBackend) |
396 | |
397 | === modified file 'duplicity/commandline.py' |
398 | --- duplicity/commandline.py 2012-11-21 01:27:35 +0000 |
399 | +++ duplicity/commandline.py 2013-01-11 18:46:21 +0000 |
400 | @@ -502,6 +502,11 @@ |
401 | # user added ssh options |
402 | parser.add_option("--ssh-options", action="extend", metavar=_("options")) |
403 | |
404 | + # user added ssl options (webdav backend) |
405 | + parser.add_option("--ssl-cacert-file", metavar=_("pem formatted bundle of certificate authorities")) |
406 | + |
407 | + parser.add_option("--ssl-no-check-certificate", action="store_true") |
408 | + |
409 | # Working directory for the tempfile module. Defaults to /tmp on most systems. |
410 | parser.add_option("--tempdir", dest="temproot", type="file", metavar=_("path")) |
411 | |
412 | |
413 | === modified file 'duplicity/errors.py' |
414 | --- duplicity/errors.py 2011-08-03 19:47:29 +0000 |
415 | +++ duplicity/errors.py 2013-01-11 18:46:21 +0000 |
416 | @@ -70,6 +70,12 @@ |
417 | """ |
418 | pass |
419 | |
420 | +class FatalBackendError(DuplicityError): |
421 | + """ |
422 | + Raised to indicate a backend failed fatally. |
423 | + """ |
424 | + pass |
425 | + |
426 | class TemporaryLoadException(BackendException): |
427 | """ |
428 | Raised to indicate a temporary issue on the backend. |
429 | |
430 | === modified file 'duplicity/globals.py' |
431 | --- duplicity/globals.py 2012-03-13 20:53:35 +0000 |
432 | +++ duplicity/globals.py 2013-01-11 18:46:21 +0000 |
433 | @@ -214,6 +214,10 @@ |
434 | # whether to use scp for put/get, sftp is default |
435 | use_scp = False |
436 | |
437 | +# HTTPS ssl optons (currently only webdav) |
438 | +ssl_cacert_file = None |
439 | +ssl_no_check_certificate = False |
440 | + |
441 | # user added rsync options |
442 | rsync_options = "" |
443 |
The 'Diff against target' has conflicts (see above). Needs to be fixed.