# ironicclient.common.http

# Source code for ironicclient.common.http

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

    from distutils.version import StrictVersion
    import functools
    from http import client as http_client
    import logging
    import re
    import textwrap
    import time
    from urllib import parse as urlparse

    from keystoneauth1 import adapter
    from keystoneauth1 import exceptions as kexc
    from oslo_serialization import jsonutils

    from ironicclient.common import filecache
    from ironicclient.common.i18n import _
    from ironicclient import exc

    # NOTE(deva): Record the latest version that this client was tested with.
    #             We still have a lot of work to do in the client to implement
    #             microversion support in the client properly! See
    #             http://specs.openstack.org/openstack/ironic-specs/specs/kilo/api-microversions.html # noqa
    #             for full details.
    DEFAULT_VER = '1.9'
    LAST_KNOWN_API_VERSION = 65
    LATEST_VERSION = '1.{}'.format(LAST_KNOWN_API_VERSION)

    LOG = logging.getLogger(__name__)
    USER_AGENT = 'python-ironicclient'
    CHUNKSIZE = 1024 * 64  # 64kB

    _MAJOR_VERSION = 1
    API_VERSION = '/v%d' % _MAJOR_VERSION
    API_VERSION_SELECTED_STATES = ('user', 'negotiated', 'cached', 'default')

    DEFAULT_MAX_RETRIES = 5
    DEFAULT_RETRY_INTERVAL = 2
    SENSITIVE_HEADERS = ('X-Auth-Token',)

    SUPPORTED_ENDPOINT_SCHEME = ('http', 'https')

    _API_VERSION_RE = re.compile(r'/+(v%d)?/*$' % _MAJOR_VERSION)

    def _trim_endpoint_api_version(url):
        """Trim API version and trailing slash from endpoint."""
        return re.sub(_API_VERSION_RE, '', url)

    def _extract_error_json(body):
        """Return  error_message from the HTTP response body."""
        try:
            body_json = jsonutils.loads(body)
        except ValueError:
            return {}

        if 'error_message' not in body_json:
            return {}

        try:
            error_json = jsonutils.loads(body_json['error_message'])
        except ValueError:
            return body_json

        err_msg = (error_json.get('faultstring') or error_json.get('description'))
        if err_msg:
            body_json['error_message'] = err_msg

        return body_json

    def get_server(url):
        """Extract and return the server & port."""
        if url is None:
            return None, None
        parts = urlparse.urlparse(url)
        return parts.hostname, str(parts.port)

    class VersionNegotiationMixin(object):
        def negotiate_version(self, conn, resp):
            """Negotiate the server version

            Assumption: Called after receiving a 406 error when doing a request.

            :param conn: A connection object
            :param resp: The response object from http request
            """
            def _query_server(conn):
                if (self.os_ironic_api_version
                        and not isinstance(self.os_ironic_api_version, list)
                        and self.os_ironic_api_version != 'latest'):
                    base_version = ("/v%s" %
                                    str(self.os_ironic_api_version).split('.')[0])
                else:
                    base_version = API_VERSION
                # Raise exception on client or server error.
                resp = self._make_simple_request(conn, 'GET', base_version)
                if not resp.ok:
                    raise exc.from_response(resp, method='GET', url=base_version)
                return resp

            version_overridden = False

            if (resp and hasattr(resp, 'request')
                    and hasattr(resp.request, 'headers')):
                orig_hdr = resp.request.headers
                # Get the version of the client's last request and fallback
                # to the default for things like unit tests to not cause
                # migraines.
                req_api_ver = orig_hdr.get('X-OpenStack-Ironic-API-Version',
                                           self.os_ironic_api_version)
            else:
                req_api_ver = self.os_ironic_api_version
            if (resp and req_api_ver != self.os_ironic_api_version
                    and self.api_version_select_state == 'negotiated'):
                # If we have a non-standard api version on the request,
                # but we think we've negotiated, then the call was overridden.
                # We should report the error with the called version
                requested_version = req_api_ver
                # And then we shouldn't save the newly negotiated
                # version of this negotiation because we have been
                # overridden a request.
                version_overridden = True
            else:
                requested_version = self.os_ironic_api_version

            if not resp:
                resp = _query_server(conn)
            if self.api_version_select_state not in API_VERSION_SELECTED_STATES:
                raise RuntimeError(
                    _('Error: self.api_version_select_state should be one of the '
                      'values in: "%(valid)s" but had the value: "%(value)s"') %
                    {'valid': ', '.join(API_VERSION_SELECTED_STATES),
                     'value': self.api_version_select_state})
            min_ver, max_ver = self._parse_version_headers(resp)
            # NOTE: servers before commit 32fb6e99 did not return version headers
            # on error, so we need to perform a GET to determine
            # the supported version range
            if not max_ver:
                LOG.debug('No version header in response, requesting from server')
                resp = _query_server(conn)
                min_ver, max_ver = self._parse_version_headers(resp)
            # Reset the maximum version that we permit
            if StrictVersion(max_ver) > StrictVersion(LATEST_VERSION):
                LOG.debug("Remote API version %(max_ver)s is greater than the "
                          "version supported by ironicclient. Maximum available "
                          "version is %(client_ver)s",
                          {'max_ver': max_ver,
                           'client_ver': LATEST_VERSION})
                max_ver = LATEST_VERSION

            # If the user requested an explicit version or we have negotiated a
            # version and still failing then error now.  The server could
            # support the version requested but the requested operation may not
            # be supported by the requested version.
            # TODO(TheJulia): We should break this method into several parts,
            # such as a sanity check/error method.
            if ((self.api_version_select_state == 'user'
                 and not self._must_negotiate_version())
                    or (self.api_version_select_state == 'negotiated'
                        and version_overridden)):
                raise exc.UnsupportedVersion(textwrap.fill(
                    _("Requested API version %(req)s is not supported by the "
                      "server, client, or the requested operation is not "
                      "supported by the requested version. "
                      "Supported version range is %(min)s to "
                      "%(max)s")
                    % {'req': requested_version,
                       'min': min_ver, 'max': max_ver}))
            if (self.api_version_select_state == 'negotiated'):
                raise exc.UnsupportedVersion(textwrap.fill(
                    _("No API version was specified or the requested operation "
                      "was not supported by the client's negotiated API version "
                      "%(req)s.  Supported version range is: %(min)s to %(max)s")
                    % {'req': requested_version,
                       'min': min_ver, 'max': max_ver}))

            if isinstance(requested_version, str):
                if requested_version == 'latest':
                    negotiated_ver = max_ver
                else:
                    negotiated_ver = str(
                        min(StrictVersion(requested_version),
                            StrictVersion(max_ver)))

            elif isinstance(requested_version, list):
                if 'latest' in requested_version:
                    raise ValueError(textwrap.fill(
                        _("The 'latest' API version can not be requested "
                          "in a list of versions. Please explicitly request "
                          "'latest' or request only versios between "
                          "%(min)s to %(max)s")
                        % {'min': min_ver, 'max': max_ver}))

                versions = []
                for version in requested_version:
                    if min_ver <= StrictVersion(version) <= max_ver:
                        versions.append(StrictVersion(version))
                if versions:
                    negotiated_ver = str(max(versions))
                else:
                    raise exc.UnsupportedVersion(textwrap.fill(
                        _("Requested API version specified and the requested "
                          "operation was not supported by the client's "
                          "requested API version %(req)s.  Supported "
                          "version range is: %(min)s to %(max)s")
                        % {'req': requested_version,
                           'min': min_ver, 'max': max_ver}))

            else:
                raise ValueError(textwrap.fill(
                    _("Requested API version %(req)s type is unsupported. "
                      "Valid types are Strings such as '1.1', 'latest' "
                      "or a list of string values representing API versions.")
                    % {'req': requested_version}))

            if StrictVersion(negotiated_ver) < StrictVersion(min_ver):
                negotiated_ver = min_ver
            # server handles microversions, but doesn't support
            # the requested version, so try a negotiated version
            self.api_version_select_state = 'negotiated'
            self.os_ironic_api_version = negotiated_ver
            LOG.debug('Negotiated API version is %s', negotiated_ver)

            # Cache the negotiated version for this server
            endpoint_override = getattr(self, 'endpoint_override', None)
            host, port = get_server(endpoint_override)
            filecache.save_data(host=host, port=port, data=negotiated_ver)

            return negotiated_ver

        def _generic_parse_version_headers(self, accessor_func):
            min_ver = accessor_func('X-OpenStack-Ironic-API-Minimum-Version',
                                    None)
            max_ver = accessor_func('X-OpenStack-Ironic-API-Maximum-Version',
                                    None)
            return min_ver, max_ver

        def _parse_version_headers(self, accessor_func):
            # NOTE(jlvillal): Declared for unit testing purposes
            raise NotImplementedError()

        def _make_simple_request(self, conn, method, url):
            # NOTE(jlvillal): Declared for unit testing purposes
            raise NotImplementedError()

        def _must_negotiate_version(self):
            return (self.api_version_select_state == 'user'
                    and (self.os_ironic_api_version == 'latest'
                         or isinstance(self.os_ironic_api_version, list)))

    _RETRY_EXCEPTIONS = (exc.Conflict, exc.ServiceUnavailable,
                         exc.ConnectionRefused, kexc.RetriableConnectionFailure)

    def with_retries(func):
        """Wrapper for _http_request adding support for retries."""
        @functools.wraps(func)
        def wrapper(self, url, method, **kwargs):
            if self.conflict_max_retries is None:
                self.conflict_max_retries = DEFAULT_MAX_RETRIES
            if self.conflict_retry_interval is None:
                self.conflict_retry_interval = DEFAULT_RETRY_INTERVAL

            num_attempts = self.conflict_max_retries + 1
            for attempt in range(1, num_attempts + 1):
                try:
                    return func(self, url, method, **kwargs)
                except _RETRY_EXCEPTIONS as error:
                    msg = ("Error contacting Ironic server: %(error)s. "
                           "Attempt %(attempt)d of %(total)d" %
                           {'attempt': attempt,
                            'total': num_attempts,
                            'error': error})
                    if attempt == num_attempts:
                        LOG.error(msg)
                        raise
                    else:
                        LOG.debug(msg)
                        time.sleep(self.conflict_retry_interval)

        return wrapper

    class SessionClient(VersionNegotiationMixin, adapter.LegacyJsonAdapter):
        """HTTP client based on Keystone client session."""

        def __init__(self,
                     os_ironic_api_version,
                     api_version_select_state,
                     max_retries,
                     retry_interval,
                     **kwargs):
            self.os_ironic_api_version = os_ironic_api_version
            self.api_version_select_state = api_version_select_state
            self.conflict_max_retries = max_retries
            self.conflict_retry_interval = retry_interval
            if isinstance(kwargs.get('endpoint_override'), str):
                kwargs['endpoint_override'] = _trim_endpoint_api_version(
                    kwargs['endpoint_override'])

            super(SessionClient, self).__init__(**kwargs)

            endpoint_filter = self._get_endpoint_filter()
            endpoint = self.get_endpoint(**endpoint_filter)
            if endpoint is None:
                raise exc.EndpointNotFound(
                    _('The Bare Metal API endpoint cannot be detected and was '
                      'not provided explicitly'))
            self.endpoint_trimmed = _trim_endpoint_api_version(endpoint)

        def _parse_version_headers(self, resp):
            return self._generic_parse_version_headers(resp.headers.get)

        def _get_endpoint_filter(self):
            return {
                'interface': self.interface,
                'service_type': self.service_type,
                'region_name': self.region_name
            }

        def _make_simple_request(self, conn, method, url):
            # NOTE: conn is self.session for this class
            return conn.request(url, method, raise_exc=False,
                                user_agent=USER_AGENT,
                                endpoint_filter=self._get_endpoint_filter(),
                                endpoint_override=self.endpoint_override)

        @with_retries
        def _http_request(self, url, method, **kwargs):

            # NOTE(TheJulia): self.os_ironic_api_version is reset in
            # the self.negotiate_version() call if negotiation occurs.
            if self.os_ironic_api_version and self._must_negotiate_version():
                self.negotiate_version(self.session, None)

            kwargs.setdefault('user_agent', USER_AGENT)
            kwargs.setdefault('auth', self.auth)
            if isinstance(self.endpoint_override, str):
                kwargs.setdefault('endpoint_override', self.endpoint_override)

            if getattr(self, 'os_ironic_api_version', None):
                kwargs['headers'].setdefault('X-OpenStack-Ironic-API-Version',
                                             self.os_ironic_api_version)

            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 resp.status_code == http_client.NOT_ACCEPTABLE:
                negotiated_ver = self.negotiate_version(self.session, resp)
                kwargs['headers']['X-OpenStack-Ironic-API-Version'] = (
                    negotiated_ver)
                return self._http_request(url, method, **kwargs)
            if resp.status_code >= http_client.BAD_REQUEST:
                error_json = _extract_error_json(resp.content)
                raise exc.from_response(resp, error_json.get('error_message'),
                                        error_json.get('debuginfo'), method, url)
            elif resp.status_code in (http_client.MOVED_PERMANENTLY,
                                      http_client.FOUND, http_client.USE_PROXY):
                # Redirected. Reissue the request to the new location.
                location = resp.headers.get('location')
                resp = self._http_request(location, method, **kwargs)
            elif resp.status_code == http_client.MULTIPLE_CHOICES:
                raise exc.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'] = jsonutils.dump_as_bytes(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 in (http_client.NO_CONTENT, http_client.RESET_CONTENT)
                    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)

    def _construct_http_client(session,
                               token=None,
                               auth_ref=None,
                               os_ironic_api_version=DEFAULT_VER,
                               api_version_select_state='default',
                               max_retries=DEFAULT_MAX_RETRIES,
                               retry_interval=DEFAULT_RETRY_INTERVAL,
                               timeout=600,
                               ca_file=None,
                               cert_file=None,
                               key_file=None,
                               insecure=None,
                               **kwargs):

        kwargs.setdefault('service_type', 'baremetal')
        kwargs.setdefault('user_agent', 'python-ironicclient')
        kwargs.setdefault('interface', kwargs.pop('endpoint_type',
                                                  'publicURL'))

        ignored = {'token': token,
                   'auth_ref': auth_ref,
                   'timeout': timeout != 600,
                   'ca_file': ca_file,
                   'cert_file': cert_file,
                   'key_file': key_file,
                   'insecure': insecure}

        dvars = [k for k, v in ignored.items() if v]

        if dvars:
            LOG.warning('The following arguments are ignored when using '
                        'the session to construct a client: %s',
                        ', '.join(dvars))

        return SessionClient(session=session,
                             os_ironic_api_version=os_ironic_api_version,
                             api_version_select_state=api_version_select_state,
                             max_retries=max_retries,
                             retry_interval=retry_interval,
                             **kwargs)

this page last updated: 2020-04-10 14:03:55

Except where otherwise noted, this document is licensed under Creative Commons Attribution 3.0 License. See all OpenStack Legal Documents.

found an error? report a bug questions?