Eventlet: Migrate API & JSON-RPC to cheroot
Serve Ironic REST API & JSON-RPC via Cheroot instead of eventlet Claude code used to find and fix minor issues when running the service. Generated-By: claude-code Change-Id: I5440c5898abfe525807434447b69ec4a32e56f2d
This commit is contained in:
@@ -10,11 +10,15 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# 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_concurrency import processutils
|
||||||
|
from oslo_log import log as logging
|
||||||
from oslo_service import service
|
from oslo_service import service
|
||||||
from oslo_service import wsgi
|
from oslo_service import sslutils
|
||||||
|
|
||||||
from ironic.api import app
|
from ironic.api import app
|
||||||
from ironic.common import exception
|
from ironic.common import exception
|
||||||
@@ -23,9 +27,22 @@ from ironic.common import utils
|
|||||||
from ironic.conf import CONF
|
from ironic.conf import CONF
|
||||||
|
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
_MAX_DEFAULT_WORKERS = 4
|
_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):
|
class BaseWSGIService(service.ServiceBase):
|
||||||
|
|
||||||
def __init__(self, name, app, conf, use_ssl=None):
|
def __init__(self, name, app, conf, use_ssl=None):
|
||||||
@@ -41,48 +58,89 @@ class BaseWSGIService(service.ServiceBase):
|
|||||||
self._conf = conf
|
self._conf = conf
|
||||||
if use_ssl is None:
|
if use_ssl is None:
|
||||||
use_ssl = conf.use_ssl
|
use_ssl = conf.use_ssl
|
||||||
|
|
||||||
|
socket_mode = None
|
||||||
|
bind_addr = (conf.host_ip, conf.port)
|
||||||
if conf.unix_socket:
|
if conf.unix_socket:
|
||||||
utils.unlink_without_raise(conf.unix_socket)
|
utils.unlink_without_raise(conf.unix_socket)
|
||||||
self.server = wsgi.Server(CONF, name, app,
|
bind_addr = conf.unix_socket
|
||||||
socket_family=socket.AF_UNIX,
|
socket_mode = conf.unix_socket_mode
|
||||||
socket_file=conf.unix_socket,
|
|
||||||
socket_mode=conf.unix_socket_mode,
|
self.server = wsgi.Server(
|
||||||
use_ssl=use_ssl)
|
bind_addr=bind_addr,
|
||||||
else:
|
wsgi_app=app,
|
||||||
self.server = wsgi.Server(CONF, name, app,
|
server_name=name)
|
||||||
host=conf.host_ip,
|
|
||||||
port=conf.port,
|
if use_ssl:
|
||||||
use_ssl=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):
|
def start(self):
|
||||||
"""Start serving this service using loaded configuration.
|
"""Start serving this service using loaded configuration.
|
||||||
|
|
||||||
:returns: None
|
: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):
|
def stop(self):
|
||||||
"""Stop serving this API.
|
"""Stop serving this API.
|
||||||
|
|
||||||
:returns: None
|
:returns: None
|
||||||
"""
|
"""
|
||||||
self.server.stop()
|
if self.server:
|
||||||
if self._conf.unix_socket:
|
self.server.stop()
|
||||||
utils.unlink_without_raise(self._conf.unix_socket)
|
if self._thread:
|
||||||
|
self._thread.join(timeout=2)
|
||||||
|
|
||||||
|
if self._unix_socket:
|
||||||
|
utils.unlink_without_raise(self._unix_socket)
|
||||||
|
|
||||||
def wait(self):
|
def wait(self):
|
||||||
"""Wait for the service to stop serving this API.
|
"""Wait for the service to stop serving this API.
|
||||||
|
|
||||||
:returns: None
|
:returns: None
|
||||||
"""
|
"""
|
||||||
self.server.wait()
|
if self._thread:
|
||||||
|
self._thread.join()
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
"""Reset server greenpool size to default.
|
"""No server greenpools to resize."""
|
||||||
|
pass
|
||||||
:returns: None
|
|
||||||
"""
|
|
||||||
self.server.reset()
|
|
||||||
|
|
||||||
|
|
||||||
class WSGIService(BaseWSGIService):
|
class WSGIService(BaseWSGIService):
|
||||||
|
@@ -103,6 +103,12 @@ opts = [
|
|||||||
mutable=True,
|
mutable=True,
|
||||||
help=_("Specifies a list of boot modes that are not allowed "
|
help=_("Specifies a list of boot modes that are not allowed "
|
||||||
"during enrollment. Eg: ['bios']")),
|
"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',
|
opt_group = cfg.OptGroup(name='api',
|
||||||
|
@@ -43,9 +43,14 @@ opts = [
|
|||||||
cfg.BoolOpt('use_ssl',
|
cfg.BoolOpt('use_ssl',
|
||||||
default=False,
|
default=False,
|
||||||
help=_('Whether to use TLS for JSON RPC')),
|
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',
|
cfg.BoolOpt('client_use_ssl',
|
||||||
default=False,
|
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 '
|
'even if use_ssl is set to False. Only makes sense '
|
||||||
'if server-side TLS is provided outside of Ironic '
|
'if server-side TLS is provided outside of Ironic '
|
||||||
'(e.g. with httpd acting as a reverse proxy).')),
|
'(e.g. with httpd acting as a reverse proxy).')),
|
||||||
|
@@ -84,7 +84,10 @@ class TestService(TestCase):
|
|||||||
super(TestService, self).setUp()
|
super(TestService, self).setUp()
|
||||||
self.config(auth_strategy='noauth', group='json_rpc')
|
self.config(auth_strategy='noauth', group='json_rpc')
|
||||||
self.server_mock = self.useFixture(fixtures.MockPatch(
|
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.serializer = FakeSerializer()
|
||||||
self.service = server.WSGIService(FakeManager(), self.serializer,
|
self.service = server.WSGIService(FakeManager(), self.serializer,
|
||||||
@@ -140,7 +143,7 @@ class TestService(TestCase):
|
|||||||
# self.config(http_basic_password='myPassword', group='json_rpc')
|
# self.config(http_basic_password='myPassword', group='json_rpc')
|
||||||
self.service = server.WSGIService(FakeManager(), self.serializer,
|
self.service = server.WSGIService(FakeManager(), self.serializer,
|
||||||
FakeContext)
|
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):
|
def test_http_basic_not_authenticated(self):
|
||||||
self._setup_http_basic()
|
self._setup_http_basic()
|
||||||
@@ -289,7 +292,7 @@ class TestService(TestCase):
|
|||||||
self.config(auth_strategy='keystone', group='json_rpc')
|
self.config(auth_strategy='keystone', group='json_rpc')
|
||||||
self.service = server.WSGIService(FakeManager(), self.serializer,
|
self.service = server.WSGIService(FakeManager(), self.serializer,
|
||||||
FakeContext)
|
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},
|
self._request('success', {'context': self.ctx, 'x': 42},
|
||||||
expected_error=401)
|
expected_error=401)
|
||||||
|
|
||||||
@@ -298,7 +301,7 @@ class TestService(TestCase):
|
|||||||
self.config(allowed_roles=['allowed', 'ignored'], group='json_rpc')
|
self.config(allowed_roles=['allowed', 'ignored'], group='json_rpc')
|
||||||
self.service = server.WSGIService(FakeManager(), self.serializer,
|
self.service = server.WSGIService(FakeManager(), self.serializer,
|
||||||
FakeContext)
|
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},
|
self._request('success', {'context': self.ctx, 'x': 42},
|
||||||
expected_error=401,
|
expected_error=401,
|
||||||
headers={'Content-Type': 'application/json',
|
headers={'Content-Type': 'application/json',
|
||||||
|
@@ -14,6 +14,7 @@ from unittest import mock
|
|||||||
|
|
||||||
from oslo_concurrency import processutils
|
from oslo_concurrency import processutils
|
||||||
from oslo_config import cfg
|
from oslo_config import cfg
|
||||||
|
from oslo_service import sslutils
|
||||||
|
|
||||||
from ironic.common import exception
|
from ironic.common import exception
|
||||||
from ironic.common import wsgi_service
|
from ironic.common import wsgi_service
|
||||||
@@ -23,21 +24,28 @@ CONF = cfg.CONF
|
|||||||
|
|
||||||
|
|
||||||
class TestWSGIService(base.TestCase):
|
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(processutils, 'get_worker_count', lambda: 2)
|
||||||
@mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True)
|
@mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True)
|
||||||
def test_workers_set_default(self, mock_server):
|
def test_workers_set_default(self, mock_server):
|
||||||
service_name = "ironic_api"
|
service_name = "ironic_api"
|
||||||
|
mock_server.return_value = self.server
|
||||||
test_service = wsgi_service.WSGIService(service_name)
|
test_service = wsgi_service.WSGIService(service_name)
|
||||||
self.assertEqual(2, test_service.workers)
|
self.assertEqual(2, test_service.workers)
|
||||||
mock_server.assert_called_once_with(CONF, service_name,
|
mock_server.assert_called_once_with(server_name=service_name,
|
||||||
test_service.app,
|
wsgi_app=test_service.app,
|
||||||
host='0.0.0.0',
|
bind_addr=('0.0.0.0', 6385))
|
||||||
port=6385,
|
|
||||||
use_ssl=False)
|
|
||||||
|
|
||||||
@mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True)
|
@mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True)
|
||||||
def test_workers_set_correct_setting(self, mock_server):
|
def test_workers_set_correct_setting(self, mock_server):
|
||||||
self.config(api_workers=8, group='api')
|
self.config(api_workers=8, group='api')
|
||||||
|
mock_server.return_value = self.server
|
||||||
test_service = wsgi_service.WSGIService("ironic_api")
|
test_service = wsgi_service.WSGIService("ironic_api")
|
||||||
self.assertEqual(8, test_service.workers)
|
self.assertEqual(8, test_service.workers)
|
||||||
|
|
||||||
@@ -45,6 +53,7 @@ class TestWSGIService(base.TestCase):
|
|||||||
@mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True)
|
@mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True)
|
||||||
def test_workers_set_zero_setting(self, mock_server):
|
def test_workers_set_zero_setting(self, mock_server):
|
||||||
self.config(api_workers=0, group='api')
|
self.config(api_workers=0, group='api')
|
||||||
|
mock_server.return_value = self.server
|
||||||
test_service = wsgi_service.WSGIService("ironic_api")
|
test_service = wsgi_service.WSGIService("ironic_api")
|
||||||
self.assertEqual(3, test_service.workers)
|
self.assertEqual(3, test_service.workers)
|
||||||
|
|
||||||
@@ -52,24 +61,44 @@ class TestWSGIService(base.TestCase):
|
|||||||
@mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True)
|
@mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True)
|
||||||
def test_workers_set_default_limit(self, mock_server):
|
def test_workers_set_default_limit(self, mock_server):
|
||||||
self.config(api_workers=0, group='api')
|
self.config(api_workers=0, group='api')
|
||||||
|
mock_server.return_value = self.server
|
||||||
test_service = wsgi_service.WSGIService("ironic_api")
|
test_service = wsgi_service.WSGIService("ironic_api")
|
||||||
self.assertEqual(4, test_service.workers)
|
self.assertEqual(4, test_service.workers)
|
||||||
|
|
||||||
@mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True)
|
@mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True)
|
||||||
def test_workers_set_negative_setting(self, mock_server):
|
def test_workers_set_negative_setting(self, mock_server):
|
||||||
self.config(api_workers=-2, group='api')
|
self.config(api_workers=-2, group='api')
|
||||||
|
mock_server.return_value = self.server
|
||||||
self.assertRaises(exception.ConfigInvalid,
|
self.assertRaises(exception.ConfigInvalid,
|
||||||
wsgi_service.WSGIService,
|
wsgi_service.WSGIService,
|
||||||
'ironic_api')
|
'ironic_api')
|
||||||
self.assertFalse(mock_server.called)
|
self.assertFalse(mock_server.called)
|
||||||
|
|
||||||
@mock.patch.object(wsgi_service.wsgi, 'Server', autospec=True)
|
@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(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'
|
service_name = 'ironic_api'
|
||||||
srv = wsgi_service.WSGIService('ironic_api', CONF.api.enable_ssl_api)
|
srv = wsgi_service.WSGIService('ironic_api', CONF.api.enable_ssl_api)
|
||||||
mock_server.assert_called_once_with(CONF, service_name,
|
mock_server.assert_called_once_with(server_name=service_name,
|
||||||
srv.app,
|
wsgi_app=srv.app,
|
||||||
host='0.0.0.0',
|
bind_addr=('0.0.0.0', 6385))
|
||||||
port=6385,
|
|
||||||
use_ssl=True)
|
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
|
bcrypt>=3.1.3 # Apache-2.0
|
||||||
websockify>=0.9.0 # LGPLv3
|
websockify>=0.9.0 # LGPLv3
|
||||||
PyYAML>=6.0.2 # MIT
|
PyYAML>=6.0.2 # MIT
|
||||||
|
cheroot>=10.0.1 # BSD
|
||||||
|
Reference in New Issue
Block a user