Switch from local RPC to automated JSON RPC on localhost
Change-Id: I4a245b3820f8054cb8e6b716aa101aeb3876e504 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
|
Any RPC settings will only take effect if you have more than one combined
|
||||||
service started or if you have additional conductors.
|
service started or if you have additional conductors.
|
||||||
|
|
||||||
If you don't plan to have more than one conductor, you can disable the
|
If you don't plan to have more than one conductor, you can switch to
|
||||||
RPC completely:
|
the local RPC:
|
||||||
|
|
||||||
.. code-block:: ini
|
.. code-block:: ini
|
||||||
|
|
||||||
|
@@ -106,7 +106,7 @@ You should make the following changes to ``/etc/ironic/ironic.conf``:
|
|||||||
console_image=<image reference>
|
console_image=<image reference>
|
||||||
|
|
||||||
#. Starting with the Yoga release series, you can use a combined
|
#. Starting with the Yoga release series, you can use a combined
|
||||||
API+conductor+novncproxy service and completely disable the RPC. Set
|
API+conductor+novncproxy service with the local RPC. Set
|
||||||
|
|
||||||
.. code-block:: ini
|
.. code-block:: ini
|
||||||
|
|
||||||
|
@@ -19,6 +19,7 @@ from oslo_service import service
|
|||||||
from ironic.command import conductor as conductor_cmd
|
from ironic.command import conductor as conductor_cmd
|
||||||
from ironic.common import service as ironic_service
|
from ironic.common import service as ironic_service
|
||||||
from ironic.common import wsgi_service
|
from ironic.common import wsgi_service
|
||||||
|
from ironic.conductor import local_rpc
|
||||||
from ironic.conductor import rpc_service
|
from ironic.conductor import rpc_service
|
||||||
from ironic.console import novncproxy_service
|
from ironic.console import novncproxy_service
|
||||||
|
|
||||||
@@ -39,6 +40,8 @@ def main():
|
|||||||
# Parse config file and command line options, then start logging
|
# Parse config file and command line options, then start logging
|
||||||
ironic_service.prepare_service('ironic', sys.argv)
|
ironic_service.prepare_service('ironic', sys.argv)
|
||||||
|
|
||||||
|
local_rpc.configure()
|
||||||
|
|
||||||
launcher = service.ServiceLauncher(CONF, restart_method='mutate')
|
launcher = service.ServiceLauncher(CONF, restart_method='mutate')
|
||||||
|
|
||||||
mgr = rpc_service.RPCService(CONF.host,
|
mgr = rpc_service.RPCService(CONF.host,
|
||||||
|
@@ -201,3 +201,10 @@ def unauthorized(message=None):
|
|||||||
if not message:
|
if not message:
|
||||||
message = _('Incorrect username or password')
|
message = _('Incorrect username or password')
|
||||||
raise exception.Unauthorized(message)
|
raise exception.Unauthorized(message)
|
||||||
|
|
||||||
|
|
||||||
|
def write_password(fileobj, username, password):
|
||||||
|
"""Write a record with the username and password to the file."""
|
||||||
|
pw_hash = bcrypt.hashpw(
|
||||||
|
password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||||
|
fileobj.write(f"{username}:{pw_hash}\n")
|
||||||
|
@@ -87,9 +87,17 @@ class Client(object):
|
|||||||
is the hostname of the remote json-rpc service.
|
is the hostname of the remote json-rpc service.
|
||||||
:param version: The RPC API version to utilize.
|
:param version: The RPC API version to utilize.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
host = topic.split('.', 1)[1]
|
host = topic.split('.', 1)[1]
|
||||||
host, port = netutils.parse_host_port(host)
|
host, port = netutils.parse_host_port(host)
|
||||||
|
return self.prepare_for_target(host, port, version=version)
|
||||||
|
|
||||||
|
def prepare_for_target(self, host, port=None, version=None):
|
||||||
|
"""Prepare the client to transmit a request to the provided target.
|
||||||
|
|
||||||
|
:param host: Remote host to connect to.
|
||||||
|
:param port: Port to use.
|
||||||
|
:param version: The RPC API version to utilize.
|
||||||
|
"""
|
||||||
return _CallContext(
|
return _CallContext(
|
||||||
host, self.serializer, version=version,
|
host, self.serializer, version=version,
|
||||||
version_cap=self.version_cap,
|
version_cap=self.version_cap,
|
||||||
|
@@ -73,13 +73,13 @@ class BaseRPCService(service.Service):
|
|||||||
serializer = objects_base.IronicObjectSerializer(is_server=True)
|
serializer = objects_base.IronicObjectSerializer(is_server=True)
|
||||||
# Perform preparatory actions before starting the RPC listener
|
# Perform preparatory actions before starting the RPC listener
|
||||||
self.manager.prepare_host()
|
self.manager.prepare_host()
|
||||||
if CONF.rpc_transport == 'json-rpc':
|
if CONF.rpc_transport == 'oslo':
|
||||||
self.rpcserver = json_rpc.WSGIService(
|
|
||||||
self.manager, serializer, context.RequestContext.from_dict)
|
|
||||||
elif CONF.rpc_transport != 'none':
|
|
||||||
target = messaging.Target(topic=self.topic, server=self.host)
|
target = messaging.Target(topic=self.topic, server=self.host)
|
||||||
endpoints = [self.manager]
|
endpoints = [self.manager]
|
||||||
self.rpcserver = rpc.get_server(target, endpoints, serializer)
|
self.rpcserver = rpc.get_server(target, endpoints, serializer)
|
||||||
|
else:
|
||||||
|
self.rpcserver = json_rpc.WSGIService(
|
||||||
|
self.manager, serializer, context.RequestContext.from_dict)
|
||||||
|
|
||||||
if self.rpcserver is not None:
|
if self.rpcserver is not None:
|
||||||
self.rpcserver.start()
|
self.rpcserver.start()
|
||||||
|
93
ironic/common/tls_utils.py
Normal file
93
ironic/common/tls_utils.py
Normal file
@@ -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')
|
114
ironic/conductor/local_rpc.py
Normal file
114
ironic/conductor/local_rpc.py
Normal file
@@ -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)
|
@@ -29,6 +29,7 @@ from ironic.common.i18n import _
|
|||||||
from ironic.common.json_rpc import client as json_rpc
|
from ironic.common.json_rpc import client as json_rpc
|
||||||
from ironic.common import release_mappings as versions
|
from ironic.common import release_mappings as versions
|
||||||
from ironic.common import rpc
|
from ironic.common import rpc
|
||||||
|
from ironic.conductor import local_rpc
|
||||||
from ironic.conf import CONF
|
from ironic.conf import CONF
|
||||||
from ironic.db import api as dbapi
|
from ironic.db import api as dbapi
|
||||||
from ironic.objects import base as objects_base
|
from ironic.objects import base as objects_base
|
||||||
@@ -39,46 +40,6 @@ LOG = log.getLogger(__name__)
|
|||||||
DBAPI = dbapi.get_instance()
|
DBAPI = dbapi.get_instance()
|
||||||
|
|
||||||
|
|
||||||
class LocalContext:
|
|
||||||
"""Context to make calls to a local conductor."""
|
|
||||||
|
|
||||||
__slots__ = ()
|
|
||||||
|
|
||||||
def call(self, context, rpc_call_name, **kwargs):
|
|
||||||
"""Make a local conductor call."""
|
|
||||||
if rpc.GLOBAL_MANAGER is None:
|
|
||||||
raise exception.ServiceUnavailable(
|
|
||||||
_("The built-in conductor is not available, it might have "
|
|
||||||
"crashed. Please check the logs and correct the "
|
|
||||||
"configuration, if required."))
|
|
||||||
try:
|
|
||||||
return getattr(rpc.GLOBAL_MANAGER, rpc_call_name)(context,
|
|
||||||
**kwargs)
|
|
||||||
# FIXME(dtantsur): can we somehow avoid wrapping the exception?
|
|
||||||
except messaging.ExpectedException as exc:
|
|
||||||
exc_value, exc_tb = exc.exc_info[1:]
|
|
||||||
raise exc_value.with_traceback(exc_tb) from None
|
|
||||||
|
|
||||||
def cast(self, context, rpc_call_name, **kwargs):
|
|
||||||
"""Make a local conductor call.
|
|
||||||
|
|
||||||
It is expected that the underlying call uses a thread to avoid
|
|
||||||
blocking the caller.
|
|
||||||
|
|
||||||
Any exceptions are logged and ignored.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return self.call(context, rpc_call_name, **kwargs)
|
|
||||||
except Exception:
|
|
||||||
# In real RPC, casts are completely asynchronous and never return
|
|
||||||
# actual errors.
|
|
||||||
LOG.exception('Ignoring unhandled exception from RPC cast %s',
|
|
||||||
rpc_call_name)
|
|
||||||
|
|
||||||
|
|
||||||
_LOCAL_CONTEXT = LocalContext()
|
|
||||||
|
|
||||||
|
|
||||||
class ConductorAPI(object):
|
class ConductorAPI(object):
|
||||||
"""Client side of the conductor RPC API.
|
"""Client side of the conductor RPC API.
|
||||||
|
|
||||||
@@ -182,12 +143,13 @@ class ConductorAPI(object):
|
|||||||
self.client = json_rpc.Client(serializer=serializer,
|
self.client = json_rpc.Client(serializer=serializer,
|
||||||
version_cap=version_cap)
|
version_cap=version_cap)
|
||||||
self.topic = ''
|
self.topic = ''
|
||||||
elif CONF.rpc_transport != 'none':
|
elif CONF.rpc_transport == 'none':
|
||||||
|
self.client = local_rpc.LocalClient(serializer=serializer,
|
||||||
|
version_cap=version_cap)
|
||||||
|
else:
|
||||||
target = messaging.Target(topic=self.topic, version='1.0')
|
target = messaging.Target(topic=self.topic, version='1.0')
|
||||||
self.client = rpc.get_client(target, version_cap=version_cap,
|
self.client = rpc.get_client(target, version_cap=version_cap,
|
||||||
serializer=serializer)
|
serializer=serializer)
|
||||||
else:
|
|
||||||
self.client = None
|
|
||||||
|
|
||||||
# NOTE(tenbrae): this is going to be buggy
|
# NOTE(tenbrae): this is going to be buggy
|
||||||
self.ring_manager = hash_ring.HashRingManager()
|
self.ring_manager = hash_ring.HashRingManager()
|
||||||
@@ -204,23 +166,6 @@ class ConductorAPI(object):
|
|||||||
# FIXME(dtantsur): this doesn't work with either JSON RPC or local
|
# FIXME(dtantsur): this doesn't work with either JSON RPC or local
|
||||||
# conductor. Do we even need this fallback?
|
# conductor. Do we even need this fallback?
|
||||||
topic = topic or self.topic
|
topic = topic or self.topic
|
||||||
# Normally a topic is a <topic prefix>.<hostname>, we need to extract
|
|
||||||
# the hostname to match it against the current host.
|
|
||||||
host = topic[len(self.topic) + 1:]
|
|
||||||
|
|
||||||
if self.client is None and host == CONF.host:
|
|
||||||
# Short-cut to a local function call if there is a built-in
|
|
||||||
# conductor.
|
|
||||||
return _LOCAL_CONTEXT
|
|
||||||
|
|
||||||
# A safeguard for the case someone uses rpc_transport=None with no
|
|
||||||
# built-in conductor.
|
|
||||||
if self.client is None:
|
|
||||||
raise exception.ServiceUnavailable(
|
|
||||||
_("Cannot use 'none' RPC to connect to remote conductor %s")
|
|
||||||
% host)
|
|
||||||
|
|
||||||
# Normal RPC path
|
|
||||||
return self.client.prepare(topic=topic, version=version)
|
return self.client.prepare(topic=topic, version=version)
|
||||||
|
|
||||||
def get_conductor_for(self, node):
|
def get_conductor_for(self, node):
|
||||||
|
@@ -41,6 +41,7 @@ from ironic.conf import inventory
|
|||||||
from ironic.conf import ipmi
|
from ironic.conf import ipmi
|
||||||
from ironic.conf import irmc
|
from ironic.conf import irmc
|
||||||
from ironic.conf import json_rpc
|
from ironic.conf import json_rpc
|
||||||
|
from ironic.conf import local_rpc
|
||||||
from ironic.conf import mdns
|
from ironic.conf import mdns
|
||||||
from ironic.conf import metrics
|
from ironic.conf import metrics
|
||||||
from ironic.conf import molds
|
from ironic.conf import molds
|
||||||
@@ -83,6 +84,7 @@ inventory.register_opts(CONF)
|
|||||||
ipmi.register_opts(CONF)
|
ipmi.register_opts(CONF)
|
||||||
irmc.register_opts(CONF)
|
irmc.register_opts(CONF)
|
||||||
json_rpc.register_opts(CONF)
|
json_rpc.register_opts(CONF)
|
||||||
|
local_rpc.register_opts(CONF)
|
||||||
mdns.register_opts(CONF)
|
mdns.register_opts(CONF)
|
||||||
metrics.register_opts(CONF)
|
metrics.register_opts(CONF)
|
||||||
molds.register_opts(CONF)
|
molds.register_opts(CONF)
|
||||||
|
39
ironic/conf/local_rpc.py
Normal file
39
ironic/conf/local_rpc.py
Normal file
@@ -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
|
@@ -40,6 +40,7 @@ _opts = [
|
|||||||
('ipmi', ironic.conf.ipmi.opts),
|
('ipmi', ironic.conf.ipmi.opts),
|
||||||
('irmc', ironic.conf.irmc.opts),
|
('irmc', ironic.conf.irmc.opts),
|
||||||
('json_rpc', ironic.conf.json_rpc.list_opts()),
|
('json_rpc', ironic.conf.json_rpc.list_opts()),
|
||||||
|
('local_rpc', ironic.conf.local_rpc.list_opts()),
|
||||||
('mdns', ironic.conf.mdns.opts),
|
('mdns', ironic.conf.mdns.opts),
|
||||||
('metrics', ironic.conf.metrics.opts),
|
('metrics', ironic.conf.metrics.opts),
|
||||||
('metrics_statsd', ironic.conf.metrics.statsd_opts),
|
('metrics_statsd', ironic.conf.metrics.statsd_opts),
|
||||||
|
69
ironic/tests/unit/common/test_tls_utils.py
Normal file
69
ironic/tests/unit/common/test_tls_utils.py
Normal file
@@ -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))
|
97
ironic/tests/unit/conductor/test_local_rpc.py
Normal file
97
ironic/tests/unit/conductor/test_local_rpc.py
Normal file
@@ -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)
|
@@ -69,28 +69,6 @@ class TestRPCService(db_base.DbTestCase):
|
|||||||
self.assertFalse(self.rpc_svc._failure)
|
self.assertFalse(self.rpc_svc._failure)
|
||||||
self.rpc_svc.wait_for_start() # should be no-op
|
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(manager.ConductorManager, 'prepare_host', autospec=True)
|
||||||
@mock.patch.object(oslo_messaging, 'Target', autospec=True)
|
@mock.patch.object(oslo_messaging, 'Target', autospec=True)
|
||||||
@mock.patch.object(objects_base, 'IronicObjectSerializer', autospec=True)
|
@mock.patch.object(objects_base, 'IronicObjectSerializer', autospec=True)
|
||||||
|
@@ -31,8 +31,8 @@ from ironic.common import components
|
|||||||
from ironic.common import exception
|
from ironic.common import exception
|
||||||
from ironic.common import indicator_states
|
from ironic.common import indicator_states
|
||||||
from ironic.common import release_mappings
|
from ironic.common import release_mappings
|
||||||
from ironic.common import rpc
|
|
||||||
from ironic.common import states
|
from ironic.common import states
|
||||||
|
from ironic.conductor import local_rpc
|
||||||
from ironic.conductor import manager as conductor_manager
|
from ironic.conductor import manager as conductor_manager
|
||||||
from ironic.conductor import rpcapi as conductor_rpcapi
|
from ironic.conductor import rpcapi as conductor_rpcapi
|
||||||
from ironic import objects
|
from ironic import objects
|
||||||
@@ -79,8 +79,7 @@ class RPCAPITestCase(db_base.DbTestCase):
|
|||||||
def test_rpc_disabled(self):
|
def test_rpc_disabled(self):
|
||||||
CONF.set_override('rpc_transport', 'none')
|
CONF.set_override('rpc_transport', 'none')
|
||||||
rpcapi = conductor_rpcapi.ConductorAPI(topic='fake-topic')
|
rpcapi = conductor_rpcapi.ConductorAPI(topic='fake-topic')
|
||||||
self.assertIsNone(rpcapi.client)
|
self.assertIsInstance(rpcapi.client, local_rpc.LocalClient)
|
||||||
self.assertTrue(rpcapi._can_send_version('9.99'))
|
|
||||||
|
|
||||||
def test_serialized_instance_has_uuid(self):
|
def test_serialized_instance_has_uuid(self):
|
||||||
self.assertIn('uuid', self.fake_node)
|
self.assertIn('uuid', self.fake_node)
|
||||||
@@ -734,80 +733,3 @@ class RPCAPITestCase(db_base.DbTestCase):
|
|||||||
service_steps={'foo': 'bar'},
|
service_steps={'foo': 'bar'},
|
||||||
disable_ramdisk=False,
|
disable_ramdisk=False,
|
||||||
version='1.57')
|
version='1.57')
|
||||||
|
|
||||||
@mock.patch.object(rpc, 'GLOBAL_MANAGER',
|
|
||||||
spec_set=conductor_manager.ConductorManager)
|
|
||||||
def test_local_call(self, mock_manager):
|
|
||||||
CONF.set_override('host', 'fake.host')
|
|
||||||
CONF.set_override('rpc_transport', 'none')
|
|
||||||
rpcapi = conductor_rpcapi.ConductorAPI(topic='fake.topic')
|
|
||||||
rpcapi.create_node(mock.sentinel.context, mock.sentinel.node,
|
|
||||||
topic='fake.topic.fake.host')
|
|
||||||
mock_manager.create_node.assert_called_once_with(
|
|
||||||
mock.sentinel.context, node_obj=mock.sentinel.node)
|
|
||||||
|
|
||||||
@mock.patch.object(rpc, 'GLOBAL_MANAGER',
|
|
||||||
spec_set=conductor_manager.ConductorManager)
|
|
||||||
def test_local_call_host_mismatch(self, mock_manager):
|
|
||||||
CONF.set_override('host', 'fake.host')
|
|
||||||
CONF.set_override('rpc_transport', 'none')
|
|
||||||
rpcapi = conductor_rpcapi.ConductorAPI(topic='fake.topic')
|
|
||||||
self.assertRaises(exception.ServiceUnavailable,
|
|
||||||
rpcapi.create_node,
|
|
||||||
mock.sentinel.context, mock.sentinel.node,
|
|
||||||
topic='fake.topic.not-fake.host')
|
|
||||||
|
|
||||||
@mock.patch.object(rpc, 'GLOBAL_MANAGER', None)
|
|
||||||
def test_local_call_no_conductor_with_rpc_disabled(self):
|
|
||||||
CONF.set_override('host', 'fake.host')
|
|
||||||
CONF.set_override('rpc_transport', 'none')
|
|
||||||
rpcapi = conductor_rpcapi.ConductorAPI(topic='fake.topic')
|
|
||||||
self.assertRaises(exception.ServiceUnavailable,
|
|
||||||
rpcapi.create_node,
|
|
||||||
mock.sentinel.context, mock.sentinel.node,
|
|
||||||
topic='fake.topic.fake.host')
|
|
||||||
|
|
||||||
@mock.patch.object(rpc, 'GLOBAL_MANAGER',
|
|
||||||
spec_set=conductor_manager.ConductorManager)
|
|
||||||
def test_local_cast(self, mock_manager):
|
|
||||||
CONF.set_override('host', 'fake.host')
|
|
||||||
CONF.set_override('rpc_transport', 'none')
|
|
||||||
rpcapi = conductor_rpcapi.ConductorAPI(topic='fake.topic')
|
|
||||||
cctxt = rpcapi._prepare_call(topic='fake.topic.fake.host')
|
|
||||||
cctxt.cast(mock.sentinel.context, 'create_node',
|
|
||||||
node_obj=mock.sentinel.node)
|
|
||||||
mock_manager.create_node.assert_called_once_with(
|
|
||||||
mock.sentinel.context, node_obj=mock.sentinel.node)
|
|
||||||
|
|
||||||
@mock.patch.object(conductor_rpcapi.LOG, 'exception', autospec=True)
|
|
||||||
@mock.patch.object(rpc, 'GLOBAL_MANAGER',
|
|
||||||
spec_set=conductor_manager.ConductorManager)
|
|
||||||
def test_local_cast_error(self, mock_manager, mock_log):
|
|
||||||
CONF.set_override('host', 'fake.host')
|
|
||||||
CONF.set_override('rpc_transport', 'none')
|
|
||||||
mock_manager.create_node.side_effect = RuntimeError('boom')
|
|
||||||
rpcapi = conductor_rpcapi.ConductorAPI(topic='fake.topic')
|
|
||||||
cctxt = rpcapi._prepare_call(topic='fake.topic.fake.host')
|
|
||||||
cctxt.cast(mock.sentinel.context, 'create_node',
|
|
||||||
node_obj=mock.sentinel.node)
|
|
||||||
mock_manager.create_node.assert_called_once_with(
|
|
||||||
mock.sentinel.context, node_obj=mock.sentinel.node)
|
|
||||||
self.assertTrue(mock_log.called)
|
|
||||||
|
|
||||||
@mock.patch.object(rpc, 'GLOBAL_MANAGER',
|
|
||||||
spec_set=conductor_manager.ConductorManager)
|
|
||||||
def test_local_call_expected_exception(self, mock_manager):
|
|
||||||
@messaging.expected_exceptions(exception.InvalidParameterValue)
|
|
||||||
def fake_create(context, node_obj):
|
|
||||||
raise exception.InvalidParameterValue('sorry')
|
|
||||||
|
|
||||||
CONF.set_override('host', 'fake.host')
|
|
||||||
CONF.set_override('rpc_transport', 'none')
|
|
||||||
rpcapi = conductor_rpcapi.ConductorAPI(topic='fake.topic')
|
|
||||||
mock_manager.create_node.side_effect = fake_create
|
|
||||||
self.assertRaisesRegex(exception.InvalidParameterValue, 'sorry',
|
|
||||||
rpcapi.create_node,
|
|
||||||
mock.sentinel.context, mock.sentinel.node,
|
|
||||||
topic='fake.topic.fake.host')
|
|
||||||
mock_manager.create_node.assert_called_once_with(
|
|
||||||
mock.sentinel.context, node_obj=mock.sentinel.node)
|
|
||||||
|
9
releasenotes/notes/localrpc-403d72535e3e0048.yaml
Normal file
9
releasenotes/notes/localrpc-403d72535e3e0048.yaml
Normal file
@@ -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.
|
@@ -50,3 +50,4 @@ bcrypt>=3.1.3 # Apache-2.0
|
|||||||
websockify>=0.9.0 # LGPLv3
|
websockify>=0.9.0 # LGPLv3
|
||||||
PyYAML>=6.0.2 # MIT
|
PyYAML>=6.0.2 # MIT
|
||||||
cheroot>=10.0.1 # BSD
|
cheroot>=10.0.1 # BSD
|
||||||
|
cryptography>=2.3 # BSD/Apache-2.0
|
||||||
|
Reference in New Issue
Block a user