diff --git a/doc/source/admin/config-qos-min-bw.rst b/doc/source/admin/config-qos-min-bw.rst index b21ef880828..36123d16e9b 100644 --- a/doc/source/admin/config-qos-min-bw.rst +++ b/doc/source/admin/config-qos-min-bw.rst @@ -276,6 +276,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 ----------------------------------- diff --git a/neutron/common/ovn/constants.py b/neutron/common/ovn/constants.py index 5d3ecdd72a2..b5d8f11390a 100644 --- a/neutron/common/ovn/constants.py +++ b/neutron/common/ovn/constants.py @@ -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, + ] diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py index e841886bd34..30c6f8f1074 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py @@ -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 diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/placement.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/placement.py new file mode 100644 index 00000000000..8a055876a0f --- /dev/null +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/placement.py @@ -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 diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py index 9ad4db808ac..ee3e7b1ebc3 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_client.py @@ -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 diff --git a/neutron/tests/functional/base.py b/neutron/tests/functional/base.py index 6c9a47d8768..4b8b2d2c36f 100644 --- a/neutron/tests/functional/base.py +++ b/neutron/tests/functional/base.py @@ -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 diff --git a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_placement.py b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_placement.py new file mode 100644 index 00000000000..0add9093e17 --- /dev/null +++ b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_placement.py @@ -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}) diff --git a/neutron/tests/unit/fake_resources.py b/neutron/tests/unit/fake_resources.py index 3934b157c5c..e7fa8b43b60 100644 --- a/neutron/tests/unit/fake_resources.py +++ b/neutron/tests/unit/fake_resources.py @@ -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, diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_placement.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_placement.py new file mode 100644 index 00000000000..430ccbab484 --- /dev/null +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/extensions/test_placement.py @@ -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)