blob: 1f13eab6a412e9f9e728ff60967618406223c577 [file] [log] [blame]
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You 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.
"""
Common utilities for OpenStack
"""
import sys
import datetime
from libcloud.utils.py3 import httplib
from libcloud.utils.iso8601 import parse_date
from libcloud.common.base import ConnectionUserAndKey, Response
from libcloud.compute.types import (LibcloudError, InvalidCredsError,
MalformedResponseError)
try:
import simplejson as json
except ImportError:
import json
AUTH_API_VERSION = '1.1'
# Auth versions which contain token expiration information.
AUTH_VERSIONS_WITH_EXPIRES = [
'1.1',
'2.0',
'2.0_apikey',
'2.0_password'
]
# How many seconds to substract from the auth token expiration time before
# testing if the token is still valid.
# The time is subtracted to account for the HTTP request latency and prevent
# user from getting "InvalidCredsError" if token is about to expire.
AUTH_TOKEN_EXPIRES_GRACE_SECONDS = 5
__all__ = [
'OpenStackBaseConnection',
'OpenStackAuthConnection',
'OpenStackServiceCatalog',
'OpenStackDriverMixin',
'AUTH_TOKEN_EXPIRES_GRACE_SECONDS'
]
# @TODO: Refactor for re-use by other openstack drivers
class OpenStackAuthResponse(Response):
def success(self):
return True
def parse_body(self):
if not self.body:
return None
if 'content-type' in self.headers:
key = 'content-type'
elif 'Content-Type' in self.headers:
key = 'Content-Type'
else:
raise LibcloudError('Missing content-type header',
driver=OpenStackAuthConnection)
content_type = self.headers[key]
if content_type.find(';') != -1:
content_type = content_type.split(';')[0]
if content_type == 'application/json':
try:
data = json.loads(self.body)
except:
raise MalformedResponseError('Failed to parse JSON',
body=self.body,
driver=OpenStackAuthConnection)
elif content_type == 'text/plain':
data = self.body
else:
data = self.body
return data
class OpenStackAuthConnection(ConnectionUserAndKey):
responseCls = OpenStackAuthResponse
name = 'OpenStack Auth'
timeout = None
def __init__(self, parent_conn, auth_url, auth_version, user_id, key,
tenant_name=None, timeout=None):
self.parent_conn = parent_conn
# enable tests to use the same mock connection classes.
self.conn_classes = parent_conn.conn_classes
super(OpenStackAuthConnection, self).__init__(
user_id, key, url=auth_url, timeout=timeout)
self.auth_version = auth_version
self.auth_url = auth_url
self.driver = self.parent_conn.driver
self.tenant_name = tenant_name
self.timeout = timeout
self.urls = {}
self.auth_token = None
self.auth_token_expires = None
self.auth_user_info = None
def morph_action_hook(self, action):
(_, _, _, request_path) = self._tuple_from_url(self.auth_url)
if request_path == '':
# No path is provided in the auth_url, use action passed to this
# method.
return action
return request_path
def add_default_headers(self, headers):
headers['Accept'] = 'application/json'
headers['Content-Type'] = 'application/json; charset=UTF-8'
return headers
def authenticate(self, force=False):
"""
Authenticate against the keystone api.
:param force: Forcefully update the token even if it's already cached
and still valid.
:type force: ``bool``
"""
if not force and self.auth_version in AUTH_VERSIONS_WITH_EXPIRES \
and self.is_token_valid():
# If token is still valid, there is no need to re-authenticate
return self
if self.auth_version == "1.0":
return self.authenticate_1_0()
elif self.auth_version == "1.1":
return self.authenticate_1_1()
elif self.auth_version == "2.0" or self.auth_version == "2.0_apikey":
return self.authenticate_2_0_with_apikey()
elif self.auth_version == "2.0_password":
return self.authenticate_2_0_with_password()
else:
raise LibcloudError('Unsupported Auth Version requested')
def authenticate_1_0(self):
headers = {
'X-Auth-User': self.user_id,
'X-Auth-Key': self.key,
}
resp = self.request('/v1.0', headers=headers, method='GET')
if resp.status == httplib.UNAUTHORIZED:
# HTTP UNAUTHORIZED (401): auth failed
raise InvalidCredsError()
elif resp.status not in [httplib.NO_CONTENT, httplib.OK]:
body = 'code: %s body:%s headers:%s' % (resp.status,
resp.body,
resp.headers)
raise MalformedResponseError('Malformed response', body=body,
driver=self.driver)
else:
headers = resp.headers
# emulate the auth 1.1 URL list
self.urls = {}
self.urls['cloudServers'] = \
[{'publicURL': headers.get('x-server-management-url', None)}]
self.urls['cloudFilesCDN'] = \
[{'publicURL': headers.get('x-cdn-management-url', None)}]
self.urls['cloudFiles'] = \
[{'publicURL': headers.get('x-storage-url', None)}]
self.auth_token = headers.get('x-auth-token', None)
self.auth_user_info = None
if not self.auth_token:
raise MalformedResponseError('Missing X-Auth-Token in \
response headers')
return self
def authenticate_1_1(self):
reqbody = json.dumps({'credentials': {'username': self.user_id,
'key': self.key}})
resp = self.request('/v1.1/auth', data=reqbody, headers={},
method='POST')
if resp.status == httplib.UNAUTHORIZED:
# HTTP UNAUTHORIZED (401): auth failed
raise InvalidCredsError()
elif resp.status != httplib.OK:
body = 'code: %s body:%s' % (resp.status, resp.body)
raise MalformedResponseError('Malformed response', body=body,
driver=self.driver)
else:
try:
body = json.loads(resp.body)
except Exception:
e = sys.exc_info()[1]
raise MalformedResponseError('Failed to parse JSON', e)
try:
expires = body['auth']['token']['expires']
self.auth_token = body['auth']['token']['id']
self.auth_token_expires = parse_date(expires)
self.urls = body['auth']['serviceCatalog']
self.auth_user_info = None
except KeyError:
e = sys.exc_info()[1]
raise MalformedResponseError('Auth JSON response is \
missing required elements', e)
return self
def authenticate_2_0_with_apikey(self):
# API Key based authentication uses the RAX-KSKEY extension.
# http://s.apache.org/oAi
data = {'auth':
{'RAX-KSKEY:apiKeyCredentials':
{'username': self.user_id, 'apiKey': self.key}}}
if self.tenant_name:
data['auth']['tenantName'] = self.tenant_name
reqbody = json.dumps(data)
return self.authenticate_2_0_with_body(reqbody)
def authenticate_2_0_with_password(self):
# Password based authentication is the only 'core' authentication
# method in Keystone at this time.
# 'keystone' - http://s.apache.org/e8h
data = {'auth':
{'passwordCredentials':
{'username': self.user_id, 'password': self.key}}}
if self.tenant_name:
data['auth']['tenantName'] = self.tenant_name
reqbody = json.dumps(data)
return self.authenticate_2_0_with_body(reqbody)
def authenticate_2_0_with_body(self, reqbody):
resp = self.request('/v2.0/tokens', data=reqbody,
headers={'Content-Type': 'application/json'},
method='POST')
if resp.status == httplib.UNAUTHORIZED:
raise InvalidCredsError()
elif resp.status not in [httplib.OK,
httplib.NON_AUTHORITATIVE_INFORMATION]:
body = 'code: %s body: %s' % (resp.status, resp.body)
raise MalformedResponseError('Malformed response', body=body,
driver=self.driver)
else:
try:
body = json.loads(resp.body)
except Exception:
e = sys.exc_info()[1]
raise MalformedResponseError('Failed to parse JSON', e)
try:
access = body['access']
expires = access['token']['expires']
self.auth_token = access['token']['id']
self.auth_token_expires = parse_date(expires)
self.urls = access['serviceCatalog']
self.auth_user_info = access.get('user', {})
except KeyError:
e = sys.exc_info()[1]
raise MalformedResponseError('Auth JSON response is \
missing required elements', e)
return self
def is_token_valid(self):
"""
Return True if the current auth token is already cached and hasn't
expired yet.
:return: ``True`` if the token is still valid, ``False`` otherwise.
:rtype: ``bool``
"""
if not self.auth_token:
return False
if not self.auth_token_expires:
return False
expires = self.auth_token_expires - \
datetime.timedelta(seconds=AUTH_TOKEN_EXPIRES_GRACE_SECONDS)
time_tuple_expires = expires.utctimetuple()
time_tuple_now = datetime.datetime.utcnow().utctimetuple()
if time_tuple_now < time_tuple_expires:
return True
return False
class OpenStackServiceCatalog(object):
"""
http://docs.openstack.org/api/openstack-identity-service/2.0/content/
This class should be instanciated with the contents of the
'serviceCatalog' in the auth response. This will do the work of figuring
out which services actually exist in the catalog as well as split them up
by type, name, and region if available
"""
_auth_version = None
_service_catalog = None
def __init__(self, service_catalog, ex_force_auth_version=None):
self._auth_version = ex_force_auth_version or AUTH_API_VERSION
self._service_catalog = {}
# Check this way because there are a couple of different 2.0_*
# auth types.
if '2.0' in self._auth_version:
self._parse_auth_v2(service_catalog)
elif ('1.1' in self._auth_version) or ('1.0' in self._auth_version):
self._parse_auth_v1(service_catalog)
else:
raise LibcloudError('auth version "%s" not supported'
% (self._auth_version))
def get_catalog(self):
return self._service_catalog
def get_public_urls(self, service_type=None, name=None):
endpoints = self.get_endpoints(service_type=service_type,
name=name)
result = []
for endpoint in endpoints:
if 'publicURL' in endpoint:
result.append(endpoint['publicURL'])
return result
def get_endpoints(self, service_type=None, name=None):
eps = []
if '2.0' in self._auth_version:
endpoints = self._service_catalog.get(service_type, {}) \
.get(name, {})
elif ('1.1' in self._auth_version) or ('1.0' in self._auth_version):
endpoints = self._service_catalog.get(name, {})
for regionName, values in endpoints.items():
eps.append(values[0])
return eps
def get_endpoint(self, service_type=None, name=None, region=None):
if '2.0' in self._auth_version:
endpoint = self._service_catalog.get(service_type, {}) \
.get(name, {}).get(region, [])
elif ('1.1' in self._auth_version) or ('1.0' in self._auth_version):
endpoint = self._service_catalog.get(name, {}).get(region, [])
# ideally an endpoint either isn't found or only one match is found.
if len(endpoint) == 1:
return endpoint[0]
else:
return {}
def _parse_auth_v1(self, service_catalog):
for service, endpoints in service_catalog.items():
self._service_catalog[service] = {}
for endpoint in endpoints:
region = endpoint.get('region')
if region not in self._service_catalog[service]:
self._service_catalog[service][region] = []
self._service_catalog[service][region].append(endpoint)
def _parse_auth_v2(self, service_catalog):
for service in service_catalog:
service_type = service['type']
service_name = service.get('name', None)
if service_type not in self._service_catalog:
self._service_catalog[service_type] = {}
if service_name not in self._service_catalog[service_type]:
self._service_catalog[service_type][service_name] = {}
for endpoint in service.get('endpoints', []):
region = endpoint.get('region', None)
catalog = self._service_catalog[service_type][service_name]
if region not in catalog:
catalog[region] = []
catalog[region].append(endpoint)
class OpenStackBaseConnection(ConnectionUserAndKey):
"""
Base class for OpenStack connections.
:param user_id: User name to use when authenticating
:type user_id: ``str``
:param key: Secret to use when authenticating.
:type key: ``str``
:param secure: Use HTTPS? (True by default.)
:type secure: ``bool``
:param ex_force_base_url: Base URL for connection requests. If
not specified, this will be determined by authenticating.
:type ex_force_base_url: ``str``
:param ex_force_auth_url: Base URL for authentication requests.
:type ex_force_auth_url: ``str``
:param ex_force_auth_version: Authentication version to use. If
not specified, defaults to AUTH_API_VERSION.
:type ex_force_auth_version: ``str``
:param ex_force_auth_token: Authentication token to use for
connection requests. If specified, the connection will not attempt
to authenticate, and the value of ex_force_base_url will be used to
determine the base request URL. If ex_force_auth_token is passed in,
ex_force_base_url must also be provided.
:type ex_force_auth_token: ``str``
:param ex_tenant_name: When authenticating, provide this tenant
name to the identity service. A scoped token will be returned.
Some cloud providers require the tenant name to be provided at
authentication time. Others will use a default tenant if none
is provided.
:type ex_tenant_name: ``str``
:param ex_force_service_type: Service type to use when selecting an
service. If not specified, a provider specific default will be used.
:type ex_force_service_type: ``str``
:param ex_force_service_name: Service name to use when selecting an
service. If not specified, a provider specific default will be used.
:type ex_force_service_name: ``str``
:param ex_force_service_region: Region to use when selecting an
service. If not specified, a provider specific default will be used.
:type ex_force_service_region: ``str``
"""
auth_url = None
auth_token = None
auth_token_expires = None
auth_user_info = None
service_catalog = None
service_type = None
service_name = None
service_region = None
_auth_version = None
def __init__(self, user_id, key, secure=True,
host=None, port=None, timeout=None,
ex_force_base_url=None,
ex_force_auth_url=None,
ex_force_auth_version=None,
ex_force_auth_token=None,
ex_tenant_name=None,
ex_force_service_type=None,
ex_force_service_name=None,
ex_force_service_region=None):
super(OpenStackBaseConnection, self).__init__(
user_id, key, secure=secure, timeout=timeout)
self._ex_force_base_url = ex_force_base_url
self._ex_force_auth_url = ex_force_auth_url
self._auth_version = self._auth_version or ex_force_auth_version
self._ex_force_auth_token = ex_force_auth_token
self._ex_tenant_name = ex_tenant_name
self._ex_force_service_type = ex_force_service_type
self._ex_force_service_name = ex_force_service_name
self._ex_force_service_region = ex_force_service_region
if ex_force_auth_token and not ex_force_base_url:
raise LibcloudError(
'Must also provide ex_force_base_url when specifying '
'ex_force_auth_token.')
if ex_force_auth_token:
self.auth_token = ex_force_auth_token
if not self._auth_version:
self._auth_version = AUTH_API_VERSION
auth_url = self._get_auth_url()
if not auth_url:
raise LibcloudError('OpenStack instance must ' +
'have auth_url set')
osa = OpenStackAuthConnection(self, auth_url, self._auth_version,
self.user_id, self.key,
tenant_name=self._ex_tenant_name,
timeout=self.timeout)
self._osa = osa
def _get_auth_url(self):
"""
Retrieve auth url for this instance using either "ex_force_auth_url"
constructor kwarg of "auth_url" class variable.
"""
auth_url = self.auth_url
if self._ex_force_auth_url is not None:
auth_url = self._ex_force_auth_url
return auth_url
def get_service_catalog(self):
if self.service_catalog is None:
self._populate_hosts_and_request_paths()
return self.service_catalog
def get_endpoint(self):
"""
Selects the endpoint to use based on provider specific values,
or overrides passed in by the user when setting up the driver.
:returns: url of the relevant endpoint for the driver
"""
service_type = self.service_type
service_name = self.service_name
service_region = self.service_region
if self._ex_force_service_type:
service_type = self._ex_force_service_type
if self._ex_force_service_name:
service_name = self._ex_force_service_name
if self._ex_force_service_region:
service_region = self._ex_force_service_region
ep = self.service_catalog.get_endpoint(service_type=service_type,
name=service_name,
region=service_region)
if 'publicURL' in ep:
return ep['publicURL']
raise LibcloudError('Could not find specified endpoint')
def add_default_headers(self, headers):
headers['X-Auth-Token'] = self.auth_token
headers['Accept'] = self.accept_format
return headers
def morph_action_hook(self, action):
self._populate_hosts_and_request_paths()
return super(OpenStackBaseConnection, self).morph_action_hook(action)
def request(self, **kwargs):
return super(OpenStackBaseConnection, self).request(**kwargs)
def _populate_hosts_and_request_paths(self):
"""
OpenStack uses a separate host for API calls which is only provided
after an initial authentication request.
"""
osa = self._osa
# Token is not available or it has expired. Need to retrieve a new one.
if not self._ex_force_auth_token and not osa.is_token_valid():
# This call may throw InvalidCreds, etc
osa.authenticate()
self.auth_token = osa.auth_token
self.auth_token_expires = osa.auth_token_expires
self.auth_user_info = osa.auth_user_info
# Pull out and parse the service catalog
osc = OpenStackServiceCatalog(osa.urls, ex_force_auth_version=
self._auth_version)
self.service_catalog = osc
# Set up connection info
url = self._ex_force_base_url or self.get_endpoint()
(self.host, self.port, self.secure, self.request_path) = \
self._tuple_from_url(url)
class OpenStackDriverMixin(object):
def __init__(self, *args, **kwargs):
self._ex_force_base_url = kwargs.get('ex_force_base_url', None)
self._ex_force_auth_url = kwargs.get('ex_force_auth_url', None)
self._ex_force_auth_version = kwargs.get('ex_force_auth_version', None)
self._ex_force_auth_token = kwargs.get('ex_force_auth_token', None)
self._ex_tenant_name = kwargs.get('ex_tenant_name', None)
self._ex_force_service_type = kwargs.get('ex_force_service_type', None)
self._ex_force_service_name = kwargs.get('ex_force_service_name', None)
self._ex_force_service_region = kwargs.get('ex_force_service_region',
None)
def openstack_connection_kwargs(self):
"""
:rtype: ``dict``
"""
rv = {}
if self._ex_force_base_url:
rv['ex_force_base_url'] = self._ex_force_base_url
if self._ex_force_auth_token:
rv['ex_force_auth_token'] = self._ex_force_auth_token
if self._ex_force_auth_url:
rv['ex_force_auth_url'] = self._ex_force_auth_url
if self._ex_force_auth_version:
rv['ex_force_auth_version'] = self._ex_force_auth_version
if self._ex_tenant_name:
rv['ex_tenant_name'] = self._ex_tenant_name
if self._ex_force_service_type:
rv['ex_force_service_type'] = self._ex_force_service_type
if self._ex_force_service_name:
rv['ex_force_service_name'] = self._ex_force_service_name
if self._ex_force_service_region:
rv['ex_force_service_region'] = self._ex_force_service_region
return rv