Revert "Switch from local RPC to automated JSON RPC on localhost"

The initial concern was the multi-process model that cotyledon uses.
Commit 71dd34a7bd moved API to the
conductor process, so this no longer applies.

This reverts commit 3831464751.

Generated-By: Claude Code
Change-Id: Iaeabcfb8f6558220a10060ccca788f1f4b959f0e
Signed-off-by: Dmitry Tantsur <dtantsur@protonmail.com>
This commit is contained in:
Dmitry Tantsur
2025-08-28 16:58:05 +02:00
parent 36364749c8
commit 4dd42796c0
19 changed files with 184 additions and 529 deletions

View File

@@ -17,8 +17,8 @@ resources and low number of nodes to handle.
Any RPC settings will only take effect if you have more than one combined Any RPC settings will only take effect if you have more than one combined
service started or if you have additional conductors. service started or if you have additional conductors.
If you don't plan to have more than one conductor, you can switch to If you don't plan to have more than one conductor, you can disable the
the local RPC: RPC completely:
.. code-block:: ini .. code-block:: ini

View File

@@ -106,7 +106,7 @@ You should make the following changes to ``/etc/ironic/ironic.conf``:
console_image=<image reference> console_image=<image reference>
#. Starting with the Yoga release series, you can use a combined #. Starting with the Yoga release series, you can use a combined
API+conductor+novncproxy service with the local RPC. Set API+conductor+novncproxy service and completely disable the RPC. Set
.. code-block:: ini .. code-block:: ini

View File

@@ -19,7 +19,6 @@ from oslo_service import service
from ironic.command import conductor as conductor_cmd from ironic.command import conductor as conductor_cmd
from ironic.command import utils from ironic.command import utils
from ironic.common import service as ironic_service from ironic.common import service as ironic_service
from ironic.conductor import local_rpc
from ironic.conductor import rpc_service from ironic.conductor import rpc_service
from ironic.console import novncproxy_service from ironic.console import novncproxy_service
@@ -40,8 +39,6 @@ def main():
# Parse config file and command line options, then start logging # Parse config file and command line options, then start logging
ironic_service.prepare_service('ironic', sys.argv) ironic_service.prepare_service('ironic', sys.argv)
local_rpc.configure()
# Choose the launcher based upon if vnc is enabled or not. # Choose the launcher based upon if vnc is enabled or not.
# The VNC proxy has to be run in the parent process, not # The VNC proxy has to be run in the parent process, not
# a sub-process. # a sub-process.

View File

