]> review.fuel-infra Code Review - openstack-build/neutron-build.git/commitdiff
Add API v2 support
authorJason Kölker <jason@koelker.net>
Wed, 9 May 2012 17:04:11 +0000 (12:04 -0500)
committerJason Kölker <jason@koelker.net>
Mon, 11 Jun 2012 15:36:10 +0000 (10:36 -0500)
* Implements BP v2-api-melange-integration
* Adds v2 Plugin specification
* Refactors SQLAlchemy usage for multiple BASE's

Change-Id: I45f008f181c18269afdfe4a9b589a7c5ae56d225

22 files changed:
etc/quantum.conf
quantum/api/v2/__init__.py [new file with mode: 0644]
quantum/api/v2/base.py [new file with mode: 0644]
quantum/api/v2/resource.py [new file with mode: 0644]
quantum/api/v2/router.py [new file with mode: 0644]
quantum/api/v2/views.py [new file with mode: 0644]
quantum/api/versions.py
quantum/common/exceptions.py
quantum/common/utils.py
quantum/db/api.py
quantum/db/db_base_plugin_v2.py [new file with mode: 0644]
quantum/db/model_base.py [new file with mode: 0644]
quantum/db/models.py
quantum/db/models_v2.py [new file with mode: 0644]
quantum/manager.py
quantum/plugins/sample/SamplePluginV2.py [new file with mode: 0644]
quantum/quantum_plugin_base_v2.py [new file with mode: 0644]
quantum/tests/unit/test_api_v2.py [new file with mode: 0644]
quantum/tests/unit/test_db_plugin.py [new file with mode: 0644]
quantum/wsgi.py
tools/pip-requires
tools/test-requires

index 5162060d297d9fa85b9f9bba05dbda25b7368f71..b19442865fca145ba6f2768d93b46b8901c46831 100644 (file)
@@ -23,6 +23,7 @@ use = egg:Paste#urlmap
 /: quantumversions
 /v1.0: quantumapi_v1_0
 /v1.1: quantumapi_v1_1
+/v2.0: quantumapi_v2_0
 
 [pipeline:quantumapi_v1_0]
 # By default, authentication is disabled.
@@ -38,6 +39,13 @@ pipeline = extensions quantumapiapp_v1_0
 pipeline = extensions quantumapiapp_v1_1
 # pipeline = authtoken keystonecontext extensions quantumapiapp_v1_1
 
+[pipeline:quantumapi_v2_0]
+# By default, authentication is disabled.
+# To enable Keystone integration comment out the
+# following line and uncomment the next one
+pipeline = extensions quantumapiapp_v2_0
+# pipeline = authtoken keystonecontext extensions quantumapiapp_v2_0
+
 [filter:keystonecontext]
 paste.filter_factory = quantum.auth:QuantumKeystoneContext.factory
 
@@ -61,3 +69,6 @@ paste.app_factory = quantum.api:APIRouterV10.factory
 
 [app:quantumapiapp_v1_1]
 paste.app_factory = quantum.api:APIRouterV11.factory
