finishes blueprint bp/apiv2 Change-Id: I36dff480aacc438565875cdd23dc396e369da9bdchanges/68/16668/5
parent
2581554921
commit
75ca60f619
@ -0,0 +1,482 @@
|
||||
# Copyright 2011 OpenStack LLC.
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Module dedicated functions/classes dealing with rate limiting requests.
|
||||
"""
|
||||
|
||||
import collections
|
||||
import copy
|
||||
import httplib
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
|
||||
import webob.dec
|
||||
import webob.exc
|
||||
|
||||
from cinder.api.openstack import wsgi
|
||||
from cinder.api.views import limits as limits_views
|
||||
from cinder.api import xmlutil
|
||||
from cinder.openstack.common import importutils
|
||||
from cinder.openstack.common import jsonutils
|
||||
from cinder import quota
|
||||
from cinder import wsgi as base_wsgi
|
||||
|
||||
QUOTAS = quota.QUOTAS
|
||||
|
||||
|
||||
# Convenience constants for the limits dictionary passed to Limiter().
|
||||
PER_SECOND = 1
|
||||
PER_MINUTE = 60
|
||||
PER_HOUR = 60 * 60
|
||||
PER_DAY = 60 * 60 * 24
|
||||
|
||||
|
||||
limits_nsmap = {None: xmlutil.XMLNS_COMMON_V10, 'atom': xmlutil.XMLNS_ATOM}
|
||||
|
||||
|
||||
class LimitsTemplate(xmlutil.TemplateBuilder):
|
||||
def construct(self):
|
||||
root = xmlutil.TemplateElement('limits', selector='limits')
|
||||
|
||||
rates = xmlutil.SubTemplateElement(root, 'rates')
|
||||
rate = xmlutil.SubTemplateElement(rates, 'rate', selector='rate')
|
||||
rate.set('uri', 'uri')
|
||||
rate.set('regex', 'regex')
|
||||
limit = xmlutil.SubTemplateElement(rate, 'limit', selector='limit')
|
||||
limit.set('value', 'value')
|
||||
limit.set('verb', 'verb')
|
||||
limit.set('remaining', 'remaining')
|
||||
limit.set('unit', 'unit')
|
||||
limit.set('next-available', 'next-available')
|
||||
|
||||
absolute = xmlutil.SubTemplateElement(root, 'absolute',
|
||||
selector='absolute')
|
||||
limit = xmlutil.SubTemplateElement(absolute, 'limit',
|
||||
selector=xmlutil.get_items)
|
||||
limit.set('name', 0)
|
||||
limit.set('value', 1)
|
||||
|
||||
return xmlutil.MasterTemplate(root, 1, nsmap=limits_nsmap)
|
||||
|
||||
|
||||
class LimitsController(object):
|
||||
"""
|
||||
Controller for accessing limits in the OpenStack API.
|
||||
"""
|
||||
|
||||
@wsgi.serializers(xml=LimitsTemplate)
|
||||
def index(self, req):
|
||||
"""
|
||||
Return all global and rate limit information.
|
||||
"""
|
||||
context = req.environ['cinder.context']
|
||||
quotas = QUOTAS.get_project_quotas(context, context.project_id,
|
||||
usages=False)
|
||||
abs_limits = dict((k, v['limit']) for k, v in quotas.items())
|
||||
rate_limits = req.environ.get("cinder.limits", [])
|
||||
|
||||
builder = self._get_view_builder(req)
|
||||
return builder.build(rate_limits, abs_limits)
|
||||
|
||||
def _get_view_builder(self, req):
|
||||
return limits_views.ViewBuilder()
|
||||
|
||||
|
||||
def create_resource():
|
||||
return wsgi.Resource(LimitsController())
|
||||
|
||||
|
||||
class Limit(object):
|
||||
"""
|
||||
Stores information about a limit for HTTP requests.
|
||||
"""
|
||||
|
||||
UNITS = {
|
||||
1: "SECOND",
|
||||
60: "MINUTE",
|
||||
60 * 60: "HOUR",
|
||||
60 * 60 * 24: "DAY",
|
||||
}
|
||||
|
||||
UNIT_MAP = dict([(v, k) for k, v in UNITS.items()])
|
||||
|
||||
def __init__(self, verb, uri, regex, value, unit):
|
||||
"""
|
||||
Initialize a new `Limit`.
|
||||
|
||||
@param verb: HTTP verb (POST, PUT, etc.)
|
||||
@param uri: Human-readable URI
|
||||
@param regex: Regular expression format for this limit
|
||||
@param value: Integer number of requests which can be made
|
||||
@param unit: Unit of measure for the value parameter
|
||||
"""
|
||||
self.verb = verb
|
||||
self.uri = uri
|
||||
self.regex = regex
|
||||
self.value = int(value)
|
||||
self.unit = unit
|
||||
self.unit_string = self.display_unit().lower()
|
||||
self.remaining = int(value)
|
||||
|
||||
if value <= 0:
|
||||
raise ValueError("Limit value must be > 0")
|
||||
|
||||
self.last_request = None
|
||||
self.next_request = None
|
||||
|
||||
self.water_level = 0
|
||||
self.capacity = self.unit
|
||||
self.request_value = float(self.capacity) / float(self.value)
|
||||
msg = _("Only %(value)s %(verb)s request(s) can be "
|
||||
"made to %(uri)s every %(unit_string)s.")
|
||||
self.error_message = msg % self.__dict__
|
||||
|
||||
def __call__(self, verb, url):
|
||||
"""
|
||||
Represents a call to this limit from a relevant request.
|
||||
|
||||
@param verb: string http verb (POST, GET, etc.)
|
||||
@param url: string URL
|
||||
"""
|
||||
if self.verb != verb or not re.match(self.regex, url):
|
||||
return
|
||||
|
||||
now = self._get_time()
|
||||
|
||||
if self.last_request is None:
|
||||
self.last_request = now
|
||||
|
||||
leak_value = now - self.last_request
|
||||
|
||||
self.water_level -= leak_value
|
||||
self.water_level = max(self.water_level, 0)
|
||||
self.water_level += self.request_value
|
||||
|
||||
difference = self.water_level - self.capacity
|
||||
|
||||
self.last_request = now
|
||||
|
||||
if difference > 0:
|
||||
self.water_level -= self.request_value
|
||||
self.next_request = now + difference
|
||||
return difference
|
||||
|
||||
cap = self.capacity
|
||||
water = self.water_level
|
||||
val = self.value
|
||||
|
||||
self.remaining = math.floor(((cap - water) / cap) * val)
|
||||
self.next_request = now
|
||||
|
||||
def _get_time(self):
|
||||
"""Retrieve the current time. Broken out for testability."""
|
||||
return time.time()
|
||||
|
||||
def display_unit(self):
|
||||
"""Display the string name of the unit."""
|
||||
return self.UNITS.get(self.unit, "UNKNOWN")
|
||||
|
||||
def display(self):
|
||||
"""Return a useful representation of this class."""
|
||||
return {
|
||||
"verb": self.verb,
|
||||
"URI": self.uri,
|
||||
"regex": self.regex,
|
||||
"value": self.value,
|
||||
"remaining": int(self.remaining),
|
||||
"unit": self.display_unit(),
|
||||
"resetTime": int(self.next_request or self._get_time()),
|
||||
}
|
||||
|
||||
# "Limit" format is a dictionary with the HTTP verb, human-readable URI,
|
||||
# a regular-expression to match, value and unit of measure (PER_DAY, etc.)
|
||||
|
||||
DEFAULT_LIMITS = [
|
||||
Limit("POST", "*", ".*", 10, PER_MINUTE),
|
||||
Limit("POST", "*/servers", "^/servers", 50, PER_DAY),
|
||||
Limit("PUT", "*", ".*", 10, PER_MINUTE),
|
||||
Limit("GET", "*changes-since*", ".*changes-since.*", 3, PER_MINUTE),
|
||||
Limit("DELETE", "*", ".*", 100, PER_MINUTE),
|
||||
]
|
||||
|
||||
|
||||
class RateLimitingMiddleware(base_wsgi.Middleware):
|
||||
"""
|
||||
Rate-limits requests passing through this middleware. All limit information
|
||||
is stored in memory for this implementation.
|
||||
"""
|
||||
|
||||
def __init__(self, application, limits=None, limiter=None, **kwargs):
|
||||
"""
|
||||
Initialize new `RateLimitingMiddleware`, which wraps the given WSGI
|
||||
application and sets up the given limits.
|
||||
|
||||
@param application: WSGI application to wrap
|
||||
@param limits: String describing limits
|
||||
@param limiter: String identifying class for representing limits
|
||||
|
||||
Other parameters are passed to the constructor for the limiter.
|
||||
"""
|
||||
base_wsgi.Middleware.__init__(self, application)
|
||||
|
||||
# Select the limiter class
|
||||
if limiter is None:
|
||||
limiter = Limiter
|
||||
else:
|
||||
limiter = importutils.import_class(limiter)
|
||||
|
||||
# Parse the limits, if any are provided
|
||||
if limits is not None:
|
||||
limits = limiter.parse_limits(limits)
|
||||
|
||||
self._limiter = limiter(limits or DEFAULT_LIMITS, **kwargs)
|
||||
|
||||
@webob.dec.wsgify(RequestClass=wsgi.Request)
|
||||
def __call__(self, req):
|
||||
"""
|
||||
Represents a single call through this middleware. We should record the
|
||||
request if we have a limit relevant to it. If no limit is relevant to
|
||||
the request, ignore it.
|
||||
|
||||
If the request should be rate limited, return a fault telling the user
|
||||
they are over the limit and need to retry later.
|
||||
"""
|
||||
verb = req.method
|
||||
url = req.url
|
||||
context = req.environ.get("cinder.context")
|
||||
|
||||
if context:
|
||||
username = context.user_id
|
||||
else:
|
||||
username = None
|
||||
|
||||
delay, error = self._limiter.check_for_delay(verb, url, username)
|
||||
|
||||
if delay:
|
||||
msg = _("This request was rate-limited.")
|
||||
retry = time.time() + delay
|
||||
return wsgi.OverLimitFault(msg, error, retry)
|
||||
|
||||
req.environ["cinder.limits"] = self._limiter.get_limits(username)
|
||||
|
||||
return self.application
|
||||
|
||||
|
||||
class Limiter(object):
|
||||
"""
|
||||
Rate-limit checking class which handles limits in memory.
|
||||
"""
|
||||
|
||||
def __init__(self, limits, **kwargs):
|
||||
"""
|
||||
Initialize the new `Limiter`.
|
||||
|
||||
@param limits: List of `Limit` objects
|
||||
"""
|
||||
self.limits = copy.deepcopy(limits)
|
||||
self.levels = collections.defaultdict(lambda: copy.deepcopy(limits))
|
||||
|
||||
# Pick up any per-user limit information
|
||||
for key, value in kwargs.items():
|
||||
if key.startswith('user:'):
|
||||
username = key[5:]
|
||||
self.levels[username] = self.parse_limits(value)
|
||||
|
||||
def get_limits(self, username=None):
|
||||
"""
|
||||
Return the limits for a given user.
|
||||
"""
|
||||
return [limit.display() for limit in self.levels[username]]
|
||||
|
||||
def check_for_delay(self, verb, url, username=None):
|
||||
"""
|
||||
Check the given verb/user/user triplet for limit.
|
||||
|
||||
@return: Tuple of delay (in seconds) and error message (or None, None)
|
||||
"""
|
||||
delays = []
|
||||
|
||||
for limit in self.levels[username]:
|
||||
delay = limit(verb, url)
|
||||
if delay:
|
||||
delays.append((delay, limit.error_message))
|
||||
|
||||
if delays:
|
||||
delays.sort()
|
||||
return delays[0]
|
||||
|
||||
return None, None
|
||||
|
||||
# Note: This method gets called before the class is instantiated,
|
||||
# so this must be either a static method or a class method. It is
|
||||
# used to develop a list of limits to feed to the constructor. We
|
||||
# put this in the class so that subclasses can override the
|
||||
# default limit parsing.
|
||||
@staticmethod
|
||||
def parse_limits(limits):
|
||||
"""
|
||||
Convert a string into a list of Limit instances. This
|
||||
implementation expects a semicolon-separated sequence of
|
||||
parenthesized groups, where each group contains a
|
||||
comma-separated sequence consisting of HTTP method,
|
||||
user-readable URI, a URI reg-exp, an integer number of
|
||||
requests which can be made, and a unit of measure. Valid
|
||||
values for the latter are "SECOND", "MINUTE", "HOUR", and
|
||||
"DAY".
|
||||
|
||||
@return: List of Limit instances.
|
||||
"""
|
||||
|
||||
# Handle empty limit strings
|
||||
limits = limits.strip()
|
||||
if not limits:
|
||||
return []
|
||||
|
||||
# Split up the limits by semicolon
|
||||
result = []
|
||||
for group in limits.split(';'):
|
||||
group = group.strip()
|
||||
if group[:1] != '(' or group[-1:] != ')':
|
||||
raise ValueError("Limit rules must be surrounded by "
|
||||
"parentheses")
|
||||
group = group[1:-1]
|
||||
|
||||
# Extract the Limit arguments
|
||||
args = [a.strip() for a in group.split(',')]
|
||||
if len(args) != 5:
|
||||
raise ValueError("Limit rules must contain the following "
|
||||
"arguments: verb, uri, regex, value, unit")
|
||||
|
||||
# Pull out the arguments
|
||||
verb, uri, regex, value, unit = args
|
||||
|
||||
# Upper-case the verb
|
||||
verb = verb.upper()
|
||||
|
||||
# Convert value--raises ValueError if it's not integer
|
||||
value = int(value)
|
||||
|
||||
# Convert unit
|
||||
unit = unit.upper()
|
||||
if unit not in Limit.UNIT_MAP:
|
||||
raise ValueError("Invalid units specified")
|
||||
unit = Limit.UNIT_MAP[unit]
|
||||
|
||||
# Build a limit
|
||||
result.append(Limit(verb, uri, regex, value, unit))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class WsgiLimiter(object):
|
||||
"""
|
||||
Rate-limit checking from a WSGI application. Uses an in-memory `Limiter`.
|
||||
|
||||
To use, POST ``/<username>`` with JSON data such as::
|
||||
|
||||
{
|
||||
"verb" : GET,
|
||||
"path" : "/servers"
|
||||
}
|
||||
|
||||
and receive a 204 No Content, or a 403 Forbidden with an X-Wait-Seconds
|
||||
header containing the number of seconds to wait before the action would
|
||||
succeed.
|
||||
"""
|
||||
|
||||
def __init__(self, limits=None):
|
||||
"""
|
||||
Initialize the new `WsgiLimiter`.
|
||||
|
||||
@param limits: List of `Limit` objects
|
||||
"""
|
||||
self._limiter = Limiter(limits or DEFAULT_LIMITS)
|
||||
|
||||
@webob.dec.wsgify(RequestClass=wsgi.Request)
|
||||
def __call__(self, request):
|
||||
"""
|
||||
Handles a call to this application. Returns 204 if the request is
|
||||
acceptable to the limiter, else a 403 is returned with a relevant
|
||||
header indicating when the request *will* succeed.
|
||||
"""
|
||||
if request.method != "POST":
|
||||
raise webob.exc.HTTPMethodNotAllowed()
|
||||
|
||||
try:
|
||||
info = dict(jsonutils.loads(request.body))
|
||||
except ValueError:
|
||||
raise webob.exc.HTTPBadRequest()
|
||||
|
||||
username = request.path_info_pop()
|
||||
verb = info.get("verb")
|
||||
path = info.get("path")
|
||||
|
||||
delay, error = self._limiter.check_for_delay(verb, path, username)
|
||||
|
||||
if delay:
|
||||
headers = {"X-Wait-Seconds": "%.2f" % delay}
|
||||
return webob.exc.HTTPForbidden(headers=headers, explanation=error)
|
||||
else:
|
||||
return webob.exc.HTTPNoContent()
|
||||
|
||||
|
||||
class WsgiLimiterProxy(object):
|
||||
"""
|
||||
Rate-limit requests based on answers from a remote source.
|
||||
"""
|
||||
|
||||
def __init__(self, limiter_address):
|
||||
"""
|
||||
Initialize the new `WsgiLimiterProxy`.
|
||||
|
||||
@param limiter_address: IP/port combination of where to request limit
|
||||
"""
|
||||
self.limiter_address = limiter_address
|
||||
|
||||
def check_for_delay(self, verb, path, username=None):
|
||||
body = jsonutils.dumps({"verb": verb, "path": path})
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
conn = httplib.HTTPConnection(self.limiter_address)
|
||||
|
||||
if username:
|
||||
conn.request("POST", "/%s" % (username), body, headers)
|
||||
else:
|
||||
conn.request("POST", "/", body, headers)
|
||||
|
||||
resp = conn.getresponse()
|
||||
|
||||
if 200 >= resp.status < 300:
|
||||
return None, None
|
||||
|
||||
return resp.getheader("X-Wait-Seconds"), resp.read() or None
|
||||
|
||||
# Note: This method gets called before the class is instantiated,
|
||||
# so this must be either a static method or a class method. It is
|
||||
# used to develop a list of limits to feed to the constructor.
|
||||
# This implementation returns an empty list, since all limit
|
||||
# decisions are made by a remote server.
|
||||
@staticmethod
|
||||
def parse_limits(limits):
|
||||
"""
|
||||
Ignore a limits string--simply doesn't apply for the limit
|
||||
proxy.
|
||||
|
||||
@return: Empty list.
|
||||
"""
|
||||
|
||||
return []
|
@ -0,0 +1,70 @@
|
||||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright 2011 OpenStack LLC.
|
||||
# Copyright 2011 United States Government as represented by the
|
||||
# Administrator of the National Aeronautics and Space Administration.
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
WSGI middleware for OpenStack Volume API.
|
||||
"""
|
||||
|
||||
from cinder.api import extensions
|
||||
import cinder.api.openstack
|
||||
from cinder.api.v2 import limits
|
||||
from cinder.api.v2 import snapshots
|
||||
from cinder.api.v2 import types
|
||||
from cinder.api.v2 import volumes
|
||||
from cinder.api import versions
|
||||
from cinder.openstack.common import log as logging
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class APIRouter(cinder.api.openstack.APIRouter):
|
||||
"""
|
||||
Routes requests on the OpenStack API to the appropriate controller
|
||||
and method.
|
||||
"""
|
||||
ExtensionManager = extensions.ExtensionManager
|
||||
|
||||
def _setup_routes(self, mapper, ext_mgr):
|
||||
self.resources['versions'] = versions.create_resource()
|
||||
mapper.connect("versions", "/",
|
||||
controller=self.resources['versions'],
|
||||
action='show')
|
||||
|
||||
mapper.redirect("", "/")
|
||||
|
||||
self.resources['volumes'] = volumes.create_resource(ext_mgr)
|
||||
mapper.resource("volume", "volumes",
|
||||
controller=self.resources['volumes'],
|
||||
collection={'detail': 'GET'},
|
||||
member={'action': 'POST'})
|
||||
|
||||
self.resources['types'] = types.create_resource()
|
||||
mapper.resource("type", "types",
|
||||
controller=self.resources['types'])
|
||||
|
||||
self.resources['snapshots'] = snapshots.create_resource(ext_mgr)
|
||||
mapper.resource("snapshot", "snapshots",
|
||||
controller=self.resources['snapshots'],
|
||||
collection={'detail': 'GET'},
|
||||
member={'action': 'POST'})
|
||||
|
||||
self.resources['limits'] = limits.create_resource()
|
||||
mapper.resource("limit", "limits",
|
||||
controller=self.resources['limits'])
|
@ -0,0 +1,216 @@
|
||||
# Copyright 2011 Justin Santa Barbara
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""The volumes snapshots api."""
|
||||
|
||||
import webob
|
||||
from webob import exc
|
||||
|
||||
from cinder.api import common
|
||||
from cinder.api.openstack import wsgi
|
||||
from cinder.api.v2 import volumes
|
||||
from cinder.api import xmlutil
|
||||
from cinder import exception
|
||||
from cinder import flags
|
||||
from cinder.openstack.common import log as logging
|
||||
from cinder import utils
|
||||
from cinder import volume
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
FLAGS = flags.FLAGS
|
||||
|
||||
|
||||
def _translate_snapshot_detail_view(context, snapshot):
|
||||
"""Maps keys for snapshots details view."""
|
||||
|
||||
d = _translate_snapshot_summary_view(context, snapshot)
|
||||
|
||||
# NOTE(gagupta): No additional data / lookups at the moment
|
||||
return d
|
||||
|
||||
|
||||
def _translate_snapshot_summary_view(context, snapshot):
|
||||
"""Maps keys for snapshots summary view."""
|
||||
d = {}
|
||||
|
||||
d['id'] = snapshot['id']
|
||||
d['created_at'] = snapshot['created_at']
|
||||
d['display_name'] = snapshot['display_name']
|
||||
d['display_description'] = snapshot['display_description']
|
||||
d['volume_id'] = snapshot['volume_id']
|
||||
d['status'] = snapshot['status']
|
||||
d['size'] = snapshot['volume_size']
|
||||
|
||||
return d
|
||||
|
||||
|
||||
def make_snapshot(elem):
|
||||
elem.set('id')
|
||||
elem.set('status')
|
||||
elem.set('size')
|
||||
elem.set('created_at')
|
||||
elem.set('display_name')
|
||||
elem.set('display_description')
|
||||
elem.set('volume_id')
|
||||
|
||||
|
||||
class SnapshotTemplate(xmlutil.TemplateBuilder):
|
||||
def construct(self):
|
||||
root = xmlutil.TemplateElement('snapshot', selector='snapshot')
|
||||
make_snapshot(root)
|
||||
return xmlutil.MasterTemplate(root, 1)
|
||||
|
||||
|
||||
class SnapshotsTemplate(xmlutil.TemplateBuilder):
|
||||
def construct(self):
|
||||
root = xmlutil.TemplateElement('snapshots')
|
||||
elem = xmlutil.SubTemplateElement(root, 'snapshot',
|
||||
selector='snapshots')
|
||||
make_snapshot(elem)
|
||||
return xmlutil.MasterTemplate(root, 1)
|
||||
|
||||
|
||||
class SnapshotsController(wsgi.Controller):
|
||||
"""The Volumes API controller for the OpenStack API."""
|
||||
|
||||
def __init__(self, ext_mgr=None):
|
||||
self.volume_api = volume.API()
|
||||
self.ext_mgr = ext_mgr
|
||||
super(SnapshotsController, self).__init__()
|
||||
|
||||
@wsgi.serializers(xml=SnapshotTemplate)
|
||||
def show(self, req, id):
|
||||
"""Return data about the given snapshot."""
|
||||
context = req.environ['cinder.context']
|
||||
|
||||
try:
|
||||
vol = self.volume_api.get_snapshot(context, id)
|
||||
except exception.NotFound:
|
||||
raise exc.HTTPNotFound()
|
||||
|
||||
return {'snapshot': _translate_snapshot_detail_view(context, vol)}
|
||||
|
||||
def delete(self, req, id):
|
||||
"""Delete a snapshot."""
|
||||
context = req.environ['cinder.context']
|
||||
|
||||
LOG.audit(_("Delete snapshot with id: %s"), id, context=context)
|
||||
|
||||
try:
|
||||
snapshot = self.volume_api.get_snapshot(context, id)
|
||||
self.volume_api.delete_snapshot(context, snapshot)
|
||||
except exception.NotFound:
|
||||
raise exc.HTTPNotFound()
|
||||
return webob.Response(status_int=202)
|
||||
|
||||
@wsgi.serializers(xml=SnapshotsTemplate)
|
||||
def index(self, req):
|
||||
"""Returns a summary list of snapshots."""
|
||||
return self._items(req, entity_maker=_translate_snapshot_summary_view)
|
||||
|
||||
@wsgi.serializers(xml=SnapshotsTemplate)
|
||||
def detail(self, req):
|
||||
"""Returns a detailed list of snapshots."""
|
||||
return self._items(req, entity_maker=_translate_snapshot_detail_view)
|
||||
|
||||
def _items(self, req, entity_maker):
|
||||
"""Returns a list of snapshots, transformed through entity_maker."""
|
||||
context = req.environ['cinder.context']
|
||||
|
||||
search_opts = {}
|
||||
search_opts.update(req.GET)
|
||||
allowed_search_options = ('status', 'volume_id', 'display_name')
|
||||
volumes.remove_invalid_options(context, search_opts,
|
||||
allowed_search_options)
|
||||
|
||||
snapshots = self.volume_api.get_all_snapshots(context,
|
||||
search_opts=search_opts)
|
||||
limited_list = common.limited(snapshots, req)
|
||||
res = [entity_maker(context, snapshot) for snapshot in limited_list]
|
||||
return {'snapshots': res}
|
||||
|
||||
@wsgi.serializers(xml=SnapshotTemplate)
|
||||
def create(self, req, body):
|
||||
"""Creates a new snapshot."""
|
||||
context = req.environ['cinder.context']
|
||||
|
||||
if not self.is_valid_body(body, 'snapshot'):
|
||||
raise exc.HTTPUnprocessableEntity()
|
||||
|
||||
snapshot = body['snapshot']
|
||||
volume_id = snapshot['volume_id']
|
||||
volume = self.volume_api.get(context, volume_id)
|
||||
force = snapshot.get('force', False)
|
||||
msg = _("Create snapshot from volume %s")
|
||||
LOG.audit(msg, volume_id, context=context)
|
||||
|
||||
if not utils.is_valid_boolstr(force):
|
||||
msg = _("Invalid value '%s' for force. ") % force
|
||||
raise exception.InvalidParameterValue(err=msg)
|
||||
|
||||
if utils.bool_from_str(force):
|
||||
new_snapshot = self.volume_api.create_snapshot_force(context,
|
||||
volume,
|
||||
snapshot.get('display_name'),
|
||||
snapshot.get('display_description'))
|
||||
else:
|
||||
new_snapshot = self.volume_api.create_snapshot(context,
|
||||
volume,
|
||||
snapshot.get('display_name'),
|
||||
snapshot.get('display_description'))
|
||||
|
||||
retval = _translate_snapshot_detail_view(context, new_snapshot)
|
||||
|
||||
return {'snapshot': retval}
|
||||
|
||||
@wsgi.serializers(xml=SnapshotTemplate)
|
||||
def update(self, req, id, body):
|
||||
"""Update a snapshot."""
|
||||
context = req.environ['cinder.context']
|
||||
|
||||
if not body:
|
||||
raise exc.HTTPUnprocessableEntity()
|
||||
|
||||
if not 'snapshot' in body:
|
||||
raise exc.HTTPUnprocessableEntity()
|
||||
|
||||
snapshot = body['snapshot']
|
||||
update_dict = {}
|
||||
|
||||
valid_update_keys = (
|
||||
'display_name',
|
||||
'display_description',
|
||||
)
|
||||
|
||||
for key in valid_update_keys:
|
||||
if key in snapshot:
|
||||
update_dict[key] = snapshot[key]
|
||||
|
||||
try:
|
||||
snapshot = self.volume_api.get_snapshot(context, id)
|
||||
self.volume_api.update_snapshot(context, snapshot, update_dict)
|
||||
except exception.NotFound:
|
||||
raise exc.HTTPNotFound()
|
||||
|
||||
snapshot.update(update_dict)
|
||||
|
||||
return {'snapshot': _translate_snapshot_detail_view(context, snapshot)}
|
||||
|
||||
|
||||
def create_resource(ext_mgr):
|
||||
return wsgi.Resource(SnapshotsController(ext_mgr))
|
@ -0,0 +1,80 @@
|
||||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright (c) 2011 Zadara Storage Inc.
|
||||
# Copyright (c) 2011 OpenStack LLC.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
""" The volume type & volume types extra specs extension"""
|
||||
|
||||
from webob import exc
|
||||
|
||||
from cinder.api.openstack import wsgi
|
||||
from cinder.api.views import types as views_types
|
||||
from cinder.api import xmlutil
|
||||
from cinder import exception
|
||||
from cinder.volume import volume_types
|
||||
|
||||
|
||||
def make_voltype(elem):
|
||||
elem.set('id')
|
||||
elem.set('name')
|
||||
extra_specs = xmlutil.make_flat_dict('extra_specs', selector='extra_specs')
|
||||
elem.append(extra_specs)
|
||||
|
||||
|
||||
class VolumeTypeTemplate(xmlutil.TemplateBuilder):
|
||||
def construct(self):
|
||||
root = xmlutil.TemplateElement('volume_type', selector='volume_type')
|
||||
make_voltype(root)
|
||||
return xmlutil.MasterTemplate(root, 1)
|
||||
|
||||
|
||||
class VolumeTypesTemplate(xmlutil.TemplateBuilder):
|
||||
def construct(self):
|
||||
root = xmlutil.TemplateElement('volume_types')
|
||||
elem = xmlutil.SubTemplateElement(root, 'volume_type',
|
||||
selector='volume_types')
|
||||
make_voltype(elem)
|
||||
return xmlutil.MasterTemplate(root, 1)
|
||||
|
||||
|
||||
class VolumeTypesController(wsgi.Controller):
|
||||
""" The volume types API controller for the OpenStack API """
|
||||
|
||||
_view_builder_class = views_types.ViewBuilder
|
||||
|
||||
@wsgi.serializers(xml=VolumeTypesTemplate)
|
||||
def index(self, req):
|
||||
""" Returns the list of volume types """
|
||||
context = req.environ['cinder.context']
|
||||
vol_types = volume_types.get_all_types(context).values()
|
||||
return self._view_builder.index(req, vol_types)
|
||||
|
||||
@wsgi.serializers(xml=VolumeTypeTemplate)
|
||||
def show(self, req, id):
|
||||
""" Return a single volume type item """
|
||||
context = req.environ['cinder.context']
|
||||
|
||||
try:
|
||||
vol_type = volume_types.get_volume_type(context, id)
|
||||
except exception.NotFound:
|
||||
raise exc.HTTPNotFound()
|
||||
|
||||
# TODO(bcwaldon): remove str cast once we use uuids
|
||||
vol_type['id'] = str(vol_type['id'])
|
||||
return self._view_builder.show(req, vol_type)
|
||||
|
||||
|
||||
def create_resource():
|
||||
return wsgi.Resource(VolumeTypesController())
|
@ -0,0 +1,397 @@
|
||||
# Copyright 2011 Justin Santa Barbara
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""The volumes api."""
|
||||
|
||||
import webob
|
||||
from webob import exc
|
||||
from xml.dom import minidom
|
||||
|
||||
from cinder.api import common
|
||||
from cinder.api.openstack import wsgi
|
||||
from cinder.api import xmlutil
|
||||
from cinder import exception
|
||||
from cinder import flags
|
||||
from cinder.openstack.common import log as logging
|
||||
from cinder.openstack.common import uuidutils
|
||||
from cinder import volume
|
||||
from cinder.volume import volume_types
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
FLAGS = flags.FLAGS
|
||||
|
||||
|
||||
def _translate_attachment_detail_view(_context, vol):
|
||||
"""Maps keys for attachment details view."""
|
||||
|
||||
d = _translate_attachment_summary_view(_context, vol)
|
||||
|
||||
# No additional data / lookups at the moment
|
||||
|
||||
return d
|
||||
|
||||
|
||||
def _translate_attachment_summary_view(_context, vol):
|
||||
"""Maps keys for attachment summary view."""
|
||||
d = {}
|
||||
|
||||
volume_id = vol['id']
|
||||
|
||||
# NOTE(justinsb): We use the volume id as the id of the attachment object
|
||||
d['id'] = volume_id
|
||||
|
||||
d['volume_id'] = volume_id
|
||||
d['server_id'] = vol['instance_uuid']
|
||||
if vol.get('mountpoint'):
|
||||
d['device'] = vol['mountpoint']
|
||||
|
||||
return d
|
||||
|
||||
|
||||
def _translate_volume_detail_view(context, vol, image_id=None):
|
||||
"""Maps keys for volumes details view."""
|
||||
|
||||
d = _translate_volume_summary_view(context, vol, image_id)
|
||||
|
||||
# No additional data / lookups at the moment
|
||||
|
||||
return d
|
||||
|
||||
|
||||
def _translate_volume_summary_view(context, vol, image_id=None):
|
||||
"""Maps keys for volumes summary view."""
|
||||
d = {}
|
||||
|
||||
d['id'] = vol['id']
|
||||
d['status'] = vol['status']
|
||||
d['size'] = vol['size']
|
||||
d['availability_zone'] = vol['availability_zone']
|
||||
d['created_at'] = vol['created_at']
|
||||
|
||||
d['attachments'] = []
|
||||
if vol['attach_status'] == 'attached':
|
||||
attachment = _translate_attachment_detail_view(context, vol)
|
||||
d['attachments'].append(attachment)
|
||||
|
||||
d['display_name'] = vol['display_name']
|
||||
d['display_description'] = vol['display_description']
|
||||
|
||||
if vol['volume_type_id'] and vol.get('volume_type'):
|
||||
d['volume_type'] = vol['volume_type']['name']
|
||||
else:
|
||||
# TODO(bcwaldon): remove str cast once we use uuids
|
||||
d['volume_type'] = str(vol['volume_type_id'])
|
||||
|
||||
d['snapshot_id'] = vol['snapshot_id']
|
||||
|
||||
if image_id:
|
||||
d['image_id'] = image_id
|
||||
|
||||
LOG.audit(_("vol=%s"), vol, context=context)
|
||||
|
||||
if vol.get('volume_metadata'):
|
||||
metadata = vol.get('volume_metadata')
|
||||
d['metadata'] = dict((item['key'], item['value']) for item in metadata)
|
||||
# avoid circular ref when vol is a Volume instance
|
||||
elif vol.get('metadata') and isinstance(vol.get('metadata'), dict):
|
||||
d['metadata'] = vol['metadata']
|
||||
else:
|
||||
d['metadata'] = {}
|
||||
|
||||
return d
|
||||
|
||||
|
||||
def make_attachment(elem):
|
||||
elem.set('id')
|
||||
elem.set('server_id')
|
||||
elem.set('volume_id')
|
||||
elem.set('device')
|
||||
|
||||
|
||||
def make_volume(elem):
|
||||
elem.set('id')
|
||||
elem.set('status')
|
||||
elem.set('size')
|
||||
elem.set('availability_zone')
|
||||
elem.set('created_at')
|
||||
elem.set('display_name')
|
||||
elem.set('display_description')
|
||||
elem.set('volume_type')
|
||||
elem.set('snapshot_id')
|
||||
|
||||
attachments = xmlutil.SubTemplateElement(elem, 'attachments')
|
||||
attachment = xmlutil.SubTemplateElement(attachments, 'attachment',
|
||||
selector='attachments')
|
||||
make_attachment(attachment)
|
||||
|
||||
# Attach metadata node
|
||||
elem.append(common.MetadataTemplate())
|
||||
|
||||
|
||||
volume_nsmap = {None: xmlutil.XMLNS_VOLUME_V2, 'atom': xmlutil.XMLNS_ATOM}
|
||||
|
||||
|
||||
class VolumeTemplate(xmlutil.TemplateBuilder):
|
||||
def construct(self):
|
||||
root = xmlutil.TemplateElement('volume', selector='volume')
|
||||
make_volume(root)
|
||||
return xmlutil.MasterTemplate(root, 1, nsmap=volume_nsmap)
|
||||
|
||||
|
||||
class VolumesTemplate(xmlutil.TemplateBuilder):
|
||||
def construct(self):
|
||||
root = xmlutil.TemplateElement('volumes')
|
||||
elem = xmlutil.SubTemplateElement(root, 'volume', selector='volumes')
|
||||
make_volume(elem)
|
||||
return xmlutil.MasterTemplate(root, 1, nsmap=volume_nsmap)
|
||||
|
||||
|
||||
class CommonDeserializer(wsgi.MetadataXMLDeserializer):
|
||||
"""Common deserializer to handle xml-formatted volume requests.
|
||||
|
||||
Handles standard volume attributes as well as the optional metadata
|
||||
attribute
|
||||
"""
|
||||
|
||||
metadata_deserializer = common.MetadataXMLDeserializer()
|
||||
|
||||
def _extract_volume(self, node):
|
||||
"""Marshal the volume attribute of a parsed request."""
|
||||
volume = {}
|
||||
volume_node = self.find_first_child_named(node, 'volume')
|
||||
|
||||
attributes = ['display_name', 'display_description', 'size',
|
||||
'volume_type', 'availability_zone']
|
||||
for attr in attributes:
|
||||
if volume_node.getAttribute(attr):
|
||||
volume[attr] = volume_node.getAttribute(attr)
|
||||
|
||||
metadata_node = self.find_first_child_named(volume_node, 'metadata')
|
||||
if metadata_node is not None:
|
||||
volume['metadata'] = self.extract_metadata(metadata_node)
|
||||
|
||||
return volume
|
||||
|
||||
|
||||
class CreateDeserializer(CommonDeserializer):
|
||||
"""Deserializer to handle xml-formatted create volume requests.
|
||||
|
||||
Handles standard volume attributes as well as the optional metadata
|
||||
attribute
|
||||
"""
|
||||
|
||||
def default(self, string):
|
||||
"""Deserialize an xml-formatted volume create request."""
|
||||
dom = minidom.parseString(string)
|
||||
volume = self._extract_volume(dom)
|
||||
return {'body': {'volume': volume}}
|
||||
|
||||
|
||||
class VolumeController(wsgi.Controller):
|
||||
"""The Volumes API controller for the OpenStack API."""
|
||||
|
||||
def __init__(self, ext_mgr):
|
||||
self.volume_api = volume.API()
|
||||
self.ext_mgr = ext_mgr
|
||||
super(VolumeController, self).__init__()
|
||||
|
||||
@wsgi.serializers(xml=VolumeTemplate)
|
||||
def show(self, req, id):
|
||||
"""Return data about the given volume."""
|
||||
context = req.environ['cinder.context']
|
||||
|
||||
try:
|
||||
vol = self.volume_api.get(context, id)
|
||||
except exception.NotFound:
|
||||
raise exc.HTTPNotFound()
|
||||
|
||||
return {'volume': _translate_volume_detail_view(context, vol)}
|
||||
|
||||
def delete(self, req, id):
|
||||
"""Delete a volume."""
|
||||
context = req.environ['cinder.context']
|
||||
|
||||
LOG.audit(_("Delete volume with id: %s"), id, context=context)
|
||||
|
||||
try:
|
||||
volume = self.volume_api.get(context, id)
|
||||
self.volume_api.delete(context, volume)
|
||||
except exception.NotFound:
|
||||
raise exc.HTTPNotFound()
|
||||
return webob.Response(status_int=202)
|
||||
|
||||
@wsgi.serializers(xml=VolumesTemplate)
|
||||
def index(self, req):
|
||||
"""Returns a summary list of volumes."""
|
||||
return self._items(req, entity_maker=_translate_volume_summary_view)
|
||||
|
||||
@wsgi.serializers(xml=VolumesTemplate)
|
||||
def detail(self, req):
|
||||
"""Returns a detailed list of volumes."""
|
||||
return self._items(req, entity_maker=_translate_volume_detail_view)
|
||||
|
||||
def _items(self, req, entity_maker):
|
||||
"""Returns a list of volumes, transformed through entity_maker."""
|
||||
|
||||
search_opts = {}
|
||||
search_opts.update(req.GET)
|
||||
|
||||
context = req.environ['cinder.context']
|
||||
remove_invalid_options(context,
|
||||
search_opts, self._get_volume_search_options())
|
||||
|
||||
volumes = self.volume_api.get_all(context, search_opts=search_opts)
|
||||
limited_list = common.limited(volumes, req)
|
||||
res = [entity_maker(context, vol) for vol in limited_list]
|
||||
return {'volumes': res}
|
||||
|
||||
def _image_uuid_from_href(self, image_href):
|
||||
# If the image href was generated by nova api, strip image_href
|
||||
# down to an id.
|
||||
try:
|
||||
image_uuid = image_href.split('/').pop()
|
||||
except (TypeError, AttributeError):
|
||||
msg = _("Invalid imageRef provided.")
|
||||
raise exc.HTTPBadRequest(explanation=msg)
|
||||
|
||||
if not uuidutils.is_uuid_like(image_uuid):
|
||||
msg = _("Invalid imageRef provided.")
|
||||
raise exc.HTTPBadRequest(explanation=msg)
|
||||
|
||||
return image_uuid
|
||||
|
||||
@wsgi.serializers(xml=VolumeTemplate)
|
||||
@wsgi.deserializers(xml=CreateDeserializer)
|
||||
def create(self, req, body):
|
||||
"""Creates a new volume."""
|
||||
if not self.is_valid_body(body, 'volume'):
|
||||
raise exc.HTTPUnprocessableEntity()
|
||||
|
||||
context = req.environ['cinder.context']
|
||||
volume = body['volume']
|
||||
|
||||
kwargs = {}
|
||||
|
||||
req_volume_type = volume.get('volume_type', None)
|
||||
if req_volume_type:
|
||||
try:
|
||||
kwargs['volume_type'] = volume_types.get_volume_type_by_name(
|
||||
context, req_volume_type)
|
||||
except exception.VolumeTypeNotFound:
|
||||
explanation = 'Volume type not found.'
|
||||
raise exc.HTTPNotFound(explanation=explanation)
|
||||
|
||||
kwargs['metadata'] = volume.get('metadata', None)
|
||||
|
||||
snapshot_id = volume.get('snapshot_id')
|
||||
if snapshot_id is not None:
|
||||
kwargs['snapshot'] = self.volume_api.get_snapshot(context,
|
||||
snapshot_id)
|
||||
else:
|
||||
kwargs['snapshot'] = None
|
||||
|
||||
size = volume.get('size', None)
|
||||
if size is None and kwargs['snapshot'] is not None:
|
||||
size = kwargs['snapshot']['volume_size']
|
||||
|
||||
LOG.audit(_("Create volume of %s GB"), size, context=context)
|
||||
|
||||
image_href = None
|
||||
image_uuid = None
|
||||
if self.ext_mgr.is_loaded('os-image-create'):
|
||||
image_href = volume.get('imageRef')
|
||||
if snapshot_id and image_href:
|
||||
msg = _("Snapshot and image cannot be specified together.")
|
||||
raise exc.HTTPBadRequest(explanation=msg)
|
||||
if image_href:
|
||||
image_uuid = self._image_uuid_from_href(image_href)
|
||||
kwargs['image_id'] = image_uuid
|
||||
|
||||
kwargs['availability_zone'] = volume.get('availability_zone', None)
|
||||
|
||||
new_volume = self.volume_api.create(context,
|
||||
size,
|
||||
volume.get('display_name'),
|
||||
volume.get('display_description'),
|
||||
**kwargs)
|
||||
|
||||
# TODO(vish): Instance should be None at db layer instead of
|
||||
# trying to lazy load, but for now we turn it into
|
||||
# a dict to avoid an error.
|
||||
retval = _translate_volume_detail_view(context,
|
||||
dict(new_volume.iteritems()),
|
||||
image_uuid)
|
||||
|
||||
return {'volume': retval}
|
||||
|
||||
def _get_volume_search_options(self):
|
||||
"""Return volume search options allowed by non-admin."""
|
||||
return ('display_name', 'status')
|
||||
|
||||
@wsgi.serializers(xml=VolumeTemplate)
|
||||
def update(self, req, id, body):
|
||||
"""Update a volume."""
|
||||
context = req.environ['cinder.context']
|
||||
|
||||
if not body:
|
||||
raise exc.HTTPUnprocessableEntity()
|
||||
|
||||
if not 'volume' in body:
|
||||
raise exc.HTTPUnprocessableEntity()
|
||||
|
||||
volume = body['volume']
|
||||
update_dict = {}
|
||||
|
||||
valid_update_keys = (
|
||||
'display_name',
|
||||
'display_description',
|
||||
'metadata',
|
||||
)
|
||||
|
||||
for key in valid_update_keys:
|
||||
if key in volume:
|
||||
update_dict[key] = volume[key]
|
||||
|
||||
try:
|
||||
volume = self.volume_api.get(context, id)
|
||||
self.volume_api.update(context, volume, update_dict)
|
||||
except exception.NotFound:
|
||||
raise exc.HTTPNotFound()
|
||||
|
||||
volume.update(update_dict)
|
||||
|
||||
return {'volume': _translate_volume_detail_view(context, volume)}
|
||||
|
||||
|
||||
def create_resource(ext_mgr):
|
||||
return wsgi.Resource(VolumeController(ext_mgr))
|
||||
|
||||
|
||||
def remove_invalid_options(context, search_options, allowed_search_options):
|
||||
"""Remove search options that are not valid for non-admin API/context."""
|
||||
if context.is_admin:
|
||||
# Allow all options
|
||||
return
|
||||
# Otherwise, strip out all unknown options
|
||||
unknown_options = [opt for opt in search_options
|
||||
if opt not in allowed_search_options]
|
||||
bad_options = ", ".join(unknown_options)
|
||||
log_msg = _("Removing options '%(bad_options)s' from query") % locals()
|
||||
LOG.debug(log_msg)
|
||||
for opt in unknown_options:
|
||||
del search_options[opt]
|
@ -0,0 +1,914 @@
|
||||
# Copyright 2011 OpenStack LLC.
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Tests dealing with HTTP rate-limiting.
|
||||
"""
|
||||
|
||||
import httplib
|
||||
import StringIO
|
||||
|
||||
from lxml import etree
|
||||
import webob
|
||||
from xml.dom import minidom
|
||||
|
||||
from cinder.api.v2 import limits
|
||||
from cinder.api import views
|
||||
from cinder.api import xmlutil
|
||||
import cinder.context
|
||||
from cinder.openstack.common import jsonutils
|
||||
from cinder import test
|
||||
|
||||
|
||||
TEST_LIMITS = [
|
||||
limits.Limit("GET", "/delayed", "^/delayed", 1, limits.PER_MINUTE),
|
||||
limits.Limit("POST", "*", ".*", 7, limits.PER_MINUTE),
|
||||
limits.Limit("POST", "/volumes", "^/volumes", 3, limits.PER_MINUTE),
|
||||
limits.Limit("PUT", "*", "", 10, limits.PER_MINUTE),
|
||||
limits.Limit("PUT", "/volumes", "^/volumes", 5, limits.PER_MINUTE),
|
||||
]
|
||||
NS = {
|
||||
'atom': 'http://www.w3.org/2005/Atom',
|
||||
'ns': 'http://docs.openstack.org/common/api/v1.0',
|
||||
}
|
||||
|
||||
|
||||
class BaseLimitTestSuite(test.TestCase):
|
||||
"""Base test suite which provides relevant stubs and time abstraction."""
|
||||
|
||||
def setUp(self):
|
||||
super(BaseLimitTestSuite, self).setUp()
|
||||
self.time = 0.0
|
||||
self.stubs.Set(limits.Limit, "_get_time", self._get_time)
|
||||
self.absolute_limits = {}
|
||||
|
||||
def stub_get_project_quotas(context, project_id, usages=True):
|
||||
return dict((k, dict(limit=v))
|
||||
for k, v in self.absolute_limits.items())
|
||||
|
||||
self.stubs.Set(cinder.quota.QUOTAS, "get_project_quotas",
|
||||
stub_get_project_quotas)
|
||||
|
||||
def _get_time(self):
|
||||
"""Return the "time" according to this test suite."""
|
||||
return self.time
|
||||
|
||||
|
||||
class LimitsControllerTest(BaseLimitTestSuite):
|
||||
"""
|
||||
Tests for `limits.LimitsController` class.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""Run before each test."""
|
||||
super(LimitsControllerTest, self).setUp()
|
||||
self.controller = limits.create_resource()
|
||||
|
||||
def _get_index_request(self, accept_header="application/json"):
|
||||
"""Helper to set routing arguments."""
|
||||
request = webob.Request.blank("/")
|
||||
request.accept = accept_header
|
||||
request.environ["wsgiorg.routing_args"] = (None, {
|
||||
"action": "index",
|
||||
"controller": "",
|
||||
})
|
||||
context = cinder.context.RequestContext('testuser', 'testproject')
|
||||
request.environ["cinder.context"] = context
|
||||
return request
|
||||
|
||||
def _populate_limits(self, request):
|
||||
"""Put limit info into a request."""
|
||||
_limits = [
|
||||
limits.Limit("GET", "*", ".*", 10, 60).display(),
|
||||
limits.Limit("POST", "*", ".*", 5, 60 * 60).display(),
|
||||
limits.Limit("GET", "changes-since*", "changes-since",
|
||||
5, 60).display(),
|
||||
]
|
||||
request.environ["cinder.limits"] = _limits
|
||||
return request
|
||||
|
||||
def test_empty_index_json(self):
|
||||
"""Test getting empty limit details in JSON."""
|
||||
request = self._get_index_request()
|
||||
response = request.get_response(self.controller)
|
||||
expected = {
|
||||
"limits": {
|
||||
"rate": [],
|
||||
"absolute": {},
|
||||
},
|
||||
}
|
||||
body = jsonutils.loads(response.body)
|
||||
self.assertEqual(expected, body)
|
||||
|
||||
def test_index_json(self):
|
||||
"""Test getting limit details in JSON."""
|
||||
request = self._get_index_request()
|
||||
request = self._populate_limits(request)
|
||||
self.absolute_limits = {
|
||||
'gigabytes': 512,
|
||||
'volumes': 5,
|
||||
}
|
||||
response = request.get_response(self.controller)
|
||||
expected = {
|
||||
"limits": {
|
||||
"rate": [
|
||||
{
|
||||
"regex": ".*",
|
||||
"uri": "*",
|
||||
"limit": [
|
||||
|