Replace eventlet wsgi by cheroot

Like ironic did previously, let's get rid of eventlet based wsgi and
use cheroot instead.

Also stop using oslo.service options, because they are now in conflict
with oslo_config_glue from cotyledon.

Change-Id: I55172351d1768bdd34fab50bac228928ce7b1765
Signed-off-by: Arnaud M <arnaud.morin@gmail.com>
This commit is contained in:
Arnaud M
2025-08-27 00:12:58 +02:00
parent 70c9e86936
commit 58690dfd7d
10 changed files with 153 additions and 86 deletions

View File

@@ -12,33 +12,73 @@
# License for the specific language governing permissions and limitations
# under the License.
from oslo_concurrency import processutils
import os
import threading
from cheroot.ssl import builtin as cheroot_ssl
from cheroot import wsgi
from oslo_config import cfg
from oslo_log import log as logging
from oslo_service import service
from oslo_service import wsgi
from oslo_service import sslutils
from mistral.api import app
from mistral.rpc import clients as rpc_clients
LOG = logging.getLogger(__name__)
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 WSGIService(service.ServiceBase):
"""Provides ability to launch Mistral API from wsgi app."""
def __init__(self, name):
self.name = name
self.app = app.setup_app()
self.workers = (
cfg.CONF.api.api_workers or processutils.get_worker_count()
# NOTE(amorin) since we moved to cheroot, we can't start more than
# one process.
# If you want to use more than one worker, you should start
# mistral-wsgi-api instead
self.workers = 1
bind_addr = (cfg.CONF.api.host, cfg.CONF.api.port)
self.server = wsgi.Server(
bind_addr=bind_addr,
wsgi_app=self.app,
server_name=name)
if cfg.CONF.api.enable_ssl_api:
# NOTE(amorin) I copy pasted this from ironic code and they
# were warning about this so I kept it
LOG.warning(
"Using 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(cfg.CONF)
cert_file = cfg.CONF.ssl.cert_file
key_file = cfg.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.server = wsgi.Server(
cfg.CONF,
name,
self.app,
host=cfg.CONF.api.host,
port=cfg.CONF.api.port,
use_ssl=cfg.CONF.api.enable_ssl_api
)
self._thread = None
def start(self):
# NOTE: When oslo.service creates an API worker it forks a new child
@@ -50,15 +90,25 @@ class WSGIService(service.ServiceBase):
# generated queue names).
rpc_clients.cleanup()
self.server.start()
print('API server started.')
self.server.prepare()
self._thread = threading.Thread(
target=self.server.serve,
daemon=True
)
self._thread.start()
LOG.info('API server started with one process. If you want more '
'workers, consider switching to a wsgi server using '
'mistral-wsgi-api')
def stop(self):
if self.server:
self.server.stop()
if self._thread:
self._thread.join(timeout=2)
def wait(self):
self.server.wait()
if self._thread:
self._thread.join()
def reset(self):
self.server.reset()
pass

View File

@@ -0,0 +1,19 @@
# Copyright 2025 - OVHcloud
#
# 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.
# NOTE(amorin)
# Hardcode the threading backend to avoid using eventlet until this will
# eventually become the default
import oslo_service.backend as service_backend
service_backend.init_backend(service_backend.BackendType.THREADING)

View File

@@ -16,17 +16,6 @@
import sys
import eventlet
eventlet.monkey_patch(
os=True,
select=True,
socket=True,
thread=False if '--use-debugger' in sys.argv else True,
time=True)
from oslo_config import cfg
from oslo_log import log as logging
from oslo_service import service
@@ -82,7 +71,6 @@ def launch_notifier():
def launch_api():
server = api_service.WSGIService('mistral_api')
launch_process(server, workers=server.workers)
@@ -90,12 +78,11 @@ def launch_any(options):
for option in options:
LAUNCH_OPTIONS[option]()
global SERVER_PROCESS_MANAGER
# Wait for the services to finish now
# This main process will do nothing starting from now
global SERVER_PROCESS_MANAGER
if SERVER_PROCESS_MANAGER:
SERVER_PROCESS_MANAGER.wait()
sys.exit(SERVER_PROCESS_MANAGER.wait())
# Map cli options to appropriate functions. The cli options are

View File

@@ -27,7 +27,6 @@ from oslo_config import cfg
from oslo_config import types
from oslo_log import log
from oslo_middleware import cors
from oslo_service import _options as service_opts
from osprofiler import opts as profiler
from mistral import version
@@ -789,7 +788,6 @@ CONF.register_opt(scheduler_type_opt)
CONF.register_opt(js_impl_opt)
CONF.register_opt(oslo_rpc_executor)
CONF.register_opt(expiration_token_duration)
CONF.register_opts(service_opts.service_opts)
CONF.register_opts(action_providers_opts, group=ACTION_PROVIDERS_GROUP)
CONF.register_opts(

View File

@@ -92,19 +92,7 @@ class EngineServer(service_base.MistralService):
self._notify_started('Engine server started.')
def stop(self, graceful=False):
# NOTE(rakhmerov): Unfortunately, oslo.service doesn't pass the
# 'graceful' parameter with a correct value. It's simply ignored
# in the corresponding call chain leading to a concrete service.
# The only workaround for now is to check 'graceful_shutdown_timeout'
# configuration option. If it's not empty (not None or 0) then we
# should treat it a graceful shutdown.
graceful = bool(CONF.graceful_shutdown_timeout)
LOG.info(
'Stopping an engine server [graceful=%s, timeout=%s]',
graceful,
CONF.graceful_shutdown_timeout
)
LOG.info('Stopping engine server')
super(EngineServer, self).stop(graceful)

View File

@@ -12,14 +12,8 @@
# License for the specific language governing permissions and limitations
# under the License.
import sys
import eventlet
eventlet.monkey_patch(
os=True,
select=True,
socket=True,
thread=False if '--use-debugger' in sys.argv else True,
time=True
)
# NOTE(amorin)
# Hardcode the threading backend to avoid using eventlet until this will
# eventually become the default
import oslo_service.backend as service_backend
service_backend.init_backend(service_backend.BackendType.THREADING)

View File

@@ -16,66 +16,83 @@ from unittest import mock
from oslo_concurrency import processutils
from oslo_config import cfg
from oslo_service import sslutils
from mistral.api import service
from mistral.tests.unit import base
CONF = cfg.CONF
class TestWSGIService(base.BaseTest):
def setUp(self):
super(TestWSGIService, self).setUp()
self.override_config('enabled', False, group='cron_trigger')
sslutils.register_opts(CONF)
self.server = mock.Mock()
@mock.patch.object(processutils, 'get_worker_count', lambda: 2)
@mock.patch.object(service.wsgi, 'Server')
def test_workers_set_default(self, wsgi_server):
service_name = "mistral_api"
with mock.patch('mistral.api.app.setup_app'):
test_service = service.WSGIService(service_name)
self.assertEqual(1, test_service.workers)
wsgi_server.assert_called_once_with(
cfg.CONF,
service_name,
test_service.app,
host='0.0.0.0',
port=8989,
use_ssl=False
bind_addr=('0.0.0.0', 8989),
wsgi_app=test_service.app,
server_name=service_name,
)
def test_workers_set_correct_setting(self):
# NOTE(amorin) since we moved to cheroot, we can't start more than
# one worker, so, no matter what the setting will be set to,
# mistral will start only one worker
self.override_config('api_workers', 8, group='api')
with mock.patch('mistral.api.app.setup_app'):
test_service = service.WSGIService("mistral_api")
self.assertEqual(8, test_service.workers)
self.assertEqual(1, test_service.workers)
@mock.patch.object(processutils, 'get_worker_count', lambda: 3)
def test_workers_set_zero_setting(self):
self.override_config('api_workers', 0, group='api')
with mock.patch('mistral.api.app.setup_app'):
test_service = service.WSGIService("mistral_api")
self.assertEqual(
processutils.get_worker_count(),
test_service.workers
)
self.assertEqual(1, test_service.workers)
@mock.patch.object(service.wsgi, 'Server')
def test_wsgi_service_with_ssl_enabled(self, wsgi_server):
@mock.patch('mistral.api.service.cheroot_ssl.BuiltinSSLAdapter',
autospec=True)
@mock.patch('mistral.api.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,
wsgi_server):
wsgi_server.return_value = self.server
self.override_config('enable_ssl_api', True, group='api')
self.override_config('cert_file', '/path/to/cert', group='ssl')
self.override_config('key_file', '/path/to/key', group='ssl')
service_name = 'mistral_api'
with mock.patch('mistral.api.app.setup_app'):
srv = service.WSGIService(service_name)
test_service = service.WSGIService(service_name)
wsgi_server.assert_called_once_with(
cfg.CONF,
service_name,
srv.app,
host='0.0.0.0',
port=8989,
use_ssl=True
server_name=service_name,
wsgi_app=test_service.app,
bind_addr=('0.0.0.0', 8989)
)
mock_ssl_adapter.assert_called_once_with(
certificate='/path/to/cert',
private_key='/path/to/key'
)
self.assertIsNotNone(self.server.ssl_adapter)

View File

@@ -34,20 +34,28 @@ class ServiceLauncherTest(base.DbTestCase):
@mock.patch.object(service.ProcessLauncher, 'launch_service')
@mock.patch.object(service.ProcessLauncher, 'wait')
def test_launch_one_process(self, mock_wait, mock_launch_service):
@mock.patch('sys.exit')
def test_launch_one_process(self, mock_exit, mock_wait,
mock_launch_service):
# Launch API
launch.launch_any(['api'])
# Make sure we tried to start a service
mock_launch_service.assert_called_once_with(mock.ANY, workers=2)
# NOTE(amorin) despite the fact that the config set 2 workers
# we are hardcoding 1 worker since we use cheroot
mock_launch_service.assert_called_once_with(mock.ANY, workers=1)
mock_wait.assert_called_once_with()
mock_exit.assert_called_once()
@mock.patch.object(service.ProcessLauncher, 'launch_service')
@mock.patch.object(service.ProcessLauncher, 'wait')
def test_launch_multiple_processes(self, mock_wait, mock_launch_service):
@mock.patch('sys.exit')
def test_launch_multiple_processes(self, mock_exit, mock_wait,
mock_launch_service):
# Launch API and executor
launch.launch_any(['api', 'executor'])
# Make sure we tried to start 2 services
mock_launch_service.call_count = 2
mock_wait.call_count = 2
mock_exit.assert_called_once()

View File

@@ -0,0 +1,6 @@
---
fixes:
- |
The Mistral API is now served by ``cheroot.wsgi.Server`` instead of the
deprecated ``oslo_service.wsgi`` / eventlet stack. Behaviour and CLI
commands are unchanged.

View File

@@ -3,10 +3,10 @@
# you find any incorrect lower bounds, let us know or propose a fix.
alembic>=0.9.6 # MIT
cheroot>=10.0.1 # BSD
croniter>=0.3.4 # MIT License
cachetools>=2.0.0 # MIT License
dogpile.cache>=0.6.2 # BSD
eventlet>=0.27.0 # MIT
Jinja2>=2.10 # BSD License (3 clause)
jsonschema>=3.2.0 # MIT
keystonemiddleware>=4.18.0 # Apache-2.0
@@ -23,7 +23,7 @@ oslo.policy>=4.5.0 # Apache-2.0
oslo.utils>=7.0.0 # Apache-2.0
oslo.log>=3.36.0 # Apache-2.0
oslo.serialization>=2.21.1 # Apache-2.0
oslo.service>=2.1.0 # Apache-2.0
oslo.service[threading]>=4.2.2 # Apache-2.0
osprofiler>=1.4.0 # Apache-2.0
paramiko>=2.4.1 # LGPLv2.1+
pbr!=2.1.0,>=2.0.0 # Apache-2.0