Add netconf-openconfig device driver

Add the initial part of a device driver using Netconf and
OpenConfig models. Implements network create/delete/update
and port create/delete/update.

Also bump paramiko lower-constrain to 2.3.2, see:
  https://github.com/paramiko/paramiko/issues/1108

Story: 2009961
Task: 44996

Depends-On: https://review.opendev.org//837105
Change-Id: Ifc89923d7f6bbfba25feb2218b80fea9e27b9c4a
changes/24/835324/24
Harald Jensås 2022-03-23 23:21:55 +01:00
parent 30b6008c17
commit 3b99ee3bc7
10 changed files with 1193 additions and 2 deletions

View File

@ -13,13 +13,16 @@
from oslo_config import cfg
from oslo_log import log as logging
import networking_baremetal.drivers.netconf.openconfig as netconf_openconfig
CONF = cfg.CONF
LOG = logging.getLogger(__name__)
_opts = [
cfg.ListOpt('enabled_devices',
default=[],
sample_default=['device.example.com'],
sample_default=['generic.example.com',
'netconf-openconfig.example.com'],
help=('Enabled devices for which the plugin should manage'
'configuration. Driver specific configuration for each '
'device must be added in separate sections.')),
@ -58,7 +61,10 @@ for device in CONF.networking_baremetal.enabled_devices:
def list_opts():
return [('networking_baremetal', _opts),
('device.example.com', _device_opts)]
('generic.example.com', _device_opts),
('netconf-openconfig.example.com',
_device_opts + netconf_openconfig._DEVICE_OPTS
+ netconf_openconfig._NCCLIENT_OPTS)]
def get_devices():

View File

