diff --git a/neutron/api/v2/router.py b/neutron/api/v2/router.py index baa36a8a84c..64516a02b4d 100644 --- a/neutron/api/v2/router.py +++ b/neutron/api/v2/router.py @@ -28,6 +28,7 @@ from neutron.api import extensions from neutron.api.v2 import attributes from neutron.api.v2 import base from neutron import manager +from neutron.pecan_wsgi import app as pecan_app from neutron import policy from neutron.quota import resource_registry from neutron import wsgi @@ -70,6 +71,8 @@ class APIRouter(base_wsgi.Router): @classmethod def factory(cls, global_config, **local_config): + if cfg.CONF.web_framework == 'pecan': + return pecan_app.v2_factory(global_config, **local_config) return cls(**local_config) def __init__(self, **local_config): diff --git a/neutron/api/versions.py b/neutron/api/versions.py index 52d452c68df..5c85f9c6cdf 100644 --- a/neutron/api/versions.py +++ b/neutron/api/versions.py @@ -13,11 +13,13 @@ # License for the specific language governing permissions and limitations # under the License. +from oslo_config import cfg import oslo_i18n import webob.dec from neutron._i18n import _ from neutron.api.views import versions as versions_view +from neutron.pecan_wsgi import app as pecan_app from neutron import wsgi @@ -25,6 +27,8 @@ class Versions(object): @classmethod def factory(cls, global_config, **local_config): + if cfg.CONF.web_framework == 'pecan': + return pecan_app.versions_factory(global_config, **local_config) return cls(app=None) @webob.dec.wsgify(RequestClass=wsgi.Request) diff --git a/neutron/cmd/eventlet/server/__init__.py b/neutron/cmd/eventlet/server/__init__.py index a0cef174d49..32425fa6c85 100644 --- a/neutron/cmd/eventlet/server/__init__.py +++ b/neutron/cmd/eventlet/server/__init__.py @@ -10,23 +10,13 @@ # License for the specific language governing permissions and limitations # under the License. -from oslo_config import cfg - from neutron import server from neutron.server import rpc_eventlet from neutron.server import wsgi_eventlet -from neutron.server import wsgi_pecan def main(): - server.boot_server(_main_neutron_server) - - -def _main_neutron_server(): - if cfg.CONF.web_framework == 'legacy': - wsgi_eventlet.eventlet_wsgi_server() - else: - wsgi_pecan.pecan_wsgi_server() + server.boot_server(wsgi_eventlet.eventlet_wsgi_server) def main_rpc_eventlet(): diff --git a/neutron/pecan_wsgi/app.py b/neutron/pecan_wsgi/app.py index ff548a776f4..ffad7be7b61 100644 --- a/neutron/pecan_wsgi/app.py +++ b/neutron/pecan_wsgi/app.py @@ -13,37 +13,18 @@ # License for the specific language governing permissions and limitations # under the License. -from keystonemiddleware import auth_token -from neutron_lib import exceptions as n_exc -from oslo_config import cfg -from oslo_middleware import cors -from oslo_middleware import http_proxy_to_wsgi -from oslo_middleware import request_id import pecan -from neutron.api import versions +from neutron.pecan_wsgi.controllers import root from neutron.pecan_wsgi import hooks from neutron.pecan_wsgi import startup -CONF = cfg.CONF -CONF.import_opt('bind_host', 'neutron.conf.common') -CONF.import_opt('bind_port', 'neutron.conf.common') + +def versions_factory(global_config, **local_config): + return pecan.make_app(root.RootController()) -def setup_app(*args, **kwargs): - config = { - 'server': { - 'port': CONF.bind_port, - 'host': CONF.bind_host - }, - 'app': { - 'root': 'neutron.pecan_wsgi.controllers.root.RootController', - 'modules': ['neutron.pecan_wsgi'], - } - #TODO(kevinbenton): error templates - } - pecan_config = pecan.configuration.conf_from_dict(config) - +def v2_factory(global_config, **local_config): app_hooks = [ hooks.ExceptionTranslationHook(), # priority 100 hooks.ContextHook(), # priority 95 @@ -54,51 +35,10 @@ def setup_app(*args, **kwargs): hooks.QueryParametersHook(), # priority 139 hooks.PolicyHook(), # priority 140 ] - - app = pecan.make_app( - pecan_config.app.root, - debug=False, - wrap_app=_wrap_app, - force_canonical=False, - hooks=app_hooks, - guess_content_type_from_ext=True - ) + app = pecan.make_app(root.V2Controller(), + debug=False, + force_canonical=False, + hooks=app_hooks, + guess_content_type_from_ext=True) startup.initialize_all() - - return app - - -def _wrap_app(app): - app = request_id.RequestId(app) - if cfg.CONF.auth_strategy == 'noauth': - pass - elif cfg.CONF.auth_strategy == 'keystone': - app = auth_token.AuthProtocol(app, {}) - else: - raise n_exc.InvalidConfigurationOption( - opt_name='auth_strategy', opt_value=cfg.CONF.auth_strategy) - - # version can be unauthenticated so it goes outside of auth - app = versions.Versions(app) - - # handle cases where neutron-server is behind a proxy - app = http_proxy_to_wsgi.HTTPProxyToWSGI(app) - - # This should be the last middleware in the list (which results in - # it being the first in the middleware chain). This is to ensure - # that any errors thrown by other middleware, such as an auth - # middleware - are annotated with CORS headers, and thus accessible - # by the browser. - app = cors.CORS(app, cfg.CONF) - cors.set_defaults( - allow_headers=['X-Auth-Token', 'X-Identity-Status', 'X-Roles', - 'X-Service-Catalog', 'X-User-Id', 'X-Tenant-Id', - 'X-OpenStack-Request-ID', - 'X-Trace-Info', 'X-Trace-HMAC'], - allow_methods=['GET', 'PUT', 'POST', 'DELETE', 'PATCH'], - expose_headers=['X-Auth-Token', 'X-Subject-Token', 'X-Service-Token', - 'X-OpenStack-Request-ID', - 'X-Trace-Info', 'X-Trace-HMAC'] - ) - return app diff --git a/neutron/pecan_wsgi/controllers/extensions.py b/neutron/pecan_wsgi/controllers/extensions.py index 251b856df1d..e95b751d4c3 100644 --- a/neutron/pecan_wsgi/controllers/extensions.py +++ b/neutron/pecan_wsgi/controllers/extensions.py @@ -38,7 +38,9 @@ class ExtensionsController(object): @utils.when(index, method='HEAD') @utils.when(index, method='PATCH') def not_supported(self): - pecan.abort(405) + # NOTE(blogan): Normally we'd return 405 but the legacy extensions + # controller returned 404. + pecan.abort(404) class ExtensionController(object): @@ -62,4 +64,6 @@ class ExtensionController(object): @utils.when(index, method='HEAD') @utils.when(index, method='PATCH') def not_supported(self): - pecan.abort(405) + # NOTE(blogan): Normally we'd return 405 but the legacy extensions + # controller returned 404. + pecan.abort(404) diff --git a/neutron/pecan_wsgi/controllers/root.py b/neutron/pecan_wsgi/controllers/root.py index f1d87e3d7dd..5793f12c467 100644 --- a/neutron/pecan_wsgi/controllers/root.py +++ b/neutron/pecan_wsgi/controllers/root.py @@ -14,6 +14,7 @@ # License for the specific language governing permissions and limitations # under the License. +from oslo_config import cfg from oslo_log import log import pecan from pecan import request @@ -24,6 +25,9 @@ from neutron import manager from neutron.pecan_wsgi.controllers import extensions as ext_ctrl from neutron.pecan_wsgi.controllers import utils + +CONF = cfg.CONF + LOG = log.getLogger(__name__) _VERSION_INFO = {} @@ -41,12 +45,16 @@ class RootController(object): @utils.expose(generic=True) def index(self): - # NOTE(kevinbenton): The pecan framework does not handle - # any requests to the root because they are intercepted - # by the 'version' returning wrapper. - pass + version_objs = [ + { + "id": "v2.0", + "status": "CURRENT", + }, + ] + builder = versions_view.get_view_builder(pecan.request) + versions = [builder.build(version) for version in version_objs] + return dict(versions=versions) - @utils.when(index, method='GET') @utils.when(index, method='HEAD') @utils.when(index, method='POST') @utils.when(index, method='PATCH') @@ -66,6 +74,11 @@ class V2Controller(object): } _load_version_info(version_info) + # NOTE(blogan): Paste deploy handled the routing to the legacy extension + # controller. If the extensions filter is removed from the api-paste.ini + # then this controller will be routed to This means operators had + # the ability to turn off the extensions controller via tha api-paste but + # will not be able to turn it off with the pecan switch. extensions = ext_ctrl.ExtensionsController() @utils.expose(generic=True) @@ -112,8 +125,3 @@ class V2Controller(object): # with the uri_identifiers request.context['uri_identifiers'] = {} return controller, remainder - - -# This controller cannot be specified directly as a member of RootController -# as its path is not a valid python identifier -pecan.route(RootController, 'v2.0', V2Controller()) diff --git a/neutron/pecan_wsgi/hooks/context.py b/neutron/pecan_wsgi/hooks/context.py index ae38f6393ad..552f8fb886e 100644 --- a/neutron/pecan_wsgi/hooks/context.py +++ b/neutron/pecan_wsgi/hooks/context.py @@ -13,41 +13,13 @@ # License for the specific language governing permissions and limitations # under the License. -from oslo_middleware import request_id from pecan import hooks -from neutron import context - class ContextHook(hooks.PecanHook): - """Configures a request context and attaches it to the request. - The following HTTP request headers are used: - X-User-Id or X-User: - Used for context.user_id. - X-Project-Id: - Used for context.tenant_id. - X-Project-Name: - Used for context.tenant_name. - X-Auth-Token: - Used for context.auth_token. - X-Roles: - Used for setting context.is_admin flag to either True or False. - The flag is set to True, if X-Roles contains either an administrator - or admin substring. Otherwise it is set to False. - """ - + """Moves the request env's neutron.context into the requests context.""" priority = 95 def before(self, state): - user_name = state.request.headers.get('X-User-Name', '') - tenant_name = state.request.headers.get('X-Project-Name') - req_id = state.request.headers.get(request_id.ENV_REQUEST_ID) - # TODO(kevinbenton): is_admin logic - # Create a context with the authentication data - ctx = context.Context.from_environ(state.request.environ, - user_name=user_name, - tenant_name=tenant_name, - request_id=req_id) - - # Inject the context... + ctx = state.request.environ['neutron.context'] state.request.context['neutron_context'] = ctx diff --git a/neutron/pecan_wsgi/hooks/policy_enforcement.py b/neutron/pecan_wsgi/hooks/policy_enforcement.py index df1f741254c..0af23dd0989 100644 --- a/neutron/pecan_wsgi/hooks/policy_enforcement.py +++ b/neutron/pecan_wsgi/hooks/policy_enforcement.py @@ -183,11 +183,14 @@ class PolicyHook(hooks.PecanHook): policy_method(neutron_context, action, item, plugin=plugin, pluralized=collection))] - except oslo_policy.PolicyNotAuthorized as e: + except oslo_policy.PolicyNotAuthorized: # This exception must be explicitly caught as the exception # translation hook won't be called if an error occurs in the - # 'after' handler. - raise webob.exc.HTTPForbidden(str(e)) + # 'after' handler. Instead of raising an HTTPForbidden exception, + # we have to set the status_code here to prevent the catch_errors + # middleware from turning this into a 500. + state.response.status_code = 403 + return if is_single: resp = resp[0] diff --git a/neutron/pecan_wsgi/startup.py b/neutron/pecan_wsgi/startup.py index 9c021760eae..0b442990456 100644 --- a/neutron/pecan_wsgi/startup.py +++ b/neutron/pecan_wsgi/startup.py @@ -18,7 +18,6 @@ from neutron_lib.plugins import directory from neutron.api import extensions from neutron.api.v2 import attributes from neutron.api.v2 import base -from neutron.api.v2 import router from neutron import manager from neutron.pecan_wsgi.controllers import resource as res_ctrl from neutron.pecan_wsgi.controllers import utils @@ -26,6 +25,16 @@ from neutron import policy from neutron.quota import resource_registry +# NOTE(blogan): This currently already exists in neutron.api.v2.router but +# instead of importing that module and creating circular imports elsewhere, +# it's easier to just copy it here. The likelihood of it needing to be changed +# are slim to none. +RESOURCES = {'network': 'networks', + 'subnet': 'subnets', + 'subnetpool': 'subnetpools', + 'port': 'ports'} + + def initialize_all(): manager.init() ext_mgr = extensions.PluginAwareExtensionManager.get_instance() @@ -33,7 +42,7 @@ def initialize_all(): # At this stage we have a fully populated resource attribute map; # build Pecan controllers and routes for all core resources plugin = directory.get_plugin() - for resource, collection in router.RESOURCES.items(): + for resource, collection in RESOURCES.items(): resource_registry.register_resource_by_name(resource) new_controller = res_ctrl.CollectionsController(collection, resource, plugin=plugin) diff --git a/neutron/server/wsgi_pecan.py b/neutron/server/wsgi_pecan.py deleted file mode 100644 index 6f43ea8fe02..00000000000 --- a/neutron/server/wsgi_pecan.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -from oslo_log import log - -from neutron._i18n import _LI -from neutron.pecan_wsgi import app as pecan_app -from neutron.server import wsgi_eventlet -from neutron import service - -LOG = log.getLogger(__name__) - - -def pecan_wsgi_server(): - LOG.info(_LI("Pecan WSGI server starting...")) - application = pecan_app.setup_app() - neutron_api = service.run_wsgi_app(application) - wsgi_eventlet.start_api_and_rpc_workers(neutron_api) diff --git a/neutron/tests/etc/api-paste.ini b/neutron/tests/etc/api-paste.ini new file mode 100644 index 00000000000..1c98cfe3676 --- /dev/null +++ b/neutron/tests/etc/api-paste.ini @@ -0,0 +1,45 @@ +[composite:neutron] +use = egg:Paste#urlmap +/: neutronversions_composite +/v2.0: neutronapi_v2_0 + +[composite:neutronapi_v2_0] +use = call:neutron.auth:pipeline_factory +noauth = cors http_proxy_to_wsgi request_id catch_errors extensions neutronapiapp_v2_0 +keystone = cors http_proxy_to_wsgi request_id catch_errors authtoken keystonecontext extensions neutronapiapp_v2_0 + +[composite:neutronversions_composite] +use = call:neutron.auth:pipeline_factory +noauth = cors http_proxy_to_wsgi neutronversions +keystone = cors http_proxy_to_wsgi neutronversions + +[filter:request_id] +paste.filter_factory = oslo_middleware:RequestId.factory + +[filter:catch_errors] +paste.filter_factory = oslo_middleware:CatchErrors.factory + +[filter:cors] +paste.filter_factory = oslo_middleware.cors:filter_factory +oslo_config_project = neutron + +[filter:http_proxy_to_wsgi] +paste.filter_factory = oslo_middleware.http_proxy_to_wsgi:HTTPProxyToWSGI.factory + +[filter:keystonecontext] +paste.filter_factory = neutron.auth:NeutronKeystoneContext.factory + +[filter:authtoken] +paste.filter_factory = keystonemiddleware.auth_token:filter_factory + +[filter:extensions] +paste.filter_factory = neutron.api.extensions:plugin_aware_extension_middleware_factory + +[app:neutronversions] +paste.app_factory = neutron.api.versions:Versions.factory + +[app:neutronapiapp_v2_0] +paste.app_factory = neutron.api.v2.router:APIRouter.factory + +[filter:osprofiler] +paste.filter_factory = osprofiler.web:WsgiMiddleware.factory diff --git a/neutron/tests/functional/pecan_wsgi/test_controllers.py b/neutron/tests/functional/pecan_wsgi/test_controllers.py index edd482ee976..0187a8b1a42 100644 --- a/neutron/tests/functional/pecan_wsgi/test_controllers.py +++ b/neutron/tests/functional/pecan_wsgi/test_controllers.py @@ -81,11 +81,11 @@ class TestRootController(test_functional.PecanFunctionalTest): self.assertEqual(value, versions[0][attr]) def test_methods(self): - self._test_method_returns_code('post') - self._test_method_returns_code('patch') - self._test_method_returns_code('delete') - self._test_method_returns_code('head') - self._test_method_returns_code('put') + self._test_method_returns_code('post', 405) + self._test_method_returns_code('patch', 405) + self._test_method_returns_code('delete', 405) + self._test_method_returns_code('head', 405) + self._test_method_returns_code('put', 405) class TestV2Controller(TestRootController): @@ -146,12 +146,12 @@ class TestExtensionsController(TestRootController): self.assertEqual(test_alias, json_body['extension']['alias']) def test_methods(self): - self._test_method_returns_code('post', 405) - self._test_method_returns_code('put', 405) - self._test_method_returns_code('patch', 405) - self._test_method_returns_code('delete', 405) - self._test_method_returns_code('head', 405) - self._test_method_returns_code('delete', 405) + self._test_method_returns_code('post', 404) + self._test_method_returns_code('put', 404) + self._test_method_returns_code('patch', 404) + self._test_method_returns_code('delete', 404) + self._test_method_returns_code('head', 404) + self._test_method_returns_code('delete', 404) class TestQuotasController(test_functional.PecanFunctionalTest): diff --git a/neutron/tests/functional/pecan_wsgi/test_functional.py b/neutron/tests/functional/pecan_wsgi/test_functional.py index fd1ad4ef1fe..bc74ee9d5e6 100644 --- a/neutron/tests/functional/pecan_wsgi/test_functional.py +++ b/neutron/tests/functional/pecan_wsgi/test_functional.py @@ -19,23 +19,60 @@ import mock from neutron_lib import constants from neutron_lib import exceptions as n_exc from oslo_config import cfg +from oslo_middleware import base +from oslo_service import wsgi from oslo_utils import uuidutils -from pecan import set_config -from pecan.testing import load_test_app import testtools +import webob.dec +import webtest from neutron.api import extensions as exts +from neutron import context from neutron import manager +from neutron import tests from neutron.tests.unit import testlib_api +class InjectContext(base.ConfigurableMiddleware): + + @webob.dec.wsgify + def __call__(self, req): + user_id = req.headers.get('X_USER_ID', '') + + # Determine the tenant + tenant_id = req.headers.get('X_PROJECT_ID') + + # Suck out the roles + roles = [r.strip() for r in req.headers.get('X_ROLES', '').split(',')] + + # Human-friendly names + tenant_name = req.headers.get('X_PROJECT_NAME') + user_name = req.headers.get('X_USER_NAME') + + # Create a context with the authentication data + ctx = context.Context(user_id, tenant_id, roles=roles, + user_name=user_name, tenant_name=tenant_name) + req.environ['neutron.context'] = ctx + return self.application + + +def create_test_app(): + paste_config_loc = os.path.join(os.path.dirname(tests.__file__), 'etc', + 'api-paste.ini') + paste_config_loc = os.path.abspath(paste_config_loc) + cfg.CONF.set_override('api_paste_config', paste_config_loc) + loader = wsgi.Loader(cfg.CONF) + app = loader.load_app('neutron') + app = InjectContext(app) + return webtest.TestApp(app) + + class PecanFunctionalTest(testlib_api.SqlTestCase): def setUp(self, service_plugins=None, extensions=None): self.setup_coreplugin('ml2', load_plugins=False) super(PecanFunctionalTest, self).setUp() self.addCleanup(exts.PluginAwareExtensionManager.clear_instance) - self.addCleanup(set_config, {}, overwrite=True) self.set_config_overrides() manager.init() ext_mgr = exts.PluginAwareExtensionManager.get_instance() @@ -45,15 +82,10 @@ class PecanFunctionalTest(testlib_api.SqlTestCase): service_plugins[constants.CORE] = ext_mgr.plugins.get( constants.CORE) ext_mgr.plugins = service_plugins - self.setup_app() - - def setup_app(self): - self.app = load_test_app(os.path.join( - os.path.dirname(__file__), - 'config.py' - )) + self.app = create_test_app() def set_config_overrides(self): + cfg.CONF.set_override('web_framework', 'pecan') cfg.CONF.set_override('auth_strategy', 'noauth') def do_request(self, url, tenant_id=None, admin=False, @@ -109,8 +141,12 @@ class TestInvalidAuth(PecanFunctionalTest): def test_invalid_auth_strategy(self): cfg.CONF.set_override('auth_strategy', 'badvalue') - with testtools.ExpectedException(n_exc.InvalidConfigurationOption): - load_test_app(os.path.join(os.path.dirname(__file__), 'config.py')) + # NOTE(blogan): the auth.pipeline_factory will throw a KeyError + # with a bad value because that value is not the paste config. + # This KeyError is translated to a LookupError, which the oslo wsgi + # code translates into PasteAppNotFound. + with testtools.ExpectedException(wsgi.PasteAppNotFound): + create_test_app() class TestExceptionTranslationHook(PecanFunctionalTest): diff --git a/neutron/tests/unit/api/test_versions.py b/neutron/tests/unit/api/test_versions.py new file mode 100644 index 00000000000..edccbfceac9 --- /dev/null +++ b/neutron/tests/unit/api/test_versions.py @@ -0,0 +1,34 @@ +# 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 mock +from oslo_config import cfg + +from neutron.api import versions +from neutron.tests import base + + +@mock.patch('neutron.api.versions.Versions.__init__', return_value=None) +@mock.patch('neutron.pecan_wsgi.app.versions_factory') +class TestVersions(base.BaseTestCase): + + def test_legacy_factory(self, pecan_mock, legacy_mock): + cfg.CONF.set_override('web_framework', 'legacy') + versions.Versions.factory({}) + pecan_mock.assert_not_called() + legacy_mock.assert_called_once_with(app=None) + + def test_pecan_factory(self, pecan_mock, legacy_mock): + cfg.CONF.set_override('web_framework', 'pecan') + versions.Versions.factory({}) + pecan_mock.assert_called_once_with({}) + legacy_mock.assert_not_called() diff --git a/neutron/tests/unit/cmd/server/__init__.py b/neutron/tests/unit/api/v2/test_router.py similarity index 63% rename from neutron/tests/unit/cmd/server/__init__.py rename to neutron/tests/unit/api/v2/test_router.py index 45f87fceec1..33cecc3ae05 100644 --- a/neutron/tests/unit/cmd/server/__init__.py +++ b/neutron/tests/unit/api/v2/test_router.py @@ -13,22 +13,22 @@ import mock from oslo_config import cfg -from neutron.cmd.eventlet import server +from neutron.api.v2 import router from neutron.tests import base -@mock.patch('neutron.server.wsgi_eventlet.eventlet_wsgi_server') -@mock.patch('neutron.server.wsgi_pecan.pecan_wsgi_server') -class TestNeutronServer(base.BaseTestCase): +@mock.patch('neutron.api.v2.router.APIRouter.__init__', return_value=None) +@mock.patch('neutron.pecan_wsgi.app.v2_factory') +class TestRouter(base.BaseTestCase): - def test_legacy_server(self, pecan_mock, legacy_mock): + def test_legacy_factory(self, pecan_mock, legacy_mock): cfg.CONF.set_override('web_framework', 'legacy') - server._main_neutron_server() + router.APIRouter.factory({}) pecan_mock.assert_not_called() - legacy_mock.assert_called_with() + legacy_mock.assert_called_once_with() - def test_pecan_server(self, pecan_mock, legacy_mock): + def test_pecan_factory(self, pecan_mock, legacy_mock): cfg.CONF.set_override('web_framework', 'pecan') - server._main_neutron_server() - pecan_mock.assert_called_with() + router.APIRouter.factory({}) + pecan_mock.assert_called_once_with({}) legacy_mock.assert_not_called()