Add standalone networking service for ironic

Implements the foundational infrastructure for a new standalone
networking service that can operate independently of the main ironic
conductor. This commit establishes the service skeleton with:

- RPC API layer with oslo.messaging integration for remote calls
- Public API interface for conductor/API to interact with the service
- RPC service implementation for handling network requests
- Stub networking manager with method signatures (implementation
  added in subsequent commit)
- Service entry point (ironic-networking command) for deployment
- Configuration options for service behavior and networking backend
- Infrastructure and packaging changes for the new service

The manager includes stub implementations that raise NetworkError,
with the full implementation of network operations, driver framework
and switch drivers are added in subsequence commits.

Related-Bug: 2113769
Assisted-by: Claude/sonnet-4.5
Change-Id: I351c7afe96cbcebd6b2e2bb5f0b4f17b5d804ceb
Signed-off-by: Allain Legacy <alegacy@redhat.com>
This commit is contained in:
Allain Legacy 2025-11-05 14:41:59 -05:00
parent f2a3ec5d5e
commit b8f3318ca6
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