Merge "Add standalone networking service for ironic"
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
64
ironic/command/networking.py
Normal file
64
ironic/command/networking.py
Normal 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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -34,6 +34,7 @@ EXTRA_EXMODS = []
|
||||
GLOBAL_MANAGER = None
|
||||
|
||||
MANAGER_TOPIC = 'ironic.conductor_manager'
|
||||
NETWORKING_TOPIC = 'ironic.networking_manager'
|
||||
|
||||
|
||||
def init(conf):
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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)
|
||||
|
||||
118
ironic/conf/ironic_networking.py
Normal file
118
ironic/conf/ironic_networking.py
Normal 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
|
||||
@@ -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),
|
||||
|
||||
0
ironic/networking/__init__.py
Normal file
0
ironic/networking/__init__.py
Normal file
169
ironic/networking/api.py
Normal file
169
ironic/networking/api.py
Normal 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)
|
||||
217
ironic/networking/manager.py
Normal file
217
ironic/networking/manager.py
Normal 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
|
||||
78
ironic/networking/rpc_service.py
Normal file
78
ironic/networking/rpc_service.py
Normal 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
251
ironic/networking/rpcapi.py
Normal 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
129
ironic/networking/utils.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
0
ironic/tests/unit/networking/__init__.py
Normal file
0
ironic/tests/unit/networking/__init__.py
Normal file
138
ironic/tests/unit/networking/test_api.py
Normal file
138
ironic/tests/unit/networking/test_api.py
Normal 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
|
||||
)
|
||||
54
ironic/tests/unit/networking/test_rpc_service.py
Normal file
54
ironic/tests/unit/networking/test_rpc_service.py
Normal 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"))
|
||||
535
ironic/tests/unit/networking/test_rpcapi.py
Normal file
535
ironic/tests/unit/networking/test_rpcapi.py
Normal 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)
|
||||
309
ironic/tests/unit/networking/test_utils.py
Normal file
309
ironic/tests/unit/networking/test_utils.py
Normal 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)
|
||||
@@ -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"
|
||||
|
||||
7
tools/config/ironic-networking-config-generator.conf
Normal file
7
tools/config/ironic-networking-config-generator.conf
Normal 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
|
||||
Reference in New Issue
Block a user