+
+[app:quantumapiapp_v2_0]
+paste.app_factory = quantum.api.v2.router:APIRouter.factory
diff --git a/quantum/api/v2/__init__.py b/quantum/api/v2/__init__.py
new file mode 100644 (file)
index 0000000..cf68947
--- /dev/null
@@ -0,0 +1,14 @@
+# Copyright (c) 2012 OpenStack, LLC.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+# implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/quantum/api/v2/base.py b/quantum/api/v2/base.py
new file mode 100644 (file)
index 0000000..eff436b
--- /dev/null
@@ -0,0 +1,208 @@
+# Copyright (c) 2012 OpenStack, LLC.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+# implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging
+
+import webob.exc
+
+from quantum.common import exceptions
+from quantum.api.v2 import resource as wsgi_resource
+from quantum.common import utils
+from quantum.api.v2 import views
+
+LOG = logging.getLogger(__name__)
+XML_NS_V20 = 'http://openstack.org/quantum/api/v2.0'
+
+FAULT_MAP = {exceptions.NotFound: webob.exc.HTTPNotFound,
+             exceptions.InUse: webob.exc.HTTPConflict,
+             exceptions.StateInvalid: webob.exc.HTTPBadRequest}
+
+
+def fields(request):
+    """
+    Extracts the list of fields to return
+    """
+    return [v for v in request.GET.getall('fields') if v]
+
+
+def filters(request):
+    """
+    Extracts the filters from the request string
+
+    Returns a dict of lists for the filters:
+
+    check=a&check=b&name=Bob&verbose=True&verbose=other
+
+    becomes
+
+    {'check': [u'a', u'b'], 'name': [u'Bob']}
+    """
+    res = {}
+    for key in set(request.GET):
+        if key in ('verbose', 'fields'):
+            continue
+
+        values = [v for v in request.GET.getall(key) if v]
+        if values:
+            res[key] = values
+    return res
+
+
+def verbose(request):
+    """
+    Determines the verbose fields for a request
+
+    Returns a list of items that are requested to be verbose:
+
+    check=a&check=b&name=Bob&verbose=True&verbose=other
+
+    returns
+
+    [True]
+
+    and
+
+    check=a&check=b&name=Bob&verbose=other
+
+    returns
+
+    ['other']
+
+    """
+    verbose = [utils.boolize(v) for v in request.GET.getall('verbose') if v]
+
+    # NOTE(jkoelker) verbose=<bool> trumps all other verbose settings
+    if True in verbose:
+        return True
+    elif False in verbose:
+        return False
+
+    return verbose
+
+
+class Controller(object):
+    def __init__(self, plugin, collection, resource, params):
+        self._plugin = plugin
+        self._collection = collection
+        self._resource = resource
+        self._params = params
+        self._view = getattr(views, self._resource)
+
+    def _items(self, request):
+        """Retrieves and formats a list of elements of the requested entity"""
+        kwargs = {'filters': filters(request),
+                  'verbose': verbose(request),
+                  'fields': fields(request)}
+
+        obj_getter = getattr(self._plugin, "get_%s" % self._collection)
+        obj_list = obj_getter(request.context, **kwargs)
+        return {self._collection: [self._view(obj) for obj in obj_list]}
+
+    def _item(self, request, id):
+        """Retrieves and formats a single element of the requested entity"""
+        kwargs = {'verbose': verbose(request),
+                  'fields': fields(request)}
+        obj_getter = getattr(self._plugin,
+                             "get_%s" % self._resource)
+        obj = obj_getter(request.context, id, **kwargs)
+        return {self._resource: self._view(obj)}
+
+    def index(self, request):
+        """Returns a list of the requested entity"""
+        return self._items(request)
+
+    def show(self, request, id):
+        """Returns detailed information about the requested entity"""
+        return self._item(request, id)
+
+    def create(self, request, body=None):
+        """Creates a new instance of the requested entity"""
+        body = self._prepare_request_body(body, allow_bulk=True)
+        obj_creator = getattr(self._plugin,
+                              "create_%s" % self._resource)
+        kwargs = {self._resource: body}
+        obj = obj_creator(request.context, **kwargs)
+        return {self._resource: self._view(obj)}
+
+    def delete(self, request, id):
+        """Deletes the specified entity"""
+        obj_deleter = getattr(self._plugin,
+                              "delete_%s" % self._resource)
+        obj_deleter(request.context, id)
+
+    def update(self, request, id, body=None):
+        """Updates the specified entity's attributes"""
+        obj_updater = getattr(self._plugin,
+                              "update_%s" % self._resource)
+        kwargs = {self._resource: body}
+        obj = obj_updater(request.context, id, **kwargs)
+        return {self._resource: self._view(obj)}
+
+    def _prepare_request_body(self, body, allow_bulk=False):
+        """ verifies required parameters are in request body.
+            Parameters with default values are considered to be
+            optional.
+
+            body argument must be the deserialized body
+        """
+        if not body:
+            raise webob.exc.HTTPBadRequest(_("Resource body required"))
+
+        body = body or {self._resource: {}}
+
+        if self._collection in body and allow_bulk:
+            bulk_body = [self._prepare_request_body({self._resource: b})
+                         if self._resource not in b
+                         else self._prepare_request_body(b)
+                         for b in body[self._collection]]
+
+            if not bulk_body:
+                raise webob.exc.HTTPBadRequest(_("Resources required"))
+
+            return {self._collection: bulk_body}
+
+        elif self._collection in body and not allow_bulk:
+            raise webob.exc.HTTPBadRequest("Bulk operation not supported")
+
+        res_dict = body.get(self._resource)
+        if res_dict is None:
+            msg = _("Unable to find '%s' in request body") % self._resource
+            raise webob.exc.HTTPBadRequest(msg)
+
+        for param in self._params:
+            param_value = res_dict.get(param['attr'], param.get('default'))
+            if param_value is None:
+                msg = _("Failed to parse request. Parameter %s not "
+                        "specified") % param
+                raise webob.exc.HTTPUnprocessableEntity(msg)
+            res_dict[param['attr']] = param_value
+        return body
+
+
+def create_resource(collection, resource, plugin, conf, params):
+    controller = Controller(plugin, collection, resource, params)
+
+    # NOTE(jkoelker) To anyone wishing to add "proper" xml support
+    #                this is where you do it
+    serializers = {
+    #    'application/xml': wsgi.XMLDictSerializer(metadata, XML_NS_V20),
+    }
+
+    deserializers = {
+    #    'application/xml': wsgi.XMLDeserializer(metadata),
+    }
+
+    return wsgi_resource.Resource(controller, FAULT_MAP, deserializers,
+                                  serializers)
diff --git a/quantum/api/v2/resource.py b/quantum/api/v2/resource.py
new file mode 100644 (file)
index 0000000..6e2f9ec
--- /dev/null
@@ -0,0 +1,126 @@
+# Copyright 2012 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.
+
+"""
+Utility methods for working with WSGI servers redux
+"""
+import logging
+
+import webob
+import webob.exc
+import webob.dec
+
+from quantum import context
+from quantum.common import exceptions
+from quantum.openstack.common import jsonutils as json
+from quantum import wsgi
+
+
+LOG = logging.getLogger(__name__)
+
+
+class Request(webob.Request):
+    """Add some Openstack API-specific logic to the base webob.Request."""
+
+    def best_match_content_type(self):
+        supported = ('application/json', )
+        return self.accept.best_match(supported,
+                                      default_match='application/json')
+
+    @property
+    def context(self):
+        #Eventually the Auth[NZ] code will supply this. (mdragon)
+        #when that happens this if block should raise instead.
+        if 'quantum.context' not in self.environ:
+            self.environ['quantum.context'] = context.get_admin_context()
+        return self.environ['quantum.context']
+
+
+def Resource(controller, faults=None, deserializers=None, serializers=None):
+    """Represents an API entity resource and the associated serialization and
+    deserialization logic
+    """
+    default_deserializers = {'application/xml': wsgi.XMLDeserializer(),
+                             'application/json': lambda x: json.loads(x)}
+    default_serializers = {'application/xml': wsgi.XMLDictSerializer(),
+                           'application/json': lambda x: json.dumps(x)}
+    format_types = {'xml': 'application/xml',
+                    'json': 'application/json'}
+    action_status = dict(create=201, delete=204)
+
+    default_deserializers.update(deserializers or {})
+    default_serializers.update(serializers or {})
+
+    deserializers = default_deserializers
+    serializers = default_serializers
+    faults = faults or {}
+
+    @webob.dec.wsgify(RequestClass=Request)
+    def resource(request):
+        route_args = request.environ.get('wsgiorg.routing_args')
+        if route_args:
+            args = route_args[1].copy()
+        else:
+            args = {}
+
+        # NOTE(jkoelker) by now the controller is already found, remove
+        #                it from the args if it is in the matchdict
+        args.pop('controller', None)
+        fmt = args.pop('format', None)
+        action = args.pop('action', None)
+
+        content_type = format_types.get(fmt,
+                                        request.best_match_content_type())
+        deserializer = deserializers.get(content_type)
+        serializer = serializers.get(content_type)
+
+        try:
+            if request.body:
+                args['body'] = deserializer(request.body)
+
+            method = getattr(controller, action)
+
+            result = method(request=request, **args)
+        except exceptions.QuantumException as e:
+            LOG.exception('%s failed' % action)
+            body = serializer({'QuantumError': str(e)})
+            kwargs = {'body': body, 'content_type': content_type}
+            for fault in faults:
+                if isinstance(e, fault):
+                    raise faults[fault](**kwargs)
+            raise webob.exc.HTTPInternalServerError(**kwargs)
+        except webob.exc.HTTPException as e:
+            LOG.exception('%s failed' % action)
+            e.body = serializer({'QuantumError': str(e)})
+            e.content_type = content_type
+            raise
+        except Exception as e:
+            # NOTE(jkoelker) Everyting else is 500
+            LOG.exception('%s failed' % action)
+            body = serializer({'QuantumError': str(e)})
+            kwargs = {'body': body, 'content_type': content_type}
+            raise webob.exc.HTTPInternalServerError(**kwargs)
+
+        status = action_status.get(action, 200)
+        body = serializer(result)
+        # NOTE(jkoelker) Comply with RFC2616 section 9.7
+        if status == 204:
+            content_type = ''
+            body = None
+
+        return webob.Response(request=request, status=status,
+                              content_type=content_type,
+                              body=body)
+    return resource
diff --git a/quantum/api/v2/router.py b/quantum/api/v2/router.py
new file mode 100644 (file)
index 0000000..6fdc9da
--- /dev/null
@@ -0,0 +1,119 @@
+# Copyright (c) 2012 OpenStack, LLC.
+#
+# Licensed under the Apache License, Version 2.0 (the 'License');
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an 'AS IS' BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+# implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging
+import urlparse
+
+import routes as routes_mapper
+import webob
+import webob.dec
+import webob.exc
+
+from quantum import manager
+from quantum import wsgi
+from quantum.api.v2 import base
+
+
+LOG = logging.getLogger(__name__)
+HEX_ELEM = '[0-9A-Fa-f]'
+UUID_PATTERN = '-'.join([HEX_ELEM + '{8}', HEX_ELEM + '{4}',
+                         HEX_ELEM + '{4}', HEX_ELEM + '{4}',
+                         HEX_ELEM + '{12}'])
+COLLECTION_ACTIONS = ['index', 'create']
+MEMBER_ACTIONS = ['show', 'update', 'delete']
+REQUIREMENTS = {'id': UUID_PATTERN, 'format': 'xml|json'}
+
+
+RESOURCE_PARAM_MAP = {
+    'networks': [
+        {'attr': 'name'},
+    ],
+    'ports': [
+        {'attr': 'state', 'default': 'DOWN'},
+    ],
+    'subnets': [
+        {'attr': 'prefix'},
+        {'attr': 'network_id'},
+    ]
+}
+
+
+class Index(wsgi.Application):
+    def __init__(self, resources):
+        self.resources = resources
+
+    @webob.dec.wsgify(RequestClass=wsgi.Request)
+    def __call__(self, req):
+        metadata = {'application/xml': {
+                        'attributes': {
+                            'resource': ['name', 'collection'],
+                            'link': ['href', 'rel'],
+                           }
+                       }
+                   }
+
+        layout = []
+        for name, collection in self.resources.iteritems():
+            href = urlparse.urljoin(req.path_url, collection)
+            resource = {'name': name,
+                        'collection': collection,
+                        'links': [{'rel': 'self',
+                                   'href': href}]}
+            layout.append(resource)
+        response = dict(resources=layout)
+        content_type = req.best_match_content_type()
+        body = wsgi.Serializer(metadata=metadata).serialize(response,
+                                                            content_type)
+        return webob.Response(body=body, content_type=content_type)
+
+
+class APIRouter(wsgi.Router):
+
+    @classmethod
+    def factory(cls, global_config, **local_config):
+        return cls(global_config, **local_config)
+
+    def __init__(self, conf, **local_config):
+        mapper = routes_mapper.Mapper()
+        plugin_provider = manager.get_plugin_provider(conf)
+        plugin = manager.get_plugin(plugin_provider)
+
+        # NOTE(jkoelker) Merge local_conf into conf after the plugin
+        #                is discovered
+        conf.update(local_config)
+        col_kwargs = dict(collection_actions=COLLECTION_ACTIONS,
+                          member_actions=MEMBER_ACTIONS)
+
+        resources = {'network': 'networks',
+                     'subnet': 'subnets',
+                     'port': 'ports'}
+
+        def _map_resource(collection, resource, params):
+            controller = base.create_resource(collection, resource,
+                                              plugin, conf,
+                                              params)
+            mapper_kwargs = dict(controller=controller,
+                                 requirements=REQUIREMENTS,
+                                 **col_kwargs)
+            return mapper.collection(collection, resource,
+                                     **mapper_kwargs)
+
+        mapper.connect('index', '/', controller=Index(resources))
+        for resource in resources:
+            _map_resource(resources[resource], resource,
+                          RESOURCE_PARAM_MAP.get(resources[resource],
+                                                 dict()))
+
+        super(APIRouter, self).__init__(mapper)
diff --git a/quantum/api/v2/views.py b/quantum/api/v2/views.py
new file mode 100644 (file)
index 0000000..932607e
--- /dev/null
@@ -0,0 +1,40 @@
+# Copyright (c) 2012 OpenStack, LLC.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+# implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+def resource(data, keys):
+    """Formats the specified entity"""
+    return dict(item for item in data.iteritems() if item[0] in keys)
+
+
+def port(port_data):
+    """Represents a view for a port object"""
+    keys = ('id', 'network_id', 'mac_address', 'fixed_ips',
+            'device_id', 'admin_state_up', 'tenant_id', 'op_status')
+    return resource(port_data, keys)
+
+
+def network(network_data):
+    """Represents a view for a network object"""
+    keys = ('id', 'name', 'subnets', 'admin_state_up', 'op_status',
+            'tenant_id', 'mac_ranges')
+    return resource(network_data, keys)
+
+
+def subnet(subnet_data):
+    """Represents a view for a subnet object"""
+    keys = ('id', 'network_id', 'tenant_id', 'gateway_ip', 'ip_version',
+            'prefix')
+    return resource(subnet_data, keys)
index 8486ef379533bb88386945e73e0359db54df5dfc..73a0c569d3fec83e3f51fb0a555854108b037dfd 100644 (file)
@@ -37,12 +37,16 @@ class Versions(object):
         version_objs = [
             {
                 "id": "v1.0",
-                "status": "CURRENT",
+                "status": "DEPRECATED",
             },
             {
                 "id": "v1.1",
                 "status": "CURRENT",
             },
+            {
+                "id": "v2.0",
+                "status": "PROPOSED",
+            },
         ]
 
         if req.path != '/':
index 5a0dc9909766a280b945732d8cac7861f16702b7..b3fef044cce863ed03c61d91e56ca9689484c3a2 100644 (file)
@@ -47,6 +47,14 @@ class NotFound(QuantumException):
     pass
 
 
+class NotAuthorized(QuantumException):
+    message = _("Not authorized.")
+
+
+class AdminRequired(NotAuthorized):
+    message = _("User does not have admin privileges: %(reason)s")
+
+
 class ClassNotFound(NotFound):
     message = _("Class %(class_name)s could not be found")
 
@@ -55,6 +63,10 @@ class NetworkNotFound(NotFound):
     message = _("Network %(net_id)s could not be found")
 
 
+class SubnetNotFound(NotFound):
+    message = _("Subnet %(subnet_id)s could not be found")
+
+
 class PortNotFound(NotFound):
     message = _("Port %(port_id)s could not be found "
                 "on network %(net_id)s")
@@ -64,12 +76,16 @@ class StateInvalid(QuantumException):
     message = _("Unsupported port state: %(port_state)s")
 
 
-class NetworkInUse(QuantumException):
+class InUse(QuantumException):
+    message = _("The resource is inuse")
+
+
+class NetworkInUse(InUse):
     message = _("Unable to complete operation on network %(net_id)s. "
                 "There is one or more attachments plugged into its ports.")
 
 
-class PortInUse(QuantumException):
+class PortInUse(InUse):
     message = _("Unable to complete operation on port %(port_id)s "
                 "for network %(net_id)s. The attachment '%(att_id)s"
                 "is plugged into the logical port.")
@@ -112,3 +128,8 @@ class InvalidContentType(Invalid):
 
 class NotImplementedError(Error):
     pass
