diff --git a/cloudpulseclient/common/__init__.py b/cloudpulseclient/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloudpulseclient/common/base.py b/cloudpulseclient/common/base.py new file mode 100644 index 0000000..8853ac0 --- /dev/null +++ b/cloudpulseclient/common/base.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2012 OpenStack LLC. +# 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. + +""" +Base utilities to build API operation managers and objects on top of. +""" + +import copy + +import six.moves.urllib.parse as urlparse + +from cloudpulseclient.openstack.common.apiclient import base + + +def getid(obj): + """Wrapper to get object's ID. + + Abstracts the common pattern of allowing both an object or an + object's ID (UUID) as a parameter when dealing with relationships. + """ + try: + return obj.id + except AttributeError: + return obj + + +class Manager(object): + """Provides CRUD operations with a particular API.""" + resource_class = None + + def __init__(self, api): + self.api = api + + def _create(self, url, body): + resp, body = self.api.json_request('POST', url, body=body) + if body: + return self.resource_class(self, body) + + def _format_body_data(self, body, response_key): + if response_key: + try: + data = body[response_key] + except KeyError: + return [] + else: + data = body + + if not isinstance(data, list): + data = [data] + + return data + + def _list_pagination(self, url, response_key=None, obj_class=None, + limit=None): + """Retrieve a list of items. + + :param url: a partial URL, e.g. '/nodes' + :param response_key: the key to be looked up in response + dictionary, e.g. 'nodes' + :param obj_class: class for constructing the returned objects. + :param limit: maximum number of items to return. If None returns + everything. + + """ + if obj_class is None: + obj_class = self.resource_class + + if limit is not None: + limit = int(limit) + + object_list = [] + object_count = 0 + limit_reached = False + while url: + resp, body = self.api.json_request('GET', url) + data = self._format_body_data(body, response_key) + for obj in data: + object_list.append(obj_class(self, obj, loaded=True)) + object_count += 1 + if limit and object_count >= limit: + # break the for loop + limit_reached = True + break + + # break the while loop and return + if limit_reached: + break + + url = body.get('next') + if url: + # NOTE(lucasagomes): We need to edit the URL to remove + # the scheme and netloc + url_parts = list(urlparse.urlparse(url)) + url_parts[0] = url_parts[1] = '' + url = urlparse.urlunparse(url_parts) + + return object_list + + def _list(self, url, response_key=None, obj_class=None, body=None): + resp, body = self.api.json_request('GET', url) + + if obj_class is None: + obj_class = self.resource_class + + data = self._format_body_data(body, response_key) + return [obj_class(self, res, loaded=True) for res in data if res] + + def _update(self, url, body, method='PATCH', response_key=None): + resp, body = self.api.json_request(method, url, body=body) + # PATCH/PUT requests may not return a body + if body: + return self.resource_class(self, body) + + def _delete(self, url): + self.api.raw_request('DELETE', url) + + +class Resource(base.Resource): + """Represents a particular instance of an object (tenant, user, etc). + + This is pretty much just a bag for attributes. + """ + + def to_dict(self): + return copy.deepcopy(self._info) diff --git a/cloudpulseclient/common/httpclient.py b/cloudpulseclient/common/httpclient.py new file mode 100644 index 0000000..92be038 --- /dev/null +++ b/cloudpulseclient/common/httpclient.py @@ -0,0 +1,374 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2012 OpenStack LLC. +# 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 copy +import json +import logging +import os +import socket +import ssl + +from keystoneclient import adapter +import six +import six.moves.urllib.parse as urlparse + +from cloudpulseclient import exceptions + + +LOG = logging.getLogger(__name__) +USER_AGENT = 'python-cloudpulseclient' +CHUNKSIZE = 1024 * 64 # 64kB + +API_VERSION = '/v1' + + +class HTTPClient(object): + + def __init__(self, endpoint, **kwargs): + self.endpoint = endpoint + self.auth_token = kwargs.get('token') + self.auth_ref = kwargs.get('auth_ref') + self.connection_params = self.get_connection_params(endpoint, **kwargs) + + @staticmethod + def get_connection_params(endpoint, **kwargs): + parts = urlparse.urlparse(endpoint) + + # trim API version and trailing slash from endpoint + path = parts.path + path = path.rstrip('/').rstrip(API_VERSION) + + _args = (parts.hostname, parts.port, path) + _kwargs = {'timeout': (float(kwargs.get('timeout')) + if kwargs.get('timeout') else 600)} + + if parts.scheme == 'https': + _class = VerifiedHTTPSConnection + _kwargs['ca_file'] = kwargs.get('ca_file', None) + _kwargs['cert_file'] = kwargs.get('cert_file', None) + _kwargs['key_file'] = kwargs.get('key_file', None) + _kwargs['insecure'] = kwargs.get('insecure', False) + elif parts.scheme == 'http': + _class = six.moves.http_client.HTTPConnection + else: + msg = 'Unsupported scheme: %s' % parts.scheme + raise exceptions.EndpointException(msg) + + return (_class, _args, _kwargs) + + def get_connection(self): + _class = self.connection_params[0] + try: + return _class(*self.connection_params[1][0:2], + **self.connection_params[2]) + except six.moves.http_client.InvalidURL: + raise exceptions.EndpointException() + + def log_curl_request(self, method, url, kwargs): + curl = ['curl -i -X %s' % method] + + for (key, value) in kwargs['headers'].items(): + header = '-H \'%s: %s\'' % (key, value) + curl.append(header) + + conn_params_fmt = [ + ('key_file', '--key %s'), + ('cert_file', '--cert %s'), + ('ca_file', '--cacert %s'), + ] + for (key, fmt) in conn_params_fmt: + value = self.connection_params[2].get(key) + if value: + curl.append(fmt % value) + + if self.connection_params[2].get('insecure'): + curl.append('-k') + + if 'body' in kwargs: + curl.append('-d \'%s\'' % kwargs['body']) + + curl.append('%s/%s' % (self.endpoint, url.lstrip(API_VERSION))) + LOG.debug(' '.join(curl)) + + @staticmethod + def log_http_response(resp, body=None): + status = (resp.version / 10.0, resp.status, resp.reason) + dump = ['\nHTTP/%.1f %s %s' % status] + dump.extend(['%s: %s' % (k, v) for k, v in resp.getheaders()]) + dump.append('') + if body: + dump.extend([body, '']) + LOG.debug('\n'.join(dump)) + + def _make_connection_url(self, url): + (_class, _args, _kwargs) = self.connection_params + base_url = _args[2] + return '%s/%s' % (base_url, url.lstrip('/')) + + def _extract_error_json(self, body): + error_json = {} + try: + body_json = json.loads(body) + if 'error_message' in body_json: + raw_msg = body_json['error_message'] + error_json = json.loads(raw_msg) + except ValueError: + return {} + + return error_json + + def _http_request(self, url, method, **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) + if self.auth_token: + kwargs['headers'].setdefault('X-Auth-Token', self.auth_token) + + self.log_curl_request(method, url, kwargs) + conn = self.get_connection() + + try: + conn_url = self._make_connection_url(url) + conn.request(method, conn_url, **kwargs) + resp = conn.getresponse() + except socket.gaierror as e: + message = ("Error finding address for %(url)s: %(e)s" + % dict(url=url, e=e)) + raise exceptions.EndpointNotFound(message) + except (socket.error, socket.timeout) as e: + endpoint = self.endpoint + message = ("Error communicating with %(endpoint)s %(e)s" + % dict(endpoint=endpoint, e=e)) + raise exceptions.ConnectionRefused(message) + + body_iter = ResponseBodyIterator(resp) + + # Read body into string if it isn't obviously image data + body_str = None + if resp.getheader('content-type', None) != 'application/octet-stream': + body_str = ''.join([chunk for chunk in body_iter]) + self.log_http_response(resp, body_str) + body_iter = six.StringIO(body_str) + else: + self.log_http_response(resp) + + if 400 <= resp.status < 600: + LOG.warn("Request returned failure status.") + error_json = self._extract_error_json(body_str) + raise exceptions.from_response( + resp, error_json.get('faultstring'), + error_json.get('debuginfo'), method, url) + elif resp.status in (301, 302, 305): + # Redirected. Reissue the request to the new location. + return self._http_request(resp['location'], method, **kwargs) + elif resp.status == 300: + raise exceptions.from_response(resp, method=method, url=url) + + return resp, body_iter + + def json_request(self, method, url, **kwargs): + kwargs.setdefault('headers', {}) + kwargs['headers'].setdefault('Content-Type', 'application/json') + kwargs['headers'].setdefault('Accept', 'application/json') + + if 'body' in kwargs: + kwargs['body'] = json.dumps(kwargs['body']) + + resp, body_iter = self._http_request(url, method, **kwargs) + content_type = resp.getheader('content-type', None) + + if resp.status == 204 or resp.status == 205 or content_type is None: + return resp, list() + + if 'application/json' in 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') + return self._http_request(url, method, **kwargs) + + +class VerifiedHTTPSConnection(six.moves.http_client.HTTPSConnection): + """httplib-compatibile connection using client-side SSL authentication + + :see http://code.activestate.com/recipes/ + 577548-https-httplib-client-connection-with-certificate-v/ + """ + + def __init__(self, host, port, key_file=None, cert_file=None, + ca_file=None, timeout=None, insecure=False): + six.moves.http_client.HTTPSConnection.__init__(self, host, port, + key_file=key_file, + cert_file=cert_file) + self.key_file = key_file + self.cert_file = cert_file + if ca_file is not None: + self.ca_file = ca_file + else: + self.ca_file = self.get_system_ca_file() + self.timeout = timeout + self.insecure = insecure + + def connect(self): + """Connect to a host on a given (SSL) port. + + If ca_file is pointing somewhere, use it to check Server Certificate. + + Redefined/copied and extended from httplib.py:1105 (Python 2.6.x). + This is needed to pass cert_reqs=ssl.CERT_REQUIRED as parameter to + ssl.wrap_socket(), which forces SSL to check server certificate against + our client certificate. + """ + sock = socket.create_connection((self.host, self.port), self.timeout) + + if self._tunnel_host: + self.sock = sock + self._tunnel() + + if self.insecure is True: + kwargs = {'cert_reqs': ssl.CERT_NONE} + else: + kwargs = {'cert_reqs': ssl.CERT_REQUIRED, 'ca_certs': self.ca_file} + + if self.cert_file: + kwargs['certfile'] = self.cert_file + if self.key_file: + kwargs['keyfile'] = self.key_file + + self.sock = ssl.wrap_socket(sock, **kwargs) + + @staticmethod + def get_system_ca_file(): + """Return path to system default CA file.""" + # Standard CA file locations for Debian/Ubuntu, RedHat/Fedora, + # Suse, FreeBSD/OpenBSD + ca_path = ['/etc/ssl/certs/ca-certificates.crt', + '/etc/pki/tls/certs/ca-bundle.crt', + '/etc/ssl/ca-bundle.pem', + '/etc/ssl/cert.pem'] + for ca in ca_path: + if os.path.exists(ca): + return ca + return None + + +class SessionClient(adapter.LegacyJsonAdapter): + """HTTP client based on Keystone client session.""" + + def _http_request(self, url, method, **kwargs): + kwargs.setdefault('user_agent', USER_AGENT) + kwargs.setdefault('auth', self.auth) + + endpoint_filter = kwargs.setdefault('endpoint_filter', {}) + endpoint_filter.setdefault('interface', self.interface) + endpoint_filter.setdefault('service_type', self.service_type) + endpoint_filter.setdefault('region_name', self.region_name) + + resp = self.session.request(url, method, + raise_exc=False, **kwargs) + + if 400 <= resp.status_code < 600: + raise exceptions.from_response(resp) + elif resp.status_code in (301, 302, 305): + # Redirected. Reissue the request to the new location. + location = resp.headers.get('location') + resp = self._http_request(location, method, **kwargs) + elif resp.status_code == 300: + raise exceptions.from_response(resp, method=method, url=url) + return resp + + def json_request(self, method, url, **kwargs): + kwargs.setdefault('headers', {}) + kwargs['headers'].setdefault('Content-Type', 'application/json') + kwargs['headers'].setdefault('Accept', 'application/json') + + if 'body' in kwargs: + kwargs['data'] = json.dumps(kwargs.pop('body')) + + resp = self._http_request(url, method, **kwargs) + body = resp.content + content_type = resp.headers.get('content-type', None) + status = resp.status_code + if status == 204 or status == 205 or content_type is None: + return resp, list() + if 'application/json' in content_type: + try: + body = resp.json() + 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') + return self._http_request(url, method, **kwargs) + + +class ResponseBodyIterator(object): + """A class that acts as an iterator over an HTTP response.""" + + def __init__(self, resp): + self.resp = resp + + def __iter__(self): + while True: + yield self.next() + + def next(self): + chunk = self.resp.read(CHUNKSIZE) + if chunk: + return chunk + else: + raise StopIteration() + + +def _construct_http_client(*args, **kwargs): + session = kwargs.pop('session', None) + auth = kwargs.pop('auth', None) + + if session: + service_type = kwargs.pop('service_type', 'baremetal') + interface = kwargs.pop('endpoint_type', None) + region_name = kwargs.pop('region_name', None) + return SessionClient(session=session, + auth=auth, + interface=interface, + service_type=service_type, + region_name=region_name, + service_name=None, + user_agent='python-cloudpulseclient') + else: + return HTTPClient(*args, **kwargs) diff --git a/cloudpulseclient/common/utils.py b/cloudpulseclient/common/utils.py new file mode 100644 index 0000000..37b2057 --- /dev/null +++ b/cloudpulseclient/common/utils.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2012 OpenStack LLC. +# 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 json + +from cloudpulseclient.openstack.common._i18n import _ +from cloudpulseclient.openstack.common.apiclient import exceptions as exc + + +def common_filters(marker=None, limit=None, sort_key=None, sort_dir=None): + """Generate common filters for any list request. + + :param marker: entity ID from which to start returning entities. + :param limit: maximum number of entities to return. + :param sort_key: field to use for sorting. + :param sort_dir: direction of sorting: 'asc' or 'desc'. + :returns: list of string filters. + """ + filters = [] + if isinstance(limit, int) and limit > 0: + filters.append('limit=%s' % limit) + if marker is not None: + filters.append('marker=%s' % marker) + if sort_key is not None: + filters.append('sort_key=%s' % sort_key) + if sort_dir is not None: + filters.append('sort_dir=%s' % sort_dir) + return filters + + +def split_and_deserialize(string): + """Split and try to JSON deserialize a string. + + Gets a string with the KEY=VALUE format, split it (using '=' as the + separator) and try to JSON deserialize the VALUE. + :returns: A tuple of (key, value). + """ + try: + key, value = string.split("=", 1) + except ValueError: + raise exc.CommandError(_('Attributes must be a list of ' + 'PATH=VALUE not "%s"') % string) + try: + value = json.loads(value) + except ValueError: + pass + + return (key, value) + + +def args_array_to_patch(op, attributes): + patch = [] + for attr in attributes: + # Sanitize + if not attr.startswith('/'): + attr = '/' + attr + if op in ['add', 'replace']: + path, value = split_and_deserialize(attr) + patch.append({'op': op, 'path': path, 'value': value}) + + elif op == "remove": + # For remove only the key is needed + patch.append({'op': op, 'path': attr}) + else: + raise exc.CommandError(_('Unknown PATCH operation: %s') % op) + return patch