[OVN] Allow IP allocation with different segments for OVN service ports
OVN creates localport [1] for each network that has metadata and allocate IP address from subnet within this network that has DHCP enabled. The traffic from this port will never go outside the chassis. While using multiple segments with subnet linked to each segment OVN needs to create an allocation of IP address for each of those subnets [2] in order to generate data for OVN NBDB IPv4 DHCP Options. The change [3] started to validate that condition, while multiple IP addresses from different segments are tried to be allocated on one port. We can skip this for OVN Metadata port, because there is no reason to prevent those kind of allocation for OVN. For stable branches we decide if a port is distributed or not by looking for DEVICE_OWNER_DHCP device owner and `ovn` device_id, instead DEVICE_OWNER_DISTRIBUTED device owner. Conflicts: neutron/db/ipam_backend_mixin.py neutron/tests/unit/db/test_ipam_pluggable_backend.py [1] http://www.openvswitch.org/support/dist-docs/ovn-architecture.7.html [2]5f42488a9a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py (L2279)
[3] https://review.opendev.org/#/c/709444/ Change-Id: Ib51cde89ed873f48db4daebc27a0980da9cc0f19 Closes-Bug: 1871608 (cherry picked from commit8d1512afb0
)
This commit is contained in:
parent
df5b28c2e5
commit
ab32d7ae64
|
@ -27,7 +27,10 @@ A routed provider network enables a single provider network to represent
|
||||||
multiple layer-2 networks (broadcast domains) or segments and enables the
|
multiple layer-2 networks (broadcast domains) or segments and enables the
|
||||||
operator to present one network to users. However, the particular IP
|
operator to present one network to users. However, the particular IP
|
||||||
addresses available to an instance depend on the segment of the network
|
addresses available to an instance depend on the segment of the network
|
||||||
available on the particular compute node.
|
available on the particular compute node. Neutron port could be associated
|
||||||
|
with only one network segment, but there is an exception for OVN distributed
|
||||||
|
services like OVN Metadata.
|
||||||
|
|
||||||
|
|
||||||
Similar to conventional networking, layer-2 (switching) handles transit of
|
Similar to conventional networking, layer-2 (switching) handles transit of
|
||||||
traffic between ports on the same segment and layer-3 (routing) handles
|
traffic between ports on the same segment and layer-3 (routing) handles
|
||||||
|
|
|
@ -118,6 +118,9 @@ Routed provider networks offer performance at scale that is difficult to
|
||||||
achieve with a plain provider network at the expense of guaranteed layer-2
|
achieve with a plain provider network at the expense of guaranteed layer-2
|
||||||
connectivity.
|
connectivity.
|
||||||
|
|
||||||
|
Neutron port could be associated with only one network segment,
|
||||||
|
but there is an exception for OVN distributed services like OVN Metadata.
|
||||||
|
|
||||||
See :ref:`config-routed-provider-networks` for more information.
|
See :ref:`config-routed-provider-networks` for more information.
|
||||||
|
|
||||||
.. _intro-os-networking-selfservice:
|
.. _intro-os-networking-selfservice:
|
||||||
|
|
|
@ -81,6 +81,11 @@ class IpamBackendMixin(db_base_plugin_common.DbBasePluginCommon):
|
||||||
raise exc.InvalidAllocationPool(pool=ip_pool)
|
raise exc.InvalidAllocationPool(pool=ip_pool)
|
||||||
return ip_range_pools
|
return ip_range_pools
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_distributed_service(port):
|
||||||
|
return (port.get('device_owner') == const.DEVICE_OWNER_DHCP and
|
||||||
|
port.get('device_id').startswith('ovn'))
|
||||||
|
|
||||||
def delete_subnet(self, context, subnet_id):
|
def delete_subnet(self, context, subnet_id):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -646,7 +651,8 @@ class IpamBackendMixin(db_base_plugin_common.DbBasePluginCommon):
|
||||||
return fixed_ip_list
|
return fixed_ip_list
|
||||||
|
|
||||||
def _ipam_get_subnets(self, context, network_id, host, service_type=None,
|
def _ipam_get_subnets(self, context, network_id, host, service_type=None,
|
||||||
fixed_configured=False, fixed_ips=None):
|
fixed_configured=False, fixed_ips=None,
|
||||||
|
distributed_service=False):
|
||||||
"""Return eligible subnets
|
"""Return eligible subnets
|
||||||
|
|
||||||
If no eligible subnets are found, determine why and potentially raise
|
If no eligible subnets are found, determine why and potentially raise
|
||||||
|
@ -654,7 +660,7 @@ class IpamBackendMixin(db_base_plugin_common.DbBasePluginCommon):
|
||||||
"""
|
"""
|
||||||
subnets = subnet_obj.Subnet.find_candidate_subnets(
|
subnets = subnet_obj.Subnet.find_candidate_subnets(
|
||||||
context, network_id, host, service_type, fixed_configured,
|
context, network_id, host, service_type, fixed_configured,
|
||||||
fixed_ips)
|
fixed_ips, distributed_service=distributed_service)
|
||||||
if subnets:
|
if subnets:
|
||||||
msg = ('This subnet is being modified by another concurrent '
|
msg = ('This subnet is being modified by another concurrent '
|
||||||
'operation')
|
'operation')
|
||||||
|
@ -722,7 +728,8 @@ class IpamBackendMixin(db_base_plugin_common.DbBasePluginCommon):
|
||||||
if old_ips and new_host_requested and not fixed_ips_requested:
|
if old_ips and new_host_requested and not fixed_ips_requested:
|
||||||
valid_subnets = self._ipam_get_subnets(
|
valid_subnets = self._ipam_get_subnets(
|
||||||
context, old_port['network_id'], host,
|
context, old_port['network_id'], host,
|
||||||
service_type=old_port.get('device_owner'))
|
service_type=old_port.get('device_owner'),
|
||||||
|
distributed_service=self._is_distributed_service(old_port))
|
||||||
valid_subnet_ids = {s['id'] for s in valid_subnets}
|
valid_subnet_ids = {s['id'] for s in valid_subnets}
|
||||||
for fixed_ip in old_ips:
|
for fixed_ip in old_ips:
|
||||||
if fixed_ip['subnet_id'] not in valid_subnet_ids:
|
if fixed_ip['subnet_id'] not in valid_subnet_ids:
|
||||||
|
|
|
@ -231,12 +231,14 @@ class IpamPluggableBackend(ipam_backend_mixin.IpamBackendMixin):
|
||||||
p = port['port']
|
p = port['port']
|
||||||
fixed_configured = p['fixed_ips'] is not constants.ATTR_NOT_SPECIFIED
|
fixed_configured = p['fixed_ips'] is not constants.ATTR_NOT_SPECIFIED
|
||||||
fixed_ips = p['fixed_ips'] if fixed_configured else []
|
fixed_ips = p['fixed_ips'] if fixed_configured else []
|
||||||
subnets = self._ipam_get_subnets(context,
|
subnets = self._ipam_get_subnets(
|
||||||
network_id=p['network_id'],
|
context,
|
||||||
host=p.get(portbindings.HOST_ID),
|
network_id=p['network_id'],
|
||||||
service_type=p.get('device_owner'),
|
host=p.get(portbindings.HOST_ID),
|
||||||
fixed_configured=fixed_configured,
|
service_type=p.get('device_owner'),
|
||||||
fixed_ips=fixed_ips)
|
fixed_configured=fixed_configured,
|
||||||
|
fixed_ips=fixed_ips,
|
||||||
|
distributed_service=self._is_distributed_service(p))
|
||||||
|
|
||||||
v4, v6_stateful, v6_stateless = self._classify_subnets(
|
v4, v6_stateful, v6_stateless = self._classify_subnets(
|
||||||
context, subnets)
|
context, subnets)
|
||||||
|
@ -348,7 +350,8 @@ class IpamPluggableBackend(ipam_backend_mixin.IpamBackendMixin):
|
||||||
subnets = self._ipam_get_subnets(
|
subnets = self._ipam_get_subnets(
|
||||||
context, network_id=port['network_id'], host=host,
|
context, network_id=port['network_id'], host=host,
|
||||||
service_type=port.get('device_owner'), fixed_configured=True,
|
service_type=port.get('device_owner'), fixed_configured=True,
|
||||||
fixed_ips=changes.add + changes.original)
|
fixed_ips=changes.add + changes.original,
|
||||||
|
distributed_service=self._is_distributed_service(port))
|
||||||
except ipam_exc.DeferIpam:
|
except ipam_exc.DeferIpam:
|
||||||
subnets = []
|
subnets = []
|
||||||
|
|
||||||
|
|
|
@ -321,7 +321,8 @@ class Subnet(base.NeutronDbObject):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_candidate_subnets(cls, context, network_id, host, service_type,
|
def find_candidate_subnets(cls, context, network_id, host, service_type,
|
||||||
fixed_configured, fixed_ips):
|
fixed_configured, fixed_ips,
|
||||||
|
distributed_service=False):
|
||||||
"""Find canditate subnets for the network, host, and service_type"""
|
"""Find canditate subnets for the network, host, and service_type"""
|
||||||
query = cls.query_subnets_on_network(context, network_id)
|
query = cls.query_subnets_on_network(context, network_id)
|
||||||
query = SubnetServiceType.query_filter_service_subnets(
|
query = SubnetServiceType.query_filter_service_subnets(
|
||||||
|
@ -335,7 +336,8 @@ class Subnet(base.NeutronDbObject):
|
||||||
# on port update with binding:host_id set. Allocation _cannot_
|
# on port update with binding:host_id set. Allocation _cannot_
|
||||||
# be deferred as requested fixed_ips would then be lost.
|
# be deferred as requested fixed_ips would then be lost.
|
||||||
return cls._query_filter_by_fixed_ips_segment(
|
return cls._query_filter_by_fixed_ips_segment(
|
||||||
query, fixed_ips).all()
|
query, fixed_ips,
|
||||||
|
allow_multiple_segments=distributed_service).all()
|
||||||
# If the host isn't known, we can't allocate on a routed network.
|
# If the host isn't known, we can't allocate on a routed network.
|
||||||
# So, exclude any subnets attached to segments.
|
# So, exclude any subnets attached to segments.
|
||||||
return cls._query_exclude_subnets_on_segments(query).all()
|
return cls._query_exclude_subnets_on_segments(query).all()
|
||||||
|
@ -357,7 +359,8 @@ class Subnet(base.NeutronDbObject):
|
||||||
return [subnet for subnet, _mapping in results]
|
return [subnet for subnet, _mapping in results]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _query_filter_by_fixed_ips_segment(cls, query, fixed_ips):
|
def _query_filter_by_fixed_ips_segment(cls, query, fixed_ips,
|
||||||
|
allow_multiple_segments=False):
|
||||||
"""Excludes subnets not on the same segment as fixed_ips
|
"""Excludes subnets not on the same segment as fixed_ips
|
||||||
|
|
||||||
:raises: FixedIpsSubnetsNotOnSameSegment
|
:raises: FixedIpsSubnetsNotOnSameSegment
|
||||||
|
@ -390,9 +393,12 @@ class Subnet(base.NeutronDbObject):
|
||||||
if subnet and subnet.segment_id not in segment_ids:
|
if subnet and subnet.segment_id not in segment_ids:
|
||||||
segment_ids.append(subnet.segment_id)
|
segment_ids.append(subnet.segment_id)
|
||||||
|
|
||||||
if 1 < len(segment_ids):
|
if 1 < len(segment_ids) and not allow_multiple_segments:
|
||||||
raise segment_exc.FixedIpsSubnetsNotOnSameSegment()
|
raise segment_exc.FixedIpsSubnetsNotOnSameSegment()
|
||||||
|
|
||||||
|
if allow_multiple_segments:
|
||||||
|
return query
|
||||||
|
|
||||||
segment_id = None if not segment_ids else segment_ids[0]
|
segment_id = None if not segment_ids else segment_ids[0]
|
||||||
return query.filter(cls.db_model.segment_id == segment_id)
|
return query.filter(cls.db_model.segment_id == segment_id)
|
||||||
|
|
||||||
|
|
|
@ -86,6 +86,18 @@ class TestIpamBackendMixin(base.BaseTestCase):
|
||||||
self.mixin._get_subnet_object = mock.Mock(
|
self.mixin._get_subnet_object = mock.Mock(
|
||||||
side_effect=_get_subnet_object)
|
side_effect=_get_subnet_object)
|
||||||
|
|
||||||
|
def test__is_distributed_service(self):
|
||||||
|
port = {'device_owner':
|
||||||
|
'%snova' % constants.DEVICE_OWNER_COMPUTE_PREFIX,
|
||||||
|
'device_id': uuidutils.generate_uuid()}
|
||||||
|
self.assertFalse(self.mixin._is_distributed_service(port))
|
||||||
|
port = {'device_owner': constants.DEVICE_OWNER_DHCP,
|
||||||
|
'device_id': uuidutils.generate_uuid()}
|
||||||
|
self.assertFalse(self.mixin._is_distributed_service(port))
|
||||||
|
port = {'device_owner': constants.DEVICE_OWNER_DHCP,
|
||||||
|
'device_id': 'ovnmeta-%s' % uuidutils.generate_uuid()}
|
||||||
|
self.assertTrue(self.mixin._is_distributed_service(port))
|
||||||
|
|
||||||
def _test_get_changed_ips_for_port(self, expected, original_ips,
|
def _test_get_changed_ips_for_port(self, expected, original_ips,
|
||||||
new_ips, owner):
|
new_ips, owner):
|
||||||
change = self.mixin._get_changed_ips_for_port(self.ctx,
|
change = self.mixin._get_changed_ips_for_port(self.ctx,
|
||||||
|
|
|
@ -692,12 +692,48 @@ class TestDbBasePluginIpam(test_db_base.NeutronDbPluginV2TestCase):
|
||||||
mocks['ipam']._ipam_get_subnets.assert_called_once_with(
|
mocks['ipam']._ipam_get_subnets.assert_called_once_with(
|
||||||
context, network_id=port_dict['network_id'], fixed_configured=True,
|
context, network_id=port_dict['network_id'], fixed_configured=True,
|
||||||
fixed_ips=[ip_dict], host=None,
|
fixed_ips=[ip_dict], host=None,
|
||||||
service_type=port_dict['device_owner'])
|
service_type=port_dict['device_owner'],
|
||||||
|
distributed_service=False)
|
||||||
# Validate port_dict is passed into address_factory
|
# Validate port_dict is passed into address_factory
|
||||||
address_factory.get_request.assert_called_once_with(context,
|
address_factory.get_request.assert_called_once_with(context,
|
||||||
port_dict,
|
port_dict,
|
||||||
ip_dict)
|
ip_dict)
|
||||||
|
|
||||||
|
@mock.patch('neutron.ipam.driver.Pool')
|
||||||
|
def test_update_ips_for_port_ovn_distributed_svc(self, pool_mock):
|
||||||
|
address_factory = mock.Mock()
|
||||||
|
mocks = self._prepare_mocks_with_pool_mock(
|
||||||
|
pool_mock, address_factory=address_factory)
|
||||||
|
context = mock.Mock()
|
||||||
|
new_ips = mock.Mock()
|
||||||
|
original_ips = mock.Mock()
|
||||||
|
mac = mock.Mock()
|
||||||
|
|
||||||
|
ip_dict = {'ip_address': '192.1.1.10',
|
||||||
|
'subnet_id': uuidutils.generate_uuid()}
|
||||||
|
changes = ipam_pluggable_backend.IpamPluggableBackend.Changes(
|
||||||
|
add=[ip_dict], original=[], remove=[])
|
||||||
|
changes_mock = mock.Mock(return_value=changes)
|
||||||
|
fixed_ips_mock = mock.Mock(return_value=changes.add)
|
||||||
|
mocks['ipam'] = ipam_pluggable_backend.IpamPluggableBackend()
|
||||||
|
mocks['ipam']._get_changed_ips_for_port = changes_mock
|
||||||
|
mocks['ipam']._ipam_get_subnets = mock.Mock(return_value=[])
|
||||||
|
mocks['ipam']._test_fixed_ips_for_port = fixed_ips_mock
|
||||||
|
mocks['ipam']._update_ips_for_pd_subnet = mock.Mock(return_value=[])
|
||||||
|
|
||||||
|
port_dict = {
|
||||||
|
'device_owner': constants.DEVICE_OWNER_DHCP,
|
||||||
|
'device_id': 'ovnmeta-%s' % uuidutils.generate_uuid(),
|
||||||
|
'network_id': uuidutils.generate_uuid()}
|
||||||
|
|
||||||
|
mocks['ipam']._update_ips_for_port(context, port_dict, None,
|
||||||
|
original_ips, new_ips, mac)
|
||||||
|
mocks['ipam']._ipam_get_subnets.assert_called_once_with(
|
||||||
|
context, network_id=port_dict['network_id'], fixed_configured=True,
|
||||||
|
fixed_ips=[ip_dict], host=None,
|
||||||
|
service_type=port_dict['device_owner'],
|
||||||
|
distributed_service=True)
|
||||||
|
|
||||||
@mock.patch('neutron.ipam.driver.Pool')
|
@mock.patch('neutron.ipam.driver.Pool')
|
||||||
def test_update_ips_for_port_passes_port_id_to_factory(self, pool_mock):
|
def test_update_ips_for_port_passes_port_id_to_factory(self, pool_mock):
|
||||||
port_id = uuidutils.generate_uuid()
|
port_id = uuidutils.generate_uuid()
|
||||||
|
|
|
@ -1990,6 +1990,116 @@ class TestOVNMechanismDriverSegment(test_segment.HostSegmentMappingTestCase):
|
||||||
lport_name=ovn_utils.ovn_provnet_port_name(seg_2['id']),
|
lport_name=ovn_utils.ovn_provnet_port_name(seg_2['id']),
|
||||||
lswitch_name=ovn_utils.ovn_name(net['id']))
|
lswitch_name=ovn_utils.ovn_name(net['id']))
|
||||||
|
|
||||||
|
def _test_segments_helper(self):
|
||||||
|
ovn_conf.cfg.CONF.set_override('ovn_metadata_enabled', True,
|
||||||
|
group='ovn')
|
||||||
|
|
||||||
|
# Create first segment and associate subnet to it.
|
||||||
|
with self.network() as n:
|
||||||
|
self.net = n
|
||||||
|
self.seg_1 = self._test_create_segment(
|
||||||
|
network_id=self.net['network']['id'], physical_network='phys_net1',
|
||||||
|
segmentation_id=200, network_type='vlan')['segment']
|
||||||
|
with self.subnet(network=self.net, cidr='10.0.1.0/24',
|
||||||
|
segment_id=self.seg_1['id']) as subnet:
|
||||||
|
self.sub_1 = subnet
|
||||||
|
|
||||||
|
# Create second segment and subnet linked to it
|
||||||
|
self.seg_2 = self._test_create_segment(
|
||||||
|
network_id=self.net['network']['id'], physical_network='phys_net2',
|
||||||
|
segmentation_id=300, network_type='vlan')['segment']
|
||||||
|
with self.subnet(network=self.net, cidr='10.0.2.0/24',
|
||||||
|
segment_id=self.seg_2['id']) as subnet:
|
||||||
|
self.sub_2 = subnet
|
||||||
|
|
||||||
|
def test_create_segments_subnet_metadata_ip_allocation(self):
|
||||||
|
self._test_segments_helper()
|
||||||
|
ovn_nb_api = self.mech_driver._nb_ovn
|
||||||
|
|
||||||
|
# Assert that metadata address has been allocated from previously
|
||||||
|
# created subnet.
|
||||||
|
self.assertIn(
|
||||||
|
'10.0.1.2',
|
||||||
|
ovn_nb_api.set_lswitch_port.call_args_list[0][1]['addresses'][0])
|
||||||
|
|
||||||
|
# Assert that the second subnet address also has been allocated for
|
||||||
|
# metadata port.
|
||||||
|
self.assertIn(
|
||||||
|
'10.0.2.2',
|
||||||
|
ovn_nb_api.set_lswitch_port.call_args_list[1][1]['addresses'][0])
|
||||||
|
# Assert also that the first subnet address is in this update
|
||||||
|
self.assertIn(
|
||||||
|
'10.0.1.2',
|
||||||
|
ovn_nb_api.set_lswitch_port.call_args_list[1][1]['addresses'][0])
|
||||||
|
self.assertEqual(
|
||||||
|
ovn_nb_api.set_lswitch_port.call_count, 2)
|
||||||
|
|
||||||
|
# Make sure both updates where on same metadata port
|
||||||
|
args_list = ovn_nb_api.set_lswitch_port.call_args_list
|
||||||
|
self.assertEqual(
|
||||||
|
'ovnmeta-%s' % self.net['network']['id'],
|
||||||
|
args_list[1][1]['external_ids']['neutron:device_id'])
|
||||||
|
self.assertEqual(
|
||||||
|
args_list[1][1]['external_ids']['neutron:device_id'],
|
||||||
|
args_list[0][1]['external_ids']['neutron:device_id'])
|
||||||
|
self.assertEqual(
|
||||||
|
args_list[1][1]['external_ids']['neutron:device_owner'],
|
||||||
|
args_list[0][1]['external_ids']['neutron:device_owner'])
|
||||||
|
self.assertEqual(
|
||||||
|
const.DEVICE_OWNER_DHCP,
|
||||||
|
args_list[1][1]['external_ids']['neutron:device_owner'])
|
||||||
|
|
||||||
|
def test_create_segments_mixed_allocation_prohibited(self):
|
||||||
|
self._test_segments_helper()
|
||||||
|
|
||||||
|
# Try to create 'normal' port with ip address
|
||||||
|
# allocations from multiple segments
|
||||||
|
kwargs = {'fixed_ips': [{'ip_address': '10.0.1.100',
|
||||||
|
'subnet_id': self.sub_1['subnet']['id']},
|
||||||
|
{'ip_address': '10.0.2.100',
|
||||||
|
'subnet_id': self.sub_2['subnet']['id']}]}
|
||||||
|
|
||||||
|
# Make sure that this allocation is prohibited.
|
||||||
|
self._create_port(
|
||||||
|
self.fmt, self.net['network']['id'],
|
||||||
|
arg_list=('fixed_ips',), **kwargs,
|
||||||
|
expected_res_status=400)
|
||||||
|
|
||||||
|
def test_create_delete_segment_distributed_service_port_not_touched(self):
|
||||||
|
self._test_segments_helper()
|
||||||
|
ovn_nb_api = self.mech_driver._nb_ovn
|
||||||
|
|
||||||
|
# Delete second subnet
|
||||||
|
self._delete('subnets', self.sub_2['subnet']['id'])
|
||||||
|
# Make sure that shared metadata port wasn't deleted.
|
||||||
|
ovn_nb_api.delete_lswitch_port.assert_not_called()
|
||||||
|
|
||||||
|
# Delete first subnet
|
||||||
|
self._delete('subnets', self.sub_1['subnet']['id'])
|
||||||
|
# Make sure that the metadata port wasn't deleted.
|
||||||
|
ovn_nb_api.delete_lswitch_port.assert_not_called()
|
||||||
|
|
||||||
|
# Delete both segments
|
||||||
|
self._delete('segments', self.seg_2['id'])
|
||||||
|
self._delete('segments', self.seg_1['id'])
|
||||||
|
|
||||||
|
# Make sure that the metadata port wasn't deleted.
|
||||||
|
deleted_ports = [
|
||||||
|
port[1]['lport_name']
|
||||||
|
for port in ovn_nb_api.delete_lswitch_port.call_args_list]
|
||||||
|
self.assertNotIn(
|
||||||
|
'ovnmeta-%s' % self.net['network']['id'],
|
||||||
|
deleted_ports)
|
||||||
|
self.assertEqual(
|
||||||
|
2,
|
||||||
|
ovn_nb_api.delete_lswitch_port.call_count)
|
||||||
|
|
||||||
|
# Only on network deletion the metadata port is deleted.
|
||||||
|
self._delete('networks', self.net['network']['id'])
|
||||||
|
self.assertEqual(
|
||||||
|
3,
|
||||||
|
ovn_nb_api.delete_lswitch_port.call_count)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(n_net, 'get_random_mac', lambda *_: '01:02:03:04:05:06')
|
@mock.patch.object(n_net, 'get_random_mac', lambda *_: '01:02:03:04:05:06')
|
||||||
class TestOVNMechanismDriverDHCPOptions(OVNMechanismDriverTestCase):
|
class TestOVNMechanismDriverDHCPOptions(OVNMechanismDriverTestCase):
|
||||||
|
|
Loading…
Reference in New Issue