From 635bc921dbf0fe0c65d5ec84a6cd327e83550e40 Mon Sep 17 00:00:00 2001 From: Angus Salkeld Date: Fri, 9 Nov 2012 23:31:27 +1100 Subject: [PATCH] Add basic functionality Signed-off-by: Angus Salkeld --- ceilometerclient/__init__.py | 31 ++ ceilometerclient/client.py | 19 + ceilometerclient/common/__init__.py | 0 ceilometerclient/common/base.py | 131 ++++++ ceilometerclient/common/http.py | 276 +++++++++++++ ceilometerclient/common/utils.py | 122 ++++++ ceilometerclient/exc.py | 163 ++++++++ ceilometerclient/openstack/__init__.py | 0 ceilometerclient/openstack/common/__init__.py | 0 .../openstack/common/importutils.py | 59 +++ ceilometerclient/openstack/common/setup.py | 366 ++++++++++++++++ ceilometerclient/openstack/common/version.py | 148 +++++++ ceilometerclient/shell.py | 335 +++++++++++++++ ceilometerclient/v1/__init__.py | 16 + ceilometerclient/v1/client.py | 37 ++ ceilometerclient/v1/meters.py | 143 +++++++ ceilometerclient/v1/shell.py | 109 +++++ ceilometerclient/version.py | 19 + setup.py | 2 +- tests/__init__.py | 0 tests/fakes.py | 80 ++++ tests/test_shell.py | 390 ++++++++++++++++++ tests/v1/__init__.py | 0 tests/v1/test_shell.py | 31 ++ 24 files changed, 2476 insertions(+), 1 deletion(-) create mode 100644 ceilometerclient/__init__.py create mode 100644 ceilometerclient/client.py create mode 100644 ceilometerclient/common/__init__.py create mode 100644 ceilometerclient/common/base.py create mode 100644 ceilometerclient/common/http.py create mode 100644 ceilometerclient/common/utils.py create mode 100644 ceilometerclient/exc.py create mode 100644 ceilometerclient/openstack/__init__.py create mode 100644 ceilometerclient/openstack/common/__init__.py create mode 100644 ceilometerclient/openstack/common/importutils.py create mode 100644 ceilometerclient/openstack/common/setup.py create mode 100644 ceilometerclient/openstack/common/version.py create mode 100644 ceilometerclient/shell.py create mode 100644 ceilometerclient/v1/__init__.py create mode 100644 ceilometerclient/v1/client.py create mode 100644 ceilometerclient/v1/meters.py create mode 100644 ceilometerclient/v1/shell.py create mode 100644 ceilometerclient/version.py create mode 100644 tests/__init__.py create mode 100644 tests/fakes.py create mode 100644 tests/test_shell.py create mode 100644 tests/v1/__init__.py create mode 100644 tests/v1/test_shell.py diff --git a/ceilometerclient/__init__.py b/ceilometerclient/__init__.py new file mode 100644 index 00000000..53345eed --- /dev/null +++ b/ceilometerclient/__init__.py @@ -0,0 +1,31 @@ +# 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 inspect +import os + + +def _get_ceilometerclient_version(): + """Read version from versioninfo file.""" + mod_abspath = inspect.getabsfile(inspect.currentframe()) + ceilometerclient_path = os.path.dirname(mod_abspath) + version_path = os.path.join(ceilometerclient_path, 'versioninfo') + + if os.path.exists(version_path): + version = open(version_path).read().strip() + else: + version = "Unknown, couldn't find versioninfo file at %s"\ + % version_path + + return version + + +__version__ = _get_ceilometerclient_version() diff --git a/ceilometerclient/client.py b/ceilometerclient/client.py new file mode 100644 index 00000000..b1a6d27d --- /dev/null +++ b/ceilometerclient/client.py @@ -0,0 +1,19 @@ +# 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 ceilometerclient.common import utils + + +def Client(version, *args, **kwargs): + module = utils.import_versioned_module(version, 'client') + client_class = getattr(module, 'Client') + return client_class(*args, **kwargs) diff --git a/ceilometerclient/common/__init__.py b/ceilometerclient/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ceilometerclient/common/base.py b/ceilometerclient/common/base.py new file mode 100644 index 00000000..3cedd32a --- /dev/null +++ b/ceilometerclient/common/base.py @@ -0,0 +1,131 @@ +# Copyright 2012 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Base utilities to build API operation managers and objects on top of. +""" + +import copy + + +# 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 + (UUID) as a parameter when dealing with relationships. + """ + try: + return obj.id + except AttributeError: + return obj + + +class Manager(object): + """ + 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, body = self.api.json_request('GET', url) + + if obj_class is None: + obj_class = self.resource_class + + data = body[response_key] + return [obj_class(self, res, loaded=True) for res in data if res] + + def _delete(self, url): + self.api.raw_request('DELETE', url) + + def _update(self, url, body, response_key=None): + resp, body = self.api.json_request('PUT', url, body=body) + # PUT requests may not return a body + if body: + return self.resource_class(self, body[response_key]) + + +class Resource(object): + """ + A resource represents a particular instance of an object (tenant, user, + 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 + """ + def __init__(self, manager, info, loaded=False): + self.manager = manager + self._info = info + self._add_details(info) + self._loaded = loaded + + def _add_details(self, info): + for (k, v) in info.iteritems(): + setattr(self, k, v) + + 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 + + def to_dict(self): + return copy.deepcopy(self._info) diff --git a/ceilometerclient/common/http.py b/ceilometerclient/common/http.py new file mode 100644 index 00000000..2c615367 --- /dev/null +++ b/ceilometerclient/common/http.py @@ -0,0 +1,276 @@ +# Copyright 2012 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import copy +import httplib +import logging +import os +import socket +import StringIO +import urlparse + +try: + import ssl +except ImportError: + #TODO(bcwaldon): Handle this failure more gracefully + pass + +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 ceilometerclient import exc + + +LOG = logging.getLogger(__name__) +USER_AGENT = 'python-ceilometerclient' +CHUNKSIZE = 1024 * 64 # 64kB + + +class HTTPClient(object): + + def __init__(self, endpoint, **kwargs): + self.endpoint = endpoint + self.auth_token = kwargs.get('token') + self.connection_params = self.get_connection_params(endpoint, **kwargs) + + @staticmethod + def get_connection_params(endpoint, **kwargs): + parts = urlparse.urlparse(endpoint) + + _args = (parts.hostname, parts.port, parts.path) + _kwargs = {'timeout': float(kwargs.get('timeout', 600))} + + if parts.scheme == 'https': + _class = VerifiedHTTPSConnection + _kwargs['ca_file'] = kwargs.get('ca_file', None) + _kwargs['cert_file'] = kwargs.get('cert_file', None) + _kwargs['key_file'] = kwargs.get('key_file', None) + _kwargs['insecure'] = kwargs.get('insecure', False) + elif parts.scheme == 'http': + _class = httplib.HTTPConnection + else: + msg = 'Unsupported scheme: %s' % parts.scheme + raise exc.InvalidEndpoint(msg) + + return (_class, _args, _kwargs) + + def get_connection(self): + _class = self.connection_params[0] + try: + return _class(*self.connection_params[1], + **self.connection_params[2]) + except httplib.InvalidURL: + raise exc.InvalidEndpoint() + + def log_curl_request(self, method, url, kwargs): + curl = ['curl -i -X %s' % method] + + for (key, value) in kwargs['headers'].items(): + header = '-H \'%s: %s\'' % (key, value) + curl.append(header) + + conn_params_fmt = [ + ('key_file', '--key %s'), + ('cert_file', '--cert %s'), + ('ca_file', '--cacert %s'), + ] + for (key, fmt) in conn_params_fmt: + value = self.connection_params[2].get(key) + if value: + curl.append(fmt % value) + + if self.connection_params[2].get('insecure'): + curl.append('-k') + + if 'body' in kwargs: + curl.append('-d \'%s\'' % kwargs['body']) + + curl.append('%s%s' % (self.endpoint, url)) + LOG.debug(' '.join(curl)) + + @staticmethod + def log_http_response(resp, body=None): + status = (resp.version / 10.0, resp.status, resp.reason) + dump = ['\nHTTP/%.1f %s %s' % status] + dump.extend(['%s: %s' % (k, v) for k, v in resp.getheaders()]) + dump.append('') + if body: + dump.extend([body, '']) + LOG.debug('\n'.join(dump)) + + def _http_request(self, url, method, **kwargs): + """ Send an http request with the specified characteristics. + + Wrapper around httplib.HTTP(S)Connection.request to handle tasks such + as setting headers and error handling. + """ + # Copy the kwargs so we can reuse the original in case of redirects + kwargs['headers'] = copy.deepcopy(kwargs.get('headers', {})) + kwargs['headers'].setdefault('User-Agent', USER_AGENT) + if self.auth_token: + kwargs['headers'].setdefault('X-Auth-Token', self.auth_token) + + self.log_curl_request(method, url, kwargs) + conn = self.get_connection() + + try: + conn_params = self.connection_params[1][2] + conn_url = os.path.normpath('%s/%s' % (conn_params, url)) + conn.request(method, conn_url, **kwargs) + resp = conn.getresponse() + except socket.gaierror as e: + message = "Error finding address for %(url)s: %(e)s" % locals() + raise exc.InvalidEndpoint(message=message) + except (socket.error, socket.timeout) as e: + endpoint = self.endpoint + message = "Error communicating with %(endpoint)s %(e)s" % locals() + raise exc.CommunicationError(message=message) + + body_iter = ResponseBodyIterator(resp) + + # Read body into string if it isn't obviously image data + if resp.getheader('content-type', None) != 'application/octet-stream': + body_str = ''.join([chunk for chunk in body_iter]) + self.log_http_response(resp, body_str) + body_iter = StringIO.StringIO(body_str) + else: + self.log_http_response(resp) + + if 400 <= resp.status < 600: + LOG.warn("Request returned failure status.") + raise exc.from_response(resp) + elif resp.status in (301, 302, 305): + # Redirected. Reissue the request to the new location. + return self._http_request(resp['location'], method, **kwargs) + elif resp.status == 300: + raise exc.from_response(resp) + + return resp, body_iter + + def json_request(self, method, url, **kwargs): + kwargs.setdefault('headers', {}) + kwargs['headers'].setdefault('Content-Type', 'application/json') + kwargs['headers'].setdefault('Accept', 'application/json') + + if 'body' in kwargs: + kwargs['body'] = json.dumps(kwargs['body']) + + resp, body_iter = self._http_request(url, method, **kwargs) + + if 'application/json' in resp.getheader('content-type', None): + body = ''.join([chunk for chunk in body_iter]) + try: + body = json.loads(body) + except ValueError: + LOG.error('Could not decode response body as JSON') + else: + body = None + + return resp, body + + def raw_request(self, method, url, **kwargs): + kwargs.setdefault('headers', {}) + kwargs['headers'].setdefault('Content-Type', + 'application/octet-stream') + return self._http_request(url, method, **kwargs) + + +class VerifiedHTTPSConnection(httplib.HTTPSConnection): + """httplib-compatibile connection using client-side SSL authentication + + :see http://code.activestate.com/recipes/ + 577548-https-httplib-client-connection-with-certificate-v/ + """ + + def __init__(self, host, port, key_file=None, cert_file=None, + ca_file=None, timeout=None, insecure=False): + httplib.HTTPSConnection.__init__(self, host, port, key_file=key_file, + cert_file=cert_file) + self.key_file = key_file + self.cert_file = cert_file + if ca_file is not None: + self.ca_file = ca_file + else: + self.ca_file = self.get_system_ca_file() + self.timeout = timeout + self.insecure = insecure + + def connect(self): + """ + Connect to a host on a given (SSL) port. + If ca_file is pointing somewhere, use it to check Server Certificate. + + Redefined/copied and extended from httplib.py:1105 (Python 2.6.x). + This is needed to pass cert_reqs=ssl.CERT_REQUIRED as parameter to + ssl.wrap_socket(), which forces SSL to check server certificate against + our client certificate. + """ + sock = socket.create_connection((self.host, self.port), self.timeout) + + if self._tunnel_host: + self.sock = sock + self._tunnel() + + if self.insecure is True: + kwargs = {'cert_reqs': ssl.CERT_NONE} + else: + kwargs = {'cert_reqs': ssl.CERT_REQUIRED, 'ca_certs': self.ca_file} + + if self.cert_file: + kwargs['certfile'] = self.cert_file + if self.key_file: + kwargs['keyfile'] = self.key_file + + self.sock = ssl.wrap_socket(sock, **kwargs) + + @staticmethod + def get_system_ca_file(): + """"Return path to system default CA file""" + # Standard CA file locations for Debian/Ubuntu, RedHat/Fedora, + # Suse, FreeBSD/OpenBSD + ca_path = ['/etc/ssl/certs/ca-certificates.crt', + '/etc/pki/tls/certs/ca-bundle.crt', + '/etc/ssl/ca-bundle.pem', + '/etc/ssl/cert.pem'] + for ca in ca_path: + if os.path.exists(ca): + return ca + return None + + +class ResponseBodyIterator(object): + """A class that acts as an iterator over an HTTP response.""" + + def __init__(self, resp): + self.resp = resp + + def __iter__(self): + while True: + yield self.next() + + def next(self): + chunk = self.resp.read(CHUNKSIZE) + if chunk: + return chunk + else: + raise StopIteration() diff --git a/ceilometerclient/common/utils.py b/ceilometerclient/common/utils.py new file mode 100644 index 00000000..2703b7a3 --- /dev/null +++ b/ceilometerclient/common/utils.py @@ -0,0 +1,122 @@ +# Copyright 2012 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import errno +import hashlib +import os +import sys +import uuid + +import prettytable + +from ceilometerclient import exc +from ceilometerclient.openstack.common import importutils + + +# Decorator for cli-args +def arg(*args, **kwargs): + def _decorator(func): + # Because of the sematics of decorator composition if we just append + # to the options list positional options will appear to be backwards. + func.__dict__.setdefault('arguments', []).insert(0, (args, kwargs)) + return func + return _decorator + + +def pretty_choice_list(l): + return ', '.join("'%s'" % i for i in l) + + +def print_list(objs, fields, field_labels, formatters={}, sortby=0): + pt = prettytable.PrettyTable([f for f in field_labels], caching=False) + pt.align = 'l' + + for o in objs: + row = [] + for field in fields: + if field in formatters: + row.append(formatters[field](o)) + else: + data = getattr(o, field, None) or '' + row.append(data) + pt.add_row(row) + print pt.get_string(sortby=field_labels[sortby]) + + +def print_dict(d, formatters={}): + pt = prettytable.PrettyTable(['Property', 'Value'], caching=False) + pt.align = 'l' + + for field in d.keys(): + if field in formatters: + pt.add_row([field, formatters[field](d[field])]) + else: + pt.add_row([field, d[field]]) + print pt.get_string(sortby='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 exc.NotFound: + pass + + # now try to get entity as uuid + try: + uuid.UUID(str(name_or_id)) + return manager.get(name_or_id) + except (ValueError, exc.NotFound): + pass + + # finally try to find entity by name + try: + return manager.find(name=name_or_id) + except exc.NotFound: + msg = "No %s with a name or ID of '%s' exists." % \ + (manager.resource_class.__name__.lower(), name_or_id) + raise exc.CommandError(msg) + + +def string_to_bool(arg): + return arg.strip().lower() in ('t', 'true', 'yes', '1') + + +def env(*vars, **kwargs): + """Search for the first defined of possibly many env vars + + Returns the first environment variable defined in vars, or + returns the default defined in kwargs. + """ + for v in vars: + value = os.environ.get(v, None) + if value: + return value + return kwargs.get('default', '') + + +def import_versioned_module(version, submodule=None): + module = 'ceilometerclient.v%s' % version + if submodule: + module = '.'.join((module, submodule)) + return importutils.import_module(module) + + +def exit(msg=''): + if msg: + print >> sys.stderr, msg + sys.exit(1) diff --git a/ceilometerclient/exc.py b/ceilometerclient/exc.py new file mode 100644 index 00000000..d3d3cab6 --- /dev/null +++ b/ceilometerclient/exc.py @@ -0,0 +1,163 @@ +# 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 sys + + +class BaseException(Exception): + """An error occurred.""" + def __init__(self, message=None): + self.message = message + + def __str__(self): + return self.message or self.__class__.__doc__ + + +class CommandError(BaseException): + """Invalid usage of CLI""" + + +class InvalidEndpoint(BaseException): + """The provided endpoint is invalid.""" + + +class CommunicationError(BaseException): + """Unable to communicate with server.""" + + +class ClientException(Exception): + """DEPRECATED""" + + +class HTTPException(ClientException): + """Base exception for all HTTP-derived exceptions""" + code = 'N/A' + + def __init__(self, details=None): + self.details = details + + def __str__(self): + return "%s (HTTP %s)" % (self.__class__.__name__, self.code) + + +class HTTPMultipleChoices(HTTPException): + code = 300 + + def __str__(self): + self.details = ("Requested version of OpenStack Images API is not" + "available.") + return "%s (HTTP %s) %s" % (self.__class__.__name__, self.code, + self.details) + + +class BadRequest(HTTPException): + """DEPRECATED""" + code = 400 + + +class HTTPBadRequest(BadRequest): + pass + + +class Unauthorized(HTTPException): + """DEPRECATED""" + code = 401 + + +class HTTPUnauthorized(Unauthorized): + pass + + +class Forbidden(HTTPException): + """DEPRECATED""" + code = 403 + + +class HTTPForbidden(Forbidden): + pass + + +class NotFound(HTTPException): + """DEPRECATED""" + code = 404 + + +class HTTPNotFound(NotFound): + pass + + +class HTTPMethodNotAllowed(HTTPException): + code = 405 + + +class Conflict(HTTPException): + """DEPRECATED""" + code = 409 + + +class HTTPConflict(Conflict): + pass + + +class OverLimit(HTTPException): + """DEPRECATED""" + code = 413 + + +class HTTPOverLimit(OverLimit): + pass + + +class HTTPInternalServerError(HTTPException): + code = 500 + + +class HTTPNotImplemented(HTTPException): + code = 501 + + +class HTTPBadGateway(HTTPException): + code = 502 + + +class ServiceUnavailable(HTTPException): + """DEPRECATED""" + code = 503 + + +class HTTPServiceUnavailable(ServiceUnavailable): + pass + + +#NOTE(bcwaldon): Build a mapping of HTTP codes to corresponding exception +# classes +_code_map = {} +for obj_name in dir(sys.modules[__name__]): + if obj_name.startswith('HTTP'): + obj = getattr(sys.modules[__name__], obj_name) + _code_map[obj.code] = obj + + +def from_response(response): + """Return an instance of an HTTPException based on httplib response.""" + cls = _code_map.get(response.status, HTTPException) + return cls() + + +class NoTokenLookupException(Exception): + """DEPRECATED""" + pass + + +class EndpointNotFound(Exception): + """DEPRECATED""" + pass diff --git a/ceilometerclient/openstack/__init__.py b/ceilometerclient/openstack/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ceilometerclient/openstack/common/__init__.py b/ceilometerclient/openstack/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ceilometerclient/openstack/common/importutils.py b/ceilometerclient/openstack/common/importutils.py new file mode 100644 index 00000000..f45372b4 --- /dev/null +++ b/ceilometerclient/openstack/common/importutils.py @@ -0,0 +1,59 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# 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. + +""" +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), exc: + 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): + """ + Import 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] diff --git a/ceilometerclient/openstack/common/setup.py b/ceilometerclient/openstack/common/setup.py new file mode 100644 index 00000000..e6f72f03 --- /dev/null +++ b/ceilometerclient/openstack/common/setup.py @@ -0,0 +1,366 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# 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. + +""" +Utilities with minimum-depends for use in setup.py +""" + +import datetime +import os +import re +import subprocess +import sys + +from setuptools.command import sdist + + +def parse_mailmap(mailmap='.mailmap'): + mapping = {} + if os.path.exists(mailmap): + with open(mailmap, 'r') as fp: + for l in fp: + l = l.strip() + if not l.startswith('#') and ' ' in l: + canonical_email, alias = [x for x in l.split(' ') + if x.startswith('<')] + mapping[alias] = canonical_email + return mapping + + +def canonicalize_emails(changelog, mapping): + """Takes in a string and an email alias mapping and replaces all + instances of the aliases in the string with their real email. + """ + for alias, email in mapping.iteritems(): + changelog = changelog.replace(alias, email) + return changelog + + +# Get requirements from the first file that exists +def get_reqs_from_files(requirements_files): + for requirements_file in requirements_files: + if os.path.exists(requirements_file): + with open(requirements_file, 'r') as fil: + return fil.read().split('\n') + return [] + + +def parse_requirements(requirements_files=['requirements.txt', + 'tools/pip-requires']): + requirements = [] + for line in get_reqs_from_files(requirements_files): + # For the requirements list, we need to inject only the portion + # after egg= so that distutils knows the package it's looking for + # such as: + # -e git://github.com/openstack/nova/master#egg=nova + if re.match(r'\s*-e\s+', line): + requirements.append(re.sub(r'\s*-e\s+.*#egg=(.*)$', r'\1', + line)) + # such as: + # http://github.com/openstack/nova/zipball/master#egg=nova + elif re.match(r'\s*https?:', line): + requirements.append(re.sub(r'\s*https?:.*#egg=(.*)$', r'\1', + line)) + # -f lines are for index locations, and don't get used here + elif re.match(r'\s*-f\s+', line): + pass + # argparse is part of the standard library starting with 2.7 + # adding it to the requirements list screws distro installs + elif line == 'argparse' and sys.version_info >= (2, 7): + pass + else: + requirements.append(line) + + return requirements + + +def parse_dependency_links(requirements_files=['requirements.txt', + 'tools/pip-requires']): + dependency_links = [] + # dependency_links inject alternate locations to find packages listed + # in requirements + for line in get_reqs_from_files(requirements_files): + # skip comments and blank lines + if re.match(r'(\s*#)|(\s*$)', line): + continue + # lines with -e or -f need the whole line, minus the flag + if re.match(r'\s*-[ef]\s+', line): + dependency_links.append(re.sub(r'\s*-[ef]\s+', '', line)) + # lines that are only urls can go in unmolested + elif re.match(r'\s*https?:', line): + dependency_links.append(line) + return dependency_links + + +def write_requirements(): + venv = os.environ.get('VIRTUAL_ENV', None) + if venv is not None: + with open("requirements.txt", "w") as req_file: + output = subprocess.Popen(["pip", "-E", venv, "freeze", "-l"], + stdout=subprocess.PIPE) + requirements = output.communicate()[0].strip() + req_file.write(requirements) + + +def _run_shell_command(cmd): + if os.name == 'nt': + output = subprocess.Popen(["cmd.exe", "/C", cmd], + stdout=subprocess.PIPE) + else: + output = subprocess.Popen(["/bin/sh", "-c", cmd], + stdout=subprocess.PIPE) + out = output.communicate() + if len(out) == 0: + return None + if len(out[0].strip()) == 0: + return None + return out[0].strip() + + +def _get_git_next_version_suffix(branch_name): + datestamp = datetime.datetime.now().strftime('%Y%m%d') + if branch_name == 'milestone-proposed': + revno_prefix = "r" + else: + revno_prefix = "" + _run_shell_command("git fetch origin +refs/meta/*:refs/remotes/meta/*") + milestone_cmd = "git show meta/openstack/release:%s" % branch_name + milestonever = _run_shell_command(milestone_cmd) + if milestonever: + first_half = "%s~%s" % (milestonever, datestamp) + else: + first_half = datestamp + + post_version = _get_git_post_version() + # post version should look like: + # 0.1.1.4.gcc9e28a + # where the bit after the last . is the short sha, and the bit between + # the last and second to last is the revno count + (revno, sha) = post_version.split(".")[-2:] + second_half = "%s%s.%s" % (revno_prefix, revno, sha) + return ".".join((first_half, second_half)) + + +def _get_git_current_tag(): + return _run_shell_command("git tag --contains HEAD") + + +def _get_git_tag_info(): + return _run_shell_command("git describe --tags") + + +def _get_git_post_version(): + current_tag = _get_git_current_tag() + if current_tag is not None: + return current_tag + else: + tag_info = _get_git_tag_info() + if tag_info is None: + base_version = "0.0" + cmd = "git --no-pager log --oneline" + out = _run_shell_command(cmd) + revno = len(out.split("\n")) + sha = _run_shell_command("git describe --always") + else: + tag_infos = tag_info.split("-") + base_version = "-".join(tag_infos[:-2]) + (revno, sha) = tag_infos[-2:] + return "%s.%s.%s" % (base_version, revno, sha) + + +def write_git_changelog(): + """Write a changelog based on the git changelog.""" + new_changelog = 'ChangeLog' + if not os.getenv('SKIP_WRITE_GIT_CHANGELOG'): + if os.path.isdir('.git'): + git_log_cmd = 'git log --stat' + changelog = _run_shell_command(git_log_cmd) + mailmap = parse_mailmap() + with open(new_changelog, "w") as changelog_file: + changelog_file.write(canonicalize_emails(changelog, mailmap)) + else: + open(new_changelog, 'w').close() + + +def generate_authors(): + """Create AUTHORS file using git commits.""" + jenkins_email = 'jenkins@review.(openstack|stackforge).org' + old_authors = 'AUTHORS.in' + new_authors = 'AUTHORS' + if not os.getenv('SKIP_GENERATE_AUTHORS'): + if os.path.isdir('.git'): + # don't include jenkins email address in AUTHORS file + git_log_cmd = ("git log --format='%aN <%aE>' | sort -u | " + "egrep -v '" + jenkins_email + "'") + changelog = _run_shell_command(git_log_cmd) + mailmap = parse_mailmap() + with open(new_authors, 'w') as new_authors_fh: + new_authors_fh.write(canonicalize_emails(changelog, mailmap)) + if os.path.exists(old_authors): + with open(old_authors, "r") as old_authors_fh: + new_authors_fh.write('\n' + old_authors_fh.read()) + else: + open(new_authors, 'w').close() + + +_rst_template = """%(heading)s +%(underline)s + +.. automodule:: %(module)s + :members: + :undoc-members: + :show-inheritance: +""" + + +def read_versioninfo(project): + """Read the versioninfo file. If it doesn't exist, we're in a github + zipball, and there's really no way to know what version we really + are, but that should be ok, because the utility of that should be + just about nil if this code path is in use in the first place.""" + versioninfo_path = os.path.join(project, 'versioninfo') + if os.path.exists(versioninfo_path): + with open(versioninfo_path, 'r') as vinfo: + version = vinfo.read().strip() + else: + version = "0.0.0" + return version + + +def write_versioninfo(project, version): + """Write a simple file containing the version of the package.""" + with open(os.path.join(project, 'versioninfo'), 'w') as fil: + fil.write("%s\n" % version) + + +def get_cmdclass(): + """Return dict of commands to run from setup.py.""" + + cmdclass = dict() + + def _find_modules(arg, dirname, files): + for filename in files: + if filename.endswith('.py') and filename != '__init__.py': + arg["%s.%s" % (dirname.replace('/', '.'), + filename[:-3])] = True + + class LocalSDist(sdist.sdist): + """Builds the ChangeLog and Authors files from VC first.""" + + def run(self): + write_git_changelog() + generate_authors() + # sdist.sdist is an old style class, can't use super() + sdist.sdist.run(self) + + cmdclass['sdist'] = LocalSDist + + # If Sphinx is installed on the box running setup.py, + # enable setup.py to build the documentation, otherwise, + # just ignore it + try: + from sphinx.setup_command import BuildDoc + + class LocalBuildDoc(BuildDoc): + def generate_autoindex(self): + print "**Autodocumenting from %s" % os.path.abspath(os.curdir) + modules = {} + option_dict = self.distribution.get_option_dict('build_sphinx') + source_dir = os.path.join(option_dict['source_dir'][1], 'api') + if not os.path.exists(source_dir): + os.makedirs(source_dir) + for pkg in self.distribution.packages: + if '.' not in pkg: + os.path.walk(pkg, _find_modules, modules) + module_list = modules.keys() + module_list.sort() + autoindex_filename = os.path.join(source_dir, 'autoindex.rst') + with open(autoindex_filename, 'w') as autoindex: + autoindex.write(""".. toctree:: + :maxdepth: 1 + +""") + for module in module_list: + output_filename = os.path.join(source_dir, + "%s.rst" % module) + heading = "The :mod:`%s` Module" % module + underline = "=" * len(heading) + values = dict(module=module, heading=heading, + underline=underline) + + print "Generating %s" % output_filename + with open(output_filename, 'w') as output_file: + output_file.write(_rst_template % values) + autoindex.write(" %s.rst\n" % module) + + def run(self): + if not os.getenv('SPHINX_DEBUG'): + self.generate_autoindex() + + for builder in ['html', 'man']: + self.builder = builder + self.finalize_options() + self.project = self.distribution.get_name() + self.version = self.distribution.get_version() + self.release = self.distribution.get_version() + BuildDoc.run(self) + cmdclass['build_sphinx'] = LocalBuildDoc + except ImportError: + pass + + return cmdclass + + +def get_git_branchname(): + for branch in _run_shell_command("git branch --color=never").split("\n"): + if branch.startswith('*'): + _branch_name = branch.split()[1].strip() + if _branch_name == "(no": + _branch_name = "no-branch" + return _branch_name + + +def get_pre_version(projectname, base_version): + """Return a version which is leading up to a version that will + be released in the future.""" + if os.path.isdir('.git'): + current_tag = _get_git_current_tag() + if current_tag is not None: + version = current_tag + else: + branch_name = os.getenv('BRANCHNAME', + os.getenv('GERRIT_REFNAME', + get_git_branchname())) + version_suffix = _get_git_next_version_suffix(branch_name) + version = "%s~%s" % (base_version, version_suffix) + write_versioninfo(projectname, version) + return version + else: + version = read_versioninfo(projectname) + return version + + +def get_post_version(projectname): + """Return a version which is equal to the tag that's on the current + revision if there is one, or tag plus number of additional revisions + if the current revision has no tag.""" + + if os.path.isdir('.git'): + version = _get_git_post_version() + write_versioninfo(projectname, version) + return version + return read_versioninfo(projectname) diff --git a/ceilometerclient/openstack/common/version.py b/ceilometerclient/openstack/common/version.py new file mode 100644 index 00000000..a19e4226 --- /dev/null +++ b/ceilometerclient/openstack/common/version.py @@ -0,0 +1,148 @@ +# 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 +# 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. + +""" +Utilities for consuming the auto-generated versioninfo files. +""" + +import datetime +import pkg_resources + +import setup + + +class _deferred_version_string(object): + """Internal helper class which provides delayed version calculation.""" + def __init__(self, version_info, prefix): + self.version_info = version_info + self.prefix = prefix + + def __str__(self): + return "%s%s" % (self.prefix, self.version_info.version_string()) + + def __repr__(self): + return "%s%s" % (self.prefix, self.version_info.version_string()) + + +class VersionInfo(object): + + def __init__(self, package, python_package=None, pre_version=None): + """Object that understands versioning for a package + :param package: name of the top level python namespace. For glance, + this would be "glance" for python-glanceclient, it + would be "glanceclient" + :param python_package: optional name of the project name. For + glance this can be left unset. For + python-glanceclient, this would be + "python-glanceclient" + :param pre_version: optional version that the project is working to + """ + self.package = package + if python_package is None: + self.python_package = package + else: + self.python_package = python_package + self.pre_version = pre_version + self.version = None + + def _generate_version(self): + """Defer to the openstack.common.setup routines for making a + version from git.""" + if self.pre_version is None: + return setup.get_post_version(self.python_package) + else: + return setup.get_pre_version(self.python_package, self.pre_version) + + def _newer_version(self, pending_version): + """Check to see if we're working with a stale version or not. + We expect a version string that either looks like: + 2012.2~f3~20120708.10.4426392 + which is an unreleased version of a pre-version, or: + 0.1.1.4.gcc9e28a + which is an unreleased version of a post-version, or: + 0.1.1 + Which is a release and which should match tag. + For now, if we have a date-embedded version, check to see if it's + old, and if so re-generate. Otherwise, just deal with it. + """ + try: + version_date = int(self.version.split("~")[-1].split('.')[0]) + if version_date < int(datetime.date.today().strftime('%Y%m%d')): + return self._generate_version() + else: + return pending_version + except Exception: + return pending_version + + def version_string_with_vcs(self, always=False): + """Return the full version of the package including suffixes indicating + VCS status. + + For instance, if we are working towards the 2012.2 release, + canonical_version_string should return 2012.2 if this is a final + release, or else something like 2012.2~f1~20120705.20 if it's not. + + :param always: if true, skip all version caching + """ + if always: + self.version = self._generate_version() + + if self.version is None: + + requirement = pkg_resources.Requirement.parse(self.python_package) + versioninfo = "%s/versioninfo" % self.package + try: + raw_version = pkg_resources.resource_string(requirement, + versioninfo) + self.version = self._newer_version(raw_version.strip()) + except (IOError, pkg_resources.DistributionNotFound): + self.version = self._generate_version() + + return self.version + + def canonical_version_string(self, always=False): + """Return the simple version of the package excluding any suffixes. + + For instance, if we are working towards the 2012.2 release, + canonical_version_string should return 2012.2 in all cases. + + :param always: if true, skip all version caching + """ + return self.version_string_with_vcs(always).split('~')[0] + + def version_string(self, always=False): + """Return the base version of the package. + + For instance, if we are working towards the 2012.2 release, + version_string should return 2012.2 if this is a final release, or + 2012.2-dev if it is not. + + :param always: if true, skip all version caching + """ + version_parts = self.version_string_with_vcs(always).split('~') + if len(version_parts) == 1: + return version_parts[0] + else: + return '%s-dev' % (version_parts[0],) + + def deferred_version_string(self, prefix=""): + """Generate an object which will expand in a string context to + the results of version_string(). We do this so that don't + call into pkg_resources every time we start up a program when + passing version information into the CONF constructor, but + rather only do the calculation when and if a version is requested + """ + return _deferred_version_string(self, prefix) diff --git a/ceilometerclient/shell.py b/ceilometerclient/shell.py new file mode 100644 index 00000000..84a4b3b9 --- /dev/null +++ b/ceilometerclient/shell.py @@ -0,0 +1,335 @@ +# 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 Metrices API. +""" + +import argparse +import httplib2 +import logging +import sys + +from keystoneclient.v2_0 import client as ksclient + +from ceilometerclient import exc +from ceilometerclient import client as ceilometerclient +from ceilometerclient.common import utils + +logger = logging.getLogger(__name__) + + +class CeilometerShell(object): + + def get_base_parser(self): + parser = argparse.ArgumentParser( + prog='ceilometer', + description=__doc__.strip(), + epilog='See "ceilometer help COMMAND" ' + 'for help on a specific command.', + add_help=False, + formatter_class=HelpFormatter, + ) + + # Global arguments + parser.add_argument('-h', '--help', + action='store_true', + help=argparse.SUPPRESS, + ) + + parser.add_argument('-d', '--debug', + default=bool(utils.env('CEILOMETERCLIENT_DEBUG')), + action='store_true', + help='Defaults to env[CEILOMETERCLIENT_DEBUG]') + + parser.add_argument('-v', '--verbose', + default=False, action="store_true", + help="Print more verbose output") + + parser.add_argument('-k', '--insecure', + default=False, + action='store_true', + help="Explicitly allow glanceclient to perform \"insecure\" " + "SSL (https) requests. The server's certificate will " + "not be verified against any certificate authorities. " + "This option should be used with caution.") + + parser.add_argument('--cert-file', + help='Path of certificate file to use in SSL connection. This ' + 'file can optionally be prepended with the private key.') + + parser.add_argument('--key-file', + help='Path of client key to use in SSL connection. This option is ' + 'not necessary if your key is prepended to your cert file.') + + parser.add_argument('--ca-file', + help='Path of CA SSL certificate(s) used to verify the remote ' + 'server\'s certificate. Without this option glance looks ' + 'for the default system CA certificates.') + + parser.add_argument('--timeout', + default=600, + help='Number of seconds to wait for a response') + + parser.add_argument('--os-username', + default=utils.env('OS_USERNAME'), + help='Defaults to env[OS_USERNAME]') + + parser.add_argument('--os_username', + help=argparse.SUPPRESS) + + parser.add_argument('--os-password', + default=utils.env('OS_PASSWORD'), + help='Defaults to env[OS_PASSWORD]') + + parser.add_argument('--os_password', + help=argparse.SUPPRESS) + + parser.add_argument('--os-tenant-id', + default=utils.env('OS_TENANT_ID'), + help='Defaults to env[OS_TENANT_ID]') + + parser.add_argument('--os_tenant_id', + help=argparse.SUPPRESS) + + parser.add_argument('--os-tenant-name', + default=utils.env('OS_TENANT_NAME'), + help='Defaults to env[OS_TENANT_NAME]') + + parser.add_argument('--os_tenant_name', + help=argparse.SUPPRESS) + + parser.add_argument('--os-auth-url', + default=utils.env('OS_AUTH_URL'), + help='Defaults to env[OS_AUTH_URL]') + + parser.add_argument('--os_auth_url', + help=argparse.SUPPRESS) + + parser.add_argument('--os-region-name', + default=utils.env('OS_REGION_NAME'), + help='Defaults to env[OS_REGION_NAME]') + + parser.add_argument('--os_region_name', + help=argparse.SUPPRESS) + + parser.add_argument('--os-auth-token', + default=utils.env('OS_AUTH_TOKEN'), + help='Defaults to env[OS_AUTH_TOKEN]') + + parser.add_argument('--os_auth_token', + help=argparse.SUPPRESS) + + parser.add_argument('--ceilometer-url', + default=utils.env('CEILOMETER_URL'), + help='Defaults to env[CEILOMETER_URL]') + + parser.add_argument('--ceilometer_url', + help=argparse.SUPPRESS) + + parser.add_argument('--ceilometer-api-version', + default=utils.env('CEILOMETER_API_VERSION', default='1'), + help='Defaults to env[CEILOMETER_API_VERSION] or 1') + + parser.add_argument('--ceilometer_api_version', + help=argparse.SUPPRESS) + + parser.add_argument('--os-service-type', + default=utils.env('OS_SERVICE_TYPE'), + help='Defaults to env[OS_SERVICE_TYPE]') + + parser.add_argument('--os_service_type', + help=argparse.SUPPRESS) + + parser.add_argument('--os-endpoint-type', + default=utils.env('OS_ENDPOINT_TYPE'), + help='Defaults to env[OS_ENDPOINT_TYPE]') + + parser.add_argument('--os_endpoint_type', + help=argparse.SUPPRESS) + + return parser + + def get_subcommand_parser(self, version): + parser = self.get_base_parser() + + self.subcommands = {} + subparsers = parser.add_subparsers(metavar='') + submodule = utils.import_versioned_module(version, 'shell') + self._find_actions(subparsers, submodule) + self._find_actions(subparsers, self) + + return parser + + 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=HelpFormatter + ) + 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 _get_ksclient(self, **kwargs): + """Get an endpoint and auth token from Keystone. + + :param username: name of user + :param password: user's password + :param tenant_id: unique identifier of tenant + :param tenant_name: name of tenant + :param auth_url: endpoint to authenticate against + """ + return ksclient.Client(username=kwargs.get('username'), + password=kwargs.get('password'), + tenant_id=kwargs.get('tenant_id'), + tenant_name=kwargs.get('tenant_name'), + auth_url=kwargs.get('auth_url'), + insecure=kwargs.get('insecure')) + + def _get_endpoint(self, client, **kwargs): + """Get an endpoint using the provided keystone client.""" + return client.service_catalog.url_for( + service_type=kwargs.get('service_type') or 'orchestration', + endpoint_type=kwargs.get('endpoint_type') or 'publicURL') + + def _setup_debugging(self, debug): + if debug: + logging.basicConfig( + format="%(levelname)s (%(module)s:%(lineno)d) %(message)s", + level=logging.DEBUG) + + httplib2.debuglevel = 1 + else: + logging.basicConfig( + format="%(levelname)s %(message)s", + level=logging.INFO) + + def main(self, argv): + # Parse args once to find version + parser = self.get_base_parser() + (options, args) = parser.parse_known_args(argv) + self._setup_debugging(options.debug) + + # build available subcommands based on version + api_version = options.ceilometer_api_version + subcommand_parser = self.get_subcommand_parser(api_version) + self.parser = subcommand_parser + + # Handle top-level --help/-h before attempting to parse + # a command off the command line + if options.help or not argv: + self.do_help(options) + return 0 + + # Parse args again and call whatever callback was selected + args = subcommand_parser.parse_args(argv) + + # Short-circuit and deal with help command right away. + if args.func == self.do_help: + self.do_help(args) + return 0 + + if not args.os_username: + raise exc.CommandError("You must provide a username via" + " either --os-username or env[OS_USERNAME]") + + if not args.os_password: + raise exc.CommandError("You must provide a password via" + " either --os-password or env[OS_PASSWORD]") + + if not (args.os_tenant_id or args.os_tenant_name): + raise exc.CommandError("You must provide a tenant_id via" + " either --os-tenant-id or via env[OS_TENANT_ID]") + + if not args.os_auth_url: + raise exc.CommandError("You must provide an auth url via" + " either --os-auth-url or via env[OS_AUTH_URL]") + kwargs = { + 'username': args.os_username, + 'password': args.os_password, + 'tenant_id': args.os_tenant_id, + 'tenant_name': args.os_tenant_name, + 'auth_url': args.os_auth_url, + 'service_type': args.os_service_type, + 'endpoint_type': args.os_endpoint_type, + 'insecure': args.insecure + } + _ksclient = self._get_ksclient(**kwargs) + token = args.os_auth_token or _ksclient.auth_token + + endpoint = args.ceilometer_url or \ + self._get_endpoint(_ksclient, **kwargs) + + kwargs = { + 'token': token, + 'insecure': args.insecure, + 'timeout': args.timeout, + 'ca_file': args.ca_file, + 'cert_file': args.cert_file, + 'key_file': args.key_file, + } + + client = ceilometerclient.Client(api_version, endpoint, **kwargs) + + try: + args.func(client, args) + except exc.Unauthorized: + raise exc.CommandError("Invalid OpenStack Identity credentials.") + + @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 getattr(args, 'command', None): + 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() + + +class HelpFormatter(argparse.HelpFormatter): + def start_section(self, heading): + # Title-case the headings + heading = '%s%s' % (heading[0].upper(), heading[1:]) + super(HelpFormatter, self).start_section(heading) + + +def main(): + try: + CeilometerShell().main(sys.argv[1:]) + + except Exception, e: + print >> sys.stderr, e + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/ceilometerclient/v1/__init__.py b/ceilometerclient/v1/__init__.py new file mode 100644 index 00000000..ccd8e520 --- /dev/null +++ b/ceilometerclient/v1/__init__.py @@ -0,0 +1,16 @@ +# Copyright 2012 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from ceilometerclient.v1.client import Client diff --git a/ceilometerclient/v1/client.py b/ceilometerclient/v1/client.py new file mode 100644 index 00000000..516a817b --- /dev/null +++ b/ceilometerclient/v1/client.py @@ -0,0 +1,37 @@ +# Copyright 2012 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from ceilometerclient.common import http +from ceilometerclient.v1 import meters + + +class Client(http.HTTPClient): + """Client for the Ceilometer v1 API. + + :param string endpoint: A user-supplied endpoint URL for the ceilometer + service. + :param string token: Token for authentication. + :param integer timeout: Allows customization of the timeout for client + http requests. (optional) + """ + + def __init__(self, *args, **kwargs): + """ Initialize a new client for the Ceilometer v1 API. """ + super(Client, self).__init__(*args, **kwargs) + self.meters = meters.MeterManager(self) + self.events = meters.EventManager(self) + self.users = meters.UserManager(self) + self.resources = meters.ResourceManager(self) + self.projects = meters.ProjectManager(self) diff --git a/ceilometerclient/v1/meters.py b/ceilometerclient/v1/meters.py new file mode 100644 index 00000000..2b7cec35 --- /dev/null +++ b/ceilometerclient/v1/meters.py @@ -0,0 +1,143 @@ +# Copyright 2012 OpenMeter LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from ceilometerclient.common import base + +class User(base.Resource): + def __init__(self, manager, info, loaded=False): + print 'user %s' % str(info) + _d = {unicode('user_id'): info} + super(User, self).__init__(manager, _d, loaded) + + def __repr__(self): + return "" % self._info + + def data(self, **kwargs): + return self.manager.data(self, **kwargs) + + +class UserManager(base.Manager): + resource_class = User + + def list(self, **kwargs): + return self._list('/users', 'users') + + +class Project(base.Resource): + def __init__(self, manager, info, loaded=False): + _d = {unicode('project_id'): info} + super(Project, self).__init__(manager, _d, loaded) + + def __repr__(self): + return "" % self._info + + def data(self, **kwargs): + return self.manager.data(self, **kwargs) + + +class ProjectManager(base.Manager): + resource_class = Project + + def list(self, **kwargs): + s = kwargs.get('source') + if s: + path = '/sources/%s/projects' % (kwargs['source']) + else: + path = '/projects' + + return self._list(path, 'projects') + + +class Resource(base.Resource): + def __repr__(self): + return "" % self._info + + def data(self, **kwargs): + return self.manager.data(self, **kwargs) + + +class ResourceManager(base.Manager): + resource_class = Resource + + def list(self, **kwargs): + u = kwargs.get('user_id') + s = kwargs.get('source') + if u: + path = '/users/%s/resources' % (u) + elif s: + path = '/sources/%s/resources' % (s) + else: + path = '/resources' + return self._list(path, 'resources') + + +class Event(base.Resource): + def __repr__(self): + return "" % self._info + + def data(self, **kwargs): + return self.manager.data(self, **kwargs) + + +class EventManager(base.Manager): + resource_class = Event + + def list(self, **kwargs): + c = kwargs['counter_name'] + r = kwargs.get('resource_id') + u = kwargs.get('user_id') + p = kwargs.get('project_id') + s = kwargs.get('source') + if r: + path = '/resources/%s/events/%s' % (r, c) + elif u: + path = '/users/%s/events/%s' % (u, c) + elif p: + path = '/projects/%s/events/%s' % (p, c) + elif s: + path = '/sources/%s/events/%s' % (s, c) + else: + path = '/events' + + self._list(path, 'events') + + +class Meter(base.Resource): + def __repr__(self): + return "" % self._info + + def data(self, **kwargs): + return self.manager.data(self, **kwargs) + + +class MeterManager(base.Manager): + resource_class = Meter + + def list(self, **kwargs): + r = kwargs.get('resource_id') + u = kwargs.get('user_id') + p = kwargs.get('project_id') + s = kwargs.get('source') + if u: + path = '/users/%s/meters' % u + elif r: + path = '/resources/%s/meters' % r + elif p: + path = '/projects/%s/meters' % p + elif s: + path = '/sources/%s/meters' % s + else: + path = '/meters' + return self._list(path, 'meters') diff --git a/ceilometerclient/v1/shell.py b/ceilometerclient/v1/shell.py new file mode 100644 index 00000000..a8e7c627 --- /dev/null +++ b/ceilometerclient/v1/shell.py @@ -0,0 +1,109 @@ +# Copyright 2012 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import json + +from ceilometerclient.common import utils +import ceilometerclient.exc as exc + + +@utils.arg('-s', '--source', metavar='', + help='ID of the resource to show events for.') +@utils.arg('-r', '--resource_id', metavar='', + help='ID of the resource to show events for.') +@utils.arg('-u', '--user_id', metavar='', + help='ID of the user to show events for.') +@utils.arg('-p', '--project_id', metavar='', + help='ID of the project to show events for.') +@utils.arg('-c', '--counter_name', metavar='', + help='Name of meter to show events for.') +def do_event_list(cc, args): + '''List the events for this meters''' + fields = {'counter_name': args.counter_name, + 'resource_id': args.resource_id, + 'user_id': args.user_id, + 'project_id': args.project_id, + 'source': args.source} + try: + events = cc.events.list(**fields) + except exc.HTTPNotFound: + raise exc.CommandError('Events not found: %s' % args.counter_name) + else: + json_format = lambda js: json.dumps(js, indent=2) + formatters = { + 'metadata': json_format, + } + for e in events: + utils.print_dict(e.to_dict(), formatters=formatters) + + +@utils.arg('-s', '--source', metavar='', + help='ID of the resource to show events for.') +@utils.arg('-r', '--resource_id', metavar='', + help='ID of the resource to show events for.') +@utils.arg('-u', '--user_id', metavar='', + help='ID of the user to show events for.') +@utils.arg('-p', '--project_id', metavar='', + help='ID of the project to show events for.') +def do_metric_list(cc, args={}): + '''List the user's metrices''' + fields = {'resource_id': args.resource_id, + 'user_id': args.user_id, + 'project_id': args.project_id, + 'source': args.source} + metrics = cc.meters.list(**fields) + field_labels = ['Name', 'Resource ID', 'User ID'] + fields = ['counter_name', 'resource_id', 'user_id'] + utils.print_list(metrics, fields, field_labels, + sortby=0) + + +def do_user_list(cc, args={}): + '''List the users''' + kwargs = {} + users = cc.users.list(**kwargs) + field_labels = ['User ID'] + fields = ['user_id'] + utils.print_list(users, fields, field_labels, + sortby=0) + + +@utils.arg('-s', '--source', metavar='', + help='ID of the resource to show events for.') +@utils.arg('-u', '--user_id', metavar='', + help='ID of the user to show events for.') +def do_resource_list(cc, args={}): + '''List the users''' + kwargs = {'source': args.source, + 'user_id': args.user_id} + resources = cc.resources.list(**kwargs) + + field_labels = ['Resource ID', 'Source', 'User ID', 'Project ID'] + fields = ['resource_id', 'source', 'user_id', 'project_id'] + utils.print_list(resources, fields, field_labels, + sortby=1) + + +@utils.arg('-s', '--source', metavar='', + help='ID of the resource to show events for.') +def do_project_list(cc, args={}): + '''List the projects''' + kwargs = {'source': args.source} + projects = cc.projects.list(**kwargs) + + field_labels = ['Project ID'] + fields = ['project_id'] + utils.print_list(projects, fields, field_labels, + sortby=0) diff --git a/ceilometerclient/version.py b/ceilometerclient/version.py new file mode 100644 index 00000000..5acaf4e7 --- /dev/null +++ b/ceilometerclient/version.py @@ -0,0 +1,19 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# 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 ceilometerclient.openstack.common import version as common_version + +version_info = common_version.VersionInfo('ceilometerclient', + python_package='python-ceilometerclient') diff --git a/setup.py b/setup.py index d0dff148..aca080ed 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ setuptools.setup( 'Programming Language :: Python', ], entry_points={ - 'console_scripts': ['ceilometerclient = ceilometerclient.shell:main'] + 'console_scripts': ['ceilometer = ceilometerclient.shell:main'] }, dependency_links=setup.parse_dependency_links(), tests_require=setup.parse_requirements(['tools/test-requires']), diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 00000000..d09e933f --- /dev/null +++ b/tests/fakes.py @@ -0,0 +1,80 @@ + +import httplib +import json +import mox + +from keystoneclient.v2_0 import client as ksclient +from ceilometerclient.v1 import client as v1client + + +def script_keystone_client(): + ksclient.Client(auth_url='http://no.where', + insecure=False, + password='password', + tenant_id='', + tenant_name='tenant_name', + username='username').AndReturn( + FakeKeystone('abcd1234')) + + +def script_heat_list(): + resp_dict = {"stacks": [{ + "id": "1", + "stack_name": "teststack", + "stack_status": 'CREATE_COMPLETE', + "creation_time": "2012-10-25T01:58:47Z" + }, + { + "id": "2", + "stack_name": "teststack2", + "stack_status": 'IN_PROGRESS', + "creation_time": "2012-10-25T01:58:47Z" + }] + } + resp = FakeHTTPResponse(200, + 'success, yo', + {'content-type': 'application/json'}, + json.dumps(resp_dict)) + v1client.Client.json_request('GET', + '/stacks?limit=20').AndReturn((resp, resp_dict)) + + +def fake_headers(): + return {'X-Auth-Token': 'abcd1234', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-ceilometerclient'} + + +class FakeServiceCatalog(): + def url_for(self, endpoint_type, service_type): + return 'http://192.168.1.5:8004/v1/f14b41234' + + +class FakeKeystone(): + service_catalog = FakeServiceCatalog() + + def __init__(self, auth_token): + self.auth_token = auth_token + + +class FakeHTTPResponse(): + + version = 1.1 + + def __init__(self, status, reason, headers, body): + self.headers = headers + self.body = body + self.status = status + self.reason = reason + + def getheader(self, name, default=None): + return self.headers.get(name, default) + + def getheaders(self): + return self.headers.items() + + def read(self, amt=None): + b = self.body + self.body = None + return b diff --git a/tests/test_shell.py b/tests/test_shell.py new file mode 100644 index 00000000..31a1155b --- /dev/null +++ b/tests/test_shell.py @@ -0,0 +1,390 @@ +import cStringIO +import os +import httplib2 +import sys + +import mox +import unittest +try: + import json +except ImportError: + import simplejson as json +from keystoneclient.v2_0 import client as ksclient + +from ceilometerclient import exc +from ceilometerclient.v1 import client as v1client +import ceilometerclient.shell +import fakes + +TEST_VAR_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), + 'var')) + + +class ShellValidationTest(unittest.TestCase): + + def test_missing_auth(self): + _old_env, os.environ = os.environ, { + 'OS_PASSWORD': 'password', + 'OS_TENANT_NAME': 'tenant_name', + 'OS_AUTH_URL': 'http://no.where', + } + self.shell_error('list', 'You must provide a username') + + os.environ = _old_env + + _old_env, os.environ = os.environ, { + 'OS_USERNAME': 'username', + 'OS_TENANT_NAME': 'tenant_name', + 'OS_AUTH_URL': 'http://no.where', + } + self.shell_error('list', 'You must provide a password') + + os.environ = _old_env + + _old_env, os.environ = os.environ, { + 'OS_USERNAME': 'username', + 'OS_PASSWORD': 'password', + 'OS_AUTH_URL': 'http://no.where', + } + self.shell_error('list', 'You must provide a tenant_id') + + os.environ = _old_env + + _old_env, os.environ = os.environ, { + 'OS_USERNAME': 'username', + 'OS_PASSWORD': 'password', + 'OS_TENANT_NAME': 'tenant_name', + } + self.shell_error('list', 'You must provide an auth url') + + os.environ = _old_env + + def test_failed_auth(self): + m = mox.Mox() + m.StubOutWithMock(ksclient, 'Client') + m.StubOutWithMock(v1client.Client, 'json_request') + fakes.script_keystone_client() + v1client.Client.json_request('GET', + '/stacks?limit=20').AndRaise(exc.Unauthorized) + + m.ReplayAll() + _old_env, os.environ = os.environ, { + 'OS_USERNAME': 'username', + 'OS_PASSWORD': 'password', + 'OS_TENANT_NAME': 'tenant_name', + 'OS_AUTH_URL': 'http://no.where', + } + self.shell_error('list', 'Invalid OpenStack Identity credentials.') + + m.VerifyAll() + + os.environ = _old_env + m.UnsetStubs() + + def test_create_validation(self): + m = mox.Mox() + m.StubOutWithMock(ksclient, 'Client') + m.StubOutWithMock(v1client.Client, 'json_request') + fakes.script_keystone_client() + + m.ReplayAll() + _old_env, os.environ = os.environ, { + 'OS_USERNAME': 'username', + 'OS_PASSWORD': 'password', + 'OS_TENANT_NAME': 'tenant_name', + 'OS_AUTH_URL': 'http://no.where', + } + self.shell_error('create teststack ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"', + 'Need to specify exactly one of') + + m.VerifyAll() + + os.environ = _old_env + m.UnsetStubs() + + def shell_error(self, argstr, error_match): + orig = sys.stderr + try: + sys.stderr = cStringIO.StringIO() + _shell = ceilometerclient.shell.CeilometerShell() + _shell.main(argstr.split()) + except exc.CommandError as e: + self.assertRegexpMatches(e.__str__(), error_match) + else: + self.fail('Expected error matching: %s' % error_match) + finally: + err = sys.stderr.getvalue() + sys.stderr.close() + sys.stderr = orig + return err + + +class ShellTest(unittest.TestCase): + + # Patch os.environ to avoid required auth info. + def setUp(self): + self.m = mox.Mox() + self.m.StubOutWithMock(ksclient, 'Client') + self.m.StubOutWithMock(v1client.Client, 'json_request') + self.m.StubOutWithMock(v1client.Client, 'raw_request') + + global _old_env + fake_env = { + 'OS_USERNAME': 'username', + 'OS_PASSWORD': 'password', + 'OS_TENANT_NAME': 'tenant_name', + 'OS_AUTH_URL': 'http://no.where', + } + _old_env, os.environ = os.environ, fake_env.copy() + + def tearDown(self): + self.m.UnsetStubs() + global _old_env + os.environ = _old_env + + def shell(self, argstr): + orig = sys.stdout + try: + sys.stdout = cStringIO.StringIO() + _shell = ceilometerclient.shell.CeilometerShell() + _shell.main(argstr.split()) + except SystemExit: + exc_type, exc_value, exc_traceback = sys.exc_info() + self.assertEqual(exc_value.code, 0) + finally: + out = sys.stdout.getvalue() + sys.stdout.close() + sys.stdout = orig + + return out + + def test_help_unknown_command(self): + self.assertRaises(exc.CommandError, self.shell, 'help foofoo') + + def test_debug(self): + httplib2.debuglevel = 0 + self.shell('--debug help') + self.assertEqual(httplib2.debuglevel, 1) + + def test_help(self): + required = [ + '^usage: heat', + '(?m)^See "heat help COMMAND" for help on a specific command', + ] + for argstr in ['--help', 'help']: + help_text = self.shell(argstr) + for r in required: + self.assertRegexpMatches(help_text, r) + + def test_help_on_subcommand(self): + required = [ + '^usage: heat list', + "(?m)^List the user's stacks", + ] + argstrings = [ + 'help list', + ] + for argstr in argstrings: + help_text = self.shell(argstr) + for r in required: + self.assertRegexpMatches(help_text, r) + + def test_list(self): + fakes.script_keystone_client() + fakes.script_heat_list() + + self.m.ReplayAll() + + list_text = self.shell('list') + + required = [ + 'ID', + 'Status', + 'Created', + 'teststack/1', + 'CREATE_COMPLETE', + 'IN_PROGRESS', + ] + for r in required: + self.assertRegexpMatches(list_text, r) + + self.m.VerifyAll() + + def test_describe(self): + fakes.script_keystone_client() + resp_dict = {"stack": { + "id": "1", + "stack_name": "teststack", + "stack_status": 'CREATE_COMPLETE', + "creation_time": "2012-10-25T01:58:47Z" + } + } + resp = fakes.FakeHTTPResponse(200, + 'OK', + {'content-type': 'application/json'}, + json.dumps(resp_dict)) + v1client.Client.json_request('GET', + '/stacks/teststack/1').AndReturn((resp, resp_dict)) + + self.m.ReplayAll() + + list_text = self.shell('describe teststack/1') + + required = [ + 'id', + 'stack_name', + 'stack_status', + 'creation_time', + 'teststack', + 'CREATE_COMPLETE', + '2012-10-25T01:58:47Z' + ] + for r in required: + self.assertRegexpMatches(list_text, r) + + self.m.VerifyAll() + + def test_create(self): + fakes.script_keystone_client() + resp = fakes.FakeHTTPResponse(201, + 'Created', + {'location': 'http://no.where/v1/tenant_id/stacks/teststack2/2'}, + None) + v1client.Client.json_request('POST', '/stacks', + body=mox.IgnoreArg()).AndReturn((resp, None)) + fakes.script_heat_list() + + self.m.ReplayAll() + + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + create_text = self.shell('create teststack ' + '--template-file=%s ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"' % template_file) + + required = [ + 'Name/ID', + 'teststack/1' + ] + for r in required: + self.assertRegexpMatches(create_text, r) + + self.m.VerifyAll() + + def test_create_url(self): + + fakes.script_keystone_client() + resp = fakes.FakeHTTPResponse(201, + 'Created', + {'location': 'http://no.where/v1/tenant_id/stacks/teststack2/2'}, + None) + v1client.Client.json_request('POST', '/stacks', + body=mox.IgnoreArg()).AndReturn((resp, None)) + fakes.script_heat_list() + + self.m.ReplayAll() + + create_text = self.shell('create teststack ' + '--template-url=http://no.where/minimal.template ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"') + + required = [ + 'Name/ID', + 'teststack2/2' + ] + for r in required: + self.assertRegexpMatches(create_text, r) + + self.m.VerifyAll() + + def test_create_object(self): + + fakes.script_keystone_client() + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + template_data = open(template_file).read() + v1client.Client.raw_request('GET', + 'http://no.where/container/minimal.template', + ).AndReturn(template_data) + + resp = fakes.FakeHTTPResponse(201, + 'Created', + {'location': 'http://no.where/v1/tenant_id/stacks/teststack2/2'}, + None) + v1client.Client.json_request('POST', '/stacks', + body=mox.IgnoreArg()).AndReturn((resp, None)) + + fakes.script_heat_list() + + self.m.ReplayAll() + + create_text = self.shell('create teststack2 ' + '--template-object=http://no.where/container/minimal.template ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"') + + required = [ + 'Name/ID', + 'teststack2/2' + ] + for r in required: + self.assertRegexpMatches(create_text, r) + + self.m.VerifyAll() + + def test_update(self): + fakes.script_keystone_client() + resp = fakes.FakeHTTPResponse(202, + 'Accepted', + {}, + 'The request is accepted for processing.') + v1client.Client.json_request('PUT', '/stacks/teststack2/2', + body=mox.IgnoreArg()).AndReturn((resp, None)) + fakes.script_heat_list() + + self.m.ReplayAll() + + template_file = os.path.join(TEST_VAR_DIR, 'minimal.template') + create_text = self.shell('update teststack2/2 ' + '--template-file=%s ' + '--parameters="InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17"' % template_file) + + required = [ + 'Name/ID', + 'teststack/1' + ] + for r in required: + self.assertRegexpMatches(create_text, r) + + self.m.VerifyAll() + + def test_delete(self): + fakes.script_keystone_client() + resp = fakes.FakeHTTPResponse(204, + 'No Content', + {}, + None) + v1client.Client.raw_request('DELETE', '/stacks/teststack2/2', + ).AndReturn((resp, None)) + fakes.script_heat_list() + + self.m.ReplayAll() + + create_text = self.shell('delete teststack2/2') + + required = [ + 'Name/ID', + 'teststack/1' + ] + for r in required: + self.assertRegexpMatches(create_text, r) + + self.m.VerifyAll() diff --git a/tests/v1/__init__.py b/tests/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/v1/test_shell.py b/tests/v1/test_shell.py new file mode 100644 index 00000000..f5d2eee4 --- /dev/null +++ b/tests/v1/test_shell.py @@ -0,0 +1,31 @@ +# Copyright 2012 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import unittest +import ceilometerclient.v1.shell as shell + + +class shellTest(unittest.TestCase): + + def test_format_parameters(self): + p = shell.format_parameters('InstanceType=m1.large;DBUsername=wp;' + 'DBPassword=verybadpassword;KeyName=heat_key;' + 'LinuxDistribution=F17') + self.assertEqual({'InstanceType': 'm1.large', + 'DBUsername': 'wp', + 'DBPassword': 'verybadpassword', + 'KeyName': 'heat_key', + 'LinuxDistribution': 'F17' + }, p) + self.assertEqual({}, shell.format_parameters(None))