1# Licensed to the Apache Software Foundation (ASF) under one or more
2# contributor license agreements.  See the NOTICE file distributed with
3# this work for additional information regarding copyright ownership.
4# The ASF licenses this file to You under the Apache License, Version 2.0
5# (the "License"); you may not use this file except in compliance with
6# the License.  You may obtain a copy of the License at
7#
8#     http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15
16"""
17Common utilities for OpenStack
18"""
19
20from libcloud.utils.py3 import ET
21from libcloud.utils.py3 import httplib
22
23from libcloud.common.base import ConnectionUserAndKey, Response
24from libcloud.common.exceptions import BaseHTTPError
25from libcloud.common.types import ProviderError
26from libcloud.compute.types import (LibcloudError, MalformedResponseError)
27from libcloud.compute.types import KeyPairDoesNotExistError
28from libcloud.common.openstack_identity import (AUTH_TOKEN_HEADER,
29                                                get_class_for_auth_version)
30
31# Imports for backward compatibility reasons
32from libcloud.common.openstack_identity import (OpenStackServiceCatalog,
33                                                OpenStackIdentityTokenScope)
34
35
36try:
37    import simplejson as json
38except ImportError:
39    import json  # type: ignore
40
41AUTH_API_VERSION = '1.1'
42
43# Auth versions which contain token expiration information.
44AUTH_VERSIONS_WITH_EXPIRES = [
45    '1.1',
46    '2.0',
47    '2.0_apikey',
48    '2.0_password',
49    '3.x',
50    '3.x_password'
51]
52
53__all__ = [
54    'OpenStackBaseConnection',
55    'OpenStackResponse',
56    'OpenStackException',
57    'OpenStackDriverMixin'
58]
59
60
61class OpenStackBaseConnection(ConnectionUserAndKey):
62
63    """
64    Base class for OpenStack connections.
65
66    :param user_id: User name to use when authenticating
67    :type user_id: ``str``
68
69    :param key: Secret to use when authenticating.
70    :type key: ``str``
71
72    :param secure: Use HTTPS?  (True by default.)
73    :type secure: ``bool``
74
75    :param ex_force_base_url: Base URL for connection requests.  If
76                              not specified, this will be determined by
77                              authenticating.
78    :type ex_force_base_url: ``str``
79
80    :param ex_force_auth_url: Base URL for authentication requests.
81    :type ex_force_auth_url: ``str``
82
83    :param ex_force_auth_version: Authentication version to use.  If
84                                  not specified, defaults to AUTH_API_VERSION.
85    :type ex_force_auth_version: ``str``
86
87    :param ex_force_auth_token: Authentication token to use for connection
88                                requests.  If specified, the connection will
89                                not attempt to authenticate, and the value
90                                of ex_force_base_url will be used to
91                                determine the base request URL.  If
92                                ex_force_auth_token is passed in,
93                                ex_force_base_url must also be provided.
94    :type ex_force_auth_token: ``str``
95
96    :param token_scope: Whether to scope a token to a "project", a
97                        "domain" or "unscoped".
98    :type token_scope: ``str``
99
100    :param ex_domain_name: When authenticating, provide this domain name to
101                           the identity service.  A scoped token will be
102                           returned. Some cloud providers require the domain
103                           name to be provided at authentication time. Others
104                           will use a default domain if none is provided.
105    :type ex_domain_name: ``str``
106
107    :param ex_tenant_name: When authenticating, provide this tenant name to the
108                           identity service. A scoped token will be returned.
109                           Some cloud providers require the tenant name to be
110                           provided at authentication time. Others will use a
111                           default tenant if none is provided.
112    :type ex_tenant_name: ``str``
113
114    :param ex_tenant_domain_id: When authenticating, provide this tenant
115                                domain id to the identity service.
116                                A scoped token will be returned.
117                                Some cloud providers require the tenant
118                                domain id to be provided at authentication
119                                time. Others will use a default tenant
120                                domain id if none is provided.
121    :type ex_tenant_domain_id: ``str``
122
123    :param ex_force_service_type: Service type to use when selecting an
124                                  service. If not specified, a provider
125                                  specific default will be used.
126    :type ex_force_service_type: ``str``
127
128    :param ex_force_service_name: Service name to use when selecting an
129                                  service. If not specified, a provider
130                                  specific default will be used.
131    :type ex_force_service_name: ``str``
132
133    :param ex_force_service_region: Region to use when selecting an service.
134                                    If not specified, a provider specific
135                                    default will be used.
136    :type ex_force_service_region: ``str``
137
138    :param ex_auth_cache: External cache where authentication tokens are
139                          stored for reuse by other processes. Tokens are
140                          always cached in memory on the driver instance. To
141                          share tokens among multiple drivers, processes, or
142                          systems, pass a cache here.
143    :type ex_auth_cache: :class:`OpenStackAuthenticationCache`
144    """
145
146    auth_url = None  # type: str
147    auth_token = None  # type: str
148    auth_token_expires = None
149    auth_user_info = None
150    service_catalog = None
151    service_type = None
152    service_name = None
153    service_region = None
154    accept_format = None
155    _auth_version = None  # type: str
156
157    def __init__(self, user_id, key, secure=True,
158                 host=None, port=None, timeout=None, proxy_url=None,
159                 ex_force_base_url=None,
160                 ex_force_auth_url=None,
161                 ex_force_auth_version=None,
162                 ex_force_auth_token=None,
163                 ex_token_scope=OpenStackIdentityTokenScope.PROJECT,
164                 ex_domain_name='Default',
165                 ex_tenant_name=None,
166                 ex_tenant_domain_id='default',
167                 ex_force_service_type=None,
168                 ex_force_service_name=None,
169                 ex_force_service_region=None,
170                 ex_auth_cache=None,
171                 retry_delay=None, backoff=None):
172        super(OpenStackBaseConnection, self).__init__(
173            user_id, key, secure=secure, timeout=timeout,
174            retry_delay=retry_delay, backoff=backoff, proxy_url=proxy_url)
175
176        if ex_force_auth_version:
177            self._auth_version = ex_force_auth_version
178
179        self.base_url = ex_force_base_url
180        self._ex_force_base_url = ex_force_base_url
181        self._ex_force_auth_url = ex_force_auth_url
182        self._ex_force_auth_token = ex_force_auth_token
183        self._ex_token_scope = ex_token_scope
184        self._ex_domain_name = ex_domain_name
185        self._ex_tenant_name = ex_tenant_name
186        self._ex_tenant_domain_id = ex_tenant_domain_id
187        self._ex_force_service_type = ex_force_service_type
188        self._ex_force_service_name = ex_force_service_name
189        self._ex_force_service_region = ex_force_service_region
190        self._ex_auth_cache = ex_auth_cache
191        self._osa = None
192
193        if ex_force_auth_token and not ex_force_base_url:
194            raise LibcloudError(
195                'Must also provide ex_force_base_url when specifying '
196                'ex_force_auth_token.')
197
198        if ex_force_auth_token:
199            self.auth_token = ex_force_auth_token
200
201        if not self._auth_version:
202            self._auth_version = AUTH_API_VERSION
203
204        auth_url = self._get_auth_url()
205
206        if not auth_url:
207            raise LibcloudError('OpenStack instance must ' +
208                                'have auth_url set')
209
210    def get_auth_class(self):
211        """
212        Retrieve identity / authentication class instance.
213
214        :rtype: :class:`OpenStackIdentityConnection`
215        """
216        if not self._osa:
217            auth_url = self._get_auth_url()
218
219            cls = get_class_for_auth_version(auth_version=self._auth_version)
220            self._osa = cls(auth_url=auth_url,
221                            user_id=self.user_id,
222                            key=self.key,
223                            tenant_name=self._ex_tenant_name,
224                            tenant_domain_id=self._ex_tenant_domain_id,
225                            domain_name=self._ex_domain_name,
226                            token_scope=self._ex_token_scope,
227                            timeout=self.timeout,
228                            proxy_url=self.proxy_url,
229                            parent_conn=self,
230                            auth_cache=self._ex_auth_cache)
231
232        return self._osa
233
234    def request(self, action, params=None, data='', headers=None,
235                method='GET', raw=False):
236        headers = headers or {}
237        params = params or {}
238
239        # Include default content-type for POST and PUT request (if available)
240        default_content_type = getattr(self, 'default_content_type', None)
241        if method.upper() in ['POST', 'PUT'] and default_content_type:
242            headers = {'Content-Type': default_content_type}
243
244        try:
245            return super().request(action=action, params=params, data=data,
246                                   method=method, headers=headers, raw=raw)
247        except BaseHTTPError as ex:
248            # Evict cached auth token if we receive Unauthorized while using it
249            if (ex.code == httplib.UNAUTHORIZED
250                    and self._ex_force_auth_token is None):
251                self.get_auth_class().clear_cached_auth_context()
252            raise
253
254    def _get_auth_url(self):
255        """
256        Retrieve auth url for this instance using either "ex_force_auth_url"
257        constructor kwarg of "auth_url" class variable.
258        """
259        auth_url = self.auth_url
260
261        if self._ex_force_auth_url is not None:
262            auth_url = self._ex_force_auth_url
263
264        return auth_url
265
266    def get_service_catalog(self):
267        if self.service_catalog is None:
268            self._populate_hosts_and_request_paths()
269
270        return self.service_catalog
271
272    def get_service_name(self):
273        """
274        Gets the service name used to look up the endpoint in the service
275        catalog.
276
277        :return: name of the service in the catalog
278        """
279        if self._ex_force_service_name:
280            return self._ex_force_service_name
281
282        return self.service_name
283
284    def get_endpoint(self):
285        """
286        Selects the endpoint to use based on provider specific values,
287        or overrides passed in by the user when setting up the driver.
288
289        :returns: url of the relevant endpoint for the driver
290        """
291        service_type = self.service_type
292        service_name = self.service_name
293        service_region = self.service_region
294
295        if self._ex_force_service_type:
296            service_type = self._ex_force_service_type
297        if self._ex_force_service_name:
298            service_name = self._ex_force_service_name
299        if self._ex_force_service_region:
300            service_region = self._ex_force_service_region
301
302        endpoint = self.service_catalog.get_endpoint(service_type=service_type,
303                                                     name=service_name,
304                                                     region=service_region)
305
306        url = endpoint.url
307
308        if not url:
309            raise LibcloudError('Could not find specified endpoint')
310
311        return url
312
313    def add_default_headers(self, headers):
314        headers[AUTH_TOKEN_HEADER] = self.auth_token
315        headers['Accept'] = self.accept_format
316        return headers
317
318    def morph_action_hook(self, action):
319        self._populate_hosts_and_request_paths()
320        return super(OpenStackBaseConnection, self).morph_action_hook(action)
321
322    def _set_up_connection_info(self, url):
323        result = self._tuple_from_url(url)
324        (self.host, self.port, self.secure, self.request_path) = result
325        self.connect()
326
327    def _populate_hosts_and_request_paths(self):
328        """
329        OpenStack uses a separate host for API calls which is only provided
330        after an initial authentication request.
331        """
332        osa = self.get_auth_class()
333
334        if self._ex_force_auth_token:
335            # If ex_force_auth_token is provided we always hit the api directly
336            # and never try to authenticate.
337            #
338            # Note: When ex_force_auth_token is provided, ex_force_base_url
339            # must be provided as well.
340            self._set_up_connection_info(url=self._ex_force_base_url)
341            return
342
343        if not osa.is_token_valid():
344            # Token is not available or it has expired. Need to retrieve a
345            # new one.
346            if self._auth_version == '2.0_apikey':
347                kwargs = {'auth_type': 'api_key'}
348            elif self._auth_version == '2.0_password':
349                kwargs = {'auth_type': 'password'}
350            else:
351                kwargs = {}
352
353            # pylint: disable=unexpected-keyword-arg
354            osa = osa.authenticate(**kwargs)  # may throw InvalidCreds
355            # pylint: enable=unexpected-keyword-arg
356
357            self.auth_token = osa.auth_token
358            self.auth_token_expires = osa.auth_token_expires
359            self.auth_user_info = osa.auth_user_info
360
361            # Pull out and parse the service catalog
362            osc = OpenStackServiceCatalog(service_catalog=osa.urls,
363                                          auth_version=self._auth_version)
364            self.service_catalog = osc
365
366        url = self._ex_force_base_url or self.get_endpoint()
367        self._set_up_connection_info(url=url)
368
369
370class OpenStackException(ProviderError):
371    pass
372
373
374class OpenStackResponse(Response):
375    node_driver = None
376
377    def success(self):
378        i = int(self.status)
379        return 200 <= i <= 299
380
381    def has_content_type(self, content_type):
382        content_type_value = self.headers.get('content-type') or ''
383        content_type_value = content_type_value.lower()
384        return content_type_value.find(content_type.lower()) > -1
385
386    def parse_body(self):
387        if self.status == httplib.NO_CONTENT or not self.body:
388            return None
389
390        if self.has_content_type('application/xml'):
391            try:
392                return ET.XML(self.body)
393            except Exception:
394                raise MalformedResponseError(
395                    'Failed to parse XML',
396                    body=self.body,
397                    driver=self.node_driver)
398
399        elif self.has_content_type('application/json'):
400            try:
401                return json.loads(self.body)
402            except Exception:
403                raise MalformedResponseError(
404                    'Failed to parse JSON',
405                    body=self.body,
406                    driver=self.node_driver)
407        else:
408            return self.body
409
410    def parse_error(self):
411        body = self.parse_body()
412
413        if self.has_content_type('application/xml'):
414            text = '; '.join([err.text or '' for err in body.getiterator()
415                              if err.text])
416        elif self.has_content_type('application/json'):
417            values = list(body.values())
418
419            context = self.connection.context
420            driver = self.connection.driver
421            key_pair_name = context.get('key_pair_name', None)
422
423            if len(values) > 0 and 'code' in values[0] and \
424                    values[0]['code'] == 404 and key_pair_name:
425                raise KeyPairDoesNotExistError(name=key_pair_name,
426                                               driver=driver)
427            elif len(values) > 0 and 'message' in values[0]:
428                text = ';'.join([fault_data['message'] for fault_data
429                                 in values])
430            else:
431                text = body
432        else:
433            # while we hope a response is always one of xml or json, we have
434            # seen html or text in the past, its not clear we can really do
435            # something to make it more readable here, so we will just pass
436            # it along as the whole response body in the text variable.
437            text = body
438
439        return '%s %s %s' % (self.status, self.error, text)
440
441
442class OpenStackDriverMixin(object):
443
444    def __init__(self,
445                 ex_force_base_url=None,
446                 ex_force_auth_url=None,
447                 ex_force_auth_version=None,
448                 ex_force_auth_token=None,
449                 ex_token_scope=OpenStackIdentityTokenScope.PROJECT,
450                 ex_domain_name='Default',
451                 ex_tenant_name=None,
452                 ex_tenant_domain_id='default',
453                 ex_force_service_type=None,
454                 ex_force_service_name=None,
455                 ex_force_service_region=None,
456                 ex_auth_cache=None, *args, **kwargs):
457        self._ex_force_base_url = ex_force_base_url
458        self._ex_force_auth_url = ex_force_auth_url
459        self._ex_force_auth_version = ex_force_auth_version
460        self._ex_force_auth_token = ex_force_auth_token
461        self._ex_token_scope = ex_token_scope
462        self._ex_domain_name = ex_domain_name
463        self._ex_tenant_name = ex_tenant_name
464        self._ex_tenant_domain_id = ex_tenant_domain_id
465        self._ex_force_service_type = ex_force_service_type
466        self._ex_force_service_name = ex_force_service_name
467        self._ex_force_service_region = ex_force_service_region
468        self._ex_auth_cache = ex_auth_cache
469
470    def openstack_connection_kwargs(self):
471        """
472        Returns certain ``ex_*`` parameters for this connection.
473
474        :rtype: ``dict``
475        """
476        rv = {}
477        if self._ex_force_base_url:
478            rv['ex_force_base_url'] = self._ex_force_base_url
479        if self._ex_force_auth_token:
480            rv['ex_force_auth_token'] = self._ex_force_auth_token
481        if self._ex_force_auth_url:
482            rv['ex_force_auth_url'] = self._ex_force_auth_url
483        if self._ex_force_auth_version:
484            rv['ex_force_auth_version'] = self._ex_force_auth_version
485        if self._ex_token_scope:
486            rv['ex_token_scope'] = self._ex_token_scope
487        if self._ex_domain_name:
488            rv['ex_domain_name'] = self._ex_domain_name
489        if self._ex_tenant_name:
490            rv['ex_tenant_name'] = self._ex_tenant_name
491        if self._ex_tenant_domain_id:
492            rv['ex_tenant_domain_id'] = self._ex_tenant_domain_id
493        if self._ex_force_service_type:
494            rv['ex_force_service_type'] = self._ex_force_service_type
495        if self._ex_force_service_name:
496            rv['ex_force_service_name'] = self._ex_force_service_name
497        if self._ex_force_service_region:
498            rv['ex_force_service_region'] = self._ex_force_service_region
499        if self._ex_auth_cache is not None:
500            rv['ex_auth_cache'] = self._ex_auth_cache
501        return rv
502