From d669dff1dcf0fbc2dc7ea0d2e0c88f244a1add4b Mon Sep 17 00:00:00 2001 From: Lucas Alvares Gomes Date: Tue, 12 May 2020 12:02:15 +0100 Subject: [PATCH] [OVN] Add support for router availability zones This patch is adding support for the router_availability_zone extension for Neutron. The OVN driver will now read from the router's availability_zone_hints field and schedule the router ports onto OVN chassis belonging to those AZs. Since the OVN driver does not rely on the L3 agent, this patch does not re-use the configuration option for the agent to configure the availability zone that a Chassis belongs to (even because there's no configuration file in nodes such as networker nodes). Instead, this patch reuses the "ovn-cms-options" field from the local OVSDB to configure the Chassis. The follow syntax has been used: $ ovs-vsctl set Open_VSwitch . external-ids:ovn-cms-options="enable-chassis-as-gw,availability-zones=az0:az1" In the example above, the Chassis has been configured to belong to two AZs: "az0" and "az1". This patch also implements listing the availability zones: $ openstack availability zone list As well as validating the router's availability zone hints: $ openstack router create --availability-zone-hint az0 --availability-zone-hint az1 test_router The above command would fail if there's no "az0" and "az1" configured in any OVN chassis. Documentation for this feature is being written and will be submitted in a separated patch. Partial-Bug: #1881095 Change-Id: I4567f3d541d382b6432c1ab3d35276d81ce71d82 Signed-off-by: Lucas Alvares Gomes --- neutron/common/ovn/constants.py | 6 ++ neutron/common/ovn/extensions.py | 9 ++- neutron/common/ovn/utils.py | 34 ++++++++- .../drivers/ovn/mech_driver/mech_driver.py | 34 ++++++++- .../ovn/mech_driver/ovsdb/ovn_client.py | 35 ++++++--- .../ovn/mech_driver/ovsdb/ovn_db_sync.py | 7 +- neutron/services/ovn_l3/plugin.py | 47 +++++++++++- neutron/tests/unit/common/ovn/test_utils.py | 28 ++++++++ neutron/tests/unit/fake_resources.py | 32 +++++++++ .../ovn/mech_driver/ovsdb/test_ovn_db_sync.py | 6 +- .../ovn/mech_driver/test_mech_driver.py | 72 +++++++++++++++++++ .../tests/unit/services/ovn_l3/test_plugin.py | 64 +++++++++++++++-- ...r-availability-zones-03a802ee19689474.yaml | 6 ++ 13 files changed, 354 insertions(+), 26 deletions(-) create mode 100644 releasenotes/notes/ovn-router-availability-zones-03a802ee19689474.yaml diff --git a/neutron/common/ovn/constants.py b/neutron/common/ovn/constants.py index 512f491f710..23ee4665358 100644 --- a/neutron/common/ovn/constants.py +++ b/neutron/common/ovn/constants.py @@ -25,6 +25,7 @@ OVN_NETWORK_MTU_EXT_ID_KEY = 'neutron:mtu' OVN_PORT_NAME_EXT_ID_KEY = 'neutron:port_name' OVN_PORT_FIP_EXT_ID_KEY = 'neutron:port_fip' OVN_ROUTER_NAME_EXT_ID_KEY = 'neutron:router_name' +OVN_ROUTER_AZ_HINTS_EXT_ID_KEY = 'neutron:availability_zone_hints' OVN_ROUTER_IS_EXT_GW = 'neutron:is_ext_gw' OVN_GW_PORT_EXT_ID_KEY = 'neutron:gw_port_id' OVN_SUBNET_EXT_ID_KEY = 'neutron:subnet_id' @@ -287,3 +288,8 @@ MCAST_FLOOD_UNREGISTERED = 'mcast_flood_unregistered' EXTERNAL_PORT_TYPES = (portbindings.VNIC_DIRECT, portbindings.VNIC_DIRECT_PHYSICAL, portbindings.VNIC_MACVTAP) + +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' diff --git a/neutron/common/ovn/extensions.py b/neutron/common/ovn/extensions.py index c613ab43325..3934e568231 100644 --- a/neutron/common/ovn/extensions.py +++ b/neutron/common/ovn/extensions.py @@ -11,6 +11,11 @@ # License for the specific language governing permissions and limitations # under the License. + +from neutron_lib.api.definitions import agent as agent_def +from neutron_lib.api.definitions import availability_zone as az_def +from neutron_lib.api.definitions import router_availability_zone as raz_def + # NOTE(russellb) This remains in its own file (vs constants.py) because we want # to be able to easily import it and export the info without any dependencies # on external imports. @@ -26,13 +31,15 @@ ML2_SUPPORTED_API_EXTENSIONS_OVN_L3 = [ 'sorting', 'project-id', 'dns-integration', + agent_def.ALIAS, + az_def.ALIAS, + raz_def.ALIAS, ] ML2_SUPPORTED_API_EXTENSIONS = [ 'address-scope', 'agent', 'allowed-address-pairs', 'auto-allocated-topology', - 'availability_zone', 'binding', 'default-subnetpools', 'external-net', diff --git a/neutron/common/ovn/utils.py b/neutron/common/ovn/utils.py index 053abb54310..a73a7c117dc 100644 --- a/neutron/common/ovn/utils.py +++ b/neutron/common/ovn/utils.py @@ -16,6 +16,7 @@ import os import re import netaddr +from neutron_lib.api.definitions import availability_zone as az_def from neutron_lib.api.definitions import external_net from neutron_lib.api.definitions import extra_dhcp_opt as edo_ext from neutron_lib.api.definitions import l3 @@ -27,6 +28,7 @@ from neutron_lib import context as n_context from neutron_lib import exceptions as n_exc from neutron_lib.plugins import directory from neutron_lib.utils import net as n_utils +from oslo_config import cfg from oslo_log import log from oslo_utils import netutils from oslo_utils import strutils @@ -40,6 +42,7 @@ from neutron.common.ovn import exceptions as ovn_exc LOG = log.getLogger(__name__) +CONF = cfg.CONF DNS_RESOLVER_FILE = "/etc/resolv.conf" @@ -489,11 +492,15 @@ def compute_address_pairs_diff(ovn_port, neutron_port): return AddrPairsDiff(added, removed, changed=any(added or removed)) +def get_ovn_cms_options(chassis): + """Return the list of CMS options in a Chassis.""" + return [opt.strip() for opt in chassis.external_ids.get( + constants.OVN_CMS_OPTIONS, '').split(',')] + + def is_gateway_chassis(chassis): """Check if the given chassis is a gateway chassis""" - external_ids = getattr(chassis, 'external_ids', {}) - return ('enable-chassis-as-gw' in external_ids.get( - 'ovn-cms-options', '').split(',')) + return constants.CMS_OPT_CHASSIS_AS_GW in get_ovn_cms_options(chassis) def get_port_capabilities(port): @@ -513,3 +520,24 @@ def get_port_id_from_gwc_row(row): :returns: String containing router port_id. """ return constants.RE_PORT_FROM_GWC.search(row.name).group(2) + + +def get_az_hints(resource): + """Return the availability zone hints from a given resource.""" + return (resource.get(az_def.AZ_HINTS) or CONF.default_availability_zones) + + +def get_chassis_availability_zones(chassis): + """Return a list of availability zones from a given OVN Chassis.""" + azs = [] + if not chassis: + return azs + + opt_key = constants.CMS_OPT_AVAILABILITY_ZONES + '=' + for opt in get_ovn_cms_options(chassis): + if not opt.startswith(opt_key): + continue + values = opt.split('=')[1] + azs = [az.strip() for az in values.split(':') if az.strip()] + break + return azs 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 753677a954b..f8391945ba9 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/mech_driver.py @@ -270,6 +270,10 @@ class OVNMechanismDriver(api.MechanismDriver): self.patch_plugin_choose("update_agent", update_agent) self.patch_plugin_choose("delete_agent", delete_agent) + # Override availability zone methods + self.patch_plugin_merge("get_availability_zones", + get_availability_zones) + # Now IDL connections can be safely used. self._post_fork_event.set() @@ -1091,7 +1095,8 @@ class OVNMechanismDriver(api.MechanismDriver): 'binary': binary, 'host': chassis.hostname, 'heartbeat_timestamp': timeutils.utcnow(), - 'availability_zone': 'n/a', + 'availability_zone': ', '.join( + ovn_utils.get_chassis_availability_zones(chassis)), 'topic': 'n/a', 'description': description, 'configurations': { @@ -1180,6 +1185,27 @@ class OVNMechanismDriver(api.MechanismDriver): txn.add(self._nb_ovn.check_liveness()) return True + def list_availability_zones(self, context, filters=None): + """List all availability zones from gateway chassis.""" + azs = {} + # TODO(lucasagomes): In the future, once the agents API in OVN + # gets more stable we should consider getting the information from + # the availability zones from the agents API itself. That would + # allow us to do things like: Do not schedule router ports on + # chassis that are offline (via the "alive" attribute for agents). + for ch in self._sb_ovn.chassis_list().execute(check_error=True): + # Only take in consideration gateway chassis because that's where + # the router ports are scheduled on + if not ovn_utils.is_gateway_chassis(ch): + continue + + azones = ovn_utils.get_chassis_availability_zones(ch) + for azone in azones: + azs[azone] = {'name': azone, 'resource': 'router', + 'state': 'available', + 'tenant_id': context.project_id} + return azs + def populate_agents(driver): for ch in driver._sb_ovn.tables['Chassis'].rows.values(): @@ -1266,3 +1292,9 @@ def create_default_drop_port_group(nb_idl): if ports_with_pg: # Add the ports to the default Port Group txn.add(nb_idl.pg_add_ports(pg_name, list(ports_with_pg))) + + +def get_availability_zones(cls, context, _driver, filters=None, fields=None, + sorts=None, limit=None, marker=None, + page_reverse=False): + return list(_driver.list_availability_zones(context, filters).values()) 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 dc37810f71e..875b3d305d7 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 @@ -1024,12 +1024,11 @@ class OVNClient(object): def _add_router_ext_gw(self, router, networks, txn): context = n_context.get_admin_context() - router_id = router['id'] # 1. Add the external gateway router port. gateways = self._get_gw_info(context, router) gw_port_id = router['gw_port_id'] port = self._plugin.get_port(context, gw_port_id) - self._create_lrouter_port(context, router_id, port, txn=txn) + self._create_lrouter_port(context, router, port, txn=txn) def _build_extids(gw_info): # TODO(lucasagomes): Remove this check after OVS 2.8.2 is tagged @@ -1044,7 +1043,7 @@ class OVNClient(object): return columns # 2. Add default route with nexthop as gateway ip - lrouter_name = utils.ovn_name(router_id) + lrouter_name = utils.ovn_name(router['id']) for gw_info in gateways: columns = _build_extids(gw_info) txn.add(self._nb_idl.add_static_route( @@ -1138,7 +1137,9 @@ class OVNClient(object): ovn_const.OVN_GW_PORT_EXT_ID_KEY: router.get('gw_port_id') or '', ovn_const.OVN_REV_NUM_EXT_ID_KEY: str(utils.get_revision_number( - router, ovn_const.TYPE_ROUTERS))} + router, ovn_const.TYPE_ROUTERS)), + ovn_const.OVN_ROUTER_AZ_HINTS_EXT_ID_KEY: + ','.join(utils.get_az_hints(router))} def create_router(self, context, router, add_external_gateway=True): """Create a logical router.""" @@ -1259,13 +1260,16 @@ class OVNClient(object): db_rev.delete_revision(context, router_id, ovn_const.TYPE_ROUTERS) def get_candidates_for_scheduling(self, physnet, cms=None, - chassis_physnets=None): + chassis_physnets=None, + availability_zone_hints=None): """Return chassis for scheduling gateway router. Criteria for selecting chassis as candidates 1) chassis from cms with proper bridge mappings 2) if no chassis is available from 1) then, select chassis with proper bridge mappings + 3) Filter the available chassis accordingly to the routers + availability zone hints (if present) """ # TODO(lucasagomes): Simplify the logic here, the CMS option has # been introduced long ago and by now all gateway chassis should @@ -1283,6 +1287,16 @@ class OVNClient(object): else: bmaps.append(chassis) candidates = cms_bmaps or bmaps + + # Filter for availability zones + if availability_zone_hints: + LOG.debug('Filtering Chassis candidates by availability zone ' + 'hints: %s', ', '.join(availability_zone_hints)) + candidates = [ch for ch in candidates + for az in availability_zone_hints + if az in utils.get_chassis_availability_zones( + self._sb_idl.lookup('Chassis', ch, None))] + if not cms_bmaps: LOG.debug("No eligible chassis with external connectivity" " through ovn-cms-options for %s", physnet) @@ -1331,9 +1345,9 @@ class OVNClient(object): return options - def _create_lrouter_port(self, context, router_id, port, txn=None): + def _create_lrouter_port(self, context, router, port, txn=None): """Create a logical router port.""" - lrouter = utils.ovn_name(router_id) + lrouter = utils.ovn_name(router['id']) networks, ipv6_ra_configs = ( self._get_nets_and_ipv6_ra_confs_for_router_port( context, port['fixed_ips'])) @@ -1347,7 +1361,8 @@ class OVNClient(object): port_net = self._plugin.get_network(n_context.get_admin_context(), port['network_id']) physnet = self._get_physnet(port_net) - candidates = self.get_candidates_for_scheduling(physnet) + candidates = self.get_candidates_for_scheduling( + physnet, availability_zone_hints=utils.get_az_hints(router)) selected_chassis = self._ovn_scheduler.select( self._nb_idl, self._sb_idl, lrouter_port_name, candidates=candidates) @@ -1374,6 +1389,7 @@ class OVNClient(object): def create_router_port(self, context, router_id, router_interface): port = self._plugin.get_port(context, router_interface['port_id']) + router = self._l3_plugin.get_router(context, router_id) with self._nb_idl.transaction(check_error=True) as txn: multi_prefix = False if (len(router_interface.get('subnet_ids', [])) == 1 and @@ -1385,9 +1401,8 @@ class OVNClient(object): self._update_lrouter_port(context, port, txn=txn) multi_prefix = True else: - self._create_lrouter_port(context, router_id, port, txn=txn) + self._create_lrouter_port(context, router, port, txn=txn) - router = self._l3_plugin.get_router(context, router_id) if router.get(l3.EXTERNAL_GW_INFO): cidr = None for fixed_ip in port['fixed_ips']: diff --git a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_db_sync.py b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_db_sync.py index 99f02c134b7..d7f840c2b2d 100644 --- a/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_db_sync.py +++ b/neutron/plugins/ml2/drivers/ovn/mech_driver/ovsdb/ovn_db_sync.py @@ -434,11 +434,13 @@ class OvnNbSynchronizer(OvnDbSynchronizer): update_snats_list.append({'id': lrouter['name'], 'add': add_snats, 'del': del_snats}) - del db_routers[lrouter['name']] else: del_lrouters_list.append(lrouter) + lrouters_names = {lr['name'] for lr in lrouters} for r_id, router in db_routers.items(): + if r_id in lrouters_names: + continue LOG.warning("Router found in Neutron but not in " "OVN DB, router id=%s", router['id']) if self.mode == SYNC_MODE_REPAIR: @@ -477,8 +479,9 @@ class OvnNbSynchronizer(OvnDbSynchronizer): try: LOG.warning("Creating the router port %s in OVN NB DB", rrport['id']) + router = db_routers[rrport['device_id']] self._ovn_client._create_lrouter_port( - ctx, rrport['device_id'], rrport) + ctx, router, rrport) except RuntimeError: LOG.warning("Create router port in OVN " "NB failed for router port %s", rrport['id']) diff --git a/neutron/services/ovn_l3/plugin.py b/neutron/services/ovn_l3/plugin.py index dfa39a0b62b..c10ef0e7768 100644 --- a/neutron/services/ovn_l3/plugin.py +++ b/neutron/services/ovn_l3/plugin.py @@ -26,6 +26,7 @@ from neutron_lib.callbacks import resources from neutron_lib import constants as n_const from neutron_lib import context as n_context from neutron_lib import exceptions as n_exc +from neutron_lib.exceptions import availability_zone as az_exc from neutron_lib.plugins import constants as plugin_constants from neutron_lib.plugins import directory from neutron_lib.services import base as service_base @@ -35,6 +36,7 @@ from oslo_utils import excutils from neutron.common.ovn import constants as ovn_const from neutron.common.ovn import extensions from neutron.common.ovn import utils +from neutron.db.availability_zone import router as router_az_db from neutron.db import l3_fip_port_details from neutron.db import ovn_revision_numbers_db as db_rev from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb import ovn_client @@ -49,7 +51,8 @@ class OVNL3RouterPlugin(service_base.ServicePluginBase, extraroute_db.ExtraRoute_dbonly_mixin, l3_gwmode_db.L3_NAT_db_mixin, dns_db.DNSDbMixin, - l3_fip_port_details.Fip_port_details_db_mixin): + l3_fip_port_details.Fip_port_details_db_mixin, + router_az_db.RouterAvailabilityZoneMixin): """Implementation of the OVN L3 Router Service Plugin. This class implements a L3 service plugin that provides @@ -311,6 +314,22 @@ class OVNL3RouterPlugin(service_base.ServicePluginBase, if port['status'] != status: self._plugin.update_port_status(context, port['id'], status) + def _get_availability_zones_from_router_port(self, lrp_name): + """Return the availability zones hints for the router port. + + Return a list of availability zones hints associated with the + router that the router port belongs to. + """ + context = n_context.get_admin_context() + if not self._plugin_driver.list_availability_zones(context): + return [] + + lrp = self._ovn.get_lrouter_port(lrp_name) + router = self.get_router( + context, lrp.external_ids[ovn_const.OVN_ROUTER_NAME_EXT_ID_KEY]) + az_hints = utils.get_az_hints(router) + return az_hints + def schedule_unhosted_gateways(self, event_from_chassis=None): # GW ports and its physnets. port_physnet_dict = self._get_gateway_port_physnet_mapping() @@ -350,9 +369,11 @@ class OVNL3RouterPlugin(service_base.ServicePluginBase, nb_idl=self._ovn, gw_chassis=all_gw_chassis, physnet=physnet, chassis_physnets=chassis_with_physnets, existing_chassis=existing_chassis) + az_hints = self._get_availability_zones_from_router_port(g_name) candidates = self._ovn_client.get_candidates_for_scheduling( physnet, cms=all_gw_chassis, - chassis_physnets=chassis_with_physnets) + chassis_physnets=chassis_with_physnets, + availability_zone_hints=az_hints) chassis = self.scheduler.select( self._ovn, self._sb_ovn, g_name, candidates=candidates, existing_chassis=existing_chassis) @@ -422,3 +443,25 @@ class OVNL3RouterPlugin(service_base.ServicePluginBase, # the OVN NB DB side l3plugin._ovn_client.update_router_port(kwargs['context'], current, if_exists=True) + + def get_router_availability_zones(self, router): + lr = self._ovn.get_lrouter(router['id']) + if not lr: + return [] + + return [az.strip() for az in lr.external_ids.get( + ovn_const.OVN_ROUTER_AZ_HINTS_EXT_ID_KEY, '').split(',') + if az.strip()] + + def validate_availability_zones(self, context, resource_type, + availability_zones): + """Verify that the availability zones exist.""" + if not availability_zones or resource_type != 'router': + return + + azs = {az['name'] for az in + self._plugin_driver.list_availability_zones(context).values()} + diff = set(availability_zones) - azs + if diff: + raise az_exc.AvailabilityZoneNotFound( + availability_zone=', '.join(diff)) diff --git a/neutron/tests/unit/common/ovn/test_utils.py b/neutron/tests/unit/common/ovn/test_utils.py index e2bcd6cd137..00c465b815f 100644 --- a/neutron/tests/unit/common/ovn/test_utils.py +++ b/neutron/tests/unit/common/ovn/test_utils.py @@ -60,6 +60,34 @@ class TestUtils(base.BaseTestCase): self.assertFalse(utils.is_gateway_chassis(non_gw_chassis_1)) self.assertFalse(utils.is_gateway_chassis(non_gw_chassis_2)) + def test_get_chassis_availability_zones_no_azs(self): + chassis = fakes.FakeOvsdbRow.create_one_ovsdb_row(attrs={ + 'external_ids': {'ovn-cms-options': 'enable-chassis-as-gw'}}) + self.assertEqual([], utils.get_chassis_availability_zones(chassis)) + + def test_get_chassis_availability_zones_one_az(self): + chassis = fakes.FakeOvsdbRow.create_one_ovsdb_row(attrs={ + 'external_ids': {'ovn-cms-options': + 'enable-chassis-as-gw,availability-zones=az0'}}) + self.assertEqual( + ['az0'], utils.get_chassis_availability_zones(chassis)) + + def test_get_chassis_availability_zones_multiple_az(self): + chassis = fakes.FakeOvsdbRow.create_one_ovsdb_row(attrs={ + 'external_ids': { + 'ovn-cms-options': + 'enable-chassis-as-gw,availability-zones=az0:az1 :az2:: :'}}) + self.assertEqual( + ['az0', 'az1', 'az2'], + utils.get_chassis_availability_zones(chassis)) + + def test_get_chassis_availability_zones_malformed(self): + chassis = fakes.FakeOvsdbRow.create_one_ovsdb_row(attrs={ + 'external_ids': {'ovn-cms-options': + 'enable-chassis-as-gw,availability-zones:az0'}}) + self.assertEqual( + [], utils.get_chassis_availability_zones(chassis)) + class TestGateWayChassisValidity(base.BaseTestCase): diff --git a/neutron/tests/unit/fake_resources.py b/neutron/tests/unit/fake_resources.py index faea453934c..2502d127a65 100644 --- a/neutron/tests/unit/fake_resources.py +++ b/neutron/tests/unit/fake_resources.py @@ -161,6 +161,8 @@ class FakeOvsdbSbOvnIdl(object): self.is_col_present = mock.Mock() self.is_col_present.return_value = False self.db_set = mock.Mock() + self.lookup = mock.MagicMock() + self.chassis_list = mock.MagicMock() class FakeOvsdbTransaction(object): @@ -751,3 +753,33 @@ class FakeOVNRouter(object): 'enabled': router.get('admin_state_up') or False, 'name': ovn_utils.ovn_name(router['id']), 'static_routes': routes}) + + +class FakeChassis(object): + + @staticmethod + def create(attrs=None, az_list=None, chassis_as_gw=False): + cms_opts = [] + if az_list: + cms_opts.append("%s=%s" % (ovn_const.CMS_OPT_AVAILABILITY_ZONES, + ':'.join(az_list))) + if chassis_as_gw: + cms_opts.append(ovn_const.CMS_OPT_CHASSIS_AS_GW) + + external_ids = {} + if cms_opts: + external_ids[ovn_const.OVN_CMS_OPTIONS] = ','.join(cms_opts) + + attrs = { + 'encaps': [], + 'external_ids': external_ids, + 'hostname': '', + 'name': uuidutils.generate_uuid(), + 'nb_cfg': 0, + 'other_config': {}, + 'transport_zones': [], + 'vtep_logical_switches': []} + + # Overwrite default attributes. + attrs.update(attrs) + return type('Chassis', (object, ), attrs) diff --git a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py index 6a573fc4103..d983b71d240 100644 --- a/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py +++ b/neutron/tests/unit/plugins/ml2/drivers/ovn/mech_driver/ovsdb/test_ovn_db_sync.py @@ -664,9 +664,9 @@ class TestOvnNbSyncML2(test_mech_driver.OVNMechanismDriverTestCase): ovn_nb_synchronizer._ovn_client.create_router.assert_has_calls( create_router_calls, any_order=True) - create_router_port_calls = [mock.call(mock.ANY, p['device_id'], - mock.ANY) - for p in create_router_port_list] + create_router_port_calls = [ + mock.call(mock.ANY, self.routers[i], mock.ANY) + for i, p in enumerate(create_router_port_list)] self.assertEqual( len(create_router_port_list), ovn_nb_synchronizer._ovn_client._create_lrouter_port.call_count) 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 2579e5a2437..c319db2f6e6 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 @@ -1692,6 +1692,43 @@ class TestOVNMechanismDriver(test_plugin.Ml2PluginV2TestCase): # Assert that ping_chassis returned False as it didn't update the db self.assertFalse(update_db) + def test_get_candidates_for_scheduling_availability_zones(self): + ovn_client = self.mech_driver._ovn_client + ch0 = fakes.FakeChassis.create(az_list=['az0', 'az1'], + chassis_as_gw=True) + ch1 = fakes.FakeChassis.create(az_list=['az3', 'az4'], + chassis_as_gw=True) + ch2 = fakes.FakeChassis.create(az_list=['az2'], chassis_as_gw=True) + ch3 = fakes.FakeChassis.create(az_list=[], chassis_as_gw=True) + ch4 = fakes.FakeChassis.create(az_list=['az0'], chassis_as_gw=True) + ch5 = fakes.FakeChassis.create(az_list=['az2'], chassis_as_gw=False) + + # Fake ovsdbapp lookup + def fake_lookup(table, chassis_name, default): + for ch in [ch0, ch1, ch2, ch3, ch4, ch5]: + if ch.name == chassis_name: + return ch + ovn_client._sb_idl.lookup = fake_lookup + + # The target physnet and availability zones + physnet = 'public' + az_hints = ['az0', 'az2'] + + cms = [ch0.name, ch1.name, ch2.name, ch3.name, ch4.name, ch5.name] + ch_physnet = {ch0.name: [physnet], ch1.name: [physnet], + ch2.name: [physnet], ch3.name: [physnet], + ch4.name: ['another-physnet'], + ch5.name: ['yet-another-physnet']} + + candidates = ovn_client.get_candidates_for_scheduling( + physnet, cms=cms, chassis_physnets=ch_physnet, + availability_zone_hints=az_hints) + + # Only chassis ch0 and ch2 should match the availability zones + # hints and physnet we passed to get_candidates_for_scheduling() + expected_candidates = [ch0.name, ch2.name] + self.assertEqual(sorted(expected_candidates), sorted(candidates)) + class OVNMechanismDriverTestCase(test_plugin.Ml2PluginV2TestCase): _mechanism_drivers = ['logger', 'ovn'] @@ -3140,3 +3177,38 @@ class TestOVNVVirtualPort(OVNMechanismDriverTestCase): self.mech_driver._ovn_client.delete_port(self.context, parent['id']) self.nb_idl.unset_lswitch_port_to_virtual_type.assert_called_once_with( virt_port['id'], parent['id'], if_exists=True) + + +class TestOVNAvailabilityZone(OVNMechanismDriverTestCase): + + def setUp(self): + super(TestOVNAvailabilityZone, self).setUp() + self.context = context.get_admin_context() + self.sb_idl = self.mech_driver._ovn_client._sb_idl + + def test_list_availability_zones(self): + ch0 = fakes.FakeChassis.create(az_list=['az0', 'az1'], + chassis_as_gw=True) + ch1 = fakes.FakeChassis.create(az_list=[], chassis_as_gw=False) + ch2 = fakes.FakeChassis.create(az_list=['az2'], chassis_as_gw=True) + ch3 = fakes.FakeChassis.create(az_list=[], chassis_as_gw=True) + self.sb_idl.chassis_list.return_value.execute.return_value = [ + ch0, ch1, ch2, ch3] + + azs = self.mech_driver.list_availability_zones(self.context) + expected_azs = {'az0': {'name': 'az0', 'resource': 'router', + 'state': 'available', 'tenant_id': mock.ANY}, + 'az1': {'name': 'az1', 'resource': 'router', + 'state': 'available', 'tenant_id': mock.ANY}, + 'az2': {'name': 'az2', 'resource': 'router', + 'state': 'available', 'tenant_id': mock.ANY}} + self.assertEqual(expected_azs, azs) + + def test_list_availability_zones_no_azs(self): + ch0 = fakes.FakeChassis.create(az_list=[], chassis_as_gw=True) + ch1 = fakes.FakeChassis.create(az_list=[], chassis_as_gw=True) + self.sb_idl.chassis_list.return_value.execute.return_value = [ + ch0, ch1] + + azs = self.mech_driver.list_availability_zones(mock.Mock()) + self.assertEqual({}, azs) diff --git a/neutron/tests/unit/services/ovn_l3/test_plugin.py b/neutron/tests/unit/services/ovn_l3/test_plugin.py index ac30228d337..14b23eb35d2 100644 --- a/neutron/tests/unit/services/ovn_l3/test_plugin.py +++ b/neutron/tests/unit/services/ovn_l3/test_plugin.py @@ -22,6 +22,7 @@ from neutron_lib.callbacks import events from neutron_lib.callbacks import resources from neutron_lib import constants from neutron_lib import exceptions as n_exc +from neutron_lib.exceptions import availability_zone as az_exc from neutron_lib.plugins import constants as plugin_constants from neutron_lib.plugins import directory from oslo_config import cfg @@ -43,6 +44,7 @@ from neutron.tests.unit.plugins.ml2 import test_plugin as test_mech_driver # Ml2PluginV2TestCase. class TestOVNL3RouterPlugin(test_mech_driver.Ml2PluginV2TestCase): + _mechanism_drivers = ['ovn'] l3_plugin = 'neutron.services.ovn_l3.plugin.OVNL3RouterPlugin' def _start_mock(self, path, return_value, new_callable=None): @@ -384,7 +386,8 @@ class TestOVNL3RouterPlugin(test_mech_driver.Ml2PluginV2TestCase): 'neutron-router-id', enabled=True, external_ids={ ovn_const.OVN_GW_PORT_EXT_ID_KEY: '', ovn_const.OVN_REV_NUM_EXT_ID_KEY: '1', - ovn_const.OVN_ROUTER_NAME_EXT_ID_KEY: 'router'}) + ovn_const.OVN_ROUTER_NAME_EXT_ID_KEY: 'router', + ovn_const.OVN_ROUTER_AZ_HINTS_EXT_ID_KEY: ''}) @mock.patch('neutron.db.extraroute_db.ExtraRoute_dbonly_mixin.' 'update_router') @@ -402,7 +405,8 @@ class TestOVNL3RouterPlugin(test_mech_driver.Ml2PluginV2TestCase): 'neutron-router-id', enabled=False, external_ids={ovn_const.OVN_ROUTER_NAME_EXT_ID_KEY: 'test', ovn_const.OVN_REV_NUM_EXT_ID_KEY: '1', - ovn_const.OVN_GW_PORT_EXT_ID_KEY: ''}) + ovn_const.OVN_GW_PORT_EXT_ID_KEY: '', + ovn_const.OVN_ROUTER_AZ_HINTS_EXT_ID_KEY: ''}) @mock.patch.object(utils, 'get_lrouter_non_gw_routes') @mock.patch('neutron.db.l3_db.L3_NAT_dbonly_mixin.update_router') @@ -495,7 +499,8 @@ class TestOVNL3RouterPlugin(test_mech_driver.Ml2PluginV2TestCase): external_ids = {ovn_const.OVN_ROUTER_NAME_EXT_ID_KEY: 'router', ovn_const.OVN_REV_NUM_EXT_ID_KEY: '1', - ovn_const.OVN_GW_PORT_EXT_ID_KEY: 'gw-port-id'} + ovn_const.OVN_GW_PORT_EXT_ID_KEY: 'gw-port-id', + ovn_const.OVN_ROUTER_AZ_HINTS_EXT_ID_KEY: ''} self.l3_inst._ovn.create_lrouter.assert_called_once_with( 'neutron-router-id', external_ids=external_ids, enabled=True, options={}) @@ -1351,6 +1356,8 @@ class TestOVNL3RouterPlugin(test_mech_driver.Ml2PluginV2TestCase): self.l3_inst.schedule_unhosted_gateways() self.nb_idl().update_lrouter_port.assert_not_called() + @mock.patch('neutron.plugins.ml2.drivers.ovn.mech_driver.mech_driver.' + 'OVNMechanismDriver.list_availability_zones', lambda *_: []) @mock.patch('neutron.services.ovn_l3.plugin.OVNL3RouterPlugin.' '_get_gateway_port_physnet_mapping') def test_schedule_unhosted_gateways(self, get_gppm): @@ -1389,7 +1396,7 @@ class TestOVNL3RouterPlugin(test_mech_driver.Ml2PluginV2TestCase): self.mock_candidates.assert_has_calls([ mock.call(mock.ANY, chassis_physnets=chassis_mappings, - cms=chassis)] * 3) + cms=chassis, availability_zone_hints=[])] * 3) self.mock_schedule.assert_has_calls([ mock.call(self.nb_idl(), self.sb_idl(), 'lrp-foo-1', [], ['chassis1', 'chassis2']), @@ -1473,6 +1480,55 @@ class TestOVNL3RouterPlugin(test_mech_driver.Ml2PluginV2TestCase): mock.ANY, self.fake_router_port, ovn_const.TYPE_ROUTER_PORTS) + def _test_get_router_availability_zones(self, azs, expected): + lr = fake_resources.FakeOvsdbRow.create_one_ovsdb_row( + attrs={'id': 'fake-router', 'external_ids': { + ovn_const.OVN_ROUTER_AZ_HINTS_EXT_ID_KEY: azs}}) + self.l3_inst._ovn.get_lrouter.return_value = lr + azs_list = self.l3_inst.get_router_availability_zones(lr) + self.assertEqual(sorted(expected), sorted(azs_list)) + + def test_get_router_availability_zones_one(self): + self._test_get_router_availability_zones('az0', ['az0']) + + def test_get_router_availability_zones_multiple(self): + self._test_get_router_availability_zones( + 'az0,az1,az2', ['az0', 'az1', 'az2']) + + def test_get_router_availability_zones_none(self): + self._test_get_router_availability_zones('', []) + + @mock.patch('neutron.plugins.ml2.drivers.ovn.mech_driver.mech_driver.' + 'OVNMechanismDriver.list_availability_zones') + def test_validate_availability_zones(self, mock_list_azs): + mock_list_azs.return_value = {'az0': {'name': 'az0'}, + 'az1': {'name': 'az1'}, + 'az2': {'name': 'az2'}} + self.assertIsNone( + self.l3_inst.validate_availability_zones( + self.context, 'router', ['az0', 'az2'])) + + @mock.patch('neutron.plugins.ml2.drivers.ovn.mech_driver.mech_driver.' + 'OVNMechanismDriver.list_availability_zones') + def test_validate_availability_zones_fail_non_exist(self, mock_list_azs): + mock_list_azs.return_value = {'az0': {'name': 'az0'}, + 'az1': {'name': 'az1'}, + 'az2': {'name': 'az2'}} + # Fails validation if the az does not exist + self.assertRaises( + az_exc.AvailabilityZoneNotFound, + self.l3_inst.validate_availability_zones, self.context, 'router', + ['az0', 'non-existent']) + + @mock.patch('neutron.plugins.ml2.drivers.ovn.mech_driver.mech_driver.' + 'OVNMechanismDriver.list_availability_zones') + def test_validate_availability_zones_no_azs(self, mock_list_azs): + # When no AZs are requested validation should just succeed + self.assertIsNone( + self.l3_inst.validate_availability_zones( + self.context, 'router', [])) + mock_list_azs.assert_not_called() + class OVNL3ExtrarouteTests(test_l3_gw.ExtGwModeIntTestCase, test_l3.L3NatDBIntTestCase, diff --git a/releasenotes/notes/ovn-router-availability-zones-03a802ee19689474.yaml b/releasenotes/notes/ovn-router-availability-zones-03a802ee19689474.yaml new file mode 100644 index 00000000000..2a66ac3a99c --- /dev/null +++ b/releasenotes/notes/ovn-router-availability-zones-03a802ee19689474.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Added support for router availability zones in OVN. The OVN driver + can now read from the router's availability_zone_hints field and + schedule router ports accordingly with the given availability zones.