[OVN][Placement] Add a SB Chassis event to track changes in BW config
Added a new OVN Client extension: OVNClientPlacementExtension. This extension is in charge of handling the bandwidth information stored in the OVN database, in the "Chassis" registers on the "ovn-cms-options" dictionary. Three new keys are created to store the resource provider information needed to parameterize the network backend bandwidth information, following the implementation done in OVS and SR-IOV: - resource_provider_bandwidths - resource_provider_inventory_defaults - resource_provider_hypervisors When the OVN Client is started, the Placement extension will check if the "placement" extension is loaded. It will also create an event to check any configuration change done in any "Chassis" register. The Placement extension will read the initial configuration stored in the OVN database and will populate it to Placement API, creating the needed resource providers, traits and inventories. NOTE: This patch belongs to a series of patches to implement minimum bandwidth scheduling blueprint in OVN backend. The next patch will make OVN backend scheduling aware using the information stored in Placement API and the port information passed by Nova when a VM is created. NOTE: this patch improves [1], fixing the error [2] reported in [3]. [1]https://review.opendev.org/c/openstack/neutron/+/776701 [2]https://paste.opendev.org/show/807614/ [3]https://launchpad.net/bugs/1936983 Partial-Bug: #1578989 Change-Id: I63e81aebce2621226ff2cfe91f16c97913c137e8
This commit is contained in:
parent
cee0653145
commit
a6f975ac03
|
@ -270,6 +270,28 @@ neutron-openvswitch-agent. However look out for:
|
|||
resource_provider_bandwidths = ens5:40000000:40000000,ens6:40000000:40000000,...
|
||||
#resource_provider_inventory_defaults = step_size:1000,...
|
||||
|
||||
OVN chassis config
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Bandwidth config values are stored in each SB chassis register, in
|
||||
"external_ids:ovn-cms-options". The configuration options are the same as in
|
||||
SR-IOV and OVS agents. This is how the values are registered:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ root@dev20:~# ovs-vsctl list Open_vSwitch
|
||||
...
|
||||
external_ids : {hostname=dev20.fistro.com, \
|
||||
ovn-cms-options="resource_provider_bandwidths=br-ex:1001:2000;br-ex2:3000:4000, \
|
||||
resource_provider_inventory_defaults=allocation_ratio:1.0;min_unit:10, \
|
||||
resource_provider_hypervisors=br-ex:dev20.fistro.com;br-ex2:dev20.fistro.com", \
|
||||
rundir="/var/run/openvswitch", \
|
||||
system-id="029e7d3d-d2ab-4f2c-bc92-ec58c94a8fc1"}
|
||||
...
|
||||
|
||||
Each configuration option defined in "external_ids:ovn-cms-options" is divided
|
||||
by commas.
|
||||
|
||||
Propagation of resource information
|
||||
-----------------------------------
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
# under the License.
|
||||
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from neutron_lib.api.definitions import portbindings
|
||||
from neutron_lib import constants as const
|
||||
|
@ -298,3 +299,16 @@ CMS_OPT_AVAILABILITY_ZONES = 'availability-zones'
|
|||
|
||||
# OVN vlan transparency option
|
||||
VLAN_PASSTHRU = 'vlan-passthru'
|
||||
|
||||
# OVN Placement API; used for minimum bandwidth scheduling allocation.
|
||||
# NOTE(ralonsoh): rehome to neutron-lib
|
||||
RP_HYPERVISORS = 'resource_provider_hypervisors'
|
||||
|
||||
# OVN mechanism driver constants.
|
||||
OVN_RP_UUID = uuid.UUID('5533233b-800c-11eb-b1f4-000056b2f5b8')
|
||||
OVN_SUPPORTED_VNIC_TYPES = [portbindings.VNIC_NORMAL,
|
||||
portbindings.VNIC_DIRECT,
|
||||
portbindings.VNIC_DIRECT_PHYSICAL,
|
||||
portbindings.VNIC_MACVTAP,
|
||||
portbindings.VNIC_VHOST_VDPA,
|
||||
]
|
||||
|
|
|
@ -20,7 +20,6 @@ import operator
|
|||
import signal
|
||||
import threading
|
||||
import types
|
||||
import uuid
|
||||
|
||||
from neutron_lib.api.definitions import portbindings
|
||||
from neutron_lib.api.definitions import provider_net
|
||||
|
@ -95,8 +94,7 @@ class OVNMechanismDriver(api.MechanismDriver):
|
|||
update network/port case, all data validation must be done within
|
||||
methods that are part of the database transaction.
|
||||
"""
|
||||
resource_provider_uuid5_namespace = uuid.UUID(
|
||||
'5533233b-800c-11eb-b1f4-000056b2f5b8')
|
||||
resource_provider_uuid5_namespace = ovn_const.OVN_RP_UUID
|
||||
|
||||
def initialize(self):
|
||||
"""Perform driver initialization.
|
||||
|
@ -190,12 +188,7 @@ class OVNMechanismDriver(api.MechanismDriver):
|
|||
in vlan_transparency_network_types)
|
||||
|
||||
def _setup_vif_port_bindings(self):
|
||||
self.supported_vnic_types = [portbindings.VNIC_NORMAL,
|
||||
portbindings.VNIC_DIRECT,
|
||||
portbindings.VNIC_DIRECT_PHYSICAL,
|
||||
portbindings.VNIC_MACVTAP,
|
||||
portbindings.VNIC_VHOST_VDPA,
|
||||
]
|
||||
self.supported_vnic_types = ovn_const.OVN_SUPPORTED_VNIC_TYPES
|
||||
self.vif_details = {
|
||||
portbindings.VIF_TYPE_OVS: {
|
||||
portbindings.CAP_PORT_FILTER: self.sg_enabled
|
||||
|
|
|
@ -0,0 +1,269 @@
|
|||
# Copyright 2021 Red Hat, Inc.
|
||||
#
|
||||
# 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 itertools
|
||||
|
||||
from keystoneauth1 import exceptions as ks_exc
|
||||
from neutron_lib import constants as n_const
|
||||
from neutron_lib.placement import constants as placement_constants
|
||||
from neutron_lib.placement import utils as placement_utils
|
||||
from neutron_lib.plugins import constants as plugins_constants
|
||||
from neutron_lib.plugins import directory
|
||||
from neutron_lib.utils import helpers
|
||||
from oslo_log import log as logging
|
||||
from ovsdbapp.backend.ovs_idl import event as row_event
|
||||
|
||||
from neutron.agent.common import placement_report
|
||||
from neutron.common.ovn import constants as ovn_const
|
||||
from neutron.common.ovn import utils as ovn_utils
|
||||
from neutron.common import utils as common_utils
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_ovn_cms_options(chassis):
|
||||
cms_options = ovn_utils.get_ovn_cms_options(chassis)
|
||||
return {n_const.RP_BANDWIDTHS: _parse_bandwidths(cms_options),
|
||||
n_const.RP_INVENTORY_DEFAULTS: _parse_inventory_defaults(
|
||||
cms_options),
|
||||
ovn_const.RP_HYPERVISORS: _parse_hypervisors(cms_options)}
|
||||
|
||||
|
||||
def _parse_bridge_mappings(chassis):
|
||||
bridge_mappings = chassis.external_ids.get('ovn-bridge-mappings', '')
|
||||
bridge_mappings = helpers.parse_mappings(bridge_mappings.split(','),
|
||||
unique_values=False)
|
||||
return {k: [v] for k, v in bridge_mappings.items()}
|
||||
|
||||
|
||||
def _parse_placement_option(option_name, cms_options):
|
||||
for cms_option in (cms_option for cms_option in cms_options if
|
||||
option_name in cms_option):
|
||||
try:
|
||||
return cms_option.split('=')[1]
|
||||
except IndexError:
|
||||
break
|
||||
|
||||
|
||||
def _parse_bandwidths(cms_options):
|
||||
bw_values = _parse_placement_option(n_const.RP_BANDWIDTHS, cms_options)
|
||||
if not bw_values:
|
||||
return {}
|
||||
|
||||
return placement_utils.parse_rp_bandwidths(bw_values.split(';'))
|
||||
|
||||
|
||||
def _parse_inventory_defaults(cms_options):
|
||||
inv_defaults = _parse_placement_option(n_const.RP_INVENTORY_DEFAULTS,
|
||||
cms_options)
|
||||
if not inv_defaults:
|
||||
return {}
|
||||
|
||||
inventory = {}
|
||||
for inv_default in inv_defaults.split(';'):
|
||||
for key in placement_constants.INVENTORY_OPTIONS:
|
||||
if key in inv_default:
|
||||
inventory[key] = inv_default.split(':')[1]
|
||||
return placement_utils.parse_rp_inventory_defaults(inventory)
|
||||
|
||||
|
||||
def _parse_hypervisors(cms_options):
|
||||
hyperv = _parse_placement_option(ovn_const.RP_HYPERVISORS, cms_options)
|
||||
if not hyperv:
|
||||
return {}
|
||||
|
||||
return helpers.parse_mappings(hyperv.split(';'), unique_values=False)
|
||||
|
||||
|
||||
def _send_deferred_batch(state):
|
||||
if not state:
|
||||
return
|
||||
|
||||
deferred_batch = state.deferred_sync()
|
||||
for deferred in deferred_batch:
|
||||
try:
|
||||
LOG.debug('Placement client: %s', str(deferred))
|
||||
deferred.execute()
|
||||
except Exception:
|
||||
LOG.exception('Placement client call failed: %s', str(deferred))
|
||||
|
||||
|
||||
def _dict_chassis_config(state):
|
||||
if state:
|
||||
return {n_const.RP_BANDWIDTHS: state._rp_bandwidths,
|
||||
n_const.RP_INVENTORY_DEFAULTS: state._rp_inventory_defaults,
|
||||
ovn_const.RP_HYPERVISORS: state._hypervisor_rps}
|
||||
|
||||
|
||||
class ChassisBandwidthConfigEvent(row_event.RowEvent):
|
||||
"""Chassis create update event to track the bandwidth config changes."""
|
||||
|
||||
def __init__(self, placement_extension):
|
||||
self._placement_extension = placement_extension
|
||||
# NOTE(ralonsoh): BW resource provider information is stored in
|
||||
# "Chassis", not "Chassis_Private".
|
||||
table = 'Chassis'
|
||||
events = (self.ROW_CREATE, self.ROW_UPDATE)
|
||||
super().__init__(events, table, None)
|
||||
self.event_name = 'ChassisBandwidthConfigEvent'
|
||||
|
||||
def run(self, event, row, old):
|
||||
name2uuid = self._placement_extension.name2uuid()
|
||||
state = self._placement_extension.build_placement_state(row, name2uuid)
|
||||
_send_deferred_batch(state)
|
||||
self._placement_extension.add_chassis_config(
|
||||
row.name, _dict_chassis_config(state))
|
||||
ch_config = self._placement_extension.get_chassis_config(row.name)
|
||||
LOG.debug('OVN chassis %(chassis)s Placement configuration modified: '
|
||||
'%(config)s', {'chassis': row.name, 'config': ch_config})
|
||||
|
||||
|
||||
@common_utils.SingletonDecorator
|
||||
class OVNClientPlacementExtension(object):
|
||||
"""OVN client Placement API extension"""
|
||||
|
||||
def __init__(self, driver):
|
||||
LOG.info('Starting OVNClientPlacementExtension')
|
||||
super().__init__()
|
||||
self._config_event = None
|
||||
self._reset(driver)
|
||||
|
||||
def _reset(self, driver):
|
||||
"""Reset the interval members values
|
||||
This class is a singleton. Once initialized, any other new instance
|
||||
will return the same object reference with the same member values.
|
||||
This method is used to reset all of them as when the class is initially
|
||||
instantiated if needed.
|
||||
"""
|
||||
self._driver = driver
|
||||
self._placement_plugin = None
|
||||
self._plugin = None
|
||||
self.uuid_ns = ovn_const.OVN_RP_UUID
|
||||
self.supported_vnic_types = ovn_const.OVN_SUPPORTED_VNIC_TYPES
|
||||
self._enabled = bool(self.placement_plugin)
|
||||
self._chassis = {} # Initial config read could take some time.
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
if not self._config_event:
|
||||
self._config_event = ChassisBandwidthConfigEvent(self)
|
||||
try:
|
||||
self._driver._sb_idl.idl.notify_handler.watch_events(
|
||||
[self._config_event])
|
||||
except AttributeError:
|
||||
self._enabled = False
|
||||
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
self._chassis = self._read_initial_chassis_config()
|
||||
|
||||
@property
|
||||
def placement_plugin(self):
|
||||
if self._placement_plugin is None:
|
||||
self._placement_plugin = directory.get_plugin(
|
||||
plugins_constants.PLACEMENT_REPORT)
|
||||
return self._placement_plugin
|
||||
|
||||
@property
|
||||
def plugin(self):
|
||||
if self._plugin is None:
|
||||
self._plugin = self._driver._plugin
|
||||
return self._plugin
|
||||
|
||||
@property
|
||||
def ovn_mech_driver(self):
|
||||
if self._ovn_mech_driver is None:
|
||||
self._ovn_mech_driver = (
|
||||
self.plugin.mechanism_manager.mech_drivers['ovn'].obj)
|
||||
return self._ovn_mech_driver
|
||||
|
||||
@property
|
||||
def chassis(self):
|
||||
return self._chassis
|
||||
|
||||
def _read_initial_chassis_config(self):
|
||||
chassis = {}
|
||||
name2uuid = self.name2uuid()
|
||||
for ch in self._driver._sb_idl.chassis_list().execute(
|
||||
check_error=True):
|
||||
state = self.build_placement_state(ch, name2uuid)
|
||||
_send_deferred_batch(state)
|
||||
config = _dict_chassis_config(state)
|
||||
if config:
|
||||
chassis[ch.name] = config
|
||||
|
||||
msg = '\n'.join(['Chassis %s: %s' % (name, config)
|
||||
for (name, config) in chassis.items()])
|
||||
LOG.debug('OVN chassis Placement initial configuration:\n%s', msg)
|
||||
return chassis
|
||||
|
||||
def name2uuid(self, name=None):
|
||||
try:
|
||||
rps = self.placement_plugin._placement_client.\
|
||||
list_resource_providers(name=name)['resource_providers']
|
||||
except (ks_exc.HttpError, ks_exc.ClientException):
|
||||
LOG.warning('Error connecting to Placement API.')
|
||||
return {}
|
||||
|
||||
_name2uuid = {rp['name']: rp['uuid'] for rp in rps}
|
||||
LOG.info('Placement information about resource providers '
|
||||
'(name:uuid):%s ', _name2uuid)
|
||||
return _name2uuid
|
||||
|
||||
def build_placement_state(self, chassis, name2uuid):
|
||||
bridge_mappings = _parse_bridge_mappings(chassis)
|
||||
cms_options = _parse_ovn_cms_options(chassis)
|
||||
LOG.debug('Building placement options for chassis %s: %s',
|
||||
chassis.name, cms_options)
|
||||
hypervisor_rps = {}
|
||||
for device, hyperv in cms_options[ovn_const.RP_HYPERVISORS].items():
|
||||
try:
|
||||
hypervisor_rps[device] = {'name': hyperv,
|
||||
'uuid': name2uuid[hyperv]}
|
||||
except (KeyError, AttributeError):
|
||||
continue
|
||||
|
||||
bridges = set(itertools.chain(*bridge_mappings.values()))
|
||||
# Remove "cms_options[RP_BANDWIDTHS]" not present in "hypervisor_rps"
|
||||
# and "bridge_mappings". If we don't have a way to match the RP bridge
|
||||
# with a host ("hypervisor_rps") or a way to match the RP bridge with
|
||||
# an external network ("bridge_mappings"), this value is irrelevant.
|
||||
rp_bw = cms_options[n_const.RP_BANDWIDTHS]
|
||||
if rp_bw:
|
||||
cms_options[n_const.RP_BANDWIDTHS] = {
|
||||
device: bw for device, bw in rp_bw.items() if
|
||||
device in hypervisor_rps and device in bridges}
|
||||
|
||||
# NOTE(ralonsoh): OVN only reports min BW RPs; packet processing RPs
|
||||
# will be added in a future implementation.
|
||||
return placement_report.PlacementState(
|
||||
rp_bandwidths=cms_options[n_const.RP_BANDWIDTHS],
|
||||
rp_inventory_defaults=cms_options[n_const.RP_INVENTORY_DEFAULTS],
|
||||
rp_pkt_processing={},
|
||||
rp_pkt_processing_inventory_defaults=None,
|
||||
driver_uuid_namespace=self.uuid_ns,
|
||||
agent_type=ovn_const.OVN_CONTROLLER_AGENT,
|
||||
hypervisor_rps=hypervisor_rps,
|
||||
device_mappings=bridge_mappings,
|
||||
supported_vnic_types=self.supported_vnic_types,
|
||||
client=self.placement_plugin._placement_client)
|
||||
|
||||
def get_chassis_config(self, chassis_name):
|
||||
return self._chassis.get(chassis_name)
|
||||
|
||||
def add_chassis_config(self, chassis_name, config):
|
||||
if config:
|
||||
self._chassis[chassis_name] = config
|
|
@ -44,6 +44,8 @@ from neutron.common import utils as common_utils
|
|||
from neutron.conf.plugins.ml2.drivers.ovn import ovn_conf
|
||||
from neutron.db import ovn_revision_numbers_db as db_rev
|
||||
from neutron.db import segments_db
|
||||
from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb.extensions \
|
||||
import placement as placement_extension
|
||||
from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb.extensions \
|
||||
import qos as qos_extension
|
||||
from neutron.scheduler import l3_ovn_scheduler
|
||||
|
@ -74,6 +76,8 @@ class OVNClient(object):
|
|||
|
||||
# TODO(ralonsoh): handle the OVN client extensions with an ext. manager
|
||||
self._qos_driver = qos_extension.OVNClientQosExtension(self)
|
||||
self._placement_extension = (
|
||||
placement_extension.OVNClientPlacementExtension(self))
|
||||
self._ovn_scheduler = l3_ovn_scheduler.get_scheduler()
|
||||
|
||||
@property
|
||||
|
|
|
@ -42,6 +42,8 @@ from neutron.conf.plugins.ml2.drivers.ovn import ovn_conf
|
|||
from neutron.db import models # noqa
|
||||
from neutron import manager
|
||||
from neutron.plugins.ml2.drivers.ovn.agent import neutron_agent
|
||||
from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb.extensions import \
|
||||
placement as ovn_client_placement
|
||||
from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb import worker
|
||||
from neutron.plugins.ml2.drivers import type_geneve # noqa
|
||||
from neutron import service # noqa
|
||||
|
@ -167,7 +169,7 @@ class TestOVNFunctionalBase(test_plugin.Ml2PluginV2TestCase,
|
|||
self.temp_dir = self.useFixture(fixtures.TempDir()).path
|
||||
self._start_ovsdb_server()
|
||||
|
||||
def setUp(self, maintenance_worker=False):
|
||||
def setUp(self, maintenance_worker=False, service_plugins=None):
|
||||
ml2_config.cfg.CONF.set_override('extension_drivers',
|
||||
self._extension_drivers,
|
||||
group='ml2')
|
||||
|
@ -187,6 +189,7 @@ class TestOVNFunctionalBase(test_plugin.Ml2PluginV2TestCase,
|
|||
|
||||
self.addCleanup(exts.PluginAwareExtensionManager.clear_instance)
|
||||
self.ovsdb_server_mgr = None
|
||||
self._service_plugins = service_plugins
|
||||
super(TestOVNFunctionalBase, self).setUp()
|
||||
self.test_log_dir = os.path.join(DEFAULT_LOG_DIR, self.id())
|
||||
base.setup_test_logging(
|
||||
|
@ -220,10 +223,14 @@ class TestOVNFunctionalBase(test_plugin.Ml2PluginV2TestCase,
|
|||
self._start_idls()
|
||||
self._start_ovn_northd()
|
||||
self.addCleanup(self._reset_agent_cache_singleton)
|
||||
self.addCleanup(self._reset_ovn_client_placement_extension)
|
||||
|
||||
def _reset_agent_cache_singleton(self):
|
||||
neutron_agent.AgentCache._instance = None
|
||||
|
||||
def _reset_ovn_client_placement_extension(self):
|
||||
ovn_client_placement.OVNClientPlacementExtension._instance = None
|
||||
|
||||
def _get_install_share_path(self):
|
||||
lookup_paths = set()
|
||||
for installation in ['local', '']:
|
||||
|
@ -253,8 +260,10 @@ class TestOVNFunctionalBase(test_plugin.Ml2PluginV2TestCase,
|
|||
|
||||
def get_additional_service_plugins(self):
|
||||
p = super(TestOVNFunctionalBase, self).get_additional_service_plugins()
|
||||
p.update({'revision_plugin_name': 'revisions'})
|
||||
p.update({'segments': 'neutron.services.segments.plugin.Plugin'})
|
||||
p.update({'revision_plugin_name': 'revisions',
|
||||
'segments': 'neutron.services.segments.plugin.Plugin'})
|
||||
if self._service_plugins:
|
||||
p.update(self._service_plugins)
|
||||
return p
|
||||
|
||||
@property
|
||||
|
|
|
@ -0,0 +1,185 @@
|
|||
# Copyright 2021 Red Hat, Inc.
|
||||
#
|
||||
# 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 neutron_lib import constants as n_const
|
||||
from neutron_lib.plugins import constants as plugins_constants
|
||||
|
||||
from neutron.common.ovn import constants as ovn_const
|
||||
from neutron.common import utils as common_utils
|
||||
from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb.extensions \
|
||||
import placement as placement_extension
|
||||
from neutron.tests.functional import base
|
||||
|
||||
|
||||
class TestOVNClientQosExtension(base.TestOVNFunctionalBase):
|
||||
|
||||
EMPTY_CHASSIS = {n_const.RP_BANDWIDTHS: {},
|
||||
n_const.RP_INVENTORY_DEFAULTS: {},
|
||||
ovn_const.RP_HYPERVISORS: {}}
|
||||
|
||||
RP_BANDWIDTHS_1 = {'br-provider0': {'egress': 1000, 'ingress': 2000}}
|
||||
RP_INVENTORY_DEFAULTS_1 = {'allocation_ratio': 1.0, 'min_unit': 2}
|
||||
RP_HYPERVISORS_1 = {'br-provider0': {'name': 'host1', 'uuid': 'uuid1'}}
|
||||
CHASSIS1 = {
|
||||
'chassis1': {
|
||||
n_const.RP_BANDWIDTHS: RP_BANDWIDTHS_1,
|
||||
n_const.RP_INVENTORY_DEFAULTS: RP_INVENTORY_DEFAULTS_1,
|
||||
ovn_const.RP_HYPERVISORS: RP_HYPERVISORS_1
|
||||
}
|
||||
}
|
||||
RP_BANDWIDTHS_2 = {'br-provider0': {'egress': 3000, 'ingress': 4000}}
|
||||
RP_INVENTORY_DEFAULTS_2 = {'allocation_ratio': 3.0, 'min_unit': 1}
|
||||
RP_HYPERVISORS_2 = {'br-provider0': {'name': 'host2', 'uuid': 'uuid2'}}
|
||||
CHASSIS2 = {
|
||||
'chassis2': {
|
||||
n_const.RP_BANDWIDTHS: RP_BANDWIDTHS_2,
|
||||
n_const.RP_INVENTORY_DEFAULTS: RP_INVENTORY_DEFAULTS_2,
|
||||
ovn_const.RP_HYPERVISORS: RP_HYPERVISORS_2
|
||||
}
|
||||
}
|
||||
|
||||
RP_BANDWIDTHS_3 = {'br-provider0': {'egress': 5000, 'ingress': 6000}}
|
||||
RP_INVENTORY_DEFAULTS_3 = {'allocation_ratio': 1.1, 'min_unit': 1}
|
||||
CHASSIS2_B = {
|
||||
'chassis2': {
|
||||
n_const.RP_BANDWIDTHS: RP_BANDWIDTHS_3,
|
||||
n_const.RP_INVENTORY_DEFAULTS: RP_INVENTORY_DEFAULTS_3,
|
||||
ovn_const.RP_HYPERVISORS: RP_HYPERVISORS_2
|
||||
}
|
||||
}
|
||||
|
||||
def setUp(self, maintenance_worker=False, service_plugins=None):
|
||||
service_plugins = {plugins_constants.PLACEMENT_REPORT: 'placement'}
|
||||
super().setUp(maintenance_worker=maintenance_worker,
|
||||
service_plugins=service_plugins)
|
||||
self.ovn_client = self.mech_driver._ovn_client
|
||||
self.placement_ext = self.ovn_client._placement_extension
|
||||
self.mock_name2uuid = mock.patch.object(
|
||||
self.placement_ext, 'name2uuid').start()
|
||||
self.mock_send_batch = mock.patch.object(
|
||||
placement_extension, '_send_deferred_batch').start()
|
||||
|
||||
def _build_external_ids(self, bandwidths, inventory_defaults, hypervisors):
|
||||
options = []
|
||||
if bandwidths:
|
||||
options.append(n_const.RP_BANDWIDTHS + '=' + bandwidths)
|
||||
if inventory_defaults:
|
||||
options.append(n_const.RP_INVENTORY_DEFAULTS + '=' +
|
||||
inventory_defaults)
|
||||
if hypervisors:
|
||||
options.append(ovn_const.RP_HYPERVISORS + '=' + hypervisors)
|
||||
return {'ovn-cms-options': ','.join(options)}
|
||||
|
||||
def _create_chassis(self, host, name, physical_nets=None, bandwidths=None,
|
||||
inventory_defaults=None, hypervisors=None):
|
||||
external_ids = self._build_external_ids(bandwidths, inventory_defaults,
|
||||
hypervisors)
|
||||
self.add_fake_chassis(host, physical_nets=physical_nets,
|
||||
external_ids=external_ids, name=name)
|
||||
|
||||
def _update_chassis(self, name, bandwidths=None, inventory_defaults=None,
|
||||
hypervisors=None):
|
||||
external_ids = self._build_external_ids(bandwidths, inventory_defaults,
|
||||
hypervisors)
|
||||
self.sb_api.db_set(
|
||||
'Chassis', name, ('external_ids', external_ids)
|
||||
).execute(check_error=True)
|
||||
|
||||
def _check_placement_cache(self, expected_chassis):
|
||||
current_chassis = None
|
||||
|
||||
def check_chassis():
|
||||
nonlocal current_chassis
|
||||
current_chassis = self.placement_ext.chassis
|
||||
return current_chassis == expected_chassis
|
||||
|
||||
try:
|
||||
common_utils.wait_until_true(check_chassis, timeout=5)
|
||||
except common_utils.WaitTimeout:
|
||||
self.fail('OVN client Placement extension cache does not have '
|
||||
'the expected chassis information.\nExpected: %s.\n'
|
||||
'Actual: %s' % (expected_chassis, current_chassis))
|
||||
|
||||
def test_read_initial_config_and_update(self):
|
||||
self.mock_name2uuid.return_value = {'host1': 'uuid1',
|
||||
'host2': 'uuid2'}
|
||||
self._create_chassis(
|
||||
'host1', 'chassis1', physical_nets=['phys1'],
|
||||
bandwidths='br-provider0:1000:2000',
|
||||
inventory_defaults='allocation_ratio:1.0;min_unit:2',
|
||||
hypervisors='br-provider0:host1')
|
||||
self._create_chassis(
|
||||
'host2', 'chassis2', physical_nets=['phys2'],
|
||||
bandwidths='br-provider0:3000:4000',
|
||||
inventory_defaults='allocation_ratio:3.0;min_unit:1',
|
||||
hypervisors='br-provider0:host2')
|
||||
self._check_placement_cache({**self.CHASSIS1, **self.CHASSIS2})
|
||||
|
||||
self._update_chassis(
|
||||
'chassis2',
|
||||
bandwidths='br-provider0:5000:6000',
|
||||
inventory_defaults='allocation_ratio:1.1;min_unit:1',
|
||||
hypervisors='br-provider0:host2')
|
||||
self._check_placement_cache({**self.CHASSIS1, **self.CHASSIS2_B})
|
||||
|
||||
def test_read_initial_empty_config_and_update(self):
|
||||
self.mock_name2uuid.return_value = {'host1': 'uuid1',
|
||||
'host2': 'uuid2'}
|
||||
self._create_chassis('host1', 'chassis1', physical_nets=['phys1'])
|
||||
self._create_chassis('host2', 'chassis2', physical_nets=['phys2'])
|
||||
self._check_placement_cache({**{'chassis1': self.EMPTY_CHASSIS},
|
||||
**{'chassis2': self.EMPTY_CHASSIS}})
|
||||
|
||||
self._update_chassis(
|
||||
'chassis1',
|
||||
bandwidths='br-provider0:1000:2000',
|
||||
inventory_defaults='allocation_ratio:1.0;min_unit:2',
|
||||
hypervisors='br-provider0:host1')
|
||||
self._check_placement_cache({**self.CHASSIS1,
|
||||
**{'chassis2': self.EMPTY_CHASSIS}})
|
||||
|
||||
self._update_chassis(
|
||||
'chassis2',
|
||||
bandwidths='br-provider0:3000:4000',
|
||||
inventory_defaults='allocation_ratio:3.0;min_unit:1',
|
||||
hypervisors='br-provider0:host2')
|
||||
self._check_placement_cache({**self.CHASSIS1, **self.CHASSIS2})
|
||||
|
||||
def test_update_twice(self):
|
||||
self.mock_name2uuid.return_value = {'host1': 'uuid1',
|
||||
'host2': 'uuid2'}
|
||||
self._create_chassis(
|
||||
'host1', 'chassis1', physical_nets=['phys1'],
|
||||
bandwidths='br-provider0:1000:2000',
|
||||
inventory_defaults='allocation_ratio:1.0;min_unit:2',
|
||||
hypervisors='br-provider0:host1')
|
||||
self._create_chassis('host2', 'chassis2', physical_nets=['phys2'])
|
||||
self._check_placement_cache({**self.CHASSIS1,
|
||||
**{'chassis2': self.EMPTY_CHASSIS}})
|
||||
|
||||
self._update_chassis(
|
||||
'chassis2',
|
||||
bandwidths='br-provider0:3000:4000',
|
||||
inventory_defaults='allocation_ratio:3.0;min_unit:1',
|
||||
hypervisors='br-provider0:host2')
|
||||
self._check_placement_cache({**self.CHASSIS1, **self.CHASSIS2})
|
||||
|
||||
self._update_chassis(
|
||||
'chassis2',
|
||||
bandwidths='br-provider0:5000:6000',
|
||||
inventory_defaults='allocation_ratio:1.1;min_unit:1',
|
||||
hypervisors='br-provider0:host2')
|
||||
self._check_placement_cache({**self.CHASSIS1, **self.CHASSIS2_B})
|
|
@ -17,6 +17,7 @@ import copy
|
|||
from unittest import mock
|
||||
|
||||
from neutron_lib.api.definitions import l3
|
||||
from neutron_lib import constants as n_const
|
||||
from neutron_lib.utils import net
|
||||
from oslo_utils import uuidutils
|
||||
|
||||
|
@ -819,7 +820,9 @@ class FakeOVNRouter(object):
|
|||
class FakeChassis(object):
|
||||
|
||||
@staticmethod
|
||||
def create(attrs=None, az_list=None, chassis_as_gw=False):
|
||||
def create(attrs=None, az_list=None, chassis_as_gw=False,
|
||||
bridge_mappings=None, rp_bandwidths=None,
|
||||
rp_inventory_defaults=None, rp_hypervisors=None):
|
||||
cms_opts = []
|
||||
if az_list:
|
||||
cms_opts.append("%s=%s" % (ovn_const.CMS_OPT_AVAILABILITY_ZONES,
|
||||
|
@ -827,10 +830,33 @@ class FakeChassis(object):
|
|||
if chassis_as_gw:
|
||||
cms_opts.append(ovn_const.CMS_OPT_CHASSIS_AS_GW)
|
||||
|
||||
if rp_bandwidths:
|
||||
cms_opts.append('%s=%s' % (n_const.RP_BANDWIDTHS,
|
||||
';'.join(rp_bandwidths)))
|
||||
elif rp_bandwidths == '': # Test wrongly defined parameter
|
||||
cms_opts.append('%s=' % n_const.RP_BANDWIDTHS)
|
||||
|
||||
if rp_inventory_defaults:
|
||||
inv_defaults = ';'.join('%s:%s' % (key, value) for key, value in
|
||||
rp_inventory_defaults.items())
|
||||
cms_opts.append('%s=%s' % (n_const.RP_INVENTORY_DEFAULTS,
|
||||
inv_defaults))
|
||||
elif rp_inventory_defaults == '': # Test wrongly defined parameter
|
||||
cms_opts.append('%s=' % n_const.RP_INVENTORY_DEFAULTS)
|
||||
|
||||
if rp_hypervisors:
|
||||
cms_opts.append('%s=%s' % (ovn_const.RP_HYPERVISORS,
|
||||
';'.join(rp_hypervisors)))
|
||||
elif rp_hypervisors == '': # Test wrongly defined parameter
|
||||
cms_opts.append('%s=' % ovn_const.RP_HYPERVISORS)
|
||||
|
||||
external_ids = {}
|
||||
if cms_opts:
|
||||
external_ids[ovn_const.OVN_CMS_OPTIONS] = ','.join(cms_opts)
|
||||
|
||||
if bridge_mappings:
|
||||
external_ids['ovn-bridge-mappings'] = ','.join(bridge_mappings)
|
||||
|
||||
attrs = {
|
||||
'encaps': [],
|
||||
'external_ids': external_ids,
|
||||
|
|
|
@ -0,0 +1,229 @@
|
|||
# Copyright 2021 Red Hat, Inc.
|
||||
#
|
||||
# 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 neutron_lib import constants as n_const
|
||||
|
||||
from neutron.common.ovn import constants as ovn_const
|
||||
from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb.extensions \
|
||||
import placement as p_extension
|
||||
from neutron.tests.unit import fake_resources as fakes
|
||||
from neutron.tests.unit.plugins.ml2 import test_plugin
|
||||
|
||||
|
||||
class TestOVNClientPlacementExtension(test_plugin.Ml2PluginV2TestCase):
|
||||
|
||||
CORE_PLUGIN_CLASS = 'neutron.plugins.ml2.plugin.Ml2Plugin'
|
||||
|
||||
def setUp(self):
|
||||
super(TestOVNClientPlacementExtension, self).setUp()
|
||||
self.setup_coreplugin(self.CORE_PLUGIN_CLASS, load_plugins=True)
|
||||
self.plugin_driver = mock.Mock()
|
||||
self.placement_driver = p_extension.OVNClientPlacementExtension(
|
||||
self.plugin_driver)
|
||||
self.placement_client = mock.Mock(
|
||||
update_trait=mock.Mock(__name__='update_trait'),
|
||||
ensure_resource_provider=mock.Mock(__name__='ensure_rp'),
|
||||
update_resource_provider_traits=mock.Mock(
|
||||
__name__='update_rp_traits'),
|
||||
update_resource_provider_inventories=mock.Mock(
|
||||
__name__='update_rp_inventories'))
|
||||
self.placement_plugin = mock.Mock(
|
||||
_placement_client=self.placement_client)
|
||||
self.placement_driver._placement_plugin = self.placement_plugin
|
||||
self.placement_client.list_resource_providers.return_value = {
|
||||
'resource_providers': [{'name': 'compute1', 'uuid': 'uuid1'},
|
||||
{'name': 'compute2', 'uuid': 'uuid2'}]
|
||||
}
|
||||
|
||||
def test__read_initial_chassis_config(self):
|
||||
# Add two public networks, a RP per bridge and the correlation between
|
||||
# the hypervisors and the bridges.
|
||||
chassis = fakes.FakeChassis.create(
|
||||
bridge_mappings=['public1:br-ext1', 'public2:br-ext2'],
|
||||
rp_bandwidths=['br-ext1:1000:2000', 'br-ext2:3000:4000'],
|
||||
rp_inventory_defaults={'allocation_ratio': 1.0, 'min_unit': 5},
|
||||
rp_hypervisors=['br-ext1:compute1', 'br-ext2:compute2'])
|
||||
self.plugin_driver._sb_idl.chassis_list.return_value.execute.\
|
||||
return_value = [chassis]
|
||||
init_conf = self.placement_driver._read_initial_chassis_config()
|
||||
expected = {chassis.name: {
|
||||
n_const.RP_BANDWIDTHS: {
|
||||
'br-ext1': {'egress': 1000, 'ingress': 2000},
|
||||
'br-ext2': {'egress': 3000, 'ingress': 4000}},
|
||||
n_const.RP_INVENTORY_DEFAULTS: {
|
||||
'allocation_ratio': 1.0, 'min_unit': 5},
|
||||
ovn_const.RP_HYPERVISORS: {
|
||||
'br-ext1': {'name': 'compute1', 'uuid': 'uuid1'},
|
||||
'br-ext2': {'name': 'compute2', 'uuid': 'uuid2'}}
|
||||
}}
|
||||
self.assertEqual(expected, init_conf)
|
||||
|
||||
# Add an extra bridge mapping that is discarded because it is not in
|
||||
# the hypervisors list (wrong configuration).
|
||||
chassis = fakes.FakeChassis.create(
|
||||
bridge_mappings=['public1:br-ext1', 'public2:br-ext2',
|
||||
'public3:br-ext3'],
|
||||
rp_bandwidths=['br-ext1:1000:2000', 'br-ext2:3000:4000'],
|
||||
rp_inventory_defaults={'allocation_ratio': 1.0, 'min_unit': 5},
|
||||
rp_hypervisors=['br-ext1:compute1', 'br-ext2:compute2'])
|
||||
self.plugin_driver._sb_idl.chassis_list.return_value.execute.\
|
||||
return_value = [chassis]
|
||||
init_conf = self.placement_driver._read_initial_chassis_config()
|
||||
expected = {chassis.name: {
|
||||
n_const.RP_BANDWIDTHS: {
|
||||
'br-ext1': {'egress': 1000, 'ingress': 2000},
|
||||
'br-ext2': {'egress': 3000, 'ingress': 4000}},
|
||||
n_const.RP_INVENTORY_DEFAULTS: {
|
||||
'allocation_ratio': 1.0, 'min_unit': 5},
|
||||
ovn_const.RP_HYPERVISORS: {
|
||||
'br-ext1': {'name': 'compute1', 'uuid': 'uuid1'},
|
||||
'br-ext2': {'name': 'compute2', 'uuid': 'uuid2'}}
|
||||
}}
|
||||
self.assertEqual(expected, init_conf)
|
||||
|
||||
# Add an unknown bridge, not present in the bridge mappings, that is
|
||||
# discarded (wrong configuration).
|
||||
chassis = fakes.FakeChassis.create(
|
||||
bridge_mappings=['public1:br-ext1', 'public2:br-ext2'],
|
||||
rp_bandwidths=['br-ext1:1000:2000', 'br-ext3:3000:4000'],
|
||||
rp_inventory_defaults={'allocation_ratio': 1.0, 'min_unit': 5},
|
||||
rp_hypervisors=['br-ext1:compute1', 'br-ext2:compute2'])
|
||||
self.plugin_driver._sb_idl.chassis_list.return_value.execute.\
|
||||
return_value = [chassis]
|
||||
init_conf = self.placement_driver._read_initial_chassis_config()
|
||||
expected = {chassis.name: {
|
||||
n_const.RP_BANDWIDTHS: {
|
||||
'br-ext1': {'egress': 1000, 'ingress': 2000}},
|
||||
n_const.RP_INVENTORY_DEFAULTS: {
|
||||
'allocation_ratio': 1.0, 'min_unit': 5},
|
||||
ovn_const.RP_HYPERVISORS: {
|
||||
'br-ext1': {'name': 'compute1', 'uuid': 'uuid1'},
|
||||
'br-ext2': {'name': 'compute2', 'uuid': 'uuid2'}}
|
||||
}}
|
||||
self.assertEqual(expected, init_conf)
|
||||
|
||||
# Add an unknown hypervisor, that is not present in the Placement list
|
||||
# of resource providers. This hypervisor is discarded (wrong
|
||||
# configuration). Because "br-ext2" has no match with an existing
|
||||
# hypervisor, is discarded too.
|
||||
chassis = fakes.FakeChassis.create(
|
||||
bridge_mappings=['public1:br-ext1', 'public2:br-ext2'],
|
||||
rp_bandwidths=['br-ext1:1000:2000', 'br-ext2:3000:4000'],
|
||||
rp_inventory_defaults={'allocation_ratio': 1.0, 'min_unit': 5},
|
||||
rp_hypervisors=['br-ext1:compute1', 'br-ext2:compute3'])
|
||||
self.plugin_driver._sb_idl.chassis_list.return_value.execute.\
|
||||
return_value = [chassis]
|
||||
init_conf = self.placement_driver._read_initial_chassis_config()
|
||||
expected = {chassis.name: {
|
||||
n_const.RP_BANDWIDTHS: {
|
||||
'br-ext1': {'egress': 1000, 'ingress': 2000}},
|
||||
n_const.RP_INVENTORY_DEFAULTS: {
|
||||
'allocation_ratio': 1.0, 'min_unit': 5},
|
||||
ovn_const.RP_HYPERVISORS: {
|
||||
'br-ext1': {'name': 'compute1', 'uuid': 'uuid1'}}
|
||||
}}
|
||||
self.assertEqual(expected, init_conf)
|
||||
|
||||
# Missing bridge mapping for br-ext2, the RP for this bridge will be
|
||||
# discarded.
|
||||
chassis = fakes.FakeChassis.create(
|
||||
bridge_mappings=['public1:br-ext1'],
|
||||
rp_bandwidths=['br-ext1:1000:2000', 'br-ext2:3000:4000'],
|
||||
rp_inventory_defaults={'allocation_ratio': 1.0, 'min_unit': 5},
|
||||
rp_hypervisors=['br-ext1:compute1', 'br-ext2:compute2'])
|
||||
self.plugin_driver._sb_idl.chassis_list.return_value.execute.\
|
||||
return_value = [chassis]
|
||||
init_conf = self.placement_driver._read_initial_chassis_config()
|
||||
expected = {chassis.name: {
|
||||
n_const.RP_BANDWIDTHS: {
|
||||
'br-ext1': {'egress': 1000, 'ingress': 2000}},
|
||||
n_const.RP_INVENTORY_DEFAULTS: {
|
||||
'allocation_ratio': 1.0, 'min_unit': 5},
|
||||
ovn_const.RP_HYPERVISORS: {
|
||||
'br-ext1': {'name': 'compute1', 'uuid': 'uuid1'},
|
||||
'br-ext2': {'name': 'compute2', 'uuid': 'uuid2'}}
|
||||
}}
|
||||
self.assertEqual(expected, init_conf)
|
||||
|
||||
# No bridge mappings, no RP BW inventories.
|
||||
chassis = fakes.FakeChassis.create(
|
||||
bridge_mappings=None,
|
||||
rp_bandwidths=['br-ext1:1000:2000', 'br-ext2:3000:4000'],
|
||||
rp_inventory_defaults={'allocation_ratio': 1.0, 'min_unit': 5},
|
||||
rp_hypervisors=['br-ext1:compute1', 'br-ext2:compute2'])
|
||||
self.plugin_driver._sb_idl.chassis_list.return_value.execute.\
|
||||
return_value = [chassis]
|
||||
init_conf = self.placement_driver._read_initial_chassis_config()
|
||||
expected = {chassis.name: {
|
||||
n_const.RP_BANDWIDTHS: {},
|
||||
n_const.RP_INVENTORY_DEFAULTS: {
|
||||
'allocation_ratio': 1.0, 'min_unit': 5},
|
||||
ovn_const.RP_HYPERVISORS: {
|
||||
'br-ext1': {'name': 'compute1', 'uuid': 'uuid1'},
|
||||
'br-ext2': {'name': 'compute2', 'uuid': 'uuid2'}}
|
||||
}}
|
||||
self.assertEqual(expected, init_conf)
|
||||
|
||||
# No bridge mappings nor hypervisors, no RP BW inventories.
|
||||
chassis = fakes.FakeChassis.create(
|
||||
bridge_mappings=None,
|
||||
rp_bandwidths=['br-ext1:1000:2000', 'br-ext2:3000:4000'],
|
||||
rp_inventory_defaults={'allocation_ratio': 1.0, 'min_unit': 5},
|
||||
rp_hypervisors=None)
|
||||
self.plugin_driver._sb_idl.chassis_list.return_value.execute.\
|
||||
return_value = [chassis]
|
||||
init_conf = self.placement_driver._read_initial_chassis_config()
|
||||
expected = {chassis.name: {
|
||||
n_const.RP_BANDWIDTHS: {},
|
||||
n_const.RP_INVENTORY_DEFAULTS: {
|
||||
'allocation_ratio': 1.0, 'min_unit': 5},
|
||||
ovn_const.RP_HYPERVISORS: {}
|
||||
}}
|
||||
self.assertEqual(expected, init_conf)
|
||||
|
||||
# If no RP BW information (any deployment not using it), OVN Placement
|
||||
# extension won't break anything (sorry for LP#1936983, that was me).
|
||||
chassis = fakes.FakeChassis.create(
|
||||
bridge_mappings=['public1:br-ext1'],
|
||||
rp_bandwidths=None,
|
||||
rp_inventory_defaults=None,
|
||||
rp_hypervisors=None)
|
||||
self.plugin_driver._sb_idl.chassis_list.return_value.execute.\
|
||||
return_value = [chassis]
|
||||
init_conf = self.placement_driver._read_initial_chassis_config()
|
||||
expected = {chassis.name: {
|
||||
n_const.RP_BANDWIDTHS: {},
|
||||
n_const.RP_INVENTORY_DEFAULTS: {},
|
||||
ovn_const.RP_HYPERVISORS: {}
|
||||
}}
|
||||
self.assertEqual(expected, init_conf)
|
||||
|
||||
# Test wrongly defined parameters. E.g.:
|
||||
# external_ids: {ovn-cms-options={resource_provider_bandwidths=, ...}}
|
||||
chassis = fakes.FakeChassis.create(
|
||||
bridge_mappings=['public1:br-ext1'],
|
||||
rp_bandwidths='',
|
||||
rp_inventory_defaults='',
|
||||
rp_hypervisors='')
|
||||
self.plugin_driver._sb_idl.chassis_list.return_value.execute.\
|
||||
return_value = [chassis]
|
||||
init_conf = self.placement_driver._read_initial_chassis_config()
|
||||
expected = {chassis.name: {
|
||||
n_const.RP_BANDWIDTHS: {},
|
||||
n_const.RP_INVENTORY_DEFAULTS: {},
|
||||
ovn_const.RP_HYPERVISORS: {}
|
||||
}}
|
||||
self.assertEqual(expected, init_conf)
|
Loading…
Reference in New Issue