@@ -212,10 +212,3 @@ def unauthorized(message=None):
if not message: if not message:
message = _('Incorrect username or password') message = _('Incorrect username or password')
raise exception.Unauthorized(message) raise exception.Unauthorized(message)
def write_password(fileobj, username, password):
"""Write a record with the username and password to the file."""
pw_hash = bcrypt.hashpw(
password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
fileobj.write(f"{username}:{pw_hash}\n")

View File

@@ -87,17 +87,9 @@ class Client(object):
is the hostname of the remote json-rpc service. is the hostname of the remote json-rpc service.
:param version: The RPC API version to utilize. :param version: The RPC API version to utilize.
""" """
host = topic.split('.', 1)[1] host = topic.split('.', 1)[1]
host, port = netutils.parse_host_port(host) host, port = netutils.parse_host_port(host)
return self.prepare_for_target(host, port, version=version)
def prepare_for_target(self, host, port=None, version=None):
"""Prepare the client to transmit a request to the provided target.
:param host: Remote host to connect to.
:param port: Port to use.
:param version: The RPC API version to utilize.
"""
return _CallContext( return _CallContext(
host, self.serializer, version=version, host, self.serializer, version=version,
version_cap=self.version_cap, version_cap=self.version_cap,

View File

@@ -61,13 +61,13 @@ class BaseRPCService(service.Service):
serializer = objects_base.IronicObjectSerializer(is_server=True) serializer = objects_base.IronicObjectSerializer(is_server=True)
# Perform preparatory actions before starting the RPC listener # Perform preparatory actions before starting the RPC listener
self.manager.prepare_host() self.manager.prepare_host()
if CONF.rpc_transport == 'oslo': if CONF.rpc_transport == 'json-rpc':
self.rpcserver = json_rpc.WSGIService(
self.manager, serializer, context.RequestContext.from_dict)
elif CONF.rpc_transport != 'none':
target = messaging.Target(topic=self.topic, server=self.host) target = messaging.Target(topic=self.topic, server=self.host)
endpoints = [self.manager] endpoints = [self.manager]
self.rpcserver = rpc.get_server(target, endpoints, serializer) self.rpcserver = rpc.get_server(target, endpoints, serializer)
else:
self.rpcserver = json_rpc.WSGIService(
self.manager, serializer, context.RequestContext.from_dict)
if self.rpcserver is not None: if self.rpcserver is not None:
self.rpcserver.start() self.rpcserver.start()

View File

@@ -1,93 +0,0 @@
# Copyright 2020 Red Hat, Inc.
#
# 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(dtantsur): partial copy from IPA commit
# d86923e7ff40c3ec1d43fe9d4068f0bd3b17de67
import datetime
import ipaddress
from cryptography.hazmat import backends
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import serialization
from cryptography import x509
from oslo_log import log
LOG = log.getLogger(__name__)
def _create_private_key(output):
"""Create a new private key and write it to a file.
Using elliptic curve keys since they are 2x smaller than RSA ones of
the same security (the NIST P-256 curve we use roughly corresponds
to RSA with 3072 bits).
:param output: Output file name.
:return: a private key object.
"""
private_key = ec.generate_private_key(ec.SECP256R1(),
backends.default_backend())
pkey_bytes = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
)
with open(output, 'wb') as fp:
fp.write(pkey_bytes)
return private_key
def generate_tls_certificate(output, private_key_output,
common_name, ip_address,
valid_for_days=30):
"""Generate a self-signed TLS certificate.
:param output: Output file name for the certificate.
:param private_key_output: Output file name for the private key.
:param common_name: Content for the common name field (e.g. host name).
:param ip_address: IP address the certificate will be valid for.
:param valid_for_days: Number of days the certificate will be valid for.
:return: the generated certificate as a string.
"""
if isinstance(ip_address, str):
ip_address = ipaddress.ip_address(ip_address)
private_key = _create_private_key(private_key_output)
subject = x509.Name([
x509.NameAttribute(x509.NameOID.COMMON_NAME, common_name),
])
alt_name = x509.SubjectAlternativeName([x509.IPAddress(ip_address)])
not_valid_before = datetime.datetime.now(tz=datetime.timezone.utc)
not_valid_after = (datetime.datetime.now(tz=datetime.timezone.utc)
+ datetime.timedelta(days=valid_for_days))
cert = (x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(subject)
.public_key(private_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(not_valid_before)
.not_valid_after(not_valid_after)
.add_extension(alt_name, critical=True)
.sign(private_key, hashes.SHA256(), backends.default_backend()))
pub_bytes = cert.public_bytes(serialization.Encoding.PEM)
with open(output, "wb") as f:
f.write(pub_bytes)
LOG.info('Generated TLS certificate for IP address %s valid from %s '
'to %s', ip_address, not_valid_before, not_valid_after)
return pub_bytes.decode('utf-8')

View File

@@ -1,119 +0,0 @@
# 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 atexit
import secrets
import socket
import tempfile
from keystoneauth1 import loading as ks_loading
from oslo_log import log
from ironic.common import auth_basic
from ironic.common.json_rpc import client
from ironic.common import tls_utils
from ironic.common import utils
from ironic.conf import CONF
LOG = log.getLogger(__name__)
_PASSWORD_BYTES = 64
_VALID_FOR_DAYS = 9999 # rotation not possible
_USERNAME = 'ironic'
def _lo_has_ipv6():
"""Check if IPv6 is available by attempting to bind to ::1."""
try:
with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('::1', 0))
return True
except (OSError, socket.error) as e:
LOG.debug('IPv6 is not available on localhost: %s', e)
return False
def _create_tls_files(ip):
with tempfile.NamedTemporaryFile(
delete=False, dir=CONF.local_rpc.temp_dir) as fp:
cert_file = fp.name
with tempfile.NamedTemporaryFile(
delete=False, dir=CONF.local_rpc.temp_dir) as fp:
key_file = fp.name
tls_utils.generate_tls_certificate(cert_file, key_file,
common_name='ironic',
ip_address=ip,
valid_for_days=_VALID_FOR_DAYS)
return cert_file, key_file
def _create_htpasswd(password):
with tempfile.NamedTemporaryFile(
mode="w+t", delete=False, dir=CONF.local_rpc.temp_dir) as fp:
auth_basic.write_password(fp, _USERNAME, password)
return fp.name
def configure():
"""Configure the local JSON RPC bus (if enabled)."""
if CONF.rpc_transport != 'none':
return
ip = '::1' if _lo_has_ipv6() else '127.0.0.1'
LOG.debug('Configuring local RPC bus on %s:%d', ip, CONF.json_rpc.port)
if CONF.local_rpc.use_ssl:
cert_file, key_file = _create_tls_files(ip)
def _cleanup():
utils.unlink_without_raise(cert_file)
utils.unlink_without_raise(key_file)
atexit.register(_cleanup)
else:
cert_file, key_file = None, None
password = secrets.token_urlsafe(_PASSWORD_BYTES)
htpasswd_path = _create_htpasswd(password)
# NOTE(dtantsur): it is not possible to override username/password without
# registering http_basic options first.
opts = ks_loading.get_auth_plugin_conf_options('http_basic')
CONF.register_opts(opts, group='json_rpc')
for key, value in [
('use_ssl', CONF.local_rpc.use_ssl),
# Client options
('auth_type', 'http_basic'),
('cafile', cert_file),
('username', _USERNAME),
('password', password),
# Server options
('auth_strategy', 'http_basic'),
('http_basic_auth_user_file', htpasswd_path),
('host_ip', ip),
('cert_file', cert_file),
('key_file', key_file),
]:
CONF.set_override(key, value, group='json_rpc')
class LocalClient(client.Client):
"""JSON RPC client that always connects to the server's host IP."""
def prepare(self, topic, version=None):
# TODO(dtantsur): check that topic matches the expected host name
# (which is not host_ip, by the way, it's CONF.host).
return self.prepare_for_target(CONF.json_rpc.host_ip)

View File

@@ -29,7 +29,6 @@ from ironic.common.i18n import _
from ironic.common.json_rpc import client as json_rpc from ironic.common.json_rpc import client as json_rpc
from ironic.common import release_mappings as versions from ironic.common import release_mappings as versions
from ironic.common import rpc from ironic.common import rpc
from ironic.conductor import local_rpc
from ironic.conf import CONF from ironic.conf import CONF
from ironic.db import api as dbapi from ironic.db import api as dbapi
from ironic.objects import base as objects_base from ironic.objects import base as objects_base
@@ -40,6 +39,46 @@ LOG = log.getLogger(__name__)
DBAPI = dbapi.get_instance() DBAPI = dbapi.get_instance()
class LocalContext:
"""Context to make calls to a local conductor."""
__slots__ = ()
def call(self, context, rpc_call_name, **kwargs):
"""Make a local conductor call."""
if rpc.GLOBAL_MANAGER is None:
raise exception.ServiceUnavailable(
_("The built-in conductor is not available, it might have "
"crashed. Please check the logs and correct the "
"configuration, if required."))
try:
return getattr(rpc.GLOBAL_MANAGER, rpc_call_name)(context,
**kwargs)
# FIXME(dtantsur): can we somehow avoid wrapping the exception?
except messaging.ExpectedException as exc:
exc_value, exc_tb = exc.exc_info[1:]
raise exc_value.with_traceback(exc_tb) from None
def cast(self, context, rpc_call_name, **kwargs):
"""Make a local conductor call.
It is expected that the underlying call uses a thread to avoid
blocking the caller.
Any exceptions are logged and ignored.
"""
try:
return self.call(context, rpc_call_name, **kwargs)
except Exception:
# In real RPC, casts are completely asynchronous and never return
# actual errors.
LOG.exception('Ignoring unhandled exception from RPC cast %s',
rpc_call_name)
_LOCAL_CONTEXT = LocalContext()
class ConductorAPI(object): class ConductorAPI(object):
"""Client side of the conductor RPC API. """Client side of the conductor RPC API.
@@ -143,13 +182,12 @@ class ConductorAPI(object):
self.client = json_rpc.Client(serializer=serializer, self.client = json_rpc.Client(serializer=serializer,
version_cap=version_cap) version_cap=version_cap)
self.topic = '' self.topic = ''
elif CONF.rpc_transport == 'none': elif CONF.rpc_transport != 'none':
self.client = local_rpc.LocalClient(serializer=serializer,
version_cap=version_cap)
else:
target = messaging.Target(topic=self.topic, version='1.0') target = messaging.Target(topic=self.topic, version='1.0')
self.client = rpc.get_client(target, version_cap=version_cap, self.client = rpc.get_client(target, version_cap=version_cap,
serializer=serializer) serializer=serializer)
else:
self.client = None
# NOTE(tenbrae): this is going to be buggy # NOTE(tenbrae): this is going to be buggy
self.ring_manager = hash_ring.HashRingManager() self.ring_manager = hash_ring.HashRingManager()
@@ -166,6 +204,23 @@ class ConductorAPI(object):
# FIXME(dtantsur): this doesn't work with either JSON RPC or local # FIXME(dtantsur): this doesn't work with either JSON RPC or local
# conductor. Do we even need this fallback? # conductor. Do we even need this fallback?
topic = topic or self.topic topic = topic or self.topic
# Normally a topic is a <topic prefix>.<hostname>, we need to extract
# the hostname to match it against the current host.
host = topic[len(self.topic) + 1:]
if self.client is None and host == CONF.host:
# Short-cut to a local function call if there is a built-in
# conductor.
return _LOCAL_CONTEXT
# A safeguard for the case someone uses rpc_transport=None with no
# built-in conductor.
if self.client is None:
raise exception.ServiceUnavailable(
_("Cannot use 'none' RPC to connect to remote conductor %s")
% host)
# Normal RPC path
return self.client.prepare(topic=topic, version=version) return self.client.prepare(topic=topic, version=version)
def get_conductor_for(self, node): def get_conductor_for(self, node):

View File

@@ -41,7 +41,6 @@ from ironic.conf import inventory
from ironic.conf import ipmi from ironic.conf import ipmi
from ironic.conf import irmc from ironic.conf import irmc
from ironic.conf import json_rpc from ironic.conf import json_rpc
from ironic.conf import local_rpc
from ironic.conf import mdns from ironic.conf import mdns
from ironic.conf import metrics from ironic.conf import metrics
from ironic.conf import molds from ironic.conf import molds
@@ -84,7 +83,6 @@ inventory.register_opts(CONF)
ipmi.register_opts(CONF) ipmi.register_opts(CONF)
irmc.register_opts(CONF) irmc.register_opts(CONF)
json_rpc.register_opts(CONF) json_rpc.register_opts(CONF)
local_rpc.register_opts(CONF)
mdns.register_opts(CONF) mdns.register_opts(CONF)
metrics.register_opts(CONF) metrics.register_opts(CONF)
molds.register_opts(CONF) molds.register_opts(CONF)

View File

@@ -104,9 +104,7 @@ api_opts = [
mutable=False, mutable=False,
help=_('If the Ironic API should utilize the RPC layer for ' help=_('If the Ironic API should utilize the RPC layer for '
'database interactions as opposed to directly ' 'database interactions as opposed to directly '
'connecting to the database API endpoint. Defaults ' 'connecting to the database API endpoint.')),
'to False, however is implied when the '
'[default]rpc_transport option is set to \'none\'.')),
] ]
driver_opts = [ driver_opts = [

View File

@@ -1,39 +0,0 @@
# 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.
from oslo_config import cfg
from ironic.common.i18n import _
CONF = cfg.CONF
opts = [
cfg.StrOpt('temp_dir',
help=_('When local RPC is used (rpc_transport=None), this is '
'the name of the directory to create temporary files '
'in. Must not be readable by any other processes. '
'If not provided, a temporary directory is used.')),
cfg.BoolOpt('use_ssl',
default=True,
help=_('Whether to use TLS on the local RPC bus. Only set to '
'False if you experience issues with TLS and if all '
'local processes are trusted!')),
]
def register_opts(conf):
conf.register_opts(opts, group='local_rpc')
def list_opts():
return opts

View File

@@ -40,7 +40,6 @@ _opts = [
('ipmi', ironic.conf.ipmi.opts), ('ipmi', ironic.conf.ipmi.opts),
('irmc', ironic.conf.irmc.opts), ('irmc', ironic.conf.irmc.opts),
('json_rpc', ironic.conf.json_rpc.list_opts()), ('json_rpc', ironic.conf.json_rpc.list_opts()),
('local_rpc', ironic.conf.local_rpc.list_opts()),
('mdns', ironic.conf.mdns.opts), ('mdns', ironic.conf.mdns.opts),
('metrics', ironic.conf.metrics.opts), ('metrics', ironic.conf.metrics.opts),
('metrics_statsd', ironic.conf.metrics.statsd_opts), ('metrics_statsd', ironic.conf.metrics.statsd_opts),

View File

@@ -1,69 +0,0 @@
# Copyright 2020 Red Hat, Inc.
#
# 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(dtantsur): partial copy from IPA commit
# d86923e7ff40c3ec1d43fe9d4068f0bd3b17de67
import datetime
import ipaddress
import os
import tempfile
from cryptography.hazmat import backends
from cryptography import x509
from ironic.common import tls_utils
from ironic.tests import base
class GenerateTestCase(base.TestCase):
def setUp(self):
super().setUp()
tempdir = tempfile.mkdtemp()
self.crt_file = os.path.join(tempdir, 'localhost.crt')
self.key_file = os.path.join(tempdir, 'localhost.key')
def test__generate(self):
result = tls_utils.generate_tls_certificate(self.crt_file,
self.key_file,
'localhost', '127.0.0.1')
now = datetime.datetime.now(
tz=datetime.timezone.utc).replace(tzinfo=None)
self.assertTrue(result.startswith("-----BEGIN CERTIFICATE-----\n"),
result)
self.assertTrue(result.endswith("\n-----END CERTIFICATE-----\n"),
result)
self.assertTrue(os.path.exists(self.key_file))
with open(self.crt_file, 'rt') as fp:
self.assertEqual(result, fp.read())
cert = x509.load_pem_x509_certificate(result.encode(),
backends.default_backend())
self.assertEqual([(x509.NameOID.COMMON_NAME, 'localhost')],
[(item.oid, item.value) for item in cert.subject])
# Sanity check for validity range
# FIXME(dtantsur): use timezone-aware properties and drop the replace()
# call above when we're ready to bump to cryptography 42.0.
self.assertLessEqual(cert.not_valid_before, now)
self.assertGreater(cert.not_valid_after,
now + datetime.timedelta(seconds=1800))
subject_alt_name = cert.extensions.get_extension_for_oid(
x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
self.assertTrue(subject_alt_name.critical)
self.assertEqual(
[ipaddress.IPv4Address('127.0.0.1')],
subject_alt_name.value.get_values_for_type(x509.IPAddress))
self.assertEqual(
[], subject_alt_name.value.get_values_for_type(x509.DNSName))

View File

@@ -1,169 +0,0 @@
# 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 ipaddress
import os
import socket
from unittest import mock
import bcrypt
from cryptography.hazmat import backends
from cryptography import x509
from ironic.common import utils
from ironic.conductor import local_rpc
from ironic.conf import CONF
from ironic.tests import base as tests_base
@mock.patch('atexit.register', autospec=True)
@mock.patch.object(local_rpc, '_lo_has_ipv6', autospec=True)
class ConfigureTestCase(tests_base.TestCase):
def setUp(self):
super().setUp()
CONF.set_override('rpc_transport', 'none')
self.addCleanup(self._cleanup_files)
def _cleanup_files(self):
if CONF.json_rpc.cert_file:
utils.unlink_without_raise(CONF.json_rpc.cert_file)
if CONF.json_rpc.key_file:
utils.unlink_without_raise(CONF.json_rpc.key_file)
if CONF.json_rpc.http_basic_auth_user_file:
utils.unlink_without_raise(CONF.json_rpc.http_basic_auth_user_file)
def _verify_tls(self, ipv6=True):
self.assertTrue(os.path.exists(CONF.json_rpc.key_file))
with open(CONF.json_rpc.cert_file, 'rb') as fp:
cert = x509.load_pem_x509_certificate(
fp.read(), backends.default_backend())
# NOTE(dtantsur): most of the TLS generation is tested in
# test_tls_utils, here only the relevant parts
subject_alt_name = cert.extensions.get_extension_for_oid(
x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
expected = (ipaddress.IPv6Address('::1') if ipv6
else ipaddress.IPv4Address('127.0.0.1'))
self.assertEqual(
[expected],
subject_alt_name.value.get_values_for_type(x509.IPAddress))
def _verify_password(self):
self.assertEqual('ironic', CONF.json_rpc.username)
self.assertTrue(CONF.json_rpc.password)
with open(CONF.json_rpc.http_basic_auth_user_file) as fp:
username, hashed = fp.read().strip().split(':', 1)
self.assertEqual(username, CONF.json_rpc.username)
self.assertTrue(
bcrypt.checkpw(CONF.json_rpc.password.encode(), hashed.encode()))
def test_wrong_rpc_transport(self, mock_lo_has_ipv6, mock_atexit_register):
CONF.set_override('rpc_transport', 'oslo')
local_rpc.configure()
mock_lo_has_ipv6.assert_not_called()
mock_atexit_register.assert_not_called()
self.assertIsNone(CONF.json_rpc.cert_file)
def test_default(self, mock_lo_has_ipv6, mock_atexit_register):
mock_lo_has_ipv6.return_value = True
local_rpc.configure()
self.assertTrue(CONF.json_rpc.use_ssl)
self.assertEqual('http_basic', CONF.json_rpc.auth_type)
self.assertEqual('http_basic', CONF.json_rpc.auth_strategy)
self.assertEqual('::1', CONF.json_rpc.host_ip)
self._verify_password()
self._verify_tls(ipv6=True)
def test_ipv4(self, mock_lo_has_ipv6, mock_atexit_register):
mock_lo_has_ipv6.return_value = False
local_rpc.configure()
self.assertTrue(CONF.json_rpc.use_ssl)
self.assertEqual('http_basic', CONF.json_rpc.auth_type)
self.assertEqual('http_basic', CONF.json_rpc.auth_strategy)
self.assertEqual('127.0.0.1', CONF.json_rpc.host_ip)
self._verify_password()
self._verify_tls(ipv6=False)
@mock.patch('socket.socket', autospec=True)
class LoHasIpv6TestCase(tests_base.TestCase):
def test_ipv6_available(self, mock_socket):
# Mock successful IPv6 socket creation and bind
mock_sock = mock.Mock()
mock_sock.__enter__ = mock.Mock(return_value=mock_sock)
mock_sock.__exit__ = mock.Mock(return_value=False)
mock_socket.return_value = mock_sock
result = local_rpc._lo_has_ipv6()
# Verify socket operations
mock_socket.assert_called_once_with(socket.AF_INET6,
socket.SOCK_STREAM)
mock_sock.setsockopt.assert_called_once_with(socket.SOL_SOCKET,
socket.SO_REUSEADDR,
1)
mock_sock.bind.assert_called_once_with(('::1', 0))
self.assertTrue(result)
def test_ipv6_not_available_os_error(self, mock_socket):
# Mock failed IPv6 socket bind (IPv6 not available)
mock_sock = mock.Mock()
mock_sock.__enter__ = mock.Mock(return_value=mock_sock)
mock_sock.__exit__ = mock.Mock(return_value=False)
mock_socket.return_value = mock_sock
mock_sock.bind.side_effect = OSError("Cannot assign requested address")
result = local_rpc._lo_has_ipv6()
# Verify socket operations attempted
mock_socket.assert_called_once_with(socket.AF_INET6,
socket.SOCK_STREAM)
mock_sock.setsockopt.assert_called_once_with(socket.SOL_SOCKET,
socket.SO_REUSEADDR,
1)
mock_sock.bind.assert_called_once_with(('::1', 0))
self.assertFalse(result)
def test_ipv6_not_available_socket_error(self, mock_socket):
# Mock socket.error during bind
mock_sock = mock.Mock()
mock_sock.__enter__ = mock.Mock(return_value=mock_sock)
mock_sock.__exit__ = mock.Mock(return_value=False)
mock_socket.return_value = mock_sock
mock_sock.bind.side_effect = socket.error("Network unreachable")
result = local_rpc._lo_has_ipv6()
# Verify socket operations attempted
mock_socket.assert_called_once_with(socket.AF_INET6,
socket.SOCK_STREAM)
mock_sock.setsockopt.assert_called_once_with(socket.SOL_SOCKET,
socket.SO_REUSEADDR,
1)
mock_sock.bind.assert_called_once_with(('::1', 0))
self.assertFalse(result)
def test_ipv6_not_available_socket_creation_fails(self, mock_socket):
# Mock socket creation failure
mock_socket.side_effect = OSError("Address family not supported")
result = local_rpc._lo_has_ipv6()
# Verify socket creation attempted
mock_socket.assert_called_once_with(socket.AF_INET6,
socket.SOCK_STREAM)
self.assertFalse(result)

View File

@@ -74,6 +74,30 @@ class TestRPCService(db_base.DbTestCase):
self.assertTrue(self.rpc_svc._started) self.assertTrue(self.rpc_svc._started)
self.assertFalse(self.rpc_svc._failure) self.assertFalse(self.rpc_svc._failure)
@mock.patch.object(console_factory, 'ConsoleContainerFactory',
autospec=True)
@mock.patch.object(manager.ConductorManager, 'prepare_host', autospec=True)
@mock.patch.object(oslo_messaging, 'Target', autospec=True)
@mock.patch.object(objects_base, 'IronicObjectSerializer', autospec=True)
@mock.patch.object(rpc, 'get_server', autospec=True)
@mock.patch.object(manager.ConductorManager, 'init_host', autospec=True)
@mock.patch.object(context, 'get_admin_context', autospec=True)
def test_start_no_rpc(self, mock_ctx, mock_init_method,
mock_rpc, mock_ios, mock_target,
mock_prepare_method, mock_console_factory):
CONF.set_override('rpc_transport', 'none')
self.rpc_svc.start()
self.assertIsNone(self.rpc_svc.rpcserver)
mock_ctx.assert_called_once_with()
mock_target.assert_not_called()
mock_rpc.assert_not_called()
mock_ios.assert_called_once_with(is_server=True)
mock_prepare_method.assert_called_once_with(self.rpc_svc.manager)
mock_init_method.assert_called_once_with(self.rpc_svc.manager,
mock_ctx.return_value)
self.assertIs(rpc.GLOBAL_MANAGER, self.rpc_svc.manager)
@mock.patch.object(console_factory, 'ConsoleContainerFactory', @mock.patch.object(console_factory, 'ConsoleContainerFactory',
autospec=True) autospec=True)
@mock.patch.object(manager.ConductorManager, 'prepare_host', autospec=True) @mock.patch.object(manager.ConductorManager, 'prepare_host', autospec=True)

View File

@@ -31,8 +31,8 @@ from ironic.common import components
from ironic.common import exception from ironic.common import exception
from ironic.common import indicator_states from ironic.common import indicator_states
from ironic.common import release_mappings from ironic.common import release_mappings
from ironic.common import rpc
from ironic.common import states from ironic.common import states
from ironic.conductor import local_rpc
from ironic.conductor import manager as conductor_manager from ironic.conductor import manager as conductor_manager
from ironic.conductor import rpcapi as conductor_rpcapi from ironic.conductor import rpcapi as conductor_rpcapi
from ironic import objects from ironic import objects
@@ -79,7 +79,8 @@ class RPCAPITestCase(db_base.DbTestCase):
def test_rpc_disabled(self): def test_rpc_disabled(self):
CONF.set_override('rpc_transport', 'none') CONF.set_override('rpc_transport', 'none')
rpcapi = conductor_rpcapi.ConductorAPI(topic='fake-topic') rpcapi = conductor_rpcapi.ConductorAPI(topic='fake-topic')
self.assertIsInstance(rpcapi.client, local_rpc.LocalClient) self.assertIsNone(rpcapi.client)
self.assertTrue(rpcapi._can_send_version('9.99'))
def test_serialized_instance_has_uuid(self): def test_serialized_instance_has_uuid(self):
self.assertIn('uuid', self.fake_node) self.assertIn('uuid', self.fake_node)
@@ -733,3 +734,80 @@ class RPCAPITestCase(db_base.DbTestCase):
service_steps={'foo': 'bar'}, service_steps={'foo': 'bar'},
disable_ramdisk=False, disable_ramdisk=False,
version='1.57') version='1.57')
@mock.patch.object(rpc, 'GLOBAL_MANAGER',
spec_set=conductor_manager.ConductorManager)
def test_local_call(self, mock_manager):
CONF.set_override('host', 'fake.host')
CONF.set_override('rpc_transport', 'none')
rpcapi = conductor_rpcapi.ConductorAPI(topic='fake.topic')
rpcapi.create_node(mock.sentinel.context, mock.sentinel.node,
topic='fake.topic.fake.host')
mock_manager.create_node.assert_called_once_with(
mock.sentinel.context, node_obj=mock.sentinel.node)
@mock.patch.object(rpc, 'GLOBAL_MANAGER',
spec_set=conductor_manager.ConductorManager)
def test_local_call_host_mismatch(self, mock_manager):
CONF.set_override('host', 'fake.host')
CONF.set_override('rpc_transport', 'none')
rpcapi = conductor_rpcapi.ConductorAPI(topic='fake.topic')
self.assertRaises(exception.ServiceUnavailable,
rpcapi.create_node,
mock.sentinel.context, mock.sentinel.node,
topic='fake.topic.not-fake.host')
@mock.patch.object(rpc, 'GLOBAL_MANAGER', None)
def test_local_call_no_conductor_with_rpc_disabled(self):
CONF.set_override('host', 'fake.host')
CONF.set_override('rpc_transport', 'none')
rpcapi = conductor_rpcapi.ConductorAPI(topic='fake.topic')
self.assertRaises(exception.ServiceUnavailable,
rpcapi.create_node,
mock.sentinel.context, mock.sentinel.node,
topic='fake.topic.fake.host')
@mock.patch.object(rpc, 'GLOBAL_MANAGER',
spec_set=conductor_manager.ConductorManager)
def test_local_cast(self, mock_manager):
CONF.set_override('host', 'fake.host')
CONF.set_override('rpc_transport', 'none')
rpcapi = conductor_rpcapi.ConductorAPI(topic='fake.topic')
cctxt = rpcapi._prepare_call(topic='fake.topic.fake.host')
cctxt.cast(mock.sentinel.context, 'create_node',
node_obj=mock.sentinel.node)
mock_manager.create_node.assert_called_once_with(
mock.sentinel.context, node_obj=mock.sentinel.node)
@mock.patch.object(conductor_rpcapi.LOG, 'exception', autospec=True)
@mock.patch.object(rpc, 'GLOBAL_MANAGER',
spec_set=conductor_manager.ConductorManager)
def test_local_cast_error(self, mock_manager, mock_log):
CONF.set_override('host', 'fake.host')
CONF.set_override('rpc_transport', 'none')
mock_manager.create_node.side_effect = RuntimeError('boom')
rpcapi = conductor_rpcapi.ConductorAPI(topic='fake.topic')
cctxt = rpcapi._prepare_call(topic='fake.topic.fake.host')
cctxt.cast(mock.sentinel.context, 'create_node',
node_obj=mock.sentinel.node)
mock_manager.create_node.assert_called_once_with(
mock.sentinel.context, node_obj=mock.sentinel.node)
self.assertTrue(mock_log.called)
@mock.patch.object(rpc, 'GLOBAL_MANAGER',
spec_set=conductor_manager.ConductorManager)
def test_local_call_expected_exception(self, mock_manager):
@messaging.expected_exceptions(exception.InvalidParameterValue)
def fake_create(context, node_obj):
raise exception.InvalidParameterValue('sorry')
CONF.set_override('host', 'fake.host')
CONF.set_override('rpc_transport', 'none')
rpcapi = conductor_rpcapi.ConductorAPI(topic='fake.topic')
mock_manager.create_node.side_effect = fake_create
self.assertRaisesRegex(exception.InvalidParameterValue, 'sorry',
rpcapi.create_node,
mock.sentinel.context, mock.sentinel.node,
topic='fake.topic.fake.host')
mock_manager.create_node.assert_called_once_with(
mock.sentinel.context, node_obj=mock.sentinel.node)

View File

@@ -0,0 +1,11 @@
---
fixes:
- |
No longer uses JSON RPC with ``[DEFAULT]rpc_transport`` set to ``none``.
It was required during the transition away from eventlet, and is no longer
needed. RPC can still be enabled by setting ``rpc_transport`` to
``json-rpc``.
upgrade:
- |
The options in the ``[local_rpc]`` group introduced in Ironic 31.0 have
been removed and no longer have any effect.

View File

@@ -47,5 +47,4 @@ 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 cheroot>=10.0.1 # BSD
cryptography>=2.3 # BSD/Apache-2.0
cotyledon>=2.0.0 # Apache-2.0 cotyledon>=2.0.0 # Apache-2.0