diff --git a/lower-constraints.txt b/lower-constraints.txt index 44a0e4b49c3..400a24c75a7 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -50,7 +50,7 @@ msgpack-python==0.4.0 munch==2.1.0 netaddr==0.7.18 netifaces==0.10.4 -neutron-lib==2.18.0 +neutron-lib==2.20.0 openstacksdk==0.31.2 os-client-config==1.28.0 os-ken==2.2.0 diff --git a/neutron/common/ovn/constants.py b/neutron/common/ovn/constants.py index d285cb01afd..c4a85361678 100644 --- a/neutron/common/ovn/constants.py +++ b/neutron/common/ovn/constants.py @@ -49,31 +49,6 @@ OVN_LIVENESS_CHECK_EXT_ID_KEY = 'neutron:liveness_check_at' METADATA_LIVENESS_CHECK_EXT_ID_KEY = 'neutron:metadata_liveness_check_at' OVN_PORT_BINDING_PROFILE = portbindings.PROFILE -# Port Binding Profile data validation -# -# To allow for validating multiple parameter sets that may contain some of the -# same keys, you can specify for which vnic_type and capability the parameter -# set is valid for. -# -# By leaving vnic_type and capability to the default of 'None' any parameter -# set that has a key which is present in the port binding data will be used for -# validation. -# -# The param_set type is Dict[str,Optional[List[any]]] where the key is used to -# match keys in the port binding data. A value of 'None' means not to check -# type for this key, when a list of type classes is provided the data will be -# validated to be of one of the listed types. -OVNPortBindingProfileParamSet = collections.namedtuple( - 'OVNPortBindingProfileParamSet', ['param_set', 'vnic_type', 'capability']) -OVN_PORT_BINDING_PROFILE_PARAMS = [ - OVNPortBindingProfileParamSet({'parent_name': [str], - 'tag': [int]}, - None, None), - OVNPortBindingProfileParamSet({'vtep-physical-switch': [str], - 'vtep-logical-switch': [str]}, - None, None), -] - MIGRATING_ATTR = 'migrating_to' OVN_ROUTER_PORT_OPTION_KEYS = ['router-port', 'nat-addresses'] OVN_GATEWAY_CHASSIS_KEY = 'redirect-chassis' @@ -295,6 +270,69 @@ UNKNOWN_ADDR = 'unknown' PORT_CAP_SWITCHDEV = 'switchdev' PORT_CAP_PARAM = 'capabilities' +VIF_DETAILS_PCI_VENDOR_INFO = 'pci_vendor_info' +VIF_DETAILS_PCI_SLOT = 'pci_slot' +VIF_DETAILS_PHYSICAL_NETWORK = 'physical_network' +VIF_DETAILS_CARD_SERIAL_NUMBER = 'card_serial_number' +VIF_DETAILS_PF_MAC_ADDRESS = 'pf_mac_address' +VIF_DETAILS_VF_NUM = 'vf_num' + +# Port Binding Profile data validation +# +# To allow for validating multiple parameter sets that may contain some of the +# same keys, you can specify for which vnic_type and capability the parameter +# set is valid for. +# +# By leaving vnic_type and capability to the default of 'None' any parameter +# set that has a key which is present in the port binding data will be used for +# validation. +# +# The param_set type is Dict[str,Optional[List[any]]] where the key is used to +# match keys in the port binding data. A value of 'None' means not to check +# type for this key, when a list of type classes is provided the data will be +# validated to be of one of the listed types. +OVNPortBindingProfileParamSet = collections.namedtuple( + 'OVNPortBindingProfileParamSet', ['param_set', 'vnic_type', 'capability']) +OVN_PORT_BINDING_PROFILE_PARAMS = [ + OVNPortBindingProfileParamSet({'parent_name': [str], + 'tag': [int]}, + None, None), + OVNPortBindingProfileParamSet({'vtep-physical-switch': [str], + 'vtep-logical-switch': [str]}, + None, None), + # For the two supported switchdev modes the data provided in the binding + # profile is similar to what is used for Legacy SR-IOV. However, the + # `physical_network` value type is Union[str,None]. When a port is + # attached to a project network backed by an overlay (tunneled) network the + # value will be 'None'. For the case of ports attached to a project + # network backed by VLAN the value will be of type `str` and set to the + # value provided in the `physical_network` tag in the Nova PCI Passthrough + # configuration. + # + # Note that while the OVN driver provides services to Legacy SR-IOV + # instances through the creation of external ports for DHCP and Metadata, + # it does not bind the instance ports themselves. Thus there is no + # parameter set for them here. + # + # Switchdev capable device exposed on the hypervisor host. + OVNPortBindingProfileParamSet({VIF_DETAILS_PCI_VENDOR_INFO: [str], + VIF_DETAILS_PCI_SLOT: [str], + VIF_DETAILS_PHYSICAL_NETWORK: [str, + type(None)]}, + portbindings.VNIC_DIRECT, + PORT_CAP_SWITCHDEV), + # SmartNIC DPU. Switchdev capable device exposed on the SmartNIC DPU + # control plane CPUs. + OVNPortBindingProfileParamSet({VIF_DETAILS_PCI_VENDOR_INFO: [str], + VIF_DETAILS_PCI_SLOT: [str], + VIF_DETAILS_PHYSICAL_NETWORK: [str, + type(None)], + VIF_DETAILS_CARD_SERIAL_NUMBER: [str], + VIF_DETAILS_PF_MAC_ADDRESS: [str], + VIF_DETAILS_VF_NUM: [int]}, + portbindings.VNIC_REMOTE_MANAGED, + None), +] # The name of the port security group attribute is currently not in neutron nor # neutron-lib api definitions or constants. To avoid importing the extension @@ -308,6 +346,11 @@ LSP_TYPE_EXTERNAL = 'external' LSP_TYPE_LOCALPORT = 'localport' LSP_OPTIONS_VIRTUAL_PARENTS_KEY = 'virtual-parents' LSP_OPTIONS_VIRTUAL_IP_KEY = 'virtual-ip' +LSP_OPTIONS_VIF_PLUG_TYPE_KEY = 'vif-plug-type' +LSP_OPTIONS_VIF_PLUG_MTU_REQUEST_KEY = 'vif-plug-mtu-request' +LSP_OPTIONS_VIF_PLUG_REPRESENTOR_PF_MAC_KEY = 'vif-plug:representor:pf-mac' +LSP_OPTIONS_VIF_PLUG_REPRESENTOR_VF_NUM_KEY = 'vif-plug:representor:vf-num' +LSP_OPTIONS_REQUESTED_CHASSIS_KEY = 'requested-chassis' LSP_OPTIONS_MCAST_FLOOD_REPORTS = 'mcast_flood_reports' LSP_OPTIONS_MCAST_FLOOD = 'mcast_flood' @@ -332,6 +375,7 @@ NEUTRON_AVAILABILITY_ZONES = 'neutron-availability-zones' OVN_CMS_OPTIONS = 'ovn-cms-options' CMS_OPT_CHASSIS_AS_GW = 'enable-chassis-as-gw' CMS_OPT_AVAILABILITY_ZONES = 'availability-zones' +CMS_OPT_CARD_SERIAL_NUMBER = 'card-serial-number' # OVN vlan transparency option VLAN_PASSTHRU = 'vlan-passthru' @@ -347,4 +391,5 @@ OVN_SUPPORTED_VNIC_TYPES = [portbindings.VNIC_NORMAL, portbindings.VNIC_DIRECT_PHYSICAL, portbindings.VNIC_MACVTAP, portbindings.VNIC_VHOST_VDPA, + portbindings.VNIC_REMOTE_MANAGED, ] 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 c5c5ce876f9..93be2619fa0 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py @@ -926,14 +926,29 @@ class OVNMechanismDriver(api.MechanismDriver): # cannot be found. chassis_physnets = [] try: + # The PortContext host property contains special handling that + # we need to take into account, thus passing both the port Dict + # and the PortContext instance so that the helper can decide + # which to use. + bind_host = self._ovn_client.determine_bind_host( + port, + port_context=context) datapath_type, iface_types, chassis_physnets = ( - self.sb_ovn.get_chassis_data_for_ml2_bind_port(context.host)) + self.sb_ovn.get_chassis_data_for_ml2_bind_port(bind_host)) iface_types = iface_types.split(',') if iface_types else [] except RuntimeError: LOG.debug('Refusing to bind port %(port_id)s due to ' 'no OVN chassis for host: %(host)s', - {'port_id': port['id'], 'host': context.host}) + {'port_id': port['id'], 'host': bind_host}) return + except n_exc.InvalidInput as e: + # The port binding profile is validated both on port creation and + # update. The new rules apply to a VNIC type previously not + # consumed by the OVN mechanism driver, so this should never + # happen. + LOG.error('Validation of binding profile unexpectedly failed ' + 'while attempting to bind port %s', port['id']) + raise e for segment_to_bind in context.segments_to_bind: network_type = segment_to_bind['network_type'] @@ -944,7 +959,7 @@ class OVNMechanismDriver(api.MechanismDriver): 'segmentation ID %(segmentation_id)s, ' 'physical network %(physical_network)s', {'port_id': port['id'], - 'host': context.host, + 'host': bind_host, 'network_type': network_type, 'segmentation_id': segmentation_id, 'physical_network': physical_network}) @@ -967,7 +982,7 @@ class OVNMechanismDriver(api.MechanismDriver): '%(chassis_physnets)s not supporting ' 'physical network: %(physical_network)s', {'port_id': port['id'], - 'host': context.host, + 'host': bind_host, 'chassis_physnets': chassis_physnets, 'physical_network': physical_network}) else: diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py index 1357b540984..d2ed6b06413 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/impl_idl_ovn.py @@ -858,6 +858,21 @@ class OvsdbSbOvnIdl(sb_impl_idl.OvnSbApiIdlImpl, Backend): # preference patch (as part of external ids) merges. return [c.name for c in self.chassis_list().execute(check_error=True)] + def get_chassis_by_card_serial_from_cms_options(self, + card_serial_number): + for ch in self.chassis_list().execute(check_error=True): + if ('{}={}' + .format(ovn_const.CMS_OPT_CARD_SERIAL_NUMBER, + card_serial_number) + in ch.external_ids.get( + ovn_const.OVN_CMS_OPTIONS, '').split(',')): + return ch + msg = _('Chassis with %s %s %s does not exist' + ) % (ovn_const.OVN_CMS_OPTIONS, + ovn_const.CMS_OPT_CARD_SERIAL_NUMBER, + card_serial_number) + raise RuntimeError(msg) + def get_chassis_data_for_ml2_bind_port(self, hostname): try: cmd = self.db_find_rows('Chassis', ('hostname', '=', hostname)) 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 3455b0e3354..e0304fadf9f 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 @@ -222,9 +222,52 @@ class OVNClient(object): if lsp.name != port['id'] and virtual_ip in utils.get_ovn_port_addresses(lsp)] + def determine_bind_host(self, port, port_context=None): + """Determine which host the port should be bound to. + + Traditionally it has been Nova's responsibility to create Virtual + Interfaces (VIFs) as part of instance life cycle, and subsequently + manage plug/unplug operations on the Open vSwitch integration bridge. + For the traditional topology the bind host will be the same as the + hypervisor hosting the instance. + + With the advent of SmartNIC DPUs which are connected to multiple + distinct CPUs we can have a topology where the instance runs on one + host and Open vSwitch and OVN runs on a different host, the SmartNIC + DPU control plane CPU. In the SmartNIC DPU topology the bind host will + be different than the hypervisor host. + + This helper accepts both a port Dict and optionally a PortContext + instance so that it can be used both before and after a port is bound. + + :param port: Port Dictionary + :type port: Dict[str,any] + :param port_context: PortContext instance describing the port + :type port_context: api.PortContext + :returns: FQDN or Hostname to bind port to. + :rtype: str + :raises: n_exc.InvalidInput, RuntimeError + """ + # Note that we use port_context.host below when called from bind_port + port = port_context.current if port_context else port + vnic_type = port.get(portbindings.VNIC_TYPE, portbindings.VNIC_NORMAL) + if vnic_type != portbindings.VNIC_REMOTE_MANAGED: + # The ``PortContext`` ``host`` property contains handling of + # special cases. + return port_context.host if port_context else port.get( + portbindings.HOST_ID, '') + + binding_prof = utils.validate_and_get_data_from_binding_profile(port) + if ovn_const.VIF_DETAILS_CARD_SERIAL_NUMBER in binding_prof: + return self._sb_idl.get_chassis_by_card_serial_from_cms_options( + binding_prof[ + ovn_const.VIF_DETAILS_CARD_SERIAL_NUMBER]).hostname + return '' + def _get_port_options(self, port): context = n_context.get_admin_context() binding_prof = utils.validate_and_get_data_from_binding_profile(port) + vnic_type = port.get(portbindings.VNIC_TYPE, portbindings.VNIC_NORMAL) vtep_physical_switch = binding_prof.get('vtep-physical-switch') port_type = '' @@ -301,8 +344,22 @@ class OVNClient(object): # HA Chassis Group will bind the port to the highest # priority Chassis if port_type != ovn_const.LSP_TYPE_EXTERNAL: - options.update({'requested-chassis': - port.get(portbindings.HOST_ID, '')}) + if (vnic_type == portbindings.VNIC_REMOTE_MANAGED and + ovn_const.VIF_DETAILS_PF_MAC_ADDRESS in binding_prof): + port_net = self._plugin.get_network( + context, port['network_id']) + options.update({ + ovn_const.LSP_OPTIONS_VIF_PLUG_TYPE_KEY: 'representor', + ovn_const.LSP_OPTIONS_VIF_PLUG_MTU_REQUEST_KEY: str( + port_net['mtu']), + ovn_const.LSP_OPTIONS_VIF_PLUG_REPRESENTOR_PF_MAC_KEY: ( + binding_prof.get( + ovn_const.VIF_DETAILS_PF_MAC_ADDRESS)), + ovn_const.LSP_OPTIONS_VIF_PLUG_REPRESENTOR_VF_NUM_KEY: str( + binding_prof.get(ovn_const.VIF_DETAILS_VF_NUM))}) + options.update({ + ovn_const.LSP_OPTIONS_REQUESTED_CHASSIS_KEY: ( + self.determine_bind_host(port))}) # TODO(lucasagomes): Enable the mcast_flood_reports by default, # according to core OVN developers it shouldn't cause any harm diff --git a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py index 7f7b48b5260..8902985c797 100644 --- a/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py +++ b/neutron/tests/functional/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py @@ -61,6 +61,9 @@ class TestPortBinding(base.TestOVNFunctionalBase): self.ovs_host = 'ovs-host' self.dpdk_host = 'dpdk-host' self.invalid_dpdk_host = 'invalid-host' + self.insecure_host = 'insecure-host' + self.smartnic_dpu_host = 'smartnic-dpu-host' + self.smartnic_dpu_serial = 'fake-smartnic-dpu-serial' self.add_fake_chassis(self.ovs_host) self.add_fake_chassis( self.dpdk_host, @@ -71,32 +74,39 @@ class TestPortBinding(base.TestOVNFunctionalBase): self.invalid_dpdk_host, external_ids={'datapath-type': 'netdev', 'iface-types': 'dummy,dummy-internal,geneve,vxlan'}) + self.add_fake_chassis( + self.smartnic_dpu_host, + external_ids={ovn_const.OVN_CMS_OPTIONS: '{}={}'.format( + ovn_const.CMS_OPT_CARD_SERIAL_NUMBER, + self.smartnic_dpu_serial)}) self.n1 = self._make_network(self.fmt, 'n1', True) res = self._create_subnet(self.fmt, self.n1['network']['id'], '10.0.0.0/24') self.deserialize(self.fmt, res) - def _create_or_update_port(self, port_id=None, hostname=None): + def _create_or_update_port(self, port_id=None, hostname=None, + vnic_type=None, binding_profile=None): + + port_data = {'port': {}} + if hostname: + port_data['port']['device_id'] = uuidutils.generate_uuid() + port_data['port']['device_owner'] = 'compute:None' + port_data['port']['binding:host_id'] = hostname + if vnic_type: + port_data['port'][portbindings.VNIC_TYPE] = vnic_type + if binding_profile: + port_data['port'][portbindings.PROFILE] = binding_profile if port_id is None: - port_data = { - 'port': {'network_id': self.n1['network']['id'], - 'tenant_id': self._tenant_id}} - - if hostname: - port_data['port']['device_id'] = uuidutils.generate_uuid() - port_data['port']['device_owner'] = 'compute:None' - port_data['port']['binding:host_id'] = hostname + port_data['port'].update({ + 'network_id': self.n1['network']['id'], + 'tenant_id': self._tenant_id}) port_req = self.new_create_request('ports', port_data, self.fmt) port_res = port_req.get_response(self.api) p = self.deserialize(self.fmt, port_res) port_id = p['port']['id'] else: - port_data = { - 'port': {'device_id': uuidutils.generate_uuid(), - 'device_owner': 'compute:None', - 'binding:host_id': hostname}} port_req = self.new_update_request('ports', port_data, port_id, self.fmt) port_res = port_req.get_response(self.api) @@ -104,16 +114,32 @@ class TestPortBinding(base.TestOVNFunctionalBase): return port_id - def _verify_vif_details(self, port_id, expected_host_name, - expected_vif_type, expected_vif_details): + def _port_show(self, port_id): port_req = self.new_show_request('ports', port_id) port_res = port_req.get_response(self.api) - p = self.deserialize(self.fmt, port_res) + return self.deserialize(self.fmt, port_res) + + def _verify_vif_details(self, port_id, expected_host_name, + expected_vif_type, expected_vif_details): + p = self._port_show(port_id) self.assertEqual(expected_host_name, p['port']['binding:host_id']) self.assertEqual(expected_vif_type, p['port']['binding:vif_type']) self.assertEqual(expected_vif_details, p['port']['binding:vif_details']) + def _find_port_row(self, port_id): + cmd = self.nb_api.db_find_rows( + 'Logical_Switch_Port', ('name', '=', port_id)) + rows = cmd.execute(check_error=True) + return rows[0] if rows else None + + def _verify_lsp_details(self, port_id, lsp_options): + ovn_lsp = self._find_port_row(port_id) + for key, value in lsp_options.items(): + self.assertEqual( + value, + ovn_lsp.options[key]) + def test_port_binding_create_port(self): port_id = self._create_or_update_port(hostname=self.ovs_host) self._verify_vif_details(port_id, self.ovs_host, 'ovs', @@ -130,6 +156,38 @@ class TestPortBinding(base.TestOVNFunctionalBase): self._verify_vif_details(port_id, self.invalid_dpdk_host, 'ovs', OVS_VIF_DETAILS) + def test_port_binding_create_remote_managed_port(self): + pci_vendor_info = 'fake-pci-vendor-info' + pci_slot = 'fake-pci-slot' + physical_network = None + pf_mac_address = 'fake-pf-mac' + vf_num = 42 + port_id = self._create_or_update_port( + hostname=self.insecure_host, + vnic_type=portbindings.VNIC_REMOTE_MANAGED, + binding_profile={ + ovn_const.VIF_DETAILS_PCI_VENDOR_INFO: pci_vendor_info, + ovn_const.VIF_DETAILS_PCI_SLOT: pci_slot, + ovn_const.VIF_DETAILS_PHYSICAL_NETWORK: physical_network, + ovn_const.VIF_DETAILS_CARD_SERIAL_NUMBER: ( + self.smartnic_dpu_serial), + ovn_const.VIF_DETAILS_PF_MAC_ADDRESS: pf_mac_address, + ovn_const.VIF_DETAILS_VF_NUM: vf_num, + }) + + self._verify_vif_details(port_id, self.insecure_host, 'ovs', + OVS_VIF_DETAILS) + self._verify_lsp_details(port_id, { + ovn_const.LSP_OPTIONS_REQUESTED_CHASSIS_KEY: ( + self.smartnic_dpu_host), + ovn_const.LSP_OPTIONS_VIF_PLUG_TYPE_KEY: 'representor', + ovn_const.LSP_OPTIONS_VIF_PLUG_MTU_REQUEST_KEY: str( + self.n1['network']['mtu']), + ovn_const.LSP_OPTIONS_VIF_PLUG_REPRESENTOR_PF_MAC_KEY: ( + pf_mac_address), + ovn_const.LSP_OPTIONS_VIF_PLUG_REPRESENTOR_VF_NUM_KEY: str(vf_num), + }) + def test_port_binding_update_port(self): port_id = self._create_or_update_port() self._verify_vif_details(port_id, '', 'unbound', {}) @@ -151,6 +209,42 @@ class TestPortBinding(base.TestOVNFunctionalBase): self._verify_vif_details(port_id, self.invalid_dpdk_host, 'ovs', OVS_VIF_DETAILS) + def test_port_binding_update_remote_managed_port(self): + port_id = self._create_or_update_port( + vnic_type=portbindings.VNIC_REMOTE_MANAGED) + self._verify_vif_details(port_id, '', 'unbound', {}) + + pci_vendor_info = 'fake-pci-vendor-info' + pci_slot = 'fake-pci-slot' + physical_network = None + pf_mac_address = 'fake-pf-mac' + vf_num = 42 + port_id = self._create_or_update_port( + port_id=port_id, + hostname=self.insecure_host, + vnic_type=portbindings.VNIC_REMOTE_MANAGED, + binding_profile={ + ovn_const.VIF_DETAILS_PCI_VENDOR_INFO: pci_vendor_info, + ovn_const.VIF_DETAILS_PCI_SLOT: pci_slot, + ovn_const.VIF_DETAILS_PHYSICAL_NETWORK: physical_network, + ovn_const.VIF_DETAILS_CARD_SERIAL_NUMBER: ( + self.smartnic_dpu_serial), + ovn_const.VIF_DETAILS_PF_MAC_ADDRESS: pf_mac_address, + ovn_const.VIF_DETAILS_VF_NUM: vf_num, + }) + self._verify_vif_details(port_id, self.insecure_host, 'ovs', + OVS_VIF_DETAILS) + self._verify_lsp_details(port_id, { + ovn_const.LSP_OPTIONS_REQUESTED_CHASSIS_KEY: ( + self.smartnic_dpu_host), + ovn_const.LSP_OPTIONS_VIF_PLUG_TYPE_KEY: 'representor', + ovn_const.LSP_OPTIONS_VIF_PLUG_MTU_REQUEST_KEY: str( + self.n1['network']['mtu']), + ovn_const.LSP_OPTIONS_VIF_PLUG_REPRESENTOR_PF_MAC_KEY: ( + pf_mac_address), + ovn_const.LSP_OPTIONS_VIF_PLUG_REPRESENTOR_VF_NUM_KEY: str(vf_num), + }) + class TestPortBindingOverTcp(TestPortBinding): def get_ovsdb_server_protocol(self): diff --git a/neutron/tests/unit/common/ovn/test_utils.py b/neutron/tests/unit/common/ovn/test_utils.py index 43abc256009..e4cfef36091 100644 --- a/neutron/tests/unit/common/ovn/test_utils.py +++ b/neutron/tests/unit/common/ovn/test_utils.py @@ -540,6 +540,50 @@ class TestValidateAndGetDataFromBindingProfile(base.BaseTestCase): utils.validate_and_get_data_from_binding_profile( {constants.OVN_PORT_BINDING_PROFILE: expect})) + binding_profile = { + constants.PORT_CAP_PARAM: [constants.PORT_CAP_SWITCHDEV], + 'pci_vendor_info': 'dead:beef', + 'pci_slot': '0000:ca:fe.42', + 'physical_network': 'physnet1', + + } + expect = binding_profile.copy() + del(expect[constants.PORT_CAP_PARAM]) + self.assertDictEqual( + expect, + utils.validate_and_get_data_from_binding_profile( + {portbindings.VNIC_TYPE: portbindings.VNIC_DIRECT, + constants.OVN_PORT_BINDING_PROFILE: binding_profile})) + + binding_profile = { + constants.PORT_CAP_PARAM: [constants.PORT_CAP_SWITCHDEV], + 'pci_vendor_info': 'dead:beef', + 'pci_slot': '0000:ca:fe.42', + 'physical_network': None, + + } + expect = binding_profile.copy() + del(expect[constants.PORT_CAP_PARAM]) + self.assertDictEqual( + expect, + utils.validate_and_get_data_from_binding_profile( + {portbindings.VNIC_TYPE: portbindings.VNIC_DIRECT, + constants.OVN_PORT_BINDING_PROFILE: binding_profile})) + + expect = { + 'pci_vendor_info': 'dead:beef', + 'pci_slot': '0000:ca:fe.42', + 'physical_network': 'physnet1', + 'card_serial_number': 'AB2000X00042', + 'pf_mac_address': '00:53:00:00:00:42', + 'vf_num': 42, + } + self.assertDictEqual( + utils.validate_and_get_data_from_binding_profile( + {portbindings.VNIC_TYPE: portbindings.VNIC_REMOTE_MANAGED, + constants.OVN_PORT_BINDING_PROFILE: expect}), + expect) + def test_unknown_profile_items_pruned(self): # Confirm that unknown profile items are pruned self.assertEqual( diff --git a/neutron/tests/unit/fake_resources.py b/neutron/tests/unit/fake_resources.py index 0251e5eec92..492a3216dcf 100644 --- a/neutron/tests/unit/fake_resources.py +++ b/neutron/tests/unit/fake_resources.py @@ -181,6 +181,7 @@ class FakeOvsdbSbOvnIdl(object): self.chassis_list = mock.MagicMock() self.is_table_present = mock.Mock() self.is_table_present.return_value = False + self.get_chassis_by_card_serial_from_cms_options = mock.Mock() class FakeOvsdbTransaction(object): @@ -835,7 +836,8 @@ class FakeChassis(object): @staticmethod def create(attrs=None, az_list=None, chassis_as_gw=False, bridge_mappings=None, rp_bandwidths=None, - rp_inventory_defaults=None, rp_hypervisors=None): + rp_inventory_defaults=None, rp_hypervisors=None, + card_serial_number=None): cms_opts = [] if az_list: cms_opts.append("%s=%s" % (ovn_const.CMS_OPT_AVAILABILITY_ZONES, @@ -863,6 +865,10 @@ class FakeChassis(object): elif rp_hypervisors == '': # Test wrongly defined parameter cms_opts.append('%s=' % ovn_const.RP_HYPERVISORS) + if card_serial_number: + cms_opts.append('%s=%s' % (ovn_const.CMS_OPT_CARD_SERIAL_NUMBER, + card_serial_number)) + external_ids = {} if cms_opts: external_ids[ovn_const.OVN_CMS_OPTIONS] = ','.join(cms_opts) @@ -870,7 +876,7 @@ class FakeChassis(object): if bridge_mappings: external_ids['ovn-bridge-mappings'] = ','.join(bridge_mappings) - attrs = { + chassis_attrs = { 'encaps': [], 'external_ids': external_ids, 'hostname': '', @@ -881,5 +887,5 @@ class FakeChassis(object): 'vtep_logical_switches': []} # Overwrite default attributes. - attrs.update(attrs) - return type('Chassis', (object, ), attrs) + chassis_attrs.update(attrs or {}) + return type('Chassis', (object, ), chassis_attrs) diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_impl_idl_ovn.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_impl_idl_ovn.py index 1b62ad4c8f1..8dba2de7be8 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_impl_idl_ovn.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_impl_idl_ovn.py @@ -792,3 +792,61 @@ class TestNBImplIdlOvn(TestDBImplIdlOvn): lb_row = self._find_ovsdb_fake_row(self.lb_table, 'name', 'lb_2') lb = self.nb_ovn_idl.get_floatingip_in_nat_or_lb(fip_id) self.assertEqual(lb['_uuid'], lb_row.uuid) + + +class TestSBImplIdlOvnBase(TestDBImplIdlOvn): + + fake_set = { + 'chassis': [ + { + 'hostname': 'fake-smartnic-dpu-chassis.fqdn', + 'external_ids': { + ovn_const.OVN_CMS_OPTIONS: ( + 'firstoption,' + 'card-serial-number=fake-serial,' + 'thirdoption'), + }, + }, + ], + } + fake_associations = {} + + def setUp(self): + super(TestSBImplIdlOvnBase, self).setUp() + + self.chassis_table = fakes.FakeOvsdbTable.create_one_ovsdb_table() + + self._tables = {} + self._tables['Chassis'] = self.chassis_table + + with mock.patch.object(impl_idl_ovn.OvsdbSbOvnIdl, 'from_worker', + return_value=mock.Mock()): + with mock.patch.object(ovs_idl.Backend, 'autocreate_indices', + create=True): + impl_idl_ovn.OvsdbSbOvnIdl.ovsdb_connection = None + self.sb_ovn_idl = impl_idl_ovn.OvsdbSbOvnIdl(mock.MagicMock()) + + self.sb_ovn_idl.idl.tables = self._tables + + def _load_sb_db(self): + # Load Chassis + fake_chassis = TestSBImplIdlOvnBase.fake_set['chassis'] + self._load_ovsdb_fake_rows(self.chassis_table, fake_chassis) + + +class TestSBImplIdlOvnGetChassisByCardSerialFromCMSOptions( + TestSBImplIdlOvnBase): + + def test_chassis_not_found(self): + self._load_sb_db() + self.assertRaises( + RuntimeError, + self.sb_ovn_idl.get_chassis_by_card_serial_from_cms_options, + 'non-existent') + + def test_chassis_found(self): + self._load_sb_db() + self.assertEqual( + 'fake-smartnic-dpu-chassis.fqdn', + self.sb_ovn_idl.get_chassis_by_card_serial_from_cms_options( + 'fake-serial').hostname) diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py new file mode 100644 index 00000000000..8333d2281be --- /dev/null +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_client.py @@ -0,0 +1,122 @@ +# Copyright 2022 Canonical +# All Rights Reserved. +# +# 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.common.ovn import constants +from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb import ovn_client +from neutron.tests import base +from neutron.tests.unit import fake_resources as fakes +from neutron_lib.api.definitions import portbindings + + +class TestOVNClientBase(base.BaseTestCase): + + def setUp(self): + super(TestOVNClientBase, self).setUp() + self.nb_idl = mock.MagicMock() + self.sb_idl = mock.MagicMock() + self.ovn_client = ovn_client.OVNClient(self.nb_idl, self.sb_idl) + + +class TestOVNClientDetermineBindHost(TestOVNClientBase): + + def setUp(self): + super(TestOVNClientDetermineBindHost, self).setUp() + self.get_chassis_by_card_serial_from_cms_options = ( + self.sb_idl.get_chassis_by_card_serial_from_cms_options) + self.fake_smartnic_hostname = 'fake-chassis-hostname' + self.get_chassis_by_card_serial_from_cms_options.return_value = ( + fakes.FakeChassis.create( + attrs={'hostname': self.fake_smartnic_hostname})) + + def test_vnic_normal_unbound_port(self): + self.assertEqual( + '', + self.ovn_client.determine_bind_host({})) + + def test_vnic_normal_bound_port(self): + port = { + portbindings.HOST_ID: 'fake-binding-host-id', + } + self.assertEqual( + 'fake-binding-host-id', + self.ovn_client.determine_bind_host(port)) + + def test_vnic_normal_port_context(self): + context = mock.MagicMock() + context.host = 'fake-binding-host-id' + self.assertEqual( + 'fake-binding-host-id', + self.ovn_client.determine_bind_host({}, port_context=context)) + + def test_vnic_remote_managed_unbound_port_no_binding_profile(self): + port = { + portbindings.VNIC_TYPE: portbindings.VNIC_REMOTE_MANAGED, + } + self.assertEqual( + '', + self.ovn_client.determine_bind_host(port)) + + def test_vnic_remote_managed_unbound_port(self): + port = { + portbindings.VNIC_TYPE: portbindings.VNIC_REMOTE_MANAGED, + constants.OVN_PORT_BINDING_PROFILE: { + constants.VIF_DETAILS_PCI_VENDOR_INFO: 'fake-pci-vendor-info', + constants.VIF_DETAILS_PCI_SLOT: 'fake-pci-slot', + constants.VIF_DETAILS_PHYSICAL_NETWORK: None, + constants.VIF_DETAILS_CARD_SERIAL_NUMBER: 'fake-serial', + constants.VIF_DETAILS_PF_MAC_ADDRESS: 'fake-pf-mac', + constants.VIF_DETAILS_VF_NUM: 42, + }, + } + self.assertEqual( + self.fake_smartnic_hostname, + self.ovn_client.determine_bind_host(port)) + + def test_vnic_remote_managed_bound_port(self): + port = { + portbindings.VNIC_TYPE: portbindings.VNIC_REMOTE_MANAGED, + portbindings.HOST_ID: 'fake-binding-host-id', + constants.OVN_PORT_BINDING_PROFILE: { + constants.VIF_DETAILS_PCI_VENDOR_INFO: 'fake-pci-vendor-info', + constants.VIF_DETAILS_PCI_SLOT: 'fake-pci-slot', + constants.VIF_DETAILS_PHYSICAL_NETWORK: None, + constants.VIF_DETAILS_CARD_SERIAL_NUMBER: 'fake-serial', + constants.VIF_DETAILS_PF_MAC_ADDRESS: 'fake-pf-mac', + constants.VIF_DETAILS_VF_NUM: 42, + }, + } + self.assertEqual( + self.fake_smartnic_hostname, + self.ovn_client.determine_bind_host(port)) + + def test_vnic_remote_managed_port_context(self): + context = mock.MagicMock() + context.current = { + portbindings.VNIC_TYPE: portbindings.VNIC_REMOTE_MANAGED, + constants.OVN_PORT_BINDING_PROFILE: { + constants.VIF_DETAILS_PCI_VENDOR_INFO: 'fake-pci-vendor-info', + constants.VIF_DETAILS_PCI_SLOT: 'fake-pci-slot', + constants.VIF_DETAILS_PHYSICAL_NETWORK: None, + constants.VIF_DETAILS_CARD_SERIAL_NUMBER: 'fake-serial', + constants.VIF_DETAILS_PF_MAC_ADDRESS: 'fake-pf-mac', + constants.VIF_DETAILS_VF_NUM: 42, + }, + } + context.host = 'fake-binding-host-id' + self.assertEqual( + self.fake_smartnic_hostname, + self.ovn_client.determine_bind_host({}, port_context=context)) diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py index fb770da4a63..12e4a022223 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/test_mech_driver.py @@ -1246,6 +1246,36 @@ class TestOVNMechanismDriver(TestOVNMechanismDriverBase): portbindings.VIF_TYPE_OVS, self.mech_driver.vif_details[portbindings.VIF_TYPE_OVS]) + def _test_bind_port_remote_managed(self, fake_segments): + fake_serial = 'fake-serial' + fake_port = fakes.FakePort.create_one_port( + attrs={'binding:vnic_type': 'remote-managed', + 'binding:profile': { + 'pci_vendor_info': 'fake-pci-vendor-info', + 'pci_slot': 'fake-pci-slot', + 'physical_network': fake_segments[0][ + 'physical_network'], + 'card_serial_number': fake_serial, + 'pf_mac_address': '00:53:00:00:00:42', + 'vf_num': 42}}).info() + fake_smartnic_dpu = 'fake-smartnic-dpu' + ch_smartnic_dpu = fakes.FakeChassis.create( + attrs={'hostname': fake_smartnic_dpu}, + card_serial_number=fake_serial) + + self.sb_ovn.get_chassis_by_card_serial_from_cms_options.\ + return_value = ch_smartnic_dpu + fake_host = 'host' + fake_port_context = fakes.FakePortContext( + fake_port, fake_host, fake_segments) + self.mech_driver.bind_port(fake_port_context) + self.sb_ovn.get_chassis_data_for_ml2_bind_port.assert_called_once_with( + fake_smartnic_dpu) + fake_port_context.set_binding.assert_called_once_with( + fake_segments[0]['id'], + portbindings.VIF_TYPE_OVS, + self.mech_driver.vif_details[portbindings.VIF_TYPE_OVS]) + def test_bind_port_vdpa(self): segment_attrs = {'network_type': 'geneve', 'physical_network': None, @@ -1284,6 +1314,24 @@ class TestOVNMechanismDriver(TestOVNMechanismDriverBase): [fakes.FakeSegment.create_one_segment(attrs=segment_attrs).info()] self._test_bind_port_sriov(fake_segments) + def test_bind_remote_managed_port_geneve(self): + """Test binding a REMOTE_MANAGED port to a geneve segment.""" + segment_attrs = {'network_type': 'geneve', + 'physical_network': None, + 'segmentation_id': 1023} + fake_segments = \ + [fakes.FakeSegment.create_one_segment(attrs=segment_attrs).info()] + self._test_bind_port_remote_managed(fake_segments) + + def test_bind_remote_managed_port_vlan(self): + """Test binding a REMOTE_MANAGED port to a geneve segment.""" + segment_attrs = {'network_type': 'vlan', + 'physical_network': 'fake-physnet', + 'segmentation_id': 42} + fake_segments = \ + [fakes.FakeSegment.create_one_segment(attrs=segment_attrs).info()] + self._test_bind_port_remote_managed(fake_segments) + def test_bind_port_vlan(self): segment_attrs = {'network_type': 'vlan', 'physical_network': 'fake-physnet', @@ -3431,9 +3479,10 @@ class TestOVNMechanismDriverSecurityGroup(MechDriverSetupBase, _, kwargs = self.mech_driver.nb_ovn.create_lswitch_port.call_args self.assertEqual( 1, self.mech_driver.nb_ovn.create_lswitch_port.call_count) - self.assertEqual(ovn_const.LSP_TYPE_EXTERNAL, kwargs['type']) - self.assertEqual(fake_grp, kwargs['ha_chassis_group']) - sync_mock.assert_called_once_with(mock.ANY, net_id, mock.ANY) + if vnic_type in ovn_const.EXTERNAL_PORT_TYPES: + self.assertEqual(ovn_const.LSP_TYPE_EXTERNAL, kwargs['type']) + self.assertEqual(fake_grp, kwargs['ha_chassis_group']) + sync_mock.assert_called_once_with(mock.ANY, net_id, mock.ANY) def test_create_port_with_vnic_direct(self): self._test_create_port_with_vnic_type(portbindings.VNIC_DIRECT) @@ -3446,6 +3495,14 @@ class TestOVNMechanismDriverSecurityGroup(MechDriverSetupBase, self._test_create_port_with_vnic_type( portbindings.VNIC_MACVTAP) + def test_create_port_with_vnic_remote_managed(self): + self._test_create_port_with_vnic_type( + portbindings.VNIC_REMOTE_MANAGED) + # Confirm LSP options are not populated when there is no binding + # profile yet. + _, kwargs = self.mech_driver.nb_ovn.create_lswitch_port.call_args + self.assertNotIn('vif-plug-type', kwargs['options']) + def test_update_port_with_sgs(self): with self.network() as n, self.subnet(n): sg1 = self._create_empty_sg('sg1') @@ -3802,8 +3859,10 @@ class TestOVNVVirtualPort(OVNMechanismDriverTestCase): self.fmt, {'network': self.net}, '10.0.0.1', '10.0.0.0/24')['subnet'] + @mock.patch.object(ovn_client.OVNClient, 'determine_bind_host') @mock.patch.object(ovn_client.OVNClient, 'get_virtual_port_parents') - def test_create_port_with_virtual_type_and_options(self, mock_get_parents): + def test_create_port_with_virtual_type_and_options( + self, mock_get_parents, mock_determine_bind_host): fake_parents = ['parent-0', 'parent-1'] mock_get_parents.return_value = fake_parents port = {'id': 'virt-port', diff --git a/releasenotes/notes/ovn-smartnic-dpu-portbinding-dd0a16bac6d2e59f.yaml b/releasenotes/notes/ovn-smartnic-dpu-portbinding-dd0a16bac6d2e59f.yaml new file mode 100644 index 00000000000..b13a0776235 --- /dev/null +++ b/releasenotes/notes/ovn-smartnic-dpu-portbinding-dd0a16bac6d2e59f.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Add support for VNIC type ``remote-managed`` in OVN. The OVN driver can now + bind remote managed ports to SmartNIC DPUs. SmartNIC DPU portbinding + requires OVN version 21.12 or above, compiled with OVN VIF version 21.12 or + above. diff --git a/requirements.txt b/requirements.txt index 0169731ce0b..d809cdbcfee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ Jinja2>=2.10 # BSD License (3 clause) keystonemiddleware>=5.1.0 # Apache-2.0 netaddr>=0.7.18 # BSD netifaces>=0.10.4 # MIT -neutron-lib>=2.18.0 # Apache-2.0 +neutron-lib>=2.20.0 # Apache-2.0 python-neutronclient>=6.7.0 # Apache-2.0 tenacity>=6.0.0 # Apache-2.0 SQLAlchemy>=1.4.23 # MIT