From 3a421e759ff8ead517770470e4edc7226c4dadb3 Mon Sep 17 00:00:00 2001 From: Salvatore Orlando Date: Tue, 24 May 2011 17:45:16 +0100 Subject: [PATCH] Work in progress on network API --- bin/quantum | 17 +++- etc/quantum.conf | 12 ++- quantum/api/__init__.py | 67 ++++++++++++- quantum/api/api_common.py | 21 ++++ quantum/api/faults.py | 62 ++++++++++++ quantum/api/networks.py | 200 ++++++++++++++++++++++++++++++++++++++ quantum/common/config.py | 34 ++----- quantum/common/utils.py | 2 +- quantum/common/wsgi.py | 10 +- quantum/service.py | 108 ++++++++++++++++---- 10 files changed, 478 insertions(+), 55 deletions(-) create mode 100644 quantum/api/api_common.py create mode 100644 quantum/api/faults.py create mode 100644 quantum/api/networks.py diff --git a/bin/quantum b/bin/quantum index 780ccc61a..9971e795a 100755 --- a/bin/quantum +++ b/bin/quantum @@ -35,6 +35,7 @@ if os.path.exists(os.path.join(possible_topdir, 'quantum', '__init__.py')): gettext.install('quantum', unicode=1) +from quantum import service from quantum.common import wsgi from quantum.common import config @@ -54,10 +55,18 @@ if __name__ == '__main__': (options, args) = config.parse_options(oparser) try: - conf, app = config.load_paste_app('quantumversionapp', options, args) - server = wsgi.Server() - server.start(app, int(conf['bind_port']), conf['bind_host']) - server.wait() + print "HERE-1" + service = service.serve_wsgi(service.QuantumApiService, + options=options, + args=args) + #version_conf, version_app = config.load_paste_app('quantumversion', options, args) + print "HERE-2" + service.wait() + #api_conf, api_app = config.load_paste_app('quantum', options, args) + #server = wsgi.Server() + #server.start(version_app, int(version_conf['bind_port']), version_conf['bind_host']) + #server.start(api_app, int(api_conf['bind_port']), api_conf['bind_host']) + #server.wait() except RuntimeError, e: sys.exit("ERROR: %s" % e) diff --git a/etc/quantum.conf b/etc/quantum.conf index 91904603d..ba96a9a27 100644 --- a/etc/quantum.conf +++ b/etc/quantum.conf @@ -11,9 +11,15 @@ bind_host = 0.0.0.0 # Port the bind the API server to bind_port = 9696 -#[app:quantum] -#paste.app_factory = quantum.service:app_factory +[composite:quantum] +use = egg:Paste#urlmap +/: quantumversions +/v0.1: quantumapi -[app:quantumversionapp] +[app:quantumversions] paste.app_factory = quantum.api.versions:Versions.factory +[app:quantumapi] +paste.app_factory = quantum.api:APIRouterV01.factory + + diff --git a/quantum/api/__init__.py b/quantum/api/__init__.py index 9602374ca..9e7d55497 100644 --- a/quantum/api/__init__.py +++ b/quantum/api/__init__.py @@ -13,4 +13,69 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. -# @author: Somik Behera, Nicira Networks, Inc. \ No newline at end of file +# @author: Salvatore Orlando, Citrix Systems + +""" +Quantum API controllers. +""" + +import logging +import routes +import webob.dec +import webob.exc + +from quantum.api import faults +from quantum.api import networks +from quantum.common import flags +from quantum.common import wsgi + + +LOG = logging.getLogger('quantum.api') +FLAGS = flags.FLAGS + +class FaultWrapper(wsgi.Middleware): + """Calls down the middleware stack, making exceptions into faults.""" + + @webob.dec.wsgify(RequestClass=wsgi.Request) + def __call__(self, req): + try: + return req.get_response(self.application) + except Exception as ex: + LOG.exception(_("Caught error: %s"), unicode(ex)) + exc = webob.exc.HTTPInternalServerError(explanation=unicode(ex)) + return faults.Fault(exc) + + +class APIRouterV01(wsgi.Router): + """ + Routes requests on the Quantum API to the appropriate controller + """ + + def __init__(self, ext_mgr=None): + mapper = routes.Mapper() + self._setup_routes(mapper) + super(APIRouterV01, self).__init__(mapper) + + def _setup_routes(self, mapper): + #server_members = self.server_members + #server_members['action'] = 'POST' + + #server_members['pause'] = 'POST' + #server_members['unpause'] = 'POST' + #server_members['diagnostics'] = 'GET' + #server_members['actions'] = 'GET' + #server_members['suspend'] = 'POST' + #server_members['resume'] = 'POST' + #server_members['rescue'] = 'POST' + #server_members['unrescue'] = 'POST' + #server_members['reset_network'] = 'POST' + #server_members['inject_network_info'] = 'POST' + + mapper.resource("network", "networks", controller=networks.Controller(), + collection={'detail': 'GET'}) + print mapper + #mapper.resource("port", "ports", controller=ports.Controller(), + # collection=dict(public='GET', private='GET'), + # parent_resource=dict(member_name='network', + # collection_name='networks')) + diff --git a/quantum/api/api_common.py b/quantum/api/api_common.py new file mode 100644 index 000000000..b33987b4d --- /dev/null +++ b/quantum/api/api_common.py @@ -0,0 +1,21 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 Citrix System. +# 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. + + +XML_NS_V01 = 'http://netstack.org/quantum/api/v0.1' +XML_NS_V10 = 'http://netstack.org/quantum/api/v1.0' + diff --git a/quantum/api/faults.py b/quantum/api/faults.py new file mode 100644 index 000000000..d61ae79fa --- /dev/null +++ b/quantum/api/faults.py @@ -0,0 +1,62 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 Citrix Systems. +# 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 quantum.api import api_common as common +from quantum.common import wsgi + +class Fault(webob.exc.HTTPException): + """Error codes for API faults""" + + _fault_names = { + 400: "malformedRequest", + 401: "unauthorized", + 402: "networkNotFound", + 403: "requestedStateInvalid", + 460: "networkInUse", + 461: "alreadyAttached", + 462: "portInUse", + 470: "serviceUnavailable", + 471: "pluginFault" + } + + def __init__(self, exception): + """Create a Fault for the given webob.exc.exception.""" + self.wrapped_exc = exception + + @webob.dec.wsgify(RequestClass=wsgi.Request) + def __call__(self, req): + """Generate a WSGI response based on the exception passed to ctor.""" + # Replace the body with fault details. + code = self.wrapped_exc.status_int + fault_name = self._fault_names.get(code, "quantumServiceFault") + fault_data = { + fault_name: { + 'code': code, + 'message': self.wrapped_exc.explanation}} + #TODO (salvatore-orlando): place over-limit stuff here + # 'code' is an attribute on the fault tag itself + metadata = {'application/xml': {'attributes': {fault_name: 'code'}}} + default_xmlns = common.XML_NS_V10 + serializer = wsgi.Serializer(metadata, default_xmlns) + content_type = req.best_match_content_type() + self.wrapped_exc.body = serializer.serialize(fault_data, content_type) + self.wrapped_exc.content_type = content_type + return self.wrapped_exc diff --git a/quantum/api/networks.py b/quantum/api/networks.py new file mode 100644 index 000000000..f98aa43e5 --- /dev/null +++ b/quantum/api/networks.py @@ -0,0 +1,200 @@ +# Copyright 2011 Citrix Systems. +# 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 base64 +import logging +import traceback + +from webob import exc +from xml.dom import minidom + +from quantum import manager +from quantum import quantum_plugin_base +from quantum.common import exceptions as exception +from quantum.common import flags +from quantum.common import wsgi +from quantum import utils +from quantum.api import api_common as common +from quantum.api import faults +import quantum.api + +LOG = logging.getLogger('quantum.api.networks') +FLAGS = flags.FLAGS + + +class Controller(wsgi.Controller): + """ Network API controller for Quantum API """ + + #TODO (salvatore-orlando): adjust metadata for quantum + _serialization_metadata = { + "application/xml": { + "attributes": { + "server": ["id", "imageId", "name", "flavorId", "hostId", + "status", "progress", "adminPass", "flavorRef", + "imageRef"], + "link": ["rel", "type", "href"], + }, + "dict_collections": { + "metadata": {"item_name": "meta", "item_key": "key"}, + }, + "list_collections": { + "public": {"item_name": "ip", "item_key": "addr"}, + "private": {"item_name": "ip", "item_key": "addr"}, + }, + }, + } + + def index(self, request): + """ Returns a list of network names and ids """ + #TODO: this should be for a given tenant!!! + print "PIPPO" + LOG.debug("HERE - index") + return self._items(request, is_detail=False) + + def _items(self, req, is_detail): + """ Returns a list of networks. """ + #TODO: we should return networks for a given tenant only + #TODO: network controller should be retrieved here!!! + test = { 'ciao':'bello','porco':'mondo' } + #builder = self._get_view_builder(req) + #servers = [builder.build(inst, is_detail)['server'] + # for inst in limited_list] + #return dict(servers=servers) + return test + + def show(self, req, id): + """ Returns network details by network id """ + try: + return "TEST NETWORK DETAILS" + except exception.NotFound: + return faults.Fault(exc.HTTPNotFound()) + + def delete(self, req, id): + """ Destroys the network with the given id """ + try: + return "TEST NETWORK DELETE" + except exception.NotFound: + return faults.Fault(exc.HTTPNotFound()) + return exc.HTTPAccepted() + + def create(self, req): + """ Creates a new network for a given tenant """ + #env = self._deserialize_create(req) + #if not env: + # return faults.Fault(exc.HTTPUnprocessableEntity()) + return "TEST NETWORK CREATE" + + def _deserialize_create(self, request): + """ + Deserialize a create request + Overrides normal behavior in the case of xml content + """ + #if request.content_type == "application/xml": + # deserializer = ServerCreateRequestXMLDeserializer() + # return deserializer.deserialize(request.body) + #else: + # return self._deserialize(request.body, request.get_content_type()) + pass + + def update(self, req, id): + """ Updates the name for the network wit the given id """ + if len(req.body) == 0: + raise exc.HTTPUnprocessableEntity() + + inst_dict = self._deserialize(req.body, req.get_content_type()) + if not inst_dict: + return faults.Fault(exc.HTTPUnprocessableEntity()) + + try: + return "TEST NETWORK UPDATE" + except exception.NotFound: + return faults.Fault(exc.HTTPNotFound()) + return exc.HTTPNoContent() + + +class NetworkCreateRequestXMLDeserializer(object): + """ + Deserializer to handle xml-formatted server create requests. + + Handles standard server attributes as well as optional metadata + and personality attributes + """ + + def deserialize(self, string): + """Deserialize an xml-formatted server create request""" + dom = minidom.parseString(string) + server = self._extract_server(dom) + return {'server': server} + + def _extract_server(self, node): + """Marshal the server attribute of a parsed request""" + server = {} + server_node = self._find_first_child_named(node, 'server') + for attr in ["name", "imageId", "flavorId"]: + server[attr] = server_node.getAttribute(attr) + metadata = self._extract_metadata(server_node) + if metadata is not None: + server["metadata"] = metadata + personality = self._extract_personality(server_node) + if personality is not None: + server["personality"] = personality + return server + + def _extract_metadata(self, server_node): + """Marshal the metadata attribute of a parsed request""" + metadata_node = self._find_first_child_named(server_node, "metadata") + if metadata_node is None: + return None + metadata = {} + for meta_node in self._find_children_named(metadata_node, "meta"): + key = meta_node.getAttribute("key") + metadata[key] = self._extract_text(meta_node) + return metadata + + def _extract_personality(self, server_node): + """Marshal the personality attribute of a parsed request""" + personality_node = \ + self._find_first_child_named(server_node, "personality") + if personality_node is None: + return None + personality = [] + for file_node in self._find_children_named(personality_node, "file"): + item = {} + if file_node.hasAttribute("path"): + item["path"] = file_node.getAttribute("path") + item["contents"] = self._extract_text(file_node) + personality.append(item) + return personality + + def _find_first_child_named(self, parent, name): + """Search a nodes children for the first child with a given name""" + for node in parent.childNodes: + if node.nodeName == name: + return node + return None + + def _find_children_named(self, parent, name): + """Return all of a nodes children who have the given name""" + for node in parent.childNodes: + if node.nodeName == name: + yield node + + def _extract_text(self, node): + """Get the text field contained by the given node""" + if len(node.childNodes) == 1: + child = node.childNodes[0] + if child.nodeType == child.TEXT_NODE: + return child.nodeValue + return "" diff --git a/quantum/common/config.py b/quantum/common/config.py index 2d858ed35..cda765078 100644 --- a/quantum/common/config.py +++ b/quantum/common/config.py @@ -244,10 +244,12 @@ def load_paste_config(app_name, options, args): problem loading the configuration file. """ conf_file = find_config_file(options, args) + print "Conf_file:%s" %conf_file if not conf_file: raise RuntimeError("Unable to locate any configuration file. " "Cannot load application %s" % app_name) try: + print "App_name:%s" %app_name conf = deploy.appconfig("config:%s" % conf_file, name=app_name) return conf_file, conf except Exception, e: @@ -255,7 +257,7 @@ def load_paste_config(app_name, options, args): % (conf_file, e)) -def load_paste_app(app_name, options, args): +def load_paste_app(conf_file, app_name): """ Builds and returns a WSGI app from a paste config file. @@ -276,40 +278,16 @@ def load_paste_app(app_name, options, args): :raises RuntimeError when config file cannot be located or application cannot be loaded from config file """ - conf_file, conf = load_paste_config(app_name, options, args) + #conf_file, conf = load_paste_config(app_name, options, args) try: - # Setup logging early, supplying both the CLI options and the - # configuration mapping from the config file - print "OPTIONS:%s" %options - print "CONF:%s" %conf - setup_logging(options, conf) - - # We only update the conf dict for the verbose and debug - # flags. Everything else must be set up in the conf file... - debug = options.get('debug') or \ - get_option(conf, 'debug', type='bool', default=False) - verbose = options.get('verbose') or \ - get_option(conf, 'verbose', type='bool', default=False) - conf['debug'] = debug - conf['verbose'] = verbose - - # Log the options used when starting if we're in debug mode... - LOG.debug("*" * 80) - LOG.debug("Configuration options gathered from config file:") - LOG.debug(conf_file) - LOG.debug("================================================") - items = dict([(k, v) for k, v in conf.items() - if k not in ('__file__', 'here')]) - for key, value in sorted(items.items()): - LOG.debug("%(key)-30s %(value)s" % locals()) - LOG.debug("*" * 80) + conf_file = os.path.abspath(conf_file) app = deploy.loadapp("config:%s" % conf_file, name=app_name) except (LookupError, ImportError), e: raise RuntimeError("Unable to load %(app_name)s from " "configuration file %(conf_file)s." "\nGot: %(e)r" % locals()) - return conf, app + return app def get_option(options, option, **kwargs): diff --git a/quantum/common/utils.py b/quantum/common/utils.py index 435ec7b87..c56a53ac1 100644 --- a/quantum/common/utils.py +++ b/quantum/common/utils.py @@ -29,7 +29,7 @@ import socket import sys import ConfigParser -from common import exceptions +from quantum.common import exceptions from exceptions import ProcessExecutionError diff --git a/quantum/common/wsgi.py b/quantum/common/wsgi.py index 73b826ef9..9a2b5bb58 100644 --- a/quantum/common/wsgi.py +++ b/quantum/common/wsgi.py @@ -1,3 +1,4 @@ + # vim: tabstop=4 shiftwidth=4 softtabstop=4 # # Copyright 2011, Nicira Networks, Inc. @@ -253,6 +254,13 @@ class Router(object): WSGI middleware that maps incoming requests to WSGI apps. """ + @classmethod + def factory(cls, global_config, **local_config): + """ + Returns an instance of the WSGI Router class + """ + return cls() + def __init__(self, mapper): """ Create a router for the given routes.Mapper. @@ -337,7 +345,7 @@ class Controller(object): MIME types to information needed to serialize to that type. """ _metadata = getattr(type(self), "_serialization_metadata", {}) - serializer = Serializer(request.environ, _metadata) + serializer = Serializer(_metadata) return serializer.to_content_type(data) diff --git a/quantum/service.py b/quantum/service.py index 50a8effa3..760263bf9 100644 --- a/quantum/service.py +++ b/quantum/service.py @@ -15,30 +15,104 @@ # License for the specific language governing permissions and limitations # under the License. +import logging import json import routes -from common import wsgi +from quantum.common import config +from quantum.common import wsgi +from quantum.common import exceptions as exception from webob import Response +LOG = logging.getLogger('quantum.service') -class NetworkController(wsgi.Controller): +class WsgiService(object): + """Base class for WSGI based services. - def version(self, request): - return "Quantum version 0.1" + For each api you define, you must also define these flags: + :_listen: The address on which to listen + :_listen_port: The port on which to listen + + """ + + def __init__(self, app_name, conf_file, conf): + self.app_name = app_name + self.conf_file = conf_file + self.conf = conf + self.wsgi_app = None + + def start(self): + self.wsgi_app = _run_wsgi(self.app_name, self.conf, self.conf_file) + + def wait(self): + self.wsgi_app.wait() -class API(wsgi.Router): - def __init__(self, options): - self.options = options - mapper = routes.Mapper() - network_controller = NetworkController() - mapper.resource("net_controller", "/network", - controller=network_controller) - mapper.connect("/", controller=network_controller, action="version") - super(API, self).__init__(mapper) +class QuantumApiService(WsgiService): + """Class for quantum-api service.""" + + @classmethod + def create(cls, conf=None, options=None, args=None): + app_name = "quantum" + if not conf: + conf_file, conf = config.load_paste_config( + app_name, options, args) + if not conf: + message = (_('No paste configuration found for: %s'), + app_name) + raise exception.Error(message) + print "OPTIONS:%s" %options + print "CONF:%s" %conf + + # Setup logging early, supplying both the CLI options and the + # configuration mapping from the config file + # We only update the conf dict for the verbose and debug + # flags. Everything else must be set up in the conf file... + # Log the options used when starting if we're in debug mode... + + config.setup_logging(options, conf) + debug = options.get('debug') or \ + config.get_option(conf, 'debug', + type='bool', default=False) + verbose = options.get('verbose') or \ + config.get_option(conf, 'verbose', + type='bool', default=False) + conf['debug'] = debug + conf['verbose'] = verbose + LOG.debug("*" * 80) + LOG.debug("Configuration options gathered from config file:") + LOG.debug(conf_file) + LOG.debug("================================================") + items = dict([(k, v) for k, v in conf.items() + if k not in ('__file__', 'here')]) + for key, value in sorted(items.items()): + LOG.debug("%(key)-30s %(value)s" % locals()) + LOG.debug("*" * 80) + service = cls(app_name, conf_file, conf) + return service -def app_factory(global_conf, **local_conf): - conf = global_conf.copy() - conf.update(local_conf) - return API(conf) +def serve_wsgi(cls, conf=None, options = None, args = None): + try: + service = cls.create(conf, options, args) + except Exception: + logging.exception('in WsgiService.create()') + raise + + service.start() + + return service + + +def _run_wsgi(app_name, paste_conf, paste_config_file): + print "CICCIO" + LOG.info(_('Using paste.deploy config at: %s'), paste_config_file) + app = config.load_paste_app(paste_config_file, app_name) + if not app: + LOG.error(_('No known API applications configured in %s.'), + paste_config_file) + return + server = wsgi.Server() + server.start(app, + int(paste_conf['bind_port']),paste_conf['bind_host']) + return server +