From dbb242b776908ca50ed8557ebfe7cfcd879366c8 Mon Sep 17 00:00:00 2001 From: AmalaBasha <amala.alungal@RACKSPACE.COM> Date: Tue, 1 Jul 2014 14:45:12 +0530 Subject: [PATCH] Replace old httpclient with requests This review implements blueprint python-request and replaces the old http client implementation in favor of a new one based on python-requests. Major changes: * raw_request and json_request removed since everything is now being handled by the same method "_request" * New methods that match HTTP's methods were added: - get - put - post - head - patch - delete * Content-Type is now being "inferred" based on the data being sent: - if it is file-like object it chunks the request - if it is a python type not instance of basestring then it'll try to serialize it to json - Every other case will keep the incoming content-type and will send the data as is. * Glanceclient's HTTPSConnection implementation will be used if no-compression flag is set to True. Co-Author: Flavio Percoco<flaper87@gmail.com> Change-Id: I09f70eee3e2777f52ce040296015d41649c2586a --- glanceclient/common/http.py | 582 ++++++------------------------- glanceclient/common/https.py | 274 +++++++++++++++ glanceclient/common/utils.py | 20 ++ glanceclient/exc.py | 2 +- glanceclient/v1/client.py | 14 +- glanceclient/v1/image_members.py | 10 +- glanceclient/v1/images.py | 49 ++- glanceclient/v2/image_members.py | 13 +- glanceclient/v2/image_tags.py | 4 +- glanceclient/v2/images.py | 37 +- glanceclient/v2/schemas.py | 2 +- requirements.txt | 1 + tests/test_exc.py | 10 +- tests/test_http.py | 405 ++++++--------------- tests/test_shell.py | 45 +-- tests/test_ssl.py | 93 +++-- tests/utils.py | 126 +++---- tests/v1/test_images.py | 2 +- tests/v1/test_legacy_shell.py | 2 +- tests/v1/test_shell.py | 32 +- tests/v2/test_images.py | 4 +- tests/v2/test_shell_v2.py | 80 +---- 22 files changed, 744 insertions(+), 1063 deletions(-) create mode 100644 glanceclient/common/https.py diff --git a/glanceclient/common/http.py b/glanceclient/common/http.py index 84714dfc..a990be5d 100644 --- a/glanceclient/common/http.py +++ b/glanceclient/common/http.py @@ -14,16 +14,11 @@ # under the License. import copy -import errno -import hashlib import logging -import posixpath import socket -import ssl -import struct +import requests import six -from six.moves import http_client from six.moves.urllib import parse try: @@ -36,9 +31,7 @@ if not hasattr(parse, 'parse_qsl'): import cgi parse.parse_qsl = cgi.parse_qsl -import OpenSSL - -from glanceclient.common import utils +from glanceclient.common import https from glanceclient import exc from glanceclient.openstack.common import importutils from glanceclient.openstack.common import network_utils @@ -46,48 +39,15 @@ from glanceclient.openstack.common import strutils osprofiler_web = importutils.try_import("osprofiler.web") -try: - from eventlet import patcher - # Handle case where we are running in a monkey patched environment - if patcher.is_monkey_patched('socket'): - from eventlet.green.httplib import HTTPSConnection - from eventlet.green.OpenSSL.SSL import GreenConnection as Connection - from eventlet.greenio import GreenSocket - # TODO(mclaren): A getsockopt workaround: see 'getsockopt' doc string - GreenSocket.getsockopt = utils.getsockopt - else: - raise ImportError -except ImportError: - HTTPSConnection = http_client.HTTPSConnection - from OpenSSL.SSL import Connection as Connection - - LOG = logging.getLogger(__name__) USER_AGENT = 'python-glanceclient' CHUNKSIZE = 1024 * 64 # 64kB -def to_bytes(s): - if isinstance(s, six.string_types): - return six.b(s) - else: - return s - - class HTTPClient(object): def __init__(self, endpoint, **kwargs): self.endpoint = endpoint - endpoint_parts = self.parse_endpoint(self.endpoint) - self.endpoint_scheme = endpoint_parts.scheme - self.endpoint_hostname = endpoint_parts.hostname - self.endpoint_port = endpoint_parts.port - self.endpoint_path = endpoint_parts.path - - self.connection_class = self.get_connection_class(self.endpoint_scheme) - self.connection_kwargs = self.get_connection_kwargs( - self.endpoint_scheme, **kwargs) - self.identity_headers = kwargs.get('identity_headers') self.auth_token = kwargs.get('token') if self.identity_headers: @@ -95,71 +55,58 @@ class HTTPClient(object): self.auth_token = self.identity_headers.get('X-Auth-Token') del self.identity_headers['X-Auth-Token'] + self.session = requests.Session() + self.session.headers["User-Agent"] = USER_AGENT + self.session.headers["X-Auth-Token"] = self.auth_token + + self.timeout = float(kwargs.get('timeout', 600)) + + if self.endpoint.startswith("https"): + compression = kwargs.get('ssl_compression', True) + + if not compression: + self.session.mount("https://", https.HTTPSAdapter()) + + self.session.verify = kwargs.get('cacert', + not kwargs.get('insecure', True)) + self.session.cert = (kwargs.get('cert_file'), + kwargs.get('key_file')) + @staticmethod def parse_endpoint(endpoint): return network_utils.urlsplit(endpoint) - @staticmethod - def get_connection_class(scheme): - if scheme == 'https': - return VerifiedHTTPSConnection - else: - return http_client.HTTPConnection - - @staticmethod - def get_connection_kwargs(scheme, **kwargs): - _kwargs = {'timeout': float(kwargs.get('timeout', 600))} - - if scheme == 'https': - _kwargs['cacert'] = kwargs.get('cacert', None) - _kwargs['cert_file'] = kwargs.get('cert_file', None) - _kwargs['key_file'] = kwargs.get('key_file', None) - _kwargs['insecure'] = kwargs.get('insecure', False) - _kwargs['ssl_compression'] = kwargs.get('ssl_compression', True) - - return _kwargs - - def get_connection(self): - _class = self.connection_class - try: - return _class(self.endpoint_hostname, self.endpoint_port, - **self.connection_kwargs) - except http_client.InvalidURL: - raise exc.InvalidEndpoint() - - def log_curl_request(self, method, url, kwargs): + def log_curl_request(self, method, url, headers, data, kwargs): curl = ['curl -i -X %s' % method] - for (key, value) in kwargs['headers'].items(): + for (key, value) in self.session.headers.items(): if key.lower() == 'x-auth-token': value = '*' * 3 header = '-H \'%s: %s\'' % (key, value) - curl.append(header) + curl.append(strutils.safe_encode(header)) - conn_params_fmt = [ - ('key_file', '--key %s'), - ('cert_file', '--cert %s'), - ('cacert', '--cacert %s'), - ] - for (key, fmt) in conn_params_fmt: - value = self.connection_kwargs.get(key) - if value: - curl.append(fmt % value) - - if self.connection_kwargs.get('insecure'): + if not self.session.verify: curl.append('-k') + else: + if isinstance(self.session.verify, six.string_types): + curl.append(' --cacert %s' % self.session.verify) - if kwargs.get('body') is not None: - curl.append('-d \'%s\'' % kwargs['body']) + if self.session.cert: + curl.append(' --cert %s --key %s' % self.session.cert) - curl.append('%s%s' % (self.endpoint, url)) + if data and isinstance(data, six.string_types): + curl.append('-d \'%s\'' % data) + + if "//:" not in url: + url = '%s%s' % (self.endpoint, url) + curl.append(url) LOG.debug(strutils.safe_encode(' '.join(curl), errors='ignore')) @staticmethod def log_http_response(resp, body=None): - status = (resp.version / 10.0, resp.status, resp.reason) + status = (resp.raw.version / 10.0, resp.status_code, resp.reason) dump = ['\nHTTP/%.1f %s %s' % status] - headers = resp.getheaders() + headers = resp.headers.items() if 'X-Auth-Token' in headers: headers['X-Auth-Token'] = '*' * 3 dump.extend(['%s: %s' % (k, v) for k, v in headers]) @@ -183,69 +130,59 @@ class HTTPClient(object): return dict((strutils.safe_encode(h), strutils.safe_encode(v)) for h, v in six.iteritems(headers)) - def _http_request(self, url, method, **kwargs): + def _request(self, method, url, **kwargs): """Send an http request with the specified characteristics. - Wrapper around httplib.HTTP(S)Connection.request to handle tasks such as setting headers and error handling. """ # Copy the kwargs so we can reuse the original in case of redirects - kwargs['headers'] = copy.deepcopy(kwargs.get('headers', {})) - kwargs['headers'].setdefault('User-Agent', USER_AGENT) + headers = kwargs.pop("headers", {}) + headers = headers and copy.deepcopy(headers) or {} - if osprofiler_web: - kwargs['headers'].update(osprofiler_web.get_trace_id_headers()) + # Default Content-Type is octet-stream + content_type = headers.get('Content-Type', 'application/octet-stream') - if self.auth_token: - kwargs['headers'].setdefault('X-Auth-Token', self.auth_token) + def chunk_body(body): + chunk = body + while chunk: + chunk = body.read(CHUNKSIZE) + yield chunk - if self.identity_headers: - for k, v in six.iteritems(self.identity_headers): - kwargs['headers'].setdefault(k, v) + data = kwargs.pop("data", None) + if data is not None and not isinstance(data, six.string_types): + try: + data = json.dumps(data) + content_type = 'application/json' + except TypeError: + # Here we assume it's + # a file-like object + # and we'll chunk it + data = chunk_body(data) - self.log_curl_request(method, url, kwargs) - conn = self.get_connection() + headers['Content-Type'] = content_type # Note(flaper87): Before letting headers / url fly, # they should be encoded otherwise httplib will - # complain. If we decide to rely on python-request - # this wont be necessary anymore. - kwargs['headers'] = self.encode_headers(kwargs['headers']) + # complain. + headers = self.encode_headers(headers) try: - if self.endpoint_path: - # NOTE(yuyangbj): this method _http_request could either be - # called by API layer, or be called recursively with - # redirection. For example, url would be '/v1/images/detail' - # from API layer, but url would be 'https://example.com:92/ - # v1/images/detail' from recursion. - # See bug #1230032 and bug #1208618. - if url is not None: - all_parts = parse.urlparse(url) - if not (all_parts.scheme and all_parts.netloc): - norm_parse = posixpath.normpath - url = norm_parse('/'.join([self.endpoint_path, url])) - else: - url = self.endpoint_path - - conn_url = parse.urlsplit(url).geturl() - # Note(flaper87): Ditto, headers / url - # encoding to make httplib happy. - conn_url = strutils.safe_encode(conn_url) - if kwargs['headers'].get('Transfer-Encoding') == 'chunked': - conn.putrequest(method, conn_url) - for header, value in kwargs['headers'].items(): - conn.putheader(header, value) - conn.endheaders() - chunk = kwargs['body'].read(CHUNKSIZE) - # Chunk it, baby... - while chunk: - conn.send('%x\r\n%s\r\n' % (len(chunk), chunk)) - chunk = kwargs['body'].read(CHUNKSIZE) - conn.send('0\r\n\r\n') - else: - conn.request(method, conn_url, **kwargs) - resp = conn.getresponse() + conn_url = "%s/%s" % (self.endpoint, url) + self.log_curl_request(method, conn_url, headers, data, kwargs) + resp = self.session.request(method, + conn_url, + data=data, + stream=True, + headers=headers, + **kwargs) + except requests.exceptions.Timeout as e: + message = ("Error communicating with %(endpoint)s %(e)s" % + dict(url=conn_url, e=e)) + raise exc.InvalidEndpoint(message=message) + except requests.exceptions.ConnectionError as e: + message = ("Error finding address for %(url)s: %(e)s" % + dict(url=conn_url, e=e)) + raise exc.CommunicationError(message=message) except socket.gaierror as e: message = "Error finding address for %s: %s" % ( self.endpoint_hostname, e) @@ -256,357 +193,46 @@ class HTTPClient(object): {'endpoint': endpoint, 'e': e}) raise exc.CommunicationError(message=message) - body_iter = ResponseBodyIterator(resp) - - # Read body into string if it isn't obviously image data - if resp.getheader('content-type', None) != 'application/octet-stream': - body_str = b''.join([to_bytes(chunk) for chunk in body_iter]) - self.log_http_response(resp, body_str) - body_iter = six.BytesIO(body_str) - else: - self.log_http_response(resp) - - if 400 <= resp.status < 600: - LOG.debug("Request returned failure status: %d" % resp.status) - raise exc.from_response(resp, body_str) - elif resp.status in (301, 302, 305): - # Redirected. Reissue the request to the new location. - return self._http_request(resp.getheader('location', None), method, - **kwargs) - elif resp.status == 300: + if not resp.ok: + LOG.error("Request returned failure status %s." % resp.status_code) + raise exc.from_response(resp, resp.content) + elif resp.status_code == requests.codes.MULTIPLE_CHOICES: raise exc.from_response(resp) + content_type = resp.headers.get('Content-Type') + + # Read body into string if it isn't obviously image data + if content_type == 'application/octet-stream': + # Do not read all response in memory when + # downloading an image. + body_iter = resp.iter_content(chunk_size=CHUNKSIZE) + self.log_http_response(resp) + else: + content = resp.content + self.log_http_response(resp, content) + if content_type and content_type.startswith('application/json'): + # Let's use requests json method, + # it should take care of response + # encoding + body_iter = resp.json() + else: + body_iter = six.StringIO(content) return resp, body_iter - def json_request(self, method, url, **kwargs): - kwargs.setdefault('headers', {}) - kwargs['headers'].setdefault('Content-Type', 'application/json') - - if 'body' in kwargs: - kwargs['body'] = json.dumps(kwargs['body']) - - resp, body_iter = self._http_request(url, method, **kwargs) - - if 'application/json' in resp.getheader('content-type', ''): - body = ''.join([chunk for chunk in body_iter]) - try: - body = json.loads(body) - except ValueError: - LOG.error('Could not decode response body as JSON') - else: - body = None - - return resp, body - - def raw_request(self, method, url, **kwargs): - kwargs.setdefault('headers', {}) - kwargs['headers'].setdefault('Content-Type', - 'application/octet-stream') - - if 'content_length' in kwargs: - content_length = kwargs.pop('content_length') - else: - content_length = None - - if (('body' in kwargs) and (hasattr(kwargs['body'], 'read') and - method.lower() in ('post', 'put'))): - - # NOTE(dosaboy): only use chunked transfer if not setting a - # content length since setting it will implicitly disable - # chunking. - - file_content_length = utils.get_file_size(kwargs['body']) - if content_length is None: - content_length = file_content_length - elif (file_content_length and - (content_length != file_content_length)): - errmsg = ("supplied content-length (%s) does not match " - "length of supplied data (%s)" % - (content_length, file_content_length)) - raise AttributeError(errmsg) - - if content_length is None: - # We use 'Transfer-Encoding: chunked' because - # body size may not always be known in advance. - kwargs['headers']['Transfer-Encoding'] = 'chunked' - else: - kwargs['headers']['Content-Length'] = str(content_length) - - return self._http_request(url, method, **kwargs) - - def client_request(self, method, url, **kwargs): - # NOTE(akurilin): this method provides compatibility with methods which - # expects requests.Response object(for example - methods of - # class Managers from common code). - if 'json' in kwargs and 'body' not in kwargs: - kwargs['body'] = kwargs.pop('json') - resp, body = self.json_request(method, url, **kwargs) - resp.json = lambda: body - resp.content = bool(body) - resp.status_code = resp.status - return resp - def head(self, url, **kwargs): - return self.client_request("HEAD", url, **kwargs) + return self._request('HEAD', url, **kwargs) def get(self, url, **kwargs): - return self.client_request("GET", url, **kwargs) + return self._request('GET', url, **kwargs) def post(self, url, **kwargs): - return self.client_request("POST", url, **kwargs) + return self._request('POST', url, **kwargs) def put(self, url, **kwargs): - return self.client_request("PUT", url, **kwargs) - - def delete(self, url, **kwargs): - return self.raw_request("DELETE", url, **kwargs) + return self._request('PUT', url, **kwargs) def patch(self, url, **kwargs): - return self.client_request("PATCH", url, **kwargs) + return self._request('PATCH', url, **kwargs) - -class OpenSSLConnectionDelegator(object): - """ - An OpenSSL.SSL.Connection delegator. - - Supplies an additional 'makefile' method which httplib requires - and is not present in OpenSSL.SSL.Connection. - - Note: Since it is not possible to inherit from OpenSSL.SSL.Connection - a delegator must be used. - """ - def __init__(self, *args, **kwargs): - self.connection = Connection(*args, **kwargs) - - def __getattr__(self, name): - return getattr(self.connection, name) - - def makefile(self, *args, **kwargs): - # Making sure socket is closed when this file is closed - # since we now avoid closing socket on connection close - # see new close method under VerifiedHTTPSConnection - kwargs['close'] = True - - return socket._fileobject(self.connection, *args, **kwargs) - - -class VerifiedHTTPSConnection(HTTPSConnection): - """ - Extended HTTPSConnection which uses the OpenSSL library - for enhanced SSL support. - Note: Much of this functionality can eventually be replaced - with native Python 3.3 code. - """ - def __init__(self, host, port=None, key_file=None, cert_file=None, - cacert=None, timeout=None, insecure=False, - ssl_compression=True): - # List of exceptions reported by Python3 instead of - # SSLConfigurationError - if six.PY3: - excp_lst = (TypeError, FileNotFoundError, ssl.SSLError) - else: - excp_lst = () - try: - HTTPSConnection.__init__(self, host, port, - key_file=key_file, - cert_file=cert_file) - self.key_file = key_file - self.cert_file = cert_file - self.timeout = timeout - self.insecure = insecure - self.ssl_compression = ssl_compression - self.cacert = None if cacert is None else str(cacert) - self.setcontext() - # ssl exceptions are reported in various form in Python 3 - # so to be compatible, we report the same kind as under - # Python2 - except excp_lst as e: - raise exc.SSLConfigurationError(str(e)) - - @staticmethod - def host_matches_cert(host, x509): - """ - Verify that the x509 certificate we have received - from 'host' correctly identifies the server we are - connecting to, i.e. that the certificate's Common Name - or a Subject Alternative Name matches 'host'. - """ - def check_match(name): - # Directly match the name - if name == host: - return True - - # Support single wildcard matching - if name.startswith('*.') and host.find('.') > 0: - if name[2:] == host.split('.', 1)[1]: - return True - - common_name = x509.get_subject().commonName - - # First see if we can match the CN - if check_match(common_name): - return True - - # Also try Subject Alternative Names for a match - san_list = None - for i in range(x509.get_extension_count()): - ext = x509.get_extension(i) - if ext.get_short_name() == b'subjectAltName': - san_list = str(ext) - for san in ''.join(san_list.split()).split(','): - if san.startswith('DNS:'): - if check_match(san.split(':', 1)[1]): - return True - - # Server certificate does not match host - msg = ('Host "%s" does not match x509 certificate contents: ' - 'CommonName "%s"' % (host, common_name)) - if san_list is not None: - msg = msg + ', subjectAltName "%s"' % san_list - raise exc.SSLCertificateError(msg) - - def verify_callback(self, connection, x509, errnum, - depth, preverify_ok): - # NOTE(leaman): preverify_ok may be a non-boolean type - preverify_ok = bool(preverify_ok) - if x509.has_expired(): - msg = "SSL Certificate expired on '%s'" % x509.get_notAfter() - raise exc.SSLCertificateError(msg) - - if depth == 0 and preverify_ok: - # We verify that the host matches against the last - # certificate in the chain - return self.host_matches_cert(self.host, x509) - else: - # Pass through OpenSSL's default result - return preverify_ok - - def setcontext(self): - """ - Set up the OpenSSL context. - """ - self.context = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD) - - if self.ssl_compression is False: - self.context.set_options(0x20000) # SSL_OP_NO_COMPRESSION - - if self.insecure is not True: - self.context.set_verify(OpenSSL.SSL.VERIFY_PEER, - self.verify_callback) - else: - self.context.set_verify(OpenSSL.SSL.VERIFY_NONE, - lambda *args: True) - - if self.cert_file: - try: - self.context.use_certificate_file(self.cert_file) - except Exception as e: - msg = 'Unable to load cert from "%s" %s' % (self.cert_file, e) - raise exc.SSLConfigurationError(msg) - if self.key_file is None: - # We support having key and cert in same file - try: - self.context.use_privatekey_file(self.cert_file) - except Exception as e: - msg = ('No key file specified and unable to load key ' - 'from "%s" %s' % (self.cert_file, e)) - raise exc.SSLConfigurationError(msg) - - if self.key_file: - try: - self.context.use_privatekey_file(self.key_file) - except Exception as e: - msg = 'Unable to load key from "%s" %s' % (self.key_file, e) - raise exc.SSLConfigurationError(msg) - - if self.cacert: - try: - self.context.load_verify_locations(to_bytes(self.cacert)) - except Exception as e: - msg = ('Unable to load CA from "%(cacert)s" %(exc)s' % - dict(cacert=self.cacert, exc=e)) - raise exc.SSLConfigurationError(msg) - else: - self.context.set_default_verify_paths() - - def connect(self): - """ - Connect to an SSL port using the OpenSSL library and apply - per-connection parameters. - """ - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - if self.timeout is not None: - # '0' microseconds - sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVTIMEO, - struct.pack('fL', self.timeout, 0)) - self.sock = OpenSSLConnectionDelegator(self.context, sock) - self.sock.connect((self.host, self.port)) - - def close(self): - if self.sock: - # Removing reference to socket but don't close it yet. - # Response close will close both socket and associated - # file. Closing socket too soon will cause response - # reads to fail with socket IO error 'Bad file descriptor'. - self.sock = None - - # Calling close on HTTPConnection to continue doing that cleanup. - HTTPSConnection.close(self) - - -class ResponseBodyIterator(object): - """ - A class that acts as an iterator over an HTTP response. - - This class will also check response body integrity when iterating over - the instance and if a checksum was supplied using `set_checksum` method, - else by default the class will not do any integrity check. - """ - - def __init__(self, resp): - self._resp = resp - self._checksum = None - self._size = int(resp.getheader('content-length', 0)) - self._end_reached = False - - def set_checksum(self, checksum): - """ - Set checksum to check against when iterating over this instance. - - :raise: AttributeError if iterator is already consumed. - """ - if self._end_reached: - raise AttributeError("Can't set checksum for an already consumed" - " iterator") - self._checksum = checksum - - def __len__(self): - return int(self._size) - - def __iter__(self): - md5sum = hashlib.md5() - while True: - try: - chunk = self.next() - except StopIteration: - self._end_reached = True - # NOTE(mouad): Check image integrity when the end of response - # body is reached. - md5sum = md5sum.hexdigest() - if self._checksum is not None and md5sum != self._checksum: - raise IOError(errno.EPIPE, - 'Corrupted image. Checksum was %s ' - 'expected %s' % (md5sum, self._checksum)) - raise - else: - yield chunk - if isinstance(chunk, six.string_types): - chunk = six.b(chunk) - md5sum.update(chunk) - - def next(self): - chunk = self._resp.read(CHUNKSIZE) - if chunk: - return chunk - else: - raise StopIteration() + def delete(self, url, **kwargs): + return self._request('DELETE', url, **kwargs) diff --git a/glanceclient/common/https.py b/glanceclient/common/https.py new file mode 100644 index 00000000..6416c191 --- /dev/null +++ b/glanceclient/common/https.py @@ -0,0 +1,274 @@ +# Copyright 2014 Red Hat, Inc +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import socket +import struct + +import OpenSSL +from requests import adapters +try: + from requests.packages.urllib3 import connectionpool + from requests.packages.urllib3 import poolmanager +except ImportError: + from urllib3 import connectionpool + from urllib3 import poolmanager + +import six +import ssl + +from glanceclient.common import utils + +try: + from eventlet import patcher + # Handle case where we are running in a monkey patched environment + if patcher.is_monkey_patched('socket'): + from eventlet.green.httplib import HTTPSConnection + from eventlet.green.OpenSSL.SSL import GreenConnection as Connection + from eventlet.greenio import GreenSocket + # TODO(mclaren): A getsockopt workaround: see 'getsockopt' doc string + GreenSocket.getsockopt = utils.getsockopt + else: + raise ImportError +except ImportError: + try: + from httplib import HTTPSConnection + except ImportError: + from http.client import HTTPSConnection + from OpenSSL.SSL import Connection as Connection + + +from glanceclient import exc + + +def to_bytes(s): + if isinstance(s, six.string_types): + return six.b(s) + else: + return s + + +class HTTPSAdapter(adapters.HTTPAdapter): + """ + This adapter will be used just when + ssl compression should be disabled. + + The init method overwrites the default + https pool by setting glanceclient's + one. + """ + + def __init__(self, *args, **kwargs): + # NOTE(flaper87): This line forces poolmanager to use + # glanceclient HTTPSConnection + poolmanager.pool_classes_by_scheme["https"] = HTTPSConnectionPool + super(HTTPSAdapter, self).__init__(*args, **kwargs) + + def cert_verify(self, conn, url, verify, cert): + super(HTTPSAdapter, self).cert_verify(conn, url, verify, cert) + conn.insecure = not verify + + +class HTTPSConnectionPool(connectionpool.HTTPSConnectionPool): + """ + HTTPSConnectionPool will be instantiated when a new + connection is requested to the HTTPSAdapter.This + implementation overwrites the _new_conn method and + returns an instances of glanceclient's VerifiedHTTPSConnection + which handles no compression. + + ssl_compression is hard-coded to False because this will + be used just when the user sets --no-ssl-compression. + """ + + scheme = 'https' + + def _new_conn(self): + self.num_connections += 1 + return VerifiedHTTPSConnection(host=self.host, + port=self.port, + key_file=self.key_file, + cert_file=self.cert_file, + cacert=self.ca_certs, + insecure=self.insecure, + ssl_compression=False) + + +class OpenSSLConnectionDelegator(object): + """ + An OpenSSL.SSL.Connection delegator. + + Supplies an additional 'makefile' method which httplib requires + and is not present in OpenSSL.SSL.Connection. + + Note: Since it is not possible to inherit from OpenSSL.SSL.Connection + a delegator must be used. + """ + def __init__(self, *args, **kwargs): + self.connection = Connection(*args, **kwargs) + + def __getattr__(self, name): + return getattr(self.connection, name) + + def makefile(self, *args, **kwargs): + return socket._fileobject(self.connection, *args, **kwargs) + + +class VerifiedHTTPSConnection(HTTPSConnection): + """ + Extended HTTPSConnection which uses the OpenSSL library + for enhanced SSL support. + Note: Much of this functionality can eventually be replaced + with native Python 3.3 code. + """ + def __init__(self, host, port=None, key_file=None, cert_file=None, + cacert=None, timeout=None, insecure=False, + ssl_compression=True): + # List of exceptions reported by Python3 instead of + # SSLConfigurationError + if six.PY3: + excp_lst = (TypeError, FileNotFoundError, ssl.SSLError) + else: + excp_lst = () + try: + HTTPSConnection.__init__(self, host, port, + key_file=key_file, + cert_file=cert_file) + self.key_file = key_file + self.cert_file = cert_file + self.timeout = timeout + self.insecure = insecure + self.ssl_compression = ssl_compression + self.cacert = None if cacert is None else str(cacert) + self.set_context() + # ssl exceptions are reported in various form in Python 3 + # so to be compatible, we report the same kind as under + # Python2 + except excp_lst as e: + raise exc.SSLConfigurationError(str(e)) + + @staticmethod + def host_matches_cert(host, x509): + """ + Verify that the x509 certificate we have received + from 'host' correctly identifies the server we are + connecting to, ie that the certificate's Common Name + or a Subject Alternative Name matches 'host'. + """ + def check_match(name): + # Directly match the name + if name == host: + return True + + # Support single wildcard matching + if name.startswith('*.') and host.find('.') > 0: + if name[2:] == host.split('.', 1)[1]: + return True + + common_name = x509.get_subject().commonName + + # First see if we can match the CN + if check_match(common_name): + return True + # Also try Subject Alternative Names for a match + san_list = None + for i in range(x509.get_extension_count()): + ext = x509.get_extension(i) + if ext.get_short_name() == b'subjectAltName': + san_list = str(ext) + for san in ''.join(san_list.split()).split(','): + if san.startswith('DNS:'): + if check_match(san.split(':', 1)[1]): + return True + + # Server certificate does not match host + msg = ('Host "%s" does not match x509 certificate contents: ' + 'CommonName "%s"' % (host, common_name)) + if san_list is not None: + msg = msg + ', subjectAltName "%s"' % san_list + raise exc.SSLCertificateError(msg) + + def verify_callback(self, connection, x509, errnum, + depth, preverify_ok): + if x509.has_expired(): + msg = "SSL Certificate expired on '%s'" % x509.get_notAfter() + raise exc.SSLCertificateError(msg) + + if depth == 0 and preverify_ok: + # We verify that the host matches against the last + # certificate in the chain + return self.host_matches_cert(self.host, x509) + else: + # Pass through OpenSSL's default result + return preverify_ok + + def set_context(self): + """ + Set up the OpenSSL context. + """ + self.context = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD) + + if self.ssl_compression is False: + self.context.set_options(0x20000) # SSL_OP_NO_COMPRESSION + + if self.insecure is not True: + self.context.set_verify(OpenSSL.SSL.VERIFY_PEER, + self.verify_callback) + else: + self.context.set_verify(OpenSSL.SSL.VERIFY_NONE, + lambda *args: True) + + if self.cert_file: + try: + self.context.use_certificate_file(self.cert_file) + except Exception as e: + msg = 'Unable to load cert from "%s" %s' % (self.cert_file, e) + raise exc.SSLConfigurationError(msg) + if self.key_file is None: + # We support having key and cert in same file + try: + self.context.use_privatekey_file(self.cert_file) + except Exception as e: + msg = ('No key file specified and unable to load key ' + 'from "%s" %s' % (self.cert_file, e)) + raise exc.SSLConfigurationError(msg) + + if self.key_file: + try: + self.context.use_privatekey_file(self.key_file) + except Exception as e: + msg = 'Unable to load key from "%s" %s' % (self.key_file, e) + raise exc.SSLConfigurationError(msg) + + if self.cacert: + try: + self.context.load_verify_locations(to_bytes(self.cacert)) + except Exception as e: + msg = 'Unable to load CA from "%s" %s' % (self.cacert, e) + raise exc.SSLConfigurationError(msg) + else: + self.context.set_default_verify_paths() + + def connect(self): + """ + Connect to an SSL port using the OpenSSL library and apply + per-connection parameters. + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + if self.timeout is not None: + # '0' microseconds + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVTIMEO, + struct.pack('LL', self.timeout, 0)) + self.sock = OpenSSLConnectionDelegator(self.context, sock) + self.sock.connect((self.host, self.port)) diff --git a/glanceclient/common/utils.py b/glanceclient/common/utils.py index 6cdc364a..04f5addc 100644 --- a/glanceclient/common/utils.py +++ b/glanceclient/common/utils.py @@ -16,6 +16,7 @@ from __future__ import print_function import errno +import hashlib import os import re import sys @@ -335,3 +336,22 @@ def print_image(image_obj, max_col_width=None): print_dict(image, max_column_width=max_col_width) else: print_dict(image) + + +def integrity_iter(iter, checksum): + """ + Check image data integrity. + + :raises: IOError + """ + md5sum = hashlib.md5() + for chunk in iter: + yield chunk + if isinstance(chunk, six.string_types): + chunk = six.b(chunk) + md5sum.update(chunk) + md5sum = md5sum.hexdigest() + if md5sum != checksum: + raise IOError(errno.EPIPE, + 'Corrupt image download. Checksum was %s expected %s' % + (md5sum, checksum)) diff --git a/glanceclient/exc.py b/glanceclient/exc.py index 9caa24d4..3eeaffa1 100644 --- a/glanceclient/exc.py +++ b/glanceclient/exc.py @@ -152,7 +152,7 @@ for obj_name in dir(sys.modules[__name__]): def from_response(response, body=None): """Return an instance of an HTTPException based on httplib response.""" - cls = _code_map.get(response.status, HTTPException) + cls = _code_map.get(response.status_code, HTTPException) if body: details = body.replace('\n\n', '\n') return cls(details=details) diff --git a/glanceclient/v1/client.py b/glanceclient/v1/client.py index 23bb7377..aeb94a2a 100644 --- a/glanceclient/v1/client.py +++ b/glanceclient/v1/client.py @@ -13,10 +13,10 @@ # License for the specific language governing permissions and limitations # under the License. -from glanceclient.common import http +from glanceclient.common.http import HTTPClient from glanceclient.common import utils -from glanceclient.v1 import image_members -from glanceclient.v1 import images +from glanceclient.v1.image_members import ImageMemberManager +from glanceclient.v1.images import ImageManager class Client(object): @@ -31,7 +31,7 @@ class Client(object): def __init__(self, endpoint, *args, **kwargs): """Initialize a new client for the Images v1 API.""" - self.http_client = http.HTTPClient(utils.strip_version(endpoint), - *args, **kwargs) - self.images = images.ImageManager(self.http_client) - self.image_members = image_members.ImageMemberManager(self.http_client) + self.http_client = HTTPClient(utils.strip_version(endpoint), + *args, **kwargs) + self.images = ImageManager(self.http_client) + self.image_members = ImageMemberManager(self.http_client) diff --git a/glanceclient/v1/image_members.py b/glanceclient/v1/image_members.py index f464fb8c..d940a5f8 100644 --- a/glanceclient/v1/image_members.py +++ b/glanceclient/v1/image_members.py @@ -34,7 +34,7 @@ class ImageMemberManager(base.ManagerWithFind): def get(self, image, member_id): image_id = base.getid(image) url = '/v1/images/%s/members/%s' % (image_id, member_id) - resp, body = self.client.json_request('GET', url) + resp, body = self.client.get(url) member = body['member'] member['image_id'] = image_id return ImageMember(self, member, loaded=True) @@ -60,7 +60,7 @@ class ImageMemberManager(base.ManagerWithFind): def _list_by_image(self, image): image_id = base.getid(image) url = '/v1/images/%s/members' % image_id - resp, body = self.client.json_request('GET', url) + resp, body = self.client.get(url) out = [] for member in body['members']: member['image_id'] = image_id @@ -70,7 +70,7 @@ class ImageMemberManager(base.ManagerWithFind): def _list_by_member(self, member): member_id = base.getid(member) url = '/v1/shared-images/%s' % member_id - resp, body = self.client.json_request('GET', url) + resp, body = self.client.get(url) out = [] for member in body['shared_images']: member['member_id'] = member_id @@ -84,7 +84,7 @@ class ImageMemberManager(base.ManagerWithFind): """Creates an image.""" url = '/v1/images/%s/members/%s' % (base.getid(image), member_id) body = {'member': {'can_share': can_share}} - self._put(url, json=body) + self.client.put(url, data=body) def replace(self, image, members): memberships = [] @@ -100,4 +100,4 @@ class ImageMemberManager(base.ManagerWithFind): obj['can_share'] = member['can_share'] memberships.append(obj) url = '/v1/images/%s/members' % base.getid(image) - self.client.json_request('PUT', url, {}, {'memberships': memberships}) + self.client.put(url, data={'memberships': memberships}) diff --git a/glanceclient/v1/images.py b/glanceclient/v1/images.py index d2af5954..87060c23 100644 --- a/glanceclient/v1/images.py +++ b/glanceclient/v1/images.py @@ -14,10 +14,9 @@ # under the License. import copy -import json import six -from six.moves.urllib import parse +import six.moves.urllib.parse as urlparse from glanceclient.common import utils from glanceclient.openstack.common.apiclient import base @@ -60,12 +59,12 @@ class ImageManager(base.ManagerWithFind): resource_class = Image def _list(self, url, response_key, obj_class=None, body=None): - resp = self.client.get(url) + resp, body = self.client.get(url) if obj_class is None: obj_class = self.resource_class - data = resp.json()[response_key] + data = body[response_key] return ([obj_class(self, res, loaded=True) for res in data if res], resp) @@ -123,13 +122,12 @@ class ImageManager(base.ManagerWithFind): :rtype: :class:`Image` """ image_id = base.getid(image) - resp, body = self.client.raw_request( - 'HEAD', '/v1/images/%s' % parse.quote(str(image_id))) - meta = self._image_meta_from_headers(dict(resp.getheaders())) + resp, body = self.client.head('/v1/images/%s' + % urlparse.quote(str(image_id))) + meta = self._image_meta_from_headers(resp.headers) return_request_id = kwargs.get('return_req_id', None) if return_request_id is not None: - return_request_id.append(resp.getheader(OS_REQ_ID_HDR, None)) - + return_request_id.append(resp.headers.get(OS_REQ_ID_HDR, None)) return Image(self, meta) def data(self, image, do_checksum=True, **kwargs): @@ -140,14 +138,14 @@ class ImageManager(base.ManagerWithFind): :rtype: iterable containing image data """ image_id = base.getid(image) - resp, body = self.client.raw_request( - 'GET', '/v1/images/%s' % parse.quote(str(image_id))) - checksum = resp.getheader('x-image-meta-checksum', None) + resp, body = self.client.get('/v1/images/%s' + % urlparse.quote(str(image_id))) + checksum = resp.headers.get('x-image-meta-checksum', None) if do_checksum and checksum is not None: - body.set_checksum(checksum) + return utils.integrity_iter(body, checksum) return_request_id = kwargs.get('return_req_id', None) if return_request_id is not None: - return_request_id.append(resp.getheader(OS_REQ_ID_HDR, None)) + return_request_id.append(resp.headers.get(OS_REQ_ID_HDR, None)) return body @@ -194,11 +192,11 @@ class ImageManager(base.ManagerWithFind): # trying to encode them qp[param] = strutils.safe_encode(value) - url = '/v1/images/detail?%s' % parse.urlencode(qp) + url = '/v1/images/detail?%s' % urlparse.urlencode(qp) images, resp = self._list(url, "images") if return_request_id is not None: - return_request_id.append(resp.getheader(OS_REQ_ID_HDR, None)) + return_request_id.append(resp.headers.get(OS_REQ_ID_HDR, None)) for image in images: if filter_owner(owner, image): @@ -253,10 +251,11 @@ class ImageManager(base.ManagerWithFind): def delete(self, image, **kwargs): """Delete an image.""" - resp = self._delete("/v1/images/%s" % base.getid(image))[0] + url = "/v1/images/%s" % base.getid(image) + resp, body = self.client.delete(url) return_request_id = kwargs.get('return_req_id', None) if return_request_id is not None: - return_request_id.append(resp.getheader(OS_REQ_ID_HDR, None)) + return_request_id.append(resp.headers.get(OS_REQ_ID_HDR, None)) def create(self, **kwargs): """Create an image @@ -284,12 +283,12 @@ class ImageManager(base.ManagerWithFind): if copy_from is not None: hdrs['x-glance-api-copy-from'] = copy_from - resp, body_iter = self.client.raw_request( - 'POST', '/v1/images', headers=hdrs, body=image_data) - body = json.loads(''.join([c for c in body_iter])) + resp, body = self.client.post('/v1/images', + headers=hdrs, + data=image_data) return_request_id = kwargs.get('return_req_id', None) if return_request_id is not None: - return_request_id.append(resp.getheader(OS_REQ_ID_HDR, None)) + return_request_id.append(resp.headers.get(OS_REQ_ID_HDR, None)) return Image(self, self._format_image_meta_for_user(body['image'])) @@ -327,11 +326,9 @@ class ImageManager(base.ManagerWithFind): hdrs['x-glance-api-copy-from'] = copy_from url = '/v1/images/%s' % base.getid(image) - resp, body_iter = self.client.raw_request( - 'PUT', url, headers=hdrs, body=image_data) - body = json.loads(''.join([c for c in body_iter])) + resp, body = self.client.put(url, headers=hdrs, data=image_data) return_request_id = kwargs.get('return_req_id', None) if return_request_id is not None: - return_request_id.append(resp.getheader(OS_REQ_ID_HDR, None)) + return_request_id.append(resp.headers.get(OS_REQ_ID_HDR, None)) return Image(self, self._format_image_meta_for_user(body['image'])) diff --git a/glanceclient/v2/image_members.py b/glanceclient/v2/image_members.py index b2632a29..dcf4ac25 100644 --- a/glanceclient/v2/image_members.py +++ b/glanceclient/v2/image_members.py @@ -21,25 +21,22 @@ class Controller(object): def list(self, image_id): url = '/v2/images/%s/members' % image_id - resp, body = self.http_client.json_request('GET', url) + resp, body = self.http_client.get(url) for member in body['members']: yield self.model(member) def delete(self, image_id, member_id): - self.http_client.json_request('DELETE', - '/v2/images/%s/members/%s' % - (image_id, member_id)) + self.http_client.delete('/v2/images/%s/members/%s' % + (image_id, member_id)) def update(self, image_id, member_id, member_status): url = '/v2/images/%s/members/%s' % (image_id, member_id) body = {'status': member_status} - resp, updated_member = self.http_client.json_request('PUT', url, - body=body) + resp, updated_member = self.http_client.put(url, data=body) return self.model(updated_member) def create(self, image_id, member_id): url = '/v2/images/%s/members' % image_id body = {'member': member_id} - resp, created_member = self.http_client.json_request('POST', url, - body=body) + resp, created_member = self.http_client.post(url, data=body) return self.model(created_member) diff --git a/glanceclient/v2/image_tags.py b/glanceclient/v2/image_tags.py index a943d6a5..5c036483 100644 --- a/glanceclient/v2/image_tags.py +++ b/glanceclient/v2/image_tags.py @@ -27,7 +27,7 @@ class Controller(object): :param tag_value: value of the tag. """ url = '/v2/images/%s/tags/%s' % (image_id, tag_value) - self.http_client.json_request('PUT', url) + self.http_client.put(url) def delete(self, image_id, tag_value): """ @@ -37,4 +37,4 @@ class Controller(object): :param tag_value: tag value to be deleted. """ url = '/v2/images/%s/tags/%s' % (image_id, tag_value) - self.http_client.json_request('DELETE', url) + self.http_client.delete(url) diff --git a/glanceclient/v2/images.py b/glanceclient/v2/images.py index 10b78645..1144e6f2 100644 --- a/glanceclient/v2/images.py +++ b/glanceclient/v2/images.py @@ -16,7 +16,6 @@ import json import six from six.moves.urllib import parse - import warlock from glanceclient.common import utils @@ -42,7 +41,7 @@ class Controller(object): empty_fun = lambda *args, **kwargs: None def paginate(url): - resp, body = self.http_client.json_request('GET', url) + resp, body = self.http_client.get(url) for image in body['images']: # NOTE(bcwaldon): remove 'self' for now until we have # an elegant way to pass it into the model constructor @@ -94,7 +93,7 @@ class Controller(object): def get(self, image_id): url = '/v2/images/%s' % image_id - resp, body = self.http_client.json_request('GET', url) + resp, body = self.http_client.get(url) #NOTE(bcwaldon): remove 'self' for now until we have an elegant # way to pass it into the model constructor without conflict body.pop('self', None) @@ -108,11 +107,12 @@ class Controller(object): :param do_checksum: Enable/disable checksum validation. """ url = '/v2/images/%s/file' % image_id - resp, body = self.http_client.raw_request('GET', url) - checksum = resp.getheader('content-md5', None) + resp, body = self.http_client.get(url) + checksum = resp.headers.get('content-md5', None) if do_checksum and checksum is not None: - body.set_checksum(checksum) - return body + return utils.integrity_iter(body, checksum) + else: + return body def upload(self, image_id, image_data, image_size=None): """ @@ -124,14 +124,17 @@ class Controller(object): """ url = '/v2/images/%s/file' % image_id hdrs = {'Content-Type': 'application/octet-stream'} - self.http_client.raw_request('PUT', url, - headers=hdrs, - body=image_data, - content_length=image_size) + if image_size: + body = {'image_data': image_data, + 'image_size': image_size} + else: + body = image_data + self.http_client.put(url, headers=hdrs, data=body) def delete(self, image_id): """Delete an image.""" - self.http_client.json_request('DELETE', '/v2/images/%s' % image_id) + url = '/v2/images/%s' % image_id + self.http_client.delete(url) def create(self, **kwargs): """Create an image.""" @@ -144,7 +147,7 @@ class Controller(object): except warlock.InvalidOperation as e: raise TypeError(utils.exception_to_str(e)) - resp, body = self.http_client.json_request('POST', url, body=image) + resp, body = self.http_client.post(url, data=image) #NOTE(esheffield): remove 'self' for now until we have an elegant # way to pass it into the model constructor without conflict body.pop('self', None) @@ -178,9 +181,7 @@ class Controller(object): url = '/v2/images/%s' % image_id hdrs = {'Content-Type': 'application/openstack-images-v2.1-json-patch'} - self.http_client.raw_request('PATCH', url, - headers=hdrs, - body=image.patch) + self.http_client.patch(url, headers=hdrs, data=image.patch) #NOTE(bcwaldon): calling image.patch doesn't clear the changes, so # we need to fetch the image again to get a clean history. This is @@ -197,9 +198,7 @@ class Controller(object): def _send_image_update_request(self, image_id, patch_body): url = '/v2/images/%s' % image_id hdrs = {'Content-Type': 'application/openstack-images-v2.1-json-patch'} - self.http_client.raw_request('PATCH', url, - headers=hdrs, - body=json.dumps(patch_body)) + self.http_client.patch(url, headers=hdrs, data=json.dumps(patch_body)) def add_location(self, image_id, url, metadata): """Add a new location entry to an image's list of locations. diff --git a/glanceclient/v2/schemas.py b/glanceclient/v2/schemas.py index 57f6cc7f..7cd169d0 100644 --- a/glanceclient/v2/schemas.py +++ b/glanceclient/v2/schemas.py @@ -81,5 +81,5 @@ class Controller(object): def get(self, schema_name): uri = '/v2/schemas/%s' % schema_name - _, raw_schema = self.http_client.json_request('GET', uri) + _, raw_schema = self.http_client.get(uri) return Schema(raw_schema) diff --git a/requirements.txt b/requirements.txt index b2d752f3..dfae8b71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,6 @@ argparse PrettyTable>=0.7,<0.8 python-keystoneclient>=0.9.0 pyOpenSSL>=0.11 +requests>=1.1 warlock>=1.0.1,<2 six>=1.7.0 diff --git a/tests/test_exc.py b/tests/test_exc.py index d5105bcb..c8ad2dfc 100644 --- a/tests/test_exc.py +++ b/tests/test_exc.py @@ -12,18 +12,16 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. - -import collections +import mock import testtools from glanceclient import exc -FakeResponse = collections.namedtuple('HTTPResponse', ['status']) - - class TestHTTPExceptions(testtools.TestCase): def test_from_response(self): """exc.from_response should return instance of an HTTP exception.""" - out = exc.from_response(FakeResponse(400)) + mock_resp = mock.Mock() + mock_resp.status_code = 400 + out = exc.from_response(mock_resp) self.assertIsInstance(out, exc.HTTPBadRequest) diff --git a/tests/test_http.py b/tests/test_http.py index 8dabdba7..0c8a4acd 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -12,21 +12,18 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. +import json -import errno -import socket - -import mock from mox3 import mox +import requests import six -from six.moves import http_client from six.moves.urllib import parse -import tempfile import testtools +import types import glanceclient from glanceclient.common import http -from glanceclient.common import utils as client_utils +from glanceclient.common import https from glanceclient import exc from tests import utils @@ -36,8 +33,7 @@ class TestClient(testtools.TestCase): def setUp(self): super(TestClient, self).setUp() self.mock = mox.Mox() - self.mock.StubOutWithMock(http_client.HTTPConnection, 'request') - self.mock.StubOutWithMock(http_client.HTTPConnection, 'getresponse') + self.mock.StubOutWithMock(requests.Session, 'request') self.endpoint = 'http://example.com:9292' self.client = http.HTTPClient(self.endpoint, token=u'abc123') @@ -85,14 +81,16 @@ class TestClient(testtools.TestCase): And the error should list the host and port that refused the connection """ - http_client.HTTPConnection.request( + requests.Session.request( mox.IgnoreArg(), mox.IgnoreArg(), + data=mox.IgnoreArg(), headers=mox.IgnoreArg(), - ).AndRaise(socket.error()) + stream=mox.IgnoreArg(), + ).AndRaise(requests.exceptions.ConnectionError()) self.mock.ReplayAll() try: - self.client.json_request('GET', '/v1/images/detail?limit=20') + self.client.get('/v1/images/detail?limit=20') #NOTE(alaski) We expect exc.CommunicationError to be raised # so we should never reach this point. try/except is used here # rather than assertRaises() so that we can check the body of @@ -103,47 +101,23 @@ class TestClient(testtools.TestCase): (comm_err.message, self.endpoint)) self.assertTrue(self.endpoint in comm_err.message, fail_msg) - def test_request_redirected(self): - resp = utils.FakeResponse({'location': 'http://www.example.com'}, - status=302, body=six.BytesIO()) - http_client.HTTPConnection.request( - mox.IgnoreArg(), - mox.IgnoreArg(), - headers=mox.IgnoreArg(), - ) - http_client.HTTPConnection.getresponse().AndReturn(resp) - - # The second request should be to the redirected location - expected_response = b'Ok' - resp2 = utils.FakeResponse({}, six.BytesIO(expected_response)) - http_client.HTTPConnection.request( - 'GET', - 'http://www.example.com', - headers=mox.IgnoreArg(), - ) - http_client.HTTPConnection.getresponse().AndReturn(resp2) - - self.mock.ReplayAll() - - self.client.json_request('GET', '/v1/images/detail') - def test_http_encoding(self): - http_client.HTTPConnection.request( - mox.IgnoreArg(), - mox.IgnoreArg(), - headers=mox.IgnoreArg()) - # Lets fake the response - # returned by httplib - expected_response = b'Ok' - fake = utils.FakeResponse({}, six.BytesIO(expected_response)) - http_client.HTTPConnection.getresponse().AndReturn(fake) + # returned by requests + response = 'Ok' + headers = {"Content-Type": "text/plain"} + fake = utils.FakeResponse(headers, six.StringIO(response)) + requests.Session.request( + mox.IgnoreArg(), + mox.IgnoreArg(), + data=mox.IgnoreArg(), + stream=mox.IgnoreArg(), + headers=mox.IgnoreArg()).AndReturn(fake) self.mock.ReplayAll() headers = {"test": u'ni\xf1o'} - resp, body = self.client.raw_request('GET', '/v1/images/detail', - headers=headers) - self.assertEqual(fake, resp) + resp, body = self.client.get('/v1/images/detail', headers=headers) + self.assertEqual(resp, fake) def test_headers_encoding(self): value = u'ni\xf1o' @@ -156,153 +130,19 @@ class TestClient(testtools.TestCase): def test_raw_request(self): " Verify the path being used for HTTP requests reflects accurately. " - - def check_request(method, path, **kwargs): - self.assertEqual('GET', method) - # NOTE(kmcdonald): See bug #1179984 for more details. - self.assertEqual('/v1/images/detail', path) - - http_client.HTTPConnection.request( + headers = {"Content-Type": "text/plain"} + response = 'Ok' + fake = utils.FakeResponse({}, six.StringIO(response)) + requests.Session.request( mox.IgnoreArg(), mox.IgnoreArg(), - headers=mox.IgnoreArg()).WithSideEffects(check_request) - - # fake the response returned by httplib - fake = utils.FakeResponse({}, six.BytesIO(b'Ok')) - http_client.HTTPConnection.getresponse().AndReturn(fake) + data=mox.IgnoreArg(), + stream=mox.IgnoreArg(), + headers=mox.IgnoreArg()).AndReturn(fake) self.mock.ReplayAll() - resp, body = self.client.raw_request('GET', '/v1/images/detail') - self.assertEqual(fake, resp) - - def test_customized_path_raw_request(self): - """ - Verify the customized path being used for HTTP requests - reflects accurately - """ - - def check_request(method, path, **kwargs): - self.assertEqual('GET', method) - self.assertEqual('/customized-path/v1/images/detail', path) - - # NOTE(yuyangbj): see bug 1230032 to get more info - endpoint = 'http://example.com:9292/customized-path' - client = http.HTTPClient(endpoint, token=u'abc123') - self.assertEqual('/customized-path', client.endpoint_path) - - http_client.HTTPConnection.request( - mox.IgnoreArg(), - mox.IgnoreArg(), - headers=mox.IgnoreArg()).WithSideEffects(check_request) - - # fake the response returned by httplib - fake = utils.FakeResponse({}, six.BytesIO(b'Ok')) - http_client.HTTPConnection.getresponse().AndReturn(fake) - self.mock.ReplayAll() - - resp, body = client.raw_request('GET', '/v1/images/detail') - self.assertEqual(fake, resp) - - def test_raw_request_no_content_length(self): - with tempfile.NamedTemporaryFile() as test_file: - test_file.write(b'abcd') - test_file.seek(0) - data_length = 4 - self.assertEqual(data_length, - client_utils.get_file_size(test_file)) - - exp_resp = {'body': test_file} - exp_resp['headers'] = {'Content-Length': str(data_length), - 'Content-Type': 'application/octet-stream'} - - def mock_request(url, method, **kwargs): - return kwargs - - rq_kwargs = {'body': test_file, 'content_length': None} - - with mock.patch.object(self.client, '_http_request') as mock_rq: - mock_rq.side_effect = mock_request - resp = self.client.raw_request('PUT', '/v1/images/detail', - **rq_kwargs) - - rq_kwargs.pop('content_length') - headers = {'Content-Length': str(data_length), - 'Content-Type': 'application/octet-stream'} - rq_kwargs['headers'] = headers - - mock_rq.assert_called_once_with('/v1/images/detail', 'PUT', - **rq_kwargs) - - self.assertEqual(exp_resp, resp) - - def test_raw_request_w_content_length(self): - with tempfile.NamedTemporaryFile() as test_file: - test_file.write(b'abcd') - test_file.seek(0) - data_length = 4 - self.assertEqual(data_length, - client_utils.get_file_size(test_file)) - - exp_resp = {'body': test_file} - # NOTE: we expect the actual file size to be overridden by the - # supplied content length. - exp_resp['headers'] = {'Content-Length': '4', - 'Content-Type': 'application/octet-stream'} - - def mock_request(url, method, **kwargs): - return kwargs - - rq_kwargs = {'body': test_file, 'content_length': data_length} - - with mock.patch.object(self.client, '_http_request') as mock_rq: - mock_rq.side_effect = mock_request - resp = self.client.raw_request('PUT', '/v1/images/detail', - **rq_kwargs) - - rq_kwargs.pop('content_length') - headers = {'Content-Length': str(data_length), - 'Content-Type': 'application/octet-stream'} - rq_kwargs['headers'] = headers - - mock_rq.assert_called_once_with('/v1/images/detail', 'PUT', - **rq_kwargs) - - self.assertEqual(exp_resp, resp) - - def test_raw_request_w_bad_content_length(self): - with tempfile.NamedTemporaryFile() as test_file: - test_file.write(b'abcd') - test_file.seek(0) - self.assertEqual(4, client_utils.get_file_size(test_file)) - - def mock_request(url, method, **kwargs): - return kwargs - - with mock.patch.object(self.client, '_http_request', mock_request): - self.assertRaises(AttributeError, self.client.raw_request, - 'PUT', '/v1/images/detail', body=test_file, - content_length=32) - - def test_connection_refused_raw_request(self): - """ - Should receive a CommunicationError if connection refused. - And the error should list the host and port that refused the - connection - """ - endpoint = 'http://example.com:9292' - client = http.HTTPClient(endpoint, token=u'abc123') - http_client.HTTPConnection.request(mox.IgnoreArg(), mox.IgnoreArg(), - headers=mox.IgnoreArg() - ).AndRaise(socket.error()) - self.mock.ReplayAll() - try: - client.raw_request('GET', '/v1/images/detail?limit=20') - - self.fail('An exception should have bypassed this line.') - except exc.CommunicationError as comm_err: - fail_msg = ("Exception message '%s' should contain '%s'" % - (comm_err.message, endpoint)) - self.assertTrue(endpoint in comm_err.message, fail_msg) + resp, body = self.client.get('/v1/images/detail', headers=headers) + self.assertEqual(resp, fake) def test_parse_endpoint(self): endpoint = 'http://example.com:9292' @@ -313,81 +153,84 @@ class TestClient(testtools.TestCase): query='', fragment='') self.assertEqual(expected, actual) - def test_get_connection_class(self): - endpoint = 'http://example.com:9292' - test_client = http.HTTPClient(endpoint, token=u'adc123') - actual = (test_client.get_connection_class('https')) - self.assertEqual(http.VerifiedHTTPSConnection, actual) - def test_get_connections_kwargs_http(self): endpoint = 'http://example.com:9292' test_client = http.HTTPClient(endpoint, token=u'adc123') - actual = test_client.get_connection_kwargs('http', insecure=True) - self.assertEqual({'timeout': 600.0}, actual) + self.assertEqual(test_client.timeout, 600.0) - def test_get_connections_kwargs_https(self): - endpoint = 'http://example.com:9292' - test_client = http.HTTPClient(endpoint, token=u'adc123') - actual = test_client.get_connection_kwargs('https', insecure=True) - expected = {'cacert': None, - 'cert_file': None, - 'insecure': True, - 'key_file': None, - 'ssl_compression': True, - 'timeout': 600.0} - self.assertEqual(expected, actual) - - def test_log_curl_request_with_non_ascii_char(self): - try: - headers = {'header1': 'value1\xa5\xa6'} - http_client_object = http.HTTPClient(self.endpoint) - http_client_object.log_curl_request('GET', - 'http://www.example.com/\xa5', - {'headers': headers}) - except UnicodeDecodeError as e: - self.fail("Unexpected UnicodeDecodeError exception '%s'" % e) - - -class TestHostResolutionError(testtools.TestCase): - - def setUp(self): - super(TestHostResolutionError, self).setUp() - self.mock = mox.Mox() - self.invalid_host = "example.com.incorrect_top_level_domain" - - def test_incorrect_domain_error(self): - """ - Make sure that using a domain which does not resolve causes an - exception which mentions that specific hostname as a reason for - failure. - """ - class FailingConnectionClass(object): - def __init__(self, *args, **kwargs): - pass - - def putrequest(self, *args, **kwargs): - raise socket.gaierror(-2, "Name or service not known") - - def request(self, *args, **kwargs): - raise socket.gaierror(-2, "Name or service not known") - - self.endpoint = 'http://%s:9292' % (self.invalid_host,) - self.client = http.HTTPClient(self.endpoint, token=u'abc123') - - self.mock.StubOutWithMock(self.client, 'get_connection') - self.client.get_connection().AndReturn(FailingConnectionClass()) + def test_http_chunked_request(self): + # Lets fake the response + # returned by requests + response = "Ok" + data = six.StringIO(response) + fake = utils.FakeResponse({}, data) + requests.Session.request( + mox.IgnoreArg(), + mox.IgnoreArg(), + stream=mox.IgnoreArg(), + data=mox.IsA(types.GeneratorType), + headers=mox.IgnoreArg()).AndReturn(fake) self.mock.ReplayAll() - try: - self.client.raw_request('GET', '/example/path') - self.fail("gaierror should be raised") - except exc.InvalidEndpoint as e: - self.assertTrue(self.invalid_host in str(e), - "exception should contain the hostname") + headers = {"test": u'chunked_request'} + resp, body = self.client.post('/v1/images/', + headers=headers, data=data) + self.assertEqual(resp, fake) - def tearDown(self): - super(TestHostResolutionError, self).tearDown() - self.mock.UnsetStubs() + def test_http_json(self): + data = {"test": "json_request"} + fake = utils.FakeResponse({}, "OK") + + def test_json(passed_data): + """ + This function tests whether the data + being passed to request's method is + a valid json or not. + + This function will be called by pymox + + :params passed_data: The data being + passed to requests.Session.request. + """ + if not isinstance(passed_data, six.string_types): + return False + + try: + passed_data = json.loads(passed_data) + return data == passed_data + except (TypeError, ValueError): + return False + + requests.Session.request( + mox.IgnoreArg(), + mox.IgnoreArg(), + stream=mox.IgnoreArg(), + data=mox.Func(test_json), + headers=mox.IgnoreArg()).AndReturn(fake) + self.mock.ReplayAll() + + headers = {"test": u'chunked_request'} + resp, body = self.client.post('/v1/images/', + headers=headers, + data=data) + self.assertEqual(resp, fake) + + def test_http_chunked_response(self): + headers = {"Content-Type": "application/octet-stream"} + data = "TEST" + fake = utils.FakeResponse(headers, six.StringIO(data)) + + requests.Session.request( + mox.IgnoreArg(), + mox.IgnoreArg(), + stream=mox.IgnoreArg(), + data=mox.IgnoreArg(), + headers=mox.IgnoreArg()).AndReturn(fake) + self.mock.ReplayAll() + headers = {"test": u'chunked_request'} + resp, body = self.client.get('/v1/images/') + self.assertTrue(isinstance(body, types.GeneratorType)) + self.assertEqual([data], list(body)) class TestVerifiedHTTPSConnection(testtools.TestCase): @@ -396,7 +239,7 @@ class TestVerifiedHTTPSConnection(testtools.TestCase): def test_setcontext_unable_to_load_cacert(self): """Add this UT case with Bug#1265730.""" self.assertRaises(exc.SSLConfigurationError, - http.VerifiedHTTPSConnection, + https.VerifiedHTTPSConnection, "127.0.0.1", None, None, @@ -405,45 +248,3 @@ class TestVerifiedHTTPSConnection(testtools.TestCase): None, False, True) - - -class TestResponseBodyIterator(testtools.TestCase): - - def test_iter_default_chunk_size_64k(self): - resp = utils.FakeResponse({}, six.BytesIO(b'X' * 98304)) - iterator = http.ResponseBodyIterator(resp) - chunks = list(iterator) - self.assertEqual([b'X' * 65536, b'X' * 32768], chunks) - - def test_integrity_check_with_correct_checksum(self): - resp = utils.FakeResponse({}, six.BytesIO(b'CCC')) - body = http.ResponseBodyIterator(resp) - body.set_checksum('defb99e69a9f1f6e06f15006b1f166ae') - list(body) - - def test_integrity_check_with_wrong_checksum(self): - resp = utils.FakeResponse({}, six.BytesIO(b'BB')) - body = http.ResponseBodyIterator(resp) - body.set_checksum('wrong') - try: - list(body) - self.fail('integrity checked passed with wrong checksum') - except IOError as e: - self.assertEqual(errno.EPIPE, e.errno) - - def test_set_checksum_in_consumed_iterator(self): - resp = utils.FakeResponse({}, six.BytesIO(b'CCC')) - body = http.ResponseBodyIterator(resp) - list(body) - # Setting checksum for an already consumed iterator should raise an - # AttributeError. - self.assertRaises( - AttributeError, body.set_checksum, - 'defb99e69a9f1f6e06f15006b1f166ae') - - def test_body_size(self): - size = 1000000007 - resp = utils.FakeResponse( - {'content-length': str(size)}, six.BytesIO(b'BB')) - body = http.ResponseBodyIterator(resp) - self.assertEqual(size, len(body)) diff --git a/tests/test_shell.py b/tests/test_shell.py index ebe77e08..28f6b132 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -105,13 +105,11 @@ class ShellCacheSchemaTest(utils.TestCase): super(ShellCacheSchemaTest, self).setUp() self._mock_client_setup() self._mock_shell_setup() - os.path.exists = mock.MagicMock() self.cache_dir = '/dir_for_cached_schema' self.cache_file = self.cache_dir + '/image_schema.json' def tearDown(self): super(ShellCacheSchemaTest, self).tearDown() - os.path.exists.reset_mock() def _mock_client_setup(self): self.schema_dict = { @@ -137,27 +135,8 @@ class ShellCacheSchemaTest(utils.TestCase): return Args(args) @mock.patch('six.moves.builtins.open', new=mock.mock_open(), create=True) - def test_cache_schema_gets_when_not_exists(self): - mocked_path_exists_result_lst = [True, False] - os.path.exists.side_effect = \ - lambda *args: mocked_path_exists_result_lst.pop(0) - - options = { - 'get_schema': False - } - - self.shell._cache_schema(self._make_args(options), - home_dir=self.cache_dir) - - self.assertEqual(4, open.mock_calls.__len__()) - self.assertEqual(mock.call(self.cache_file, 'w'), open.mock_calls[0]) - self.assertEqual(mock.call().write(json.dumps(self.schema_dict)), - open.mock_calls[2]) - - @mock.patch('six.moves.builtins.open', new=mock.mock_open(), create=True) - def test_cache_schema_gets_when_forced(self): - os.path.exists.return_value = True - + @mock.patch('os.path.exists', return_value=True) + def test_cache_schema_gets_when_forced(self, exists_mock): options = { 'get_schema': True } @@ -171,9 +150,23 @@ class ShellCacheSchemaTest(utils.TestCase): open.mock_calls[2]) @mock.patch('six.moves.builtins.open', new=mock.mock_open(), create=True) - def test_cache_schema_leaves_when_present_not_forced(self): - os.path.exists.return_value = True + @mock.patch('os.path.exists', side_effect=[True, False]) + def test_cache_schema_gets_when_not_exists(self, exists_mock): + options = { + 'get_schema': False + } + self.shell._cache_schema(self._make_args(options), + home_dir=self.cache_dir) + + self.assertEqual(4, open.mock_calls.__len__()) + self.assertEqual(mock.call(self.cache_file, 'w'), open.mock_calls[0]) + self.assertEqual(mock.call().write(json.dumps(self.schema_dict)), + open.mock_calls[2]) + + @mock.patch('six.moves.builtins.open', new=mock.mock_open(), create=True) + @mock.patch('os.path.exists', return_value=True) + def test_cache_schema_leaves_when_present_not_forced(self, exists_mock): options = { 'get_schema': False } @@ -183,5 +176,5 @@ class ShellCacheSchemaTest(utils.TestCase): os.path.exists.assert_any_call(self.cache_dir) os.path.exists.assert_any_call(self.cache_file) - self.assertEqual(2, os.path.exists.call_count) + self.assertEqual(2, exists_mock.call_count) self.assertEqual(0, open.mock_calls.__len__()) diff --git a/tests/test_ssl.py b/tests/test_ssl.py index f8278a77..ecbccfa6 100644 --- a/tests/test_ssl.py +++ b/tests/test_ssl.py @@ -16,9 +16,11 @@ import os from OpenSSL import crypto +from requests.packages.urllib3 import poolmanager import testtools from glanceclient.common import http +from glanceclient.common import https from glanceclient import exc @@ -26,6 +28,26 @@ TEST_VAR_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), 'var')) +class TestRequestsIntegration(testtools.TestCase): + + def test_pool_patch(self): + client = http.HTTPClient("https://localhost", + ssl_compression=True) + self.assertNotEqual(https.HTTPSConnectionPool, + poolmanager.pool_classes_by_scheme["https"]) + + adapter = client.session.adapters.get("https://") + self.assertFalse(isinstance(adapter, https.HTTPSAdapter)) + + client = http.HTTPClient("https://localhost", + ssl_compression=False) + self.assertEqual(https.HTTPSConnectionPool, + poolmanager.pool_classes_by_scheme["https"]) + + adapter = client.session.adapters.get("https://") + self.assertTrue(isinstance(adapter, https.HTTPSAdapter)) + + class TestVerifiedHTTPSConnection(testtools.TestCase): def test_ssl_init_ok(self): """ @@ -35,10 +57,10 @@ class TestVerifiedHTTPSConnection(testtools.TestCase): cert_file = os.path.join(TEST_VAR_DIR, 'certificate.crt') cacert = os.path.join(TEST_VAR_DIR, 'ca.crt') try: - http.VerifiedHTTPSConnection('127.0.0.1', 0, - key_file=key_file, - cert_file=cert_file, - cacert=cacert) + https.VerifiedHTTPSConnection('127.0.0.1', 0, + key_file=key_file, + cert_file=cert_file, + cacert=cacert) except exc.SSLConfigurationError: self.fail('Failed to init VerifiedHTTPSConnection.') @@ -49,9 +71,9 @@ class TestVerifiedHTTPSConnection(testtools.TestCase): cert_file = os.path.join(TEST_VAR_DIR, 'certificate.crt') cacert = os.path.join(TEST_VAR_DIR, 'ca.crt') try: - http.VerifiedHTTPSConnection('127.0.0.1', 0, - cert_file=cert_file, - cacert=cacert) + https.VerifiedHTTPSConnection('127.0.0.1', 0, + cert_file=cert_file, + cacert=cacert) self.fail('Failed to raise assertion.') except exc.SSLConfigurationError: pass @@ -63,9 +85,9 @@ class TestVerifiedHTTPSConnection(testtools.TestCase): key_file = os.path.join(TEST_VAR_DIR, 'privatekey.key') cacert = os.path.join(TEST_VAR_DIR, 'ca.crt') try: - http.VerifiedHTTPSConnection('127.0.0.1', 0, - key_file=key_file, - cacert=cacert) + https.VerifiedHTTPSConnection('127.0.0.1', 0, + key_file=key_file, + cacert=cacert) except exc.SSLConfigurationError: pass except Exception: @@ -78,9 +100,9 @@ class TestVerifiedHTTPSConnection(testtools.TestCase): cert_file = os.path.join(TEST_VAR_DIR, 'certificate.crt') cacert = os.path.join(TEST_VAR_DIR, 'ca.crt') try: - http.VerifiedHTTPSConnection('127.0.0.1', 0, - cert_file=cert_file, - cacert=cacert) + https.VerifiedHTTPSConnection('127.0.0.1', 0, + cert_file=cert_file, + cacert=cacert) self.fail('Failed to raise assertion.') except exc.SSLConfigurationError: pass @@ -92,9 +114,9 @@ class TestVerifiedHTTPSConnection(testtools.TestCase): cert_file = os.path.join(TEST_VAR_DIR, 'badcert.crt') cacert = os.path.join(TEST_VAR_DIR, 'ca.crt') try: - http.VerifiedHTTPSConnection('127.0.0.1', 0, - cert_file=cert_file, - cacert=cacert) + https.VerifiedHTTPSConnection('127.0.0.1', 0, + cert_file=cert_file, + cacert=cacert) self.fail('Failed to raise assertion.') except exc.SSLConfigurationError: pass @@ -106,9 +128,9 @@ class TestVerifiedHTTPSConnection(testtools.TestCase): cert_file = os.path.join(TEST_VAR_DIR, 'certificate.crt') cacert = os.path.join(TEST_VAR_DIR, 'badca.crt') try: - http.VerifiedHTTPSConnection('127.0.0.1', 0, - cert_file=cert_file, - cacert=cacert) + https.VerifiedHTTPSConnection('127.0.0.1', 0, + cert_file=cert_file, + cacert=cacert) self.fail('Failed to raise assertion.') except exc.SSLConfigurationError: pass @@ -123,7 +145,7 @@ class TestVerifiedHTTPSConnection(testtools.TestCase): # The expected cert should have CN=0.0.0.0 self.assertEqual('0.0.0.0', cert.get_subject().commonName) try: - conn = http.VerifiedHTTPSConnection('0.0.0.0', 0) + conn = https.VerifiedHTTPSConnection('0.0.0.0', 0) conn.verify_callback(None, cert, 0, 0, 1) except Exception: self.fail('Unexpected exception.') @@ -138,7 +160,7 @@ class TestVerifiedHTTPSConnection(testtools.TestCase): # The expected cert should have CN=*.pong.example.com self.assertEqual('*.pong.example.com', cert.get_subject().commonName) try: - conn = http.VerifiedHTTPSConnection('ping.pong.example.com', 0) + conn = https.VerifiedHTTPSConnection('ping.pong.example.com', 0) conn.verify_callback(None, cert, 0, 0, 1) except Exception: self.fail('Unexpected exception.') @@ -153,13 +175,13 @@ class TestVerifiedHTTPSConnection(testtools.TestCase): # The expected cert should have CN=0.0.0.0 self.assertEqual('0.0.0.0', cert.get_subject().commonName) try: - conn = http.VerifiedHTTPSConnection('alt1.example.com', 0) + conn = https.VerifiedHTTPSConnection('alt1.example.com', 0) conn.verify_callback(None, cert, 0, 0, 1) except Exception: self.fail('Unexpected exception.') try: - conn = http.VerifiedHTTPSConnection('alt2.example.com', 0) + conn = https.VerifiedHTTPSConnection('alt2.example.com', 0) conn.verify_callback(None, cert, 0, 0, 1) except Exception: self.fail('Unexpected exception.') @@ -174,19 +196,19 @@ class TestVerifiedHTTPSConnection(testtools.TestCase): # The expected cert should have CN=0.0.0.0 self.assertEqual('0.0.0.0', cert.get_subject().commonName) try: - conn = http.VerifiedHTTPSConnection('alt1.example.com', 0) + conn = https.VerifiedHTTPSConnection('alt1.example.com', 0) conn.verify_callback(None, cert, 0, 0, 1) except Exception: self.fail('Unexpected exception.') try: - conn = http.VerifiedHTTPSConnection('alt2.example.com', 0) + conn = https.VerifiedHTTPSConnection('alt2.example.com', 0) conn.verify_callback(None, cert, 0, 0, 1) except Exception: self.fail('Unexpected exception.') try: - conn = http.VerifiedHTTPSConnection('alt3.example.net', 0) + conn = https.VerifiedHTTPSConnection('alt3.example.net', 0) conn.verify_callback(None, cert, 0, 0, 1) self.fail('Failed to raise assertion.') except exc.SSLCertificateError: @@ -202,7 +224,7 @@ class TestVerifiedHTTPSConnection(testtools.TestCase): # The expected cert should have CN=0.0.0.0 self.assertEqual('0.0.0.0', cert.get_subject().commonName) try: - conn = http.VerifiedHTTPSConnection('mismatch.example.com', 0) + conn = https.VerifiedHTTPSConnection('mismatch.example.com', 0) except Exception: self.fail('Failed to init VerifiedHTTPSConnection.') @@ -220,10 +242,9 @@ class TestVerifiedHTTPSConnection(testtools.TestCase): self.assertEqual('openstack.example.com', cert.get_subject().commonName) try: - conn = http.VerifiedHTTPSConnection('openstack.example.com', 0) + conn = https.VerifiedHTTPSConnection('openstack.example.com', 0) except Exception: self.fail('Failed to init VerifiedHTTPSConnection.') - self.assertRaises(exc.SSLCertificateError, conn.verify_callback, None, cert, 0, 0, 1) @@ -236,7 +257,7 @@ class TestVerifiedHTTPSConnection(testtools.TestCase): key_file = 'fake.key' self.assertRaises( exc.SSLConfigurationError, - http.VerifiedHTTPSConnection, '127.0.0.1', + https.VerifiedHTTPSConnection, '127.0.0.1', 0, key_file=key_file, cert_file=cert_file, cacert=cacert) @@ -248,7 +269,7 @@ class TestVerifiedHTTPSConnection(testtools.TestCase): cert_file = os.path.join(TEST_VAR_DIR, 'certificate.crt') cacert = os.path.join(TEST_VAR_DIR, 'ca.crt') try: - http.VerifiedHTTPSConnection( + https.VerifiedHTTPSConnection( '127.0.0.1', 0, key_file=key_file, cert_file=cert_file, @@ -264,7 +285,7 @@ class TestVerifiedHTTPSConnection(testtools.TestCase): cert_file = os.path.join(TEST_VAR_DIR, 'certificate.crt') cacert = os.path.join(TEST_VAR_DIR, 'ca.crt') try: - http.VerifiedHTTPSConnection( + https.VerifiedHTTPSConnection( '127.0.0.1', 0, key_file=key_file, cert_file=cert_file, @@ -286,9 +307,9 @@ class TestVerifiedHTTPSConnection(testtools.TestCase): cert_file = cert_file.encode('ascii', 'strict').decode('utf-8') cacert = cacert.encode('ascii', 'strict').decode('utf-8') try: - http.VerifiedHTTPSConnection('127.0.0.1', 0, - key_file=key_file, - cert_file=cert_file, - cacert=cacert) + https.VerifiedHTTPSConnection('127.0.0.1', 0, + key_file=key_file, + cert_file=cert_file, + cacert=cacert) except exc.SSLConfigurationError: self.fail('Failed to init VerifiedHTTPSConnection.') diff --git a/tests/utils.py b/tests/utils.py index 2eb5f02d..0fa0e2e4 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -14,64 +14,53 @@ # under the License. import copy -import requests +import json import six import testtools -from glanceclient.common import http - class FakeAPI(object): def __init__(self, fixtures): self.fixtures = fixtures self.calls = [] - def _request(self, method, url, headers=None, body=None, + def _request(self, method, url, headers=None, data=None, content_length=None): - call = (method, url, headers or {}, body) + call = (method, url, headers or {}, data) if content_length is not None: call = tuple(list(call) + [content_length]) self.calls.append(call) - return self.fixtures[url][method] + fixture = self.fixtures[url][method] - def raw_request(self, *args, **kwargs): - fixture = self._request(*args, **kwargs) - resp = FakeResponse(fixture[0], six.StringIO(fixture[1])) - body_iter = http.ResponseBodyIterator(resp) - return resp, body_iter + data = fixture[1] + if isinstance(fixture[1], six.string_types): + try: + data = json.loads(fixture[1]) + except ValueError: + data = six.StringIO(fixture[1]) - def json_request(self, *args, **kwargs): - fixture = self._request(*args, **kwargs) - return FakeResponse(fixture[0]), fixture[1] + return FakeResponse(fixture[0], fixture[1]), data - def client_request(self, method, url, **kwargs): - if 'json' in kwargs and 'body' not in kwargs: - kwargs['body'] = kwargs.pop('json') - resp, body = self.json_request(method, url, **kwargs) - resp.json = lambda: body - resp.content = bool(body) - return resp + def get(self, *args, **kwargs): + return self._request('GET', *args, **kwargs) - def head(self, url, **kwargs): - return self.client_request("HEAD", url, **kwargs) + def post(self, *args, **kwargs): + return self._request('POST', *args, **kwargs) - def get(self, url, **kwargs): - return self.client_request("GET", url, **kwargs) + def put(self, *args, **kwargs): + return self._request('PUT', *args, **kwargs) - def post(self, url, **kwargs): - return self.client_request("POST", url, **kwargs) + def patch(self, *args, **kwargs): + return self._request('PATCH', *args, **kwargs) - def put(self, url, **kwargs): - return self.client_request("PUT", url, **kwargs) + def delete(self, *args, **kwargs): + return self._request('DELETE', *args, **kwargs) - def delete(self, url, **kwargs): - return self.raw_request("DELETE", url, **kwargs) - - def patch(self, url, **kwargs): - return self.client_request("PATCH", url, **kwargs) + def head(self, *args, **kwargs): + return self._request('HEAD', *args, **kwargs) -class FakeResponse(object): +class RawRequest(object): def __init__(self, headers, body=None, version=1.0, status=200, reason="Ok"): """ @@ -97,36 +86,55 @@ class FakeResponse(object): return self.body.read(amt) +class FakeResponse(object): + def __init__(self, headers=None, body=None, + version=1.0, status_code=200, reason="Ok"): + """ + :param headers: dict representing HTTP response headers + :param body: file-like object + :param version: HTTP Version + :param status: Response status code + :param reason: Status code related message. + """ + self.body = body + self.reason = reason + self.version = version + self.headers = headers + self.status_code = status_code + self.raw = RawRequest(headers, body=body, reason=reason, + version=version, status=status_code) + + @property + def ok(self): + return (self.status_code < 400 or + self.status_code >= 600) + + def read(self, amt): + return self.body.read(amt) + + @property + def content(self): + if hasattr(self.body, "read"): + return self.body.read() + return self.body + + def json(self, **kwargs): + return self.body and json.loads(self.content) or "" + + def iter_content(self, chunk_size=1, decode_unicode=False): + while True: + chunk = self.raw.read(chunk_size) + if not chunk: + break + yield chunk + + class TestCase(testtools.TestCase): TEST_REQUEST_BASE = { 'config': {'danger_mode': False}, 'verify': True} -class TestResponse(requests.Response): - """ - Class used to wrap requests.Response and provide some - convenience to initialize with a dict - """ - def __init__(self, data): - self._text = None - super(TestResponse, self) - if isinstance(data, dict): - self.status_code = data.get('status_code', None) - self.headers = data.get('headers', None) - # Fake the text attribute to streamline Response creation - self._text = data.get('text', None) - else: - self.status_code = data - - def __eq__(self, other): - return self.__dict__ == other.__dict__ - - @property - def text(self): - return self._text - - class FakeTTYStdout(six.StringIO): """A Fake stdout that try to emulate a TTY device as much as possible.""" diff --git a/tests/v1/test_images.py b/tests/v1/test_images.py index b42cd829..e63634f7 100644 --- a/tests/v1/test_images.py +++ b/tests/v1/test_images.py @@ -932,7 +932,7 @@ class ParameterFakeAPI(utils.FakeAPI): }, ]} - def json_request(self, method, url, **kwargs): + def get(self, url, **kwargs): self.url = url return utils.FakeResponse({}), ParameterFakeAPI.image_list diff --git a/tests/v1/test_legacy_shell.py b/tests/v1/test_legacy_shell.py index e86d6e65..2ccb748e 100644 --- a/tests/v1/test_legacy_shell.py +++ b/tests/v1/test_legacy_shell.py @@ -257,7 +257,7 @@ class LegacyShellV1Test(testtools.TestCase): args = Image() gc = client.Client('1', 'http://is.invalid') self.assertRaises( - exc.InvalidEndpoint, test_shell.do_update, gc, args) + exc.CommunicationError, test_shell.do_update, gc, args) def test_do_update(self): class Image(): diff --git a/tests/v1/test_shell.py b/tests/v1/test_shell.py index d9cccd5f..71ee11b9 100644 --- a/tests/v1/test_shell.py +++ b/tests/v1/test_shell.py @@ -224,81 +224,81 @@ class ShellInvalidEndpointandParameterTest(utils.TestCase): def test_image_list_invalid_endpoint(self): self.assertRaises( - exc.InvalidEndpoint, self.run_command, 'image-list') + exc.CommunicationError, self.run_command, 'image-list') def test_image_details_invalid_endpoint_legacy(self): self.assertRaises( - exc.InvalidEndpoint, self.run_command, 'details') + exc.CommunicationError, self.run_command, 'details') def test_image_update_invalid_endpoint_legacy(self): self.assertRaises( - exc.InvalidEndpoint, + exc.CommunicationError, self.run_command, 'update {"name":""test}') def test_image_index_invalid_endpoint_legacy(self): self.assertRaises( - exc.InvalidEndpoint, + exc.CommunicationError, self.run_command, 'index') def test_image_create_invalid_endpoint(self): self.assertRaises( - exc.InvalidEndpoint, + exc.CommunicationError, self.run_command, 'image-create') def test_image_delete_invalid_endpoint(self): self.assertRaises( - exc.InvalidEndpoint, + exc.CommunicationError, self.run_command, 'image-delete <fake>') def test_image_download_invalid_endpoint(self): self.assertRaises( - exc.InvalidEndpoint, + exc.CommunicationError, self.run_command, 'image-download <fake>') def test_image_members_invalid_endpoint(self): self.assertRaises( - exc.InvalidEndpoint, + exc.CommunicationError, self.run_command, 'image-members fake_id') def test_members_list_invalid_endpoint(self): self.assertRaises( - exc.InvalidEndpoint, + exc.CommunicationError, self.run_command, 'member-list --image-id fake') def test_member_replace_invalid_endpoint(self): self.assertRaises( - exc.InvalidEndpoint, + exc.CommunicationError, self.run_command, 'members-replace image_id member_id') def test_image_show_invalid_endpoint_legacy(self): self.assertRaises( - exc.InvalidEndpoint, self.run_command, 'show image') + exc.CommunicationError, self.run_command, 'show image') def test_image_show_invalid_endpoint(self): self.assertRaises( - exc.InvalidEndpoint, + exc.CommunicationError, self.run_command, 'image-show --human-readable <IMAGE_ID>') def test_member_images_invalid_endpoint_legacy(self): self.assertRaises( - exc.InvalidEndpoint, + exc.CommunicationError, self.run_command, 'member-images member_id') def test_member_create_invalid_endpoint(self): self.assertRaises( - exc.InvalidEndpoint, + exc.CommunicationError, self.run_command, 'member-create --can-share <IMAGE_ID> <TENANT_ID>') def test_member_delete_invalid_endpoint(self): self.assertRaises( - exc.InvalidEndpoint, + exc.CommunicationError, self.run_command, 'member-delete <IMAGE_ID> <TENANT_ID>') def test_member_add_invalid_endpoint(self): self.assertRaises( - exc.InvalidEndpoint, + exc.CommunicationError, self.run_command, 'member-add <IMAGE_ID> <TENANT_ID>') diff --git a/tests/v2/test_images.py b/tests/v2/test_images.py index c93e34ed..d231a31d 100644 --- a/tests/v2/test_images.py +++ b/tests/v2/test_images.py @@ -514,9 +514,11 @@ class TestController(testtools.TestCase): image_data = 'CCC' image_id = '606b0e88-7c5a-4d54-b5bb-046105d4de6f' self.controller.upload(image_id, image_data, image_size=3) + body = {'image_data': image_data, + 'image_size': 3} expect = [('PUT', '/v2/images/%s/file' % image_id, {'Content-Type': 'application/octet-stream'}, - image_data, 3)] + body)] self.assertEqual(expect, self.api.calls) def test_data_without_checksum(self): diff --git a/tests/v2/test_shell_v2.py b/tests/v2/test_shell_v2.py index 38d2afb4..6d0a3d94 100644 --- a/tests/v2/test_shell_v2.py +++ b/tests/v2/test_shell_v2.py @@ -13,17 +13,12 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. - import json import mock -import six import testtools -from glanceclient.common import http -from glanceclient.common import progressbar from glanceclient.common import utils from glanceclient.v2 import shell as test_shell -from tests import utils as test_utils class ShellV2Test(testtools.TestCase): @@ -208,16 +203,18 @@ class ShellV2Test(testtools.TestCase): utils.print_dict.assert_called_once_with({ 'id': 'pass', 'name': 'IMG-01', 'disk_format': 'vhd'}) - def test_do_location_add_update_with_invalid_json_metadata(self): - args = self._make_args({'id': 'pass', - 'url': 'http://foo/bar', - 'metadata': '{1, 2, 3}'}) - self.assert_exits_with_msg(test_shell.do_location_add, - args, - 'Metadata is not a valid JSON object.') - self.assert_exits_with_msg(test_shell.do_location_update, - args, - 'Metadata is not a valid JSON object.') + def test_do_explain(self): + input = { + 'page_size': 18, + 'id': 'pass', + 'schemas': 'test', + 'model': 'test', + } + args = self._make_args(input) + with mock.patch.object(utils, 'print_list'): + test_shell.do_explain(self.gc, args) + + self.gc.schemas.get.assert_called_once_with('test') def test_do_location_add(self): gc = self.gc @@ -260,19 +257,6 @@ class ShellV2Test(testtools.TestCase): loc['metadata']) utils.print_dict.assert_called_once_with(expect_image) - def test_do_explain(self): - input = { - 'page_size': 18, - 'id': 'pass', - 'schemas': 'test', - 'model': 'test', - } - args = self._make_args(input) - with mock.patch.object(utils, 'print_list'): - test_shell.do_explain(self.gc, args) - - self.gc.schemas.get.assert_called_once_with('test') - def test_image_upload(self): args = self._make_args( {'id': 'IMG-01', 'file': 'test', 'size': 1024, 'progress': False}) @@ -283,46 +267,6 @@ class ShellV2Test(testtools.TestCase): test_shell.do_image_upload(self.gc, args) mocked_upload.assert_called_once_with('IMG-01', 'testfile', 1024) - def test_image_upload_with_progressbar(self): - args = self._make_args( - {'id': 'IMG-01', 'file': 'test', 'size': 1024, 'progress': True}) - - with mock.patch.object(self.gc.images, 'upload') as mocked_upload: - utils.get_data_file = mock.Mock(return_value='testfile') - utils.get_file_size = mock.Mock(return_value=8) - mocked_upload.return_value = None - test_shell.do_image_upload(self.gc, args) - self.assertIsInstance(mocked_upload.call_args[0][1], - progressbar.VerboseFileWrapper) - - def test_image_download(self): - args = self._make_args( - {'id': 'pass', 'file': 'test', 'progress': False}) - - with mock.patch.object(self.gc.images, 'data') as mocked_data: - resp = test_utils.FakeResponse({}, six.StringIO('CCC')) - ret = mocked_data.return_value = http.ResponseBodyIterator(resp) - test_shell.do_image_download(self.gc, args) - - mocked_data.assert_called_once_with('pass') - utils.save_image.assert_called_once_with(ret, 'test') - - def test_image_download_with_progressbar(self): - args = self._make_args( - {'id': 'pass', 'file': 'test', 'progress': True}) - - with mock.patch.object(self.gc.images, 'data') as mocked_data: - resp = test_utils.FakeResponse({}, six.StringIO('CCC')) - mocked_data.return_value = http.ResponseBodyIterator(resp) - test_shell.do_image_download(self.gc, args) - - mocked_data.assert_called_once_with('pass') - utils.save_image.assert_called_once_with(mock.ANY, 'test') - self.assertIsInstance( - utils.save_image.call_args[0][0], - progressbar.VerboseIteratorWrapper - ) - def test_do_image_delete(self): args = self._make_args({'id': 'pass', 'file': 'test'}) with mock.patch.object(self.gc.images, 'delete') as mocked_delete: