Merge "Eventlet: Migrate API & JSON-RPC to cheroot"

This commit is contained in:
Zuul
2025-06-18 01:09:39 +00:00
committed by Gerrit Code Review
7 changed files with 161 additions and 38 deletions

View File

@ -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):

View File

@ -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',

View File

@ -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).')),

View File

@ -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',

View File

@ -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)

View File

@ -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.

View File

@ -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