From 3831464751735ede659c972283bdc5c44d583e04 Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Fri, 11 Jul 2025 17:23:00 +0200 Subject: [PATCH] Switch from local RPC to automated JSON RPC on localhost Change-Id: I4a245b3820f8054cb8e6b716aa101aeb3876e504 Signed-off-by: Dmitry Tantsur --- .../configure-ironic-singleprocess.inc | 4 +- doc/source/install/standalone/configure.rst | 2 +- ironic/command/singleprocess.py | 3 + ironic/common/auth_basic.py | 7 ++ ironic/common/json_rpc/client.py | 10 +- ironic/common/rpc_service.py | 8 +- ironic/common/tls_utils.py | 93 ++++++++++++++ ironic/conductor/local_rpc.py | 114 ++++++++++++++++++ ironic/conductor/rpcapi.py | 65 +--------- ironic/conf/__init__.py | 2 + ironic/conf/local_rpc.py | 39 ++++++ ironic/conf/opts.py | 1 + ironic/tests/unit/common/test_tls_utils.py | 69 +++++++++++ ironic/tests/unit/conductor/test_local_rpc.py | 97 +++++++++++++++ .../tests/unit/conductor/test_rpc_service.py | 22 ---- ironic/tests/unit/conductor/test_rpcapi.py | 82 +------------ .../notes/localrpc-403d72535e3e0048.yaml | 9 ++ requirements.txt | 1 + 18 files changed, 458 insertions(+), 170 deletions(-) create mode 100644 ironic/common/tls_utils.py create mode 100644 ironic/conductor/local_rpc.py create mode 100644 ironic/conf/local_rpc.py create mode 100644 ironic/tests/unit/common/test_tls_utils.py create mode 100644 ironic/tests/unit/conductor/test_local_rpc.py create mode 100644 releasenotes/notes/localrpc-403d72535e3e0048.yaml diff --git a/doc/source/install/include/configure-ironic-singleprocess.inc b/doc/source/install/include/configure-ironic-singleprocess.inc index b63d4befac..78ecb78b44 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 disable the - RPC completely: + If you don't plan to have more than one conductor, you can switch to + the local RPC: .. code-block:: ini diff --git a/doc/source/install/standalone/configure.rst b/doc/source/install/standalone/configure.rst index cfa43fa996..570881646e 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 and completely disable the RPC. Set + API+conductor+novncproxy service with the local RPC. Set .. code-block:: ini diff --git a/ironic/command/singleprocess.py b/ironic/command/singleprocess.py index 04782e8ed2..93c44f3051 100644 --- a/ironic/command/singleprocess.py +++ b/ironic/command/singleprocess.py @@ -19,6 +19,7 @@ from oslo_service import service from ironic.command import conductor as conductor_cmd from ironic.common import service as ironic_service from ironic.common import wsgi_service +from ironic.conductor import local_rpc from ironic.conductor import rpc_service from ironic.console import novncproxy_service @@ -39,6 +40,8 @@ def main(): # Parse config file and command line options, then start logging ironic_service.prepare_service('ironic', sys.argv) + local_rpc.configure() + launcher = service.ServiceLauncher(CONF, restart_method='mutate') mgr = rpc_service.RPCService(CONF.host, diff --git a/ironic/common/auth_basic.py b/ironic/common/auth_basic.py index e338edee37..85856ec0b5 100644 --- a/ironic/common/auth_basic.py +++ b/ironic/common/auth_basic.py @@ -201,3 +201,10 @@ 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 5880dfba82..b90de1be33 100644 --- a/ironic/common/json_rpc/client.py +++ b/ironic/common/json_rpc/client.py @@ -87,9 +87,17 @@ 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 a06f8a076e..41a6074e80 100644 --- a/ironic/common/rpc_service.py +++ b/ironic/common/rpc_service.py @@ -73,13 +73,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 == 'json-rpc': - self.rpcserver = json_rpc.WSGIService( - self.manager, serializer, context.RequestContext.from_dict) - elif CONF.rpc_transport != 'none': + if CONF.rpc_transport == 'oslo': 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 new file mode 100644 index 0000000000..3bdbc916b6 --- /dev/null +++ b/ironic/common/tls_utils.py @@ -0,0 +1,93 @@ +# 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 new file mode 100644 index 0000000000..79f55b7230 --- /dev/null +++ b/ironic/conductor/local_rpc.py @@ -0,0 +1,114 @@ +# 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 os +import secrets +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(): + return ( + os.path.exists("/proc/sys/net/ipv6/conf/lo/disable_ipv6") + and open("/proc/sys/net/ipv6/conf/lo/disable_ipv6").read() != "1" + ) + + +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 8200f47576..d92e2f62aa 100644 --- a/ironic/conductor/rpcapi.py +++ b/ironic/conductor/rpcapi.py @@ -29,6 +29,7 @@ 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 @@ -39,46 +40,6 @@ 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. @@ -182,12 +143,13 @@ class ConductorAPI(object): self.client = json_rpc.Client(serializer=serializer, version_cap=version_cap) 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') 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() @@ -204,23 +166,6 @@ 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 7ce493c993..413bbf0f07 100644 --- a/ironic/conf/__init__.py +++ b/ironic/conf/__init__.py @@ -41,6 +41,7 @@ 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 @@ -83,6 +84,7 @@ 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/local_rpc.py b/ironic/conf/local_rpc.py new file mode 100644 index 0000000000..7b349f447f --- /dev/null +++ b/ironic/conf/local_rpc.py @@ -0,0 +1,39 @@ +# 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 85e84761d5..b33219f306 100644 --- a/ironic/conf/opts.py +++ b/ironic/conf/opts.py @@ -40,6 +40,7 @@ _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 new file mode 100644 index 0000000000..e2f9c6942b --- /dev/null +++ b/ironic/tests/unit/common/test_tls_utils.py @@ -0,0 +1,69 @@ +# 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 new file mode 100644 index 0000000000..e3b2ba1a91 --- /dev/null +++ b/ironic/tests/unit/conductor/test_local_rpc.py @@ -0,0 +1,97 @@ +# 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 +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) diff --git a/ironic/tests/unit/conductor/test_rpc_service.py b/ironic/tests/unit/conductor/test_rpc_service.py index d872ba22ab..f8b585ede3 100644 --- a/ironic/tests/unit/conductor/test_rpc_service.py +++ b/ironic/tests/unit/conductor/test_rpc_service.py @@ -69,28 +69,6 @@ class TestRPCService(db_base.DbTestCase): self.assertFalse(self.rpc_svc._failure) self.rpc_svc.wait_for_start() # should be no-op - @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): - 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(manager.ConductorManager, 'prepare_host', autospec=True) @mock.patch.object(oslo_messaging, 'Target', autospec=True) @mock.patch.object(objects_base, 'IronicObjectSerializer', autospec=True) diff --git a/ironic/tests/unit/conductor/test_rpcapi.py b/ironic/tests/unit/conductor/test_rpcapi.py index cc6a6d5627..c497e9f472 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,8 +79,7 @@ class RPCAPITestCase(db_base.DbTestCase): def test_rpc_disabled(self): CONF.set_override('rpc_transport', 'none') rpcapi = conductor_rpcapi.ConductorAPI(topic='fake-topic') - self.assertIsNone(rpcapi.client) - self.assertTrue(rpcapi._can_send_version('9.99')) + self.assertIsInstance(rpcapi.client, local_rpc.LocalClient) def test_serialized_instance_has_uuid(self): self.assertIn('uuid', self.fake_node) @@ -734,80 +733,3 @@ 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/localrpc-403d72535e3e0048.yaml b/releasenotes/notes/localrpc-403d72535e3e0048.yaml new file mode 100644 index 0000000000..27512abfd2 --- /dev/null +++ b/releasenotes/notes/localrpc-403d72535e3e0048.yaml @@ -0,0 +1,9 @@ +--- +upgrade: + - | + All-in-one Ironic processes that use ``rpc_transport=none`` are switched + to using JSON RPC over localhost on upgrade. This is because the current + model is not compatible with the post-eventlet architecture. + + Make sure that local traffic is possible on port 8089. If not, you may + change the port by modifying the ``[json_rpc]port`` option. diff --git a/requirements.txt b/requirements.txt index d18249b4e7..9101e4d07b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -50,3 +50,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