Revert "Switch from local RPC to automated JSON RPC on localhost"
The initial concern was the multi-process model that cotyledon uses. Commit71dd34a7bd
moved API to the conductor process, so this no longer applies. This reverts commit3831464751
. Generated-By: Claude Code Change-Id: Iaeabcfb8f6558220a10060ccca788f1f4b959f0e Signed-off-by: Dmitry Tantsur <dtantsur@protonmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
@@ -106,7 +106,7 @@ You should make the following changes to ``/etc/ironic/ironic.conf``:
|
||||
console_image=<image reference>
|
||||
|
||||
#. 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
|
||||
|
||||
|
@@ -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.
|
||||
|
@@ -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")
|
||||
|
@@ -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,
|
||||
|
@@ -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()
|
||||
|
@@ -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')
|
@@ -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)
|
@@ -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 <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)
|
||||
|
||||
def get_conductor_for(self, node):
|
||||
|
@@ -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)
|
||||
|
@@ -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 = [
|
||||
|
@@ -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
|
@@ -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),
|
||||
|
@@ -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))
|
@@ -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)
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
11
releasenotes/notes/no-localrpc-09e47b3a9229ad8e.yaml
Normal file
11
releasenotes/notes/no-localrpc-09e47b3a9229ad8e.yaml
Normal 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.
|
@@ -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
|
||||
|
Reference in New Issue
Block a user