+
+
+class FixedIPNotAvailable(QuantumException):
+    message = _("Fixed IP (%(ip)s) unavailable for network "
+                "%(network_uuid)s")
index 3d09ca6da3805da7405082b0c7abbe80360518d7..931a82c41ba69648c18854fd47f9b045f1085670 100644 (file)
@@ -62,14 +62,29 @@ def bool_from_string(subject):
 
     Useful for JSON-decoded stuff and config file parsing
     """
-    if type(subject) == type(bool):
+    if isinstance(subject, bool):
         return subject
-    if hasattr(subject, 'startswith'):  # str or unicode...
+    elif isinstance(subject, basestring):
         if subject.strip().lower() in ('true', 'on', '1'):
             return True
     return False
 
 
+def boolize(subject):
+    """
+    Quak like a boolean
+    """
+    if isinstance(subject, bool):
+        return subject
+    elif isinstance(subject, basestring):
+        sub = subject.strip().lower()
+        if sub == 'true':
+            return True
+        elif sub == 'false':
+            return False
+    return subject
+
+
 def execute(cmd, process_input=None, addl_env=None, check_exit_code=True):
     logging.debug("Running cmd: %s", cmd)
     env = os.environ.copy()
index 85a9c89dd1ac5afca66c18e7e20fa766a1156c8d..a99762031ebed8de70e6745705e27b571448dfa2 100644 (file)
@@ -27,7 +27,7 @@ from sqlalchemy.orm import sessionmaker, exc
 
 from quantum.api.api_common import OperationalStatus
 from quantum.common import exceptions as q_exc
-from quantum.db import models
+from quantum.db import model_base, models
 
 
 LOG = logging.getLogger(__name__)
@@ -35,7 +35,7 @@ LOG = logging.getLogger(__name__)
 
 _ENGINE = None
 _MAKER = None
-BASE = models.BASE
+BASE = model_base.BASE
 
 
 class MySQLPingListener(object):
@@ -79,15 +79,16 @@ def configure_db(options):
             engine_args['listeners'] = [MySQLPingListener()]
 
         _ENGINE = create_engine(options['sql_connection'], **engine_args)
-        if not register_models():
+        base = options.get('base', BASE)
+        if not register_models(base):
             if 'reconnect_interval' in options:
-                retry_registration(options['reconnect_interval'])
+                retry_registration(options['reconnect_interval'], base)
 
 
-def clear_db():
+def clear_db(base=BASE):
     global _ENGINE
     assert _ENGINE
-    for table in reversed(BASE.metadata.sorted_tables):
+    for table in reversed(base.metadata.sorted_tables):
         _ENGINE.execute(table.delete())
 
 
@@ -102,32 +103,32 @@ def get_session(autocommit=True, expire_on_commit=False):
     return _MAKER()
 
 
-def retry_registration(reconnect_interval):
+def retry_registration(reconnect_interval, base=BASE):
     while True:
         LOG.info("Unable to connect to database. Retrying in %s seconds" %
                  reconnect_interval)
         time.sleep(reconnect_interval)
-        if register_models():
+        if register_models(base):
             break
 
 
-def register_models():
+def register_models(base=BASE):
     """Register Models and create properties"""
     global _ENGINE
     assert _ENGINE
     try:
-        BASE.metadata.create_all(_ENGINE)
+        base.metadata.create_all(_ENGINE)
     except sql.exc.OperationalError as e:
         LOG.info("Database registration exception: %s" % e)
         return False
     return True
 
 
-def unregister_models():
+def unregister_models(base=BASE):
     """Unregister Models, useful clearing out data before testing"""
     global _ENGINE
     assert _ENGINE
-    BASE.metadata.drop_all(_ENGINE)
+    base.metadata.drop_all(_ENGINE)
 
 
 def network_create(tenant_id, name, op_status=OperationalStatus.UNKNOWN):
@@ -158,7 +159,7 @@ def network_get(net_id):
         return (session.query(models.Network).
                 filter_by(uuid=net_id).
                 one())
-    except exc.NoResultFound, e:
+    except exc.NoResultFound:
         raise q_exc.NetworkNotFound(net_id=net_id)
 
 
@@ -199,7 +200,7 @@ def validate_network_ownership(tenant_id, net_id):
                 filter_by(uuid=net_id).
                 filter_by(tenant_id=tenant_id).
                 one())
-    except exc.NoResultFound, e:
+    except exc.NoResultFound:
         raise q_exc.NetworkNotFound(net_id=net_id)
 
 
diff --git a/quantum/db/db_base_plugin_v2.py b/quantum/db/db_base_plugin_v2.py
new file mode 100644 (file)
index 0000000..a1dbf59
--- /dev/null
@@ -0,0 +1,295 @@
+# Copyright (c) 2012 OpenStack, LLC.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+# implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging
+
+from sqlalchemy import orm
+from sqlalchemy.orm import exc
+
+from quantum import quantum_plugin_base_v2
+from quantum.common import exceptions as q_exc
+from quantum.db import api as db
+from quantum.db import models_v2
+
+
+LOG = logging.getLogger(__name__)
+
+
+class QuantumDbPluginV2(quantum_plugin_base_v2.QuantumPluginBaseV2):
+    """ A class that implements the v2 Quantum plugin interface
+        using SQLAlchemy models.  Whenever a non-read call happens
+        the plugin will call an event handler class method (e.g.,
+        network_created()).  The result is that this class can be
+        sub-classed by other classes that add custom behaviors on
+        certain events.
+    """
+
+    def __init__(self):
+        # NOTE(jkoelker) This is an incomlete implementation. Subclasses
+        #                must override __init__ and setup the database
+        #                and not call into this class's __init__.
+        #                This connection is setup as memory for the tests.
+        sql_connection = 'sqlite:///:memory:'
+        db.configure_db({'sql_connection': sql_connection,
+                         'base': models_v2.model_base.BASEV2})
+
+    def _get_tenant_id_for_create(self, context, resource):
+        if context.is_admin and 'tenant_id' in resource:
+            tenant_id = resource['tenant_id']
+        elif ('tenant_id' in resource and
+              resource['tenant_id'] != context.tenant_id):
+            reason = _('Cannot create resource for another tenant')
+            raise q_exc.AdminRequired(reason=reason)
+        else:
+            tenant_id = context.tenant_id
+        return tenant_id
+
+    def _model_query(self, context, model):
+        query = context.session.query(model)
+
+        # NOTE(jkoelker) non-admin queries are scoped to their tenant_id
+        if not context.is_admin and hasattr(model.tenant_id):
+            query = query.filter(tenant_id=context.tenant_id)
+
+        return query
+
+    def _get_by_id(self, context, model, id, joins=(), verbose=None):
+        query = self._model_query(context, model)
+        if verbose:
+            if verbose and isinstance(verbose, list):
+                options = [orm.joinedload(join) for join in joins
+                           if join in verbose]
+            else:
+                options = [orm.joinedload(join) for join in joins]
+            query = query.options(*options)
+        return query.filter_by(id=id).one()
+
+    def _get_network(self, context, id, verbose=None):
+        try:
+            network = self._get_by_id(context, models_v2.Network, id,
+                                      joins=('subnets',), verbose=verbose)
+        except exc.NoResultFound:
+            raise q_exc.NetworkNotFound(net_id=id)
+        except exc.MultipleResultsFound:
+            LOG.error('Multiple networks match for %s' % id)
+            raise q_exc.NetworkNotFound(net_id=id)
+        return network
+
+    def _get_subnet(self, context, id, verbose=None):
+        try:
+            subnet = self._get_by_id(context, models_v2.Subnet, id,
+                                     verbose=verbose)
+        except exc.NoResultFound:
+            raise q_exc.SubnetNotFound(subnet_id=id)
+        except exc.MultipleResultsFound:
+            LOG.error('Multiple subnets match for %s' % id)
+            raise q_exc.SubnetNotFound(subnet_id=id)
+        return subnet
+
+    def _get_port(self, context, id, verbose=None):
+        try:
+            port = self._get_by_id(context, models_v2.Port, id,
+                                   verbose=verbose)
+        except exc.NoResultFound:
+            # NOTE(jkoelker) The PortNotFound exceptions requires net_id
+            #                kwarg in order to set the message correctly
+            raise q_exc.PortNotFound(port_id=id, net_id=None)
+        except exc.MultipleResultsFound:
+            LOG.error('Multiple ports match for %s' % id)
+            raise q_exc.PortNotFound(port_id=id)
+        return port
+
+    def _fields(self, resource, fields):
+        if fields:
+            return dict(((key, item) for key, item in resource.iteritems()
+                         if key in fields))
+        return resource
+
+    def _get_collection(self, context, model, dict_func, filters=None,
+                        fields=None, verbose=None):
+        collection = self._model_query(context, model)
+        if filters:
+            for key, value in filters.iteritems():
+                column = getattr(model, key, None)
+                if column:
+                    collection = collection.filter(column.in_(value))
+        return [dict_func(c, fields) for c in collection.all()]
+
+    def _make_network_dict(self, network, fields=None):
+        res = {'id': network['id'],
+               'name': network['name'],
+               'tenant_id': network['tenant_id'],
+               'admin_state_up': network['admin_state_up'],
+               'op_status': network['op_status'],
+               'subnets': [subnet['id']
+                            for subnet in network['subnets']]}
+
+        return self._fields(res, fields)
+
+    def _make_subnet_dict(self, subnet, fields=None):
+        res = {'id': subnet['id'],
+               'network_id': subnet['network_id'],
+               'tenant_id': subnet['tenant_id'],
+               'ip_version': subnet['ip_version'],
+               'prefix': subnet['prefix'],
+               'gateway_ip': subnet['gateway_ip']}
+        return self._fields(res, fields)
+
+    def _make_port_dict(self, port, fields=None):
+        res = {"id": port["id"],
+               "network_id": port["network_id"],
+               'tenant_id': port['tenant_id'],
+               "mac_address": port["mac_address"],
+               "admin_state_up": port["admin_state_up"],
+               "op_status": port["op_status"],
+               "fixed_ips": [ip["address"] for ip in port["fixed_ips"]],
+               "device_id": port["device_id"]}
+        return self._fields(res, fields)
+
+    def create_network(self, context, network):
+        n = network['network']
+
+        # NOTE(jkoelker) Get the tenant_id outside of the session to avoid
+        #                unneeded db action if the operation raises
+        tenant_id = self._get_tenant_id_for_create(context, n)
+        with context.session.begin():
+            network = models_v2.Network(tenant_id=tenant_id,
+                                        name=n['name'],
+                                        admin_state_up=n['admin_state_up'],
+                                        op_status="ACTIVE")
+            context.session.add(network)
+        return self._make_network_dict(network)
+
+    def update_network(self, context, id, network):
+        n = network['network']
+        with context.session.begin():
+            network = self._get_network(context, id)
+            network.update(n)
+        return self._make_network_dict(network)
+
+    def delete_network(self, context, id):
+        with context.session.begin():
+            network = self._get_network(context, id)
+
+            # TODO(anyone) Delegation?
+            ports_qry = context.session.query(models_v2.Port)
+            ports_qry.filter_by(network_id=id).delete()
+
+            subnets_qry = context.session.query(models_v2.Subnet)
+            subnets_qry.filter_by(network_id=id).delete()
+
+            context.session.delete(network)
+
+    def get_network(self, context, id, fields=None, verbose=None):
+        network = self._get_network(context, id, verbose=verbose)
+        return self._make_network_dict(network, fields)
+
+    def get_networks(self, context, filters=None, fields=None, verbose=None):
+        return self._get_collection(context, models_v2.Network,
+                                    self._make_network_dict,
+                                    filters=filters, fields=fields,
+                                    verbose=verbose)
+
+    def create_subnet(self, context, subnet):
+        s = subnet['subnet']
+        # NOTE(jkoelker) Get the tenant_id outside of the session to avoid
+        #                unneeded db action if the operation raises
+        tenant_id = self._get_tenant_id_for_create(context, s)
+        with context.session.begin():
+            subnet = models_v2.Subnet(tenant_id=tenant_id,
+                                      network_id=s['network_id'],
+                                      ip_version=s['ip_version'],
+                                      prefix=s['prefix'],
+                                      gateway_ip=s['gateway_ip'])
+
+            context.session.add(subnet)
+        return self._make_subnet_dict(subnet)
+
+    def update_subnet(self, context, id, subnet):
+        s = subnet['subnet']
+        with context.session.begin():
+            subnet = self._get_subnet(context, id)
+            subnet.update(s)
+        return self._make_subnet_dict(subnet)
+
+    def delete_subnet(self, context, id):
+        with context.session.begin():
+            subnet = self._get_subnet(context, id)
+
+            allocations_qry = context.session.query(models_v2.IPAllocation)
+            allocations_qry.filter_by(subnet_id=id).delete()
+
+            context.session.delete(subnet)
+
+    def get_subnet(self, context, id, fields=None, verbose=None):
+        subnet = self._get_subnet(context, id, verbose=verbose)
+        return self._make_subnet_dict(subnet, fields)
+
+    def get_subnets(self, context, filters=None, fields=None, verbose=None):
+        return self._get_collection(context, models_v2.Subnet,
+                                    self._make_subnet_dict,
+                                    filters=filters, fields=fields,
+                                    verbose=verbose)
+
+    def create_port(self, context, port):
+        p = port['port']
+        # NOTE(jkoelker) Get the tenant_id outside of the session to avoid
+        #                unneeded db action if the operation raises
+        tenant_id = self._get_tenant_id_for_create(context, p)
+
+        #FIXME(danwent): allocate MAC
+        mac_address = p.get('mac_address', 'ca:fe:de:ad:be:ef')
+        with context.session.begin():
+            network = self._get_network(context, p["network_id"])
+
+            port = models_v2.Port(tenant_id=tenant_id,
+                                  network_id=p['network_id'],
+                                  mac_address=mac_address,
+                                  admin_state_up=p['admin_state_up'],
+                                  op_status="ACTIVE",
+                                  device_id=p['device_id'])
+            context.session.add(port)
+
+            # TODO(anyone) ip allocation
+            #for subnet in network["subnets"]:
+            #    pass
+
+        return self._make_port_dict(port)
+
+    def update_port(self, context, id, port):
+        p = port['port']
+        with context.session.begin():
+            port = self._get_port(context, id)
+            port.update(p)
+        return self._make_port_dict(port)
+
+    def delete_port(self, context, id):
+        with context.session.begin():
+            port = self._get_port(context, id)
+
+            allocations_qry = context.session.query(models_v2.IPAllocation)
+            allocations_qry.filter_by(port_id=id).delete()
+
+            context.session.delete(port)
+
+    def get_port(self, context, id, fields=None, verbose=None):
+        port = self._get_port(context, id, verbose=verbose)
+        return self._make_port_dict(port, fields)
+
+    def get_ports(self, context, filters=None, fields=None, verbose=None):
+        return self._get_collection(context, models_v2.Port,
+                                    self._make_port_dict,
+                                    filters=filters, fields=fields,
+                                    verbose=verbose)
diff --git a/quantum/db/model_base.py b/quantum/db/model_base.py
new file mode 100644 (file)
index 0000000..4c27f4a
--- /dev/null
@@ -0,0 +1,72 @@
+# Copyright (c) 2012 OpenStack, LLC.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+# implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import uuid
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+from sqlalchemy.ext import declarative
+
+
+def str_uuid():
+    return str(uuid.uuid4())
+
+
+class QuantumBase(object):
+    """Base class for Quantum Models."""
+
+    def __setitem__(self, key, value):
+        setattr(self, key, value)
+
+    def __getitem__(self, key):
+        return getattr(self, key)
+
+    def get(self, key, default=None):
+        return getattr(self, key, default)
+
+    def __iter__(self):
+        self._i = iter(orm.object_mapper(self).columns)
+        return self
+
+    def next(self):
+        n = self._i.next().name
+        return n, getattr(self, n)
+
+    def update(self, values):
+        """Make the model object behave like a dict"""
+        for k, v in values.iteritems():
+            setattr(self, k, v)
+
+    def iteritems(self):
+        """Make the model object behave like a dict.
+        Includes attributes from joins."""
+        local = dict(self)
+        joined = dict([(k, v) for k, v in self.__dict__.iteritems()
+                       if not k[0] == '_'])
+        local.update(joined)
+        return local.iteritems()
+
+
+class QuantumBaseV2(QuantumBase):
+    id = sa.Column(sa.String(36), primary_key=True, default=str_uuid)
+
+    @declarative.declared_attr
+    def __tablename__(cls):
+        # NOTE(jkoelker) use the pluralized name of the class as the table
+        return cls.__name__.lower() + 's'
+
+
+BASE = declarative.declarative_base(cls=QuantumBase)
+BASEV2 = declarative.declarative_base(cls=QuantumBaseV2)
index e26e20c2d85b35392d2571337cf30f2541b8068f..4fe9b28493e291a53c57788a4dee060a6a85b51d 100644 (file)
 import uuid
 
 from sqlalchemy import Column, String, ForeignKey
-from sqlalchemy.ext.declarative import declarative_base
-from sqlalchemy.orm import relation, object_mapper
+from sqlalchemy.orm import relation
 
 from quantum.api import api_common as common
+from quantum.db import model_base
 
 
-BASE = declarative_base()
+BASE = model_base.BASE
 
 
-class QuantumBase(object):
-    """Base class for Quantum Models."""
-
-    def __setitem__(self, key, value):
-        setattr(self, key, value)
-
-    def __getitem__(self, key):
-        return getattr(self, key)
-
-    def get(self, key, default=None):
-        return getattr(self, key, default)
-
-    def __iter__(self):
-        self._i = iter(object_mapper(self).columns)
-        return self
-
-    def next(self):
-        n = self._i.next().name
-        return n, getattr(self, n)
-
-    def update(self, values):
-        """Make the model object behave like a dict"""
-        for k, v in values.iteritems():
-            setattr(self, k, v)
-
-    def iteritems(self):
-        """Make the model object behave like a dict.
-        Includes attributes from joins."""
-        local = dict(self)
-        joined = dict([(k, v) for k, v in self.__dict__.iteritems()
-                       if not k[0] == '_'])
-        local.update(joined)
-        return local.iteritems()
-
-
-class Port(BASE, QuantumBase):
+class Port(model_base.BASE):
     """Represents a port on a quantum network"""
     __tablename__ = 'ports'
 
@@ -90,7 +55,7 @@ class Port(BASE, QuantumBase):
                                            self.interface_id)
 
 
-class Network(BASE, QuantumBase):
+class Network(model_base.BASE):
     """Represents a quantum network"""
     __tablename__ = 'networks'
 
diff --git a/quantum/db/models_v2.py b/quantum/db/models_v2.py
new file mode 100644 (file)
index 0000000..82b5ec3
--- /dev/null
@@ -0,0 +1,72 @@
+# Copyright (c) 2012 OpenStack, LLC.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+# implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+
+from quantum.db import model_base
+
+
+class HasTenant(object):
+    """Tenant mixin, add to subclasses that have a tenant."""
+    # NOTE(jkoelker) tenant_id is just a free form string ;(
+    tenant_id = sa.Column(sa.String(255))
+
+
+class IPAllocation(model_base.BASEV2):
+    """Internal representation of a IP address allocation in a Quantum
+       subnet
+    """
+    port_id = sa.Column(sa.String(36), sa.ForeignKey('ports.id'))
+    address = sa.Column(sa.String(16), nullable=False, primary_key=True)
+    subnet_id = sa.Column(sa.String(36), sa.ForeignKey('subnets.id'),
+                            primary_key=True)
+    allocated = sa.Column(sa.Boolean(), nullable=False)
+
+
+class Port(model_base.BASEV2, HasTenant):
+    """Represents a port on a quantum v2 network"""
+    network_id = sa.Column(sa.String(36), sa.ForeignKey("networks.id"),
+                             nullable=False)
+    fixed_ips = orm.relationship(IPAllocation, backref='ports')
+    mac_address = sa.Column(sa.String(32), nullable=False)
+    admin_state_up = sa.Column(sa.Boolean(), nullable=False)
+    op_status = sa.Column(sa.String(16), nullable=False)
+    device_id = sa.Column(sa.String(255), nullable=False)
+
+
+class Subnet(model_base.BASEV2, HasTenant):
+    """Represents a quantum subnet"""
+    network_id = sa.Column(sa.String(36), sa.ForeignKey('networks.id'))
+    allocations = orm.relationship(IPAllocation,
+                                   backref=orm.backref('subnet',
+                                                       uselist=False))
+    ip_version = sa.Column(sa.Integer, nullable=False)
+    prefix = sa.Column(sa.String(255), nullable=False)
+    gateway_ip = sa.Column(sa.String(255))
+
+    #TODO(danwent):
+    # - dns_namservers
+    # - excluded_ranges
+    # - additional_routes
+
+
+class Network(model_base.BASEV2, HasTenant):
+    """Represents a v2 quantum network"""
+    name = sa.Column(sa.String(255))
+    ports = orm.relationship(Port, backref='networks')
+    subnets = orm.relationship(Subnet, backref='networks')
+    op_status = sa.Column(sa.String(16))
+    admin_state_up = sa.Column(sa.Boolean)
index 4db5ffdf53573e8a54b0c979572e1c12fe15511d..1f5f4f8f87f7a6ca59cd4dd4868ed07161ab28f9 100644 (file)
@@ -30,7 +30,6 @@ from quantum.common import utils
 from quantum.common.config import find_config_file
 from quantum.common.exceptions import ClassNotFound
 from quantum.openstack.common import importutils
-from quantum.quantum_plugin_base import QuantumPluginBase
 
 
 LOG = logging.getLogger(__name__)
@@ -46,6 +45,29 @@ def find_config(basepath):
     return None
 
 
+def get_plugin(plugin_provider):
+    # If the plugin can't be found let them know gracefully
+    try:
+        LOG.info("Loading Plugin: %s" % plugin_provider)
+        plugin_klass = importutils.import_class(plugin_provider)
+    except ClassNotFound:
+        LOG.exception("Error loading plugin")
+        raise Exception("Plugin not found.  You can install a "
+                        "plugin with: pip install <plugin-name>\n"
+                        "Example: pip install quantum-sample-plugin")
+    return plugin_klass()
+
+
+def get_plugin_provider(options, config_file=None):
+    if config_file:
+        config_file = [config_file]
+
+    if not 'plugin_provider' in options:
+        cf = find_config_file(options, config_file, CONFIG_FILE)
+        options['plugin_provider'] = utils.get_plugin_from_config(cf)
+    return options['plugin_provider']
+
+
 class QuantumManager(object):
 
     _instance = None
@@ -55,31 +77,13 @@ class QuantumManager(object):
         if not options:
             options = {}
 
-        if config_file:
-            config_file = [config_file]
-
-        self.configuration_file = find_config_file(options, config_file,
-                                                   CONFIG_FILE)
-        if not 'plugin_provider' in options:
-            options['plugin_provider'] = utils.get_plugin_from_config(
-                self.configuration_file)
-        LOG.debug("Plugin location:%s", options['plugin_provider'])
-
-        # If the plugin can't be found let them know gracefully
-        try:
-            plugin_klass = importutils.import_class(options['plugin_provider'])
-        except ClassNotFound:
-            raise Exception("Plugin not found.  You can install a "
-                            "plugin with: pip install <plugin-name>\n"
-                            "Example: pip install quantum-sample-plugin")
-
-        if not issubclass(plugin_klass, QuantumPluginBase):
-            raise Exception("Configured Quantum plug-in "
-                            "didn't pass compatibility test")
-        else:
-            LOG.debug("Successfully imported Quantum plug-in."
-                      "All compatibility tests passed")
-        self.plugin = plugin_klass()
+        # NOTE(jkoelker) Testing for the subclass with the __subclasshook__
+        #                breaks tach monitoring. It has been removed
+        #                intentianally to allow v2 plugins to be monitored
+        #                for performance metrics.
+        plugin_provider = get_plugin_provider(options, config_file)
+        LOG.debug("Plugin location:%s", plugin_provider)
+        self.plugin = get_plugin(plugin_provider)
 
     @classmethod
     def get_plugin(cls, options=None, config_file=None):
diff --git a/quantum/plugins/sample/SamplePluginV2.py b/quantum/plugins/sample/SamplePluginV2.py
new file mode 100644 (file)
index 0000000..5a6f9b9
--- /dev/null
@@ -0,0 +1,121 @@
+# Copyright (c) 2012 OpenStack, LLC.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+# implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging
+import uuid
+
+from quantum import quantum_plugin_base_v2
+
+
+LOG = logging.getLogger(__name__)
+
+
+class QuantumEchoPlugin(quantum_plugin_base_v2.QuantumPluginBaseV2):
+
+    """
+    QuantumEchoPlugin is a demo plugin that doesn't
+    do anything but demonstrate the concept of a
+    concrete Quantum Plugin. Any call to this plugin
+    will result in just a log statement with the name
+    method that was called and its arguments.
+    """
+
+    def _log(self, name, context, **kwargs):
+        kwarg_msg = ' '.join([('%s: |%s|' % (str(key), kwargs[key]))
+                              for key in kwargs])
+
+        # TODO(anyone) Add a nice __repr__ and __str__ to context
+        #LOG.debug('%s context: %s %s' % (name, context, kwarg_msg))
+        LOG.debug('%s %s' % (name, kwarg_msg))
+
+    def create_subnet(self, context, subnet):
+        self._log("create_subnet", context, subnet=subnet)
+        res = {"id": str(uuid.uuid4())}
+        res.update(subnet)
+        return res
+
+    def update_subnet(self, context, id, subnet):
+        self._log("update_subnet", context, id=id, subnet=subnet)
+        res = {"id": id}
+        res.update(subnet)
+        return res
+
+    def get_subnet(self, context, id, show=None, verbose=None):
+        self._log("get_subnet", context, id=id, show=show,
+                  verbose=verbose)
+        return {"id": id}
+
+    def delete_subnet(self, context, id):
+        self._log("delete_subnet", context, id=id)
+
+    def get_subnets(self, context, filters=None, show=None, verbose=None):
+        self._log("get_subnets", context, filters=filters, show=show,
+                  verbose=verbose)
+        return []
+
+    def create_network(self, context, network):
+        self._log("create_network", context, network=network)
+        res = {"id": str(uuid.uuid4())}
+        res.update(network)
+        return res
+
+    def update_network(self, context, id, network):
+        self._log("update_network", context, id=id, network=network)
+        res = {"id": id}
+        res.update(network)
+        return res
+
+    def get_network(self, context, id, show=None, verbose=None):
+        self._log("get_network", context, id=id, show=show,
+                  verbose=verbose)
+        return {"id": id}
+
+    def delete_network(self, context, id):
+        self._log("delete_network", context, id=id)
+
+    def get_networks(self, context, filters=None, show=None, verbose=None):
+        self._log("get_networks", context, filters=filters, show=show,
+                  verbose=verbose)
+        return []
+
+    def create_port(self, context, port):
+        self._log("create_port", context, port=port)
+        res = {"id": str(uuid.uuid4())}
+        res.update(port)
+        return res
+
+    def update_port(self, context, id, port):
+        self._log("update_port", context, id=id, port=port)
+        res = {"id": id}
+        res.update(port)
+        return res
+
+    def get_port(self, context, id, show=None, verbose=None):
+        self._log("get_port", context, id=id, show=show,
+                  verbose=verbose)
+        return {"id": id}
+
+    def delete_port(self, context, id):
+        self._log("delete_port", context, id=id)
+
+    def get_ports(self, context, filters=None, show=None, verbose=None):
+        self._log("get_ports", context, filters=filters, show=show,
+                  verbose=verbose)
+        return []
+
+    supported_extension_aliases = ["FOXNSOX"]
+
+    def method_to_support_foxnsox_extension(self, context):
+        self._log("method_to_support_foxnsox_extension", context)
diff --git a/quantum/quantum_plugin_base_v2.py b/quantum/quantum_plugin_base_v2.py
new file mode 100644 (file)
index 0000000..47d068e
--- /dev/null
@@ -0,0 +1,195 @@
+# Copyright 2011 Nicira Networks, Inc.
+# 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.
+# @author: Dan Wendlandt, Nicira, Inc.
+
+"""
+v2 Quantum Plug-in API specification.
+
+QuantumPluginBase provides the definition of minimum set of
+methods that needs to be implemented by a v2 Quantum Plug-in.
+"""
+
+from abc import ABCMeta, abstractmethod
+
+
+class QuantumPluginBaseV2(object):
+
+    __metaclass__ = ABCMeta
+
+    @abstractmethod
+    def create_subnet(self, context, subnet):
+        """
+        Create a subnet, which represents a range of IP addresses
+        that can be allocated to devices
+        : param subnet_data: data describing the prefix
+          {
+            "network_id": UUID of the network to which this subnet
+                          is bound.
+            "ip_version": integer indicating IP protocol version.
+                          example: 4
+            "prefix": string indicating IP prefix indicating addresses
+                      that can be allocated for devices on this subnet.
+                      example: "10.0.0.0/24"
+            "gateway_ip": string indicating the default gateway
+                          for devices on this subnet. example: "10.0.0.1"
+            "dns_nameservers": list of strings stricting indication the
+                               DNS name servers for devices on this
+                               subnet.  example: [ "8.8.8.8", "8.8.4.4" ]
+            "excluded_ranges" : list of dicts indicating pairs of IPs that
+                                should not be allocated from the prefix.
+                                example: [ { "start" : "10.0.0.2",
+                                             "end" : "10.0.0.5" } ]
+            "additional_routes": list of dicts indicating routes beyond
+                                 the default gateway and local prefix route
+                                 that should be injected into the device.
+                                 example: [{"destination": "192.168.0.0/16",
+                                              "nexthop": "10.0.0.5" } ]
+          }
+        """
+        pass
+
+    @abstractmethod
+    def update_subnet(self, context, id, subnet):
+        pass
+
+    @abstractmethod
+    def get_subnet(self, context, id, fields=None, verbose=None):
+        pass
+
+    @abstractmethod
+    def delete_subnet(self, context, id):
+        pass
+
+    @abstractmethod
+    def get_subnets(self, context, filters=None, fields=None, verbose=None):
+        pass
+
+    @abstractmethod
+    def create_network(self, context, network):
+        """
+        Creates a new Virtual Network, assigns a name and associates
+
+        :param net_data:
+          {
+           'name': a human-readable name associated
+                   with network referenced by net-id
+                   example: "net-1"
+           'admin-state-up': indicates whether this network should
+                             be operational.
+           'subnets': list of subnet uuids associated with this
+                      network.
+          }
+        :raises:
+        """
+        pass
+
+    @abstractmethod
+    def update_network(self, context, id, network):
+        pass
+
+    @abstractmethod
+    def delete_network(self, context, id):
+        pass
+
+    @abstractmethod
+    def get_network(self, context, id, fields=None, verbose=None):
+        pass
+
+    @abstractmethod
+    def get_networks(self, context, filters=None, fields=None, verbose=None):
+        pass
+
+    @abstractmethod
+    def create_port(self, context, port):
+        """
+        Creates a port on the specified Virtual Network. Optionally
+        specify customization of port IP-related attributes, otherwise
+        the port gets the default values of these attributes associated with
+        the subnet.
+
+        :param port_data:
+          {"network_id" : UUID of network that this port is attached to.
+           "admin-state-up" : boolean indicating whether this port should be
+                              operational.
+           "mac_address" : (optional) mac address used on this port.  If no
+                           value is specified, the plugin will generate a
+                           MAC address based on internal configuration.
+           "fixed_ips" : (optional) list of dictionaries describing the
+                         fixed IPs to be allocated for use by the device on
+                         this port. If not specified, the plugin will
+                         attempt to find a v4 and v6 subnet associated
+                         with the network and allocate an IP for that
+                         subnet.
+                         Note: "address" is optional, in which case an
+                               address from the specified subnet is
+                               selected.
+                         example: [{"subnet": "<uuid>",
+                                    "address": "10.0.0.9"}]
+           "routes" : (optional) list of routes to be injected into this
+                      device. If not specified, the port will get a
+                      route for its local subnet, a route for the default
+                      gateway, and each of the routes in the
+                      'additional_routes' field of the subnet.
+                      example: [ { "destination" : "192.168.0.0/16",
+                                   "nexthop" : "10.0.0.5" } ]
+          }
+        :raises: exception.NetworkNotFound
+        :raises: exception.RequestedFixedIPNotAvailable
+        :raises: exception.FixedIPNotAvailable
+        :raises: exception.RouteInvalid
+        """
+        pass
+
+    @abstractmethod
+    def update_port(self, context, id, port):
+        """
+        Updates the attributes of a specific port on the
+        specified Virtual Network.
+
+        :returns: a mapping sequence with the following signature:
+                    {'port-id': uuid representing the
+                                 updated port on specified quantum network
+                     'port-state': update port state( UP or DOWN)
+                    }
+        :raises: exception.StateInvalid
+        :raises: exception.PortNotFound
+        """
+        pass
+
+    @abstractmethod
+    def delete_port(self, context, id):
+        """
+        Deletes a port on a specified Virtual Network,
+        if the port contains a remote interface attachment,
+        the remote interface is first un-plugged and then the port
+        is deleted.
+
+        :returns: a mapping sequence with the following signature:
+                    {'port-id': uuid representing the deleted port
+                                 on specified quantum network
+                    }
+        :raises: exception.PortInUse
+        :raises: exception.PortNotFound
+        :raises: exception.NetworkNotFound
+        """
+        pass
+
+    @abstractmethod
+    def get_port(self, context, id, fields=None, verbose=None):
+        pass
+
+    @abstractmethod
+    def get_ports(self, context, filters=None, fields=None, verbose=None):
+        pass
diff --git a/quantum/tests/unit/test_api_v2.py b/quantum/tests/unit/test_api_v2.py
new file mode 100644 (file)
index 0000000..b7f8c5f
--- /dev/null
@@ -0,0 +1,486 @@
+# Copyright 2012 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 spec
+
+import logging
+import unittest
+import uuid
+
+import mock
+import webtest
+
+from webob import exc
+
+from quantum.common import exceptions as q_exc
+from quantum.api.v2 import resource as wsgi_resource
+from quantum.api.v2 import router
+from quantum.api.v2 import views
+
+
+LOG = logging.getLogger(__name__)
+
+
+def _get_path(resource, id=None, fmt=None):
+    path = '/%s' % resource
+
+    if id is not None:
+        path = path + '/%s' % id
+
+    if fmt is not None:
+        path = path + '.%s' % fmt
+
+    return path
+
+
+class V2WsgiResourceTestCase(unittest.TestCase):
+    def test_unmapped_quantum_error(self):
+        controller = mock.MagicMock()
+        controller.test.side_effect = q_exc.QuantumException()
+
+        resource = webtest.TestApp(wsgi_resource.Resource(controller))
+
+        environ = {'wsgiorg.routing_args': (None, {'action': 'test'})}
+        res = resource.get('', extra_environ=environ, expect_errors=True)
+        self.assertEqual(res.status_int,  exc.HTTPInternalServerError.code)
+
+    def test_mapped_quantum_error(self):
+        controller = mock.MagicMock()
+        controller.test.side_effect = q_exc.QuantumException()
+
+        faults = {q_exc.QuantumException: exc.HTTPGatewayTimeout}
+        resource = webtest.TestApp(wsgi_resource.Resource(controller,
+                                                          faults=faults))
+
+        environ = {'wsgiorg.routing_args': (None, {'action': 'test'})}
+        res = resource.get('', extra_environ=environ, expect_errors=True)
+        self.assertEqual(res.status_int, exc.HTTPGatewayTimeout.code)
+
+    def test_http_error(self):
+        controller = mock.MagicMock()
+        controller.test.side_effect = exc.HTTPGatewayTimeout()
+
+        resource = webtest.TestApp(wsgi_resource.Resource(controller))
+
+        environ = {'wsgiorg.routing_args': (None, {'action': 'test'})}
+        res = resource.get('', extra_environ=environ, expect_errors=True)
+        self.assertEqual(res.status_int, exc.HTTPGatewayTimeout.code)
+
+    def test_unhandled_error(self):
+        controller = mock.MagicMock()
+        controller.test.side_effect = Exception()
+
+        resource = webtest.TestApp(wsgi_resource.Resource(controller))
+
+        environ = {'wsgiorg.routing_args': (None, {'action': 'test'})}
+        res = resource.get('', extra_environ=environ, expect_errors=True)
+        self.assertEqual(res.status_int, exc.HTTPInternalServerError.code)
+
+
+class ResourceIndexTestCase(unittest.TestCase):
+    def test_index_json(self):
+        index = webtest.TestApp(router.Index({'foo': 'bar'}))
+        res = index.get('')
+
+        self.assertTrue('resources' in res.json)
+        self.assertTrue(len(res.json['resources']) == 1)
+
+        resource = res.json['resources'][0]
+        self.assertTrue('collection' in resource)
+        self.assertTrue(resource['collection'] == 'bar')
+
+        self.assertTrue('name' in resource)
+        self.assertTrue(resource['name'] == 'foo')
+
+        self.assertTrue('links' in resource)
+        self.assertTrue(len(resource['links']) == 1)
+
+        link = resource['links'][0]
+        self.assertTrue('href' in link)
+        self.assertTrue(link['href'] == 'http://localhost/bar')
+        self.assertTrue('rel' in link)
+        self.assertTrue(link['rel'] == 'self')
+
+
+class APIv2TestCase(unittest.TestCase):
+    # NOTE(jkoelker) This potentially leaks the mock object if the setUp
+    #                raises without being caught. Using unittest2
+    #                or dropping 2.6 support so we can use addCleanup
+    #                will get around this.
+    def setUp(self):
+        plugin = 'quantum.quantum_plugin_base_v2.QuantumPluginBaseV2'
+        self._plugin_patcher = mock.patch(plugin, autospec=True)
+        self.plugin = self._plugin_patcher.start()
+
+        api = router.APIRouter({'plugin_provider': plugin})
+        self.api = webtest.TestApp(api)
+
+    def tearDown(self):
+        self._plugin_patcher.stop()
+        self.api = None
+        self.plugin = None
+
+    def test_verbose_attr(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'verbose': 'foo'})
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=mock.ANY,
+                                                      fields=mock.ANY,
+                                                      verbose=['foo'])
+
+    def test_multiple_verbose_attr(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'verbose': ['foo', 'bar']})
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=mock.ANY,
+                                                      fields=mock.ANY,
+                                                      verbose=['foo',
+                                                               'bar'])
+
+    def test_verbose_false_str(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'verbose': 'false'})
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=mock.ANY,
+                                                      fields=mock.ANY,
+                                                      verbose=False)
+
+    def test_verbose_true_str(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'verbose': 'true'})
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=mock.ANY,
+                                                      fields=mock.ANY,
+                                                      verbose=True)
+
+    def test_verbose_true_trump_attr(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'verbose': ['true', 'foo']})
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=mock.ANY,
+                                                      fields=mock.ANY,
+                                                      verbose=True)
+
+    def test_verbose_false_trump_attr(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'verbose': ['false', 'foo']})
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=mock.ANY,
+                                                      fields=mock.ANY,
+                                                      verbose=False)
+
+    def test_verbose_true_trump_false(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'verbose': ['true', 'false']})
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=mock.ANY,
+                                                      fields=mock.ANY,
+                                                      verbose=True)
+
+    def test_fields(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'fields': 'foo'})
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=mock.ANY,
+                                                      fields=['foo'],
+                                                      verbose=mock.ANY)
+
+    def test_fields_multiple(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'fields': ['foo', 'bar']})
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=mock.ANY,
+                                                      fields=['foo', 'bar'],
+                                                      verbose=mock.ANY)
+
+    def test_fields_multiple_with_empty(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'fields': ['foo', '']})
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=mock.ANY,
+                                                      fields=['foo'],
+                                                      verbose=mock.ANY)
+
+    def test_fields_empty(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'fields': ''})
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=mock.ANY,
+                                                      fields=[],
+                                                      verbose=mock.ANY)
+
+    def test_fields_multiple_empty(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'fields': ['', '']})
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=mock.ANY,
+                                                      fields=[],
+                                                      verbose=mock.ANY)
+
+    def test_filters(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'foo': 'bar'})
+        filters = {'foo': ['bar']}
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=filters,
+                                                      fields=mock.ANY,
+                                                      verbose=mock.ANY)
+
+    def test_filters_empty(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'foo': ''})
+        filters = {}
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=filters,
+                                                      fields=mock.ANY,
+                                                      verbose=mock.ANY)
+
+    def test_filters_multiple_empty(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'foo': ['', '']})
+        filters = {}
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=filters,
+                                                      fields=mock.ANY,
+                                                      verbose=mock.ANY)
+
+    def test_filters_multiple_with_empty(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'foo': ['bar', '']})
+        filters = {'foo': ['bar']}
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=filters,
+                                                      fields=mock.ANY,
+                                                      verbose=mock.ANY)
+
+    def test_filters_multiple_values(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'foo': ['bar', 'bar2']})
+        filters = {'foo': ['bar', 'bar2']}
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=filters,
+                                                      fields=mock.ANY,
+                                                      verbose=mock.ANY)
+
+    def test_filters_multiple(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'foo': 'bar',
+                                             'foo2': 'bar2'})
+        filters = {'foo': ['bar'], 'foo2': ['bar2']}
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=filters,
+                                                      fields=mock.ANY,
+                                                      verbose=mock.ANY)
+
+    def test_filters_with_fields(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'foo': 'bar', 'fields': 'foo'})
+        filters = {'foo': ['bar']}
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=filters,
+                                                      fields=['foo'],
+                                                      verbose=mock.ANY)
+
+    def test_filters_with_verbose(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'foo': 'bar',
+                                             'verbose': 'true'})
+        filters = {'foo': ['bar']}
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=filters,
+                                                      fields=mock.ANY,
+                                                      verbose=True)
+
+    def test_filters_with_fields_and_verbose(self):
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = []
+
+        self.api.get(_get_path('networks'), {'foo': 'bar',
+                                             'fields': 'foo',
+                                             'verbose': 'true'})
+        filters = {'foo': ['bar']}
+        instance.get_networks.assert_called_once_with(mock.ANY,
+                                                      filters=filters,
+                                                      fields=['foo'],
+                                                      verbose=True)
+
+
+class JSONV2TestCase(APIv2TestCase):
+    def test_list(self):
+        return_value = [{'network': {'name': 'net1',
+                                     'admin_state_up': True,
+                                     'subnets': []}}]
+        instance = self.plugin.return_value
+        instance.get_networks.return_value = return_value
+
+        res = self.api.get(_get_path('networks'))
+        self.assertTrue('networks' in res.json)
+
+    def test_create(self):
+        data = {'network': {'name': 'net1', 'admin_state_up': True}}
+        return_value = {'subnets': []}
+        return_value.update(data['network'].copy())
+
+        instance = self.plugin.return_value
+        instance.create_network.return_value = return_value
+
+        res = self.api.post_json(_get_path('networks'), data)
+        self.assertEqual(res.status_int, exc.HTTPCreated.code)
+
+    def test_create_no_body(self):
+        data = {'whoa': None}
+        res = self.api.post_json(_get_path('networks'), data,
+                                 expect_errors=True)
+        self.assertEqual(res.status_int, exc.HTTPBadRequest.code)
+
+    def test_create_no_resource(self):
+        res = self.api.post_json(_get_path('networks'), dict(),
+                                 expect_errors=True)
+        self.assertEqual(res.status_int, exc.HTTPBadRequest.code)
+
+    def test_create_missing_attr(self):
+        data = {'network': {'what': 'who'}}
+        res = self.api.post_json(_get_path('networks'), data,
+                                 expect_errors=True)
+        self.assertEqual(res.status_int, 422)
+
+    def test_create_bulk(self):
+        data = {'networks': [{'name': 'net1', 'admin_state_up': True},
+                             {'name': 'net2', 'admin_state_up': True}]}
+
+        def side_effect(context, network):
+            nets = network.copy()
+            for net in nets['networks']:
+                net.update({'subnets': []})
+            return nets
+
+        instance = self.plugin.return_value
+        instance.create_network.side_effect = side_effect
+
+        res = self.api.post_json(_get_path('networks'), data)
+        self.assertEqual(res.status_int, exc.HTTPCreated.code)
+
+    def test_create_bulk_no_networks(self):
+        data = {'networks': []}
+        res = self.api.post_json(_get_path('networks'), data,
+                                 expect_errors=True)
+        self.assertEqual(res.status_int, exc.HTTPBadRequest.code)
+
+    def test_create_bulk_missing_attr(self):
+        data = {'networks': [{'what': 'who'}]}
+        res = self.api.post_json(_get_path('networks'), data,
+                                 expect_errors=True)
+        self.assertEqual(res.status_int, 422)
+
+    def test_create_bulk_partial_body(self):
+        data = {'networks': [{'name': 'net1', 'admin_state_up': True},
+                             {}]}
+        res = self.api.post_json(_get_path('networks'), data,
+                                 expect_errors=True)
+        self.assertEqual(res.status_int, 422)
+
+    def test_fields(self):
+        return_value = {'name': 'net1', 'admin_state_up': True,
+                        'subnets': []}
+
+        instance = self.plugin.return_value
+        instance.get_network.return_value = return_value
+
+        self.api.get(_get_path('networks', id=str(uuid.uuid4())))
+
+    def test_delete(self):
+        instance = self.plugin.return_value
+        instance.delete_network.return_value = None
+
+        res = self.api.delete(_get_path('networks', id=str(uuid.uuid4())))
+        self.assertEqual(res.status_int, exc.HTTPNoContent.code)
+
+    def test_update(self):
+        data = {'network': {'name': 'net1', 'admin_state_up': True}}
+        return_value = {'subnets': []}
+        return_value.update(data['network'].copy())
+
+        instance = self.plugin.return_value
+        instance.update_network.return_value = return_value
+
+        self.api.put_json(_get_path('networks',
+                                    id=str(uuid.uuid4())), data)
+
+
+class V2Views(unittest.TestCase):
+    def _view(self, keys, func):
+        data = dict((key, 'value') for key in keys)
+        data['fake'] = 'value'
+        res = func(data)
+        self.assertTrue('fake' not in res)
+        for key in keys:
+            self.assertTrue(key in res)
+
+    def test_resource(self):
+        res = views.resource({'one': 1, 'two': 2}, ['one'])
+        self.assertTrue('one' in res)
+        self.assertTrue('two' not in res)
+
+    def test_network(self):
+        keys = ('id', 'name', 'subnets', 'admin_state_up', 'op_status',
+                'tenant_id', 'mac_ranges')
+        self._view(keys, views.network)
+
+    def test_port(self):
+        keys = ('id', 'network_id', 'mac_address', 'fixed_ips',
+                'device_id', 'admin_state_up', 'tenant_id', 'op_status')
+        self._view(keys, views.port)
+
+    def test_subnet(self):
+        keys = ('id', 'network_id', 'tenant_id', 'gateway_ip',
+                'ip_version', 'prefix')
+        self._view(keys, views.subnet)
diff --git a/quantum/tests/unit/test_db_plugin.py b/quantum/tests/unit/test_db_plugin.py
new file mode 100644 (file)
index 0000000..f4a6670
--- /dev/null
@@ -0,0 +1,317 @@
+# Copyright (c) 2012 OpenStack, LLC.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+# implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging
+import unittest
+import contextlib
+
+from quantum.api.v2.router import APIRouter
+from quantum.db import api as db
+from quantum.tests.unit.testlib_api import create_request
+from quantum.wsgi import Serializer, JSONDeserializer
+
+
+LOG = logging.getLogger(__name__)
+
+
+class QuantumDbPluginV2TestCase(unittest.TestCase):
+    def setUp(self):
+        super(QuantumDbPluginV2TestCase, self).setUp()
+
+        # NOTE(jkoelker) for a 'pluggable' framework, Quantum sure
+        #                doesn't like when the plugin changes ;)
+        db._ENGINE = None
+        db._MAKER = None
+
+        self._tenant_id = 'test-tenant'
+
+        json_deserializer = JSONDeserializer()
+        self._deserializers = {
+            'application/json': json_deserializer,
+        }
+
+        plugin = 'quantum.db.db_base_plugin_v2.QuantumDbPluginV2'
+        self.api = APIRouter({'plugin_provider': plugin})
+
+    def tearDown(self):
+        super(QuantumDbPluginV2TestCase, self).tearDown()
+        # NOTE(jkoelker) for a 'pluggable' framework, Quantum sure
+        #                doesn't like when the plugin changes ;)
+        db._ENGINE = None
+        db._MAKER = None
+
+    def _req(self, method, resource, data=None, fmt='json', id=None):
+        if id:
+            path = '/%(resource)s/%(id)s.%(fmt)s' % locals()
+        else:
+            path = '/%(resource)s.%(fmt)s' % locals()
+        content_type = 'application/%s' % fmt
+        body = None
+        if data:
+            body = Serializer().serialize(data, content_type)
+        return create_request(path, body, content_type, method)
+
+    def new_create_request(self, resource, data, fmt='json'):
+        return self._req('POST', resource, data, fmt)
+
+    def new_list_request(self, resource, fmt='json'):
+        return self._req('GET', resource, None, fmt)
+
+    def new_show_request(self, resource, id, fmt='json'):
+        return self._req('GET', resource, None, fmt, id=id)
+
+    def new_delete_request(self, resource, id, fmt='json'):
+        return self._req('DELETE', resource, None, fmt, id=id)
+
+    def new_update_request(self, resource, data, id, fmt='json'):
+        return self._req('PUT', resource, data, fmt, id=id)
+
+    def deserialize(self, content_type, response):
+        ctype = 'application/%s' % content_type
+        data = self._deserializers[ctype].\
+                            deserialize(response.body)['body']
+        return data
+
+    def _create_network(self, fmt, name, admin_status_up):
+        data = {'network': {'name': name,
+                            'admin_state_up': admin_status_up}}
+        network_req = self.new_create_request('networks', data, fmt)
+        return network_req.get_response(self.api)
+
+    def _create_subnet(self, fmt, net_id, gateway_ip, prefix):
+        data = {'subnet': {'network_id': net_id,
+                           'allocations': [],
+                           'prefix': prefix,
+                           'ip_version': 4,
+                           'gateway_ip': gateway_ip}}
+        subnet_req = self.new_create_request('subnets', data, fmt)
+        return subnet_req.get_response(self.api)
+
+    def _make_subnet(self, fmt, network, gateway, prefix):
+        res = self._create_subnet(fmt, network['network']['id'],
+                                  gateway, prefix)
+        return self.deserialize(fmt, res)
+
+    def _delete(self, collection, id):
+        req = self.new_delete_request(collection, id)
+        req.get_response(self.api)
+
+    @contextlib.contextmanager
+    def network(self, name='net1', admin_status_up=True, fmt='json'):
+        res = self._create_network(fmt, name, admin_status_up)
+        network = self.deserialize(fmt, res)
+        yield network
+        self._delete('networks', network['network']['id'])
+
+    @contextlib.contextmanager
+    def subnet(self, network=None, gateway='10.0.0.1',
+               prefix='10.0.0.0/24', fmt='json'):
+        # TODO(anyone) DRY this
+        if not network:
+            with self.network() as network:
+                subnet = self._make_subnet(fmt, network, gateway, prefix)
+                yield subnet
+                self._delete('subnets', subnet['subnet']['id'])
+        else:
+            subnet = self._make_subnet(fmt, network, gateway, prefix)
+            yield subnet
+            self._delete('subnets', subnet['subnet']['id'])
+
+
+class TestV2HTTPResponse(QuantumDbPluginV2TestCase):
+    def test_create_returns_201(self):
+        res = self._create_network('json', 'net2', True)
+        self.assertEquals(res.status_int, 201)
+
+    def test_list_returns_200(self):
+        req = self.new_list_request('networks')
+        res = req.get_response(self.api)
+        self.assertEquals(res.status_int, 200)
+
+    def test_show_returns_200(self):
+        with self.network() as net:
+            req = self.new_show_request('networks', net['network']['id'])
+            res = req.get_response(self.api)
+            self.assertEquals(res.status_int, 200)
+
+    def test_delete_returns_204(self):
+        res = self._create_network('json', 'net1', True)
+        net = self.deserialize('json', res)
+        req = self.new_delete_request('networks', net['network']['id'])
+        res = req.get_response(self.api)
+        self.assertEquals(res.status_int, 204)
+
+    def test_update_returns_200(self):
+        with self.network() as net:
+            req = self.new_update_request('networks',
+                                          {'network': {'name': 'steve'}},
+                                          net['network']['id'])
+            res = req.get_response(self.api)
+            self.assertEquals(res.status_int, 200)
+
+    def test_bad_route_404(self):
+        req = self.new_list_request('doohickeys')
+        res = req.get_response(self.api)
+        self.assertEquals(res.status_int, 404)
+
+
+#class TestPortsV2(APIv2TestCase):
+#    def setUp(self):
+#        super(TestPortsV2, self).setUp()
+#        res = self._create_network('json', 'net1', True)
+#        data = self._deserializers['application/json'].\
+#                            deserialize(res.body)['body']
+#        self.net_id = data['network']['id']
+#
+#    def _create_port(self, fmt, net_id, admin_state_up, device_id,
+#                     custom_req_body=None,
+#                     expected_res_status=None):
+#        content_type = 'application/' + fmt
+#        data = {'port': {'network_id': net_id,
+#                         'admin_state_up': admin_state_up,
+#                         'device_id': device_id}}
+#        port_req = self.new_create_request('ports', data, fmt)
+#        port_res = port_req.get_response(self.api)
+#        return json.loads(port_res.body)
+#
+#    def test_create_port_json(self):
+#        port = self._create_port('json', self.net_id, True, 'dev_id_1')
+#        self.assertEqual(port['id'], 'dev_id_1')
+#        self.assertEqual(port['admin_state_up'], 'DOWN')
+#        self.assertEqual(port['device_id'], 'dev_id_1')
+#        self.assertTrue('mac_address' in port)
+#        self.assertTrue('op_status' in port)
+#
+#    def test_list_ports(self):
+#        port1 = self._create_port('json', self.net_id, True, 'dev_id_1')
+#        port2 = self._create_port('json', self.net_id, True, 'dev_id_2')
+#
+#        res = self.new_list_request('ports', 'json')
+#        port_list = json.loads(res.body)['body']
+#        self.assertTrue(port1 in port_list['ports'])
+#        self.assertTrue(port2 in port_list['ports'])
+#
+#    def test_show_port(self):
+#        port = self._create_port('json', self.net_id, True, 'dev_id_1')
+#        res = self.new_show_request('port', 'json', port['id'])
+#        port = json.loads(res.body)['body']
+#        self.assertEquals(port['port']['name'], 'dev_id_1')
+#
+#    def test_delete_port(self):
+#        port = self._create_port('json', self.net_id, True, 'dev_id_1')
+#        self.new_delete_request('port', 'json', port['id'])
+#
+#        port = self.new_show_request('port', 'json', port['id'])
+#
+#        self.assertEquals(res.status_int, 404)
+#
+#    def test_update_port(self):
+#        port = self._create_port('json', self.net_id, True, 'dev_id_1')
+#        port_body = {'port': {'device_id': 'Bob'}}
+#        res = self.new_update_request('port', port_body, port['id'])
+#        port = json.loads(res.body)['body']
+#        self.assertEquals(port['device_id'], 'Bob')
+#
+#    def test_delete_non_existent_port_404(self):
+#        res = self.new_delete_request('port', 'json', 1)
+#        self.assertEquals(res.status_int, 404)
+#
+#    def test_show_non_existent_port_404(self):
+#        res = self.new_show_request('port', 'json', 1)
+#        self.assertEquals(res.status_int, 404)
+#
+#    def test_update_non_existent_port_404(self):
+#        res = self.new_update_request('port', 'json', 1)
+#        self.assertEquals(res.status_int, 404)
+
+
+class TestNetworksV2(QuantumDbPluginV2TestCase):
+    # NOTE(cerberus): successful network update and delete are
+    #                 effectively tested above
+    def test_create_network(self):
+        name = 'net1'
+        keys = [('subnets', []), ('name', name), ('admin_state_up', True),
+                ('op_status', 'ACTIVE')]
+        with self.network(name=name) as net:
+            for k, v in keys:
+                self.assertEquals(net['network'][k], v)
+
+    def test_list_networks(self):
+        with self.network(name='net1') as net1:
+            with self.network(name='net2') as net2:
+                req = self.new_list_request('networks')
+                res = self.deserialize('json', req.get_response(self.api))
+
+                self.assertEquals(res['networks'][0]['name'],
+                                  net1['network']['name'])
+                self.assertEquals(res['networks'][1]['name'],
+                                  net2['network']['name'])
+
+    def test_show_network(self):
+        with self.network(name='net1') as net:
+            req = self.new_show_request('networks', net['network']['id'])
+            res = self.deserialize('json', req.get_response(self.api))
+            self.assertEquals(res['network']['name'],
+                              net['network']['name'])
+
+
+class TestSubnetsV2(QuantumDbPluginV2TestCase):
+    def test_create_subnet(self):
+        gateway = '10.0.0.1'
+        prefix = '10.0.0.0/24'
+        keys = [('ip_version', 4), ('gateway_ip', gateway),
+                ('prefix', prefix)]
+        with self.subnet(gateway=gateway, prefix=prefix) as subnet:
+            for k, v in keys:
+                self.assertEquals(subnet['subnet'][k], v)
+
+    def test_update_subnet(self):
+        with self.subnet() as subnet:
+            data = {'subnet': {'network_id': 'blarg',
+                               'prefix': '192.168.0.0/24'}}
+            req = self.new_update_request('subnets', data,
+                                          subnet['subnet']['id'])
+            res = self.deserialize('json', req.get_response(self.api))
+            self.assertEqual(res['subnet']['prefix'],
+                             data['subnet']['prefix'])
+
+    def test_show_subnet(self):
+        with self.network() as network:
+            with self.subnet(network=network) as subnet:
+                req = self.new_show_request('subnets',
+                                            subnet['subnet']['id'])
+                res = self.deserialize('json', req.get_response(self.api))
+                self.assertEquals(res['subnet']['id'],
+                                  subnet['subnet']['id'])
+                self.assertEquals(res['subnet']['network_id'],
+                                  network['network']['id'])
+
+    def test_list_subnets(self):
+        # NOTE(jkoelker) This would be a good place to use contextlib.nested
+        #                or just drop 2.6 support ;)
+        with self.network() as network:
+            with self.subnet(network=network, gateway='10.0.0.1',
+                             prefix='10.0.1.0/24') as subnet:
+                with self.subnet(network=network, gateway='10.0.1.1',
+                                 prefix='10.0.1.0/24') as subnet2:
+                    req = self.new_list_request('subnets')
+                    res = self.deserialize('json',
+                                           req.get_response(self.api))
+                    res1 = res['subnets'][0]
+                    res2 = res['subnets'][1]
+                    self.assertEquals(res1['prefix'],
+                                      subnet['subnet']['prefix'])
+                    self.assertEquals(res2['prefix'],
+                                      subnet2['subnet']['prefix'])
index 77ef3134d71e7662c4d0c61a705aaed92d55a378..c0e0aff6defc87ec3ad96707f9a43aa89c450507 100644 (file)
@@ -89,6 +89,33 @@ class Middleware(object):
     behavior.
     """
 
