2 |
2 |
#
|
3 |
3 |
|
4 |
4 |
# Copyright (C) 2010, 2011 Google Inc.
|
|
5 |
# Copyright (C) 2013, GRNET S.A.
|
5 |
6 |
#
|
6 |
7 |
# This program is free software; you can redistribute it and/or modify
|
7 |
8 |
# it under the terms of the GNU General Public License as published by
|
... | ... | |
19 |
20 |
# 02110-1301, USA.
|
20 |
21 |
|
21 |
22 |
|
22 |
|
"""Ganeti RAPI client.
|
23 |
|
|
24 |
|
@attention: To use the RAPI client, the application B{must} call
|
25 |
|
C{pycurl.global_init} during initialization and
|
26 |
|
C{pycurl.global_cleanup} before exiting the process. This is very
|
27 |
|
important in multi-threaded programs. See curl_global_init(3) and
|
28 |
|
curl_global_cleanup(3) for details. The decorator L{UsesRapiClient}
|
29 |
|
can be used.
|
30 |
|
|
31 |
|
"""
|
|
23 |
"""Ganeti RAPI client."""
|
32 |
24 |
|
33 |
25 |
# No Ganeti-specific modules should be imported. The RAPI client is supposed to
|
34 |
26 |
# be standalone.
|
35 |
27 |
|
|
28 |
import requests
|
36 |
29 |
import logging
|
37 |
30 |
import simplejson
|
38 |
|
import socket
|
39 |
|
import urllib
|
40 |
|
import threading
|
41 |
|
import pycurl
|
42 |
31 |
import time
|
43 |
32 |
|
44 |
|
try:
|
45 |
|
from cStringIO import StringIO
|
46 |
|
except ImportError:
|
47 |
|
from StringIO import StringIO
|
48 |
|
|
49 |
|
|
50 |
33 |
GANETI_RAPI_PORT = 5080
|
51 |
34 |
GANETI_RAPI_VERSION = 2
|
52 |
35 |
|
... | ... | |
112 |
95 |
_NODE_MIGRATE_REQV1 = NODE_MIGRATE_REQV1
|
113 |
96 |
_NODE_EVAC_RES1 = NODE_EVAC_RES1
|
114 |
97 |
|
115 |
|
# Older pycURL versions don't have all error constants
|
116 |
|
try:
|
117 |
|
_CURLE_SSL_CACERT = pycurl.E_SSL_CACERT
|
118 |
|
_CURLE_SSL_CACERT_BADFILE = pycurl.E_SSL_CACERT_BADFILE
|
119 |
|
except AttributeError:
|
120 |
|
_CURLE_SSL_CACERT = 60
|
121 |
|
_CURLE_SSL_CACERT_BADFILE = 77
|
122 |
|
|
123 |
|
_CURL_SSL_CERT_ERRORS = frozenset([
|
124 |
|
_CURLE_SSL_CACERT,
|
125 |
|
_CURLE_SSL_CACERT_BADFILE,
|
126 |
|
])
|
127 |
98 |
|
128 |
99 |
|
129 |
100 |
class Error(Exception):
|
... | ... | |
183 |
154 |
return condition
|
184 |
155 |
|
185 |
156 |
|
186 |
|
def UsesRapiClient(fn):
|
187 |
|
"""Decorator for code using RAPI client to initialize pycURL.
|
188 |
|
|
189 |
|
"""
|
190 |
|
def wrapper(*args, **kwargs):
|
191 |
|
# curl_global_init(3) and curl_global_cleanup(3) must be called with only
|
192 |
|
# one thread running. This check is just a safety measure -- it doesn't
|
193 |
|
# cover all cases.
|
194 |
|
assert threading.activeCount() == 1, \
|
195 |
|
"Found active threads when initializing pycURL"
|
196 |
|
|
197 |
|
pycurl.global_init(pycurl.GLOBAL_ALL)
|
198 |
|
try:
|
199 |
|
return fn(*args, **kwargs)
|
200 |
|
finally:
|
201 |
|
pycurl.global_cleanup()
|
202 |
|
|
203 |
|
return wrapper
|
204 |
|
|
205 |
|
|
206 |
|
def GenericCurlConfig(verbose=False, use_signal=False,
|
207 |
|
use_curl_cabundle=False, cafile=None, capath=None,
|
208 |
|
proxy=None, verify_hostname=False,
|
209 |
|
connect_timeout=None, timeout=None,
|
210 |
|
_pycurl_version_fn=pycurl.version_info):
|
211 |
|
"""Curl configuration function generator.
|
212 |
|
|
213 |
|
@type verbose: bool
|
214 |
|
@param verbose: Whether to set cURL to verbose mode
|
215 |
|
@type use_signal: bool
|
216 |
|
@param use_signal: Whether to allow cURL to use signals
|
217 |
|
@type use_curl_cabundle: bool
|
218 |
|
@param use_curl_cabundle: Whether to use cURL's default CA bundle
|
219 |
|
@type cafile: string
|
220 |
|
@param cafile: In which file we can find the certificates
|
221 |
|
@type capath: string
|
222 |
|
@param capath: In which directory we can find the certificates
|
223 |
|
@type proxy: string
|
224 |
|
@param proxy: Proxy to use, None for default behaviour and empty string for
|
225 |
|
disabling proxies (see curl_easy_setopt(3))
|
226 |
|
@type verify_hostname: bool
|
227 |
|
@param verify_hostname: Whether to verify the remote peer certificate's
|
228 |
|
commonName
|
229 |
|
@type connect_timeout: number
|
230 |
|
@param connect_timeout: Timeout for establishing connection in seconds
|
231 |
|
@type timeout: number
|
232 |
|
@param timeout: Timeout for complete transfer in seconds (see
|
233 |
|
curl_easy_setopt(3)).
|
234 |
|
|
235 |
|
"""
|
236 |
|
if use_curl_cabundle and (cafile or capath):
|
237 |
|
raise Error("Can not use default CA bundle when CA file or path is set")
|
238 |
|
|
239 |
|
def _ConfigCurl(curl, logger):
|
240 |
|
"""Configures a cURL object
|
241 |
|
|
242 |
|
@type curl: pycurl.Curl
|
243 |
|
@param curl: cURL object
|
244 |
|
|
245 |
|
"""
|
246 |
|
logger.debug("Using cURL version %s", pycurl.version)
|
247 |
|
|
248 |
|
# pycurl.version_info returns a tuple with information about the used
|
249 |
|
# version of libcurl. Item 5 is the SSL library linked to it.
|
250 |
|
# e.g.: (3, '7.18.0', 463360, 'x86_64-pc-linux-gnu', 1581, 'GnuTLS/2.0.4',
|
251 |
|
# 0, '1.2.3.3', ...)
|
252 |
|
sslver = _pycurl_version_fn()[5]
|
253 |
|
if not sslver:
|
254 |
|
raise Error("No SSL support in cURL")
|
255 |
|
|
256 |
|
lcsslver = sslver.lower()
|
257 |
|
if lcsslver.startswith("openssl/"):
|
258 |
|
pass
|
259 |
|
elif lcsslver.startswith("nss/"):
|
260 |
|
# TODO: investigate compatibility beyond a simple test
|
261 |
|
pass
|
262 |
|
elif lcsslver.startswith("gnutls/"):
|
263 |
|
if capath:
|
264 |
|
raise Error("cURL linked against GnuTLS has no support for a"
|
265 |
|
" CA path (%s)" % (pycurl.version, ))
|
266 |
|
else:
|
267 |
|
raise NotImplementedError("cURL uses unsupported SSL version '%s'" %
|
268 |
|
sslver)
|
269 |
|
|
270 |
|
curl.setopt(pycurl.VERBOSE, verbose)
|
271 |
|
curl.setopt(pycurl.NOSIGNAL, not use_signal)
|
272 |
|
|
273 |
|
# Whether to verify remote peer's CN
|
274 |
|
if verify_hostname:
|
275 |
|
# curl_easy_setopt(3): "When CURLOPT_SSL_VERIFYHOST is 2, that
|
276 |
|
# certificate must indicate that the server is the server to which you
|
277 |
|
# meant to connect, or the connection fails. [...] When the value is 1,
|
278 |
|
# the certificate must contain a Common Name field, but it doesn't matter
|
279 |
|
# what name it says. [...]"
|
280 |
|
curl.setopt(pycurl.SSL_VERIFYHOST, 2)
|
281 |
|
else:
|
282 |
|
curl.setopt(pycurl.SSL_VERIFYHOST, 0)
|
283 |
|
|
284 |
|
if cafile or capath or use_curl_cabundle:
|
285 |
|
# Require certificates to be checked
|
286 |
|
curl.setopt(pycurl.SSL_VERIFYPEER, True)
|
287 |
|
if cafile:
|
288 |
|
curl.setopt(pycurl.CAINFO, str(cafile))
|
289 |
|
if capath:
|
290 |
|
curl.setopt(pycurl.CAPATH, str(capath))
|
291 |
|
# Not changing anything for using default CA bundle
|
292 |
|
else:
|
293 |
|
# Disable SSL certificate verification
|
294 |
|
curl.setopt(pycurl.SSL_VERIFYPEER, False)
|
295 |
|
|
296 |
|
if proxy is not None:
|
297 |
|
curl.setopt(pycurl.PROXY, str(proxy))
|
298 |
|
|
299 |
|
# Timeouts
|
300 |
|
if connect_timeout is not None:
|
301 |
|
curl.setopt(pycurl.CONNECTTIMEOUT, connect_timeout)
|
302 |
|
if timeout is not None:
|
303 |
|
curl.setopt(pycurl.TIMEOUT, timeout)
|
304 |
|
|
305 |
|
return _ConfigCurl
|
306 |
|
|
307 |
|
|
308 |
157 |
class GanetiRapiClient(object): # pylint: disable=R0904
|
309 |
158 |
"""Ganeti RAPI client.
|
310 |
159 |
|
... | ... | |
313 |
162 |
_json_encoder = simplejson.JSONEncoder(sort_keys=True)
|
314 |
163 |
|
315 |
164 |
def __init__(self, host, port=GANETI_RAPI_PORT,
|
316 |
|
username=None, password=None, logger=logging,
|
317 |
|
curl_config_fn=None, curl_factory=None):
|
|
165 |
username=None, password=None, logger=logging):
|
318 |
166 |
"""Initializes this class.
|
319 |
167 |
|
320 |
168 |
@type host: string
|
... | ... | |
325 |
173 |
@param username: the username to connect with
|
326 |
174 |
@type password: string
|
327 |
175 |
@param password: the password to connect with
|
328 |
|
@type curl_config_fn: callable
|
329 |
|
@param curl_config_fn: Function to configure C{pycurl.Curl} object
|
330 |
176 |
@param logger: Logging object
|
331 |
177 |
|
332 |
178 |
"""
|
333 |
|
self._username = username
|
334 |
|
self._password = password
|
335 |
179 |
self._logger = logger
|
336 |
|
self._curl_config_fn = curl_config_fn
|
337 |
|
self._curl_factory = curl_factory
|
338 |
|
|
339 |
|
try:
|
340 |
|
socket.inet_pton(socket.AF_INET6, host)
|
341 |
|
address = "[%s]:%s" % (host, port)
|
342 |
|
except socket.error:
|
343 |
|
address = "%s:%s" % (host, port)
|
344 |
|
|
345 |
|
self._base_url = "https://%s" % address
|
|
180 |
self._base_url = "https://%s:%s" % (host, port)
|
346 |
181 |
|
347 |
182 |
if username is not None:
|
348 |
183 |
if password is None:
|
... | ... | |
350 |
185 |
elif password:
|
351 |
186 |
raise Error("Specified password without username")
|
352 |
187 |
|
353 |
|
def _CreateCurl(self):
|
354 |
|
"""Creates a cURL object.
|
355 |
|
|
356 |
|
"""
|
357 |
|
# Create pycURL object if no factory is provided
|
358 |
|
if self._curl_factory:
|
359 |
|
curl = self._curl_factory()
|
360 |
|
else:
|
361 |
|
curl = pycurl.Curl()
|
362 |
|
|
363 |
|
# Default cURL settings
|
364 |
|
curl.setopt(pycurl.VERBOSE, False)
|
365 |
|
curl.setopt(pycurl.FOLLOWLOCATION, False)
|
366 |
|
curl.setopt(pycurl.MAXREDIRS, 5)
|
367 |
|
curl.setopt(pycurl.NOSIGNAL, True)
|
368 |
|
curl.setopt(pycurl.USERAGENT, self.USER_AGENT)
|
369 |
|
curl.setopt(pycurl.SSL_VERIFYHOST, 0)
|
370 |
|
curl.setopt(pycurl.SSL_VERIFYPEER, False)
|
371 |
|
curl.setopt(pycurl.HTTPHEADER, [
|
372 |
|
"Accept: %s" % HTTP_APP_JSON,
|
373 |
|
"Content-type: %s" % HTTP_APP_JSON,
|
374 |
|
])
|
375 |
|
|
376 |
|
assert ((self._username is None and self._password is None) ^
|
377 |
|
(self._username is not None and self._password is not None))
|
378 |
|
|
379 |
|
if self._username:
|
380 |
|
# Setup authentication
|
381 |
|
curl.setopt(pycurl.HTTPAUTH, pycurl.HTTPAUTH_BASIC)
|
382 |
|
curl.setopt(pycurl.USERPWD,
|
383 |
|
str("%s:%s" % (self._username, self._password)))
|
384 |
|
|
385 |
|
# Call external configuration function
|
386 |
|
if self._curl_config_fn:
|
387 |
|
self._curl_config_fn(curl, self._logger)
|
388 |
|
|
389 |
|
return curl
|
390 |
|
|
391 |
|
@staticmethod
|
392 |
|
def _EncodeQuery(query):
|
393 |
|
"""Encode query values for RAPI URL.
|
394 |
|
|
395 |
|
@type query: list of two-tuples
|
396 |
|
@param query: Query arguments
|
397 |
|
@rtype: list
|
398 |
|
@return: Query list with encoded values
|
399 |
|
|
400 |
|
"""
|
401 |
|
result = []
|
402 |
|
|
403 |
|
for name, value in query:
|
404 |
|
if value is None:
|
405 |
|
result.append((name, ""))
|
406 |
|
|
407 |
|
elif isinstance(value, bool):
|
408 |
|
# Boolean values must be encoded as 0 or 1
|
409 |
|
result.append((name, int(value)))
|
410 |
|
|
411 |
|
elif isinstance(value, (list, tuple, dict)):
|
412 |
|
raise ValueError("Invalid query data type %r" % type(value).__name__)
|
413 |
|
|
414 |
|
else:
|
415 |
|
result.append((name, value))
|
416 |
|
|
417 |
|
return result
|
|
188 |
self._auth = (username, password)
|
418 |
189 |
|
419 |
190 |
def _SendRequest(self, method, path, query, content):
|
420 |
191 |
"""Sends an HTTP request.
|
... | ... | |
439 |
210 |
|
440 |
211 |
"""
|
441 |
212 |
assert path.startswith("/")
|
|
213 |
url = "%s%s" % (self._base_url, path)
|
442 |
214 |
|
443 |
|
curl = self._CreateCurl()
|
444 |
|
|
|
215 |
headers = {}
|
445 |
216 |
if content is not None:
|
446 |
217 |
encoded_content = self._json_encoder.encode(content)
|
|
218 |
headers = {"content-type": HTTP_APP_JSON,
|
|
219 |
"accept": HTTP_APP_JSON}
|
447 |
220 |
else:
|
448 |
221 |
encoded_content = ""
|
449 |
222 |
|
450 |
|
# Build URL
|
451 |
|
urlparts = [self._base_url, path]
|
452 |
|
if query:
|
453 |
|
urlparts.append("?")
|
454 |
|
urlparts.append(urllib.urlencode(self._EncodeQuery(query)))
|
|
223 |
if query is not None:
|
|
224 |
query = dict(query)
|
455 |
225 |
|
456 |
|
url = "".join(urlparts)
|
|
226 |
self._logger.debug("Sending request %s %s (query=%r) (content=%r)",
|
|
227 |
method, url, query, encoded_content)
|
457 |
228 |
|
458 |
|
self._logger.debug("Sending request %s %s (content=%r)",
|
459 |
|
method, url, encoded_content)
|
|
229 |
req_method = getattr(requests, method.lower())
|
|
230 |
r = req_method(url, auth=self._auth, headers=headers, params=query,
|
|
231 |
data=encoded_content, verify=False)
|
460 |
232 |
|
461 |
|
# Buffer for response
|
462 |
|
encoded_resp_body = StringIO()
|
463 |
233 |
|
464 |
|
# Configure cURL
|
465 |
|
curl.setopt(pycurl.CUSTOMREQUEST, str(method))
|
466 |
|
curl.setopt(pycurl.URL, str(url))
|
467 |
|
curl.setopt(pycurl.POSTFIELDS, str(encoded_content))
|
468 |
|
curl.setopt(pycurl.WRITEFUNCTION, encoded_resp_body.write)
|
469 |
|
|
470 |
|
try:
|
471 |
|
# Send request and wait for response
|
472 |
|
try:
|
473 |
|
curl.perform()
|
474 |
|
except pycurl.error, err:
|
475 |
|
if err.args[0] in _CURL_SSL_CERT_ERRORS:
|
476 |
|
raise CertificateError("SSL certificate error %s" % err,
|
477 |
|
code=err.args[0])
|
478 |
|
|
479 |
|
raise GanetiApiError(str(err), code=err.args[0])
|
480 |
|
finally:
|
481 |
|
# Reset settings to not keep references to large objects in memory
|
482 |
|
# between requests
|
483 |
|
curl.setopt(pycurl.POSTFIELDS, "")
|
484 |
|
curl.setopt(pycurl.WRITEFUNCTION, lambda _: None)
|
485 |
|
|
486 |
|
# Get HTTP response code
|
487 |
|
http_code = curl.getinfo(pycurl.RESPONSE_CODE)
|
488 |
|
|
489 |
|
# Was anything written to the response buffer?
|
490 |
|
if encoded_resp_body.tell():
|
491 |
|
response_content = simplejson.loads(encoded_resp_body.getvalue())
|
|
234 |
http_code = r.status_code
|
|
235 |
if r.content is not None:
|
|
236 |
response_content = simplejson.loads(r.content)
|
492 |
237 |
else:
|
493 |
|
response_content = None
|
|
238 |
response_content = None
|
494 |
239 |
|
495 |
240 |
if http_code != HTTP_OK:
|
496 |
241 |
if isinstance(response_content, dict):
|