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 <doug.hellmann@dreamhost.com> Change-Id: I1b99a16de68f902370a8999eca073c56f9f14865changes/57/17457/4
parent
42f1f02077
commit
3173ab4c4b
@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- encoding: utf-8 -*-
|
||||
#
|
||||
# Copyright © 2012 New Dream Network, LLC (DreamHost)
|
||||
#
|
||||
# Author: Doug Hellmann <doug.hellmann@dreamhost.com>
|
||||
#
|
||||
# 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)
|
@ -0,0 +1,44 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
#
|
||||
# Copyright © 2012 New Dream Network, LLC (DreamHost)
|
||||
#
|
||||
# Author: Doug Hellmann <doug.hellmann@dreamhost.com>
|
||||
#
|
||||
# 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,
|
||||
)
|
@ -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
|
@ -0,0 +1,31 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
#
|
||||
# Copyright © 2012 New Dream Network, LLC (DreamHost)
|
||||
#
|
||||
# Author: Doug Hellmann <doug.hellmann@dreamhost.com>
|
||||
#
|
||||
# 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()
|
@ -0,0 +1,560 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
#
|
||||
# Copyright © 2012 New Dream Network, LLC (DreamHost)
|
||||
#
|
||||
# Author: Doug Hellmann <doug.hellmann@dreamhost.com>
|
||||
#
|
||||
# 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/<extension> -- 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/<project>/resources -- list of resource ids
|
||||
# [x] /resources -- list of resource ids
|
||||
# [x] /sources/<source>/resources -- list of resource ids
|
||||
# [x] /users/<user>/resources -- list of resource ids
|
||||
#
|
||||
# [x] /users -- list of user ids
|
||||
# [x] /sources/<source>/users -- list of user ids
|
||||
#
|
||||
# [x] /projects -- list of project ids
|
||||
# [x] /sources/<source>/projects -- list of project ids
|
||||
#
|
||||
# [ ] /resources/<resource> -- metadata
|
||||
#
|
||||
# [ ] /projects/<project>/meters -- list of meters reporting for parent obj
|
||||
# [ ] /resources/<resource>/meters -- list of meters reporting for parent obj
|
||||
# [ ] /sources/<source>/meters -- list of meters reporting for parent obj
|
||||
# [ ] /users/<user>/meters -- list of meters reporting for parent obj
|
||||
#
|
||||
# [x] /projects/<project>/meters/<meter> -- events
|
||||
# [x] /resources/<resource>/meters/<meter> -- events
|
||||
# [x] /sources/<source>/meters/<meter> -- events
|
||||
# [x] /users/<user>/meters/<meter> -- events
|
||||
#
|
||||
# [ ] /projects/<project>/meters/<meter>/duration -- total time for selected
|
||||
# meter
|
||||
# [x] /resources/<resource>/meters/<meter>/duration -- total time for selected
|
||||
# meter
|
||||
# [ ] /sources/<source>/meters/<meter>/duration -- total time for selected
|
||||
# meter
|
||||
# [ ] /users/<user>/meters/<meter>/duration -- total time for selected meter
|
||||
#
|
||||
# [ ] /projects/<project>/meters/<meter>/volume -- total or max volume for
|
||||
# selected meter
|
||||
# [x] /projects/<project>/meters/<meter>/volume/max -- max volume for
|
||||
# selected meter
|
||||
# [x] /projects/<project>/meters/<meter>/volume/sum -- total volume for
|
||||
# selected meter
|
||||
# [ ] /resources/<resource>/meters/<meter>/volume -- total or max volume for
|
||||
# selected meter
|
||||
# [x] /resources/<resource>/meters/<meter>/volume/max -- max volume for
|
||||
# selected meter
|
||||
# [x] /resources/<resource>/meters/<meter>/volume/sum -- total volume for
|
||||
# selected meter
|
||||
# [ ] /sources/<source>/meters/<meter>/volume -- total or max volume for
|
||||
# selected meter
|
||||
# [ ] /users/<user>/meters/<meter>/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()
|
@ -0,0 +1,44 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
#
|
||||
# Copyright © 2012 New Dream Network, LLC (DreamHost)
|
||||
#
|
||||
# Author: Doug Hellmann <doug.hellmann@dreamhost.com>
|
||||
#
|
||||
# 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
|
@ -0,0 +1,74 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
#
|
||||
# Copyright © 2012 New Dream Network, LLC (DreamHost)
|
||||
#
|
||||
# Author: Doug Hellmann <doug.hellmann@dreamhost.com>
|
||||
#
|
||||
# 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
|
@ -0,0 +1,50 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
#
|
||||
# Copyright © 2012 New Dream Network, LLC (DreamHost)
|
||||
#
|
||||
# Author: Julien Danjou <julien@danjou.info>
|
||||
#
|
||||
# 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
|
@ -0,0 +1,30 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
#
|
||||
# Copyright © 2012 New Dream Network, LLC (DreamHost)
|
||||
#
|
||||
# Author: Doug Hellmann <doug.hellmann@dreamhost.com>
|
||||
#
|
||||
# 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()
|
@ -0,0 +1,82 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
#
|
||||
# Copyright © 2012 New Dream Network, LLC (DreamHost)
|
||||
#
|
||||
# Author: Julien Danjou <julien@danjou.info>
|
||||
#
|
||||
# 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)
|
@ -0,0 +1,143 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
#
|
||||
# Copyright © 2012 New Dream Network, LLC (DreamHost)
|
||||
#
|
||||
# Author: Doug Hellmann <doug.hellmann@dreamhost.com>
|
||||
#
|
||||
# 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)
|
@ -0,0 +1,80 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
#
|
||||
# Copyright © 2012 New Dream Network, LLC (DreamHost)
|
||||
#
|
||||
# Author: Steven Berler <steven.berler@dreamhost.com>
|
||||