+    @classmethod
+    def factory(cls, global_config, **local_config):
+        """Used for paste app factories in paste.deploy config files.
+
+        Any local configuration (that is, values under the [filter:APPNAME]
+        section of the paste config) will be passed into the `__init__` method
+        as kwargs.
+
+        A hypothetical configuration would look like:
+
+            [filter:analytics]
+            redis_host = 127.0.0.1
+            paste.filter_factory = nova.api.analytics:Analytics.factory
+
+        which would result in a call to the `Analytics` class as
+
+            import nova.api.analytics
+            analytics.Analytics(app_from_paste, redis_host='127.0.0.1')
+
+        You could of course re-implement the `factory` method in subclasses,
+        but using the kwarg passing it shouldn't be necessary.
+
+        """
+        def _factory(app):
+            return cls(app, **local_config)
+        return _factory
+
     def __init__(self, application):
         self.application = application
 
@@ -204,6 +231,12 @@ class XMLDictSerializer(DictSerializer):
 
         return self.to_xml_string(node)
 
+    def __call__(self, data):
+        # Provides a migration path to a cleaner WSGI layer, this
+        # "default" stuff and extreme extensibility isn't being used
+        # like originally intended
+        return self.default(data)
+
     def to_xml_string(self, node, has_atom=False):
         self._add_xmlns(node, has_atom)
         return node.toxml('UTF-8')
@@ -427,6 +460,10 @@ class XMLDeserializer(TextDeserializer):
     def default(self, datastring):
         return {'body': self._from_xml(datastring)}
 
+    def __call__(self, datastring):
+        # Adding a migration path to allow us to remove unncessary classes
+        return self.default(datastring)
+
 
 class RequestHeadersDeserializer(ActionDispatcher):
     """Default request headers deserializer"""
index 05efffe7cf76c734617dc9d5a93bb46cb57165e2..ea254aad5fbec4a622e86ef4ea000d29c07cd9c8 100644 (file)
@@ -4,5 +4,5 @@ Routes>=1.12.3
 eventlet>=0.9.12
 lxml
 python-gflags==1.3
-sqlalchemy
+sqlalchemy>0.6.4
 webob==1.2.0
index 8d3899115a01d2abb3600feab200410e8aac8e80..67d40ade80339fd56b5797913b67e516f80a4aa7 100644 (file)
@@ -1,6 +1,6 @@
 distribute>=0.6.24
 coverage
-mock>=0.7.1
+mock>=0.8
 mox==0.5.3
 nose
 nosexcover