diff options
| author | Justin Santa Barbara <justin@fathomdb.com> | 2011-03-28 08:05:28 -0700 |
|---|---|---|
| committer | Justin Santa Barbara <justin@fathomdb.com> | 2011-03-28 08:05:28 -0700 |
| commit | 0138c728549a9e565f695e33e1090b52541cd59d (patch) | |
| tree | d39ab53a0dd18a327cda6bd8437964b8764a17f3 /nova/api | |
| parent | aecd4eb9d363875cd84be5aa6fdb9afeb500b4f4 (diff) | |
| parent | 8501cd94e3929918fdbfe0ca489304449f2f7fe3 (diff) | |
Merged with trunk
Diffstat (limited to 'nova/api')
24 files changed, 1913 insertions, 209 deletions
diff --git a/nova/api/direct.py b/nova/api/direct.py index dfca250e0..e5f33cee4 100644 --- a/nova/api/direct.py +++ b/nova/api/direct.py @@ -38,6 +38,7 @@ import routes import webob from nova import context +from nova import exception from nova import flags from nova import utils from nova import wsgi @@ -205,10 +206,53 @@ class ServiceWrapper(wsgi.Controller): # NOTE(vish): make sure we have no unicode keys for py2.6. params = dict([(str(k), v) for (k, v) in params.iteritems()]) result = method(context, **params) - if type(result) is dict or type(result) is list: - return self._serialize(result, req.best_match_content_type()) - else: + if result is None or type(result) is str or type(result) is unicode: return result + try: + return self._serialize(result, req.best_match_content_type()) + except: + raise exception.Error("returned non-serializable type: %s" + % result) + + +class Limited(object): + __notdoc = """Limit the available methods on a given object. + + (Not a docstring so that the docstring can be conditionally overriden.) + + Useful when defining a public API that only exposes a subset of an + internal API. + + Expected usage of this class is to define a subclass that lists the allowed + methods in the 'allowed' variable. + + Additionally where appropriate methods can be added or overwritten, for + example to provide backwards compatibility. + + The wrapping approach has been chosen so that the wrapped API can maintain + its own internal consistency, for example if it calls "self.create" it + should get its own create method rather than anything we do here. + + """ + + _allowed = None + + def __init__(self, proxy): + self._proxy = proxy + if not self.__doc__: + self.__doc__ = proxy.__doc__ + if not self._allowed: + self._allowed = [] + + def __getattr__(self, key): + """Only return methods that are named in self._allowed.""" + if key not in self._allowed: + raise AttributeError() + return getattr(self._proxy, key) + + def __dir__(self): + """Only return methods that are named in self._allowed.""" + return [x for x in dir(self._proxy) if x in self._allowed] class Proxy(object): diff --git a/nova/api/ec2/__init__.py b/nova/api/ec2/__init__.py index fccebca5d..a3c3b25a1 100644 --- a/nova/api/ec2/__init__.py +++ b/nova/api/ec2/__init__.py @@ -31,7 +31,7 @@ from nova import log as logging from nova import utils from nova import wsgi from nova.api.ec2 import apirequest -from nova.api.ec2 import cloud +from nova.api.ec2 import ec2utils from nova.auth import manager @@ -61,10 +61,13 @@ class RequestLogging(wsgi.Middleware): return rv def log_request_completion(self, response, request, start): - controller = request.environ.get('ec2.controller', None) - if controller: - controller = controller.__class__.__name__ - action = request.environ.get('ec2.action', None) + apireq = request.environ.get('ec2.request', None) + if apireq: + controller = apireq.controller + action = apireq.action + else: + controller = None + action = None ctxt = request.environ.get('ec2.context', None) delta = utils.utcnow() - start seconds = delta.seconds @@ -75,7 +78,7 @@ class RequestLogging(wsgi.Middleware): microseconds, request.remote_addr, request.method, - request.path_info, + "%s%s" % (request.script_name, request.path_info), controller, action, response.status_int, @@ -319,13 +322,13 @@ class Executor(wsgi.Application): except exception.InstanceNotFound as ex: LOG.info(_('InstanceNotFound raised: %s'), unicode(ex), context=context) - ec2_id = cloud.id_to_ec2_id(ex.instance_id) + ec2_id = ec2utils.id_to_ec2_id(ex.instance_id) message = _('Instance %s not found') % ec2_id return self._error(req, context, type(ex).__name__, message) except exception.VolumeNotFound as ex: LOG.info(_('VolumeNotFound raised: %s'), unicode(ex), context=context) - ec2_id = cloud.id_to_ec2_id(ex.volume_id, 'vol-%08x') + ec2_id = ec2utils.id_to_ec2_id(ex.volume_id, 'vol-%08x') message = _('Volume %s not found') % ec2_id return self._error(req, context, type(ex).__name__, message) except exception.NotFound as ex: diff --git a/nova/api/ec2/admin.py b/nova/api/ec2/admin.py index d9a4ef999..6a5609d4a 100644 --- a/nova/api/ec2/admin.py +++ b/nova/api/ec2/admin.py @@ -28,6 +28,7 @@ from nova import exception from nova import flags from nova import log as logging from nova import utils +from nova.api.ec2 import ec2utils from nova.auth import manager @@ -60,7 +61,7 @@ def project_dict(project): def host_dict(host, compute_service, instances, volume_service, volumes, now): """Convert a host model object to a result dict""" - rv = {'hostanme': host, 'instance_count': len(instances), + rv = {'hostname': host, 'instance_count': len(instances), 'volume_count': len(volumes)} if compute_service: latest = compute_service['updated_at'] or compute_service['created_at'] @@ -92,15 +93,18 @@ def vpn_dict(project, vpn_instance): 'public_ip': project.vpn_ip, 'public_port': project.vpn_port} if vpn_instance: - rv['instance_id'] = vpn_instance['ec2_id'] + rv['instance_id'] = ec2utils.id_to_ec2_id(vpn_instance['id']) rv['created_at'] = utils.isotime(vpn_instance['created_at']) address = vpn_instance.get('fixed_ip', None) if address: rv['internal_ip'] = address['address'] - if utils.vpn_ping(project.vpn_ip, project.vpn_port): - rv['state'] = 'running' + if project.vpn_ip and project.vpn_port: + if utils.vpn_ping(project.vpn_ip, project.vpn_port): + rv['state'] = 'running' + else: + rv['state'] = 'down' else: - rv['state'] = 'down' + rv['state'] = 'down - invalid project vpn config' else: rv['state'] = 'pending' return rv @@ -116,7 +120,8 @@ class AdminController(object): def describe_instance_types(self, context, **_kwargs): """Returns all active instance types data (vcpus, memory, etc.)""" - return {'instanceTypeSet': [db.instance_type_get_all(context)]} + return {'instanceTypeSet': [instance_dict(v) for v in + db.instance_type_get_all(context).values()]} def describe_user(self, _context, name, **_kwargs): """Returns user data, including access and secret keys.""" @@ -279,7 +284,7 @@ class AdminController(object): ", ensure it isn't running, and try " "again in a few minutes") instance = self._vpn_for(context, project) - return {'instance_id': instance['ec2_id']} + return {'instance_id': ec2utils.id_to_ec2_id(instance['id'])} def describe_vpns(self, context): vpns = [] @@ -299,7 +304,7 @@ class AdminController(object): * Volume (up, down, None) * Volume Count """ - services = db.service_get_all(context) + services = db.service_get_all(context, False) now = datetime.datetime.utcnow() hosts = [] rv = [] diff --git a/nova/api/ec2/cloud.py b/nova/api/ec2/cloud.py index 40a9da0e7..0da642318 100644 --- a/nova/api/ec2/cloud.py +++ b/nova/api/ec2/cloud.py @@ -196,7 +196,7 @@ class CloudController(object): def _describe_availability_zones(self, context, **kwargs): ctxt = context.elevated() - enabled_services = db.service_get_all(ctxt) + enabled_services = db.service_get_all(ctxt, False) disabled_services = db.service_get_all(ctxt, True) available_zones = [] for zone in [service.availability_zone for service @@ -221,7 +221,7 @@ class CloudController(object): rv = {'availabilityZoneInfo': [{'zoneName': 'nova', 'zoneState': 'available'}]} - services = db.service_get_all(context) + services = db.service_get_all(context, False) now = datetime.datetime.utcnow() hosts = [] for host in [service['host'] for service in services]: @@ -541,7 +541,7 @@ class CloudController(object): volumes = [] for ec2_id in volume_id: internal_id = ec2utils.ec2_id_to_id(ec2_id) - volume = self.volume_api.get(context, internal_id) + volume = self.volume_api.get(context, volume_id=internal_id) volumes.append(volume) else: volumes = self.volume_api.get_all(context) @@ -585,9 +585,11 @@ class CloudController(object): def create_volume(self, context, size, **kwargs): LOG.audit(_("Create volume of %s GB"), size, context=context) - volume = self.volume_api.create(context, size, - kwargs.get('display_name'), - kwargs.get('display_description')) + volume = self.volume_api.create( + context, + size=size, + name=kwargs.get('display_name'), + description=kwargs.get('display_description')) # 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. @@ -606,7 +608,9 @@ class CloudController(object): if field in kwargs: changes[field] = kwargs[field] if changes: - self.volume_api.update(context, volume_id, kwargs) + self.volume_api.update(context, + volume_id=volume_id, + fields=changes) return True def attach_volume(self, context, volume_id, instance_id, device, **kwargs): @@ -619,7 +623,7 @@ class CloudController(object): instance_id=instance_id, volume_id=volume_id, device=device) - volume = self.volume_api.get(context, volume_id) + volume = self.volume_api.get(context, volume_id=volume_id) return {'attachTime': volume['attach_time'], 'device': volume['mountpoint'], 'instanceId': ec2utils.id_to_ec2_id(instance_id), @@ -630,7 +634,7 @@ class CloudController(object): def detach_volume(self, context, volume_id, **kwargs): volume_id = ec2utils.ec2_id_to_id(volume_id) LOG.audit(_("Detach volume %s"), volume_id, context=context) - volume = self.volume_api.get(context, volume_id) + volume = self.volume_api.get(context, volume_id=volume_id) instance = self.compute_api.detach_volume(context, volume_id=volume_id) return {'attachTime': volume['attach_time'], 'device': volume['mountpoint'], @@ -768,7 +772,7 @@ class CloudController(object): def release_address(self, context, public_ip, **kwargs): LOG.audit(_("Release address %s"), public_ip, context=context) - self.network_api.release_floating_ip(context, public_ip) + self.network_api.release_floating_ip(context, address=public_ip) return {'releaseResponse': ["Address released."]} def associate_address(self, context, instance_id, public_ip, **kwargs): @@ -782,7 +786,7 @@ class CloudController(object): def disassociate_address(self, context, public_ip, **kwargs): LOG.audit(_("Disassociate address %s"), public_ip, context=context) - self.network_api.disassociate_floating_ip(context, public_ip) + self.network_api.disassociate_floating_ip(context, address=public_ip) return {'disassociateResponse': ["Address disassociated."]} def run_instances(self, context, **kwargs): @@ -959,7 +963,7 @@ class CloudController(object): raise exception.NotFound(_('Image %s not found') % image_id) internal_id = image['id'] del(image['id']) - raise Exception(image) + image['properties']['is_public'] = (operation_type == 'add') return self.image_service.update(context, internal_id, image) diff --git a/nova/api/openstack/__init__.py b/nova/api/openstack/__init__.py index ce3cff337..8fabbce8e 100644 --- a/nova/api/openstack/__init__.py +++ b/nova/api/openstack/__init__.py @@ -33,7 +33,10 @@ from nova.api.openstack import backup_schedules from nova.api.openstack import consoles from nova.api.openstack import flavors from nova.api.openstack import images +from nova.api.openstack import image_metadata +from nova.api.openstack import limits from nova.api.openstack import servers +from nova.api.openstack import server_metadata from nova.api.openstack import shared_ip_groups from nova.api.openstack import users from nova.api.openstack import zones @@ -70,10 +73,15 @@ class APIRouter(wsgi.Router): """Simple paste factory, :class:`nova.wsgi.Router` doesn't have one""" return cls() - def __init__(self): + def __init__(self, ext_mgr=None): + self.server_members = {} mapper = routes.Mapper() + self._setup_routes(mapper) + super(APIRouter, self).__init__(mapper) - server_members = {'action': 'POST'} + def _setup_routes(self, mapper): + server_members = self.server_members + server_members['action'] = 'POST' if FLAGS.allow_admin_api: LOG.debug(_("Including admin operations in API.")) @@ -98,10 +106,6 @@ class APIRouter(wsgi.Router): controller=accounts.Controller(), collection={'detail': 'GET'}) - mapper.resource("server", "servers", controller=servers.Controller(), - collection={'detail': 'GET'}, - member=server_members) - mapper.resource("backup_schedule", "backup_schedule", controller=backup_schedules.Controller(), parent_resource=dict(member_name='server', @@ -114,25 +118,50 @@ class APIRouter(wsgi.Router): mapper.resource("image", "images", controller=images.Controller(), collection={'detail': 'GET'}) - mapper.resource("flavor", "flavors", controller=flavors.Controller(), - collection={'detail': 'GET'}) + mapper.resource("shared_ip_group", "shared_ip_groups", collection={'detail': 'GET'}, controller=shared_ip_groups.Controller()) - super(APIRouter, self).__init__(mapper) + _limits = limits.LimitsController() + mapper.resource("limit", "limits", controller=_limits) -class Versions(wsgi.Application): - @webob.dec.wsgify(RequestClass=wsgi.Request) - def __call__(self, req): - """Respond to a request for all OpenStack API versions.""" - response = { - "versions": [ - dict(status="CURRENT", id="v1.0")]} - metadata = { - "application/xml": { - "attributes": dict(version=["status", "id"])}} - - content_type = req.best_match_content_type() - return wsgi.Serializer(metadata).serialize(response, content_type) +class APIRouterV10(APIRouter): + """Define routes specific to OpenStack API V1.0.""" + + def _setup_routes(self, mapper): + super(APIRouterV10, self)._setup_routes(mapper) + mapper.resource("server", "servers", + controller=servers.ControllerV10(), + collection={'detail': 'GET'}, + member=self.server_members) + + mapper.resource("flavor", "flavors", + controller=flavors.ControllerV10(), + collection={'detail': 'GET'}) + + +class APIRouterV11(APIRouter): + """Define routes specific to OpenStack API V1.1.""" + + def _setup_routes(self, mapper): + super(APIRouterV11, self)._setup_routes(mapper) + mapper.resource("server", "servers", + controller=servers.ControllerV11(), + collection={'detail': 'GET'}, + member=self.server_members) + + mapper.resource("image_meta", "meta", + controller=image_metadata.Controller(), + parent_resource=dict(member_name='image', + collection_name='images')) + + mapper.resource("server_meta", "meta", + controller=server_metadata.Controller(), + parent_resource=dict(member_name='server', + collection_name='servers')) + + mapper.resource("flavor", "flavors", + controller=flavors.ControllerV11(), + collection={'detail': 'GET'}) diff --git a/nova/api/openstack/accounts.py b/nova/api/openstack/accounts.py index 2510ffb61..86066fa20 100644 --- a/nova/api/openstack/accounts.py +++ b/nova/api/openstack/accounts.py @@ -14,6 +14,7 @@ # under the License. import common +import webob.exc from nova import exception from nova import flags @@ -51,10 +52,10 @@ class Controller(wsgi.Controller): raise exception.NotAuthorized(_("Not admin user.")) def index(self, req): - raise faults.Fault(exc.HTTPNotImplemented()) + raise faults.Fault(webob.exc.HTTPNotImplemented()) def detail(self, req): - raise faults.Fault(exc.HTTPNotImplemented()) + raise faults.Fault(webob.exc.HTTPNotImplemented()) def show(self, req, id): """Return data about the given account id""" @@ -69,7 +70,7 @@ class Controller(wsgi.Controller): def create(self, req): """We use update with create-or-update semantics because the id comes from an external source""" - raise faults.Fault(exc.HTTPNotImplemented()) + raise faults.Fault(webob.exc.HTTPNotImplemented()) def update(self, req, id): """This is really create or update.""" diff --git a/nova/api/openstack/common.py b/nova/api/openstack/common.py index 74ac21024..8cad1273a 100644 --- a/nova/api/openstack/common.py +++ b/nova/api/openstack/common.py @@ -15,12 +15,17 @@ # License for the specific language governing permissions and limitations # under the License. -import webob.exc +from urlparse import urlparse + +import webob from nova import exception +from nova import flags + +FLAGS = flags.FLAGS -def limited(items, request, max_limit=1000): +def limited(items, request, max_limit=FLAGS.osapi_max_limit): """ Return a slice of items according to requested offset and limit. @@ -54,6 +59,36 @@ def limited(items, request, max_limit=1000): return items[offset:range_end] +def limited_by_marker(items, request, max_limit=FLAGS.osapi_max_limit): + """Return a slice of items according to the requested marker and limit.""" + + try: + marker = int(request.GET.get('marker', 0)) + except ValueError: + raise webob.exc.HTTPBadRequest(_('marker param must be an integer')) + + try: + limit = int(request.GET.get('limit', max_limit)) + except ValueError: + raise webob.exc.HTTPBadRequest(_('limit param must be an integer')) + + if limit < 0: + raise webob.exc.HTTPBadRequest(_('limit param must be positive')) + + limit = min(max_limit, limit) + start_index = 0 + if marker: + start_index = -1 + for i, item in enumerate(items): + if item['id'] == marker: + start_index = i + 1 + break + if start_index < 0: + raise webob.exc.HTTPBadRequest(_('marker [%s] not found' % marker)) + range_end = start_index + limit + return items[start_index:range_end] + + def get_image_id_from_image_hash(image_service, context, image_hash): """Given an Image ID Hash, return an objectstore Image ID. @@ -74,3 +109,16 @@ def get_image_id_from_image_hash(image_service, context, image_hash): if abs(hash(image_id)) == int(image_hash): return image_id raise exception.NotFound(image_hash) + + +def get_id_from_href(href): + """Return the id portion of a url as an int. + + Given: http://www.foo.com/bar/123?q=4 + Returns: 123 + + """ + try: + return int(urlparse(href).path.split('/')[-1]) + except: + raise webob.exc.HTTPBadRequest(_('could not parse id from href')) diff --git a/nova/api/openstack/extensions.py b/nova/api/openstack/extensions.py new file mode 100644 index 000000000..9d98d849a --- /dev/null +++ b/nova/api/openstack/extensions.py @@ -0,0 +1,369 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import imp +import os +import sys +import routes +import webob.dec +import webob.exc + +from nova import flags +from nova import log as logging +from nova import wsgi +from nova.api.openstack import faults + + +LOG = logging.getLogger('extensions') + + +FLAGS = flags.FLAGS + + +class ActionExtensionController(wsgi.Controller): + + def __init__(self, application): + + self.application = application + self.action_handlers = {} + + def add_action(self, action_name, handler): + self.action_handlers[action_name] = handler + + def action(self, req, id): + + input_dict = self._deserialize(req.body, req.get_content_type()) + for action_name, handler in self.action_handlers.iteritems(): + if action_name in input_dict: + return handler(input_dict, req, id) + # no action handler found (bump to downstream application) + res = self.application + return res + + +class ResponseExtensionController(wsgi.Controller): + + def __init__(self, application): + self.application = application + self.handlers = [] + + def add_handler(self, handler): + self.handlers.append(handler) + + def process(self, req, *args, **kwargs): + res = req.get_response(self.application) + content_type = req.best_match_content_type() + # currently response handlers are un-ordered + for handler in self.handlers: + res = handler(res) + try: + body = res.body + headers = res.headers + except AttributeError: + body = self._serialize(res, content_type) + headers = {"Content-Type": content_type} + res = webob.Response() + res.body = body + res.headers = headers + return res + + +class ExtensionController(wsgi.Controller): + + def __init__(self, extension_manager): + self.extension_manager = extension_manager + + def _translate(self, ext): + ext_data = {} + ext_data['name'] = ext.get_name() + ext_data['alias'] = ext.get_alias() + ext_data['description'] = ext.get_description() + ext_data['namespace'] = ext.get_namespace() + ext_data['updated'] = ext.get_updated() + ext_data['links'] = [] # TODO: implement extension links + return ext_data + + def index(self, req): + extensions = [] + for alias, ext in self.extension_manager.extensions.iteritems(): + extensions.append(self._translate(ext)) + return dict(extensions=extensions) + + def show(self, req, id): + # NOTE: the extensions alias is used as the 'id' for show + ext = self.extension_manager.extensions[id] + return self._translate(ext) + + def delete(self, req, id): + raise faults.Fault(exc.HTTPNotFound()) + + def create(self, req): + raise faults.Fault(exc.HTTPNotFound()) + + def delete(self, req, id): + raise faults.Fault(exc.HTTPNotFound()) + + +class ExtensionMiddleware(wsgi.Middleware): + """ + Extensions middleware that intercepts configured routes for extensions. + """ + @classmethod + def factory(cls, global_config, **local_config): + """ paste factory """ + def _factory(app): + return cls(app, **local_config) + return _factory + + def _action_ext_controllers(self, application, ext_mgr, mapper): + """ + Return a dict of ActionExtensionController objects by collection + """ + action_controllers = {} + for action in ext_mgr.get_actions(): + if not action.collection in action_controllers.keys(): + controller = ActionExtensionController(application) + mapper.connect("/%s/:(id)/action.:(format)" % + action.collection, + action='action', + controller=controller, + conditions=dict(method=['POST'])) + mapper.connect("/%s/:(id)/action" % action.collection, + action='action', + controller=controller, + conditions=dict(method=['POST'])) + action_controllers[action.collection] = controller + + return action_controllers + + def _response_ext_controllers(self, application, ext_mgr, mapper): + """ + Return a dict of ResponseExtensionController objects by collection + """ + response_ext_controllers = {} + for resp_ext in ext_mgr.get_response_extensions(): + if not resp_ext.key in response_ext_controllers.keys(): + controller = ResponseExtensionController(application) + mapper.connect(resp_ext.url_route + '.:(format)', + action='process', + controller=controller, + conditions=resp_ext.conditions) + + mapper.connect(resp_ext.url_route, + action='process', + controller=controller, + conditions=resp_ext.conditions) + response_ext_controllers[resp_ext.key] = controller + + return response_ext_controllers + + def __init__(self, application, ext_mgr=None): + + if ext_mgr is None: + ext_mgr = ExtensionManager(FLAGS.osapi_extensions_path) + self.ext_mgr = ext_mgr + + mapper = routes.Mapper() + + # extended resources + for resource in ext_mgr.get_resources(): + LOG.debug(_('Extended resource: %s'), + resource.collection) + mapper.resource(resource.collection, resource.collection, + controller=resource.controller, + collection=resource.collection_actions, + member=resource.member_actions, + parent_resource=resource.parent) + + # extended actions + action_controllers = self._action_ext_controllers(application, ext_mgr, + mapper) + for action in ext_mgr.get_actions(): + LOG.debug(_('Extended action: %s'), action.action_name) + controller = action_controllers[action.collection] + controller.add_action(action.action_name, action.handler) + + # extended responses + resp_controllers = self._response_ext_controllers(application, ext_mgr, + mapper) + for response_ext in ext_mgr.get_response_extensions(): + LOG.debug(_('Extended response: %s'), response_ext.key) + controller = resp_controllers[response_ext.key] + controller.add_handler(response_ext.handler) + + self._router = routes.middleware.RoutesMiddleware(self._dispatch, + mapper) + + super(ExtensionMiddleware, self).__init__(application) + + @webob.dec.wsgify(RequestClass=wsgi.Request) + def __call__(self, req): + """ + Route the incoming request with router. + """ + req.environ['extended.app'] = self.application + return self._router + + @staticmethod + @webob.dec.wsgify(RequestClass=wsgi.Request) + def _dispatch(req): + """ + Returns the routed WSGI app's response or defers to the extended + application. + """ + match = req.environ['wsgiorg.routing_args'][1] + if not match: + return req.environ['extended.app'] + app = match['controller'] + return app + + +class ExtensionManager(object): + """ + Load extensions from the configured extension path. + See nova/tests/api/openstack/extensions/foxinsocks.py for an example + extension implementation. + """ + + def __init__(self, path): + LOG.audit(_('Initializing extension manager.')) + + self.path = path + self.extensions = {} + self._load_extensions() + + def get_resources(self): + """ + returns a list of ResourceExtension objects + """ + resources = [] + resources.append(ResourceExtension('extensions', + ExtensionController(self))) + for alias, ext in self.extensions.iteritems(): + try: + resources.extend(ext.get_resources()) + except AttributeError: + # NOTE: Extension aren't required to have resource extensions + pass + return resources + + def get_actions(self): + """ + returns a list of ActionExtension objects + """ + actions = [] + for alias, ext in self.extensions.iteritems(): + try: + actions.extend(ext.get_actions()) + except AttributeError: + # NOTE: Extension aren't required to have action extensions + pass + return actions + + def get_response_extensions(self): + """ + returns a list of ResponseExtension objects + """ + response_exts = [] + for alias, ext in self.extensions.iteritems(): + try: + response_exts.extend(ext.get_response_extensions()) + except AttributeError: + # NOTE: Extension aren't required to have response extensions + pass + return response_exts + + def _check_extension(self, extension): + """ + Checks for required methods in extension objects. + """ + try: + LOG.debug(_('Ext name: %s'), extension.get_name()) + LOG.debug(_('Ext alias: %s'), extension.get_alias()) + LOG.debug(_('Ext description: %s'), extension.get_description()) + LOG.debug(_('Ext namespace: %s'), extension.get_namespace()) + LOG.debug(_('Ext updated: %s'), extension.get_updated()) + except AttributeError as ex: + LOG.exception(_("Exception loading extension: %s"), unicode(ex)) + + def _load_extensions(self): + """ + Load extensions from the configured path. The extension name is + constructed from the module_name. If your extension module was named + widgets.py the extension class within that module should be + 'Widgets'. + + See nova/tests/api/openstack/extensions/foxinsocks.py for an example + extension implementation. + """ + if not os.path.exists(self.path): + return + + for f in os.listdir(self.path): + LOG.audit(_('Loading extension file: %s'), f) + mod_name, file_ext = os.path.splitext(os.path.split(f)[-1]) + ext_path = os.path.join(self.path, f) + if file_ext.lower() == '.py': + mod = imp.load_source(mod_name, ext_path) + ext_name = mod_name[0].upper() + mod_name[1:] + try: + new_ext = getattr(mod, ext_name)() + self._check_extension(new_ext) + self.extensions[new_ext.get_alias()] = new_ext + except AttributeError as ex: + LOG.exception(_("Exception loading extension: %s"), + unicode(ex)) + + +class ResponseExtension(object): + """ + ResponseExtension objects can be used to add data to responses from + core nova OpenStack API controllers. + """ + + def __init__(self, method, url_route, handler): + self.url_route = url_route + self.handler = handler + self.conditions = dict(method=[method]) + self.key = "%s-%s" % (method, url_route) + + +class ActionExtension(object): + """ + ActionExtension objects can be used to add custom actions to core nova + nova OpenStack API controllers. + """ + + def __init__(self, collection, action_name, handler): + self.collection = collection + self.action_name = action_name + self.handler = handler + + +class ResourceExtension(object): + """ + ResourceExtension objects can be used to add top level resources + to the OpenStack API in nova. + """ + + def __init__(self, collection, controller, parent=None, + collection_actions={}, member_actions={}): + self.collection = collection + self.controller = controller + self.parent = parent + self.collection_actions = collection_actions + self.member_actions = member_actions diff --git a/nova/api/openstack/faults.py b/nova/api/openstack/faults.py index 2fd733299..0e9c4b26f 100644 --- a/nova/api/openstack/faults.py +++ b/nova/api/openstack/faults.py @@ -61,3 +61,42 @@ class Fault(webob.exc.HTTPException): content_type = req.best_match_content_type() self.wrapped_exc.body = serializer.serialize(fault_data, content_type) return self.wrapped_exc + + +class OverLimitFault(webob.exc.HTTPException): + """ + Rate-limited request response. + """ + + _serialization_metadata = { + "application/xml": { + "attributes": { + "overLimitFault": "code", + }, + }, + } + + def __init__(self, message, details, retry_time): + """ + Initialize new `OverLimitFault` with relevant information. + """ + self.wrapped_exc = webob.exc.HTTPForbidden() + self.content = { + "overLimitFault": { + "code": self.wrapped_exc.status_int, + "message": message, + "details": details, + }, + } + + @webob.dec.wsgify(RequestClass=wsgi.Request) + def __call__(self, request): + """ + Return the wrapped exception with a serialized body conforming to our + error format. + """ + serializer = wsgi.Serializer(self._serialization_metadata) + content_type = request.best_match_content_type() + content = serializer.serialize(self.content, content_type) + self.wrapped_exc.body = content + return self.wrapped_exc diff --git a/nova/api/openstack/flavors.py b/nova/api/openstack/flavors.py index f3d040ba3..5b99b5a6f 100644 --- a/nova/api/openstack/flavors.py +++ b/nova/api/openstack/flavors.py @@ -15,15 +15,12 @@ # License for the specific language governing permissions and limitations # under the License. -from webob import exc +import webob from nova import db -from nova import context -from nova.api.openstack import faults -from nova.api.openstack import common -from nova.compute import instance_types +from nova import exception from nova import wsgi -import nova.api.openstack +from nova.api.openstack import views class Controller(wsgi.Controller): @@ -32,28 +29,50 @@ class Controller(wsgi.Controller): _serialization_metadata = { 'application/xml': { "attributes": { - "flavor": ["id", "name", "ram", "disk"]}}} + "flavor": ["id", "name", "ram", "disk"], + "link": ["rel", "type", "href"], + } + } + } def index(self, req): """Return all flavors in brief.""" - return dict(flavors=[dict(id=flavor['id'], name=flavor['name']) - for flavor in self.detail(req)['flavors']]) + items = self._get_flavors(req, is_detail=False) + return dict(flavors=items) def detail(self, req): """Return all flavors in detail.""" - items = [self.show(req, id)['flavor'] for id in self._all_ids(req)] + items = self._get_flavors(req, is_detail=True) return dict(flavors=items) + def _get_flavors(self, req, is_detail=True): + """Helper function that returns a list of flavor dicts.""" + ctxt = req.environ['nova.context'] + flavors = db.api.instance_type_get_all(ctxt) + builder = self._get_view_builder(req) + items = [builder.build(flavor, is_detail=is_detail) + for flavor in flavors.values()] + return items + def show(self, req, id): """Return data about the given flavor id.""" - ctxt = req.environ['nova.context'] - values = db.instance_type_get_by_flavor_id(ctxt, id) + try: + ctxt = req.environ['nova.context'] + flavor = db.api.instance_type_get_by_flavor_id(ctxt, id) + except exception.NotFound: + return webob.exc.HTTPNotFound() + + builder = self._get_view_builder(req) + values = builder.build(flavor, is_detail=True) return dict(flavor=values) - raise faults.Fault(exc.HTTPNotFound()) - def _all_ids(self, req): - """Return the list of all flavorids.""" - ctxt = req.environ['nova.context'] - inst_types = db.instance_type_get_all(ctxt) - flavor_ids = [inst_types[i]['flavorid'] for i in inst_types.keys()] - return sorted(flavor_ids) + +class ControllerV10(Controller): + def _get_view_builder(self, req): + return views.flavors.ViewBuilder() + + +class ControllerV11(Controller): + def _get_view_builder(self, req): + base_url = req.application_url + return views.flavors.ViewBuilderV11(base_url) diff --git a/nova/api/openstack/image_metadata.py b/nova/api/openstack/image_metadata.py new file mode 100644 index 000000000..c9d6ac532 --- /dev/null +++ b/nova/api/openstack/image_metadata.py @@ -0,0 +1,93 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from webob import exc + +from nova import flags +from nova import utils +from nova import wsgi +from nova.api.openstack import faults + + +FLAGS = flags.FLAGS + + +class Controller(wsgi.Controller): + """The image metadata API controller for the Openstack API""" + + def __init__(self): + self.image_service = utils.import_object(FLAGS.image_service) + super(Controller, self).__init__() + + def _get_metadata(self, context, image_id, image=None): + if not image: + image = self.image_service.show(context, image_id) + metadata = image.get('properties', {}) + return metadata + + def index(self, req, image_id): + """Returns the list of metadata for a given instance""" + context = req.environ['nova.context'] + metadata = self._get_metadata(context, image_id) + return dict(metadata=metadata) + + def show(self, req, image_id, id): + context = req.environ['nova.context'] + metadata = self._get_metadata(context, image_id) + if id in metadata: + return {id: metadata[id]} + else: + return faults.Fault(exc.HTTPNotFound()) + + def create(self, req, image_id): + context = req.environ['nova.context'] + body = self._deserialize(req.body, req.get_content_type()) + img = self.image_service.show(context, image_id) + metadata = self._get_metadata(context, image_id, img) + if 'metadata' in body: + for key, value in body['metadata'].iteritems(): + metadata[key] = value + img['properties'] = metadata + self.image_service.update(context, image_id, img, None) + return dict(metadata=metadata) + + def update(self, req, image_id, id): + context = req.environ['nova.context'] + body = self._deserialize(req.body, req.get_content_type()) + if not id in body: + expl = _('Request body and URI mismatch') + raise exc.HTTPBadRequest(explanation=expl) + if len(body) > 1: + expl = _('Request body contains too many items') + raise exc.HTTPBadRequest(explanation=expl) + img = self.image_service.show(context, image_id) + metadata = self._get_metadata(context, image_id, img) + metadata[id] = body[id] + img['properties'] = metadata + self.image_service.update(context, image_id, img, None) + + return req.body + + def delete(self, req, image_id, id): + context = req.environ['nova.context'] + img = self.image_service.show(context, image_id) + metadata = self._get_metadata(context, image_id) + if not id in metadata: + return faults.Fault(exc.HTTPNotFound()) + metadata.pop(id) + img['properties'] = metadata + self.image_service.update(context, image_id, img, None) diff --git a/nova/api/openstack/images.py b/nova/api/openstack/images.py index 98f0dd96b..79852ecc6 100644 --- a/nova/api/openstack/images.py +++ b/nova/api/openstack/images.py @@ -15,10 +15,14 @@ # License for the specific language governing permissions and limitations # under the License. +import datetime + from webob import exc from nova import compute +from nova import exception from nova import flags +from nova import log from nova import utils from nova import wsgi import nova.api.openstack @@ -27,6 +31,8 @@ from nova.api.openstack import faults import nova.image.service +LOG = log.getLogger('nova.api.openstack.images') + FLAGS = flags.FLAGS @@ -84,8 +90,6 @@ def _translate_status(item): # S3ImageService pass - return item - def _filter_keys(item, keys): """ @@ -104,6 +108,100 @@ def _convert_image_id_to_hash(image): image['id'] = image_id +def _translate_s3_like_images(image_metadata): + """Work-around for leaky S3ImageService abstraction""" + api_metadata = image_metadata.copy() + _convert_image_id_to_hash(api_metadata) + api_metadata = _translate_keys(api_metadata) + _translate_status(api_metadata) + return api_metadata + + +def _translate_from_image_service_to_api(image_metadata): + """Translate from ImageService to OpenStack API style attribute names + + This involves 4 steps: + + 1. Filter out attributes that the OpenStack API doesn't need + + 2. Translate from base image attributes from names used by + BaseImageService to names used by OpenStack API + + 3. Add in any image properties + + 4. Format values according to API spec (for example dates must + look like "2010-08-10T12:00:00Z") + """ + service_metadata = image_metadata.copy() + properties = service_metadata.pop('properties', {}) + + # 1. Filter out unecessary attributes + api_keys = ['id', 'name', 'updated_at', 'created_at', 'status'] + api_metadata = utils.subset_dict(service_metadata, api_keys) + + # 2. Translate base image attributes + api_map = {'updated_at': 'updated', 'created_at': 'created'} + api_metadata = utils.map_dict_keys(api_metadata, api_map) + + # 3. Add in any image properties + # 3a. serverId is used for backups and snapshots + try: + api_metadata['serverId'] = int(properties['instance_id']) + except KeyError: + pass # skip if it's not present + except ValueError: + pass # skip if it's not an integer + + # 3b. Progress special case + # TODO(sirp): ImageService doesn't have a notion of progress yet, so for + # now just fake it + if service_metadata['status'] == 'saving': + api_metadata['progress'] = 0 + + # 4. Format values + # 4a. Format Image Status (API requires uppercase) + api_metadata['status'] = _format_status_for_api(api_metadata['status']) + + # 4b. Format timestamps + for attr in ('created', 'updated'): + if attr in api_metadata: + api_metadata[attr] = _format_datetime_for_api( + api_metadata[attr]) + + return api_metadata + + +def _format_status_for_api(status): + """Return status in a format compliant with OpenStack API""" + mapping = {'queued': 'QUEUED', + 'preparing': 'PREPARING', + 'saving': 'SAVING', + 'active': 'ACTIVE', + 'killed': 'FAILED'} + return mapping[status] + + +def _format_datetime_for_api(datetime_): + """Stringify datetime objects in a format compliant with OpenStack API""" + API_DATETIME_FMT = '%Y-%m-%dT%H:%M:%SZ' + return datetime_.strftime(API_DATETIME_FMT) + + +def _safe_translate(image_metadata): + """Translate attributes for OpenStack API, temporary workaround for + S3ImageService attribute leakage. + """ + # FIXME(sirp): The S3ImageService appears to be leaking implementation + # details, including its internal attribute names, and internal + # `status` values. Working around it for now. + s3_like_image = ('imageId' in image_metadata) + if s3_like_image: + translate = _translate_s3_like_images + else: + translate = _translate_from_image_service_to_api + return translate(image_metadata) + + class Controller(wsgi.Controller): _serialization_metadata = { @@ -117,33 +215,32 @@ class Controller(wsgi.Controller): def index(self, req): """Return all public images in brief""" - items = self._service.index(req.environ['nova.context']) - items = common.limited(items, req) - items = [_filter_keys(item, ('id', 'name')) for item in items] - return dict(images=items) + context = req.environ['nova.context'] + image_metas = self._service.index(context) + image_metas = common.limited(image_metas, req) + return dict(images=image_metas) def detail(self, req): """Return all public images in detail""" - try: - items = self._service.detail(req.environ['nova.context']) - except NotImplementedError: - items = self._service.index(req.environ['nova.context']) - for image in items: - _convert_image_id_to_hash(image) - - items = common.limited(items, req) - items = [_translate_keys(item) for item in items] - items = [_translate_status(item) for item in items] - return dict(images=items) + context = req.environ['nova.context'] + image_metas = self._service.detail(context) + image_metas = common.limited(image_metas, req) + api_image_metas = [_safe_translate(image_meta) + for image_meta in image_metas] + return dict(images=api_image_metas) def show(self, req, id): """Return data about the given image id""" - image_id = common.get_image_id_from_image_hash(self._service, - req.environ['nova.context'], id) + context = req.environ['nova.context'] + try: + image_id = common.get_image_id_from_image_hash( + self._service, context, id) + except exception.NotFound: + raise faults.Fault(exc.HTTPNotFound()) - image = self._service.show(req.environ['nova.context'], image_id) - _convert_image_id_to_hash(image) - return dict(image=image) + image_meta = self._service.show(context, image_id) + api_image_meta = _safe_translate(image_meta) + return dict(image=api_image_meta) def delete(self, req, id): # Only public images are supported for now. @@ -154,11 +251,10 @@ class Controller(wsgi.Controller): env = self._deserialize(req.body, req.get_content_type()) instance_id = env["image"]["serverId"] name = env["image"]["name"] - image_meta = compute.API().snapshot( context, instance_id, name) - - return dict(image=image_meta) + api_image_meta = _safe_translate(image_meta) + return dict(image=api_image_meta) def update(self, req, id): # Users may not modify public images, and that's all that diff --git a/nova/api/openstack/limits.py b/nova/api/openstack/limits.py new file mode 100644 index 000000000..efc7d193d --- /dev/null +++ b/nova/api/openstack/limits.py @@ -0,0 +1,358 @@ +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License.import datetime + +""" +Module dedicated functions/classes dealing with rate limiting requests. +""" + +import copy +import httplib +import json +import math +import re +import time +import urllib +import webob.exc + +from collections import defaultdict + +from webob.dec import wsgify + +from nova import wsgi +from nova.api.openstack import faults +from nova.wsgi import Controller +from nova.wsgi import Middleware + + +# Convenience constants for the limits dictionary passed to Limiter(). +PER_SECOND = 1 +PER_MINUTE = 60 +PER_HOUR = 60 * 60 +PER_DAY = 60 * 60 * 24 + + +class LimitsController(Controller): + """ + Controller for accessing limits in the OpenStack API. + """ + + _serialization_metadata = { + "application/xml": { + "attributes": { + "limit": ["verb", "URI", "regex", "value", "unit", + "resetTime", "remaining", "name"], + }, + "plurals": { + "rate": "limit", + }, + }, + } + + def index(self, req): + """ + Return all global and rate limit information. + """ + abs_limits = {} + rate_limits = req.environ.get("nova.limits", []) + + return { + "limits": { + "rate": rate_limits, + "absolute": abs_limits, + }, + } + + +class Limit(object): + """ + Stores information about a limit for HTTP requets. + """ + + UNITS = { + 1: "SECOND", + 60: "MINUTE", + 60 * 60: "HOUR", + 60 * 60 * 24: "DAY", + } + + 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) + self.error_message = _("Only %(value)s %(verb)s request(s) can be "\ + "made to %(uri)s every %(unit_string)s." % 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(Middleware): + """ + Rate-limits requests passing through this middleware. All limit information + is stored in memory for this implementation. + """ + + def __init__(self, application, limits=None): + """ + Initialize new `RateLimitingMiddleware`, which wraps the given WSGI + application and sets up the given limits. + + @param application: WSGI application to wrap + @param limits: List of dictionaries describing limits + """ + Middleware.__init__(self, application) + self._limiter = Limiter(limits or DEFAULT_LIMITS) + + @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("nova.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 faults.OverLimitFault(msg, error, retry) + + req.environ["nova.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): + """ + Initialize the new `Limiter`. + + @param limits: List of `Limit` objects + """ + self.limits = copy.deepcopy(limits) + self.levels = defaultdict(lambda: copy.deepcopy(limits)) + + 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 + + +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) + + @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(json.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 = json.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 diff --git a/nova/api/openstack/server_metadata.py b/nova/api/openstack/server_metadata.py new file mode 100644 index 000000000..45bbac99d --- /dev/null +++ b/nova/api/openstack/server_metadata.py @@ -0,0 +1,78 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from webob import exc + +from nova import compute +from nova import wsgi +from nova.api.openstack import faults + + +class Controller(wsgi.Controller): + """ The server metadata API controller for the Openstack API """ + + def __init__(self): + self.compute_api = compute.API() + super(Controller, self).__init__() + + def _get_metadata(self, context, server_id): + metadata = self.compute_api.get_instance_metadata(context, server_id) + meta_dict = {} + for key, value in metadata.iteritems(): + meta_dict[key] = value + return dict(metadata=meta_dict) + + def index(self, req, server_id): + """ Returns the list of metadata for a given instance """ + context = req.environ['nova.context'] + return self._get_metadata(context, server_id) + + def create(self, req, server_id): + context = req.environ['nova.context'] + body = self._deserialize(req.body, req.get_content_type()) + self.compute_api.update_or_create_instance_metadata(context, + server_id, + body['metadata']) + return req.body + + def update(self, req, server_id, id): + context = req.environ['nova.context'] + body = self._deserialize(req.body, req.get_content_type()) + if not id in body: + expl = _('Request body and URI mismatch') + raise exc.HTTPBadRequest(explanation=expl) + if len(body) > 1: + expl = _('Request body contains too many items') + raise exc.HTTPBadRequest(explanation=expl) + self.compute_api.update_or_create_instance_metadata(context, + server_id, + body) + return req.body + + def show(self, req, server_id, id): + """ Return a single metadata item """ + context = req.environ['nova.context'] + data = self._get_metadata(context, server_id) + if id in data['metadata']: + return {id: data['metadata'][id]} + else: + return faults.Fault(exc.HTTPNotFound()) + + def delete(self, req, server_id, id): + """ Deletes an existing metadata """ + context = req.environ['nova.context'] + self.compute_api.delete_instance_metadata(context, server_id, id) diff --git a/nova/api/openstack/servers.py b/nova/api/openstack/servers.py index 3ecd4fb01..75a305a14 100644 --- a/nova/api/openstack/servers.py +++ b/nova/api/openstack/servers.py @@ -15,124 +15,95 @@ import base64 import hashlib -import json import traceback -from xml.dom import minidom from webob import exc +from xml.dom import minidom from nova import compute +from nova import context from nova import exception from nova import flags from nova import log as logging -from nova import wsgi +from nova import quota from nova import utils +from nova import wsgi from nova.api.openstack import common from nova.api.openstack import faults +import nova.api.openstack.views.addresses +import nova.api.openstack.views.flavors +import nova.api.openstack.views.servers from nova.auth import manager as auth_manager from nova.compute import instance_types from nova.compute import power_state import nova.api.openstack +from nova.scheduler import api as scheduler_api LOG = logging.getLogger('server') - - FLAGS = flags.FLAGS -def _translate_detail_keys(inst): - """ Coerces into dictionary format, mapping everything to Rackspace-like - attributes for return""" - power_mapping = { - None: 'build', - power_state.NOSTATE: 'build', - power_state.RUNNING: 'active', - power_state.BLOCKED: 'active', - power_state.SUSPENDED: 'suspended', - power_state.PAUSED: 'paused', - power_state.SHUTDOWN: 'active', - power_state.SHUTOFF: 'active', - power_state.CRASHED: 'error', - power_state.FAILED: 'error'} - inst_dict = {} - - mapped_keys = dict(status='state', imageId='image_id', - flavorId='instance_type', name='display_name', id='id') - - for k, v in mapped_keys.iteritems(): - inst_dict[k] = inst[v] - - inst_dict['status'] = power_mapping[inst_dict['status']] - inst_dict['addresses'] = dict(public=[], private=[]) - - # grab single private fixed ip - private_ips = utils.get_from_path(inst, 'fixed_ip/address') - inst_dict['addresses']['private'] = private_ips - - # grab all public floating ips - public_ips = utils.get_from_path(inst, 'fixed_ip/floating_ips/address') - inst_dict['addresses']['public'] = public_ips - - # Return the metadata as a dictionary - metadata = {} - for item in inst['metadata']: - metadata[item['key']] = item['value'] - inst_dict['metadata'] = metadata - - inst_dict['hostId'] = '' - if inst['host']: - inst_dict['hostId'] = hashlib.sha224(inst['host']).hexdigest() - - return dict(server=inst_dict) - - -def _translate_keys(inst): - """ Coerces into dictionary format, excluding all model attributes - save for id and name """ - return dict(server=dict(id=inst['id'], name=inst['display_name'])) - - class Controller(wsgi.Controller): """ The Server API controller for the OpenStack API """ _serialization_metadata = { - 'application/xml': { + "application/xml": { "attributes": { "server": ["id", "imageId", "name", "flavorId", "hostId", - "status", "progress", "adminPass"]}}} + "status", "progress", "adminPass", "flavorRef", + "imageRef"], + "link": ["rel", "type", "href"], + }, + }, + } def __init__(self): self.compute_api = compute.API() self._image_service = utils.import_object(FLAGS.image_service) super(Controller, self).__init__() + def ips(self, req, id): + try: + instance = self.compute_api.get(req.environ['nova.context'], id) + except exception.NotFound: + return faults.Fault(exc.HTTPNotFound()) + + builder = self._get_addresses_view_builder(req) + return builder.build(instance) + def index(self, req): """ Returns a list of server names and ids for a given user """ - return self._items(req, entity_maker=_translate_keys) + return self._items(req, is_detail=False) def detail(self, req): """ Returns a list of server details for a given user """ - return self._items(req, entity_maker=_translate_detail_keys) + return self._items(req, is_detail=True) - def _items(self, req, entity_maker): + def _items(self, req, is_detail): """Returns a list of servers for a given user. - entity_maker - either _translate_detail_keys or _translate_keys + builder - the response model builder """ instance_list = self.compute_api.get_all(req.environ['nova.context']) - limited_list = common.limited(instance_list, req) - res = [entity_maker(inst)['server'] for inst in limited_list] - return dict(servers=res) + limited_list = self._limit_items(instance_list, req) + builder = self._get_view_builder(req) + servers = [builder.build(inst, is_detail)['server'] + for inst in limited_list] + return dict(servers=servers) + @scheduler_api.redirect_handler def show(self, req, id): """ Returns server details by server id """ try: - instance = self.compute_api.get(req.environ['nova.context'], id) - return _translate_detail_keys(instance) + instance = self.compute_api.routing_get( + req.environ['nova.context'], id) + builder = self._get_view_builder(req) + return builder.build(instance, is_detail=True) except exception.NotFound: return faults.Fault(exc.HTTPNotFound()) + @scheduler_api.redirect_handler def delete(self, req, id): """ Destroys a server """ try: @@ -157,8 +128,9 @@ class Controller(wsgi.Controller): key_name = key_pair['name'] key_data = key_pair['public_key'] + requested_image_id = self._image_id_from_req_data(env) image_id = common.get_image_id_from_image_hash(self._image_service, - context, env['server']['imageId']) + context, requested_image_id) kernel_id, ramdisk_id = self._get_kernel_ramdisk_from_image( req, image_id) @@ -172,13 +144,16 @@ class Controller(wsgi.Controller): for k, v in env['server']['metadata'].items(): metadata.append({'key': k, 'value': v}) - personality = env['server'].get('personality', []) - injected_files = self._get_injected_files(personality) + personality = env['server'].get('personality') + injected_files = [] + if personality: + injected_files = self._get_injected_files(personality) + flavor_id = self._flavor_id_from_req_data(env) try: - instances = self.compute_api.create( + (inst,) = self.compute_api.create( context, - instance_types.get_by_flavor_id(env['server']['flavorId']), + instance_types.get_by_flavor_id(flavor_id), image_id, kernel_id=kernel_id, ramdisk_id=ramdisk_id, @@ -188,10 +163,14 @@ class Controller(wsgi.Controller): key_data=key_data, metadata=metadata, injected_files=injected_files) - except QuotaError as error: + except quota.QuotaError as error: self._handle_quota_error(error) - server = _translate_keys(instances[0]) + inst['instance_type'] = flavor_id + inst['image_id'] = requested_image_id + + builder = self._get_view_builder(req) + server = builder.build(inst, is_detail=True) password = "%s%s" % (server['server']['name'][:4], utils.generate_password(12)) server['server']['adminPass'] = password @@ -220,6 +199,7 @@ class Controller(wsgi.Controller): underlying compute service. """ injected_files = [] + for item in personality: try: path = item['path'] @@ -238,7 +218,7 @@ class Controller(wsgi.Controller): injected_files.append((path, contents)) return injected_files - def _handle_quota_errors(self, error): + def _handle_quota_error(self, error): """ Reraise quota errors as api-specific http exceptions """ @@ -254,6 +234,7 @@ class Controller(wsgi.Controller): # if the original error is okay, just reraise it raise error + @scheduler_api.redirect_handler def update(self, req, id): """ Updates the server name or password """ if len(req.body) == 0: @@ -269,7 +250,7 @@ class Controller(wsgi.Controller): update_dict['admin_pass'] = inst_dict['server']['adminPass'] try: self.compute_api.set_admin_password(ctxt, id) - except exception.TimeoutException, e: + except exception.TimeoutException: return exc.HTTPRequestTimeout() if 'name' in inst_dict['server']: update_dict['display_name'] = inst_dict['server']['name'] @@ -279,6 +260,7 @@ class Controller(wsgi.Controller): return faults.Fault(exc.HTTPNotFound()) return exc.HTTPNoContent() + @scheduler_api.redirect_handler def action(self, req, id): """Multi-purpose method used to reboot, rebuild, or resize a server""" @@ -344,6 +326,7 @@ class Controller(wsgi.Controller): return faults.Fault(exc.HTTPUnprocessableEntity()) return exc.HTTPAccepted() + @scheduler_api.redirect_handler def lock(self, req, id): """ lock the instance with id @@ -359,6 +342,7 @@ class Controller(wsgi.Controller): return faults.Fault(exc.HTTPUnprocessableEntity()) return exc.HTTPAccepted() + @scheduler_api.redirect_handler def unlock(self, req, id): """ unlock the instance with id @@ -374,6 +358,7 @@ class Controller(wsgi.Controller): return faults.Fault(exc.HTTPUnprocessableEntity()) return exc.HTTPAccepted() + @scheduler_api.redirect_handler def get_lock(self, req, id): """ return the boolean state of (instance with id)'s lock @@ -388,6 +373,7 @@ class Controller(wsgi.Controller): return faults.Fault(exc.HTTPUnprocessableEntity()) return exc.HTTPAccepted() + @scheduler_api.redirect_handler def reset_network(self, req, id): """ Reset networking on an instance (admin only). @@ -402,6 +388,7 @@ class Controller(wsgi.Controller): return faults.Fault(exc.HTTPUnprocessableEntity()) return exc.HTTPAccepted() + @scheduler_api.redirect_handler def inject_network_info(self, req, id): """ Inject network info for an instance (admin only). @@ -416,6 +403,7 @@ class Controller(wsgi.Controller): return faults.Fault(exc.HTTPUnprocessableEntity()) return exc.HTTPAccepted() + @scheduler_api.redirect_handler def pause(self, req, id): """ Permit Admins to Pause the server. """ ctxt = req.environ['nova.context'] @@ -427,6 +415,7 @@ class Controller(wsgi.Controller): return faults.Fault(exc.HTTPUnprocessableEntity()) return exc.HTTPAccepted() + @scheduler_api.redirect_handler def unpause(self, req, id): """ Permit Admins to Unpause the server. """ ctxt = req.environ['nova.context'] @@ -438,6 +427,7 @@ class Controller(wsgi.Controller): return faults.Fault(exc.HTTPUnprocessableEntity()) return exc.HTTPAccepted() + @scheduler_api.redirect_handler def suspend(self, req, id): """permit admins to suspend the server""" context = req.environ['nova.context'] @@ -449,6 +439,7 @@ class Controller(wsgi.Controller): return faults.Fault(exc.HTTPUnprocessableEntity()) return exc.HTTPAccepted() + @scheduler_api.redirect_handler def resume(self, req, id): """permit admins to resume the server from suspend""" context = req.environ['nova.context'] @@ -460,6 +451,7 @@ class Controller(wsgi.Controller): return faults.Fault(exc.HTTPUnprocessableEntity()) return exc.HTTPAccepted() + @scheduler_api.redirect_handler def rescue(self, req, id): """Permit users to rescue the server.""" context = req.environ["nova.context"] @@ -471,6 +463,7 @@ class Controller(wsgi.Controller): return faults.Fault(exc.HTTPUnprocessableEntity()) return exc.HTTPAccepted() + @scheduler_api.redirect_handler def unrescue(self, req, id): """Permit users to unrescue the server.""" context = req.environ["nova.context"] @@ -482,6 +475,7 @@ class Controller(wsgi.Controller): return faults.Fault(exc.HTTPUnprocessableEntity()) return exc.HTTPAccepted() + @scheduler_api.redirect_handler def get_ajax_console(self, req, id): """ Returns a url to an instance's ajaxterm console. """ try: @@ -491,6 +485,7 @@ class Controller(wsgi.Controller): return faults.Fault(exc.HTTPNotFound()) return exc.HTTPAccepted() + @scheduler_api.redirect_handler def diagnostics(self, req, id): """Permit Admins to retrieve server diagnostics.""" ctxt = req.environ["nova.context"] @@ -511,33 +506,41 @@ class Controller(wsgi.Controller): return dict(actions=actions) def _get_kernel_ramdisk_from_image(self, req, image_id): - """Retrevies kernel and ramdisk IDs from Glance - - Only 'machine' (ami) type use kernel and ramdisk outside of the - image. + """Fetch an image from the ImageService, then if present, return the + associated kernel and ramdisk image IDs. """ - # FIXME(sirp): Since we're retrieving the kernel_id from an - # image_property, this means only Glance is supported. - # The BaseImageService needs to expose a consistent way of accessing - # kernel_id and ramdisk_id - image = self._image_service.show(req.environ['nova.context'], image_id) + context = req.environ['nova.context'] + image_meta = self._image_service.show(context, image_id) + # NOTE(sirp): extracted to a separate method to aid unit-testing, the + # new method doesn't need a request obj or an ImageService stub + kernel_id, ramdisk_id = self._do_get_kernel_ramdisk_from_image( + image_meta) + return kernel_id, ramdisk_id + + @staticmethod + def _do_get_kernel_ramdisk_from_image(image_meta): + """Given an ImageService image_meta, return kernel and ramdisk image + ids if present. - if image['status'] != 'active': + This is only valid for `ami` style images. + """ + image_id = image_meta['id'] + if image_meta['status'] != 'active': raise exception.Invalid( _("Cannot build from image %(image_id)s, status not active") % locals()) - if image['disk_format'] != 'ami': + if image_meta['properties']['disk_format'] != 'ami': return None, None try: - kernel_id = image['properties']['kernel_id'] + kernel_id = image_meta['properties']['kernel_id'] except KeyError: raise exception.NotFound( _("Kernel not found for image %(image_id)s") % locals()) try: - ramdisk_id = image['properties']['ramdisk_id'] + ramdisk_id = image_meta['properties']['ramdisk_id'] except KeyError: raise exception.NotFound( _("Ramdisk not found for image %(image_id)s") % locals()) @@ -545,6 +548,51 @@ class Controller(wsgi.Controller): return kernel_id, ramdisk_id +class ControllerV10(Controller): + def _image_id_from_req_data(self, data): + return data['server']['imageId'] + + def _flavor_id_from_req_data(self, data): + return data['server']['flavorId'] + + def _get_view_builder(self, req): + addresses_builder = nova.api.openstack.views.addresses.ViewBuilderV10() + return nova.api.openstack.views.servers.ViewBuilderV10( + addresses_builder) + + def _get_addresses_view_builder(self, req): + return nova.api.openstack.views.addresses.ViewBuilderV10(req) + + def _limit_items(self, items, req): + return common.limited(items, req) + + +class ControllerV11(Controller): + def _image_id_from_req_data(self, data): + href = data['server']['imageRef'] + return common.get_id_from_href(href) + + def _flavor_id_from_req_data(self, data): + href = data['server']['flavorRef'] + return common.get_id_from_href(href) + + def _get_view_builder(self, req): + base_url = req.application_url + flavor_builder = nova.api.openstack.views.flavors.ViewBuilderV11( + base_url) + image_builder = nova.api.openstack.views.images.ViewBuilderV11( + base_url) + addresses_builder = nova.api.openstack.views.addresses.ViewBuilderV11() + return nova.api.openstack.views.servers.ViewBuilderV11( + addresses_builder, flavor_builder, image_builder, base_url) + + def _get_addresses_view_builder(self, req): + return nova.api.openstack.views.addresses.ViewBuilderV11(req) + + def _limit_items(self, items, req): + return common.limited_by_marker(items, req) + + class ServerCreateRequestXMLDeserializer(object): """ Deserializer to handle xml-formatted server create requests. diff --git a/nova/api/openstack/users.py b/nova/api/openstack/users.py index ebd0f4512..d3ab3d553 100644 --- a/nova/api/openstack/users.py +++ b/nova/api/openstack/users.py @@ -13,13 +13,14 @@ # License for the specific language governing permissions and limitations # under the License. -import common +from webob import exc from nova import exception from nova import flags from nova import log as logging from nova import wsgi - +from nova.api.openstack import common +from nova.api.openstack import faults from nova.auth import manager FLAGS = flags.FLAGS @@ -63,7 +64,17 @@ class Controller(wsgi.Controller): def show(self, req, id): """Return data about the given user id""" - user = self.manager.get_user(id) + + #NOTE(justinsb): The drivers are a little inconsistent in how they + # deal with "NotFound" - some throw, some return None. + try: + user = self.manager.get_user(id) + except exception.NotFound: + user = None + + if user is None: + raise faults.Fault(exc.HTTPNotFound()) + return dict(user=_translate_keys(user)) def delete(self, req, id): diff --git a/nova/api/openstack/versions.py b/nova/api/openstack/versions.py new file mode 100644 index 000000000..33f1dd628 --- /dev/null +++ b/nova/api/openstack/versions.py @@ -0,0 +1,54 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import webob.dec +import webob.exc + +from nova import wsgi +import nova.api.openstack.views.versions + + +class Versions(wsgi.Application): + @webob.dec.wsgify(RequestClass=wsgi.Request) + def __call__(self, req): + """Respond to a request for all OpenStack API versions.""" + version_objs = [ + { + "id": "v1.1", + "status": "CURRENT", + }, + { + "id": "v1.0", + "status": "DEPRECATED", + }, + ] + + builder = nova.api.openstack.views.versions.get_view_builder(req) + versions = [builder.build(version) for version in version_objs] + response = dict(versions=versions) + + metadata = { + "application/xml": { + "attributes": { + "version": ["status", "id"], + "link": ["rel", "href"], + } + } + } + + content_type = req.best_match_content_type() + return wsgi.Serializer(metadata).serialize(response, content_type) diff --git a/nova/api/openstack/views/__init__.py b/nova/api/openstack/views/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/nova/api/openstack/views/__init__.py diff --git a/nova/api/openstack/views/addresses.py b/nova/api/openstack/views/addresses.py new file mode 100644 index 000000000..90c77855b --- /dev/null +++ b/nova/api/openstack/views/addresses.py @@ -0,0 +1,42 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010-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. + +from nova import utils +from nova.api.openstack import common + + +class ViewBuilder(object): + ''' Models a server addresses response as a python dictionary.''' + + def build(self, inst): + raise NotImplementedError() + + +class ViewBuilderV10(ViewBuilder): + def build(self, inst): + private_ips = utils.get_from_path(inst, 'fixed_ip/address') + public_ips = utils.get_from_path(inst, 'fixed_ip/floating_ips/address') + return dict(public=public_ips, private=private_ips) + + +class ViewBuilderV11(ViewBuilder): + def build(self, inst): + private_ips = utils.get_from_path(inst, 'fixed_ip/address') + private_ips = [dict(version=4, addr=a) for a in private_ips] + public_ips = utils.get_from_path(inst, 'fixed_ip/floating_ips/address') + public_ips = [dict(version=4, addr=a) for a in public_ips] + return dict(public=public_ips, private=private_ips) diff --git a/nova/api/openstack/views/flavors.py b/nova/api/openstack/views/flavors.py new file mode 100644 index 000000000..462890ab2 --- /dev/null +++ b/nova/api/openstack/views/flavors.py @@ -0,0 +1,96 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010-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. + +from nova.api.openstack import common + + +class ViewBuilder(object): + + def build(self, flavor_obj, is_detail=False): + """Generic method used to generate a flavor entity.""" + if is_detail: + flavor = self._build_detail(flavor_obj) + else: + flavor = self._build_simple(flavor_obj) + + self._build_extra(flavor) + + return flavor + + def _build_simple(self, flavor_obj): + """Build a minimal representation of a flavor.""" + return { + "id": flavor_obj["flavorid"], + "name": flavor_obj["name"], + } + + def _build_detail(self, flavor_obj): + """Build a more complete representation of a flavor.""" + simple = self._build_simple(flavor_obj) + + detail = { + "ram": flavor_obj["memory_mb"], + "disk": flavor_obj["local_gb"], + } + + detail.update(simple) + + return detail + + def _build_extra(self, flavor_obj): + """Hook for version-specific changes to newly created flavor object.""" + pass + + +class ViewBuilderV11(ViewBuilder): + """Openstack API v1.1 flavors view builder.""" + + def __init__(self, base_url): + """ + :param base_url: url of the root wsgi application + """ + self.base_url = base_url + + def _build_extra(self, flavor_obj): + flavor_obj["links"] = self._build_links(flavor_obj) + + def _build_links(self, flavor_obj): + """Generate a container of links that refer to the provided flavor.""" + href = self.generate_href(flavor_obj["id"]) + + links = [ + { + "rel": "self", + "href": href, + }, + { + "rel": "bookmark", + "type": "application/json", + "href": href, + }, + { + "rel": "bookmark", + "type": "application/xml", + "href": href, + }, + ] + + return links + + def generate_href(self, flavor_id): + """Create an url that refers to a specific flavor id.""" + return "%s/flavors/%s" % (self.base_url, flavor_id) diff --git a/nova/api/openstack/views/images.py b/nova/api/openstack/views/images.py new file mode 100644 index 000000000..a6c6ad7d1 --- /dev/null +++ b/nova/api/openstack/views/images.py @@ -0,0 +1,34 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010-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. + +from nova.api.openstack import common + + +class ViewBuilder(object): + def __init__(self): + pass + + def build(self, image_obj): + raise NotImplementedError() + + +class ViewBuilderV11(ViewBuilder): + def __init__(self, base_url): + self.base_url = base_url + + def generate_href(self, image_id): + return "%s/images/%s" % (self.base_url, image_id) diff --git a/nova/api/openstack/views/servers.py b/nova/api/openstack/views/servers.py new file mode 100644 index 000000000..4e7f62eb3 --- /dev/null +++ b/nova/api/openstack/views/servers.py @@ -0,0 +1,168 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010-2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import hashlib +import os + +from nova.compute import power_state +import nova.compute +import nova.context +from nova.api.openstack import common +from nova.api.openstack.views import addresses as addresses_view +from nova.api.openstack.views import flavors as flavors_view +from nova.api.openstack.views import images as images_view +from nova import utils + + +class ViewBuilder(object): + """Model a server response as a python dictionary. + + Public methods: build + Abstract methods: _build_image, _build_flavor + + """ + + def __init__(self, addresses_builder): + self.addresses_builder = addresses_builder + + def build(self, inst, is_detail): + """Return a dict that represenst a server.""" + if is_detail: + server = self._build_detail(inst) + else: + server = self._build_simple(inst) + + self._build_extra(server, inst) + + return server + + def _build_simple(self, inst): + """Return a simple model of a server.""" + return dict(server=dict(id=inst['id'], name=inst['display_name'])) + + def _build_detail(self, inst): + """Returns a detailed model of a server.""" + power_mapping = { + None: 'build', + power_state.NOSTATE: 'build', + power_state.RUNNING: 'active', + power_state.BLOCKED: 'active', + power_state.SUSPENDED: 'suspended', + power_state.PAUSED: 'paused', + power_state.SHUTDOWN: 'active', + power_state.SHUTOFF: 'active', + power_state.CRASHED: 'error', + power_state.FAILED: 'error'} + + inst_dict = { + 'id': int(inst['id']), + 'name': inst['display_name'], + 'addresses': self.addresses_builder.build(inst), + 'status': power_mapping[inst.get('state')]} + + ctxt = nova.context.get_admin_context() + compute_api = nova.compute.API() + if compute_api.has_finished_migration(ctxt, inst['id']): + inst_dict['status'] = 'resize-confirm' + + # Return the metadata as a dictionary + metadata = {} + for item in inst.get('metadata', []): + metadata[item['key']] = item['value'] + inst_dict['metadata'] = metadata + + inst_dict['hostId'] = '' + if inst.get('host'): + inst_dict['hostId'] = hashlib.sha224(inst['host']).hexdigest() + + self._build_image(inst_dict, inst) + self._build_flavor(inst_dict, inst) + + return dict(server=inst_dict) + + def _build_image(self, response, inst): + """Return the image sub-resource of a server.""" + raise NotImplementedError() + + def _build_flavor(self, response, inst): + """Return the flavor sub-resource of a server.""" + raise NotImplementedError() + + def _build_extra(self, response, inst): + pass + + +class ViewBuilderV10(ViewBuilder): + """Model an Openstack API V1.0 server response.""" + + def _build_image(self, response, inst): + if 'image_id' in dict(inst): + response['imageId'] = inst['image_id'] + + def _build_flavor(self, response, inst): + if 'instance_type' in dict(inst): + response['flavorId'] = inst['instance_type'] + + +class ViewBuilderV11(ViewBuilder): + """Model an Openstack API V1.0 server response.""" + def __init__(self, addresses_builder, flavor_builder, image_builder, + base_url): + ViewBuilder.__init__(self, addresses_builder) + self.flavor_builder = flavor_builder + self.image_builder = image_builder + self.base_url = base_url + + def _build_image(self, response, inst): + if "image_id" in dict(inst): + image_id = inst.get("image_id") + response["imageRef"] = self.image_builder.generate_href(image_id) + + def _build_flavor(self, response, inst): + if "instance_type" in dict(inst): + flavor_id = inst["instance_type"] + flavor_ref = self.flavor_builder.generate_href(flavor_id) + response["flavorRef"] = flavor_ref + + def _build_extra(self, response, inst): + self._build_links(response, inst) + + def _build_links(self, response, inst): + href = self.generate_href(inst["id"]) + + links = [ + { + "rel": "self", + "href": href, + }, + { + "rel": "bookmark", + "type": "application/json", + "href": href, + }, + { + "rel": "bookmark", + "type": "application/xml", + "href": href, + }, + ] + + response["server"]["links"] = links + + def generate_href(self, server_id): + """Create an url that refers to a specific server id.""" + return os.path.join(self.base_url, "servers", str(server_id)) diff --git a/nova/api/openstack/views/versions.py b/nova/api/openstack/views/versions.py new file mode 100644 index 000000000..d0145c94a --- /dev/null +++ b/nova/api/openstack/views/versions.py @@ -0,0 +1,59 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010-2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os + + +def get_view_builder(req): + base_url = req.application_url + return ViewBuilder(base_url) + + +class ViewBuilder(object): + + def __init__(self, base_url): + """ + :param base_url: url of the root wsgi application + """ + self.base_url = base_url + + def build(self, version_data): + """Generic method used to generate a version entity.""" + version = { + "id": version_data["id"], + "status": version_data["status"], + "links": self._build_links(version_data), + } + + return version + + def _build_links(self, version_data): + """Generate a container of links that refer to the provided version.""" + href = self.generate_href(version_data["id"]) + + links = [ + { + "rel": "self", + "href": href, + }, + ] + + return links + + def generate_href(self, version_number): + """Create an url that refers to a specific version_number.""" + return os.path.join(self.base_url, version_number) diff --git a/nova/api/openstack/zones.py b/nova/api/openstack/zones.py index 8fe84275a..846cb48a1 100644 --- a/nova/api/openstack/zones.py +++ b/nova/api/openstack/zones.py @@ -15,9 +15,10 @@ import common +from nova import db from nova import flags +from nova import log as logging from nova import wsgi -from nova import db from nova.scheduler import api @@ -38,7 +39,8 @@ def _exclude_keys(item, keys): def _scrub_zone(zone): - return _filter_keys(zone, ('id', 'api_url')) + return _exclude_keys(zone, ('username', 'password', 'created_at', + 'deleted', 'deleted_at', 'updated_at')) class Controller(wsgi.Controller): @@ -52,13 +54,9 @@ class Controller(wsgi.Controller): """Return all zones in brief""" # Ask the ZoneManager in the Scheduler for most recent data, # or fall-back to the database ... - items = api.API().get_zone_list(req.environ['nova.context']) - if not items: - items = db.zone_get_all(req.environ['nova.context']) - + items = api.get_zone_list(req.environ['nova.context']) items = common.limited(items, req) - items = [_exclude_keys(item, ['username', 'password']) - for item in items] + items = [_scrub_zone(item) for item in items] return dict(zones=items) def detail(self, req): @@ -67,29 +65,37 @@ class Controller(wsgi.Controller): def info(self, req): """Return name and capabilities for this zone.""" - return dict(zone=dict(name=FLAGS.zone_name, - capabilities=FLAGS.zone_capabilities)) + items = api.get_zone_capabilities(req.environ['nova.context']) + + zone = dict(name=FLAGS.zone_name) + caps = FLAGS.zone_capabilities + for cap in caps: + key, value = cap.split('=') + zone[key] = value + for item, (min_value, max_value) in items.iteritems(): + zone[item] = "%s,%s" % (min_value, max_value) + return dict(zone=zone) def show(self, req, id): """Return data about the given zone id""" zone_id = int(id) - zone = db.zone_get(req.environ['nova.context'], zone_id) + zone = api.zone_get(req.environ['nova.context'], zone_id) return dict(zone=_scrub_zone(zone)) def delete(self, req, id): zone_id = int(id) - db.zone_delete(req.environ['nova.context'], zone_id) + api.zone_delete(req.environ['nova.context'], zone_id) return {} def create(self, req): context = req.environ['nova.context'] env = self._deserialize(req.body, req.get_content_type()) - zone = db.zone_create(context, env["zone"]) + zone = api.zone_create(context, env["zone"]) return dict(zone=_scrub_zone(zone)) def update(self, req, id): context = req.environ['nova.context'] env = self._deserialize(req.body, req.get_content_type()) zone_id = int(id) - zone = db.zone_update(context, zone_id, env["zone"]) + zone = api.zone_update(context, zone_id, env["zone"]) return dict(zone=_scrub_zone(zone)) |
