diff --git a/openstack-common.conf b/openstack-common.conf new file mode 100644 index 00000000..804aba2a --- /dev/null +++ b/openstack-common.conf @@ -0,0 +1,9 @@ +[DEFAULT] + +# The list of modules to copy from openstack-common +module=apiclient +module=strutils +module=install_venv_common + +# The base module to hold the copy of openstack.common +base=troveclient \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 0e96d60e..69818ab4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,10 @@ pbr>=0.5.16,<0.6 argparse -httplib2 lxml>=2.3 PrettyTable>=0.6,<0.8 +requests>=1.1 +simplejson>=2.0.9 +Babel>=1.3 +six>=1.4.1 +# Compat +httplib2 diff --git a/setup.cfg b/setup.cfg index bc83705f..b58003d7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,15 +17,22 @@ classifier = Programming Language :: Python :: 2.7 Programming Language :: Python :: 2.6 -[entry_points] -console_scripts = - trove-cli = troveclient.cli:main - trove-mgmt-cli = troveclient.mcli:main +[global] +setup-hooks = + pbr.hooks.setup_hook [files] packages = troveclient -[global] -setup-hooks = - pbr.hooks.setup_hook +[entry_points] +console_scripts = + trove = troveclient.shell:main + +[build_sphinx] +all_files = 1 +source-dir = doc/source +build-dir = doc/build + +[upload_sphinx] +upload-dir = doc/build/html \ No newline at end of file diff --git a/setup.py b/setup.py index 15f4e9d5..2a0786a8 100755 --- a/setup.py +++ b/setup.py @@ -18,5 +18,5 @@ import setuptools setuptools.setup( - setup_requires=['pbr>=0.5.20'], + setup_requires=['pbr>=0.5.21,<1.0'], pbr=True) diff --git a/tools/install_venv_common.py b/tools/install_venv_common.py new file mode 100644 index 00000000..92d66ae7 --- /dev/null +++ b/tools/install_venv_common.py @@ -0,0 +1,213 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 OpenStack Foundation +# Copyright 2013 IBM Corp. +# +# 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. + +"""Provides methods needed by installation script for OpenStack development +virtual environments. + +Since this script is used to bootstrap a virtualenv from the system's Python +environment, it should be kept strictly compatible with Python 2.6. + +Synced in from openstack-common +""" + +from __future__ import print_function + +import optparse +import os +import subprocess +import sys + + +class InstallVenv(object): + + def __init__(self, root, venv, requirements, + test_requirements, py_version, + project): + self.root = root + self.venv = venv + self.requirements = requirements + self.test_requirements = test_requirements + self.py_version = py_version + self.project = project + + def die(self, message, *args): + print(message % args, file=sys.stderr) + sys.exit(1) + + def check_python_version(self): + if sys.version_info < (2, 6): + self.die("Need Python Version >= 2.6") + + def run_command_with_code(self, cmd, redirect_output=True, + check_exit_code=True): + """Runs a command in an out-of-process shell. + + Returns the output of that command. Working directory is self.root. + """ + if redirect_output: + stdout = subprocess.PIPE + else: + stdout = None + + proc = subprocess.Popen(cmd, cwd=self.root, stdout=stdout) + output = proc.communicate()[0] + if check_exit_code and proc.returncode != 0: + self.die('Command "%s" failed.\n%s', ' '.join(cmd), output) + return (output, proc.returncode) + + def run_command(self, cmd, redirect_output=True, check_exit_code=True): + return self.run_command_with_code(cmd, redirect_output, + check_exit_code)[0] + + def get_distro(self): + if (os.path.exists('/etc/fedora-release') or + os.path.exists('/etc/redhat-release')): + return Fedora( + self.root, self.venv, self.requirements, + self.test_requirements, self.py_version, self.project) + else: + return Distro( + self.root, self.venv, self.requirements, + self.test_requirements, self.py_version, self.project) + + def check_dependencies(self): + self.get_distro().install_virtualenv() + + def create_virtualenv(self, no_site_packages=True): + """Creates the virtual environment and installs PIP. + + Creates the virtual environment and installs PIP only into the + virtual environment. + """ + if not os.path.isdir(self.venv): + print('Creating venv...', end=' ') + if no_site_packages: + self.run_command(['virtualenv', '-q', '--no-site-packages', + self.venv]) + else: + self.run_command(['virtualenv', '-q', self.venv]) + print('done.') + else: + print("venv already exists...") + pass + + def pip_install(self, *args): + self.run_command(['tools/with_venv.sh', + 'pip', 'install', '--upgrade'] + list(args), + redirect_output=False) + + def install_dependencies(self): + print('Installing dependencies with pip (this can take a while)...') + + # First things first, make sure our venv has the latest pip and + # setuptools and pbr + self.pip_install('pip>=1.4') + self.pip_install('setuptools') + self.pip_install('pbr') + + self.pip_install('-r', self.requirements, '-r', self.test_requirements) + + def post_process(self): + self.get_distro().post_process() + + def parse_args(self, argv): + """Parses command-line arguments.""" + parser = optparse.OptionParser() + parser.add_option('-n', '--no-site-packages', + action='store_true', + help="Do not inherit packages from global Python " + "install") + return parser.parse_args(argv[1:])[0] + + +class Distro(InstallVenv): + + def check_cmd(self, cmd): + return bool(self.run_command(['which', cmd], + check_exit_code=False).strip()) + + def install_virtualenv(self): + if self.check_cmd('virtualenv'): + return + + if self.check_cmd('easy_install'): + print('Installing virtualenv via easy_install...', end=' ') + if self.run_command(['easy_install', 'virtualenv']): + print('Succeeded') + return + else: + print('Failed') + + self.die('ERROR: virtualenv not found.\n\n%s development' + ' requires virtualenv, please install it using your' + ' favorite package management tool' % self.project) + + def post_process(self): + """Any distribution-specific post-processing gets done here. + + In particular, this is useful for applying patches to code inside + the venv. + """ + pass + + +class Fedora(Distro): + """This covers all Fedora-based distributions. + + Includes: Fedora, RHEL, CentOS, Scientific Linux + """ + + def check_pkg(self, pkg): + return self.run_command_with_code(['rpm', '-q', pkg], + check_exit_code=False)[1] == 0 + + def apply_patch(self, originalfile, patchfile): + self.run_command(['patch', '-N', originalfile, patchfile], + check_exit_code=False) + + def install_virtualenv(self): + if self.check_cmd('virtualenv'): + return + + if not self.check_pkg('python-virtualenv'): + self.die("Please install 'python-virtualenv'.") + + super(Fedora, self).install_virtualenv() + + def post_process(self): + """Workaround for a bug in eventlet. + + This currently affects RHEL6.1, but the fix can safely be + applied to all RHEL and Fedora distributions. + + This can be removed when the fix is applied upstream. + + Nova: https://bugs.launchpad.net/nova/+bug/884915 + Upstream: https://bitbucket.org/eventlet/eventlet/issue/89 + RHEL: https://bugzilla.redhat.com/958868 + """ + + if os.path.exists('contrib/redhat-eventlet.patch'): + # Install "patch" program if it's not there + if not self.check_pkg('patch'): + self.die("Please install 'patch'.") + + # Apply the eventlet patch + self.apply_patch(os.path.join(self.venv, 'lib', self.py_version, + 'site-packages', + 'eventlet/green/subprocess.py'), + 'contrib/redhat-eventlet.patch') diff --git a/troveclient/__init__.py b/troveclient/__init__.py index 6ccbda39..8efe8477 100644 --- a/troveclient/__init__.py +++ b/troveclient/__init__.py @@ -1,5 +1,6 @@ -# Copyright (c) 2011 OpenStack Foundation -# All Rights Reserved. +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# 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 @@ -13,20 +14,14 @@ # License for the specific language governing permissions and limitations # under the License. +__all__ = ['__version__'] -from troveclient.accounts import Accounts # noqa -from troveclient.databases import Databases # noqa -from troveclient.flavors import Flavors # noqa -from troveclient.instances import Instances # noqa -from troveclient.hosts import Hosts # noqa -from troveclient.management import Management # noqa -from troveclient.management import RootHistory # noqa -from troveclient.management import MgmtFlavors # noqa -from troveclient.root import Root # noqa -from troveclient.storage import StorageInfo # noqa -from troveclient.users import Users # noqa -from troveclient.versions import Versions # noqa -from troveclient.diagnostics import DiagnosticsInterrogator # noqa -from troveclient.diagnostics import HwInfoInterrogator # noqa -from troveclient.client import Dbaas # noqa -from troveclient.client import TroveHTTPClient # noqa +import pbr.version + +version_info = pbr.version.VersionInfo('python-troveclient') +# We have a circular import problem when we first run python setup.py sdist +# It's harmless, so deflect it. +try: + __version__ = version_info.version_string() +except AttributeError: + __version__ = None diff --git a/troveclient/base.py b/troveclient/base.py index a56ad0f9..58b848e1 100644 --- a/troveclient/base.py +++ b/troveclient/base.py @@ -18,11 +18,14 @@ """ Base utilities to build API operation managers and objects on top of. """ - +import abc import contextlib import hashlib import os -from troveclient import exceptions + +import six + +from troveclient.openstack.common.apiclient import exceptions from troveclient import utils @@ -92,14 +95,15 @@ class Manager(utils.HookableMixin): 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', + base_dir = utils.env('TROVECLIENT_UUID_CACHE_DIR', default="~/.troveclient") # 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() + username = utils.env('OS_USERNAME', 'TROVE_USERNAME') + url = utils.env('OS_URL', 'TROVE_URL') + uniqifier = hashlib.md5(username.encode('utf-8') + + url.encode('utf-8')).hexdigest() cache_dir = os.path.expanduser(os.path.join(base_dir, uniqifier)) @@ -163,11 +167,15 @@ class Manager(utils.HookableMixin): return body -class ManagerWithFind(Manager): +class ManagerWithFind(six.with_metaclass(abc.ABCMeta, Manager)): """ Like a `Manager`, but with additional `find()`/`findall()` methods. """ + @abc.abstractmethod + def list(self): + pass + def find(self, **kwargs): """ Find a single item with attributes matching ``**kwargs``. @@ -193,7 +201,7 @@ class ManagerWithFind(Manager): the Python side. """ found = [] - searches = kwargs.items() + searches = list(kwargs.items()) for obj in self.list(): try: @@ -205,9 +213,6 @@ class ManagerWithFind(Manager): return found - def list(self): - raise NotImplementedError - class Resource(object): """ @@ -246,7 +251,7 @@ class Resource(object): return None def _add_details(self, info): - for (k, v) in info.iteritems(): + for (k, v) in six.iteritems(info): try: setattr(self, k, v) except AttributeError: @@ -265,8 +270,8 @@ class Resource(object): return self.__dict__[k] def __repr__(self): - reprkeys = sorted(k for k in self.__dict__.keys() - if k[0] != '_' and k != 'manager') + reprkeys = sorted(k for k in list(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) diff --git a/troveclient/client.py b/troveclient/client.py index 87fecead..14ec959f 100644 --- a/troveclient/client.py +++ b/troveclient/client.py @@ -13,12 +13,24 @@ # License for the specific language governing permissions and limitations # under the License. -import httplib2 +""" +OpenStack Client interface. Handles the REST calls and responses. +""" + +from __future__ import print_function + import logging import os -import time -import urlparse -import sys + +try: + import urlparse +except ImportError: + import urllib.parse as urlparse + +try: + from eventlet import sleep +except ImportError: + from time import sleep try: import json @@ -30,92 +42,67 @@ if not hasattr(urlparse, 'parse_qsl'): import cgi urlparse.parse_qsl = cgi.parse_qsl -from troveclient import auth -from troveclient import exceptions +import requests + +from troveclient.openstack.common.apiclient import exceptions +from troveclient import service_catalog +from troveclient import utils +from troveclient.openstack.common.apiclient import client -_logger = logging.getLogger(__name__) -RDC_PP = os.environ.get("RDC_PP", "False") == "True" - - -expected_errors = (400, 401, 403, 404, 408, 409, 413, 422, 500, 501) - - -def log_to_streamhandler(stream=None): - stream = stream or sys.stderr - ch = logging.StreamHandler(stream) - _logger.setLevel(logging.DEBUG) - _logger.addHandler(ch) - - -if 'REDDWARFCLIENT_DEBUG' in os.environ and os.environ['REDDWARFCLIENT_DEBUG']: - log_to_streamhandler() - - -class TroveHTTPClient(httplib2.Http): +class HTTPClient(object): USER_AGENT = 'python-troveclient' - def __init__(self, user, password, tenant, auth_url, service_name, - service_url=None, - auth_strategy=None, insecure=False, - timeout=None, proxy_tenant_id=None, + def __init__(self, user, password, projectid, auth_url, insecure=False, + timeout=None, tenant_id=None, proxy_tenant_id=None, proxy_token=None, region_name=None, endpoint_type='publicURL', service_type=None, - timings=False): - - super(TroveHTTPClient, self).__init__(timeout=timeout) - - self.username = user + service_name=None, database_service_name=None, retries=None, + http_log_debug=False, cacert=None): + self.user = user self.password = password - self.tenant = tenant - if auth_url: - self.auth_url = auth_url.rstrip('/') - else: - self.auth_url = None + self.projectid = projectid + self.tenant_id = tenant_id + self.auth_url = auth_url.rstrip('/') + self.version = 'v1' 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 - - self.times = [] # [("item", starttime, endtime), ...] + self.database_service_name = database_service_name + self.retries = int(retries or 0) + self.http_log_debug = http_log_debug + self.management_url = None self.auth_token = None self.proxy_token = proxy_token self.proxy_tenant_id = proxy_tenant_id + self.timeout = timeout - # httplib2 overrides - self.force_exception_to_status_code = True - self.disable_ssl_certificate_validation = insecure - - auth_cls = auth.get_authenticator_cls(auth_strategy) - - self.authenticator = auth_cls(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) - - def get_timings(self): - return self.times - - def http_log(self, args, kwargs, resp, body): - if not RDC_PP: - self.simple_log(args, kwargs, resp, body) + if insecure: + self.verify_cert = False else: - self.pretty_log(args, kwargs, resp, body) + if cacert: + self.verify_cert = cacert + else: + self.verify_cert = True - def simple_log(self, args, kwargs, resp, body): - if not _logger.isEnabledFor(logging.DEBUG): + self._logger = logging.getLogger(__name__) + if self.http_log_debug and not self._logger.handlers: + ch = logging.StreamHandler() + self._logger.setLevel(logging.DEBUG) + self._logger.addHandler(ch) + if hasattr(requests, 'logging'): + requests.logging.getLogger(requests.__name__).addHandler(ch) + + def http_log_req(self, args, kwargs): + if not self.http_log_debug: return string_parts = ['curl -i'] for element in args: - if element in ('GET', 'POST'): + if element in ('GET', 'POST', 'DELETE', 'PUT'): string_parts.append(' -X %s' % element) else: string_parts.append(' %s' % element) @@ -124,117 +111,96 @@ class TroveHTTPClient(httplib2.Http): header = ' -H "%s: %s"' % (element, kwargs['headers'][element]) string_parts.append(header) - _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) + if 'data' in kwargs: + string_parts.append(" -d '%s'" % (kwargs['data'])) + self._logger.debug("\nREQ: %s\n" % "".join(string_parts)) - def pretty_log(self, args, kwargs, resp, body): - if not _logger.isEnabledFor(logging.DEBUG): + def http_log_resp(self, resp): + if not self.http_log_debug: return + self._logger.debug( + "RESP: [%s] %s\nRESP BODY: %s\n", + resp.status_code, + resp.headers, + resp.text) - 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) - - for element in kwargs['headers']: - header = ' -H "%s: %s"' % (element, kwargs['headers'][element]) - string_parts.append(header) - - curl_cmd = "".join(string_parts) - _logger.debug("REQUEST:") - if 'body' in kwargs: - _logger.debug("%s -d '%s'" % (curl_cmd, kwargs['body'])) - try: - req_body = json.dumps(json.loads(kwargs['body']), - sort_keys=True, indent=4) - except: - req_body = kwargs['body'] - _logger.debug("BODY: %s\n" % (req_body)) - else: - _logger.debug(curl_cmd) - - try: - resp_body = json.dumps(json.loads(body), sort_keys=True, indent=4) - except: - resp_body = body - _logger.debug("RESPONSE HEADERS: %s" % resp) - _logger.debug("RESPONSE BODY : %s" % resp_body) - - def request(self, *args, **kwargs): + def request(self, url, method, **kwargs): kwargs.setdefault('headers', kwargs.get('headers', {})) kwargs['headers']['User-Agent'] = self.USER_AGENT - self.morph_request(kwargs) + kwargs['headers']['Accept'] = 'application/json' + if 'body' in kwargs: + kwargs['headers']['Content-Type'] = 'application/json' + kwargs['data'] = json.dumps(kwargs['body']) + del kwargs['body'] - resp, body = super(TroveHTTPClient, self).request(*args, **kwargs) + if self.timeout: + kwargs.setdefault('timeout', self.timeout) + self.http_log_req((url, method,), kwargs) + resp = requests.request( + method, + url, + verify=self.verify_cert, + **kwargs) + self.http_log_resp(resp) - # Save this in case anyone wants it. - self.last_response = (resp, body) - self.http_log(args, kwargs, resp, body) - - if body: + if resp.text: try: - body = self.morph_response_body(body) - except exceptions.ResponseFormatError: - # Acceptable only if the response status is an error code. - # Otherwise its the API or client misbehaving. - self.raise_error_from_status(resp, None) - raise # Not accepted! + body = json.loads(resp.text) + except ValueError: + pass + body = None else: body = None - if resp.status in expected_errors: - raise exceptions.from_response(resp, body) + if resp.status_code >= 400: + raise exceptions.from_response(resp, body, url) return resp, body - def raise_error_from_status(self, resp, body): - if resp.status in expected_errors: - raise exceptions.from_response(resp, body) - - def morph_request(self, kwargs): - kwargs['headers']['Accept'] = 'application/json' - kwargs['headers']['Content-Type'] = 'application/json' - if 'body' in kwargs: - kwargs['body'] = json.dumps(kwargs['body']) - - def morph_response_body(self, body_string): - try: - return json.loads(body_string) - except ValueError: - raise exceptions.ResponseFormatError() - - 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 - def _cs_request(self, url, method, **kwargs): - def request(): + auth_attempts = 0 + attempts = 0 + backoff = 1 + while True: + attempts += 1 + if not self.management_url or not self.auth_token: + self.authenticate() 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 - - 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: - return request() - except exceptions.Unauthorized, ex: - self.authenticate() - return request() + if self.projectid: + kwargs['headers']['X-Auth-Project-Id'] = self.projectid + try: + resp, body = self.request(self.management_url + url, method, + **kwargs) + return resp, body + except exceptions.BadRequest as e: + if attempts > self.retries: + raise + except exceptions.Unauthorized: + if auth_attempts > 0: + raise + self._logger.debug("Unauthorized, reauthenticating.") + self.management_url = self.auth_token = None + # First reauth. Discount this attempt. + attempts -= 1 + auth_attempts += 1 + continue + except exceptions.ClientException as e: + if attempts > self.retries: + raise + if 500 <= e.code <= 599: + pass + else: + raise + except requests.exceptions.ConnectionError as e: + # Catch a connection refused from requests.request + self._logger.debug("Connection refused: %s" % e) + msg = 'Unable to establish connection: %s' % e + raise exceptions.ConnectionError(msg) + self._logger.debug( + "Failed attempt(%s of %s), retrying in %s seconds" % + (attempts, self.retries, backoff)) + sleep(backoff) + backoff *= 2 def get(self, url, **kwargs): return self._cs_request(url, 'GET', **kwargs) @@ -248,124 +214,192 @@ class TroveHTTPClient(httplib2.Http): def delete(self, url, **kwargs): return self._cs_request(url, 'DELETE', **kwargs) - def authenticate(self): - """Auths the client and gets a token. May optionally set a service url. - - The client will get auth errors until the authentication step - occurs. Additionally, if a service_url was not explicitly given in - the clients __init__ method, one will be obtained from the auth - service. - + def _extract_service_catalog(self, url, resp, body, extract_token=True): + """See what the auth service told us and process the response. + We may get redirected to another site, fail or actually get + back a service catalog with a token and our endpoints. """ - catalog = self.authenticator.authenticate() - if self.service_url: - possible_service_url = None + + if resp.status_code == 200: # content must always present + try: + self.auth_url = url + self.service_catalog = \ + service_catalog.ServiceCatalog(body) + + if extract_token: + self.auth_token = self.service_catalog.get_token() + + management_url = self.service_catalog.url_for( + attr='region', + filter_value=self.region_name, + endpoint_type=self.endpoint_type, + service_type=self.service_type, + service_name=self.service_name, + database_service_name=self.database_service_name) + self.management_url = management_url.rstrip('/') + return None + 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_code == 305: + return resp['location'] else: - if self.endpoint_type == "publicURL": - possible_service_url = catalog.get_public_url() - elif self.endpoint_type == "adminURL": - possible_service_url = catalog.get_management_url() - self.authenticate_with_token(catalog.get_token(), possible_service_url) + raise exceptions.from_response(resp, body, url) - def authenticate_with_token(self, token, service_url=None): - self.auth_token = token - if not self.service_url: - if not service_url: - raise exceptions.ServiceUrlNotGiven() - else: - self.service_url = service_url + def _fetch_endpoints_from_auth(self, url): + """We have a token, but don't know the final endpoint for + the region. We have to go back to the auth service and + ask again. This request requires an admin-level token + to work. The proxy token supplied could be from a low-level enduser. + We can't get this from the keystone service endpoint, we have to use + the admin endpoint. -class Dbaas(object): - """ - Top-level object to access the Rackspace Database as a Service API. + This will overwrite our admin token with the user token. + """ - Create an instance with your creds:: - - >>> red = Dbaas(USERNAME, API_KEY, TENANT, AUTH_URL, SERVICE_NAME, \ - SERVICE_URL) - - Then call methods on its managers:: - - >>> red.instances.list() - ... - >>> red.flavors.list() - ... - - &c. - """ - - def __init__(self, username, api_key, tenant=None, auth_url=None, - service_type='database', service_name=None, - service_url=None, insecure=False, auth_strategy='keystone', - region_name=None, client_cls=TroveHTTPClient): - from troveclient.versions import Versions - from troveclient.databases import Databases - from troveclient.flavors import Flavors - from troveclient.instances import Instances - from troveclient.limits import Limits - from troveclient.users import Users - from troveclient.root import Root - from troveclient.hosts import Hosts - from troveclient.quota import Quotas - from troveclient.backups import Backups - from troveclient.security_groups import SecurityGroups - from troveclient.security_groups import SecurityGroupRules - from troveclient.storage import StorageInfo - from troveclient.management import Management - from troveclient.management import MgmtFlavors - from troveclient.accounts import Accounts - from troveclient.diagnostics import DiagnosticsInterrogator - from troveclient.diagnostics import HwInfoInterrogator - - self.client = client_cls(username, api_key, tenant, auth_url, - service_type=service_type, - service_name=service_name, - service_url=service_url, - insecure=insecure, - auth_strategy=auth_strategy, - region_name=region_name) - self.versions = Versions(self) - self.databases = Databases(self) - self.flavors = Flavors(self) - self.instances = Instances(self) - self.limits = Limits(self) - self.users = Users(self) - self.root = Root(self) - self.hosts = Hosts(self) - self.quota = Quotas(self) - self.backups = Backups(self) - self.security_groups = SecurityGroups(self) - self.security_group_rules = SecurityGroupRules(self) - self.storage = StorageInfo(self) - self.management = Management(self) - self.mgmt_flavor = MgmtFlavors(self) - self.accounts = Accounts(self) - self.diagnostics = DiagnosticsInterrogator(self) - self.hwinfo = HwInfoInterrogator(self) - - class Mgmt(object): - def __init__(self, dbaas): - self.instances = dbaas.management - self.hosts = dbaas.hosts - self.accounts = dbaas.accounts - self.storage = dbaas.storage - - self.mgmt = Mgmt(self) - - def set_management_url(self, url): - self.client.management_url = url - - def get_timings(self): - return self.client.get_timings() + # GET ...:5001/v2.0/tokens/#####/endpoints + url = '/'.join([url, 'tokens', '%s?belongsTo=%s' + % (self.proxy_token, self.proxy_tenant_id)]) + self._logger.debug("Using Endpoint URL: %s" % url) + resp, body = self.request(url, "GET", + headers={'X-Auth-Token': self.auth_token}) + return self._extract_service_catalog(url, resp, body, + extract_token=False) def authenticate(self): - """ - Authenticate against the server. + magic_tuple = urlparse.urlsplit(self.auth_url) + scheme, netloc, path, query, frag = magic_tuple + port = magic_tuple.port + if port is None: + port = 80 + path_parts = path.split('/') + for part in path_parts: + if len(part) > 0 and part[0] == 'v': + self.version = part + break - This is called to perform an authentication to retrieve a token. + # TODO(sandy): Assume admin endpoint is 35357 for now. + # Ideally this is going to have to be provided by the service catalog. + new_netloc = netloc.replace(':%d' % port, ':%d' % (35357,)) + admin_url = urlparse.urlunsplit((scheme, new_netloc, + path, query, frag)) - Returns on success; raises :exc:`exceptions.Unauthorized` if the - credentials are wrong. - """ - self.client.authenticate() + auth_url = self.auth_url + if self.version == "v2.0": + while auth_url: + if "TROVE_RAX_AUTH" in os.environ: + auth_url = self._rax_auth(auth_url) + else: + auth_url = self._v2_auth(auth_url) + + # Are we acting on behalf of another user via an + # existing token? If so, our actual endpoints may + # be different than that of the admin token. + if self.proxy_token: + self._fetch_endpoints_from_auth(admin_url) + # Since keystone no longer returns the user token + # with the endpoints any more, we need to replace + # our service account token with the user token. + self.auth_token = self.proxy_token + else: + try: + while auth_url: + auth_url = self._v1_auth(auth_url) + # In some configurations trove makes redirection to + # v2.0 keystone endpoint. Also, new location does not contain + # real endpoint, only hostname and port. + except exceptions.AuthorizationFailure: + if auth_url.find('v2.0') < 0: + auth_url = auth_url + '/v2.0' + self._v2_auth(auth_url) + + def _v1_auth(self, url): + if self.proxy_token: + raise exceptions.NoTokenLookupException() + + headers = {'X-Auth-User': self.user, + 'X-Auth-Key': self.password} + if self.projectid: + headers['X-Auth-Project-Id'] = self.projectid + + resp, body = self.request(url, 'GET', headers=headers) + if resp.status_code in (200, 204): # in some cases we get No Content + try: + mgmt_header = 'x-server-management-url' + self.management_url = resp.headers[mgmt_header].rstrip('/') + self.auth_token = resp.headers['x-auth-token'] + self.auth_url = url + except (KeyError, TypeError): + raise exceptions.AuthorizationFailure() + elif resp.status_code == 305: + return resp.headers['location'] + else: + raise exceptions.from_response(resp, body, url) + + def _v2_auth(self, url): + """Authenticate against a v2.0 auth service.""" + body = {"auth": { + "passwordCredentials": {"username": self.user, + "password": self.password}}} + + if self.projectid: + body['auth']['tenantName'] = self.projectid + elif self.tenant_id: + body['auth']['tenantId'] = self.tenant_id + + self._authenticate(url, body) + + def _rax_auth(self, url): + """Authenticate against the Rackspace auth service.""" + body = {"auth": { + "RAX-KSKEY:apiKeyCredentials": { + "username": self.user, + "apiKey": self.password, + "tenantName": self.projectid}}} + + self._authenticate(url, body) + + def _authenticate(self, url, body): + """Authenticate and extract the service catalog.""" + token_url = url + "/tokens" + + # Make sure we follow redirects when trying to reach Keystone + resp, body = self.request( + token_url, + "POST", + body=body, + allow_redirects=True) + + return self._extract_service_catalog(url, resp, body) + + def get_database_api_version_from_endpoint(self): + magic_tuple = urlparse.urlsplit(self.management_url) + scheme, netloc, path, query, frag = magic_tuple + v = path.split("/")[1] + valid_versions = ['v1.0'] + if v not in valid_versions: + msg = "Invalid client version '%s'. must be one of: %s" % ( + (v, ', '.join(valid_versions))) + raise exceptions.UnsupportedVersion(msg) + return v[1:] + + +def get_version_map(): + return { + '1.0': 'troveclient.v1.client.Client', + } + + +def Client(version, *args, **kwargs): + version_map = get_version_map() + client_class = client.BaseClient.get_class('database', + version, version_map) + return client_class(*args, **kwargs) diff --git a/troveclient/common.py b/troveclient/common.py index 345aa905..9d24da94 100644 --- a/troveclient/common.py +++ b/troveclient/common.py @@ -20,44 +20,17 @@ import pickle import sys from troveclient import client -from troveclient.xml import TroveXmlClient +#from troveclient.xml import TroveXmlClient from troveclient import exceptions from urllib import quote -def methods_of(obj): - """Get all callable methods of an object that don't start with underscore - returns a list of tuples of the form (method_name, method)""" - result = {} - for i in dir(obj): - if callable(getattr(obj, i)) and not i.startswith('_'): - result[i] = getattr(obj, i) - return result - - def check_for_exceptions(resp, body): - if resp.status in (400, 422, 500): + if resp.status_code in (400, 422, 500): raise exceptions.from_response(resp, body) -def print_actions(cmd, actions): - """Print help for the command with list of options and description""" - print ("Available actions for '%s' cmd:") % cmd - for k, v in actions.iteritems(): - print "\t%-20s%s" % (k, v.__doc__) - sys.exit(2) - - -def print_commands(commands): - """Print the list of available commands and description""" - - print "Available commands" - for k, v in commands.iteritems(): - print "\t%-20s%s" % (k, v.__doc__) - sys.exit(2) - - def limit_url(url, limit=None, marker=None): if not limit and not marker: return url @@ -79,325 +52,6 @@ def quote_user_host(user, host): return quoted.replace('.', '%2e') -class CliOptions(object): - """A token object containing the user, apikey and token which - is pickleable.""" - - APITOKEN = os.path.expanduser("~/.apitoken") - - DEFAULT_VALUES = { - 'username': None, - 'apikey': None, - 'tenant_id': None, - 'auth_url': None, - 'auth_type': 'keystone', - 'service_type': 'database', - 'service_name': '', - 'region': 'RegionOne', - 'service_url': None, - 'insecure': False, - 'verbose': False, - 'debug': False, - 'token': None, - 'xml': None, - } - - def __init__(self, **kwargs): - for key, value in self.DEFAULT_VALUES.items(): - setattr(self, key, value) - - @classmethod - def default(cls): - kwargs = copy.deepcopy(cls.DEFAULT_VALUES) - return cls(**kwargs) - - @classmethod - def load_from_file(cls): - try: - with open(cls.APITOKEN, 'rb') as token: - return pickle.load(token) - except IOError: - pass # File probably not found. - except: - print("ERROR: Token file found at %s was corrupt." % cls.APITOKEN) - return cls.default() - - @classmethod - def save_from_instance_fields(cls, instance): - apitoken = cls.default() - for key, default_value in cls.DEFAULT_VALUES.items(): - final_value = getattr(instance, key, default_value) - setattr(apitoken, key, final_value) - with open(cls.APITOKEN, 'wb') as token: - pickle.dump(apitoken, token, protocol=2) - - @classmethod - def create_optparser(cls, load_file): - oparser = optparse.OptionParser( - usage="%prog [options] ", - version='1.0', conflict_handler='resolve') - if load_file: - file = cls.load_from_file() - else: - file = cls.default() - - def add_option(*args, **kwargs): - if len(args) == 1: - name = args[0] - else: - name = args[1] - kwargs['default'] = getattr(file, name, cls.DEFAULT_VALUES[name]) - oparser.add_option("--%s" % name, **kwargs) - - add_option("verbose", action="store_true", - help="Show equivalent curl statement along " - "with actual HTTP communication.") - add_option("debug", action="store_true", - help="Show the stack trace on errors.") - add_option("auth_url", help="Auth API endpoint URL with port and " - "version. Default: http://localhost:5000/v2.0") - add_option("username", help="Login username") - add_option("apikey", help="Api key") - add_option("tenant_id", - help="Tenant Id associated with the account") - add_option("auth_type", - help="Auth type to support different auth environments, \ - Supported values are 'keystone', 'rax'.") - add_option("service_type", - help="Service type is a name associated for the catalog") - add_option("service_name", - help="Service name as provided in the service catalog") - add_option("service_url", - help="Service endpoint to use " - "if the catalog doesn't have one.") - add_option("region", help="Region the service is located in") - add_option("insecure", action="store_true", - help="Run in insecure mode for https endpoints.") - add_option("token", help="Token from a prior login.") - add_option("xml", action="store_true", help="Changes format to XML.") - - oparser.add_option("--secure", action="store_false", dest="insecure", - help="Run in insecure mode for https endpoints.") - oparser.add_option("--json", action="store_false", dest="xml", - help="Changes format to JSON.") - oparser.add_option("--terse", action="store_false", dest="verbose", - help="Toggles verbose mode off.") - oparser.add_option("--hide-debug", action="store_false", dest="debug", - help="Toggles debug mode off.") - return oparser - - -class ArgumentRequired(Exception): - def __init__(self, param): - self.param = param - - def __str__(self): - return 'Argument "--%s" required.' % self.param - - -class ArgumentsRequired(ArgumentRequired): - def __init__(self, *params): - self.params = params - - def __str__(self): - returnstring = 'Specify at least one of these arguments: ' - for param in self.params: - returnstring = returnstring + '"--%s" ' % param - return returnstring - - -class CommandsBase(object): - params = [] - - def __init__(self, parser): - self._parse_options(parser) - - def _get_client(self): - """Creates the all important client object.""" - try: - if self.xml: - client_cls = TroveXmlClient - else: - client_cls = client.TroveHTTPClient - if self.verbose: - client.log_to_streamhandler(sys.stdout) - client.RDC_PP = True - return client.Dbaas(self.username, self.apikey, self.tenant_id, - auth_url=self.auth_url, - auth_strategy=self.auth_type, - service_type=self.service_type, - service_name=self.service_name, - region_name=self.region, - service_url=self.service_url, - insecure=self.insecure, - client_cls=client_cls) - except: - if self.debug: - raise - print sys.exc_info()[1] - - def _safe_exec(self, func, *args, **kwargs): - if not self.debug: - try: - return func(*args, **kwargs) - except: - print(sys.exc_info()[1]) - return None - else: - return func(*args, **kwargs) - - @classmethod - def _prepare_parser(cls, parser): - for param in cls.params: - parser.add_option("--%s" % param) - - def _parse_options(self, parser): - opts, args = parser.parse_args() - for param in opts.__dict__: - value = getattr(opts, param) - setattr(self, param, value) - - def _require(self, *params): - for param in params: - if not hasattr(self, param): - raise ArgumentRequired(param) - if not getattr(self, param): - raise ArgumentRequired(param) - - def _require_at_least_one_of(self, *params): - # One or more of params is required to be present. - argument_present = False - for param in params: - if hasattr(self, param): - if getattr(self, param): - argument_present = True - if argument_present is False: - raise ArgumentsRequired(*params) - - def _make_list(self, *params): - # Convert the listed params to lists. - for param in params: - raw = getattr(self, param) - if isinstance(raw, list): - return - raw = [item.strip() for item in raw.split(',')] - setattr(self, param, raw) - - def _pretty_print(self, func, *args, **kwargs): - if self.verbose: - self._safe_exec(func, *args, **kwargs) - return # Skip this, since the verbose stuff will show up anyway. - - def wrapped_func(): - result = func(*args, **kwargs) - if result: - print(json.dumps(result._info, sort_keys=True, indent=4)) - else: - print("OK") - - self._safe_exec(wrapped_func) - - def _dumps(self, item): - return json.dumps(item, sort_keys=True, indent=4) - - def _pretty_list(self, func, *args, **kwargs): - result = self._safe_exec(func, *args, **kwargs) - if self.verbose: - return - if result and len(result) > 0: - for item in result: - print(self._dumps(item._info)) - else: - print("OK") - - def _pretty_paged(self, func, *args, **kwargs): - try: - limit = self.limit - if limit: - limit = int(limit, 10) - result = func(*args, limit=limit, marker=self.marker, **kwargs) - if self.verbose: - return # Verbose already shows the output, so skip this. - if result and len(result) > 0: - for item in result: - print self._dumps(item._info) - if result.links: - print("Links:") - for link in result.links: - print self._dumps((link)) - else: - print("OK") - except: - if self.debug: - raise - print sys.exc_info()[1] - - -class Auth(CommandsBase): - """Authenticate with your username and api key""" - params = [ - 'apikey', - 'auth_strategy', - 'auth_type', - 'auth_url', - 'options', - 'region', - 'service_name', - 'service_type', - 'service_url', - 'tenant_id', - 'username', - ] - - def __init__(self, parser): - super(Auth, self).__init__(parser) - self.dbaas = None - - def login(self): - """Login to retrieve an auth token to use for other api calls""" - self._require('username', 'apikey', 'tenant_id', 'auth_url') - try: - self.dbaas = self._get_client() - self.dbaas.authenticate() - self.token = self.dbaas.client.auth_token - self.service_url = self.dbaas.client.service_url - CliOptions.save_from_instance_fields(self) - print("Token aquired! Saving to %s..." % CliOptions.APITOKEN) - print(" service_url = %s" % self.service_url) - print(" token = %s" % self.token) - except: - if self.debug: - raise - print sys.exc_info()[1] - - -class AuthedCommandsBase(CommandsBase): - """Commands that work only with an authicated client.""" - - def __init__(self, parser): - """Makes sure a token is available somehow and logs in.""" - super(AuthedCommandsBase, self).__init__(parser) - try: - self._require('token') - except ArgumentRequired: - if self.debug: - raise - print('No token argument supplied. Use the "auth login" command ' - 'to log in and get a token.\n') - sys.exit(1) - try: - self._require('service_url') - except ArgumentRequired: - if self.debug: - raise - print('No service_url given.\n') - sys.exit(1) - self.dbaas = self._get_client() - # Actually set the token to avoid a re-auth. - self.dbaas.client.auth_token = self.token - self.dbaas.client.authenticate_with_token(self.token, self.service_url) - - class Paginated(object): """ Pretends to be a list if you iterate over it, but also keeps a next property you can use to get the next page of data. """ diff --git a/troveclient/compat/__init__.py b/troveclient/compat/__init__.py new file mode 100644 index 00000000..55d8ef82 --- /dev/null +++ b/troveclient/compat/__init__.py @@ -0,0 +1,32 @@ +# Copyright (c) 2011 OpenStack Foundation +# 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 troveclient.v1.accounts import Accounts # noqa +from troveclient.v1.databases import Databases # noqa +from troveclient.v1.flavors import Flavors # noqa +from troveclient.v1.instances import Instances # noqa +from troveclient.v1.hosts import Hosts # noqa +from troveclient.v1.management import Management # noqa +from troveclient.v1.management import RootHistory # noqa +from troveclient.v1.management import MgmtFlavors # noqa +from troveclient.v1.root import Root # noqa +from troveclient.v1.storage import StorageInfo # noqa +from troveclient.v1.users import Users # noqa +from troveclient.compat.versions import Versions # noqa +from troveclient.v1.diagnostics import DiagnosticsInterrogator # noqa +from troveclient.v1.diagnostics import HwInfoInterrogator # noqa +from troveclient.compat.client import Dbaas # noqa +from troveclient.compat.client import TroveHTTPClient # noqa diff --git a/troveclient/compat/auth.py b/troveclient/compat/auth.py new file mode 100644 index 00000000..89d7239a --- /dev/null +++ b/troveclient/compat/auth.py @@ -0,0 +1,252 @@ +# Copyright 2012 OpenStack Foundation +# +# 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 troveclient.compat import exceptions + + +def get_authenticator_cls(cls_or_name): + """Factory method to retrieve Authenticator class.""" + if isinstance(cls_or_name, type): + return cls_or_name + elif isinstance(cls_or_name, basestring): + if cls_or_name == "keystone": + return KeyStoneV2Authenticator + elif cls_or_name == "rax": + return RaxAuthenticator + elif cls_or_name == "auth1.1": + return Auth1_1 + elif cls_or_name == "fake": + return FakeAuth + + raise ValueError("Could not determine authenticator class from the given " + "value %r." % cls_or_name) + + +class Authenticator(object): + """ + Helper class to perform Keystone or other miscellaneous authentication. + + The "authenticate" method returns a ServiceCatalog, which can be used + to obtain a token. + + """ + + URL_REQUIRED = True + + 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, root_key='access'): + """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, + root_key=root_key) + 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): + raise NotImplementedError("Missing authenticate method.") + + +class KeyStoneV2Authenticator(Authenticator): + def authenticate(self): + if self.url is None: + raise exceptions.AuthUrlNotGiven() + return self._v2_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) + + +class Auth1_1(Authenticator): + def authenticate(self): + """Authenticate against a v2.0 auth service.""" + if self.url is None: + raise exceptions.AuthUrlNotGiven() + auth_url = self.url + body = { + "credentials": { + "username": self.username, + "key": self.password + }} + return self._authenticate(auth_url, body, root_key='auth') + + +class RaxAuthenticator(Authenticator): + def authenticate(self): + if self.url is None: + raise exceptions.AuthUrlNotGiven() + return self._rax_auth(self.url) + + 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 FakeAuth(Authenticator): + """Useful for faking auth.""" + + def authenticate(self): + class FakeCatalog(object): + def __init__(self, auth): + self.auth = auth + + def get_public_url(self): + return "%s/%s" % ('http://localhost:8779/v1.0', + self.auth.tenant) + + def get_token(self): + return self.auth.tenant + + return FakeCatalog(self) + + +class ServiceCatalog(object): + """Represents a Keystone Service Catalog which describes a service. + + This class has methods to obtain a valid token as well as a public service + url and a management url. + + """ + + def __init__(self, resource_dict, region=None, service_type=None, + service_name=None, service_url=None, root_key='access'): + 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.root_key = root_key + 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[self.root_key]['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 Trove 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 'serviceCatalog' not in self.catalog[self.root_key]: + raise exceptions.EndpointNotFound() + + # Full catalog ... + catalog = self.catalog[self.root_key]['serviceCatalog'] + + for service in catalog: + if service.get("type") != self.service_type: + continue + + if (self.service_name and self.service_type == 'database' 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/troveclient/compat/base.py b/troveclient/compat/base.py new file mode 100644 index 00000000..1ffdae60 --- /dev/null +++ b/troveclient/compat/base.py @@ -0,0 +1,294 @@ +# Copyright 2010 Jacob Kaplan-Moss + +# Copyright 2012 OpenStack Foundation +# 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 troveclient.compat import exceptions +from troveclient.compat 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): + """ + Abstracts the common pattern of allowing both an object or an object's ID + as a parameter when dealing with relationships. + """ + 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: + 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="~/.troveclient") + + # 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/troveclient/cli.py b/troveclient/compat/cli.py similarity index 99% rename from troveclient/cli.py rename to troveclient/compat/cli.py index f63091cd..8dd2fb55 100644 --- a/troveclient/cli.py +++ b/troveclient/compat/cli.py @@ -31,7 +31,7 @@ if os.path.exists(os.path.join(possible_topdir, 'troveclient', '__init__.py')): sys.path.insert(0, possible_topdir) -from troveclient import common +from troveclient.compat import common class InstanceCommands(common.AuthedCommandsBase): @@ -342,6 +342,7 @@ COMMANDS = { def main(): # Parse arguments + import pdb load_file = True for index, arg in enumerate(sys.argv): if (arg == "auth" and len(sys.argv) > (index + 1) diff --git a/troveclient/compat/client.py b/troveclient/compat/client.py new file mode 100644 index 00000000..3b637c2c --- /dev/null +++ b/troveclient/compat/client.py @@ -0,0 +1,373 @@ +# Copyright (c) 2011 OpenStack Foundation +# 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. + +import httplib2 +import logging +import os +import time +import urlparse +import sys + +try: + import json +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 troveclient.compat import auth +from troveclient.compat import exceptions + + +_logger = logging.getLogger(__name__) +RDC_PP = os.environ.get("RDC_PP", "False") == "True" + + +expected_errors = (400, 401, 403, 404, 408, 409, 413, 422, 500, 501) + + +def log_to_streamhandler(stream=None): + stream = stream or sys.stderr + ch = logging.StreamHandler(stream) + _logger.setLevel(logging.DEBUG) + _logger.addHandler(ch) + + +if 'REDDWARFCLIENT_DEBUG' in os.environ and os.environ['REDDWARFCLIENT_DEBUG']: + log_to_streamhandler() + + +class TroveHTTPClient(httplib2.Http): + + USER_AGENT = 'python-troveclient' + + def __init__(self, user, password, tenant, auth_url, service_name, + service_url=None, + 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(TroveHTTPClient, self).__init__(timeout=timeout) + + self.username = user + self.password = password + self.tenant = tenant + if auth_url: + self.auth_url = auth_url.rstrip('/') + else: + self.auth_url = None + 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 + + self.times = [] # [("item", starttime, endtime), ...] + + self.auth_token = None + self.proxy_token = proxy_token + self.proxy_tenant_id = proxy_tenant_id + + # httplib2 overrides + self.force_exception_to_status_code = True + self.disable_ssl_certificate_validation = insecure + + auth_cls = auth.get_authenticator_cls(auth_strategy) + + self.authenticator = auth_cls(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) + + def get_timings(self): + return self.times + + def http_log(self, args, kwargs, resp, body): + if not RDC_PP: + self.simple_log(args, kwargs, resp, body) + else: + self.pretty_log(args, kwargs, resp, body) + + def simple_log(self, args, kwargs, resp, body): + if not _logger.isEnabledFor(logging.DEBUG): + return + + 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) + + for element in kwargs['headers']: + header = ' -H "%s: %s"' % (element, kwargs['headers'][element]) + string_parts.append(header) + + _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 pretty_log(self, args, kwargs, resp, body): + if not _logger.isEnabledFor(logging.DEBUG): + return + + 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) + + for element in kwargs['headers']: + header = ' -H "%s: %s"' % (element, kwargs['headers'][element]) + string_parts.append(header) + + curl_cmd = "".join(string_parts) + _logger.debug("REQUEST:") + if 'body' in kwargs: + _logger.debug("%s -d '%s'" % (curl_cmd, kwargs['body'])) + try: + req_body = json.dumps(json.loads(kwargs['body']), + sort_keys=True, indent=4) + except: + req_body = kwargs['body'] + _logger.debug("BODY: %s\n" % (req_body)) + else: + _logger.debug(curl_cmd) + + try: + resp_body = json.dumps(json.loads(body), sort_keys=True, indent=4) + except: + resp_body = body + _logger.debug("RESPONSE HEADERS: %s" % resp) + _logger.debug("RESPONSE BODY : %s" % resp_body) + + def request(self, *args, **kwargs): + kwargs.setdefault('headers', kwargs.get('headers', {})) + kwargs['headers']['User-Agent'] = self.USER_AGENT + self.morph_request(kwargs) + + resp, body = super(TroveHTTPClient, self).request(*args, **kwargs) + # compat between requests and httplib2 + resp.status_code = resp.status + + # Save this in case anyone wants it. + self.last_response = (resp, body) + self.http_log(args, kwargs, resp, body) + + if body: + try: + body = self.morph_response_body(body) + except exceptions.ResponseFormatError: + # Acceptable only if the response status is an error code. + # Otherwise its the API or client misbehaving. + self.raise_error_from_status(resp, None) + raise # Not accepted! + else: + body = None + + if resp.status in expected_errors: + raise exceptions.from_response(resp, body) + + return resp, body + + def raise_error_from_status(self, resp, body): + if resp.status in expected_errors: + raise exceptions.from_response(resp, body) + + def morph_request(self, kwargs): + kwargs['headers']['Accept'] = 'application/json' + kwargs['headers']['Content-Type'] = 'application/json' + if 'body' in kwargs: + kwargs['body'] = json.dumps(kwargs['body']) + + def morph_response_body(self, body_string): + try: + return json.loads(body_string) + except ValueError: + raise exceptions.ResponseFormatError() + + 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 + + def _cs_request(self, url, method, **kwargs): + def request(): + 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 + + 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: + return request() + except exceptions.Unauthorized, ex: + self.authenticate() + return request() + + 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): + """Auths the client and gets a token. May optionally set a service url. + + The client will get auth errors until the authentication step + occurs. Additionally, if a service_url was not explicitly given in + the clients __init__ method, one will be obtained from the auth + service. + + """ + catalog = self.authenticator.authenticate() + if self.service_url: + possible_service_url = None + else: + if self.endpoint_type == "publicURL": + possible_service_url = catalog.get_public_url() + elif self.endpoint_type == "adminURL": + possible_service_url = catalog.get_management_url() + self.authenticate_with_token(catalog.get_token(), possible_service_url) + + def authenticate_with_token(self, token, service_url=None): + self.auth_token = token + if not self.service_url: + if not service_url: + raise exceptions.ServiceUrlNotGiven() + else: + self.service_url = service_url + + +class Dbaas(object): + """ + Top-level object to access the Rackspace Database as a Service API. + + Create an instance with your creds:: + + >>> red = Dbaas(USERNAME, API_KEY, TENANT, AUTH_URL, SERVICE_NAME, \ + SERVICE_URL) + + Then call methods on its managers:: + + >>> red.instances.list() + ... + >>> red.flavors.list() + ... + + &c. + """ + + def __init__(self, username, api_key, tenant=None, auth_url=None, + service_type='database', service_name=None, + service_url=None, insecure=False, auth_strategy='keystone', + region_name=None, client_cls=TroveHTTPClient): + from troveclient.compat.versions import Versions + from troveclient.v1.databases import Databases + from troveclient.v1.flavors import Flavors + from troveclient.v1.instances import Instances + from troveclient.v1.limits import Limits + from troveclient.v1.users import Users + from troveclient.v1.root import Root + from troveclient.v1.hosts import Hosts + from troveclient.v1.quota import Quotas + from troveclient.v1.backups import Backups + from troveclient.v1.security_groups import SecurityGroups + from troveclient.v1.security_groups import SecurityGroupRules + from troveclient.v1.storage import StorageInfo + from troveclient.v1.management import Management + from troveclient.v1.management import MgmtFlavors + from troveclient.v1.accounts import Accounts + from troveclient.v1.diagnostics import DiagnosticsInterrogator + from troveclient.v1.diagnostics import HwInfoInterrogator + + self.client = client_cls(username, api_key, tenant, auth_url, + service_type=service_type, + service_name=service_name, + service_url=service_url, + insecure=insecure, + auth_strategy=auth_strategy, + region_name=region_name) + self.versions = Versions(self) + self.databases = Databases(self) + self.flavors = Flavors(self) + self.instances = Instances(self) + self.limits = Limits(self) + self.users = Users(self) + self.root = Root(self) + self.hosts = Hosts(self) + self.quota = Quotas(self) + self.backups = Backups(self) + self.security_groups = SecurityGroups(self) + self.security_group_rules = SecurityGroupRules(self) + self.storage = StorageInfo(self) + self.management = Management(self) + self.mgmt_flavor = MgmtFlavors(self) + self.accounts = Accounts(self) + self.diagnostics = DiagnosticsInterrogator(self) + self.hwinfo = HwInfoInterrogator(self) + + class Mgmt(object): + def __init__(self, dbaas): + self.instances = dbaas.management + self.hosts = dbaas.hosts + self.accounts = dbaas.accounts + self.storage = dbaas.storage + + self.mgmt = Mgmt(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/troveclient/compat/common.py b/troveclient/compat/common.py new file mode 100644 index 00000000..85c3a632 --- /dev/null +++ b/troveclient/compat/common.py @@ -0,0 +1,429 @@ +# Copyright 2011 OpenStack Foundation +# +# 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 copy +import json +import optparse +import os +import pickle +import sys + +from troveclient.compat import client +from troveclient.compat.xml import TroveXmlClient +from troveclient.compat import exceptions + +from urllib import quote + + +def methods_of(obj): + """Get all callable methods of an object that don't start with underscore + returns a list of tuples of the form (method_name, method)""" + result = {} + for i in dir(obj): + if callable(getattr(obj, i)) and not i.startswith('_'): + result[i] = getattr(obj, i) + return result + + +def check_for_exceptions(resp, body): + if resp.status in (400, 422, 500): + raise exceptions.from_response(resp, body) + + +def print_actions(cmd, actions): + """Print help for the command with list of options and description""" + print ("Available actions for '%s' cmd:") % cmd + for k, v in actions.iteritems(): + print "\t%-20s%s" % (k, v.__doc__) + sys.exit(2) + + +def print_commands(commands): + """Print the list of available commands and description""" + + print "Available commands" + for k, v in commands.iteritems(): + print "\t%-20s%s" % (k, v.__doc__) + sys.exit(2) + + +def limit_url(url, limit=None, marker=None): + if not limit and not marker: + return url + query = [] + if marker: + query.append("marker=%s" % marker) + if limit: + query.append("limit=%s" % limit) + query = '?' + '&'.join(query) + return url + query + + +def quote_user_host(user, host): + quoted = '' + if host: + quoted = quote("%s@%s" % (user, host)) + else: + quoted = quote("%s" % user) + return quoted.replace('.', '%2e') + + +class CliOptions(object): + """A token object containing the user, apikey and token which + is pickleable.""" + + APITOKEN = os.path.expanduser("~/.apitoken") + + DEFAULT_VALUES = { + 'username': None, + 'apikey': None, + 'tenant_id': None, + 'auth_url': None, + 'auth_type': 'keystone', + 'service_type': 'database', + 'service_name': '', + 'region': 'RegionOne', + 'service_url': None, + 'insecure': False, + 'verbose': False, + 'debug': False, + 'token': None, + 'xml': None, + } + + def __init__(self, **kwargs): + for key, value in self.DEFAULT_VALUES.items(): + setattr(self, key, value) + + @classmethod + def default(cls): + kwargs = copy.deepcopy(cls.DEFAULT_VALUES) + return cls(**kwargs) + + @classmethod + def load_from_file(cls): + try: + with open(cls.APITOKEN, 'rb') as token: + return pickle.load(token) + except IOError: + pass # File probably not found. + except: + print("ERROR: Token file found at %s was corrupt." % cls.APITOKEN) + return cls.default() + + @classmethod + def save_from_instance_fields(cls, instance): + apitoken = cls.default() + for key, default_value in cls.DEFAULT_VALUES.items(): + final_value = getattr(instance, key, default_value) + setattr(apitoken, key, final_value) + with open(cls.APITOKEN, 'wb') as token: + pickle.dump(apitoken, token, protocol=2) + + @classmethod + def create_optparser(cls, load_file): + oparser = optparse.OptionParser( + usage="%prog [options] ", + version='1.0', conflict_handler='resolve') + if load_file: + file = cls.load_from_file() + else: + file = cls.default() + + def add_option(*args, **kwargs): + if len(args) == 1: + name = args[0] + else: + name = args[1] + kwargs['default'] = getattr(file, name, cls.DEFAULT_VALUES[name]) + oparser.add_option("--%s" % name, **kwargs) + + add_option("verbose", action="store_true", + help="Show equivalent curl statement along " + "with actual HTTP communication.") + add_option("debug", action="store_true", + help="Show the stack trace on errors.") + add_option("auth_url", help="Auth API endpoint URL with port and " + "version. Default: http://localhost:5000/v2.0") + add_option("username", help="Login username") + add_option("apikey", help="Api key") + add_option("tenant_id", + help="Tenant Id associated with the account") + add_option("auth_type", + help="Auth type to support different auth environments, \ + Supported values are 'keystone', 'rax'.") + add_option("service_type", + help="Service type is a name associated for the catalog") + add_option("service_name", + help="Service name as provided in the service catalog") + add_option("service_url", + help="Service endpoint to use " + "if the catalog doesn't have one.") + add_option("region", help="Region the service is located in") + add_option("insecure", action="store_true", + help="Run in insecure mode for https endpoints.") + add_option("token", help="Token from a prior login.") + add_option("xml", action="store_true", help="Changes format to XML.") + + oparser.add_option("--secure", action="store_false", dest="insecure", + help="Run in insecure mode for https endpoints.") + oparser.add_option("--json", action="store_false", dest="xml", + help="Changes format to JSON.") + oparser.add_option("--terse", action="store_false", dest="verbose", + help="Toggles verbose mode off.") + oparser.add_option("--hide-debug", action="store_false", dest="debug", + help="Toggles debug mode off.") + return oparser + + +class ArgumentRequired(Exception): + def __init__(self, param): + self.param = param + + def __str__(self): + return 'Argument "--%s" required.' % self.param + + +class ArgumentsRequired(ArgumentRequired): + def __init__(self, *params): + self.params = params + + def __str__(self): + returnstring = 'Specify at least one of these arguments: ' + for param in self.params: + returnstring = returnstring + '"--%s" ' % param + return returnstring + + +class CommandsBase(object): + params = [] + + def __init__(self, parser): + self._parse_options(parser) + + def _get_client(self): + """Creates the all important client object.""" + try: + if self.xml: + client_cls = TroveXmlClient + else: + client_cls = client.TroveHTTPClient + if self.verbose: + client.log_to_streamhandler(sys.stdout) + client.RDC_PP = True + return client.Dbaas(self.username, self.apikey, self.tenant_id, + auth_url=self.auth_url, + auth_strategy=self.auth_type, + service_type=self.service_type, + service_name=self.service_name, + region_name=self.region, + service_url=self.service_url, + insecure=self.insecure, + client_cls=client_cls) + except: + if self.debug: + raise + print sys.exc_info()[1] + + def _safe_exec(self, func, *args, **kwargs): + if not self.debug: + try: + return func(*args, **kwargs) + except: + print(sys.exc_info()[1]) + return None + else: + return func(*args, **kwargs) + + @classmethod + def _prepare_parser(cls, parser): + for param in cls.params: + parser.add_option("--%s" % param) + + def _parse_options(self, parser): + opts, args = parser.parse_args() + for param in opts.__dict__: + value = getattr(opts, param) + setattr(self, param, value) + + def _require(self, *params): + for param in params: + if not hasattr(self, param): + raise ArgumentRequired(param) + if not getattr(self, param): + raise ArgumentRequired(param) + + def _require_at_least_one_of(self, *params): + # One or more of params is required to be present. + argument_present = False + for param in params: + if hasattr(self, param): + if getattr(self, param): + argument_present = True + if argument_present is False: + raise ArgumentsRequired(*params) + + def _make_list(self, *params): + # Convert the listed params to lists. + for param in params: + raw = getattr(self, param) + if isinstance(raw, list): + return + raw = [item.strip() for item in raw.split(',')] + setattr(self, param, raw) + + def _pretty_print(self, func, *args, **kwargs): + if self.verbose: + self._safe_exec(func, *args, **kwargs) + return # Skip this, since the verbose stuff will show up anyway. + + def wrapped_func(): + result = func(*args, **kwargs) + if result: + print(json.dumps(result._info, sort_keys=True, indent=4)) + else: + print("OK") + + self._safe_exec(wrapped_func) + + def _dumps(self, item): + return json.dumps(item, sort_keys=True, indent=4) + + def _pretty_list(self, func, *args, **kwargs): + result = self._safe_exec(func, *args, **kwargs) + if self.verbose: + return + if result and len(result) > 0: + for item in result: + print(self._dumps(item._info)) + else: + print("OK") + + def _pretty_paged(self, func, *args, **kwargs): + try: + limit = self.limit + if limit: + limit = int(limit, 10) + result = func(*args, limit=limit, marker=self.marker, **kwargs) + if self.verbose: + return # Verbose already shows the output, so skip this. + if result and len(result) > 0: + for item in result: + print self._dumps(item._info) + if result.links: + print("Links:") + for link in result.links: + print self._dumps((link)) + else: + print("OK") + except: + if self.debug: + raise + print sys.exc_info()[1] + + +class Auth(CommandsBase): + """Authenticate with your username and api key""" + params = [ + 'apikey', + 'auth_strategy', + 'auth_type', + 'auth_url', + 'options', + 'region', + 'service_name', + 'service_type', + 'service_url', + 'tenant_id', + 'username', + ] + + def __init__(self, parser): + super(Auth, self).__init__(parser) + self.dbaas = None + + def login(self): + """Login to retrieve an auth token to use for other api calls""" + self._require('username', 'apikey', 'tenant_id', 'auth_url') + try: + self.dbaas = self._get_client() + self.dbaas.authenticate() + self.token = self.dbaas.client.auth_token + self.service_url = self.dbaas.client.service_url + CliOptions.save_from_instance_fields(self) + print("Token aquired! Saving to %s..." % CliOptions.APITOKEN) + print(" service_url = %s" % self.service_url) + print(" token = %s" % self.token) + except: + if self.debug: + raise + print sys.exc_info()[1] + + +class AuthedCommandsBase(CommandsBase): + """Commands that work only with an authicated client.""" + + def __init__(self, parser): + """Makes sure a token is available somehow and logs in.""" + super(AuthedCommandsBase, self).__init__(parser) + try: + self._require('token') + except ArgumentRequired: + if self.debug: + raise + print('No token argument supplied. Use the "auth login" command ' + 'to log in and get a token.\n') + sys.exit(1) + try: + self._require('service_url') + except ArgumentRequired: + if self.debug: + raise + print('No service_url given.\n') + sys.exit(1) + self.dbaas = self._get_client() + # Actually set the token to avoid a re-auth. + self.dbaas.client.auth_token = self.token + self.dbaas.client.authenticate_with_token(self.token, self.service_url) + + +class Paginated(object): + """ Pretends to be a list if you iterate over it, but also keeps a + next property you can use to get the next page of data. """ + + def __init__(self, items=[], next_marker=None, links=[]): + self.items = items + self.next = next_marker + self.links = links + + def __len__(self): + return len(self.items) + + def __iter__(self): + return self.items.__iter__() + + def __getitem__(self, key): + return self.items[key] + + def __setitem__(self, key, value): + self.items[key] = value + + def __delitem__(self, key): + del self.items[key] + + def __reversed__(self): + return reversed(self.items) + + def __contains__(self, needle): + return needle in self.items diff --git a/troveclient/compat/exceptions.py b/troveclient/compat/exceptions.py new file mode 100644 index 00000000..2ec3fb4f --- /dev/null +++ b/troveclient/compat/exceptions.py @@ -0,0 +1,179 @@ +# Copyright 2011 OpenStack Foundation +# +# 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. + + +class UnsupportedVersion(Exception): + """Indicates that the user is trying to use an unsupported + version of the API""" + pass + + +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 AuthUrlNotGiven(EndpointNotFound): + """The auth url was not given.""" + pass + + +class ServiceUrlNotGiven(EndpointNotFound): + """The service url was not given.""" + pass + + +class ResponseFormatError(Exception): + """Could not parse the response format.""" + 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. + """ + http_status = 422 + message = "Unprocessable Entity" + + +# 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): + """ + Return an instance of an ClientException or subclass + based on an httplib2 response. + + Usage:: + + resp, body = http.request(...) + if resp.status != 200: + raise exception_from_response(resp, body) + """ + cls = _code_map.get(response.status, ClientException) + if body: + message = "n/a" + details = "n/a" + if hasattr(body, 'keys'): + error = body[body.keys()[0]] + message = error.get('message', None) + details = error.get('details', None) + return cls(code=response.status, message=message, details=details) + else: + request_id = response.get('x-compute-request-id') + return cls(code=response.status, request_id=request_id) diff --git a/troveclient/mcli.py b/troveclient/compat/mcli.py similarity index 98% rename from troveclient/mcli.py rename to troveclient/compat/mcli.py index 54651157..baa096c6 100644 --- a/troveclient/mcli.py +++ b/troveclient/compat/mcli.py @@ -28,11 +28,11 @@ import sys possible_topdir = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]), os.pardir, os.pardir)) -if os.path.exists(os.path.join(possible_topdir, 'troveclient', +if os.path.exists(os.path.join(possible_topdir, 'troveclient.compat', '__init__.py')): sys.path.insert(0, possible_topdir) -from troveclient import common +from troveclient.compat import common oparser = None diff --git a/troveclient/compat/utils.py b/troveclient/compat/utils.py new file mode 100644 index 00000000..dd15fea6 --- /dev/null +++ b/troveclient/compat/utils.py @@ -0,0 +1,67 @@ +# Copyright 2012 OpenStack Foundation +# +# 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 + + +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/troveclient/versions.py b/troveclient/compat/versions.py similarity index 97% rename from troveclient/versions.py rename to troveclient/compat/versions.py index d7e02f55..5c8b7d2b 100644 --- a/troveclient/versions.py +++ b/troveclient/compat/versions.py @@ -13,7 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. -from troveclient import base +from troveclient.compat import base class Version(base.Resource): diff --git a/troveclient/compat/xml.py b/troveclient/compat/xml.py new file mode 100644 index 00000000..b4589934 --- /dev/null +++ b/troveclient/compat/xml.py @@ -0,0 +1,292 @@ +from lxml import etree +from numbers import Number + +from troveclient.compat import exceptions +from troveclient.compat.client import TroveHTTPClient + +XML_NS = {None: "http://docs.openstack.org/database/api/v1.0"} + +# If XML element is listed here then this searches through the ancestors. +LISTIFY = { + "accounts": [[]], + "databases": [[]], + "flavors": [[]], + "instances": [[]], + "links": [[]], + "hosts": [[]], + "devices": [[]], + "users": [[]], + "versions": [[]], + "attachments": [[]], + "limits": [[]], + "security_groups": [[]], + "backups": [[]] +} + + +class IntDict(object): + pass + + +TYPE_MAP = { + "instance": { + "volume": { + "used": float, + "size": int, + }, + "deleted": bool, + "server": { + "local_id": int, + "deleted": bool, + }, + }, + "instances": { + "deleted": bool, + }, + "deleted": bool, + "flavor": { + "ram": int, + }, + "diagnostics": { + "vmHwm": int, + "vmPeak": int, + "vmSize": int, + "threads": int, + "vmRss": int, + "fdSize": int, + }, + "security_group_rule": { + "from_port": int, + "to_port": int, + }, + "quotas": IntDict, +} +TYPE_MAP["flavors"] = TYPE_MAP["flavor"] + +REQUEST_AS_LIST = set(['databases', 'users']) + + +def element_ancestors_match_list(element, list): + """ + For element root at matches against + list ["blah", "foo"]. + """ + itr_elem = element.getparent() + for name in list: + if itr_elem is None: + break + if name != normalize_tag(itr_elem): + return False + itr_elem = itr_elem.getparent() + return True + + +def element_must_be_list(parent_element, name): + """Determines if an element to be created should be a dict or list.""" + if name in LISTIFY: + list_of_lists = LISTIFY[name] + for tag_list in list_of_lists: + if element_ancestors_match_list(parent_element, tag_list): + return True + return False + + +def element_to_json(name, element): + if element_must_be_list(element, name): + return element_to_list(element) + else: + return element_to_dict(element) + + +def root_element_to_json(name, element): + """Returns a tuple of the root JSON value, plus the links if found.""" + if name == "rootEnabled": # Why oh why were we inconsistent here? :'( + if element.text.strip() == "False": + return False, None + elif element.text.strip() == "True": + return True, None + if element_must_be_list(element, name): + return element_to_list(element, True) + else: + return element_to_dict(element), None + + +def element_to_list(element, check_for_links=False): + """ + For element "foo" in + Returns [{}, {}] + """ + links = None + result = [] + for child_element in element: + # The "links" element gets jammed into the root element. + if check_for_links and normalize_tag(child_element) == "links": + links = element_to_list(child_element) + else: + result.append(element_to_dict(child_element)) + if check_for_links: + return result, links + else: + return result + + +def element_to_dict(element): + result = {} + for name, value in element.items(): + result[name] = value + for child_element in element: + name = normalize_tag(child_element) + result[name] = element_to_json(name, child_element) + if len(result) == 0 and element.text: + string_value = element.text.strip() + if len(string_value): + if string_value == 'None': + return None + return string_value + return result + + +def standardize_json_lists(json_dict): + """ + In XML, we might see something like {'instances':{'instances':[...]}}, + which we must change to just {'instances':[...]} to be compatable with + the true JSON format. + + If any items are dictionaries with only one item which is a list, + simply remove the dictionary and insert its list directly. + """ + found_items = [] + for key, value in json_dict.items(): + value = json_dict[key] + if isinstance(value, dict): + if len(value) == 1 and isinstance(value.values()[0], list): + found_items.append(key) + else: + standardize_json_lists(value) + for key in found_items: + json_dict[key] = json_dict[key].values()[0] + + +def normalize_tag(elem): + """Given an element, returns the tag minus the XMLNS junk. + + IOW, .tag may sometimes return the XML namespace at the start of the + string. This gets rids of that. + """ + try: + prefix = "{" + elem.nsmap[None] + "}" + if elem.tag.startswith(prefix): + return elem.tag[len(prefix):] + except KeyError: + pass + return elem.tag + + +def create_root_xml_element(name, value): + """Create the first element using a name and a dictionary.""" + element = etree.Element(name, nsmap=XML_NS) + if name in REQUEST_AS_LIST: + add_subelements_from_list(element, name, value) + else: + populate_element_from_dict(element, value) + return element + + +def create_subelement(parent_element, name, value): + """Attaches a new element onto the parent element.""" + if isinstance(value, dict): + create_subelement_from_dict(parent_element, name, value) + elif isinstance(value, list): + create_subelement_from_list(parent_element, name, value) + else: + raise TypeError("Can't handle type %s." % type(value)) + + +def create_subelement_from_dict(parent_element, name, dict): + element = etree.SubElement(parent_element, name) + populate_element_from_dict(element, dict) + + +def create_subelement_from_list(parent_element, name, list): + element = etree.SubElement(parent_element, name) + add_subelements_from_list(element, name, list) + + +def add_subelements_from_list(element, name, list): + if name.endswith("s"): + item_name = name[:len(name) - 1] + else: + item_name = name + for item in list: + create_subelement(element, item_name, item) + + +def populate_element_from_dict(element, dict): + for key, value in dict.items(): + if isinstance(value, basestring): + element.set(key, value) + elif isinstance(value, Number): + element.set(key, str(value)) + elif isinstance(value, None.__class__): + element.set(key, '') + else: + create_subelement(element, key, value) + + +def modify_response_types(value, type_translator): + """ + This will convert some string in response dictionary to ints or bool + so that our respose is compatiable with code expecting JSON style responses + """ + if isinstance(value, str): + if value == 'True': + return True + elif value == 'False': + return False + else: + return type_translator(value) + elif isinstance(value, dict): + for k, v in value.iteritems(): + if type_translator is not IntDict: + if v.__class__ is dict and v.__len__() == 0: + value[k] = None + elif k in type_translator: + value[k] = modify_response_types(value[k], + type_translator[k]) + else: + value[k] = int(value[k]) + return value + elif isinstance(value, list): + return [modify_response_types(element, type_translator) + for element in value] + + +class TroveXmlClient(TroveHTTPClient): + + @classmethod + def morph_request(self, kwargs): + kwargs['headers']['Accept'] = 'application/xml' + kwargs['headers']['Content-Type'] = 'application/xml' + if 'body' in kwargs: + body = kwargs['body'] + root_name = body.keys()[0] + xml = create_root_xml_element(root_name, body[root_name]) + xml_string = etree.tostring(xml, pretty_print=True) + kwargs['body'] = xml_string + + @classmethod + def morph_response_body(self, body_string): + # The root XML element always becomes a dictionary with a single + # field, which has the same key as the elements name. + result = {} + try: + root_element = etree.XML(body_string) + except etree.XMLSyntaxError: + raise exceptions.ResponseFormatError() + root_name = normalize_tag(root_element) + root_value, links = root_element_to_json(root_name, root_element) + result = {root_name: root_value} + if links: + result['links'] = links + modify_response_types(result, TYPE_MAP) + return result diff --git a/troveclient/exceptions.py b/troveclient/exceptions.py index 2ec3fb4f..7808a327 100644 --- a/troveclient/exceptions.py +++ b/troveclient/exceptions.py @@ -157,7 +157,7 @@ _code_map = dict((c.http_status, c) for c in [BadRequest, Unauthorized, def from_response(response, body): """ Return an instance of an ClientException or subclass - based on an httplib2 response. + based on an request's response. Usage:: diff --git a/troveclient/openstack/__init__.py b/troveclient/openstack/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/troveclient/openstack/common/__init__.py b/troveclient/openstack/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/troveclient/openstack/common/apiclient/__init__.py b/troveclient/openstack/common/apiclient/__init__.py new file mode 100644 index 00000000..d5d00222 --- /dev/null +++ b/troveclient/openstack/common/apiclient/__init__.py @@ -0,0 +1,16 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 OpenStack Foundation +# 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. diff --git a/troveclient/openstack/common/apiclient/auth.py b/troveclient/openstack/common/apiclient/auth.py new file mode 100644 index 00000000..8aaeeea3 --- /dev/null +++ b/troveclient/openstack/common/apiclient/auth.py @@ -0,0 +1,227 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 OpenStack Foundation +# Copyright 2013 Spanish National Research Council. +# 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. + +# E0202: An attribute inherited from %s hide this method +# pylint: disable=E0202 + +import abc +import argparse +import logging +import os + +from stevedore import extension + +from troveclient.openstack.common.apiclient import exceptions + + +logger = logging.getLogger(__name__) + + +_discovered_plugins = {} + + +def discover_auth_systems(): + """Discover the available auth-systems. + + This won't take into account the old style auth-systems. + """ + global _discovered_plugins + _discovered_plugins = {} + + def add_plugin(ext): + _discovered_plugins[ext.name] = ext.plugin + + ep_namespace = "troveclient.openstack.common.apiclient.auth" + mgr = extension.ExtensionManager(ep_namespace) + mgr.map(add_plugin) + + +def load_auth_system_opts(parser): + """Load options needed by the available auth-systems into a parser. + + This function will try to populate the parser with options from the + available plugins. + """ + group = parser.add_argument_group("Common auth options") + BaseAuthPlugin.add_common_opts(group) + for name, auth_plugin in _discovered_plugins.iteritems(): + group = parser.add_argument_group( + "Auth-system '%s' options" % name, + conflict_handler="resolve") + auth_plugin.add_opts(group) + + +def load_plugin(auth_system): + try: + plugin_class = _discovered_plugins[auth_system] + except KeyError: + raise exceptions.AuthSystemNotFound(auth_system) + return plugin_class(auth_system=auth_system) + + +def load_plugin_from_args(args): + """Load requred plugin and populate it with options. + + Try to guess auth system if it is not specified. Systems are tried in + alphabetical order. + + :type args: argparse.Namespace + :raises: AuthorizationFailure + """ + auth_system = args.os_auth_system + if auth_system: + plugin = load_plugin(auth_system) + plugin.parse_opts(args) + plugin.sufficient_options() + return plugin + + for plugin_auth_system in sorted(_discovered_plugins.iterkeys()): + plugin_class = _discovered_plugins[plugin_auth_system] + plugin = plugin_class() + plugin.parse_opts(args) + try: + plugin.sufficient_options() + except exceptions.AuthPluginOptionsMissing: + continue + return plugin + raise exceptions.AuthPluginOptionsMissing(["auth_system"]) + + +class BaseAuthPlugin(object): + """Base class for authentication plugins. + + An authentication plugin needs to override at least the authenticate + method to be a valid plugin. + """ + + __metaclass__ = abc.ABCMeta + + auth_system = None + opt_names = [] + common_opt_names = [ + "auth_system", + "username", + "password", + "tenant_name", + "token", + "auth_url", + ] + + def __init__(self, auth_system=None, **kwargs): + self.auth_system = auth_system or self.auth_system + self.opts = dict((name, kwargs.get(name)) + for name in self.opt_names) + + @staticmethod + def _parser_add_opt(parser, opt): + """Add an option to parser in two variants. + + :param opt: option name (with underscores) + """ + dashed_opt = opt.replace("_", "-") + env_var = "OS_%s" % opt.upper() + arg_default = os.environ.get(env_var, "") + arg_help = "Defaults to env[%s]." % env_var + parser.add_argument( + "--os-%s" % dashed_opt, + metavar="<%s>" % dashed_opt, + default=arg_default, + help=arg_help) + parser.add_argument( + "--os_%s" % opt, + metavar="<%s>" % dashed_opt, + help=argparse.SUPPRESS) + + @classmethod + def add_opts(cls, parser): + """Populate the parser with the options for this plugin. + """ + for opt in cls.opt_names: + # use `BaseAuthPlugin.common_opt_names` since it is never + # changed in child classes + if opt not in BaseAuthPlugin.common_opt_names: + cls._parser_add_opt(parser, opt) + + @classmethod + def add_common_opts(cls, parser): + """Add options that are common for several plugins. + """ + for opt in cls.common_opt_names: + cls._parser_add_opt(parser, opt) + + @staticmethod + def get_opt(opt_name, args): + """Return option name and value. + + :param opt_name: name of the option, e.g., "username" + :param args: parsed arguments + """ + return (opt_name, getattr(args, "os_%s" % opt_name, None)) + + def parse_opts(self, args): + """Parse the actual auth-system options if any. + + This method is expected to populate the attribute `self.opts` with a + dict containing the options and values needed to make authentication. + """ + self.opts.update(dict(self.get_opt(opt_name, args) + for opt_name in self.opt_names)) + + def authenticate(self, http_client): + """Authenticate using plugin defined method. + + The method usually analyses `self.opts` and performs + a request to authentication server. + + :param http_client: client object that needs authentication + :type http_client: HTTPClient + :raises: AuthorizationFailure + """ + self.sufficient_options() + self._do_authenticate(http_client) + + @abc.abstractmethod + def _do_authenticate(self, http_client): + """Protected method for authentication. + """ + + def sufficient_options(self): + """Check if all required options are present. + + :raises: AuthPluginOptionsMissing + """ + missing = [opt + for opt in self.opt_names + if not self.opts.get(opt)] + if missing: + raise exceptions.AuthPluginOptionsMissing(missing) + + @abc.abstractmethod + def token_and_endpoint(self, endpoint_type, service_type): + """Return token and endpoint. + + :param service_type: Service type of the endpoint + :type service_type: string + :param endpoint_type: Type of endpoint. + Possible values: public or publicURL, + internal or internalURL, + admin or adminURL + :type endpoint_type: string + :returns: tuple of token and endpoint strings + :raises: EndpointException + """ diff --git a/troveclient/openstack/common/apiclient/base.py b/troveclient/openstack/common/apiclient/base.py new file mode 100644 index 00000000..56ce3b44 --- /dev/null +++ b/troveclient/openstack/common/apiclient/base.py @@ -0,0 +1,492 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 OpenStack Foundation +# Copyright 2012 Grid Dynamics +# Copyright 2013 OpenStack Foundation +# 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. +""" + +# E1102: %s is not callable +# pylint: disable=E1102 + +import abc +import urllib + +from troveclient.openstack.common.apiclient import exceptions +from troveclient.openstack.common import strutils + + +def getid(obj): + """Return id if argument is a Resource. + + Abstracts the common pattern of allowing both an object or an object's ID + (UUID) as a parameter when dealing with relationships. + """ + try: + if obj.uuid: + return obj.uuid + except AttributeError: + pass + try: + return obj.id + except AttributeError: + return obj + + +# TODO(aababilov): call run_hooks() in HookableMixin's child classes +class HookableMixin(object): + """Mixin so classes can register and run hooks.""" + _hooks_map = {} + + @classmethod + def add_hook(cls, hook_type, hook_func): + """Add a new hook of specified type. + + :param cls: class that registers hooks + :param hook_type: hook type, e.g., '__pre_parse_args__' + :param hook_func: hook function + """ + 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): + """Run all hooks of specified type. + + :param cls: class that registers hooks + :param hook_type: hook type, e.g., '__pre_parse_args__' + :param **args: args to be passed to every hook function + :param **kwargs: kwargs to be passed to every hook function + """ + hook_funcs = cls._hooks_map.get(hook_type) or [] + for hook_func in hook_funcs: + hook_func(*args, **kwargs) + + +class BaseManager(HookableMixin): + """Basic manager type providing common operations. + + Managers interact with a particular type of API (servers, flavors, images, + etc.) and provide CRUD operations for them. + """ + resource_class = None + + def __init__(self, client): + """Initializes BaseManager with `client`. + + :param client: instance of BaseClient descendant for HTTP requests + """ + super(BaseManager, self).__init__() + self.client = client + + def _list(self, url, response_key, obj_class=None, json=None): + """List the collection. + + :param url: a partial URL, e.g., '/servers' + :param response_key: the key to be looked up in response dictionary, + e.g., 'servers' + :param obj_class: class for constructing the returned objects + (self.resource_class will be used by default) + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + """ + if json: + body = self.client.post(url, json=json).json() + else: + body = self.client.get(url).json() + + 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... + try: + data = data['values'] + except (KeyError, TypeError): + pass + + return [obj_class(self, res, loaded=True) for res in data if res] + + def _get(self, url, response_key): + """Get an object from collection. + + :param url: a partial URL, e.g., '/servers' + :param response_key: the key to be looked up in response dictionary, + e.g., 'server' + """ + body = self.client.get(url).json() + return self.resource_class(self, body[response_key], loaded=True) + + def _head(self, url): + """Retrieve request headers for an object. + + :param url: a partial URL, e.g., '/servers' + """ + resp = self.client.head(url) + return resp.status_code == 204 + + def _post(self, url, json, response_key, return_raw=False): + """Create an object. + + :param url: a partial URL, e.g., '/servers' + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + :param response_key: the key to be looked up in response dictionary, + e.g., 'servers' + :param return_raw: flag to force returning raw JSON instead of + Python object of self.resource_class + """ + body = self.client.post(url, json=json).json() + if return_raw: + return body[response_key] + return self.resource_class(self, body[response_key]) + + def _put(self, url, json=None, response_key=None): + """Update an object with PUT method. + + :param url: a partial URL, e.g., '/servers' + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + :param response_key: the key to be looked up in response dictionary, + e.g., 'servers' + """ + resp = self.client.put(url, json=json) + # PUT requests may not return a body + if resp.content: + body = resp.json() + if response_key is not None: + return self.resource_class(self, body[response_key]) + else: + return self.resource_class(self, body) + + def _patch(self, url, json=None, response_key=None): + """Update an object with PATCH method. + + :param url: a partial URL, e.g., '/servers' + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + :param response_key: the key to be looked up in response dictionary, + e.g., 'servers' + """ + body = self.client.patch(url, json=json).json() + if response_key is not None: + return self.resource_class(self, body[response_key]) + else: + return self.resource_class(self, body) + + def _delete(self, url): + """Delete an object. + + :param url: a partial URL, e.g., '/servers/my-server' + """ + return self.client.delete(url) + + +class ManagerWithFind(BaseManager): + """Manager with additional `find()`/`findall()` methods.""" + + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def list(self): + pass + + 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(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 + + +class CrudManager(BaseManager): + """Base manager class for manipulating entities. + + Children of this class are expected to define a `collection_key` and `key`. + + - `collection_key`: Usually a plural noun by convention (e.g. `entities`); + used to refer collections in both URL's (e.g. `/v3/entities`) and JSON + objects containing a list of member resources (e.g. `{'entities': [{}, + {}, {}]}`). + - `key`: Usually a singular noun by convention (e.g. `entity`); used to + refer to an individual member of the collection. + + """ + collection_key = None + key = None + + def build_url(self, base_url=None, **kwargs): + """Builds a resource URL for the given kwargs. + + Given an example collection where `collection_key = 'entities'` and + `key = 'entity'`, the following URL's could be generated. + + By default, the URL will represent a collection of entities, e.g.:: + + /entities + + If kwargs contains an `entity_id`, then the URL will represent a + specific member, e.g.:: + + /entities/{entity_id} + + :param base_url: if provided, the generated URL will be appended to it + """ + url = base_url if base_url is not None else '' + + url += '/%s' % self.collection_key + + # do we have a specific entity? + entity_id = kwargs.get('%s_id' % self.key) + if entity_id is not None: + url += '/%s' % entity_id + + return url + + def _filter_kwargs(self, kwargs): + """Drop null values and handle ids.""" + for key, ref in kwargs.copy().iteritems(): + if ref is None: + kwargs.pop(key) + else: + if isinstance(ref, Resource): + kwargs.pop(key) + kwargs['%s_id' % key] = getid(ref) + return kwargs + + def create(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + return self._post( + self.build_url(**kwargs), + {self.key: kwargs}, + self.key) + + def get(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + return self._get( + self.build_url(**kwargs), + self.key) + + def head(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + return self._head(self.build_url(**kwargs)) + + def list(self, base_url=None, **kwargs): + """List the collection. + + :param base_url: if provided, the generated URL will be appended to it + """ + kwargs = self._filter_kwargs(kwargs) + + return self._list( + '%(base_url)s%(query)s' % { + 'base_url': self.build_url(base_url=base_url, **kwargs), + 'query': '?%s' % urllib.urlencode(kwargs) if kwargs else '', + }, + self.collection_key) + + def put(self, base_url=None, **kwargs): + """Update an element. + + :param base_url: if provided, the generated URL will be appended to it + """ + kwargs = self._filter_kwargs(kwargs) + + return self._put(self.build_url(base_url=base_url, **kwargs)) + + def update(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + params = kwargs.copy() + params.pop('%s_id' % self.key) + + return self._patch( + self.build_url(**kwargs), + {self.key: params}, + self.key) + + def delete(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + + return self._delete( + self.build_url(**kwargs)) + + def find(self, base_url=None, **kwargs): + """Find a single item with attributes matching ``**kwargs``. + + :param base_url: if provided, the generated URL will be appended to it + """ + kwargs = self._filter_kwargs(kwargs) + + rl = self._list( + '%(base_url)s%(query)s' % { + 'base_url': self.build_url(base_url=base_url, **kwargs), + 'query': '?%s' % urllib.urlencode(kwargs) if kwargs else '', + }, + self.collection_key) + num = len(rl) + + if num == 0: + msg = "No %s matching %s." % (self.resource_class.__name__, kwargs) + raise exceptions.NotFound(404, msg) + elif num > 1: + raise exceptions.NoUniqueMatch + else: + return rl[0] + + +class Extension(HookableMixin): + """Extension descriptor.""" + + SUPPORTED_HOOKS = ('__pre_parse_args__', '__post_parse_args__') + manager_class = None + + def __init__(self, name, module): + super(Extension, self).__init__() + self.name = name + self.module = module + self._parse_extension_module() + + def _parse_extension_module(self): + self.manager_class = None + for attr_name, attr_value in self.module.__dict__.items(): + if attr_name in self.SUPPORTED_HOOKS: + self.add_hook(attr_name, attr_value) + else: + try: + if issubclass(attr_value, BaseManager): + self.manager_class = attr_value + except TypeError: + pass + + def __repr__(self): + return "" % self.name + + +class Resource(object): + """Base class for OpenStack resources (tenant, user, etc.). + + This is pretty much just a bag for attributes. + """ + + HUMAN_ID = False + NAME_ATTR = 'name' + + def __init__(self, manager, info, loaded=False): + """Populate and bind to a manager. + + :param manager: BaseManager object + :param info: dictionary representing resource attributes + :param loaded: prevent lazy-loading if set to True + """ + self.manager = manager + self._info = info + self._add_details(info) + self._loaded = loaded + + 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) + + @property + def human_id(self): + """Human-readable ID which can be used for bash completion. + """ + if self.NAME_ATTR in self.__dict__ and self.HUMAN_ID: + return strutils.to_slug(getattr(self, self.NAME_ATTR)) + return None + + def _add_details(self, info): + for (k, v) in info.iteritems(): + try: + setattr(self, k, v) + self._info[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 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, Resource): + return NotImplemented + # two resources of different types are not equal + 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/troveclient/openstack/common/apiclient/client.py b/troveclient/openstack/common/apiclient/client.py new file mode 100644 index 00000000..6afe5751 --- /dev/null +++ b/troveclient/openstack/common/apiclient/client.py @@ -0,0 +1,360 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 OpenStack Foundation +# Copyright 2011 Piston Cloud Computing, Inc. +# Copyright 2013 Alessio Ababilov +# Copyright 2013 Grid Dynamics +# Copyright 2013 OpenStack Foundation +# 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. + +""" +OpenStack Client interface. Handles the REST calls and responses. +""" + +# E0202: An attribute inherited from %s hide this method +# pylint: disable=E0202 + +import logging +import time + +try: + import simplejson as json +except ImportError: + import json + +import requests + +from troveclient.openstack.common.apiclient import exceptions +from troveclient.openstack.common import importutils + + +_logger = logging.getLogger(__name__) + + +class HTTPClient(object): + """This client handles sending HTTP requests to OpenStack servers. + + Features: + - share authentication information between several clients to different + services (e.g., for compute and image clients); + - reissue authentication request for expired tokens; + - encode/decode JSON bodies; + - raise exeptions on HTTP errors; + - pluggable authentication; + - store authentication information in a keyring; + - store time spent for requests; + - register clients for particular services, so one can use + `http_client.identity` or `http_client.compute`; + - log requests and responses in a format that is easy to copy-and-paste + into terminal and send the same request with curl. + """ + + user_agent = "troveclient.openstack.common.apiclient" + + def __init__(self, + auth_plugin, + region_name=None, + endpoint_type="publicURL", + original_ip=None, + verify=True, + cert=None, + timeout=None, + timings=False, + keyring_saver=None, + debug=False, + user_agent=None, + http=None): + self.auth_plugin = auth_plugin + + self.endpoint_type = endpoint_type + self.region_name = region_name + + self.original_ip = original_ip + self.timeout = timeout + self.verify = verify + self.cert = cert + + self.keyring_saver = keyring_saver + self.debug = debug + self.user_agent = user_agent or self.user_agent + + self.times = [] # [("item", starttime, endtime), ...] + self.timings = timings + + # requests within the same session can reuse TCP connections from pool + self.http = http or requests.Session() + + self.cached_token = None + + def _http_log_req(self, method, url, kwargs): + if not self.debug: + return + + string_parts = [ + "curl -i", + "-X '%s'" % method, + "'%s'" % url, + ] + + for element in kwargs['headers']: + header = "-H '%s: %s'" % (element, kwargs['headers'][element]) + string_parts.append(header) + + _logger.debug("REQ: %s" % " ".join(string_parts)) + if 'data' in kwargs: + _logger.debug("REQ BODY: %s\n" % (kwargs['data'])) + + def _http_log_resp(self, resp): + if not self.debug: + return + _logger.debug( + "RESP: [%s] %s\n", + resp.status_code, + resp.headers) + if resp._content_consumed: + _logger.debug( + "RESP BODY: %s\n", + resp.text) + + def serialize(self, kwargs): + if kwargs.get('json') is not None: + kwargs['headers']['Content-Type'] = 'application/json' + kwargs['data'] = json.dumps(kwargs['json']) + try: + del kwargs['json'] + except KeyError: + pass + + def get_timings(self): + return self.times + + def reset_timings(self): + self.times = [] + + def request(self, method, url, **kwargs): + """Send an http request with the specified characteristics. + + Wrapper around `requests.Session.request` to handle tasks such as + setting headers, JSON encoding/decoding, and error handling. + + :param method: method of HTTP request + :param url: URL of HTTP request + :param kwargs: any other parameter that can be passed to +' requests.Session.request (such as `headers`) or `json` + that will be encoded as JSON and used as `data` argument + """ + kwargs.setdefault("headers", kwargs.get("headers", {})) + kwargs["headers"]["User-Agent"] = self.user_agent + if self.original_ip: + kwargs["headers"]["Forwarded"] = "for=%s;by=%s" % ( + self.original_ip, self.user_agent) + if self.timeout is not None: + kwargs.setdefault("timeout", self.timeout) + kwargs.setdefault("verify", self.verify) + if self.cert is not None: + kwargs.setdefault("cert", self.cert) + self.serialize(kwargs) + + self._http_log_req(method, url, kwargs) + if self.timings: + start_time = time.time() + resp = self.http.request(method, url, **kwargs) + if self.timings: + self.times.append(("%s %s" % (method, url), + start_time, time.time())) + self._http_log_resp(resp) + + if resp.status_code >= 400: + _logger.debug( + "Request returned failure status: %s", + resp.status_code) + raise exceptions.from_response(resp, method, url) + + return resp + + @staticmethod + def concat_url(endpoint, url): + """Concatenate endpoint and final URL. + + E.g., "http://keystone/v2.0/" and "/tokens" are concatenated to + "http://keystone/v2.0/tokens". + + :param endpoint: the base URL + :param url: the final URL + """ + return "%s/%s" % (endpoint.rstrip("/"), url.strip("/")) + + def client_request(self, client, method, url, **kwargs): + """Send an http request using `client`'s endpoint and specified `url`. + + If request was rejected as unauthorized (possibly because the token is + expired), issue one authorization attempt and send the request once + again. + + :param client: instance of BaseClient descendant + :param method: method of HTTP request + :param url: URL of HTTP request + :param kwargs: any other parameter that can be passed to +' `HTTPClient.request` + """ + + filter_args = { + "endpoint_type": client.endpoint_type or self.endpoint_type, + "service_type": client.service_type, + } + token, endpoint = (self.cached_token, client.cached_endpoint) + just_authenticated = False + if not (token and endpoint): + try: + token, endpoint = self.auth_plugin.token_and_endpoint( + **filter_args) + except exceptions.EndpointException: + pass + if not (token and endpoint): + self.authenticate() + just_authenticated = True + token, endpoint = self.auth_plugin.token_and_endpoint( + **filter_args) + if not (token and endpoint): + raise exceptions.AuthorizationFailure( + "Cannot find endpoint or token for request") + + old_token_endpoint = (token, endpoint) + kwargs.setdefault("headers", {})["X-Auth-Token"] = token + self.cached_token = token + client.cached_endpoint = endpoint + # Perform the request once. If we get Unauthorized, then it + # might be because the auth token expired, so try to + # re-authenticate and try again. If it still fails, bail. + try: + return self.request( + method, self.concat_url(endpoint, url), **kwargs) + except exceptions.Unauthorized as unauth_ex: + if just_authenticated: + raise + self.cached_token = None + client.cached_endpoint = None + self.authenticate() + try: + token, endpoint = self.auth_plugin.token_and_endpoint( + **filter_args) + except exceptions.EndpointException: + raise unauth_ex + if (not (token and endpoint) or + old_token_endpoint == (token, endpoint)): + raise unauth_ex + self.cached_token = token + client.cached_endpoint = endpoint + kwargs["headers"]["X-Auth-Token"] = token + return self.request( + method, self.concat_url(endpoint, url), **kwargs) + + def add_client(self, base_client_instance): + """Add a new instance of :class:`BaseClient` descendant. + + `self` will store a reference to `base_client_instance`. + + Example: + + >>> def test_clients(): + ... from keystoneclient.auth import keystone + ... from openstack.common.apiclient import client + ... auth = keystone.KeystoneAuthPlugin( + ... username="user", password="pass", tenant_name="tenant", + ... auth_url="http://auth:5000/v2.0") + ... openstack_client = client.HTTPClient(auth) + ... # create nova client + ... from novaclient.v1_1 import client + ... client.Client(openstack_client) + ... # create keystone client + ... from keystoneclient.v2_0 import client + ... client.Client(openstack_client) + ... # use them + ... openstack_client.identity.tenants.list() + ... openstack_client.compute.servers.list() + """ + service_type = base_client_instance.service_type + if service_type and not hasattr(self, service_type): + setattr(self, service_type, base_client_instance) + + def authenticate(self): + self.auth_plugin.authenticate(self) + # Store the authentication results in the keyring for later requests + if self.keyring_saver: + self.keyring_saver.save(self) + + +class BaseClient(object): + """Top-level object to access the OpenStack API. + + This client uses :class:`HTTPClient` to send requests. :class:`HTTPClient` + will handle a bunch of issues such as authentication. + """ + + service_type = None + endpoint_type = None # "publicURL" will be used + cached_endpoint = None + + def __init__(self, http_client, extensions=None): + self.http_client = http_client + http_client.add_client(self) + + # Add in any extensions... + if extensions: + for extension in extensions: + if extension.manager_class: + setattr(self, extension.name, + extension.manager_class(self)) + + def client_request(self, method, url, **kwargs): + return self.http_client.client_request( + self, method, url, **kwargs) + + def head(self, url, **kwargs): + return self.client_request("HEAD", url, **kwargs) + + def get(self, url, **kwargs): + return self.client_request("GET", url, **kwargs) + + def post(self, url, **kwargs): + return self.client_request("POST", url, **kwargs) + + def put(self, url, **kwargs): + return self.client_request("PUT", url, **kwargs) + + def delete(self, url, **kwargs): + return self.client_request("DELETE", url, **kwargs) + + def patch(self, url, **kwargs): + return self.client_request("PATCH", url, **kwargs) + + @staticmethod + def get_class(api_name, version, version_map): + """Returns the client class for the requested API version + + :param api_name: the name of the API, e.g. 'compute', 'image', etc + :param version: the requested API version + :param version_map: a dict of client classes keyed by version + :rtype: a client class for the requested API version + """ + try: + client_path = version_map[str(version)] + except (KeyError, ValueError): + msg = "Invalid %s client version '%s'. must be one of: %s" % ( + (api_name, version, ', '.join(version_map.keys()))) + raise exceptions.UnsupportedVersion(msg) + + return importutils.import_class(client_path) diff --git a/troveclient/openstack/common/apiclient/exceptions.py b/troveclient/openstack/common/apiclient/exceptions.py new file mode 100644 index 00000000..b03def77 --- /dev/null +++ b/troveclient/openstack/common/apiclient/exceptions.py @@ -0,0 +1,446 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 Nebula, Inc. +# Copyright 2013 Alessio Ababilov +# Copyright 2013 OpenStack Foundation +# 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. + +""" +Exception definitions. +""" + +import sys + + +class ClientException(Exception): + """The base exception class for all exceptions this library raises. + """ + pass + + +class MissingArgs(ClientException): + """Supplied arguments are not sufficient for calling a function.""" + def __init__(self, missing): + self.missing = missing + msg = "Missing argument(s): %s" % ", ".join(missing) + super(MissingArgs, self).__init__(msg) + + +class ValidationError(ClientException): + """Error in validation on API client side.""" + pass + + +class UnsupportedVersion(ClientException): + """User is trying to use an unsupported version of the API.""" + pass + + +class CommandError(ClientException): + """Error in CLI tool.""" + pass + + +class AuthorizationFailure(ClientException): + """Cannot authorize API client.""" + pass + + +class AuthPluginOptionsMissing(AuthorizationFailure): + """Auth plugin misses some options.""" + def __init__(self, opt_names): + super(AuthPluginOptionsMissing, self).__init__( + "Authentication failed. Missing options: %s" % + ", ".join(opt_names)) + self.opt_names = opt_names + + +class AuthSystemNotFound(AuthorizationFailure): + """User has specified a AuthSystem that is not installed.""" + def __init__(self, auth_system): + super(AuthSystemNotFound, self).__init__( + "AuthSystemNotFound: %s" % repr(auth_system)) + self.auth_system = auth_system + + +class NoUniqueMatch(ClientException): + """Multiple entities found instead of one.""" + pass + + +class EndpointException(ClientException): + """Something is rotten in Service Catalog.""" + pass + + +class EndpointNotFound(EndpointException): + """Could not find requested endpoint in Service Catalog.""" + pass + + +class AmbiguousEndpoints(EndpointException): + """Found more than one matching endpoint in Service Catalog.""" + def __init__(self, endpoints=None): + super(AmbiguousEndpoints, self).__init__( + "AmbiguousEndpoints: %s" % repr(endpoints)) + self.endpoints = endpoints + + +class HttpError(ClientException): + """The base exception class for all HTTP exceptions. + """ + http_status = 0 + message = "HTTP Error" + + def __init__(self, message=None, details=None, + response=None, request_id=None, + url=None, method=None, http_status=None): + self.http_status = http_status or self.http_status + self.message = message or self.message + self.details = details + self.request_id = request_id + self.response = response + self.url = url + self.method = method + formatted_string = "%s (HTTP %s)" % (self.message, self.http_status) + if request_id: + formatted_string += " (Request-ID: %s)" % request_id + super(HttpError, self).__init__(formatted_string) + + +class HTTPClientError(HttpError): + """Client-side HTTP error. + + Exception for cases in which the client seems to have erred. + """ + message = "HTTP Client Error" + + +class HttpServerError(HttpError): + """Server-side HTTP error. + + Exception for cases in which the server is aware that it has + erred or is incapable of performing the request. + """ + message = "HTTP Server Error" + + +class BadRequest(HTTPClientError): + """HTTP 400 - Bad Request. + + The request cannot be fulfilled due to bad syntax. + """ + http_status = 400 + message = "Bad Request" + + +class Unauthorized(HTTPClientError): + """HTTP 401 - Unauthorized. + + Similar to 403 Forbidden, but specifically for use when authentication + is required and has failed or has not yet been provided. + """ + http_status = 401 + message = "Unauthorized" + + +class PaymentRequired(HTTPClientError): + """HTTP 402 - Payment Required. + + Reserved for future use. + """ + http_status = 402 + message = "Payment Required" + + +class Forbidden(HTTPClientError): + """HTTP 403 - Forbidden. + + The request was a valid request, but the server is refusing to respond + to it. + """ + http_status = 403 + message = "Forbidden" + + +class NotFound(HTTPClientError): + """HTTP 404 - Not Found. + + The requested resource could not be found but may be available again + in the future. + """ + http_status = 404 + message = "Not Found" + + +class MethodNotAllowed(HTTPClientError): + """HTTP 405 - Method Not Allowed. + + A request was made of a resource using a request method not supported + by that resource. + """ + http_status = 405 + message = "Method Not Allowed" + + +class NotAcceptable(HTTPClientError): + """HTTP 406 - Not Acceptable. + + The requested resource is only capable of generating content not + acceptable according to the Accept headers sent in the request. + """ + http_status = 406 + message = "Not Acceptable" + + +class ProxyAuthenticationRequired(HTTPClientError): + """HTTP 407 - Proxy Authentication Required. + + The client must first authenticate itself with the proxy. + """ + http_status = 407 + message = "Proxy Authentication Required" + + +class RequestTimeout(HTTPClientError): + """HTTP 408 - Request Timeout. + + The server timed out waiting for the request. + """ + http_status = 408 + message = "Request Timeout" + + +class Conflict(HTTPClientError): + """HTTP 409 - Conflict. + + Indicates that the request could not be processed because of conflict + in the request, such as an edit conflict. + """ + http_status = 409 + message = "Conflict" + + +class Gone(HTTPClientError): + """HTTP 410 - Gone. + + Indicates that the resource requested is no longer available and will + not be available again. + """ + http_status = 410 + message = "Gone" + + +class LengthRequired(HTTPClientError): + """HTTP 411 - Length Required. + + The request did not specify the length of its content, which is + required by the requested resource. + """ + http_status = 411 + message = "Length Required" + + +class PreconditionFailed(HTTPClientError): + """HTTP 412 - Precondition Failed. + + The server does not meet one of the preconditions that the requester + put on the request. + """ + http_status = 412 + message = "Precondition Failed" + + +class RequestEntityTooLarge(HTTPClientError): + """HTTP 413 - Request Entity Too Large. + + The request is larger than the server is willing or able to process. + """ + http_status = 413 + message = "Request Entity Too Large" + + def __init__(self, *args, **kwargs): + try: + self.retry_after = int(kwargs.pop('retry_after')) + except (KeyError, ValueError): + self.retry_after = 0 + + super(RequestEntityTooLarge, self).__init__(*args, **kwargs) + + +class RequestUriTooLong(HTTPClientError): + """HTTP 414 - Request-URI Too Long. + + The URI provided was too long for the server to process. + """ + http_status = 414 + message = "Request-URI Too Long" + + +class UnsupportedMediaType(HTTPClientError): + """HTTP 415 - Unsupported Media Type. + + The request entity has a media type which the server or resource does + not support. + """ + http_status = 415 + message = "Unsupported Media Type" + + +class RequestedRangeNotSatisfiable(HTTPClientError): + """HTTP 416 - Requested Range Not Satisfiable. + + The client has asked for a portion of the file, but the server cannot + supply that portion. + """ + http_status = 416 + message = "Requested Range Not Satisfiable" + + +class ExpectationFailed(HTTPClientError): + """HTTP 417 - Expectation Failed. + + The server cannot meet the requirements of the Expect request-header field. + """ + http_status = 417 + message = "Expectation Failed" + + +class UnprocessableEntity(HTTPClientError): + """HTTP 422 - Unprocessable Entity. + + The request was well-formed but was unable to be followed due to semantic + errors. + """ + http_status = 422 + message = "Unprocessable Entity" + + +class InternalServerError(HttpServerError): + """HTTP 500 - Internal Server Error. + + A generic error message, given when no more specific message is suitable. + """ + http_status = 500 + message = "Internal Server Error" + + +# NotImplemented is a python keyword. +class HttpNotImplemented(HttpServerError): + """HTTP 501 - Not Implemented. + + The server either does not recognize the request method, or it lacks + the ability to fulfill the request. + """ + http_status = 501 + message = "Not Implemented" + + +class BadGateway(HttpServerError): + """HTTP 502 - Bad Gateway. + + The server was acting as a gateway or proxy and received an invalid + response from the upstream server. + """ + http_status = 502 + message = "Bad Gateway" + + +class ServiceUnavailable(HttpServerError): + """HTTP 503 - Service Unavailable. + + The server is currently unavailable. + """ + http_status = 503 + message = "Service Unavailable" + + +class GatewayTimeout(HttpServerError): + """HTTP 504 - Gateway Timeout. + + The server was acting as a gateway or proxy and did not receive a timely + response from the upstream server. + """ + http_status = 504 + message = "Gateway Timeout" + + +class HttpVersionNotSupported(HttpServerError): + """HTTP 505 - HttpVersion Not Supported. + + The server does not support the HTTP protocol version used in the request. + """ + http_status = 505 + message = "HTTP Version Not Supported" + + +# 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 HttpError.__subclasses__()) +_code_map = {} +for obj in sys.modules[__name__].__dict__.values(): + if isinstance(obj, type): + try: + http_status = obj.http_status + except AttributeError: + pass + else: + if http_status: + _code_map[http_status] = obj + + +def from_response(response, method, url): + """Returns an instance of :class:`HttpError` or subclass based on response. + + :param response: instance of `requests.Response` class + :param method: HTTP method used for request + :param url: URL used for request + """ + kwargs = { + "http_status": response.status_code, + "response": response, + "method": method, + "url": url, + "request_id": response.headers.get("x-compute-request-id"), + } + if "retry-after" in response.headers: + kwargs["retry_after"] = response.headers["retry-after"] + + content_type = response.headers.get("Content-Type", "") + if content_type.startswith("application/json"): + try: + body = response.json() + except ValueError: + pass + else: + if hasattr(body, "keys"): + error = body[body.keys()[0]] + kwargs["message"] = error.get("message", None) + kwargs["details"] = error.get("details", None) + elif content_type.startswith("text/"): + kwargs["details"] = response.text + + try: + cls = _code_map[response.status_code] + except KeyError: + if 500 <= response.status_code < 600: + cls = HttpServerError + elif 400 <= response.status_code < 500: + cls = HTTPClientError + else: + cls = HttpError + return cls(**kwargs) diff --git a/troveclient/openstack/common/apiclient/fake_client.py b/troveclient/openstack/common/apiclient/fake_client.py new file mode 100644 index 00000000..b423fedf --- /dev/null +++ b/troveclient/openstack/common/apiclient/fake_client.py @@ -0,0 +1,172 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 OpenStack Foundation +# 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. + +""" +A fake server that "responds" to API methods with pre-canned responses. + +All of these responses come from the spec, so if for some reason the spec's +wrong the tests might raise AssertionError. I've indicated in comments the +places where actual behavior differs from the spec. +""" + +# W0102: Dangerous default value %s as argument +# pylint: disable=W0102 + +import json +import urlparse + +import requests + +from troveclient.openstack.common.apiclient import client + + +def assert_has_keys(dct, required=[], optional=[]): + for k in required: + try: + assert k in dct + except AssertionError: + extra_keys = set(dct.keys()).difference(set(required + optional)) + raise AssertionError("found unexpected keys: %s" % + list(extra_keys)) + + +class TestResponse(requests.Response): + """Wrap requests.Response and provide a convenient initialization. + """ + + def __init__(self, data): + super(TestResponse, self).__init__() + self._content_consumed = True + if isinstance(data, dict): + self.status_code = data.get('status_code', 200) + # Fake the text attribute to streamline Response creation + text = data.get('text', "") + if isinstance(text, (dict, list)): + self._content = json.dumps(text) + default_headers = { + "Content-Type": "application/json", + } + else: + self._content = text + default_headers = {} + self.headers = data.get('headers') or default_headers + else: + self.status_code = data + + def __eq__(self, other): + return (self.status_code == other.status_code and + self.headers == other.headers and + self._content == other._content) + + +class FakeHTTPClient(client.HTTPClient): + + def __init__(self, *args, **kwargs): + self.callstack = [] + self.fixtures = kwargs.pop("fixtures", None) or {} + if not args and not "auth_plugin" in kwargs: + args = (None, ) + super(FakeHTTPClient, self).__init__(*args, **kwargs) + + def assert_called(self, method, url, body=None, pos=-1): + """Assert than an API method was just called. + """ + expected = (method, url) + called = self.callstack[pos][0:2] + assert self.callstack, \ + "Expected %s %s but no calls were made." % expected + + assert expected == called, 'Expected %s %s; got %s %s' % \ + (expected + called) + + if body is not None: + if self.callstack[pos][3] != body: + raise AssertionError('%r != %r' % + (self.callstack[pos][3], body)) + + def assert_called_anytime(self, method, url, body=None): + """Assert than an API method was called anytime in the test. + """ + expected = (method, url) + + assert self.callstack, \ + "Expected %s %s but no calls were made." % expected + + found = False + entry = None + for entry in self.callstack: + if expected == entry[0:2]: + found = True + break + + assert found, 'Expected %s %s; got %s' % \ + (method, url, self.callstack) + if body is not None: + assert entry[3] == body, "%s != %s" % (entry[3], body) + + self.callstack = [] + + def clear_callstack(self): + self.callstack = [] + + def authenticate(self): + pass + + def client_request(self, client, method, url, **kwargs): + # Check that certain things are called correctly + if method in ["GET", "DELETE"]: + assert "json" not in kwargs + + # Note the call + self.callstack.append( + (method, + url, + kwargs.get("headers") or {}, + kwargs.get("json") or kwargs.get("data"))) + try: + fixture = self.fixtures[url][method] + except KeyError: + pass + else: + return TestResponse({"headers": fixture[0], + "text": fixture[1]}) + + # Call the method + args = urlparse.parse_qsl(urlparse.urlparse(url)[4]) + kwargs.update(args) + munged_url = url.rsplit('?', 1)[0] + munged_url = munged_url.strip('/').replace('/', '_').replace('.', '_') + munged_url = munged_url.replace('-', '_') + + callback = "%s_%s" % (method.lower(), munged_url) + + if not hasattr(self, callback): + raise AssertionError('Called unknown API method: %s %s, ' + 'expected fakes method name: %s' % + (method, url, callback)) + + resp = getattr(self, callback)(**kwargs) + if len(resp) == 3: + status, headers, body = resp + else: + status, body = resp + headers = {} + return TestResponse({ + "status_code": status, + "text": body, + "headers": headers, + }) diff --git a/troveclient/openstack/common/gettextutils.py b/troveclient/openstack/common/gettextutils.py new file mode 100644 index 00000000..30eaa01e --- /dev/null +++ b/troveclient/openstack/common/gettextutils.py @@ -0,0 +1,364 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 Red Hat, Inc. +# Copyright 2013 IBM Corp. +# 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. + +""" +gettext for openstack-common modules. + +Usual usage in an openstack.common module: + + from troveclient.openstack.common.gettextutils import _ +""" + +import copy +import gettext +import logging +import os +import re +try: + import UserString as _userString +except ImportError: + import collections as _userString + +from babel import localedata +import six + +_localedir = os.environ.get('troveclient'.upper() + '_LOCALEDIR') +_t = gettext.translation('troveclient', localedir=_localedir, fallback=True) + +_AVAILABLE_LANGUAGES = {} +USE_LAZY = False + + +def enable_lazy(): + """Convenience function for configuring _() to use lazy gettext + + Call this at the start of execution to enable the gettextutils._ + function to use lazy gettext functionality. This is useful if + your project is importing _ directly instead of using the + gettextutils.install() way of importing the _ function. + """ + global USE_LAZY + USE_LAZY = True + + +def _(msg): + if USE_LAZY: + return Message(msg, 'troveclient') + else: + if six.PY3: + return _t.gettext(msg) + return _t.ugettext(msg) + + +def install(domain, lazy=False): + """Install a _() function using the given translation domain. + + Given a translation domain, install a _() function using gettext's + install() function. + + The main difference from gettext.install() is that we allow + overriding the default localedir (e.g. /usr/share/locale) using + a translation-domain-specific environment variable (e.g. + NOVA_LOCALEDIR). + + :param domain: the translation domain + :param lazy: indicates whether or not to install the lazy _() function. + The lazy _() introduces a way to do deferred translation + of messages by installing a _ that builds Message objects, + instead of strings, which can then be lazily translated into + any available locale. + """ + if lazy: + # NOTE(mrodden): Lazy gettext functionality. + # + # The following introduces a deferred way to do translations on + # messages in OpenStack. We override the standard _() function + # and % (format string) operation to build Message objects that can + # later be translated when we have more information. + # + # Also included below is an example LocaleHandler that translates + # Messages to an associated locale, effectively allowing many logs, + # each with their own locale. + + def _lazy_gettext(msg): + """Create and return a Message object. + + Lazy gettext function for a given domain, it is a factory method + for a project/module to get a lazy gettext function for its own + translation domain (i.e. nova, glance, cinder, etc.) + + Message encapsulates a string so that we can translate + it later when needed. + """ + return Message(msg, domain) + + from six import moves + moves.builtins.__dict__['_'] = _lazy_gettext + else: + localedir = '%s_LOCALEDIR' % domain.upper() + if six.PY3: + gettext.install(domain, + localedir=os.environ.get(localedir)) + else: + gettext.install(domain, + localedir=os.environ.get(localedir), + unicode=True) + + +class Message(_userString.UserString, object): + """Class used to encapsulate translatable messages.""" + def __init__(self, msg, domain): + # _msg is the gettext msgid and should never change + self._msg = msg + self._left_extra_msg = '' + self._right_extra_msg = '' + self._locale = None + self.params = None + self.domain = domain + + @property + def data(self): + # NOTE(mrodden): this should always resolve to a unicode string + # that best represents the state of the message currently + + localedir = os.environ.get(self.domain.upper() + '_LOCALEDIR') + if self.locale: + lang = gettext.translation(self.domain, + localedir=localedir, + languages=[self.locale], + fallback=True) + else: + # use system locale for translations + lang = gettext.translation(self.domain, + localedir=localedir, + fallback=True) + + if six.PY3: + ugettext = lang.gettext + else: + ugettext = lang.ugettext + + full_msg = (self._left_extra_msg + + ugettext(self._msg) + + self._right_extra_msg) + + if self.params is not None: + full_msg = full_msg % self.params + + return six.text_type(full_msg) + + @property + def locale(self): + return self._locale + + @locale.setter + def locale(self, value): + self._locale = value + if not self.params: + return + + # This Message object may have been constructed with one or more + # Message objects as substitution parameters, given as a single + # Message, or a tuple or Map containing some, so when setting the + # locale for this Message we need to set it for those Messages too. + if isinstance(self.params, Message): + self.params.locale = value + return + if isinstance(self.params, tuple): + for param in self.params: + if isinstance(param, Message): + param.locale = value + return + for param in self.params.values(): + if isinstance(param, Message): + param.locale = value + + def _save_dictionary_parameter(self, dict_param): + full_msg = self.data + # look for %(blah) fields in string; + # ignore %% and deal with the + # case where % is first character on the line + keys = re.findall('(?:[^%]|^)?%\((\w*)\)[a-z]', full_msg) + + # if we don't find any %(blah) blocks but have a %s + if not keys and re.findall('(?:[^%]|^)%[a-z]', full_msg): + # apparently the full dictionary is the parameter + params = copy.deepcopy(dict_param) + else: + params = {} + for key in keys: + try: + params[key] = copy.deepcopy(dict_param[key]) + except TypeError: + # cast uncopyable thing to unicode string + params[key] = six.text_type(dict_param[key]) + + return params + + def _save_parameters(self, other): + # we check for None later to see if + # we actually have parameters to inject, + # so encapsulate if our parameter is actually None + if other is None: + self.params = (other, ) + elif isinstance(other, dict): + self.params = self._save_dictionary_parameter(other) + else: + # fallback to casting to unicode, + # this will handle the problematic python code-like + # objects that cannot be deep-copied + try: + self.params = copy.deepcopy(other) + except TypeError: + self.params = six.text_type(other) + + return self + + # overrides to be more string-like + def __unicode__(self): + return self.data + + def __str__(self): + if six.PY3: + return self.__unicode__() + return self.data.encode('utf-8') + + def __getstate__(self): + to_copy = ['_msg', '_right_extra_msg', '_left_extra_msg', + 'domain', 'params', '_locale'] + new_dict = self.__dict__.fromkeys(to_copy) + for attr in to_copy: + new_dict[attr] = copy.deepcopy(self.__dict__[attr]) + + return new_dict + + def __setstate__(self, state): + for (k, v) in state.items(): + setattr(self, k, v) + + # operator overloads + def __add__(self, other): + copied = copy.deepcopy(self) + copied._right_extra_msg += other.__str__() + return copied + + def __radd__(self, other): + copied = copy.deepcopy(self) + copied._left_extra_msg += other.__str__() + return copied + + def __mod__(self, other): + # do a format string to catch and raise + # any possible KeyErrors from missing parameters + self.data % other + copied = copy.deepcopy(self) + return copied._save_parameters(other) + + def __mul__(self, other): + return self.data * other + + def __rmul__(self, other): + return other * self.data + + def __getitem__(self, key): + return self.data[key] + + def __getslice__(self, start, end): + return self.data.__getslice__(start, end) + + def __getattribute__(self, name): + # NOTE(mrodden): handle lossy operations that we can't deal with yet + # These override the UserString implementation, since UserString + # uses our __class__ attribute to try and build a new message + # after running the inner data string through the operation. + # At that point, we have lost the gettext message id and can just + # safely resolve to a string instead. + ops = ['capitalize', 'center', 'decode', 'encode', + 'expandtabs', 'ljust', 'lstrip', 'replace', 'rjust', 'rstrip', + 'strip', 'swapcase', 'title', 'translate', 'upper', 'zfill'] + if name in ops: + return getattr(self.data, name) + else: + return _userString.UserString.__getattribute__(self, name) + + +def get_available_languages(domain): + """Lists the available languages for the given translation domain. + + :param domain: the domain to get languages for + """ + if domain in _AVAILABLE_LANGUAGES: + return copy.copy(_AVAILABLE_LANGUAGES[domain]) + + localedir = '%s_LOCALEDIR' % domain.upper() + find = lambda x: gettext.find(domain, + localedir=os.environ.get(localedir), + languages=[x]) + + # NOTE(mrodden): en_US should always be available (and first in case + # order matters) since our in-line message strings are en_US + language_list = ['en_US'] + # NOTE(luisg): Babel <1.0 used a function called list(), which was + # renamed to locale_identifiers() in >=1.0, the requirements master list + # requires >=0.9.6, uncapped, so defensively work with both. We can remove + # this check when the master list updates to >=1.0, and all projects udpate + list_identifiers = (getattr(localedata, 'list', None) or + getattr(localedata, 'locale_identifiers')) + locale_identifiers = list_identifiers() + for i in locale_identifiers: + if find(i) is not None: + language_list.append(i) + _AVAILABLE_LANGUAGES[domain] = language_list + return copy.copy(language_list) + + +def get_localized_message(message, user_locale): + """Gets a localized version of the given message in the given locale.""" + if isinstance(message, Message): + if user_locale: + message.locale = user_locale + return six.text_type(message) + else: + return message + + +class LocaleHandler(logging.Handler): + """Handler that can have a locale associated to translate Messages. + + A quick example of how to utilize the Message class above. + LocaleHandler takes a locale and a target logging.Handler object + to forward LogRecord objects to after translating the internal Message. + """ + + def __init__(self, locale, target): + """Initialize a LocaleHandler + + :param locale: locale to use for translating messages + :param target: logging.Handler object to forward + LogRecord objects to after translation + """ + logging.Handler.__init__(self) + self.locale = locale + self.target = target + + def emit(self, record): + if isinstance(record.msg, Message): + # set the locale and resolve to a string + record.msg.locale = self.locale + + self.target.emit(record) diff --git a/troveclient/openstack/common/importutils.py b/troveclient/openstack/common/importutils.py new file mode 100644 index 00000000..7a303f93 --- /dev/null +++ b/troveclient/openstack/common/importutils.py @@ -0,0 +1,68 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack Foundation. +# 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. + +""" +Import related utilities and helper functions. +""" + +import sys +import traceback + + +def import_class(import_str): + """Returns a class from a string including module and class.""" + mod_str, _sep, class_str = import_str.rpartition('.') + try: + __import__(mod_str) + return getattr(sys.modules[mod_str], class_str) + except (ValueError, AttributeError): + raise ImportError('Class %s cannot be found (%s)' % + (class_str, + traceback.format_exception(*sys.exc_info()))) + + +def import_object(import_str, *args, **kwargs): + """Import a class and return an instance of it.""" + return import_class(import_str)(*args, **kwargs) + + +def import_object_ns(name_space, import_str, *args, **kwargs): + """Tries to import object from default namespace. + + Imports a class and return an instance of it, first by trying + to find the class in a default namespace, then failing back to + a full path if not found in the default namespace. + """ + import_value = "%s.%s" % (name_space, import_str) + try: + return import_class(import_value)(*args, **kwargs) + except ImportError: + return import_class(import_str)(*args, **kwargs) + + +def import_module(import_str): + """Import a module.""" + __import__(import_str) + return sys.modules[import_str] + + +def try_import(import_str, default=None): + """Try to import a module and if it fails return default.""" + try: + return import_module(import_str) + except ImportError: + return default diff --git a/troveclient/openstack/common/strutils.py b/troveclient/openstack/common/strutils.py new file mode 100644 index 00000000..28cb00ec --- /dev/null +++ b/troveclient/openstack/common/strutils.py @@ -0,0 +1,218 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack Foundation. +# 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. + +""" +System-level utilities and helper functions. +""" + +import re +import sys +import unicodedata + +import six + +from troveclient.openstack.common.gettextutils import _ # noqa + + +# Used for looking up extensions of text +# to their 'multiplied' byte amount +BYTE_MULTIPLIERS = { + '': 1, + 't': 1024 ** 4, + 'g': 1024 ** 3, + 'm': 1024 ** 2, + 'k': 1024, +} +BYTE_REGEX = re.compile(r'(^-?\d+)(\D*)') + +TRUE_STRINGS = ('1', 't', 'true', 'on', 'y', 'yes') +FALSE_STRINGS = ('0', 'f', 'false', 'off', 'n', 'no') + +SLUGIFY_STRIP_RE = re.compile(r"[^\w\s-]") +SLUGIFY_HYPHENATE_RE = re.compile(r"[-\s]+") + + +def int_from_bool_as_string(subject): + """Interpret a string as a boolean and return either 1 or 0. + + Any string value in: + + ('True', 'true', 'On', 'on', '1') + + is interpreted as a boolean True. + + Useful for JSON-decoded stuff and config file parsing + """ + return bool_from_string(subject) and 1 or 0 + + +def bool_from_string(subject, strict=False): + """Interpret a string as a boolean. + + A case-insensitive match is performed such that strings matching 't', + 'true', 'on', 'y', 'yes', or '1' are considered True and, when + `strict=False`, anything else is considered False. + + Useful for JSON-decoded stuff and config file parsing. + + If `strict=True`, unrecognized values, including None, will raise a + ValueError which is useful when parsing values passed in from an API call. + Strings yielding False are 'f', 'false', 'off', 'n', 'no', or '0'. + """ + if not isinstance(subject, six.string_types): + subject = str(subject) + + lowered = subject.strip().lower() + + if lowered in TRUE_STRINGS: + return True + elif lowered in FALSE_STRINGS: + return False + elif strict: + acceptable = ', '.join( + "'%s'" % s for s in sorted(TRUE_STRINGS + FALSE_STRINGS)) + msg = _("Unrecognized value '%(val)s', acceptable values are:" + " %(acceptable)s") % {'val': subject, + 'acceptable': acceptable} + raise ValueError(msg) + else: + return False + + +def safe_decode(text, incoming=None, errors='strict'): + """Decodes incoming str using `incoming` if they're not already unicode. + + :param incoming: Text's current encoding + :param errors: Errors handling policy. See here for valid + values http://docs.python.org/2/library/codecs.html + :returns: text or a unicode `incoming` encoded + representation of it. + :raises TypeError: If text is not an isntance of str + """ + if not isinstance(text, six.string_types): + raise TypeError("%s can't be decoded" % type(text)) + + if isinstance(text, six.text_type): + return text + + if not incoming: + incoming = (sys.stdin.encoding or + sys.getdefaultencoding()) + + try: + return text.decode(incoming, errors) + except UnicodeDecodeError: + # Note(flaper87) If we get here, it means that + # sys.stdin.encoding / sys.getdefaultencoding + # didn't return a suitable encoding to decode + # text. This happens mostly when global LANG + # var is not set correctly and there's no + # default encoding. In this case, most likely + # python will use ASCII or ANSI encoders as + # default encodings but they won't be capable + # of decoding non-ASCII characters. + # + # Also, UTF-8 is being used since it's an ASCII + # extension. + return text.decode('utf-8', errors) + + +def safe_encode(text, incoming=None, + encoding='utf-8', errors='strict'): + """Encodes incoming str/unicode using `encoding`. + + If incoming is not specified, text is expected to be encoded with + current python's default encoding. (`sys.getdefaultencoding`) + + :param incoming: Text's current encoding + :param encoding: Expected encoding for text (Default UTF-8) + :param errors: Errors handling policy. See here for valid + values http://docs.python.org/2/library/codecs.html + :returns: text or a bytestring `encoding` encoded + representation of it. + :raises TypeError: If text is not an isntance of str + """ + if not isinstance(text, six.string_types): + raise TypeError("%s can't be encoded" % type(text)) + + if not incoming: + incoming = (sys.stdin.encoding or + sys.getdefaultencoding()) + + if isinstance(text, six.text_type): + return text.encode(encoding, errors) + elif text and encoding != incoming: + # Decode text before encoding it with `encoding` + text = safe_decode(text, incoming, errors) + return text.encode(encoding, errors) + + return text + + +def to_bytes(text, default=0): + """Converts a string into an integer of bytes. + + Looks at the last characters of the text to determine + what conversion is needed to turn the input text into a byte number. + Supports "B, K(B), M(B), G(B), and T(B)". (case insensitive) + + :param text: String input for bytes size conversion. + :param default: Default return value when text is blank. + + """ + match = BYTE_REGEX.search(text) + if match: + magnitude = int(match.group(1)) + mult_key_org = match.group(2) + if not mult_key_org: + return magnitude + elif text: + msg = _('Invalid string format: %s') % text + raise TypeError(msg) + else: + return default + mult_key = mult_key_org.lower().replace('b', '', 1) + multiplier = BYTE_MULTIPLIERS.get(mult_key) + if multiplier is None: + msg = _('Unknown byte multiplier: %s') % mult_key_org + raise TypeError(msg) + return magnitude * multiplier + + +def to_slug(value, incoming=None, errors="strict"): + """Normalize string. + + Convert to lowercase, remove non-word characters, and convert spaces + to hyphens. + + Inspired by Django's `slugify` filter. + + :param value: Text to slugify + :param incoming: Text's current encoding + :param errors: Errors handling policy. See here for valid + values http://docs.python.org/2/library/codecs.html + :returns: slugified unicode representation of `value` + :raises TypeError: If text is not an instance of str + """ + value = safe_decode(value, incoming, errors) + # NOTE(aababilov): no need to use safe_(encode|decode) here: + # encodings are always "ascii", error handling is always "ignore" + # and types are always known (first: unicode; second: str) + value = unicodedata.normalize("NFKD", value).encode( + "ascii", "ignore").decode("ascii") + value = SLUGIFY_STRIP_RE.sub("", value).strip().lower() + return SLUGIFY_HYPHENATE_RE.sub("-", value) diff --git a/troveclient/service_catalog.py b/troveclient/service_catalog.py new file mode 100644 index 00000000..a5431429 --- /dev/null +++ b/troveclient/service_catalog.py @@ -0,0 +1,86 @@ +# Copyright 2011 OpenStack LLC. +# Copyright 2011, Piston Cloud Computing, Inc. +# +# 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 openstack.common.apiclient import exceptions + + +class ServiceCatalog(object): + """Helper methods for dealing with a Keystone Service Catalog.""" + + def __init__(self, resource_dict): + self.catalog = resource_dict + + def get_token(self): + return self.catalog['access']['token']['id'] + + def url_for(self, attr=None, filter_value=None, + service_type=None, endpoint_type='publicURL', + service_name=None, database_service_name=None): + """Fetch the public URL from the Compute service for + a particular endpoint attribute. If none given, return + the first. See tests for sample service catalog. + """ + 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 'serviceCatalog' not in self.catalog['access']: + return None + + # Full catalog ... + catalog = self.catalog['access']['serviceCatalog'] + + for service in catalog: + + # NOTE(thingee): For backwards compatibility, if they have v2 + # enabled and the service_type is set to 'database', go ahead and + # accept that. + skip_service_type_check = False + if service_type == 'databasev2' and service['type'] == 'database': + version = service['endpoints'][0]['publicURL'].split('/')[3] + if version == 'v2': + skip_service_type_check = True + + if (not skip_service_type_check + and service.get("type") != service_type): + continue + + if (database_service_name and service_type in ('database', + 'databasev2') + and service.get('name') != database_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][endpoint_type] diff --git a/troveclient/shell.py b/troveclient/shell.py new file mode 100644 index 00000000..53a522c9 --- /dev/null +++ b/troveclient/shell.py @@ -0,0 +1,533 @@ +# Copyright 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. + +""" +Command-line interface to the OpenStack Trove API. +""" + +from __future__ import print_function + +import argparse +import glob +import imp +import itertools +import os +import pkgutil +import sys +import logging + +import six + +import troveclient +from troveclient import client +#from troveclient import exceptions as exc +#import troveclient.extension +from troveclient.openstack.common import strutils +from troveclient.openstack.common.apiclient import exceptions as exc +from troveclient import utils +from troveclient.v1 import shell as shell_v1 + +DEFAULT_OS_DATABASE_API_VERSION = "1.0" +DEFAULT_TROVE_ENDPOINT_TYPE = 'publicURL' +DEFAULT_TROVE_SERVICE_TYPE = 'database' + +logger = logging.getLogger(__name__) + + +class TroveClientArgumentParser(argparse.ArgumentParser): + + def __init__(self, *args, **kwargs): + super(TroveClientArgumentParser, self).__init__(*args, **kwargs) + + def error(self, message): + """error(message: string) + + Prints a usage message incorporating the message to stderr and + exits. + """ + self.print_usage(sys.stderr) + #FIXME(lzyeval): if changes occur in argparse.ArgParser._check_value + choose_from = ' (choose from' + progparts = self.prog.partition(' ') + self.exit(2, "error: %(errmsg)s\nTry '%(mainp)s help %(subp)s'" + " for more information.\n" % + {'errmsg': message.split(choose_from)[0], + 'mainp': progparts[0], + 'subp': progparts[2]}) + + +class OpenStackTroveShell(object): + + def get_base_parser(self): + parser = TroveClientArgumentParser( + prog='trove', + description=__doc__.strip(), + epilog='See "trove help COMMAND" ' + 'for help on a specific command.', + add_help=False, + formatter_class=OpenStackHelpFormatter, + ) + + # Global arguments + parser.add_argument('-h', '--help', + action='store_true', + help=argparse.SUPPRESS) + + parser.add_argument('--version', + action='version', + version=troveclient.__version__) + + parser.add_argument('--debug', + action='store_true', + default=utils.env('TROVECLIENT_DEBUG', + default=False), + help="Print debugging output") + + parser.add_argument('--os-username', + metavar='', + default=utils.env('OS_USERNAME', + 'TROVE_USERNAME'), + help='Defaults to env[OS_USERNAME].') + parser.add_argument('--os_username', + help=argparse.SUPPRESS) + + parser.add_argument('--os-password', + metavar='', + default=utils.env('OS_PASSWORD', + 'TROVE_PASSWORD'), + help='Defaults to env[OS_PASSWORD].') + parser.add_argument('--os_password', + help=argparse.SUPPRESS) + + parser.add_argument('--os-tenant-name', + metavar='', + default=utils.env('OS_TENANT_NAME', + 'TROVE_PROJECT_ID'), + help='Defaults to env[OS_TENANT_NAME].') + parser.add_argument('--os_tenant_name', + help=argparse.SUPPRESS) + + parser.add_argument('--os-tenant-id', + metavar='', + default=utils.env('OS_TENANT_ID', + 'TROVE_TENANT_ID'), + help='Defaults to env[OS_TENANT_ID].') + parser.add_argument('--os_tenant_id', + help=argparse.SUPPRESS) + + parser.add_argument('--os-auth-url', + metavar='', + default=utils.env('OS_AUTH_URL', + 'TROVE_URL'), + help='Defaults to env[OS_AUTH_URL].') + parser.add_argument('--os_auth_url', + help=argparse.SUPPRESS) + + parser.add_argument('--os-region-name', + metavar='', + default=utils.env('OS_REGION_NAME', + 'TROVE_REGION_NAME'), + help='Defaults to env[OS_REGION_NAME].') + parser.add_argument('--os_region_name', + help=argparse.SUPPRESS) + + parser.add_argument('--service-type', + metavar='', + help='Defaults to database for most actions') + parser.add_argument('--service_type', + help=argparse.SUPPRESS) + + parser.add_argument('--service-name', + metavar='', + default=utils.env('TROVE_SERVICE_NAME'), + help='Defaults to env[TROVE_SERVICE_NAME]') + parser.add_argument('--service_name', + help=argparse.SUPPRESS) + + parser.add_argument('--database-service-name', + metavar='', + default=utils.env('TROVE_DATABASE_SERVICE_NAME'), + help='Defaults to env' + '[TROVE_DATABASE_SERVICE_NAME]') + parser.add_argument('--database_service_name', + help=argparse.SUPPRESS) + + parser.add_argument('--endpoint-type', + metavar='', + default=utils.env('TROVE_ENDPOINT_TYPE', + default=DEFAULT_TROVE_ENDPOINT_TYPE), + help='Defaults to env[TROVE_ENDPOINT_TYPE] or ' + + DEFAULT_TROVE_ENDPOINT_TYPE + '.') + parser.add_argument('--endpoint_type', + help=argparse.SUPPRESS) + + parser.add_argument('--os-database-api-version', + metavar='', + default=utils.env('OS_DATABASE_API_VERSION', + default=DEFAULT_OS_DATABASE_API_VERSION), + help='Accepts 1,defaults ' + 'to env[OS_DATABASE_API_VERSION].') + parser.add_argument('--os_database_api_version', + help=argparse.SUPPRESS) + + parser.add_argument('--os-cacert', + metavar='', + default=utils.env('OS_CACERT', default=None), + help='Specify a CA bundle file to use in ' + 'verifying a TLS (https) server certificate. ' + 'Defaults to env[OS_CACERT]') + + parser.add_argument('--insecure', + default=utils.env('TROVECLIENT_INSECURE', + default=False), + action='store_true', + help=argparse.SUPPRESS) + + parser.add_argument('--retries', + metavar='', + type=int, + default=0, + help='Number of retries.') + + # FIXME(dtroyer): The args below are here for diablo compatibility, + # remove them in folsum cycle + + # alias for --os-username, left in for backwards compatibility + parser.add_argument('--username', + help=argparse.SUPPRESS) + + # alias for --os-region_name, left in for backwards compatibility + parser.add_argument('--region_name', + help=argparse.SUPPRESS) + + # alias for --os-password, left in for backwards compatibility + parser.add_argument('--apikey', '--password', dest='apikey', + default=utils.env('TROVE_API_KEY'), + help=argparse.SUPPRESS) + + # alias for --os-tenant-name, left in for backward compatibility + parser.add_argument('--projectid', '--tenant_name', dest='projectid', + default=utils.env('TROVE_PROJECT_ID'), + help=argparse.SUPPRESS) + + # alias for --os-auth-url, left in for backward compatibility + parser.add_argument('--url', '--auth_url', dest='url', + default=utils.env('TROVE_URL'), + help=argparse.SUPPRESS) + + return parser + + def get_subcommand_parser(self, version): + parser = self.get_base_parser() + + self.subcommands = {} + subparsers = parser.add_subparsers(metavar='') + + try: + actions_module = { + '1.0': shell_v1, + }[version] + except KeyError: + actions_module = shell_v1 + + self._find_actions(subparsers, actions_module) + self._find_actions(subparsers, self) + + for extension in self.extensions: + self._find_actions(subparsers, extension.module) + + self._add_bash_completion_subparser(subparsers) + + return parser + + def _discover_extensions(self, version): + extensions = [] + for name, module in itertools.chain( + self._discover_via_python_path(version), + self._discover_via_contrib_path(version)): + + extension = troveclient.extension.Extension(name, module) + extensions.append(extension) + + return extensions + + def _discover_via_python_path(self, version): + for (module_loader, name, ispkg) in pkgutil.iter_modules(): + if name.endswith('python_troveclient_ext'): + if not hasattr(module_loader, 'load_module'): + # Python 2.6 compat: actually get an ImpImporter obj + module_loader = module_loader.find_module(name) + + module = module_loader.load_module(name) + yield name, module + + def _discover_via_contrib_path(self, version): + module_path = os.path.dirname(os.path.abspath(__file__)) + version_str = "v%s" % version.replace('.', '_') + ext_path = os.path.join(module_path, version_str, 'contrib') + ext_glob = os.path.join(ext_path, "*.py") + + for ext_path in glob.iglob(ext_glob): + name = os.path.basename(ext_path)[:-3] + + if name == "__init__": + continue + + module = imp.load_source(name, ext_path) + yield name, module + + def _add_bash_completion_subparser(self, subparsers): + subparser = subparsers.add_parser( + 'bash_completion', + add_help=False, + formatter_class=OpenStackHelpFormatter) + + self.subcommands['bash_completion'] = subparser + subparser.set_defaults(func=self.do_bash_completion) + + def _find_actions(self, subparsers, actions_module): + for attr in (a for a in dir(actions_module) if a.startswith('do_')): + # I prefer to be hypen-separated instead of underscores. + command = attr[3:].replace('_', '-') + callback = getattr(actions_module, attr) + desc = callback.__doc__ or '' + help = desc.strip().split('\n')[0] + arguments = getattr(callback, 'arguments', []) + + subparser = subparsers.add_parser( + command, + help=help, + description=desc, + add_help=False, + formatter_class=OpenStackHelpFormatter) + + subparser.add_argument('-h', '--help', + action='help', + help=argparse.SUPPRESS,) + + self.subcommands[command] = subparser + for (args, kwargs) in arguments: + subparser.add_argument(*args, **kwargs) + subparser.set_defaults(func=callback) + + def setup_debugging(self, debug): + if not debug: + return + + streamhandler = logging.StreamHandler() + streamformat = "%(levelname)s (%(module)s:%(lineno)d) %(message)s" + streamhandler.setFormatter(logging.Formatter(streamformat)) + logger.setLevel(logging.DEBUG) + logger.addHandler(streamhandler) + + def main(self, argv): + # Parse args once to find version and debug settings + parser = self.get_base_parser() + (options, args) = parser.parse_known_args(argv) + self.setup_debugging(options.debug) + + # build available subcommands based on version + self.extensions = self._discover_extensions( + options.os_database_api_version) + self._run_extension_hooks('__pre_parse_args__') + + subcommand_parser = self.get_subcommand_parser( + options.os_database_api_version) + self.parser = subcommand_parser + + if options.help or not argv: + subcommand_parser.print_help() + return 0 + + args = subcommand_parser.parse_args(argv) + self._run_extension_hooks('__post_parse_args__', args) + + # Short-circuit and deal with help right away. + if args.func == self.do_help: + self.do_help(args) + return 0 + elif args.func == self.do_bash_completion: + self.do_bash_completion(args) + return 0 + + (os_username, os_password, os_tenant_name, os_auth_url, + os_region_name, os_tenant_id, endpoint_type, insecure, + service_type, service_name, database_service_name, + username, apikey, projectid, url, region_name, cacert) = ( + args.os_username, args.os_password, + args.os_tenant_name, args.os_auth_url, + args.os_region_name, args.os_tenant_id, + args.endpoint_type, args.insecure, + args.service_type, args.service_name, + args.database_service_name, args.username, + args.apikey, args.projectid, + args.url, args.region_name, args.os_cacert) + + if not endpoint_type: + endpoint_type = DEFAULT_TROVE_ENDPOINT_TYPE + + if not service_type: + service_type = DEFAULT_TROVE_SERVICE_TYPE + service_type = utils.get_service_type(args.func) or service_type + + #FIXME(usrleon): Here should be restrict for project id same as + # for os_username or os_password but for compatibility it is not. + + if not utils.isunauthenticated(args.func): + if not os_username: + if not username: + raise exc.CommandError( + "You must provide a username " + "via either --os-username or env[OS_USERNAME]") + else: + os_username = username + + if not os_password: + if not apikey: + raise exc.CommandError("You must provide a password " + "via either --os-password or via " + "env[OS_PASSWORD]") + else: + os_password = apikey + + if not (os_tenant_name or os_tenant_id): + if not projectid: + raise exc.CommandError("You must provide a tenant_id " + "via either --os-tenant-id or " + "env[OS_TENANT_ID]") + else: + os_tenant_name = projectid + + if not os_auth_url: + if not url: + raise exc.CommandError( + "You must provide an auth url " + "via either --os-auth-url or env[OS_AUTH_URL]") + else: + os_auth_url = url + + if not os_region_name and region_name: + os_region_name = region_name + + if not (os_tenant_name or os_tenant_id): + raise exc.CommandError( + "You must provide a tenant_id " + "via either --os-tenant-id or env[OS_TENANT_ID]") + + if not os_auth_url: + raise exc.CommandError( + "You must provide an auth url " + "via either --os-auth-url or env[OS_AUTH_URL]") + + self.cs = client.Client(options.os_database_api_version, os_username, + os_password, os_tenant_name, os_auth_url, + insecure, region_name=os_region_name, + tenant_id=os_tenant_id, + endpoint_type=endpoint_type, + extensions=self.extensions, + service_type=service_type, + service_name=service_name, + database_service_name=database_service_name, + retries=options.retries, + http_log_debug=args.debug, + cacert=cacert) + + try: + if not utils.isunauthenticated(args.func): + self.cs.authenticate() + except exc.Unauthorized: + raise exc.CommandError("Invalid OpenStack Trove credentials.") + except exc.AuthorizationFailure: + raise exc.CommandError("Unable to authorize user") + + endpoint_api_version = self.cs.get_database_api_version_from_endpoint() + if endpoint_api_version != options.os_database_api_version: + msg = (("Database API version is set to %s " + "but you are accessing a %s endpoint. " + "Change its value via either --os-database-api-version " + "or env[OS_DATABASE_API_VERSION]") + % (options.os_database_api_version, endpoint_api_version)) + #raise exc.InvalidAPIVersion(msg) + raise exc.UnsupportedVersion(msg) + + args.func(self.cs, args) + + def _run_extension_hooks(self, hook_type, *args, **kwargs): + """Run hooks for all registered extensions.""" + for extension in self.extensions: + extension.run_hooks(hook_type, *args, **kwargs) + + def do_bash_completion(self, args): + """Print arguments for bash_completion. + + Prints all of the commands and options to stdout so that the + trove.bash_completion script doesn't have to hard code them. + """ + commands = set() + options = set() + for sc_str, sc in list(self.subcommands.items()): + commands.add(sc_str) + for option in list(sc._optionals._option_string_actions.keys()): + options.add(option) + + commands.remove('bash-completion') + commands.remove('bash_completion') + print(' '.join(commands | options)) + + @utils.arg('command', metavar='', nargs='?', + help='Display help for ') + def do_help(self, args): + """ + Display help about this program or one of its subcommands. + """ + if args.command: + if args.command in self.subcommands: + self.subcommands[args.command].print_help() + else: + raise exc.CommandError("'%s' is not a valid subcommand" % + args.command) + else: + self.parser.print_help() + + +# I'm picky about my shell help. +class OpenStackHelpFormatter(argparse.HelpFormatter): + def start_section(self, heading): + # Title-case the headings + heading = '%s%s' % (heading[0].upper(), heading[1:]) + super(OpenStackHelpFormatter, self).start_section(heading) + + +def main(): + try: + if sys.version_info >= (3, 0): + OpenStackTroveShell().main(sys.argv[1:]) + else: + OpenStackTroveShell().main(map(strutils.safe_decode, + sys.argv[1:])) + except KeyboardInterrupt: + print("... terminating trove client", file=sys.stderr) + sys.exit(130) + except Exception as e: + logger.debug(e, exc_info=1) + message = e.message + if not isinstance(message, six.string_types): + message = str(message) + print("ERROR: %s" % strutils.safe_encode(message), file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/troveclient/tests/test_accounts.py b/troveclient/tests/test_accounts.py index e2716faa..1b5a1f45 100644 --- a/troveclient/tests/test_accounts.py +++ b/troveclient/tests/test_accounts.py @@ -1,7 +1,7 @@ from testtools import TestCase from mock import Mock -from troveclient import accounts +from troveclient.v1 import accounts from troveclient import base """ @@ -55,11 +55,11 @@ class AccountsTest(TestCase): def test_index(self): resp = Mock() - resp.status = 400 + resp.status_code = 400 body = {"Accounts": {}} self.accounts.api.client.get = Mock(return_value=(resp, body)) self.assertRaises(Exception, self.accounts.index) - resp.status = 200 + resp.status_code = 200 self.assertTrue(isinstance(self.accounts.index(), base.Resource)) self.accounts.api.client.get = Mock(return_value=(resp, None)) self.assertRaises(Exception, self.accounts.index) diff --git a/troveclient/tests/test_base.py b/troveclient/tests/test_base.py index d4eaa0f1..6d4e9eb5 100644 --- a/troveclient/tests/test_base.py +++ b/troveclient/tests/test_base.py @@ -5,12 +5,15 @@ from testtools import TestCase from mock import Mock from troveclient import base -from troveclient import exceptions +from troveclient.openstack.common.apiclient import exceptions +from troveclient import utils """ Unit tests for base.py """ +UUID = '8e8ec658-c7b0-4243-bdf8-6f7f2952c0d0' + def obj_class(self, res, loaded=True): return res @@ -234,69 +237,72 @@ class ManagerListTest(ManagerTest): self.assertEqual(len(data_), len(l)) -class ManagerWithFind(TestCase): +class FakeResource(object): + + def __init__(self, _id, properties): + self.id = _id + try: + self.name = properties['name'] + except KeyError: + pass + try: + self.display_name = properties['display_name'] + except KeyError: + pass + + +class FakeManager(base.ManagerWithFind): + + resource_class = FakeResource + + resources = [ + FakeResource('1234', {'name': 'entity_one'}), + FakeResource(UUID, {'name': 'entity_two'}), + FakeResource('4242', {'display_name': 'entity_three'}), + FakeResource('5678', {'name': '9876'}) + ] + + def get(self, resource_id): + for resource in self.resources: + if resource.id == str(resource_id): + return resource + raise exceptions.NotFound(resource_id) + + def list(self): + return self.resources + + +class FindResourceTestCase(TestCase): def setUp(self): - super(ManagerWithFind, self).setUp() - self.orig__init = base.ManagerWithFind.__init__ - base.ManagerWithFind.__init__ = Mock(return_value=None) - self.manager = base.ManagerWithFind() + super(FindResourceTestCase, self).setUp() + self.manager = FakeManager(None) - def tearDown(self): - super(ManagerWithFind, self).tearDown() - base.ManagerWithFind.__init__ = self.orig__init + def test_find_none(self): + self.assertRaises(exceptions.CommandError, + utils.find_resource, + self.manager, + 'asdf') - def test_find(self): - obj1 = Mock() - obj1.attr1 = "v1" - obj1.attr2 = "v2" - obj1.attr3 = "v3" + def test_find_by_integer_id(self): + output = utils.find_resource(self.manager, 1234) + self.assertEqual(output, self.manager.get('1234')) - obj2 = Mock() - obj2.attr1 = "v1" - obj2.attr2 = "v2" + def test_find_by_str_id(self): + output = utils.find_resource(self.manager, '1234') + self.assertEqual(output, self.manager.get('1234')) - self.manager.list = Mock(return_value=[obj1, obj2]) - self.manager.resource_class = Mock + def test_find_by_uuid(self): + output = utils.find_resource(self.manager, UUID) + self.assertEqual(output, self.manager.get(UUID)) - # exactly one match case - found = self.manager.find(attr1="v1", attr2="v2", attr3="v3") - self.assertEqual(obj1, found) + def test_find_by_str_name(self): + output = utils.find_resource(self.manager, 'entity_one') + self.assertEqual(output, self.manager.get('1234')) - # no match case - self.assertRaises(exceptions.NotFound, self.manager.find, - attr1="v2", attr2="v2", attr3="v3") - - # multiple matches case - obj2.attr3 = "v3" - self.assertRaises(exceptions.NoUniqueMatch, self.manager.find, - attr1="v1", attr2="v2", attr3="v3") - - def test_findall(self): - obj1 = Mock() - obj1.attr1 = "v1" - obj1.attr2 = "v2" - obj1.attr3 = "v3" - - obj2 = Mock() - obj2.attr1 = "v1" - obj2.attr2 = "v2" - - self.manager.list = Mock(return_value=[obj1, obj2]) - - found = self.manager.findall(attr1="v1", attr2="v2", attr3="v3") - self.assertEqual(1, len(found)) - self.assertEqual(obj1, found[0]) - - found = self.manager.findall(attr1="v2", attr2="v2", attr3="v3") - self.assertEqual(0, len(found)) - - found = self.manager.findall(attr7="v1", attr2="v2") - self.assertEqual(0, len(found)) - - def test_list(self): - # this method is not yet implemented, exception expected - self.assertRaises(NotImplementedError, self.manager.list) + def test_find_by_str_displayname(self): + output = utils.find_resource(self.manager, 'entity_three') + self.assertEqual(output, self.manager.get('4242')) class ResourceTest(TestCase): diff --git a/troveclient/tests/test_client.py b/troveclient/tests/test_client.py index 1a23a29c..84c332ad 100644 --- a/troveclient/tests/test_client.py +++ b/troveclient/tests/test_client.py @@ -1,317 +1,34 @@ -import logging -import httplib2 -import time +# 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 testtools import TestCase -from mock import Mock -from troveclient import client -from troveclient import exceptions - -""" -Unit tests for client.py -""" +import troveclient.v1.client +from troveclient.client import get_version_map +from troveclient.openstack.common.apiclient import client +from troveclient.openstack.common.apiclient import exceptions class ClientTest(TestCase): - def test_log_to_streamhandler(self): - client.log_to_streamhandler() - self.assertTrue(client._logger.level == logging.DEBUG) + def test_get_client_class_v1(self): + version_map = get_version_map() + output = client.BaseClient.get_class('database', + '1.0', version_map) + self.assertEqual(output, troveclient.v1.client.Client) -class TroveHTTPClientTest(TestCase): - def setUp(self): - super(TroveHTTPClientTest, self).setUp() - self.orig__init = client.TroveHTTPClient.__init__ - client.TroveHTTPClient.__init__ = Mock(return_value=None) - self.hc = client.TroveHTTPClient() - self.hc.auth_token = "test-auth-token" - self.hc.service_url = "test-service-url/" - self.hc.tenant = "test-tenant" - - self.__debug_lines = list() - - self.orig_client__logger = client._logger - client._logger = Mock() - - self.orig_time = time.time - self.orig_htttp_request = httplib2.Http.request - - def tearDown(self): - super(TroveHTTPClientTest, self).tearDown() - client.TroveHTTPClient.__init__ = self.orig__init - client._logger = self.orig_client__logger - time.time = self.orig_time - httplib2.Http.request = self.orig_htttp_request - - def side_effect_func_for_moc_debug(self, s, *args): - self.__debug_lines.append(s) - - def test___init__(self): - client.TroveHTTPClient.__init__ = self.orig__init - - user = "test-user" - password = "test-password" - tenant = "test-tenant" - auth_url = "http://test-auth-url/" - service_name = None - - # when there is no auth_strategy provided - self.assertRaises(ValueError, client.TroveHTTPClient, user, - password, tenant, auth_url, service_name) - - hc = client.TroveHTTPClient(user, password, tenant, auth_url, - service_name, auth_strategy="fake") - self.assertEqual("http://test-auth-url", hc.auth_url) - - # auth_url is none - hc = client.TroveHTTPClient(user, password, tenant, None, - service_name, auth_strategy="fake") - self.assertEqual(None, hc.auth_url) - - def test_get_timings(self): - self.hc.times = ["item1", "item2"] - self.assertEqual(2, len(self.hc.get_timings())) - self.assertEqual("item1", self.hc.get_timings()[0]) - self.assertEqual("item2", self.hc.get_timings()[1]) - - def test_http_log(self): - self.hc.simple_log = Mock(return_value=None) - self.hc.pretty_log = Mock(return_value=None) - - client.RDC_PP = False - self.hc.http_log(None, None, None, None) - self.assertEqual(1, self.hc.simple_log.call_count) - - client.RDC_PP = True - self.hc.http_log(None, None, None, None) - self.assertEqual(1, self.hc.pretty_log.call_count) - - def test_simple_log(self): - client._logger.isEnabledFor = Mock(return_value=False) - self.hc.simple_log(None, None, None, None) - self.assertEqual(0, len(self.__debug_lines)) - - client._logger.isEnabledFor = Mock(return_value=True) - se = self.side_effect_func_for_moc_debug - client._logger.debug = Mock(side_effect=se) - self.hc.simple_log(['item1', 'GET', 'item3', 'POST', 'item5'], - {'headers': {'e1': 'e1-v', 'e2': 'e2-v'}, - 'body': 'body'}, None, None) - self.assertEqual(3, len(self.__debug_lines)) - self.assertTrue(self.__debug_lines[0].startswith('REQ: curl -i')) - self.assertTrue(self.__debug_lines[1].startswith('REQ BODY:')) - self.assertTrue(self.__debug_lines[2].startswith('RESP:')) - - def test_pretty_log(self): - client._logger.isEnabledFor = Mock(return_value=False) - self.hc.pretty_log(None, None, None, None) - self.assertEqual(0, len(self.__debug_lines)) - - client._logger.isEnabledFor = Mock(return_value=True) - se = self.side_effect_func_for_moc_debug - client._logger.debug = Mock(side_effect=se) - self.hc.pretty_log(['item1', 'GET', 'item3', 'POST', 'item5'], - {'headers': {'e1': 'e1-v', 'e2': 'e2-v'}, - 'body': 'body'}, None, None) - self.assertEqual(5, len(self.__debug_lines)) - self.assertTrue(self.__debug_lines[0].startswith('REQUEST:')) - self.assertTrue(self.__debug_lines[1].startswith('curl -i')) - self.assertTrue(self.__debug_lines[2].startswith('BODY:')) - self.assertTrue(self.__debug_lines[3].startswith('RESPONSE HEADERS:')) - self.assertTrue(self.__debug_lines[4].startswith('RESPONSE BODY')) - - # no body case - self.__debug_lines = list() - self.hc.pretty_log(['item1', 'GET', 'item3', 'POST', 'item5'], - {'headers': {'e1': 'e1-v', 'e2': 'e2-v'}}, - None, None) - self.assertEqual(4, len(self.__debug_lines)) - self.assertTrue(self.__debug_lines[0].startswith('REQUEST:')) - self.assertTrue(self.__debug_lines[1].startswith('curl -i')) - self.assertTrue(self.__debug_lines[2].startswith('RESPONSE HEADERS:')) - self.assertTrue(self.__debug_lines[3].startswith('RESPONSE BODY')) - - def test_request(self): - self.hc.USER_AGENT = "user-agent" - resp = Mock() - body = Mock() - resp.status = 200 - httplib2.Http.request = Mock(return_value=(resp, body)) - self.hc.morph_response_body = Mock(return_value=body) - r, b = self.hc.request() - self.assertEqual(resp, r) - self.assertEqual(body, b) - self.assertEqual((resp, body), self.hc.last_response) - - httplib2.Http.request = Mock(return_value=(resp, None)) - r, b = self.hc.request() - self.assertEqual(resp, r) - self.assertEqual(None, b) - - status_list = [400, 401, 403, 404, 408, 409, 413, 500, 501] - for status in status_list: - resp.status = status - self.assertRaises(Exception, self.hc.request) - - exception = exceptions.ResponseFormatError - self.hc.morph_response_body = Mock(side_effect=exception) - self.assertRaises(Exception, self.hc.request) - - def test_raise_error_from_status(self): - resp = Mock() - resp.status = 200 - self.hc.raise_error_from_status(resp, Mock()) - - status_list = [400, 401, 403, 404, 408, 409, 413, 500, 501] - for status in status_list: - resp.status = status - self.assertRaises(Exception, - self.hc.raise_error_from_status, resp, Mock()) - - def test_morph_request(self): - kwargs = dict() - kwargs['headers'] = dict() - kwargs['body'] = ['body', {'item1': 'value1'}] - self.hc.morph_request(kwargs) - expected = {'body': '["body", {"item1": "value1"}]', - 'headers': {'Content-Type': 'application/json', - 'Accept': 'application/json'}} - self.assertEqual(expected, kwargs) - - def test_morph_response_body(self): - body_string = '["body", {"item1": "value1"}]' - expected = ['body', {'item1': 'value1'}] - self.assertEqual(expected, self.hc.morph_response_body(body_string)) - body_string = '["body", {"item1": }]' - self.assertRaises(exceptions.ResponseFormatError, - self.hc.morph_response_body, body_string) - - def test__time_request(self): - self.__time = 0 - - def side_effect_func(): - self.__time = self.__time + 1 - return self.__time - - time.time = Mock(side_effect=side_effect_func) - self.hc.request = Mock(return_value=("mock-response", "mock-body")) - self.hc.times = list() - resp, body = self.hc._time_request("test-url", "Get") - self.assertEqual(("mock-response", "mock-body"), (resp, body)) - self.assertEqual([('Get test-url', 1, 2)], self.hc.times) - - def mock_time_request_func(self): - def side_effect_func(url, method, **kwargs): - return url, method - - self.hc._time_request = Mock(side_effect=side_effect_func) - - def test__cs_request(self): - self.mock_time_request_func() - resp, body = self.hc._cs_request("test-url", "GET") - self.assertEqual(('test-service-url/test-url', 'GET'), (resp, body)) - - self.hc.authenticate = Mock(side_effect=ValueError) - self.hc.auth_token = None - self.hc.service_url = None - self.assertRaises(ValueError, self.hc._cs_request, "test-url", "GET") - - self.hc.authenticate = Mock(return_value=None) - self.hc.service_url = "test-service-url/" - - def side_effect_func_time_req(url, method, **kwargs): - raise exceptions.Unauthorized(None) - - self.hc._time_request = Mock(side_effect=side_effect_func_time_req) - self.assertRaises(exceptions.Unauthorized, - self.hc._cs_request, "test-url", "GET") - - def test_get(self): - self.mock_time_request_func() - resp, body = self.hc.get("test-url") - self.assertEqual(("test-service-url/test-url", "GET"), (resp, body)) - - def test_post(self): - self.mock_time_request_func() - resp, body = self.hc.post("test-url") - self.assertEqual(("test-service-url/test-url", "POST"), (resp, body)) - - def test_put(self): - self.mock_time_request_func() - resp, body = self.hc.put("test-url") - self.assertEqual(("test-service-url/test-url", "PUT"), (resp, body)) - - def test_delete(self): - self.mock_time_request_func() - resp, body = self.hc.delete("test-url") - self.assertEqual(("test-service-url/test-url", "DELETE"), (resp, body)) - - def test_authenticate(self): - self.hc.authenticator = Mock() - catalog = Mock() - catalog.get_public_url = Mock(return_value="public-url") - catalog.get_management_url = Mock(return_value="mng-url") - catalog.get_token = Mock(return_value="test-token") - - self.__auth_calls = [] - - def side_effect_func(token, url): - self.__auth_calls = [token, url] - - self.hc.authenticate_with_token = Mock(side_effect=side_effect_func) - self.hc.authenticator.authenticate = Mock(return_value=catalog) - self.hc.endpoint_type = "publicURL" - self.hc.authenticate() - self.assertEqual(["test-token", None], - self.__auth_calls) - - self.__auth_calls = [] - self.hc.service_url = None - self.hc.authenticate() - self.assertEqual(["test-token", "public-url"], self.__auth_calls) - - self.__auth_calls = [] - self.hc.endpoint_type = "adminURL" - self.hc.authenticate() - self.assertEqual(["test-token", "mng-url"], self.__auth_calls) - - def test_authenticate_with_token(self): - self.hc.service_url = None - self.assertRaises(exceptions.ServiceUrlNotGiven, - self.hc.authenticate_with_token, "token", None) - self.hc.authenticate_with_token("token", "test-url") - self.assertEqual("test-url", self.hc.service_url) - self.assertEqual("token", self.hc.auth_token) - - -class DbaasTest(TestCase): - def setUp(self): - super(DbaasTest, self).setUp() - self.orig__init = client.TroveHTTPClient.__init__ - client.TroveHTTPClient.__init__ = Mock(return_value=None) - self.dbaas = client.Dbaas("user", "api-key") - - def tearDown(self): - super(DbaasTest, self).tearDown() - client.TroveHTTPClient.__init__ = self.orig__init - - def test___init__(self): - client.TroveHTTPClient.__init__ = Mock(return_value=None) - self.assertNotEqual(None, self.dbaas.mgmt) - - def test_set_management_url(self): - self.dbaas.set_management_url("test-management-url") - self.assertEqual("test-management-url", - self.dbaas.client.management_url) - - def test_get_timings(self): - __timings = {'start': 1, 'end': 2} - self.dbaas.client.get_timings = Mock(return_value=__timings) - self.assertEqual(__timings, self.dbaas.get_timings()) - - def test_authenticate(self): - mock_auth = Mock(return_value=None) - self.dbaas.client.authenticate = mock_auth - self.dbaas.authenticate() - self.assertEqual(1, mock_auth.call_count) + def test_get_client_class_unknown(self): + version_map = get_version_map() + self.assertRaises(exceptions.UnsupportedVersion, + client.BaseClient.get_class, 'database', + '0', version_map) diff --git a/troveclient/tests/test_common.py b/troveclient/tests/test_common.py index 072a147e..78dd02dc 100644 --- a/troveclient/tests/test_common.py +++ b/troveclient/tests/test_common.py @@ -6,7 +6,7 @@ import collections from testtools import TestCase from mock import Mock -from troveclient import common +from troveclient.compat import common from troveclient import client """ @@ -40,13 +40,14 @@ class CommonTest(TestCase): status = [400, 422, 500] for s in status: resp = Mock() + #compat still uses status resp.status = s self.assertRaises(Exception, common.check_for_exceptions, resp, "body") # a no-exception case resp = Mock() - resp.status = 200 + resp.status_code = 200 common.check_for_exceptions(resp, "body") def test_print_actions(self): @@ -156,26 +157,6 @@ class CommandsBaseTest(TestCase): def test___init__(self): self.assertNotEqual(None, self.cmd_base) - def test__get_client(self): - client.log_to_streamhandler = Mock(return_value=None) - expected = Mock() - client.Dbaas = Mock(return_value=expected) - - self.cmd_base.xml = Mock() - self.cmd_base.verbose = False - r = self.cmd_base._get_client() - self.assertEqual(expected, r) - - self.cmd_base.xml = None - self.cmd_base.verbose = True - r = self.cmd_base._get_client() - self.assertEqual(expected, r) - - # test debug true - self.cmd_base.debug = True - client.Dbaas = Mock(side_effect=ValueError) - self.assertRaises(ValueError, self.cmd_base._get_client) - def test__safe_exec(self): func = Mock(return_value="test") self.cmd_base.debug = True diff --git a/troveclient/tests/test_instances.py b/troveclient/tests/test_instances.py index 065a5b5c..b7c04d26 100644 --- a/troveclient/tests/test_instances.py +++ b/troveclient/tests/test_instances.py @@ -1,7 +1,7 @@ from testtools import TestCase from mock import Mock -from troveclient import instances +from troveclient.v1 import instances from troveclient import base """ @@ -113,17 +113,17 @@ class InstancesTest(TestCase): def test_delete(self): resp = Mock() - resp.status = 200 + resp.status_code = 200 body = None self.instances.api.client.delete = Mock(return_value=(resp, body)) self.instances.delete('instance1') - resp.status = 500 + resp.status_code = 500 self.assertRaises(Exception, self.instances.delete, 'instance1') def test__action(self): body = Mock() resp = Mock() - resp.status = 200 + resp.status_code = 200 self.instances.api.client.post = Mock(return_value=(resp, body)) self.assertEqual('instance-1', self.instances._action(1, body)) diff --git a/troveclient/tests/test_limits.py b/troveclient/tests/test_limits.py index d40b646b..8b9087dc 100644 --- a/troveclient/tests/test_limits.py +++ b/troveclient/tests/test_limits.py @@ -1,6 +1,6 @@ from testtools import TestCase from mock import Mock -from troveclient import limits +from troveclient.v1 import limits class LimitsTest(TestCase): @@ -18,7 +18,7 @@ class LimitsTest(TestCase): def test_list(self): resp = Mock() - resp.status = 200 + resp.status_code = 200 body = {"limits": [ {'maxTotalInstances': 55, @@ -66,7 +66,7 @@ class LimitsTest(TestCase): RESPONSE_KEY = "limits" resp = Mock() - resp.status = status_code + resp.status_code = status_code body = {RESPONSE_KEY: { 'absolute': {}, 'rate': [ diff --git a/troveclient/tests/test_management.py b/troveclient/tests/test_management.py index e21bd299..0b377494 100644 --- a/troveclient/tests/test_management.py +++ b/troveclient/tests/test_management.py @@ -1,7 +1,7 @@ from testtools import TestCase from mock import Mock -from troveclient import management +from troveclient.v1 import management from troveclient import base """ @@ -92,10 +92,10 @@ class ManagementTest(TestCase): def test__action(self): resp = Mock() self.management.api.client.post = Mock(return_value=(resp, 'body')) - resp.status = 200 + resp.status_code = 200 self.management._action(1, 'body') self.assertEqual(1, self.management.api.client.post.call_count) - resp.status = 400 + resp.status_code = 400 self.assertRaises(Exception, self.management._action, 1, 'body') self.assertEqual(2, self.management.api.client.post.call_count) diff --git a/troveclient/tests/test_secgroups.py b/troveclient/tests/test_secgroups.py index e85e18b2..1d4d355d 100644 --- a/troveclient/tests/test_secgroups.py +++ b/troveclient/tests/test_secgroups.py @@ -1,7 +1,7 @@ from testtools import TestCase from mock import Mock -from troveclient import security_groups +from troveclient.v1 import security_groups """ Unit tests for security_groups.py @@ -96,6 +96,6 @@ class SecGroupRuleTest(TestCase): self.security_group_rules.api.client.delete = \ Mock(return_value=(resp, body)) self.security_group_rules.delete(self.id) - resp.status = 500 + resp.status_code = 500 self.assertRaises(Exception, self.security_group_rules.delete, self.id) diff --git a/troveclient/tests/test_users.py b/troveclient/tests/test_users.py index 59598686..5f04d380 100644 --- a/troveclient/tests/test_users.py +++ b/troveclient/tests/test_users.py @@ -1,7 +1,7 @@ from testtools import TestCase from mock import Mock -from troveclient import users +from troveclient.v1 import users from troveclient import base """ @@ -65,7 +65,7 @@ class UsersTest(TestCase): def test_create(self): self.users.api.client.post = self._get_mock_method() - self._resp.status = 200 + self._resp.status_code = 200 user = self._build_fake_user('user1') self.users.create(23, [user]) @@ -87,15 +87,15 @@ class UsersTest(TestCase): # Make sure that response of 400 is recognized as an error. user['host'] = '%' - self._resp.status = 400 + self._resp.status_code = 400 self.assertRaises(Exception, self.users.create, 12, [user]) def test_delete(self): self.users.api.client.delete = self._get_mock_method() - self._resp.status = 200 + self._resp.status_code = 200 self.users.delete(27, 'user1') self.assertEqual('/instances/27/users/user1', self._url) - self._resp.status = 400 + self._resp.status_code = 400 self.assertRaises(Exception, self.users.delete, 34, 'user1') def test__list(self): @@ -109,7 +109,7 @@ class UsersTest(TestCase): body.__getitem__ = Mock(return_value=["test-value"]) resp = Mock() - resp.status = 200 + resp.status_code = 200 self.users.resource_class = Mock(side_effect=side_effect_func) self.users.api.client.get = Mock(return_value=(resp, body)) self.assertEqual(["test-value"], self.users._list('url', key).items) diff --git a/troveclient/tests/test_xml.py b/troveclient/tests/test_xml.py index 1374e25a..4f9d8525 100644 --- a/troveclient/tests/test_xml.py +++ b/troveclient/tests/test_xml.py @@ -1,9 +1,11 @@ from testtools import TestCase from lxml import etree -from troveclient import xml +#from troveclient import xml -class XmlTest(TestCase): +# Killing this until xml support is brought back. +#class XmlTest(TestCase): +class XmlTest(object): ELEMENT = ''' diff --git a/troveclient/utils.py b/troveclient/utils.py index dd15fea6..0cb9d49e 100644 --- a/troveclient/utils.py +++ b/troveclient/utils.py @@ -12,8 +12,186 @@ # License for the specific language governing permissions and limitations # under the License. +from __future__ import print_function + import os import re +import sys +import uuid + +import six +import prettytable + +from troveclient.openstack.common.apiclient import exceptions +from troveclient.openstack.common import strutils + + +def arg(*args, **kwargs): + """Decorator for CLI args.""" + def _decorator(func): + add_arg(func, *args, **kwargs) + return func + return _decorator + + +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', '') + + +def add_arg(f, *args, **kwargs): + """Bind CLI arguments to a shell.py `do_foo` function.""" + + if not hasattr(f, 'arguments'): + f.arguments = [] + + # NOTE(sirp): avoid dups that can occur when the module is shared across + # tests. + if (args, kwargs) not in f.arguments: + # Because of the sematics of decorator composition if we just append + # to the options list positional options will appear to be backwards. + f.arguments.insert(0, (args, kwargs)) + + +def unauthenticated(f): + """ + Adds 'unauthenticated' attribute to decorated function. + Usage: + @unauthenticated + def mymethod(f): + ... + """ + f.unauthenticated = True + return f + + +def isunauthenticated(f): + """ + Checks to see if the function is marked as not requiring authentication + with the @unauthenticated decorator. Returns True if decorator is + set to True, False otherwise. + """ + return getattr(f, 'unauthenticated', False) + + +def service_type(stype): + """ + Adds 'service_type' attribute to decorated function. + Usage: + @service_type('database') + def mymethod(f): + ... + """ + def inner(f): + f.service_type = stype + return f + return inner + + +def get_service_type(f): + """ + Retrieves service type from function + """ + return getattr(f, 'service_type', None) + + +def translate_keys(collection, convert): + for item in collection: + keys = list(item.__dict__.keys()) + for from_key, to_key in convert: + if from_key in keys and to_key not in keys: + setattr(item, to_key, item._info[from_key]) + + +def _print(pt, order): + if sys.version_info >= (3, 0): + print(pt.get_string(sortby=order)) + else: + print(strutils.safe_encode(pt.get_string(sortby=order))) + + +def print_list(objs, fields, formatters={}, order_by=None): + mixed_case_fields = [] + pt = prettytable.PrettyTable([f for f in fields], caching=False) + pt.aligns = ['l' for f in fields] + + for o in objs: + row = [] + for field in fields: + if field in formatters: + row.append(formatters[field](o)) + else: + if field in mixed_case_fields: + field_name = field.replace(' ', '_') + else: + field_name = field.lower().replace(' ', '_') + data = getattr(o, field_name, '') + row.append(data) + pt.add_row(row) + + if order_by is None: + order_by = fields[0] + _print(pt, order_by) + + +def print_dict(d, property="Property"): + pt = prettytable.PrettyTable([property, 'Value'], caching=False) + pt.aligns = ['l', 'l'] + [pt.add_row(list(r)) for r in six.iteritems(d)] + _print(pt, property) + + +def find_resource(manager, name_or_id): + """Helper for the _find_* methods.""" + # first try to get entity as integer id + try: + if isinstance(name_or_id, int) or name_or_id.isdigit(): + return manager.get(int(name_or_id)) + except exceptions.NotFound: + pass + + if sys.version_info <= (3, 0): + name_or_id = strutils.safe_decode(name_or_id) + + # now try to get entity as uuid + try: + uuid.UUID(name_or_id) + return manager.get(name_or_id) + except (ValueError, exceptions.NotFound): + pass + + try: + try: + return manager.find(human_id=name_or_id) + except exceptions.NotFound: + pass + + # finally try to find entity by name + try: + return manager.find(name=name_or_id) + except exceptions.NotFound: + try: + return manager.find(display_name=name_or_id) + except (UnicodeDecodeError, exceptions.NotFound): + try: + # Instances does not have name, but display_name + return manager.find(display_name=name_or_id) + except exceptions.NotFound: + msg = "No %s with a name or ID of '%s' exists." % \ + (manager.resource_class.__name__.lower(), name_or_id) + raise exceptions.CommandError(msg) + except exceptions.NoUniqueMatch: + msg = ("Multiple %s matches found for '%s', use an ID to be more" + " specific." % (manager.resource_class.__name__.lower(), + name_or_id)) + raise exceptions.CommandError(msg) class HookableMixin(object): @@ -34,18 +212,6 @@ class HookableMixin(object): 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]+') diff --git a/troveclient/v1/__init__.py b/troveclient/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/troveclient/accounts.py b/troveclient/v1/accounts.py similarity index 97% rename from troveclient/accounts.py rename to troveclient/v1/accounts.py index ef114543..7e5a48ed 100644 --- a/troveclient/accounts.py +++ b/troveclient/v1/accounts.py @@ -58,6 +58,10 @@ class Accounts(base.ManagerWithFind): acct_name = self._get_account_name(account) return self._list("/mgmt/accounts/%s" % acct_name, 'account') + # Appease the abc gods + def list(self): + pass + @staticmethod def _get_account_name(account): try: diff --git a/troveclient/backups.py b/troveclient/v1/backups.py similarity index 97% rename from troveclient/backups.py rename to troveclient/v1/backups.py index 5d4ce513..85b79f7f 100644 --- a/troveclient/backups.py +++ b/troveclient/v1/backups.py @@ -70,5 +70,5 @@ class Backups(base.ManagerWithFind): :param backup_id: The backup id to delete """ resp, body = self.api.client.delete("/backups/%s" % backup_id) - if resp.status in (422, 500): + if resp.status_code in (422, 500): raise exceptions.from_response(resp, body) diff --git a/troveclient/v1/client.py b/troveclient/v1/client.py new file mode 100644 index 00000000..0b80e211 --- /dev/null +++ b/troveclient/v1/client.py @@ -0,0 +1,105 @@ +from troveclient import client +from troveclient.v1.databases import Databases +from troveclient.v1.flavors import Flavors +from troveclient.v1.instances import Instances +from troveclient.v1.limits import Limits +from troveclient.v1.users import Users +from troveclient.v1.root import Root +from troveclient.v1.hosts import Hosts +from troveclient.v1.quota import Quotas +from troveclient.v1.backups import Backups +from troveclient.v1.security_groups import SecurityGroups +from troveclient.v1.security_groups import SecurityGroupRules +from troveclient.v1.storage import StorageInfo +from troveclient.v1.management import Management +from troveclient.v1.management import MgmtFlavors +from troveclient.v1.accounts import Accounts +from troveclient.v1.diagnostics import DiagnosticsInterrogator +from troveclient.v1.diagnostics import HwInfoInterrogator + + +class Client(object): + """ + Top-level object to access the OpenStack Database API. + + Create an instance with your creds:: + + >>> client = Client(USERNAME, PASSWORD, PROJECT_ID, AUTH_URL) + + Then call methods on its managers:: + + >>> client.instances.list() + ... + + """ + + def __init__(self, username, password, project_id=None, auth_url='', + insecure=False, timeout=None, tenant_id=None, + proxy_tenant_id=None, proxy_token=None, region_name=None, + endpoint_type='publicURL', extensions=None, + service_type='database', service_name=None, + database_service_name=None, retries=None, + http_log_debug=False, + cacert=None): + # self.limits = limits.LimitsManager(self) + + # extensions + self.flavors = Flavors(self) + self.users = Users(self) + self.databases = Databases(self) + self.backups = Backups(self) + self.instances = Instances(self) + self.limits = Limits(self) + self.root = Root(self) + self.security_group_rules = SecurityGroupRules(self) + self.security_groups = SecurityGroups(self) + + #self.hosts = Hosts(self) + #self.quota = Quotas(self) + #self.storage = StorageInfo(self) + #self.management = Management(self) + #self.mgmt_flavor = MgmtFlavors(self) + #self.accounts = Accounts(self) + #self.diagnostics = DiagnosticsInterrogator(self) + #self.hwinfo = HwInfoInterrogator(self) + + # Add in any extensions... + if extensions: + for extension in extensions: + if extension.manager_class: + setattr(self, extension.name, + extension.manager_class(self)) + + self.client = client.HTTPClient( + username, + password, + project_id, + auth_url, + insecure=insecure, + timeout=timeout, + tenant_id=tenant_id, + proxy_token=proxy_token, + proxy_tenant_id=proxy_tenant_id, + region_name=region_name, + endpoint_type=endpoint_type, + service_type=service_type, + service_name=service_name, + database_service_name=database_service_name, + retries=retries, + http_log_debug=http_log_debug, + cacert=cacert) + + def authenticate(self): + """ + Authenticate against the server. + + Normally this is called automatically when you first access the API, + but you can call this method to force authentication right now. + + Returns on success; raises :exc:`exceptions.Unauthorized` if the + credentials are wrong. + """ + self.client.authenticate() + + def get_database_api_version_from_endpoint(self): + return self.client.get_database_api_version_from_endpoint() diff --git a/troveclient/databases.py b/troveclient/v1/databases.py similarity index 100% rename from troveclient/databases.py rename to troveclient/v1/databases.py diff --git a/troveclient/diagnostics.py b/troveclient/v1/diagnostics.py similarity index 93% rename from troveclient/diagnostics.py rename to troveclient/v1/diagnostics.py index 01f8244c..64295808 100644 --- a/troveclient/diagnostics.py +++ b/troveclient/v1/diagnostics.py @@ -37,6 +37,10 @@ class DiagnosticsInterrogator(base.ManagerWithFind): return self._get("/mgmt/instances/%s/diagnostics" % base.getid(instance), "diagnostics") + # Appease the abc gods + def list(self): + pass + class HwInfo(base.Resource): @@ -55,3 +59,7 @@ class HwInfoInterrogator(base.ManagerWithFind): Get the hardware information of the instance. """ return self._get("/mgmt/instances/%s/hwinfo" % base.getid(instance)) + + # Appease the abc gods + def list(self): + pass diff --git a/troveclient/flavors.py b/troveclient/v1/flavors.py similarity index 80% rename from troveclient/flavors.py rename to troveclient/v1/flavors.py index 34a1c61e..1bb49d9c 100644 --- a/troveclient/flavors.py +++ b/troveclient/v1/flavors.py @@ -32,15 +32,6 @@ class Flavors(base.ManagerWithFind): """ resource_class = Flavor - def __repr__(self): - return "" % id(self) - - def _list(self, url, response_key): - resp, body = self.api.client.get(url) - if not body: - raise Exception("Call to " + url + " did not return a body.") - return [self.resource_class(self, res) for res in body[response_key]] - def list(self): """ Get a list of all flavors. diff --git a/troveclient/hosts.py b/troveclient/v1/hosts.py similarity index 97% rename from troveclient/hosts.py rename to troveclient/v1/hosts.py index c44a2380..d42f6880 100644 --- a/troveclient/hosts.py +++ b/troveclient/v1/hosts.py @@ -76,3 +76,7 @@ class Hosts(base.ManagerWithFind): return host.name except AttributeError: return host + + # Appease the abc gods + def list(self): + pass diff --git a/troveclient/instances.py b/troveclient/v1/instances.py similarity index 99% rename from troveclient/instances.py rename to troveclient/v1/instances.py index 27ad9756..3b12f023 100644 --- a/troveclient/instances.py +++ b/troveclient/v1/instances.py @@ -127,7 +127,7 @@ class Instances(base.ManagerWithFind): """ resp, body = self.api.client.delete("/instances/%s" % base.getid(instance)) - if resp.status in (422, 500): + if resp.status_code in (422, 500): raise exceptions.from_response(resp, body) def _action(self, instance_id, body): diff --git a/troveclient/limits.py b/troveclient/v1/limits.py similarity index 96% rename from troveclient/limits.py rename to troveclient/v1/limits.py index 51575da5..88de1a0e 100644 --- a/troveclient/limits.py +++ b/troveclient/v1/limits.py @@ -35,7 +35,7 @@ class Limits(base.ManagerWithFind): def _list(self, url, response_key): resp, body = self.api.client.get(url) - if resp is None or resp.status != 200: + if resp is None or resp.status_code != 200: raise exceptions.from_response(resp, body) if not body: diff --git a/troveclient/management.py b/troveclient/v1/management.py similarity index 96% rename from troveclient/management.py rename to troveclient/v1/management.py index 14503daf..0aabe21e 100644 --- a/troveclient/management.py +++ b/troveclient/v1/management.py @@ -19,8 +19,8 @@ import urlparse from troveclient.common import check_for_exceptions from troveclient.common import limit_url from troveclient.common import Paginated -from troveclient.instances import Instance -from troveclient.flavors import Flavor +from troveclient.v1.instances import Instance +from troveclient.v1.flavors import Flavor class RootHistory(base.Resource): @@ -35,6 +35,10 @@ class Management(base.ManagerWithFind): """ resource_class = Instance + # Appease the abc gods + def list(self): + pass + def _list(self, url, response_key, limit=None, marker=None): resp, body = self.api.client.get(limit_url(url, limit, marker)) if not body: @@ -146,6 +150,10 @@ class MgmtFlavors(base.ManagerWithFind): def __repr__(self): return "" % id(self) + # Appease the abc gods + def list(self): + pass + def create(self, name, ram, disk, vcpus, flavorid="auto", ephemeral=None, swap=None, rxtx_factor=None, service_type=None): diff --git a/troveclient/quota.py b/troveclient/v1/quota.py similarity index 96% rename from troveclient/quota.py rename to troveclient/v1/quota.py index 4cc4d866..fdf27d39 100644 --- a/troveclient/quota.py +++ b/troveclient/v1/quota.py @@ -49,3 +49,7 @@ class Quotas(base.ManagerWithFind): if 'quotas' not in body: raise Exception("Missing key value 'quotas' in response body.") return body['quotas'] + + # Appease the abc gods + def list(self): + pass diff --git a/troveclient/root.py b/troveclient/v1/root.py similarity index 94% rename from troveclient/root.py rename to troveclient/v1/root.py index b245fab1..247332b3 100644 --- a/troveclient/root.py +++ b/troveclient/v1/root.py @@ -15,7 +15,7 @@ from troveclient import base -from troveclient import users +from troveclient.v1 import users from troveclient.common import check_for_exceptions @@ -41,3 +41,7 @@ class Root(base.ManagerWithFind): resp, body = self.api.client.get(self.url % instance_id) check_for_exceptions(resp, body) return self.resource_class(self, body, loaded=True) + + # Appease the abc gods + def list(self): + pass diff --git a/troveclient/security_groups.py b/troveclient/v1/security_groups.py similarity index 97% rename from troveclient/security_groups.py rename to troveclient/v1/security_groups.py index caece795..5edda16d 100644 --- a/troveclient/security_groups.py +++ b/troveclient/v1/security_groups.py @@ -116,5 +116,9 @@ class SecurityGroupRules(base.ManagerWithFind): """ resp, body = self.api.client.delete("/security-group-rules/%s" % base.getid(security_group_rule)) - if resp.status in (422, 500): + if resp.status_code in (422, 500): raise exceptions.from_response(resp, body) + + # Appease the abc gods + def list(self): + pass diff --git a/troveclient/v1/shell.py b/troveclient/v1/shell.py new file mode 100644 index 00000000..b8ce02de --- /dev/null +++ b/troveclient/v1/shell.py @@ -0,0 +1,492 @@ +from __future__ import print_function + +import argparse +import copy +import os +import sys +import time + +from troveclient import exceptions +from troveclient import utils + + +def _poll_for_status(poll_fn, obj_id, action, final_ok_states, + poll_period=5, show_progress=True): + """Block while an action is being performed, periodically printing + progress. + """ + def print_progress(progress): + if show_progress: + msg = ('\rInstance %(action)s... %(progress)s%% complete' + % dict(action=action, progress=progress)) + else: + msg = '\rInstance %(action)s...' % dict(action=action) + + sys.stdout.write(msg) + sys.stdout.flush() + + print() + while True: + obj = poll_fn(obj_id) + status = obj.status.lower() + progress = getattr(obj, 'progress', None) or 0 + if status in final_ok_states: + print_progress(100) + print("\nFinished") + break + elif status == "error": + print("\nError %(action)s instance" % {'action': action}) + break + else: + print_progress(progress) + time.sleep(poll_period) + + +def _print_instance(instance): + # Get rid of those ugly links + if instance._info.get('links'): + del(instance._info['links']) + utils.print_dict(instance._info) + + +def _find_instance(cs, instance): + """Get a instance by ID.""" + return utils.find_resource(cs.instances, instance) + + +def _find_flavor(cs, flavor): + """Get a flavor by ID.""" + return utils.find_resource(cs.flavors, flavor) + + +def _find_backup(cs, backup): + """Gets a backup by ID.""" + return utils.find_resource(cs.backups, backup) + + +# Flavor related calls + +@utils.service_type('database') +def do_list_flavors(cs, args): + """Lists available flavors.""" + flavors = cs.flavors.list() + utils.print_list(flavors, ['id', 'name', 'ram']) + + +@utils.arg('flavor', metavar='', help='ID of the flavor.') +@utils.service_type('database') +def do_show_flavor(cs, args): + """Show details of a flavor.""" + flavor = _find_flavor(cs, args.flavor) + _print_instance(flavor) + + +# Instance related calls + +@utils.service_type('database') +def do_list(cs, args): + """List all the instances.""" + instances = cs.instances.list() + + for instance in instances: + setattr(instance, 'flavor_id', instance.flavor['id']) + if hasattr(instance, 'volume'): + setattr(instance, 'size', instance.volume['size']) + utils.print_list(instances, ['id', 'name', 'status', 'flavor_id', 'size']) + + +@utils.arg('instance', metavar='', help='ID of the instance.') +@utils.service_type('database') +def do_show(cs, args): + """Show details of an instance.""" + instance = _find_instance(cs, args.instance) + instance._info['flavor'] = instance.flavor['id'] + if hasattr(instance, 'volume'): + instance._info['volume'] = instance.volume['size'] + + _print_instance(instance) + + +@utils.arg('instance', metavar='', help='ID of the instance.') +@utils.service_type('database') +def do_delete(cs, args): + """Deletes an instance.""" + cs.instances.delete(args.instance) + + +@utils.arg('name', + metavar='', + type=str, + help='Name of the instance') +@utils.arg('--size', + metavar='', + type=int, + default=None, + help='Size of the instance disk in GB') +@utils.arg('flavor_id', + metavar='', + help='Flavor of the instance') +@utils.arg('--databases', metavar='', + help='Optional list of databases.', + nargs="+", default=[]) +@utils.arg('--users', metavar='', + help='Optional list of users in the form user:password.', + nargs="+", default=[]) +@utils.arg('--backup', + metavar='', + default=None, + help='A backup UUID') +@utils.arg('--availability_zone', + metavar='', + default=None, + help='The Zone hint to give to nova') +@utils.service_type('database') +def do_create(cs, args): + """Add a new instance.""" + volume = None + if args.size: + volume = {"size": args.size} + restore_point = None + if args.backup: + restore_point = {"backupRef": self.backup} + databases = [{'name': value} for value in args.databases] + users = [{'name': n, 'password': p} for (n, p) in + [z.split(':')[:2] for z in args.users]] + instance = cs.instances.create(args.name, + args.flavor_id, + volume=volume, + databases=databases, + users=users, + restorePoint=restore_point, + availability_zone=args.availability_zone) + instance._info['flavor'] = instance.flavor['id'] + if hasattr(instance, 'volume'): + instance._info['volume'] = instance.volume['size'] + del(instance._info['links']) + + _print_instance(instance) + + +@utils.arg('instance', + metavar='', + type=str, + help='UUID of the instance') +@utils.arg('flavor_id', + metavar='', + help='Flavor of the instance') +def resize_flavor(cs, args): + """Resizes the flavor of an instance.""" + cs.instances.resize_flavor(args.instance, args.flavor_id) + + +@utils.arg('instance', + metavar='', + type=str, + help='UUID of the instance') +@utils.arg('size', + metavar='', + type=int, + default=None, + help='Size of the instance disk in GB') +def resize_volume(cs, args): + """Resizes the volume size of an instance.""" + cs.instances.resize_volume(args.instance, args.size) + + +@utils.arg('instance', + metavar='', + type=str, + help='UUID of the instance') +def restart(cs, args): + """Restarts the instance.""" + cs.instances.restart(args.instance) + + +# Backup related commands + +@utils.arg('backup', metavar='', help='ID of the backup.') +@utils.service_type('database') +def do_show_backup(cs, args): + """Show details of a backup.""" + backups = _find_backup(args.backup) + _print_instance(backup) + + +@utils.arg('instance', metavar='', help='ID of the instance.') +@utils.service_type('database') +def do_list_instance_backups(cs, args): + """List available backups for an instance.""" + backups = cs.instances.backups(args.instance) + utils.print_list(backups, ['id', 'instance_id', + 'name', 'description', 'status']) + + +@utils.service_type('database') +def do_list_backups(cs, args): + """List available backups.""" + backups = cs.backups.list() + utils.print_list(backups, ['id', 'instance_id', + 'name', 'description', 'status']) + + +@utils.arg('backup', metavar='', help='ID of the backup.') +@utils.service_type('database') +def do_delete_backup(cs, args): + """Deletes a backup.""" + cs.backups.delete(args.backup) + + +@utils.arg('name', metavar='', help='Name of the backup.') +@utils.arg('instance', metavar='', help='UUID of the instance.') +@utils.arg('--description', metavar='', + default=None, + help='An optional description for the backup.') +@utils.service_type('database') +def do_create_backup(cs, args): + """Deletes a backup.""" + backup = cs.backups.create(args.name, args.instance, + description=args.description) + _print_instance(backup) + + +# Database related actions + +@utils.arg('instance', metavar='', help='UUID of the instance.') +@utils.arg('name', metavar='', help='Name of the backup.') +@utils.arg('--character_set', metavar='', + default=None, + help='Optional character set for database') +@utils.arg('--collate', metavar='', default=None, + help='Optional collation type for database') +@utils.service_type('database') +def do_create_database(cs, args): + """Creates a database on an instance.""" + database_dict = {'name': args.name} + if args.collate: + database_dict['collate'] = args.collate + if args.character_set: + database_dict['character_set'] = args.character_set + cs.databases.create(args.instance, + [database_dict]) + + +@utils.arg('instance', metavar='', help='UUID of the instance.') +@utils.service_type('database') +def do_list_databases(cs, args): + """Lists available databases on an instance.""" + wrapper = cs.databases.list(args.instance) + databases = wrapper.items + while (wrapper.next): + wrapper = cs.databases.list(args.instance, marker=wrapper.next) + databases = wrapper.items + + utils.print_list(databases, ['name']) + + +@utils.arg('instance', metavar='', help='UUID of the instance.') +@utils.arg('database', metavar='', help='Name of the database.') +@utils.service_type('database') +def do_delete_database(cs, args): + """Deletes a database.""" + cs.databases.delete(args.instance, args.database) + + +# User related actions + +@utils.arg('instance', metavar='', help='UUID of the instance.') +@utils.arg('name', metavar='', help='Name of user') +@utils.arg('password', metavar='', help='Password of user') +@utils.arg('--host', metavar='', default=None, + help='Optional host of user') +@utils.arg('--databases', metavar='', + help='Optional list of databases.', + nargs="+", default=[]) +@utils.service_type('database') +def do_create_user(cs, args): + """Creates a user.""" + databases = [{'name': value} for value in args.databases] + user = {'name': args.name, 'password': args.password, + 'databases': databases} + if args.host: + user['host'] = args.host + cs.users.create(args.instance, [user]) + + +@utils.arg('instance', metavar='', help='UUID of the instance.') +@utils.service_type('database') +def do_list_users(cs, args): + """Lists the users for a instance.""" + wrapper = cs.users.list(args.instance) + users = wrapper.items + while (wrapper.next): + wrapper = cs.users.list(args.instance, marker=wrapper.next) + users += wrapper.items + + utils.print_list(users, ['name', 'host', 'databases']) + + +@utils.arg('instance', metavar='', help='UUID of the instance.') +@utils.arg('name', metavar='', help='Name of user') +@utils.arg('--host', metavar='', default=None, + help='Optional host of user') +@utils.service_type('database') +def do_delete_user(cs, args): + """Deletes a user from the instance.""" + cs.users.delete(args.instance, args.name, hostname=args.host) + + +@utils.arg('instance', metavar='', help='UUID of the instance.') +@utils.arg('name', metavar='', help='Name of user') +@utils.arg('--host', metavar='', default=None, + help='Optional host of user') +@utils.service_type('database') +# Quoting is not working now that we arent using httplib2 +# anymore and instead are using requests +def do_get_user(cs, args): + """Gets a user from the instance.""" + user = cs.users.get(args.instance, args.name, hostname=args.host) + _print_instance(user) + + +@utils.arg('instance', metavar='', help='UUID of the instance.') +@utils.arg('name', metavar='', help='Name of user') +@utils.arg('--host', metavar='', default=None, + help='Optional host of user') +@utils.service_type('database') +# Quoting is not working now that we arent using httplib2 +# anymore and instead are using requests +def do_get_user_access(cs, args): + """Gets a users access from the instance.""" + access = cs.users.list_access(args.instance, args.name, hostname=args.host) + utils.print_list(access, ['name']) + + +@utils.arg('instance', metavar='', help='UUID of the instance.') +@utils.arg('name', metavar='', help='Name of user') +@utils.arg('--host', metavar='', default=None, + help='Optional host of user') +@utils.arg('--new_name', metavar='', default=None, + help='Optional new name of user') +@utils.arg('--new_password', metavar='', default=None, + help='Optional new password of user') +@utils.arg('--new_host', metavar='', default=None, + help='Optional new host of user') +@utils.service_type('database') +# Quoting is not working now that we arent using httplib2 +# anymore and instead are using requests +def do_update_user_attributes(cs, args): + """Updates a users attributes from the instance.""" + new_attrs = {} + if args.new_name: + new_attrs['name'] = args.new_name + if args.new_password: + new_attrs['password'] = args.new_password + if args.new_host: + new_attrs['host'] = args.new_host + cs.users.update_attributes(args.instance, args.name, + newuserattr=new_attrs, hostname=args.host) + + +@utils.arg('instance', metavar='', help='UUID of the instance.') +@utils.arg('name', metavar='', help='Name of user') +@utils.arg('--host', metavar='', default=None, + help='Optional host of user') +@utils.arg('databases', metavar='', + help='List of databases.', + nargs="+", default=[]) +@utils.service_type('database') +def do_grant_user_access(cs, args): + """Grants access to a atabase(s) for a user.""" + cs.users.grant(args.instance, args.name, + args.databases, hostname=args.host) + + +@utils.arg('instance', metavar='', help='UUID of the instance.') +@utils.arg('name', metavar='', help='Name of user') +@utils.arg('database', metavar='', help='A single database.') +@utils.arg('--host', metavar='', default=None, + help='Optional host of user') +@utils.service_type('database') +def do_revoke_user_access(cs, args): + """Revokes access to a database for a user.""" + cs.users.revoke(args.instance, args.name, + args.database, hostname=args.host) + + +# Limits related commands + +@utils.service_type('database') +def do_list_limits(cs, args): + """Lists the limits for a tenant.""" + limits = cs.limits.list() + # Pop the first one, its absolute limits + absolute = limits.pop(0) + _print_instance(absolute) + utils.print_list(limits, ['value', 'verb', 'remaining', 'unit']) + + +# Root related commands + +@utils.arg('instance', metavar='', help='UUID of the instance.') +@utils.service_type('database') +def do_enable_root(cs, args): + """Enables root for a instance.""" + root = cs.root.create(args.instance) + utils.print_dict({'name': root[0], 'password': root[1]}) + + +@utils.arg('instance', metavar='', help='UUID of the instance.') +@utils.service_type('database') +def do_get_root(cs, args): + """Gets root enabled status for a instance.""" + root = cs.root.is_root_enabled(args.instance) + utils.print_dict({'is_root_enabled': root.rootEnabled}) + + +# security group related functions + +@utils.service_type('database') +def do_list_security_groups(cs, args): + """Lists all security gropus.""" + wrapper = cs.security_groups.list() + sec_grps = wrapper.items + while (wrapper.next): + wrapper = cs.security_groups.list() + sec_grps += wrapper.items + + utils.print_list(sec_grps, ['id', 'name', 'rules', 'instance_id']) + + +@utils.arg('security_group', metavar='', + help='ID of the security group.') +@utils.service_type('database') +def do_show_security_group(cs, args): + """Shows details about a security group.""" + sec_grp = cs.security_groups.get(args.security_group) + _print_instance(sec_grp) + + +@utils.arg('security_group', metavar='', + help='Security group name') +@utils.arg('protocol', metavar='', help='Protocol') +@utils.arg('from_port', metavar='', help='from port') +@utils.arg('to_port', metavar='', help='to port') +@utils.arg('cidr', metavar='', help='CIDR address') +@utils.service_type('database') +def do_create_security_group_rule(cs, args): + """Creates a security group rule.""" + rule = cs.security_group_rules.create(args.security_group, + args.protocol, + args.from_port, + args.to_port, + args.cidr) + + _print_instance(rule) + + +@utils.arg('security_group_rule', metavar='', + help='Security group rule') +@utils.service_type('database') +def do_delete_security_group_rule(cs, args): + """Deletes a security group rule.""" + cs.security_group_rules.delete(args.security_group_rule) diff --git a/troveclient/storage.py b/troveclient/v1/storage.py similarity index 95% rename from troveclient/storage.py rename to troveclient/v1/storage.py index c7cbf094..72424c50 100644 --- a/troveclient/storage.py +++ b/troveclient/v1/storage.py @@ -43,3 +43,7 @@ class StorageInfo(base.ManagerWithFind): :rtype: list of :class:`Storages`. """ return self._list("/mgmt/storage", "devices") + + # Appease the abc gods + def list(self): + pass diff --git a/troveclient/users.py b/troveclient/v1/users.py similarity index 99% rename from troveclient/users.py rename to troveclient/v1/users.py index f77a8c93..f04ca34a 100644 --- a/troveclient/users.py +++ b/troveclient/v1/users.py @@ -14,7 +14,7 @@ # under the License. from troveclient import base -from troveclient import databases +from troveclient.v1 import databases from troveclient.common import check_for_exceptions from troveclient.common import limit_url from troveclient.common import Paginated