diff --git a/doc/source/install/include/configure-ironic-singleprocess.inc b/doc/source/install/include/configure-ironic-singleprocess.inc index a3de04d329..0e624ee2e7 100644 --- a/doc/source/install/include/configure-ironic-singleprocess.inc +++ b/doc/source/install/include/configure-ironic-singleprocess.inc @@ -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 service started or if you have additional conductors. - If you don't plan to have more than one conductor, you can switch to - the local RPC: + If you don't plan to have more than one conductor, you can disable the + RPC completely: .. code-block:: ini diff --git a/doc/source/install/standalone/configure.rst b/doc/source/install/standalone/configure.rst index 570881646e..cfa43fa996 100644 --- a/doc/source/install/standalone/configure.rst +++ b/doc/source/install/standalone/configure.rst @@ -106,7 +106,7 @@ You should make the following changes to ``/etc/ironic/ironic.conf``: console_image= #. 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 diff --git a/ironic/command/singleprocess.py b/ironic/command/singleprocess.py index 27237d0176..0f4cff34ef 100644 --- a/ironic/command/singleprocess.py +++ b/ironic/command/singleprocess.py @@ -19,7 +19,6 @@ from oslo_service import service from ironic.command import conductor as conductor_cmd from ironic.command import utils from ironic.common import service as ironic_service -from ironic.conductor import local_rpc from ironic.conductor import rpc_service from ironic.console import novncproxy_service @@ -40,8 +39,6 @@ def main(): # Parse config file and command line options, then start logging ironic_service.prepare_service('ironic', sys.argv) - local_rpc.configure() - # Choose the launcher based upon if vnc is enabled or not. # The VNC proxy has to be run in the parent process, not # a sub-process. diff --git a/ironic/common/auth_basic.py b/ironic/common/auth_basic.py index 43a60f46bf..30386bca31 100644 --- a/ironic/common/auth_basic.py +++ b/ironic/common/auth_basic.py @@ -212,10 +212,3 @@ def unauthorized(message=None): if not message: message = _('Incorrect username or password') 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") diff --git a/ironic/common/json_rpc/client.py b/ironic/common/json_rpc/client.py index b90de1be33..5880dfba82 100644 --- a/ironic/common/json_rpc/client.py +++ b/ironic/common/json_rpc/client.py @@ -87,17 +87,9 @@ class Client(object): is the hostname of the remote json-rpc service. :param version: The RPC API version to utilize. """ + host = topic.split('.', 1)[1] 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( host, self.serializer, version=version, version_cap=self.version_cap, diff --git a/ironic/common/rpc_service.py b/ironic/common/rpc_service.py index d2137f6ac8..1719aac4af 100644 --- a/ironic/common/rpc_service.py +++ b/ironic/common/rpc_service.py @@ -61,13 +61,13 @@ class BaseRPCService(service.Service): serializer = objects_base.IronicObjectSerializer(is_server=True) # Perform preparatory actions before starting the RPC listener 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) endpoints = [self.manager] 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: self.rpcserver.start() diff --git a/ironic/common/tls_utils.py b/ironic/common/tls_utils.py deleted file mode 100644 index 3bdbc916b6..0000000000 --- a/ironic/common/tls_utils.py +++ /dev/null @@ -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') diff --git a/ironic/conductor/local_rpc.py b/ironic/conductor/local_rpc.py deleted file mode 100644 index 7725e599eb..0000000000 --- a/ironic/conductor/local_rpc.py +++ /dev/null @@ -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) diff --git a/ironic/conductor/rpcapi.py b/ironic/conductor/rpcapi.py index d92e2f62aa..8200f47576 100644 --- a/ironic/conductor/rpcapi.py +++ b/ironic/conductor/rpcapi.py @@ -29,7 +29,6 @@ from ironic.common.i18n import _ from ironic.common.json_rpc import client as json_rpc from ironic.common import release_mappings as versions from ironic.common import rpc -from ironic.conductor import local_rpc from ironic.conf import CONF from ironic.db import api as dbapi from ironic.objects import base as objects_base @@ -40,6 +39,46 @@ LOG = log.getLogger(__name__) 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): """Client side of the conductor RPC API. @@ -143,13 +182,12 @@ class ConductorAPI(object): self.client = json_rpc.Client(serializer=serializer, version_cap=version_cap) self.topic = '' - elif CONF.rpc_transport == 'none': - self.client = local_rpc.LocalClient(serializer=serializer, - version_cap=version_cap) - else: + elif CONF.rpc_transport != 'none': target = messaging.Target(topic=self.topic, version='1.0') self.client = rpc.get_client(target, version_cap=version_cap, serializer=serializer) + else: + self.client = None # NOTE(tenbrae): this is going to be buggy 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 # conductor. Do we even need this fallback? topic = topic or self.topic + # Normally a topic is a ., 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) def get_conductor_for(self, node): diff --git a/ironic/conf/__init__.py b/ironic/conf/__init__.py index 413bbf0f07..7ce493c993 100644 --- a/ironic/conf/__init__.py +++ b/ironic/conf/__init__.py @@ -41,7 +41,6 @@ from ironic.conf import inventory from ironic.conf import ipmi from ironic.conf import irmc from ironic.conf import json_rpc -from ironic.conf import local_rpc from ironic.conf import mdns from ironic.conf import metrics from ironic.conf import molds @@ -84,7 +83,6 @@ inventory.register_opts(CONF) ipmi.register_opts(CONF) irmc.register_opts(CONF) json_rpc.register_opts(CONF) -local_rpc.register_opts(CONF) mdns.register_opts(CONF) metrics.register_opts(CONF) molds.register_opts(CONF) diff --git a/ironic/conf/default.py b/ironic/conf/default.py index 2b8a54104e..bfd505e4a8 100644 --- a/ironic/conf/default.py +++ b/ironic/conf/default.py @@ -104,9 +104,7 @@ api_opts = [ mutable=False, help=_('If the Ironic API should utilize the RPC layer for ' 'database interactions as opposed to directly ' - 'connecting to the database API endpoint. Defaults ' - 'to False, however is implied when the ' - '[default]rpc_transport option is set to \'none\'.')), + 'connecting to the database API endpoint.')), ] driver_opts = [ diff --git a/ironic/conf/local_rpc.py b/ironic/conf/local_rpc.py deleted file mode 100644 index 7b349f447f..0000000000 --- a/ironic/conf/local_rpc.py +++ /dev/null @@ -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 diff --git a/ironic/conf/opts.py b/ironic/conf/opts.py index 7ae0edc872..5481a73dc8 100644 --- a/ironic/conf/opts.py +++ b/ironic/conf/opts.py @@ -40,7 +40,6 @@ _opts = [ ('ipmi', ironic.conf.ipmi.opts), ('irmc', ironic.conf.irmc.opts), ('json_rpc', ironic.conf.json_rpc.list_opts()), - ('local_rpc', ironic.conf.local_rpc.list_opts()), ('mdns', ironic.conf.mdns.opts), ('metrics', ironic.conf.metrics.opts), ('metrics_statsd', ironic.conf.metrics.statsd_opts), diff --git a/ironic/tests/unit/common/test_tls_utils.py b/ironic/tests/unit/common/test_tls_utils.py deleted file mode 100644 index e2f9c6942b..0000000000 --- a/ironic/tests/unit/common/test_tls_utils.py +++ /dev/null @@ -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)) diff --git a/ironic/tests/unit/conductor/test_local_rpc.py b/ironic/tests/unit/conductor/test_local_rpc.py deleted file mode 100644 index 942eaf4aa8..0000000000 --- a/ironic/tests/unit/conductor/test_local_rpc.py +++ /dev/null @@ -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) diff --git a/ironic/tests/unit/conductor/test_rpc_service.py b/ironic/tests/unit/conductor/test_rpc_service.py index 3d162310c2..9342204f30 100644 --- a/ironic/tests/unit/conductor/test_rpc_service.py +++ b/ironic/tests/unit/conductor/test_rpc_service.py @@ -74,6 +74,30 @@ class TestRPCService(db_base.DbTestCase): self.assertTrue(self.rpc_svc._started) 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', autospec=True) @mock.patch.object(manager.ConductorManager, 'prepare_host', autospec=True) diff --git a/ironic/tests/unit/conductor/test_rpcapi.py b/ironic/tests/unit/conductor/test_rpcapi.py index c497e9f472..cc6a6d5627 100644 --- a/ironic/tests/unit/conductor/test_rpcapi.py +++ b/ironic/tests/unit/conductor/test_rpcapi.py @@ -31,8 +31,8 @@ from ironic.common import components from ironic.common import exception from ironic.common import indicator_states from ironic.common import release_mappings +from ironic.common import rpc from ironic.common import states -from ironic.conductor import local_rpc from ironic.conductor import manager as conductor_manager from ironic.conductor import rpcapi as conductor_rpcapi from ironic import objects @@ -79,7 +79,8 @@ class RPCAPITestCase(db_base.DbTestCase): def test_rpc_disabled(self): CONF.set_override('rpc_transport', 'none') 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): self.assertIn('uuid', self.fake_node) @@ -733,3 +734,80 @@ class RPCAPITestCase(db_base.DbTestCase): service_steps={'foo': 'bar'}, disable_ramdisk=False, 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) diff --git a/releasenotes/notes/no-localrpc-09e47b3a9229ad8e.yaml b/releasenotes/notes/no-localrpc-09e47b3a9229ad8e.yaml new file mode 100644 index 0000000000..4f24e2aa6c --- /dev/null +++ b/releasenotes/notes/no-localrpc-09e47b3a9229ad8e.yaml @@ -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. diff --git a/requirements.txt b/requirements.txt index 3d8637a2f6..c4ad3e855b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,5 +47,4 @@ bcrypt>=3.1.3 # Apache-2.0 websockify>=0.9.0 # LGPLv3 PyYAML>=6.0.2 # MIT cheroot>=10.0.1 # BSD -cryptography>=2.3 # BSD/Apache-2.0 cotyledon>=2.0.0 # Apache-2.0