From: Jason Kölker Date: Wed, 9 May 2012 17:04:11 +0000 (-0500) Subject: Add API v2 support X-Git-Url: https://review.fuel-infra.org/gitweb?a=commitdiff_plain;h=e791e9e3703d0426a80cb0d4423a9501c2445335;p=openstack-build%2Fneutron-build.git Add API v2 support * Implements BP v2-api-melange-integration * Adds v2 Plugin specification * Refactors SQLAlchemy usage for multiple BASE's Change-Id: I45f008f181c18269afdfe4a9b589a7c5ae56d225 --- diff --git a/etc/quantum.conf b/etc/quantum.conf index 5162060d2..b19442865 100644 --- a/etc/quantum.conf +++ b/etc/quantum.conf @@ -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 index 000000000..cf6894721 --- /dev/null +++ b/quantum/api/v2/__init__.py @@ -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 index 000000000..eff436b5f --- /dev/null +++ b/quantum/api/v2/base.py @@ -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= 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 index 000000000..6e2f9ecda --- /dev/null +++ b/quantum/api/v2/resource.py @@ -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 index 000000000..6fdc9daa5 --- /dev/null +++ b/quantum/api/v2/router.py @@ -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 index 000000000..932607e61 --- /dev/null +++ b/quantum/api/v2/views.py @@ -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) diff --git a/quantum/api/versions.py b/quantum/api/versions.py index 8486ef379..73a0c569d 100644 --- a/quantum/api/versions.py +++ b/quantum/api/versions.py @@ -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 != '/': diff --git a/quantum/common/exceptions.py b/quantum/common/exceptions.py index 5a0dc9909..b3fef044c 100644 --- a/quantum/common/exceptions.py +++ b/quantum/common/exceptions.py @@ -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") diff --git a/quantum/common/utils.py b/quantum/common/utils.py index 3d09ca6da..931a82c41 100644 --- a/quantum/common/utils.py +++ b/quantum/common/utils.py @@ -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() diff --git a/quantum/db/api.py b/quantum/db/api.py index 85a9c89dd..a99762031 100644 --- a/quantum/db/api.py +++ b/quantum/db/api.py @@ -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 index 000000000..a1dbf59a5 --- /dev/null +++ b/quantum/db/db_base_plugin_v2.py @@ -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 index 000000000..4c27f4a6b --- /dev/null +++ b/quantum/db/model_base.py @@ -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) diff --git a/quantum/db/models.py b/quantum/db/models.py index e26e20c2d..4fe9b2849 100644 --- a/quantum/db/models.py +++ b/quantum/db/models.py @@ -21,51 +21,16 @@ 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 index 000000000..82b5ec30e --- /dev/null +++ b/quantum/db/models_v2.py @@ -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) diff --git a/quantum/manager.py b/quantum/manager.py index 4db5ffdf5..1f5f4f8f8 100644 --- a/quantum/manager.py +++ b/quantum/manager.py @@ -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 \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 \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 index 000000000..5a6f9b94f --- /dev/null +++ b/quantum/plugins/sample/SamplePluginV2.py @@ -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 index 000000000..47d068e44 --- /dev/null +++ b/quantum/quantum_plugin_base_v2.py @@ -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": "", + "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 index 000000000..b7f8c5f87 --- /dev/null +++ b/quantum/tests/unit/test_api_v2.py @@ -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 index 000000000..f4a6670ef --- /dev/null +++ b/quantum/tests/unit/test_db_plugin.py @@ -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']) diff --git a/quantum/wsgi.py b/quantum/wsgi.py index 77ef3134d..c0e0aff6d 100644 --- a/quantum/wsgi.py +++ b/quantum/wsgi.py @@ -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""" diff --git a/tools/pip-requires b/tools/pip-requires index 05efffe7c..ea254aad5 100644 --- a/tools/pip-requires +++ b/tools/pip-requires @@ -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 diff --git a/tools/test-requires b/tools/test-requires index 8d3899115..67d40ade8 100644 --- a/tools/test-requires +++ b/tools/test-requires @@ -1,6 +1,6 @@ distribute>=0.6.24 coverage -mock>=0.7.1 +mock>=0.8 mox==0.5.3 nose nosexcover