@ -0,0 +1,669 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import re
from urllib.parse import parse_qs as urlparse_qs
from urllib.parse import urlparse
import uuid
from xml.etree.ElementTree import fromstring as etree_fromstring
from ncclient import manager
from ncclient.operations.rpc import RPCError
from ncclient.transport.errors import AuthenticationError
from ncclient.transport.errors import SessionCloseError
from ncclient.transport.errors import SSHError
from neutron_lib.api.definitions import portbindings
from neutron_lib.api.definitions import provider_net
from neutron_lib import constants as n_const
from neutron_lib import exceptions as n_exec
from neutron_lib.plugins.ml2 import api
from oslo_config import cfg
from oslo_log import log as logging
import tenacity
from networking_baremetal import common
from networking_baremetal import constants
from networking_baremetal.constants import NetconfEditConfigOperation as nc_op
from networking_baremetal.drivers import base
from networking_baremetal import exceptions
from networking_baremetal.openconfig.interfaces import interfaces
from networking_baremetal.openconfig.network_instance import network_instance
from networking_baremetal.openconfig.vlan import vlan
CONF = cfg.CONF
LOG = logging.getLogger(__name__)
LOCK_DENIED_TAG = 'lock-denied' # [RFC 4741]
CANDIDATE = 'candidate'
RUNNING = 'running'
# Options for the device, maps to the local_link_information in the
# port binding profile.
_DEVICE_OPTS = [
cfg.StrOpt('network_instance',
default='default',
advanced=True,
help=('The L2, L3, or L2+L3 forwarding instance to use when '
'defining VLANs on the device.')),
cfg.DictOpt('port_id_re_sub',
default={},
sample_default={'pattern': 'Ethernet', 'repl': 'eth'},
help=('Regular expression pattern and replacement string. '
'Some devices do not use the port description from '
'LLDP in Netconf configuration. If the regular '
'expression pattern and replacement string is set the '
'port_id will be modified before passing configuration '
'to the device.')),
cfg.ListOpt('disabled_properties',
item_type=cfg.types.String(
choices=['port_mtu']),
default=[],
help=('A list of properties that should not be used, '
'currently only "port_mtu" is valid'))
]
# Configuration option for Netconf client connection
_NCCLIENT_OPTS = [
cfg.StrOpt('host',
help=('The hostname or IP address to use for connecting to the '
'netconf device.'),
sample_default='device.example.com'),
cfg.StrOpt('username',
help='The username to use for SSH authentication.',
sample_default='netconf'),
cfg.IntOpt('port', default=830,
help=('The port to use for connection to the netconf '
'device.')),
cfg.StrOpt('password',
help=('The password used if using password authentication, or '
'the passphrase to use for unlocking keys that require '
'it. (To disable attempting key authentication '
'altogether, set options *allow_agent* and '
'*look_for_keys* to `False`.'),
sample_default='secret'),
cfg.StrOpt('key_filename',
help='Private key filename',
default='~/.ssh/id_rsa'),
cfg.BoolOpt('hostkey_verify',
default=True,
help=('Enables hostkey verification from '
'~/.ssh/known_hosts')),
cfg.DictOpt('device_params',
default={'name': 'default'},
help=('ncclient device handler parameters, see ncclient '
'documentation for supported device handlers.')),
cfg.BoolOpt('allow_agent',
default=True,
help='Enables querying SSH agent (if found) for keys.'),
cfg.BoolOpt('look_for_keys',
default=True,
help=('Enables looking in the usual locations for ssh keys '
'(e.g. :file:`~/.ssh/id_*`)')),
]
class NetconfLockDenied(n_exec.NeutronException):
message = ('Access to the requested lock is denied because the'
'lock is currently held by another entity.')
class NetconfOpenConfigClient(base.BaseDeviceClient):
def __init__(self, device):
super().__init__(device)
self.device = device
self.capabilities = set()
# Reduce the log level for ncclient, it is very chatty by default
netconf_logger = logging.getLogger('ncclient')
netconf_logger.setLevel(logging.WARNING)
@staticmethod
def _get_lock_session_id(err_info):
"""Parse lock-denied error [RFC6241]
error-tag: lock-denied
error-type: protocol
error-severity: error
error-info: <session-id> : session ID of session holding the
requested lock, or zero to indicate a non-NETCONF
entity holds the lock
Description: Access to the requested lock is denied because the
lock is currently held by another entity.
"""
root = etree_fromstring(err_info)
session_id = root.find(
"./{urn:ietf:params:xml:ns:netconf:base:1.0}session-id").text
return session_id
@staticmethod
def process_capabilities(server_capabilities):
capabilities = set()
for capability in server_capabilities:
for k, v in constants.IANA_NETCONF_CAPABILITIES.items():
if v in capability:
capabilities.add(k)
if capability.startswith('http://openconfig.net/yang'):
openconfig_module = urlparse_qs(
urlparse(capability).query).get('module').pop()
capabilities.add(openconfig_module)
return capabilities
def get_capabilities(self):
# https://github.com/ncclient/ncclient/issues/525
_ignore_close_issue_525 = False
args = self.get_client_args()
try:
with manager.connect(**args) as nc_client:
server_capabilities = nc_client.server_capabilities
_ignore_close_issue_525 = True
except SessionCloseError as e:
if not _ignore_close_issue_525:
raise e
except (SSHError, AuthenticationError) as e:
raise exceptions.DeviceConnectionError(device=self.device, err=e)
return self.process_capabilities(server_capabilities)
def get_client_args(self):
"""Get client connection arguments from configuration
:param device: Device identifier
"""
args = dict(
host=CONF[self.device].host,
port=CONF[self.device].port,
username=CONF[self.device].username,
hostkey_verify=CONF[self.device].hostkey_verify,
device_params=CONF[self.device].device_params,
keepalive=True,
allow_agent=CONF[self.device].allow_agent,
look_for_keys=CONF[self.device].look_for_keys,
)
if CONF[self.device].key_filename:
args['key_filename'] = CONF[self.device].key_filename
if CONF[self.device].password:
args['password'] = CONF[self.device].password
return args
def get(self):
"""Get current configuration/staate from device"""
pass
@tenacity.retry(
reraise=True,
retry=tenacity.retry_if_exception_type(NetconfLockDenied),
wait=tenacity.wait_random_exponential(multiplier=1, min=2, max=10),
stop=tenacity.stop_after_attempt(5))
def get_lock_and_configure(self, client, source, config):
try:
with client.locked(source):
xml_config = common.config_to_xml(config)
LOG.info(
'Sending configuration to Netconf device %(dev)s: '
'%(conf)s',
{'dev': self.device, 'conf': xml_config})
if source == CANDIDATE:
# Revert the candidate configuration to the current
# running configuration. Any uncommitted changes are
# discarded.
client.discard_changes()
# Edit the candidate configuration
client.edit_config(target=source, config=xml_config)
# Validate the candidate configuration
if (':validate' in self.capabilities
or ':validate:1.1' in self.capabilities):
client.validate(source='candidate')
# Commit the candidate config, 30 seconds timeout
if (':confirmed-commit' in self.capabilities
or ':confirmed-commit:1.1' in self.capabilities):
client.commit(confirmed=True, timeout=str(30))
# Confirm the commit, if this commit does not
# succeed the device will revert the config after
# 30 seconds.
client.commit()
elif source == RUNNING:
client.edit_config(target=source, config=xml_config)
# TODO(hjensas): persist config.
except RPCError as err:
if err.tag == LOCK_DENIED_TAG:
# If the candidate config is modified, some vendors do not
# permit a new session to take a lock. This is per the RFC,
# in this case a lock-denied error where session-id == 0 is
# returned, because no session is actually holding the
# lock we can discard changes which will release the lock.
if (source == CANDIDATE
and self._get_lock_session_id(err.info) == '0'):
client.discard_changes()
raise NetconfLockDenied()
else:
LOG.error('Netconf XML: %s', common.config_to_xml(config))
raise err
def edit_config(self, config):
"""Edit configuration on the device
:param config: Configuration, or list of configurations
"""
# https://github.com/ncclient/ncclient/issues/525
_ignore_close_issue_525 = False
if not isinstance(config, list):
config = [config]
try:
with manager.connect(**self.get_client_args()) as client:
self.capabilities = self.process_capabilities(
client.server_capabilities)
if ':candidate' in self.capabilities:
self.get_lock_and_configure(client, CANDIDATE, config)
_ignore_close_issue_525 = True
elif ':writable-running' in self.capabilities:
self.get_lock_and_configure(client, RUNNING, config)
_ignore_close_issue_525 = True
except SessionCloseError as e:
if not _ignore_close_issue_525:
raise e
class NetconfOpenConfigDriver(base.BaseDeviceDriver):
SUPPORTED_BOND_MODES = set().union(constants.NON_SWITCH_BOND_MODES)
def __init__(self, device):
super().__init__(device)
self.client = NetconfOpenConfigClient(device)
self.device = device
def validate(self):
try:
LOG.info('Device %(device)s was loaded. Device capabilities: '
'%(caps)s', {'device': self.device,
'caps': self.client.get_capabilities()})
except exceptions.DeviceConnectionError as e:
raise exceptions.DriverValidationError(device=self.device, err=e)
def load_config(self):
"""Register driver specific configuration"""
CONF.register_opts(_DEVICE_OPTS, group=self.device)
CONF.register_opts(_NCCLIENT_OPTS, group=self.device)
def create_network(self, context):
"""Create network on device
:param context: NetworkContext instance describing the new
network.
"""
network = context.current
segmentation_id = network[provider_net.SEGMENTATION_ID]
net_instances = network_instance.NetworkInstances()
net_instance = net_instances.add(CONF[self.device].network_instance)
_vlan = net_instance.vlans.add(segmentation_id)
# Devices has limitations for vlan names, use the hex variant of the
# network UUID which is shorter.
_vlan.config.name = self._uuid_as_hex(network[api.ID])
_vlan.config.status = constants.VLAN_ACTIVE
self.client.edit_config(net_instances)
def update_network(self, context):
"""Update network on device
:param context: NetworkContext instance describing the new
network.
"""
network = context.current
network_orig = context.original
segmentation_id = network[provider_net.SEGMENTATION_ID]
segmentation_id_orig = network_orig[provider_net.SEGMENTATION_ID]
admin_state = network['admin_state_up']
admin_state_orig = network_orig['admin_state_up']
add_net_instances = network_instance.NetworkInstances()
add_net_instance = add_net_instances.add(
CONF[self.device].network_instance)
del_net_instances = None
need_update = False
if segmentation_id:
_vlan = add_net_instance.vlans.add(segmentation_id)
# Devices has limitations for vlan names, use the hex variant of
# the network UUID which is shorter.
_vlan.config.name = self._uuid_as_hex(network[api.ID])
if network['admin_state_up']:
_vlan.config.status = constants.VLAN_ACTIVE
else:
_vlan.config.status = constants.VLAN_SUSPENDED
if admin_state != admin_state_orig:
need_update = True
if segmentation_id_orig and segmentation_id != segmentation_id_orig:
need_update = True
del_net_instances = network_instance.NetworkInstances()
del_net_instance = del_net_instances.add(
CONF[self.device].network_instance)
vlan_orig = del_net_instance.vlans.remove(segmentation_id_orig)
# Not all devices support removing a VLAN, in that case lets
# make sure the VLAN is suspended and set a name to indicate the
# network was deleted.
vlan_orig.config.name = f'neutron-DELETED-{segmentation_id_orig}'
vlan_orig.config.status = constants.VLAN_SUSPENDED
if not need_update:
return
# If the segmentation ID changed, delete the old VLAN first to avoid
# vlan name conflict.
if del_net_instances is not None:
self.client.edit_config(del_net_instances)
self.client.edit_config(add_net_instances)
def delete_network(self, context):
"""Delete network on device
:param context: NetworkContext instance describing the new
network.
"""
network = context.current
segmentation_id = network[provider_net.SEGMENTATION_ID]
net_instances = network_instance.NetworkInstances()
net_instance = net_instances.add(CONF[self.device].network_instance)
_vlan = net_instance.vlans.remove(segmentation_id)
# Not all devices support removing a VLAN, in that case lets
# make sure the VLAN is suspended and set a name to indicate the
# network was deleted.
_vlan.config.name = f'neutron-DELETED-{segmentation_id}'
_vlan.config.status = constants.VLAN_SUSPENDED
self.client.edit_config(net_instances)
def create_port(self, context, segment, links):
"""Create/Configure port on device
:param context: PortContext instance describing the new
state of the port, as well as the original state prior
to the update_port call.
:param segment: segment dictionary describing segment to bind
:param links: Local link information filtered for the device.
"""
port = context.current
binding_profile = port[portbindings.PROFILE]
local_link_information = binding_profile.get(
constants.LOCAL_LINK_INFO)
local_group_information = binding_profile.get(
constants.LOCAL_GROUP_INFO, {})
bond_mode = local_group_information.get('bond_mode')
if segment[api.NETWORK_TYPE] != n_const.TYPE_VLAN:
switched_vlan = None
else:
switched_vlan = vlan.VlanSwitchedVlan()
switched_vlan.config.operation = nc_op.REPLACE
switched_vlan.config.interface_mode = constants.VLAN_MODE_ACCESS
switched_vlan.config.access_vlan = segment[api.SEGMENTATION_ID]
if not bond_mode or bond_mode in constants.NON_SWITCH_BOND_MODES:
self.create_non_bond(context, switched_vlan, links)
elif bond_mode in constants.LACP_BOND_MODES:
if len(local_link_information) == len(links):
self.create_lacp_aggregate(context, switched_vlan, links)
else:
# Some links is on a different device,
# MLAG aggregate must be pre-configured.
self.create_pre_conf_aggregate(context, switched_vlan, links)
elif bond_mode in constants.PRE_CONF_ONLY_BOND_MODES:
self.create_pre_conf_aggregate(context, switched_vlan, links)
def create_non_bond(self, context, switched_vlan, links):
"""Create/Configure ports on device
:param context: PortContext instance describing the new
state of the port, as well as the original state prior
to the update_port call.
:param switched_vlan: switched_vlan OpenConfig object
:param links: Local link information filtered for the device.
"""
port = context.current
network = context.network.current
ifaces = interfaces.Interfaces()
for link in links:
link_port_id = link.get(constants.PORT_ID)
link_port_id = self._port_id_resub(link_port_id)
iface = ifaces.add(link_port_id)
iface.config.enabled = port['admin_state_up']
if 'port_mtu' not in CONF[self.device].disabled_properties:
iface.config.mtu = network[api.MTU]
iface.config.description = f'neutron-{port[api.ID]}'
if switched_vlan is not None:
iface.ethernet.switched_vlan = switched_vlan
else:
del iface.ethernet
self.client.edit_config(ifaces)
def create_lacp_aggregate(self, context, switched_vlan, links):
"""Create/Configure LACP aggregate on device
:param context: PortContext instance describing the new
state of the port, as well as the original state prior
to the update_port call.
:param switched_vlan: switched_vlan OpenConfig object
:param links: Local link information filtered for the device.
"""
pass
def create_pre_conf_aggregate(self, context, switched_vlan, links):
"""Create/Configure pre-configured aggregate on device
:param context: PortContext instance describing the new
state of the port, as well as the original state prior
to the update_port call.
:param switched_vlan: switched_vlan OpenConfig object
:param links: Local link information filtered for the device.
"""
pass
def update_port(self, context, links):
"""Update port on device
:param context: PortContext instance describing the new
state of the port, as well as the original state prior
to the update_port call.
:param links: Local link information filtered for the device.
"""
if (not self.admin_state_changed(context)
and not self.network_mtu_changed(context)):
return
port = context.current
binding_profile = port[portbindings.PROFILE]
local_link_information = binding_profile.get(
constants.LOCAL_LINK_INFO)
local_group_information = binding_profile.get(
constants.LOCAL_GROUP_INFO, {})
bond_mode = local_group_information.get('bond_mode')
if not bond_mode or bond_mode in constants.NON_SWITCH_BOND_MODES:
self.update_non_bond(context, links)
elif bond_mode in constants.LACP_BOND_MODES:
if len(local_link_information) == len(links):
self.update_lacp_aggregate(context, links)
else:
# Some links is on a different device,
# MLAG aggregate must be pre-configured.
self.update_pre_conf_aggregate(context, links)
elif bond_mode in constants.PRE_CONF_ONLY_BOND_MODES:
self.update_pre_conf_aggregate(context, links)
def update_non_bond(self, context, links):
"""Update port on device
:param context: PortContext instance describing the new
state of the port, as well as the original state prior
to the update_port call.
:param links: Local link information filtered for the device.
"""
network = context.network.current
ifaces = interfaces.Interfaces()
port = context.current
for link in links:
link_port_id = link.get(constants.PORT_ID)
link_port_id = self._port_id_resub(link_port_id)
iface = ifaces.add(link_port_id)
iface.config.enabled = port['admin_state_up']
if 'port_mtu' not in CONF[self.device].disabled_properties:
iface.config.mtu = network[api.MTU]
del iface.ethernet
self.client.edit_config(ifaces)
def update_lacp_aggregate(self, context, links):
"""Update LACP aggregate on device
:param context: PortContext instance describing the new
state of the port, as well as the original state prior
to the update_port call.
:param links: Local link information filtered for the device.
"""
pass
def update_pre_conf_aggregate(self, context, links):
"""Update pre-configured aggregate on device
:param context: PortContext instance describing the new
state of the port, as well as the original state prior
to the update_port call.
:param links: Local link information filtered for the device.
"""
pass
def delete_port(self, context, links, current=True):
"""Delete/Un-configure port on device
:param context: PortContext instance describing the new
state of the port, as well as the original state prior
to the update_port call.
:param links: Local link information filtered for the device.
:param current: Boolean, when true use context.current, when
false use context.original
"""
port = context.current if current else context.original
binding_profile = port[portbindings.PROFILE]
local_link_information = binding_profile.get(
constants.LOCAL_LINK_INFO)
local_group_information = binding_profile.get(
constants.LOCAL_GROUP_INFO, {})
bond_mode = local_group_information.get('bond_mode')
if not bond_mode or bond_mode in constants.NON_SWITCH_BOND_MODES:
self.delete_non_bond(context, links)
elif bond_mode in constants.LACP_BOND_MODES:
if len(local_link_information) == len(links):
self.delete_lacp_aggregate(context, links)
else:
# Some links is on a different device,
# MLAG aggregate must be pre-configured.
self.delete_pre_conf_aggregate(links)
elif bond_mode in constants.PRE_CONF_ONLY_BOND_MODES:
self.delete_pre_conf_aggregate(links)
def delete_non_bond(self, context, links):
"""Delete/Un-configure port on device
:param context: PortContext instance describing the new
state of the port, as well as the original state prior
to the update_port call.
:param links: Local link information filtered for the device.
"""
network = context.network.current
ifaces = interfaces.Interfaces()
for link in links:
link_port_id = link.get(constants.PORT_ID)
link_port_id = self._port_id_resub(link_port_id)
iface = ifaces.add(link_port_id)
iface.config.operation = nc_op.REMOVE
# Not possible mark entire config for removal due to name leaf-ref
# Set dummy values for properties to remove
iface.config.description = ''
iface.config.enabled = False
if 'port_mtu' not in CONF[self.device].disabled_properties:
iface.config.mtu = 0
if network[provider_net.NETWORK_TYPE] == n_const.TYPE_VLAN:
iface.ethernet.switched_vlan.config.operation = nc_op.REMOVE
else:
del iface.ethernet
self.client.edit_config(ifaces)
def delete_lacp_aggregate(self, context, links):
"""Delete/Un-configure LACP aggregate on device
:param context: PortContext instance describing the new
state of the port, as well as the original state prior
to the update_port call.
:param links: Local link information filtered for the device.
"""
pass
def delete_pre_conf_aggregate(self, links):
"""Delete/Un-configure pre-configured aggregate on device
:param links: Local link information filtered for the device.
"""
pass
@staticmethod
def _uuid_as_hex(_uuid):
return uuid.UUID(_uuid).hex
def _port_id_resub(self, link_port_id):
"""Replace pattern
Regular expression pattern and replacement string.
Some devices don not use the port description from
LLDP in Netconf configuration. If the regular expression
pattern and replacement string is set the port_id will
be modified before passing configuration to the device.
Replacing the leftmost non-overlapping occurrences of pattern
in string by the replacement repl.
"""
if CONF[self.device].port_id_re_sub:
pattern = CONF[self.device].port_id_re_sub.get('pattern')
repl = CONF[self.device].port_id_re_sub.get('repl')
link_port_id = re.sub(pattern, repl, link_port_id)
return link_port_id
@staticmethod
def admin_state_changed(context):
port = context.current
port_orig = context.original
return (port and port_orig
and port['admin_state_up'] != port_orig['admin_state_up'])
@staticmethod
def network_mtu_changed(context):
network = context.network.current
network_orig = context.network.original
return (network and network_orig
and network[api.MTU] != network_orig[api.MTU])

