Merge "Add standalone networking service for ironic"

This commit is contained in:
Zuul
2025-12-01 14:20:32 +00:00
committed by Gerrit Code Review
26 changed files with 2117 additions and 4 deletions

2
.gitignore vendored
View File

@@ -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

View File

@@ -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())

View File

@@ -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)

View File

@@ -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

View File

@@ -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.

View File

@@ -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'],

View File

@@ -34,6 +34,7 @@ EXTRA_EXMODS = []
GLOBAL_MANAGER = None
MANAGER_TOPIC = 'ironic.conductor_manager'
NETWORKING_TOPIC = 'ironic.networking_manager'
def init(conf):

View File

@@ -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()})

View File

@@ -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)

View File

@@ -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

View File

@@ -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),

View File

169
ironic/networking/api.py Normal file
View File

@@ -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)

View File

@@ -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

View File

@@ -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},
)

251
ironic/networking/rpcapi.py Normal file
View File

@@ -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,
)

129
ironic/networking/utils.py Normal file
View File

@@ -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

View File

@@ -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(

View File

View File

@@ -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
)

View File

@@ -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"))

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"

View File

@@ -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

View File

@@ -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