Source code for ironic_inspector_client.common.http

# 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.

"""Generic code for inspector client."""

import json
import logging

from keystoneauth1 import exceptions as ks_exc
from keystoneauth1 import session as ks_session
import requests

from ironic_inspector_client.common.i18n import _


_ERROR_ENCODING = 'utf-8'
LOG = logging.getLogger('ironic_inspector_client')


_MIN_VERSION_HEADER = 'X-OpenStack-Ironic-Inspector-API-Minimum-Version'
_MAX_VERSION_HEADER = 'X-OpenStack-Ironic-Inspector-API-Maximum-Version'
_VERSION_HEADER = 'X-OpenStack-Ironic-Inspector-API-Version'
_AUTH_TOKEN_HEADER = 'X-Auth-Token'


def _parse_version(api_version):
    try:
        return tuple(int(x) for x in api_version.split('.'))
    except (ValueError, TypeError):
        raise ValueError(_("Malformed API version: expect tuple, string "
                           "in form of X.Y or integer"))


[docs] class ClientError(requests.HTTPError): """Error returned from a server.""" def __init__(self, response): # inspector returns error message in body msg = response.content.decode(_ERROR_ENCODING) try: msg = json.loads(msg) except ValueError: LOG.debug('Old style error response returned, assuming ' 'ironic-discoverd') except TypeError: LOG.exception('Bad error response from Ironic Inspector') else: try: msg = msg['error']['message'] except KeyError as exc: LOG.error('Invalid error response from Ironic Inspector: ' '%(msg)s (missing key %(key)s)', {'msg': msg, 'key': exc}) # It's surprisingly common to try accessing ironic URL with # ironic-inspector-client, handle this case try: msg = msg['error_message'] except KeyError: pass else: msg = _('Received Ironic-style response %s. Are you ' 'trying to access Ironic URL instead of Ironic ' 'Inspector?') % msg except TypeError: LOG.exception('Bad error response from Ironic Inspector') LOG.debug('Inspector returned error "%(msg)s" (HTTP %(code)s)', {'msg': msg, 'code': response.status_code}) super(ClientError, self).__init__(msg, response=response)
[docs] @classmethod def raise_if_needed(cls, response): """Raise exception if response contains error.""" if response.status_code >= 400: raise cls(response)
[docs] class VersionNotSupported(Exception): """Denotes that requested API versions is not supported by the server. :ivar expected: requested version. :ivar supported: sequence with two items: minimum and maximum actually supported versions. """ def __init__(self, expected, supported): msg = (_('Version %(expected)s is not supported by the server, ' 'supported range is %(supported)s') % {'expected': expected, 'supported': ' to '.join(str(x) for x in supported)}) self.expected_version = expected self.supported_versions = supported super(Exception, self).__init__(msg)
[docs] class EndpointNotFound(Exception): """Denotes that endpoint for the introspection service was not found. :ivar service_type: requested service type """ def __init__(self, service_type): self.service_type = service_type msg = _('Endpoint of type %s was not found in the service catalog ' 'and was not provided explicitly') % service_type super(Exception, self).__init__(msg)
[docs] class BaseClient(object): """Base class for clients, provides common HTTP code.""" def __init__(self, api_version, inspector_url=None, session=None, service_type='baremetal-introspection', interface=None, region_name=None): """Create a client. :param api_version: minimum API version that must be supported by the server :param inspector_url: *Ironic Inspector* URL in form: http://host:port[/ver]. When session is provided, defaults to service URL from the catalog. As a last resort defaults to ``http://<current host>:5050/v<MAJOR>``. :param session: existing keystone session. A session without authentication is created if this is set to None. :param service_type: service type to use when looking up the URL :param interface: interface type (public, internal, etc) to use when looking up the URL :param region_name: region name to use when looking up the URL :raises: EndpointNotFound if the introspection service endpoint was not provided via inspector_url and was not found in the service catalog. """ self._base_url = inspector_url if session is None: self._session = ks_session.Session(None) else: self._session = session if not inspector_url: try: self._base_url = session.get_endpoint( service_type=service_type, interface=interface, region_name=region_name) except ks_exc.CatalogException as exc: LOG.error('%(iface)s endpoint for %(stype)s in region ' '%(region)s was not found in the service ' 'catalog: %(error)s', {'iface': interface, 'stype': service_type, 'region': region_name, 'error': exc}) raise EndpointNotFound(service_type=service_type) if not self._base_url: # This handles the case when session=None and no inspector_url is # provided, as well as keystoneauth plugins that may return None. raise EndpointNotFound(service_type=service_type) self._base_url = self._base_url.rstrip('/') self._api_version = self._check_api_version(api_version) self._version_str = '%d.%d' % self._api_version ver_postfix = '/v%d' % self._api_version[0] if not self._base_url.endswith(ver_postfix): self._base_url += ver_postfix def _add_headers(self, headers): headers[_VERSION_HEADER] = self._version_str return headers def _check_api_version(self, api_version): if isinstance(api_version, int): api_version = (api_version, 0) if isinstance(api_version, str): api_version = _parse_version(api_version) api_version = tuple(api_version) if not all(isinstance(x, int) for x in api_version): raise TypeError(_("All API version components should be integers")) if len(api_version) == 1: api_version += (0,) elif len(api_version) > 2: raise ValueError(_("API version should be of length 1 or 2")) minv, maxv = self.server_api_versions() if api_version < minv or api_version > maxv: raise VersionNotSupported(api_version, (minv, maxv)) return api_version
[docs] def request(self, method, url, **kwargs): """Make an HTTP request. :param method: HTTP method :param endpoint: relative endpoint :param kwargs: arguments to pass to 'requests' library """ headers = self._add_headers(kwargs.pop('headers', {})) url = self._base_url + '/' + url.lstrip('/') LOG.debug('Requesting %(method)s %(url)s (API version %(ver)s) ' 'with %(args)s', {'method': method.upper(), 'url': url, 'ver': self._version_str, 'args': kwargs}) res = self._session.request(url, method, headers=headers, raise_exc=False, **kwargs) LOG.debug('Got response for %(method)s %(url)s with status code ' '%(code)s', {'url': url, 'method': method.upper(), 'code': res.status_code}) ClientError.raise_if_needed(res) return res
[docs] def server_api_versions(self): """Get minimum and maximum supported API versions from a server. :return: tuple (minimum version, maximum version) each version is returned as a tuple (X, Y) :raises: *requests* library exception on connection problems. :raises: ValueError if returned version cannot be parsed """ res = self._session.get(self._base_url, authenticated=False, raise_exc=False) # HTTP Not Found is a valid response for older (2.0.0) servers if res.status_code >= 400 and res.status_code != 404: ClientError.raise_if_needed(res) min_ver = res.headers.get(_MIN_VERSION_HEADER, '1.0') max_ver = res.headers.get(_MAX_VERSION_HEADER, '1.0') res = (_parse_version(min_ver), _parse_version(max_ver)) LOG.debug('Supported API version range for %(url)s is ' '[%(min)s, %(max)s]', {'url': self._base_url, 'min': min_ver, 'max': max_ver}) return res