View File

@ -0,0 +1,500 @@
# 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 xml.etree import ElementTree
from ncclient import manager
from neutron.plugins.ml2 import driver_context
from neutron_lib import constants as n_const
from neutron_lib.plugins.ml2 import api
from oslo_config import fixture as config_fixture
from oslo_utils import uuidutils
from networking_baremetal import config
from networking_baremetal import constants
from networking_baremetal.constants import NetconfEditConfigOperation as nc_op
from networking_baremetal.drivers.netconf import openconfig
from networking_baremetal.tests import base
from networking_baremetal.tests.unit.plugins.ml2 import utils as ml2_utils
class TestNetconfOpenConfigClient(base.TestCase):
def setUp(self):
super(TestNetconfOpenConfigClient, self).setUp()
self.device = 'foo'
self.conf = self.useFixture(config_fixture.Config())
self.conf.register_opts(config._opts + config._device_opts,
group='foo')
self.conf.register_opts((openconfig._DEVICE_OPTS
+ openconfig._NCCLIENT_OPTS), group='foo')
self.conf.config(enabled_devices=['foo'],
group='networking_baremetal')
self.conf.config(driver='test-driver',
switch_id='aa:bb:cc:dd:ee:ff',
switch_info='foo',
physical_networks=['fake_physical_network'],
device_params={'name': 'default'},
host='foo.example.com',
key_filename='/test/test_key_file',
username='foo_user',
group='foo')
self.client = openconfig.NetconfOpenConfigClient(self.device)
def test_get_lock_session_id(self):
err_info = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<error-info xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">'
'<session-id>{}</session-id>'
'</error-info>')
self.assertEqual('0', self.client._get_lock_session_id(
err_info.format(0)))
self.assertEqual('abc-123', self.client._get_lock_session_id(
err_info.format('abc-123')))
def test_get_client_args(self):
self.assertEqual(
{'device_params': {'name': 'default'},
'host': 'foo.example.com',
'hostkey_verify': True,
'keepalive': True,
'key_filename': '/test/test_key_file',
'port': 830,
'username': 'foo_user',
'allow_agent': True,
'look_for_keys': True}, self.client.get_client_args())
@mock.patch.object(manager, 'connect', autospec=True)
def test_get_capabilities(self, mock_manager):
fake_caps = set(constants.IANA_NETCONF_CAPABILITIES.values())
fake_caps.add('http://openconfig.net/yang/'
'network-instance?'
'module=openconfig-network-instance&'
'revision=2021-07-22')
fake_caps.add('http://openconfig.net/yang/'
'interfaces?'
'module=openconfig-interfaces&'
'revision=2021-04-06')
ncclient_mock = mock.Mock()
ncclient_mock.server_capabilities = fake_caps
mock_manager.return_value.__enter__.return_value = ncclient_mock
self.assertEqual({
':base:1.0', ':base:1.1', ':candidate', ':confirmed-commit',
':confirmed-commit:1.1', ':rollback-on-error', ':startup',
':validate', ':validate:1.1', ':writable-running',
'openconfig-network-instance', 'openconfig-interfaces'},
self.client.get_capabilities())
@mock.patch.object(manager, 'connect', autospec=True)
@mock.patch.object(openconfig.NetconfOpenConfigClient,
'get_lock_and_configure', autospec=True)
def test_edit_config_writable_running(self, mock_lock_config,
mock_manager):
fake_config = mock.Mock()
fake_config.to_xml_element.return_value = ElementTree.Element('fake')
ncclient_mock = mock.Mock()
fake_caps = {constants.IANA_NETCONF_CAPABILITIES[':writable-running']}
ncclient_mock.server_capabilities = fake_caps
mock_manager.return_value.__enter__.return_value = ncclient_mock
self.client.edit_config(fake_config)
mock_lock_config.assert_called_once_with(self.client, ncclient_mock,
openconfig.RUNNING,
[fake_config])
@mock.patch.object(manager, 'connect', autospec=True)
@mock.patch.object(openconfig.NetconfOpenConfigClient,
'get_lock_and_configure', autospec=True)
def test_edit_config_candidate(self, mock_lock_config, mock_manager):
fake_config = mock.Mock()
fake_config.to_xml_element.return_value = ElementTree.Element('fake')
ncclient_mock = mock.Mock()
fake_caps = {constants.IANA_NETCONF_CAPABILITIES[':candidate']}
ncclient_mock.server_capabilities = fake_caps
mock_manager.return_value.__enter__.return_value = ncclient_mock
self.client.edit_config(fake_config)
mock_lock_config.assert_called_once_with(self.client, ncclient_mock,
openconfig.CANDIDATE,
[fake_config])
def test_get_lock_and_configure_confirmed_commit(self):
self.client.capabilities = {':candidate', ':writable-running',
':confirmed-commit'}
fake_config = mock.Mock()
fake_config.to_xml_element.return_value = ElementTree.Element('fake')
mock_client = mock.MagicMock()
self.client.get_lock_and_configure(mock_client, openconfig.CANDIDATE,
[fake_config])
mock_client.locked.assert_called_with(openconfig.CANDIDATE)
mock_client.discard_changes.assert_called_once()
mock_client.edit_config.assert_called_with(
target=openconfig.CANDIDATE,
config='<config><fake /></config>')
mock_client.validate.assert_not_called()
mock_client.commit.assert_has_calls([
mock.call(confirmed=True, timeout=str(30)), mock.call()])
def test_get_lock_and_configure_validate(self):
self.client.capabilities = {':candidate', ':writable-running',
':validate'}
fake_config = mock.Mock()
fake_config.to_xml_element.return_value = ElementTree.Element('fake')
mock_client = mock.MagicMock()
self.client.get_lock_and_configure(mock_client, openconfig.CANDIDATE,
[fake_config])
mock_client.locked.assert_called_with(openconfig.CANDIDATE)
mock_client.discard_changes.assert_called_once()
mock_client.edit_config.assert_called_with(
target=openconfig.CANDIDATE,
config='<config><fake /></config>')
mock_client.validate.assert_called_once_with(
source=openconfig.CANDIDATE)
mock_client.commit.assert_called_once_with()
def test_get_lock_and_configure_writeable_running(self):
self.client.capabilities = {':writable-running'}
fake_config = mock.Mock()
fake_config.to_xml_element.return_value = ElementTree.Element('fake')
mock_client = mock.MagicMock()
self.client.get_lock_and_configure(mock_client, openconfig.RUNNING,
[fake_config])
mock_client.locked.assert_called_with(openconfig.RUNNING)
mock_client.discard_changes.assert_not_called()
mock_client.validate.assert_not_called()
mock_client.commit.assert_not_called()
mock_client.edit_config.assert_called_with(
target=openconfig.RUNNING,
config='<config><fake /></config>')
class TestNetconfOpenConfigDriver(base.TestCase):
def setUp(self):
super(TestNetconfOpenConfigDriver, self).setUp()
self.device = 'foo'
self.conf = self.useFixture(config_fixture.Config())
self.conf.register_opts(config._opts + config._device_opts,
group='foo')
self.conf.register_opts((openconfig._DEVICE_OPTS
+ openconfig._NCCLIENT_OPTS), group='foo')
self.conf.config(enabled_devices=['foo'],
group='networking_baremetal')
self.conf.config(driver='test-driver',
switch_id='aa:bb:cc:dd:ee:ff',
switch_info='foo',
physical_networks=['fake_physical_network'],
device_params={'name': 'default'},
host='foo.example.com',
key_filename='/test/test_key_file',
username='foo_user',
group='foo')
mock_client = mock.patch.object(openconfig, 'NetconfOpenConfigClient',
autospec=True)
self.mock_client = mock_client.start()
self.addCleanup(mock_client.stop)
self.driver = openconfig.NetconfOpenConfigDriver(self.device)
self.mock_client.assert_called_once_with('foo')
self.mock_client.reset_mock()
def test_validate(self):
self.driver.validate()
self.driver.client.get_capabilities.assert_called_once_with()
@mock.patch.object(openconfig, 'CONF', autospec=True)
def test_load_config(self, mock_conf):
self.driver.load_config()
mock_conf.register_opts.assert_has_calls(
[mock.call(openconfig._DEVICE_OPTS, group=self.driver.device),
mock.call(openconfig._NCCLIENT_OPTS, group=self.driver.device)])
def test_create_network(self):
m_nc = mock.create_autospec(driver_context.NetworkContext)
m_nc.current = ml2_utils.get_test_network()
self.driver.create_network(m_nc)
net_instances = self.driver.client.edit_config.call_args[0][0]
for net_instance in net_instances:
self.assertEqual(net_instance.name, 'default')
vlans = net_instance.vlans
for vlan in vlans:
self.assertEqual(vlan.config.operation, nc_op.MERGE.value)
self.assertEqual(vlan.config.name,
self.driver._uuid_as_hex(m_nc.current['id']))
self.assertEqual(vlan.config.status, constants.VLAN_ACTIVE)
def test_update_network_no_changes(self):
tenant_id = uuidutils.generate_uuid()
network_id = uuidutils.generate_uuid()
project_id = uuidutils.generate_uuid()
m_nc = mock.create_autospec(driver_context.NetworkContext)
m_nc.current = ml2_utils.get_test_network(
id=network_id, tenant_id=tenant_id, project_id=project_id,
network_type=n_const.TYPE_VLAN)
m_nc.original = ml2_utils.get_test_network(
id=network_id, tenant_id=tenant_id, project_id=project_id,
network_type=n_const.TYPE_VLAN)
self.assertEqual(m_nc.current, m_nc.original)
self.driver.update_network(m_nc)
self.driver.client.edit_config.assert_not_called()
def test_update_network_change_vlan_id(self):
tenant_id = uuidutils.generate_uuid()
network_id = uuidutils.generate_uuid()
project_id = uuidutils.generate_uuid()
m_nc = mock.create_autospec(driver_context.NetworkContext)
m_nc.current = ml2_utils.get_test_network(
id=network_id, tenant_id=tenant_id, project_id=project_id,
network_type=n_const.TYPE_VLAN, segmentation_id=10)
m_nc.original = ml2_utils.get_test_network(
id=network_id, tenant_id=tenant_id, project_id=project_id,
network_type=n_const.TYPE_VLAN, segmentation_id=20)
self.driver.update_network(m_nc)
call_args_list = self.driver.client.edit_config.call_args_list
del_net_instances = call_args_list[0][0][0]
add_net_instances = call_args_list[1][0][0]
self.driver.client.edit_config.assert_has_calls(
[mock.call(del_net_instances), mock.call(add_net_instances)])
for net_instance in del_net_instances:
self.assertEqual(net_instance.name, 'default')
for vlan in net_instance.vlans:
self.assertEqual(vlan.operation, nc_op.REMOVE.value)
self.assertEqual(vlan.vlan_id, 20)
self.assertEqual(vlan.config.status, constants.VLAN_SUSPENDED)
self.assertEqual(vlan.config.name, 'neutron-DELETED-20')
for net_instance in add_net_instances:
self.assertEqual(net_instance.name, 'default')
for vlan in net_instance.vlans:
self.assertEqual(vlan.operation, nc_op.MERGE.value)
self.assertEqual(vlan.config.name,
self.driver._uuid_as_hex(network_id))
self.assertEqual(vlan.vlan_id, 10)
def test_update_network_change_admin_state(self):
tenant_id = uuidutils.generate_uuid()
network_id = uuidutils.generate_uuid()
project_id = uuidutils.generate_uuid()
m_nc = mock.create_autospec(driver_context.NetworkContext)
m_nc.current = ml2_utils.get_test_network(
id=network_id, tenant_id=tenant_id, project_id=project_id,
network_type=n_const.TYPE_VLAN, segmentation_id=10,
admin_state_up=False)
m_nc.original = ml2_utils.get_test_network(
id=network_id, tenant_id=tenant_id, project_id=project_id,
network_type=n_const.TYPE_VLAN, segmentation_id=10,
admin_state_up=True)
self.driver.update_network(m_nc)
call_args_list = self.driver.client.edit_config.call_args_list
add_net_instances = call_args_list[0][0][0]
self.driver.client.edit_config.assert_called_once_with(
add_net_instances)
for net_instance in add_net_instances:
self.assertEqual(net_instance.name, 'default')
for vlan in net_instance.vlans:
self.assertEqual(vlan.operation, nc_op.MERGE.value)
self.assertEqual(vlan.config.status, constants.VLAN_SUSPENDED)
self.assertEqual(vlan.config.name,
self.driver._uuid_as_hex(network_id))
self.assertEqual(vlan.vlan_id, 10)
def test_delete_network(self):
m_nc = mock.create_autospec(driver_context.NetworkContext)
m_nc.current = ml2_utils.get_test_network(
network_type=n_const.TYPE_VLAN, segmentation_id=15)
self.driver.delete_network(m_nc)
self.driver.client.edit_config.assert_called_once()
call_args_list = self.driver.client.edit_config.call_args_list
net_instances = call_args_list[0][0][0]
for net_instance in net_instances:
self.assertEqual(net_instance.name, 'default')
for vlan in net_instance.vlans:
self.assertEqual(vlan.operation, nc_op.REMOVE.value)
self.assertEqual(vlan.vlan_id, 15)
self.assertEqual(vlan.config.status, constants.VLAN_SUSPENDED)
self.assertEqual(vlan.config.name, 'neutron-DELETED-15')
def test_create_port_vlan(self):
tenant_id = uuidutils.generate_uuid()
network_id = uuidutils.generate_uuid()
project_id = uuidutils.generate_uuid()
m_nc = mock.create_autospec(driver_context.NetworkContext)
m_pc = mock.create_autospec(driver_context.PortContext)
m_nc.current = ml2_utils.get_test_network(
id=network_id, tenant_id=tenant_id, project_id=project_id,
network_type=n_const.TYPE_VLAN, segmentation_id=15)
m_pc.current = ml2_utils.get_test_port(
network_id=network_id, tenant_id=tenant_id, project_id=project_id)
m_pc.network = m_nc
segment = {
api.ID: uuidutils.generate_uuid(),
api.PHYSICAL_NETWORK:
m_nc.current['provider:physical_network'],
api.NETWORK_TYPE: m_nc.current['provider:network_type'],
api.SEGMENTATION_ID: m_nc.current['provider:segmentation_id']}
links = m_pc.current['binding:profile'][constants.LOCAL_LINK_INFO]
self.driver.create_port(m_pc, segment, links)
self.driver.client.edit_config.assert_called_once()
call_args_list = self.driver.client.edit_config.call_args_list
ifaces = call_args_list[0][0][0]
for iface in ifaces:
self.assertEqual(iface.name, links[0]['port_id'])
self.assertEqual(iface.config.enabled,
m_pc.current['admin_state_up'])
self.assertEqual(iface.config.mtu, m_nc.current[api.MTU])
self.assertEqual(iface.config.description,
f'neutron-{m_pc.current[api.ID]}')
self.assertEqual(iface.ethernet.switched_vlan.config.operation,
nc_op.REPLACE.value)
self.assertEqual(
iface.ethernet.switched_vlan.config.interface_mode,
constants.VLAN_MODE_ACCESS)
self.assertEqual(
iface.ethernet.switched_vlan.config.access_vlan,
segment[api.SEGMENTATION_ID])
def test_create_port_flat(self):
tenant_id = uuidutils.generate_uuid()
network_id = uuidutils.generate_uuid()
project_id = uuidutils.generate_uuid()
m_nc = mock.create_autospec(driver_context.NetworkContext)
m_pc = mock.create_autospec(driver_context.PortContext)
m_nc.current = ml2_utils.get_test_network(
id=network_id, tenant_id=tenant_id, project_id=project_id,
network_type=n_const.TYPE_FLAT)
m_pc.current = ml2_utils.get_test_port(
network_id=network_id, tenant_id=tenant_id, project_id=project_id)
m_pc.network = m_nc
segment = {
api.ID: uuidutils.generate_uuid(),
api.PHYSICAL_NETWORK:
m_nc.current['provider:physical_network'],
api.NETWORK_TYPE: m_nc.current['provider:network_type']}
links = m_pc.current['binding:profile'][constants.LOCAL_LINK_INFO]
self.driver.create_port(m_pc, segment, links)
self.driver.client.edit_config.assert_called_once()
call_args_list = self.driver.client.edit_config.call_args_list
ifaces = call_args_list[0][0][0]
for iface in ifaces:
self.assertEqual(iface.name, links[0]['port_id'])
self.assertEqual(iface.config.enabled,
m_pc.current['admin_state_up'])
self.assertEqual(iface.config.mtu, m_nc.current[api.MTU])
self.assertEqual(iface.config.description,
f'neutron-{m_pc.current[api.ID]}')
self.assertIsNone(iface.ethernet)
def test_update_port(self):
tenant_id = uuidutils.generate_uuid()
network_id = uuidutils.generate_uuid()
project_id = uuidutils.generate_uuid()
m_nc = mock.create_autospec(driver_context.NetworkContext)
m_pc = mock.create_autospec(driver_context.PortContext)
m_nc.current = ml2_utils.get_test_network(
id=network_id, tenant_id=tenant_id, project_id=project_id,
network_type=n_const.TYPE_VLAN, segmentation_id=15,
mtu=9000)
m_nc.original = ml2_utils.get_test_network(
id=network_id, tenant_id=tenant_id, project_id=project_id,
network_type=n_const.TYPE_VLAN, segmentation_id=15,
mtu=1500)
m_pc.current = ml2_utils.get_test_port(
network_id=network_id, tenant_id=tenant_id, project_id=project_id,
admin_state_up=False)
m_pc.original = ml2_utils.get_test_port(
network_id=network_id, tenant_id=tenant_id, project_id=project_id,
admin_state_up=True)
m_pc.network = m_nc
links = m_pc.current['binding:profile'][constants.LOCAL_LINK_INFO]
self.driver.update_port(m_pc, links)
self.driver.client.edit_config.assert_called_once()
call_args_list = self.driver.client.edit_config.call_args_list
ifaces = call_args_list[0][0][0]
for iface in ifaces:
self.assertEqual(iface.name, links[0]['port_id'])
self.assertEqual(iface.config.enabled,
m_pc.current['admin_state_up'])
self.assertEqual(iface.config.mtu, m_nc.current[api.MTU])
self.assertIsNone(iface.ethernet)
def test_update_port_no_supported_attrib_changed(self):
tenant_id = uuidutils.generate_uuid()
network_id = uuidutils.generate_uuid()
project_id = uuidutils.generate_uuid()
m_nc = mock.create_autospec(driver_context.NetworkContext)
m_pc = mock.create_autospec(driver_context.PortContext)
m_nc.current = ml2_utils.get_test_network(
id=network_id, tenant_id=tenant_id, project_id=project_id,
network_type=n_const.TYPE_VLAN, segmentation_id=15)
m_nc.original = ml2_utils.get_test_network(
id=network_id, tenant_id=tenant_id, project_id=project_id,
network_type=n_const.TYPE_VLAN, segmentation_id=15)
m_pc.current = ml2_utils.get_test_port(
network_id=network_id, tenant_id=tenant_id, project_id=project_id,
name='current')
m_pc.original = ml2_utils.get_test_port(
network_id=network_id, tenant_id=tenant_id, project_id=project_id,
name='original')
m_pc.network = m_nc
links = m_pc.current['binding:profile'][constants.LOCAL_LINK_INFO]
self.driver.update_port(m_pc, links)
self.driver.client.edit_config.assert_not_called()
def test_delete_port_vlan(self):
tenant_id = uuidutils.generate_uuid()
network_id = uuidutils.generate_uuid()
project_id = uuidutils.generate_uuid()
m_nc = mock.create_autospec(driver_context.NetworkContext)
m_pc = mock.create_autospec(driver_context.PortContext)
m_nc.current = ml2_utils.get_test_network(
id=network_id, tenant_id=tenant_id, project_id=project_id,
network_type=n_const.TYPE_VLAN, segmentation_id=15)
m_pc.current = ml2_utils.get_test_port(
network_id=network_id, tenant_id=tenant_id, project_id=project_id)
m_pc.network = m_nc
links = m_pc.current['binding:profile'][constants.LOCAL_LINK_INFO]
self.driver.delete_port(m_pc, links)
self.driver.client.edit_config.assert_called_once()
call_args_list = self.driver.client.edit_config.call_args_list
ifaces = call_args_list[0][0][0]
for iface in ifaces:
self.assertEqual(iface.name, links[0]['port_id'])
self.assertEqual(iface.config.operation, nc_op.REMOVE.value)
self.assertEqual(iface.config.description, '')
self.assertFalse(iface.config.enabled)
self.assertEqual(iface.config.mtu, 0)
self.assertEqual(iface.ethernet.switched_vlan.config.operation,
nc_op.REMOVE.value)
def test_delete_port_flat(self):
tenant_id = uuidutils.generate_uuid()
network_id = uuidutils.generate_uuid()
project_id = uuidutils.generate_uuid()
m_nc = mock.create_autospec(driver_context.NetworkContext)
m_pc = mock.create_autospec(driver_context.PortContext)
m_nc.current = ml2_utils.get_test_network(
id=network_id, tenant_id=tenant_id, project_id=project_id,
network_type=n_const.TYPE_FLAT)
m_pc.current = ml2_utils.get_test_port(
network_id=network_id, tenant_id=tenant_id, project_id=project_id)
m_pc.network = m_nc
links = m_pc.current['binding:profile'][constants.LOCAL_LINK_INFO]
self.driver.delete_port(m_pc, links)
self.driver.client.edit_config.assert_called_once()
call_args_list = self.driver.client.edit_config.call_args_list
ifaces = call_args_list[0][0][0]
for iface in ifaces:
self.assertEqual(iface.name, links[0]['port_id'])
self.assertEqual(iface.config.operation, nc_op.REMOVE.value)
self.assertEqual(iface.config.description, '')
self.assertFalse(iface.config.enabled)
self.assertEqual(iface.config.mtu, 0)
self.assertIsNone(iface.ethernet)

