diff --git a/.gitignore b/.gitignore index 15f7bb380a..d1848002d5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ releasenotes/build # sample config files etc/ironic/ironic.conf.sample +etc/ironic/ironic.networking.conf.sample etc/ironic/policy.yaml.sample # Packages/installer info @@ -33,6 +34,7 @@ develop-eggs # Other *.DS_Store .idea +.vscode .testrepository .stestr .tox diff --git a/ironic/command/networking.py b/ironic/command/networking.py new file mode 100644 index 0000000000..156c97af47 --- /dev/null +++ b/ironic/command/networking.py @@ -0,0 +1,64 @@ +# +# 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. + +""" +The Ironic Networking Service +""" + +import sys + +from oslo_config import cfg +from oslo_log import log +from oslo_service import service + +from ironic.command import utils as command_utils +from ironic.common import service as ironic_service +from ironic.networking import rpc_service + +CONF = cfg.CONF + +LOG = log.getLogger(__name__) + + +def issue_startup_warnings(conf): + """Issue any startup warnings for the networking service.""" + # Add any networking-specific startup warnings here + LOG.info("Starting Ironic Networking Service") + + +def main(): + # NOTE(alegacy): Safeguard to prevent 'ironic.networking.manager' + # from being imported prior to the configuration options being loaded. + assert 'ironic.networking.manager' not in sys.modules + + # Parse config file and command line options, then start logging + ironic_service.prepare_service('ironic_networking', sys.argv) + ironic_service.ensure_rpc_transport(CONF) + + mgr = rpc_service.NetworkingRPCService(CONF.host, + 'ironic.networking.manager', + 'NetworkingManager') + + issue_startup_warnings(CONF) + + launcher = service.launch(CONF, mgr, restart_method='mutate') + + # Set override signals. + command_utils.handle_signal() + + # Start the processes! + sys.exit(launcher.wait()) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/ironic/command/singleprocess.py b/ironic/command/singleprocess.py index 5131d6efec..3fbf41dc50 100644 --- a/ironic/command/singleprocess.py +++ b/ironic/command/singleprocess.py @@ -34,6 +34,7 @@ def main(): # more information see: https://bugs.launchpad.net/ironic/+bug/1562258 # and https://bugs.launchpad.net/ironic/+bug/1279774. assert 'ironic.conductor.manager' not in sys.modules + assert 'ironic.networking.manager' not in sys.modules # Parse config file and command line options, then start logging ironic_service.prepare_service('ironic', sys.argv) diff --git a/ironic/common/exception.py b/ironic/common/exception.py index 3d1052fe3b..bc36b6ec1a 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -377,6 +377,11 @@ class NodeNotFound(NotFound): _msg_fmt = _("Node %(node)s could not be found.") +class SwitchNotFound(NotFound): + _msg_fmt = _("Switch %(switch_id)s could not be found or is not " + "supported by any configured switch driver.") + + class DuplicateNodeOnLookup(NodeNotFound): pass # Same error message, the difference only matters internally diff --git a/ironic/common/network.py b/ironic/common/network.py index 00735e7dbd..d3819bce8a 100644 --- a/ironic/common/network.py +++ b/ironic/common/network.py @@ -18,6 +18,15 @@ from ironic.common import exception LOG = log.getLogger(__name__) +# Network Types +IDLE_NETWORK = 'idle' +RESCUING_NETWORK = 'rescuing' +CLEANING_NETWORK = 'cleaning' +SERVICING_NETWORK = 'servicing' +INSPECTION_NETWORK = 'inspection' +PROVISIONING_NETWORK = 'provisioning' +TENANT_NETWORK = 'tenant' + def get_node_vif_ids(task): """Get all VIF ids for a node. diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py index 820b9a53b1..39306fcfe0 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -946,6 +946,7 @@ RELEASE_MAPPING = { 'master': { 'api': '1.104', 'rpc': '1.62', + 'networking_rpc': '1.0', 'objects': { 'Allocation': ['1.3', '1.2', '1.1'], 'BIOSSetting': ['1.2', '1.1'], diff --git a/ironic/common/rpc.py b/ironic/common/rpc.py index 0dd821a6e8..ae81be4541 100644 --- a/ironic/common/rpc.py +++ b/ironic/common/rpc.py @@ -34,6 +34,7 @@ EXTRA_EXMODS = [] GLOBAL_MANAGER = None MANAGER_TOPIC = 'ironic.conductor_manager' +NETWORKING_TOPIC = 'ironic.networking_manager' def init(conf): diff --git a/ironic/common/rpc_service.py b/ironic/common/rpc_service.py index ece953c638..c007867ad7 100644 --- a/ironic/common/rpc_service.py +++ b/ironic/common/rpc_service.py @@ -55,19 +55,22 @@ class BaseRPCService(service.Service): else: self._started = True + def _rpc_transport(self): + return CONF.rpc_transport + def _real_start(self): admin_context = context.get_admin_context() 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': + if self._rpc_transport() == 'json-rpc': conf_group = getattr(self.manager, 'json_rpc_conf_group', 'json_rpc') self.rpcserver = json_rpc.WSGIService( self.manager, serializer, context.RequestContext.from_dict, conf_group=conf_group) - elif CONF.rpc_transport != 'none': + elif self._rpc_transport() != 'none': target = messaging.Target(topic=self.topic, server=self.host) endpoints = [self.manager] self.rpcserver = rpc.get_server(target, endpoints, serializer) @@ -80,4 +83,4 @@ class BaseRPCService(service.Service): LOG.info('Created RPC server with %(transport)s transport for service ' '%(service)s on host %(host)s.', {'service': self.topic, 'host': self.host, - 'transport': CONF.rpc_transport}) + 'transport': self._rpc_transport()}) diff --git a/ironic/conf/__init__.py b/ironic/conf/__init__.py index 7ce493c993..43b633c281 100644 --- a/ironic/conf/__init__.py +++ b/ironic/conf/__init__.py @@ -40,6 +40,7 @@ from ironic.conf import inspector from ironic.conf import inventory from ironic.conf import ipmi from ironic.conf import irmc +from ironic.conf import ironic_networking from ironic.conf import json_rpc from ironic.conf import mdns from ironic.conf import metrics @@ -82,7 +83,11 @@ inspector.register_opts(CONF) inventory.register_opts(CONF) ipmi.register_opts(CONF) irmc.register_opts(CONF) +ironic_networking.register_opts(CONF) +# Register default json_rpc group used for conductor json_rpc.register_opts(CONF) +# Register a separate json_rpc group for ironic networking specific settings +json_rpc.register_opts(CONF, group='ironic_networking_json_rpc') mdns.register_opts(CONF) metrics.register_opts(CONF) molds.register_opts(CONF) diff --git a/ironic/conf/ironic_networking.py b/ironic/conf/ironic_networking.py new file mode 100644 index 0000000000..e8ed0c3e15 --- /dev/null +++ b/ironic/conf/ironic_networking.py @@ -0,0 +1,118 @@ +# +# 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 _ + +opts = [ + # Overrides the global rpc_transport setting so that the conductor + # and networking service can use different transports if necessary. + cfg.StrOpt('rpc_transport', + default=None, + choices=['json-rpc', 'oslo_messaging'], + help=_('The transport mechanism used for RPC communication. ' + 'This can be set to "json-rpc" for JSON-RPC, ' + '"oslo_messaging" for Oslo Messaging, or "none" ' + 'for no transport.')), + cfg.StrOpt('switch_config_file', + default='', + help=_('Path to the switch configuration file that defines ' + 'switches to be acted upon. The config file should be ' + 'in INI format. For syntax refer to the user guide.')), + cfg.StrOpt('driver_config_dir', + default='/var/lib/ironic/networking', + help=_('The path to the driver configuration directory. This ' + 'is used to dynamically write driver config files that ' + 'are derived from entries in the file specified by the ' + 'switch_config_file option. This directory should not ' + 'be populated with files manually.')), + cfg.ListOpt('enabled_switch_drivers', + default=[], + help=_('A list of switch drivers to load and make available ' + 'for managing network switches. Switch drivers are ' + 'loaded from external projects via entry points in ' + 'the "ironic.networking.switch_drivers" namespace. ' + 'Only drivers listed here will be loaded and made ' + 'available for use. An empty list means no switch ' + 'drivers will be loaded.')), + cfg.ListOpt('allowed_vlans', + default=None, + help=_('A list of VLAN IDs that are allowed to be used for ' + 'port configuration. If not specified (None), all ' + 'VLAN IDs are allowed. If set to an empty list ([]), ' + 'no VLANs are allowed. If set to a list of values, ' + 'only the specified VLAN IDs are allowed. The list ' + 'is a comma separated list of VLAN ID values or range ' + 'of values. For example, 100,101,102-104,106 would ' + 'allow VLANs 100, 101, 102, 103, 104, and 106, but ' + 'not 105. This setting can be overridden on a ' + 'per-switch basis in the switch configuration file.')), + cfg.StrOpt('cleaning_network', + default='', + help=_('The network to use for cleaning nodes. This should be ' + 'expressed as {access|trunk}/native_vlan=VLAN_ID. Can ' + 'be overridden on a per-node basis using the ' + 'driver_info attribute and specifying this as ' + '`cleaning_network`')), + cfg.StrOpt('rescuing_network', + default='', + help=_('The network to use for rescuing nodes. This should be ' + 'expressed as {access|trunk}/native_vlan=VLAN_ID. Can ' + 'be overridden on a per-node basis using the ' + 'driver_info attribute and specifying this as ' + '`rescuing_network`')), + cfg.StrOpt('provisioning_network', + default='', + help=_('The network to use for provisioning nodes. This ' + 'should be expressed as ' + '{access|trunk}/native_vlan=VLAN_ID. Can be overridden ' + 'on a per-node basis using the driver_info attribute ' + 'and specifying this as ' + '`provisioning_network`')), + cfg.StrOpt('servicing_network', + default='', + help=_('The network to use for servicing nodes. This ' + 'should be expressed as ' + '{access|trunk}/native_vlan=VLAN_ID. Can be overridden ' + 'on a per-node basis using the driver_info attribute ' + 'and specifying this as ' + '`servicing_network`')), + cfg.StrOpt('inspection_network', + default='', + help=_('The network to use for inspecting nodes. This ' + 'should be expressed as ' + '{access|trunk}/native_vlan=VLAN_ID. Can be overridden ' + 'on a per-node basis using the driver_info attribute ' + 'and specifying this as ' + '`inspection_network`')), + cfg.StrOpt('idle_network', + default='', + help=_('The network to use for initial inspecting of nodes. ' + 'If provided switch ports will be configured back to ' + 'this network whenever any of the other networks are ' + 'removed/unconfigured. ' + 'This should be expressed as ' + '{access|trunk}/native_vlan=VLAN_ID. Can be overridden ' + 'on a per-node basis using the driver_info attribute ' + 'and specifying this as ' + '`idle_network`')) +] + + +def register_opts(conf): + conf.register_opts(opts, group='ironic_networking') + + +def list_opts(): + return opts diff --git a/ironic/conf/opts.py b/ironic/conf/opts.py index 5f33098b82..7df35c3630 100644 --- a/ironic/conf/opts.py +++ b/ironic/conf/opts.py @@ -41,6 +41,9 @@ _opts = [ ('inventory', ironic.conf.inventory.opts), ('ipmi', ironic.conf.ipmi.opts), ('irmc', ironic.conf.irmc.opts), + ('ironic_networking', ironic.conf.ironic_networking.list_opts()), + # Expose the ironic networking-specific JSON-RPC group for sample configs + ('ironic_networking_json_rpc', ironic.conf.json_rpc.list_opts()), ('json_rpc', ironic.conf.json_rpc.list_opts()), ('mdns', ironic.conf.mdns.opts), ('metrics', ironic.conf.metrics.opts), diff --git a/ironic/networking/__init__.py b/ironic/networking/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/networking/api.py b/ironic/networking/api.py new file mode 100644 index 0000000000..e4fbb79439 --- /dev/null +++ b/ironic/networking/api.py @@ -0,0 +1,169 @@ +# +# 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. + +""" +Networking API for other parts of Ironic to use. +""" + +from ironic.networking import rpcapi + +# Global networking API instance +_NETWORKING_API = None + + +def get_networking_api(): + """Get the networking API instance. + + :returns: NetworkingAPI instance + """ + global _NETWORKING_API + if _NETWORKING_API is None: + _NETWORKING_API = rpcapi.NetworkingAPI() + return _NETWORKING_API + + +def update_port( + context, + switch_id, + port_name, + description, + mode, + native_vlan, + allowed_vlans=None, + lag_name=None, + default_vlan=None +): + """Update a network switch port configuration. + + This is a convenience function that other parts of Ironic can use + to update network switch port configurations. + + :param context: request context. + :param switch_id: Identifier for the switch. + :param port_name: Name of the port on the switch. + :param description: Description to set for the port. + :param mode: Port mode ('access', 'trunk', or 'hybrid'). + :param native_vlan: VLAN ID to be set on the port. + :param allowed_vlans: List of allowed VLAN IDs to be added(optional). + :param default_vlan: VLAN ID to removed from the port(optional). + :param lag_name: LAG name if port is part of a link aggregation group. + :raises: InvalidParameterValue if validation fails. + :raises: NetworkError if the network operation fails. + :returns: Dictionary containing the updated port configuration. + """ + api = get_networking_api() + return api.update_port( + context, + switch_id, + port_name, + description, + mode, + native_vlan, + allowed_vlans=allowed_vlans, + lag_name=lag_name, + default_vlan=default_vlan + ) + + +def reset_port( + context, + switch_id, + port_name, + native_vlan=None, + allowed_vlans=None, + default_vlan=None, +): + """Reset a network switch port to default configuration. + + This is a convenience function that other parts of Ironic can use + to reset network switch ports to their default configurations. + + :param context: request context. + :param switch_id: Identifier for the switch. + :param port_name: Name of the port on the switch. + :param native_vlan: VLAN ID to be removed from the port. + :param allowed_vlans: List of allowed VLAN IDs to be removed(optional). + :param default_vlan: VLAN ID to restore onto the port(optional). + :raises: InvalidParameterValue if validation fails. + :raises: NetworkError if the network operation fails. + :returns: Dictionary containing the reset port configuration. + """ + api = get_networking_api() + return api.reset_port( + context, + switch_id, + port_name, + native_vlan, + allowed_vlans=allowed_vlans, + default_vlan=default_vlan + ) + + +def update_lag( + context, + switch_ids, + lag_name, + description, + mode, + native_vlan, + aggregation_mode, + allowed_vlans=None, + default_vlan=None +): + """Update a link aggregation group (LAG) configuration. + + This is a convenience function that other parts of Ironic can use + to update LAG configurations. + + :param context: request context. + :param switch_ids: List of switch identifiers. + :param lag_name: Name of the LAG. + :param description: Description for the LAG. + :param mode: LAG mode ('access' or 'trunk'). + :param native_vlan: VLAN ID to be set for the LAG. + :param aggregation_mode: Aggregation mode (e.g., 'lacp', 'static'). + :param allowed_vlans: List of allowed VLAN IDs to be added (optional). + :param default_vlan: VLAN ID to removed from the port(optional). + :raises: InvalidParameterValue if validation fails. + :raises: NetworkError if the network operation fails. + :returns: Dictionary containing the updated LAG configuration. + """ + api = get_networking_api() + return api.update_lag( + context, + switch_ids, + lag_name, + description, + mode, + native_vlan, + aggregation_mode, + allowed_vlans=allowed_vlans, + default_vlan=default_vlan + ) + + +def delete_lag(context, switch_ids, lag_name): + """Delete a link aggregation group (LAG) configuration. + + This is a convenience function that other parts of Ironic can use + to delete LAG configurations. + + :param context: request context. + :param switch_ids: List of switch identifiers. + :param lag_name: Name of the LAG to delete. + :raises: InvalidParameterValue if validation fails. + :raises: NetworkError if the network operation fails. + :returns: Dictionary containing the deletion status. + """ + api = get_networking_api() + return api.delete_lag(context, switch_ids, lag_name) diff --git a/ironic/networking/manager.py b/ironic/networking/manager.py new file mode 100644 index 0000000000..08d1b5dad1 --- /dev/null +++ b/ironic/networking/manager.py @@ -0,0 +1,217 @@ +# +# 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. + +"""Networking service manager for Ironic. + +The networking service handles network-related operations for Ironic, +providing RPC interfaces for configuring switch ports and network settings. +""" + +from oslo_log import log +import oslo_messaging as messaging + +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.common import metrics_utils +from ironic.common import rpc +from ironic.conf import CONF + +LOG = log.getLogger(__name__) + +METRICS = metrics_utils.get_metrics_logger(__name__) + + +class NetworkingManager(object): + """Ironic Networking service manager.""" + + # NOTE(alegacy): This must be in sync with rpcapi.NetworkingAPI's. + RPC_API_VERSION = "1.0" + + target = messaging.Target(version=RPC_API_VERSION) + + def __init__(self, host, topic=None): + if not host: + host = CONF.host + self.host = host + if topic is None: + topic = rpc.NETWORKING_TOPIC + self.topic = topic + + # Tell the RPC service which json-rpc config group to use for + # networking. This enables separate listener configuration. + self.json_rpc_conf_group = "ironic_networking_json_rpc" + + def prepare_host(self): + """Prepare host for networking service initialization. + + This method is called by the RPC service before starting the listener. + """ + pass + + def init_host(self, admin_context=None): + """Initialize the networking service host. + + :param admin_context: admin context (unused but kept for compatibility) + """ + LOG.info("Initializing networking service on host %s", self.host) + LOG.warning( + "Networking service initialized with stub implementations. " + "Driver framework not yet loaded." + ) + + @METRICS.timer("NetworkingManager.update_port") + @messaging.expected_exceptions( + exception.InvalidParameterValue, + exception.NetworkError, + exception.SwitchNotFound, + ) + def update_port( + self, + context, + switch_id, + port_name, + description, + mode, + native_vlan, + allowed_vlans=None, + default_vlan=None, + lag_name=None, + ): + """Update a network switch port configuration (stub). + + :param context: request context. + :param switch_id: Identifier of the network switch. + :param port_name: Name of the port to update. + :param description: Description for the port. + :param mode: Port mode (e.g., 'access', 'trunk'). + :param native_vlan: VLAN ID to be removed from the port. + :param allowed_vlans: Allowed VLAN IDs to be removed (optional). + :param default_vlan: VLAN ID to restore onto the port (optional). + :param lag_name: Name of the LAG if port is part of a link aggregation + group (optional). + :raises: NotImplementedError - Implementation not loaded + """ + LOG.warning( + "update_port called but driver framework not loaded: " + "switch=%s, port=%s", + switch_id, + port_name, + ) + raise exception.NetworkError( + _("Network driver framework not yet loaded") + ) + + @METRICS.timer("NetworkingManager.reset_port") + @messaging.expected_exceptions( + exception.InvalidParameterValue, + exception.NetworkError, + exception.SwitchNotFound, + ) + def reset_port( + self, + context, + switch_id, + port_name, + native_vlan, + allowed_vlans=None, + default_vlan=None, + ): + """Reset a network switch port to default configuration (stub). + + :param context: request context. + :param switch_id: Identifier of the network switch. + :param port_name: Name of the port to reset. + :param native_vlan: VLAN ID to be removed from the port. + :param allowed_vlans: Allowed VLAN IDs to be removed (optional). + :param default_vlan: VLAN ID to restore onto the port (optional). + :raises: NotImplementedError - Implementation not loaded + """ + LOG.warning( + "reset_port called but driver framework not loaded: " + "switch=%s, port=%s", + switch_id, + port_name, + ) + raise exception.NetworkError( + _("Network driver framework not yet loaded") + ) + + @METRICS.timer("NetworkingManager.update_lag") + @messaging.expected_exceptions( + exception.InvalidParameterValue, + exception.NetworkError, + exception.SwitchNotFound, + exception.Invalid, + ) + def update_lag( + self, + context, + switch_ids, + lag_name, + description, + mode, + native_vlan, + aggregation_mode, + allowed_vlans=None, + default_vlan=None, + ): + """Update a link aggregation group (LAG) configuration (stub). + + :param context: request context. + :param switch_ids: List of switch identifiers. + :param lag_name: Name of the LAG to update. + :param description: Description for the LAG. + :param mode: LAG mode (e.g., 'access', 'trunk'). + :param native_vlan: VLAN ID to be removed from the port. + :param aggregation_mode: Aggregation mode (e.g., 'lacp', 'static'). + :param allowed_vlans: Allowed VLAN IDs to be removed (optional). + :param default_vlan: VLAN ID to restore onto the port (optional). + :raises: Invalid - LAG operations are not yet supported. + """ + raise exception.Invalid( + _("LAG operations are not yet supported") + ) + + @METRICS.timer("NetworkingManager.delete_lag") + @messaging.expected_exceptions( + exception.InvalidParameterValue, + exception.NetworkError, + exception.SwitchNotFound, + exception.Invalid, + ) + def delete_lag(self, context, switch_ids, lag_name): + """Delete a link aggregation group (LAG) configuration (stub). + + :param context: request context. + :param switch_ids: List of switch identifiers. + :param lag_name: Name of the LAG to delete. + :raises: Invalid - LAG operations are not yet supported. + """ + raise exception.Invalid( + _("LAG operations are not yet supported") + ) + + @METRICS.timer("NetworkingManager.get_switches") + @messaging.expected_exceptions(exception.NetworkError) + def get_switches(self, context): + """Get information about all switches (stub). + + :param context: Request context + :returns: Empty dictionary (no drivers loaded) + """ + LOG.warning("get_switches called but driver framework not loaded") + return {} + + def cleanup(self): + """Clean up resources.""" + pass diff --git a/ironic/networking/rpc_service.py b/ironic/networking/rpc_service.py new file mode 100644 index 0000000000..23a5cab039 --- /dev/null +++ b/ironic/networking/rpc_service.py @@ -0,0 +1,78 @@ +# +# 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 oslo_log import log + +from ironic.common import rpc_service +from ironic.networking import utils as networking_utils + +LOG = log.getLogger(__name__) +CONF = cfg.CONF + + +class NetworkingRPCService(rpc_service.BaseRPCService): + """RPC service for the Ironic Networking Manager.""" + + def __init__(self, host, manager_module, manager_class): + super().__init__(host, manager_module, manager_class) + self.graceful_shutdown = False + + def _rpc_transport(self): + return networking_utils.rpc_transport() + + def _real_start(self): + """Start the networking service.""" + super()._real_start() + LOG.info( + "Started networking RPC server for service %(service)s on " + "host %(host)s.", + {"service": self.topic, "host": self.host}, + ) + + def stop(self): + """Stop the networking service.""" + LOG.info( + "Stopping networking RPC server for service %(service)s on " + "host %(host)s.", + {"service": self.topic, "host": self.host}, + ) + + try: + if hasattr(self.manager, "del_host"): + self.manager.del_host() + except Exception as e: + LOG.exception( + "Service error occurred when cleaning up " + "the networking RPC manager. Error: %s", + e, + ) + + try: + if self.rpcserver is not None: + self.rpcserver.stop() + self.rpcserver.wait() + except Exception as e: + LOG.exception( + "Service error occurred when stopping the " + "networking RPC server. Error: %s", + e, + ) + + super().stop(graceful=True) + LOG.info( + "Stopped networking RPC server for service %(service)s on " + "host %(host)s.", + {"service": self.topic, "host": self.host}, + ) diff --git a/ironic/networking/rpcapi.py b/ironic/networking/rpcapi.py new file mode 100644 index 0000000000..be5661159a --- /dev/null +++ b/ironic/networking/rpcapi.py @@ -0,0 +1,251 @@ +# +# 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. + +""" +Client side of the networking RPC API. +""" + +from oslo_log import log +import oslo_messaging as messaging + +from ironic.common import exception +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.conf import CONF +from ironic.networking import utils as networking_utils +from ironic.objects import base as objects_base + +LOG = log.getLogger(__name__) + + +class NetworkingAPI(object): + """Client side of the networking RPC API. + + API version history: + + | 1.0 - Initial version. + """ + + # NOTE(alegacy): This must be in sync with manager.NetworkingManager's. + RPC_API_VERSION = "1.0" + + def __init__(self, topic=None): + super(NetworkingAPI, self).__init__() + self.topic = topic + if self.topic is None: + if networking_utils.rpc_transport() == "json-rpc": + # Use host_ip and port from the JSON-RPC config for topic + host_ip = CONF.ironic_networking_json_rpc.host_ip + port = CONF.ironic_networking_json_rpc.port + topic_host = f"{host_ip}:{port}" + self.topic = f"ironic.{topic_host}" + else: + self.topic = rpc.NETWORKING_TOPIC + + serializer = objects_base.IronicObjectSerializer() + release_ver = versions.RELEASE_MAPPING.get(CONF.pin_release_version) + version_cap = ( + release_ver.get("networking_rpc") + if release_ver else self.RPC_API_VERSION + ) + + if networking_utils.rpc_transport() == "json-rpc": + # Use a dedicated configuration group for networking JSON-RPC + self.client = json_rpc.Client( + serializer=serializer, + version_cap=version_cap, + conf_group="ironic_networking_json_rpc", + ) + # Keep the original topic for JSON-RPC (needed for host extraction) + elif networking_utils.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 + + def _prepare_call(self, topic, version=None): + """Prepare an RPC call. + + :param topic: RPC topic to send to. + :param version: RPC API version to require. + """ + topic = topic or self.topic + + # A safeguard for the case someone uses rpc_transport=None + if self.client is None: + raise exception.ServiceUnavailable( + _("Cannot use 'none' RPC to connect to networking service") + ) + + # Normal RPC path + return self.client.prepare(topic=topic, version=version) + + def get_topic(self): + """Get RPC topic name for the networking service.""" + return self.topic + + def update_port( + self, + context, + switch_id, + port_name, + description, + mode, + native_vlan, + allowed_vlans=None, + default_vlan=None, + lag_name=None, + topic=None, + ): + """Update a port configuration on a switch. + + :param context: request context. + :param switch_id: Identifier for the switch. + :param port_name: Name of the port on the switch. + :param description: Description to set for the port. + :param mode: Port mode ('access', 'trunk', or 'hybrid'). + :param native_vlan: VLAN ID to be set on the port. + :param allowed_vlans: List of allowed VLAN IDs to be added(optional). + :param default_vlan: VLAN ID to removed from the port(optional). + :param lag_name: LAG name if port is part of a link aggregation group. + :param topic: RPC topic. Defaults to self.topic. + :raises: InvalidParameterValue if validation fails. + :raises: NetworkError if the network operation fails. + :returns: Dictionary containing the updated port configuration. + """ + cctxt = self._prepare_call(topic=topic, version="1.0") + return cctxt.call( + context, + "update_port", + switch_id=switch_id, + port_name=port_name, + description=description, + mode=mode, + native_vlan=native_vlan, + allowed_vlans=allowed_vlans, + lag_name=lag_name, + default_vlan=default_vlan, + ) + + def reset_port( + self, + context, + switch_id, + port_name, + native_vlan, + allowed_vlans=None, + default_vlan=None, + topic=None, + ): + """Reset a network switch port to default configuration. + + :param context: request context. + :param switch_id: Identifier for the switch. + :param port_name: Name of the port on the switch. + :param native_vlan: VLAN ID to be removed from the port. + :param allowed_vlans: List of allowed VLAN IDs to be removed(optional). + :param default_vlan: VLAN ID to restore onto the port(optional). + :param topic: RPC topic. Defaults to self.topic. + :raises: InvalidParameterValue if validation fails. + :raises: NetworkError if the network operation fails. + :returns: Dictionary containing the reset port configuration. + """ + cctxt = self._prepare_call(topic=topic, version="1.0") + return cctxt.call( + context, + "reset_port", + switch_id=switch_id, + port_name=port_name, + native_vlan=native_vlan, + allowed_vlans=allowed_vlans, + default_vlan=default_vlan, + ) + + def get_switches(self, context, topic=None): + """Get information about all configured switches. + + :param context: request context. + :param topic: RPC topic. Defaults to self.topic. + :raises: NetworkError if the network operation fails. + :returns: Dictionary with switch_id as key and switch_info as value. + """ + cctxt = self._prepare_call(topic=topic, version="1.0") + return cctxt.call(context, "get_switches") + + def update_lag( + self, + context, + switch_ids, + lag_name, + description, + mode, + native_vlan, + aggregation_mode, + allowed_vlans=None, + default_vlan=None, + topic=None, + ): + """Update a link aggregation group (LAG) configuration. + + :param context: request context. + :param switch_ids: List of switch identifiers. + :param lag_name: Name of the LAG. + :param description: Description for the LAG. + :param mode: LAG mode ('access' or 'trunk'). + :param native_vlan: VLAN ID to be set for the LAG. + :param aggregation_mode: Aggregation mode (e.g., 'lacp', 'static'). + :param allowed_vlans: List of allowed VLAN IDs to be added (optional). + :param default_vlan: VLAN ID to removed from the port(optional). + :param topic: RPC topic. Defaults to self.topic. + :raises: InvalidParameterValue if validation fails. + :raises: NetworkError if the network operation fails. + :returns: Dictionary containing the updated LAG configuration. + """ + cctxt = self._prepare_call(topic=topic, version="1.0") + return cctxt.call( + context, + "update_lag", + switch_ids=switch_ids, + lag_name=lag_name, + description=description, + mode=mode, + native_vlan=native_vlan, + aggregation_mode=aggregation_mode, + allowed_vlans=allowed_vlans, + default_vlan=default_vlan, + ) + + def delete_lag( + self, context, switch_ids, lag_name, topic=None + ): + """Delete a link aggregation group (LAG) configuration. + + :param context: request context. + :param switch_ids: List of switch identifiers. + :param lag_name: Name of the LAG to delete. + :param topic: RPC topic. Defaults to self.topic. + :raises: InvalidParameterValue if validation fails. + :raises: NetworkError if the network operation fails. + :returns: Dictionary containing the deletion status. + """ + cctxt = self._prepare_call(topic=topic, version="1.0") + return cctxt.call( + context, + "delete_lag", + switch_ids=switch_ids, + lag_name=lag_name, + ) diff --git a/ironic/networking/utils.py b/ironic/networking/utils.py new file mode 100644 index 0000000000..509f1f39e5 --- /dev/null +++ b/ironic/networking/utils.py @@ -0,0 +1,129 @@ +# +# 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. + +""" +Utilities for networking service operations. +""" + +from oslo_config import cfg +from oslo_log import log + +from ironic.common import exception +from ironic.common.i18n import _ + +LOG = log.getLogger(__name__) +CONF = cfg.CONF + + +def rpc_transport(): + """Get the RPC transport type.""" + if CONF.ironic_networking.rpc_transport is None: + return CONF.rpc_transport + else: + return CONF.ironic_networking.rpc_transport + + +def parse_vlan_ranges(vlan_spec): + """Parse VLAN specification into a set of VLAN IDs. + + :param vlan_spec: List of VLAN IDs or ranges (e.g., ['100', '102-104']) + :returns: Set of integer VLAN IDs + :raises: InvalidParameterValue if the specification is invalid + """ + if vlan_spec is None: + return None + + vlan_set = set() + for item in vlan_spec: + item = item.strip() + if '-' in item: + # Handle range (e.g., "102-104") + try: + start, end = item.split('-', 1) + start_vlan = int(start.strip()) + end_vlan = int(end.strip()) + if start_vlan < 1 or end_vlan > 4094: + raise exception.InvalidParameterValue( + _('VLAN IDs must be between 1 and 4094, got range ' + '%(start)s-%(end)s') % {'start': start_vlan, + 'end': end_vlan}) + if start_vlan > end_vlan: + raise exception.InvalidParameterValue( + _('Invalid VLAN range %(start)s-%(end)s: start must ' + 'be less than or equal to end') % + {'start': start_vlan, 'end': end_vlan}) + vlan_set.update(range(start_vlan, end_vlan + 1)) + except (ValueError, AttributeError) as e: + raise exception.InvalidParameterValue( + _('Invalid VLAN range format "%(item)s": %(error)s') % + {'item': item, 'error': str(e)}) + else: + # Handle single VLAN ID + try: + vlan_id = int(item) + if vlan_id < 1 or vlan_id > 4094: + raise exception.InvalidParameterValue( + _('VLAN ID must be between 1 and 4094, got %s') % + vlan_id) + vlan_set.add(vlan_id) + except ValueError as e: + raise exception.InvalidParameterValue( + _('Invalid VLAN ID "%(item)s": %(error)s') % + {'item': item, 'error': str(e)}) + + return vlan_set + + +def validate_vlan_allowed(vlan_id, allowed_vlans_config=None, + switch_config=None): + """Validate that a VLAN ID is allowed. + + :param vlan_id: The VLAN ID to validate + :param allowed_vlans_config: Global list of allowed vlans from config + :param switch_config: Optional switch-specific configuration dict that + may contain an 'allowed_vlans' key + :returns: True if the VLAN is allowed + :raises: InvalidParameterValue if the VLAN is not allowed + """ + # Check switch-specific configuration first (if provided) + if switch_config and 'allowed_vlans' in switch_config: + allowed_spec = switch_config['allowed_vlans'] + else: + # Fall back to global configuration + if allowed_vlans_config is not None: + allowed_spec = allowed_vlans_config + else: + allowed_spec = CONF.ironic_networking.allowed_vlans + + # None means all VLANs are allowed + if allowed_spec is None: + return True + + # Empty list means no VLANs are allowed + if isinstance(allowed_spec, list) and len(allowed_spec) == 0: + raise exception.InvalidParameterValue( + _('VLAN %(vlan)s is not allowed: no VLANs are permitted by ' + 'configuration') % {'vlan': vlan_id}) + + # Parse and check against allowed VLANs + try: + allowed_vlans = parse_vlan_ranges(allowed_spec) + if vlan_id not in allowed_vlans: + raise exception.InvalidParameterValue( + _('VLAN %(vlan)s is not in the list of allowed VLANs') % + {'vlan': vlan_id}) + except exception.InvalidParameterValue: + # Re-raise validation errors from parse_vlan_ranges + raise + + return True diff --git a/ironic/tests/unit/common/test_release_mappings.py b/ironic/tests/unit/common/test_release_mappings.py index 907a1b9478..335b2f163e 100644 --- a/ironic/tests/unit/common/test_release_mappings.py +++ b/ironic/tests/unit/common/test_release_mappings.py @@ -58,12 +58,18 @@ class ReleaseMappingsTestCase(base.TestCase): def test_structure(self): for value in release_mappings.RELEASE_MAPPING.values(): self.assertIsInstance(value, dict) - self.assertEqual({'api', 'rpc', 'objects'}, set(value)) + # networking_rpc is optional + expected_keys = {'api', 'rpc', 'objects'} + optional_keys = {'networking_rpc'} + self.assertTrue(set(value).issubset(expected_keys | optional_keys)) + self.assertTrue(expected_keys.issubset(set(value))) self.assertIsInstance(value['api'], str) (major, minor) = value['api'].split('.') self.assertEqual(1, int(major)) self.assertLessEqual(int(minor), api_versions.MINOR_MAX_VERSION) self.assertIsInstance(value['rpc'], str) + if 'networking_rpc' in value: + self.assertIsInstance(value['networking_rpc'], str) self.assertIsInstance(value['objects'], dict) for obj_value in value['objects'].values(): self.assertIsInstance(obj_value, list) @@ -82,6 +88,12 @@ class ReleaseMappingsTestCase(base.TestCase): self.assertEqual(rpcapi.ConductorAPI.RPC_API_VERSION, release_mappings.RELEASE_MAPPING['master']['rpc']) + def test_current_networking_rpc_version(self): + from ironic.networking import rpcapi as networking_rpcapi + self.assertEqual( + networking_rpcapi.NetworkingAPI.RPC_API_VERSION, + release_mappings.RELEASE_MAPPING['master']['networking_rpc']) + def test_current_object_versions(self): registered_objects = obj_base.IronicObjectRegistry.obj_classes() obj_versions = release_mappings.get_object_versions( diff --git a/ironic/tests/unit/networking/__init__.py b/ironic/tests/unit/networking/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/tests/unit/networking/test_api.py b/ironic/tests/unit/networking/test_api.py new file mode 100644 index 0000000000..7f13cb8c9a --- /dev/null +++ b/ironic/tests/unit/networking/test_api.py @@ -0,0 +1,138 @@ +# +# 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. +"""Unit tests for ``ironic.networking.api``.""" +import unittest +from unittest import mock + +from ironic.networking import api + + +class NetworkingApiTestCase(unittest.TestCase): + """Test cases for helper functions in ``ironic.networking.api``.""" + + def setUp(self): + super().setUp() + self.addCleanup(setattr, api, "_NETWORKING_API", None) + + def test_get_networking_api_singleton(self): + with mock.patch( + "ironic.networking.api.rpcapi.NetworkingAPI", + autospec=True, + ) as mock_cls: + instance = mock_cls.return_value + + result1 = api.get_networking_api() + result2 = api.get_networking_api() + + self.assertIs(result1, instance) + self.assertIs(result2, instance) + mock_cls.assert_called_once_with() + + def test_update_port_delegates_to_rpc(self): + api_mock = mock.Mock() + with mock.patch.object( + api, "get_networking_api", return_value=api_mock, + autospec=True + ): + context = object() + result = api.update_port( + context, + "switch0", + "eth0", + "description", + "access", + 24, + allowed_vlans=[10], + lag_name="pc1", + ) + + api_mock.update_port.assert_called_once_with( + context, + "switch0", + "eth0", + "description", + "access", + 24, + allowed_vlans=[10], + lag_name="pc1", + default_vlan=None, + ) + self.assertIs(result, api_mock.update_port.return_value) + + def test_reset_port_delegates_to_rpc(self): + api_mock = mock.Mock() + with mock.patch.object( + api, "get_networking_api", return_value=api_mock, + autospec=True + ): + context = object() + result = api.reset_port( + context, "switch1", "eth1", default_vlan=11 + ) + + api_mock.reset_port.assert_called_once_with( + context, "switch1", "eth1", None, allowed_vlans=None, + default_vlan=11 + ) + self.assertIs(result, api_mock.reset_port.return_value) + + def test_update_lag_delegates_to_rpc(self): + api_mock = mock.Mock() + with mock.patch.object( + api, "get_networking_api", return_value=api_mock, + autospec=True + ): + context = object() + result = api.update_lag( + context, + ["switch1", "switch2"], + "pc", + "desc", + "trunk", + 100, + "lacp", + allowed_vlans=[200], + ) + + api_mock.update_lag.assert_called_once_with( + context, + ["switch1", "switch2"], + "pc", + "desc", + "trunk", + 100, + "lacp", + allowed_vlans=[200], + default_vlan=None, + ) + self.assertIs( + result, api_mock.update_lag.return_value + ) + + def test_delete_lag_delegates_to_rpc(self): + api_mock = mock.Mock() + with mock.patch.object( + api, "get_networking_api", return_value=api_mock, + autospec=True + ): + context = object() + result = api.delete_lag( + context, ["switch1"], "pc" + ) + + api_mock.delete_lag.assert_called_once_with( + context, ["switch1"], "pc" + ) + self.assertIs( + result, api_mock.delete_lag.return_value + ) diff --git a/ironic/tests/unit/networking/test_rpc_service.py b/ironic/tests/unit/networking/test_rpc_service.py new file mode 100644 index 0000000000..3d452452cd --- /dev/null +++ b/ironic/tests/unit/networking/test_rpc_service.py @@ -0,0 +1,54 @@ +# +# 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 unittest import mock + +from oslo_config import cfg + +from ironic.common.json_rpc import server as json_rpc_server +from ironic.common import rpc_service +from ironic.networking import manager as networking_manager +from ironic.tests import base as tests_base + + +CONF = cfg.CONF + + +class TestNetworkingRPCService(tests_base.TestCase): + + @mock.patch.object(json_rpc_server, "WSGIService", autospec=True) + @mock.patch.object( + rpc_service.objects_base, "IronicObjectSerializer", autospec=True + ) + @mock.patch.object(rpc_service.context, "get_admin_context", autospec=True) + def test_json_rpc_uses_networking_group(self, mock_ctx, mock_ser, mock_ws): + CONF.set_override("rpc_transport", "json-rpc") + # Ensure ironic networking group is registered and distinguishable + CONF.set_override("port", 9999, group="ironic_networking_json_rpc") + CONF.set_override("port", 8089, group="json_rpc") + + networking_manager.NetworkingManager(host="hostA") + svc = rpc_service.BaseRPCService( + "hostA", "ironic.networking.manager", "NetworkingManager" + ) + + # Trigger start path to build server + with mock.patch.object(svc.manager, "prepare_host", autospec=True): + with mock.patch.object(svc.manager, "init_host", autospec=True): + svc._real_start() + + self.assertTrue(mock_ws.called) + # Ensure conf_group was propagated to WSGIService + _, kwargs = mock_ws.call_args + self.assertEqual("ironic_networking_json_rpc", + kwargs.get("conf_group")) diff --git a/ironic/tests/unit/networking/test_rpcapi.py b/ironic/tests/unit/networking/test_rpcapi.py new file mode 100644 index 0000000000..cac58a5c15 --- /dev/null +++ b/ironic/tests/unit/networking/test_rpcapi.py @@ -0,0 +1,535 @@ +# +# 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. + +"""Unit tests for networking RPC API.""" + +from oslo_config import cfg +import unittest.mock as mock + +from ironic.common import exception +from ironic.common import rpc +from ironic.networking import rpcapi +from ironic.tests import base as test_base + +CONF = cfg.CONF + + +class TestNetworkingAPI(test_base.TestCase): + """Test cases for NetworkingAPI RPC client.""" + + def setUp(self): + super(TestNetworkingAPI, self).setUp() + self.context = mock.Mock() + self.api = rpcapi.NetworkingAPI() + + def test_init_default_topic(self): + """Test NetworkingAPI initialization with default topic.""" + with mock.patch.object(rpc, "NETWORKING_TOPIC", "test-topic"): + api = rpcapi.NetworkingAPI() + self.assertEqual("test-topic", api.topic) + + def test_init_custom_topic(self): + """Test NetworkingAPI initialization with custom topic.""" + api = rpcapi.NetworkingAPI(topic="custom-topic") + self.assertEqual("custom-topic", api.topic) + + def test_get_topic(self): + """Test get_topic method.""" + with mock.patch.object(rpc, "NETWORKING_TOPIC", "test-topic"): + api = rpcapi.NetworkingAPI() + self.assertEqual("test-topic", api.get_topic()) + + @mock.patch.object(rpc, "NETWORKING_TOPIC", "test-topic") + def test_init_with_none_transport(self): + """Test initialization with rpc_transport=none.""" + CONF.set_override("rpc_transport", "none") + api = rpcapi.NetworkingAPI() + self.assertIsNone(api.client) + + @mock.patch.object(rpc, "NETWORKING_TOPIC", "test-topic") + def test_init_with_json_rpc_transport(self): + """Test initialization with json-rpc transport.""" + CONF.set_override("rpc_transport", "json-rpc") + api = rpcapi.NetworkingAPI() + self.assertIsNotNone(api.client) + # Ensure the client is configured to use networking group + self.assertEqual("ironic_networking_json_rpc", api.client.conf_group) + + @mock.patch.object(rpc, "NETWORKING_TOPIC", "test-topic") + def test_init_with_oslo_messaging_transport(self): + """Test initialization with oslo.messaging transport.""" + CONF.set_override("rpc_transport", "oslo") + api = rpcapi.NetworkingAPI() + self.assertIsNotNone(api.client) + + @mock.patch.object(rpc, "NETWORKING_TOPIC", "test-topic") + def test_init_with_json_rpc_uses_networking_host(self): + """Test initialization with json-rpc uses networking host for topic.""" + CONF.set_override("rpc_transport", "json-rpc") + CONF.set_override( + "host_ip", "test-networking-host", + group="ironic_networking_json_rpc" + ) + CONF.set_override("port", 8089, group="ironic_networking_json_rpc") + api = rpcapi.NetworkingAPI() + self.assertEqual("ironic.test-networking-host:8089", api.topic) + self.assertIsNotNone(api.client) + + @mock.patch.object(rpc, "NETWORKING_TOPIC", "test-topic") + def test_init_with_oslo_messaging_uses_default_topic(self): + """Test initialization with oslo.messaging uses default topic.""" + CONF.set_override("rpc_transport", "oslo") + CONF.set_override("host", "test-networking-host") + api = rpcapi.NetworkingAPI() + self.assertEqual("test-topic", api.topic) + self.assertIsNotNone(api.client) + + @mock.patch.object(rpc, "NETWORKING_TOPIC", "test-topic") + def test_init_with_custom_topic_overrides_networking_host(self): + """Test custom topic overrides networking host even for JSON-RPC.""" + CONF.set_override("rpc_transport", "json-rpc") + CONF.set_override("host", "test-networking-host") + api = rpcapi.NetworkingAPI(topic="custom-topic") + self.assertEqual("custom-topic", api.topic) + self.assertIsNotNone(api.client) + + def test_prepare_call_none_client(self): + """Test _prepare_call with None client raises exception.""" + self.api.client = None + exc = self.assertRaises( + exception.ServiceUnavailable, self.api._prepare_call, topic="test" + ) + self.assertIn("Cannot use 'none' RPC", str(exc)) + + @mock.patch.object(rpcapi.NetworkingAPI, "_prepare_call", autospec=True) + def test_update_port_success(self, mock_prepare): + """Test successful update_port call.""" + mock_cctxt = mock.Mock() + mock_prepare.return_value = mock_cctxt + mock_cctxt.call.return_value = {"status": "configured"} + + result = self.api.update_port( + self.context, + "switch-01", + "port-01", + "Test port", + "access", + 100, + allowed_vlans=None, + lag_name=None, + default_vlan=1 + ) + + mock_prepare.assert_called_once_with( + self.api, topic=None, version="1.0" + ) + mock_cctxt.call.assert_called_once_with( + self.context, + "update_port", + switch_id="switch-01", + port_name="port-01", + description="Test port", + mode="access", + native_vlan=100, + allowed_vlans=None, + lag_name=None, + default_vlan=1 + ) + self.assertEqual({"status": "configured"}, result) + + @mock.patch.object(rpcapi.NetworkingAPI, "_prepare_call", autospec=True) + def test_update_port_with_allowed_vlans(self, mock_prepare): + """Test update_port call with allowed VLANs.""" + mock_cctxt = mock.Mock() + mock_prepare.return_value = mock_cctxt + mock_cctxt.call.return_value = {"status": "configured"} + + result = self.api.update_port( + self.context, + "switch-01", + "port-01", + "Test port", + "trunk", + 100, + allowed_vlans=[101, 102], + lag_name="po1", + default_vlan=1 + ) + + mock_prepare.assert_called_once_with( + self.api, topic=None, version="1.0" + ) + mock_cctxt.call.assert_called_once_with( + self.context, + "update_port", + switch_id="switch-01", + port_name="port-01", + description="Test port", + mode="trunk", + native_vlan=100, + allowed_vlans=[101, 102], + lag_name="po1", + default_vlan=1 + ) + self.assertEqual({"status": "configured"}, result) + + @mock.patch.object(rpcapi.NetworkingAPI, "_prepare_call", autospec=True) + def test_update_port_with_custom_topic(self, mock_prepare): + """Test update_port call with custom topic.""" + mock_cctxt = mock.Mock() + mock_prepare.return_value = mock_cctxt + mock_cctxt.call.return_value = {"status": "configured"} + + result = self.api.update_port( + self.context, + "switch-01", + "port-01", + "Test port", + "access", + 100, + default_vlan=1, + topic="custom-topic", + ) + + mock_prepare.assert_called_once_with( + self.api, topic="custom-topic", version="1.0" + ) + mock_cctxt.call.assert_called_once_with( + self.context, + "update_port", + switch_id="switch-01", + port_name="port-01", + description="Test port", + mode="access", + native_vlan=100, + allowed_vlans=None, + lag_name=None, + default_vlan=1, + ) + self.assertEqual({"status": "configured"}, result) + + @mock.patch.object(rpcapi.NetworkingAPI, "_prepare_call", autospec=True) + def test_reset_port_success(self, mock_prepare): + """Test successful reset_port call.""" + mock_cctxt = mock.Mock() + mock_prepare.return_value = mock_cctxt + mock_cctxt.call.return_value = {"status": "reset"} + + result = self.api.reset_port( + self.context, + "switch-01", + "port-01", + 100, + default_vlan=1) + + mock_prepare.assert_called_once_with( + self.api, topic=None, version="1.0" + ) + mock_cctxt.call.assert_called_once_with( + self.context, + "reset_port", + switch_id="switch-01", + port_name="port-01", + native_vlan=100, + allowed_vlans=None, + default_vlan=1, + ) + self.assertEqual({"status": "reset"}, result) + + @mock.patch.object(rpcapi.NetworkingAPI, "_prepare_call", autospec=True) + def test_reset_port_with_allowed_vlans(self, mock_prepare): + """Test reset_port call with allowed VLANs.""" + mock_cctxt = mock.Mock() + mock_prepare.return_value = mock_cctxt + mock_cctxt.call.return_value = {"status": "reset"} + + result = self.api.reset_port( + self.context, + "switch-01", + "port-01", + 100, + allowed_vlans=[101, 102], + default_vlan=1 + ) + + mock_prepare.assert_called_once_with( + self.api, topic=None, version="1.0" + ) + mock_cctxt.call.assert_called_once_with( + self.context, + "reset_port", + switch_id="switch-01", + port_name="port-01", + native_vlan=100, + allowed_vlans=[101, 102], + default_vlan=1, + ) + self.assertEqual({"status": "reset"}, result) + + @mock.patch.object(rpcapi.NetworkingAPI, "_prepare_call", autospec=True) + def test_reset_port_with_custom_topic(self, mock_prepare): + """Test reset_port call with custom topic.""" + mock_cctxt = mock.Mock() + mock_prepare.return_value = mock_cctxt + mock_cctxt.call.return_value = {"status": "reset"} + + result = self.api.reset_port( + self.context, + "switch-01", + "port-01", + 100, + default_vlan=1, + topic="custom-topic" + ) + + mock_prepare.assert_called_once_with( + self.api, topic="custom-topic", version="1.0" + ) + mock_cctxt.call.assert_called_once_with( + self.context, + "reset_port", + switch_id="switch-01", + port_name="port-01", + native_vlan=100, + allowed_vlans=None, + default_vlan=1, + ) + self.assertEqual({"status": "reset"}, result) + + @mock.patch.object(rpcapi.NetworkingAPI, "_prepare_call", autospec=True) + def test_get_switches_success(self, mock_prepare): + """Test successful get_switches call.""" + mock_cctxt = mock.Mock() + mock_prepare.return_value = mock_cctxt + expected_switches = { + "switch-01": {"name": "switch-01", "status": "connected"}, + "switch-02": {"name": "switch-02", "status": "connected"}, + } + mock_cctxt.call.return_value = expected_switches + + result = self.api.get_switches(self.context) + + mock_prepare.assert_called_once_with( + self.api, topic=None, version="1.0" + ) + mock_cctxt.call.assert_called_once_with(self.context, "get_switches") + self.assertEqual(expected_switches, result) + + @mock.patch.object(rpcapi.NetworkingAPI, "_prepare_call", autospec=True) + def test_get_switches_with_custom_topic(self, mock_prepare): + """Test get_switches call with custom topic.""" + mock_cctxt = mock.Mock() + mock_prepare.return_value = mock_cctxt + expected_switches = {} + mock_cctxt.call.return_value = expected_switches + + result = self.api.get_switches(self.context, topic="custom-topic") + + mock_prepare.assert_called_once_with( + self.api, topic="custom-topic", version="1.0" + ) + mock_cctxt.call.assert_called_once_with(self.context, "get_switches") + self.assertEqual(expected_switches, result) + + @mock.patch.object(rpcapi.NetworkingAPI, "_prepare_call", autospec=True) + def test_update_lag_success(self, mock_prepare): + """Test successful update_lag call.""" + mock_cctxt = mock.Mock() + mock_prepare.return_value = mock_cctxt + mock_cctxt.call.return_value = {"status": "configured"} + + result = self.api.update_lag( + self.context, + ["switch-01", "switch-02"], + "lag-01", + "Test LAG", + "trunk", + 100, + "lacp", + allowed_vlans=[101, 102], + default_vlan=1, + ) + + mock_prepare.assert_called_once_with( + self.api, topic=None, version="1.0" + ) + mock_cctxt.call.assert_called_once_with( + self.context, + "update_lag", + switch_ids=["switch-01", "switch-02"], + lag_name="lag-01", + description="Test LAG", + mode="trunk", + native_vlan=100, + aggregation_mode="lacp", + allowed_vlans=[101, 102], + default_vlan=1, + ) + self.assertEqual({"status": "configured"}, result) + + @mock.patch.object(rpcapi.NetworkingAPI, "_prepare_call", autospec=True) + def test_update_lag_without_allowed_vlans(self, mock_prepare): + """Test update_lag call without allowed VLANs.""" + mock_cctxt = mock.Mock() + mock_prepare.return_value = mock_cctxt + mock_cctxt.call.return_value = {"status": "configured"} + + result = self.api.update_lag( + self.context, + ["switch-01"], + "lag-01", + "Test LAG", + "access", + 100, + "static", + default_vlan=1, + ) + + mock_prepare.assert_called_once_with( + self.api, topic=None, version="1.0" + ) + mock_cctxt.call.assert_called_once_with( + self.context, + "update_lag", + switch_ids=["switch-01"], + lag_name="lag-01", + description="Test LAG", + mode="access", + native_vlan=100, + aggregation_mode="static", + allowed_vlans=None, + default_vlan=1, + ) + self.assertEqual({"status": "configured"}, result) + + @mock.patch.object(rpcapi.NetworkingAPI, "_prepare_call", autospec=True) + def test_update_lag_with_custom_topic(self, mock_prepare): + """Test update_lag call with custom topic.""" + mock_cctxt = mock.Mock() + mock_prepare.return_value = mock_cctxt + mock_cctxt.call.return_value = {"status": "configured"} + + result = self.api.update_lag( + self.context, + ["switch-01"], + "lag-01", + "Test LAG", + "access", + 100, + "static", + default_vlan=1, + topic="custom-topic", + ) + + mock_prepare.assert_called_once_with( + self.api, topic="custom-topic", version="1.0" + ) + mock_cctxt.call.assert_called_once_with( + self.context, + "update_lag", + switch_ids=["switch-01"], + lag_name="lag-01", + description="Test LAG", + mode="access", + native_vlan=100, + aggregation_mode="static", + allowed_vlans=None, + default_vlan=1, + ) + self.assertEqual({"status": "configured"}, result) + + @mock.patch.object(rpcapi.NetworkingAPI, "_prepare_call", autospec=True) + def test_delete_lag_success(self, mock_prepare): + """Test successful delete_lag call.""" + mock_cctxt = mock.Mock() + mock_prepare.return_value = mock_cctxt + mock_cctxt.call.return_value = {"status": "deleted"} + + result = self.api.delete_lag( + self.context, ["switch-01", "switch-02"], "lag-01" + ) + + mock_prepare.assert_called_once_with( + self.api, topic=None, version="1.0" + ) + mock_cctxt.call.assert_called_once_with( + self.context, + "delete_lag", + switch_ids=["switch-01", "switch-02"], + lag_name="lag-01", + ) + self.assertEqual({"status": "deleted"}, result) + + @mock.patch.object(rpcapi.NetworkingAPI, "_prepare_call", autospec=True) + def test_delete_lag_with_custom_topic(self, mock_prepare): + """Test delete_lag call with custom topic.""" + mock_cctxt = mock.Mock() + mock_prepare.return_value = mock_cctxt + mock_cctxt.call.return_value = {"status": "deleted"} + + result = self.api.delete_lag( + self.context, ["switch-01"], "lag-01", topic="custom-topic" + ) + + mock_prepare.assert_called_once_with( + self.api, topic="custom-topic", version="1.0" + ) + mock_cctxt.call.assert_called_once_with( + self.context, + "delete_lag", + switch_ids=["switch-01"], + lag_name="lag-01", + ) + self.assertEqual({"status": "deleted"}, result) + + +class TestNetworkingAPIVersionCap(test_base.TestCase): + """Test cases for NetworkingAPI version cap handling.""" + + def setUp(self): + super(TestNetworkingAPIVersionCap, self).setUp() + self.context = mock.Mock() + + @mock.patch.object(rpc, "NETWORKING_TOPIC", "test-topic") + def test_version_cap_from_release_mapping(self): + """Test version cap is set from release mapping.""" + with mock.patch.object( + rpcapi.versions, + "RELEASE_MAPPING", + {"zed": {"rpc": "1.0"}}): + CONF.set_override("pin_release_version", "zed") + api = rpcapi.NetworkingAPI() + # Version cap should be applied from release mapping + self.assertIsNotNone(api.client) + + @mock.patch.object(rpc, "NETWORKING_TOPIC", "test-topic") + def test_version_cap_fallback_to_current(self): + """Test version cap falls back to current version.""" + with mock.patch.object(rpcapi.versions, "RELEASE_MAPPING", {}): + CONF.set_override("pin_release_version", None) + api = rpcapi.NetworkingAPI() + # Should use current RPC_API_VERSION + self.assertIsNotNone(api.client) + + @mock.patch.object(rpc, "NETWORKING_TOPIC", "test-topic") + def test_version_cap_no_pin_release_version(self): + """Test version cap when pin_release_version is not set.""" + with mock.patch.object( + rpcapi.versions, + "RELEASE_MAPPING", + {"zed": {"rpc": "1.0"}} + ): + CONF.set_override("pin_release_version", None) + api = rpcapi.NetworkingAPI() + # Should use current RPC_API_VERSION + self.assertIsNotNone(api.client) diff --git a/ironic/tests/unit/networking/test_utils.py b/ironic/tests/unit/networking/test_utils.py new file mode 100644 index 0000000000..2a05afe28c --- /dev/null +++ b/ironic/tests/unit/networking/test_utils.py @@ -0,0 +1,309 @@ +# +# 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. +"""Unit tests for ``ironic.networking.utils``.""" + +import unittest +from unittest import mock + +from ironic.common import exception +from ironic.networking import utils +from ironic.tests import base + + +class ParseVlanRangesTestCase(base.TestCase): + """Test cases for parse_vlan_ranges function.""" + + def test_parse_vlan_ranges_none(self): + """Test that None returns None.""" + result = utils.parse_vlan_ranges(None) + self.assertIsNone(result) + + def test_parse_vlan_ranges_empty_list(self): + """Test that empty list returns empty set.""" + result = utils.parse_vlan_ranges([]) + self.assertEqual(set(), result) + + def test_parse_vlan_ranges_single_vlan(self): + """Test parsing a single VLAN ID.""" + result = utils.parse_vlan_ranges(['100']) + self.assertEqual({100}, result) + + def test_parse_vlan_ranges_multiple_vlans(self): + """Test parsing multiple VLAN IDs.""" + result = utils.parse_vlan_ranges(['100', '200', '300']) + self.assertEqual({100, 200, 300}, result) + + def test_parse_vlan_ranges_simple_range(self): + """Test parsing a simple VLAN range.""" + result = utils.parse_vlan_ranges(['100-103']) + self.assertEqual({100, 101, 102, 103}, result) + + def test_parse_vlan_ranges_complex_spec(self): + """Test parsing complex specification with ranges and singles.""" + result = utils.parse_vlan_ranges( + ['100', '101', '102-104', '106'] + ) + self.assertEqual({100, 101, 102, 103, 104, 106}, result) + + def test_parse_vlan_ranges_with_spaces(self): + """Test parsing with spaces in the specification.""" + result = utils.parse_vlan_ranges( + [' 100 ', ' 102 - 104 ', ' 106'] + ) + self.assertEqual({100, 102, 103, 104, 106}, result) + + def test_parse_vlan_ranges_invalid_vlan_too_low(self): + """Test that VLAN ID 0 raises an error.""" + self.assertRaises( + exception.InvalidParameterValue, + utils.parse_vlan_ranges, + ['0'] + ) + + def test_parse_vlan_ranges_invalid_vlan_too_high(self): + """Test that VLAN ID 4095 raises an error.""" + self.assertRaises( + exception.InvalidParameterValue, + utils.parse_vlan_ranges, + ['4095'] + ) + + def test_parse_vlan_ranges_invalid_range_start_too_low(self): + """Test that range starting at 0 raises an error.""" + self.assertRaises( + exception.InvalidParameterValue, + utils.parse_vlan_ranges, + ['0-10'] + ) + + def test_parse_vlan_ranges_invalid_range_end_too_high(self): + """Test that range ending at 4095 raises an error.""" + self.assertRaises( + exception.InvalidParameterValue, + utils.parse_vlan_ranges, + ['4090-4095'] + ) + + def test_parse_vlan_ranges_invalid_range_start_greater_than_end(self): + """Test that reversed range raises an error.""" + self.assertRaises( + exception.InvalidParameterValue, + utils.parse_vlan_ranges, + ['104-100'] + ) + + def test_parse_vlan_ranges_invalid_format_not_a_number(self): + """Test that non-numeric VLAN raises an error.""" + self.assertRaises( + exception.InvalidParameterValue, + utils.parse_vlan_ranges, + ['abc'] + ) + + def test_parse_vlan_ranges_invalid_format_bad_range(self): + """Test that malformed range raises an error.""" + self.assertRaises( + exception.InvalidParameterValue, + utils.parse_vlan_ranges, + ['100-200-300'] + ) + + def test_parse_vlan_ranges_boundary_values(self): + """Test parsing with boundary VLAN values (1 and 4094).""" + result = utils.parse_vlan_ranges(['1', '4094']) + self.assertEqual({1, 4094}, result) + + +class ValidateVlanAllowedTestCase(base.TestCase): + """Test cases for validate_vlan_allowed function.""" + + def test_validate_vlan_allowed_none_config(self): + """Test that None config allows all VLANs.""" + with mock.patch('ironic.networking.utils.CONF', + spec_set=['ironic_networking']) as mock_conf: + mock_conf.ironic_networking.allowed_vlans = None + result = utils.validate_vlan_allowed(100) + self.assertTrue(result) + + def test_validate_vlan_allowed_empty_list_config(self): + """Test that empty list config denies all VLANs.""" + with mock.patch('ironic.networking.utils.CONF', + spec_set=['ironic_networking']) as mock_conf: + mock_conf.ironic_networking.allowed_vlans = [] + self.assertRaises( + exception.InvalidParameterValue, + utils.validate_vlan_allowed, + 100 + ) + + def test_validate_vlan_allowed_vlan_in_list(self): + """Test that VLAN in allowed list is accepted.""" + with mock.patch('ironic.networking.utils.CONF', + spec_set=['ironic_networking']) as mock_conf: + mock_conf.ironic_networking.allowed_vlans = ['100', '200', '300'] + result = utils.validate_vlan_allowed(100) + self.assertTrue(result) + + def test_validate_vlan_allowed_vlan_not_in_list(self): + """Test that VLAN not in allowed list is rejected.""" + with mock.patch('ironic.networking.utils.CONF', + spec_set=['ironic_networking']) as mock_conf: + mock_conf.ironic_networking.allowed_vlans = ['100', '200', '300'] + self.assertRaises( + exception.InvalidParameterValue, + utils.validate_vlan_allowed, + 150 + ) + + def test_validate_vlan_allowed_vlan_in_range(self): + """Test that VLAN in allowed range is accepted.""" + with mock.patch('ironic.networking.utils.CONF', + spec_set=['ironic_networking']) as mock_conf: + mock_conf.ironic_networking.allowed_vlans = ['100-200'] + result = utils.validate_vlan_allowed(150) + self.assertTrue(result) + + def test_validate_vlan_allowed_vlan_not_in_range(self): + """Test that VLAN not in allowed range is rejected.""" + with mock.patch('ironic.networking.utils.CONF', + spec_set=['ironic_networking']) as mock_conf: + mock_conf.ironic_networking.allowed_vlans = ['100-200'] + self.assertRaises( + exception.InvalidParameterValue, + utils.validate_vlan_allowed, + 250 + ) + + def test_validate_vlan_allowed_complex_spec(self): + """Test validation with complex allowed VLAN specification.""" + with mock.patch('ironic.networking.utils.CONF', + spec_set=['ironic_networking']) as mock_conf: + mock_conf.ironic_networking.allowed_vlans = [ + '100', '101', '102-104', '106' + ] + # Test allowed VLANs + self.assertTrue(utils.validate_vlan_allowed(100)) + self.assertTrue(utils.validate_vlan_allowed(101)) + self.assertTrue(utils.validate_vlan_allowed(102)) + self.assertTrue(utils.validate_vlan_allowed(103)) + self.assertTrue(utils.validate_vlan_allowed(104)) + self.assertTrue(utils.validate_vlan_allowed(106)) + # Test disallowed VLAN + self.assertRaises( + exception.InvalidParameterValue, + utils.validate_vlan_allowed, + 105 + ) + + def test_validate_vlan_allowed_override_config(self): + """Test that allowed_vlans_config parameter overrides CONF.""" + with mock.patch('ironic.networking.utils.CONF', + spec_set=['ironic_networking']) as mock_conf: + mock_conf.ironic_networking.allowed_vlans = ['100'] + # Override should allow 200, not 100 + result = utils.validate_vlan_allowed( + 200, + allowed_vlans_config=['200'] + ) + self.assertTrue(result) + # Should reject 100 when using override + self.assertRaises( + exception.InvalidParameterValue, + utils.validate_vlan_allowed, + 100, + allowed_vlans_config=['200'] + ) + + def test_validate_vlan_allowed_switch_config_override(self): + """Test that switch config overrides global config.""" + with mock.patch('ironic.networking.utils.CONF', + spec_set=['ironic_networking']) as mock_conf: + mock_conf.ironic_networking.allowed_vlans = ['100'] + switch_config = {'allowed_vlans': ['200']} + # Switch config should allow 200, not 100 + result = utils.validate_vlan_allowed( + 200, + switch_config=switch_config + ) + self.assertTrue(result) + # Should reject 100 when using switch config + self.assertRaises( + exception.InvalidParameterValue, + utils.validate_vlan_allowed, + 100, + switch_config=switch_config + ) + + def test_validate_vlan_allowed_switch_config_no_allowed_vlans(self): + """Test that switch config without allowed_vlans uses global.""" + with mock.patch('ironic.networking.utils.CONF', + spec_set=['ironic_networking']) as mock_conf: + mock_conf.ironic_networking.allowed_vlans = ['100'] + switch_config = {'some_other_key': 'value'} + # Should fall back to global config + result = utils.validate_vlan_allowed( + 100, + switch_config=switch_config + ) + self.assertTrue(result) + + def test_validate_vlan_allowed_switch_config_empty_list(self): + """Test that switch config with empty list denies all VLANs.""" + with mock.patch('ironic.networking.utils.CONF', + spec_set=['ironic_networking']) as mock_conf: + mock_conf.ironic_networking.allowed_vlans = ['100'] + switch_config = {'allowed_vlans': []} + # Switch config empty list should deny even though global allows + self.assertRaises( + exception.InvalidParameterValue, + utils.validate_vlan_allowed, + 100, + switch_config=switch_config + ) + + def test_validate_vlan_allowed_switch_config_none(self): + """Test that switch config with None allows all VLANs.""" + with mock.patch('ironic.networking.utils.CONF', + spec_set=['ironic_networking']) as mock_conf: + mock_conf.ironic_networking.allowed_vlans = ['100'] + switch_config = {'allowed_vlans': None} + # Switch config None should allow all, even though global restricts + result = utils.validate_vlan_allowed( + 200, + switch_config=switch_config + ) + self.assertTrue(result) + + +class RpcTransportTestCase(unittest.TestCase): + """Test cases for rpc_transport function.""" + + def test_rpc_transport_uses_networking_when_set(self): + """Test that networking.rpc_transport is used when set.""" + with mock.patch('ironic.networking.utils.CONF', + spec_set=['ironic_networking', 'rpc_transport'] + ) as mock_conf: + mock_conf.ironic_networking.rpc_transport = 'json-rpc' + mock_conf.rpc_transport = 'oslo_messaging' + result = utils.rpc_transport() + self.assertEqual('json-rpc', result) + + def test_rpc_transport_falls_back_to_global(self): + """Test that global rpc_transport is used when networking is None.""" + with mock.patch('ironic.networking.utils.CONF', + spec_set=['ironic_networking', 'rpc_transport'] + ) as mock_conf: + mock_conf.ironic_networking.rpc_transport = None + mock_conf.rpc_transport = 'oslo_messaging' + result = utils.rpc_transport() + self.assertEqual('oslo_messaging', result) diff --git a/pyproject.toml b/pyproject.toml index 681f192db9..ac742b5aa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -203,6 +203,7 @@ ironic = "ironic.command.singleprocess:main" ironic-api = "ironic.command.api:main" ironic-dbsync = "ironic.command.dbsync:main" ironic-conductor = "ironic.command.conductor:main" +ironic-networking = "ironic.command.networking:main" ironic-novncproxy = "ironic.command.novncproxy:main" ironic-status = "ironic.command.status:main" ironic-pxe-filter = "ironic.command.pxe_filter:main" diff --git a/tools/config/ironic-networking-config-generator.conf b/tools/config/ironic-networking-config-generator.conf new file mode 100644 index 0000000000..f542468c99 --- /dev/null +++ b/tools/config/ironic-networking-config-generator.conf @@ -0,0 +1,7 @@ +[DEFAULT] +output_file = etc/ironic/ironic.networking.conf.sample +wrap_width = 62 +namespace = ironic +namespace = oslo.log +namespace = oslo.service.service +namespace = oslo.concurrency diff --git a/tox.ini b/tox.ini index 29c15fbf60..5dd8b06c05 100644 --- a/tox.ini +++ b/tox.ini @@ -67,6 +67,7 @@ commands = sitepackages = False commands = oslo-config-generator --config-file=tools/config/ironic-config-generator.conf + oslo-config-generator --config-file=tools/config/ironic-networking-config-generator.conf [testenv:genpolicy] sitepackages = False