diff --git a/reddwarfclient/__init__.py b/reddwarfclient/__init__.py index 2914c33b..40bd3f81 100644 --- a/reddwarfclient/__init__.py +++ b/reddwarfclient/__init__.py @@ -15,7 +15,6 @@ from reddwarfclient.accounts import Accounts -from reddwarfclient.config import Configs from reddwarfclient.databases import Databases from reddwarfclient.flavors import Flavors from reddwarfclient.instances import Instances diff --git a/reddwarfclient/accounts.py b/reddwarfclient/accounts.py index 8c5db215..183330e5 100644 --- a/reddwarfclient/accounts.py +++ b/reddwarfclient/accounts.py @@ -13,7 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. -from novaclient import base +from reddwarfclient import base class Account(base.Resource): diff --git a/reddwarfclient/auth.py b/reddwarfclient/auth.py new file mode 100644 index 00000000..35ccf1d3 --- /dev/null +++ b/reddwarfclient/auth.py @@ -0,0 +1,178 @@ +# Copyright 2012 OpenStack LLC +# +# 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 reddwarfclient import exceptions + + +class Authenticator(object): + """ + Helper class to perform Keystone or other miscellaneous authentication. + """ + + def __init__(self, client, type, url, username, password, tenant, + region=None, service_type=None, service_name=None, + service_url=None): + self.client = client + self.type = type + self.url = url + self.username = username + self.password = password + self.tenant = tenant + self.region = region + self.service_type = service_type + self.service_name = service_name + self.service_url = service_url + + def _authenticate(self, url, body): + """Authenticate and extract the service catalog.""" + # Make sure we follow redirects when trying to reach Keystone + tmp_follow_all_redirects = self.client.follow_all_redirects + self.client.follow_all_redirects = True + + try: + resp, body = self.client._time_request(url, "POST", body=body) + finally: + self.client.follow_all_redirects = tmp_follow_all_redirects + + if resp.status == 200: # content must always present + try: + return ServiceCatalog(body, region=self.region, + service_type=self.service_type, + service_name=self.service_name, + service_url=self.service_url) + except exceptions.AmbiguousEndpoints: + print "Found more than one valid endpoint. Use a more "\ + "restrictive filter" + raise + except KeyError: + raise exceptions.AuthorizationFailure() + except exceptions.EndpointNotFound: + print "Could not find any suitable endpoint. Correct region?" + raise + + elif resp.status == 305: + return resp['location'] + else: + raise exceptions.from_response(resp, body) + + def authenticate(self): + if self.type == "keystone": + return self._v2_auth(self.url) + elif self.type == "rax": + return self._rax_auth(self.url) + + def _v2_auth(self, url): + """Authenticate against a v2.0 auth service.""" + body = {"auth": { + "passwordCredentials": { + "username": self.username, + "password": self.password} + } + } + + if self.tenant: + body['auth']['tenantName'] = self.tenant + + return self._authenticate(url, body) + + def _rax_auth(self, url): + """Authenticate against the Rackspace auth service.""" + body = {'auth': { + 'RAX-KSKEY:apiKeyCredentials': { + 'username': self.username, + 'apiKey': self.password, + 'tenantName': self.tenant} + } + } + + return self._authenticate(self.url, body) + + +class ServiceCatalog(object): + """Helper methods for dealing with a Keystone Service Catalog.""" + + def __init__(self, resource_dict, region=None, service_type=None, + service_name=None, service_url=None): + self.catalog = resource_dict + self.region = region + self.service_type = service_type + self.service_name = service_name + self.service_url = service_url + self.management_url = None + self.public_url = None + self._load() + + def _load(self): + if not self.service_url: + self.public_url = self._url_for(attr='region', + filter_value=self.region, + endpoint_type="publicURL") + self.management_url = self._url_for(attr='region', + filter_value=self.region, + endpoint_type="adminURL") + else: + self.public_url = self.service_url + self.management_url = self.service_url + + def get_token(self): + return self.catalog['access']['token']['id'] + + def get_management_url(self): + return self.management_url + + def get_public_url(self): + return self.public_url + + def _url_for(self, attr=None, filter_value=None, + endpoint_type='publicURL'): + """ + Fetch the public URL from the Reddwarf service for a particular + endpoint attribute. If none given, return the first. + """ + matching_endpoints = [] + if 'endpoints' in self.catalog: + # We have a bastardized service catalog. Treat it special. :/ + for endpoint in self.catalog['endpoints']: + if not filter_value or endpoint[attr] == filter_value: + matching_endpoints.append(endpoint) + if not matching_endpoints: + raise exceptions.EndpointNotFound() + + # We don't always get a service catalog back ... + if not 'serviceCatalog' in self.catalog['access']: + raise exceptions.EndpointNotFound() + + # Full catalog ... + catalog = self.catalog['access']['serviceCatalog'] + + for service in catalog: + if service.get("type") != self.service_type: + continue + + if (self.service_name and self.service_type == 'reddwarf' and + service.get('name') != self.service_name): + continue + + endpoints = service['endpoints'] + for endpoint in endpoints: + if not filter_value or endpoint.get(attr) == filter_value: + endpoint["serviceName"] = service.get("name") + matching_endpoints.append(endpoint) + + if not matching_endpoints: + raise exceptions.EndpointNotFound() + elif len(matching_endpoints) > 1: + raise exceptions.AmbiguousEndpoints(endpoints=matching_endpoints) + else: + return matching_endpoints[0].get(endpoint_type, None) diff --git a/reddwarfclient/base.py b/reddwarfclient/base.py index db627a1a..6660ff4a 100644 --- a/reddwarfclient/base.py +++ b/reddwarfclient/base.py @@ -1,14 +1,293 @@ -def isid(obj): +# Copyright 2010 Jacob Kaplan-Moss + +# 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. + +""" +Base utilities to build API operation managers and objects on top of. +""" + +import contextlib +import hashlib +import os +from reddwarfclient import exceptions +from reddwarfclient import utils + + +# Python 2.4 compat +try: + all +except NameError: + def all(iterable): + return True not in (not x for x in iterable) + + +def getid(obj): """ - Returns true if the given object can be converted to an ID, - false otherwise. + Abstracts the common pattern of allowing both an object or an object's ID + as a parameter when dealing with relationships. """ - if hasattr(obj, "id"): - return True - else: - try: - int(obj) - except ValueError: - return False + try: + return obj.id + except AttributeError: + return obj + + +class Manager(utils.HookableMixin): + """ + Managers interact with a particular type of API (servers, flavors, images, + etc.) and provide CRUD operations for them. + """ + resource_class = None + + def __init__(self, api): + self.api = api + + def _list(self, url, response_key, obj_class=None, body=None): + resp = None + if body: + resp, body = self.api.client.post(url, body=body) else: - return True + resp, body = self.api.client.get(url) + + if obj_class is None: + obj_class = self.resource_class + + data = body[response_key] + # NOTE(ja): keystone returns values as list as {'values': [ ... ]} + # unlike other services which just return the list... + if isinstance(data, dict): + try: + data = data['values'] + except KeyError: + pass + + with self.completion_cache('human_id', obj_class, mode="w"): + with self.completion_cache('uuid', obj_class, mode="w"): + return [obj_class(self, res, loaded=True) + for res in data if res] + + @contextlib.contextmanager + def completion_cache(self, cache_type, obj_class, mode): + """ + The completion cache store items that can be used for bash + autocompletion, like UUIDs or human-friendly IDs. + + A resource listing will clear and repopulate the cache. + + A resource create will append to the cache. + + Delete is not handled because listings are assumed to be performed + often enough to keep the cache reasonably up-to-date. + """ + base_dir = utils.env('REDDWARFCLIENT_ID_CACHE_DIR', + default="~/.reddwarfclient") + + # NOTE(sirp): Keep separate UUID caches for each username + endpoint + # pair + username = utils.env('OS_USERNAME', 'USERNAME') + url = utils.env('OS_URL', 'SERVICE_URL') + uniqifier = hashlib.md5(username + url).hexdigest() + + cache_dir = os.path.expanduser(os.path.join(base_dir, uniqifier)) + + try: + os.makedirs(cache_dir, 0755) + except OSError: + # NOTE(kiall): This is typicaly either permission denied while + # attempting to create the directory, or the directory + # already exists. Either way, don't fail. + pass + + resource = obj_class.__name__.lower() + filename = "%s-%s-cache" % (resource, cache_type.replace('_', '-')) + path = os.path.join(cache_dir, filename) + + cache_attr = "_%s_cache" % cache_type + + try: + setattr(self, cache_attr, open(path, mode)) + except IOError: + # NOTE(kiall): This is typicaly a permission denied while + # attempting to write the cache file. + pass + + try: + yield + finally: + cache = getattr(self, cache_attr, None) + if cache: + cache.close() + delattr(self, cache_attr) + + def write_to_completion_cache(self, cache_type, val): + cache = getattr(self, "_%s_cache" % cache_type, None) + if cache: + cache.write("%s\n" % val) + + def _get(self, url, response_key=None): + resp, body = self.api.client.get(url) + if response_key: + return self.resource_class(self, body[response_key], loaded=True) + else: + return self.resource_class(self, body, loaded=True) + + def _create(self, url, body, response_key, return_raw=False, **kwargs): + self.run_hooks('modify_body_for_create', body, **kwargs) + resp, body = self.api.client.post(url, body=body) + if return_raw: + return body[response_key] + + with self.completion_cache('human_id', self.resource_class, mode="a"): + with self.completion_cache('uuid', self.resource_class, mode="a"): + return self.resource_class(self, body[response_key]) + + def _delete(self, url): + resp, body = self.api.client.delete(url) + + def _update(self, url, body, **kwargs): + self.run_hooks('modify_body_for_update', body, **kwargs) + resp, body = self.api.client.put(url, body=body) + return body + + +class ManagerWithFind(Manager): + """ + Like a `Manager`, but with additional `find()`/`findall()` methods. + """ + def find(self, **kwargs): + """ + Find a single item with attributes matching ``**kwargs``. + + This isn't very efficient: it loads the entire list then filters on + the Python side. + """ + matches = self.findall(**kwargs) + num_matches = len(matches) + if num_matches == 0: + msg = "No %s matching %s." % (self.resource_class.__name__, kwargs) + raise exceptions.NotFound(404, msg) + elif num_matches > 1: + raise exceptions.NoUniqueMatch + else: + return matches[0] + + def findall(self, **kwargs): + """ + Find all items with attributes matching ``**kwargs``. + + This isn't very efficient: it loads the entire list then filters on + the Python side. + """ + found = [] + searches = kwargs.items() + + for obj in self.list(): + try: + if all(getattr(obj, attr) == value + for (attr, value) in searches): + found.append(obj) + except AttributeError: + continue + + return found + + def list(self): + raise NotImplementedError + + +class Resource(object): + """ + A resource represents a particular instance of an object (server, flavor, + etc). This is pretty much just a bag for attributes. + + :param manager: Manager object + :param info: dictionary representing resource attributes + :param loaded: prevent lazy-loading if set to True + """ + HUMAN_ID = False + + def __init__(self, manager, info, loaded=False): + self.manager = manager + self._info = info + self._add_details(info) + self._loaded = loaded + + # NOTE(sirp): ensure `id` is already present because if it isn't we'll + # enter an infinite loop of __getattr__ -> get -> __init__ -> + # __getattr__ -> ... + if 'id' in self.__dict__ and len(str(self.id)) == 36: + self.manager.write_to_completion_cache('uuid', self.id) + + human_id = self.human_id + if human_id: + self.manager.write_to_completion_cache('human_id', human_id) + + @property + def human_id(self): + """Subclasses may override this provide a pretty ID which can be used + for bash completion. + """ + if 'name' in self.__dict__ and self.HUMAN_ID: + return utils.slugify(self.name) + return None + + def _add_details(self, info): + for (k, v) in info.iteritems(): + try: + setattr(self, k, v) + except AttributeError: + # In this case we already defined the attribute on the class + pass + + def __getattr__(self, k): + if k not in self.__dict__: + #NOTE(bcwaldon): disallow lazy-loading if already loaded once + if not self.is_loaded(): + self.get() + return self.__getattr__(k) + + raise AttributeError(k) + else: + return self.__dict__[k] + + def __repr__(self): + reprkeys = sorted(k for k in self.__dict__.keys() if k[0] != '_' and + k != 'manager') + info = ", ".join("%s=%s" % (k, getattr(self, k)) for k in reprkeys) + return "<%s %s>" % (self.__class__.__name__, info) + + def get(self): + # set_loaded() first ... so if we have to bail, we know we tried. + self.set_loaded(True) + if not hasattr(self.manager, 'get'): + return + + new = self.manager.get(self.id) + if new: + self._add_details(new._info) + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return False + if hasattr(self, 'id') and hasattr(other, 'id'): + return self.id == other.id + return self._info == other._info + + def is_loaded(self): + return self._loaded + + def set_loaded(self, val): + self._loaded = val diff --git a/reddwarfclient/cli.py b/reddwarfclient/cli.py index 941dbed2..6cbc694f 100644 --- a/reddwarfclient/cli.py +++ b/reddwarfclient/cli.py @@ -32,16 +32,11 @@ possible_topdir = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]), if os.path.exists(os.path.join(possible_topdir, 'reddwarfclient', '__init__.py')): sys.path.insert(0, possible_topdir) -if os.path.exists(os.path.join(possible_topdir, 'nova', '__init__.py')): - sys.path.insert(0, possible_topdir) from reddwarfclient import common -oparser = None - - def _pretty_print(info): print json.dumps(info, sort_keys=True, indent=4) @@ -261,11 +256,29 @@ class VersionCommands(object): print sys.exc_info()[1] -def config_options(): - global oparser - oparser.add_option("-u", "--url", default="http://localhost:5000/v1.1", +def config_options(oparser): + oparser.add_option("--auth_url", default="http://localhost:5000/v2.0", help="Auth API endpoint URL with port and version. \ - Default: http://localhost:5000/v1.1") + Default: http://localhost:5000/v2.0") + oparser.add_option("--username", help="Login username") + oparser.add_option("--apikey", help="Api key") + oparser.add_option("--tenant_id", + help="Tenant Id associated with the account") + oparser.add_option("--auth_type", default="keystone", + help="Auth type to support different auth environments, \ + Supported values are 'keystone', 'rax'.") + oparser.add_option("--service_type", default="reddwarf", + help="Service type is a name associated for the catalog") + oparser.add_option("--service_name", default="Reddwarf", + help="Service name as provided in the service catalog") + oparser.add_option("--service_url", default="", + help="Service endpoint to use if the catalog doesn't \ + have one") + oparser.add_option("--region", default="RegionOne", + help="Region the service is located in") + oparser.add_option("-i", "--insecure", action="store_true", + dest="insecure", default=False, + help="Run in insecure mode for https endpoints.") COMMANDS = {'auth': common.Auth, @@ -280,10 +293,9 @@ COMMANDS = {'auth': common.Auth, def main(): # Parse arguments - global oparser oparser = optparse.OptionParser("%prog [options] ", version='1.0') - config_options() + config_options(oparser) (options, args) = oparser.parse_args() if not args: @@ -307,7 +319,12 @@ def main(): fn = actions.get(action) try: - fn(*args) + # TODO(rnirmal): Fix when we have proper argument parsing for + # the rest of the commands. + if fn.__name__ == "login": + fn(*args, options=options) + else: + fn(*args) sys.exit(0) except TypeError as err: print "Possible wrong number of arguments supplied." diff --git a/reddwarfclient/client.py b/reddwarfclient/client.py index b950284b..72197229 100644 --- a/reddwarfclient/client.py +++ b/reddwarfclient/client.py @@ -13,6 +13,9 @@ # License for the specific language governing permissions and limitations # under the License. +import httplib2 +import logging +import os import time import urlparse @@ -21,137 +24,88 @@ try: except ImportError: import simplejson as json +# Python 2.5 compat fix +if not hasattr(urlparse, 'parse_qsl'): + import cgi + urlparse.parse_qsl = cgi.parse_qsl -from novaclient.client import HTTPClient -from novaclient.v1_1.client import Client - -from novaclient import exceptions as nova_exceptions +from reddwarfclient import auth from reddwarfclient import exceptions -class ReddwarfHTTPClient(HTTPClient): - """ - Class for overriding the HTTP authenticate call and making it specific to - reddwarf - """ +_logger = logging.getLogger(__name__) +if 'REDDWARFCLIENT_DEBUG' in os.environ and os.environ['REDDWARFCLIENT_DEBUG']: + ch = logging.StreamHandler() + _logger.setLevel(logging.DEBUG) + _logger.addHandler(ch) - def __init__(self, user, apikey, tenant, auth_url, service_name, + +class ReddwarfHTTPClient(httplib2.Http): + + USER_AGENT = 'python-reddwarfclient' + + def __init__(self, user, password, tenant, auth_url, service_name, service_url=None, - auth_strategy=None, **kwargs): - super(ReddwarfHTTPClient, self).__init__(user, apikey, tenant, - auth_url, - **kwargs) - self.api_key = apikey + auth_strategy=None, insecure=False, + timeout=None, proxy_tenant_id=None, + proxy_token=None, region_name=None, + endpoint_type='publicURL', service_type=None, + timings=False): + + super(ReddwarfHTTPClient, self).__init__(timeout=timeout) + + self.username = user + self.password = password self.tenant = tenant - self.service = service_name - self.management_url = service_url - if auth_strategy == "basic": - self.auth_strategy = self.basic_auth - elif auth_strategy == "rax": - self.auth_strategy = self._rax_auth - else: - self.auth_strategy = super(ReddwarfHTTPClient, self).authenticate + self.auth_url = auth_url.rstrip('/') + self.region_name = region_name + self.endpoint_type = endpoint_type + self.service_url = service_url + self.service_type = service_type + self.service_name = service_name + self.timings = timings - def authenticate(self): - self.auth_strategy() + self.times = [] # [("item", starttime, endtime), ...] - def _authenticate_without_tokens(self, url, body): - """Authenticate and extract the service catalog.""" - #TODO(tim.simpson): Copy pasta from Nova client's "_authenticate" but - # does not append "tokens" to the url. + self.auth_token = None + self.proxy_token = proxy_token + self.proxy_tenant_id = proxy_tenant_id - # Make sure we follow redirects when trying to reach Keystone - tmp_follow_all_redirects = self.follow_all_redirects - self.follow_all_redirects = True + # httplib2 overrides + self.force_exception_to_status_code = True + self.disable_ssl_certificate_validation = insecure + self.authenticator = auth.Authenticator(self, auth_strategy, + self.auth_url, self.username, + self.password, self.tenant, + region=region_name, + service_type=service_type, + service_name=service_name, + service_url=service_url) - try: - resp, body = self.request(url, "POST", body=body) - finally: - self.follow_all_redirects = tmp_follow_all_redirects + def get_timings(self): + return self.times - return resp, body + def http_log(self, args, kwargs, resp, body): + if not _logger.isEnabledFor(logging.DEBUG): + return - def basic_auth(self): - """Authenticate against a v2.0 auth service.""" - auth_url = self.auth_url - body = {"credentials": {"username": self.user, - "key": self.password}} - resp, resp_body = self._authenticate_without_tokens(auth_url, body) + string_parts = ['curl -i'] + for element in args: + if element in ('GET', 'POST'): + string_parts.append(' -X %s' % element) + else: + string_parts.append(' %s' % element) - try: - self.auth_token = resp_body['auth']['token']['id'] - except KeyError: - raise nova_exceptions.AuthorizationFailure() - catalog = resp_body['auth']['serviceCatalog'] - if 'cloudDatabases' not in catalog: - raise nova_exceptions.EndpointNotFound() - endpoints = catalog['cloudDatabases'] - for endpoint in endpoints: - if self.region_name is None or \ - endpoint['region'] == self.region_name: - self.management_url = endpoint['publicURL'] - return - raise nova_exceptions.EndpointNotFound() + for element in kwargs['headers']: + header = ' -H "%s: %s"' % (element, kwargs['headers'][element]) + string_parts.append(header) - def _rax_auth(self): - """Authenticate against the Rackspace auth service.""" - body = {'auth': { - 'RAX-KSKEY:apiKeyCredentials': { - 'username': self.user, - 'apiKey': self.password, - 'tenantName': self.projectid}}} - - resp, resp_body = self._authenticate_without_tokens(self.auth_url, body) - - try: - self.auth_token = resp_body['access']['token']['id'] - except KeyError: - raise nova_exceptions.AuthorizationFailure() - if not self.management_url: - catalogs = resp_body['access']['serviceCatalog'] - for catalog in catalogs: - if catalog['name'] == "cloudDatabases": - endpoints = catalog['endpoints'] - for endpoint in endpoints: - if self.region_name is None or \ - endpoint['region'] == self.region_name: - self.management_url = endpoint['publicURL'] - return - raise nova_exceptions.EndpointNotFound() - - def _get_token(self, path, req_body): - """Set the management url and auth token""" - token_url = urlparse.urljoin(self.auth_url, path) - resp, body = self.request(token_url, "POST", body=req_body) - if 'access' in body: - if not self.management_url: - # Assume the new Keystone lite: - catalog = body['access']['serviceCatalog'] - for service in catalog: - if service['name'] == self.service: - self.management_url = service['adminURL'] - self.auth_token = body['access']['token']['id'] - else: - # Assume pre-Keystone Light: - try: - if not self.management_url: - keys = ['auth', - 'serviceCatalog', - self.service, - 0, - 'publicURL'] - url = body - for key in keys: - url = url[key] - self.management_url = url - self.auth_token = body['auth']['token']['id'] - except KeyError: - raise NotImplementedError("Service: %s is not available" - % self.service) + _logger.debug("REQ: %s\n" % "".join(string_parts)) + if 'body' in kwargs: + _logger.debug("REQ BODY: %s\n" % (kwargs['body'])) + _logger.debug("RESP:%s %s\n", resp, body) def request(self, *args, **kwargs): - #TODO(tim.simpson): Copy and pasted from novaclient, since we raise - # extra exception subclasses not raised there. kwargs.setdefault('headers', kwargs.get('headers', {})) kwargs['headers']['User-Agent'] = self.USER_AGENT kwargs['headers']['Accept'] = 'application/json' @@ -159,11 +113,10 @@ class ReddwarfHTTPClient(HTTPClient): kwargs['headers']['Content-Type'] = 'application/json' kwargs['body'] = json.dumps(kwargs['body']) - resp, body = super(HTTPClient, self).request(*args, **kwargs) + resp, body = super(ReddwarfHTTPClient, self).request(*args, **kwargs) # Save this in case anyone wants it. self.last_response = (resp, body) - self.http_log(args, kwargs, resp, body) if body: @@ -179,8 +132,60 @@ class ReddwarfHTTPClient(HTTPClient): return resp, body + def _time_request(self, url, method, **kwargs): + start_time = time.time() + resp, body = self.request(url, method, **kwargs) + self.times.append(("%s %s" % (method, url), + start_time, time.time())) + return resp, body -class Dbaas(Client): + def _cs_request(self, url, method, **kwargs): + if not self.auth_token or not self.service_url: + self.authenticate() + + # Perform the request once. If we get a 401 back then it + # might be because the auth token expired, so try to + # re-authenticate and try again. If it still fails, bail. + try: + kwargs.setdefault('headers', {})['X-Auth-Token'] = self.auth_token + if self.tenant: + kwargs['headers']['X-Auth-Project-Id'] = self.tenant + + resp, body = self._time_request(self.service_url + url, method, + **kwargs) + return resp, body + except exceptions.Unauthorized, ex: + try: + self.authenticate() + resp, body = self._time_request(self.service_url + url, + method, **kwargs) + return resp, body + except exceptions.Unauthorized: + raise ex + + def get(self, url, **kwargs): + return self._cs_request(url, 'GET', **kwargs) + + def post(self, url, **kwargs): + return self._cs_request(url, 'POST', **kwargs) + + def put(self, url, **kwargs): + return self._cs_request(url, 'PUT', **kwargs) + + def delete(self, url, **kwargs): + return self._cs_request(url, 'DELETE', **kwargs) + + def authenticate(self): + catalog = self.authenticator.authenticate() + self.auth_token = catalog.get_token() + if not self.service_url: + if self.endpoint_type == "publicURL": + self.service_url = catalog.get_public_url() + elif self.endpoint_type == "adminURL": + self.service_url = catalog.get_management_url() + + +class Dbaas(object): """ Top-level object to access the Rackspace Database as a Service API. @@ -200,8 +205,8 @@ class Dbaas(Client): """ def __init__(self, username, api_key, tenant=None, auth_url=None, - service_type='reddwarf', service_name='Reddwarf Service', - service_url=None, insecure=False, auth_strategy=None, + service_type='reddwarf', service_name='Reddwarf', + service_url=None, insecure=False, auth_strategy='keystone', region_name=None): from reddwarfclient.versions import Versions from reddwarfclient.databases import Databases @@ -213,10 +218,8 @@ class Dbaas(Client): from reddwarfclient.storage import StorageInfo from reddwarfclient.management import Management from reddwarfclient.accounts import Accounts - from reddwarfclient.config import Configs from reddwarfclient.diagnostics import Interrogator - super(Dbaas, self).__init__(username, api_key, tenant, auth_url) self.client = ReddwarfHTTPClient(username, api_key, tenant, auth_url, service_type=service_type, service_name=service_name, @@ -234,5 +237,21 @@ class Dbaas(Client): self.storage = StorageInfo(self) self.management = Management(self) self.accounts = Accounts(self) - self.configs = Configs(self) self.diagnostics = Interrogator(self) + + def set_management_url(self, url): + self.client.management_url = url + + def get_timings(self): + return self.client.get_timings() + + def authenticate(self): + """ + Authenticate against the server. + + This is called to perform an authentication to retrieve a token. + + Returns on success; raises :exc:`exceptions.Unauthorized` if the + credentials are wrong. + """ + self.client.authenticate() diff --git a/reddwarfclient/common.py b/reddwarfclient/common.py index e5261e22..c3449c65 100644 --- a/reddwarfclient/common.py +++ b/reddwarfclient/common.py @@ -17,7 +17,7 @@ import pickle import sys from reddwarfclient.client import Dbaas -import exceptions +from reddwarfclient import exceptions APITOKEN = os.path.expanduser("~/.apitoken") @@ -31,9 +31,11 @@ def get_client(): dbaas = Dbaas(apitoken._user, apitoken._apikey, tenant=apitoken._tenant, auth_url=apitoken._auth_url, auth_strategy=apitoken._auth_strategy, + service_type=apitoken._service_type, service_name=apitoken._service_name, service_url=apitoken._service_url, - insecure=apitoken._insecure) + insecure=apitoken._insecure, + region_name=apitoken._region_name) dbaas.client.auth_token = apitoken._token return dbaas except IOError: @@ -94,13 +96,15 @@ class APIToken(object): is pickleable.""" def __init__(self, user, apikey, tenant, token, auth_url, auth_strategy, - service_name, service_url, region_name, insecure): + service_type, service_name, service_url, region_name, + insecure): self._user = user self._apikey = apikey self._tenant = tenant self._token = token self._auth_url = auth_url self._auth_strategy = auth_strategy + self._service_type = service_type self._service_name = service_name self._service_url = service_url self._region_name = region_name @@ -113,20 +117,24 @@ class Auth(object): def __init__(self): pass - def login(self, user, apikey, tenant="dbaas", - auth_url="http://localhost:5000/v1.1", - auth_strategy=None, service_name="reddwarf", - region_name="default", service_url=None, insecure=True): + def login(self, options=None): """Login to retrieve an auth token to use for other api calls""" try: - dbaas = Dbaas(user, apikey, tenant, auth_url=auth_url, - auth_strategy=auth_strategy, - service_name=service_name, region_name=None, - service_url=service_url, insecure=insecure) + dbaas = Dbaas(options.username, options.apikey, options.tenant_id, + auth_url=options.auth_url, + auth_strategy=options.auth_type, + service_type=options.service_type, + service_name=options.service_name, + region_name=options.region, + service_url=options.service_url, + insecure=options.insecure) dbaas.authenticate() - apitoken = APIToken(user, apikey, tenant, dbaas.client.auth_token, - auth_url, auth_strategy, service_name, - service_url, region_name, insecure) + apitoken = APIToken(options.username, options.apikey, + options.tenant_id, dbaas.client.auth_token, + options.auth_url, options.auth_type, + options.service_type, options.service_name, + options.service_url, options.region, + options.insecure) with open(APITOKEN, 'wb') as token: pickle.dump(apitoken, token, protocol=2) diff --git a/reddwarfclient/config.py b/reddwarfclient/config.py deleted file mode 100644 index e3429322..00000000 --- a/reddwarfclient/config.py +++ /dev/null @@ -1,73 +0,0 @@ -# Copyright (c) 2011 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 novaclient import base - - -class Config(base.Resource): - """ - A configuration entry - """ - def __repr__(self): - return "" % self.key - - -class Configs(base.ManagerWithFind): - """ - Manage :class:`Configs` resources. - """ - resource_class = Config - - def create(self, configs): - """ - Create the configuration entries - """ - body = {"configs": configs} - url = "/mgmt/configs" - resp, body = self.api.client.post(url, body=body) - - def delete(self, config): - """ - Delete an existing configuration - """ - url = "/mgmt/configs/%s" % config - self._delete(url) - - def list(self): - """ - Get a list of all configuration entries - """ - resp, body = self.api.client.get("/mgmt/configs") - if not body: - raise Exception("Call to /mgmt/configs did not return a body.") - return [self.resource_class(self, res) for res in body['configs']] - - def get(self, config): - """ - Get the specified configuration entry - """ - url = "/mgmt/configs/%s" % config - resp, body = self.api.client.get(url) - if not body: - raise Exception("Call to %s did not return a body." % url) - return self.resource_class(self, body['config']) - - def update(self, config): - """ - Update the configuration entries - """ - body = {"config": config} - url = "/mgmt/configs/%s" % config['key'] - resp, body = self.api.client.put(url, body=body) diff --git a/reddwarfclient/databases.py b/reddwarfclient/databases.py index 48934e12..d7f31e1a 100644 --- a/reddwarfclient/databases.py +++ b/reddwarfclient/databases.py @@ -1,4 +1,4 @@ -from novaclient import base +from reddwarfclient import base from reddwarfclient.common import check_for_exceptions from reddwarfclient.common import limit_url from reddwarfclient.common import Paginated diff --git a/reddwarfclient/diagnostics.py b/reddwarfclient/diagnostics.py index 1fae43d3..3a81ab8d 100644 --- a/reddwarfclient/diagnostics.py +++ b/reddwarfclient/diagnostics.py @@ -13,7 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. -from novaclient import base +from reddwarfclient import base import exceptions diff --git a/reddwarfclient/exceptions.py b/reddwarfclient/exceptions.py index 5a9a0c78..33c25e80 100644 --- a/reddwarfclient/exceptions.py +++ b/reddwarfclient/exceptions.py @@ -12,24 +12,113 @@ # License for the specific language governing permissions and limitations # under the License. -from novaclient import exceptions -from novaclient.exceptions import UnsupportedVersion -from novaclient.exceptions import CommandError -from novaclient.exceptions import AuthorizationFailure -from novaclient.exceptions import NoUniqueMatch -from novaclient.exceptions import NoTokenLookupException -from novaclient.exceptions import EndpointNotFound -from novaclient.exceptions import AmbiguousEndpoints -from novaclient.exceptions import ClientException -from novaclient.exceptions import BadRequest -from novaclient.exceptions import Unauthorized -from novaclient.exceptions import Forbidden -from novaclient.exceptions import NotFound -from novaclient.exceptions import OverLimit -from novaclient.exceptions import HTTPNotImplemented +class UnsupportedVersion(Exception): + """Indicates that the user is trying to use an unsupported + version of the API""" + pass -class UnprocessableEntity(exceptions.ClientException): +class CommandError(Exception): + pass + + +class AuthorizationFailure(Exception): + pass + + +class NoUniqueMatch(Exception): + pass + + +class NoTokenLookupException(Exception): + """This form of authentication does not support looking up + endpoints from an existing token.""" + pass + + +class EndpointNotFound(Exception): + """Could not find Service or Region in Service Catalog.""" + pass + + +class AmbiguousEndpoints(Exception): + """Found more than one matching endpoint in Service Catalog.""" + def __init__(self, endpoints=None): + self.endpoints = endpoints + + def __str__(self): + return "AmbiguousEndpoints: %s" % repr(self.endpoints) + + +class ClientException(Exception): + """ + The base exception class for all exceptions this library raises. + """ + def __init__(self, code, message=None, details=None, request_id=None): + self.code = code + self.message = message or self.__class__.message + self.details = details + self.request_id = request_id + + def __str__(self): + formatted_string = "%s (HTTP %s)" % (self.message, self.code) + if self.request_id: + formatted_string += " (Request-ID: %s)" % self.request_id + + return formatted_string + + +class BadRequest(ClientException): + """ + HTTP 400 - Bad request: you sent some malformed data. + """ + http_status = 400 + message = "Bad request" + + +class Unauthorized(ClientException): + """ + HTTP 401 - Unauthorized: bad credentials. + """ + http_status = 401 + message = "Unauthorized" + + +class Forbidden(ClientException): + """ + HTTP 403 - Forbidden: your credentials don't give you access to this + resource. + """ + http_status = 403 + message = "Forbidden" + + +class NotFound(ClientException): + """ + HTTP 404 - Not found + """ + http_status = 404 + message = "Not found" + + +class OverLimit(ClientException): + """ + HTTP 413 - Over limit: you're over the API limits for this time period. + """ + http_status = 413 + message = "Over limit" + + +# NotImplemented is a python keyword. +class HTTPNotImplemented(ClientException): + """ + HTTP 501 - Not Implemented: the server does not support this operation. + """ + http_status = 501 + message = "Not Implemented" + + +class UnprocessableEntity(ClientException): """ HTTP 422 - Unprocessable Entity: The request cannot be processed. """ @@ -37,7 +126,16 @@ class UnprocessableEntity(exceptions.ClientException): message = "Unprocessable Entity" -_code_map = dict((c.http_status, c) for c in [UnprocessableEntity]) +# In Python 2.4 Exception is old-style and thus doesn't have a __subclasses__() +# so we can do this: +# _code_map = dict((c.http_status, c) +# for c in ClientException.__subclasses__()) +# +# Instead, we have to hardcode it: +_code_map = dict((c.http_status, c) for c in [BadRequest, Unauthorized, + Forbidden, NotFound, OverLimit, + HTTPNotImplemented, + UnprocessableEntity]) def from_response(response, body): @@ -51,10 +149,7 @@ def from_response(response, body): if resp.status != 200: raise exception_from_response(resp, body) """ - cls = _code_map.get(response.status, None) - if not cls: - cls = exceptions._code_map.get(response.status, - exceptions.ClientException) + cls = _code_map.get(response.status, ClientException) if body: message = "n/a" details = "n/a" @@ -64,4 +159,4 @@ def from_response(response, body): details = error.get('details', None) return cls(code=response.status, message=message, details=details) else: - return cls(code=response.status) + return cls(code=response.status, request_id=request_id) diff --git a/reddwarfclient/flavors.py b/reddwarfclient/flavors.py index 6bd280bb..ba01a5f9 100644 --- a/reddwarfclient/flavors.py +++ b/reddwarfclient/flavors.py @@ -14,7 +14,7 @@ # under the License. -from novaclient import base +from reddwarfclient import base import exceptions diff --git a/reddwarfclient/hosts.py b/reddwarfclient/hosts.py index 5f047c37..96bc621d 100644 --- a/reddwarfclient/hosts.py +++ b/reddwarfclient/hosts.py @@ -13,7 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. -from novaclient import base +from reddwarfclient import base class Host(base.Resource): diff --git a/reddwarfclient/instances.py b/reddwarfclient/instances.py index 2682224d..6004551b 100644 --- a/reddwarfclient/instances.py +++ b/reddwarfclient/instances.py @@ -13,7 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. -from novaclient import base +from reddwarfclient import base import exceptions import urlparse diff --git a/reddwarfclient/management.py b/reddwarfclient/management.py index 54a0165c..5a695f7f 100644 --- a/reddwarfclient/management.py +++ b/reddwarfclient/management.py @@ -13,7 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. -from novaclient import base +from reddwarfclient import base import urlparse from reddwarfclient.common import check_for_exceptions diff --git a/reddwarfclient/mcli.py b/reddwarfclient/mcli.py index 32a8cc70..6f4dd48e 100644 --- a/reddwarfclient/mcli.py +++ b/reddwarfclient/mcli.py @@ -32,8 +32,6 @@ possible_topdir = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]), if os.path.exists(os.path.join(possible_topdir, 'reddwarfclient', '__init__.py')): sys.path.insert(0, possible_topdir) -if os.path.exists(os.path.join(possible_topdir, 'nova', '__init__.py')): - sys.path.insert(0, possible_topdir) from reddwarfclient import common diff --git a/reddwarfclient/root.py b/reddwarfclient/root.py index a71b200e..33b0da70 100644 --- a/reddwarfclient/root.py +++ b/reddwarfclient/root.py @@ -13,7 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. -from novaclient import base +from reddwarfclient import base from reddwarfclient import users from reddwarfclient.common import check_for_exceptions diff --git a/reddwarfclient/storage.py b/reddwarfclient/storage.py index 9a317f52..653096ef 100644 --- a/reddwarfclient/storage.py +++ b/reddwarfclient/storage.py @@ -13,7 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. -from novaclient import base +from reddwarfclient import base class Device(base.Resource): diff --git a/reddwarfclient/users.py b/reddwarfclient/users.py index 5f21ada2..4ee6d338 100644 --- a/reddwarfclient/users.py +++ b/reddwarfclient/users.py @@ -13,7 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. -from novaclient import base +from reddwarfclient import base from reddwarfclient.common import check_for_exceptions from reddwarfclient.common import limit_url from reddwarfclient.common import Paginated diff --git a/reddwarfclient/utils.py b/reddwarfclient/utils.py new file mode 100644 index 00000000..3deb8062 --- /dev/null +++ b/reddwarfclient/utils.py @@ -0,0 +1,68 @@ +# Copyright 2012 OpenStack LLC +# +# 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. + +import os +import re +import sys + + +class HookableMixin(object): + """Mixin so classes can register and run hooks.""" + _hooks_map = {} + + @classmethod + def add_hook(cls, hook_type, hook_func): + if hook_type not in cls._hooks_map: + cls._hooks_map[hook_type] = [] + + cls._hooks_map[hook_type].append(hook_func) + + @classmethod + def run_hooks(cls, hook_type, *args, **kwargs): + hook_funcs = cls._hooks_map.get(hook_type) or [] + for hook_func in hook_funcs: + hook_func(*args, **kwargs) + + +def env(*vars, **kwargs): + """ + returns the first environment variable set + if none are non-empty, defaults to '' or keyword arg default + """ + for v in vars: + value = os.environ.get(v, None) + if value: + return value + return kwargs.get('default', '') + + +_slugify_strip_re = re.compile(r'[^\w\s-]') +_slugify_hyphenate_re = re.compile(r'[-\s]+') + + +# http://code.activestate.com/recipes/ +# 577257-slugify-make-a-string-usable-in-a-url-or-filename/ +def slugify(value): + """ + Normalizes string, converts to lowercase, removes non-alpha characters, + and converts spaces to hyphens. + + From Django's "django/template/defaultfilters.py". + """ + import unicodedata + if not isinstance(value, unicode): + value = unicode(value) + value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore') + value = unicode(_slugify_strip_re.sub('', value).strip().lower()) + return _slugify_hyphenate_re.sub('-', value) diff --git a/reddwarfclient/versions.py b/reddwarfclient/versions.py index b666e6a5..f7b52c45 100644 --- a/reddwarfclient/versions.py +++ b/reddwarfclient/versions.py @@ -13,7 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. -from novaclient import base +from reddwarfclient import base class Version(base.Resource): diff --git a/setup.py b/setup.py index ee8d5a5a..93389a3b 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ import setuptools import sys -requirements = ["python-novaclient"] +requirements = [] def read_file(file_name):