View File

@ -0,0 +1,8 @@
---
features:
- |
Added an `OpenConfig <http://openconfig.net>`__ based device driver
(driver name: ``netconf-openconfig``) using Network Configuration Protocol
(**NETCONF**). Implements network create, delete and update functionality
as well as port create, delete and update.

View File

@ -6,6 +6,7 @@
# of appearance. Changing the order has an impact on the overall integration
# process, which may cause wedges in the gate later.
ncclient>=0.6.9 # Apache-2.0
neutron-lib>=1.28.0 # Apache-2.0
oslo.config>=5.2.0 # Apache-2.0
oslo.i18n>=3.15.3 # Apache-2.0

View File

@ -36,6 +36,9 @@ console_scripts =
neutron.ml2.mechanism_drivers =
baremetal = networking_baremetal.plugins.ml2.baremetal_mech:BaremetalMechanismDriver
networking_baremetal.drivers =
netconf-openconfig = networking_baremetal.drivers.netconf.openconfig:NetconfOpenConfigDriver
[pbr]
autodoc_index_modules = True
api_doc_dir = contributor/api

View File

@ -8,3 +8,4 @@ python-subunit>=1.0.0 # Apache-2.0/BSD
testtools>=2.2.0 # MIT
stestr>=2.0.0 # Apache-2.0
testscenarios>=0.4 # Apache-2.0/BSD
ncclient>=0.6.9 # Apache-2.0

View File

@ -70,6 +70,9 @@ commands =
sphinx-build -a -E -W -d releasenotes/build/doctrees -b html releasenotes/source releasenotes/build/html
[testenv:genconfig]
deps =
-c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master}
-r{toxinidir}/test-requirements.txt
commands =
oslo-config-generator --config-file=tools/config/networking-baremetal-config-generator.conf