Merge "Eventlet: Migrate API & JSON-RPC to cheroot"
This commit is contained in:
@ -10,11 +10,15 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import socket
|
||||
import os
|
||||
import threading
|
||||
|
||||
from cheroot.ssl import builtin as cheroot_ssl
|
||||
from cheroot import wsgi
|
||||
from oslo_concurrency import processutils
|
||||
from oslo_log import log as logging
|
||||
from oslo_service import service
|
||||
from oslo_service import wsgi
|
||||
from oslo_service import sslutils
|
||||
|
||||
from ironic.api import app
|
||||
from ironic.common import exception
|
||||
@ -23,9 +27,22 @@ from ironic.common import utils
|
||||
from ironic.conf import CONF
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
_MAX_DEFAULT_WORKERS = 4
|
||||
|
||||
|
||||
def validate_cert_paths(cert_file, key_file):
|
||||
if cert_file and not os.path.exists(cert_file):
|
||||
raise RuntimeError(_("Unable to find cert_file: %s") % cert_file)
|
||||
if key_file and not os.path.exists(key_file):
|
||||
raise RuntimeError(_("Unable to find key_file: %s") % key_file)
|
||||
|
||||
if not cert_file or not key_file:
|
||||
raise RuntimeError(_("When running server in SSL mode, you must "
|
||||
"specify a valid cert_file and key_file "
|
||||
"paths in your configuration file"))
|
||||
|
||||
|
||||
class BaseWSGIService(service.ServiceBase):
|
||||
|
||||
def __init__(self, name, app, conf, use_ssl=None):
|
||||
@ -41,48 +58,89 @@ class BaseWSGIService(service.ServiceBase):
|
||||
self._conf = conf
|
||||
if use_ssl is None:
|
||||
use_ssl = conf.use_ssl
|
||||
|
||||
socket_mode = None
|
||||
bind_addr = (conf.host_ip, conf.port)
|
||||
if conf.unix_socket:
|
||||
utils.unlink_without_raise(conf.unix_socket)
|
||||
self.server = wsgi.Server(CONF, name, app,
|
||||
socket_family=socket.AF_UNIX,
|
||||
socket_file=conf.unix_socket,
|
||||
socket_mode=conf.unix_socket_mode,
|
||||
use_ssl=use_ssl)
|
||||
else:
|
||||
self.server = wsgi.Server(CONF, name, app,
|
||||
host=conf.host_ip,
|
||||
port=conf.port,
|
||||
use_ssl=use_ssl)
|
||||
bind_addr = conf.unix_socket
|
||||
socket_mode = conf.unix_socket_mode
|
||||
|
||||
self.server = wsgi.Server(
|
||||
bind_addr=bind_addr,
|
||||
wsgi_app=app,
|
||||
server_name=name)
|
||||
|
||||
if use_ssl:
|
||||
cert_file = getattr(conf, "cert_file", None)
|
||||
key_file = getattr(conf, "key_file", None)
|
||||
|
||||
if not (cert_file and key_file):
|
||||
LOG.warning(
|
||||
"Falling back to deprecated [ssl] group for TLS "
|
||||
"credentials: the global [ssl] configuration block is "
|
||||
"deprecated and will be removed in 2026.1"
|
||||
)
|
||||
|
||||
# Register global SSL config options and validate the
|
||||
# existence of configured certificate/private key file paths,
|
||||
# when in secure mode.
|
||||
sslutils.is_enabled(CONF)
|
||||
cert_file = CONF.ssl.cert_file
|
||||
key_file = CONF.ssl.key_file
|
||||
|
||||
validate_cert_paths(cert_file, key_file)
|
||||
|
||||
self.server.ssl_adapter = cheroot_ssl.BuiltinSSLAdapter(
|
||||
certificate=cert_file,
|
||||
private_key=key_file,
|
||||
)
|
||||
|
||||
self._unix_socket = conf.unix_socket
|
||||
self._socket_mode = socket_mode
|
||||
self._thread = None
|
||||
|
||||
def start(self):
|
||||
"""Start serving this service using loaded configuration.
|
||||
|
||||
:returns: None
|
||||
"""
|
||||
self.server.start()
|
||||
self.server.prepare()
|
||||
|
||||
if self._unix_socket and self._socket_mode is not None:
|
||||
os.chmod(self._unix_socket, self._socket_mode)
|
||||
|
||||
self._thread = threading.Thread(
|
||||
target=self.server.serve,
|
||||
daemon=True
|
||||
)
|
||||
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""Stop serving this API.
|
||||
|
||||
:returns: None
|
||||
"""
|
||||
self.server.stop()
|
||||
if self._conf.unix_socket:
|
||||
utils.unlink_without_raise(self._conf.unix_socket)
|
||||
if self.server:
|
||||
self.server.stop()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2)
|
||||
|
||||
if self._unix_socket:
|
||||
utils.unlink_without_raise(self._unix_socket)
|
||||
|
||||
def wait(self):
|
||||
"""Wait for the service to stop serving this API.
|
||||
|
||||
:returns: None
|
||||
"""
|
||||
self.server.wait()
|
||||
if self._thread:
|
||||
self._thread.join()
|
||||
|
||||
def reset(self):
|
||||
"""Reset server greenpool size to default.
|
||||
|
||||
:returns: None
|
||||
"""
|
||||
self.server.reset()
|
||||
"""No server greenpools to resize."""
|
||||
pass
|
||||
|
||||
|
||||
class WSGIService(BaseWSGIService):
|
||||
|
@ -103,6 +103,12 @@ opts = [
|
||||
mutable=True,
|
||||
help=_("Specifies a list of boot modes that are not allowed "
|
||||
"during enrollment. Eg: ['bios']")),
|
||||
cfg.StrOpt('cert_file',
|
||||
help="Certificate file to use when starting "
|
||||
"the server securely."),
|
||||
cfg.StrOpt('key_file',
|
||||
help="Private key file to use when starting "
|
||||
"the server securely."),
|
||||
]
|
||||
|
||||
opt_group = cfg.OptGroup(name='api',
|
||||
|
@ -43,9 +43,14 @@ opts = [
|
||||
cfg.BoolOpt('use_ssl',
|
||||
default=False,
|
||||
help=_('Whether to use TLS for JSON RPC')),
|
||||
cfg.StrOpt('cert_file',
|
||||
help=_("Certificate file the JSON-RPC listener will present "
|
||||
"to clients when [json_rpc]use_ssl=True.")),
|
||||
cfg.StrOpt('key_file',
|
||||
help=_("Private key file matching cert_file.")),
|
||||
cfg.BoolOpt('client_use_ssl',
|
||||
default=False,
|
||||
help=_('Set to True for force TLS connections in the client '
|
||||
help=_('Set to True to force TLS connections in the client '
|
||||
'even if use_ssl is set to False. Only makes sense '
|
||||
'if server-side TLS is provided outside of Ironic '
|
||||
'(e.g. with httpd acting as a reverse proxy).')),
|
||||
|
@ -84,7 +84,10 @@ class TestService(TestCase):
|
||||
super(TestService, self).setUp()
|
||||
self.config(auth_strategy='noauth', group='json_rpc')
|
||||
self.server_mock = self.useFixture(fixtures.MockPatch(
|
||||
'oslo_service.wsgi.Server', autospec=True)).mock
|
||||
'cheroot.wsgi.Server', autospec=True)).mock
|
||||
|
||||
server_instance = self.server_mock.return_value
|
||||
server_instance.requests = mock.MagicMock()
|
||||
|
||||
self.serializer = FakeSerializer()
|
||||
self.service = server.WSGIService(FakeManager(), self.serializer,
|
||||
@ -140,7 +143,7 @@ class TestService(TestCase):
|
||||
# self.config(http_basic_password='myPassword', group='json_rpc')
|
||||
self.service = server.WSGIService(FakeManager(), self.serializer,
|
||||
FakeContext)
|
||||
self.app = self.server_mock.call_args[0][2]
|
||||
self.app = self.server_mock.call_args.kwargs['wsgi_app']
|
||||
|
||||
def test_http_basic_not_authenticated(self):
|
||||
self._setup_http_basic()
|
||||
@ -289,7 +292,7 @@ class TestService(TestCase):
|
||||
self.config(auth_strategy='keystone', group='json_rpc')
|
||||
self.service = server.WSGIService(FakeManager(), self.serializer,
|
||||
FakeContext)
|
||||
self.app = self.server_mock.call_args[0][2]
|
||||
self.app = self.server_mock.call_args.kwargs['wsgi_app']
|
||||
self._request('success', {'context': self.ctx, 'x': 42},
|
||||
expected_error=401)
|
||||
|
||||
@ -298,7 +301,7 @@ class TestService(TestCase):
|
||||
self.config(allowed_roles=['allowed', 'ignored'], group='json_rpc')
|
||||
self.service = server.WSGIService(FakeManager(), self.serializer,
|
||||
FakeContext)
|
||||
self.app = self.server_mock.call_args[0][2]
|
||||
self.app = self.server_mock.call_args.kwargs['wsgi_app']
|
||||
self._request('success', {'context': self.ctx, 'x': 42},
|
||||
expected_error=401,
|
||||
headers={'Content-Type': 'application/json',
|
||||
|
@ -14,6 +14,7 @@ from unittest import mock
|
||||
|
||||
from oslo_concurrency import processutils
|
||||
from oslo_config import cfg
|
||||
from oslo_service import sslutils
|
||||
|
||||
from ironic.common import exception
|
||||
from ironic.common import wsgi_service
|
||||
@ -23,21 +24,28 @@ CONF = cfg.CONF
|
||||
|
||||
|
||||
class TestWSGIService(base.TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
sslutils.register_opts(CONF)
|
||||
self.server = mock.Mock()
|
||||
self.server.requests = mock.Mock(min=0, max=0)
|
||||
|
||||
@mock.patch.object(processutils, 'get_worker_count', lambda: 2)
|
||||
@mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True)
|
||||
def test_workers_set_default(self, mock_server):
|
||||
service_name = "ironic_api"
|
||||
mock_server.return_value = self.server
|
||||
test_service = wsgi_service.WSGIService(service_name)
|
||||
self.assertEqual(2, test_service.workers)
|
||||
mock_server.assert_called_once_with(CONF, service_name,
|
||||
test_service.app,
|
||||
host='0.0.0.0',
|
||||
port=6385,
|
||||
use_ssl=False)
|
||||
mock_server.assert_called_once_with(server_name=service_name,
|
||||
wsgi_app=test_service.app,
|
||||
bind_addr=('0.0.0.0', 6385))
|
||||
|
||||
@mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True)
|
||||
def test_workers_set_correct_setting(self, mock_server):
|
||||
self.config(api_workers=8, group='api')
|
||||
mock_server.return_value = self.server
|
||||
test_service = wsgi_service.WSGIService("ironic_api")
|
||||
self.assertEqual(8, test_service.workers)
|
||||
|
||||
@ -45,6 +53,7 @@ class TestWSGIService(base.TestCase):
|
||||
@mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True)
|
||||
def test_workers_set_zero_setting(self, mock_server):
|
||||
self.config(api_workers=0, group='api')
|
||||
mock_server.return_value = self.server
|
||||
test_service = wsgi_service.WSGIService("ironic_api")
|
||||
self.assertEqual(3, test_service.workers)
|
||||
|
||||
@ -52,24 +61,44 @@ class TestWSGIService(base.TestCase):
|
||||
@mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True)
|
||||
def test_workers_set_default_limit(self, mock_server):
|
||||
self.config(api_workers=0, group='api')
|
||||
mock_server.return_value = self.server
|
||||
test_service = wsgi_service.WSGIService("ironic_api")
|
||||
self.assertEqual(4, test_service.workers)
|
||||
|
||||
@mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True)
|
||||
def test_workers_set_negative_setting(self, mock_server):
|
||||
self.config(api_workers=-2, group='api')
|
||||
mock_server.return_value = self.server
|
||||
self.assertRaises(exception.ConfigInvalid,
|
||||
wsgi_service.WSGIService,
|
||||
'ironic_api')
|
||||
self.assertFalse(mock_server.called)
|
||||
|
||||
@mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True)
|
||||
def test_wsgi_service_with_ssl_enabled(self, mock_server):
|
||||
@mock.patch('ironic.common.wsgi_service.cheroot_ssl.BuiltinSSLAdapter',
|
||||
autospec=True)
|
||||
@mock.patch('ironic.common.wsgi_service.validate_cert_paths',
|
||||
autospec=True)
|
||||
@mock.patch('oslo_service.sslutils.is_enabled', return_value=True,
|
||||
autospec=True)
|
||||
def test_wsgi_service_with_ssl_enabled(self, mock_is_enabled,
|
||||
mock_validate_tls,
|
||||
mock_ssl_adapter,
|
||||
mock_server):
|
||||
self.config(enable_ssl_api=True, group='api')
|
||||
self.config(cert_file='/path/to/cert', group='ssl')
|
||||
self.config(key_file='/path/to/key', group='ssl')
|
||||
|
||||
mock_server.return_value = self.server
|
||||
|
||||
service_name = 'ironic_api'
|
||||
srv = wsgi_service.WSGIService('ironic_api', CONF.api.enable_ssl_api)
|
||||
mock_server.assert_called_once_with(CONF, service_name,
|
||||
srv.app,
|
||||
host='0.0.0.0',
|
||||
port=6385,
|
||||
use_ssl=True)
|
||||
mock_server.assert_called_once_with(server_name=service_name,
|
||||
wsgi_app=srv.app,
|
||||
bind_addr=('0.0.0.0', 6385))
|
||||
|
||||
mock_ssl_adapter.assert_called_once_with(
|
||||
certificate='/path/to/cert',
|
||||
private_key='/path/to/key'
|
||||
)
|
||||
self.assertIsNotNone(self.server.ssl_adapter)
|
||||
|
@ -0,0 +1,21 @@
|
||||
---
|
||||
fixes:
|
||||
- |
|
||||
The Ironic REST API and JSON-RPC endpoints are now served by
|
||||
``cheroot.wsgi.Server`` instead of the deprecated ``oslo_service.wsgi``
|
||||
/ eventlet stack. Behaviour and CLI commands are unchanged.
|
||||
|
||||
features:
|
||||
- |
|
||||
The REST API and JSON-RPC listeners now honour new options in their own
|
||||
config sections:
|
||||
|
||||
* ``[api]cert_file`` / ``[api]key_file``
|
||||
* ``[json_rpc]cert_file`` / ``[json_rpc]key_file``
|
||||
|
||||
This lets operators present different certificates for each endpoint
|
||||
without touching the global ``[ssl]`` block as that is now deprecated,
|
||||
to be removed in **2026.1**.
|
||||
|
||||
Deployments that still rely on the global ``[ssl]`` section are advised
|
||||
to move the certificate settings to the per-service options.
|
@ -49,3 +49,4 @@ os-service-types>=1.7.0 # Apache-2.0
|
||||
bcrypt>=3.1.3 # Apache-2.0
|
||||
websockify>=0.9.0 # LGPLv3
|
||||
PyYAML>=6.0.2 # MIT
|
||||
cheroot>=10.0.1 # BSD
|
||||
|
Reference in New Issue
Block a user