File: http.py

package info (click to toggle)
python-ironic-inspector-client 5.4.0-3
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 592 kB
  • sloc: python: 2,082; makefile: 15; sh: 2
file content (240 lines) | stat: -rw-r--r-- 9,951 bytes parent folder | download | duplicates (4)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
# 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"))


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)

    @classmethod
    def raise_if_needed(cls, response):
        """Raise exception if response contains error."""
        if response.status_code >= 400:
            raise cls(response)


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)


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)


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

    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

    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