From 3173ab4c4b4dbe6ebbb2b8505bb3d9f6de51977a Mon Sep 17 00:00:00 2001 From: Doug Hellmann Date: Mon, 3 Dec 2012 12:57:36 -0500 Subject: [PATCH] Implement V2 API with Pecan and WSME This changeset reimplements the API using Pecan and WSME instead of Flask. Pecan uses "object dispatch" instead of declared routes. The controller classes are chained together to implement the API. Most of what we have are simple REST lookups, but a few cases required custom methods. WSME is used to define types of inputs and outputs for each controller method. The WSME layer handles serizlization and deserialization in several formats. In our case, only JSON and XML are configured. There are a few small changes to the return types in the API, as well as to error handling. Now all errors are returned as JSON messages made up of a mapping containing the key 'error_message' and the text of the error. This will later be enhanced to include XML support for XML requests. This change also moves the script for starting the V1 API to a new name and replaces it with a script that starts the V2 API. There is an open bug/blueprint to fix that so both versions of the API are loaded. blueprint api-server-pecan-wsme Signed-off-by: Doug Hellmann Change-Id: I1b99a16de68f902370a8999eca073c56f9f14865 --- MANIFEST.in | 1 + bin/ceilometer-api | 40 +- bin/ceilometer-api-v1 | 46 ++ ceilometer/api/acl.py | 33 +- ceilometer/api/app.py | 44 ++ ceilometer/api/config.py | 41 ++ ceilometer/api/controllers/__init__.py | 0 ceilometer/api/controllers/root.py | 31 + ceilometer/api/controllers/v2.py | 560 ++++++++++++++++++ ceilometer/api/hooks.py | 44 ++ ceilometer/api/middleware.py | 74 +++ ceilometer/api/v1/acl.py | 50 ++ ceilometer/storage/impl_test.py | 4 +- ceilometer/tests/api.py | 115 +++- ceilometer/tests/db.py | 2 +- tests/api/{ => v1}/test_acl.py | 23 +- tests/api/v2/__init__.py | 0 tests/api/v2/base.py | 30 + tests/api/v2/test_acl.py | 82 +++ .../v2/test_compute_duration_by_resource.py | 143 +++++ tests/api/v2/test_get_query_ts.py | 80 +++ tests/api/v2/test_list_events.py | 108 ++++ tests/api/v2/test_list_meters.py | 157 +++++ tests/api/v2/test_list_projects.py | 118 ++++ tests/api/v2/test_list_resources.py | 202 +++++++ tests/api/v2/test_list_sources.py | 47 ++ tests/api/v2/test_list_users.py | 120 ++++ tests/api/v2/test_max_project_volume.py | 95 +++ tests/api/v2/test_max_resource_volume.py | 94 +++ tests/api/v2/test_sum_project_volume.py | 94 +++ tests/api/v2/test_sum_resource_volume.py | 94 +++ tools/pip-requires | 1 + tools/test-requires | 6 + tools/test-requires-folsom | 6 + 34 files changed, 2548 insertions(+), 37 deletions(-) create mode 100755 bin/ceilometer-api-v1 create mode 100644 ceilometer/api/app.py create mode 100644 ceilometer/api/config.py create mode 100644 ceilometer/api/controllers/__init__.py create mode 100644 ceilometer/api/controllers/root.py create mode 100644 ceilometer/api/controllers/v2.py create mode 100644 ceilometer/api/hooks.py create mode 100644 ceilometer/api/middleware.py create mode 100644 ceilometer/api/v1/acl.py rename tests/api/{ => v1}/test_acl.py (74%) create mode 100644 tests/api/v2/__init__.py create mode 100644 tests/api/v2/base.py create mode 100644 tests/api/v2/test_acl.py create mode 100644 tests/api/v2/test_compute_duration_by_resource.py create mode 100644 tests/api/v2/test_get_query_ts.py create mode 100644 tests/api/v2/test_list_events.py create mode 100644 tests/api/v2/test_list_meters.py create mode 100644 tests/api/v2/test_list_projects.py create mode 100644 tests/api/v2/test_list_resources.py create mode 100644 tests/api/v2/test_list_sources.py create mode 100644 tests/api/v2/test_list_users.py create mode 100644 tests/api/v2/test_max_project_volume.py create mode 100644 tests/api/v2/test_max_resource_volume.py create mode 100644 tests/api/v2/test_sum_project_volume.py create mode 100644 tests/api/v2/test_sum_resource_volume.py diff --git a/MANIFEST.in b/MANIFEST.in index 4afd7824..9c8ae9ee 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,3 +5,4 @@ exclude .gitignore exclude .gitreview global-exclude *.pyc +recursive-include public * diff --git a/bin/ceilometer-api b/bin/ceilometer-api index 462bbde9..3b650a54 100755 --- a/bin/ceilometer-api +++ b/bin/ceilometer-api @@ -18,10 +18,15 @@ # under the License. """Set up the development API server. """ +import os import sys +from wsgiref import simple_server + +from pecan import configuration from ceilometer.api import acl -from ceilometer.api.v1 import app +from ceilometer.api import app +from ceilometer.api import config as api_config from ceilometer.openstack.common import cfg from ceilometer.openstack.common import log as logging @@ -32,15 +37,34 @@ if __name__ == '__main__': # inputs. acl.register_opts(cfg.CONF) - # Parse config file and command line options, - # then configure logging. + # Parse OpenStack config file and command line options, then + # configure logging. cfg.CONF(sys.argv[1:]) logging.setup('ceilometer.api') - root = app.make_app() + # Set up the pecan configuration + filename = api_config.__file__.replace('.pyc', '.py') + pecan_config = configuration.conf_from_file(filename) - # Enable debug mode - if cfg.CONF.verbose or cfg.CONF.debug: - root.debug = True + # Build the WSGI app + root = app.setup_app(pecan_config, + extra_hooks=[acl.AdminAuthHook()]) + root = acl.install(root, cfg.CONF) - root.run(host='0.0.0.0', port=cfg.CONF.metering_api_port) + # Create the WSGI server and start it + host, port = '0.0.0.0', int(cfg.CONF.metering_api_port) + srv = simple_server.make_server(host, port, root) + + print 'Starting server in PID %s' % os.getpid() + + if host == '0.0.0.0': + print 'serving on 0.0.0.0:%s, view at http://127.0.0.1:%s' % \ + (port, port) + else: + print "serving on http://%s:%s" % (host, port) + + try: + srv.serve_forever() + except KeyboardInterrupt: + # allow CTRL+C to shutdown without an error + pass diff --git a/bin/ceilometer-api-v1 b/bin/ceilometer-api-v1 new file mode 100755 index 00000000..462bbde9 --- /dev/null +++ b/bin/ceilometer-api-v1 @@ -0,0 +1,46 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Doug Hellmann +# +# 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. +"""Set up the development API server. +""" +import sys + +from ceilometer.api import acl +from ceilometer.api.v1 import app +from ceilometer.openstack.common import cfg +from ceilometer.openstack.common import log as logging + + +if __name__ == '__main__': + # Register keystone middleware option before + # parsing the config file and command line + # inputs. + acl.register_opts(cfg.CONF) + + # Parse config file and command line options, + # then configure logging. + cfg.CONF(sys.argv[1:]) + logging.setup('ceilometer.api') + + root = app.make_app() + + # Enable debug mode + if cfg.CONF.verbose or cfg.CONF.debug: + root.debug = True + + root.run(host='0.0.0.0', port=cfg.CONF.metering_api_port) diff --git a/ceilometer/api/acl.py b/ceilometer/api/acl.py index 555e5afd..f97f5ba2 100644 --- a/ceilometer/api/acl.py +++ b/ceilometer/api/acl.py @@ -2,7 +2,7 @@ # # Copyright © 2012 New Dream Network, LLC (DreamHost) # -# Author: Julien Danjou +# Author: Doug Hellmann # # 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 @@ -17,9 +17,12 @@ # under the License. """Set up the ACL to acces the API server.""" -import flask from ceilometer import policy +from pecan import hooks + +from webob import exc + import keystoneclient.middleware.auth_token as auth_token @@ -34,17 +37,19 @@ def register_opts(conf): def install(app, conf): """Install ACL check on application.""" - app.wsgi_app = auth_token.AuthProtocol(app.wsgi_app, - conf=conf, - ) - app.before_request(check) - return app + new_app = auth_token.AuthProtocol(app, + conf=conf, + ) + return new_app -def check(): - """Check application access.""" - headers = flask.request.headers - if not policy.check_is_admin(headers.get('X-Roles', "").split(","), - headers.get('X-Tenant-Id'), - headers.get('X-Tenant-Name')): - return "Access denied", 401 +class AdminAuthHook(hooks.PecanHook): + """Verify that the user has admin rights + """ + + def before(self, state): + headers = state.request.headers + if not policy.check_is_admin(headers.get('X-Roles', "").split(","), + headers.get('X-Tenant-Id'), + headers.get('X-Tenant-Name')): + raise exc.HTTPUnauthorized() diff --git a/ceilometer/api/app.py b/ceilometer/api/app.py new file mode 100644 index 00000000..aba22626 --- /dev/null +++ b/ceilometer/api/app.py @@ -0,0 +1,44 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Doug Hellmann +# +# 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 pecan import make_app +from ceilometer.api import hooks +from ceilometer.api import middleware +from ceilometer.service import prepare_service + + +def setup_app(config, extra_hooks=[]): + + # Initialize the cfg.CONF object + prepare_service([]) + + # FIXME: Replace DBHook with a hooks.TransactionHook + app_hooks = [hooks.ConfigHook(), + hooks.DBHook()] + app_hooks.extend(extra_hooks) + + return make_app( + config.app.root, + static_root=config.app.static_root, + template_path=config.app.template_path, + logging=getattr(config, 'logging', {}), + debug=getattr(config.app, 'debug', False), + force_canonical=getattr(config.app, 'force_canonical', True), + hooks=app_hooks, + wrap_app=middleware.ParsableErrorMiddleware, + ) diff --git a/ceilometer/api/config.py b/ceilometer/api/config.py new file mode 100644 index 00000000..22f7ed65 --- /dev/null +++ b/ceilometer/api/config.py @@ -0,0 +1,41 @@ +# Server Specific Configurations +server = { + 'port': '8080', + 'host': '0.0.0.0' +} + +# Pecan Application Configurations +app = { + 'root': 'ceilometer.api.controllers.root.RootController', + 'modules': ['ceilometer.api'], + 'static_root': '%(confdir)s/public', + 'template_path': '%(confdir)s/ceilometer/api/templates', + 'debug': False, +} + +logging = { + 'loggers': { + 'root': {'level': 'INFO', 'handlers': ['console']}, + 'ceilometer': {'level': 'DEBUG', 'handlers': ['console']} + }, + 'handlers': { + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + 'formatter': 'simple' + } + }, + 'formatters': { + 'simple': { + 'format': ('%(asctime)s %(levelname)-5.5s [%(name)s]' + '[%(threadName)s] %(message)s') + } + }, +} + +# Custom Configurations must be in Python dictionary format:: +# +# foo = {'bar':'baz'} +# +# All configurations are accessible at:: +# pecan.conf diff --git a/ceilometer/api/controllers/__init__.py b/ceilometer/api/controllers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ceilometer/api/controllers/root.py b/ceilometer/api/controllers/root.py new file mode 100644 index 00000000..8c940523 --- /dev/null +++ b/ceilometer/api/controllers/root.py @@ -0,0 +1,31 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Doug Hellmann +# +# 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 pecan import expose + +from . import v2 + + +class RootController(object): + + v2 = v2.V2Controller() + + @expose(generic=True, template='index.html') + def index(self): + # FIXME: Return version information + return dict() diff --git a/ceilometer/api/controllers/v2.py b/ceilometer/api/controllers/v2.py new file mode 100644 index 00000000..bbdd5618 --- /dev/null +++ b/ceilometer/api/controllers/v2.py @@ -0,0 +1,560 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Doug Hellmann +# +# 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. +"""Version 2 of the API. +""" + +# [ ] / -- information about this version of the API +# +# [ ] /extensions -- list of available extensions +# [ ] /extensions/ -- details about a specific extension +# +# [ ] /sources -- list of known sources (where do we get this?) +# [ ] /sources/components -- list of components which provide metering +# data (where do we get this)? +# +# [x] /projects//resources -- list of resource ids +# [x] /resources -- list of resource ids +# [x] /sources//resources -- list of resource ids +# [x] /users//resources -- list of resource ids +# +# [x] /users -- list of user ids +# [x] /sources//users -- list of user ids +# +# [x] /projects -- list of project ids +# [x] /sources//projects -- list of project ids +# +# [ ] /resources/ -- metadata +# +# [ ] /projects//meters -- list of meters reporting for parent obj +# [ ] /resources//meters -- list of meters reporting for parent obj +# [ ] /sources//meters -- list of meters reporting for parent obj +# [ ] /users//meters -- list of meters reporting for parent obj +# +# [x] /projects//meters/ -- events +# [x] /resources//meters/ -- events +# [x] /sources//meters/ -- events +# [x] /users//meters/ -- events +# +# [ ] /projects//meters//duration -- total time for selected +# meter +# [x] /resources//meters//duration -- total time for selected +# meter +# [ ] /sources//meters//duration -- total time for selected +# meter +# [ ] /users//meters//duration -- total time for selected meter +# +# [ ] /projects//meters//volume -- total or max volume for +# selected meter +# [x] /projects//meters//volume/max -- max volume for +# selected meter +# [x] /projects//meters//volume/sum -- total volume for +# selected meter +# [ ] /resources//meters//volume -- total or max volume for +# selected meter +# [x] /resources//meters//volume/max -- max volume for +# selected meter +# [x] /resources//meters//volume/sum -- total volume for +# selected meter +# [ ] /sources//meters//volume -- total or max volume for +# selected meter +# [ ] /users//meters//volume -- total or max volume for selected +# meter + +import datetime +import os + +import pecan +from pecan import request +from pecan.rest import RestController + +import wsme +import wsme.pecan +from wsme.types import Base, text, wsattr + +from ceilometer.openstack.common import jsonutils +from ceilometer.openstack.common import log as logging +from ceilometer.openstack.common import timeutils +from ceilometer import storage + + +LOG = logging.getLogger(__name__) + + +def _get_query_timestamps(args={}): + """Return any optional timestamp information in the request. + + Determine the desired range, if any, from the GET arguments. Set + up the query range using the specified offset. + + [query_start ... start_timestamp ... end_timestamp ... query_end] + + Returns a dictionary containing: + + query_start: First timestamp to use for query + start_timestamp: start_timestamp parameter from request + query_end: Final timestamp to use for query + end_timestamp: end_timestamp parameter from request + search_offset: search_offset parameter from request + + """ + search_offset = int(args.get('search_offset', 0)) + + start_timestamp = args.get('start_timestamp') + if start_timestamp: + start_timestamp = timeutils.parse_isotime(start_timestamp) + start_timestamp = start_timestamp.replace(tzinfo=None) + query_start = (start_timestamp - + datetime.timedelta(minutes=search_offset)) + else: + query_start = None + + end_timestamp = args.get('end_timestamp') + if end_timestamp: + end_timestamp = timeutils.parse_isotime(end_timestamp) + end_timestamp = end_timestamp.replace(tzinfo=None) + query_end = end_timestamp + datetime.timedelta(minutes=search_offset) + else: + query_end = None + + return {'query_start': query_start, + 'query_end': query_end, + 'start_timestamp': start_timestamp, + 'end_timestamp': end_timestamp, + 'search_offset': search_offset, + } + + +# FIXME(dhellmann): Change APIs that use this to return float? +class MeterVolume(Base): + volume = wsattr(float, mandatory=False) + + def __init__(self, volume, **kw): + if volume is not None: + volume = float(volume) + super(MeterVolume, self).__init__(volume=volume, **kw) + + +class MeterVolumeController(object): + + @wsme.pecan.wsexpose(MeterVolume) + def max(self): + """Find the maximum volume for the matching meter events. + """ + q_ts = _get_query_timestamps(request.params) + + try: + meter = request.context['meter_id'] + except KeyError: + raise ValueError('No meter specified') + + resource = request.context.get('resource_id') + project = request.context.get('project_id') + + # Query the database for the max volume + f = storage.EventFilter(meter=meter, + resource=resource, + start=q_ts['query_start'], + end=q_ts['query_end'], + project=project, + ) + + # TODO(sberler): do we want to return an error if the resource + # does not exist? + results = list(request.storage_conn.get_volume_max(f)) + + value = None + if results: + if resource: + # If the caller specified a resource there should only + # be one result. + value = results[0].get('value') + else: + # FIXME(sberler): Currently get_volume_max is really + # always grouping by resource_id. We should add a new + # function in the storage driver that does not do this + # grouping (and potentially rename the existing one to + # get_volume_max_by_resource()) + value = max(result.get('value') for result in results) + + return MeterVolume(volume=value) + + @wsme.pecan.wsexpose(MeterVolume) + def sum(self): + """Compute the total volume for the matching meter events. + """ + q_ts = _get_query_timestamps(request.params) + + try: + meter = request.context['meter_id'] + except KeyError: + raise ValueError('No meter specified') + + resource = request.context.get('resource_id') + project = request.context.get('project_id') + + f = storage.EventFilter(meter=meter, + project=project, + start=q_ts['query_start'], + end=q_ts['query_end'], + resource=resource, + ) + + # TODO(sberler): do we want to return an error if the resource + # does not exist? + results = list(request.storage_conn.get_volume_sum(f)) + + value = None + if results: + if resource: + # If the caller specified a resource there should only + # be one result. + value = results[0].get('value') + else: + # FIXME(sberler): Currently get_volume_max is really + # always grouping by resource_id. We should add a new + # function in the storage driver that does not do this + # grouping (and potentially rename the existing one to + # get_volume_max_by_resource()) + value = sum(result.get('value') for result in results) + + return MeterVolume(volume=value) + + +class Event(Base): + source = text + counter_name = text + counter_type = text + counter_volume = float + user_id = text + project_id = text + resource_id = text + timestamp = datetime.datetime + # FIXME(dhellmann): Need to add the metadata back as + # a flat {text: text} mapping. + #resource_metadata = ? + message_id = text + + def __init__(self, counter_volume=None, **kwds): + if counter_volume is not None: + counter_volume = float(counter_volume) + super(Event, self).__init__(counter_volume=counter_volume, + **kwds) + + +class Duration(Base): + start_timestamp = datetime.datetime + end_timestamp = datetime.datetime + duration = float + + +class MeterController(RestController): + """Manages operations on a single meter. + """ + + volume = MeterVolumeController() + + _custom_actions = { + 'duration': ['GET'], + } + + def __init__(self, meter_id): + request.context['meter_id'] = meter_id + self._id = meter_id + + @wsme.pecan.wsexpose([Event]) + def get_all(self): + """Return all events for the meter. + """ + q_ts = _get_query_timestamps(request.params) + f = storage.EventFilter( + user=request.context.get('user_id'), + project=request.context.get('project_id'), + start=q_ts['query_start'], + end=q_ts['query_end'], + resource=request.context.get('resource_id'), + meter=self._id, + source=request.context.get('source_id'), + ) + return [Event(**e) + for e in request.storage_conn.get_raw_events(f) + ] + + @wsme.pecan.wsexpose(Duration) + def duration(self): + """Computes the duration of the meter events in the time range given. + """ + q_ts = _get_query_timestamps(request.params) + start_timestamp = q_ts['start_timestamp'] + end_timestamp = q_ts['end_timestamp'] + + # Query the database for the interval of timestamps + # within the desired range. + f = storage.EventFilter(user=request.context.get('user_id'), + project=request.context.get('project_id'), + start=q_ts['query_start'], + end=q_ts['query_end'], + resource=request.context.get('resource_id'), + meter=self._id, + source=request.context.get('source_id'), + ) + min_ts, max_ts = request.storage_conn.get_event_interval(f) + + # "Clamp" the timestamps we return to the original time + # range, excluding the offset. + LOG.debug('start_timestamp %s, end_timestamp %s, min_ts %s, max_ts %s', + start_timestamp, end_timestamp, min_ts, max_ts) + if start_timestamp and min_ts and min_ts < start_timestamp: + min_ts = start_timestamp + LOG.debug('clamping min timestamp to range') + if end_timestamp and max_ts and max_ts > end_timestamp: + max_ts = end_timestamp + LOG.debug('clamping max timestamp to range') + + # If we got valid timestamps back, compute a duration in minutes. + # + # If the min > max after clamping then we know the + # timestamps on the events fell outside of the time + # range we care about for the query, so treat them as + # "invalid." + # + # If the timestamps are invalid, return None as a + # sentinal indicating that there is something "funny" + # about the range. + if min_ts and max_ts and (min_ts <= max_ts): + # Can't use timedelta.total_seconds() because + # it is not available in Python 2.6. + diff = max_ts - min_ts + duration = (diff.seconds + (diff.days * 24 * 60 ** 2)) / 60 + else: + min_ts = max_ts = duration = None + + return Duration(start_timestamp=min_ts, + end_timestamp=max_ts, + duration=duration, + ) + + +class Meter(Base): + name = text + type = text + resource_id = text + project_id = text + user_id = text + + +class MetersController(RestController): + """Works on meters.""" + + @pecan.expose() + def _lookup(self, meter_id, *remainder): + return MeterController(meter_id), remainder + + @wsme.pecan.wsexpose([Meter]) + def get_all(self): + user_id = request.context.get('user_id') + project_id = request.context.get('project_id') + resource_id = request.context.get('resource_id') + source_id = request.context.get('source_id') + return [Meter(**m) + for m in request.storage_conn.get_meters(user=user_id, + project=project_id, + resource=resource_id, + source=source_id, + )] + + +class ResourceController(RestController): + """Manages operations on a single resource. + """ + + def __init__(self, resource_id): + request.context['resource_id'] = resource_id + + meters = MetersController() + + +class MeterDescription(Base): + counter_name = text + counter_type = text + + +class Resource(Base): + resource_id = text + project_id = text + user_id = text + timestamp = datetime.datetime + #metadata = ? + meter = wsattr([MeterDescription]) + + def __init__(self, meter=[], **kwds): + meter = [MeterDescription(**m) for m in meter] + super(Resource, self).__init__(meter=meter, **kwds) + + +class ResourcesController(RestController): + """Works on resources.""" + + @pecan.expose() + def _lookup(self, resource_id, *remainder): + return ResourceController(resource_id), remainder + + @wsme.pecan.wsexpose([Resource]) + def get_all(self, start_timestamp=None, end_timestamp=None): + if start_timestamp: + start_timestamp = timeutils.parse_isotime(start_timestamp) + if end_timestamp: + end_timestamp = timeutils.parse_isotime(end_timestamp) + + resources = [ + Resource(**r) + for r in request.storage_conn.get_resources( + source=request.context.get('source_id'), + user=request.context.get('user_id'), + project=request.context.get('project_id'), + start_timestamp=start_timestamp, + end_timestamp=end_timestamp, + )] + return resources + + +class ProjectController(RestController): + """Works on resources.""" + + def __init__(self, project_id): + request.context['project_id'] = project_id + + meters = MetersController() + resources = ResourcesController() + + +class ProjectsController(RestController): + """Works on projects.""" + + @pecan.expose() + def _lookup(self, project_id, *remainder): + return ProjectController(project_id), remainder + + @wsme.pecan.wsexpose([text]) + def get_all(self): + source_id = request.context.get('source_id') + projects = list(request.storage_conn.get_projects(source=source_id)) + return projects + + meters = MetersController() + + +class UserController(RestController): + """Works on reusers.""" + + def __init__(self, user_id): + request.context['user_id'] = user_id + + meters = MetersController() + resources = ResourcesController() + + +class UsersController(RestController): + """Works on users.""" + + @pecan.expose() + def _lookup(self, user_id, *remainder): + return UserController(user_id), remainder + + @wsme.pecan.wsexpose([text]) + def get_all(self): + source_id = request.context.get('source_id') + users = list(request.storage_conn.get_users(source=source_id)) + return users + + +class Source(Base): + name = text + data = {text: text} + + +class SourceController(RestController): + """Works on resources.""" + + def __init__(self, source_id, data): + request.context['source_id'] = source_id + self._id = source_id + self._data = data + + @wsme.pecan.wsexpose(Source) + def get(self): + response = Source(name=self._id, data=self._data) + print 'RETURNING:', response + return response + + meters = MetersController() + resources = ResourcesController() + projects = ProjectsController() + users = UsersController() + + +class SourcesController(RestController): + """Works on sources.""" + + def __init__(self): + self._sources = None + + @property + def sources(self): + # FIXME(dhellmann): Add a configuration option for the filename. + # + # FIXME(dhellmann): We only want to load the file once in a process, + # but we want to be able to mock the loading out in separate tests. + # + if not self._sources: + self._sources = self._load_sources(os.path.abspath("sources.json")) + return self._sources + + @staticmethod + def _load_sources(filename): + try: + with open(filename, "r") as f: + sources = jsonutils.load(f) + except IOError as err: + LOG.warning('Could not load data source definitions from %s: %s' % + (filename, err)) + sources = {} + return sources + + @pecan.expose() + def _lookup(self, source_id, *remainder): + try: + data = self.sources[source_id] + except KeyError: + # Unknown source + pecan.abort(404, detail='No source %s' % source_id) + return SourceController(source_id, data), remainder + + @wsme.pecan.wsexpose([Source]) + def get_all(self): + return [Source(name=key, data=value) + for key, value in self.sources.iteritems()] + + +class V2Controller(object): + """Version 2 API controller root.""" + + projects = ProjectsController() + resources = ResourcesController() + sources = SourcesController() + users = UsersController() + meters = MetersController() diff --git a/ceilometer/api/hooks.py b/ceilometer/api/hooks.py new file mode 100644 index 00000000..58edcc19 --- /dev/null +++ b/ceilometer/api/hooks.py @@ -0,0 +1,44 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Doug Hellmann +# +# 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 pecan import hooks + +from ceilometer.openstack.common import cfg +from ceilometer import storage + + +class ConfigHook(hooks.PecanHook): + """Attach the configuration object to the request + so controllers can get to it. + """ + + def before(self, state): + state.request.cfg = cfg.CONF + + +class DBHook(hooks.PecanHook): + + def before(self, state): + storage_engine = storage.get_engine(state.request.cfg) + state.request.storage_engine = storage_engine + state.request.storage_conn = storage_engine.get_connection( + state.request.cfg) + + # def after(self, state): + # print 'method:', state.request.method + # print 'response:', state.response.status diff --git a/ceilometer/api/middleware.py b/ceilometer/api/middleware.py new file mode 100644 index 00000000..22786b61 --- /dev/null +++ b/ceilometer/api/middleware.py @@ -0,0 +1,74 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Doug Hellmann +# +# 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. +"""Middleware to replace the plain text message body of an error +response with one formatted so the client can parse it. + +Based on pecan.middleware.errordocument +""" + +import json + +from webob import exc + + +class ParsableErrorMiddleware(object): + """Replace error body with something the client can parse. + """ + def __init__(self, app): + self.app = app + + def __call__(self, environ, start_response): + # Request for this state, modified by replace_start_response() + # and used when an error is being reported. + state = {} + + def replacement_start_response(status, headers, exc_info=None): + """Overrides the default response to make errors parsable. + """ + try: + status_code = int(status.split(' ')[0]) + state['status_code'] = status_code + except (ValueError, TypeError): # pragma: nocover + raise Exception(( + 'ErrorDocumentMiddleware received an invalid ' + 'status %s' % status + )) + else: + if (state['status_code'] / 100) not in (2, 3): + # Remove some headers so we can replace them later + # when we have the full error message and can + # compute the length. + headers = [(h, v) + for (h, v) in headers + if h not in ('Content-Length', 'Content-Type') + ] + # Save the headers in case we need to modify them. + state['headers'] = headers + return start_response(status, headers, exc_info) + + app_iter = self.app(environ, replacement_start_response) + if (state['status_code'] / 100) not in (2, 3): + # FIXME(dhellmann): Always returns errors as JSON, + # but should look at the environ to determine + # the desired type. + body = [json.dumps({'error_message': '\n'.join(app_iter)})] + state['headers'].append(('Content-Length', len(body[0]))) + state['headers'].append(('Content-Type', 'application/json')) + else: + body = app_iter + return body diff --git a/ceilometer/api/v1/acl.py b/ceilometer/api/v1/acl.py new file mode 100644 index 00000000..555e5afd --- /dev/null +++ b/ceilometer/api/v1/acl.py @@ -0,0 +1,50 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Julien Danjou +# +# 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. +"""Set up the ACL to acces the API server.""" + +import flask +from ceilometer import policy + +import keystoneclient.middleware.auth_token as auth_token + + +def register_opts(conf): + """Register keystoneclient middleware options + """ + conf.register_opts(auth_token.opts, + group='keystone_authtoken', + ) + auth_token.CONF = conf + + +def install(app, conf): + """Install ACL check on application.""" + app.wsgi_app = auth_token.AuthProtocol(app.wsgi_app, + conf=conf, + ) + app.before_request(check) + return app + + +def check(): + """Check application access.""" + headers = flask.request.headers + if not policy.check_is_admin(headers.get('X-Roles', "").split(","), + headers.get('X-Tenant-Id'), + headers.get('X-Tenant-Name')): + return "Access denied", 401 diff --git a/ceilometer/storage/impl_test.py b/ceilometer/storage/impl_test.py index dd6f34a8..a01bc899 100644 --- a/ceilometer/storage/impl_test.py +++ b/ceilometer/storage/impl_test.py @@ -20,10 +20,10 @@ This driver is based on MIM, an in-memory version of MongoDB. """ -import logging - from ming import mim +from ceilometer.openstack.common import log as logging + from ceilometer.storage import base from ceilometer.storage import impl_mongodb diff --git a/ceilometer/tests/api.py b/ceilometer/tests/api.py index 83f442ab..1b0b870e 100644 --- a/ceilometer/tests/api.py +++ b/ceilometer/tests/api.py @@ -19,16 +19,28 @@ """ import json +import os import urllib +import unittest import flask +from pecan import set_config +from pecan.testing import load_test_app -from ceilometer.tests import db as db_test_base -from ceilometer.api.v1 import blueprint as v1_blueprint +import mox +import stubout + +from ceilometer import storage from ceilometer.api.v1 import app as v1_app +from ceilometer.api.v1 import blueprint as v1_blueprint +from ceilometer.api.controllers import v2 +from ceilometer.openstack.common import cfg +from ceilometer.tests import db as db_test_base class TestBase(db_test_base.TestBase): + """Use only for v1 API tests. + """ def setUp(self): super(TestBase, self).setUp() @@ -52,3 +64,102 @@ class TestBase(db_test_base.TestBase): print 'RAW DATA:', rv raise return data + + +class FunctionalTest(unittest.TestCase): + """ + Used for functional tests of Pecan controllers where you need to + test your literal application and its integration with the + framework. + """ + + DBNAME = 'testdb' + + PATH_PREFIX = '' + + SOURCE_DATA = {'test_source': {'somekey': '666'}} + + def setUp(self): + + cfg.CONF.database_connection = 'test://localhost/%s' % self.DBNAME + self.conn = storage.get_connection(cfg.CONF) + # Don't want to use drop_database() because we + # may end up running out of spidermonkey instances. + # http://davisp.lighthouseapp.com/projects/26898/tickets/22 + self.conn.conn[self.DBNAME].clear() + + # Determine where we are so we can set up paths in the config + root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), + '..', + '..', + ) + ) + self.config = { + + 'app': { + 'root': 'ceilometer.api.controllers.root.RootController', + 'modules': ['ceilometer.api'], + 'static_root': '%s/public' % root_dir, + 'template_path': '%s/ceilometer/api/templates' % root_dir, + }, + + 'logging': { + 'loggers': { + 'root': {'level': 'INFO', 'handlers': ['console']}, + 'ceilometer': {'level': 'DEBUG', + 'handlers': ['console'], + }, + }, + 'handlers': { + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + 'formatter': 'simple' + } + }, + 'formatters': { + 'simple': { + 'format': ('%(asctime)s %(levelname)-5.5s [%(name)s]' + '[%(threadName)s] %(message)s') + } + }, + }, + } + + self.mox = mox.Mox() + self.stubs = stubout.StubOutForTesting() + + self.app = self._make_app() + self._stubout_sources() + + def _make_app(self): + return load_test_app(self.config) + + def _stubout_sources(self): + """Source data is usually read from a file, but + we want to let tests define their own. The class + attribute SOURCE_DATA is injected into the controller + as though it was read from the usual configuration + file. + """ + self.stubs.SmartSet(v2.SourcesController, 'sources', + self.SOURCE_DATA) + + def tearDown(self): + self.mox.UnsetStubs() + self.stubs.UnsetAll() + self.stubs.SmartUnsetAll() + self.mox.VerifyAll() + set_config({}, overwrite=True) + + def get_json(self, path, expect_errors=False, headers=None, **params): + full_path = self.PATH_PREFIX + path + print 'GET: %s %r' % (full_path, params) + response = self.app.get(full_path, + params=params, + headers=headers, + expect_errors=expect_errors) + if not expect_errors: + response = response.json + print 'GOT:', response + return response diff --git a/ceilometer/tests/db.py b/ceilometer/tests/db.py index fc460368..cff0d04b 100644 --- a/ceilometer/tests/db.py +++ b/ceilometer/tests/db.py @@ -18,7 +18,6 @@ """Base classes for API tests. """ -import logging import os from ming import mim @@ -27,6 +26,7 @@ import mock from nose.plugins import skip +from ceilometer.openstack.common import log as logging from ceilometer.storage import impl_mongodb from ceilometer.tests import base as test_base diff --git a/tests/api/test_acl.py b/tests/api/v1/test_acl.py similarity index 74% rename from tests/api/test_acl.py rename to tests/api/v1/test_acl.py index d2dbc3cb..66c3a3a7 100644 --- a/tests/api/test_acl.py +++ b/tests/api/v1/test_acl.py @@ -18,8 +18,7 @@ """Test ACL.""" from ceilometer.tests import api as tests_api -from ceilometer.api import acl -from ceilometer.openstack.common import cfg +from ceilometer.api.v1 import acl class TestAPIACL(tests_api.TestBase): @@ -42,14 +41,18 @@ class TestAPIACL(tests_api.TestBase): self.app.preprocess_request() self.assertEqual(self.test_app.get().status_code, 401) - def test_authenticated_wrong_tenant(self): - with self.app.test_request_context('/', headers={ - "X-Roles": "admin", - "X-Tenant-Name": "foobar", - "X-Tenant-Id": "bc23a9d531064583ace8f67dad60f6bb", - }): - self.app.preprocess_request() - self.assertEqual(self.test_app.get().status_code, 401) + # FIXME(dhellmann): This test is not properly looking at the tenant + # info. The status code returned is the expected value, but it + # is not clear why. + # + # def test_authenticated_wrong_tenant(self): + # with self.app.test_request_context('/', headers={ + # "X-Roles": "admin", + # "X-Tenant-Name": "foobar", + # "X-Tenant-Id": "bc23a9d531064583ace8f67dad60f6bb", + # }): + # self.app.preprocess_request() + # self.assertEqual(self.test_app.get().status_code, 401) def test_authenticated(self): with self.app.test_request_context('/', headers={ diff --git a/tests/api/v2/__init__.py b/tests/api/v2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/api/v2/base.py b/tests/api/v2/base.py new file mode 100644 index 00000000..d6af4490 --- /dev/null +++ b/tests/api/v2/base.py @@ -0,0 +1,30 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Doug Hellmann +# +# 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 ceilometer.tests import api + + +class FunctionalTest(api.FunctionalTest): + + PATH_PREFIX = '/v2' + + def setUp(self): + super(FunctionalTest, self).setUp() + + def tearDown(self): + super(FunctionalTest, self).tearDown() diff --git a/tests/api/v2/test_acl.py b/tests/api/v2/test_acl.py new file mode 100644 index 00000000..5798a77a --- /dev/null +++ b/tests/api/v2/test_acl.py @@ -0,0 +1,82 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Julien Danjou +# +# 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. +"""Test ACL.""" + +from ceilometer.api import acl +from ceilometer.api import app +from .base import FunctionalTest + + +class TestAPIACL(FunctionalTest): + + def _make_app(self): + # Save the original app constructor so + # we can use it in our wrapper + original_setup_app = app.setup_app + + # Wrap application construction with + # a function that ensures the AdminAuthHook + # is provided. + def setup_app(config, extra_hooks=[]): + extra_hooks = extra_hooks[:] + extra_hooks.append(acl.AdminAuthHook()) + return original_setup_app(config, extra_hooks) + + self.stubs.Set(app, 'setup_app', setup_app) + result = super(TestAPIACL, self)._make_app() + acl.install(result, {}) + return result + + def test_non_authenticated(self): + response = self.get_json('/sources', expect_errors=True) + self.assertEqual(response.status_code, 401) + + def test_authenticated_wrong_role(self): + response = self.get_json('/sources', + expect_errors=True, + headers={ + "X-Roles": "Member", + "X-Tenant-Name": "admin", + "X-Tenant-Id": "bc23a9d531064583ace8f67dad60f6bb", + }) + self.assertEqual(response.status_code, 401) + + # FIXME(dhellmann): This test is not properly looking at the tenant + # info. We do not correctly detect the improper tenant. That's + # really something the keystone middleware would have to do using + # the incoming token, which we aren't providing. + # + # def test_authenticated_wrong_tenant(self): + # response = self.get_json('/sources', + # expect_errors=True, + # headers={ + # "X-Roles": "admin", + # "X-Tenant-Name": "achoo", + # "X-Tenant-Id": "bc23a9d531064583ace8f67dad60f6bb", + # }) + # self.assertEqual(response.status_code, 401) + + def test_authenticated(self): + response = self.get_json('/sources', + expect_errors=True, + headers={ + "X-Roles": "admin", + "X-Tenant-Name": "admin", + "X-Tenant-Id": "bc23a9d531064583ace8f67dad60f6bb", + }) + self.assertEqual(response.status_code, 200) diff --git a/tests/api/v2/test_compute_duration_by_resource.py b/tests/api/v2/test_compute_duration_by_resource.py new file mode 100644 index 00000000..a5be8051 --- /dev/null +++ b/tests/api/v2/test_compute_duration_by_resource.py @@ -0,0 +1,143 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Doug Hellmann +# +# 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. +"""Test listing raw events. +""" + +import datetime +import logging + +from ceilometer.openstack.common import timeutils +from ceilometer.storage import impl_test +from .base import FunctionalTest + +LOG = logging.getLogger(__name__) + + +class TestComputeDurationByResource(FunctionalTest): + + def setUp(self): + super(TestComputeDurationByResource, self).setUp() + + # Create events relative to the range and pretend + # that the intervening events exist. + + self.early1 = datetime.datetime(2012, 8, 27, 7, 0) + self.early2 = datetime.datetime(2012, 8, 27, 17, 0) + + self.start = datetime.datetime(2012, 8, 28, 0, 0) + + self.middle1 = datetime.datetime(2012, 8, 28, 8, 0) + self.middle2 = datetime.datetime(2012, 8, 28, 18, 0) + + self.end = datetime.datetime(2012, 8, 28, 23, 59) + + self.late1 = datetime.datetime(2012, 8, 29, 9, 0) + self.late2 = datetime.datetime(2012, 8, 29, 19, 0) + + def _stub_interval_func(self, func): + self.stubs.Set(impl_test.TestConnection, + 'get_event_interval', + func) + + def _set_interval(self, start, end): + def get_interval(ignore_self, event_filter): + assert event_filter.start + assert event_filter.end + return (start, end) + self._stub_interval_func(get_interval) + + def _invoke_api(self): + return self.get_json( + '/resources/resource-id/meters/instance:m1.tiny/duration', + start_timestamp=self.start.isoformat(), + end_timestamp=self.end.isoformat(), + search_offset=10, # this value doesn't matter, db call is mocked + ) + + def test_before_range(self): + self._set_interval(self.early1, self.early2) + data = self._invoke_api() + assert data['start_timestamp'] is None + assert data['end_timestamp'] is None + assert data['duration'] is None + + def _assert_times_match(self, actual, expected): + #import pdb; pdb.set_trace() + if actual: + actual = timeutils.parse_isotime(actual) + actual = actual.replace(tzinfo=None) + assert actual == expected + + def test_overlap_range_start(self): + self._set_interval(self.early1, self.middle1) + data = self._invoke_api() + self._assert_times_match(data['start_timestamp'], self.start) + self._assert_times_match(data['end_timestamp'], self.middle1) + assert data['duration'] == 8 * 60 + + def test_within_range(self): + self._set_interval(self.middle1, self.middle2) + data = self._invoke_api() + self._assert_times_match(data['start_timestamp'], self.middle1) + self._assert_times_match(data['end_timestamp'], self.middle2) + assert data['duration'] == 10 * 60 + + def test_within_range_zero_duration(self): + self._set_interval(self.middle1, self.middle1) + data = self._invoke_api() + self._assert_times_match(data['start_timestamp'], self.middle1) + self._assert_times_match(data['end_timestamp'], self.middle1) + assert data['duration'] == 0 + + def test_overlap_range_end(self): + self._set_interval(self.middle2, self.late1) + data = self._invoke_api() + self._assert_times_match(data['start_timestamp'], self.middle2) + self._assert_times_match(data['end_timestamp'], self.end) + assert data['duration'] == (6 * 60) - 1 + + def test_after_range(self): + self._set_interval(self.late1, self.late2) + data = self._invoke_api() + assert data['start_timestamp'] is None + assert data['end_timestamp'] is None + assert data['duration'] is None + + def test_without_end_timestamp(self): + def get_interval(ignore_self, event_filter): + return (self.late1, self.late2) + self._stub_interval_func(get_interval) + data = self.get_json( + '/resources/resource-id/meters/instance:m1.tiny/duration', + start_timestamp=self.late1.isoformat(), + search_offset=10, # this value doesn't matter, db call is mocked + ) + self._assert_times_match(data['start_timestamp'], self.late1) + self._assert_times_match(data['end_timestamp'], self.late2) + + def test_without_start_timestamp(self): + def get_interval(ignore_self, event_filter): + return (self.early1, self.early2) + self._stub_interval_func(get_interval) + data = self.get_json( + '/resources/resource-id/meters/instance:m1.tiny/duration', + end_timestamp=self.early2.isoformat(), + search_offset=10, # this value doesn't matter, db call is mocked + ) + self._assert_times_match(data['start_timestamp'], self.early1) + self._assert_times_match(data['end_timestamp'], self.early2) diff --git a/tests/api/v2/test_get_query_ts.py b/tests/api/v2/test_get_query_ts.py new file mode 100644 index 00000000..b123107f --- /dev/null +++ b/tests/api/v2/test_get_query_ts.py @@ -0,0 +1,80 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Steven Berler +# +# 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. +"""Test the _get_query_timestamps helper function. +""" + +import unittest +import datetime + +from ceilometer.api.controllers import v2 as api + + +class TimestampTest(unittest.TestCase): + + def test_get_query_timestamps_none_specified(self): + result = api._get_query_timestamps() + expected = {'start_timestamp': None, + 'end_timestamp': None, + 'query_start': None, + 'query_end': None, + 'search_offset': 0, + } + + assert result == expected + + def test_get_query_timestamps_start(self): + args = {'start_timestamp': '2012-09-20T12:13:14'} + result = api._get_query_timestamps(args) + expected = { + 'start_timestamp': datetime.datetime(2012, 9, 20, 12, 13, 14), + 'end_timestamp': None, + 'query_start': datetime.datetime(2012, 9, 20, 12, 13, 14), + 'query_end': None, + 'search_offset': 0, + } + + assert result == expected + + def test_get_query_timestamps_end(self): + args = {'end_timestamp': '2012-09-20T12:13:14'} + result = api._get_query_timestamps(args) + expected = { + 'end_timestamp': datetime.datetime(2012, 9, 20, 12, 13, 14), + 'start_timestamp': None, + 'query_end': datetime.datetime(2012, 9, 20, 12, 13, 14), + 'query_start': None, + 'search_offset': 0, + } + + assert result == expected + + def test_get_query_timestamps_with_offset(self): + args = {'start_timestamp': '2012-09-20T12:13:14', + 'end_timestamp': '2012-09-20T13:24:25', + 'search_offset': '20', + } + result = api._get_query_timestamps(args) + expected = { + 'query_end': datetime.datetime(2012, 9, 20, 13, 44, 25), + 'query_start': datetime.datetime(2012, 9, 20, 11, 53, 14), + 'end_timestamp': datetime.datetime(2012, 9, 20, 13, 24, 25), + 'start_timestamp': datetime.datetime(2012, 9, 20, 12, 13, 14), + 'search_offset': 20, + } + + assert result == expected diff --git a/tests/api/v2/test_list_events.py b/tests/api/v2/test_list_events.py new file mode 100644 index 00000000..e8db9fde --- /dev/null +++ b/tests/api/v2/test_list_events.py @@ -0,0 +1,108 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Doug Hellmann +# +# 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. +"""Test listing raw events. +""" + +import datetime +import logging + +from ceilometer import counter +from ceilometer import meter +from ceilometer.openstack.common import cfg + +from .base import FunctionalTest + +LOG = logging.getLogger(__name__) + + +class TestListEvents(FunctionalTest): + + def setUp(self): + super(TestListEvents, self).setUp() + self.counter1 = counter.Counter( + 'instance', + 'cumulative', + 1, + 'user-id', + 'project1', + 'resource-id', + timestamp=datetime.datetime(2012, 7, 2, 10, 40), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter', + } + ) + msg = meter.meter_message_from_counter(self.counter1, + cfg.CONF.metering_secret, + 'test_source', + ) + self.conn.record_metering_data(msg) + + self.counter2 = counter.Counter( + 'instance', + 'cumulative', + 1, + 'user-id2', + 'project2', + 'resource-id-alternate', + timestamp=datetime.datetime(2012, 7, 2, 10, 41), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter2', + } + ) + msg2 = meter.meter_message_from_counter(self.counter2, + cfg.CONF.metering_secret, + 'source2', + ) + self.conn.record_metering_data(msg2) + + def test_all(self): + data = self.get_json('/resources') + self.assertEquals(2, len(data)) + + def test_empty_project(self): + data = self.get_json('/projects/no-such-project/meters/instance') + self.assertEquals([], data) + + def test_by_project(self): + data = self.get_json('/projects/project1/meters/instance') + self.assertEquals(1, len(data)) + + def test_empty_resource(self): + data = self.get_json('/resources/no-such-resource/meters/instance') + self.assertEquals([], data) + + def test_by_resource(self): + data = self.get_json('/resources/resource-id/meters/instance') + self.assertEquals(1, len(data)) + + def test_empty_source(self): + data = self.get_json('/sources/no-such-source/meters/instance', + expect_errors=True) + self.assertEquals(data.status_code, 404) + + def test_by_source(self): + data = self.get_json('/sources/test_source/meters/instance') + self.assertEquals(1, len(data)) + + def test_empty_user(self): + data = self.get_json('/users/no-such-user/meters/instance') + self.assertEquals([], data) + + def test_by_user(self): + data = self.get_json('/users/user-id/meters/instance') + self.assertEquals(1, len(data)) diff --git a/tests/api/v2/test_list_meters.py b/tests/api/v2/test_list_meters.py new file mode 100644 index 00000000..79c9f6c5 --- /dev/null +++ b/tests/api/v2/test_list_meters.py @@ -0,0 +1,157 @@ +# -*- encoding: utf-8 -*- +# +# Copyright 2012 Red Hat, Inc. +# +# Author: Angus Salkeld +# +# 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. +"""Test listing meters. +""" + +import datetime +import logging + +from ceilometer import counter +from ceilometer import meter +from ceilometer.openstack.common import cfg + +from .base import FunctionalTest + +LOG = logging.getLogger(__name__) + + +class TestListEmptyMeters(FunctionalTest): + + def test_empty(self): + data = self.get_json('/meters') + self.assertEquals([], data) + + +class TestListMeters(FunctionalTest): + + def setUp(self): + super(TestListMeters, self).setUp() + + for cnt in [ + counter.Counter( + 'meter.test', + 'cumulative', + 1, + 'user-id', + 'project-id', + 'resource-id', + timestamp=datetime.datetime(2012, 7, 2, 10, 40), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter', + }), + counter.Counter( + 'meter.test', + 'cumulative', + 3, + 'user-id', + 'project-id', + 'resource-id', + timestamp=datetime.datetime(2012, 7, 2, 11, 40), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter', + }), + counter.Counter( + 'meter.mine', + 'gauge', + 1, + 'user-id', + 'project-id', + 'resource-id2', + timestamp=datetime.datetime(2012, 7, 2, 10, 41), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter2', + }), + counter.Counter( + 'meter.test', + 'cumulative', + 1, + 'user-id2', + 'project-id2', + 'resource-id3', + timestamp=datetime.datetime(2012, 7, 2, 10, 42), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter3', + }), + counter.Counter( + 'meter.mine', + 'gauge', + 1, + 'user-id4', + 'project-id2', + 'resource-id4', + timestamp=datetime.datetime(2012, 7, 2, 10, 43), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter4', + })]: + msg = meter.meter_message_from_counter(cnt, + cfg.CONF.metering_secret, + 'test_source') + self.conn.record_metering_data(msg) + + def test_list_meters(self): + data = self.get_json('/meters') + self.assertEquals(4, len(data)) + self.assertEquals(set(r['resource_id'] for r in data), + set(['resource-id', + 'resource-id2', + 'resource-id3', + 'resource-id4'])) + self.assertEquals(set(r['name'] for r in data), + set(['meter.test', + 'meter.mine'])) + + def test_with_resource(self): + data = self.get_json('/resources/resource-id/meters') + ids = set(r['name'] for r in data) + self.assertEquals(set(['meter.test']), ids) + + def test_with_source(self): + data = self.get_json('/sources/test_source/meters') + ids = set(r['resource_id'] for r in data) + self.assertEquals(set(['resource-id', + 'resource-id2', + 'resource-id3', + 'resource-id4']), ids) + + def test_with_source_non_existent(self): + data = self.get_json('/sources/test_source_doesnt_exist/meters', + expect_errors=True) + self.assert_('No source test_source_doesnt_exist' in + data.json['error_message']) + + def test_with_user(self): + data = self.get_json('/users/user-id/meters') + + nids = set(r['name'] for r in data) + self.assertEquals(set(['meter.mine', 'meter.test']), nids) + + rids = set(r['resource_id'] for r in data) + self.assertEquals(set(['resource-id', 'resource-id2']), rids) + + def test_with_user_non_existent(self): + data = self.get_json('/users/user-id-foobar123/meters') + self.assertEquals(data, []) + + def test_with_project(self): + data = self.get_json('/projects/project-id2/meters') + ids = set(r['resource_id'] for r in data) + self.assertEquals(set(['resource-id3', 'resource-id4']), ids) + + def test_with_project_non_existent(self): + data = self.get_json('/projects/jd-was-here/meters') + self.assertEquals(data, []) diff --git a/tests/api/v2/test_list_projects.py b/tests/api/v2/test_list_projects.py new file mode 100644 index 00000000..2ba21b4d --- /dev/null +++ b/tests/api/v2/test_list_projects.py @@ -0,0 +1,118 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Doug Hellmann +# +# 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. +"""Test listing users. +""" + +import datetime +import logging + +from ceilometer import counter +from ceilometer import meter + +from ceilometer.openstack.common import cfg + +from .base import FunctionalTest + +LOG = logging.getLogger(__name__) + + +class TestListProjects(FunctionalTest): + + def test_empty(self): + data = self.get_json('/projects') + self.assertEquals([], data) + + def test_projects(self): + counter1 = counter.Counter( + 'instance', + 'cumulative', + 1, + 'user-id', + 'project-id', + 'resource-id', + timestamp=datetime.datetime(2012, 7, 2, 10, 40), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter', + } + ) + msg = meter.meter_message_from_counter(counter1, + cfg.CONF.metering_secret, + 'test_source', + ) + self.conn.record_metering_data(msg) + + counter2 = counter.Counter( + 'instance', + 'cumulative', + 1, + 'user-id2', + 'project-id2', + 'resource-id-alternate', + timestamp=datetime.datetime(2012, 7, 2, 10, 41), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter2', + } + ) + msg2 = meter.meter_message_from_counter(counter2, + cfg.CONF.metering_secret, + 'test_source', + ) + self.conn.record_metering_data(msg2) + + data = self.get_json('/projects') + self.assertEquals(['project-id', 'project-id2'], data) + + def test_with_source(self): + counter1 = counter.Counter( + 'instance', + 'cumulative', + 1, + 'user-id', + 'project-id', + 'resource-id', + timestamp=datetime.datetime(2012, 7, 2, 10, 40), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter', + } + ) + msg = meter.meter_message_from_counter(counter1, + cfg.CONF.metering_secret, + 'test_source', + ) + self.conn.record_metering_data(msg) + + counter2 = counter.Counter( + 'instance', + 'cumulative', + 1, + 'user-id2', + 'project-id2', + 'resource-id-alternate', + timestamp=datetime.datetime(2012, 7, 2, 10, 41), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter2', + } + ) + msg2 = meter.meter_message_from_counter(counter2, + cfg.CONF.metering_secret, + 'not-test', + ) + self.conn.record_metering_data(msg2) + + data = self.get_json('/sources/test_source/projects') + self.assertEquals(['project-id'], data) diff --git a/tests/api/v2/test_list_resources.py b/tests/api/v2/test_list_resources.py new file mode 100644 index 00000000..c27dd38c --- /dev/null +++ b/tests/api/v2/test_list_resources.py @@ -0,0 +1,202 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Doug Hellmann +# +# 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. +"""Test listing resources. +""" + +import datetime +import logging + +from ceilometer import counter +from ceilometer import meter +from ceilometer.openstack.common import cfg + +from .base import FunctionalTest + +LOG = logging.getLogger(__name__) + + +class TestListResources(FunctionalTest): + + SOURCE_DATA = {'test_list_resources': {}} + + def test_empty(self): + data = self.get_json('/resources') + self.assertEquals([], data) + + def test_instances(self): + counter1 = counter.Counter( + 'instance', + 'cumulative', + 1, + 'user-id', + 'project-id', + 'resource-id', + timestamp=datetime.datetime(2012, 7, 2, 10, 40), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter', + } + ) + msg = meter.meter_message_from_counter(counter1, + cfg.CONF.metering_secret, + 'test', + ) + self.conn.record_metering_data(msg) + + counter2 = counter.Counter( + 'instance', + 'cumulative', + 1, + 'user-id', + 'project-id', + 'resource-id-alternate', + timestamp=datetime.datetime(2012, 7, 2, 10, 41), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter2', + } + ) + msg2 = meter.meter_message_from_counter(counter2, + cfg.CONF.metering_secret, + 'test', + ) + self.conn.record_metering_data(msg2) + + data = self.get_json('/resources') + self.assertEquals(2, len(data)) + + def test_with_source(self): + counter1 = counter.Counter( + 'instance', + 'cumulative', + 1, + 'user-id', + 'project-id', + 'resource-id', + timestamp=datetime.datetime(2012, 7, 2, 10, 40), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter', + } + ) + msg = meter.meter_message_from_counter(counter1, + cfg.CONF.metering_secret, + 'test_list_resources', + ) + self.conn.record_metering_data(msg) + + counter2 = counter.Counter( + 'instance', + 'cumulative', + 1, + 'user-id2', + 'project-id', + 'resource-id-alternate', + timestamp=datetime.datetime(2012, 7, 2, 10, 41), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter2', + } + ) + msg2 = meter.meter_message_from_counter(counter2, + cfg.CONF.metering_secret, + 'not-test', + ) + self.conn.record_metering_data(msg2) + + data = self.get_json('/sources/test_list_resources/resources') + ids = [r['resource_id'] for r in data] + self.assertEquals(['resource-id'], ids) + + def test_with_user(self): + counter1 = counter.Counter( + 'instance', + 'cumulative', + 1, + 'user-id', + 'project-id', + 'resource-id', + timestamp=datetime.datetime(2012, 7, 2, 10, 40), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter', + } + ) + msg = meter.meter_message_from_counter(counter1, + cfg.CONF.metering_secret, + 'test_list_resources', + ) + self.conn.record_metering_data(msg) + + counter2 = counter.Counter( + 'instance', + 'cumulative', + 1, + 'user-id2', + 'project-id', + 'resource-id-alternate', + timestamp=datetime.datetime(2012, 7, 2, 10, 41), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter2', + } + ) + msg2 = meter.meter_message_from_counter(counter2, + cfg.CONF.metering_secret, + 'not-test', + ) + self.conn.record_metering_data(msg2) + + data = self.get_json('/users/user-id/resources') + ids = [r['resource_id'] for r in data] + self.assertEquals(['resource-id'], ids) + + def test_with_project(self): + counter1 = counter.Counter( + 'instance', + 'cumulative', + 1, + 'user-id', + 'project-id', + 'resource-id', + timestamp=datetime.datetime(2012, 7, 2, 10, 40), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter', + } + ) + msg = meter.meter_message_from_counter(counter1, + cfg.CONF.metering_secret, + 'test_list_resources', + ) + self.conn.record_metering_data(msg) + + counter2 = counter.Counter( + 'instance', + 'cumulative', + 1, + 'user-id2', + 'project-id2', + 'resource-id-alternate', + timestamp=datetime.datetime(2012, 7, 2, 10, 41), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter2', + } + ) + msg2 = meter.meter_message_from_counter(counter2, + cfg.CONF.metering_secret, + 'not-test', + ) + self.conn.record_metering_data(msg2) + + data = self.get_json('/projects/project-id/resources') + ids = [r['resource_id'] for r in data] + self.assertEquals(['resource-id'], ids) diff --git a/tests/api/v2/test_list_sources.py b/tests/api/v2/test_list_sources.py new file mode 100644 index 00000000..5f403f26 --- /dev/null +++ b/tests/api/v2/test_list_sources.py @@ -0,0 +1,47 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 Julien Danjou +# +# Author: Julien Danjou +# +# 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. +"""Test listing users. +""" + +from .base import FunctionalTest + + +class TestListSource(FunctionalTest): + + def test_all(self): + ydata = self.get_json('/sources') + self.assertEqual(len(ydata), 1) + source = ydata[0] + self.assertEqual(source['name'], 'test_source') + + def test_source(self): + ydata = self.get_json('/sources/test_source') + self.assert_("data" in ydata) + self.assert_("somekey" in ydata['data']) + self.assertEqual(ydata['data']["somekey"], '666') + + def test_unknownsource(self): + ydata = self.get_json( + '/sources/test_source_that_does_not_exist', + expect_errors=True) + print 'GOT:', ydata + self.assertEqual(ydata.status_code, 404) + self.assert_( + "No source test_source_that_does_not_exist" in + ydata.json['error_message'] + ) diff --git a/tests/api/v2/test_list_users.py b/tests/api/v2/test_list_users.py new file mode 100644 index 00000000..4f5c37ff --- /dev/null +++ b/tests/api/v2/test_list_users.py @@ -0,0 +1,120 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Doug Hellmann +# +# 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. +"""Test listing users. +""" + +import datetime +import logging + +from ceilometer import counter +from ceilometer import meter + +from ceilometer.openstack.common import cfg + +from .base import FunctionalTest + +LOG = logging.getLogger(__name__) + + +class TestListUsers(FunctionalTest): + + SOURCE_DATA = {'test_list_users': {}} + + def test_empty(self): + data = self.get_json('/users') + self.assertEquals([], data) + + def test_users(self): + counter1 = counter.Counter( + 'instance', + 'cumulative', + 1, + 'user-id', + 'project-id', + 'resource-id', + timestamp=datetime.datetime(2012, 7, 2, 10, 40), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter', + } + ) + msg = meter.meter_message_from_counter(counter1, + cfg.CONF.metering_secret, + 'test_list_users', + ) + self.conn.record_metering_data(msg) + + counter2 = counter.Counter( + 'instance', + 'cumulative', + 1, + 'user-id2', + 'project-id', + 'resource-id-alternate', + timestamp=datetime.datetime(2012, 7, 2, 10, 41), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter2', + } + ) + msg2 = meter.meter_message_from_counter(counter2, + cfg.CONF.metering_secret, + 'test_list_users', + ) + self.conn.record_metering_data(msg2) + + data = self.get_json('/users') + self.assertEquals(['user-id', 'user-id2'], data) + + def test_with_source(self): + counter1 = counter.Counter( + 'instance', + 'cumulative', + 1, + 'user-id', + 'project-id', + 'resource-id', + timestamp=datetime.datetime(2012, 7, 2, 10, 40), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter', + } + ) + msg = meter.meter_message_from_counter(counter1, + cfg.CONF.metering_secret, + 'test_list_users', + ) + self.conn.record_metering_data(msg) + + counter2 = counter.Counter( + 'instance', + 'cumulative', + 1, + 'user-id2', + 'project-id', + 'resource-id-alternate', + timestamp=datetime.datetime(2012, 7, 2, 10, 41), + resource_metadata={'display_name': 'test-server', + 'tag': 'self.counter2', + } + ) + msg2 = meter.meter_message_from_counter(counter2, + cfg.CONF.metering_secret, + 'not-test', + ) + self.conn.record_metering_data(msg2) + + data = self.get_json('/sources/test_list_users/users') + self.assertEquals(['user-id'], data) diff --git a/tests/api/v2/test_max_project_volume.py b/tests/api/v2/test_max_project_volume.py new file mode 100644 index 00000000..b4b05ac8 --- /dev/null +++ b/tests/api/v2/test_max_project_volume.py @@ -0,0 +1,95 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Steven Berler +# +# 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. +"""Test getting the max resource volume. +""" + +import datetime + +from ceilometer import counter +from ceilometer import meter + +from ceilometer.openstack.common import cfg +from ceilometer.tests.db import require_map_reduce + +from .base import FunctionalTest + + +class TestMaxProjectVolume(FunctionalTest): + + PATH = '/projects/project1/meters/volume.size/volume/max' + + def setUp(self): + super(TestMaxProjectVolume, self).setUp() + require_map_reduce(self.conn) + + self.counters = [] + for i in range(3): + c = counter.Counter( + 'volume.size', + 'gauge', + 5 + i, + 'user-id', + 'project1', + 'resource-id-%s' % i, + timestamp=datetime.datetime(2012, 9, 25, 10 + i, 30 + i), + resource_metadata={'display_name': 'test-volume', + 'tag': 'self.counter', + } + ) + self.counters.append(c) + msg = meter.meter_message_from_counter(c, + cfg.CONF.metering_secret, + 'source1', + ) + self.conn.record_metering_data(msg) + + def test_no_time_bounds(self): + data = self.get_json(self.PATH) + expected = {'volume': 7} + self.assertEqual(data, expected) + + def test_start_timestamp(self): + data = self.get_json(self.PATH, + start_timestamp='2012-09-25T11:30:00') + expected = {'volume': 7} + self.assertEqual(data, expected) + + def test_start_timestamp_after(self): + data = self.get_json(self.PATH, + start_timestamp='2012-09-25T12:34:00') + expected = {'volume': None} + self.assertEqual(data, expected) + + def test_end_timestamp(self): + data = self.get_json(self.PATH, + end_timestamp='2012-09-25T11:30:00') + expected = {'volume': 5} + self.assertEqual(data, expected) + + def test_end_timestamp_before(self): + data = self.get_json(self.PATH, + end_timestamp='2012-09-25T09:54:00') + expected = {'volume': None} + self.assertEqual(data, expected) + + def test_start_end_timestamp(self): + data = self.get_json(self.PATH, + start_timestamp='2012-09-25T11:30:00', + end_timestamp='2012-09-25T11:32:00') + expected = {'volume': 6} + self.assertEqual(data, expected) diff --git a/tests/api/v2/test_max_resource_volume.py b/tests/api/v2/test_max_resource_volume.py new file mode 100644 index 00000000..ad757a3f --- /dev/null +++ b/tests/api/v2/test_max_resource_volume.py @@ -0,0 +1,94 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Steven Berler +# +# 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. +"""Test getting the max resource volume. +""" + +import datetime + +from ceilometer import counter +from ceilometer import meter + +from ceilometer.openstack.common import cfg +from .base import FunctionalTest +from ceilometer.tests.db import require_map_reduce + + +class TestMaxResourceVolume(FunctionalTest): + + PATH = '/resources/resource-id/meters/volume.size/volume/max' + + def setUp(self): + super(TestMaxResourceVolume, self).setUp() + require_map_reduce(self.conn) + + self.counters = [] + for i in range(3): + c = counter.Counter( + 'volume.size', + 'gauge', + 5 + i, + 'user-id', + 'project1', + 'resource-id', + timestamp=datetime.datetime(2012, 9, 25, 10 + i, 30 + i), + resource_metadata={'display_name': 'test-volume', + 'tag': 'self.counter', + } + ) + self.counters.append(c) + msg = meter.meter_message_from_counter(c, + cfg.CONF.metering_secret, + 'source1', + ) + self.conn.record_metering_data(msg) + + def test_no_time_bounds(self): + data = self.get_json(self.PATH) + expected = {'volume': 7} + assert data == expected + + def test_start_timestamp(self): + data = self.get_json(self.PATH, + start_timestamp='2012-09-25T11:30:00') + expected = {'volume': 7} + assert data == expected + + def test_start_timestamp_after(self): + data = self.get_json(self.PATH, + start_timestamp='2012-09-25T12:34:00') + expected = {'volume': None} + assert data == expected + + def test_end_timestamp(self): + data = self.get_json(self.PATH, + end_timestamp='2012-09-25T11:30:00') + expected = {'volume': 5} + assert data == expected + + def test_end_timestamp_before(self): + data = self.get_json(self.PATH, + end_timestamp='2012-09-25T09:54:00') + expected = {'volume': None} + assert data == expected + + def test_start_end_timestamp(self): + data = self.get_json(self.PATH, + start_timestamp='2012-09-25T11:30:00', + end_timestamp='2012-09-25T11:32:00') + expected = {'volume': 6} + assert data == expected diff --git a/tests/api/v2/test_sum_project_volume.py b/tests/api/v2/test_sum_project_volume.py new file mode 100644 index 00000000..4b1457e2 --- /dev/null +++ b/tests/api/v2/test_sum_project_volume.py @@ -0,0 +1,94 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Steven Berler +# +# 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. +"""Test getting the sum project volume. +""" + +import datetime + +from ceilometer import counter +from ceilometer import meter + +from ceilometer.openstack.common import cfg +from .base import FunctionalTest +from ceilometer.tests.db import require_map_reduce + + +class TestSumProjectVolume(FunctionalTest): + + PATH = '/projects/project1/meters/volume.size/volume/sum' + + def setUp(self): + super(TestSumProjectVolume, self).setUp() + require_map_reduce(self.conn) + + self.counters = [] + for i in range(3): + c = counter.Counter( + 'volume.size', + 'gauge', + 5 + i, + 'user-id', + 'project1', + 'resource-id-%s' % i, + timestamp=datetime.datetime(2012, 9, 25, 10 + i, 30 + i), + resource_metadata={'display_name': 'test-volume', + 'tag': 'self.counter', + } + ) + self.counters.append(c) + msg = meter.meter_message_from_counter(c, + cfg.CONF.metering_secret, + 'source1', + ) + self.conn.record_metering_data(msg) + + def test_no_time_bounds(self): + data = self.get_json(self.PATH) + expected = {'volume': 5 + 6 + 7} + assert data == expected + + def test_start_timestamp(self): + data = self.get_json(self.PATH, + start_timestamp='2012-09-25T11:30:00') + expected = {'volume': 6 + 7} + assert data == expected + + def test_start_timestamp_after(self): + data = self.get_json(self.PATH, + start_timestamp='2012-09-25T12:34:00') + expected = {'volume': None} + assert data == expected + + def test_end_timestamp(self): + data = self.get_json(self.PATH, + end_timestamp='2012-09-25T11:30:00') + expected = {'volume': 5} + assert data == expected + + def test_end_timestamp_before(self): + data = self.get_json(self.PATH, + end_timestamp='2012-09-25T09:54:00') + expected = {'volume': None} + assert data == expected + + def test_start_end_timestamp(self): + data = self.get_json(self.PATH, + start_timestamp='2012-09-25T11:30:00', + end_timestamp='2012-09-25T11:32:00') + expected = {'volume': 6} + assert data == expected diff --git a/tests/api/v2/test_sum_resource_volume.py b/tests/api/v2/test_sum_resource_volume.py new file mode 100644 index 00000000..14322d8c --- /dev/null +++ b/tests/api/v2/test_sum_resource_volume.py @@ -0,0 +1,94 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Doug Hellmann +# +# 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. +"""Test getting the total resource volume. +""" + +import datetime + +from ceilometer import counter +from ceilometer import meter + +from ceilometer.openstack.common import cfg +from .base import FunctionalTest +from ceilometer.tests.db import require_map_reduce + + +class TestSumResourceVolume(FunctionalTest): + + PATH = '/resources/resource-id/meters/volume.size/volume/sum' + + def setUp(self): + super(TestSumResourceVolume, self).setUp() + require_map_reduce(self.conn) + + self.counters = [] + for i in range(3): + c = counter.Counter( + 'volume.size', + 'gauge', + 5 + i, + 'user-id', + 'project1', + 'resource-id', + timestamp=datetime.datetime(2012, 9, 25, 10 + i, 30 + i), + resource_metadata={'display_name': 'test-volume', + 'tag': 'self.counter', + } + ) + self.counters.append(c) + msg = meter.meter_message_from_counter(c, + cfg.CONF.metering_secret, + 'source1', + ) + self.conn.record_metering_data(msg) + + def test_no_time_bounds(self): + data = self.get_json(self.PATH) + expected = {'volume': 5 + 6 + 7} + assert data == expected + + def test_start_timestamp(self): + data = self.get_json(self.PATH, + start_timestamp='2012-09-25T11:30:00') + expected = {'volume': 6 + 7} + assert data == expected + + def test_start_timestamp_after(self): + data = self.get_json(self.PATH, + start_timestamp='2012-09-25T12:34:00') + expected = {'volume': None} + assert data == expected + + def test_end_timestamp(self): + data = self.get_json(self.PATH, + end_timestamp='2012-09-25T11:30:00') + expected = {'volume': 5} + assert data == expected + + def test_end_timestamp_before(self): + data = self.get_json(self.PATH, + end_timestamp='2012-09-25T09:54:00') + expected = {'volume': None} + assert data == expected + + def test_start_end_timestamp(self): + data = self.get_json(self.PATH, + start_timestamp='2012-09-25T11:30:00', + end_timestamp='2012-09-25T11:32:00') + expected = {'volume': 6} + assert data == expected diff --git a/tools/pip-requires b/tools/pip-requires index e1033ed0..a6f395ff 100644 --- a/tools/pip-requires +++ b/tools/pip-requires @@ -15,3 +15,4 @@ python-glanceclient python-novaclient>=2.6.10 python-keystoneclient>=0.2,<0.3 python-swiftclient +pecan diff --git a/tools/test-requires b/tools/test-requires index 34311d47..607a005c 100644 --- a/tools/test-requires +++ b/tools/test-requires @@ -14,3 +14,9 @@ https://github.com/dreamhost/Ming/zipball/master#egg=Ming http://tarballs.openstack.org/nova/nova-master.tar.gz http://tarballs.openstack.org/glance/glance-master.tar.gz setuptools-git>=0.4 +# FIXME(dhellmann): We need a version of WSME more current +# than what is released right now. We can't include it in +# pip-requires because we have to point to the Mercurial +# checkout on bitbucket. I hope to have that resolved +# very soon. +hg+https://bitbucket.org/cdevienne/wsme diff --git a/tools/test-requires-folsom b/tools/test-requires-folsom index dec44ccb..15f5078c 100644 --- a/tools/test-requires-folsom +++ b/tools/test-requires-folsom @@ -14,3 +14,9 @@ mox https://github.com/dreamhost/Ming/zipball/master#egg=Ming http://tarballs.openstack.org/glance/glance-stable-folsom.tar.gz setuptools-git>=0.4 +# FIXME(dhellmann): We need a version of WSME more current +# than what is released right now. We can't include it in +# pip-requires because we have to point to the Mercurial +# checkout on bitbucket. I hope to have that resolved +# very soon. +hg+https://bitbucket.org/cdevienne/wsme