From 4e182a664ee650a20f6667fbce357db57069e169 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Tue, 5 Dec 2023 14:03:51 +0000 Subject: [PATCH] Remove nova.wsgi module We want this module for use elsewhere. Given there's only a single caller (nova.service) we can simply move the code to the caller. Change-Id: I2c3887db8b3f6833bf24f5114fd955e1af590d03 Signed-off-by: Stephen Finucane --- nova/api/openstack/common.py | 2 +- nova/api/openstack/compute/routes.py | 5 +- nova/service.py | 213 ++++++++++++++- nova/tests/unit/api/test_wsgi.py | 74 ++++-- nova/tests/unit/api/test_wsgi_loader.py | 38 +++ nova/tests/unit/test_service.py | 250 ++++++++++++++++++ nova/tests/unit/test_wsgi.py | 332 ------------------------ nova/wsgi.py | 225 ---------------- 8 files changed, 555 insertions(+), 584 deletions(-) create mode 100644 nova/tests/unit/api/test_wsgi_loader.py delete mode 100644 nova/tests/unit/test_wsgi.py delete mode 100644 nova/wsgi.py diff --git a/nova/api/openstack/common.py b/nova/api/openstack/common.py index 39780073ef52..c54cb8bfb894 100644 --- a/nova/api/openstack/common.py +++ b/nova/api/openstack/common.py @@ -153,7 +153,7 @@ def get_sort_params(input_params, default_key='created_at', The input parameters are not modified. :param input_params: webob.multidict of request parameters (from - nova.wsgi.Request.params) + nova.api.wsgi.Request.params) :param default_key: default sort key value, added to the list if no 'sort_key' parameters are supplied :param default_dir: default sort dir value, added to the list if no diff --git a/nova/api/openstack/compute/routes.py b/nova/api/openstack/compute/routes.py index d0df3a5875c4..7ffdc696e089 100644 --- a/nova/api/openstack/compute/routes.py +++ b/nova/api/openstack/compute/routes.py @@ -880,5 +880,8 @@ class APIRouterV21(base_wsgi.Router): @classmethod def factory(cls, global_config, **local_config): - """Simple paste factory, :class:`nova.wsgi.Router` doesn't have one.""" + """Simple paste factory. + + :class:`nova.api.wsgi.Router` doesn't have one. + """ return cls() diff --git a/nova/service.py b/nova/service.py index ddc6ec156615..6e04f991541e 100644 --- a/nova/service.py +++ b/nova/service.py @@ -18,13 +18,20 @@ """Generic Node base class for all workers that run on hosts.""" import os +import os.path import random +import socket +import ssl import sys +import eventlet +import eventlet.wsgi +import greenlet from oslo_concurrency import processutils from oslo_log import log as logging import oslo_messaging as messaging from oslo_service import service +from oslo_utils import excutils from oslo_utils import importutils from nova.api import wsgi as api_wsgi @@ -42,15 +49,12 @@ from nova import rpc from nova import servicegroup from nova import utils from nova import version -from nova import wsgi osprofiler = importutils.try_import("osprofiler") osprofiler_initializer = importutils.try_import("osprofiler.initializer") - -LOG = logging.getLogger(__name__) - CONF = nova.conf.CONF +LOG = logging.getLogger(__name__) SERVICE_MANAGERS = { 'nova-compute': 'nova.compute.manager.ComputeManager', @@ -323,6 +327,193 @@ class Service(service.Service): context.CELL_CACHE = {} +class WSGIServer(service.ServiceBase): + """Server class to manage a WSGI server, serving a WSGI application.""" + + default_pool_size = CONF.wsgi.default_pool_size + + def __init__(self, name, app, host='0.0.0.0', port=0, pool_size=None, + protocol=eventlet.wsgi.HttpProtocol, backlog=128, + use_ssl=False, max_url_len=None): + """Initialize, but do not start, a WSGI server. + + :param name: Pretty name for logging. + :param app: The WSGI application to serve. + :param host: IP address to serve the application. + :param port: Port number to server the application. + :param pool_size: Maximum number of eventlets to spawn concurrently. + :param backlog: Maximum number of queued connections. + :param max_url_len: Maximum length of permitted URLs. + :returns: None + :raises: nova.exception.InvalidInput + """ + # Allow operators to customize http requests max header line size. + eventlet.wsgi.MAX_HEADER_LINE = CONF.wsgi.max_header_line + self.name = name + self.app = app + self._server = None + self._protocol = protocol + self.pool_size = pool_size or self.default_pool_size + self._pool = eventlet.GreenPool(self.pool_size) + self._logger = logging.getLogger("nova.%s.wsgi.server" % self.name) + self._use_ssl = use_ssl + self._max_url_len = max_url_len + self.client_socket_timeout = CONF.wsgi.client_socket_timeout or None + + if backlog < 1: + raise exception.InvalidInput( + reason=_('The backlog must be more than 0')) + + bind_addr = (host, port) + # TODO(dims): eventlet's green dns/socket module does not actually + # support IPv6 in getaddrinfo(). We need to get around this in the + # future or monitor upstream for a fix + try: + info = socket.getaddrinfo(bind_addr[0], + bind_addr[1], + socket.AF_UNSPEC, + socket.SOCK_STREAM)[0] + family = info[0] + bind_addr = info[-1] + except Exception: + family = socket.AF_INET + + try: + self._socket = eventlet.listen(bind_addr, family, backlog=backlog) + except EnvironmentError: + LOG.error("Could not bind to %(host)s:%(port)s", + {'host': host, 'port': port}) + raise + + (self.host, self.port) = self._socket.getsockname()[0:2] + LOG.info("%(name)s listening on %(host)s:%(port)s", + {'name': self.name, 'host': self.host, 'port': self.port}) + + def start(self): + """Start serving a WSGI application. + + :returns: None + """ + # The server socket object will be closed after server exits, + # but the underlying file descriptor will remain open, and will + # give bad file descriptor error. So duplicating the socket object, + # to keep file descriptor usable. + + dup_socket = self._socket.dup() + dup_socket.setsockopt(socket.SOL_SOCKET, + socket.SO_REUSEADDR, 1) + # sockets can hang around forever without keepalive + dup_socket.setsockopt(socket.SOL_SOCKET, + socket.SO_KEEPALIVE, 1) + + # This option isn't available in the OS X version of eventlet + if hasattr(socket, 'TCP_KEEPIDLE'): + dup_socket.setsockopt(socket.IPPROTO_TCP, + socket.TCP_KEEPIDLE, + CONF.wsgi.tcp_keepidle) + + if self._use_ssl: + try: + ca_file = CONF.wsgi.ssl_ca_file + cert_file = CONF.wsgi.ssl_cert_file + key_file = CONF.wsgi.ssl_key_file + + if cert_file and not os.path.exists(cert_file): + raise RuntimeError( + _("Unable to find cert_file : %s") % cert_file) + + if ca_file and not os.path.exists(ca_file): + raise RuntimeError( + _("Unable to find ca_file : %s") % ca_file) + + if key_file and not os.path.exists(key_file): + raise RuntimeError( + _("Unable to find key_file : %s") % key_file) + + if self._use_ssl and (not cert_file or not key_file): + raise RuntimeError( + _("When running server in SSL mode, you must " + "specify both a cert_file and key_file " + "option value in your configuration file")) + ssl_kwargs = { + 'server_side': True, + 'certfile': cert_file, + 'keyfile': key_file, + 'cert_reqs': ssl.CERT_NONE, + } + + if CONF.wsgi.ssl_ca_file: + ssl_kwargs['ca_certs'] = ca_file + ssl_kwargs['cert_reqs'] = ssl.CERT_REQUIRED + + dup_socket = eventlet.wrap_ssl(dup_socket, + **ssl_kwargs) + except Exception: + with excutils.save_and_reraise_exception(): + LOG.error( + "Failed to start %(name)s on %(host)s:%(port)s with " + "SSL support", + {'name': self.name, 'host': self.host, + 'port': self.port}) + + wsgi_kwargs = { + 'func': eventlet.wsgi.server, + 'sock': dup_socket, + 'site': self.app, + 'protocol': self._protocol, + 'custom_pool': self._pool, + 'log': self._logger, + 'log_format': CONF.wsgi.wsgi_log_format, + 'debug': False, + 'keepalive': CONF.wsgi.keep_alive, + 'socket_timeout': self.client_socket_timeout + } + + if self._max_url_len: + wsgi_kwargs['url_length_limit'] = self._max_url_len + + self._server = utils.spawn(**wsgi_kwargs) + + def reset(self): + """Reset server greenpool size to default. + + :returns: None + + """ + self._pool.resize(self.pool_size) + + def stop(self): + """Stop this server. + + This is not a very nice action, as currently the method by which a + server is stopped is by killing its eventlet. + + :returns: None + + """ + LOG.info("Stopping WSGI server.") + + if self._server is not None: + # Resize pool to stop new requests from being processed + self._pool.resize(0) + self._server.kill() + + def wait(self): + """Block, until the server has stopped. + + Waits on the server's eventlet to finish, then returns. + + :returns: None + + """ + try: + if self._server is not None: + self._pool.waitall() + self._server.wait() + except greenlet.GreenletExit: + LOG.info("WSGI server has stopped.") + + class WSGIService(service.Service): """Provides ability to launch API from a 'paste' configuration.""" @@ -363,12 +554,14 @@ class WSGIService(service.Service): 'workers': str(self.workers)}) raise exception.InvalidInput(msg) self.use_ssl = use_ssl - self.server = wsgi.Server(name, - self.app, - host=self.host, - port=self.port, - use_ssl=self.use_ssl, - max_url_len=max_url_len) + self.server = WSGIServer( + name, + self.app, + host=self.host, + port=self.port, + use_ssl=self.use_ssl, + max_url_len=max_url_len, + ) # Pull back actual port used self.port = self.server.port self.backdoor_port = None diff --git a/nova/tests/unit/api/test_wsgi.py b/nova/tests/unit/api/test_wsgi.py index b8f215c73054..e73de39e5ad0 100644 --- a/nova/tests/unit/api/test_wsgi.py +++ b/nova/tests/unit/api/test_wsgi.py @@ -19,19 +19,17 @@ Test WSGI basics and provide some helper functions for other WSGI tests. """ -import sys -from unittest import mock +import tempfile import routes import webob -from nova.api.openstack import wsgi_app from nova.api import wsgi +import nova.exception from nova import test -from nova import utils -class Test(test.NoDBTestCase): +class TestRouter(test.NoDBTestCase): def test_router(self): @@ -55,14 +53,60 @@ class Test(test.NoDBTestCase): result = webob.Request.blank('/bad').get_response(Router()) self.assertNotEqual(result.body, "Router result") - @mock.patch('nova.api.openstack.wsgi_app._setup_service', new=mock.Mock()) - @mock.patch('paste.deploy.loadapp', new=mock.Mock()) - def test_init_application_passes_sys_argv_to_config(self): - with utils.temporary_mutation(sys, argv=mock.sentinel.argv): - with mock.patch('nova.config.parse_args') as mock_parse_args: - wsgi_app.init_application('test-app') - mock_parse_args.assert_called_once_with( - mock.sentinel.argv, - default_config_files=[ - '/etc/nova/api-paste.ini', '/etc/nova/nova.conf']) +class TestLoaderNothingExists(test.NoDBTestCase): + """Loader tests where os.path.exists always returns False.""" + + def setUp(self): + super(TestLoaderNothingExists, self).setUp() + self.stub_out('os.path.exists', lambda _: False) + + def test_relpath_config_not_found(self): + self.flags(api_paste_config='api-paste.ini', group='wsgi') + self.assertRaises( + nova.exception.ConfigNotFound, + wsgi.Loader, + ) + + def test_asbpath_config_not_found(self): + self.flags(api_paste_config='/etc/nova/api-paste.ini', group='wsgi') + self.assertRaises( + nova.exception.ConfigNotFound, + wsgi.Loader, + ) + + +class TestLoaderNormalFilesystem(test.NoDBTestCase): + """Loader tests with normal filesystem (unmodified os.path module).""" + + _paste_config = """ +[app:test_app] +use = egg:Paste#static +document_root = /tmp + """ + + def setUp(self): + super(TestLoaderNormalFilesystem, self).setUp() + self.config = tempfile.NamedTemporaryFile(mode="w+t") + self.config.write(self._paste_config.lstrip()) + self.config.seek(0) + self.config.flush() + self.loader = wsgi.Loader(self.config.name) + + def test_config_found(self): + self.assertEqual(self.config.name, self.loader.config_path) + + def test_app_not_found(self): + self.assertRaises( + nova.exception.PasteAppNotFound, + self.loader.load_app, + "nonexistent app", + ) + + def test_app_found(self): + url_parser = self.loader.load_app("test_app") + self.assertEqual("/tmp", url_parser.directory) + + def tearDown(self): + self.config.close() + super(TestLoaderNormalFilesystem, self).tearDown() diff --git a/nova/tests/unit/api/test_wsgi_loader.py b/nova/tests/unit/api/test_wsgi_loader.py new file mode 100644 index 000000000000..5d4c6a3c968b --- /dev/null +++ b/nova/tests/unit/api/test_wsgi_loader.py @@ -0,0 +1,38 @@ +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# Copyright 2010 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import sys +from unittest import mock + +from nova.api.openstack import wsgi_app +from nova import test +from nova import utils + + +class TestInitApplication(test.NoDBTestCase): + + @mock.patch('nova.api.openstack.wsgi_app._setup_service', new=mock.Mock()) + @mock.patch('paste.deploy.loadapp', new=mock.Mock()) + def test_init_application_passes_sys_argv_to_config(self): + + with utils.temporary_mutation(sys, argv=mock.sentinel.argv): + with mock.patch('nova.config.parse_args') as mock_parse_args: + wsgi_app.init_application('test-app') + mock_parse_args.assert_called_once_with( + mock.sentinel.argv, + default_config_files=[ + '/etc/nova/api-paste.ini', '/etc/nova/nova.conf']) diff --git a/nova/tests/unit/test_service.py b/nova/tests/unit/test_service.py index 9928708b7c00..dbfa2cd38e79 100644 --- a/nova/tests/unit/test_service.py +++ b/nova/tests/unit/test_service.py @@ -18,12 +18,18 @@ Unit Tests for remote procedure calls using queue """ +import os.path +import socket from unittest import mock +import eventlet +import eventlet.wsgi from oslo_concurrency import processutils from oslo_config import cfg from oslo_service import service as _service +import requests import testtools +import webob from nova import exception from nova import manager @@ -42,6 +48,9 @@ test_service_opts = [ default=0, help="Port number to bind test service to"), ] +SSL_CERT_DIR = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), 'ssl_cert') +) CONF = cfg.CONF CONF.register_opts(test_service_opts) @@ -314,6 +323,247 @@ class ServiceTestCase(test.NoDBTestCase): mock_check_old.assert_has_calls([mock.call(), mock.call()]) +class TestWSGIServer(test.NoDBTestCase): + """WSGI server tests.""" + + def test_no_app(self): + server = service.WSGIServer("test_app", None) + self.assertEqual("test_app", server.name) + + def test_custom_max_header_line(self): + self.flags(max_header_line=4096, group='wsgi') # Default is 16384 + service.WSGIServer("test_custom_max_header_line", None) + self.assertEqual(CONF.wsgi.max_header_line, + eventlet.wsgi.MAX_HEADER_LINE) + + def test_start_random_port(self): + server = service.WSGIServer( + "test_random_port", None, host="127.0.0.1", port=0) + server.start() + self.assertNotEqual(0, server.port) + server.stop() + server.wait() + + @testtools.skipIf(not utils.is_ipv6_supported(), "no ipv6 support") + def test_start_random_port_with_ipv6(self): + server = service.WSGIServer( + "test_random_port", None, + host="::1", port=0) + server.start() + self.assertEqual("::1", server.host) + self.assertNotEqual(0, server.port) + server.stop() + server.wait() + + @testtools.skipIf(not utils.is_linux(), 'SO_REUSEADDR behaves differently ' + 'on OSX and BSD, see bugs ' + '1436895 and 1467145') + def test_socket_options_for_simple_server(self): + # test normal socket options has set properly + self.flags(tcp_keepidle=500, group='wsgi') + server = service.WSGIServer( + "test_socket_options", None, host="127.0.0.1", port=0) + server.start() + sock = server._socket + self.assertEqual(1, sock.getsockopt(socket.SOL_SOCKET, + socket.SO_REUSEADDR)) + self.assertEqual(1, sock.getsockopt(socket.SOL_SOCKET, + socket.SO_KEEPALIVE)) + if hasattr(socket, 'TCP_KEEPIDLE'): + self.assertEqual(CONF.wsgi.tcp_keepidle, + sock.getsockopt(socket.IPPROTO_TCP, + socket.TCP_KEEPIDLE)) + server.stop() + server.wait() + + def test_server_pool_waitall(self): + # test pools waitall method gets called while stopping server + server = service.WSGIServer( + "test_server", None, host="127.0.0.1") + server.start() + with mock.patch.object(server._pool, + 'waitall') as mock_waitall: + server.stop() + server.wait() + mock_waitall.assert_called_once_with() + + def test_uri_length_limit(self): + server = service.WSGIServer( + "test_uri_length_limit", None, + host="127.0.0.1", max_url_len=16384) + server.start() + + uri = "http://127.0.0.1:%d/%s" % (server.port, 10000 * 'x') + resp = requests.get(uri, proxies={"http": ""}) + eventlet.sleep(0) + self.assertNotEqual(resp.status_code, + requests.codes.REQUEST_URI_TOO_LARGE) + + uri = "http://127.0.0.1:%d/%s" % (server.port, 20000 * 'x') + resp = requests.get(uri, proxies={"http": ""}) + eventlet.sleep(0) + self.assertEqual(resp.status_code, + requests.codes.REQUEST_URI_TOO_LARGE) + server.stop() + server.wait() + + def test_reset_pool_size_to_default(self): + server = service.WSGIServer( + "test_resize", None, + host="127.0.0.1", max_url_len=16384) + server.start() + + # Stopping the server, which in turn sets pool size to 0 + server.stop() + self.assertEqual(server._pool.size, 0) + + # Resetting pool size to default + server.reset() + server.start() + self.addCleanup(server.stop) + self.assertEqual(server._pool.size, CONF.wsgi.default_pool_size) + + def test_client_socket_timeout(self): + self.flags(client_socket_timeout=5, group='wsgi') + + # mocking eventlet spawn method to check it is called with + # configured 'client_socket_timeout' value. + with mock.patch('nova.utils.spawn') as mock_spawn: + server = service.WSGIServer( + "test_app", None, + host="127.0.0.1", port=0) + server.start() + self.addCleanup(server.stop) + _, kwargs = mock_spawn.call_args + self.assertEqual(CONF.wsgi.client_socket_timeout, + kwargs['socket_timeout']) + + def test_keep_alive(self): + self.flags(keep_alive=False, group='wsgi') + + # mocking eventlet spawn method to check it is called with + # configured 'keep_alive' value. + with mock.patch('nova.utils.spawn') as mock_spawn: + server = service.WSGIServer( + "test_app", None, + host="127.0.0.1", port=0) + server.start() + self.addCleanup(server.stop) + _, kwargs = mock_spawn.call_args + self.assertEqual(CONF.wsgi.keep_alive, + kwargs['keepalive']) + + +@testtools.skip("bug/1482633: test hangs on Python 3") +class TestWSGIServerWithSSL(test.NoDBTestCase): + """WSGI server with SSL tests.""" + + def setUp(self): + super(TestWSGIServerWithSSL, self).setUp() + self.flags(enabled_ssl_apis=['fake_ssl']) + self.flags( + ssl_cert_file=os.path.join(SSL_CERT_DIR, 'certificate.crt'), + ssl_key_file=os.path.join(SSL_CERT_DIR, 'privatekey.key'), + group='wsgi') + + def test_ssl_server(self): + + def test_app(env, start_response): + start_response('200 OK', {}) + return ['PONG'] + + fake_ssl_server = service.WSGIServer( + "fake_ssl", test_app, + host="127.0.0.1", port=0, + use_ssl=True) + fake_ssl_server.start() + self.assertNotEqual(0, fake_ssl_server.port) + + response = requests.post( + 'https://127.0.0.1:%s/' % fake_ssl_server.port, + verify=os.path.join(SSL_CERT_DIR, 'ca.crt'), data='PING') + self.assertEqual(response.text, 'PONG') + + fake_ssl_server.stop() + fake_ssl_server.wait() + + def test_two_servers(self): + + def test_app(env, start_response): + start_response('200 OK', {}) + return ['PONG'] + + fake_ssl_server = service.WSGIServer( + "fake_ssl", test_app, + host="127.0.0.1", port=0, use_ssl=True) + fake_ssl_server.start() + self.assertNotEqual(0, fake_ssl_server.port) + + fake_server = service.WSGIServer( + "fake", test_app, + host="127.0.0.1", port=0) + fake_server.start() + self.assertNotEqual(0, fake_server.port) + + response = requests.post( + 'https://127.0.0.1:%s/' % fake_ssl_server.port, + verify=os.path.join(SSL_CERT_DIR, 'ca.crt'), data='PING') + self.assertEqual(response.text, 'PONG') + + response = requests.post('http://127.0.0.1:%s/' % fake_server.port, + data='PING') + self.assertEqual(response.text, 'PONG') + + fake_ssl_server.stop() + fake_ssl_server.wait() + fake_server.stop() + fake_server.wait() + + @testtools.skipIf(not utils.is_linux(), 'SO_REUSEADDR behaves differently ' + 'on OSX and BSD, see bugs ' + '1436895 and 1467145') + def test_socket_options_for_ssl_server(self): + # test normal socket options has set properly + self.flags(tcp_keepidle=500, group='wsgi') + server = service.WSGIServer( + "test_socket_options", None, + host="127.0.0.1", port=0, + use_ssl=True) + server.start() + sock = server._socket + self.assertEqual(1, sock.getsockopt(socket.SOL_SOCKET, + socket.SO_REUSEADDR)) + self.assertEqual(1, sock.getsockopt(socket.SOL_SOCKET, + socket.SO_KEEPALIVE)) + if hasattr(socket, 'TCP_KEEPIDLE'): + self.assertEqual(CONF.wsgi.tcp_keepidle, + sock.getsockopt(socket.IPPROTO_TCP, + socket.TCP_KEEPIDLE)) + server.stop() + server.wait() + + @testtools.skipIf(not utils.is_ipv6_supported(), "no ipv6 support") + def test_app_using_ipv6_and_ssl(self): + greetings = 'Hello, World!!!' + + @webob.dec.wsgify + def hello_world(req): + return greetings + + server = service.WSGIServer( + "fake_ssl", hello_world, + host="::1", port=0, use_ssl=True) + + server.start() + + response = requests.get('https://[::1]:%d/' % server.port, + verify=os.path.join(SSL_CERT_DIR, 'ca.crt')) + self.assertEqual(greetings, response.text) + + server.stop() + server.wait() + + class TestWSGIService(test.NoDBTestCase): def setUp(self): diff --git a/nova/tests/unit/test_wsgi.py b/nova/tests/unit/test_wsgi.py deleted file mode 100644 index 24cc22667beb..000000000000 --- a/nova/tests/unit/test_wsgi.py +++ /dev/null @@ -1,332 +0,0 @@ -# Copyright 2011 United States Government as represented by the -# Administrator of the National Aeronautics and Space Administration. -# 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. - -"""Unit tests for `nova.wsgi`.""" - -import os.path -import socket -import tempfile -from unittest import mock - -import eventlet -import eventlet.wsgi -from oslo_config import cfg -import requests -import testtools -import webob - -import nova.api.wsgi -import nova.exception -from nova import test -from nova.tests.unit import utils -import nova.wsgi - -SSL_CERT_DIR = os.path.normpath(os.path.join( - os.path.dirname(os.path.abspath(__file__)), - 'ssl_cert')) -CONF = cfg.CONF - - -class TestLoaderNothingExists(test.NoDBTestCase): - """Loader tests where os.path.exists always returns False.""" - - def setUp(self): - super(TestLoaderNothingExists, self).setUp() - self.stub_out('os.path.exists', lambda _: False) - - def test_relpath_config_not_found(self): - self.flags(api_paste_config='api-paste.ini', group='wsgi') - self.assertRaises( - nova.exception.ConfigNotFound, - nova.api.wsgi.Loader, - ) - - def test_asbpath_config_not_found(self): - self.flags(api_paste_config='/etc/nova/api-paste.ini', group='wsgi') - self.assertRaises( - nova.exception.ConfigNotFound, - nova.api.wsgi.Loader, - ) - - -class TestLoaderNormalFilesystem(test.NoDBTestCase): - """Loader tests with normal filesystem (unmodified os.path module).""" - - _paste_config = """ -[app:test_app] -use = egg:Paste#static -document_root = /tmp - """ - - def setUp(self): - super(TestLoaderNormalFilesystem, self).setUp() - self.config = tempfile.NamedTemporaryFile(mode="w+t") - self.config.write(self._paste_config.lstrip()) - self.config.seek(0) - self.config.flush() - self.loader = nova.api.wsgi.Loader(self.config.name) - - def test_config_found(self): - self.assertEqual(self.config.name, self.loader.config_path) - - def test_app_not_found(self): - self.assertRaises( - nova.exception.PasteAppNotFound, - self.loader.load_app, - "nonexistent app", - ) - - def test_app_found(self): - url_parser = self.loader.load_app("test_app") - self.assertEqual("/tmp", url_parser.directory) - - def tearDown(self): - self.config.close() - super(TestLoaderNormalFilesystem, self).tearDown() - - -class TestWSGIServer(test.NoDBTestCase): - """WSGI server tests.""" - - def test_no_app(self): - server = nova.wsgi.Server("test_app", None) - self.assertEqual("test_app", server.name) - - def test_custom_max_header_line(self): - self.flags(max_header_line=4096, group='wsgi') # Default is 16384 - nova.wsgi.Server("test_custom_max_header_line", None) - self.assertEqual(CONF.wsgi.max_header_line, - eventlet.wsgi.MAX_HEADER_LINE) - - def test_start_random_port(self): - server = nova.wsgi.Server("test_random_port", None, - host="127.0.0.1", port=0) - server.start() - self.assertNotEqual(0, server.port) - server.stop() - server.wait() - - @testtools.skipIf(not utils.is_ipv6_supported(), "no ipv6 support") - def test_start_random_port_with_ipv6(self): - server = nova.wsgi.Server("test_random_port", None, - host="::1", port=0) - server.start() - self.assertEqual("::1", server.host) - self.assertNotEqual(0, server.port) - server.stop() - server.wait() - - @testtools.skipIf(not utils.is_linux(), 'SO_REUSEADDR behaves differently ' - 'on OSX and BSD, see bugs ' - '1436895 and 1467145') - def test_socket_options_for_simple_server(self): - # test normal socket options has set properly - self.flags(tcp_keepidle=500, group='wsgi') - server = nova.wsgi.Server("test_socket_options", None, - host="127.0.0.1", port=0) - server.start() - sock = server._socket - self.assertEqual(1, sock.getsockopt(socket.SOL_SOCKET, - socket.SO_REUSEADDR)) - self.assertEqual(1, sock.getsockopt(socket.SOL_SOCKET, - socket.SO_KEEPALIVE)) - if hasattr(socket, 'TCP_KEEPIDLE'): - self.assertEqual(CONF.wsgi.tcp_keepidle, - sock.getsockopt(socket.IPPROTO_TCP, - socket.TCP_KEEPIDLE)) - server.stop() - server.wait() - - def test_server_pool_waitall(self): - # test pools waitall method gets called while stopping server - server = nova.wsgi.Server("test_server", None, - host="127.0.0.1") - server.start() - with mock.patch.object(server._pool, - 'waitall') as mock_waitall: - server.stop() - server.wait() - mock_waitall.assert_called_once_with() - - def test_uri_length_limit(self): - server = nova.wsgi.Server("test_uri_length_limit", None, - host="127.0.0.1", max_url_len=16384) - server.start() - - uri = "http://127.0.0.1:%d/%s" % (server.port, 10000 * 'x') - resp = requests.get(uri, proxies={"http": ""}) - eventlet.sleep(0) - self.assertNotEqual(resp.status_code, - requests.codes.REQUEST_URI_TOO_LARGE) - - uri = "http://127.0.0.1:%d/%s" % (server.port, 20000 * 'x') - resp = requests.get(uri, proxies={"http": ""}) - eventlet.sleep(0) - self.assertEqual(resp.status_code, - requests.codes.REQUEST_URI_TOO_LARGE) - server.stop() - server.wait() - - def test_reset_pool_size_to_default(self): - server = nova.wsgi.Server("test_resize", None, - host="127.0.0.1", max_url_len=16384) - server.start() - - # Stopping the server, which in turn sets pool size to 0 - server.stop() - self.assertEqual(server._pool.size, 0) - - # Resetting pool size to default - server.reset() - server.start() - self.addCleanup(server.stop) - self.assertEqual(server._pool.size, CONF.wsgi.default_pool_size) - - def test_client_socket_timeout(self): - self.flags(client_socket_timeout=5, group='wsgi') - - # mocking eventlet spawn method to check it is called with - # configured 'client_socket_timeout' value. - with mock.patch('nova.utils.spawn') as mock_spawn: - server = nova.wsgi.Server("test_app", None, - host="127.0.0.1", port=0) - server.start() - self.addCleanup(server.stop) - _, kwargs = mock_spawn.call_args - self.assertEqual(CONF.wsgi.client_socket_timeout, - kwargs['socket_timeout']) - - def test_keep_alive(self): - self.flags(keep_alive=False, group='wsgi') - - # mocking eventlet spawn method to check it is called with - # configured 'keep_alive' value. - with mock.patch('nova.utils.spawn') as mock_spawn: - server = nova.wsgi.Server("test_app", None, - host="127.0.0.1", port=0) - server.start() - self.addCleanup(server.stop) - _, kwargs = mock_spawn.call_args - self.assertEqual(CONF.wsgi.keep_alive, - kwargs['keepalive']) - - -@testtools.skip("bug/1482633: test hangs on Python 3") -class TestWSGIServerWithSSL(test.NoDBTestCase): - """WSGI server with SSL tests.""" - - def setUp(self): - super(TestWSGIServerWithSSL, self).setUp() - self.flags(enabled_ssl_apis=['fake_ssl']) - self.flags( - ssl_cert_file=os.path.join(SSL_CERT_DIR, 'certificate.crt'), - ssl_key_file=os.path.join(SSL_CERT_DIR, 'privatekey.key'), - group='wsgi') - - def test_ssl_server(self): - - def test_app(env, start_response): - start_response('200 OK', {}) - return ['PONG'] - - fake_ssl_server = nova.wsgi.Server("fake_ssl", test_app, - host="127.0.0.1", port=0, - use_ssl=True) - fake_ssl_server.start() - self.assertNotEqual(0, fake_ssl_server.port) - - response = requests.post( - 'https://127.0.0.1:%s/' % fake_ssl_server.port, - verify=os.path.join(SSL_CERT_DIR, 'ca.crt'), data='PING') - self.assertEqual(response.text, 'PONG') - - fake_ssl_server.stop() - fake_ssl_server.wait() - - def test_two_servers(self): - - def test_app(env, start_response): - start_response('200 OK', {}) - return ['PONG'] - - fake_ssl_server = nova.wsgi.Server("fake_ssl", test_app, - host="127.0.0.1", port=0, use_ssl=True) - fake_ssl_server.start() - self.assertNotEqual(0, fake_ssl_server.port) - - fake_server = nova.wsgi.Server("fake", test_app, - host="127.0.0.1", port=0) - fake_server.start() - self.assertNotEqual(0, fake_server.port) - - response = requests.post( - 'https://127.0.0.1:%s/' % fake_ssl_server.port, - verify=os.path.join(SSL_CERT_DIR, 'ca.crt'), data='PING') - self.assertEqual(response.text, 'PONG') - - response = requests.post('http://127.0.0.1:%s/' % fake_server.port, - data='PING') - self.assertEqual(response.text, 'PONG') - - fake_ssl_server.stop() - fake_ssl_server.wait() - fake_server.stop() - fake_server.wait() - - @testtools.skipIf(not utils.is_linux(), 'SO_REUSEADDR behaves differently ' - 'on OSX and BSD, see bugs ' - '1436895 and 1467145') - def test_socket_options_for_ssl_server(self): - # test normal socket options has set properly - self.flags(tcp_keepidle=500, group='wsgi') - server = nova.wsgi.Server("test_socket_options", None, - host="127.0.0.1", port=0, - use_ssl=True) - server.start() - sock = server._socket - self.assertEqual(1, sock.getsockopt(socket.SOL_SOCKET, - socket.SO_REUSEADDR)) - self.assertEqual(1, sock.getsockopt(socket.SOL_SOCKET, - socket.SO_KEEPALIVE)) - if hasattr(socket, 'TCP_KEEPIDLE'): - self.assertEqual(CONF.wsgi.tcp_keepidle, - sock.getsockopt(socket.IPPROTO_TCP, - socket.TCP_KEEPIDLE)) - server.stop() - server.wait() - - @testtools.skipIf(not utils.is_ipv6_supported(), "no ipv6 support") - def test_app_using_ipv6_and_ssl(self): - greetings = 'Hello, World!!!' - - @webob.dec.wsgify - def hello_world(req): - return greetings - - server = nova.wsgi.Server("fake_ssl", - hello_world, - host="::1", - port=0, - use_ssl=True) - - server.start() - - response = requests.get('https://[::1]:%d/' % server.port, - verify=os.path.join(SSL_CERT_DIR, 'ca.crt')) - self.assertEqual(greetings, response.text) - - server.stop() - server.wait() diff --git a/nova/wsgi.py b/nova/wsgi.py deleted file mode 100644 index d40b324db482..000000000000 --- a/nova/wsgi.py +++ /dev/null @@ -1,225 +0,0 @@ -# Copyright 2010 United States Government as represented by the -# Administrator of the National Aeronautics and Space Administration. -# Copyright 2010 OpenStack Foundation -# 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.""" - -import os.path -import socket -import ssl - -import eventlet -import eventlet.wsgi -import greenlet -from oslo_log import log as logging -from oslo_service import service -from oslo_utils import excutils - -import nova.conf -from nova import exception -from nova.i18n import _ -from nova import utils - -CONF = nova.conf.CONF - -LOG = logging.getLogger(__name__) - - -class Server(service.ServiceBase): - """Server class to manage a WSGI server, serving a WSGI application.""" - - default_pool_size = CONF.wsgi.default_pool_size - - def __init__(self, name, app, host='0.0.0.0', port=0, pool_size=None, - protocol=eventlet.wsgi.HttpProtocol, backlog=128, - use_ssl=False, max_url_len=None): - """Initialize, but do not start, a WSGI server. - - :param name: Pretty name for logging. - :param app: The WSGI application to serve. - :param host: IP address to serve the application. - :param port: Port number to server the application. - :param pool_size: Maximum number of eventlets to spawn concurrently. - :param backlog: Maximum number of queued connections. - :param max_url_len: Maximum length of permitted URLs. - :returns: None - :raises: nova.exception.InvalidInput - """ - # Allow operators to customize http requests max header line size. - eventlet.wsgi.MAX_HEADER_LINE = CONF.wsgi.max_header_line - self.name = name - self.app = app - self._server = None - self._protocol = protocol - self.pool_size = pool_size or self.default_pool_size - self._pool = eventlet.GreenPool(self.pool_size) - self._logger = logging.getLogger("nova.%s.wsgi.server" % self.name) - self._use_ssl = use_ssl - self._max_url_len = max_url_len - self.client_socket_timeout = CONF.wsgi.client_socket_timeout or None - - if backlog < 1: - raise exception.InvalidInput( - reason=_('The backlog must be more than 0')) - - bind_addr = (host, port) - # TODO(dims): eventlet's green dns/socket module does not actually - # support IPv6 in getaddrinfo(). We need to get around this in the - # future or monitor upstream for a fix - try: - info = socket.getaddrinfo(bind_addr[0], - bind_addr[1], - socket.AF_UNSPEC, - socket.SOCK_STREAM)[0] - family = info[0] - bind_addr = info[-1] - except Exception: - family = socket.AF_INET - - try: - self._socket = eventlet.listen(bind_addr, family, backlog=backlog) - except EnvironmentError: - LOG.error("Could not bind to %(host)s:%(port)s", - {'host': host, 'port': port}) - raise - - (self.host, self.port) = self._socket.getsockname()[0:2] - LOG.info("%(name)s listening on %(host)s:%(port)s", - {'name': self.name, 'host': self.host, 'port': self.port}) - - def start(self): - """Start serving a WSGI application. - - :returns: None - """ - # The server socket object will be closed after server exits, - # but the underlying file descriptor will remain open, and will - # give bad file descriptor error. So duplicating the socket object, - # to keep file descriptor usable. - - dup_socket = self._socket.dup() - dup_socket.setsockopt(socket.SOL_SOCKET, - socket.SO_REUSEADDR, 1) - # sockets can hang around forever without keepalive - dup_socket.setsockopt(socket.SOL_SOCKET, - socket.SO_KEEPALIVE, 1) - - # This option isn't available in the OS X version of eventlet - if hasattr(socket, 'TCP_KEEPIDLE'): - dup_socket.setsockopt(socket.IPPROTO_TCP, - socket.TCP_KEEPIDLE, - CONF.wsgi.tcp_keepidle) - - if self._use_ssl: - try: - ca_file = CONF.wsgi.ssl_ca_file - cert_file = CONF.wsgi.ssl_cert_file - key_file = CONF.wsgi.ssl_key_file - - if cert_file and not os.path.exists(cert_file): - raise RuntimeError( - _("Unable to find cert_file : %s") % cert_file) - - if ca_file and not os.path.exists(ca_file): - raise RuntimeError( - _("Unable to find ca_file : %s") % ca_file) - - if key_file and not os.path.exists(key_file): - raise RuntimeError( - _("Unable to find key_file : %s") % key_file) - - if self._use_ssl and (not cert_file or not key_file): - raise RuntimeError( - _("When running server in SSL mode, you must " - "specify both a cert_file and key_file " - "option value in your configuration file")) - ssl_kwargs = { - 'server_side': True, - 'certfile': cert_file, - 'keyfile': key_file, - 'cert_reqs': ssl.CERT_NONE, - } - - if CONF.wsgi.ssl_ca_file: - ssl_kwargs['ca_certs'] = ca_file - ssl_kwargs['cert_reqs'] = ssl.CERT_REQUIRED - - dup_socket = eventlet.wrap_ssl(dup_socket, - **ssl_kwargs) - except Exception: - with excutils.save_and_reraise_exception(): - LOG.error( - "Failed to start %(name)s on %(host)s:%(port)s with " - "SSL support", - {'name': self.name, 'host': self.host, - 'port': self.port}) - - wsgi_kwargs = { - 'func': eventlet.wsgi.server, - 'sock': dup_socket, - 'site': self.app, - 'protocol': self._protocol, - 'custom_pool': self._pool, - 'log': self._logger, - 'log_format': CONF.wsgi.wsgi_log_format, - 'debug': False, - 'keepalive': CONF.wsgi.keep_alive, - 'socket_timeout': self.client_socket_timeout - } - - if self._max_url_len: - wsgi_kwargs['url_length_limit'] = self._max_url_len - - self._server = utils.spawn(**wsgi_kwargs) - - def reset(self): - """Reset server greenpool size to default. - - :returns: None - - """ - self._pool.resize(self.pool_size) - - def stop(self): - """Stop this server. - - This is not a very nice action, as currently the method by which a - server is stopped is by killing its eventlet. - - :returns: None - - """ - LOG.info("Stopping WSGI server.") - - if self._server is not None: - # Resize pool to stop new requests from being processed - self._pool.resize(0) - self._server.kill() - - def wait(self): - """Block, until the server has stopped. - - Waits on the server's eventlet to finish, then returns. - - :returns: None - - """ - try: - if self._server is not None: - self._pool.waitall() - self._server.wait() - except greenlet.GreenletExit: - LOG.info("WSGI server has stopped.")